@homie-rocks/studio 0.19.1 → 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/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
@@ -108,14 +109,20 @@ are watched only by those they let in (NETPLAY.md section 16, site/SITE.md).
108
109
 
109
110
  ## Upgrading a studio
110
111
 
111
- A studio pins one version, so nothing changes until it asks. To take a newer one:
112
+ A studio pins one version, so nothing changes until it asks. To take a newer one (`latest`, or a version):
112
113
 
113
114
  ```sh
114
- npx -y @homie-rocks/studio@0.18.2 upgrade # the plan; changes nothing
115
- npx -y @homie-rocks/studio@0.18.2 upgrade --apply # after the person agrees
115
+ npx -y @homie-rocks/studio@latest upgrade # what's new, and the plan; changes nothing
116
+ npx -y @homie-rocks/studio@0.19.2 upgrade --apply # after the person agrees (the version the plan named)
116
117
  npm install && npm run build
117
118
  ```
118
119
 
120
+ The plan starts with **what's new** since the version the studio pins: one line per version and every upgrade note
121
+ (anything the person has to do), read from the new version's own `CHANGELOG.md`, which ships in the package (the
122
+ repository's [CHANGELOG.md](https://github.com/homie-rocks/homie/blob/main/CHANGELOG.md) is the same file). `--json`
123
+ carries it as `whatsNew`. In Claude Desktop, `studio_run ["upgrade"]` runs the extension's own newer toolkit for
124
+ this, and the studio card says what's new when a studio is behind.
125
+
119
126
  `upgrade` compares the studio with what this version's `new` would write for it: an AGENTS.md section, a README,
120
127
  a `.gitignore` line, a D1 migration or a package.json script it lacks is added, and one it never changed since an
