incanto 0.71.0 → 0.73.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 (92) hide show
  1. package/bin/incanto-check.mjs +114 -42
  2. package/bin/incanto-editor.mjs +15 -9
  3. package/bin/incanto-feel.mjs +11 -1
  4. package/bin/incanto-play.mjs +11 -1
  5. package/bin/incanto-playtest.mjs +11 -1
  6. package/bin/incanto-verify.mjs +9 -2
  7. package/dist/2d.d.ts +29 -4
  8. package/dist/2d.js +3 -3
  9. package/dist/3d.d.ts +137 -15
  10. package/dist/3d.js +6 -6
  11. package/dist/{agent8-Cfmd3ar_.js → agent8-CHTT4unP.js} +1 -1
  12. package/dist/{audio-player-L8yccdMP.d.ts → audio-player-BOrk4eQx.d.ts} +1 -1
  13. package/dist/{behavior-Dcz0fr1S.d.ts → behavior-J1cmVah0.d.ts} +9 -1
  14. package/dist/{create-game-LCHBLfA4.js → create-game-CAu-ucHg.js} +8 -7
  15. package/dist/{create-game-CH4kt78v.js → create-game-CbTLFjis.js} +37 -13
  16. package/dist/debug.d.ts +1 -1
  17. package/dist/debug.js +1 -1
  18. package/dist/editor.js +1036 -930
  19. package/dist/{environment-presets-99BzP_L-.js → environment-presets-BsFJM3v0.js} +277 -60
  20. package/dist/{gameplay-CNULJvwh.js → gameplay-CRte0OVf.js} +589 -35
  21. package/dist/gameplay.d.ts +136 -4
  22. package/dist/gameplay.js +2 -2
  23. package/dist/index.d.ts +6 -4
  24. package/dist/index.js +6 -6
  25. package/dist/{loader-8-IHvbKD.d.ts → loader-BAeWMYfF.d.ts} +10 -3
  26. package/dist/net.d.ts +2 -2
  27. package/dist/net.js +2 -2
  28. package/dist/{physics-2d-FhrXUc6B.js → physics-2d-CovokK7s.js} +94 -11
  29. package/dist/{physics-3d-CFEGxBXZ.js → physics-3d-BiK6Emwn.js} +202 -15
  30. package/dist/{picking-DVo7fI13.js → picking-Dtd8JInh.js} +2 -2
  31. package/dist/react.d.ts +1 -1
  32. package/dist/react.js +1 -1
  33. package/dist/{register-BYQCBySi.js → register-CY9QCCZs.js} +45 -9
  34. package/dist/{register-DuVdzxbq.js → register-CjociOtt.js} +19 -4
  35. package/dist/{replay-DEvp3kyV.js → replay-B6BbgGM_.js} +55 -12
  36. package/dist/{replay-F7IZHdFR.d.ts → replay-wZK5Ok5c.d.ts} +2 -2
  37. package/dist/{save-slots-Bvuh2p_r.js → save-slots-CEUGeLuB.js} +57 -3
  38. package/dist/{split-screen-eULetcg-.js → split-screen-BxZ5rBe7.js} +4 -4
  39. package/dist/{split-screen-CIYf1zSf.d.ts → split-screen-D6sx8z3Z.d.ts} +2 -2
  40. package/dist/{sprite-animation-CqR2o3SA.js → sprite-animation-Bz3DHD_4.js} +1 -1
  41. package/dist/{src-_jk0qLsS.js → src-D-dnVrFY.js} +1 -1
  42. package/dist/{test-CmN54kWv.js → test-DZmCFz0Q.js} +162 -30
  43. package/dist/test.d.ts +56 -5
  44. package/dist/test.js +2 -2
  45. package/dist/{touch-BnCyPA0G.js → touch-CioTZB-y.js} +1 -1
  46. package/dist/vite.d.ts +8 -1
  47. package/dist/vite.js +22 -5
  48. package/editor/assets/{agent8-yLIEYHbd.js → agent8-GuZ5u5bi.js} +1 -1
  49. package/editor/assets/{debug-u31w_yhq.js → debug-BbxionO2.js} +1 -1
  50. package/editor/assets/{index-DK9xMGpW.js → index-CNlTzQbm.js} +92 -92
  51. package/editor/index.html +1 -1
  52. package/package.json +1 -1
  53. package/schemas/scene.schema.json +634 -10
  54. package/skills/incanto-3d-character.md +113 -5
  55. package/skills/incanto-assets.md +9 -3
  56. package/skills/incanto-behaviors-and-scripts.md +26 -2
  57. package/skills/incanto-building-2d-games.md +15 -0
  58. package/skills/incanto-building-3d-games.md +45 -0
  59. package/skills/incanto-editor.md +10 -1
  60. package/skills/incanto-environment.md +7 -2
  61. package/skills/incanto-game-feel.md +16 -11
  62. package/skills/incanto-gameplay-behaviors.md +174 -15
  63. package/skills/incanto-node-reference.md +122 -45
  64. package/skills/incanto-physics-and-input.md +67 -5
  65. package/skills/incanto-scene-json-authoring.md +37 -1
  66. package/skills/incanto-verifying-your-game.md +19 -2
  67. package/templates-app/beacon-isle-3d/PROJECT/Status.md +8 -0
  68. package/templates-app/beacon-isle-3d/PROJECT/Structure.md +3 -0
  69. package/templates-app/beacon-isle-3d/package.json +1 -1
  70. package/templates-app/beacon-isle-3d/src/game.scene.json +512 -184
  71. package/templates-app/beacon-isle-3d/verify.ts +144 -0
  72. package/templates-app/molehill-2d/package.json +1 -1
  73. package/templates-app/platformer-2d/PROJECT/Status.md +7 -0
  74. package/templates-app/platformer-2d/PROJECT/Structure.md +3 -0
  75. package/templates-app/platformer-2d/package.json +1 -1
  76. package/templates-app/platformer-2d/src/game.scene.json +319 -93
  77. package/templates-app/platformer-2d/verify.ts +54 -0
  78. package/templates-app/star-survivor/package.json +1 -1
  79. package/templates-app/star-survivor/src/game.scene.json +18 -9
  80. package/templates-app/tps-3d/PROJECT/Context.md +1 -1
  81. package/templates-app/tps-3d/PROJECT/Status.md +40 -1
  82. package/templates-app/tps-3d/PROJECT/Structure.md +19 -0
  83. package/templates-app/tps-3d/package.json +1 -1
  84. package/templates-app/tps-3d/src/behaviors.ts +9 -0
  85. package/templates-app/tps-3d/src/cover.scene.json +26 -0
  86. package/templates-app/tps-3d/src/game.scene.json +379 -80
  87. package/templates-app/tps-3d/src/main.ts +14 -0
  88. package/templates-app/tps-3d/src/pillar.scene.json +25 -0
  89. package/templates-app/tps-3d/verify.ts +582 -3
  90. package/templates-app/village-quest-3d/package.json +1 -1
  91. package/templates-app/village-quest-3d/src/grove.scene.json +98 -46
  92. package/templates-app/village-quest-3d/src/village.scene.json +449 -223
