hayao 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +10 -0
  2. package/bin/create-hayao.mjs +1 -1
  3. package/dist/anim/blend.d.ts +68 -0
  4. package/dist/anim/clip.d.ts +87 -0
  5. package/dist/anim/ik.d.ts +40 -0
  6. package/dist/anim/skeleton.d.ts +64 -0
  7. package/dist/app/browser.d.ts +33 -3
  8. package/dist/app/game.d.ts +15 -11
  9. package/dist/audio/audio.d.ts +18 -1
  10. package/dist/audio/synth.d.ts +7 -0
  11. package/dist/audio/zzfx.d.ts +14 -0
  12. package/dist/core/clock.d.ts +1 -1
  13. package/dist/core/projection.d.ts +36 -0
  14. package/dist/core/rng.d.ts +9 -0
  15. package/dist/hayao.global.js +190 -5
  16. package/dist/index.d.ts +20 -1
  17. package/dist/index.js +4159 -979
  18. package/dist/index.js.map +4 -4
  19. package/dist/index.min.js +190 -5
  20. package/dist/input/actions.d.ts +60 -6
  21. package/dist/input/gamepad.d.ts +101 -0
  22. package/dist/input/source.d.ts +85 -4
  23. package/dist/logic/coroutine.d.ts +68 -0
  24. package/dist/render/canvas.d.ts +3 -7
  25. package/dist/render/canvas2d-core.d.ts +13 -0
  26. package/dist/render/commands.d.ts +55 -2
  27. package/dist/render/lightRun.d.ts +35 -0
  28. package/dist/render/paint.d.ts +8 -0
  29. package/dist/render/renderer.d.ts +25 -0
  30. package/dist/render/svg.d.ts +2 -1
  31. package/dist/render/svgString.d.ts +8 -3
  32. package/dist/render/webgl.d.ts +176 -0
  33. package/dist/scene/clipPlayer.d.ts +58 -0
  34. package/dist/scene/ikTarget.d.ts +25 -0
  35. package/dist/scene/iso.d.ts +73 -0
  36. package/dist/scene/light.d.ts +80 -0
  37. package/dist/scene/node.d.ts +81 -2
  38. package/dist/scene/nodes.d.ts +34 -0
  39. package/dist/scene/particles.d.ts +19 -0
  40. package/dist/scene/shadow2d.d.ts +25 -0
  41. package/dist/scene/skeletonDebug.d.ts +28 -0
  42. package/dist/scene/verletChain.d.ts +76 -0
  43. package/dist/ui/touch.d.ts +51 -0
  44. package/dist/verify/dom.d.ts +26 -0
  45. package/dist/verify/gates.d.ts +25 -0
  46. package/dist/world.d.ts +72 -7
  47. package/docs/API.md +138 -27
  48. package/docs/CONVENTIONS.md +346 -2
  49. package/docs/EMBED.md +5 -0
  50. package/docs/QUICKSTART.md +13 -1
  51. package/docs/VERIFICATION.md +34 -4
  52. package/package.json +2 -1
package/README.md CHANGED
@@ -199,6 +199,16 @@ pathfinding, procgen, synth, juice) for learning one primitive in isolation.
199
199
  of the engine and what generalized lives in
