incanto 0.65.0 → 0.67.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 (88) hide show
  1. package/bin/_behaviors-loader.mjs +87 -0
  2. package/bin/incanto-editor.mjs +39 -2
  3. package/bin/incanto-feel.mjs +4 -1
  4. package/bin/incanto-frame.mjs +52 -3
  5. package/bin/incanto-logs.mjs +33 -3
  6. package/bin/incanto-multiplay.mjs +50 -1
  7. package/bin/incanto-play.mjs +3 -1
  8. package/bin/incanto-playtest.mjs +3 -25
  9. package/bin/incanto-verify.mjs +15 -2
  10. package/dist/2d.d.ts +76 -4
  11. package/dist/2d.js +3 -3
  12. package/dist/3d.d.ts +213 -14
  13. package/dist/3d.js +6 -6
  14. package/dist/{audio-player-BrEHwbK1.d.ts → audio-player-BNUHo0JF.d.ts} +1 -1
  15. package/dist/{behavior-CmMuQ6CL.d.ts → behavior-ZnfzKDo3.d.ts} +58 -1
  16. package/dist/{create-game-DIyh5Zy9.js → create-game-DMetd3vR.js} +14 -8
  17. package/dist/{create-game-W2xzTJhs.js → create-game-FAXdHta3.js} +138 -11
  18. package/dist/debug.d.ts +1 -1
  19. package/dist/debug.js +3 -2
  20. package/dist/{duplicate-CqSAtdrh.js → duplicate-B8QnX2-r.js} +1 -1
  21. package/dist/editor.js +7 -2
  22. package/dist/{environment-presets-BvHs6HEk.js → environment-presets-C-5g2pMh.js} +113 -9
  23. package/dist/{gameplay-D-H986A4.js → gameplay-CGSv-9XR.js} +295 -12
  24. package/dist/gameplay.d.ts +102 -6
  25. package/dist/gameplay.js +2 -2
  26. package/dist/index.d.ts +50 -6
  27. package/dist/index.js +7 -6
  28. package/dist/{loader-CTB441z4.d.ts → loader-DyT30ArB.d.ts} +1 -1
  29. package/dist/{loader-lQDCwNag.js → loader-KZESOZmE.js} +148 -1
  30. package/dist/net.d.ts +2 -2
  31. package/dist/net.js +1 -1
  32. package/dist/{physics-2d-CmnunaR2.js → physics-2d-_B6zj3zU.js} +57 -4
  33. package/dist/{physics-3d-DULB1lD1.js → physics-3d-i1IeAY0F.js} +74 -5
  34. package/dist/{quiet-rapier-BAJ4K94N.js → quiet-rapier-C6fW4zcW.js} +14 -1
  35. package/dist/react.d.ts +1 -1
  36. package/dist/react.js +1 -1
  37. package/dist/{register-CgeR4ckF.js → register-Cs53CY7s.js} +110 -16
  38. package/dist/{register-DO5_Qp0Q.js → register-vIxU3Xbv.js} +38 -4
  39. package/dist/rendering-options-ByAdi8PF.js +184 -0
  40. package/dist/{replay-CEw2xAhG.js → replay-BkpDV51O.js} +4 -3
  41. package/dist/{replay-CThvwkcQ.d.ts → replay-D0GcEHYU.d.ts} +1 -1
  42. package/dist/{split-screen-DxZzC1nm.d.ts → split-screen-DD5eYIgW.d.ts} +2 -2
  43. package/dist/{split-screen-gb0c3xdb.js → split-screen-DS8RaUV_.js} +2 -2
  44. package/dist/{src-DYffzHbd.js → src-BLlsGJW9.js} +1 -1
  45. package/dist/{teardown-BordHL9o.js → teardown-LB9cTnXJ.js} +2 -2
  46. package/dist/{test-CXZuXs8k.js → test-81uZ967a.js} +127 -24
  47. package/dist/test.d.ts +7 -5
  48. package/dist/test.js +2 -2
  49. package/dist/{touch-DESwnpOc.js → touch-DEAmqGdf.js} +30 -186
  50. package/dist/vite.d.ts +0 -7
  51. package/dist/vite.js +13 -3
  52. package/editor/assets/{agent8-DO5qzIOD.js → agent8-C2jtNKbx.js} +1 -1
  53. package/editor/assets/debug-DoU3KeqS.js +3 -0
  54. package/editor/assets/{index-CqEUVaPN.js → index-CNqHojcC.js} +92 -92
  55. package/editor/index.html +1 -1
  56. package/package.json +1 -1
  57. package/schemas/scene.schema.json +20 -1
  58. package/skills/incanto-building-3d-games.md +19 -0
  59. package/skills/incanto-editor.md +5 -1
  60. package/skills/incanto-environment.md +2 -2
  61. package/skills/incanto-gameplay-behaviors.md +86 -7
  62. package/skills/incanto-hud.md +14 -1
  63. package/skills/incanto-multiplayer.md +14 -0
  64. package/skills/incanto-node-reference.md +60 -2
  65. package/skills/incanto-performance.md +35 -0
  66. package/skills/incanto-physics-and-input.md +69 -0
  67. package/skills/incanto-playtesting.md +15 -1
  68. package/skills/incanto-save-slots.md +21 -1
  69. package/skills/incanto-verifying-your-game.md +19 -0
  70. package/skills/incanto-web-integration.md +24 -3
  71. package/skills/incanto-your-first-game.md +4 -4
  72. package/templates-app/beacon-isle-3d/package.json +1 -1
  73. package/templates-app/beacon-isle-3d/src/game.scene.json +38 -19
  74. package/templates-app/platformer-2d/package.json +1 -1
  75. package/templates-app/platformer-2d/src/game.scene.json +99 -53
  76. package/templates-app/star-survivor/package.json +1 -1
  77. package/templates-app/star-survivor/src/game.scene.json +31 -17
  78. package/templates-app/tps-3d/package.json +1 -1
  79. package/templates-app/tps-3d/src/game.scene.json +14 -8
  80. package/templates-app/village-quest-3d/package.json +1 -1
  81. package/templates-app/village-quest-3d/src/grove.scene.json +20 -11
  82. package/templates-app/village-quest-3d/src/village.scene.json +10 -7
  83. package/editor/assets/debug-DntG9CVD.js +0 -3
  84. package/templates-app/beacon-isle-3d/coverage.json +0 -9
  85. package/templates-app/platformer-2d/coverage.json +0 -5
  86. package/templates-app/star-survivor/coverage.json +0 -5
  87. package/templates-app/tps-3d/coverage.json +0 -5
  88. 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-lQDCwNag.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-KZESOZmE.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-CqSAtdrh.js";
