incanto 0.64.1 → 0.66.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 (59) hide show
  1. package/dist/2d.d.ts +38 -3
  2. package/dist/2d.js +3 -3
  3. package/dist/3d.d.ts +130 -3
  4. package/dist/3d.js +5 -5
  5. package/dist/{audio-player-D5GJgb_x.d.ts → audio-player-_UAcHxnC.d.ts} +1 -1
  6. package/dist/{behavior-DsgayMsH.d.ts → behavior-BXNLfIJk.d.ts} +128 -0
  7. package/dist/{create-game-B746rYbr.js → create-game-DYJCIzO0.js} +67 -6
  8. package/dist/{create-game-B1KM6dA6.js → create-game-Dd7H4bJV.js} +6 -6
  9. package/dist/debug.d.ts +1 -1
  10. package/dist/{duplicate-BOOKmkQ7.js → duplicate-CqSAtdrh.js} +1 -1
  11. package/dist/{environment-presets-D2vzw583.js → environment-presets-BlPsEmq6.js} +70 -6
  12. package/dist/{gameplay-CLgFdkh5.js → gameplay-D1RADWu3.js} +374 -18
  13. package/dist/gameplay.d.ts +94 -3
  14. package/dist/gameplay.js +2 -2
  15. package/dist/index.d.ts +4 -4
  16. package/dist/index.js +5 -5
  17. package/dist/{loader-DolLJWJn.d.ts → loader-Cga7FVP4.d.ts} +1 -1
  18. package/dist/{loader-zDynoew_.js → loader-lQDCwNag.js} +59 -3
  19. package/dist/net.d.ts +2 -2
  20. package/dist/net.js +1 -1
  21. package/dist/{physics-2d-BPJcJRRP.js → physics-2d-Cji5A6sX.js} +32 -4
  22. package/dist/{physics-3d-BOO58xzw.js → physics-3d-BkHjwJgI.js} +49 -5
  23. package/dist/{quiet-rapier-BAJ4K94N.js → quiet-rapier-C6fW4zcW.js} +14 -1
  24. package/dist/react.d.ts +1 -1
  25. package/dist/react.js +1 -1
  26. package/dist/{register-Btm7_Emq.js → register-BmuqYTiY.js} +44 -1
  27. package/dist/{register-dGsbnJ87.js → register-D0CxCveZ.js} +38 -4
  28. package/dist/{replay-C5x2vPF5.d.ts → replay-BHoB6fCU.d.ts} +1 -1
  29. package/dist/{replay-DKRmiVjk.js → replay-IsZbNu6d.js} +2 -2
  30. package/dist/{split-screen-Dx0LvzqS.js → split-screen-D_i7GRcY.js} +2 -2
  31. package/dist/{split-screen-D7OopelJ.d.ts → split-screen-eJUFjhi1.d.ts} +2 -2
  32. package/dist/{src-CNS2xQB1.js → src-BTLbXFPZ.js} +1 -1
  33. package/dist/{teardown-BwhkcNt8.js → teardown-byR9USax.js} +1 -1
  34. package/dist/{test-Ccob3X5i.js → test-DgrD0jHD.js} +13 -13
  35. package/dist/test.d.ts +4 -4
  36. package/dist/test.js +2 -2
  37. package/dist/vite.js +2 -2
  38. package/editor/assets/{agent8-Di-UEn5O.js → agent8-9N-Pd_YS.js} +1 -1
  39. package/editor/assets/{debug-DWztJ5_y.js → debug-CkbJICYp.js} +1 -1
  40. package/editor/assets/{index-fct4H89G.js → index-CeDhIPTC.js} +53 -53
  41. package/editor/index.html +1 -1
  42. package/package.json +1 -1
  43. package/schemas/scene.schema.json +19 -0
  44. package/skills/incanto-3d-character.md +11 -1
  45. package/skills/incanto-behaviors-and-scripts.md +21 -0
  46. package/skills/incanto-gameplay-behaviors.md +177 -9
  47. package/skills/incanto-node-reference.md +59 -1
  48. package/skills/incanto-physics-and-input.md +84 -1
  49. package/skills/incanto-web-integration.md +24 -3
  50. package/templates-app/beacon-isle-3d/package.json +1 -1
  51. package/templates-app/platformer-2d/package.json +1 -1
  52. package/templates-app/star-survivor/package.json +1 -1
  53. package/templates-app/tps-3d/package.json +1 -1
  54. package/templates-app/village-quest-3d/package.json +1 -1
  55. package/templates-app/beacon-isle-3d/coverage.json +0 -9
  56. package/templates-app/platformer-2d/coverage.json +0 -5
  57. package/templates-app/star-survivor/coverage.json +0 -5
  58. package/templates-app/tps-3d/coverage.json +0 -5
  59. package/templates-app/village-quest-3d/coverage.json +0 -9
