@homie-rocks/studio 0.19.2 → 0.21.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 +93 -2
- package/README.md +39 -0
- package/bin/homie-studio.mjs +56 -3
- package/lab/harness.js +371 -0
- package/lab/lab.ts +180 -0
- package/lab/page/index.html +83 -0
- package/lab/page/lab.css +174 -0
- package/lab/page/page.js +802 -0
- package/lab/take.js +189 -0
- package/lib/build.mjs +61 -44
- package/lib/check.mjs +3 -1
- package/lib/lab-check.mjs +212 -0
- package/lib/lab.mjs +546 -0
- package/lib/mcp-tools.mjs +45 -1
- package/lib/mcp.mjs +4 -2
- package/lib/office.mjs +30 -8
- package/lib/port-check.mjs +3 -1
- package/lib/progress.mjs +16 -1
- package/lib/scaffold.mjs +13 -2
- package/lib/studio.mjs +14 -0
- package/lib/template-history.json +74 -0
- package/lib/thumb.mjs +82 -0
- package/mcp/ui/lab.js +67 -0
- package/package.json +6 -1
- package/port/index.ts +2 -0
- package/starters/ember-vale/lab.json +22 -0
- package/starters/ember-vale/src/main.ts +271 -19
- package/starters/ember-vale/tunables.json +13 -0
- package/starters/gem-rush/lab.json +19 -0
- package/starters/gem-rush/src/main.ts +213 -38
- package/starters/gem-rush/tunables.json +13 -0
- package/worker/version.mjs +1 -1
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
|
+
})();
|
package/lab/lab.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @homie-rocks/studio/lab — a game opts into the Game Lab (`homie-studio lab <game>`) with a few calls. The lab plays
|
|
3
|
+
* the same take in two builds side by side, New (the working tree) and Today (the last commit), on one clock, with the
|
|
4
|
+
* same seed and the same presses, and draws what these calls report: a timeline of phases, graphs of tracked values,
|
|
5
|
+
* the game's own overlays, preset views and live sliders.
|
|
6
|
+
*
|
|
7
|
+
* Outside the lab every call is a no-op (one check of a constant), so the calls stay in the game for good and cost a
|
|
8
|
+
* player nothing. The lab's clock, dice and inputs are installed in the game's page before its first script (the
|
|
9
|
+
* lab's harness, lab/harness.js): requestAnimationFrame, performance.now(), Date.now(), timers and Math.random() all
|
|
10
|
+
* run on the lab's clock there, so netplay's offline host, a round's countdown and a knockback's timer all slow down,
|
|
11
|
+
* pause and step together.
|
|
12
|
+
*
|
|
13
|
+
* import { lab } from '@homie-rocks/studio/lab';
|
|
14
|
+
* import tuning from '../tunables.json';
|
|
15
|
+
*
|
|
16
|
+
* const T = lab.tunables(tuning); // T.knockSpeed: the file's value; in the lab, the slider's
|
|
17
|
+
* const view = lab.camera({ game: null, close: { zoom: 2.4 } });
|
|
18
|
+
* lab.overlay('trail', (ctx) => { for (const p of lab.past('me', 30)) dot(ctx, p.x, p.y); });
|
|
19
|
+
*
|
|
20
|
+
* function frame(t: number): void {
|
|
21
|
+
* const dt = lab.time.dt(t, 0.05); // seconds since the last frame, at most 0.05
|
|
22
|
+
* ...
|
|
23
|
+
* lab.track('speed', Math.hypot(vx, vy), 'px/s');
|
|
24
|
+
* lab.phase(knocked ? 'LAUNCH' : null, 'Leaves fast, eases out');
|
|
25
|
+
* lab.pose('me', { x, y });
|
|
26
|
+
* ...in the world's transform: lab.draw(ctx);
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* tunables.json (next to game.json): { "knockSpeed": { "value": 950, "min": 300, "max": 2000, "step": 10,
|
|
30
|
+
* "unit": "px/s", "group": "Knock", "note": "How fast a bumped body leaves" }, ... }. A slider the person keeps is
|
|
31
|
+
* written back into that file by the lab, so a kept change is code.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** One tunable in tunables.json: its value and the slider around it. A bare number is a value with no range. */
|
|
35
|
+
export interface TunableSpec {
|
|
36
|
+
value: number;
|
|
37
|
+
min?: number;
|
|
38
|
+
max?: number;
|
|
39
|
+
step?: number;
|
|
40
|
+
unit?: string;
|
|
41
|
+
group?: string;
|
|
42
|
+
note?: string;
|
|
43
|
+
}
|
|
44
|
+
export type TunableFile = Record<string, TunableSpec | number>;
|
|
45
|
+
export type Tuned<S extends TunableFile> = { readonly [K in keyof S]: number };
|
|
46
|
+
|
|
47
|
+
/** What the lab's harness puts on the page (window.__homieLab) before the game's first script. */
|
|
48
|
+
interface LabHost {
|
|
49
|
+
v: 1;
|
|
50
|
+
pane: 'new' | 'today';
|
|
51
|
+
stage: string | null;
|
|
52
|
+
frame: number;
|
|
53
|
+
fps: number;
|
|
54
|
+
scale: number;
|
|
55
|
+
paused: boolean;
|
|
56
|
+
track(name: string, value: number, unit?: string): void;
|
|
57
|
+
phase(name: string | null, note?: string): void;
|
|
58
|
+
pose(name: string, value: Record<string, unknown>): void;
|
|
59
|
+
past(name: string, count: number, every: number): Record<string, unknown>[];
|
|
60
|
+
views(names: string[]): void;
|
|
61
|
+
view(): string | null;
|
|
62
|
+
overlays(names: string[]): void;
|
|
63
|
+
overlay(name: string): boolean;
|
|
64
|
+
tunables(spec: TunableFile): Record<string, number>;
|
|
65
|
+
random(): number;
|
|
66
|
+
hold(): void;
|
|
67
|
+
ready(): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const host: LabHost | null = typeof window !== 'undefined' ? ((window as unknown as { __homieLab?: LabHost }).__homieLab ?? null) : null;
|
|
71
|
+
const ON = Boolean(host && host.v === 1);
|
|
72
|
+
|
|
73
|
+
type Draw = (ctx: unknown, ...rest: unknown[]) => void;
|
|
74
|
+
const drawers = new Map<string, Draw>();
|
|
75
|
+
let lastT = -1;
|
|
76
|
+
|
|
77
|
+
const valueOf = (s: TunableSpec | number): number => (typeof s === 'number' ? s : Number(s?.value));
|
|
78
|
+
|
|
79
|
+
export const lab = {
|
|
80
|
+
/** True when this page is a pane of the Game Lab. */
|
|
81
|
+
on: ON,
|
|
82
|
+
|
|
83
|
+
time: {
|
|
84
|
+
/**
|
|
85
|
+
* Seconds since the previous frame, at most `max` (a tab that slept does not teleport the world). Call it once a
|
|
86
|
+
* frame with requestAnimationFrame's time. In the lab that time is the lab's clock: every frame is exactly one step
|
|
87
|
+
* of its frame rate (60, 30, 15 or 12 a second), so a take plays the same every time, at any speed.
|
|
88
|
+
*/
|
|
89
|
+
dt(t: number, max = 0.05): number {
|
|
90
|
+
const d = lastT < 0 ? 0 : Math.max(0, (t - lastT) / 1000);
|
|
91
|
+
lastT = t;
|
|
92
|
+
return Math.min(max, d);
|
|
93
|
+
},
|
|
94
|
+
/** The time now in ms (performance.now(); the lab's clock in the lab). */
|
|
95
|
+
now(): number { return performance.now(); },
|
|
96
|
+
/** The lab's frame number (1 is the take's first frame); 0 outside the lab. */
|
|
97
|
+
get frame(): number { return host?.frame ?? 0; },
|
|
98
|
+
/** Frames a second the lab runs the game at (60 outside the lab). */
|
|
99
|
+
get fps(): number { return host?.fps ?? 60; },
|
|
100
|
+
/** How fast the lab plays (1, 0.5, 0.25, 0.1): the game never needs it, a frame is a frame at any speed. */
|
|
101
|
+
get scale(): number { return host?.scale ?? 1; },
|
|
102
|
+
get paused(): boolean { return host?.paused ?? false; },
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The take's stage (lab.json "stage"), or null: a scene the game sets up for one mechanic, the way a fighting game's
|
|
107
|
+
* training mode stands a dummy in front of you. Read it where the round starts; it is null outside the lab, so a
|
|
108
|
+
* stage never reaches a player. Check the mechanic in a take without a stage too: real play is the test.
|
|
109
|
+
*/
|
|
110
|
+
get stage(): string | null { return host?.stage ?? null; },
|
|
111
|
+
|
|
112
|
+
/** A number to graph this frame (New against Today). `unit` names its axis the first time. */
|
|
113
|
+
track(name: string, value: number, unit?: string): void { if (ON) host!.track(name, value, unit); },
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The phase the mechanic is in from now on: a short name in capitals (COIL, RISE, HANG, LAUNCH, SETTLE) and a note
|
|
117
|
+
* that says what it is for ("Overlap on the way out"). null: none. It lasts until the next call, so call it when the
|
|
118
|
+
* phase changes, or every frame with what it is.
|
|
119
|
+
*/
|
|
120
|
+
phase(name: string | null, note?: string): void { if (ON) host!.phase(name, note); },
|
|
121
|
+
|
|
122
|
+
/** A small picture of something this frame ({ x, y, angle, sx, sy }): what `past` returns to an overlay. */
|
|
123
|
+
pose(name: string, value: Record<string, unknown>): void { if (ON) host!.pose(name, value); },
|
|
124
|
+
|
|
125
|
+
/** The last `count` poses of `name`, newest first, one every `every` frames (onion skins, arcs, trails). */
|
|
126
|
+
past<P extends Record<string, unknown> = Record<string, number>>(name: string, count = 8, every = 1): P[] {
|
|
127
|
+
return ON ? (host!.past(name, count, every) as P[]) : [];
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Preset views the lab offers as buttons (Side, Front, Top, Orbit in a 3D game; Close, Arena in a flat one). Returns
|
|
132
|
+
* the active preset's value each frame: the first preset outside the lab (give it null: the game's own camera).
|
|
133
|
+
*/
|
|
134
|
+
camera<V>(presets: Record<string, V>): () => V | null {
|
|
135
|
+
const names = Object.keys(presets);
|
|
136
|
+
const first = names.length ? (presets[names[0] as string] as V) : null;
|
|
137
|
+
if (!ON) return () => first;
|
|
138
|
+
host!.views(names);
|
|
139
|
+
return () => { const v = host!.view(); return v !== null && Object.hasOwn(presets, v) ? (presets[v] as V) : first; };
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
/** An overlay the lab can switch on (onion skin, arcs, smears, hit boxes): drawn by the game's own renderer. */
|
|
143
|
+
overlay(name: string, draw: Draw): void {
|
|
144
|
+
if (!ON) return;
|
|
145
|
+
drawers.set(name, draw);
|
|
146
|
+
host!.overlays([...drawers.keys()]);
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
/** Draw every overlay the lab has on, in whatever transform the game is in (call it in world space). */
|
|
150
|
+
draw(ctx: unknown, ...rest: unknown[]): void {
|
|
151
|
+
if (!ON) return;
|
|
152
|
+
for (const [name, fn] of drawers) if (host!.overlay(name)) { try { fn(ctx, ...rest); } catch { /* an overlay never breaks the frame */ } }
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The game's tunables (tunables.json): an object of numbers by name. Outside the lab, the file's values. In the lab,
|
|
157
|
+
* the values the sliders hold now (a take restarts when one moves); the lab writes kept values back into the file.
|
|
158
|
+
*/
|
|
159
|
+
tunables<S extends TunableFile>(spec: S): Tuned<S> {
|
|
160
|
+
const base: Record<string, number> = {};
|
|
161
|
+
for (const [k, s] of Object.entries(spec)) base[k] = valueOf(s);
|
|
162
|
+
if (!ON) return Object.freeze(base) as Tuned<S>;
|
|
163
|
+
const live = host!.tunables(spec);
|
|
164
|
+
const out = {} as Record<string, number>;
|
|
165
|
+
for (const k of Object.keys(base)) Object.defineProperty(out, k, { enumerable: true, get: () => (Number.isFinite(live[k]) ? (live[k] as number) : (base[k] as number)) });
|
|
166
|
+
return out as Tuned<S>;
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Dice for juice (particles, shake, sparks): its own stream in the lab, so a build that adds a particle never moves
|
|
171
|
+
* a gem or a bot in the other build (Math.random() is the world's dice, the same in both). Math.random() outside.
|
|
172
|
+
*/
|
|
173
|
+
random(): number { return ON ? host!.random() : Math.random(); },
|
|
174
|
+
|
|
175
|
+
/** A game that loads assets after its page has loaded: hold() early, ready() when it can play. */
|
|
176
|
+
hold(): void { if (ON) host!.hold(); },
|
|
177
|
+
ready(): void { if (ON) host!.ready(); },
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export default lab;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<meta name="color-scheme" content="dark">
|
|
7
|
+
<title>Game Lab</title>
|
|
8
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 1l7 7-7 7-7-7z' fill='%23ffad3b'/%3E%3C/svg%3E">
|
|
9
|
+
<link rel="stylesheet" href="/_lab/lab.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<header class="bar top">
|
|
13
|
+
<div class="brand"><span class="mark" aria-hidden="true"></span><span class="word">GAME LAB</span></div>
|
|
14
|
+
<div class="title"><b id="game-name">…</b><span class="sep">/</span><span id="take-name">…</span></div>
|
|
15
|
+
<div class="status" id="status" aria-live="polite"></div>
|
|
16
|
+
</header>
|
|
17
|
+
|
|
18
|
+
<nav class="bar tools" aria-label="View">
|
|
19
|
+
<div class="group" role="group" aria-label="Which build" id="layout"></div>
|
|
20
|
+
<div class="group" role="group" aria-label="Views" id="views"></div>
|
|
21
|
+
<div class="group" role="group" aria-label="Device" id="device"></div>
|
|
22
|
+
<div class="group" role="group" aria-label="Overlays" id="overlays"></div>
|
|
23
|
+
<div class="group grow"></div>
|
|
24
|
+
<div class="group" role="group" aria-label="Take">
|
|
25
|
+
<label class="select"><span class="sr">Take</span><select id="take-pick"></select></label>
|
|
26
|
+
<button type="button" id="rec" class="rec" title="Record a take: play it in NEW">● REC</button>
|
|
27
|
+
</div>
|
|
28
|
+
</nav>
|
|
29
|
+
|
|
30
|
+
<main class="work">
|
|
31
|
+
<section class="stage" id="stage" aria-label="New and Today side by side">
|
|
32
|
+
<div class="slot" data-pane="new">
|
|
33
|
+
<div class="frame"><iframe title="New: the working tree" id="pane-new" tabindex="-1"></iframe></div>
|
|
34
|
+
<div class="tag new"><span>NEW</span><small id="new-sub"></small></div>
|
|
35
|
+
<div class="caption" id="cap-new"></div>
|
|
36
|
+
<div class="veil" id="veil-new" hidden></div>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="slot" data-pane="today">
|
|
39
|
+
<div class="frame"><iframe title="Today: the last commit" id="pane-today" tabindex="-1"></iframe></div>
|
|
40
|
+
<div class="tag today"><span>TODAY</span><small id="today-sub"></small></div>
|
|
41
|
+
<div class="caption" id="cap-today"></div>
|
|
42
|
+
<div class="veil" id="veil-today" hidden></div>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="recbar" id="recbar" hidden><span class="dot"></span><span id="rec-text">Recording: play in NEW</span><button type="button" id="rec-stop">Stop</button></div>
|
|
45
|
+
</section>
|
|
46
|
+
|
|
47
|
+
<aside class="tune" id="tune" aria-label="Tunables">
|
|
48
|
+
<div class="tune-head"><b>TUNABLES</b><small id="tune-file"></small></div>
|
|
49
|
+
<div class="tune-list" id="tune-list"></div>
|
|
50
|
+
<div class="tune-foot">
|
|
51
|
+
<button type="button" id="keep" class="primary" disabled>Keep in code</button>
|
|
52
|
+
<button type="button" id="undo" disabled>Undo</button>
|
|
53
|
+
</div>
|
|
54
|
+
</aside>
|
|
55
|
+
|
|
56
|
+
<section class="deck" aria-label="Timeline">
|
|
57
|
+
<div class="transport">
|
|
58
|
+
<div class="group" role="group" aria-label="Playback">
|
|
59
|
+
<button type="button" id="to-start" title="Start (Home)">|◀</button>
|
|
60
|
+
<button type="button" id="back" title="Back a frame (←)">◀</button>
|
|
61
|
+
<button type="button" id="play" class="wide" title="Play or pause (Space)">Pause</button>
|
|
62
|
+
<button type="button" id="fwd" title="Forward a frame (→)">▶</button>
|
|
63
|
+
<button type="button" id="to-end" title="End (End)">▶|</button>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="group" role="group" aria-label="Speed" id="speeds"></div>
|
|
66
|
+
<div class="group" role="group" aria-label="Frames a second" id="fpses"></div>
|
|
67
|
+
<div class="group" role="group" aria-label="Loop"><button type="button" id="loop" class="on" title="Play the take again from the start">Loop</button></div>
|
|
68
|
+
<div class="counter" id="counter">F 0 / 0 · 0.00 s</div>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="legend" id="legend"></div>
|
|
71
|
+
<div class="lines">
|
|
72
|
+
<canvas id="timeline" aria-label="Phases of the take, New above Today: click or drag to move the playhead"></canvas>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="tracks" id="tracks" role="tablist" aria-label="Graphs"></div>
|
|
75
|
+
<div class="lines">
|
|
76
|
+
<canvas id="graph" aria-label="The chosen value over the take, New against Today"></canvas>
|
|
77
|
+
</div>
|
|
78
|
+
<p class="hint" id="hint"></p>
|
|
79
|
+
</section>
|
|
80
|
+
</main>
|
|
81
|
+
<script type="module" src="/_lab/page.js"></script>
|
|
82
|
+
</body>
|
|
83
|
+
</html>
|