incanto 0.69.0 → 0.71.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 (147) hide show
  1. package/bin/_behaviors-loader.mjs +22 -0
  2. package/bin/_read-json.mjs +28 -0
  3. package/bin/incanto-assets.mjs +5 -4
  4. package/bin/incanto-check.mjs +58 -31
  5. package/bin/incanto-editor.mjs +128 -5
  6. package/bin/incanto-env.mjs +3 -2
  7. package/bin/incanto-feel.mjs +24 -13
  8. package/bin/incanto-frame.mjs +8 -1
  9. package/bin/incanto-multiplay.mjs +11 -9
  10. package/bin/incanto-new.mjs +105 -10
  11. package/bin/incanto-play.mjs +158 -16
  12. package/bin/incanto-playtest.mjs +66 -23
  13. package/bin/incanto-skills.mjs +14 -2
  14. package/bin/incanto-verify.mjs +121 -102
  15. package/bin/incanto.mjs +3 -2
  16. package/dist/2d.d.ts +296 -34
  17. package/dist/2d.js +4 -4
  18. package/dist/3d.d.ts +157 -9
  19. package/dist/3d.js +8 -8
  20. package/dist/{agent8-CvsfVskX.js → agent8-Cfmd3ar_.js} +61 -8
  21. package/dist/{audio-player-C0j2k-yB.d.ts → audio-player-L8yccdMP.d.ts} +2 -2
  22. package/dist/{behavior-rm8vVzu_.d.ts → behavior-Dcz0fr1S.d.ts} +755 -46
  23. package/dist/{create-game-CwxEDlCm.js → create-game-CH4kt78v.js} +143 -60
  24. package/dist/{create-game-CvJ1wSRP.js → create-game-LCHBLfA4.js} +98 -48
  25. package/dist/debug.d.ts +1 -1
  26. package/dist/debug.js +2 -3
  27. package/dist/diagnostics-Cu85N3tL.d.ts +12 -0
  28. package/dist/{editor-switch-CnIOiyNJ.d.ts → editor-switch-CFU9mCec.d.ts} +22 -13
  29. package/dist/editor.js +1088 -882
  30. package/dist/env.d.ts +1 -1
  31. package/dist/env.js +5 -3
  32. package/dist/{environment-presets-BO-7XoFV.js → environment-presets-99BzP_L-.js} +298 -45
  33. package/dist/{frame-report-Dlq13Gyj.js → frame-report-D-_7YF2G.js} +1 -1
  34. package/dist/{gameplay-D1KixWK8.js → gameplay-CNULJvwh.js} +914 -214
  35. package/dist/gameplay.d.ts +254 -10
  36. package/dist/gameplay.js +2 -2
  37. package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
  38. package/dist/index.d.ts +179 -13
  39. package/dist/index.js +10 -12
  40. package/dist/json-CfTjpvW8.js +67 -0
  41. package/dist/{loader-DadV2QCT.d.ts → loader-8-IHvbKD.d.ts} +2 -2
  42. package/dist/net.d.ts +27 -3
  43. package/dist/net.js +2 -2
  44. package/dist/{noise-CGUMx44x.js → noise-D3nPpmFg.js} +1 -1
  45. package/dist/{physics-2d-CF5yyjbj.js → physics-2d-FhrXUc6B.js} +136 -11
  46. package/dist/{physics-3d-DbYaEcZy.js → physics-3d-CFEGxBXZ.js} +104 -23
  47. package/dist/{teardown-CMqFVxO9.js → picking-DVo7fI13.js} +104 -13
  48. package/dist/react.d.ts +2 -2
  49. package/dist/react.js +2 -2
  50. package/dist/{register-DbVArsns.js → register-BYQCBySi.js} +429 -28
  51. package/dist/{register-mmfugpaQ.js → register-DuVdzxbq.js} +594 -831
  52. package/dist/{replay-BEHLDcQV.js → replay-DEvp3kyV.js} +224 -14
  53. package/dist/{replay-BQfkX4x7.d.ts → replay-F7IZHdFR.d.ts} +20 -3
  54. package/dist/{schema-B6ugCV1Q.d.ts → rng-Bb-IutXB.d.ts} +38 -21
  55. package/dist/{rng-DP-SR7eg.js → rng-CDOMybym.js} +22 -0
  56. package/dist/{loader-DZE7B07H.js → save-slots-Bvuh2p_r.js} +4619 -2318
  57. package/dist/{split-screen-Cg76MCV5.d.ts → split-screen-CIYf1zSf.d.ts} +36 -4
  58. package/dist/{split-screen-CauMwu6y.js → split-screen-eULetcg-.js} +159 -26
  59. package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
  60. package/dist/{src-CYEu-_S9.js → src-_jk0qLsS.js} +2 -17
  61. package/dist/test-CmN54kWv.js +4264 -0
  62. package/dist/test.d.ts +639 -30
  63. package/dist/test.js +3 -3
  64. package/dist/touch-BnCyPA0G.js +519 -0
  65. package/dist/vite.d.ts +20 -37
  66. package/dist/vite.js +177 -7
  67. package/dist/{webgl-unavailable-Z5_g8x9H.js → webgl-unavailable-C8aDbGmR.js} +44 -0
  68. package/editor/assets/agent8-yLIEYHbd.js +1 -0
  69. package/editor/assets/{debug-CMULinBW.js → debug-u31w_yhq.js} +2 -2
  70. package/editor/assets/index-DK9xMGpW.js +11046 -0
  71. package/editor/index.html +1 -1
  72. package/package.json +4 -14
  73. package/schemas/scene.schema.json +34 -3
  74. package/skills/incanto-3d-character.md +14 -1
  75. package/skills/incanto-3d-models.md +12 -0
  76. package/skills/incanto-assets.md +9 -1
  77. package/skills/incanto-audio.md +8 -2
  78. package/skills/incanto-behaviors-and-scripts.md +105 -7
  79. package/skills/incanto-building-2d-games.md +97 -10
  80. package/skills/incanto-building-3d-games.md +58 -4
  81. package/skills/incanto-editor.md +46 -7
  82. package/skills/incanto-environment.md +19 -1
  83. package/skills/incanto-game-feel.md +70 -0
  84. package/skills/incanto-gameplay-behaviors.md +172 -20
  85. package/skills/incanto-hud.md +142 -7
  86. package/skills/incanto-localization.md +13 -5
  87. package/skills/incanto-multiplayer.md +83 -3
  88. package/skills/incanto-node-reference.md +239 -59
  89. package/skills/incanto-performance.md +52 -0
  90. package/skills/incanto-physics-and-input.md +123 -24
  91. package/skills/incanto-playtesting.md +112 -2
  92. package/skills/incanto-save-slots.md +217 -7
  93. package/skills/incanto-scene-json-authoring.md +69 -12
  94. package/skills/incanto-verifying-your-game.md +207 -10
  95. package/skills/incanto-web-integration.md +28 -0
  96. package/skills/incanto-your-first-game.md +3 -1
  97. package/templates-app/beacon-isle-3d/generate-world.ts +77 -9
  98. package/templates-app/beacon-isle-3d/package.json +2 -2
  99. package/templates-app/beacon-isle-3d/src/behaviors.ts +22 -0
  100. package/templates-app/beacon-isle-3d/src/game.scene.json +143 -600
  101. package/templates-app/beacon-isle-3d/src/main.ts +11 -1
  102. package/templates-app/beacon-isle-3d/tsconfig.json +1 -1
  103. package/templates-app/beacon-isle-3d/verify.ts +3 -1
  104. package/templates-app/molehill-2d/.incanto/playtest/lost-seed1.json +4277 -0
  105. package/templates-app/molehill-2d/PROJECT/Context.md +58 -0
  106. package/templates-app/molehill-2d/PROJECT/Requirements.md +39 -0
  107. package/templates-app/molehill-2d/PROJECT/Status.md +27 -0
  108. package/templates-app/molehill-2d/PROJECT/Structure.md +48 -0
  109. package/templates-app/molehill-2d/docs/project-2d-rules.md +44 -0
  110. package/templates-app/molehill-2d/index.html +73 -0
  111. package/templates-app/molehill-2d/package.json +23 -0
  112. package/templates-app/molehill-2d/src/behaviors.ts +198 -0
  113. package/templates-app/molehill-2d/src/game.scene.json +1205 -0
  114. package/templates-app/molehill-2d/src/main.ts +41 -0
  115. package/templates-app/molehill-2d/tsconfig.json +13 -0
  116. package/templates-app/molehill-2d/verify.ts +247 -0
  117. package/templates-app/molehill-2d/vite.config.ts +12 -0
  118. package/templates-app/platformer-2d/package.json +2 -2
  119. package/templates-app/platformer-2d/src/game.scene.json +178 -986
  120. package/templates-app/platformer-2d/src/main.ts +11 -1
  121. package/templates-app/platformer-2d/tsconfig.json +1 -1
  122. package/templates-app/star-survivor/package.json +2 -2
  123. package/templates-app/star-survivor/src/game.scene.json +43 -216
  124. package/templates-app/star-survivor/src/main.ts +11 -1
  125. package/templates-app/star-survivor/tsconfig.json +1 -1
  126. package/templates-app/tps-3d/PROJECT/Context.md +1 -1
  127. package/templates-app/tps-3d/package.json +2 -2
  128. package/templates-app/tps-3d/src/behaviors.ts +19 -1
  129. package/templates-app/tps-3d/src/game.scene.json +66 -263
  130. package/templates-app/tps-3d/src/main.ts +13 -2
  131. package/templates-app/tps-3d/tsconfig.json +1 -1
  132. package/templates-app/village-quest-3d/.incanto/playtest/swapped-seed1.json +1735 -0
  133. package/templates-app/village-quest-3d/package.json +2 -2
  134. package/templates-app/village-quest-3d/src/behaviors.ts +42 -4
  135. package/templates-app/village-quest-3d/src/grove.scene.json +68 -306
  136. package/templates-app/village-quest-3d/src/main.ts +11 -1
  137. package/templates-app/village-quest-3d/src/village.scene.json +202 -863
  138. package/templates-app/village-quest-3d/tsconfig.json +1 -1
  139. package/templates-app/village-quest-3d/verify.ts +14 -1
  140. package/dist/duplicate-Dj4L-5AA.js +0 -22
  141. package/dist/json-CwwhxQgb.js +0 -36
  142. package/dist/registry-CF70EArN.js +0 -212
  143. package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
  144. package/dist/test-D6ISmuFE.js +0 -2383
  145. package/dist/touch-DEAmqGdf.js +0 -225
  146. package/editor/assets/agent8-BDvaZMQJ.js +0 -1
  147. package/editor/assets/index-CZIRr5EL.js +0 -10958
@@ -1,8 +1,146 @@
1
- import { O as Node, P as Behavior, _ as SaveSlots, k as diagnose, n as loadScene, w as effectiveOrder, z as registerBehavior } from "./loader-DZE7B07H.js";
1
+ import { t as jsonClone } from "./json-CfTjpvW8.js";
2
+ import { B as HudLayer, J as Node, K as requireNumber, P as effectiveOrder, Y as diagnose, d as rebuildOptions, et as Behavior, f as rememberAuthoredPath, h as loadScene, n as SaveSlots, ot as registerBehavior, s as duplicateNode } from "./save-slots-Bvuh2p_r.js";
2
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
3
- import { t as jsonClone } from "./json-CwwhxQgb.js";
4
- import { t as duplicateNode } from "./duplicate-Dj4L-5AA.js";
4
+ import { t as Rng } from "./rng-CDOMybym.js";
5
+ import { h as didYouMean } from "./touch-BnCyPA0G.js";
5
6
  import { Color, CubeCamera, DepthTexture, DoubleSide, Euler, FloatType, Frustum, HalfFloatType, LinearMipmapLinearFilter, MathUtils, Matrix4, Mesh, MeshDepthMaterial, Object3D, PerspectiveCamera, Plane, PlaneGeometry, Quaternion, ShaderMaterial, Sphere, Vector2, Vector3, Vector4, WebGLCubeRenderTarget, WebGLRenderTarget } from "three";