@@ -1,7 +1,7 @@
1
- import { O as Node, P as Behavior, _ as SaveSlots, k as diagnose, n as loadScene, w as effectiveOrder, z as registerBehavior } from "./loader-zDynoew_.js";
1
+ import { O as Node, P as Behavior, _ as SaveSlots, k as diagnose, n as loadScene, w as effectiveOrder, z as registerBehavior } from "./loader-lQDCwNag.js";
2
2
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
3
3
  import { t as jsonClone } from "./json-CwwhxQgb.js";
4
- import { t as duplicateNode } from "./duplicate-BOOKmkQ7.js";
4
+ import { t as duplicateNode } from "./duplicate-CqSAtdrh.js";
5
5
  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";
6
6
  //#region src/3d/frustum.ts
7
7
  const scratchFrustum = new Frustum();
@@ -2534,6 +2534,36 @@ var StaticBody3D = class extends PhysicsBody3D {
2534
2534
  /** Sensor volume emitting `triggerEnter(other)` / `triggerExit(other)`. */
2535
2535
  var Area3D = class extends PhysicsBody3D {
2536
2536
  static typeName = "Area3D";
2537
+ /**
2538
+ * Who is inside RIGHT NOW — the standing answer behind the enter/exit pair.
2539
+ *
2540
+ * Enter and exit tell you about crossings; a pressure plate, a capture point,
2541
+ * a "how many enemies are in the blast" count and a shop trigger all want
2542
+ * OCCUPANCY. Without this every one of them kept a private `Set` fed by the
2543
+ * two signals, and had to guard it, because a body that is teleported or
2544
+ * freed inside a sensor does not reliably announce its exit. Physics already
2545
+ * knows; now it says.
2546
+ *
2547
+ * ```ts
2548
+ * const load = this.area.overlapping()
2549
+ * .reduce((kg, b) => kg + ((b as RigidBody3D).mass ?? 0), 0);
2550
+ * door.open = load >= 60;
2551
+ * ```
2552
+ *
2553
+ * It reports everything the solver says is inside, STATIC WORLD INCLUDED — a
2554
+ * plate laid into the floor genuinely contains the floor. Pass a group to ask
2555
+ * the question you usually mean:
2556
+ *
2557
+ * ```ts
2558
+ * area.overlapping('crate') // only things tagged `crate`
2559
+ * ```
2560
+ *
2561
+ * Empty in a scene with no physics world.
2562
+ */
2563
+ overlapping(group) {
2564
+ const all = this._physics?.overlapping(this) ?? [];
2565
+ return group === void 0 ? all : all.filter((n) => n.isInGroup(group));
2566
+ }
2537
2567
  };
2538
2568
  /** Dynamic simulated body. */
2539
2569
  var RigidBody3D = class extends PhysicsBody3D {
@@ -2548,6 +2578,11 @@ var RigidBody3D = class extends PhysicsBody3D {
2548
2578
  0,
2549
2579
  0,
2550
2580
  0
2581
+ ] },
2582
+ angularVelocity: { default: [
2583
+ 0,
2584
+ 0,
2585
+ 0
2551
2586
  ] }
2552
2587
  };
2553
2588
  mass = 1;
@@ -2561,6 +2596,18 @@ var RigidBody3D = class extends PhysicsBody3D {
2561
2596
  0,
2562
2597
  0
2563
2598
  ];
2599
+ /**
2600
+ * rad/s about each world axis. Read back every step; write to spin.
2601
+ *
2602
+ * A puzzle builder could neither launch a spinning body nor read how fast a
2603
+ * lever was swinging — only its angle, differenced by hand — because this had
2604
+ * no counterpart to `linearVelocity`. `fixedRotation` pins it at zero.
2605
+ */
2606
+ angularVelocity = [
2607
+ 0,
2608
+ 0,
2609
+ 0
2610
+ ];
2564
2611
  /** @internal set by Physics3D */
2565
2612
  _physics3d = null;
2566
2613
  /** World-space impulse (kg·m/s) — the floating-capsule controller's verb. */
@@ -4360,6 +4407,65 @@ function firstWater(node) {
4360
4407
  return null;
4361
4408
  }
4362
4409
  //#endregion
