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.
Files changed (61) hide show
  1. package/bin/incanto-verify.mjs +9 -2
  2. package/dist/2d.d.ts +59 -7
  3. package/dist/2d.js +3 -3
  4. package/dist/3d.d.ts +34 -4
  5. package/dist/3d.js +5 -5
  6. package/dist/{behavior-CyQoSu4n.d.ts → behavior-l08AEbq9.d.ts} +5 -0
  7. package/dist/{create-game-CzK9_pzg.js → create-game-C5jQYPah.js} +28 -71
  8. package/dist/{create-game-BRt6XKmP.js → create-game-DpbUrMOQ.js} +146 -7
  9. package/dist/debug.d.ts +1 -1
  10. package/dist/{duplicate-MNLMAcbz.js → duplicate-BPLZDZpd.js} +1 -1
  11. package/dist/{environment-presets-DAbEdEwh.js → environment-presets-CvvQr_bJ.js} +118 -24
  12. package/dist/{frame-report-njybhZon.js → frame-report-BSMny7oe.js} +1 -1
  13. package/dist/{frame-report-DZ70IY26.d.ts → frame-report-DCnHFmto.d.ts} +1 -1
  14. package/dist/{gameplay-CZ2yq37J.js → gameplay-BVphcxmE.js} +163 -14
  15. package/dist/gameplay.d.ts +81 -2
  16. package/dist/gameplay.js +2 -2
  17. package/dist/index.d.ts +45 -5
  18. package/dist/index.js +8 -9
  19. package/dist/{loader-DhI1jFW_.d.ts → loader-BbEMTuWg.d.ts} +1 -1
  20. package/dist/{loader-BTkHYrQn.js → loader-BcrRSjxB.js} +682 -682
  21. package/dist/net.d.ts +2 -2
  22. package/dist/net.js +1 -1
  23. package/dist/{pathfinding-CXGCpRQe.d.ts → pathfinding-mEN4V1CU.d.ts} +1 -1
  24. package/dist/{physics-2d-CfWAggJ1.js → physics-2d-BLcvEFDR.js} +2 -2
  25. package/dist/{physics-3d-C_ZJ6f_d.js → physics-3d-QBrfIT2Y.js} +3 -3
  26. package/dist/react.d.ts +1 -1
  27. package/dist/react.js +1 -1
  28. package/dist/{register-en63AEZO.js → register-C6ZBFRjd.js} +63 -60
  29. package/dist/{register-p48lHE2o.js → register-Ch70uByv.js} +1615 -1502
  30. package/dist/{replay-ePMz26jw.d.ts → replay-Dw6gMlYA.d.ts} +1 -1
  31. package/dist/{replay-t1pP0gQg.js → replay-s7I2GstT.js} +31 -11
  32. package/dist/sheet-grid-BT6N_Bjs.js +59 -0
  33. package/dist/{split-screen-BsdOHbzP.d.ts → split-screen-B0baBwxI.d.ts} +5 -1
  34. package/dist/{split-screen-CSb_uZ6W.js → split-screen-DLsUrleX.js} +14 -3
  35. package/dist/{sprite-animation-7qvUxF6Z.js → sprite-animation-C0wXLBZJ.js} +8 -4
  36. package/dist/{src-BVOVHRL0.js → src-CGjmPw65.js} +55 -2
  37. package/dist/{teardown-C7uVSJvx.js → teardown-Cs113S9F.js} +74 -2
  38. package/dist/{test-Ca5eqELC.js → test-it1VekWs.js} +28 -19
  39. package/dist/test.d.ts +4 -4
  40. package/dist/test.js +2 -2
  41. package/dist/{touch-BnMyy9tr.js → touch-DESwnpOc.js} +173 -1
  42. package/dist/vite.d.ts +1 -1
  43. package/dist/vite.js +3 -3
  44. package/editor/assets/{agent8-Cz4oHPtm.js → agent8-CGT7r3Mb.js} +1 -1
  45. package/editor/assets/{debug-r-Fi9nZh.js → debug-BxWSIHG3.js} +1 -1
  46. package/editor/assets/{index-BfqmvXYD.js → index-CV1m-aX5.js} +62 -62
  47. package/editor/index.html +1 -1
  48. package/package.json +1 -1
  49. package/skills/incanto-assets.md +5 -1
  50. package/skills/incanto-audio.md +7 -1
  51. package/skills/incanto-building-3d-games.md +18 -5
  52. package/skills/incanto-gameplay-behaviors.md +21 -1
  53. package/skills/incanto-hud.md +6 -0
  54. package/skills/incanto-performance.md +5 -0
  55. package/skills/incanto-physics-and-input.md +14 -0
  56. package/skills/incanto-save-slots.md +2 -1
  57. package/skills/incanto-verifying-your-game.md +4 -0
  58. package/templates-app/beacon-isle-3d/package.json +1 -1
  59. package/templates-app/tps-3d/package.json +1 -1
  60. package/templates-app/village-quest-3d/package.json +1 -1
  61. 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/input.ts
