@homie-rocks/studio 0.30.2 → 0.31.1

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 (90) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/README.md +349 -23
  3. package/assets/animate.ts +16 -6
  4. package/assets/assets.ts +14 -3
  5. package/assets/render-page.ts +10 -2
  6. package/assets/safety.mjs +7 -1
  7. package/bin/homie-studio.mjs +204 -124
  8. package/lib/art-cli.mjs +61 -18
  9. package/lib/art-tools.mjs +53 -10
  10. package/lib/asset-check.mjs +249 -46
  11. package/lib/asset-import.mjs +11 -5
  12. package/lib/asset-manifest.mjs +128 -51
  13. package/lib/build.mjs +239 -72
  14. package/lib/characters.mjs +119 -20
  15. package/lib/check.mjs +110 -7
  16. package/lib/chrome.mjs +11 -0
  17. package/lib/clips.mjs +54 -0
  18. package/lib/cloudflare.mjs +203 -10
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/deploy-state.mjs +166 -0
  23. package/lib/dev.mjs +430 -0
  24. package/lib/directory.mjs +172 -9
  25. package/lib/doctor.mjs +12 -11
  26. package/lib/handoff.mjs +2 -6
  27. package/lib/library.mjs +1 -1
  28. package/lib/mcp-tools.mjs +42 -37
  29. package/lib/mcp.mjs +1 -1
  30. package/lib/net.mjs +81 -2
  31. package/lib/node-version.mjs +40 -0
  32. package/lib/office.mjs +6 -6
  33. package/lib/optimise.mjs +39 -4
  34. package/lib/parts-build.mjs +169 -0
  35. package/lib/parts-cli.mjs +104 -0
  36. package/lib/parts-store.mjs +342 -0
  37. package/lib/parts-tools.mjs +92 -0
  38. package/lib/parts.mjs +720 -0
  39. package/lib/perf.mjs +131 -31
  40. package/lib/port-check.mjs +40 -6
  41. package/lib/preview.mjs +77 -0
  42. package/lib/routes.mjs +240 -0
  43. package/lib/scaffold.mjs +83 -22
  44. package/lib/shoot.mjs +307 -0
  45. package/lib/site.mjs +1 -2
  46. package/lib/stage.mjs +118 -0
  47. package/lib/studio.mjs +0 -66
  48. package/lib/style-board.mjs +49 -6
  49. package/lib/template-history.json +101 -0
  50. package/lib/trailer.mjs +47 -0
  51. package/lib/typecheck.mjs +107 -0
  52. package/mcp/ui/lineup.js +3 -3
  53. package/mcp/ui/rights.js +1 -3
  54. package/netplay/NETPLAY.md +552 -16
  55. package/netplay/netplay.ts +950 -43
  56. package/package.json +10 -1
  57. package/parts/PARTS.md +169 -0
  58. package/port/index.ts +8 -2
  59. package/port/personas.ts +275 -0
  60. package/port/powerups.ts +357 -0
  61. package/port/probe.ts +38 -2
  62. package/port/room.ts +329 -40
  63. package/site/SITE.md +102 -20
  64. package/starters/ember-vale/index.html +1 -1
  65. package/starters/ember-vale/src/main.ts +7 -1
  66. package/starters/gem-rush/index.html +1 -1
  67. package/starters/gem-rush/src/main.ts +41 -12
  68. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  69. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  70. package/starters/gem-rush-3d/index.html +1 -1
  71. package/starters/gem-rush-3d/src/main.ts +50 -11
  72. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  73. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  74. package/starters/hero-rush-3d/index.html +1 -1
  75. package/starters/hero-rush-3d/src/main.ts +50 -11
  76. package/worker/arrival.mjs +4 -1
  77. package/worker/discover.mjs +11 -25
  78. package/worker/index.mjs +142 -62
  79. package/worker/license.mjs +45 -41
  80. package/worker/office-page.mjs +0 -4
  81. package/worker/office-schema.mjs +6 -0
  82. package/worker/office.mjs +19 -23
  83. package/worker/pages.mjs +156 -29
  84. package/worker/parts.mjs +275 -0
  85. package/worker/room.mjs +157 -14
  86. package/worker/schema.mjs +6 -8
  87. package/worker/seats.mjs +71 -0
  88. package/worker/site.mjs +51 -53
  89. package/worker/version.mjs +1 -1
  90. package/lib/remix-assets.mjs +0 -84
