incanto 0.68.0 → 0.70.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 (153) hide show
  1. package/assets/catalog.json +9 -5
  2. package/bin/_behaviors-loader.mjs +22 -0
  3. package/bin/_read-json.mjs +28 -0
  4. package/bin/incanto-assets.mjs +19 -5
  5. package/bin/incanto-check.mjs +92 -15
  6. package/bin/incanto-editor.mjs +128 -5
  7. package/bin/incanto-env.mjs +3 -2
  8. package/bin/incanto-feel.mjs +24 -13
  9. package/bin/incanto-frame.mjs +8 -1
  10. package/bin/incanto-multiplay.mjs +11 -9
  11. package/bin/incanto-new.mjs +128 -5
  12. package/bin/incanto-play.mjs +158 -16
  13. package/bin/incanto-playtest.mjs +66 -23
  14. package/bin/incanto-serve.mjs +160 -0
  15. package/bin/incanto-skills.mjs +14 -2
  16. package/bin/incanto-verify.mjs +165 -44
  17. package/bin/incanto.mjs +4 -2
  18. package/dist/2d.d.ts +285 -36
  19. package/dist/2d.js +4 -4
  20. package/dist/3d.d.ts +158 -10
  21. package/dist/3d.js +8 -8
  22. package/dist/{agent8-CvsfVskX.js → agent8-CmNF01gA.js} +61 -8
  23. package/dist/{audio-player-DOrq7sP-.d.ts → audio-player-DaMxqfNE.d.ts} +33 -14
  24. package/dist/{behavior-DoFPYrgo.d.ts → behavior-DZExDn9o.d.ts} +809 -44
  25. package/dist/{create-game-IZIydDwI.js → create-game-Bwvh6q8A.js} +148 -61
  26. package/dist/{create-game-DbWtVTxD.js → create-game-C7ffQWW7.js} +103 -49
  27. package/dist/debug.d.ts +1 -1
  28. package/dist/debug.js +2 -3
  29. package/dist/diagnostics-Cu85N3tL.d.ts +12 -0
  30. package/dist/{editor-switch-CnIOiyNJ.d.ts → editor-switch-CFU9mCec.d.ts} +22 -13
  31. package/dist/editor.js +1066 -864
  32. package/dist/env.d.ts +1 -1
  33. package/dist/env.js +5 -3
  34. package/dist/{environment-presets-CybQXNqS.js → environment-presets-D6Q5BxeE.js} +299 -46
  35. package/dist/{frame-report-DCnHFmto.d.ts → frame-report-DNxDAb1w.d.ts} +8 -0
  36. package/dist/{frame-report-BSMny7oe.js → frame-report-Dlq13Gyj.js} +1 -0
  37. package/dist/{gameplay-DM1eu_cV.js → gameplay-BfHkuzVb.js} +825 -221
  38. package/dist/gameplay.d.ts +187 -7
  39. package/dist/gameplay.js +1 -1
  40. package/dist/{heightmap-CRK0M4jT.js → heightmap-BYgD5Edk.js} +1 -1
  41. package/dist/index.d.ts +156 -13
  42. package/dist/index.js +10 -12
  43. package/dist/json-CfTjpvW8.js +67 -0
  44. package/dist/{loader-CcB533FR.d.ts → loader-Cff09LMm.d.ts} +2 -2
  45. package/dist/net.d.ts +27 -3
  46. package/dist/net.js +2 -2
  47. package/dist/{noise-CGUMx44x.js → noise-D3nPpmFg.js} +1 -1
  48. package/dist/{physics-2d-B7Y6dPZO.js → physics-2d-CE0Qvy3V.js} +136 -11
  49. package/dist/{physics-3d-bG3n70Ky.js → physics-3d-CpH-2gn5.js} +104 -23
  50. package/dist/{teardown-D2NEmxPB.js → picking-CQJ_PJKh.js} +106 -14
  51. package/dist/react.d.ts +2 -2
  52. package/dist/react.js +2 -2
  53. package/dist/{register-3ta-2Xig.js → register-6DYnKZcy.js} +652 -831
  54. package/dist/{register-ibCjm-wH.js → register-Bkk0wSDB.js} +348 -30
  55. package/dist/{replay-CYvhVHHN.js → replay-DjAkAzMq.js} +224 -14
  56. package/dist/{replay-Dvn8aeBd.d.ts → replay-Dmw-PKQu.d.ts} +20 -3
  57. package/dist/{schema-B6ugCV1Q.d.ts → rng-Bb-IutXB.d.ts} +38 -21
  58. package/dist/{rng-DP-SR7eg.js → rng-CDOMybym.js} +22 -0
  59. package/dist/{loader-BC4PNtJX.js → save-slots-BXVg148r.js} +4558 -2294
  60. package/dist/{sheet-grid-BT6N_Bjs.js → sheet-grid-Cea343VO.js} +6 -2
  61. package/dist/{split-screen-DhrSzZIB.d.ts → split-screen--k-XpBjr.d.ts} +36 -4
  62. package/dist/{split-screen-CYwDkbLF.js → split-screen-PL78oVXP.js} +159 -26
  63. package/dist/{sprite-animation-CY-mrr1L.js → sprite-animation-CqR2o3SA.js} +39 -8
  64. package/dist/{src-D7RIqXYF.js → src-Cxfiv1Hg.js} +2 -17
  65. package/dist/test-iHYVUcDK.js +4036 -0
  66. package/dist/test.d.ts +542 -30
  67. package/dist/test.js +3 -3
  68. package/dist/touch-BnCyPA0G.js +519 -0
  69. package/dist/vite.d.ts +54 -3
  70. package/dist/vite.js +349 -17
  71. package/dist/{webgl-unavailable-N9nQqesw.js → webgl-unavailable-C8aDbGmR.js} +56 -1
  72. package/editor/assets/agent8-D0MS174y.js +1 -0
  73. package/editor/assets/{debug-BBhKuBNV.js → debug-BnXkKuYu.js} +2 -2
  74. package/editor/assets/index-CIu3uc3l.js +11046 -0
  75. package/editor/index.html +1 -1
  76. package/package.json +7 -16
  77. package/schemas/scene.schema.json +30 -3
  78. package/skills/incanto-3d-character.md +14 -1
  79. package/skills/incanto-3d-models.md +12 -0
  80. package/skills/incanto-assets.md +25 -3
  81. package/skills/incanto-audio.md +27 -5
  82. package/skills/incanto-behaviors-and-scripts.md +83 -6
  83. package/skills/incanto-building-2d-games.md +106 -8
  84. package/skills/incanto-building-3d-games.md +118 -6
  85. package/skills/incanto-editor.md +46 -7
  86. package/skills/incanto-environment.md +19 -1
  87. package/skills/incanto-game-feel.md +70 -0
  88. package/skills/incanto-gameplay-behaviors.md +121 -14
  89. package/skills/incanto-hud.md +128 -7
  90. package/skills/incanto-localization.md +13 -5
  91. package/skills/incanto-multiplayer.md +83 -3
  92. package/skills/incanto-node-reference.md +222 -58
  93. package/skills/incanto-performance.md +52 -0
  94. package/skills/incanto-physics-and-input.md +123 -24
  95. package/skills/incanto-playtesting.md +78 -2
  96. package/skills/incanto-save-slots.md +188 -6
  97. package/skills/incanto-scene-json-authoring.md +69 -12
  98. package/skills/incanto-verifying-your-game.md +292 -12
  99. package/skills/incanto-web-integration.md +28 -0
  100. package/skills/incanto-your-first-game.md +5 -2
  101. package/templates-app/beacon-isle-3d/generate-world.ts +77 -9
  102. package/templates-app/beacon-isle-3d/package.json +2 -2
  103. package/templates-app/beacon-isle-3d/src/behaviors.ts +22 -0
  104. package/templates-app/beacon-isle-3d/src/game.scene.json +103 -378
  105. package/templates-app/beacon-isle-3d/src/main.ts +24 -4
  106. package/templates-app/beacon-isle-3d/tsconfig.json +1 -1
  107. package/templates-app/beacon-isle-3d/verify.ts +3 -1
  108. package/templates-app/molehill-2d/.incanto/playtest/lost-seed1.json +4277 -0
  109. package/templates-app/molehill-2d/PROJECT/Context.md +58 -0
  110. package/templates-app/molehill-2d/PROJECT/Requirements.md +39 -0
  111. package/templates-app/molehill-2d/PROJECT/Status.md +27 -0
  112. package/templates-app/molehill-2d/PROJECT/Structure.md +48 -0
  113. package/templates-app/molehill-2d/docs/project-2d-rules.md +44 -0
  114. package/templates-app/molehill-2d/index.html +73 -0
  115. package/templates-app/molehill-2d/package.json +23 -0
  116. package/templates-app/molehill-2d/src/behaviors.ts +198 -0
  117. package/templates-app/molehill-2d/src/game.scene.json +1255 -0
  118. package/templates-app/molehill-2d/src/main.ts +41 -0
  119. package/templates-app/molehill-2d/tsconfig.json +13 -0
  120. package/templates-app/molehill-2d/verify.ts +247 -0
  121. package/templates-app/molehill-2d/vite.config.ts +12 -0
  122. package/templates-app/platformer-2d/index.html +0 -23
  123. package/templates-app/platformer-2d/package.json +2 -2
  124. package/templates-app/platformer-2d/src/behaviors.ts +26 -16
  125. package/templates-app/platformer-2d/src/game.scene.json +143 -625
  126. package/templates-app/platformer-2d/src/main.ts +35 -13
  127. package/templates-app/platformer-2d/tsconfig.json +1 -1
  128. package/templates-app/star-survivor/package.json +2 -2
  129. package/templates-app/star-survivor/src/game.scene.json +41 -195
  130. package/templates-app/star-survivor/src/main.ts +28 -7
  131. package/templates-app/star-survivor/tsconfig.json +1 -1
  132. package/templates-app/tps-3d/PROJECT/Context.md +1 -1
  133. package/templates-app/tps-3d/package.json +2 -2
  134. package/templates-app/tps-3d/src/behaviors.ts +19 -1
  135. package/templates-app/tps-3d/src/game.scene.json +78 -217
  136. package/templates-app/tps-3d/src/main.ts +39 -17
  137. package/templates-app/tps-3d/tsconfig.json +1 -1
  138. package/templates-app/village-quest-3d/.incanto/playtest/swapped-seed1.json +1735 -0
  139. package/templates-app/village-quest-3d/package.json +2 -2
  140. package/templates-app/village-quest-3d/src/behaviors.ts +21 -0
  141. package/templates-app/village-quest-3d/src/grove.scene.json +54 -221
  142. package/templates-app/village-quest-3d/src/main.ts +24 -4
  143. package/templates-app/village-quest-3d/src/village.scene.json +199 -838
  144. package/templates-app/village-quest-3d/tsconfig.json +1 -1
  145. package/templates-app/village-quest-3d/verify.ts +14 -1
  146. package/dist/duplicate-IWIqk0HJ.js +0 -22
  147. package/dist/json-CwwhxQgb.js +0 -36
  148. package/dist/registry-CF70EArN.js +0 -212
  149. package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
  150. package/dist/test-8hoHeRmo.js +0 -2340
  151. package/dist/touch-DEAmqGdf.js +0 -225
  152. package/editor/assets/agent8-BrrHOjMJ.js +0 -1
  153. package/editor/assets/index-eVd0BToA.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-BC4PNtJX.js";
