hayao 0.3.0 → 0.4.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.
@@ -56,12 +56,77 @@ The engine, not game code, owns the clock, the rAF loop, focus, audio muting,
56
56
  and the DOM shell. Do not re-implement any of that inside a game.
57
57
 
58
58
  The third `onUpdate`/behavior argument is the **world context** (`input`, `rng`,
59
- `clock`, `time`) prefer it over closing over `world` from `build()`, so a node's
60
- behavior is self-contained and liftable. For headless tests, `world.runSteps(n,
59
+ `clock`, `time`, plus `state`, `events`, `width`/`height`, `paused`,
60
+ `timeScale`, and `camera()`) prefer it over closing over `world` from
61
+ `build()`, so a node's behavior is self-contained and liftable. Type the state
62
+ with `defineGame<TState>` and keep it in `world.state`/`ctx.state` — never in
63
+ module-level variables, which leak across `restart()` and hide from `hash()`
64
+ (see ENGINE.md §Typed state). For headless tests, `world.runSteps(n,
61
65
  i => actions)` fast-forwards an exact step count; `world.advance(realMs)` is the
62
66
  *realtime* driver and clamps big deltas to one frame budget (never use it to skip
63
67
  ahead in a test).
64
68
 
69
+ ## Renderers
70
+
71
+ The engine ships four renderer backends, all consuming the same `DrawCommand[]`
72
+ display list. The sim never reads the renderer — renderer choice is purely a host
73
+ concern and has zero effect on `world.hash()` or determinism.
74
+
75
+ | Backend | `RunOptions.renderer` | Best for |
76
+ |---|---|---|
77
+ | `SvgRenderer` | `'svg'` (default) | Sparse scenes, DOM inspection, crisp resolution-independence |
78
+ | `Canvas2DRenderer` | `'canvas'` | High entity counts, particles, performance-first |
79
+ | `WebGL2Renderer` | `'webgl'` | Post-processing effects (bloom, pixelate, glitch, palette rotation) |
80
+ | `HeadlessRenderer` | — (Node.js only) | Tests, `npm run verify`, `toSVGString()` |
81
+
82
+ To select a renderer:
83
+
84
+ ```ts
85
+ import { runBrowser, WebGL2Renderer } from '@hayao';
86
+
87
+ // Built-in string shorthand:
88
+ runBrowser(def, mount, { renderer: 'webgl' });
89
+
90
+ // Or pass your own configured instance directly via the DOM:
91
+ const renderer = new WebGL2Renderer({ width, height, background,
92
+ postProcess: `#version 300 es
93
+ precision mediump float;
94
+ in vec2 v_uv;
95
+ uniform sampler2D u_scene;
96
+ out vec4 fragColor;
97
+ void main() {
98
+ // Vignette
99
+ vec2 uv = v_uv - 0.5;
100
+ float d = dot(uv, uv);
101
+ fragColor = texture(u_scene, v_uv) * (1.0 - d * 1.8);
102
+ }`,
103
+ });
104
+ ```
105
+
106
+ ### WebGL2Renderer post-processing
107
+
108
+ `WebGL2Renderer` rasterizes the full display list via Canvas2D (pixel-exact
109
+ parity with `Canvas2DRenderer`) then passes the frame through a GLSL fragment
110
+ shader. This gives you any full-screen effect in 5 lines of GLSL, without
111
+ touching the display list or the sim.
112
+
113
+ The fragment shader interface:
114
+ - `in vec2 v_uv` — 0..1 UV with (0,0) at top-left
115
+ - `uniform sampler2D u_scene` — the rasterized frame
116
+ - `out vec4 fragColor` — output pixel
117
+
118
+ Change the shader at runtime:
119
+ ```ts
120
+ // On game handle:
121
+ (handle.renderer as WebGL2Renderer).setPostProcess(shaderSrc);
122
+ (handle.renderer as WebGL2Renderer).clearPostProcess(); // back to passthrough
123
+ ```
124
+
125
+ **Verifiability**: because the renderer is a pure display-list consumer, the
126
+ `HeadlessRenderer` and `SvgRenderer` see the identical `DrawCommand[]` input
127
+ and remain the reference backends for all verify passes. Post-processing effects
128
+ are cosmetic — they are never part of the game's `world.hash()`.
129
+
65
130
  ## Pointer & continuous input
66
131
 
67
132
  Keyboard actions are the default and the only input that flows through the
@@ -78,6 +143,204 @@ actions do. For lockstep/replay, **quantize at the host edge**: map a tap to an
78
143
  action (`input.press('fire')`) or snap position to a grid cell you feed as a
79
144
  discrete move. Keep raw pointer floats out of the sim's canonical state.
80
145
 
146
+ **Replay-exact analog** (twin-stick aim, analog throttle): pass QUANTIZED axes as
147
+ `world.step(actions, axes)`'s second argument. Those enter `getState()` → hash and
148
+ the input log (`InputRecorder`/`frameAxes`), so `assertDeterministic` and netplay
149
+ reproduce them. Snap at the edge with `snapAxis(v, buckets)` / `quantizeAngle(rad,
150
+ buckets)`. Games that never pass axes keep their pinned hashes byte-for-byte.
151
+
152
+ **Mouse buttons are actions.** The browser driver wires PointerSource to the
153
+ KeyboardSource, so while a button is held the actions `mouse.left` /
154
+ `mouse.right` / `mouse.middle` are down — bindable in an inputMap
155
+ (`shield: ['mouse.right']`), queryable directly
156
+ (`input.justPressed('mouse.left')`), and replay-exact because they enter the
157
+ same deterministic actionsDown log as keys. The raw 0/1 axes `pointer.right`
158
+ / `pointer.middle` exist alongside `pointer.down` for analog-style reads. The
159
+ browser context menu is suppressed by default so right-click is usable as game
160
+ input (opt back in via `PointerSourceOptions.contextMenu`).
161
+
162
+ **Action names, not key codes.** `isDown`/`justPressed`/`justReleased` take
163
+ ACTION names from the input map (`'jump'`), never key codes (`'Space'`). Once
164
+ a host has declared its sources, querying an action no source can produce
165
+ logs a console.warn once per name — so `isDown('jmup')` fails loudly instead
166
+ of silently never firing. (`justReleased` exists and mirrors `justPressed`.)
167
+
168
+ **Sustained virtual input.** `press()` is a one-shot tap (cleared after one step).
169
+ For an on-screen control that models STATE (held joystick, hold-to-fire), use
170
+ `input.setHeld(action, on)` — it stays in the action set every step until released,
171
+ no re-press-per-frame.
172
+
173
+ **Multitouch.** `pointer.read()`/`sample()` describe the primary pointer (unchanged).
174
+ For two thumbs at once, `pointer.readAll()` returns every live touch with its stable
175
+ `id`. Or skip the raw layer: **`TouchControls`** (`ui/`, sibling to `Shell`) renders
176
+ floating sticks/buttons and drives the same action set via `setHeld` — a virtual
177
+ gamepad in one declaration. Anchor host-drawn UI to the played area with
178
+ `handle.viewport()` (letterbox rect + scale), never re-derived `getBoundingClientRect`.
179
+
180
+ **Proving the host layer.** Touch/pointer host code is the one layer sim proofs
181
+ can't see. `bootDom(def)` (`verify/dom`, under jsdom/happy-dom) boots the real
182
+ `runBrowser` wiring, fires synthetic touches, and steps so you assert on `probe()`.
183
+
184
+
185
+ ## Custom input sources (gamepad, MIDI, accelerometer, …)
186
+
187
+ The engine's input layer is open-ended: anything that implements `InputSource`
188
+ (`sample(input: InputState) + optional dispose()`) can be plugged in alongside
189
+ the built-in `KeyboardSource` and `PointerSource`.
190
+
191
+ ### Wiring a source into the step loop
192
+
193
+ Pass sources at start-up — or add them after the handle is returned:
194
+
195
+ ```ts
196
+ import { GamepadSource, DEFAULT_GAMEPAD_MAP } from '@hayao';
197
+
198
+ // Option A: at start-up
199
+ const handle = runBrowser(def, mount, {
200
+ sources: [new GamepadSource({ keyboard: handle.input })], // ← needs handle first
201
+ });
202
+
203
+ // Option B: post-creation (recommended for gamepad — wait for 'gamepadconnected')
204
+ const handle = runBrowser(def, mount);
205
+ window.addEventListener('gamepadconnected', () => {
206
+ const gamepad = new GamepadSource({ keyboard: handle.input });
207
+ handle.addSource(gamepad); // sampled every step; addSource returns a cleanup fn
208
+ });
209
+ ```
210
+
211
+ ### Axis naming convention
212
+
213
+ Use a dotted namespace prefixed by the source name. All hayao built-ins follow:
214
+
215
+ | Axis key | Source | Range | Notes |
216
+ |-------------------|--------------|--------|---------------------------------|
217
+ | `pointer.x` | PointerSource| design | Design-space x (letterbox-safe) |
218
+ | `pointer.y` | PointerSource| design | Design-space y |
219
+ | `pointer.down` | PointerSource| 0/1 | Primary pointer pressed |
220
+ | `gamepad.lx` | GamepadSource| ±1 | Left stick x (after deadzone) |
221
+ | `gamepad.ly` | GamepadSource| ±1 | Left stick y |
222
+ | `gamepad.rx` | GamepadSource| ±1 | Right stick x |
223
+ | `gamepad.ry` | GamepadSource| ±1 | Right stick y |
224
+ | `gamepad.lt` | GamepadSource| 0..1 | Left trigger |
225
+ | `gamepad.rt` | GamepadSource| 0..1 | Right trigger |
226
+ | `gamepad.dpad.x` | GamepadSource| −1/0/1 | D-pad horizontal |
227
+ | `gamepad.dpad.y` | GamepadSource| −1/0/1 | D-pad vertical (−1 = up) |
228
+
229
+ Custom sources: follow the same dotted convention (`mydev.axis1`, `midi.cc7`).
230
+
231
+ ### When to call sample()
232
+
233
+ The engine calls `sample(world.input)` once per fixed step, **before**
234
+ `world.advance()`. This mirrors `PointerSource.sample()` in the browser driver.
235
+ Never call `sample()` inside `onUpdate` or from sim code — axes are host-side.
236
+
237
+ ### Determinism rules for custom sources
238
+
239
+ 1. **Quantize at the host edge.** Use `snapAxis(v, buckets)` / `quantizeAngle`
240
+ before writing to `input.axes`. Unquantized floats differ across machines and
241
+ across frames when the device hasn't moved — quantized values are stable.
242
+ 2. **Axes are live by default** (NOT in `world.hash()` or the input log). This
243
+ is correct for most uses: the sim reads a "current" value, and replay is
244
+ driven by the recorded action log, not raw hardware.
245
+ 3. **For replay-exact analog** (twin-stick aim, analog throttle): pass the
246
+ quantized axes as the SECOND argument to `world.step(actions, axes)`. They
247
+ then enter `getState()` → hash and `InputRecorder` (same as pointer axes in
248
+ Studio sessions). Only do this if you need bit-exact replay of analog values.
249
+ 4. **For discrete input** (button press → action): call
250
+ `keyboard.setHeld(action, on)` — presses flow through `KeyboardSource.currentActions()`
251
+ into the SAME deterministic string log as keys and are covered by record/replay
252
+ and lockstep netplay at zero extra cost. This is what `GamepadSource` does for
253
+ its `buttonMap`. Prefer `setHeld` over `press()` for buttons: `press()` is a
254
+ one-shot tap (cleared after one step), `setHeld` models the held/released cycle.
255
+
256
+ ### GamepadSource
257
+
258
+ `GamepadSource` is the reference implementation of `InputSource`. It polls
259
+ `navigator.getGamepads()` per step and provides:
260
+
261
+ - **Analog sticks + triggers** as quantized axes (see table above).
262
+ - **D-pad + face buttons + start/select** as deterministic actions via a
263
+ `GamepadMap` (button index → action name). Defaults match `DEFAULT_INPUT_MAP`
264
+ so a gamepad works with the keyboard action set out of the box.
265
+ - **Deadzone** with circular magnitude clamping (default 0.12, matching typical
266
+ hardware drift). Output is remapped to fill the full ±1 range post-deadzone.
267
+ - **Configurable quantization** (`stickBuckets`, `triggerBuckets`).
268
+
269
+ ```ts
270
+ import { GamepadSource, DEFAULT_GAMEPAD_MAP } from '@hayao';
271
+
272
+ const gamepad = new GamepadSource({
273
+ keyboard: handle.input, // required for button→action routing
274
+ index: 0, // gamepad slot (default 0)
275
+ deadzone: 0.15, // override default 0.12
276
+ stickBuckets: 64, // coarser for a grid-based game
277
+ buttonMap: {
278
+ ...DEFAULT_GAMEPAD_MAP,
279
+ 6: 'dodge', // LT → dodge action
280
+ },
281
+ });
282
+ const removeGamepad = handle.addSource(gamepad);
283
+ // later: removeGamepad() to dispose and stop sampling
284
+ ```
285
+
286
+ Sim code reads analog axes the same way pointer input is read:
287
+
288
+ ```ts
289
+ player.onUpdate = (n, dt, ctx) => {
290
+ const lx = ctx.input.axis('gamepad.lx'); // −1..1 after deadzone + quantize
291
+ const ly = ctx.input.axis('gamepad.ly');
292
+ // Also check the fallback action for keyboard/dpad parity:
293
+ const dx = ctx.input.isDown('right') ? 1 : ctx.input.isDown('left') ? -1 : lx;
294
+ n.pos.x += dx * 200 * dt;
295
+ };
296
+ ```
297
+
298
+ ### Building your own InputSource
299
+
300
+ ```ts
301
+ import { InputSource, snapAxis, InputState } from '@hayao';
302
+
303
+ class AccelerometerSource implements InputSource {
304
+ private x = 0;
305
+ private y = 0;
306
+
307
+ constructor() {
308
+ window.addEventListener('devicemotion', (e) => {
309
+ this.x = e.accelerationIncludingGravity?.x ?? 0;
310
+ this.y = e.accelerationIncludingGravity?.y ?? 0;
311
+ });
312
+ }
313
+
314
+ sample(input: InputState): void {
315
+ // Normalize to ±1, quantize for determinism.
316
+ input.axes.set('accel.x', snapAxis(this.x / 9.8, 64, -1, 1));
317
+ input.axes.set('accel.y', snapAxis(this.y / 9.8, 64, -1, 1));
318
+ }
319
+ }
320
+
321
+ handle.addSource(new AccelerometerSource());
322
+ // Sim: ctx.input.axis('accel.x')
323
+ ```
324
+
325
+ ## Depth & 2.5D (isometric / overlap)
326
+
327
+ Painter's `z` is the one depth primitive: the renderer stable-sorts by it, ties
328
+ broken by tree order. For any game where sprites overlap (top-down, iso), derive
329
+ `z` from a depth axis instead of hand-setting it per mover:
330
+
331
+ - **`DepthSort({ key })`** — a container that assigns each child's `z` from an
332
+ accessor at draw time (positions are final for the frame). `key: (n) => n.pos.y`
333
+ for top-down overlap; `key: (n) => n.gx + n.gy` for an iso lattice. It removes
334
+ per-node depth bookkeeping from every overlap game.
335
+ - **`iso({ tileW, tileH, origin })`** — the grid↔screen projection (core, no
336
+ trig, hash-safe). `toScreen(gx, gy, elev?)` places a cell; `toGrid(sx, sy)`
337
+ inverts the ground plane so `pointer.x/y` → a tile coord. Use this for picking;
338
+ `physics/tilemap` is **orthogonal-only** and cannot pick on an iso map.
339
+ - **`IsoPrism({ tileW, tileH, height, fill })`** — a raised tile as three
340
+ auto-shaded faces (top + two sides), one node instead of three hand-rolled
341
+ polys. Pairs with `DepthSort` keyed on `gx + gy`.
342
+ - **`diamond` / `regularPoly` shapes** — sugar over `poly` so an iso tile or hex
343
+ reads as intent, not four re-typed corners.
81
344
 
82
345
  ## The human-contact layer
83
346
 
@@ -146,6 +409,31 @@ RNG state and clock state are part of `world.hash()`, `world.snapshot()`, and
146
409
  saves, so a restored world resumes *identically* — that is what makes replay,
147
410
  undo, and time-travel free.
148
411
 
412
+ **The dev guard.** Pass `guardDeterminism: true` in the world config and every
413
+ `step()` wraps `Math.random`/`Date.now` so a stray call inside the sim warns
414
+ ONCE with a stack hint (values still flow — nothing breaks mid-frame). It
415
+ never throws, so it is safe to leave on in tests and dev builds. It converts
416
+ "the replay diverged three hours later" into "here is the exact call site, now".
417
+
418
+ **Cosmetic randomness.** Rule 1 has two escape hatches for pure-view code so
419
+ decoration never perturbs the gameplay stream:
420
+
421
+ - `world.rng.split(key)` derives an independent child stream — give a cosmetic
422
+ node its own private `Rng` at construction (Particles and Shaker do this),
423
+ and its draws stop racing gameplay draws for the same numbers.
424
+ - `hashNoise(...values)` is *stateless* deterministic noise → `[0, 1)`: same
425
+ inputs, same output, no stream consumed — so it is safe to call from
426
+ `draw()` for per-entity variation (`hashNoise(i, ctx.frame)` for jitter,
427
+ phase offsets, hue nudges). Never call `rng.float()` from `draw()`.
428
+
429
+ **Catch-up steps.** `advance(realMs)` runs as many fixed steps as real time
430
+ demands, clamped at `clock.maxFrameMs` (default 250, with a warn-once when it
431
+ clamps). So the FIRST advance after a stall — tab restored, debugger resumed,
432
+ slow boot — can legitimately run multiple steps at once, and first-frame logic
433
+ that assumes "one advance = one step" (e.g. gravity applied before the floor
434
+ exists) sinks the player. Guard the logic, and in tests never fast-forward
435
+ with a big `advance(ms)` — use `runSteps(n)`, which has no realtime clamp.
436
+
149
437
  ## House style (defaults — swap deliberately, not accidentally)
150
438
 
151
439
  - **Code-as-art, zero binary assets.** A `Sprite` is a vector `Shape`
package/docs/EMBED.md CHANGED
@@ -80,6 +80,11 @@ If you prefer modules, inline `dist/index.min.js` as a blob and dynamic-import i
80
80
  - **Assets.** If your game loads a webfont or atlas, use the `preload` +
81
81
  `splash` lifecycle (see [API.md](API.md)) so there's no pop-in — the engine
82
82
  holds a palette-guaranteed splash until preload resolves.
83
+ - **Touch.** These targets (Artifacts, itch, js13k) are largely played on phones.
84
+ A keyboard game is unplayable there until you add `TouchControls` — a virtual
85
+ gamepad in one declaration that drives the same action set as keys:
86
+ `new TouchControls(handle, { left: ['up','down','left','right'], buttons: [{ action: 'fire' }] })`.
87
+ It only mounts on coarse-pointer devices by default. See CONVENTIONS §Pointer.
83
88
  - **The one gap the engine can't close.** The window between the browser
84
89
  *fetching* the file and the script *executing* is pre-engine by physics —
85
90
  nothing hayao can draw yet. Recipe B shrinks it to a single local file.
@@ -119,7 +119,19 @@ can reach is provably winnable — all without opening a browser.
119
119
  - **Mark pure-view nodes `cosmetic = true`** so transient display never enters
120
120
  `world.hash()` — otherwise determinism checks will (correctly) fail.
121
121
  - **`justPressed` is a one-frame rising edge** — insert a neutral frame between
122
- repeated taps of the same action.
122
+ repeated taps of the same action. `justReleased` mirrors it.
123
+ - **Query ACTION names, not key codes** — `isDown('jump')`, never
124
+ `isDown('Space')`. In the browser, an undeclared name warns once in the
125
+ console instead of silently never firing.
126
+ - **Pointer coordinates are design-space** — `input.axis('pointer.x'/'pointer.y')`
127
+ already un-letterboxed; mouse buttons are bindable actions
128
+ (`mouse.left/right/middle`). See CONVENTIONS §Pointer & continuous input.
129
+ - **`splash: false`** in `defineGame` skips the built-in loading splash (e.g.
130
+ for embeds that paint their own cover).
131
+ - **The browser build is scriptable** — `?capture=1` exposes `window.__hayao`
132
+ (pump/probe/hash/shot), and `handle.tick()` single-steps the real loop; the
133
+ loop keeps running in hidden tabs (never patch rAF yourself). See
134
+ VERIFICATION §Channel 2.
123
135
 
124
136
  ## Where to go next
125
137
 
@@ -113,10 +113,40 @@ writeFileSync('shots/title.svg', r.toSVGString()); // a real vector screenshot
113
113
  ```