7
+ //#region src/gameplay/spatial.ts
8
+ /** Duck-type test: every spatial node (Node2D/Node3D) exposes `position: number[]`. */
9
+ function hasPosition$1(node) {
10
+ return Array.isArray(node.position);
11
+ }
12
+ /**
13
+ * Movement behaviors are dimension-agnostic — they read/write `this.node.position`
14
+ * directly. Attaching one to a non-spatial node (no `position` array) would be a
15
+ * silent no-op, so assert it at ready (engine philosophy: hard load-time errors).
16
+ */
17
+ function requirePosition(behavior) {
18
+ const node = behavior.node;
19
+ if (!hasPosition$1(node)) {
20
+ const behaviorName = behavior.constructor.name;
21
+ throw new IncantoError("PROP_TYPE_MISMATCH", `${behaviorName} on '${node.getPath()}' needs a spatial node with a numeric 'position' array (a Node2D/Node3D subtype). '${node.name}' has none.`, { path: node.getPath() });
22
+ }
23
+ return node;
24
+ }
25
+ /** Euclidean distance between two position arrays (works for 2D and 3D). */
26
+ function distance$1(a, b) {
27
+ let sum = 0;
28
+ const n = Math.max(a.length, b.length);
29
+ for (let i = 0; i < n; i++) {
30
+ const d = (a[i] ?? 0) - (b[i] ?? 0);
31
+ sum += d * d;
32
+ }
33
+ return Math.sqrt(sum);
34
+ }
35
+ /** Component-wise `a + b` (dimension = max of the two; missing components are 0). */
36
+ function add(a, b) {
37
+ const n = Math.max(a.length, b.length);
38
+ const out = new Array(n);
39
+ for (let i = 0; i < n; i++) out[i] = (a[i] ?? 0) + (b[i] ?? 0);
40
+ return out;
41
+ }
42
+ /** Vector length. */
43
+ function length(v) {
44
+ let sum = 0;
45
+ for (const c of v) sum += c * c;
46
+ return Math.sqrt(sum);
47
+ }
48
+ /** Unit vector (zero-length stays zero). */
49
+ function normalize(v) {
50
+ const len = length(v);
51
+ if (len === 0) return v.map(() => 0);
52
+ return v.map((c) => c / len);
53
+ }
54
+ /**
55
+ * Step `from` toward `to` by at most `maxStep`. Returns the new position and
56
+ * whether the destination was reached this step (clamped — no overshoot).
57
+ */
58
+ function moveToward(from, to, maxStep) {
59
+ const n = Math.max(from.length, to.length);
60
+ let sum = 0;
61
+ for (let i = 0; i < n; i++) {
62
+ const d = (to[i] ?? 0) - (from[i] ?? 0);
63
+ sum += d * d;
64
+ }
65
+ const dist = Math.sqrt(sum);
66
+ const position = new Array(n);
67
+ if (dist <= maxStep || dist === 0) {
68
+ for (let i = 0; i < n; i++) position[i] = to[i] ?? 0;
69
+ return {
70
+ position,
71
+ reached: true
72
+ };
73
+ }
74
+ const f = maxStep / dist;
75
+ for (let i = 0; i < n; i++) {
76
+ const c = from[i] ?? 0;
77
+ position[i] = c + ((to[i] ?? 0) - c) * f;
78
+ }
79
+ return {
80
+ position,
81
+ reached: false
82
+ };
83
+ }
84
+ /**
85
+ * Frame-rate-independent lerp factor for an exponential chase. `smoothing` is a
86
+ * 0..1 retention factor: 0 snaps instantly; higher values chase more slowly.
87
+ * (60fps reference, matching Camera2D's `follow`.)
88
+ */
89
+ function smoothingFactor(smoothing, dt) {
90
+ const s = Math.min(Math.max(smoothing, 0), .99);
91
+ return s <= 0 ? 1 : 1 - s ** (dt * 60);
92
+ }
93
+ /**
94
+ * A node's position with every spatial ancestor's added in.
95
+ *
96
+ * The tree composes transforms for DRAWING — three does it for free — and every
97
+ * piece of engine logic that reads `node.position` gets the LOCAL number
98
+ * instead. Physics already knew (`worldPosition2D`/`worldPosition3D` in the
99
+ * adapters); the camera did not, and neither did anything that chases a target.
100
+ *
101
+ * A camera parented to the player is the Godot and Phaser idiom, and the
102
+ * authoring skill explicitly permits it — *"EVERY node type can hold children …
103
+ * any node works as a grouping container"*. Measured on exactly that scene:
104
+ *
105
+ * ```
106
+ * player world position : [1400, 900]
107
+ * renderer view centre : {"x":0,"y":0}
108
+ * framing : camera /Level/Player/Cam centred [1400, 900]
109
+ * 1 in view, 0 outside it
110
+ * ```
111
+ *
112
+ * The renderer drew empty world 1600 px from the player while `framing` — the
113
+ * instrument the skills tell you to trust before handing a game back —
114
+ * certified the player on screen.
115
+ *
116
+ * Arity comes from the node itself: a 2D node has two components, a 3D node
117
+ * three, and an ancestor that is neither contributes nothing.
118
+ */
119
+ function worldPosition(node) {
120
+ const own = node.position;
121
+ if (!Array.isArray(own)) return [];
122
+ const out = own.slice();
123
+ for (let at = node.parent; at; at = at.parent) {
124
+ const p = at.position;
125
+ if (!Array.isArray(p)) continue;
126
+ for (let i = 0; i < out.length; i++) out[i] = (out[i] ?? 0) + (p[i] ?? 0);
127
+ }
128
+ return out;
129
+ }
130
+ /**
131
+ * Turn a world position into the local one `node.position` has to hold for the
132
+ * node to END UP there — the inverse of `worldPosition` for its parent chain.
133
+ */
134
+ function localFromWorld(node, world) {
135
+ const out = world.slice();
136
+ for (let at = node.parent; at; at = at.parent) {
137
+ const p = at.position;
138
+ if (!Array.isArray(p)) continue;
139
+ for (let i = 0; i < out.length; i++) out[i] = (out[i] ?? 0) - (p[i] ?? 0);
140
+ }
141
+ return out;
142
+ }
143
+ //#endregion
6
144
  //#region src/3d/frustum.ts
7
145
  const scratchFrustum = new Frustum();
8
146
  const scratchMatrix = new Matrix4();
@@ -2204,12 +2342,29 @@ function simpleWaveHeight(x, z, time, waveHeight) {
2204
2342
  }
2205
2343
  //#endregion
2206
2344
  //#region src/3d/physics/collider-validate.ts
2345
+ /** Every key a 3D collider may carry — see the 2D twin for what this closes. */
2346
+ const COLLIDER_KEYS_3D = [
2347
+ "shape",
2348
+ "size",
2349
+ "radius",
2350
+ "height",
2351
+ "offset",
2352
+ "vertices",
2353
+ "indices"
2354
+ ];
2207
2355
  /**
2208
2356
  * Hard-validate a 3D collider prop at LOAD time (see the 2D twin for rationale).
2209
2357
  * An EMPTY collider is allowed — a body without a collider is work in progress.
2210
2358
  */