4410
+ //#region src/gameplay/face-travel.ts
4411
+ const RAD2DEG = 180 / Math.PI;
4412
+ /**
4413
+ * Turn a node to face the direction something is travelling.
4414
+ *
4415
+ * `CharacterController3D` does this for the PLAYER (`skinPath`/`turnSpeed`) and
4416
+ * nothing did it for anyone else — so every game with a patrolling or chasing
4417
+ * enemy hand-wrote the same six lines, against a formula the docs simultaneously
4418
+ * teach and warn about: `incanto-3d-character.md` calls the wrong version of it
4419
+ * "the #1 recurring bug" (a character that runs BACKWARDS, showing you its back
4420
+ * while charging at you). This is the right version, once, in the engine.
4421
+ *
4422
+ * The rule, for 3D: the model is **+Z-FORWARD**, so `yaw = atan2(dx, dz)` — x
4423
+ * and z, never y, and NO 180° added. The 180° is only the at-rest mount for a
4424
+ * behind-the-shoulder camera, not a facing formula.
4425
+ *
4426
+ * In 2D `rotation` is a scalar and the plane is x/y, so it is `atan2(dy, dx)` —
4427
+ * correct for the top-down games that use `Patrol`/`Chase`. A platformer faces
4428
+ * with `flipX` instead and should not set `facePath`.
4429
+ *
4430
+ * `turnSpeed` is rad/s, the same units and the same shortest-arc, rate-limited
4431
+ * step the controller uses; at the default 100 it is effectively an instant
4432
+ * snap. A zero-length direction leaves the rotation alone — a node that is not
4433
+ * moving has nothing to face.
4434
+ */
4435
+ function faceTravel(target, dir, dt, turnSpeed) {
4436
+ const spatial = target;
4437
+ const rot = spatial.rotation;
4438
+ const is3d = Array.isArray(rot);
4439
+ const [a, b] = is3d ? [dir[0] ?? 0, dir[2] ?? 0] : [dir[0] ?? 0, dir[1] ?? 0];
4440
+ if (Math.hypot(a, b) < 1e-6) return;
4441
+ const want = (is3d ? Math.atan2(a, b) : Math.atan2(b, a)) * RAD2DEG;
4442
+ const current = is3d ? rot[1] ?? 0 : rot ?? 0;
4443
+ let delta = (want - current + 540) % 360 - 180;
4444
+ const step = turnSpeed * dt * RAD2DEG;
4445
+ if (Math.abs(delta) > step) delta = Math.sign(delta) * step;
4446
+ const next = current + delta;
4447
+ if (is3d) spatial.rotation = [
4448
+ rot[0] ?? 0,
4449
+ next,
4450
+ rot[2] ?? 0
4451
+ ];
4452
+ else spatial.rotation = next;
4453
+ }
4454
+ /** The shared `facePath`/`turnSpeed` prop pair for the AI movement behaviors. */
4455
+ const FACING_PROPS = {
4456
+ /**
4457
+ * Node path of the thing to TURN toward the travel direction — usually the
4458
+ * `ModelInstance3D` skin ('../Skin'). Empty (default) turns nothing, so a
4459
+ * scene that never asks is unchanged.
4460
+ */
4461
+ facePath: {
4462
+ default: "",
4463
+ nodePath: true
4464
+ },
4465
+ /** Turn rate in rad/s; 100 (default) is an instant snap, like the controller. */
4466
+ turnSpeed: { default: 100 }
4467
+ };
4468
+ //#endregion
4363
4469
  //#region src/gameplay/move-body.ts
4364
4470
  function moveBody(node, to, dt) {
4365
4471
  const body = node;
@@ -4521,6 +4627,8 @@ function localFromWorld(node, world) {
4521
4627
  * it leaves), so wire it to an attack.
4522
4628
  * - `loseRange` (>0) gives up the chase when the target gets that far away,
4523
4629
  * emitting `lostTarget` once.
4630
+ * - `facePath`/`turnSpeed` turn a skin toward the direction of travel — the job
4631
+ * `CharacterController3D` does for the player and nobody did for an NPC.
4524
4632
  * - `moveParent` (default false) moves the parent node instead of this one.
4525
4633
  * A node carries ONE behavior, so an enemy whose ROOT must hold `Health`
4526
4634
  * (e.g. for `Health.freeOnDeath` clone-safe cleanup) puts `Chase` on a CHILD
@@ -4542,7 +4650,8 @@ var Chase = class extends Behavior {
4542
4650
  /** Give up (emit lostTarget) beyond this distance (0 = never lose). */
4543
4651
  loseRange: { default: 0 },
4544
4652
  /** Move the parent node instead of this one (AI-on-a-child pattern). */
4545
- moveParent: { default: false }
4653
+ moveParent: { default: false },
4654
+ ...FACING_PROPS
4546
4655
  };
4547
4656
  static signals = ["reachedTarget", "lostTarget"];
4548
4657
  target = "";
@@ -4550,6 +4659,8 @@ var Chase = class extends Behavior {
4550
4659
  stopRange = 0;
4551
4660
  loseRange = 0;
4552
4661
  moveParent = false;
4662
+ facePath = "";
4663
+ turnSpeed = 100;
4553
4664
  inRange = false;
4554
4665
  lost = false;
4555
4666
  onReady() {
@@ -4586,8 +4697,13 @@ var Chase = class extends Behavior {
4586
4697
  }
4587
4698
  this.inRange = false;
4588
4699
  const step = Math.min(this.speed * dt, this.stopRange > 0 ? d - this.stopRange : d);
4700
+ const from = [...mover.position];
4589
4701
  const { position } = moveToward(mover.position, target.position, Math.max(0, step));
4590
4702
  moveBody(mover, position, dt);
4703
+ if (this.facePath !== "") {
4704
+ const skin = this.node.getNodeOrNull(this.facePath);
4705
+ if (skin) faceTravel(skin, position.map((v, i) => v - (from[i] ?? 0)), dt, this.turnSpeed);
4706
+ }
4591
4707
  }
4592
4708
  };
4593
4709
  //#endregion
@@ -4621,9 +4737,7 @@ var Clickable = class extends Behavior {
4621
4737
  /** Mouse button: 0 left, 1 middle, 2 right. */
4622
4738
  button: { default: 0 },
4623
4739
  /** Ignore clicks further away than this (0 = any distance). */
4624
- maxDistance: { default: 0 },
4625
- /** Stop responding without removing the behavior. */
4626
- enabled: { default: true }
4740
+ maxDistance: { default: 0 }
4627
4741
  };
4628
4742
  static signals = [
4629
4743
  "clicked",
@@ -4632,20 +4746,19 @@ var Clickable = class extends Behavior {
4632
4746
  ];
4633
4747
  button = 0;
4634
4748
  maxDistance = 0;
4635
- enabled = true;
4636
4749
  /** True while the cursor is over this node — read it to tint a hover state. */
4637
4750
  hovering = false;
4638
4751
  pressedHere = false;
4639
4752
  update() {
4640
4753
  const engine = this.node.tree?.engine;
4641
- if (!engine || !this.enabled) return;
4754
+ if (!engine) return;
4642
4755
  const picker = engine.picker;
4643
4756
  const pointer = engine.input.pointerPosition();
4644
4757
  if (!picker || !pointer) {
4645
4758
  if (this.hovering) this.setHover(false);
4646
4759
  return;
4647
4760
  }
4648
- const hit = picker(pointer.x, pointer.y);
4761
+ const hit = engine.pickAt(pointer.x, pointer.y);
4649
4762
  const over = hit !== null && this.isSelfOrDescendant(hit) && this.withinRange(hit);
4650
4763
  if (over !== this.hovering) this.setHover(over);
4651
4764
  if (engine.input.mouseJustPressed(this.button)) this.pressedHere = over;
@@ -4715,6 +4828,104 @@ group: { default: "player" } };
4715
4828
  }
4716
4829
  };
