incanto 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +36 -0
  3. package/THIRD-PARTY-NOTICES.md +88 -0
  4. package/assets/audio/attacked.mp3 +0 -0
  5. package/assets/audio/explosion.mp3 +0 -0
  6. package/assets/audio/gold_loot.mp3 +0 -0
  7. package/assets/audio/heal.mp3 +0 -0
  8. package/assets/audio/hit_metal_bang.mp3 +0 -0
  9. package/assets/audio/ice_spear.mp3 +0 -0
  10. package/assets/audio/monster_died.mp3 +0 -0
  11. package/assets/audio/slash.mp3 +0 -0
  12. package/assets/audio/smite.mp3 +0 -0
  13. package/assets/audio/spells_cast.mp3 +0 -0
  14. package/assets/audio/ui_click.wav +0 -0
  15. package/assets/audio/walk.mp3 +0 -0
  16. package/assets/catalog.json +390 -0
  17. package/assets/characters/2dbasic.json +41 -0
  18. package/assets/characters/2dbasic.png +0 -0
  19. package/assets/characters/ghost.json +46 -0
  20. package/assets/characters/ghost.png +0 -0
  21. package/assets/characters/goblin.json +40 -0
  22. package/assets/characters/goblin.png +0 -0
  23. package/assets/characters/medieval-knight.json +41 -0
  24. package/assets/characters/medieval-knight.png +0 -0
  25. package/assets/effects/swoosh.png +0 -0
  26. package/assets/items/box.png +0 -0
  27. package/assets/items/buff_potion.png +0 -0
  28. package/assets/items/coin.png +0 -0
  29. package/assets/items/gem.png +0 -0
  30. package/assets/items/gold.png +0 -0
  31. package/assets/items/hp_potion.png +0 -0
  32. package/assets/items/locked_item_box.png +0 -0
  33. package/assets/items/map.png +0 -0
  34. package/assets/items/resurrection_potion.png +0 -0
  35. package/assets/items/super_box.png +0 -0
  36. package/assets/items/trap.png +0 -0
  37. package/assets/tiles/floor00.jpg +0 -0
  38. package/assets/tiles/minecraft-tiles.png +0 -0
  39. package/assets/tiles/wall00.jpg +0 -0
  40. package/assets/vegetation/ash_color.png +0 -0
  41. package/assets/vegetation/aspen_color.png +0 -0
  42. package/assets/vegetation/bark/birch_color_1k.jpg +0 -0
  43. package/assets/vegetation/bark/birch_normal_1k.jpg +0 -0
  44. package/assets/vegetation/bark/birch_roughness_1k.jpg +0 -0
  45. package/assets/vegetation/bark/oak_color_1k.jpg +0 -0
  46. package/assets/vegetation/bark/oak_normal_1k.jpg +0 -0
  47. package/assets/vegetation/bark/oak_roughness_1k.jpg +0 -0
  48. package/assets/vegetation/bark/pine_color_1k.jpg +0 -0
  49. package/assets/vegetation/bark/pine_normal_1k.jpg +0 -0
  50. package/assets/vegetation/bark/pine_roughness_1k.jpg +0 -0
  51. package/assets/vegetation/ground/dirt_color.jpg +0 -0
  52. package/assets/vegetation/ground/dirt_normal.jpg +0 -0
  53. package/assets/vegetation/ground/grass.jpg +0 -0
  54. package/assets/vegetation/oak_color.png +0 -0
  55. package/assets/vegetation/pine_color.png +0 -0
  56. package/bin/incanto-assets.mjs +107 -0
  57. package/bin/incanto-check.mjs +107 -0
  58. package/bin/incanto-editor.mjs +343 -0
  59. package/bin/incanto-env.mjs +144 -0
  60. package/bin/incanto-model.mjs +296 -0
  61. package/bin/incanto-play.mjs +219 -0
  62. package/bin/incanto-skills.mjs +71 -0
  63. package/dist/2d.d.ts +642 -0
  64. package/dist/2d.js +44 -0
  65. package/dist/3d.d.ts +1860 -0
  66. package/dist/3d.js +5 -0
  67. package/dist/agent8-DzU2fFyH.js +129 -0
  68. package/dist/audio-player-DqUR3XFs.d.ts +110 -0
  69. package/dist/behavior-BAQq7HGM.d.ts +851 -0
  70. package/dist/create-game-BdjpTHrW.js +1725 -0
  71. package/dist/create-game-CZHROKcT.js +527 -0
  72. package/dist/debug-draw-CZmOYjL2.js +13 -0
  73. package/dist/debug.d.ts +66 -0
  74. package/dist/debug.js +658 -0
  75. package/dist/duplicate-DP2WPYom.js +22 -0
  76. package/dist/env.d.ts +430 -0
  77. package/dist/env.js +3152 -0
  78. package/dist/errors-BMFaY68Q.d.ts +33 -0
  79. package/dist/errors-BpWbnbb_.js +13 -0
  80. package/dist/gameplay-Ccruc3Wd.js +1501 -0
  81. package/dist/gameplay.d.ts +543 -0
  82. package/dist/gameplay.js +2 -0
  83. package/dist/heightmap-CroQPEER.js +185 -0
  84. package/dist/index.d.ts +305 -0
  85. package/dist/index.js +62 -0
  86. package/dist/json-BLk7H2Qa.js +30 -0
  87. package/dist/loader-CGs_G-r0.js +919 -0
  88. package/dist/loader-Mo0KghCv.d.ts +41 -0
  89. package/dist/net.d.ts +427 -0
  90. package/dist/net.js +772 -0
  91. package/dist/noise-CGUMx44x.js +82 -0
  92. package/dist/particle-sim-CbN4YUuH.d.ts +63 -0
  93. package/dist/particle-sim-DYuSUxvK.js +1319 -0
  94. package/dist/physics-2d-KuMWPTf6.js +288 -0
  95. package/dist/physics-3d-Dl67vOLT.js +434 -0
  96. package/dist/react.d.ts +65 -0
  97. package/dist/react.js +209 -0
  98. package/dist/register-BuUV1_KB.js +561 -0
  99. package/dist/register-CNlYAS1_.js +10634 -0
  100. package/dist/register-DPEV9_9t.js +851 -0
  101. package/dist/register-Dasmnurl.js +374 -0
  102. package/dist/registry-BVJ2HbCn.js +132 -0
  103. package/dist/rng-DP-SR7eg.js +38 -0
  104. package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
  105. package/dist/schema-CcoWb32N.d.ts +104 -0
  106. package/dist/test.d.ts +158 -0
  107. package/dist/test.js +275 -0
  108. package/dist/touch-031PxtCR.js +208 -0
  109. package/dist/vite.d.ts +26 -0
  110. package/dist/vite.js +57 -0
  111. package/editor/assets/GameServer-C56iOUgF.js +1 -0
  112. package/editor/assets/agent8-Bp7QFI7v.js +1 -0
  113. package/editor/assets/index-DF3tMeKJ.css +1 -0
  114. package/editor/assets/index-Dl2pjA8e.js +7365 -0
  115. package/editor/assets/rapier-CEuLKeCu.js +1 -0
  116. package/editor/assets/rapier-DE6a0vmv.js +1 -0
  117. package/editor/index.html +169 -0
  118. package/package.json +97 -0
  119. package/schemas/scene.schema.json +4254 -0
  120. package/skills/README.md +9 -0
  121. package/skills/incanto-3d-character.md +229 -0
  122. package/skills/incanto-3d-models.md +151 -0
  123. package/skills/incanto-assets.md +118 -0
  124. package/skills/incanto-audio.md +309 -0
  125. package/skills/incanto-behaviors-and-scripts.md +169 -0
  126. package/skills/incanto-building-2d-games.md +242 -0
  127. package/skills/incanto-building-3d-games.md +245 -0
  128. package/skills/incanto-editor.md +163 -0
  129. package/skills/incanto-environment.md +743 -0
  130. package/skills/incanto-gameplay-behaviors.md +707 -0
  131. package/skills/incanto-multiplayer.md +264 -0
  132. package/skills/incanto-node-reference.md +797 -0
  133. package/skills/incanto-physics-and-input.md +164 -0
  134. package/skills/incanto-scene-json-authoring.md +325 -0
  135. package/skills/incanto-verifying-your-game.md +191 -0
  136. package/skills/incanto-web-integration.md +96 -0
  137. package/templates/agent8-server.js +84 -0
  138. package/templates/agent8-server.ts +138 -0
