hayao 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -24
- package/bin/create-hayao.mjs +380 -0
- package/bin/hayao-mcp-cli.mjs +11 -0
- package/dist/app/browser.d.ts +33 -2
- package/dist/app/game.d.ts +47 -2
- package/dist/app/tuning.d.ts +68 -0
- package/dist/art/palette.d.ts +41 -0
- package/dist/audio/adaptive.d.ts +58 -0
- package/dist/audio/album.d.ts +16 -0
- package/dist/audio/analysis.d.ts +59 -0
- package/dist/audio/audio.d.ts +21 -0
- package/dist/audio/chord.d.ts +17 -0
- package/dist/audio/genres.d.ts +11 -0
- package/dist/audio/lint.d.ts +29 -0
- package/dist/audio/match.d.ts +38 -0
- package/dist/audio/music.d.ts +88 -0
- package/dist/audio/pcm.d.ts +54 -0
- package/dist/audio/quality.d.ts +28 -0
- package/dist/audio/reverb.d.ts +15 -0
- package/dist/audio/synth.d.ts +56 -0
- package/dist/audio/theory.d.ts +64 -0
- package/dist/content/campaign.d.ts +69 -0
- package/dist/content/generate.d.ts +78 -0
- package/dist/content/level.d.ts +93 -0
- package/dist/content/worldgraph.d.ts +73 -0
- package/dist/core/dmath.d.ts +2 -0
- package/dist/hayao.global.js +15 -0
- package/dist/index.d.ts +30 -1
- package/dist/index.js +4293 -434
- package/dist/index.js.map +4 -4
- package/dist/index.min.js +15 -0
- package/dist/input/source.d.ts +52 -1
- package/dist/mcp.js +31225 -0
- package/dist/rasterize-worker-lite.mjs +13 -0
- package/dist/render/canvas.d.ts +6 -1
- package/dist/render/commands.d.ts +46 -0
- package/dist/render/paint.d.ts +33 -0
- package/dist/render/renderer.d.ts +22 -0
- package/dist/render/svg.d.ts +4 -1
- package/dist/render/svgString.d.ts +6 -2
- package/dist/scene/cameraController.d.ts +42 -0
- package/dist/scene/node.d.ts +17 -4
- package/dist/scene/nodes.d.ts +14 -0
- package/dist/scene/parallax.d.ts +15 -0
- package/dist/studio/mcpMain.d.ts +1 -0
- package/dist/studio/mcpServer.d.ts +2 -0
- package/dist/studio/record.d.ts +54 -0
- package/dist/studio/run.d.ts +78 -0
- package/dist/studio/session.d.ts +80 -0
- package/dist/studio/timeline.d.ts +35 -0
- package/dist/studio/vitePlugin.d.ts +6 -0
- package/dist/studio-plugin.js +228 -0
- package/dist/ui/overlay.d.ts +6 -0
- package/dist/verify/audioFilmstrip.d.ts +39 -0
- package/dist/verify/ethnography.d.ts +67 -0
- package/dist/verify/gates.d.ts +160 -0
- package/dist/verify/layout.d.ts +30 -3
- package/dist/verify/ramp.d.ts +40 -0
- package/dist/world.d.ts +38 -2
- package/dist-studio/assets/index-C7tty_Wo.js +109 -0
- package/dist-studio/assets/index-CM3tjRQo.css +1 -0
- package/dist-studio/index.html +18 -0
- package/docs/API.md +233 -9
- package/docs/CONVENTIONS.md +223 -0
- package/docs/EMBED.md +85 -0
- package/docs/QUICKSTART.md +129 -0
- package/docs/STUDIO.md +97 -0
- package/docs/VERIFICATION.md +226 -0
- package/package.json +39 -6
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// src/studio/vitePlugin.ts
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, statSync, watch, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, extname, join, normalize, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
function findPrebuiltStudio() {
|
|
7
|
+
for (const rel of ["../dist-studio", "../../dist-studio"]) {
|
|
8
|
+
try {
|
|
9
|
+
const dir = fileURLToPath(new URL(rel, import.meta.url));
|
|
10
|
+
if (existsSync(join(dir, "index.html"))) return dir;
|
|
11
|
+
} catch {
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
var MIME = {
|
|
17
|
+
".html": "text/html; charset=utf-8",
|
|
18
|
+
".js": "text/javascript",
|
|
19
|
+
".mjs": "text/javascript",
|
|
20
|
+
".css": "text/css",
|
|
21
|
+
".svg": "image/svg+xml",
|
|
22
|
+
".png": "image/png",
|
|
23
|
+
".json": "application/json",
|
|
24
|
+
".woff2": "font/woff2",
|
|
25
|
+
".wasm": "application/wasm"
|
|
26
|
+
};
|
|
27
|
+
function readBody(req) {
|
|
28
|
+
return new Promise((resolvePromise, reject) => {
|
|
29
|
+
const chunks = [];
|
|
30
|
+
req.on("data", (c) => chunks.push(c));
|
|
31
|
+
req.on("end", () => resolvePromise(Buffer.concat(chunks).toString("utf8")));
|
|
32
|
+
req.on("error", reject);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function json(res, status, body) {
|
|
36
|
+
res.statusCode = status;
|
|
37
|
+
res.setHeader("content-type", "application/json");
|
|
38
|
+
res.end(JSON.stringify(body));
|
|
39
|
+
}
|
|
40
|
+
var SAFE_ID = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
41
|
+
function hayaoStudio(opts = {}) {
|
|
42
|
+
let studioDir = "";
|
|
43
|
+
let projectRoot = "";
|
|
44
|
+
let buildRef = "unknown";
|
|
45
|
+
return {
|
|
46
|
+
name: "hayao-studio",
|
|
47
|
+
configResolved(config) {
|
|
48
|
+
projectRoot = config.root;
|
|
49
|
+
studioDir = resolve(projectRoot, opts.dir ?? ".studio");
|
|
50
|
+
mkdirSync(join(studioDir, "sessions"), { recursive: true });
|
|
51
|
+
mkdirSync(join(studioDir, "shots"), { recursive: true });
|
|
52
|
+
try {
|
|
53
|
+
buildRef = execSync("git rev-parse --short HEAD", { cwd: projectRoot, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
54
|
+
} catch {
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
configureServer(server) {
|
|
58
|
+
const titleToModule = /* @__PURE__ */ new Map();
|
|
59
|
+
async function loadGameByTitle(title) {
|
|
60
|
+
const engine = await server.ssrLoadModule("@hayao");
|
|
61
|
+
const candidates = [];
|
|
62
|
+
const cached = titleToModule.get(title);
|
|
63
|
+
if (cached) candidates.push(cached);
|
|
64
|
+
for (const [parent] of [["examples"], ["sandboxes"]]) {
|
|
65
|
+
const dir = resolve(projectRoot, parent);
|
|
66
|
+
if (!existsSync(dir)) continue;
|
|
67
|
+
for (const slug of readdirSync(dir)) {
|
|
68
|
+
for (const file of [`${parent}/${slug}/game.ts`, `${parent}/${slug}/${slug}.ts`]) {
|
|
69
|
+
if (existsSync(resolve(projectRoot, file))) candidates.push(`/${file}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (existsSync(resolve(projectRoot, "game.ts"))) candidates.push("/game.ts");
|
|
74
|
+
for (const path of candidates) {
|
|
75
|
+
try {
|
|
76
|
+
const mod = await server.ssrLoadModule(path);
|
|
77
|
+
for (const v of Object.values(mod)) {
|
|
78
|
+
if (v && typeof v === "object" && "build" in v && "title" in v && v.title === title) {
|
|
79
|
+
titleToModule.set(title, path);
|
|
80
|
+
return { def: v, engine };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
throw new Error(`no game titled "${title}" found`);
|
|
87
|
+
}
|
|
88
|
+
server.middlewares.use((req, res, next) => {
|
|
89
|
+
void (async () => {
|
|
90
|
+
const url = (req.url ?? "").split("?")[0];
|
|
91
|
+
if (req.method === "GET" && url.startsWith("/__studio/report/")) {
|
|
92
|
+
const id = decodeURIComponent(url.slice("/__studio/report/".length));
|
|
93
|
+
if (!SAFE_ID.test(id)) return json(res, 400, { error: "bad session id" });
|
|
94
|
+
const sessionPath = join(studioDir, "sessions", `${id}.json`);
|
|
95
|
+
if (!existsSync(sessionPath)) return json(res, 404, { error: "no such session" });
|
|
96
|
+
const session = JSON.parse(readFileSync(sessionPath, "utf8"));
|
|
97
|
+
const reportsDir = join(studioDir, "reports");
|
|
98
|
+
const cachePath = join(reportsDir, `${id}.json`);
|
|
99
|
+
if (existsSync(cachePath)) {
|
|
100
|
+
const cachedReport = JSON.parse(readFileSync(cachePath, "utf8"));
|
|
101
|
+
if (cachedReport.buildRef === session.buildRef) return json(res, 200, cachedReport);
|
|
102
|
+
}
|
|
103
|
+
const { def, engine } = await loadGameByTitle(session.game);
|
|
104
|
+
const report = engine.analyzePlaytest(def, session);
|
|
105
|
+
mkdirSync(reportsDir, { recursive: true });
|
|
106
|
+
writeFileSync(cachePath, JSON.stringify(report, null, 1));
|
|
107
|
+
return json(res, 200, report);
|
|
108
|
+
}
|
|
109
|
+
if (req.method === "POST" && url === "/__studio/session") {
|
|
110
|
+
const session = JSON.parse(await readBody(req));
|
|
111
|
+
if (typeof session.id !== "string" || !SAFE_ID.test(session.id)) {
|
|
112
|
+
return json(res, 400, { error: "bad session id" });
|
|
113
|
+
}
|
|
114
|
+
writeFileSync(join(studioDir, "sessions", `${session.id}.json`), JSON.stringify(session, null, 1));
|
|
115
|
+
return json(res, 200, { ok: true });
|
|
116
|
+
}
|
|
117
|
+
if (req.method === "POST" && url === "/__shot") {
|
|
118
|
+
const { path, svg } = JSON.parse(await readBody(req));
|
|
119
|
+
if (typeof svg !== "string") return json(res, 400, { error: "missing svg" });
|
|
120
|
+
const rel = normalize(path ?? `shot-${buildRef}.svg`).replace(/^([/\\])+/, "");
|
|
121
|
+
if (rel.split(/[/\\]/).includes("..")) return json(res, 400, { error: "bad path" });
|
|
122
|
+
const target = rel.startsWith("shots/") || rel.startsWith(".studio/") ? resolve(projectRoot, rel) : join(studioDir, "shots", rel);
|
|
123
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
124
|
+
writeFileSync(target, svg);
|
|
125
|
+
return json(res, 200, { ok: true, path: target });
|
|
126
|
+
}
|
|
127
|
+
if (req.method === "POST" && url === "/__studio/knobs") {
|
|
128
|
+
const body = await readBody(req);
|
|
129
|
+
JSON.parse(body);
|
|
130
|
+
writeFileSync(join(studioDir, "knobs.json"), body);
|
|
131
|
+
return json(res, 200, { ok: true });
|
|
132
|
+
}
|
|
133
|
+
if (req.method === "GET" && url === "/__studio/games") {
|
|
134
|
+
const games = [];
|
|
135
|
+
for (const [parent, kind, prefix] of [
|
|
136
|
+
["examples", "example", "/examples/"],
|
|
137
|
+
["sandboxes", "gym", "/sandboxes/"]
|
|
138
|
+
]) {
|
|
139
|
+
const dir = resolve(projectRoot, parent);
|
|
140
|
+
if (!existsSync(dir)) continue;
|
|
141
|
+
for (const slug of readdirSync(dir)) {
|
|
142
|
+
if (existsSync(join(dir, slug, "index.html"))) games.push({ slug, kind, url: `${prefix}${slug}/` });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (games.length === 0 && existsSync(join(projectRoot, "index.html")) && existsSync(join(projectRoot, "game.ts"))) {
|
|
146
|
+
games.push({ slug: projectRoot.split("/").pop() ?? "game", kind: "project", url: "/" });
|
|
147
|
+
}
|
|
148
|
+
return json(res, 200, games);
|
|
149
|
+
}
|
|
150
|
+
if (req.method === "GET" && (url === "/studio" || url.startsWith("/studio/")) && !existsSync(join(projectRoot, "studio", "index.html"))) {
|
|
151
|
+
const prebuilt = findPrebuiltStudio();
|
|
152
|
+
if (!prebuilt) return json(res, 404, { error: "no prebuilt Studio UI in this hayao build" });
|
|
153
|
+
const rel = normalize(decodeURIComponent(url.replace(/^\/studio\/?/, "")));
|
|
154
|
+
if (rel.split(/[/\\]/).includes("..")) return json(res, 400, { error: "bad path" });
|
|
155
|
+
let file = join(prebuilt, rel);
|
|
156
|
+
if (!existsSync(file) || statSync(file).isDirectory()) file = join(prebuilt, "index.html");
|
|
157
|
+
res.statusCode = 200;
|
|
158
|
+
res.setHeader("content-type", MIME[extname(file)] ?? "text/html; charset=utf-8");
|
|
159
|
+
createReadStream(file).pipe(res);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (req.method === "GET" && url.startsWith("/__studio/variants/")) {
|
|
163
|
+
const rel = normalize(decodeURIComponent(url.slice("/__studio/variants/".length)));
|
|
164
|
+
if (rel.split(/[/\\]/).includes("..")) return json(res, 400, { error: "bad path" });
|
|
165
|
+
let file = join(studioDir, "variants", rel);
|
|
166
|
+
if (existsSync(file) && statSync(file).isDirectory()) file = join(file, "index.html");
|
|
167
|
+
if (!existsSync(file)) return json(res, 404, { error: "no such variant file" });
|
|
168
|
+
res.statusCode = 200;
|
|
169
|
+
res.setHeader("content-type", MIME[extname(file)] ?? "application/octet-stream");
|
|
170
|
+
createReadStream(file).pipe(res);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (req.method === "GET" && url.startsWith("/__studio/session/")) {
|
|
174
|
+
const id = decodeURIComponent(url.slice("/__studio/session/".length));
|
|
175
|
+
if (!SAFE_ID.test(id)) return json(res, 400, { error: "bad session id" });
|
|
176
|
+
const file = join(studioDir, "sessions", `${id}.json`);
|
|
177
|
+
if (!existsSync(file)) return json(res, 404, { error: "no such session" });
|
|
178
|
+
res.statusCode = 200;
|
|
179
|
+
res.setHeader("content-type", "application/json");
|
|
180
|
+
createReadStream(file).pipe(res);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (req.method === "GET" && url === "/__studio/state") {
|
|
184
|
+
const knobsPath = join(studioDir, "knobs.json");
|
|
185
|
+
const knobs = existsSync(knobsPath) ? JSON.parse(readFileSync(knobsPath, "utf8")) : null;
|
|
186
|
+
const variantsPath = join(studioDir, "variants.json");
|
|
187
|
+
const variants = existsSync(variantsPath) ? JSON.parse(readFileSync(variantsPath, "utf8")) : {};
|
|
188
|
+
const sessions = readdirSync(join(studioDir, "sessions")).filter((f) => f.endsWith(".json")).map((f) => {
|
|
189
|
+
try {
|
|
190
|
+
const s = JSON.parse(readFileSync(join(studioDir, "sessions", f), "utf8"));
|
|
191
|
+
return {
|
|
192
|
+
id: s.id,
|
|
193
|
+
game: s.game,
|
|
194
|
+
startedAt: s.startedAt,
|
|
195
|
+
endReason: s.endReason,
|
|
196
|
+
frames: s.inputLog?.frames?.length ?? 0,
|
|
197
|
+
annotations: Array.isArray(s.annotations) ? s.annotations.length : 0,
|
|
198
|
+
variant: s.variant?.name ?? "dev"
|
|
199
|
+
};
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}).filter(Boolean);
|
|
204
|
+
const urls = server.resolvedUrls?.network ?? [];
|
|
205
|
+
return json(res, 200, { buildRef, knobs, variants, sessions, urls });
|
|
206
|
+
}
|
|
207
|
+
if (req.method === "GET" && url === "/__studio/events") {
|
|
208
|
+
res.statusCode = 200;
|
|
209
|
+
res.setHeader("content-type", "text/event-stream");
|
|
210
|
+
res.setHeader("cache-control", "no-cache");
|
|
211
|
+
res.write("retry: 2000\n\n");
|
|
212
|
+
const watcher = watch(studioDir, { recursive: true }, (_event, filename) => {
|
|
213
|
+
res.write(`data: ${JSON.stringify({ file: filename ?? "" })}
|
|
214
|
+
|
|
215
|
+
`);
|
|
216
|
+
});
|
|
217
|
+
req.on("close", () => watcher.close());
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
next();
|
|
221
|
+
})().catch((err) => json(res, 500, { error: String(err) }));
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
export {
|
|
227
|
+
hayaoStudio
|
|
228
|
+
};
|
package/dist/ui/overlay.d.ts
CHANGED
|
@@ -20,6 +20,12 @@ export interface ScreenHandle {
|
|
|
20
20
|
}
|
|
21
21
|
/** Set the element overlays mount into (defaults to document.body). */
|
|
22
22
|
export declare function setOverlayHost(el: HTMLElement): void;
|
|
23
|
+
/**
|
|
24
|
+
* Observe DOM screen chrome (menus/title/game-over) opening and closing —
|
|
25
|
+
* Studio's session recorder uses this to time menu dwell, which the sim can't
|
|
26
|
+
* see. Observer only; it must never mutate.
|
|
27
|
+
*/
|
|
28
|
+
export declare function setScreenObserver(cb: ((kind: 'show' | 'hide', title?: string) => void) | null): void;
|
|
23
29
|
/** Show a screen (replaces any current one). Returns a handle. */
|
|
24
30
|
export declare function showScreen(spec: ScreenSpec): ScreenHandle;
|
|
25
31
|
export declare function hideScreen(): void;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type StereoBuffer } from '../audio/pcm';
|
|
2
|
+
import { type AudioFeatures } from '../audio/analysis';
|
|
3
|
+
export interface AudioFilmstripOptions {
|
|
4
|
+
/** Overall SVG width in px (default 900). */
|
|
5
|
+
width?: number;
|
|
6
|
+
/** Time columns in the spectrogram (default 220). */
|
|
7
|
+
timeBins?: number;
|
|
8
|
+
/** Frequency bands in the spectrogram (default 64). */
|
|
9
|
+
freqBands?: number;
|
|
10
|
+
/** Title shown above the strip. */
|
|
11
|
+
title?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Render an audio filmstrip SVG for a stereo buffer: L/R waveforms, a
|
|
15
|
+
* spectrogram heatmap, and the feature readout. Deterministic and standalone
|
|
16
|
+
* (inline styles, no external refs) so it drops straight into t.artifact().
|
|
17
|
+
*/
|
|
18
|
+
export declare function renderAudioFilmstrip(buf: StereoBuffer, opts?: AudioFilmstripOptions): string;
|
|
19
|
+
export interface AudioExpectation {
|
|
20
|
+
minDurationSec?: number;
|
|
21
|
+
maxDurationSec?: number;
|
|
22
|
+
maxPeakDb?: number;
|
|
23
|
+
minRms?: number;
|
|
24
|
+
minCentroidHz?: number;
|
|
25
|
+
maxCentroidHz?: number;
|
|
26
|
+
tempoBpm?: number;
|
|
27
|
+
tempoToleranceBpm?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface AudioAssertion {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
failures: string[];
|
|
32
|
+
features: AudioFeatures;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Assert a rendered buffer's measured features against an expectation. This is
|
|
36
|
+
* the numeric half of the audio channel — what a verify.ts suite calls to prove
|
|
37
|
+
* "this track is a ~120bpm piece that isn't clipping and isn't silent".
|
|
38
|
+
*/
|
|
39
|
+
export declare function assertAudio(buf: StereoBuffer, exp: AudioExpectation): AudioAssertion;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { GameDefinition } from '../app/game';
|
|
2
|
+
import { type PlaytestSession } from '../studio/session';
|
|
3
|
+
export interface HesitationSpan {
|
|
4
|
+
startFrame: number;
|
|
5
|
+
frames: number;
|
|
6
|
+
/** Probe sampled at the span's first frame (what was on screen when input went quiet). */
|
|
7
|
+
probe: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
export interface DeathCluster {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
count: number;
|
|
13
|
+
frames: number[];
|
|
14
|
+
}
|
|
15
|
+
export interface FutileVerb {
|
|
16
|
+
action: string;
|
|
17
|
+
/** Rising-edge presses after which no probe field changed within the window. */
|
|
18
|
+
futilePresses: number;
|
|
19
|
+
totalPresses: number;
|
|
20
|
+
frames: number[];
|
|
21
|
+
}
|
|
22
|
+
export interface QuitContext {
|
|
23
|
+
frame: number;
|
|
24
|
+
probe: Record<string, unknown>;
|
|
25
|
+
/** Deaths / hesitation starts inside the final window before the quit. */
|
|
26
|
+
recentDeaths: number;
|
|
27
|
+
recentHesitations: number;
|
|
28
|
+
endReason: PlaytestSession['endReason'];
|
|
29
|
+
}
|
|
30
|
+
export interface AnnotationContext {
|
|
31
|
+
frame: number;
|
|
32
|
+
tag: string;
|
|
33
|
+
note?: string;
|
|
34
|
+
probe: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
export interface PlaytestReport {
|
|
37
|
+
sessionId: string;
|
|
38
|
+
game: string;
|
|
39
|
+
buildRef: string;
|
|
40
|
+
seed: number;
|
|
41
|
+
variant: PlaytestSession['variant'];
|
|
42
|
+
frames: number;
|
|
43
|
+
/** Sim seconds (frames × dt); wall time can be longer (pauses, hidden tab). */
|
|
44
|
+
simSeconds: number;
|
|
45
|
+
reachedGoal: boolean;
|
|
46
|
+
deaths: number;
|
|
47
|
+
hesitations: HesitationSpan[];
|
|
48
|
+
deathClusters: DeathCluster[];
|
|
49
|
+
futileVerbs: FutileVerb[];
|
|
50
|
+
/** Present unless the session ended at the goal. */
|
|
51
|
+
quit?: QuitContext;
|
|
52
|
+
annotations: AnnotationContext[];
|
|
53
|
+
knobEvents: PlaytestSession['knobEvents'];
|
|
54
|
+
/** Verbs the player NEVER used (declared in the input map but absent from the log). */
|
|
55
|
+
unusedActions: string[];
|
|
56
|
+
}
|
|
57
|
+
export interface AnalyzeOptions {
|
|
58
|
+
/** Min quiet frames to count as a hesitation (default 45 ≈ 0.75s @ 60fps). */
|
|
59
|
+
hesitationFrames?: number;
|
|
60
|
+
/** Frames after a press in which SOMETHING must change, else it was futile (default 30). */
|
|
61
|
+
futileWindow?: number;
|
|
62
|
+
/** Final-window size for quit context, in frames (default 300 ≈ 5s). */
|
|
63
|
+
quitWindow?: number;
|
|
64
|
+
/** Death-cluster bucket size in world px (default 64). */
|
|
65
|
+
clusterPx?: number;
|
|
66
|
+
}
|
|
67
|
+
export declare function analyzePlaytest(def: GameDefinition, session: PlaytestSession, opts?: AnalyzeOptions): PlaytestReport;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { DrawCommand } from '../render/commands';
|
|
2
|
+
import type { Vec2 } from '../core/math';
|
|
3
|
+
/** The forgiveness-relevant slice of any character config (PlatformerConfig satisfies it). */
|
|
4
|
+
export interface ForgivenessSpec {
|
|
5
|
+
/** Seconds of coyote time after leaving the ground. */
|
|
6
|
+
coyoteTime: number;
|
|
7
|
+
/** Seconds a jump press is buffered before landing. */
|
|
8
|
+
jumpBuffer: number;
|
|
9
|
+
/** Pixels the body is nudged sideways to slip past a ceiling corner (0 = none). */
|
|
10
|
+
jumpCornerNudge?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ForgivenessOptions {
|
|
13
|
+
/** Minimum coyote time in seconds (default 0.05 ≈ 3 frames @60Hz). */
|
|
14
|
+
minCoyote?: number;
|
|
15
|
+
/** Minimum jump-buffer in seconds (default 0.05). */
|
|
16
|
+
minBuffer?: number;
|
|
17
|
+
/** Require a non-zero jump corner nudge (default true). */
|
|
18
|
+
requireCornerNudge?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Static audit of a controller's grace windows. A platformer that ships without
|
|
22
|
+
* coyote time and input buffering feels like it drops inputs — the single most
|
|
23
|
+
* common reason an otherwise-correct platformer feels amateur.
|
|
24
|
+
*/
|
|
25
|
+
export declare function forgivenessIssues(spec: ForgivenessSpec, opts?: ForgivenessOptions): string[];
|
|
26
|
+
/**
|
|
27
|
+
* Behavioural grace-window prover. `accepts(delayFrames)` runs the sim with the
|
|
28
|
+
* grace-triggering input applied `delayFrames` after the enabling event and
|
|
29
|
+
* returns whether the action still took. A correct window accepts every delay in
|
|
30
|
+
* `[0, windowFrames]` and refuses `windowFrames + 1` — the exact edge FUN law 5
|
|
31
|
+
* demands. Use it for coyote time, jump buffering, i-frames, mercy windows.
|
|
32
|
+
*/
|
|
33
|
+
export declare function graceWindowIssues(label: string, windowFrames: number, accepts: (delayFrames: number) => boolean): string[];
|
|
34
|
+
export type FeedbackChannel = 'visual' | 'audio' | 'haptic';
|
|
35
|
+
/** What a game promises to fire for one event kind — audited statically. */
|
|
36
|
+
export interface FeedbackResponse {
|
|
37
|
+
/** Channels this event answers on (particles/flash = visual, sfx = audio, shake/rumble = haptic). */
|
|
38
|
+
channels: FeedbackChannel[];
|
|
39
|
+
/** Screen-shake trauma added (0..1), if any — bounded by the envelope. */
|
|
40
|
+
shake?: number;
|
|
41
|
+
/** Hit-stop / freeze frames injected, if any — bounded by the envelope. */
|
|
42
|
+
hitstopFrames?: number;
|
|
43
|
+
}
|
|
44
|
+
/** event kind → its feedback response. The game declares this next to its sim. */
|
|
45
|
+
export type FeedbackContract = Record<string, FeedbackResponse>;
|
|
46
|
+
export interface FeedbackOptions {
|
|
47
|
+
/** Minimum distinct channels per event (default 2). */
|
|
48
|
+
minChannels?: number;
|
|
49
|
+
/** Allowed screen-shake trauma range (default [0, 1]). */
|
|
50
|
+
shake?: [number, number];
|
|
51
|
+
/** Allowed hit-stop range in frames (default [0, 12] — beyond ~0.2s reads as a hitch). */
|
|
52
|
+
hitstop?: [number, number];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Audit a feedback contract: every required event exists, answers on ≥ minChannels
|
|
56
|
+
* distinct channels, and keeps its shake/hit-stop inside the envelope. This is the
|
|
57
|
+
* "does every hit/land/collect/death actually land on the senses?" gate.
|
|
58
|
+
*/
|
|
59
|
+
export declare function feedbackIssues(contract: FeedbackContract, requiredEvents: readonly string[], opts?: FeedbackOptions): string[];
|
|
60
|
+
/** Relative luminance (WCAG) of a #rrggbb / #rgb color, in [0,1]. */
|
|
61
|
+
export declare function relLuminance(hex: string): number;
|
|
62
|
+
/** WCAG contrast ratio between two colors, in [1, 21]. */
|
|
63
|
+
export declare function contrastRatio(a: string, b: string): number;
|
|
64
|
+
export interface SalienceOptions {
|
|
65
|
+
/** Minimum contrast the avatar must hold against the background (default 3). */
|
|
66
|
+
minBackgroundContrast?: number;
|
|
67
|
+
/**
|
|
68
|
+
* The avatar's fill must out-contrast the MEDIAN non-avatar fill against the
|
|
69
|
+
* background by at least this factor (default 1.15) — i.e. it visibly pops from
|
|
70
|
+
* the scenery rather than blending into it.
|
|
71
|
+
*/
|
|
72
|
+
minSalienceFactor?: number;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Readability gate: the player avatar must be the most salient thing on screen.
|
|
76
|
+
* The game passes the exact fill it painted the avatar with; the gate asserts that
|
|
77
|
+
* color out-contrasts both the background and the median scenery fill in the live
|
|
78
|
+
* display list. "Where's my guy?" is a fun-killer no correctness proof catches —
|
|
79
|
+
* this one does, from pure draw data.
|
|
80
|
+
*/
|
|
81
|
+
export declare function salienceIssues(commands: DrawCommand[], avatarFill: string, background: string, opts?: SalienceOptions): string[];
|
|
82
|
+
export interface TelegraphFrame {
|
|
83
|
+
/** True on frames where a threat is warning (flash/wind-up) before it can hurt you. */
|
|
84
|
+
telegraphing: boolean;
|
|
85
|
+
/** True on frames where the threat's hitbox is live (it can deal damage). */
|
|
86
|
+
active: boolean;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Telegraph gate: every activation of a threat must be preceded by at least
|
|
90
|
+
* `minFrames` of contiguous telegraph. Reactive play is only possible if danger
|
|
91
|
+
* announces itself; a hitbox that goes live with no wind-up is an unfair death.
|
|
92
|
+
* Feed a per-frame timeline (one entry per sim frame) for a single threat.
|
|
93
|
+
*/
|
|
94
|
+
export declare function telegraphIssues(timeline: readonly TelegraphFrame[], minFrames: number, label?: string): string[];
|
|
95
|
+
export interface CameraOptions {
|
|
96
|
+
/** Fixed timestep between samples in seconds (default 1/60). */
|
|
97
|
+
dt?: number;
|
|
98
|
+
/** Max camera speed in px/s before it reads as a snap (default 1800). */
|
|
99
|
+
maxSpeed?: number;
|
|
100
|
+
/** Max camera acceleration in px/s² before the motion reads as jerky (default 90000). */
|
|
101
|
+
maxAccel?: number;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Camera lawfulness gate over a sampled position series (one Vec2 per frame). A
|
|
105
|
+
* good follow camera never teleports and never jerks: bounded velocity (no snap)
|
|
106
|
+
* and bounded acceleration (smooth, not stuttery). You cannot systematize when a
|
|
107
|
+
* director SHOULD break these for drama — but an unbidden snap is always a bug.
|
|
108
|
+
*/
|
|
109
|
+
export declare function cameraIssues(samples: readonly Vec2[], opts?: CameraOptions): string[];
|
|
110
|
+
/**
|
|
111
|
+
* Look-ahead gate: on the axes where the target is moving, the camera should lead
|
|
112
|
+
* in the SAME direction (bias the view toward where you're going), never trail
|
|
113
|
+
* backwards. Compares net camera drift to net target drift across the series.
|
|
114
|
+
*/
|
|
115
|
+
export declare function lookAheadIssues(cameraSamples: readonly Vec2[], targetSamples: readonly Vec2[], minLead?: number): string[];
|
|
116
|
+
export interface FeelSpec {
|
|
117
|
+
/** The avatar's fill color — enables the salience gate. */
|
|
118
|
+
avatarFill?: string;
|
|
119
|
+
/** Background color (defaults to the game's `background`). */
|
|
120
|
+
background?: string;
|
|
121
|
+
/** Controller grace config — enables the forgiveness gate (PlatformerConfig fits). */
|
|
122
|
+
forgiveness?: ForgivenessSpec;
|
|
123
|
+
/** Declared feedback contract + the events that must be covered — enables the feedback gate. */
|
|
124
|
+
feedback?: {
|
|
125
|
+
contract: FeedbackContract;
|
|
126
|
+
events: readonly string[];
|
|
127
|
+
};
|
|
128
|
+
/** True for scrolling games — enables the camera gate (needs sampled positions). */
|
|
129
|
+
scrolls?: boolean;
|
|
130
|
+
}
|
|
131
|
+
/** Runtime inputs the audit/verify computes and hands to the aggregator. */
|
|
132
|
+
export interface FeelContext {
|
|
133
|
+
/** A rendered display list (drive a few frames, then `world.render()`). */
|
|
134
|
+
commands?: DrawCommand[];
|
|
135
|
+
/** Sampled camera-follow positions over a run (exclude the pre-start frame). */
|
|
136
|
+
camSamples?: readonly Vec2[];
|
|
137
|
+
/** Matching target positions, for the look-ahead check. */
|
|
138
|
+
targetSamples?: readonly Vec2[];
|
|
139
|
+
/** Fixed timestep for the camera gate (default 1/60). */
|
|
140
|
+
dt?: number;
|
|
141
|
+
/** Background fallback if the spec omits one. */
|
|
142
|
+
background?: string;
|
|
143
|
+
}
|
|
144
|
+
export interface FeelReport {
|
|
145
|
+
ok: boolean;
|
|
146
|
+
/** One entry per gate that actually ran (given the spec + context). */
|
|
147
|
+
sections: {
|
|
148
|
+
gate: string;
|
|
149
|
+
issues: string[];
|
|
150
|
+
}[];
|
|
151
|
+
/** Gates the spec asks for but that lacked the runtime context to run. */
|
|
152
|
+
skipped: string[];
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Run every feel gate the spec provides inputs for, returning a structured report.
|
|
156
|
+
* Static gates (forgiveness, feedback) run from the spec alone; salience needs a
|
|
157
|
+
* rendered display list; the camera gate needs sampled positions. Gates whose
|
|
158
|
+
* context is missing are reported as `skipped`, never silently dropped.
|
|
159
|
+
*/
|
|
160
|
+
export declare function runFeelGates(spec: FeelSpec, ctx?: FeelContext): FeelReport;
|
package/dist/verify/layout.d.ts
CHANGED
|
@@ -22,11 +22,38 @@ export interface LayoutOptions {
|
|
|
22
22
|
backgroundZ?: number;
|
|
23
23
|
/** Extra pixels of breathing room demanded around text (default 0 = touch is allowed, overlap is not). */
|
|
24
24
|
margin?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Lint transient view chrome too (floating popups, particles). Off by default:
|
|
27
|
+
* a "+10" drifting across a HUD label is motion, not a layout bug (see #26).
|
|
28
|
+
*/
|
|
29
|
+
includeTransient?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* The page background these commands paint on. When given, contrast lints run
|
|
32
|
+
* (see `minContrast` / `minTextContrast`): a shape or label that barely differs
|
|
33
|
+
* from what sits under it is invisible even though the sim hashes fine (#30).
|
|
34
|
+
*/
|
|
35
|
+
background?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Minimum contrast ratio a solid foreground shape must hold against the
|
|
38
|
+
* background to count as visible at all (default 1.5). Only checked when
|
|
39
|
+
* `background` is set.
|
|
40
|
+
*/
|
|
41
|
+
minContrast?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Minimum contrast ratio text must hold against its backing (panel fill, or the
|
|
44
|
+
* background) — the WCAG AA readability bar (default 4.5). Only when `background`
|
|
45
|
+
* is set. Pass 0 to skip the text-contrast check while keeping shape contrast.
|
|
46
|
+
*/
|
|
47
|
+
minTextContrast?: number;
|
|
25
48
|
}
|
|
26
49
|
/**
|
|
27
|
-
* Lint a display list for text readability. Returns human-readable
|
|
28
|
-
* (empty = clean). Checks:
|
|
29
|
-
* text-vs-
|
|
50
|
+
* Lint a display list for text readability and visibility. Returns human-readable
|
|
51
|
+
* issues (empty = clean). Checks:
|
|
52
|
+
* 1. text-vs-shape partial overlaps (a label half-under a shape),
|
|
53
|
+
* 2. text fully hidden behind an opaque higher-z shape (silent invisible labels, #31),
|
|
54
|
+
* 3. text-vs-text overlaps,
|
|
55
|
+
* 4. (opt-in via `background`) low-contrast shapes and text (#30).
|
|
56
|
+
* Transient commands (popups/particles) are skipped unless `includeTransient`.
|
|
30
57
|
*/
|
|
31
58
|
export declare function layoutIssues(commands: DrawCommand[], opts?: LayoutOptions): string[];
|
|
32
59
|
/** Friendly names a hint text may use to reference a key code. */
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface RampStats {
|
|
2
|
+
count: number;
|
|
3
|
+
min: number;
|
|
4
|
+
max: number;
|
|
5
|
+
mean: number;
|
|
6
|
+
/** Distinct difficulty values — a flat curve has 1. */
|
|
7
|
+
distinct: number;
|
|
8
|
+
/** Largest single step-to-step increase. */
|
|
9
|
+
maxJump: number;
|
|
10
|
+
/** Fraction of steps that rise or hold (0..1). */
|
|
11
|
+
forwardFraction: number;
|
|
12
|
+
}
|
|
13
|
+
/** Summarize a difficulty series — handy for logging the curve next to the gate. */
|
|
14
|
+
export declare function rampStats(difficulty: readonly number[]): RampStats;
|
|
15
|
+
export interface RampOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Largest allowed single-step increase, as a MULTIPLE of the average forward
|
|
18
|
+
* step. A step steeper than this reads as a difficulty cliff (default 3×).
|
|
19
|
+
*/
|
|
20
|
+
maxJumpFactor?: number;
|
|
21
|
+
/** Absolute floor for the jump envelope so tiny early steps don't over-constrain (default 2). */
|
|
22
|
+
minJumpAllowance?: number;
|
|
23
|
+
/** Minimum fraction of steps that must rise-or-hold — forward progress (default 0.6). */
|
|
24
|
+
minForwardFraction?: number;
|
|
25
|
+
/** Minimum distinct difficulty values — variety, not a flat line (default 3). */
|
|
26
|
+
minDistinct?: number;
|
|
27
|
+
/** Require the last level to be within this fraction of the peak (default 0.8 = finale ≥ 80% of max). */
|
|
28
|
+
finaleFraction?: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Audit a difficulty curve. `difficulty[i]` is the i-th level's difficulty proxy
|
|
32
|
+
* — typically the solver's solution depth, but any monotone measure works. Returns
|
|
33
|
+
* the specific curve sins found (empty array = a well-shaped ramp).
|
|
34
|
+
*/
|
|
35
|
+
export declare function rampIssues(difficulty: readonly number[], opts?: RampOptions): string[];
|
|
36
|
+
/**
|
|
37
|
+
* Assert a difficulty curve is well-shaped (throws with the first issue if not).
|
|
38
|
+
* The campaign-level analogue of `assertSolvable` — one call gates the ramp.
|
|
39
|
+
*/
|
|
40
|
+
export declare function assertRamp(difficulty: readonly number[], opts?: RampOptions): void;
|
package/dist/world.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { Rng } from './core/rng';
|
|
|
4
4
|
import { InputState } from './input/actions';
|
|
5
5
|
import { Node, type WorldContext } from './scene/node';
|
|
6
6
|
import type { Camera2D } from './scene/nodes';
|
|
7
|
-
import { type Transform } from './core/math';
|
|
7
|
+
import { type Transform, type Vec2 } from './core/math';
|
|
8
8
|
import type { DrawCommand } from './render/commands';
|
|
9
9
|
export interface WorldConfig {
|
|
10
10
|
seed?: number;
|
|
@@ -12,6 +12,8 @@ export interface WorldConfig {
|
|
|
12
12
|
/** Design-space dimensions (default 1280×720). */
|
|
13
13
|
width?: number;
|
|
14
14
|
height?: number;
|
|
15
|
+
/** Resolved tuning values (see app/tuning.ts). Sim state: hashed + snapshotted. */
|
|
16
|
+
tuning?: Record<string, number | string>;
|
|
15
17
|
}
|
|
16
18
|
/** Default engine event map; games extend it with their own keys. */
|
|
17
19
|
export interface CoreEvents {
|
|
@@ -37,7 +39,14 @@ export declare class World implements WorldContext {
|
|
|
37
39
|
private seed;
|
|
38
40
|
private freeQueue;
|
|
39
41
|
private started;
|
|
42
|
+
private tuningValues;
|
|
40
43
|
constructor(config?: WorldConfig);
|
|
44
|
+
/**
|
|
45
|
+
* Read a tuning value resolved at world creation (declared default unless
|
|
46
|
+
* overridden). Throws on an undeclared key — a typo here would otherwise
|
|
47
|
+
* silently read `undefined` into the sim.
|
|
48
|
+
*/
|
|
49
|
+
tune<T extends number | string = number>(key: string): T;
|
|
41
50
|
get time(): number;
|
|
42
51
|
get frame(): number;
|
|
43
52
|
/** Replace the scene root, entering the tree. */
|
|
@@ -49,11 +58,36 @@ export declare class World implements WorldContext {
|
|
|
49
58
|
* This is THE deterministic transition — call it from Node or the browser loop.
|
|
50
59
|
*/
|
|
51
60
|
step(actionsDown?: Iterable<string>): void;
|
|
52
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Feed REAL elapsed ms; runs 0+ fixed steps. Returns steps run. This is the
|
|
63
|
+
* realtime driver entry point — it CLAMPS realMs to the clock's maxFrameMs
|
|
64
|
+
* (default 250) to avoid a spiral of death, so `advance(1200)` runs ~15 steps,
|
|
65
|
+
* not 72. For headless tests/harnesses that want to fast-forward an exact
|
|
66
|
+
* number of steps, use `runSteps(n)` or `step()` — never `advance` with a big ms.
|
|
67
|
+
*/
|
|
53
68
|
advance(realMs: number, actionsDown?: Iterable<string>): number;
|
|
69
|
+
/**
|
|
70
|
+
* Run exactly `n` fixed steps — the deterministic fast-forward for tests and
|
|
71
|
+
* headless drivers (unlike `advance`, no realtime clamp). Pass `actionsFor(i)`
|
|
72
|
+
* to script the input per step; omit it to hold nothing down.
|
|
73
|
+
*/
|
|
74
|
+
runSteps(n: number, actionsFor?: (i: number) => Iterable<string>): void;
|
|
54
75
|
private flushFree;
|
|
55
76
|
/** The view transform (inverse of the active camera), mapping world → screen. */
|
|
56
77
|
viewTransform(): Transform;
|
|
78
|
+
/**
|
|
79
|
+
* Map a world-space point to screen/design space (the forward view transform).
|
|
80
|
+
* With no active camera this is the identity, so world == screen.
|
|
81
|
+
*/
|
|
82
|
+
worldToScreen(p: Vec2): Vec2;
|
|
83
|
+
/**
|
|
84
|
+
* Map a screen/design-space point back to world space — the inverse of the
|
|
85
|
+
* view transform. Use this to turn a pointer position (in design units) into
|
|
86
|
+
* a world coordinate under a scrolled/zoomed camera. Keep raw pointer values
|
|
87
|
+
* OUT of the sim: convert at the host edge and feed the result in as a
|
|
88
|
+
* quantized action/axis, or determinism breaks (see docs/CONVENTIONS.md).
|
|
89
|
+
*/
|
|
90
|
+
screenToWorld(p: Vec2): Vec2;
|
|
57
91
|
/** Project the whole scene to a display list (already camera-applied). */
|
|
58
92
|
render(): DrawCommand[];
|
|
59
93
|
/** Deterministic structural hash of the whole sim state. */
|
|
@@ -72,4 +106,6 @@ export interface WorldSnapshot {
|
|
|
72
106
|
input: ReturnType<InputState['getState']>;
|
|
73
107
|
state: Record<string, unknown>;
|
|
74
108
|
tree: ReturnType<Node['serialize']>;
|
|
109
|
+
/** Resolved tuning values (absent in pre-tuning saves). */
|
|
110
|
+
tuning?: Record<string, number | string>;
|
|
75
111
|
}
|