incanto 0.72.0 → 0.74.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 (84) hide show
  1. package/bin/incanto-check.mjs +114 -42
  2. package/bin/incanto-verify.mjs +9 -2
  3. package/dist/2d.d.ts +29 -4
  4. package/dist/2d.js +3 -3
  5. package/dist/3d.d.ts +137 -15
  6. package/dist/3d.js +6 -6
  7. package/dist/{agent8-BqZ0_O1u.js → agent8-CHTT4unP.js} +1 -1
  8. package/dist/{audio-player-ioZ7VKC6.d.ts → audio-player-BOrk4eQx.d.ts} +1 -1
  9. package/dist/{behavior-Bf9P9oB2.d.ts → behavior-J1cmVah0.d.ts} +8 -0
  10. package/dist/{create-game-CmFAtLZp.js → create-game-BAiA-FjP.js} +7 -7
  11. package/dist/{create-game-C9rWqqAC.js → create-game-ssvGvP7W.js} +7 -7
  12. package/dist/debug.d.ts +1 -1
  13. package/dist/debug.js +1 -1
  14. package/dist/editor.js +965 -893
  15. package/dist/{environment-presets-BeHoaGhy.js → environment-presets-BkqlWewf.js} +277 -60
  16. package/dist/{gameplay-BlQe-M07.js → gameplay-Bxe1sMVT.js} +611 -39
  17. package/dist/gameplay.d.ts +141 -7
  18. package/dist/gameplay.js +2 -2
  19. package/dist/index.d.ts +6 -4
  20. package/dist/index.js +6 -6
  21. package/dist/{loader-CBfQzB6S.d.ts → loader-BAeWMYfF.d.ts} +1 -1
  22. package/dist/net.d.ts +2 -2
  23. package/dist/net.js +2 -2
  24. package/dist/{physics-2d-DrIgTlLk.js → physics-2d-C-SfvjzH.js} +94 -11
  25. package/dist/{physics-3d-BzOCw1VS.js → physics-3d-rOzeg890.js} +202 -15
  26. package/dist/{picking-C9McVJrO.js → picking-BMV34Pjl.js} +2 -2
  27. package/dist/react.d.ts +1 -1
  28. package/dist/react.js +1 -1
  29. package/dist/{register-5IYfooAN.js → register-CjociOtt.js} +19 -4
  30. package/dist/{register-C8HuRkOf.js → register-DRQyZKGU.js} +45 -9
  31. package/dist/{replay-datiAE-b.js → replay-B6BbgGM_.js} +42 -6
  32. package/dist/{replay-CBEChqXq.d.ts → replay-wZK5Ok5c.d.ts} +1 -1
  33. package/dist/{save-slots-CEuJPUle.js → save-slots-CEUGeLuB.js} +11 -2
  34. package/dist/{split-screen-Bm5DTElG.js → split-screen-BxZ5rBe7.js} +4 -4
  35. package/dist/{split-screen-DkqLng6q.d.ts → split-screen-D6sx8z3Z.d.ts} +2 -2
  36. package/dist/{sprite-animation-CqR2o3SA.js → sprite-animation-Bz3DHD_4.js} +1 -1
  37. package/dist/{src-DRSsRleD.js → src-CqtvYtSN.js} +1 -1
  38. package/dist/{test-Djuq3Vhq.js → test-CEl0y2Sw.js} +162 -30
  39. package/dist/test.d.ts +56 -5
  40. package/dist/test.js +2 -2
  41. package/dist/{touch-BnCyPA0G.js → touch-CioTZB-y.js} +1 -1
  42. package/dist/vite.js +3 -3
  43. package/editor/assets/{agent8-CMKz2cax.js → agent8-BlzturGi.js} +1 -1
  44. package/editor/assets/{debug-DLrQm-az.js → debug-BMUi8usj.js} +1 -1
  45. package/editor/assets/{index-BEKuxYcE.js → index-CrUCQoaB.js} +53 -53
  46. package/editor/index.html +1 -1
  47. package/package.json +1 -1
  48. package/schemas/scene.schema.json +634 -10
  49. package/skills/incanto-3d-character.md +113 -5
  50. package/skills/incanto-assets.md +9 -3
  51. package/skills/incanto-behaviors-and-scripts.md +26 -2
  52. package/skills/incanto-building-2d-games.md +9 -0
  53. package/skills/incanto-building-3d-games.md +45 -0
  54. package/skills/incanto-editor.md +6 -1
  55. package/skills/incanto-environment.md +7 -2
  56. package/skills/incanto-game-feel.md +16 -11
  57. package/skills/incanto-gameplay-behaviors.md +179 -16
  58. package/skills/incanto-node-reference.md +124 -45
  59. package/skills/incanto-physics-and-input.md +67 -5
  60. package/skills/incanto-scene-json-authoring.md +9 -3
  61. package/skills/incanto-verifying-your-game.md +19 -2
  62. package/templates-app/beacon-isle-3d/PROJECT/Status.md +8 -0
  63. package/templates-app/beacon-isle-3d/PROJECT/Structure.md +3 -0
  64. package/templates-app/beacon-isle-3d/package.json +1 -1
  65. package/templates-app/beacon-isle-3d/src/game.scene.json +136 -0
  66. package/templates-app/beacon-isle-3d/verify.ts +144 -0
  67. package/templates-app/molehill-2d/package.json +1 -1
  68. package/templates-app/platformer-2d/PROJECT/Status.md +7 -0
  69. package/templates-app/platformer-2d/PROJECT/Structure.md +3 -0
  70. package/templates-app/platformer-2d/package.json +1 -1
  71. package/templates-app/platformer-2d/src/game.scene.json +126 -0
  72. package/templates-app/platformer-2d/verify.ts +54 -0
  73. package/templates-app/star-survivor/package.json +1 -1
  74. package/templates-app/tps-3d/PROJECT/Context.md +1 -1
  75. package/templates-app/tps-3d/PROJECT/Status.md +40 -1
  76. package/templates-app/tps-3d/PROJECT/Structure.md +19 -0
  77. package/templates-app/tps-3d/package.json +1 -1
  78. package/templates-app/tps-3d/src/behaviors.ts +9 -0
  79. package/templates-app/tps-3d/src/cover.scene.json +26 -0
  80. package/templates-app/tps-3d/src/game.scene.json +331 -75
  81. package/templates-app/tps-3d/src/main.ts +14 -0
  82. package/templates-app/tps-3d/src/pillar.scene.json +25 -0
  83. package/templates-app/tps-3d/verify.ts +582 -3
  84. package/templates-app/village-quest-3d/package.json +1 -1
