incanto 0.69.0 → 0.71.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/_behaviors-loader.mjs +22 -0
- package/bin/_read-json.mjs +28 -0
- package/bin/incanto-assets.mjs +5 -4
- package/bin/incanto-check.mjs +58 -31
- package/bin/incanto-editor.mjs +128 -5
- package/bin/incanto-env.mjs +3 -2
- package/bin/incanto-feel.mjs +24 -13
- package/bin/incanto-frame.mjs +8 -1
- package/bin/incanto-multiplay.mjs +11 -9
- package/bin/incanto-new.mjs +105 -10
- package/bin/incanto-play.mjs +158 -16
- package/bin/incanto-playtest.mjs +66 -23
- package/bin/incanto-skills.mjs +14 -2
- package/bin/incanto-verify.mjs +121 -102
- package/bin/incanto.mjs +3 -2
- package/dist/2d.d.ts +296 -34
- package/dist/2d.js +4 -4
- package/dist/3d.d.ts +157 -9
- package/dist/3d.js +8 -8
- package/dist/{agent8-CvsfVskX.js → agent8-Cfmd3ar_.js} +61 -8
- package/dist/{audio-player-C0j2k-yB.d.ts → audio-player-L8yccdMP.d.ts} +2 -2
- package/dist/{behavior-rm8vVzu_.d.ts → behavior-Dcz0fr1S.d.ts} +755 -46
- package/dist/{create-game-CwxEDlCm.js → create-game-CH4kt78v.js} +143 -60
- package/dist/{create-game-CvJ1wSRP.js → create-game-LCHBLfA4.js} +98 -48
- package/dist/debug.d.ts +1 -1
- package/dist/debug.js +2 -3
- package/dist/diagnostics-Cu85N3tL.d.ts +12 -0
- package/dist/{editor-switch-CnIOiyNJ.d.ts → editor-switch-CFU9mCec.d.ts} +22 -13
- package/dist/editor.js +1088 -882
- package/dist/env.d.ts +1 -1
- package/dist/env.js +5 -3
- package/dist/{environment-presets-BO-7XoFV.js → environment-presets-99BzP_L-.js} +298 -45
- package/dist/{frame-report-Dlq13Gyj.js → frame-report-D-_7YF2G.js} +1 -1
- package/dist/{gameplay-D1KixWK8.js → gameplay-CNULJvwh.js} +914 -214
- package/dist/gameplay.d.ts +254 -10
- package/dist/gameplay.js +2 -2
- package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
- package/dist/index.d.ts +179 -13
- package/dist/index.js +10 -12
- package/dist/json-CfTjpvW8.js +67 -0
- package/dist/{loader-DadV2QCT.d.ts → loader-8-IHvbKD.d.ts} +2 -2
- package/dist/net.d.ts +27 -3
- package/dist/net.js +2 -2
- package/dist/{noise-CGUMx44x.js → noise-D3nPpmFg.js} +1 -1
- package/dist/{physics-2d-CF5yyjbj.js → physics-2d-FhrXUc6B.js} +136 -11
- package/dist/{physics-3d-DbYaEcZy.js → physics-3d-CFEGxBXZ.js} +104 -23
- package/dist/{teardown-CMqFVxO9.js → picking-DVo7fI13.js} +104 -13
- package/dist/react.d.ts +2 -2
- package/dist/react.js +2 -2
- package/dist/{register-DbVArsns.js → register-BYQCBySi.js} +429 -28
- package/dist/{register-mmfugpaQ.js → register-DuVdzxbq.js} +594 -831
- package/dist/{replay-BEHLDcQV.js → replay-DEvp3kyV.js} +224 -14
- package/dist/{replay-BQfkX4x7.d.ts → replay-F7IZHdFR.d.ts} +20 -3
- package/dist/{schema-B6ugCV1Q.d.ts → rng-Bb-IutXB.d.ts} +38 -21
- package/dist/{rng-DP-SR7eg.js → rng-CDOMybym.js} +22 -0
- package/dist/{loader-DZE7B07H.js → save-slots-Bvuh2p_r.js} +4619 -2318
- package/dist/{split-screen-Cg76MCV5.d.ts → split-screen-CIYf1zSf.d.ts} +36 -4
- package/dist/{split-screen-CauMwu6y.js → split-screen-eULetcg-.js} +159 -26
- package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
- package/dist/{src-CYEu-_S9.js → src-_jk0qLsS.js} +2 -17
- package/dist/test-CmN54kWv.js +4264 -0
- package/dist/test.d.ts +639 -30
- package/dist/test.js +3 -3
- package/dist/touch-BnCyPA0G.js +519 -0
- package/dist/vite.d.ts +20 -37
- package/dist/vite.js +177 -7
- package/dist/{webgl-unavailable-Z5_g8x9H.js → webgl-unavailable-C8aDbGmR.js} +44 -0
- package/editor/assets/agent8-yLIEYHbd.js +1 -0
- package/editor/assets/{debug-CMULinBW.js → debug-u31w_yhq.js} +2 -2
- package/editor/assets/index-DK9xMGpW.js +11046 -0
- package/editor/index.html +1 -1
- package/package.json +4 -14
- package/schemas/scene.schema.json +34 -3
- package/skills/incanto-3d-character.md +14 -1
- package/skills/incanto-3d-models.md +12 -0
- package/skills/incanto-assets.md +9 -1
- package/skills/incanto-audio.md +8 -2
- package/skills/incanto-behaviors-and-scripts.md +105 -7
- package/skills/incanto-building-2d-games.md +97 -10
- package/skills/incanto-building-3d-games.md +58 -4
- package/skills/incanto-editor.md +46 -7
- package/skills/incanto-environment.md +19 -1
- package/skills/incanto-game-feel.md +70 -0
- package/skills/incanto-gameplay-behaviors.md +172 -20
- package/skills/incanto-hud.md +142 -7
- package/skills/incanto-localization.md +13 -5
- package/skills/incanto-multiplayer.md +83 -3
- package/skills/incanto-node-reference.md +239 -59
- package/skills/incanto-performance.md +52 -0
- package/skills/incanto-physics-and-input.md +123 -24
- package/skills/incanto-playtesting.md +112 -2
- package/skills/incanto-save-slots.md +217 -7
- package/skills/incanto-scene-json-authoring.md +69 -12
- package/skills/incanto-verifying-your-game.md +207 -10
- package/skills/incanto-web-integration.md +28 -0
- package/skills/incanto-your-first-game.md +3 -1
- package/templates-app/beacon-isle-3d/generate-world.ts +77 -9
- package/templates-app/beacon-isle-3d/package.json +2 -2
- package/templates-app/beacon-isle-3d/src/behaviors.ts +22 -0
- package/templates-app/beacon-isle-3d/src/game.scene.json +143 -600
- package/templates-app/beacon-isle-3d/src/main.ts +11 -1
- package/templates-app/beacon-isle-3d/tsconfig.json +1 -1
- package/templates-app/beacon-isle-3d/verify.ts +3 -1
- package/templates-app/molehill-2d/.incanto/playtest/lost-seed1.json +4277 -0
- package/templates-app/molehill-2d/PROJECT/Context.md +58 -0
- package/templates-app/molehill-2d/PROJECT/Requirements.md +39 -0
- package/templates-app/molehill-2d/PROJECT/Status.md +27 -0
- package/templates-app/molehill-2d/PROJECT/Structure.md +48 -0
- package/templates-app/molehill-2d/docs/project-2d-rules.md +44 -0
- package/templates-app/molehill-2d/index.html +73 -0
- package/templates-app/molehill-2d/package.json +23 -0
- package/templates-app/molehill-2d/src/behaviors.ts +198 -0
- package/templates-app/molehill-2d/src/game.scene.json +1205 -0
- package/templates-app/molehill-2d/src/main.ts +41 -0
- package/templates-app/molehill-2d/tsconfig.json +13 -0
- package/templates-app/molehill-2d/verify.ts +247 -0
- package/templates-app/molehill-2d/vite.config.ts +12 -0
- package/templates-app/platformer-2d/package.json +2 -2
- package/templates-app/platformer-2d/src/game.scene.json +178 -986
- package/templates-app/platformer-2d/src/main.ts +11 -1
- package/templates-app/platformer-2d/tsconfig.json +1 -1
- package/templates-app/star-survivor/package.json +2 -2
- package/templates-app/star-survivor/src/game.scene.json +43 -216
- package/templates-app/star-survivor/src/main.ts +11 -1
- package/templates-app/star-survivor/tsconfig.json +1 -1
- package/templates-app/tps-3d/PROJECT/Context.md +1 -1
- package/templates-app/tps-3d/package.json +2 -2
- package/templates-app/tps-3d/src/behaviors.ts +19 -1
- package/templates-app/tps-3d/src/game.scene.json +66 -263
- package/templates-app/tps-3d/src/main.ts +13 -2
- package/templates-app/tps-3d/tsconfig.json +1 -1
- package/templates-app/village-quest-3d/.incanto/playtest/swapped-seed1.json +1735 -0
- package/templates-app/village-quest-3d/package.json +2 -2
- package/templates-app/village-quest-3d/src/behaviors.ts +42 -4
- package/templates-app/village-quest-3d/src/grove.scene.json +68 -306
- package/templates-app/village-quest-3d/src/main.ts +11 -1
- package/templates-app/village-quest-3d/src/village.scene.json +202 -863
- package/templates-app/village-quest-3d/tsconfig.json +1 -1
- package/templates-app/village-quest-3d/verify.ts +14 -1
- package/dist/duplicate-Dj4L-5AA.js +0 -22
- package/dist/json-CwwhxQgb.js +0 -36
- package/dist/registry-CF70EArN.js +0 -212
- package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
- package/dist/test-D6ISmuFE.js +0 -2383
- package/dist/touch-DEAmqGdf.js +0 -225
- package/editor/assets/agent8-BDvaZMQJ.js +0 -1
- package/editor/assets/index-CZIRr5EL.js +0 -10958
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { o as __exportAll } from "./json-CfTjpvW8.js";
|
|
2
|
+
import { B as HudLayer, F as resolveOrderGroups, G as UiText, H as TAPPABLE, I as InputMap, J as Node, N as ORDER_GROUP_BASE, U as UiBanner, V as HudWidgetBase, W as UiBar, Y as diagnose, a as restoreBehaviors, ct as Signal, i as captureBehaviors, j as createSaveStore, q as requireText, t as ENGINE_STATE_KEY, z as FITS } from "./save-slots-Bvuh2p_r.js";
|
|
3
3
|
import { t as IncantoError } from "./errors-BpWbnbb_.js";
|
|
4
|
-
import { t as Rng } from "./rng-
|
|
5
|
-
import {
|
|
6
|
-
import { r as capturePointer } from "./touch-DEAmqGdf.js";
|
|
4
|
+
import { t as Rng } from "./rng-CDOMybym.js";
|
|
5
|
+
import { p as registerNode } from "./touch-BnCyPA0G.js";
|
|
7
6
|
//#region src/core/audio/buses.ts
|
|
8
7
|
const AUDIO_LOG_LIMIT = 200;
|
|
9
8
|
var AudioBuses = class {
|
|
@@ -22,15 +21,33 @@ var AudioBuses = class {
|
|
|
22
21
|
* Bounded (200) — a game running for an hour must not grow a log.
|
|
23
22
|
*/
|
|
24
23
|
events = [];
|
|
24
|
+
/**
|
|
25
|
+
* EVERY node that has sounded, ever — a set of paths, not a window.
|
|
26
|
+
*
|
|
27
|
+
* `recent()` is bounded at 200, which is right for a log and wrong for the
|
|
28
|
+
* question "is this AudioPlayer wired?". A game with one sound firing about
|
|
29
|
+
* three times a second overflows the window inside a minute, so a 60-second
|
|
30
|
+
* playtest read the tail and reported correctly-wired feedback as never
|
|
31
|
+
* fired. This grows with the SCENE, not with the run — which is true only
|
|
32
|
+
* because it records the AUTHORED path: a clone's own path is new every
|
|
33
|
+
* spawn, and recording those grew the set by one entry per enemy, forever,
|
|
34
|
+
* in every shipped game. See `Node.wiringPath`.
|
|
35
|
+
*/
|
|
36
|
+
sourcePaths = /* @__PURE__ */ new Set();
|
|
25
37
|
/** @internal Called by the players; games read `recent()`. */
|
|
26
38
|
record(event) {
|
|
27
39
|
this.events.push(event);
|
|
28
40
|
if (this.events.length > AUDIO_LOG_LIMIT) this.events.shift();
|
|
41
|
+
this.sourcePaths.add(event.wiredAs ?? event.from);
|
|
29
42
|
}
|
|
30
43
|
/** The last `limit` sounds, oldest first. */
|
|
31
44
|
recent(limit = AUDIO_LOG_LIMIT) {
|
|
32
45
|
return this.events.slice(Math.max(0, this.events.length - limit));
|
|
33
46
|
}
|
|
47
|
+
/** Every node path that has sounded since the last `clearLog()`. */
|
|
48
|
+
sources() {
|
|
49
|
+
return [...this.sourcePaths];
|
|
50
|
+
}
|
|
34
51
|
/** How many times a preset/clip/track sounded — the assertion you want. */
|
|
35
52
|
countOf(name) {
|
|
36
53
|
return this.events.reduce((n, e) => e.name === name ? n + 1 : n, 0);
|
|
@@ -38,11 +55,13 @@ var AudioBuses = class {
|
|
|
38
55
|
/** Forget what has sounded so far (between scenes, or between assertions). */
|
|
39
56
|
clearLog() {
|
|
40
57
|
this.events.length = 0;
|
|
58
|
+
this.sourcePaths.clear();
|
|
41
59
|
}
|
|
42
60
|
_master = 1;
|
|
43
61
|
_sfx = 1;
|
|
44
62
|
_music = 1;
|
|
45
63
|
_muted = false;
|
|
64
|
+
_suspended = false;
|
|
46
65
|
get master() {
|
|
47
66
|
return this._master;
|
|
48
67
|
}
|
|
@@ -69,9 +88,30 @@ var AudioBuses = class {
|
|
|
69
88
|
this._muted = v;
|
|
70
89
|
this.changed.emit();
|
|
71
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Silence the game WITHOUT it counting as the player muting it.
|
|
93
|
+
*
|
|
94
|
+
* `pause-when-hidden` set `muted = true` when the tab went away, which fires
|
|
95
|
+
* `changed`, which `Settings.bindAudio` persists. Close the tab while it is
|
|
96
|
+
* hidden and `muted: true` is on disk: EVERY later session of that game boots
|
|
97
|
+
* silent, and no shipped starter has an in-game way back.
|
|
98
|
+
*
|
|
99
|
+
* A suspension is the engine's, not the player's. It never touches `muted`,
|
|
100
|
+
* so nothing persists it and unsuspending cannot turn the sound back on for
|
|
101
|
+
* someone who turned it off.
|
|
102
|
+
*/
|
|
103
|
+
suspend(on) {
|
|
104
|
+
if (on === this._suspended) return;
|
|
105
|
+
this._suspended = on;
|
|
106
|
+
this.changed.emit();
|
|
107
|
+
}
|
|
108
|
+
/** Is the engine holding the sound off (hidden tab), as against the player? */
|
|
109
|
+
get suspended() {
|
|
110
|
+
return this._suspended;
|
|
111
|
+
}
|
|
72
112
|
/** Final gain for a sound on `bus` with its own `sourceVolume` (all clamped). */
|
|
73
113
|
effectiveVolume(bus, sourceVolume) {
|
|
74
|
-
if (this._muted) return 0;
|
|
114
|
+
if (this._muted || this._suspended) return 0;
|
|
75
115
|
const busGain = bus === "music" ? this._music : this._sfx;
|
|
76
116
|
return this._master * busGain * clamp01$3(sourceVolume);
|
|
77
117
|
}
|
|
@@ -241,16 +281,36 @@ var WebAudioMusicBackend = class {
|
|
|
241
281
|
};
|
|
242
282
|
//#endregion
|
|
243
283
|
//#region src/core/audio/music-manager.ts
|
|
244
|
-
/**
|
|
284
|
+
/**
|
|
285
|
+
* Mints silent tracks so the state machine runs with no audio backend.
|
|
286
|
+
*
|
|
287
|
+
* Including its CLOCK. `playhead: () => null` meant that headless — the verify
|
|
288
|
+
* VM, the only place a game is checked automatically — "where is the song" had
|
|
289
|
+
* no answer, so a game charted to a soundtrack could not be tested at all. The
|
|
290
|
+
* silent track carries the same elapsed-seconds counter the fades already run
|
|
291
|
+
* on. It has no duration to wrap at, so it counts up for as long as it plays.
|
|
292
|
+
*/
|
|
245
293
|
const SILENT_BACKEND = {
|
|
246
|
-
createTrack: (src) =>
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
294
|
+
createTrack: (src) => {
|
|
295
|
+
let playing = false;
|
|
296
|
+
let at = 0;
|
|
297
|
+
return {
|
|
298
|
+
src,
|
|
299
|
+
setGain() {},
|
|
300
|
+
setLoop() {},
|
|
301
|
+
play() {
|
|
302
|
+
playing = true;
|
|
303
|
+
},
|
|
304
|
+
stop() {
|
|
305
|
+
playing = false;
|
|
306
|
+
at = 0;
|
|
307
|
+
},
|
|
308
|
+
tick(dt) {
|
|
309
|
+
if (playing && Number.isFinite(dt) && dt > 0) at += dt;
|
|
310
|
+
},
|
|
311
|
+
playhead: () => playing ? at : null
|
|
312
|
+
};
|
|
313
|
+
},
|
|
254
314
|
unlock() {}
|
|
255
315
|
};
|
|
256
316
|
/**
|
|
@@ -373,13 +433,6 @@ var MusicManager = class {
|
|
|
373
433
|
* it's a fade-in. Crossfading to the already-current src is a no-op.
|
|
374
434
|
*/
|
|
375
435
|
crossfadeTo(src, seconds = 2) {
|
|
376
|
-
this.buses.record({
|
|
377
|
-
kind: "music",
|
|
378
|
-
name: src,
|
|
379
|
-
from: "engine.music",
|
|
380
|
-
bus: this._bus,
|
|
381
|
-
at: 0
|
|
382
|
-
});
|
|
383
436
|
const backend = this.ensure();
|
|
384
437
|
if (this.active && this.active.track.src === src && this.active.phase !== "fadeOut") return;
|
|
385
438
|
const secs = Math.max(0, seconds);
|
|
@@ -387,6 +440,13 @@ var MusicManager = class {
|
|
|
387
440
|
this.play(src, { fadeIn: secs });
|
|
388
441
|
return;
|
|
389
442
|
}
|
|
443
|
+
this.buses.record({
|
|
444
|
+
kind: "music",
|
|
445
|
+
name: src,
|
|
446
|
+
from: "engine.music",
|
|
447
|
+
bus: this._bus,
|
|
448
|
+
at: 0
|
|
449
|
+
});
|
|
390
450
|
this.killOutgoing();
|
|
391
451
|
this.outgoing = {
|
|
392
452
|
...this.active,
|
|
@@ -446,6 +506,8 @@ var MusicManager = class {
|
|
|
446
506
|
}
|
|
447
507
|
/** Advance fades by `dt` seconds and (re)apply bus×fade gains. Frame-driven. */
|
|
448
508
|
tick(dt) {
|
|
509
|
+
this.active?.track.tick?.(dt);
|
|
510
|
+
this.outgoing?.track.tick?.(dt);
|
|
449
511
|
if (this.active && (this.active.phase === "fadeIn" || this.active.phase === "crossIn")) {
|
|
450
512
|
this.active.t += dt;
|
|
451
513
|
if (this.active.t >= this.active.duration) this.active.phase = "steady";
|
|
@@ -1063,11 +1125,27 @@ var SfxEngine = class {
|
|
|
1063
1125
|
* The AUDIO clock, in seconds — the one a sound is actually scheduled
|
|
1064
1126
|
* against, and the only clock that does not drift relative to what you hear.
|
|
1065
1127
|
*
|
|
1066
|
-
* `0` headless and before the first sound, since there is no context yet.
|
|
1067
1128
|
* Schedule with a lead: `sfx.play(params, 1, { when: sfx.now + 0.08 })`.
|
|
1129
|
+
*
|
|
1130
|
+
* With no AudioContext — headless, and before the first sound — it is the
|
|
1131
|
+
* engine's own real elapsed time instead of a frozen `0`. A constant made the
|
|
1132
|
+
* documented lookahead scheduler queue the notes inside the first 80 ms and
|
|
1133
|
+
* then nothing ever again: measured on a 21-beat metronome in the verify VM,
|
|
1134
|
+
* **1 of 21 beats**, with no error and no warning. The silent clock runs so
|
|
1135
|
+
* the schedule is reachable; nothing sounds either way.
|
|
1068
1136
|
*/
|
|
1069
1137
|
get now() {
|
|
1070
|
-
return this.ctx?.currentTime ??
|
|
1138
|
+
return this.ctx?.currentTime ?? this.silentNow;
|
|
1139
|
+
}
|
|
1140
|
+
/** The clock `now` reports when there is no context to ask. */
|
|
1141
|
+
silentNow = 0;
|
|
1142
|
+
/**
|
|
1143
|
+
* @internal Advance the silent clock — the engine calls this every frame with
|
|
1144
|
+
* the REAL dt, beside `music.tick`, because the audio does not slow down when
|
|
1145
|
+
* the game does.
|
|
1146
|
+
*/
|
|
1147
|
+
_tick(dt) {
|
|
1148
|
+
if (Number.isFinite(dt) && dt > 0) this.silentNow += dt;
|
|
1071
1149
|
}
|
|
1072
1150
|
/**
|
|
1073
1151
|
* Play a preset at `gain` (0..1, already bus-multiplied by the caller). Each
|
|
@@ -1257,728 +1335,28 @@ function translateOn(node, value) {
|
|
|
1257
1335
|
return locale ? locale.resolve(value) : value;
|
|
1258
1336
|
}
|
|
1259
1337
|
//#endregion
|
|
1260
|
-
//#region src/core/nodes/hud.ts
|
|
1261
|
-
const ANCHOR_CSS = {
|
|
1262
|
-
topLeft: "left:16px;top:16px;align-items:flex-start;",
|
|
1263
|
-
top: "left:50%;top:16px;transform:translateX(-50%);align-items:center;",
|
|
1264
|
-
topRight: "right:16px;top:16px;align-items:flex-end;",
|
|
1265
|
-
left: "left:16px;top:50%;transform:translateY(-50%);align-items:flex-start;",
|
|
1266
|
-
center: "left:50%;top:50%;transform:translate(-50%,-50%);align-items:center;",
|
|
1267
|
-
right: "right:16px;top:50%;transform:translateY(-50%);align-items:flex-end;",
|
|
1268
|
-
bottomLeft: "left:16px;bottom:16px;align-items:flex-start;",
|
|
1269
|
-
bottom: "left:50%;bottom:16px;transform:translateX(-50%);align-items:center;",
|
|
1270
|
-
bottomRight: "right:16px;bottom:16px;align-items:flex-end;"
|
|
1271
|
-
};
|
|
1272
|
-
const FONT = "600 14px system-ui, -apple-system, 'Segoe UI', sans-serif";
|
|
1273
|
-
function hasDom() {
|
|
1274
|
-
return typeof document !== "undefined";
|
|
1275
|
-
}
|
|
1276
|
-
/**
|
|
1277
|
-
* The overlay container. One per scene is plenty; widgets mount into its
|
|
1278
|
-
* anchor slots. `zIndex` lifts it above game canvases; the layer never eats
|
|
1279
|
-
* pointer events (widgets that need clicks opt in individually).
|
|
1280
|
-
*/
|
|
1281
|
-
var HudLayer = class extends Node {
|
|
1282
|
-
static typeName = "HudLayer";
|
|
1283
|
-
static props = {
|
|
1284
|
-
zIndex: { default: 100 },
|
|
1285
|
-
visible: { default: true },
|
|
1286
|
-
/**
|
|
1287
|
-
* Arrow keys / d-pad move focus between the focusable widgets under this
|
|
1288
|
-
* layer, Enter / A activates. OFF by default — a game whose HUD has a
|
|
1289
|
-
* button must not lose its arrow keys the moment one exists.
|
|
1290
|
-
*/
|
|
1291
|
-
focusNavigation: { default: false }
|
|
1292
|
-
};
|
|
1293
|
-
zIndex = 100;
|
|
1294
|
-
visible = true;
|
|
1295
|
-
focusNavigation = false;
|
|
1296
|
-
/** @internal root overlay element (null headless). */
|
|
1297
|
-
_element = null;
|
|
1298
|
-
slots = /* @__PURE__ */ new Map();
|
|
1299
|
-
onEnterTree() {
|
|
1300
|
-
if (!hasDom()) return;
|
|
1301
|
-
const el = document.createElement("div");
|
|
1302
|
-
el.dataset.incantoHud = this.name;
|
|
1303
|
-
el.style.cssText = `position:fixed;inset:0;pointer-events:none;z-index:${this.zIndex};font:${FONT};`;
|
|
1304
|
-
this._element = el;
|
|
1305
|
-
this._mount();
|
|
1306
|
-
}
|
|
1307
|
-
/**
|
|
1308
|
-
* Put the overlay where the page's owner says UI goes: `engine.uiHost`, or
|
|
1309
|
-
* the window when nobody claims it (a game — a HUD covers the screen).
|
|
1310
|
-
*
|
|
1311
|
-
* Re-checked every frame because a scene's tree is BUILT before it is handed
|
|
1312
|
-
* to an engine: at onEnterTree there is no engine to ask yet, so the first
|
|
1313
|
-
* update is where the answer actually arrives. appendChild moves the element
|
|
1314
|
-
* if it already landed somewhere else, so the HUD follows the host across a
|
|
1315
|
-
* scene swap into a differently-hosted engine (game → editor) instead of
|
|
1316
|
-
* being stranded on document.body over the inspector.
|
|
1317
|
-
*/
|
|
1318
|
-
_mount() {
|
|
1319
|
-
const el = this._element;
|
|
1320
|
-
if (!el) return;
|
|
1321
|
-
const host = this.tree?.engine?.uiHost ?? document.body;
|
|
1322
|
-
if (el.parentElement === host) return;
|
|
1323
|
-
el.style.position = host === document.body ? "fixed" : "absolute";
|
|
1324
|
-
host.appendChild(el);
|
|
1325
|
-
}
|
|
1326
|
-
onExitTree() {
|
|
1327
|
-
this._element?.remove();
|
|
1328
|
-
this._element = null;
|
|
1329
|
-
this.slots.clear();
|
|
1330
|
-
}
|
|
1331
|
-
update() {
|
|
1332
|
-
if (this._element) {
|
|
1333
|
-
this._mount();
|
|
1334
|
-
this._element.style.display = this.visible ? "" : "none";
|
|
1335
|
-
}
|
|
1336
|
-
if (this.focusNavigation) this.stepFocus();
|
|
1337
|
-
}
|
|
1338
|
-
/**
|
|
1339
|
-
* Arrow keys / d-pad move the focus, Enter / A activates it.
|
|
1340
|
-
*
|
|
1341
|
-
* Off by default: a game whose HUD has a button must not lose its arrow keys
|
|
1342
|
-
* to a menu the moment one exists. Turn it on for the screens that ARE menus
|
|
1343
|
-
* (`"focusNavigation": true` on the pause panel's layer), and off again when
|
|
1344
|
-
* play resumes.
|
|
1345
|
-
*/
|
|
1346
|
-
stepFocus() {
|
|
1347
|
-
const engine = this.tree?.engine;
|
|
1348
|
-
if (!engine) return;
|
|
1349
|
-
const items = this.focusables();
|
|
1350
|
-
if (items.length === 0) return;
|
|
1351
|
-
const input = engine.input;
|
|
1352
|
-
const down = input.keyJustPressed("ArrowDown") || input.keyJustPressed("Pad13");
|
|
1353
|
-
const up = input.keyJustPressed("ArrowUp") || input.keyJustPressed("Pad12");
|
|
1354
|
-
const left = input.keyJustPressed("ArrowLeft") || input.keyJustPressed("Pad14");
|
|
1355
|
-
const right = input.keyJustPressed("ArrowRight") || input.keyJustPressed("Pad15");
|
|
1356
|
-
const confirm = input.keyJustPressed("Enter") || input.keyJustPressed("Pad0");
|
|
1357
|
-
let index = items.findIndex((w) => w._focused);
|
|
1358
|
-
if (index === -1 && (down || up || confirm)) index = 0;
|
|
1359
|
-
else if (down) index = (index + 1) % items.length;
|
|
1360
|
-
else if (up) index = (index - 1 + items.length) % items.length;
|
|
1361
|
-
if (index !== -1) {
|
|
1362
|
-
for (const w of items) {
|
|
1363
|
-
const next = w === items[index];
|
|
1364
|
-
if (next !== w._focused) {
|
|
1365
|
-
w._focused = next;
|
|
1366
|
-
w._paintFocus();
|
|
1367
|
-
}
|
|
1368
|
-
}
|
|
1369
|
-
const focused = items[index];
|
|
1370
|
-
if (focused) {
|
|
1371
|
-
if (confirm) focused._activate(0);
|
|
1372
|
-
else if (left) focused._activate(-1);
|
|
1373
|
-
else if (right) focused._activate(1);
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
/**
|
|
1378
|
-
* Focusable widgets under this layer, in tree order — **on screen only**.
|
|
1379
|
-
*
|
|
1380
|
-
* This filtered on each widget's OWN `visible` and recursed into hidden
|
|
1381
|
-
* containers anyway, so arrow-keys walked into closed panels and Enter really
|
|
1382
|
-
* pressed what it found. Measured on a title screen: 23 focus stops, 21 of
|
|
1383
|
-
* them under an ancestor with `visible = false`, and
|
|
1384
|
-
*
|
|
1385
|
-
* gold before = 150 -> Enter on the hidden Shop/Row0/Buy -> gold 120,
|
|
1386
|
-
* screen still 'title'
|
|
1387
|
-
*
|
|
1388
|
-
* The layer's own visibility was not a gate either. It is now: a HUD nobody
|
|
1389
|
-
* can see has no ring, and a subtree nobody can see is not walked.
|
|
1390
|
-
*
|
|
1391
|
-
* PUBLIC, because a game that wants to know what its ring contains could not
|
|
1392
|
-
* ask — and "why is focus on that" is exactly the question this answers.
|
|
1393
|
-
*/
|
|
1394
|
-
focusables() {
|
|
1395
|
-
const out = [];
|
|
1396
|
-
if (!this.visible) return out;
|
|
1397
|
-
const walk = (node) => {
|
|
1398
|
-
if (node instanceof HudWidgetBase && !node.visible) return;
|
|
1399
|
-
if (node instanceof HudWidgetBase && node.focusable) out.push(node);
|
|
1400
|
-
for (const child of node.children) walk(child);
|
|
1401
|
-
};
|
|
1402
|
-
walk(this);
|
|
1403
|
-
return out;
|
|
1404
|
-
}
|
|
1405
|
-
/** Move focus to a widget by hand — opening a menu should start somewhere. */
|
|
1406
|
-
focus(widget) {
|
|
1407
|
-
for (const w of this.focusables()) {
|
|
1408
|
-
const next = w === widget;
|
|
1409
|
-
if (next !== w._focused) {
|
|
1410
|
-
w._focused = next;
|
|
1411
|
-
w._paintFocus();
|
|
1412
|
-
}
|
|
1413
|
-
}
|
|
1414
|
-
}
|
|
1415
|
-
/** @internal Widgets mount into per-anchor flex columns. */
|
|
1416
|
-
_slot(anchor) {
|
|
1417
|
-
if (!this._element) return null;
|
|
1418
|
-
let slot = this.slots.get(anchor);
|
|
1419
|
-
if (!slot) {
|
|
1420
|
-
slot = document.createElement("div");
|
|
1421
|
-
slot.style.cssText = `position:absolute;display:flex;flex-direction:column;gap:8px;${ANCHOR_CSS[anchor] ?? ANCHOR_CSS.topLeft}`;
|
|
1422
|
-
this._element.appendChild(slot);
|
|
1423
|
-
this.slots.set(anchor, slot);
|
|
1424
|
-
}
|
|
1425
|
-
return slot;
|
|
1426
|
-
}
|
|
1427
|
-
};
|
|
1428
|
-
/**
|
|
1429
|
-
* The widget an element belongs to.
|
|
1430
|
-
*
|
|
1431
|
-
* A pick lands on whatever div is under the cursor — usually a CHILD of the
|
|
1432
|
-
* widget (an icon inside a slot), so the walk upward is the point.
|
|
1433
|
-
*/
|
|
1434
|
-
const widgetOf = /* @__PURE__ */ new WeakMap();
|
|
1435
|
-
/** The nearest ancestor widget that actually accepts a drop. */
|
|
1436
|
-
function dropTargetFromElement(el) {
|
|
1437
|
-
for (let n = el; n; n = n.parentElement) {
|
|
1438
|
-
const found = widgetOf.get(n);
|
|
1439
|
-
if (found?.dropTarget) return found;
|
|
1440
|
-
}
|
|
1441
|
-
return null;
|
|
1442
|
-
}
|
|
1443
|
-
function widgetFromElement(el) {
|
|
1444
|
-
for (let n = el; n; n = n.parentElement) {
|
|
1445
|
-
const found = widgetOf.get(n);
|
|
1446
|
-
if (found) return found;
|
|
1447
|
-
}
|
|
1448
|
-
return null;
|
|
1449
|
-
}
|
|
1450
|
-
/** The drag in flight, if any. One pointer, one drag. */
|
|
1451
|
-
let dragging = null;
|
|
1452
|
-
/** The drop target the dragged widget came OUT of — putting it back is a no-op. */
|
|
1453
|
-
let draggingFrom = null;
|
|
1454
|
-
/** Shared plumbing: mount into the parent HudLayer's anchor slot. */
|
|
1455
|
-
var HudWidgetBase = class extends Node {
|
|
1456
|
-
/**
|
|
1457
|
-
* Wireable visibility: `noSave → Continue.hide`.
|
|
1458
|
-
*
|
|
1459
|
-
* `visible` is a prop, and a `connections[]` handler has to be a METHOD — the
|
|
1460
|
-
* same wall that kept a score off the screen until widgets grew `setText`. A
|
|
1461
|
-
* title screen greying out its own Continue button is the case that asked.
|
|
1462
|
-
*/
|
|
1463
|
-
show(..._args) {
|
|
1464
|
-
this.visible = true;
|
|
1465
|
-
}
|
|
1466
|
-
hide() {
|
|
1467
|
-
this.visible = false;
|
|
1468
|
-
}
|
|
1469
|
-
static props = {
|
|
1470
|
-
anchor: {
|
|
1471
|
-
default: "topLeft",
|
|
1472
|
-
options: [
|
|
1473
|
-
"topLeft",
|
|
1474
|
-
"top",
|
|
1475
|
-
"topRight",
|
|
1476
|
-
"left",
|
|
1477
|
-
"center",
|
|
1478
|
-
"right",
|
|
1479
|
-
"bottomLeft",
|
|
1480
|
-
"bottom",
|
|
1481
|
-
"bottomRight"
|
|
1482
|
-
]
|
|
1483
|
-
},
|
|
1484
|
-
visible: { default: true },
|
|
1485
|
-
/**
|
|
1486
|
-
* Can menu navigation land on this? Interactive widgets default to true.
|
|
1487
|
-
*
|
|
1488
|
-
* A menu you can only click is not playable on a controller, and "add
|
|
1489
|
-
* gamepad support" is not a thing a JSON scene could express at all.
|
|
1490
|
-
*/
|
|
1491
|
-
focusable: { default: false },
|
|
1492
|
-
/**
|
|
1493
|
-
* Can the player pick this up and drop it somewhere? An inventory item is
|
|
1494
|
-
* a `draggable` widget; a slot is a `dropTarget`.
|
|
1495
|
-
*/
|
|
1496
|
-
draggable: { default: false },
|
|
1497
|
-
/** Can something be dropped ON this? */
|
|
1498
|
-
dropTarget: { default: false }
|
|
1499
|
-
};
|
|
1500
|
-
static signals = [
|
|
1501
|
-
"dragStarted",
|
|
1502
|
-
"dragCancelled",
|
|
1503
|
-
"droppedOn",
|
|
1504
|
-
"dropped"
|
|
1505
|
-
];
|
|
1506
|
-
anchor = "topLeft";
|
|
1507
|
-
visible = true;
|
|
1508
|
-
focusable = false;
|
|
1509
|
-
draggable = false;
|
|
1510
|
-
dropTarget = false;
|
|
1511
|
-
/** @internal Set by the owning HudLayer while this widget has focus. */
|
|
1512
|
-
_focused = false;
|
|
1513
|
-
/**
|
|
1514
|
-
* @internal Confirm (`dir` 0) or nudge (-1 left / +1 right) — what pressing
|
|
1515
|
-
* A or an arrow ON this widget means. Default: nothing.
|
|
1516
|
-
*/
|
|
1517
|
-
_activate(_dir) {}
|
|
1518
|
-
/** @internal Draw the focus ring. Overridable if a widget wants its own. */
|
|
1519
|
-
_paintFocus() {
|
|
1520
|
-
if (!this._element) return;
|
|
1521
|
-
this._element.style.outline = this._focused ? "2px solid #6ee7dc" : "";
|
|
1522
|
-
this._element.style.outlineOffset = this._focused ? "2px" : "";
|
|
1523
|
-
}
|
|
1524
|
-
/** @internal */
|
|
1525
|
-
_element = null;
|
|
1526
|
-
layer() {
|
|
1527
|
-
for (let p = this.parent; p; p = p.parent) if (p instanceof HudLayer) return p;
|
|
1528
|
-
return null;
|
|
1529
|
-
}
|
|
1530
|
-
onReady() {
|
|
1531
|
-
if (!hasDom()) return;
|
|
1532
|
-
this._mountWidget();
|
|
1533
|
-
}
|
|
1534
|
-
/**
|
|
1535
|
-
* @internal Build and attach, once a host exists.
|
|
1536
|
-
*
|
|
1537
|
-
* A widget inside a UiPanel mounts into the PANEL, not the layer's anchor
|
|
1538
|
-
* slot — that is what turns structure in the tree into structure on screen,
|
|
1539
|
-
* and it is what a menu is. Ready order does not decide it: the panel builds
|
|
1540
|
-
* on demand when a child asks (`_container`), and `update` retries for
|
|
1541
|
-
* anything attached at runtime before its host existed.
|
|
1542
|
-
*/
|
|
1543
|
-
_mountWidget() {
|
|
1544
|
-
if (this._element || !hasDom()) return;
|
|
1545
|
-
const host = this.host() ?? this.layer()?._slot(this.anchor);
|
|
1546
|
-
if (!host) return;
|
|
1547
|
-
this._element = this._build();
|
|
1548
|
-
widgetOf.set(this._element, this);
|
|
1549
|
-
if (this.draggable || this.dropTarget) this._wireDrag(this._element);
|
|
1550
|
-
host.appendChild(this._element);
|
|
1551
|
-
}
|
|
1552
|
-
/**
|
|
1553
|
-
* Drag and drop, on the DOM the engine already builds.
|
|
1554
|
-
*
|
|
1555
|
-
* An inventory is the one screen where "click it" is not enough, and every
|
|
1556
|
-
* game that wanted one dropped out of scene JSON to hand-roll pointer
|
|
1557
|
-
* handlers. The whole gesture is four signals and no new node type: a
|
|
1558
|
-
* `draggable` widget emits `dragStarted` and, if it lands on one,
|
|
1559
|
-
* `droppedOn(target)`; a `dropTarget` emits `dropped(source)`. Who owns the
|
|
1560
|
-
* ITEM is the game's business — this reports the gesture, not a model.
|
|
1561
|
-
*/
|
|
1562
|
-
_wireDrag(el) {
|
|
1563
|
-
el.style.touchAction = "none";
|
|
1564
|
-
if (this.draggable) {
|
|
1565
|
-
el.style.cursor = "grab";
|
|
1566
|
-
el.addEventListener("pointerdown", (event) => {
|
|
1567
|
-
if (!this.draggable) return;
|
|
1568
|
-
event.preventDefault();
|
|
1569
|
-
capturePointer(el, event.pointerId);
|
|
1570
|
-
dragging = this;
|
|
1571
|
-
draggingFrom = dropTargetFromElement(el.parentElement);
|
|
1572
|
-
el.style.opacity = "0.6";
|
|
1573
|
-
this.emit("dragStarted", this);
|
|
1574
|
-
});
|
|
1575
|
-
el.addEventListener("pointerup", (event) => {
|
|
1576
|
-
if (dragging !== this) return;
|
|
1577
|
-
el.style.opacity = "";
|
|
1578
|
-
el.releasePointerCapture?.(event.pointerId);
|
|
1579
|
-
const under = document.elementFromPoint?.(event.clientX, event.clientY) ?? null;
|
|
1580
|
-
const target = dropTargetFromElement(under) ?? widgetFromElement(under);
|
|
1581
|
-
const origin = draggingFrom;
|
|
1582
|
-
dragging = null;
|
|
1583
|
-
draggingFrom = null;
|
|
1584
|
-
if (target && target !== this && target !== origin && target.dropTarget) {
|
|
1585
|
-
this.emit("droppedOn", target);
|
|
1586
|
-
target.emit("dropped", this);
|
|
1587
|
-
} else this.emit("dragCancelled", this);
|
|
1588
|
-
});
|
|
1589
|
-
}
|
|
1590
|
-
}
|
|
1591
|
-
/** The nearest ancestor that holds widgets itself (a UiPanel). */
|
|
1592
|
-
host() {
|
|
1593
|
-
for (let p = this.parent; p; p = p.parent) {
|
|
1594
|
-
const holder = p;
|
|
1595
|
-
if (typeof holder._container === "function") return holder._container();
|
|
1596
|
-
if (p instanceof HudLayer) return null;
|
|
1597
|
-
}
|
|
1598
|
-
return null;
|
|
1599
|
-
}
|
|
1600
|
-
onExitTree() {
|
|
1601
|
-
this._element?.remove();
|
|
1602
|
-
this._element = null;
|
|
1603
|
-
}
|
|
1604
|
-
update(_dt) {
|
|
1605
|
-
if (!this._element) {
|
|
1606
|
-
this._mountWidget();
|
|
1607
|
-
if (!this._element) return;
|
|
1608
|
-
}
|
|
1609
|
-
this._followAnchor();
|
|
1610
|
-
this._sync();
|
|
1611
|
-
if (this._element.style.display !== "none") this._shownDisplay = this._element.style.display;
|
|
1612
|
-
this._element.style.display = this.visible ? this._shownDisplay : "none";
|
|
1613
|
-
}
|
|
1614
|
-
/**
|
|
1615
|
-
* The display this widget's own css asks for while shown — `grid` for a grid
|
|
1616
|
-
* panel, `flex` for a row, `''` for a widget that never says.
|
|
1617
|
-
*/
|
|
1618
|
-
_shownDisplay = "";
|
|
1619
|
-
/** Which anchor slot this widget was mounted into. */
|
|
1620
|
-
mountedAnchor = null;
|
|
1621
|
-
/**
|
|
1622
|
-
* Move to the slot the anchor now names.
|
|
1623
|
-
*
|
|
1624
|
-
* `_mountWidget` read `anchor` once and `_element` is nulled only on
|
|
1625
|
-
* `onExitTree`, so moving a widget from topLeft to bottomRight — from a
|
|
1626
|
-
* behavior or from the editor's inspector, which live-patches string props —
|
|
1627
|
-
* did nothing at all, forever.
|
|
1628
|
-
*/
|
|
1629
|
-
_followAnchor() {
|
|
1630
|
-
if (this.host()) return;
|
|
1631
|
-
if (this.mountedAnchor === null) this.mountedAnchor = this.anchor;
|
|
1632
|
-
if (this.mountedAnchor === this.anchor) return;
|
|
1633
|
-
const slot = this.layer()?._slot(this.anchor);
|
|
1634
|
-
if (!slot || !this._element) return;
|
|
1635
|
-
this.mountedAnchor = this.anchor;
|
|
1636
|
-
slot.appendChild(this._element);
|
|
1637
|
-
}
|
|
1638
|
-
_sync() {}
|
|
1639
|
-
/**
|
|
1640
|
-
* Resolve a text prop through the engine's locale.
|
|
1641
|
-
*
|
|
1642
|
-
* Called every frame from `_sync`, which is what makes switching language
|
|
1643
|
-
* repaint with nothing to invalidate: widgets already re-read their props, so
|
|
1644
|
-
* the new string simply appears on the next frame. A plain string costs one
|
|
1645
|
-
* `startsWith` and is returned untouched.
|
|
1646
|
-
*/
|
|
1647
|
-
_t(value) {
|
|
1648
|
-
const locale = this.tree?.engine?.locale;
|
|
1649
|
-
return locale ? locale.resolve(value) : value;
|
|
1650
|
-
}
|
|
1651
|
-
/**
|
|
1652
|
-
* A translation with an English wording to fall back to.
|
|
1653
|
-
*
|
|
1654
|
-
* For text the ENGINE supplies — "Music", "Mute", "Unlimited" — where there
|
|
1655
|
-
* is no author string to carry a `@t:` marker. Asks `declares()`, not
|
|
1656
|
-
* `has()`: a key only another locale declares would otherwise come back as
|
|
1657
|
-
* the raw key, which is worse than the English.
|
|
1658
|
-
*/
|
|
1659
|
-
/**
|
|
1660
|
-
* What this widget's label READS as — resolved every frame, never stored.
|
|
1661
|
-
*
|
|
1662
|
-
* `UiVolumeSlider` and `UiMuteToggle` used to compute their auto-label in
|
|
1663
|
-
* `_build()` and write the result back into `this.label`, which broke them
|
|
1664
|
-
* twice over: the per-frame `_t(this.label)` then had a plain string with no
|
|
1665
|
-
* marker left to resolve, and `_build` runs from `onReady` — inside
|
|
1666
|
-
* `loadScene`, BEFORE `setScene` merges the scene's `strings` — so the table
|
|
1667
|
-
* was empty at bake time and the English was permanent even for a game that
|
|
1668
|
-
* boots in another language.
|
|
1669
|
-
*/
|
|
1670
|
-
/**
|
|
1671
|
-
* What this widget actually PAINTS, when that differs from its props.
|
|
1672
|
-
*
|
|
1673
|
-
* A headless capture printed `text="@t:menu.start"` — byte-identical in
|
|
1674
|
-
* every language — so a passing capture proved nothing about localization
|
|
1675
|
-
* and a broken translation was invisible to every check the engine has.
|
|
1676
|
-
* `incanto-frame` reads the WebGL buffer and cannot read DOM text at all.
|
|
1677
|
-
*
|
|
1678
|
-
* `undefined` when the painted words ARE the prop: recording it twice would
|
|
1679
|
-
* be noise on every widget in a game that never localizes anything.
|
|
1680
|
-
*/
|
|
1681
|
-
_paintedText() {}
|
|
1682
|
-
_labelText() {
|
|
1683
|
-
const label = this.label;
|
|
1684
|
-
return typeof label === "string" ? this._t(label) : "";
|
|
1685
|
-
}
|
|
1686
|
-
_tOr(key, fallback) {
|
|
1687
|
-
const locale = this.tree?.engine?.locale;
|
|
1688
|
-
return locale?.declares(key) ? locale.t(key) : fallback;
|
|
1689
|
-
}
|
|
1690
|
-
};
|
|
1691
|
-
/** A text line (score, timer, hints). Set `.text` from behaviors. */
|
|
1692
|
-
var UiText = class extends HudWidgetBase {
|
|
1693
|
-
static typeName = "UiText";
|
|
1694
|
-
static props = {
|
|
1695
|
-
...HudWidgetBase.props,
|
|
1696
|
-
text: { default: "" },
|
|
1697
|
-
format: { default: "" },
|
|
1698
|
-
size: { default: 16 },
|
|
1699
|
-
color: { default: "#ffffff" },
|
|
1700
|
-
shadow: { default: true }
|
|
1701
|
-
};
|
|
1702
|
-
text = "";
|
|
1703
|
-
/**
|
|
1704
|
-
* A template with a `{}` slot that `setText` fills — `"Gems {} / 8"`.
|
|
1705
|
-
*
|
|
1706
|
-
* Empty (the default) means `setText` replaces the whole line. Resolved at
|
|
1707
|
-
* PAINT like `text`, so `"@t:hud.gems"` works and changing locale re-reads
|
|
1708
|
-
* it: the value lives here, the words live in the strings table.
|
|
1709
|
-
*/
|
|
1710
|
-
format = "";
|
|
1711
|
-
size = 16;
|
|
1712
|
-
color = "#ffffff";
|
|
1713
|
-
shadow = true;
|
|
1714
|
-
last = "\0";
|
|
1715
|
-
/** null until something has actually set a value — see `shown`. */
|
|
1716
|
-
slot = null;
|
|
1717
|
-
/**
|
|
1718
|
-
* Put a signal's value on screen — `scoreChanged → setText`.
|
|
1719
|
-
*
|
|
1720
|
-
* A `connections[]` handler must be a METHOD and `text` is a property, so the
|
|
1721
|
-
* one wire every game needs (score to screen) could not be declared: every
|
|
1722
|
-
* HUD began with a behavior whose whole job was one assignment.
|
|
1723
|
-
*/
|
|
1724
|
-
setText(value) {
|
|
1725
|
-
this.slot = String(value ?? "");
|
|
1726
|
-
if (!this.format) this.text = this.slot;
|
|
1727
|
-
}
|
|
1728
|
-
/**
|
|
1729
|
-
* What it actually paints, after the template and the locale.
|
|
1730
|
-
*
|
|
1731
|
-
* Before the first value arrives, a formatted widget shows `text` — its
|
|
1732
|
-
* authored opening line. Filling the slot with nothing instead would put
|
|
1733
|
-
* `"Gems / 8"` on screen for every game's first frame, and a score line
|
|
1734
|
-
* that starts blank looks broken rather than empty.
|
|
1735
|
-
*/
|
|
1736
|
-
get shown() {
|
|
1737
|
-
if (this.format && this.slot !== null) return this._t(this.format).replace("{}", this.slot);
|
|
1738
|
-
return this._t(this.text);
|
|
1739
|
-
}
|
|
1740
|
-
_paintedText() {
|
|
1741
|
-
const painted = this.shown;
|
|
1742
|
-
return painted === this.text ? void 0 : painted;
|
|
1743
|
-
}
|
|
1744
|
-
_build() {
|
|
1745
|
-
const el = document.createElement("div");
|
|
1746
|
-
el.style.cssText = this.look();
|
|
1747
|
-
this.lastLook = this.look();
|
|
1748
|
-
return el;
|
|
1749
|
-
}
|
|
1750
|
-
/** The whole appearance as one string, so re-applying is one comparison. */
|
|
1751
|
-
look() {
|
|
1752
|
-
return `font-size:${this.size}px;color:${this.color};${this.shadow ? "text-shadow:0 1px 3px rgba(0,0,0,.7);" : ""}white-space:pre;`;
|
|
1753
|
-
}
|
|
1754
|
-
lastLook = "";
|
|
1755
|
-
_sync() {
|
|
1756
|
-
const shown = this.shown;
|
|
1757
|
-
if (shown !== this.last && this._element) {
|
|
1758
|
-
this.last = shown;
|
|
1759
|
-
this._element.textContent = shown;
|
|
1760
|
-
}
|
|
1761
|
-
const look = this.look();
|
|
1762
|
-
if (look !== this.lastLook && this._element) {
|
|
1763
|
-
this.lastLook = look;
|
|
1764
|
-
this._element.style.cssText = look;
|
|
1765
|
-
}
|
|
1766
|
-
}
|
|
1767
|
-
};
|
|
1768
|
-
/**
|
|
1769
|
-
* A bar fed the wrong argument shows a plausible wrong number forever.
|
|
1770
|
-
*
|
|
1771
|
-
* The trap is one signal away: `Health.damaged` is `(amount, current)`, so
|
|
1772
|
-
* wiring it to `setValue` paints the DAMAGE as the health and looks like a
|
|
1773
|
-
* working health bar. Say which signal has the shape a bar wants.
|
|
1774
|
-
*/
|
|
1775
|
-
function requireNumber(value, node, method) {
|
|
1776
|
-
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
1777
|
-
throw new IncantoError("PROP_TYPE_MISMATCH", `UiBar '${node.getPath()}'.${method} needs a number, got ${JSON.stringify(value)}. Check what the wired signal passes FIRST — 'damaged' is (amount, current), 'healthChanged' is (current, max), which is the shape a bar wants.`);
|
|
1778
|
-
}
|
|
1779
|
-
/** A labeled progress bar (health, stamina, reload, boss HP). */
|
|
1780
|
-
var UiBar = class extends HudWidgetBase {
|
|
1781
|
-
static typeName = "UiBar";
|
|
1782
|
-
static props = {
|
|
1783
|
-
...HudWidgetBase.props,
|
|
1784
|
-
value: { default: 100 },
|
|
1785
|
-
max: { default: 100 },
|
|
1786
|
-
width: { default: 180 },
|
|
1787
|
-
height: { default: 14 },
|
|
1788
|
-
color: { default: "#4ade80" },
|
|
1789
|
-
lowColor: { default: "#ef4444" },
|
|
1790
|
-
lowThreshold: { default: .3 },
|
|
1791
|
-
background: { default: "rgba(0,0,0,0.5)" },
|
|
1792
|
-
label: { default: "" }
|
|
1793
|
-
};
|
|
1794
|
-
value = 100;
|
|
1795
|
-
max = 100;
|
|
1796
|
-
width = 180;
|
|
1797
|
-
height = 14;
|
|
1798
|
-
color = "#4ade80";
|
|
1799
|
-
lowColor = "#ef4444";
|
|
1800
|
-
lowThreshold = .3;
|
|
1801
|
-
background = "rgba(0,0,0,0.5)";
|
|
1802
|
-
label = "";
|
|
1803
|
-
fill = null;
|
|
1804
|
-
tag = null;
|
|
1805
|
-
lastLabel = "\0";
|
|
1806
|
-
lastRatio = -1;
|
|
1807
|
-
/** Current fill ratio 0..1 (what the bar shows). */
|
|
1808
|
-
get ratio() {
|
|
1809
|
-
return this.max > 0 ? Math.min(1, Math.max(0, this.value / this.max)) : 0;
|
|
1810
|
-
}
|
|
1811
|
-
/**
|
|
1812
|
-
* Drive the bar from a signal — `healthChanged → setValue`.
|
|
1813
|
-
*
|
|
1814
|
-
* Takes the max as a second argument because that is the shape the signal
|
|
1815
|
-
* already has, so a bar whose ceiling moves (a max-HP upgrade) needs no
|
|
1816
|
-
* second wire.
|
|
1817
|
-
*/
|
|
1818
|
-
setValue(value, max) {
|
|
1819
|
-
this.value = requireNumber(value, this, "setValue");
|
|
1820
|
-
if (max !== void 0) this.max = requireNumber(max, this, "setValue");
|
|
1821
|
-
}
|
|
1822
|
-
/** Move the ceiling on its own, leaving the fill where it is. */
|
|
1823
|
-
setMax(max) {
|
|
1824
|
-
this.max = requireNumber(max, this, "setMax");
|
|
1825
|
-
}
|
|
1826
|
-
_build() {
|
|
1827
|
-
const wrap = document.createElement("div");
|
|
1828
|
-
wrap.style.cssText = "display:flex;align-items:center;gap:6px;";
|
|
1829
|
-
const tag = document.createElement("span");
|
|
1830
|
-
tag.style.cssText = "color:#fff;font-size:12px;text-shadow:0 1px 3px rgba(0,0,0,.7);";
|
|
1831
|
-
this.tag = tag;
|
|
1832
|
-
wrap.appendChild(tag);
|
|
1833
|
-
this.track = document.createElement("div");
|
|
1834
|
-
this.track.style.cssText = this.trackLook();
|
|
1835
|
-
this.lastTrackLook = this.trackLook();
|
|
1836
|
-
this.fill = document.createElement("div");
|
|
1837
|
-
this.fill.style.cssText = `height:100%;width:100%;background:${this.color};border-radius:inherit;transition:width .15s;`;
|
|
1838
|
-
this.track.appendChild(this.fill);
|
|
1839
|
-
wrap.appendChild(this.track);
|
|
1840
|
-
return wrap;
|
|
1841
|
-
}
|
|
1842
|
-
track = null;
|
|
1843
|
-
lastTrackLook = "";
|
|
1844
|
-
lastFillColor = "";
|
|
1845
|
-
/** The track's whole appearance, so re-applying is one comparison. */
|
|
1846
|
-
trackLook() {
|
|
1847
|
-
return `width:${this.width}px;height:${this.height}px;background:${this.background};border-radius:${this.height / 2}px;overflow:hidden;`;
|
|
1848
|
-
}
|
|
1849
|
-
_sync() {
|
|
1850
|
-
const label = this._t(this.label);
|
|
1851
|
-
if (label !== this.lastLabel && this.tag) {
|
|
1852
|
-
this.lastLabel = label;
|
|
1853
|
-
this.tag.textContent = label;
|
|
1854
|
-
this.tag.style.display = label ? "" : "none";
|
|
1855
|
-
}
|
|
1856
|
-
const look = this.trackLook();
|
|
1857
|
-
if (look !== this.lastTrackLook && this.track) {
|
|
1858
|
-
this.lastTrackLook = look;
|
|
1859
|
-
this.track.style.cssText = look;
|
|
1860
|
-
}
|
|
1861
|
-
const r = this.ratio;
|
|
1862
|
-
if (!this.fill) return;
|
|
1863
|
-
const colour = r <= this.lowThreshold ? this.lowColor : this.color;
|
|
1864
|
-
if (colour !== this.lastFillColor) {
|
|
1865
|
-
this.lastFillColor = colour;
|
|
1866
|
-
this.fill.style.background = colour;
|
|
1867
|
-
}
|
|
1868
|
-
if (r === this.lastRatio) return;
|
|
1869
|
-
this.lastRatio = r;
|
|
1870
|
-
this.fill.style.width = `${r * 100}%`;
|
|
1871
|
-
}
|
|
1872
|
-
};
|
|
1873
|
-
/**
|
|
1874
|
-
* Center-screen announcements ("WAVE 2", "YOU DIED", "LEVEL UP") with fade
|
|
1875
|
-
* in/out and a queue — call `.show(text, { color, seconds })`; `sticky:true`
|
|
1876
|
-
* (seconds: 0) keeps it until the next show(). Emits `bannerShown(text)`.
|
|
1877
|
-
*/
|
|
1878
|
-
var UiBanner = class extends HudWidgetBase {
|
|
1879
|
-
static typeName = "UiBanner";
|
|
1880
|
-
static signals = ["bannerShown"];
|
|
1881
|
-
static props = {
|
|
1882
|
-
...HudWidgetBase.props,
|
|
1883
|
-
anchor: {
|
|
1884
|
-
default: "center",
|
|
1885
|
-
options: [
|
|
1886
|
-
"topLeft",
|
|
1887
|
-
"top",
|
|
1888
|
-
"topRight",
|
|
1889
|
-
"left",
|
|
1890
|
-
"center",
|
|
1891
|
-
"right",
|
|
1892
|
-
"bottomLeft",
|
|
1893
|
-
"bottom",
|
|
1894
|
-
"bottomRight"
|
|
1895
|
-
]
|
|
1896
|
-
},
|
|
1897
|
-
size: { default: 42 },
|
|
1898
|
-
seconds: { default: 2 }
|
|
1899
|
-
};
|
|
1900
|
-
anchor = "center";
|
|
1901
|
-
size = 42;
|
|
1902
|
-
/** Default display time (per-show override via options). */
|
|
1903
|
-
seconds = 2;
|
|
1904
|
-
queue = [];
|
|
1905
|
-
current = null;
|
|
1906
|
-
remaining = 0;
|
|
1907
|
-
/** What the banner is showing right now ('' when idle) — test-friendly. */
|
|
1908
|
-
get showing() {
|
|
1909
|
-
return this.current?.text ?? "";
|
|
1910
|
-
}
|
|
1911
|
-
show(text, opts) {
|
|
1912
|
-
this.queue.push({
|
|
1913
|
-
text,
|
|
1914
|
-
source: text,
|
|
1915
|
-
color: opts?.color ?? "#ffffff",
|
|
1916
|
-
seconds: opts?.seconds ?? this.seconds
|
|
1917
|
-
});
|
|
1918
|
-
}
|
|
1919
|
-
/** Drop everything (scene transitions). */
|
|
1920
|
-
clear() {
|
|
1921
|
-
this.queue.length = 0;
|
|
1922
|
-
this.current = null;
|
|
1923
|
-
this.remaining = 0;
|
|
1924
|
-
if (this._element) this._element.style.opacity = "0";
|
|
1925
|
-
}
|
|
1926
|
-
_build() {
|
|
1927
|
-
const el = document.createElement("div");
|
|
1928
|
-
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;`;
|
|
1929
|
-
return el;
|
|
1930
|
-
}
|
|
1931
|
-
/** The whole look, so re-applying is one comparison. `size` was baked in at
|
|
1932
|
-
* build, and a banner could never be resized. */
|
|
1933
|
-
look() {
|
|
1934
|
-
return `font-size:${this.size}px;font-weight:800;color:#fff;text-shadow:0 2px 12px rgba(0,0,0,.8);transition:opacity .25s;text-align:center;`;
|
|
1935
|
-
}
|
|
1936
|
-
lastLook = "";
|
|
1937
|
-
update(dt) {
|
|
1938
|
-
super.update(dt);
|
|
1939
|
-
const look = this.look();
|
|
1940
|
-
if (look !== this.lastLook && this._element) {
|
|
1941
|
-
this.lastLook = look;
|
|
1942
|
-
const opacity = this._element.style.opacity;
|
|
1943
|
-
this._element.style.cssText = look;
|
|
1944
|
-
this._element.style.opacity = opacity;
|
|
1945
|
-
}
|
|
1946
|
-
if (this.current) {
|
|
1947
|
-
const next = this._t(this.current.source);
|
|
1948
|
-
if (next !== this.current.text) {
|
|
1949
|
-
this.current.text = next;
|
|
1950
|
-
if (this._element) this._element.textContent = next;
|
|
1951
|
-
}
|
|
1952
|
-
if (this.current.seconds > 0) {
|
|
1953
|
-
this.remaining -= dt;
|
|
1954
|
-
if (this.remaining <= 0) {
|
|
1955
|
-
this.current = null;
|
|
1956
|
-
if (this._element) this._element.style.opacity = "0";
|
|
1957
|
-
}
|
|
1958
|
-
} else if (this.queue.length > 0) this.current = null;
|
|
1959
|
-
}
|
|
1960
|
-
if (!this.current && this.queue.length > 0) {
|
|
1961
|
-
this.current = this.queue.shift();
|
|
1962
|
-
this.current.text = this._t(this.current.source);
|
|
1963
|
-
this.remaining = this.current.seconds;
|
|
1964
|
-
if (this._element) {
|
|
1965
|
-
this._element.textContent = this.current.text;
|
|
1966
|
-
this._element.style.color = this.current.color;
|
|
1967
|
-
this._element.style.opacity = "1";
|
|
1968
|
-
}
|
|
1969
|
-
this.emit("bannerShown", this.current.text);
|
|
1970
|
-
}
|
|
1971
|
-
}
|
|
1972
|
-
};
|
|
1973
|
-
//#endregion
|
|
1974
1338
|
//#region src/core/effects-log.ts
|
|
1975
1339
|
const EFFECT_LOG_LIMIT = 200;
|
|
1976
1340
|
var EffectLog = class {
|
|
1977
1341
|
events = [];
|
|
1342
|
+
/**
|
|
1343
|
+
* EVERY node that has fired, ever — a set of paths, not a window. See
|
|
1344
|
+
* `AudioBuses.sources`: 200 entries is a log, and "is this emitter wired?"
|
|
1345
|
+
* needs the whole run.
|
|
1346
|
+
*
|
|
1347
|
+
* AUTHORED paths, so it is bounded by the scene: a wave of forty enemies is
|
|
1348
|
+
* one emitter fired forty times. See `Node.wiringPath`.
|
|
1349
|
+
*/
|
|
1350
|
+
sourcePaths = /* @__PURE__ */ new Set();
|
|
1978
1351
|
/** @internal Called by the effects themselves; games read `recent()`. */
|
|
1979
1352
|
record(event) {
|
|
1980
1353
|
this.events.push(event);
|
|
1981
1354
|
if (this.events.length > EFFECT_LOG_LIMIT) this.events.shift();
|
|
1355
|
+
this.sourcePaths.add(event.wiredAs ?? event.from);
|
|
1356
|
+
}
|
|
1357
|
+
/** Every node path that has fired an effect since the last `clearLog()`. */
|
|
1358
|
+
sources() {
|
|
1359
|
+
return [...this.sourcePaths];
|
|
1982
1360
|
}
|
|
1983
1361
|
/** The last `limit` effects, oldest first. */
|
|
1984
1362
|
recent(limit = EFFECT_LOG_LIMIT) {
|
|
@@ -1995,6 +1373,7 @@ var EffectLog = class {
|
|
|
1995
1373
|
/** Forget what has fired so far (between scenes, or between assertions). */
|
|
1996
1374
|
clearLog() {
|
|
1997
1375
|
this.events.length = 0;
|
|
1376
|
+
this.sourcePaths.clear();
|
|
1998
1377
|
}
|
|
1999
1378
|
};
|
|
2000
1379
|
//#endregion
|
|
@@ -2406,6 +1785,28 @@ function qualityRendering(tier) {
|
|
|
2406
1785
|
//#region src/core/engine.ts
|
|
2407
1786
|
var engine_exports = /* @__PURE__ */ __exportAll({ Engine: () => Engine });
|
|
2408
1787
|
const MAX_DT_SECONDS = .25;
|
|
1788
|
+
/**
|
|
1789
|
+
* The save API, called before the engine has a scene — which is what `onReady`
|
|
1790
|
+
* is.
|
|
1791
|
+
*
|
|
1792
|
+
* `loadScene(json, { engine })` attaches the engine to the TREE so a behavior
|
|
1793
|
+
* can use `this.engine` in `onReady`, and the ready pass runs there — before
|
|
1794
|
+
* `setScene` assigns `engine.scene`. So the natural place to put "continue my
|
|
1795
|
+
* save", the root behavior's own `onReady`, sees `scene === null`, and both
|
|
1796
|
+
* halves of the save API used to answer without a word:
|
|
1797
|
+
*
|
|
1798
|
+
* ```
|
|
1799
|
+
* captureState() {} <- an empty slot, written over a good one
|
|
1800
|
+
* restoreState(saved) missing: [every uid], expected: 0
|
|
1801
|
+
* ```
|
|
1802
|
+
*
|
|
1803
|
+
* The second is worse than silence: `missing` is documented as "a save naming a
|
|
1804
|
+
* uid this build deleted", so an idle game whose continue quietly did nothing
|
|
1805
|
+
* reported the shape of a save format change. Measured on a game built from the
|
|
1806
|
+
* tarball for exactly this — every number came back zero and every check was
|
|
1807
|
+
* green.
|
|
1808
|
+
*/
|
|
1809
|
+
const NO_SCENE_YET = (what, consequence) => `[incanto] ${what}() ran before the engine had a scene, and ${consequence}. \`onReady\` fires while the scene is still being loaded — \`engine.scene\` is not assigned until \`setScene\`. Save and restore AFTER the game boots: from the \`sceneChanged\` signal, or after \`createGame2D\`/\`createGame3D\` resolves.`;
|
|
2409
1810
|
function rafScheduler(cb) {
|
|
2410
1811
|
let live = true;
|
|
2411
1812
|
let id = requestAnimationFrame(function loop(t) {
|
|
@@ -2495,6 +1896,50 @@ var Engine = class {
|
|
|
2495
1896
|
});
|
|
2496
1897
|
return this.ensuring;
|
|
2497
1898
|
}
|
|
1899
|
+
/**
|
|
1900
|
+
* The seed this engine was built with — game logic draws from `rng`, and
|
|
1901
|
+
* anything DECORATIVE derives its own stream from this instead.
|
|
1902
|
+
*
|
|
1903
|
+
* A particle emitter used to draw from `rng`, and it spawns a number of
|
|
1904
|
+
* particles that depends on `dt`. Measured on the same scene and seed, the
|
|
1905
|
+
* same simulated two seconds, comparing the world's own draws by update
|
|
1906
|
+
* index:
|
|
1907
|
+
*
|
|
1908
|
+
* ```
|
|
1909
|
+
* particles off: 60fps and 30fps identical
|
|
1910
|
+
* particles ON : they diverge at update 2
|
|
1911
|
+
* ```
|
|
1912
|
+
*
|
|
1913
|
+
* So a purely visual effect changed the game's random decisions, and the
|
|
1914
|
+
* PLAYER'S FRAME RATE decided them. Derived streams stay reproducible for
|
|
1915
|
+
* replays and screenshots without spending the one the game reads.
|
|
1916
|
+
*/
|
|
1917
|
+
seed;
|
|
1918
|
+
/**
|
|
1919
|
+
* How to resolve an `instance:` sub-scene, remembered from the boot load.
|
|
1920
|
+
*
|
|
1921
|
+
* `loadScene(json, { engine, resolveScene })` records it here, so the paths
|
|
1922
|
+
* that RELOAD — `restartScene`, `goToScene`, a game-over restart key — can
|
|
1923
|
+
* pass it back. Without that a game using the documented sub-scenes booted
|
|
1924
|
+
* fine and threw `UNRESOLVED_INSTANCE` on its first restart.
|
|
1925
|
+
*/
|
|
1926
|
+
resolveScene;
|
|
1927
|
+
/**
|
|
1928
|
+
* How the current scene was LOADED, for the rebuilds that happen later.
|
|
1929
|
+
*
|
|
1930
|
+
* `duplicateNode` — every `Spawner`, every `NetworkSpawner`, every clone a
|
|
1931
|
+
* game makes — rebuilds a subtree from scene JSON long after `loadScene`
|
|
1932
|
+
* returned, and it did so with no options at all. Under
|
|
1933
|
+
* `stubMissingBehaviors` (how EVERY headless check runs a game whose scripts
|
|
1934
|
+
* are TypeScript it cannot import) the first spawn met the real registry and
|
|
1935
|
+
* threw `UNKNOWN_BEHAVIOR` out of `update`, disabling the spawner for the
|
|
1936
|
+
* rest of the run — a wave game reported with no waves and nothing saying
|
|
1937
|
+
* why.
|
|
1938
|
+
*
|
|
1939
|
+
* Recorded here for the same reason as `resolveScene` above: the engine
|
|
1940
|
+
* outlives the call, and the rebuild paths can reach it.
|
|
1941
|
+
*/
|
|
1942
|
+
loadOptions = {};
|
|
2498
1943
|
/** Seeded randomness for game logic (deterministic when `seed` is set). */
|
|
2499
1944
|
rng;
|
|
2500
1945
|
/** The engine log channel (debug overlay + headless harness tail this). */
|
|
@@ -2509,6 +1954,57 @@ var Engine = class {
|
|
|
2509
1954
|
* and cleared on dispose; null when nothing renders (headless).
|
|
2510
1955
|
*/
|
|
2511
1956
|
picker = null;
|
|
1957
|
+
/**
|
|
1958
|
+
* Screen pixel → a point in the WORLD, when a renderer has offered one.
|
|
1959
|
+
*
|
|
1960
|
+
* `picker` answers "which node"; this answers "which place", and a mouse game
|
|
1961
|
+
* needs both — drag-and-launch, drop a tower on the map, steer a cursor unit,
|
|
1962
|
+
* draw the slingshot band. The conversion has always existed
|
|
1963
|
+
* (`Renderer2D.worldFromScreen` / `Renderer3D.worldFromScreen`) and lived
|
|
1964
|
+
* only on the renderer, so a Behavior — which holds a node and an engine and
|
|
1965
|
+
* nothing else — could not call it. Its two workarounds were both forbidden:
|
|
1966
|
+
* stash the renderer in a module global, or hand-roll the projection the 2D
|
|
1967
|
+
* skill says in so many words not to hand-roll.
|
|
1968
|
+
*
|
|
1969
|
+
* The answer is the scene's own units and shape: `[x, y]` in 2D (design
|
|
1970
|
+
* pixels), `[x, y, z]` on the ground plane in 3D — the same array a node's
|
|
1971
|
+
* `position` is, so it goes straight back into one.
|
|
1972
|
+
*
|
|
1973
|
+
* Instance-scoped, set by the renderer on construction and cleared on
|
|
1974
|
+
* dispose. Headless a harness may install a geometric one; null when nothing
|
|
1975
|
+
* has.
|
|
1976
|
+
*/
|
|
1977
|
+
toWorld = null;
|
|
1978
|
+
/**
|
|
1979
|
+
* A world point → the screen pixel drawing it. The inverse of `toWorld`, for
|
|
1980
|
+
* pinning DOM to a node or placing a marker over one.
|
|
1981
|
+
*
|
|
1982
|
+
* `behind` is true when the point is behind the camera (3D); a 2D renderer
|
|
1983
|
+
* always reports false.
|
|
1984
|
+
*/
|
|
1985
|
+
toScreen = null;
|
|
1986
|
+
/**
|
|
1987
|
+
* Where the cursor is in the world, or null.
|
|
1988
|
+
*
|
|
1989
|
+
* The two-line dance every mouse game writes — read the pointer, convert it —
|
|
1990
|
+
* with the two ways it goes wrong already handled: a pointer nothing has
|
|
1991
|
+
* moved yet is null rather than the origin, and no converter is null rather
|
|
1992
|
+
* than a guess.
|
|
1993
|
+
*/
|
|
1994
|
+
/**
|
|
1995
|
+
* Screen pixel → a ray into the world, when a renderer has offered one.
|
|
1996
|
+
*
|
|
1997
|
+
* `toWorld` lands on a plane; this is the general form, and it is what a shot
|
|
1998
|
+
* aimed at what the cursor is OVER needs: feed it straight to
|
|
1999
|
+
* `engine.physics.castRay(origin, dir, …)`. 3D only — a 2D renderer installs
|
|
2000
|
+
* nothing here, since a 2D world has no depth to cast into.
|
|
2001
|
+
*/
|
|
2002
|
+
screenRay = null;
|
|
2003
|
+
pointerWorld() {
|
|
2004
|
+
const at = this.input.pointerPosition();
|
|
2005
|
+
if (!at || !this.toWorld) return null;
|
|
2006
|
+
return this.toWorld(at.x, at.y);
|
|
2007
|
+
}
|
|
2512
2008
|
/** Frames the engine has stepped — monotonic, and the pick cache's key. */
|
|
2513
2009
|
frameNumber = 0;
|
|
2514
2010
|
/** @internal frame number + answer, so N Clickables cost ONE raycast. */
|
|
@@ -2597,7 +2093,35 @@ var Engine = class {
|
|
|
2597
2093
|
* (hit-stop / pause). Scales BOTH variable and fixed updates — physics,
|
|
2598
2094
|
* timers, behaviors all breathe together. See `gameplay` `hitStop()`.
|
|
2599
2095
|
*/
|
|
2600
|
-
timeScale
|
|
2096
|
+
get timeScale() {
|
|
2097
|
+
return this._timeScale;
|
|
2098
|
+
}
|
|
2099
|
+
set timeScale(v) {
|
|
2100
|
+
if (!Number.isFinite(v)) {
|
|
2101
|
+
diagnose(this, "warn", `[incanto] timeScale must be a finite number, got ${v} — keeping ${this._timeScale}.`);
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
this._timeScale = v;
|
|
2105
|
+
this._timeScaleWrites += 1;
|
|
2106
|
+
}
|
|
2107
|
+
_timeScale = 1;
|
|
2108
|
+
_timeScaleWrites = 0;
|
|
2109
|
+
/**
|
|
2110
|
+
* How many times anyone has written `timeScale` — the clock's ownership tag.
|
|
2111
|
+
*
|
|
2112
|
+
* A temporary freeze cannot tell its own 0 from somebody else's by VALUE.
|
|
2113
|
+
* `hitStop(engine)` then `flow.gameOver()` is the ordinary killing blow, and
|
|
2114
|
+
* both set 0; the freeze then wrote its captured scale back five frames later
|
|
2115
|
+
* and the world started moving again behind a sticky GAME OVER banner —
|
|
2116
|
+
* enemies walking, timers running, the player watching their corpse get shot.
|
|
2117
|
+
*
|
|
2118
|
+
* A counter answers the question the value cannot: *has anyone touched this
|
|
2119
|
+
* since I did?* Cheap (one increment per write, and writes are rare) and it
|
|
2120
|
+
* needs no registry of who holds what.
|
|
2121
|
+
*/
|
|
2122
|
+
get timeScaleWrites() {
|
|
2123
|
+
return this._timeScaleWrites;
|
|
2124
|
+
}
|
|
2601
2125
|
/**
|
|
2602
2126
|
* The node the dev overlay has selected (renderers draw its bounding box
|
|
2603
2127
|
* as an orange outline in the game view so you can SEE what you picked).
|
|
@@ -2620,11 +2144,39 @@ var Engine = class {
|
|
|
2620
2144
|
get unscaledTime() {
|
|
2621
2145
|
return this._unscaledTime;
|
|
2622
2146
|
}
|
|
2147
|
+
/**
|
|
2148
|
+
* THIS frame's dt in real seconds — the one a paused game still gets.
|
|
2149
|
+
*
|
|
2150
|
+
* `update(dt)` receives the SCALED dt, which is right for anything that is
|
|
2151
|
+
* part of the simulation and wrong for everything that is a presentation of
|
|
2152
|
+
* it. At `timeScale = 0` — which is what `GameFlow.pause()` and, by default,
|
|
2153
|
+
* `gameOver()` set — a scaled countdown does not count:
|
|
2154
|
+
*
|
|
2155
|
+
* a timed UiBanner still up when the player dies never expires, so the
|
|
2156
|
+
* flow's own sticky GAME OVER banner is queued behind it and NEVER
|
|
2157
|
+
* PAINTED — the ending screen shows the last wave toast, forever
|
|
2158
|
+
* a live CameraShake never settles: `t -= dt` is a no-op while the rest of
|
|
2159
|
+
* update() re-rolls a full-magnitude offset every rendered frame, and
|
|
2160
|
+
* with FollowCamera's smoothing that is an unbounded random walk
|
|
2161
|
+
* a remote player's interpolation stops, so everyone else is a statue while
|
|
2162
|
+
* their real positions keep arriving
|
|
2163
|
+
*
|
|
2164
|
+
* `hitStop`, `AudioPlayer` and `music.tick` already use the real clock for
|
|
2165
|
+
* exactly this reason. This is the same clock, per frame, so a countdown can
|
|
2166
|
+
* use it without reading `unscaledTime` twice and subtracting.
|
|
2167
|
+
*/
|
|
2168
|
+
get unscaledDelta() {
|
|
2169
|
+
return this._unscaledDelta;
|
|
2170
|
+
}
|
|
2171
|
+
_unscaledDelta = 0;
|
|
2623
2172
|
constructor(opts = {}) {
|
|
2624
|
-
|
|
2173
|
+
const hz = opts.fixedHz ?? 60;
|
|
2174
|
+
if (!Number.isFinite(hz) || hz <= 0) throw new IncantoError("PROP_TYPE_MISMATCH", `fixedHz must be a finite positive rate in Hz, got ${hz}. 60 is the default; 30 halves physics cost.`);
|
|
2175
|
+
this.fixedStep = 1 / hz;
|
|
2625
2176
|
this.maxFixedSteps = opts.maxFixedStepsPerTick ?? 5;
|
|
2626
2177
|
this.scheduler = opts.scheduler ?? rafScheduler;
|
|
2627
|
-
this.
|
|
2178
|
+
this.seed = opts.seed ?? Math.random() * 4294967295 >>> 0;
|
|
2179
|
+
this.rng = new Rng(this.seed);
|
|
2628
2180
|
this.maxFps = opts.maxFps ?? 0;
|
|
2629
2181
|
this.settings = opts.settings instanceof Settings ? opts.settings : new Settings(opts.settings?.namespace ?? "settings");
|
|
2630
2182
|
this.sfx._setBuses(this.audio);
|
|
@@ -2638,17 +2190,30 @@ var Engine = class {
|
|
|
2638
2190
|
/** Replace the active scene. The previous scene's root is freed. */
|
|
2639
2191
|
setScene(scene) {
|
|
2640
2192
|
if (scene === this._scene) return;
|
|
2641
|
-
this._scene
|
|
2642
|
-
this.
|
|
2193
|
+
const leaving = this._scene;
|
|
2194
|
+
if (this.swapping) {
|
|
2195
|
+
diagnose(this, "error", "[incanto] setScene() was called from inside a scene teardown — ignoring the inner call. Free a scene once; wire the next one from `sceneChanged` instead.");
|
|
2196
|
+
return;
|
|
2197
|
+
}
|
|
2198
|
+
this.swapping = true;
|
|
2199
|
+
try {
|
|
2200
|
+
leaving?.root.free();
|
|
2201
|
+
leaving?.tree._setEngine(null);
|
|
2202
|
+
} finally {
|
|
2203
|
+
this.swapping = false;
|
|
2204
|
+
}
|
|
2643
2205
|
this._scene = scene;
|
|
2644
2206
|
scene.tree._setEngine(this);
|
|
2645
2207
|
this.input.clear();
|
|
2646
2208
|
this._time = 0;
|
|
2647
2209
|
this._unscaledTime = 0;
|
|
2648
2210
|
this.timeScale = 1;
|
|
2211
|
+
this.accumulator = 0;
|
|
2649
2212
|
if (scene.input) this.input.declare(scene.input);
|
|
2650
2213
|
if (scene.strings) this.locale.load(scene.strings);
|
|
2651
2214
|
this.orderGroups = resolveOrderGroups(scene.orderGroups);
|
|
2215
|
+
this.audio.clearLog();
|
|
2216
|
+
this.effects.clearLog();
|
|
2652
2217
|
this.sceneChanged.emit(scene);
|
|
2653
2218
|
}
|
|
2654
2219
|
start() {
|
|
@@ -2725,6 +2290,29 @@ var Engine = class {
|
|
|
2725
2290
|
_nodeErrored(_node) {
|
|
2726
2291
|
this.errorCount += 1;
|
|
2727
2292
|
}
|
|
2293
|
+
/**
|
|
2294
|
+
* @internal Something OUTSIDE a frame failed — a replication send that
|
|
2295
|
+
* rejected, a transport that refused. Count it and say it once.
|
|
2296
|
+
*
|
|
2297
|
+
* `stats().errors` is what every harness in the ecosystem ends with, and it
|
|
2298
|
+
* only ever counted things that threw INSIDE a frame. A multiplayer client
|
|
2299
|
+
* whose socket died kept running perfectly on its own screen while every
|
|
2300
|
+
* other player watched it frozen, and `errors: 0` with an empty log said the
|
|
2301
|
+
* game was fine. Repeats are counted and not re-printed, for the reason
|
|
2302
|
+
* `reportFrameError` gives: sixty identical traces a second is how you lose
|
|
2303
|
+
* the first one.
|
|
2304
|
+
*/
|
|
2305
|
+
_asyncFailed(what, error) {
|
|
2306
|
+
this.errorCount += 1;
|
|
2307
|
+
const line = `${what}: ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`;
|
|
2308
|
+
if (line === this.lastAsyncError) return;
|
|
2309
|
+
this.lastAsyncError = line;
|
|
2310
|
+
this.log.error(`[incanto] ${line}`);
|
|
2311
|
+
console.error(`[incanto] ${line}`);
|
|
2312
|
+
}
|
|
2313
|
+
lastAsyncError = null;
|
|
2314
|
+
/** Re-entrancy guard for `setScene` — an exit hook can now reach the engine. */
|
|
2315
|
+
swapping = false;
|
|
2728
2316
|
errorCount = 0;
|
|
2729
2317
|
lastFrameError = null;
|
|
2730
2318
|
/**
|
|
@@ -2750,25 +2338,51 @@ var Engine = class {
|
|
|
2750
2338
|
* core/save-slots.ts for why a save carries state rather than a tree
|
|
2751
2339
|
* snapshot.
|
|
2752
2340
|
*/
|
|
2341
|
+
/**
|
|
2342
|
+
* Behaviors whose `serialize()` threw during the LAST `captureState()`.
|
|
2343
|
+
*
|
|
2344
|
+
* A write path that cannot see part of the run must not overwrite the slot
|
|
2345
|
+
* that still can — `SavePoint.save()` reads this and refuses.
|
|
2346
|
+
*/
|
|
2347
|
+
get lastCaptureFailures() {
|
|
2348
|
+
return this._captureFailures;
|
|
2349
|
+
}
|
|
2350
|
+
_captureFailures = [];
|
|
2753
2351
|
captureState() {
|
|
2754
2352
|
const root = this.scene?.root;
|
|
2755
|
-
if (!root)
|
|
2756
|
-
|
|
2353
|
+
if (!root) {
|
|
2354
|
+
diagnose(this, "error", NO_SCENE_YET("captureState", "saved an EMPTY slot"));
|
|
2355
|
+
return {};
|
|
2356
|
+
}
|
|
2357
|
+
const { state, unaddressable, unrecordable, failed } = captureBehaviors(root, this.scene?.source, this.scene?.authoredUids);
|
|
2757
2358
|
for (const path of unaddressable) diagnose(this, "warn", `save: ${path} has state to save and no uid to key it under.`);
|
|
2359
|
+
this._captureFailures = failed;
|
|
2360
|
+
if (failed.length > 0) diagnose(this, "error", `[incanto] save: ${failed.length} behavior(s) threw out of serialize(), so their state is NOT in this save: ${failed.slice(0, 4).join("; ")}${failed.length > 4 ? `, +${failed.length - 4} more` : ""}.`);
|
|
2758
2361
|
if (unrecordable.length > 0) diagnose(this, "error", `[incanto] save: ${unrecordable.length} authored node(s) this run consumed have no uid, so nothing records that they are gone and LOADING THIS SAVE BRINGS THEM BACK: ${unrecordable.slice(0, 8).join(", ")}${unrecordable.length > 8 ? `, +${unrecordable.length - 8} more` : ""}. Give each one a "uid" from newUid().`);
|
|
2362
|
+
state[ENGINE_STATE_KEY] = { rng: this.rng.position };
|
|
2759
2363
|
return state;
|
|
2760
2364
|
}
|
|
2761
2365
|
/** Give it back, after the scene is loaded and onReady has run. */
|
|
2762
2366
|
restoreState(state) {
|
|
2367
|
+
const engineState = state[ENGINE_STATE_KEY];
|
|
2368
|
+
if (typeof engineState?.rng === "number") this.rng.position = engineState.rng;
|
|
2763
2369
|
const root = this.scene?.root;
|
|
2764
|
-
if (!root)
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2370
|
+
if (!root) {
|
|
2371
|
+
diagnose(this, "error", NO_SCENE_YET("restoreState", "restored NOTHING"));
|
|
2372
|
+
return {
|
|
2373
|
+
missing: Object.keys(state).filter((k) => k !== ENGINE_STATE_KEY),
|
|
2374
|
+
skipped: [],
|
|
2375
|
+
refused: [],
|
|
2376
|
+
unannounced: [],
|
|
2377
|
+
stale: [],
|
|
2378
|
+
restored: 0,
|
|
2379
|
+
expected: 0,
|
|
2380
|
+
freed: 0
|
|
2381
|
+
};
|
|
2382
|
+
}
|
|
2383
|
+
const report = restoreBehaviors(root, state, this.scene?.authoredUids);
|
|
2384
|
+
if (report.stale.length > 0) diagnose(this, "warn", `[incanto] restore: ${report.stale.length} authored node(s) are missing from this scene and the save does not say they were consumed — this scene has been PLAYED since it loaded, so the restore leaves THIS run's changes in place. Reload the scene first (GameFlow.restart with restoreOnReady, or restartScene(engine) then restore) — note the SavePoint node is a NEW one after a restart. Starting with '${report.stale[0]}'.`);
|
|
2385
|
+
return report;
|
|
2772
2386
|
}
|
|
2773
2387
|
erroredNodes() {
|
|
2774
2388
|
const out = [];
|
|
@@ -2843,13 +2457,24 @@ var Engine = class {
|
|
|
2843
2457
|
*/
|
|
2844
2458
|
dispose() {
|
|
2845
2459
|
this.stop();
|
|
2460
|
+
const physics = this.physics;
|
|
2461
|
+
this.physics = null;
|
|
2462
|
+
try {
|
|
2463
|
+
physics?.dispose();
|
|
2464
|
+
} catch (error) {
|
|
2465
|
+
diagnose(this, "warn", "[incanto] teardown: physics failed (continuing)", error);
|
|
2466
|
+
}
|
|
2846
2467
|
this.music.dispose();
|
|
2847
2468
|
this.sfx.dispose();
|
|
2848
2469
|
if (this._scene) {
|
|
2849
|
-
this._scene.tree._setEngine(null);
|
|
2850
2470
|
this._scene.root.free();
|
|
2471
|
+
this._scene.tree._setEngine(null);
|
|
2851
2472
|
this._scene = null;
|
|
2852
|
-
|
|
2473
|
+
try {
|
|
2474
|
+
this.sceneChanged.emit(null);
|
|
2475
|
+
} catch (error) {
|
|
2476
|
+
diagnose(this, "warn", "[incanto] teardown: a `sceneChanged` listener threw (continuing) — it fires with NULL here, so a handler that reads the scene has to check for it", error);
|
|
2477
|
+
}
|
|
2853
2478
|
}
|
|
2854
2479
|
this.input.dispose();
|
|
2855
2480
|
this.input.clear();
|
|
@@ -2870,14 +2495,18 @@ var Engine = class {
|
|
|
2870
2495
|
const dt = this.fixedStep * (opts.ignorePause ? 1 : this.timeScale);
|
|
2871
2496
|
this._time += dt;
|
|
2872
2497
|
this._unscaledTime += this.fixedStep;
|
|
2498
|
+
this._unscaledDelta = this.fixedStep;
|
|
2873
2499
|
const startedFixed = performance.now();
|
|
2874
2500
|
this.input.beginFrame();
|
|
2501
|
+
this.input.beginFixedStep();
|
|
2875
2502
|
scene.tree.fixedUpdate(dt);
|
|
2876
2503
|
this.fixedUpdated.emit(dt);
|
|
2504
|
+
this.input.endFixedStep();
|
|
2877
2505
|
const startedUpdate = performance.now();
|
|
2878
2506
|
this.frameNumber += 1;
|
|
2879
2507
|
scene.tree.update(dt);
|
|
2880
|
-
this.music.tick(
|
|
2508
|
+
this.music.tick(this.fixedStep);
|
|
2509
|
+
this.sfx._tick(this.fixedStep);
|
|
2881
2510
|
this.updated.emit(dt);
|
|
2882
2511
|
const ended = performance.now();
|
|
2883
2512
|
const updateMs = Math.max(0, ended - startedUpdate - this.renderMs);
|
|
@@ -2892,10 +2521,13 @@ var Engine = class {
|
|
|
2892
2521
|
this.lastMs = nowMs;
|
|
2893
2522
|
return;
|
|
2894
2523
|
}
|
|
2895
|
-
const
|
|
2524
|
+
const raw = (nowMs - this.lastMs) / 1e3;
|
|
2525
|
+
if (!Number.isFinite(raw)) diagnose(this, "warn", `[incanto] tick() was handed a non-finite timestamp (${nowMs}); this frame is skipped. A NaN here used to stop every fixed step for the life of the engine.`);
|
|
2526
|
+
const rawDt = Number.isFinite(raw) ? Math.max(0, Math.min(raw, MAX_DT_SECONDS)) : 0;
|
|
2896
2527
|
const dt = rawDt * this.timeScale;
|
|
2897
2528
|
this._time += dt;
|
|
2898
2529
|
this._unscaledTime += rawDt;
|
|
2530
|
+
this._unscaledDelta = rawDt;
|
|
2899
2531
|
this.lastMs = nowMs;
|
|
2900
2532
|
const scene = this._scene;
|
|
2901
2533
|
if (!scene) return;
|
|
@@ -2904,8 +2536,10 @@ var Engine = class {
|
|
|
2904
2536
|
let steps = 0;
|
|
2905
2537
|
const startedFixed = performance.now();
|
|
2906
2538
|
while (this.accumulator >= this.fixedStep && steps < this.maxFixedSteps) {
|
|
2539
|
+
this.input.beginFixedStep();
|
|
2907
2540
|
scene.tree.fixedUpdate(this.fixedStep);
|
|
2908
2541
|
this.fixedUpdated.emit(this.fixedStep);
|
|
2542
|
+
this.input.endFixedStep();
|
|
2909
2543
|
this.accumulator -= this.fixedStep;
|
|
2910
2544
|
steps += 1;
|
|
2911
2545
|
}
|
|
@@ -2913,7 +2547,8 @@ var Engine = class {
|
|
|
2913
2547
|
const startedUpdate = performance.now();
|
|
2914
2548
|
this.frameNumber += 1;
|
|
2915
2549
|
scene.tree.update(dt);
|
|
2916
|
-
this.music.tick(
|
|
2550
|
+
this.music.tick(rawDt);
|
|
2551
|
+
this.sfx._tick(rawDt);
|
|
2917
2552
|
this.updated.emit(dt);
|
|
2918
2553
|
const ended = performance.now();
|
|
2919
2554
|
const updateMs = Math.max(0, ended - startedUpdate - this.renderMs);
|
|
@@ -2952,12 +2587,24 @@ var AudioPlayer = class extends Node {
|
|
|
2952
2587
|
static typeName = "AudioPlayer";
|
|
2953
2588
|
static signals = ["finished"];
|
|
2954
2589
|
static props = {
|
|
2955
|
-
src: {
|
|
2590
|
+
src: {
|
|
2591
|
+
default: "",
|
|
2592
|
+
asset: {
|
|
2593
|
+
kind: "audio",
|
|
2594
|
+
urlOnly: true
|
|
2595
|
+
}
|
|
2596
|
+
},
|
|
2956
2597
|
preset: {
|
|
2957
2598
|
default: "custom",
|
|
2958
2599
|
options: ["custom", ...SFX_PRESET_NAMES]
|
|
2959
2600
|
},
|
|
2960
|
-
volume: {
|
|
2601
|
+
volume: {
|
|
2602
|
+
default: 1,
|
|
2603
|
+
range: {
|
|
2604
|
+
min: 0,
|
|
2605
|
+
max: 1
|
|
2606
|
+
}
|
|
2607
|
+
},
|
|
2961
2608
|
pitch: { default: 1 },
|
|
2962
2609
|
seed: { default: 0 },
|
|
2963
2610
|
bus: {
|
|
@@ -3071,6 +2718,7 @@ var AudioPlayer = class extends Node {
|
|
|
3071
2718
|
kind,
|
|
3072
2719
|
name,
|
|
3073
2720
|
from: this.getPath(),
|
|
2721
|
+
wiredAs: this.wiringPath(),
|
|
3074
2722
|
bus: this.bus === "music" ? "music" : "sfx",
|
|
3075
2723
|
at: engine.time,
|
|
3076
2724
|
...this.loop ? { loop: true } : {}
|
|
@@ -3323,7 +2971,7 @@ var UiButton = class extends HudWidgetBase {
|
|
|
3323
2971
|
/** The whole look, so re-applying is one comparison. `size`/`color` were baked
|
|
3324
2972
|
* in at build, and a button could never be restyled afterwards. */
|
|
3325
2973
|
look() {
|
|
3326
|
-
return
|
|
2974
|
+
return `${TAPPABLE}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;`;
|
|
3327
2975
|
}
|
|
3328
2976
|
lastLook = "";
|
|
3329
2977
|
_sync() {
|
|
@@ -3400,7 +3048,9 @@ var UiDialogue = class extends HudWidgetBase {
|
|
|
3400
3048
|
return this.current ? this.current.text.slice(0, Math.floor(this.revealed)) : "";
|
|
3401
3049
|
}
|
|
3402
3050
|
/** Queue a line; optional `choices` renders buttons after the line types out. */
|
|
3403
|
-
say(speaker, text, choices) {
|
|
3051
|
+
say(speaker, text, choices = void 0) {
|
|
3052
|
+
requireText(speaker, this, "UiDialogue.say (speaker)");
|
|
3053
|
+
requireText(text, this, "UiDialogue.say (text)");
|
|
3404
3054
|
const source = {
|
|
3405
3055
|
speaker,
|
|
3406
3056
|
text,
|
|
@@ -3431,6 +3081,16 @@ var UiDialogue = class extends HudWidgetBase {
|
|
|
3431
3081
|
* `dir` is 0 for "activate" and ±1 for a nudge, the same shape `UiSelect`
|
|
3432
3082
|
* uses — so one key layout drives a menu, a slider and a conversation.
|
|
3433
3083
|
*/
|
|
3084
|
+
/**
|
|
3085
|
+
* A click on the BOX is `advance()`, which refuses to skip a choice.
|
|
3086
|
+
*
|
|
3087
|
+
* The DOM handler is `box.addEventListener('click', () => this.advance())`
|
|
3088
|
+
* and `advance` returns early while `current.choices` is set. Enter is a
|
|
3089
|
+
* different verb — it answers — so a harness clicking the box must not.
|
|
3090
|
+
*/
|
|
3091
|
+
_click() {
|
|
3092
|
+
this.advance();
|
|
3093
|
+
}
|
|
3434
3094
|
_activate(dir) {
|
|
3435
3095
|
if (!this.active || !this.current) return;
|
|
3436
3096
|
const choices = this.current.choices;
|
|
@@ -3491,7 +3151,7 @@ var UiDialogue = class extends HudWidgetBase {
|
|
|
3491
3151
|
}
|
|
3492
3152
|
_build() {
|
|
3493
3153
|
const box = document.createElement("div");
|
|
3494
|
-
box.style.cssText =
|
|
3154
|
+
box.style.cssText = `${TAPPABLE}width:${this.width}px;${FITS}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;`;
|
|
3495
3155
|
this.speakerEl = document.createElement("div");
|
|
3496
3156
|
this.speakerEl.style.cssText = "font-weight:800;color:#ffd166;margin-bottom:4px;font-size:13px;";
|
|
3497
3157
|
this.textEl = document.createElement("div");
|
|
@@ -3545,7 +3205,7 @@ var UiDialogue = class extends HudWidgetBase {
|
|
|
3545
3205
|
const b = document.createElement("button");
|
|
3546
3206
|
b.type = "button";
|
|
3547
3207
|
b.textContent = label;
|
|
3548
|
-
b.style.cssText =
|
|
3208
|
+
b.style.cssText = `${TAPPABLE}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;`;
|
|
3549
3209
|
b.addEventListener("click", (e) => {
|
|
3550
3210
|
e.stopPropagation();
|
|
3551
3211
|
this.choose(i);
|
|
@@ -3729,14 +3389,27 @@ var Timer = class extends Node {
|
|
|
3729
3389
|
autostart = false;
|
|
3730
3390
|
running = false;
|
|
3731
3391
|
remaining = 0;
|
|
3732
|
-
start(time) {
|
|
3733
|
-
|
|
3392
|
+
start(time = this.waitTime) {
|
|
3393
|
+
this.waitTime = time;
|
|
3734
3394
|
this.remaining = this.waitTime;
|
|
3735
3395
|
this.running = true;
|
|
3736
3396
|
}
|
|
3737
3397
|
stop() {
|
|
3738
3398
|
this.running = false;
|
|
3739
3399
|
}
|
|
3400
|
+
/**
|
|
3401
|
+
* Scene-load validation: a period of zero is a timer that never fires.
|
|
3402
|
+
*
|
|
3403
|
+
* `update` guards it — a non-positive period "would spin forever on any dt",
|
|
3404
|
+
* so it calls `stop()` — and the guard is right. Being SILENT about it is
|
|
3405
|
+
* what a load error is for: a scene asking for a tick every zero seconds got
|
|
3406
|
+
* no ticks and no word said. The runtime guard stays, for code that zeroes
|
|
3407
|
+
* the period from a handler.
|
|
3408
|
+
*/
|
|
3409
|
+
static validateJson(node) {
|
|
3410
|
+
const timer = node;
|
|
3411
|
+
if (!(timer.waitTime > 0)) throw new IncantoError("PROP_TYPE_MISMATCH", `Timer '${node.name}': "waitTime" must be > 0, got ${timer.waitTime} — a timer with no period never fires (it stops itself on the first frame).`, { prop: "waitTime" });
|
|
3412
|
+
}
|
|
3740
3413
|
onReady() {
|
|
3741
3414
|
if (this.autostart) this.start();
|
|
3742
3415
|
}
|
|
@@ -3757,6 +3430,20 @@ var Timer = class extends Node {
|
|
|
3757
3430
|
};
|
|
3758
3431
|
//#endregion
|
|
3759
3432
|
//#region src/core/nodes/ui-kit.ts
|
|
3433
|
+
/**
|
|
3434
|
+
* The widgets a MENU is made of.
|
|
3435
|
+
*
|
|
3436
|
+
* The HUD kit could say things — text, a bar, a banner, a button — and could not
|
|
3437
|
+
* arrange them or take a value back. So every title screen, pause menu, options
|
|
3438
|
+
* panel, inventory grid and shop in every game built on this engine was
|
|
3439
|
+
* hand-rolled DOM: `star-survivor`'s level-up menu builds its own elements, which
|
|
3440
|
+
* drops it out of scene JSON, out of the editor, out of `incanto-check`, and out
|
|
3441
|
+
* of every headless verification the agent has.
|
|
3442
|
+
*
|
|
3443
|
+
* These are the missing pieces, and they are deliberately the SAME kind of thing
|
|
3444
|
+
* the existing widgets are — nodes in the tree, props in JSON, DOM built by the
|
|
3445
|
+
* engine — so an inventory is a scene, not a component library.
|
|
3446
|
+
*/
|
|
3760
3447
|
function px(n) {
|
|
3761
3448
|
return `${n}px`;
|
|
3762
3449
|
}
|
|
@@ -3826,7 +3513,7 @@ var UiPanel = class extends HudWidgetBase {
|
|
|
3826
3513
|
return this._element;
|
|
3827
3514
|
}
|
|
3828
3515
|
css() {
|
|
3829
|
-
return `${this.layout === "grid" ? `display:grid;grid-template-columns:repeat(${Math.max(1, this.columns)},auto);` : `display:flex;flex-direction:${this.layout};align-items:stretch;`}gap:${px(this.gap)};padding:${px(this.padding)};background:${this.background};border-radius:${px(this.radius)};` + (this.width > 0 ? `width:${px(this.width)};` : "") + (this.height > 0 ? `height:${px(this.height)};` : "") + (this.border ? `border:${this.border};` : "") +
|
|
3516
|
+
return `${this.layout === "grid" ? `display:grid;grid-template-columns:repeat(${Math.max(1, this.columns)},auto);` : `display:flex;flex-direction:${this.layout};align-items:stretch;`}gap:${px(this.gap)};padding:${px(this.padding)};background:${this.background};border-radius:${px(this.radius)};` + (this.width > 0 ? `width:${px(this.width)};` : "") + `${FITS}max-height:calc(100vh - 32px);overflow:auto;` + (this.height > 0 ? `height:${px(this.height)};` : "") + (this.border ? `border:${this.border};` : "") + `box-sizing:border-box;${TAPPABLE}`;
|
|
3830
3517
|
}
|
|
3831
3518
|
_sync() {
|
|
3832
3519
|
const next = this.css();
|
|
@@ -3858,7 +3545,13 @@ var UiImage = class extends HudWidgetBase {
|
|
|
3858
3545
|
},
|
|
3859
3546
|
/** Multiply the image by a colour: greyed-out items, team tints. */
|
|
3860
3547
|
tint: { default: "" },
|
|
3861
|
-
opacity: {
|
|
3548
|
+
opacity: {
|
|
3549
|
+
default: 1,
|
|
3550
|
+
range: {
|
|
3551
|
+
min: 0,
|
|
3552
|
+
max: 1
|
|
3553
|
+
}
|
|
3554
|
+
}
|
|
3862
3555
|
};
|
|
3863
3556
|
src = "";
|
|
3864
3557
|
width = 48;
|
|
@@ -3882,7 +3575,7 @@ var UiImage = class extends HudWidgetBase {
|
|
|
3882
3575
|
}
|
|
3883
3576
|
css() {
|
|
3884
3577
|
const url = this.resolved();
|
|
3885
|
-
return `width:${px(this.width)};height:${px(this.height)};flex:none
|
|
3578
|
+
return `width:${px(this.width)};height:${px(this.height)};flex:none;${FITS}` + (url ? `background-image:url("${url}");` : "") + `background-size:${this.fit === "fill" ? "100% 100%" : this.fit};background-position:center;background-repeat:no-repeat;opacity:${this.opacity};` + (this.tint ? `background-color:${this.tint};background-blend-mode:multiply;` : "");
|
|
3886
3579
|
}
|
|
3887
3580
|
_sync() {
|
|
3888
3581
|
const next = this.key();
|
|
@@ -3923,7 +3616,20 @@ var UiSlider = class extends HudWidgetBase {
|
|
|
3923
3616
|
/** Left/right nudge by one step — the only sane thing a d-pad can mean here. */
|
|
3924
3617
|
_activate(dir) {
|
|
3925
3618
|
if (dir === 0) return;
|
|
3926
|
-
|
|
3619
|
+
this.choose(this.value + dir * this.step);
|
|
3620
|
+
}
|
|
3621
|
+
/**
|
|
3622
|
+
* Move it as a PERSON would: set the value and say so.
|
|
3623
|
+
*
|
|
3624
|
+
* `UiButton` has `press()` and the value widgets had nothing, because
|
|
3625
|
+
* assigning `.value` deliberately does not emit (restoring a saved setting
|
|
3626
|
+
* must not fire the handler that saved it). So a harness — or a game driving
|
|
3627
|
+
* its own menu: a preset button, "reset to defaults", a tutorial that moves a
|
|
3628
|
+
* slider for you — had one route to "the player changed this", and it was
|
|
3629
|
+
* `_activate`, which is internal and takes a direction rather than a value.
|
|
3630
|
+
*/
|
|
3631
|
+
choose(value) {
|
|
3632
|
+
const next = Math.min(this.max, Math.max(this.min, value));
|
|
3927
3633
|
if (next === this.value) return;
|
|
3928
3634
|
this.value = next;
|
|
3929
3635
|
this.last = next;
|
|
@@ -3935,7 +3641,7 @@ var UiSlider = class extends HudWidgetBase {
|
|
|
3935
3641
|
last = NaN;
|
|
3936
3642
|
_build() {
|
|
3937
3643
|
const wrap = document.createElement("div");
|
|
3938
|
-
wrap.style.cssText =
|
|
3644
|
+
wrap.style.cssText = `display:flex;align-items:center;gap:8px;${TAPPABLE}font:600 13px system-ui,-apple-system,'Segoe UI',sans-serif;color:#fff;`;
|
|
3939
3645
|
const text = document.createElement("span");
|
|
3940
3646
|
text.textContent = this._labelText();
|
|
3941
3647
|
const input = document.createElement("input");
|
|
@@ -3944,7 +3650,7 @@ var UiSlider = class extends HudWidgetBase {
|
|
|
3944
3650
|
input.max = String(this.max);
|
|
3945
3651
|
input.step = String(this.step);
|
|
3946
3652
|
input.value = String(this.value);
|
|
3947
|
-
input.style.cssText = `width:${px(this.width)};accent-color:${this.color}
|
|
3653
|
+
input.style.cssText = `width:${px(this.width)};accent-color:${this.color};${FITS}`;
|
|
3948
3654
|
input.addEventListener("input", () => {
|
|
3949
3655
|
this.value = Number(input.value);
|
|
3950
3656
|
this.last = this.value;
|
|
@@ -3981,18 +3687,21 @@ var UiToggle = class extends HudWidgetBase {
|
|
|
3981
3687
|
focusable = true;
|
|
3982
3688
|
/** Confirm flips it; left/right set it explicitly, which reads better on a pad. */
|
|
3983
3689
|
_activate(dir) {
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
if (this.
|
|
3989
|
-
this.
|
|
3690
|
+
this.choose(dir === 0 ? !this.value : dir > 0);
|
|
3691
|
+
}
|
|
3692
|
+
/** Flip it as a PERSON would: set the value and say so. See `UiSlider.choose`. */
|
|
3693
|
+
choose(value) {
|
|
3694
|
+
if (value === this.value) return;
|
|
3695
|
+
this.value = value;
|
|
3696
|
+
this.last = value;
|
|
3697
|
+
if (this.input) this.input.checked = value;
|
|
3698
|
+
this.emit("changed", value);
|
|
3990
3699
|
}
|
|
3991
3700
|
input = null;
|
|
3992
3701
|
last = null;
|
|
3993
3702
|
_build() {
|
|
3994
3703
|
const wrap = document.createElement("label");
|
|
3995
|
-
wrap.style.cssText =
|
|
3704
|
+
wrap.style.cssText = `display:flex;align-items:center;gap:8px;${TAPPABLE}cursor:pointer;font:600 13px system-ui,-apple-system,'Segoe UI',sans-serif;color:#fff;`;
|
|
3996
3705
|
const input = document.createElement("input");
|
|
3997
3706
|
input.type = "checkbox";
|
|
3998
3707
|
input.checked = this.value;
|
|
@@ -4040,16 +3749,53 @@ var UiSelect = class extends HudWidgetBase {
|
|
|
4040
3749
|
options = "";
|
|
4041
3750
|
value = "";
|
|
4042
3751
|
focusable = true;
|
|
3752
|
+
/** The options, as the list of values it actually offers. */
|
|
3753
|
+
choices() {
|
|
3754
|
+
return this.options.split(",").map((o) => o.trim()).filter(Boolean);
|
|
3755
|
+
}
|
|
3756
|
+
/**
|
|
3757
|
+
* A value the list cannot show is a setting the game and the screen disagree
|
|
3758
|
+
* about.
|
|
3759
|
+
*
|
|
3760
|
+
* The browser cannot select an option that does not exist, so it shows the
|
|
3761
|
+
* FIRST one — while `node.value` keeps answering with what it was given, and
|
|
3762
|
+
* `changed` only fires when a person moves it, so nothing ever reconciles
|
|
3763
|
+
* them. That is the shape a saved setting has after a patch removes a quality
|
|
3764
|
+
* tier, and it is silent for the rest of the session.
|
|
3765
|
+
*
|
|
3766
|
+
* A hard error at LOAD, like every other enum in this engine. An empty value
|
|
3767
|
+
* is "nothing chosen yet", which is a choice.
|
|
3768
|
+
*/
|
|
3769
|
+
static validateJson(node) {
|
|
3770
|
+
const self = node;
|
|
3771
|
+
const choices = self.choices();
|
|
3772
|
+
if (self.value === "" || choices.length === 0 || choices.includes(self.value)) return;
|
|
3773
|
+
throw new IncantoError("PROP_TYPE_MISMATCH", `UiSelect '${node.name}' has value '${self.value}', which is not one of its options [${choices.join(", ")}] — the browser would show the first one instead, and the game would go on believing the value it was given.`, { prop: "value" });
|
|
3774
|
+
}
|
|
4043
3775
|
/** Left/right (and confirm) walk the list — a select has no other gesture. */
|
|
4044
3776
|
_activate(dir) {
|
|
4045
|
-
const choices = this.
|
|
3777
|
+
const choices = this.choices();
|
|
4046
3778
|
if (choices.length === 0) return;
|
|
4047
3779
|
const next = choices[(Math.max(0, choices.indexOf(this.value)) + (dir === 0 ? 1 : dir) + choices.length) % choices.length];
|
|
4048
|
-
if (
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
3780
|
+
if (next) this.choose(next);
|
|
3781
|
+
}
|
|
3782
|
+
/**
|
|
3783
|
+
* Pick an option as a PERSON would: set the value and say so. See
|
|
3784
|
+
* `UiSlider.choose`.
|
|
3785
|
+
*
|
|
3786
|
+
* An option the list does not offer is refused, for the reason
|
|
3787
|
+
* `validateJson` refuses it in the file: the browser would show the first one
|
|
3788
|
+
* while the node kept answering with what it was given, and nothing would
|
|
3789
|
+
* ever reconcile them.
|
|
3790
|
+
*/
|
|
3791
|
+
choose(value) {
|
|
3792
|
+
const choices = this.choices();
|
|
3793
|
+
if (choices.length > 0 && !choices.includes(value)) throw new IncantoError("PROP_TYPE_MISMATCH", `UiSelect '${this.name}': choose('${value}') is not one of its options [${choices.join(", ")}].`, { prop: "value" });
|
|
3794
|
+
if (value === this.value) return;
|
|
3795
|
+
this.value = value;
|
|
3796
|
+
this.last = value;
|
|
3797
|
+
if (this.select) this.select.value = value;
|
|
3798
|
+
this.emit("changed", value);
|
|
4053
3799
|
}
|
|
4054
3800
|
select = null;
|
|
4055
3801
|
labelEl = null;
|
|
@@ -4058,7 +3804,7 @@ var UiSelect = class extends HudWidgetBase {
|
|
|
4058
3804
|
last = "\0";
|
|
4059
3805
|
_build() {
|
|
4060
3806
|
const wrap = document.createElement("label");
|
|
4061
|
-
wrap.style.cssText =
|
|
3807
|
+
wrap.style.cssText = `display:flex;align-items:center;gap:8px;${TAPPABLE}font:600 13px system-ui,-apple-system,'Segoe UI',sans-serif;color:#fff;`;
|
|
4062
3808
|
const text = document.createElement("span");
|
|
4063
3809
|
this.labelEl = text;
|
|
4064
3810
|
const select = document.createElement("select");
|
|
@@ -4102,6 +3848,25 @@ var UiSelect = class extends HudWidgetBase {
|
|
|
4102
3848
|
if (this.value) select.value = this.value;
|
|
4103
3849
|
this.last = this.value;
|
|
4104
3850
|
}
|
|
3851
|
+
/** What was already reported, so a stuck value is said once and not per frame. */
|
|
3852
|
+
complainedAbout = "\0";
|
|
3853
|
+
/**
|
|
3854
|
+
* The runtime half of `validateJson`.
|
|
3855
|
+
*
|
|
3856
|
+
* A behaviour may legitimately write the value first and widen `options`
|
|
3857
|
+
* after — so this reports rather than refuses, and goes quiet again when the
|
|
3858
|
+
* list catches up.
|
|
3859
|
+
*/
|
|
3860
|
+
reportMismatch() {
|
|
3861
|
+
const choices = this.choices();
|
|
3862
|
+
if (!(this.value !== "" && choices.length > 0 && !choices.includes(this.value))) {
|
|
3863
|
+
this.complainedAbout = "\0";
|
|
3864
|
+
return;
|
|
3865
|
+
}
|
|
3866
|
+
if (this.complainedAbout === this.value) return;
|
|
3867
|
+
this.complainedAbout = this.value;
|
|
3868
|
+
diagnose(this.tree?.engine ?? null, "error", `[incanto] UiSelect '${this.getPath()}' has value '${this.value}', which is not one of its options [${choices.join(", ")}]. The menu shows the first one instead, so the game and the screen now disagree about this setting.`);
|
|
3869
|
+
}
|
|
4105
3870
|
_sync() {
|
|
4106
3871
|
const label = this._t(this.label);
|
|
4107
3872
|
if (label !== this.lastLabel && this.labelEl) {
|
|
@@ -4114,6 +3879,9 @@ var UiSelect = class extends HudWidgetBase {
|
|
|
4114
3879
|
this.select.value = this.value;
|
|
4115
3880
|
}
|
|
4116
3881
|
}
|
|
3882
|
+
_pull() {
|
|
3883
|
+
this.reportMismatch();
|
|
3884
|
+
}
|
|
4117
3885
|
};
|
|
4118
3886
|
/** Endonyms: `ko` must read as the Korean word to someone who cannot read `Korean`. */
|
|
4119
3887
|
const ENDONYMS = {
|
|
@@ -4159,21 +3927,19 @@ var UiLanguageSelect = class extends UiSelect {
|
|
|
4159
3927
|
label = "Language";
|
|
4160
3928
|
/** Last locale we PUSHED into `value`, to tell an engine change from a player one. */
|
|
4161
3929
|
lastLocale = "";
|
|
4162
|
-
|
|
3930
|
+
_pull() {
|
|
4163
3931
|
const locale = this.tree?.engine?.locale;
|
|
4164
|
-
if (locale)
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
}
|
|
3932
|
+
if (!locale) return;
|
|
3933
|
+
const available = locale.available();
|
|
3934
|
+
const options = available.join(",");
|
|
3935
|
+
if (options !== this.options) this.options = options;
|
|
3936
|
+
if (locale.locale !== this.lastLocale) {
|
|
3937
|
+
this.lastLocale = locale.locale;
|
|
3938
|
+
this.value = locale.locale;
|
|
3939
|
+
} else if (this.value !== locale.locale && available.includes(this.value)) {
|
|
3940
|
+
locale.locale = this.value;
|
|
3941
|
+
this.lastLocale = this.value;
|
|
4175
3942
|
}
|
|
4176
|
-
super._sync();
|
|
4177
3943
|
}
|
|
4178
3944
|
_optionLabel(value) {
|
|
4179
3945
|
return ENDONYMS[value] ?? value;
|
|
@@ -4204,18 +3970,19 @@ var UiSettingSelect = class extends UiSelect {
|
|
|
4204
3970
|
* `settings.quality.high` is worse than one reading `High`. English is the
|
|
4205
3971
|
* base language and the fallback, always.
|
|
4206
3972
|
*/
|
|
4207
|
-
|
|
3973
|
+
_pull() {
|
|
4208
3974
|
const engine = this._engine();
|
|
4209
|
-
if (engine)
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
}
|
|
3975
|
+
if (!engine) return;
|
|
3976
|
+
const stored = this._read(engine);
|
|
3977
|
+
if (stored !== this.lastPushed) {
|
|
3978
|
+
this.lastPushed = stored;
|
|
3979
|
+
this.value = stored;
|
|
3980
|
+
} else if (this.value !== stored) {
|
|
3981
|
+
this._write(engine, this.value);
|
|
3982
|
+
this.lastPushed = this._read(engine);
|
|
4218
3983
|
}
|
|
3984
|
+
}
|
|
3985
|
+
_sync() {
|
|
4219
3986
|
super._sync();
|
|
4220
3987
|
}
|
|
4221
3988
|
};
|
|
@@ -4378,19 +4145,17 @@ var UiVolumeSlider = class extends UiSlider {
|
|
|
4378
4145
|
const english = VOLUME_LABELS[this.bus] ?? VOLUME_LABELS.master;
|
|
4379
4146
|
return this._tOr(`settings.volume.${this.bus}`, english);
|
|
4380
4147
|
}
|
|
4381
|
-
|
|
4148
|
+
_pull() {
|
|
4382
4149
|
const audio = this.tree?.engine?.audio;
|
|
4383
|
-
if (audio)
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
}
|
|
4150
|
+
if (!audio) return;
|
|
4151
|
+
const stored = audio[this.bus];
|
|
4152
|
+
if (stored !== this.lastPushed) {
|
|
4153
|
+
this.lastPushed = stored;
|
|
4154
|
+
this.value = stored;
|
|
4155
|
+
} else if (this.value !== stored) {
|
|
4156
|
+
audio[this.bus] = this.value;
|
|
4157
|
+
this.lastPushed = audio[this.bus];
|
|
4392
4158
|
}
|
|
4393
|
-
super._sync();
|
|
4394
4159
|
}
|
|
4395
4160
|
};
|
|
4396
4161
|
/**
|
|
@@ -4413,18 +4178,16 @@ var UiMuteToggle = class extends UiToggle {
|
|
|
4413
4178
|
_labelText() {
|
|
4414
4179
|
return this.label ? this._t(this.label) : this._tOr("settings.mute", "Mute");
|
|
4415
4180
|
}
|
|
4416
|
-
|
|
4181
|
+
_pull() {
|
|
4417
4182
|
const audio = this.tree?.engine?.audio;
|
|
4418
|
-
if (audio)
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
}
|
|
4183
|
+
if (!audio) return;
|
|
4184
|
+
if (audio.muted !== this.lastPushed) {
|
|
4185
|
+
this.lastPushed = audio.muted;
|
|
4186
|
+
this.value = audio.muted;
|
|
4187
|
+
} else if (this.value !== audio.muted) {
|
|
4188
|
+
audio.muted = this.value;
|
|
4189
|
+
this.lastPushed = audio.muted;
|
|
4426
4190
|
}
|
|
4427
|
-
super._sync();
|
|
4428
4191
|
}
|
|
4429
4192
|
};
|
|
4430
4193
|
//#endregion
|
|
@@ -4458,4 +4221,4 @@ function registerCoreNodes() {
|
|
|
4458
4221
|
registerNode(UiMuteToggle);
|
|
4459
4222
|
}
|
|
4460
4223
|
//#endregion
|
|
4461
|
-
export {
|
|
4224
|
+
export { T_PREFIX as A, SFX_PRESET_NAMES as B, qualityRendering as C, EffectLog as D, LogManager as E, isAudioContextAvailable as F, crossfadeGains as G, synthSfx as H, ROLLOFF_MODELS as I, fadeGain as K, spatialGain as L, translateOn as M, translationKey as N, BASE_LOCALE as O, SfxEngine as P, spatialPan as R, qualityEnvironment as S, suggestQuality as T, MusicManager as U, sfxDuration as V, WebAudioMusicBackend as W, AudioPlayer as _, UiMuteToggle as a, Settings as b, UiRenderScaleSelect as c, UiToggle as d, UiVolumeSlider as f, UiDialogue as g, UiButton as h, UiLanguageSelect as i, suggestLocale as j, Localization as k, UiSelect as l, Respawn as m, UiFrameCapSelect as n, UiPanel as o, Timer as p, AudioBuses as q, UiImage as r, UiQualitySelect as s, registerCoreNodes as t, UiSlider as u, Engine as v, readDeviceHints as w, qualityCaps as x, engine_exports as y, SFX_PRESETS as z };
|