@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.
@@ -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
- /** Relay errors after which reconnecting would only repeat the refusal. */
480
- const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many', 'flood']);
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, ageP90 + iv * 1.2 + 4));
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
- connectTimer = setTimeout(() => { if (!roleKnown) goOfflineHost('relay-timeout'); }, connectTimeoutMs);
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
- if ((ws.bufferedAmount ?? 0) > 256 * 1024) return false;
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.2.0",
4
- "description": "A game studio on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, the site Worker with public rooms (Table/Lobby Durable Objects, D1, R2), deploy, and the homie.rocks directory.",
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
+ }
@@ -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;