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.
- package/bin/incanto-verify.mjs +9 -2
- package/dist/2d.d.ts +59 -7
- package/dist/2d.js +3 -3
- package/dist/3d.d.ts +34 -4
- package/dist/3d.js +5 -5
- package/dist/{behavior-CyQoSu4n.d.ts → behavior-l08AEbq9.d.ts} +5 -0
- package/dist/{create-game-CqJkfMzm.js → create-game-C5jQYPah.js} +28 -71
- package/dist/{create-game-YvaNWhe6.js → create-game-DpbUrMOQ.js} +146 -7
- package/dist/debug.d.ts +1 -1
- package/dist/{duplicate-MNLMAcbz.js → duplicate-BPLZDZpd.js} +1 -1
- package/dist/{environment-presets-DLHqgNAF.js → environment-presets-CvvQr_bJ.js} +106 -24
- package/dist/{frame-report-njybhZon.js → frame-report-BSMny7oe.js} +1 -1
- package/dist/{frame-report-DZ70IY26.d.ts → frame-report-DCnHFmto.d.ts} +1 -1
- package/dist/{gameplay-C_SPzmUe.js → gameplay-BVphcxmE.js} +89 -13
- package/dist/gameplay.d.ts +26 -1
- package/dist/gameplay.js +1 -1
- package/dist/index.d.ts +45 -5
- package/dist/index.js +8 -9
- package/dist/{loader-DhI1jFW_.d.ts → loader-BbEMTuWg.d.ts} +1 -1
- package/dist/{loader-BTkHYrQn.js → loader-BcrRSjxB.js} +682 -682
- package/dist/net.d.ts +2 -2
- package/dist/net.js +1 -1
- package/dist/{pathfinding-CXGCpRQe.d.ts → pathfinding-mEN4V1CU.d.ts} +1 -1
- package/dist/{physics-2d-CfWAggJ1.js → physics-2d-BLcvEFDR.js} +2 -2
- package/dist/{physics-3d-NzDwWPrF.js → physics-3d-QBrfIT2Y.js} +3 -3
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{register-en63AEZO.js → register-C6ZBFRjd.js} +63 -60
- package/dist/{register-p48lHE2o.js → register-Ch70uByv.js} +1615 -1502
- package/dist/{replay-ePMz26jw.d.ts → replay-Dw6gMlYA.d.ts} +1 -1
- package/dist/{replay-t1pP0gQg.js → replay-s7I2GstT.js} +31 -11
- package/dist/sheet-grid-BT6N_Bjs.js +59 -0
- package/dist/{split-screen-BsdOHbzP.d.ts → split-screen-B0baBwxI.d.ts} +5 -1
- package/dist/{split-screen-CSb_uZ6W.js → split-screen-DLsUrleX.js} +14 -3
- package/dist/{sprite-animation-7qvUxF6Z.js → sprite-animation-C0wXLBZJ.js} +8 -4
- package/dist/{src-51GA-9m-.js → src-CGjmPw65.js} +55 -2
- package/dist/{teardown-C7uVSJvx.js → teardown-Cs113S9F.js} +74 -2
- package/dist/{test-Bu6SPNsV.js → test-it1VekWs.js} +28 -19
- package/dist/test.d.ts +4 -4
- package/dist/test.js +2 -2
- package/dist/{touch-BnMyy9tr.js → touch-DESwnpOc.js} +173 -1
- package/dist/vite.d.ts +1 -1
- package/dist/vite.js +3 -3
- package/editor/assets/{agent8-X0Nesj-k.js → agent8-CGT7r3Mb.js} +1 -1
- package/editor/assets/{debug-TXqu-ftX.js → debug-BxWSIHG3.js} +1 -1
- package/editor/assets/{index-DTdwKKIv.js → index-CV1m-aX5.js} +62 -62
- package/editor/index.html +1 -1
- package/package.json +1 -1
- package/skills/incanto-assets.md +5 -1
- package/skills/incanto-audio.md +7 -1
- package/skills/incanto-gameplay-behaviors.md +2 -1
- package/skills/incanto-hud.md +6 -0
- package/skills/incanto-performance.md +5 -0
- package/skills/incanto-physics-and-input.md +14 -0
- package/skills/incanto-save-slots.md +2 -1
- package/skills/incanto-verifying-your-game.md +4 -0
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/village-quest-3d/package.json +1 -1
- package/dist/log-report-CPFm4OXf.js +0 -173
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as diagnose, M as Signal, S as
|
|
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/
|
|
1160
|
-
const
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
-
|
|
1186
|
-
|
|
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
|
-
*
|
|
1189
|
-
*
|
|
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
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
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
|
-
/**
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1252
|
+
onExitTree() {
|
|
1253
|
+
this._element?.remove();
|
|
1254
|
+
this._element = null;
|
|
1255
|
+
this.slots.clear();
|
|
1213
1256
|
}
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
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
|
-
/**
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
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
|
-
|
|
1225
|
-
|
|
1226
|
-
const
|
|
1227
|
-
|
|
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
|
|
1234
|
-
*
|
|
1235
|
-
*
|
|
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
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
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
|
-
|
|
1253
|
-
|
|
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
|
-
|
|
1256
|
-
this.
|
|
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
|
-
*
|
|
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
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
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
|
-
/**
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
this.
|
|
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
|
-
*
|
|
1433
|
+
* @internal Build and attach, once a host exists.
|
|
1427
1434
|
*
|
|
1428
|
-
*
|
|
1429
|
-
*
|
|
1430
|
-
*
|
|
1431
|
-
*
|
|
1432
|
-
*
|
|
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
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
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
|
-
*
|
|
1451
|
+
* Drag and drop, on the DOM the engine already builds.
|
|
1442
1452
|
*
|
|
1443
|
-
*
|
|
1444
|
-
*
|
|
1445
|
-
*
|
|
1446
|
-
*
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
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
|
-
*
|
|
1510
|
+
* Move to the slot the anchor now names.
|
|
1456
1511
|
*
|
|
1457
|
-
*
|
|
1458
|
-
*
|
|
1459
|
-
*
|
|
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
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
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
|
-
*
|
|
1528
|
+
* Resolve a text prop through the engine's locale.
|
|
1469
1529
|
*
|
|
1470
|
-
*
|
|
1471
|
-
*
|
|
1472
|
-
*
|
|
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
|
-
|
|
1475
|
-
locale
|
|
1476
|
-
return locale.
|
|
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
|
-
*
|
|
1482
|
-
* whenever anything changes them.
|
|
1553
|
+
* A template with a `{}` slot that `setText` fills — `"Gems {} / 8"`.
|
|
1483
1554
|
*
|
|
1484
|
-
*
|
|
1485
|
-
*
|
|
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
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
1559
|
-
|
|
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
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
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
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
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
|
-
*
|
|
1657
|
+
* Drive the bar from a signal — `healthChanged → setValue`.
|
|
1661
1658
|
*
|
|
1662
|
-
*
|
|
1663
|
-
*
|
|
1664
|
-
*
|
|
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
|
-
|
|
1702
|
-
|
|
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
|
-
/**
|
|
1705
|
-
|
|
1706
|
-
|
|
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
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
this.
|
|
1715
|
-
|
|
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
|
-
|
|
1718
|
-
|
|
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
|
-
|
|
1721
|
-
|
|
1722
|
-
if (
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
this.
|
|
1728
|
-
this.
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
this.
|
|
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
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
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
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
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 (
|
|
1779
|
-
this.
|
|
1780
|
-
|
|
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
|
-
|
|
1783
|
-
|
|
1784
|
-
this.
|
|
1785
|
-
|
|
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
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
if (
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
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
|
-
|
|
1806
|
-
|
|
1807
|
-
this.errorCount += 1;
|
|
1909
|
+
error(...parts) {
|
|
1910
|
+
this.push("error", parts);
|
|
1808
1911
|
}
|
|
1809
|
-
|
|
1810
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1842
|
-
|
|
1843
|
-
const
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
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
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
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.
|
|
1870
|
-
|
|
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
|
-
*
|
|
2064
|
+
* Choose a starting tier from the device, ONCE.
|
|
1874
2065
|
*
|
|
1875
|
-
*
|
|
1876
|
-
*
|
|
1877
|
-
*
|
|
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
|
-
|
|
1881
|
-
this.
|
|
2070
|
+
autoQuality(hints = readDeviceHints()) {
|
|
2071
|
+
if (!this.values.qualityChosen) this.values.quality = suggestQuality(hints);
|
|
2072
|
+
return this.values.quality;
|
|
1882
2073
|
}
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
this.
|
|
1886
|
-
this.
|
|
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
|
-
*
|
|
1893
|
-
*
|
|
1894
|
-
*
|
|
1895
|
-
*
|
|
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
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
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
|
-
*
|
|
1909
|
-
*
|
|
1910
|
-
*
|
|
1911
|
-
*
|
|
1912
|
-
*
|
|
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
|
-
|
|
1916
|
-
|
|
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
|
-
*
|
|
1920
|
-
*
|
|
1921
|
-
*
|
|
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
|
-
|
|
1924
|
-
this.
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
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
|
-
*
|
|
1938
|
-
*
|
|
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
|
-
*
|
|
1944
|
-
*
|
|
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
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2016
|
-
*
|
|
2017
|
-
*
|
|
2018
|
-
*
|
|
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
|
-
*
|
|
2024
|
-
*
|
|
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
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
},
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
}
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
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
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
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
|
-
*
|
|
2069
|
-
*
|
|
2070
|
-
*
|
|
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
|
-
|
|
2074
|
-
/**
|
|
2075
|
-
|
|
2076
|
-
/**
|
|
2077
|
-
|
|
2078
|
-
/**
|
|
2079
|
-
|
|
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
|
-
*
|
|
2100
|
-
*
|
|
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
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
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
|
-
*
|
|
2113
|
-
*
|
|
2114
|
-
*
|
|
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
|
-
|
|
2117
|
-
this._spatialPose = pose;
|
|
2118
|
-
}
|
|
2287
|
+
effects = new EffectLog();
|
|
2119
2288
|
/**
|
|
2120
|
-
*
|
|
2289
|
+
* Persisted player settings — volume, quality, sensitivity.
|
|
2121
2290
|
*
|
|
2122
|
-
*
|
|
2123
|
-
*
|
|
2124
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
2207
|
-
*
|
|
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
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
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
|
-
*
|
|
2225
|
-
*
|
|
2226
|
-
*
|
|
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
|
-
|
|
2233
|
-
this.
|
|
2234
|
-
super.free();
|
|
2355
|
+
get time() {
|
|
2356
|
+
return this._time;
|
|
2235
2357
|
}
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
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
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
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
|
-
|
|
2249
|
-
|
|
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
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2312
|
-
*
|
|
2313
|
-
*
|
|
2314
|
-
*
|
|
2315
|
-
*
|
|
2316
|
-
*
|
|
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
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
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
|
-
*
|
|
2418
|
+
* Should this display frame run?
|
|
2340
2419
|
*
|
|
2341
|
-
*
|
|
2342
|
-
*
|
|
2343
|
-
*
|
|
2344
|
-
*
|
|
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
|
-
|
|
2347
|
-
const
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
if (
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
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
|
-
*
|
|
2430
|
-
*
|
|
2431
|
-
*
|
|
2432
|
-
*
|
|
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
|
-
|
|
2436
|
-
this.
|
|
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
|
-
|
|
2439
|
-
|
|
2462
|
+
/** @internal A node quarantined itself; count it for `stats().errors`. */
|
|
2463
|
+
_nodeErrored(_node) {
|
|
2464
|
+
this.errorCount += 1;
|
|
2440
2465
|
}
|
|
2441
|
-
|
|
2442
|
-
|
|
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
|
-
*
|
|
2487
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2510
|
-
*
|
|
2511
|
-
*
|
|
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
|
-
|
|
2516
|
-
|
|
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
|
-
*
|
|
2485
|
+
* Every behavior's state, ready to put in a save slot.
|
|
2526
2486
|
*
|
|
2527
|
-
*
|
|
2528
|
-
*
|
|
2529
|
-
*
|
|
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
|
-
|
|
2535
|
-
|
|
2536
|
-
if (
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
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
|
-
/**
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
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
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
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
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
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.
|
|
2578
|
-
|
|
2526
|
+
this.lastFrameError = null;
|
|
2527
|
+
return nodes.length;
|
|
2579
2528
|
}
|
|
2580
|
-
_sync() {}
|
|
2581
2529
|
/**
|
|
2582
|
-
*
|
|
2530
|
+
* How long the last render took, in ms — called by whoever renders.
|
|
2583
2531
|
*
|
|
2584
|
-
*
|
|
2585
|
-
*
|
|
2586
|
-
*
|
|
2587
|
-
*
|
|
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
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
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
|
-
*
|
|
2608
|
-
*
|
|
2609
|
-
*
|
|
2610
|
-
*
|
|
2611
|
-
*
|
|
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
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
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
|
-
*
|
|
2622
|
-
*
|
|
2623
|
-
*
|
|
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
|
-
|
|
2628
|
-
this.
|
|
2629
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2635
|
-
*
|
|
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
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
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
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
this.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2660
|
-
*
|
|
2661
|
-
*
|
|
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
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
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
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
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
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
/**
|
|
2696
|
-
|
|
2697
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2703
|
-
*
|
|
2704
|
-
*
|
|
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
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
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
|
-
/**
|
|
2711
|
-
|
|
2712
|
-
|
|
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
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
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
|
-
|
|
2732
|
-
|
|
2733
|
-
if (
|
|
2734
|
-
|
|
2735
|
-
this.
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
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
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
"
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
"
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
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
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
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
|
-
/**
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
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
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
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
|
-
|
|
2802
|
-
|
|
2803
|
-
if (this.
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
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 (
|
|
2813
|
-
this.
|
|
2814
|
-
this.
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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 };
|