4717
4830
  //#endregion
4831
+ //#region src/gameplay/currency.ts
4832
+ /**
4833
+ * Money. Earn it, spend it, and refuse what you cannot afford.
4834
+ *
4835
+ * `ScoreKeeper` is the closest thing the library had and it is a SCORE: it
4836
+ * counts up, it has no spend, no affordability question, and its counter is
4837
+ * already wired to the win condition. So a tower defense built on 0.65.0 wrote
4838
+ * its own wallet — and so does every shop, every build mode, every upgrade tree
4839
+ * and every economy game.
4840
+ *
4841
+ * `spend` is the whole point: it returns whether it went through, and emits
4842
+ * `refused` when it did not, so "you cannot afford that" is a wire rather than
4843
+ * a comparison every caller repeats and one caller forgets.
4844
+ */
4845
+ var Currency = class extends Behavior {
4846
+ static props = {
4847
+ /** What you start with. */
4848
+ amount: { default: 0 },
4849
+ /** Ceiling (0 = none). */
4850
+ max: { default: 0 }
4851
+ };
4852
+ static signals = [
4853
+ "changed",
4854
+ "earned",
4855
+ "spent",
4856
+ "refused"
4857
+ ];
4858
+ amount = 0;
4859
+ max = 0;
4860
+ /** Can this be paid right now? */
4861
+ canAfford(cost) {
4862
+ return cost <= this.amount;
4863
+ }
4864
+ /** Pay `cost` if it is there. Returns whether it went through. */
4865
+ spend(cost) {
4866
+ if (!this.canAfford(cost)) {
4867
+ this.emit("refused", cost);
4868
+ return false;
4869
+ }
4870
+ this.amount -= cost;
4871
+ this.emit("spent", cost);
4872
+ this.emit("changed", this.amount);
4873
+ return true;
4874
+ }
4875
+ /** Take payment in. */
4876
+ earn(gain) {
4877
+ this.amount = this.max > 0 ? Math.min(this.max, this.amount + gain) : this.amount + gain;
4878
+ this.emit("earned", gain);
4879
+ this.emit("changed", this.amount);
4880
+ }
4881
+ /** Set it outright (a shop that grants, a cheat, a restore). */
4882
+ setAmount(next) {
4883
+ this.amount = this.max > 0 ? Math.min(this.max, next) : next;
4884
+ this.emit("changed", this.amount);
4885
+ }
4886
+ serialize() {
4887
+ return { amount: this.amount };
4888
+ }
4889
+ deserialize(data) {
4890
+ const d = data;
4891
+ if (typeof d.amount === "number") this.amount = d.amount;
4892
+ }
4893
+ };
4894
+ //#endregion
4895
+ //#region src/gameplay/free-target.ts
4896
+ /**
4897
+ * Free the ENTITY a despawning behavior belongs to, not just the node it sits on.
4898
+ *
4899
+ * `Lifetime` and `DamageOnContact` both remove their own node, which is right
4900
+ * when the behavior IS the thing. It is wrong for the composition the docs
4901
+ * teach: a node carries ONE script, so a bullet that both hurts and expires must
4902
+ * put those on CHILD nodes — and then each child quietly removed itself while
4903
+ * the bullet flew on forever, leaking a node per shot.
4904
+ *
4905
+ * `freeParent: true` is the other half. It is the same escape hatch the movement
4906
+ * behaviors call `moveParent`, named the same way for the same reason.
4907
+ */
4908
+ function freeOwner(behavior, freeParent, who) {
4909
+ if (!freeParent) {
4910
+ behavior.node.queueFree();
4911
+ return;
4912
+ }
4913
+ const parent = behavior.node.parent;
4914
+ if (!parent) {
4915
+ diagnose(behavior.node.tree?.engine ?? null, "error", `[incanto] ${who} on '${behavior.node.getPath()}': "freeParent" is set and this node has no parent to free — it is the scene root. Nothing was removed. Drop the prop, or move this behavior onto a child of the thing you meant to despawn.`);
4916
+ return;
4917
+ }
4918
+ parent.queueFree();
4919
+ }
4920
+ /** The shared prop, so both behaviors declare it identically. */
4921
+ const FREE_PARENT_PROP = {
4922
+ /**
4923
+ * Free this node's PARENT instead of this node — the composition where a
4924
+ * bullet carries `Projectile` and its children carry the damage and the
4925
+ * timer. Without it each child removes only itself and the bullet leaks.
4926
+ */
4927
+ freeParent: { default: false } };
4928
+ //#endregion
4718
4929
  //#region src/gameplay/health.ts