200
200
  [docs/BUILDLOG.md](https://github.com/hellojanpacan/hayao-js/blob/main/docs/BUILDLOG.md).
201
201
 
202
+ Since v0.3 (on main, toward 0.4.0): a screen-space HUD layer
203
+ (`node.screenSpace`), pause + time scale (`world.paused`/`timeScale`,
204
+ `node.pauseMode`), deterministic generator coroutines, typed `world.state`
205
+ (`defineGame<TState>`), ellipse/arc/lineDash rendering, a ZzFX porting bridge
206
+ (`specFromZzfx`), pointer-button actions, and a built-in hidden-tab ticker —
207
+ driven by the js13k recreation triage in
208
+ [docs/TRIAGE-2026-07-recreations.md](https://github.com/hellojanpacan/hayao-js/blob/main/docs/TRIAGE-2026-07-recreations.md);
209
+ authoring contracts documented in
210
+ [docs/ENGINE.md](https://github.com/hellojanpacan/hayao-js/blob/main/docs/ENGINE.md).
211
+
202
212
  ## License
203
213
 
204
214
  [MIT](LICENSE) © Jan Pacan
@@ -37,7 +37,7 @@ const files = {
37
37
  verify: 'tsx verify.ts',
38
38
  build: 'vite build',
39
39
  },
40
- dependencies: { hayao: '^0.3' },
40
+ dependencies: { hayao: '^0.4' },
41
41
  devDependencies: {
42
42
  '@types/node': '^22.10.0',
43
43
  tsx: '^4.19.0',
@@ -0,0 +1,68 @@
1
+ import { type ClipDef } from './clip';
2
+ /** A sampled pose: "target/channel" → value. Plain object; order-insensitive. */
3
+ export type Pose = Record<string, number>;
4
+ /** Compose a pose key from a track's target + channel. */
5
+ export declare const poseKey: (target: string, channel: string) => string;
6
+ /**
7
+ * Sample a whole clip into a Pose at NORMALIZED phase `phase` in [0,1) — the
8
+ * blend-space entry point. `phase * duration` is the clip-local time (so two
9
+ * clips of different length stay in step). Pure; allocates one fresh Pose.
10
+ */
11
+ export declare function samplePose(def: ClipDef, phase: number): Pose;
12
+ /**
13
+ * Linear blend of two poses: `out[k] = lerp(a[k], b[k], t)`. Keys present in only
14
+ * one pose pass through unblended (the other side has no opinion). When `out` is
15
+ * supplied it is reused (cleared then filled) — the allocation-free path
16
+ * ClipPlayer takes during a crossfade. Returns `out`.
17
+ */
18
+ export declare function mixPose(a: Pose, b: Pose, t: number, out?: Pose): Pose;
19
+ /** One entry of a blend space: a clip pinned at a parameter position. */
20
+ export interface BlendSample {
21
+ clip: ClipDef;
22
+ /** Parameter position of this sample (e.g. speed). Blend1D reads `.x` only. */
23
+ x: number;
24
+ /** Second parameter (Blend2D only). */
25
+ y?: number;
26
+ }
27
+ /**
28
+ * A 1-D blend space (e.g. idle→walk→run along speed). Samples are sorted by `x`
29
+ * on construction; `sample(param, phase)` finds the bracketing pair and mixes
30
+ * them by the normalized distance, all clips sampled at the SAME phase (foot-lock).
31
+ * Below the first / above the last sample it clamps to the end clip.
32
+ */
33
+ export declare class Blend1D {
34
+ readonly samples: BlendSample[];
35
+ constructor(samples: BlendSample[]);
36
+ /** The clip weights at parameter `param` (sums to 1; empty space → {}). */
37
+ weights(param: number): {
38
+ clip: ClipDef;
39
+ weight: number;
40
+ }[];
41
+ /** Blend the neighbor clips at `param`, all sampled at normalized `phase`. */
42
+ sample(param: number, phase: number): Pose;
43
+ }
44
+ /**
45
+ * A 2-D blend space (e.g. a locomotion pad: forward/back × left/right). No
46
+ * triangulation library — we pick the NEAREST THREE samples to the query point
47
+ * and blend them barycentrically; if the point is outside their triangle the
48
+ * barycentric weights are clamped to >= 0 and renormalized (a documented
49
+ * approximation: no Delaunay, so a sparse/concave set can pick a non-ideal trio).
50
+ */
51
+ export declare class Blend2D {
52
+ readonly samples: Required<BlendSample>[];
53
+ constructor(samples: BlendSample[]);
54
+ /** Clip weights at (px,py): nearest-3 barycentric, clamped + renormalized. */
55
+ weights(px: number, py: number): {
56
+ clip: ClipDef;
57
+ weight: number;
58
+ }[];
59
+ private barycentric;
60
+ /** Blend the nearest-3 clips at (px,py), all sampled at normalized `phase`. */
61
+ sample(px: number, py: number, phase: number): Pose;
62
+ }
63
+ /**
64
+ * Validate a blend space's samples (levelIssues idiom). Catches empty spaces,
65
+ * duplicate parameter positions (a degenerate bracket / triangle), and non-finite
66
+ * coordinates. `dims` selects which axes must be finite.
67
+ */
68
+ export declare function blendIssues(samples: BlendSample[], dims?: 1 | 2): string[];
@@ -0,0 +1,87 @@
1
+ import { EASINGS } from '../scene/tween';
2
+ /** Which transform channel a track drives. */
3
+ export type ClipChannel = 'x' | 'y' | 'rotation' | 'scaleX' | 'scaleY' | 'opacity';
4
+ export declare const CLIP_CHANNELS: readonly ClipChannel[];
5
+ /** How the playhead behaves past `duration`. */
6
+ export type ClipLoop = 'loop' | 'once' | 'pingpong';
7
+ /** One keyframe: value `v` at time `t` (seconds), with the ease used to reach it FROM the previous key. */
8
+ export interface Keyframe {
9
+ t: number;
10
+ v: number;
11
+ /** EASINGS name applied over the segment ENDING at this key. Default 'linear'. */
12
+ ease?: keyof typeof EASINGS;
13
+ }
14
+ /** One track: a channel of one target node, driven by an ordered keyframe list. */
15
+ export interface TrackDef {
16
+ /** '/'-separated path from the rig root to the target node (matched by Node.name). */
17
+ target: string;
18
+ channel: ClipChannel;
19
+ /** Keyframes, ascending in `t`. At least one. */
20
+ keys: Keyframe[];
21
+ }
22
+ /** A discrete event fired as the playhead crosses `t` (half-open (t0,t1] window). */
23
+ export interface ClipEvent {
24
+ t: number;
25
+ name: string;
26
+ }
27
+ /** A full clip: duration, loop mode, tracks, optional events. Plain data. */
28
+ export interface ClipDef {
29
+ /** Clip length in seconds (> 0). */
30
+ duration: number;
31
+ loop: ClipLoop;
32
+ tracks: TrackDef[];
33
+ events?: ClipEvent[];
34
+ /** RESERVED — root motion is not implemented (see file header). */
35
+ rootMotion?: never;
36
+ }
37
+ /**
38
+ * Map a raw elapsed time to a clip-local time in [0, duration], honoring the
39
+ * loop mode. `once` clamps at both ends; `loop` wraps; `pingpong` folds so the
40
+ * playhead ping-pongs 0→d→0. Pure and total (guards duration <= 0 → 0).
41
+ */
42
+ export declare function clipTime(def: ClipDef, elapsed: number): number;
43
+ /** True once a `once` clip has run past its end (used by ClipPlayer to fire `finished`). */
44
+ export declare function clipFinished(def: ClipDef, elapsed: number): boolean;
45
+ /**
46
+ * Binary-search the keyframe segment containing local time `t` and return the
47
+ * eased-interpolated value. Before the first key → first value; after the last →
48
+ * last value (clips are sampled at a clamped clipTime, so this only bites at the
49
+ * exact ends). Allocation-free: no array building, no closures.
50
+ */
51
+ export declare function sampleTrack(keys: Keyframe[], t: number): number;
52
+ /**
53
+ * A sampled pose channel: which target/channel and the value. `sampleClip`
54
+ * returns one per track. Kept flat (not a nested object) so ClipPlayer can bind
55
+ * each to a resolved node once and apply in O(tracks) with no lookups.
56
+ */
57
+ export interface SampledChannel {
58
+ target: string;
59
+ channel: ClipChannel;
60
+ value: number;
61
+ }
62
+ /**
63
+ * PURE sample of the whole clip at raw `elapsed` seconds → one SampledChannel per
64
+ * track. Never mutates `def`. Two calls with the same args are deep-equal. This
65
+ * is the reference sampler; ClipPlayer uses a prebound, allocation-free variant
66
+ * of the same math for the hot path.
67
+ */
68
+ export declare function sampleClip(def: ClipDef, elapsed: number): SampledChannel[];
69
+ /**
70
+ * Events crossed as the playhead advances from raw `prev` to raw `next`, in the
71
+ * order they fire. FIXED SEMANTICS:
72
+ * - half-open window `(t0, t1]` in clip-local time (an event exactly at the new
73
+ * time fires; one exactly at the old time does NOT re-fire);
74
+ * - `loop` wrap emits the TAIL segment (prevLocal, duration] then the HEAD
75
+ * segment (0, nextLocal];
76
+ * - `once` never re-fires an event once the playhead is at/after duration;
77
+ * - order within a segment is ascending by `t`, tail before head on a wrap.
78
+ * Pure; returns event names in fire order.
79
+ */
80
+ export declare function clipEvents(def: ClipDef, prev: number, next: number): string[];
81
+ /**
82
+ * Validate a clip's DATA, in the levelIssues/tuningIssues idiom: return ALL
83
+ * problems as human-readable strings (empty = clean), never throw. Optionally
84
+ * pass the set of valid target paths (`knownTargets`) to catch tracks that point
85
+ * at a node the rig doesn't contain.
86
+ */
87
+ export declare function clipIssues(def: ClipDef, knownTargets?: readonly string[]): string[];
@@ -0,0 +1,40 @@
1
+ import type { Vec2 } from '../core/math';
2
+ /** Result of a 2-bone solve: absolute WORLD-space angles (radians) for each joint. */
3
+ export interface TwoBoneResult {
4
+ /** Absolute angle of bone 0 (root→mid). */
5
+ angle0: number;
6
+ /** Absolute angle of bone 1 (mid→end). */
7
+ angle1: number;
8
+ /** True if the target was in range; false → chain clamped to full extension toward it. */
9
+ reachable: boolean;
10
+ }
11
+ /**
12
+ * Analytic two-bone IK. `root` is the base joint (world), `l0`/`l1` the bone
13
+ * lengths, `target` the desired end-effector position (world). `bendDir` is +1
14
+ * or −1 to choose which side the middle joint bends to (elbow up/down). Returns
15
+ * the two ABSOLUTE bone angles (add to nothing — they are world angles; convert
16
+ * to local by subtracting the parent's world angle at the call site).
17
+ */
18
+ export declare function solveTwoBoneIK(root: Vec2, l0: number, l1: number, target: Vec2, bendDir?: 1 | -1): TwoBoneResult;
19
+ export interface FabrikResult {
20
+ /** Joint positions (world), joints[0] === root, joints[n] === end effector. */
21
+ joints: Vec2[];
22
+ /** Absolute bone angles (radians), one per bone (length joints.length - 1). */
23
+ angles: number[];
24
+ reachable: boolean;
25
+ /** Iterations actually run before convergence (or the cap). */
26
+ iterations: number;
27
+ }
28
+ /**
29
+ * FABRIK N-bone solver. `root` anchors joint 0; `lengths` are the bone lengths
30
+ * (n bones → n+1 joints); `target` is the desired end position. Alternates a
31
+ * backward reach (pull the end to the target, propagate inward) and a forward
32
+ * reach (pin the root, propagate outward) until the end is within `tolerance` of
33
+ * the target or `maxIter` is hit. When the target is unreachable the chain is
34
+ * laid out straight toward it (reachable=false). Pure; deterministic (dmath).
35
+ */
36
+ export declare function solveFabrik(root: Vec2, lengths: number[], target: Vec2, opts?: {
37
+ maxIter?: number;
38
+ tolerance?: number;
39
+ initial?: Vec2[];
40
+ }): FabrikResult;
@@ -0,0 +1,64 @@
1
+ import { Node, type NodeConfig } from '../scene/node';
2
+ import type { ClipChannel, ClipDef, Keyframe } from './clip';
3
+ export interface Bone2DConfig extends NodeConfig {
4
+ /** Bone reach in local px (root→tip along +x at rotation 0). Default 0. */
5
+ length?: number;
6
+ }
7
+ /**
8
+ * A rig joint: a Node with a `length`. Rotation is the joint angle; children
9
+ * inherit the transform, so a chain of Bone2Ds nested tip-to-root forms a limb.
10
+ * Registered (`Bone2D`) so a serialized rig round-trips — `length` persists.
11
+ */
12
+ export declare class Bone2D extends Node {
13
+ readonly type = "Bone2D";
14
+ /** Reach in local px along +x. Used by IK, skinning, and the debug overlay. */
15
+ length: number;
16
+ constructor(config?: Bone2DConfig);
17
+ /** Tip position in LOCAL space (root is the node origin; tip is `length` along +x). */
18
+ get tip(): {
19
+ x: number;
20
+ y: number;
21
+ };
22
+ protected serializeProps(): Record<string, unknown>;
23
+ applyProps(props: Record<string, unknown>): void;
24
+ }
25
+ /**
26
+ * An index of a rig subtree: '/'-separated name path → node. Paths are relative
27
+ * to the rig root passed to `buildSkeleton` (the root itself is '' and its direct
28
+ * children are just their names). Ordered iteration only (a plain Map, built by a
29
+ * deterministic DFS).
30
+ */
31
+ export declare class Skeleton {
32
+ readonly root: Node;
33
+ readonly byPath: Map<string, Node>;
34
+ constructor(root: Node);
35
+ private index;
36
+ /** Resolve a track/IK target path to a node (null if the rig lacks it). */
37
+ resolve(path: string): Node | null;
38
+ /** All target paths in the rig — feed to `clipIssues(def, skeleton.targets())`. */
39
+ targets(): string[];
40
+ /** Only the Bone2D joints, by path (for IK / skinning). */
41
+ bones(): Bone2D[];
42
+ }
43
+ /** Build a skeleton index from a rig root. */
44
+ export declare function buildSkeleton(root: Node): Skeleton;
45
+ /**
46
+ * A track prebound to its target node: the resolved Node, the channel, and the
47
+ * clip's keyframes captured by reference. ClipPlayer holds an array of these and
48
+ * applies each with a single write in the hot path — no path lookup, no find().
49
+ */
50
+ export interface BoundTrack {
51
+ node: Node;
52
+ channel: ClipChannel;
53
+ keys: Keyframe[];
54
+ /** The track's path, kept for diagnostics / rebind. */
55
+ target: string;
56
+ }
57
+ /**
58
+ * Resolve every track of a clip against a skeleton ONCE. Tracks whose target the
59
+ * rig doesn't contain are dropped (surfaced separately by clipIssues) so the hot
60
+ * path never guards for a null node. Returns bound tracks in clip order.
61
+ */
62
+ export declare function resolveTracks(def: ClipDef, skeleton: Skeleton): BoundTrack[];
63
+ /** Apply one channel's value to a node's transform (the single hot-path write). */
64
+ export declare function applyChannel(node: Node, channel: ClipChannel, value: number): void;
@@ -1,10 +1,11 @@
1
1
  import type { CreateWorldOptions, GameDefinition } from './game';
2
- import { KeyboardSource, PointerSource } from '../input/source';
2
+ import { KeyboardSource, PointerSource, type InputSource } from '../input/source';
3
3
  import type { Vec2 } from '../core/math';
4
- import type { Renderer } from '../render/renderer';
4
+ import type { Renderer, Viewport } from '../render/renderer';
5
5
  import type { World } from '../world';
6
6
  export interface RunOptions {
7
- renderer?: 'svg' | 'canvas';
7
+ /** Rendering backend. Default 'svg'. Use 'webgl' for post-processing effects. */
8
+ renderer?: 'svg' | 'canvas' | 'webgl';
8
9
  /** Start the pause/settings shell (Esc). Default true. */
9
10
  shell?: boolean;
10
11
  onRestart?: () => void;
@@ -24,6 +25,13 @@ export interface RunOptions {
24
25
  * steps (no pause overlay — Studio's scrubber holds the sim with this).
25
26
  */
26
27
  isHeld?: () => boolean;
28
+ /**
29
+ * Extra input sources (e.g. GamepadSource) sampled each step alongside the
30
+ * built-in KeyboardSource and PointerSource. Each source's sample() is called
31
+ * before world.advance(), and dispose() on stop(). You can also add sources
32
+ * after the handle is returned via GameHandle.addSource().
33
+ */
34
+ sources?: InputSource[];
27
35
  }
28
36
  export interface GameHandle {
29
37
  world: World;
@@ -40,10 +48,32 @@ export interface GameHandle {
40
48
  canvas: HTMLElement | SVGElement | undefined;
41
49
  /** Map a pointer event's clientX/Y to design coordinates (undoes the letterbox). */
42
50
  toDesign(clientX: number, clientY: number): Vec2;
51
+ /** The drawn (letterboxed) area within the mount — anchor host-drawn UI here. */
52
+ viewport(): Viewport | undefined;
43
53
  /** Resolves after preload completes and the first real frame has rendered. */
44
54
  ready: Promise<void>;
45
55
  /** Run a callback once the game is ready (fires immediately if already ready). */
46
56
  onReady(cb: () => void): void;
57
+ /**
58
+ * Register an extra input source (e.g. GamepadSource) to be sampled each step
59
+ * alongside PointerSource. Returns a cleanup function that removes the source
60
+ * (calling its dispose()) when invoked.
61
+ *
62
+ * Typical pattern:
63
+ * const handle = runBrowser(def, mount);
64
+ * const gamepad = new GamepadSource({ keyboard: handle.input });
65
+ * const removeGamepad = handle.addSource(gamepad);
66
+ */
67
+ addSource(source: InputSource): () => void;
68
+ /**
69
+ * Drive one frame by hand: sample the pointer (and extra sources), advance
70
+ * the world by dtMs with the currently-held actions, and render — exactly
71
+ * what the real loop does per frame. Default dtMs is one fixed step's ms.
72
+ * For headless/tool-driven frame-stepping with visuals updating; the wall-
73
+ * clock loop keeps running independently (pause the shell or use isHeld to
74
+ * make tick() the only driver).
75
+ */
76
+ tick(dtMs?: number): void;
47
77
  stop(): void;
48
78
  restart(): void;
49
79
  }
@@ -19,7 +19,7 @@ export interface SplashConfig {
19
19
  /** Keep the splash up at least this long, so a fast preload doesn't flash by. */
20
20
  minDurationMs?: number;
21
21
  }
22
- export interface GameDefinition {
22
+ export interface GameDefinition<TState extends Record<string, unknown> = Record<string, unknown>> {
23
23
  title: string;
24
24
  width?: number;
25
25
  height?: number;
@@ -28,9 +28,9 @@ export interface GameDefinition {
28
28
  clock?: ClockConfig;
29
29
  inputMap?: InputMap;
30
30
  /** Build the initial scene tree for a fresh world. */
31
- build(world: World): Node;
31
+ build(world: World<TState>): Node;
32
32
  /** Optional compact probe snapshot for verification (defaults to World.probe). */
33
- probe?(world: World): Record<string, unknown>;
33
+ probe?(world: World<TState>): Record<string, unknown>;
34
34
  /**
35
35
  * Live-tunable parameters, declared once. Defaults ARE the config; Studio and
36
36
  * tests override them via `createWorld(def, { tuning })` and the sim reads
@@ -42,18 +42,22 @@ export interface GameDefinition {
42
42
  * from data (closures do not survive a restore). One contract serves every
43
43
  * carryover path: Studio knob changes, variant toggles, HMR, and net rollback.
44
44
  */
45
- attach?(world: World): void;
45
+ attach?(world: World<TState>): void;
46
46
  /**
47
47
  * Awaited before the world starts stepping — load fonts, sprite atlases, a
48
48
  * SoundFont, anything async. The engine holds a splash on screen until it
49
49
  * resolves, so there is no asset pop-in and no ungoverned pre-first-frame window.
50
50
  */
51
- preload?(world: World): Promise<void>;
51
+ preload?(world: World<TState>): Promise<void>;
52
52
  /** Boot splash config, or `false` to start on the first frame with no cover. */
53
53
  splash?: SplashConfig | false;
54
54
  }
55
- /** Identity + defaults. Kept as a function so games read `export default defineGame({…})`. */
56
- export declare function defineGame(def: GameDefinition): Required<Pick<GameDefinition, 'width' | 'height' | 'seed' | 'inputMap' | 'background'>> & GameDefinition;
55
+ /**
56
+ * Identity + defaults. Kept as a function so games read `export default defineGame({…})`.
57
+ * Pass a state shape for a typed world: `defineGame<{ score: number }>({ … })`
58
+ * types `world.state` in build/probe/attach/preload (default keeps untyped calls as-is).
59
+ */
60
+ export declare function defineGame<TState extends Record<string, unknown> = Record<string, unknown>>(def: GameDefinition<TState>): Required<Pick<GameDefinition<TState>, 'width' | 'height' | 'seed' | 'inputMap' | 'background'>> & GameDefinition<TState>;
57
61
  export interface CreateWorldOptions {
58
62
  seed?: number;
59
63
  /** Overrides for declared tuning knobs (undeclared keys are dropped). */
@@ -63,11 +67,11 @@ export interface CreateWorldOptions {
63
67
  * Build a live, deterministic World from a game definition. No browser needed.
64
68
  * `opts` as a bare number is the legacy seed override.
65
69
  */
66
- export declare function createWorld(def: GameDefinition, opts?: number | CreateWorldOptions): World;
70
+ export declare function createWorld<TState extends Record<string, unknown> = Record<string, unknown>>(def: GameDefinition<TState>, opts?: number | CreateWorldOptions): World<TState>;
67
71
  /** The input map a game uses (with defaults applied). */
68
72
  export declare function gameInputMap(def: GameDefinition): InputMap;
69
- export interface HeadlessResult {
70
- world: World;
73
+ export interface HeadlessResult<TState extends Record<string, unknown> = Record<string, unknown>> {
74
+ world: World<TState>;
71
75
  hash: string;
72
76
  steps: number;
73
77
  }
@@ -76,4 +80,4 @@ export interface HeadlessResult {
76
80
  * steps) and return the final world + state hash. This is what tests, the CI
77
81
  * verifier, and replays call. The browser is never involved.
78
82
  */
79
- export declare function runHeadless(def: GameDefinition, inputLog?: InputLog): HeadlessResult;
83
+ export declare function runHeadless<TState extends Record<string, unknown> = Record<string, unknown>>(def: GameDefinition<TState>, inputLog?: InputLog): HeadlessResult<TState>;
@@ -17,12 +17,29 @@ export interface Tone {
17
17
  pan?: number;
18
18
  }
19
19
  export declare class AudioBus {
20
- private ctx;
20
+ private _ctx;
21
21
  private master;
22
22
  private musicGain;
23
23
  private sfxGain;
24
24
  private vol;
25
25
  private padOn;
26
+ /**
27
+ * The live AudioContext (null until `start()` unlocks audio). External
28
+ * synths being ported in (bundled ZzFX/ZzFXM, hand-rolled Web Audio) should
29
+ * create their sources ON this context — never their own — so they inherit
30
+ * the autoplay unlock the user gesture already paid for.
31
+ */
32
+ get ctx(): AudioContext | null;
33
+ /**
34
+ * The SFX mix bus (null until `start()`). Connect external one-shot sources
35
+ * here instead of `ctx.destination` so master/sfx volume and mute apply.
36
+ */
37
+ get sfxBus(): AudioNode | null;
38
+ /**
39
+ * The music mix bus (null until `start()`). Connect external music sources
40
+ * (e.g. a ZzFXM-rendered buffer) here so master/music volume and mute apply.
41
+ */
42
+ get musicBus(): AudioNode | null;
26
43
  get available(): boolean;
27
44
  get started(): boolean;
28
45
  /** Must be called from a user gesture (browser autoplay policy). */
@@ -28,6 +28,13 @@ export interface SoundSpec {
28
28
  pitchJumpTime?: number;
29
29
  vibrato?: number;
30
30
  vibratoFreq?: number;
31
+ /**
32
+ * Pitch-envelope repeat period, seconds (ZzFX `repeatTime`). Every `repeat`
33
+ * seconds the slide/slideAccel/pitchJump progression resets to its start
34
+ * while the amplitude envelope keeps going — the classic ZzFX stutter/trill.
35
+ * 0 or absent = off (bit-identical to the pre-`repeat` renderer).
36
+ */
37
+ repeat?: number;
31
38
  /** Second detuned oscillator, cents (0 = off). Adds unison warmth/chorus. */
32
39
  detune?: number;
33
40
  /** Sub-oscillator: a sine one octave down, mixed at this level 0..1. Adds weight. */
@@ -0,0 +1,14 @@
1
+ import type { SoundSpec } from './synth';
2
+ /** Test hook: clear the warn-once memory. */
3
+ export declare function resetZzfxWarnings(): void;
4
+ /**
5
+ * Convert a positional ZzFX parameter array to a hayao SoundSpec.
6
+ * Argument order (defaults in parens) is ZzFX's `zzfx(...)` signature:
7
+ * volume(1), randomness(.05), frequency(220), attack(0), sustain(0),
8
+ * release(.1), shape(0), shapeCurve(1), slide(0), deltaSlide(0), pitchJump(0),
9
+ * pitchJumpTime(0), repeatTime(0), noise(0), modulation(0), bitCrush(0),
10
+ * delay(0), sustainVolume(1), decay(0), tremolo(0), filter(0).
11
+ * Holes/undefined take the ZzFX defaults, so `[,,1675,,.06,.24,1,1.82,,,837,.06]`
12
+ * pastes straight in.
13
+ */
14
+ export declare function specFromZzfx(p: readonly (number | undefined)[]): SoundSpec;
@@ -7,7 +7,7 @@ export interface ClockConfig {
7
7
  export declare class Clock {
8
8
  readonly stepMs: number;
9
9
  readonly dt: number;
10
- private maxFrameMs;
10
+ readonly maxFrameMs: number;
11
11
  private accumulator;
12
12
  private _frame;
13
13
  private _simTimeMs;
@@ -0,0 +1,36 @@
1
+ import type { Vec2 } from './math';
2
+ export interface IsoConfig {
3
+ /** Full width of a tile diamond in screen px (the horizontal diagonal). */
4
+ tileW: number;
5
+ /** Full height of a tile diamond in screen px (the vertical diagonal). 2:1 iso ⇒ tileW/2. */
6
+ tileH: number;
7
+ /** Screen px where grid cell (0,0)'s CENTER lands. Default (0,0). */
8
+ origin?: Vec2;
9
+ /** Screen px a tile rises per unit of elevation. Default tileH (one tile-height per level). */
10
+ elevStep?: number;
11
+ }
12
+ /**
13
+ * A parameterised grid↔screen projection. `toScreen` places a (possibly raised)
14
+ * cell centre; `toGrid` inverts the ground plane (elev 0) to FRACTIONAL grid
15
+ * coordinates — floor them for the containing tile. Round-trips exactly for
16
+ * every integer cell. Covers 2:1 iso (tileH = tileW/2), true dimetric, and
17
+ * top-down/oblique (tileH = tileW) by choice of tile size.
18
+ */
19
+ export interface IsoProjection {
20
+ readonly tileW: number;
21
+ readonly tileH: number;
22
+ readonly origin: Vec2;
23
+ readonly elevStep: number;
24
+ /** Grid cell → screen-space centre. `elev` shifts the point up by `elevStep` per unit. */
25
+ toScreen(gx: number, gy: number, elev?: number): Vec2;
26
+ /** Screen point → fractional grid coords on the ground plane (floor for the tile). */
27
+ toGrid(sx: number, sy: number): Vec2;
28
+ }
29
+ /**
30
+ * Build an isometric/dimetric/oblique projection. Diamond lattice:
31
+ * screenX = origin.x + (gx - gy) · tileW/2
32
+ * screenY = origin.y + (gx + gy) · tileH/2 − elev · elevStep
33
+ * The inverse solves the 2×2 system directly, so `toGrid(toScreen(g)) === g` for
34
+ * all integer cells (no accumulated float drift).
35
+ */
36
+ export declare function iso(cfg: IsoConfig): IsoProjection;
@@ -34,5 +34,14 @@ export declare class Rng {
34
34
  getState(): RngState;
35
35
  setState(state: RngState): void;
36
36
  }
37
+ /**
38
+ * Stateless deterministic noise: hash any mix of numbers to a float in [0,1).
39
+ * Same inputs ALWAYS give the same output and no stream is consumed, so it is
40
+ * safe to call from `draw()` for per-entity cosmetic variation (jitter, phase
41
+ * offsets, hue nudges) — `hashNoise(entity.id, ctx.frame)` never touches
42
+ * `world.rng` and cannot desync a replay. Mixes the full float bits of each
43
+ * value through a strong integer hash (murmur3-style avalanche).
44
+ */
45
+ export declare function hashNoise(...values: number[]): number;
37
46
  /** Deterministic 32-bit FNV-1a hash of a string — handy for stable ids/seeds. */
38
47
  export declare function hashString(str: string): number;