incanto 0.68.0 → 0.70.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/assets/catalog.json +9 -5
- package/bin/_behaviors-loader.mjs +22 -0
- package/bin/_read-json.mjs +28 -0
- package/bin/incanto-assets.mjs +19 -5
- package/bin/incanto-check.mjs +92 -15
- 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 +128 -5
- package/bin/incanto-play.mjs +158 -16
- package/bin/incanto-playtest.mjs +66 -23
- package/bin/incanto-serve.mjs +160 -0
- package/bin/incanto-skills.mjs +14 -2
- package/bin/incanto-verify.mjs +165 -44
- package/bin/incanto.mjs +4 -2
- package/dist/2d.d.ts +285 -36
- package/dist/2d.js +4 -4
- package/dist/3d.d.ts +158 -10
- package/dist/3d.js +8 -8
- package/dist/{agent8-CvsfVskX.js → agent8-CmNF01gA.js} +61 -8
- package/dist/{audio-player-DOrq7sP-.d.ts → audio-player-DaMxqfNE.d.ts} +33 -14
- package/dist/{behavior-DoFPYrgo.d.ts → behavior-DZExDn9o.d.ts} +809 -44
- package/dist/{create-game-IZIydDwI.js → create-game-Bwvh6q8A.js} +148 -61
- package/dist/{create-game-DbWtVTxD.js → create-game-C7ffQWW7.js} +103 -49
- 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 +1066 -864
- package/dist/env.d.ts +1 -1
- package/dist/env.js +5 -3
- package/dist/{environment-presets-CybQXNqS.js → environment-presets-D6Q5BxeE.js} +299 -46
- package/dist/{frame-report-DCnHFmto.d.ts → frame-report-DNxDAb1w.d.ts} +8 -0
- package/dist/{frame-report-BSMny7oe.js → frame-report-Dlq13Gyj.js} +1 -0
- package/dist/{gameplay-DM1eu_cV.js → gameplay-BfHkuzVb.js} +825 -221
- package/dist/gameplay.d.ts +187 -7
- package/dist/gameplay.js +1 -1
- package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
- package/dist/index.d.ts +156 -13
- package/dist/index.js +10 -12
- package/dist/json-CfTjpvW8.js +67 -0
- package/dist/{loader-CcB533FR.d.ts → loader-Cff09LMm.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-B7Y6dPZO.js → physics-2d-CE0Qvy3V.js} +136 -11
- package/dist/{physics-3d-bG3n70Ky.js → physics-3d-CpH-2gn5.js} +104 -23
- package/dist/{teardown-D2NEmxPB.js → picking-CQJ_PJKh.js} +106 -14
- package/dist/react.d.ts +2 -2
- package/dist/react.js +2 -2
- package/dist/{register-3ta-2Xig.js → register-6DYnKZcy.js} +652 -831
- package/dist/{register-ibCjm-wH.js → register-Bkk0wSDB.js} +348 -30
- package/dist/{replay-CYvhVHHN.js → replay-DjAkAzMq.js} +224 -14
- package/dist/{replay-Dvn8aeBd.d.ts → replay-Dmw-PKQu.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-BC4PNtJX.js → save-slots-BXVg148r.js} +4558 -2294
- package/dist/{sheet-grid-BT6N_Bjs.js → sheet-grid-Cea343VO.js} +6 -2
- package/dist/{split-screen-DhrSzZIB.d.ts → split-screen--k-XpBjr.d.ts} +36 -4
- package/dist/{split-screen-CYwDkbLF.js → split-screen-PL78oVXP.js} +159 -26
- package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
- package/dist/{src-D7RIqXYF.js → src-Cxfiv1Hg.js} +2 -17
- package/dist/test-iHYVUcDK.js +4036 -0
- package/dist/test.d.ts +542 -30
- package/dist/test.js +3 -3
- package/dist/touch-BnCyPA0G.js +519 -0
- package/dist/vite.d.ts +54 -3
- package/dist/vite.js +349 -17
- package/dist/{webgl-unavailable-N9nQqesw.js → webgl-unavailable-C8aDbGmR.js} +56 -1
- package/editor/assets/agent8-D0MS174y.js +1 -0
- package/editor/assets/{debug-BBhKuBNV.js → debug-BnXkKuYu.js} +2 -2
- package/editor/assets/index-CIu3uc3l.js +11046 -0
- package/editor/index.html +1 -1
- package/package.json +7 -16
- package/schemas/scene.schema.json +30 -3
- package/skills/incanto-3d-character.md +14 -1
- package/skills/incanto-3d-models.md +12 -0
- package/skills/incanto-assets.md +25 -3
- package/skills/incanto-audio.md +27 -5
- package/skills/incanto-behaviors-and-scripts.md +83 -6
- package/skills/incanto-building-2d-games.md +106 -8
- package/skills/incanto-building-3d-games.md +118 -6
- 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 +121 -14
- package/skills/incanto-hud.md +128 -7
- package/skills/incanto-localization.md +13 -5
- package/skills/incanto-multiplayer.md +83 -3
- package/skills/incanto-node-reference.md +222 -58
- package/skills/incanto-performance.md +52 -0
- package/skills/incanto-physics-and-input.md +123 -24
- package/skills/incanto-playtesting.md +78 -2
- package/skills/incanto-save-slots.md +188 -6
- package/skills/incanto-scene-json-authoring.md +69 -12
- package/skills/incanto-verifying-your-game.md +292 -12
- package/skills/incanto-web-integration.md +28 -0
- package/skills/incanto-your-first-game.md +5 -2
- 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 +103 -378
- package/templates-app/beacon-isle-3d/src/main.ts +24 -4
- 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 +1255 -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/index.html +0 -23
- package/templates-app/platformer-2d/package.json +2 -2
- package/templates-app/platformer-2d/src/behaviors.ts +26 -16
- package/templates-app/platformer-2d/src/game.scene.json +143 -625
- package/templates-app/platformer-2d/src/main.ts +35 -13
- 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 +41 -195
- package/templates-app/star-survivor/src/main.ts +28 -7
- 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 +78 -217
- package/templates-app/tps-3d/src/main.ts +39 -17
- 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 +21 -0
- package/templates-app/village-quest-3d/src/grove.scene.json +54 -221
- package/templates-app/village-quest-3d/src/main.ts +24 -4
- package/templates-app/village-quest-3d/src/village.scene.json +199 -838
- package/templates-app/village-quest-3d/tsconfig.json +1 -1
- package/templates-app/village-quest-3d/verify.ts +14 -1
- package/dist/duplicate-IWIqk0HJ.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-8hoHeRmo.js +0 -2340
- package/dist/touch-DEAmqGdf.js +0 -225
- package/editor/assets/agent8-BrrHOjMJ.js +0 -1
- package/editor/assets/index-eVd0BToA.js +0 -10958
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { o as __exportAll } from "./json-CfTjpvW8.js";
|
|
2
|
+
import { B as TAPPABLE, G as requireText, H as UiBar, K as Node, L as FITS, N as resolveOrderGroups, P as InputMap, R as HudLayer, U as UiText, V as UiBanner, a as restoreBehaviors, i as captureBehaviors, j as ORDER_GROUP_BASE, k as createSaveStore, ot as Signal, q as diagnose, t as ENGINE_STATE_KEY, z as HudWidgetBase } from "./save-slots-BXVg148r.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
|
|
@@ -2031,6 +1410,25 @@ var FrameStatsRing = class {
|
|
|
2031
1410
|
const span = this.spanMs();
|
|
2032
1411
|
return span > 0 ? span / (this.count - 1) : 0;
|
|
2033
1412
|
}
|
|
1413
|
+
/**
|
|
1414
|
+
* How long ago the newest sample arrived, in ms. `null` with no samples.
|
|
1415
|
+
*
|
|
1416
|
+
* `fps()` is a rate over a WINDOW, so it keeps answering after the frames
|
|
1417
|
+
* stop: a hidden tab, a descheduled loop, a page the browser froze. Measured
|
|
1418
|
+
* on an engine that ticked 40 times and then stopped being ticked:
|
|
1419
|
+
*
|
|
1420
|
+
* while ticking : fps 59.9
|
|
1421
|
+
* after 0 more ticks : fps 59.9 <- the last sample is 668 ms old
|
|
1422
|
+
*
|
|
1423
|
+
* Both numbers are true about the window they describe. What was missing was
|
|
1424
|
+
* any way to ask WHEN that window was, so "60 fps" read the same whether it
|
|
1425
|
+
* was measured 8 ms ago or four seconds ago.
|
|
1426
|
+
*/
|
|
1427
|
+
ageMs(nowMs) {
|
|
1428
|
+
if (this.count === 0) return null;
|
|
1429
|
+
const newest = this.times[(this.head + this.count - 1) % this.capacity];
|
|
1430
|
+
return Math.max(0, nowMs - newest);
|
|
1431
|
+
}
|
|
2034
1432
|
/** Forget all samples (engine.stop() — a paused game has no frame rate). */
|
|
2035
1433
|
clear() {
|
|
2036
1434
|
this.head = 0;
|
|
@@ -2387,6 +1785,28 @@ function qualityRendering(tier) {
|
|
|
2387
1785
|
//#region src/core/engine.ts
|
|
2388
1786
|
var engine_exports = /* @__PURE__ */ __exportAll({ Engine: () => Engine });
|
|
2389
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.`;
|
|
2390
1810
|
function rafScheduler(cb) {
|
|
2391
1811
|
let live = true;
|
|
2392
1812
|
let id = requestAnimationFrame(function loop(t) {
|
|
@@ -2433,6 +1853,93 @@ var Engine = class {
|
|
|
2433
1853
|
* learns about Rapier or three. From a behavior, prefer `this.physics`.
|
|
2434
1854
|
*/
|
|
2435
1855
|
physics = null;
|
|
1856
|
+
/**
|
|
1857
|
+
* How to build a physics world for a scene that needs one.
|
|
1858
|
+
*
|
|
1859
|
+
* `createGame2D`/`createGame3D` install this; core never learns what Rapier
|
|
1860
|
+
* is. It exists because `physics: 'auto'` can only look at the scene the game
|
|
1861
|
+
* BOOTED with, and the most universal act of finishing a game is putting a
|
|
1862
|
+
* title screen in front of it:
|
|
1863
|
+
*
|
|
1864
|
+
* ```
|
|
1865
|
+
* after boot on the title : engine.physics = null
|
|
1866
|
+
* after goToScene into game: engine.physics = null
|
|
1867
|
+
* player y after 2 s : 0 -> 0
|
|
1868
|
+
* ```
|
|
1869
|
+
*
|
|
1870
|
+
* The game rendered perfectly and was inert — nothing fell, nothing collided,
|
|
1871
|
+
* nothing could be collected or hurt. One scene swap turned a working game
|
|
1872
|
+
* into a diorama, and nothing was logged.
|
|
1873
|
+
*/
|
|
1874
|
+
physicsProvider = null;
|
|
1875
|
+
ensuring = null;
|
|
1876
|
+
/**
|
|
1877
|
+
* Give the current scene a physics world if it needs one and has none.
|
|
1878
|
+
*
|
|
1879
|
+
* Idempotent and safe to call on every swap: the provider decides whether the
|
|
1880
|
+
* incoming tree actually wants physics, and an explicit `physics: false` at
|
|
1881
|
+
* boot installs no provider at all, so opting out stays opted out.
|
|
1882
|
+
*
|
|
1883
|
+
* Asynchronous because the Rapier WASM has to load. The first frames after a
|
|
1884
|
+
* swap therefore run unsimulated — which every AI behaviour already tolerates
|
|
1885
|
+
* (`moveBody` falls back to a direct write when `_physics` is null) and which
|
|
1886
|
+
* is a hitch, where the alternative was a game that never simulated at all.
|
|
1887
|
+
*/
|
|
1888
|
+
ensurePhysics() {
|
|
1889
|
+
if (this.physics || !this.physicsProvider || !this._scene) return Promise.resolve();
|
|
1890
|
+
if (this.ensuring) return this.ensuring;
|
|
1891
|
+
const root = this._scene.root;
|
|
1892
|
+
this.ensuring = this.physicsProvider(root).catch((e) => {
|
|
1893
|
+
this.log.error(`[incanto] could not enable physics for this scene: ${String(e?.message ?? e)}`);
|
|
1894
|
+
}).finally(() => {
|
|
1895
|
+
this.ensuring = null;
|
|
1896
|
+
});
|
|
1897
|
+
return this.ensuring;
|
|
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 = {};
|
|
2436
1943
|
/** Seeded randomness for game logic (deterministic when `seed` is set). */
|
|
2437
1944
|
rng;
|
|
2438
1945
|
/** The engine log channel (debug overlay + headless harness tail this). */
|
|
@@ -2447,6 +1954,57 @@ var Engine = class {
|
|
|
2447
1954
|
* and cleared on dispose; null when nothing renders (headless).
|
|
2448
1955
|
*/
|
|
2449
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
|
+
}
|
|
2450
2008
|
/** Frames the engine has stepped — monotonic, and the pick cache's key. */
|
|
2451
2009
|
frameNumber = 0;
|
|
2452
2010
|
/** @internal frame number + answer, so N Clickables cost ONE raycast. */
|
|
@@ -2535,7 +2093,35 @@ var Engine = class {
|
|
|
2535
2093
|
* (hit-stop / pause). Scales BOTH variable and fixed updates — physics,
|
|
2536
2094
|
* timers, behaviors all breathe together. See `gameplay` `hitStop()`.
|
|
2537
2095
|
*/
|
|
2538
|
-
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
|
+
}
|
|
2539
2125
|
/**
|
|
2540
2126
|
* The node the dev overlay has selected (renderers draw its bounding box
|
|
2541
2127
|
* as an orange outline in the game view so you can SEE what you picked).
|
|
@@ -2558,11 +2144,39 @@ var Engine = class {
|
|
|
2558
2144
|
get unscaledTime() {
|
|
2559
2145
|
return this._unscaledTime;
|
|
2560
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;
|
|
2561
2172
|
constructor(opts = {}) {
|
|
2562
|
-
|
|
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;
|
|
2563
2176
|
this.maxFixedSteps = opts.maxFixedStepsPerTick ?? 5;
|
|
2564
2177
|
this.scheduler = opts.scheduler ?? rafScheduler;
|
|
2565
|
-
this.
|
|
2178
|
+
this.seed = opts.seed ?? Math.random() * 4294967295 >>> 0;
|
|
2179
|
+
this.rng = new Rng(this.seed);
|
|
2566
2180
|
this.maxFps = opts.maxFps ?? 0;
|
|
2567
2181
|
this.settings = opts.settings instanceof Settings ? opts.settings : new Settings(opts.settings?.namespace ?? "settings");
|
|
2568
2182
|
this.sfx._setBuses(this.audio);
|
|
@@ -2576,17 +2190,30 @@ var Engine = class {
|
|
|
2576
2190
|
/** Replace the active scene. The previous scene's root is freed. */
|
|
2577
2191
|
setScene(scene) {
|
|
2578
2192
|
if (scene === this._scene) return;
|
|
2579
|
-
this._scene
|
|
2580
|
-
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
|
+
}
|
|
2581
2205
|
this._scene = scene;
|
|
2582
2206
|
scene.tree._setEngine(this);
|
|
2583
2207
|
this.input.clear();
|
|
2584
2208
|
this._time = 0;
|
|
2585
2209
|
this._unscaledTime = 0;
|
|
2586
2210
|
this.timeScale = 1;
|
|
2211
|
+
this.accumulator = 0;
|
|
2587
2212
|
if (scene.input) this.input.declare(scene.input);
|
|
2588
2213
|
if (scene.strings) this.locale.load(scene.strings);
|
|
2589
2214
|
this.orderGroups = resolveOrderGroups(scene.orderGroups);
|
|
2215
|
+
this.audio.clearLog();
|
|
2216
|
+
this.effects.clearLog();
|
|
2590
2217
|
this.sceneChanged.emit(scene);
|
|
2591
2218
|
}
|
|
2592
2219
|
start() {
|
|
@@ -2663,6 +2290,29 @@ var Engine = class {
|
|
|
2663
2290
|
_nodeErrored(_node) {
|
|
2664
2291
|
this.errorCount += 1;
|
|
2665
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;
|
|
2666
2316
|
errorCount = 0;
|
|
2667
2317
|
lastFrameError = null;
|
|
2668
2318
|
/**
|
|
@@ -2688,25 +2338,51 @@ var Engine = class {
|
|
|
2688
2338
|
* core/save-slots.ts for why a save carries state rather than a tree
|
|
2689
2339
|
* snapshot.
|
|
2690
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 = [];
|
|
2691
2351
|
captureState() {
|
|
2692
2352
|
const root = this.scene?.root;
|
|
2693
|
-
if (!root)
|
|
2694
|
-
|
|
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);
|
|
2695
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` : ""}.`);
|
|
2696
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 };
|
|
2697
2363
|
return state;
|
|
2698
2364
|
}
|
|
2699
2365
|
/** Give it back, after the scene is loaded and onReady has run. */
|
|
2700
2366
|
restoreState(state) {
|
|
2367
|
+
const engineState = state[ENGINE_STATE_KEY];
|
|
2368
|
+
if (typeof engineState?.rng === "number") this.rng.position = engineState.rng;
|
|
2701
2369
|
const root = this.scene?.root;
|
|
2702
|
-
if (!root)
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
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;
|
|
2710
2386
|
}
|
|
2711
2387
|
erroredNodes() {
|
|
2712
2388
|
const out = [];
|
|
@@ -2756,6 +2432,7 @@ var Engine = class {
|
|
|
2756
2432
|
return {
|
|
2757
2433
|
fps: this.frameStats.fps(),
|
|
2758
2434
|
frameMs: this.frameStats.frameMs(),
|
|
2435
|
+
lastFrameAgeMs: this.frameStats.ageMs(performance.now()),
|
|
2759
2436
|
nodes: this._scene?.tree.root ? countNodes(this._scene.tree.root) : 0,
|
|
2760
2437
|
running: this.disposeScheduler !== null,
|
|
2761
2438
|
errors: this.errorCount,
|
|
@@ -2780,11 +2457,18 @@ var Engine = class {
|
|
|
2780
2457
|
*/
|
|
2781
2458
|
dispose() {
|
|
2782
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
|
+
}
|
|
2783
2467
|
this.music.dispose();
|
|
2784
2468
|
this.sfx.dispose();
|
|
2785
2469
|
if (this._scene) {
|
|
2786
|
-
this._scene.tree._setEngine(null);
|
|
2787
2470
|
this._scene.root.free();
|
|
2471
|
+
this._scene.tree._setEngine(null);
|
|
2788
2472
|
this._scene = null;
|
|
2789
2473
|
this.sceneChanged.emit(null);
|
|
2790
2474
|
}
|
|
@@ -2807,13 +2491,18 @@ var Engine = class {
|
|
|
2807
2491
|
const dt = this.fixedStep * (opts.ignorePause ? 1 : this.timeScale);
|
|
2808
2492
|
this._time += dt;
|
|
2809
2493
|
this._unscaledTime += this.fixedStep;
|
|
2494
|
+
this._unscaledDelta = this.fixedStep;
|
|
2810
2495
|
const startedFixed = performance.now();
|
|
2496
|
+
this.input.beginFrame();
|
|
2497
|
+
this.input.beginFixedStep();
|
|
2811
2498
|
scene.tree.fixedUpdate(dt);
|
|
2812
2499
|
this.fixedUpdated.emit(dt);
|
|
2500
|
+
this.input.endFixedStep();
|
|
2813
2501
|
const startedUpdate = performance.now();
|
|
2814
2502
|
this.frameNumber += 1;
|
|
2815
2503
|
scene.tree.update(dt);
|
|
2816
|
-
this.music.tick(
|
|
2504
|
+
this.music.tick(this.fixedStep);
|
|
2505
|
+
this.sfx._tick(this.fixedStep);
|
|
2817
2506
|
this.updated.emit(dt);
|
|
2818
2507
|
const ended = performance.now();
|
|
2819
2508
|
const updateMs = Math.max(0, ended - startedUpdate - this.renderMs);
|
|
@@ -2828,19 +2517,25 @@ var Engine = class {
|
|
|
2828
2517
|
this.lastMs = nowMs;
|
|
2829
2518
|
return;
|
|
2830
2519
|
}
|
|
2831
|
-
const
|
|
2520
|
+
const raw = (nowMs - this.lastMs) / 1e3;
|
|
2521
|
+
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.`);
|
|
2522
|
+
const rawDt = Number.isFinite(raw) ? Math.max(0, Math.min(raw, MAX_DT_SECONDS)) : 0;
|
|
2832
2523
|
const dt = rawDt * this.timeScale;
|
|
2833
2524
|
this._time += dt;
|
|
2834
2525
|
this._unscaledTime += rawDt;
|
|
2526
|
+
this._unscaledDelta = rawDt;
|
|
2835
2527
|
this.lastMs = nowMs;
|
|
2836
2528
|
const scene = this._scene;
|
|
2837
2529
|
if (!scene) return;
|
|
2530
|
+
this.input.beginFrame();
|
|
2838
2531
|
this.accumulator += dt;
|
|
2839
2532
|
let steps = 0;
|
|
2840
2533
|
const startedFixed = performance.now();
|
|
2841
2534
|
while (this.accumulator >= this.fixedStep && steps < this.maxFixedSteps) {
|
|
2535
|
+
this.input.beginFixedStep();
|
|
2842
2536
|
scene.tree.fixedUpdate(this.fixedStep);
|
|
2843
2537
|
this.fixedUpdated.emit(this.fixedStep);
|
|
2538
|
+
this.input.endFixedStep();
|
|
2844
2539
|
this.accumulator -= this.fixedStep;
|
|
2845
2540
|
steps += 1;
|
|
2846
2541
|
}
|
|
@@ -2848,7 +2543,8 @@ var Engine = class {
|
|
|
2848
2543
|
const startedUpdate = performance.now();
|
|
2849
2544
|
this.frameNumber += 1;
|
|
2850
2545
|
scene.tree.update(dt);
|
|
2851
|
-
this.music.tick(
|
|
2546
|
+
this.music.tick(rawDt);
|
|
2547
|
+
this.sfx._tick(rawDt);
|
|
2852
2548
|
this.updated.emit(dt);
|
|
2853
2549
|
const ended = performance.now();
|
|
2854
2550
|
const updateMs = Math.max(0, ended - startedUpdate - this.renderMs);
|
|
@@ -2887,12 +2583,24 @@ var AudioPlayer = class extends Node {
|
|
|
2887
2583
|
static typeName = "AudioPlayer";
|
|
2888
2584
|
static signals = ["finished"];
|
|
2889
2585
|
static props = {
|
|
2890
|
-
src: {
|
|
2586
|
+
src: {
|
|
2587
|
+
default: "",
|
|
2588
|
+
asset: {
|
|
2589
|
+
kind: "audio",
|
|
2590
|
+
urlOnly: true
|
|
2591
|
+
}
|
|
2592
|
+
},
|
|
2891
2593
|
preset: {
|
|
2892
2594
|
default: "custom",
|
|
2893
2595
|
options: ["custom", ...SFX_PRESET_NAMES]
|
|
2894
2596
|
},
|
|
2895
|
-
volume: {
|
|
2597
|
+
volume: {
|
|
2598
|
+
default: 1,
|
|
2599
|
+
range: {
|
|
2600
|
+
min: 0,
|
|
2601
|
+
max: 1
|
|
2602
|
+
}
|
|
2603
|
+
},
|
|
2896
2604
|
pitch: { default: 1 },
|
|
2897
2605
|
seed: { default: 0 },
|
|
2898
2606
|
bus: {
|
|
@@ -3006,8 +2714,10 @@ var AudioPlayer = class extends Node {
|
|
|
3006
2714
|
kind,
|
|
3007
2715
|
name,
|
|
3008
2716
|
from: this.getPath(),
|
|
2717
|
+
wiredAs: this.wiringPath(),
|
|
3009
2718
|
bus: this.bus === "music" ? "music" : "sfx",
|
|
3010
|
-
at: engine.time
|
|
2719
|
+
at: engine.time,
|
|
2720
|
+
...this.loop ? { loop: true } : {}
|
|
3011
2721
|
});
|
|
3012
2722
|
}
|
|
3013
2723
|
/** Final gain = engine buses × this volume (1 when not in a tree). */
|
|
@@ -3040,31 +2750,57 @@ var AudioPlayer = class extends Node {
|
|
|
3040
2750
|
};
|
|
3041
2751
|
}
|
|
3042
2752
|
/**
|
|
3043
|
-
* Start the sound
|
|
2753
|
+
* Start the sound, now.
|
|
2754
|
+
*
|
|
2755
|
+
* **Takes no arguments, on purpose.** This is the method scenes wire signals
|
|
2756
|
+
* to — `incanto-audio.md` teaches
|
|
2757
|
+
* `{"signal": "collected", "from": "Player/Collector", "to": "Coin",
|
|
2758
|
+
* "handler": "play"}` — and a signal hands its handler whatever it carries.
|
|
2759
|
+
* `collected` leads with a NUMBER. When this briefly took an optional
|
|
2760
|
+
* scheduling time, that wiring became `play(10)`, which scheduled the pickup
|
|
2761
|
+
* sound at ABSOLUTE audio-clock second 10: inaudible for the first ten
|
|
2762
|
+
* seconds of the game and fine thereafter. The same optional parameter also
|
|
2763
|
+
* made `Function.length` 1, so the engine's own arity checker cried wolf on
|
|
2764
|
+
* the shipped `platformer-2d` template's own harness.
|
|
2765
|
+
*
|
|
2766
|
+
* Scheduling has its own name: {@link playAt}.
|
|
2767
|
+
*/
|
|
2768
|
+
/**
|
|
2769
|
+
* Start the sound at `when` on the AUDIO clock (`engine.sfx.now` + a lead).
|
|
3044
2770
|
*
|
|
3045
2771
|
* A frame is a 16.67 ms grid at 60 Hz and 33.33 ms at 30, so a sound fired
|
|
3046
2772
|
* from `update()` cannot land closer than one frame to where a chart wants
|
|
3047
|
-
* it — measured 0 of 64 notes
|
|
3048
|
-
*
|
|
3049
|
-
* ms:
|
|
2773
|
+
* it — measured 0 of 64 notes given a scheduled start. Queue them a lead
|
|
2774
|
+
* ahead and the same notes land at |mean| 0.000000 ms:
|
|
3050
2775
|
*
|
|
3051
2776
|
* ```ts
|
|
3052
|
-
* const
|
|
3053
|
-
* for (const note of dueSoon(engine.sfx.now +
|
|
3054
|
-
* hit.
|
|
2777
|
+
* const LEAD = 0.08;
|
|
2778
|
+
* for (const note of dueSoon(engine.sfx.now + LEAD)) {
|
|
2779
|
+
* hit.playAt(startedAt + note.atSec);
|
|
3055
2780
|
* }
|
|
3056
2781
|
* ```
|
|
3057
2782
|
*
|
|
3058
2783
|
* PRESETS only — a `src` clip goes through an `<audio>` element, which has no
|
|
3059
|
-
* scheduling clock,
|
|
3060
|
-
* approximated.
|
|
3061
|
-
*
|
|
3062
|
-
*
|
|
2784
|
+
* scheduling clock, so `playAt` on one plays immediately rather than being
|
|
2785
|
+
* silently approximated. A time already past plays now (Web Audio's own
|
|
2786
|
+
* rule), so a late scheduler is late, not silent. The bus gain, the
|
|
2787
|
+
* `engine.audio.recent()` entry and `muted` all still apply.
|
|
2788
|
+
*
|
|
2789
|
+
* Separate from {@link play} because `play` is what scenes wire signals to,
|
|
2790
|
+
* and a signal hands its handler whatever it carries.
|
|
3063
2791
|
*/
|
|
3064
|
-
|
|
2792
|
+
playAt(when) {
|
|
2793
|
+
if (this.preset !== "custom") {
|
|
2794
|
+
this.note("preset", this.preset);
|
|
2795
|
+
this.playPreset(Number.isFinite(when) ? when : void 0);
|
|
2796
|
+
return;
|
|
2797
|
+
}
|
|
2798
|
+
this.play();
|
|
2799
|
+
}
|
|
2800
|
+
play() {
|
|
3065
2801
|
if (this.preset !== "custom") {
|
|
3066
2802
|
this.note("preset", this.preset);
|
|
3067
|
-
this.playPreset(
|
|
2803
|
+
this.playPreset();
|
|
3068
2804
|
return;
|
|
3069
2805
|
}
|
|
3070
2806
|
if (!this.src) return;
|
|
@@ -3231,7 +2967,7 @@ var UiButton = class extends HudWidgetBase {
|
|
|
3231
2967
|
/** The whole look, so re-applying is one comparison. `size`/`color` were baked
|
|
3232
2968
|
* in at build, and a button could never be restyled afterwards. */
|
|
3233
2969
|
look() {
|
|
3234
|
-
return
|
|
2970
|
+
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;`;
|
|
3235
2971
|
}
|
|
3236
2972
|
lastLook = "";
|
|
3237
2973
|
_sync() {
|
|
@@ -3308,7 +3044,9 @@ var UiDialogue = class extends HudWidgetBase {
|
|
|
3308
3044
|
return this.current ? this.current.text.slice(0, Math.floor(this.revealed)) : "";
|
|
3309
3045
|
}
|
|
3310
3046
|
/** Queue a line; optional `choices` renders buttons after the line types out. */
|
|
3311
|
-
say(speaker, text, choices) {
|
|
3047
|
+
say(speaker, text, choices = void 0) {
|
|
3048
|
+
requireText(speaker, this, "UiDialogue.say (speaker)");
|
|
3049
|
+
requireText(text, this, "UiDialogue.say (text)");
|
|
3312
3050
|
const source = {
|
|
3313
3051
|
speaker,
|
|
3314
3052
|
text,
|
|
@@ -3339,6 +3077,16 @@ var UiDialogue = class extends HudWidgetBase {
|
|
|
3339
3077
|
* `dir` is 0 for "activate" and ±1 for a nudge, the same shape `UiSelect`
|
|
3340
3078
|
* uses — so one key layout drives a menu, a slider and a conversation.
|
|
3341
3079
|
*/
|
|
3080
|
+
/**
|
|
3081
|
+
* A click on the BOX is `advance()`, which refuses to skip a choice.
|
|
3082
|
+
*
|
|
3083
|
+
* The DOM handler is `box.addEventListener('click', () => this.advance())`
|
|
3084
|
+
* and `advance` returns early while `current.choices` is set. Enter is a
|
|
3085
|
+
* different verb — it answers — so a harness clicking the box must not.
|
|
3086
|
+
*/
|
|
3087
|
+
_click() {
|
|
3088
|
+
this.advance();
|
|
3089
|
+
}
|
|
3342
3090
|
_activate(dir) {
|
|
3343
3091
|
if (!this.active || !this.current) return;
|
|
3344
3092
|
const choices = this.current.choices;
|
|
@@ -3399,7 +3147,7 @@ var UiDialogue = class extends HudWidgetBase {
|
|
|
3399
3147
|
}
|
|
3400
3148
|
_build() {
|
|
3401
3149
|
const box = document.createElement("div");
|
|
3402
|
-
box.style.cssText =
|
|
3150
|
+
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;`;
|
|
3403
3151
|
this.speakerEl = document.createElement("div");
|
|
3404
3152
|
this.speakerEl.style.cssText = "font-weight:800;color:#ffd166;margin-bottom:4px;font-size:13px;";
|
|
3405
3153
|
this.textEl = document.createElement("div");
|
|
@@ -3453,7 +3201,7 @@ var UiDialogue = class extends HudWidgetBase {
|
|
|
3453
3201
|
const b = document.createElement("button");
|
|
3454
3202
|
b.type = "button";
|
|
3455
3203
|
b.textContent = label;
|
|
3456
|
-
b.style.cssText =
|
|
3204
|
+
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;`;
|
|
3457
3205
|
b.addEventListener("click", (e) => {
|
|
3458
3206
|
e.stopPropagation();
|
|
3459
3207
|
this.choose(i);
|
|
@@ -3637,14 +3385,27 @@ var Timer = class extends Node {
|
|
|
3637
3385
|
autostart = false;
|
|
3638
3386
|
running = false;
|
|
3639
3387
|
remaining = 0;
|
|
3640
|
-
start(time) {
|
|
3641
|
-
|
|
3388
|
+
start(time = this.waitTime) {
|
|
3389
|
+
this.waitTime = time;
|
|
3642
3390
|
this.remaining = this.waitTime;
|
|
3643
3391
|
this.running = true;
|
|
3644
3392
|
}
|
|
3645
3393
|
stop() {
|
|
3646
3394
|
this.running = false;
|
|
3647
3395
|
}
|
|
3396
|
+
/**
|
|
3397
|
+
* Scene-load validation: a period of zero is a timer that never fires.
|
|
3398
|
+
*
|
|
3399
|
+
* `update` guards it — a non-positive period "would spin forever on any dt",
|
|
3400
|
+
* so it calls `stop()` — and the guard is right. Being SILENT about it is
|
|
3401
|
+
* what a load error is for: a scene asking for a tick every zero seconds got
|
|
3402
|
+
* no ticks and no word said. The runtime guard stays, for code that zeroes
|
|
3403
|
+
* the period from a handler.
|
|
3404
|
+
*/
|
|
3405
|
+
static validateJson(node) {
|
|
3406
|
+
const timer = node;
|
|
3407
|
+
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" });
|
|
3408
|
+
}
|
|
3648
3409
|
onReady() {
|
|
3649
3410
|
if (this.autostart) this.start();
|
|
3650
3411
|
}
|
|
@@ -3665,6 +3426,20 @@ var Timer = class extends Node {
|
|
|
3665
3426
|
};
|
|
3666
3427
|
//#endregion
|
|
3667
3428
|
//#region src/core/nodes/ui-kit.ts
|
|
3429
|
+
/**
|
|
3430
|
+
* The widgets a MENU is made of.
|
|
3431
|
+
*
|
|
3432
|
+
* The HUD kit could say things — text, a bar, a banner, a button — and could not
|
|
3433
|
+
* arrange them or take a value back. So every title screen, pause menu, options
|
|
3434
|
+
* panel, inventory grid and shop in every game built on this engine was
|
|
3435
|
+
* hand-rolled DOM: `star-survivor`'s level-up menu builds its own elements, which
|
|
3436
|
+
* drops it out of scene JSON, out of the editor, out of `incanto-check`, and out
|
|
3437
|
+
* of every headless verification the agent has.
|
|
3438
|
+
*
|
|
3439
|
+
* These are the missing pieces, and they are deliberately the SAME kind of thing
|
|
3440
|
+
* the existing widgets are — nodes in the tree, props in JSON, DOM built by the
|
|
3441
|
+
* engine — so an inventory is a scene, not a component library.
|
|
3442
|
+
*/
|
|
3668
3443
|
function px(n) {
|
|
3669
3444
|
return `${n}px`;
|
|
3670
3445
|
}
|
|
@@ -3734,7 +3509,7 @@ var UiPanel = class extends HudWidgetBase {
|
|
|
3734
3509
|
return this._element;
|
|
3735
3510
|
}
|
|
3736
3511
|
css() {
|
|
3737
|
-
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};` : "") +
|
|
3512
|
+
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}`;
|
|
3738
3513
|
}
|
|
3739
3514
|
_sync() {
|
|
3740
3515
|
const next = this.css();
|
|
@@ -3766,7 +3541,13 @@ var UiImage = class extends HudWidgetBase {
|
|
|
3766
3541
|
},
|
|
3767
3542
|
/** Multiply the image by a colour: greyed-out items, team tints. */
|
|
3768
3543
|
tint: { default: "" },
|
|
3769
|
-
opacity: {
|
|
3544
|
+
opacity: {
|
|
3545
|
+
default: 1,
|
|
3546
|
+
range: {
|
|
3547
|
+
min: 0,
|
|
3548
|
+
max: 1
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3770
3551
|
};
|
|
3771
3552
|
src = "";
|
|
3772
3553
|
width = 48;
|
|
@@ -3790,7 +3571,7 @@ var UiImage = class extends HudWidgetBase {
|
|
|
3790
3571
|
}
|
|
3791
3572
|
css() {
|
|
3792
3573
|
const url = this.resolved();
|
|
3793
|
-
return `width:${px(this.width)};height:${px(this.height)};flex:none
|
|
3574
|
+
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;` : "");
|
|
3794
3575
|
}
|
|
3795
3576
|
_sync() {
|
|
3796
3577
|
const next = this.key();
|
|
@@ -3843,7 +3624,7 @@ var UiSlider = class extends HudWidgetBase {
|
|
|
3843
3624
|
last = NaN;
|
|
3844
3625
|
_build() {
|
|
3845
3626
|
const wrap = document.createElement("div");
|
|
3846
|
-
wrap.style.cssText =
|
|
3627
|
+
wrap.style.cssText = `display:flex;align-items:center;gap:8px;${TAPPABLE}font:600 13px system-ui,-apple-system,'Segoe UI',sans-serif;color:#fff;`;
|
|
3847
3628
|
const text = document.createElement("span");
|
|
3848
3629
|
text.textContent = this._labelText();
|
|
3849
3630
|
const input = document.createElement("input");
|
|
@@ -3852,7 +3633,7 @@ var UiSlider = class extends HudWidgetBase {
|
|
|
3852
3633
|
input.max = String(this.max);
|
|
3853
3634
|
input.step = String(this.step);
|
|
3854
3635
|
input.value = String(this.value);
|
|
3855
|
-
input.style.cssText = `width:${px(this.width)};accent-color:${this.color}
|
|
3636
|
+
input.style.cssText = `width:${px(this.width)};accent-color:${this.color};${FITS}`;
|
|
3856
3637
|
input.addEventListener("input", () => {
|
|
3857
3638
|
this.value = Number(input.value);
|
|
3858
3639
|
this.last = this.value;
|
|
@@ -3900,7 +3681,7 @@ var UiToggle = class extends HudWidgetBase {
|
|
|
3900
3681
|
last = null;
|
|
3901
3682
|
_build() {
|
|
3902
3683
|
const wrap = document.createElement("label");
|
|
3903
|
-
wrap.style.cssText =
|
|
3684
|
+
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;`;
|
|
3904
3685
|
const input = document.createElement("input");
|
|
3905
3686
|
input.type = "checkbox";
|
|
3906
3687
|
input.checked = this.value;
|
|
@@ -3948,6 +3729,29 @@ var UiSelect = class extends HudWidgetBase {
|
|
|
3948
3729
|
options = "";
|
|
3949
3730
|
value = "";
|
|
3950
3731
|
focusable = true;
|
|
3732
|
+
/** The options, as the list of values it actually offers. */
|
|
3733
|
+
choices() {
|
|
3734
|
+
return this.options.split(",").map((o) => o.trim()).filter(Boolean);
|
|
3735
|
+
}
|
|
3736
|
+
/**
|
|
3737
|
+
* A value the list cannot show is a setting the game and the screen disagree
|
|
3738
|
+
* about.
|
|
3739
|
+
*
|
|
3740
|
+
* The browser cannot select an option that does not exist, so it shows the
|
|
3741
|
+
* FIRST one — while `node.value` keeps answering with what it was given, and
|
|
3742
|
+
* `changed` only fires when a person moves it, so nothing ever reconciles
|
|
3743
|
+
* them. That is the shape a saved setting has after a patch removes a quality
|
|
3744
|
+
* tier, and it is silent for the rest of the session.
|
|
3745
|
+
*
|
|
3746
|
+
* A hard error at LOAD, like every other enum in this engine. An empty value
|
|
3747
|
+
* is "nothing chosen yet", which is a choice.
|
|
3748
|
+
*/
|
|
3749
|
+
static validateJson(node) {
|
|
3750
|
+
const self = node;
|
|
3751
|
+
const choices = self.choices();
|
|
3752
|
+
if (self.value === "" || choices.length === 0 || choices.includes(self.value)) return;
|
|
3753
|
+
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" });
|
|
3754
|
+
}
|
|
3951
3755
|
/** Left/right (and confirm) walk the list — a select has no other gesture. */
|
|
3952
3756
|
_activate(dir) {
|
|
3953
3757
|
const choices = this.options.split(",").map((o) => o.trim()).filter(Boolean);
|
|
@@ -3966,7 +3770,7 @@ var UiSelect = class extends HudWidgetBase {
|
|
|
3966
3770
|
last = "\0";
|
|
3967
3771
|
_build() {
|
|
3968
3772
|
const wrap = document.createElement("label");
|
|
3969
|
-
wrap.style.cssText =
|
|
3773
|
+
wrap.style.cssText = `display:flex;align-items:center;gap:8px;${TAPPABLE}font:600 13px system-ui,-apple-system,'Segoe UI',sans-serif;color:#fff;`;
|
|
3970
3774
|
const text = document.createElement("span");
|
|
3971
3775
|
this.labelEl = text;
|
|
3972
3776
|
const select = document.createElement("select");
|
|
@@ -4010,6 +3814,25 @@ var UiSelect = class extends HudWidgetBase {
|
|
|
4010
3814
|
if (this.value) select.value = this.value;
|
|
4011
3815
|
this.last = this.value;
|
|
4012
3816
|
}
|
|
3817
|
+
/** What was already reported, so a stuck value is said once and not per frame. */
|
|
3818
|
+
complainedAbout = "\0";
|
|
3819
|
+
/**
|
|
3820
|
+
* The runtime half of `validateJson`.
|
|
3821
|
+
*
|
|
3822
|
+
* A behaviour may legitimately write the value first and widen `options`
|
|
3823
|
+
* after — so this reports rather than refuses, and goes quiet again when the
|
|
3824
|
+
* list catches up.
|
|
3825
|
+
*/
|
|
3826
|
+
reportMismatch() {
|
|
3827
|
+
const choices = this.choices();
|
|
3828
|
+
if (!(this.value !== "" && choices.length > 0 && !choices.includes(this.value))) {
|
|
3829
|
+
this.complainedAbout = "\0";
|
|
3830
|
+
return;
|
|
3831
|
+
}
|
|
3832
|
+
if (this.complainedAbout === this.value) return;
|
|
3833
|
+
this.complainedAbout = this.value;
|
|
3834
|
+
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.`);
|
|
3835
|
+
}
|
|
4013
3836
|
_sync() {
|
|
4014
3837
|
const label = this._t(this.label);
|
|
4015
3838
|
if (label !== this.lastLabel && this.labelEl) {
|
|
@@ -4022,6 +3845,9 @@ var UiSelect = class extends HudWidgetBase {
|
|
|
4022
3845
|
this.select.value = this.value;
|
|
4023
3846
|
}
|
|
4024
3847
|
}
|
|
3848
|
+
_pull() {
|
|
3849
|
+
this.reportMismatch();
|
|
3850
|
+
}
|
|
4025
3851
|
};
|
|
4026
3852
|
/** Endonyms: `ko` must read as the Korean word to someone who cannot read `Korean`. */
|
|
4027
3853
|
const ENDONYMS = {
|
|
@@ -4067,21 +3893,19 @@ var UiLanguageSelect = class extends UiSelect {
|
|
|
4067
3893
|
label = "Language";
|
|
4068
3894
|
/** Last locale we PUSHED into `value`, to tell an engine change from a player one. */
|
|
4069
3895
|
lastLocale = "";
|
|
4070
|
-
|
|
3896
|
+
_pull() {
|
|
4071
3897
|
const locale = this.tree?.engine?.locale;
|
|
4072
|
-
if (locale)
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
}
|
|
3898
|
+
if (!locale) return;
|
|
3899
|
+
const available = locale.available();
|
|
3900
|
+
const options = available.join(",");
|
|
3901
|
+
if (options !== this.options) this.options = options;
|
|
3902
|
+
if (locale.locale !== this.lastLocale) {
|
|
3903
|
+
this.lastLocale = locale.locale;
|
|
3904
|
+
this.value = locale.locale;
|
|
3905
|
+
} else if (this.value !== locale.locale && available.includes(this.value)) {
|
|
3906
|
+
locale.locale = this.value;
|
|
3907
|
+
this.lastLocale = this.value;
|
|
4083
3908
|
}
|
|
4084
|
-
super._sync();
|
|
4085
3909
|
}
|
|
4086
3910
|
_optionLabel(value) {
|
|
4087
3911
|
return ENDONYMS[value] ?? value;
|
|
@@ -4112,18 +3936,19 @@ var UiSettingSelect = class extends UiSelect {
|
|
|
4112
3936
|
* `settings.quality.high` is worse than one reading `High`. English is the
|
|
4113
3937
|
* base language and the fallback, always.
|
|
4114
3938
|
*/
|
|
4115
|
-
|
|
3939
|
+
_pull() {
|
|
4116
3940
|
const engine = this._engine();
|
|
4117
|
-
if (engine)
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
}
|
|
3941
|
+
if (!engine) return;
|
|
3942
|
+
const stored = this._read(engine);
|
|
3943
|
+
if (stored !== this.lastPushed) {
|
|
3944
|
+
this.lastPushed = stored;
|
|
3945
|
+
this.value = stored;
|
|
3946
|
+
} else if (this.value !== stored) {
|
|
3947
|
+
this._write(engine, this.value);
|
|
3948
|
+
this.lastPushed = this._read(engine);
|
|
4126
3949
|
}
|
|
3950
|
+
}
|
|
3951
|
+
_sync() {
|
|
4127
3952
|
super._sync();
|
|
4128
3953
|
}
|
|
4129
3954
|
};
|
|
@@ -4286,19 +4111,17 @@ var UiVolumeSlider = class extends UiSlider {
|
|
|
4286
4111
|
const english = VOLUME_LABELS[this.bus] ?? VOLUME_LABELS.master;
|
|
4287
4112
|
return this._tOr(`settings.volume.${this.bus}`, english);
|
|
4288
4113
|
}
|
|
4289
|
-
|
|
4114
|
+
_pull() {
|
|
4290
4115
|
const audio = this.tree?.engine?.audio;
|
|
4291
|
-
if (audio)
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
}
|
|
4116
|
+
if (!audio) return;
|
|
4117
|
+
const stored = audio[this.bus];
|
|
4118
|
+
if (stored !== this.lastPushed) {
|
|
4119
|
+
this.lastPushed = stored;
|
|
4120
|
+
this.value = stored;
|
|
4121
|
+
} else if (this.value !== stored) {
|
|
4122
|
+
audio[this.bus] = this.value;
|
|
4123
|
+
this.lastPushed = audio[this.bus];
|
|
4300
4124
|
}
|
|
4301
|
-
super._sync();
|
|
4302
4125
|
}
|
|
4303
4126
|
};
|
|
4304
4127
|
/**
|
|
@@ -4321,18 +4144,16 @@ var UiMuteToggle = class extends UiToggle {
|
|
|
4321
4144
|
_labelText() {
|
|
4322
4145
|
return this.label ? this._t(this.label) : this._tOr("settings.mute", "Mute");
|
|
4323
4146
|
}
|
|
4324
|
-
|
|
4147
|
+
_pull() {
|
|
4325
4148
|
const audio = this.tree?.engine?.audio;
|
|
4326
|
-
if (audio)
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
}
|
|
4149
|
+
if (!audio) return;
|
|
4150
|
+
if (audio.muted !== this.lastPushed) {
|
|
4151
|
+
this.lastPushed = audio.muted;
|
|
4152
|
+
this.value = audio.muted;
|
|
4153
|
+
} else if (this.value !== audio.muted) {
|
|
4154
|
+
audio.muted = this.value;
|
|
4155
|
+
this.lastPushed = audio.muted;
|
|
4334
4156
|
}
|
|
4335
|
-
super._sync();
|
|
4336
4157
|
}
|
|
4337
4158
|
};
|
|
4338
4159
|
//#endregion
|
|
@@ -4366,4 +4187,4 @@ function registerCoreNodes() {
|
|
|
4366
4187
|
registerNode(UiMuteToggle);
|
|
4367
4188
|
}
|
|
4368
4189
|
//#endregion
|
|
4369
|
-
export {
|
|
4190
|
+
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 };
|