4719
4930
  /**
4720
4931
  * Hit points with regeneration and post-hit invulnerability (i-frames) —
@@ -4905,7 +5116,9 @@ function onTriggerEnter(behavior, fn) {
4905
5116
  * - `repeatEvery` (seconds, 0 = off) re-damages targets that STAY overlapped —
4906
5117
  * lava pools, poison clouds, an enemy standing on you. Contact events fire
4907
5118
  * only on entry/exit; this is the "and it keeps hurting" knob.
4908
- * - `destroySelf` frees the hazard after a hit (single-use projectiles).
5119
+ * - `destroySelf` frees the hazard after a hit (single-use projectiles), and
5120
+ * `freeParent` makes that free the PARENT — a bullet whose hitbox is a child,
5121
+ * which is the only shape a node-carries-one-script engine allows.
4909
5122
  *
4910
5123
  * SCORING PATTERN (clone-safe): wire the KILLER's `dealtDamage` →
4911
5124
  * `ScoreKeeper.addScore`. The weapon is usually a non-cloned node (it lives on
@@ -4923,7 +5136,8 @@ var DamageOnContact = class extends Behavior {
4923
5136
  /** Re-damage targets still overlapping every N seconds (0 = entry only). */
4924
5137
  repeatEvery: { default: 0 },
4925
5138
  /** queueFree() this node after the first successful hit. */
4926
- destroySelf: { default: false }
5139
+ destroySelf: { default: false },
5140
+ ...FREE_PARENT_PROP
4927
5141
  };
4928
5142
  static signals = ["dealtDamage"];
4929
5143
  amount = 10;
@@ -4931,6 +5145,7 @@ var DamageOnContact = class extends Behavior {
4931
5145
  oncePerTarget = true;
4932
5146
  repeatEvery = 0;
4933
5147
  destroySelf = false;
5148
+ freeParent = false;
4934
5149
  hit = /* @__PURE__ */ new WeakSet();
4935
5150
  /** Targets currently overlapping → seconds until their next repeat tick. */
4936
5151
  inside = /* @__PURE__ */ new Map();
@@ -4966,7 +5181,7 @@ var DamageOnContact = class extends Behavior {
4966
5181
  if (this.oncePerTarget) this.hit.add(other);
4967
5182
  found.health.damage(this.amount);
4968
5183
  this.emit("dealtDamage", this.amount, found.owner);
4969
- if (this.destroySelf) this.node.queueFree();
5184
+ if (this.destroySelf) freeOwner(this, this.freeParent, "DamageOnContact");
4970
5185
  }
4971
5186
  };
