@origingame/engine 0.0.0-stage → 0.1.1
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/bin/og.mjs +25 -0
- package/dist/web/origin-engine.d.ts +1233 -0
- package/dist/web/origin-engine.js +8628 -0
- package/dist/web/origin-engine_bg.wasm +0 -0
- package/dist/web/origin-engine_bg.wasm.d.ts +366 -0
- package/dist/web-publish/origin-engine-lanes.js +8509 -0
- package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish/origin-engine.d.ts +1176 -0
- package/dist/web-publish/origin-engine.js +8313 -0
- package/dist/web-publish/origin-engine_bg.wasm +0 -0
- package/dist/web-publish/origin-engine_bg.wasm.d.ts +347 -0
- package/dist/web-publish/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
- package/dist/web-publish-2d/origin-engine-lanes.js +8509 -0
- package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine.d.ts +1176 -0
- package/dist/web-publish-2d/origin-engine.js +8313 -0
- package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
- package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +347 -0
- package/dist/web-publish-2d/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
- package/knowledge/README.md +31 -0
- package/knowledge/knowledge.json +13 -0
- package/knowledge/looks/catalogue.json +1871 -0
- package/knowledge/package.json +33 -0
- package/knowledge/rules/platform.json +329 -0
- package/knowledge/scaffold/README.md +27 -0
- package/knowledge/scaffold/distribution.json +48 -0
- package/knowledge/scaffold/origin-engine.json +19 -0
- package/knowledge/scaffold/template/.origin/setup +14 -0
- package/knowledge/scaffold/template/LICENSES.md +23 -0
- package/knowledge/scaffold/template/README.md +37 -0
- package/knowledge/scaffold/template/assets/ai/guard.ogt +48 -0
- package/knowledge/scaffold/template/assets/audio/click.ogt +9 -0
- package/knowledge/scaffold/template/assets/fonts/NotoSansSC-Regular-subset.ttf.ogt +12 -0
- package/knowledge/scaffold/template/assets/fonts/OFL.txt +93 -0
- package/knowledge/scaffold/template/assets/looks/origin-game.ogt +12 -0
- package/knowledge/scaffold/template/assets/nav/yard.ogt +27 -0
- package/knowledge/scaffold/template/assets/scenes/main.ogt +34 -0
- package/knowledge/scaffold/template/assets/strings/en.ogt +14 -0
- package/knowledge/scaffold/template/assets/textures/stone_n.png.ogt +7 -0
- package/knowledge/scaffold/template/gitattributes +22 -0
- package/knowledge/scaffold/template/gitignore +8 -0
- package/knowledge/scaffold/template/package.json +18 -0
- package/knowledge/scaffold/template/project.ogt +25 -0
- package/knowledge/scaffold/template/src/main.ts +246 -0
- package/knowledge/scaffold/template/tests/guard.og.ts +67 -0
- package/knowledge/scaffold/template/tests/smoke.og.ts +43 -0
- package/knowledge/scaffold/template/tsconfig.json +17 -0
- package/knowledge/scripts/lib.ts +207 -0
- package/knowledge/scripts/mount.ts +210 -0
- package/knowledge/scripts/pack.ts +25 -0
- package/knowledge/scripts/scaffold.ts +72 -0
- package/knowledge/scripts/validate.ts +749 -0
- package/knowledge/skills/og-ai/SKILL.md +386 -0
- package/knowledge/skills/og-audio/SKILL.md +191 -0
- package/knowledge/skills/og-build/SKILL.md +330 -0
- package/knowledge/skills/og-core/SKILL.md +285 -0
- package/knowledge/skills/og-feel/SKILL.md +385 -0
- package/knowledge/skills/og-game/SKILL.md +1479 -0
- package/knowledge/skills/og-i18n/SKILL.md +220 -0
- package/knowledge/skills/og-platform-sdk/SKILL.md +199 -0
- package/knowledge/skills/og-taste/SKILL.md +537 -0
- package/knowledge/skills/og-ui/SKILL.md +444 -0
- package/knowledge/wiki/ai.md +627 -0
- package/knowledge/wiki/anim.md +1187 -0
- package/knowledge/wiki/atmosphere.md +636 -0
- package/knowledge/wiki/audio.md +732 -0
- package/knowledge/wiki/camera.md +711 -0
- package/knowledge/wiki/commands.md +357 -0
- package/knowledge/wiki/game.md +220 -0
- package/knowledge/wiki/i18n.md +213 -0
- package/knowledge/wiki/index.md +1027 -0
- package/knowledge/wiki/input.md +1714 -0
- package/knowledge/wiki/load.md +1280 -0
- package/knowledge/wiki/math.md +166 -0
- package/knowledge/wiki/nav.md +1154 -0
- package/knowledge/wiki/physics.md +3142 -0
- package/knowledge/wiki/platform.md +857 -0
- package/knowledge/wiki/probe.md +1692 -0
- package/knowledge/wiki/reflect.md +285 -0
- package/knowledge/wiki/render.md +3511 -0
- package/knowledge/wiki/rng.md +393 -0
- package/knowledge/wiki/stream.md +376 -0
- package/knowledge/wiki/system.md +197 -0
- package/knowledge/wiki/systems.md +43 -0
- package/knowledge/wiki/terrain.md +727 -0
- package/knowledge/wiki/tilemap.md +422 -0
- package/knowledge/wiki/time.md +150 -0
- package/knowledge/wiki/transform.md +230 -0
- package/knowledge/wiki/tween.md +580 -0
- package/knowledge/wiki/ui.md +8000 -0
- package/knowledge/wiki/world.md +909 -0
- package/package.json +68 -4
- package/schema/api.schema.json +29675 -0
- package/shell/README.md +22 -0
- package/shell/brand/README.md +23 -0
- package/shell/brand/icon-192.png +0 -0
- package/shell/brand/icon-512.png +0 -0
- package/shell/brand/mark-on-paper.svg +6 -0
- package/shell/brand/origin-mark.svg +5 -0
- package/shell/brand/origin-motion.css +251 -0
- package/shell/brand/tokens.css +20 -0
- package/shell/brand.ts +132 -0
- package/shell/page.ts +715 -0
- package/shell/platform.ts +203 -0
- package/shell/player.ts +238 -0
- package/shell/shell.css +185 -0
- package/shell/sw.ts +177 -0
- package/shell/transport.ts +515 -0
- package/shell/wire.ts +369 -0
- package/src/e6.ts +69 -0
- package/src/env.d.ts +100 -0
- package/src/generated/abi.ts +980 -0
- package/src/index.ts +193 -0
- package/src/node/audio.ts +86 -0
- package/src/node/build.ts +109 -0
- package/src/node/device.ts +981 -0
- package/src/node/doctor.ts +60 -0
- package/src/node/index.ts +102 -0
- package/src/node/platform.ts +24 -0
- package/src/node/playtest-run.ts +249 -0
- package/src/node/png.ts +130 -0
- package/src/node/polish.ts +976 -0
- package/src/node/run.ts +373 -0
- package/src/node/web-build.ts +808 -0
- package/src/p2.ts +35 -0
- package/src/platform-local.ts +211 -0
- package/src/playtest.ts +131 -0
- package/src/runtime.ts +1509 -0
- package/src/systems/cameras.ts +247 -0
- package/src/ui-kit/bind.ts +141 -0
- package/src/ui-kit/huds.ts +303 -0
- package/src/ui-kit/index.ts +36 -0
- package/src/ui-kit/menus.ts +187 -0
- package/src/ui-kit/settings.ts +131 -0
- package/src/ui-kit/store.ts +222 -0
- package/src/ui-kit/story.ts +89 -0
- package/src/ui-kit/util.ts +106 -0
- package/src/web/a11y.ts +429 -0
- package/src/web/audio-worklet.ts +193 -0
- package/src/web/audio.ts +307 -0
- package/src/web/basis.ts +239 -0
- package/src/web/gamepad.ts +123 -0
- package/src/web/host.ts +166 -0
- package/src/web/input.ts +237 -0
- package/src/web/lanes.ts +21 -0
- package/src/web/play.ts +20 -0
- package/src/web/rpc-ring.ts +110 -0
- package/src/web/settle.ts +122 -0
- package/src/web/shell.ts +643 -0
- package/src/web/text-input.ts +248 -0
- package/src/web/worker.ts +1002 -0
- package/types/bindings.json +13654 -0
- package/types/index.d.ts +20090 -0
- package/vendor/basis/LICENSE +201 -0
- package/vendor/basis/NOTICE +15 -0
- package/vendor/basis/README.md +18 -0
- package/vendor/basis/basis_transcoder.js +19 -0
- package/vendor/basis/basis_transcoder.wasm +0 -0
- package/README.md +0 -3
|
@@ -0,0 +1,976 @@
|
|
|
1
|
+
// `og polish`: a project's playtests played headless, each frame sampled,
|
|
2
|
+
// and the mechanical part of og-taste and og-feel scored from what was
|
|
3
|
+
// seen and heard, into a markdown report with the frames, the numbers and
|
|
4
|
+
// the fix for each finding (the skill section that says how).
|
|
5
|
+
//
|
|
6
|
+
// bun src/node/polish.ts --entry <game bundle> --root <project> --out <dir>
|
|
7
|
+
// [--warnings <cook warnings.json>] [--frames N (in all, default 8)] [--json] <tests/x.og.ts>...
|
|
8
|
+
// bun src/node/polish.ts --one <tests/x.og.ts> --entry <bundle> --root <project> --out <dir>
|
|
9
|
+
//
|
|
10
|
+
// Each playtest runs in its own process (`--one`), on the native host as
|
|
11
|
+
// `og run --headless --commands` runs it, with three additions to its
|
|
12
|
+
// script: `dump_ui` beside every `screenshot` (the UI tree of the frame),
|
|
13
|
+
// a `dump_pulse` marker on every input step (the tick it landed) and a
|
|
14
|
+
// `dump_pulse` at the start of every tick (crates/og-runtime/src/polish.rs: time
|
|
15
|
+
// scale, hit stop, trauma, floating texts, tweens, particles, voices by
|
|
16
|
+
// bus, named entities, UI bounds, step and render times). The run's audio
|
|
17
|
+
// is captured to a WAV. The playtest's checks do not run: `og test` does
|
|
18
|
+
// that. What is measured, and the thresholds, are og-build §5 (the
|
|
19
|
+
// knowledge package's skills); each finding's fix names a skill section.
|
|
20
|
+
// Without a renderer (tier `none`) the screenshots are frames unchecked: the
|
|
21
|
+
// measures that read frames (contrast, text size, HUD) and frame time are
|
|
22
|
+
// n/a, and the frame rows say "no renderer"; every other measure is the same.
|
|
23
|
+
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
26
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
27
|
+
import { bundleGame } from "./build.ts";
|
|
28
|
+
import { loadNativeHost, native } from "./index.ts";
|
|
29
|
+
import { openAudio } from "./audio.ts";
|
|
30
|
+
import { nativePlatform } from "./platform.ts";
|
|
31
|
+
import { decodePng, encodePng, half, type Image } from "./png.ts";
|
|
32
|
+
import { createEngine, type Engine, type GameToken } from "../runtime.ts";
|
|
33
|
+
import type { PlaytestDesc } from "../playtest.ts";
|
|
34
|
+
|
|
35
|
+
declare const Bun: {
|
|
36
|
+
spawnSync(cmd: string[], o?: { cwd?: string; stdout?: string; stderr?: string }): { exitCode: number; stdout: Uint8Array; stderr: Uint8Array };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const argv = process.argv.slice(2);
|
|
40
|
+
const opt = (name: string) => {
|
|
41
|
+
const i = argv.indexOf(name);
|
|
42
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
43
|
+
};
|
|
44
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
45
|
+
const VALUED = new Set(["--one", "--entry", "--root", "--out", "--warnings", "--frames"]);
|
|
46
|
+
const positional = argv.filter((a, i) => !a.startsWith("--") && !VALUED.has(argv[i - 1] ?? ""));
|
|
47
|
+
|
|
48
|
+
function fail(reason: string): never {
|
|
49
|
+
process.stderr.write(`og polish: ${reason}\n`);
|
|
50
|
+
process.exit(2);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── the run of one playtest ────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/** One sample, as crates/og-runtime/src/polish.rs writes it. */
|
|
56
|
+
export interface Pulse {
|
|
57
|
+
tick: number;
|
|
58
|
+
scale: number;
|
|
59
|
+
hitStop: number;
|
|
60
|
+
trauma: number;
|
|
61
|
+
punch: number;
|
|
62
|
+
kick: number;
|
|
63
|
+
floating: number;
|
|
64
|
+
spawned: number;
|
|
65
|
+
texts: string[];
|
|
66
|
+
ghosts: number;
|
|
67
|
+
tweens: number;
|
|
68
|
+
particles: number[];
|
|
69
|
+
voices: Array<[number, number, number]>;
|
|
70
|
+
/** Name, position, scale x and y, flipbook clip × 1000 + frame (-1: none). */
|
|
71
|
+
named: Array<[string, number, number, number, number, number, number]>;
|
|
72
|
+
ui: Array<[string, number, number, number, number, number]>;
|
|
73
|
+
stepMs: number;
|
|
74
|
+
renderMs: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface UiDump {
|
|
78
|
+
tick: number;
|
|
79
|
+
/** The surface the CSS-pixel bounds are on (dpr: device pixels per CSS pixel). */
|
|
80
|
+
surface?: { width: number; height: number; dpr: number } | null;
|
|
81
|
+
elements: Array<{ id: string; parent: string; kind?: string; disabled?: boolean; clipped?: boolean; text: string; x: number; y: number; width: number; height: number }>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What `--one` writes as run.json. */
|
|
85
|
+
interface RunRecord {
|
|
86
|
+
playtest: string;
|
|
87
|
+
title: string;
|
|
88
|
+
size: { width: number; height: number };
|
|
89
|
+
tier: string;
|
|
90
|
+
rate: number;
|
|
91
|
+
ticks: number;
|
|
92
|
+
quit: boolean;
|
|
93
|
+
samples: Pulse[];
|
|
94
|
+
/** Facts by tick, where they changed. */
|
|
95
|
+
facts: Array<{ tick: number; facts: unknown }>;
|
|
96
|
+
inputs: Array<{ step: string; action: string; tick: number }>;
|
|
97
|
+
shots: Array<{ name: string; file: string; tick: number; ui: string | null }>;
|
|
98
|
+
wav: string;
|
|
99
|
+
warnings: string[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const INPUT = /^(key_down|touch_start|touch|gamepad|type|scroll)$/;
|
|
103
|
+
|
|
104
|
+
/** project.ogt's text, which the engine reads tick, seed, scenes and scene from (CreateOptions.project). */
|
|
105
|
+
function projectText(dir: string): string | undefined {
|
|
106
|
+
return existsSync(join(dir, "project.ogt")) ? readFileSync(join(dir, "project.ogt"), "utf8") : undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function projectProfile(text: string | undefined): "2d" | "3d" | undefined {
|
|
110
|
+
const m = text === undefined ? null : /^\s*profile\s+"(2d|3d)"/m.exec(text);
|
|
111
|
+
return m ? (m[1] as "2d" | "3d") : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function runOne(file: string, entry: string, root: string, out: string): Promise<void> {
|
|
115
|
+
rmSync(out, { recursive: true, force: true });
|
|
116
|
+
mkdirSync(out, { recursive: true });
|
|
117
|
+
const bundled = join(out, ".playtest.js");
|
|
118
|
+
const b = await bundleGame(file, bundled, "native");
|
|
119
|
+
if (!b.ok) fail(`${file} does not bundle: ${b.reason}`);
|
|
120
|
+
const desc = ((await import(pathToFileURL(bundled).href)) as { default?: PlaytestDesc }).default;
|
|
121
|
+
if (!desc || typeof desc.commands !== "string") fail(`${file} has no default export: export default playtest({ name, commands, check })`);
|
|
122
|
+
const m = /^(\d+)x(\d+)$/.exec(desc!.size ?? "1280x720");
|
|
123
|
+
const size = { width: Number(m?.[1] ?? 1280), height: Number(m?.[2] ?? 720) };
|
|
124
|
+
|
|
125
|
+
// The script, with a dump_ui beside each screenshot and a marker on each input.
|
|
126
|
+
const steps = desc!.commands.split(";").map((s) => s.trim()).filter(Boolean);
|
|
127
|
+
const script: string[] = [];
|
|
128
|
+
const inputSteps: Array<{ step: string; action: string; marker: string }> = [];
|
|
129
|
+
const shotSteps: Array<{ name: string; ui: string }> = [];
|
|
130
|
+
for (const step of steps) {
|
|
131
|
+
script.push(step);
|
|
132
|
+
const [t, action = "", ...args] = step.split(":");
|
|
133
|
+
if (action === "screenshot") {
|
|
134
|
+
const name = (args[0] && !/^(full|\d+)$/.test(args[0]) ? args[0] : `shot-${shotSteps.length}.png`).replace(/\.png$/, "");
|
|
135
|
+
const ui = `polish-ui-${shotSteps.length}.json`;
|
|
136
|
+
script.push(`${t}:dump_ui:${ui}`);
|
|
137
|
+
shotSteps.push({ name, ui });
|
|
138
|
+
} else if (action === "touch_end" || (action === "touch" && args[1] === "up")) {
|
|
139
|
+
// A release: a UI button answers it (activation on release), so a press is also measured from it.
|
|
140
|
+
const marker = `polish-input-${inputSteps.length}.json`;
|
|
141
|
+
script.push(`${t}:dump_pulse:${marker}`);
|
|
142
|
+
inputSteps.push({ step, action: "release", marker });
|
|
143
|
+
} else if (INPUT.test(action) && !(action === "gamepad" && args[0] === "off") && !(action === "touch" && args[1] !== "down")) {
|
|
144
|
+
const marker = `polish-input-${inputSteps.length}.json`;
|
|
145
|
+
script.push(`${t}:dump_pulse:${marker}`);
|
|
146
|
+
inputSteps.push({ step, action, marker });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
loadNativeHost();
|
|
151
|
+
const extras = native();
|
|
152
|
+
const game = ((await import(pathToFileURL(resolve(entry)).href)) as { default?: GameToken }).default;
|
|
153
|
+
if (!game) fail(`${entry} has no default export`);
|
|
154
|
+
const project = projectText(root);
|
|
155
|
+
const profile = projectProfile(project);
|
|
156
|
+
// GameDesc.start runs once the renderer is attached (as og run and the web Worker do).
|
|
157
|
+
const created = createEngine(game!, { root, deferStart: true, ...(project === undefined ? {} : { project }), ...(profile ? { profile } : {}), ...(desc!.seed === undefined ? {} : { seed: desc!.seed }) });
|
|
158
|
+
if (!created.ok) fail(`${entry}: ${created.reason}`);
|
|
159
|
+
const engine: Engine = created.value;
|
|
160
|
+
engine.attachPlatform(nativePlatform());
|
|
161
|
+
const opened = openAudio(engine.id, "capture");
|
|
162
|
+
if (!opened.ok) fail(`audio: ${opened.reason}`);
|
|
163
|
+
const sink = opened.value;
|
|
164
|
+
const attached = JSON.parse(extras.og_node_attach_offscreen(engine.id, size.width, size.height)) as { ok: boolean; value?: string; reason?: string };
|
|
165
|
+
const tier = attached.ok ? attached.value! : "none";
|
|
166
|
+
const started = engine.advance(0);
|
|
167
|
+
if (!started.ok) fail(started.reason);
|
|
168
|
+
|
|
169
|
+
const warnings: string[] = [];
|
|
170
|
+
const markers = new Map<string, number>();
|
|
171
|
+
const uiDumps = new Map<string, string>();
|
|
172
|
+
const shotsByName = new Map<string, { file: string; tick: number }>();
|
|
173
|
+
const samples: Pulse[] = [];
|
|
174
|
+
const drain = () => {
|
|
175
|
+
for (const o of engine.takeOutputs()) {
|
|
176
|
+
if (o.action === "dump_pulse") {
|
|
177
|
+
if (o.file.startsWith("polish-input-")) markers.set(o.file, (JSON.parse(o.data) as Pulse).tick);
|
|
178
|
+
else samples.push(JSON.parse(o.data) as Pulse);
|
|
179
|
+
} else if (o.action === "dump_ui" && o.file.startsWith("polish-ui-")) {
|
|
180
|
+
// Only a plain file name under out: `polish-ui-../x` would escape it.
|
|
181
|
+
if (o.file !== basename(o.file)) {
|
|
182
|
+
warnings.push(`tick ${o.tick}: output path '${o.file}' leaves ${out}; not written`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
writeFileSync(join(out, o.file), o.data);
|
|
186
|
+
uiDumps.set(o.file, o.file);
|
|
187
|
+
} else if (o.kind === "png") {
|
|
188
|
+
const path = join(out, "frames", basename(o.file));
|
|
189
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
190
|
+
writeFileSync(path, Uint8Array.from(atob(o.data), (c) => c.charCodeAt(0)));
|
|
191
|
+
shotsByName.set(basename(o.file).replace(/\.png$/, ""), { file: path, tick: o.tick });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
for (const l of engine.takeLog()) if (l.warning) warnings.push(`tick ${l.tick}: ${l.text}`);
|
|
195
|
+
};
|
|
196
|
+
drain();
|
|
197
|
+
// A dump_pulse at the start of every tick through the script's last step
|
|
198
|
+
// (the state the previous tick left), each its own output.
|
|
199
|
+
const rate = engine.rate;
|
|
200
|
+
const last = Math.max(0, ...steps.map((st) => Number(st.split(":")[0]) || 0));
|
|
201
|
+
// Steps ascend in time: the pulses go in order, before a step of the same tick.
|
|
202
|
+
const timed = script.map((st, i) => ({ at: Number(st.split(":")[0]) || 0, order: 1, i, st }));
|
|
203
|
+
for (let k = 1; k <= Math.ceil(last * rate) + 1; k++) timed.push({ at: Number((k / rate).toFixed(6)), order: 0, i: k, st: `${(k / rate).toFixed(6)}:dump_pulse` });
|
|
204
|
+
timed.sort((a, b) => a.at - b.at || a.order - b.order || a.i - b.i);
|
|
205
|
+
const report = engine.probe.commands(timed.map((x) => x.st).join(";")) as { untilTick: number } | null;
|
|
206
|
+
drain();
|
|
207
|
+
if (!report) fail(`${file}: the script is malformed`);
|
|
208
|
+
const facts: Array<{ tick: number; facts: unknown }> = [];
|
|
209
|
+
let lastFacts = "";
|
|
210
|
+
let quit = false;
|
|
211
|
+
for (;;) {
|
|
212
|
+
const answer = engine.frame(1000 / rate);
|
|
213
|
+
if (!answer.ok) fail(`${file}: ${answer.reason ?? "the frame failed"}`);
|
|
214
|
+
sink.pump();
|
|
215
|
+
drain();
|
|
216
|
+
const state = engine.probe.state() as { tick: number; facts: unknown };
|
|
217
|
+
const f = JSON.stringify(state.facts ?? null);
|
|
218
|
+
if (f !== lastFacts) {
|
|
219
|
+
facts.push({ tick: state.tick, facts: state.facts ?? null });
|
|
220
|
+
lastFacts = f;
|
|
221
|
+
}
|
|
222
|
+
if (answer.quit) {
|
|
223
|
+
quit = true;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
if ((answer.tick ?? 0) > report!.untilTick) break;
|
|
227
|
+
}
|
|
228
|
+
drain();
|
|
229
|
+
const wav = join(out, "audio.wav");
|
|
230
|
+
writeFileSync(wav, sink.wav());
|
|
231
|
+
sink.close();
|
|
232
|
+
const record: RunRecord = {
|
|
233
|
+
playtest: basename(file).replace(/\.og\.ts$/, ""),
|
|
234
|
+
title: desc!.name,
|
|
235
|
+
size,
|
|
236
|
+
tier,
|
|
237
|
+
rate,
|
|
238
|
+
ticks: samples.at(-1)?.tick ?? 0,
|
|
239
|
+
quit,
|
|
240
|
+
samples,
|
|
241
|
+
facts,
|
|
242
|
+
inputs: inputSteps.map((s) => ({ step: s.step, action: s.action, tick: markers.get(s.marker) ?? -1 })),
|
|
243
|
+
shots: shotSteps.map((s) => {
|
|
244
|
+
const shot = shotsByName.get(s.name);
|
|
245
|
+
return { name: s.name, file: shot?.file ?? "", tick: shot?.tick ?? -1, ui: uiDumps.has(s.ui) ? join(out, s.ui) : null };
|
|
246
|
+
}),
|
|
247
|
+
wav,
|
|
248
|
+
warnings,
|
|
249
|
+
};
|
|
250
|
+
writeFileSync(join(out, "run.json"), JSON.stringify(record));
|
|
251
|
+
process.exit(0);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── measures ───────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
const BUS = { master: 0, music: 1, sfx: 2, ui: 3 };
|
|
257
|
+
|
|
258
|
+
/** Relative luminance (WCAG) of an sRGB byte triple. */
|
|
259
|
+
function luminance(r: number, g: number, b: number): number {
|
|
260
|
+
const c = (v: number) => {
|
|
261
|
+
const s = v / 255;
|
|
262
|
+
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
263
|
+
};
|
|
264
|
+
return 0.2126 * c(r) + 0.7152 * c(g) + 0.0722 * c(b);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const median = (xs: number[]) => {
|
|
268
|
+
if (xs.length === 0) return 0;
|
|
269
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
270
|
+
return s[Math.floor(s.length / 2)]!;
|
|
271
|
+
};
|
|
272
|
+
const pct = (xs: number[], p: number) => {
|
|
273
|
+
if (xs.length === 0) return 0;
|
|
274
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
275
|
+
return s[Math.min(s.length - 1, Math.floor(p * s.length))]!;
|
|
276
|
+
};
|
|
277
|
+
const r2 = (x: number) => Math.round(x * 100) / 100;
|
|
278
|
+
/** 100 at or under `good`, 0 at or over `bad`, linear between. */
|
|
279
|
+
const band = (x: number, good: number, bad: number) => Math.round(100 * Math.max(0, Math.min(1, (bad - x) / (bad - good))));
|
|
280
|
+
|
|
281
|
+
interface TextBox {
|
|
282
|
+
id: string;
|
|
283
|
+
text: string;
|
|
284
|
+
contrast: number;
|
|
285
|
+
/** The tallest run of ink rows, px: a line's ascenders to its descenders. */
|
|
286
|
+
ink: number;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Kinds that show a picture, whose `text` is the picture's name (`heart`, `coin`), not words. */
|
|
290
|
+
const PICTURES = new Set(["icon", "image"]);
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Whether an element's text is read as text: not a picture (og-ui reports
|
|
294
|
+
* an icon's name as its text), not cut by a scroll container (a sliver of a
|
|
295
|
+
* line is no measure of it) and not a disabled control or inside one (WCAG
|
|
296
|
+
* exempts inactive controls from contrast). The dump names each element's
|
|
297
|
+
* `kind`, `clipped` and `disabled`.
|
|
298
|
+
*/
|
|
299
|
+
function readsAsText(e: UiDump["elements"][number], byId: Map<string, UiDump["elements"][number]>): boolean {
|
|
300
|
+
if (PICTURES.has(e.kind ?? "") || e.clipped) return false;
|
|
301
|
+
for (let at: UiDump["elements"][number] | undefined = e, n = 0; at && n < 64; at = at.parent ? byId.get(at.parent) : undefined, n++) {
|
|
302
|
+
if (at.disabled) return false;
|
|
303
|
+
}
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The text elements of a frame: the contrast between ink and ground, and the
|
|
309
|
+
* height of the ink's lines. Inside the element's rectangle (inset from its
|
|
310
|
+
* rounded ends), the ground is the median luminance. The ink is measured
|
|
311
|
+
* over the text's own glyphs, not the rectangle: the glyph pixels are those
|
|
312
|
+
* at least half-way from the ground to the strongest ink (nearer the text's
|
|
313
|
+
* colour than the ground's), and the ink is the mean of the farthest
|
|
314
|
+
* twentieth of them, the glyph cores the eye reads (for a line of text
|
|
315
|
+
* that fills its rectangle, what its 1 % farthest pixels read); so a short word in a wide label
|
|
316
|
+
* (kestrel-stair FINDINGS #20: "You" on a 356 px row) reads what its
|
|
317
|
+
* letters show, not its antialiased edges diluted by the empty rest. A
|
|
318
|
+
* rectangle with no ink is not drawn (covered by a transition, faded out)
|
|
319
|
+
* and is left out.
|
|
320
|
+
*/
|
|
321
|
+
/**
|
|
322
|
+
* What a dialog's or a tutorial's scrim covers: the elements built before the
|
|
323
|
+
* topmost `modal` or `spotlight` layer and outside it, dimmed on purpose
|
|
324
|
+
* (what shows through a spotlight's `hole` is not).
|
|
325
|
+
*/
|
|
326
|
+
function behindScrim(ui: UiDump): Set<number> {
|
|
327
|
+
const out = new Set<number>();
|
|
328
|
+
const els = ui.elements;
|
|
329
|
+
let d = -1;
|
|
330
|
+
for (let i = 0; i < els.length; i++) if (els[i]!.kind === "modal" || els[i]!.kind === "spotlight") d = i;
|
|
331
|
+
if (d < 0) return out;
|
|
332
|
+
const byId = new Map(els.map((e) => [e.id, e]));
|
|
333
|
+
const inside = (e: UiDump["elements"][number]) => {
|
|
334
|
+
for (let at: UiDump["elements"][number] | undefined = e, n = 0; at && n < 64; at = at.parent ? byId.get(at.parent) : undefined, n++) if (at === els[d]) return true;
|
|
335
|
+
return false;
|
|
336
|
+
};
|
|
337
|
+
const holes = els.filter((e) => e.kind === "hole" && inside(e));
|
|
338
|
+
for (let i = 0; i < d; i++) {
|
|
339
|
+
const e = els[i]!;
|
|
340
|
+
const cx = e.x + e.width / 2, cy = e.y + e.height / 2;
|
|
341
|
+
if (!holes.some((h) => cx >= h.x && cx <= h.x + h.width && cy >= h.y && cy <= h.y + h.height)) out.add(i);
|
|
342
|
+
}
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* What is mid-transition in a shot: an element whose bounds (CSS px) are not
|
|
348
|
+
* the same in the pulses of the ticks before and after it, such as a toast
|
|
349
|
+
* sliding and fading out over a dialog or a panel popping in. Its pixels are
|
|
350
|
+
* a moment of a fade, not its colours. Matched by id and bounds (ids repeat:
|
|
351
|
+
* every toast holds a `Text`).
|
|
352
|
+
*/
|
|
353
|
+
function inMotion(ui: UiDump, before: Pulse | undefined, after: Pulse | undefined): Set<number> {
|
|
354
|
+
const out = new Set<number>();
|
|
355
|
+
const still = (p: Pulse | undefined, e: UiDump["elements"][number]) =>
|
|
356
|
+
!p || p.ui.some((u) => u[0] === e.id && Math.abs(u[1] - e.x) < 0.5 && Math.abs(u[2] - e.y) < 0.5 && Math.abs(u[3] - e.width) < 0.5 && Math.abs(u[4] - e.height) < 0.5);
|
|
357
|
+
for (const [n, e] of ui.elements.entries()) if (!still(before, e) || !still(after, e)) out.add(n);
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function textBoxes(img: Image, ui: UiDump, moving: Set<number> = new Set()): TextBox[] {
|
|
362
|
+
const out: TextBox[] = [];
|
|
363
|
+
const byId = new Map(ui.elements.map((e) => [e.id, e]));
|
|
364
|
+
const dimmed = behindScrim(ui);
|
|
365
|
+
for (const [n, e] of ui.elements.entries()) {
|
|
366
|
+
if (dimmed.has(n) || moving.has(n) || !/[\p{L}\p{N}]/u.test(e.text) || !readsAsText(e, byId) || e.width < 6 || e.height < 6) continue;
|
|
367
|
+
// A pill's rounded ends show what is behind it: inset a wide element's ends.
|
|
368
|
+
const ix = e.width > 3 * e.height ? Math.min(e.height / 2, 12) : 0, iy = e.height * 0.12;
|
|
369
|
+
const x0 = Math.max(0, Math.floor(e.x + ix)), y0 = Math.max(0, Math.floor(e.y + iy));
|
|
370
|
+
const x1 = Math.min(img.width, Math.ceil(e.x + e.width - ix)), y1 = Math.min(img.height, Math.ceil(e.y + e.height - iy));
|
|
371
|
+
if (x1 - x0 < 3 || y1 - y0 < 3) continue;
|
|
372
|
+
const w = x1 - x0;
|
|
373
|
+
const lum: number[] = [];
|
|
374
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
|
|
375
|
+
const i = (y * img.width + x) * 4;
|
|
376
|
+
lum.push(luminance(img.rgba[i]!, img.rgba[i + 1]!, img.rgba[i + 2]!));
|
|
377
|
+
}
|
|
378
|
+
const ground = median(lum);
|
|
379
|
+
const dev = lum.map((v) => Math.abs(v - ground));
|
|
380
|
+
// The strongest ink (a few pixels short of the very farthest, so one
|
|
381
|
+
// stray pixel does not set it), then the glyphs: every pixel at least
|
|
382
|
+
// half-way to it, on the side most of them are.
|
|
383
|
+
const peak = pct(dev, 0.998);
|
|
384
|
+
if (peak < 0.01) continue;
|
|
385
|
+
let up = 0, down = 0;
|
|
386
|
+
for (let k = 0; k < lum.length; k++) {
|
|
387
|
+
if (dev[k]! < peak / 2) continue;
|
|
388
|
+
if (lum[k]! > ground) up++;
|
|
389
|
+
else down++;
|
|
390
|
+
}
|
|
391
|
+
const light = up >= down;
|
|
392
|
+
const glyphs: number[] = [];
|
|
393
|
+
for (let k = 0; k < lum.length; k++) if (dev[k]! >= peak / 2 && (lum[k]! > ground) === light) glyphs.push(dev[k]!);
|
|
394
|
+
// Their cores: the farthest twentieth (what the 1 % farthest of a
|
|
395
|
+
// rectangle a line of text fills reads, the measure before glyphs).
|
|
396
|
+
const core = pct(glyphs, 0.95);
|
|
397
|
+
let sum = 0, cores = 0;
|
|
398
|
+
for (let k = 0; k < lum.length; k++) {
|
|
399
|
+
if (dev[k]! >= core && dev[k]! >= peak / 2 && (lum[k]! > ground) === light) {
|
|
400
|
+
sum += lum[k]!;
|
|
401
|
+
cores++;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const ink = sum / Math.max(1, cores);
|
|
405
|
+
// The ink's rows, as before for a text that fills its rectangle; a
|
|
406
|
+
// sparse one's from its glyphs.
|
|
407
|
+
const far = Math.max(pct(dev, 0.99), peak / 2);
|
|
408
|
+
const contrast = (Math.max(ink, ground) + 0.05) / (Math.min(ink, ground) + 0.05);
|
|
409
|
+
const rows: boolean[] = [];
|
|
410
|
+
for (let y = 0; y < y1 - y0; y++) {
|
|
411
|
+
let any = false;
|
|
412
|
+
for (let x = 0; x < w && !any; x++) any = dev[y * w + x]! >= far / 4 && (lum[y * w + x]! > ground) === light;
|
|
413
|
+
rows.push(any);
|
|
414
|
+
}
|
|
415
|
+
const runs: number[] = [];
|
|
416
|
+
let run = 0;
|
|
417
|
+
// A run of ink rows, bridging one-row gaps (thin strokes between a glyph's bars).
|
|
418
|
+
for (let y = 0; y <= rows.length; y++) {
|
|
419
|
+
if (rows[y] || (rows[y - 1] && rows[y + 1])) run++;
|
|
420
|
+
else if (run > 0) {
|
|
421
|
+
runs.push(run);
|
|
422
|
+
run = 0;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
out.push({ id: e.id, text: e.text, contrast: r2(contrast), ink: Math.max(0, ...runs.filter((h) => h >= 2)) });
|
|
426
|
+
}
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
interface Occupancy {
|
|
431
|
+
/** Leaf elements' union over the surface. */
|
|
432
|
+
share: number;
|
|
433
|
+
/** Of the central third (the middle ninth), how much leaves cover. */
|
|
434
|
+
centre: number;
|
|
435
|
+
/** A screen, not gameplay: a modal or dialogue layer, a large scroll list, or three or more text leaves in the central third. */
|
|
436
|
+
menu: boolean;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** og-ui's touch controls: the joystick, the d-pad and action buttons. */
|
|
440
|
+
const TOUCH_CONTROLS = new Set(["joystick", "dpad", "action"]);
|
|
441
|
+
|
|
442
|
+
function occupancy(ui: UiDump, size: { width: number; height: number }): Occupancy {
|
|
443
|
+
const parents = new Set(ui.elements.map((e) => e.parent));
|
|
444
|
+
// Touch controls are the player's thumbs, not information on the play
|
|
445
|
+
// surface (og-ui sizes them to at least 44 CSS px): they do not count.
|
|
446
|
+
const byId = new Map(ui.elements.map((e) => [e.id, e]));
|
|
447
|
+
const touch = (e: UiDump["elements"][number]) => {
|
|
448
|
+
for (let at: UiDump["elements"][number] | undefined = e, n = 0; at && n < 64; at = at.parent ? byId.get(at.parent) : undefined, n++) if (TOUCH_CONTROLS.has(at.kind ?? "")) return true;
|
|
449
|
+
return false;
|
|
450
|
+
};
|
|
451
|
+
const leaves = ui.elements.filter((e) => !parents.has(e.id) && !touch(e) && e.width * e.height < 0.9 * size.width * size.height && e.width > 0 && e.height > 0);
|
|
452
|
+
const cell = 8;
|
|
453
|
+
const cw = Math.ceil(size.width / cell), ch = Math.ceil(size.height / cell);
|
|
454
|
+
const grid = new Uint8Array(cw * ch);
|
|
455
|
+
for (const e of leaves) {
|
|
456
|
+
for (let y = Math.max(0, Math.floor(e.y / cell)); y < Math.min(ch, Math.ceil((e.y + e.height) / cell)); y++)
|
|
457
|
+
for (let x = Math.max(0, Math.floor(e.x / cell)); x < Math.min(cw, Math.ceil((e.x + e.width) / cell)); x++) grid[y * cw + x] = 1;
|
|
458
|
+
}
|
|
459
|
+
let all = 0, centre = 0, centreCells = 0;
|
|
460
|
+
for (let y = 0; y < ch; y++) for (let x = 0; x < cw; x++) {
|
|
461
|
+
const inCentre = x >= cw / 3 && x < (2 * cw) / 3 && y >= ch / 3 && y < (2 * ch) / 3;
|
|
462
|
+
if (inCentre) centreCells++;
|
|
463
|
+
if (grid[y * cw + x]) {
|
|
464
|
+
all++;
|
|
465
|
+
if (inCentre) centre++;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const inCentre = (e: UiDump["elements"][number]) => {
|
|
469
|
+
const cx = e.x + e.width / 2, cy = e.y + e.height / 2;
|
|
470
|
+
return cx > size.width / 3 && cx < (2 * size.width) / 3 && cy > size.height / 3 && cy < (2 * size.height) / 3;
|
|
471
|
+
};
|
|
472
|
+
// A screen, not a HUD: a dialog or loading layer, a conversation, a list
|
|
473
|
+
// that fills a quarter of the surface, or three texts in the centre.
|
|
474
|
+
const screen = ui.elements.some((e) => e.kind === "modal" || e.kind === "dialogue" || (e.kind === "scroll" && e.width * e.height >= 0.25 * size.width * size.height));
|
|
475
|
+
return { share: r2((100 * all) / (cw * ch)), centre: r2((100 * centre) / Math.max(1, centreCells)), menu: screen || leaves.filter((e) => e.text.trim() && inCentre(e)).length >= 3 };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Numeric leaves of the facts by path. */
|
|
479
|
+
function leaves(v: unknown, path = "", out = new Map<string, number>()): Map<string, number> {
|
|
480
|
+
if (typeof v === "number" && Number.isFinite(v)) out.set(path, v);
|
|
481
|
+
else if (Array.isArray(v)) v.forEach((x, i) => leaves(x, `${path}[${i}]`, out));
|
|
482
|
+
else if (v && typeof v === "object") for (const [k, x] of Object.entries(v)) leaves(x, path ? `${path}.${k}` : k, out);
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const HEALTH = /(^|[.\]])(hp|health|life|lives|shield)$/i;
|
|
487
|
+
const HITS = /(^|[.\]])(damage|damaged|hits|kills|crits?|dealt|taken)$/i;
|
|
488
|
+
|
|
489
|
+
interface Signals {
|
|
490
|
+
/** A named entity accelerated or changed scale. */
|
|
491
|
+
moved: boolean;
|
|
492
|
+
/** A named entity's flipbook changed clip. */
|
|
493
|
+
clip: boolean;
|
|
494
|
+
/** Anything moved at all, or a flipbook advanced a frame (activity, not an answer). */
|
|
495
|
+
moving: boolean;
|
|
496
|
+
ui: boolean;
|
|
497
|
+
uiMoved: boolean;
|
|
498
|
+
uiScreen: boolean;
|
|
499
|
+
sound: boolean;
|
|
500
|
+
sfx: boolean;
|
|
501
|
+
uiSound: boolean;
|
|
502
|
+
tween: boolean;
|
|
503
|
+
floating: boolean;
|
|
504
|
+
ghosts: boolean;
|
|
505
|
+
shake: boolean;
|
|
506
|
+
hitStop: boolean;
|
|
507
|
+
particles: boolean;
|
|
508
|
+
/** Anything a player would see or hear change. */
|
|
509
|
+
any: boolean;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function signals(s: Pulse[], i: number): Signals {
|
|
513
|
+
const a = s[i]!, p = s[i - 1], pp = s[i - 2];
|
|
514
|
+
const none: Signals = { moved: false, clip: false, moving: false, ui: false, uiMoved: false, uiScreen: false, sound: false, sfx: false, uiSound: false, tween: false, floating: false, ghosts: false, shake: false, hitStop: false, particles: false, any: false };
|
|
515
|
+
if (!p) return none;
|
|
516
|
+
const pos = (x: Pulse | undefined) => new Map((x?.named ?? []).map((n) => [n[0], n]));
|
|
517
|
+
const P = pos(p), PP = pos(pp);
|
|
518
|
+
let moved = false, clip = false, moving = false;
|
|
519
|
+
for (const n of a.named) {
|
|
520
|
+
const q = P.get(n[0]);
|
|
521
|
+
if (!q) continue;
|
|
522
|
+
const r = PP.get(n[0]) ?? q;
|
|
523
|
+
const accel = Math.hypot(n[1] - 2 * q[1] + r[1], n[2] - 2 * q[2] + r[2], n[3] - 2 * q[3] + r[3]);
|
|
524
|
+
if (accel > 1e-3 || Math.abs(n[4] - q[4]) > 1e-3 || Math.abs(n[5] - q[5]) > 1e-3) moved = true;
|
|
525
|
+
if (Math.floor(n[6] / 1000) !== Math.floor(q[6] / 1000)) clip = true;
|
|
526
|
+
if (Math.hypot(n[1] - q[1], n[2] - q[2], n[3] - q[3]) > 1e-4 || n[6] !== q[6]) moving = true;
|
|
527
|
+
}
|
|
528
|
+
const ids = (x: Pulse) => new Set(x.ui.map((e) => e[0]));
|
|
529
|
+
const A = ids(a), B = ids(p);
|
|
530
|
+
let changedIds = 0;
|
|
531
|
+
for (const id of A) if (!B.has(id)) changedIds++;
|
|
532
|
+
for (const id of B) if (!A.has(id)) changedIds++;
|
|
533
|
+
const prevUi = new Map(p.ui.map((e) => [e[0], e]));
|
|
534
|
+
let uiMoved = false, uiText = false;
|
|
535
|
+
for (const e of a.ui) {
|
|
536
|
+
const q = prevUi.get(e[0]);
|
|
537
|
+
if (!q) continue;
|
|
538
|
+
if (Math.abs(e[1] - q[1]) + Math.abs(e[2] - q[2]) + Math.abs(e[3] - q[3]) + Math.abs(e[4] - q[4]) > 0.05) uiMoved = true;
|
|
539
|
+
if (e[5] !== q[5]) uiText = true;
|
|
540
|
+
}
|
|
541
|
+
const prevVoices = new Set(p.voices.map((v) => v[0]));
|
|
542
|
+
const started = a.voices.filter((v) => !prevVoices.has(v[0]));
|
|
543
|
+
const prevParticles = new Set(p.particles);
|
|
544
|
+
const out: Signals = {
|
|
545
|
+
moved,
|
|
546
|
+
clip,
|
|
547
|
+
moving,
|
|
548
|
+
ui: changedIds > 0 || uiMoved || uiText,
|
|
549
|
+
uiMoved,
|
|
550
|
+
uiScreen: changedIds >= 3,
|
|
551
|
+
sound: started.length > 0,
|
|
552
|
+
sfx: started.some((v) => v[1] === BUS.sfx || v[1] === BUS.master),
|
|
553
|
+
uiSound: started.some((v) => v[1] === BUS.ui),
|
|
554
|
+
tween: a.tweens > p.tweens,
|
|
555
|
+
floating: a.spawned > p.spawned,
|
|
556
|
+
ghosts: a.ghosts > p.ghosts,
|
|
557
|
+
shake: a.trauma > p.trauma + 1e-4 || a.punch > p.punch + 1e-4 || a.kick > p.kick + 1e-4,
|
|
558
|
+
hitStop: a.hitStop > p.hitStop || (a.scale < 1 && p.scale >= 1),
|
|
559
|
+
particles: a.particles.some((id) => !prevParticles.has(id)),
|
|
560
|
+
any: false,
|
|
561
|
+
};
|
|
562
|
+
out.any = out.moved || out.clip || out.ui || out.sound || out.tween || out.floating || out.ghosts || out.shake || out.hitStop || out.particles;
|
|
563
|
+
return out;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** The WAV's level, clipping and silence, per tick of `rate`. */
|
|
567
|
+
function audioStats(path: string, rate: number) {
|
|
568
|
+
const bytes = readFileSync(path);
|
|
569
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
570
|
+
if (bytes.length < 44) return { seconds: 0, peak: 0, rms: 0, clipped: 0, silentShare: 100 };
|
|
571
|
+
const sampleRate = view.getUint32(24, true);
|
|
572
|
+
const channels = view.getUint16(22, true);
|
|
573
|
+
const n = (bytes.length - 44) >> 1;
|
|
574
|
+
const perTick = Math.max(1, Math.round((sampleRate / rate) * channels));
|
|
575
|
+
let peak = 0, sum = 0, clipped = 0, silent = 0, ticks = 0, acc = 0, k = 0;
|
|
576
|
+
for (let i = 0; i < n; i++) {
|
|
577
|
+
const x = view.getInt16(44 + i * 2, true) / 32767;
|
|
578
|
+
const a = Math.abs(x);
|
|
579
|
+
peak = Math.max(peak, a);
|
|
580
|
+
sum += x * x;
|
|
581
|
+
acc += x * x;
|
|
582
|
+
if (a >= 0.999) clipped++;
|
|
583
|
+
if (++k === perTick) {
|
|
584
|
+
if (Math.sqrt(acc / k) < 1e-4) silent++;
|
|
585
|
+
ticks++;
|
|
586
|
+
acc = 0;
|
|
587
|
+
k = 0;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return { seconds: r2(n / channels / sampleRate), peak: r2(peak), rms: Math.round(Math.sqrt(sum / Math.max(1, n)) * 1e4) / 1e4, clipped, silentShare: r2((100 * silent) / Math.max(1, ticks)) };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
interface Finding {
|
|
594
|
+
measure: string;
|
|
595
|
+
where: string;
|
|
596
|
+
detail: string;
|
|
597
|
+
fix: string;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
interface PlaytestMeasures {
|
|
601
|
+
playtest: string;
|
|
602
|
+
title: string;
|
|
603
|
+
tier: string;
|
|
604
|
+
ticks: number;
|
|
605
|
+
shots: Array<{ name: string; tick: number; frame: string | null; menu: boolean; hud: number; centre: number; texts: number; lowContrast: number; small: number }>;
|
|
606
|
+
contrast: { texts: number; passing: number; worst: Array<{ shot: string; id: string; text: string; ratio: number }> };
|
|
607
|
+
textSize: { texts: number; passing: number; smallest: Array<{ shot: string; id: string; text: string; ink: number }> };
|
|
608
|
+
hud: { gameplayShots: number; worstShare: number; worstCentre: number };
|
|
609
|
+
ui: { screenChanges: number; withMotion: number; presses: number; pressesWithSound: number; motionTicks: number };
|
|
610
|
+
latency: { inputs: number; answered: number; medianTicks: number; maxTicks: number; baselineShare: number; sound: number };
|
|
611
|
+
damage: { moments: number; hitStop: number; shake: number; floating: number; sound: number; flash: number; particles: number; facts: string[] };
|
|
612
|
+
idle: { share: number };
|
|
613
|
+
particles: { peakSystems: number; bursts: number };
|
|
614
|
+
/** `rendered` false: no renderer (tier `none`), so render and frame time were not measured. */
|
|
615
|
+
frame: { tier: string; rendered: boolean; stepMedianMs: number; stepP95Ms: number; renderMedianMs: number; renderP95Ms: number; frameP95Ms: number; frameMaxMs: number };
|
|
616
|
+
/** The screenshots taken without a renderer: frames unchecked (contrast, text size and HUD read frames). */
|
|
617
|
+
unchecked: number;
|
|
618
|
+
audio: ReturnType<typeof audioStats>;
|
|
619
|
+
warnings: string[];
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function measure(rec: RunRecord, frameDir: string, frames: number, relTo: string): { m: PlaytestMeasures; findings: Finding[] } {
|
|
623
|
+
const s = rec.samples;
|
|
624
|
+
const sig = s.map((_, i) => signals(s, i));
|
|
625
|
+
const tickIndex = new Map(s.map((p, i) => [p.tick, i]));
|
|
626
|
+
const findings: Finding[] = [];
|
|
627
|
+
|
|
628
|
+
// Frames: contrast, text size, HUD.
|
|
629
|
+
const shots: PlaytestMeasures["shots"] = [];
|
|
630
|
+
const allTexts: Array<TextBox & { shot: string }> = [];
|
|
631
|
+
let gameplayShots = 0, worstShare = 0, worstCentre = 0;
|
|
632
|
+
let written = 0;
|
|
633
|
+
for (const shot of rec.shots) {
|
|
634
|
+
if (!shot.file || !existsSync(shot.file)) continue;
|
|
635
|
+
const img = decodePng(new Uint8Array(readFileSync(shot.file)));
|
|
636
|
+
const ui: UiDump = shot.ui && existsSync(shot.ui) ? (JSON.parse(readFileSync(shot.ui, "utf8")) as UiDump) : { tick: shot.tick, elements: [] };
|
|
637
|
+
// The bounds are CSS pixels of the UI's surface; the frame is device pixels, maybe halved.
|
|
638
|
+
const scale = img.width / (ui.surface?.width ?? rec.size.width);
|
|
639
|
+
const scaled: UiDump = { ...ui, elements: ui.elements.map((e) => ({ ...e, x: e.x * scale, y: e.y * scale, width: e.width * scale, height: e.height * scale })) };
|
|
640
|
+
const at = tickIndex.get(shot.tick);
|
|
641
|
+
const moving = at === undefined ? new Set<number>() : inMotion(ui, s[at - 1], s[at + 1]);
|
|
642
|
+
const boxes = textBoxes(img, scaled, moving).map((b) => ({ ...b, ink: r2(b.ink / scale) }));
|
|
643
|
+
const occ = occupancy(ui, ui.surface ? { width: ui.surface.width, height: ui.surface.height } : rec.size);
|
|
644
|
+
let frame: string | null = null;
|
|
645
|
+
if (written < frames) {
|
|
646
|
+
mkdirSync(frameDir, { recursive: true });
|
|
647
|
+
const path = join(frameDir, `${rec.playtest}-${shot.name}.png`);
|
|
648
|
+
writeFileSync(path, encodePng(img.width > 800 ? half(img) : img));
|
|
649
|
+
frame = relative(relTo, path);
|
|
650
|
+
written++;
|
|
651
|
+
}
|
|
652
|
+
for (const b of boxes) allTexts.push({ ...b, shot: shot.name });
|
|
653
|
+
if (!occ.menu && ui.elements.length > 0) {
|
|
654
|
+
gameplayShots++;
|
|
655
|
+
worstShare = Math.max(worstShare, occ.share);
|
|
656
|
+
worstCentre = Math.max(worstCentre, occ.centre);
|
|
657
|
+
}
|
|
658
|
+
shots.push({ name: shot.name, tick: shot.tick, frame, menu: occ.menu, hud: occ.share, centre: occ.centre, texts: boxes.length, lowContrast: boxes.filter((b) => b.contrast < 4.5).length, small: boxes.filter((b) => b.ink > 0 && b.ink < 9).length });
|
|
659
|
+
}
|
|
660
|
+
const contrastPass = allTexts.filter((b) => b.contrast >= 4.5);
|
|
661
|
+
const worstContrast = [...allTexts].sort((a, b) => a.contrast - b.contrast).slice(0, 3).map((b) => ({ shot: b.shot, id: b.id, text: b.text.slice(0, 40), ratio: b.contrast }));
|
|
662
|
+
for (const w of worstContrast.filter((w) => w.ratio < 4.5)) findings.push({ measure: "contrast", where: `${rec.playtest} ${w.shot}: \`${w.id}\` "${w.text}"`, detail: `${w.ratio}:1 between ink and ground (body text wants 4.5:1)`, fix: "og-taste §3 (value before hue): darken the ground or lighten the ink in the skin's palette, never per call" });
|
|
663
|
+
const sized = allTexts.filter((b) => b.ink > 0);
|
|
664
|
+
// Ink lines of at least 9 px are a 13 px face (x-height and caps to descenders).
|
|
665
|
+
const sizePass = sized.filter((b) => b.ink >= 9);
|
|
666
|
+
const smallest = [...sized].sort((a, b) => a.ink - b.ink).slice(0, 3).map((b) => ({ shot: b.shot, id: b.id, text: b.text.slice(0, 40), ink: b.ink }));
|
|
667
|
+
for (const w of smallest.filter((w) => w.ink < 9)) findings.push({ measure: "text size", where: `${rec.playtest} ${w.shot}: \`${w.id}\` "${w.text}"`, detail: `ink lines ${w.ink} CSS px tall (a 13 px face's capitals and digits are about 9)`, fix: "og-taste §4 and §10.6: use the ramp's body step or larger for anything read on a phone" });
|
|
668
|
+
if (gameplayShots > 0 && (worstShare > 15 || worstCentre > 5))
|
|
669
|
+
findings.push({ measure: "HUD occupancy", where: `${rec.playtest}`, detail: `the HUD covers up to ${worstShare} % of the surface and ${worstCentre} % of the central third`, fix: "og-taste §2 (the play surface is the hero; HUD weight): capsules in the corners, the centre third free" });
|
|
670
|
+
|
|
671
|
+
// UI motion, transitions and sounds.
|
|
672
|
+
let screenChanges = 0, withMotion = 0, motionTicks = 0;
|
|
673
|
+
for (let i = 1; i < s.length; i++) {
|
|
674
|
+
if (sig[i]!.uiMoved) motionTicks++;
|
|
675
|
+
if (!sig[i]!.uiScreen || i < 3) continue;
|
|
676
|
+
screenChanges++;
|
|
677
|
+
let moving = 0;
|
|
678
|
+
for (let k = Math.max(1, i - 2); k < Math.min(s.length, i + 20); k++) if (sig[k]!.uiMoved || sig[k]!.tween) moving++;
|
|
679
|
+
if (moving >= 2) withMotion++;
|
|
680
|
+
}
|
|
681
|
+
let presses = 0, pressesWithSound = 0;
|
|
682
|
+
// Input-to-response latency.
|
|
683
|
+
const lat: number[] = [];
|
|
684
|
+
let soundAnswered = 0;
|
|
685
|
+
const quiet = new Array(s.length).fill(true) as boolean[];
|
|
686
|
+
const releases = rec.inputs.filter((x) => x.action === "release" && x.tick >= 0).map((x) => x.tick);
|
|
687
|
+
const inputs = rec.inputs.filter((x) => x.action !== "release");
|
|
688
|
+
for (const r of releases) {
|
|
689
|
+
const at = tickIndex.get(r + 1);
|
|
690
|
+
if (at !== undefined) for (let k = Math.max(0, at - 1); k < Math.min(s.length, at + 30); k++) quiet[k] = false;
|
|
691
|
+
}
|
|
692
|
+
for (const inp of inputs) {
|
|
693
|
+
const at = tickIndex.get(inp.tick + 1);
|
|
694
|
+
if (inp.tick < 0 || at === undefined) continue;
|
|
695
|
+
for (let k = Math.max(0, at - 1); k < Math.min(s.length, at + 30); k++) quiet[k] = false;
|
|
696
|
+
// A press's answer may wait for its release (a button activates on release): measure from the release then.
|
|
697
|
+
const touch = inp.action === "touch_start" || inp.action === "touch";
|
|
698
|
+
const release = touch ? releases.find((r) => r >= inp.tick) : undefined;
|
|
699
|
+
const releaseAt = release === undefined ? undefined : tickIndex.get(release + 1);
|
|
700
|
+
let first = -1, uiAt = -1, soundAt = -1, from = at;
|
|
701
|
+
for (let k = at; k < Math.min(s.length, at + 30); k++) {
|
|
702
|
+
if (first < 0 && sig[k]!.any) {
|
|
703
|
+
if (releaseAt !== undefined && k >= releaseAt && from === at) from = releaseAt;
|
|
704
|
+
first = k - from;
|
|
705
|
+
}
|
|
706
|
+
if (uiAt < 0 && sig[k]!.ui) uiAt = k - at;
|
|
707
|
+
if (soundAt < 0 && sig[k]!.sound) soundAt = k - from;
|
|
708
|
+
}
|
|
709
|
+
if (first >= 0) lat.push(first);
|
|
710
|
+
if (soundAt >= 0 && soundAt <= 6) soundAnswered++;
|
|
711
|
+
if (touch && uiAt >= 0 && uiAt <= 6 + (releaseAt === undefined ? 0 : releaseAt - at)) {
|
|
712
|
+
presses++;
|
|
713
|
+
let uiSound = false;
|
|
714
|
+
for (let k = at; k < Math.min(s.length, (releaseAt ?? at) + 8); k++) uiSound ||= sig[k]!.uiSound;
|
|
715
|
+
if (uiSound) pressesWithSound++;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
let baseQuiet = 0, baseActive = 0;
|
|
719
|
+
for (let i = 10; i < s.length; i++) if (quiet[i]) {
|
|
720
|
+
baseQuiet++;
|
|
721
|
+
if (sig[i]!.any) baseActive++;
|
|
722
|
+
}
|
|
723
|
+
const baselineShare = r2((100 * baseActive) / Math.max(1, baseQuiet));
|
|
724
|
+
const answered = lat.length;
|
|
725
|
+
if (inputs.length > 0) {
|
|
726
|
+
if (answered < inputs.length) findings.push({ measure: "input response", where: rec.playtest, detail: `${inputs.length - answered} of ${inputs.length} inputs changed nothing seen or heard within 30 ticks`, fix: "og-feel §1 (answer on the same tick) and og-taste §6: every press shows an answer, a refused one too" });
|
|
727
|
+
else if (median(lat) > 1) findings.push({ measure: "input latency", where: rec.playtest, detail: `the median input is answered ${median(lat)} ticks after the tick it landed (0 is the same tick)`, fix: "og-feel §1: read input and act in the same tick; buffer, do not queue" });
|
|
728
|
+
}
|
|
729
|
+
if (screenChanges > 0 && withMotion < screenChanges) findings.push({ measure: "screen transitions", where: rec.playtest, detail: `${screenChanges - withMotion} of ${screenChanges} screen changes had no motion in the 20 ticks around them`, fix: "og-taste §5 (screens change through a transition): og.ui.screenTransition, one kind per game" });
|
|
730
|
+
if (presses > 0 && pressesWithSound < presses) findings.push({ measure: "UI sounds", where: rec.playtest, detail: `${presses - pressesWithSound} of ${presses} UI presses played no sound on the ui bus`, fix: "og-taste §6 (feedback) and og-audio §3: give the skin or og.ui.configure its press and open sounds" });
|
|
731
|
+
|
|
732
|
+
// Damage facts and their feedback.
|
|
733
|
+
const moments: number[] = [];
|
|
734
|
+
const factKeys = new Set<string>();
|
|
735
|
+
for (let i = 1; i < rec.facts.length; i++) {
|
|
736
|
+
const a = leaves(rec.facts[i - 1]!.facts), b = leaves(rec.facts[i]!.facts);
|
|
737
|
+
let hit = false;
|
|
738
|
+
for (const [k, v] of b) {
|
|
739
|
+
const was = a.get(k);
|
|
740
|
+
if (was === undefined) continue;
|
|
741
|
+
if ((HEALTH.test(k) && v < was) || (HITS.test(k) && v > was)) {
|
|
742
|
+
hit = true;
|
|
743
|
+
factKeys.add(k.replace(/\[\d+\]/g, "[]"));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const tick = rec.facts[i]!.tick;
|
|
747
|
+
if (hit && (moments.length === 0 || tick - moments.at(-1)! > 3)) moments.push(tick);
|
|
748
|
+
}
|
|
749
|
+
const dmg = { moments: moments.length, hitStop: 0, shake: 0, floating: 0, sound: 0, flash: 0, particles: 0, facts: [...factKeys].slice(0, 6) };
|
|
750
|
+
for (const t of moments) {
|
|
751
|
+
const i = tickIndex.get(t) ?? -1;
|
|
752
|
+
if (i < 0) continue;
|
|
753
|
+
const w = sig.slice(Math.max(1, i - 3), Math.min(s.length, i + 9));
|
|
754
|
+
const held = s.slice(Math.max(0, i - 3), Math.min(s.length, i + 9)).some((p) => p.hitStop > 0 || p.scale < 1);
|
|
755
|
+
if (held || w.some((x) => x.hitStop)) dmg.hitStop++;
|
|
756
|
+
if (w.some((x) => x.shake)) dmg.shake++;
|
|
757
|
+
if (w.some((x) => x.floating)) dmg.floating++;
|
|
758
|
+
if (w.some((x) => x.sfx)) dmg.sound++;
|
|
759
|
+
if (w.some((x) => x.tween)) dmg.flash++;
|
|
760
|
+
if (w.some((x) => x.particles)) dmg.particles++;
|
|
761
|
+
}
|
|
762
|
+
if (dmg.moments > 0) {
|
|
763
|
+
const missing = [["hit stop", dmg.hitStop, "og-feel §3 (hit stop by impact weight)"], ["shake", dmg.shake, "og-feel §4 (trauma, not offsets)"], ["floating text", dmg.floating, "og-feel §7 (floating text)"], ["a sound", dmg.sound, "og-audio §1 (every event sounds; a hit's sound on its tick)"]] as const;
|
|
764
|
+
for (const [what, n, fix] of missing) if (n < dmg.moments) findings.push({ measure: "hit feedback", where: rec.playtest, detail: `${dmg.moments - n} of ${dmg.moments} damage moments (${dmg.facts.join(", ")}) had no ${what} within 8 ticks`, fix });
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Idle frames, particles, frame time.
|
|
768
|
+
let idle = 0;
|
|
769
|
+
for (let i = 10; i < s.length; i++) {
|
|
770
|
+
const p = s[i]!;
|
|
771
|
+
if (!sig[i]!.any && !sig[i]!.moving && p.tweens === 0 && p.floating === 0 && p.particles.length === 0 && p.voices.length === 0) idle++;
|
|
772
|
+
}
|
|
773
|
+
const idleShare = r2((100 * idle) / Math.max(1, s.length - 10));
|
|
774
|
+
if (idleShare > 25) findings.push({ measure: "idle frames", where: rec.playtest, detail: `${idleShare} % of ticks show and play nothing new (nothing moves or animates, no UI motion, no sound, no tween, text or particle)`, fix: "og-feel §9 (idle loops: breathing, a pickup's spin) and og-taste §5 (motion): something alive on every screen" });
|
|
775
|
+
let peakSystems = 0, bursts = 0;
|
|
776
|
+
for (let i = 0; i < s.length; i++) {
|
|
777
|
+
peakSystems = Math.max(peakSystems, s[i]!.particles.length);
|
|
778
|
+
if (sig[i]!.particles) bursts++;
|
|
779
|
+
}
|
|
780
|
+
const step = s.slice(5).map((p) => p.stepMs), render = s.slice(5).map((p) => p.renderMs), total = s.slice(5).map((p) => p.stepMs + p.renderMs);
|
|
781
|
+
const rendered = rec.tier !== "none";
|
|
782
|
+
const frame = { tier: rec.tier, rendered, stepMedianMs: r2(median(step)), stepP95Ms: r2(pct(step, 0.95)), renderMedianMs: r2(median(render)), renderP95Ms: r2(pct(render, 0.95)), frameP95Ms: r2(pct(total, 0.95)), frameMaxMs: r2(Math.max(0, ...total)) };
|
|
783
|
+
// Without a renderer there is no frame time to judge: the step alone is not a frame.
|
|
784
|
+
if (rendered && frame.frameP95Ms > 16.7) findings.push({ measure: "frame time", where: rec.playtest, detail: `p95 step + render ${frame.frameP95Ms} ms on ${rec.tier} (the frame is 16.7 ms)`, fix: "og-game §14 (performance): fewer draws and particles, cheaper systems; og run --json measures" });
|
|
785
|
+
const audio = audioStats(rec.wav, rec.rate);
|
|
786
|
+
if (audio.clipped > 0) findings.push({ measure: "audio clipping", where: rec.playtest, detail: `${audio.clipped} samples at full scale (peak ${audio.peak})`, fix: "og-audio §4 (the mix): lower the loud sources or put a limiter on master" });
|
|
787
|
+
|
|
788
|
+
return {
|
|
789
|
+
m: {
|
|
790
|
+
playtest: rec.playtest,
|
|
791
|
+
title: rec.title,
|
|
792
|
+
tier: rec.tier,
|
|
793
|
+
ticks: rec.ticks,
|
|
794
|
+
shots,
|
|
795
|
+
contrast: { texts: allTexts.length, passing: contrastPass.length, worst: worstContrast },
|
|
796
|
+
textSize: { texts: sized.length, passing: sizePass.length, smallest },
|
|
797
|
+
hud: { gameplayShots, worstShare, worstCentre },
|
|
798
|
+
ui: { screenChanges, withMotion, presses, pressesWithSound, motionTicks },
|
|
799
|
+
latency: { inputs: inputs.length, answered, medianTicks: median(lat), maxTicks: lat.length ? Math.max(...lat) : 0, baselineShare, sound: soundAnswered },
|
|
800
|
+
damage: dmg,
|
|
801
|
+
idle: { share: idleShare },
|
|
802
|
+
particles: { peakSystems, bursts },
|
|
803
|
+
frame,
|
|
804
|
+
unchecked: rendered ? 0 : rec.shots.length,
|
|
805
|
+
audio,
|
|
806
|
+
warnings: rec.warnings,
|
|
807
|
+
},
|
|
808
|
+
findings,
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ── scores and the report ──────────────────────────────────────────────────
|
|
813
|
+
|
|
814
|
+
interface Scores {
|
|
815
|
+
contrast: number | null;
|
|
816
|
+
textSize: number | null;
|
|
817
|
+
hud: number | null;
|
|
818
|
+
transitions: number | null;
|
|
819
|
+
uiSounds: number | null;
|
|
820
|
+
latency: number | null;
|
|
821
|
+
hitFeedback: number | null;
|
|
822
|
+
idle: number | null;
|
|
823
|
+
frameTime: number | null;
|
|
824
|
+
inputSound: number | null;
|
|
825
|
+
locale: number | null;
|
|
826
|
+
taste: number | null;
|
|
827
|
+
feel: number | null;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function scores(ms: PlaytestMeasures[], missingKeys: number, hasLocales: boolean): Scores {
|
|
831
|
+
const sum = (f: (m: PlaytestMeasures) => number) => ms.reduce((n, m) => n + f(m), 0);
|
|
832
|
+
const share = (num: number, den: number) => (den > 0 ? Math.round((100 * num) / den) : null);
|
|
833
|
+
const lat = ms.filter((m) => m.latency.inputs > 0);
|
|
834
|
+
const latency = lat.length === 0 ? null : Math.round(lat.reduce((n, m) => n + (m.latency.answered / m.latency.inputs) * (m.latency.medianTicks <= 1 ? 100 : m.latency.medianTicks <= 3 ? 70 : m.latency.medianTicks <= 6 ? 40 : 0), 0) / lat.length);
|
|
835
|
+
const dm = sum((m) => m.damage.moments);
|
|
836
|
+
const hitFeedback = dm === 0 ? null : Math.round((100 * sum((m) => Math.min(3, [m.damage.hitStop, m.damage.shake, m.damage.floating, m.damage.sound].filter((n) => n > 0).length) / 3 * m.damage.moments)) / dm);
|
|
837
|
+
const gameplay = ms.filter((m) => m.hud.gameplayShots > 0);
|
|
838
|
+
const hud = gameplay.length === 0 ? null : Math.min(...gameplay.map((m) => Math.min(band(m.hud.worstShare, 15, 45), band(m.hud.worstCentre, 5, 30))));
|
|
839
|
+
const timed = ms.filter((m) => m.frame.rendered);
|
|
840
|
+
const frameTime = timed.length === 0 ? null : Math.min(...timed.map((m) => band(m.frame.frameP95Ms, 8.3, 33.3)));
|
|
841
|
+
const idle = ms.length === 0 ? null : Math.round(ms.reduce((n, m) => n + band(m.idle.share, 10, 60), 0) / ms.length);
|
|
842
|
+
const s: Scores = {
|
|
843
|
+
contrast: share(sum((m) => m.contrast.passing), sum((m) => m.contrast.texts)),
|
|
844
|
+
textSize: share(sum((m) => m.textSize.passing), sum((m) => m.textSize.texts)),
|
|
845
|
+
hud,
|
|
846
|
+
transitions: share(sum((m) => m.ui.withMotion), sum((m) => m.ui.screenChanges)),
|
|
847
|
+
uiSounds: share(sum((m) => m.ui.pressesWithSound), sum((m) => m.ui.presses)),
|
|
848
|
+
latency,
|
|
849
|
+
hitFeedback,
|
|
850
|
+
idle,
|
|
851
|
+
frameTime,
|
|
852
|
+
inputSound: share(sum((m) => m.latency.sound), sum((m) => m.latency.inputs)),
|
|
853
|
+
locale: hasLocales ? (missingKeys === 0 ? 100 : band(missingKeys, 0, 20)) : null,
|
|
854
|
+
taste: null,
|
|
855
|
+
feel: null,
|
|
856
|
+
};
|
|
857
|
+
const mean = (xs: Array<number | null>) => {
|
|
858
|
+
const v = xs.filter((x): x is number => x !== null);
|
|
859
|
+
return v.length ? Math.round(v.reduce((a, b) => a + b, 0) / v.length) : null;
|
|
860
|
+
};
|
|
861
|
+
s.taste = mean([s.contrast, s.textSize, s.hud, s.transitions, s.uiSounds, s.locale]);
|
|
862
|
+
s.feel = mean([s.latency, s.hitFeedback, s.idle, s.frameTime, s.inputSound]);
|
|
863
|
+
return s;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const show = (x: number | null) => (x === null ? "n/a" : String(x));
|
|
867
|
+
|
|
868
|
+
function markdown(project: string, ms: PlaytestMeasures[], sc: Scores, findings: Finding[], warnings: string[], date: string): string {
|
|
869
|
+
const L: string[] = [];
|
|
870
|
+
L.push(`# og polish: ${project}`, "");
|
|
871
|
+
L.push(`${date}, ${ms.length} playtest(s) on ${[...new Set(ms.map((m) => m.tier))].join(", ")}. Mechanical measures of og-taste and og-feel (og-build §5); a score is 0 to 100, n/a when nothing in the playtests exercises it. The measures cannot judge style: read the frames.`, "");
|
|
872
|
+
const unchecked = ms.reduce((n, m) => n + m.unchecked, 0);
|
|
873
|
+
const blind = ms.filter((m) => !m.frame.rendered).length;
|
|
874
|
+
if (blind > 0) L.push(`No renderer for ${blind} playtest(s): ${unchecked} frame(s) unchecked, so contrast, text size, HUD occupancy and frame time are n/a there; run og polish where there is a renderer (the author's machine, an executor with a GPU) to measure them.`, "");
|
|
875
|
+
L.push("## Scores", "");
|
|
876
|
+
L.push("| | Score | Measure |", "|---|---|---|");
|
|
877
|
+
L.push(`| **Taste** | **${show(sc.taste)}** | mean of the taste rows |`);
|
|
878
|
+
L.push(`| Contrast | ${show(sc.contrast)} | text boxes whose ink and ground reach 4.5:1 |`);
|
|
879
|
+
L.push(`| Text size | ${show(sc.textSize)} | text boxes whose tallest ink line reaches 9 CSS px (a 13 px face's capitals) |`);
|
|
880
|
+
L.push(`| HUD occupancy | ${show(sc.hud)} | gameplay frames: HUD at most 15 % of the surface and 5 % of the central third |`);
|
|
881
|
+
L.push(`| Screen transitions | ${show(sc.transitions)} | screen changes with UI motion around them |`);
|
|
882
|
+
L.push(`| UI sounds | ${show(sc.uiSounds)} | UI presses answered on the ui bus |`);
|
|
883
|
+
L.push(`| Locale keys | ${show(sc.locale)} | string tables without missing keys (og cook) |`);
|
|
884
|
+
L.push(`| **Feel** | **${show(sc.feel)}** | mean of the feel rows |`);
|
|
885
|
+
L.push(`| Input latency | ${show(sc.latency)} | inputs answered, by the median ticks to the answer (0–1: 100) |`);
|
|
886
|
+
L.push(`| Hit feedback | ${show(sc.hitFeedback)} | damage moments with hit stop, shake, floating text or a sound (three of four: 100) |`);
|
|
887
|
+
L.push(`| Idle frames | ${show(sc.idle)} | ticks where nothing new is seen or heard (10 %: 100, 60 %: 0) |`);
|
|
888
|
+
L.push(`| Frame time | ${sc.frameTime === null && ms.length > 0 && ms.every((m) => !m.frame.rendered) ? "n/a (no renderer)" : show(sc.frameTime)} | p95 step + render (8.3 ms: 100, 33 ms: 0), worst playtest${ms.some((m) => !m.frame.rendered) ? "; no renderer: not measured" : ""} |`);
|
|
889
|
+
L.push(`| Sound on input | ${show(sc.inputSound)} | inputs answered by a sound within 6 ticks |`, "");
|
|
890
|
+
L.push("## Fixes", "");
|
|
891
|
+
if (findings.length === 0) L.push("No finding crossed a threshold.", "");
|
|
892
|
+
else {
|
|
893
|
+
L.push("| Measure | Where | Found | Fix |", "|---|---|---|---|");
|
|
894
|
+
for (const f of findings) L.push(`| ${f.measure} | ${f.where.replace(/\|/g, "\\|")} | ${f.detail.replace(/\|/g, "\\|")} | ${f.fix} |`);
|
|
895
|
+
L.push("");
|
|
896
|
+
}
|
|
897
|
+
if (warnings.length) {
|
|
898
|
+
L.push("Cook warnings:", "");
|
|
899
|
+
for (const w of warnings) L.push(`- ${w}`);
|
|
900
|
+
L.push("");
|
|
901
|
+
}
|
|
902
|
+
L.push("## Playtests", "");
|
|
903
|
+
for (const m of ms) {
|
|
904
|
+
L.push(`### ${m.playtest}`, "", `${m.title} (${m.ticks} ticks)`, "");
|
|
905
|
+
L.push("| Measure | Value |", "|---|---|");
|
|
906
|
+
L.push(`| Text boxes | ${m.contrast.texts}; ${m.contrast.passing} at 4.5:1; ${m.textSize.passing} of ${m.textSize.texts} at 9 px ink |`);
|
|
907
|
+
if (m.contrast.worst.length) L.push(`| Lowest contrast | ${m.contrast.worst.map((w) => `${w.ratio}:1 \`${w.id}\` (${w.shot})`).join("; ")} |`);
|
|
908
|
+
if (m.textSize.smallest.length) L.push(`| Smallest ink | ${m.textSize.smallest.map((w) => `${w.ink} px \`${w.id}\` (${w.shot})`).join("; ")} |`);
|
|
909
|
+
L.push(`| HUD | ${m.hud.gameplayShots} gameplay frame(s), up to ${m.hud.worstShare} % of the surface, ${m.hud.worstCentre} % of the centre |`);
|
|
910
|
+
L.push(`| UI | ${m.ui.screenChanges} screen change(s), ${m.ui.withMotion} with motion; ${m.ui.motionTicks} ticks of UI motion; ${m.ui.presses} press(es), ${m.ui.pressesWithSound} with a ui-bus sound |`);
|
|
911
|
+
L.push(`| Inputs | ${m.latency.inputs}, ${m.latency.answered} answered, median ${m.latency.medianTicks} ticks, max ${m.latency.maxTicks}; ${m.latency.sound} with a sound; ${m.latency.baselineShare} % of input-free ticks also change something |`);
|
|
912
|
+
L.push(`| Damage | ${m.damage.moments} moment(s)${m.damage.facts.length ? ` from ${m.damage.facts.map((f) => `\`${f}\``).join(", ")}` : ""}: hit stop ${m.damage.hitStop}, shake ${m.damage.shake}, floating text ${m.damage.floating}, sound ${m.damage.sound}, tween ${m.damage.flash}, particles ${m.damage.particles} |`);
|
|
913
|
+
L.push(`| Idle | ${m.idle.share} % of ticks |`);
|
|
914
|
+
L.push(`| Particles | peak ${m.particles.peakSystems} system(s), ${m.particles.bursts} burst(s) |`);
|
|
915
|
+
if (m.frame.rendered) L.push(`| Frame (${m.frame.tier}) | step ${m.frame.stepMedianMs} ms median, ${m.frame.stepP95Ms} p95; render ${m.frame.renderMedianMs} median, ${m.frame.renderP95Ms} p95; frame p95 ${m.frame.frameP95Ms}, max ${m.frame.frameMaxMs} |`);
|
|
916
|
+
else L.push(`| Frame (no renderer) | step ${m.frame.stepMedianMs} ms median, ${m.frame.stepP95Ms} p95; no renderer: render and frame time not measured; ${m.unchecked} frame(s) unchecked |`);
|
|
917
|
+
L.push(`| Audio | ${m.audio.seconds} s, peak ${m.audio.peak}, RMS ${m.audio.rms}, ${m.audio.clipped} clipped, ${m.audio.silentShare} % silent ticks |`, "");
|
|
918
|
+
const framed = m.shots.filter((s) => s.frame);
|
|
919
|
+
for (const s of framed) L.push(` `);
|
|
920
|
+
if (framed.length) L.push("", framed.map((s) => `${s.name} (tick ${s.tick}${s.menu ? ", menu" : ""}: HUD ${s.hud} %, ${s.texts} texts, ${s.lowContrast} under 4.5:1, ${s.small} small)`).join("; "), "");
|
|
921
|
+
}
|
|
922
|
+
return L.join("\n") + "\n";
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async function main(): Promise<void> {
|
|
926
|
+
const one = opt("--one");
|
|
927
|
+
const entry = opt("--entry");
|
|
928
|
+
const root = opt("--root");
|
|
929
|
+
const out = opt("--out");
|
|
930
|
+
if (!entry || !root || !out) fail("usage: bun src/node/polish.ts --entry <bundle> --root <project> --out <dir> [--warnings <file>] [--frames N] [--json] <tests/x.og.ts>...");
|
|
931
|
+
if (one) return runOne(resolve(one), resolve(entry!), resolve(root!), resolve(out!));
|
|
932
|
+
const files = positional.map((f) => resolve(f));
|
|
933
|
+
if (files.length === 0) fail(`${root} has no playtests: og polish measures what playtests play; add tests/*.og.ts (og-build §3)`);
|
|
934
|
+
const outDir = resolve(out!);
|
|
935
|
+
mkdirSync(outDir, { recursive: true });
|
|
936
|
+
const runs = join(outDir, ".runs");
|
|
937
|
+
const warnings: string[] = opt("--warnings") && existsSync(opt("--warnings")!) ? (JSON.parse(readFileSync(opt("--warnings")!, "utf8")) as string[]) : [];
|
|
938
|
+
// The report's frames: at most --frames in all, shared out over the playtests.
|
|
939
|
+
const budget = Math.max(0, Math.floor(Number(opt("--frames") ?? 8)));
|
|
940
|
+
let framesLeft = budget;
|
|
941
|
+
const ms: PlaytestMeasures[] = [];
|
|
942
|
+
const findings: Finding[] = [];
|
|
943
|
+
for (const f of files) {
|
|
944
|
+
const name = basename(f).replace(/\.og\.ts$/, "");
|
|
945
|
+
const dir = join(runs, name);
|
|
946
|
+
const t0 = performance.now();
|
|
947
|
+
const r = Bun.spawnSync(["bun", join(here, "polish.ts"), "--one", f, "--entry", resolve(entry!), "--root", resolve(root!), "--out", dir], { stdout: "pipe", stderr: "pipe" });
|
|
948
|
+
if (r.exitCode !== 0 || !existsSync(join(dir, "run.json"))) {
|
|
949
|
+
process.stderr.write(`og polish: ${name} did not run (exit ${r.exitCode}): ${new TextDecoder().decode(r.stderr).trim().split("\n").slice(-3).join(" ")}\n`);
|
|
950
|
+
findings.push({ measure: "run", where: name, detail: "the playtest did not run", fix: "og test shows why" });
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
const rec = JSON.parse(readFileSync(join(dir, "run.json"), "utf8")) as RunRecord;
|
|
954
|
+
const share = Math.min(framesLeft, Math.max(1, Math.floor(budget / files.length)));
|
|
955
|
+
const got = measure(rec, join(outDir, "frames"), share, outDir);
|
|
956
|
+
framesLeft -= got.m.shots.filter((x) => x.frame).length;
|
|
957
|
+
ms.push(got.m);
|
|
958
|
+
findings.push(...got.findings);
|
|
959
|
+
process.stdout.write(`og polish: ${name} (${rec.samples.length} ticks, ${(performance.now() - t0).toFixed(0)} ms)\n`);
|
|
960
|
+
}
|
|
961
|
+
const missing = warnings.filter((w) => /key '/.test(w)).length;
|
|
962
|
+
if (missing > 0) findings.push({ measure: "locale keys", where: "assets/strings", detail: `og cook warns about ${missing} key(s) a table lacks or formats differently`, fix: "og-i18n §1: every locale has every key, with the same arguments" });
|
|
963
|
+
const hasLocales = existsSync(join(root!, "assets", "strings")) || warnings.length > 0;
|
|
964
|
+
const sc = scores(ms, missing, hasLocales);
|
|
965
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
966
|
+
const project = basename(resolve(root!));
|
|
967
|
+
writeFileSync(join(outDir, "report.md"), markdown(project, ms, sc, findings, warnings, date));
|
|
968
|
+
writeFileSync(join(outDir, "polish.json"), JSON.stringify({ project, date, scores: sc, findings, playtests: ms, warnings }, null, 1) + "\n");
|
|
969
|
+
rmSync(runs, { recursive: true, force: true });
|
|
970
|
+
if (argv.includes("--json")) process.stdout.write(JSON.stringify({ project, scores: sc, findings: findings.length, report: join(outDir, "report.md") }) + "\n");
|
|
971
|
+
else process.stdout.write(`og polish: taste ${show(sc.taste)}, feel ${show(sc.feel)}; ${findings.length} finding(s); ${join(outDir, "report.md")}\n`);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
declare function atob(data: string): string;
|
|
975
|
+
|
|
976
|
+
await main();
|