@homie-rocks/studio 0.37.1 → 0.38.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 (87) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/bin/homie-studio.mjs +7 -6
  3. package/lib/cloudflare.mjs +28 -3
  4. package/lib/dev.mjs +1 -1
  5. package/lib/parts-build.mjs +23 -4
  6. package/lib/parts-cli.mjs +54 -3
  7. package/lib/parts-purchase.mjs +174 -0
  8. package/lib/parts-store.mjs +50 -19
  9. package/lib/parts-tools.mjs +15 -12
  10. package/lib/parts-upload.mjs +43 -0
  11. package/lib/parts.mjs +49 -5
  12. package/lib/perf.mjs +17 -10
  13. package/lib/port-check.mjs +77 -18
  14. package/lib/purchase-links.mjs +42 -0
  15. package/lib/purchase-wallet.mjs +114 -0
  16. package/lib/rules-check-worker.mjs +15 -2
  17. package/lib/scaffold.mjs +25 -5
  18. package/lib/shoot.mjs +12 -6
  19. package/lib/shop-links.mjs +15 -6
  20. package/lib/shop.mjs +91 -21
  21. package/lib/stripe-connect.mjs +2 -2
  22. package/lib/template-history.json +85 -0
  23. package/netplay/NETPLAY.md +20 -6
  24. package/netplay/netplay.ts +3 -1
  25. package/package.json +12 -2
  26. package/parts/COMMERCIAL-LICENCE.md +21 -0
  27. package/parts/PARTS.md +238 -10
  28. package/port/probe.ts +2 -1
  29. package/rules/core.ts +3 -1
  30. package/rules/host.ts +3 -1
  31. package/rules/pack.ts +30 -3
  32. package/rules/view.ts +232 -40
  33. package/starters/coin-dash/lab.json +17 -0
  34. package/starters/coin-dash/src/rules.ts +3 -3
  35. package/starters/gem-rush/game.json +19 -4
  36. package/starters/gem-rush/map/main.json +50 -0
  37. package/starters/gem-rush/src/move.ts +23 -0
  38. package/starters/gem-rush/src/rules.ts +118 -0
  39. package/starters/gem-rush/src/view.ts +429 -0
  40. package/starters/gem-rush/tunables.json +98 -11
  41. package/worker/extensions.mjs +13 -0
  42. package/worker/index.mjs +3 -2
  43. package/worker/machine-payments.mjs +72 -0
  44. package/worker/office.mjs +4 -1
  45. package/worker/parts-protected.mjs +111 -0
  46. package/worker/parts-purchase-catalogue.mjs +34 -0
  47. package/worker/parts-resource.mjs +32 -0
  48. package/worker/parts-sale.mjs +59 -0
  49. package/worker/parts.mjs +24 -11
  50. package/worker/payment-capabilities.mjs +70 -0
  51. package/worker/payment-dry-exchange.mjs +30 -0
  52. package/worker/payment-manual-refund.mjs +40 -0
  53. package/worker/payment-policy.mjs +40 -0
  54. package/worker/payment-recovery.mjs +163 -0
  55. package/worker/payment-test-exchange.mjs +19 -0
  56. package/worker/purchase-core.mjs +140 -0
  57. package/worker/purchase-crypto.mjs +17 -0
  58. package/worker/purchase-delivery.mjs +108 -0
  59. package/worker/purchase-discovery.mjs +26 -0
  60. package/worker/purchase-hosted-readiness.mjs +16 -0
  61. package/worker/purchase-keys.mjs +20 -0
  62. package/worker/purchase-links.mjs +9 -0
  63. package/worker/purchase-machine.mjs +279 -0
  64. package/worker/purchase-mcp.mjs +42 -0
  65. package/worker/purchase-office-routes.mjs +17 -0
  66. package/worker/purchase-office-script.mjs +38 -0
  67. package/worker/purchase-office.mjs +35 -0
  68. package/worker/purchase-owner.mjs +243 -0
  69. package/worker/purchase-pricing.mjs +8 -0
  70. package/worker/purchase-problem.mjs +4 -0
  71. package/worker/purchase-reconciliation.mjs +582 -0
  72. package/worker/purchase-response.mjs +82 -0
  73. package/worker/purchase-routes.mjs +611 -0
  74. package/worker/purchase-schema.mjs +50 -0
  75. package/worker/purchase-store.mjs +148 -0
  76. package/worker/purchases.mjs +6 -0
  77. package/worker/resource-kinds.mjs +20 -0
  78. package/worker/selling/index.mjs +8 -0
  79. package/worker/selling/runtime.mjs +42 -0
  80. package/worker/settlement-journal.mjs +138 -0
  81. package/worker/shop-page.mjs +7 -4
  82. package/worker/shop-refunds.mjs +22 -0
  83. package/worker/shop-rules.mjs +1 -1
  84. package/worker/shop.mjs +18 -18
  85. package/worker/stripe.mjs +36 -3
  86. package/worker/version.mjs +1 -1
  87. package/starters/gem-rush/src/main.ts +0 -1087