4972
5187
  /**
@@ -5080,6 +5295,109 @@ function phaseOf(hour) {
5080
5295
  return "dusk";
5081
5296
  }
5082
5297
  //#endregion
5298
+ //#region src/gameplay/face-target.ts
5299
+ /**
5300
+ * Turn toward the nearest thing in a group, and say when you are on it.
5301
+ *
5302
+ * A turret is the shape every tower defense, sentry gun, security camera and
5303
+ * idle-NPC-that-watches-you needs, and it could not be composed. `Chase`
5304
+ * already finds the nearest target and turns toward it — and then MOVES, which
5305
+ * is the one thing a turret must not do; `Patrol`/`Chase`'s `facePath` only
5306
+ * turns as a side effect of locomotion, so a stationary node cannot use it.
5307
+ * A tower defense built on 0.65.0 hand-wrote acquisition (~20 lines) and
5308
+ * turn-and-gate-the-shot (~15) because of that.
5309
+ *
5310
+ * `aimed` fires when the facing settles onto the target and `lostAim` when it
5311
+ * comes off, so "only fire while actually pointed at it" is a wire rather than
5312
+ * an angle comparison you write yourself.
5313
+ */
5314
+ var FaceTarget = class extends Behavior {
5315
+ static props = {
5316
+ /** Group to acquire from — the nearest live member wins. */
5317
+ targetGroup: {
5318
+ default: "",
5319
+ required: true
5320
+ },
5321
+ /** Only acquire within this distance (0 = anywhere). */
5322
+ range: { default: 0 },
5323
+ /** Node to TURN (usually a skin); empty turns this behavior's own node. */
5324
+ facePath: {
5325
+ default: "",
5326
+ nodePath: true
5327
+ },
5328
+ /** Turn rate rad/s; 100 is an instant snap, as on the controller. */
5329
+ turnSpeed: { default: 6 },
5330
+ /** Degrees of slop that still counts as pointed at it. */
5331
+ aimTolerance: { default: 6 }
5332
+ };
5333
+ static signals = [
5334
+ "acquired",
5335
+ "lostTarget",
5336
+ "aimed",
5337
+ "lostAim"
5338
+ ];
5339
+ targetGroup = "";
5340
+ range = 0;
5341
+ facePath = "";
5342
+ turnSpeed = 6;
5343
+ aimTolerance = 6;
5344
+ /** The node currently being tracked, or null. */
5345
+ target = null;
5346
+ /** True while the facing is within `aimTolerance` of the target. */
5347
+ onTarget = false;
5348
+ onReady() {
5349
+ if (this.targetGroup === "") throw new IncantoError("PROP_TYPE_MISMATCH", `FaceTarget on '${this.node.getPath()}': "targetGroup" is required — it is the group this turret acquires from.`, { prop: "targetGroup" });
5350
+ }
5351
+ update(dt) {
5352
+ const self = this.node;
5353
+ if (!hasPosition$1(self)) return;
5354
+ const found = this.nearest();
5355
+ if (found !== this.target) {
5356
+ this.target = found;
5357
+ this.emit(found ? "acquired" : "lostTarget", found);
5358
+ if (!found) this.setAim(false);
5359
+ }
5360
+ if (!found) return;
5361
+ const turret = this.facePath === "" ? self : this.node.getNodeOrNull(this.facePath);
5362
+ if (!turret) return;
5363
+ const dir = found.position.map((v, i) => v - (self.position[i] ?? 0));
5364
+ faceTravel(turret, dir, dt, this.turnSpeed);
5365
+ this.setAim(this.pointedAt(turret, dir));
5366
+ }
5367
+ nearest() {
5368
+ const self = this.node;
5369
+ let best = null;
5370
+ let bestD = Number.POSITIVE_INFINITY;
5371
+ for (const candidate of this.node.tree?.getNodesInGroup(this.targetGroup) ?? []) {
5372
+ if (!hasPosition$1(candidate)) continue;
5373
+ const d = Math.hypot(...candidate.position.map((v, i) => v - (self.position[i] ?? 0)));
5374
+ if (this.range > 0 && d > this.range) continue;
5375
+ if (d < bestD) {
5376
+ bestD = d;
5377
+ best = candidate;
5378
+ }
5379
+ }
5380
+ return best;
5381
+ }
5382
+ /** Is the turret's forward within tolerance of the direction to the target? */
5383
+ pointedAt(turret, dir) {
5384
+ const rot = turret.rotation;
5385
+ const is3d = Array.isArray(rot);
5386
+ const [a, b] = is3d ? [dir[0] ?? 0, dir[2] ?? 0] : [dir[0] ?? 0, dir[1] ?? 0];
5387
+ const len = Math.hypot(a, b);
5388
+ if (len < 1e-6) return true;
5389
+ const yaw = (is3d ? rot[1] ?? 0 : rot ?? 0) * Math.PI / 180;
5390
+ const fx = is3d ? Math.sin(yaw) : Math.cos(yaw);
5391
+ const fy = is3d ? Math.cos(yaw) : Math.sin(yaw);
5392
+ return (fx * a + fy * b) / len >= Math.cos(this.aimTolerance * Math.PI / 180);
5393
+ }
5394
+ setAim(on) {
5395
+ if (on === this.onTarget) return;
5396
+ this.onTarget = on;
5397
+ this.emit(on ? "aimed" : "lostAim", this.target);
5398
+ }
5399
+ };
5400
+ //#endregion
5083
5401
  //#region src/gameplay/float-away.ts