121
128
  older template wrote it (`lib/template-history.json` knows every earlier version's text by a fingerprint) takes
@@ -446,6 +453,44 @@ Minified JavaScript gzips about as well as source text, and a bundle with three.
446
453
  source in strings, which no minifier touches; the line for such a file says `minified, 19% GLSL shader source in
447
454
  strings`, and the JSON's `code` has every measure.
448
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
+
449
494
  ## Checks on a computer without a GPU
450
495
 
451
496
  `check`, `port check` and `look` run Chrome headless. On a Mac they use its GPU. On Linux (a Claude Code cloud
@@ -498,6 +543,7 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
498
543
  | `lib/cloudflare.mjs` | `deploy` (and `deploy --plan`): Wrangler, D1, migrations; refuses resources it did not create. In Workers Builds, migrations and deploy only. `storage add`: the optional R2 bucket; `media move`: big media into it, checked by SHA-256 (every deploy runs it). |
499
544
  | `lib/check.mjs` | `check`: two fresh Chrome processes (computer + phone) must share a room and finish a round with both in it, with each one's frame rate; on a busy computer it waits out a round a browser was dropped from, and says why when none counts. `lib/chrome.mjs`: which Chrome, and how (the GPU on a Mac, SwiftShader on Linux). |
500
545
  | `lib/upgrade.mjs`, `lib/template-history.json` | `upgrade`: an existing studio takes what a newer template adds, never over its own edits. |
546
+ | `lib/changelog.mjs`, `CHANGELOG.md` | The release history this package carries (a copy of the repository's): `upgrade`'s what's new, the studio card's, and the release notes. |
501
547
  | `lib/progress.mjs`, `lib/setup.mjs` | The progress feed (`progress …`), a build the chat opened (`progress attach`), a change as a pull request (`progress change`, `progress pr`), and `setup attach`. |
502
548
  | `lib/net.mjs`, `lib/repo.mjs` | The toolkit's own web requests: through the environment's proxy, and an honest reason when one fails. The studio's repository (`owner/name`), from its remote, never the engine's. |
503
549
  | `template/` (repository root), `scripts/template.mjs` | The public "Deploy to Cloudflare" template, generated from `new --template`. |
@@ -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]
@@ -61,7 +75,9 @@
61
75
  * homie-studio upgrade [--apply] [--diff]
62
76
  * (what this version's template adds to an existing studio: AGENTS.md
63
77
  * sections, READMEs, .gitignore lines, the pin; changes nothing until
64
- * --apply, and never a file or section the studio changed)
78
+ * --apply, and never a file or section the studio changed. It starts
79
+ * with what's new since the studio's version, from this package's
80
+ * CHANGELOG.md)
65
81
  * homie-studio stats [--range 1d|7d|30d|90d] [--game <id> | --song <slug> | --video <slug>] [--url <site>]
66
82
  * homie-studio stats key [--hours 1] (a read key for the Homie MCP tool studio_stats)
67
83
  * homie-studio stats link (a one-time link to the private stats page, for the owner's browser)
@@ -167,9 +183,11 @@ import { look } from '../lib/look.mjs';
167
183
  import { importPort, planPort } from '../lib/port.mjs';
168
184
  import { portCheck } from '../lib/port-check.mjs';
169
185
  import { DEFAULT_GOAL, perfCompare, perfRun, perfSizes } from '../lib/perf.mjs';
186
+ import { LAB_PORT, labServe, labSet, labStop } from '../lib/lab.mjs';
170
187
  import { R2_COST, lineOf, mediaPlan, r2OverOf, recordUpload, resolveMedia, sizeOf, typeOf } from '../lib/media.mjs';
171
188
  import { ensureMigrations, migrationWord, newStudio } from '../lib/scaffold.mjs';
172
189
  import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
190
+ import { whatsNewLines } from '../lib/changelog.mjs';
173
191
  import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
174
192
  import { STUDIO_VERSION } from '../lib/version.mjs';
175
193
  import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
@@ -231,6 +249,25 @@ function print(result) {
231
249
  for (const m of result.siteSkipped ?? []) lines.push(` left out: ${m.what}: ${m.why}`);
232
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(', ')}`);
233
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
+ }
234
271
  case 'media list':
235
272
  for (const kind of ['music', 'videos']) {
236
273
  lines.push(`${kind}/manifest.json: ${result[kind].pages.length} page(s)`);
@@ -439,7 +476,9 @@ function print(result) {
439
476
  lines.push(result.applied
440
477
  ? `${result.studio} is on the @homie-rocks/studio ${result.to} template now (${list.length} change${list.length === 1 ? '' : 's'}).`
441
478
  : list.length ? `${result.studio}: what the @homie-rocks/studio ${result.to} template adds (from ${result.from ?? 'an older version'}). Nothing is changed until --apply.` : `${result.studio} has everything the @homie-rocks/studio ${result.to} template writes.`);
442
- if (list.length) lines.push('');
479
+ // What the versions since the studio's own brought, from this package's CHANGELOG.md (also after --apply).
480
+ if (result.whatsNew) lines.push('', ...whatsNewLines(result.whatsNew, { source: `@homie-rocks/studio ${result.to}'s CHANGELOG.md` }));
481
+ if (list.length) lines.push('', result.applied ? 'Done:' : 'The changes:');
443
482
  for (const c of list) {
444
483
  lines.push(` ${mark[c.kind] ?? '·'} ${c.file.padEnd(18)} ${c.what}${c.kind === 'add-section' && (c.after || c.before) ? ` (${c.after ? `after "${c.after}"` : `before "${c.before}"`})` : ''}`);
445
484
  if (result.applied) continue;
@@ -550,6 +589,7 @@ async function main() {
550
589
  if (cmd === 'games') return { ok: true, command: 'games', games: listGames(root).map(({ dir, ...g }) => ({ ...g, dir: relative(root, dir) })) };
551
590
  if (cmd === 'build') return tracked(root, 'build', () => build(root, { only: positional[1] ?? null, log, maps: flags.has('maps') }), 'build');
552
591
  if (cmd === 'perf' && sub === 'sizes') return perfSizes(root, positional[2] ?? listGames(root)[0]?.id);
592
+ if (cmd === 'lab') return labCommand(root, sub);
553
593
  if (cmd === 'perf') {
554
594
  const game = sub ?? listGames(root)[0]?.id;
555
595
  const url = flags.get('url') ?? siteUrl(root);
@@ -657,6 +697,21 @@ async function main() {
657
697
  return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
658
698
  }
659
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
+
660
715
  /** `homie-studio codex …` (lib/codex.mjs): the Game Codex of one game. */
661
716
  function codexCommand(root, sub) {
662
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
+ })();