@homie-rocks/studio 0.19.2 → 0.20.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.
@@ -0,0 +1,802 @@
1
+ /*
2
+ * The Game Lab page (@homie-rocks/studio lab/page): New and Today side by side, on one clock.
3
+ *
4
+ * Each pane is the game's own page in a frame, with the lab's harness first (lab/harness.js). The page owns the clock:
5
+ * it steps both panes the same number of frames at the same time, so frame f is frame f in both. Going back means
6
+ * loading the panes again and running them to that frame as fast as they go: a take is deterministic (seeded dice,
7
+ * the same presses at the same frames), so the frame it lands on is the frame it was. That is also checked: every
8
+ * replay's frames are compared with the last run's, and a difference is shown.
9
+ *
10
+ * window.labApp is what `homie-studio lab check` drives headless (the same code the person sees).
11
+ */
12
+ import { DEVICES, FPS, SCALES, expandTake, framesOf, inputsFromRecording } from '/_lab/take.js';
13
+
14
+ const $ = (id) => document.getElementById(id);
15
+ const GAME = location.pathname.split('/').filter(Boolean)[0];
16
+ const Q = new URLSearchParams(location.search);
17
+ const CHECK = Q.has('check');
18
+ // Phases get these, in the order they first appear (never the motion colours below, so the two never read alike).
19
+ const PALETTE = ['#a374ff', '#17c3b2', '#f5a524', '#ff6fb5', '#8bd346', '#4dd2ff', '#ffd84a', '#e58cff', '#5ee6c7', '#ff9e57'];
20
+ const MOTION = { HOLD: '#3b7bff', MOVE: '#59627c', FAST: '#ff5a6a' };
21
+ const SPEED_LABEL = { 1: '1×', 0.5: '½', 0.25: '¼', 0.1: '⅒' };
22
+ const BODY = 'body speed';
23
+ const COST = 'js ms/frame';
24
+
25
+ const S = {
26
+ state: null,
27
+ takeName: null,
28
+ takeRaw: null,
29
+ take: null,
30
+ fps: 60,
31
+ frames: 1,
32
+ frame: 0,
33
+ scale: 1,
34
+ playing: false,
35
+ loop: true,
36
+ device: 'desk',
37
+ layout: 'both',
38
+ view: null,
39
+ overlays: new Set(),
40
+ track: null,
41
+ overrides: {},
42
+ mode: 'replay',
43
+ busy: false,
44
+ token: 0,
45
+ nonce: 0,
46
+ acc: 0,
47
+ last: 0,
48
+ dirty: true,
49
+ mismatch: null,
50
+ colours: new Map(),
51
+ units: new Map(),
52
+ told: { new: false, today: false },
53
+ recordStartedAt: 0,
54
+ primed: false,
55
+ };
56
+
57
+ const pane = (name) => ({ name, el: $(`pane-${name}`), slot: document.querySelector(`.slot[data-pane="${name}"]`), api: null, read: 0, rec: [], epoch: 0, meta: null, available: true, why: null });
58
+ const P = { new: pane('new'), today: pane('today') };
59
+ const live = () => [P.new, P.today].filter((p) => p.available && p.api);
60
+
61
+ /* ------------------------------------------------------------------ what the harness asks for */
62
+ window.__homieLabPanes = {
63
+ config(name) {
64
+ const t = S.take;
65
+ return {
66
+ fps: S.fps, seed: t?.seed ?? 1, stage: t?.stage ?? null, inputs: S.mode === 'record' ? [] : t?.inputs ?? [], storage: t?.storage ?? null, view: S.view,
67
+ overlays: [...S.overlays], tunables: name === 'new' ? { ...S.overrides } : {}, frames: S.frames, dpr: DEVICES[S.device].dpr, mode: S.mode, scale: S.scale,
68
+ };
69
+ },
70
+ // A press while recording: both panes get it at the same frame.
71
+ input(_from, ev) { for (const p of live()) p.api.input(ev); },
72
+ };
73
+
74
+ /* ------------------------------------------------------------------ state from the server */
75
+ async function loadState() {
76
+ const r = await fetch(`/_lab/api/${GAME}/state${Q.get('today') ? `?today=${encodeURIComponent(Q.get('today'))}` : ''}`, { cache: 'no-store' });
77
+ S.state = await r.json();
78
+ if (!S.state.ok) throw new Error(S.state.why ?? 'the lab could not start');
79
+ P.today.available = S.state.today.ok;
80
+ P.today.why = S.state.today.ok ? null : S.state.today.error ? `Today did not build: ${S.state.today.error}` : S.state.today.none;
81
+ P.new.available = S.state.new.ok;
82
+ P.new.why = S.state.new.ok ? null : `New did not build: ${S.state.new.error}`;
83
+ $('game-name').textContent = S.state.game.name;
84
+ document.title = `${S.state.game.name} · Game Lab`;
85
+ return S.state;
86
+ }
87
+
88
+ function pickTake(name) {
89
+ const takes = S.state.takes.takes;
90
+ const names = Object.keys(takes);
91
+ S.takeName = names.includes(name) ? name : S.state.takes.default ?? names[0] ?? null;
92
+ S.takeRaw = S.takeName ? takes[S.takeName] : { seconds: 4, inputs: [], note: 'No take yet: press REC and play' };
93
+ S.fps = FPS.includes(Number(Q.get('fps'))) ? Number(Q.get('fps')) : FPS.includes(Number(S.takeRaw.fps)) ? Number(S.takeRaw.fps) : S.fps;
94
+ S.take = expandTake(S.takeRaw, { fps: S.fps });
95
+ S.frames = S.take.frames;
96
+ if (!S.deviceSet) S.device = Q.get('device') in DEVICES ? Q.get('device') : S.take.device;
97
+ S.view = S.take.view ?? S.view;
98
+ S.overlays = new Set(S.take.overlays);
99
+ S.track = S.take.track ?? S.track;
100
+ $('take-name').textContent = S.takeName ? `${S.takeName}${S.takeRaw.note ? ` · ${S.takeRaw.note}` : ''}` : 'no take yet';
101
+ }
102
+
103
+ /* ------------------------------------------------------------------ panes: load, run, collect */
104
+ function invalidate(list = [P.new, P.today]) { for (const p of list) { p.rec = []; p.epoch += 1; } S.mismatch = null; S.dirty = true; }
105
+
106
+ async function load(p, token) {
107
+ const d = DEVICES[S.device];
108
+ p.api = null;
109
+ p.read = 0;
110
+ p.meta = null;
111
+ if (!p.available) { p.el.removeAttribute('src'); return; }
112
+ p.el.width = d.w; p.el.height = d.h;
113
+ p.el.style.width = `${d.w}px`; p.el.style.height = `${d.h}px`;
114
+ await new Promise((done) => { p.el.onload = done; p.el.src = `/${GAME}/${p.name}/?b=${++S.nonce}`; });
115
+ if (token !== S.token) return;
116
+ const api = p.el.contentWindow?.__homieLabPane;
117
+ if (!api) { p.why = `The ${p.name} page has no lab harness (it did not load).`; p.available = false; return; }
118
+ await api.ready;
119
+ p.api = api;
120
+ remember(p);
121
+ }
122
+
123
+ function collect(p) {
124
+ const fr = p.api.frames;
125
+ for (; p.read < fr.length; p.read += 1) {
126
+ const r = fr[p.read];
127
+ const old = p.rec[r.f - 1];
128
+ if (old && old.epoch === p.epoch && old.sig !== r.sig && !S.mismatch && S.mode !== 'record') S.mismatch = { pane: p.name, f: r.f };
129
+ p.rec[r.f - 1] = { f: r.f, tr: { ...r.tr }, ph: r.ph ? [r.ph[0], r.ph[1]] : null, ms: r.ms, sig: r.sig, me: r.me ? [r.me[0], r.me[1]] : null, epoch: p.epoch };
130
+ }
131
+ remember(p);
132
+ }
133
+ /** What a pane's game has said so far, kept across reloads (a fresh pane has said nothing yet). */
134
+ function remember(p) {
135
+ p.meta = p.api.meta();
136
+ for (const [k, u] of Object.entries(p.meta.units ?? {})) if (!S.units.has(k) || (!S.units.get(k) && u)) S.units.set(k, u);
137
+ if (p.meta.instrumented || p.meta.views?.length || p.meta.overlays?.length || p.meta.tunables) S.told[p.name] = true;
138
+ }
139
+
140
+ async function advance(n, list = live()) {
141
+ if (n <= 0 || !list.length) return;
142
+ await Promise.all(list.map((p) => p.api.run(n)));
143
+ for (const p of list) collect(p);
144
+ S.frame = Math.max(...list.map((p) => p.api.frame));
145
+ S.dirty = true;
146
+ }
147
+
148
+ /** Load the panes again and run them to `target` (as fast as they go). Returns false when a newer boot took over. */
149
+ async function boot(target = 0, names = ['new', 'today']) {
150
+ const token = ++S.token;
151
+ S.busy = true;
152
+ showVeils(true);
153
+ const list = names.map((n) => P[n]);
154
+ try {
155
+ await Promise.all(list.map((p) => load(p, token)));
156
+ if (token !== S.token) return false;
157
+ const runnable = list.filter((p) => p.api);
158
+ for (const p of runnable) p.api.set({ scale: S.scale, paused: !S.playing });
159
+ // Run in slices, so the counter moves and the page stays responsive while a long seek runs. Frame 1 is the first
160
+ // the game draws (before it, the canvas is empty), so a pane always stands on frame 1 or later.
161
+ const goal = Math.max(1, Math.min(target, S.frames));
162
+ while (runnable.length && Math.min(...runnable.map((p) => p.api.frame)) < goal) {
163
+ const at = Math.min(...runnable.map((p) => p.api.frame));
164
+ await Promise.all(runnable.map((p) => p.api.run(Math.min(30, goal - p.api.frame))));
165
+ for (const p of runnable) collect(p);
166
+ if (token !== S.token) return false;
167
+ if (goal - at > 60) { $('counter').textContent = `SEEK ${Math.min(...runnable.map((p) => p.api.frame))} / ${goal}`; await nextPaint(); }
168
+ }
169
+ // A pane booted alone (a slider moved) catches up with the other.
170
+ S.frame = Math.max(0, ...live().map((p) => p.api.frame));
171
+ S.dirty = true;
172
+ return true;
173
+ } finally {
174
+ if (token === S.token) { S.busy = false; showVeils(false); paint(); }
175
+ }
176
+ }
177
+ const nextPaint = () => new Promise((r) => requestAnimationFrame(() => r()));
178
+
179
+ /** Run the whole take once (fast), so the timeline and graphs are whole, then go back to the start. */
180
+ async function prime({ play = !CHECK } = {}) {
181
+ S.primed = false;
182
+ S.playing = false;
183
+ invalidate();
184
+ setStatus();
185
+ if (!(await boot(S.frames))) return;
186
+ S.primed = true;
187
+ setStatus();
188
+ if (!(await boot(0))) return;
189
+ if (play) setPlaying(true);
190
+ }
191
+
192
+ /* ------------------------------------------------------------------ the clock */
193
+ function setPlaying(on) {
194
+ if (on && S.frame >= S.frames) { S.acc = 0; S.playing = true; void restart(); updateButtons(); return; }
195
+ S.playing = on;
196
+ S.acc = 0;
197
+ for (const p of live()) p.api.set({ paused: !on });
198
+ updateButtons();
199
+ }
200
+ async function restart() { await boot(0); }
201
+
202
+ async function seek(f) {
203
+ const target = Math.max(1, Math.min(S.frames, Math.round(f)));
204
+ if (target === S.frame && !S.busy) return;
205
+ if (target > S.frame && live().length && !S.busy) { await advance(target - S.frame); paint(); return; }
206
+ S.seekTo = target;
207
+ if (S.seeking) return;
208
+ S.seeking = true;
209
+ try {
210
+ while (S.seekTo !== undefined) {
211
+ const t = S.seekTo;
212
+ S.seekTo = undefined;
213
+ await boot(t);
214
+ }
215
+ } finally { S.seeking = false; }
216
+ }
217
+
218
+ function tick(now) {
219
+ const real = S.last ? now - S.last : 0;
220
+ S.last = now;
221
+ if (S.playing && !S.busy && S.primed && live().length) {
222
+ S.acc += Math.min(250, real) * S.scale;
223
+ const step = 1000 / S.fps;
224
+ let n = Math.floor(S.acc / step);
225
+ if (n > 0) {
226
+ S.acc -= n * step;
227
+ n = Math.min(n, 4, S.frames - S.frame);
228
+ if (n > 0) { S.busy = true; void advance(n).finally(() => { S.busy = false; if (S.mode === 'record') recTick(); }); }
229
+ else if (S.frame >= S.frames) {
230
+ if (S.mode === 'record') void stopRecording();
231
+ else if (S.loop) void restart();
232
+ else setPlaying(false);
233
+ }
234
+ }
235
+ }
236
+ if (S.dirty) paint();
237
+ requestAnimationFrame(tick);
238
+ }
239
+
240
+ /* ------------------------------------------------------------------ what was recorded, as pictures */
241
+ function colourOf(name) {
242
+ if (!S.colours.has(name)) S.colours.set(name, PALETTE[S.colours.size % PALETTE.length]);
243
+ return S.colours.get(name);
244
+ }
245
+ function phasesOf(p) {
246
+ const out = [];
247
+ for (let i = 0; i < S.frames; i += 1) {
248
+ const r = p.rec[i];
249
+ const name = r?.ph?.[0] ?? null;
250
+ const prev = out[out.length - 1];
251
+ if (prev && prev.name === name && prev.to === i) { prev.to = i + 1; if (r?.ph?.[1]) prev.note = r.ph[1]; continue; }
252
+ if (r === undefined) continue;
253
+ if (name) out.push({ name, note: r.ph[1] ?? '', from: i, to: i + 1 });
254
+ else if (prev && prev.to === i) out.push({ name: null, from: i, to: i + 1 });
255
+ }
256
+ return out.filter((s) => s.name);
257
+ }
258
+
259
+ /** Every graphable value: what the game tracks, its body's speed (the port probe's), and its JavaScript per frame. */
260
+ function trackNames() {
261
+ const names = [...S.units.keys()];
262
+ if ([P.new, P.today].some((p) => p.rec.some((r) => r?.me))) names.push(BODY);
263
+ names.push(COST);
264
+ return names;
265
+ }
266
+ function unitOf(name) {
267
+ if (name === BODY) return 'u/s';
268
+ if (name === COST) return 'ms';
269
+ return S.units.get(name) || '';
270
+ }
271
+ function seriesOf(p, name) {
272
+ const out = new Array(S.frames).fill(null);
273
+ for (let i = 0; i < S.frames; i += 1) {
274
+ const r = p.rec[i];
275
+ if (!r) continue;
276
+ if (name === COST) out[i] = r.ms;
277
+ else if (name === BODY) {
278
+ const prev = p.rec[i - 1];
279
+ out[i] = r.me && prev?.me ? Math.hypot(r.me[0] - prev.me[0], r.me[1] - prev.me[1]) * S.fps : r.me ? 0 : null;
280
+ } else out[i] = Object.hasOwn(r.tr, name) ? r.tr[name] : null;
281
+ }
282
+ return out;
283
+ }
284
+ function motionOf(series) {
285
+ let max = 0;
286
+ for (const v of series) if (v !== null && Math.abs(v) > max) max = Math.abs(v);
287
+ return series.map((v) => (v === null || max === 0 ? null : Math.abs(v) <= max * 0.08 ? 'HOLD' : Math.abs(v) >= max * 0.6 ? 'FAST' : 'MOVE'));
288
+ }
289
+
290
+ /* ------------------------------------------------------------------ drawing: timeline and graph */
291
+ function canvasOf(id) {
292
+ const c = $(id);
293
+ const dpr = Math.min(3, devicePixelRatio || 1);
294
+ const w = Math.max(10, Math.round(c.clientWidth));
295
+ const h = Math.max(10, Math.round(c.clientHeight));
296
+ if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) { c.width = Math.round(w * dpr); c.height = Math.round(h * dpr); }
297
+ const ctx = c.getContext('2d');
298
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
299
+ ctx.clearRect(0, 0, w, h);
300
+ return { ctx, w, h };
301
+ }
302
+ const PAD_L = 8;
303
+ const PAD_R = 34;
304
+ const xOf = (f, w) => PAD_L + (f / S.frames) * (w - PAD_L - PAD_R);
305
+ const frameAt = (x, w) => Math.round(((x - PAD_L) / (w - PAD_L - PAD_R)) * S.frames);
306
+
307
+ function drawTimeline() {
308
+ const { ctx, w } = canvasOf('timeline');
309
+ const mono = '700 9.5px ui-monospace, SFMono-Regular, Menlo, monospace';
310
+ const rows = [
311
+ { p: P.new, y: 0, h: 19, label: true },
312
+ { p: P.today, y: 27, h: 13, label: true },
313
+ ];
314
+ const track = S.track ?? trackNames()[0];
315
+ for (const row of rows) {
316
+ ctx.fillStyle = '#0a0e18';
317
+ ctx.fillRect(PAD_L, row.y, w - PAD_L - PAD_R, row.h);
318
+ if (!row.p.available) continue;
319
+ for (const s of phasesOf(row.p)) {
320
+ const x0 = xOf(s.from, w); const x1 = xOf(s.to, w);
321
+ const c = colourOf(s.name);
322
+ ctx.fillStyle = c;
323
+ ctx.globalAlpha = row.p === P.new ? 0.9 : 0.6;
324
+ ctx.fillRect(x0, row.y, Math.max(1, x1 - x0 - 1), row.h);
325
+ ctx.globalAlpha = 1;
326
+ const here = S.frame - 1 >= s.from && S.frame - 1 < s.to;
327
+ if (here) { ctx.strokeStyle = '#ffad3b'; ctx.lineWidth = 2; ctx.strokeRect(x0 + 1, row.y + 1, Math.max(1, x1 - x0 - 3), row.h - 2); }
328
+ ctx.font = mono;
329
+ const text = s.name;
330
+ if (row.label && ctx.measureText(text).width + 8 < x1 - x0) { ctx.fillStyle = '#0a0d14'; ctx.textBaseline = 'middle'; ctx.fillText(text, x0 + 4, row.y + row.h / 2 + 0.5); }
331
+ }
332
+ // Motion beneath each row: HOLD, MOVE, FAST, from the chosen graph's values.
333
+ const m = motionOf(seriesOf(row.p, track));
334
+ const y = row.y + row.h + 1;
335
+ for (let i = 0; i < m.length; i += 1) {
336
+ if (!m[i]) continue;
337
+ ctx.fillStyle = MOTION[m[i]];
338
+ ctx.fillRect(xOf(i, w), y, Math.max(1, xOf(i + 1, w) - xOf(i, w)), 4);
339
+ }
340
+ }
341
+ // The ruler: a tick every 10 frames, seconds named.
342
+ const ry = 48;
343
+ ctx.fillStyle = '#59627c';
344
+ ctx.font = '600 9px ui-monospace, SFMono-Regular, Menlo, monospace';
345
+ ctx.textBaseline = 'top';
346
+ const every = S.frames > 600 ? 60 : S.frames > 240 ? 20 : 10;
347
+ for (let f = 0; f <= S.frames; f += every) {
348
+ const x = xOf(f, w);
349
+ const sec = f % S.fps === 0;
350
+ ctx.fillRect(Math.round(x), ry - (sec ? 3 : 1), 1, sec ? 5 : 3);
351
+ const label = sec ? `${f / S.fps}s` : String(f);
352
+ if (x + ctx.measureText(label).width < w - PAD_R + 20) { ctx.fillStyle = sec ? '#8d96b0' : '#59627c'; ctx.fillText(label, x + 2, ry + 1); ctx.fillStyle = '#59627c'; }
353
+ }
354
+ playhead(ctx, w, 0, 58);
355
+ }
356
+
357
+ function drawGraph() {
358
+ const { ctx, w, h } = canvasOf('graph');
359
+ const name = S.track ?? trackNames()[0];
360
+ const a = seriesOf(P.new, name);
361
+ const b = P.today.available ? seriesOf(P.today, name) : [];
362
+ let max = 0; let min = 0;
363
+ for (const v of [...a, ...b]) if (v !== null) { if (v > max) max = v; if (v < min) min = v; }
364
+ if (max === min) max = min + 1;
365
+ const nice = niceMax(max);
366
+ const top = 16; const bottom = h - 6;
367
+ const yOf = (v) => bottom - ((v - min) / (nice - min)) * (bottom - top);
368
+ ctx.fillStyle = '#0a0e18';
369
+ ctx.fillRect(PAD_L, top, w - PAD_L - PAD_R, bottom - top);
370
+ // Grid, and the axis on the right.
371
+ ctx.font = '600 9px ui-monospace, SFMono-Regular, Menlo, monospace';
372
+ ctx.textBaseline = 'middle';
373
+ for (const v of [0, nice / 2, nice]) {
374
+ const y = Math.round(yOf(v)) + 0.5;
375
+ ctx.strokeStyle = '#1a2236'; ctx.lineWidth = 1;
376
+ ctx.beginPath(); ctx.moveTo(PAD_L, y); ctx.lineTo(w - PAD_R, y); ctx.stroke();
377
+ ctx.fillStyle = '#59627c'; ctx.fillText(fmt(v), w - PAD_R + 5, y);
378
+ }
379
+ // New's phase edges, faint.
380
+ ctx.strokeStyle = 'rgba(141,150,176,.16)';
381
+ for (const s of phasesOf(P.new)) { const x = Math.round(xOf(s.from, w)) + 0.5; ctx.beginPath(); ctx.moveTo(x, top); ctx.lineTo(x, bottom); ctx.stroke(); }
382
+ const path = (series) => {
383
+ ctx.beginPath();
384
+ let on = false;
385
+ for (let i = 0; i < series.length; i += 1) {
386
+ const v = series[i];
387
+ if (v === null) { on = false; continue; }
388
+ const x = xOf(i + 0.5, w); const y = yOf(v);
389
+ if (!on) { ctx.moveTo(x, y); on = true; } else ctx.lineTo(x, y);
390
+ }
391
+ };
392
+ // New: a filled line in amber.
393
+ ctx.save();
394
+ path(a);
395
+ ctx.strokeStyle = '#ffad3b'; ctx.lineWidth = 1.6; ctx.lineJoin = 'round'; ctx.stroke();
396
+ ctx.restore();
397
+ ctx.save();
398
+ ctx.beginPath();
399
+ let started = false; let lastX = 0; let firstX = 0;
400
+ for (let i = 0; i < a.length; i += 1) {
401
+ if (a[i] === null) continue;
402
+ const x = xOf(i + 0.5, w); const y = yOf(a[i]);
403
+ if (!started) { ctx.moveTo(x, yOf(Math.max(min, 0))); ctx.lineTo(x, y); started = true; firstX = x; } else ctx.lineTo(x, y);
404
+ lastX = x;
405
+ }
406
+ if (started) { ctx.lineTo(lastX, yOf(Math.max(min, 0))); ctx.lineTo(firstX, yOf(Math.max(min, 0))); ctx.fillStyle = 'rgba(255,173,59,.16)'; ctx.fill(); }
407
+ ctx.restore();
408
+ // Today: dashed, cool.
409
+ if (b.length) { ctx.save(); path(b); ctx.setLineDash([4, 3]); ctx.strokeStyle = 'rgba(124,196,255,.9)'; ctx.lineWidth = 1.3; ctx.stroke(); ctx.restore(); }
410
+ // Its name, and the two lines' names.
411
+ ctx.textBaseline = 'top';
412
+ ctx.font = '700 10px ui-monospace, SFMono-Regular, Menlo, monospace';
413
+ ctx.fillStyle = '#8d96b0';
414
+ ctx.fillText(`${name}${unitOf(name) ? ` ${unitOf(name)}` : ''}`, PAD_L + 6, 2);
415
+ const legend = [['new', '#ffad3b', false], ...(b.length ? [['today', '#7cc4ff', true]] : [])];
416
+ let lx = w - PAD_R - 6;
417
+ ctx.textAlign = 'right';
418
+ for (const [label, colour, dashed] of legend.reverse()) {
419
+ ctx.fillStyle = colour; ctx.fillText(label, lx, 2);
420
+ const tw = ctx.measureText(label).width;
421
+ ctx.strokeStyle = colour; ctx.lineWidth = 1.5; ctx.setLineDash(dashed ? [3, 2] : []);
422
+ ctx.beginPath(); ctx.moveTo(lx - tw - 22, 7); ctx.lineTo(lx - tw - 6, 7); ctx.stroke(); ctx.setLineDash([]);
423
+ lx -= tw + 34;
424
+ }
425
+ ctx.textAlign = 'left';
426
+ playhead(ctx, w, top, bottom);
427
+ }
428
+
429
+ function playhead(ctx, w, y0, y1) {
430
+ if (!S.frames) return;
431
+ const x = Math.round(xOf(Math.max(0, S.frame - 0.5), w)) + 0.5;
432
+ ctx.strokeStyle = '#ffad3b'; ctx.lineWidth = 2;
433
+ ctx.beginPath(); ctx.moveTo(x, y0); ctx.lineTo(x, y1); ctx.stroke();
434
+ }
435
+ function niceMax(v) { const p = 10 ** Math.floor(Math.log10(Math.max(1e-9, v))); for (const m of [1, 2, 2.5, 5, 10]) if (m * p >= v) return m * p; return 10 * p; }
436
+ function fmt(v) { const a = Math.abs(v); return a >= 1000 ? `${Math.round(v / 100) / 10}k` : a >= 100 ? String(Math.round(v)) : a >= 10 ? (Math.round(v * 10) / 10).toString() : (Math.round(v * 100) / 100).toString(); }
437
+
438
+ /* ------------------------------------------------------------------ the stage */
439
+ const BAND_TOP = 26;
440
+ const BAND_BOTTOM = 34;
441
+ function layoutStage() {
442
+ const stage = $('stage');
443
+ const W = stage.clientWidth; const H = stage.clientHeight;
444
+ const d = DEVICES[S.device];
445
+ const both = S.layout === 'both';
446
+ const pad = W < 640 ? 8 : 14; const gap = W < 640 ? 8 : 16;
447
+ // Each pane has a band above it (its name) and below it (the phase it is in), outside the game's own picture.
448
+ const BAND = BAND_TOP + BAND_BOTTOM;
449
+ const one = Math.min((W - pad * 2) / d.w, (H - pad * 2 - BAND) / d.h);
450
+ const row = Math.min((W - pad * 2 - gap) / 2 / d.w, (H - pad * 2 - BAND) / d.h);
451
+ const col = Math.min((W - pad * 2) / d.w, (H - pad * 2 - gap - BAND * 2) / 2 / d.h);
452
+ const place = (p, x, y, s, show) => {
453
+ p.slot.hidden = !show;
454
+ p.slot.style.left = `${Math.round(x)}px`; p.slot.style.top = `${Math.round(y)}px`;
455
+ p.slot.style.width = `${Math.round(d.w * s)}px`; p.slot.style.height = `${Math.round(d.h * s) + BAND}px`;
456
+ p.el.style.transform = `scale(${s})`;
457
+ };
458
+ P.new.slot.classList.remove('ghost'); P.today.slot.classList.remove('ghost');
459
+ if (both) {
460
+ if (row >= col) {
461
+ const s = row; const tw = d.w * s * 2 + gap;
462
+ const x = (W - tw) / 2; const y = (H - d.h * s - BAND) / 2;
463
+ place(P.new, x, y, s, true); place(P.today, x + d.w * s + gap, y, s, true);
464
+ } else {
465
+ const s = col; const th = (d.h * s + BAND) * 2 + gap;
466
+ const x = (W - d.w * s) / 2; const y = (H - th) / 2;
467
+ place(P.new, x, y, s, true); place(P.today, x, y + d.h * s + BAND + gap, s, true);
468
+ }
469
+ } else {
470
+ const s = one;
471
+ const x = (W - d.w * s) / 2; const y = (H - d.h * s - BAND) / 2;
472
+ place(P.new, x, y, s, S.layout !== 'today');
473
+ place(P.today, x, y, s, S.layout !== 'new');
474
+ if (S.layout === 'ghost') P.today.slot.classList.add('ghost');
475
+ }
476
+ }
477
+
478
+ function showVeils(busy) {
479
+ for (const p of [P.new, P.today]) {
480
+ const v = $(`veil-${p.name}`);
481
+ if (!p.available) { v.hidden = false; v.innerHTML = ''; const b = document.createElement('b'); b.textContent = p.name === 'new' ? 'NEW' : 'TODAY'; v.append(b, document.createTextNode(p.why ?? 'Not available.')); continue; }
482
+ v.hidden = true;
483
+ }
484
+ $('stage').classList.toggle('busy', busy);
485
+ }
486
+
487
+ /* ------------------------------------------------------------------ painting the page */
488
+ function paint() {
489
+ S.dirty = false;
490
+ const sec = S.frame / S.fps;
491
+ if (!S.busy || S.frame > 0) $('counter').textContent = `F ${S.frame} / ${S.frames} · ${sec.toFixed(2)} s`;
492
+ for (const p of [P.new, P.today]) {
493
+ const cap = $(`cap-${p.name}`);
494
+ cap.innerHTML = '';
495
+ const r = p.rec[S.frame - 1];
496
+ if (r?.ph && p.available) {
497
+ const b = document.createElement('b'); b.textContent = r.ph[0]; b.style.color = colourOf(r.ph[0]); b.style.borderColor = colourOf(r.ph[0]);
498
+ cap.append(b);
499
+ if (r.ph[1]) { const s = document.createElement('span'); s.textContent = r.ph[1]; cap.append(s); }
500
+ }
501
+ }
502
+ drawTimeline();
503
+ drawGraph();
504
+ }
505
+
506
+ function setStatus() {
507
+ const box = $('status');
508
+ box.innerHTML = '';
509
+ const chip = (text, cls = '', title = '') => { const c = document.createElement('span'); c.className = `chip ${cls}`; const d = document.createElement('span'); d.className = 'dot'; c.append(d, document.createTextNode(text)); if (title) c.title = title; box.append(c); return c; };
510
+ const st = S.state;
511
+ if (!st) return;
512
+ if (!S.primed) chip('Reading the take', 'busy warn');
513
+ if (!st.new.ok) chip('New did not build', 'bad', st.new.error ?? '');
514
+ if (st.today.ok && st.new.dirty === 0 && st.today.ref === 'HEAD') chip('New = Today', 'warn', 'games/ has no change since the last commit: change something and New shows it');
515
+ else if (st.new.dirty) chip(`${st.new.dirty} file${st.new.dirty === 1 ? '' : 's'} changed`, '', 'games/ differs from the last commit');
516
+ for (const p of [P.new, P.today]) { const e = p.meta?.errors ?? []; if (e.length) chip(`${e.length} error${e.length === 1 ? '' : 's'} in ${p.name}`, 'bad', e.join('\n')); }
517
+ if (S.mismatch) chip(`Replays differ at F${S.mismatch.f} (${S.mismatch.pane})`, 'bad', 'The same build played the same take differently: something in the game reads a clock, dice or input the lab does not drive (crypto, a fetch, an audio clock). The lab skill says how to find it.');
518
+ else if (S.primed && P.new.rec.length && S.replayed) chip('Replays match', 'ok', 'Every replay of this take landed on the same frames as the first run');
519
+ $('new-sub').textContent = st.new.ok ? `working tree${st.new.dirty ? ` · ${st.new.dirty} changed` : ''}` : 'did not build';
520
+ $('today-sub').textContent = st.today.ok ? `${st.today.commit.short} ${st.today.commit.subject}` : st.today.ref;
521
+ const instrumented = S.told.new || S.told.today;
522
+ const hint = $('hint');
523
+ if (P.new.meta && !instrumented) hint.innerHTML = 'This game tells the lab nothing yet: the graph is its body\'s speed from the port probe. The lab skill adds <code>lab.phase</code>, <code>lab.track</code> and <code>lab.tunables</code>.';
524
+ else if (!S.takeName) hint.innerHTML = 'No take yet: press <code>● REC</code> and play the move in NEW (both builds get the same presses), or write one in <code>lab.json</code>.';
525
+ else hint.textContent = `${S.takeRaw.note ?? ''}${S.takeRaw.note ? ' · ' : ''}${S.take.stage ? `stage ${S.take.stage} · ` : ''}seed ${S.take.seed} · ${(S.takeRaw.inputs ?? []).length} press${(S.takeRaw.inputs ?? []).length === 1 ? '' : 'es'} · ${DEVICES[S.device].label} ${DEVICES[S.device].w}×${DEVICES[S.device].h}`;
526
+ }
527
+
528
+ /* ------------------------------------------------------------------ controls */
529
+ function buttons(boxId, items, current, onPick, label) {
530
+ const box = $(boxId);
531
+ box.innerHTML = '';
532
+ if (!items.length) return;
533
+ if (label) { const l = document.createElement('span'); l.className = 'label'; l.textContent = label; box.append(l); }
534
+ for (const [value, text, title] of items) {
535
+ const b = document.createElement('button');
536
+ b.type = 'button'; b.textContent = text; if (title) b.title = title;
537
+ const on = typeof current === 'function' ? current(value) : current === value;
538
+ b.classList.toggle('on', on);
539
+ b.setAttribute('aria-pressed', String(on));
540
+ b.onclick = () => onPick(value);
541
+ box.append(b);
542
+ }
543
+ }
544
+
545
+ function updateButtons() {
546
+ buttons('layout', [['new', 'New'], ['today', 'Today'], ['both', 'Both'], ['ghost', 'Ghost', 'Today over New, half strength']], S.layout, (v) => { S.layout = v; layoutStage(); updateButtons(); });
547
+ const views = [...new Set([...(P.new.meta?.views ?? []), ...(P.today.meta?.views ?? [])])];
548
+ buttons('views', views.map((v) => [v, v]), (v) => (S.view ?? views[0]) === v, (v) => { S.view = v; for (const p of live()) p.api.set({ view: v }); redrawIfPaused(); updateButtons(); }, views.length ? 'View' : '');
549
+ buttons('device', Object.entries(DEVICES).map(([k, d]) => [k, d.label, `${d.w}×${d.h}`]), S.device, (v) => { if (v === S.device) return; S.device = v; S.deviceSet = true; layoutStage(); updateButtons(); void prime(); });
550
+ const overlays = [...new Set([...(P.new.meta?.overlays ?? []), ...(P.today.meta?.overlays ?? [])])];
551
+ buttons('overlays', overlays.map((o) => [o, o]), (o) => S.overlays.has(o), (o) => { if (S.overlays.has(o)) S.overlays.delete(o); else S.overlays.add(o); for (const p of live()) p.api.set({ overlays: { [o]: S.overlays.has(o) } }); redrawIfPaused(); updateButtons(); }, overlays.length ? 'Show' : '');
552
+ buttons('speeds', SCALES.map((s) => [s, SPEED_LABEL[s], `${s}× speed`]), S.scale, (s) => { S.scale = s; for (const p of live()) p.api.set({ scale: s }); updateButtons(); });
553
+ buttons('fpses', FPS.map((f) => [f, String(f), `${f} frames a second`]), S.fps, (f) => { if (f === S.fps) return; S.fps = f; S.take = expandTake(S.takeRaw, { fps: f }); S.frames = S.take.frames; updateButtons(); void prime(); });
554
+ const tracks = trackNames();
555
+ buttons('tracks', tracks.map((t) => [t, t]), (t) => (S.track ?? tracks[0]) === t, (t) => { S.track = t; S.dirty = true; updateButtons(); });
556
+ $('play').textContent = S.playing ? 'Pause' : 'Play';
557
+ $('play').classList.toggle('on', S.playing);
558
+ $('loop').classList.toggle('on', S.loop);
559
+ $('rec').classList.toggle('on', S.mode === 'record');
560
+ const sel = $('take-pick');
561
+ const names = Object.keys(S.state?.takes?.takes ?? {});
562
+ if (sel.dataset.sig !== names.join('|') + S.takeName) {
563
+ sel.dataset.sig = names.join('|') + S.takeName;
564
+ sel.innerHTML = '';
565
+ for (const n of names) { const o = document.createElement('option'); o.value = n; o.textContent = n; o.selected = n === S.takeName; sel.append(o); }
566
+ if (!names.length) { const o = document.createElement('option'); o.textContent = 'no take yet'; sel.append(o); }
567
+ }
568
+ legend();
569
+ }
570
+
571
+ function legend() {
572
+ const box = $('legend');
573
+ box.innerHTML = '';
574
+ for (const [k, c] of Object.entries(MOTION)) { const s = document.createElement('span'); const i = document.createElement('i'); i.style.background = c; s.append(i, document.createTextNode(k)); box.append(s); }
575
+ const r = document.createElement('span'); r.className = 'right'; r.textContent = 'phases: new above, today below'; box.append(r);
576
+ }
577
+
578
+ async function redrawIfPaused() { if (!S.playing && S.primed) await boot(S.frame); }
579
+
580
+ /* ------------------------------------------------------------------ tunables */
581
+ const pretty = (k) => k.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ').toLowerCase();
582
+ function tunablesView() {
583
+ const list = $('tune-list');
584
+ list.innerHTML = '';
585
+ const t = S.state?.tunables ?? {};
586
+ const spec = t.spec ?? P.new.meta?.tunables ?? null;
587
+ $('tune-file').textContent = t.spec ? t.file : '';
588
+ if (!spec || !Object.keys(spec).length) {
589
+ const p = document.createElement('p'); p.className = 'tune-empty';
590
+ p.innerHTML = t.error ? `tunables.json: ${escapeHtml(t.error)}` : 'No tunables yet. Put the numbers that shape the move (a launch speed, a hit-stop, an ease) in <code>tunables.json</code> and read them with <code>lab.tunables</code>: each becomes a slider here, and a kept slider is written back into the file.';
591
+ list.append(p);
592
+ } else {
593
+ let group = null;
594
+ for (const [name, raw] of Object.entries(spec)) {
595
+ const s = typeof raw === 'number' ? { value: raw } : raw;
596
+ if (s.group && s.group !== group) { group = s.group; const g = document.createElement('div'); g.className = 'tune-group'; g.textContent = group; list.append(g); }
597
+ const file = Number(s.value);
598
+ const v = Object.hasOwn(S.overrides, name) ? S.overrides[name] : file;
599
+ const min = Number.isFinite(s.min) ? s.min : Math.min(0, file);
600
+ const max = Number.isFinite(s.max) ? s.max : Math.max(1, Math.abs(file) * 2);
601
+ const step = Number.isFinite(s.step) ? s.step : (max - min) / 200;
602
+ const today = t.today?.[name];
603
+ const k = document.createElement('div'); k.className = `knob${v !== file ? ' changed' : ''}`;
604
+ const row = document.createElement('div'); row.className = 'row';
605
+ const n = document.createElement('span'); n.className = 'name'; n.textContent = pretty(name); n.title = name;
606
+ const val = document.createElement('span'); val.className = 'val'; val.textContent = fmtVal(v, step);
607
+ const unit = document.createElement('span'); unit.className = 'unit'; unit.textContent = s.unit ?? '';
608
+ row.append(n, val, unit);
609
+ const track = document.createElement('div'); track.className = 'track';
610
+ const input = document.createElement('input');
611
+ input.type = 'range'; input.min = String(min); input.max = String(max); input.step = String(step); input.value = String(v);
612
+ input.setAttribute('aria-label', `${pretty(name)}${s.unit ? ` (${s.unit})` : ''}`);
613
+ const fill = () => input.style.setProperty('--fill', `${((Number(input.value) - min) / (max - min || 1)) * 100}%`);
614
+ fill();
615
+ let tick = null;
616
+ if (Number.isFinite(today) && max > min) { tick = document.createElement('i'); tick.className = 'tick'; tick.style.left = `calc(${((today - min) / (max - min)) * 100}% - 1px)`; tick.title = `Today: ${today}`; tick.hidden = today === v; }
617
+ input.oninput = () => { const x = Number(input.value); val.textContent = fmtVal(x, step); fill(); if (x === file) delete S.overrides[name]; else S.overrides[name] = x; k.classList.toggle('changed', x !== file); if (tick) tick.hidden = today === x; footState(); retune(); };
618
+ track.append(input);
619
+ if (tick) track.append(tick);
620
+ k.append(row, track);
621
+ const was = document.createElement('span'); was.className = 'was';
622
+ was.textContent = [v !== file ? `file ${fmtVal(file, step)}` : null, Number.isFinite(today) && today !== v ? `today ${fmtVal(today, step)}` : null].filter(Boolean).join(' · ');
623
+ if (was.textContent) k.append(was);
624
+ if (s.note) { const no = document.createElement('span'); no.className = 'note'; no.textContent = s.note; k.append(no); }
625
+ list.append(k);
626
+ }
627
+ }
628
+ footState();
629
+ }
630
+ const fmtVal = (v, step) => (step >= 1 ? String(Math.round(v)) : step >= 0.1 ? v.toFixed(1) : step >= 0.01 ? v.toFixed(2) : String(Math.round(v * 1000) / 1000));
631
+ function footState() {
632
+ const any = Object.keys(S.overrides).length > 0;
633
+ $('keep').disabled = !any || !S.state?.tunables?.spec;
634
+ $('undo').disabled = !any;
635
+ }
636
+ let retuneTimer = 0;
637
+ function retune() {
638
+ clearTimeout(retuneTimer);
639
+ retuneTimer = setTimeout(async () => {
640
+ invalidate([P.new]);
641
+ const playing = S.playing;
642
+ await boot(S.frame, ['new']);
643
+ if (playing) setPlaying(true);
644
+ }, 200);
645
+ }
646
+
647
+ async function keep() {
648
+ const values = { ...S.overrides };
649
+ $('keep').disabled = true;
650
+ $('keep').textContent = 'Keeping…';
651
+ try {
652
+ const r = await fetch(`/_lab/api/${GAME}/tunables`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ values }) });
653
+ const j = await r.json();
654
+ if (!j.ok) throw new Error(j.why);
655
+ S.overrides = {};
656
+ await loadState();
657
+ tunablesView();
658
+ invalidate([P.new]);
659
+ await boot(S.frame, ['new']);
660
+ flash(`Kept in ${j.file}: ${j.changed.map((c) => `${c.name} ${c.from} → ${c.to}`).join(', ')}`);
661
+ } catch (error) { flash(`Not kept: ${error.message}`); }
662
+ finally { $('keep').textContent = 'Keep in code'; footState(); setStatus(); }
663
+ }
664
+ function flash(text) { $('hint').textContent = text; }
665
+ function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]); }
666
+
667
+ /* ------------------------------------------------------------------ recording a take */
668
+ const REC_SECONDS = 20;
669
+ async function startRecording() {
670
+ S.mode = 'record';
671
+ S.recFramesBefore = S.frames;
672
+ S.frames = Math.round(REC_SECONDS * S.fps);
673
+ invalidate();
674
+ $('recbar').hidden = false;
675
+ $('rec-text').textContent = `Recording: play in NEW (both builds get your presses) · up to ${REC_SECONDS} s`;
676
+ updateButtons();
677
+ S.primed = true;
678
+ await boot(0);
679
+ P.new.el.focus();
680
+ try { P.new.el.contentWindow.focus(); } catch { /* fine */ }
681
+ setPlaying(true);
682
+ }
683
+ function recTick() { $('rec-text').textContent = `Recording ${(S.frame / S.fps).toFixed(1)} s: play in NEW (both builds get your presses)`; }
684
+ async function stopRecording() {
685
+ if (S.mode !== 'record') return;
686
+ setPlaying(false);
687
+ const events = (P.new.api?.recorded ?? []).map((e) => ({ ...e }));
688
+ const seconds = Math.max(0.5, Math.ceil((S.frame / S.fps) * 10) / 10);
689
+ S.mode = 'replay';
690
+ $('recbar').hidden = true;
691
+ const taken = Object.keys(S.state.takes.takes);
692
+ let n = taken.length + 1;
693
+ while (taken.includes(`take-${n}`)) n += 1;
694
+ const name = (CHECK ? `take-${n}` : (prompt('Name this take (lowercase, hyphens)', `take-${n}`) ?? '')).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32);
695
+ const take = { note: 'Recorded in the lab', seconds, seed: S.take?.seed ?? 1, device: S.device, fps: S.fps, ...(S.take?.stage ? { stage: S.take.stage } : {}), ...(S.view ? { view: S.view } : {}), ...(S.overlays.size ? { overlays: [...S.overlays] } : {}), ...(S.track ? { track: S.track } : {}), ...(S.takeRaw?.storage ? { storage: S.takeRaw.storage } : {}), inputs: inputsFromRecording(events) };
696
+ if (!name) { S.frames = S.recFramesBefore; flash('Recording dropped (no name).'); await prime(); return; }
697
+ const r = await fetch(`/_lab/api/${GAME}/take`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, take, default: true }) });
698
+ const j = await r.json();
699
+ if (!j.ok) { flash(`Not saved: ${j.why}`); S.frames = S.recFramesBefore; await prime(); return; }
700
+ await loadState();
701
+ pickTake(name);
702
+ updateButtons();
703
+ setStatus();
704
+ flash(`Take "${name}" saved in ${j.file}: ${take.inputs.length} inputs over ${seconds} s.`);
705
+ await prime();
706
+ }
707
+
708
+ /* ------------------------------------------------------------------ scrubbing */
709
+ function scrubber(id) {
710
+ const c = $(id);
711
+ let down = false;
712
+ const at = (e) => { const r = c.getBoundingClientRect(); return frameAt(e.clientX - r.left, r.width); };
713
+ c.addEventListener('pointerdown', (e) => { if (!S.primed || S.mode === 'record') return; down = true; c.setPointerCapture(e.pointerId); setPlaying(false); void seek(at(e)); });
714
+ c.addEventListener('pointermove', (e) => { if (down) void seek(at(e)); });
715
+ const up = () => { down = false; };
716
+ c.addEventListener('pointerup', up);
717
+ c.addEventListener('pointercancel', up);
718
+ }
719
+
720
+ /* ------------------------------------------------------------------ start */
721
+ function wire() {
722
+ $('play').onclick = () => setPlaying(!S.playing);
723
+ $('to-start').onclick = () => { setPlaying(false); void seek(0); };
724
+ $('to-end').onclick = () => { setPlaying(false); void seek(S.frames); };
725
+ $('back').onclick = () => { setPlaying(false); void seek(S.frame - 1); };
726
+ $('fwd').onclick = () => { setPlaying(false); void seek(S.frame + 1); };
727
+ $('loop').onclick = () => { S.loop = !S.loop; updateButtons(); };
728
+ $('keep').onclick = () => void keep();
729
+ $('undo').onclick = () => { S.overrides = {}; tunablesView(); retune(); };
730
+ $('rec').onclick = () => (S.mode === 'record' ? void stopRecording() : void startRecording());
731
+ $('rec-stop').onclick = () => void stopRecording();
732
+ $('take-pick').onchange = (e) => { pickTake(e.target.value); updateButtons(); setStatus(); void prime(); };
733
+ scrubber('timeline');
734
+ scrubber('graph');
735
+ addEventListener('resize', () => { layoutStage(); S.dirty = true; });
736
+ addEventListener('keydown', (e) => {
737
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement || e.metaKey || e.ctrlKey) return;
738
+ const k = e.code;
739
+ if (k === 'Space') { e.preventDefault(); setPlaying(!S.playing); }
740
+ else if (k === 'ArrowLeft') { e.preventDefault(); setPlaying(false); void seek(S.frame - (e.shiftKey ? 10 : 1)); }
741
+ else if (k === 'ArrowRight') { e.preventDefault(); setPlaying(false); void seek(S.frame + (e.shiftKey ? 10 : 1)); }
742
+ else if (k === 'Home') { setPlaying(false); void seek(0); }
743
+ else if (k === 'End') { setPlaying(false); void seek(S.frames); }
744
+ else if (/^Digit[1-4]$/.test(k)) { S.scale = SCALES[Number(k.slice(5)) - 1]; for (const p of live()) p.api.set({ scale: S.scale }); updateButtons(); }
745
+ });
746
+ if (!CHECK && typeof EventSource === 'function') {
747
+ const es = new EventSource(`/_lab/api/${GAME}/events`);
748
+ es.onmessage = async (m) => {
749
+ let e; try { e = JSON.parse(m.data); } catch { return; }
750
+ if (e.k !== 'build') return;
751
+ await loadState();
752
+ setStatus();
753
+ tunablesView();
754
+ const p = e.pane === 'today' ? P.today : P.new;
755
+ if (!e.ok) { flash(`${e.pane === 'today' ? 'Today' : 'New'} did not build: ${e.error}`); return; }
756
+ flash(e.pane === 'today' ? `Today is now ${S.state.today.commit?.short} "${S.state.today.commit?.subject}".` : 'New rebuilt from your change.');
757
+ invalidate([p]);
758
+ const playing = S.playing;
759
+ await boot(S.frame, [p.name]);
760
+ if (playing) setPlaying(true);
761
+ };
762
+ }
763
+ }
764
+
765
+ async function main() {
766
+ wire();
767
+ await loadState();
768
+ pickTake(Q.get('take'));
769
+ if (Q.get('layout') && ['new', 'today', 'both', 'ghost'].includes(Q.get('layout'))) S.layout = Q.get('layout');
770
+ if (Q.get('view')) S.view = Q.get('view');
771
+ if (Q.get('speed') && SCALES.includes(Number(Q.get('speed')))) S.scale = Number(Q.get('speed'));
772
+ layoutStage();
773
+ updateButtons();
774
+ setStatus();
775
+ tunablesView();
776
+ requestAnimationFrame(tick);
777
+ await prime();
778
+ S.replayed = true;
779
+ updateButtons();
780
+ setStatus();
781
+ tunablesView();
782
+ }
783
+
784
+ /* ------------------------------------------------------------------ what `homie-studio lab check` drives */
785
+ const readyP = main();
786
+ window.labApp = {
787
+ ready: readyP,
788
+ state: () => ({ frame: S.frame, frames: S.frames, fps: S.fps, device: S.device, take: S.takeName, playing: S.playing, busy: S.busy, primed: S.primed, mismatch: S.mismatch }),
789
+ data() {
790
+ const one = (p) => ({ available: p.available, why: p.why, meta: p.meta, frames: p.rec.map((r) => (r ? { f: r.f, tr: r.tr, ph: r.ph, ms: r.ms, sig: r.sig, me: r.me } : null)), phases: phasesOf(p) });
791
+ return { game: GAME, take: S.takeName, note: S.takeRaw?.note ?? null, fps: S.fps, frames: S.frames, device: S.device, seed: S.take?.seed ?? 1, inputs: S.take?.inputs.length ?? 0, todayBuild: S.state?.today ?? null, newBuild: S.state?.new ?? null, tunables: S.state?.tunables ?? null, overrides: { ...S.overrides }, units: Object.fromEntries(trackNames().map((t) => [t, unitOf(t)])), mismatch: S.mismatch, new: one(P.new), today: one(P.today) };
792
+ },
793
+ /** The whole take again, from a fresh load: each frame is compared with the run before (mismatch says where). */
794
+ async rerun() { S.mismatch = null; await boot(S.frames); paint(); return this.data(); },
795
+ async seek(f) { setPlaying(false); await seek(f); paint(); await nextPaint(); await nextPaint(); return S.frame; },
796
+ setTrack(t) { S.track = t; updateButtons(); paint(); },
797
+ setLayout(l) { S.layout = l; layoutStage(); updateButtons(); },
798
+ setOverlays(list) { S.overlays = new Set(list); for (const p of live()) p.api.set({ overlays: Object.fromEntries((p.meta?.overlays ?? []).map((o) => [o, S.overlays.has(o)])) }); updateButtons(); },
799
+ setView(v) { S.view = v; for (const p of live()) p.api.set({ view: v }); updateButtons(); },
800
+ setScale(s) { S.scale = s; updateButtons(); },
801
+ play(on = true) { setPlaying(on); },
802
+ };