@@ -1,8 +1,8 @@
1
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
+ 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-CEUGeLuB.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { t as Rng } from "./rng-CDOMybym.js";
5
- import { h as didYouMean } from "./touch-BnCyPA0G.js";
5
+ import { h as didYouMean } from "./touch-CioTZB-y.js";
6
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
7
  //#region src/gameplay/spatial.ts
8
8
  /** Duck-type test: every spatial node (Node2D/Node3D) exposes `position: number[]`. */
@@ -2433,7 +2433,14 @@ var Node3D = class Node3D extends Node {
2433
2433
  "overlay"
2434
2434
  ]
2435
2435
  },
2436
- snapToGround: { default: null }
2436
+ snapToGround: {
2437
+ default: null,
2438
+ kinds: [
2439
+ "null",
2440
+ "boolean",
2441
+ "number"
2442
+ ]
2443
+ }
2437
2444
  };
2438
2445
  position = [
2439
2446
  0,
@@ -2698,9 +2705,27 @@ var PhysicsBody3D = class extends Node3D {
2698
2705
  /** @internal Set by Physics3D when the body is created. */
2699
2706
  _physics = null;
2700
2707
  };
2701
- /** Immovable collider (ground, walls, platforms). */
2708
+ /**
2709
+ * Immovable collider (ground, walls, platforms).
2710
+ *
2711
+ * It also carries the SURFACE it is: `friction` and `restitution` used to
2712
+ * live on `RigidBody3D` alone, so a trampoline, an ice patch or a sticky
2713
+ * landing could not be written in scene JSON, and Rapier's AVERAGE rule
2714
+ * halved whatever a ball brought to a plain floor. A static body's authored
2715
+ * value WINS the pair — restitution above 0 combines by Max, friction below
2716
+ * the default by Min and above it by Max — so the pad decides, whatever lands
2717
+ * on it. Left at the defaults, nothing changes for any existing scene.
2718
+ */
2702
2719
  var StaticBody3D = class extends PhysicsBody3D {
2703
2720
  static typeName = "StaticBody3D";
2721
+ static props = {
2722
+ friction: { default: .5 },
2723
+ restitution: { default: 0 }
2724
+ };
2725
+ /** 0.5 plain · 0 ice (slides on and on) · 1+ glue. Away from 0.5 it wins the pair. */
2726
+ friction = .5;
2727
+ /** 0 dead · 1 trampoline. Above 0 it wins the pair: a dead ball still bounces. */
2728
+ restitution = 0;
2704
2729
  };
2705
2730
  /** Sensor volume emitting `triggerEnter(other)` / `triggerExit(other)`. */
2706
2731
  var Area3D = class extends PhysicsBody3D {
@@ -2754,13 +2779,32 @@ var RigidBody3D = class extends PhysicsBody3D {
2754
2779
  0,
2755
2780
  0,
2756
2781
  0
2757
- ] }
2782
+ ] },
2783
+ linearDamping: {
2784
+ default: 0,
2785
+ range: { min: 0 }
2786
+ },
2787
+ angularDamping: {
2788
+ default: 0,
2789
+ range: { min: 0 }
2790
+ }
2758
2791
  };
2759
2792
  mass = 1;
2760
2793
  gravityScale = 1;
2761
2794
  fixedRotation = false;
2762
2795
  friction = .5;
2763
2796
  restitution = 0;
2797
+ /**
2798
+ * Drag, per second: velocity decays by `e^(-linearDamping·t)`. `0` = none.
2799
+ * A body flying through nothing keeps its speed forever otherwise.
2800
+ */
2801
+ linearDamping = 0;
2802
+ /**
2803
+ * Rolling resistance, per second, on the spin. Rapier has none by itself:
2804
+ * friction only converts slip into spin, so a rolling ball is a wheel and a
2805
+ * golf ball rolled to the edge of the world. `1`–`3` stops a ball on a green.
2806
+ */
2807
+ angularDamping = 0;
2764
2808
  /** m/s, y-up. Read back every step; write to launch. */
2765
2809
  linearVelocity = [
2766
2810
  0,
@@ -2815,7 +2859,8 @@ var CharacterBody3D = class extends PhysicsBody3D {
2815
2859
  stepHeight: {
2816
2860
  default: .35,
2817
2861
  range: { min: 0 }
2818
- }
2862
+ },
2863
+ collideWithCharacters: { default: true }
2819
2864
  };
2820
2865
  /** m/s, y-up (up = +y). */