@@ -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-CEuJPUle.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) {
@@ -5517,15 +5737,15 @@ var DayNight = class extends Behavior {
5517
5737
  }
5518
5738
  update(dt) {
5519
5739
  if (!this.paused) this.hour = (this.hour + dt / this.daySeconds * 24) % 24;
5520
- this.applyClock -= dt;
5521
- if (this.applyClock > 0) return;
5522
- this.applyClock = .2;
5523
- this.applyHour();
5524
5740
  const next = phaseOf(this.hour);
5525
5741
  if (next !== this.phase) {
5526
5742
  this.phase = next;
5527
5743
  this.emit("dayPhaseChanged", next);
5528
5744
  }
5745
+ this.applyClock -= dt;
5746
+ if (this.applyClock > 0) return;
5747
+ this.applyClock = .2;
5748
+ this.applyHour();
5529
5749
  }
5530
5750
  applyHour() {
5531
5751
  const engine = this.node.tree?.engine;
@@ -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,64 @@ 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`: of those inside `coneDeg` of the current camera's
6441
+ * forward, the one nearest the CENTRE of the view — a crosshair — so a
6442
+ * note on the desk and the drawer under it are two different looks, and
6443
+ * in third person the plot the farmer looks at beats the plot under the
6444
+ * farmer's feet (with no camera, the same as `nearest`).
6445
+ */
6446
+ focus: {
6447
+ default: "any",
6448
+ options: [
6449
+ "any",
6450
+ "nearest",
6451
+ "facing"
6452
+ ]
6453
+ },
6454
+ /** Full width of the `facing` cone, degrees — a generous crosshair. */
6455
+ coneDeg: {
6456
+ default: 40,
6457
+ range: {
6458
+ min: 1,
6459
+ max: 360
6460
+ }
6461
+ }
6213
6462
  };
6214
- static signals = ["interacted"];
6463
+ static signals = [
6464
+ "interacted",
6465
+ "focused",
6466
+ "unfocused"
6467
+ ];
6215
6468
  action = "interact";
6216
6469
  range = 2;
6217
6470
  actorGroup = "player";
6471
+ prompt = "";
6472
+ focus = "any";
6473
+ coneDeg = 40;
6474
+ /**
6475
+ * The actor this Interactable would answer RIGHT NOW, or null — updated every
6476
+ * frame, with `focused(actor)` / `unfocused(actor)` on the edges. A HUD reads
6477
+ * the focused one's `prompt`.
6478
+ */
6479
+ focusedBy = null;
6218
6480
  /** Actions already reported missing, so a per-frame read says it once. */
6219
6481
  reportedActions = /* @__PURE__ */ new Set();
6220
6482
  update() {
6221
6483
  const engine = this.node.tree?.engine;
6222
6484
  if (!engine?.scene) return;
6223
- if (!tolerateUnknownAction({
6485
+ const pressed = tolerateUnknownAction({
6224
6486
  engine,
6225
6487
  who: this.node.getPath(),
6226
6488
  reported: this.reportedActions,
6227
6489
  consequence: "nothing here can be interacted with",
6228
6490
  authored: this.action !== "interact"
6229
- }, this.action, () => engine.input.justPressed(this.action), false)) return;
6491
+ }, this.action, () => engine.input.justPressed(this.action), false);
6230
6492
  if (!hasPosition(this.node)) return;
6231
6493
  const self = worldPosition(this.node);
6232
6494
  let nearest = null;
@@ -6240,9 +6502,92 @@ var Interactable = class extends Behavior {
6240
6502
  nearestDist = d;
6241
6503
  }
6242
6504
  }
6243
- if (nearest) this.emit("interacted", nearest);
6505
+ let holder = nearest;
6506
+ if (holder && this.focus !== "any" && !this.claims(holder, self)) holder = null;
6507
+ if (holder !== this.focusedBy) {
6508
+ const was = this.focusedBy;
6509
+ this.focusedBy = holder;
6510
+ if (was) this.emit("unfocused", was);
6511
+ if (holder) this.emit("focused", holder);
6512
+ }
6513
+ if (!pressed || !holder) return;
6514
+ this.emit("interacted", holder);
6515
+ }
6516
+ /** Is THIS the Interactable `actor` means — the nearest (that it faces) of every one in reach? */
6517
+ claims(actor, self) {
6518
+ const eye = this.focus === "facing" ? cameraEye(this.node.getRoot()) : null;
6519
+ const from = eye ? eye.at : worldPosition(actor);
6520
+ const centred = (target) => {
6521
+ if (!eye) return 1;
6522
+ const dx = (target[0] ?? 0) - (from[0] ?? 0);
6523
+ const dy = (target[1] ?? 0) - (from[1] ?? 0);
6524
+ const dz = (target[2] ?? 0) - (from[2] ?? 0);
6525
+ const len = Math.hypot(dx, dy, dz);
6526
+ if (len < 1e-6) return 1;
6527
+ return dx / len * eye.fx + dy / len * eye.fy + dz / len * eye.fz;
6528
+ };
6529
+ const edge = Math.cos(this.coneDeg / 2 * (Math.PI / 180));
6530
+ const mineCentred = centred(self);
6531
+ if (mineCentred < edge) return false;
6532
+ const mine = eye ? -mineCentred : distance(from, self);
6533
+ for (const other of interactablesIn(this.node.getRoot())) {
6534
+ if (other === this || other.focus === "any" || other.actorGroup !== this.actorGroup) continue;
6535
+ if (!hasPosition(other.node)) continue;
6536
+ const there = worldPosition(other.node);
6537
+ if (distance(worldPosition(actor), there) > other.range) continue;
6538
+ const theirsCentred = centred(there);
6539
+ if (eye && theirsCentred < edge) continue;
6540
+ if ((eye ? -theirsCentred : distance(from, there)) < mine) return false;
6541
+ }
6542
+ return true;
6244
6543
  }
6245
6544
  };
6545
+ /** Every Interactable in the tree — walked on demand, only while an actor is in reach of one. */
6546
+ function interactablesIn(root) {
6547
+ const out = [];
6548
+ const walk = (n) => {
6549
+ if (n.behavior instanceof Interactable) out.push(n.behavior);
6550
+ for (const c of n.children) walk(c);
6551
+ };
6552
+ walk(root);
6553
+ return out;
6554
+ }
6555
+ /**
6556
+ * Where the current camera is and which way it looks, or null when the tree
6557
+ * has no `Camera3D` (a 2D scene — `facing` then means `nearest`). Forward is
6558
+ * the camera's -z through its Euler XYZ rotation: the third basis column
6559
+ * negated, `(-sin ry, sin rx·cos ry, -cos rx·cos ry)`.
6560
+ */
6561
+ function cameraEye(root) {
6562
+ let first = null;
6563
+ let current = null;
6564
+ const walk = (n) => {
6565
+ if (n.constructor.typeName === "Camera3D") {
6566
+ first ??= n;
6567
+ if (n.current) current ??= n;
6568
+ }
6569
+ for (const c of n.children) walk(c);
6570
+ };
6571
+ walk(root);
6572
+ const cam = current ?? first;
6573
+ if (!cam) return null;
6574
+ const rot = cam.rotation ?? [
6575
+ 0,
6576
+ 0,
6577
+ 0
6578
+ ];
6579
+ const rx = (rot[0] ?? 0) * Math.PI / 180;
6580
+ const ry = (rot[1] ?? 0) * Math.PI / 180;
6581
+ const fx = -Math.sin(ry);
6582
+ const fy = Math.sin(rx) * Math.cos(ry);
6583
+ const fz = -Math.cos(rx) * Math.cos(ry);
6584
+ return {
6585
+ at: worldPosition(cam),
6586
+ fx,
6587
+ fy,
6588
+ fz
6589
+ };
6590
+ }
6246
6591
  function collectGroup(node, group) {
6247
6592
  const out = [];
6248
6593
  const walk = (n) => {
@@ -6703,11 +7048,18 @@ var PathFollow = class extends Behavior {
6703
7048
  static signals = ["waypointReached", "arrived"];
6704
7049
  static props = {
6705
7050
  speed: { default: 120 },
6706
- loop: { default: false }
7051
+ loop: { default: false },
7052
+ /** Move the parent node instead of this one — the AI-on-a-child pattern `Chase`/`Patrol`/`Wander` share. */
7053
+ moveParent: { default: false },
7054
+ /** A node to TURN toward the direction of travel (a skin, a hull) — the same `facePath`/`turnSpeed` the other movers have. */
7055
+ ...FACING_PROPS
6707
7056
  };
6708
7057
  /** Units per second (px in 2D, meters in 3D). */
6709
7058
  speed = 120;
6710
7059
  loop = false;
7060
+ moveParent = false;
7061
+ facePath = "";
7062
+ turnSpeed = 100;
6711
7063
  waypoints = [];
6712
7064
  index = 0;
6713
7065
  /** True while there is somewhere left to go. */
@@ -6725,24 +7077,47 @@ var PathFollow = class extends Behavior {
6725
7077
  }
6726
7078
  update(dt) {
6727
7079
  if (!this.moving) return;
6728
- const node = requirePosition(this);
7080
+ const node = this.mover();
6729
7081
  let budget = this.speed * dt;
7082
+ const from = [...node.position];
7083
+ let at = [...node.position];
6730
7084
  while (budget > 0 && this.index < this.waypoints.length) {
6731
7085
  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;
7086
+ const step = moveToward(at, target, budget);
7087
+ const travelled = distanceBetween(at, step.position);
7088
+ at = step.position;
6735
7089
  budget -= travelled;
6736
7090
  if (step.reached) {
6737
7091
  this.node.emit("waypointReached", this.index);
6738
7092
  this.index += 1;
6739
7093
  if (this.index >= this.waypoints.length) if (this.loop && this.waypoints.length > 1) this.index = 0;
6740
7094
  else {
7095
+ moveBody(node, at, dt);
7096
+ this.face(node, from, at, dt);
6741
7097
  this.node.emit("arrived");
6742
7098
  return;
6743
7099
  }
6744
- } else return;
7100
+ } else break;
6745
7101
  }
7102
+ moveBody(node, at, dt);
7103
+ this.face(node, from, at, dt);
7104
+ }
7105
+ /** `facePath` turns toward where this frame went — nothing when it did not move. */
7106
+ face(node, from, to, dt) {
7107
+ if (this.facePath === "") return;
7108
+ const skin = this.node.getNodeOrNull(this.facePath);
7109
+ if (!skin) return;
7110
+ faceTravel(skin, to.map((v, i) => v - (from[i] ?? 0)), dt, this.turnSpeed);
7111
+ }
7112
+ /** The node this walks: the parent under `moveParent`, else this one — both must be spatial. */
7113
+ mover() {
7114
+ if (!this.moveParent) return requirePosition(this);
7115
+ const parent = this.node.parent;
7116
+ 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.`, {
7117
+ prop: "moveParent",
7118
+ path: this.node.getPath()
7119
+ });
7120
+ return parent;
6746
7121
  }
