import { a as SceneJson, c as JsonValue, o as JsonKind, s as JsonObject, t as Rng } from "./rng-BsXZg3D6.js"; //#region src/core/signal.d.ts /** * Minimal typed observer used for every engine event (Godot signal semantics). * * Emission is snapshot-based: listeners added during an emit do not fire in that * emit, and listeners removed during an emit are skipped. */ type SignalListener = (...args: Args) => void; declare class Signal { private connections; /** Connect a listener. Returns a disposer. Connecting the same fn twice is a no-op. */ connect(fn: SignalListener, opts?: { once?: boolean; }): () => void; disconnect(fn: SignalListener): void; disconnectAll(): void; /** * How many listeners are attached. * * A leak is a SLOPE, and a game had no way to see one: every check in this * engine is a snapshot. A handler connected per frame and never disconnected, * or a `once` that armed and never fired, accumulates invisibly and is * perfectly healthy for the first thirty seconds of every playthrough. */ get count(): number; emit(...args: Args): void; get connectionCount(): number; } //#endregion //#region src/core/log.d.ts type LogLevel = "debug" | "info" | "warn" | "error"; interface LogEntry { /** Monotonic per-manager sequence number (1-based). */ readonly seq: number; /** Wall-clock-ish timestamp (performance.now when available) — diagnostics only. */ readonly timeMs: number; readonly level: LogLevel; /** The raw logged values, untouched — consumers format. */ readonly parts: readonly unknown[]; } /** * The engine's log channel (`engine.log`, `this.log` in a Behavior): a ring * buffer plus a live `added` signal, so debug overlays and headless test * harnesses can tail game logs without scraping the browser console. */ declare class LogManager { /** Fires once per entry, after it is buffered. */ readonly added: Signal<[LogEntry]>; private readonly buffer; private readonly capacity; private seq; constructor(capacity?: number); debug(...parts: unknown[]): void; info(...parts: unknown[]): void; warn(...parts: unknown[]): void; error(...parts: unknown[]): void; /** Buffered entries, oldest first (capped at capacity). */ entries(): readonly LogEntry[]; /** Empty the buffer. The sequence keeps counting (entries stay unique). */ clear(): void; private push; } //#endregion //#region src/core/scene-tree.d.ts /** * Owns a node tree and drives its lifecycle: * * - `setRoot` → onEnterTree parent-first, then onReady children-first (once per instance) * - `update`/`fixedUpdate` → parent-first traversal, then flush of queued frees * - group queries across attached nodes * * Headless by design — tests step it manually; the render loop (M2) calls it. */ declare class SceneTree { private _root; private _engine; private readonly freeQueue; /** * name → attached nodes with that name. Maintained by Node enter/exit/rename * so `%name` targeting and root-level getNodesByName are O(1) — behaviors * resolve targets every frame and a full-tree walk per chaser melted horde * scenes (N chasers × whole tree, 60×/s). */ private readonly _nameIndex; /** * group → the ATTACHED nodes in it, maintained the way names are. * * `getNodesInGroup` was an un-indexed full-tree walk that allocated a fresh * array per call, while the sibling name lookup was made O(1) for exactly * this reason. The one engine behavior that calls it every frame — * `FaceTarget`, the documented tower-defence turret — therefore cost * turrets x TOTAL SCENE NODES per frame: a 40-turret map in a 5,000-node * world walked 200,000 nodes a frame to find 40 of them, and allocated 40 * arrays doing it. */ private readonly _groupIndex; /** * uid → node, maintained the way names are. * * `getNodeByUid` was a depth-first walk of the whole tree, and `restoreState` * calls it once per saved uid plus once per freed uid — so loading a save * was `nodes x saved-keys`. A 3,000-node world with 200 saved behaviors * walked 600,000 nodes to hand back 200 values, on the frame the player is * already waiting through a level load. */ private readonly _uidIndex; /** * Templates a spawner has detached, by the path they were detached FROM. * * A spawner takes its prefab out of the live tree so it stops ticking. The * second spawner pointing at the same template then found nothing and * hard-failed the scene: * * Spawner on '/World/B': "prefab" '../Coin' does not resolve to a template * * One shared coin between two spawners is an ordinary thing to author, so the * tree remembers where each template went. Scene-scoped, like everything * else here — a new scene starts with none. * @internal */ readonly _templates: Map; /** * Bumped whenever ANY node attaches or detaches — cheap "did the tree * change shape?" check for per-step body gathers and similar caches. */ _structureVersion: number; /** * WHAT changed, not just that something did. * * The version alone forces every consumer into a full-tree re-walk, and the * physics step is gated on it: one bullet spawned per frame means * `collectBodies` + `collectJoints` + `syncScatters` walk the entire scene at * 60 Hz. A bullet-hell or a survivors-like does nothing else all game. * * This log lets a consumer replay just the attaches and detaches it has not * seen. It is bounded — a scene load appends thousands of entries and nobody * benefits from replaying those — so a consumer that falls behind * `_structureLogBase` takes the full walk, exactly as before. Correctness does * not depend on the log; only the fast path does. */ private _structureLog; /** The `_structureVersion` the first entry in the log corresponds to. */ private _structureLogBase; private static readonly STRUCTURE_LOG_MAX; /** * Attaches and detaches since `sinceVersion`, or null when the log no longer * reaches back that far (take the full walk). * * The returned array is the live buffer's tail — do not hold it across frames. */ _structureSince(sinceVersion: number): ReadonlyArray<{ node: Node; added: boolean; }> | null; private _logStructure; private _frameId; /** * Monotonic update-frame counter — bumped once per `update()`. Per-frame * caches (e.g. the 3d grass-bender body gather) key off this so they rebuild * at most once a frame regardless of node visitation order. */ get frameId(): number; /** @internal Node attach hook. */ _indexName(node: Node): void; /** @internal A uid was assigned or changed on an ATTACHED node. */ _reindexUid(node: Node, oldUid: string | null): void; /** @internal The node carrying this uid, or undefined. */ _nodeWithUid(uid: string): Node | undefined; /** @internal A node joined a group, or arrived already in one. */ _indexGroup(node: Node, group: string): void; /** @internal A node left a group, or left the tree. */ _unindexGroup(node: Node, group: string): void; /** * @internal A RENAME: same node, same place, different key in the name index. * * Deliberately not logged as a detach + attach. It is not a structural change, * and treating it as one would make the physics step tear down and rebuild the * node's Rapier body for a name edit. */ _reindexName(node: Node, oldName: string): void; /** @internal Node detach hook (`name` = the entry to remove). */ _unindexName(node: Node, name?: string): void; /** @internal Every ATTACHED node named `name` (undefined = none). */ _nodesNamed(name: string): ReadonlySet | undefined; /** * @internal Opaque per-frame scratch the 3d layer parks its shared moving-body * snapshot on (kept here so it is per-tree-instance, never a module global — * core stays three-free, the 3d resolver owns the shape). */ _frameScratch: unknown; get root(): Node | null; /** The engine driving this tree (set by Engine.setScene), or null. */ get engine(): Engine | null; /** @internal */ _setEngine(engine: Engine | null): void; setRoot(node: Node): void; /** * Has the initial load finished? * * `false` through `setRoot`'s enter/ready pass — where a throw is a load * error and must stay one — and `true` for everything after it. * @internal */ _live: boolean; update(dt: number): void; fixedUpdate(dt: number): void; getNodesInGroup(group: string): Node[]; /** Call `method(...args)` on every group member that implements it. */ callGroup(group: string, method: string, ...args: unknown[]): void; /** @internal */ _queueFree(node: Node): void; /** @internal Called by Node.free(): a freed root must not be re-drivable. */ _detachRoot(node: Node): void; /** * @internal Is this node already on its way out? * * A save taken from a `collected` handler runs BEFORE the deferred free, so * the pickup that triggered the checkpoint is still in the tree — and came * back on the next run while its score did not. */ _isQueuedFree(node: Node): boolean; private flushFreeQueue; } //#endregion //#region src/core/node.d.ts /** Lifecycle hooks a SceneTree drives. Wired in scene-tree.ts. */ interface NodeLifecycle { onEnterTree(): void; onReady(): void; onExitTree(): void; update(dt: number): void; fixedUpdate(dt: number): void; } declare class Node implements NodeLifecycle { static readonly typeName: string; /** * Signals this node type can emit, declared statically (merged up the class * hierarchy). A behavior's `static signals` are declared onto its node at * load. Emitting or subscribing an UNDECLARED signal is a hard error — * a typo'd signal name must fail loudly, never silently never-fire. */ static readonly signals?: readonly string[]; private _name; private _parent; private readonly _children; /** * child name → how many children carry it. Built on first use, then * MAINTAINED — the whole reason this exists. * * `uniqueSiblingName` used to build a fresh `Set` of every existing sibling's * name on every insert, BEFORE asking whether the requested name was even * taken. Filling one parent therefore costs K(K+1)/2 Set operations, and a * single attach into a horde of 8,000 cost about thirteen times what * simulating that whole tree for a frame costs. A spawner-driven game gets * slower the longer it is played, at the moment it is busiest. * * The three places `_children` changes are the three places this changes: * `addChild`, `removeChild` and the `name` setter. `_children` is private to * this file, so that list is the whole list. */ private _childNames; /** * stem → the next trailing number worth TRYING. A hint, never the truth. * * The name index alone left a second quadratic in the same function: eight * thousand children all called `Enemy` means eight thousand collisions, and * `while (taken.has(stem + counter)) counter += 1` restarted at 2 every time. * Measured, 4x the children still cost 16.7x the time. Starting the scan * where the last one finished makes the whole fill linear; the `while` still * runs, so a wrong hint costs a step and never a wrong name. */ private _nameCounters; private readonly _groups; private readonly _signals; /** * Disconnects for connections this node made on OTHER nodes. * * Null until it makes one — most nodes never do, and an empty array per node * is a real cost in a scene with thousands of them. * @internal */ _outgoing: Array<() => void> | null; private _declared; /** * signal → (the listener a caller gave us) → (the quarantining wrapper Signal * actually holds). Null until this node has a listener, which most never do. */ private _wrapped; private _tree; private _ready; /** * Optional STABLE identifier (scene JSON `uid`) — unlike names (unique only * among siblings) a uid is unique across the whole scene, so scripts and * tools can address a node no matter where it moves. */ private _uid; get uid(): string | null; set uid(value: string | null); /** Free-form JSON identity for game logic (e.g. `{kind: 'ITEM', value: 10}`). */ tags: Record; /** Behavior attachment blob from scene JSON (resolved by the loader). */ script: JsonObject | null; /** The resolved behavior instance (set by the loader from `script`). */ behavior: Behavior | null; /** Replication config blob from scene JSON (interpreted in M6; preserved until then). */ network: JsonObject | null; constructor(name?: string); get name(): string; set name(value: string); get parent(): Node | null; get children(): readonly Node[]; get groups(): ReadonlySet; /** The SceneTree this node is attached to, or null while detached. */ get tree(): SceneTree | null; /** Whether onReady has run (it runs at most once per instance). */ get isReady(): boolean; /** The sibling-name index, built once per parent and then kept up to date. */ private childNames; private nameCounters; addChild(child: T): T; removeChild(child: Node): void; reparent(newParent: Node): void; findChild(name: string, recursive?: boolean): Node | null; /** Topmost ancestor (the node itself when detached). */ getRoot(): Node; /** Depth-first search of THIS subtree for the node carrying `uid`. */ getNodeByUid(uid: string): Node | null; /** Every node in THIS subtree named `name` — names repeat, so a list. */ getNodesByName(name: string): Node[]; getPath(): string; /** * The path of the AUTHORED node this one stands for — `getPath()` with every * cloned ancestor's auto-renamed name put back (`/Game/Foe37/Fx` → * `/Game/Foe/Fx`). * * "Is this AudioPlayer wired?" is a question about the scene, and a wave of * forty enemies is one emitter fired forty times, not forty emitters. The * feedback sets ask it, and answering with the live path made a set the * engine promised would "grow with the SCENE, not with the run" grow by one * entry per spawn, forever, in every shipped game. * * The event LOGS keep the live path: a log names the node that acted. */ wiringPath(): string; getNode(path: string): Node; getNodeOrNull(path: string): Node | null; /** * `parseNodePath`, but the error says WHERE it was written. * * Every other scene-load error appends the offending node — `NODE_NOT_FOUND` * lists the children that do exist, an unknown type ends `(at '/Level')` — * and the six `BAD_NODE_PATH` messages were the only family in the engine * that named neither the node nor anything greppable. A bad path in a * behavior prop printed `[BAD_NODE_PATH] Node path must not be empty.` and * nothing else, so bisecting the scene was the only way to find it. */ private parseFrom; private resolve; addToGroup(group: string): void; removeFromGroup(group: string): void; isInGroup(group: string): boolean; private declaredSignals; /** Declare an ad-hoc signal on THIS instance (static `signals` covers types). */ declareSignal(name: string): void; /** Every signal this instance may emit (static + behavior + ad-hoc). */ declaredSignalNames(): string[]; private assertDeclared; /** Get the named DECLARED signal (creating its Signal object on demand). */ signal(name: string): Signal; on(signal: string, fn: SignalListener, opts?: { once?: boolean; owner?: Node; }): () => void; off(signal: string, fn: SignalListener): void; /** * Wrap a listener so ITS throw stays ITS problem. * * `Signal.emit` calls listeners directly, so an exception from one unwinds * into whoever EMITTED. The node quarantine then blames and disables the * emitter's script, and every OTHER listener on that signal is skipped for * the rest of the emit. Measured on the same broken handler, two wirings: * * ``` * wired with node.on() emitter ticks 1/10 emitter script DISABLED * wired with JSON connections emitter ticks 10/10 emitter script fine * ``` * * The JSON half has been quarantined since `quarantineWire` shipped, with a * comment saying why: *"a behavior is your code; the node is the engine's. A * typo in the first must not switch off the second."* `.on()` is the same * category — it is the spelling every skill teaches and five shipped examples * use, including `controller.on('movementStateChanged', …)`, where blaming * the emitter stops the player moving entirely. That is verbatim the defect * `quarantineWire` was written to prevent, reached through the other door. * * The owner node is NOT marked `errored`: its own update is fine, only this * wire is off. That is exactly what `quarantineWire` does, and marking it * would restore the over-punishment this removes. */ private quarantined; /** * How many listeners this node has on `signal` — 0 for one nobody watches. * * The engine's own leak test reads it, and so can a game: a handler connected * every frame and never disconnected is invisible to every other check here, * because every other check is a snapshot and a leak is a slope. */ listenerCount(signal: string): number; emit(signal: string, ...args: unknown[]): void; /** * Defer destruction to the end of the current update pass (flushed by the * SceneTree). Frees immediately when detached from any tree. */ queueFree(): void; /** * Has this node been torn down? * * A scene swap frees the tree it replaces, so the answer is `true` for every * node of the scene you just left — which is exactly the state a behavior is * in when it runs one more line after `goToScene`. Kept as a flag rather than * inferred from `parent`/`tree`, because a DETACHED node (a prefab on a * shelf, a clone waiting to be added) has neither and is perfectly alive. */ get freed(): boolean; private _freed; /** Immediately detach and tear down this node and its children. */ free(): void; /** * Run a structural lifecycle hook, quarantining it the way `update` is. * * Only `update`/`fixedUpdate` were ever quarantined, so a throw from a * RUNTIME-created node's `onReady`/`onEnterTree` unwound into the `update()` * of whatever created it. Measured on one Spawner scene, 120 steps: * * control A spawner alone nodes 20, errors 0 * control B clone throws in UPDATE nodes 20, errors 19, each line * correctly named on the clone * A + B clone throws in onReady nodes 2, errors 1, * log: "behavior 'Spawner' on /World/Spawner threw in update" * * The innocent Spawner was quarantined — spawning stopped permanently — and * the one headline named the wrong node, the wrong script and the wrong hook. * An `onExitTree` throw on a runtime `queueFree` was worse still: it escaped * `engine.step()` entirely, with `stats().errors: 0` and an EMPTY log, * because the free-queue flush is outside behaviour propagation. * * During the initial load the throw is re-raised: `loadScene` is where an * authoring mistake has to hard-fail, and that is stated design. */ private _runHook; /** @internal */ _propagateEnterTree(tree: SceneTree): void; /** @internal */ _propagateReady(): void; /** @internal */ _propagateExitTree(): void; /** @internal */ _propagateUpdate(dt: number): void; /** @internal */ _propagateFixedUpdate(dt: number): void; /** * Report an engine-level problem about THIS node. * * Goes to `engine.log` (what the debug overlay, `runScript()` and any agent * tool read) as well as the console. A `console.warn` alone is invisible to * every channel except a human with devtools open. */ protected diagnose(level: "warn" | "error", ...parts: unknown[]): void; /** * This node threw and is being skipped. Its CHILDREN still run. * * The alternative is what used to happen: the exception escaped into the rAF * callback, the loop was never rescheduled, and the game froze — permanently, * silently, with `stats().running` still reporting true. In a vibe-coded * project the least-tested code in the repo is a behavior's `update`, so this * is not an edge case; it is Tuesday. * * Set it back to false to try the node again (the debug overlay's "resume"). */ errored: boolean; /** * This node's SCRIPT threw and is being skipped — the node itself still runs. * * A behavior is your code; the node is the engine's. A typo in the first must * not switch off the second, or breaking a sword script stops the character * from walking. */ behaviorErrored: boolean; /** * Report ONCE and stop running this node's own update. * * Once, because a behavior that throws throws every frame: sixty identical * stack traces a second buries the one line that matters and costs more than * the game did. */ private _quarantine; onEnterTree(): void; onReady(): void; onExitTree(): void; update(_dt: number): void; fixedUpdate(_dt: number): void; } //#endregion //#region src/core/audio/buses.d.ts /** The two volume buses every sound routes through (plus the global `master`). */ type BusName = "sfx" | "music"; /** * Global volume state — `master` × `sfx`/`music` gain (0..1) plus a `muted` * flag. PURE state (no WebAudio): games and AI set it to control overall * loudness/mute; the renderer reads `effectiveVolume()` when it actually plays * a sound. Lives on the engine as `engine.audio` (instance-scoped, no * singletons). The WebAudio wiring is in the adapter; this just holds the * numbers so they're fully testable in `node`. * * ```ts * engine.audio.master = 0.8; // dim everything * engine.audio.music = 0.4; // quieter background music * engine.audio.muted = true; // mute toggle * ``` */ /** One thing that sounded (or would have, headless). */ interface AudioEvent { /** `preset` (procedural one-shot), `src` (a clip), `music`, or `voice`. */ kind: "preset" | "src" | "music" | "voice"; /** The preset name, the clip url, or the voice preset. */ name: string; /** Node path for a node-driven sound; `engine.music` / `engine.sfx` otherwise. */ from: string; /** * The AUTHORED node this stands for — `from` with a clone's auto-renamed * ancestors put back (`/Game/Foe37/Snd` → `/Game/Foe/Snd`). Absent = same as * `from`. Only `sources()` reads it; the log keeps the live path. */ wiredAs?: string; bus: BusName; /** `engine.time` when it fired, in seconds. */ at: number; /** * It repeats until stopped, rather than ending on its own. * * A looping sound is recorded ONCE — re-recording every pass would evict the * rest of the log within seconds for a 0.2 s preset — so without this a * headless check could not tell "the alarm loops" from "the alarm fired once * and stopped". Both read `countOf('alarm') === 1`. */ loop?: boolean; /** * WHERE it came from, for a spatial sound: the gain it arrived at (0..1 * after distance rolloff), which side (`pan`, −1 left … +1 right) and how * far the listener was. Absent for a sound that is not spatial, and for one * played in a scene with no camera to hear it. * * Thirty shipped scenes set `spatial: true` and nothing had ever checked * that a sound is louder on the side it comes from, because the record said * only that it fired. A game whose mechanic is "walk toward the noise" could * not be verified at all. */ gain?: number; pan?: number; distance?: number; } declare class AudioBuses { /** Fires whenever any value changes (drives live re-gain of playing sounds). */ readonly changed: Signal<[]>; /** * What has sounded, most recent last. * * Audio is a no-op headless, which left "did the coin sound actually fire * when the coin was collected?" unanswerable — the audio skill's own advice * was to *"assert `play()` doesn't throw and check your gameplay logic * instead"*, which checks something else. The CALL still happens with no * backend, so recording it answers the wiring question everywhere: in the * verify VM, in a playtest, in a test. * * Bounded (200) — a game running for an hour must not grow a log. */ private readonly events; /** * EVERY node that has sounded, ever — a set of paths, not a window. * * `recent()` is bounded at 200, which is right for a log and wrong for the * question "is this AudioPlayer wired?". A game with one sound firing about * three times a second overflows the window inside a minute, so a 60-second * playtest read the tail and reported correctly-wired feedback as never * fired. This grows with the SCENE, not with the run — which is true only * because it records the AUTHORED path: a clone's own path is new every * spawn, and recording those grew the set by one entry per enemy, forever, * in every shipped game. See `Node.wiringPath`. */ private readonly sourcePaths; /** @internal Called by the players; games read `recent()`. */ record(event: AudioEvent): void; /** The last `limit` sounds, oldest first. */ recent(limit?: number): AudioEvent[]; /** Every node path that has sounded since the last `clearLog()`. */ sources(): string[]; /** How many times a preset/clip/track sounded — the assertion you want. */ countOf(name: string): number; /** Forget what has sounded so far (between scenes, or between assertions). */ clearLog(): void; private _master; private _sfx; private _music; private _muted; private _suspended; get master(): number; set master(v: number); get sfx(): number; set sfx(v: number); get music(): number; set music(v: number); get muted(): boolean; set muted(v: boolean); /** * Silence the game WITHOUT it counting as the player muting it. * * `pause-when-hidden` set `muted = true` when the tab went away, which fires * `changed`, which `Settings.bindAudio` persists. Close the tab while it is * hidden and `muted: true` is on disk: EVERY later session of that game boots * silent, and no shipped starter has an in-game way back. * * A suspension is the engine's, not the player's. It never touches `muted`, * so nothing persists it and unsuspending cannot turn the sound back on for * someone who turned it off. */ suspend(on: boolean): void; /** Is the engine holding the sound off (hidden tab), as against the player? */ get suspended(): boolean; /** Final gain for a sound on `bus` with its own `sourceVolume` (all clamped). */ effectiveVolume(bus: BusName, sourceVolume: number): number; /** Clamp + finite-guard a new value; emit only on a real change. */ /** * Write THEN emit. * * This used to emit from inside the expression assigning the field, so every * listener of `changed` read the OLD value — including the thing the signal * exists for ("live re-gain of playing sounds"), which therefore re-gained to * the volume you just left. */ private assign; } //#endregion //#region src/core/audio/music-manager.d.ts /** * One streamed music track the manager controls — a thin handle the adapter * implements over an HTMLAudio element routed through a WebAudio GainNode (so * its gain can ramp). Pure-JS fakes implement the same shape for tests. */ interface MusicTrack { readonly src: string; /** Set the track's output gain (0..1, already bus-multiplied). */ setGain(gain: number): void; setLoop(loop: boolean): void; /** Begin (or resume) playback. May be gesture-blocked; that's not an error. */ play(): void; /** Stop and release the underlying element/source. */ stop(): void; /** * Seconds into the track, or `null` when the backend cannot say. * * Optional so an existing custom backend keeps compiling. */ playhead?(): number | null; /** * Advance a track that has no clock of its own, by the engine's real dt. * * Optional, and the WebAudio backend does not implement it: an element's own * `currentTime` is the authority whenever there is one. The silent backend * does, so a game charted to a soundtrack has a playhead in the verify VM. */ tick?(dt: number): void; } /** Backend that mints {@link MusicTrack}s — WebAudio-backed in the browser. */ interface MusicBackend { /** False headless (no AudioContext) — the manager stays a pure no-op. */ readonly available?: boolean; createTrack(src: string): MusicTrack; /** Resume a gesture-suspended context (wired to the first user gesture). */ unlock(): void; /** Release the context it owns (optional — teardown). */ dispose?(): void; } interface PlayMusicOptions { /** Loop the track (default true — music usually loops). */ loop?: boolean; /** Fade in over N seconds (default 0 — start at full gain). */ fadeIn?: number; /** Which volume bus to route through (default 'music'). */ bus?: BusName; } /** * Single-track background-music manager: plays ONE music track, crossfading to * a new one (equal-power) or fading in/out. Lives on the engine as * `engine.music`; routes through `engine.audio` (the music bus by default) so * the global volume/mute sliders dim it for free. * * The state machine (current/outgoing track, fade phase + timer) is fully * testable headlessly via an injected backend; the real backend is WebAudio + * HTMLAudio and a no-op when there's no AudioContext. Drive `tick(dt)` once per * frame to advance fades (the engine wires this to `updated`). * * ```ts * // NOTE: the bundle ships SFX only — no music track. A background track is * // your game's own file under public/, or a URL. * engine.music.play('/audio/theme.mp3'); // loop, full * engine.music.crossfadeTo('boss.mp3', 3); // 3s equal-power swap * engine.music.stop(2); // fade out over 2s * engine.music.setVolume(0.5); // per-manager volume * ``` */ declare class MusicManager { private readonly buses; private backend; private resolved; /** The current (incoming) track; an outgoing one lives during a fade. */ private active; private outgoing; private _bus; private _volume; constructor(buses: AudioBuses); /** Source of the logically-current track, or null when nothing is playing. */ get current(): string | null; /** * Seconds into the current track — the playhead of the thing you can HEAR. * * `null` when nothing is playing, or when the backend has no clock (headless, * or a custom backend that does not implement it). * * A game charted to a soundtrack needs this and there was no way to get it: * `current` gave the src and the backend was private, so the only option was * an `engine.time` counter started next to `music.play()` — a different clock * from the one the audio is on, and one that keeps counting through a * gesture-block, a stall or a seek. This is the audio's own answer. * * It is the RAW element time, so a looping track wraps to 0 at each pass: * that is a loop boundary you can chart against, not a fault. */ get playhead(): number | null; /** Per-manager volume (0..1), on top of the bus gain. */ get volume(): number; /** * The backend, or an INERT one that mints silent tracks. * * Never null, on purpose: the manager used to return before touching its * state when there was no backend, so `current` stayed null forever in every * headless run — including the verify VM, which is the only place a game is * checked automatically. "Did the boss music start?" had no answer anywhere. * With an inert backend the state machine runs identically and nothing plays. */ private ensure; /** * Where a track that will never play gets reported. The engine sets this; a * bare MusicManager (tests) stays quiet. */ onTrackError: ((src: string, reason: string) => void) | null; /** Resume a gesture-suspended context + (re)start any pending track. */ unlock(): void; /** Play a single track (replacing any current one immediately, no fade). */ play(src: string, opts?: PlayMusicOptions): void; /** * Equal-power crossfade to a new track over `seconds`. With no current track * it's a fade-in. Crossfading to the already-current src is a no-op. */ crossfadeTo(src: string, seconds?: number): void; /** Stop the current track, optionally fading out over `fadeOut` seconds. */ stop(fadeOut?: number): void; /** * Stop instantly and release the backend — music must not outlive the game. * * `dispose()` is the one call that tears an SPA-mounted game down, and it used * to leave the track playing with the engine that owned it gone. */ dispose(): void; /** Set the per-manager volume (re-applied to live tracks on the next tick). */ setVolume(v: number): void; /** Advance fades by `dt` seconds and (re)apply bus×fade gains. Frame-driven. */ tick(dt: number): void; /** Effective bus gain for THIS manager (master × bus × manager volume). */ private busGain; /** Push the current fade-derived gains onto both tracks. */ private applyGains; private killOutgoing; } //#endregion //#region src/core/audio/sfx-presets.d.ts /** * jsfxr/sfxr-style procedural sound parameters — the AUDIO analog of the * particle presets: zero asset bytes, instant, deterministic. A small, pure * float-DSP synth (`synthSfx`) turns a param set into a PCM `Float32Array` * entirely in core (no WebAudio, no DOM), so the params and the waveform are * fully unit-testable in `node`. The browser path (webaudio-sfx.ts) just wraps * the PCM in an AudioBuffer and plays it. * * This is intentionally a *small* sfxr — enough for crisp arcade SFX, not a * full DAW. Each preset is one oscillator + an ADSR envelope + optional * frequency ramp/vibrato/noise, mirroring the classic "Bfxr" knobs. */ type SfxWave = "square" | "sawtooth" | "sine" | "triangle" | "noise"; interface SfxParams { /** Oscillator shape. `noise` is white noise (explosions, hits, steps). */ wave: SfxWave; /** Starting pitch in Hz. */ baseFreq: number; /** Linear pitch slide over the sound, in Hz/second (+ rises, − falls). */ freqRamp: number; /** Attack time (s) — fade in from silence. */ attack: number; /** Sustain time (s) — the body of the sound at full level. */ sustain: number; /** Decay time (s) — fade out to silence. */ decay: number; /** Square-wave duty cycle 0..1 (ignored by other waves). */ duty?: number; /** Vibrato depth as a fraction of baseFreq (0 = none). */ vibratoDepth?: number; /** Vibrato rate in Hz. */ vibratoRate?: number; /** Peak amplitude 0..1. */ volume: number; } /** * A tasteful, distinct set of common game sounds. Pick one via * `AudioPlayer.preset`. Tuned by ear for crisp arcade feel; `seed`/`pitch` * props add free variation so repeated sounds don't feel robotic. */ declare const SFX_PRESETS: Record; declare const SFX_PRESET_NAMES: readonly string[]; interface SynthOptions { /** Output sample rate (Hz) — match the AudioContext's. Default 44100. */ sampleRate?: number; /** Pitch multiplier for variation (1 = preset's pitch). Default 1. */ pitch?: number; /** Seed for the noise generator + tiny envelope jitter. Default 0. */ seed?: number; } /** * How long a preset lasts, in seconds — the same `attack + sustain + decay` * `synthSfx` uses to size its buffer. * * `pitch` deliberately does not enter into it: it is a frequency multiplier, * not a playback rate, so a preset played an octave up is the same length. */ declare function sfxDuration(params: SfxParams): number; /** * Render a preset to mono PCM in [-1, 1]. PURE: identical inputs → identical * output (the only randomness is the seeded noise generator), so determinism is * unit-testable headlessly. Length = ceil((attack+sustain+decay) * sampleRate). */ declare function synthSfx(params: SfxParams, opts?: SynthOptions): Float32Array; //#endregion //#region src/core/audio/spatial.d.ts /** * PURE spatial-audio math — the testable heart of 3D positional sound. No * WebAudio, no three: just the distance-attenuation curve and listener-relative * panning that the adapter feeds into a real `PannerNode`. Mirrors the WebAudio * `PannerNode` distance models so the headless math and the browser graph agree, * and so the gain we apply on the (non-spatial) HTMLAudio path matches what a * panner would produce. Fully unit-testable in `node`. */ type Vec3 = readonly [number, number, number]; /** The three WebAudio distance models (PannerNode.distanceModel). */ type RolloffModel = "inverse" | "linear" | "exponential"; declare const ROLLOFF_MODELS: readonly RolloffModel[]; interface SpatialParams { /** Distance at which gain is 1; closer never gets louder. */ refDistance: number; /** Distance past which gain stops falling (clamped). */ maxDistance: number; /** Attenuation curve shape (matches PannerNode.distanceModel). */ rolloff: RolloffModel; /** * Rolloff factor (PannerNode.rolloffFactor) — how quickly gain drops. * Defaults to 1. Higher = steeper falloff. */ rolloffFactor?: number; } /** A listener pose in world space (the active Camera3D drives this). */ interface Listener { position: Vec3; /** Unit-ish forward (camera looks down -Z by default). */ forward: Vec3; /** Unit-ish up. */ up: Vec3; } /** * Gain (0..1) for a source `distance` units from the listener, per the chosen * model. Identical to the WebAudio PannerNode formulas so the headless number * matches the browser panner: * * - inverse: ref / (ref + factor·(clamp(d) − ref)) * - linear: 1 − factor·(clamp(d) − ref) / (max − ref) * - exponential: (clamp(d) / ref)^(−factor) * * where `clamp(d)` is `d` clamped to `[ref, max]`. Always full inside * `refDistance`; monotonic non-increasing out to `maxDistance`. Non-finite or * degenerate inputs return a safe finite gain. */ declare function spatialGain(distance: number, params: SpatialParams): number; /** * Stereo pan (−1 left … 0 center … +1 right) for a source at `sourcePos`, * relative to the listener's orientation. Projects the listener→source vector * onto the listener's RIGHT axis (forward × up) and normalizes by its length — * sign tells left/right, magnitude tells how far off-axis. A source at the * listener position (or dead ahead) is centered. Result clamped to [−1, 1]. * * This is the CPU twin of what HRTF/equalpower panning does to the azimuth; the * real PannerNode does the full spatialization, but exposing the sign here makes * the geometry unit-testable and lets the 2D fallback pan by the same rule. */ declare function spatialPan(sourcePos: Vec3, listener: Listener): number; //#endregion //#region src/core/audio/voice.d.ts /** * Continuous parametric voices — the engine hum, wind rush and thruster * roar that one-shot SFX can't do (racing-3d hand-built exactly this graph). * A voice is a small WebAudio patch you retune every frame: * * const voice = engine.sfx.startVoice('engine'); * voice.set({ pitch: rpm / 4000, volume: throttle * 0.6 }); * voice.stop(); // fades out and frees the graph * * Headless (no AudioContext) the handle is inert — same call sites, no ifs. */ type VoicePreset = "engine" | "wind" | "hum" | "noise"; interface Voice { /** Retune live. pitch 1 = the preset's base; volume 0..1. */ set(params: { pitch?: number; volume?: number; }): void; /** Fade out over `seconds` (default 0.15) and free the nodes. */ stop(seconds?: number): void; readonly stopped: boolean; } //#endregion //#region src/core/audio/webaudio-sfx.d.ts /** A spatial emitter: where the sound is + where the listener is. */ /** The shape `AudioBuses.record` takes (structural — no import cycle). */ interface AudioRecord { kind: "preset" | "src" | "music" | "voice"; name: string; from: string; bus: "sfx" | "music"; at: number; } interface SpatialPlay { /** Emitter world position. */ position: Vec3; /** The listener pose (active Camera3D). */ listener: Listener; refDistance: number; maxDistance: number; rolloff: RolloffModel; rolloffFactor?: number; } /** True when a real (or injected) AudioContext backend exists. */ declare function isAudioContextAvailable(): boolean; interface SfxPlayOptions { pitch?: number; seed?: number; /** When set, the sound is spatialized through a PannerNode at this emitter. */ spatial?: SpatialPlay; /** * Start at this point on the AUDIO clock (`engine.sfx.now` + lead), instead * of "as soon as this call returns". * * Nothing in the audio API took a time, so a sound could only be fired from a * frame — and a frame is a 16.67 ms grid at 60 Hz, 33.33 ms at 30. Measured * on a metronome: firing from `update()` gave 0 of 64 notes a scheduled * start; the same notes queued one frame ahead with `when` land at * |mean| 0.000000 ms. That difference is the whole of a rhythm game, and * anything charted to a soundtrack. * * A time already past plays immediately (Web Audio's own rule), so a late * scheduler degrades instead of going silent. The bus gain and the * `engine.audio.recent()` record apply either way — which is what a * hand-rolled `AudioContext` scheduler loses. */ when?: number; } /** * One AudioContext + a PCM cache. Instance-scoped (held by the renderer's game * instance, not a singleton). `play()` synthesizes (cached) and fires a * one-shot source at the given gain. */ declare class SfxEngine { private ctx; private resolved; private readonly pcmCache; /** * The engine's buses, when this SfxEngine belongs to one — so a continuous * voice lands in the same audio record as everything else. A record that * covered only SOME sounds would answer "did the engine hum start?" with a * confident no. */ private buses; /** Live continuous voices, so teardown can silence the ones nobody stopped. */ private readonly voices; /** @internal Engine wiring. */ _setBuses(buses: { record(event: AudioRecord): void; }): void; /** True once a backend is present (lazily created on first use). */ get available(): boolean; private ensure; /** * Start a continuous parametric voice (engine hum, wind, thrusters) — * retune it per frame with `voice.set({ pitch, volume })`, `stop()` when * done. Headless returns an inert handle (same call sites, no ifs). */ startVoice(preset: VoicePreset, gain?: number): Voice; /** How many continuous voices are still running (teardown/leak checks). */ get liveVoiceCount(): number; /** Silence every continuous voice — the hum must not outlive the game. */ stopAll(seconds?: number): void; /** * Silence everything and release the AudioContext. * * A browser allows only a handful of contexts per page, so an SPA that mounts * the game a few times must hand this one back — `dispose()` is documented as * the call that tears everything down. */ dispose(): void; /** Resume a gesture-suspended context (wired to the first user gesture). */ unlock(): void; /** * The AUDIO clock, in seconds — the one a sound is actually scheduled * against, and the only clock that does not drift relative to what you hear. * * Schedule with a lead: `sfx.play(params, 1, { when: sfx.now + 0.08 })`. * * With no AudioContext — headless, and before the first sound — it is the * engine's own real elapsed time instead of a frozen `0`. A constant made the * documented lookahead scheduler queue the notes inside the first 80 ms and * then nothing ever again: measured on a 21-beat metronome in the verify VM, * **1 of 21 beats**, with no error and no warning. The silent clock runs so * the schedule is reachable; nothing sounds either way. */ get now(): number; /** The clock `now` reports when there is no context to ask. */ private silentNow; /** * @internal Advance the silent clock — the engine calls this every frame with * the REAL dt, beside `music.tick`, because the audio does not slow down when * the game does. */ _tick(dt: number): void; /** * Play a preset at `gain` (0..1, already bus-multiplied by the caller). Each * call spawns its own source so rapid repeats overlap instead of cutting off. * Headless → no-op. */ play(params: SfxParams, gain: number, opts?: SfxPlayOptions): void; } //#endregion //#region src/core/effects-log.d.ts /** * What SHOWED — the visual half of the audio record. * * A game's feedback is sound and vision, and only one of them could be checked. * `engine.audio.countOf('coin')` answers "did the coin sound fire?"; nothing * answered "did the explosion?". `framing()` says where an emitter IS, which is * a different question — a particle system that never fired and one that fired * a hundred times sit at the same coordinates. * * Nothing here renders, so nothing here is measured in pixels. It records that * the effect WAS ASKED FOR, which is the wiring question a game actually has: * the burst that never happened because the signal was never connected looks * exactly like the burst that happened off-screen, and this tells them apart. * * ```ts * session.engine.effects.clearLog(); * smashTheCrystal(); * session.step(200); * session.engine.effects.countOf('explosion'); // 1 * session.engine.effects.countFrom('/Game/Crystal/Boom'); // 1 * ``` */ /** The kinds of thing a game does to say "that happened". */ type EffectKind = "burst" | "emit" | "trail" | "shake" | "flash" | "hitstop"; /** One effect that fired. */ interface EffectEvent { kind: EffectKind; /** The preset name, the flash colour, or `''` when the effect has no name. */ name: string; /** Node path, or `engine` for the screen-wide ones. */ from: string; /** * The AUTHORED node this stands for — `from` with a clone's auto-renamed * ancestors put back (`/Game/Foe37/Fx` → `/Game/Foe/Fx`). Absent = same as * `from`. Only `sources()` reads it; the log keeps the live path. */ wiredAs?: string; /** Particles in the burst, shake magnitude, seconds frozen — the amount. */ amount: number; /** `engine.time` when it fired, in seconds. */ at: number; } declare class EffectLog { private readonly events; /** * EVERY node that has fired, ever — a set of paths, not a window. See * `AudioBuses.sources`: 200 entries is a log, and "is this emitter wired?" * needs the whole run. * * AUTHORED paths, so it is bounded by the scene: a wave of forty enemies is * one emitter fired forty times. See `Node.wiringPath`. */ private readonly sourcePaths; /** @internal Called by the effects themselves; games read `recent()`. */ record(event: EffectEvent): void; /** Every node path that has fired an effect since the last `clearLog()`. */ sources(): string[]; /** The last `limit` effects, oldest first. */ recent(limit?: number): EffectEvent[]; /** How many times an effect with this name fired (`explosion`, `#ff0000`). */ countOf(name: string): number; /** * How many effects of this KIND fired (`shake`, `hitstop`, `burst`, `sfx`). * A shake has no name, so `countOf('shake')` answered 0 for a boss whose * every slam shook the camera — the harness that asked reached for the kind. */ countKind(kind: string): number; /** How many effects this node fired — the question a wiring check has. */ countFrom(path: string): number; /** Forget what has fired so far (between scenes, or between assertions). */ clearLog(): void; } //#endregion //#region src/core/input.d.ts declare class InputMap { private readonly actions; private readonly down; private readonly pressedEdge; private readonly releasedEdge; private readonly pressedEdgeFixed; private readonly releasedEdgeFixed; /** Which pass is asking. Set by the Engine around each phase. */ private phase; /** * Actions something has actually ASKED about since this scene loaded. * * Recorded in the four read methods only — never in `get`/`button`, because * `pressAction` goes through those, and the tool that reports this list is * the same tool that injects presses. Marking an action read because a * playtester pressed it would make the check answer its own question. */ private readonly queried; private detach; /** Load (or extend with) scene-JSON action declarations. Hard-validates shape. */ declare(decls: Record): void; /** * Where the pointer is, in CANVAS pixels (top-left origin), or null when it * has never been over the canvas. * * This is what a click-driven game asks for. `pointerDelta()` answers "how far * did the mouse move" — the mouse-look question — and for a whole class of * genres that is the wrong question and the only one the engine could answer. * Pair it with `engine.pickAt(x, y)` for the node under the cursor and * `engine.pointerWorld()` for the place — both reachable from a Behavior, * which `renderer.pick` / `renderer.worldFromScreen` are not. */ pointerPosition(): { x: number; y: number; } | null; /** * @internal Feed a pointer position. `target` scales client → canvas pixels * so a CSS-stretched canvas still reports coordinates its renderer can use. */ setPointerPosition(clientX: number, clientY: number, target?: HTMLElement): void; private pointerPos; /** Feed a key state change (code = KeyboardEvent.code, e.g. 'Space', 'KeyW'). */ handleKey(code: string, isDown: boolean): void; private readonly injectedDown; private readonly injectedPressed; private readonly injectedReleased; /** The fixed pass's view of the injected edges — see `pressedEdgeFixed`. */ private readonly injectedPressedFixed; private readonly injectedReleasedFixed; private readonly injectedPressedNext; private readonly injectedReleasedNext; /** True between the start of a frame's update pass and `endFrame()`. */ private inFrame; private readonly injectedVectors; /** * Press a button ACTION directly — no key codes involved. This is how * scripted gameplay tests and touch buttons drive the game by intent * (`press('jump')`) instead of reverse-engineering keybinds. */ /** * Every action this scene declared, with its kind. * * A scene states its own control vocabulary in `input{}`, which means a tool * can drive a game it has never seen — the automated playtester's entire * premise. Nothing else could ask: `actions` was private. */ declaredActions(): Array<{ name: string; type: "button" | "vector2"; }>; /** * Declared actions NOTHING has read — a control the scene promises and no * code implements. * * `village-quest-3d` declared `"restart": { "keys": ["KeyR"] }` and not one * line read it, so the game's own control list said R restarts and R did * nothing. The scene is where controls are DECLARED; whether anything * consumes one is invisible there, and a `grep` for the name cannot tell an * engine node's read from a dead string. * * The list is advisory, like `behaviorsWithoutSave`: an action polled only in * a state a run never reached is on it honestly. Read it, do not gate on it. */ unreadActions(): string[]; pressAction(action: string): void; /** Release an injected button action (yields one justReleased frame). */ releaseAction(action: string): void; /** * Feed an analog direction for a vector2 ACTION — virtual joysticks and * scripted runs. Persists until replaced; (0, 0) clears the injection. * Combines with key state in getVector (clamped to unit length). */ setActionVector(action: string, x: number, y: number): void; /** * Actions the scene wants on-screen touch controls for, in declaration * order (`"touch": "joystick"` on vector2, `"touch": "button"` on buttons). * The touch overlay (core/touch.ts) renders these. */ touchControls(): Array<{ action: string; kind: "joystick" | "button"; }>; /** Drop all injected action state (held buttons, vectors, pending edges). */ clearInjected(): void; private dx; private dy; private wheel; /** Mouse buttons feed the same code space as keys: Mouse0/Mouse1/Mouse2. */ /** * A mouse/pen/finger button. * * `kind` matters on the way UP: a finger that lifts CEASES TO EXIST, while a * cursor stays where you left it. Without it the last-tapped node kept * `hovering: true` for the rest of the session — a permanently highlighted * tile, mole or card on every phone, on a device with no hover at all. * * The position is cleared at `endFrame`, not here: `Clickable` fires on the * release over the node the press started on, so a pointer cleared in the * same frame takes the click with it. */ handleMouseButton(button: number, isDown: boolean, kind?: "mouse" | "touch"): void; /** A finger came up this frame — see `endFrame`. */ private touchLifted; /** Accumulate look deltas (movementX/Y under pointer lock, else move deltas). */ handlePointerMove(dx: number, dy: number): void; handleWheel(deltaY: number): void; /** Drain the accumulated pointer delta (read once per frame). */ pointerDelta(): { x: number; y: number; }; /** Drain the accumulated wheel delta. */ wheelDelta(): number; /** * Wire browser pointer events on a canvas: buttons → Mouse0/1/2 codes, * movement → pointerDelta (movementX/Y so pointer lock just works), * wheel → wheelDelta. `lockOnClick` requests pointer lock on mousedown * (the FPS pattern). */ private readonly padAxesState; private padButtonsDown; /** * Feed one polled gamepad snapshot (standard mapping). Buttons become * codes `Pad0`..`Pad16` in the same space as keys — declare them in input * actions like any key ("jump": ["Space", "Pad0"]). Axes land in * `padAxes()` with a deadzone. Pass null when no pad is connected. */ pollGamepad(pad: { buttons: readonly { pressed: boolean; }[]; axes: readonly number[]; } | null): void; /** Deadzoned analog sticks: 0 = left {x,y}, 1 = right. */ padAxes(stick?: 0 | 1): { x: number; y: number; }; /** * Poll the browser Gamepad API every frame (the first connected pad). * Wire once at boot: `engine.input.attachGamepad(engine)`. Headless no-op. */ attachGamepad(_engine?: unknown): () => void; /** * Pointer input from a mouse, a FINGER or a pen — one code path. * * This used to bind `mousedown`/`mousemove`/`mouseup`, which on a phone only * arrive as COMPATIBILITY events: delayed until the browser has decided the * touch is not a scroll, never fired for drags or multi-touch, and suppressed * outright once the surface claims its gestures with `touch-action: none`. * So every genre the comment below lists — match-3, tower defense, card, * point-and-click, RTS — was mouse-only on the device most of them are played * on. Pointer events cover all three input kinds and fire immediately. * * Only the PRIMARY pointer drives it: a second finger (the one on the virtual * stick) must not also swing the camera. */ attachPointer(target: HTMLElement, opts?: { lockOnClick?: boolean; }): () => void; /** * Wire browser keyboard events. Returns (and chains into dispose()) a detach * function. * * By default the browser default is prevented for keys BOUND to a declared * action — an embedded game must not scroll its host page on Space/arrows. * Keys typed into editable elements (inputs, textareas, contenteditable) * are ignored entirely so DOM UI overlays keep working. */ attachKeyboard(target: { addEventListener: (t: string, cb: (e: KeyboardEvent) => void) => void; removeEventListener: (t: string, cb: (e: KeyboardEvent) => void) => void; }, opts?: { preventDefault?: "bound" | "none"; }): () => void; /** Whether any declared action binds this key code. */ private codeIsBound; dispose(): void; isPressed(action: string): boolean; justPressed(action: string): boolean; justReleased(action: string): boolean; /** * Mouse button state WITHOUT declaring an action for it. * * A click on a tile is not a bindable action — there is no key to remap, and * asking a match-3 to declare `"click": { "keys": ["Mouse0"] }` in every scene * is ceremony. Buttons already live in the key set as `Mouse0`/`Mouse1`/…; * these read them directly. 0 left, 1 middle, 2 right. */ /** * Is this raw CODE down / just pressed? (`'ArrowUp'`, `'Enter'`, `'Pad12'`…) * * Menu navigation is not a bindable action — there is no key to remap, and * asking every scene to declare `"menuUp": { "keys": ["ArrowUp", "Pad12"] }` * is ceremony for something every menu on every platform does the same way. */ keyPressed(code: string): boolean; keyJustPressed(code: string): boolean; mousePressed(button?: number): boolean; mouseJustPressed(button?: number): boolean; mouseJustReleased(button?: number): boolean; /** Normalized direction for a vector2 action (y-down: up = -y). */ getVector(action: string): { x: number; y: number; }; /** Consume one-frame edges. The Engine calls this at the end of every tick. */ endFrame(): void; /** * @internal Start a tick: poll the devices that have no events of their own, * then open the frame. * * The gamepad used to be polled from an `engine.updated` handler — AFTER both * passes, and nine lines before `endFrame()` cleared what it had just * produced. So a pad's press edge was created and destroyed inside one tick * and `justPressed('jump')` was never true anywhere in the tree: * * PAD 8 taps -> update() saw justPressed 0, justReleased 0, isPressed 32 * KEY 8 taps -> update() saw justPressed 8, justReleased 8 * * A controller could hold but could never press: 0.00 px of jump on Pad0 * against 74.33 px on Space, and `HudLayer.focusNavigation` — the built-in * controller-menu feature — moved focus zero times. Polling HERE puts a pad * where a keyboard already is: an edge that exists before the frame opens. */ beginFrame(): void; /** Devices with no events of their own, polled at the start of every tick. */ private readonly pollers; /** * @internal The fixed pass is starting. Edges it has not seen yet are live. */ beginFixedStep(): void; /** * @internal The fixed pass is done for this tick — it has now seen the edges. * * Called after the FIRST fixed step only, so an edge reaches `fixedUpdate` * exactly once however many steps a tick runs. A tick that runs none leaves * this view untouched and the next one delivers it. */ endFixedStep(): void; /** * Drop all action declarations and injected action state (physical key * state is kept). The Engine calls this on setScene so keybinds never * bleed between scenes. */ clear(): void; private get; private button; } //#endregion //#region src/core/localization.d.ts /** The base locale. Every other locale falls back to it, one key at a time. */ declare const BASE_LOCALE = "en"; /** * The marker that makes a string prop translatable: `"@t:hud.score"`. * * A prefix rather than an object wrapper, matching the `"$assetKey"` reference * already in the engine — the prop is declared a string, so its value should * stay a string. It also stays authorable by hand: * * ```json * { "type": "UiText", "props": { "text": "@t:hud.score" } } * ``` */ declare const T_PREFIX = "@t:"; /** `locale → key → string`. A locale may declare any subset of the base's keys. */ type LocaleTables = Record>; /** The key a `"@t:…"` value names, or null for an ordinary string. */ declare function translationKey(value: string): string | null; declare class Localization { /** Fires with the new locale after it changes — for anything that caches. */ readonly changed: Signal<[string]>; private tables; private current; constructor(tables?: LocaleTables, locale?: string); /** * Merge a scene's `strings` table in. * * Merged rather than replaced: a game's shared UI strings live in the scene it * boots from, and a level that adds three lines of its own should not wipe * them. Per-key, so a level CAN override one string. */ load(tables: LocaleTables): void; get locale(): string; /** * Switch language. Setting one nobody declared is not an error — it simply * resolves everything through the base locale, which is the correct outcome * for a player whose browser reports a language this game does not ship. */ set locale(next: string); /** Every locale with at least one string — what a language picker offers. */ available(): string[]; /** * The string for `key`: current locale, else English, else the key itself. * * Returning the key rather than an empty string is deliberate — a screen * reading `menu.credits` tells an author exactly what to add, where a blank * one tells them nothing and looks like a rendering bug. */ t(key: string, params?: Record): string; /** * Resolve a prop value: `"@t:key"` translates, anything else passes through. * * This is what every text-bearing widget calls, every frame. A plain string is * returned untouched, so a game that never localizes anything pays a * `startsWith` per widget per frame and nothing else. */ resolve(value: string, params?: Record): string; /** * Whether the ACTIVE locale or the base declares this key. * * This is the runtime question, and it is not the same as `has`: a key only * `ko` declares is "declared", and an English player still gets the raw key * back from `t()`, because the fallback chain is current → base → the key. * Widgets that ask before translating asked the wrong one and painted * `settings.quality.low` into an English menu. */ declares(key: string): boolean; /** Whether any locale declares this key — for the audit, not for the runtime. */ has(key: string): boolean; } /** * The player's language, if the browser will say and the game ships it. * * A starting point only — the language setting is the real answer, exactly as * `suggestQuality` is a starting point for the quality tier. */ declare function suggestLocale(available: readonly string[], languages?: readonly string[]): string; //#endregion //#region src/core/order-groups.d.ts /** * Named draw-order bands — Unity's "sorting layers" for both dimensions. * A node's effective render order is `base(orderGroup) + renderOrder`, so * `renderOrder` stays a FINE offset within its band and whole categories * layer correctly without magic numbers: * * background < terrain < default < characters < effects < overlay * * Engine type defaults: particles sit in `effects`, Terrain3D in `terrain`; * everything else is `default` (base 0 — identical to pre-band behavior). */ type OrderGroup = "background" | "terrain" | "default" | "characters" | "effects" | "overlay"; declare const ORDER_GROUP_BASE: Record; /** * A scene's own bands, on top of the six built in. * * A band is a NUMBER, not a name — `base(orderGroup) + renderOrder` is the whole * mechanism — so a game that wants "ui-back sits between effects and overlay" * declares what it is worth: * * ```json * { "orderGroups": { "ui-back": 2500, "ui-front": 3500 } } * ``` * * Declaring a built-in name RE-BASES it for this scene, which is deliberate * (a 2D game with no terrain can reclaim that band) and is why the merge is * scene-last. */ type OrderGroupTable = Readonly>; /** Built-ins with a scene's declarations layered over them. */ declare function resolveOrderGroups(declared?: Record): OrderGroupTable; /** * base + fine offset. * * An unknown name resolves to 0 (`default`) rather than throwing, because this * runs per node per frame and a scene that got here has already been validated: * `loadScene` hard-fails an `orderGroup` naming a band nothing declares, which * is where a typo should be caught. */ declare function effectiveOrder(group: string, renderOrder: number, table?: OrderGroupTable): number; //#endregion //#region src/core/physics-query.d.ts /** What a ray found: how far, the surface normal, and the body it belongs to. */ interface RayHit { distance: number; normal: number[]; node: Node | null; /** * WHERE the ray landed, in world units — `origin + dir * distance`. * * The thing every caller actually wants: an impact effect, a decal, a tower * dropped on uneven ground, a waypoint. `incanto-web-integration.md` sends * you here for exactly that case — "`worldFromScreen` lands it on the ground * plane, and `rayFromScreen` hands you the ray when the ground is terrain or * a stack of crates" — and then the hit carried a distance and left the * arithmetic, and the normalization it depends on, to the caller. * * 2 numbers in 2D (pixels), 3 in 3D (metres), like every other position. */ point: number[]; } /** * The physics world, as core knows it — the QUERIES a behavior asks, with no * mention of Rapier or three, so `Engine` can hold one without core learning * about either runtime. `Physics2D` and `Physics3D` satisfy it structurally * (2D in PIXELS y-down with 2-vectors, 3D in METERS y-up with 3-vectors). * * This exists because `incanto-physics-and-input.md` has always taught * `physics.castRay(origin, dir, maxLen, exclude?)` without ever saying where * that `physics` comes from. It lived only on the object `createGame2D/3D()` * returns — the BOOT site — so a `Behavior`, which is where AI lives and where * line of sight is decided, could not reach it. A stealth game's guard had to * cast the private `node._physics` to call a documented API. */ interface PhysicsQuery { /** * Fire a ray and report the first thing it hits, or `null`. * * `dir` may be any length — `target - eye` is the usual spelling — and is * normalized, so `distance` and `maxLen` are both in world units (px / m) * rather than multiples of `dir`. A zero-length direction returns `null`. * * Sensors never block a ray. Pass `exclude` for the body you are casting from * — a ray starting inside its own collider hits itself at distance 0. */ castRay(origin: readonly number[], dir: readonly number[], maxLen: number, exclude?: Node, opts?: { staticOnly?: boolean; }): RayHit | null; /** * A THICK ray — a sphere sweep in 3D, a circle sweep in 2D. * * Use it where skimming matters: a thin ray that passes 10 cm over a wall * reports "clear" while the wall still fills the frame, and in 2D a shot can * thread a one-pixel gap between two floor tiles. Same filters and the same * direction contract as `castRay`; `distance` is the travel of the CENTRE. * * It was 3D-only, and therefore optional here, until 0.70. */ castSphere(origin: readonly number[], dir: readonly number[], radius: number, maxLen: number, exclude?: Node, opts?: { staticOnly?: boolean; }): { distance: number; node: Node | null; } | null; /** * How fast this body is actually moving, from the SOLVER. * * 2D answers in px/s (y-down), 3D in m/s (y-up). `linearVelocity` on the node * is written back once per step, so a behavior reading it from inside * `fixedUpdate` — after a collision, before the write-back — sees the value * from BEFORE the impact, which is exactly when a game wants to know how hard * it hit something. */ velocityOf(node: Node): number[]; /** * Mass the solver actually uses — collider-derived unless the body overrides * it. Scale a push by this rather than by the authored `mass` prop. */ massOf(node: Node): number; /** Tear the world down and detach from the engine. */ dispose(): void; } //#endregion //#region src/core/save.d.ts /** * Namespaced game persistence — high scores, unlocks, settings. localStorage * in the browser, in-memory headless (tests and SSR stay green). Values are * JSON round-tripped, so what you get back is what you saved. * * const save = createSaveStore('my-game'); * save.set('highScore', 4200); * const best = save.get('highScore', 0); */ interface SaveStore { get(key: string, fallback: T): T; set(key: string, value: JsonValue): void; remove(key: string): void; /** Wipe THIS namespace only. */ clear(): void; /** * Every key this namespace holds. * * Optional, so a store a game injected itself keeps compiling. Without it, * the slot layer could only find saves through its own INDEX — and one * corrupt byte in that index made every intact save on the machine * unreachable and then orphaned them for good on the next write. A store * that can be enumerated can be rebuilt. */ keys?(): string[]; /** * Keys whose stored text is not JSON any more. * * `parse` swallows a syntax error and answers with the fallback — the * RECOVERY is deliberate ("the fallback beats a crash"), the silence was not. * A truncated slot from a power cut is indistinguishable from a slot that was * never written, and a load menu that shows one fewer save than the player * remembers is the worst thing a save system can do quietly. */ corrupt?(): string[]; /** * Does a write here survive the page closing? * * `false` means this store is a Map that lives as long as the tab does — a * private window, storage disabled, quota exhausted, or headless. Everything * still works IN the session, which is exactly what made it undetectable: * measured in Safari-private conditions, `set('highScore', 4200)` then * `get('highScore', 0)` returned 4200, zero warnings, and the interface had * no way to ask. On reload the score was 0, the slot list empty, and * `SavePoint` had emitted `saved(1)` for a write that went nowhere. * * Read it once and tell the player their progress will not be kept. * * Optional so a game that injects its OWN store (a cloud backend, a test * double) keeps compiling; absent counts as persistent, which is what a store * somebody wrote on purpose almost always is. Every store the engine makes * says so explicitly. */ readonly persistent?: boolean; } declare function createSaveStore(namespace: string): SaveStore; //#endregion //#region src/core/save-slots.d.ts /** Behavior state, keyed by the uid of the node carrying it. */ type BehaviorState = Record; interface SaveSlot { /** Which scene to load before restoring. Whatever key your game routes on. */ scene: string; /** `uid → whatever that behavior's serialize() returned`. */ state: BehaviorState; /** Shown in a load menu. Free text — "Chapter 2 · Emberwood". */ label: string; /** Unix ms. Passed in, never read from a clock here, so saves stay testable. */ savedAt: number; /** Seconds played, if the game tracks it. */ playtime: number; /** Anything else the game wants in the slot (difficulty, seed, a thumbnail). */ data: JsonObject; } /** What a restore could not place, so a game can decide whether that matters. */ interface RestoreReport { /** Entries whose uid is not in this scene — a node deleted since the save. */ missing: string[]; /** Entries whose node has no behavior, or one that cannot deserialize. */ skipped: string[]; /** How many behaviors actually took their state back. */ restored: number; /** * How much state this tree HAD to give back — every behavior in it with a * `serialize`, addressable or not. * * `restored: 0` is ambiguous on its own and the ambiguity is expensive: a * hand-written scene with no uids saves `{}`, and the documented check * (`if (report.missing.length)`) passes on it while the whole run is gone. * `restored: 0, expected: 2` cannot be read as success. */ expected: number; /** Authored nodes the save says the run consumed, and that were freed again. */ freed: number; /** * Behaviors whose `deserialize` THREW, and what it said. * * They are in `skipped` too, because they took nothing — but a save this * build cannot read and a node that has no `deserialize` at all are different * problems, and `skipped` alone said both. */ refused: string[]; /** * Behaviors that took their state and then threw out of `announce()`. * * The state IS restored; the SCREEN was not told. That is the difference * between a correct save and a HUD showing zeros over a 1400-coin run, and it * used to be a bare `catch {}` — `restored: 2, expected: 2`, no log, no * error. */ unannounced: string[]; /** * Authored nodes THIS tree already consumed that the save says should still * be there — the tell that the scene was PLAYED before the restore. * * `#freed` can take nodes away and nothing puts them back, so restoring into * a live scene (a pause-menu Load, a slot menu, the canonical * death→reload-checkpoint wiring) leaves this run's collectibles deleted and * reports fully green. Measured on 3 gems worth 10 with `scoreToWin: 30`: * * ``` * CONTROL restartScene then restore → score 10, gems [Gem2,Gem3] → 30, won TRUE * death wired straight to restore → score 10, gems [Gem3] → 20, won FALSE * ``` * * …and the next autosave writes the hybrid back to the slot, so the * unwinnable run survives a page reload. Every other field is identical * between the two, which is why nothing could tell them apart. * * A node that freed ITSELF on a timer between load and restore lands here * too, which is why this is reported rather than thrown: the caller knows * which of its nodes are transient and the engine does not. */ stale: string[]; } /** * Every behavior in the tree that has something to say, by uid. * * A behavior with no `serialize` contributes nothing — the hook is optional in * exactly the way the other five are, so adding save support to a game is * additive and a game that never saves pays nothing. * * A node with no `uid` is skipped and reported, because state keyed by nothing * cannot be given back. The editor assigns a uid to every node it touches; a * hand-written scene may not have. */ declare function captureBehaviors(root: Node, source?: JsonValue, authored?: ReadonlySet): { state: BehaviorState; /** Paths of nodes that had state to save and no uid to save it under. */ unaddressable: string[]; /** Behaviors in this tree that have something to save — addressable or not. */ saveable: number; /** Authored nodes this run consumed that the `#freed` ledger cannot record. */ unrecordable: string[]; /** Behaviors whose `serialize()` THREW — their state is not in `state`. */ failed: string[]; }; declare function restoreBehaviors(root: Node, state: BehaviorState, authored?: ReadonlySet): RestoreReport; /** * The slot layer: a named list of saves over the existing store. * * Slot ids are yours (`'auto'`, `'1'`, `'2'`, `'3'`) — the engine does not * decide how many you have or whether one of them autosaves. */ declare class SaveSlots { private readonly store; constructor(namespace?: string, store?: SaveStore); /** * Will these slots survive the page closing? * * `false` in a private window, with storage disabled, or headless — every * write still succeeds and every read still answers, for as long as the tab * lives. A load menu should say so instead of offering a slot that will not * be there tomorrow. */ get persistent(): boolean; /** * The slot index — rebuilt from the store when it cannot be trusted. * * `store.get(INDEX_KEY, [])` falls back to an EMPTY LIST when the stored text * is not JSON, so one corrupt byte in a derived index emptied the whole load * menu while every save on the machine was still perfectly readable — and the * next `write()` then set the index to `[newId]`, orphaning them for good. * The index is a cache of what the store holds, and the store can be asked. */ private ids; /** Slot ids that have a save, newest first. */ list(): string[]; /** * Slots that EXIST and cannot be read — a truncated write, or a save from a * build that predates this one. * * `read()` answers `null` for both of those and for "no such slot", and * `list()` filters them out, so a save the player made silently disappeared * from the menu. A load screen should render "1 save could not be read" * rather than one fewer row, which is why this is a list and not a warning. */ problems(): Array<{ id: string; why: "corrupt" | "unreadable"; }>; private isCorrupt; /** Every slot with its contents — what a load menu actually renders. */ all(): Array; read(id: string): SaveSlot | null; /** * Write a slot. * * `savedAt` is a parameter rather than `Date.now()` so a test can assert an * ordering without sleeping, and so a game that wants server time can use it. */ write(id: string, slot: Omit & Partial): void; remove(id: string): void; /** Wipe every slot — a "delete all data" button, and test isolation. */ clear(): void; } /** * Behaviors in this tree that hold state and cannot save it — the audit's * question, answerable before a player loses an hour. * * A behavior with props but no `serialize` is not necessarily wrong (an * `Oscillate` derives everything from time), so this reports rather than warns, * and the caller decides. */ declare function behaviorsWithoutSave(root: Node): string[]; /** * Behaviors that DO save, on nodes with no uid to save them under — the state * that goes nowhere. * * `incanto-check` warns about the built-ins it can recognise from the JSON * (Health, ScoreKeeper, Collector), but a scene file cannot be asked whether * YOUR behavior has a `serialize`. This can: it walks the live tree, so a quest * log or a wallet of your own is named too. * * The pair to read before shipping: `behaviorsWithoutSave` is "did you forget * to write serialize", this is "did you forget the uid that makes it count". */ declare function savesWithoutUid(root: Node): string[]; //#endregion //#region src/core/scene/scene.d.ts /** * A loaded, live scene: the node tree plus everything needed to round-trip * back to scene JSON (declared connections, asset/input/multiplayer blobs). * * JSON is the source of truth for STRUCTURE; imperative listeners added via * `node.on(...)` are code, not data, and intentionally do not serialize. */ declare class Scene { readonly name: string; readonly dimension: "2d" | "3d" | undefined; readonly root: Node; readonly tree: SceneTree; /** Scene-level rendering environment (read by renderer adapters EVERY * frame, cheap when unchanged) — mutable so runtime look changes * (setEnvironment3D, DayNight) are just writes to this object. */ environment: JsonObject | undefined; /** Asset declarations (consumed by renderer/asset layers, `$key` references). */ readonly assets: Record | undefined; /** Named constant values (`{"@const": "NAME"}` prop references resolve to these). */ readonly constants: Record | undefined; /** Input action declarations (loaded into `Engine.input` on setScene). */ readonly input: JsonObject | undefined; /** Translations by locale (merged into `Engine.locale` on setScene). */ readonly strings: Record> | undefined; /** Extra draw-order bands (`name → base`), layered over the built-ins. */ readonly orderGroups: Record | undefined; /** Physics config (gravity etc. — interpreted by the physics modules). */ readonly physics: JsonObject | undefined; /** Declared piece of another scene — see `SceneJson.fragment`. */ readonly fragment: boolean | undefined; /** Multiplayer config (room etc. — interpreted by incanto/net). */ readonly multiplayer: JsonObject | undefined; /** Design-resolution viewport (consumed by renderers via resolveViewport). */ readonly viewport: JsonObject | undefined; private readonly connections; /** The full source JSON this scene was loaded from — `restartScene` fuel. */ readonly source: SceneJson; constructor(source: SceneJson, root: Node, tree: SceneTree); /** * Every uid this scene had the moment it finished loading. * * The `#freed` ledger's left-hand side: a uid in here that is not in the live * tree is a node this run consumed. Includes nodes inside expanded * `instance:` sub-scenes, which the scene FILE cannot show. */ readonly authoredUids: ReadonlySet; /** Lossless export. Note: expanded sub-scene instances serialize as full trees in M1. */ toJSON(): SceneJson; /** * The connections whose BOTH ENDS still exist in the tree being written. * * `toJSON` is what `incanto-play`'s `capture` writes — "your screenshot", the * skills call it — and it wrote every connection the file declared, including * ones pointing at nodes the run had freed. The result was a scene the engine * REFUSES to load: * * DANGLING_CONNECTION: connection to 'Coin' resolves nowhere * * so a captured game with any collectible in it produced a file no tool could * read back. Dropping a wire whose node is gone is what the tree already did * when it freed the node; this makes the written file say the same thing. */ private liveConnections; private resolves; } //#endregion //#region src/core/settings.d.ts /** One of three tiers, resolved once at boot and adjustable in an options menu. */ type QualityTier = "low" | "medium" | "high"; interface SettingsValues { master: number; sfx: number; music: number; muted: boolean; quality: QualityTier; /** Mouse/stick look sensitivity multiplier. */ sensitivity: number; invertY: boolean; /** Reduce camera shake, flashes and other motion. */ reduceMotion: boolean; /** * The player's language. `'en'` is the base and the default; anything a locale * does not translate falls back to English, silently and on purpose. */ language: string; /** * Has a HUMAN chosen the quality tier? * * Without this, a first boot cannot tell "the default happens to be high" from * "the player picked high", so device auto-detection would either never run or * would overwrite a deliberate choice on every launch. */ qualityChosen: boolean; /** Frames per second to draw at most. 0 = uncapped (draw every rAF). */ maxFps: number; /** * Extra multiplier on the render resolution, on top of the tier's pixel ratio. * 1 = as the tier says. Below 1 trades sharpness for frame rate. */ renderScale: number; } /** What a device probe can see without a GPU context. */ interface DeviceHints { hardwareConcurrency?: number; deviceMemory?: number; coarsePointer?: boolean; maxTextureSize?: number; } /** * A default tier from what the browser will admit to. * * Deliberately crude: this picks a STARTING point, and the options menu is the * real answer. A phone that reports four cores and a coarse pointer should not * open on the setting tuned for a desktop GPU. */ declare function suggestQuality(hints: DeviceHints): QualityTier; /** Read what this environment will tell us, safely, without touching WebGL. */ declare function readDeviceHints(): DeviceHints; declare class Settings { /** Fires after any change, with the key that changed. */ readonly changed: Signal<[keyof SettingsValues]>; private readonly store; private readonly values; constructor(namespace?: string, store?: SaveStore); get(key: K): SettingsValues[K]; set(key: K, value: SettingsValues[K]): void; /** Every value at once — for an options screen, or a save-file dump. */ all(): SettingsValues; /** Back to defaults, on disk too. */ reset(): void; /** * Choose a starting tier from the device, ONCE. * * Only on a first visit: after someone has picked a tier, re-detecting on * every launch would silently undo their choice — which is the behaviour every * player experiences as "the settings do not save". */ autoQuality(hints?: DeviceHints): QualityTier; /** Record that the tier came from a person, not from the device probe. */ chooseQuality(tier: QualityTier): void; /** * Apply the saved quality tier to a live scene, and keep applying it. * * The same shape as `bindAudio` and `bindLocale`. Until 0.38 this did not * exist: `settings.quality` was written to disk and read by NOBODY, and * `qualityEnvironment` — the function that turns a tier into something the * renderer understands — emitted patches the environment parser rejects, for * all three tiers. Both were public, documented and never once called. */ bindQuality(apply: (patch: Record) => void): () => void; /** * Apply the saved frame cap, and again whenever it changes. * * Separate from `bindQuality` because it is not a tier: a player caps the * frame rate to keep a phone cool or a battery alive, on the highest visual * settings the device can hold, and those two choices do not belong on one * slider. 0 means uncapped. Returns the unsubscribe. */ bindFrameCap(apply: (fps: number) => void): () => void; /** * Apply the saved resolution multiplier, and again whenever it changes. * * The lever that buys the most frames per unit of ugliness on a weak GPU — * shading 44% of the pixels at 0.66 costs a soft image and nothing else, * while dropping a quality tier costs shadows entirely. */ bindRenderScale(apply: (scale: number) => void): () => void; /** * Apply the saved language, and keep it in step both ways. * * The same shape as `bindAudio`: a game that writes * `engine.locale.locale = 'ko'` from an options menu has already persisted it * without knowing this class exists. Returns the unsubscribe. */ bindLocale(locale: Localization): () => void; /** * Push the saved volumes into the engine's buses, and keep writing them back * whenever anything changes them. * * The subscription is the point: a game that sets `engine.audio.music = 0.4` * from a slider has already persisted it, without knowing this class exists. */ bindAudio(audio: AudioBuses): () => void; } /** * What a quality tier MEANS, as a LIVE environment patch. * * Only the levers that actually cost: shadows, post, bloom and clouds. * Deliberately an `environment` patch, because `setEnvironment3D(engine, patch)` * already applies one live and validated — this is not a second rendering * pipeline, it is three presets for the one that exists. * * The `off` spelling matters and got this wrong for three releases: every tier * emitted `{ enabled: false }`, which the environment parser rejects * (`environment.bloom has unknown key 'enabled'`). Nothing called this function, * so nothing found out. In a live patch `null` DELETES a key — that is how a * stage is turned off — and `shadows` alone takes a real `false`. * * Antialiasing and pixel ratio are NOT here: `antialias` is a WebGL context * attribute and cannot change without rebuilding the renderer. See * `qualityRendering`. */ declare function qualityEnvironment(tier: QualityTier): Record; /** * What a quality tier means for the levers that can only be set at BOOT. * * `antialias` is a context attribute: three fixes it when the WebGL context is * created, so a settings menu that changes it is choosing what the NEXT launch * gets. Every engine has this constraint and every game's options screen says * "requires restart" next to it — `createGame3D` reads the saved tier at boot, * so the restart is all it takes. * * `pixelRatio` CAN change live (adaptive resolution already does it), but it * belongs with antialias as "how many pixels do we draw" rather than "what is in * the scene". */ /** * The per-tier CEILING for nodes that are expensive on their own. * * The quality tier travels as an environment patch, and an environment patch * can only reach environment stages — shadows, bloom, post, clouds. Water is * the single most expensive thing this engine draws and it is a NODE, so no * tier could touch it. * * These caps ride the render sync walk instead (see `SyncOptions.quality`). The * contract is one-directional and everything rests on it: **a cap clamps DOWN, * never up.** A tier may take away what the scene asked for; it may never give * a scene something it did not ask for, or `high` on a phone would silently * upgrade a deliberately cheap pond into a mirror. */ interface QualityCaps { /** * Whether a water surface may run its multi-pass "fancy" path at all. * * False collapses it to the single-pass lake shader: no planar mirror, no * reflection cube, no refraction grab, no depth completion — 8.4 scene * submissions a frame become 3.5. It is the largest single saving available * on a low-end device, and it is not free: the simple shader has no samplers, * so the shoreline dissolve, contact foam and depth absorption go with it. */ fancyWater: boolean; /** * Whether a water surface may run its planar MIRROR pass — a whole extra * render of the scene, per water node, per frame. * * The most expensive single thing water does, and the first to go on a phone * that can still afford the rest of the fancy shader. The surface keeps its * cube reflection, so it still mirrors the sky and the far shore; what it * loses is the sharp reflection of nearby geometry. */ waterMirror: boolean; /** * Floor (ms) on how often a reflection may be re-rendered. 0 = the scene * decides. * * This exists because of a trap: the reflection cube runs on a SLOW schedule * precisely because a mirror is present. Suppress the mirror without holding * that schedule and the cube starts refreshing at the scene's own interval — * six faces of the scene, several times a second — which hands most of the * saving straight back. */ minReflectionIntervalMs: number; } //#endregion //#region src/core/phase-timing.d.ts /** * Where the frame went — the breakdown `stats()` could not give. * * fps and frameMs are a TOTAL, and a total has no next question. This engine * has already lost a day to that: a frame drop everyone attributed to the GPU * turned out to be garbage collection, and nothing on screen could have said * so. Three of a frame's four parts are the engine's own work, and the fourth * is what is left — which is exactly where GC and browser work hide. * * Means over the same rolling window as `frameMs`, so the numbers are * comparable to each other and to the whole. */ /** One frame's own accounting, in milliseconds. */ interface FramePhases { /** Fixed steps: physics, and anything on `fixedUpdate`. */ fixedMs: number; /** The variable update: behaviors, node logic, tweens. */ updateMs: number; /** Whatever renders, as reported by it. 0 when nothing does (headless). */ renderMs: number; /** * The frame minus the three above. GC, browser layout, input handling, * anything outside the engine's own loop — and the one to look at when the * others are small and the frame is not. */ otherMs: number; } //#endregion //#region src/core/stats.d.ts /** Loop-side counters from `engine.stats()` — queryable at any time. */ interface EngineStats { /** Frames per second over the last ~60 REAL ticks (headless runs: 0). */ fps: number; /** Mean frame delta in ms over the same window (headless runs: 0). */ frameMs: number; /** * How long ago the newest frame arrived, in ms. `null` when none has. * * `fps` is a rate over a WINDOW, so it keeps answering after the frames stop * — a hidden tab, a descheduled loop, a page the browser froze. Measured on * an engine that ticked 40 times and then stopped being ticked: * * ``` * while ticking : fps 59.9 * after 0 more ticks : fps 59.9 <- the last sample is 668 ms old * ``` * * Both numbers are true about the window they describe; nothing said WHEN * that window was, so "60 fps" read the same whether it was measured 8 ms ago * or four seconds ago. A reader that cares should compare this against a * frame budget before trusting `fps`. */ lastFrameAgeMs: number | null; /** Current node count of the active scene tree (no scene: 0). */ nodes: number; /** Whether the loop is scheduled (start() without stop()/dispose()). */ running: boolean; /** * Errors swallowed to keep the game alive since start: nodes that quarantined * themselves plus frames that threw. Non-zero means something IS broken and * `engine.log` says what — a green-looking game with `errors: 7` is the case * this counter exists for. */ errors: number; /** * Where the frame went: fixed (physics), update (behaviors), render, and * everything else. Means over the same window as `frameMs`. */ phases: FramePhases; } /** GPU-side counters from `renderer.stats()` — the LAST rendered frame. */ interface RendererStats { triangles: number; drawCalls: number; geometries: number; textures: number; } /** The merged `game.stats()` surface returned by createGame2D/3D. */ type GameStats = EngineStats & RendererStats; //#endregion //#region src/core/engine.d.ts /** Schedules a per-frame callback; returns a disposer. */ type Scheduler = (cb: (nowMs: number) => void) => () => void; interface EngineOptions { /** Fixed-update frequency in Hz (default 60). */ fixedHz?: number; /** Spiral-of-death clamp: max fixedUpdate steps per tick (default 5). */ maxFixedStepsPerTick?: number; /** Frame source (default: requestAnimationFrame loop). Inject for headless tests. */ scheduler?: Scheduler; /** Seed for `engine.rng` — set it to make a run reproducible (default: random). */ seed?: number; /** Frame cap in fps; 0 (default) runs at whatever the display offers. */ maxFps?: number; /** * Which SETTINGS this game owns — its own, or the origin's. * * Persisted settings are keyed `incanto::` in the browser's * storage, and this defaulted to `settings` with no way to change it. So two * Incanto games served from one domain shared one volume slider, one language * and one quality tier. Measured: game A's player sets * `renderScale 0.25, muted, quality low`; game B — whose source never mentions * settings — boots at a 480×232 drawing buffer, silent. * * `SaveSlots(namespace)` and `createSaveStore(namespace)` have always taken * one and document it as "keeps two games on one origin apart". This is the * third sibling finally saying the same thing. * * Pass a `Settings` instance instead when a game wants to share deliberately * (a launcher and its titles), or drive them from its own store. */ settings?: Settings | { namespace: string; }; } /** * The game loop: drives a Scene's tree with fixed-timestep `fixedUpdate` * (physics/network window) and variable `update` (everything else). * * Instance-scoped by design — multiple engines can coexist. Headless-testable * via the injectable scheduler and the public `tick(nowMs)`. */ declare class Engine { /** Emitted after the tree's variable update each frame, with dt seconds. */ readonly updated: Signal<[number]>; /** Emitted after each fixed step, with the fixed dt seconds. */ readonly fixedUpdated: Signal<[number]>; /** * Emitted after a scene swap completes (input map redeclared) with the new * scene, and with null on dispose — overlays (touch controls, debug panels) * rebuild themselves here. */ readonly sceneChanged: Signal<[Scene | null]>; /** Declarative input — scene `input{}` declarations load on setScene. */ readonly input: InputMap; /** * The physics world, once something enabled it — `null` in a game that has * none, which is most 2D puzzle games and every menu scene. * * `enablePhysics2D/3D` set this, and `dispose()` clears it. It is the handle * `incanto-physics-and-input.md` has always assumed you had: the skill teaches * `physics.castRay(...)` but the object lived only where the game BOOTED, so * a `Behavior` — where AI lives, and where line of sight is decided — had no * way to reach it short of casting the private `node._physics`. * * Typed as the query surface rather than the concrete class so core never * learns about Rapier or three. From a behavior, prefer `this.physics`. */ physics: PhysicsQuery | null; /** * How to build a physics world for a scene that needs one. * * `createGame2D`/`createGame3D` install this; core never learns what Rapier * is. It exists because `physics: 'auto'` can only look at the scene the game * BOOTED with, and the most universal act of finishing a game is putting a * title screen in front of it: * * ``` * after boot on the title : engine.physics = null * after goToScene into game: engine.physics = null * player y after 2 s : 0 -> 0 * ``` * * The game rendered perfectly and was inert — nothing fell, nothing collided, * nothing could be collected or hurt. One scene swap turned a working game * into a diorama, and nothing was logged. */ physicsProvider: ((root: Node) => Promise) | null; private ensuring; /** * Give the current scene a physics world if it needs one and has none. * * Idempotent and safe to call on every swap: the provider decides whether the * incoming tree actually wants physics, and an explicit `physics: false` at * boot installs no provider at all, so opting out stays opted out. * * Asynchronous because the Rapier WASM has to load. The first frames after a * swap therefore run unsimulated — which every AI behaviour already tolerates * (`moveBody` falls back to a direct write when `_physics` is null) and which * is a hitch, where the alternative was a game that never simulated at all. */ ensurePhysics(): Promise; /** * The seed this engine was built with — game logic draws from `rng`, and * anything DECORATIVE derives its own stream from this instead. * * A particle emitter used to draw from `rng`, and it spawns a number of * particles that depends on `dt`. Measured on the same scene and seed, the * same simulated two seconds, comparing the world's own draws by update * index: * * ``` * particles off: 60fps and 30fps identical * particles ON : they diverge at update 2 * ``` * * So a purely visual effect changed the game's random decisions, and the * PLAYER'S FRAME RATE decided them. Derived streams stay reproducible for * replays and screenshots without spending the one the game reads. */ readonly seed: number; /** * How to resolve an `instance:` sub-scene, remembered from the boot load. * * `loadScene(json, { engine, resolveScene })` records it here, so the paths * that RELOAD — `restartScene`, `goToScene`, a game-over restart key — can * pass it back. Without that a game using the documented sub-scenes booted * fine and threw `UNRESOLVED_INSTANCE` on its first restart. */ resolveScene?: (path: string) => SceneJson | null | undefined; /** * How the current scene was LOADED, for the rebuilds that happen later. * * `duplicateNode` — every `Spawner`, every `NetworkSpawner`, every clone a * game makes — rebuilds a subtree from scene JSON long after `loadScene` * returned, and it did so with no options at all. Under * `stubMissingBehaviors` (how EVERY headless check runs a game whose scripts * are TypeScript it cannot import) the first spawn met the real registry and * threw `UNKNOWN_BEHAVIOR` out of `update`, disabling the spawner for the * rest of the run — a wave game reported with no waves and nothing saying * why. * * Recorded here for the same reason as `resolveScene` above: the engine * outlives the call, and the rebuild paths can reach it. */ loadOptions: { stubMissingBehaviors?: boolean; stubAllBehaviors?: boolean; }; /** Seeded randomness for game logic (deterministic when `seed` is set). */ readonly rng: Rng; /** The engine log channel (debug overlay + headless harness tail this). */ readonly log: LogManager; /** * Screen pixel → the node under it, when a renderer has offered one. * * The raycast has always existed (`Renderer3D.pick` / `Renderer2D.pick`) and * was reachable only from whoever held the renderer — so a Behavior, which * holds a node, could not use it. Every mouse-driven genre needs exactly this * and had no way to ask. Instance-scoped, set by the renderer on construction * and cleared on dispose; null when nothing renders (headless). */ picker: ((x: number, y: number) => Node | null) | null; /** * Screen pixel → a point in the WORLD, when a renderer has offered one. * * `picker` answers "which node"; this answers "which place", and a mouse game * needs both — drag-and-launch, drop a tower on the map, steer a cursor unit, * draw the slingshot band. The conversion has always existed * (`Renderer2D.worldFromScreen` / `Renderer3D.worldFromScreen`) and lived * only on the renderer, so a Behavior — which holds a node and an engine and * nothing else — could not call it. Its two workarounds were both forbidden: * stash the renderer in a module global, or hand-roll the projection the 2D * skill says in so many words not to hand-roll. * * The answer is the scene's own units and shape: `[x, y]` in 2D (design * pixels), `[x, y, z]` on the ground plane in 3D — the same array a node's * `position` is, so it goes straight back into one. * * Instance-scoped, set by the renderer on construction and cleared on * dispose. Headless a harness may install a geometric one; null when nothing * has. */ toWorld: ((x: number, y: number) => number[] | null) | null; /** * A world point → the screen pixel drawing it. The inverse of `toWorld`, for * pinning DOM to a node or placing a marker over one. * * `behind` is true when the point is behind the camera (3D); a 2D renderer * always reports false. */ toScreen: ((world: readonly number[]) => { x: number; y: number; behind: boolean; } | null) | null; /** * Where the cursor is in the world, or null. * * The two-line dance every mouse game writes — read the pointer, convert it — * with the two ways it goes wrong already handled: a pointer nothing has * moved yet is null rather than the origin, and no converter is null rather * than a guess. */ /** * Screen pixel → a ray into the world, when a renderer has offered one. * * `toWorld` lands on a plane; this is the general form, and it is what a shot * aimed at what the cursor is OVER needs: feed it straight to * `engine.physics.castRay(origin, dir, …)`. 3D only — a 2D renderer installs * nothing here, since a 2D world has no depth to cast into. */ screenRay: ((x: number, y: number) => { origin: number[]; dir: number[]; } | null) | null; pointerWorld(): number[] | null; /** Frames the engine has stepped — monotonic, and the pick cache's key. */ frameNumber: number; /** @internal frame number + answer, so N Clickables cost ONE raycast. */ private pickCacheFrame; private pickCacheKey; private pickCacheHit; /** * The node under (x, y), answered at most ONCE per frame per cursor position. * * `Clickable` asks every frame, and a board game has one per cell: 42 pads * measured **420 picker calls over ten frames** — one GPU raycast per pad per * frame, all answering the same question about the same pixel. At 60 fps that * is ~2500 raycasts a second to find out what the cursor is on, and a 200-tile * board was unusable. * * Cached on (frame, x, y), so a game that picks at two different points in one * frame still gets two real answers. */ pickAt(x: number, y: number): Node | null; /** Global volume buses — `master` × `sfx`/`music` gain + `muted`. AudioPlayer * routes its volume through this; games set it for global volume/mute. */ readonly audio: AudioBuses; /** * What SHOWED — the visual half of the audio record. Particle bursts, camera * shake, screen flash and freeze frames land here, so "did the explosion * fire?" is answerable in a run with no renderer. */ readonly effects: EffectLog; /** * Persisted player settings — volume, quality, sensitivity. * * `createSaveStore` was good and had zero callers, so every game's volume * reset on reload. Reading it here costs nothing (localStorage, once) and * `bindAudio` makes the volumes write themselves. */ readonly settings: Settings; /** * Translations, and the locale in force. English is the base and the default; * anything a locale does not declare falls back to it silently. */ readonly locale: Localization; /** * Draw-order bands in force: the six built in, plus whatever the current * scene declares. Read by `Node2D`/`Node3D.effectiveRenderOrder`. */ orderGroups: OrderGroupTable; /** Low-latency procedural-SFX player (WebAudio, headless-safe). AudioPlayer * presets play through it; games may call `engine.sfx.play(...)` directly. */ readonly sfx: SfxEngine; /** Single-track background-music manager (crossfade/loop, headless-safe). * `engine.music.play(src)` / `crossfadeTo(src, secs)` / `stop(fadeOut)`; * routes through the music bus and is advanced each frame by the loop. */ readonly music: MusicManager; /** * Where nodes that build DOM (HudLayer and its widgets) mount their elements. * * `null` — the default — means `document.body` with `position: fixed`, i.e. a * game's HUD covers the window, which is what a game wants. A host that only * OWNS PART of the page sets this to its own (positioned) container so the * HUD stays inside it: the scene editor points it at the viewport pane, which * is why an edited scene's HUD no longer floats over the inspector. */ uiHost: HTMLElement | null; private readonly fixedStep; private readonly maxFixedSteps; private readonly scheduler; private _scene; private lastMs; private accumulator; private disposeScheduler; private readonly frameStats; /** Where each frame's time went — the breakdown `frameMs` alone cannot give. */ private readonly phaseTimings; /** This frame's render cost, as reported by whoever renders. */ private renderMs; /** * Game-time multiplier: 1 = realtime, 0.5 = slow motion, 0 = frozen * (hit-stop / pause). Scales BOTH variable and fixed updates — physics, * timers, behaviors all breathe together. See `gameplay` `hitStop()`. */ get timeScale(): number; set timeScale(v: number); private _timeScale; private _timeScaleWrites; /** * How many times anyone has written `timeScale` — the clock's ownership tag. * * A temporary freeze cannot tell its own 0 from somebody else's by VALUE. * `hitStop(engine)` then `flow.gameOver()` is the ordinary killing blow, and * both set 0; the freeze then wrote its captured scale back five frames later * and the world started moving again behind a sticky GAME OVER banner — * enemies walking, timers running, the player watching their corpse get shot. * * A counter answers the question the value cannot: *has anyone touched this * since I did?* Cheap (one increment per write, and writes are rare) and it * needs no registry of who holds what. */ get timeScaleWrites(): number; /** * The node the dev overlay has selected (renderers draw its bounding box * as an orange outline in the game view so you can SEE what you picked). * Null when nothing is selected; cleared automatically when it leaves the * tree. Set by the debug overlay — games normally never touch this. */ debugSelection: Node | null; private _time; private _unscaledTime; /** * Elapsed GAME time in seconds since the scene started — the sum of every * scaled dt (freezes at timeScale 0, crawls in slow motion). Unity's * `Time.time`. Resets on `setScene`. */ get time(): number; /** Elapsed REAL time in seconds since the scene started (ignores timeScale) — * UI animations that must keep moving during slow-mo/pause read this. */ get unscaledTime(): number; /** * THIS frame's dt in real seconds — the one a paused game still gets. * * `update(dt)` receives the SCALED dt, which is right for anything that is * part of the simulation and wrong for everything that is a presentation of * it. At `timeScale = 0` — which is what `GameFlow.pause()` and, by default, * `gameOver()` set — a scaled countdown does not count: * * a timed UiBanner still up when the player dies never expires, so the * flow's own sticky GAME OVER banner is queued behind it and NEVER * PAINTED — the ending screen shows the last wave toast, forever * a live CameraShake never settles: `t -= dt` is a no-op while the rest of * update() re-rolls a full-magnitude offset every rendered frame, and * with FollowCamera's smoothing that is an unbounded random walk * a remote player's interpolation stops, so everyone else is a statue while * their real positions keep arriving * * `hitStop`, `AudioPlayer` and `music.tick` already use the real clock for * exactly this reason. This is the same clock, per frame, so a countdown can * use it without reading `unscaledTime` twice and subtracting. */ get unscaledDelta(): number; private _unscaledDelta; constructor(opts?: EngineOptions); get scene(): Scene | null; /** Replace the active scene. The previous scene's root is freed. */ setScene(scene: Scene): void; start(): void; /** * The frame cap: fps, or 0 for "as fast as the display goes" (the default). * * Live — a settings menu writes it mid-game. It gates the whole frame, not * just the render: an uncapped loop on a 144 Hz phone burns battery running * game logic nobody sees. * * `tick()` is deliberately NOT gated. A headless harness driving its own * clock asked for that frame; the cap is about the real-time loop. */ maxFps: number; private nextFrameAt; private lastOfferedAt; private displayPeriodMs; /** * Should this display frame run? * * The obvious `if (now - last < interval) return` is wrong in a way that only * shows up between the round numbers: a 45 fps cap on a 60 Hz display misses * every other deadline by a fraction of a millisecond, waits a whole display * frame more, and delivers 30 — asking for 45 makes the game slower than not * capping at all. The fix is to allow a frame that is within half a display * period of its deadline, and to advance the deadline by the interval rather * than from `now`, so the error does not accumulate. */ private frameIsDue; /** * A frame threw somewhere outside a single node (a renderer, physics, a * signal handler). Report the first one and count the rest: a broken frame * repeats sixty times a second, and sixty identical traces a second is how * you lose the first one. */ private reportFrameError; /** @internal A node quarantined itself; count it for `stats().errors`. */ _nodeErrored(_node: unknown): void; /** * @internal Something OUTSIDE a frame failed — a replication send that * rejected, a transport that refused. Count it and say it once. * * `stats().errors` is what every harness in the ecosystem ends with, and it * only ever counted things that threw INSIDE a frame. A multiplayer client * whose socket died kept running perfectly on its own screen while every * other player watched it frozen, and `errors: 0` with an empty log said the * game was fine. Repeats are counted and not re-printed, for the reason * `reportFrameError` gives: sixty identical traces a second is how you lose * the first one. */ _asyncFailed(what: string, error: unknown): void; private lastAsyncError; /** Re-entrancy guard for `setScene` — an exit hook can now reach the engine. */ private swapping; private errorCount; private lastFrameError; /** * Every node with something quarantined — its own update, its script, or * both — so a tool can list them and put them back. Walks on demand; there is * no per-frame bookkeeping for this. */ /** * The string for `key` in the current locale — English if this locale does not * declare it, the key itself if nothing does. * * The short form of `engine.locale.t`, because a behavior reaching for a * translated string is the common case: * `banner.show(this.engine.t('wave.start', { n: wave }))`. */ t(key: string, params?: Record): string; /** * Every behavior's state, ready to put in a save slot. * * Pairs with `restoreState` after the scene has RELOADED — see * core/save-slots.ts for why a save carries state rather than a tree * snapshot. */ /** * Behaviors whose `serialize()` threw during the LAST `captureState()`. * * A write path that cannot see part of the run must not overwrite the slot * that still can — `SavePoint.save()` reads this and refuses. */ get lastCaptureFailures(): readonly string[]; private _captureFailures; captureState(): BehaviorState; /** Give it back, after the scene is loaded and onReady has run. */ restoreState(state: BehaviorState): RestoreReport; erroredNodes(): Node[]; /** Un-skip every quarantined node — the "try again" after you fix the code. */ resumeErroredNodes(): number; /** * How long the last render took, in ms — called by whoever renders. * * The engine does not render, so this is the one slice it cannot measure for * itself; without it the cost lands in `otherMs` beside the GC and the two * are indistinguishable, which is the confusion this whole breakdown exists * to end. */ noteRenderMs(ms: number): void; stop(): void; /** * Live performance counters, queryable at ANY time. fps/frameMs come from a * rolling window of REAL `tick` timestamps — headless `step()` runs report 0. * Node count walks the active tree on demand. Renderer counters (triangles, * draw calls) live on `renderer.stats()` / the merged `game.stats()`. */ stats(): EngineStats; /** * Render interpolation factor in [0,1]: how far the wall clock has advanced * INTO the next fixed step. Renderers lerp physics bodies between their last * two fixed-step transforms by this much so motion looks smooth even when the * display refresh doesn't divide evenly into the 60Hz fixed step (the classic * fixed-timestep judder). Only meaningful in the real-time `tick()` loop; * the headless `step()` path doesn't bank wall-clock time. */ get interpolationAlpha(): number; /** * Full teardown in one call: stop the loop, free the scene tree, detach * every input listener. The single-unmount story for SPA embedding — * renderers own GPU resources and keep their own dispose(). */ dispose(): void; /** * Advance exactly ONE fixed step and one variable update (both dt = the * fixed step scaled by `timeScale`), bypassing the wall-clock accumulator * entirely. Drift-free by construction — the unit of time for headless * harnesses (incanto/test runScript): every input edge is visible to BOTH * fixedUpdate and update exactly once. * * `ignorePause: true` steps a frame at full scale even while the game is * paused — the debug overlay's single-step button, and nothing else. */ step(opts?: { ignorePause?: boolean; }): void; /** Advance the loop manually. First call after (re)start only primes the clock. */ tick(nowMs: number): void; } //#endregion //#region src/core/prop-range.d.ts /** * The numeric range a prop documents, checked at LOAD. * * The docs give ranges constantly — `metalness`/`roughness` "0..1 … `-1` = keep * authored", `targetHeight` "> 0 uniformly scales the model", `opacity` 0..1 — * and the schema had no way to say so, so every one was a type check and * nothing more. Measured on `ModelInstance3D`: * * ``` * targetHeight: -1.8 NO ERROR (kept -1.8) * metalness: 5 NO ERROR (kept 5) * roughness: -3 NO ERROR (kept -3) * ``` * * The two failure modes differ, which is why neither is noticed. A negative * `targetHeight` folds into the same branch as "not set", so the model keeps * its authored size and looks like the prop did nothing; `metalness: 5` reaches * three and the surface goes wrong. An author who mistypes a decimal point gets * a different wrong answer depending on which prop they mistyped it in, and no * message in either case. */ interface PropRange { /** Lowest accepted value. */ min?: number; /** Highest accepted value. */ max?: number; /** `min` is a bound the value must EXCEED, not reach (`targetHeight > 0`). */ exclusiveMin?: boolean; /** * `max` is a bound the value must stay UNDER, not reach. * * `Camera3D.fov` is the case: 180 is a legal number and a degenerate * frustum — the whole world collapses to a point at screen centre and the * framing instrument still calls it on screen. */ exclusiveMax?: boolean; /** * Values outside the range that mean something anyway. * * `metalness: -1` is "keep whatever the GLB authored" — a real value with a * real meaning, and the reason a range cannot just be a clamp. */ allow?: readonly number[]; } //#endregion //#region src/core/registry.d.ts /** A node prop definition. The default both documents and types the prop. */ interface PropDef { default: JsonValue; /** For enum-like string props: the valid values. Drives editor/debug * dropdowns and the generated JSON-Schema enum — keep it exhaustive. */ options?: readonly string[]; /** * Every JSON kind this prop accepts, when it accepts more than the default's. * MUST contain the default's own kind — `registerNode` refuses a list that * does not, because that default would be unwritable in a scene. * * A prop typed `boolean | JsonObject` in TypeScript is one kind to the loader: * whatever the default happens to be. `Water3D.underwater` (default `true`) * and `Flowers3D.density` (default `'sparse'`) both wrote a `validateJson` * branch for the other half — checking `underwater.visibility`, budgeting * plants/m² — and neither branch could be reached from a scene, because the * kind gate runs first. Both halves were in the skills; only one loaded. * * When a value's kind is not the default's, the kind gate is all this file * checks: `options`, `range` and the array template describe the DEFAULT's * kind, so the node's own `validateJson` owns the other half's shape. */ kinds?: readonly JsonKind[]; /** * This prop holds a NODE PATH (`%Unique`, `/Absolute/From/Root`, or one * relative to the node that owns the prop) resolved with `getNodeOrNull`. * * Declaring it is what lets a tool rewrite the reference when the target is * renamed. Without it a rename silently breaks the link — the resolve returns * null and the game keeps running with an enemy that never chases anyone. * Set it on every prop you feed to `getNode`/`getNodeOrNull`. */ nodePath?: boolean; /** * The prop must be given a value — an empty string is not a configuration. * * `Chase.target` and `FollowCamera.target` threw for this in `onReady`, which * is the browser, at the moment the level starts. Declared here it is a LOAD * error instead: `incanto-check` reports it while you are still writing the * scene, and the editor knows not to "unlink" a prop whose emptiness the * engine refuses. */ required?: boolean; /** * This prop names an entry in the scene's `assets{}` block. * * Declaring it is what makes a typo a LOAD error. The check already existed — * `AssetStore2D.resolve` throws `UNKNOWN_ASSET` — and it runs inside the * renderer, in a browser, when the texture is first needed. Headless the * scene loaded clean, so `incanto-check` certified a sprite that will draw * nothing, and the docs' promise that "the engine hard-fails on a raw URL" * was not true anywhere an author could see it. * * The same story as `nodePath` and `required`: the rule existed, nothing * routed to it at author time. */ /** * The numeric range this prop documents. * * Without it a documented range is prose only: `metalness: 5` and * `targetHeight: -1.8` both passed the kind check and then failed in two * different silent ways. See prop-range. */ range?: PropRange; asset?: { /** The `assets{}` entry `type` a `$ref` here must point at. */ /** * `audio` has no `assets{}` entry type — `AudioPlayer.src` takes a url and * only a url. It is declared anyway so the prop is VISIBLE to everything * that walks asset props (the editor's picker, `incanto-check`'s * missing-file warning), which is the whole point of the spec. */ kind: "texture" | "spritesheet" | "model" | "animation" | "audio"; /** * A value that is not a `$ref` is an error. * * False for props that legitimately take something else — a raw URL * (`UiImage.src`, `Sprite3D.texture`) or the name of a clip baked into a * GLB (`ModelInstance3D.animation`). */ refOnly?: boolean; /** * A `$ref` here is an error — this prop takes a raw URL. * * The 3D texture props declared no `asset` spec at all, so * `validateAssetRefs` never looked at them: `"texture": "$hero"` — the * spelling the 2D sibling's own error message teaches — was certified valid * at author time and then fetched as a relative path, 404ing at runtime * with a sprite that simply never appears. */ urlOnly?: boolean; /** * The prop is an OBJECT whose values are the references, not a string. * * `CharacterController3D.animations` is the zero-TypeScript way to animate * a character — `{ "idle": "$idle", "run": "$run" }` — and every one of * those values is an asset ref that nothing validated. A typo there is a * character that never animates, with no error anywhere, which is the exact * class of silence the hard-error rule exists to prevent. */ mapValues?: boolean; }; /** * For an OBJECT-valued prop shaped as a tagged union — `collider`, whose * `shape` decides which other keys are required. One declaration gives an * inspector both the dropdown AND something valid to write when the tag * changes: switching `box` → `sphere` must not leave a `size` and no * `radius`, which is a hard load error. */ variants?: { /** The key that picks the variant (e.g. `'shape'`). */tag: string; /** Tag value → a COMPLETE, valid value for the prop. */ byTag: Record; }; } type PropSchema = Record; interface NodeCtor { new (name?: string): Node; readonly typeName: string; readonly props?: PropSchema; readonly signals?: readonly string[]; } declare function registerNode(ctor: NodeCtor, opts?: { replace?: boolean; }): void; declare function getNodeType(typeName: string): NodeCtor; declare function registeredTypes(): string[]; /** Test isolation helper. */ declare function clearRegistry(): void; /** * Effective prop schema for a type: own static `props` merged over every * ancestor's (subclass keys win). */ declare function getNodeSchema(typeName: string): PropSchema; /** Walk a constructor's prototype chain collecting static `signals` (deduped). */ declare function mergeStaticSignals(ctor: { signals?: readonly string[]; } | null): string[]; /** Every signal a registered type declares (its own + inherited). */ declare function getNodeSignals(typeName: string): string[]; /** * Instantiate a registered type, assign schema defaults (cloned), then the * given props — validating every key and its JSON kind against the default. */ declare function createNode(typeName: string, props?: Record, extraOptions?: Record): Node; //#endregion //#region src/core/behavior.d.ts interface BehaviorCtor { new (): Behavior; props?: PropSchema; signals?: readonly string[]; } /** * Vibe-coded logic attached to a JSON-declared node — the ONE script a node * may carry (`"script": {"name": "PlayerController", "props": {...}}`). * * Lifecycle hooks run AFTER the node's own. Props follow the same * schema/defaults/delta model as node props. */ declare abstract class Behavior { /** * Every behavior accepts `enabled`, inherited down the static chain — see * `enabled` below for why it is a universal prop rather than one each AI * behavior declares for itself. */ static props?: PropSchema; /** * Whether this behavior's `update`/`fixedUpdate` run. `false` stops it * without removing it — and this is what makes ENEMY AI expressible in JSON. * * A node holds ONE behavior, so `Patrol` and `Chase` cannot share one. Both * can ride child nodes with `moveParent`, but then both run at once, and * before this nothing could stop either — so "patrols until it spots you, * then chases", the most ordinary AI there is, had to be hand-written in * TypeScript, which is exactly what the gameplay library exists to prevent. * With this the whole state machine is two wires: * * ```jsonc * { "signal": "spotted", "from": "Eyes", "to": "Walk", "handler": "disable" }, * { "signal": "spotted", "from": "Eyes", "to": "Hunt", "handler": "enable" } * ``` * * `Clickable` had already hand-rolled this exact prop for itself. One * behavior needed it and solved it locally; nothing generalized it. * * Asleep is NOT detached: the behavior keeps its props and its accumulated * state, stays reachable as `node.behavior`, keeps its signal connections, * and still saves through `serialize()`. Only the per-frame hooks pause. The * lifecycle hooks (`onEnterTree`/`onReady`/`onExitTree`) are not gated — they * are structural, they fire once, and a behavior that skipped its own * `onReady` would wake up uninitialized. */ enabled: boolean; /** Start running again — a connection handler, so a wire can do it. */ enable(): void; /** Stop running without being removed — a connection handler. */ disable(): void; /** * Custom signals this behavior emits via `this.emit(...)` — declared onto * its node at load. Undeclared emits are hard errors (typo safety). */ static signals?: readonly string[]; /** The node this behavior is attached to (assigned by the loader). */ node: Node; /** * The running engine, reached through the scene tree. * * The message names the FIX, because there is one and it is one word. Reading * this in `onReady` is the documented pattern — `Wander` draws its first * heading there — and `onReady` runs during `loadScene`, before the tree * belongs to anyone. Every boot path in the engine passes `{ engine }` for * exactly that reason; the two-step boot the skills print did not, so a scene * with one wandering critter in it threw at load and the error said only that * something was not attached. */ get engine(): Engine; /** Shortcut for `this.engine.input`. */ get input(): InputMap; /** Seeded engine randomness — use this, not Math.random(), for replayability. */ get rng(): Rng; /** The engine log channel (visible in the debug overlay and test harness). */ get log(): LogManager; /** * The physics world, for the queries AI asks — `castRay` for line of sight, * `castSphere` for a probe that must not skim past a wall. * * ```ts * const hit = this.physics?.castRay(eye, dir, range, this.node); * const blocked = hit !== null && hit.node !== player; * ``` * * `null` in a game with no physics, so a behavior that only sometimes needs * a ray degrades instead of throwing. Pass the caster as `exclude` — a ray * that starts inside its own collider hits itself at distance 0. */ get physics(): PhysicsQuery | null; getNode(path: string): Node; /** The node's `getNodeOrNull`, for the same reason `getNode` is here. */ getNodeOrNull(path: string): Node | null; emit(signal: string, ...args: unknown[]): void; on(signal: string, fn: SignalListener, opts?: { once?: boolean; }): () => void; onEnterTree?(): void; onReady?(): void; onExitTree?(): void; update?(dt: number): void; fixedUpdate?(dt: number): void; /** * What this behavior needs in a save file — the sixth optional hook, and the * one that made "continue where you left off" possible at all. * * Return the state a fresh `onReady` could NOT recreate: current health, * accumulated score, which quest flags are set. Do not return anything the * scene JSON already says (`maxHealth`, `speed`) — that comes back from the * file, and duplicating it just makes old saves fight new balance patches. * * Must be plain JSON. Never a Node, a Texture, or a callback. * * ```ts * override serialize() { return { current: this.current }; } * override deserialize(data: JsonValue) { * const d = data as { current?: number }; * if (typeof d.current === 'number') this.current = d.current; * } * ``` * * Returning `undefined` writes nothing, so a behavior can decline to save on * a given frame (a boss that is mid-death-animation, say). */ serialize?(): JsonValue | undefined; /** * Take that state back, AFTER `onReady` has run. * * Defensive on purpose: this data may come from a save written by an older * build of the game. Check what you read; do not assume shape. Throwing here * costs only this one behavior's state — the restore keeps going — but * quietly ignoring a field you renamed is usually the better answer. */ deserialize?(data: JsonValue): void; /** * Tell the world what you were just handed — the seventh hook, and the one * that made "Continue" show the right numbers. * * `deserialize` writes fields. A HUD is wired to SIGNALS (`scoreChanged`, * `healthChanged`, `changed`), because that is what `incanto-hud.md` teaches * and what the editor can wire. Nothing emitted them after a restore, so a * Continue boot measured like this: * * ``` * HUD score="0" hp=100/100 gems="0" * TRUTH score=1400 hp=38 gems=7 * ``` * * with `errors: 0`, an empty log, and a restore report of * `{missing:[], skipped:[], restored:2, expected:2}` — every instrument * green over a screen showing a fresh start. Health was the worst of the * three: its setter is private and early-returns when unchanged, so nothing * a game could call would repaint the bar; it read 100 at 38 HP until the * next hit teleported it to 37. * * `restoreBehaviors` calls this once, after every `deserialize` in the pass, * so a handler that reads a sibling sees restored values there too. * * Emit only what DISPLAYS. Not `died`, not `won`, not `levelUp` — a save is * being read, nothing just happened, and re-firing an outcome signal on load * is how a Continue lands straight on the game-over screen. */ announce?(): void; } /** Explicit registration — never an import side effect (tree-shaking safety). */ declare function registerBehavior(name: string, ctor: BehaviorCtor, opts?: { replace?: boolean; }): void; declare function getBehavior(name: string): BehaviorCtor; /** * A registered behavior's merged prop schema, `{}` for one nobody registered. * * The node side has had `getNodeSchema` for a long time; a behavior's props were * reachable only by whoever already held the class, which a tool reading scene * JSON never does. Deliberately non-throwing: a tool inspecting a scene that * names a behavior from a game it did not load should degrade, not blow up. */ declare function behaviorSchema(name: string): PropSchema; /** A registered behavior's signals (own + inherited), `[]` for an unknown one. */ declare function behaviorSignals(name: string): string[]; declare function registeredBehaviors(): string[]; /** Test isolation helper. */ declare function clearBehaviors(): void; //#endregion export { T_PREFIX as $, SettingsValues as A, BusName as At, captureBehaviors as B, EngineStats as C, sfxDuration as Ct, QualityCaps as D, MusicTrack as Dt, DeviceHints as E, MusicManager as Et, BehaviorState as F, LogLevel as Ft, ORDER_GROUP_BASE as G, savesWithoutUid as H, RestoreReport as I, LogManager as It, effectiveOrder as J, OrderGroup as K, SaveSlot as L, Signal as Lt, readDeviceHints as M, NodeLifecycle as Mt, suggestQuality as N, SceneTree as Nt, QualityTier as O, PlayMusicOptions as Ot, Scene as P, LogEntry as Pt, Localization as Q, SaveSlots as R, SignalListener as Rt, Scheduler as S, SynthOptions as St, RendererStats as T, MusicBackend as Tt, SaveStore as U, restoreBehaviors as V, createSaveStore as W, BASE_LOCALE as X, resolveOrderGroups as Y, LocaleTables as Z, mergeStaticSignals as _, spatialPan as _t, clearBehaviors as a, EffectLog as at, Engine as b, SfxParams as bt, registeredBehaviors as c, isAudioContextAvailable as ct, PropSchema as d, Listener as dt, suggestLocale as et, clearRegistry as f, ROLLOFF_MODELS as ft, getNodeType as g, spatialGain as gt, getNodeSignals as h, Vec3 as ht, behaviorSignals as i, EffectKind as it, qualityEnvironment as j, Node as jt, Settings as k, AudioBuses as kt, NodeCtor as l, Voice as lt, getNodeSchema as m, SpatialParams as mt, BehaviorCtor as n, InputMap as nt, getBehavior as o, SfxEngine as ot, createNode as p, RolloffModel as pt, OrderGroupTable as q, behaviorSchema as r, EffectEvent as rt, registerBehavior as s, SfxPlayOptions as st, Behavior as t, translationKey as tt, PropDef as u, VoicePreset as ut, registerNode as v, SFX_PRESETS as vt, GameStats as w, synthSfx as wt, EngineOptions as x, SfxWave as xt, registeredTypes as y, SFX_PRESET_NAMES as yt, behaviorsWithoutSave as z };