2821
2866
  velocity = [
@@ -2837,6 +2882,16 @@ var CharacterBody3D = class extends PhysicsBody3D {
2837
2882
  /** Max climbable slope angle. */
2838
2883
  slopeLimitDeg = 45;
2839
2884
  /**
2885
+ * Whether OTHER characters are obstacles to this one. On (default), a
2886
+ * character is a wall to every other character — Godot's answer, and what a
2887
+ * few enemies want. Off, the controller ignores every other kinematic body
2888
+ * and a crowd costs what the floor and the walls cost: eighty chasers around
2889
+ * a player cost 64–286 ms a step with this on (each slide shape-casts
2890
+ * against every neighbour, O(n²) in the crowd) and under a millisecond off.
2891
+ * A survivor's horde overlaps itself and nobody minds.
2892
+ */
2893
+ collideWithCharacters = true;
2894
+ /**
2840
2895
  * How high a step this body walks UP without jumping (0 = none).
2841
2896
  *
2842
2897
  * Rapier's character controller does not autostep unless asked, and nothing
@@ -3321,7 +3376,10 @@ var Water3D = class Water3D extends Node3D {
3321
3376
  mirrorInterval: { default: 33 },
3322
3377
  swellSteepness: { default: .35 },
3323
3378
  foam: { default: true },
3324
- reflectivity: { default: null },
3379
+ reflectivity: {
3380
+ default: null,
3381
+ kinds: ["null", "number"]
3382
+ },
3325
3383
  interaction: { default: true },
3326
3384
  splash: { default: true },
3327
3385
  sunDirection: { default: [
@@ -4739,11 +4797,23 @@ function moveBody(node, to, dt) {
4739
4797
  */
4740
4798
  var Chase = class extends Behavior {
4741
4799
  static props = {
4742
- /** NodePath of the node to chase (resolved from THIS behavior's node). */
4800
+ /** NodePath of the node to chase (resolved from THIS behavior's node). Optional beside `targetGroup`. */
4743
4801
  target: {
4744
4802
  default: "",
4745
- nodePath: true,
4746
- required: true
4803
+ nodePath: true
4804
+ },
4805
+ /**
4806
+ * The other way to name a target: the NEAREST member of this group, re-picked
4807
+ * every `retargetEvery` seconds. `target: "%Player"` was the whole vocabulary,
4808
+ * written for games with one hero — a guard in an RTS, a co-op, a survival
4809
+ * game with allies could not chase "whichever of the six is closest".
4810
+ * With no member in reach the chaser waits.
4811
+ */
4812
+ targetGroup: { default: "" },
4813
+ /** Seconds between nearest-of-group picks (`0` = every frame). */
4814
+ retargetEvery: {
4815
+ default: .25,
4816
+ range: { min: 0 }
4747
4817
  },
4748
4818
  /** Units per second toward the target. */
4749
4819
  speed: { default: 60 },
@@ -4753,20 +4823,46 @@ var Chase = class extends Behavior {
4753
4823
  loseRange: { default: 0 },
4754
4824
  /** Move the parent node instead of this one (AI-on-a-child pattern). */
4755
4825
  moveParent: { default: false },
4826
+ /**
4827
+ * Chase along the GROUND — the target's height is not a direction.
4828
+ *
4829
+ * `null` (default) decides from the mover: a `CharacterBody3D` walks (the
4830
+ * KCC's `slopeLimitDeg` and `stepHeight` say what it climbs, `stickToGround`
4831
+ * keeps it on the surface), anything else — a 2D body, a plain node, a
4832
+ * rigid body — chases in every axis. `true`/`false` overrides either way:
4833
+ * a flying enemy on a kinematic capsule says `false`.
4834
+ *
4835
+ * Before this a chaser moved straight at `target.position` in all three
4836
+ * axes, so a player on a ledge was met by enemies pushing diagonally up
4837
+ * whatever face was in the way — a 70° cliff included — and the slope
4838
+ * limit never got a say.
4839
+ */
4840
+ ground: {
4841
+ default: null,
4842
+ kinds: ["null", "boolean"]
4843
+ },
4756
4844
  ...FACING_PROPS
4757
4845
  };
4758
4846
  static signals = ["reachedTarget", "lostTarget"];
4759
4847
  target = "";
4848
+ targetGroup = "";
4849
+ retargetEvery = .25;
4760
4850
  speed = 60;
4851
+ /** The group member currently chased (`targetGroup`), or null. */
4852
+ picked = null;
4853
+ sincePick = Number.POSITIVE_INFINITY;
4761
4854
  stopRange = 0;
4762
4855
  loseRange = 0;
4763
4856
  moveParent = false;
4857
+ ground = null;
4764
4858
  facePath = "";
4859
+ /** Signed fall speed accumulated while a ground chaser is off the floor. */
4860
+ fall = 0;
4765
4861
  turnSpeed = 100;
4766
4862
  inRange = false;
4767
4863
  lost = false;
4768
4864
  onReady() {
4769
- if (this.target === "") throw new IncantoError("PROP_TYPE_MISMATCH", `Chase on '${this.node.getPath()}': "target" (a node path) is required.`, { prop: "target" });
4865
+ if (this.target === "" && this.targetGroup === "") throw new IncantoError("PROP_TYPE_MISMATCH", `Chase on '${this.node.getPath()}': "target" (a node path) or "targetGroup" (a group name) is required.`, { prop: "target" });
4770
4866
  if (this.moveParent) {
4771
4867
  const parent = this.node.parent;
4772
4868
  if (!parent || !hasPosition$1(parent)) throw new IncantoError("PROP_TYPE_MISMATCH", `Chase on '${this.node.getPath()}': "moveParent" needs a spatial parent (a Node2D/Node3D) to move. '${this.node.name}' has none.`, {
@@ -4775,12 +4871,66 @@ var Chase = class extends Behavior {
4775
4871
  });
4776
4872
  } else requirePosition(this);
4777
4873
  }
4874
+ /**
4875
+ * A walker falls off what it walks off. `CharacterBody3D` applies no gravity
4876
+ * of its own ("integrate velocity yourself"), and a chaser that ignores the
4877
+ * target's height would otherwise walk off a ledge and keep its altitude —
4878
+ * measured on tps-3d's 0.6 m Ramp, where the old chase glided down over five
4879
+ * seconds only because the player happened to stand lower. Scene gravity
4880
+ * (default 9.81), accumulated while `isOnFloor()` says no, reset when it says
4881
+ * yes; the KCC's snap-to-ground handles the last 10 cm.
4882
+ */
4883
+ fallStep(mover, dt) {
4884
+ if (mover.isOnFloor?.() ?? true) {
4885
+ this.fall = 0;
4886
+ return 0;
4887
+ }
4888
+ const g = (this.node.tree?.engine?.scene?.physics?.gravity)?.[1] ?? (mover.position.length === 2 ? 980 : -9.81);
4889
+ this.fall += g * dt;
4890
+ return this.fall * dt;
4891
+ }
4892
+ /** Off the floor with nowhere to chase: still fall. */
4893
+ dropIfAirborne(mover, dt) {
4894
+ const drop = this.fallStep(mover, dt);
4895
+ if (drop !== 0) {
4896
+ const to = [...mover.position];
4897
+ to[1] = (to[1] ?? 0) + drop;
4898
+ moveBody(mover, to, dt);
4899
+ }
4900
+ }
4901
+ /** The nearest live member of `targetGroup` — re-picked every `retargetEvery` seconds, never the mover itself. */
4902
+ nearestOfGroup(mover, dt) {
4903
+ this.sincePick += dt;
4904
+ if (this.picked && this.picked.tree !== null && this.sincePick < this.retargetEvery) return this.picked;
4905
+ this.sincePick = 0;
4906
+ const here = worldPosition(mover);
4907
+ let best = null;
4908
+ let bestD = Number.POSITIVE_INFINITY;
4909
+ for (const n of this.node.tree?.getNodesInGroup(this.targetGroup) ?? []) {
4910
+ if (n === mover || n === this.node || !hasPosition$1(n)) continue;
4911
+ const d = distance$1(here, worldPosition(n));
4912
+ if (d < bestD) {
4913
+ best = n;
4914
+ bestD = d;
4915
+ }
4916
+ }
4917
+ this.picked = best;
4918
+ return best;
4919
+ }
4920
+ /** See `ground`: an explicit answer, else "is the mover a 3D kinematic capsule". */
4921
+ walksTheGround(mover) {
4922
+ if (this.ground !== null) return this.ground;
4923
+ const kcc = mover;
4924
+ return mover.position.length === 3 && typeof kcc.slopeLimitDeg === "number";
4925
+ }
4778
4926
  update(dt) {
4779
4927
  const mover = this.moveParent ? this.node.parent : this.node;
4780
4928
  if (!mover || !hasPosition$1(mover)) return;
4781
- const target = this.node.getNodeOrNull(this.target);
4929
+ const target = this.targetGroup !== "" ? this.nearestOfGroup(mover, dt) : this.node.getNodeOrNull(this.target);
4782
4930
  if (!target || !hasPosition$1(target)) return;
4783
4931
  const targetPos = localFromWorld(mover, worldPosition(target));
4932
+ const walks = this.walksTheGround(mover);
4933
+ if (walks) targetPos[1] = mover.position[1] ?? 0;
4784
4934
  const d = distance$1(mover.position, targetPos);
4785
4935
  if (this.loseRange > 0 && d > this.loseRange) {
4786
4936
  if (!this.lost) {
@@ -4788,6 +4938,7 @@ var Chase = class extends Behavior {
4788
4938
  this.inRange = false;
4789
4939
  this.emit("lostTarget");
4790
4940
  }
4941
+ if (walks) this.dropIfAirborne(mover, dt);
4791
4942
  return;
4792
4943
  }
4793
4944
  this.lost = false;
@@ -4796,12 +4947,14 @@ var Chase = class extends Behavior {
4796
4947
  this.inRange = true;
4797
4948
  this.emit("reachedTarget");
4798
4949
  }
4950
+ if (walks) this.dropIfAirborne(mover, dt);
4799
4951
  return;
4800
4952
  }
4801
4953
  this.inRange = false;
4802
4954
  const step = Math.min(this.speed * dt, this.stopRange > 0 ? d - this.stopRange : d);
4803
4955
  const from = [...mover.position];
4804
4956
  const { position } = moveToward(mover.position, targetPos, Math.max(0, step));
4957
+ if (walks) position[1] = (position[1] ?? 0) + this.fallStep(mover, dt);
4805
4958
  moveBody(mover, position, dt);
4806
4959
  if (this.facePath !== "") {
4807
4960
  const skin = this.node.getNodeOrNull(this.facePath);
@@ -4810,6 +4963,59 @@ var Chase = class extends Behavior {
4810
4963
  }
4811
4964
  };
4812
4965
  //#endregion
4966
+ //#region src/gameplay/checkpoint.ts
4967
+ /**
4968
+ * A checkpoint: touch it and a `Respawn` puts you back HERE from now on.
4969
+ *
4970
+ * Sits on an `Area2D`/`Area3D` (listens to `triggerEnter`). `respawn` names the
4971
+ * `Respawn` node whose `to` becomes this checkpoint's world position, less
4972
+ * `dropBelow` so the character lands ON the flag rather than inside it.
4973
+ * `group` gates who can light it; `once` (default) lights it a single time.
4974
+ * Emits `activated(other)`.
4975
+ *
4976
+ * Built after a platformer built from the tarball wrote exactly this in nine
4977
+ * lines — the ninth being the one that found `Respawn.to` was read once at
4978
+ * ready. A checkpoint is the second most common thing a platformer has, and it
4979
+ * should be one node with two props, not a behaviour every game rewrites.
4980
+ */
4981
+ var Checkpoint = class extends Behavior {
4982
+ static props = {
4983
+ /** Node path of the `Respawn` to redirect. */
4984
+ respawn: {
4985
+ default: "",
4986
+ nodePath: true,
4987
+ required: true
4988
+ },
4989
+ /** Only nodes in this group light it ('' = anything). */
4990
+ group: { default: "player" },
4991
+ /** Light it once; false re-fires `activated` on every entry. */
4992
+ once: { default: true },
4993
+ /** How far below the checkpoint's origin the respawn point sits. */
4994
+ dropBelow: { default: 0 }
4995
+ };
4996
+ static signals = ["activated"];
4997
+ respawn = "";
4998
+ group = "player";
4999
+ once = true;
5000
+ dropBelow = 0;
5001
+ /** True after the first activation. */
5002
+ lit = false;
5003
+ onReady() {
5004
+ this.node.on("triggerEnter", (other) => {
5005
+ const who = other;
5006
+ if (this.group !== "" && !who.isInGroup(this.group)) return;
5007
+ if (this.once && this.lit) return;
5008
+ const catcher = this.node.getNodeOrNull(this.respawn);
5009
+ if (!catcher || !Array.isArray(catcher.to)) return;
5010
+ const to = [...worldPosition(this.node)];
5011
+ if (to.length >= 2) to[1] = (to[1] ?? 0) - this.dropBelow;
5012
+ catcher.to = to;
5013
+ this.lit = true;
5014
+ this.emit("activated", who);
5015
+ });
5016
+ }
5017
+ };
5018
+ //#endregion
4813
5019
  //#region src/gameplay/clickable.ts
4814
5020
  /**
4815
5021
  * Click and hover THIS node — the mouse as gameplay input.
@@ -5204,6 +5410,17 @@ var Health = class extends Behavior {
5204
5410
  if (this.invulnerableFor > 0) this.invulnTimer = this.invulnerableFor;
5205
5411
  if (this.current <= 0) this.die();
5206
5412
  }
5413
+ /**
5414
+ * Be immune for `seconds` from NOW — a dodge roll, a spawn, a cutscene.
5415
+ *
5416
+ * `invulnerableFor` grants i-frames only AFTER a hit; this is the window
5417
+ * before one. It extends a window already open and never shortens it, so a
5418
+ * second dodge mid-window cannot expose you. Dead or not, it is harmless.
5419
+ */
5420
+ protect(seconds) {
5421
+ if (requireNumber(seconds, this.node, "Health.protect") < 0) throw new IncantoError("PROP_TYPE_MISMATCH", `Health.protect on '${this.node.getPath()}': seconds must be >= 0, got ${seconds}.`);
5422
+ this.invulnTimer = Math.max(this.invulnTimer, seconds);
5423
+ }
5207
5424
  /** Restore `n` hit points (clamped to `max`). No-op when dead. Emits `healed`. */
5208
5425
  heal(n) {
5209
5426
  if (this.dead || requireNumber(n, this.node, "Health.heal") <= 0) return;
@@ -5351,8 +5568,10 @@ function onTriggerEnter(behavior, fn) {
5351
5568
  * whose Health-owner node is in that group takes damage. This is how you stop
5352
5569
  * enemies killing each other — give the player's weapon `targetGroup:'enemy'`
5353
5570
  * and each enemy's contact hitbox `targetGroup:'player'`.
5354
- * - `oncePerTarget` (default) prevents re-hitting the same node (a stationary
5355
- * hazard would otherwise drain a resting body every frame).
5571
+ * - `oncePerTarget` (default) prevents re-hitting the same node WHILE it stays
5572
+ * in contact (a stationary hazard would otherwise drain a resting body every
5573
+ * frame). It is per overlap, not per lifetime: leave and come back, or disarm
5574
+ * the hitbox and arm it again over the same foe, and the hit lands again.
5356
5575
  * - `repeatEvery` (seconds, 0 = off) re-damages targets that STAY overlapped —
5357
5576
  * lava pools, poison clouds, an enemy standing on you. Contact events fire
5358
5577
  * only on entry/exit; this is the "and it keeps hurting" knob.
@@ -5396,6 +5615,7 @@ var DamageOnContact = class extends Behavior {
5396
5615
  });
5397
5616
  this.node.on("triggerExit", (other) => {
5398
5617
  this.inside.delete(other);
5618
+ this.hit.delete(other);
5399
5619
  });
5400
5620
  }
5401
5621
  update(dt) {
@@ -6161,13 +6381,15 @@ var GameFlow = class GameFlow extends Behavior {
6161
6381
  this.engine.timeScale = 1;
6162
6382
  }
6163
6383
  update() {
6384
+ const pausePressed = this.readAction("pauseAction", "The pause key will do nothing.");
6385
+ const restartPressed = this.readAction("restartAction", "The restart key will do nothing.");
6164
6386
  if (this.state === "playing" || this.state === "paused") {
6165
- if (this.readAction("pauseAction", "The pause key will do nothing.")) if (this.state === "playing") this.pause();
6387
+ if (pausePressed) if (this.state === "playing") this.pause();
6166
6388
  else this.resume();
6167
6389
  return;
6168
6390
  }
6169
6391
  if (this.state !== "gameover" && this.state !== "won") return;
6170
- if (this.readAction("restartAction", "The restart key will do nothing.")) this.restart();
6392
+ if (restartPressed) this.restart();
6171
6393
  }
6172
6394
  };
6173
6395
  /** The `HudLayer` a widget lives on, or null. */
@@ -6209,24 +6431,63 @@ var Interactable = class extends Behavior {
6209
6431
  /** Maximum distance an actor may be to interact. */
6210
6432
  range: { default: 2 },
6211
6433
  /** Only nodes in this group can interact. */
6212
- actorGroup: { default: "player" }
6434
+ actorGroup: { default: "player" },
6435
+ /** What the actor sees on it — `Open drawer`. Read by whoever draws the "[E] …" prompt. */
6436
+ prompt: { default: "" },
6437
+ /**
6438
+ * Which Interactable answers a press when several are in range. `any`:
6439
+ * every one of them (the old rule). `nearest`: only the closest to the
6440
+ * actor. `facing`: the closest inside `coneDeg` of the current camera's
6441
+ * forward — the one the player is LOOKING at, pitch included, so a note
6442
+ * on the desk and the drawer under it are two different looks (with no
6443
+ * camera, the same as `nearest`).
6444
+ */
6445
+ focus: {
6446
+ default: "any",
6447
+ options: [
6448
+ "any",
6449
+ "nearest",
6450
+ "facing"
6451
+ ]
6452
+ },
6453
+ /** Full width of the `facing` cone, degrees — a generous crosshair. */
6454
+ coneDeg: {
6455
+ default: 40,
6456
+ range: {
6457
+ min: 1,
6458
+ max: 360
6459
+ }
6460
+ }
6213
6461
  };
6214
- static signals = ["interacted"];
6462
+ static signals = [
6463
+ "interacted",
6464
+ "focused",
6465
+ "unfocused"
6466
+ ];
6215
6467
  action = "interact";
6216
6468
  range = 2;
6217
6469
  actorGroup = "player";
6470
+ prompt = "";
6471
+ focus = "any";
6472
+ coneDeg = 40;
6473
+ /**
6474
+ * The actor this Interactable would answer RIGHT NOW, or null — updated every
6475
+ * frame, with `focused(actor)` / `unfocused(actor)` on the edges. A HUD reads
6476
+ * the focused one's `prompt`.
6477
+ */
6478
+ focusedBy = null;
6218
6479
  /** Actions already reported missing, so a per-frame read says it once. */
6219
6480
  reportedActions = /* @__PURE__ */ new Set();
6220
6481
  update() {
6221
6482
  const engine = this.node.tree?.engine;
6222
6483
  if (!engine?.scene) return;
6223
- if (!tolerateUnknownAction({
6484
+ const pressed = tolerateUnknownAction({
6224
6485
  engine,
6225
6486
  who: this.node.getPath(),
6226
6487
  reported: this.reportedActions,
6227
6488
  consequence: "nothing here can be interacted with",
6228
6489
  authored: this.action !== "interact"
6229
- }, this.action, () => engine.input.justPressed(this.action), false)) return;
6490
+ }, this.action, () => engine.input.justPressed(this.action), false);
6230
6491
  if (!hasPosition(this.node)) return;
6231
6492
  const self = worldPosition(this.node);
6232
6493
  let nearest = null;
@@ -6240,9 +6501,89 @@ var Interactable = class extends Behavior {
6240
6501
  nearestDist = d;
6241
6502
  }
6242
6503
  }
6243
- if (nearest) this.emit("interacted", nearest);
6504
+ let holder = nearest;
6505
+ if (holder && this.focus !== "any" && !this.claims(holder, self)) holder = null;
6506
+ if (holder !== this.focusedBy) {
6507
+ const was = this.focusedBy;
6508
+ this.focusedBy = holder;
6509
+ if (was) this.emit("unfocused", was);
6510
+ if (holder) this.emit("focused", holder);
6511
+ }
6512
+ if (!pressed || !holder) return;
6513
+ this.emit("interacted", holder);
6514
+ }
6515
+ /** Is THIS the Interactable `actor` means — the nearest (that it faces) of every one in reach? */
6516
+ claims(actor, self) {
6517
+ const eye = this.focus === "facing" ? cameraEye(this.node.getRoot()) : null;
6518
+ const from = eye ? eye.at : worldPosition(actor);
6519
+ const inCone = (target) => {
6520
+ if (!eye) return true;
6521
+ const dx = (target[0] ?? 0) - (from[0] ?? 0);
6522
+ const dy = (target[1] ?? 0) - (from[1] ?? 0);
6523
+ const dz = (target[2] ?? 0) - (from[2] ?? 0);
6524
+ const len = Math.hypot(dx, dy, dz);
6525
+ if (len < 1e-6) return true;
6526
+ return dx / len * eye.fx + dy / len * eye.fy + dz / len * eye.fz >= Math.cos(this.coneDeg / 2 * (Math.PI / 180));
6527
+ };
6528
+ if (!inCone(self)) return false;
6529
+ const mine = distance(from, self);
6530
+ for (const other of interactablesIn(this.node.getRoot())) {
6531
+ if (other === this || other.focus === "any" || other.actorGroup !== this.actorGroup) continue;
6532
+ if (!hasPosition(other.node)) continue;
6533
+ const there = worldPosition(other.node);
6534
+ if (distance(worldPosition(actor), there) > other.range) continue;
6535
+ if (!inCone(there)) continue;
6536
+ if (distance(from, there) < mine) return false;
6537
+ }
6538
+ return true;
6244
6539
  }
6245
6540
  };
6541
+ /** Every Interactable in the tree — walked on demand, only while an actor is in reach of one. */
6542
+ function interactablesIn(root) {
6543
+ const out = [];
6544
+ const walk = (n) => {
6545
+ if (n.behavior instanceof Interactable) out.push(n.behavior);
6546
+ for (const c of n.children) walk(c);
6547
+ };
6548
+ walk(root);
6549
+ return out;
6550
+ }
6551
+ /**
6552
+ * Where the current camera is and which way it looks, or null when the tree
6553
+ * has no `Camera3D` (a 2D scene — `facing` then means `nearest`). Forward is
6554
+ * the camera's -z through its Euler XYZ rotation: the third basis column
6555
+ * negated, `(-sin ry, sin rx·cos ry, -cos rx·cos ry)`.
6556
+ */
6557
+ function cameraEye(root) {
6558
+ let first = null;
6559
+ let current = null;
6560
+ const walk = (n) => {
6561
+ if (n.constructor.typeName === "Camera3D") {
6562
+ first ??= n;
6563
+ if (n.current) current ??= n;
6564
+ }
6565
+ for (const c of n.children) walk(c);
6566
+ };
6567
+ walk(root);
6568
+ const cam = current ?? first;
6569
+ if (!cam) return null;
6570
+ const rot = cam.rotation ?? [
6571
+ 0,
6572
+ 0,
6573
+ 0
6574
+ ];
6575
+ const rx = (rot[0] ?? 0) * Math.PI / 180;
6576
+ const ry = (rot[1] ?? 0) * Math.PI / 180;
6577
+ const fx = -Math.sin(ry);
6578
+ const fy = Math.sin(rx) * Math.cos(ry);
6579
+ const fz = -Math.cos(rx) * Math.cos(ry);
6580
+ return {
6581
+ at: worldPosition(cam),
6582
+ fx,
6583
+ fy,
6584
+ fz
6585
+ };
6586
+ }
6246
6587
  function collectGroup(node, group) {
6247
6588
  const out = [];
6248
6589
  const walk = (n) => {
@@ -6703,11 +7044,14 @@ var PathFollow = class extends Behavior {
6703
7044
  static signals = ["waypointReached", "arrived"];
6704
7045
  static props = {
6705
7046
  speed: { default: 120 },
6706
- loop: { default: false }
7047
+ loop: { default: false },
7048
+ /** Move the parent node instead of this one — the AI-on-a-child pattern `Chase`/`Patrol`/`Wander` share. */
7049
+ moveParent: { default: false }
6707
7050
  };
6708
7051
  /** Units per second (px in 2D, meters in 3D). */
6709
7052
  speed = 120;
6710
7053
  loop = false;
7054
+ moveParent = false;
6711
7055
  waypoints = [];
6712
7056
  index = 0;
6713
7057
  /** True while there is somewhere left to go. */
@@ -6725,24 +7069,37 @@ var PathFollow = class extends Behavior {
6725
7069
  }
6726
7070
  update(dt) {
6727
7071
  if (!this.moving) return;
6728
- const node = requirePosition(this);
7072
+ const node = this.mover();
6729
7073
  let budget = this.speed * dt;
7074
+ let at = [...node.position];
6730
7075
  while (budget > 0 && this.index < this.waypoints.length) {
6731
7076
  const target = this.waypoints[this.index];
6732
- const step = moveToward(node.position, target, budget);
6733
- const travelled = distanceBetween(node.position, step.position);
6734
- node.position = step.position;
7077
+ const step = moveToward(at, target, budget);
7078
+ const travelled = distanceBetween(at, step.position);
7079
+ at = step.position;
6735
7080
  budget -= travelled;
6736
7081
  if (step.reached) {
6737
7082
  this.node.emit("waypointReached", this.index);
6738
7083
  this.index += 1;
6739
7084
  if (this.index >= this.waypoints.length) if (this.loop && this.waypoints.length > 1) this.index = 0;
6740
7085
  else {
7086
+ moveBody(node, at, dt);
6741
7087
  this.node.emit("arrived");
6742
7088
  return;
6743
7089
  }
6744
- } else return;
7090
+ } else break;
6745
7091
  }
7092
+ moveBody(node, at, dt);
7093
+ }
7094
+ /** The node this walks: the parent under `moveParent`, else this one — both must be spatial. */
7095
+ mover() {
7096
+ if (!this.moveParent) return requirePosition(this);
7097
+ const parent = this.node.parent;
7098
+ if (!parent || !hasPosition$1(parent)) throw new IncantoError("PROP_TYPE_MISMATCH", `PathFollow on '${this.node.getPath()}': "moveParent" needs a spatial parent (a Node2D/Node3D) to move.`, {
7099
+ prop: "moveParent",
7100
+ path: this.node.getPath()
7101
+ });
7102
+ return parent;
6746
7103
  }
6747
7104
  };
6748
7105
  function distanceBetween(a, b) {
@@ -7120,8 +7477,21 @@ var Projectile = class extends Behavior {
7120
7477
  /** Units per second along `direction`. */
7121
7478
  speed: { default: 300 },
7122
7479
  /** A vector ([x,y(,z)]) OR the string 'forward' (null = 'forward', from rotation). */
7123
- direction: { default: null },
7124
- /** Constant +y (downward, 2D y-down) acceleration; 0 = straight line. */
7480
+ direction: {
7481
+ default: null,
7482
+ kinds: [
7483
+ "null",
7484
+ "array",
7485
+ "string"
7486
+ ]
7487
+ },
7488
+ /**
7489
+ * Constant DOWNWARD acceleration in the scene's own convention — +y in 2D
7490
+ * (y-down), −y in 3D (y-up) — so `9.81` pulls a 3D lob down. It used to add
7491
+ * to +y regardless, the 2D convention, and `9.81` in a y-up scene LIFTED the
7492
+ * shot (measured: 19 m up at 1.5 s); `Chase.ground` signs its fall the same
7493
+ * way. 0 = straight line.
7494
+ */
7125
7495
  gravity: { default: 0 }
7126
7496
  };
7127
7497
  static signals = [];
@@ -7180,7 +7550,7 @@ var Projectile = class extends Behavior {
7180
7550
  update(dt) {
7181
7551
  if (!this.armed) this.aim();
7182
7552
  const node = this.node;
7183
- if (this.gravity !== 0) this.fall += this.gravity * dt;
7553
+ if (this.gravity !== 0) this.fall += this.gravity * dt * (node.position.length === 3 ? -1 : 1);
7184
7554
  const pos = node.position;
7185
7555
  const n = Math.max(pos.length, this.velocity.length);
7186
7556
  const next = new Array(n);
@@ -7429,6 +7799,17 @@ var ScoreKeeper = class extends Behavior {
7429
7799
  scoreToWin = 0;
7430
7800
  hasWon = false;
7431
7801
  hasLost = false;
7802
+ /**
7803
+ * Whether the run is over — readable, not only heard. A stealth yard's HUD
7804
+ * and a melee arena's both read `score.won` for their banner and got
7805
+ * `undefined`, silently, because only the signals were public.
7806
+ */
7807
+ get won() {
7808
+ return this.hasWon;
7809
+ }
7810
+ get lost() {
7811
+ return this.hasLost;
7812
+ }
7432
7813
  onReady() {
7433
7814
  if (this.lives < 0) throw new IncantoError("PROP_TYPE_MISMATCH", `ScoreKeeper on '${this.node.getPath()}': "lives" must be >= 0, got ${this.lives}.`, { prop: "lives" });
7434
7815
  if (this.scoreToWin < 0) throw new IncantoError("PROP_TYPE_MISMATCH", `ScoreKeeper on '${this.node.getPath()}': "scoreToWin" must be >= 0, got ${this.scoreToWin}.`, { prop: "scoreToWin" });
@@ -7563,8 +7944,12 @@ var SpawnSource = class {
7563
7944
  */
7564
7945
  spawn(template, opts = void 0) {
7565
7946
  const clone = duplicateNode(template, rebuildOptions(this.behavior.node));
7566
- const at = opts?.at;
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);
7947
+ const at = opts?.at ?? [];
7948
+ const under = opts?.parent ?? this.behavior.node;
7949
+ if (hasPosition$1(clone) && (at.length > 0 || under !== this.behavior.node)) {
7950
+ const origin = under === this.behavior.node || !hasPosition$1(this.behavior.node) ? [] : sub(worldPosition(this.behavior.node), worldPosition(under));
7951
+ clone.position = add(origin, at.length > 0 ? at : zeros(origin.length || clone.position.length));
7952
+ }
7568
7953
  if (typeof clone.visible === "boolean") clone.visible = true;
7569
7954
  (opts?.parent ?? this.behavior.node).addChild(clone);
7570
7955
  return clone;
@@ -7593,6 +7978,15 @@ function absolutePrefabPath(node, prefab) {
7593
7978
  }
7594
7979
  return parts.join("/");
7595
7980
  }
7981
+ function sub(a, b) {
7982
+ const n = Math.max(a.length, b.length);
7983
+ const out = [];
7984
+ for (let i = 0; i < n; i++) out.push((a[i] ?? 0) - (b[i] ?? 0));
7985
+ return out;
7986
+ }
7987
+ function zeros(n) {
7988
+ return Array.from({ length: n }, () => 0);
7989
+ }
7596
7990
  //#endregion
7597
7991
  //#region src/gameplay/spawner.ts
7598
7992
  /**
@@ -7626,7 +8020,17 @@ var Spawner = class extends Behavior {
7626
8020
  /** Begin ticking at ready. */
7627
8021
  autoStart: { default: true },
7628
8022
  /** Total LIFETIME spawns (0 = infinite). */
7629
- total: { default: 0 }
8023
+ total: { default: 0 },
8024
+ /**
8025
+ * Where clones live: a node path, or '' for under the spawner itself. Clones
8026
+ * of a spawner that MOVES ride along with it when they are its children —
8027
+ * a gate a director slid to a new edge before each spawn dragged every
8028
+ * enemy it had made — so a container makes the spawner just a place.
8029
+ */
8030
+ into: {
8031
+ default: "",
8032
+ nodePath: true
8033
+ }
7630
8034
  };
7631
8035
  static signals = ["spawned", "finished"];
7632
8036
  prefab = "";
@@ -7635,6 +8039,7 @@ var Spawner = class extends Behavior {
7635
8039
  at = [];
7636
8040
  autoStart = true;
7637
8041
  total = 0;
8042
+ into = "";
7638
8043
  source = new SpawnSource(this);
7639
8044
  template;
7640
8045
  live = /* @__PURE__ */ new Set();
@@ -7657,6 +8062,7 @@ var Spawner = class extends Behavior {
7657
8062
  onReady() {
7658
8063
  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" });
7659
8064
  this.template = this.source.resolveTemplate(this.prefab);
8065
+ if (this.into !== "") this.node.getNode(this.into);
7660
8066
  this.running = this.autoStart;
7661
8067
  this.timer = this.interval;
7662
8068
  }
@@ -7674,7 +8080,11 @@ var Spawner = class extends Behavior {
7674
8080
  this.prune();
7675
8081
  if (this.done) return null;
7676
8082
  if (this.max > 0 && this.live.size >= this.max) return null;
7677
- const clone = this.source.spawn(this.template, this.at.length > 0 ? { at: this.at } : void 0);
8083
+ const parent = this.into === "" ? void 0 : this.node.getNode(this.into);
8084
+ const clone = this.source.spawn(this.template, this.at.length > 0 || parent ? {
8085
+ at: this.at,
8086
+ parent
8087
+ } : void 0);
7678
8088
  this.live.add(clone);
7679
8089
  this.spawnedCount += 1;
7680
8090
  this.emit("spawned", clone);
@@ -7698,6 +8108,140 @@ var Spawner = class extends Behavior {
7698
8108
  }
7699
8109
  };
7700
8110
  //#endregion
8111
+ //#region src/gameplay/turret.ts
8112
+ /**
8113
+ * The oldest verb in games: pick the nearest live member of `targetGroup`
8114
+ * within `range` and hit it every `cooldown` — a defense tower, a ship's gun,
8115
+ * a sentry, a trooper who fights what he meets.
8116
+ *
8117
+ * Two games in a row wrote these forty lines before it existed (an RTS's
8118
+ * trooper, a tower defense's tower): the engine had `Chase` for going TO a
8119
+ * target and `Projectile` for the bullet, and nothing that decides to shoot.
8120
+ *
8121
+ * - **Hitscan** by default: the target's `Health` (on it or a child, as
8122
+ * `DamageOnContact` finds it) takes `damage(n)`.
8123
+ * - **Or a clone**: name a `shelf` (a `PrefabShelf`) and a `prefab` on it; each
8124
+ * shot takes one, puts it at the turret plus `muzzle`, points its
8125
+ * `Projectile` at the target and adds it under `into` (the turret's parent
8126
+ * when empty). The clone does the hurting then — give it `DamageOnContact`.
8127
+ * - `facePath` turns a barrel toward the target at `turnSpeed`.
8128
+ *
8129
+ * Signals: `acquired(target)` when something comes in range, `fired(target)`
8130
+ * per shot, `lost()` when nothing live is in range any more.
8131
+ */
8132
+ var Turret = class extends Behavior {
8133
+ static props = {
8134
+ /** Who it shoots: the nearest live member of this group. */
8135
+ targetGroup: { default: "enemy" },
8136
+ /** How far it reaches, in the scene's units. */
8137
+ range: {
8138
+ default: 6,
8139
+ range: { min: 0 }
8140
+ },
8141
+ /** Hitscan damage per shot (ignored when a `prefab` does the hurting). */
8142
+ damage: { default: 10 },
8143
+ /** Seconds between shots. */
8144
+ cooldown: {
8145
+ default: .6,
8146
+ range: { min: 0 }
8147
+ },
8148
+ /** A `PrefabShelf` to take each shot from (empty = hitscan). */
8149
+ shelf: {
8150
+ default: "",
8151
+ nodePath: true
8152
+ },
8153
+ /** The template on that shelf. */
8154
+ prefab: { default: "" },
8155
+ /** Where clones start, relative to the turret. */
8156
+ muzzle: { default: [] },
8157
+ /** Where clones go; empty = the turret's parent. */
8158
+ into: {
8159
+ default: "",
8160
+ nodePath: true
8161
+ },
8162
+ ...FACING_PROPS
8163
+ };
8164
+ static signals = [
8165
+ "acquired",
8166
+ "fired",
8167
+ "lost"
8168
+ ];
8169
+ targetGroup = "enemy";
8170
+ range = 6;
8171
+ damage = 10;
8172
+ cooldown = .6;
8173
+ shelf = "";
8174
+ prefab = "";
8175
+ muzzle = [];
8176
+ into = "";
8177
+ facePath = "";
8178
+ turnSpeed = 100;
8179
+ /** What it is shooting at right now, or null. */
8180
+ target = null;
8181
+ /** Shots fired so far. */
8182
+ shots = 0;
8183
+ wait = 0;
8184
+ onReady() {
8185
+ if (this.shelf === "" !== (this.prefab === "")) throw new IncantoError("PROP_TYPE_MISMATCH", `Turret on '${this.node.getPath()}': "shelf" and "prefab" go together — name both for a projectile, neither for hitscan.`, {
8186
+ prop: this.shelf === "" ? "shelf" : "prefab",
8187
+ path: this.node.getPath()
8188
+ });
8189
+ }
8190
+ update(dt) {
8191
+ this.wait = Math.max(0, this.wait - dt);
8192
+ if (!hasPosition$1(this.node)) return;
8193
+ const here = worldPosition(this.node);
8194
+ let best = null;
8195
+ let bestD = this.range;
8196
+ for (const n of this.node.tree?.getNodesInGroup(this.targetGroup) ?? []) {
8197
+ if (n === this.node || !hasPosition$1(n)) continue;
8198
+ if (findHealth(n)?.health.isDead) continue;
8199
+ const d = distance$1(here, worldPosition(n));
8200
+ if (d <= bestD) {
8201
+ best = n;
8202
+ bestD = d;
8203
+ }
8204
+ }
8205
+ if (best !== this.target) {
8206
+ const had = this.target;
8207
+ this.target = best;
8208
+ if (best) this.emit("acquired", best);
8209
+ else if (had) this.emit("lost");
8210
+ }
8211
+ if (!best) return;
8212
+ const dir = worldPosition(best).map((v, i) => v - (here[i] ?? 0));
8213
+ if (this.facePath !== "") {
8214
+ const barrel = this.node.getNodeOrNull(this.facePath);
8215
+ if (barrel) faceTravel(barrel, dir, dt, this.turnSpeed);
8216
+ }
8217
+ if (this.wait > 0) return;
8218
+ this.wait = this.cooldown;
8219
+ this.shots += 1;
8220
+ if (this.shelf !== "") this.launch(best, here, dir);
8221
+ else findHealth(best)?.health.damage(this.damage);
8222
+ this.emit("fired", best);
8223
+ }
8224
+ /** One clone from the shelf, at the muzzle, aimed at the target, into the container. */
8225
+ launch(target, here, dir) {
8226
+ const shelf = this.node.getNodeOrNull(this.shelf)?.behavior;
8227
+ if (!shelf || typeof shelf.make !== "function") throw new IncantoError("NODE_NOT_FOUND", `Turret on '${this.node.getPath()}': "shelf" '${this.shelf}' is not a PrefabShelf.`, {
8228
+ prop: "shelf",
8229
+ path: this.node.getPath()
8230
+ });
8231
+ const clone = shelf.make(this.prefab);
8232
+ if (!hasPosition$1(clone)) return;
8233
+ const container = this.into !== "" ? this.node.getNodeOrNull(this.into) : this.node.parent;
8234
+ if (!container) return;
8235
+ const len = Math.hypot(...dir) || 1;
8236
+ const unit = dir.map((v) => v / len);
8237
+ const containerAt = hasPosition$1(container) ? worldPosition(container) : here.map(() => 0);
8238
+ clone.position = here.map((v, i) => v + (this.muzzle[i] ?? 0) - (containerAt[i] ?? 0));
8239
+ const shot = clone.behavior;
8240
+ if (shot && "direction" in shot) shot.direction = unit;
8241
+ container.addChild(clone);
8242
+ }
8243
+ };
8244
+ //#endregion
7701
8245
  //#region src/gameplay/wander.ts
7702
8246
  /**
7703
8247
  * Aimless roaming inside a circle around the spawn point — idle critters,
@@ -7793,6 +8337,14 @@ var WaveSpawner = class extends Behavior {
7793
8337
  parsed = [];
7794
8338
  live = /* @__PURE__ */ new Set();
7795
8339
  waveIndex = -1;
8340
+ /** The wave now running or being cleared, 1-based — what a HUD prints as `wave 2 / 5`. `0` before the first. */
8341
+ get wave() {
8342
+ return this.waveIndex < 0 ? 0 : Math.min(this.parsed.length, this.waveIndex + 1);
8343
+ }
8344
+ /** How many waves were declared. */
8345
+ get waveCount() {
8346
+ return this.parsed.length;
8347
+ }
7796
8348
  phase = "idle";
7797
8349
  timer = 0;
7798
8350
  spawnedThisWave = 0;
@@ -8105,6 +8657,8 @@ const GAMEPLAY_BEHAVIORS = {
8105
8657
  PathFollow,
8106
8658
  Health,
8107
8659
  Lifetime,
8660
+ Checkpoint,
8661
+ Turret,
8108
8662
  ScoreKeeper,
8109
8663
  Currency,
8110
8664
  Pickup,
@@ -8138,4 +8692,4 @@ function registerGameplayBehaviors(opts) {
8138
8692
  for (const [name, ctor] of Object.entries(GAMEPLAY_BEHAVIORS)) registerBehavior(name, ctor, { replace });
8139
8693
  }
8140
8694
  //#endregion
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 };
8695
+ export { localFromWorld as $, FaceTarget as A, WATER_CUTOUT_MAX as B, GameFlow as C, FollowCamera as D, tolerateUnknownAction as E, Currency as F, PhysicsBody3D as G, rejectScale as H, Collector as I, Node3D as J, RigidBody3D as K, Checkpoint as L, phaseOf as M, DamageOnContact as N, effectRng as O, Health as P, colliderFootDrop as Q, Chase as R, Interactable as S, restartScene as T, Area3D as U, WaterCutout3D as V, CharacterBody3D as W, createCausticsQuad as X, validateCollider3D as Y, WATER_MAX_RIPPLES as Z, Lifetime as _, Wander as a, hitStop as b, ScoreKeeper as c, PrefabShelf as d, worldPosition as et, Pickup as f, MoveTo as g, Oscillate as h, WaveSpawner as i, DayNight as j, FloatAway as k, SavePoint as l, PathFollow as m, registerGameplayBehaviors as n, Turret as o, Patrol as p, StaticBody3D as q, ZombieAI as r, Spawner as s, GAMEPLAY_BEHAVIORS as t, Projectile as u, CameraShake as v, goToScene as w, screenFlash as x, Cooldown as y, Water3D as z };