@homie-rocks/studio 0.2.0 → 0.3.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/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;
package/port/hud.ts ADDED
@@ -0,0 +1,96 @@
1
+ /*
2
+ * A small room HUD for a port that has none of its own: the round clock
3
+ * (top-left chip), the top scores (top-right, four rows at most), and the
4
+ * results card between rounds. Everything is text on small translucent chips;
5
+ * nothing opaque sits in the middle of the screen during play; names are set
6
+ * with textContent (people type them). A big screen (a spectator) gets the
7
+ * same board without any "you".
8
+ *
9
+ * A game with its own HUD should draw the same facts itself (clock from
10
+ * room.clock(), results from room.results()) instead.
11
+ */
12
+ import type { BodyBase, Room } from './room';
13
+
14
+ export interface HudOptions {
15
+ /** What a point is called on the board ("gems", "tiles"). */
16
+ unit?: string;
17
+ /** A short controls line shown for the first seconds ("Drag to move · tap to jump"). */
18
+ hint?: { phone: string; desk: string };
19
+ /** Accent colour for "you". */
20
+ accent?: string;
21
+ }
22
+
23
+ const CSS = `
24
+ .hp-hud{position:fixed;z-index:2147482000;pointer-events:none;font:600 14px/1.25 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color:#eef1f8;text-shadow:0 1px 2px rgba(0,0,0,.7)}
25
+ .hp-chip{background:rgba(6,9,18,.55);border-radius:10px;padding:5px 9px;backdrop-filter:blur(4px)}
26
+ .hp-clock{left:calc(10px + env(safe-area-inset-left));top:calc(10px + env(safe-area-inset-top))}
27
+ .hp-board{right:calc(10px + env(safe-area-inset-right));top:calc(10px + env(safe-area-inset-top));text-align:right;min-width:92px}
28
+ .hp-board div{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:42vw}
29
+ .hp-board .me{color:var(--hp-accent,#ffd166)}
30
+ .hp-board .bot{opacity:.62}
31
+ .hp-results{left:50%;top:50%;transform:translate(-50%,-50%);min-width:220px;max-width:min(360px,86vw);padding:14px 16px;border-radius:14px;background:rgba(6,9,18,.82)}
32
+ .hp-results h2{margin:0 0 8px;font-size:15px;letter-spacing:.04em;text-transform:uppercase;color:var(--hp-accent,#ffd166)}
33
+ .hp-results ol{margin:0;padding-left:20px}
34
+ .hp-hint{left:50%;bottom:calc(14px + env(safe-area-inset-bottom));transform:translateX(-50%);opacity:.85;transition:opacity .8s}
35
+ @media (max-width:540px){.hp-hud{font-size:13px}}
36
+ `;
37
+
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ export function createHud<B extends BodyBase>(room: Room<B, any, any>, opts: HudOptions = {}): { destroy(): void } {
40
+ if (typeof document === 'undefined') return { destroy() {} };
41
+ const style = document.createElement('style');
42
+ style.textContent = CSS;
43
+ document.head.append(style);
44
+ const mk = (cls: string): HTMLDivElement => { const d = document.createElement('div'); d.className = `hp-hud ${cls}`; return d; };
45
+ const clock = mk('hp-chip hp-clock');
46
+ const board = mk('hp-chip hp-board');
47
+ const results = mk('hp-results');
48
+ results.style.display = 'none';
49
+ const hint = mk('hp-chip hp-hint');
50
+ if (opts.accent) document.documentElement.style.setProperty('--hp-accent', opts.accent);
51
+ const phone = Math.min(innerWidth, innerHeight) <= 540;
52
+ const spectator = (): boolean => !room.net.offline && room.net.seat === null;
53
+ if (opts.hint && !spectator()) hint.textContent = phone ? opts.hint.phone : opts.hint.desk; else hint.style.display = 'none';
54
+ const mount = (): void => { document.body.append(clock, board, results, hint); };
55
+ if (document.body) mount(); else document.addEventListener('DOMContentLoaded', mount, { once: true });
56
+ const bornAt = performance.now();
57
+ let lastResults = '';
58
+
59
+ const paint = (): void => {
60
+ const c = room.clock();
61
+ clock.textContent = c.phase === 'none' ? 'Joining…' : c.phase === 'live' ? `Round ${c.n} · ${Math.floor(c.secondsLeft / 60)}:${String(c.secondsLeft % 60).padStart(2, '0')}` : `Next round in ${c.secondsLeft}`;
62
+ const me = room.mySeat();
63
+ const rows = room.view().slice().sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot)).slice(0, phone ? 4 : 5);
64
+ board.textContent = '';
65
+ for (const b of rows) {
66
+ const d = document.createElement('div');
67
+ const mine = !spectator() && me !== null && b.seat === me && !b.bot;
68
+ d.className = mine ? 'me' : b.bot ? 'bot' : '';
69
+ d.textContent = `${mine ? 'You' : b.name}${b.bot ? ' · bot' : ''} ${b.score}`;
70
+ board.append(d);
71
+ }
72
+ const r = room.round;
73
+ if (r && r.phase === 'over' && r.results) {
74
+ const key = `${r.n}:${r.endsAt}`;
75
+ if (key !== lastResults) {
76
+ lastResults = key;
77
+ results.textContent = '';
78
+ const h = document.createElement('h2'); h.textContent = `Round ${r.n}`;
79
+ const ol = document.createElement('ol');
80
+ for (const row of r.results.slice(0, 6)) {
81
+ const li = document.createElement('li');
82
+ const mine = !spectator() && me !== null && row.seat === me && !row.bot;
83
+ li.textContent = `${mine ? 'You' : row.name}${row.bot ? ' (bot)' : ''} — ${row.score}${opts.unit ? ` ${opts.unit}` : ''}`;
84
+ if (mine) li.style.color = 'var(--hp-accent,#ffd166)';
85
+ ol.append(li);
86
+ }
87
+ results.append(h, ol);
88
+ }
89
+ results.style.display = 'block';
90
+ } else results.style.display = 'none';
91
+ if (hint.style.display !== 'none' && performance.now() - bornAt > 9000) hint.style.opacity = '0';
92
+ };
93
+ const timer = setInterval(paint, 200);
94
+ paint();
95
+ return { destroy() { clearInterval(timer); clock.remove(); board.remove(); results.remove(); hint.remove(); style.remove(); } };
96
+ }
package/port/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ /*
2
+ * @homie-rocks/studio/port — the toolkit the port skill uses to make an existing
3
+ * single-player web game multiplayer on the netplay contract.
4
+ *
5
+ * early sandbox shims + first-touch audio (import first)
6
+ * createRoom the host scaffold: bots, join-in-progress, rounds, host migration, snapshots
7
+ * createControls / createTouchControls / createKeys / synthKey
8
+ * keys on computers, a floating stick and small buttons on phones
9
+ * groundBasis / screenToGround / PlayerYaw
10
+ * the camera rules: input on screen axes, a yaw only the player turns
11
+ * exposePort what `homie-studio port check` reads (owner tests)
12
+ * createHud a minimal clock / scores / results overlay
13
+ * BotBrain, seek, nearest, rubberBand
14
+ * bot scaffolds
15
+ * and everything from @homie-rocks/studio/netplay.
16
+ */
17
+ import './early';
18
+
19
+ export * from '../netplay/netplay';
20
+ export { installSandboxShims, sandboxReport, type SandboxReport } from './sandbox';
21
+ export { installAudioUnlock, audioReport } from './audio';
22
+ export { createKeys, synthKey, holdKey, tapKey, keyInfo, releaseAllSynthKeys, type Keys, type Dir } from './keys';
23
+ export { createTouchControls, type TouchControls, type TouchOptions, type TouchButton } from './touch';
24
+ export { createControls, type Controls, type ControlsOptions } from './controls';
25
+ export { groundBasis, screenToGround, PlayerYaw, type GroundBasis, type CameraLike } from './camera';
26
+ export { exposePort, type PortProbeOptions, type View } from './probe';
27
+ export { createRoom, type Room, type RoomOptions, type BodyBase, type RoomSnap, type RoomCkpt } from './room';
28
+ export { createHud, type HudOptions } from './hud';
29
+ export { BotBrain, seek, flee, wanderer, nearest, rubberBand, protectedNewcomer, type V2 } from './bots';
package/port/keys.ts ADDED
@@ -0,0 +1,102 @@
1
+ /*
2
+ * Keys on computers, and keys synthesized from touch on phones.
3
+ *
4
+ * Most single-player web games read the keyboard. A port keeps that code and
5
+ * lets the touch kit press the same keys (a stick held left presses ArrowLeft,
6
+ * a button presses Space), so the game's own input code stays the one truth.
7
+ * The synthetic events carry the legacy keyCode/which/charCode too, because
8
+ * older games (and jQuery) read those rather than `key` or `code`.
9
+ */
10
+
11
+ export type Dir = 'left' | 'right' | 'up' | 'down';
12
+
13
+ const CODES: Record<string, [key: string, keyCode: number]> = {
14
+ ArrowLeft: ['ArrowLeft', 37], ArrowUp: ['ArrowUp', 38], ArrowRight: ['ArrowRight', 39], ArrowDown: ['ArrowDown', 40],
15
+ Space: [' ', 32], Enter: ['Enter', 13], Escape: ['Escape', 27], Tab: ['Tab', 9], Backspace: ['Backspace', 8],
16
+ ShiftLeft: ['Shift', 16], ShiftRight: ['Shift', 16], ControlLeft: ['Control', 17], AltLeft: ['Alt', 18],
17
+ };
18
+
19
+ /** `key`, `keyCode` for a KeyboardEvent.code ('KeyW', 'Digit1', 'ArrowLeft', 'Space', …). */
20
+ export function keyInfo(code: string): { key: string; keyCode: number } {
21
+ const known = CODES[code];
22
+ if (known) return { key: known[0], keyCode: known[1] };
23
+ const letter = /^Key([A-Z])$/.exec(code);
24
+ if (letter) return { key: (letter[1] as string).toLowerCase(), keyCode: (letter[1] as string).charCodeAt(0) };
25
+ const digit = /^Digit([0-9])$/.exec(code);
26
+ if (digit) return { key: digit[1] as string, keyCode: 48 + Number(digit[1]) };
27
+ return { key: code, keyCode: 0 };
28
+ }
29
+
30
+ /** Where synthetic keys go: the focused element (it bubbles to document and window), else the body. */
31
+ function keyTarget(): EventTarget {
32
+ const a = typeof document !== 'undefined' ? document.activeElement : null;
33
+ return a && a !== document.documentElement ? a : (document.body ?? document);
34
+ }
35
+
36
+ const synthDown = new Set<string>();
37
+
38
+ /** Dispatch one synthetic key event that old and new games both understand. */
39
+ export function synthKey(type: 'keydown' | 'keyup', code: string, target: EventTarget = keyTarget()): void {
40
+ const { key, keyCode } = keyInfo(code);
41
+ const ev = new KeyboardEvent(type, { key, code, bubbles: true, cancelable: true, composed: true, repeat: false });
42
+ for (const [prop, value] of [['keyCode', keyCode], ['which', keyCode], ['charCode', type === 'keydown' ? 0 : 0]] as const) {
43
+ try { Object.defineProperty(ev, prop, { get: () => value }); } catch { /* read-only on this engine */ }
44
+ }
45
+ if (type === 'keydown') synthDown.add(code); else synthDown.delete(code);
46
+ target.dispatchEvent(ev);
47
+ }
48
+
49
+ /** Hold `code` down (once) or let it go; no repeats, like a real key held still. */
50
+ export function holdKey(code: string, down: boolean): void {
51
+ if (down && !synthDown.has(code)) synthKey('keydown', code);
52
+ else if (!down && synthDown.has(code)) synthKey('keyup', code);
53
+ }
54
+
55
+ /** A quick press and release (a swipe on a board game, a tap). */
56
+ export function tapKey(code: string, holdMs = 40): void {
57
+ synthKey('keydown', code);
58
+ setTimeout(() => synthKey('keyup', code), holdMs);
59
+ }
60
+
61
+ export function releaseAllSynthKeys(): void { for (const c of [...synthDown]) synthKey('keyup', c); }
62
+
63
+ /** The keyboard, read directly: WASD and arrows as a move vector, and edges for buttons. */
64
+ export interface Keys {
65
+ /** Screen-space move: x right, y DOWN, length ≤ 1. */
66
+ move(): { x: number; y: number };
67
+ down(code: string): boolean;
68
+ /** True once per press (consumed). */
69
+ pressed(code: string): boolean;
70
+ destroy(): void;
71
+ }
72
+
73
+ export function createKeys(target: Window | HTMLElement = window): Keys {
74
+ const held = new Set<string>();
75
+ const edges = new Set<string>();
76
+ const kd = (e: Event): void => {
77
+ const k = e as KeyboardEvent;
78
+ if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(k.code)) k.preventDefault();
79
+ if (!held.has(k.code)) edges.add(k.code);
80
+ held.add(k.code);
81
+ };
82
+ const ku = (e: Event): void => { held.delete((e as KeyboardEvent).code); };
83
+ // A lost focus releases keys (a key-up we never saw would hold a direction forever). It never touches fingers.
84
+ const blur = (): void => { held.clear(); };
85
+ target.addEventListener('keydown', kd);
86
+ target.addEventListener('keyup', ku);
87
+ window.addEventListener('blur', blur);
88
+ return {
89
+ move() {
90
+ let x = 0; let y = 0;
91
+ if (held.has('KeyA') || held.has('ArrowLeft')) x -= 1;
92
+ if (held.has('KeyD') || held.has('ArrowRight')) x += 1;
93
+ if (held.has('KeyW') || held.has('ArrowUp')) y -= 1;
94
+ if (held.has('KeyS') || held.has('ArrowDown')) y += 1;
95
+ const len = Math.hypot(x, y);
96
+ return len > 1 ? { x: x / len, y: y / len } : { x, y };
97
+ },
98
+ down: (code) => held.has(code),
99
+ pressed(code) { const had = edges.has(code); edges.delete(code); return had; },
100
+ destroy() { target.removeEventListener('keydown', kd); target.removeEventListener('keyup', ku); window.removeEventListener('blur', blur); },
101
+ };
102
+ }
package/port/probe.ts ADDED
@@ -0,0 +1,126 @@
1
+ /*
2
+ * What `homie-studio port check` reads inside a ported game: one object on
3
+ * window.__homiePort, sampled every frame into a ring buffer, so the owner
4
+ * tests can judge motion on SCREEN axes (the camera's, not the game's):
5
+ *
6
+ * hold a direction 5 s → one straight line the pressed way, camera yaw change < 10°
7
+ * alternate directions 10 s → every press goes the pressed way within 600 ms
8
+ *
9
+ * A port calls exposePort() once. `self` is the player's own body on the
10
+ * ground plane; `basis` is the camera's screen axes on that plane (the default
11
+ * is a flat 2D view: x right, y down). Board and turn-based games have no body:
12
+ * they report `lastMove` (the direction the game APPLIED) instead, and the
13
+ * check presses and swipes and compares.
14
+ */
15
+ import type { Netplay } from '../netplay/netplay';
16
+ import { audioReport } from './audio';
17
+ import { sandboxReport } from './sandbox';
18
+
19
+ export type View = 'top' | 'side' | 'first-person' | 'board' | 'maze';
20
+
21
+ export interface PortProbeOptions {
22
+ /** top: top-down or three-quarter (all four directions move you); side: a side view (left/right run, up jumps);
23
+ * first-person: WASD strafes on the camera's axes; board: no body (grid, cards, puzzle); maze: a top view on
24
+ * a grid of corridors whose turns are buffered (a turn into a wall waits for the next opening). */
25
+ view: View;
26
+ /** My body's position on the ground plane (2D: canvas x, y with y down; 3D: world x and z). null = no body yet. */
27
+ self?: () => { x: number; y: number } | null;
28
+ /** The camera's screen-right and screen-up directions on that plane. Default (2D): right (1,0), up (0,-1). */
29
+ basis?: () => { right: [number, number]; up: [number, number] };
30
+ /** About how big a body is, in the same units (a radius or half a width). The checks scale distances by it. */
31
+ size?: number;
32
+ /** Board games: the last move the game applied ('left' | 'right' | 'up' | 'down' | any word) and a counter. */
33
+ lastMove?: () => string | null;
34
+ moves?: () => number;
35
+ /** My score right now, if the game has one. */
36
+ score?: () => number | null;
37
+ /** True while my body is not mine to steer (knocked back, stunned, dead, respawning): the owner tests skip
38
+ * those frames, and the recipe's newcomer rule says it should rarely happen in a person's first seconds. */
39
+ busy?: () => boolean;
40
+ /** Anything else worth a look in a receipt (numbers or short strings). */
41
+ extra?: Record<string, () => unknown>;
42
+ /** The keys that move you (KeyboardEvent.code). Default: the arrows (WASD for first-person). */
43
+ keys?: { up: string; down: string; left: string; right: string };
44
+ /** Where the check puts a thumb for the stick, as screen fractions (default [0.24, 0.74]). */
45
+ thumb?: [number, number];
46
+ /** CSS selector of the game's own world when it is HTML (a DOM board, a grid of tiles): the check counts it as
47
+ * the world, not as UI covering the screen. A canvas game needs nothing here. */
48
+ world?: string;
49
+ }
50
+
51
+ interface PortProbe {
52
+ v: 1;
53
+ view: View;
54
+ size: number;
55
+ keys: { up: string; down: string; left: string; right: string };
56
+ thumb: [number, number];
57
+ world: string | null;
58
+ /** Rows since `t` (performance.now ms): [t, x, y, rightX, rightY, upX, upY, moves, busy (1/0)]. */
59
+ rows(since?: number): number[][];
60
+ now(): number;
61
+ info(): Record<string, unknown>;
62
+ errors: string[];
63
+ }
64
+
65
+ const RING = 4000;
66
+
67
+ export function exposePort(net: Netplay<unknown, unknown, unknown> | null, opts: PortProbeOptions): PortProbe {
68
+ const rows: number[][] = [];
69
+ let frames = 0;
70
+ const frameTimes: number[] = [];
71
+ const errors: string[] = [];
72
+ if (typeof window !== 'undefined') {
73
+ window.addEventListener('error', (e) => { errors.push(String((e as ErrorEvent).message ?? e).slice(0, 300)); if (errors.length > 50) errors.shift(); });
74
+ window.addEventListener('unhandledrejection', (e) => { errors.push(`unhandled: ${String((e as PromiseRejectionEvent).reason).slice(0, 300)}`); if (errors.length > 50) errors.shift(); });
75
+ }
76
+ const flat = (): { right: [number, number]; up: [number, number] } => ({ right: [1, 0], up: [0, -1] });
77
+ const basisOf = opts.basis ?? flat;
78
+ const sample = (t: number): void => {
79
+ frames += 1;
80
+ frameTimes.push(t); if (frameTimes.length > 120) frameTimes.shift();
81
+ let me: { x: number; y: number } | null = null;
82
+ try { me = opts.self ? opts.self() : null; } catch { me = null; }
83
+ let b = flat();
84
+ try { b = basisOf(); } catch { /* keep flat */ }
85
+ let m = 0;
86
+ try { m = opts.moves ? Number(opts.moves()) || 0 : 0; } catch { /* ignore */ }
87
+ let busy = 0;
88
+ try { busy = opts.busy && opts.busy() ? 1 : 0; } catch { /* ignore */ }
89
+ rows.push([t, me ? me.x : NaN, me ? me.y : NaN, b.right[0], b.right[1], b.up[0], b.up[1], m, busy]);
90
+ if (rows.length > RING) rows.splice(0, rows.length - RING);
91
+ };
92
+ const loop = (t: number): void => { sample(t); requestAnimationFrame(loop); };
93
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(loop);
94
+ const probe: PortProbe = {
95
+ v: 1,
96
+ view: opts.view,
97
+ size: opts.size ?? 1,
98
+ keys: opts.keys ?? (opts.view === 'first-person' ? { up: 'KeyW', down: 'KeyS', left: 'KeyA', right: 'KeyD' } : { up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight' }),
99
+ thumb: opts.thumb ?? [0.24, 0.74],
100
+ world: opts.world ?? null,
101
+ rows(since = 0) { return rows.filter((r) => (r[0] as number) >= since); },
102
+ now: () => performance.now(),
103
+ info() {
104
+ const fps = frameTimes.length > 10 ? (frameTimes.length - 1) / (((frameTimes[frameTimes.length - 1] as number) - (frameTimes[0] as number)) / 1000) : null;
105
+ const extra: Record<string, unknown> = {};
106
+ for (const [k, f] of Object.entries(opts.extra ?? {})) { try { extra[k] = f(); } catch (e) { extra[k] = `error: ${String(e)}`; } }
107
+ let lastMove: string | null = null;
108
+ try { lastMove = opts.lastMove ? opts.lastMove() : null; } catch { /* ignore */ }
109
+ let score: number | null = null;
110
+ try { score = opts.score ? opts.score() : null; } catch { /* ignore */ }
111
+ return {
112
+ view: opts.view, frames, fps: fps === null ? null : Math.round(fps * 10) / 10,
113
+ role: net?.role ?? null, seat: net?.seat ?? null, offline: net?.offline ?? null, owned: net?.owned ?? null,
114
+ round: net?.roundInfo ? { n: net.roundInfo.n, phase: net.roundInfo.phase, endsAt: net.roundInfo.endsAt, leftMs: Math.round(net.roundInfo.endsAt - net.now()) } : null,
115
+ slots: net?.slots?.map((s) => ({ slot: s.slot, seat: s.seat, bot: s.bot })) ?? null,
116
+ lastMove, moves: opts.moves ? opts.moves() : null, score,
117
+ audio: audioReport(), sandbox: sandboxReport(), errors: errors.slice(-10), extra,
118
+ };
119
+ },
120
+ errors,
121
+ };
122
+ (window as unknown as { __homiePort: PortProbe }).__homiePort = probe;
123
+ // The contract's own probes too, so the netplay e2e and any older harness can read this game.
124
+ try { net?.expose({ self: () => { const s = opts.self?.(); return s ? { x: s.x, y: s.y } : null; }, frames: () => frames }); } catch { /* offline */ }
125
+ return probe;
126
+ }