1
+ import { t as jsonClone } from "./json-CfTjpvW8.js";
2
+ import { K as Node, M as effectiveOrder, Q as Behavior, R as HudLayer, W as requireNumber, it as registerBehavior, n as SaveSlots, p as loadScene, q as diagnose, s as duplicateNode, u as rebuildOptions } from "./save-slots-BXVg148r.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-IWIqk0HJ.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 },
@@ -4321,10 +4549,18 @@ var Buoyancy = class extends Behavior {
4321
4549
  wet = false;
4322
4550
  /** Said once: a body that cannot be pushed will never start being able to. */
4323
4551
  reportedNoImpulse = false;
4552
+ /** Said once: there was no water to float on when this first looked. */
4553
+ reportedNoWater = false;
4324
4554
  update(dt) {
4325
4555
  if (dt <= 0) return;
4326
4556
  const water = this.resolveWater();
4327
- 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
+ }
4328
4564
  const node = this.node;
4329
4565
  if (!node.applyImpulse) {
4330
4566
  if (!this.reportedNoImpulse) {
@@ -4333,11 +4569,14 @@ var Buoyancy = class extends Behavior {
4333
4569
  }
4334
4570
  return;
4335
4571
  }
4336
- const [x, y, z] = [
4337
- node.position[0] ?? 0,
4338
- node.position[1] ?? 0,
4339
- node.position[2] ?? 0
4340
- ];
4572
+ const [x, y, z] = (() => {
4573
+ const w = worldPosition(node);
4574
+ return [
4575
+ w[0] ?? 0,
4576
+ w[1] ?? 0,
4577
+ w[2] ?? 0
4578
+ ];
4579
+ })();
4341
4580
  const half = [(this.size[0] ?? 1) / 2, (this.size[1] ?? 1) / 2];
4342
4581
  const points = this.samplePoints(x, z, half[0], half[1]);
4343
4582
  const mass = node.mass ?? 1;
@@ -4480,143 +4719,6 @@ function moveBody(node, to, dt) {
4480
4719
  body.moveAndSlide();
4481
4720
  }
4482
4721
  //#endregion
4483
- //#region src/gameplay/spatial.ts
4484
- /** Duck-type test: every spatial node (Node2D/Node3D) exposes `position: number[]`. */
4485
- function hasPosition$1(node) {
4486
- return Array.isArray(node.position);
4487
- }
4488
- /**
4489
- * Movement behaviors are dimension-agnostic — they read/write `this.node.position`
4490
- * directly. Attaching one to a non-spatial node (no `position` array) would be a
4491
- * silent no-op, so assert it at ready (engine philosophy: hard load-time errors).
4492
- */
4493
- function requirePosition(behavior) {
4494
- const node = behavior.node;
4495
- if (!hasPosition$1(node)) {
4496
- const behaviorName = behavior.constructor.name;
4497
- 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() });
4498
- }
4499
- return node;
4500
- }
4501
- /** Euclidean distance between two position arrays (works for 2D and 3D). */
4502
- function distance$1(a, b) {
4503
- let sum = 0;
4504
- const n = Math.max(a.length, b.length);
4505
- for (let i = 0; i < n; i++) {
4506
- const d = (a[i] ?? 0) - (b[i] ?? 0);
4507
- sum += d * d;
4508
- }
4509
- return Math.sqrt(sum);
4510
- }
4511
- /** Component-wise `a + b` (dimension = max of the two; missing components are 0). */
4512
- function add(a, b) {
4513
- const n = Math.max(a.length, b.length);
4514
- const out = new Array(n);
4515
- for (let i = 0; i < n; i++) out[i] = (a[i] ?? 0) + (b[i] ?? 0);
4516
- return out;
4517
- }
4518
- /** Vector length. */
4519
- function length(v) {
4520
- let sum = 0;
4521
- for (const c of v) sum += c * c;
4522
- return Math.sqrt(sum);
4523
- }
4524
- /** Unit vector (zero-length stays zero). */
4525
- function normalize(v) {
4526
- const len = length(v);
4527
- if (len === 0) return v.map(() => 0);
4528
- return v.map((c) => c / len);
4529
- }
4530
- /**
4531
- * Step `from` toward `to` by at most `maxStep`. Returns the new position and
4532
- * whether the destination was reached this step (clamped — no overshoot).
4533
- */
4534
- function moveToward(from, to, maxStep) {
4535
- const n = Math.max(from.length, to.length);
4536
- let sum = 0;
4537
- for (let i = 0; i < n; i++) {
4538
- const d = (to[i] ?? 0) - (from[i] ?? 0);
4539
- sum += d * d;
4540
- }
4541
- const dist = Math.sqrt(sum);
4542
- const position = new Array(n);
4543
- if (dist <= maxStep || dist === 0) {
4544
- for (let i = 0; i < n; i++) position[i] = to[i] ?? 0;
4545
- return {
4546
- position,
4547
- reached: true
4548
- };
4549
- }
4550
- const f = maxStep / dist;
4551
- for (let i = 0; i < n; i++) {
4552
- const c = from[i] ?? 0;
4553
- position[i] = c + ((to[i] ?? 0) - c) * f;
4554
- }
4555
- return {
4556
- position,
4557
- reached: false
4558
- };
4559
- }
4560
- /**
4561
- * Frame-rate-independent lerp factor for an exponential chase. `smoothing` is a
4562
- * 0..1 retention factor: 0 snaps instantly; higher values chase more slowly.
4563
- * (60fps reference, matching Camera2D's `follow`.)
4564
- */
4565
- function smoothingFactor(smoothing, dt) {
4566
- const s = Math.min(Math.max(smoothing, 0), .99);
4567
- return s <= 0 ? 1 : 1 - s ** (dt * 60);
4568
- }
4569
- /**
4570
- * A node's position with every spatial ancestor's added in.
4571
- *
4572
- * The tree composes transforms for DRAWING — three does it for free — and every
4573
- * piece of engine logic that reads `node.position` gets the LOCAL number
4574
- * instead. Physics already knew (`worldPosition2D`/`worldPosition3D` in the
4575
- * adapters); the camera did not, and neither did anything that chases a target.
4576
- *
4577
- * A camera parented to the player is the Godot and Phaser idiom, and the
4578
- * authoring skill explicitly permits it — *"EVERY node type can hold children …
4579
- * any node works as a grouping container"*. Measured on exactly that scene:
4580
- *
4581
- * ```
4582
- * player world position : [1400, 900]
4583
- * renderer view centre : {"x":0,"y":0}
4584
- * framing : camera /Level/Player/Cam centred [1400, 900]
4585
- * 1 in view, 0 outside it
4586
- * ```
4587
- *
4588
- * The renderer drew empty world 1600 px from the player while `framing` — the
4589
- * instrument the skills tell you to trust before handing a game back —
4590
- * certified the player on screen.
4591
- *
4592
- * Arity comes from the node itself: a 2D node has two components, a 3D node
4593
- * three, and an ancestor that is neither contributes nothing.
4594
- */
4595
- function worldPosition(node) {
4596
- const own = node.position;
4597
- if (!Array.isArray(own)) return [];
4598
- const out = own.slice();
4599
- for (let at = node.parent; at; at = at.parent) {
4600
- const p = at.position;
4601
- if (!Array.isArray(p)) continue;
4602
- for (let i = 0; i < out.length; i++) out[i] = (out[i] ?? 0) + (p[i] ?? 0);
4603
- }
4604
- return out;
4605
- }
4606
- /**
4607
- * Turn a world position into the local one `node.position` has to hold for the
4608
- * node to END UP there — the inverse of `worldPosition` for its parent chain.
4609
- */
4610
- function localFromWorld(node, world) {
4611
- const out = world.slice();
4612
- for (let at = node.parent; at; at = at.parent) {
4613
- const p = at.position;
4614
- if (!Array.isArray(p)) continue;
4615
- for (let i = 0; i < out.length; i++) out[i] = (out[i] ?? 0) - (p[i] ?? 0);
4616
- }
4617
- return out;
4618
- }
4619
- //#endregion
4620
4722
  //#region src/gameplay/chase.ts
