@homie-rocks/studio 0.37.2 → 0.39.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/bin/homie-studio.mjs +16 -4
  3. package/lib/build.mjs +3 -0
  4. package/lib/cloudflare.mjs +2 -2
  5. package/lib/dev.mjs +12 -1
  6. package/lib/mcp-tools.mjs +10 -0
  7. package/lib/perf.mjs +17 -10
  8. package/lib/port-check.mjs +101 -19
  9. package/lib/rules-check-worker.mjs +15 -2
  10. package/lib/scaffold.mjs +19 -6
  11. package/lib/shoot.mjs +11 -5
  12. package/lib/studio.mjs +1 -1
  13. package/lib/template-history.json +85 -0
  14. package/lib/tools-build.mjs +55 -0
  15. package/lib/tools-cli.mjs +55 -0
  16. package/lib/worker-build.mjs +35 -0
  17. package/netplay/NETPLAY.md +20 -6
  18. package/netplay/netplay.ts +3 -1
  19. package/package.json +17 -6
  20. package/port/probe.ts +2 -1
  21. package/rules/core.ts +3 -1
  22. package/rules/host.ts +3 -1
  23. package/rules/pack.ts +30 -3
  24. package/rules/view.ts +232 -40
  25. package/starters/coin-dash/lab.json +17 -0
  26. package/starters/coin-dash/src/rules.ts +3 -3
  27. package/starters/gem-rush/game.json +19 -4
  28. package/starters/gem-rush/map/main.json +50 -0
  29. package/starters/gem-rush/src/move.ts +23 -0
  30. package/starters/gem-rush/src/rules.ts +118 -0
  31. package/starters/gem-rush/src/view.ts +429 -0
  32. package/starters/gem-rush/tunables.json +98 -11
  33. package/tools/TOOLS.md +113 -0
  34. package/tools/tools.ts +28 -0
  35. package/worker/app-records.mjs +8 -0
  36. package/worker/index.mjs +26 -0
  37. package/worker/mcp-room.mjs +61 -0
  38. package/worker/mcp-store.mjs +45 -0
  39. package/worker/mcp-tools.mjs +105 -0
  40. package/worker/mcp.mjs +159 -0
  41. package/worker/office-page.mjs +1 -1
  42. package/worker/office.mjs +2 -1
  43. package/worker/purchase-mcp.mjs +3 -3
  44. package/worker/tool-events.mjs +14 -0
  45. package/worker/tool-identity.mjs +4 -0
  46. package/worker/tool-webhook.mjs +28 -0
  47. package/worker/version.mjs +1 -1
  48. package/starters/gem-rush/src/main.ts +0 -1087
