incanto 0.33.0 → 0.35.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/dist/2d.d.ts +5 -5
- package/dist/2d.js +3 -3
- package/dist/3d.d.ts +122 -14
- package/dist/3d.js +5 -5
- package/dist/{audit-C4kmDK0o.js → audit-C5oZGAru.js} +37 -0
- package/dist/{behavior-CKwTCjfR.d.ts → behavior-CGSCWOHB.d.ts} +319 -22
- package/dist/{create-game-D2QU5x7S.js → create-game-B0Wfhi2-.js} +6 -5
- package/dist/{create-game-sFuTLqjD.js → create-game-BUD89Kqh.js} +20 -6
- package/dist/debug.d.ts +1 -1
- package/dist/{duplicate-BgnG1Lqz.js → duplicate-C716f-97.js} +1 -1
- package/dist/{editor-switch-BJb-CWfA.d.ts → editor-switch-DyXEtH36.d.ts} +1 -1
- package/dist/editor.js +1739 -1312
- package/dist/env.d.ts +1 -1
- package/dist/{environment-presets-CQtEGogB.js → environment-presets-TAGvmM3z.js} +240 -74
- package/dist/{errors-1dXlIwoR.d.ts → errors-BY2kL0hv.d.ts} +1 -1
- package/dist/{gameplay-BpQCbABv.js → gameplay-BvhcQbfJ.js} +75 -11
- package/dist/gameplay.d.ts +12 -5
- package/dist/gameplay.js +1 -1
- package/dist/index.d.ts +72 -7
- package/dist/index.js +6 -6
- package/dist/{loader-COn5fS0o.d.ts → loader-CUcj00M8.d.ts} +19 -2
- package/dist/{loader-Buk8Bu1h.js → loader-Mig5fY4n.js} +110 -18
- package/dist/net.d.ts +2 -2
- package/dist/net.js +3 -3
- package/dist/{particle-sim-CwJ5rI_P.d.ts → particle-sim-B-vZBF5R.d.ts} +1 -1
- package/dist/{pathfinding-DUw9mir9.d.ts → pathfinding-DgOo2KNF.d.ts} +1 -1
- package/dist/{physics-2d-DjXR5DMu.js → physics-2d-DqAclql-.js} +22 -9
- package/dist/{physics-3d-DF8npb1O.js → physics-3d-DxBH4sIF.js} +27 -11
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/{register-CscIzJEO.js → register-BFLg0-_i.js} +697 -21
- package/dist/{register-CtI-itec.js → register-BjbPMA5B.js} +2 -2
- package/dist/{register-FIJtNbub.js → register-DSmIRAf7.js} +11 -5
- package/dist/{schema-CcoWb32N.d.ts → schema-3ywbdlrv.d.ts} +10 -0
- package/dist/{test-BRxLd2jH.js → test-9EokzbRd.js} +10 -10
- package/dist/test.d.ts +4 -4
- package/dist/test.js +2 -2
- package/dist/vite.js +1 -1
- package/editor/assets/{agent8-BdDP3xKW.js → agent8-D3_GWeuh.js} +1 -1
- package/editor/assets/{debug-DbjTyTlC.js → debug-CX0LDd1r.js} +1 -1
- package/editor/assets/index-CMlKFT0C.js +10773 -0
- package/editor/index.html +1 -1
- package/package.json +1 -1
- package/schemas/scene.schema.json +251 -6
- package/skills/incanto-assets.md +21 -0
- package/skills/incanto-behaviors-and-scripts.md +16 -0
- package/skills/incanto-editor.md +17 -1
- package/skills/incanto-gameplay-behaviors.md +6 -0
- package/skills/incanto-hud.md +73 -1
- package/skills/incanto-localization.md +132 -0
- package/skills/incanto-node-reference.md +78 -24
- package/skills/incanto-save-slots.md +152 -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/PROJECT/Context.md +16 -0
- package/templates-app/village-quest-3d/package.json +1 -1
- package/templates-app/village-quest-3d/src/village.scene.json +25 -3
- package/editor/assets/index-BWCudoz1.js +0 -10696
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { f as Node, h as
|
|
1
|
+
import { C as Signal, _ as behaviorSchema, f as Node, h as diagnose, m as InputMap } from "./loader-Mig5fY4n.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-CJdGpT2V.js";
|
|
@@ -924,6 +924,120 @@ var SfxEngine = class {
|
|
|
924
924
|
}
|
|
925
925
|
};
|
|
926
926
|
//#endregion
|
|
927
|
+
//#region src/core/localization.ts
|
|
928
|
+
/** The base locale. Every other locale falls back to it, one key at a time. */
|
|
929
|
+
const BASE_LOCALE = "en";
|
|
930
|
+
/**
|
|
931
|
+
* The marker that makes a string prop translatable: `"@t:hud.score"`.
|
|
932
|
+
*
|
|
933
|
+
* A prefix rather than an object wrapper, matching the `"$assetKey"` reference
|
|
934
|
+
* already in the engine — the prop is declared a string, so its value should
|
|
935
|
+
* stay a string. It also stays authorable by hand:
|
|
936
|
+
*
|
|
937
|
+
* ```json
|
|
938
|
+
* { "type": "UiText", "props": { "text": "@t:hud.score" } }
|
|
939
|
+
* ```
|
|
940
|
+
*/
|
|
941
|
+
const T_PREFIX = "@t:";
|
|
942
|
+
/** The key a `"@t:…"` value names, or null for an ordinary string. */
|
|
943
|
+
function translationKey(value) {
|
|
944
|
+
return value.startsWith("@t:") ? value.slice(3) : null;
|
|
945
|
+
}
|
|
946
|
+
/** `Hello, {name}` + `{name: 'Ada'}` → `Hello, Ada`. Unknown slots stay put. */
|
|
947
|
+
function interpolate(text, params) {
|
|
948
|
+
if (!params) return text;
|
|
949
|
+
return text.replace(/\{(\w+)\}/g, (whole, name) => name in params ? String(params[name]) : whole);
|
|
950
|
+
}
|
|
951
|
+
var Localization = class {
|
|
952
|
+
/** Fires with the new locale after it changes — for anything that caches. */
|
|
953
|
+
changed = new Signal();
|
|
954
|
+
tables = {};
|
|
955
|
+
current = "en";
|
|
956
|
+
constructor(tables, locale) {
|
|
957
|
+
if (tables) this.load(tables);
|
|
958
|
+
if (locale) this.locale = locale;
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Merge a scene's `strings` table in.
|
|
962
|
+
*
|
|
963
|
+
* Merged rather than replaced: a game's shared UI strings live in the scene it
|
|
964
|
+
* boots from, and a level that adds three lines of its own should not wipe
|
|
965
|
+
* them. Per-key, so a level CAN override one string.
|
|
966
|
+
*/
|
|
967
|
+
load(tables) {
|
|
968
|
+
for (const [locale, entries] of Object.entries(tables)) {
|
|
969
|
+
this.tables[locale] ??= {};
|
|
970
|
+
const target = this.tables[locale];
|
|
971
|
+
for (const [key, text] of Object.entries(entries)) if (typeof text === "string") target[key] = text;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
get locale() {
|
|
975
|
+
return this.current;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Switch language. Setting one nobody declared is not an error — it simply
|
|
979
|
+
* resolves everything through the base locale, which is the correct outcome
|
|
980
|
+
* for a player whose browser reports a language this game does not ship.
|
|
981
|
+
*/
|
|
982
|
+
set locale(next) {
|
|
983
|
+
if (next === this.current || next === "") return;
|
|
984
|
+
this.current = next;
|
|
985
|
+
this.changed.emit(next);
|
|
986
|
+
}
|
|
987
|
+
/** Every locale with at least one string — what a language picker offers. */
|
|
988
|
+
available() {
|
|
989
|
+
const out = new Set(["en"]);
|
|
990
|
+
for (const [locale, entries] of Object.entries(this.tables)) if (Object.keys(entries).length > 0) out.add(locale);
|
|
991
|
+
return [...out];
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* The string for `key`: current locale, else English, else the key itself.
|
|
995
|
+
*
|
|
996
|
+
* Returning the key rather than an empty string is deliberate — a screen
|
|
997
|
+
* reading `menu.credits` tells an author exactly what to add, where a blank
|
|
998
|
+
* one tells them nothing and looks like a rendering bug.
|
|
999
|
+
*/
|
|
1000
|
+
t(key, params) {
|
|
1001
|
+
const own = this.tables[this.current]?.[key];
|
|
1002
|
+
if (own !== void 0) return interpolate(own, params);
|
|
1003
|
+
const base = this.tables["en"]?.[key];
|
|
1004
|
+
if (base !== void 0) return interpolate(base, params);
|
|
1005
|
+
return key;
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Resolve a prop value: `"@t:key"` translates, anything else passes through.
|
|
1009
|
+
*
|
|
1010
|
+
* This is what every text-bearing widget calls, every frame. A plain string is
|
|
1011
|
+
* returned untouched, so a game that never localizes anything pays a
|
|
1012
|
+
* `startsWith` per widget per frame and nothing else.
|
|
1013
|
+
*/
|
|
1014
|
+
resolve(value, params) {
|
|
1015
|
+
const key = translationKey(value);
|
|
1016
|
+
return key === null ? value : this.t(key, params);
|
|
1017
|
+
}
|
|
1018
|
+
/** Whether any locale declares this key — for the audit, not for the runtime. */
|
|
1019
|
+
has(key) {
|
|
1020
|
+
for (const entries of Object.values(this.tables)) if (entries[key] !== void 0) return true;
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
};
|
|
1024
|
+
/**
|
|
1025
|
+
* The player's language, if the browser will say and the game ships it.
|
|
1026
|
+
*
|
|
1027
|
+
* A starting point only — the language setting is the real answer, exactly as
|
|
1028
|
+
* `suggestQuality` is a starting point for the quality tier.
|
|
1029
|
+
*/
|
|
1030
|
+
function suggestLocale(available, languages) {
|
|
1031
|
+
const nav = globalThis.navigator;
|
|
1032
|
+
const wanted = languages ?? nav?.languages ?? [];
|
|
1033
|
+
for (const tag of wanted) {
|
|
1034
|
+
const base = tag.split("-")[0] ?? "";
|
|
1035
|
+
const hit = available.find((a) => a === tag || a === base);
|
|
1036
|
+
if (hit) return hit;
|
|
1037
|
+
}
|
|
1038
|
+
return "en";
|
|
1039
|
+
}
|
|
1040
|
+
//#endregion
|
|
927
1041
|
//#region src/core/frame-stats.ts
|
|
928
1042
|
/**
|
|
929
1043
|
* Rolling window of REAL frame timestamps — the math behind `engine.stats()`
|
|
@@ -1071,6 +1185,214 @@ function storageOrNull() {
|
|
|
1071
1185
|
}
|
|
1072
1186
|
}
|
|
1073
1187
|
//#endregion
|
|
1188
|
+
//#region src/core/save-slots.ts
|
|
1189
|
+
/**
|
|
1190
|
+
* Save slots — "continue where you left off", which no game on this engine could
|
|
1191
|
+
* do.
|
|
1192
|
+
*
|
|
1193
|
+
* `createSaveStore` has been here a long time and `Scene.toJSON()` round-trips a
|
|
1194
|
+
* live tree, so two thirds of the work existed. The missing third was that
|
|
1195
|
+
* BEHAVIOR state had nowhere to go: `Behavior` declared `onEnterTree`, `onReady`,
|
|
1196
|
+
* `onExitTree`, `update` and `fixedUpdate` and nothing for "write yourself down",
|
|
1197
|
+
* so `Health.current`, `ScoreKeeper.score` and every quest flag were
|
|
1198
|
+
* unrecoverable. Both quest templates this repo ships were RPGs you could not
|
|
1199
|
+
* resume.
|
|
1200
|
+
*
|
|
1201
|
+
* ## What a save is, and what it deliberately is NOT
|
|
1202
|
+
*
|
|
1203
|
+
* A save is **which scene, plus every behavior's state, keyed by node uid**. It
|
|
1204
|
+
* is not a snapshot of the live tree.
|
|
1205
|
+
*
|
|
1206
|
+
* That is a design decision, not a shortcut. Restoring a live tree by walking
|
|
1207
|
+
* paths breaks on this engine specifically:
|
|
1208
|
+
*
|
|
1209
|
+
* - `Spawner.onReady` DETACHES its prefab template from the tree (see
|
|
1210
|
+
* `SpawnSource.resolveTemplate`), so a captured tree and a freshly booted one
|
|
1211
|
+
* legitimately disagree about which nodes exist, every time.
|
|
1212
|
+
* Every scene with a spawner would report phantom removals.
|
|
1213
|
+
* - A spawned enemy carrying its own spawner has already lost ITS template, so
|
|
1214
|
+
* re-adding that subtree runs `onReady` against a node that is not there and
|
|
1215
|
+
* throws — and `onReady` is not quarantined, so it kills the whole load.
|
|
1216
|
+
* - Prop deltas cannot be applied with `applySchemaProps`, which resets every
|
|
1217
|
+
* prop to its default before writing.
|
|
1218
|
+
*
|
|
1219
|
+
* Reloading the scene from source sidesteps all three: the structure comes from
|
|
1220
|
+
* the file (which is authoritative and already validated), and the save carries
|
|
1221
|
+
* only what the file cannot know. A fresh boot detaches the same templates the
|
|
1222
|
+
* captured one did, so there is nothing to reconcile.
|
|
1223
|
+
*
|
|
1224
|
+
* The cost, stated plainly so nobody discovers it: **spawned enemies and
|
|
1225
|
+
* mid-level positions are not restored.** You resume at the scene's start with
|
|
1226
|
+
* your stats, inventory, unlocks and quest flags intact. That is what a
|
|
1227
|
+
* checkpoint save is, and it is what both quest templates need. A game that
|
|
1228
|
+
* wants a position saves it — `serialize()` can return anything.
|
|
1229
|
+
*
|
|
1230
|
+
* Node uids are the join key because they are the one identifier the engine
|
|
1231
|
+
* already promises survives a rename or a reparent.
|
|
1232
|
+
*/
|
|
1233
|
+
/**
|
|
1234
|
+
* Every behavior in the tree that has something to say, by uid.
|
|
1235
|
+
*
|
|
1236
|
+
* A behavior with no `serialize` contributes nothing — the hook is optional in
|
|
1237
|
+
* exactly the way the other five are, so adding save support to a game is
|
|
1238
|
+
* additive and a game that never saves pays nothing.
|
|
1239
|
+
*
|
|
1240
|
+
* A node with no `uid` is skipped and reported, because state keyed by nothing
|
|
1241
|
+
* cannot be given back. The editor assigns a uid to every node it touches; a
|
|
1242
|
+
* hand-written scene may not have.
|
|
1243
|
+
*/
|
|
1244
|
+
function captureBehaviors(root) {
|
|
1245
|
+
const state = {};
|
|
1246
|
+
const unaddressable = [];
|
|
1247
|
+
const walk = (node) => {
|
|
1248
|
+
const behavior = node.behavior;
|
|
1249
|
+
if (behavior && typeof behavior.serialize === "function") {
|
|
1250
|
+
const value = behavior.serialize();
|
|
1251
|
+
if (value !== void 0) if (node.uid) state[node.uid] = value;
|
|
1252
|
+
else unaddressable.push(node.getPath());
|
|
1253
|
+
}
|
|
1254
|
+
for (const child of node.children) walk(child);
|
|
1255
|
+
};
|
|
1256
|
+
walk(root);
|
|
1257
|
+
return {
|
|
1258
|
+
state,
|
|
1259
|
+
unaddressable
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Hand each behavior its state back.
|
|
1264
|
+
*
|
|
1265
|
+
* Call this AFTER the scene has loaded and `onReady` has run: `onReady` is where
|
|
1266
|
+
* a behavior sets its starting values, so restoring first would be overwritten
|
|
1267
|
+
* by the fresh start.
|
|
1268
|
+
*
|
|
1269
|
+
* Never throws on a mismatch. A save from an older build of the game will name
|
|
1270
|
+
* uids that no longer exist, and refusing to load it would mean a patch that
|
|
1271
|
+
* moves one node deletes everyone's progress. It reports instead.
|
|
1272
|
+
*/
|
|
1273
|
+
function restoreBehaviors(root, state) {
|
|
1274
|
+
const report = {
|
|
1275
|
+
missing: [],
|
|
1276
|
+
skipped: [],
|
|
1277
|
+
restored: 0
|
|
1278
|
+
};
|
|
1279
|
+
for (const [uid, value] of Object.entries(state)) {
|
|
1280
|
+
const node = root.getNodeByUid(uid);
|
|
1281
|
+
if (!node) {
|
|
1282
|
+
report.missing.push(uid);
|
|
1283
|
+
continue;
|
|
1284
|
+
}
|
|
1285
|
+
const behavior = node.behavior;
|
|
1286
|
+
if (!behavior || typeof behavior.deserialize !== "function") {
|
|
1287
|
+
report.skipped.push(uid);
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
try {
|
|
1291
|
+
behavior.deserialize(value);
|
|
1292
|
+
report.restored += 1;
|
|
1293
|
+
} catch {
|
|
1294
|
+
report.skipped.push(uid);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
return report;
|
|
1298
|
+
}
|
|
1299
|
+
/** Slot ids, so a "3 save slots" menu is a list and not a naming convention. */
|
|
1300
|
+
const INDEX_KEY = "slots";
|
|
1301
|
+
/**
|
|
1302
|
+
* The slot layer: a named list of saves over the existing store.
|
|
1303
|
+
*
|
|
1304
|
+
* Slot ids are yours (`'auto'`, `'1'`, `'2'`, `'3'`) — the engine does not
|
|
1305
|
+
* decide how many you have or whether one of them autosaves.
|
|
1306
|
+
*/
|
|
1307
|
+
var SaveSlots = class {
|
|
1308
|
+
store;
|
|
1309
|
+
constructor(namespace = "saves", store) {
|
|
1310
|
+
this.store = store ?? createSaveStore(namespace);
|
|
1311
|
+
}
|
|
1312
|
+
/** Slot ids that have a save, newest first. */
|
|
1313
|
+
list() {
|
|
1314
|
+
const rows = this.store.get(INDEX_KEY, []).map((id) => ({
|
|
1315
|
+
id,
|
|
1316
|
+
slot: this.read(id)
|
|
1317
|
+
})).filter((row) => row.slot !== null);
|
|
1318
|
+
rows.sort((a, b) => b.slot.savedAt - a.slot.savedAt);
|
|
1319
|
+
return rows.map((row) => row.id);
|
|
1320
|
+
}
|
|
1321
|
+
/** Every slot with its contents — what a load menu actually renders. */
|
|
1322
|
+
all() {
|
|
1323
|
+
return this.list().map((id) => ({
|
|
1324
|
+
id,
|
|
1325
|
+
...this.read(id)
|
|
1326
|
+
}));
|
|
1327
|
+
}
|
|
1328
|
+
read(id) {
|
|
1329
|
+
const raw = this.store.get(key(id), null);
|
|
1330
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
1331
|
+
const slot = raw;
|
|
1332
|
+
if (typeof slot.scene !== "string" || typeof slot.state !== "object" || slot.state === null) return null;
|
|
1333
|
+
return {
|
|
1334
|
+
scene: slot.scene,
|
|
1335
|
+
state: slot.state,
|
|
1336
|
+
label: typeof slot.label === "string" ? slot.label : "",
|
|
1337
|
+
savedAt: typeof slot.savedAt === "number" ? slot.savedAt : 0,
|
|
1338
|
+
playtime: typeof slot.playtime === "number" ? slot.playtime : 0,
|
|
1339
|
+
data: slot.data ?? {}
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* Write a slot.
|
|
1344
|
+
*
|
|
1345
|
+
* `savedAt` is a parameter rather than `Date.now()` so a test can assert an
|
|
1346
|
+
* ordering without sleeping, and so a game that wants server time can use it.
|
|
1347
|
+
*/
|
|
1348
|
+
write(id, slot) {
|
|
1349
|
+
const full = {
|
|
1350
|
+
scene: slot.scene,
|
|
1351
|
+
state: slot.state,
|
|
1352
|
+
label: slot.label ?? "",
|
|
1353
|
+
savedAt: slot.savedAt ?? 0,
|
|
1354
|
+
playtime: slot.playtime ?? 0,
|
|
1355
|
+
data: slot.data ?? {}
|
|
1356
|
+
};
|
|
1357
|
+
this.store.set(key(id), full);
|
|
1358
|
+
const ids = this.store.get(INDEX_KEY, []);
|
|
1359
|
+
if (!ids.includes(id)) this.store.set(INDEX_KEY, [...ids, id]);
|
|
1360
|
+
}
|
|
1361
|
+
remove(id) {
|
|
1362
|
+
this.store.remove(key(id));
|
|
1363
|
+
this.store.set(INDEX_KEY, this.store.get(INDEX_KEY, []).filter((other) => other !== id));
|
|
1364
|
+
}
|
|
1365
|
+
/** Wipe every slot — a "delete all data" button, and test isolation. */
|
|
1366
|
+
clear() {
|
|
1367
|
+
for (const id of this.store.get(INDEX_KEY, [])) this.store.remove(key(id));
|
|
1368
|
+
this.store.set(INDEX_KEY, []);
|
|
1369
|
+
}
|
|
1370
|
+
};
|
|
1371
|
+
function key(id) {
|
|
1372
|
+
return `slot:${id}`;
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Behaviors in this tree that hold state and cannot save it — the audit's
|
|
1376
|
+
* question, answerable before a player loses an hour.
|
|
1377
|
+
*
|
|
1378
|
+
* A behavior with props but no `serialize` is not necessarily wrong (an
|
|
1379
|
+
* `Oscillate` derives everything from time), so this reports rather than warns,
|
|
1380
|
+
* and the caller decides.
|
|
1381
|
+
*/
|
|
1382
|
+
function behaviorsWithoutSave(root) {
|
|
1383
|
+
const out = [];
|
|
1384
|
+
const walk = (node) => {
|
|
1385
|
+
const behavior = node.behavior;
|
|
1386
|
+
const name = node.script?.name;
|
|
1387
|
+
if (behavior && typeof name === "string" && typeof behavior.serialize !== "function") {
|
|
1388
|
+
if (Object.keys(behaviorSchema(name)).length > 0) out.push(`${node.getPath()} (${name})`);
|
|
1389
|
+
}
|
|
1390
|
+
for (const child of node.children) walk(child);
|
|
1391
|
+
};
|
|
1392
|
+
walk(root);
|
|
1393
|
+
return out;
|
|
1394
|
+
}
|
|
1395
|
+
//#endregion
|
|
1074
1396
|
//#region src/core/settings.ts
|
|
1075
1397
|
const DEFAULTS = {
|
|
1076
1398
|
master: 1,
|
|
@@ -1080,7 +1402,8 @@ const DEFAULTS = {
|
|
|
1080
1402
|
quality: "high",
|
|
1081
1403
|
sensitivity: 1,
|
|
1082
1404
|
invertY: false,
|
|
1083
|
-
reduceMotion: false
|
|
1405
|
+
reduceMotion: false,
|
|
1406
|
+
language: "en"
|
|
1084
1407
|
};
|
|
1085
1408
|
const TIERS = [
|
|
1086
1409
|
"low",
|
|
@@ -1146,6 +1469,19 @@ var Settings = class {
|
|
|
1146
1469
|
for (const key of Object.keys(DEFAULTS)) this.set(key, DEFAULTS[key]);
|
|
1147
1470
|
}
|
|
1148
1471
|
/**
|
|
1472
|
+
* Apply the saved language, and keep it in step both ways.
|
|
1473
|
+
*
|
|
1474
|
+
* The same shape as `bindAudio`: a game that writes
|
|
1475
|
+
* `engine.locale.locale = 'ko'` from an options menu has already persisted it
|
|
1476
|
+
* without knowing this class exists. Returns the unsubscribe.
|
|
1477
|
+
*/
|
|
1478
|
+
bindLocale(locale) {
|
|
1479
|
+
locale.locale = this.values.language;
|
|
1480
|
+
return locale.changed.connect((next) => {
|
|
1481
|
+
this.set("language", next);
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1149
1485
|
* Push the saved volumes into the engine's buses, and keep writing them back
|
|
1150
1486
|
* whenever anything changes them.
|
|
1151
1487
|
*
|
|
@@ -1267,6 +1603,11 @@ var Engine = class {
|
|
|
1267
1603
|
* `bindAudio` makes the volumes write themselves.
|
|
1268
1604
|
*/
|
|
1269
1605
|
settings = new Settings();
|
|
1606
|
+
/**
|
|
1607
|
+
* Translations, and the locale in force. English is the base and the default;
|
|
1608
|
+
* anything a locale does not declare falls back to it silently.
|
|
1609
|
+
*/
|
|
1610
|
+
locale = new Localization();
|
|
1270
1611
|
/** Low-latency procedural-SFX player (WebAudio, headless-safe). AudioPlayer
|
|
1271
1612
|
* presets play through it; games may call `engine.sfx.play(...)` directly. */
|
|
1272
1613
|
sfx = new SfxEngine();
|
|
@@ -1340,6 +1681,7 @@ var Engine = class {
|
|
|
1340
1681
|
this._time = 0;
|
|
1341
1682
|
this._unscaledTime = 0;
|
|
1342
1683
|
if (scene.input) this.input.declare(scene.input);
|
|
1684
|
+
if (scene.strings) this.locale.load(scene.strings);
|
|
1343
1685
|
this.sceneChanged.emit(scene);
|
|
1344
1686
|
}
|
|
1345
1687
|
start() {
|
|
@@ -1377,6 +1719,41 @@ var Engine = class {
|
|
|
1377
1719
|
* both — so a tool can list them and put them back. Walks on demand; there is
|
|
1378
1720
|
* no per-frame bookkeeping for this.
|
|
1379
1721
|
*/
|
|
1722
|
+
/**
|
|
1723
|
+
* The string for `key` in the current locale — English if this locale does not
|
|
1724
|
+
* declare it, the key itself if nothing does.
|
|
1725
|
+
*
|
|
1726
|
+
* The short form of `engine.locale.t`, because a behavior reaching for a
|
|
1727
|
+
* translated string is the common case:
|
|
1728
|
+
* `banner.show(this.engine.t('wave.start', { n: wave }))`.
|
|
1729
|
+
*/
|
|
1730
|
+
t(key, params) {
|
|
1731
|
+
return this.locale.t(key, params);
|
|
1732
|
+
}
|
|
1733
|
+
/**
|
|
1734
|
+
* Every behavior's state, ready to put in a save slot.
|
|
1735
|
+
*
|
|
1736
|
+
* Pairs with `restoreState` after the scene has RELOADED — see
|
|
1737
|
+
* core/save-slots.ts for why a save carries state rather than a tree
|
|
1738
|
+
* snapshot.
|
|
1739
|
+
*/
|
|
1740
|
+
captureState() {
|
|
1741
|
+
const root = this.scene?.root;
|
|
1742
|
+
if (!root) return {};
|
|
1743
|
+
const { state, unaddressable } = captureBehaviors(root);
|
|
1744
|
+
for (const path of unaddressable) diagnose(this, "warn", `save: ${path} has state to save and no uid to key it under.`);
|
|
1745
|
+
return state;
|
|
1746
|
+
}
|
|
1747
|
+
/** Give it back, after the scene is loaded and onReady has run. */
|
|
1748
|
+
restoreState(state) {
|
|
1749
|
+
const root = this.scene?.root;
|
|
1750
|
+
if (!root) return {
|
|
1751
|
+
missing: Object.keys(state),
|
|
1752
|
+
skipped: [],
|
|
1753
|
+
restored: 0
|
|
1754
|
+
};
|
|
1755
|
+
return restoreBehaviors(root, state);
|
|
1756
|
+
}
|
|
1380
1757
|
erroredNodes() {
|
|
1381
1758
|
const out = [];
|
|
1382
1759
|
const walk = (node) => {
|
|
@@ -1727,10 +2104,17 @@ var HudLayer = class extends Node {
|
|
|
1727
2104
|
static typeName = "HudLayer";
|
|
1728
2105
|
static props = {
|
|
1729
2106
|
zIndex: { default: 100 },
|
|
1730
|
-
visible: { default: true }
|
|
2107
|
+
visible: { default: true },
|
|
2108
|
+
/**
|
|
2109
|
+
* Arrow keys / d-pad move focus between the focusable widgets under this
|
|
2110
|
+
* layer, Enter / A activates. OFF by default — a game whose HUD has a
|
|
2111
|
+
* button must not lose its arrow keys the moment one exists.
|
|
2112
|
+
*/
|
|
2113
|
+
focusNavigation: { default: false }
|
|
1731
2114
|
};
|
|
1732
2115
|
zIndex = 100;
|
|
1733
2116
|
visible = true;
|
|
2117
|
+
focusNavigation = false;
|
|
1734
2118
|
/** @internal root overlay element (null headless). */
|
|
1735
2119
|
_element = null;
|
|
1736
2120
|
slots = /* @__PURE__ */ new Map();
|
|
@@ -1771,6 +2155,66 @@ var HudLayer = class extends Node {
|
|
|
1771
2155
|
this._mount();
|
|
1772
2156
|
this._element.style.display = this.visible ? "" : "none";
|
|
1773
2157
|
}
|
|
2158
|
+
if (this.focusNavigation) this.stepFocus();
|
|
2159
|
+
}
|
|
2160
|
+
/**
|
|
2161
|
+
* Arrow keys / d-pad move the focus, Enter / A activates it.
|
|
2162
|
+
*
|
|
2163
|
+
* Off by default: a game whose HUD has a button must not lose its arrow keys
|
|
2164
|
+
* to a menu the moment one exists. Turn it on for the screens that ARE menus
|
|
2165
|
+
* (`"focusNavigation": true` on the pause panel's layer), and off again when
|
|
2166
|
+
* play resumes.
|
|
2167
|
+
*/
|
|
2168
|
+
stepFocus() {
|
|
2169
|
+
const engine = this.tree?.engine;
|
|
2170
|
+
if (!engine) return;
|
|
2171
|
+
const items = this.focusables();
|
|
2172
|
+
if (items.length === 0) return;
|
|
2173
|
+
const input = engine.input;
|
|
2174
|
+
const down = input.keyJustPressed("ArrowDown") || input.keyJustPressed("Pad13");
|
|
2175
|
+
const up = input.keyJustPressed("ArrowUp") || input.keyJustPressed("Pad12");
|
|
2176
|
+
const left = input.keyJustPressed("ArrowLeft") || input.keyJustPressed("Pad14");
|
|
2177
|
+
const right = input.keyJustPressed("ArrowRight") || input.keyJustPressed("Pad15");
|
|
2178
|
+
const confirm = input.keyJustPressed("Enter") || input.keyJustPressed("Pad0");
|
|
2179
|
+
let index = items.findIndex((w) => w._focused);
|
|
2180
|
+
if (index === -1 && (down || up || confirm)) index = 0;
|
|
2181
|
+
else if (down) index = (index + 1) % items.length;
|
|
2182
|
+
else if (up) index = (index - 1 + items.length) % items.length;
|
|
2183
|
+
if (index !== -1) {
|
|
2184
|
+
for (const w of items) {
|
|
2185
|
+
const next = w === items[index];
|
|
2186
|
+
if (next !== w._focused) {
|
|
2187
|
+
w._focused = next;
|
|
2188
|
+
w._paintFocus();
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
const focused = items[index];
|
|
2192
|
+
if (focused) {
|
|
2193
|
+
if (confirm) focused._activate(0);
|
|
2194
|
+
else if (left) focused._activate(-1);
|
|
2195
|
+
else if (right) focused._activate(1);
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
/** Focusable widgets under this layer, in tree order. */
|
|
2200
|
+
focusables() {
|
|
2201
|
+
const out = [];
|
|
2202
|
+
const walk = (node) => {
|
|
2203
|
+
if (node instanceof HudWidgetBase && node.focusable && node.visible) out.push(node);
|
|
2204
|
+
for (const child of node.children) walk(child);
|
|
2205
|
+
};
|
|
2206
|
+
walk(this);
|
|
2207
|
+
return out;
|
|
2208
|
+
}
|
|
2209
|
+
/** Move focus to a widget by hand — opening a menu should start somewhere. */
|
|
2210
|
+
focus(widget) {
|
|
2211
|
+
for (const w of this.focusables()) {
|
|
2212
|
+
const next = w === widget;
|
|
2213
|
+
if (next !== w._focused) {
|
|
2214
|
+
w._focused = next;
|
|
2215
|
+
w._paintFocus();
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
1774
2218
|
}
|
|
1775
2219
|
/** @internal Widgets mount into per-anchor flex columns. */
|
|
1776
2220
|
_slot(anchor) {
|
|
@@ -1785,6 +2229,22 @@ var HudLayer = class extends Node {
|
|
|
1785
2229
|
return slot;
|
|
1786
2230
|
}
|
|
1787
2231
|
};
|
|
2232
|
+
/**
|
|
2233
|
+
* The widget an element belongs to.
|
|
2234
|
+
*
|
|
2235
|
+
* A pick lands on whatever div is under the cursor — usually a CHILD of the
|
|
2236
|
+
* widget (an icon inside a slot), so the walk upward is the point.
|
|
2237
|
+
*/
|
|
2238
|
+
const widgetOf = /* @__PURE__ */ new WeakMap();
|
|
2239
|
+
function widgetFromElement(el) {
|
|
2240
|
+
for (let n = el; n; n = n.parentElement) {
|
|
2241
|
+
const found = widgetOf.get(n);
|
|
2242
|
+
if (found) return found;
|
|
2243
|
+
}
|
|
2244
|
+
return null;
|
|
2245
|
+
}
|
|
2246
|
+
/** The drag in flight, if any. One pointer, one drag. */
|
|
2247
|
+
let dragging = null;
|
|
1788
2248
|
/** Shared plumbing: mount into the parent HudLayer's anchor slot. */
|
|
1789
2249
|
var HudWidgetBase = class extends Node {
|
|
1790
2250
|
static props = {
|
|
@@ -1802,10 +2262,46 @@ var HudWidgetBase = class extends Node {
|
|
|
1802
2262
|
"bottomRight"
|
|
1803
2263
|
]
|
|
1804
2264
|
},
|
|
1805
|
-
visible: { default: true }
|
|
2265
|
+
visible: { default: true },
|
|
2266
|
+
/**
|
|
2267
|
+
* Can menu navigation land on this? Interactive widgets default to true.
|
|
2268
|
+
*
|
|
2269
|
+
* A menu you can only click is not playable on a controller, and "add
|
|
2270
|
+
* gamepad support" is not a thing a JSON scene could express at all.
|
|
2271
|
+
*/
|
|
2272
|
+
focusable: { default: false },
|
|
2273
|
+
/**
|
|
2274
|
+
* Can the player pick this up and drop it somewhere? An inventory item is
|
|
2275
|
+
* a `draggable` widget; a slot is a `dropTarget`.
|
|
2276
|
+
*/
|
|
2277
|
+
draggable: { default: false },
|
|
2278
|
+
/** Can something be dropped ON this? */
|
|
2279
|
+
dropTarget: { default: false }
|
|
1806
2280
|
};
|
|
2281
|
+
static signals = [
|
|
2282
|
+
"dragStarted",
|
|
2283
|
+
"dragCancelled",
|
|
2284
|
+
"droppedOn",
|
|
2285
|
+
"dropped"
|
|
2286
|
+
];
|
|
1807
2287
|
anchor = "topLeft";
|
|
1808
2288
|
visible = true;
|
|
2289
|
+
focusable = false;
|
|
2290
|
+
draggable = false;
|
|
2291
|
+
dropTarget = false;
|
|
2292
|
+
/** @internal Set by the owning HudLayer while this widget has focus. */
|
|
2293
|
+
_focused = false;
|
|
2294
|
+
/**
|
|
2295
|
+
* @internal Confirm (`dir` 0) or nudge (-1 left / +1 right) — what pressing
|
|
2296
|
+
* A or an arrow ON this widget means. Default: nothing.
|
|
2297
|
+
*/
|
|
2298
|
+
_activate(_dir) {}
|
|
2299
|
+
/** @internal Draw the focus ring. Overridable if a widget wants its own. */
|
|
2300
|
+
_paintFocus() {
|
|
2301
|
+
if (!this._element) return;
|
|
2302
|
+
this._element.style.outline = this._focused ? "2px solid #6ee7dc" : "";
|
|
2303
|
+
this._element.style.outlineOffset = this._focused ? "2px" : "";
|
|
2304
|
+
}
|
|
1809
2305
|
/** @internal */
|
|
1810
2306
|
_element = null;
|
|
1811
2307
|
layer() {
|
|
@@ -1830,8 +2326,45 @@ var HudWidgetBase = class extends Node {
|
|
|
1830
2326
|
const host = this.host() ?? this.layer()?._slot(this.anchor);
|
|
1831
2327
|
if (!host) return;
|
|
1832
2328
|
this._element = this._build();
|
|
2329
|
+
widgetOf.set(this._element, this);
|
|
2330
|
+
if (this.draggable || this.dropTarget) this._wireDrag(this._element);
|
|
1833
2331
|
host.appendChild(this._element);
|
|
1834
2332
|
}
|
|
2333
|
+
/**
|
|
2334
|
+
* Drag and drop, on the DOM the engine already builds.
|
|
2335
|
+
*
|
|
2336
|
+
* An inventory is the one screen where "click it" is not enough, and every
|
|
2337
|
+
* game that wanted one dropped out of scene JSON to hand-roll pointer
|
|
2338
|
+
* handlers. The whole gesture is four signals and no new node type: a
|
|
2339
|
+
* `draggable` widget emits `dragStarted` and, if it lands on one,
|
|
2340
|
+
* `droppedOn(target)`; a `dropTarget` emits `dropped(source)`. Who owns the
|
|
2341
|
+
* ITEM is the game's business — this reports the gesture, not a model.
|
|
2342
|
+
*/
|
|
2343
|
+
_wireDrag(el) {
|
|
2344
|
+
el.style.touchAction = "none";
|
|
2345
|
+
if (this.draggable) {
|
|
2346
|
+
el.style.cursor = "grab";
|
|
2347
|
+
el.addEventListener("pointerdown", (event) => {
|
|
2348
|
+
if (!this.draggable) return;
|
|
2349
|
+
event.preventDefault();
|
|
2350
|
+
el.setPointerCapture?.(event.pointerId);
|
|
2351
|
+
dragging = this;
|
|
2352
|
+
el.style.opacity = "0.6";
|
|
2353
|
+
this.emit("dragStarted", this);
|
|
2354
|
+
});
|
|
2355
|
+
el.addEventListener("pointerup", (event) => {
|
|
2356
|
+
if (dragging !== this) return;
|
|
2357
|
+
el.style.opacity = "";
|
|
2358
|
+
el.releasePointerCapture?.(event.pointerId);
|
|
2359
|
+
const target = widgetFromElement(document.elementFromPoint?.(event.clientX, event.clientY) ?? null);
|
|
2360
|
+
dragging = null;
|
|
2361
|
+
if (target && target !== this && target.dropTarget) {
|
|
2362
|
+
this.emit("droppedOn", target);
|
|
2363
|
+
target.emit("dropped", this);
|
|
2364
|
+
} else this.emit("dragCancelled", this);
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
1835
2368
|
/** The nearest ancestor that holds widgets itself (a UiPanel). */
|
|
1836
2369
|
host() {
|
|
1837
2370
|
for (let p = this.parent; p; p = p.parent) {
|
|
@@ -1854,6 +2387,18 @@ var HudWidgetBase = class extends Node {
|
|
|
1854
2387
|
this._sync();
|
|
1855
2388
|
}
|
|
1856
2389
|
_sync() {}
|
|
2390
|
+
/**
|
|
2391
|
+
* Resolve a text prop through the engine's locale.
|
|
2392
|
+
*
|
|
2393
|
+
* Called every frame from `_sync`, which is what makes switching language
|
|
2394
|
+
* repaint with nothing to invalidate: widgets already re-read their props, so
|
|
2395
|
+
* the new string simply appears on the next frame. A plain string costs one
|
|
2396
|
+
* `startsWith` and is returned untouched.
|
|
2397
|
+
*/
|
|
2398
|
+
_t(value) {
|
|
2399
|
+
const locale = this.tree?.engine?.locale;
|
|
2400
|
+
return locale ? locale.resolve(value) : value;
|
|
2401
|
+
}
|
|
1857
2402
|
};
|
|
1858
2403
|
/** A text line (score, timer, hints). Set `.text` from behaviors. */
|
|
1859
2404
|
var UiText = class extends HudWidgetBase {
|
|
@@ -1876,9 +2421,10 @@ var UiText = class extends HudWidgetBase {
|
|
|
1876
2421
|
return el;
|
|
1877
2422
|
}
|
|
1878
2423
|
_sync() {
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
this.
|
|
2424
|
+
const shown = this._t(this.text);
|
|
2425
|
+
if (shown !== this.last && this._element) {
|
|
2426
|
+
this.last = shown;
|
|
2427
|
+
this._element.textContent = shown;
|
|
1882
2428
|
}
|
|
1883
2429
|
}
|
|
1884
2430
|
};
|
|
@@ -1907,6 +2453,8 @@ var UiBar = class extends HudWidgetBase {
|
|
|
1907
2453
|
background = "rgba(0,0,0,0.5)";
|
|
1908
2454
|
label = "";
|
|
1909
2455
|
fill = null;
|
|
2456
|
+
tag = null;
|
|
2457
|
+
lastLabel = "\0";
|
|
1910
2458
|
lastRatio = -1;
|
|
1911
2459
|
/** Current fill ratio 0..1 (what the bar shows). */
|
|
1912
2460
|
get ratio() {
|
|
@@ -1917,8 +2465,8 @@ var UiBar = class extends HudWidgetBase {
|
|
|
1917
2465
|
wrap.style.cssText = "display:flex;align-items:center;gap:6px;";
|
|
1918
2466
|
if (this.label) {
|
|
1919
2467
|
const tag = document.createElement("span");
|
|
1920
|
-
tag.textContent = this.label;
|
|
1921
2468
|
tag.style.cssText = "color:#fff;font-size:12px;text-shadow:0 1px 3px rgba(0,0,0,.7);";
|
|
2469
|
+
this.tag = tag;
|
|
1922
2470
|
wrap.appendChild(tag);
|
|
1923
2471
|
}
|
|
1924
2472
|
const track = document.createElement("div");
|
|
@@ -1930,6 +2478,11 @@ var UiBar = class extends HudWidgetBase {
|
|
|
1930
2478
|
return wrap;
|
|
1931
2479
|
}
|
|
1932
2480
|
_sync() {
|
|
2481
|
+
const label = this._t(this.label);
|
|
2482
|
+
if (label !== this.lastLabel && this.tag) {
|
|
2483
|
+
this.lastLabel = label;
|
|
2484
|
+
this.tag.textContent = label;
|
|
2485
|
+
}
|
|
1933
2486
|
const r = this.ratio;
|
|
1934
2487
|
if (r === this.lastRatio || !this.fill) return;
|
|
1935
2488
|
this.lastRatio = r;
|
|
@@ -2007,6 +2560,7 @@ var UiBanner = class extends HudWidgetBase {
|
|
|
2007
2560
|
}
|
|
2008
2561
|
if (!this.current && this.queue.length > 0) {
|
|
2009
2562
|
this.current = this.queue.shift();
|
|
2563
|
+
this.current.text = this._t(this.current.text);
|
|
2010
2564
|
this.remaining = this.current.seconds;
|
|
2011
2565
|
if (this._element) {
|
|
2012
2566
|
this._element.textContent = this.current.text;
|
|
@@ -2035,13 +2589,19 @@ var UiButton = class extends HudWidgetBase {
|
|
|
2035
2589
|
size: { default: 16 },
|
|
2036
2590
|
color: { default: "#ffffff" },
|
|
2037
2591
|
background: { default: "rgba(255,255,255,0.14)" },
|
|
2038
|
-
disabled: { default: false }
|
|
2592
|
+
disabled: { default: false },
|
|
2593
|
+
focusable: { default: true }
|
|
2039
2594
|
};
|
|
2040
2595
|
text = "OK";
|
|
2041
2596
|
size = 16;
|
|
2042
2597
|
color = "#ffffff";
|
|
2043
2598
|
background = "rgba(255,255,255,0.14)";
|
|
2044
2599
|
disabled = false;
|
|
2600
|
+
focusable = true;
|
|
2601
|
+
/** Enter / A on a focused button is a press. */
|
|
2602
|
+
_activate(_dir) {
|
|
2603
|
+
this.press();
|
|
2604
|
+
}
|
|
2045
2605
|
lastText = "";
|
|
2046
2606
|
/** Programmatic press — same path as a click (tests, gamepad menus). */
|
|
2047
2607
|
press() {
|
|
@@ -2069,9 +2629,10 @@ var UiButton = class extends HudWidgetBase {
|
|
|
2069
2629
|
}
|
|
2070
2630
|
_sync() {
|
|
2071
2631
|
if (!this._element) return;
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
this.
|
|
2632
|
+
const shown = this._t(this.text);
|
|
2633
|
+
if (shown !== this.lastText) {
|
|
2634
|
+
this.lastText = shown;
|
|
2635
|
+
this._element.textContent = shown;
|
|
2075
2636
|
}
|
|
2076
2637
|
this._element.disabled = this.disabled;
|
|
2077
2638
|
this._element.style.opacity = this.disabled ? "0.45" : "1";
|
|
@@ -2196,6 +2757,9 @@ var UiDialogue = class extends HudWidgetBase {
|
|
|
2196
2757
|
super.update(dt);
|
|
2197
2758
|
if (!this.current && this.queue.length > 0) {
|
|
2198
2759
|
this.current = this.queue.shift();
|
|
2760
|
+
this.current.text = this._t(this.current.text);
|
|
2761
|
+
this.current.speaker = this._t(this.current.speaker);
|
|
2762
|
+
if (this.current.choices) this.current.choices = this.current.choices.map((c) => this._t(c));
|
|
2199
2763
|
this.revealed = this.charsPerSecond <= 0 ? this.current.text.length : 0;
|
|
2200
2764
|
this.emit("lineShown", this.current.text);
|
|
2201
2765
|
if (this._element) {
|
|
@@ -2419,9 +2983,10 @@ var UiSlider = class extends HudWidgetBase {
|
|
|
2419
2983
|
max: { default: 1 },
|
|
2420
2984
|
step: { default: .01 },
|
|
2421
2985
|
width: { default: 180 },
|
|
2422
|
-
color: { default: "#6ee7dc" }
|
|
2986
|
+
color: { default: "#6ee7dc" },
|
|
2987
|
+
focusable: { default: true }
|
|
2423
2988
|
};
|
|
2424
|
-
static signals = ["changed"];
|
|
2989
|
+
static signals = [...HudWidgetBase.signals, "changed"];
|
|
2425
2990
|
label = "";
|
|
2426
2991
|
value = .5;
|
|
2427
2992
|
min = 0;
|
|
@@ -2429,6 +2994,17 @@ var UiSlider = class extends HudWidgetBase {
|
|
|
2429
2994
|
step = .01;
|
|
2430
2995
|
width = 180;
|
|
2431
2996
|
color = "#6ee7dc";
|
|
2997
|
+
focusable = true;
|
|
2998
|
+
/** Left/right nudge by one step — the only sane thing a d-pad can mean here. */
|
|
2999
|
+
_activate(dir) {
|
|
3000
|
+
if (dir === 0) return;
|
|
3001
|
+
const next = Math.min(this.max, Math.max(this.min, this.value + dir * this.step));
|
|
3002
|
+
if (next === this.value) return;
|
|
3003
|
+
this.value = next;
|
|
3004
|
+
this.last = next;
|
|
3005
|
+
if (this.input) this.input.value = String(next);
|
|
3006
|
+
this.emit("changed", next);
|
|
3007
|
+
}
|
|
2432
3008
|
input = null;
|
|
2433
3009
|
text = null;
|
|
2434
3010
|
last = NaN;
|
|
@@ -2470,11 +3046,22 @@ var UiToggle = class extends HudWidgetBase {
|
|
|
2470
3046
|
static props = {
|
|
2471
3047
|
...HudWidgetBase.props,
|
|
2472
3048
|
label: { default: "" },
|
|
2473
|
-
value: { default: false }
|
|
3049
|
+
value: { default: false },
|
|
3050
|
+
focusable: { default: true }
|
|
2474
3051
|
};
|
|
2475
|
-
static signals = ["changed"];
|
|
3052
|
+
static signals = [...HudWidgetBase.signals, "changed"];
|
|
2476
3053
|
label = "";
|
|
2477
3054
|
value = false;
|
|
3055
|
+
focusable = true;
|
|
3056
|
+
/** Confirm flips it; left/right set it explicitly, which reads better on a pad. */
|
|
3057
|
+
_activate(dir) {
|
|
3058
|
+
const next = dir === 0 ? !this.value : dir > 0;
|
|
3059
|
+
if (next === this.value) return;
|
|
3060
|
+
this.value = next;
|
|
3061
|
+
this.last = next;
|
|
3062
|
+
if (this.input) this.input.checked = next;
|
|
3063
|
+
this.emit("changed", next);
|
|
3064
|
+
}
|
|
2478
3065
|
input = null;
|
|
2479
3066
|
last = null;
|
|
2480
3067
|
_build() {
|
|
@@ -2510,20 +3097,35 @@ var UiSelect = class extends HudWidgetBase {
|
|
|
2510
3097
|
label: { default: "" },
|
|
2511
3098
|
/** Comma-separated choices: `"low,medium,high"`. */
|
|
2512
3099
|
options: { default: "" },
|
|
2513
|
-
value: { default: "" }
|
|
3100
|
+
value: { default: "" },
|
|
3101
|
+
focusable: { default: true }
|
|
2514
3102
|
};
|
|
2515
|
-
static signals = ["changed"];
|
|
3103
|
+
static signals = [...HudWidgetBase.signals, "changed"];
|
|
2516
3104
|
label = "";
|
|
2517
3105
|
options = "";
|
|
2518
3106
|
value = "";
|
|
3107
|
+
focusable = true;
|
|
3108
|
+
/** Left/right (and confirm) walk the list — a select has no other gesture. */
|
|
3109
|
+
_activate(dir) {
|
|
3110
|
+
const choices = this.options.split(",").map((o) => o.trim()).filter(Boolean);
|
|
3111
|
+
if (choices.length === 0) return;
|
|
3112
|
+
const next = choices[(Math.max(0, choices.indexOf(this.value)) + (dir === 0 ? 1 : dir) + choices.length) % choices.length];
|
|
3113
|
+
if (!next || next === this.value) return;
|
|
3114
|
+
this.value = next;
|
|
3115
|
+
this.last = next;
|
|
3116
|
+
if (this.select) this.select.value = next;
|
|
3117
|
+
this.emit("changed", next);
|
|
3118
|
+
}
|
|
2519
3119
|
select = null;
|
|
3120
|
+
labelEl = null;
|
|
3121
|
+
lastLabel = "x";
|
|
2520
3122
|
lastOptions = "\0";
|
|
2521
3123
|
last = "\0";
|
|
2522
3124
|
_build() {
|
|
2523
3125
|
const wrap = document.createElement("label");
|
|
2524
3126
|
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;";
|
|
2525
3127
|
const text = document.createElement("span");
|
|
2526
|
-
|
|
3128
|
+
this.labelEl = text;
|
|
2527
3129
|
const select = document.createElement("select");
|
|
2528
3130
|
select.addEventListener("change", () => {
|
|
2529
3131
|
this.value = select.value;
|
|
@@ -2536,6 +3138,10 @@ var UiSelect = class extends HudWidgetBase {
|
|
|
2536
3138
|
this.fillOptions();
|
|
2537
3139
|
return wrap;
|
|
2538
3140
|
}
|
|
3141
|
+
/** Override point: what an option READS as, when it differs from its value. */
|
|
3142
|
+
_optionLabel(value) {
|
|
3143
|
+
return value;
|
|
3144
|
+
}
|
|
2539
3145
|
fillOptions() {
|
|
2540
3146
|
const select = this.select;
|
|
2541
3147
|
if (!select || this.options === this.lastOptions) return;
|
|
@@ -2546,13 +3152,18 @@ var UiSelect = class extends HudWidgetBase {
|
|
|
2546
3152
|
if (!value) continue;
|
|
2547
3153
|
const option = document.createElement("option");
|
|
2548
3154
|
option.value = value;
|
|
2549
|
-
option.textContent = value;
|
|
3155
|
+
option.textContent = this._optionLabel(value);
|
|
2550
3156
|
select.appendChild(option);
|
|
2551
3157
|
}
|
|
2552
3158
|
if (this.value) select.value = this.value;
|
|
2553
3159
|
this.last = this.value;
|
|
2554
3160
|
}
|
|
2555
3161
|
_sync() {
|
|
3162
|
+
const label = this._t(this.label);
|
|
3163
|
+
if (label !== this.lastLabel && this.labelEl) {
|
|
3164
|
+
this.lastLabel = label;
|
|
3165
|
+
this.labelEl.textContent = label;
|
|
3166
|
+
}
|
|
2556
3167
|
this.fillOptions();
|
|
2557
3168
|
if (this.select && this.value !== this.last) {
|
|
2558
3169
|
this.last = this.value;
|
|
@@ -2560,6 +3171,70 @@ var UiSelect = class extends HudWidgetBase {
|
|
|
2560
3171
|
}
|
|
2561
3172
|
}
|
|
2562
3173
|
};
|
|
3174
|
+
/** Endonyms: `ko` must read as the Korean word to someone who cannot read `Korean`. */
|
|
3175
|
+
const ENDONYMS = {
|
|
3176
|
+
en: "English",
|
|
3177
|
+
ko: "한국어",
|
|
3178
|
+
ja: "日本語",
|
|
3179
|
+
"zh-CN": "简体中文",
|
|
3180
|
+
"zh-TW": "繁體中文",
|
|
3181
|
+
es: "Español",
|
|
3182
|
+
fr: "Français",
|
|
3183
|
+
de: "Deutsch",
|
|
3184
|
+
pt: "Português",
|
|
3185
|
+
ru: "Русский",
|
|
3186
|
+
it: "Italiano",
|
|
3187
|
+
th: "ไทย",
|
|
3188
|
+
vi: "Tiếng Việt",
|
|
3189
|
+
id: "Bahasa Indonesia"
|
|
3190
|
+
};
|
|
3191
|
+
/**
|
|
3192
|
+
* The language picker, done — because every game needs the same one.
|
|
3193
|
+
*
|
|
3194
|
+
* A plain `UiSelect` bound to the locale would take a behavior, a signal
|
|
3195
|
+
* connection, and the knowledge that `Settings` persists it: three things an
|
|
3196
|
+
* agent has to get right to ship a feature the engine already has. This is one
|
|
3197
|
+
* node.
|
|
3198
|
+
*
|
|
3199
|
+
* ```json
|
|
3200
|
+
* { "name": "Language", "type": "UiLanguageSelect",
|
|
3201
|
+
* "props": { "label": "@t:settings.language" } }
|
|
3202
|
+
* ```
|
|
3203
|
+
*
|
|
3204
|
+
* Options are the locales the SCENE declares, so a game shipping only English
|
|
3205
|
+
* shows only English and the widget quietly costs nothing. Each is labeled with
|
|
3206
|
+
* its own endonym, because a player who cannot read the language currently on
|
|
3207
|
+
* screen still has to be able to find theirs.
|
|
3208
|
+
*/
|
|
3209
|
+
var UiLanguageSelect = class extends UiSelect {
|
|
3210
|
+
static typeName = "UiLanguageSelect";
|
|
3211
|
+
static props = {
|
|
3212
|
+
...UiSelect.props,
|
|
3213
|
+
label: { default: "Language" }
|
|
3214
|
+
};
|
|
3215
|
+
label = "Language";
|
|
3216
|
+
/** Last locale we PUSHED into `value`, to tell an engine change from a player one. */
|
|
3217
|
+
lastLocale = "";
|
|
3218
|
+
_sync() {
|
|
3219
|
+
const locale = this.tree?.engine?.locale;
|
|
3220
|
+
if (locale) {
|
|
3221
|
+
const available = locale.available();
|
|
3222
|
+
const options = available.join(",");
|
|
3223
|
+
if (options !== this.options) this.options = options;
|
|
3224
|
+
if (locale.locale !== this.lastLocale) {
|
|
3225
|
+
this.lastLocale = locale.locale;
|
|
3226
|
+
this.value = locale.locale;
|
|
3227
|
+
} else if (this.value !== locale.locale && available.includes(this.value)) {
|
|
3228
|
+
locale.locale = this.value;
|
|
3229
|
+
this.lastLocale = this.value;
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
super._sync();
|
|
3233
|
+
}
|
|
3234
|
+
_optionLabel(value) {
|
|
3235
|
+
return ENDONYMS[value] ?? value;
|
|
3236
|
+
}
|
|
3237
|
+
};
|
|
2563
3238
|
//#endregion
|
|
2564
3239
|
//#region src/core/register.ts
|
|
2565
3240
|
/**
|
|
@@ -2582,6 +3257,7 @@ function registerCoreNodes() {
|
|
|
2582
3257
|
registerNode(UiSlider);
|
|
2583
3258
|
registerNode(UiToggle);
|
|
2584
3259
|
registerNode(UiSelect);
|
|
3260
|
+
registerNode(UiLanguageSelect);
|
|
2585
3261
|
}
|
|
2586
3262
|
//#endregion
|
|
2587
|
-
export {
|
|
3263
|
+
export { translationKey as A, WebAudioMusicBackend as B, restoreBehaviors as C, Localization as D, BASE_LOCALE as E, spatialPan as F, fadeGain as H, SFX_PRESETS as I, SFX_PRESET_NAMES as L, isAudioContextAvailable as M, ROLLOFF_MODELS as N, T_PREFIX as O, spatialGain as P, synthSfx as R, captureBehaviors as S, LogManager as T, AudioBuses as U, crossfadeGains as V, qualityEnvironment as _, UiSlider as a, SaveSlots as b, UiButton as c, UiBanner as d, UiBar as f, Settings as g, Engine as h, UiSelect as i, SfxEngine as j, suggestLocale as k, UiDialogue as l, AudioPlayer as m, UiImage as n, UiToggle as o, UiText as p, UiPanel as r, Timer as s, registerCoreNodes as t, HudLayer as u, readDeviceHints as v, createSaveStore as w, behaviorsWithoutSave as x, suggestQuality as y, MusicManager as z };
|