177
- /** Keys typed into editable elements belong to the page UI, not the game. */
178
- function isEditableTarget(target) {
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
- * Declarative input from scene JSON `input{}` DOM-free logic (feed key state
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
- * Convention: vector2 follows the 2D y-down space (up = -y). `justPressed` /
187
- * `justReleased` are one-frame edges, settled by the Engine each tick.
183
+ * Pure data + tree structure: no rendering, no DOM, no three.js renderer
184
+ * adapters subscribe from the outside.
188
185
  */
189
- var InputMap = class {
190
- actions = /* @__PURE__ */ new Map();
191
- down = /* @__PURE__ */ new Set();
192
- pressedEdge = /* @__PURE__ */ new Set();
193
- releasedEdge = /* @__PURE__ */ new Set();
194
- detach = null;
195
- /** Load (or extend with) scene-JSON action declarations. Hard-validates shape. */
196
- declare(decls) {
197
- for (const [name, action] of parseInputDecls(decls)) this.actions.set(name, action);
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
- * Where the pointer is, in CANVAS pixels (top-left origin), or null when it
201
- * has never been over the canvas.
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
- pointerPosition() {
209
- return this.pointerPos ? { ...this.pointerPos } : null;
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
- * @internal Feed a pointer position. `target` scales client → canvas pixels
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
- pointerPos = null;
230
- /** Feed a key state change (code = KeyboardEvent.code, e.g. 'Space', 'KeyW'). */
231
- handleKey(code, isDown) {
232
- if (isDown) {
233
- if (!this.down.has(code)) {
234
- this.down.add(code);
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
- injectedDown = /* @__PURE__ */ new Set();
243
- injectedPressed = /* @__PURE__ */ new Set();
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
- pressAction(action) {
267
- this.button(action);
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
- /** Release an injected button action (yields one justReleased frame). */
274
- releaseAction(action) {
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
- * Feed an analog direction for a vector2 ACTION — virtual joysticks and
280
- * scripted runs. Persists until replaced; (0, 0) clears the injection.
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
- * Actions the scene wants on-screen touch controls for, in declaration
294
- * order (`"touch": "joystick"` on vector2, `"touch": "button"` on buttons).
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
- /** Drop all injected action state (held buttons, vectors, pending edges). */
306
- clearInjected() {
307
- this.injectedDown.clear();
308
- this.injectedPressed.clear();
309
- this.injectedReleased.clear();
310
- this.injectedVectors.clear();
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
- dx = 0;
313
- dy = 0;
314
- wheel = 0;
315
- /** Mouse buttons feed the same code space as keys: Mouse0/Mouse1/Mouse2. */
316
- handleMouseButton(button, isDown) {
317
- this.handleKey(`Mouse${button}`, isDown);
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
- /** Accumulate look deltas (movementX/Y under pointer lock, else move deltas). */
320
- handlePointerMove(dx, dy) {
321
- this.dx += dx;
322
- this.dy += dy;
261
+ reparent(newParent) {
262
+ this._parent?.removeChild(this);
263
+ newParent.addChild(this);
323
264
  }
324
- handleWheel(deltaY) {
325
- this.wheel += deltaY;
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
- /** Drain the accumulated pointer delta (read once per frame). */
328
- pointerDelta() {
329
- const d = {
330
- x: this.dx,
331
- y: this.dy
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.dx = 0;
334
- this.dy = 0;
335
- return d;
299
+ walk(this);
300
+ return out;
336
301
  }
337
- /** Drain the accumulated wheel delta. */
338
- wheelDelta() {
339
- const w = this.wheel;
340
- this.wheel = 0;
341
- return w;
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
- * Base class of everything in an Incanto scene (Godot's Node model).
1265
+ * Hand each behavior its state back.
1054
1266
  *
1055
- * Pure data + tree structure: no rendering, no DOM, no three.js renderer
1056
- * adapters subscribe from the outside.
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
- var Node = class {
1059
- static typeName = "Node";
1060
- _name;
1061
- _parent = null;
1062
- _children = [];
1063
- _groups = /* @__PURE__ */ new Set();
1064
- _signals = /* @__PURE__ */ new Map();
1065
- _declared = null;
1066
- _tree = null;
1067
- _ready = false;
1068
- /**
1069
- * Optional STABLE identifier (scene JSON `uid`) — unlike names (unique only
1070
- * among siblings) a uid is unique across the whole scene, so scripts and
1071
- * tools can address a node no matter where it moves.
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
- const matches = [];
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
- let current;
1207
- let segments = parsed.segments;
1208
- if (parsed.kind === "absolute") {
1209
- const root = this.getRoot();
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
- return current;
1223
- }
1224
- addToGroup(group) {
1225
- this._groups.add(group);
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
- return this._declared;
1244
- }
1245
- /** Declare an ad-hoc signal on THIS instance (static `signals` covers types). */
1246
- declareSignal(name) {
1247
- this.declaredSignals().add(name);
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
- /** @internal */
1311
- _propagateReady() {
1312
- for (const c of [...this._children]) if (c._tree) c._propagateReady();
1313
- if (!this._ready) {
1314
- this._ready = true;
1315
- this.onReady();
1316
- this.behavior?.onReady?.();
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
- /** @internal */
1320
- _propagateExitTree() {
1321
- for (const c of [...this._children]) c._propagateExitTree();
1322
- this.onExitTree();
1323
- this.behavior?.onExitTree?.();
1324
- this._tree?._unindexName(this);
1325
- this._tree = null;
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
- /** @internal */
1328
- _propagateUpdate(dt) {
1329
- if (!this.errored) try {
1330
- this.update(dt);
1331
- } catch (error) {
1332
- this._quarantine("update", error, "node");
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
- /** @internal */
1343
- _propagateFixedUpdate(dt) {
1344
- if (!this.errored) try {
1345
- this.fixedUpdate(dt);
1346
- } catch (error) {
1347
- this._quarantine("fixedUpdate", error, "node");
1348
- }
1349
- if (this.behavior && !this.behaviorErrored) try {
1350
- this.behavior.fixedUpdate?.(dt);
1351
- } catch (error) {
1352
- this._quarantine("fixedUpdate", error, "behavior");
1353
- }
1354
- const kids = this._children;
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
- * Report an engine-level problem about THIS node.
1357
+ * Write a slot.
1359
1358
  *
1360
- * Goes to `engine.log` (what the debug overlay, `runScript()` and any agent
1361
- * tool read) as well as the console. A `console.warn` alone is invisible to
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
- diagnose(level, ...parts) {
1365
- diagnose(this._tree?.engine ?? null, level, ...parts);
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
- * This node threw and is being skipped. Its CHILDREN still run.
1369
- *
1370
- * The alternative is what used to happen: the exception escaped into the rAF
1371
- * callback, the loop was never rescheduled, and the game froze — permanently,
1372
- * silently, with `stats().running` still reporting true. In a vibe-coded
1373
- * project the least-tested code in the repo is a behavior's `update`, so this
1374
- * is not an edge case; it is Tuesday.
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 collectByName(node, name, out) {
1416
- if (node.name === name) out.push(node);
1417
- for (const c of node.children) collectByName(c, name, out);
1385
+ function key(id) {
1386
+ return `slot:${id}`;
1418
1387
  }
1419
1388
  /**
1420
- * Deterministic sibling auto-rename: keep the requested name when free,
1421
- * otherwise increment its trailing number ('Enemy' 'Enemy2', 'Enemy2' → 'Enemy3').
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 uniqueSiblingName(requested, siblings, self) {
1424
- const taken = new Set(siblings.filter((s) => s !== self).map((s) => s.name));
1425
- if (!taken.has(requested)) return requested;
1426
- const m = requested.match(/^(.*?)(\d+)$/);
1427
- const stem = m ? m[1] : requested;
1428
- let counter = m ? Number(m[2]) + 1 : 2;
1429
- while (taken.has(`${stem}${counter}`)) counter += 1;
1430
- return `${stem}${counter}`;
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, InputMap as S, Behavior as T, savesWithoutUid as _, SCENE_FORMAT as a, effectiveOrder as b, SceneTree as c, resolveConstants as d, Node as f, restoreBehaviors as g, captureBehaviors as h, serializeNode as i, registeredBehaviors as j, getBehavior as k, CONST_REF_KEY as l, behaviorsWithoutSave as m, loadScene as n, computeViewport as o, SaveSlots as p, Scene as r, resolveViewport as s, buildNodeJson as t, isConstRef as u, createSaveStore as v, parseNodePath as w, resolveOrderGroups as x, ORDER_GROUP_BASE as y };
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 };