incanto 0.56.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/bin/incanto-verify.mjs +9 -2
  2. package/dist/2d.d.ts +59 -7
  3. package/dist/2d.js +3 -3
  4. package/dist/3d.d.ts +34 -4
  5. package/dist/3d.js +5 -5
  6. package/dist/{behavior-CyQoSu4n.d.ts → behavior-l08AEbq9.d.ts} +5 -0
  7. package/dist/{create-game-CqJkfMzm.js → create-game-C5jQYPah.js} +28 -71
  8. package/dist/{create-game-YvaNWhe6.js → create-game-DpbUrMOQ.js} +146 -7
  9. package/dist/debug.d.ts +1 -1
  10. package/dist/{duplicate-MNLMAcbz.js → duplicate-BPLZDZpd.js} +1 -1
  11. package/dist/{environment-presets-DLHqgNAF.js → environment-presets-CvvQr_bJ.js} +106 -24
  12. package/dist/{frame-report-njybhZon.js → frame-report-BSMny7oe.js} +1 -1
  13. package/dist/{frame-report-DZ70IY26.d.ts → frame-report-DCnHFmto.d.ts} +1 -1
  14. package/dist/{gameplay-C_SPzmUe.js → gameplay-BVphcxmE.js} +89 -13
  15. package/dist/gameplay.d.ts +26 -1
  16. package/dist/gameplay.js +1 -1
  17. package/dist/index.d.ts +45 -5
  18. package/dist/index.js +8 -9
  19. package/dist/{loader-DhI1jFW_.d.ts → loader-BbEMTuWg.d.ts} +1 -1
  20. package/dist/{loader-BTkHYrQn.js → loader-BcrRSjxB.js} +682 -682
  21. package/dist/net.d.ts +2 -2
  22. package/dist/net.js +1 -1
  23. package/dist/{pathfinding-CXGCpRQe.d.ts → pathfinding-mEN4V1CU.d.ts} +1 -1
  24. package/dist/{physics-2d-CfWAggJ1.js → physics-2d-BLcvEFDR.js} +2 -2
  25. package/dist/{physics-3d-NzDwWPrF.js → physics-3d-QBrfIT2Y.js} +3 -3
  26. package/dist/react.d.ts +1 -1
  27. package/dist/react.js +1 -1
  28. package/dist/{register-en63AEZO.js → register-C6ZBFRjd.js} +63 -60
  29. package/dist/{register-p48lHE2o.js → register-Ch70uByv.js} +1615 -1502
  30. package/dist/{replay-ePMz26jw.d.ts → replay-Dw6gMlYA.d.ts} +1 -1
  31. package/dist/{replay-t1pP0gQg.js → replay-s7I2GstT.js} +31 -11
  32. package/dist/sheet-grid-BT6N_Bjs.js +59 -0
  33. package/dist/{split-screen-BsdOHbzP.d.ts → split-screen-B0baBwxI.d.ts} +5 -1
  34. package/dist/{split-screen-CSb_uZ6W.js → split-screen-DLsUrleX.js} +14 -3
  35. package/dist/{sprite-animation-7qvUxF6Z.js → sprite-animation-C0wXLBZJ.js} +8 -4
  36. package/dist/{src-51GA-9m-.js → src-CGjmPw65.js} +55 -2
  37. package/dist/{teardown-C7uVSJvx.js → teardown-Cs113S9F.js} +74 -2
  38. package/dist/{test-Bu6SPNsV.js → test-it1VekWs.js} +28 -19
  39. package/dist/test.d.ts +4 -4
  40. package/dist/test.js +2 -2
  41. package/dist/{touch-BnMyy9tr.js → touch-DESwnpOc.js} +173 -1
  42. package/dist/vite.d.ts +1 -1
  43. package/dist/vite.js +3 -3
  44. package/editor/assets/{agent8-X0Nesj-k.js → agent8-CGT7r3Mb.js} +1 -1
  45. package/editor/assets/{debug-TXqu-ftX.js → debug-BxWSIHG3.js} +1 -1
  46. package/editor/assets/{index-DTdwKKIv.js → index-CV1m-aX5.js} +62 -62
  47. package/editor/index.html +1 -1
  48. package/package.json +1 -1
  49. package/skills/incanto-assets.md +5 -1
  50. package/skills/incanto-audio.md +7 -1
  51. package/skills/incanto-gameplay-behaviors.md +2 -1
  52. package/skills/incanto-hud.md +6 -0
  53. package/skills/incanto-performance.md +5 -0
  54. package/skills/incanto-physics-and-input.md +14 -0
  55. package/skills/incanto-save-slots.md +2 -1
  56. package/skills/incanto-verifying-your-game.md +4 -0
  57. package/templates-app/beacon-isle-3d/package.json +1 -1
  58. package/templates-app/tps-3d/package.json +1 -1
  59. package/templates-app/village-quest-3d/package.json +1 -1
  60. package/dist/log-report-CPFm4OXf.js +0 -173
@@ -1,4 +1,4 @@
1
- import { C as diagnose, M as Signal, S as InputMap, f as Node, g as restoreBehaviors, h as captureBehaviors, v as createSaveStore, x as resolveOrderGroups, y as ORDER_GROUP_BASE } from "./loader-BTkHYrQn.js";
1
+ import { C as diagnose, M as Signal, S as Node, _ as createSaveStore, b as resolveOrderGroups, h as restoreBehaviors, m as captureBehaviors, v as ORDER_GROUP_BASE, x as InputMap } from "./loader-BcrRSjxB.js";
2
2
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
3
3
  import { t as Rng } from "./rng-DP-SR7eg.js";
4
4
  import { l as registerNode } from "./registry-C7u42TID.js";