4
+ import { t as duplicateNode } from "./duplicate-B8QnX2-r.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. */
@@ -4711,7 +4758,7 @@ var Clickable = class extends Behavior {
4711
4758
  if (this.hovering) this.setHover(false);
4712
4759
  return;
4713
4760
  }
4714
- const hit = picker(pointer.x, pointer.y);
4761
+ const hit = engine.pickAt(pointer.x, pointer.y);
4715
4762
  const over = hit !== null && this.isSelfOrDescendant(hit) && this.withinRange(hit);
4716
4763
  if (over !== this.hovering) this.setHover(over);
4717
4764
  if (engine.input.mouseJustPressed(this.button)) this.pressedHere = over;
@@ -4781,6 +4828,104 @@ group: { default: "player" } };
4781
4828
  }
4782
4829
  };
4783
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
4784
4929
  //#region src/gameplay/health.ts
4785
4930
  /**
4786
4931
  * Hit points with regeneration and post-hit invulnerability (i-frames) —
@@ -4871,11 +5016,19 @@ var Health = class extends Behavior {
4871
5016
  * unlosable. Every checker was green on it, and `incanto-playtest` even
4872
5017
  * reported `lost in 19/20` because a single `died` reads as a loss.
4873
5018
  *
4874
- * Wire it where the life is spent:
5019
+ * **Wire `reviveFull`, not this, to `lifeLost`.** `ScoreKeeper.loseLife()`
5020
+ * emits `lifeLost` WITH the remaining life count, and this method's first
5021
+ * parameter is the HP to come back at — so the documented recipe revived at
5022
+ * 2 HP, then 1, then 0, and nothing warned (the wire-arity guard fires only
5023
+ * when a handler is UNDER-fed, and `hp` is optional):
4875
5024
  *
4876
- * ```json
4877
- * { "signal": "lifeLost", "from": "Score", "to": "Player", "handler": "revive" }
4878
5025
  * ```
5026
+ * death 1: lives=2 hp=2
5027
+ * death 2: lives=1 hp=1
5028
+ * death 3: lives=0 hp=0 dead=false <- alive at zero HP
5029
+ * ```
5030
+ *
5031
+ * Reach for `revive(hp)` when your game genuinely wants a partial comeback.
4879
5032
  *
4880
5033
  * A no-op on the living — reviving someone who never died would silently
4881
5034
  * refill their health, which is a different feature and not this one.
@@ -4888,6 +5041,21 @@ var Health = class extends Behavior {
4888
5041
  this.invulnTimer = this.invulnerableFor;
4889
5042
  this.emit("revived", this.current);
4890
5043
  }
5044
+ /**
5045
+ * Bring a dead one back at FULL health — the handler a life-lost wire wants.
5046
+ *
5047
+ * It exists because `revive` takes an optional `hp` and `lifeLost` carries the
5048
+ * life count, so wiring the two silently made "you have 2 lives left" mean
5049
+ * "come back at 2 HP". This takes no arguments, so no signal's payload can
5050
+ * ever change what it means.
5051
+ *
5052
+ * ```json
5053
+ * { "signal": "lifeLost", "from": "Score", "to": "Player", "handler": "reviveFull" }
5054
+ * ```
5055
+ */
5056
+ reviveFull() {
5057
+ this.revive();
5058
+ }
4891
5059
  /** Drop to 0 and die immediately (ignores i-frames). */
