incanto 0.55.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-CzK9_pzg.js → create-game-C5jQYPah.js} +28 -71
- package/dist/{create-game-BRt6XKmP.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-DAbEdEwh.js → environment-presets-CvvQr_bJ.js} +118 -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-CZ2yq37J.js → gameplay-BVphcxmE.js} +163 -14
- package/dist/gameplay.d.ts +81 -2
- package/dist/gameplay.js +2 -2
- 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-C_ZJ6f_d.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-BVOVHRL0.js → src-CGjmPw65.js} +55 -2
- package/dist/{teardown-C7uVSJvx.js → teardown-Cs113S9F.js} +74 -2
- package/dist/{test-Ca5eqELC.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-Cz4oHPtm.js → agent8-CGT7r3Mb.js} +1 -1
- package/editor/assets/{debug-r-Fi9nZh.js → debug-BxWSIHG3.js} +1 -1
- package/editor/assets/{index-BfqmvXYD.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-building-3d-games.md +18 -5
- package/skills/incanto-gameplay-behaviors.md +21 -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
|
@@ -173,172 +173,557 @@ function diagnose(engine, level, ...parts) {
|
|
|
173
173
|
else console.info(...parts);
|
|
174
174
|
}
|
|
175
175
|
//#endregion
|
|
176
|
-
//#region src/core/
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const el = target;
|
|
180
|
-
return !!el && (el.isContentEditable === true || el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT");
|
|
176
|
+
//#region src/core/node.ts
|
|
177
|
+
function validateName(name) {
|
|
178
|
+
if (name === "" || name.includes("/") || name.includes("%")) throw new IncantoError("TREE_VIOLATION", `Invalid node name '${name}'. Names must be non-empty and must not contain '/' or '%'.`);
|
|
181
179
|
}
|
|
182
180
|
/**
|
|
183
|
-
*
|
|
184
|
-
* via `handleKey`, or wire a browser with `attachKeyboard(window)`).
|
|
181
|
+
* Base class of everything in an Incanto scene (Godot's Node model).
|
|
185
182
|
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
183
|
+
* Pure data + tree structure: no rendering, no DOM, no three.js — renderer
|
|
184
|
+
* adapters subscribe from the outside.
|
|
188
185
|
*/
|
|
189
|
-
var
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
186
|
+
var Node = class {
|
|
187
|
+
static typeName = "Node";
|
|
188
|
+
_name;
|
|
189
|
+
_parent = null;
|
|
190
|
+
_children = [];
|
|
191
|
+
_groups = /* @__PURE__ */ new Set();
|
|
192
|
+
_signals = /* @__PURE__ */ new Map();
|
|
193
|
+
_declared = null;
|
|
194
|
+
_tree = null;
|
|
195
|
+
_ready = false;
|
|
199
196
|
/**
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
* This is what a click-driven game asks for. `pointerDelta()` answers "how far
|
|
204
|
-
* did the mouse move" — the mouse-look question — and for a whole class of
|
|
205
|
-
* genres that is the wrong question and the only one the engine could answer.
|
|
206
|
-
* Pair it with `renderer.pick(x, y)` to get the node under the cursor.
|
|
197
|
+
* Optional STABLE identifier (scene JSON `uid`) — unlike names (unique only
|
|
198
|
+
* among siblings) a uid is unique across the whole scene, so scripts and
|
|
199
|
+
* tools can address a node no matter where it moves.
|
|
207
200
|
*/
|
|
208
|
-
|
|
209
|
-
|
|
201
|
+
uid = null;
|
|
202
|
+
/** Free-form JSON identity for game logic (e.g. `{kind: 'ITEM', value: 10}`). */
|
|
203
|
+
tags = {};
|
|
204
|
+
/** Behavior attachment blob from scene JSON (resolved by the loader). */
|
|
205
|
+
script = null;
|
|
206
|
+
/** The resolved behavior instance (set by the loader from `script`). */
|
|
207
|
+
behavior = null;
|
|
208
|
+
/** Replication config blob from scene JSON (interpreted in M6; preserved until then). */
|
|
209
|
+
network = null;
|
|
210
|
+
constructor(name) {
|
|
211
|
+
const ctor = this.constructor;
|
|
212
|
+
this._name = name ?? ctor.typeName;
|
|
213
|
+
validateName(this._name);
|
|
210
214
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
* so a CSS-stretched canvas still reports coordinates its renderer can use.
|
|
214
|
-
*/
|
|
215
|
-
setPointerPosition(clientX, clientY, target) {
|
|
216
|
-
const rect = target?.getBoundingClientRect?.();
|
|
217
|
-
if (!rect || rect.width === 0 || rect.height === 0) {
|
|
218
|
-
this.pointerPos = {
|
|
219
|
-
x: clientX,
|
|
220
|
-
y: clientY
|
|
221
|
-
};
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
this.pointerPos = {
|
|
225
|
-
x: clientX - rect.left,
|
|
226
|
-
y: clientY - rect.top
|
|
227
|
-
};
|
|
215
|
+
get name() {
|
|
216
|
+
return this._name;
|
|
228
217
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
this.pressedEdge.add(code);
|
|
236
|
-
}
|
|
237
|
-
} else if (this.down.has(code)) {
|
|
238
|
-
this.down.delete(code);
|
|
239
|
-
this.releasedEdge.add(code);
|
|
240
|
-
}
|
|
218
|
+
set name(value) {
|
|
219
|
+
validateName(value);
|
|
220
|
+
const old = this._name;
|
|
221
|
+
if (this._parent) this._name = uniqueSiblingName(value, this._parent._children, this);
|
|
222
|
+
else this._name = value;
|
|
223
|
+
if (this._tree && this._name !== old) this._tree._reindexName(this, old);
|
|
241
224
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
injectedReleased = /* @__PURE__ */ new Set();
|
|
245
|
-
injectedVectors = /* @__PURE__ */ new Map();
|
|
246
|
-
/**
|
|
247
|
-
* Press a button ACTION directly — no key codes involved. This is how
|
|
248
|
-
* scripted gameplay tests and touch buttons drive the game by intent
|
|
249
|
-
* (`press('jump')`) instead of reverse-engineering keybinds.
|
|
250
|
-
*/
|
|
251
|
-
/**
|
|
252
|
-
* Every action this scene declared, with its kind.
|
|
253
|
-
*
|
|
254
|
-
* A scene states its own control vocabulary in `input{}`, which means a tool
|
|
255
|
-
* can drive a game it has never seen — the automated playtester's entire
|
|
256
|
-
* premise. Nothing else could ask: `actions` was private.
|
|
257
|
-
*/
|
|
258
|
-
declaredActions() {
|
|
259
|
-
const out = [];
|
|
260
|
-
for (const [name, def] of this.actions) out.push({
|
|
261
|
-
name,
|
|
262
|
-
type: def.type
|
|
263
|
-
});
|
|
264
|
-
return out;
|
|
225
|
+
get parent() {
|
|
226
|
+
return this._parent;
|
|
265
227
|
}
|
|
266
|
-
|
|
267
|
-
this.
|
|
268
|
-
if (!this.injectedDown.has(action)) {
|
|
269
|
-
this.injectedDown.add(action);
|
|
270
|
-
this.injectedPressed.add(action);
|
|
271
|
-
}
|
|
228
|
+
get children() {
|
|
229
|
+
return this._children;
|
|
272
230
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
this.button(action);
|
|
276
|
-
if (this.injectedDown.delete(action)) this.injectedReleased.add(action);
|
|
231
|
+
get groups() {
|
|
232
|
+
return this._groups;
|
|
277
233
|
}
|
|
278
|
-
/**
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
* Combines with key state in getVector (clamped to unit length).
|
|
282
|
-
*/
|
|
283
|
-
setActionVector(action, x, y) {
|
|
284
|
-
if (this.get(action).type !== "vector2") throw new IncantoError("BAD_FORMAT", `Input action '${action}' is a button — use pressAction/releaseAction, not setActionVector.`);
|
|
285
|
-
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new IncantoError("BAD_FORMAT", `setActionVector('${action}') needs finite numbers, got (${x}, ${y}).`);
|
|
286
|
-
if (x === 0 && y === 0) this.injectedVectors.delete(action);
|
|
287
|
-
else this.injectedVectors.set(action, {
|
|
288
|
-
x,
|
|
289
|
-
y
|
|
290
|
-
});
|
|
234
|
+
/** The SceneTree this node is attached to, or null while detached. */
|
|
235
|
+
get tree() {
|
|
236
|
+
return this._tree;
|
|
291
237
|
}
|
|
292
|
-
/**
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
* The touch overlay (core/touch.ts) renders these.
|
|
296
|
-
*/
|
|
297
|
-
touchControls() {
|
|
298
|
-
const out = [];
|
|
299
|
-
for (const [name, def] of this.actions) if (def.touch) out.push({
|
|
300
|
-
action: name,
|
|
301
|
-
kind: def.touch
|
|
302
|
-
});
|
|
303
|
-
return out;
|
|
238
|
+
/** Whether onReady has run (it runs at most once per instance). */
|
|
239
|
+
get isReady() {
|
|
240
|
+
return this._ready;
|
|
304
241
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
this.
|
|
308
|
-
this.
|
|
309
|
-
this
|
|
310
|
-
this.
|
|
242
|
+
addChild(child) {
|
|
243
|
+
if (child._parent !== null) throw new IncantoError("TREE_VIOLATION", `Cannot add '${child.name}': it already has parent '${child._parent.name}'. removeChild/reparent first.`);
|
|
244
|
+
for (let n = this; n; n = n._parent) if (n === child) throw new IncantoError("TREE_VIOLATION", `Cannot add '${child.name}' to '${this.name}': it is this node or an ancestor (cycle).`);
|
|
245
|
+
child._name = uniqueSiblingName(child._name, this._children, child);
|
|
246
|
+
child._parent = this;
|
|
247
|
+
this._children.push(child);
|
|
248
|
+
if (this._tree) {
|
|
249
|
+
child._propagateEnterTree(this._tree);
|
|
250
|
+
child._propagateReady();
|
|
251
|
+
}
|
|
252
|
+
return child;
|
|
311
253
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
254
|
+
removeChild(child) {
|
|
255
|
+
const i = this._children.indexOf(child);
|
|
256
|
+
if (i === -1) throw new IncantoError("TREE_VIOLATION", `'${child.name}' is not a child of '${this.name}'.`);
|
|
257
|
+
if (child._tree) child._propagateExitTree();
|
|
258
|
+
this._children.splice(i, 1);
|
|
259
|
+
child._parent = null;
|
|
318
260
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
this
|
|
322
|
-
this.dy += dy;
|
|
261
|
+
reparent(newParent) {
|
|
262
|
+
this._parent?.removeChild(this);
|
|
263
|
+
newParent.addChild(this);
|
|
323
264
|
}
|
|
324
|
-
|
|
325
|
-
this.
|
|
265
|
+
findChild(name, recursive = true) {
|
|
266
|
+
for (const c of this._children) if (c._name === name) return c;
|
|
267
|
+
if (recursive) for (const c of this._children) {
|
|
268
|
+
const found = c.findChild(name, true);
|
|
269
|
+
if (found) return found;
|
|
270
|
+
}
|
|
271
|
+
return null;
|
|
326
272
|
}
|
|
327
|
-
/**
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
273
|
+
/** Topmost ancestor (the node itself when detached). */
|
|
274
|
+
getRoot() {
|
|
275
|
+
let n = this;
|
|
276
|
+
while (n._parent) n = n._parent;
|
|
277
|
+
return n;
|
|
278
|
+
}
|
|
279
|
+
/** Depth-first search of THIS subtree for the node carrying `uid`. */
|
|
280
|
+
getNodeByUid(uid) {
|
|
281
|
+
if (this.uid === uid) return this;
|
|
282
|
+
for (const child of this.children) {
|
|
283
|
+
const found = child.getNodeByUid(uid);
|
|
284
|
+
if (found) return found;
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
/** Every node in THIS subtree named `name` — names repeat, so a list. */
|
|
289
|
+
getNodesByName(name) {
|
|
290
|
+
if (this._tree && this._parent === null) {
|
|
291
|
+
const set = this._tree._nodesNamed(name);
|
|
292
|
+
return set ? [...set] : [];
|
|
293
|
+
}
|
|
294
|
+
const out = [];
|
|
295
|
+
const walk = (node) => {
|
|
296
|
+
if (node.name === name) out.push(node);
|
|
297
|
+
for (const child of node.children) walk(child);
|
|
332
298
|
};
|
|
333
|
-
this
|
|
334
|
-
|
|
335
|
-
return d;
|
|
299
|
+
walk(this);
|
|
300
|
+
return out;
|
|
336
301
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
302
|
+
getPath() {
|
|
303
|
+
const parts = [];
|
|
304
|
+
for (let n = this; n; n = n._parent) parts.unshift(n._name);
|
|
305
|
+
return `/${parts.join("/")}`;
|
|
306
|
+
}
|
|
307
|
+
getNode(path) {
|
|
308
|
+
const found = this.resolve(path);
|
|
309
|
+
if (found) return found;
|
|
310
|
+
throw new IncantoError("NODE_NOT_FOUND", `No node at '${path}' from '${this.getPath()}'. Children here: [${this._children.map((c) => c._name).join(", ")}].`);
|
|
311
|
+
}
|
|
312
|
+
getNodeOrNull(path) {
|
|
313
|
+
return this.resolve(path);
|
|
314
|
+
}
|
|
315
|
+
resolve(path) {
|
|
316
|
+
const parsed = parseNodePath(path);
|
|
317
|
+
if (parsed.kind === "unique") {
|
|
318
|
+
const tree = this._tree;
|
|
319
|
+
if (tree) {
|
|
320
|
+
const set = tree._nodesNamed(parsed.name);
|
|
321
|
+
if (!set || set.size === 0) return null;
|
|
322
|
+
if (set.size > 1) {
|
|
323
|
+
const paths = [];
|
|
324
|
+
for (const m of set) paths.push(m.getPath());
|
|
325
|
+
throw new IncantoError("DUPLICATE_UNIQUE_NAME", `'%${parsed.name}' is ambiguous: ${set.size} nodes named '${parsed.name}' (${paths.join(", ")}).`);
|
|
326
|
+
}
|
|
327
|
+
for (const m of set) return m;
|
|
328
|
+
}
|
|
329
|
+
const matches = [];
|
|
330
|
+
collectByName(this.getRoot(), parsed.name, matches);
|
|
331
|
+
if (matches.length > 1) throw new IncantoError("DUPLICATE_UNIQUE_NAME", `'%${parsed.name}' is ambiguous: ${matches.length} nodes named '${parsed.name}' (${matches.map((m) => m.getPath()).join(", ")}).`);
|
|
332
|
+
return matches[0] ?? null;
|
|
333
|
+
}
|
|
334
|
+
let current;
|
|
335
|
+
let segments = parsed.segments;
|
|
336
|
+
if (parsed.kind === "absolute") {
|
|
337
|
+
const root = this.getRoot();
|
|
338
|
+
if (segments[0] !== root._name && segments[0] !== "root") return null;
|
|
339
|
+
current = root;
|
|
340
|
+
segments = segments.slice(1);
|
|
341
|
+
} else current = this;
|
|
342
|
+
for (const seg of segments) {
|
|
343
|
+
if (!current) return null;
|
|
344
|
+
if (seg === "..") {
|
|
345
|
+
current = current._parent;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
current = current._children.find((c) => c._name === seg) ?? null;
|
|
349
|
+
}
|
|
350
|
+
return current;
|
|
351
|
+
}
|
|
352
|
+
addToGroup(group) {
|
|
353
|
+
this._groups.add(group);
|
|
354
|
+
}
|
|
355
|
+
removeFromGroup(group) {
|
|
356
|
+
this._groups.delete(group);
|
|
357
|
+
}
|
|
358
|
+
isInGroup(group) {
|
|
359
|
+
return this._groups.has(group);
|
|
360
|
+
}
|
|
361
|
+
declaredSignals() {
|
|
362
|
+
if (!this._declared) {
|
|
363
|
+
this._declared = /* @__PURE__ */ new Set();
|
|
364
|
+
let ctor = this.constructor;
|
|
365
|
+
while (typeof ctor === "function") {
|
|
366
|
+
const own = ctor;
|
|
367
|
+
if (Object.hasOwn(ctor, "signals") && own.signals) for (const s of own.signals) this._declared.add(s);
|
|
368
|
+
ctor = Object.getPrototypeOf(ctor);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return this._declared;
|
|
372
|
+
}
|
|
373
|
+
/** Declare an ad-hoc signal on THIS instance (static `signals` covers types). */
|
|
374
|
+
declareSignal(name) {
|
|
375
|
+
this.declaredSignals().add(name);
|
|
376
|
+
}
|
|
377
|
+
/** Every signal this instance may emit (static + behavior + ad-hoc). */
|
|
378
|
+
declaredSignalNames() {
|
|
379
|
+
return [...this.declaredSignals()];
|
|
380
|
+
}
|
|
381
|
+
assertDeclared(name) {
|
|
382
|
+
if (this.declaredSignals().has(name)) return;
|
|
383
|
+
const ctor = this.constructor;
|
|
384
|
+
const declared = [...this.declaredSignals()];
|
|
385
|
+
throw new IncantoError("UNKNOWN_SIGNAL", `Unknown signal '${name}' on '${this.getPath()}' (${ctor.typeName}). Declared signals: [${declared.join(", ")}]. Declare it with "static signals = ['${name}']" on the node class or its behavior, or call node.declareSignal('${name}').`, {
|
|
386
|
+
signal: name,
|
|
387
|
+
path: this.getPath(),
|
|
388
|
+
nodeType: ctor.typeName,
|
|
389
|
+
validOptions: declared
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
/** Get the named DECLARED signal (creating its Signal object on demand). */
|
|
393
|
+
signal(name) {
|
|
394
|
+
this.assertDeclared(name);
|
|
395
|
+
let sig = this._signals.get(name);
|
|
396
|
+
if (!sig) {
|
|
397
|
+
sig = new Signal();
|
|
398
|
+
this._signals.set(name, sig);
|
|
399
|
+
}
|
|
400
|
+
return sig;
|
|
401
|
+
}
|
|
402
|
+
on(signal, fn, opts) {
|
|
403
|
+
return this.signal(signal).connect(fn, opts);
|
|
404
|
+
}
|
|
405
|
+
off(signal, fn) {
|
|
406
|
+
this._signals.get(signal)?.disconnect(fn);
|
|
407
|
+
}
|
|
408
|
+
emit(signal, ...args) {
|
|
409
|
+
this.assertDeclared(signal);
|
|
410
|
+
this._signals.get(signal)?.emit(...args);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Defer destruction to the end of the current update pass (flushed by the
|
|
414
|
+
* SceneTree). Frees immediately when detached from any tree.
|
|
415
|
+
*/
|
|
416
|
+
queueFree() {
|
|
417
|
+
if (this._tree) this._tree._queueFree(this);
|
|
418
|
+
else this.free();
|
|
419
|
+
}
|
|
420
|
+
/** Immediately detach and tear down this node and its children. */
|
|
421
|
+
free() {
|
|
422
|
+
const tree = this._tree;
|
|
423
|
+
for (const child of [...this._children]) child.free();
|
|
424
|
+
this._parent?.removeChild(this);
|
|
425
|
+
if (this._tree) this._propagateExitTree();
|
|
426
|
+
tree?._detachRoot(this);
|
|
427
|
+
for (const sig of this._signals.values()) sig.disconnectAll();
|
|
428
|
+
this._signals.clear();
|
|
429
|
+
}
|
|
430
|
+
/** @internal */
|
|
431
|
+
_propagateEnterTree(tree) {
|
|
432
|
+
this._tree = tree;
|
|
433
|
+
tree._indexName(this);
|
|
434
|
+
this.onEnterTree();
|
|
435
|
+
this.behavior?.onEnterTree?.();
|
|
436
|
+
for (const c of [...this._children]) if (c._tree !== tree) c._propagateEnterTree(tree);
|
|
437
|
+
}
|
|
438
|
+
/** @internal */
|
|
439
|
+
_propagateReady() {
|
|
440
|
+
for (const c of [...this._children]) if (c._tree) c._propagateReady();
|
|
441
|
+
if (!this._ready) {
|
|
442
|
+
this._ready = true;
|
|
443
|
+
this.onReady();
|
|
444
|
+
this.behavior?.onReady?.();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/** @internal */
|
|
448
|
+
_propagateExitTree() {
|
|
449
|
+
for (const c of [...this._children]) c._propagateExitTree();
|
|
450
|
+
this.onExitTree();
|
|
451
|
+
this.behavior?.onExitTree?.();
|
|
452
|
+
this._tree?._unindexName(this);
|
|
453
|
+
this._tree = null;
|
|
454
|
+
}
|
|
455
|
+
/** @internal */
|
|
456
|
+
_propagateUpdate(dt) {
|
|
457
|
+
if (!this.errored) try {
|
|
458
|
+
this.update(dt);
|
|
459
|
+
} catch (error) {
|
|
460
|
+
this._quarantine("update", error, "node");
|
|
461
|
+
}
|
|
462
|
+
if (this.behavior && !this.behaviorErrored) try {
|
|
463
|
+
this.behavior.update?.(dt);
|
|
464
|
+
} catch (error) {
|
|
465
|
+
this._quarantine("update", error, "behavior");
|
|
466
|
+
}
|
|
467
|
+
const kids = this._children;
|
|
468
|
+
for (let i = 0, n = kids.length; i < n; i++) kids[i]?._propagateUpdate(dt);
|
|
469
|
+
}
|
|
470
|
+
/** @internal */
|
|
471
|
+
_propagateFixedUpdate(dt) {
|
|
472
|
+
if (!this.errored) try {
|
|
473
|
+
this.fixedUpdate(dt);
|
|
474
|
+
} catch (error) {
|
|
475
|
+
this._quarantine("fixedUpdate", error, "node");
|
|
476
|
+
}
|
|
477
|
+
if (this.behavior && !this.behaviorErrored) try {
|
|
478
|
+
this.behavior.fixedUpdate?.(dt);
|
|
479
|
+
} catch (error) {
|
|
480
|
+
this._quarantine("fixedUpdate", error, "behavior");
|
|
481
|
+
}
|
|
482
|
+
const kids = this._children;
|
|
483
|
+
for (let i = 0, n = kids.length; i < n; i++) kids[i]?._propagateFixedUpdate(dt);
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Report an engine-level problem about THIS node.
|
|
487
|
+
*
|
|
488
|
+
* Goes to `engine.log` (what the debug overlay, `runScript()` and any agent
|
|
489
|
+
* tool read) as well as the console. A `console.warn` alone is invisible to
|
|
490
|
+
* every channel except a human with devtools open.
|
|
491
|
+
*/
|
|
492
|
+
diagnose(level, ...parts) {
|
|
493
|
+
diagnose(this._tree?.engine ?? null, level, ...parts);
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* This node threw and is being skipped. Its CHILDREN still run.
|
|
497
|
+
*
|
|
498
|
+
* The alternative is what used to happen: the exception escaped into the rAF
|
|
499
|
+
* callback, the loop was never rescheduled, and the game froze — permanently,
|
|
500
|
+
* silently, with `stats().running` still reporting true. In a vibe-coded
|
|
501
|
+
* project the least-tested code in the repo is a behavior's `update`, so this
|
|
502
|
+
* is not an edge case; it is Tuesday.
|
|
503
|
+
*
|
|
504
|
+
* Set it back to false to try the node again (the debug overlay's "resume").
|
|
505
|
+
*/
|
|
506
|
+
errored = false;
|
|
507
|
+
/**
|
|
508
|
+
* This node's SCRIPT threw and is being skipped — the node itself still runs.
|
|
509
|
+
*
|
|
510
|
+
* A behavior is your code; the node is the engine's. A typo in the first must
|
|
511
|
+
* not switch off the second, or breaking a sword script stops the character
|
|
512
|
+
* from walking.
|
|
513
|
+
*/
|
|
514
|
+
behaviorErrored = false;
|
|
515
|
+
/**
|
|
516
|
+
* Report ONCE and stop running this node's own update.
|
|
517
|
+
*
|
|
518
|
+
* Once, because a behavior that throws throws every frame: sixty identical
|
|
519
|
+
* stack traces a second buries the one line that matters and costs more than
|
|
520
|
+
* the game did.
|
|
521
|
+
*/
|
|
522
|
+
_quarantine(phase, error, what) {
|
|
523
|
+
const where = `${this.getPath()} (${this.constructor.typeName ?? "Node"})`;
|
|
524
|
+
let message;
|
|
525
|
+
if (what === "behavior") {
|
|
526
|
+
this.behaviorErrored = true;
|
|
527
|
+
message = `[incanto] behavior '${this.behavior?.constructor.name ?? "?"}' on ${where} threw in ${phase} — THE SCRIPT is now skipped; the node itself and the rest of the scene keep running.`;
|
|
528
|
+
} else {
|
|
529
|
+
this.errored = true;
|
|
530
|
+
message = `[incanto] ${where} threw in ${phase} — this node is now SKIPPED. Its children and the rest of the scene keep running.`;
|
|
531
|
+
}
|
|
532
|
+
const engine = this._tree?.engine;
|
|
533
|
+
engine?.log.error(message, error);
|
|
534
|
+
engine?._nodeErrored(this);
|
|
535
|
+
console.error(message, error);
|
|
536
|
+
}
|
|
537
|
+
onEnterTree() {}
|
|
538
|
+
onReady() {}
|
|
539
|
+
onExitTree() {}
|
|
540
|
+
update(_dt) {}
|
|
541
|
+
fixedUpdate(_dt) {}
|
|
542
|
+
};
|
|
543
|
+
function collectByName(node, name, out) {
|
|
544
|
+
if (node.name === name) out.push(node);
|
|
545
|
+
for (const c of node.children) collectByName(c, name, out);
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Deterministic sibling auto-rename: keep the requested name when free,
|
|
549
|
+
* otherwise increment its trailing number ('Enemy' → 'Enemy2', 'Enemy2' → 'Enemy3').
|
|
550
|
+
*/
|
|
551
|
+
function uniqueSiblingName(requested, siblings, self) {
|
|
552
|
+
const taken = new Set(siblings.filter((s) => s !== self).map((s) => s.name));
|
|
553
|
+
if (!taken.has(requested)) return requested;
|
|
554
|
+
const m = requested.match(/^(.*?)(\d+)$/);
|
|
555
|
+
const stem = m ? m[1] : requested;
|
|
556
|
+
let counter = m ? Number(m[2]) + 1 : 2;
|
|
557
|
+
while (taken.has(`${stem}${counter}`)) counter += 1;
|
|
558
|
+
return `${stem}${counter}`;
|
|
559
|
+
}
|
|
560
|
+
//#endregion
|
|
561
|
+
//#region src/core/input.ts
|
|
562
|
+
/** Keys typed into editable elements belong to the page UI, not the game. */
|
|
563
|
+
function isEditableTarget(target) {
|
|
564
|
+
const el = target;
|
|
565
|
+
return !!el && (el.isContentEditable === true || el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT");
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Declarative input from scene JSON `input{}` — DOM-free logic (feed key state
|
|
569
|
+
* via `handleKey`, or wire a browser with `attachKeyboard(window)`).
|
|
570
|
+
*
|
|
571
|
+
* Convention: vector2 follows the 2D y-down space (up = -y). `justPressed` /
|
|
572
|
+
* `justReleased` are one-frame edges, settled by the Engine each tick.
|
|
573
|
+
*/
|
|
574
|
+
var InputMap = class {
|
|
575
|
+
actions = /* @__PURE__ */ new Map();
|
|
576
|
+
down = /* @__PURE__ */ new Set();
|
|
577
|
+
pressedEdge = /* @__PURE__ */ new Set();
|
|
578
|
+
releasedEdge = /* @__PURE__ */ new Set();
|
|
579
|
+
detach = null;
|
|
580
|
+
/** Load (or extend with) scene-JSON action declarations. Hard-validates shape. */
|
|
581
|
+
declare(decls) {
|
|
582
|
+
for (const [name, action] of parseInputDecls(decls)) this.actions.set(name, action);
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Where the pointer is, in CANVAS pixels (top-left origin), or null when it
|
|
586
|
+
* has never been over the canvas.
|
|
587
|
+
*
|
|
588
|
+
* This is what a click-driven game asks for. `pointerDelta()` answers "how far
|
|
589
|
+
* did the mouse move" — the mouse-look question — and for a whole class of
|
|
590
|
+
* genres that is the wrong question and the only one the engine could answer.
|
|
591
|
+
* Pair it with `renderer.pick(x, y)` to get the node under the cursor.
|
|
592
|
+
*/
|
|
593
|
+
pointerPosition() {
|
|
594
|
+
return this.pointerPos ? { ...this.pointerPos } : null;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* @internal Feed a pointer position. `target` scales client → canvas pixels
|
|
598
|
+
* so a CSS-stretched canvas still reports coordinates its renderer can use.
|
|
599
|
+
*/
|
|
600
|
+
setPointerPosition(clientX, clientY, target) {
|
|
601
|
+
const rect = target?.getBoundingClientRect?.();
|
|
602
|
+
if (!rect || rect.width === 0 || rect.height === 0) {
|
|
603
|
+
this.pointerPos = {
|
|
604
|
+
x: clientX,
|
|
605
|
+
y: clientY
|
|
606
|
+
};
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
this.pointerPos = {
|
|
610
|
+
x: clientX - rect.left,
|
|
611
|
+
y: clientY - rect.top
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
pointerPos = null;
|
|
615
|
+
/** Feed a key state change (code = KeyboardEvent.code, e.g. 'Space', 'KeyW'). */
|
|
616
|
+
handleKey(code, isDown) {
|
|
617
|
+
if (isDown) {
|
|
618
|
+
if (!this.down.has(code)) {
|
|
619
|
+
this.down.add(code);
|
|
620
|
+
this.pressedEdge.add(code);
|
|
621
|
+
}
|
|
622
|
+
} else if (this.down.has(code)) {
|
|
623
|
+
this.down.delete(code);
|
|
624
|
+
this.releasedEdge.add(code);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
injectedDown = /* @__PURE__ */ new Set();
|
|
628
|
+
injectedPressed = /* @__PURE__ */ new Set();
|
|
629
|
+
injectedReleased = /* @__PURE__ */ new Set();
|
|
630
|
+
injectedVectors = /* @__PURE__ */ new Map();
|
|
631
|
+
/**
|
|
632
|
+
* Press a button ACTION directly — no key codes involved. This is how
|
|
633
|
+
* scripted gameplay tests and touch buttons drive the game by intent
|
|
634
|
+
* (`press('jump')`) instead of reverse-engineering keybinds.
|
|
635
|
+
*/
|
|
636
|
+
/**
|
|
637
|
+
* Every action this scene declared, with its kind.
|
|
638
|
+
*
|
|
639
|
+
* A scene states its own control vocabulary in `input{}`, which means a tool
|
|
640
|
+
* can drive a game it has never seen — the automated playtester's entire
|
|
641
|
+
* premise. Nothing else could ask: `actions` was private.
|
|
642
|
+
*/
|
|
643
|
+
declaredActions() {
|
|
644
|
+
const out = [];
|
|
645
|
+
for (const [name, def] of this.actions) out.push({
|
|
646
|
+
name,
|
|
647
|
+
type: def.type
|
|
648
|
+
});
|
|
649
|
+
return out;
|
|
650
|
+
}
|
|
651
|
+
pressAction(action) {
|
|
652
|
+
this.button(action);
|
|
653
|
+
if (!this.injectedDown.has(action)) {
|
|
654
|
+
this.injectedDown.add(action);
|
|
655
|
+
this.injectedPressed.add(action);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/** Release an injected button action (yields one justReleased frame). */
|
|
659
|
+
releaseAction(action) {
|
|
660
|
+
this.button(action);
|
|
661
|
+
if (this.injectedDown.delete(action)) this.injectedReleased.add(action);
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Feed an analog direction for a vector2 ACTION — virtual joysticks and
|
|
665
|
+
* scripted runs. Persists until replaced; (0, 0) clears the injection.
|
|
666
|
+
* Combines with key state in getVector (clamped to unit length).
|
|
667
|
+
*/
|
|
668
|
+
setActionVector(action, x, y) {
|
|
669
|
+
if (this.get(action).type !== "vector2") throw new IncantoError("BAD_FORMAT", `Input action '${action}' is a button — use pressAction/releaseAction, not setActionVector.`);
|
|
670
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new IncantoError("BAD_FORMAT", `setActionVector('${action}') needs finite numbers, got (${x}, ${y}).`);
|
|
671
|
+
if (x === 0 && y === 0) this.injectedVectors.delete(action);
|
|
672
|
+
else this.injectedVectors.set(action, {
|
|
673
|
+
x,
|
|
674
|
+
y
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Actions the scene wants on-screen touch controls for, in declaration
|
|
679
|
+
* order (`"touch": "joystick"` on vector2, `"touch": "button"` on buttons).
|
|
680
|
+
* The touch overlay (core/touch.ts) renders these.
|
|
681
|
+
*/
|
|
682
|
+
touchControls() {
|
|
683
|
+
const out = [];
|
|
684
|
+
for (const [name, def] of this.actions) if (def.touch) out.push({
|
|
685
|
+
action: name,
|
|
686
|
+
kind: def.touch
|
|
687
|
+
});
|
|
688
|
+
return out;
|
|
689
|
+
}
|
|
690
|
+
/** Drop all injected action state (held buttons, vectors, pending edges). */
|
|
691
|
+
clearInjected() {
|
|
692
|
+
this.injectedDown.clear();
|
|
693
|
+
this.injectedPressed.clear();
|
|
694
|
+
this.injectedReleased.clear();
|
|
695
|
+
this.injectedVectors.clear();
|
|
696
|
+
}
|
|
697
|
+
dx = 0;
|
|
698
|
+
dy = 0;
|
|
699
|
+
wheel = 0;
|
|
700
|
+
/** Mouse buttons feed the same code space as keys: Mouse0/Mouse1/Mouse2. */
|
|
701
|
+
handleMouseButton(button, isDown) {
|
|
702
|
+
this.handleKey(`Mouse${button}`, isDown);
|
|
703
|
+
}
|
|
704
|
+
/** Accumulate look deltas (movementX/Y under pointer lock, else move deltas). */
|
|
705
|
+
handlePointerMove(dx, dy) {
|
|
706
|
+
this.dx += dx;
|
|
707
|
+
this.dy += dy;
|
|
708
|
+
}
|
|
709
|
+
handleWheel(deltaY) {
|
|
710
|
+
this.wheel += deltaY;
|
|
711
|
+
}
|
|
712
|
+
/** Drain the accumulated pointer delta (read once per frame). */
|
|
713
|
+
pointerDelta() {
|
|
714
|
+
const d = {
|
|
715
|
+
x: this.dx,
|
|
716
|
+
y: this.dy
|
|
717
|
+
};
|
|
718
|
+
this.dx = 0;
|
|
719
|
+
this.dy = 0;
|
|
720
|
+
return d;
|
|
721
|
+
}
|
|
722
|
+
/** Drain the accumulated wheel delta. */
|
|
723
|
+
wheelDelta() {
|
|
724
|
+
const w = this.wheel;
|
|
725
|
+
this.wheel = 0;
|
|
726
|
+
return w;
|
|
342
727
|
}
|
|
343
728
|
/**
|
|
344
729
|
* Wire browser pointer events on a canvas: buttons → Mouse0/1/2 codes,
|
|
@@ -867,567 +1252,182 @@ function captureBehaviors(root, source) {
|
|
|
867
1252
|
/** Every uid the scene FILE declares, in document order. */
|
|
868
1253
|
function authoredUids(source) {
|
|
869
1254
|
const out = [];
|
|
870
|
-
const walk = (node) => {
|
|
871
|
-
if (typeof node !== "object" || node === null) return;
|
|
872
|
-
const n = node;
|
|
873
|
-
if (typeof n.uid === "string" && n.uid !== "") out.push(n.uid);
|
|
874
|
-
if (Array.isArray(n.children)) for (const child of n.children) walk(child);
|
|
875
|
-
};
|
|
876
|
-
walk(source.root);
|
|
877
|
-
return out;
|
|
878
|
-
}
|
|
879
|
-
/**
|
|
880
|
-
* Hand each behavior its state back.
|
|
881
|
-
*
|
|
882
|
-
* Call this AFTER the scene has loaded and `onReady` has run: `onReady` is where
|
|
883
|
-
* a behavior sets its starting values, so restoring first would be overwritten
|
|
884
|
-
* by the fresh start.
|
|
885
|
-
*
|
|
886
|
-
* Never throws on a mismatch. A save from an older build of the game will name
|
|
887
|
-
* uids that no longer exist, and refusing to load it would mean a patch that
|
|
888
|
-
* moves one node deletes everyone's progress. It reports instead.
|
|
889
|
-
*/
|
|
890
|
-
function restoreBehaviors(root, state) {
|
|
891
|
-
const report = {
|
|
892
|
-
missing: [],
|
|
893
|
-
skipped: [],
|
|
894
|
-
restored: 0,
|
|
895
|
-
expected: captureBehaviors(root).saveable,
|
|
896
|
-
freed: 0
|
|
897
|
-
};
|
|
898
|
-
for (const [uid, value] of Object.entries(state)) {
|
|
899
|
-
if (uid === "#freed") {
|
|
900
|
-
for (const gone of value ?? []) {
|
|
901
|
-
const node = root.getNodeByUid(gone);
|
|
902
|
-
if (node) {
|
|
903
|
-
node.queueFree();
|
|
904
|
-
report.freed += 1;
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
continue;
|
|
908
|
-
}
|
|
909
|
-
const node = root.getNodeByUid(uid);
|
|
910
|
-
if (!node) {
|
|
911
|
-
report.missing.push(uid);
|
|
912
|
-
continue;
|
|
913
|
-
}
|
|
914
|
-
const behavior = node.behavior;
|
|
915
|
-
if (!behavior || typeof behavior.deserialize !== "function") {
|
|
916
|
-
report.skipped.push(uid);
|
|
917
|
-
continue;
|
|
918
|
-
}
|
|
919
|
-
try {
|
|
920
|
-
behavior.deserialize(value);
|
|
921
|
-
report.restored += 1;
|
|
922
|
-
} catch {
|
|
923
|
-
report.skipped.push(uid);
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
return report;
|
|
927
|
-
}
|
|
928
|
-
/** Slot ids, so a "3 save slots" menu is a list and not a naming convention. */
|
|
929
|
-
const INDEX_KEY = "slots";
|
|
930
|
-
/**
|
|
931
|
-
* The slot layer: a named list of saves over the existing store.
|
|
932
|
-
*
|
|
933
|
-
* Slot ids are yours (`'auto'`, `'1'`, `'2'`, `'3'`) — the engine does not
|
|
934
|
-
* decide how many you have or whether one of them autosaves.
|
|
935
|
-
*/
|
|
936
|
-
var SaveSlots = class {
|
|
937
|
-
store;
|
|
938
|
-
constructor(namespace = "saves", store) {
|
|
939
|
-
this.store = store ?? createSaveStore(namespace);
|
|
940
|
-
}
|
|
941
|
-
/** Slot ids that have a save, newest first. */
|
|
942
|
-
list() {
|
|
943
|
-
const rows = this.store.get(INDEX_KEY, []).map((id) => ({
|
|
944
|
-
id,
|
|
945
|
-
slot: this.read(id)
|
|
946
|
-
})).filter((row) => row.slot !== null);
|
|
947
|
-
rows.sort((a, b) => b.slot.savedAt - a.slot.savedAt);
|
|
948
|
-
return rows.map((row) => row.id);
|
|
949
|
-
}
|
|
950
|
-
/** Every slot with its contents — what a load menu actually renders. */
|
|
951
|
-
all() {
|
|
952
|
-
return this.list().map((id) => ({
|
|
953
|
-
id,
|
|
954
|
-
...this.read(id)
|
|
955
|
-
}));
|
|
956
|
-
}
|
|
957
|
-
read(id) {
|
|
958
|
-
const raw = this.store.get(key(id), null);
|
|
959
|
-
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
960
|
-
const slot = raw;
|
|
961
|
-
if (typeof slot.scene !== "string" || typeof slot.state !== "object" || slot.state === null) return null;
|
|
962
|
-
return {
|
|
963
|
-
scene: slot.scene,
|
|
964
|
-
state: slot.state,
|
|
965
|
-
label: typeof slot.label === "string" ? slot.label : "",
|
|
966
|
-
savedAt: typeof slot.savedAt === "number" ? slot.savedAt : 0,
|
|
967
|
-
playtime: typeof slot.playtime === "number" ? slot.playtime : 0,
|
|
968
|
-
data: slot.data ?? {}
|
|
969
|
-
};
|
|
970
|
-
}
|
|
971
|
-
/**
|
|
972
|
-
* Write a slot.
|
|
973
|
-
*
|
|
974
|
-
* `savedAt` is a parameter rather than `Date.now()` so a test can assert an
|
|
975
|
-
* ordering without sleeping, and so a game that wants server time can use it.
|
|
976
|
-
*/
|
|
977
|
-
write(id, slot) {
|
|
978
|
-
const full = {
|
|
979
|
-
scene: slot.scene,
|
|
980
|
-
state: slot.state,
|
|
981
|
-
label: slot.label ?? "",
|
|
982
|
-
savedAt: slot.savedAt ?? 0,
|
|
983
|
-
playtime: slot.playtime ?? 0,
|
|
984
|
-
data: slot.data ?? {}
|
|
985
|
-
};
|
|
986
|
-
this.store.set(key(id), full);
|
|
987
|
-
const ids = this.store.get(INDEX_KEY, []);
|
|
988
|
-
if (!ids.includes(id)) this.store.set(INDEX_KEY, [...ids, id]);
|
|
989
|
-
}
|
|
990
|
-
remove(id) {
|
|
991
|
-
this.store.remove(key(id));
|
|
992
|
-
this.store.set(INDEX_KEY, this.store.get(INDEX_KEY, []).filter((other) => other !== id));
|
|
993
|
-
}
|
|
994
|
-
/** Wipe every slot — a "delete all data" button, and test isolation. */
|
|
995
|
-
clear() {
|
|
996
|
-
for (const id of this.store.get(INDEX_KEY, [])) this.store.remove(key(id));
|
|
997
|
-
this.store.set(INDEX_KEY, []);
|
|
998
|
-
}
|
|
999
|
-
};
|
|
1000
|
-
function key(id) {
|
|
1001
|
-
return `slot:${id}`;
|
|
1002
|
-
}
|
|
1003
|
-
/**
|
|
1004
|
-
* Behaviors in this tree that hold state and cannot save it — the audit's
|
|
1005
|
-
* question, answerable before a player loses an hour.
|
|
1006
|
-
*
|
|
1007
|
-
* A behavior with props but no `serialize` is not necessarily wrong (an
|
|
1008
|
-
* `Oscillate` derives everything from time), so this reports rather than warns,
|
|
1009
|
-
* and the caller decides.
|
|
1010
|
-
*/
|
|
1011
|
-
function behaviorsWithoutSave(root) {
|
|
1012
|
-
const out = [];
|
|
1013
|
-
const walk = (node) => {
|
|
1014
|
-
const behavior = node.behavior;
|
|
1015
|
-
const name = node.script?.name;
|
|
1016
|
-
if (behavior && typeof name === "string" && typeof behavior.serialize !== "function") {
|
|
1017
|
-
if (Object.keys(behaviorSchema(name)).length > 0) out.push(`${node.getPath()} (${name})`);
|
|
1018
|
-
}
|
|
1019
|
-
for (const child of node.children) walk(child);
|
|
1020
|
-
};
|
|
1021
|
-
walk(root);
|
|
1022
|
-
return out;
|
|
1023
|
-
}
|
|
1024
|
-
/**
|
|
1025
|
-
* Behaviors that DO save, on nodes with no uid to save them under — the state
|
|
1026
|
-
* that goes nowhere.
|
|
1027
|
-
*
|
|
1028
|
-
* `incanto-check` warns about the built-ins it can recognise from the JSON
|
|
1029
|
-
* (Health, ScoreKeeper, Collector), but a scene file cannot be asked whether
|
|
1030
|
-
* YOUR behavior has a `serialize`. This can: it walks the live tree, so a quest
|
|
1031
|
-
* log or a wallet of your own is named too.
|
|
1032
|
-
*
|
|
1033
|
-
* The pair to read before shipping: `behaviorsWithoutSave` is "did you forget
|
|
1034
|
-
* to write serialize", this is "did you forget the uid that makes it count".
|
|
1035
|
-
*/
|
|
1036
|
-
function savesWithoutUid(root) {
|
|
1037
|
-
const out = [];
|
|
1038
|
-
const walk = (node) => {
|
|
1039
|
-
const behavior = node.behavior;
|
|
1040
|
-
const name = node.script?.name;
|
|
1041
|
-
if (behavior && typeof behavior.serialize === "function" && !node.uid) out.push(`${node.getPath()} (${name ?? "behavior"})`);
|
|
1042
|
-
for (const child of node.children) walk(child);
|
|
1255
|
+
const walk = (node) => {
|
|
1256
|
+
if (typeof node !== "object" || node === null) return;
|
|
1257
|
+
const n = node;
|
|
1258
|
+
if (typeof n.uid === "string" && n.uid !== "") out.push(n.uid);
|
|
1259
|
+
if (Array.isArray(n.children)) for (const child of n.children) walk(child);
|
|
1043
1260
|
};
|
|
1044
|
-
walk(root);
|
|
1261
|
+
walk(source.root);
|
|
1045
1262
|
return out;
|
|
1046
1263
|
}
|
|
1047
|
-
//#endregion
|
|
1048
|
-
//#region src/core/node.ts
|
|
1049
|
-
function validateName(name) {
|
|
1050
|
-
if (name === "" || name.includes("/") || name.includes("%")) throw new IncantoError("TREE_VIOLATION", `Invalid node name '${name}'. Names must be non-empty and must not contain '/' or '%'.`);
|
|
1051
|
-
}
|
|
1052
1264
|
/**
|
|
1053
|
-
*
|
|
1265
|
+
* Hand each behavior its state back.
|
|
1054
1266
|
*
|
|
1055
|
-
*
|
|
1056
|
-
*
|
|
1267
|
+
* Call this AFTER the scene has loaded and `onReady` has run: `onReady` is where
|
|
1268
|
+
* a behavior sets its starting values, so restoring first would be overwritten
|
|
1269
|
+
* by the fresh start.
|
|
1270
|
+
*
|
|
1271
|
+
* Never throws on a mismatch. A save from an older build of the game will name
|
|
1272
|
+
* uids that no longer exist, and refusing to load it would mean a patch that
|
|
1273
|
+
* moves one node deletes everyone's progress. It reports instead.
|
|
1057
1274
|
*/
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
uid = null;
|
|
1074
|
-
/** Free-form JSON identity for game logic (e.g. `{kind: 'ITEM', value: 10}`). */
|
|
1075
|
-
tags = {};
|
|
1076
|
-
/** Behavior attachment blob from scene JSON (resolved by the loader). */
|
|
1077
|
-
script = null;
|
|
1078
|
-
/** The resolved behavior instance (set by the loader from `script`). */
|
|
1079
|
-
behavior = null;
|
|
1080
|
-
/** Replication config blob from scene JSON (interpreted in M6; preserved until then). */
|
|
1081
|
-
network = null;
|
|
1082
|
-
constructor(name) {
|
|
1083
|
-
const ctor = this.constructor;
|
|
1084
|
-
this._name = name ?? ctor.typeName;
|
|
1085
|
-
validateName(this._name);
|
|
1086
|
-
}
|
|
1087
|
-
get name() {
|
|
1088
|
-
return this._name;
|
|
1089
|
-
}
|
|
1090
|
-
set name(value) {
|
|
1091
|
-
validateName(value);
|
|
1092
|
-
const old = this._name;
|
|
1093
|
-
if (this._parent) this._name = uniqueSiblingName(value, this._parent._children, this);
|
|
1094
|
-
else this._name = value;
|
|
1095
|
-
if (this._tree && this._name !== old) this._tree._reindexName(this, old);
|
|
1096
|
-
}
|
|
1097
|
-
get parent() {
|
|
1098
|
-
return this._parent;
|
|
1099
|
-
}
|
|
1100
|
-
get children() {
|
|
1101
|
-
return this._children;
|
|
1102
|
-
}
|
|
1103
|
-
get groups() {
|
|
1104
|
-
return this._groups;
|
|
1105
|
-
}
|
|
1106
|
-
/** The SceneTree this node is attached to, or null while detached. */
|
|
1107
|
-
get tree() {
|
|
1108
|
-
return this._tree;
|
|
1109
|
-
}
|
|
1110
|
-
/** Whether onReady has run (it runs at most once per instance). */
|
|
1111
|
-
get isReady() {
|
|
1112
|
-
return this._ready;
|
|
1113
|
-
}
|
|
1114
|
-
addChild(child) {
|
|
1115
|
-
if (child._parent !== null) throw new IncantoError("TREE_VIOLATION", `Cannot add '${child.name}': it already has parent '${child._parent.name}'. removeChild/reparent first.`);
|
|
1116
|
-
for (let n = this; n; n = n._parent) if (n === child) throw new IncantoError("TREE_VIOLATION", `Cannot add '${child.name}' to '${this.name}': it is this node or an ancestor (cycle).`);
|
|
1117
|
-
child._name = uniqueSiblingName(child._name, this._children, child);
|
|
1118
|
-
child._parent = this;
|
|
1119
|
-
this._children.push(child);
|
|
1120
|
-
if (this._tree) {
|
|
1121
|
-
child._propagateEnterTree(this._tree);
|
|
1122
|
-
child._propagateReady();
|
|
1123
|
-
}
|
|
1124
|
-
return child;
|
|
1125
|
-
}
|
|
1126
|
-
removeChild(child) {
|
|
1127
|
-
const i = this._children.indexOf(child);
|
|
1128
|
-
if (i === -1) throw new IncantoError("TREE_VIOLATION", `'${child.name}' is not a child of '${this.name}'.`);
|
|
1129
|
-
if (child._tree) child._propagateExitTree();
|
|
1130
|
-
this._children.splice(i, 1);
|
|
1131
|
-
child._parent = null;
|
|
1132
|
-
}
|
|
1133
|
-
reparent(newParent) {
|
|
1134
|
-
this._parent?.removeChild(this);
|
|
1135
|
-
newParent.addChild(this);
|
|
1136
|
-
}
|
|
1137
|
-
findChild(name, recursive = true) {
|
|
1138
|
-
for (const c of this._children) if (c._name === name) return c;
|
|
1139
|
-
if (recursive) for (const c of this._children) {
|
|
1140
|
-
const found = c.findChild(name, true);
|
|
1141
|
-
if (found) return found;
|
|
1142
|
-
}
|
|
1143
|
-
return null;
|
|
1144
|
-
}
|
|
1145
|
-
/** Topmost ancestor (the node itself when detached). */
|
|
1146
|
-
getRoot() {
|
|
1147
|
-
let n = this;
|
|
1148
|
-
while (n._parent) n = n._parent;
|
|
1149
|
-
return n;
|
|
1150
|
-
}
|
|
1151
|
-
/** Depth-first search of THIS subtree for the node carrying `uid`. */
|
|
1152
|
-
getNodeByUid(uid) {
|
|
1153
|
-
if (this.uid === uid) return this;
|
|
1154
|
-
for (const child of this.children) {
|
|
1155
|
-
const found = child.getNodeByUid(uid);
|
|
1156
|
-
if (found) return found;
|
|
1157
|
-
}
|
|
1158
|
-
return null;
|
|
1159
|
-
}
|
|
1160
|
-
/** Every node in THIS subtree named `name` — names repeat, so a list. */
|
|
1161
|
-
getNodesByName(name) {
|
|
1162
|
-
if (this._tree && this._parent === null) {
|
|
1163
|
-
const set = this._tree._nodesNamed(name);
|
|
1164
|
-
return set ? [...set] : [];
|
|
1165
|
-
}
|
|
1166
|
-
const out = [];
|
|
1167
|
-
const walk = (node) => {
|
|
1168
|
-
if (node.name === name) out.push(node);
|
|
1169
|
-
for (const child of node.children) walk(child);
|
|
1170
|
-
};
|
|
1171
|
-
walk(this);
|
|
1172
|
-
return out;
|
|
1173
|
-
}
|
|
1174
|
-
getPath() {
|
|
1175
|
-
const parts = [];
|
|
1176
|
-
for (let n = this; n; n = n._parent) parts.unshift(n._name);
|
|
1177
|
-
return `/${parts.join("/")}`;
|
|
1178
|
-
}
|
|
1179
|
-
getNode(path) {
|
|
1180
|
-
const found = this.resolve(path);
|
|
1181
|
-
if (found) return found;
|
|
1182
|
-
throw new IncantoError("NODE_NOT_FOUND", `No node at '${path}' from '${this.getPath()}'. Children here: [${this._children.map((c) => c._name).join(", ")}].`);
|
|
1183
|
-
}
|
|
1184
|
-
getNodeOrNull(path) {
|
|
1185
|
-
return this.resolve(path);
|
|
1186
|
-
}
|
|
1187
|
-
resolve(path) {
|
|
1188
|
-
const parsed = parseNodePath(path);
|
|
1189
|
-
if (parsed.kind === "unique") {
|
|
1190
|
-
const tree = this._tree;
|
|
1191
|
-
if (tree) {
|
|
1192
|
-
const set = tree._nodesNamed(parsed.name);
|
|
1193
|
-
if (!set || set.size === 0) return null;
|
|
1194
|
-
if (set.size > 1) {
|
|
1195
|
-
const paths = [];
|
|
1196
|
-
for (const m of set) paths.push(m.getPath());
|
|
1197
|
-
throw new IncantoError("DUPLICATE_UNIQUE_NAME", `'%${parsed.name}' is ambiguous: ${set.size} nodes named '${parsed.name}' (${paths.join(", ")}).`);
|
|
1275
|
+
function restoreBehaviors(root, state) {
|
|
1276
|
+
const report = {
|
|
1277
|
+
missing: [],
|
|
1278
|
+
skipped: [],
|
|
1279
|
+
restored: 0,
|
|
1280
|
+
expected: captureBehaviors(root).saveable,
|
|
1281
|
+
freed: 0
|
|
1282
|
+
};
|
|
1283
|
+
for (const [uid, value] of Object.entries(state)) {
|
|
1284
|
+
if (uid === "#freed") {
|
|
1285
|
+
for (const gone of value ?? []) {
|
|
1286
|
+
const node = root.getNodeByUid(gone);
|
|
1287
|
+
if (node) {
|
|
1288
|
+
node.queueFree();
|
|
1289
|
+
report.freed += 1;
|
|
1198
1290
|
}
|
|
1199
|
-
for (const m of set) return m;
|
|
1200
1291
|
}
|
|
1201
|
-
|
|
1202
|
-
collectByName(this.getRoot(), parsed.name, matches);
|
|
1203
|
-
if (matches.length > 1) throw new IncantoError("DUPLICATE_UNIQUE_NAME", `'%${parsed.name}' is ambiguous: ${matches.length} nodes named '${parsed.name}' (${matches.map((m) => m.getPath()).join(", ")}).`);
|
|
1204
|
-
return matches[0] ?? null;
|
|
1292
|
+
continue;
|
|
1205
1293
|
}
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
if (segments[0] !== root._name && segments[0] !== "root") return null;
|
|
1211
|
-
current = root;
|
|
1212
|
-
segments = segments.slice(1);
|
|
1213
|
-
} else current = this;
|
|
1214
|
-
for (const seg of segments) {
|
|
1215
|
-
if (!current) return null;
|
|
1216
|
-
if (seg === "..") {
|
|
1217
|
-
current = current._parent;
|
|
1218
|
-
continue;
|
|
1219
|
-
}
|
|
1220
|
-
current = current._children.find((c) => c._name === seg) ?? null;
|
|
1294
|
+
const node = root.getNodeByUid(uid);
|
|
1295
|
+
if (!node) {
|
|
1296
|
+
report.missing.push(uid);
|
|
1297
|
+
continue;
|
|
1221
1298
|
}
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
}
|
|
1227
|
-
removeFromGroup(group) {
|
|
1228
|
-
this._groups.delete(group);
|
|
1229
|
-
}
|
|
1230
|
-
isInGroup(group) {
|
|
1231
|
-
return this._groups.has(group);
|
|
1232
|
-
}
|
|
1233
|
-
declaredSignals() {
|
|
1234
|
-
if (!this._declared) {
|
|
1235
|
-
this._declared = /* @__PURE__ */ new Set();
|
|
1236
|
-
let ctor = this.constructor;
|
|
1237
|
-
while (typeof ctor === "function") {
|
|
1238
|
-
const own = ctor;
|
|
1239
|
-
if (Object.hasOwn(ctor, "signals") && own.signals) for (const s of own.signals) this._declared.add(s);
|
|
1240
|
-
ctor = Object.getPrototypeOf(ctor);
|
|
1241
|
-
}
|
|
1299
|
+
const behavior = node.behavior;
|
|
1300
|
+
if (!behavior || typeof behavior.deserialize !== "function") {
|
|
1301
|
+
report.skipped.push(uid);
|
|
1302
|
+
continue;
|
|
1242
1303
|
}
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
}
|
|
1249
|
-
/** Every signal this instance may emit (static + behavior + ad-hoc). */
|
|
1250
|
-
declaredSignalNames() {
|
|
1251
|
-
return [...this.declaredSignals()];
|
|
1252
|
-
}
|
|
1253
|
-
assertDeclared(name) {
|
|
1254
|
-
if (this.declaredSignals().has(name)) return;
|
|
1255
|
-
const ctor = this.constructor;
|
|
1256
|
-
const declared = [...this.declaredSignals()];
|
|
1257
|
-
throw new IncantoError("UNKNOWN_SIGNAL", `Unknown signal '${name}' on '${this.getPath()}' (${ctor.typeName}). Declared signals: [${declared.join(", ")}]. Declare it with "static signals = ['${name}']" on the node class or its behavior, or call node.declareSignal('${name}').`, {
|
|
1258
|
-
signal: name,
|
|
1259
|
-
path: this.getPath(),
|
|
1260
|
-
nodeType: ctor.typeName,
|
|
1261
|
-
validOptions: declared
|
|
1262
|
-
});
|
|
1263
|
-
}
|
|
1264
|
-
/** Get the named DECLARED signal (creating its Signal object on demand). */
|
|
1265
|
-
signal(name) {
|
|
1266
|
-
this.assertDeclared(name);
|
|
1267
|
-
let sig = this._signals.get(name);
|
|
1268
|
-
if (!sig) {
|
|
1269
|
-
sig = new Signal();
|
|
1270
|
-
this._signals.set(name, sig);
|
|
1304
|
+
try {
|
|
1305
|
+
behavior.deserialize(value);
|
|
1306
|
+
report.restored += 1;
|
|
1307
|
+
} catch {
|
|
1308
|
+
report.skipped.push(uid);
|
|
1271
1309
|
}
|
|
1272
|
-
return sig;
|
|
1273
|
-
}
|
|
1274
|
-
on(signal, fn, opts) {
|
|
1275
|
-
return this.signal(signal).connect(fn, opts);
|
|
1276
|
-
}
|
|
1277
|
-
off(signal, fn) {
|
|
1278
|
-
this._signals.get(signal)?.disconnect(fn);
|
|
1279
|
-
}
|
|
1280
|
-
emit(signal, ...args) {
|
|
1281
|
-
this.assertDeclared(signal);
|
|
1282
|
-
this._signals.get(signal)?.emit(...args);
|
|
1283
|
-
}
|
|
1284
|
-
/**
|
|
1285
|
-
* Defer destruction to the end of the current update pass (flushed by the
|
|
1286
|
-
* SceneTree). Frees immediately when detached from any tree.
|
|
1287
|
-
*/
|
|
1288
|
-
queueFree() {
|
|
1289
|
-
if (this._tree) this._tree._queueFree(this);
|
|
1290
|
-
else this.free();
|
|
1291
|
-
}
|
|
1292
|
-
/** Immediately detach and tear down this node and its children. */
|
|
1293
|
-
free() {
|
|
1294
|
-
const tree = this._tree;
|
|
1295
|
-
for (const child of [...this._children]) child.free();
|
|
1296
|
-
this._parent?.removeChild(this);
|
|
1297
|
-
if (this._tree) this._propagateExitTree();
|
|
1298
|
-
tree?._detachRoot(this);
|
|
1299
|
-
for (const sig of this._signals.values()) sig.disconnectAll();
|
|
1300
|
-
this._signals.clear();
|
|
1301
|
-
}
|
|
1302
|
-
/** @internal */
|
|
1303
|
-
_propagateEnterTree(tree) {
|
|
1304
|
-
this._tree = tree;
|
|
1305
|
-
tree._indexName(this);
|
|
1306
|
-
this.onEnterTree();
|
|
1307
|
-
this.behavior?.onEnterTree?.();
|
|
1308
|
-
for (const c of [...this._children]) if (c._tree !== tree) c._propagateEnterTree(tree);
|
|
1309
1310
|
}
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1311
|
+
return report;
|
|
1312
|
+
}
|
|
1313
|
+
/** Slot ids, so a "3 save slots" menu is a list and not a naming convention. */
|
|
1314
|
+
const INDEX_KEY = "slots";
|
|
1315
|
+
/**
|
|
1316
|
+
* The slot layer: a named list of saves over the existing store.
|
|
1317
|
+
*
|
|
1318
|
+
* Slot ids are yours (`'auto'`, `'1'`, `'2'`, `'3'`) — the engine does not
|
|
1319
|
+
* decide how many you have or whether one of them autosaves.
|
|
1320
|
+
*/
|
|
1321
|
+
var SaveSlots = class {
|
|
1322
|
+
store;
|
|
1323
|
+
constructor(namespace = "saves", store) {
|
|
1324
|
+
this.store = store ?? createSaveStore(namespace);
|
|
1318
1325
|
}
|
|
1319
|
-
/**
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
+
/** Slot ids that have a save, newest first. */
|
|
1327
|
+
list() {
|
|
1328
|
+
const rows = this.store.get(INDEX_KEY, []).map((id) => ({
|
|
1329
|
+
id,
|
|
1330
|
+
slot: this.read(id)
|
|
1331
|
+
})).filter((row) => row.slot !== null);
|
|
1332
|
+
rows.sort((a, b) => b.slot.savedAt - a.slot.savedAt);
|
|
1333
|
+
return rows.map((row) => row.id);
|
|
1326
1334
|
}
|
|
1327
|
-
/**
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
}
|
|
1334
|
-
if (this.behavior && !this.behaviorErrored) try {
|
|
1335
|
-
this.behavior.update?.(dt);
|
|
1336
|
-
} catch (error) {
|
|
1337
|
-
this._quarantine("update", error, "behavior");
|
|
1338
|
-
}
|
|
1339
|
-
const kids = this._children;
|
|
1340
|
-
for (let i = 0, n = kids.length; i < n; i++) kids[i]?._propagateUpdate(dt);
|
|
1335
|
+
/** Every slot with its contents — what a load menu actually renders. */
|
|
1336
|
+
all() {
|
|
1337
|
+
return this.list().map((id) => ({
|
|
1338
|
+
id,
|
|
1339
|
+
...this.read(id)
|
|
1340
|
+
}));
|
|
1341
1341
|
}
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
if (
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
for (let i = 0, n = kids.length; i < n; i++) kids[i]?._propagateFixedUpdate(dt);
|
|
1342
|
+
read(id) {
|
|
1343
|
+
const raw = this.store.get(key(id), null);
|
|
1344
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
1345
|
+
const slot = raw;
|
|
1346
|
+
if (typeof slot.scene !== "string" || typeof slot.state !== "object" || slot.state === null) return null;
|
|
1347
|
+
return {
|
|
1348
|
+
scene: slot.scene,
|
|
1349
|
+
state: slot.state,
|
|
1350
|
+
label: typeof slot.label === "string" ? slot.label : "",
|
|
1351
|
+
savedAt: typeof slot.savedAt === "number" ? slot.savedAt : 0,
|
|
1352
|
+
playtime: typeof slot.playtime === "number" ? slot.playtime : 0,
|
|
1353
|
+
data: slot.data ?? {}
|
|
1354
|
+
};
|
|
1356
1355
|
}
|
|
1357
1356
|
/**
|
|
1358
|
-
*
|
|
1357
|
+
* Write a slot.
|
|
1359
1358
|
*
|
|
1360
|
-
*
|
|
1361
|
-
*
|
|
1362
|
-
* every channel except a human with devtools open.
|
|
1359
|
+
* `savedAt` is a parameter rather than `Date.now()` so a test can assert an
|
|
1360
|
+
* ordering without sleeping, and so a game that wants server time can use it.
|
|
1363
1361
|
*/
|
|
1364
|
-
|
|
1365
|
-
|
|
1362
|
+
write(id, slot) {
|
|
1363
|
+
const full = {
|
|
1364
|
+
scene: slot.scene,
|
|
1365
|
+
state: slot.state,
|
|
1366
|
+
label: slot.label ?? "",
|
|
1367
|
+
savedAt: slot.savedAt ?? 0,
|
|
1368
|
+
playtime: slot.playtime ?? 0,
|
|
1369
|
+
data: slot.data ?? {}
|
|
1370
|
+
};
|
|
1371
|
+
this.store.set(key(id), full);
|
|
1372
|
+
const ids = this.store.get(INDEX_KEY, []);
|
|
1373
|
+
if (!ids.includes(id)) this.store.set(INDEX_KEY, [...ids, id]);
|
|
1366
1374
|
}
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
*
|
|
1376
|
-
* Set it back to false to try the node again (the debug overlay's "resume").
|
|
1377
|
-
*/
|
|
1378
|
-
errored = false;
|
|
1379
|
-
/**
|
|
1380
|
-
* This node's SCRIPT threw and is being skipped — the node itself still runs.
|
|
1381
|
-
*
|
|
1382
|
-
* A behavior is your code; the node is the engine's. A typo in the first must
|
|
1383
|
-
* not switch off the second, or breaking a sword script stops the character
|
|
1384
|
-
* from walking.
|
|
1385
|
-
*/
|
|
1386
|
-
behaviorErrored = false;
|
|
1387
|
-
/**
|
|
1388
|
-
* Report ONCE and stop running this node's own update.
|
|
1389
|
-
*
|
|
1390
|
-
* Once, because a behavior that throws throws every frame: sixty identical
|
|
1391
|
-
* stack traces a second buries the one line that matters and costs more than
|
|
1392
|
-
* the game did.
|
|
1393
|
-
*/
|
|
1394
|
-
_quarantine(phase, error, what) {
|
|
1395
|
-
const where = `${this.getPath()} (${this.constructor.typeName ?? "Node"})`;
|
|
1396
|
-
let message;
|
|
1397
|
-
if (what === "behavior") {
|
|
1398
|
-
this.behaviorErrored = true;
|
|
1399
|
-
message = `[incanto] behavior '${this.behavior?.constructor.name ?? "?"}' on ${where} threw in ${phase} — THE SCRIPT is now skipped; the node itself and the rest of the scene keep running.`;
|
|
1400
|
-
} else {
|
|
1401
|
-
this.errored = true;
|
|
1402
|
-
message = `[incanto] ${where} threw in ${phase} — this node is now SKIPPED. Its children and the rest of the scene keep running.`;
|
|
1403
|
-
}
|
|
1404
|
-
const engine = this._tree?.engine;
|
|
1405
|
-
engine?.log.error(message, error);
|
|
1406
|
-
engine?._nodeErrored(this);
|
|
1407
|
-
console.error(message, error);
|
|
1375
|
+
remove(id) {
|
|
1376
|
+
this.store.remove(key(id));
|
|
1377
|
+
this.store.set(INDEX_KEY, this.store.get(INDEX_KEY, []).filter((other) => other !== id));
|
|
1378
|
+
}
|
|
1379
|
+
/** Wipe every slot — a "delete all data" button, and test isolation. */
|
|
1380
|
+
clear() {
|
|
1381
|
+
for (const id of this.store.get(INDEX_KEY, [])) this.store.remove(key(id));
|
|
1382
|
+
this.store.set(INDEX_KEY, []);
|
|
1408
1383
|
}
|
|
1409
|
-
onEnterTree() {}
|
|
1410
|
-
onReady() {}
|
|
1411
|
-
onExitTree() {}
|
|
1412
|
-
update(_dt) {}
|
|
1413
|
-
fixedUpdate(_dt) {}
|
|
1414
1384
|
};
|
|
1415
|
-
function
|
|
1416
|
-
|
|
1417
|
-
for (const c of node.children) collectByName(c, name, out);
|
|
1385
|
+
function key(id) {
|
|
1386
|
+
return `slot:${id}`;
|
|
1418
1387
|
}
|
|
1419
1388
|
/**
|
|
1420
|
-
*
|
|
1421
|
-
*
|
|
1389
|
+
* Behaviors in this tree that hold state and cannot save it — the audit's
|
|
1390
|
+
* question, answerable before a player loses an hour.
|
|
1391
|
+
*
|
|
1392
|
+
* A behavior with props but no `serialize` is not necessarily wrong (an
|
|
1393
|
+
* `Oscillate` derives everything from time), so this reports rather than warns,
|
|
1394
|
+
* and the caller decides.
|
|
1422
1395
|
*/
|
|
1423
|
-
function
|
|
1424
|
-
const
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1396
|
+
function behaviorsWithoutSave(root) {
|
|
1397
|
+
const out = [];
|
|
1398
|
+
const walk = (node) => {
|
|
1399
|
+
const behavior = node.behavior;
|
|
1400
|
+
const name = node.script?.name;
|
|
1401
|
+
if (behavior && typeof name === "string" && typeof behavior.serialize !== "function") {
|
|
1402
|
+
if (Object.keys(behaviorSchema(name)).length > 0) out.push(`${node.getPath()} (${name})`);
|
|
1403
|
+
}
|
|
1404
|
+
for (const child of node.children) walk(child);
|
|
1405
|
+
};
|
|
1406
|
+
walk(root);
|
|
1407
|
+
return out;
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* Behaviors that DO save, on nodes with no uid to save them under — the state
|
|
1411
|
+
* that goes nowhere.
|
|
1412
|
+
*
|
|
1413
|
+
* `incanto-check` warns about the built-ins it can recognise from the JSON
|
|
1414
|
+
* (Health, ScoreKeeper, Collector), but a scene file cannot be asked whether
|
|
1415
|
+
* YOUR behavior has a `serialize`. This can: it walks the live tree, so a quest
|
|
1416
|
+
* log or a wallet of your own is named too.
|
|
1417
|
+
*
|
|
1418
|
+
* The pair to read before shipping: `behaviorsWithoutSave` is "did you forget
|
|
1419
|
+
* to write serialize", this is "did you forget the uid that makes it count".
|
|
1420
|
+
*/
|
|
1421
|
+
function savesWithoutUid(root) {
|
|
1422
|
+
const out = [];
|
|
1423
|
+
const walk = (node) => {
|
|
1424
|
+
const behavior = node.behavior;
|
|
1425
|
+
const name = node.script?.name;
|
|
1426
|
+
if (behavior && typeof behavior.serialize === "function" && !node.uid) out.push(`${node.getPath()} (${name ?? "behavior"})`);
|
|
1427
|
+
for (const child of node.children) walk(child);
|
|
1428
|
+
};
|
|
1429
|
+
walk(root);
|
|
1430
|
+
return out;
|
|
1431
1431
|
}
|
|
1432
1432
|
//#endregion
|
|
1433
1433
|
//#region src/core/scene/constants.ts
|
|
@@ -2066,4 +2066,4 @@ function validateUniqueUids(root) {
|
|
|
2066
2066
|
walk(root);
|
|
2067
2067
|
}
|
|
2068
2068
|
//#endregion
|
|
2069
|
-
export { registerBehavior as A, diagnose as C, behaviorSignals as D, behaviorSchema as E, Signal as M, clearBehaviors as O,
|
|
2069
|
+
export { registerBehavior as A, diagnose as C, behaviorSignals as D, behaviorSchema as E, Signal as M, clearBehaviors as O, Node as S, Behavior as T, createSaveStore as _, SCENE_FORMAT as a, resolveOrderGroups as b, SceneTree as c, resolveConstants as d, SaveSlots as f, savesWithoutUid as g, restoreBehaviors as h, serializeNode as i, registeredBehaviors as j, getBehavior as k, CONST_REF_KEY as l, captureBehaviors as m, loadScene as n, computeViewport as o, behaviorsWithoutSave as p, Scene as r, resolveViewport as s, buildNodeJson as t, isConstRef as u, ORDER_GROUP_BASE as v, parseNodePath as w, InputMap as x, effectiveOrder as y };
|