@@ -0,0 +1,1319 @@
1
+ import { y as Signal } from "./loader-CGs_G-r0.js";
2
+ import { t as IncantoError } from "./errors-BpWbnbb_.js";
3
+ import { t as Rng } from "./rng-DP-SR7eg.js";
4
+ import { l as synthSfx } from "./register-BuUV1_KB.js";
5
+ import { n as jsonEquals } from "./json-BLk7H2Qa.js";
6
+ //#region src/core/audio/buses.ts
7
+ /**
8
+ * Global volume state — `master` × `sfx`/`music` gain (0..1) plus a `muted`
9
+ * flag. PURE state (no WebAudio): games and AI set it to control overall
10
+ * loudness/mute; the renderer reads `effectiveVolume()` when it actually plays
11
+ * a sound. Lives on the engine as `engine.audio` (instance-scoped, no
12
+ * singletons). The WebAudio wiring is in the adapter; this just holds the
13
+ * numbers so they're fully testable in `node`.
14
+ *
15
+ * ```ts
16
+ * engine.audio.master = 0.8; // dim everything
17
+ * engine.audio.music = 0.4; // quieter background music
18
+ * engine.audio.muted = true; // mute toggle
19
+ * ```
20
+ */
21
+ var AudioBuses = class {
22
+ /** Fires whenever any value changes (drives live re-gain of playing sounds). */
23
+ changed = new Signal();
24
+ _master = 1;
25
+ _sfx = 1;
26
+ _music = 1;
27
+ _muted = false;
28
+ get master() {
29
+ return this._master;
30
+ }
31
+ set master(v) {
32
+ this._master = this.assign(this._master, v);
33
+ }
34
+ get sfx() {
35
+ return this._sfx;
36
+ }
37
+ set sfx(v) {
38
+ this._sfx = this.assign(this._sfx, v);
39
+ }
40
+ get music() {
41
+ return this._music;
42
+ }
43
+ set music(v) {
44
+ this._music = this.assign(this._music, v);
45
+ }
46
+ get muted() {
47
+ return this._muted;
48
+ }
49
+ set muted(v) {
50
+ if (v === this._muted) return;
51
+ this._muted = v;
52
+ this.changed.emit();
53
+ }
54
+ /** Final gain for a sound on `bus` with its own `sourceVolume` (all clamped). */
55
+ effectiveVolume(bus, sourceVolume) {
56
+ if (this._muted) return 0;
57
+ const busGain = bus === "music" ? this._music : this._sfx;
58
+ return this._master * busGain * clamp01$2(sourceVolume);
59
+ }
60
+ /** Clamp + finite-guard a new value; emit only on a real change. */
61
+ assign(current, next) {
62
+ if (!Number.isFinite(next)) return current;
63
+ const clamped = clamp01$2(next);
64
+ if (clamped !== current) this.changed.emit();
65
+ return clamped;
66
+ }
67
+ };
68
+ function clamp01$2(v) {
69
+ if (!Number.isFinite(v)) return 0;
70
+ return v < 0 ? 0 : v > 1 ? 1 : v;
71
+ }
72
+ //#endregion
73
+ //#region src/core/audio/crossfade.ts
74
+ /**
75
+ * PURE crossfade / fade envelope math for the music manager. No WebAudio: just
76
+ * the gain curves over time, so the loudness behaviour is unit-testable in
77
+ * `node` and the headless music state machine can be validated without a backend.
78
+ * The adapter applies these gains to real GainNodes / element volumes each frame.
79
+ */
80
+ /** Linear 0→1 (`in`) or 1→0 (`out`) over `duration` seconds at elapsed `t`. */
81
+ function fadeGain(t, duration, dir) {
82
+ const p = progress(t, duration);
83
+ return dir === "in" ? p : 1 - p;
84
+ }
85
+ /**
86
+ * Equal-power crossfade gains at elapsed `t` over `seconds`. The outgoing track
87
+ * fades cos(¼π·p) and the incoming sin(¼π·p) so `out² + in² ≈ 1` throughout —
88
+ * constant perceived loudness, no mid-fade dip (the classic equal-power law).
89
+ * `p` is `t/seconds` clamped to [0,1]; a zero-second fade swaps instantly.
90
+ */
91
+ function crossfadeGains(t, seconds) {
92
+ const angle = progress(t, seconds) * Math.PI / 2;
93
+ return {
94
+ out: clamp01$1(Math.cos(angle)),
95
+ in: clamp01$1(Math.sin(angle))
96
+ };
97
+ }
98
+ /** Normalized progress 0..1; a non-positive duration is instantly complete. */
99
+ function progress(t, duration) {
100
+ if (!Number.isFinite(t)) return 1;
101
+ if (duration <= 0) return 1;
102
+ return clamp01$1(t / duration);
103
+ }
104
+ function clamp01$1(v) {
105
+ if (!Number.isFinite(v)) return 0;
106
+ return v < 0 ? 0 : v > 1 ? 1 : v;
107
+ }
108
+ function resolveCtor$1() {
109
+ const g = globalThis;
110
+ return g.AudioContext ?? g.webkitAudioContext ?? null;
111
+ }
112
+ function createContext$1() {
113
+ const Ctor = resolveCtor$1();
114
+ return Ctor ? new Ctor() : null;
115
+ }
116
+ function createElement() {
117
+ if (typeof Audio === "undefined") return null;
118
+ return new Audio();
119
+ }
120
+ /** A no-op track for the headless / missing-backend path. */
121
+ const INERT = {
122
+ src: "",
123
+ setGain() {},
124
+ setLoop() {},
125
+ play() {},
126
+ stop() {}
127
+ };
128
+ var WebAudioTrack = class {
129
+ src;
130
+ el;
131
+ gainNode;
132
+ constructor(src, el, ctx) {
133
+ this.src = src;
134
+ this.el = el;
135
+ try {
136
+ el.crossOrigin = "anonymous";
137
+ } catch {}
138
+ el.src = src;
139
+ const source = ctx.createMediaElementSource(el);
140
+ this.gainNode = ctx.createGain();
141
+ source.connect(this.gainNode);
142
+ this.gainNode.connect(ctx.destination);
143
+ }
144
+ setGain(gain) {
145
+ this.gainNode.gain.value = gain;
146
+ }
147
+ setLoop(loop) {
148
+ this.el.loop = loop;
149
+ }
150
+ play() {
151
+ this.el.play().catch(() => {});
152
+ }
153
+ stop() {
154
+ this.el.pause();
155
+ this.el.currentTime = 0;
156
+ }
157
+ };
158
+ var WebAudioMusicBackend = class {
159
+ ctx = null;
160
+ resolved = false;
161
+ /** True once a backend is present (lazily created on first use). */
162
+ get available() {
163
+ return this.ensure() !== null;
164
+ }
165
+ ensure() {
166
+ if (!this.resolved) {
167
+ this.ctx = createContext$1();
168
+ this.resolved = true;
169
+ }
170
+ return this.ctx;
171
+ }
172
+ createTrack(src) {
173
+ const ctx = this.ensure();
174
+ const el = createElement();
175
+ if (!ctx || !el) return {
176
+ ...INERT,
177
+ src
178
+ };
179
+ if (ctx.state === "suspended") ctx.resume();
180
+ return new WebAudioTrack(src, el, ctx);
181
+ }
182
+ unlock() {
183
+ const ctx = this.ensure();
184
+ if (ctx && ctx.state === "suspended") ctx.resume();
185
+ }
186
+ };
187
+ /**
188
+ * Single-track background-music manager: plays ONE music track, crossfading to
189
+ * a new one (equal-power) or fading in/out. Lives on the engine as
190
+ * `engine.music`; routes through `engine.audio` (the music bus by default) so
191
+ * the global volume/mute sliders dim it for free.
192
+ *
193
+ * The state machine (current/outgoing track, fade phase + timer) is fully
194
+ * testable headlessly via an injected backend; the real backend is WebAudio +
195
+ * HTMLAudio and a no-op when there's no AudioContext. Drive `tick(dt)` once per
196
+ * frame to advance fades (the engine wires this to `updated`).
197
+ *
198
+ * ```ts
199
+ * engine.music.play('incanto/assets/audio/theme.mp3'); // loop, full
200
+ * engine.music.crossfadeTo('boss.mp3', 3); // 3s equal-power swap
201
+ * engine.music.stop(2); // fade out over 2s
202
+ * engine.music.setVolume(0.5); // per-manager volume
203
+ * ```
204
+ */
205
+ var MusicManager = class {
206
+ buses;
207
+ backend = null;
208
+ resolved = false;
209
+ /** The current (incoming) track; an outgoing one lives during a fade. */
210
+ active = null;
211
+ outgoing = null;
212
+ _bus = "music";
213
+ _volume = 1;
214
+ constructor(buses) {
215
+ this.buses = buses;
216
+ }
217
+ /** Source of the logically-current track, or null when nothing is playing. */
218
+ get current() {
219
+ return this.active?.track.src ?? null;
220
+ }
221
+ /** Per-manager volume (0..1), on top of the bus gain. */
222
+ get volume() {
223
+ return this._volume;
224
+ }
225
+ ensure() {
226
+ if (!this.resolved) {
227
+ const b = new WebAudioMusicBackend();
228
+ this.backend = b && b.available === false ? null : b;
229
+ this.resolved = true;
230
+ }
231
+ return this.backend;
232
+ }
233
+ /** Resume a gesture-suspended context + (re)start any pending track. */
234
+ unlock() {
235
+ this.ensure()?.unlock();
236
+ this.active?.track.play();
237
+ }
238
+ /** Play a single track (replacing any current one immediately, no fade). */
239
+ play(src, opts = {}) {
240
+ const backend = this.ensure();
241
+ if (!backend) return;
242
+ this.killOutgoing();
243
+ this.active?.track.stop();
244
+ this._bus = opts.bus ?? "music";
245
+ const track = backend.createTrack(src);
246
+ track.setLoop(opts.loop ?? true);
247
+ const fadeIn = Math.max(0, opts.fadeIn ?? 0);
248
+ this.active = {
249
+ track,
250
+ phase: fadeIn > 0 ? "fadeIn" : "steady",
251
+ t: 0,
252
+ duration: fadeIn,
253
+ freeOnEnd: false
254
+ };
255
+ track.setGain(fadeIn > 0 ? 0 : this.busGain());
256
+ track.play();
257
+ }
258
+ /**
259
+ * Equal-power crossfade to a new track over `seconds`. With no current track
260
+ * it's a fade-in. Crossfading to the already-current src is a no-op.
261
+ */
262
+ crossfadeTo(src, seconds = 2) {
263
+ const backend = this.ensure();
264
+ if (!backend) return;
265
+ if (this.active && this.active.track.src === src && this.active.phase !== "fadeOut") return;
266
+ const secs = Math.max(0, seconds);
267
+ if (!this.active) {
268
+ this.play(src, { fadeIn: secs });
269
+ return;
270
+ }
271
+ this.killOutgoing();
272
+ this.outgoing = {
273
+ ...this.active,
274
+ phase: "crossOut",
275
+ t: 0,
276
+ duration: secs,
277
+ freeOnEnd: true
278
+ };
279
+ const track = backend.createTrack(src);
280
+ track.setLoop(true);
281
+ track.setGain(0);
282
+ track.play();
283
+ this.active = {
284
+ track,
285
+ phase: "crossIn",
286
+ t: 0,
287
+ duration: secs,
288
+ freeOnEnd: false
289
+ };
290
+ this.applyGains();
291
+ }
292
+ /** Stop the current track, optionally fading out over `fadeOut` seconds. */
293
+ stop(fadeOut = 0) {
294
+ this.killOutgoing();
295
+ const a = this.active;
296
+ this.active = null;
297
+ if (!a) return;
298
+ const secs = Math.max(0, fadeOut);
299
+ if (secs <= 0) {
300
+ a.track.stop();
301
+ return;
302
+ }
303
+ this.outgoing = {
304
+ ...a,
305
+ phase: "fadeOut",
306
+ t: 0,
307
+ duration: secs,
308
+ freeOnEnd: true
309
+ };
310
+ this.applyGains();
311
+ }
312
+ /** Set the per-manager volume (re-applied to live tracks on the next tick). */
313
+ setVolume(v) {
314
+ this._volume = clamp01(v);
315
+ }
316
+ /** Advance fades by `dt` seconds and (re)apply bus×fade gains. Frame-driven. */
317
+ tick(dt) {
318
+ if (this.active && (this.active.phase === "fadeIn" || this.active.phase === "crossIn")) {
319
+ this.active.t += dt;
320
+ if (this.active.t >= this.active.duration) this.active.phase = "steady";
321
+ }
322
+ if (this.outgoing) {
323
+ this.outgoing.t += dt;
324
+ if (this.outgoing.t >= this.outgoing.duration) {
325
+ this.outgoing.track.setGain(0);
326
+ this.outgoing.track.stop();
327
+ this.outgoing = null;
328
+ }
329
+ }
330
+ this.applyGains();
331
+ }
332
+ /** Effective bus gain for THIS manager (master × bus × manager volume). */
333
+ busGain() {
334
+ return this.buses.effectiveVolume(this._bus, this._volume);
335
+ }
336
+ /** Push the current fade-derived gains onto both tracks. */
337
+ applyGains() {
338
+ const base = this.busGain();
339
+ if (this.active?.phase === "crossIn" && this.outgoing?.phase === "crossOut") {
340
+ const g = crossfadeGains(this.active.t, this.active.duration);
341
+ this.active.track.setGain(base * g.in);
342
+ this.outgoing.track.setGain(base * g.out);
343
+ return;
344
+ }
345
+ if (this.active) {
346
+ const g = this.active.phase === "fadeIn" ? fadeGain(this.active.t, this.active.duration, "in") : 1;
347
+ this.active.track.setGain(base * g);
348
+ }
349
+ if (this.outgoing) {
350
+ const g = this.outgoing.phase === "fadeOut" || this.outgoing.phase === "crossOut" ? fadeGain(this.outgoing.t, this.outgoing.duration, "out") : 0;
351
+ this.outgoing.track.setGain(base * g);
352
+ }
353
+ }
354
+ killOutgoing() {
355
+ this.outgoing?.track.stop();
356
+ this.outgoing = null;
357
+ }
358
+ };
359
+ function clamp01(v) {
360
+ if (!Number.isFinite(v)) return 0;
361
+ return v < 0 ? 0 : v > 1 ? 1 : v;
362
+ }
363
+ //#endregion
364
+ //#region src/core/audio/webaudio-sfx.ts
365
+ /** Position a WebAudio AudioListener from a pose (modern AudioParam or legacy). */
366
+ function applyListenerPose(ctxListener, pose) {
367
+ const [px, py, pz] = pose.position;
368
+ const [fx, fy, fz] = pose.forward;
369
+ const [ux, uy, uz] = pose.up;
370
+ const l = ctxListener;
371
+ if (l.positionX && l.forwardX && l.upX) {
372
+ l.positionX.value = px;
373
+ if (l.positionY) l.positionY.value = py;
374
+ if (l.positionZ) l.positionZ.value = pz;
375
+ l.forwardX.value = fx;
376
+ if (l.forwardY) l.forwardY.value = fy;
377
+ if (l.forwardZ) l.forwardZ.value = fz;
378
+ l.upX.value = ux;
379
+ if (l.upY) l.upY.value = uy;
380
+ if (l.upZ) l.upZ.value = uz;
381
+ } else {
382
+ l.setPosition?.(px, py, pz);
383
+ l.setOrientation?.(fx, fy, fz, ux, uy, uz);
384
+ }
385
+ }
386
+ /** Position a PannerNode at an emitter (modern AudioParam or legacy). */
387
+ function applyPannerPosition(panner, pos) {
388
+ const p = panner;
389
+ if (p.positionX) {
390
+ p.positionX.value = pos[0];
391
+ if (p.positionY) p.positionY.value = pos[1];
392
+ if (p.positionZ) p.positionZ.value = pos[2];
393
+ } else p.setPosition?.(pos[0], pos[1], pos[2]);
394
+ }
395
+ function resolveCtor() {
396
+ const g = globalThis;
397
+ return g.AudioContext ?? g.webkitAudioContext ?? null;
398
+ }
399
+ /** True when a real (or injected) AudioContext backend exists. */
400
+ function isAudioContextAvailable() {
401
+ return resolveCtor() !== null;
402
+ }
403
+ function createContext() {
404
+ const Ctor = resolveCtor();
405
+ if (!Ctor) return null;
406
+ return new Ctor();
407
+ }
408
+ /** Cache key for a synthesized waveform (params identity + variation knobs). */
409
+ function cacheKey(params, opts, sampleRate) {
410
+ return `${params.wave}|${params.baseFreq}|${params.freqRamp}|${params.attack}|${params.sustain}|${params.decay}|${params.duty}|${params.vibratoDepth}|${params.vibratoRate}|${params.volume}|${opts.pitch ?? 1}|${opts.seed ?? 0}|${sampleRate}`;
411
+ }
412
+ /**
413
+ * One AudioContext + a PCM cache. Instance-scoped (held by the renderer's game
414
+ * instance, not a singleton). `play()` synthesizes (cached) and fires a
415
+ * one-shot source at the given gain.
416
+ */
417
+ var SfxEngine = class {
418
+ ctx = null;
419
+ resolved = false;
420
+ pcmCache = /* @__PURE__ */ new Map();
421
+ /** True once a backend is present (lazily created on first use). */
422
+ get available() {
423
+ return this.ensure() !== null;
424
+ }
425
+ ensure() {
426
+ if (!this.resolved) {
427
+ this.ctx = createContext();
428
+ this.resolved = true;
429
+ }
430
+ return this.ctx;
431
+ }
432
+ /** Resume a gesture-suspended context (wired to the first user gesture). */
433
+ unlock() {
434
+ const ctx = this.ensure();
435
+ if (ctx && ctx.state === "suspended") ctx.resume();
436
+ }
437
+ /**
438
+ * Play a preset at `gain` (0..1, already bus-multiplied by the caller). Each
439
+ * call spawns its own source so rapid repeats overlap instead of cutting off.
440
+ * Headless → no-op.
441
+ */
442
+ play(params, gain, opts = {}) {
443
+ const ctx = this.ensure();
444
+ if (!ctx) return;
445
+ if (ctx.state === "suspended") ctx.resume();
446
+ if (gain <= 0) return;
447
+ const sampleRate = ctx.sampleRate || 44100;
448
+ const synthOpts = {
449
+ sampleRate,
450
+ pitch: opts.pitch,
451
+ seed: opts.seed
452
+ };
453
+ const key = cacheKey(params, synthOpts, sampleRate);
454
+ let pcm = this.pcmCache.get(key);
455
+ if (!pcm) {
456
+ pcm = synthSfx(params, synthOpts);
457
+ this.pcmCache.set(key, pcm);
458
+ }
459
+ const buffer = ctx.createBuffer(1, pcm.length, sampleRate);
460
+ buffer.getChannelData(0).set(pcm);
461
+ const gainNode = ctx.createGain();
462
+ gainNode.gain.value = gain;
463
+ if (opts.spatial && typeof ctx.createPanner === "function") {
464
+ const s = opts.spatial;
465
+ const panner = ctx.createPanner();
466
+ panner.panningModel = "HRTF";
467
+ panner.distanceModel = s.rolloff;
468
+ panner.refDistance = Math.max(0, s.refDistance);
469
+ panner.maxDistance = Math.max(s.refDistance, s.maxDistance);
470
+ panner.rolloffFactor = s.rolloffFactor ?? 1;
471
+ applyPannerPosition(panner, s.position);
472
+ if (ctx.listener) applyListenerPose(ctx.listener, s.listener);
473
+ gainNode.connect(panner);
474
+ panner.connect(ctx.destination);
475
+ } else gainNode.connect(ctx.destination);
476
+ const source = ctx.createBufferSource();
477
+ source.buffer = buffer;
478
+ source.connect(gainNode);
479
+ source.start();
480
+ }
481
+ };
482
+ //#endregion
483
+ //#region src/core/frame-stats.ts
484
+ /**
485
+ * Rolling window of REAL frame timestamps — the math behind `engine.stats()`
486
+ * fps/frameMs. Pure and renderer-free: push tick times in, read averages out.
487
+ *
488
+ * Only `Engine.tick(nowMs)` feeds it; headless `step()` advances sim time
489
+ * without wall-clock frames, so stepped runs honestly report 0 fps.
490
+ */
491
+ var FrameStatsRing = class {
492
+ capacity;
493
+ times;
494
+ head = 0;
495
+ count = 0;
496
+ constructor(capacity = 60) {
497
+ this.capacity = capacity;
498
+ this.times = new Array(capacity);
499
+ }
500
+ /** Record one frame timestamp (ms). Evicts the oldest at capacity. */
501
+ push(nowMs) {
502
+ this.times[(this.head + this.count) % this.capacity] = nowMs;
503
+ if (this.count < this.capacity) this.count += 1;
504
+ else this.head = (this.head + 1) % this.capacity;
505
+ }
506
+ /** Frames per second over the window — 0 with fewer than two samples. */
507
+ fps() {
508
+ const span = this.spanMs();
509
+ return span > 0 ? (this.count - 1) / span * 1e3 : 0;
510
+ }
511
+ /** Mean delta between frames in ms — 0 with fewer than two samples. */
512
+ frameMs() {
513
+ const span = this.spanMs();
514
+ return span > 0 ? span / (this.count - 1) : 0;
515
+ }
516
+ /** Forget all samples (engine.stop() — a paused game has no frame rate). */
517
+ clear() {
518
+ this.head = 0;
519
+ this.count = 0;
520
+ }
521
+ spanMs() {
522
+ if (this.count < 2) return 0;
523
+ const oldest = this.times[this.head];
524
+ return this.times[(this.head + this.count - 1) % this.capacity] - oldest;
525
+ }
526
+ };
527
+ //#endregion
528
+ //#region src/core/input.ts
529
+ /** Keys typed into editable elements belong to the page UI, not the game. */
530
+ function isEditableTarget(target) {
531
+ const el = target;
532
+ return !!el && (el.isContentEditable === true || el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT");
533
+ }
534
+ /**
535
+ * Declarative input from scene JSON `input{}` — DOM-free logic (feed key state
536
+ * via `handleKey`, or wire a browser with `attachKeyboard(window)`).
537
+ *
538
+ * Convention: vector2 follows the 2D y-down space (up = -y). `justPressed` /
539
+ * `justReleased` are one-frame edges, settled by the Engine each tick.
540
+ */
541
+ var InputMap = class {
542
+ actions = /* @__PURE__ */ new Map();
543
+ down = /* @__PURE__ */ new Set();
544
+ pressedEdge = /* @__PURE__ */ new Set();
545
+ releasedEdge = /* @__PURE__ */ new Set();
546
+ detach = null;
547
+ /** Load (or extend with) scene-JSON action declarations. Hard-validates shape. */
548
+ declare(decls) {
549
+ for (const [name, raw] of Object.entries(decls)) {
550
+ const decl = raw;
551
+ if (decl.type === "button") {
552
+ const keys = decl.keys;
553
+ if (!Array.isArray(keys) || keys.some((k) => typeof k !== "string")) throw new IncantoError("BAD_FORMAT", `Input action '${name}': button "keys" must be an array of KeyboardEvent.code strings.`);
554
+ if (decl.touch !== void 0 && decl.touch !== "button") throw new IncantoError("BAD_FORMAT", `Input action '${name}': button "touch" must be 'button', got ${JSON.stringify(decl.touch)}.`);
555
+ this.actions.set(name, {
556
+ type: "button",
557
+ keys: [...keys],
558
+ ...decl.touch === "button" ? { touch: "button" } : {}
559
+ });
560
+ } else if (decl.type === "vector2") {
561
+ const dirs = decl.keys;
562
+ for (const dir of [
563
+ "up",
564
+ "down",
565
+ "left",
566
+ "right"
567
+ ]) if (!Array.isArray(dirs?.[dir])) throw new IncantoError("BAD_FORMAT", `Input action '${name}': vector2 "keys" needs arrays for up/down/left/right.`);
568
+ if (decl.touch !== void 0 && decl.touch !== "joystick") throw new IncantoError("BAD_FORMAT", `Input action '${name}': vector2 "touch" must be 'joystick', got ${JSON.stringify(decl.touch)}.`);
569
+ const d = dirs;
570
+ this.actions.set(name, {
571
+ type: "vector2",
572
+ keys: {
573
+ up: [...d.up],
574
+ down: [...d.down],
575
+ left: [...d.left],
576
+ right: [...d.right]
577
+ },
578
+ ...decl.touch === "joystick" ? { touch: "joystick" } : {}
579
+ });
580
+ } else throw new IncantoError("BAD_FORMAT", `Input action '${name}': "type" must be 'button' or 'vector2', got ${JSON.stringify(decl.type)}.`);
581
+ }
582
+ }
583
+ /** Feed a key state change (code = KeyboardEvent.code, e.g. 'Space', 'KeyW'). */
584
+ handleKey(code, isDown) {
585
+ if (isDown) {
586
+ if (!this.down.has(code)) {
587
+ this.down.add(code);
588
+ this.pressedEdge.add(code);
589
+ }
590
+ } else if (this.down.has(code)) {
591
+ this.down.delete(code);
592
+ this.releasedEdge.add(code);
593
+ }
594
+ }
595
+ injectedDown = /* @__PURE__ */ new Set();
596
+ injectedPressed = /* @__PURE__ */ new Set();
597
+ injectedReleased = /* @__PURE__ */ new Set();
598
+ injectedVectors = /* @__PURE__ */ new Map();
599
+ /**
600
+ * Press a button ACTION directly — no key codes involved. This is how
601
+ * scripted gameplay tests and touch buttons drive the game by intent
602
+ * (`press('jump')`) instead of reverse-engineering keybinds.
603
+ */
604
+ pressAction(action) {
605
+ this.button(action);
606
+ if (!this.injectedDown.has(action)) {
607
+ this.injectedDown.add(action);
608
+ this.injectedPressed.add(action);
609
+ }
610
+ }
611
+ /** Release an injected button action (yields one justReleased frame). */
612
+ releaseAction(action) {
613
+ this.button(action);
614
+ if (this.injectedDown.delete(action)) this.injectedReleased.add(action);
615
+ }
616
+ /**
617
+ * Feed an analog direction for a vector2 ACTION — virtual joysticks and
618
+ * scripted runs. Persists until replaced; (0, 0) clears the injection.
619
+ * Combines with key state in getVector (clamped to unit length).
620
+ */
621
+ setActionVector(action, x, y) {
622
+ if (this.get(action).type !== "vector2") throw new IncantoError("BAD_FORMAT", `Input action '${action}' is a button — use pressAction/releaseAction, not setActionVector.`);
623
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new IncantoError("BAD_FORMAT", `setActionVector('${action}') needs finite numbers, got (${x}, ${y}).`);
624
+ if (x === 0 && y === 0) this.injectedVectors.delete(action);
625
+ else this.injectedVectors.set(action, {
626
+ x,
627
+ y
628
+ });
629
+ }
630
+ /**
631
+ * Actions the scene wants on-screen touch controls for, in declaration
632
+ * order (`"touch": "joystick"` on vector2, `"touch": "button"` on buttons).
633
+ * The touch overlay (core/touch.ts) renders these.
634
+ */
635
+ touchControls() {
636
+ const out = [];
637
+ for (const [name, def] of this.actions) if (def.touch) out.push({
638
+ action: name,
639
+ kind: def.touch
640
+ });
641
+ return out;
642
+ }
643
+ /** Drop all injected action state (held buttons, vectors, pending edges). */
644
+ clearInjected() {
645
+ this.injectedDown.clear();
646
+ this.injectedPressed.clear();
647
+ this.injectedReleased.clear();
648
+ this.injectedVectors.clear();
649
+ }
650
+ dx = 0;
651
+ dy = 0;
652
+ wheel = 0;
653
+ /** Mouse buttons feed the same code space as keys: Mouse0/Mouse1/Mouse2. */
654
+ handleMouseButton(button, isDown) {
655
+ this.handleKey(`Mouse${button}`, isDown);
656
+ }
657
+ /** Accumulate look deltas (movementX/Y under pointer lock, else move deltas). */
658
+ handlePointerMove(dx, dy) {
659
+ this.dx += dx;
660
+ this.dy += dy;
661
+ }
662
+ handleWheel(deltaY) {
663
+ this.wheel += deltaY;
664
+ }
665
+ /** Drain the accumulated pointer delta (read once per frame). */
666
+ pointerDelta() {
667
+ const d = {
668
+ x: this.dx,
669
+ y: this.dy
670
+ };
671
+ this.dx = 0;
672
+ this.dy = 0;
673
+ return d;
674
+ }
675
+ /** Drain the accumulated wheel delta. */
676
+ wheelDelta() {
677
+ const w = this.wheel;
678
+ this.wheel = 0;
679
+ return w;
680
+ }
681
+ /**
682
+ * Wire browser pointer events on a canvas: buttons → Mouse0/1/2 codes,
683
+ * movement → pointerDelta (movementX/Y so pointer lock just works),
684
+ * wheel → wheelDelta. `lockOnClick` requests pointer lock on mousedown
685
+ * (the FPS pattern).
686
+ */
687
+ attachPointer(target, opts) {
688
+ let buttonsHeld = 0;
689
+ const onDown = (e) => {
690
+ buttonsHeld++;
691
+ this.handleMouseButton(e.button, true);
692
+ if (opts?.lockOnClick && document.pointerLockElement !== target) target.requestPointerLock?.();
693
+ };
694
+ const onUp = (e) => {
695
+ buttonsHeld = Math.max(0, buttonsHeld - 1);
696
+ this.handleMouseButton(e.button, false);
697
+ };
698
+ const onMove = (e) => {
699
+ if (document.pointerLockElement === target || buttonsHeld > 0) this.handlePointerMove(e.movementX, e.movementY);
700
+ };
701
+ const onWheel = (e) => {
702
+ e.preventDefault();
703
+ this.handleWheel(e.deltaY);
704
+ };
705
+ const onContext = (e) => e.preventDefault();
706
+ target.addEventListener("mousedown", onDown);
707
+ window.addEventListener("mouseup", onUp);
708
+ window.addEventListener("mousemove", onMove);
709
+ target.addEventListener("wheel", onWheel, { passive: false });
710
+ target.addEventListener("contextmenu", onContext);
711
+ const detach = () => {
712
+ target.removeEventListener("mousedown", onDown);
713
+ window.removeEventListener("mouseup", onUp);
714
+ window.removeEventListener("mousemove", onMove);
715
+ target.removeEventListener("wheel", onWheel);
716
+ target.removeEventListener("contextmenu", onContext);
717
+ };
718
+ const prev = this.detach;
719
+ this.detach = () => {
720
+ prev?.();
721
+ detach();
722
+ };
723
+ return detach;
724
+ }
725
+ /**
726
+ * Wire browser keyboard events. Returns (and chains into dispose()) a detach
727
+ * function.
728
+ *
729
+ * By default the browser default is prevented for keys BOUND to a declared
730
+ * action — an embedded game must not scroll its host page on Space/arrows.
731
+ * Keys typed into editable elements (inputs, textareas, contenteditable)
732
+ * are ignored entirely so DOM UI overlays keep working.
733
+ */
734
+ attachKeyboard(target, opts) {
735
+ const mode = opts?.preventDefault ?? "bound";
736
+ const onDown = (e) => {
737
+ if (isEditableTarget(e.target)) return;
738
+ if ((e.ctrlKey || e.metaKey) && !e.code.startsWith("Control") && !e.code.startsWith("Meta")) return;
739
+ if (mode === "bound" && this.codeIsBound(e.code)) e.preventDefault?.();
740
+ if (!e.repeat) this.handleKey(e.code, true);
741
+ };
742
+ const onUp = (e) => {
743
+ if (isEditableTarget(e.target)) return;
744
+ this.handleKey(e.code, false);
745
+ };
746
+ target.addEventListener("keydown", onDown);
747
+ target.addEventListener("keyup", onUp);
748
+ const detach = () => {
749
+ target.removeEventListener("keydown", onDown);
750
+ target.removeEventListener("keyup", onUp);
751
+ };
752
+ const prev = this.detach;
753
+ this.detach = () => {
754
+ prev?.();
755
+ detach();
756
+ };
757
+ return detach;
758
+ }
759
+ /** Whether any declared action binds this key code. */
760
+ codeIsBound(code) {
761
+ for (const action of this.actions.values()) if (action.type === "button") {
762
+ if (action.keys.includes(code)) return true;
763
+ } else {
764
+ const k = action.keys;
765
+ if (k.up.includes(code) || k.down.includes(code) || k.left.includes(code) || k.right.includes(code)) return true;
766
+ }
767
+ return false;
768
+ }
769
+ dispose() {
770
+ this.detach?.();
771
+ this.detach = null;
772
+ }
773
+ isPressed(action) {
774
+ return this.button(action).keys.some((k) => this.down.has(k)) || this.injectedDown.has(action);
775
+ }
776
+ justPressed(action) {
777
+ return this.button(action).keys.some((k) => this.pressedEdge.has(k)) || this.injectedPressed.has(action);
778
+ }
779
+ justReleased(action) {
780
+ return this.button(action).keys.some((k) => this.releasedEdge.has(k)) || this.injectedReleased.has(action);
781
+ }
782
+ /** Normalized direction for a vector2 action (y-down: up = -y). */
783
+ getVector(action) {
784
+ const def = this.get(action);
785
+ if (def.type !== "vector2") throw new IncantoError("BAD_FORMAT", `Input action '${action}' is a button — use isPressed/justPressed, not getVector.`);
786
+ const active = (codes) => codes.some((k) => this.down.has(k)) ? 1 : 0;
787
+ let x = active(def.keys.right) - active(def.keys.left);
788
+ let y = active(def.keys.down) - active(def.keys.up);
789
+ const injected = this.injectedVectors.get(action);
790
+ if (injected) {
791
+ x += injected.x;
792
+ y += injected.y;
793
+ }
794
+ const len = Math.hypot(x, y);
795
+ if (len > 1) {
796
+ x /= len;
797
+ y /= len;
798
+ }
799
+ return {
800
+ x,
801
+ y
802
+ };
803
+ }
804
+ /** Consume one-frame edges. The Engine calls this at the end of every tick. */
805
+ endFrame() {
806
+ this.pressedEdge.clear();
807
+ this.releasedEdge.clear();
808
+ this.injectedPressed.clear();
809
+ this.injectedReleased.clear();
810
+ }
811
+ /**
812
+ * Drop all action declarations and injected action state (physical key
813
+ * state is kept). The Engine calls this on setScene so keybinds never
814
+ * bleed between scenes.
815
+ */
816
+ clear() {
817
+ this.actions.clear();
818
+ this.pressedEdge.clear();
819
+ this.releasedEdge.clear();
820
+ this.clearInjected();
821
+ }
822
+ get(action) {
823
+ const def = this.actions.get(action);
824
+ if (!def) throw new IncantoError("BAD_FORMAT", `Unknown input action '${action}'. Declared actions: [${[...this.actions.keys()].join(", ")}].`);
825
+ return def;
826
+ }
827
+ button(action) {
828
+ const def = this.get(action);
829
+ if (def.type !== "button") throw new IncantoError("BAD_FORMAT", `Input action '${action}' is a vector2 — use getVector, not button queries.`);
830
+ return def;
831
+ }
832
+ };
833
+ //#endregion
834
+ //#region src/core/log.ts
835
+ /**
836
+ * The engine's log channel (`engine.log`, `this.log` in a Behavior): a ring
837
+ * buffer plus a live `added` signal, so debug overlays and headless test
838
+ * harnesses can tail game logs without scraping the browser console.
839
+ */
840
+ var LogManager = class {
841
+ /** Fires once per entry, after it is buffered. */
842
+ added = new Signal();
843
+ buffer = [];
844
+ capacity;
845
+ seq = 0;
846
+ constructor(capacity = 1e3) {
847
+ this.capacity = capacity;
848
+ }
849
+ debug(...parts) {
850
+ this.push("debug", parts);
851
+ }
852
+ info(...parts) {
853
+ this.push("info", parts);
854
+ }
855
+ warn(...parts) {
856
+ this.push("warn", parts);
857
+ }
858
+ error(...parts) {
859
+ this.push("error", parts);
860
+ }
861
+ /** Buffered entries, oldest first (capped at capacity). */
862
+ entries() {
863
+ return this.buffer;
864
+ }
865
+ /** Empty the buffer. The sequence keeps counting (entries stay unique). */
866
+ clear() {
867
+ this.buffer.length = 0;
868
+ }
869
+ push(level, parts) {
870
+ this.seq += 1;
871
+ const entry = {
872
+ seq: this.seq,
873
+ timeMs: globalThis.performance?.now() ?? 0,
874
+ level,
875
+ parts
876
+ };
877
+ this.buffer.push(entry);
878
+ if (this.buffer.length > this.capacity) this.buffer.splice(0, this.buffer.length - this.capacity);
879
+ this.added.emit(entry);
880
+ }
881
+ };
882
+ //#endregion
883
+ //#region src/core/engine.ts
884
+ const MAX_DT_SECONDS = .25;
885
+ function rafScheduler(cb) {
886
+ let live = true;
887
+ let id = requestAnimationFrame(function loop(t) {
888
+ if (!live) return;
889
+ cb(t);
890
+ id = requestAnimationFrame(loop);
891
+ });
892
+ return () => {
893
+ live = false;
894
+ cancelAnimationFrame(id);
895
+ };
896
+ }
897
+ /**
898
+ * The game loop: drives a Scene's tree with fixed-timestep `fixedUpdate`
899
+ * (physics/network window) and variable `update` (everything else).
900
+ *
901
+ * Instance-scoped by design — multiple engines can coexist. Headless-testable
902
+ * via the injectable scheduler and the public `tick(nowMs)`.
903
+ */
904
+ var Engine = class {
905
+ /** Emitted after the tree's variable update each frame, with dt seconds. */
906
+ updated = new Signal();
907
+ /** Emitted after each fixed step, with the fixed dt seconds. */
908
+ fixedUpdated = new Signal();
909
+ /**
910
+ * Emitted after a scene swap completes (input map redeclared) with the new
911
+ * scene, and with null on dispose — overlays (touch controls, debug panels)
912
+ * rebuild themselves here.
913
+ */
914
+ sceneChanged = new Signal();
915
+ /** Declarative input — scene `input{}` declarations load on setScene. */
916
+ input = new InputMap();
917
+ /** Seeded randomness for game logic (deterministic when `seed` is set). */
918
+ rng;
919
+ /** The engine log channel (debug overlay + headless harness tail this). */
920
+ log = new LogManager();
921
+ /** Global volume buses — `master` × `sfx`/`music` gain + `muted`. AudioPlayer
922
+ * routes its volume through this; games set it for global volume/mute. */
923
+ audio = new AudioBuses();
924
+ /** Low-latency procedural-SFX player (WebAudio, headless-safe). AudioPlayer
925
+ * presets play through it; games may call `engine.sfx.play(...)` directly. */
926
+ sfx = new SfxEngine();
927
+ /** Single-track background-music manager (crossfade/loop, headless-safe).
928
+ * `engine.music.play(src)` / `crossfadeTo(src, secs)` / `stop(fadeOut)`;
929
+ * routes through the music bus and is advanced each frame by the loop. */
930
+ music = new MusicManager(this.audio);
931
+ fixedStep;
932
+ maxFixedSteps;
933
+ scheduler;
934
+ _scene = null;
935
+ lastMs = null;
936
+ accumulator = 0;
937
+ disposeScheduler = null;
938
+ frameStats = new FrameStatsRing();
939
+ constructor(opts = {}) {
940
+ this.fixedStep = 1 / (opts.fixedHz ?? 60);
941
+ this.maxFixedSteps = opts.maxFixedStepsPerTick ?? 5;
942
+ this.scheduler = opts.scheduler ?? rafScheduler;
943
+ this.rng = new Rng(opts.seed ?? Math.random() * 4294967295 >>> 0);
944
+ }
945
+ get scene() {
946
+ return this._scene;
947
+ }
948
+ /** Replace the active scene. The previous scene's root is freed. */
949
+ setScene(scene) {
950
+ if (scene === this._scene) return;
951
+ this._scene?.tree._setEngine(null);
952
+ this._scene?.root.free();
953
+ this._scene = scene;
954
+ scene.tree._setEngine(this);
955
+ this.input.clear();
956
+ if (scene.input) this.input.declare(scene.input);
957
+ this.sceneChanged.emit(scene);
958
+ }
959
+ start() {
960
+ if (this.disposeScheduler) return;
961
+ this.disposeScheduler = this.scheduler((now) => this.tick(now));
962
+ }
963
+ stop() {
964
+ this.disposeScheduler?.();
965
+ this.disposeScheduler = null;
966
+ this.lastMs = null;
967
+ this.accumulator = 0;
968
+ this.frameStats.clear();
969
+ }
970
+ /**
971
+ * Live performance counters, queryable at ANY time. fps/frameMs come from a
972
+ * rolling window of REAL `tick` timestamps — headless `step()` runs report 0.
973
+ * Node count walks the active tree on demand. Renderer counters (triangles,
974
+ * draw calls) live on `renderer.stats()` / the merged `game.stats()`.
975
+ */
976
+ stats() {
977
+ return {
978
+ fps: this.frameStats.fps(),
979
+ frameMs: this.frameStats.frameMs(),
980
+ nodes: this._scene?.tree.root ? countNodes(this._scene.tree.root) : 0,
981
+ running: this.disposeScheduler !== null
982
+ };
983
+ }
984
+ /**
985
+ * Render interpolation factor in [0,1]: how far the wall clock has advanced
986
+ * INTO the next fixed step. Renderers lerp physics bodies between their last
987
+ * two fixed-step transforms by this much so motion looks smooth even when the
988
+ * display refresh doesn't divide evenly into the 60Hz fixed step (the classic
989
+ * fixed-timestep judder). Only meaningful in the real-time `tick()` loop;
990
+ * the headless `step()` path doesn't bank wall-clock time.
991
+ */
992
+ get interpolationAlpha() {
993
+ return this.fixedStep > 0 ? Math.min(this.accumulator / this.fixedStep, 1) : 1;
994
+ }
995
+ /**
996
+ * Full teardown in one call: stop the loop, free the scene tree, detach
997
+ * every input listener. The single-unmount story for SPA embedding —
998
+ * renderers own GPU resources and keep their own dispose().
999
+ */
1000
+ dispose() {
1001
+ this.stop();
1002
+ if (this._scene) {
1003
+ this._scene.tree._setEngine(null);
1004
+ this._scene.root.free();
1005
+ this._scene = null;
1006
+ this.sceneChanged.emit(null);
1007
+ }
1008
+ this.input.dispose();
1009
+ this.input.clear();
1010
+ }
1011
+ /**
1012
+ * Advance exactly ONE fixed step and one variable update (both dt = the
1013
+ * fixed step), bypassing the wall-clock accumulator entirely. Drift-free
1014
+ * by construction — the unit of time for headless harnesses (incanto/test
1015
+ * runScript): every input edge is visible to BOTH fixedUpdate and update
1016
+ * exactly once.
1017
+ */
1018
+ step() {
1019
+ const scene = this._scene;
1020
+ if (!scene) return;
1021
+ scene.tree.fixedUpdate(this.fixedStep);
1022
+ this.fixedUpdated.emit(this.fixedStep);
1023
+ scene.tree.update(this.fixedStep);
1024
+ this.music.tick(this.fixedStep);
1025
+ this.updated.emit(this.fixedStep);
1026
+ this.input.endFrame();
1027
+ }
1028
+ /** Advance the loop manually. First call after (re)start only primes the clock. */
1029
+ tick(nowMs) {
1030
+ this.frameStats.push(nowMs);
1031
+ if (this.lastMs === null) {
1032
+ this.lastMs = nowMs;
1033
+ return;
1034
+ }
1035
+ const dt = Math.min((nowMs - this.lastMs) / 1e3, MAX_DT_SECONDS);
1036
+ this.lastMs = nowMs;
1037
+ const scene = this._scene;
1038
+ if (!scene) return;
1039
+ this.accumulator += dt;
1040
+ let steps = 0;
1041
+ while (this.accumulator >= this.fixedStep && steps < this.maxFixedSteps) {
1042
+ scene.tree.fixedUpdate(this.fixedStep);
1043
+ this.fixedUpdated.emit(this.fixedStep);
1044
+ this.accumulator -= this.fixedStep;
1045
+ steps += 1;
1046
+ }
1047
+ if (this.accumulator >= this.fixedStep) this.accumulator %= this.fixedStep;
1048
+ scene.tree.update(dt);
1049
+ this.music.tick(dt);
1050
+ this.updated.emit(dt);
1051
+ this.input.endFrame();
1052
+ }
1053
+ };
1054
+ function countNodes(root) {
1055
+ let n = 1;
1056
+ for (const child of root.children) n += countNodes(child);
1057
+ return n;
1058
+ }
1059
+ //#endregion
1060
+ //#region src/core/particle-presets.ts
1061
+ const PARTICLE_PRESETS = {
1062
+ fire: {
1063
+ rate: 60,
1064
+ lifetime: [.4, .9],
1065
+ speed: [30, 70],
1066
+ directionDeg: -90,
1067
+ spreadDeg: 35,
1068
+ gravity: [0, -30],
1069
+ sizeStart: 14,
1070
+ sizeEnd: 3,
1071
+ colorStart: "#ffcf5a",
1072
+ colorEnd: "#e8401f",
1073
+ alphaStart: .9,
1074
+ alphaEnd: 0,
1075
+ blend: "add"
1076
+ },
1077
+ smoke: {
1078
+ rate: 20,
1079
+ lifetime: [1.5, 2.5],
1080
+ speed: [10, 25],
1081
+ directionDeg: -90,
1082
+ spreadDeg: 40,
1083
+ gravity: [0, -12],
1084
+ sizeStart: 10,
1085
+ sizeEnd: 26,
1086
+ colorStart: "#8a8a8a",
1087
+ colorEnd: "#3a3a3a",
1088
+ alphaStart: .35,
1089
+ alphaEnd: 0,
1090
+ blend: "normal"
1091
+ },
1092
+ sparks: {
1093
+ rate: 80,
1094
+ lifetime: [.3, .7],
1095
+ speed: [120, 260],
1096
+ spreadDeg: 360,
1097
+ gravity: [0, 240],
1098
+ drag: 1,
1099
+ sizeStart: 4,
1100
+ sizeEnd: 1,
1101
+ colorStart: "#fff3b0",
1102
+ colorEnd: "#ff9d2e",
1103
+ blend: "add"
1104
+ },
1105
+ fireworks: {
1106
+ rate: 0,
1107
+ burst: 120,
1108
+ lifetime: [.8, 1.6],
1109
+ speed: [80, 240],
1110
+ spreadDeg: 360,
1111
+ gravity: [0, 90],
1112
+ drag: .6,
1113
+ sizeStart: 5,
1114
+ sizeEnd: 1,
1115
+ colorStart: "#9ad8ff",
1116
+ colorEnd: "#ff5ad8",
1117
+ blend: "add"
1118
+ },
1119
+ explosion: {
1120
+ rate: 0,
1121
+ burst: 60,
1122
+ lifetime: [.25, .6],
1123
+ speed: [150, 420],
1124
+ spreadDeg: 360,
1125
+ drag: 3,
1126
+ sizeStart: 16,
1127
+ sizeEnd: 2,
1128
+ colorStart: "#ffe08a",
1129
+ colorEnd: "#ff3b1f",
1130
+ blend: "add"
1131
+ },
1132
+ flash: {
1133
+ rate: 0,
1134
+ burst: 8,
1135
+ lifetime: [.08, .16],
1136
+ speed: [0, 30],
1137
+ spreadDeg: 360,
1138
+ sizeStart: 64,
1139
+ sizeEnd: 96,
1140
+ colorStart: "#ffffff",
1141
+ colorEnd: "#ffffff",
1142
+ alphaStart: 1,
1143
+ alphaEnd: 0,
1144
+ blend: "add"
1145
+ },
1146
+ lightning: {
1147
+ rate: 90,
1148
+ lifetime: [.05, .12],
1149
+ speed: [400, 900],
1150
+ directionDeg: -90,
1151
+ spreadDeg: 14,
1152
+ sizeStart: 6,
1153
+ sizeEnd: 2,
1154
+ colorStart: "#eaf2ff",
1155
+ colorEnd: "#7fb4ff",
1156
+ blend: "add"
1157
+ },
1158
+ rain: {
1159
+ rate: 160,
1160
+ lifetime: [.8, 1.2],
1161
+ speed: [300, 420],
1162
+ directionDeg: 90,
1163
+ spreadDeg: 6,
1164
+ sizeStart: 3,
1165
+ sizeEnd: 3,
1166
+ colorStart: "#9ec8ff",
1167
+ colorEnd: "#9ec8ff",
1168
+ alphaStart: .5,
1169
+ alphaEnd: .2,
1170
+ blend: "normal"
1171
+ },
1172
+ snow: {
1173
+ rate: 40,
1174
+ lifetime: [3, 5],
1175
+ speed: [20, 50],
1176
+ directionDeg: 90,
1177
+ spreadDeg: 30,
1178
+ drag: .2,
1179
+ sizeStart: 5,
1180
+ sizeEnd: 4,
1181
+ colorStart: "#ffffff",
1182
+ colorEnd: "#ffffff",
1183
+ alphaStart: .9,
1184
+ alphaEnd: .6,
1185
+ blend: "normal"
1186
+ },
1187
+ magic: {
1188
+ rate: 50,
1189
+ lifetime: [.6, 1.4],
1190
+ speed: [20, 80],
1191
+ spreadDeg: 360,
1192
+ gravity: [0, -20],
1193
+ sizeStart: 7,
1194
+ sizeEnd: 1,
1195
+ colorStart: "#9dffe8",
1196
+ colorEnd: "#7a5cff",
1197
+ blend: "add"
1198
+ }
1199
+ };
1200
+ const PARTICLE_PRESET_NAMES = Object.keys(PARTICLE_PRESETS);
1201
+ /**
1202
+ * Apply the delta rule across three layers: schema default < preset < the
1203
+ * scene's explicit props. A prop still equal to its SCHEMA default is
1204
+ * considered "unset" and takes the preset value.
1205
+ */
1206
+ function applyParticlePreset(target, presetName, schema) {
1207
+ if (presetName === "custom") return;
1208
+ const preset = PARTICLE_PRESETS[presetName];
1209
+ if (!preset) return;
1210
+ for (const [key, presetValue] of Object.entries(preset)) {
1211
+ const def = schema[key]?.default;
1212
+ const current = target[key];
1213
+ if (def !== void 0 && jsonEquals(current, def)) target[key] = Array.isArray(presetValue) ? [...presetValue] : presetValue;
1214
+ }
1215
+ }
1216
+ //#endregion
1217
+ //#region src/core/particle-sim.ts
1218
+ const FIELDS = 9;
1219
+ var ParticleSim = class {
1220
+ config;
1221
+ rng;
1222
+ data;
1223
+ alive = 0;
1224
+ spawnAccumulator = 0;
1225
+ everSpawned = false;
1226
+ constructor(config, rng) {
1227
+ this.config = config;
1228
+ this.rng = rng;
1229
+ this.data = new Float64Array(Math.max(1, config.maxParticles) * FIELDS);
1230
+ }
1231
+ get count() {
1232
+ return this.alive;
1233
+ }
1234
+ /** True when nothing is alive and at least one particle has ever spawned. */
1235
+ get done() {
1236
+ return this.everSpawned && this.alive === 0;
1237
+ }
1238
+ /** Spawn n particles immediately (fireworks, explosions, flashes). */
1239
+ burst(n) {
1240
+ for (let i = 0; i < n; i++) this.spawn();
1241
+ }
1242
+ update(dt) {
1243
+ if (this.config.rate > 0) {
1244
+ this.spawnAccumulator += this.config.rate * dt;
1245
+ while (this.spawnAccumulator >= 1) {
1246
+ this.spawnAccumulator -= 1;
1247
+ this.spawn();
1248
+ }
1249
+ }
1250
+ const d = this.data;
1251
+ const [gx, gy, gz] = this.config.gravity;
1252
+ const damp = this.config.drag > 0 ? Math.exp(-this.config.drag * dt) : 1;
1253
+ let i = 0;
1254
+ while (i < this.alive) {
1255
+ const base = i * FIELDS;
1256
+ const age = d[base + 6] + dt;
1257
+ if (age >= d[base + 7]) {
1258
+ this.kill(i);
1259
+ continue;
1260
+ }
1261
+ d[base + 6] = age;
1262
+ d[base + 3] = (d[base + 3] + gx * dt) * damp;
1263
+ d[base + 4] = (d[base + 4] + gy * dt) * damp;
1264
+ d[base + 5] = (d[base + 5] + gz * dt) * damp;
1265
+ d[base + 0] = d[base + 0] + d[base + 3] * dt;
1266
+ d[base + 1] = d[base + 1] + d[base + 4] * dt;
1267
+ d[base + 2] = d[base + 2] + d[base + 5] * dt;
1268
+ i += 1;
1269
+ }
1270
+ }
1271
+ forEach(fn) {
1272
+ const d = this.data;
1273
+ for (let i = 0; i < this.alive; i++) {
1274
+ const base = i * FIELDS;
1275
+ const age = d[base + 6];
1276
+ const life = d[base + 7];
1277
+ fn({
1278
+ x: d[base + 0],
1279
+ y: d[base + 1],
1280
+ z: d[base + 2],
1281
+ vx: d[base + 3],
1282
+ vy: d[base + 4],
1283
+ vz: d[base + 5],
1284
+ age,
1285
+ life,
1286
+ t: life > 0 ? age / life : 1,
1287
+ seed: d[base + 8]
1288
+ });
1289
+ }
1290
+ }
1291
+ spawn() {
1292
+ if (this.alive >= this.config.maxParticles) return;
1293
+ const base = this.alive * FIELDS;
1294
+ const d = this.data;
1295
+ const c = this.config;
1296
+ const angle = (c.directionDeg + (this.rng.next() - .5) * c.spreadDeg) * Math.PI / 180;
1297
+ const speed = c.speed[0] + this.rng.next() * (c.speed[1] - c.speed[0]);
1298
+ const zTilt = c.spreadZ ? (this.rng.next() - .5) * (c.spreadDeg * Math.PI / 180) : 0;
1299
+ const planar = Math.cos(zTilt);
1300
+ d[base + 0] = 0;
1301
+ d[base + 1] = 0;
1302
+ d[base + 2] = 0;
1303
+ d[base + 3] = Math.cos(angle) * speed * planar;
1304
+ d[base + 4] = Math.sin(angle) * speed * planar;
1305
+ d[base + 5] = Math.sin(zTilt) * speed;
1306
+ d[base + 6] = 0;
1307
+ d[base + 7] = c.lifetime[0] + this.rng.next() * (c.lifetime[1] - c.lifetime[0]);
1308
+ d[base + 8] = this.rng.next();
1309
+ this.alive += 1;
1310
+ this.everSpawned = true;
1311
+ }
1312
+ kill(index) {
1313
+ const last = this.alive - 1;
1314
+ if (index !== last) this.data.copyWithin(index * FIELDS, last * FIELDS, last * FIELDS + FIELDS);
1315
+ this.alive = last;
1316
+ }
1317
+ };
1318
+ //#endregion
1319
+ export { Engine as a, SfxEngine as c, WebAudioMusicBackend as d, crossfadeGains as f, applyParticlePreset as i, isAudioContextAvailable as l, AudioBuses as m, PARTICLE_PRESETS as n, LogManager as o, fadeGain as p, PARTICLE_PRESET_NAMES as r, InputMap as s, ParticleSim as t, MusicManager as u };