4892
5060
  kill() {
4893
5061
  if (this.dead) return;
@@ -4971,7 +5139,9 @@ function onTriggerEnter(behavior, fn) {
4971
5139
  * - `repeatEvery` (seconds, 0 = off) re-damages targets that STAY overlapped —
4972
5140
  * lava pools, poison clouds, an enemy standing on you. Contact events fire
4973
5141
  * only on entry/exit; this is the "and it keeps hurting" knob.
4974
- * - `destroySelf` frees the hazard after a hit (single-use projectiles).
5142
+ * - `destroySelf` frees the hazard after a hit (single-use projectiles), and
5143
+ * `freeParent` makes that free the PARENT — a bullet whose hitbox is a child,
5144
+ * which is the only shape a node-carries-one-script engine allows.
4975
5145
  *
4976
5146
  * SCORING PATTERN (clone-safe): wire the KILLER's `dealtDamage` →
4977
5147
  * `ScoreKeeper.addScore`. The weapon is usually a non-cloned node (it lives on
@@ -4989,7 +5159,8 @@ var DamageOnContact = class extends Behavior {
4989
5159
  /** Re-damage targets still overlapping every N seconds (0 = entry only). */
4990
5160
  repeatEvery: { default: 0 },
4991
5161
  /** queueFree() this node after the first successful hit. */
4992
- destroySelf: { default: false }
5162
+ destroySelf: { default: false },
5163
+ ...FREE_PARENT_PROP
4993
5164
  };
4994
5165
  static signals = ["dealtDamage"];
4995
5166
  amount = 10;
@@ -4997,6 +5168,7 @@ var DamageOnContact = class extends Behavior {
4997
5168
  oncePerTarget = true;
4998
5169
  repeatEvery = 0;
4999
5170
  destroySelf = false;
5171
+ freeParent = false;
5000
5172
  hit = /* @__PURE__ */ new WeakSet();
5001
5173
  /** Targets currently overlapping → seconds until their next repeat tick. */
5002
5174
  inside = /* @__PURE__ */ new Map();
@@ -5032,7 +5204,7 @@ var DamageOnContact = class extends Behavior {
5032
5204
  if (this.oncePerTarget) this.hit.add(other);
5033
5205
  found.health.damage(this.amount);
5034
5206
  this.emit("dealtDamage", this.amount, found.owner);
5035
- if (this.destroySelf) this.node.queueFree();
5207
+ if (this.destroySelf) freeOwner(this, this.freeParent, "DamageOnContact");
5036
5208
  }
5037
5209
  };
5038
5210
  /**
@@ -5146,6 +5318,109 @@ function phaseOf(hour) {
5146
5318
  return "dusk";
5147
5319
  }
5148
5320
  //#endregion
5321
+ //#region src/gameplay/face-target.ts
5322
+ /**
5323
+ * Turn toward the nearest thing in a group, and say when you are on it.
5324
+ *
5325
+ * A turret is the shape every tower defense, sentry gun, security camera and
5326
+ * idle-NPC-that-watches-you needs, and it could not be composed. `Chase`
5327
+ * already finds the nearest target and turns toward it — and then MOVES, which
5328
+ * is the one thing a turret must not do; `Patrol`/`Chase`'s `facePath` only
5329
+ * turns as a side effect of locomotion, so a stationary node cannot use it.
5330
+ * A tower defense built on 0.65.0 hand-wrote acquisition (~20 lines) and
5331
+ * turn-and-gate-the-shot (~15) because of that.
5332
+ *
5333
+ * `aimed` fires when the facing settles onto the target and `lostAim` when it
5334
+ * comes off, so "only fire while actually pointed at it" is a wire rather than
5335
+ * an angle comparison you write yourself.
5336
+ */
5337
+ var FaceTarget = class extends Behavior {
5338
+ static props = {
5339
+ /** Group to acquire from — the nearest live member wins. */
5340
+ targetGroup: {
5341
+ default: "",
5342
+ required: true
5343
+ },
5344
+ /** Only acquire within this distance (0 = anywhere). */
5345
+ range: { default: 0 },
5346
+ /** Node to TURN (usually a skin); empty turns this behavior's own node. */
5347
+ facePath: {
5348
+ default: "",
5349
+ nodePath: true
5350
+ },
5351
+ /** Turn rate rad/s; 100 is an instant snap, as on the controller. */
5352
+ turnSpeed: { default: 6 },
5353
+ /** Degrees of slop that still counts as pointed at it. */
5354
+ aimTolerance: { default: 6 }
5355
+ };
5356
+ static signals = [
5357
+ "acquired",
5358
+ "lostTarget",
5359
+ "aimed",
5360
+ "lostAim"
5361
+ ];
5362
+ targetGroup = "";
5363
+ range = 0;
5364
+ facePath = "";
5365
+ turnSpeed = 6;
5366
+ aimTolerance = 6;
5367
+ /** The node currently being tracked, or null. */
5368
+ target = null;
5369
+ /** True while the facing is within `aimTolerance` of the target. */
5370
+ onTarget = false;
5371
+ onReady() {
5372
+ 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" });
5373
+ }
5374
+ update(dt) {
5375
+ const self = this.node;
5376
+ if (!hasPosition$1(self)) return;
5377
+ const found = this.nearest();
5378
+ if (found !== this.target) {
5379
+ this.target = found;
5380
+ this.emit(found ? "acquired" : "lostTarget", found);
5381
+ if (!found) this.setAim(false);
5382
+ }
5383
+ if (!found) return;
5384
+ const turret = this.facePath === "" ? self : this.node.getNodeOrNull(this.facePath);
5385
+ if (!turret) return;
5386
+ const dir = found.position.map((v, i) => v - (self.position[i] ?? 0));
5387
+ faceTravel(turret, dir, dt, this.turnSpeed);
5388
+ this.setAim(this.pointedAt(turret, dir));
5389
+ }
5390
+ nearest() {
5391
+ const self = this.node;
5392
+ let best = null;
5393
+ let bestD = Number.POSITIVE_INFINITY;
5394
+ for (const candidate of this.node.tree?.getNodesInGroup(this.targetGroup) ?? []) {
5395
+ if (!hasPosition$1(candidate)) continue;
5396
+ const d = Math.hypot(...candidate.position.map((v, i) => v - (self.position[i] ?? 0)));
5397
+ if (this.range > 0 && d > this.range) continue;
5398
+ if (d < bestD) {
5399
+ bestD = d;
5400
+ best = candidate;
5401
+ }
5402
+ }
5403
+ return best;
5404
+ }
5405
+ /** Is the turret's forward within tolerance of the direction to the target? */
5406
+ pointedAt(turret, dir) {
5407
+ const rot = turret.rotation;
5408
+ const is3d = Array.isArray(rot);
5409
+ const [a, b] = is3d ? [dir[0] ?? 0, dir[2] ?? 0] : [dir[0] ?? 0, dir[1] ?? 0];
5410
+ const len = Math.hypot(a, b);
5411
+ if (len < 1e-6) return true;
5412
+ const yaw = (is3d ? rot[1] ?? 0 : rot ?? 0) * Math.PI / 180;
5413
+ const fx = is3d ? Math.sin(yaw) : Math.cos(yaw);
5414
+ const fy = is3d ? Math.cos(yaw) : Math.sin(yaw);
5415
+ return (fx * a + fy * b) / len >= Math.cos(this.aimTolerance * Math.PI / 180);
5416
+ }
5417
+ setAim(on) {
5418
+ if (on === this.onTarget) return;
5419
+ this.onTarget = on;
5420
+ this.emit(on ? "aimed" : "lostAim", this.target);
5421
+ }
5422
+ };
5423
+ //#endregion
5149
5424
  //#region src/gameplay/float-away.ts
5150
5425
  /**
5151
5426
  * Rise, fade, and be gone — the second half of a damage number.
@@ -5780,6 +6055,10 @@ const hitStops = /* @__PURE__ */ new Map();
5780
6055
  * pickups that vanish. Accumulates `dt`; on elapse emits `expired` then
5781
6056
  * `queueFree()`s its node.
5782
6057
  *
6058
+ * `freeParent: true` frees the node's PARENT instead — the shape a bullet needs,
6059
+ * since a node carries ONE script so `Projectile`, the damage and the timer have
6060
+ * to live on separate nodes.
6061
+ *
5783
6062
  * With `startOnSignal: true` the countdown is armed manually via `startTimer()`
5784
6063
  * (wire a signal → `startTimer`), so the lifetime begins on an event rather
5785
6064
  * than at spawn.
@@ -5789,11 +6068,13 @@ var Lifetime = class extends Behavior {
5789
6068
  /** Seconds before the node frees itself. */
5790
6069
  seconds: { default: 1 },
5791
6070
  /** Defer the countdown until `startTimer()` is called (default: start at ready). */
5792
- startOnSignal: { default: false }
6071
+ startOnSignal: { default: false },
6072
+ ...FREE_PARENT_PROP
5793
6073
  };
5794
6074
  static signals = ["expired"];
5795
6075
  seconds = 1;
5796
6076
  startOnSignal = false;
6077
+ freeParent = false;
5797
6078
  elapsed = 0;
5798
6079
  running = false;
5799
6080
  fired = false;
@@ -5814,7 +6095,7 @@ var Lifetime = class extends Behavior {
5814
6095
  this.fired = true;
5815
6096
  this.running = false;
5816
6097
  this.emit("expired");
5817
- this.node.queueFree();
6098
+ freeOwner(this, this.freeParent, "Lifetime");
5818
6099
  }
5819
6100
  }
5820
6101
  };
@@ -7102,6 +7383,7 @@ const GAMEPLAY_BEHAVIORS = {
7102
7383
  Health,
7103
7384
  Lifetime,
7104
7385
  ScoreKeeper,
7386
+ Currency,
7105
7387
  Pickup,
7106
7388
  Collector,
7107
7389
  DamageOnContact,
@@ -7111,6 +7393,7 @@ const GAMEPLAY_BEHAVIORS = {
7111
7393
  FollowCamera,
7112
7394
  Patrol,
7113
7395
  Chase,
7396
+ FaceTarget,
7114
7397
  Wander,
7115
7398
  ZombieAI,
7116
7399
  MoveTo,
@@ -7131,4 +7414,4 @@ function registerGameplayBehaviors(opts) {
7131
7414
  for (const [name, ctor] of Object.entries(GAMEPLAY_BEHAVIORS)) registerBehavior(name, ctor, { replace });
7132
7415
  }
7133
7416
  //#endregion
7134
- 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 };
7417
+ 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 };
@@ -1,4 +1,4 @@
1
- import { b as Engine, d as PropSchema, jt as Node, n as BehaviorCtor, t as Behavior } from "./behavior-CmMuQ6CL.js";
1
+ import { b as Engine, d as PropSchema, jt as Node, n as BehaviorCtor, t as Behavior } from "./behavior-ZnfzKDo3.js";
2
2
  import { c as JsonValue, s as JsonObject } from "./schema-CFeioQRE.js";
3
3
 
4
4
  //#region src/gameplay/chase.d.ts
@@ -60,6 +60,37 @@ declare class Collector extends Behavior {
60
60
  override deserialize(data: JsonValue): void;
61
61
  }
62
62
  //#endregion
63
+ //#region src/gameplay/currency.d.ts
64
+ /**
65
+ * Money. Earn it, spend it, and refuse what you cannot afford.
66
+ *
67
+ * `ScoreKeeper` is the closest thing the library had and it is a SCORE: it
68
+ * counts up, it has no spend, no affordability question, and its counter is
69
+ * already wired to the win condition. So a tower defense built on 0.65.0 wrote
70
+ * its own wallet — and so does every shop, every build mode, every upgrade tree
71
+ * and every economy game.
72
+ *
73
+ * `spend` is the whole point: it returns whether it went through, and emits
74
+ * `refused` when it did not, so "you cannot afford that" is a wire rather than
75
+ * a comparison every caller repeats and one caller forgets.
76
+ */
77
+ declare class Currency extends Behavior {
78
+ static readonly props: PropSchema;
79
+ static readonly signals: readonly string[];
80
+ amount: number;
81
+ max: number;
82
+ /** Can this be paid right now? */
83
+ canAfford(cost: number): boolean;
84
+ /** Pay `cost` if it is there. Returns whether it went through. */
85
+ spend(cost: number): boolean;
86
+ /** Take payment in. */
87
+ earn(gain: number): void;
88
+ /** Set it outright (a shop that grants, a cheat, a restore). */
89
+ setAmount(next: number): void;
90
+ override serialize(): JsonValue;
91
+ override deserialize(data: JsonValue): void;
92
+ }
93
+ //#endregion
63
94
  //#region src/gameplay/health.d.ts
64
95
  /**
65
96
  * Hit points with regeneration and post-hit invulnerability (i-frames) —
@@ -112,16 +143,37 @@ declare class Health extends Behavior {
112
143
  * unlosable. Every checker was green on it, and `incanto-playtest` even
113
144
  * reported `lost in 19/20` because a single `died` reads as a loss.
114
145
  *
115
- * Wire it where the life is spent:
146
+ * **Wire `reviveFull`, not this, to `lifeLost`.** `ScoreKeeper.loseLife()`
147
+ * emits `lifeLost` WITH the remaining life count, and this method's first
148
+ * parameter is the HP to come back at — so the documented recipe revived at
149
+ * 2 HP, then 1, then 0, and nothing warned (the wire-arity guard fires only
150
+ * when a handler is UNDER-fed, and `hp` is optional):
116
151
  *
117
- * ```json
118
- * { "signal": "lifeLost", "from": "Score", "to": "Player", "handler": "revive" }
152
+ * ```
153
+ * death 1: lives=2 hp=2
154
+ * death 2: lives=1 hp=1
155
+ * death 3: lives=0 hp=0 dead=false <- alive at zero HP
119
156
  * ```
120
157
  *
158
+ * Reach for `revive(hp)` when your game genuinely wants a partial comeback.
159
+ *
121
160
  * A no-op on the living — reviving someone who never died would silently
122
161
  * refill their health, which is a different feature and not this one.
123
162
  */
124
163
  revive(hp?: number): void;
164
+ /**
165
+ * Bring a dead one back at FULL health — the handler a life-lost wire wants.
166
+ *
167
+ * It exists because `revive` takes an optional `hp` and `lifeLost` carries the
168
+ * life count, so wiring the two silently made "you have 2 lives left" mean
169
+ * "come back at 2 HP". This takes no arguments, so no signal's payload can
170
+ * ever change what it means.
171
+ *
172
+ * ```json
173
+ * { "signal": "lifeLost", "from": "Score", "to": "Player", "handler": "reviveFull" }
174
+ * ```
175
+ */
176
+ reviveFull(): void;
125
177
  /** Drop to 0 and die immediately (ignores i-frames). */
126
178
  kill(): void;
127
179
  /**
@@ -162,7 +214,9 @@ declare class Health extends Behavior {
162
214
  * - `repeatEvery` (seconds, 0 = off) re-damages targets that STAY overlapped —
163
215
  * lava pools, poison clouds, an enemy standing on you. Contact events fire
164
216
  * only on entry/exit; this is the "and it keeps hurting" knob.
165
- * - `destroySelf` frees the hazard after a hit (single-use projectiles).
217
+ * - `destroySelf` frees the hazard after a hit (single-use projectiles), and
218
+ * `freeParent` makes that free the PARENT — a bullet whose hitbox is a child,
219
+ * which is the only shape a node-carries-one-script engine allows.
166
220
  *
167
221
  * SCORING PATTERN (clone-safe): wire the KILLER's `dealtDamage` →
168
222
  * `ScoreKeeper.addScore`. The weapon is usually a non-cloned node (it lives on
@@ -177,6 +231,7 @@ declare class DamageOnContact extends Behavior {
177
231
  oncePerTarget: boolean;
178
232
  repeatEvery: number;
179
233
  destroySelf: boolean;
234
+ freeParent: boolean;
180
235
  private readonly hit;
181
236
  /** Targets currently overlapping → seconds until their next repeat tick. */
182
237
  private readonly inside;
@@ -185,6 +240,42 @@ declare class DamageOnContact extends Behavior {
185
240
  private tryDamage;
186
241
  }
187
242
  //#endregion
243
+ //#region src/gameplay/face-target.d.ts
244
+ /**
245
+ * Turn toward the nearest thing in a group, and say when you are on it.
246
+ *
247
+ * A turret is the shape every tower defense, sentry gun, security camera and
248
+ * idle-NPC-that-watches-you needs, and it could not be composed. `Chase`
249
+ * already finds the nearest target and turns toward it — and then MOVES, which
250
+ * is the one thing a turret must not do; `Patrol`/`Chase`'s `facePath` only
251
+ * turns as a side effect of locomotion, so a stationary node cannot use it.
252
+ * A tower defense built on 0.65.0 hand-wrote acquisition (~20 lines) and
253
+ * turn-and-gate-the-shot (~15) because of that.
254
+ *
255
+ * `aimed` fires when the facing settles onto the target and `lostAim` when it
256
+ * comes off, so "only fire while actually pointed at it" is a wire rather than
257
+ * an angle comparison you write yourself.
258
+ */
259
+ declare class FaceTarget extends Behavior {
260
+ static readonly props: PropSchema;
261
+ static readonly signals: readonly string[];
262
+ targetGroup: string;
263
+ range: number;
264
+ facePath: string;
265
+ turnSpeed: number;
266
+ aimTolerance: number;
267
+ /** The node currently being tracked, or null. */
268
+ target: Node | null;
269
+ /** True while the facing is within `aimTolerance` of the target. */
270
+ onTarget: boolean;
271
+ override onReady(): void;
272
+ override update(dt: number): void;
273
+ private nearest;
274
+ /** Is the turret's forward within tolerance of the direction to the target? */
275
+ private pointedAt;
276
+ private setAim;
277
+ }
278
+ //#endregion
188
279
  //#region src/gameplay/follow-camera.d.ts
189
280
  /**
190
281
  * Make the node it sits on chase a target's position — THE camera-follow
@@ -239,6 +330,10 @@ declare class Interactable extends Behavior {
239
330
  * pickups that vanish. Accumulates `dt`; on elapse emits `expired` then
240
331
  * `queueFree()`s its node.
241
332
  *
333
+ * `freeParent: true` frees the node's PARENT instead — the shape a bullet needs,
334
+ * since a node carries ONE script so `Projectile`, the damage and the timer have
335
+ * to live on separate nodes.
336
+ *
242
337
  * With `startOnSignal: true` the countdown is armed manually via `startTimer()`
243
338
  * (wire a signal → `startTimer`), so the lifetime begins on an event rather
244
339
  * than at spawn.
@@ -248,6 +343,7 @@ declare class Lifetime extends Behavior {
248
343
  static readonly signals: readonly string[];
249
344
  seconds: number;
250
345
  startOnSignal: boolean;
346
+ freeParent: boolean;
251
347
  private elapsed;
252
348
  private running;
253
349
  private fired;
@@ -987,4 +1083,4 @@ declare function registerGameplayBehaviors(opts?: {
987
1083
  replace?: boolean;
988
1084
  }): void;
989
1085
  //#endregion
990
- export { CameraShake, Chase, Collector, Cooldown, DamageOnContact, DayNight, type DayPhase, FloatAway, FollowCamera, GAMEPLAY_BEHAVIORS, GameFlow, type GameFlowState, Health, Interactable, Lifetime, MoveTo, Oscillate, PathFollow, Patrol, Pickup, Projectile, SavePoint, ScoreKeeper, Spawner, Wander, WaveSpawner, ZombieAI, goToScene, hitStop, phaseOf, registerGameplayBehaviors, restartScene, screenFlash };
1086
+ export { CameraShake, Chase, Collector, Cooldown, Currency, DamageOnContact, DayNight, type DayPhase, FaceTarget, FloatAway, FollowCamera, GAMEPLAY_BEHAVIORS, GameFlow, type GameFlowState, Health, Interactable, Lifetime, MoveTo, Oscillate, PathFollow, Patrol, Pickup, Projectile, SavePoint, ScoreKeeper, Spawner, Wander, WaveSpawner, ZombieAI, goToScene, hitStop, phaseOf, registerGameplayBehaviors, restartScene, screenFlash };
package/dist/gameplay.js CHANGED
@@ -1,2 +1,2 @@
1
- import { A as Health, C as restartScene, D as DayNight, E as FloatAway, M as Chase, O as phaseOf, S as goToScene, T as FollowCamera, _ as Cooldown, a as Wander, b as Interactable, c as SavePoint, d as Patrol, f as PathFollow, g as CameraShake, h as Lifetime, i as WaveSpawner, j as Collector, k as DamageOnContact, l as Projectile, m as MoveTo, n as registerGameplayBehaviors, o as Spawner, p as Oscillate, r as ZombieAI, s as ScoreKeeper, t as GAMEPLAY_BEHAVIORS, u as Pickup, v as hitStop, x as GameFlow, y as screenFlash } from "./gameplay-D-H986A4.js";
2
- export { CameraShake, Chase, Collector, Cooldown, DamageOnContact, DayNight, FloatAway, FollowCamera, GAMEPLAY_BEHAVIORS, GameFlow, Health, Interactable, Lifetime, MoveTo, Oscillate, PathFollow, Patrol, Pickup, Projectile, SavePoint, ScoreKeeper, Spawner, Wander, WaveSpawner, ZombieAI, goToScene, hitStop, phaseOf, registerGameplayBehaviors, restartScene, screenFlash };
1
+ import { A as DamageOnContact, C as restartScene, D as FaceTarget, E as FloatAway, M as Currency, N as Collector, O as DayNight, P as Chase, S as goToScene, T as FollowCamera, _ as Cooldown, a as Wander, b as Interactable, c as SavePoint, d as Patrol, f as PathFollow, g as CameraShake, h as Lifetime, i as WaveSpawner, j as Health, k as phaseOf, l as Projectile, m as MoveTo, n as registerGameplayBehaviors, o as Spawner, p as Oscillate, r as ZombieAI, s as ScoreKeeper, t as GAMEPLAY_BEHAVIORS, u as Pickup, v as hitStop, x as GameFlow, y as screenFlash } from "./gameplay-CGSv-9XR.js";
2
+ export { CameraShake, Chase, Collector, Cooldown, Currency, DamageOnContact, DayNight, FaceTarget, FloatAway, FollowCamera, GAMEPLAY_BEHAVIORS, GameFlow, Health, Interactable, Lifetime, MoveTo, Oscillate, PathFollow, Patrol, Pickup, Projectile, SavePoint, ScoreKeeper, Spawner, Wander, WaveSpawner, ZombieAI, goToScene, hitStop, phaseOf, registerGameplayBehaviors, restartScene, screenFlash };