incanto 0.7.0 → 0.8.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 (48) hide show
  1. package/bin/incanto-check.mjs +6 -4
  2. package/dist/2d.d.ts +93 -3
  3. package/dist/2d.js +4 -4
  4. package/dist/3d.d.ts +45 -4
  5. package/dist/3d.js +4 -4
  6. package/dist/{audio-player-VSjk_vG8.d.ts → audio-player-Dyjg5k92.d.ts} +1 -1
  7. package/dist/audit-C6rMyict.js +58 -0
  8. package/dist/{behavior-e3kAmPGC.d.ts → behavior-Bod1AyJS.d.ts} +211 -174
  9. package/dist/{create-game-CEFoFN8d.js → create-game-66c4iYJE.js} +76 -6
  10. package/dist/{create-game-X5iRu6pf.js → create-game-BNDSbhy-.js} +88 -6
  11. package/dist/debug.d.ts +1 -1
  12. package/dist/debug.js +3 -0
  13. package/dist/{errors-BMFaY68Q.d.ts → errors-1dXlIwoR.d.ts} +7 -1
  14. package/dist/{gameplay-CDFgSG6z.js → gameplay-DPMgZk9W.js} +108 -1
  15. package/dist/gameplay.d.ts +44 -3
  16. package/dist/gameplay.js +2 -2
  17. package/dist/index.d.ts +151 -9
  18. package/dist/index.js +189 -4
  19. package/dist/{loader-y_X4zYO0.d.ts → loader-BcUDfNHn.d.ts} +1 -1
  20. package/dist/net.d.ts +46 -2
  21. package/dist/net.js +54 -2
  22. package/dist/particle-sim-CyUU7HVU.js +281 -0
  23. package/dist/{physics-2d-DmQ540uR.js → physics-2d-q2N362ph.js} +45 -4
  24. package/dist/{physics-3d-CDjuhtt_.js → physics-3d-CvHr31d4.js} +1 -1
  25. package/dist/react.d.ts +1 -1
  26. package/dist/react.js +1 -1
  27. package/dist/{register-CscQqB7V.js → register-Cbs7QxoV.js} +139 -4
  28. package/dist/{register-ClKnoILk.js → register-Cqjxrfq0.js} +254 -4
  29. package/dist/{register-C35HDZpm.js → register-CxmuI9FL.js} +1 -1
  30. package/dist/{particle-sim-CrTE7c02.js → register-DRPIjrKG.js} +1185 -278
  31. package/dist/test.d.ts +4 -10
  32. package/dist/test.js +11 -16
  33. package/editor/assets/{agent8-1WJU-yXr.js → agent8-B8QnWCeP.js} +1 -1
  34. package/editor/assets/{index-DzYFgZbl.js → index-CVhnNkb3.js} +81 -81
  35. package/editor/index.html +1 -1
  36. package/package.json +1 -1
  37. package/schemas/scene.schema.json +427 -0
  38. package/skills/incanto-3d-character.md +14 -0
  39. package/skills/incanto-audio.md +23 -0
  40. package/skills/incanto-building-2d-games.md +31 -0
  41. package/skills/incanto-building-3d-games.md +21 -0
  42. package/skills/incanto-gameplay-behaviors.md +28 -0
  43. package/skills/incanto-hud.md +24 -0
  44. package/skills/incanto-multiplayer.md +27 -0
  45. package/skills/incanto-node-reference.md +73 -0
  46. package/skills/incanto-physics-and-input.md +9 -0
  47. package/skills/incanto-verifying-your-game.md +40 -0
  48. package/dist/register-Dl_ixIJe.js +0 -834
@@ -1,8 +1,7 @@
1
- import { y as Signal } from "./loader-B4OEXDZ8.js";
1
+ import { f as Node, y as Signal } from "./loader-B4OEXDZ8.js";
2
2
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
3
3
  import { t as Rng } from "./rng-DP-SR7eg.js";
4
- import { p as synthSfx } from "./register-Dl_ixIJe.js";
5
- import { n as jsonEquals } from "./json-BLk7H2Qa.js";
4
+ import { l as registerNode } from "./registry-BVJ2HbCn.js";
6
5
  //#region src/core/audio/buses.ts
7
6
  /**
8
7
  * Global volume state — `master` × `sfx`/`music` gain (0..1) plus a `muted`
@@ -55,17 +54,17 @@ var AudioBuses = class {
55
54
  effectiveVolume(bus, sourceVolume) {
56
55
  if (this._muted) return 0;
57
56
  const busGain = bus === "music" ? this._music : this._sfx;
58
- return this._master * busGain * clamp01$2(sourceVolume);
57
+ return this._master * busGain * clamp01$3(sourceVolume);
59
58
  }
60
59
  /** Clamp + finite-guard a new value; emit only on a real change. */
61
60
  assign(current, next) {
62
61
  if (!Number.isFinite(next)) return current;
63
- const clamped = clamp01$2(next);
62
+ const clamped = clamp01$3(next);
64
63
  if (clamped !== current) this.changed.emit();
65
64
  return clamped;
66
65
  }
67
66
  };
