@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.
package/CHANGELOG.md CHANGED
@@ -17,9 +17,60 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.20.0] - 2026-10-02
21
+
22
+ **Plugin 0.21.0** · [#30](https://github.com/homie-rocks/homie/pull/30)
23
+
24
+ A Game Lab: tune how one move of your game feels (a jump, a hit, a dash), your new version beside your last commit,
25
+ frame by frame.
26
+
27
+ ### Added
28
+
29
+ - Plugin: the `lab` skill. Say "iterate on the jump", "make the hit feel punchier" or "tune the drift". Claude first
30
+ teaches your game to tell the lab about that move (its phases, the numbers that shape it) without changing how it
31
+ plays, and commits that. Then it proposes the change as named phases and numbers, shows it to you New beside Today,
32
+ keeps only what you like, and can record a side-by-side clip to share.
33
+ - `homie-studio lab <game>`: the Game Lab, a page on your own computer. One short take of your game plays in New (your
34
+ working copy, rebuilt every time a file is saved) beside Today (your last commit), on one clock, with the same
35
+ presses on the same frames. Slow it to a quarter or a tenth, step a frame at a time, try 30, 15 or 12 frames a
36
+ second, or switch to a phone-sized screen; the frames are the same frames at any speed.
37
+ - In the lab: a timeline that names each phase of the move, graphs of New against Today, captions, the game's own
38
+ onion skin and arcs, and its views (close, the whole arena). Its numbers are sliders: move one and New plays it at
39
+ once, and "Keep in code" writes the ones you like into the game's `tunables.json`. Press REC and play a move to make
40
+ a new take; both versions get your presses.
41
+ - `homie-studio lab check <game>`: the same take with no window. How long each phase lasts in each version, the peaks
42
+ of what the game tracks, whether a replay landed on exactly the same frames, a contact sheet and a still.
43
+ `homie-studio lab set <game> name=value` writes a number into `tunables.json`; `homie-studio lab --stop` stops the lab.
44
+ - Homie for Claude Desktop (`homie-studio mcp`): `game_lab` opens the lab and answers with a card: a still of both
45
+ versions at the move's busiest moment, their phases side by side, and Open.
46
+ - For your game's code, `@homie-rocks/studio/lab`: `lab.tunables`, `lab.phase`, `lab.track`, `lab.camera`,
47
+ `lab.overlay`, `lab.stage` (a training dummy for one move) and `lab.random` (dice for sparks and shake). Every call
48
+ does nothing outside the lab, so the calls stay in your game.
49
+ - Plugin: the perf skill's `try --measure` measures a change you keep for how it feels and says what it costs, without
50
+ reverting it (`try` on its own reverts anything that is not faster).
51
+
52
+ ### Changed
53
+
54
+ - Gem Rush's bump lands. The bumped body holds for 70 ms, white and squashed against the hit, then flies stretched
55
+ along it, eases to a stop 185 px away at any frame rate (it used to slide a different distance at 12 and at 60
56
+ frames a second, then creep), and wobbles as it settles. Sparks, and a small camera kick for whoever was in it.
57
+ - Ember Vale's strike lands. The slime freezes white when it is hit and its damage pops up, a swipe carries through,
58
+ the slime is pushed back and wobbles like jelly, and a kill bursts. Your hero steps into each strike, so three
59
+ strikes in a row still reach.
60
+ - Both starters come ready for the lab: their numbers in `tunables.json` and a take in `lab.json`, which
61
+ `homie-studio game new` copies with the game. A new studio's AGENTS.md names both files and the lab command.
62
+
63
+ ### Upgrade notes
64
+
65
+ - A game you made from a starter before 0.20.0 keeps its old feel: the new bump and strike come with a new copy
66
+ (`homie-studio game new`). To tune a move of your own game, tell Claude "build a Game Lab for <the move>": it adds
67
+ the lab's calls and a take, and commits that first.
68
+ - Today is your last commit, so commit (or stash) your work in progress before you open the lab. Its builds live in
69
+ `.studio/lab/`, which git already ignores.
70
+
20
71
  ## [0.19.2] - 2026-10-02
21
72
 
22
- **Plugin 0.20.2** · [#29](https://github.com/homie-rocks/homie/pull/29) · [npm](https://www.npmjs.com/package/@homie-rocks/studio/v/0.19.2)
73
+ **Plugin 0.20.2** · [#29](https://github.com/homie-rocks/homie/pull/29) · [release-2026-10-02-studio-0.19.2](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-02-studio-0.19.2)
23
74
 
24
75
  Release notes: this changelog, on every GitHub release, inside the npm package, and at the top of
25
76
  `homie-studio upgrade`.
@@ -846,7 +897,8 @@ The first studio toolkit: a studio in one folder, a multiplayer starter, and dep
846
897
  create, and `publish` to the homie.rocks directory.
847
898
  - Plugin: the `studio-setup`, `game` and `publish` skills, and the remote Homie MCP.
848
899
 
849
- [0.19.2]: https://www.npmjs.com/package/@homie-rocks/studio/v/0.19.2
900
+ [0.20.0]: https://github.com/homie-rocks/homie/pull/30
901
+ [0.19.2]: https://github.com/homie-rocks/homie/releases/tag/release-2026-10-02-studio-0.19.2
850
902
  [0.19.1]: https://github.com/homie-rocks/homie/releases/tag/release-2026-10-02-studio-0.19.1
851
903
  [0.19.0]: https://github.com/homie-rocks/homie/releases/tag/release-2026-10-02-studio-0.19.0
852
904
  [0.18.2]: https://github.com/homie-rocks/homie/releases/tag/release-2026-10-02-studio-0.18.2
package/README.md CHANGED
@@ -16,6 +16,7 @@ npx homie-studio game new crown-thief --from gem-rush --name "Crown Thief"
16
16
  npx homie-studio dev # the whole site locally
17
17
  npx homie-studio check crown-thief --url http://127.0.0.1:8787
18
18
  npx homie-studio perf crown-thief --url http://127.0.0.1:8787 # how fast it runs: a computer and an emulated phone, host and replica
19
+ npx homie-studio lab crown-thief # the Game Lab: how a move feels, New beside Today, frame by frame
19
20
  npx homie-studio deploy --plan # what deploy will create, and what it costs; changes nothing
20
21
  npx homie-studio deploy # the studio's own Cloudflare
21
22
  npx homie-studio publish # the homie.rocks directory
@@ -452,6 +453,44 @@ Minified JavaScript gzips about as well as source text, and a bundle with three.
452
453
  source in strings, which no minifier touches; the line for such a file says `minified, 19% GLSL shader source in
453
454
  strings`, and the JSON's `code` has every measure.
454
455
 
456
+ ## How it feels: the Game Lab (`homie-studio lab`)
457
+
458
+ ```sh
459
+ npx homie-studio lab crown-thief [--today HEAD|<ref>] [--port 8790] # http://127.0.0.1:8790/crown-thief/ until --stop
460
+ npx homie-studio lab check crown-thief [--take knock] [--fps 30] [--device phone] # the same, headless: numbers and pictures
461
+ npx homie-studio lab set crown-thief hitStopMs=80 knockDistance=200 # values into games/crown-thief/tunables.json
462
+ npx homie-studio lab --stop
463
+ ```
464
+
465
+ A page on this computer for one mechanic at a time (a jump, a hit, a dash, a drift). It plays one short **take**
466
+ (`games/<id>/lab.json`: a few seconds, a seed, the presses) in two builds side by side: **New**, the working tree,
467
+ rebuilt on every save, and **Today**, the last commit (or any ref), built from git's own checkout of it with only the
468
+ game's files (and what its bundle reads from the studio), cached per commit in `.studio/lab/`. Both play on the lab's
469
+ clock: requestAnimationFrame, performance.now(), Date.now(), timers and Math.random() are the lab's in the game's page
470
+ (its harness, `lab/harness.js`, is the page's first script), so frame f is exactly f/fps seconds in both builds, the
471
+ dice are the same, the presses land on the same frames, and netplay's offline host, a round's clock and a knockback's
472
+ timer all slow down, pause and step together. Every replay is compared with the last run, frame by frame: the page
473
+ says "Replays match", or the frame where a build read something the lab does not drive.
474
+
475
+ The page: New, Today, both side by side or Today ghosted over New; the game's own views (`lab.camera`) and overlays
476
+ (`lab.overlay`: onion skin, arcs, reach); Desk (1280x800) or Phone (390x844); play, pause, a frame back or forward,
477
+ start and end; 1x, ½, ¼ and ⅒ speed (each frame is the same frame at any speed); 60, 30, 15 or 12 frames a second (what
478
+ a slower device draws); a frame counter; a timeline of the phases each build names (`lab.phase`), scrubbable, with each
479
+ frame's motion marked HOLD, MOVE or FAST; graphs of every tracked value (`lab.track`), New against Today, beside the
480
+ body's speed from the port probe and the game's JavaScript per frame; the phase each pane is in, as a caption; the
481
+ game's tunables (`games/<id>/tunables.json`) as sliders, with Today's value marked, that restart New with the new value
482
+ and write kept values back into the file ("Keep in code", one tunable a line); and REC, which records a new take while
483
+ the person plays it in New (both builds get the same presses, on the same frames). A phone opens it too.
484
+
485
+ A game opts in with `@homie-rocks/studio/lab` (`lab` from `/port` too, `HomiePort.lab` in a static game): every call is
486
+ a no-op outside the lab. Both starters do: Gem Rush's bump (a dummy to bump: `lab.stage`) and Ember Vale's strike (a
487
+ training slime). `lab check` plays the take headless twice per build and writes `.studio/lab/<id>/check-<time>/`:
488
+ summary.json (each phase's frames, each tracked value's peak and where it ends, the JavaScript per frame, errors, the
489
+ replay check), REPORT.md (New against Today), sheet.png (the lab at each phase's start) and still.jpg; and
490
+ `.studio/lab/<id>/latest.json`, the last check in a few fields (`.studio/lab/server.json` says where a running lab is).
491
+ The local MCP's `game_lab` starts the lab and answers with a card. The Homie plugin's `lab` skill has the method:
492
+ instrument and commit first (Today is the game as it is), then change how it feels, and keep what the person likes.
493
+
455
494
  ## Checks on a computer without a GPU
456
495
 
457
496
  `check`, `port check` and `look` run Chrome headless. On a Mac they use its GPU. On Linux (a Claude Code cloud
@@ -37,6 +37,20 @@
37
37
  * (better, worse or the same within the noise: medians, a 95% interval, a rank test,
38
38
  * and the guards that must not get worse; runs taken side by side with the same
39
39
  * --pair label on both sides are judged as pairs, so a computer's drift cancels)
40
+ * homie-studio lab <id> [--port 8790] [--today HEAD|<ref>] [--take <name>]
41
+ * (the Game Lab, on this computer, until --stop: one take of the game (games/<id>/lab.json)
42
+ * played in New, the working tree, rebuilt on every save, beside Today, built from git's
43
+ * checkout of the ref, on one clock with the same seed and presses: slowed to a tenth or
44
+ * a frame at a time, at 60, 30, 15 or 12 fps, on a computer or a phone; a timeline of the
45
+ * phases the game names, graphs New against Today, the game's own overlays and views,
46
+ * sliders that write kept values into games/<id>/tunables.json, and REC for a new take.
47
+ * The game calls @homie-rocks/studio/lab; the plugin's lab skill has the method)
48
+ * homie-studio lab check <id> [--take <name>] [--today <ref>] [--device desk|phone] [--fps 60] [--frames 6] [--out <dir>]
49
+ * (the same take headless, twice per build: every phase, each tracked value's peak, the
50
+ * game's JavaScript per frame, whether a replay lands on the same frames, a contact sheet
51
+ * and a still; .studio/lab/<id>/check-<time>/)
52
+ * homie-studio lab set <id> <name>=<value> ... (values into games/<id>/tunables.json, one tunable a line)
53
+ * homie-studio lab --stop (this studio's Game Lab only, and the checkouts Today was built from)
40
54
  * homie-studio chrome [install] [--fresh] (which Chrome the checks use; on Linux, `install` fetches Chrome for Testing;
41
55
  * --fresh fetches it even when the machine has a Chrome)
42
56
  * homie-studio look [<path>...] [--url <site>] [--shots <dir>] [--only computer,phone,sideways]
@@ -169,6 +183,7 @@ import { look } from '../lib/look.mjs';
169
183
  import { importPort, planPort } from '../lib/port.mjs';
170
184
  import { portCheck } from '../lib/port-check.mjs';
171
185
  import { DEFAULT_GOAL, perfCompare, perfRun, perfSizes } from '../lib/perf.mjs';
186
+ import { LAB_PORT, labServe, labSet, labStop } from '../lib/lab.mjs';
172
187
  import { R2_COST, lineOf, mediaPlan, r2OverOf, recordUpload, resolveMedia, sizeOf, typeOf } from '../lib/media.mjs';
173
188
  import { ensureMigrations, migrationWord, newStudio } from '../lib/scaffold.mjs';
174
189
  import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
@@ -234,6 +249,25 @@ function print(result) {
234
249
  for (const m of result.siteSkipped ?? []) lines.push(` left out: ${m.what}: ${m.why}`);
235
250
  if (result.codexes?.length) lines.push(`Game Codex (the owner's private pages; codex link <id> opens one): ${result.codexes.map((x) => `/_studio/codex/${x}/`).join(', ')}`);
236
251
  break;
252
+ case 'lab':
253
+ lines.push(result.already ? `The Game Lab is running: ${result.url}` : `The Game Lab stopped (it was at ${result.url}).`);
254
+ break;
255
+ case 'lab stop':
256
+ lines.push(result.stopped.length ? 'Stopped this studio\'s Game Lab.' : 'No Game Lab of this studio was running.', ...(result.removed.length ? [`Removed the checkouts Today was built from: ${result.removed.join(', ')}`] : []));
257
+ break;
258
+ case 'lab set':
259
+ lines.push(result.changed.length ? `${result.file}: ${result.changed.map((c) => `${c.name} ${c.from} -> ${c.to}`).join(', ')}` : `${result.file}: nothing changed`);
260
+ break;
261
+ case 'lab check': {
262
+ const det = (x) => (x === null ? 'same frames' : `DIFFERED from frame ${x}`);
263
+ lines.push(`${result.game}: take "${result.take ?? '(none)'}", ${result.frames} frames at ${result.fps} fps`,
264
+ ` New: ${result.phases.new.join(' · ') || 'no phases'}; JavaScript ${result.cost.new?.mean} ms a frame (p95 ${result.cost.new?.p95}); replay: ${det(result.deterministic.new)}`,
265
+ result.phases.today ? ` Today (${result.today}): ${result.phases.today.join(' · ') || 'no phases'}; JavaScript ${result.cost.today?.mean} ms a frame (p95 ${result.cost.today?.p95}); replay: ${det(result.deterministic.today)}` : ` Today: ${result.todayNote ?? 'none'}`,
266
+ ...(result.software ? [` Note: a software renderer (${result.renderer}): pictures are right, the JavaScript numbers are slow`] : []),
267
+ ...result.errors.map((e) => ` error: ${e}`),
268
+ `Report: ${result.report}`, `Pictures: ${result.sheet}, ${result.still}`);
269
+ break;
270
+ }
237
271
  case 'media list':
238
272
  for (const kind of ['music', 'videos']) {
239
273
  lines.push(`${kind}/manifest.json: ${result[kind].pages.length} page(s)`);
@@ -555,6 +589,7 @@ async function main() {
555
589
  if (cmd === 'games') return { ok: true, command: 'games', games: listGames(root).map(({ dir, ...g }) => ({ ...g, dir: relative(root, dir) })) };
556
590
  if (cmd === 'build') return tracked(root, 'build', () => build(root, { only: positional[1] ?? null, log, maps: flags.has('maps') }), 'build');
557
591
  if (cmd === 'perf' && sub === 'sizes') return perfSizes(root, positional[2] ?? listGames(root)[0]?.id);
592
+ if (cmd === 'lab') return labCommand(root, sub);
558
593
  if (cmd === 'perf') {
559
594
  const game = sub ?? listGames(root)[0]?.id;
560
595
  const url = flags.get('url') ?? siteUrl(root);
@@ -662,6 +697,21 @@ async function main() {
662
697
  return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
663
698
  }
664
699
 
700
+ /** `homie-studio lab …` (lib/lab.mjs, lib/lab-check.mjs): the Game Lab of one game. */
701
+ async function labCommand(root, sub) {
702
+ if (flags.has('stop') || sub === 'stop') return labStop(root);
703
+ if (sub === 'set') return labSet(root, positional[2], positional.slice(3));
704
+ if (sub === 'check') {
705
+ const { labCheck } = await import('../lib/lab-check.mjs');
706
+ const num = (k, d) => (flags.has(k) ? Number(flags.get(k)) : d);
707
+ return labCheck(root, positional[2] ?? null, {
708
+ take: flags.get('take') ?? null, today: String(flags.get('today') ?? 'HEAD'), device: flags.get('device') ?? null, fps: num('fps', null),
709
+ out: flags.get('out') ? resolve(String(flags.get('out'))) : null, still: flags.get('still') ? resolve(String(flags.get('still'))) : null, frames: num('frames', 6), log,
710
+ });
711
+ }
712
+ return labServe(root, sub ?? null, { port: Number(flags.get('port') ?? LAB_PORT), today: String(flags.get('today') ?? 'HEAD'), take: flags.get('take') ?? null, log });
713
+ }
714
+
665
715
  /** `homie-studio codex …` (lib/codex.mjs): the Game Codex of one game. */
666
716
  function codexCommand(root, sub) {
667
717
  const games = listGames(root);
package/lab/harness.js ADDED
@@ -0,0 +1,371 @@
1
+ /*
2
+ * The Game Lab's harness (@homie-rocks/studio lab/harness.js): the first script of a game's page when the lab serves
3
+ * it (/new/ and /today/, `homie-studio lab <game>`), before the game's own. It makes a take replayable:
4
+ *
5
+ * - THE CLOCK. requestAnimationFrame, performance.now(), Date.now() (and new Date()), setTimeout and setInterval run
6
+ * on the lab's clock. Frame f happens at exactly f / fps seconds, and only when the lab steps it, so a take plays
7
+ * the same at 1x, 1/10 or one frame at a time, and a paused game is paused all the way down (netplay's offline
8
+ * host, a round's countdown, a knockback's timer).
9
+ * - THE DICE. Math.random() is seeded (the world's dice, the same in both builds); window.__homieLab.random() is a
10
+ * second stream for juice (particles, shake), so a build that adds a spark never moves a gem in the other.
11
+ * - STORAGE. localStorage and sessionStorage are this take's own, in memory, seeded from the take (a hero at level
12
+ * 3), so a take never changes the next one.
13
+ * - INPUT. A take's keys and pointer presses are dispatched at the start of their frame. While the person records,
14
+ * their real presses are held back and dispatched the same way at the next frame (both panes get them at the same
15
+ * frame); while a take replays, real presses are ignored. Focus changes never reach the game.
16
+ * - WHAT THE GAME SAYS. window.__homieLab is what @homie-rocks/studio/lab calls: tracked values, phases, poses,
17
+ * preset views, overlays and tunables, recorded per frame for the lab page to draw.
18
+ *
19
+ * The page that frames it (the lab page) drives it through window.__homieLabPane. Opened on its own (no lab around
20
+ * it) it plays in real time on the same seeded clock. Plain script, no build step: it is served as it is.
21
+ */
22
+ (function () {
23
+ 'use strict';
24
+ if (window.__homieLabPane) return;
25
+
26
+ var real = {
27
+ now: performance.now.bind(performance),
28
+ raf: window.requestAnimationFrame.bind(window),
29
+ setTimeout: window.setTimeout.bind(window),
30
+ random: Math.random,
31
+ Date: Date,
32
+ };
33
+
34
+ /* ------------------------------------------------------------------ config (from the lab page around it) */
35
+ var pane = /\/today\//.test(location.pathname) ? 'today' : 'new';
36
+ var cfg = null;
37
+ try { if (window.parent !== window && window.parent.__homieLabPanes) cfg = window.parent.__homieLabPanes.config(pane); } catch (e) { cfg = null; }
38
+ var free = !cfg;
39
+ cfg = cfg || {};
40
+ var fps = [60, 30, 15, 12].indexOf(Number(cfg.fps)) >= 0 ? Number(cfg.fps) : 60;
41
+ var step = 1000 / fps;
42
+ var seed = (Number(cfg.seed) >>> 0) || 1;
43
+ var epoch = Number.isFinite(cfg.epoch) ? cfg.epoch : Date.UTC(2026, 0, 1);
44
+ var inputs = Array.isArray(cfg.inputs) ? cfg.inputs.slice().sort(function (a, b) { return (a.at || 0) - (b.at || 0); }) : [];
45
+ var mode = cfg.mode === 'record' ? 'record' : 'replay';
46
+ var limit = Number(cfg.frames) > 0 ? Number(cfg.frames) : Infinity;
47
+
48
+ /* ------------------------------------------------------------------ the dice */
49
+ function sfc32(a, b, c, d) {
50
+ return function () {
51
+ a >>>= 0; b >>>= 0; c >>>= 0; d >>>= 0;
52
+ var t = (a + b) | 0; a = b ^ (b >>> 9); b = (c + (c << 3)) | 0; c = (c << 21) | (c >>> 11); d = (d + 1) | 0; t = (t + d) | 0; c = (c + t) | 0;
53
+ return (t >>> 0) / 4294967296;
54
+ };
55
+ }
56
+ function dice(s) { var r = sfc32(0x9e3779b9, 0x243f6a88, 0xb7e15162, s); for (var i = 0; i < 15; i++) r(); return r; }
57
+ var worldDice = dice(seed);
58
+ var fxDice = dice((seed ^ 0x5bd1e995) >>> 0);
59
+ Math.random = function () { return worldDice(); };
60
+
61
+ /* ------------------------------------------------------------------ the clock */
62
+ var vt = 0; // ms since the page started, on the lab's clock
63
+ var frame = 0;
64
+ performance.now = function () { return vt; };
65
+ var RealDate = real.Date;
66
+ function LabDate() {
67
+ var a = Array.prototype.slice.call(arguments);
68
+ if (!(this instanceof LabDate)) return new RealDate(epoch + vt).toString();
69
+ return a.length ? new (Function.prototype.bind.apply(RealDate, [null].concat(a)))() : new RealDate(epoch + vt);
70
+ }
71
+ LabDate.prototype = RealDate.prototype;
72
+ LabDate.now = function () { return epoch + vt; };
73
+ LabDate.parse = RealDate.parse;
74
+ LabDate.UTC = RealDate.UTC;
75
+ window.Date = LabDate;
76
+
77
+ var rafQ = new Map();
78
+ var rafId = 0;
79
+ window.requestAnimationFrame = function (cb) { rafId += 1; rafQ.set(rafId, cb); return rafId; };
80
+ window.cancelAnimationFrame = function (id) { rafQ.delete(id); };
81
+
82
+ var timers = new Map();
83
+ var timerId = 0;
84
+ var timerSeq = 0;
85
+ function addTimer(fn, ms, args, every) {
86
+ timerId += 1;
87
+ timers.set(timerId, { at: vt + Math.max(0, Number(ms) || 0), fn: fn, args: args, every: every ? Math.max(1, Number(ms) || 0) : 0, seq: ++timerSeq });
88
+ return timerId;
89
+ }
90
+ window.setTimeout = function (fn, ms) { return addTimer(fn, ms, Array.prototype.slice.call(arguments, 2), false); };
91
+ window.setInterval = function (fn, ms) { return addTimer(fn, ms, Array.prototype.slice.call(arguments, 2), true); };
92
+ window.clearTimeout = window.clearInterval = function (id) { timers.delete(id); };
93
+ function fireTimers(until) {
94
+ for (var guard = 0; guard < 10000; guard++) {
95
+ var next = null; var nextId = 0;
96
+ timers.forEach(function (tm, id) { if (tm.at <= until && (!next || tm.at < next.at || (tm.at === next.at && tm.seq < next.seq))) { next = tm; nextId = id; } });
97
+ if (!next) return;
98
+ if (next.at > vt) vt = next.at;
99
+ if (next.every) { next.at += next.every; next.seq = ++timerSeq; } else timers.delete(nextId);
100
+ try { if (typeof next.fn === 'function') next.fn.apply(window, next.args); } catch (e) { error(e); }
101
+ }
102
+ }
103
+
104
+ /* ------------------------------------------------------------------ storage: this take's own */
105
+ function memStorage(seedItems) {
106
+ var m = new Map();
107
+ if (seedItems && typeof seedItems === 'object') Object.keys(seedItems).forEach(function (k) { m.set(String(k), typeof seedItems[k] === 'string' ? seedItems[k] : JSON.stringify(seedItems[k])); });
108
+ return {
109
+ getItem: function (k) { k = String(k); return m.has(k) ? m.get(k) : null; },
110
+ setItem: function (k, v) { m.set(String(k), String(v)); },
111
+ removeItem: function (k) { m.delete(String(k)); },
112
+ clear: function () { m.clear(); },
113
+ key: function (i) { return Array.from(m.keys())[i] || null; },
114
+ get length() { return m.size; },
115
+ };
116
+ }
117
+ var local = memStorage(cfg.storage);
118
+ var session = memStorage(null);
119
+ try { Object.defineProperty(window, 'localStorage', { configurable: true, get: function () { return local; } }); } catch (e) { /* kept */ }
120
+ try { Object.defineProperty(window, 'sessionStorage', { configurable: true, get: function () { return session; } }); } catch (e) { /* kept */ }
121
+
122
+ /* ------------------------------------------------------------------ the display */
123
+ if (Number(cfg.dpr) > 0) { var dpr = Number(cfg.dpr); try { Object.defineProperty(window, 'devicePixelRatio', { configurable: true, get: function () { return dpr; } }); } catch (e) { /* kept */ } }
124
+
125
+ /* ------------------------------------------------------------------ input */
126
+ var KEYS = ['keydown', 'keyup'];
127
+ var POINTERS = ['pointerdown', 'pointermove', 'pointerup', 'pointercancel'];
128
+ var queued = []; // inputs for the next frame (recorded, or forwarded from the other pane)
129
+ var recorded = [];
130
+ var nextInput = 0;
131
+ var captured = new Map(); // pointerId -> element (pointer capture for synthetic pointers)
132
+ var sp = Element.prototype.setPointerCapture;
133
+ var rp = Element.prototype.releasePointerCapture;
134
+ var hp = Element.prototype.hasPointerCapture;
135
+ Element.prototype.setPointerCapture = function (id) { captured.set(id, this); try { sp.call(this, id); } catch (e) { /* a synthetic pointer */ } };
136
+ Element.prototype.releasePointerCapture = function (id) { if (captured.get(id) === this) captured.delete(id); try { rp.call(this, id); } catch (e) { /* a synthetic pointer */ } };
137
+ Element.prototype.hasPointerCapture = function (id) { return captured.get(id) === this || (function (el) { try { return hp.call(el, id); } catch (e) { return false; } })(this); };
138
+
139
+ function keyInfo(code) {
140
+ var known = { ArrowLeft: ['ArrowLeft', 37], ArrowUp: ['ArrowUp', 38], ArrowRight: ['ArrowRight', 39], ArrowDown: ['ArrowDown', 40], Space: [' ', 32], Enter: ['Enter', 13], Escape: ['Escape', 27], ShiftLeft: ['Shift', 16], ShiftRight: ['Shift', 16] };
141
+ if (known[code]) return known[code];
142
+ var l = /^Key([A-Z])$/.exec(code); if (l) return [l[1].toLowerCase(), l[1].charCodeAt(0)];
143
+ var d = /^Digit([0-9])$/.exec(code); if (d) return [d[1], 48 + Number(d[1])];
144
+ return [code, 0];
145
+ }
146
+ function dispatch(ev) {
147
+ if (ev.type === 'key') {
148
+ var k = keyInfo(ev.code);
149
+ var ke = new KeyboardEvent(ev.down ? 'keydown' : 'keyup', { key: k[0], code: ev.code, bubbles: true, cancelable: true, composed: true });
150
+ try { Object.defineProperty(ke, 'keyCode', { get: function () { return k[1]; } }); Object.defineProperty(ke, 'which', { get: function () { return k[1]; } }); } catch (e) { /* read-only */ }
151
+ var at = document.activeElement && document.activeElement !== document.documentElement ? document.activeElement : document.body || document;
152
+ at.dispatchEvent(ke);
153
+ return;
154
+ }
155
+ if (ev.type === 'pointer') {
156
+ var type = 'pointer' + ev.ev;
157
+ var target = captured.get(ev.id) || document.elementFromPoint(ev.x, ev.y) || document.body;
158
+ var pe = new PointerEvent(type, { clientX: ev.x, clientY: ev.y, screenX: ev.x, screenY: ev.y, pointerId: ev.id, pointerType: ev.pt || 'touch', isPrimary: ev.id === (ev.primary || ev.id), buttons: ev.ev === 'up' || ev.ev === 'cancel' ? 0 : 1, button: ev.ev === 'move' ? -1 : 0, bubbles: true, cancelable: true, composed: true, width: 20, height: 20, pressure: ev.ev === 'up' ? 0 : 0.5 });
159
+ target.dispatchEvent(pe);
160
+ if (ev.ev === 'up' || ev.ev === 'cancel') captured.delete(ev.id);
161
+ }
162
+ }
163
+ function fromEvent(e) {
164
+ if (KEYS.indexOf(e.type) >= 0) return { type: 'key', code: e.code, down: e.type === 'keydown' };
165
+ var ev = e.type.slice(7);
166
+ return { type: 'pointer', ev: ev, x: Math.round(e.clientX * 10) / 10, y: Math.round(e.clientY * 10) / 10, id: e.pointerId, pt: e.pointerType === 'mouse' ? 'mouse' : e.pointerType || 'touch' };
167
+ }
168
+ // Real presses: held back for the next frame while recording (and given to the other pane too), ignored otherwise.
169
+ function onReal(e) {
170
+ if (!e.isTrusted) return;
171
+ if (KEYS.indexOf(e.type) >= 0 && (e.repeat || e.metaKey || e.ctrlKey)) { if (e.repeat) { e.stopImmediatePropagation(); e.preventDefault(); } return; }
172
+ if (e.target && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName || '')) return; // typing into the game's own box
173
+ e.stopImmediatePropagation();
174
+ if (KEYS.indexOf(e.type) >= 0 || e.type !== 'pointermove') e.preventDefault();
175
+ if (mode !== 'record' || free) { if (free) dispatchLater(fromEvent(e)); return; }
176
+ var ev = fromEvent(e);
177
+ try { window.parent.__homieLabPanes.input(pane, ev); } catch (x) { queue(ev); }
178
+ }
179
+ function dispatchLater(ev) { queued.push(ev); }
180
+ KEYS.concat(POINTERS).forEach(function (t) { window.addEventListener(t, onReal, true); });
181
+ // Focus coming and going (the person clicking the lab's buttons) is not the game's business.
182
+ ['blur', 'focus'].forEach(function (t) { window.addEventListener(t, function (e) { if (e.target === window) e.stopImmediatePropagation(); }, true); });
183
+ document.addEventListener('visibilitychange', function (e) { e.stopImmediatePropagation(); }, true);
184
+
185
+ function queue(ev) {
186
+ var at = Math.round((frame + 1) * step * 1000) / 1000;
187
+ var row = Object.assign({ at: at }, ev);
188
+ // A pointer that moved twice before the next frame: only where it ended counts (a take stays small).
189
+ var last = recorded[recorded.length - 1];
190
+ if (row.type === 'pointer' && row.ev === 'move' && last && last.at === at && last.type === 'pointer' && last.ev === 'move' && last.id === row.id) {
191
+ recorded[recorded.length - 1] = row;
192
+ for (var i = queued.length - 1; i >= 0; i--) if (queued[i] === last) { queued[i] = row; break; }
193
+ return;
194
+ }
195
+ queued.push(row);
196
+ recorded.push(row);
197
+ }
198
+
199
+ /* ------------------------------------------------------------------ what the game reports */
200
+ var meta = { units: {}, views: [], overlays: [], tunables: null, used: { track: 0, phase: 0, pose: 0 }, errors: [], held: false };
201
+ var phaseNow = null;
202
+ var cur = null;
203
+ var poses = new Map();
204
+ var view = typeof cfg.view === 'string' ? cfg.view : null;
205
+ var overlaysOn = new Set(Array.isArray(cfg.overlays) ? cfg.overlays : []);
206
+ var live = cfg.tunables && typeof cfg.tunables === 'object' ? Object.assign({}, cfg.tunables) : {};
207
+ var frames = [];
208
+ var readyWaiters = [];
209
+ var state = { scale: Number(cfg.scale) || 1, paused: false };
210
+
211
+ function error(e) {
212
+ var m = String((e && (e.message || e.reason)) || e).slice(0, 240);
213
+ if (meta.errors.length < 20 && meta.errors.indexOf(m) < 0) meta.errors.push(m);
214
+ }
215
+ window.addEventListener('error', function (e) { error(e.error || e.message); });
216
+ window.addEventListener('unhandledrejection', function (e) { error(e.reason); });
217
+
218
+ window.__homieLab = {
219
+ v: 1,
220
+ pane: pane,
221
+ stage: typeof cfg.stage === 'string' && cfg.stage ? cfg.stage.slice(0, 32) : null,
222
+ get frame() { return frame; },
223
+ get fps() { return fps; },
224
+ get scale() { return state.scale; },
225
+ get paused() { return state.paused; },
226
+ track: function (name, value, unit) {
227
+ meta.used.track += 1;
228
+ var v = Number(value);
229
+ if (cur) cur.tr[String(name).slice(0, 32)] = Number.isFinite(v) ? Math.round(v * 1000) / 1000 : null;
230
+ if (unit && !meta.units[name]) meta.units[name] = String(unit).slice(0, 12);
231
+ else if (!(name in meta.units)) meta.units[name] = '';
232
+ },
233
+ phase: function (name, note) {
234
+ meta.used.phase += 1;
235
+ phaseNow = name ? [String(name).slice(0, 24), note ? String(note).slice(0, 90) : ''] : null;
236
+ if (cur) cur.ph = phaseNow;
237
+ },
238
+ pose: function (name, value) {
239
+ meta.used.pose += 1;
240
+ var ring = poses.get(name);
241
+ if (!ring) { ring = []; poses.set(name, ring); }
242
+ var copy = {};
243
+ if (value && typeof value === 'object') Object.keys(value).forEach(function (k) { copy[k] = value[k]; });
244
+ if (ring.length && ring[ring.length - 1].f === frame) ring[ring.length - 1].v = copy; else ring.push({ f: frame, v: copy });
245
+ if (ring.length > 720) ring.shift();
246
+ },
247
+ past: function (name, count, every) {
248
+ var ring = poses.get(name) || [];
249
+ var out = [];
250
+ var stride = Math.max(1, Number(every) || 1);
251
+ var want = Math.max(0, Math.min(240, Number(count) || 8));
252
+ for (var i = ring.length - 1; i >= 0 && out.length < want; i--) {
253
+ var r = ring[i];
254
+ if (r.f >= frame) continue;
255
+ if ((frame - r.f) % stride === 0) out.push(r.v);
256
+ }
257
+ return out;
258
+ },
259
+ views: function (names) { meta.views = names.map(String).slice(0, 8); },
260
+ view: function () { return view; },
261
+ overlays: function (names) { meta.overlays = names.map(String).slice(0, 12); },
262
+ overlay: function (name) { return overlaysOn.has(name); },
263
+ tunables: function (spec) { meta.tunables = spec; return live; },
264
+ random: function () { return fxDice(); },
265
+ hold: function () { meta.held = true; },
266
+ ready: function () { meta.held = false; readyWaiters.splice(0).forEach(function (r) { r(); }); },
267
+ };
268
+
269
+ /* ------------------------------------------------------------------ one frame */
270
+ function hash(s) { var h = 2166136261; for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0).toString(36); }
271
+ function selfAt() {
272
+ try {
273
+ var p = window.__homiePort;
274
+ if (!p) return null;
275
+ var rows = p.rows(vt);
276
+ var r = rows[rows.length - 1];
277
+ return r && Number.isFinite(r[1]) ? [Math.round(r[1] * 100) / 100, Math.round(r[2] * 100) / 100] : null;
278
+ } catch (e) { return null; }
279
+ }
280
+ function runFrame() {
281
+ var target = (frame + 1) * step;
282
+ fireTimers(target);
283
+ vt = target;
284
+ frame += 1;
285
+ cur = { f: frame, t: Math.round(vt * 1000) / 1000, tr: {}, ph: phaseNow, ms: 0, sig: '' };
286
+ // Half a millisecond of slack: a take's times are written to 0.1 ms, and frames are at least 16.7 ms apart.
287
+ while (nextInput < inputs.length && (inputs[nextInput].at || 0) <= vt + 0.5) { try { dispatch(inputs[nextInput]); } catch (e) { error(e); } nextInput += 1; }
288
+ var now = queued.splice(0);
289
+ for (var i = 0; i < now.length; i++) { try { dispatch(now[i]); } catch (e) { error(e); } }
290
+ var q = rafQ;
291
+ rafQ = new Map();
292
+ var t0 = real.now();
293
+ q.forEach(function (cb) { try { cb(vt); } catch (e) { error(e); } });
294
+ cur.ms = Math.round((real.now() - t0) * 1000) / 1000;
295
+ cur.ph = phaseNow;
296
+ var me = selfAt();
297
+ if (me) cur.me = me;
298
+ cur.sig = hash(JSON.stringify([cur.tr, cur.ph, me]));
299
+ frames.push(cur);
300
+ var done = cur;
301
+ cur = null;
302
+ return done;
303
+ }
304
+
305
+ var channel = new MessageChannel();
306
+ var waiting = [];
307
+ channel.port1.onmessage = function () { var w = waiting.shift(); if (w) w(); };
308
+ function yieldTask() { return new Promise(function (r) { waiting.push(r); channel.port2.postMessage(0); }); }
309
+
310
+ var loaded = new Promise(function (resolve) {
311
+ function go() {
312
+ var fonts = document.fonts && document.fonts.ready ? document.fonts.ready.catch(function () {}) : Promise.resolve();
313
+ fonts.then(function () { return yieldTask(); }).then(function () {
314
+ if (!meta.held) resolve();
315
+ else readyWaiters.push(resolve);
316
+ });
317
+ }
318
+ if (document.readyState === 'complete') go(); else window.addEventListener('load', go, { once: true });
319
+ });
320
+
321
+ var running = false;
322
+ window.__homieLabPane = {
323
+ v: 1,
324
+ pane: pane,
325
+ ready: loaded,
326
+ get frame() { return frame; },
327
+ get t() { return vt; },
328
+ get fps() { return fps; },
329
+ frames: frames,
330
+ recorded: recorded,
331
+ /** What the game said about itself: units of its tracks, its views, overlays and tunables, errors. */
332
+ meta: function () { return { units: meta.units, views: meta.views, overlays: meta.overlays, tunables: meta.tunables, instrumented: meta.used.track + meta.used.phase + meta.used.pose > 0, used: meta.used, errors: meta.errors.slice() }; },
333
+ /** Run `n` frames, a task apart (promises settle between frames, as in a browser). */
334
+ run: function (n) {
335
+ if (running) return Promise.resolve(frame);
336
+ running = true;
337
+ var left = Math.max(0, Math.min(Number(n) || 0, limit - frame));
338
+ return loaded.then(function loop() {
339
+ if (left <= 0) { running = false; return frame; }
340
+ left -= 1;
341
+ runFrame();
342
+ return left > 0 ? yieldTask().then(loop) : (running = false, frame);
343
+ });
344
+ },
345
+ /** A press for the next frame (the person's, forwarded by the lab page to both panes). */
346
+ input: function (ev) { queue(ev); },
347
+ set: function (o) {
348
+ if (!o) return;
349
+ if ('view' in o) view = o.view;
350
+ if (o.overlays) Object.keys(o.overlays).forEach(function (k) { if (o.overlays[k]) overlaysOn.add(k); else overlaysOn.delete(k); });
351
+ if (o.tunables) Object.keys(o.tunables).forEach(function (k) { live[k] = o.tunables[k]; });
352
+ if (o.mode === 'record' || o.mode === 'replay') mode = o.mode;
353
+ if (Number(o.scale) > 0) state.scale = Number(o.scale);
354
+ if (typeof o.paused === 'boolean') state.paused = o.paused;
355
+ },
356
+ get mode() { return mode; },
357
+ /** The first canvas's picture now (a still for the lab's sheet). */
358
+ still: function (type, quality) {
359
+ var c = document.querySelector('canvas');
360
+ try { return c ? c.toDataURL(type || 'image/jpeg', quality || 0.82) : null; } catch (e) { return null; }
361
+ },
362
+ };
363
+
364
+ // On its own (no lab around it): real time, one lab frame per screen frame, on the same seeded clock.
365
+ if (free) {
366
+ loaded.then(function () {
367
+ var tick = function () { runFrame(); if (frames.length > 1200) frames.splice(0, frames.length - 1200); real.raf(tick); };
368
+ real.raf(tick);
369
+ });
370
+ }
371
+ })();