4621
4723
  /**
4622
4724
  * Home in on a target node — the workhorse enemy AI. Each frame moves toward
@@ -4678,7 +4780,8 @@ var Chase = class extends Behavior {
4678
4780
  if (!mover || !hasPosition$1(mover)) return;
4679
4781
  const target = this.node.getNodeOrNull(this.target);
4680
4782
  if (!target || !hasPosition$1(target)) return;
4681
- const d = distance$1(mover.position, target.position);
4783
+ const targetPos = localFromWorld(mover, worldPosition(target));
4784
+ const d = distance$1(mover.position, targetPos);
4682
4785
  if (this.loseRange > 0 && d > this.loseRange) {
4683
4786
  if (!this.lost) {
4684
4787
  this.lost = true;
@@ -4698,7 +4801,7 @@ var Chase = class extends Behavior {
4698
4801
  this.inRange = false;
4699
4802
  const step = Math.min(this.speed * dt, this.stopRange > 0 ? d - this.stopRange : d);
4700
4803
  const from = [...mover.position];
4701
- const { position } = moveToward(mover.position, target.position, Math.max(0, step));
4804
+ const { position } = moveToward(mover.position, targetPos, Math.max(0, step));
4702
4805
  moveBody(mover, position, dt);
4703
4806
  if (this.facePath !== "") {
4704
4807
  const skin = this.node.getNodeOrNull(this.facePath);
@@ -4732,12 +4835,23 @@ var Chase = class extends Behavior {
4732
4835
  * elsewhere is not a click. `hovered`/`unhovered` bracket the cursor being over
4733
4836
  * it.
4734
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;
4735
4846
  var Clickable = class extends Behavior {
4736
4847
  static props = {
4737
4848
  /** Mouse button: 0 left, 1 middle, 2 right. */
4738
4849
  button: { default: 0 },
4739
- /** Ignore clicks further away than this (0 = any distance). */
4740
- 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
+ }
4741
4855
  };
4742
4856
  static signals = [
4743
4857
  "clicked",
@@ -4749,17 +4863,24 @@ var Clickable = class extends Behavior {
4749
4863
  /** True while the cursor is over this node — read it to tint a hover state. */
4750
4864
  hovering = false;
4751
4865
  pressedHere = false;
4866
+ /** Frames in a row with nothing to click WITH — see the complaint below. */
4867
+ silentFrames = 0;
4752
4868
  update() {
4753
4869
  const engine = this.node.tree?.engine;
4754
4870
  if (!engine) return;
4755
4871
  const picker = engine.picker;
4756
4872
  const pointer = engine.input.pointerPosition();
4757
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
+ }
4758
4878
  if (this.hovering) this.setHover(false);
4759
4879
  return;
4760
4880
  }
4881
+ this.silentFrames = 0;
4761
4882
  const hit = engine.pickAt(pointer.x, pointer.y);
4762
- const over = hit !== null && this.isSelfOrDescendant(hit) && this.withinRange(hit);
4883
+ const over = hit !== null && this.isSelfOrDescendant(hit) && this.withinRange();
4763
4884
  if (over !== this.hovering) this.setHover(over);
4764
4885
  if (engine.input.mouseJustPressed(this.button)) this.pressedHere = over;
4765
4886
  if (engine.input.mouseJustReleased(this.button)) {
@@ -4776,19 +4897,65 @@ var Clickable = class extends Behavior {
4776
4897
  for (let n = hit; n; n = n.parent) if (n === this.node) return true;
4777
4898
  return false;
4778
4899
  }
4779
- 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() {
4780
4911
  if (this.maxDistance <= 0) return true;
4781
- if (!this.node.tree?.engine?.scene?.root) return true;
4782
- const a = this.node.position;
4783
- const b = hit.position;
4784
- 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;
4785
4923
  let sum = 0;
4786
- for (let i = 0; i < Math.max(a.length, b.length); i++) {
4787
- 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);
4788
4926
  sum += d * d;
4789
4927
  }
4790
4928
  return Math.sqrt(sum) <= this.maxDistance;
4791
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
+ }
4792
4959
  };