68
- function clamp01$2(v) {
67
+ function clamp01$3(v) {
69
68
  if (!Number.isFinite(v)) return 0;
70
69
  return v < 0 ? 0 : v > 1 ? 1 : v;
71
70
  }
@@ -91,17 +90,17 @@ function fadeGain(t, duration, dir) {
91
90
  function crossfadeGains(t, seconds) {
92
91
  const angle = progress(t, seconds) * Math.PI / 2;
93
92
  return {
94
- out: clamp01$1(Math.cos(angle)),
95
- in: clamp01$1(Math.sin(angle))
93
+ out: clamp01$2(Math.cos(angle)),
94
+ in: clamp01$2(Math.sin(angle))
96
95
  };
97
96
  }
98
97
  /** Normalized progress 0..1; a non-positive duration is instantly complete. */
99
98
  function progress(t, duration) {
100
99
  if (!Number.isFinite(t)) return 1;
101
100
  if (duration <= 0) return 1;
102
- return clamp01$1(t / duration);
101
+ return clamp01$2(t / duration);
103
102
  }
104
- function clamp01$1(v) {
103
+ function clamp01$2(v) {
105
104
  if (!Number.isFinite(v)) return 0;
106
105
  return v < 0 ? 0 : v > 1 ? 1 : v;
107
106
  }
@@ -113,12 +112,12 @@ function createContext$1() {
113
112
  const Ctor = resolveCtor$1();
114
113
  return Ctor ? new Ctor() : null;
115
114
  }
116
- function createElement() {
115
+ function createElement$1() {
117
116
  if (typeof Audio === "undefined") return null;
118
117
  return new Audio();
119
118
  }
120
119
  /** A no-op track for the headless / missing-backend path. */
121
- const INERT = {
120
+ const INERT$1 = {
122
121
  src: "",
123
122
  setGain() {},
124
123
  setLoop() {},
@@ -171,9 +170,9 @@ var WebAudioMusicBackend = class {
171
170
  }
172
171
  createTrack(src) {
173
172
  const ctx = this.ensure();
174
- const el = createElement();
173
+ const el = createElement$1();
175
174
  if (!ctx || !el) return {
176
- ...INERT,
175
+ ...INERT$1,
177
176
  src
178
177
  };
179
178
  if (ctx.state === "suspended") ctx.resume();
@@ -311,7 +310,7 @@ var MusicManager = class {
311
310
  }
312
311
  /** Set the per-manager volume (re-applied to live tracks on the next tick). */
313
312
  setVolume(v) {
314
- this._volume = clamp01(v);
313
+ this._volume = clamp01$1(v);
315
314
  }
316
315
  /** Advance fades by `dt` seconds and (re)apply bus×fade gains. Frame-driven. */
317
316
  tick(dt) {
@@ -356,11 +355,436 @@ var MusicManager = class {
356
355
  this.outgoing = null;
357
356
  }
358
357
  };
359
- function clamp01(v) {
358
+ function clamp01$1(v) {
360
359
  if (!Number.isFinite(v)) return 0;
361
360
  return v < 0 ? 0 : v > 1 ? 1 : v;
362
361
  }
363
362
  //#endregion
363
+ //#region src/core/audio/sfx-presets.ts
364
+ /**
365
+ * A tasteful, distinct set of common game sounds. Pick one via
366
+ * `AudioPlayer.preset`. Tuned by ear for crisp arcade feel; `seed`/`pitch`
367
+ * props add free variation so repeated sounds don't feel robotic.
368
+ */
369
+ const SFX_PRESETS = {
370
+ coin: {
371
+ wave: "square",
372
+ baseFreq: 988,
373
+ freqRamp: 1800,
374
+ attack: 0,
375
+ sustain: .04,
376
+ decay: .18,
377
+ duty: .5,
378
+ volume: .4
379
+ },
380
+ pickup: {
381
+ wave: "sine",
382
+ baseFreq: 660,
383
+ freqRamp: 900,
384
+ attack: .005,
385
+ sustain: .05,
386
+ decay: .12,
387
+ volume: .45
388
+ },
389
+ jump: {
390
+ wave: "square",
391
+ baseFreq: 320,
392
+ freqRamp: 600,
393
+ attack: 0,
394
+ sustain: .03,
395
+ decay: .12,
396
+ duty: .35,
397
+ volume: .4
398
+ },
399
+ hurt: {
400
+ wave: "sawtooth",
401
+ baseFreq: 380,
402
+ freqRamp: -520,
403
+ attack: 0,
404
+ sustain: .05,
405
+ decay: .16,
406
+ volume: .5
407
+ },
408
+ hit: {
409
+ wave: "noise",
410
+ baseFreq: 600,
411
+ freqRamp: -400,
412
+ attack: 0,
413
+ sustain: .02,
414
+ decay: .1,
415
+ volume: .55
416
+ },
417
+ explosion: {
418
+ wave: "noise",
419
+ baseFreq: 420,
420
+ freqRamp: -360,
421
+ attack: 0,
422
+ sustain: .12,
423
+ decay: .5,
424
+ volume: .6
425
+ },
426
+ powerup: {
427
+ wave: "square",
428
+ baseFreq: 440,
429
+ freqRamp: 1200,
430
+ attack: .01,
431
+ sustain: .18,
432
+ decay: .22,
433
+ duty: .5,
434
+ vibratoDepth: .08,
435
+ vibratoRate: 18,
436
+ volume: .4
437
+ },
438
+ laser: {
439
+ wave: "sawtooth",
440
+ baseFreq: 1400,
441
+ freqRamp: -2600,
442
+ attack: 0,
443
+ sustain: .03,
444
+ decay: .14,
445
+ volume: .4
446
+ },
447
+ shoot: {
448
+ wave: "square",
449
+ baseFreq: 900,
450
+ freqRamp: -1400,
451
+ attack: 0,
452
+ sustain: .02,
453
+ decay: .1,
454
+ duty: .25,
455
+ volume: .45
456
+ },
457
+ blip: {
458
+ wave: "square",
459
+ baseFreq: 760,
460
+ freqRamp: 0,
461
+ attack: 0,
462
+ sustain: .015,
463
+ decay: .04,
464
+ duty: .5,
465
+ volume: .35
466
+ },
467
+ select: {
468
+ wave: "triangle",
469
+ baseFreq: 520,
470
+ freqRamp: 420,
471
+ attack: .002,
472
+ sustain: .04,
473
+ decay: .08,
474
+ volume: .4
475
+ },
476
+ step: {
477
+ wave: "noise",
478
+ baseFreq: 220,
479
+ freqRamp: -120,
480
+ attack: 0,
481
+ sustain: .01,
482
+ decay: .05,
483
+ volume: .3
484
+ },
485
+ win: {
486
+ wave: "square",
487
+ baseFreq: 523,
488
+ freqRamp: 900,
489
+ attack: .01,
490
+ sustain: .35,
491
+ decay: .3,
492
+ duty: .5,
493
+ vibratoDepth: .05,
494
+ vibratoRate: 10,
495
+ volume: .42
496
+ },
497
+ lose: {
498
+ wave: "sawtooth",
499
+ baseFreq: 392,
500
+ freqRamp: -300,
501
+ attack: .01,
502
+ sustain: .3,
503
+ decay: .4,
504
+ volume: .45
505
+ }
506
+ };
507
+ const SFX_PRESET_NAMES = Object.keys(SFX_PRESETS);
508
+ const TWO_PI = Math.PI * 2;
509
+ /**
510
+ * Render a preset to mono PCM in [-1, 1]. PURE: identical inputs → identical
511
+ * output (the only randomness is the seeded noise generator), so determinism is
512
+ * unit-testable headlessly. Length = ceil((attack+sustain+decay) * sampleRate).
513
+ */
514
+ function synthSfx(params, opts = {}) {
515
+ const sampleRate = opts.sampleRate ?? 44100;
516
+ const pitch = opts.pitch ?? 1;
517
+ const rng = new Rng((opts.seed ?? 0) ^ 42405);
518
+ const total = params.attack + params.sustain + params.decay;
519
+ const length = Math.max(1, Math.ceil(total * sampleRate));
520
+ const out = new Float32Array(length);
521
+ const duty = params.duty ?? .5;
522
+ const vibDepth = params.vibratoDepth ?? 0;
523
+ const vibRate = params.vibratoRate ?? 0;
524
+ let phase = 0;
525
+ let lastNoise = rng.range(-1, 1);
526
+ let noiseHold = 0;
527
+ for (let i = 0; i < length; i++) {
528
+ const t = i / sampleRate;
529
+ let env;
530
+ if (t < params.attack) env = params.attack > 0 ? t / params.attack : 1;
531
+ else if (t < params.attack + params.sustain) env = 1;
532
+ else {
533
+ const d = t - params.attack - params.sustain;
534
+ env = params.decay > 0 ? Math.max(0, 1 - d / params.decay) : 0;
535
+ }
536
+ let freq = (params.baseFreq + params.freqRamp * t) * pitch;
537
+ if (vibDepth > 0 && vibRate > 0) freq += freq * vibDepth * Math.sin(TWO_PI * vibRate * t);
538
+ freq = Math.max(0, freq);
539
+ phase += freq / sampleRate;
540
+ phase -= Math.floor(phase);
541
+ let sample;
542
+ switch (params.wave) {
543
+ case "square":
544
+ sample = phase < duty ? 1 : -1;
545
+ break;
546
+ case "sawtooth":
547
+ sample = 2 * phase - 1;
548
+ break;
549
+ case "triangle":
550
+ sample = 4 * Math.abs(phase - .5) - 1;
551
+ break;
552
+ case "sine":
553
+ sample = Math.sin(TWO_PI * phase);
554
+ break;
555
+ default:
556
+ noiseHold -= freq / sampleRate;
557
+ if (noiseHold <= 0) {
558
+ lastNoise = rng.range(-1, 1);
559
+ noiseHold += 1;
560
+ }
561
+ sample = lastNoise;
562
+ break;
563
+ }
564
+ out[i] = clamp$1(sample * env * params.volume);
565
+ }
566
+ return out;
567
+ }
568
+ function clamp$1(v) {
569
+ return v < -1 ? -1 : v > 1 ? 1 : v;
570
+ }
571
+ //#endregion
572
+ //#region src/core/audio/spatial.ts
573
+ const ROLLOFF_MODELS = [
574
+ "inverse",
575
+ "linear",
576
+ "exponential"
577
+ ];
578
+ /**
579
+ * Gain (0..1) for a source `distance` units from the listener, per the chosen
580
+ * model. Identical to the WebAudio PannerNode formulas so the headless number
581
+ * matches the browser panner:
582
+ *
583
+ * - inverse: ref / (ref + factor·(clamp(d) − ref))
584
+ * - linear: 1 − factor·(clamp(d) − ref) / (max − ref)
585
+ * - exponential: (clamp(d) / ref)^(−factor)
586
+ *
587
+ * where `clamp(d)` is `d` clamped to `[ref, max]`. Always full inside
588
+ * `refDistance`; monotonic non-increasing out to `maxDistance`. Non-finite or
589
+ * degenerate inputs return a safe finite gain.
590
+ */
591
+ function spatialGain(distance, params) {
592
+ const ref = Math.max(0, params.refDistance);
593
+ const max = Math.max(ref, params.maxDistance);
594
+ const factor = params.rolloffFactor ?? 1;
595
+ if (!Number.isFinite(distance)) return 1;
596
+ const d = clamp(distance, ref, max);
597
+ if (d <= ref) return 1;
598
+ let g;
599
+ switch (params.rolloff) {
600
+ case "linear": {
601
+ const span = max - ref;
602
+ g = span <= 0 ? 1 : 1 - factor * (d - ref) / span;
603
+ break;
604
+ }
605
+ case "exponential":
606
+ g = ref <= 0 ? 1 : (d / ref) ** -factor;
607
+ break;
608
+ default:
609
+ g = ref / (ref + factor * (d - ref));
610
+ break;
611
+ }
612
+ if (!Number.isFinite(g)) return 0;
613
+ return clamp01(g);
614
+ }
615
+ const scratchToSource = [
616
+ 0,
617
+ 0,
618
+ 0
619
+ ];
620
+ /**
621
+ * Stereo pan (−1 left … 0 center … +1 right) for a source at `sourcePos`,
622
+ * relative to the listener's orientation. Projects the listener→source vector
623
+ * onto the listener's RIGHT axis (forward × up) and normalizes by its length —
624
+ * sign tells left/right, magnitude tells how far off-axis. A source at the
625
+ * listener position (or dead ahead) is centered. Result clamped to [−1, 1].
626
+ *
627
+ * This is the CPU twin of what HRTF/equalpower panning does to the azimuth; the
628
+ * real PannerNode does the full spatialization, but exposing the sign here makes
629
+ * the geometry unit-testable and lets the 2D fallback pan by the same rule.
630
+ */
631
+ function spatialPan(sourcePos, listener) {
632
+ const dx = sourcePos[0] - listener.position[0];
633
+ const dy = sourcePos[1] - listener.position[1];
634
+ const dz = sourcePos[2] - listener.position[2];
635
+ const len = Math.hypot(dx, dy, dz);
636
+ if (len < 1e-6) return 0;
637
+ scratchToSource[0] = dx / len;
638
+ scratchToSource[1] = dy / len;
639
+ scratchToSource[2] = dz / len;
640
+ const [fx, fy, fz] = listener.forward;
641
+ const [ux, uy, uz] = listener.up;
642
+ const rx = fy * uz - fz * uy;
643
+ const ry = fz * ux - fx * uz;
644
+ const rz = fx * uy - fy * ux;
645
+ const rLen = Math.hypot(rx, ry, rz);
646
+ if (rLen < 1e-6) return 0;
647
+ return clamp((scratchToSource[0] * rx + scratchToSource[1] * ry + scratchToSource[2] * rz) / rLen, -1, 1);
648
+ }
649
+ function clamp(v, lo, hi) {
650
+ return v < lo ? lo : v > hi ? hi : v;
651
+ }
652
+ function clamp01(v) {
653
+ return v < 0 ? 0 : v > 1 ? 1 : v;
654
+ }
655
+ //#endregion
656
+ //#region src/core/audio/voice.ts
657
+ const SPECS = {
658
+ engine: {
659
+ oscillators: [[
660
+ "sawtooth",
661
+ 55,
662
+ 0
663
+ ], [
664
+ "sawtooth",
665
+ 55,
666
+ 12
667
+ ]],
668
+ noise: null,
669
+ lowpassHz: 900
670
+ },
671
+ wind: {
672
+ oscillators: [],
673
+ noise: [
674
+ 700,
675
+ .6,
676
+ 1
677
+ ],
678
+ lowpassHz: 4e3
679
+ },
680
+ hum: {
681
+ oscillators: [[
682
+ "sine",
683
+ 110,
684
+ 0
685
+ ], [
686
+ "triangle",
687
+ 110,
688
+ 5
689
+ ]],
690
+ noise: null,
691
+ lowpassHz: 1200
692
+ },
693
+ noise: {
694
+ oscillators: [],
695
+ noise: [
696
+ 1200,
697
+ .3,
698
+ 1
699
+ ],
700
+ lowpassHz: 6e3
701
+ }
702
+ };
703
+ const INERT = {
704
+ set: () => {},
705
+ stop: () => {},
706
+ stopped: true
707
+ };
708
+ /** @internal Build a live voice on a context (SfxEngine wraps this). */
709
+ function createVoice(ctx, preset, gain) {
710
+ const spec = SPECS[preset] ?? SPECS.hum;
711
+ const out = ctx.createGain();
712
+ out.gain.value = 0;
713
+ out.connect(ctx.destination);
714
+ const master = gain;
715
+ const lowpass = ctx.createBiquadFilter();
716
+ lowpass.type = "lowpass";
717
+ lowpass.frequency.value = spec.lowpassHz;
718
+ lowpass.connect(out);
719
+ const oscs = [];
720
+ for (const [type, hz, cents] of spec.oscillators) {
721
+ const osc = ctx.createOscillator();
722
+ osc.type = type;
723
+ osc.frequency.value = hz;
724
+ osc.detune.value = cents;
725
+ osc.connect(lowpass);
726
+ osc.start();
727
+ oscs.push(osc);
728
+ }
729
+ let noiseSrc = null;
730
+ let noiseBand = null;
731
+ if (spec.noise) {
732
+ const [center, q, noiseGain] = spec.noise;
733
+ const buffer = ctx.createBuffer(1, ctx.sampleRate * 2, ctx.sampleRate);
734
+ const data = buffer.getChannelData(0);
735
+ let seed = 987654321;
736
+ for (let i = 0; i < data.length; i++) {
737
+ seed = seed * 1664525 + 1013904223 >>> 0;
738
+ data[i] = seed / 4294967295 * 2 - 1;
739
+ }
740
+ noiseSrc = ctx.createBufferSource();
741
+ noiseSrc.buffer = buffer;
742
+ noiseSrc.loop = true;
743
+ noiseBand = ctx.createBiquadFilter();
744
+ noiseBand.type = "bandpass";
745
+ noiseBand.frequency.value = center;
746
+ noiseBand.Q.value = q;
747
+ const ng = ctx.createGain();
748
+ ng.gain.value = noiseGain;
749
+ noiseSrc.connect(noiseBand);
750
+ noiseBand.connect(ng);
751
+ ng.connect(lowpass);
752
+ noiseSrc.start();
753
+ }
754
+ const baseFreqs = spec.oscillators.map(([, hz]) => hz);
755
+ const noiseCenter = spec.noise?.[0] ?? 0;
756
+ let stopped = false;
757
+ const now = () => ctx.currentTime;
758
+ out.gain.setTargetAtTime(.4 * master, now(), .05);
759
+ return {
760
+ get stopped() {
761
+ return stopped;
762
+ },
763
+ set({ pitch, volume }) {
764
+ if (stopped) return;
765
+ if (pitch !== void 0) {
766
+ const p = Math.max(.05, pitch);
767
+ oscs.forEach((osc, i) => {
768
+ osc.frequency.setTargetAtTime(baseFreqs[i] * p, now(), .03);
769
+ });
770
+ if (noiseBand) noiseBand.frequency.setTargetAtTime(noiseCenter * p, now(), .03);
771
+ }
772
+ if (volume !== void 0) out.gain.setTargetAtTime(Math.max(0, Math.min(1, volume)) * master, now(), .04);
773
+ },
774
+ stop(seconds = .15) {
775
+ if (stopped) return;
776
+ stopped = true;
777
+ out.gain.setTargetAtTime(0, now(), Math.max(.01, seconds / 3));
778
+ setTimeout(() => {
779
+ for (const osc of oscs) osc.stop();
780
+ noiseSrc?.stop();
781
+ out.disconnect();
782
+ }, seconds * 1e3 + 120);
783
+ }
784
+ };
785
+ }
786
+ const INERT_VOICE = INERT;
787
+ //#endregion
364
788
  //#region src/core/audio/webaudio-sfx.ts
365
789
  /** Position a WebAudio AudioListener from a pose (modern AudioParam or legacy). */
366
790
  function applyListenerPose(ctxListener, pose) {
@@ -429,6 +853,17 @@ var SfxEngine = class {
429
853
  }
430
854
  return this.ctx;
431
855
  }
856
+ /**
857
+ * Start a continuous parametric voice (engine hum, wind, thrusters) —
858
+ * retune it per frame with `voice.set({ pitch, volume })`, `stop()` when
859
+ * done. Headless returns an inert handle (same call sites, no ifs).
860
+ */
861
+ startVoice(preset, gain = 1) {
862
+ const ctx = this.ensure();
863
+ if (!ctx) return INERT_VOICE;
864
+ if (ctx.state === "suspended") ctx.resume();
865
+ return createVoice(ctx, preset, gain);
866
+ }
432
867
  /** Resume a gesture-suspended context (wired to the first user gesture). */
433
868
  unlock() {
434
869
  const ctx = this.ensure();
@@ -1011,6 +1446,13 @@ var Engine = class {
1011
1446
  * timers, behaviors all breathe together. See `gameplay` `hitStop()`.
1012
1447
  */
1013
1448
  timeScale = 1;
1449
+ /**
1450
+ * The node the dev overlay has selected (renderers draw its bounding box
1451
+ * as an orange outline in the game view so you can SEE what you picked).
1452
+ * Null when nothing is selected; cleared automatically when it leaves the
1453
+ * tree. Set by the debug overlay — games normally never touch this.
1454
+ */
1455
+ debugSelection = null;
1014
1456
  _time = 0;
1015
1457
  _unscaledTime = 0;
1016
1458
  /**
@@ -1151,284 +1593,749 @@ function countNodes(root) {
1151
1593
  for (const child of root.children) n += countNodes(child);
1152
1594
  return n;
1153
1595
  }
1596
+ function createElement() {
1597
+ if (typeof Audio === "undefined") return null;
1598
+ return new Audio();
1599
+ }
1600
+ /**
1601
+ * One sound. `play()` (or `autoplay: true`) starts it; `finished` fires when
1602
+ * it ends. Browsers block audio before the first user gesture — a blocked
1603
+ * play marks `pendingGesture`; `createGame` retries pending players on the
1604
+ * first pointer/key gesture automatically.
1605
+ *
1606
+ * Two playback modes:
1607
+ * - `preset: 'custom'` (default) → plays the audio file at `src` via an HTMLAudio
1608
+ * element (good for music / long clips; supports `loop`).
1609
+ * - `preset: 'coin' | 'jump' | …` (a procedural SFX preset) → synthesizes a
1610
+ * zero-asset sound through WebAudio: instant, deterministic, overlap-friendly
1611
+ * for rapid-fire SFX. `pitch`/`seed` vary it. The art-free audio analog of the
1612
+ * particle presets. See `incanto-audio.md` for the full preset list.
1613
+ *
1614
+ * Volume routes through the engine's buses: `engine.audio.master × bus(sfx|music)
1615
+ * × volume`. Set `engine.audio.master`/`sfx`/`music`/`muted` for global control.
1616
+ */
1617
+ var AudioPlayer = class extends Node {
1618
+ static typeName = "AudioPlayer";
1619
+ static signals = ["finished"];
1620
+ static props = {
1621
+ src: { default: "" },
1622
+ preset: {
1623
+ default: "custom",
1624
+ options: ["custom", ...SFX_PRESET_NAMES]
1625
+ },
1626
+ volume: { default: 1 },
1627
+ pitch: { default: 1 },
1628
+ seed: { default: 0 },
1629
+ bus: {
1630
+ default: "sfx",
1631
+ options: ["sfx", "music"]
1632
+ },
1633
+ loop: { default: false },
1634
+ autoplay: { default: false },
1635
+ spatial: { default: false },
1636
+ refDistance: { default: 1 },
1637
+ maxDistance: { default: 50 },
1638
+ rolloff: {
1639
+ default: "inverse",
1640
+ options: [...ROLLOFF_MODELS]
1641
+ }
1642
+ };
1643
+ /** Audio file url (same resolution rules as scene asset urls). Used when
1644
+ * `preset === 'custom'`; ignored for procedural presets. */
1645
+ src = "";
1646
+ /** A procedural SFX preset name (zero-asset), or 'custom' to use `src`. */
1647
+ preset = "custom";
1648
+ volume = 1;
1649
+ /** Pitch multiplier for procedural presets (1 = unchanged). */
1650
+ pitch = 1;
1651
+ /** Variation seed for noisy presets (e.g. explosion/hit/step). */
1652
+ seed = 0;
1653
+ /** Which volume bus this routes through. */
1654
+ bus = "sfx";
1655
+ loop = false;
1656
+ /** Start on the first frame in the tree (subject to the gesture policy). */
1657
+ autoplay = false;
1658
+ /**
1659
+ * 3D positional audio: in a 3D scene the sound pans + attenuates by the
1660
+ * emitter's world position relative to the active Camera3D (the listener).
1661
+ * Default false → identical non-spatial behavior (back-compat). In a 2D scene
1662
+ * spatial is currently ignored (the adapter feeds no pose) — see incanto-audio.
1663
+ */
1664
+ spatial = false;
1665
+ /** Distance at which spatial gain is full; closer never gets louder. */
1666
+ refDistance = 1;
1667
+ /** Distance past which spatial gain stops falling. */
1668
+ maxDistance = 50;
1669
+ /** Spatial attenuation curve: 'inverse' | 'linear' | 'exponential'. */
1670
+ rolloff = "inverse";
1671
+ /** Loader hook: unknown presets / buses fail at LOAD (agents self-correct). */
1672
+ static validateJson(node) {
1673
+ const a = node;
1674
+ if (a.preset !== "custom" && !SFX_PRESET_NAMES.includes(a.preset)) throw new IncantoError("BAD_FORMAT", `AudioPlayer '${node.name}' preset must be 'custom' or one of [${SFX_PRESET_NAMES.join(", ")}], got '${a.preset}'.`, {
1675
+ prop: "preset",
1676
+ validOptions: SFX_PRESET_NAMES
1677
+ });
1678
+ if (a.bus !== "sfx" && a.bus !== "music") throw new IncantoError("BAD_FORMAT", `AudioPlayer '${node.name}' bus must be 'sfx' or 'music', got '${a.bus}'.`, {
1679
+ prop: "bus",
1680
+ validOptions: ["sfx", "music"]
1681
+ });
1682
+ if (!ROLLOFF_MODELS.includes(a.rolloff)) throw new IncantoError("BAD_FORMAT", `AudioPlayer '${node.name}' rolloff must be one of [${ROLLOFF_MODELS.join(", ")}], got '${a.rolloff}'.`, {
1683
+ prop: "rolloff",
1684
+ validOptions: [...ROLLOFF_MODELS]
1685
+ });
1686
+ }
1687
+ /** A play() was blocked by the browser's autoplay policy. */
1688
+ pendingGesture = false;
1689
+ element = null;
1690
+ _playing = false;
1691
+ autoplayed = false;
1692
+ /** Last spatial pose pushed by the 3D adapter (null until/unless spatial). */
1693
+ _spatialPose = null;
1694
+ get playing() {
1695
+ return this._playing;
1696
+ }
1697
+ /**
1698
+ * @internal Per-frame spatial feed from the 3D adapter (detected structurally
1699
+ * by syncTree). Stores the emitter world position + listener pose; ignored
1700
+ * unless `spatial` is on. Headless / 2D scenes never call this.
1701
+ */
1702
+ _setSpatialPose(pose) {
1703
+ this._spatialPose = pose;
1704
+ }
1705
+ /** Final gain = engine buses × this volume (1 when not in a tree). */
1706
+ gain() {
1707
+ const buses = this.tree?.engine?.audio;
1708
+ return buses ? buses.effectiveVolume(this.bus, this.volume) : this.volume;
1709
+ }
1710
+ /** Distance gain for the src/element path (1 when not spatial / no pose). */
1711
+ spatialElementGain() {
1712
+ if (!this.spatial || !this._spatialPose) return 1;
1713
+ const { position, listener } = this._spatialPose;
1714
+ const dx = position[0] - listener.position[0];
1715
+ const dy = position[1] - listener.position[1];
1716
+ const dz = position[2] - listener.position[2];
1717
+ return spatialGain(Math.hypot(dx, dy, dz), {
1718
+ refDistance: this.refDistance,
1719
+ maxDistance: this.maxDistance,
1720
+ rolloff: this.rolloff
1721
+ });
1722
+ }
1723
+ /** The `spatial` option for the SFX path, or undefined when not spatial. */
1724
+ spatialPlay() {
1725
+ if (!this.spatial || !this._spatialPose) return void 0;
1726
+ return {
1727
+ position: this._spatialPose.position,
1728
+ listener: this._spatialPose.listener,
1729
+ refDistance: this.refDistance,
1730
+ maxDistance: this.maxDistance,
1731
+ rolloff: this.rolloff
1732
+ };
1733
+ }
1734
+ play() {
1735
+ if (this.preset !== "custom") {
1736
+ this.playPreset();
1737
+ return;
1738
+ }
1739
+ if (!this.src) return;
1740
+ if (!this.element) {
1741
+ this.element = createElement();
1742
+ if (!this.element) return;
1743
+ this.element.addEventListener("ended", () => {
1744
+ if (this.loop) return;
1745
+ this._playing = false;
1746
+ this.emit("finished");
1747
+ });
1748
+ }
1749
+ const el = this.element;
1750
+ el.src = this.src;
1751
+ el.volume = this.gain() * this.spatialElementGain();
1752
+ el.loop = this.loop;
1753
+ this._playing = true;
1754
+ this.pendingGesture = false;
1755
+ el.play().catch(() => {
1756
+ this._playing = false;
1757
+ this.pendingGesture = true;
1758
+ });
1759
+ }
1760
+ /**
1761
+ * Procedural-SFX path: synthesize the preset and fire it through WebAudio
1762
+ * (low-latency, overlap-friendly). Fire-and-forget — no `finished`/`playing`
1763
+ * tracking (these are short one-shots, not the element-driven `src` clip).
1764
+ * Headless (no AudioContext) → silent no-op. WebAudio also needs a gesture;
1765
+ * a suspended context is resumed by the same unlock listener as `src` players.
1766
+ */
1767
+ playPreset() {
1768
+ const params = SFX_PRESETS[this.preset];
1769
+ if (!params) return;
1770
+ const sfx = this.tree?.engine?.sfx;
1771
+ if (!sfx) return;
1772
+ sfx.play(params, this.gain(), {
1773
+ pitch: this.pitch,
1774
+ seed: this.seed,
1775
+ spatial: this.spatialPlay()
1776
+ });
1777
+ }
1778
+ stop() {
1779
+ if (this.element) {
1780
+ this.element.pause();
1781
+ this.element.currentTime = 0;
1782
+ }
1783
+ this._playing = false;
1784
+ this.pendingGesture = false;
1785
+ }
1786
+ /** Replay a gesture-blocked play (wired to the first user gesture). */
1787
+ retryPending() {
1788
+ if (this.pendingGesture) this.play();
1789
+ }
1790
+ update(_dt) {
1791
+ if (this.autoplay && !this.autoplayed) {
1792
+ this.autoplayed = true;
1793
+ this.play();
1794
+ }
1795
+ if (this.element && this._playing) {
1796
+ this.element.volume = this.gain() * this.spatialElementGain();
1797
+ this.element.loop = this.loop;
1798
+ }
1799
+ }
1800
+ };
1154
1801
  //#endregion
1155
- //#region src/core/order-groups.ts
1156
- const ORDER_GROUP_BASE = {
1157
- background: -2e3,
1158
- terrain: -1e3,
1159
- default: 0,
1160
- characters: 1e3,
1161
- effects: 2e3,
1162
- overlay: 3e3
1802
+ //#region src/core/nodes/hud.ts
1803
+ const ANCHOR_CSS = {
1804
+ topLeft: "left:16px;top:16px;align-items:flex-start;",
1805
+ top: "left:50%;top:16px;transform:translateX(-50%);align-items:center;",
1806
+ topRight: "right:16px;top:16px;align-items:flex-end;",
1807
+ left: "left:16px;top:50%;transform:translateY(-50%);align-items:flex-start;",
1808
+ center: "left:50%;top:50%;transform:translate(-50%,-50%);align-items:center;",
1809
+ right: "right:16px;top:50%;transform:translateY(-50%);align-items:flex-end;",
1810
+ bottomLeft: "left:16px;bottom:16px;align-items:flex-start;",
1811
+ bottom: "left:50%;bottom:16px;transform:translateX(-50%);align-items:center;",
1812
+ bottomRight: "right:16px;bottom:16px;align-items:flex-end;"
1163
1813
  };
1164
- /** base + fine offset (unknown group names fall back to `default`). */
1165
- function effectiveOrder(group, renderOrder) {
1166
- return (ORDER_GROUP_BASE[group] ?? 0) + renderOrder;
1814
+ const FONT = "600 14px system-ui, -apple-system, 'Segoe UI', sans-serif";
1815
+ function hasDom() {
1816
+ return typeof document !== "undefined";
1167
1817
  }
1168
- //#endregion
1169
- //#region src/core/particle-presets.ts
1170
- const PARTICLE_PRESETS = {
1171
- fire: {
1172
- rate: 60,
1173
- lifetime: [.4, .9],
1174
- speed: [30, 70],
1175
- directionDeg: -90,
1176
- spreadDeg: 35,
1177
- gravity: [0, -30],
1178
- sizeStart: 14,
1179
- sizeEnd: 3,
1180
- colorStart: "#ffcf5a",
1181
- colorEnd: "#e8401f",
1182
- alphaStart: .9,
1183
- alphaEnd: 0,
1184
- blend: "add"
1185
- },
1186
- smoke: {
1187
- rate: 20,
1188
- lifetime: [1.5, 2.5],
1189
- speed: [10, 25],
1190
- directionDeg: -90,
1191
- spreadDeg: 40,
1192
- gravity: [0, -12],
1193
- sizeStart: 10,
1194
- sizeEnd: 26,
1195
- colorStart: "#8a8a8a",
1196
- colorEnd: "#3a3a3a",
1197
- alphaStart: .35,
1198
- alphaEnd: 0,
1199
- blend: "normal"
1200
- },
1201
- sparks: {
1202
- rate: 80,
1203
- lifetime: [.3, .7],
1204
- speed: [120, 260],
1205
- spreadDeg: 360,
1206
- gravity: [0, 240],
1207
- drag: 1,
1208
- sizeStart: 4,
1209
- sizeEnd: 1,
1210
- colorStart: "#fff3b0",
1211
- colorEnd: "#ff9d2e",
1212
- blend: "add"
1213
- },
1214
- fireworks: {
1215
- rate: 0,
1216
- burst: 120,
1217
- lifetime: [.8, 1.6],
1218
- speed: [80, 240],
1219
- spreadDeg: 360,
1220
- gravity: [0, 90],
1221
- drag: .6,
1222
- sizeStart: 5,
1223
- sizeEnd: 1,
1224
- colorStart: "#9ad8ff",
1225
- colorEnd: "#ff5ad8",
1226
- blend: "add"
1227
- },
1228
- explosion: {
1229
- rate: 0,
1230
- burst: 60,
1231
- lifetime: [.25, .6],
1232
- speed: [150, 420],
1233
- spreadDeg: 360,
1234
- drag: 3,
1235
- sizeStart: 16,
1236
- sizeEnd: 2,
1237
- colorStart: "#ffe08a",
1238
- colorEnd: "#ff3b1f",
1239
- blend: "add"
1240
- },
1241
- flash: {
1242
- rate: 0,
1243
- burst: 8,
1244
- lifetime: [.08, .16],
1245
- speed: [0, 30],
1246
- spreadDeg: 360,
1247
- sizeStart: 64,
1248
- sizeEnd: 96,
1249
- colorStart: "#ffffff",
1250
- colorEnd: "#ffffff",
1251
- alphaStart: 1,
1252
- alphaEnd: 0,
1253
- blend: "add"
1254
- },
1255
- lightning: {
1256
- rate: 90,
1257
- lifetime: [.05, .12],
1258
- speed: [400, 900],
1259
- directionDeg: -90,
1260
- spreadDeg: 14,
1261
- sizeStart: 6,
1262
- sizeEnd: 2,
1263
- colorStart: "#eaf2ff",
1264
- colorEnd: "#7fb4ff",
1265
- blend: "add"
1266
- },
1267
- rain: {
1268
- rate: 160,
1269
- lifetime: [.8, 1.2],
1270
- speed: [300, 420],
1271
- directionDeg: 90,
1272
- spreadDeg: 6,
1273
- sizeStart: 3,
1274
- sizeEnd: 3,
1275
- colorStart: "#9ec8ff",
1276
- colorEnd: "#9ec8ff",
1277
- alphaStart: .5,
1278
- alphaEnd: .2,
1279
- blend: "normal"
1280
- },
1281
- snow: {
1282
- rate: 40,
1283
- lifetime: [3, 5],
1284
- speed: [20, 50],
1285
- directionDeg: 90,
1286
- spreadDeg: 30,
1287
- drag: .2,
1288
- sizeStart: 5,
1289
- sizeEnd: 4,
1290
- colorStart: "#ffffff",
1291
- colorEnd: "#ffffff",
1292
- alphaStart: .9,
1293
- alphaEnd: .6,
1294
- blend: "normal"
1295
- },
1296
- magic: {
1297
- rate: 50,
1298
- lifetime: [.6, 1.4],
1299
- speed: [20, 80],
1300
- spreadDeg: 360,
1301
- gravity: [0, -20],
1302
- sizeStart: 7,
1303
- sizeEnd: 1,
1304
- colorStart: "#9dffe8",
1305
- colorEnd: "#7a5cff",
1306
- blend: "add"
1818
+ /**
1819
+ * The overlay container. One per scene is plenty; widgets mount into its
1820
+ * anchor slots. `zIndex` lifts it above game canvases; the layer never eats
1821
+ * pointer events (widgets that need clicks opt in individually).
1822
+ */
1823
+ var HudLayer = class extends Node {
1824
+ static typeName = "HudLayer";
1825
+ static props = {
1826
+ zIndex: { default: 100 },
1827
+ visible: { default: true }
1828
+ };
1829
+ zIndex = 100;
1830
+ visible = true;
1831
+ /** @internal root overlay element (null headless). */
1832
+ _element = null;
1833
+ slots = /* @__PURE__ */ new Map();
1834
+ onEnterTree() {
1835
+ if (!hasDom()) return;
1836
+ const el = document.createElement("div");
1837
+ el.dataset.incantoHud = this.name;
1838
+ el.style.cssText = `position:fixed;inset:0;pointer-events:none;z-index:${this.zIndex};font:${FONT};`;
1839
+ document.body.appendChild(el);
1840
+ this._element = el;
1841
+ }
1842
+ onExitTree() {
1843
+ this._element?.remove();
1844
+ this._element = null;
1845
+ this.slots.clear();
1846
+ }
1847
+ update() {
1848
+ if (this._element) this._element.style.display = this.visible ? "" : "none";
1849
+ }
1850
+ /** @internal Widgets mount into per-anchor flex columns. */
1851
+ _slot(anchor) {
1852
+ if (!this._element) return null;
1853
+ let slot = this.slots.get(anchor);
1854
+ if (!slot) {
1855
+ slot = document.createElement("div");
1856
+ slot.style.cssText = `position:absolute;display:flex;flex-direction:column;gap:8px;${ANCHOR_CSS[anchor] ?? ANCHOR_CSS.topLeft}`;
1857
+ this._element.appendChild(slot);
1858
+ this.slots.set(anchor, slot);
1859
+ }
1860
+ return slot;
1861
+ }
1862
+ };
1863
+ /** Shared plumbing: mount into the parent HudLayer's anchor slot. */
1864
+ var HudWidgetBase = class extends Node {
1865
+ static props = {
1866
+ anchor: {
1867
+ default: "topLeft",
1868
+ options: [
1869
+ "topLeft",
1870
+ "top",
1871
+ "topRight",
1872
+ "left",
1873
+ "center",
1874
+ "right",
1875
+ "bottomLeft",
1876
+ "bottom",
1877
+ "bottomRight"
1878
+ ]
1879
+ },
1880
+ visible: { default: true }
1881
+ };
1882
+ anchor = "topLeft";
1883
+ visible = true;
1884
+ /** @internal */
1885
+ _element = null;
1886
+ layer() {
1887
+ for (let p = this.parent; p; p = p.parent) if (p instanceof HudLayer) return p;
1888
+ return null;
1889
+ }
1890
+ onReady() {
1891
+ if (!hasDom()) return;
1892
+ const slot = this.layer()?._slot(this.anchor);
1893
+ if (!slot) return;
1894
+ this._element = this._build();
1895
+ slot.appendChild(this._element);
1896
+ }
1897
+ onExitTree() {
1898
+ this._element?.remove();
1899
+ this._element = null;
1900
+ }
1901
+ update(_dt) {
1902
+ if (!this._element) return;
1903
+ this._element.style.display = this.visible ? "" : "none";
1904
+ this._sync();
1905
+ }
1906
+ _sync() {}
1907
+ };
1908
+ /** A text line (score, timer, hints). Set `.text` from behaviors. */
1909
+ var UiText = class extends HudWidgetBase {
1910
+ static typeName = "UiText";
1911
+ static props = {
1912
+ ...HudWidgetBase.props,
1913
+ text: { default: "" },
1914
+ size: { default: 16 },
1915
+ color: { default: "#ffffff" },
1916
+ shadow: { default: true }
1917
+ };
1918
+ text = "";
1919
+ size = 16;
1920
+ color = "#ffffff";
1921
+ shadow = true;
1922
+ last = "\0";
1923
+ _build() {
1924
+ const el = document.createElement("div");
1925
+ el.style.cssText = `font-size:${this.size}px;color:${this.color};${this.shadow ? "text-shadow:0 1px 3px rgba(0,0,0,.7);" : ""}white-space:pre;`;
1926
+ return el;
1927
+ }
1928
+ _sync() {
1929
+ if (this.text !== this.last && this._element) {
1930
+ this.last = this.text;
1931
+ this._element.textContent = this.text;
1932
+ }
1933
+ }
1934
+ };
1935
+ /** A labeled progress bar (health, stamina, reload, boss HP). */
1936
+ var UiBar = class extends HudWidgetBase {
1937
+ static typeName = "UiBar";
1938
+ static props = {
1939
+ ...HudWidgetBase.props,
1940
+ value: { default: 100 },
1941
+ max: { default: 100 },
1942
+ width: { default: 180 },
1943
+ height: { default: 14 },
1944
+ color: { default: "#4ade80" },
1945
+ lowColor: { default: "#ef4444" },
1946
+ lowThreshold: { default: .3 },
1947
+ background: { default: "rgba(0,0,0,0.5)" },
1948
+ label: { default: "" }
1949
+ };
1950
+ value = 100;
1951
+ max = 100;
1952
+ width = 180;
1953
+ height = 14;
1954
+ color = "#4ade80";
1955
+ lowColor = "#ef4444";
1956
+ lowThreshold = .3;
1957
+ background = "rgba(0,0,0,0.5)";
1958
+ label = "";
1959
+ fill = null;
1960
+ lastRatio = -1;
1961
+ /** Current fill ratio 0..1 (what the bar shows). */
1962
+ get ratio() {
1963
+ return this.max > 0 ? Math.min(1, Math.max(0, this.value / this.max)) : 0;
1964
+ }
1965
+ _build() {
1966
+ const wrap = document.createElement("div");
1967
+ wrap.style.cssText = "display:flex;align-items:center;gap:6px;";
1968
+ if (this.label) {
1969
+ const tag = document.createElement("span");
1970
+ tag.textContent = this.label;
1971
+ tag.style.cssText = "color:#fff;font-size:12px;text-shadow:0 1px 3px rgba(0,0,0,.7);";
1972
+ wrap.appendChild(tag);
1973
+ }
1974
+ const track = document.createElement("div");
1975
+ track.style.cssText = `width:${this.width}px;height:${this.height}px;background:${this.background};border-radius:${this.height / 2}px;overflow:hidden;`;
1976
+ this.fill = document.createElement("div");
1977
+ this.fill.style.cssText = `height:100%;width:100%;background:${this.color};border-radius:inherit;transition:width .15s;`;
1978
+ track.appendChild(this.fill);
1979
+ wrap.appendChild(track);
1980
+ return wrap;
1981
+ }
1982
+ _sync() {
1983
+ const r = this.ratio;
1984
+ if (r === this.lastRatio || !this.fill) return;
1985
+ this.lastRatio = r;
1986
+ this.fill.style.width = `${r * 100}%`;
1987
+ this.fill.style.background = r <= this.lowThreshold ? this.lowColor : this.color;
1307
1988
  }
1308
1989
  };
1309
- const PARTICLE_PRESET_NAMES = Object.keys(PARTICLE_PRESETS);
1310
1990
  /**
1311
- * Apply the delta rule across three layers: schema default < preset < the
1312
- * scene's explicit props. A prop still equal to its SCHEMA default is
1313
- * considered "unset" and takes the preset value.
1991
+ * Center-screen announcements ("WAVE 2", "YOU DIED", "LEVEL UP") with fade
1992
+ * in/out and a queue call `.show(text, { color, seconds })`; `sticky:true`
1993
+ * (seconds: 0) keeps it until the next show(). Emits `bannerShown(text)`.
1314
1994
  */
1315
- function applyParticlePreset(target, presetName, schema) {
1316
- if (presetName === "custom") return;
1317
- const preset = PARTICLE_PRESETS[presetName];
1318
- if (!preset) return;
1319
- for (const [key, presetValue] of Object.entries(preset)) {
1320
- const def = schema[key]?.default;
1321
- const current = target[key];
1322
- if (def !== void 0 && jsonEquals(current, def)) target[key] = Array.isArray(presetValue) ? [...presetValue] : presetValue;
1995
+ var UiBanner = class extends HudWidgetBase {
1996
+ static typeName = "UiBanner";
1997
+ static signals = ["bannerShown"];
1998
+ static props = {
1999
+ ...HudWidgetBase.props,
2000
+ anchor: {
2001
+ default: "center",
2002
+ options: [
2003
+ "topLeft",
2004
+ "top",
2005
+ "topRight",
2006
+ "left",
2007
+ "center",
2008
+ "right",
2009
+ "bottomLeft",
2010
+ "bottom",
2011
+ "bottomRight"
2012
+ ]
2013
+ },
2014
+ size: { default: 42 },
2015
+ seconds: { default: 2 }
2016
+ };
2017
+ anchor = "center";
2018
+ size = 42;
2019
+ /** Default display time (per-show override via options). */
2020
+ seconds = 2;
2021
+ queue = [];
2022
+ current = null;
2023
+ remaining = 0;
2024
+ /** What the banner is showing right now ('' when idle) — test-friendly. */
2025
+ get showing() {
2026
+ return this.current?.text ?? "";
2027
+ }
2028
+ show(text, opts) {
2029
+ this.queue.push({
2030
+ text,
2031
+ color: opts?.color ?? "#ffffff",
2032
+ seconds: opts?.seconds ?? this.seconds
2033
+ });
1323
2034
  }
1324
- }
1325
- //#endregion
1326
- //#region src/core/particle-sim.ts
1327
- const FIELDS = 9;
1328
- var ParticleSim = class {
1329
- config;
1330
- rng;
1331
- data;
1332
- alive = 0;
1333
- spawnAccumulator = 0;
1334
- everSpawned = false;
1335
- constructor(config, rng) {
1336
- this.config = config;
1337
- this.rng = rng;
1338
- this.data = new Float64Array(Math.max(1, config.maxParticles) * FIELDS);
1339
- }
1340
- /** Change the emission rate LIVE (particles/sec; 0 pauses emission) — lets an
1341
- * emitter toggle on/off at runtime (drift smoke, throttle flames). */
1342
- setRate(rate) {
1343
- this.config.rate = rate;
1344
- if (rate <= 0) this.spawnAccumulator = 0;
1345
- }
1346
- get count() {
1347
- return this.alive;
1348
- }
1349
- /** True when nothing is alive and at least one particle has ever spawned. */
1350
- get done() {
1351
- return this.everSpawned && this.alive === 0;
1352
- }
1353
- /** Spawn n particles immediately (fireworks, explosions, flashes). */
1354
- burst(n) {
1355
- for (let i = 0; i < n; i++) this.spawn();
2035
+ /** Drop everything (scene transitions). */
2036
+ clear() {
2037
+ this.queue.length = 0;
2038
+ this.current = null;
2039
+ this.remaining = 0;
2040
+ if (this._element) this._element.style.opacity = "0";
2041
+ }
2042
+ _build() {
2043
+ const el = document.createElement("div");
2044
+ el.style.cssText = `font-size:${this.size}px;font-weight:800;color:#fff;text-shadow:0 2px 12px rgba(0,0,0,.8);opacity:0;transition:opacity .25s;text-align:center;`;
2045
+ return el;
1356
2046
  }
1357
2047
  update(dt) {
1358
- if (this.config.rate > 0) {
1359
- this.spawnAccumulator += this.config.rate * dt;
1360
- while (this.spawnAccumulator >= 1) {
1361
- this.spawnAccumulator -= 1;
1362
- this.spawn();
1363
- }
2048
+ super.update(dt);
2049
+ if (this.current) {
2050
+ if (this.current.seconds > 0) {
2051
+ this.remaining -= dt;
2052
+ if (this.remaining <= 0) {
2053
+ this.current = null;
2054
+ if (this._element) this._element.style.opacity = "0";
2055
+ }
2056
+ } else if (this.queue.length > 0) this.current = null;
1364
2057
  }
1365
- const d = this.data;
1366
- const [gx, gy, gz] = this.config.gravity;
1367
- const damp = this.config.drag > 0 ? Math.exp(-this.config.drag * dt) : 1;
1368
- let i = 0;
1369
- while (i < this.alive) {
1370
- const base = i * FIELDS;
1371
- const age = d[base + 6] + dt;
1372
- if (age >= d[base + 7]) {
1373
- this.kill(i);
1374
- continue;
2058
+ if (!this.current && this.queue.length > 0) {
2059
+ this.current = this.queue.shift();
2060
+ this.remaining = this.current.seconds;
2061
+ if (this._element) {
2062
+ this._element.textContent = this.current.text;
2063
+ this._element.style.color = this.current.color;
2064
+ this._element.style.opacity = "1";
1375
2065
  }
1376
- d[base + 6] = age;
1377
- d[base + 3] = (d[base + 3] + gx * dt) * damp;
1378
- d[base + 4] = (d[base + 4] + gy * dt) * damp;
1379
- d[base + 5] = (d[base + 5] + gz * dt) * damp;
1380
- d[base + 0] = d[base + 0] + d[base + 3] * dt;
1381
- d[base + 1] = d[base + 1] + d[base + 4] * dt;
1382
- d[base + 2] = d[base + 2] + d[base + 5] * dt;
1383
- i += 1;
2066
+ this.emit("bannerShown", this.current.text);
1384
2067
  }
1385
2068
  }
1386
- forEach(fn) {
1387
- const d = this.data;
1388
- for (let i = 0; i < this.alive; i++) {
1389
- const base = i * FIELDS;
1390
- const age = d[base + 6];
1391
- const life = d[base + 7];
1392
- fn({
1393
- x: d[base + 0],
1394
- y: d[base + 1],
1395
- z: d[base + 2],
1396
- vx: d[base + 3],
1397
- vy: d[base + 4],
1398
- vz: d[base + 5],
1399
- age,
1400
- life,
1401
- t: life > 0 ? age / life : 1,
1402
- seed: d[base + 8]
1403
- });
2069
+ };
2070
+ //#endregion
2071
+ //#region src/core/nodes/dialogue.ts
2072
+ /**
2073
+ * Interactive HUD: clickable buttons and a typewriter dialogue box — the
2074
+ * storytelling layer RPG/adventure games kept hand-rolling in DOM.
2075
+ * Same family as the other HUD widgets: children of a HudLayer, DOM-backed,
2076
+ * silent no-ops headless.
2077
+ */
2078
+ /** A clickable button (menus, "START", dialog choices). Emits `pressed`. */
2079
+ var UiButton = class extends HudWidgetBase {
2080
+ static typeName = "UiButton";
2081
+ static signals = ["pressed"];
2082
+ static props = {
2083
+ ...HudWidgetBase.props,
2084
+ text: { default: "OK" },
2085
+ size: { default: 16 },
2086
+ color: { default: "#ffffff" },
2087
+ background: { default: "rgba(255,255,255,0.14)" },
2088
+ disabled: { default: false }
2089
+ };
2090
+ text = "OK";
2091
+ size = 16;
2092
+ color = "#ffffff";
2093
+ background = "rgba(255,255,255,0.14)";
2094
+ disabled = false;
2095
+ lastText = "";
2096
+ /** Programmatic press — same path as a click (tests, gamepad menus). */
2097
+ press() {
2098
+ if (this.disabled) return;
2099
+ this.emit("pressed");
2100
+ }
2101
+ _build() {
2102
+ const el = document.createElement("button");
2103
+ el.type = "button";
2104
+ el.style.cssText = `pointer-events:auto;cursor:pointer;font:600 ${this.size}px system-ui,sans-serif;color:${this.color};background:${this.background};border:1px solid rgba(255,255,255,0.25);border-radius:8px;padding:8px 18px;transition:transform .08s, background .15s;`;
2105
+ el.addEventListener("mouseenter", () => {
2106
+ if (!this.disabled) el.style.background = "rgba(255,255,255,0.24)";
2107
+ });
2108
+ el.addEventListener("mouseleave", () => {
2109
+ el.style.background = this.background;
2110
+ });
2111
+ el.addEventListener("pointerdown", () => {
2112
+ if (!this.disabled) el.style.transform = "scale(0.95)";
2113
+ });
2114
+ el.addEventListener("pointerup", () => {
2115
+ el.style.transform = "";
2116
+ });
2117
+ el.addEventListener("click", () => this.press());
2118
+ return el;
2119
+ }
2120
+ _sync() {
2121
+ if (!this._element) return;
2122
+ if (this.text !== this.lastText) {
2123
+ this.lastText = this.text;
2124
+ this._element.textContent = this.text;
2125
+ }
2126
+ this._element.disabled = this.disabled;
2127
+ this._element.style.opacity = this.disabled ? "0.45" : "1";
2128
+ }
2129
+ };
2130
+ /**
2131
+ * A typewriter dialogue box with a queue and choices:
2132
+ *
2133
+ * const talk = node.getNode('%Dialogue') as UiDialogue;
2134
+ * talk.say('Elder', 'Welcome to Lumina Village...');
2135
+ * talk.say('Elder', 'Will you help us?', ['Yes', 'No']);
2136
+ * talk.on('choiceMade', (index) => { ... });
2137
+ *
2138
+ * Click / `advance()` reveals the full line, then advances. Emits
2139
+ * `lineShown(text)`, `choiceMade(index)`, `dialogueFinished()`.
2140
+ */
2141
+ var UiDialogue = class extends HudWidgetBase {
2142
+ static typeName = "UiDialogue";
2143
+ static signals = [
2144
+ "lineShown",
2145
+ "choiceMade",
2146
+ "dialogueFinished"
2147
+ ];
2148
+ static props = {
2149
+ ...HudWidgetBase.props,
2150
+ anchor: {
2151
+ default: "bottom",
2152
+ options: [
2153
+ "topLeft",
2154
+ "top",
2155
+ "topRight",
2156
+ "left",
2157
+ "center",
2158
+ "right",
2159
+ "bottomLeft",
2160
+ "bottom",
2161
+ "bottomRight"
2162
+ ]
2163
+ },
2164
+ charsPerSecond: { default: 40 },
2165
+ width: { default: 520 }
2166
+ };
2167
+ anchor = "bottom";
2168
+ /** Typewriter speed; 0 = instant lines. */
2169
+ charsPerSecond = 40;
2170
+ width = 520;
2171
+ queue = [];
2172
+ current = null;
2173
+ revealed = 0;
2174
+ speakerEl = null;
2175
+ textEl = null;
2176
+ choicesEl = null;
2177
+ hintEl = null;
2178
+ /** True while a conversation is on screen (pause player input on this). */
2179
+ get active() {
2180
+ return this.current !== null || this.queue.length > 0;
2181
+ }
2182
+ /** What the box currently shows (grows while typing) — test-friendly. */
2183
+ get showing() {
2184
+ return this.current ? this.current.text.slice(0, Math.floor(this.revealed)) : "";
2185
+ }
2186
+ /** Queue a line; optional `choices` renders buttons after the line types out. */
2187
+ say(speaker, text, choices) {
2188
+ this.queue.push({
2189
+ speaker,
2190
+ text,
2191
+ choices: choices ?? null
2192
+ });
2193
+ }
2194
+ /**
2195
+ * Player pressed "next": mid-typing reveals the whole line; a fully-typed
2196
+ * line without choices advances (choice lines wait for `choose`).
2197
+ */
2198
+ advance() {
2199
+ if (!this.current) return;
2200
+ if (this.revealed < this.current.text.length) {
2201
+ this.revealed = this.current.text.length;
2202
+ return;
2203
+ }
2204
+ if (this.current.choices) return;
2205
+ this.next();
2206
+ }
2207
+ /** Pick a choice on the current line (buttons call this). */
2208
+ choose(index) {
2209
+ if (!this.current?.choices) return;
2210
+ if (index < 0 || index >= this.current.choices.length) return;
2211
+ this.emit("choiceMade", index);
2212
+ this.next();
2213
+ }
2214
+ /** Drop the conversation (cutscene interrupts). */
2215
+ clear() {
2216
+ this.queue.length = 0;
2217
+ this.current = null;
2218
+ this.revealed = 0;
2219
+ if (this._element) this._element.style.display = "none";
2220
+ }
2221
+ next() {
2222
+ this.current = null;
2223
+ this.revealed = 0;
2224
+ if (this.queue.length === 0) {
2225
+ if (this._element) this._element.style.display = "none";
2226
+ this.emit("dialogueFinished");
1404
2227
  }
1405
2228
  }
1406
- spawn() {
1407
- if (this.alive >= this.config.maxParticles) return;
1408
- const base = this.alive * FIELDS;
1409
- const d = this.data;
1410
- const c = this.config;
1411
- const angle = (c.directionDeg + (this.rng.next() - .5) * c.spreadDeg) * Math.PI / 180;
1412
- const speed = c.speed[0] + this.rng.next() * (c.speed[1] - c.speed[0]);
1413
- const zTilt = c.spreadZ ? (this.rng.next() - .5) * (c.spreadDeg * Math.PI / 180) : 0;
1414
- const planar = Math.cos(zTilt);
1415
- d[base + 0] = 0;
1416
- d[base + 1] = 0;
1417
- d[base + 2] = 0;
1418
- d[base + 3] = Math.cos(angle) * speed * planar;
1419
- d[base + 4] = Math.sin(angle) * speed * planar;
1420
- d[base + 5] = Math.sin(zTilt) * speed;
1421
- d[base + 6] = 0;
1422
- d[base + 7] = c.lifetime[0] + this.rng.next() * (c.lifetime[1] - c.lifetime[0]);
1423
- d[base + 8] = this.rng.next();
1424
- this.alive += 1;
1425
- this.everSpawned = true;
1426
- }
1427
- kill(index) {
1428
- const last = this.alive - 1;
1429
- if (index !== last) this.data.copyWithin(index * FIELDS, last * FIELDS, last * FIELDS + FIELDS);
1430
- this.alive = last;
2229
+ _build() {
2230
+ const box = document.createElement("div");
2231
+ box.style.cssText = `pointer-events:auto;width:${this.width}px;max-width:86vw;background:rgba(12,14,22,0.92);border:1px solid rgba(255,255,255,0.2);border-radius:12px;padding:14px 18px;display:none;font:15px/1.5 system-ui,sans-serif;color:#fff;cursor:pointer;user-select:none;`;
2232
+ this.speakerEl = document.createElement("div");
2233
+ this.speakerEl.style.cssText = "font-weight:800;color:#ffd166;margin-bottom:4px;font-size:13px;";
2234
+ this.textEl = document.createElement("div");
2235
+ this.textEl.style.cssText = "min-height:3em;white-space:pre-wrap;";
2236
+ this.choicesEl = document.createElement("div");
2237
+ this.choicesEl.style.cssText = "display:flex;gap:8px;margin-top:10px;flex-wrap:wrap;";
2238
+ this.hintEl = document.createElement("div");
2239
+ this.hintEl.textContent = "▼";
2240
+ this.hintEl.style.cssText = "text-align:right;opacity:0.5;font-size:11px;animation:none;";
2241
+ box.append(this.speakerEl, this.textEl, this.choicesEl, this.hintEl);
2242
+ box.addEventListener("click", () => this.advance());
2243
+ return box;
2244
+ }
2245
+ update(dt) {
2246
+ super.update(dt);
2247
+ if (!this.current && this.queue.length > 0) {
2248
+ this.current = this.queue.shift();
2249
+ this.revealed = this.charsPerSecond <= 0 ? this.current.text.length : 0;
2250
+ this.emit("lineShown", this.current.text);
2251
+ if (this._element) {
2252
+ this._element.style.display = "block";
2253
+ if (this.speakerEl) this.speakerEl.textContent = this.current.speaker;
2254
+ if (this.choicesEl) this.choicesEl.replaceChildren();
2255
+ }
2256
+ }
2257
+ if (!this.current) return;
2258
+ const wasTyping = this.revealed < this.current.text.length;
2259
+ if (wasTyping) this.revealed = Math.min(this.current.text.length, this.revealed + this.charsPerSecond * dt);
2260
+ const doneTyping = this.revealed >= this.current.text.length;
2261
+ if (this.textEl) this.textEl.textContent = this.showing;
2262
+ if (this.hintEl) this.hintEl.style.visibility = doneTyping && !this.current.choices ? "visible" : "hidden";
2263
+ if (doneTyping && wasTyping && this.current.choices && this.choicesEl) this.current.choices.forEach((label, i) => {
2264
+ const b = document.createElement("button");
2265
+ b.type = "button";
2266
+ b.textContent = label;
2267
+ b.style.cssText = "pointer-events:auto;cursor:pointer;font:600 13px system-ui,sans-serif;color:#fff;background:rgba(255,255,255,0.12);border:1px solid rgba(255,255,255,0.3);border-radius:8px;padding:6px 14px;";
2268
+ b.addEventListener("click", (e) => {
2269
+ e.stopPropagation();
2270
+ this.choose(i);
2271
+ });
2272
+ this.choicesEl?.appendChild(b);
2273
+ });
1431
2274
  }
1432
2275
  };
1433
2276
  //#endregion
1434
- export { ORDER_GROUP_BASE as a, LogManager as c, isAudioContextAvailable as d, MusicManager as f, AudioBuses as g, fadeGain as h, applyParticlePreset as i, InputMap as l, crossfadeGains as m, PARTICLE_PRESETS as n, effectiveOrder as o, WebAudioMusicBackend as p, PARTICLE_PRESET_NAMES as r, Engine as s, ParticleSim as t, SfxEngine as u };
2277
+ //#region src/core/nodes/timer.ts
2278
+ /**
2279
+ * The canonical serializable game clock — never `setTimeout` in game logic.
2280
+ * Emits `timeout` every `waitTime` seconds (once with `oneShot`).
2281
+ */
2282
+ var Timer = class extends Node {
2283
+ static typeName = "Timer";
2284
+ static signals = ["timeout"];
2285
+ static props = {
2286
+ waitTime: { default: 1 },
2287
+ oneShot: { default: false },
2288
+ autostart: { default: false }
2289
+ };
2290
+ /** Seconds between timeouts. */
2291
+ waitTime = 1;
2292
+ oneShot = false;
2293
+ autostart = false;
2294
+ running = false;
2295
+ remaining = 0;
2296
+ start(time) {
2297
+ if (time !== void 0) this.waitTime = time;
2298
+ this.remaining = this.waitTime;
2299
+ this.running = true;
2300
+ }
2301
+ stop() {
2302
+ this.running = false;
2303
+ }
2304
+ onReady() {
2305
+ if (this.autostart) this.start();
2306
+ }
2307
+ update(dt) {
2308
+ if (!this.running) return;
2309
+ if (this.waitTime <= 0) {
2310
+ this.stop();
2311
+ return;
2312
+ }
2313
+ this.remaining -= dt;
2314
+ while (this.remaining <= 0 && this.running) {
2315
+ this.emit("timeout");
2316
+ if (this.oneShot) this.stop();
2317
+ else if (this.waitTime <= 0) this.stop();
2318
+ else this.remaining += this.waitTime;
2319
+ }
2320
+ }
2321
+ };
2322
+ //#endregion
2323
+ //#region src/core/register.ts
2324
+ /**
2325
+ * Register the core node types. Call once in your game entry before loading
2326
+ * scenes. Registration is explicit — never an import-time side effect — so
2327
+ * bundler tree-shaking can never silently drop node types.
2328
+ */
2329
+ function registerCoreNodes() {
2330
+ registerNode(Node);
2331
+ registerNode(Timer);
2332
+ registerNode(AudioPlayer);
2333
+ registerNode(HudLayer);
2334
+ registerNode(UiText);
2335
+ registerNode(UiBar);
2336
+ registerNode(UiBanner);
2337
+ registerNode(UiButton);
2338
+ registerNode(UiDialogue);
2339
+ }
2340
+ //#endregion
2341
+ export { crossfadeGains as C, WebAudioMusicBackend as S, AudioBuses as T, spatialPan as _, HudLayer as a, synthSfx as b, UiText as c, LogManager as d, InputMap as f, spatialGain as g, ROLLOFF_MODELS as h, UiDialogue as i, AudioPlayer as l, isAudioContextAvailable as m, Timer as n, UiBanner as o, SfxEngine as p, UiButton as r, UiBar as s, registerCoreNodes as t, Engine as u, SFX_PRESETS as v, fadeGain as w, MusicManager as x, SFX_PRESET_NAMES as y };