6747
7122
  };
6748
7123
  function distanceBetween(a, b) {
@@ -7120,8 +7495,21 @@ var Projectile = class extends Behavior {
7120
7495
  /** Units per second along `direction`. */
7121
7496
  speed: { default: 300 },
7122
7497
  /** 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. */
7498
+ direction: {
7499
+ default: null,
7500
+ kinds: [
7501
+ "null",
7502
+ "array",
7503
+ "string"
7504
+ ]
7505
+ },
7506
+ /**
7507
+ * Constant DOWNWARD acceleration in the scene's own convention — +y in 2D
7508
+ * (y-down), −y in 3D (y-up) — so `9.81` pulls a 3D lob down. It used to add
7509
+ * to +y regardless, the 2D convention, and `9.81` in a y-up scene LIFTED the
7510
+ * shot (measured: 19 m up at 1.5 s); `Chase.ground` signs its fall the same
7511
+ * way. 0 = straight line.
7512
+ */
7125
7513
  gravity: { default: 0 }
7126
7514
  };
7127
7515
  static signals = [];
@@ -7180,7 +7568,7 @@ var Projectile = class extends Behavior {
7180
7568
  update(dt) {
7181
7569
  if (!this.armed) this.aim();
7182
7570
  const node = this.node;
7183
- if (this.gravity !== 0) this.fall += this.gravity * dt;
7571
+ if (this.gravity !== 0) this.fall += this.gravity * dt * (node.position.length === 3 ? -1 : 1);
7184
7572
  const pos = node.position;
7185
7573
  const n = Math.max(pos.length, this.velocity.length);
7186
7574
  const next = new Array(n);
@@ -7429,6 +7817,17 @@ var ScoreKeeper = class extends Behavior {
7429
7817
  scoreToWin = 0;
7430
7818
  hasWon = false;
7431
7819
  hasLost = false;
7820
+ /**
7821
+ * Whether the run is over — readable, not only heard. A stealth yard's HUD
7822
+ * and a melee arena's both read `score.won` for their banner and got
7823
+ * `undefined`, silently, because only the signals were public.
7824
+ */
7825
+ get won() {
7826
+ return this.hasWon;
7827
+ }
7828
+ get lost() {
7829
+ return this.hasLost;
7830
+ }
7432
7831
  onReady() {
7433
7832
  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
7833
  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 +7962,12 @@ var SpawnSource = class {
7563
7962
  */
7564
7963
  spawn(template, opts = void 0) {
7565
7964
  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);
7965
+ const at = opts?.at ?? [];
7966
+ const under = opts?.parent ?? this.behavior.node;
7967
+ if (hasPosition$1(clone) && (at.length > 0 || under !== this.behavior.node)) {
7968
+ const origin = under === this.behavior.node || !hasPosition$1(this.behavior.node) ? [] : sub(worldPosition(this.behavior.node), worldPosition(under));
7969
+ clone.position = add(origin, at.length > 0 ? at : zeros(origin.length || clone.position.length));
7970
+ }
7568
7971
  if (typeof clone.visible === "boolean") clone.visible = true;
7569
7972
  (opts?.parent ?? this.behavior.node).addChild(clone);
7570
7973
  return clone;
@@ -7593,6 +7996,15 @@ function absolutePrefabPath(node, prefab) {
7593
7996
  }
7594
7997
  return parts.join("/");
7595
7998
  }
7999
+ function sub(a, b) {
8000
+ const n = Math.max(a.length, b.length);
8001
+ const out = [];
8002
+ for (let i = 0; i < n; i++) out.push((a[i] ?? 0) - (b[i] ?? 0));
8003
+ return out;
8004
+ }
8005
+ function zeros(n) {
8006
+ return Array.from({ length: n }, () => 0);
8007
+ }
7596
8008
  //#endregion
7597
8009
  //#region src/gameplay/spawner.ts
7598
8010
  /**
@@ -7626,7 +8038,17 @@ var Spawner = class extends Behavior {
7626
8038
  /** Begin ticking at ready. */
7627
8039
  autoStart: { default: true },
7628
8040
  /** Total LIFETIME spawns (0 = infinite). */
7629
- total: { default: 0 }
8041
+ total: { default: 0 },
8042
+ /**
8043
+ * Where clones live: a node path, or '' for under the spawner itself. Clones
8044
+ * of a spawner that MOVES ride along with it when they are its children —
8045
+ * a gate a director slid to a new edge before each spawn dragged every
8046
+ * enemy it had made — so a container makes the spawner just a place.
8047
+ */
8048
+ into: {
8049
+ default: "",
8050
+ nodePath: true
8051
+ }
7630
8052
  };
7631
8053
  static signals = ["spawned", "finished"];
7632
8054
  prefab = "";
@@ -7635,6 +8057,7 @@ var Spawner = class extends Behavior {
7635
8057
  at = [];
7636
8058
  autoStart = true;
7637
8059
  total = 0;
8060
+ into = "";
7638
8061
  source = new SpawnSource(this);
7639
8062
  template;
7640
8063
  live = /* @__PURE__ */ new Set();
@@ -7657,6 +8080,7 @@ var Spawner = class extends Behavior {
7657
8080
  onReady() {
7658
8081
  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
8082
  this.template = this.source.resolveTemplate(this.prefab);
8083
+ if (this.into !== "") this.node.getNode(this.into);
7660
8084
  this.running = this.autoStart;
7661
8085
  this.timer = this.interval;
7662
8086
  }
@@ -7674,7 +8098,11 @@ var Spawner = class extends Behavior {
7674
8098
  this.prune();
7675
8099
  if (this.done) return null;
7676
8100
  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);
8101
+ const parent = this.into === "" ? void 0 : this.node.getNode(this.into);
8102
+ const clone = this.source.spawn(this.template, this.at.length > 0 || parent ? {
8103
+ at: this.at,
8104
+ parent
8105
+ } : void 0);
7678
8106
  this.live.add(clone);
7679
8107
  this.spawnedCount += 1;
7680
8108
  this.emit("spawned", clone);
@@ -7698,6 +8126,140 @@ var Spawner = class extends Behavior {
7698
8126
  }
7699
8127
  };
7700
8128
  //#endregion
8129
+ //#region src/gameplay/turret.ts
8130
+ /**
8131
+ * The oldest verb in games: pick the nearest live member of `targetGroup`
8132
+ * within `range` and hit it every `cooldown` — a defense tower, a ship's gun,
8133
+ * a sentry, a trooper who fights what he meets.
8134
+ *
8135
+ * Two games in a row wrote these forty lines before it existed (an RTS's
8136
+ * trooper, a tower defense's tower): the engine had `Chase` for going TO a
8137
+ * target and `Projectile` for the bullet, and nothing that decides to shoot.
8138
+ *
8139
+ * - **Hitscan** by default: the target's `Health` (on it or a child, as
8140
+ * `DamageOnContact` finds it) takes `damage(n)`.
8141
+ * - **Or a clone**: name a `shelf` (a `PrefabShelf`) and a `prefab` on it; each
8142
+ * shot takes one, puts it at the turret plus `muzzle`, points its
8143
+ * `Projectile` at the target and adds it under `into` (the turret's parent
8144
+ * when empty). The clone does the hurting then — give it `DamageOnContact`.
8145
+ * - `facePath` turns a barrel toward the target at `turnSpeed`.
8146
+ *
8147
+ * Signals: `acquired(target)` when something comes in range, `fired(target)`
8148
+ * per shot, `lost()` when nothing live is in range any more.
8149
+ */
8150
+ var Turret = class extends Behavior {
8151
+ static props = {
8152
+ /** Who it shoots: the nearest live member of this group. */
8153
+ targetGroup: { default: "enemy" },
8154
+ /** How far it reaches, in the scene's units. */
8155
+ range: {
8156
+ default: 6,
8157
+ range: { min: 0 }
8158
+ },
8159
+ /** Hitscan damage per shot (ignored when a `prefab` does the hurting). */
8160
+ damage: { default: 10 },
8161
+ /** Seconds between shots. */
8162
+ cooldown: {
8163
+ default: .6,
8164
+ range: { min: 0 }
8165
+ },
8166
+ /** A `PrefabShelf` to take each shot from (empty = hitscan). */
8167
+ shelf: {
8168
+ default: "",
8169
+ nodePath: true
8170
+ },
8171
+ /** The template on that shelf. */
8172
+ prefab: { default: "" },
8173
+ /** Where clones start, relative to the turret. */
8174
+ muzzle: { default: [] },
8175
+ /** Where clones go; empty = the turret's parent. */
8176
+ into: {
8177
+ default: "",
8178
+ nodePath: true
8179
+ },
8180
+ ...FACING_PROPS
8181
+ };
8182
+ static signals = [
8183
+ "acquired",
8184
+ "fired",
8185
+ "lost"
8186
+ ];
8187
+ targetGroup = "enemy";
8188
+ range = 6;
8189
+ damage = 10;
8190
+ cooldown = .6;
8191
+ shelf = "";
8192
+ prefab = "";
8193
+ muzzle = [];
8194
+ into = "";
8195
+ facePath = "";
8196
+ turnSpeed = 100;
8197
+ /** What it is shooting at right now, or null. */
8198
+ target = null;
8199
+ /** Shots fired so far. */
8200
+ shots = 0;
8201
+ wait = 0;
8202
+ onReady() {
8203
+ 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.`, {
8204
+ prop: this.shelf === "" ? "shelf" : "prefab",
8205
+ path: this.node.getPath()
8206
+ });
8207
+ }
8208
+ update(dt) {
8209
+ this.wait = Math.max(0, this.wait - dt);
8210
+ if (!hasPosition$1(this.node)) return;
8211
+ const here = worldPosition(this.node);
8212
+ let best = null;
8213
+ let bestD = this.range;
8214
+ for (const n of this.node.tree?.getNodesInGroup(this.targetGroup) ?? []) {
8215
+ if (n === this.node || !hasPosition$1(n)) continue;
8216
+ if (findHealth(n)?.health.isDead) continue;
8217
+ const d = distance$1(here, worldPosition(n));
8218
+ if (d <= bestD) {
8219
+ best = n;
8220
+ bestD = d;
8221
+ }
8222
+ }
8223
+ if (best !== this.target) {
8224
+ const had = this.target;
8225
+ this.target = best;
8226
+ if (best) this.emit("acquired", best);
8227
+ else if (had) this.emit("lost");
8228
+ }
8229
+ if (!best) return;
8230
+ const dir = worldPosition(best).map((v, i) => v - (here[i] ?? 0));
8231
+ if (this.facePath !== "") {
8232
+ const barrel = this.node.getNodeOrNull(this.facePath);
8233
+ if (barrel) faceTravel(barrel, dir, dt, this.turnSpeed);
8234
+ }
8235
+ if (this.wait > 0) return;
8236
+ this.wait = this.cooldown;
8237
+ this.shots += 1;
8238
+ if (this.shelf !== "") this.launch(best, here, dir);
8239
+ else findHealth(best)?.health.damage(this.damage);
8240
+ this.emit("fired", best);
8241
+ }
8242
+ /** One clone from the shelf, at the muzzle, aimed at the target, into the container. */
8243
+ launch(target, here, dir) {
8244
+ const shelf = this.node.getNodeOrNull(this.shelf)?.behavior;
8245
+ if (!shelf || typeof shelf.make !== "function") throw new IncantoError("NODE_NOT_FOUND", `Turret on '${this.node.getPath()}': "shelf" '${this.shelf}' is not a PrefabShelf.`, {
8246
+ prop: "shelf",
8247
+ path: this.node.getPath()
8248
+ });
8249
+ const clone = shelf.make(this.prefab);
8250
+ if (!hasPosition$1(clone)) return;
8251
+ const container = this.into !== "" ? this.node.getNodeOrNull(this.into) : this.node.parent;
8252
+ if (!container) return;
8253
+ const len = Math.hypot(...dir) || 1;
8254
+ const unit = dir.map((v) => v / len);
8255
+ const containerAt = hasPosition$1(container) ? worldPosition(container) : here.map(() => 0);
8256
+ clone.position = here.map((v, i) => v + (this.muzzle[i] ?? 0) - (containerAt[i] ?? 0));
8257
+ const shot = clone.behavior;
8258
+ if (shot && "direction" in shot) shot.direction = unit;
8259
+ container.addChild(clone);
8260
+ }
8261
+ };
8262
+ //#endregion
7701
8263
  //#region src/gameplay/wander.ts
7702
8264
  /**
7703
8265
  * Aimless roaming inside a circle around the spawn point — idle critters,
@@ -7793,6 +8355,14 @@ var WaveSpawner = class extends Behavior {
7793
8355
  parsed = [];
7794
8356
  live = /* @__PURE__ */ new Set();
7795
8357
  waveIndex = -1;
8358
+ /** The wave now running or being cleared, 1-based — what a HUD prints as `wave 2 / 5`. `0` before the first. */
8359
+ get wave() {
8360
+ return this.waveIndex < 0 ? 0 : Math.min(this.parsed.length, this.waveIndex + 1);
8361
+ }
8362
+ /** How many waves were declared. */
8363
+ get waveCount() {
8364
+ return this.parsed.length;
8365
+ }
7796
8366
  phase = "idle";
7797
8367
  timer = 0;
7798
8368
  spawnedThisWave = 0;
@@ -8105,6 +8675,8 @@ const GAMEPLAY_BEHAVIORS = {
8105
8675
  PathFollow,
8106
8676
  Health,
8107
8677
  Lifetime,
8678
+ Checkpoint,
8679
+ Turret,
8108
8680
  ScoreKeeper,
8109
8681
  Currency,
8110
8682
  Pickup,
@@ -8138,4 +8710,4 @@ function registerGameplayBehaviors(opts) {
8138
8710
  for (const [name, ctor] of Object.entries(GAMEPLAY_BEHAVIORS)) registerBehavior(name, ctor, { replace });
8139
8711
  }
8140
8712
  //#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 };
8713
+ 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 };