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
package/README.md
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/hellojanpacan/hayao-js/actions/workflows/ci.yml)
|
|
4
4
|
|
|
5
|
-
**Play the
|
|
5
|
+
**Play the machine-verified example games at [hayao.dev/play](https://hayao.dev/play/).**
|
|
6
|
+
Four are art-finished in the house woodblock style (Lanternway, Rootward,
|
|
7
|
+
Tarnholm, Driftlight); the rest are playable engine slices. See where it's headed
|
|
8
|
+
on the [roadmap](https://hayao.dev/roadmap/).
|
|
6
9
|
|
|
7
10
|
**An AI-first game engine.** A deterministic, headless-native simulation kernel
|
|
8
11
|
with a Godot-style scene tree, pluggable renderers (SVG / Canvas / headless),
|
|
@@ -41,7 +44,74 @@ npm run verify # run the determinism + solver harness over all examples
|
|
|
41
44
|
```
|
|
42
45
|
|
|
43
46
|
A game is one folder under `examples/<slug>/`, imports **only** from `@hayao`,
|
|
44
|
-
and is a `defineGame()` call. See [docs/CONVENTIONS.md](docs/CONVENTIONS.md).
|
|
47
|
+
and is a `defineGame()` call. See [docs/CONVENTIONS.md](https://github.com/hellojanpacan/hayao-js/blob/main/docs/CONVENTIONS.md).
|
|
48
|
+
|
|
49
|
+
## Start a new game in one command
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
npm create hayao@latest my-game # scaffolds a runnable project
|
|
53
|
+
cd my-game && npm install
|
|
54
|
+
npm run verify # prove every level winnable — no browser
|
|
55
|
+
npm run dev # play it
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The scaffolded starter isn't a hello-world — it already **generates a solver-proven,
|
|
59
|
+
ramped campaign**, so an AI author lands in a project that knows the invariants
|
|
60
|
+
(`AGENTS.md`) and can prove its own output from the first commit.
|
|
61
|
+
|
|
62
|
+
## Generate an hour of proven content, don't hand-author it
|
|
63
|
+
|
|
64
|
+
The hard part of "make me a whole game" was never one level — it was *forty* balanced
|
|
65
|
+
ones. hayao closes that gap: express a level as a `Puzzle` factory and let the
|
|
66
|
+
**solver-backed generator** compose the campaign.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { composeCampaign, assertRamp } from '@hayao';
|
|
70
|
+
|
|
71
|
+
const campaign = composeCampaign({
|
|
72
|
+
seed: 7,
|
|
73
|
+
acts: [
|
|
74
|
+
{ name: 'Shallows', count: 12, minDepth: 2, maxDepth: 4, factory: makeLevel },
|
|
75
|
+
{ name: 'Deep', count: 14, minDepth: 5, maxDepth: 7, factory: makeLevel },
|
|
76
|
+
{ name: 'Abyss', count: 16, minDepth: 8, maxDepth: 12, factory: makeLevel },
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
assertRamp(campaign.difficulty); // the whole curve is proven well-shaped
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Every level is `solve()`-proven winnable inside its difficulty band; `assertRamp`
|
|
83
|
+
proves the curve escalates without cliffs. An hour of content ships as a **list of
|
|
84
|
+
seeds**, each provable — not forty hand-drawn maps. The reference is
|
|
85
|
+
[examples/lanternfold](examples/lanternfold) — a 42-level lantern-lighting campaign
|
|
86
|
+
composed entirely this way, with `npm run eval` scoring proof coverage across the
|
|
87
|
+
whole portfolio.
|
|
88
|
+
|
|
89
|
+
## Close the one channel a machine can't: fun
|
|
90
|
+
|
|
91
|
+
Hayao proves winnability, determinism, ramp, and feel-floors by machine. The one
|
|
92
|
+
thing no headless gate can score is **fun** — and that takes a human. **Hayao
|
|
93
|
+
Studio** is the instrument for that channel: `npm run dev`, open `/studio/`, and
|
|
94
|
+
every human playtest becomes a bit-exactly replayable artifact the agent can read.
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
// main.ts — Studio-instrumented driver (drop-in for runBrowser)
|
|
98
|
+
import { runStudio } from 'hayao';
|
|
99
|
+
runStudio(game, document.getElementById('app')!, { hot: import.meta.hot });
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
// vite.config.ts — the dev-server harness (sessions, live knobs, A/B, /studio/)
|
|
104
|
+
import { hayaoStudio } from 'hayao/studio';
|
|
105
|
+
export default { plugins: [hayaoStudio()] };
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
A session `(seed, tuning, inputLog, axes, knobEvents)` re-simulates the whole run
|
|
109
|
+
in Node, so any metric is computable after the fact and any tick is re-inspectable.
|
|
110
|
+
The bundled `hayao-mcp` sidecar is the agent's window in — `list_sessions`,
|
|
111
|
+
`get_playtest_report` (hesitations, deaths, quit context), `inspect_moment`
|
|
112
|
+
(replay any tick → probe + screenshot), `get_knob_state` (values the human
|
|
113
|
+
accepted, to write back into `tuning:` defaults). Telemetry describes; the human
|
|
114
|
+
directs. Full doctrine in [docs/STUDIO.md](https://github.com/hellojanpacan/hayao-js/blob/main/docs/STUDIO.md).
|
|
45
115
|
|
|
46
116
|
## The layers
|
|
47
117
|
|
|
@@ -80,35 +150,54 @@ GUIs, binary scenes, and a bespoke language, all opaque to an LLM; hayao keeps
|
|
|
80
150
|
the model and makes every part **text, typed, greppable, and headlessly
|
|
81
151
|
verifiable**. Canvas engines couple the sim to the render loop and the browser;
|
|
82
152
|
hayao decouples them so the sim is a pure function you can test in Node. Full
|
|
83
|
-
reasoning in [docs/ENGINE.md](docs/ENGINE.md).
|
|
153
|
+
reasoning in [docs/ENGINE.md](https://github.com/hellojanpacan/hayao-js/blob/main/docs/ENGINE.md).
|
|
84
154
|
|
|
85
155
|
## Documentation
|
|
86
156
|
|
|
87
|
-
- [docs/
|
|
88
|
-
- [docs/
|
|
89
|
-
- [docs/
|
|
90
|
-
- [docs/
|
|
91
|
-
- [docs/
|
|
92
|
-
- [docs/
|
|
93
|
-
- [
|
|
157
|
+
- [docs/QUICKSTART.md](https://github.com/hellojanpacan/hayao-js/blob/main/docs/QUICKSTART.md) — **using `hayao` from npm**: install (it's ESM-only), define a game, and prove it correct headlessly — a complete runnable example.
|
|
158
|
+
- [docs/API.md](https://github.com/hellojanpacan/hayao-js/blob/main/docs/API.md) — the full, greppable public surface (every export + signature). Ships in the package.
|
|
159
|
+
- [docs/ARCHITECTURE.md](https://github.com/hellojanpacan/hayao-js/blob/main/docs/ARCHITECTURE.md) — the authoritative design and the determinism contract.
|
|
160
|
+
- [docs/CONVENTIONS.md](https://github.com/hellojanpacan/hayao-js/blob/main/docs/CONVENTIONS.md) — how games are structured, house style, definition of done.
|
|
161
|
+
- [docs/VERIFICATION.md](https://github.com/hellojanpacan/hayao-js/blob/main/docs/VERIFICATION.md) — the two verification channels; how to prove a game correct.
|
|
162
|
+
- [docs/STUDIO.md](https://github.com/hellojanpacan/hayao-js/blob/main/docs/STUDIO.md) — the human/AI playtest loop: sessions, live knobs, A/B variants, the MCP sidecar.
|
|
163
|
+
- [docs/GALLERY.md](https://github.com/hellojanpacan/hayao-js/blob/main/docs/GALLERY.md) — the verified gallery: what "machine-proven" means per game, and how to run the proof yourself.
|
|
164
|
+
- [docs/ENGINE.md](https://github.com/hellojanpacan/hayao-js/blob/main/docs/ENGINE.md) — why a custom engine, and when NOT to use one.
|
|
165
|
+
- [docs/LESSONS.md](https://github.com/hellojanpacan/hayao-js/blob/main/docs/LESSONS.md) — transferable lessons from real LLM-authored game builds.
|
|
166
|
+
- [docs/FRICTION.md](https://github.com/hellojanpacan/hayao-js/blob/main/docs/FRICTION.md) — process-lesson log: what fought an AI session and what check/doc now prevents it.
|
|
167
|
+
- [AGENTS.md](https://github.com/hellojanpacan/hayao-js/blob/main/AGENTS.md) — the operating manual for an AI author working in this repo.
|
|
168
|
+
|
|
169
|
+
## The site
|
|
170
|
+
|
|
171
|
+
Three doors, split by audience: [hayao.dev](https://hayao.dev/) is the marketing
|
|
172
|
+
landing (`index.html`), [hayao.dev/play](https://hayao.dev/play/) is the example
|
|
173
|
+
store (`play/index.html`), [hayao.dev/roadmap](https://hayao.dev/roadmap/) is the
|
|
174
|
+
public roadmap + js13k benchmark ladder (`roadmap/index.html`), and developer docs
|
|
175
|
+
live at [hayao.js.org](https://hayao.js.org/). Featured-game thumbnails are
|
|
176
|
+
regenerated with `npm run thumbs`.
|
|
94
177
|
|
|
95
178
|
## Status
|
|
96
179
|
|
|
97
|
-
**v0.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
180
|
+
**v0.3 — Hayao Studio: the human/AI playtest loop ships with the engine.**
|
|
181
|
+
The v0.2 campaign covered the most popular 2D indie genres (platformer,
|
|
182
|
+
metroidvania, Zelda-like, stealth, horde survival, bullet hell, tower defense,
|
|
183
|
+
RTS, roguelike, deckbuilder, tactics, match-3, idle, farming, horror, city
|
|
184
|
+
builder, rhythm, physics arcade, racing, narrative — plus the original Sokoban),
|
|
185
|
+
and grew from there: js13k-benchmark reproductions (Seamfold, Gravewell), a
|
|
186
|
+
deterministic rigid-body physics wave (Rookspire demolition, Brasswick pinball),
|
|
187
|
+
a netplay showcase (Fernclash — lockstep + rollback across tabs), a 2-player
|
|
188
|
+
co-op survival (Kinfall), and the flagship metroidvania (Kintsugi). 30+ example
|
|
189
|
+
games now live under `examples/`, each shipping a `verify.ts` suite that
|
|
106
190
|
machine-proves its truth: solver-proven puzzles, bot-beaten levels, duel-proven
|
|
107
|
-
counter systems, win-rate-tuned balance, fairness gates for procgen,
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
191
|
+
counter systems, win-rate-tuned balance, fairness gates for procgen, frame-exact
|
|
192
|
+
timing windows, bit-for-bit peer agreement, golden replay hashes.
|
|
193
|
+
|
|
194
|
+
v0.3 adds the one channel a machine can't close on its own — **fun** — as a
|
|
195
|
+
first-class, replayable instrument (Hayao Studio, above), plus a de-anchoring
|
|
196
|
+
`sandboxes/` shelf: single-mechanic labs (physics, particles, camera,
|
|
197
|
+
pathfinding, procgen, synth, juice) for learning one primitive in isolation.
|
|
198
|
+
740+ tests; `npm run verify` runs the whole portfolio. What each genre demanded
|
|
199
|
+
of the engine and what generalized lives in
|
|
200
|
+
[docs/BUILDLOG.md](https://github.com/hellojanpacan/hayao-js/blob/main/docs/BUILDLOG.md).
|
|
112
201
|
|
|
113
202
|
## License
|
|
114
203
|
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// create-hayao — scaffold a fresh, runnable hayao project in one command.
|
|
3
|
+
//
|
|
4
|
+
// npm create hayao@latest my-game
|
|
5
|
+
// # or: npx create-hayao my-game
|
|
6
|
+
//
|
|
7
|
+
// The point is AI-first distribution: the generated project is shaped so a coding
|
|
8
|
+
// agent lands in a repo that already knows the invariants (AGENTS.md), already has
|
|
9
|
+
// the verify gate wired (`npm run verify`), and ships a STARTER that demonstrates
|
|
10
|
+
// the headline capability — a solver-backed generator composing a proven, ramped
|
|
11
|
+
// campaign. An agent opening this folder can author a game and prove it correct
|
|
12
|
+
// without ever opening a browser. Zero network beyond `npm install`.
|
|
13
|
+
|
|
14
|
+
import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
|
+
|
|
17
|
+
const name = process.argv[2] || 'my-hayao-game';
|
|
18
|
+
const dir = resolve(process.cwd(), name);
|
|
19
|
+
const slug = name.replace(/[^a-z0-9-]/gi, '-').toLowerCase();
|
|
20
|
+
|
|
21
|
+
if (existsSync(dir)) {
|
|
22
|
+
console.error(`✗ ${dir} already exists — pick a new directory name.`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Files to write, relative to the new project root. */
|
|
27
|
+
const files = {
|
|
28
|
+
'package.json': JSON.stringify(
|
|
29
|
+
{
|
|
30
|
+
name: slug,
|
|
31
|
+
private: true,
|
|
32
|
+
type: 'module',
|
|
33
|
+
scripts: {
|
|
34
|
+
dev: 'vite',
|
|
35
|
+
check: 'tsc --noEmit',
|
|
36
|
+
test: 'vitest run',
|
|
37
|
+
verify: 'tsx verify.ts',
|
|
38
|
+
build: 'vite build',
|
|
39
|
+
},
|
|
40
|
+
dependencies: { hayao: '^0.3' },
|
|
41
|
+
devDependencies: {
|
|
42
|
+
'@types/node': '^22.10.0',
|
|
43
|
+
tsx: '^4.19.0',
|
|
44
|
+
typescript: '^5.7.0',
|
|
45
|
+
vite: '^6.0.0',
|
|
46
|
+
vitest: '^2.1.0',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
null,
|
|
50
|
+
2,
|
|
51
|
+
) + '\n',
|
|
52
|
+
|
|
53
|
+
'tsconfig.json': JSON.stringify(
|
|
54
|
+
{
|
|
55
|
+
compilerOptions: {
|
|
56
|
+
target: 'ES2022',
|
|
57
|
+
module: 'ESNext',
|
|
58
|
+
moduleResolution: 'bundler',
|
|
59
|
+
strict: true,
|
|
60
|
+
noUnusedLocals: true,
|
|
61
|
+
skipLibCheck: true,
|
|
62
|
+
types: ['vite/client', 'node'],
|
|
63
|
+
// Runtime resolution for tsx (verify.ts, the MCP sidecar loading game
|
|
64
|
+
// modules); TYPES come from hayao-env.d.ts's ambient declaration.
|
|
65
|
+
paths: { '@hayao': ['./node_modules/hayao/dist/index.js'] },
|
|
66
|
+
},
|
|
67
|
+
include: ['*.ts', '*.d.ts'],
|
|
68
|
+
},
|
|
69
|
+
null,
|
|
70
|
+
2,
|
|
71
|
+
) + '\n',
|
|
72
|
+
|
|
73
|
+
'vite.config.ts': `import { defineConfig } from 'vite';
|
|
74
|
+
import { hayaoStudio } from 'hayao/studio';
|
|
75
|
+
// Mirror the hayao convention: games import from the single '@hayao' seam.
|
|
76
|
+
// hayaoStudio() is the Studio dev harness: playtest sessions record to
|
|
77
|
+
// .studio/, live tuning knobs, A/B variants, and the /studio/ page — see
|
|
78
|
+
// docs/STUDIO.md in the hayao repo. Dev-only; production builds are untouched.
|
|
79
|
+
export default defineConfig({
|
|
80
|
+
resolve: { alias: { '@hayao': 'hayao' } },
|
|
81
|
+
plugins: [hayaoStudio()],
|
|
82
|
+
});
|
|
83
|
+
`,
|
|
84
|
+
|
|
85
|
+
'hayao-env.d.ts': `// Types for the '@hayao' seam: runtime resolution is the tsconfig paths entry
|
|
86
|
+
// (tsx) and the vite alias; this ambient declaration gives TS the package's
|
|
87
|
+
// real types for both.
|
|
88
|
+
declare module '@hayao' {
|
|
89
|
+
export * from 'hayao';
|
|
90
|
+
}
|
|
91
|
+
`,
|
|
92
|
+
|
|
93
|
+
'.mcp.json': JSON.stringify(
|
|
94
|
+
{ mcpServers: { 'hayao-studio': { command: 'npx', args: ['hayao-mcp'] } } },
|
|
95
|
+
null,
|
|
96
|
+
2,
|
|
97
|
+
) + '\n',
|
|
98
|
+
|
|
99
|
+
'index.html': `<!doctype html>
|
|
100
|
+
<html lang="en">
|
|
101
|
+
<head>
|
|
102
|
+
<meta charset="UTF-8" />
|
|
103
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
104
|
+
<title>${slug} · hayao</title>
|
|
105
|
+
<style>html,body{margin:0;height:100%;background:#12121a;font-family:Georgia,serif}#app{position:absolute;inset:0}</style>
|
|
106
|
+
</head>
|
|
107
|
+
<body>
|
|
108
|
+
<div id="app"></div>
|
|
109
|
+
<script type="module" src="./main.ts"></script>
|
|
110
|
+
</body>
|
|
111
|
+
</html>
|
|
112
|
+
`,
|
|
113
|
+
|
|
114
|
+
'logic.ts': `// Pure puzzle logic — no engine imports beyond the Puzzle type. This is the truth
|
|
115
|
+
// the solver proves and the scene tree merely views. A tiny "toggle" puzzle: tap a
|
|
116
|
+
// cell to flip it and its plus-neighbours; win when the whole grid is lit. Every
|
|
117
|
+
// tap is self-inverse, so any board reached by scrambling from solved is winnable —
|
|
118
|
+
// which makes it the perfect target for the solver-backed generator.
|
|
119
|
+
import type { Puzzle } from '@hayao';
|
|
120
|
+
import { Rng } from '@hayao';
|
|
121
|
+
|
|
122
|
+
export interface ToggleState { lit: number[]; cols: number; rows: number; }
|
|
123
|
+
|
|
124
|
+
export function flip(lit: readonly number[], i: number, cols: number, rows: number): number[] {
|
|
125
|
+
const out = lit.slice();
|
|
126
|
+
const x = i % cols, y = Math.floor(i / cols);
|
|
127
|
+
const set = (xx: number, yy: number) => { if (xx>=0&&yy>=0&&xx<cols&&yy<rows){ const j=yy*cols+xx; out[j]=out[j]?0:1; } };
|
|
128
|
+
set(x,y); set(x-1,y); set(x+1,y); set(x,y-1); set(x,y+1);
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function makeToggle(rng: Rng, cols = 4, rows = 4, scramble = 8): Puzzle<ToggleState, number> {
|
|
133
|
+
let lit = new Array<number>(cols*rows).fill(1);
|
|
134
|
+
for (let k=0;k<scramble;k++) lit = flip(lit, rng.int(cols*rows), cols, rows);
|
|
135
|
+
const start = lit.slice();
|
|
136
|
+
const all = Array.from({length: cols*rows}, (_,i)=>i);
|
|
137
|
+
return {
|
|
138
|
+
initial: () => ({ lit: start.slice(), cols, rows }),
|
|
139
|
+
moves: () => all,
|
|
140
|
+
apply: (s, i) => ({ lit: flip(s.lit, i, cols, rows), cols, rows }),
|
|
141
|
+
isWin: (s) => s.lit.every(v=>v===1),
|
|
142
|
+
key: (s) => s.lit.join(''),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
`,
|
|
146
|
+
|
|
147
|
+
'game.ts': `// The game: pure logic (logic.ts) → a scene-tree view. Content is generated and
|
|
148
|
+
// PROVEN by the solver-backed generator, then composed into a ramped campaign.
|
|
149
|
+
import { Node, Sprite, Text, KENTO, withAlpha, linearGradient, glow, registerNode, audio,
|
|
150
|
+
defineGame, composeCampaign, knob, type World } from '@hayao';
|
|
151
|
+
import { makeToggle, flip, type ToggleState } from './logic';
|
|
152
|
+
|
|
153
|
+
// Generate a small, proven, ramped campaign at load — every level solver-winnable.
|
|
154
|
+
const CAMPAIGN = composeCampaign<ToggleState, number>({
|
|
155
|
+
seed: 7,
|
|
156
|
+
acts: [
|
|
157
|
+
{ name: 'Warmup', count: 6, minDepth: 2, maxDepth: 3, factory: (r) => makeToggle(r, 3, 3, 5) },
|
|
158
|
+
{ name: 'Deeper', count: 8, minDepth: 4, maxDepth: 6, factory: (r) => makeToggle(r, 4, 4, 12) },
|
|
159
|
+
],
|
|
160
|
+
});
|
|
161
|
+
export const LEVELS = CAMPAIGN.levels;
|
|
162
|
+
|
|
163
|
+
const W = 1280, H = 720;
|
|
164
|
+
|
|
165
|
+
class GameView extends Node {
|
|
166
|
+
override readonly type = 'GameView';
|
|
167
|
+
idx = 0;
|
|
168
|
+
lit: number[] = LEVELS[0].puzzle.initial().lit;
|
|
169
|
+
cursor = 0;
|
|
170
|
+
private layer = new Node({ name: 'layer' });
|
|
171
|
+
|
|
172
|
+
protected override onReady(): void { this.layer.cosmetic = true; this.addChild(this.layer); this.paint(); }
|
|
173
|
+
private dims() { const s = LEVELS[this.idx].puzzle.initial(); return { cols: s.cols, rows: s.rows }; }
|
|
174
|
+
|
|
175
|
+
private paint(): void {
|
|
176
|
+
for (const c of this.layer.children.slice()) this.layer.removeChild(c);
|
|
177
|
+
const { cols, rows } = this.dims();
|
|
178
|
+
// Studio knob: adjust live on /studio/ while playing, accept, then ask the
|
|
179
|
+
// agent to write the accepted value back into the tuning default below.
|
|
180
|
+
const round = (this.world as World).tune<number>('cellRound');
|
|
181
|
+
const cell = 120, ox = W/2 - (cols*cell)/2 + cell/2, oy = H/2 - (rows*cell)/2 + cell/2;
|
|
182
|
+
for (let i=0;i<cols*rows;i++){
|
|
183
|
+
const x = ox + (i%cols)*cell, y = oy + Math.floor(i/cols)*cell, on = this.lit[i]===1;
|
|
184
|
+
this.layer.addChild(new Sprite({ name:\`c\${i}\`, pos:{x,y}, z:1,
|
|
185
|
+
shape:{kind:'rect', w:cell*0.7, h:cell*0.7, r:round},
|
|
186
|
+
fill: on ? KENTO.kaki : withAlpha(KENTO.kinako, 0.15),
|
|
187
|
+
gradient: on ? linearGradient([KENTO.ko, KENTO.shuDeep], 90) : undefined,
|
|
188
|
+
stroke: withAlpha(KENTO.gofun, on?0.5:0.2), strokeWidth: 2,
|
|
189
|
+
shadow: on ? glow(withAlpha(KENTO.kaki,0.8), 40) : undefined }));
|
|
190
|
+
}
|
|
191
|
+
const cp = { x: ox + (this.cursor%cols)*cell, y: oy + Math.floor(this.cursor/cols)*cell };
|
|
192
|
+
this.layer.addChild(new Sprite({ name:'cursor', pos:cp, z:5, shape:{kind:'rect', w:cell*0.82, h:cell*0.82, r:22}, fill:'none', stroke:KENTO.asagi, strokeWidth:3 }));
|
|
193
|
+
this.layer.addChild(new Text({ name:'hud', text:\`\${LEVELS[this.idx].actName} — level \${this.idx+1}/\${LEVELS.length} · arrows move · space tap\`, pos:{x:W/2,y:60}, size:24, align:'center', fill:KENTO.gofun }));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private tap(): void {
|
|
197
|
+
const { cols, rows } = this.dims();
|
|
198
|
+
this.lit = flip(this.lit, this.cursor, cols, rows); audio.blip(360);
|
|
199
|
+
if (this.lit.every(v=>v===1)) { this.idx = (this.idx+1) % LEVELS.length; this.lit = LEVELS[this.idx].puzzle.initial().lit; this.cursor = 0; audio.success(); }
|
|
200
|
+
this.paint();
|
|
201
|
+
}
|
|
202
|
+
private move(dx:number,dy:number): void {
|
|
203
|
+
const { cols, rows } = this.dims();
|
|
204
|
+
const x = Math.max(0,Math.min(cols-1,(this.cursor%cols)+dx));
|
|
205
|
+
const y = Math.max(0,Math.min(rows-1,Math.floor(this.cursor/cols)+dy));
|
|
206
|
+
this.cursor = y*cols+x; this.paint();
|
|
207
|
+
}
|
|
208
|
+
protected override onProcess(): void {
|
|
209
|
+
if (!this.world) return; const inp = (this.world as World).input;
|
|
210
|
+
if (inp.justPressed('up')) this.move(0,-1);
|
|
211
|
+
if (inp.justPressed('down')) this.move(0,1);
|
|
212
|
+
if (inp.justPressed('left')) this.move(-1,0);
|
|
213
|
+
if (inp.justPressed('right')) this.move(1,0);
|
|
214
|
+
if (inp.justPressed('confirm')) this.tap();
|
|
215
|
+
}
|
|
216
|
+
protected override serializeProps() { return { idx: this.idx, lit: this.lit, cursor: this.cursor }; }
|
|
217
|
+
override applyProps(p: Record<string, unknown>) { if (p.lit){ this.idx=p.idx as number; this.lit=p.lit as number[]; this.cursor=p.cursor as number; this.paint(); } }
|
|
218
|
+
}
|
|
219
|
+
registerNode('GameView', () => new GameView());
|
|
220
|
+
|
|
221
|
+
export const game = defineGame({
|
|
222
|
+
title: '${slug}', width: W, height: H, background: KENTO.yohaku,
|
|
223
|
+
// Live-tunable knobs: the /studio/ page builds sliders from this spec, values
|
|
224
|
+
// are hashed sim state read via world.tune(). Defaults ARE the config.
|
|
225
|
+
tuning: { knobs: [knob.num('cellRound', { default: 18, min: 0, max: 40, step: 1, group: 'look' })] },
|
|
226
|
+
build: () => new GameView({ name: 'game' }),
|
|
227
|
+
probe: (w) => { const v = w.root.find('game') as GameView | null; return { level: v?.idx ?? 0, lit: v ? v.lit.reduce((a,b)=>a+b,0) : 0 }; },
|
|
228
|
+
});
|
|
229
|
+
`,
|
|
230
|
+
|
|
231
|
+
'main.ts': `import { runStudio } from '@hayao';
|
|
232
|
+
import { game } from './game';
|
|
233
|
+
import { variants } from './variants';
|
|
234
|
+
|
|
235
|
+
// Studio-instrumented dev driver: playtests record to the dev server,
|
|
236
|
+
// ?seed=/?tuning=/?variant= override, the /studio/ page drives the knobs, and
|
|
237
|
+
// \`hot\` carries the live world across code edits. Production builds behave
|
|
238
|
+
// like plain runBrowser (the Studio endpoints simply aren't there).
|
|
239
|
+
runStudio(game, document.getElementById('app')!, { variants, hot: import.meta.hot });
|
|
240
|
+
// Literal self-accept — Vite marks HMR boundaries by static source scan, so
|
|
241
|
+
// this line (not a call inside the engine) is what prevents full reloads.
|
|
242
|
+
import.meta.hot?.accept();
|
|
243
|
+
`,
|
|
244
|
+
|
|
245
|
+
'variants.ts': `// A/B variants for Studio playtests: pick with ?variant=<name> or compare two
|
|
246
|
+
// side by side on the /studio/ page. Tuning-only variants hot-toggle mid-play.
|
|
247
|
+
import type { Variant } from '@hayao';
|
|
248
|
+
|
|
249
|
+
export const variants: Record<string, Variant> = {
|
|
250
|
+
chunky: { label: 'Chunky — big rounded cells', tuning: { cellRound: 34 } },
|
|
251
|
+
sharp: { label: 'Sharp — square cells', tuning: { cellRound: 2 } },
|
|
252
|
+
};
|
|
253
|
+
`,
|
|
254
|
+
|
|
255
|
+
'verify.ts': `// Prove the game correct — no browser. Run: npm run verify
|
|
256
|
+
import { solve, rampIssues } from '@hayao';
|
|
257
|
+
import { LEVELS } from './game';
|
|
258
|
+
|
|
259
|
+
let failures = 0;
|
|
260
|
+
const ok = (label: string, pass: boolean) => { console.log(\` \${pass?'✓':'✗'} \${label}\`); if (!pass) failures++; };
|
|
261
|
+
|
|
262
|
+
// Every generated level is solver-proven winnable.
|
|
263
|
+
let allWin = true;
|
|
264
|
+
for (const l of LEVELS) { const r = solve(l.puzzle); if (!r.solvable) allWin = false; }
|
|
265
|
+
ok(\`all \${LEVELS.length} levels solver-proven winnable\`, allWin);
|
|
266
|
+
|
|
267
|
+
// The difficulty curve is well-shaped.
|
|
268
|
+
const problems = rampIssues(LEVELS.map(l => l.depth));
|
|
269
|
+
ok(problems.length === 0 ? 'difficulty ramps cleanly' : \`ramp: \${problems[0]}\`, problems.length === 0);
|
|
270
|
+
|
|
271
|
+
console.log(failures === 0 ? '\\nAll checks passed.' : \`\\n\${failures} failed.\`);
|
|
272
|
+
process.exit(failures === 0 ? 0 : 1);
|
|
273
|
+
`,
|
|
274
|
+
|
|
275
|
+
'game.test.ts': `import { describe, expect, it } from 'vitest';
|
|
276
|
+
import { Rng, solve } from '@hayao';
|
|
277
|
+
import { makeToggle, flip } from './logic';
|
|
278
|
+
import { LEVELS } from './game';
|
|
279
|
+
|
|
280
|
+
describe('toggle puzzle', () => {
|
|
281
|
+
it('flip is self-inverse', () => {
|
|
282
|
+
const a = new Array(9).fill(0);
|
|
283
|
+
expect(flip(flip(a, 4, 3, 3), 4, 3, 3)).toEqual(a);
|
|
284
|
+
});
|
|
285
|
+
it('generated boards are always winnable', () => {
|
|
286
|
+
for (let s=0;s<20;s++) expect(solve(makeToggle(new Rng(s), 4, 4, 10)).solvable).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
it('the shipped campaign is winnable end to end', () => {
|
|
289
|
+
for (const l of LEVELS) expect(solve(l.puzzle).solvable).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
`,
|
|
293
|
+
|
|
294
|
+
'AGENTS.md': `# AGENTS.md — working in this hayao project
|
|
295
|
+
|
|
296
|
+
You are the game developer. The engine (npm package \`hayao\`) pre-solves the traps:
|
|
297
|
+
headless verification, determinism, crisp rendering, DOM menus. Hold the invariants.
|
|
298
|
+
|
|
299
|
+
## Commands
|
|
300
|
+
- \`npm run dev\` — vite dev server (play in a browser)
|
|
301
|
+
- \`npm run check\` — typecheck (must pass before handoff)
|
|
302
|
+
- \`npm test\` — vitest (headless, runs in Node)
|
|
303
|
+
- \`npm run verify\` — prove every level winnable + the ramp well-shaped, no browser
|
|
304
|
+
|
|
305
|
+
## Invariants (determinism is sacred)
|
|
306
|
+
- **Import only from \`@hayao\`.** The whole public surface is one seam.
|
|
307
|
+
- **All randomness flows through a seeded \`Rng\`** — never \`Math.random\`, \`Date.now\`,
|
|
308
|
+
or argless \`new Date\` in game logic.
|
|
309
|
+
- **Turn-based/puzzle rules live in a pure \`Puzzle<State, Move>\` module** (logic.ts);
|
|
310
|
+
the scene tree is a *view*. Every level is machine-proven winnable via \`solve()\`.
|
|
311
|
+
- **Menus are DOM overlays; mark pure-view nodes \`cosmetic = true\`** so they stay out
|
|
312
|
+
of \`world.hash()\`.
|
|
313
|
+
- **Generate content, don't hand-author volume.** \`generateLevels\` / \`composeCampaign\`
|
|
314
|
+
emit solver-verified levels inside a difficulty band; \`rampIssues\`/\`assertRamp\`
|
|
315
|
+
prove the curve. See game.ts for the pattern.
|
|
316
|
+
|
|
317
|
+
## Where things are
|
|
318
|
+
- \`logic.ts\` — pure puzzle rules (the truth)
|
|
319
|
+
- \`game.ts\` — scene-tree view + the generated campaign + tuning knobs
|
|
320
|
+
- \`variants.ts\` — named A/B alternatives for Studio playtests
|
|
321
|
+
- \`verify.ts\` — the proof harness (winnable + ramp)
|
|
322
|
+
|
|
323
|
+
## The Studio (human playtests → your context)
|
|
324
|
+
\`npm run dev\` then open \`/studio/\` — the human plays there; every session
|
|
325
|
+
records to \`.studio/\` as a bit-exactly replayable artifact. The
|
|
326
|
+
\`hayao-studio\` MCP server (.mcp.json) is YOUR window into them:
|
|
327
|
+
\`list_sessions\`, \`get_playtest_report\` (hesitations, deaths, futile verbs,
|
|
328
|
+
quit context), \`inspect_moment\` (replay any tick → probe + screenshot),
|
|
329
|
+
\`get_knob_state\` (values the human accepted — write them back into the
|
|
330
|
+
\`tuning:\` defaults in game.ts, then \`npm run verify\`). Telemetry describes,
|
|
331
|
+
the human directs: propose fixes from the data, never auto-apply.
|
|
332
|
+
Install \`@resvg/resvg-js\` as a devDependency to enable inspect_moment PNGs.
|
|
333
|
+
|
|
334
|
+
Full docs & the greppable API digest: https://github.com/hellojanpacan/hayao-js
|
|
335
|
+
`,
|
|
336
|
+
|
|
337
|
+
'llms.txt': `# ${slug} (built with hayao)
|
|
338
|
+
|
|
339
|
+
> A hayao game. hayao is an AI-first, deterministic, headless-verifiable game engine.
|
|
340
|
+
> Author in logic.ts (pure Puzzle) + game.ts (scene view); prove with \`npm run verify\`.
|
|
341
|
+
|
|
342
|
+
- Import only from \`@hayao\`. All randomness through a seeded \`Rng\`.
|
|
343
|
+
- Content is generated and solver-proven: see \`composeCampaign\` in game.ts.
|
|
344
|
+
- Engine docs: https://raw.githubusercontent.com/hellojanpacan/hayao-js/main/AGENTS.md
|
|
345
|
+
- API digest: https://raw.githubusercontent.com/hellojanpacan/hayao-js/main/docs/API.md
|
|
346
|
+
`,
|
|
347
|
+
|
|
348
|
+
'README.md': `# ${slug}
|
|
349
|
+
|
|
350
|
+
A game built with [hayao](https://github.com/hellojanpacan/hayao-js) — an AI-first,
|
|
351
|
+
deterministic, headless-verifiable game engine.
|
|
352
|
+
|
|
353
|
+
\`\`\`sh
|
|
354
|
+
npm install
|
|
355
|
+
npm run dev # play in a browser
|
|
356
|
+
npm test # headless tests
|
|
357
|
+
npm run verify # prove every level winnable + the ramp well-shaped
|
|
358
|
+
\`\`\`
|
|
359
|
+
|
|
360
|
+
Every level in this game is **machine-generated and solver-proven winnable** — the
|
|
361
|
+
content is composed by \`composeCampaign\` (see \`game.ts\`), not hand-authored. Read
|
|
362
|
+
\`AGENTS.md\` for the conventions an AI author should hold.
|
|
363
|
+
`,
|
|
364
|
+
|
|
365
|
+
'.gitignore': `node_modules\ndist\n*.log\n.studio/\n`,
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
mkdirSync(dir, { recursive: true });
|
|
369
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
370
|
+
const p = join(dir, rel);
|
|
371
|
+
mkdirSync(join(p, '..'), { recursive: true });
|
|
372
|
+
writeFileSync(p, content);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
console.log(`\n✨ Scaffolded a hayao game in ${name}/\n`);
|
|
376
|
+
console.log(` cd ${name}`);
|
|
377
|
+
console.log(' npm install');
|
|
378
|
+
console.log(' npm run verify # prove it correct — no browser');
|
|
379
|
+
console.log(' npm run dev # play it\n');
|
|
380
|
+
console.log('An agent working here should read AGENTS.md first.\n');
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hayao-mcp — start the Studio MCP sidecar in a hayao project.
|
|
3
|
+
// Spawns dist/mcp.js under tsx so the project's TS game modules and its
|
|
4
|
+
// tsconfig '@hayao' path resolve. Register in .mcp.json as:
|
|
5
|
+
// { "mcpServers": { "hayao-studio": { "command": "npx", "args": ["hayao-mcp"] } } }
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const server = fileURLToPath(new URL('../dist/mcp.js', import.meta.url));
|
|
10
|
+
const child = spawn('npx', ['tsx', server], { stdio: 'inherit' });
|
|
11
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
package/dist/app/browser.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { GameDefinition } from './game';
|
|
2
|
-
import { KeyboardSource } from '../input/source';
|
|
1
|
+
import type { CreateWorldOptions, GameDefinition } from './game';
|
|
2
|
+
import { KeyboardSource, PointerSource } from '../input/source';
|
|
3
|
+
import type { Vec2 } from '../core/math';
|
|
3
4
|
import type { Renderer } from '../render/renderer';
|
|
4
5
|
import type { World } from '../world';
|
|
5
6
|
export interface RunOptions {
|
|
@@ -7,12 +8,42 @@ export interface RunOptions {
|
|
|
7
8
|
/** Start the pause/settings shell (Esc). Default true. */
|
|
8
9
|
shell?: boolean;
|
|
9
10
|
onRestart?: () => void;
|
|
11
|
+
/** World creation overrides (seed, tuning) — applied on start AND restart. */
|
|
12
|
+
world?: CreateWorldOptions;
|
|
13
|
+
/**
|
|
14
|
+
* Observer hook: called after each advance that ran ≥1 step, with the step
|
|
15
|
+
* count and the action set those steps saw. Every step in the batch saw the
|
|
16
|
+
* SAME actions and axes, so recording `steps × (actions, axes)` replays
|
|
17
|
+
* exactly. Studio's session recorder rides this; it must never mutate.
|
|
18
|
+
*/
|
|
19
|
+
onAdvance?: (world: World, steps: number, actions: readonly string[]) => void;
|
|
20
|
+
/** Shell pause/resume observer (true = paused). */
|
|
21
|
+
onPause?: (paused: boolean) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Freeze gate: while it returns true the loop keeps rendering but runs no
|
|
24
|
+
* steps (no pause overlay — Studio's scrubber holds the sim with this).
|
|
25
|
+
*/
|
|
26
|
+
isHeld?: () => boolean;
|
|
10
27
|
}
|
|
11
28
|
export interface GameHandle {
|
|
12
29
|
world: World;
|
|
13
30
|
renderer: Renderer;
|
|
14
31
|
/** The live input source — game UI calls input.press('action') for buttons. */
|
|
15
32
|
input: KeyboardSource;
|
|
33
|
+
/**
|
|
34
|
+
* Continuous pointer/touch in DESIGN space, sampled into world.input.axes each
|
|
35
|
+
* step (pointer.x / pointer.y / pointer.down). Read `handle.pointer.read()` for
|
|
36
|
+
* a one-off, or `world.input.axis('pointer.x')` inside the sim.
|
|
37
|
+
*/
|
|
38
|
+
pointer: PointerSource;
|
|
39
|
+
/** The mounted canvas/svg node (attach your own listeners here if needed). */
|
|
40
|
+
canvas: HTMLElement | SVGElement | undefined;
|
|
41
|
+
/** Map a pointer event's clientX/Y to design coordinates (undoes the letterbox). */
|
|
42
|
+
toDesign(clientX: number, clientY: number): Vec2;
|
|
43
|
+
/** Resolves after preload completes and the first real frame has rendered. */
|
|
44
|
+
ready: Promise<void>;
|
|
45
|
+
/** Run a callback once the game is ready (fires immediately if already ready). */
|
|
46
|
+
onReady(cb: () => void): void;
|
|
16
47
|
stop(): void;
|
|
17
48
|
restart(): void;
|
|
18
49
|
}
|