4793
4960
  //#endregion
4794
4961
  //#region src/gameplay/collector.ts
@@ -4826,6 +4993,10 @@ group: { default: "player" } };
4826
4993
  const d = data;
4827
4994
  if (typeof d.total === "number") this.total = d.total;
4828
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
+ }
4829
5000
  };
4830
5001
  //#endregion
4831
5002
  //#region src/gameplay/currency.ts
@@ -4859,10 +5030,15 @@ var Currency = class extends Behavior {
4859
5030
  max = 0;
4860
5031
  /** Can this be paid right now? */
4861
5032
  canAfford(cost) {
5033
+ if (cost <= 0) return true;
4862
5034
  return cost <= this.amount;
4863
5035
  }
4864
5036
  /** Pay `cost` if it is there. Returns whether it went through. */
4865
5037
  spend(cost) {
5038
+ if (requireNumber(cost, this.node, "Currency.spend") <= 0) {
5039
+ this.refuseNonsense("spend", cost);
5040
+ return true;
5041
+ }
4866
5042
  if (!this.canAfford(cost)) {
4867
5043
  this.emit("refused", cost);
4868
5044
  return false;
@@ -4874,13 +5050,31 @@ var Currency = class extends Behavior {
4874
5050
  }
4875
5051
  /** Take payment in. */
4876
5052
  earn(gain) {
5053
+ if (requireNumber(gain, this.node, "Currency.earn") <= 0) {
5054
+ this.refuseNonsense("earn", gain);
5055
+ return;
5056
+ }
4877
5057
  this.amount = this.max > 0 ? Math.min(this.max, this.amount + gain) : this.amount + gain;
4878
5058
  this.emit("earned", gain);
4879
5059
  this.emit("changed", this.amount);
4880
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
+ }
4881
5074
  /** Set it outright (a shop that grants, a cheat, a restore). */
4882
5075
  setAmount(next) {
4883
- 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;
4884
5078
  this.emit("changed", this.amount);
4885
5079
  }
4886
5080
  serialize() {
@@ -4890,6 +5084,10 @@ var Currency = class extends Behavior {
4890
5084
  const d = data;
4891
5085
  if (typeof d.amount === "number") this.amount = d.amount;
4892
5086
  }
5087
+ /** The wallet on screen — `setAmount`'s own doc comment already named a restore. */
5088
+ announce() {
5089
+ this.emit("changed", this.amount);
5090
+ }
4893
5091
  };
4894
5092
  //#endregion
4895
5093
  //#region src/gameplay/free-target.ts
@@ -4953,7 +5151,15 @@ var Health = class extends Behavior {
4953
5151
  /** Seconds of immunity after taking a hit (0 = no i-frames). */
4954
5152
  invulnerableFor: { default: 0 },
4955
5153
  /** queueFree() this node when it dies (clone-safe spawned-entity cleanup). */
4956
- 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
4957
5163
  };
4958
5164
  static signals = [
4959
5165
  "damaged",
@@ -4966,6 +5172,7 @@ var Health = class extends Behavior {
4966
5172
  regenPerSec = 0;
4967
5173
  invulnerableFor = 0;
4968
5174
  freeOnDeath = false;
5175
+ freeParent = false;
4969
5176
  /** Current hit points (set to `max` on ready). */
4970
5177
  current = 0;
4971
5178
  invulnTimer = 0;
@@ -4990,7 +5197,7 @@ var Health = class extends Behavior {
4990
5197
  }
4991
5198
  /** Apply `n` damage. No-op while invulnerable or already dead. Emits `damaged`. */
4992
5199
  damage(n) {
4993
- if (this.dead || n <= 0) return;
5200
+ if (this.dead || requireNumber(n, this.node, "Health.damage") <= 0) return;
4994
5201
  if (this.invulnTimer > 0) return;
4995
5202
  this.setCurrent(this.current - n);
4996
5203
  this.emit("damaged", n, this.current);
@@ -4999,7 +5206,7 @@ var Health = class extends Behavior {
4999
5206
  }
5000
5207
  /** Restore `n` hit points (clamped to `max`). No-op when dead. Emits `healed`. */
5001
5208
  heal(n) {
5002
- if (this.dead || n <= 0) return;
5209
+ if (this.dead || requireNumber(n, this.node, "Health.heal") <= 0) return;
5003
5210
  this.setCurrent(this.current + n);
5004
5211
  this.emit("healed", n, this.current);
5005
5212
  }
@@ -5033,11 +5240,11 @@ var Health = class extends Behavior {
5033
5240
  * A no-op on the living — reviving someone who never died would silently
5034
5241
  * refill their health, which is a different feature and not this one.
5035
5242
  */
5036
- revive(hp) {
5243
+ revive(hp = this.max) {
5037
5244
  if (!this.dead) return;
5038
5245
  this.dead = false;
5039
5246
  this.current = 0;
5040
- this.setCurrent(hp === void 0 ? this.max : hp);
5247
+ this.setCurrent(hp);
5041
5248
  this.invulnTimer = this.invulnerableFor;
5042
5249
  this.emit("revived", this.current);
5043
5250
  }
@@ -5086,7 +5293,7 @@ var Health = class extends Behavior {
5086
5293
  this.dead = true;
5087
5294
  this.setCurrent(0);
5088
5295
  this.emit("died");
5089
- if (this.freeOnDeath) this.node.queueFree();
5296
+ if (this.freeOnDeath) freeOwner(this, this.freeParent, "Health");
5090
5297
  }
5091
5298
  /** Current HP and whether death already fired — `max` comes back from JSON. */
5092
5299
  serialize() {
@@ -5100,6 +5307,16 @@ var Health = class extends Behavior {
5100
5307
  if (typeof d.current === "number") this.current = Math.min(this.max, Math.max(0, d.current));
5101
5308
  if (typeof d.dead === "boolean") this.dead = d.dead;
5102
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
+ }
5103
5320
  };
5104
5321
  //#endregion
5105
5322
  //#region src/gameplay/trigger.ts
@@ -5274,6 +5491,27 @@ var DayNight = class extends Behavior {
5274
5491
  baseAmbient = .3;
5275
5492
  baseExposure = 1;
5276
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
+ }
5277
5515
  onReady() {
5278
5516
  this.hour = this.startHour;
5279
5517
  }
@@ -5292,7 +5530,13 @@ var DayNight = class extends Behavior {
5292
5530
  applyHour() {
5293
5531
  const engine = this.node.tree?.engine;
5294
5532
  const env = engine?.scene?.environment;
5295
- 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
+ }
5296
5540
  if (!this.captured) {
5297
5541
  this.captured = true;
5298
5542
  this.baseAmbient = env.ambient?.intensity ?? .3;
@@ -5480,7 +5724,8 @@ var FloatAway = class extends Behavior {
5480
5724
  const t = Math.min(1, this.elapsed / life);
5481
5725
  const next = [...this.start];
5482
5726
  const eased = 1 - (1 - t) * (1 - t);
5483
- 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;
5484
5729
  if (this.drift !== 0) next[0] = (this.start[0] ?? 0) + this.drift * eased;
5485
5730
  node.position = next;
5486
5731
  if (node.opacity !== void 0) {
@@ -5494,6 +5739,46 @@ var FloatAway = class extends Behavior {
5494
5739
  }
5495
5740
  };
5496
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
5497
5782
  //#region src/gameplay/follow-camera.ts
5498
5783
  /**
5499
5784
  * Make the node it sits on chase a target's position — THE camera-follow
@@ -5537,6 +5822,7 @@ var FollowCamera = class extends Behavior {
5537
5822
  offset = [];
5538
5823
  smoothing = 0;
5539
5824
  deadzone = 0;
5825
+ shakeRng = null;
5540
5826
  onReady() {
5541
5827
  if (this.target === "") throw new IncantoError("PROP_TYPE_MISMATCH", `FollowCamera on '${this.node.getPath()}': "target" (a node path) is required.`, { prop: "target" });
5542
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" });
@@ -5564,13 +5850,14 @@ var FollowCamera = class extends Behavior {
5564
5850
  else cam.position = localFromWorld(cam, here.map((c, i) => c + ((desired[i] ?? c) - c) * f));
5565
5851
  }
5566
5852
  if (this.shakeT > 0) {
5567
- this.shakeT -= dt;
5853
+ this.shakeT -= this.engine?.unscaledDelta || dt;
5568
5854
  const k = this.shakeMag * Math.max(0, this.shakeT) / this.shakeFalloff;
5569
5855
  if (this.shakeT <= 0) this.shakeMag = 0;
5570
5856
  const jolted = cam.position.slice();
5857
+ this.shakeRng ??= effectRng(this.node);
5571
5858
  for (let i = 0; i < jolted.length; i++) {
5572
5859
  const scale = jolted.length >= 3 && i === 1 ? .5 : 1;
5573
- 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;
5574
5861
  }
5575
5862
  cam.position = jolted;
5576
5863
  }
@@ -5582,8 +5869,14 @@ var FollowCamera = class extends Behavior {
5582
5869
  * Read an input action that a game is ALLOWED not to declare — and say so once
5583
5870
  * when it is missing.
5584
5871
  *
5585
- * `CharacterController3D` and `GameFlow` wrap every input read in a bare
5586
- * `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
5587
5880
  * crash, and `pause()` stays an API even with no pause action. What was wrong is
5588
5881
  * that the tolerance is indistinguishable from a TYPO. Write
5589
5882
  * `"moveAction": "movement"` and the character stands still, forever, while
@@ -5608,27 +5901,80 @@ function tolerateUnknownAction(ctx, action, read, fallback) {
5608
5901
  //#endregion
5609
5902
  //#region src/gameplay/game-flow.ts
5610
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
+ /**
5611
5920
  * Reload the CURRENT scene from its source JSON — fresh nodes, reset physics,
5612
5921
  * rewired input. The restart primitive every game-over screen wants.
5613
5922
  */
5614
5923
  function restartScene(engine) {
5615
5924
  const source = engine.scene?.source;
5616
5925
  if (!source) return;
5617
- engine.setScene(loadScene(jsonClone(source)));
5926
+ engine.setScene(reload(engine, source));
5927
+ engine.ensurePhysics();
5618
5928
  }
5619
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
+ /**
5620
5938
  * Swap to another scene behind a black fade (title→game→next level).
5621
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.
5622
5959
  */
5623
5960
  function goToScene(engine, sceneJson, opts) {
5961
+ if (travelling.has(engine)) return;
5624
5962
  const fade = opts?.fadeSeconds ?? .4;
5625
5963
  const swap = () => {
5626
- engine.setScene(loadScene(jsonClone(sceneJson)));
5964
+ engine.setScene(reload(engine, sceneJson));
5965
+ engine.ensurePhysics();
5627
5966
  };
5628
5967
  if (typeof document === "undefined" || fade <= 0) {
5629
5968
  swap();
5630
5969
  return;
5631
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
+ };
5632
5978
  const id = "incanto-scene-fade";
5633
5979
  let el = document.getElementById(id);
5634
5980
  if (!el) {
@@ -5640,11 +5986,17 @@ function goToScene(engine, sceneJson, opts) {
5640
5986
  el.style.transition = `opacity ${fade / 2}s`;
5641
5987
  el.style.opacity = "1";
5642
5988
  setTimeout(() => {
5643
- swap();
5644
- const out = document.getElementById(id);
5645
- if (out) {
5646
- out.style.opacity = "0";
5647
- 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
+ }
5648
6000
  }
5649
6001
  }, fade * 500 + 30);
5650
6002
  }
@@ -5725,12 +6077,40 @@ var GameFlow = class GameFlow extends Behavior {
5725
6077
  onReady() {
5726
6078
  this.syncPausePanel();
5727
6079
  }
5728
- /** 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
+ */
5729
6095
  syncPausePanel() {
5730
6096
  if (this.pausePanelPath === "") return;
5731
6097
  const panel = this.node.getNodeOrNull(this.pausePanelPath);
5732
- 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
+ }
5733
6111
  }
6112
+ /** The layer's own `focusNavigation`, while this flow is borrowing it. */
6113
+ navigationWas = null;
5734
6114
  gameOver(text = "GAME OVER", color = "#ef4444") {
5735
6115
  this.transition("gameover", text, color);
5736
6116
  }
@@ -5756,7 +6136,7 @@ var GameFlow = class GameFlow extends Behavior {
5756
6136
  restartScene(this.engine);
5757
6137
  }
5758
6138
  /** Fade to another scene (next level, back to title). */
5759
- goToScene(sceneJson, opts) {
6139
+ goToScene(sceneJson, opts = void 0) {
5760
6140
  this.thaw();
5761
6141
  goToScene(this.engine, sceneJson, opts);
5762
6142
  }
@@ -5790,6 +6170,15 @@ var GameFlow = class GameFlow extends Behavior {
5790
6170
  if (this.readAction("restartAction", "The restart key will do nothing.")) this.restart();
5791
6171
  }
5792
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
+ }
5793
6182
  //#endregion
5794
6183
  //#region src/gameplay/interactable.ts
5795
6184
  function hasPosition(node) {
@@ -5826,18 +6215,26 @@ var Interactable = class extends Behavior {
5826
6215
  action = "interact";
5827
6216
  range = 2;
5828
6217
  actorGroup = "player";
6218
+ /** Actions already reported missing, so a per-frame read says it once. */
6219
+ reportedActions = /* @__PURE__ */ new Set();
5829
6220
  update() {
5830
6221
  const engine = this.node.tree?.engine;
5831
6222
  if (!engine?.scene) return;
5832
- 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;
5833
6230
  if (!hasPosition(this.node)) return;
5834
- const self = this.node.position;
6231
+ const self = worldPosition(this.node);
5835
6232
  let nearest = null;
5836
6233
  let nearestDist = Number.POSITIVE_INFINITY;
5837
6234
  const actors = this.node.tree?.getNodesInGroup(this.actorGroup) ?? collectGroup(this.node.getRoot(), this.actorGroup);
5838
6235
  for (const actor of actors) {
5839
6236
  if (!hasPosition(actor)) continue;
5840
- const d = distance(self, actor.position);
6237
+ const d = distance(self, worldPosition(actor));
5841
6238
  if (d <= this.range && d < nearestDist) {
5842
6239
  nearest = actor;
5843
6240
  nearestDist = d;
@@ -5901,13 +6298,14 @@ var Cooldown = class {
5901
6298
  };
5902
6299
  const SHAKE_FALLOFF_SECONDS = .28;
5903
6300
  /** File the effect with the engine, if this one is attached to a running game. */
5904
- function recordEffect(engine, kind, name, from, amount) {
6301
+ function recordEffect(engine, kind, name, from, amount, wiredAs) {
5905
6302
  engine?.effects.record({
5906
6303
  kind,
5907
6304
  name,
5908
6305
  from,
5909
6306
  amount,
5910
- at: engine.time
6307
+ at: engine.time,
6308
+ wiredAs
5911
6309
  });
5912
6310
  }
5913
6311
  /**
@@ -5926,6 +6324,19 @@ var CameraShake = class extends Behavior {
5926
6324
  t = 0;
5927
6325
  prev = [];
5928
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
+ /**
5929
6340
  * Start a shake — unless the player asked for no motion.
5930
6341
  *
5931
6342
  * `settings.reduceMotion` is an accessibility switch, and camera movement the
@@ -5937,11 +6348,11 @@ var CameraShake = class extends Behavior {
5937
6348
  * for 80 ms is feedback, not motion, and removing it would change what the
5938
6349
  * game is rather than how it moves.
5939
6350
  */
5940
- shake(magnitude, seconds) {
6351
+ shake(magnitude, seconds = this.falloff) {
5941
6352
  if (this.engine?.settings.get("reduceMotion") === true) return;
5942
6353
  this.magnitude = Math.max(this.magnitude, magnitude);
5943
- this.t = seconds ?? this.falloff;
5944
- 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());
5945
6356
  }
5946
6357
  update(dt) {
5947
6358
  const node = requirePosition(this);
@@ -5954,14 +6365,15 @@ var CameraShake = class extends Behavior {
5954
6365
  }
5955
6366
  return;
5956
6367
  }
5957
- this.t -= dt;
6368
+ this.t -= this.engine?.unscaledDelta || dt;
5958
6369
  const k = this.magnitude * Math.max(0, this.t) / this.falloff;
5959
6370
  if (this.t <= 0) this.magnitude = 0;
5960
6371
  const next = pos.slice();
5961
6372
  if (this.prev.length !== next.length) this.prev = new Array(next.length).fill(0);
5962
6373
  for (let i = 0; i < next.length; i++) {
5963
6374
  const scale = next.length >= 3 && i === 1 ? .5 : 1;
5964
- 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;
5965
6377
  next[i] = (next[i] ?? 0) + this.prev[i];
5966
6378
  }
5967
6379
  node.position = next;
@@ -6008,19 +6420,21 @@ function hitStop(engine, seconds = .08) {
6008
6420
  state.until = Math.max(state.until, engine.unscaledTime + seconds);
6009
6421
  return;
6010
6422
  }
6011
- const restore = engine.timeScale;
6423
+ const restore = engine.timeScale === 0 ? 1 : engine.timeScale;
6012
6424
  const entry = {
6013
6425
  startedAt: engine.unscaledTime,
6014
6426
  until: engine.unscaledTime + seconds,
6015
6427
  restore,
6016
- release: () => {}
6428
+ release: () => {},
6429
+ ownedAt: 0
6017
6430
  };
6018
6431
  hitStops.set(engine, entry);
6019
6432
  engine.timeScale = 0;
6433
+ entry.ownedAt = engine.timeScaleWrites;
6020
6434
  entry.release = engine.updated.connect(() => {
6021
6435
  if (!liveHitStop(engine)) return;
6022
6436
  if (engine.unscaledTime < entry.until) return;
6023
- engine.timeScale = entry.restore;
6437
+ if (engine.timeScaleWrites === entry.ownedAt) engine.timeScale = entry.restore;
6024
6438
  entry.release();
6025
6439
  hitStops.delete(engine);
6026
6440
  });
@@ -6047,7 +6461,7 @@ function liveHitStop(engine) {
6047
6461
  entry.release();
6048
6462
  hitStops.delete(engine);
6049
6463
  }
6050
- const hitStops = /* @__PURE__ */ new Map();
6464
+ const hitStops = /* @__PURE__ */ new WeakMap();
6051
6465
  //#endregion
6052
6466
  //#region src/gameplay/lifetime.ts
6053
6467
  /**
@@ -6452,13 +6866,40 @@ var Patrol = class extends Behavior {
6452
6866
  const raw = this.points[i];
6453
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.`);
6454
6868
  }
6455
- /** 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
+ */
6456
6894
  pointAt(i) {
6457
6895
  const raw = this.points[i];
6458
6896
  if (Array.isArray(raw)) return raw;
6459
6897
  if (typeof raw === "string") {
6460
6898
  const marker = this.node.getNodeOrNull(raw);
6461
- 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));
6462
6903
  }
6463
6904
  return null;
6464
6905
  }
@@ -6556,6 +6997,26 @@ function findCollector(node, group) {
6556
6997
  * `null` (the engine's "any JSON" escape hatch) and `null` means `'forward'`.
6557
6998
  * `gravity` adds a constant downward (+y, the 2D y-down convention) pull, for
6558
6999
  * lobbed/arcing shots.
7000
+ *
7001
+ * **Every prop here is live.** `speed` and `direction` re-bake the heading when
7002
+ * written, and the heading is resolved on the FIRST STEP rather than at
7003
+ * `onReady` — because `onReady` is before the one place a game aims a spawned
7004
+ * bullet. `SpawnSource.spawn` ends with `parent.addChild(clone)` and `Spawner`
7005
+ * emits `spawned(node)` after that, so a handler that sets a rotation or a
7006
+ * direction was always too late. Measured at speed 100 over 60 frames:
7007
+ *
7008
+ * ```
7009
+ * A rotation 0 direction [0,1] -> pos [ 0, 100] authored: obeyed
7010
+ * B rotation 90 direction "forward" -> pos [ 0, 100] authored: obeyed
7011
+ * C rotation 90 set after addChild -> pos [100, 0] IGNORED
7012
+ * D direction [0,1] set after add -> pos [100, 0] IGNORED — and it read
7013
+ * back [0,1] while
7014
+ * travelling +x
7015
+ * ```
7016
+ *
7017
+ * What is deliberately NOT live is `rotation` after that first step: a
7018
+ * projectile that spins for looks would have its spin become its trajectory.
7019
+ * Call `aim()` to re-derive from the current rotation on purpose.
6559
7020
  */
6560
7021
  var Projectile = class extends Behavior {
6561
7022
  static props = {
@@ -6567,31 +7028,76 @@ var Projectile = class extends Behavior {
6567
7028
  gravity: { default: 0 }
6568
7029
  };
6569
7030
  static signals = [];
6570
- speed = 300;
6571
- direction = null;
7031
+ _speed = 300;
7032
+ _direction = null;
6572
7033
  gravity = 0;
7034
+ /** The straight-line part, `direction × speed`. Empty until the first step. */
6573
7035
  velocity = [];
6574
- onReady() {
6575
- 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" });
7036
+ /**
7037
+ * The +y accumulated by `gravity`, kept APART from `velocity`.
7038
+ *
7039
+ * Re-aiming replaces the straight-line part; the fall is a fact about the
7040
+ * world, not about the shot, so an arrow re-aimed halfway down keeps falling
7041
+ * rather than snapping back to level.
7042
+ */
7043
+ fall = 0;
7044
+ /** Units per second along `direction`. Writable at any time. */
7045
+ get speed() {
7046
+ return this._speed;
7047
+ }
7048
+ set speed(value) {
7049
+ this._speed = value;
7050
+ if (this.armed) this.aim();
7051
+ }
7052
+ /** A vector (`[x,y(,z)]`) or `'forward'`. Writable at any time. */
7053
+ get direction() {
7054
+ return this._direction;
7055
+ }
7056
+ set direction(value) {
7057
+ this._direction = value;
7058
+ if (this.armed) this.aim();
7059
+ else if (this.node) this.resolveDirection();
7060
+ }
7061
+ get armed() {
7062
+ return this.velocity.length > 0;
7063
+ }
7064
+ /**
7065
+ * Re-derive the heading from `direction` (or, for `'forward'`, from the
7066
+ * node's CURRENT rotation) and bake it into the velocity.
7067
+ *
7068
+ * This is what a `spawned(node)` handler calls after turning the clone, and
7069
+ * the only way a `'forward'` projectile ever re-reads its rotation.
7070
+ */
7071
+ aim(direction = void 0) {
7072
+ if (direction !== void 0) this._direction = direction;
6576
7073
  const node = requirePosition(this);
6577
7074
  const dir = this.resolveDirection();
6578
- this.velocity = dir.map((c) => c * this.speed);
7075
+ this.velocity = dir.map((c) => c * this._speed);
6579
7076
  while (this.velocity.length < node.position.length) this.velocity.push(0);
6580
7077
  }
7078
+ onReady() {
7079
+ 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" });
7080
+ requirePosition(this);
7081
+ this.resolveDirection();
7082
+ }
6581
7083
  update(dt) {
7084
+ if (!this.armed) this.aim();
6582
7085
  const node = this.node;
6583
- if (this.gravity !== 0) this.velocity[1] = (this.velocity[1] ?? 0) + this.gravity * dt;
7086
+ if (this.gravity !== 0) this.fall += this.gravity * dt;
6584
7087
  const pos = node.position;
6585
7088
  const n = Math.max(pos.length, this.velocity.length);
6586
7089
  const next = new Array(n);
6587
- for (let i = 0; i < n; i++) next[i] = (pos[i] ?? 0) + (this.velocity[i] ?? 0) * dt;
7090
+ for (let i = 0; i < n; i++) {
7091
+ const v = (this.velocity[i] ?? 0) + (i === 1 ? this.fall : 0);
7092
+ next[i] = (pos[i] ?? 0) + v * dt;
7093
+ }
6588
7094
  node.position = next;
6589
7095
  }
6590
7096
  /** Resolve `direction` to a unit vector, deriving 'forward' from rotation. */
6591
7097
  resolveDirection() {
6592
- if (Array.isArray(this.direction)) return normalize(this.direction);
6593
- if (this.direction === null || this.direction === "forward") return this.forwardFromRotation();
6594
- 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" });
7098
+ if (Array.isArray(this._direction)) return normalize(this._direction);
7099
+ if (this._direction === null || this._direction === "forward") return this.forwardFromRotation();
7100
+ 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" });
6595
7101
  }
6596
7102
  forwardFromRotation() {
6597
7103
  const rot = this.node.rotation;
@@ -6658,6 +7164,7 @@ var SavePoint = class extends Behavior {
6658
7164
  };
6659
7165
  static signals = [
6660
7166
  "saved",
7167
+ "saveFailed",
6661
7168
  "restored",
6662
7169
  "noSave",
6663
7170
  "hasSave"
@@ -6741,9 +7248,16 @@ var SavePoint = class extends Behavior {
6741
7248
  */
6742
7249
  save() {
6743
7250
  const engine = this.engine;
7251
+ const state = engine.captureState();
7252
+ const failures = engine.lastCaptureFailures;
7253
+ if (failures.length > 0) {
7254
+ 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]}`);
7255
+ this.emit("saveFailed", this.slot);
7256
+ return;
7257
+ }
6744
7258
  this.store().write(this.slot, {
6745
7259
  scene: this.scene || (engine.scene?.source.name ?? ""),
6746
- state: engine.captureState(),
7260
+ state,
6747
7261
  label: this.label,
6748
7262
  savedAt: Date.now(),
6749
7263
  playtime: Math.round(this.elapsed)
@@ -6768,6 +7282,9 @@ var SavePoint = class extends Behavior {
6768
7282
  return;
6769
7283
  }
6770
7284
  const report = this.engine.restoreState(slot.state);
7285
+ const here = this.engine?.scene?.name;
7286
+ 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).`);
7287
+ 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."));
6771
7288
  this.emit("restored", report.restored);
6772
7289
  }
6773
7290
  /** Forget this slot — a "delete save" button. */
@@ -6807,7 +7324,8 @@ var ScoreKeeper = class extends Behavior {
6807
7324
  "scoreChanged",
6808
7325
  "won",
6809
7326
  "lost",
6810
- "lifeLost"
7327
+ "lifeLost",
7328
+ "livesChanged"
6811
7329
  ];
6812
7330
  score = 0;
6813
7331
  lives = 0;
@@ -6820,11 +7338,11 @@ var ScoreKeeper = class extends Behavior {
6820
7338
  }
6821
7339
  /** Add `n` to the score (negative subtracts), emit `scoreChanged`, check win. */
6822
7340
  addScore(n) {
6823
- this.setScore(this.score + n);
7341
+ this.setScore(this.score + requireNumber(n, this.node, "ScoreKeeper.addScore"));
6824
7342
  }
6825
7343
  /** Set the score to `value`, emit `scoreChanged`, check win. */
6826
7344
  setScore(value) {
6827
- this.score = value;
7345
+ this.score = requireNumber(value, this.node, "ScoreKeeper.setScore");
6828
7346
  this.emit("scoreChanged", this.score);
6829
7347
  if (this.scoreToWin > 0 && !this.hasWon && this.score >= this.scoreToWin) {
6830
7348
  this.hasWon = true;
@@ -6836,6 +7354,7 @@ var ScoreKeeper = class extends Behavior {
6836
7354
  if (this.lives <= 0 || this.hasLost) return;
6837
7355
  this.lives -= 1;
6838
7356
  this.emit("lifeLost", this.lives);
7357
+ this.emit("livesChanged", this.lives);
6839
7358
  if (this.lives <= 0) {
6840
7359
  this.hasLost = true;
6841
7360
  this.emit("lost");
@@ -6857,6 +7376,17 @@ var ScoreKeeper = class extends Behavior {
6857
7376
  if (typeof d.hasWon === "boolean") this.hasWon = d.hasWon;
6858
7377
  if (typeof d.hasLost === "boolean") this.hasLost = d.hasLost;
6859
7378
  }
7379
+ /**
7380
+ * The score line and the lives counter. NOT `won`/`lost`.
7381
+ *
7382
+ * Re-firing those on load is how a Continue lands straight on the game-over
7383
+ * screen it was loaded to escape — the flags come back through `hasWon` /
7384
+ * `hasLost` and the game reads them, rather than being told again.
7385
+ */
7386
+ announce() {
7387
+ this.emit("scoreChanged", this.score);
7388
+ this.emit("livesChanged", this.lives);
7389
+ }
6860
7390
  };
6861
7391
  //#endregion
6862
7392
  //#region src/gameplay/spawn-source.ts
@@ -6892,6 +7422,12 @@ var SpawnSource = class {
6892
7422
  const cached = this.detached.get(prefab);
6893
7423
  if (cached) return cached;
6894
7424
  const node = this.behavior.node;
7425
+ const claim = absolutePrefabPath(node, prefab);
7426
+ const shared = claim ? node.tree?._templates.get(claim) ?? null : null;
7427
+ if (shared) {
7428
+ this.detached.set(prefab, shared);
7429
+ return shared;
7430
+ }
6895
7431
  const template = prefab === "" ? null : node.getNodeOrNull(prefab);
6896
7432
  if (!template) {
6897
7433
  const behaviorName = this.behavior.constructor.name;
@@ -6900,6 +7436,7 @@ var SpawnSource = class {
6900
7436
  path: node.getPath()
6901
7437
  });
6902
7438
  }
7439
+ if (claim) node.tree?._templates.set(claim, template);
6903
7440
  if (template.parent) template.parent.removeChild(template);
6904
7441
  this.detached.set(prefab, template);
6905
7442
  return template;
@@ -6926,8 +7463,8 @@ var SpawnSource = class {
6926
7463
  * The scene loads, the spawner spawns, the clone is alive and visible — it is
6927
7464
  * just somewhere else in the level, and nothing has anything to report.
6928
7465
  */
6929
- spawn(template, opts) {
6930
- const clone = duplicateNode(template);
7466
+ spawn(template, opts = void 0) {
7467
+ const clone = duplicateNode(template, rebuildOptions(this.behavior.node));
6931
7468
  const at = opts?.at;
6932
7469
  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);
6933
7470
  if (typeof clone.visible === "boolean") clone.visible = true;
@@ -6935,6 +7472,29 @@ var SpawnSource = class {
6935
7472
  return clone;
6936
7473
  }
6937
7474
  };
7475
+ /**
7476
+ * The absolute path a relative prefab reference points at, resolved against the
7477
+ * spawner's own position — the key two spawners can agree on.
7478
+ *
7479
+ * Returns null for a form there is no single answer for (`%Unique` names a node
7480
+ * by identity, not by place), in which case the template is simply not shared —
7481
+ * which is the behaviour that already existed.
7482
+ */
7483
+ function absolutePrefabPath(node, prefab) {
7484
+ if (prefab === "" || prefab.startsWith("%")) return null;
7485
+ if (prefab.startsWith("/")) return prefab;
7486
+ const parts = node.getPath().split("/");
7487
+ for (const step of prefab.split("/")) {
7488
+ if (step === "" || step === ".") continue;
7489
+ if (step === "..") {
7490
+ if (parts.length <= 1) return null;
7491
+ parts.pop();
7492
+ continue;
7493
+ }
7494
+ parts.push(step);
7495
+ }
7496
+ return parts.join("/");
7497
+ }
6938
7498
  //#endregion
6939
7499
  //#region src/gameplay/spawner.ts
6940
7500
  /**
@@ -6993,7 +7553,11 @@ var Spawner = class extends Behavior {
6993
7553
  _templateForTest() {
6994
7554
  return this.template;
6995
7555
  }
7556
+ onEnterTree() {
7557
+ this.template = this.source.resolveTemplate(this.prefab);
7558
+ }
6996
7559
  onReady() {
7560
+ 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" });
6997
7561
  this.template = this.source.resolveTemplate(this.prefab);
6998
7562
  this.running = this.autoStart;
6999
7563
  this.timer = this.interval;
@@ -7080,9 +7644,10 @@ var Wander = class extends Behavior {
7080
7644
  pickGoal() {
7081
7645
  this.timer = this.changeEvery;
7082
7646
  const dims = this.origin.length;
7647
+ const up = dims === 3 ? 1 : -1;
7083
7648
  const next = new Array(dims);
7084
7649
  for (let attempt = 0; attempt < 8; attempt++) {
7085
- for (let i = 0; i < dims; i++) next[i] = (this.origin[i] ?? 0) + this.rng.range(-this.radius, this.radius);
7650
+ 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);
7086
7651
  if (distance$1(next, this.origin) <= this.radius) {
7087
7652
  this.goal = next;
7088
7653
  return;
@@ -7132,6 +7697,13 @@ var WaveSpawner = class extends Behavior {
7132
7697
  timer = 0;
7133
7698
  spawnedThisWave = 0;
7134
7699
  announced = false;
7700
+ onEnterTree() {
7701
+ if (!Array.isArray(this.waves) || this.waves.length === 0) return;
7702
+ for (const raw of this.waves) {
7703
+ const prefab = raw.prefab;
7704
+ if (typeof prefab === "string" && prefab !== "") this.source.resolveTemplate(prefab);
7705
+ }
7706
+ }
7135
7707
  onReady() {
7136
7708
  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" });
7137
7709
  this.parsed = this.waves.map((raw, i) => this.parseWave(raw, i));
@@ -7142,6 +7714,38 @@ var WaveSpawner = class extends Behavior {
7142
7714
  start() {
7143
7715
  this.beginWave(0);
7144
7716
  }
7717
+ /**
7718
+ * Which wave you were on — and nothing else.
7719
+ *
7720
+ * `behaviorsWithoutSave` is documented as "the list to read before shipping",
7721
+ * and nobody had read it against this repo's own examples. Five of them put a
7722
+ * `WaveSpawner` at the centre of the game (`fps-3d`, `tps-3d`, `tps-3d-neon`,
7723
+ * `tps-3d-ruins`, `dogfight-3d`), and every one of them saved a run on wave 3
7724
+ * and continued it on wave 1 — silently, because a save reloads the scene
7725
+ * from source and a fresh `onReady` honestly starts at the beginning.
7726
+ *
7727
+ * The wave RESTARTS rather than resuming mid-spawn, which is the same
7728
+ * checkpoint rule the rest of the save system follows: spawned entities are
7729
+ * never restored, so resuming "four enemies into wave 3" would mean resuming
7730
+ * a wave whose enemies do not exist. `waveStarted` fires again for the HUD.
7731
+ */
7732
+ serialize() {
7733
+ return {
7734
+ wave: this.waveIndex,
7735
+ done: this.phase === "done"
7736
+ };
7737
+ }
7738
+ deserialize(data) {
7739
+ const d = data ?? {};
7740
+ if (d.done === true) {
7741
+ this.phase = "done";
7742
+ this.waveIndex = this.parsed.length - 1;
7743
+ return;
7744
+ }
7745
+ if (typeof d.wave !== "number" || !Number.isInteger(d.wave)) return;
7746
+ const wave = Math.max(0, Math.min(d.wave, this.parsed.length - 1));
7747
+ this.beginWave(wave);
7748
+ }
7145
7749
  update(dt) {
7146
7750
  if (this.phase === "idle" || this.phase === "done") return;
7147
7751
  const wave = this.parsed[this.waveIndex];
@@ -7312,7 +7916,7 @@ var ZombieAI = class extends Behavior {
7312
7916
  this.pickGoal();
7313
7917
  }
7314
7918
  const target = this.aggroTarget ? this.node.getNodeOrNull(this.aggroTarget) : null;
7315
- const targetPos = target && hasPosition$1(target) ? target.position : null;
7919
+ const targetPos = target && hasPosition$1(target) ? localFromWorld(mover, worldPosition(target)) : null;
7316
7920
  const aggroDist = targetPos ? this.groundDistance(mover.position, targetPos) : Number.POSITIVE_INFINITY;
7317
7921
  if (!this.aggro && aggroDist <= this.aggroRange) {
7318
7922
  this.aggro = true;
@@ -7432,4 +8036,4 @@ function registerGameplayBehaviors(opts) {
7432
8036
  for (const [name, ctor] of Object.entries(GAMEPLAY_BEHAVIORS)) registerBehavior(name, ctor, { replace });
7433
8037
  }
7434
8038
  //#endregion
7435
- 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 };
8039
+ export { phaseOf as A, Area3D as B, restartScene as C, FloatAway as D, effectRng as E, Chase as F, Node3D as G, PhysicsBody3D as H, Water3D as I, WATER_MAX_RIPPLES as J, validateCollider3D as K, WATER_CUTOUT_MAX as L, Health as M, Currency as N, FaceTarget as O, Collector as P, WaterCutout3D as R, goToScene as S, FollowCamera as T, RigidBody3D as U, CharacterBody3D as V, StaticBody3D as W, localFromWorld as X, colliderFootDrop as Y, worldPosition as Z, 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, DamageOnContact as j, DayNight as k, Projectile as l, MoveTo as m, registerGameplayBehaviors as n, Spawner as o, Oscillate as p, createCausticsQuad 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, rejectScale as z };