5084
5402
  /**
5085
5403
  * Rise, fade, and be gone — the second half of a damage number.
@@ -5714,6 +6032,10 @@ const hitStops = /* @__PURE__ */ new Map();
5714
6032
  * pickups that vanish. Accumulates `dt`; on elapse emits `expired` then
5715
6033
  * `queueFree()`s its node.
5716
6034
  *
6035
+ * `freeParent: true` frees the node's PARENT instead — the shape a bullet needs,
6036
+ * since a node carries ONE script so `Projectile`, the damage and the timer have
6037
+ * to live on separate nodes.
6038
+ *
5717
6039
  * With `startOnSignal: true` the countdown is armed manually via `startTimer()`
5718
6040
  * (wire a signal → `startTimer`), so the lifetime begins on an event rather
5719
6041
  * than at spawn.
@@ -5723,11 +6045,13 @@ var Lifetime = class extends Behavior {
5723
6045
  /** Seconds before the node frees itself. */
5724
6046
  seconds: { default: 1 },
5725
6047
  /** Defer the countdown until `startTimer()` is called (default: start at ready). */
5726
- startOnSignal: { default: false }
6048
+ startOnSignal: { default: false },
6049
+ ...FREE_PARENT_PROP
5727
6050
  };
5728
6051
  static signals = ["expired"];
5729
6052
  seconds = 1;
5730
6053
  startOnSignal = false;
6054
+ freeParent = false;
5731
6055
  elapsed = 0;
5732
6056
  running = false;
5733
6057
  fired = false;
@@ -5748,7 +6072,7 @@ var Lifetime = class extends Behavior {
5748
6072
  this.fired = true;
5749
6073
  this.running = false;
5750
6074
  this.emit("expired");
5751
- this.node.queueFree();
6075
+ freeOwner(this, this.freeParent, "Lifetime");
5752
6076
  }
5753
6077
  }
5754
6078
  };