@@ -0,0 +1,118 @@
1
+ import { defineRules, f } from '@homie-rocks/studio/rules';
2
+ import { move } from './move';
3
+
4
+ export default defineRules({
5
+ contract: 2, space: { dims: 2 }, move,
6
+ shapes: {
7
+ events: { hit: { from: f.vec3(), by: f.ref() }, take: { by: f.ref(), at: f.vec3() }, score: { value: f.u8() }, zone: {} },
8
+ effects: { wave: {}, knock: { dir: f.dir(), by: f.ref() } },
9
+ },
10
+ shared: { zone: f.vec3(), zoneN: f.u32(), zoneUntil: f.tick() },
11
+ entities: {
12
+ runner: {
13
+ player: { away: 'think', leave: 'bot' },
14
+ fields: { score: f.u16({ score: true }), pickups: f.u16(), target: f.vec3(), noticed: f.fix(), arrived: f.bit() },
15
+ motion: { speed: f.fix({ init: 6.8 }), accel: f.fix({ init: 14 }), knockDir: f.dir(), knockFrom: f.vec3(), knockAt: f.fix(), knockUntil: f.fix() },
16
+ input: { ax: f.i8(), ay: f.i8(), wave: f.press() },
17
+ body: { shape: 'circle', radius: 0.44, maxSpeed: 6.8 },
18
+ tick(world, self) {
19
+ if (self.driver === 'person' && !self.away) { self.motion.speed = 6.8; self.motion.accel = 14; }
20
+ if (self.input.wave) {
21
+ world.emit('wave', self.id, {});
22
+ world.sendArea({ sphere: { at: self.pos, r: world.tune.knockRange } }, 'hit', { from: self.pos, by: self.id });
23
+ }
24
+ if (world.round.phase === 'live') for (const gem of world.near(self.pos, 0.7, 'gem')) world.send(gem.id, 'take', { by: self.id, at: self.pos });
25
+ },
26
+ think(world, self) {
27
+ const M = world.math;
28
+ self.motion.speed = 5; self.motion.accel = 5;
29
+ if (world.stage === 'dummy' || self.motion.knockAt > 0) return { ax: 0, ay: 0 };
30
+ const level = world.level - 1;
31
+ const reactions = [650, 420, 250, 170, 110];
32
+ const noise = [0.55, 0.3, 0.15, 0.07, 0.02];
33
+ const aggression = [0.1, 0.3, 0.5, 0.7, 0.9];
34
+ const positioning = [0.1, 0.35, 0.5, 0.75, 0.95];
35
+ if (self.noticed === 0 || world.tick - self.noticed >= reactions[level] / 1000 / world.dt) {
36
+ let best = self.pos, nearest = 10000;
37
+ for (const gem of world.near(self.pos, 64, 'gem')) {
38
+ const hot = M.dist(gem.pos, world.shared.zone) < 3;
39
+ const distance = M.dist(gem.pos, self.pos) * (hot ? 1.5 - positioning[level] : 1);
40
+ if (distance < nearest) { nearest = distance; best = gem.pos; }
41
+ }
42
+ self.target = { x: best.x + (world.random() - 0.5) * 4 * noise[level], y: best.y + (world.random() - 0.5) * 4 * noise[level], z: 0 };
43
+ self.noticed = world.tick; self.arrived = false;
44
+ }
45
+ const d = M.sub(self.target, self.pos), distance = M.len(d);
46
+ if (distance < 0.12 && !self.arrived) { self.arrived = true; self.noticed = world.tick; }
47
+ const v = distance < 0.12 ? { x: 0, y: 0, z: 0 } : M.norm(d);
48
+ let rival = false;
49
+ for (const p of world.near(self.pos, world.tune.knockRange, 'runner')) if (p.id !== self.id) rival = true;
50
+ return { ax: Math.round(v.x * 127), ay: Math.round(v.y * 127), wave: rival && world.round.phase === 'live' && world.random() < aggression[level] * 0.8 * world.dt };
51
+ },
52
+ on: {
53
+ score(world, self, e) { self.score += e.value; self.pickups += 1; },
54
+ takeover(world, self) { self.motion.speed = 6.8; self.motion.accel = 14; self.motion.knockAt = 0; self.motion.knockUntil = 0; self.vel = { x: 0, y: 0, z: 0 }; },
55
+ arrive(world, self) {
56
+ if (world.stage === 'dummy') {
57
+ let index = 0;
58
+ for (const p of world.near(self.pos, 64, 'runner')) if (p.driver === 'bot' && p.seat < self.seat) index++;
59
+ world.place(self, self.driver === 'person' ? { x: 12.8, y: 10, z: 0 } : index === 0 ? { x: 14.64, y: 10, z: 0 } : { x: 28.4, y: 3 + (index - 1) * 14, z: 0 });
60
+ }
61
+ },
62
+ hit(world, self, e) {
63
+ if (e.by === self.id) return;
64
+ const M = world.math, m = self.motion, delta = M.sub(self.pos, e.from);
65
+ m.knockDir = M.len(delta) < 0.02 ? M.dir(world.random() * M.TAU) : M.norm(delta);
66
+ m.knockFrom = self.pos;
67
+ m.knockAt = world.tick + world.tune.hitStopMs / 1000 / world.dt;
68
+ m.knockUntil = m.knockAt + world.tune.knockMs / 1000 / world.dt;
69
+ world.emit('knock', self.id, { dir: m.knockDir, by: e.by });
70
+ },
71
+ },
72
+ onRoom: {
73
+ roundStart(world, self) {
74
+ self.score = 0; self.pickups = 0; self.noticed = 0;
75
+ self.motion.knockAt = 0; self.motion.knockUntil = 0;
76
+ const spots = world.map.spots('start');
77
+ if (world.stage !== 'dummy') world.place(self, spots[self.seat % spots.length]);
78
+ else {
79
+ let index = 0;
80
+ for (const p of world.near(self.pos, 64, 'runner')) if (p.driver === 'bot' && p.seat < self.seat) index++;
81
+ world.place(self, self.driver === 'person' ? { x: 12.8, y: 10, z: 0 } : index === 0 ? { x: 14.64, y: 10, z: 0 } : { x: 28.4, y: 3 + (index - 1) * 14, z: 0 });
82
+ }
83
+ },
84
+ },
85
+ },
86
+ gem: {
87
+ fields: { takenAt: f.tick() },
88
+ on: {
89
+ take(world, self, e) {
90
+ if (self.takenAt === world.tick || world.math.dist(e.at, self.pos) >= 0.7) return;
91
+ self.takenAt = world.tick;
92
+ world.send(e.by, 'score', { value: world.math.dist(self.pos, world.shared.zone) < 3 ? 2 : 1 });
93
+ world.place(self, { x: 1.2 + world.random() * 29.6, y: 1.2 + world.random() * 17.6, z: 0 });
94
+ },
95
+ },
96
+ onRoom: { roundOver(world, self) { world.despawn(self); } },
97
+ },
98
+ },
99
+ room: {
100
+ rounds: { seconds: 60, breakSeconds: 7 }, bots: { keep: 3 },
101
+ join(ctx, player) { const spots = ctx.map.spots('start'); return { kind: 'runner', at: spots[player.seat % spots.length] }; },
102
+ on: {
103
+ roundStart(world) {
104
+ for (let i = 0; i < 14; i++) world.spawn('gem', { x: 1.2 + world.random() * 29.6, y: 1.2 + world.random() * 17.6, z: 0 }, {});
105
+ world.shared.zone = { x: 4.4 + world.random() * 23.2, y: 4 + world.random() * 12, z: 0 };
106
+ world.shared.zoneN += 1; world.shared.zoneUntil = world.tick + world.ticks(12);
107
+ world.after(world.ticks(12), 'zone', {});
108
+ },
109
+ zone(world) {
110
+ if (world.round.phase !== 'live') return;
111
+ world.shared.zone = { x: 4.4 + world.random() * 23.2, y: 4 + world.random() * 12, z: 0 };
112
+ world.shared.zoneN += 1; world.shared.zoneUntil = world.tick + world.ticks(12);
113
+ world.after(world.ticks(12), 'zone', {});
114
+ },
115
+ },
116
+ },
117
+ map: './map',
118
+ });
@@ -0,0 +1,429 @@
1
+ /** Gem Rush: the canvas draws the room; rules.ts decides the game. Coordinates here are pixels (50 per metre). */
2
+ import { openRoom, type Entity } from '@homie-rocks/studio/rules/view';
3
+ import type rules from './rules';
4
+ import { guardGestures, PALETTE, AI_MARK, type Slot, type RoundInfo } from '@homie-rocks/studio/netplay';
5
+ import { BUBBLE_FONT, createBubbles, createLabels, fitView, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
6
+ import { lab } from '@homie-rocks/studio/lab';
7
+ // This canvas draws positive Y downward; report its axes to the control checks.
8
+ const room = openRoom<typeof rules>({ screenBasis: () => ({ right: [1, 0], up: [0, -1] }) });
9
+ const net = room.net;
10
+ guardGestures({ touch: 'canvas' });
11
+ const W = 1600, H = 1000, R_AV = 22, R_GEM = 13;
12
+ const T = { ...room.tune, knockRange: Number(room.tune.knockRange) * 50, knockDistance: Number(room.tune.knockDistance) * 50 } as Record<string, number>;
13
+ const colourOf = (slot: number, seat: number | null): string => PALETTE[(seat ?? slot) % PALETTE.length] as string;
14
+ const BOT_NAMES = ['Rook', 'Vex', 'Moth', 'Kilo', 'Juno', 'Pike', 'Nyx', 'Ash'];
15
+ const botName = (slot: number): string => BOT_NAMES[slot % BOT_NAMES.length] as string;
16
+ const label = (name: string, bot: boolean): string => name.endsWith(AI_MARK) ? name : bot ? `${name} · bot` : name;
17
+ const mySeat = (): number | null => room.seat;
18
+ const viewSeat = (): number | null => net.viewSeat;
19
+ const me = { x: 800, y: 500, has: false };
20
+ const drawn = new Map<number, { x: number; y: number; seat: number; score: number; slot: number }>();
21
+ const waves: { x: number; y: number; at: number; colour: string; knock?: boolean }[] = [];
22
+ let wavesSeen = 0, knocksSeen = 0, frames = 0;
23
+ let gems: { x: number; y: number; id: number }[] = [];
24
+ let round: RoundInfo | null = null;
25
+ let zone: { x: number; y: number; r: number } | null = null;
26
+ const slotOf = (e: Entity): number => e.kind === 'runner' ? e.seat ?? 0 : 0;
27
+ function updateView(): void {
28
+ const v = moveVector(); room.input({ ax: Math.round(v.x * 127), ay: Math.round(v.y * 127) });
29
+ const own = room.me; me.has = Boolean(own);
30
+ if (own) { me.x = own.pos.x * 50; me.y = own.pos.y * 50; }
31
+ drawn.clear();
32
+ room.each('runner', e => drawn.set(slotOf(e), { slot: slotOf(e), seat: e.driver === 'bot' ? -1 : e.seat ?? -1, x: e.pos.x * 50, y: e.pos.y * 50, score: Number(e.score) }));
33
+ gems = []; room.each('gem', e => gems.push({ id: gems.length, x: e.pos.x * 50, y: e.pos.y * 50 }));
34
+ const z = room.shared.zone as { x: number; y: number } | undefined;
35
+ zone = z ? { x: z.x * 50, y: z.y * 50, r: 150 } : null;
36
+ const r = room.round;
37
+ round = r ? { ...r, startedAt: 0, endsAt: net.now() + r.secondsLeft * 1000 } : null;
38
+ }
39
+ function effect(name: 'wave' | 'knock', e: { id?: string; at?: { x: number; y: number } | null; dir?: { x: number; y: number }; by?: string }): void {
40
+ const body = e.id ? room.get(e.id) : null;
41
+ if (!body || body.kind !== 'runner' || !e.at) return;
42
+ const slot = slotOf(body), at = { x: e.at.x * 50, y: e.at.y * 50 }, now = performance.now();
43
+ waves.push({ ...at, at: now, colour: colourOf(slot, body.seat ?? null), knock: name === 'knock' });
44
+ if (name === 'wave') { wavesSeen++; pushes.set(slot, now); }
45
+ else if (e.dir) {
46
+ const by = e.by ? room.get(e.by) : null;
47
+ knocksSeen++; bumped({ slot, dx: e.dir.x, dy: e.dir.y, by: by?.kind === 'runner' ? by.seat : undefined }, at);
48
+ if (lab.on) Object.assign(subject, { slot, at: net.now(), x0: at.x, y0: at.y, px: at.x, py: at.y, has: true });
49
+ }
50
+ }
51
+ room.on('wave', e => effect('wave', e));
52
+ room.on('knock', e => effect('knock', e));
53
+ const keys = new Set<string>();
54
+ addEventListener('keydown', (e) => {
55
+ if (['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) e.preventDefault();
56
+ if (e.code === 'Space' && !keys.has('Space')) wave();
57
+ keys.add(e.code);
58
+ });
59
+ addEventListener('keyup', (e) => keys.delete(e.code));
60
+ addEventListener('blur', () => keys.clear());
61
+
62
+ const stick = { id: -1, ox: 0, oy: 0, x: 0, y: 0, active: false };
63
+ const canvas = document.getElementById('c') as HTMLCanvasElement;
64
+ const ctx = canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D;
65
+ canvas.addEventListener('pointerdown', (e) => {
66
+ if (e.pointerType === 'mouse') return;
67
+ if (stick.active && e.pointerId !== stick.id) { wave(); return; } // a second finger waves
68
+ stick.id = e.pointerId; stick.ox = e.clientX; stick.oy = e.clientY; stick.x = e.clientX; stick.y = e.clientY; stick.active = true;
69
+ canvas.setPointerCapture(e.pointerId);
70
+ });
71
+ canvas.addEventListener('pointermove', (e) => { if (e.pointerId === stick.id) { stick.x = e.clientX; stick.y = e.clientY; } });
72
+ const endStick = (e: PointerEvent): void => { if (e.pointerId === stick.id) { stick.active = false; stick.id = -1; } };
73
+ canvas.addEventListener('pointerup', endStick);
74
+ canvas.addEventListener('pointercancel', endStick);
75
+
76
+ function moveVector(): { x: number; y: number } {
77
+ let x = 0; let y = 0;
78
+ if (keys.has('KeyA') || keys.has('ArrowLeft')) x -= 1;
79
+ if (keys.has('KeyD') || keys.has('ArrowRight')) x += 1;
80
+ if (keys.has('KeyW') || keys.has('ArrowUp')) y -= 1;
81
+ if (keys.has('KeyS') || keys.has('ArrowDown')) y += 1;
82
+ if (stick.active) {
83
+ const dx = stick.x - stick.ox; const dy = stick.y - stick.oy;
84
+ const len = Math.hypot(dx, dy);
85
+ if (len > 6) { const m = Math.min(1, len / 56); x += (dx / len) * m; y += (dy / len) * m; }
86
+ }
87
+ const len = Math.hypot(x, y);
88
+ return len > 1 ? { x: x / len, y: y / len } : { x, y };
89
+ }
90
+
91
+ function wave(): void {
92
+ const v = moveVector(); room.input({ ax: Math.round(v.x * 127), ay: Math.round(v.y * 127), wave: true });
93
+ }
94
+ const bumps = new Map<number, { at: number; dx: number; dy: number }>();
95
+ const pushes = new Map<number, number>();
96
+ const sparks: { x: number; y: number; vx: number; vy: number; at: number; life: number }[] = [];
97
+ let kickAt = -1e9;
98
+ function bumped(d: { slot: number; dx?: number; dy?: number; by?: number }, at: { x: number; y: number }): void {
99
+ const now = performance.now();
100
+ const dx = Number(d.dx) || 0; const dy = Number(d.dy) || 0;
101
+ bumps.set(d.slot, { at: now, dx, dy });
102
+ const base = Math.atan2(dy, dx);
103
+ for (let i = 0; i < T.sparks; i += 1) {
104
+ const a = base + (lab.random() - 0.5) * 1.6;
105
+ const v = 420 + lab.random() * 520;
106
+ sparks.push({ x: at.x - dx * R_AV * 0.6, y: at.y - dy * R_AV * 0.6, vx: Math.cos(a) * v, vy: Math.sin(a) * v, at: now, life: 170 + lab.random() * 170 });
107
+ }
108
+ if (sparks.length > 160) sparks.splice(0, sparks.length - 160);
109
+ // The camera kicks for whoever was in it: the body bumped, or the one who waved.
110
+ const mine = [...drawn.values()].find((x) => x.seat === net.seat)?.slot;
111
+ if (mine !== undefined && (d.slot === mine || d.by === mine)) kickAt = now;
112
+ }
113
+ /** A body's look this frame: offset, stretch along an angle, and how white it flashes. */
114
+ function bodyFx(slot: number, t: number): { ox: number; oy: number; sx: number; sy: number; ang: number; flash: number } {
115
+ const out = { ox: 0, oy: 0, sx: 1, sy: 1, ang: 0, flash: 0 };
116
+ const p = pushes.get(slot);
117
+ if (p !== undefined) { const u = (t - p) / 170; if (u >= 0 && u < 1) { const k = 0.14 * Math.sin(Math.PI * u); out.sx = 1 + k; out.sy = 1 + k; } }
118
+ const b = bumps.get(slot);
119
+ if (!b) return out;
120
+ const age = t - b.at;
121
+ const hs = T.hitStopMs; const end = hs + T.knockMs;
122
+ if (age > end + T.settleMs + 200) { bumps.delete(slot); return out; }
123
+ out.ang = Math.atan2(b.dy, b.dx);
124
+ out.flash = T.flashMs > 0 ? Math.max(0, 1 - age / T.flashMs) : 0;
125
+ let k = 0;
126
+ if (age < hs) { k = -T.squash * 0.45; out.ox = Math.sin(age * 0.9) * 3; out.oy = Math.cos(age * 1.3) * 3; }
127
+ else if (age < end) k = T.squash * (1 - (age - hs) / T.knockMs) ** Math.max(0, T.knockEase - 1);
128
+ else if (T.settleMs > 0 && age < end + T.settleMs) { const w = (age - end) / T.settleMs; k = -T.squash * 0.8 * Math.exp(-3 * w) * Math.sin(2.5 * Math.PI * w); }
129
+ out.sx *= 1 + k; out.sy *= 1 / (1 + k);
130
+ return out;
131
+ }
132
+ function drawSparks(t: number, scale: number): void {
133
+ ctx.lineCap = 'round';
134
+ for (let i = sparks.length - 1; i >= 0; i -= 1) {
135
+ const sp = sparks[i] as (typeof sparks)[number];
136
+ const age = t - sp.at;
137
+ if (age > sp.life) { sparks.splice(i, 1); continue; }
138
+ if (age < T.hitStopMs * 0.5) continue;
139
+ const u = age / sp.life; const s = age / 1000 * (1 - u * 0.5);
140
+ const x = sp.x + sp.vx * s; const y = sp.y + sp.vy * s;
141
+ ctx.strokeStyle = u < 0.4 ? '#ffffff' : '#ffd166'; ctx.globalAlpha = 1 - u; ctx.lineWidth = (2.5 + 4 * (1 - u)) / Math.max(0.5, scale);
142
+ ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x - sp.vx * 0.022 * (1 - u), y - sp.vy * 0.022 * (1 - u)); ctx.stroke();
143
+ }
144
+ ctx.globalAlpha = 1; ctx.lineCap = 'butt';
145
+ }
146
+
147
+ type Pose = { x: number; y: number; sx?: number; sy?: number; a?: number };
148
+ const subject = { slot: -1, at: 0, x0: 0, y0: 0, px: 0, py: 0, has: false };
149
+ function subjectAt(): Pose | null { return subject.has ? drawn.get(subject.slot) ?? null : null; }
150
+ function labReport(dt: number): void {
151
+ const at = subjectAt();
152
+ if (!at || dt <= 0) { lab.phase(null); return; }
153
+ const age = net.now() - subject.at;
154
+ lab.track('speed', Math.hypot(at.x - subject.px, at.y - subject.py) / dt, 'px/s');
155
+ lab.track('distance', Math.hypot(at.x - subject.x0, at.y - subject.y0), 'px');
156
+ subject.px = at.x; subject.py = at.y;
157
+ const fx = bodyFx(subject.slot, performance.now());
158
+ lab.track('stretch', (fx.sx - 1) * 100, '%');
159
+ if (age < T.hitStopMs) lab.phase('HIT-STOP', 'The hit lands: hold, flash, squash');
160
+ else if (age < T.hitStopMs + T.knockMs * 0.3) lab.phase('LAUNCH', 'Leaves fast, stretched along the hit');
161
+ else if (age < T.hitStopMs + T.knockMs) lab.phase('SLIDE', 'Eases into the stop: no creep, no pop');
162
+ else if (age < T.hitStopMs + T.knockMs + T.settleMs) lab.phase('SETTLE', 'Squash on the stop, overlap on the way out');
163
+ else lab.phase(null);
164
+ lab.pose('subject', { x: at.x, y: at.y, sx: fx.sx, sy: fx.sy, a: fx.ang });
165
+ }
166
+ /** The lab's views: the game's own camera, close on the knock, the whole arena. */
167
+ const labView = lab.camera<{ zoom?: number; whole?: boolean } | null>({ game: null, close: { zoom: 2.4 }, arena: { whole: true } });
168
+ lab.overlay('onion', (c, k) => {
169
+ const g = c as CanvasRenderingContext2D; const s = Number(k) || 1;
170
+ const ghosts = lab.past<Pose>('subject', 8, 3);
171
+ ghosts.forEach((p, i) => { g.globalAlpha = 0.5 * (1 - i / ghosts.length); g.strokeStyle = '#ffad3b'; g.lineWidth = 2 / s; g.beginPath(); g.ellipse(p.x, p.y, R_AV * (p.sx ?? 1), R_AV * (p.sy ?? 1), p.a ?? 0, 0, Math.PI * 2); g.stroke(); });
172
+ g.globalAlpha = 1;
173
+ });
174
+ lab.overlay('arcs', (c, k) => {
175
+ const g = c as CanvasRenderingContext2D; const s = Number(k) || 1;
176
+ const pts = lab.past<Pose>('subject', 90, 1);
177
+ if (pts.length < 2) return;
178
+ g.strokeStyle = 'rgba(124,196,255,.55)'; g.lineWidth = 1.5 / s;
179
+ g.beginPath(); pts.forEach((p, i) => (i ? g.lineTo(p.x, p.y) : g.moveTo(p.x, p.y))); g.stroke();
180
+ g.fillStyle = '#7cc4ff';
181
+ for (const p of pts) { g.beginPath(); g.arc(p.x, p.y, 2.5 / s, 0, Math.PI * 2); g.fill(); }
182
+ });
183
+ lab.overlay('reach', (c, k) => {
184
+ if (!me.has) return;
185
+ const g = c as CanvasRenderingContext2D; const s = Number(k) || 1;
186
+ g.strokeStyle = 'rgba(255,255,255,.35)'; g.lineWidth = 1.5 / s; g.setLineDash([8 / s, 6 / s]);
187
+ g.beginPath(); g.arc(me.x, me.y, T.knockRange, 0, Math.PI * 2); g.stroke(); g.setLineDash([]);
188
+ });
189
+
190
+ /* ------------------------------------------------------------------ draw */
191
+ let dpr = 1;
192
+ function resize(): void {
193
+ dpr = Math.min(2, devicePixelRatio || 1);
194
+ canvas.width = Math.round(innerWidth * dpr);
195
+ canvas.height = Math.round(innerHeight * dpr);
196
+ }
197
+ addEventListener('resize', resize);
198
+ resize();
199
+
200
+ const cam = { x: W / 2, y: H / 2, scale: 0 };
201
+ const labels = createLabels({ screen: () => ({ w: innerWidth, h: innerHeight }) });
202
+ // Room chat (NETPLAY.md section 19): what a player says in the room's chat (the play page's Chat, or a quick line) shows
203
+ // over their body for a few seconds, when they want it there. The studio taking a message down takes its bubble too.
204
+ const bubbles = createBubbles({ measure: (t) => { ctx.font = BUBBLE_FONT; return ctx.measureText(t).width; }, screen: () => ({ w: innerWidth, h: innerHeight }) });
205
+ net.on('say', (s) => bubbles.say(s.seat, s.text, { id: s.id, kind: s.kind }));
206
+ net.on('unchat', (e) => { for (const id of e.ids) bubbles.remove(id); });
207
+ let shownBubbles: BubbleOut[] = [];
208
+ let shownLabels: LabelOut[] = [];
209
+ let lastDraw = 0;
210
+ /** Where a seat's body is drawn now (host: the real body; replica: interpolated), or null. */
211
+ function seatPos(seat: number): { x: number; y: number } | null {
212
+ if (seat === mySeat() && me.has && !net.watching) return me;
213
+ const d = [...drawn.values()].find((x) => x.seat === seat);
214
+ return d ? { x: d.x, y: d.y } : null;
215
+ }
216
+ const gemGlow = (() => {
217
+ const g = ctx.createRadialGradient(0, 0, 0, 0, 0, R_GEM * 2.8);
218
+ g.addColorStop(0, 'rgba(255,209,102,0.55)'); g.addColorStop(0.4, 'rgba(255,170,60,0.18)'); g.addColorStop(1, 'rgba(255,150,40,0)');
219
+ return g;
220
+ })();
221
+ function draw(t: number): void {
222
+ const cw = innerWidth; const ch = innerHeight;
223
+ const phone = Math.min(cw, ch) <= 540;
224
+ // The view: my own body, or (a watcher) the followed player's, drawn exactly as their own browser frames it.
225
+ const view = viewSeat();
226
+ const followed = net.watching && view !== null ? seatPos(view) : null;
227
+ const overview = net.watching ? !followed : mySeat() === null;
228
+ // The camera: close on the body whose view this is, never past the arena's edge (except as far as it takes to keep
229
+ // that body clear of the HUD), and never so far out that an empty band shows beside the arena (held upright, a
230
+ // phone showed the arena as a band with a third of the screen dark below or above it; now the arena fills the
231
+ // screen). Following nobody: the whole arena.
232
+ const focus = net.watching ? followed : overview || !me.has ? null : me;
233
+ // The Game Lab's views (outside the lab: the game's own camera, always).
234
+ const lv = labView();
235
+ const want = overview || lv?.whole ? { scale: Math.min(cw / (W + 80), ch / (H + 80)), x: W / 2, y: H / 2 }
236
+ : fitView({ world: { w: W, h: H }, screen: { w: cw, h: ch }, readable: 0, zoom: (Math.min(cw, ch) / (phone ? 560 : 820)) * (lv?.zoom ?? 1), focus: lv?.zoom ? subjectAt() ?? focus : focus, inset: { top: 52, bottom: 36 } });
237
+ // A watcher's switch glides: the zoom and the pan ease over a few frames, never a cut.
238
+ cam.scale = cam.scale ? cam.scale + (want.scale - cam.scale) * 0.12 : want.scale;
239
+ const scale = cam.scale;
240
+ cam.x += (want.x - cam.x) * 0.2; cam.y += (want.y - cam.y) * 0.2;
241
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
242
+ const bg = ctx.createRadialGradient(cw / 2, ch / 2, 0, cw / 2, ch / 2, Math.max(cw, ch) * 0.8);
243
+ bg.addColorStop(0, '#0d1426'); bg.addColorStop(1, '#04060c');
244
+ ctx.fillStyle = bg; ctx.fillRect(0, 0, cw, ch);
245
+
246
+ // The camera's kick (a bump you were in): a few px, gone in a fifth of a second.
247
+ const kick = t - kickAt < 200 ? T.shake * (1 - (t - kickAt) / 200) ** 2 : 0;
248
+ ctx.save();
249
+ ctx.translate(cw / 2 + (kick ? (lab.random() - 0.5) * 2 * kick : 0), ch / 2 + (kick ? (lab.random() - 0.5) * 2 * kick : 0)); ctx.scale(scale, scale); ctx.translate(-cam.x, -cam.y);
250
+ // arena
251
+ ctx.strokeStyle = 'rgba(125,240,255,0.07)'; ctx.lineWidth = 1 / scale;
252
+ ctx.beginPath();
253
+ for (let x = 0; x <= W; x += 100) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
254
+ for (let y = 0; y <= H; y += 100) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
255
+ ctx.stroke();
256
+ ctx.strokeStyle = 'rgba(125,240,255,0.55)'; ctx.lineWidth = 4; ctx.strokeRect(0, 0, W, H);
257
+ // hot zone (keyed state): gems inside score double
258
+ if (zone) {
259
+ const pulse = 0.5 + 0.5 * Math.sin(t / 300);
260
+ ctx.fillStyle = `rgba(255,120,190,${0.06 + 0.05 * pulse})`; ctx.beginPath(); ctx.arc(zone.x, zone.y, zone.r, 0, Math.PI * 2); ctx.fill();
261
+ ctx.strokeStyle = 'rgba(255,140,200,0.6)'; ctx.lineWidth = 3; ctx.setLineDash([14, 10]); ctx.stroke(); ctx.setLineDash([]);
262
+ ctx.fillStyle = 'rgba(255,170,215,0.85)'; ctx.font = '700 16px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center';
263
+ ctx.fillText('×2', zone.x, zone.y + 6);
264
+ }
265
+
266
+ // gems
267
+ const gemList: { x: number; y: number; id: number }[] = gems;
268
+ for (const g of gemList) {
269
+ const spin = t / 500 + g.id;
270
+ const r = R_GEM * (1 + 0.12 * Math.sin(t / 200 + g.id));
271
+ ctx.save(); ctx.translate(g.x, g.y);
272
+ ctx.globalCompositeOperation = 'lighter';
273
+ ctx.fillStyle = gemGlow; ctx.beginPath(); ctx.arc(0, 0, r * 2.6, 0, Math.PI * 2); ctx.fill();
274
+ ctx.globalCompositeOperation = 'source-over';
275
+ ctx.rotate(spin);
276
+ ctx.fillStyle = '#ffd166'; ctx.beginPath(); ctx.moveTo(0, -r); ctx.lineTo(r * 0.8, 0); ctx.lineTo(0, r); ctx.lineTo(-r * 0.8, 0); ctx.closePath(); ctx.fill();
277
+ ctx.restore();
278
+ }
279
+
280
+ // waves
281
+ for (let i = waves.length - 1; i >= 0; i -= 1) {
282
+ const w = waves[i] as (typeof waves)[number];
283
+ const age = (t - w.at) / T.ringMs;
284
+ if (age > 1) { waves.splice(i, 1); continue; }
285
+ ctx.strokeStyle = w.colour; ctx.globalAlpha = 1 - age; ctx.lineWidth = w.knock ? 3 : 5;
286
+ ctx.beginPath(); ctx.arc(w.x, w.y, w.knock ? R_AV + 6 + age * 40 : R_AV + age * (T.knockRange + 10), 0, Math.PI * 2); ctx.stroke(); ctx.globalAlpha = 1;
287
+ }
288
+
289
+ // avatars
290
+ const names = new Map<number, Slot>((net.slots ?? []).map((s) => [s.slot, s]));
291
+ // `mine`: the body whose view this is: my own, or the player a watcher follows (named, never "You").
292
+ const list: { slot: number; seat: number | null; x: number; y: number; bot: boolean; name: string; mine: boolean }[] = [];
293
+ const isView = (seat: number | null, bot: boolean): boolean => !bot && seat !== null && seat === view;
294
+ for (const d of drawn.values()) {
295
+ const own = !net.watching && d.seat >= 0 && d.seat === net.seat;
296
+ const s = names.get(d.slot);
297
+ list.push({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, x: own && me.has ? me.x : d.x, y: own && me.has ? me.y : d.y, bot: d.seat < 0, name: s?.name ?? (d.seat >= 0 ? `Player ${d.seat + 1}` : botName(d.slot)), mine: isView(d.seat >= 0 ? d.seat : null, d.seat < 0) });
298
+ }
299
+ // Names go on after the arena, on the screen, so they read at one size on any screen and never pile up.
300
+ const fs = Math.round(Math.max(15, Math.min(22, (15 * Math.min(cw, ch)) / 720)));
301
+ ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;
302
+ const tags: LabelIn[] = [];
303
+ const heads = new Map<number, { x: number; y: number; seat: number; mine: boolean }>();
304
+ const viewAt = list.find((a) => a.mine) ?? null;
305
+ for (const a of list) {
306
+ const colour = colourOf(a.slot, a.bot ? null : a.seat);
307
+ const fx = bodyFx(a.slot, t);
308
+ ctx.save();
309
+ ctx.translate(a.x + fx.ox, a.y + fx.oy); ctx.rotate(fx.ang); ctx.scale(fx.sx, fx.sy); ctx.rotate(-fx.ang);
310
+ ctx.globalAlpha = a.bot ? 0.62 : 1;
311
+ ctx.fillStyle = colour; ctx.beginPath(); ctx.arc(0, 0, R_AV, 0, Math.PI * 2); ctx.fill();
312
+ ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(0, 0, R_AV * 0.45, 0, Math.PI * 2); ctx.fill();
313
+ if (fx.flash > 0) { ctx.globalAlpha = fx.flash; ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(0, 0, R_AV, 0, Math.PI * 2); ctx.fill(); }
314
+ ctx.restore();
315
+ if (a.mine) { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(a.x, a.y, R_AV + 7, 0, Math.PI * 2); ctx.stroke(); }
316
+ ctx.globalAlpha = 1;
317
+ const text = a.mine && !net.watching ? 'You' : label(a.name, a.bot);
318
+ const sx = cw / 2 + (a.x - cam.x) * scale; const sy = ch / 2 + (a.y - cam.y) * scale; const r = (R_AV + (a.mine ? 7 : 0)) * scale;
319
+ if (sx + r < 0 || sx - r > cw || sy + r < 0 || sy - r > ch) continue; // off screen: no name at the edge
320
+ if (a.seat !== null && !a.bot) heads.set(a.slot, { x: sx, y: sy - r - 5, seat: a.seat, mine: a.mine });
321
+ // People before bots, nearer the view's body first; each keeps off the others' bodies when it can.
322
+ tags.push({ key: a.slot, text, x: sx, y: sy - r - 5, w: ctx.measureText(text).width, h: fs * 1.2, below: sy + r + 4 + fs * 1.2, body: { left: sx - r, top: sy - r, right: sx + r, bottom: sy + r }, self: a.mine, rank: (a.bot ? 10_000 : 0) + (viewAt ? Math.hypot(a.x - viewAt.x, a.y - viewAt.y) : 0) });
323
+ }
324
+ drawSparks(t, scale);
325
+ lab.draw(ctx, scale);
326
+ ctx.restore();
327
+ shownLabels = labels.place(tags, Math.min(0.1, (t - (lastDraw || t)) / 1000));
328
+ lastDraw = t;
329
+ ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;
330
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineJoin = 'round';
331
+ for (const l of [...shownLabels].sort((x, y) => Number(Boolean(x.self)) - Number(Boolean(y.self)))) {
332
+ if (l.alpha <= 0) continue;
333
+ ctx.globalAlpha = l.alpha;
334
+ if (l.moved) {
335
+ // Off its own spot: a thin line to its body says whose it is.
336
+ const under = l.top > l.y;
337
+ ctx.strokeStyle = 'rgba(232,236,245,0.4)'; ctx.lineWidth = 1;
338
+ ctx.beginPath(); ctx.moveTo(l.cx, under ? l.top : l.bottom); ctx.lineTo(l.x, under ? (l.body?.bottom ?? l.top - 6) : l.y + 2); ctx.stroke();
339
+ }
340
+ if (l.self) { ctx.fillStyle = 'rgba(6,10,20,0.7)'; ctx.beginPath(); ctx.roundRect(l.left - 5, l.top - 1, l.right - l.left + 10, l.bottom - l.top + 2, 6); ctx.fill(); }
341
+ else { ctx.strokeStyle = 'rgba(4,6,12,0.85)'; ctx.lineWidth = 3; ctx.strokeText(l.text, l.cx, l.cy); }
342
+ ctx.fillStyle = l.self ? '#ffffff' : 'rgba(232,236,245,0.85)';
343
+ ctx.fillText(l.text, l.cx, l.cy);
344
+ }
345
+ ctx.globalAlpha = 1; ctx.textBaseline = 'alphabetic';
346
+ // Speech bubbles (room chat): over each speaker's name, the view's own player first.
347
+ if (bubbles.size) {
348
+ const anchors: BubbleIn[] = [];
349
+ for (const [slot, h] of heads) {
350
+ const l = shownLabels.find((x) => x.key === slot && x.alpha > 0 && !x.moved);
351
+ anchors.push({ key: h.seat, x: l ? l.cx : h.x, y: l ? l.top - 1 : h.y, self: h.mine });
352
+ }
353
+ shownBubbles = bubbles.place(anchors);
354
+ paintBubbles(ctx, shownBubbles);
355
+ } else shownBubbles = [];
356
+
357
+ hud(cw, ch, phone, list);
358
+ if (stick.active) {
359
+ ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 3;
360
+ ctx.beginPath(); ctx.arc(stick.ox, stick.oy, 56, 0, Math.PI * 2); ctx.stroke();
361
+ const dx = stick.x - stick.ox; const dy = stick.y - stick.oy; const len = Math.hypot(dx, dy); const m = len > 56 ? 56 / len : 1;
362
+ ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.beginPath(); ctx.arc(stick.ox + dx * m, stick.oy + dy * m, 24, 0, Math.PI * 2); ctx.fill();
363
+ }
364
+ }
365
+
366
+ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; name: string; bot: boolean; mine: boolean }[]): void {
367
+ const pad = phone ? 14 : 20;
368
+ const debugStrip = new URLSearchParams(location.search).get('debug') === '1' || (window as unknown as { HOMIE_NET?: { debug?: boolean } }).HOMIE_NET?.debug;
369
+ const top = pad + (debugStrip ? (phone ? 50 : 26) : 0);
370
+ const now = net.now();
371
+ const r = round;
372
+ ctx.textAlign = 'left'; ctx.fillStyle = '#e8ecf5';
373
+ ctx.font = `700 ${phone ? 20 : 24}px ui-sans-serif, system-ui, sans-serif`;
374
+ const left = r ? Math.max(0, Math.ceil((r.endsAt - now) / 1000)) : 0;
375
+ const clock = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
376
+ ctx.fillText(r ? (r.phase === 'live' ? `Round ${r.n} · ${clock}` : `Next round in ${left}`) : 'Joining…', pad, top + 18);
377
+ // scores
378
+ const scores = new Map<number, number>();
379
+ for (const d of drawn.values()) scores.set(d.slot, d.score);
380
+ const rows = [...list].sort((a, b) => (scores.get(b.slot) ?? 0) - (scores.get(a.slot) ?? 0)).slice(0, phone ? 4 : 6);
381
+ ctx.font = `600 ${phone ? 14 : 16}px ui-sans-serif, system-ui, sans-serif`;
382
+ ctx.textAlign = 'right';
383
+ // The play page's room button (and a server's pill beside it) sit at the top right (game.json screen.share's
384
+ // default): the scores start under that band, so the buttons never cover a score.
385
+ const board = top + 18 + 44;
386
+ rows.forEach((a, i) => {
387
+ ctx.fillStyle = a.mine ? '#ffffff' : a.bot ? 'rgba(232,236,245,0.55)' : 'rgba(232,236,245,0.85)';
388
+ ctx.fillText(`${a.mine && !net.watching ? 'You' : label(a.name, a.bot)} ${scores.get(a.slot) ?? 0}`, cw - pad, board + i * (phone ? 20 : 22));
389
+ });
390
+ // role badge
391
+ ctx.textAlign = 'left'; ctx.font = '600 11px ui-monospace, Menlo, monospace'; ctx.fillStyle = 'rgba(125,240,255,0.7)';
392
+ ctx.fillText(net.offline ? 'OFFLINE HOST' : net.watching ? 'WATCHING' : net.role.toUpperCase(), pad, ch - pad);
393
+ if (r && r.phase === 'over' && r.results) {
394
+ const w = Math.min(360, cw - 40); const h = 44 + Math.min(6, r.results.length) * 26;
395
+ const x = (cw - w) / 2; const y = (ch - h) / 2;
396
+ ctx.fillStyle = 'rgba(6,10,20,0.86)'; ctx.fillRect(x, y, w, h);
397
+ ctx.strokeStyle = 'rgba(125,240,255,0.5)'; ctx.strokeRect(x, y, w, h);
398
+ ctx.textAlign = 'center'; ctx.fillStyle = '#7df0ff'; ctx.font = '700 18px ui-sans-serif, system-ui, sans-serif';
399
+ ctx.fillText(`Round ${r.n} results`, cw / 2, y + 28);
400
+ ctx.font = '600 16px ui-sans-serif, system-ui, sans-serif';
401
+ const view = viewSeat();
402
+ r.results.slice(0, 6).forEach((row, i) => {
403
+ const mine = !row.bot && row.seat !== null && row.seat === view;
404
+ ctx.fillStyle = mine ? '#ffffff' : row.bot ? 'rgba(232,236,245,0.6)' : '#e8ecf5';
405
+ ctx.fillText(`${row.place}. ${mine && !net.watching ? 'You' : label(row.name, row.bot)}${mine && net.watching ? ' ◂' : ''} — ${row.score}`, cw / 2, y + 56 + i * 26);
406
+ });
407
+ }
408
+ // Only a browser that WAS in its room is reconnecting; one still joining, or stopped for good, is not (section 22).
409
+ if (net.link === 'reconnecting' && net.role !== 'host') {
410
+ ctx.textAlign = 'center'; ctx.fillStyle = '#ffd166'; ctx.font = '600 14px ui-sans-serif, system-ui, sans-serif';
411
+ ctx.fillText('Reconnecting…', cw / 2, ch - pad);
412
+ }
413
+ if (frames < 240 && mySeat() !== null) {
414
+ ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(232,236,245,0.7)'; ctx.font = '500 14px ui-sans-serif, system-ui, sans-serif';
415
+ ctx.fillText(phone ? 'Drag to move · second finger bumps' : 'WASD / arrows to move · Space bumps', cw / 2, ch - pad - 18);
416
+ }
417
+ }
418
+
419
+ function frame(t: number): void {
420
+ const dt = lab.time.dt(t, 0.05);
421
+ updateView();
422
+ if (lab.on) labReport(dt);
423
+ draw(t); frames++; requestAnimationFrame(frame);
424
+ }
425
+ room.probe({ frames: () => frames, waves: () => wavesSeen, knocks: () => knocksSeen, zone: () => zone,
426
+ camera: () => ({ ...cam, view: viewSeat() }),
427
+ labels: () => shownLabels.map(l => ({ self: Boolean(l.self), alpha: l.alpha, moved: l.moved, left: Math.round(l.left), top: Math.round(l.top), right: Math.round(l.right), bottom: Math.round(l.bottom) })),
428
+ bubbles: () => shownBubbles.map(b => ({ seat: b.key, text: b.lines.join(' '), alpha: b.alpha })) });
429
+ requestAnimationFrame(frame);