@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 +54 -2
- package/README.md +39 -0
- package/bin/homie-studio.mjs +50 -0
- 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/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/scaffold.mjs +7 -1
- package/lib/studio.mjs +14 -0
- package/lib/template-history.json +37 -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/take.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A take: one short, repeatable piece of play the Game Lab shows in both builds (games/<id>/lab.json). The same file
|
|
3
|
+
* is read by the lab page (served as it is) and by `homie-studio lab` in Node.
|
|
4
|
+
*
|
|
5
|
+
* {
|
|
6
|
+
* "v": 1,
|
|
7
|
+
* "default": "knock",
|
|
8
|
+
* "takes": {
|
|
9
|
+
* "knock": {
|
|
10
|
+
* "note": "Walk right into Rook and bump him",
|
|
11
|
+
* "seconds": 3, // how long the take is
|
|
12
|
+
* "seed": 7, // the world's dice (Math.random) in both builds
|
|
13
|
+
* "device": "desk", // desk (1280x800) or phone (390x844)
|
|
14
|
+
* "fps": 60, // 60, 30, 15 or 12 frames a second
|
|
15
|
+
* "stage": "dummy", // a scene the game sets up for this mechanic (lab.stage; optional)
|
|
16
|
+
* "view": "close", // one of the game's lab.camera presets (optional)
|
|
17
|
+
* "overlays": ["onion"], // overlays on at the start (optional)
|
|
18
|
+
* "track": "speed", // the graph shown first (optional)
|
|
19
|
+
* "storage": { "key": "value" }, // localStorage at the start (a saved hero, a setting)
|
|
20
|
+
* "inputs": [
|
|
21
|
+
* { "at": 0.1, "key": "ArrowRight", "hold": 0.6 }, // pressed at 0.1 s, held 0.6 s
|
|
22
|
+
* { "at": 0.9, "key": "Space" }, // a tap
|
|
23
|
+
* { "at": 1.0, "down": "KeyW" }, { "at": 1.4, "up": "KeyW" },
|
|
24
|
+
* { "at": 1.5, "pointer": "down", "x": 120, "y": 600, "id": 2, "pt": "touch" }
|
|
25
|
+
* ]
|
|
26
|
+
* }
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* Times are seconds from the take's start; x and y are CSS pixels of the game's page at the take's device size.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export const DEVICES = Object.freeze({
|
|
34
|
+
desk: { w: 1280, h: 800, dpr: 1, label: 'Desk' },
|
|
35
|
+
phone: { w: 390, h: 844, dpr: 2, label: 'Phone' },
|
|
36
|
+
});
|
|
37
|
+
export const FPS = Object.freeze([60, 30, 15, 12]);
|
|
38
|
+
export const SCALES = Object.freeze([1, 0.5, 0.25, 0.1]);
|
|
39
|
+
export const LIMITS = Object.freeze({ seconds: 60, inputs: 5000, takes: 24, name: 32 });
|
|
40
|
+
export const TAKE_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
41
|
+
|
|
42
|
+
const num = (v, d) => (Number.isFinite(Number(v)) ? Number(v) : d);
|
|
43
|
+
const r4 = (v) => Math.round(v * 10000) / 10000;
|
|
44
|
+
|
|
45
|
+
/** What is wrong with a take, in words (empty: nothing). */
|
|
46
|
+
export function checkTake(take, name = 'take') {
|
|
47
|
+
const out = [];
|
|
48
|
+
if (!take || typeof take !== 'object' || Array.isArray(take)) return [`${name} is not an object`];
|
|
49
|
+
const s = Number(take.seconds);
|
|
50
|
+
if (!(s > 0 && s <= LIMITS.seconds)) out.push(`${name}.seconds must be between 0 and ${LIMITS.seconds}`);
|
|
51
|
+
if (take.fps !== undefined && !FPS.includes(Number(take.fps))) out.push(`${name}.fps must be one of ${FPS.join(', ')}`);
|
|
52
|
+
if (take.device !== undefined && !Object.hasOwn(DEVICES, take.device)) out.push(`${name}.device must be desk or phone`);
|
|
53
|
+
if (take.seed !== undefined && !(Number.isInteger(Number(take.seed)) && Number(take.seed) >= 0)) out.push(`${name}.seed must be a whole number`);
|
|
54
|
+
if (take.stage !== undefined && !(typeof take.stage === 'string' && /^[a-z0-9][a-z0-9-]{0,31}$/.test(take.stage))) out.push(`${name}.stage is a short lowercase name`);
|
|
55
|
+
if (take.storage !== undefined && (typeof take.storage !== 'object' || Array.isArray(take.storage) || take.storage === null)) out.push(`${name}.storage must be an object of keys`);
|
|
56
|
+
const inputs = take.inputs ?? [];
|
|
57
|
+
if (!Array.isArray(inputs)) out.push(`${name}.inputs must be a list`);
|
|
58
|
+
else {
|
|
59
|
+
if (inputs.length > LIMITS.inputs) out.push(`${name} has ${inputs.length} inputs (at most ${LIMITS.inputs})`);
|
|
60
|
+
inputs.forEach((row, i) => {
|
|
61
|
+
const at = Number(row?.at);
|
|
62
|
+
if (!(at >= 0)) { out.push(`${name}.inputs[${i}].at must be seconds from the start`); return; }
|
|
63
|
+
const kinds = ['key', 'down', 'up', 'pointer'].filter((k) => row[k] !== undefined);
|
|
64
|
+
if (kinds.length !== 1) out.push(`${name}.inputs[${i}] needs exactly one of key, down, up or pointer`);
|
|
65
|
+
else if (kinds[0] === 'pointer') {
|
|
66
|
+
if (!['down', 'move', 'up', 'cancel'].includes(row.pointer)) out.push(`${name}.inputs[${i}].pointer is down, move, up or cancel`);
|
|
67
|
+
if (!Number.isFinite(Number(row.x)) || !Number.isFinite(Number(row.y))) out.push(`${name}.inputs[${i}] needs x and y`);
|
|
68
|
+
} else if (typeof row[kinds[0]] !== 'string' || !/^[A-Za-z0-9]{1,24}$/.test(row[kinds[0]])) out.push(`${name}.inputs[${i}].${kinds[0]} is a KeyboardEvent code such as ArrowRight, KeyW or Space`);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Every take of a lab file, checked: { takes, default, problems }. */
|
|
75
|
+
export function readLabFile(json) {
|
|
76
|
+
const problems = [];
|
|
77
|
+
const takes = {};
|
|
78
|
+
if (!json || typeof json !== 'object') return { takes, default: null, problems: ['lab.json is not an object'] };
|
|
79
|
+
const all = json.takes && typeof json.takes === 'object' ? json.takes : {};
|
|
80
|
+
for (const [name, take] of Object.entries(all).slice(0, LIMITS.takes)) {
|
|
81
|
+
if (!TAKE_NAME.test(name)) { problems.push(`take "${name}": a name is lowercase letters, digits and hyphens`); continue; }
|
|
82
|
+
const p = checkTake(take, name);
|
|
83
|
+
if (p.length) problems.push(...p); else takes[name] = take;
|
|
84
|
+
}
|
|
85
|
+
const names = Object.keys(takes);
|
|
86
|
+
return { takes, default: names.includes(json.default) ? json.default : names[0] ?? null, problems };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** How many frames a take has at its frame rate. */
|
|
90
|
+
export function framesOf(take, fps = take?.fps ?? 60) { return Math.max(1, Math.round(num(take?.seconds, 3) * fps)); }
|
|
91
|
+
|
|
92
|
+
/** A take as the harness plays it: inputs as events in ms, in order. */
|
|
93
|
+
export function expandTake(take, { fps } = {}) {
|
|
94
|
+
const f = FPS.includes(Number(fps)) ? Number(fps) : FPS.includes(Number(take.fps)) ? Number(take.fps) : 60;
|
|
95
|
+
const events = [];
|
|
96
|
+
for (const row of take.inputs ?? []) {
|
|
97
|
+
const at = num(row.at, 0) * 1000;
|
|
98
|
+
if (row.key !== undefined) {
|
|
99
|
+
events.push({ at, type: 'key', code: row.key, down: true });
|
|
100
|
+
events.push({ at: at + Math.max(1000 / f, num(row.hold, 0.05) * 1000), type: 'key', code: row.key, down: false });
|
|
101
|
+
} else if (row.down !== undefined) events.push({ at, type: 'key', code: row.down, down: true });
|
|
102
|
+
else if (row.up !== undefined) events.push({ at, type: 'key', code: row.up, down: false });
|
|
103
|
+
else if (row.pointer !== undefined) events.push({ at, type: 'pointer', ev: row.pointer, x: num(row.x, 0), y: num(row.y, 0), id: Math.max(1, Math.round(num(row.id, 1))), pt: row.pt === 'mouse' || row.pt === 'pen' ? row.pt : 'touch' });
|
|
104
|
+
}
|
|
105
|
+
// Stable: two events at one time keep the file's order.
|
|
106
|
+
const order = events.map((e, i) => [e, i]).sort((a, b) => a[0].at - b[0].at || a[1] - b[1]).map(([e]) => e);
|
|
107
|
+
return {
|
|
108
|
+
fps: f,
|
|
109
|
+
frames: framesOf(take, f),
|
|
110
|
+
seed: Number.isInteger(Number(take.seed)) ? Number(take.seed) : 1,
|
|
111
|
+
device: Object.hasOwn(DEVICES, take.device) ? take.device : 'desk',
|
|
112
|
+
stage: typeof take.stage === 'string' ? take.stage : null,
|
|
113
|
+
view: typeof take.view === 'string' ? take.view : null,
|
|
114
|
+
overlays: Array.isArray(take.overlays) ? take.overlays.map(String) : [],
|
|
115
|
+
track: typeof take.track === 'string' ? take.track : null,
|
|
116
|
+
storage: take.storage && typeof take.storage === 'object' ? take.storage : null,
|
|
117
|
+
inputs: order,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* What the harness recorded (events in ms) as a take's inputs (seconds): a key pressed and let go becomes one row
|
|
123
|
+
* with its hold; anything else stays an edge.
|
|
124
|
+
*/
|
|
125
|
+
export function inputsFromRecording(events) {
|
|
126
|
+
const rows = [];
|
|
127
|
+
const open = new Map();
|
|
128
|
+
for (const e of events) {
|
|
129
|
+
const at = r4(num(e.at, 0) / 1000);
|
|
130
|
+
if (e.type === 'key' && e.down) { const row = { at, down: e.code }; open.set(e.code, row); rows.push(row); }
|
|
131
|
+
else if (e.type === 'key') {
|
|
132
|
+
const d = open.get(e.code);
|
|
133
|
+
if (d) { open.delete(e.code); delete d.down; d.key = e.code; d.hold = r4(at - d.at); } else rows.push({ at, up: e.code });
|
|
134
|
+
} else if (e.type === 'pointer') rows.push({ at, pointer: e.ev, x: e.x, y: e.y, id: e.id, pt: e.pt });
|
|
135
|
+
}
|
|
136
|
+
return rows.map((r) => (r.key !== undefined ? { at: r.at, key: r.key, hold: r.hold } : r));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** lab.json as text: readable, one input to a line. */
|
|
140
|
+
export function formatLabFile(json) {
|
|
141
|
+
const out = ['{', ` "v": 1,`, ` "default": ${JSON.stringify(json.default ?? null)},`, ' "takes": {'];
|
|
142
|
+
const names = Object.keys(json.takes ?? {});
|
|
143
|
+
names.forEach((name, i) => {
|
|
144
|
+
const t = json.takes[name];
|
|
145
|
+
const keys = Object.keys(t).filter((k) => k !== 'inputs');
|
|
146
|
+
out.push(` ${JSON.stringify(name)}: {`);
|
|
147
|
+
const lines = keys.map((k) => ` ${JSON.stringify(k)}: ${JSON.stringify(t[k])}`);
|
|
148
|
+
const inputs = Array.isArray(t.inputs) ? t.inputs : [];
|
|
149
|
+
lines.push(` "inputs": [${inputs.length ? `\n${inputs.map((r) => ` ${JSON.stringify(r)}`).join(',\n')}\n ` : ''}]`);
|
|
150
|
+
out.push(lines.join(',\n'));
|
|
151
|
+
out.push(` }${i < names.length - 1 ? ',' : ''}`);
|
|
152
|
+
});
|
|
153
|
+
out.push(' }', '}');
|
|
154
|
+
return `${out.join('\n')}\n`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** tunables.json as text: one tunable to a line, so a kept change is a one-line diff. */
|
|
158
|
+
export function formatTunables(spec) {
|
|
159
|
+
const names = Object.keys(spec ?? {});
|
|
160
|
+
if (!names.length) return '{}\n';
|
|
161
|
+
const one = (s) => (s && typeof s === 'object' && !Array.isArray(s) ? `{ ${Object.entries(s).map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`).join(', ')} }` : JSON.stringify(s));
|
|
162
|
+
return `{\n${names.map((k, i) => ` ${JSON.stringify(k)}: ${one(spec[k])}${i < names.length - 1 ? ',' : ''}`).join('\n')}\n}\n`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A tunable's value (a bare number, or { value }). */
|
|
166
|
+
export function tunableValue(s) { return typeof s === 'number' ? s : num(s?.value, NaN); }
|
|
167
|
+
|
|
168
|
+
/** tunables.json with new values (by name): what is unknown or not a number is refused, ranges are kept. */
|
|
169
|
+
export function setTunables(spec, values) {
|
|
170
|
+
const next = JSON.parse(JSON.stringify(spec ?? {}));
|
|
171
|
+
const changed = [];
|
|
172
|
+
const refused = [];
|
|
173
|
+
for (const [k, raw] of Object.entries(values ?? {})) {
|
|
174
|
+
const v = Number(raw);
|
|
175
|
+
if (!Object.hasOwn(next, k)) { refused.push(`${k}: not in tunables.json`); continue; }
|
|
176
|
+
if (!Number.isFinite(v)) { refused.push(`${k}: ${JSON.stringify(raw)} is not a number`); continue; }
|
|
177
|
+
const s = next[k];
|
|
178
|
+
const before = tunableValue(s);
|
|
179
|
+
if (typeof s === 'number') next[k] = v;
|
|
180
|
+
else {
|
|
181
|
+
s.value = v;
|
|
182
|
+
// A value past the slider's end moves the end: the file says what was kept, and the slider still reaches it.
|
|
183
|
+
if (Number.isFinite(s.min) && v < s.min) s.min = v;
|
|
184
|
+
if (Number.isFinite(s.max) && v > s.max) s.max = v;
|
|
185
|
+
}
|
|
186
|
+
if (before !== v) changed.push({ name: k, from: before, to: v });
|
|
187
|
+
}
|
|
188
|
+
return { spec: next, changed, refused };
|
|
189
|
+
}
|
package/lib/build.mjs
CHANGED
|
@@ -46,7 +46,7 @@ import { vocabularyOf } from '../worker/brain.mjs';
|
|
|
46
46
|
|
|
47
47
|
const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
|
|
48
48
|
/** Never copied into a static game's served folder. */
|
|
49
|
-
const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md', 'CODEX.md']);
|
|
49
|
+
const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md', 'CODEX.md', 'lab.json']);
|
|
50
50
|
const LOADERS = { '.png': 'file', '.jpg': 'file', '.jpeg': 'file', '.gif': 'file', '.webp': 'file', '.mp3': 'file', '.ogg': 'file', '.wav': 'file', '.m4a': 'file', '.glb': 'file', '.gltf': 'file', '.bin': 'file', '.hdr': 'file', '.svg': 'file', '.json': 'json', '.woff2': 'file', '.ttf': 'file' };
|
|
51
51
|
|
|
52
52
|
/** The port toolkit as one classic script (window.HomiePort), for static games. Built once per build. */
|
|
@@ -222,10 +222,66 @@ function keepMap(root, id, out, metafile) {
|
|
|
222
222
|
writeFileSync(join(dir, 'main.js.sha256'), `${createHash('sha256').update(readFileSync(join(out, 'assets', 'main.js'))).digest('hex')}\n`);
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
|
|
225
|
+
/** esbuild, as the studio has it installed (the version its package.json pins). */
|
|
226
|
+
export async function studioEsbuild(root) {
|
|
226
227
|
const require = createRequire(join(root, 'package.json'));
|
|
227
|
-
|
|
228
|
-
|
|
228
|
+
try { return require('esbuild'); } catch { return import('esbuild'); }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* One game's own files into `out` (emptied first): its bundle, static copy or own build's output, its index.html and
|
|
233
|
+
* its public/ folder. What `build` serves at /games/<id>/, and what the Game Lab (lib/lab.mjs) builds New and Today
|
|
234
|
+
* with. `sourcemap` is esbuild's (the lab keeps a linked map beside its builds); `maps` keeps the site build's map in
|
|
235
|
+
* .studio/maps/<id>/. Returns { mode, warnings, metafile } (metafile: the bundle's inputs, null for other modes).
|
|
236
|
+
*/
|
|
237
|
+
export async function buildGameFiles(esbuild, root, g, out, { maps = false, sourcemap = null, cache = {}, log = () => {} } = {}) {
|
|
238
|
+
rmSync(out, { recursive: true, force: true });
|
|
239
|
+
mkdirSync(join(out, 'assets'), { recursive: true });
|
|
240
|
+
const mode = g.build?.mode ?? 'bundle';
|
|
241
|
+
let warnings = 0;
|
|
242
|
+
let metafile = null;
|
|
243
|
+
const bundle = async (entryRel) => {
|
|
244
|
+
const entry = join(g.dir, entryRel);
|
|
245
|
+
if (!existsSync(entry)) throw new Error(`games/${g.id}: entry ${entryRel} not found`);
|
|
246
|
+
const result = await esbuild.build({
|
|
247
|
+
entryPoints: [entry], bundle: true, format: 'esm', target: 'es2022', minify: true, sourcemap: sourcemap ?? (maps ? 'external' : false),
|
|
248
|
+
outfile: join(out, 'assets', 'main.js'), absWorkingDir: root, logLevel: 'silent', metafile: true,
|
|
249
|
+
loader: LOADERS, assetNames: '[name]-[hash]',
|
|
250
|
+
}).catch((error) => {
|
|
251
|
+
const first = error.errors?.[0];
|
|
252
|
+
throw new Error(`games/${g.id} did not build: ${first ? `${first.text}${first.location ? ` (${first.location.file}:${first.location.line})` : ''}` : error.message}`);
|
|
253
|
+
});
|
|
254
|
+
warnings += result.warnings.length;
|
|
255
|
+
metafile = result.metafile;
|
|
256
|
+
if (maps) keepMap(root, g.id, out, result.metafile);
|
|
257
|
+
};
|
|
258
|
+
if (mode === 'static') {
|
|
259
|
+
copyStatic(g.dir, out);
|
|
260
|
+
writeFileSync(join(out, 'homie-port.js'), await portScript(esbuild, root, cache));
|
|
261
|
+
if (g.entry) await bundle(g.entry);
|
|
262
|
+
const html = readFileSync(join(out, 'index.html'), 'utf8');
|
|
263
|
+
if (!/homie-port\.js/.test(html)) log(`warning: games/${g.id}/index.html does not load ./homie-port.js (the port toolkit); add <script src="./homie-port.js"></script> first in <head>`);
|
|
264
|
+
} else if (mode === 'command') {
|
|
265
|
+
const command = String(g.build.command ?? 'npm run build');
|
|
266
|
+
const res = spawnSync(command, { cwd: g.dir, shell: true, encoding: 'utf8', timeout: 10 * 60_000, maxBuffer: 64 * 1024 * 1024 });
|
|
267
|
+
if (res.status !== 0) throw new Error(`games/${g.id}: \`${command}\` failed: ${`${res.stdout ?? ''}${res.stderr ?? ''}`.trim().split('\n').slice(-6).join(' ')}`);
|
|
268
|
+
const built = join(g.dir, String(g.build.out ?? 'dist'));
|
|
269
|
+
if (!existsSync(join(built, 'index.html'))) throw new Error(`games/${g.id}: \`${command}\` left no index.html in ${relative(g.dir, built) || '.'}`);
|
|
270
|
+
cpSync(built, out, { recursive: true });
|
|
271
|
+
writeFileSync(join(out, 'homie-port.js'), await portScript(esbuild, root, cache));
|
|
272
|
+
} else {
|
|
273
|
+
await bundle(g.entry ?? 'src/main.ts');
|
|
274
|
+
const html = join(g.dir, 'index.html');
|
|
275
|
+
if (!existsSync(html)) throw new Error(`games/${g.id}/index.html is missing`);
|
|
276
|
+
writeFileSync(join(out, 'index.html'), readFileSync(html, 'utf8'));
|
|
277
|
+
}
|
|
278
|
+
if (mode !== 'static' && existsSync(join(g.dir, 'public'))) cpSync(join(g.dir, 'public'), out, { recursive: true });
|
|
279
|
+
if (!existsSync(join(out, 'index.html'))) throw new Error(`games/${g.id}/index.html is missing`);
|
|
280
|
+
return { mode, warnings, metafile };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false } = {}) {
|
|
284
|
+
const esbuild = await studioEsbuild(root);
|
|
229
285
|
const studio = readStudio(root);
|
|
230
286
|
const dist = join(root, 'site', 'dist');
|
|
231
287
|
const games = listGames(root).filter((g) => !only || g.id === only);
|
|
@@ -236,47 +292,8 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
|
|
|
236
292
|
const cache = {};
|
|
237
293
|
for (const g of games) {
|
|
238
294
|
const out = join(dist, 'games', g.id);
|
|
239
|
-
rmSync(out, { recursive: true, force: true });
|
|
240
|
-
mkdirSync(join(out, 'assets'), { recursive: true });
|
|
241
|
-
const mode = g.build?.mode ?? 'bundle';
|
|
242
295
|
const started = Date.now();
|
|
243
|
-
|
|
244
|
-
const bundle = async (entryRel) => {
|
|
245
|
-
const entry = join(g.dir, entryRel);
|
|
246
|
-
if (!existsSync(entry)) throw new Error(`games/${g.id}: entry ${entryRel} not found`);
|
|
247
|
-
const result = await esbuild.build({
|
|
248
|
-
entryPoints: [entry], bundle: true, format: 'esm', target: 'es2022', minify: true, sourcemap: maps ? 'external' : false,
|
|
249
|
-
outfile: join(out, 'assets', 'main.js'), absWorkingDir: root, logLevel: 'silent', metafile: true,
|
|
250
|
-
loader: LOADERS, assetNames: '[name]-[hash]',
|
|
251
|
-
}).catch((error) => {
|
|
252
|
-
const first = error.errors?.[0];
|
|
253
|
-
throw new Error(`games/${g.id} did not build: ${first ? `${first.text}${first.location ? ` (${first.location.file}:${first.location.line})` : ''}` : error.message}`);
|
|
254
|
-
});
|
|
255
|
-
warnings += result.warnings.length;
|
|
256
|
-
if (maps) keepMap(root, g.id, out, result.metafile);
|
|
257
|
-
};
|
|
258
|
-
if (mode === 'static') {
|
|
259
|
-
copyStatic(g.dir, out);
|
|
260
|
-
writeFileSync(join(out, 'homie-port.js'), await portScript(esbuild, root, cache));
|
|
261
|
-
if (g.entry) await bundle(g.entry);
|
|
262
|
-
const html = readFileSync(join(out, 'index.html'), 'utf8');
|
|
263
|
-
if (!/homie-port\.js/.test(html)) log(`warning: games/${g.id}/index.html does not load ./homie-port.js (the port toolkit); add <script src="./homie-port.js"></script> first in <head>`);
|
|
264
|
-
} else if (mode === 'command') {
|
|
265
|
-
const command = String(g.build.command ?? 'npm run build');
|
|
266
|
-
const res = spawnSync(command, { cwd: g.dir, shell: true, encoding: 'utf8', timeout: 10 * 60_000, maxBuffer: 64 * 1024 * 1024 });
|
|
267
|
-
if (res.status !== 0) throw new Error(`games/${g.id}: \`${command}\` failed: ${`${res.stdout ?? ''}${res.stderr ?? ''}`.trim().split('\n').slice(-6).join(' ')}`);
|
|
268
|
-
const built = join(g.dir, String(g.build.out ?? 'dist'));
|
|
269
|
-
if (!existsSync(join(built, 'index.html'))) throw new Error(`games/${g.id}: \`${command}\` left no index.html in ${relative(g.dir, built) || '.'}`);
|
|
270
|
-
cpSync(built, out, { recursive: true });
|
|
271
|
-
writeFileSync(join(out, 'homie-port.js'), await portScript(esbuild, root, cache));
|
|
272
|
-
} else {
|
|
273
|
-
await bundle(g.entry ?? 'src/main.ts');
|
|
274
|
-
const html = join(g.dir, 'index.html');
|
|
275
|
-
if (!existsSync(html)) throw new Error(`games/${g.id}/index.html is missing`);
|
|
276
|
-
writeFileSync(join(out, 'index.html'), readFileSync(html, 'utf8'));
|
|
277
|
-
}
|
|
278
|
-
if (mode !== 'static' && existsSync(join(g.dir, 'public'))) cpSync(join(g.dir, 'public'), out, { recursive: true });
|
|
279
|
-
if (!existsSync(join(out, 'index.html'))) throw new Error(`games/${g.id}/index.html is missing`);
|
|
296
|
+
const { mode, warnings } = await buildGameFiles(esbuild, root, g, out, { maps, cache, log });
|
|
280
297
|
// The guides' vocabulary (NETPLAY.md section 18): checked here, so a room never meets a line it cannot say.
|
|
281
298
|
vocabFor(g, out);
|
|
282
299
|
// The game's own source, for other studios to remix (game.json "share": { "source": false } keeps it private).
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio lab check <game>`: the Game Lab, headless. The same lab page a person opens (lab/page) plays the take
|
|
3
|
+
* in New and Today in a headless Chrome on this computer's GPU, then plays it again from a fresh load and compares
|
|
4
|
+
* every frame, and writes what it saw:
|
|
5
|
+
*
|
|
6
|
+
* .studio/lab/<game>/check-<time>/
|
|
7
|
+
* summary.json per build: each phase (frames, ms), each tracked value (peak, when, where it ends), the game's
|
|
8
|
+
* JavaScript per frame (median, p95), errors; whether a replay landed on the same frames
|
|
9
|
+
* REPORT.md the same, New against Today, in a few lines a person reads
|
|
10
|
+
* sheet.png the lab at the start of each phase, New beside Today
|
|
11
|
+
* still.jpg New beside Today at the take's busiest moment (what the Homie card shows)
|
|
12
|
+
* page.jpg the whole lab page at that moment
|
|
13
|
+
* .studio/lab/<game>/latest.json the last check, in a few fields (where its files are, its phases and numbers): what a
|
|
14
|
+
* Studio pane or a card shows without running anything (.studio/lab/server.json says where the
|
|
15
|
+
* lab itself is running)
|
|
16
|
+
*
|
|
17
|
+
* homie-studio lab check <game> [--take <name>] [--today HEAD|<ref>] [--device desk|phone] [--fps 60] [--frames 6]
|
|
18
|
+
* [--out <dir>] [--still <file.jpg>]
|
|
19
|
+
*
|
|
20
|
+
* It prints paths and a few numbers, never the data. The cost numbers are the game's JavaScript per frame on this
|
|
21
|
+
* computer, both builds on the same frames: a hint that a feel change costs something, never a frame-rate claim (the
|
|
22
|
+
* perf skill measures that, in two browsers in a room).
|
|
23
|
+
*/
|
|
24
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { join, relative } from 'node:path';
|
|
26
|
+
import { chromeArgs, findChrome, noChrome, SOFTWARE_GL } from './chrome.mjs';
|
|
27
|
+
import { labDir, startLabServer } from './lab.mjs';
|
|
28
|
+
import { listGames } from './studio.mjs';
|
|
29
|
+
|
|
30
|
+
const round = (v, d = 2) => (Number.isFinite(v) ? Math.round(v * 10 ** d) / 10 ** d : null);
|
|
31
|
+
const pct = (xs, p) => { const s = xs.filter(Number.isFinite).sort((a, b) => a - b); return s.length ? s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))] : null; };
|
|
32
|
+
|
|
33
|
+
/** One build's take, in numbers. */
|
|
34
|
+
export function summarise(pane, { fps, units }) {
|
|
35
|
+
if (!pane?.available) return { available: false, why: pane?.why ?? null };
|
|
36
|
+
const frames = pane.frames ?? [];
|
|
37
|
+
const phases = (pane.phases ?? []).map((s) => ({ name: s.name, note: s.note ?? '', from: s.from + 1, to: s.to, frames: s.to - s.from, ms: round(((s.to - s.from) * 1000) / fps, 0) }));
|
|
38
|
+
const names = new Set();
|
|
39
|
+
for (const r of frames) for (const k of Object.keys(r?.tr ?? {})) names.add(k);
|
|
40
|
+
const tracks = {};
|
|
41
|
+
for (const name of names) {
|
|
42
|
+
const xs = frames.map((r) => (r?.tr && Object.hasOwn(r.tr, name) ? r.tr[name] : null));
|
|
43
|
+
let peak = null; let peakAt = null; let last = null;
|
|
44
|
+
xs.forEach((v, i) => { if (v === null) return; if (peak === null || Math.abs(v) > Math.abs(peak)) { peak = v; peakAt = i + 1; } last = v; });
|
|
45
|
+
const vals = xs.filter((v) => v !== null);
|
|
46
|
+
tracks[name] = { unit: units?.[name] ?? '', peak: round(peak), peakAt, final: round(last), mean: round(vals.reduce((a, b) => a + b, 0) / Math.max(1, vals.length)) };
|
|
47
|
+
}
|
|
48
|
+
const ms = frames.map((r) => r?.ms).filter(Number.isFinite);
|
|
49
|
+
return {
|
|
50
|
+
available: true,
|
|
51
|
+
instrumented: Boolean(pane.meta?.instrumented || pane.meta?.views?.length || pane.meta?.overlays?.length),
|
|
52
|
+
frames: frames.filter(Boolean).length,
|
|
53
|
+
phases, tracks,
|
|
54
|
+
// Chrome's clock in a page is coarse (0.1 ms steps): the mean over every frame is the finer number.
|
|
55
|
+
cost: { mean: round(ms.reduce((a, b) => a + b, 0) / Math.max(1, ms.length), 3), median: round(pct(ms, 0.5), 3), p95: round(pct(ms, 0.95), 3), max: round(Math.max(0, ...ms), 3) },
|
|
56
|
+
views: pane.meta?.views ?? [], overlays: pane.meta?.overlays ?? [],
|
|
57
|
+
errors: pane.meta?.errors ?? [],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Where two runs of one build first differ (frame number), or null when every frame matches. */
|
|
62
|
+
export function firstDifference(a, b) {
|
|
63
|
+
const n = Math.max(a?.length ?? 0, b?.length ?? 0);
|
|
64
|
+
for (let i = 0; i < n; i += 1) if ((a?.[i]?.sig ?? null) !== (b?.[i]?.sig ?? null)) return i + 1;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Frames worth a picture: where each phase starts (New's, then Today's), filled in evenly, at most `n`. */
|
|
69
|
+
export function keyFrames(newPhases, todayPhases, total, n = 6) {
|
|
70
|
+
const want = [];
|
|
71
|
+
const add = (f) => { if (f >= 1 && f <= total && !want.some((x) => Math.abs(x - f) < 3)) want.push(f); };
|
|
72
|
+
for (const s of newPhases ?? []) add(Math.min(s.to, s.from + 3));
|
|
73
|
+
for (const s of todayPhases ?? []) add(Math.min(s.to, s.from + 3));
|
|
74
|
+
for (let i = 1; want.length < n && i <= n * 2; i += 1) add(Math.round((i / (n * 2 + 1)) * total));
|
|
75
|
+
return want.sort((a, b) => a - b).slice(0, n);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function reportOf(r) {
|
|
79
|
+
const n = r.new; const t = r.today;
|
|
80
|
+
const lines = [`# ${r.game}: take "${r.take ?? '(none)'}"`, '', `${r.note ?? ''}`.trim(), '',
|
|
81
|
+
`- ${r.frames} frames at ${r.fps} fps (${round(r.frames / r.fps, 2)} s), ${r.device}, seed ${r.seed}, ${r.inputs} input events.`,
|
|
82
|
+
`- New: the working tree${r.newBuild?.dirty ? ` (${r.newBuild.dirty} changed in games/${r.game})` : ''}. Today: ${r.todayBuild?.commit ? `${r.todayBuild.commit.short} "${r.todayBuild.commit.subject}"` : r.todayBuild?.none ?? 'none'}.`,
|
|
83
|
+
`- Replays: ${r.deterministic.new === null ? 'New replayed on the same frames' : `New DIFFERED from frame ${r.deterministic.new}`}${t.available ? `; ${r.deterministic.today === null ? 'Today replayed on the same frames' : `Today DIFFERED from frame ${r.deterministic.today}`}` : ''}.`,
|
|
84
|
+
...(Object.keys(r.overrides ?? {}).length ? [`- Sliders not kept yet: ${Object.entries(r.overrides).map(([k, v]) => `${k}=${v}`).join(', ')}`] : []),
|
|
85
|
+
'', '## Phases', '', '| | New | Today |', '| --- | --- | --- |'];
|
|
86
|
+
const names = [...new Set([...(n.phases ?? []).map((p) => p.name), ...(t.phases ?? []).map((p) => p.name)])];
|
|
87
|
+
const of = (side, name) => (side.phases ?? []).filter((p) => p.name === name).map((p) => `F${p.from}-${p.to} (${p.frames} f, ${p.ms} ms)`).join(', ') || '-';
|
|
88
|
+
for (const name of names) lines.push(`| ${name} | ${of(n, name)} | ${t.available ? of(t, name) : 'n/a'} |`);
|
|
89
|
+
lines.push('', '## Tracked values (peak, at frame; where it ends)', '', '| | New | Today |', '| --- | --- | --- |');
|
|
90
|
+
for (const name of [...new Set([...Object.keys(n.tracks ?? {}), ...Object.keys(t.tracks ?? {})])]) {
|
|
91
|
+
const cell = (side) => { const x = side.tracks?.[name]; return x ? `${x.peak}${x.unit ? ` ${x.unit}` : ''} at F${x.peakAt}; ends ${x.final}` : '-'; };
|
|
92
|
+
lines.push(`| ${name} | ${cell(n)} | ${t.available ? cell(t) : 'n/a'} |`);
|
|
93
|
+
}
|
|
94
|
+
lines.push('', `## The game's JavaScript per frame (this computer, the same frames)`, '',
|
|
95
|
+
`New mean ${n.cost?.mean} ms (median ${n.cost?.median}, p95 ${n.cost?.p95})${t.available ? `; Today mean ${t.cost?.mean} ms (median ${t.cost?.median}, p95 ${t.cost?.p95})` : ''}. Chrome times a page in steps of about 0.1 ms, so the mean over every frame is the finer number. A hint, not a frame rate: the perf skill measures that.`);
|
|
96
|
+
const errors = [...(n.errors ?? []).map((e) => `New: ${e}`), ...(t.errors ?? []).map((e) => `Today: ${e}`)];
|
|
97
|
+
if (errors.length) lines.push('', '## Errors', '', ...errors.map((e) => `- ${e}`));
|
|
98
|
+
lines.push('', `Pictures: sheet.png (the lab at ${r.keyFrames.map((f) => `F${f}`).join(', ')}), still.jpg (F${r.stillFrame}).`, '');
|
|
99
|
+
return lines.join('\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function labCheck(root, id, { take = null, today = 'HEAD', device = null, fps = null, out = null, still = null, frames: nKey = 6, log = () => {} } = {}) {
|
|
103
|
+
const games = listGames(root);
|
|
104
|
+
const game = id ?? (games.length === 1 ? games[0].id : null);
|
|
105
|
+
if (!game || !games.some((g) => g.id === game)) return { ok: false, command: 'lab check', why: `name the game: homie-studio lab check <${games.map((g) => g.id).join('|') || 'id'}>` };
|
|
106
|
+
const chrome = findChrome();
|
|
107
|
+
if (!chrome) return { ok: false, command: 'lab check', why: noChrome() };
|
|
108
|
+
let puppeteer;
|
|
109
|
+
try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'lab check', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
|
|
110
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
111
|
+
const dir = out ?? join(labDir(root, game), `check-${stamp}`);
|
|
112
|
+
mkdirSync(dir, { recursive: true });
|
|
113
|
+
const server = await startLabServer(root, { port: 0, today, watchFiles: false, log: () => {} });
|
|
114
|
+
const browser = await puppeteer.launch({
|
|
115
|
+
executablePath: chrome, headless: true, timeout: 150_000, protocolTimeout: 240_000,
|
|
116
|
+
args: [...chromeArgs(), '--mute-audio', '--window-size=1600,1000', '--no-first-run', '--no-default-browser-check', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--force-color-profile=srgb'],
|
|
117
|
+
});
|
|
118
|
+
const t0 = Date.now();
|
|
119
|
+
try {
|
|
120
|
+
const page = await browser.newPage();
|
|
121
|
+
await page.setViewport({ width: 1600, height: 1000, deviceScaleFactor: 1 });
|
|
122
|
+
await page.setUserAgent(`${await browser.userAgent()} homie-studio-check homie-studio-lab`);
|
|
123
|
+
const pageErrors = [];
|
|
124
|
+
page.on('pageerror', (e) => { if (pageErrors.length < 10) pageErrors.push(String(e?.message ?? e).slice(0, 240)); });
|
|
125
|
+
const q = new URLSearchParams({ check: '1', ...(take ? { take } : {}), ...(device ? { device } : {}), ...(fps ? { fps: String(fps) } : {}), ...(today !== 'HEAD' ? { today } : {}) });
|
|
126
|
+
log(`lab: ${server.url}/${game}/ (headless)`);
|
|
127
|
+
await page.goto(`${server.url}/${game}/?${q}`, { waitUntil: 'load', timeout: 90_000 });
|
|
128
|
+
await page.waitForFunction(() => Boolean(window.labApp), { timeout: 30_000 });
|
|
129
|
+
await page.evaluate(() => window.labApp.ready);
|
|
130
|
+
const renderer = await page.evaluate(() => { try { const g = document.createElement('canvas').getContext('webgl'); const i = g?.getExtension('WEBGL_debug_renderer_info'); return i ? g.getParameter(i.UNMASKED_RENDERER_WEBGL) : null; } catch { return null; } });
|
|
131
|
+
const first = await page.evaluate(() => window.labApp.data());
|
|
132
|
+
if (!first.take && !Object.keys(first.tunables?.spec ?? {}).length) log('lab: this game has no take yet (lab.json): the check played a still take with no presses');
|
|
133
|
+
log(`lab: played ${first.frames} frames in both builds; playing them again from a fresh load`);
|
|
134
|
+
const second = await page.evaluate(() => window.labApp.rerun());
|
|
135
|
+
const deterministic = {
|
|
136
|
+
new: first.new.available ? firstDifference(first.new.frames, second.new.frames) : null,
|
|
137
|
+
today: first.today.available ? firstDifference(first.today.frames, second.today.frames) : null,
|
|
138
|
+
};
|
|
139
|
+
const summary = {
|
|
140
|
+
ok: true, game, take: first.take, note: first.note, fps: first.fps, frames: first.frames, device: first.device, seed: first.seed, inputs: first.inputs,
|
|
141
|
+
newBuild: first.newBuild, todayBuild: first.todayBuild, overrides: first.overrides, renderer, software: SOFTWARE_GL.test(String(renderer ?? '')),
|
|
142
|
+
deterministic, new: summarise(first.new, first), today: summarise(first.today, first), pageErrors,
|
|
143
|
+
};
|
|
144
|
+
// Pictures: the lab at the start of each phase, New beside Today.
|
|
145
|
+
const keys = keyFrames(first.new.phases, first.today.phases, first.frames, Math.max(1, Math.min(12, Number(nKey) || 6)));
|
|
146
|
+
const shots = [];
|
|
147
|
+
// Just the panes (their names above, their phases below), not the stage's empty margin.
|
|
148
|
+
const panes = () => page.evaluate(() => {
|
|
149
|
+
const rs = [...document.querySelectorAll('.slot')].filter((e) => !e.hidden).map((e) => e.getBoundingClientRect());
|
|
150
|
+
const x = Math.min(...rs.map((r) => r.left)); const y = Math.min(...rs.map((r) => r.top));
|
|
151
|
+
return { x: Math.max(0, x - 6), y: Math.max(0, y - 6), width: Math.max(...rs.map((r) => r.right)) - x + 12, height: Math.max(...rs.map((r) => r.bottom)) - y + 12 };
|
|
152
|
+
});
|
|
153
|
+
for (const f of keys) {
|
|
154
|
+
await page.evaluate((x) => window.labApp.seek(x), f);
|
|
155
|
+
const png = await page.screenshot({ type: 'png', clip: await panes() });
|
|
156
|
+
const at = (side) => side.phases?.find((p) => f >= p.from && f <= p.to)?.name ?? '';
|
|
157
|
+
shots.push({ f, png: `data:image/png;base64,${Buffer.from(png).toString('base64')}`, label: `F${f} · ${round(f / first.fps, 2)} s`, newPhase: at(summary.new), todayPhase: summary.today.available ? at(summary.today) : '' });
|
|
158
|
+
}
|
|
159
|
+
// The still: the take's busiest moment (the first phase of New, a few frames in), the whole lab page.
|
|
160
|
+
const stillFrame = summary.new.phases?.[0] ? Math.min(summary.new.phases[0].to, summary.new.phases[0].from + 6) : Math.round(first.frames / 2);
|
|
161
|
+
await page.evaluate((x) => window.labApp.seek(x), stillFrame);
|
|
162
|
+
const stillPath = still ?? join(dir, 'still.jpg');
|
|
163
|
+
await page.screenshot({ path: stillPath, type: 'jpeg', quality: 84, clip: await panes() });
|
|
164
|
+
await page.screenshot({ path: join(dir, 'page.jpg'), type: 'jpeg', quality: 82 });
|
|
165
|
+
const sheet = await browser.newPage();
|
|
166
|
+
await sheet.setViewport({ width: 1600, height: 900, deviceScaleFactor: 1 });
|
|
167
|
+
await sheet.setContent(sheetHtml(summary, shots), { waitUntil: 'load' });
|
|
168
|
+
await sheet.screenshot({ path: join(dir, 'sheet.png'), fullPage: true });
|
|
169
|
+
summary.keyFrames = keys;
|
|
170
|
+
summary.stillFrame = stillFrame;
|
|
171
|
+
summary.ms = Date.now() - t0;
|
|
172
|
+
writeFileSync(join(dir, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`);
|
|
173
|
+
writeFileSync(join(dir, 'REPORT.md'), reportOf(summary));
|
|
174
|
+
const rel = (p) => relative(root, p) || p;
|
|
175
|
+
const latest = {
|
|
176
|
+
v: 1, at: new Date().toISOString(), game, take: summary.take, frames: summary.frames, fps: summary.fps, device: summary.device,
|
|
177
|
+
out: rel(dir), report: rel(join(dir, 'REPORT.md')), sheet: rel(join(dir, 'sheet.png')), still: rel(stillPath), page: rel(join(dir, 'page.jpg')),
|
|
178
|
+
today: summary.todayBuild?.commit?.short ?? null, deterministic,
|
|
179
|
+
timeline: { new: (summary.new.phases ?? []).map(({ name, from, to }) => ({ name, from, to })), today: summary.today.available ? summary.today.phases.map(({ name, from, to }) => ({ name, from, to })) : null },
|
|
180
|
+
cost: { new: summary.new.cost ?? null, today: summary.today.available ? summary.today.cost : null },
|
|
181
|
+
};
|
|
182
|
+
if (!out) writeFileSync(join(labDir(root, game), 'latest.json'), `${JSON.stringify(latest, null, 2)}\n`);
|
|
183
|
+
return {
|
|
184
|
+
ok: true, command: 'lab check', game, take: summary.take, out: rel(dir), report: rel(join(dir, 'REPORT.md')), sheet: rel(join(dir, 'sheet.png')), still: rel(stillPath),
|
|
185
|
+
frames: summary.frames, fps: summary.fps, deterministic, renderer, software: summary.software,
|
|
186
|
+
phases: { new: summary.new.phases?.map((p) => `${p.name} ${p.frames}f`) ?? [], today: summary.today.available ? summary.today.phases.map((p) => `${p.name} ${p.frames}f`) : null },
|
|
187
|
+
timeline: { new: (summary.new.phases ?? []).map(({ name, from, to }) => ({ name, from, to })), today: summary.today.available ? summary.today.phases.map(({ name, from, to }) => ({ name, from, to })) : null },
|
|
188
|
+
page: rel(join(dir, 'page.jpg')),
|
|
189
|
+
cost: { new: summary.new.cost ?? null, today: summary.today.available ? summary.today.cost : null },
|
|
190
|
+
errors: [...(summary.new.errors ?? []), ...(summary.today.errors ?? []), ...pageErrors].slice(0, 6),
|
|
191
|
+
today: summary.todayBuild?.commit?.short ?? null, todayNote: summary.todayBuild?.none ?? summary.todayBuild?.error ?? null,
|
|
192
|
+
};
|
|
193
|
+
} finally {
|
|
194
|
+
await browser.close().catch(() => {});
|
|
195
|
+
await server.close().catch(() => {});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function sheetHtml(summary, shots) {
|
|
200
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
201
|
+
const cells = shots.map((s) => `<figure><img src="${s.png}" alt=""><figcaption><b>${esc(s.label)}</b><span class="n">${esc(s.newPhase || '·')}</span><span class="t">${esc(s.todayPhase || '·')}</span></figcaption></figure>`).join('');
|
|
202
|
+
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
203
|
+
body { margin: 0; padding: 24px; background: #080b12; color: #e7eaf3; font: 600 13px/1.3 ui-monospace, Menlo, monospace; }
|
|
204
|
+
h1 { margin: 0 0 4px; font-size: 16px; letter-spacing: .2em; } p { margin: 0 0 18px; color: #8d96b0; }
|
|
205
|
+
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
|
206
|
+
figure { margin: 0; } img { width: 100%; display: block; border: 1px solid #232c42; }
|
|
207
|
+
figcaption { display: flex; gap: 12px; margin-top: 6px; } .n { color: #ffad3b; } .t { color: #7cc4ff; }
|
|
208
|
+
</style></head><body><h1>GAME LAB · ${esc(summary.game)} · ${esc(summary.take ?? 'no take')}</h1>
|
|
209
|
+
<p>New (amber) beside Today (blue)${summary.todayBuild?.commit ? `, Today ${esc(summary.todayBuild.commit.short)}` : ''} · ${summary.frames} frames at ${summary.fps} fps · seed ${summary.seed}</p>
|
|
210
|
+
<div class="grid">${cells}</div></body></html>`;
|
|
211
|
+
}
|
|
212
|
+
|