@@ -1,1087 +0,0 @@
1
- /*
2
- * GEM RUSH — the netplay v1 reference game (@homie-rocks/studio/netplay/NETPLAY.md).
3
- *
4
- * Move, collect gems, score; 60 s rounds that cycle forever; the round starts
5
- * the moment the first browser arrives, with two bots, and every human who
6
- * arrives takes a bot's body. Every browser renders the game itself with its
7
- * own camera. One browser is host: it runs pickups, scoring, bots and the
8
- * clock, and broadcasts snapshots. Everyone else moves their own avatar
9
- * locally (instant) and sends it to the host; everything else is drawn from
10
- * interpolated snapshots. If the host leaves, a survivor continues the SAME
11
- * round from the relay's checkpoint.
12
- *
13
- * It also exercises the rest of the contract: a wave near another body knocks
14
- * it back (the host TAKES a client-owned body, drives the knockback itself, and
15
- * GIVES it back), replica movement is speed-capped on the host, and the hot
16
- * zone (gems inside it score double) is slow state on the keyed state channel,
17
- * so a joiner has it in its welcome and a promoted host inherits it.
18
- *
19
- * WATCH ANY PLAYER (contract revision 5): a watcher (/<game>/watch) never
20
- * takes a seat. `net.viewSeat` says whose view to draw; the camera follows that
21
- * player's body as their own browser does, the board marks their row, and with
22
- * nobody followed the whole arena shows. Waves call `net.spotlight(seat)`, so a
23
- * watcher on Auto cuts to whoever just bumped.
24
- *
25
- * SERVERS AND THE SKILL DIAL (contract revision 6): the bots play at the room's
26
- * dial (`net.skillOf(slot)`: how late they see, how well they aim, whether they
27
- * fight for the hot zone, how often they bump), which the party votes in the
28
- * play page; a hybrid server's AI seats are kept as AI bodies (the Roster's
29
- * reserve, marked " · AI"), an AI with a pass takes one, and a humans-only
30
- * server's bots are off. The game says so: `caps: ['skill', 'agents']`.
31
- *
32
- * THE GAME LAB (`homie-studio lab gem-rush`): the knock is tuned in the lab. Its numbers are tunables.json (read with
33
- * lab.tunables, so the lab's sliders move them and write kept values back), and the game tells the lab what the knock
34
- * is doing: its phases, the bumped body's speed and distance, poses for the onion skin and the spacing arc, and preset
35
- * views. Outside the lab every lab call is a no-op. lab.json holds the take the lab plays.
36
- *
37
- * Canvas 2D on purpose: the point is the contract, in ~700 readable lines.
38
- */
39
- import { createNetplay, guardGestures, Roster, q, lerp, capMove, PALETTE, AI_MARK, type Peer, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
40
- // The port toolkit: its probe (what `homie-studio port check` reads for the owner tests, and sandbox + audio shims),
41
- // and a flat world on every screen (port/view.ts: the camera, and name labels that never pile up).
42
- import { BUBBLE_FONT, createBubbles, createLabels, exposePort, fitView, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
43
- // The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
44
- import { lab } from '@homie-rocks/studio/lab';
45
- import tuning from '../tunables.json';
46
-
47
- /* ------------------------------------------------------------------ rules */
48
- const W = 1600;
49
- const H = 1000;
50
- const R_AV = 22;
51
- const R_GEM = 13;
52
- const SPEED = 340;
53
- const BOT_SPEED = 250;
54
- const ROUND_MS = 60_000;
55
- const BREAK_MS = 7_000;
56
- const GEM_COUNT = 14;
57
- const MIN_SLOTS = 3; // 1 human + 2 bots from the first frame
58
- const MAX_SLOTS = 8;
59
- /** The knock's numbers (tunables.json): the file's values, or the Game Lab's sliders while it plays a take. */
60
- const T = lab.tunables(tuning);
61
- const ZONE_MS = 12_000;
62
- /** The contract's 12 colours (PALETTE, NETPLAY.md section 3): a person wears their seat's, so the watch page's strip matches. */
63
- const colourOf = (slot: number, seat: number | null): string => PALETTE[(seat ?? slot) % PALETTE.length] as string;
64
- const BOT_NAMES = ['Rook', 'Vex', 'Moth', 'Kilo', 'Juno', 'Pike', 'Nyx', 'Ash'];
65
- const botName = (slot: number): string => BOT_NAMES[slot % BOT_NAMES.length] as string;
66
- /** An AI's name already ends in " · AI" (the relay sees to it); a plain bot says bot. */
67
- const label = (name: string, bot: boolean): string => (name.endsWith(AI_MARK) ? name : bot ? `${name} · bot` : name);
68
-
69
- /* ------------------------------------------------------------ wire shapes */
70
- /** Snapshot: compact arrays, quantized. ~40 B per body, ~16 B per gem. (Reset epochs ride in the helper's `c` table.) */
71
- type P = [slot: number, seat: number, x: number, y: number, score: number, vx: number, vy: number];
72
- type G = [id: number, x: number, y: number];
73
- interface Snap { r: [n: number, phase: number, startedAt: number, endsAt: number]; p: P[]; g: G[] }
74
- /**
75
- * Replica input: the avatar (owner movement) AND the stick intent (host movement), so either mode reads the same
76
- * frame. The helper stamps the reset epoch the replica has adopted.
77
- */
78
- type Avatar = [x: number, y: number, vx: number, vy: number, mx: number, my: number];
79
- /** A body's bump, while it lasts: its direction (kvx, kvy), where its slide starts (kx, ky) and when (kat, after the hit-stop). */
80
- interface Knock { kvx: number; kvy: number; kx: number; ky: number; kat: number; knockUntil: number }
81
- interface Body extends Knock { slot: number; seat: number | null; name: string; bot: boolean; x: number; y: number; vx: number; vy: number; score: number; tx: number; ty: number }
82
- interface Gem { id: number; x: number; y: number }
83
- /** Slow state, on the keyed state channel (net.state('zone', ...)), not in the 20 Hz snapshot. */
84
- interface Zone { n: number; x: number; y: number; r: number; until: number }
85
- interface Ckpt { round: RoundInfo; bodies: Body[]; gems: Gem[]; roster: Slot[]; tick: number; gemSeq: number; occ?: [number, number][] }
86
-
87
- /* --------------------------------------------------------------- the net */
88
- /**
89
- * `?movement=host` plays host movement: the host moves every body from the seats' stick intents (its rules own
90
- * movement), and each replica PREDICTS its own body by replaying its unacknowledged intents on the host's position.
91
- * Default `owner`: each browser moves its own body and the host bounds it.
92
- */
93
- const MOVEMENT: 'owner' | 'host' = (() => { try { return new URLSearchParams(location.search).get('movement') === 'host' ? 'host' : 'owner'; } catch { return 'owner'; } })();
94
- // caps: its bots read the dial ('skill'), and its Roster takes an AI's slot for it ('agents': the join passes p.agent).
95
- const net = createNetplay<Snap, Avatar, Ckpt>({ game: 'gem-rush', maxPlayers: MAX_SLOTS, movement: MOVEMENT, snapshotHz: 20, inputHz: 20, checkpointMs: 1000, caps: ['skill', 'agents'], checkpoint: () => checkpoint() });
96
- // A touch game guards its own page (NETPLAY.md section 24): a long press on the canvas never selects text or raises
97
- // the copy/paste callout on a phone, and a touch on it never pans or zooms the page.
98
- guardGestures({ touch: 'canvas' });
99
- /** The Roster keeps the server's AI seats (revision 6): the policy is read whenever it fills. */
100
- const policy = () => net.policy;
101
-
102
- /* ------------------------------------------------------------ host state */
103
- let roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
104
- let bodies = new Map<number, Body>();
105
- let gems: Gem[] = [];
106
- let gemSeq = 0;
107
- let round: RoundInfo | null = null;
108
- let zone: Zone | null = null;
109
- let tick = 0;
110
- let hosting = false;
111
- let cheatResets = 0;
112
- let controlResets = 0;
113
- let predictionError = 0;
114
- /** Gems each slot picked up this round (a gem, whatever it scored): the dial's numbers (`pickups` probe). */
115
- let pickups = new Map<number, number>();
116
-
117
- /* ------------------------------------------------ this browser's avatar */
118
- const me = { x: W / 2, y: H / 2, vx: 0, vy: 0, kvx: 0, kvy: 0, kx: 0, ky: 0, kat: 0, knockUntil: 0, has: false };
119
- /** Offline (no shell) plays as a local seat 0, with keys or touch. */
120
- const mySeat = (): number | null => (net.offline ? 0 : net.seat);
121
- /** Whose view to draw: my own seat, or for a watcher the player it follows (null: the whole arena). */
122
- const viewSeat = (): number | null => (net.offline ? 0 : net.viewSeat);
123
-
124
- /* ------------------------------------------------------- replica view */
125
- const drawn = new Map<number, { x: number; y: number; seat: number; score: number; slot: number }>();
126
- const waves: { x: number; y: number; at: number; colour: string; knock?: boolean }[] = [];
127
- let wavesSeen = 0;
128
- let knocksSeen = 0;
129
- let firstStateAt = 0;
130
- let firstSnapAt = 0;
131
- let frames = 0;
132
-
133
- const rnd = (a: number, b: number): number => a + Math.random() * (b - a);
134
- const spawnPoint = (i: number): { x: number; y: number } => {
135
- const a = (i / MAX_SLOTS) * Math.PI * 2;
136
- return { x: W / 2 + Math.cos(a) * 330, y: H / 2 + Math.sin(a) * 250 };
137
- };
138
- const newGem = (): Gem => ({ id: ++gemSeq, x: rnd(60, W - 60), y: rnd(60, H - 60) });
139
-
140
- function bodyFor(slot: Slot): Body {
141
- const sp = spawnPoint(slot.slot);
142
- return { slot: slot.slot, seat: slot.seat, name: slot.name, bot: slot.bot, x: sp.x, y: sp.y, vx: 0, vy: 0, score: 0, tx: sp.x, ty: sp.y, kvx: 0, kvy: 0, kx: sp.x, ky: sp.y, kat: 0, knockUntil: 0 };
143
- }
144
-
145
- function syncBodiesFromRoster(): void {
146
- const seen = new Set<number>();
147
- for (const s of roster.slots) {
148
- seen.add(s.slot);
149
- const b = bodies.get(s.slot);
150
- if (!b) { bodies.set(s.slot, bodyFor(s)); continue; }
151
- b.seat = s.seat; b.name = s.name; b.bot = s.bot;
152
- }
153
- for (const k of [...bodies.keys()]) if (!seen.has(k)) bodies.delete(k);
154
- }
155
-
156
- function publishRoster(): void { net.roster(roster.toJSON()); }
157
-
158
- /** The host moved body `b` itself: tell its owner (a replica adopts it; the host's own body is `me`). */
159
- function hostMoved(b: Body): void {
160
- if (b.bot || b.seat === null) return;
161
- if (b.seat === mySeat()) { me.x = b.x; me.y = b.y; me.vx = 0; me.vy = 0; me.has = true; return; }
162
- net.reset(b.seat);
163
- }
164
-
165
- function newZone(n: number): Zone {
166
- return { n, x: rnd(220, W - 220), y: rnd(200, H - 200), r: 150, until: net.now() + ZONE_MS };
167
- }
168
- function setZone(z: Zone): void { zone = z; net.state('zone', z); }
169
-
170
- function startRound(n: number, rollover = false): void {
171
- const now = net.now();
172
- roster.trim();
173
- // The next round of a running room: the trim is what makes room (the seats a server no longer keeps for AI go
174
- // here), so whoever was seated with no body is given one now, before the bodies are placed.
175
- if (rollover && !net.offline) seatWaiting();
176
- syncBodiesFromRoster();
177
- let i = 0;
178
- for (const b of [...bodies.values()].sort((a, c) => a.slot - c.slot)) {
179
- const sp = spawnPoint(i++);
180
- b.x = sp.x; b.y = sp.y; b.vx = 0; b.vy = 0; b.score = 0; b.tx = sp.x; b.ty = sp.y; b.kvx = 0; b.kvy = 0; b.knockUntil = 0;
181
- hostMoved(b);
182
- }
183
- gems = Array.from({ length: GEM_COUNT }, newGem);
184
- pickups = new Map();
185
- sight.clear();
186
- round = { n, phase: 'live', startedAt: now, endsAt: now + ROUND_MS };
187
- if (lab.stage === 'dummy') stageDummy();
188
- setZone(newZone((zone?.n ?? 0) + 1));
189
- net.round(round);
190
- publishRoster();
191
- net.snapshot(buildSnap(), tick, true);
192
- }
193
-
194
- function endRound(): void {
195
- if (!round) return;
196
- const now = net.now();
197
- const ranked = [...bodies.values()].sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot) || a.slot - b.slot);
198
- const agentSlot = (slot: number): boolean => Boolean(roster.slots.find((x) => x.slot === slot)?.agent);
199
- const results: RoundResult[] = ranked.map((b, i) => ({ slot: b.slot, seat: b.seat, name: b.name, score: b.score, bot: b.bot, place: i + 1, ...(agentSlot(b.slot) ? { agent: true as const } : {}) }));
200
- round = { n: round.n, phase: 'over', startedAt: now, endsAt: now + BREAK_MS, results };
201
- net.round(round);
202
- }
203
-
204
- /* ---------------------------------------------------- becoming the host */
205
- function becomeHost(e: RoleChange<Snap, Ckpt>): void {
206
- hosting = true;
207
- if (e.promoted) restore(e);
208
- else {
209
- roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
210
- bodies = new Map();
211
- const seat = mySeat();
212
- if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);
213
- syncBodiesFromRoster();
214
- startRound((e.round?.n ?? 0) + 1);
215
- }
216
- // Whoever is connected now is who plays: seats that left during the gap become bots.
217
- const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null).map((p) => ({ seat: p.seat, name: p.name, agent: p.agent ?? null, occ: p.occ ?? null }));
218
- // `occ` (NETPLAY.md section 25): which stay in its seat each peer's is, so a seat number that changed hands while
219
- // nobody was hosting is claimed afresh instead of inheriting the last player's body in silence.
220
- const { claimed } = roster.reconcile(peers);
221
- syncBodiesFromRoster();
222
- for (const s of claimed) { const b = bodies.get(s.slot); if (b && b.seat !== mySeat()) hostMoved(b); }
223
- // My own body is wherever my local avatar is: it was client-owned a moment ago and still is.
224
- const mine = mySeat() !== null ? [...bodies.values()].find((b) => b.seat === mySeat()) : undefined;
225
- if (mine && me.has) { mine.x = me.x; mine.y = me.y; }
226
- else if (mine) { me.x = mine.x; me.y = mine.y; me.has = true; }
227
- // The hot zone is keyed state: the relay handed it over with the role.
228
- zone = net.stateOf<Zone>('zone') ?? zone;
229
- if (!zone && round) setZone(newZone(1));
230
- publishRoster();
231
- }
232
-
233
- function restore(e: RoleChange<Snap, Ckpt>): void {
234
- const ck = e.ckpt?.d ?? null;
235
- if (ck) {
236
- roster = Roster.from(ck.roster, { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy }, ck.occ ?? null);
237
- bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
238
- gems = ck.gems.map((g) => ({ ...g }));
239
- gemSeq = ck.gemSeq;
240
- round = ck.round;
241
- tick = ck.tick;
242
- } else {
243
- roster = Roster.from(e.roster ?? [], { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
244
- bodies = new Map();
245
- syncBodiesFromRoster();
246
- }
247
- // The snapshot is newer than the checkpoint (20 Hz vs 1 Hz): positions, scores and gems from it.
248
- const s = e.snap;
249
- if (s && (!e.ckpt || s.st >= e.ckpt.st)) {
250
- for (const [slot, seat, x, y, score] of s.d.p) {
251
- let b = bodies.get(slot);
252
- if (!b) {
253
- const rs0 = roster.slots.find((r) => r.slot === slot) ?? { slot, seat: seat >= 0 ? seat : null, name: seat >= 0 ? `Player ${seat + 1}` : botName(slot), bot: seat < 0 };
254
- b = bodyFor(rs0);
255
- bodies.set(slot, b);
256
- }
257
- b.x = x; b.y = y; b.score = score; b.tx = x; b.ty = y;
258
- }
259
- gems = s.d.g.map(([id, x, y]) => ({ id, x, y }));
260
- gemSeq = Math.max(gemSeq, ...gems.map((g) => g.id));
261
- const [n, ph, startedAt, endsAt] = s.d.r;
262
- round = { n, phase: ph ? 'over' : 'live', startedAt, endsAt, ...(round?.n === n && round.results ? { results: round.results } : {}) };
263
- tick = Math.max(tick, s.k);
264
- }
265
- // A round message the relay kept is at least as authoritative as the checkpoint's copy.
266
- if (e.round && (!round || e.round.n > round.n || (e.round.n === round.n && e.round.phase === 'over' && round.phase === 'live'))) round = e.round;
267
- if (!round) { startRound(1); return; }
268
- net.round(round);
269
- }
270
-
271
- function checkpoint(): Ckpt {
272
- return {
273
- round: round ?? { n: 0, phase: 'live', startedAt: 0, endsAt: 0 },
274
- bodies: [...bodies.values()].map((b) => ({ ...b })),
275
- gems: gems.map((g) => ({ ...g })),
276
- roster: roster.toJSON(),
277
- // Whose stay each body is, beside the roster: how the next host tells a player who came back from a new one.
278
- occ: roster.occupants(),
279
- tick,
280
- gemSeq,
281
- };
282
- }
283
-
284
- net.on('role', (e) => {
285
- if (e.role === 'host') becomeHost(e);
286
- else { hosting = false; }
287
- });
288
- /** Host: a body for somebody the relay seated. False when every body is somebody's (they wait: see seatWaiting). */
289
- function giveBody(p: Peer): boolean {
290
- if (p.seat === null) return false;
291
- // An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).
292
- const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ ?? null);
293
- if (!c) return false;
294
- syncBodiesFromRoster();
295
- const b = bodies.get(c.slot.slot);
296
- // The arriving human takes over the bot's body where it stands, score and all (reset = "adopt this position").
297
- if (b) { b.vx = 0; b.vy = 0; b.kvx = 0; b.kvy = 0; b.knockUntil = 0; hostMoved(b); }
298
- return true;
299
- }
300
- /**
301
- * SEAT WHOEVER IS WAITING (NETPLAY.md section 28). The relay seats a person; this host gives them a body. A claim that
302
- * found every body taken (a server's AI seats were still kept, say) used to be tried once: that player watched for the
303
- * rest of the visit, online, with nothing said. It is tried again whenever a body frees up and at every round start.
304
- */
305
- function seatWaiting(): boolean {
306
- let any = false;
307
- for (const p of net.peers.values()) if (p.seat !== null && !roster.bySeat(p.seat) && giveBody(p)) any = true;
308
- return any;
309
- }
310
- net.on('join', (p) => {
311
- if (!hosting || !giveBody(p)) return;
312
- publishRoster();
313
- net.snapshot(buildSnap(), tick, true);
314
- });
315
- net.on('leave', (p) => {
316
- if (!hosting || p.seat === null) return;
317
- roster.release(p.seat); // their body stays, driven by a bot
318
- syncBodiesFromRoster();
319
- // A body just went to a bot: somebody seated with none takes it now.
320
- const seated = seatWaiting();
321
- publishRoster();
322
- if (seated) net.snapshot(buildSnap(), tick, true);
323
- });
324
- // The server's policy changed: its AI seats come (or go between rounds) at once.
325
- net.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });
326
- net.on('event', (e) => {
327
- if (e.k === 'wave') addWave(e.d as { slot: number });
328
- if (e.k === 'knock') addWave(e.d as { slot: number }, true);
329
- });
330
- net.on('round', (r) => { round = hosting ? round : r; });
331
- net.on('state', (e) => { if (e.k === 'zone' && !hosting) zone = (e.d as Zone | null) ?? null; if (!firstStateAt) firstStateAt = performance.now(); });
332
- net.on('snapshot', () => { if (!firstSnapAt) firstSnapAt = performance.now(); });
333
- /** My body's control changed: on a reset, stand where the host put me (a new round, a takeover, a knockback's end). */
334
- net.on('control', (e) => {
335
- if (!e.reset) return;
336
- controlResets += 1;
337
- const p = (e.snap as Snapshot<Snap>).d.p.find((x) => x[1] === net.seat);
338
- if (p) { me.x = p[2]; me.y = p[3]; me.vx = 0; me.vy = 0; me.has = true; }
339
- });
340
-
341
- /* ----------------------------------------------------------------- input */
342
- const keys = new Set<string>();
343
- addEventListener('keydown', (e) => {
344
- if (['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) e.preventDefault();
345
- if (e.code === 'Space' && !keys.has('Space')) wave();
346
- keys.add(e.code);
347
- });
348
- addEventListener('keyup', (e) => keys.delete(e.code));
349
- addEventListener('blur', () => keys.clear());
350
-
351
- const stick = { id: -1, ox: 0, oy: 0, x: 0, y: 0, active: false };
352
- const canvas = document.getElementById('c') as HTMLCanvasElement;
353
- const ctx = canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D;
354
- canvas.addEventListener('pointerdown', (e) => {
355
- if (e.pointerType === 'mouse') return;
356
- if (stick.active && e.pointerId !== stick.id) { wave(); return; } // a second finger waves
357
- stick.id = e.pointerId; stick.ox = e.clientX; stick.oy = e.clientY; stick.x = e.clientX; stick.y = e.clientY; stick.active = true;
358
- canvas.setPointerCapture(e.pointerId);
359
- });
360
- canvas.addEventListener('pointermove', (e) => { if (e.pointerId === stick.id) { stick.x = e.clientX; stick.y = e.clientY; } });
361
- const endStick = (e: PointerEvent): void => { if (e.pointerId === stick.id) { stick.active = false; stick.id = -1; } };
362
- canvas.addEventListener('pointerup', endStick);
363
- canvas.addEventListener('pointercancel', endStick);
364
-
365
- function moveVector(): { x: number; y: number } {
366
- let x = 0; let y = 0;
367
- if (keys.has('KeyA') || keys.has('ArrowLeft')) x -= 1;
368
- if (keys.has('KeyD') || keys.has('ArrowRight')) x += 1;
369
- if (keys.has('KeyW') || keys.has('ArrowUp')) y -= 1;
370
- if (keys.has('KeyS') || keys.has('ArrowDown')) y += 1;
371
- if (stick.active) {
372
- const dx = stick.x - stick.ox; const dy = stick.y - stick.oy;
373
- const len = Math.hypot(dx, dy);
374
- if (len > 6) { const m = Math.min(1, len / 56); x += (dx / len) * m; y += (dy / len) * m; }
375
- }
376
- const len = Math.hypot(x, y);
377
- return len > 1 ? { x: x / len, y: y / len } : { x, y };
378
- }
379
-
380
- function wave(): void {
381
- const seat = mySeat();
382
- if (seat === null) return;
383
- if (hosting) {
384
- const b = [...bodies.values()].find((x) => x.seat === seat);
385
- if (b) hostWave(b);
386
- } else net.press('wave');
387
- }
388
- /** Host: a wave rings out, and knocks back every body within reach (bots, the host itself, and replicas). */
389
- function hostWave(from: Body): void {
390
- addWave({ slot: from.slot }); net.send('wave', { slot: from.slot });
391
- for (const b of bodies.values()) {
392
- if (b === from || Math.hypot(b.x - from.x, b.y - from.y) > T.knockRange) continue;
393
- knock(b, from.x, from.y, from.slot);
394
- }
395
- }
396
- /**
397
- * Host: knock a body back. The hit LANDS first: for T.hitStopMs the body holds where it was hit (it flashes, squashes
398
- * and shakes on every screen), then it SLIDES T.knockDistance along the hit, fast at first and easing into the stop
399
- * (T.knockEase), so it arrives at rest, the same distance at any frame rate, and its owner steers again with no pop.
400
- * A replica's body is TAKEN for the whole bump (its owner's avatar frames are ignored; the host drives it; the owner
401
- * draws its body from snapshots), then the helper GIVES it back with a reset, so the owner stands where it ended.
402
- */
403
- function knock(b: Body, fx: number, fy: number, by: number): void {
404
- let dx = b.x - fx; let dy = b.y - fy;
405
- const len = Math.hypot(dx, dy);
406
- if (len < 1) { const a = Math.random() * Math.PI * 2; dx = Math.cos(a); dy = Math.sin(a); } else { dx /= len; dy /= len; }
407
- const at = net.now() + T.hitStopMs;
408
- const o: Knock & { x: number; y: number } = !b.bot && b.seat !== null && b.seat === mySeat() ? me : b;
409
- o.kvx = dx; o.kvy = dy; o.kx = o.x; o.ky = o.y; o.kat = at; o.knockUntil = at + T.knockMs;
410
- if (o === b && !b.bot && b.seat !== null) net.take(b.seat, T.hitStopMs + T.knockMs);
411
- labKnock(b);
412
- const d = { slot: b.slot, dx: q(dx, 2), dy: q(dy, 2), by };
413
- addWave(d, true); net.send('knock', d);
414
- }
415
-
416
- /** Where a bumped body is now: held through the hit-stop, then eased out along the hit. Sets its velocity too. */
417
- function slide(o: Knock & { x: number; y: number; vx: number; vy: number }, now: number, dt: number): void {
418
- const u = Number.isFinite(o.kat) ? Math.max(0, Math.min(1, (now - o.kat) / Math.max(1, T.knockMs))) : 1;
419
- const e = 1 - (1 - u) ** Math.max(1, T.knockEase);
420
- const x = Math.max(R_AV, Math.min(W - R_AV, o.kx + o.kvx * T.knockDistance * e));
421
- const y = Math.max(R_AV, Math.min(H - R_AV, o.ky + o.kvy * T.knockDistance * e));
422
- if (u >= 1) { o.vx = 0; o.vy = 0; } else if (dt > 0) { o.vx = (x - o.x) / dt; o.vy = (y - o.y) / dt; }
423
- o.x = x; o.y = y;
424
- }
425
- /** The frame a bump is over: the body lands exactly where its slide ends, at rest (at 12 fps the last step is long). */
426
- function landed(o: Knock & { x: number; y: number; vx: number; vy: number }): void {
427
- if (!o.kat) return;
428
- slide(o, o.kat + T.knockMs, 0);
429
- o.kat = 0;
430
- }
431
- function addWave(d: { slot: number; dx?: number; dy?: number; by?: number }, isKnock = false): void {
432
- const pos = hosting ? bodies.get(d.slot) : [...drawn.values()].find((x) => x.slot === d.slot);
433
- // The action, for a watcher on Auto: whoever waved (a person, never a bot).
434
- const waver = pos && 'bot' in pos ? (pos.bot ? null : pos.seat) : pos && pos.seat >= 0 ? pos.seat : null;
435
- if (!isKnock) net.spotlight(waver);
436
- const mineSlot = hosting ? [...bodies.values()].find((x) => x.seat !== null && x.seat === mySeat())?.slot : [...drawn.values()].find((x) => x.seat === net.seat)?.slot;
437
- const at = mineSlot === d.slot && me.has ? me : pos;
438
- if (!at) return;
439
- waves.push({ x: at.x, y: at.y, at: performance.now(), colour: isKnock ? '#ffffff' : PALETTE[d.slot % PALETTE.length] as string, knock: isKnock });
440
- if (isKnock) knocksSeen += 1; else wavesSeen += 1;
441
- if (isKnock) bumped(d, at); else pushes.set(d.slot, performance.now());
442
- }
443
-
444
- /* ---------------------------------------------------------------- simulate */
445
- /** The one movement rule, shared by the owner, the host (host movement) and a replica's prediction. */
446
- function integrate(o: { x: number; y: number; vx: number; vy: number }, mx: number, my: number, dt: number): void {
447
- mx = Number(mx) || 0; my = Number(my) || 0;
448
- const len = Math.hypot(mx, my);
449
- if (len > 1) { mx /= len; my /= len; } // an intent is at most full stick: no speed hack through intents
450
- const k = Math.min(1, dt * 14);
451
- o.vx += (mx * SPEED - o.vx) * k;
452
- o.vy += (my * SPEED - o.vy) * k;
453
- o.x = Math.max(R_AV, Math.min(W - R_AV, o.x + o.vx * dt));
454
- o.y = Math.max(R_AV, Math.min(H - R_AV, o.y + o.vy * dt));
455
- }
456
-
457
- function stepMe(dt: number): void {
458
- const seat = mySeat();
459
- if (seat === null) { me.has = false; return; }
460
- // Host-driven (host movement, or knocked back on a replica): stepReplica predicts me instead.
461
- if (!net.owned) return;
462
- if (me.knockUntil > net.now()) { slide(me, net.now(), dt); return; }
463
- landed(me);
464
- const v = moveVector();
465
- integrate(me, v.x, v.y, dt);
466
- }
467
-
468
- /**
469
- * Replica, host-driven body. My stick moves me locally every frame (instant response), with the same rule the host
470
- * runs. When a new snapshot lands: start from where the host has me, replay every intent it had not acknowledged,
471
- * and pull toward that by 30% (or jump, if it is far). The host stays the truth; I never wait for it.
472
- */
473
- let predictedK = -1;
474
- function predictMe(dt: number): void {
475
- const v = moveVector();
476
- integrate(me, v.x, v.y, dt);
477
- const snap = net.latest();
478
- if (!snap || snap.k === predictedK) return;
479
- predictedK = snap.k;
480
- const p = snap.d.p.find((x) => x[1] === net.seat);
481
- if (!p) return;
482
- const pred = { x: p[2], y: p[3], vx: p[5] ?? 0, vy: p[6] ?? 0 };
483
- for (const f of net.pending()) {
484
- let left = Math.min(0.25, f.dt / 1000);
485
- while (left > 0) { const h = Math.min(left, 1 / 60); integrate(pred, f.a[4], f.a[5], h); left -= h; }
486
- }
487
- const err = Math.hypot(pred.x - me.x, pred.y - me.y);
488
- predictionError = err;
489
- if (!me.has || err > 150) { me.x = pred.x; me.y = pred.y; me.vx = pred.vx; me.vy = pred.vy; }
490
- else { me.x += (pred.x - me.x) * 0.3; me.y += (pred.y - me.y) * 0.3; }
491
- me.has = true;
492
- }
493
-
494
- function stepKnocked(b: Body, dt: number): void { slide(b, net.now(), dt); }
495
-
496
- /**
497
- * THE BOTS, AT THE ROOM'S DIAL (NETPLAY.md section 17). A bot re-reads the world only every `reactionMs` (what it
498
- * last noticed is where it heads, and a miss costs it that long again), aims up to 200 px off at aimNoise 1, leaves
499
- * the hot zone to the people at positioning 0 and fights for it at 1, and waves at a rival in reach about
500
- * `aggression x 0.8` times a second. Rookie is beatable by anyone, Maxed by few: measured with the
501
- * `pickups` probe over 60 s rounds, a Maxed bot picks up a little over twice the gems of a Rookie.
502
- */
503
- const sight = new Map<number, { at: number; tx: number; ty: number; arrived?: boolean }>();
504
-
505
- function stepBots(dt: number): void {
506
- const taken = new Set<number>();
507
- const now = net.now();
508
- for (const b of bodies.values()) {
509
- if (!b.bot) continue;
510
- if (b.knockUntil > now) { stepKnocked(b, dt); continue; }
511
- landed(b);
512
- if (lab.stage === 'dummy') { standStill(b, dt); continue; }
513
- const s: Skill = net.skillOf(b.slot); // Fair when nobody set a dial
514
- let eye = sight.get(b.slot);
515
- if (!eye || now - eye.at >= s.reactionMs) { // REACTION TIME
516
- const g = pickGem(b, taken, s); // POSITIONING
517
- const miss = 200 * s.aimNoise; // AIM NOISE: up to 200 px off at 1
518
- eye = { at: now, tx: g ? g.x + (Math.random() - 0.5) * miss : b.tx, ty: g ? g.y + (Math.random() - 0.5) * miss : b.ty };
519
- sight.set(b.slot, eye);
520
- if (g) taken.add(g.id);
521
- }
522
- b.tx = eye.tx; b.ty = eye.ty;
523
- const rival = nearestBody(b, T.knockRange); // AGGRESSION: waves at a rival in reach
524
- if (rival && round?.phase === 'live' && Math.random() < s.aggression * 0.8 * dt) hostWave(b);
525
- const dx = b.tx - b.x; const dy = b.ty - b.y; const dist = Math.hypot(dx, dy);
526
- const len = dist || 1;
527
- // Arrived where it aimed: it stands there, and notices what it missed only a reaction later.
528
- if (dist < 6 && !eye.arrived) { eye.arrived = true; eye.at = now; }
529
- const speed = dist < 6 ? 0 : BOT_SPEED;
530
- const k = Math.min(1, dt * 5);
531
- b.vx += ((dx / len) * speed - b.vx) * k;
532
- b.vy += ((dy / len) * speed - b.vy) * k;
533
- b.x = Math.max(R_AV, Math.min(W - R_AV, b.x + b.vx * dt));
534
- b.y = Math.max(R_AV, Math.min(H - R_AV, b.y + b.vy * dt));
535
- }
536
- }
537
-
538
- /** Positioning: 0 leaves the hot zone to the people, 1 fights for it. */
539
- function pickGem(b: Body, taken: Set<number>, s: Skill): Gem | null {
540
- let best: Gem | null = null; let bd = Infinity;
541
- for (const g of gems) {
542
- if (taken.has(g.id)) continue;
543
- const hot = zone !== null && Math.hypot(g.x - zone.x, g.y - zone.y) < zone.r;
544
- const d = Math.hypot(g.x - b.x, g.y - b.y) * (hot ? 1.5 - s.positioning : 1);
545
- if (d < bd) { bd = d; best = g; }
546
- }
547
- return best;
548
- }
549
-
550
- /** The nearest other body within `range` (a bot's rival), or null. */
551
- function nearestBody(b: Body, range: number): Body | null {
552
- let best: Body | null = null; let bd = range;
553
- for (const o of bodies.values()) {
554
- if (o === b) continue;
555
- const d = Math.hypot(o.x - b.x, o.y - b.y);
556
- if (d < bd) { bd = d; best = o; }
557
- }
558
- return best;
559
- }
560
-
561
- function stepHost(dt: number): void {
562
- tick += 1;
563
- const seat = mySeat();
564
- const now0 = net.now();
565
- for (const b of bodies.values()) {
566
- if (b.bot) continue;
567
- if (b.seat === seat) {
568
- me.has = true;
569
- b.x = me.x; b.y = me.y; b.vx = me.vx; b.vy = me.vy;
570
- continue;
571
- }
572
- if (b.seat === null) continue;
573
- const ctl = net.control(b.seat);
574
- if (ctl.taken || b.knockUntil > now0) { stepKnocked(b, dt); if (net.takePresses(b.seat)['wave']) hostWave(b); continue; } // the host drives the knockback
575
- if (MOVEMENT === 'host') {
576
- // Host movement: the seat sends intents; the rules move the body.
577
- const f = net.inputOf(b.seat);
578
- integrate(b, f ? Number(f.a?.[4]) : 0, f ? Number(f.a?.[5]) : 0, dt);
579
- if (net.takePresses(b.seat)['wave']) hostWave(b);
580
- continue;
581
- }
582
- const a = net.avatar(b.seat); // null while taken, or until the owner has adopted the last reset
583
- if (a && Array.isArray(a)) {
584
- // Client-owned movement, bounded: to the arena, and to 1.3x top speed per frame. A legitimate avatar catches
585
- // up within a frame; a teleport crawls, and a claim more than half a second of running away is reset.
586
- const claim = { x: Math.max(R_AV, Math.min(W - R_AV, Number(a[0]))), y: Math.max(R_AV, Math.min(H - R_AV, Number(a[1]))) };
587
- const m = capMove(b, claim, SPEED * 1.3 * dt + 6);
588
- if (m.over > SPEED * 0.5) { net.reset(b.seat); cheatResets += 1; }
589
- else { b.x = m.x; b.y = m.y; b.vx = Number(a[2]) || 0; b.vy = Number(a[3]) || 0; }
590
- }
591
- if (net.takePresses(b.seat)['wave']) hostWave(b);
592
- }
593
- stepBots(dt);
594
- const now = net.now();
595
- if (round && round.phase === 'live') {
596
- if (!zone || now >= zone.until) setZone(newZone((zone?.n ?? 0) + 1));
597
- for (const b of bodies.values()) {
598
- for (let i = 0; i < gems.length; i += 1) {
599
- const g = gems[i] as Gem;
600
- if (Math.hypot(g.x - b.x, g.y - b.y) < R_AV + R_GEM) {
601
- b.score += zone && Math.hypot(g.x - zone.x, g.y - zone.y) < zone.r ? 2 : 1;
602
- pickups.set(b.slot, (pickups.get(b.slot) ?? 0) + 1);
603
- gems[i] = newGem();
604
- }
605
- }
606
- }
607
- if (now >= round.endsAt) endRound();
608
- } else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1, true);
609
- if (net.snapshotDue()) net.snapshot(buildSnap(), tick);
610
- }
611
-
612
- function buildSnap(): Snap {
613
- const r = round ?? { n: 0, phase: 'live', startedAt: 0, endsAt: 0 };
614
- return {
615
- r: [r.n, r.phase === 'over' ? 1 : 0, r.startedAt, r.endsAt],
616
- p: [...bodies.values()].map((b) => [b.slot, b.seat ?? -1, q(b.x, 1), q(b.y, 1), b.score, q(b.vx, 0), q(b.vy, 0)] as P),
617
- g: gems.map((g) => [g.id, q(g.x, 0), q(g.y, 0)] as G),
618
- };
619
- }
620
-
621
- function stepReplica(dt: number): void {
622
- const seat = net.seat;
623
- if (seat !== null) {
624
- // Host-driven (host movement, or taken for a knockback): predict from the host's position and my intents.
625
- if (!net.owned) predictMe(dt);
626
- // Only once I have a body (the first control event adopts it): before that my position is nobody's.
627
- if (me.has) {
628
- const v = moveVector();
629
- // Stick direction as held "buttons": a new direction is a press edge, so it is sent within 16 ms.
630
- const held = [v.x > 0.2 ? 'R' : v.x < -0.2 ? 'L' : '', v.y > 0.2 ? 'D' : v.y < -0.2 ? 'U' : ''].filter(Boolean);
631
- net.input([q(me.x, 1), q(me.y, 1), q(me.vx, 0), q(me.vy, 0), q(v.x, 2), q(v.y, 2)], held);
632
- }
633
- }
634
- const smp = net.sample();
635
- drawn.clear();
636
- if (!smp) return;
637
- const bySlot = new Map<number, P>(smp.a.d.p.map((p) => [p[0], p]));
638
- for (const pb of smp.b.d.p) {
639
- const pa = bySlot.get(pb[0]) ?? pb;
640
- drawn.set(pb[0], { slot: pb[0], seat: pb[1], x: lerp(pa[2], pb[2], smp.alpha), y: lerp(pa[3], pb[3], smp.alpha), score: pb[4] });
641
- }
642
- const [n, ph, startedAt, endsAt] = smp.b.d.r;
643
- if (!round || round.n !== n || (round.phase === 'over') !== (ph === 1)) {
644
- const kept = net.roundInfo && net.roundInfo.n === n ? net.roundInfo : null;
645
- round = kept ?? { n, phase: ph ? 'over' : 'live', startedAt, endsAt };
646
- }
647
- }
648
-
649
- /* -------------------------------------------------------------- how a bump looks */
650
- /*
651
- * Every screen draws a bump from the knock event (the host's own, or the one it sends): the hit lands (the body flashes
652
- * white and holds, squashed against the hit, shaking), flies (stretched along the hit, less as it slows), stops
653
- * (squashed, then a wobble that dies away: overlap), throws sparks, and kicks the camera of whoever was in it. The
654
- * waver's own body pushes out a little when it waves. Sparks and the camera's kick roll lab.random(): their own dice, so
655
- * the world's (Math.random) stay the same as a build without them, which the Game Lab needs to compare the two.
656
- */
657
- const bumps = new Map<number, { at: number; dx: number; dy: number }>();
658
- const pushes = new Map<number, number>();
659
- const sparks: { x: number; y: number; vx: number; vy: number; at: number; life: number }[] = [];
660
- let kickAt = -1e9;
661
- function bumped(d: { slot: number; dx?: number; dy?: number; by?: number }, at: { x: number; y: number }): void {
662
- const now = performance.now();
663
- const dx = Number(d.dx) || 0; const dy = Number(d.dy) || 0;
664
- bumps.set(d.slot, { at: now, dx, dy });
665
- const base = Math.atan2(dy, dx);
666
- for (let i = 0; i < T.sparks; i += 1) {
667
- const a = base + (lab.random() - 0.5) * 1.6;
668
- const v = 420 + lab.random() * 520;
669
- 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 });
670
- }
671
- if (sparks.length > 160) sparks.splice(0, sparks.length - 160);
672
- // The camera kicks for whoever was in it: the body bumped, or the one who waved.
673
- const mine = hosting ? [...bodies.values()].find((x) => x.seat !== null && x.seat === mySeat())?.slot : [...drawn.values()].find((x) => x.seat === net.seat)?.slot;
674
- if (mine !== undefined && (d.slot === mine || d.by === mine)) kickAt = now;
675
- }
676
- /** A body's look this frame: offset, stretch along an angle, and how white it flashes. */
677
- function bodyFx(slot: number, t: number): { ox: number; oy: number; sx: number; sy: number; ang: number; flash: number } {
678
- const out = { ox: 0, oy: 0, sx: 1, sy: 1, ang: 0, flash: 0 };
679
- const p = pushes.get(slot);
680
- 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; } }
681
- const b = bumps.get(slot);
682
- if (!b) return out;
683
- const age = t - b.at;
684
- const hs = T.hitStopMs; const end = hs + T.knockMs;
685
- if (age > end + T.settleMs + 200) { bumps.delete(slot); return out; }
686
- out.ang = Math.atan2(b.dy, b.dx);
687
- out.flash = T.flashMs > 0 ? Math.max(0, 1 - age / T.flashMs) : 0;
688
- let k = 0;
689
- if (age < hs) { k = -T.squash * 0.45; out.ox = Math.sin(age * 0.9) * 3; out.oy = Math.cos(age * 1.3) * 3; }
690
- else if (age < end) k = T.squash * (1 - (age - hs) / T.knockMs) ** Math.max(0, T.knockEase - 1);
691
- 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); }
692
- out.sx *= 1 + k; out.sy *= 1 / (1 + k);
693
- return out;
694
- }
695
- function drawSparks(t: number, scale: number): void {
696
- ctx.lineCap = 'round';
697
- for (let i = sparks.length - 1; i >= 0; i -= 1) {
698
- const sp = sparks[i] as (typeof sparks)[number];
699
- const age = t - sp.at;
700
- if (age > sp.life) { sparks.splice(i, 1); continue; }
701
- if (age < T.hitStopMs * 0.5) continue;
702
- const u = age / sp.life; const s = age / 1000 * (1 - u * 0.5);
703
- const x = sp.x + sp.vx * s; const y = sp.y + sp.vy * s;
704
- ctx.strokeStyle = u < 0.4 ? '#ffffff' : '#ffd166'; ctx.globalAlpha = 1 - u; ctx.lineWidth = (2.5 + 4 * (1 - u)) / Math.max(0.5, scale);
705
- ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x - sp.vx * 0.022 * (1 - u), y - sp.vy * 0.022 * (1 - u)); ctx.stroke();
706
- }
707
- ctx.globalAlpha = 1; ctx.lineCap = 'butt';
708
- }
709
-
710
- /* -------------------------------------------------------------- the Game Lab */
711
- /*
712
- * What the lab shows of a knock (lab.json's take "knock"): the body bumped last is the subject. Every frame its phase,
713
- * its speed and its distance from where it was hit go to the lab, and its pose feeds the onion skin and the spacing
714
- * arc (dots far apart: fast; close together: slow). Only in the lab: outside it, labKnock and labReport never run.
715
- */
716
- type Pose = { x: number; y: number; sx?: number; sy?: number; a?: number };
717
- /**
718
- * The take's stage "dummy" (lab.stage, only ever set by the lab): you stand left of the middle with a bot a short step
719
- * to your right, and every bot stands still unless it is bumped, like a training dummy. The knock alone, every time.
720
- */
721
- function stageDummy(): void {
722
- const seat = mySeat();
723
- const list = [...bodies.values()].sort((a, c) => a.slot - c.slot);
724
- const mine = list.find((b) => !b.bot && b.seat === seat);
725
- const bots = list.filter((b) => b.bot);
726
- if (mine) { mine.x = W / 2 - 160; mine.y = H / 2; hostMoved(mine); }
727
- bots.forEach((b, i) => { b.x = i === 0 ? W / 2 - 160 + 92 : W - 180; b.y = i === 0 ? H / 2 : 150 + (i - 1) * 700; b.tx = b.x; b.ty = b.y; });
728
- }
729
- /** A dummy at rest: it eases to a stop wherever the last bump left it. */
730
- function standStill(b: Body, dt: number): void {
731
- const k = Math.min(1, dt * 5);
732
- b.vx -= b.vx * k; b.vy -= b.vy * k;
733
- b.x = Math.max(R_AV, Math.min(W - R_AV, b.x + b.vx * dt)); b.y = Math.max(R_AV, Math.min(H - R_AV, b.y + b.vy * dt));
734
- }
735
- const subject = { slot: -1, at: 0, x0: 0, y0: 0, px: 0, py: 0, has: false };
736
- function labKnock(b: Body): void {
737
- if (!lab.on) return;
738
- subject.slot = b.slot; subject.at = net.now(); subject.x0 = b.x; subject.y0 = b.y; subject.px = b.x; subject.py = b.y; subject.has = true;
739
- }
740
- /** Where the subject is now (the host's own body is `me`), or null. */
741
- function subjectAt(): Pose | null {
742
- if (!subject.has) return null;
743
- const b = bodies.get(subject.slot);
744
- if (!b) return null;
745
- return !b.bot && b.seat !== null && b.seat === mySeat() ? me : b;
746
- }
747
- function labReport(dt: number): void {
748
- const at = subjectAt();
749
- if (!at || dt <= 0) { lab.phase(null); return; }
750
- const age = net.now() - subject.at;
751
- lab.track('speed', Math.hypot(at.x - subject.px, at.y - subject.py) / dt, 'px/s');
752
- lab.track('distance', Math.hypot(at.x - subject.x0, at.y - subject.y0), 'px');
753
- subject.px = at.x; subject.py = at.y;
754
- const fx = bodyFx(subject.slot, performance.now());
755
- lab.track('stretch', (fx.sx - 1) * 100, '%');
756
- if (age < T.hitStopMs) lab.phase('HIT-STOP', 'The hit lands: hold, flash, squash');
757
- else if (age < T.hitStopMs + T.knockMs * 0.3) lab.phase('LAUNCH', 'Leaves fast, stretched along the hit');
758
- else if (age < T.hitStopMs + T.knockMs) lab.phase('SLIDE', 'Eases into the stop: no creep, no pop');
759
- else if (age < T.hitStopMs + T.knockMs + T.settleMs) lab.phase('SETTLE', 'Squash on the stop, overlap on the way out');
760
- else lab.phase(null);
761
- lab.pose('subject', { x: at.x, y: at.y, sx: fx.sx, sy: fx.sy, a: fx.ang });
762
- }
763
- /** The lab's views: the game's own camera, close on the knock, the whole arena. */
764
- const labView = lab.camera<{ zoom?: number; whole?: boolean } | null>({ game: null, close: { zoom: 2.4 }, arena: { whole: true } });
765
- lab.overlay('onion', (c, k) => {
766
- const g = c as CanvasRenderingContext2D; const s = Number(k) || 1;
767
- const ghosts = lab.past<Pose>('subject', 8, 3);
768
- 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(); });
769
- g.globalAlpha = 1;
770
- });
771
- lab.overlay('arcs', (c, k) => {
772
- const g = c as CanvasRenderingContext2D; const s = Number(k) || 1;
773
- const pts = lab.past<Pose>('subject', 90, 1);
774
- if (pts.length < 2) return;
775
- g.strokeStyle = 'rgba(124,196,255,.55)'; g.lineWidth = 1.5 / s;
776
- g.beginPath(); pts.forEach((p, i) => (i ? g.lineTo(p.x, p.y) : g.moveTo(p.x, p.y))); g.stroke();
777
- g.fillStyle = '#7cc4ff';
778
- for (const p of pts) { g.beginPath(); g.arc(p.x, p.y, 2.5 / s, 0, Math.PI * 2); g.fill(); }
779
- });
780
- lab.overlay('reach', (c, k) => {
781
- if (!me.has) return;
782
- const g = c as CanvasRenderingContext2D; const s = Number(k) || 1;
783
- g.strokeStyle = 'rgba(255,255,255,.35)'; g.lineWidth = 1.5 / s; g.setLineDash([8 / s, 6 / s]);
784
- g.beginPath(); g.arc(me.x, me.y, T.knockRange, 0, Math.PI * 2); g.stroke(); g.setLineDash([]);
785
- });
786
-
787
- /* ------------------------------------------------------------------ draw */
788
- let dpr = 1;
789
- function resize(): void {
790
- dpr = Math.min(2, devicePixelRatio || 1);
791
- canvas.width = Math.round(innerWidth * dpr);
792
- canvas.height = Math.round(innerHeight * dpr);
793
- }
794
- addEventListener('resize', resize);
795
- resize();
796
-
797
- const cam = { x: W / 2, y: H / 2, scale: 0 };
798
- const labels = createLabels({ screen: () => ({ w: innerWidth, h: innerHeight }) });
799
- // 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
800
- // over their body for a few seconds, when they want it there. The studio taking a message down takes its bubble too.
801
- const bubbles = createBubbles({ measure: (t) => { ctx.font = BUBBLE_FONT; return ctx.measureText(t).width; }, screen: () => ({ w: innerWidth, h: innerHeight }) });
802
- net.on('say', (s) => bubbles.say(s.seat, s.text, { id: s.id, kind: s.kind }));
803
- net.on('unchat', (e) => { for (const id of e.ids) bubbles.remove(id); });
804
- let shownBubbles: BubbleOut[] = [];
805
- let shownLabels: LabelOut[] = [];
806
- let lastDraw = 0;
807
- /** Where a seat's body is drawn now (host: the real body; replica: interpolated), or null. */
808
- function seatPos(seat: number): { x: number; y: number } | null {
809
- if (seat === mySeat() && me.has && !net.watching) return me;
810
- if (hosting) { const b = [...bodies.values()].find((x) => !x.bot && x.seat === seat); return b ? { x: b.x, y: b.y } : null; }
811
- const d = [...drawn.values()].find((x) => x.seat === seat);
812
- return d ? { x: d.x, y: d.y } : null;
813
- }
814
- const gemGlow = (() => {
815
- const g = ctx.createRadialGradient(0, 0, 0, 0, 0, R_GEM * 2.8);
816
- 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)');
817
- return g;
818
- })();
819
- function draw(t: number): void {
820
- const cw = innerWidth; const ch = innerHeight;
821
- const phone = Math.min(cw, ch) <= 540;
822
- // The view: my own body, or (a watcher) the followed player's, drawn exactly as their own browser frames it.
823
- const view = viewSeat();
824
- const followed = net.watching && view !== null ? seatPos(view) : null;
825
- const overview = net.watching ? !followed : mySeat() === null;
826
- // The camera: close on the body whose view this is, never past the arena's edge (except as far as it takes to keep
827
- // that body clear of the HUD), and never so far out that an empty band shows beside the arena (held upright, a
828
- // phone showed the arena as a band with a third of the screen dark below or above it; now the arena fills the
829
- // screen). Following nobody: the whole arena.
830
- const focus = net.watching ? followed : overview || !me.has ? null : me;
831
- // The Game Lab's views (outside the lab: the game's own camera, always).
832
- const lv = labView();
833
- const want = overview || lv?.whole ? { scale: Math.min(cw / (W + 80), ch / (H + 80)), x: W / 2, y: H / 2 }
834
- : 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 } });
835
- // A watcher's switch glides: the zoom and the pan ease over a few frames, never a cut.
836
- cam.scale = cam.scale ? cam.scale + (want.scale - cam.scale) * 0.12 : want.scale;
837
- const scale = cam.scale;
838
- cam.x += (want.x - cam.x) * 0.2; cam.y += (want.y - cam.y) * 0.2;
839
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
840
- const bg = ctx.createRadialGradient(cw / 2, ch / 2, 0, cw / 2, ch / 2, Math.max(cw, ch) * 0.8);
841
- bg.addColorStop(0, '#0d1426'); bg.addColorStop(1, '#04060c');
842
- ctx.fillStyle = bg; ctx.fillRect(0, 0, cw, ch);
843
-
844
- // The camera's kick (a bump you were in): a few px, gone in a fifth of a second.
845
- const kick = t - kickAt < 200 ? T.shake * (1 - (t - kickAt) / 200) ** 2 : 0;
846
- ctx.save();
847
- 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);
848
- // arena
849
- ctx.strokeStyle = 'rgba(125,240,255,0.07)'; ctx.lineWidth = 1 / scale;
850
- ctx.beginPath();
851
- for (let x = 0; x <= W; x += 100) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
852
- for (let y = 0; y <= H; y += 100) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
853
- ctx.stroke();
854
- ctx.strokeStyle = 'rgba(125,240,255,0.55)'; ctx.lineWidth = 4; ctx.strokeRect(0, 0, W, H);
855
- // hot zone (keyed state): gems inside score double
856
- if (zone) {
857
- const pulse = 0.5 + 0.5 * Math.sin(t / 300);
858
- 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();
859
- ctx.strokeStyle = 'rgba(255,140,200,0.6)'; ctx.lineWidth = 3; ctx.setLineDash([14, 10]); ctx.stroke(); ctx.setLineDash([]);
860
- ctx.fillStyle = 'rgba(255,170,215,0.85)'; ctx.font = '700 16px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center';
861
- ctx.fillText('×2', zone.x, zone.y + 6);
862
- }
863
-
864
- // gems
865
- const gemList: { x: number; y: number; id: number }[] = hosting ? gems : (net.latest()?.d.g ?? []).map(([id, x, y]) => ({ id, x, y }));
866
- for (const g of gemList) {
867
- const spin = t / 500 + g.id;
868
- const r = R_GEM * (1 + 0.12 * Math.sin(t / 200 + g.id));
869
- ctx.save(); ctx.translate(g.x, g.y);
870
- ctx.globalCompositeOperation = 'lighter';
871
- ctx.fillStyle = gemGlow; ctx.beginPath(); ctx.arc(0, 0, r * 2.6, 0, Math.PI * 2); ctx.fill();
872
- ctx.globalCompositeOperation = 'source-over';
873
- ctx.rotate(spin);
874
- 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();
875
- ctx.restore();
876
- }
877
-
878
- // waves
879
- for (let i = waves.length - 1; i >= 0; i -= 1) {
880
- const w = waves[i] as (typeof waves)[number];
881
- const age = (t - w.at) / T.ringMs;
882
- if (age > 1) { waves.splice(i, 1); continue; }
883
- ctx.strokeStyle = w.colour; ctx.globalAlpha = 1 - age; ctx.lineWidth = w.knock ? 3 : 5;
884
- 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;
885
- }
886
-
887
- // avatars
888
- const names = new Map<number, Slot>((net.slots ?? roster.slots).map((s) => [s.slot, s]));
889
- // `mine`: the body whose view this is: my own, or the player a watcher follows (named, never "You").
890
- const list: { slot: number; seat: number | null; x: number; y: number; bot: boolean; name: string; mine: boolean }[] = [];
891
- const isView = (seat: number | null, bot: boolean): boolean => !bot && seat !== null && seat === view;
892
- if (hosting) {
893
- for (const b of bodies.values()) list.push({ slot: b.slot, seat: b.seat, x: b.x, y: b.y, bot: b.bot, name: b.name, mine: isView(b.seat, b.bot) });
894
- } else {
895
- for (const d of drawn.values()) {
896
- const own = !net.watching && d.seat >= 0 && d.seat === net.seat;
897
- const s = names.get(d.slot);
898
- 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) });
899
- }
900
- }
901
- // Names go on after the arena, on the screen, so they read at one size on any screen and never pile up.
902
- const fs = Math.round(Math.max(15, Math.min(22, (15 * Math.min(cw, ch)) / 720)));
903
- ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;
904
- const tags: LabelIn[] = [];
905
- const heads = new Map<number, { x: number; y: number; seat: number; mine: boolean }>();
906
- const viewAt = list.find((a) => a.mine) ?? null;
907
- for (const a of list) {
908
- const colour = colourOf(a.slot, a.bot ? null : a.seat);
909
- const fx = bodyFx(a.slot, t);
910
- ctx.save();
911
- ctx.translate(a.x + fx.ox, a.y + fx.oy); ctx.rotate(fx.ang); ctx.scale(fx.sx, fx.sy); ctx.rotate(-fx.ang);
912
- ctx.globalAlpha = a.bot ? 0.62 : 1;
913
- ctx.fillStyle = colour; ctx.beginPath(); ctx.arc(0, 0, R_AV, 0, Math.PI * 2); ctx.fill();
914
- ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.beginPath(); ctx.arc(0, 0, R_AV * 0.45, 0, Math.PI * 2); ctx.fill();
915
- 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(); }
916
- ctx.restore();
917
- 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(); }
918
- ctx.globalAlpha = 1;
919
- const text = a.mine && !net.watching ? 'You' : label(a.name, a.bot);
920
- 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;
921
- if (sx + r < 0 || sx - r > cw || sy + r < 0 || sy - r > ch) continue; // off screen: no name at the edge
922
- if (a.seat !== null && !a.bot) heads.set(a.slot, { x: sx, y: sy - r - 5, seat: a.seat, mine: a.mine });
923
- // People before bots, nearer the view's body first; each keeps off the others' bodies when it can.
924
- 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) });
925
- }
926
- drawSparks(t, scale);
927
- lab.draw(ctx, scale);
928
- ctx.restore();
929
- shownLabels = labels.place(tags, Math.min(0.1, (t - (lastDraw || t)) / 1000));
930
- lastDraw = t;
931
- ctx.font = `600 ${fs}px ui-sans-serif, system-ui, sans-serif`;
932
- ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineJoin = 'round';
933
- for (const l of [...shownLabels].sort((x, y) => Number(Boolean(x.self)) - Number(Boolean(y.self)))) {
934
- if (l.alpha <= 0) continue;
935
- ctx.globalAlpha = l.alpha;
936
- if (l.moved) {
937
- // Off its own spot: a thin line to its body says whose it is.
938
- const under = l.top > l.y;
939
- ctx.strokeStyle = 'rgba(232,236,245,0.4)'; ctx.lineWidth = 1;
940
- 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();
941
- }
942
- 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(); }
943
- else { ctx.strokeStyle = 'rgba(4,6,12,0.85)'; ctx.lineWidth = 3; ctx.strokeText(l.text, l.cx, l.cy); }
944
- ctx.fillStyle = l.self ? '#ffffff' : 'rgba(232,236,245,0.85)';
945
- ctx.fillText(l.text, l.cx, l.cy);
946
- }
947
- ctx.globalAlpha = 1; ctx.textBaseline = 'alphabetic';
948
- // Speech bubbles (room chat): over each speaker's name, the view's own player first.
949
- if (bubbles.size) {
950
- const anchors: BubbleIn[] = [];
951
- for (const [slot, h] of heads) {
952
- const l = shownLabels.find((x) => x.key === slot && x.alpha > 0 && !x.moved);
953
- anchors.push({ key: h.seat, x: l ? l.cx : h.x, y: l ? l.top - 1 : h.y, self: h.mine });
954
- }
955
- shownBubbles = bubbles.place(anchors);
956
- paintBubbles(ctx, shownBubbles);
957
- } else shownBubbles = [];
958
-
959
- hud(cw, ch, phone, list);
960
- if (stick.active) {
961
- ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 3;
962
- ctx.beginPath(); ctx.arc(stick.ox, stick.oy, 56, 0, Math.PI * 2); ctx.stroke();
963
- 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;
964
- 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();
965
- }
966
- }
967
-
968
- function hud(cw: number, ch: number, phone: boolean, list: { slot: number; name: string; bot: boolean; mine: boolean }[]): void {
969
- const pad = phone ? 14 : 20;
970
- const debugStrip = new URLSearchParams(location.search).get('debug') === '1' || (window as unknown as { HOMIE_NET?: { debug?: boolean } }).HOMIE_NET?.debug;
971
- const top = pad + (debugStrip ? (phone ? 50 : 26) : 0);
972
- const now = net.now();
973
- const r = round;
974
- ctx.textAlign = 'left'; ctx.fillStyle = '#e8ecf5';
975
- ctx.font = `700 ${phone ? 20 : 24}px ui-sans-serif, system-ui, sans-serif`;
976
- const left = r ? Math.max(0, Math.ceil((r.endsAt - now) / 1000)) : 0;
977
- const clock = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
978
- ctx.fillText(r ? (r.phase === 'live' ? `Round ${r.n} · ${clock}` : `Next round in ${left}`) : 'Joining…', pad, top + 18);
979
- // scores
980
- const scores = new Map<number, number>();
981
- if (hosting) for (const b of bodies.values()) scores.set(b.slot, b.score);
982
- else for (const d of drawn.values()) scores.set(d.slot, d.score);
983
- const rows = [...list].sort((a, b) => (scores.get(b.slot) ?? 0) - (scores.get(a.slot) ?? 0)).slice(0, phone ? 4 : 6);
984
- ctx.font = `600 ${phone ? 14 : 16}px ui-sans-serif, system-ui, sans-serif`;
985
- ctx.textAlign = 'right';
986
- // The play page's room button (and a server's pill beside it) sit at the top right (game.json screen.share's
987
- // default): the scores start under that band, so the buttons never cover a score.
988
- const board = top + 18 + 44;
989
- rows.forEach((a, i) => {
990
- ctx.fillStyle = a.mine ? '#ffffff' : a.bot ? 'rgba(232,236,245,0.55)' : 'rgba(232,236,245,0.85)';
991
- ctx.fillText(`${a.mine && !net.watching ? 'You' : label(a.name, a.bot)} ${scores.get(a.slot) ?? 0}`, cw - pad, board + i * (phone ? 20 : 22));
992
- });
993
- // role badge
994
- ctx.textAlign = 'left'; ctx.font = '600 11px ui-monospace, Menlo, monospace'; ctx.fillStyle = 'rgba(125,240,255,0.7)';
995
- ctx.fillText(net.offline ? 'OFFLINE HOST' : net.watching ? 'WATCHING' : net.role.toUpperCase(), pad, ch - pad);
996
- if (r && r.phase === 'over' && r.results) {
997
- const w = Math.min(360, cw - 40); const h = 44 + Math.min(6, r.results.length) * 26;
998
- const x = (cw - w) / 2; const y = (ch - h) / 2;
999
- ctx.fillStyle = 'rgba(6,10,20,0.86)'; ctx.fillRect(x, y, w, h);
1000
- ctx.strokeStyle = 'rgba(125,240,255,0.5)'; ctx.strokeRect(x, y, w, h);
1001
- ctx.textAlign = 'center'; ctx.fillStyle = '#7df0ff'; ctx.font = '700 18px ui-sans-serif, system-ui, sans-serif';
1002
- ctx.fillText(`Round ${r.n} results`, cw / 2, y + 28);
1003
- ctx.font = '600 16px ui-sans-serif, system-ui, sans-serif';
1004
- const view = viewSeat();
1005
- r.results.slice(0, 6).forEach((row, i) => {
1006
- const mine = !row.bot && row.seat !== null && row.seat === view;
1007
- ctx.fillStyle = mine ? '#ffffff' : row.bot ? 'rgba(232,236,245,0.6)' : '#e8ecf5';
1008
- ctx.fillText(`${row.place}. ${mine && !net.watching ? 'You' : label(row.name, row.bot)}${mine && net.watching ? ' ◂' : ''} — ${row.score}`, cw / 2, y + 56 + i * 26);
1009
- });
1010
- }
1011
- // Only a browser that WAS in its room is reconnecting; one still joining, or stopped for good, is not (section 22).
1012
- if (net.link === 'reconnecting' && net.role !== 'host') {
1013
- ctx.textAlign = 'center'; ctx.fillStyle = '#ffd166'; ctx.font = '600 14px ui-sans-serif, system-ui, sans-serif';
1014
- ctx.fillText('Reconnecting…', cw / 2, ch - pad);
1015
- }
1016
- if (frames < 240 && mySeat() !== null) {
1017
- ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(232,236,245,0.7)'; ctx.font = '500 14px ui-sans-serif, system-ui, sans-serif';
1018
- ctx.fillText(phone ? 'Drag to move · second finger bumps' : 'WASD / arrows to move · Space bumps', cw / 2, ch - pad - 18);
1019
- }
1020
- }
1021
-
1022
- /* ------------------------------------------------------------------ loop */
1023
- function frame(t: number): void {
1024
- const dt = lab.time.dt(t, 0.05);
1025
- stepMe(dt);
1026
- if (hosting) stepHost(dt);
1027
- else stepReplica(dt);
1028
- if (lab.on) labReport(dt);
1029
- draw(t);
1030
- frames += 1;
1031
- requestAnimationFrame(frame);
1032
- }
1033
-
1034
- net.expose({
1035
- // Room chat's bubbles on this screen now: whose, what, and where (an end-to-end test reads them).
1036
- bubbles: () => shownBubbles.map((b) => ({ seat: b.key, text: b.lines.join(' '), alpha: b.alpha, left: Math.round(b.left), top: Math.round(b.top), right: Math.round(b.right), bottom: Math.round(b.bottom) })),
1037
- self: () => (me.has ? { x: me.x, y: me.y } : null),
1038
- peer: (seat: number) => {
1039
- if (hosting) { const b = [...bodies.values()].find((x) => x.seat === seat); return b ? { x: b.x, y: b.y } : null; }
1040
- const d = [...drawn.values()].find((x) => x.seat === seat);
1041
- return d ? { x: d.x, y: d.y } : null;
1042
- },
1043
- frames: () => frames,
1044
- scores: () => (hosting ? [...bodies.values()].map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })) : [...drawn.values()].map((d) => ({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, bot: d.seat < 0, score: d.score }))),
1045
- /** Host: gems each slot picked up this round, the round's age, and the dial each bot plays at (the dial's numbers). */
1046
- pickups: () => (hosting ? { round: round ? { n: round.n, phase: round.phase, ageMs: net.now() - round.startedAt } : null, slots: [...bodies.values()].map((b) => ({ slot: b.slot, bot: b.bot, gems: pickups.get(b.slot) ?? 0, level: b.bot ? net.skillOf(b.slot).level : null })) } : null),
1047
- /** Host: when each bot last looked (its reaction clock). */
1048
- sight: () => (hosting ? [...sight.entries()].map(([slot, e]) => ({ slot, at: e.at })) : null),
1049
- waves: () => wavesSeen,
1050
- knocks: () => knocksSeen,
1051
- zone: () => zone,
1052
- owned: () => net.owned,
1053
- cheatResets: () => cheatResets,
1054
- movement: () => MOVEMENT,
1055
- controlResets: () => controlResets,
1056
- predictionError: () => predictionError,
1057
- /** Where the camera looks and whose view it is (a watcher following a player: their body, under the camera). */
1058
- camera: () => ({ x: cam.x, y: cam.y, scale: cam.scale, view: viewSeat() }),
1059
- /** The names as drawn (boxes, never the text): the e2e probe counts overlaps and checks your own. */
1060
- 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) })),
1061
- /** performance.now() of the first keyed-state value and the first live snapshot this browser received. */
1062
- arrivals: () => ({ firstStateAt, firstSnapAt }),
1063
- /** Harness hook (host only): knock the body of `seat` back, toward the middle of the arena. */
1064
- debugKnock: (seat: number) => {
1065
- if (!hosting) return false;
1066
- const b = [...bodies.values()].find((x) => x.seat === seat);
1067
- if (!b) return false;
1068
- knock(b, b.x < W / 2 ? b.x - 1 : b.x + 1, b.y, -1); // push toward the middle, away from the nearer wall (bumped by nobody)
1069
- return true;
1070
- },
1071
- });
1072
-
1073
- exposePort(net, {
1074
- view: 'top',
1075
- self: () => (me.has && mySeat() !== null ? { x: me.x, y: me.y } : null),
1076
- size: R_AV,
1077
- score: () => { const seat = mySeat(); const b = hosting ? [...bodies.values()].find((x) => x.seat === seat) : [...drawn.values()].find((x) => x.seat === seat); return b ? b.score : null; },
1078
- });
1079
-
1080
- void net.ready.then(() => {
1081
- const seat = mySeat();
1082
- if (seat !== null && hosting) {
1083
- const b = [...bodies.values()].find((x) => x.seat === seat);
1084
- if (b) { me.x = b.x; me.y = b.y; me.has = true; }
1085
- }
1086
- });
1087
- requestAnimationFrame(frame);