@@ -6004,6 +6328,13 @@ function distanceBetween(a, b) {
6004
6328
  *
6005
6329
  * - `loop` (default) — after the last point, head back to the first.
6006
6330
  * - `mode: 'pingpong'` — reverse direction at each end instead of wrapping.
6331
+ * - `moveParent` (default false) moves the parent node instead of this one,
6332
+ * exactly as `Chase` does. A node carries ONE behavior, so a guard that must
6333
+ * patrol AND hold `Health` on its body puts `Patrol` on a CHILD. Without
6334
+ * this the patrolling body had to carry `Patrol` itself and could hold
6335
+ * nothing else — so a patrolling enemy could not be hurt.
6336
+ * - `facePath`/`turnSpeed` turn a skin toward the direction of travel, the job
6337
+ * `CharacterController3D` does for the player and nobody did for an NPC.
6007
6338
  */
6008
6339
  var Patrol = class extends Behavior {
6009
6340
  static props = {
@@ -6022,7 +6353,10 @@ var Patrol = class extends Behavior {
6022
6353
  options: ["loop", "pingpong"]
6023
6354
  },
6024
6355
  /** Seconds to wait at each reached point before continuing. */
6025
- pauseAt: { default: 0 }
6356
+ pauseAt: { default: 0 },
6357
+ /** Move the parent node instead of this one (AI-on-a-child pattern). */
6358
+ moveParent: { default: false },
6359
+ ...FACING_PROPS
6026
6360
  };
6027
6361
  static signals = ["reachedPoint"];
6028
6362
  points = [];
@@ -6030,33 +6364,53 @@ var Patrol = class extends Behavior {
6030
6364
  loop = true;
6031
6365
  mode = "loop";
6032
6366
  pauseAt = 0;
6367
+ moveParent = false;
6368
+ facePath = "";
6369
+ turnSpeed = 100;
6033
6370
  index = 0;
6034
6371
  direction = 1;
6035
6372
  pauseTimer = 0;
6036
6373
  onReady() {
6037
6374
  if (!Array.isArray(this.points) || this.points.length === 0) throw new IncantoError("PROP_TYPE_MISMATCH", `Patrol on '${this.node.getPath()}': "points" must be a non-empty array of position arrays or node paths.`, { prop: "points" });
6038
6375
  if (this.mode !== "loop" && this.mode !== "pingpong") throw new IncantoError("PROP_TYPE_MISMATCH", `Patrol on '${this.node.getPath()}': "mode" must be 'loop' or 'pingpong', got '${this.mode}'.`, { prop: "mode" });
6039
- requirePosition(this);
6376
+ if (this.moveParent) {
6377
+ const parent = this.node.parent;
6378
+ if (!parent || !hasPosition$1(parent)) throw new IncantoError("PROP_TYPE_MISMATCH", `Patrol on '${this.node.getPath()}': "moveParent" needs a spatial parent (a Node2D/Node3D) to move. '${this.node.name}' has none.`, {
6379
+ prop: "moveParent",
6380
+ path: this.node.getPath()
6381
+ });
6382
+ } else requirePosition(this);
6040
6383
  }
6041
6384
  update(dt) {
6042
6385
  if (this.pauseTimer > 0) {
6043
6386
  this.pauseTimer = Math.max(0, this.pauseTimer - dt);
6044
6387
  return;
6045
6388
  }
6046
- const node = this.node;
6389
+ const mover = this.moveParent ? this.node.parent : this.node;
6390
+ if (!mover || !hasPosition$1(mover)) return;
6391
+ const node = mover;
6047
6392
  const target = this.pointAt(this.index);
6048
6393
  if (!target) {
6049
6394
  this.reportDeadWaypoint(this.index);
6050
6395
  return;
6051
6396
  }
6397
+ const from = [...node.position];
6052
6398
  const { position, reached } = moveToward(node.position, target, this.speed * dt);
6053
6399
  moveBody(node, position, dt);
6400
+ this.face(from, position, dt);
6054
6401
  if (reached) {
6055
6402
  this.emit("reachedPoint", this.index);
6056
6403
  this.advance();
6057
6404
  if (this.pauseAt > 0) this.pauseTimer = this.pauseAt;
6058
6405
  }
6059
6406
  }
6407
+ /** Turn the skin toward the step just taken. */
6408
+ face(from, to, dt) {
6409
+ if (this.facePath === "") return;
6410
+ const skin = this.node.getNodeOrNull(this.facePath);
6411
+ if (!skin) return;
6412
+ faceTravel(skin, to.map((v, i) => v - (from[i] ?? 0)), dt, this.turnSpeed);
6413
+ }
6060
6414
  /** Waypoints already reported dead, so a per-frame stall says it once. */
6061
6415
  reportedDead = /* @__PURE__ */ new Set();
6062
6416
  /**
@@ -7006,6 +7360,7 @@ const GAMEPLAY_BEHAVIORS = {
7006
7360
  Health,
7007
7361
  Lifetime,
7008
7362
  ScoreKeeper,
7363
+ Currency,
7009
7364
  Pickup,
7010
7365
  Collector,
7011
7366
  DamageOnContact,
@@ -7015,6 +7370,7 @@ const GAMEPLAY_BEHAVIORS = {
7015
7370
  FollowCamera,
7016
7371
  Patrol,
7017
7372
  Chase,
7373
+ FaceTarget,
7018
7374
  Wander,
7019
7375
  ZombieAI,
7020
7376
  MoveTo,
@@ -7035,4 +7391,4 @@ function registerGameplayBehaviors(opts) {
7035
7391
  for (const [name, ctor] of Object.entries(GAMEPLAY_BEHAVIORS)) registerBehavior(name, ctor, { replace });
7036
7392
  }
7037
7393
  //#endregion
7038
- export { Health as A, CharacterBody3D as B, restartScene as C, DayNight as D, FloatAway as E, Water3D as F, validateCollider3D as G, RigidBody3D as H, WATER_CUTOUT_MAX as I, colliderFootDrop as J, createCausticsQuad as K, WaterCutout3D as L, Chase as M, localFromWorld as N, phaseOf as O, worldPosition as P, rejectScale as R, goToScene as S, FollowCamera as T, StaticBody3D as U, PhysicsBody3D as V, Node3D as W, Cooldown as _, Wander as a, Interactable as b, SavePoint as c, Patrol as d, PathFollow as f, CameraShake as g, Lifetime as h, WaveSpawner as i, Collector as j, DamageOnContact as k, Projectile as l, MoveTo as m, registerGameplayBehaviors as n, Spawner as o, Oscillate as p, WATER_MAX_RIPPLES as q, ZombieAI as r, ScoreKeeper as s, GAMEPLAY_BEHAVIORS as t, Pickup as u, hitStop as v, tolerateUnknownAction as w, GameFlow as x, screenFlash as y, Area3D as z };
7394
+ export { DamageOnContact as A, rejectScale as B, restartScene as C, FaceTarget as D, FloatAway as E, localFromWorld as F, StaticBody3D as G, CharacterBody3D as H, worldPosition as I, createCausticsQuad as J, Node3D as K, Water3D as L, Currency as M, Collector as N, DayNight as O, Chase as P, WATER_CUTOUT_MAX as R, goToScene as S, FollowCamera as T, PhysicsBody3D as U, Area3D as V, RigidBody3D as W, colliderFootDrop as X, WATER_MAX_RIPPLES as Y, Cooldown as _, Wander as a, Interactable as b, SavePoint as c, Patrol as d, PathFollow as f, CameraShake as g, Lifetime as h, WaveSpawner as i, Health as j, phaseOf as k, Projectile as l, MoveTo as m, registerGameplayBehaviors as n, Spawner as o, Oscillate as p, validateCollider3D as q, ZombieAI as r, ScoreKeeper as s, GAMEPLAY_BEHAVIORS as t, Pickup as u, hitStop as v, tolerateUnknownAction as w, GameFlow as x, screenFlash as y, WaterCutout3D as z };