@@ -0,0 +1,357 @@
1
+ /*
2
+ * @homie-rocks/studio/powerups: pickups the host owns, and three of them that work.
3
+ * =============================================================================
4
+ *
5
+ * Power-ups are where a multiplayer game's bookkeeping goes wrong first. Who holds what, and for how much longer,
6
+ * is state every browser must agree on; it has timers in it; and the browser that owns it can leave. The usual
7
+ * first version keeps `shieldUntil = Date.now() + 8000` in the host's memory, and the first host migration either
8
+ * forgets every power-up in the room or hands the new host deadlines measured on somebody else's clock.
9
+ *
10
+ * So everything here is ONE PLAIN OBJECT (`PowerState`) and PURE FUNCTIONS over it: each takes a state and returns
11
+ * a new one, with the events that happened. Nothing reads a clock, a random number or the network.
12
+ *
13
+ * THE CONTRACT (`PowerupContract` below spells it as types)
14
+ * inputs none of its own. It reads bodies' positions; a game with a "use" button holds the item in its own
15
+ * state and calls `grant` when it is pressed.
16
+ * state `PowerState`: pickups lying on spawn spots, who holds what, and how long each has LEFT in
17
+ * milliseconds. Remaining time, never a deadline: a new host carries on from "3.2 s left" without
18
+ * knowing what time the old host thought it was. The spawn sequence's seed is in the state too, so the
19
+ * next pickup is the same one whoever is hosting.
20
+ * netplay host-authoritative. The host calls `stepPowerups`, `collectPowerups` and `resolveHit` in its tick
21
+ * and puts `packPowerups(state)` in its snapshot AND its checkpoint; replicas `unpackPowerups` and draw.
22
+ * It is the game's own snapshot data: the wire protocol is untouched. `powerFlags` is a bitmask per
23
+ * body for a game that would rather carry one small number on each body.
24
+ *
25
+ * THE THREE
26
+ * magnet a wider bite (`biteRadius`), and things inside its reach drift toward the mouth (`magnetPull`):
27
+ * the drift is what makes the wider bite read as fair on screen
28
+ * phase no collisions: `collides(state, a, b)` is false while either body holds it
29
+ * shield blocks ONE hit and bounces the attacker back along its own approach (`resolveHit`); the charge is
30
+ * spent, the timer still runs out on its own
31
+ *
32
+ * A game adds its own kind by adding a row to `rules.kinds` and reading `holds(state, slot, 'its-kind')`.
33
+ *
34
+ * DRAWING IS A THIN LAYER AT THE BOTTOM, and optional: `powerDraws` fills instance matrices (gems on spots, a ring
35
+ * round each magnet holder, a bubble round each shield holder) for three instanced meshes the game owns, and
36
+ * `hudPills` says what the HUD's pill shows. Neither imports a renderer, and both read only the state, so they
37
+ * work the same on the host and on a replica.
38
+ * =============================================================================
39
+ */
40
+ import type { V2 } from './bots';
41
+
42
+ /** One kind of power-up. */
43
+ export interface PowerDef {
44
+ /** How long it lasts once picked up, milliseconds. */
45
+ ms: number;
46
+ /** How often it spawns compared with the others (default 1). */
47
+ weight?: number;
48
+ /** Hits it absorbs before it is used up (a shield: 1). Absent: it only runs out on time. */
49
+ charges?: number;
50
+ /** What the HUD calls it. */
51
+ label?: string;
52
+ }
53
+
54
+ export interface PowerRules {
55
+ /** The kinds, by name. THEIR ORDER IS PART OF THE WIRE FORMAT (a kind travels as its index): add new ones last. */
56
+ kinds: Record<string, PowerDef>;
57
+ /** Milliseconds a spot stays empty after its pickup is taken. */
58
+ respawnMs: number;
59
+ /** Milliseconds before the first pickups appear (default: `respawnMs`). */
60
+ firstMs?: number;
61
+ /** Most pickups lying about at once. */
62
+ maxOnField: number;
63
+ /** How close a body's edge must be to a spot to take its pickup, in the game's units. */
64
+ pickupRadius: number;
65
+ magnet: { /** Bite radius is multiplied by this. */ bite: number; /** Things within `reach` bite radii drift in. */ reach: number; /** Units a second. */ pull: number };
66
+ shield: { /** The speed the attacker is thrown back at, units a second. */ bounce: number };
67
+ }
68
+
69
+ /** Tuned on a game where bodies are about one unit across and cross the arena in ten seconds. Change freely. */
70
+ export const POWER_RULES: PowerRules = Object.freeze({
71
+ kinds: Object.freeze({
72
+ magnet: Object.freeze({ ms: 9000, weight: 1, label: 'Magnet' }),
73
+ phase: Object.freeze({ ms: 6000, weight: 1, label: 'Phase' }),
74
+ shield: Object.freeze({ ms: 12000, weight: 1, charges: 1, label: 'Shield' }),
75
+ }),
76
+ respawnMs: 12000,
77
+ firstMs: 4000,
78
+ maxOnField: 3,
79
+ pickupRadius: 0.6,
80
+ magnet: Object.freeze({ bite: 1.8, reach: 3, pull: 6 }),
81
+ shield: Object.freeze({ bounce: 9 }),
82
+ }) as PowerRules;
83
+
84
+ /** A pickup lying on a spawn spot. */
85
+ export interface Pickup { id: number; spot: number; kind: string }
86
+ /** A power-up somebody holds: `left` is milliseconds remaining. */
87
+ export interface Held { slot: number; kind: string; left: number; charges: number }
88
+
89
+ export interface PowerState {
90
+ /** Milliseconds this state has been stepped. Only for effects that want a phase; nothing compares it to a clock. */
91
+ t: number;
92
+ /** The spawn sequence's generator. In the state so a new host draws the same next pickup. */
93
+ seed: number;
94
+ nextId: number;
95
+ pickups: Pickup[];
96
+ /** For each spawn spot: milliseconds until it may spawn again. */
97
+ cool: number[];
98
+ held: Held[];
99
+ }
100
+
101
+ export type PowerEvent =
102
+ | { type: 'spawn'; id: number; spot: number; kind: string }
103
+ | { type: 'pickup'; id: number; spot: number; kind: string; slot: number }
104
+ | { type: 'expire'; slot: number; kind: string }
105
+ | { type: 'block'; slot: number; by: number }
106
+ | { type: 'spent'; slot: number; kind: string };
107
+
108
+ /** The state as it travels in a snapshot or a checkpoint: small arrays, kinds as their index in `rules.kinds`. */
109
+ export interface PowerWire { t: number; s: number; n: number; p: [id: number, spot: number, kind: number][]; c: number[]; h: [slot: number, kind: number, left: number, charges: number][] }
110
+
111
+ /** The mechanic's contract, as types: what it reads, what it owns, what it sends, and who decides. */
112
+ export interface PowerupContract {
113
+ /** It has no input of its own: it reads where the bodies are. */
114
+ inputs: { bodies: readonly PowerBody[] };
115
+ state: PowerState;
116
+ /** In the host's snapshot and its checkpoint. Additive: a game without power-ups ignores the field. */
117
+ wire: PowerWire;
118
+ netplay: 'host-authoritative';
119
+ }
120
+
121
+ /** A body as the rules see it: its slot, where it is, and its radius. */
122
+ export interface PowerBody extends V2 { slot: number; r?: number }
123
+
124
+ export interface Stepped { state: PowerState; events: PowerEvent[] }
125
+
126
+ /** One draw from the state's generator: a number in [0, 1) and the next seed. */
127
+ function draw(seed: number): [number, number] {
128
+ const a = (seed + 0x6d2b79f5) >>> 0;
129
+ let t = a;
130
+ t = Math.imul(t ^ (t >>> 15), t | 1);
131
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
132
+ return [((t ^ (t >>> 14)) >>> 0) / 4294967296, a];
133
+ }
134
+
135
+ /** A fresh state for an arena with `spots` spawn spots. */
136
+ export function createPowerups(spots: number, seed: number, rules: PowerRules = POWER_RULES): PowerState {
137
+ const first = rules.firstMs ?? rules.respawnMs;
138
+ // Staggered, so the spots do not all fill on one frame and then all again one respawn later.
139
+ return { t: 0, seed: seed >>> 0, nextId: 1, pickups: [], cool: Array.from({ length: Math.max(0, spots) }, (_, i) => first + (i * rules.respawnMs) / Math.max(1, spots)), held: [] };
140
+ }
141
+
142
+ /** Time passes: timers run down, what has run out expires, and empty spots that are due get a pickup. */
143
+ export function stepPowerups(s: PowerState, dtMs: number, rules: PowerRules = POWER_RULES): Stepped {
144
+ const dt = Math.max(0, dtMs);
145
+ const events: PowerEvent[] = [];
146
+ const held: Held[] = [];
147
+ for (const h of s.held) {
148
+ const left = h.left - dt;
149
+ if (left > 0) held.push({ ...h, left }); else events.push({ type: 'expire', slot: h.slot, kind: h.kind });
150
+ }
151
+ const taken = new Set(s.pickups.map((p) => p.spot));
152
+ const cool = s.cool.map((c, i) => (taken.has(i) ? c : Math.max(0, c - dt)));
153
+ const pickups = s.pickups.slice();
154
+ let seed = s.seed; let nextId = s.nextId;
155
+ const names = Object.keys(rules.kinds);
156
+ const total = names.reduce((n, k) => n + Math.max(0, (rules.kinds[k] as PowerDef).weight ?? 1), 0);
157
+ for (let i = 0; i < cool.length && pickups.length < rules.maxOnField && total > 0; i += 1) {
158
+ if (taken.has(i) || (cool[i] as number) > 0) continue;
159
+ const [u, next] = draw(seed);
160
+ seed = next;
161
+ let pick = names[names.length - 1] as string; let acc = 0;
162
+ for (const k of names) { acc += Math.max(0, (rules.kinds[k] as PowerDef).weight ?? 1); if (u * total < acc) { pick = k; break; } }
163
+ pickups.push({ id: nextId, spot: i, kind: pick });
164
+ events.push({ type: 'spawn', id: nextId, spot: i, kind: pick });
165
+ nextId += 1; taken.add(i);
166
+ }
167
+ return { state: { t: s.t + dt, seed, nextId, pickups, cool, held }, events };
168
+ }
169
+
170
+ /** Give a slot a power-up (a pickup does; a game's own shop or a "use" button may). Holding it already refreshes it. */
171
+ export function grant(s: PowerState, slot: number, kind: string, rules: PowerRules = POWER_RULES): PowerState {
172
+ const def = rules.kinds[kind];
173
+ if (!def) return s;
174
+ return { ...s, held: [...s.held.filter((h) => !(h.slot === slot && h.kind === kind)), { slot, kind, left: def.ms, charges: def.charges ?? 0 }] };
175
+ }
176
+
177
+ /**
178
+ * Bodies take the pickups they touch. `spots` are the spawn spots' positions, in the order the state counts them.
179
+ * When two bodies reach one pickup on the same tick the lower slot has it: a rule, so every host agrees.
180
+ */
181
+ export function collectPowerups(s: PowerState, bodies: readonly PowerBody[], spots: readonly V2[], rules: PowerRules = POWER_RULES): Stepped {
182
+ if (!s.pickups.length) return { state: s, events: [] };
183
+ const order = [...bodies].sort((a, b) => a.slot - b.slot);
184
+ const events: PowerEvent[] = [];
185
+ let state = s;
186
+ const left: Pickup[] = [];
187
+ const cool = s.cool.slice();
188
+ for (const p of s.pickups) {
189
+ const at = spots[p.spot];
190
+ const who = at ? order.find((b) => Math.hypot(b.x - at.x, b.y - at.y) <= rules.pickupRadius + (b.r ?? 0)) : undefined;
191
+ if (!who) { left.push(p); continue; }
192
+ cool[p.spot] = rules.respawnMs;
193
+ state = grant(state, who.slot, p.kind, rules);
194
+ events.push({ type: 'pickup', id: p.id, spot: p.spot, kind: p.kind, slot: who.slot });
195
+ }
196
+ return events.length ? { state: { ...state, pickups: left, cool }, events } : { state: s, events };
197
+ }
198
+
199
+ /** Whether a slot holds a kind now. */
200
+ export const holds = (s: PowerState, slot: number, kind: string): boolean => s.held.some((h) => h.slot === slot && h.kind === kind);
201
+ /** Everything a slot holds. */
202
+ export const heldBy = (s: PowerState, slot: number): Held[] => s.held.filter((h) => h.slot === slot);
203
+ /** A body left the round (or died): what it held goes with it. */
204
+ export const dropSlot = (s: PowerState, slot: number): PowerState => (s.held.some((h) => h.slot === slot) ? { ...s, held: s.held.filter((h) => h.slot !== slot) } : s);
205
+
206
+ /** Magnet: how far a slot's bite reaches, given its ordinary radius. */
207
+ export const biteRadius = (s: PowerState, slot: number, base: number, rules: PowerRules = POWER_RULES): number => (holds(s, slot, 'magnet') ? base * rules.magnet.bite : base);
208
+
209
+ /**
210
+ * Magnet: where a loose thing is after `dtMs` of being drawn toward a holder's mouth. Unchanged when the slot holds
211
+ * no magnet or the thing is out of reach. The host may move the real thing with it; a replica may move only what it
212
+ * draws. Either way it never overshoots the mouth.
213
+ */
214
+ export function magnetPull(s: PowerState, slot: number, mouth: V2, item: V2, base: number, dtMs: number, rules: PowerRules = POWER_RULES): V2 {
215
+ if (!holds(s, slot, 'magnet')) return item;
216
+ const dx = mouth.x - item.x; const dy = mouth.y - item.y; const d = Math.hypot(dx, dy);
217
+ if (d < 1e-9 || d > base * rules.magnet.bite * rules.magnet.reach) return item;
218
+ const move = Math.min(d, (rules.magnet.pull * Math.max(0, dtMs)) / 1000);
219
+ return { x: item.x + (dx / d) * move, y: item.y + (dy / d) * move };
220
+ }
221
+
222
+ /** Phase: whether two bodies collide at all. False while either is phased. */
223
+ export const collides = (s: PowerState, a: number, b: number): boolean => !holds(s, a, 'phase') && !holds(s, b, 'phase');
224
+
225
+ export interface HitResult {
226
+ state: PowerState;
227
+ /** `hit`: it lands, the game applies its own damage. `blocked`: a shield took it. `phased`: it passed through. */
228
+ outcome: 'hit' | 'blocked' | 'phased';
229
+ /** For `blocked`: the velocity to give the attacker, back along the way it came. */
230
+ bounce: V2 | null;
231
+ events: PowerEvent[];
232
+ }
233
+
234
+ /**
235
+ * Shield and phase: what happens when `attacker` hits `victim`. `toward` is the direction from the attacker to the
236
+ * victim (any length). A shield spends one charge, and is gone when it has none left; the attacker is thrown back.
237
+ */
238
+ export function resolveHit(s: PowerState, attacker: number, victim: number, toward: V2, rules: PowerRules = POWER_RULES): HitResult {
239
+ if (!collides(s, attacker, victim)) return { state: s, outcome: 'phased', bounce: null, events: [] };
240
+ const shield = s.held.find((h) => h.slot === victim && h.kind === 'shield' && h.charges > 0);
241
+ if (!shield) return { state: s, outcome: 'hit', bounce: null, events: [] };
242
+ const charges = shield.charges - 1;
243
+ const events: PowerEvent[] = [{ type: 'block', slot: victim, by: attacker }];
244
+ if (charges <= 0) events.push({ type: 'spent', slot: victim, kind: 'shield' });
245
+ const held = charges > 0 ? s.held.map((h) => (h === shield ? { ...h, charges } : h)) : s.held.filter((h) => h !== shield);
246
+ const d = Math.hypot(toward.x, toward.y);
247
+ const bounce = d < 1e-9 ? { x: 0, y: 0 } : { x: 0 - (toward.x / d) * rules.shield.bounce, y: 0 - (toward.y / d) * rules.shield.bounce };
248
+ return { state: { ...s, held }, outcome: 'blocked', bounce, events };
249
+ }
250
+
251
+ /** A slot's power-ups as one small number: bit `i` is the `i`-th kind of `rules.kinds`. For a field on each body. */
252
+ export function powerFlags(s: PowerState, slot: number, rules: PowerRules = POWER_RULES): number {
253
+ const names = Object.keys(rules.kinds);
254
+ let f = 0;
255
+ for (const h of s.held) if (h.slot === slot) { const i = names.indexOf(h.kind); if (i >= 0 && i < 31) f |= 1 << i; }
256
+ return f;
257
+ }
258
+ /** Whether a `powerFlags` number says a kind is held. */
259
+ export const flagHolds = (flags: number, kind: string, rules: PowerRules = POWER_RULES): boolean => { const i = Object.keys(rules.kinds).indexOf(kind); return i >= 0 && (flags & (1 << i)) !== 0; };
260
+
261
+ /** The state for a snapshot or a checkpoint. Times are whole milliseconds. */
262
+ export function packPowerups(s: PowerState, rules: PowerRules = POWER_RULES): PowerWire {
263
+ const names = Object.keys(rules.kinds);
264
+ return {
265
+ t: Math.round(s.t), s: s.seed, n: s.nextId,
266
+ p: s.pickups.map((p) => [p.id, p.spot, names.indexOf(p.kind)]),
267
+ c: s.cool.map((c) => Math.round(c)),
268
+ h: s.held.map((h) => [h.slot, names.indexOf(h.kind), Math.round(h.left), h.charges]),
269
+ };
270
+ }
271
+
272
+ /**
273
+ * The reverse, on a replica every snapshot and on a new host once (from the last snapshot or the checkpoint).
274
+ * Anything malformed, or a kind this build does not know, is dropped: a stale tab reading a newer room keeps
275
+ * running with the power-ups it understands.
276
+ */
277
+ export function unpackPowerups(w: PowerWire | null | undefined, rules: PowerRules = POWER_RULES): PowerState {
278
+ const names = Object.keys(rules.kinds);
279
+ const num = (v: unknown, d = 0): number => (typeof v === 'number' && Number.isFinite(v) ? v : d);
280
+ const o = (w ?? {}) as Partial<PowerWire>;
281
+ const pickups: Pickup[] = [];
282
+ for (const p of Array.isArray(o.p) ? o.p : []) { const kind = names[num(p?.[2], -1)]; if (kind !== undefined && Number.isInteger(p[0]) && Number.isInteger(p[1])) pickups.push({ id: num(p[0]), spot: num(p[1]), kind }); }
283
+ const held: Held[] = [];
284
+ for (const h of Array.isArray(o.h) ? o.h : []) { const kind = names[num(h?.[1], -1)]; if (kind !== undefined && Number.isInteger(h[0]) && num(h[2]) > 0) held.push({ slot: num(h[0]), kind, left: num(h[2]), charges: num(h[3]) }); }
285
+ return { t: num(o.t), seed: num(o.s) >>> 0, nextId: Math.max(1, num(o.n, 1)), pickups, cool: (Array.isArray(o.c) ? o.c : []).map((c) => Math.max(0, num(c))), held };
286
+ }
287
+
288
+ /* ------------------------------------------------------------------ drawing (optional; no renderer imported) */
289
+
290
+ /** Where a thing is for drawing: the game's flat `x, y`, and how high above the ground (default 0). */
291
+ export interface DrawPoint extends V2 { h?: number }
292
+
293
+ export interface PowerDrawOptions {
294
+ /** `'xz'`: a 3D game, y up, the flat `y` goes to world z (the default). `'xy'`: a flat game, z toward the viewer. */
295
+ plane?: 'xz' | 'xy';
296
+ /** Gem size, and how high it floats over its spot. */
297
+ gem?: number; float?: number;
298
+ }
299
+
300
+ export interface PowerDraws {
301
+ /** 4x4 matrices, column-major, 16 numbers each: what an InstancedMesh's `instanceMatrix.array` holds. */
302
+ gems: Float32Array; rings: Float32Array; bubbles: Float32Array;
303
+ /** How many of each are filled this frame: set each mesh's `count` to it. */
304
+ gemCount: number; ringCount: number; bubbleCount: number;
305
+ /** For each gem, its kind's index in `rules.kinds` (a colour table), and for each ring and bubble, the slot. */
306
+ gemKinds: Uint8Array; ringSlots: Int16Array; bubbleSlots: Int16Array;
307
+ }
308
+
309
+ /** Buffers for up to `gems` pickups and `bodies` holders. Make once; `powerDraws` refills them. */
310
+ export function createPowerDraws(gems: number, bodies: number): PowerDraws {
311
+ return { gems: new Float32Array(gems * 16), rings: new Float32Array(bodies * 16), bubbles: new Float32Array(bodies * 16), gemCount: 0, ringCount: 0, bubbleCount: 0, gemKinds: new Uint8Array(gems), ringSlots: new Int16Array(bodies), bubbleSlots: new Int16Array(bodies) };
312
+ }
313
+
314
+ /** A matrix: a turn of `spin` about the up axis, a uniform scale, then a move to `(x, y, h)` on the chosen plane. */
315
+ function place(out: Float32Array, at: number, x: number, y: number, h: number, scale: number, spin: number, xz: boolean): void {
316
+ const c = Math.cos(spin) * scale; const s = Math.sin(spin) * scale;
317
+ const o = at * 16;
318
+ out.fill(0, o, o + 16);
319
+ if (xz) { out[o] = c; out[o + 2] = -s; out[o + 5] = scale; out[o + 8] = s; out[o + 10] = c; out[o + 12] = x; out[o + 13] = h; out[o + 14] = y; }
320
+ else { out[o] = c; out[o + 1] = s; out[o + 4] = -s; out[o + 5] = c; out[o + 10] = scale; out[o + 12] = x; out[o + 13] = y; out[o + 14] = h; }
321
+ out[o + 15] = 1;
322
+ }
323
+
324
+ /**
325
+ * Fill the three instance buffers from the state: a gem on every occupied spot (turning, bobbing), a ring at the
326
+ * feet of every magnet holder (as wide as its bite), a bubble round every shield holder (breathing a little).
327
+ * `now` is any millisecond clock the browser draws with: it only animates. Three draws, whatever the room size.
328
+ */
329
+ export function powerDraws(s: PowerState, spots: readonly DrawPoint[], bodies: readonly (PowerBody & { h?: number })[], now: number, out: PowerDraws, rules: PowerRules = POWER_RULES, opts: PowerDrawOptions = {}): PowerDraws {
330
+ const xz = (opts.plane ?? 'xz') === 'xz';
331
+ const names = Object.keys(rules.kinds);
332
+ const size = opts.gem ?? 0.45; const float = opts.float ?? 0.9;
333
+ let g = 0;
334
+ for (const p of s.pickups) {
335
+ const at = spots[p.spot];
336
+ if (!at || g >= out.gemKinds.length) continue;
337
+ place(out.gems, g, at.x, at.y, (at.h ?? 0) + float + Math.sin(now / 420 + p.id) * 0.12, size, now / 600 + p.id, xz);
338
+ out.gemKinds[g] = Math.max(0, names.indexOf(p.kind));
339
+ g += 1;
340
+ }
341
+ let r = 0; let b = 0;
342
+ for (const body of bodies) {
343
+ const base = body.r ?? 0.5;
344
+ if (holds(s, body.slot, 'magnet') && r < out.ringSlots.length) { place(out.rings, r, body.x, body.y, (body.h ?? 0) + 0.05, biteRadius(s, body.slot, base, rules), -now / 900, xz); out.ringSlots[r] = body.slot; r += 1; }
345
+ if (holds(s, body.slot, 'shield') && b < out.bubbleSlots.length) { place(out.bubbles, b, body.x, body.y, (body.h ?? 0) + base, base * (1.35 + Math.sin(now / 300 + body.slot) * 0.04), 0, xz); out.bubbleSlots[b] = body.slot; b += 1; }
346
+ }
347
+ out.gemCount = g; out.ringCount = r; out.bubbleCount = b;
348
+ return out;
349
+ }
350
+
351
+ /** One pill of the HUD: what is held, how much of its time is left (1 full, 0 gone), and its charges. */
352
+ export interface HudPill { kind: string; label: string; left: number; fraction: number; charges: number }
353
+
354
+ /** What a slot's HUD shows, soonest to run out first. */
355
+ export function hudPills(s: PowerState, slot: number, rules: PowerRules = POWER_RULES): HudPill[] {
356
+ return heldBy(s, slot).map((h) => { const def = rules.kinds[h.kind]; return { kind: h.kind, label: def?.label ?? h.kind, left: h.left, fraction: def ? Math.max(0, Math.min(1, h.left / def.ms)) : 0, charges: h.charges }; }).sort((a, b) => a.left - b.left);
357
+ }
package/port/probe.ts CHANGED
@@ -37,8 +37,13 @@ export interface PortProbeOptions {
37
37
  /** True while my body is not mine to steer (knocked back, stunned, dead, respawning): the owner tests skip
38
38
  * those frames, and the recipe's newcomer rule says it should rarely happen in a person's first seconds. */
39
39
  busy?: () => boolean;
40
- /** Anything else worth a look in a receipt (numbers or short strings). */
41
- extra?: Record<string, () => unknown>;
40
+ /**
41
+ * Anything else worth a look in a receipt (numbers or short strings), each a function read when asked. The names
42
+ * in `PortExtra` are read BY NAME by the instruments (the playtest skill, `homie-studio perf`, `shoot`): set the
43
+ * ones this game has, and its rows say what state each press and picture was taken in. Any other name is kept
44
+ * in the receipt as it is.
45
+ */
46
+ extra?: PortExtra & Record<string, () => unknown>;
42
47
  /** The keys that move you (KeyboardEvent.code). Default: the arrows (WASD for first-person). */
43
48
  keys?: { up: string; down: string; left: string; right: string };
44
49
  /** Where the check puts a thumb for the stick, as screen fractions (default [0.24, 0.74]). */
@@ -48,6 +53,35 @@ export interface PortProbeOptions {
48
53
  world?: string;
49
54
  }
50
55
 
56
+ /**
57
+ * THE STATE HOOKS THE INSTRUMENTS READ BY NAME (all optional). A playtest that cannot tell a dead body from a broken
58
+ * control, or a perf run that cannot see the renderer's own counters, says "unknown" or "not exposed" where one of
59
+ * these lines would have given it the answer.
60
+ *
61
+ * exposePort(net, { view: 'top', self, busy,
62
+ * extra: { alive: () => me.hp > 0, mode: () => hud.mode, loadout: () => me.weapon,
63
+ * drawCalls: () => renderer.info.render.calls, triangles: () => renderer.info.render.triangles } });
64
+ *
65
+ * The round (`info().round`: { n, phase: 'live' | 'over', endsAt, leftMs }) needs no hook: it is the room's own, from
66
+ * `net.round(...)`, which `createRoom` calls for every round it runs. So are `info().link` and `info().reconnects`
67
+ * (where this browser stands with its room, and how often that was interrupted).
68
+ */
69
+ export interface PortExtra {
70
+ /** The local body is alive. false: spectating until the next round (a press that moves nothing is then not a broken control). */
71
+ alive?: () => boolean;
72
+ /** The control mode on screen, in a word or two ("manual fire", "auto", "driving"): it changes what a press does. */
73
+ mode?: () => string;
74
+ /** What the player holds (a weapon, a tool), when it changes which controls show. */
75
+ loadout?: () => string;
76
+ /** A thumb is down on the game's own stick right now. */
77
+ touchHeld?: () => boolean;
78
+ /** The renderer's last frame, for measured scene cost (three.js: `renderer.info.render.calls` / `.triangles`). */
79
+ drawCalls?: () => number;
80
+ triangles?: () => number;
81
+ }
82
+ /** The names above, for a test and for anything that lists what a probe may say. */
83
+ export const PORT_EXTRA_NAMES = ['alive', 'mode', 'loadout', 'touchHeld', 'drawCalls', 'triangles'] as const;
84
+
51
85
  interface PortProbe {
52
86
  v: 1;
53
87
  view: View;
@@ -113,6 +147,8 @@ export function exposePort(net: Netplay<unknown, unknown, unknown> | null, opts:
113
147
  role: net?.role ?? null, seat: net?.seat ?? null, offline: net?.offline ?? null, owned: net?.owned ?? null,
114
148
  round: net?.roundInfo ? { n: net.roundInfo.n, phase: net.roundInfo.phase, endsAt: net.roundInfo.endsAt, leftMs: Math.round(net.roundInfo.endsAt - net.now()) } : null,
115
149
  slots: net?.slots?.map((s) => ({ slot: s.slot, seat: s.seat, bot: s.bot })) ?? null,
150
+ // Where this browser stands with its room (netplay revision 9): a reading taken while cut off is not play.
151
+ link: net?.link ?? null, reconnects: net?.reconnects ?? null,
116
152
  lastMove, moves: opts.moves ? opts.moves() : null, score,
117
153
  audio: audioReport(), sandbox: sandboxReport(), errors: errors.slice(-10), extra,
118
154
  };