@@ -153,10 +153,15 @@ const INERT$1 = {
153
153
  var WebAudioTrack = class {
154
154
  src;
155
155
  el;
156
+ onError;
156
157
  gainNode;
157
- constructor(src, el, ctx) {
158
+ constructor(src, el, ctx, onError) {
158
159
  this.src = src;
159
160
  this.el = el;
161
+ this.onError = onError;
162
+ el.addEventListener?.("error", () => {
163
+ this.fail(`could not load ${src}`);
164
+ });
160
165
  try {
161
166
  el.crossOrigin = "anonymous";
162
167
  } catch {}
@@ -173,8 +178,18 @@ var WebAudioTrack = class {
173
178
  this.el.loop = loop;
174
179
  }
175
180
  play() {
176
- this.el.play().catch(() => {});
181
+ this.el.play().catch((error) => {
182
+ const name = error?.name ?? "";
183
+ if (name === "NotSupportedError") this.fail(`${name}: ${this.src}`);
184
+ });
185
+ }
186
+ /** Report ONCE — a track that cannot play must not spam a per-frame tick. */
187
+ fail(reason) {
188
+ if (this.failed) return;
189
+ this.failed = true;
190
+ this.onError?.(this.src, reason);
177
191
  }
192
+ failed = false;
178
193
  stop() {
179
194
  this.el.pause();
180
195
  this.el.currentTime = 0;
@@ -194,6 +209,9 @@ var WebAudioMusicBackend = class {
194
209
  }
195
210
  return this.ctx;
196
211
  }
212
+ /** Told when a track will never play. Set by the manager, which knows the
213
+ * engine that owns the log. */
214
+ onTrackError = null;
197
215
  createTrack(src) {
198
216
  const ctx = this.ensure();
199
217
  const el = createElement$1();
@@ -202,7 +220,7 @@ var WebAudioMusicBackend = class {
202
220
  src
203
221
  };
204
222
  if (ctx.state === "suspended") ctx.resume();
205
- return new WebAudioTrack(src, el, ctx);
223
+ return new WebAudioTrack(src, el, ctx, (s, reason) => this.onTrackError?.(s, reason));
206
224
  }
207
225
  unlock() {
208
226
  const ctx = this.ensure();
@@ -280,10 +298,19 @@ var MusicManager = class {
280
298
  if (!this.resolved) {
281
299
  const b = new WebAudioMusicBackend();
282
300
  this.backend = b && b.available === false ? null : b;
301
+ const withErrors = this.backend;
302
+ if (withErrors && "onTrackError" in withErrors) withErrors.onTrackError = (src, reason) => {
303
+ this.onTrackError?.(src, reason);
304
+ };
283
305
  this.resolved = true;
284
306
  }
285
307
  return this.backend ?? SILENT_BACKEND;
286
308
  }
309
+ /**
310
+ * Where a track that will never play gets reported. The engine sets this; a
311
+ * bare MusicManager (tests) stays quiet.
312
+ */
313
+ onTrackError = null;
287
314
  /** Resume a gesture-suspended context + (re)start any pending track. */
288
315
  unlock() {
289
316
  this.ensure()?.unlock();
@@ -1156,1669 +1183,1733 @@ function suggestLocale(available, languages) {
1156
1183
  return "en";
1157
1184
  }
1158
1185
  //#endregion
1159
- //#region src/core/effects-log.ts
1160
- const EFFECT_LOG_LIMIT = 200;
1161
- var EffectLog = class {
1162
- events = [];
1163
- /** @internal Called by the effects themselves; games read `recent()`. */
1164
- record(event) {
1165
- this.events.push(event);
1166
- if (this.events.length > EFFECT_LOG_LIMIT) this.events.shift();
1167
- }
1168
- /** The last `limit` effects, oldest first. */
1169
- recent(limit = EFFECT_LOG_LIMIT) {
1170
- return this.events.slice(Math.max(0, this.events.length - limit));
1171
- }
1172
- /** How many times an effect with this name fired (`explosion`, `#ff0000`). */
1173
- countOf(name) {
1174
- return this.events.reduce((n, e) => e.name === name ? n + 1 : n, 0);
1175
- }
1176
- /** How many effects this node fired — the question a wiring check has. */
1177
- countFrom(path) {
1178
- return this.events.reduce((n, e) => e.from === path ? n + 1 : n, 0);
1179
- }
1180
- /** Forget what has fired so far (between scenes, or between assertions). */
1181
- clearLog() {
1182
- this.events.length = 0;
1183
- }
1186
+ //#region src/core/nodes/hud.ts
1187
+ const ANCHOR_CSS = {
1188
+ topLeft: "left:16px;top:16px;align-items:flex-start;",
1189
+ top: "left:50%;top:16px;transform:translateX(-50%);align-items:center;",
1190
+ topRight: "right:16px;top:16px;align-items:flex-end;",
1191
+ left: "left:16px;top:50%;transform:translateY(-50%);align-items:flex-start;",
1192
+ center: "left:50%;top:50%;transform:translate(-50%,-50%);align-items:center;",
1193
+ right: "right:16px;top:50%;transform:translateY(-50%);align-items:flex-end;",
1194
+ bottomLeft: "left:16px;bottom:16px;align-items:flex-start;",
1195
+ bottom: "left:50%;bottom:16px;transform:translateX(-50%);align-items:center;",
1196
+ bottomRight: "right:16px;bottom:16px;align-items:flex-end;"
1184
1197
  };
1185
- //#endregion
1186
- //#region src/core/frame-stats.ts
1198
+ const FONT = "600 14px system-ui, -apple-system, 'Segoe UI', sans-serif";
1199
+ function hasDom() {
1200
+ return typeof document !== "undefined";
1201
+ }
1187
1202
  /**
1188
- * Rolling window of REAL frame timestamps — the math behind `engine.stats()`
1189
- * fps/frameMs. Pure and renderer-free: push tick times in, read averages out.
1190
- *
1191
- * Only `Engine.tick(nowMs)` feeds it; headless `step()` advances sim time
1192
- * without wall-clock frames, so stepped runs honestly report 0 fps.
1203
+ * The overlay container. One per scene is plenty; widgets mount into its
1204
+ * anchor slots. `zIndex` lifts it above game canvases; the layer never eats
1205
+ * pointer events (widgets that need clicks opt in individually).
1193
1206
  */
1194
- var FrameStatsRing = class {
1195
- capacity;
1196
- times;
1197
- head = 0;
1198
- count = 0;
1199
- constructor(capacity = 60) {
1200
- this.capacity = capacity;
1201
- this.times = new Array(capacity);
1207
+ var HudLayer = class extends Node {
1208
+ static typeName = "HudLayer";
1209
+ static props = {
1210
+ zIndex: { default: 100 },
1211
+ visible: { default: true },
1212
+ /**
1213
+ * Arrow keys / d-pad move focus between the focusable widgets under this
1214
+ * layer, Enter / A activates. OFF by default — a game whose HUD has a
1215
+ * button must not lose its arrow keys the moment one exists.
1216
+ */
1217
+ focusNavigation: { default: false }
1218
+ };
1219
+ zIndex = 100;
1220
+ visible = true;
1221
+ focusNavigation = false;
1222
+ /** @internal root overlay element (null headless). */
1223
+ _element = null;
1224
+ slots = /* @__PURE__ */ new Map();
1225
+ onEnterTree() {
1226
+ if (!hasDom()) return;
1227
+ const el = document.createElement("div");
1228
+ el.dataset.incantoHud = this.name;
1229
+ el.style.cssText = `position:fixed;inset:0;pointer-events:none;z-index:${this.zIndex};font:${FONT};`;
1230
+ this._element = el;
1231
+ this._mount();
1202
1232
  }
1203
- /** Record one frame timestamp (ms). Evicts the oldest at capacity. */
1204
- push(nowMs) {
1205
- this.times[(this.head + this.count) % this.capacity] = nowMs;
1206
- if (this.count < this.capacity) this.count += 1;
1207
- else this.head = (this.head + 1) % this.capacity;
1233
+ /**
1234
+ * Put the overlay where the page's owner says UI goes: `engine.uiHost`, or
1235
+ * the window when nobody claims it (a game — a HUD covers the screen).
1236
+ *
1237
+ * Re-checked every frame because a scene's tree is BUILT before it is handed
1238
+ * to an engine: at onEnterTree there is no engine to ask yet, so the first
1239
+ * update is where the answer actually arrives. appendChild moves the element
1240
+ * if it already landed somewhere else, so the HUD follows the host across a
1241
+ * scene swap into a differently-hosted engine (game → editor) instead of
1242
+ * being stranded on document.body over the inspector.
1243
+ */
1244
+ _mount() {
1245
+ const el = this._element;
1246
+ if (!el) return;
1247
+ const host = this.tree?.engine?.uiHost ?? document.body;
1248
+ if (el.parentElement === host) return;
1249
+ el.style.position = host === document.body ? "fixed" : "absolute";
1250
+ host.appendChild(el);
1208
1251
  }
1209
- /** Frames per second over the window — 0 with fewer than two samples. */
1210
- fps() {
1211
- const span = this.spanMs();
1212
- return span > 0 ? (this.count - 1) / span * 1e3 : 0;
1252
+ onExitTree() {
1253
+ this._element?.remove();
1254
+ this._element = null;
1255
+ this.slots.clear();
1213
1256
  }
1214
- /** Mean delta between frames in ms — 0 with fewer than two samples. */
1215
- frameMs() {
1216
- const span = this.spanMs();
1217
- return span > 0 ? span / (this.count - 1) : 0;
1257
+ update() {
1258
+ if (this._element) {
1259
+ this._mount();
1260
+ this._element.style.display = this.visible ? "" : "none";
1261
+ }
1262
+ if (this.focusNavigation) this.stepFocus();
1218
1263
  }
1219
- /** Forget all samples (engine.stop() — a paused game has no frame rate). */
1220
- clear() {
1221
- this.head = 0;
1222
- this.count = 0;
1264
+ /**
1265
+ * Arrow keys / d-pad move the focus, Enter / A activates it.
1266
+ *
1267
+ * Off by default: a game whose HUD has a button must not lose its arrow keys
1268
+ * to a menu the moment one exists. Turn it on for the screens that ARE menus
1269
+ * (`"focusNavigation": true` on the pause panel's layer), and off again when
1270
+ * play resumes.
1271
+ */
1272
+ stepFocus() {
1273
+ const engine = this.tree?.engine;
1274
+ if (!engine) return;
1275
+ const items = this.focusables();
1276
+ if (items.length === 0) return;
1277
+ const input = engine.input;
1278
+ const down = input.keyJustPressed("ArrowDown") || input.keyJustPressed("Pad13");
1279
+ const up = input.keyJustPressed("ArrowUp") || input.keyJustPressed("Pad12");
1280
+ const left = input.keyJustPressed("ArrowLeft") || input.keyJustPressed("Pad14");
1281
+ const right = input.keyJustPressed("ArrowRight") || input.keyJustPressed("Pad15");
1282
+ const confirm = input.keyJustPressed("Enter") || input.keyJustPressed("Pad0");
1283
+ let index = items.findIndex((w) => w._focused);
1284
+ if (index === -1 && (down || up || confirm)) index = 0;
1285
+ else if (down) index = (index + 1) % items.length;
1286
+ else if (up) index = (index - 1 + items.length) % items.length;
1287
+ if (index !== -1) {
1288
+ for (const w of items) {
1289
+ const next = w === items[index];
1290
+ if (next !== w._focused) {
1291
+ w._focused = next;
1292
+ w._paintFocus();
1293
+ }
1294
+ }
1295
+ const focused = items[index];
1296
+ if (focused) {
1297
+ if (confirm) focused._activate(0);
1298
+ else if (left) focused._activate(-1);
1299
+ else if (right) focused._activate(1);
1300
+ }
1301
+ }
1223
1302
  }
1224
- spanMs() {
1225
- if (this.count < 2) return 0;
1226
- const oldest = this.times[this.head];
1227
- return this.times[(this.head + this.count - 1) % this.capacity] - oldest;
1303
+ /** Focusable widgets under this layer, in tree order. */
1304
+ focusables() {
1305
+ const out = [];
1306
+ const walk = (node) => {
1307
+ if (node instanceof HudWidgetBase && node.focusable && node.visible) out.push(node);
1308
+ for (const child of node.children) walk(child);
1309
+ };
1310
+ walk(this);
1311
+ return out;
1312
+ }
1313
+ /** Move focus to a widget by hand — opening a menu should start somewhere. */
1314
+ focus(widget) {
1315
+ for (const w of this.focusables()) {
1316
+ const next = w === widget;
1317
+ if (next !== w._focused) {
1318
+ w._focused = next;
1319
+ w._paintFocus();
1320
+ }
1321
+ }
1322
+ }
1323
+ /** @internal Widgets mount into per-anchor flex columns. */
1324
+ _slot(anchor) {
1325
+ if (!this._element) return null;
1326
+ let slot = this.slots.get(anchor);
1327
+ if (!slot) {
1328
+ slot = document.createElement("div");
1329
+ slot.style.cssText = `position:absolute;display:flex;flex-direction:column;gap:8px;${ANCHOR_CSS[anchor] ?? ANCHOR_CSS.topLeft}`;
1330
+ this._element.appendChild(slot);
1331
+ this.slots.set(anchor, slot);
1332
+ }
1333
+ return slot;
1228
1334
  }
1229
1335
  };
1230
- //#endregion
1231
- //#region src/core/log.ts
1232
1336
  /**
1233
- * The engine's log channel (`engine.log`, `this.log` in a Behavior): a ring
1234
- * buffer plus a live `added` signal, so debug overlays and headless test
1235
- * harnesses can tail game logs without scraping the browser console.
1337
+ * The widget an element belongs to.
1338
+ *
1339
+ * A pick lands on whatever div is under the cursor — usually a CHILD of the
1340
+ * widget (an icon inside a slot), so the walk upward is the point.
1236
1341
  */
1237
- var LogManager = class {
1238
- /** Fires once per entry, after it is buffered. */
1239
- added = new Signal();
1240
- buffer = [];
1241
- capacity;
1242
- seq = 0;
1243
- constructor(capacity = 1e3) {
1244
- this.capacity = capacity;
1245
- }
1246
- debug(...parts) {
1247
- this.push("debug", parts);
1248
- }
1249
- info(...parts) {
1250
- this.push("info", parts);
1342
+ const widgetOf = /* @__PURE__ */ new WeakMap();
1343
+ function widgetFromElement(el) {
1344
+ for (let n = el; n; n = n.parentElement) {
1345
+ const found = widgetOf.get(n);
1346
+ if (found) return found;
1251
1347
  }
1252
- warn(...parts) {
1253
- this.push("warn", parts);
1348
+ return null;
1349
+ }
1350
+ /** The drag in flight, if any. One pointer, one drag. */
1351
+ let dragging = null;
1352
+ /** Shared plumbing: mount into the parent HudLayer's anchor slot. */
1353
+ var HudWidgetBase = class extends Node {
1354
+ /**
1355
+ * Wireable visibility: `noSave → Continue.hide`.
1356
+ *
1357
+ * `visible` is a prop, and a `connections[]` handler has to be a METHOD — the
1358
+ * same wall that kept a score off the screen until widgets grew `setText`. A
1359
+ * title screen greying out its own Continue button is the case that asked.
1360
+ */
1361
+ show(..._args) {
1362
+ this.visible = true;
1254
1363
  }
1255
- error(...parts) {
1256
- this.push("error", parts);
1257
- }
1258
- /** Buffered entries, oldest first (capped at capacity). */
1259
- entries() {
1260
- return this.buffer;
1261
- }
1262
- /** Empty the buffer. The sequence keeps counting (entries stay unique). */
1263
- clear() {
1264
- this.buffer.length = 0;
1265
- }
1266
- push(level, parts) {
1267
- this.seq += 1;
1268
- const entry = {
1269
- seq: this.seq,
1270
- timeMs: globalThis.performance?.now() ?? 0,
1271
- level,
1272
- parts
1273
- };
1274
- this.buffer.push(entry);
1275
- if (this.buffer.length > this.capacity) this.buffer.splice(0, this.buffer.length - this.capacity);
1276
- this.added.emit(entry);
1277
- }
1278
- };
1279
- //#endregion
1280
- //#region src/core/phase-timing.ts
1281
- const EMPTY = {
1282
- fixedMs: 0,
1283
- updateMs: 0,
1284
- renderMs: 0,
1285
- otherMs: 0
1286
- };
1287
- var PhaseTimings = class {
1288
- capacity;
1289
- fixed = [];
1290
- update = [];
1291
- render = [];
1292
- head = 0;
1293
- constructor(capacity = 60) {
1294
- this.capacity = capacity;
1295
- }
1296
- /** Bank one frame's engine-side work. */
1297
- push(fixedMs, updateMs, renderMs) {
1298
- const i = this.head % this.capacity;
1299
- this.fixed[i] = fixedMs;
1300
- this.update[i] = updateMs;
1301
- this.render[i] = renderMs;
1302
- this.head += 1;
1303
- }
1304
- /** Means over the window; `frameMs` (0 headless) fills in `otherMs`. */
1305
- read(frameMs) {
1306
- const n = Math.min(this.head, this.capacity);
1307
- if (n === 0) return { ...EMPTY };
1308
- const fixedMs = mean(this.fixed, n);
1309
- const updateMs = mean(this.update, n);
1310
- const renderMs = mean(this.render, n);
1311
- return {
1312
- fixedMs,
1313
- updateMs,
1314
- renderMs,
1315
- otherMs: Math.max(0, frameMs - fixedMs - updateMs - renderMs)
1316
- };
1317
- }
1318
- clear() {
1319
- this.fixed.length = 0;
1320
- this.update.length = 0;
1321
- this.render.length = 0;
1322
- this.head = 0;
1323
- }
1324
- };
1325
- function mean(values, n) {
1326
- let total = 0;
1327
- for (let i = 0; i < n; i++) total += values[i] ?? 0;
1328
- return total / n;
1329
- }
1330
- //#endregion
1331
- //#region src/core/settings.ts
1332
- const DEFAULTS = {
1333
- master: 1,
1334
- sfx: 1,
1335
- music: 1,
1336
- muted: false,
1337
- quality: "high",
1338
- sensitivity: 1,
1339
- invertY: false,
1340
- reduceMotion: false,
1341
- language: "en",
1342
- qualityChosen: false,
1343
- maxFps: 0,
1344
- renderScale: 1
1345
- };
1346
- const TIERS = [
1347
- "low",
1348
- "medium",
1349
- "high"
1350
- ];
1351
- /**
1352
- * A default tier from what the browser will admit to.
1353
- *
1354
- * Deliberately crude: this picks a STARTING point, and the options menu is the
1355
- * real answer. A phone that reports four cores and a coarse pointer should not
1356
- * open on the setting tuned for a desktop GPU.
1357
- */
1358
- function suggestQuality(hints) {
1359
- const cores = hints.hardwareConcurrency ?? 8;
1360
- const memory = hints.deviceMemory ?? 8;
1361
- if (hints.coarsePointer && (cores <= 6 || memory <= 4)) return "low";
1362
- if (cores <= 4 || memory <= 4 || (hints.maxTextureSize ?? 8192) < 8192) return "low";
1363
- if (cores <= 8 || memory <= 8) return "medium";
1364
- return "high";
1365
- }
1366
- /** Read what this environment will tell us, safely, without touching WebGL. */
1367
- function readDeviceHints() {
1368
- const nav = globalThis.navigator;
1369
- const mm = globalThis.matchMedia;
1370
- const out = {};
1371
- if (typeof nav?.hardwareConcurrency === "number") out.hardwareConcurrency = nav.hardwareConcurrency;
1372
- if (typeof nav?.deviceMemory === "number") out.deviceMemory = nav.deviceMemory;
1373
- try {
1374
- if (mm) out.coarsePointer = mm("(pointer: coarse)").matches;
1375
- } catch {}
1376
- return out;
1377
- }
1378
- var Settings = class {
1379
- /** Fires after any change, with the key that changed. */
1380
- changed = new Signal();
1381
- store;
1382
- values;
1383
- constructor(namespace = "settings", store) {
1384
- this.store = store ?? createSaveStore(namespace);
1385
- this.values = { ...DEFAULTS };
1386
- for (const key of Object.keys(DEFAULTS)) {
1387
- const saved = this.store.get(key, DEFAULTS[key]);
1388
- this.values[key] = saved;
1389
- }
1390
- if (!TIERS.includes(this.values.quality)) this.values.quality = DEFAULTS.quality;
1391
- }
1392
- get(key) {
1393
- return this.values[key];
1394
- }
1395
- set(key, value) {
1396
- if (this.values[key] === value) return;
1397
- this.values[key] = value;
1398
- this.store.set(key, value);
1399
- this.changed.emit(key);
1400
- }
1401
- /** Every value at once — for an options screen, or a save-file dump. */
1402
- all() {
1403
- return { ...this.values };
1404
- }
1405
- /** Back to defaults, on disk too. */
1406
- reset() {
1407
- for (const key of Object.keys(DEFAULTS)) this.set(key, DEFAULTS[key]);
1364
+ hide() {
1365
+ this.visible = false;
1408
1366
  }
1367
+ static props = {
1368
+ anchor: {
1369
+ default: "topLeft",
1370
+ options: [
1371
+ "topLeft",
1372
+ "top",
1373
+ "topRight",
1374
+ "left",
1375
+ "center",
1376
+ "right",
1377
+ "bottomLeft",
1378
+ "bottom",
1379
+ "bottomRight"
1380
+ ]
1381
+ },
1382
+ visible: { default: true },
1383
+ /**
1384
+ * Can menu navigation land on this? Interactive widgets default to true.
1385
+ *
1386
+ * A menu you can only click is not playable on a controller, and "add
1387
+ * gamepad support" is not a thing a JSON scene could express at all.
1388
+ */
1389
+ focusable: { default: false },
1390
+ /**
1391
+ * Can the player pick this up and drop it somewhere? An inventory item is
1392
+ * a `draggable` widget; a slot is a `dropTarget`.
1393
+ */
1394
+ draggable: { default: false },
1395
+ /** Can something be dropped ON this? */
1396
+ dropTarget: { default: false }
1397
+ };
1398
+ static signals = [
1399
+ "dragStarted",
1400
+ "dragCancelled",
1401
+ "droppedOn",
1402
+ "dropped"
1403
+ ];
1404
+ anchor = "topLeft";
1405
+ visible = true;
1406
+ focusable = false;
1407
+ draggable = false;
1408
+ dropTarget = false;
1409
+ /** @internal Set by the owning HudLayer while this widget has focus. */
1410
+ _focused = false;
1409
1411
  /**
1410
- * Choose a starting tier from the device, ONCE.
1411
- *
1412
- * Only on a first visit: after someone has picked a tier, re-detecting on
1413
- * every launch would silently undo their choice — which is the behaviour every
1414
- * player experiences as "the settings do not save".
1412
+ * @internal Confirm (`dir` 0) or nudge (-1 left / +1 right) — what pressing
1413
+ * A or an arrow ON this widget means. Default: nothing.
1415
1414
  */
1416
- autoQuality(hints = readDeviceHints()) {
1417
- if (!this.values.qualityChosen) this.values.quality = suggestQuality(hints);
1418
- return this.values.quality;
1415
+ _activate(_dir) {}
1416
+ /** @internal Draw the focus ring. Overridable if a widget wants its own. */
1417
+ _paintFocus() {
1418
+ if (!this._element) return;
1419
+ this._element.style.outline = this._focused ? "2px solid #6ee7dc" : "";
1420
+ this._element.style.outlineOffset = this._focused ? "2px" : "";
1419
1421
  }
1420
- /** Record that the tier came from a person, not from the device probe. */
1421
- chooseQuality(tier) {
1422
- this.set("quality", tier);
1423
- this.set("qualityChosen", true);
1422
+ /** @internal */
1423
+ _element = null;
1424
+ layer() {
1425
+ for (let p = this.parent; p; p = p.parent) if (p instanceof HudLayer) return p;
1426
+ return null;
1427
+ }
1428
+ onReady() {
1429
+ if (!hasDom()) return;
1430
+ this._mountWidget();
1424
1431
  }
1425
1432
  /**
1426
- * Apply the saved quality tier to a live scene, and keep applying it.
1433
+ * @internal Build and attach, once a host exists.
1427
1434
  *
1428
- * The same shape as `bindAudio` and `bindLocale`. Until 0.38 this did not
1429
- * exist: `settings.quality` was written to disk and read by NOBODY, and
1430
- * `qualityEnvironment` — the function that turns a tier into something the
1431
- * renderer understands — emitted patches the environment parser rejects, for
1432
- * all three tiers. Both were public, documented and never once called.
1435
+ * A widget inside a UiPanel mounts into the PANEL, not the layer's anchor
1436
+ * slot — that is what turns structure in the tree into structure on screen,
1437
+ * and it is what a menu is. Ready order does not decide it: the panel builds
1438
+ * on demand when a child asks (`_container`), and `update` retries for
1439
+ * anything attached at runtime before its host existed.
1433
1440
  */
1434
- bindQuality(apply) {
1435
- apply(qualityEnvironment(this.values.quality));
1436
- return this.changed.connect((key) => {
1437
- if (key === "quality") apply(qualityEnvironment(this.values.quality));
1438
- });
1441
+ _mountWidget() {
1442
+ if (this._element || !hasDom()) return;
1443
+ const host = this.host() ?? this.layer()?._slot(this.anchor);
1444
+ if (!host) return;
1445
+ this._element = this._build();
1446
+ widgetOf.set(this._element, this);
1447
+ if (this.draggable || this.dropTarget) this._wireDrag(this._element);
1448
+ host.appendChild(this._element);
1439
1449
  }
1440
1450
  /**
1441
- * Apply the saved frame cap, and again whenever it changes.
1451
+ * Drag and drop, on the DOM the engine already builds.
1442
1452
  *
1443
- * Separate from `bindQuality` because it is not a tier: a player caps the
1444
- * frame rate to keep a phone cool or a battery alive, on the highest visual
1445
- * settings the device can hold, and those two choices do not belong on one
1446
- * slider. 0 means uncapped. Returns the unsubscribe.
1447
- */
1448
- bindFrameCap(apply) {
1449
- apply(this.values.maxFps);
1450
- return this.changed.connect((key) => {
1451
- if (key === "maxFps") apply(this.values.maxFps);
1452
- });
1453
+ * An inventory is the one screen where "click it" is not enough, and every
1454
+ * game that wanted one dropped out of scene JSON to hand-roll pointer
1455
+ * handlers. The whole gesture is four signals and no new node type: a
1456
+ * `draggable` widget emits `dragStarted` and, if it lands on one,
1457
+ * `droppedOn(target)`; a `dropTarget` emits `dropped(source)`. Who owns the
1458
+ * ITEM is the game's business — this reports the gesture, not a model.
1459
+ */
1460
+ _wireDrag(el) {
1461
+ el.style.touchAction = "none";
1462
+ if (this.draggable) {
1463
+ el.style.cursor = "grab";
1464
+ el.addEventListener("pointerdown", (event) => {
1465
+ if (!this.draggable) return;
1466
+ event.preventDefault();
1467
+ el.setPointerCapture?.(event.pointerId);
1468
+ dragging = this;
1469
+ el.style.opacity = "0.6";
1470
+ this.emit("dragStarted", this);
1471
+ });
1472
+ el.addEventListener("pointerup", (event) => {
1473
+ if (dragging !== this) return;
1474
+ el.style.opacity = "";
1475
+ el.releasePointerCapture?.(event.pointerId);
1476
+ const target = widgetFromElement(document.elementFromPoint?.(event.clientX, event.clientY) ?? null);
1477
+ dragging = null;
1478
+ if (target && target !== this && target.dropTarget) {
1479
+ this.emit("droppedOn", target);
1480
+ target.emit("dropped", this);
1481
+ } else this.emit("dragCancelled", this);
1482
+ });
1483
+ }
1484
+ }
1485
+ /** The nearest ancestor that holds widgets itself (a UiPanel). */
1486
+ host() {
1487
+ for (let p = this.parent; p; p = p.parent) {
1488
+ const holder = p;
1489
+ if (typeof holder._container === "function") return holder._container();
1490
+ if (p instanceof HudLayer) return null;
1491
+ }
1492
+ return null;
1493
+ }
1494
+ onExitTree() {
1495
+ this._element?.remove();
1496
+ this._element = null;
1497
+ }
1498
+ update(_dt) {
1499
+ if (!this._element) {
1500
+ this._mountWidget();
1501
+ if (!this._element) return;
1502
+ }
1503
+ this._element.style.display = this.visible ? "" : "none";
1504
+ this._followAnchor();
1505
+ this._sync();
1453
1506
  }
1507
+ /** Which anchor slot this widget was mounted into. */
1508
+ mountedAnchor = null;
1454
1509
  /**
1455
- * Apply the saved resolution multiplier, and again whenever it changes.
1510
+ * Move to the slot the anchor now names.
1456
1511
  *
1457
- * The lever that buys the most frames per unit of ugliness on a weak GPU —
1458
- * shading 44% of the pixels at 0.66 costs a soft image and nothing else,
1459
- * while dropping a quality tier costs shadows entirely.
1512
+ * `_mountWidget` read `anchor` once and `_element` is nulled only on
1513
+ * `onExitTree`, so moving a widget from topLeft to bottomRight — from a
1514
+ * behavior or from the editor's inspector, which live-patches string props —
1515
+ * did nothing at all, forever.
1460
1516
  */
1461
- bindRenderScale(apply) {
1462
- apply(this.values.renderScale);
1463
- return this.changed.connect((key) => {
1464
- if (key === "renderScale") apply(this.values.renderScale);
1465
- });
1517
+ _followAnchor() {
1518
+ if (this.host()) return;
1519
+ if (this.mountedAnchor === null) this.mountedAnchor = this.anchor;
1520
+ if (this.mountedAnchor === this.anchor) return;
1521
+ const slot = this.layer()?._slot(this.anchor);
1522
+ if (!slot || !this._element) return;
1523
+ this.mountedAnchor = this.anchor;
1524
+ slot.appendChild(this._element);
1466
1525
  }
1526
+ _sync() {}
1467
1527
  /**
1468
- * Apply the saved language, and keep it in step both ways.
1528
+ * Resolve a text prop through the engine's locale.
1469
1529
  *
1470
- * The same shape as `bindAudio`: a game that writes
1471
- * `engine.locale.locale = 'ko'` from an options menu has already persisted it
1472
- * without knowing this class exists. Returns the unsubscribe.
1530
+ * Called every frame from `_sync`, which is what makes switching language
1531
+ * repaint with nothing to invalidate: widgets already re-read their props, so
1532
+ * the new string simply appears on the next frame. A plain string costs one
1533
+ * `startsWith` and is returned untouched.
1473
1534
  */
1474
- bindLocale(locale) {
1475
- locale.locale = this.values.language;
1476
- return locale.changed.connect((next) => {
1477
- this.set("language", next);
1478
- });
1535
+ _t(value) {
1536
+ const locale = this.tree?.engine?.locale;
1537
+ return locale ? locale.resolve(value) : value;
1479
1538
  }
1539
+ };
1540
+ /** A text line (score, timer, hints). Set `.text` from behaviors. */
1541
+ var UiText = class extends HudWidgetBase {
1542
+ static typeName = "UiText";
1543
+ static props = {
1544
+ ...HudWidgetBase.props,
1545
+ text: { default: "" },
1546
+ format: { default: "" },
1547
+ size: { default: 16 },
1548
+ color: { default: "#ffffff" },
1549
+ shadow: { default: true }
1550
+ };
1551
+ text = "";
1480
1552
  /**
1481
- * Push the saved volumes into the engine's buses, and keep writing them back
1482
- * whenever anything changes them.
1553
+ * A template with a `{}` slot that `setText` fills — `"Gems {} / 8"`.
1483
1554
  *
1484
- * The subscription is the point: a game that sets `engine.audio.music = 0.4`
1485
- * from a slider has already persisted it, without knowing this class exists.
1555
+ * Empty (the default) means `setText` replaces the whole line. Resolved at
1556
+ * PAINT like `text`, so `"@t:hud.gems"` works and changing locale re-reads
1557
+ * it: the value lives here, the words live in the strings table.
1486
1558
  */
1487
- bindAudio(audio) {
1488
- audio.master = this.values.master;
1489
- audio.sfx = this.values.sfx;
1490
- audio.music = this.values.music;
1491
- audio.muted = this.values.muted;
1492
- return audio.changed.connect(() => {
1493
- this.set("master", audio.master);
1494
- this.set("sfx", audio.sfx);
1495
- this.set("music", audio.music);
1496
- this.set("muted", audio.muted);
1497
- });
1559
+ format = "";
1560
+ size = 16;
1561
+ color = "#ffffff";
1562
+ shadow = true;
1563
+ last = "\0";
1564
+ /** null until something has actually set a value — see `shown`. */
1565
+ slot = null;
1566
+ /**
1567
+ * Put a signal's value on screen — `scoreChanged → setText`.
1568
+ *
1569
+ * A `connections[]` handler must be a METHOD and `text` is a property, so the
1570
+ * one wire every game needs (score to screen) could not be declared: every
1571
+ * HUD began with a behavior whose whole job was one assignment.
1572
+ */
1573
+ setText(value) {
1574
+ this.slot = String(value ?? "");
1575
+ if (!this.format) this.text = this.slot;
1576
+ }
1577
+ /**
1578
+ * What it actually paints, after the template and the locale.
1579
+ *
1580
+ * Before the first value arrives, a formatted widget shows `text` — its
1581
+ * authored opening line. Filling the slot with nothing instead would put
1582
+ * `"Gems / 8"` on screen for every game's first frame, and a score line
1583
+ * that starts blank looks broken rather than empty.
1584
+ */
1585
+ get shown() {
1586
+ if (this.format && this.slot !== null) return this._t(this.format).replace("{}", this.slot);
1587
+ return this._t(this.text);
1588
+ }
1589
+ _build() {
1590
+ const el = document.createElement("div");
1591
+ el.style.cssText = this.look();
1592
+ this.lastLook = this.look();
1593
+ return el;
1594
+ }
1595
+ /** The whole appearance as one string, so re-applying is one comparison. */
1596
+ look() {
1597
+ return `font-size:${this.size}px;color:${this.color};${this.shadow ? "text-shadow:0 1px 3px rgba(0,0,0,.7);" : ""}white-space:pre;`;
1598
+ }
1599
+ lastLook = "";
1600
+ _sync() {
1601
+ const shown = this.shown;
1602
+ if (shown !== this.last && this._element) {
1603
+ this.last = shown;
1604
+ this._element.textContent = shown;
1605
+ }
1606
+ const look = this.look();
1607
+ if (look !== this.lastLook && this._element) {
1608
+ this.lastLook = look;
1609
+ this._element.style.cssText = look;
1610
+ }
1498
1611
  }
1499
1612
  };
1500
1613
  /**
1501
- * What a quality tier MEANS, as a LIVE environment patch.
1502
- *
1503
- * Only the levers that actually cost: shadows, post, bloom and clouds.
1504
- * Deliberately an `environment` patch, because `setEnvironment3D(engine, patch)`
1505
- * already applies one live and validated — this is not a second rendering
1506
- * pipeline, it is three presets for the one that exists.
1507
- *
1508
- * The `off` spelling matters and got this wrong for three releases: every tier
1509
- * emitted `{ enabled: false }`, which the environment parser rejects
1510
- * (`environment.bloom has unknown key 'enabled'`). Nothing called this function,
1511
- * so nothing found out. In a live patch `null` DELETES a key — that is how a
1512
- * stage is turned off — and `shadows` alone takes a real `false`.
1513
- *
1514
- * Antialiasing and pixel ratio are NOT here: `antialias` is a WebGL context
1515
- * attribute and cannot change without rebuilding the renderer. See
1516
- * `qualityRendering`.
1517
- */
1518
- function qualityEnvironment(tier) {
1519
- if (tier === "low") return {
1520
- shadows: false,
1521
- bloom: null,
1522
- post: null,
1523
- clouds: null
1524
- };
1525
- if (tier === "medium") return {
1526
- shadows: { static: true },
1527
- bloom: {},
1528
- post: null
1529
- };
1530
- return {
1531
- shadows: true,
1532
- bloom: {},
1533
- post: {}
1534
- };
1535
- }
1536
- const QUALITY_CAPS = Object.freeze({
1537
- low: Object.freeze({
1538
- fancyWater: false,
1539
- waterMirror: false,
1540
- minReflectionIntervalMs: 2e3
1541
- }),
1542
- medium: Object.freeze({
1543
- fancyWater: true,
1544
- waterMirror: false,
1545
- minReflectionIntervalMs: 2e3
1546
- }),
1547
- high: Object.freeze({
1548
- fancyWater: true,
1549
- waterMirror: true,
1550
- minReflectionIntervalMs: 0
1551
- })
1552
- });
1553
- /**
1554
- * The caps for a tier — the same frozen object every call.
1614
+ * A bar fed the wrong argument shows a plausible wrong number forever.
1555
1615
  *
1556
- * This is read once per water node per frame, so it must not allocate.
1616
+ * The trap is one signal away: `Health.damaged` is `(amount, current)`, so
1617
+ * wiring it to `setValue` paints the DAMAGE as the health and looks like a
1618
+ * working health bar. Say which signal has the shape a bar wants.
1557
1619
  */
1558
- function qualityCaps(tier) {
1559
- return QUALITY_CAPS[tier] ?? QUALITY_CAPS.high;
1620
+ function requireNumber(value, node, method) {
1621
+ if (typeof value === "number" && Number.isFinite(value)) return value;
1622
+ 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.`);
1560
1623
  }
1561
- function qualityRendering(tier) {
1562
- const device = Math.min(globalThis.devicePixelRatio ?? 1, 2);
1563
- if (tier === "low") return {
1564
- antialias: false,
1565
- pixelRatio: 1
1566
- };
1567
- if (tier === "medium") return {
1568
- antialias: true,
1569
- pixelRatio: Math.min(device, 1.5)
1570
- };
1571
- return {
1572
- antialias: true,
1573
- pixelRatio: device
1624
+ /** A labeled progress bar (health, stamina, reload, boss HP). */
1625
+ var UiBar = class extends HudWidgetBase {
1626
+ static typeName = "UiBar";
1627
+ static props = {
1628
+ ...HudWidgetBase.props,
1629
+ value: { default: 100 },
1630
+ max: { default: 100 },
1631
+ width: { default: 180 },
1632
+ height: { default: 14 },
1633
+ color: { default: "#4ade80" },
1634
+ lowColor: { default: "#ef4444" },
1635
+ lowThreshold: { default: .3 },
1636
+ background: { default: "rgba(0,0,0,0.5)" },
1637
+ label: { default: "" }
1574
1638
  };
1575
- }
1576
- //#endregion
1577
- //#region src/core/engine.ts
1578
- const MAX_DT_SECONDS = .25;
1579
- function rafScheduler(cb) {
1580
- let live = true;
1581
- let id = requestAnimationFrame(function loop(t) {
1582
- if (!live) return;
1583
- cb(t);
1584
- id = requestAnimationFrame(loop);
1585
- });
1586
- return () => {
1587
- live = false;
1588
- cancelAnimationFrame(id);
1589
- };
1590
- }
1591
- /**
1592
- * The game loop: drives a Scene's tree with fixed-timestep `fixedUpdate`
1593
- * (physics/network window) and variable `update` (everything else).
1594
- *
1595
- * Instance-scoped by design — multiple engines can coexist. Headless-testable
1596
- * via the injectable scheduler and the public `tick(nowMs)`.
1597
- */
1598
- var Engine = class {
1599
- /** Emitted after the tree's variable update each frame, with dt seconds. */
1600
- updated = new Signal();
1601
- /** Emitted after each fixed step, with the fixed dt seconds. */
1602
- fixedUpdated = new Signal();
1603
- /**
1604
- * Emitted after a scene swap completes (input map redeclared) with the new
1605
- * scene, and with null on dispose — overlays (touch controls, debug panels)
1606
- * rebuild themselves here.
1607
- */
1608
- sceneChanged = new Signal();
1609
- /** Declarative input — scene `input{}` declarations load on setScene. */
1610
- input = new InputMap();
1611
- /** Seeded randomness for game logic (deterministic when `seed` is set). */
1612
- rng;
1613
- /** The engine log channel (debug overlay + headless harness tail this). */
1614
- log = new LogManager();
1615
- /**
1616
- * Screen pixel → the node under it, when a renderer has offered one.
1617
- *
1618
- * The raycast has always existed (`Renderer3D.pick` / `Renderer2D.pick`) and
1619
- * was reachable only from whoever held the renderer — so a Behavior, which
1620
- * holds a node, could not use it. Every mouse-driven genre needs exactly this
1621
- * and had no way to ask. Instance-scoped, set by the renderer on construction
1622
- * and cleared on dispose; null when nothing renders (headless).
1623
- */
1624
- picker = null;
1625
- /** Global volume buses — `master` × `sfx`/`music` gain + `muted`. AudioPlayer
1626
- * routes its volume through this; games set it for global volume/mute. */
1627
- audio = new AudioBuses();
1628
- /**
1629
- * What SHOWED — the visual half of the audio record. Particle bursts, camera
1630
- * shake, screen flash and freeze frames land here, so "did the explosion
1631
- * fire?" is answerable in a run with no renderer.
1632
- */
1633
- effects = new EffectLog();
1634
- /**
1635
- * Persisted player settings — volume, quality, sensitivity.
1636
- *
1637
- * `createSaveStore` was good and had zero callers, so every game's volume
1638
- * reset on reload. Reading it here costs nothing (localStorage, once) and
1639
- * `bindAudio` makes the volumes write themselves.
1640
- */
1641
- settings = new Settings();
1642
- /**
1643
- * Translations, and the locale in force. English is the base and the default;
1644
- * anything a locale does not declare falls back to it silently.
1645
- */
1646
- locale = new Localization();
1647
- /**
1648
- * Draw-order bands in force: the six built in, plus whatever the current
1649
- * scene declares. Read by `Node2D`/`Node3D.effectiveRenderOrder`.
1650
- */
1651
- orderGroups = ORDER_GROUP_BASE;
1652
- /** Low-latency procedural-SFX player (WebAudio, headless-safe). AudioPlayer
1653
- * presets play through it; games may call `engine.sfx.play(...)` directly. */
1654
- sfx = new SfxEngine();
1655
- /** Single-track background-music manager (crossfade/loop, headless-safe).
1656
- * `engine.music.play(src)` / `crossfadeTo(src, secs)` / `stop(fadeOut)`;
1657
- * routes through the music bus and is advanced each frame by the loop. */
1658
- music = new MusicManager(this.audio);
1639
+ value = 100;
1640
+ max = 100;
1641
+ width = 180;
1642
+ height = 14;
1643
+ color = "#4ade80";
1644
+ lowColor = "#ef4444";
1645
+ lowThreshold = .3;
1646
+ background = "rgba(0,0,0,0.5)";
1647
+ label = "";
1648
+ fill = null;
1649
+ tag = null;
1650
+ lastLabel = "\0";
1651
+ lastRatio = -1;
1652
+ /** Current fill ratio 0..1 (what the bar shows). */
1653
+ get ratio() {
1654
+ return this.max > 0 ? Math.min(1, Math.max(0, this.value / this.max)) : 0;
1655
+ }
1659
1656
  /**
1660
- * Where nodes that build DOM (HudLayer and its widgets) mount their elements.
1657
+ * Drive the bar from a signal — `healthChanged → setValue`.
1661
1658
  *
1662
- * `null` — the default — means `document.body` with `position: fixed`, i.e. a
1663
- * game's HUD covers the window, which is what a game wants. A host that only
1664
- * OWNS PART of the page sets this to its own (positioned) container so the
1665
- * HUD stays inside it: the scene editor points it at the viewport pane, which
1666
- * is why an edited scene's HUD no longer floats over the inspector.
1667
- */
1668
- uiHost = null;
1669
- fixedStep;
1670
- maxFixedSteps;
1671
- scheduler;
1672
- _scene = null;
1673
- lastMs = null;
1674
- accumulator = 0;
1675
- disposeScheduler = null;
1676
- frameStats = new FrameStatsRing();
1677
- /** Where each frame's time went — the breakdown `frameMs` alone cannot give. */
1678
- phaseTimings = new PhaseTimings();
1679
- /** This frame's render cost, as reported by whoever renders. */
1680
- renderMs = 0;
1681
- /**
1682
- * Game-time multiplier: 1 = realtime, 0.5 = slow motion, 0 = frozen
1683
- * (hit-stop / pause). Scales BOTH variable and fixed updates — physics,
1684
- * timers, behaviors all breathe together. See `gameplay` `hitStop()`.
1685
- */
1686
- timeScale = 1;
1687
- /**
1688
- * The node the dev overlay has selected (renderers draw its bounding box
1689
- * as an orange outline in the game view so you can SEE what you picked).
1690
- * Null when nothing is selected; cleared automatically when it leaves the
1691
- * tree. Set by the debug overlay — games normally never touch this.
1692
- */
1693
- debugSelection = null;
1694
- _time = 0;
1695
- _unscaledTime = 0;
1696
- /**
1697
- * Elapsed GAME time in seconds since the scene started — the sum of every
1698
- * scaled dt (freezes at timeScale 0, crawls in slow motion). Unity's
1699
- * `Time.time`. Resets on `setScene`.
1659
+ * Takes the max as a second argument because that is the shape the signal
1660
+ * already has, so a bar whose ceiling moves (a max-HP upgrade) needs no
1661
+ * second wire.
1700
1662
  */
1701
- get time() {
1702
- return this._time;
1663
+ setValue(value, max) {
1664
+ this.value = requireNumber(value, this, "setValue");
1665
+ if (max !== void 0) this.max = requireNumber(max, this, "setValue");
1703
1666
  }
1704
- /** Elapsed REAL time in seconds since the scene started (ignores timeScale) —
1705
- * UI animations that must keep moving during slow-mo/pause read this. */
1706
- get unscaledTime() {
1707
- return this._unscaledTime;
1667
+ /** Move the ceiling on its own, leaving the fill where it is. */
1668
+ setMax(max) {
1669
+ this.max = requireNumber(max, this, "setMax");
1708
1670
  }
1709
- constructor(opts = {}) {
1710
- this.fixedStep = 1 / (opts.fixedHz ?? 60);
1711
- this.maxFixedSteps = opts.maxFixedStepsPerTick ?? 5;
1712
- this.scheduler = opts.scheduler ?? rafScheduler;
1713
- this.rng = new Rng(opts.seed ?? Math.random() * 4294967295 >>> 0);
1714
- this.maxFps = opts.maxFps ?? 0;
1715
- this.sfx._setBuses(this.audio);
1671
+ _build() {
1672
+ const wrap = document.createElement("div");
1673
+ wrap.style.cssText = "display:flex;align-items:center;gap:6px;";
1674
+ const tag = document.createElement("span");
1675
+ tag.style.cssText = "color:#fff;font-size:12px;text-shadow:0 1px 3px rgba(0,0,0,.7);";
1676
+ this.tag = tag;
1677
+ wrap.appendChild(tag);
1678
+ this.track = document.createElement("div");
1679
+ this.track.style.cssText = this.trackLook();
1680
+ this.lastTrackLook = this.trackLook();
1681
+ this.fill = document.createElement("div");
1682
+ this.fill.style.cssText = `height:100%;width:100%;background:${this.color};border-radius:inherit;transition:width .15s;`;
1683
+ this.track.appendChild(this.fill);
1684
+ wrap.appendChild(this.track);
1685
+ return wrap;
1716
1686
  }
1717
- get scene() {
1718
- return this._scene;
1687
+ track = null;
1688
+ lastTrackLook = "";
1689
+ lastFillColor = "";
1690
+ /** The track's whole appearance, so re-applying is one comparison. */
1691
+ trackLook() {
1692
+ return `width:${this.width}px;height:${this.height}px;background:${this.background};border-radius:${this.height / 2}px;overflow:hidden;`;
1719
1693
  }
1720
- /** Replace the active scene. The previous scene's root is freed. */
1721
- setScene(scene) {
1722
- if (scene === this._scene) return;
1723
- this._scene?.tree._setEngine(null);
1724
- this._scene?.root.free();
1725
- this._scene = scene;
1726
- scene.tree._setEngine(this);
1727
- this.input.clear();
1728
- this._time = 0;
1729
- this._unscaledTime = 0;
1730
- if (scene.input) this.input.declare(scene.input);
1731
- if (scene.strings) this.locale.load(scene.strings);
1732
- this.orderGroups = resolveOrderGroups(scene.orderGroups);
1733
- this.sceneChanged.emit(scene);
1694
+ _sync() {
1695
+ const label = this._t(this.label);
1696
+ if (label !== this.lastLabel && this.tag) {
1697
+ this.lastLabel = label;
1698
+ this.tag.textContent = label;
1699
+ this.tag.style.display = label ? "" : "none";
1700
+ }
1701
+ const look = this.trackLook();
1702
+ if (look !== this.lastTrackLook && this.track) {
1703
+ this.lastTrackLook = look;
1704
+ this.track.style.cssText = look;
1705
+ }
1706
+ const r = this.ratio;
1707
+ if (!this.fill) return;
1708
+ const colour = r <= this.lowThreshold ? this.lowColor : this.color;
1709
+ if (colour !== this.lastFillColor) {
1710
+ this.lastFillColor = colour;
1711
+ this.fill.style.background = colour;
1712
+ }
1713
+ if (r === this.lastRatio) return;
1714
+ this.lastRatio = r;
1715
+ this.fill.style.width = `${r * 100}%`;
1734
1716
  }
1735
- start() {
1736
- if (this.disposeScheduler) return;
1737
- this.disposeScheduler = this.scheduler((now) => {
1738
- if (!this.frameIsDue(now)) return;
1739
- try {
1740
- this.tick(now);
1741
- } catch (error) {
1742
- this.reportFrameError(error);
1743
- }
1717
+ };
1718
+ /**
1719
+ * Center-screen announcements ("WAVE 2", "YOU DIED", "LEVEL UP") with fade
1720
+ * in/out and a queue — call `.show(text, { color, seconds })`; `sticky:true`
1721
+ * (seconds: 0) keeps it until the next show(). Emits `bannerShown(text)`.
1722
+ */
1723
+ var UiBanner = class extends HudWidgetBase {
1724
+ static typeName = "UiBanner";
1725
+ static signals = ["bannerShown"];
1726
+ static props = {
1727
+ ...HudWidgetBase.props,
1728
+ anchor: {
1729
+ default: "center",
1730
+ options: [
1731
+ "topLeft",
1732
+ "top",
1733
+ "topRight",
1734
+ "left",
1735
+ "center",
1736
+ "right",
1737
+ "bottomLeft",
1738
+ "bottom",
1739
+ "bottomRight"
1740
+ ]
1741
+ },
1742
+ size: { default: 42 },
1743
+ seconds: { default: 2 }
1744
+ };
1745
+ anchor = "center";
1746
+ size = 42;
1747
+ /** Default display time (per-show override via options). */
1748
+ seconds = 2;
1749
+ queue = [];
1750
+ current = null;
1751
+ remaining = 0;
1752
+ /** What the banner is showing right now ('' when idle) — test-friendly. */
1753
+ get showing() {
1754
+ return this.current?.text ?? "";
1755
+ }
1756
+ show(text, opts) {
1757
+ this.queue.push({
1758
+ text,
1759
+ color: opts?.color ?? "#ffffff",
1760
+ seconds: opts?.seconds ?? this.seconds
1744
1761
  });
1745
1762
  }
1746
- /**
1747
- * The frame cap: fps, or 0 for "as fast as the display goes" (the default).
1748
- *
1749
- * Live — a settings menu writes it mid-game. It gates the whole frame, not
1750
- * just the render: an uncapped loop on a 144 Hz phone burns battery running
1751
- * game logic nobody sees.
1752
- *
1753
- * `tick()` is deliberately NOT gated. A headless harness driving its own
1754
- * clock asked for that frame; the cap is about the real-time loop.
1755
- */
1756
- maxFps = 0;
1757
- nextFrameAt = 0;
1758
- lastOfferedAt = null;
1759
- displayPeriodMs = 0;
1760
- /**
1761
- * Should this display frame run?
1762
- *
1763
- * The obvious `if (now - last < interval) return` is wrong in a way that only
1764
- * shows up between the round numbers: a 45 fps cap on a 60 Hz display misses
1765
- * every other deadline by a fraction of a millisecond, waits a whole display
1766
- * frame more, and delivers 30 — asking for 45 makes the game slower than not
1767
- * capping at all. The fix is to allow a frame that is within half a display
1768
- * period of its deadline, and to advance the deadline by the interval rather
1769
- * than from `now`, so the error does not accumulate.
1770
- */
1771
- frameIsDue(now) {
1772
- const prev = this.lastOfferedAt;
1773
- this.lastOfferedAt = now;
1774
- if (prev !== null) {
1775
- const delta = now - prev;
1776
- if (delta > 0 && delta < 500) this.displayPeriodMs = this.displayPeriodMs === 0 ? delta : this.displayPeriodMs * .9 + delta * .1;
1763
+ /** Drop everything (scene transitions). */
1764
+ clear() {
1765
+ this.queue.length = 0;
1766
+ this.current = null;
1767
+ this.remaining = 0;
1768
+ if (this._element) this._element.style.opacity = "0";
1769
+ }
1770
+ _build() {
1771
+ const el = document.createElement("div");
1772
+ 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;`;
1773
+ return el;
1774
+ }
1775
+ /** The whole look, so re-applying is one comparison. `size` was baked in at
1776
+ * build, and a banner could never be resized. */
1777
+ look() {
1778
+ 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;`;
1779
+ }
1780
+ lastLook = "";
1781
+ update(dt) {
1782
+ super.update(dt);
1783
+ const look = this.look();
1784
+ if (look !== this.lastLook && this._element) {
1785
+ this.lastLook = look;
1786
+ const opacity = this._element.style.opacity;
1787
+ this._element.style.cssText = look;
1788
+ this._element.style.opacity = opacity;
1777
1789
  }
1778
- if (!(this.maxFps > 0)) {
1779
- this.nextFrameAt = 0;
1780
- return true;
1790
+ if (this.current) {
1791
+ if (this.current.seconds > 0) {
1792
+ this.remaining -= dt;
1793
+ if (this.remaining <= 0) {
1794
+ this.current = null;
1795
+ if (this._element) this._element.style.opacity = "0";
1796
+ }
1797
+ } else if (this.queue.length > 0) this.current = null;
1781
1798
  }
1782
- const interval = 1e3 / this.maxFps;
1783
- if (this.nextFrameAt === 0) {
1784
- this.nextFrameAt = now + interval;
1785
- return true;
1799
+ if (!this.current && this.queue.length > 0) {
1800
+ this.current = this.queue.shift();
1801
+ this.current.text = this._t(this.current.text);
1802
+ this.remaining = this.current.seconds;
1803
+ if (this._element) {
1804
+ this._element.textContent = this.current.text;
1805
+ this._element.style.color = this.current.color;
1806
+ this._element.style.opacity = "1";
1807
+ }
1808
+ this.emit("bannerShown", this.current.text);
1786
1809
  }
1787
- if (now < this.nextFrameAt - this.displayPeriodMs * .5) return false;
1788
- this.nextFrameAt = Math.max(now, this.nextFrameAt + interval);
1789
- return true;
1790
1810
  }
1791
- /**
1792
- * A frame threw somewhere outside a single node (a renderer, physics, a
1793
- * signal handler). Report the first one and count the rest: a broken frame
1794
- * repeats sixty times a second, and sixty identical traces a second is how
1795
- * you lose the first one.
1796
- */
1797
- reportFrameError(error) {
1798
- this.errorCount += 1;
1799
- const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
1800
- if (text === this.lastFrameError) return;
1801
- this.lastFrameError = text;
1802
- this.log.error("[incanto] a frame threw — the loop keeps running:", error);
1803
- console.error("[incanto] a frame threw — the loop keeps running:", error);
1811
+ };
1812
+ //#endregion
1813
+ //#region src/core/effects-log.ts
1814
+ const EFFECT_LOG_LIMIT = 200;
1815
+ var EffectLog = class {
1816
+ events = [];
1817
+ /** @internal Called by the effects themselves; games read `recent()`. */
1818
+ record(event) {
1819
+ this.events.push(event);
1820
+ if (this.events.length > EFFECT_LOG_LIMIT) this.events.shift();
1821
+ }
1822
+ /** The last `limit` effects, oldest first. */
1823
+ recent(limit = EFFECT_LOG_LIMIT) {
1824
+ return this.events.slice(Math.max(0, this.events.length - limit));
1825
+ }
1826
+ /** How many times an effect with this name fired (`explosion`, `#ff0000`). */
1827
+ countOf(name) {
1828
+ return this.events.reduce((n, e) => e.name === name ? n + 1 : n, 0);
1829
+ }
1830
+ /** How many effects this node fired — the question a wiring check has. */
1831
+ countFrom(path) {
1832
+ return this.events.reduce((n, e) => e.from === path ? n + 1 : n, 0);
1833
+ }
1834
+ /** Forget what has fired so far (between scenes, or between assertions). */
1835
+ clearLog() {
1836
+ this.events.length = 0;
1837
+ }
1838
+ };
1839
+ //#endregion
1840
+ //#region src/core/frame-stats.ts
1841
+ /**
1842
+ * Rolling window of REAL frame timestamps — the math behind `engine.stats()`
1843
+ * fps/frameMs. Pure and renderer-free: push tick times in, read averages out.
1844
+ *
1845
+ * Only `Engine.tick(nowMs)` feeds it; headless `step()` advances sim time
1846
+ * without wall-clock frames, so stepped runs honestly report 0 fps.
1847
+ */
1848
+ var FrameStatsRing = class {
1849
+ capacity;
1850
+ times;
1851
+ head = 0;
1852
+ count = 0;
1853
+ constructor(capacity = 60) {
1854
+ this.capacity = capacity;
1855
+ this.times = new Array(capacity);
1856
+ }
1857
+ /** Record one frame timestamp (ms). Evicts the oldest at capacity. */
1858
+ push(nowMs) {
1859
+ this.times[(this.head + this.count) % this.capacity] = nowMs;
1860
+ if (this.count < this.capacity) this.count += 1;
1861
+ else this.head = (this.head + 1) % this.capacity;
1862
+ }
1863
+ /** Frames per second over the window — 0 with fewer than two samples. */
1864
+ fps() {
1865
+ const span = this.spanMs();
1866
+ return span > 0 ? (this.count - 1) / span * 1e3 : 0;
1867
+ }
1868
+ /** Mean delta between frames in ms — 0 with fewer than two samples. */
1869
+ frameMs() {
1870
+ const span = this.spanMs();
1871
+ return span > 0 ? span / (this.count - 1) : 0;
1872
+ }
1873
+ /** Forget all samples (engine.stop() — a paused game has no frame rate). */
1874
+ clear() {
1875
+ this.head = 0;
1876
+ this.count = 0;
1877
+ }
1878
+ spanMs() {
1879
+ if (this.count < 2) return 0;
1880
+ const oldest = this.times[this.head];
1881
+ return this.times[(this.head + this.count - 1) % this.capacity] - oldest;
1882
+ }
1883
+ };
1884
+ //#endregion
1885
+ //#region src/core/log.ts
1886
+ /**
1887
+ * The engine's log channel (`engine.log`, `this.log` in a Behavior): a ring
1888
+ * buffer plus a live `added` signal, so debug overlays and headless test
1889
+ * harnesses can tail game logs without scraping the browser console.
1890
+ */
1891
+ var LogManager = class {
1892
+ /** Fires once per entry, after it is buffered. */
1893
+ added = new Signal();
1894
+ buffer = [];
1895
+ capacity;
1896
+ seq = 0;
1897
+ constructor(capacity = 1e3) {
1898
+ this.capacity = capacity;
1899
+ }
1900
+ debug(...parts) {
1901
+ this.push("debug", parts);
1902
+ }
1903
+ info(...parts) {
1904
+ this.push("info", parts);
1905
+ }
1906
+ warn(...parts) {
1907
+ this.push("warn", parts);
1804
1908
  }
1805
- /** @internal A node quarantined itself; count it for `stats().errors`. */
1806
- _nodeErrored(_node) {
1807
- this.errorCount += 1;
1909
+ error(...parts) {
1910
+ this.push("error", parts);
1808
1911
  }
1809
- errorCount = 0;
1810
- lastFrameError = null;
1811
- /**
1812
- * Every node with something quarantined — its own update, its script, or
1813
- * both — so a tool can list them and put them back. Walks on demand; there is
1814
- * no per-frame bookkeeping for this.
1815
- */
1816
- /**
1817
- * The string for `key` in the current locale — English if this locale does not
1818
- * declare it, the key itself if nothing does.
1819
- *
1820
- * The short form of `engine.locale.t`, because a behavior reaching for a
1821
- * translated string is the common case:
1822
- * `banner.show(this.engine.t('wave.start', { n: wave }))`.
1823
- */
1824
- t(key, params) {
1825
- return this.locale.t(key, params);
1912
+ /** Buffered entries, oldest first (capped at capacity). */
1913
+ entries() {
1914
+ return this.buffer;
1826
1915
  }
1827
- /**
1828
- * Every behavior's state, ready to put in a save slot.
1829
- *
1830
- * Pairs with `restoreState` after the scene has RELOADED — see
1831
- * core/save-slots.ts for why a save carries state rather than a tree
1832
- * snapshot.
1833
- */
1834
- captureState() {
1835
- const root = this.scene?.root;
1836
- if (!root) return {};
1837
- const { state, unaddressable } = captureBehaviors(root, this.scene?.source);
1838
- for (const path of unaddressable) diagnose(this, "warn", `save: ${path} has state to save and no uid to key it under.`);
1839
- return state;
1916
+ /** Empty the buffer. The sequence keeps counting (entries stay unique). */
1917
+ clear() {
1918
+ this.buffer.length = 0;
1840
1919
  }
1841
- /** Give it back, after the scene is loaded and onReady has run. */
1842
- restoreState(state) {
1843
- const root = this.scene?.root;
1844
- if (!root) return {
1845
- missing: Object.keys(state),
1846
- skipped: [],
1847
- restored: 0,
1848
- expected: 0,
1849
- freed: 0
1920
+ push(level, parts) {
1921
+ this.seq += 1;
1922
+ const entry = {
1923
+ seq: this.seq,
1924
+ timeMs: globalThis.performance?.now() ?? 0,
1925
+ level,
1926
+ parts
1850
1927
  };
1851
- return restoreBehaviors(root, state);
1928
+ this.buffer.push(entry);
1929
+ if (this.buffer.length > this.capacity) this.buffer.splice(0, this.buffer.length - this.capacity);
1930
+ this.added.emit(entry);
1852
1931
  }
1853
- erroredNodes() {
1854
- const out = [];
1855
- const walk = (node) => {
1856
- if (node.errored || node.behaviorErrored) out.push(node);
1857
- for (const child of node.children) walk(child);
1932
+ };
1933
+ //#endregion
1934
+ //#region src/core/phase-timing.ts
1935
+ const EMPTY = {
1936
+ fixedMs: 0,
1937
+ updateMs: 0,
1938
+ renderMs: 0,
1939
+ otherMs: 0
1940
+ };
1941
+ var PhaseTimings = class {
1942
+ capacity;
1943
+ fixed = [];
1944
+ update = [];
1945
+ render = [];
1946
+ head = 0;
1947
+ constructor(capacity = 60) {
1948
+ this.capacity = capacity;
1949
+ }
1950
+ /** Bank one frame's engine-side work. */
1951
+ push(fixedMs, updateMs, renderMs) {
1952
+ const i = this.head % this.capacity;
1953
+ this.fixed[i] = fixedMs;
1954
+ this.update[i] = updateMs;
1955
+ this.render[i] = renderMs;
1956
+ this.head += 1;
1957
+ }
1958
+ /** Means over the window; `frameMs` (0 headless) fills in `otherMs`. */
1959
+ read(frameMs) {
1960
+ const n = Math.min(this.head, this.capacity);
1961
+ if (n === 0) return { ...EMPTY };
1962
+ const fixedMs = mean(this.fixed, n);
1963
+ const updateMs = mean(this.update, n);
1964
+ const renderMs = mean(this.render, n);
1965
+ return {
1966
+ fixedMs,
1967
+ updateMs,
1968
+ renderMs,
1969
+ otherMs: Math.max(0, frameMs - fixedMs - updateMs - renderMs)
1858
1970
  };
1859
- if (this._scene?.root) walk(this._scene.root);
1860
- return out;
1861
1971
  }
1862
- /** Un-skip every quarantined node — the "try again" after you fix the code. */
1863
- resumeErroredNodes() {
1864
- const nodes = this.erroredNodes();
1865
- for (const node of nodes) {
1866
- node.errored = false;
1867
- node.behaviorErrored = false;
1972
+ clear() {
1973
+ this.fixed.length = 0;
1974
+ this.update.length = 0;
1975
+ this.render.length = 0;
1976
+ this.head = 0;
1977
+ }
1978
+ };
1979
+ function mean(values, n) {
1980
+ let total = 0;
1981
+ for (let i = 0; i < n; i++) total += values[i] ?? 0;
1982
+ return total / n;
1983
+ }
1984
+ //#endregion
1985
+ //#region src/core/settings.ts
1986
+ const DEFAULTS = {
1987
+ master: 1,
1988
+ sfx: 1,
1989
+ music: 1,
1990
+ muted: false,
1991
+ quality: "high",
1992
+ sensitivity: 1,
1993
+ invertY: false,
1994
+ reduceMotion: false,
1995
+ language: "en",
1996
+ qualityChosen: false,
1997
+ maxFps: 0,
1998
+ renderScale: 1
1999
+ };
2000
+ const TIERS = [
2001
+ "low",
2002
+ "medium",
2003
+ "high"
2004
+ ];
2005
+ /**
2006
+ * A default tier from what the browser will admit to.
2007
+ *
2008
+ * Deliberately crude: this picks a STARTING point, and the options menu is the
2009
+ * real answer. A phone that reports four cores and a coarse pointer should not
2010
+ * open on the setting tuned for a desktop GPU.
2011
+ */
2012
+ function suggestQuality(hints) {
2013
+ const cores = hints.hardwareConcurrency ?? 8;
2014
+ const memory = hints.deviceMemory ?? 8;
2015
+ if (hints.coarsePointer && (cores <= 6 || memory <= 4)) return "low";
2016
+ if (cores <= 4 || memory <= 4 || (hints.maxTextureSize ?? 8192) < 8192) return "low";
2017
+ if (cores <= 8 || memory <= 8) return "medium";
2018
+ return "high";
2019
+ }
2020
+ /** Read what this environment will tell us, safely, without touching WebGL. */
2021
+ function readDeviceHints() {
2022
+ const nav = globalThis.navigator;
2023
+ const mm = globalThis.matchMedia;
2024
+ const out = {};
2025
+ if (typeof nav?.hardwareConcurrency === "number") out.hardwareConcurrency = nav.hardwareConcurrency;
2026
+ if (typeof nav?.deviceMemory === "number") out.deviceMemory = nav.deviceMemory;
2027
+ try {
2028
+ if (mm) out.coarsePointer = mm("(pointer: coarse)").matches;
2029
+ } catch {}
2030
+ return out;
2031
+ }
2032
+ var Settings = class {
2033
+ /** Fires after any change, with the key that changed. */
2034
+ changed = new Signal();
2035
+ store;
2036
+ values;
2037
+ constructor(namespace = "settings", store) {
2038
+ this.store = store ?? createSaveStore(namespace);
2039
+ this.values = { ...DEFAULTS };
2040
+ for (const key of Object.keys(DEFAULTS)) {
2041
+ const saved = this.store.get(key, DEFAULTS[key]);
2042
+ this.values[key] = saved;
1868
2043
  }
1869
- this.lastFrameError = null;
1870
- return nodes.length;
2044
+ if (!TIERS.includes(this.values.quality)) this.values.quality = DEFAULTS.quality;
2045
+ }
2046
+ get(key) {
2047
+ return this.values[key];
2048
+ }
2049
+ set(key, value) {
2050
+ if (this.values[key] === value) return;
2051
+ this.values[key] = value;
2052
+ this.store.set(key, value);
2053
+ this.changed.emit(key);
2054
+ }
2055
+ /** Every value at once — for an options screen, or a save-file dump. */
2056
+ all() {
2057
+ return { ...this.values };
2058
+ }
2059
+ /** Back to defaults, on disk too. */
2060
+ reset() {
2061
+ for (const key of Object.keys(DEFAULTS)) this.set(key, DEFAULTS[key]);
1871
2062
  }
1872
2063
  /**
1873
- * How long the last render took, in ms — called by whoever renders.
2064
+ * Choose a starting tier from the device, ONCE.
1874
2065
  *
1875
- * The engine does not render, so this is the one slice it cannot measure for
1876
- * itself; without it the cost lands in `otherMs` beside the GC and the two
1877
- * are indistinguishable, which is the confusion this whole breakdown exists
1878
- * to end.
2066
+ * Only on a first visit: after someone has picked a tier, re-detecting on
2067
+ * every launch would silently undo their choice — which is the behaviour every
2068
+ * player experiences as "the settings do not save".
1879
2069
  */
1880
- noteRenderMs(ms) {
1881
- this.renderMs = ms;
2070
+ autoQuality(hints = readDeviceHints()) {
2071
+ if (!this.values.qualityChosen) this.values.quality = suggestQuality(hints);
2072
+ return this.values.quality;
1882
2073
  }
1883
- stop() {
1884
- this.disposeScheduler?.();
1885
- this.disposeScheduler = null;
1886
- this.lastMs = null;
1887
- this.accumulator = 0;
1888
- this.frameStats.clear();
1889
- this.phaseTimings.clear();
2074
+ /** Record that the tier came from a person, not from the device probe. */
2075
+ chooseQuality(tier) {
2076
+ this.set("quality", tier);
2077
+ this.set("qualityChosen", true);
1890
2078
  }
1891
2079
  /**
1892
- * Live performance counters, queryable at ANY time. fps/frameMs come from a
1893
- * rolling window of REAL `tick` timestamps — headless `step()` runs report 0.
1894
- * Node count walks the active tree on demand. Renderer counters (triangles,
1895
- * draw calls) live on `renderer.stats()` / the merged `game.stats()`.
2080
+ * Apply the saved quality tier to a live scene, and keep applying it.
2081
+ *
2082
+ * The same shape as `bindAudio` and `bindLocale`. Until 0.38 this did not
2083
+ * exist: `settings.quality` was written to disk and read by NOBODY, and
2084
+ * `qualityEnvironment` — the function that turns a tier into something the
2085
+ * renderer understands — emitted patches the environment parser rejects, for
2086
+ * all three tiers. Both were public, documented and never once called.
1896
2087
  */
1897
- stats() {
1898
- return {
1899
- fps: this.frameStats.fps(),
1900
- frameMs: this.frameStats.frameMs(),
1901
- nodes: this._scene?.tree.root ? countNodes(this._scene.tree.root) : 0,
1902
- running: this.disposeScheduler !== null,
1903
- errors: this.errorCount,
1904
- phases: this.phaseTimings.read(this.frameStats.frameMs())
1905
- };
2088
+ bindQuality(apply) {
2089
+ apply(qualityEnvironment(this.values.quality));
2090
+ return this.changed.connect((key) => {
2091
+ if (key === "quality") apply(qualityEnvironment(this.values.quality));
2092
+ });
2093
+ }
2094
+ /**
2095
+ * Apply the saved frame cap, and again whenever it changes.
2096
+ *
2097
+ * Separate from `bindQuality` because it is not a tier: a player caps the
2098
+ * frame rate to keep a phone cool or a battery alive, on the highest visual
2099
+ * settings the device can hold, and those two choices do not belong on one
2100
+ * slider. 0 means uncapped. Returns the unsubscribe.
2101
+ */
2102
+ bindFrameCap(apply) {
2103
+ apply(this.values.maxFps);
2104
+ return this.changed.connect((key) => {
2105
+ if (key === "maxFps") apply(this.values.maxFps);
2106
+ });
1906
2107
  }
1907
2108
  /**
1908
- * Render interpolation factor in [0,1]: how far the wall clock has advanced
1909
- * INTO the next fixed step. Renderers lerp physics bodies between their last
1910
- * two fixed-step transforms by this much so motion looks smooth even when the
1911
- * display refresh doesn't divide evenly into the 60Hz fixed step (the classic
1912
- * fixed-timestep judder). Only meaningful in the real-time `tick()` loop;
1913
- * the headless `step()` path doesn't bank wall-clock time.
2109
+ * Apply the saved resolution multiplier, and again whenever it changes.
2110
+ *
2111
+ * The lever that buys the most frames per unit of ugliness on a weak GPU —
2112
+ * shading 44% of the pixels at 0.66 costs a soft image and nothing else,
2113
+ * while dropping a quality tier costs shadows entirely.
1914
2114
  */
1915
- get interpolationAlpha() {
1916
- return this.fixedStep > 0 ? Math.min(this.accumulator / this.fixedStep, 1) : 1;
2115
+ bindRenderScale(apply) {
2116
+ apply(this.values.renderScale);
2117
+ return this.changed.connect((key) => {
2118
+ if (key === "renderScale") apply(this.values.renderScale);
2119
+ });
1917
2120
  }
1918
2121
  /**
1919
- * Full teardown in one call: stop the loop, free the scene tree, detach
1920
- * every input listener. The single-unmount story for SPA embedding —
1921
- * renderers own GPU resources and keep their own dispose().
2122
+ * Apply the saved language, and keep it in step both ways.
2123
+ *
2124
+ * The same shape as `bindAudio`: a game that writes
2125
+ * `engine.locale.locale = 'ko'` from an options menu has already persisted it
2126
+ * without knowing this class exists. Returns the unsubscribe.
1922
2127
  */
1923
- dispose() {
1924
- this.stop();
1925
- this.music.dispose();
1926
- this.sfx.dispose();
1927
- if (this._scene) {
1928
- this._scene.tree._setEngine(null);
1929
- this._scene.root.free();
1930
- this._scene = null;
1931
- this.sceneChanged.emit(null);
1932
- }
1933
- this.input.dispose();
1934
- this.input.clear();
2128
+ bindLocale(locale) {
2129
+ locale.locale = this.values.language;
2130
+ return locale.changed.connect((next) => {
2131
+ this.set("language", next);
2132
+ });
1935
2133
  }
1936
2134
  /**
1937
- * Advance exactly ONE fixed step and one variable update (both dt = the
1938
- * fixed step scaled by `timeScale`), bypassing the wall-clock accumulator
1939
- * entirely. Drift-free by construction — the unit of time for headless
1940
- * harnesses (incanto/test runScript): every input edge is visible to BOTH
1941
- * fixedUpdate and update exactly once.
2135
+ * Push the saved volumes into the engine's buses, and keep writing them back
2136
+ * whenever anything changes them.
1942
2137
  *
1943
- * `ignorePause: true` steps a frame at full scale even while the game is
1944
- * paused — the debug overlay's single-step button, and nothing else.
2138
+ * The subscription is the point: a game that sets `engine.audio.music = 0.4`
2139
+ * from a slider has already persisted it, without knowing this class exists.
1945
2140
  */
1946
- step(opts = {}) {
1947
- const scene = this._scene;
1948
- if (!scene) return;
1949
- const dt = this.fixedStep * (opts.ignorePause ? 1 : this.timeScale);
1950
- this._time += dt;
1951
- this._unscaledTime += this.fixedStep;
1952
- const startedFixed = performance.now();
1953
- scene.tree.fixedUpdate(dt);
1954
- this.fixedUpdated.emit(dt);
1955
- const startedUpdate = performance.now();
1956
- scene.tree.update(dt);
1957
- this.music.tick(dt);
1958
- this.updated.emit(dt);
1959
- const ended = performance.now();
1960
- const updateMs = Math.max(0, ended - startedUpdate - this.renderMs);
1961
- this.phaseTimings.push(startedUpdate - startedFixed, updateMs, this.renderMs);
1962
- this.renderMs = 0;
1963
- this.input.endFrame();
1964
- }
1965
- /** Advance the loop manually. First call after (re)start only primes the clock. */
1966
- tick(nowMs) {
1967
- this.frameStats.push(nowMs);
1968
- if (this.lastMs === null) {
1969
- this.lastMs = nowMs;
1970
- return;
1971
- }
1972
- const rawDt = Math.max(0, Math.min((nowMs - this.lastMs) / 1e3, MAX_DT_SECONDS));
1973
- const dt = rawDt * this.timeScale;
1974
- this._time += dt;
1975
- this._unscaledTime += rawDt;
1976
- this.lastMs = nowMs;
1977
- const scene = this._scene;
1978
- if (!scene) return;
1979
- this.accumulator += dt;
1980
- let steps = 0;
1981
- const startedFixed = performance.now();
1982
- while (this.accumulator >= this.fixedStep && steps < this.maxFixedSteps) {
1983
- scene.tree.fixedUpdate(this.fixedStep);
1984
- this.fixedUpdated.emit(this.fixedStep);
1985
- this.accumulator -= this.fixedStep;
1986
- steps += 1;
1987
- }
1988
- if (this.accumulator >= this.fixedStep) this.accumulator %= this.fixedStep;
1989
- const startedUpdate = performance.now();
1990
- scene.tree.update(dt);
1991
- this.music.tick(dt);
1992
- this.updated.emit(dt);
1993
- const ended = performance.now();
1994
- const updateMs = Math.max(0, ended - startedUpdate - this.renderMs);
1995
- this.phaseTimings.push(startedUpdate - startedFixed, updateMs, this.renderMs);
1996
- this.renderMs = 0;
1997
- this.input.endFrame();
2141
+ bindAudio(audio) {
2142
+ audio.master = this.values.master;
2143
+ audio.sfx = this.values.sfx;
2144
+ audio.music = this.values.music;
2145
+ audio.muted = this.values.muted;
2146
+ return audio.changed.connect(() => {
2147
+ this.set("master", audio.master);
2148
+ this.set("sfx", audio.sfx);
2149
+ this.set("music", audio.music);
2150
+ this.set("muted", audio.muted);
2151
+ });
1998
2152
  }
1999
2153
  };
2000
- function countNodes(root) {
2001
- let n = 1;
2002
- for (const child of root.children) n += countNodes(child);
2003
- return n;
2004
- }
2005
- function createElement() {
2006
- if (typeof Audio === "undefined") return null;
2007
- return new Audio();
2008
- }
2009
2154
  /**
2010
- * One sound. `play()` (or `autoplay: true`) starts it; `finished` fires when
2011
- * it ends. Browsers block audio before the first user gesture — a blocked
2012
- * play marks `pendingGesture`; `createGame` retries pending players on the
2013
- * first pointer/key gesture automatically.
2155
+ * What a quality tier MEANS, as a LIVE environment patch.
2014
2156
  *
2015
- * Two playback modes:
2016
- * - `preset: 'custom'` (default) → plays the audio file at `src` via an HTMLAudio
2017
- * element (good for music / long clips; supports `loop`).
2018
- * - `preset: 'coin' | 'jump' | …` (a procedural SFX preset) → synthesizes a
2019
- * zero-asset sound through WebAudio: instant, deterministic, overlap-friendly
2020
- * for rapid-fire SFX. `pitch`/`seed` vary it. The art-free audio analog of the
2021
- * particle presets. See `incanto-audio.md` for the full preset list.
2157
+ * Only the levers that actually cost: shadows, post, bloom and clouds.
2158
+ * Deliberately an `environment` patch, because `setEnvironment3D(engine, patch)`
2159
+ * already applies one live and validated — this is not a second rendering
2160
+ * pipeline, it is three presets for the one that exists.
2022
2161
  *
2023
- * Volume routes through the engine's buses: `engine.audio.master × bus(sfx|music)
2024
- * × volume`. Set `engine.audio.master`/`sfx`/`music`/`muted` for global control.
2162
+ * The `off` spelling matters and got this wrong for three releases: every tier
2163
+ * emitted `{ enabled: false }`, which the environment parser rejects
2164
+ * (`environment.bloom has unknown key 'enabled'`). Nothing called this function,
2165
+ * so nothing found out. In a live patch `null` DELETES a key — that is how a
2166
+ * stage is turned off — and `shadows` alone takes a real `false`.
2167
+ *
2168
+ * Antialiasing and pixel ratio are NOT here: `antialias` is a WebGL context
2169
+ * attribute and cannot change without rebuilding the renderer. See
2170
+ * `qualityRendering`.
2025
2171
  */
2026
- var AudioPlayer = class extends Node {
2027
- static typeName = "AudioPlayer";
2028
- static signals = ["finished"];
2029
- static props = {
2030
- src: { default: "" },
2031
- preset: {
2032
- default: "custom",
2033
- options: ["custom", ...SFX_PRESET_NAMES]
2034
- },
2035
- volume: { default: 1 },
2036
- pitch: { default: 1 },
2037
- seed: { default: 0 },
2038
- bus: {
2039
- default: "sfx",
2040
- options: ["sfx", "music"]
2041
- },
2042
- loop: { default: false },
2043
- autoplay: { default: false },
2044
- spatial: { default: false },
2045
- refDistance: { default: 1 },
2046
- maxDistance: { default: 50 },
2047
- rolloff: {
2048
- default: "inverse",
2049
- options: [...ROLLOFF_MODELS]
2050
- }
2172
+ function qualityEnvironment(tier) {
2173
+ if (tier === "low") return {
2174
+ shadows: false,
2175
+ bloom: null,
2176
+ post: null,
2177
+ clouds: null
2178
+ };
2179
+ if (tier === "medium") return {
2180
+ shadows: { static: true },
2181
+ bloom: {},
2182
+ post: null
2183
+ };
2184
+ return {
2185
+ shadows: true,
2186
+ bloom: {},
2187
+ post: {}
2188
+ };
2189
+ }
2190
+ const QUALITY_CAPS = Object.freeze({
2191
+ low: Object.freeze({
2192
+ fancyWater: false,
2193
+ waterMirror: false,
2194
+ minReflectionIntervalMs: 2e3
2195
+ }),
2196
+ medium: Object.freeze({
2197
+ fancyWater: true,
2198
+ waterMirror: false,
2199
+ minReflectionIntervalMs: 2e3
2200
+ }),
2201
+ high: Object.freeze({
2202
+ fancyWater: true,
2203
+ waterMirror: true,
2204
+ minReflectionIntervalMs: 0
2205
+ })
2206
+ });
2207
+ /**
2208
+ * The caps for a tier — the same frozen object every call.
2209
+ *
2210
+ * This is read once per water node per frame, so it must not allocate.
2211
+ */
2212
+ function qualityCaps(tier) {
2213
+ return QUALITY_CAPS[tier] ?? QUALITY_CAPS.high;
2214
+ }
2215
+ function qualityRendering(tier) {
2216
+ const device = Math.min(globalThis.devicePixelRatio ?? 1, 2);
2217
+ if (tier === "low") return {
2218
+ antialias: false,
2219
+ pixelRatio: 1
2220
+ };
2221
+ if (tier === "medium") return {
2222
+ antialias: true,
2223
+ pixelRatio: Math.min(device, 1.5)
2224
+ };
2225
+ return {
2226
+ antialias: true,
2227
+ pixelRatio: device
2228
+ };
2229
+ }
2230
+ //#endregion
2231
+ //#region src/core/engine.ts
2232
+ const MAX_DT_SECONDS = .25;
2233
+ function rafScheduler(cb) {
2234
+ let live = true;
2235
+ let id = requestAnimationFrame(function loop(t) {
2236
+ if (!live) return;
2237
+ cb(t);
2238
+ id = requestAnimationFrame(loop);
2239
+ });
2240
+ return () => {
2241
+ live = false;
2242
+ cancelAnimationFrame(id);
2051
2243
  };
2052
- /** Audio file url (same resolution rules as scene asset urls). Used when
2053
- * `preset === 'custom'`; ignored for procedural presets. */
2054
- src = "";
2055
- /** A procedural SFX preset name (zero-asset), or 'custom' to use `src`. */
2056
- preset = "custom";
2057
- volume = 1;
2058
- /** Pitch multiplier for procedural presets (1 = unchanged). */
2059
- pitch = 1;
2060
- /** Variation seed for noisy presets (e.g. explosion/hit/step). */
2061
- seed = 0;
2062
- /** Which volume bus this routes through. */
2063
- bus = "sfx";
2064
- loop = false;
2065
- /** Start on the first frame in the tree (subject to the gesture policy). */
2066
- autoplay = false;
2244
+ }
2245
+ /**
2246
+ * The game loop: drives a Scene's tree with fixed-timestep `fixedUpdate`
2247
+ * (physics/network window) and variable `update` (everything else).
2248
+ *
2249
+ * Instance-scoped by design — multiple engines can coexist. Headless-testable
2250
+ * via the injectable scheduler and the public `tick(nowMs)`.
2251
+ */
2252
+ var Engine = class {
2253
+ /** Emitted after the tree's variable update each frame, with dt seconds. */
2254
+ updated = new Signal();
2255
+ /** Emitted after each fixed step, with the fixed dt seconds. */
2256
+ fixedUpdated = new Signal();
2067
2257
  /**
2068
- * 3D positional audio: in a 3D scene the sound pans + attenuates by the
2069
- * emitter's world position relative to the active Camera3D (the listener).
2070
- * Default false → identical non-spatial behavior (back-compat). In a 2D scene
2071
- * spatial is currently ignored (the adapter feeds no pose) — see incanto-audio.
2258
+ * Emitted after a scene swap completes (input map redeclared) with the new
2259
+ * scene, and with null on dispose — overlays (touch controls, debug panels)
2260
+ * rebuild themselves here.
2072
2261
  */
2073
- spatial = false;
2074
- /** Distance at which spatial gain is full; closer never gets louder. */
2075
- refDistance = 1;
2076
- /** Distance past which spatial gain stops falling. */
2077
- maxDistance = 50;
2078
- /** Spatial attenuation curve: 'inverse' | 'linear' | 'exponential'. */
2079
- rolloff = "inverse";
2080
- /** Loader hook: unknown presets / buses fail at LOAD (agents self-correct). */
2081
- static validateJson(node) {
2082
- const a = node;
2083
- if (a.preset !== "custom" && !SFX_PRESET_NAMES.includes(a.preset)) throw new IncantoError("BAD_FORMAT", `AudioPlayer '${node.name}' preset must be 'custom' or one of [${SFX_PRESET_NAMES.join(", ")}], got '${a.preset}'.`, {
2084
- prop: "preset",
2085
- validOptions: SFX_PRESET_NAMES
2086
- });
2087
- if (a.bus !== "sfx" && a.bus !== "music") throw new IncantoError("BAD_FORMAT", `AudioPlayer '${node.name}' bus must be 'sfx' or 'music', got '${a.bus}'.`, {
2088
- prop: "bus",
2089
- validOptions: ["sfx", "music"]
2090
- });
2091
- if (!ROLLOFF_MODELS.includes(a.rolloff)) throw new IncantoError("BAD_FORMAT", `AudioPlayer '${node.name}' rolloff must be one of [${ROLLOFF_MODELS.join(", ")}], got '${a.rolloff}'.`, {
2092
- prop: "rolloff",
2093
- validOptions: [...ROLLOFF_MODELS]
2094
- });
2095
- }
2096
- /** A play() was blocked by the browser's autoplay policy. */
2097
- pendingGesture = false;
2262
+ sceneChanged = new Signal();
2263
+ /** Declarative input — scene `input{}` declarations load on setScene. */
2264
+ input = new InputMap();
2265
+ /** Seeded randomness for game logic (deterministic when `seed` is set). */
2266
+ rng;
2267
+ /** The engine log channel (debug overlay + headless harness tail this). */
2268
+ log = new LogManager();
2098
2269
  /**
2099
- * Why this player is silent, when the reason is the FILE rather than the
2100
- * autoplay gate. Read by `game.assetErrors()`.
2270
+ * Screen pixel → the node under it, when a renderer has offered one.
2271
+ *
2272
+ * The raycast has always existed (`Renderer3D.pick` / `Renderer2D.pick`) and
2273
+ * was reachable only from whoever held the renderer — so a Behavior, which
2274
+ * holds a node, could not use it. Every mouse-driven genre needs exactly this
2275
+ * and had no way to ask. Instance-scoped, set by the renderer on construction
2276
+ * and cleared on dispose; null when nothing renders (headless).
2101
2277
  */
2102
- loadError = null;
2103
- element = null;
2104
- _playing = false;
2105
- autoplayed = false;
2106
- /** Last spatial pose pushed by the 3D adapter (null until/unless spatial). */
2107
- _spatialPose = null;
2108
- get playing() {
2109
- return this._playing;
2110
- }
2278
+ picker = null;
2279
+ /** Global volume buses — `master` × `sfx`/`music` gain + `muted`. AudioPlayer
2280
+ * routes its volume through this; games set it for global volume/mute. */
2281
+ audio = new AudioBuses();
2111
2282
  /**
2112
- * @internal Per-frame spatial feed from the 3D adapter (detected structurally
2113
- * by syncTree). Stores the emitter world position + listener pose; ignored
2114
- * unless `spatial` is on. Headless / 2D scenes never call this.
2283
+ * What SHOWED — the visual half of the audio record. Particle bursts, camera
2284
+ * shake, screen flash and freeze frames land here, so "did the explosion
2285
+ * fire?" is answerable in a run with no renderer.
2115
2286
  */
2116
- _setSpatialPose(pose) {
2117
- this._spatialPose = pose;
2118
- }
2287
+ effects = new EffectLog();
2119
2288
  /**
2120
- * Write this sound into the engine's audio record.
2289
+ * Persisted player settings — volume, quality, sensitivity.
2121
2290
  *
2122
- * The CALL happens even where the backend does not (headless, the verify VM),
2123
- * which is exactly why the record is worth keeping: it answers "did the coin
2124
- * sound fire when the coin was collected" in places no sound can be heard.
2291
+ * `createSaveStore` was good and had zero callers, so every game's volume
2292
+ * reset on reload. Reading it here costs nothing (localStorage, once) and
2293
+ * `bindAudio` makes the volumes write themselves.
2125
2294
  */
2126
- note(kind, name) {
2127
- const engine = this.tree?.engine;
2128
- engine?.audio.record({
2129
- kind,
2130
- name,
2131
- from: this.getPath(),
2132
- bus: this.bus === "music" ? "music" : "sfx",
2133
- at: engine.time
2134
- });
2135
- }
2136
- /** Final gain = engine buses × this volume (1 when not in a tree). */
2137
- gain() {
2138
- const buses = this.tree?.engine?.audio;
2139
- return buses ? buses.effectiveVolume(this.bus, this.volume) : this.volume;
2140
- }
2141
- /** Distance gain for the src/element path (1 when not spatial / no pose). */
2142
- spatialElementGain() {
2143
- if (!this.spatial || !this._spatialPose) return 1;
2144
- const { position, listener } = this._spatialPose;
2145
- const dx = position[0] - listener.position[0];
2146
- const dy = position[1] - listener.position[1];
2147
- const dz = position[2] - listener.position[2];
2148
- return spatialGain(Math.hypot(dx, dy, dz), {
2149
- refDistance: this.refDistance,
2150
- maxDistance: this.maxDistance,
2151
- rolloff: this.rolloff
2152
- });
2153
- }
2154
- /** The `spatial` option for the SFX path, or undefined when not spatial. */
2155
- spatialPlay() {
2156
- if (!this.spatial || !this._spatialPose) return void 0;
2157
- return {
2158
- position: this._spatialPose.position,
2159
- listener: this._spatialPose.listener,
2160
- refDistance: this.refDistance,
2161
- maxDistance: this.maxDistance,
2162
- rolloff: this.rolloff
2163
- };
2164
- }
2165
- play() {
2166
- if (this.preset !== "custom") {
2167
- this.note("preset", this.preset);
2168
- this.playPreset();
2169
- return;
2170
- }
2171
- if (!this.src) return;
2172
- this.note("src", this.src);
2173
- if (!this.element) {
2174
- this.element = createElement();
2175
- if (!this.element) return;
2176
- this.element.addEventListener("ended", () => {
2177
- if (this.loop) return;
2178
- this._playing = false;
2179
- this.emit("finished");
2180
- });
2181
- this.element.addEventListener("error", () => {
2182
- this._playing = false;
2183
- if (this.loadError) return;
2184
- this.loadError = `could not load ${this.src}`;
2185
- diagnose(this.tree?.engine ?? null, "error", `[incanto] AudioPlayer '${this.name}' could not load '${this.src}' — it is silent.`);
2186
- });
2187
- }
2188
- const el = this.element;
2189
- el.src = this.src;
2190
- el.volume = this.gain() * this.spatialElementGain();
2191
- el.loop = this.loop;
2192
- this._playing = true;
2193
- this.pendingGesture = false;
2194
- el.play().catch((error) => {
2195
- this._playing = false;
2196
- const name = error?.name ?? "";
2197
- if (name !== "NotSupportedError") {
2198
- this.pendingGesture = true;
2199
- return;
2200
- }
2201
- this.loadError = `${name}: ${this.src}`;
2202
- diagnose(this.tree?.engine ?? null, "error", `[incanto] AudioPlayer '${this.name}' cannot play '${this.src}' (${name}) — it is silent.`);
2203
- });
2204
- }
2295
+ settings = new Settings();
2205
2296
  /**
2206
- * Procedural-SFX path: synthesize the preset and fire it through WebAudio
2207
- * (low-latency, overlap-friendly). Fire-and-forget — no `finished`/`playing`
2208
- * tracking (these are short one-shots, not the element-driven `src` clip).
2209
- * Headless (no AudioContext) → silent no-op. WebAudio also needs a gesture;
2210
- * a suspended context is resumed by the same unlock listener as `src` players.
2297
+ * Translations, and the locale in force. English is the base and the default;
2298
+ * anything a locale does not declare falls back to it silently.
2211
2299
  */
2212
- playPreset() {
2213
- const params = SFX_PRESETS[this.preset];
2214
- if (!params) return;
2215
- const sfx = this.tree?.engine?.sfx;
2216
- if (!sfx) return;
2217
- sfx.play(params, this.gain(), {
2218
- pitch: this.pitch,
2219
- seed: this.seed,
2220
- spatial: this.spatialPlay()
2221
- });
2222
- }
2300
+ locale = new Localization();
2301
+ /**
2302
+ * Draw-order bands in force: the six built in, plus whatever the current
2303
+ * scene declares. Read by `Node2D`/`Node3D.effectiveRenderOrder`.
2304
+ */
2305
+ orderGroups = ORDER_GROUP_BASE;
2306
+ /** Low-latency procedural-SFX player (WebAudio, headless-safe). AudioPlayer
2307
+ * presets play through it; games may call `engine.sfx.play(...)` directly. */
2308
+ sfx = new SfxEngine();
2309
+ /** Single-track background-music manager (crossfade/loop, headless-safe).
2310
+ * `engine.music.play(src)` / `crossfadeTo(src, secs)` / `stop(fadeOut)`;
2311
+ * routes through the music bus and is advanced each frame by the loop. */
2312
+ music = new MusicManager(this.audio);
2313
+ /**
2314
+ * Where nodes that build DOM (HudLayer and its widgets) mount their elements.
2315
+ *
2316
+ * `null` — the default — means `document.body` with `position: fixed`, i.e. a
2317
+ * game's HUD covers the window, which is what a game wants. A host that only
2318
+ * OWNS PART of the page sets this to its own (positioned) container so the
2319
+ * HUD stays inside it: the scene editor points it at the viewport pane, which
2320
+ * is why an edited scene's HUD no longer floats over the inspector.
2321
+ */
2322
+ uiHost = null;
2323
+ fixedStep;
2324
+ maxFixedSteps;
2325
+ scheduler;
2326
+ _scene = null;
2327
+ lastMs = null;
2328
+ accumulator = 0;
2329
+ disposeScheduler = null;
2330
+ frameStats = new FrameStatsRing();
2331
+ /** Where each frame's time went — the breakdown `frameMs` alone cannot give. */
2332
+ phaseTimings = new PhaseTimings();
2333
+ /** This frame's render cost, as reported by whoever renders. */
2334
+ renderMs = 0;
2335
+ /**
2336
+ * Game-time multiplier: 1 = realtime, 0.5 = slow motion, 0 = frozen
2337
+ * (hit-stop / pause). Scales BOTH variable and fixed updates — physics,
2338
+ * timers, behaviors all breathe together. See `gameplay` `hitStop()`.
2339
+ */
2340
+ timeScale = 1;
2341
+ /**
2342
+ * The node the dev overlay has selected (renderers draw its bounding box
2343
+ * as an orange outline in the game view so you can SEE what you picked).
2344
+ * Null when nothing is selected; cleared automatically when it leaves the
2345
+ * tree. Set by the debug overlay — games normally never touch this.
2346
+ */
2347
+ debugSelection = null;
2348
+ _time = 0;
2349
+ _unscaledTime = 0;
2223
2350
  /**
2224
- * A destroyed node goes SILENT.
2225
- *
2226
- * Nothing used to stop a sound on teardown, so a level's looping background
2227
- * clip kept playing over the level after it — the node was freed, so nobody
2228
- * had a handle to stop it with either. Being MOVED is deliberately not this:
2229
- * reparenting a node is not destroying it, and a sound that survives the move
2230
- * is what a scene graph should do.
2351
+ * Elapsed GAME time in seconds since the scene started — the sum of every
2352
+ * scaled dt (freezes at timeScale 0, crawls in slow motion). Unity's
2353
+ * `Time.time`. Resets on `setScene`.
2231
2354
  */
2232
- free() {
2233
- this.stop();
2234
- super.free();
2355
+ get time() {
2356
+ return this._time;
2235
2357
  }
2236
- stop() {
2237
- if (this.element) {
2238
- this.element.pause();
2239
- this.element.currentTime = 0;
2240
- }
2241
- this._playing = false;
2242
- this.pendingGesture = false;
2358
+ /** Elapsed REAL time in seconds since the scene started (ignores timeScale) —
2359
+ * UI animations that must keep moving during slow-mo/pause read this. */
2360
+ get unscaledTime() {
2361
+ return this._unscaledTime;
2243
2362
  }
2244
- /** Replay a gesture-blocked play (wired to the first user gesture). */
2245
- retryPending() {
2246
- if (this.pendingGesture) this.play();
2363
+ constructor(opts = {}) {
2364
+ this.fixedStep = 1 / (opts.fixedHz ?? 60);
2365
+ this.maxFixedSteps = opts.maxFixedStepsPerTick ?? 5;
2366
+ this.scheduler = opts.scheduler ?? rafScheduler;
2367
+ this.rng = new Rng(opts.seed ?? Math.random() * 4294967295 >>> 0);
2368
+ this.maxFps = opts.maxFps ?? 0;
2369
+ this.sfx._setBuses(this.audio);
2370
+ this.music.onTrackError = (src, reason) => {
2371
+ diagnose(this, "error", `[incanto] music '${src}' will not play — ${reason}.`);
2372
+ };
2247
2373
  }
2248
- update(_dt) {
2249
- if (this.autoplay && !this.autoplayed) {
2250
- this.autoplayed = true;
2251
- this.play();
2252
- }
2253
- if (this.element && this._playing) {
2254
- this.element.volume = this.gain() * this.spatialElementGain();
2255
- this.element.loop = this.loop;
2256
- }
2374
+ get scene() {
2375
+ return this._scene;
2257
2376
  }
2258
- };
2259
- //#endregion
2260
- //#region src/core/nodes/hud.ts
2261
- const ANCHOR_CSS = {
2262
- topLeft: "left:16px;top:16px;align-items:flex-start;",
2263
- top: "left:50%;top:16px;transform:translateX(-50%);align-items:center;",
2264
- topRight: "right:16px;top:16px;align-items:flex-end;",
2265
- left: "left:16px;top:50%;transform:translateY(-50%);align-items:flex-start;",
2266
- center: "left:50%;top:50%;transform:translate(-50%,-50%);align-items:center;",
2267
- right: "right:16px;top:50%;transform:translateY(-50%);align-items:flex-end;",
2268
- bottomLeft: "left:16px;bottom:16px;align-items:flex-start;",
2269
- bottom: "left:50%;bottom:16px;transform:translateX(-50%);align-items:center;",
2270
- bottomRight: "right:16px;bottom:16px;align-items:flex-end;"
2271
- };
2272
- const FONT = "600 14px system-ui, -apple-system, 'Segoe UI', sans-serif";
2273
- function hasDom() {
2274
- return typeof document !== "undefined";
2275
- }
2276
- /**
2277
- * The overlay container. One per scene is plenty; widgets mount into its
2278
- * anchor slots. `zIndex` lifts it above game canvases; the layer never eats
2279
- * pointer events (widgets that need clicks opt in individually).
2280
- */
2281
- var HudLayer = class extends Node {
2282
- static typeName = "HudLayer";
2283
- static props = {
2284
- zIndex: { default: 100 },
2285
- visible: { default: true },
2286
- /**
2287
- * Arrow keys / d-pad move focus between the focusable widgets under this
2288
- * layer, Enter / A activates. OFF by default — a game whose HUD has a
2289
- * button must not lose its arrow keys the moment one exists.
2290
- */
2291
- focusNavigation: { default: false }
2292
- };
2293
- zIndex = 100;
2294
- visible = true;
2295
- focusNavigation = false;
2296
- /** @internal root overlay element (null headless). */
2297
- _element = null;
2298
- slots = /* @__PURE__ */ new Map();
2299
- onEnterTree() {
2300
- if (!hasDom()) return;
2301
- const el = document.createElement("div");
2302
- el.dataset.incantoHud = this.name;
2303
- el.style.cssText = `position:fixed;inset:0;pointer-events:none;z-index:${this.zIndex};font:${FONT};`;
2304
- this._element = el;
2305
- this._mount();
2377
+ /** Replace the active scene. The previous scene's root is freed. */
2378
+ setScene(scene) {
2379
+ if (scene === this._scene) return;
2380
+ this._scene?.tree._setEngine(null);
2381
+ this._scene?.root.free();
2382
+ this._scene = scene;
2383
+ scene.tree._setEngine(this);
2384
+ this.input.clear();
2385
+ this._time = 0;
2386
+ this._unscaledTime = 0;
2387
+ if (scene.input) this.input.declare(scene.input);
2388
+ if (scene.strings) this.locale.load(scene.strings);
2389
+ this.orderGroups = resolveOrderGroups(scene.orderGroups);
2390
+ this.sceneChanged.emit(scene);
2391
+ }
2392
+ start() {
2393
+ if (this.disposeScheduler) return;
2394
+ this.disposeScheduler = this.scheduler((now) => {
2395
+ if (!this.frameIsDue(now)) return;
2396
+ try {
2397
+ this.tick(now);
2398
+ } catch (error) {
2399
+ this.reportFrameError(error);
2400
+ }
2401
+ });
2306
2402
  }
2307
2403
  /**
2308
- * Put the overlay where the page's owner says UI goes: `engine.uiHost`, or
2309
- * the window when nobody claims it (a game — a HUD covers the screen).
2404
+ * The frame cap: fps, or 0 for "as fast as the display goes" (the default).
2310
2405
  *
2311
- * Re-checked every frame because a scene's tree is BUILT before it is handed
2312
- * to an engine: at onEnterTree there is no engine to ask yet, so the first
2313
- * update is where the answer actually arrives. appendChild moves the element
2314
- * if it already landed somewhere else, so the HUD follows the host across a
2315
- * scene swap into a differently-hosted engine (game → editor) instead of
2316
- * being stranded on document.body over the inspector.
2406
+ * Live — a settings menu writes it mid-game. It gates the whole frame, not
2407
+ * just the render: an uncapped loop on a 144 Hz phone burns battery running
2408
+ * game logic nobody sees.
2409
+ *
2410
+ * `tick()` is deliberately NOT gated. A headless harness driving its own
2411
+ * clock asked for that frame; the cap is about the real-time loop.
2317
2412
  */
2318
- _mount() {
2319
- const el = this._element;
2320
- if (!el) return;
2321
- const host = this.tree?.engine?.uiHost ?? document.body;
2322
- if (el.parentElement === host) return;
2323
- el.style.position = host === document.body ? "fixed" : "absolute";
2324
- host.appendChild(el);
2325
- }
2326
- onExitTree() {
2327
- this._element?.remove();
2328
- this._element = null;
2329
- this.slots.clear();
2330
- }
2331
- update() {
2332
- if (this._element) {
2333
- this._mount();
2334
- this._element.style.display = this.visible ? "" : "none";
2335
- }
2336
- if (this.focusNavigation) this.stepFocus();
2337
- }
2413
+ maxFps = 0;
2414
+ nextFrameAt = 0;
2415
+ lastOfferedAt = null;
2416
+ displayPeriodMs = 0;
2338
2417
  /**
2339
- * Arrow keys / d-pad move the focus, Enter / A activates it.
2418
+ * Should this display frame run?
2340
2419
  *
2341
- * Off by default: a game whose HUD has a button must not lose its arrow keys
2342
- * to a menu the moment one exists. Turn it on for the screens that ARE menus
2343
- * (`"focusNavigation": true` on the pause panel's layer), and off again when
2344
- * play resumes.
2420
+ * The obvious `if (now - last < interval) return` is wrong in a way that only
2421
+ * shows up between the round numbers: a 45 fps cap on a 60 Hz display misses
2422
+ * every other deadline by a fraction of a millisecond, waits a whole display
2423
+ * frame more, and delivers 30 — asking for 45 makes the game slower than not
2424
+ * capping at all. The fix is to allow a frame that is within half a display
2425
+ * period of its deadline, and to advance the deadline by the interval rather
2426
+ * than from `now`, so the error does not accumulate.
2345
2427
  */
2346
- stepFocus() {
2347
- const engine = this.tree?.engine;
2348
- if (!engine) return;
2349
- const items = this.focusables();
2350
- if (items.length === 0) return;
2351
- const input = engine.input;
2352
- const down = input.keyJustPressed("ArrowDown") || input.keyJustPressed("Pad13");
2353
- const up = input.keyJustPressed("ArrowUp") || input.keyJustPressed("Pad12");
2354
- const left = input.keyJustPressed("ArrowLeft") || input.keyJustPressed("Pad14");
2355
- const right = input.keyJustPressed("ArrowRight") || input.keyJustPressed("Pad15");
2356
- const confirm = input.keyJustPressed("Enter") || input.keyJustPressed("Pad0");
2357
- let index = items.findIndex((w) => w._focused);
2358
- if (index === -1 && (down || up || confirm)) index = 0;
2359
- else if (down) index = (index + 1) % items.length;
2360
- else if (up) index = (index - 1 + items.length) % items.length;
2361
- if (index !== -1) {
2362
- for (const w of items) {
2363
- const next = w === items[index];
2364
- if (next !== w._focused) {
2365
- w._focused = next;
2366
- w._paintFocus();
2367
- }
2368
- }
2369
- const focused = items[index];
2370
- if (focused) {
2371
- if (confirm) focused._activate(0);
2372
- else if (left) focused._activate(-1);
2373
- else if (right) focused._activate(1);
2374
- }
2375
- }
2376
- }
2377
- /** Focusable widgets under this layer, in tree order. */
2378
- focusables() {
2379
- const out = [];
2380
- const walk = (node) => {
2381
- if (node instanceof HudWidgetBase && node.focusable && node.visible) out.push(node);
2382
- for (const child of node.children) walk(child);
2383
- };
2384
- walk(this);
2385
- return out;
2386
- }
2387
- /** Move focus to a widget by hand — opening a menu should start somewhere. */
2388
- focus(widget) {
2389
- for (const w of this.focusables()) {
2390
- const next = w === widget;
2391
- if (next !== w._focused) {
2392
- w._focused = next;
2393
- w._paintFocus();
2394
- }
2395
- }
2396
- }
2397
- /** @internal Widgets mount into per-anchor flex columns. */
2398
- _slot(anchor) {
2399
- if (!this._element) return null;
2400
- let slot = this.slots.get(anchor);
2401
- if (!slot) {
2402
- slot = document.createElement("div");
2403
- slot.style.cssText = `position:absolute;display:flex;flex-direction:column;gap:8px;${ANCHOR_CSS[anchor] ?? ANCHOR_CSS.topLeft}`;
2404
- this._element.appendChild(slot);
2405
- this.slots.set(anchor, slot);
2406
- }
2407
- return slot;
2408
- }
2409
- };
2410
- /**
2411
- * The widget an element belongs to.
2412
- *
2413
- * A pick lands on whatever div is under the cursor — usually a CHILD of the
2414
- * widget (an icon inside a slot), so the walk upward is the point.
2415
- */
2416
- const widgetOf = /* @__PURE__ */ new WeakMap();
2417
- function widgetFromElement(el) {
2418
- for (let n = el; n; n = n.parentElement) {
2419
- const found = widgetOf.get(n);
2420
- if (found) return found;
2428
+ frameIsDue(now) {
2429
+ const prev = this.lastOfferedAt;
2430
+ this.lastOfferedAt = now;
2431
+ if (prev !== null) {
2432
+ const delta = now - prev;
2433
+ if (delta > 0 && delta < 500) this.displayPeriodMs = this.displayPeriodMs === 0 ? delta : this.displayPeriodMs * .9 + delta * .1;
2434
+ }
2435
+ if (!(this.maxFps > 0)) {
2436
+ this.nextFrameAt = 0;
2437
+ return true;
2438
+ }
2439
+ const interval = 1e3 / this.maxFps;
2440
+ if (this.nextFrameAt === 0) {
2441
+ this.nextFrameAt = now + interval;
2442
+ return true;
2443
+ }
2444
+ if (now < this.nextFrameAt - this.displayPeriodMs * .5) return false;
2445
+ this.nextFrameAt = Math.max(now, this.nextFrameAt + interval);
2446
+ return true;
2421
2447
  }
2422
- return null;
2423
- }
2424
- /** The drag in flight, if any. One pointer, one drag. */
2425
- let dragging = null;
2426
- /** Shared plumbing: mount into the parent HudLayer's anchor slot. */
2427
- var HudWidgetBase = class extends Node {
2428
2448
  /**
2429
- * Wireable visibility: `noSave → Continue.hide`.
2430
- *
2431
- * `visible` is a prop, and a `connections[]` handler has to be a METHOD — the
2432
- * same wall that kept a score off the screen until widgets grew `setText`. A
2433
- * title screen greying out its own Continue button is the case that asked.
2449
+ * A frame threw somewhere outside a single node (a renderer, physics, a
2450
+ * signal handler). Report the first one and count the rest: a broken frame
2451
+ * repeats sixty times a second, and sixty identical traces a second is how
2452
+ * you lose the first one.
2434
2453
  */
2435
- show(..._args) {
2436
- this.visible = true;
2454
+ reportFrameError(error) {
2455
+ this.errorCount += 1;
2456
+ const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
2457
+ if (text === this.lastFrameError) return;
2458
+ this.lastFrameError = text;
2459
+ this.log.error("[incanto] a frame threw — the loop keeps running:", error);
2460
+ console.error("[incanto] a frame threw — the loop keeps running:", error);
2437
2461
  }
2438
- hide() {
2439
- this.visible = false;
2462
+ /** @internal A node quarantined itself; count it for `stats().errors`. */
2463
+ _nodeErrored(_node) {
2464
+ this.errorCount += 1;
2440
2465
  }
2441
- static props = {
2442
- anchor: {
2443
- default: "topLeft",
2444
- options: [
2445
- "topLeft",
2446
- "top",
2447
- "topRight",
2448
- "left",
2449
- "center",
2450
- "right",
2451
- "bottomLeft",
2452
- "bottom",
2453
- "bottomRight"
2454
- ]
2455
- },
2456
- visible: { default: true },
2457
- /**
2458
- * Can menu navigation land on this? Interactive widgets default to true.
2459
- *
2460
- * A menu you can only click is not playable on a controller, and "add
2461
- * gamepad support" is not a thing a JSON scene could express at all.
2462
- */
2463
- focusable: { default: false },
2464
- /**
2465
- * Can the player pick this up and drop it somewhere? An inventory item is
2466
- * a `draggable` widget; a slot is a `dropTarget`.
2467
- */
2468
- draggable: { default: false },
2469
- /** Can something be dropped ON this? */
2470
- dropTarget: { default: false }
2471
- };
2472
- static signals = [
2473
- "dragStarted",
2474
- "dragCancelled",
2475
- "droppedOn",
2476
- "dropped"
2477
- ];
2478
- anchor = "topLeft";
2479
- visible = true;
2480
- focusable = false;
2481
- draggable = false;
2482
- dropTarget = false;
2483
- /** @internal Set by the owning HudLayer while this widget has focus. */
2484
- _focused = false;
2466
+ errorCount = 0;
2467
+ lastFrameError = null;
2485
2468
  /**
2486
- * @internal Confirm (`dir` 0) or nudge (-1 left / +1 right) — what pressing
2487
- * A or an arrow ON this widget means. Default: nothing.
2469
+ * Every node with something quarantined — its own update, its script, or
2470
+ * both — so a tool can list them and put them back. Walks on demand; there is
2471
+ * no per-frame bookkeeping for this.
2488
2472
  */
2489
- _activate(_dir) {}
2490
- /** @internal Draw the focus ring. Overridable if a widget wants its own. */
2491
- _paintFocus() {
2492
- if (!this._element) return;
2493
- this._element.style.outline = this._focused ? "2px solid #6ee7dc" : "";
2494
- this._element.style.outlineOffset = this._focused ? "2px" : "";
2495
- }
2496
- /** @internal */
2497
- _element = null;
2498
- layer() {
2499
- for (let p = this.parent; p; p = p.parent) if (p instanceof HudLayer) return p;
2500
- return null;
2501
- }
2502
- onReady() {
2503
- if (!hasDom()) return;
2504
- this._mountWidget();
2505
- }
2506
2473
  /**
2507
- * @internal Build and attach, once a host exists.
2474
+ * The string for `key` in the current locale — English if this locale does not
2475
+ * declare it, the key itself if nothing does.
2508
2476
  *
2509
- * A widget inside a UiPanel mounts into the PANEL, not the layer's anchor
2510
- * slot — that is what turns structure in the tree into structure on screen,
2511
- * and it is what a menu is. Ready order does not decide it: the panel builds
2512
- * on demand when a child asks (`_container`), and `update` retries for
2513
- * anything attached at runtime before its host existed.
2477
+ * The short form of `engine.locale.t`, because a behavior reaching for a
2478
+ * translated string is the common case:
2479
+ * `banner.show(this.engine.t('wave.start', { n: wave }))`.
2514
2480
  */
2515
- _mountWidget() {
2516
- if (this._element || !hasDom()) return;
2517
- const host = this.host() ?? this.layer()?._slot(this.anchor);
2518
- if (!host) return;
2519
- this._element = this._build();
2520
- widgetOf.set(this._element, this);
2521
- if (this.draggable || this.dropTarget) this._wireDrag(this._element);
2522
- host.appendChild(this._element);
2481
+ t(key, params) {
2482
+ return this.locale.t(key, params);
2523
2483
  }
2524
2484
  /**
2525
- * Drag and drop, on the DOM the engine already builds.
2485
+ * Every behavior's state, ready to put in a save slot.
2526
2486
  *
2527
- * An inventory is the one screen where "click it" is not enough, and every
2528
- * game that wanted one dropped out of scene JSON to hand-roll pointer
2529
- * handlers. The whole gesture is four signals and no new node type: a
2530
- * `draggable` widget emits `dragStarted` and, if it lands on one,
2531
- * `droppedOn(target)`; a `dropTarget` emits `dropped(source)`. Who owns the
2532
- * ITEM is the game's business — this reports the gesture, not a model.
2487
+ * Pairs with `restoreState` after the scene has RELOADED — see
2488
+ * core/save-slots.ts for why a save carries state rather than a tree
2489
+ * snapshot.
2533
2490
  */
2534
- _wireDrag(el) {
2535
- el.style.touchAction = "none";
2536
- if (this.draggable) {
2537
- el.style.cursor = "grab";
2538
- el.addEventListener("pointerdown", (event) => {
2539
- if (!this.draggable) return;
2540
- event.preventDefault();
2541
- el.setPointerCapture?.(event.pointerId);
2542
- dragging = this;
2543
- el.style.opacity = "0.6";
2544
- this.emit("dragStarted", this);
2545
- });
2546
- el.addEventListener("pointerup", (event) => {
2547
- if (dragging !== this) return;
2548
- el.style.opacity = "";
2549
- el.releasePointerCapture?.(event.pointerId);
2550
- const target = widgetFromElement(document.elementFromPoint?.(event.clientX, event.clientY) ?? null);
2551
- dragging = null;
2552
- if (target && target !== this && target.dropTarget) {
2553
- this.emit("droppedOn", target);
2554
- target.emit("dropped", this);
2555
- } else this.emit("dragCancelled", this);
2556
- });
2557
- }
2491
+ captureState() {
2492
+ const root = this.scene?.root;
2493
+ if (!root) return {};
2494
+ const { state, unaddressable } = captureBehaviors(root, this.scene?.source);
2495
+ for (const path of unaddressable) diagnose(this, "warn", `save: ${path} has state to save and no uid to key it under.`);
2496
+ return state;
2558
2497
  }
2559
- /** The nearest ancestor that holds widgets itself (a UiPanel). */
2560
- host() {
2561
- for (let p = this.parent; p; p = p.parent) {
2562
- const holder = p;
2563
- if (typeof holder._container === "function") return holder._container();
2564
- if (p instanceof HudLayer) return null;
2565
- }
2566
- return null;
2498
+ /** Give it back, after the scene is loaded and onReady has run. */
2499
+ restoreState(state) {
2500
+ const root = this.scene?.root;
2501
+ if (!root) return {
2502
+ missing: Object.keys(state),
2503
+ skipped: [],
2504
+ restored: 0,
2505
+ expected: 0,
2506
+ freed: 0
2507
+ };
2508
+ return restoreBehaviors(root, state);
2567
2509
  }
2568
- onExitTree() {
2569
- this._element?.remove();
2570
- this._element = null;
2510
+ erroredNodes() {
2511
+ const out = [];
2512
+ const walk = (node) => {
2513
+ if (node.errored || node.behaviorErrored) out.push(node);
2514
+ for (const child of node.children) walk(child);
2515
+ };
2516
+ if (this._scene?.root) walk(this._scene.root);
2517
+ return out;
2571
2518
  }
2572
- update(_dt) {
2573
- if (!this._element) {
2574
- this._mountWidget();
2575
- if (!this._element) return;
2519
+ /** Un-skip every quarantined node — the "try again" after you fix the code. */
2520
+ resumeErroredNodes() {
2521
+ const nodes = this.erroredNodes();
2522
+ for (const node of nodes) {
2523
+ node.errored = false;
2524
+ node.behaviorErrored = false;
2576
2525
  }
2577
- this._element.style.display = this.visible ? "" : "none";
2578
- this._sync();
2526
+ this.lastFrameError = null;
2527
+ return nodes.length;
2579
2528
  }
2580
- _sync() {}
2581
2529
  /**
2582
- * Resolve a text prop through the engine's locale.
2530
+ * How long the last render took, in ms — called by whoever renders.
2583
2531
  *
2584
- * Called every frame from `_sync`, which is what makes switching language
2585
- * repaint with nothing to invalidate: widgets already re-read their props, so
2586
- * the new string simply appears on the next frame. A plain string costs one
2587
- * `startsWith` and is returned untouched.
2532
+ * The engine does not render, so this is the one slice it cannot measure for
2533
+ * itself; without it the cost lands in `otherMs` beside the GC and the two
2534
+ * are indistinguishable, which is the confusion this whole breakdown exists
2535
+ * to end.
2588
2536
  */
2589
- _t(value) {
2590
- const locale = this.tree?.engine?.locale;
2591
- return locale ? locale.resolve(value) : value;
2537
+ noteRenderMs(ms) {
2538
+ this.renderMs = ms;
2539
+ }
2540
+ stop() {
2541
+ this.disposeScheduler?.();
2542
+ this.disposeScheduler = null;
2543
+ this.lastMs = null;
2544
+ this.accumulator = 0;
2545
+ this.frameStats.clear();
2546
+ this.phaseTimings.clear();
2547
+ }
2548
+ /**
2549
+ * Live performance counters, queryable at ANY time. fps/frameMs come from a
2550
+ * rolling window of REAL `tick` timestamps — headless `step()` runs report 0.
2551
+ * Node count walks the active tree on demand. Renderer counters (triangles,
2552
+ * draw calls) live on `renderer.stats()` / the merged `game.stats()`.
2553
+ */
2554
+ stats() {
2555
+ return {
2556
+ fps: this.frameStats.fps(),
2557
+ frameMs: this.frameStats.frameMs(),
2558
+ nodes: this._scene?.tree.root ? countNodes(this._scene.tree.root) : 0,
2559
+ running: this.disposeScheduler !== null,
2560
+ errors: this.errorCount,
2561
+ phases: this.phaseTimings.read(this.frameStats.frameMs())
2562
+ };
2592
2563
  }
2593
- };
2594
- /** A text line (score, timer, hints). Set `.text` from behaviors. */
2595
- var UiText = class extends HudWidgetBase {
2596
- static typeName = "UiText";
2597
- static props = {
2598
- ...HudWidgetBase.props,
2599
- text: { default: "" },
2600
- format: { default: "" },
2601
- size: { default: 16 },
2602
- color: { default: "#ffffff" },
2603
- shadow: { default: true }
2604
- };
2605
- text = "";
2606
2564
  /**
2607
- * A template with a `{}` slot that `setText` fills — `"Gems {} / 8"`.
2608
- *
2609
- * Empty (the default) means `setText` replaces the whole line. Resolved at
2610
- * PAINT like `text`, so `"@t:hud.gems"` works and changing locale re-reads
2611
- * it: the value lives here, the words live in the strings table.
2565
+ * Render interpolation factor in [0,1]: how far the wall clock has advanced
2566
+ * INTO the next fixed step. Renderers lerp physics bodies between their last
2567
+ * two fixed-step transforms by this much so motion looks smooth even when the
2568
+ * display refresh doesn't divide evenly into the 60Hz fixed step (the classic
2569
+ * fixed-timestep judder). Only meaningful in the real-time `tick()` loop;
2570
+ * the headless `step()` path doesn't bank wall-clock time.
2612
2571
  */
2613
- format = "";
2614
- size = 16;
2615
- color = "#ffffff";
2616
- shadow = true;
2617
- last = "\0";
2618
- /** null until something has actually set a value — see `shown`. */
2619
- slot = null;
2572
+ get interpolationAlpha() {
2573
+ return this.fixedStep > 0 ? Math.min(this.accumulator / this.fixedStep, 1) : 1;
2574
+ }
2620
2575
  /**
2621
- * Put a signal's value on screen — `scoreChanged → setText`.
2622
- *
2623
- * A `connections[]` handler must be a METHOD and `text` is a property, so the
2624
- * one wire every game needs (score to screen) could not be declared: every
2625
- * HUD began with a behavior whose whole job was one assignment.
2576
+ * Full teardown in one call: stop the loop, free the scene tree, detach
2577
+ * every input listener. The single-unmount story for SPA embedding —
2578
+ * renderers own GPU resources and keep their own dispose().
2626
2579
  */
2627
- setText(value) {
2628
- this.slot = String(value ?? "");
2629
- if (!this.format) this.text = this.slot;
2580
+ dispose() {
2581
+ this.stop();
2582
+ this.music.dispose();
2583
+ this.sfx.dispose();
2584
+ if (this._scene) {
2585
+ this._scene.tree._setEngine(null);
2586
+ this._scene.root.free();
2587
+ this._scene = null;
2588
+ this.sceneChanged.emit(null);
2589
+ }
2590
+ this.input.dispose();
2591
+ this.input.clear();
2630
2592
  }
2631
2593
  /**
2632
- * What it actually paints, after the template and the locale.
2594
+ * Advance exactly ONE fixed step and one variable update (both dt = the
2595
+ * fixed step scaled by `timeScale`), bypassing the wall-clock accumulator
2596
+ * entirely. Drift-free by construction — the unit of time for headless
2597
+ * harnesses (incanto/test runScript): every input edge is visible to BOTH
2598
+ * fixedUpdate and update exactly once.
2633
2599
  *
2634
- * Before the first value arrives, a formatted widget shows `text` — its
2635
- * authored opening line. Filling the slot with nothing instead would put
2636
- * `"Gems / 8"` on screen for every game's first frame, and a score line
2637
- * that starts blank looks broken rather than empty.
2600
+ * `ignorePause: true` steps a frame at full scale even while the game is
2601
+ * paused — the debug overlay's single-step button, and nothing else.
2638
2602
  */
2639
- get shown() {
2640
- if (this.format && this.slot !== null) return this._t(this.format).replace("{}", this.slot);
2641
- return this._t(this.text);
2642
- }
2643
- _build() {
2644
- const el = document.createElement("div");
2645
- el.style.cssText = `font-size:${this.size}px;color:${this.color};${this.shadow ? "text-shadow:0 1px 3px rgba(0,0,0,.7);" : ""}white-space:pre;`;
2646
- return el;
2603
+ step(opts = {}) {
2604
+ const scene = this._scene;
2605
+ if (!scene) return;
2606
+ const dt = this.fixedStep * (opts.ignorePause ? 1 : this.timeScale);
2607
+ this._time += dt;
2608
+ this._unscaledTime += this.fixedStep;
2609
+ const startedFixed = performance.now();
2610
+ scene.tree.fixedUpdate(dt);
2611
+ this.fixedUpdated.emit(dt);
2612
+ const startedUpdate = performance.now();
2613
+ scene.tree.update(dt);
2614
+ this.music.tick(dt);
2615
+ this.updated.emit(dt);
2616
+ const ended = performance.now();
2617
+ const updateMs = Math.max(0, ended - startedUpdate - this.renderMs);
2618
+ this.phaseTimings.push(startedUpdate - startedFixed, updateMs, this.renderMs);
2619
+ this.renderMs = 0;
2620
+ this.input.endFrame();
2647
2621
  }
2648
- _sync() {
2649
- const shown = this.shown;
2650
- if (shown !== this.last && this._element) {
2651
- this.last = shown;
2652
- this._element.textContent = shown;
2622
+ /** Advance the loop manually. First call after (re)start only primes the clock. */
2623
+ tick(nowMs) {
2624
+ this.frameStats.push(nowMs);
2625
+ if (this.lastMs === null) {
2626
+ this.lastMs = nowMs;
2627
+ return;
2628
+ }
2629
+ const rawDt = Math.max(0, Math.min((nowMs - this.lastMs) / 1e3, MAX_DT_SECONDS));
2630
+ const dt = rawDt * this.timeScale;
2631
+ this._time += dt;
2632
+ this._unscaledTime += rawDt;
2633
+ this.lastMs = nowMs;
2634
+ const scene = this._scene;
2635
+ if (!scene) return;
2636
+ this.accumulator += dt;
2637
+ let steps = 0;
2638
+ const startedFixed = performance.now();
2639
+ while (this.accumulator >= this.fixedStep && steps < this.maxFixedSteps) {
2640
+ scene.tree.fixedUpdate(this.fixedStep);
2641
+ this.fixedUpdated.emit(this.fixedStep);
2642
+ this.accumulator -= this.fixedStep;
2643
+ steps += 1;
2653
2644
  }
2645
+ if (this.accumulator >= this.fixedStep) this.accumulator %= this.fixedStep;
2646
+ const startedUpdate = performance.now();
2647
+ scene.tree.update(dt);
2648
+ this.music.tick(dt);
2649
+ this.updated.emit(dt);
2650
+ const ended = performance.now();
2651
+ const updateMs = Math.max(0, ended - startedUpdate - this.renderMs);
2652
+ this.phaseTimings.push(startedUpdate - startedFixed, updateMs, this.renderMs);
2653
+ this.renderMs = 0;
2654
+ this.input.endFrame();
2654
2655
  }
2655
2656
  };
2657
+ function countNodes(root) {
2658
+ let n = 1;
2659
+ for (const child of root.children) n += countNodes(child);
2660
+ return n;
2661
+ }
2662
+ function createElement() {
2663
+ if (typeof Audio === "undefined") return null;
2664
+ return new Audio();
2665
+ }
2656
2666
  /**
2657
- * A bar fed the wrong argument shows a plausible wrong number forever.
2667
+ * One sound. `play()` (or `autoplay: true`) starts it; `finished` fires when
2668
+ * it ends. Browsers block audio before the first user gesture — a blocked
2669
+ * play marks `pendingGesture`; `createGame` retries pending players on the
2670
+ * first pointer/key gesture automatically.
2658
2671
  *
2659
- * The trap is one signal away: `Health.damaged` is `(amount, current)`, so
2660
- * wiring it to `setValue` paints the DAMAGE as the health and looks like a
2661
- * working health bar. Say which signal has the shape a bar wants.
2672
+ * Two playback modes:
2673
+ * - `preset: 'custom'` (default) → plays the audio file at `src` via an HTMLAudio
2674
+ * element (good for music / long clips; supports `loop`).
2675
+ * - `preset: 'coin' | 'jump' | …` (a procedural SFX preset) → synthesizes a
2676
+ * zero-asset sound through WebAudio: instant, deterministic, overlap-friendly
2677
+ * for rapid-fire SFX. `pitch`/`seed` vary it. The art-free audio analog of the
2678
+ * particle presets. See `incanto-audio.md` for the full preset list.
2679
+ *
2680
+ * Volume routes through the engine's buses: `engine.audio.master × bus(sfx|music)
2681
+ * × volume`. Set `engine.audio.master`/`sfx`/`music`/`muted` for global control.
2662
2682
  */
2663
- function requireNumber(value, node, method) {
2664
- if (typeof value === "number" && Number.isFinite(value)) return value;
2665
- 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.`);
2666
- }
2667
- /** A labeled progress bar (health, stamina, reload, boss HP). */
2668
- var UiBar = class extends HudWidgetBase {
2669
- static typeName = "UiBar";
2683
+ var AudioPlayer = class extends Node {
2684
+ static typeName = "AudioPlayer";
2685
+ static signals = ["finished"];
2670
2686
  static props = {
2671
- ...HudWidgetBase.props,
2672
- value: { default: 100 },
2673
- max: { default: 100 },
2674
- width: { default: 180 },
2675
- height: { default: 14 },
2676
- color: { default: "#4ade80" },
2677
- lowColor: { default: "#ef4444" },
2678
- lowThreshold: { default: .3 },
2679
- background: { default: "rgba(0,0,0,0.5)" },
2680
- label: { default: "" }
2687
+ src: { default: "" },
2688
+ preset: {
2689
+ default: "custom",
2690
+ options: ["custom", ...SFX_PRESET_NAMES]
2691
+ },
2692
+ volume: { default: 1 },
2693
+ pitch: { default: 1 },
2694
+ seed: { default: 0 },
2695
+ bus: {
2696
+ default: "sfx",
2697
+ options: ["sfx", "music"]
2698
+ },
2699
+ loop: { default: false },
2700
+ autoplay: { default: false },
2701
+ spatial: { default: false },
2702
+ refDistance: { default: 1 },
2703
+ maxDistance: { default: 50 },
2704
+ rolloff: {
2705
+ default: "inverse",
2706
+ options: [...ROLLOFF_MODELS]
2707
+ }
2681
2708
  };
2682
- value = 100;
2683
- max = 100;
2684
- width = 180;
2685
- height = 14;
2686
- color = "#4ade80";
2687
- lowColor = "#ef4444";
2688
- lowThreshold = .3;
2689
- background = "rgba(0,0,0,0.5)";
2690
- label = "";
2691
- fill = null;
2692
- tag = null;
2693
- lastLabel = "\0";
2694
- lastRatio = -1;
2695
- /** Current fill ratio 0..1 (what the bar shows). */
2696
- get ratio() {
2697
- return this.max > 0 ? Math.min(1, Math.max(0, this.value / this.max)) : 0;
2709
+ /** Audio file url (same resolution rules as scene asset urls). Used when
2710
+ * `preset === 'custom'`; ignored for procedural presets. */
2711
+ src = "";
2712
+ /** A procedural SFX preset name (zero-asset), or 'custom' to use `src`. */
2713
+ preset = "custom";
2714
+ volume = 1;
2715
+ /** Pitch multiplier for procedural presets (1 = unchanged). */
2716
+ pitch = 1;
2717
+ /** Variation seed for noisy presets (e.g. explosion/hit/step). */
2718
+ seed = 0;
2719
+ /** Which volume bus this routes through. */
2720
+ bus = "sfx";
2721
+ loop = false;
2722
+ /** Start on the first frame in the tree (subject to the gesture policy). */
2723
+ autoplay = false;
2724
+ /**
2725
+ * 3D positional audio: in a 3D scene the sound pans + attenuates by the
2726
+ * emitter's world position relative to the active Camera3D (the listener).
2727
+ * Default false → identical non-spatial behavior (back-compat). In a 2D scene
2728
+ * spatial is currently ignored (the adapter feeds no pose) — see incanto-audio.
2729
+ */
2730
+ spatial = false;
2731
+ /** Distance at which spatial gain is full; closer never gets louder. */
2732
+ refDistance = 1;
2733
+ /** Distance past which spatial gain stops falling. */
2734
+ maxDistance = 50;
2735
+ /** Spatial attenuation curve: 'inverse' | 'linear' | 'exponential'. */
2736
+ rolloff = "inverse";
2737
+ /** Loader hook: unknown presets / buses fail at LOAD (agents self-correct). */
2738
+ static validateJson(node) {
2739
+ const a = node;
2740
+ if (a.preset !== "custom" && !SFX_PRESET_NAMES.includes(a.preset)) throw new IncantoError("BAD_FORMAT", `AudioPlayer '${node.name}' preset must be 'custom' or one of [${SFX_PRESET_NAMES.join(", ")}], got '${a.preset}'.`, {
2741
+ prop: "preset",
2742
+ validOptions: SFX_PRESET_NAMES
2743
+ });
2744
+ if (a.bus !== "sfx" && a.bus !== "music") throw new IncantoError("BAD_FORMAT", `AudioPlayer '${node.name}' bus must be 'sfx' or 'music', got '${a.bus}'.`, {
2745
+ prop: "bus",
2746
+ validOptions: ["sfx", "music"]
2747
+ });
2748
+ if (!ROLLOFF_MODELS.includes(a.rolloff)) throw new IncantoError("BAD_FORMAT", `AudioPlayer '${node.name}' rolloff must be one of [${ROLLOFF_MODELS.join(", ")}], got '${a.rolloff}'.`, {
2749
+ prop: "rolloff",
2750
+ validOptions: [...ROLLOFF_MODELS]
2751
+ });
2698
2752
  }
2753
+ /** A play() was blocked by the browser's autoplay policy. */
2754
+ pendingGesture = false;
2699
2755
  /**
2700
- * Drive the bar from a signal — `healthChanged → setValue`.
2756
+ * Why this player is silent, when the reason is the FILE rather than the
2757
+ * autoplay gate. Read by `game.assetErrors()`.
2758
+ */
2759
+ loadError = null;
2760
+ element = null;
2761
+ _playing = false;
2762
+ autoplayed = false;
2763
+ /** Last spatial pose pushed by the 3D adapter (null until/unless spatial). */
2764
+ _spatialPose = null;
2765
+ get playing() {
2766
+ return this._playing;
2767
+ }
2768
+ /**
2769
+ * @internal Per-frame spatial feed from the 3D adapter (detected structurally
2770
+ * by syncTree). Stores the emitter world position + listener pose; ignored
2771
+ * unless `spatial` is on. Headless / 2D scenes never call this.
2772
+ */
2773
+ _setSpatialPose(pose) {
2774
+ this._spatialPose = pose;
2775
+ }
2776
+ /**
2777
+ * Write this sound into the engine's audio record.
2701
2778
  *
2702
- * Takes the max as a second argument because that is the shape the signal
2703
- * already has, so a bar whose ceiling moves (a max-HP upgrade) needs no
2704
- * second wire.
2779
+ * The CALL happens even where the backend does not (headless, the verify VM),
2780
+ * which is exactly why the record is worth keeping: it answers "did the coin
2781
+ * sound fire when the coin was collected" in places no sound can be heard.
2705
2782
  */
2706
- setValue(value, max) {
2707
- this.value = requireNumber(value, this, "setValue");
2708
- if (max !== void 0) this.max = requireNumber(max, this, "setValue");
2783
+ note(kind, name) {
2784
+ const engine = this.tree?.engine;
2785
+ engine?.audio.record({
2786
+ kind,
2787
+ name,
2788
+ from: this.getPath(),
2789
+ bus: this.bus === "music" ? "music" : "sfx",
2790
+ at: engine.time
2791
+ });
2709
2792
  }
2710
- /** Move the ceiling on its own, leaving the fill where it is. */
2711
- setMax(max) {
2712
- this.max = requireNumber(max, this, "setMax");
2793
+ /** Final gain = engine buses × this volume (1 when not in a tree). */
2794
+ gain() {
2795
+ const buses = this.tree?.engine?.audio;
2796
+ return buses ? buses.effectiveVolume(this.bus, this.volume) : this.volume;
2713
2797
  }
2714
- _build() {
2715
- const wrap = document.createElement("div");
2716
- wrap.style.cssText = "display:flex;align-items:center;gap:6px;";
2717
- if (this.label) {
2718
- const tag = document.createElement("span");
2719
- tag.style.cssText = "color:#fff;font-size:12px;text-shadow:0 1px 3px rgba(0,0,0,.7);";
2720
- this.tag = tag;
2721
- wrap.appendChild(tag);
2722
- }
2723
- const track = document.createElement("div");
2724
- track.style.cssText = `width:${this.width}px;height:${this.height}px;background:${this.background};border-radius:${this.height / 2}px;overflow:hidden;`;
2725
- this.fill = document.createElement("div");
2726
- this.fill.style.cssText = `height:100%;width:100%;background:${this.color};border-radius:inherit;transition:width .15s;`;
2727
- track.appendChild(this.fill);
2728
- wrap.appendChild(track);
2729
- return wrap;
2798
+ /** Distance gain for the src/element path (1 when not spatial / no pose). */
2799
+ spatialElementGain() {
2800
+ if (!this.spatial || !this._spatialPose) return 1;
2801
+ const { position, listener } = this._spatialPose;
2802
+ const dx = position[0] - listener.position[0];
2803
+ const dy = position[1] - listener.position[1];
2804
+ const dz = position[2] - listener.position[2];
2805
+ return spatialGain(Math.hypot(dx, dy, dz), {
2806
+ refDistance: this.refDistance,
2807
+ maxDistance: this.maxDistance,
2808
+ rolloff: this.rolloff
2809
+ });
2730
2810
  }
2731
- _sync() {
2732
- const label = this._t(this.label);
2733
- if (label !== this.lastLabel && this.tag) {
2734
- this.lastLabel = label;
2735
- this.tag.textContent = label;
2736
- }
2737
- const r = this.ratio;
2738
- if (r === this.lastRatio || !this.fill) return;
2739
- this.lastRatio = r;
2740
- this.fill.style.width = `${r * 100}%`;
2741
- this.fill.style.background = r <= this.lowThreshold ? this.lowColor : this.color;
2811
+ /** The `spatial` option for the SFX path, or undefined when not spatial. */
2812
+ spatialPlay() {
2813
+ if (!this.spatial || !this._spatialPose) return void 0;
2814
+ return {
2815
+ position: this._spatialPose.position,
2816
+ listener: this._spatialPose.listener,
2817
+ refDistance: this.refDistance,
2818
+ maxDistance: this.maxDistance,
2819
+ rolloff: this.rolloff
2820
+ };
2742
2821
  }
2743
- };
2744
- /**
2745
- * Center-screen announcements ("WAVE 2", "YOU DIED", "LEVEL UP") with fade
2746
- * in/out and a queue — call `.show(text, { color, seconds })`; `sticky:true`
2747
- * (seconds: 0) keeps it until the next show(). Emits `bannerShown(text)`.
2748
- */
2749
- var UiBanner = class extends HudWidgetBase {
2750
- static typeName = "UiBanner";
2751
- static signals = ["bannerShown"];
2752
- static props = {
2753
- ...HudWidgetBase.props,
2754
- anchor: {
2755
- default: "center",
2756
- options: [
2757
- "topLeft",
2758
- "top",
2759
- "topRight",
2760
- "left",
2761
- "center",
2762
- "right",
2763
- "bottomLeft",
2764
- "bottom",
2765
- "bottomRight"
2766
- ]
2767
- },
2768
- size: { default: 42 },
2769
- seconds: { default: 2 }
2770
- };
2771
- anchor = "center";
2772
- size = 42;
2773
- /** Default display time (per-show override via options). */
2774
- seconds = 2;
2775
- queue = [];
2776
- current = null;
2777
- remaining = 0;
2778
- /** What the banner is showing right now ('' when idle) — test-friendly. */
2779
- get showing() {
2780
- return this.current?.text ?? "";
2822
+ play() {
2823
+ if (this.preset !== "custom") {
2824
+ this.note("preset", this.preset);
2825
+ this.playPreset();
2826
+ return;
2827
+ }
2828
+ if (!this.src) return;
2829
+ this.note("src", this.src);
2830
+ if (!this.element) {
2831
+ this.element = createElement();
2832
+ if (!this.element) return;
2833
+ this.element.addEventListener("ended", () => {
2834
+ if (this.loop) return;
2835
+ this._playing = false;
2836
+ this.emit("finished");
2837
+ });
2838
+ this.element.addEventListener("error", () => {
2839
+ this._playing = false;
2840
+ if (this.loadError) return;
2841
+ this.loadError = `could not load ${this.src}`;
2842
+ diagnose(this.tree?.engine ?? null, "error", `[incanto] AudioPlayer '${this.name}' could not load '${this.src}' — it is silent.`);
2843
+ });
2844
+ }
2845
+ const el = this.element;
2846
+ el.src = this.src;
2847
+ el.volume = this.gain() * this.spatialElementGain();
2848
+ el.loop = this.loop;
2849
+ this._playing = true;
2850
+ this.pendingGesture = false;
2851
+ el.play().catch((error) => {
2852
+ this._playing = false;
2853
+ const name = error?.name ?? "";
2854
+ if (name !== "NotSupportedError") {
2855
+ this.pendingGesture = true;
2856
+ return;
2857
+ }
2858
+ this.loadError = `${name}: ${this.src}`;
2859
+ diagnose(this.tree?.engine ?? null, "error", `[incanto] AudioPlayer '${this.name}' cannot play '${this.src}' (${name}) — it is silent.`);
2860
+ });
2781
2861
  }
2782
- show(text, opts) {
2783
- this.queue.push({
2784
- text,
2785
- color: opts?.color ?? "#ffffff",
2786
- seconds: opts?.seconds ?? this.seconds
2862
+ /**
2863
+ * Procedural-SFX path: synthesize the preset and fire it through WebAudio
2864
+ * (low-latency, overlap-friendly). Fire-and-forget — no `finished`/`playing`
2865
+ * tracking (these are short one-shots, not the element-driven `src` clip).
2866
+ * Headless (no AudioContext) → silent no-op. WebAudio also needs a gesture;
2867
+ * a suspended context is resumed by the same unlock listener as `src` players.
2868
+ */
2869
+ playPreset() {
2870
+ const params = SFX_PRESETS[this.preset];
2871
+ if (!params) return;
2872
+ const sfx = this.tree?.engine?.sfx;
2873
+ if (!sfx) return;
2874
+ sfx.play(params, this.gain(), {
2875
+ pitch: this.pitch,
2876
+ seed: this.seed,
2877
+ spatial: this.spatialPlay()
2787
2878
  });
2788
2879
  }
2789
- /** Drop everything (scene transitions). */
2790
- clear() {
2791
- this.queue.length = 0;
2792
- this.current = null;
2793
- this.remaining = 0;
2794
- if (this._element) this._element.style.opacity = "0";
2880
+ /**
2881
+ * A destroyed node goes SILENT.
2882
+ *
2883
+ * Nothing used to stop a sound on teardown, so a level's looping background
2884
+ * clip kept playing over the level after it — the node was freed, so nobody
2885
+ * had a handle to stop it with either. Being MOVED is deliberately not this:
2886
+ * reparenting a node is not destroying it, and a sound that survives the move
2887
+ * is what a scene graph should do.
2888
+ */
2889
+ free() {
2890
+ this.stop();
2891
+ super.free();
2795
2892
  }
2796
- _build() {
2797
- const el = document.createElement("div");
2798
- 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;`;
2799
- return el;
2893
+ stop() {
2894
+ if (this.element) {
2895
+ this.element.pause();
2896
+ this.element.currentTime = 0;
2897
+ }
2898
+ this._playing = false;
2899
+ this.pendingGesture = false;
2800
2900
  }
2801
- update(dt) {
2802
- super.update(dt);
2803
- if (this.current) {
2804
- if (this.current.seconds > 0) {
2805
- this.remaining -= dt;
2806
- if (this.remaining <= 0) {
2807
- this.current = null;
2808
- if (this._element) this._element.style.opacity = "0";
2809
- }
2810
- } else if (this.queue.length > 0) this.current = null;
2901
+ /** Replay a gesture-blocked play (wired to the first user gesture). */
2902
+ retryPending() {
2903
+ if (this.pendingGesture) this.play();
2904
+ }
2905
+ update(_dt) {
2906
+ if (this.autoplay && !this.autoplayed) {
2907
+ this.autoplayed = true;
2908
+ this.play();
2811
2909
  }
2812
- if (!this.current && this.queue.length > 0) {
2813
- this.current = this.queue.shift();
2814
- this.current.text = this._t(this.current.text);
2815
- this.remaining = this.current.seconds;
2816
- if (this._element) {
2817
- this._element.textContent = this.current.text;
2818
- this._element.style.color = this.current.color;
2819
- this._element.style.opacity = "1";
2820
- }
2821
- this.emit("bannerShown", this.current.text);
2910
+ if (this.element && this._playing) {
2911
+ this.element.volume = this.gain() * this.spatialElementGain();
2912
+ this.element.loop = this.loop;
2822
2913
  }
2823
2914
  }
2824
2915
  };
@@ -2862,7 +2953,8 @@ var UiButton = class extends HudWidgetBase {
2862
2953
  _build() {
2863
2954
  const el = document.createElement("button");
2864
2955
  el.type = "button";
2865
- el.style.cssText = `pointer-events:auto;cursor:pointer;font:600 ${this.size}px system-ui,sans-serif;color:${this.color};background:${this.background};border:1px solid rgba(255,255,255,0.25);border-radius:8px;padding:8px 18px;transition:transform .08s, background .15s;`;
2956
+ el.style.cssText = this.look();
2957
+ this.lastLook = this.look();
2866
2958
  el.addEventListener("mouseenter", () => {
2867
2959
  if (!this.disabled) el.style.background = "rgba(255,255,255,0.24)";
2868
2960
  });
@@ -2878,8 +2970,19 @@ var UiButton = class extends HudWidgetBase {
2878
2970
  el.addEventListener("click", () => this.press());
2879
2971
  return el;
2880
2972
  }
2973
+ /** The whole look, so re-applying is one comparison. `size`/`color` were baked
2974
+ * in at build, and a button could never be restyled afterwards. */
2975
+ look() {
2976
+ return `pointer-events:auto;cursor:pointer;font:600 ${this.size}px system-ui,sans-serif;color:${this.color};background:${this.background};border:1px solid rgba(255,255,255,0.25);border-radius:8px;padding:8px 18px;transition:transform .08s, background .15s;`;
2977
+ }
2978
+ lastLook = "";
2881
2979
  _sync() {
2882
2980
  if (!this._element) return;
2981
+ const look = this.look();
2982
+ if (look !== this.lastLook) {
2983
+ this.lastLook = look;
2984
+ this._element.style.cssText = look;
2985
+ }
2883
2986
  const shown = this._t(this.text);
2884
2987
  if (shown !== this.lastText) {
2885
2988
  this.lastText = shown;
@@ -3274,7 +3377,7 @@ var UiSlider = class extends HudWidgetBase {
3274
3377
  const wrap = document.createElement("div");
3275
3378
  wrap.style.cssText = "display:flex;align-items:center;gap:8px;pointer-events:auto;font:600 13px system-ui,-apple-system,'Segoe UI',sans-serif;color:#fff;";
3276
3379
  const text = document.createElement("span");
3277
- text.textContent = this.label;
3380
+ text.textContent = this._t(this.label);
3278
3381
  const input = document.createElement("input");
3279
3382
  input.type = "range";
3280
3383
  input.min = String(this.min);
@@ -3299,7 +3402,8 @@ var UiSlider = class extends HudWidgetBase {
3299
3402
  this.last = this.value;
3300
3403
  this.input.value = String(this.value);
3301
3404
  }
3302
- if (this.text && this.text.textContent !== this.label) this.text.textContent = this.label;
3405
+ const label = this._t(this.label);
3406
+ if (this.text && this.text.textContent !== label) this.text.textContent = label;
3303
3407
  }
3304
3408
  };
3305
3409
  /** An on/off switch — mute, invert-Y, fullscreen. Emits `changed(boolean)`. */
@@ -3338,17 +3442,26 @@ var UiToggle = class extends HudWidgetBase {
3338
3442
  this.emit("changed", this.value);
3339
3443
  });
3340
3444
  const text = document.createElement("span");
3341
- text.textContent = this.label;
3445
+ text.textContent = this._t(this.label);
3446
+ this.tag = text;
3447
+ this.lastLabel = this._t(this.label);
3342
3448
  wrap.append(input, text);
3343
3449
  this.input = input;
3344
3450
  this.last = this.value;
3345
3451
  return wrap;
3346
3452
  }
3453
+ tag = null;
3454
+ lastLabel = "";
3347
3455
  _sync() {
3348
3456
  if (this.input && this.value !== this.last) {
3349
3457
  this.last = this.value;
3350
3458
  this.input.checked = this.value;
3351
3459
  }
3460
+ const label = this._t(this.label);
3461
+ if (this.tag && label !== this.lastLabel) {
3462
+ this.lastLabel = label;
3463
+ this.tag.textContent = label;
3464
+ }
3352
3465
  }
3353
3466
  };
3354
3467
  /** One of several — quality, resolution, language. Emits `changed(value)`. */
@@ -3781,4 +3894,4 @@ function registerCoreNodes() {
3781
3894
  registerNode(UiMuteToggle);
3782
3895
  }
3783
3896
  //#endregion
3784
- export { BASE_LOCALE as A, SFX_PRESETS as B, qualityCaps as C, suggestQuality as D, readDeviceHints as E, SfxEngine as F, crossfadeGains as G, synthSfx as H, isAudioContextAvailable as I, fadeGain as K, ROLLOFF_MODELS as L, T_PREFIX as M, suggestLocale as N, LogManager as O, translationKey as P, spatialGain as R, Settings as S, qualityRendering as T, MusicManager as U, SFX_PRESET_NAMES as V, WebAudioMusicBackend as W, UiBanner as _, UiMuteToggle as a, AudioPlayer as b, UiRenderScaleSelect as c, UiToggle as d, UiVolumeSlider as f, HudLayer as g, UiDialogue as h, UiLanguageSelect as i, Localization as j, EffectLog as k, UiSelect as l, UiButton 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, UiBar as v, qualityEnvironment as w, Engine as x, UiText as y, spatialPan as z };
3897
+ export { UiText as A, spatialPan as B, suggestQuality as C, HudWidgetBase as D, HudLayer as E, translationKey as F, WebAudioMusicBackend as G, SFX_PRESET_NAMES as H, SfxEngine as I, AudioBuses as J, crossfadeGains as K, isAudioContextAvailable as L, Localization as M, T_PREFIX as N, UiBanner as O, suggestLocale as P, ROLLOFF_MODELS as R, readDeviceHints as S, EffectLog as T, synthSfx as U, SFX_PRESETS as V, MusicManager as W, Engine as _, UiMuteToggle as a, qualityEnvironment as b, UiRenderScaleSelect as c, UiToggle as d, UiVolumeSlider as f, AudioPlayer as g, UiDialogue as h, UiLanguageSelect as i, BASE_LOCALE as j, UiBar as k, UiSelect as l, UiButton as m, UiFrameCapSelect as n, UiPanel as o, Timer as p, fadeGain as q, UiImage as r, UiQualitySelect as s, registerCoreNodes as t, UiSlider as u, Settings as v, LogManager as w, qualityRendering as x, qualityCaps as y, spatialGain as z };