2211
2359
  function validateCollider3D(collider, nodeName) {
2212
2360
  const shape = collider.shape;
2361
+ for (const key of Object.keys(collider)) {
2362
+ if (COLLIDER_KEYS_3D.includes(key)) continue;
2363
+ throw new IncantoError("BAD_FORMAT", `Collider on '${nodeName}' has an unknown key "${key}".` + didYouMean(key, COLLIDER_KEYS_3D) + ` A collider is [${COLLIDER_KEYS_3D.join(", ")}] — friction, restitution and mass are props of the BODY, not of its collider.`, {
2364
+ prop: key,
2365
+ validOptions: [...COLLIDER_KEYS_3D]
2366
+ });
2367
+ }
2213
2368
  if (shape === void 0) return;
2214
2369
  if (shape === "box") {
2215
2370
  const size = collider.size;
@@ -2414,6 +2569,22 @@ var Node3D = class Node3D extends Node {
2414
2569
  * error rather than a silent per-node decode; overrides that do not need it
2415
2570
  * simply keep the one-argument signature. Null in a headless test or a
2416
2571
  * detached probe, where the node falls back to loading uncached. */
2572
+ /**
2573
+ * Is there anything left to wait for before this node can be FROZEN?
2574
+ *
2575
+ * The static latch fired unconditionally, while `Renderer3D.render()` calls
2576
+ * `assets.load()` and `syncTree()` in the SAME frame — so a
2577
+ * `ModelInstance3D` frozen by its own or an ancestor group's `static` never
2578
+ * mounted its GLB at all, permanently and silently: the asset loaded fine, so
2579
+ * `assetErrors()` is empty and `stats().errors` is 0, and `framing` reads
2580
+ * props rather than pixels and still calls the node on-screen.
2581
+ *
2582
+ * The 2D adapter already had exactly this gate (`Node2D._staticReady`); this
2583
+ * is its twin. A node with nothing to wait for is ready by definition.
2584
+ */
2585
+ _staticReady(_assets) {
2586
+ return true;
2587
+ }
2417
2588
  _syncObject3D(alpha = 1, _assets = null) {
2418
2589
  const o = this._ensureObject3D();
2419
2590
  const prev = this._interpPrev;
@@ -2560,7 +2731,7 @@ var Area3D = class extends PhysicsBody3D {
2560
2731
  *
2561
2732
  * Empty in a scene with no physics world.
2562
2733
  */
2563
- overlapping(group) {
2734
+ overlapping(group = void 0) {
2564
2735
  const all = this._physics?.overlapping(this) ?? [];
2565
2736
  return group === void 0 ? all : all.filter((n) => n.isInGroup(group));
2566
2737
  }
@@ -2608,6 +2779,17 @@ var RigidBody3D = class extends PhysicsBody3D {
2608
2779
  0,
2609
2780
  0
2610
2781
  ];
2782
+ /**
2783
+ * The other name for `linearVelocity` — see `RigidBody2D.velocity`. On a
2784
+ * plain TS class `body.velocity = [...]` succeeds silently and does nothing,
2785
+ * and that is the spelling the physics skill taught.
2786
+ */
2787
+ get velocity() {
2788
+ return this.linearVelocity;
2789
+ }
2790
+ set velocity(v) {
2791
+ this.linearVelocity = v;
2792
+ }
2611
2793
  /** @internal set by Physics3D */
2612
2794
  _physics3d = null;
2613
2795
  /** World-space impulse (kg·m/s) — the floating-capsule controller's verb. */
@@ -2693,6 +2875,37 @@ var CharacterBody3D = class extends PhysicsBody3D {
2693
2875
  if (!this._physics) throw new IncantoError("TREE_VIOLATION", this.collider.shape === void 0 ? `CharacterBody3D '${this.name}' has no collider — set a collider prop (physics skips collider-less bodies).` : `CharacterBody3D '${this.name}' has no physics world — await enablePhysics3D(engine) first.`);
2694
2876
  this._physics.moveAndSlide(this);
2695
2877
  }
2878
+ /** @internal Set by the physics step from the KCC's resolved collisions. */
2879
+ _onWall = null;
2880
+ /** @internal */
2881
+ _onCeiling = false;
2882
+ /**
2883
+ * Touching a wall.
2884
+ *
2885
+ * `CharacterBody2D` has had this since wall jump and wall slide turned out to
2886
+ * be not merely awkward to write in 2D but IMPOSSIBLE — `isOnFloor()` was the
2887
+ * only surface a character could report, so even a hand-written behavior had
2888
+ * nothing to ask. 3D was left with exactly that problem: a 3D platformer
2889
+ * could not wall-jump, and nothing said why.
2890
+ */
2891
+ isOnWall() {
2892
+ return this._onWall !== null;
2893
+ }
2894
+ /**
2895
+ * The wall's normal, pointing AWAY from it (toward the character) — the
2896
+ * vector a wall jump pushes along. `null` when not touching one.
2897
+ *
2898
+ * 2D answers this as `wallSide()`, a -1/+1 scalar, because a 2D character
2899
+ * has only two horizontal directions. In three dimensions the honest answer
2900
+ * is the normal itself.
2901
+ */
2902
+ wallNormal() {
2903
+ return this._onWall === null ? null : [...this._onWall];
2904
+ }
2905
+ /** Head hit something — cut the jump short, the way every platformer does. */
2906
+ isOnCeiling() {
2907
+ return this._onCeiling;
2908
+ }
2696
2909
  isOnFloor() {
2697
2910
  return this._grounded;
2698
2911
  }
@@ -3087,7 +3300,13 @@ var Water3D = class Water3D extends Node3D {
3087
3300
  static props = {
3088
3301
  size: { default: [40, 40] },
3089
3302
  color: { default: "#2a6fbe" },
3090
- opacity: { default: .8 },
3303
+ opacity: {
3304
+ default: .8,
3305
+ range: {
3306
+ min: 0,
3307
+ max: 1
3308
+ }
3309
+ },
3091
3310
  renderOrder: { default: 1 },
3092
3311
  waveHeight: { default: .04 },
3093
3312
  waveSpeed: { default: 1 },
@@ -3111,11 +3330,20 @@ var Water3D = class Water3D extends Node3D {
3111
3330
  .3
3112
3331
  ] },
3113
3332
  sunColor: { default: "#fff5d6" },
3114
- sunIntensity: { default: 1 },
3115
- detailStrength: { default: .26 },
3333
+ sunIntensity: {
3334
+ default: 1,
3335
+ range: { min: 0 }
3336
+ },
3337
+ detailStrength: {
3338
+ default: .26,
3339
+ range: { min: 0 }
3340
+ },
3116
3341
  absorption: { default: .15 },
3117
3342
  refraction: { default: true },
3118
- underwater: { default: true },
3343
+ underwater: {
3344
+ default: true,
3345
+ kinds: ["boolean", "object"]
3346
+ },
3119
3347
  swell: { default: 0 },
3120
3348
  swellDirectionDeg: { default: 0 },
3121
3349
  swellWavelength: { default: 30 },
@@ -4255,143 +4483,6 @@ const cutoutMaskMaterial = new ShaderMaterial({
4255
4483
  side: DoubleSide
4256
4484
  });
4257
4485
  //#endregion
4258
- //#region src/gameplay/spatial.ts
4259
- /** Duck-type test: every spatial node (Node2D/Node3D) exposes `position: number[]`. */
4260
- function hasPosition$1(node) {
4261
- return Array.isArray(node.position);
4262
- }
4263
- /**
4264
- * Movement behaviors are dimension-agnostic — they read/write `this.node.position`
4265
- * directly. Attaching one to a non-spatial node (no `position` array) would be a
4266
- * silent no-op, so assert it at ready (engine philosophy: hard load-time errors).
4267
- */
4268
- function requirePosition(behavior) {
4269
- const node = behavior.node;
4270
- if (!hasPosition$1(node)) {
4271
- const behaviorName = behavior.constructor.name;
4272
- throw new IncantoError("PROP_TYPE_MISMATCH", `${behaviorName} on '${node.getPath()}' needs a spatial node with a numeric 'position' array (a Node2D/Node3D subtype). '${node.name}' has none.`, { path: node.getPath() });
4273
- }
4274
- return node;
4275
- }
4276
- /** Euclidean distance between two position arrays (works for 2D and 3D). */
4277
- function distance$1(a, b) {
4278
- let sum = 0;
4279
- const n = Math.max(a.length, b.length);
4280
- for (let i = 0; i < n; i++) {
4281
- const d = (a[i] ?? 0) - (b[i] ?? 0);
4282
- sum += d * d;
4283
- }
4284
- return Math.sqrt(sum);
4285
- }
4286
- /** Component-wise `a + b` (dimension = max of the two; missing components are 0). */
4287
- function add(a, b) {
4288
- const n = Math.max(a.length, b.length);
4289
- const out = new Array(n);
4290
- for (let i = 0; i < n; i++) out[i] = (a[i] ?? 0) + (b[i] ?? 0);
4291
- return out;
4292
- }
4293
- /** Vector length. */
4294
- function length(v) {
4295
- let sum = 0;
4296
- for (const c of v) sum += c * c;
4297
- return Math.sqrt(sum);
4298
- }
4299
- /** Unit vector (zero-length stays zero). */
4300
- function normalize(v) {
4301
- const len = length(v);
4302
- if (len === 0) return v.map(() => 0);
4303
- return v.map((c) => c / len);
4304
- }
4305
- /**
4306
- * Step `from` toward `to` by at most `maxStep`. Returns the new position and
4307
- * whether the destination was reached this step (clamped — no overshoot).
4308
- */
4309
- function moveToward(from, to, maxStep) {
4310
- const n = Math.max(from.length, to.length);
4311
- let sum = 0;
4312
- for (let i = 0; i < n; i++) {
4313
- const d = (to[i] ?? 0) - (from[i] ?? 0);
4314
- sum += d * d;
4315
- }
4316
- const dist = Math.sqrt(sum);
4317
- const position = new Array(n);
4318
- if (dist <= maxStep || dist === 0) {
4319
- for (let i = 0; i < n; i++) position[i] = to[i] ?? 0;
4320
- return {
4321
- position,
4322
- reached: true
4323
- };
4324
- }
4325
- const f = maxStep / dist;
4326
- for (let i = 0; i < n; i++) {
4327
- const c = from[i] ?? 0;
4328
- position[i] = c + ((to[i] ?? 0) - c) * f;
4329
- }
4330
- return {
4331
- position,
4332
- reached: false
4333
- };
4334
- }
4335
- /**
4336
- * Frame-rate-independent lerp factor for an exponential chase. `smoothing` is a
4337
- * 0..1 retention factor: 0 snaps instantly; higher values chase more slowly.
4338
- * (60fps reference, matching Camera2D's `follow`.)
4339
- */
4340
- function smoothingFactor(smoothing, dt) {
4341
- const s = Math.min(Math.max(smoothing, 0), .99);
4342
- return s <= 0 ? 1 : 1 - s ** (dt * 60);
4343
- }
4344
- /**
4345
- * A node's position with every spatial ancestor's added in.
4346
- *
4347
- * The tree composes transforms for DRAWING — three does it for free — and every
4348
- * piece of engine logic that reads `node.position` gets the LOCAL number
4349
- * instead. Physics already knew (`worldPosition2D`/`worldPosition3D` in the
4350
- * adapters); the camera did not, and neither did anything that chases a target.
4351
- *
4352
- * A camera parented to the player is the Godot and Phaser idiom, and the
4353
- * authoring skill explicitly permits it — *"EVERY node type can hold children …
4354
- * any node works as a grouping container"*. Measured on exactly that scene:
4355
- *
4356
- * ```
4357
- * player world position : [1400, 900]
4358
- * renderer view centre : {"x":0,"y":0}
4359
- * framing : camera /Level/Player/Cam centred [1400, 900]
4360
- * 1 in view, 0 outside it
4361
- * ```
4362
- *
4363
- * The renderer drew empty world 1600 px from the player while `framing` — the
4364
- * instrument the skills tell you to trust before handing a game back —
4365
- * certified the player on screen.
4366
- *
4367
- * Arity comes from the node itself: a 2D node has two components, a 3D node
4368
- * three, and an ancestor that is neither contributes nothing.
4369
- */
4370
- function worldPosition(node) {
4371
- const own = node.position;
4372
- if (!Array.isArray(own)) return [];
4373
- const out = own.slice();
4374
- for (let at = node.parent; at; at = at.parent) {
4375
- const p = at.position;
4376
- if (!Array.isArray(p)) continue;
4377
- for (let i = 0; i < out.length; i++) out[i] = (out[i] ?? 0) + (p[i] ?? 0);
4378
- }
4379
- return out;
4380
- }
4381
- /**
4382
- * Turn a world position into the local one `node.position` has to hold for the
4383
- * node to END UP there — the inverse of `worldPosition` for its parent chain.
4384
- */
4385
- function localFromWorld(node, world) {
4386
- const out = world.slice();
4387
- for (let at = node.parent; at; at = at.parent) {
4388
- const p = at.position;
4389
- if (!Array.isArray(p)) continue;
4390
- for (let i = 0; i < out.length; i++) out[i] = (out[i] ?? 0) - (p[i] ?? 0);
4391
- }
4392
- return out;
4393
- }
4394
- //#endregion
4395
4486
  //#region src/gameplay/buoyancy.ts
4396
4487
  /** Gravity the float is sized against (m/s²) — the cap is expressed in g. */
4397
4488
  const G = 9.81;
@@ -4458,10 +4549,18 @@ var Buoyancy = class extends Behavior {
4458
4549
  wet = false;
4459
4550
  /** Said once: a body that cannot be pushed will never start being able to. */
4460
4551
  reportedNoImpulse = false;
4552
+ /** Said once: there was no water to float on when this first looked. */
4553
+ reportedNoWater = false;
4461
4554
  update(dt) {
4462
4555
  if (dt <= 0) return;
4463
4556
  const water = this.resolveWater();
4464
- if (!water) return;
4557
+ if (!water) {
4558
+ if (!this.reportedNoWater) {
4559
+ this.reportedNoWater = true;
4560
+ diagnose(this.node.tree?.engine ?? null, "warn", `[incanto] ${this.node.getPath()}: Buoyancy found no Water3D in the tree, so this body is not floating. It keeps looking, so a water added later is fine — otherwise add one, or name it with "water".`);
4561
+ }
4562
+ return;
4563
+ }
4465
4564
  const node = this.node;
4466
4565
  if (!node.applyImpulse) {
4467
4566
  if (!this.reportedNoImpulse) {
@@ -4736,12 +4835,23 @@ var Chase = class extends Behavior {
4736
4835
  * elsewhere is not a click. `hovered`/`unhovered` bracket the cursor being over
4737
4836
  * it.
4738
4837
  */
4838
+ /**
4839
+ * Frames of no pointer before this is a mistake rather than a still cursor.
4840
+ *
4841
+ * Two seconds at 60 Hz: long enough that a player who has not moved the mouse
4842
+ * yet is not told their game is broken, short enough to be on screen before
4843
+ * anyone starts reading the source.
4844
+ */
4845
+ const QUIET_BEFORE_COMPLAINING = 120;
4739
4846
  var Clickable = class extends Behavior {
4740
4847
  static props = {
4741
4848
  /** Mouse button: 0 left, 1 middle, 2 right. */
4742
4849
  button: { default: 0 },
4743
- /** Ignore clicks further away than this (0 = any distance). */
4744
- maxDistance: { default: 0 }
4850
+ /** Ignore clicks further than this from the CURRENT CAMERA (0 = any). */
4851
+ maxDistance: {
4852
+ default: 0,
4853
+ range: { min: 0 }
4854
+ }
4745
4855
  };
4746
4856
  static signals = [
4747
4857
  "clicked",
@@ -4753,17 +4863,24 @@ var Clickable = class extends Behavior {
4753
4863
  /** True while the cursor is over this node — read it to tint a hover state. */
4754
4864
  hovering = false;
4755
4865
  pressedHere = false;
4866
+ /** Frames in a row with nothing to click WITH — see the complaint below. */
4867
+ silentFrames = 0;
4756
4868
  update() {
4757
4869
  const engine = this.node.tree?.engine;
4758
4870
  if (!engine) return;
4759
4871
  const picker = engine.picker;
4760
4872
  const pointer = engine.input.pointerPosition();
4761
4873
  if (!picker || !pointer) {
4874
+ if (picker) {
4875
+ this.silentFrames += 1;
4876
+ if (this.silentFrames === QUIET_BEFORE_COMPLAINING) diagnose(engine, "error", `[incanto] Clickable on '${this.node.getPath()}' has had no pointer for ${QUIET_BEFORE_COMPLAINING} frames, so it can never fire. Nothing is feeding the cursor: pass \`pointer: true\` to createGame2D/3D, or call \`engine.input.attachPointer(canvas)\` on a manual boot.`);
4877
+ }
4762
4878
  if (this.hovering) this.setHover(false);
4763
4879
  return;
4764
4880
  }
4881
+ this.silentFrames = 0;
4765
4882
  const hit = engine.pickAt(pointer.x, pointer.y);
4766
- const over = hit !== null && this.isSelfOrDescendant(hit) && this.withinRange(hit);
4883
+ const over = hit !== null && this.isSelfOrDescendant(hit) && this.withinRange();
4767
4884
  if (over !== this.hovering) this.setHover(over);
4768
4885
  if (engine.input.mouseJustPressed(this.button)) this.pressedHere = over;
4769
4886
  if (engine.input.mouseJustReleased(this.button)) {
@@ -4780,19 +4897,65 @@ var Clickable = class extends Behavior {
4780
4897
  for (let n = hit; n; n = n.parent) if (n === this.node) return true;
4781
4898
  return false;
4782
4899
  }
4783
- withinRange(hit) {
4900
+ /**
4901
+ * Is this node close enough to the camera to be worth clicking?
4902
+ *
4903
+ * It used to measure `distance(this.node, hit)` — and `hit` is always this
4904
+ * node or a DESCENDANT of it, since `isSelfOrDescendant` runs first. So the
4905
+ * prop documented as "ignore clicks further than this" gated on WHICH PART of
4906
+ * the node you clicked: a chest 500 px away measured 0 and was allowed, while
4907
+ * its own lid 200 px up measured 200 and was refused. The unused local named
4908
+ * `camera`, assigned the scene ROOT, is where the intent got lost.
4909
+ */
4910
+ withinRange() {
4784
4911
  if (this.maxDistance <= 0) return true;
4785
- if (!this.node.tree?.engine?.scene?.root) return true;
4786
- const a = this.node.position;
4787
- const b = hit.position;
4788
- if (!Array.isArray(a) || !Array.isArray(b)) return true;
4912
+ const camera = this.currentCamera();
4913
+ if (!camera) {
4914
+ if (!this.warnedNoCamera) {
4915
+ this.warnedNoCamera = true;
4916
+ diagnose(this.node.tree?.engine ?? null, "error", `[incanto] Clickable on '${this.node.getPath()}' has maxDistance ${this.maxDistance}, which is a range FROM THE CAMERA — and this scene has no camera to measure from, so every click is accepted. Add one, or drop the prop.`);
4917
+ }
4918
+ return true;
4919
+ }
4920
+ const from = worldPosition(camera);
4921
+ const to = worldPosition(this.node);
4922
+ if (from.length === 0 || to.length === 0) return true;
4789
4923
  let sum = 0;
4790
- for (let i = 0; i < Math.max(a.length, b.length); i++) {
4791
- const d = (a[i] ?? 0) - (b[i] ?? 0);
4924
+ for (let i = 0; i < Math.max(from.length, to.length); i++) {
4925
+ const d = (from[i] ?? 0) - (to[i] ?? 0);
4792
4926
  sum += d * d;
4793
4927
  }
4794
4928
  return Math.sqrt(sum) <= this.maxDistance;
4795
4929
  }
4930
+ warnedNoCamera = false;
4931
+ camera = null;
4932
+ /**
4933
+ * The camera this scene is drawn from — `current` first, then the first one.
4934
+ *
4935
+ * The same order `describeFraming` and both renderers use. Re-resolved when
4936
+ * the cached one leaves the tree, so a scene swap does not measure from a
4937
+ * camera that is gone.
4938
+ */
4939
+ currentCamera() {
4940
+ if (this.camera?.tree && this.camera.current === true) return this.camera;
4941
+ const root = this.node.tree?.root;
4942
+ if (!root) return null;
4943
+ let first = null;
4944
+ const walk = (node) => {
4945
+ const type = node.constructor.typeName ?? "";
4946
+ if (type === "Camera2D" || type === "Camera3D") {
4947
+ if (node.current === true) return node;
4948
+ first ??= node;
4949
+ }
4950
+ for (const child of node.children) {
4951
+ const found = walk(child);
4952
+ if (found) return found;
4953
+ }
4954
+ return null;
4955
+ };
4956
+ this.camera = walk(root) ?? first;
4957
+ return this.camera;
4958
+ }
4796
4959
  };
4797
4960
  //#endregion
4798
4961
  //#region src/gameplay/collector.ts
@@ -4830,6 +4993,10 @@ group: { default: "player" } };
4830
4993
  const d = data;
4831
4994
  if (typeof d.total === "number") this.total = d.total;
4832
4995
  }
4996
+ /** The counter on screen. `collect(0)` used to be the only way to repaint it. */
4997
+ announce() {
4998
+ this.emit("totalChanged", this.total);
4999
+ }
4833
5000
  };
4834
5001
  //#endregion
4835
5002
  //#region src/gameplay/currency.ts
@@ -4863,10 +5030,15 @@ var Currency = class extends Behavior {
4863
5030
  max = 0;
4864
5031
  /** Can this be paid right now? */
4865
5032
  canAfford(cost) {
5033
+ if (cost <= 0) return true;
4866
5034
  return cost <= this.amount;
4867
5035
  }
4868
5036
  /** Pay `cost` if it is there. Returns whether it went through. */
4869
5037
  spend(cost) {
5038
+ if (requireNumber(cost, this.node, "Currency.spend") <= 0) {
5039
+ this.refuseNonsense("spend", cost);
5040
+ return true;
5041
+ }
4870
5042
  if (!this.canAfford(cost)) {
4871
5043
  this.emit("refused", cost);
4872
5044
  return false;
@@ -4878,13 +5050,31 @@ var Currency = class extends Behavior {
4878
5050
  }
4879
5051
  /** Take payment in. */
4880
5052
  earn(gain) {
5053
+ if (requireNumber(gain, this.node, "Currency.earn") <= 0) {
5054
+ this.refuseNonsense("earn", gain);
5055
+ return;
5056
+ }
4881
5057
  this.amount = this.max > 0 ? Math.min(this.max, this.amount + gain) : this.amount + gain;
4882
5058
  this.emit("earned", gain);
4883
5059
  this.emit("changed", this.amount);
4884
5060
  }
5061
+ /** What has already been complained about, so a loop says it once. */
5062
+ complained = /* @__PURE__ */ new Set();
5063
+ /**
5064
+ * A zero is ordinary; a NEGATIVE is a caller that flipped a sign.
5065
+ *
5066
+ * Reported rather than thrown: this is called from game code inside a frame,
5067
+ * and a throw here quarantines the node holding the player's money.
5068
+ */
5069
+ refuseNonsense(what, n) {
5070
+ if (n === 0 || this.complained.has(what)) return;
5071
+ this.complained.add(what);
5072
+ diagnose(this.node.tree?.engine ?? null, "error", `[incanto] Currency.${what}(${n}) on '${this.node.getPath()}' — a negative amount is a sign flip in the caller, never an offer. Ignored: ${what === "spend" ? "spending a negative used to PAY you" : "earning a negative used to TAKE from you"}.`);
5073
+ }
4885
5074
  /** Set it outright (a shop that grants, a cheat, a restore). */
4886
5075
  setAmount(next) {
4887
- this.amount = this.max > 0 ? Math.min(this.max, next) : next;
5076
+ const n = requireNumber(next, this.node, "Currency.setAmount");
5077
+ this.amount = this.max > 0 ? Math.min(this.max, n) : n;
4888
5078
  this.emit("changed", this.amount);
4889
5079
  }
4890
5080
  serialize() {
@@ -4894,6 +5084,10 @@ var Currency = class extends Behavior {
4894
5084
  const d = data;
4895
5085
  if (typeof d.amount === "number") this.amount = d.amount;
4896
5086
  }
5087
+ /** The wallet on screen — `setAmount`'s own doc comment already named a restore. */
5088
+ announce() {
5089
+ this.emit("changed", this.amount);
5090
+ }
4897
5091
  };
4898
5092
  //#endregion
4899
5093
  //#region src/gameplay/free-target.ts
@@ -4957,7 +5151,15 @@ var Health = class extends Behavior {
4957
5151
  /** Seconds of immunity after taking a hit (0 = no i-frames). */
4958
5152
  invulnerableFor: { default: 0 },
4959
5153
  /** queueFree() this node when it dies (clone-safe spawned-entity cleanup). */
4960
- freeOnDeath: { default: false }
5154
+ freeOnDeath: { default: false },
5155
+ /**
5156
+ * …the PARENT, for the composition every other despawning behaviour
5157
+ * supports. A node carries ONE script, so an enemy that walks and can be
5158
+ * hurt splits `Patrol` and `Health` across children — and then the Health
5159
+ * child froze itself and left an IMMORTAL body walking, which is the exact
5160
+ * failure `Lifetime` and `DamageOnContact` got this prop for.
5161
+ */
5162
+ ...FREE_PARENT_PROP
4961
5163
  };
4962
5164
  static signals = [
4963
5165
  "damaged",
@@ -4970,6 +5172,7 @@ var Health = class extends Behavior {
4970
5172
  regenPerSec = 0;
4971
5173
  invulnerableFor = 0;
4972
5174
  freeOnDeath = false;
5175
+ freeParent = false;
4973
5176
  /** Current hit points (set to `max` on ready). */
4974
5177
  current = 0;
4975
5178
  invulnTimer = 0;
@@ -4994,7 +5197,7 @@ var Health = class extends Behavior {
4994
5197
  }
4995
5198
  /** Apply `n` damage. No-op while invulnerable or already dead. Emits `damaged`. */
4996
5199
  damage(n) {
4997
- if (this.dead || n <= 0) return;
5200
+ if (this.dead || requireNumber(n, this.node, "Health.damage") <= 0) return;
4998
5201
  if (this.invulnTimer > 0) return;
4999
5202
  this.setCurrent(this.current - n);
5000
5203
  this.emit("damaged", n, this.current);
@@ -5003,7 +5206,7 @@ var Health = class extends Behavior {
5003
5206
  }
5004
5207
  /** Restore `n` hit points (clamped to `max`). No-op when dead. Emits `healed`. */
5005
5208
  heal(n) {
5006
- if (this.dead || n <= 0) return;
5209
+ if (this.dead || requireNumber(n, this.node, "Health.heal") <= 0) return;
5007
5210
  this.setCurrent(this.current + n);
5008
5211
  this.emit("healed", n, this.current);
5009
5212
  }
@@ -5037,11 +5240,11 @@ var Health = class extends Behavior {
5037
5240
  * A no-op on the living — reviving someone who never died would silently
5038
5241
  * refill their health, which is a different feature and not this one.
5039
5242
  */
5040
- revive(hp) {
5243
+ revive(hp = this.max) {
5041
5244
  if (!this.dead) return;
5042
5245
  this.dead = false;
5043
5246
  this.current = 0;
5044
- this.setCurrent(hp === void 0 ? this.max : hp);
5247
+ this.setCurrent(hp);
5045
5248
  this.invulnTimer = this.invulnerableFor;
5046
5249
  this.emit("revived", this.current);
5047
5250
  }
@@ -5090,7 +5293,7 @@ var Health = class extends Behavior {
5090
5293
  this.dead = true;
5091
5294
  this.setCurrent(0);
5092
5295
  this.emit("died");
5093
- if (this.freeOnDeath) this.node.queueFree();
5296
+ if (this.freeOnDeath) freeOwner(this, this.freeParent, "Health");
5094
5297
  }
5095
5298
  /** Current HP and whether death already fired — `max` comes back from JSON. */
5096
5299
  serialize() {
@@ -5104,6 +5307,16 @@ var Health = class extends Behavior {
5104
5307
  if (typeof d.current === "number") this.current = Math.min(this.max, Math.max(0, d.current));
5105
5308
  if (typeof d.dead === "boolean") this.dead = d.dead;
5106
5309
  }
5310
+ /**
5311
+ * Repaint the bar. Not `died` — a save is being read, nobody just died.
5312
+ *
5313
+ * This one needed the hook most: `setCurrent` is private AND early-returns
5314
+ * when the value is unchanged, so no call a game could make would repaint a
5315
+ * restored bar. It read 100 at 38 HP until the next hit teleported it to 37.
5316
+ */
5317
+ announce() {
5318
+ this.emit("healthChanged", this.current, this.max);
5319
+ }
5107
5320
  };
5108
5321
  //#endregion
5109
5322
  //#region src/gameplay/trigger.ts
@@ -5278,6 +5491,27 @@ var DayNight = class extends Behavior {
5278
5491
  baseAmbient = .3;
5279
5492
  baseExposure = 1;
5280
5493
  captured = false;
5494
+ /** Said once: there was no sky to move when this first looked. */
5495
+ reportedNoSky = false;
5496
+ /**
5497
+ * What time it is. A save that forgets it returns you at dawn.
5498
+ *
5499
+ * `startHour` is the scene's opening, not the run's clock: a fresh `onReady`
5500
+ * cannot know that this player has been out all night. `paused` goes with it
5501
+ * because a game that stops the clock for a cutscene must not resume ticking
5502
+ * on load.
5503
+ */
5504
+ serialize() {
5505
+ return {
5506
+ hour: this.hour,
5507
+ paused: this.paused
5508
+ };
5509
+ }
5510
+ deserialize(data) {
5511
+ const d = data ?? {};
5512
+ if (typeof d.hour === "number" && Number.isFinite(d.hour)) this.hour = (d.hour % 24 + 24) % 24;
5513
+ if (typeof d.paused === "boolean") this.paused = d.paused;
5514
+ }
5281
5515
  onReady() {
5282
5516
  this.hour = this.startHour;
5283
5517
  }
@@ -5296,7 +5530,13 @@ var DayNight = class extends Behavior {
5296
5530
  applyHour() {
5297
5531
  const engine = this.node.tree?.engine;
5298
5532
  const env = engine?.scene?.environment;
5299
- if (!engine || !env?.sky) return;
5533
+ if (!engine || !env?.sky) {
5534
+ if (engine && !this.reportedNoSky) {
5535
+ this.reportedNoSky = true;
5536
+ diagnose(engine, "warn", `[incanto] ${this.node.getPath()}: DayNight drives environment.sky, and this scene has no \`sky\` — the clock runs and nothing on screen changes. It keeps looking, so a sky added later is fine; otherwise add \`"sky": { "type": "atmosphere" }\` to the scene environment.`);
5537
+ }
5538
+ return;
5539
+ }
5300
5540
  if (!this.captured) {
5301
5541
  this.captured = true;
5302
5542
  this.baseAmbient = env.ambient?.intensity ?? .3;
@@ -5484,7 +5724,8 @@ var FloatAway = class extends Behavior {
5484
5724
  const t = Math.min(1, this.elapsed / life);
5485
5725
  const next = [...this.start];
5486
5726
  const eased = 1 - (1 - t) * (1 - t);
5487
- next[1] = (this.start[1] ?? 0) + this.rise * eased;
5727
+ const up = this.start.length === 2 ? -1 : 1;
5728
+ next[1] = (this.start[1] ?? 0) + up * this.rise * eased;
5488
5729
  if (this.drift !== 0) next[0] = (this.start[0] ?? 0) + this.drift * eased;
5489
5730
  node.position = next;
5490
5731
  if (node.opacity !== void 0) {
@@ -5498,6 +5739,46 @@ var FloatAway = class extends Behavior {
5498
5739
  }
5499
5740
  };
5500
5741
  //#endregion
5742
+ //#region src/core/effect-rng.ts
5743
+ /**
5744
+ * A random stream for something DECORATIVE, derived from the engine's seed.
5745
+ *
5746
+ * Particle emitters drew from `engine.rng` — the stream game logic reads — and
5747
+ * they spawn a number of particles that depends on `dt`. Measured on one scene
5748
+ * and one seed over the same simulated two seconds, comparing the world's own
5749
+ * draws by update index:
5750
+ *
5751
+ * ```
5752
+ * particles off: 60fps and 30fps identical
5753
+ * particles ON : they diverge at update 2
5754
+ * ```
5755
+ *
5756
+ * A purely visual effect changed the game's random decisions, and the PLAYER'S
5757
+ * FRAME RATE decided them: the same seed gives a different game on a slower
5758
+ * machine, and a replay recorded at 60 fps does not reproduce on a laptop that
5759
+ * dropped to 30.
5760
+ *
5761
+ * Derived from `engine.seed` and the node's PATH, so it is:
5762
+ *
5763
+ * - **stable** — the same emitter in the same scene gets the same stream every
5764
+ * run, which is what a screenshot diff and a replay need;
5765
+ * - **distinct** — two emitters do not march in lockstep;
5766
+ * - **separate** — nothing it draws is a value the game will not draw.
5767
+ *
5768
+ * Falls back to a fixed constant with no engine, which is what the emitters
5769
+ * already did when they had no tree.
5770
+ */
5771
+ function effectRng(node, salt = 0) {
5772
+ const engine = node.tree?.engine;
5773
+ const path = node.getPath();
5774
+ let hash = (engine?.seed ?? 4660) >>> 0;
5775
+ for (let i = 0; i < path.length; i++) {
5776
+ hash ^= path.charCodeAt(i);
5777
+ hash = Math.imul(hash, 16777619) >>> 0;
5778
+ }
5779
+ return new Rng((hash ^ Math.imul(salt + 1, 2654435769)) >>> 0);
5780
+ }
5781
+ //#endregion
5501
5782
  //#region src/gameplay/follow-camera.ts
5502
5783
  /**
5503
5784
  * Make the node it sits on chase a target's position — THE camera-follow
@@ -5541,6 +5822,7 @@ var FollowCamera = class extends Behavior {
5541
5822
  offset = [];
5542
5823
  smoothing = 0;
5543
5824
  deadzone = 0;
5825
+ shakeRng = null;
5544
5826
  onReady() {
5545
5827
  if (this.target === "") throw new IncantoError("PROP_TYPE_MISMATCH", `FollowCamera on '${this.node.getPath()}': "target" (a node path) is required.`, { prop: "target" });
5546
5828
  if (this.deadzone < 0) throw new IncantoError("PROP_TYPE_MISMATCH", `FollowCamera on '${this.node.getPath()}': "deadzone" must be >= 0, got ${this.deadzone}.`, { prop: "deadzone" });
@@ -5568,13 +5850,14 @@ var FollowCamera = class extends Behavior {
5568
5850
  else cam.position = localFromWorld(cam, here.map((c, i) => c + ((desired[i] ?? c) - c) * f));
5569
5851
  }
5570
5852
  if (this.shakeT > 0) {
5571
- this.shakeT -= dt;
5853
+ this.shakeT -= this.engine?.unscaledDelta || dt;
5572
5854
  const k = this.shakeMag * Math.max(0, this.shakeT) / this.shakeFalloff;
5573
5855
  if (this.shakeT <= 0) this.shakeMag = 0;
5574
5856
  const jolted = cam.position.slice();
5857
+ this.shakeRng ??= effectRng(this.node);
5575
5858
  for (let i = 0; i < jolted.length; i++) {
5576
5859
  const scale = jolted.length >= 3 && i === 1 ? .5 : 1;
5577
- jolted[i] = (jolted[i] ?? 0) + (this.engine.rng.next() * 2 - 1) * k * scale;
5860
+ jolted[i] = (jolted[i] ?? 0) + (this.shakeRng.next() * 2 - 1) * k * scale;
5578
5861
  }
5579
5862
  cam.position = jolted;
5580
5863
  }
@@ -5586,8 +5869,14 @@ var FollowCamera = class extends Behavior {
5586
5869
  * Read an input action that a game is ALLOWED not to declare — and say so once
5587
5870
  * when it is missing.
5588
5871
  *
5589
- * `CharacterController3D` and `GameFlow` wrap every input read in a bare
5590
- * `catch {}`. The tolerance is right: a game with no sprint key should not
5872
+ * `CharacterController3D` and `GameFlow` wrapped every input read in a bare
5873
+ * `catch {}` and they were not the only readers, which is a mistake this
5874
+ * comment made for a year by naming them. `CharacterController2D` and
5875
+ * `Interactable` read bare, so a top-down game with no `jump` and a
5876
+ * point-and-click with no `interact` had the whole NODE quarantined: the
5877
+ * character could not move, the NPC stopped answering.
5878
+ * `every-action-a-game-may-not-declare.test.ts` reads the sources and fails on
5879
+ * the next one. The tolerance is right: a game with no sprint key should not
5591
5880
  * crash, and `pause()` stays an API even with no pause action. What was wrong is
5592
5881
  * that the tolerance is indistinguishable from a TYPO. Write
5593
5882
  * `"moveAction": "movement"` and the character stands still, forever, while
@@ -5612,29 +5901,80 @@ function tolerateUnknownAction(ctx, action, read, fallback) {
5612
5901
  //#endregion
5613
5902
  //#region src/gameplay/game-flow.ts
5614
5903
  /**
5904
+ * Build a scene the way the game BOOTED it — resolver included.
5905
+ *
5906
+ * Both reload paths called `loadScene(clone, { engine })` and nothing else, so
5907
+ * the `resolveScene` `createGame2D/3D` was given at boot was dropped on every
5908
+ * restart and every scene change. A game using the documented `instance:`
5909
+ * sub-scenes therefore booted fine and threw `UNRESOLVED_INSTANCE` on its FIRST
5910
+ * restart — inside `GameFlow.update`, which took the restart key with it for the
5911
+ * rest of the run. The engine remembers it now; this is where it comes back.
5912
+ */
5913
+ function reload(engine, source) {
5914
+ return loadScene(jsonClone(source), {
5915
+ engine,
5916
+ ...engine.resolveScene ? { resolveScene: engine.resolveScene } : {}
5917
+ });
5918
+ }
5919
+ /**
5615
5920
  * Reload the CURRENT scene from its source JSON — fresh nodes, reset physics,
5616
5921
  * rewired input. The restart primitive every game-over screen wants.
5617
5922
  */
5618
5923
  function restartScene(engine) {
5619
5924
  const source = engine.scene?.source;
5620
5925
  if (!source) return;
5621
- engine.setScene(loadScene(jsonClone(source), { engine }));
5926
+ engine.setScene(reload(engine, source));
5622
5927
  engine.ensurePhysics();
5623
5928
  }
5624
5929
  /**
5930
+ * Engines with a travel in flight. One door at a time, and the scene you are
5931
+ * LEAVING stops simulating while the screen is black.
5932
+ *
5933
+ * A `WeakSet` rather than a field, so this stays a free function and an engine
5934
+ * that is garbage collected mid-fade takes its entry with it.
5935
+ */
5936
+ const travelling = /* @__PURE__ */ new WeakSet();
5937
+ /**
5625
5938
  * Swap to another scene behind a black fade (title→game→next level).
5626
5939
  * Headless (no DOM) the swap is immediate. Restores timeScale to 1.
5940
+ *
5941
+ * ONE AT A TIME. A door is usually a trigger, and a player who walks along a
5942
+ * wall of them fires several inside one fade. Measured on three doorways passed
5943
+ * in about a second:
5944
+ *
5945
+ * ```
5946
+ * setScene calls: 3 | order: ["crypt","garden","forge"]
5947
+ * final scene : forge
5948
+ * ```
5949
+ *
5950
+ * Three complete scenes loaded and the player landed in the third — a level
5951
+ * they never walked to, from a door they never reached. Further calls while a
5952
+ * travel is in flight are IGNORED rather than queued: you already went through
5953
+ * a door, and the second one is the same input arriving twice.
5954
+ *
5955
+ * The outgoing scene also stops while the screen is black. It kept simulating
5956
+ * through the fade, so a player who triggered the door mid-fall died in the
5957
+ * level they were leaving — and the checkpoint that death wrote was for the
5958
+ * level they had left.
5627
5959
  */
5628
5960
  function goToScene(engine, sceneJson, opts) {
5961
+ if (travelling.has(engine)) return;
5629
5962
  const fade = opts?.fadeSeconds ?? .4;
5630
5963
  const swap = () => {
5631
- engine.setScene(loadScene(jsonClone(sceneJson), { engine }));
5964
+ engine.setScene(reload(engine, sceneJson));
5632
5965
  engine.ensurePhysics();
5633
5966
  };
5634
5967
  if (typeof document === "undefined" || fade <= 0) {
5635
5968
  swap();
5636
5969
  return;
5637
5970
  }
5971
+ travelling.add(engine);
5972
+ const wasTimeScale = engine.timeScale;
5973
+ engine.timeScale = 0;
5974
+ const arrive = () => {
5975
+ travelling.delete(engine);
5976
+ if (wasTimeScale !== 0) engine.timeScale = wasTimeScale;
5977
+ };
5638
5978
  const id = "incanto-scene-fade";
5639
5979
  let el = document.getElementById(id);
5640
5980
  if (!el) {
@@ -5646,11 +5986,17 @@ function goToScene(engine, sceneJson, opts) {
5646
5986
  el.style.transition = `opacity ${fade / 2}s`;
5647
5987
  el.style.opacity = "1";
5648
5988
  setTimeout(() => {
5649
- swap();
5650
- const out = document.getElementById(id);
5651
- if (out) {
5652
- out.style.opacity = "0";
5653
- setTimeout(() => out.remove(), fade * 500 + 60);
5989
+ try {
5990
+ swap();
5991
+ } catch (e) {
5992
+ diagnose(engine, "error", `[incanto] goToScene: the destination scene did not load — staying in '${engine.scene?.name ?? "?"}'.`, e);
5993
+ } finally {
5994
+ arrive();
5995
+ const out = document.getElementById(id);
5996
+ if (out) {
5997
+ out.style.opacity = "0";
5998
+ setTimeout(() => out.remove(), fade * 500 + 60);
5999
+ }
5654
6000
  }
5655
6001
  }, fade * 500 + 30);
5656
6002
  }
@@ -5731,12 +6077,40 @@ var GameFlow = class GameFlow extends Behavior {
5731
6077
  onReady() {
5732
6078
  this.syncPausePanel();
5733
6079
  }
5734
- /** The panel follows the state; nothing else may own its visibility. */
6080
+ /**
6081
+ * The panel follows the state; nothing else may own its visibility.
6082
+ *
6083
+ * …and while it is up, the layer it lives on takes ARROW KEYS AND THE PAD.
6084
+ * `HudLayer.focusNavigation` is off by default and rightly so — a game whose
6085
+ * HUD happens to contain a button must not lose its arrow keys the moment one
6086
+ * exists — and `incanto-hud.md` says "turn it on for the screens that ARE
6087
+ * menus and off again when play resumes". A pause panel IS that screen, and
6088
+ * this is the one place in the engine that knows when it opens and closes, so
6089
+ * every game with a pause menu was writing the same two lines or shipping a
6090
+ * menu no controller could reach.
6091
+ *
6092
+ * The layer's own value is remembered and put back: a game that turned
6093
+ * navigation on for its own reasons keeps it after a pause.
6094
+ */
5735
6095
  syncPausePanel() {
5736
6096
  if (this.pausePanelPath === "") return;
5737
6097
  const panel = this.node.getNodeOrNull(this.pausePanelPath);
5738
- if (panel && "visible" in panel) panel.visible = this.state === "paused";
6098
+ if (!panel || !("visible" in panel)) return;
6099
+ const paused = this.state === "paused";
6100
+ panel.visible = paused;
6101
+ const layer = layerAbove(panel);
6102
+ if (!layer) return;
6103
+ if (paused) {
6104
+ if (this.navigationWas === null) this.navigationWas = layer.focusNavigation;
6105
+ layer.focusNavigation = true;
6106
+ layer.focus(firstFocusable(layer));
6107
+ } else if (this.navigationWas !== null) {
6108
+ if (layer.focusNavigation) layer.focusNavigation = this.navigationWas;
6109
+ this.navigationWas = null;
6110
+ }
5739
6111
  }
6112
+ /** The layer's own `focusNavigation`, while this flow is borrowing it. */
6113
+ navigationWas = null;
5740
6114
  gameOver(text = "GAME OVER", color = "#ef4444") {
5741
6115
  this.transition("gameover", text, color);
5742
6116
  }
@@ -5762,7 +6136,7 @@ var GameFlow = class GameFlow extends Behavior {
5762
6136
  restartScene(this.engine);
5763
6137
  }
5764
6138
  /** Fade to another scene (next level, back to title). */
5765
- goToScene(sceneJson, opts) {
6139
+ goToScene(sceneJson, opts = void 0) {
5766
6140
  this.thaw();
5767
6141
  goToScene(this.engine, sceneJson, opts);
5768
6142
  }
@@ -5796,6 +6170,15 @@ var GameFlow = class GameFlow extends Behavior {
5796
6170
  if (this.readAction("restartAction", "The restart key will do nothing.")) this.restart();
5797
6171
  }
5798
6172
  };
6173
+ /** The `HudLayer` a widget lives on, or null. */
6174
+ function layerAbove(node) {
6175
+ for (let at = node; at; at = at.parent) if (at instanceof HudLayer) return at;
6176
+ return null;
6177
+ }
6178
+ /** Where the ring starts: opening a menu should land somewhere, not nowhere. */
6179
+ function firstFocusable(layer) {
6180
+ return layer.focusables()[0] ?? null;
6181
+ }
5799
6182
  //#endregion
5800
6183
  //#region src/gameplay/interactable.ts
5801
6184
  function hasPosition(node) {
@@ -5832,10 +6215,18 @@ var Interactable = class extends Behavior {
5832
6215
  action = "interact";
5833
6216
  range = 2;
5834
6217
  actorGroup = "player";
6218
+ /** Actions already reported missing, so a per-frame read says it once. */
6219
+ reportedActions = /* @__PURE__ */ new Set();
5835
6220
  update() {
5836
6221
  const engine = this.node.tree?.engine;
5837
6222
  if (!engine?.scene) return;
5838
- if (!engine.input.justPressed(this.action)) return;
6223
+ if (!tolerateUnknownAction({
6224
+ engine,
6225
+ who: this.node.getPath(),
6226
+ reported: this.reportedActions,
6227
+ consequence: "nothing here can be interacted with",
6228
+ authored: this.action !== "interact"
6229
+ }, this.action, () => engine.input.justPressed(this.action), false)) return;
5839
6230
  if (!hasPosition(this.node)) return;
5840
6231
  const self = worldPosition(this.node);
5841
6232
  let nearest = null;
@@ -5907,13 +6298,14 @@ var Cooldown = class {
5907
6298
  };
5908
6299
  const SHAKE_FALLOFF_SECONDS = .28;
5909
6300
  /** File the effect with the engine, if this one is attached to a running game. */
5910
- function recordEffect(engine, kind, name, from, amount) {
6301
+ function recordEffect(engine, kind, name, from, amount, wiredAs) {
5911
6302
  engine?.effects.record({
5912
6303
  kind,
5913
6304
  name,
5914
6305
  from,
5915
6306
  amount,
5916
- at: engine.time
6307
+ at: engine.time,
6308
+ wiredAs
5917
6309
  });
5918
6310
  }
5919
6311
  /**
@@ -5932,6 +6324,19 @@ var CameraShake = class extends Behavior {
5932
6324
  t = 0;
5933
6325
  prev = [];
5934
6326
  /**
6327
+ * Decoration draws from its OWN stream — see `effectRng`.
6328
+ *
6329
+ * A shake drew from `engine.rng`, the stream game logic reads and
6330
+ * `captureState` saves, once per axis per RENDERED frame. So the player's
6331
+ * frame rate decided the game's random numbers, and toggling the
6332
+ * `reduceMotion` accessibility switch changed the rolls: a replay recorded at
6333
+ * 60 fps does not reproduce on a laptop that dropped to 30, and two players
6334
+ * with the same seed diverge because one of them was hit. The particle
6335
+ * siblings were moved off `engine.rng` for exactly this; both camera shakes
6336
+ * were left behind.
6337
+ */
6338
+ shakeRng = null;
6339
+ /**
5935
6340
  * Start a shake — unless the player asked for no motion.
5936
6341
  *
5937
6342
  * `settings.reduceMotion` is an accessibility switch, and camera movement the
@@ -5943,11 +6348,11 @@ var CameraShake = class extends Behavior {
5943
6348
  * for 80 ms is feedback, not motion, and removing it would change what the
5944
6349
  * game is rather than how it moves.
5945
6350
  */
5946
- shake(magnitude, seconds) {
6351
+ shake(magnitude, seconds = this.falloff) {
5947
6352
  if (this.engine?.settings.get("reduceMotion") === true) return;
5948
6353
  this.magnitude = Math.max(this.magnitude, magnitude);
5949
- this.t = seconds ?? this.falloff;
5950
- recordEffect(this.engine, "shake", "", this.node?.getPath() ?? "engine", magnitude);
6354
+ this.t = seconds;
6355
+ recordEffect(this.engine, "shake", "", this.node?.getPath() ?? "engine", magnitude, this.node?.wiringPath());
5951
6356
  }
5952
6357
  update(dt) {
5953
6358
  const node = requirePosition(this);
@@ -5960,14 +6365,15 @@ var CameraShake = class extends Behavior {
5960
6365
  }
5961
6366
  return;
5962
6367
  }
5963
- this.t -= dt;
6368
+ this.t -= this.engine?.unscaledDelta || dt;
5964
6369
  const k = this.magnitude * Math.max(0, this.t) / this.falloff;
5965
6370
  if (this.t <= 0) this.magnitude = 0;
5966
6371
  const next = pos.slice();
5967
6372
  if (this.prev.length !== next.length) this.prev = new Array(next.length).fill(0);
5968
6373
  for (let i = 0; i < next.length; i++) {
5969
6374
  const scale = next.length >= 3 && i === 1 ? .5 : 1;
5970
- this.prev[i] = (this.engine.rng.next() * 2 - 1) * k * scale;
6375
+ this.shakeRng ??= effectRng(this.node);
6376
+ this.prev[i] = (this.shakeRng.next() * 2 - 1) * k * scale;
5971
6377
  next[i] = (next[i] ?? 0) + this.prev[i];
5972
6378
  }
5973
6379
  node.position = next;
@@ -6014,19 +6420,21 @@ function hitStop(engine, seconds = .08) {
6014
6420
  state.until = Math.max(state.until, engine.unscaledTime + seconds);
6015
6421
  return;
6016
6422
  }
6017
- const restore = engine.timeScale;
6423
+ const restore = engine.timeScale === 0 ? 1 : engine.timeScale;
6018
6424
  const entry = {
6019
6425
  startedAt: engine.unscaledTime,
6020
6426
  until: engine.unscaledTime + seconds,
6021
6427
  restore,
6022
- release: () => {}
6428
+ release: () => {},
6429
+ ownedAt: 0
6023
6430
  };
6024
6431
  hitStops.set(engine, entry);
6025
6432
  engine.timeScale = 0;
6433
+ entry.ownedAt = engine.timeScaleWrites;
6026
6434
  entry.release = engine.updated.connect(() => {
6027
6435
  if (!liveHitStop(engine)) return;
6028
6436
  if (engine.unscaledTime < entry.until) return;
6029
- engine.timeScale = entry.restore;
6437
+ if (engine.timeScaleWrites === entry.ownedAt) engine.timeScale = entry.restore;
6030
6438
  entry.release();
6031
6439
  hitStops.delete(engine);
6032
6440
  });
@@ -6053,7 +6461,7 @@ function liveHitStop(engine) {
6053
6461
  entry.release();
6054
6462
  hitStops.delete(engine);
6055
6463
  }
6056
- const hitStops = /* @__PURE__ */ new Map();
6464
+ const hitStops = /* @__PURE__ */ new WeakMap();
6057
6465
  //#endregion
6058
6466
  //#region src/gameplay/lifetime.ts
6059
6467
  /**
@@ -6458,13 +6866,40 @@ var Patrol = class extends Behavior {
6458
6866
  const raw = this.points[i];
6459
6867
  diagnose(this.node.tree?.engine ?? null, "error", `[incanto] ${this.node.getPath()}: waypoint ${i} (${JSON.stringify(raw)}) resolves to nothing — the patrol stops here and never moves again.`);
6460
6868
  }
6461
- /** Resolve waypoint `i` to a position, reading node paths live. */
6869
+ /**
6870
+ * Resolve waypoint `i` to a position in the MOVER's frame, reading node paths
6871
+ * live.
6872
+ *
6873
+ * A node-path waypoint used to return the marker's raw `position`, which is
6874
+ * parent-relative — and it was then fed to `moveToward(mover.position, …)`,
6875
+ * i.e. read in the MOVER's frame. So a walker and its markers under different
6876
+ * parents walked a shifted copy of the path, silently, with the marker nodes
6877
+ * sitting right there in the scene. Measured on a tower-defence lane
6878
+ * organised the ordinary way (markers under `/root/Path`, spawns under a
6879
+ * container at [-50, 90]): every creep stacked motionless 90 px below and
6880
+ * 72 px short of the keep, `incanto-check` `1/1 scene(s) valid`, zero errors,
6881
+ * zero warnings, and the wave could be neither won nor lost.
6882
+ *
6883
+ * `Chase` has converted through world space since the identical bug was found
6884
+ * there ("comparing two nodes' raw `position` only worked while they happened
6885
+ * to share a parent"), and a `FollowCamera` aimed at a node uses its world
6886
+ * position too. Patrol was the outlier.
6887
+ *
6888
+ * A LITERAL array stays local, because that is the only frame an author can
6889
+ * mean when they type numbers into a behaviour's props.
6890
+ *
6891
+ * `reportDeadWaypoint` could not see this: it fires when a path resolves to
6892
+ * NOTHING, and a path that resolves into the wrong frame resolves fine.
6893
+ */
6462
6894
  pointAt(i) {
6463
6895
  const raw = this.points[i];
6464
6896
  if (Array.isArray(raw)) return raw;
6465
6897
  if (typeof raw === "string") {
6466
6898
  const marker = this.node.getNodeOrNull(raw);
6467
- return Array.isArray(marker?.position) ? marker.position : null;
6899
+ if (!marker || !hasPosition$1(marker)) return null;
6900
+ const mover = this.moveParent ? this.node.parent : this.node;
6901
+ if (!mover || !hasPosition$1(mover)) return null;
6902
+ return localFromWorld(mover, worldPosition(marker));
6468
6903
  }
6469
6904
  return null;
6470
6905
  }
@@ -6549,6 +6984,103 @@ function findCollector(node, group) {
6549
6984
  return null;
6550
6985
  }
6551
6986
  //#endregion
6987
+ //#region src/gameplay/prefab-shelf.ts
6988
+ /**
6989
+ * A shelf of templates the GAME clones itself — towers, bolts, damage numbers.
6990
+ *
6991
+ * `Spawner` detaches its one prefab at enter, so a spawned enemy's whole
6992
+ * subtree is live and nobody has to think about it. A game that clones on its
6993
+ * own terms — a tower the player places, a bolt that tower fires — has no
6994
+ * spawner, so its templates sat in the tree as LIVE nodes: their behaviours
6995
+ * ticked, their turrets shot, and `visible: false` hid a node without switching
6996
+ * it off. The documented answer was to author every template `"enabled": false`
6997
+ * and wake the clone with `clone.behavior?.enable()`.
6998
+ *
6999
+ * That wakes ONE node, and the same docs teach that a node carries one
7000
+ * behaviour — so a prefab's parts live on children, and every one of those
7001
+ * stays asleep, silently and for good, because `enabled` is a pause switch that
7002
+ * never complains. Measured on a shipped tower defense: the bolt's
7003
+ * `Projectile` was woken and its sibling `Lifetime` was not, so every bolt that
7004
+ * MISSED flew forever — five still in the tree at 69 seconds, in a game that
7005
+ * passed its harness, its audit and every rung of the ladder.
7006
+ *
7007
+ * ```json
7008
+ * { "name": "Prefabs", "type": "Node2D", "script": { "name": "PrefabShelf" },
7009
+ * "children": [ { "name": "Tower", "type": "Node2D", "children": [] } ] }
7010
+ * ```
7011
+ * ```ts
7012
+ * const shelf = this.node.getNode('/Game/Prefabs').behavior as PrefabShelf;
7013
+ * const tower = shelf.make('Tower'); // detached, like `duplicateNode`
7014
+ * tower.position = at; // set it up BEFORE it readies
7015
+ * this.node.getNode('/Game/Towers').addChild(tower);
7016
+ * ```
7017
+ *
7018
+ * The children never ENTER the tree, so nothing has to be asleep, nothing has
7019
+ * to be hidden, and `make()` hands back a clone that is awake all the way down.
7020
+ */
7021
+ var PrefabShelf = class extends Behavior {
7022
+ static props = {
7023
+ /**
7024
+ * Which children to take, comma-separated. Empty (the default) takes them
7025
+ * ALL, which is what a shelf is; name them when the node doubles as
7026
+ * something else and only some of its children are templates.
7027
+ */
7028
+ prefabs: { default: "" } };
7029
+ static signals = [];
7030
+ prefabs = "";
7031
+ /** Templates, in the order they were authored. */
7032
+ held = /* @__PURE__ */ new Map();
7033
+ onEnterTree() {
7034
+ this.claim();
7035
+ }
7036
+ /** A shelf attached at runtime never saw `onEnterTree`; idempotent. */
7037
+ onReady() {
7038
+ this.claim();
7039
+ }
7040
+ claim() {
7041
+ const wanted = this.prefabs.split(",").map((name) => name.trim()).filter((name) => name !== "");
7042
+ const from = wanted.length > 0 ? wanted : [...this.node.children].map((child) => child.name);
7043
+ for (const name of from) {
7044
+ if (this.held.has(name)) continue;
7045
+ const template = this.node.children.find((child) => child.name === name);
7046
+ if (!template) throw new IncantoError("NODE_NOT_FOUND", `PrefabShelf on '${this.node.getPath()}': "prefabs" names '${name}', which is not a child of this node. It holds: ${this.childNames() || "(nothing)"}.`, {
7047
+ prop: "prefabs",
7048
+ path: this.node.getPath()
7049
+ });
7050
+ rememberAuthoredPath(template);
7051
+ this.node.removeChild(template);
7052
+ this.held.set(name, template);
7053
+ }
7054
+ }
7055
+ /** What this shelf holds, in authored order. */
7056
+ names() {
7057
+ return [...this.held.keys()];
7058
+ }
7059
+ /**
7060
+ * A live clone of one template, DETACHED — `addChild` it yourself.
7061
+ *
7062
+ * Detached for the same reason `duplicateNode` is, and it is not a
7063
+ * formality: `onReady` fires on attach, and a behaviour that reads its node
7064
+ * there (`FloatAway` banks its starting position) would bank the wrong one.
7065
+ * Set the clone up, then attach it.
7066
+ *
7067
+ * The clone is VISIBLE even if the template was authored hidden (a scene
7068
+ * migrating off the old `visible: false` shelf keeps working), and every
7069
+ * behaviour in it is however it was authored: awake unless the template
7070
+ * itself asks to sleep, which is now only ever a state machine's own doing.
7071
+ */
7072
+ make(name) {
7073
+ const template = this.held.get(name);
7074
+ if (!template) throw new IncantoError("NODE_NOT_FOUND", `PrefabShelf on '${this.node.getPath()}': no template named '${name}'. This shelf holds: ${this.childNames() || "(nothing)"}.`, { path: this.node.getPath() });
7075
+ const clone = duplicateNode(template, rebuildOptions(this.node));
7076
+ if (typeof clone.visible === "boolean") clone.visible = true;
7077
+ return clone;
7078
+ }
7079
+ childNames() {
7080
+ return this.names().join(", ");
7081
+ }
7082
+ };
7083
+ //#endregion
6552
7084
  //#region src/gameplay/projectile.ts
6553
7085
  /**
6554
7086
  * Constant-velocity motion in a straight line — bullets, arrows, thrown rocks.
@@ -6562,6 +7094,26 @@ function findCollector(node, group) {
6562
7094
  * `null` (the engine's "any JSON" escape hatch) and `null` means `'forward'`.
6563
7095
  * `gravity` adds a constant downward (+y, the 2D y-down convention) pull, for
6564
7096
  * lobbed/arcing shots.
7097
+ *
7098
+ * **Every prop here is live.** `speed` and `direction` re-bake the heading when
7099
+ * written, and the heading is resolved on the FIRST STEP rather than at
7100
+ * `onReady` — because `onReady` is before the one place a game aims a spawned
7101
+ * bullet. `SpawnSource.spawn` ends with `parent.addChild(clone)` and `Spawner`
7102
+ * emits `spawned(node)` after that, so a handler that sets a rotation or a
7103
+ * direction was always too late. Measured at speed 100 over 60 frames:
7104
+ *
7105
+ * ```
7106
+ * A rotation 0 direction [0,1] -> pos [ 0, 100] authored: obeyed
7107
+ * B rotation 90 direction "forward" -> pos [ 0, 100] authored: obeyed
7108
+ * C rotation 90 set after addChild -> pos [100, 0] IGNORED
7109
+ * D direction [0,1] set after add -> pos [100, 0] IGNORED — and it read
7110
+ * back [0,1] while
7111
+ * travelling +x
7112
+ * ```
7113
+ *
7114
+ * What is deliberately NOT live is `rotation` after that first step: a
7115
+ * projectile that spins for looks would have its spin become its trajectory.
7116
+ * Call `aim()` to re-derive from the current rotation on purpose.
6565
7117
  */
6566
7118
  var Projectile = class extends Behavior {
6567
7119
  static props = {
@@ -6573,31 +7125,76 @@ var Projectile = class extends Behavior {
6573
7125
  gravity: { default: 0 }
6574
7126
  };
6575
7127
  static signals = [];
6576
- speed = 300;
6577
- direction = null;
7128
+ _speed = 300;
7129
+ _direction = null;
6578
7130
  gravity = 0;
7131
+ /** The straight-line part, `direction × speed`. Empty until the first step. */
6579
7132
  velocity = [];
6580
- onReady() {
6581
- if (this.speed === 0 && this.gravity === 0) throw new IncantoError("PROP_TYPE_MISMATCH", `Projectile on '${this.node.getPath()}': a projectile with "speed" 0 and "gravity" 0 never moves — set one of them.`, { prop: "speed" });
7133
+ /**
7134
+ * The +y accumulated by `gravity`, kept APART from `velocity`.
7135
+ *
7136
+ * Re-aiming replaces the straight-line part; the fall is a fact about the
7137
+ * world, not about the shot, so an arrow re-aimed halfway down keeps falling
7138
+ * rather than snapping back to level.
7139
+ */
7140
+ fall = 0;
7141
+ /** Units per second along `direction`. Writable at any time. */
7142
+ get speed() {
7143
+ return this._speed;
7144
+ }
7145
+ set speed(value) {
7146
+ this._speed = value;
7147
+ if (this.armed) this.aim();
7148
+ }
7149
+ /** A vector (`[x,y(,z)]`) or `'forward'`. Writable at any time. */
7150
+ get direction() {
7151
+ return this._direction;
7152
+ }
7153
+ set direction(value) {
7154
+ this._direction = value;
7155
+ if (this.armed) this.aim();
7156
+ else if (this.node) this.resolveDirection();
7157
+ }
7158
+ get armed() {
7159
+ return this.velocity.length > 0;
7160
+ }
7161
+ /**
7162
+ * Re-derive the heading from `direction` (or, for `'forward'`, from the
7163
+ * node's CURRENT rotation) and bake it into the velocity.
7164
+ *
7165
+ * This is what a `spawned(node)` handler calls after turning the clone, and
7166
+ * the only way a `'forward'` projectile ever re-reads its rotation.
7167
+ */
7168
+ aim(direction = void 0) {
7169
+ if (direction !== void 0) this._direction = direction;
6582
7170
  const node = requirePosition(this);
6583
7171
  const dir = this.resolveDirection();
6584
- this.velocity = dir.map((c) => c * this.speed);
7172
+ this.velocity = dir.map((c) => c * this._speed);
6585
7173
  while (this.velocity.length < node.position.length) this.velocity.push(0);
6586
7174
  }
7175
+ onReady() {
7176
+ if (this.speed === 0 && this.gravity === 0) throw new IncantoError("PROP_TYPE_MISMATCH", `Projectile on '${this.node.getPath()}': a projectile with "speed" 0 and "gravity" 0 never moves — set one of them.`, { prop: "speed" });
7177
+ requirePosition(this);
7178
+ this.resolveDirection();
7179
+ }
6587
7180
  update(dt) {
7181
+ if (!this.armed) this.aim();
6588
7182
  const node = this.node;
6589
- if (this.gravity !== 0) this.velocity[1] = (this.velocity[1] ?? 0) + this.gravity * dt;
7183
+ if (this.gravity !== 0) this.fall += this.gravity * dt;
6590
7184
  const pos = node.position;
6591
7185
  const n = Math.max(pos.length, this.velocity.length);
6592
7186
  const next = new Array(n);
6593
- for (let i = 0; i < n; i++) next[i] = (pos[i] ?? 0) + (this.velocity[i] ?? 0) * dt;
7187
+ for (let i = 0; i < n; i++) {
7188
+ const v = (this.velocity[i] ?? 0) + (i === 1 ? this.fall : 0);
7189
+ next[i] = (pos[i] ?? 0) + v * dt;
7190
+ }
6594
7191
  node.position = next;
6595
7192
  }
6596
7193
  /** Resolve `direction` to a unit vector, deriving 'forward' from rotation. */
6597
7194
  resolveDirection() {
6598
- if (Array.isArray(this.direction)) return normalize(this.direction);
6599
- if (this.direction === null || this.direction === "forward") return this.forwardFromRotation();
6600
- throw new IncantoError("PROP_TYPE_MISMATCH", `Projectile on '${this.node.getPath()}': "direction" must be a vector or 'forward', got ${JSON.stringify(this.direction)}.`, { prop: "direction" });
7195
+ if (Array.isArray(this._direction)) return normalize(this._direction);
7196
+ if (this._direction === null || this._direction === "forward") return this.forwardFromRotation();
7197
+ throw new IncantoError("PROP_TYPE_MISMATCH", `Projectile on '${this.node.getPath()}': "direction" must be a vector or 'forward', got ${JSON.stringify(this._direction)}.`, { prop: "direction" });
6601
7198
  }
6602
7199
  forwardFromRotation() {
6603
7200
  const rot = this.node.rotation;
@@ -6664,6 +7261,7 @@ var SavePoint = class extends Behavior {
6664
7261
  };
6665
7262
  static signals = [
6666
7263
  "saved",
7264
+ "saveFailed",
6667
7265
  "restored",
6668
7266
  "noSave",
6669
7267
  "hasSave"
@@ -6747,9 +7345,16 @@ var SavePoint = class extends Behavior {
6747
7345
  */
6748
7346
  save() {
6749
7347
  const engine = this.engine;
7348
+ const state = engine.captureState();
7349
+ const failures = engine.lastCaptureFailures;
7350
+ if (failures.length > 0) {
7351
+ engine.log.error(`[incanto] SavePoint '${this.slot}' REFUSED to write: ${failures.length} behavior(s) threw out of serialize(), so this save is missing their run and would overwrite the slot that is not. First: ${failures[0]}`);
7352
+ this.emit("saveFailed", this.slot);
7353
+ return;
7354
+ }
6750
7355
  this.store().write(this.slot, {
6751
7356
  scene: this.scene || (engine.scene?.source.name ?? ""),
6752
- state: engine.captureState(),
7357
+ state,
6753
7358
  label: this.label,
6754
7359
  savedAt: Date.now(),
6755
7360
  playtime: Math.round(this.elapsed)
@@ -6774,6 +7379,9 @@ var SavePoint = class extends Behavior {
6774
7379
  return;
6775
7380
  }
6776
7381
  const report = this.engine.restoreState(slot.state);
7382
+ const here = this.scene || this.engine?.scene?.source.name;
7383
+ if (here && slot.scene && slot.scene !== here) diagnose(this.engine, "error", `[incanto] SavePoint '${this.slot}': this slot was saved in scene '${slot.scene}' and you are in '${here}'. Load that scene first — a save is restored INTO the scene it came from (incanto-save-slots.md).`);
7384
+ else if (report.expected > 0 && report.restored === 0) diagnose(this.engine, "error", `[incanto] SavePoint '${this.slot}': restored 0 of ${report.expected} saveable behaviour(s)` + (report.missing.length > 0 ? ` — ${report.missing.length} uid(s) in the save are not in this scene, starting with '${report.missing[0]}'. That save is from an older build, or from another scene.` : ". Nothing in the save matched a node here."));
6777
7385
  this.emit("restored", report.restored);
6778
7386
  }
6779
7387
  /** Forget this slot — a "delete save" button. */
@@ -6813,7 +7421,8 @@ var ScoreKeeper = class extends Behavior {
6813
7421
  "scoreChanged",
6814
7422
  "won",
6815
7423
  "lost",
6816
- "lifeLost"
7424
+ "lifeLost",
7425
+ "livesChanged"
6817
7426
  ];
6818
7427
  score = 0;
6819
7428
  lives = 0;
@@ -6826,11 +7435,11 @@ var ScoreKeeper = class extends Behavior {
6826
7435
  }
6827
7436
  /** Add `n` to the score (negative subtracts), emit `scoreChanged`, check win. */
6828
7437
  addScore(n) {
6829
- this.setScore(this.score + n);
7438
+ this.setScore(this.score + requireNumber(n, this.node, "ScoreKeeper.addScore"));
6830
7439
  }
6831
7440
  /** Set the score to `value`, emit `scoreChanged`, check win. */
6832
7441
  setScore(value) {
6833
- this.score = value;
7442
+ this.score = requireNumber(value, this.node, "ScoreKeeper.setScore");
6834
7443
  this.emit("scoreChanged", this.score);
6835
7444
  if (this.scoreToWin > 0 && !this.hasWon && this.score >= this.scoreToWin) {
6836
7445
  this.hasWon = true;
@@ -6842,6 +7451,7 @@ var ScoreKeeper = class extends Behavior {
6842
7451
  if (this.lives <= 0 || this.hasLost) return;
6843
7452
  this.lives -= 1;
6844
7453
  this.emit("lifeLost", this.lives);
7454
+ this.emit("livesChanged", this.lives);
6845
7455
  if (this.lives <= 0) {
6846
7456
  this.hasLost = true;
6847
7457
  this.emit("lost");
@@ -6863,6 +7473,17 @@ var ScoreKeeper = class extends Behavior {
6863
7473
  if (typeof d.hasWon === "boolean") this.hasWon = d.hasWon;
6864
7474
  if (typeof d.hasLost === "boolean") this.hasLost = d.hasLost;
6865
7475
  }
7476
+ /**
7477
+ * The score line and the lives counter. NOT `won`/`lost`.
7478
+ *
7479
+ * Re-firing those on load is how a Continue lands straight on the game-over
7480
+ * screen it was loaded to escape — the flags come back through `hasWon` /
7481
+ * `hasLost` and the game reads them, rather than being told again.
7482
+ */
7483
+ announce() {
7484
+ this.emit("scoreChanged", this.score);
7485
+ this.emit("livesChanged", this.lives);
7486
+ }
6866
7487
  };
6867
7488
  //#endregion
6868
7489
  //#region src/gameplay/spawn-source.ts
@@ -6898,6 +7519,12 @@ var SpawnSource = class {
6898
7519
  const cached = this.detached.get(prefab);
6899
7520
  if (cached) return cached;
6900
7521
  const node = this.behavior.node;
7522
+ const claim = absolutePrefabPath(node, prefab);
7523
+ const shared = claim ? node.tree?._templates.get(claim) ?? null : null;
7524
+ if (shared) {
7525
+ this.detached.set(prefab, shared);
7526
+ return shared;
7527
+ }
6901
7528
  const template = prefab === "" ? null : node.getNodeOrNull(prefab);
6902
7529
  if (!template) {
6903
7530
  const behaviorName = this.behavior.constructor.name;
@@ -6906,6 +7533,8 @@ var SpawnSource = class {
6906
7533
  path: node.getPath()
6907
7534
  });
6908
7535
  }
7536
+ if (claim) node.tree?._templates.set(claim, template);
7537
+ rememberAuthoredPath(template);
6909
7538
  if (template.parent) template.parent.removeChild(template);
6910
7539
  this.detached.set(prefab, template);
6911
7540
  return template;
@@ -6932,8 +7561,8 @@ var SpawnSource = class {
6932
7561
  * The scene loads, the spawner spawns, the clone is alive and visible — it is
6933
7562
  * just somewhere else in the level, and nothing has anything to report.
6934
7563
  */
6935
- spawn(template, opts) {
6936
- const clone = duplicateNode(template);
7564
+ spawn(template, opts = void 0) {
7565
+ const clone = duplicateNode(template, rebuildOptions(this.behavior.node));
6937
7566
  const at = opts?.at;
6938
7567
  if (at && at.length > 0 && hasPosition$1(clone)) clone.position = add((opts?.parent ?? this.behavior.node) === this.behavior.node || !hasPosition$1(this.behavior.node) ? [] : this.behavior.node.position, at);
6939
7568
  if (typeof clone.visible === "boolean") clone.visible = true;
@@ -6941,6 +7570,29 @@ var SpawnSource = class {
6941
7570
  return clone;
6942
7571
  }
6943
7572
  };
7573
+ /**
7574
+ * The absolute path a relative prefab reference points at, resolved against the
7575
+ * spawner's own position — the key two spawners can agree on.
7576
+ *
7577
+ * Returns null for a form there is no single answer for (`%Unique` names a node
7578
+ * by identity, not by place), in which case the template is simply not shared —
7579
+ * which is the behaviour that already existed.
7580
+ */
7581
+ function absolutePrefabPath(node, prefab) {
7582
+ if (prefab === "" || prefab.startsWith("%")) return null;
7583
+ if (prefab.startsWith("/")) return prefab;
7584
+ const parts = node.getPath().split("/");
7585
+ for (const step of prefab.split("/")) {
7586
+ if (step === "" || step === ".") continue;
7587
+ if (step === "..") {
7588
+ if (parts.length <= 1) return null;
7589
+ parts.pop();
7590
+ continue;
7591
+ }
7592
+ parts.push(step);
7593
+ }
7594
+ return parts.join("/");
7595
+ }
6944
7596
  //#endregion
6945
7597
  //#region src/gameplay/spawner.ts
6946
7598
  /**
@@ -6999,7 +7651,11 @@ var Spawner = class extends Behavior {
6999
7651
  _templateForTest() {
7000
7652
  return this.template;
7001
7653
  }
7654
+ onEnterTree() {
7655
+ this.template = this.source.resolveTemplate(this.prefab);
7656
+ }
7002
7657
  onReady() {
7658
+ if (!(this.interval > 0) && this.max <= 0 && this.total <= 0) throw new IncantoError("PROP_TYPE_MISMATCH", `Spawner on '${this.node.getPath()}': "interval" ${this.interval} spawns a clone every FRAME and neither "max" (live cap) nor "total" (lifetime cap) is set, so the scene grows without bound. Give it an interval, or a cap.`, { prop: "interval" });
7003
7659
  this.template = this.source.resolveTemplate(this.prefab);
7004
7660
  this.running = this.autoStart;
7005
7661
  this.timer = this.interval;
@@ -7086,9 +7742,10 @@ var Wander = class extends Behavior {
7086
7742
  pickGoal() {
7087
7743
  this.timer = this.changeEvery;
7088
7744
  const dims = this.origin.length;
7745
+ const up = dims === 3 ? 1 : -1;
7089
7746
  const next = new Array(dims);
7090
7747
  for (let attempt = 0; attempt < 8; attempt++) {
7091
- for (let i = 0; i < dims; i++) next[i] = (this.origin[i] ?? 0) + this.rng.range(-this.radius, this.radius);
7748
+ for (let i = 0; i < dims; i++) next[i] = i === up ? this.origin[i] ?? 0 : (this.origin[i] ?? 0) + this.rng.range(-this.radius, this.radius);
7092
7749
  if (distance$1(next, this.origin) <= this.radius) {
7093
7750
  this.goal = next;
7094
7751
  return;
@@ -7110,8 +7767,9 @@ var Wander = class extends Behavior {
7110
7767
  * every `interval` seconds, then waits until they're ALL cleared (freed) before
7111
7768
  * starting the next wave.
7112
7769
  *
7113
- * Signals: `waveStarted(i)` when a wave begins spawning, `waveCleared(i)` when
7114
- * its last instance frees, `allCleared` after the final wave clears.
7770
+ * Signals: `spawned(node)` per clone, `waveStarted(i)` when a wave begins
7771
+ * spawning, `waveCleared(i)` when its last instance frees, `allCleared` after
7772
+ * the final wave clears.
7115
7773
  */
7116
7774
  var WaveSpawner = class extends Behavior {
7117
7775
  static props = {
@@ -7124,6 +7782,7 @@ var WaveSpawner = class extends Behavior {
7124
7782
  autoStart: { default: true }
7125
7783
  };
7126
7784
  static signals = [
7785
+ "spawned",
7127
7786
  "waveStarted",
7128
7787
  "waveCleared",
7129
7788
  "allCleared"
@@ -7138,6 +7797,13 @@ var WaveSpawner = class extends Behavior {
7138
7797
  timer = 0;
7139
7798
  spawnedThisWave = 0;
7140
7799
  announced = false;
7800
+ onEnterTree() {
7801
+ if (!Array.isArray(this.waves) || this.waves.length === 0) return;
7802
+ for (const raw of this.waves) {
7803
+ const prefab = raw.prefab;
7804
+ if (typeof prefab === "string" && prefab !== "") this.source.resolveTemplate(prefab);
7805
+ }
7806
+ }
7141
7807
  onReady() {
7142
7808
  if (!Array.isArray(this.waves) || this.waves.length === 0) throw new IncantoError("PROP_TYPE_MISMATCH", `WaveSpawner on '${this.node.getPath()}': "waves" must be a non-empty array of { prefab, count, interval, delayBefore }.`, { prop: "waves" });
7143
7809
  this.parsed = this.waves.map((raw, i) => this.parseWave(raw, i));
@@ -7148,6 +7814,38 @@ var WaveSpawner = class extends Behavior {
7148
7814
  start() {
7149
7815
  this.beginWave(0);
7150
7816
  }
7817
+ /**
7818
+ * Which wave you were on — and nothing else.
7819
+ *
7820
+ * `behaviorsWithoutSave` is documented as "the list to read before shipping",
7821
+ * and nobody had read it against this repo's own examples. Five of them put a
7822
+ * `WaveSpawner` at the centre of the game (`fps-3d`, `tps-3d`, `tps-3d-neon`,
7823
+ * `tps-3d-ruins`, `dogfight-3d`), and every one of them saved a run on wave 3
7824
+ * and continued it on wave 1 — silently, because a save reloads the scene
7825
+ * from source and a fresh `onReady` honestly starts at the beginning.
7826
+ *
7827
+ * The wave RESTARTS rather than resuming mid-spawn, which is the same
7828
+ * checkpoint rule the rest of the save system follows: spawned entities are
7829
+ * never restored, so resuming "four enemies into wave 3" would mean resuming
7830
+ * a wave whose enemies do not exist. `waveStarted` fires again for the HUD.
7831
+ */
7832
+ serialize() {
7833
+ return {
7834
+ wave: this.waveIndex,
7835
+ done: this.phase === "done"
7836
+ };
7837
+ }
7838
+ deserialize(data) {
7839
+ const d = data ?? {};
7840
+ if (d.done === true) {
7841
+ this.phase = "done";
7842
+ this.waveIndex = this.parsed.length - 1;
7843
+ return;
7844
+ }
7845
+ if (typeof d.wave !== "number" || !Number.isInteger(d.wave)) return;
7846
+ const wave = Math.max(0, Math.min(d.wave, this.parsed.length - 1));
7847
+ this.beginWave(wave);
7848
+ }
7151
7849
  update(dt) {
7152
7850
  if (this.phase === "idle" || this.phase === "done") return;
7153
7851
  const wave = this.parsed[this.waveIndex];
@@ -7170,6 +7868,7 @@ var WaveSpawner = class extends Behavior {
7170
7868
  const clone = this.source.spawn(this.source.resolveTemplate(wave.prefab));
7171
7869
  this.live.add(clone);
7172
7870
  this.spawnedThisWave += 1;
7871
+ this.emit("spawned", clone);
7173
7872
  this.timer += wave.interval;
7174
7873
  }
7175
7874
  if (this.spawnedThisWave >= wave.count) this.phase = "clearing";
@@ -7426,7 +8125,8 @@ const GAMEPLAY_BEHAVIORS = {
7426
8125
  WaveSpawner,
7427
8126
  Projectile,
7428
8127
  Buoyancy,
7429
- FloatAway
8128
+ FloatAway,
8129
+ PrefabShelf
7430
8130
  };
7431
8131
  /**
7432
8132
  * Register all built-in gameplay behaviors. Idempotent and hot-reload tolerant
@@ -7438,4 +8138,4 @@ function registerGameplayBehaviors(opts) {
7438
8138
  for (const [name, ctor] of Object.entries(GAMEPLAY_BEHAVIORS)) registerBehavior(name, ctor, { replace });
7439
8139
  }
7440
8140
  //#endregion
7441
- export { DamageOnContact as A, rejectScale as B, restartScene as C, FaceTarget as D, FloatAway as E, localFromWorld as F, StaticBody3D as G, CharacterBody3D as H, worldPosition as I, createCausticsQuad as J, Node3D as K, Water3D as L, Currency as M, Collector as N, DayNight as O, Chase as P, WATER_CUTOUT_MAX as R, goToScene as S, FollowCamera as T, PhysicsBody3D as U, Area3D as V, RigidBody3D as W, colliderFootDrop as X, WATER_MAX_RIPPLES as Y, Cooldown as _, Wander as a, Interactable as b, SavePoint as c, Patrol as d, PathFollow as f, CameraShake as g, Lifetime as h, WaveSpawner as i, Health as j, phaseOf as k, Projectile as l, MoveTo as m, registerGameplayBehaviors as n, Spawner as o, Oscillate as p, validateCollider3D as q, ZombieAI as r, ScoreKeeper as s, GAMEPLAY_BEHAVIORS as t, Pickup as u, hitStop as v, tolerateUnknownAction as w, GameFlow as x, screenFlash as y, WaterCutout3D as z };
8141
+ export { DayNight as A, rejectScale as B, goToScene as C, effectRng as D, FollowCamera as E, Collector as F, StaticBody3D as G, CharacterBody3D as H, Chase as I, createCausticsQuad as J, Node3D as K, Water3D as L, DamageOnContact as M, Health as N, FloatAway as O, Currency as P, worldPosition as Q, WATER_CUTOUT_MAX as R, GameFlow as S, tolerateUnknownAction as T, PhysicsBody3D as U, Area3D as V, RigidBody3D as W, colliderFootDrop as X, WATER_MAX_RIPPLES as Y, localFromWorld as Z, CameraShake as _, Wander as a, screenFlash as b, SavePoint as c, Pickup as d, Patrol as f, Lifetime as g, MoveTo as h, WaveSpawner as i, phaseOf as j, FaceTarget as k, Projectile as l, Oscillate as m, registerGameplayBehaviors as n, Spawner as o, PathFollow as p, validateCollider3D as q, ZombieAI as r, ScoreKeeper as s, GAMEPLAY_BEHAVIORS as t, PrefabShelf as u, Cooldown as v, restartScene as w, Interactable as x, hitStop as y, WaterCutout3D as z };