114
114
 
115
115
  Because rendering is SVG, the screenshot is **resolution-independent and text is
116
- crisp** — no canvas fuzz, no GPU. In the browser, `?capture=1` exposes
117
- `window.__hayao` (`pump`, `probe`, `hash`, `shot`, `save`, `key`) for scripted
118
- sessions and aesthetic captures but you rarely need it, because the DOM is
119
- already inspectable and the sim already runs in Node.
116
+ crisp** — no canvas fuzz, no GPU. You rarely need a browser at all, because the
117
+ DOM is already inspectable and the sim already runs in Node — but when a tool
118
+ must drive the real browser build, two seams exist:
119
+
120
+ **`?capture=1` → `window.__hayao`** (`src/verify/capture.ts`). Appending
121
+ `?capture=1` to a game's URL skips the splash and installs a scripted-session
122
+ API on `window`:
123
+
124
+ | Member | What it does |
125
+ |---|---|
126
+ | `pump(frames, actions?)` | Step exactly `frames` fixed steps with `actions` held (wall-clock loop suspended), then return `probe()` |
127
+ | `probe()` | The game's probe snapshot |
128
+ | `hash()` | `world.hash()` — the determinism hash |
129
+ | `shot()` | Current frame as an SVG string (a vector screenshot) |
130
+ | `save(path)` | POST that SVG to the dev server's `/__shot` endpoint; resolves `true` on success |
131
+ | `key(type, code)` | Dispatch a synthetic `keydown`/`keyup` by key code |
132
+ | `world` | The live `World` — full access when the shorthands aren't enough |
133
+
134
+ `probe` is whatever the game's `defineGame({ probe })` returns — expose exactly
135
+ the state a scripted session will assert on.
136
+
137
+ **`handle.tick(dtMs?)`** — the `runBrowser` handle can drive one frame by hand:
138
+ sample the pointer and extra sources, advance by `dtMs` (default one fixed
139
+ step) with the currently-held actions, render. It is the SAME code path the
140
+ wall-clock loop runs, so tool-driven frame stepping is the real loop, not a
141
+ simulation of it. The wall-clock loop keeps running independently — pause the
142
+ shell or hold via `RunOptions.isHeld` to make `tick()` the only driver.
143
+
144
+ **Hidden tabs and iframes keep ticking.** Browsers throttle rAF to death in
145
+ hidden tabs/iframes; the engine now owns the fallback — when `document.hidden`,
146
+ the loop pumps via `setTimeout(16)` and swaps back to native rAF on
147
+ visibility. Games and harnesses must **NOT** patch the global
148
+ `requestAnimationFrame` anymore: per-game patches double-fire and race the
149
+ engine's scheduler (the 2026-07 triage traced real bugs to exactly that).
120
150
 
121
151
  For MOTION, use the filmstrip instead of a single frame: it samples a whole
122
152
  run into one SVG contact sheet, so pacing, readability under load, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hayao",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "An AI-first game engine: a deterministic, headless-native simulation kernel with a Godot-style scene tree, pluggable renderers (SVG/Canvas/headless), and a built-in verification harness — designed so an LLM can author, test, and prove correct a whole game without ever opening a browser.",
6
6
  "keywords": [
@@ -87,6 +87,7 @@
87
87
  "@types/react": "^19.2.17",
88
88
  "@types/react-dom": "^19.2.3",
89
89
  "esbuild": "^0.28.0",
90
+ "happy-dom": "^20.10.6",
90
91
  "leva": "^0.10.1",
91
92
  "qrcode": "^1.5.4",
92
93
  "react": "^19.2.7",