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.
- package/bin/incanto-check.mjs +6 -4
- package/dist/2d.d.ts +93 -3
- package/dist/2d.js +4 -4
- package/dist/3d.d.ts +45 -4
- package/dist/3d.js +4 -4
- package/dist/{audio-player-VSjk_vG8.d.ts → audio-player-Dyjg5k92.d.ts} +1 -1
- package/dist/audit-C6rMyict.js +58 -0
- package/dist/{behavior-e3kAmPGC.d.ts → behavior-Bod1AyJS.d.ts} +211 -174
- package/dist/{create-game-CEFoFN8d.js → create-game-66c4iYJE.js} +76 -6
- package/dist/{create-game-X5iRu6pf.js → create-game-BNDSbhy-.js} +88 -6
- package/dist/debug.d.ts +1 -1
- package/dist/debug.js +3 -0
- package/dist/{errors-BMFaY68Q.d.ts → errors-1dXlIwoR.d.ts} +7 -1
- package/dist/{gameplay-CDFgSG6z.js → gameplay-DPMgZk9W.js} +108 -1
- package/dist/gameplay.d.ts +44 -3
- package/dist/gameplay.js +2 -2
- package/dist/index.d.ts +151 -9
- package/dist/index.js +189 -4
- package/dist/{loader-y_X4zYO0.d.ts → loader-BcUDfNHn.d.ts} +1 -1
- package/dist/net.d.ts +46 -2
- package/dist/net.js +54 -2
- package/dist/particle-sim-CyUU7HVU.js +281 -0
- package/dist/{physics-2d-DmQ540uR.js → physics-2d-q2N362ph.js} +45 -4
- package/dist/{physics-3d-CDjuhtt_.js → physics-3d-CvHr31d4.js} +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{register-CscQqB7V.js → register-Cbs7QxoV.js} +139 -4
- package/dist/{register-ClKnoILk.js → register-Cqjxrfq0.js} +254 -4
- package/dist/{register-C35HDZpm.js → register-CxmuI9FL.js} +1 -1
- package/dist/{particle-sim-CrTE7c02.js → register-DRPIjrKG.js} +1185 -278
- package/dist/test.d.ts +4 -10
- package/dist/test.js +11 -16
- package/editor/assets/{agent8-1WJU-yXr.js → agent8-B8QnWCeP.js} +1 -1
- package/editor/assets/{index-DzYFgZbl.js → index-CVhnNkb3.js} +81 -81
- package/editor/index.html +1 -1
- package/package.json +1 -1
- package/schemas/scene.schema.json +427 -0
- package/skills/incanto-3d-character.md +14 -0
- package/skills/incanto-audio.md +23 -0
- package/skills/incanto-building-2d-games.md +31 -0
- package/skills/incanto-building-3d-games.md +21 -0
- package/skills/incanto-gameplay-behaviors.md +28 -0
- package/skills/incanto-hud.md +24 -0
- package/skills/incanto-multiplayer.md +27 -0
- package/skills/incanto-node-reference.md +73 -0
- package/skills/incanto-physics-and-input.md +9 -0
- package/skills/incanto-verifying-your-game.md +40 -0
- 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 {
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
95
|
-
in: clamp01$
|
|
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$
|
|
101
|
+
return clamp01$2(t / duration);
|
|
103
102
|
}
|
|
104
|
-
function clamp01$
|
|
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/
|
|
1156
|
-
const
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
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
|
-
|
|
1165
|
-
function
|
|
1166
|
-
return
|
|
1814
|
+
const FONT = "600 14px system-ui, -apple-system, 'Segoe UI', sans-serif";
|
|
1815
|
+
function hasDom() {
|
|
1816
|
+
return typeof document !== "undefined";
|
|
1167
1817
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
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
|
-
*
|
|
1312
|
-
*
|
|
1313
|
-
*
|
|
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
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
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
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
this.
|
|
1362
|
-
this.
|
|
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
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
this.
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
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
|
-
|
|
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 };
|