@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/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
+ }