@homie-rocks/studio 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -11
- package/bin/homie-studio.mjs +132 -27
- package/lib/build.mjs +100 -23
- package/lib/cloudflare.mjs +146 -28
- package/lib/media.mjs +126 -0
- package/lib/port-check.mjs +651 -0
- package/lib/port.mjs +277 -0
- package/lib/scaffold.mjs +47 -18
- package/lib/studio.mjs +1 -1
- package/media/MEDIA.md +81 -0
- package/netplay/netplay.ts +93 -8
- package/package.json +12 -2
- package/port/audio.ts +96 -0
- package/port/bots.ts +97 -0
- package/port/camera.ts +75 -0
- package/port/controls.ts +48 -0
- package/port/early.ts +14 -0
- package/port/global.ts +9 -0
- package/port/hud.ts +96 -0
- package/port/index.ts +29 -0
- package/port/keys.ts +102 -0
- package/port/probe.ts +126 -0
- package/port/room.ts +356 -0
- package/port/sandbox.ts +112 -0
- package/port/touch.ts +378 -0
- package/starters/gem-rush/src/main.ts +9 -0
- package/worker/index.mjs +113 -11
- package/worker/pages.mjs +155 -13
- package/worker/qr.mjs +501 -0
package/netplay/netplay.ts
CHANGED
|
@@ -254,6 +254,17 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
|
254
254
|
readonly spectator: boolean;
|
|
255
255
|
readonly offline: boolean;
|
|
256
256
|
readonly connected: boolean;
|
|
257
|
+
/** Why the client stopped for good (a refusal reconnecting would repeat: 'replaced', 'room-full', ...), else null. */
|
|
258
|
+
readonly closedWhy: string | null;
|
|
259
|
+
/** How long the socket has been down while it reconnects (ms; 0 while connected). */
|
|
260
|
+
readonly downMs: number;
|
|
261
|
+
/**
|
|
262
|
+
* Host: hand the room to another browser — a checkpoint, then the protocol's `yield` (the same road a hidden
|
|
263
|
+
* tab takes). The relay elects the best other candidate, or keeps me host when nobody else can host.
|
|
264
|
+
* Use it when a big screen or a struggling phone should give the room to a seated computer (never in a
|
|
265
|
+
* host's first 30 s, at most once a minute); false when not sent.
|
|
266
|
+
*/
|
|
267
|
+
handOff(): boolean;
|
|
257
268
|
/** Seated replica: true while my browser moves my body; false while the host does (host movement, or taken). */
|
|
258
269
|
readonly owned: boolean;
|
|
259
270
|
readonly id: string | null;
|
|
@@ -476,8 +487,26 @@ export class Roster {
|
|
|
476
487
|
/* --------------------------------------------------------------- the client */
|
|
477
488
|
|
|
478
489
|
const LADDER = [250, 500, 1000, 2000, 4000];
|
|
479
|
-
/**
|
|
480
|
-
|
|
490
|
+
/**
|
|
491
|
+
* Relay errors after which reconnecting would only repeat the refusal. A 'flood' kick is NOT one: a replica's
|
|
492
|
+
* inputs bunch up behind a stall it did not cause (a host migration, a relay or network hiccup that delivers a
|
|
493
|
+
* second of frames at once), and a kick that ended the page's play for good left a phone frozen with no word.
|
|
494
|
+
* After one it comes back (its seat token keeps its body), sending at the slow rate below.
|
|
495
|
+
*/
|
|
496
|
+
const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many']);
|
|
497
|
+
/**
|
|
498
|
+
* Input pacing (NETPLAY.md §5): at most this many `in` frames in any rolling second from this browser, whatever
|
|
499
|
+
* the frame rate or the press rate: two thirds of the relay's cap of 60, so frames that reach it bunched (a
|
|
500
|
+
* stall anywhere between) still fit under the cap.
|
|
501
|
+
*/
|
|
502
|
+
const IN_CAP_PER_S = 32;
|
|
503
|
+
/** Longest a page with a live socket listens for the relay's welcome before it plays alone (ms of time it could listen). */
|
|
504
|
+
const CONNECT_OPEN_MAX_MS = 10_000;
|
|
505
|
+
/** After the relay reports dropped inputs (or kicked this browser for them): the cap for the next while. */
|
|
506
|
+
const IN_SLOW_CAP_PER_S = 15;
|
|
507
|
+
const IN_SLOW_MS = 8000;
|
|
508
|
+
/** A socket with this much still unsent does not queue another input frame (the newest one waits instead). */
|
|
509
|
+
const IN_BACKLOG_BYTES = 2048;
|
|
481
510
|
|
|
482
511
|
function heldList(held: Iterable<string> | Record<string, boolean> | undefined): string[] {
|
|
483
512
|
if (!held) return [];
|
|
@@ -606,6 +635,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
606
635
|
let prevHeld = new Set<string>();
|
|
607
636
|
let pendingPresses: Record<string, number> = {};
|
|
608
637
|
let inputTimer: ReturnType<typeof setTimeout> | null = null;
|
|
638
|
+
/** Wall times of the input frames sent in the last second, and until when the slow cap holds. */
|
|
639
|
+
const inSent: number[] = [];
|
|
640
|
+
let inSlowUntil = 0;
|
|
641
|
+
/** Why this client stopped for good (a FINAL_ERRORS refusal), or null while it plays or reconnects. */
|
|
642
|
+
let closedWhy: string | null = null;
|
|
643
|
+
/** Wall time the socket went down (0 while connected): how long a reconnect has taken. */
|
|
644
|
+
let downSince = 0;
|
|
609
645
|
const sentHist: { q: number; a: A; h: string[]; at: number; r: number }[] = [];
|
|
610
646
|
const inputs = new Map<number, InputFrame<A>>();
|
|
611
647
|
const presses = new Map<number, Record<string, number>>();
|
|
@@ -798,12 +834,15 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
798
834
|
}
|
|
799
835
|
case 'error': {
|
|
800
836
|
const code = String(m['code'] ?? '');
|
|
837
|
+
// Inputs over the relay's cap were dropped: send fewer for a while (the newest frame still goes out).
|
|
838
|
+
if ((code === 'rate' && m['of'] === 'in') || code === 'flood') inSlowUntil = wall() + IN_SLOW_MS;
|
|
801
839
|
if (code === 'rate' || code === 'state-full') { warnOnce(`${code}:${String(m['of'] ?? '')}`, m['message']); return; }
|
|
802
840
|
console.warn('[netplay] relay refused:', code, m['message']);
|
|
803
841
|
// The same seat opened in another tab, a full room, a version the relay does not speak: reconnecting
|
|
804
842
|
// would repeat the refusal (or, for 'replaced', make the two tabs evict each other for ever).
|
|
805
843
|
if (FINAL_ERRORS.has(code)) {
|
|
806
844
|
closed = true;
|
|
845
|
+
closedWhy = code;
|
|
807
846
|
post?.({ what: 'closed', why: code });
|
|
808
847
|
}
|
|
809
848
|
return;
|
|
@@ -846,6 +885,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
846
885
|
if (next === 'host' && !snap && !continuing) snap = localSnap();
|
|
847
886
|
offline = false;
|
|
848
887
|
connected = true;
|
|
888
|
+
downSince = 0;
|
|
849
889
|
attempt = 0;
|
|
850
890
|
emit('status', true);
|
|
851
891
|
post?.({ what: 'token', token, seat, room: cfg?.room ?? null });
|
|
@@ -897,10 +937,14 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
897
937
|
if (ages.length > 40) ages.shift();
|
|
898
938
|
const sorted = [...ages].sort((x, y) => x - y);
|
|
899
939
|
ageP90 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.9))] as number;
|
|
940
|
+
// The 95th percentile and 1.4 intervals, not the 90th and 1.2. A phone host's frame
|
|
941
|
+
// quantisation (snapshots at 50/67 ms) plus 30-50 ms relay jitter held the picture on 5-8 % of a desktop
|
|
942
|
+
// replica's frames in the contract e2e (interpolation-not-starved); this buys ~20 ms of delay for it.
|
|
943
|
+
const ageP95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))] as number;
|
|
900
944
|
// Render far enough back that the next snapshot is normally here before we need it: its age, plus one
|
|
901
945
|
// interval. Rise up to 6 ms a snapshot (a hold is visible); fall 6% of the excess a snapshot, at least 3 ms
|
|
902
946
|
// (boot-time jank clears in about a second, as a brief time-warp nobody sees).
|
|
903
|
-
const target = Math.max(50, Math.min(400,
|
|
947
|
+
const target = Math.max(50, Math.min(400, ageP95 + iv * 1.4 + 6));
|
|
904
948
|
if (!delaySmooth || ages.length <= 3) delaySmooth = target;
|
|
905
949
|
else if (target > delaySmooth) delaySmooth += Math.min(6, target - delaySmooth);
|
|
906
950
|
else delaySmooth -= Math.min(delaySmooth - target, Math.max(3, (delaySmooth - target) * 0.06));
|
|
@@ -1010,9 +1054,24 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1010
1054
|
raw({ t: 'ckpt', k: c.k, st: c.st, d: c.d, c: c.c });
|
|
1011
1055
|
}
|
|
1012
1056
|
|
|
1057
|
+
/** How long until one more input frame fits the rolling-second cap (0: now). */
|
|
1058
|
+
function inputBudgetWait(): number {
|
|
1059
|
+
const t = wall();
|
|
1060
|
+
while (inSent.length && (inSent[0] as number) <= t - 1000) inSent.shift();
|
|
1061
|
+
const cap = t < inSlowUntil ? IN_SLOW_CAP_PER_S : IN_CAP_PER_S;
|
|
1062
|
+
if (inSent.length < cap) return 0;
|
|
1063
|
+
return Math.max(1, (inSent[inSent.length - cap] as number) + 1000 - t);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1013
1066
|
function flushInput(): void {
|
|
1014
1067
|
inputTimer = null;
|
|
1015
1068
|
if (role === 'host' || seat === null || offline || !connected || pendingA === undefined) return;
|
|
1069
|
+
// Never a burst: over the cap, or with the last frames still unsent on the socket, the newest frame waits
|
|
1070
|
+
// (it replaces whatever was pending, so nothing stale is ever flushed after a stall).
|
|
1071
|
+
const budget = inputBudgetWait();
|
|
1072
|
+
const backlog = (ws?.bufferedAmount ?? 0) > IN_BACKLOG_BYTES;
|
|
1073
|
+
if (budget > 0 || backlog) { inputTimer = setTimeout(flushInput, backlog ? Math.max(budget, 30) : budget); return; }
|
|
1074
|
+
inSent.push(wall());
|
|
1016
1075
|
inSeq += 1;
|
|
1017
1076
|
const msg: Record<string, unknown> = { t: 'in', q: inSeq, a: pendingA, h: pendingHeld, r: mine.rs };
|
|
1018
1077
|
if (Object.keys(pendingPresses).length) msg['p'] = pendingPresses;
|
|
@@ -1026,7 +1085,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1026
1085
|
|
|
1027
1086
|
function scheduleInput(urgent: boolean): void {
|
|
1028
1087
|
const since = wall() - lastInputSentAt;
|
|
1029
|
-
const wait = urgent ? Math.max(0, 16 - since) : Math.max(0, inputMs - since);
|
|
1088
|
+
const wait = Math.max(inputBudgetWait(), urgent ? Math.max(0, 16 - since) : Math.max(0, inputMs - since));
|
|
1030
1089
|
if (wait <= 0) { if (inputTimer) { clearTimeout(inputTimer); inputTimer = null; } flushInput(); return; }
|
|
1031
1090
|
if (inputTimer) return;
|
|
1032
1091
|
inputTimer = setTimeout(flushInput, wait);
|
|
@@ -1071,6 +1130,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1071
1130
|
if (why !== 'closed') { try { sock.close(4000, why); } catch { /* gone */ } }
|
|
1072
1131
|
const was = connected;
|
|
1073
1132
|
connected = false;
|
|
1133
|
+
if (was) downSince = wall();
|
|
1074
1134
|
if (was) emit('status', false);
|
|
1075
1135
|
notifyStats(true);
|
|
1076
1136
|
retry();
|
|
@@ -1106,8 +1166,24 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1106
1166
|
} else {
|
|
1107
1167
|
post?.({ what: 'attached', v: NETPLAY_VERSION });
|
|
1108
1168
|
open();
|
|
1109
|
-
// A relay that never answers must not leave a game on a black screen.
|
|
1110
|
-
|
|
1169
|
+
// A relay that never answers must not leave a game on a black screen. But the wait is counted only while
|
|
1170
|
+
// this page is able to listen: a phone compiling shaders under load is blocked for seconds at a time, and
|
|
1171
|
+
// neither its socket's open nor the relay's welcome can be handled meanwhile. Giving up on the wall clock
|
|
1172
|
+
// started a private fight 1 at every loaded boot and swapped it for the room's a moment later. A socket
|
|
1173
|
+
// that is connecting or open gets CONNECT_OPEN_MAX_MS of that listening time; one that failed outright
|
|
1174
|
+
// (ws is null between retries) gets connectTimeoutMs.
|
|
1175
|
+
let listenedMs = 0;
|
|
1176
|
+
let lastCheckAt = wall();
|
|
1177
|
+
const connectCheck = (): void => {
|
|
1178
|
+
if (roleKnown) return;
|
|
1179
|
+
const t = wall();
|
|
1180
|
+
listenedMs += Math.min(t - lastCheckAt, 600);
|
|
1181
|
+
lastCheckAt = t;
|
|
1182
|
+
const live = Boolean(ws && (ws.readyState === 0 || ws.readyState === 1));
|
|
1183
|
+
if (listenedMs < (live ? CONNECT_OPEN_MAX_MS : connectTimeoutMs)) { connectTimer = setTimeout(connectCheck, 250); return; }
|
|
1184
|
+
goOfflineHost('relay-timeout');
|
|
1185
|
+
};
|
|
1186
|
+
connectTimer = setTimeout(connectCheck, 250);
|
|
1111
1187
|
pingTimer = setInterval(ping, 2000);
|
|
1112
1188
|
ckptTimer = setInterval(sendCheckpoint, checkpointMs);
|
|
1113
1189
|
statsTimer = setInterval(() => notifyStats(true), 500);
|
|
@@ -1133,6 +1209,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1133
1209
|
get spectator() { return seat === null && !offline; },
|
|
1134
1210
|
get offline() { return offline; },
|
|
1135
1211
|
get connected() { return connected; },
|
|
1212
|
+
get closedWhy() { return closedWhy; },
|
|
1213
|
+
get downMs() { return connected || !downSince ? 0 : wall() - downSince; },
|
|
1136
1214
|
get owned() { return role === 'host' || offline || seat === null ? true : mine.own; },
|
|
1137
1215
|
get id() { return id; },
|
|
1138
1216
|
get name() { return name; },
|
|
@@ -1161,8 +1239,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1161
1239
|
const c = ctlWire();
|
|
1162
1240
|
const text = JSON.stringify(c.length ? { t: 'snap', k: tick, st, d, c } : { t: 'snap', k: tick, st, d });
|
|
1163
1241
|
if (!ws || ws.readyState !== 1) return false;
|
|
1164
|
-
// A congested socket drops a snapshot rather than queueing a stale one.
|
|
1165
|
-
|
|
1242
|
+
// A congested socket drops a snapshot rather than queueing a stale one. At 3 KB (about six
|
|
1243
|
+
// snapshots), not 256 KB. A starved network thread on a loaded phone held a second of snapshots and then
|
|
1244
|
+
// flushed them at once; the relay counted 30+ in a second, dropped them, and called the host stalled.
|
|
1245
|
+
if ((ws.bufferedAmount ?? 0) > 3 * 1024) return false;
|
|
1166
1246
|
try { ws.send(text); } catch { return false; }
|
|
1167
1247
|
countBytes(bytesOut, text.length);
|
|
1168
1248
|
lastSnapSentAt = wall();
|
|
@@ -1173,6 +1253,11 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1173
1253
|
return true;
|
|
1174
1254
|
},
|
|
1175
1255
|
checkpointNow: sendCheckpoint,
|
|
1256
|
+
handOff() {
|
|
1257
|
+
if (role !== 'host' || offline || !connected || peers.size < 2) return false;
|
|
1258
|
+
sendCheckpoint();
|
|
1259
|
+
return raw({ t: 'yield' });
|
|
1260
|
+
},
|
|
1176
1261
|
round(r) {
|
|
1177
1262
|
roundInfo = r;
|
|
1178
1263
|
post?.({ what: 'round', round: r });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), song and video pages from the music/ and videos/ manifests, optional R2 storage for large media, deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"homepage": "https://homie.rocks/studio/",
|
|
@@ -18,6 +18,14 @@
|
|
|
18
18
|
"types": "./netplay/netplay.ts",
|
|
19
19
|
"default": "./netplay/netplay.ts"
|
|
20
20
|
},
|
|
21
|
+
"./port": {
|
|
22
|
+
"types": "./port/index.ts",
|
|
23
|
+
"default": "./port/index.ts"
|
|
24
|
+
},
|
|
25
|
+
"./port/early": {
|
|
26
|
+
"types": "./port/early.ts",
|
|
27
|
+
"default": "./port/early.ts"
|
|
28
|
+
},
|
|
21
29
|
"./worker": "./worker/index.mjs",
|
|
22
30
|
"./package.json": "./package.json"
|
|
23
31
|
},
|
|
@@ -25,8 +33,10 @@
|
|
|
25
33
|
"bin/",
|
|
26
34
|
"lib/",
|
|
27
35
|
"netplay/",
|
|
36
|
+
"port/",
|
|
28
37
|
"worker/",
|
|
29
38
|
"starters/",
|
|
39
|
+
"media/",
|
|
30
40
|
"README.md",
|
|
31
41
|
"LICENSE",
|
|
32
42
|
"NOTICE"
|
package/port/audio.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Sound that starts on the first touch, with no "tap for sound" screen.
|
|
3
|
+
*
|
|
4
|
+
* Browsers start every AudioContext suspended and refuse media.play() until a
|
|
5
|
+
* person has touched the page. A single-player game usually handled that with
|
|
6
|
+
* a title screen; a multiplayer port drops the player straight into a live
|
|
7
|
+
* round, so the unlock has to ride the first real input instead:
|
|
8
|
+
*
|
|
9
|
+
* - every AudioContext the game makes is remembered (the constructor is
|
|
10
|
+
* wrapped, so install this BEFORE the game's code runs);
|
|
11
|
+
* - on pointerdown / pointerup / touchstart / touchend / keydown / click
|
|
12
|
+
* (capture phase, so a game that stops propagation still unlocks), every
|
|
13
|
+
* suspended context is resumed inside the gesture and plays one silent
|
|
14
|
+
* sample (iOS needs a sound started inside the gesture);
|
|
15
|
+
* - a media element whose play() was refused is played again on that gesture;
|
|
16
|
+
* - iOS's silent switch mutes Web Audio unless the audio session says
|
|
17
|
+
* "playback".
|
|
18
|
+
* A held stick only counts as a gesture when the finger lifts (browsers grant
|
|
19
|
+
* activation on touchend), so the first tap or the first release is when sound
|
|
20
|
+
* begins. Say so nowhere: it just works.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
type Ctx = AudioContext;
|
|
24
|
+
const contexts = new Set<Ctx>();
|
|
25
|
+
const refused = new Set<HTMLMediaElement>();
|
|
26
|
+
let installed = false;
|
|
27
|
+
let unlockedAt = 0;
|
|
28
|
+
let gestures = 0;
|
|
29
|
+
|
|
30
|
+
function silentTick(ctx: Ctx): void {
|
|
31
|
+
try {
|
|
32
|
+
const b = ctx.createBuffer(1, 1, 22050);
|
|
33
|
+
const s = ctx.createBufferSource();
|
|
34
|
+
s.buffer = b;
|
|
35
|
+
s.connect(ctx.destination);
|
|
36
|
+
s.start(0);
|
|
37
|
+
} catch { /* a closed context */ }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function onGesture(): void {
|
|
41
|
+
gestures += 1;
|
|
42
|
+
for (const ctx of contexts) {
|
|
43
|
+
if (ctx.state === 'closed') { contexts.delete(ctx); continue; }
|
|
44
|
+
if (ctx.state !== 'running') { void ctx.resume().catch(() => {}); silentTick(ctx); }
|
|
45
|
+
}
|
|
46
|
+
for (const el of [...refused]) {
|
|
47
|
+
refused.delete(el);
|
|
48
|
+
const p = realPlay?.call(el);
|
|
49
|
+
if (p && typeof p.catch === 'function') p.catch(() => { refused.add(el); });
|
|
50
|
+
}
|
|
51
|
+
if (!unlockedAt && [...contexts].some((c) => c.state === 'running')) unlockedAt = performance.now();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let realPlay: (() => Promise<void>) | null = null;
|
|
55
|
+
|
|
56
|
+
/** Wrap AudioContext and media play() so the first gesture unlocks everything. Idempotent. */
|
|
57
|
+
export function installAudioUnlock(): void {
|
|
58
|
+
if (installed || typeof window === 'undefined') return;
|
|
59
|
+
installed = true;
|
|
60
|
+
const w = window as unknown as Record<string, unknown>;
|
|
61
|
+
for (const name of ['AudioContext', 'webkitAudioContext']) {
|
|
62
|
+
const Real = w[name] as (new (...a: unknown[]) => Ctx) | undefined;
|
|
63
|
+
if (typeof Real !== 'function') continue;
|
|
64
|
+
// A subclass keeps instanceof, static members and every method; it only remembers the instance.
|
|
65
|
+
const Tracked = class extends (Real as new (...a: unknown[]) => Ctx) {
|
|
66
|
+
constructor(...args: unknown[]) { super(...args); contexts.add(this as unknown as Ctx); }
|
|
67
|
+
};
|
|
68
|
+
try { Object.defineProperty(window, name, { value: Tracked, configurable: true, writable: true }); } catch { /* keep the real one */ }
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const session = (navigator as unknown as { audioSession?: { type: string } }).audioSession;
|
|
72
|
+
if (session) session.type = 'playback';
|
|
73
|
+
} catch { /* not iOS */ }
|
|
74
|
+
if (typeof HTMLMediaElement !== 'undefined') {
|
|
75
|
+
realPlay = HTMLMediaElement.prototype.play;
|
|
76
|
+
const play = realPlay;
|
|
77
|
+
HTMLMediaElement.prototype.play = function patchedPlay(this: HTMLMediaElement): Promise<void> {
|
|
78
|
+
const p = play.call(this);
|
|
79
|
+
if (!p || typeof p.catch !== 'function') return p;
|
|
80
|
+
// Refused for want of a gesture: it plays on the first one. Any other failure is the game's to see.
|
|
81
|
+
return p.catch((e: unknown) => {
|
|
82
|
+
if (e && (e as { name?: string }).name === 'NotAllowedError') { refused.add(this); return undefined; }
|
|
83
|
+
throw e;
|
|
84
|
+
}) as Promise<void>;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
for (const ev of ['pointerdown', 'pointerup', 'touchstart', 'touchend', 'mousedown', 'keydown', 'click']) {
|
|
88
|
+
window.addEventListener(ev, onGesture, { capture: true, passive: true });
|
|
89
|
+
}
|
|
90
|
+
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') for (const c of contexts) if (c.state === 'suspended' && gestures) void c.resume().catch(() => {}); });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What the check harness reads: each context's state, and when the first one started running. */
|
|
94
|
+
export function audioReport(): { contexts: string[]; refusedMedia: number; gestures: number; unlockedAtMs: number | null } {
|
|
95
|
+
return { contexts: [...contexts].map((c) => c.state), refusedMedia: refused.size, gestures, unlockedAtMs: unlockedAt || null };
|
|
96
|
+
}
|
package/port/bots.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Bot scaffolds. A port's bots fill every empty seat from the first frame, so
|
|
3
|
+
* a lone stranger is never alone, and each arriving person takes a bot's body.
|
|
4
|
+
* What made bots good in real rooms:
|
|
5
|
+
*
|
|
6
|
+
* - They play the SAME rules through the same inputs a person has (a stick
|
|
7
|
+
* vector and button presses), never a shortcut the person cannot take.
|
|
8
|
+
* - They commit to a target for a while (seconds, not frames) and give up a
|
|
9
|
+
* target they cannot reach; a stuck detector (little progress in ~3 s)
|
|
10
|
+
* picks the most open direction instead of pushing a wall forever.
|
|
11
|
+
* - They are beatable: a reaction delay (~250 ms), a little aim error, and a
|
|
12
|
+
* skill that eases off when they lead the people.
|
|
13
|
+
* - They are kind to newcomers: no bot targets a person in their first
|
|
14
|
+
* ~15 s, and a person's first minute is never a pile-on.
|
|
15
|
+
* - A body a person left keeps playing as a bot, where it stands.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface V2 { x: number; y: number }
|
|
19
|
+
|
|
20
|
+
/** Unit vector from `from` toward `to` (a stick push), or zero when there. */
|
|
21
|
+
export function seek(from: V2, to: V2, arrive = 0): V2 {
|
|
22
|
+
const dx = to.x - from.x; const dy = to.y - from.y; const d = Math.hypot(dx, dy);
|
|
23
|
+
if (d <= Math.max(1e-6, arrive)) return { x: 0, y: 0 };
|
|
24
|
+
const m = arrive > 0 ? Math.min(1, d / (arrive * 3)) : 1;
|
|
25
|
+
return { x: (dx / d) * m, y: (dy / d) * m };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Away from a point (flee), unit length. */
|
|
29
|
+
export function flee(from: V2, danger: V2): V2 { const s = seek(danger, from); return s; }
|
|
30
|
+
|
|
31
|
+
/** A slowly wandering direction that does not jitter frame to frame. */
|
|
32
|
+
export function wanderer(seed = Math.random() * 1000): (dt: number) => V2 {
|
|
33
|
+
let a = seed;
|
|
34
|
+
return (dt) => { a += (Math.random() - 0.5) * 2.4 * dt; return { x: Math.cos(a), y: Math.sin(a) }; };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The nearest item by straight distance, skipping ones other bots already claimed. */
|
|
38
|
+
export function nearest<T extends V2>(from: V2, items: Iterable<T>, taken?: Set<T>): T | null {
|
|
39
|
+
let best: T | null = null; let bd = Infinity;
|
|
40
|
+
for (const it of items) {
|
|
41
|
+
if (taken?.has(it)) continue;
|
|
42
|
+
const d = Math.hypot(it.x - from.x, it.y - from.y);
|
|
43
|
+
if (d < bd) { bd = d; best = it; }
|
|
44
|
+
}
|
|
45
|
+
return best;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A bot's intent with a person's limits: it sees the world `reactionMs` late, commits to a target for `commitMs`,
|
|
50
|
+
* and notices when it is stuck. Call `think(now, pos, choose)` every frame; it returns the stick to push.
|
|
51
|
+
*/
|
|
52
|
+
export class BotBrain<T extends V2 = V2> {
|
|
53
|
+
target: T | null = null;
|
|
54
|
+
private chosenAt = 0;
|
|
55
|
+
private lastCheck: { at: number; x: number; y: number } | null = null;
|
|
56
|
+
private unstickUntil = 0;
|
|
57
|
+
private unstick: V2 = { x: 0, y: 0 };
|
|
58
|
+
private queue: { at: number; v: V2 }[] = [];
|
|
59
|
+
constructor(readonly opts: { reactionMs?: number; commitMs?: number; stuckMs?: number; stuckDist?: number; aimError?: number } = {}) {}
|
|
60
|
+
|
|
61
|
+
think(now: number, pos: V2, choose: () => T | null, arrive = 0): V2 {
|
|
62
|
+
const { reactionMs = 250, commitMs = 2500, stuckMs = 3000, stuckDist = 24, aimError = 0.12 } = this.opts;
|
|
63
|
+
if (!this.target || now - this.chosenAt > commitMs) { this.target = choose(); this.chosenAt = now; }
|
|
64
|
+
if (!this.lastCheck || now - this.lastCheck.at > stuckMs) {
|
|
65
|
+
if (this.lastCheck && Math.hypot(pos.x - this.lastCheck.x, pos.y - this.lastCheck.y) < stuckDist && this.target) {
|
|
66
|
+
const a = Math.random() * Math.PI * 2;
|
|
67
|
+
this.unstick = { x: Math.cos(a), y: Math.sin(a) };
|
|
68
|
+
this.unstickUntil = now + 700;
|
|
69
|
+
this.target = null;
|
|
70
|
+
}
|
|
71
|
+
this.lastCheck = { at: now, x: pos.x, y: pos.y };
|
|
72
|
+
}
|
|
73
|
+
let v: V2 = now < this.unstickUntil ? this.unstick : this.target ? seek(pos, this.target, arrive) : { x: 0, y: 0 };
|
|
74
|
+
if (aimError > 0 && (v.x || v.y)) {
|
|
75
|
+
const a = Math.atan2(v.y, v.x) + (Math.random() - 0.5) * aimError; const m = Math.hypot(v.x, v.y);
|
|
76
|
+
v = { x: Math.cos(a) * m, y: Math.sin(a) * m };
|
|
77
|
+
}
|
|
78
|
+
// Reaction delay: the push decided now is acted on reactionMs later.
|
|
79
|
+
this.queue.push({ at: now + reactionMs, v });
|
|
80
|
+
while (this.queue.length && (this.queue[0] as { at: number }).at <= now) this.current = (this.queue.shift() as { v: V2 }).v;
|
|
81
|
+
return this.current;
|
|
82
|
+
}
|
|
83
|
+
private current: V2 = { x: 0, y: 0 };
|
|
84
|
+
|
|
85
|
+
/** Forget the target (the round restarted, the bot respawned). */
|
|
86
|
+
reset(): void { this.target = null; this.queue.length = 0; this.current = { x: 0, y: 0 }; this.lastCheck = null; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Ease bots off when they lead the people: 1 = full skill, down to `floor` when the best bot is far ahead. */
|
|
90
|
+
export function rubberBand(bestBot: number, bestPerson: number, floor = 0.6): number {
|
|
91
|
+
if (bestBot <= bestPerson) return 1;
|
|
92
|
+
const lead = (bestBot - bestPerson) / Math.max(1, bestPerson + 3);
|
|
93
|
+
return Math.max(floor, 1 - lead * 0.5);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A person is protected from bots for their first `ms` (default 15 s) in a round. */
|
|
97
|
+
export function protectedNewcomer(joinedAt: number, now: number, ms = 15_000): boolean { return now - joinedAt < ms; }
|
package/port/camera.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The camera rules a port keeps (people's hands found every one of these):
|
|
3
|
+
*
|
|
4
|
+
* 1. Every direction means the same thing on screen, every second. Read the
|
|
5
|
+
* stick and WASD against the CAMERA's screen axes, never the character's
|
|
6
|
+
* facing. The classic failure: "back" turns the character toward the lens,
|
|
7
|
+
* a chase camera swings round to follow the new heading, and "back" now
|
|
8
|
+
* points somewhere else, so the character turns again, forever.
|
|
9
|
+
* 2. The camera never turns by itself while a direction is held. Its yaw
|
|
10
|
+
* changes only for an explicit look input (mouse, look-drag) or at a round
|
|
11
|
+
* start. A fixed orientation ("north up") is the safe default for top-down
|
|
12
|
+
* and three-quarter games; a slow re-align after rest turned the view 80-90°
|
|
13
|
+
* on its own and broke the next presses.
|
|
14
|
+
* 3. Obstacles between the lens and the player fade; the camera does not jump
|
|
15
|
+
* in front of them.
|
|
16
|
+
* 4. Close enough to read: the player character is big in frame (about 14% of
|
|
17
|
+
* a phone's height, 20% of a desktop's), the ground ahead visible. A camera
|
|
18
|
+
* pulled far overhead reads as "you are so high above".
|
|
19
|
+
* 5. No cuts, snaps or re-aims except at a round start or a respawn.
|
|
20
|
+
*
|
|
21
|
+
* The checks prove rule 1 and 2: hold a direction 5 s → one straight line and
|
|
22
|
+
* under 10° of camera yaw; alternate directions for 10 s → every press goes
|
|
23
|
+
* the pressed way on screen.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Minimal shape of a three.js camera (anything with matrixWorld.elements works). */
|
|
27
|
+
export interface CameraLike { matrixWorld: { elements: ArrayLike<number> }; updateMatrixWorld?: (force?: boolean) => void }
|
|
28
|
+
|
|
29
|
+
export interface GroundBasis {
|
|
30
|
+
/** Screen-right on the ground plane, as (x, z). */
|
|
31
|
+
right: [number, number];
|
|
32
|
+
/** Screen-up (away from the viewer) on the ground plane, as (x, z). */
|
|
33
|
+
up: [number, number];
|
|
34
|
+
/** Camera yaw in degrees: 0 when screen-up is world -z (three.js's default look direction). */
|
|
35
|
+
yawDeg: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const norm = (x: number, z: number): [number, number] => { const l = Math.hypot(x, z) || 1; return [x / l, z / l]; };
|
|
39
|
+
|
|
40
|
+
/** The camera's screen axes laid on the ground (XZ) plane, from its world matrix. */
|
|
41
|
+
export function groundBasis(camera: CameraLike): GroundBasis {
|
|
42
|
+
const e = camera.matrixWorld.elements;
|
|
43
|
+
const right = norm(e[0] as number, e[2] as number);
|
|
44
|
+
// The camera looks down its local -Z: forward = -(column 2). Straight down, fall back to its local +Y.
|
|
45
|
+
let up = norm(-(e[8] as number), -(e[10] as number));
|
|
46
|
+
if (Math.hypot(e[8] as number, e[10] as number) < 1e-4) up = norm(e[4] as number, e[6] as number);
|
|
47
|
+
return { right, up, yawDeg: (Math.atan2(-up[0], -up[1]) * 180) / Math.PI };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Screen-space input (x right, y DOWN, as keys and the touch stick give it) to a ground direction (x, z) for this
|
|
52
|
+
* camera. This is THE mapping rule: the same push goes the same way on screen whatever the character faces.
|
|
53
|
+
*/
|
|
54
|
+
export function screenToGround(input: { x: number; y: number }, basis: GroundBasis): { x: number; z: number } {
|
|
55
|
+
const sx = input.x; const sy = -input.y; // screen up is positive here
|
|
56
|
+
return { x: basis.right[0] * sx + basis.up[0] * sy, z: basis.right[1] * sx + basis.up[1] * sy };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A yaw the player owns: it moves only by `turn(deg)` (mouse, look-drag) or `set()` at a round start. Use it for
|
|
61
|
+
* chase and first-person cameras; never feed it the character's heading.
|
|
62
|
+
*/
|
|
63
|
+
export class PlayerYaw {
|
|
64
|
+
yaw: number;
|
|
65
|
+
constructor(initialDeg = 0) { this.yaw = initialDeg; }
|
|
66
|
+
turn(deltaDeg: number): number { this.yaw = ((this.yaw + deltaDeg + 540) % 360) - 180; return this.yaw; }
|
|
67
|
+
set(deg: number): number { this.yaw = ((deg + 540) % 360) - 180; return this.yaw; }
|
|
68
|
+
/** Screen-space input to a ground direction (x, z) for this yaw (0 = screen-up is world -z). */
|
|
69
|
+
toGround(input: { x: number; y: number }): { x: number; z: number } {
|
|
70
|
+
const a = (this.yaw * Math.PI) / 180;
|
|
71
|
+
const up: [number, number] = [-Math.sin(a), -Math.cos(a)];
|
|
72
|
+
const right: [number, number] = [Math.cos(a), -Math.sin(a)];
|
|
73
|
+
return screenToGround(input, { right, up, yawDeg: this.yaw });
|
|
74
|
+
}
|
|
75
|
+
}
|
package/port/controls.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* One input for keys AND touch, in screen space. A port that writes its own
|
|
3
|
+
* input (rather than letting the touch kit press the game's keys) reads this:
|
|
4
|
+
*
|
|
5
|
+
* const input = createControls({ actions: { jump: ['Space', 'KeyW'] }, touch: { buttons: [{ id: 'jump', label: 'JUMP' }] } });
|
|
6
|
+
* const m = input.move(); // x right, y DOWN, length ≤ 1 (WASD, arrows or the stick)
|
|
7
|
+
* if (input.pressed('jump')) … // once per press, from a key or a touch button
|
|
8
|
+
*
|
|
9
|
+
* Map `move()` to the world with the camera's screen axes (camera.ts), never
|
|
10
|
+
* with the character's facing.
|
|
11
|
+
*/
|
|
12
|
+
import { createKeys } from './keys';
|
|
13
|
+
import { createTouchControls, type TouchControls, type TouchOptions } from './touch';
|
|
14
|
+
|
|
15
|
+
export interface ControlsOptions {
|
|
16
|
+
/** Action → key codes (desktop) that trigger it. A touch button with the same id triggers it too. */
|
|
17
|
+
actions?: Record<string, string[]>;
|
|
18
|
+
touch?: TouchOptions;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Controls {
|
|
22
|
+
move(): { x: number; y: number };
|
|
23
|
+
held(action: string): boolean;
|
|
24
|
+
pressed(action: string): boolean;
|
|
25
|
+
readonly touch: TouchControls;
|
|
26
|
+
destroy(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createControls(opts: ControlsOptions = {}): Controls {
|
|
30
|
+
const keys = createKeys();
|
|
31
|
+
const touch = createTouchControls(opts.touch ?? {});
|
|
32
|
+
const actions = opts.actions ?? {};
|
|
33
|
+
return {
|
|
34
|
+
touch,
|
|
35
|
+
move() {
|
|
36
|
+
const k = keys.move(); const s = touch.stick();
|
|
37
|
+
const x = k.x + s.x; const y = k.y + s.y; const len = Math.hypot(x, y);
|
|
38
|
+
return len > 1 ? { x: x / len, y: y / len } : { x, y };
|
|
39
|
+
},
|
|
40
|
+
held(a) { return touch.held(a) || (actions[a] ?? []).some((c) => keys.down(c)); },
|
|
41
|
+
pressed(a) {
|
|
42
|
+
let hit = touch.pressed(a);
|
|
43
|
+
for (const c of actions[a] ?? []) if (keys.pressed(c)) hit = true;
|
|
44
|
+
return hit;
|
|
45
|
+
},
|
|
46
|
+
destroy() { keys.destroy(); touch.destroy(); },
|
|
47
|
+
};
|
|
48
|
+
}
|
package/port/early.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Import this FIRST in a bundled port (before three.js, before the game):
|
|
3
|
+
*
|
|
4
|
+
* import '@homie-rocks/studio/port/early';
|
|
5
|
+
*
|
|
6
|
+
* It makes the sandboxed play frame safe for a single-player game's habits
|
|
7
|
+
* (localStorage, cookies, gamepads) and arms the first-touch audio unlock.
|
|
8
|
+
* A static port gets the same from homie-port.js, the first script in <head>.
|
|
9
|
+
*/
|
|
10
|
+
import { installAudioUnlock } from './audio';
|
|
11
|
+
import { installSandboxShims } from './sandbox';
|
|
12
|
+
|
|
13
|
+
installSandboxShims();
|
|
14
|
+
installAudioUnlock();
|
package/port/global.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The same toolkit as one classic script for STATIC ports (games made of
|
|
3
|
+
* plain <script> tags): `homie-studio build` compiles it to homie-port.js in
|
|
4
|
+
* the game's folder, and `homie-studio port import` loads it first in <head>.
|
|
5
|
+
* Everything is on window.HomiePort (HomiePort.createRoom, HomiePort.createTouchControls, …).
|
|
6
|
+
*/
|
|
7
|
+
import * as port from './index';
|
|
8
|
+
|
|
9
|
+
(window as unknown as { HomiePort: typeof port }).HomiePort = port;
|