incanto 0.74.0 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/bin/incanto-multiplay.mjs +52 -4
  2. package/dist/2d.d.ts +83 -7
  3. package/dist/2d.js +4 -3
  4. package/dist/3d.d.ts +861 -167
  5. package/dist/3d.js +6 -6
  6. package/dist/{agent8-CHTT4unP.js → agent8-B-bh3n0X.js} +1 -1
  7. package/dist/{audio-player-BOrk4eQx.d.ts → audio-player-BRo2uvG6.d.ts} +23 -2
  8. package/dist/{behavior-J1cmVah0.d.ts → behavior-B_245qRy.d.ts} +15 -1
  9. package/dist/{create-game-ssvGvP7W.js → create-game-BMIPKrQW.js} +84 -8
  10. package/dist/{create-game-BAiA-FjP.js → create-game-Dai5vfVu.js} +80 -11
  11. package/dist/debug.d.ts +1 -1
  12. package/dist/{editor-switch-CFU9mCec.d.ts → editor-switch-Bzt0GzVp.d.ts} +13 -3
  13. package/dist/editor.js +1600 -1529
  14. package/dist/env.d.ts +1 -1
  15. package/dist/{environment-presets-BkqlWewf.js → environment-presets-e_9YCnGu.js} +2361 -104
  16. package/dist/{gameplay-Bxe1sMVT.js → gameplay-Bfff_beb.js} +10919 -6798
  17. package/dist/gameplay.d.ts +573 -31
  18. package/dist/gameplay.js +3 -2
  19. package/dist/index.d.ts +150 -6
  20. package/dist/index.js +6 -6
  21. package/dist/{loader-BAeWMYfF.d.ts → loader-CbkVdXL8.d.ts} +2 -2
  22. package/dist/net.d.ts +2 -2
  23. package/dist/net.js +2 -2
  24. package/dist/{physics-2d-C-SfvjzH.js → physics-2d-Tuf5LAyq.js} +250 -31
  25. package/dist/{physics-3d-rOzeg890.js → physics-3d-C5MOujfQ.js} +673 -51
  26. package/dist/{picking-BMV34Pjl.js → picking-DIwE48sU.js} +10 -7
  27. package/dist/{save-slots-CEUGeLuB.js → pose-ByFB_J3O.js} +217 -11
  28. package/dist/react.d.ts +2 -2
  29. package/dist/react.js +1 -1
  30. package/dist/{register-DRQyZKGU.js → register-7E3P1gz6.js} +191 -430
  31. package/dist/{register-CjociOtt.js → register-B768Qedj.js} +596 -2
  32. package/dist/{replay-B6BbgGM_.js → replay-DrqQ6dZj.js} +133 -18
  33. package/dist/{replay-wZK5Ok5c.d.ts → replay-W5nCw_cU.d.ts} +21 -2
  34. package/dist/{rng-Bb-IutXB.d.ts → rng-BsXZg3D6.d.ts} +7 -0
  35. package/dist/{split-screen-BxZ5rBe7.js → split-screen-B-dIcQsQ.js} +10 -5
  36. package/dist/{split-screen-D6sx8z3Z.d.ts → split-screen-CiLJf2-C.d.ts} +3 -3
  37. package/dist/{sprite-animation-Bz3DHD_4.js → sprite-animation-SQa5gIu2.js} +8 -6
  38. package/dist/{src-CqtvYtSN.js → src-D823V07k.js} +1 -1
  39. package/dist/{test-CEl0y2Sw.js → test-CVbxnXlv.js} +487 -82
  40. package/dist/test.d.ts +90 -12
  41. package/dist/test.js +2 -2
  42. package/dist/vite.js +34 -5
  43. package/editor/assets/{agent8-BlzturGi.js → agent8-DA-I5qhv.js} +1 -1
  44. package/editor/assets/{debug-BMUi8usj.js → debug-99tEPoGm.js} +1 -1
  45. package/editor/assets/index-Db74LZY-.js +11579 -0
  46. package/editor/index.html +1 -1
  47. package/package.json +1 -1
  48. package/schemas/scene.schema.json +1639 -280
  49. package/skills/incanto-3d-character.md +383 -20
  50. package/skills/incanto-audio.md +24 -2
  51. package/skills/incanto-building-2d-games.md +21 -2
  52. package/skills/incanto-building-3d-games.md +38 -3
  53. package/skills/incanto-editor.md +29 -8
  54. package/skills/incanto-environment.md +257 -14
  55. package/skills/incanto-game-feel.md +60 -1
  56. package/skills/incanto-gameplay-behaviors.md +668 -20
  57. package/skills/incanto-hud.md +116 -0
  58. package/skills/incanto-multiplayer.md +141 -2
  59. package/skills/incanto-node-reference.md +398 -16
  60. package/skills/incanto-performance.md +32 -0
  61. package/skills/incanto-physics-and-input.md +241 -13
  62. package/skills/incanto-playtesting.md +2 -2
  63. package/skills/incanto-save-slots.md +61 -2
  64. package/skills/incanto-scene-json-authoring.md +13 -0
  65. package/skills/incanto-verifying-your-game.md +86 -3
  66. package/templates-app/beacon-isle-3d/PROJECT/Status.md +12 -0
  67. package/templates-app/beacon-isle-3d/package.json +1 -1
  68. package/templates-app/beacon-isle-3d/src/game.scene.json +157 -3
  69. package/templates-app/molehill-2d/PROJECT/Status.md +7 -0
  70. package/templates-app/molehill-2d/package.json +1 -1
  71. package/templates-app/molehill-2d/src/game.scene.json +129 -2
  72. package/templates-app/platformer-2d/PROJECT/Status.md +7 -0
  73. package/templates-app/platformer-2d/docs/project-2d-rules.md +3 -2
  74. package/templates-app/platformer-2d/package.json +1 -1
  75. package/templates-app/platformer-2d/src/behaviors.ts +40 -26
  76. package/templates-app/platformer-2d/src/game.scene.json +144 -2
  77. package/templates-app/platformer-2d/verify.ts +18 -1
  78. package/templates-app/star-survivor/PROJECT/Status.md +7 -0
  79. package/templates-app/star-survivor/package.json +1 -1
  80. package/templates-app/star-survivor/src/behaviors.ts +9 -8
  81. package/templates-app/star-survivor/src/game.scene.json +142 -4
  82. package/templates-app/star-survivor/verify.ts +13 -0
  83. package/templates-app/tps-3d/PROJECT/Status.md +6 -0
  84. package/templates-app/tps-3d/package.json +1 -1
  85. package/templates-app/tps-3d/src/game.scene.json +71 -10
  86. package/templates-app/village-quest-3d/PROJECT/Status.md +7 -0
  87. package/templates-app/village-quest-3d/package.json +1 -1
  88. package/templates-app/village-quest-3d/src/behaviors.ts +8 -16
  89. package/templates-app/village-quest-3d/src/grove.scene.json +121 -5
  90. package/templates-app/village-quest-3d/src/village.scene.json +126 -2
  91. package/templates-app/village-quest-3d/verify.ts +52 -2
  92. package/editor/assets/index-CrUCQoaB.js +0 -11046
@@ -1,14 +1,14 @@
1
- import { Y as diagnose } from "./save-slots-CEUGeLuB.js";
2
- import { M as translateOn, t as registerCoreNodes } from "./register-CjociOtt.js";
1
+ import { et as diagnose, h as rebuildOptions, i as worldPosition, v as buildNodeJson } from "./pose-ByFB_J3O.js";
2
+ import { P as translateOn, t as registerCoreNodes } from "./register-B768Qedj.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { t as Rng } from "./rng-CDOMybym.js";
5
5
  import { c as getNodeSchema, h as didYouMean, p as registerNode } from "./touch-CioTZB-y.js";
6
6
  import { t as createNoise2D } from "./noise-D3nPpmFg.js";
7
- import { i as ParticleSim, n as resolveFrames, o as PARTICLE_PRESET_NAMES, r as validateAnimations, s as applyParticlePreset, t as resolveAnimation } from "./sprite-animation-Bz3DHD_4.js";
8
- import { E as tolerateUnknownAction, G as PhysicsBody3D, H as rejectScale, J as Node3D, K as RigidBody3D, O as effectRng, Q as colliderFootDrop, U as Area3D, V as WaterCutout3D, W as CharacterBody3D, et as worldPosition, q as StaticBody3D, z as Water3D } from "./gameplay-Bxe1sMVT.js";
7
+ import { i as ParticleSim, n as resolveFrames, o as PARTICLE_PRESET_NAMES, r as validateAnimations, s as applyParticlePreset, t as resolveAnimation } from "./sprite-animation-SQa5gIu2.js";
8
+ import { $ as Water3D, Q as tolerateUnknownAction, V as effectRng, at as PhysicsBody3D, ct as Node3D, ft as colliderFootDrop, ht as buildSeaSpectrum, it as CharacterBody3D, mt as seaFftSupported, nt as rejectScale, ot as RigidBody3D, pt as SeaFft, rt as Area3D, st as StaticBody3D, tt as WaterCutout3D, vt as findHealth } from "./gameplay-Bfff_beb.js";
9
9
  import { t as checkSheetGrid } from "./sheet-grid-Cea343VO.js";
10
10
  import { n as splatWeights, t as buildHeightmap } from "./heightmap-BYgD5Edk.js";
11
- import { AdditiveBlending, AnimationClip, AnimationMixer, Box3, BoxGeometry, BufferAttribute, BufferGeometry, CanvasTexture, CapsuleGeometry, ClampToEdgeWrapping, Color, ConeGeometry, CylinderGeometry, DataTexture, DirectionalLight, DoubleSide, DynamicDrawUsage, Euler, Group, IcosahedronGeometry, ImageBitmapLoader, InstancedBufferAttribute, InstancedMesh, LinearFilter, LinearMipmapLinearFilter, LoopOnce, LoopRepeat, MathUtils, Matrix4, Mesh, MeshBasicMaterial, MeshDepthMaterial, MeshPhysicalMaterial, MeshStandardMaterial, NearestFilter, NoBlending, NoColorSpace, NormalBlending, PerspectiveCamera, PlaneGeometry, PointLight, Points, PointsMaterial, Quaternion, QuaternionKeyframeTrack, RGBADepthPacking, RGBAFormat, RepeatWrapping, SRGBColorSpace, ShaderChunk, ShaderMaterial, SphereGeometry, SpotLight, Texture, TextureLoader, UniformsLib, UniformsUtils, Vector3, Vector4, VectorKeyframeTrack } from "three";
11
+ import { AdditiveBlending, AnimationClip, AnimationMixer, Box3, BoxGeometry, BufferAttribute, BufferGeometry, CanvasTexture, CapsuleGeometry, ClampToEdgeWrapping, Color, ConeGeometry, CubeCamera, CylinderGeometry, DataTexture, DirectionalLight, DoubleSide, DynamicDrawUsage, Euler, Group, HalfFloatType, IcosahedronGeometry, ImageBitmapLoader, InstancedBufferAttribute, InstancedMesh, LinearFilter, LinearMipmapLinearFilter, LoopOnce, LoopRepeat, MathUtils, Matrix4, Mesh, MeshBasicMaterial, MeshDepthMaterial, MeshPhysicalMaterial, MeshStandardMaterial, NearestFilter, NoBlending, NoColorSpace, NormalBlending, PerspectiveCamera, PlaneGeometry, PointLight, Points, PointsMaterial, Quaternion, QuaternionKeyframeTrack, RGBADepthPacking, RGBAFormat, RepeatWrapping, SRGBColorSpace, ShaderChunk, ShaderMaterial, SphereGeometry, SpotLight, Texture, TextureLoader, UniformsLib, UniformsUtils, Vector3, Vector4, VectorKeyframeTrack, WebGLCubeRenderTarget } from "three";
12
12
  import { clone } from "three/addons/utils/SkeletonUtils.js";
13
13
  //#region src/3d/textures.ts
14
14
  /**
@@ -752,9 +752,14 @@ const matScratch = new Matrix4();
752
752
  * "props": { "type": "spherical", "target": "%Anchor", "anchor": [0, -20] } }
753
753
  *
754
754
  * Types: `fixed` welds the bodies rigidly · `spherical` is a ball joint at the
755
- * anchors · `rope` caps the anchor distance at `length` (m; 0 = measured at
756
- * creation) · `spring` pulls toward `length` with `stiffness`/`damping`.
757
- * Anchors are LOCAL meter offsets on each body.
755
+ * anchors · `revolute` is a HINGE about `axis` (a door, a wheel, a lever) with
756
+ * optional `limits` (degrees) and a `motorSpeed` (degrees a second) ·
757
+ * `prismatic` is a SLIDE along `axis` (a portcullis, an elevator, a piston)
758
+ * with `limits` in metres, a `motorSpeed` in metres a second and `travel`
759
+ * read back · `rope`
760
+ * caps the anchor distance at `length` (m; 0 = measured at creation) ·
761
+ * `spring` pulls toward `length` with `stiffness`/`damping`. Anchors are LOCAL
762
+ * meter offsets on each body; `angle` reads the hinge back.
758
763
  */
759
764
  var Joint3D = class extends Node3D {
760
765
  static typeName = "Joint3D";
@@ -764,6 +769,8 @@ var Joint3D = class extends Node3D {
764
769
  options: [
765
770
  "fixed",
766
771
  "spherical",
772
+ "revolute",
773
+ "prismatic",
767
774
  "rope",
768
775
  "spring"
769
776
  ]
@@ -783,6 +790,21 @@ var Joint3D = class extends Node3D {
783
790
  0,
784
791
  0
785
792
  ] },
793
+ /** The hinge axis of a `revolute`, or the line of a `prismatic`, in this body's local frame. */
794
+ axis: { default: [
795
+ 0,
796
+ 1,
797
+ 0
798
+ ] },
799
+ /** The stops: `[minDeg, maxDeg]` for a `revolute`, `[min, max]` metres for a `prismatic`; empty = free. */
800
+ limits: { default: [] },
801
+ /** The motor's target speed — degrees a second (`revolute`) or metres a second (`prismatic`); 0 = no motor. */
802
+ motorSpeed: { default: 0 },
803
+ /** How hard the motor holds its speed (acceleration per rad/s of error). */
804
+ motorStrength: {
805
+ default: 20,
806
+ range: { min: 0 }
807
+ },
786
808
  length: { default: 0 },
787
809
  stiffness: { default: 50 },
788
810
  damping: { default: 5 },
@@ -792,6 +814,33 @@ var Joint3D = class extends Node3D {
792
814
  }
793
815
  };
794
816
  type = "spherical";
817
+ /** Hinge axis of a `revolute`, or the line of a `prismatic` (local to this body). */
818
+ axis = [
819
+ 0,
820
+ 1,
821
+ 0
822
+ ];
823
+ /**
824
+ * `[min, max]` in degrees for a `revolute` swing; `[]` = no stops. Measured
825
+ * as `angle` is: this body turned about `axis`, relative to the target.
826
+ */
827
+ limits = [];
828
+ /** Degrees a second the motor drives the hinge at; `0` = no motor (free). */
829
+ motorSpeed = 0;
830
+ /** The motor's gain — how quickly it reaches and holds `motorSpeed`. */
831
+ motorStrength = 20;
832
+ /**
833
+ * The hinge's current angle in degrees — this body's rotation about `axis`
834
+ * relative to the target, written by physics every step. `0` when the joint
835
+ * is not a `revolute` or is not simulated yet.
836
+ */
837
+ angle = 0;
838
+ /**
839
+ * A `prismatic` slide's current travel in metres — this body's anchor along
840
+ * `axis` relative to the target's, written by physics every step. `0` where
841
+ * the two anchors coincide, which is where the scene loaded it.
842
+ */
843
+ travel = 0;
795
844
  target = "";
796
845
  anchor = [
797
846
  0,
@@ -835,6 +884,11 @@ var Joint3D = class extends Node3D {
835
884
  onEnterTree() {
836
885
  if (!(this.parent instanceof PhysicsBody3D)) throw new IncantoError("TREE_VIOLATION", `Joint3D '${this.name}' must be a child of a physics body (its body A); parent is '${this.parent?.name ?? "none"}'.`);
837
886
  if (this.target === "") throw new IncantoError("BAD_FORMAT", `Joint3D '${this.name}' needs a "target" node path to the body it links to.`);
887
+ if (this.type === "revolute" || this.type === "prismatic") {
888
+ const [x, y, z] = this.axis;
889
+ if (!(Math.hypot(x ?? 0, y ?? 0, z ?? 0) > 1e-6)) throw new IncantoError("BAD_FORMAT", `Joint3D '${this.name}': a ${this.type} joint needs a non-zero "axis" (got ${JSON.stringify(this.axis)}).`);
890
+ if (this.limits.length !== 0 && this.limits.length !== 2) throw new IncantoError("BAD_FORMAT", `Joint3D '${this.name}': "limits" is [minDeg, maxDeg] or [] (got ${JSON.stringify(this.limits)}).`);
891
+ }
838
892
  }
839
893
  /** @internal The two bodies, resolved (throws on a bad target at step time). */
840
894
  _resolveBodies() {
@@ -852,6 +906,25 @@ var Joint3D = class extends Node3D {
852
906
  }
853
907
  };
854
908
  //#endregion
909
+ //#region src/3d/terrain/course.ts
910
+ /** Is this a point either spelling accepts? */
911
+ function isCoursePoint(p) {
912
+ if (!Array.isArray(p)) return false;
913
+ if (p.length === 2) return Number.isFinite(p[0]) && Number.isFinite(p[1]);
914
+ if (p.length === 3) return Number.isFinite(p[0]) && Number.isFinite(p[1]) && Number.isFinite(p[2]);
915
+ return false;
916
+ }
917
+ /** The `[x, z]` of a point in either spelling. Assumes `isCoursePoint`. */
918
+ function coursePoint(p) {
919
+ return p.length === 3 ? [p[0] ?? 0, p[2] ?? 0] : [p[0] ?? 0, p[1] ?? 0];
920
+ }
921
+ /** A whole course, flattened to `[x, z]` whichever way it was written. */
922
+ function course(path) {
923
+ return path.map((p) => coursePoint(p));
924
+ }
925
+ /** What to tell an author who wrote neither spelling. */
926
+ const COURSE_SHAPE = "[x, z] — or [x, y, z], whose y is ignored";
927
+ //#endregion
855
928
  //#region src/3d/terrain/splat-material.ts
856
929
  /** Splice `code` immediately AFTER `marker` — a missing marker is a hard error
857
930
  * (a silently unpatched terrain shader renders weight-colored vertex soup). */
@@ -1716,7 +1789,7 @@ var Terrain3D = class extends Node3D {
1716
1789
  if (!Array.isArray(t.channels)) throw new IncantoError("BAD_FORMAT", `Terrain3D '${node.name}' channels must be an array of { path, width, depth, taper? }, got ${JSON.stringify(t.channels)}.`, { prop: "channels" });
1717
1790
  t.channels.forEach((c, i) => {
1718
1791
  const path = c.path;
1719
- if (!(c && typeof c === "object" && Array.isArray(path) && path.length >= 2 && path.every((p) => Array.isArray(p) && Number.isFinite(p[0]) && Number.isFinite(p[1])) && c.width > 0 && c.depth > 0 && (c.taper === void 0 || c.taper > 0))) throw new IncantoError("BAD_FORMAT", `Terrain3D '${node.name}' channels[${i}] needs a path of 2+ [x, z] points plus positive width and depth, got ${JSON.stringify(c)}.`, { prop: "channels" });
1792
+ if (!(c && typeof c === "object" && Array.isArray(path) && path.length >= 2 && path.every(isCoursePoint) && c.width > 0 && c.depth > 0 && (c.taper === void 0 || c.taper > 0))) throw new IncantoError("BAD_FORMAT", `Terrain3D '${node.name}' channels[${i}] needs a path of 2+ ${COURSE_SHAPE} points plus positive width and depth, got ${JSON.stringify(c)}.`, { prop: "channels" });
1720
1793
  });
1721
1794
  if (t.theme === "custom") {
1722
1795
  if (!Array.isArray(t.layers) || t.layers.length < 1 || t.layers.length > 4) throw new IncantoError("BAD_FORMAT", `Terrain3D '${node.name}' theme 'custom' needs 1–4 layers, got ${Array.isArray(t.layers) ? t.layers.length : typeof t.layers}.`, { prop: "layers" });
@@ -1759,10 +1832,17 @@ var Terrain3D = class extends Node3D {
1759
1832
  else this.externalChannels.set(owner, specs);
1760
1833
  this.externalRev++;
1761
1834
  }
1762
- /** Authored trenches plus the ones other nodes cut. */
1835
+ /**
1836
+ * Authored trenches plus the ones other nodes cut — every course flattened to
1837
+ * `[x, z]`, whichever spelling it was written in (see `course`).
1838
+ */
1763
1839
  allChannels() {
1764
- if (this.externalChannels.size === 0) return this.channels;
1765
- return [...this.channels, ...[...this.externalChannels.values()].flat()];
1840
+ const mine = this.channels.map((c) => ({
1841
+ ...c,
1842
+ path: course(c.path)
1843
+ }));
1844
+ if (this.externalChannels.size === 0) return mine;
1845
+ return [...mine, ...[...this.externalChannels.values()].flat()];
1766
1846
  }
1767
1847
  /** @internal The pure heightfield (lazily built — physics pulls this). */
1768
1848
  _heightmap() {
@@ -2613,6 +2693,18 @@ var BoneLookAt3D = class extends Node3D {
2613
2693
  lookedUpFor = "";
2614
2694
  /** 0..1 engage blend — eases in/out so tracking never snaps. */
2615
2695
  engage = 0;
2696
+ /**
2697
+ * How far the head is COMMITTED to the target, 0..1.
2698
+ *
2699
+ * `attachedBone` was a test hook for "did it find the bone"; this is the one
2700
+ * for "is it watching" — the whole observable effect of the node, and it eased
2701
+ * in and out where nothing could read it. A harness asking whether an NPC
2702
+ * tracks the player, and lets go when the player walks behind it, has no other
2703
+ * question to ask.
2704
+ */
2705
+ get engaged() {
2706
+ return this.engage;
2707
+ }
2616
2708
  /** Test hook. */
2617
2709
  get attachedBone() {
2618
2710
  return this.boneObj;
@@ -2679,7 +2771,7 @@ const parentScratch = new Quaternion();
2679
2771
  const quatScratch$1 = new Quaternion();
2680
2772
  //#endregion
2681
2773
  //#region src/3d/nodes/look-at.ts
2682
- const RAD2DEG$1 = 180 / Math.PI;
2774
+ const RAD2DEG$5 = 180 / Math.PI;
2683
2775
  /** World up. The whole fix is that this argument exists. */
2684
2776
  const UP = new Vector3(0, 1, 0);
2685
2777
  const eyeScratch = new Vector3();
@@ -2722,20 +2814,22 @@ function aimAt(node, warned, what) {
2722
2814
  lookScratch.lookAt(eyeScratch, targetScratch, UP);
2723
2815
  eulerScratch$1.setFromRotationMatrix(lookScratch, "XYZ");
2724
2816
  node.rotation = [
2725
- eulerScratch$1.x * RAD2DEG$1,
2726
- eulerScratch$1.y * RAD2DEG$1,
2727
- eulerScratch$1.z * RAD2DEG$1
2817
+ eulerScratch$1.x * RAD2DEG$5,
2818
+ eulerScratch$1.y * RAD2DEG$5,
2819
+ eulerScratch$1.z * RAD2DEG$5
2728
2820
  ];
2729
2821
  return warned;
2730
2822
  }
2731
2823
  //#endregion
2732
2824
  //#region src/3d/nodes/camera-3d.ts
2825
+ const DEG2RAD$1 = Math.PI / 180;
2826
+ const RAD2DEG$4 = 180 / Math.PI;
2733
2827
  /**
2734
2828
  * A perspective camera. Mark exactly one camera `current: true`; with none
2735
2829
  * marked, the renderer falls back to the first camera in tree order.
2736
2830
  * Aspect ratio is managed by the renderer on resize.
2737
2831
  */
2738
- var Camera3D = class extends Node3D {
2832
+ var Camera3D = class Camera3D extends Node3D {
2739
2833
  static typeName = "Camera3D";
2740
2834
  static props = {
2741
2835
  fov: {
@@ -2747,6 +2841,27 @@ var Camera3D = class extends Node3D {
2747
2841
  exclusiveMax: true
2748
2842
  }
2749
2843
  },
2844
+ /**
2845
+ * The least the player must see ACROSS, in degrees. `0` = off.
2846
+ *
2847
+ * `fov` is the VERTICAL field of view and the aspect comes from the canvas,
2848
+ * so a `fov: 60` camera shows 91° across a 16:9 window and **30° across a
2849
+ * 390×844 phone** — the same game, down a keyhole, held in portrait. Ask
2850
+ * for a width and the vertical fov widens until the screen carries it;
2851
+ * a window already wider than that keeps the fov it was authored with, so
2852
+ * this only ever ADDS view on a narrow screen.
2853
+ *
2854
+ * `65` is a good floor for a third-person game meant to be playable in one
2855
+ * hand. Off by default: no existing scene's framing moves.
2856
+ */
2857
+ minHorizontalFov: {
2858
+ default: 0,
2859
+ range: {
2860
+ min: 0,
2861
+ max: 180,
2862
+ exclusiveMax: true
2863
+ }
2864
+ },
2750
2865
  near: {
2751
2866
  default: .1,
2752
2867
  range: {
@@ -2765,6 +2880,17 @@ var Camera3D = class extends Node3D {
2765
2880
  lookAt: {
2766
2881
  default: "",
2767
2882
  nodePath: true
2883
+ },
2884
+ /**
2885
+ * Seconds the renderer FLIES from the last drawn pose to this camera when
2886
+ * it becomes current (0 = a cut). Every switch used to be a cut — a
2887
+ * cutscene's door camera, the return to play, a chase camera taking over.
2888
+ * The camera nodes are untouched: picking, framing and the editor read
2889
+ * the node; only the picture takes the long way round.
2890
+ */
2891
+ blendSeconds: {
2892
+ default: 0,
2893
+ range: { min: 0 }
2768
2894
  }
2769
2895
  };
2770
2896
  /** A frustum whose planes are the wrong way round draws nothing at all. */
@@ -2773,9 +2899,29 @@ var Camera3D = class extends Node3D {
2773
2899
  if (self.near >= self.far) throw new IncantoError("PROP_TYPE_MISMATCH", `Camera3D '${node.name}' has an inverted depth range: near ${self.near} is not less than far ${self.far}. The near plane is the closest thing this camera can see and the far plane the furthest.`);
2774
2900
  }
2775
2901
  fov = 60;
2902
+ minHorizontalFov = 0;
2776
2903
  near = .1;
2777
2904
  far = 1e3;
2778
2905
  current = false;
2906
+ /**
2907
+ * Make THIS the scene's camera and no other: the renderer takes the first
2908
+ * camera whose `current` is true in tree order, so setting your own without
2909
+ * clearing the gameplay camera's changed nothing visible. A handler a
2910
+ * connection can name — `triggerEnter → DoorCam.makeCurrent`.
2911
+ */
2912
+ makeCurrent(blendSeconds = void 0) {
2913
+ const root = this.tree?.root ?? this.getRoot();
2914
+ const walk = (n) => {
2915
+ if (n instanceof Camera3D) n.current = n === this;
2916
+ for (const c of n.children) walk(c);
2917
+ };
2918
+ walk(root);
2919
+ this.current = true;
2920
+ this._blendRequest = blendSeconds;
2921
+ }
2922
+ blendSeconds = 0;
2923
+ /** @internal `makeCurrent(seconds)` — read once by the renderer's sync. */
2924
+ _blendRequest = void 0;
2779
2925
  lookAt = "";
2780
2926
  /**
2781
2927
  * Aim, every frame, whenever `lookAt` names something — see `aimAt`, which
@@ -2789,11 +2935,26 @@ var Camera3D = class extends Node3D {
2789
2935
  _createObject3D() {
2790
2936
  return new PerspectiveCamera(this.fov, 1, this.near, this.far);
2791
2937
  }
2938
+ /**
2939
+ * The vertical fov to actually render with at this aspect.
2940
+ *
2941
+ * Plain `fov` unless `minHorizontalFov` asks for more width than the screen
2942
+ * carries, in which case it is widened until it does. Public because a game
2943
+ * that wants to know what it is really showing — a harness, a debug overlay —
2944
+ * could not otherwise ask, and the answer is not the authored number.
2945
+ */
2946
+ viewFov(aspect) {
2947
+ if (!(this.minHorizontalFov > 0) || !(aspect > 0)) return this.fov;
2948
+ const half = this.minHorizontalFov / 2 * DEG2RAD$1;
2949
+ const wanted = 2 * Math.atan(Math.tan(half) / aspect) * RAD2DEG$4;
2950
+ return Math.min(179.9, Math.max(this.fov, wanted));
2951
+ }
2792
2952
  _syncObject3D() {
2793
2953
  super._syncObject3D();
2794
2954
  const cam = this._ensureObject3D();
2795
- if (cam.fov !== this.fov || cam.near !== this.near || cam.far !== this.far) {
2796
- cam.fov = this.fov;
2955
+ const fov = this.viewFov(cam.aspect);
2956
+ if (cam.fov !== fov || cam.near !== this.near || cam.far !== this.far) {
2957
+ cam.fov = fov;
2797
2958
  cam.near = this.near;
2798
2959
  cam.far = this.far;
2799
2960
  cam.updateProjectionMatrix();
@@ -2811,8 +2972,47 @@ const MOVEMENT_STATES = [
2811
2972
  "walk",
2812
2973
  "run",
2813
2974
  "fastRun",
2814
- "airborne"
2975
+ "airborne",
2976
+ "swim",
2977
+ "climb",
2978
+ "crouch",
2979
+ "sneak",
2980
+ "dash"
2815
2981
  ];
2982
+ /**
2983
+ * What to play when the scene's `animations` map has no clip for a state.
2984
+ *
2985
+ * A missing entry used to leave whatever was playing BEFORE, which is never
2986
+ * what anyone meant: a character swimming across a bay in a frozen jump pose,
2987
+ * because `swim` was unmapped and the last state was `airborne`. Every state
2988
+ * added after the original five had this — `dash`, `climb`, `sneak`, `crouch`
2989
+ * — and no scene in the repo mapped any of them.
2990
+ *
2991
+ * Each chain ends at `idle`, so any map with one clip in it animates.
2992
+ */
2993
+ const CLIP_FALLBACK = {
2994
+ idle: [],
2995
+ walk: ["idle"],
2996
+ run: ["walk", "idle"],
2997
+ fastRun: [
2998
+ "run",
2999
+ "walk",
3000
+ "idle"
3001
+ ],
3002
+ airborne: ["idle"],
3003
+ swim: ["walk", "idle"],
3004
+ climb: ["walk", "idle"],
3005
+ crouch: ["idle"],
3006
+ sneak: ["walk", "idle"],
3007
+ dash: [
3008
+ "fastRun",
3009
+ "run",
3010
+ "walk",
3011
+ "idle"
3012
+ ]
3013
+ };
3014
+ /** How much of a swimmer's head rides clear of the water (m) — see `swimDepth`. */
3015
+ const HEAD_ABOVE_WATER = .15;
2816
3016
  /** Air control stands down this long after a wall kick, so the kick lands. */
2817
3017
  const WALL_LOCKOUT_SECONDS = .18;
2818
3018
  const ACC_DELTA_TIME = 8;
@@ -2820,13 +3020,27 @@ const DRAG_DAMPING = .15;
2820
3020
  const SPRING_K = 1.2;
2821
3021
  const SPRING_DAMPING = .08;
2822
3022
  const FALLING_MAX_VEL = -20;
3023
+ /** The swim spring: how hard the water returns the body to its waterline (1/s²) and damps it (1/s). */
3024
+ const SWIM_K = 30;
3025
+ const SWIM_DAMPING = 8;
3026
+ /** Lift cap while swimming, in g — water pushes back; it does not launch. */
3027
+ const SWIM_MAX_G = 3;
3028
+ const STANDARD_G = 9.81;
3029
+ /** A probe hit closer than this began inside the collider it hit. */
3030
+ const INSIDE_HIT = 1e-4;
3031
+ /** How fast a swimmer pulls themselves up onto a ledge (m/s). */
3032
+ const CLIMB_SPEED = 2;
3033
+ /** After letting go of a ladder, this long before it can be grabbed again (s). */
3034
+ const LADDER_LOCKOUT = .35;
3035
+ /** How often the controller re-scans the tree for Water3D nodes (fixed steps). */
3036
+ const WATER_SCAN_STEPS = 60;
2823
3037
  /** The schema's own default for an action prop — never a second copy of it. */
2824
3038
  function defaultActionOf(prop) {
2825
3039
  const schema = CharacterController3D.props[prop];
2826
3040
  return typeof schema?.default === "string" ? schema.default : "";
2827
3041
  }
2828
3042
  const LOOK_SPEED = .002;
2829
- const RAD2DEG = 180 / Math.PI;
3043
+ const RAD2DEG$3 = 180 / Math.PI;
2830
3044
  /** Spring-arm camera collision: keep the camera this far in front of an obstacle. */
2831
3045
  const CAM_COLLISION_MARGIN = .3;
2832
3046
  /** Never pull the orbit camera closer to the eye than this (avoids clipping into the skin). */
@@ -2853,8 +3067,21 @@ const UP_VEC = new Vector3(0, 1, 0);
2853
3067
  */
2854
3068
  var CharacterController3D = class extends Node3D {
2855
3069
  static typeName = "CharacterController3D";
2856
- static signals = ["movementStateChanged", "wallJumped"];
3070
+ static signals = [
3071
+ "movementStateChanged",
3072
+ "wallJumped",
3073
+ "lockChanged",
3074
+ "dashed"
3075
+ ];
2857
3076
  static props = {
3077
+ /**
3078
+ * Off = the controller lets go: no input, no forces, no camera, no
3079
+ * animation — the body is an ordinary RigidBody3D until it is on again.
3080
+ * The switch a game flips when the player gets into a car, sits in a
3081
+ * cutscene or opens a menu; flip the body's `enabled`/`visible` beside it
3082
+ * to park the character out of sight.
3083
+ */
3084
+ enabled: { default: true },
2858
3085
  view: {
2859
3086
  default: "free",
2860
3087
  options: [
@@ -2889,12 +3116,115 @@ var CharacterController3D = class extends Node3D {
2889
3116
  fallGravity: { default: 2.5 },
2890
3117
  floatHeight: { default: .01 },
2891
3118
  stepHeight: { default: .35 },
3119
+ slopeLimitDeg: {
3120
+ default: 55,
3121
+ range: {
3122
+ min: 0,
3123
+ max: 90
3124
+ }
3125
+ },
3126
+ /** Swimming pace (m/s) in water too deep to stand in; 0 = no swimming (the body sinks). */
3127
+ swimSpeed: { default: 1.6 },
3128
+ /** How far the body's origin rides BELOW the surface while swimming (m). */
3129
+ swimDepth: {
3130
+ default: null,
3131
+ kinds: ["null", "number"]
3132
+ },
3133
+ /** Held: swim DOWN at `swimSpeed`. Absent from the input map = no diving. */
3134
+ diveAction: { default: "dive" },
3135
+ /** Ladder pace (m/s): push UP into an Area3D in `ladderGroup` and the body climbs it; 0 = no ladders. */
3136
+ climbSpeed: { default: 1.5 },
3137
+ /** Any Area3D in this group is a ladder. */
3138
+ ladderGroup: { default: "ladder" },
3139
+ /** On the way down from a jump, a ledge ahead no higher than this above the capsule's bottom is caught and pulled up onto (m); 0 = off. */
3140
+ mantleHeight: {
3141
+ default: 0,
3142
+ range: { min: 0 }
3143
+ },
3144
+ /**
3145
+ * The capsule's height while the crouch key is held (m); 0 = no crouch.
3146
+ * The body sinks by half the difference so the feet stay put, a duct
3147
+ * lower than the standing capsule is crawled through, and a ceiling that
3148
+ * will not let you stand keeps you down until you are out from under it.
3149
+ */
3150
+ crouchHeight: {
3151
+ default: 0,
3152
+ range: { min: 0 }
3153
+ },
3154
+ /** Pace while crouched, as a fraction of the standing pace. */
3155
+ crouchSpeedMultiplier: {
3156
+ default: .5,
3157
+ range: { min: 0 }
3158
+ },
3159
+ /** Held = crouch. Missing from the input map = the character never crouches. */
3160
+ crouchAction: { default: "crouch" },
3161
+ /**
3162
+ * A roll: this fast (m/s) for `dashSeconds`, where the stick points or —
3163
+ * with no stick — where the skin faces; 0 = no dash. The velocity is held
3164
+ * for the roll's frames (the controller brakes toward its own pace every
3165
+ * step, so a single impulse dies), `state` reads `dash`, and `dashed`
3166
+ * fires once per roll — hang the i-frames on it (`Health.protect`).
3167
+ */
3168
+ dashSpeed: {
3169
+ default: 0,
3170
+ range: { min: 0 }
3171
+ },
3172
+ dashSeconds: {
3173
+ default: .25,
3174
+ range: { min: 0 }
3175
+ },
3176
+ /** Seconds after a roll ends before the next can start. */
3177
+ dashCooldown: {
3178
+ default: 0,
3179
+ range: { min: 0 }
3180
+ },
3181
+ /** Pressed = roll. Missing from the input map = the character never dashes. */
3182
+ dashAction: { default: "dash" },
3183
+ /**
3184
+ * With no stick, roll BACKWARD — away from where the skin faces — instead
3185
+ * of forward: the Souls-like backstep a lock-on game wants (a neutral
3186
+ * roll into the thing you are locked on is the wrong way).
3187
+ */
3188
+ dashBackstep: { default: false },
2892
3189
  mouseLook: { default: true },
2893
3190
  zoomMin: { default: 0 },
2894
3191
  zoomMax: { default: 0 },
2895
3192
  pitchMin: { default: -1.3 },
2896
3193
  pitchMax: { default: 1.5 },
3194
+ /**
3195
+ * Where the camera RESTS before anyone has looked around, in degrees.
3196
+ * Negative looks down, which is what a third-person game usually wants.
3197
+ *
3198
+ * `pitch` is a radian field the mouse mutates, so the alternative was a
3199
+ * behaviour whose only job is one line in `onReady`. On a phone it stops
3200
+ * being a nicety: a portrait screen is very tall, so a level camera spends
3201
+ * the top half on sky and the bottom on the ground at the player's feet,
3202
+ * and a player with no mouse cannot fix it before they start dragging.
3203
+ * Clamped by `pitchMin`/`pitchMax` like every other pitch. `0` is the old
3204
+ * behaviour.
3205
+ */
3206
+ pitchStart: {
3207
+ default: 0,
3208
+ range: {
3209
+ min: -180,
3210
+ max: 180
3211
+ }
3212
+ },
2897
3213
  cameraCollision: { default: true },
3214
+ /**
3215
+ * Who owns the scene's camera: `'drive'` (default) is this controller, as
3216
+ * it always has been — the rig behind the character, per `view`. `'none'`
3217
+ * leaves the camera exactly where the scene puts it, for the whole shelf of
3218
+ * games with a FIXED view: a board game, a bomb arena, an isometric puzzle,
3219
+ * a fixed-angle horror. Those games could not use this controller at all,
3220
+ * because it re-posed their camera every frame; now they pair it with an
3221
+ * authored `Camera3D` (and a `FollowCamera` behaviour when the board
3222
+ * scrolls).
3223
+ */
3224
+ camera: {
3225
+ default: "drive",
3226
+ options: ["drive", "none"]
3227
+ },
2898
3228
  platformCarry: { default: true },
2899
3229
  turnSpeed: { default: 100 },
2900
3230
  camLerp: { default: 25 },
@@ -2906,6 +3236,20 @@ var CharacterController3D = class extends Node3D {
2906
3236
  nodePath: true
2907
3237
  },
2908
3238
  skinYawOffset: { default: 0 },
3239
+ /**
3240
+ * The button that LOCKS ON: the camera yaw follows the nearest live member
3241
+ * of `lockGroup` within `lockRange`, and the skin FACES it while the feet go
3242
+ * anywhere — a locked player circling a brute shows it a face, not a
3243
+ * shoulder, and a hitbox hung off the skin's yaw swings at it. Press again
3244
+ * to let go; the lock lets go by itself when the target dies, leaves the
3245
+ * tree, or gets 1.5 × `lockRange` away. '' = no lock-on.
3246
+ */
3247
+ lockOnAction: { default: "" },
3248
+ lockGroup: { default: "enemy" },
3249
+ lockRange: {
3250
+ default: 12,
3251
+ range: { min: 0 }
3252
+ },
2909
3253
  animations: {
2910
3254
  default: {},
2911
3255
  asset: {
@@ -2914,6 +3258,7 @@ var CharacterController3D = class extends Node3D {
2914
3258
  }
2915
3259
  }
2916
3260
  };
3261
+ enabled = true;
2917
3262
  view = "free";
2918
3263
  camDistance = 4;
2919
3264
  eyeHeight = .64;
@@ -2940,6 +3285,60 @@ var CharacterController3D = class extends Node3D {
2940
3285
  fallGravity = 2.5;
2941
3286
  floatHeight = .01;
2942
3287
  /**
3288
+ * Swimming pace in water too deep to stand in (m/s; `sprint` still
3289
+ * multiplies). `0` turns swimming OFF and the body sinks to the bed, which
3290
+ * is what a drowning pit wants and what every scene got before this.
3291
+ */
3292
+ swimSpeed = 1.6;
3293
+ /**
3294
+ * How far the body's ORIGIN rides below the surface while swimming (m), or
3295
+ * `null` (the default) to take it from the body: deep enough that the head
3296
+ * rides 15 cm clear and the rest is in the water, which is what treading
3297
+ * water looks like.
3298
+ *
3299
+ * The origin is the middle of the capsule, so a fixed number is only ever
3300
+ * right for one character size. The 0.35 m this used to default to left the
3301
+ * head of a standard 1.68 m rig 0.46 m clear of the surface — a person
3302
+ * floating like a boat, chest and shoulders in the air.
3303
+ */
3304
+ swimDepth = null;
3305
+ /** Held: swim down at `swimSpeed`. Missing from the input map is fine. */
3306
+ diveAction = "dive";
3307
+ /**
3308
+ * Ladder pace (m/s). A ladder is any `Area3D` in `ladderGroup`: push UP into
3309
+ * it and the body climbs, gravity off, held to the rungs; at the top it
3310
+ * pulls itself onto the ledge, down steps off at the bottom, `jump` lets go.
3311
+ * `0` = no ladders (every scene before this).
3312
+ */
3313
+ climbSpeed = 1.5;
3314
+ ladderGroup = "ladder";
3315
+ /**
3316
+ * The pull-up, for any jump. A jump that falls a hand short of a ledge is
3317
+ * the most common near-miss in a platformer, and every one handles it the
3318
+ * same way: catch the edge, pull up. On the way down from a jump, a ledge
3319
+ * ahead no higher than this above the capsule's bottom is taken — the same
3320
+ * climb that takes a swimmer out of the water and a climber off a ladder.
3321
+ * `0` (the default) = a jump near a wall feels exactly as it did.
3322
+ */
3323
+ mantleHeight = 0;
3324
+ crouchHeight = 0;
3325
+ crouchSpeedMultiplier = .5;
3326
+ crouchAction = "crouch";
3327
+ dashSpeed = 0;
3328
+ dashSeconds = .25;
3329
+ dashCooldown = 0;
3330
+ dashAction = "dash";
3331
+ dashBackstep = false;
3332
+ dashLeft = 0;
3333
+ dashCooldownLeft = 0;
3334
+ dashDir = [0, -1];
3335
+ /** Read-only: shorter right now — by the key, or by a ceiling. */
3336
+ crouching = false;
3337
+ /** The capsule height to stand back up to. */
3338
+ standingHeight = 0;
3339
+ /** The skin's authored y offset, restored on standing. */
3340
+ skinRest = null;
3341
+ /**
2943
3342
  * How high a step the PLAYER walks up without jumping (0 = off).
2944
3343
  *
2945
3344
  * The hover spring holds the capsule a fixed distance above whatever is
@@ -2958,13 +3357,29 @@ var CharacterController3D = class extends Node3D {
2958
3357
  * 200 kg character does not move a seesaw a 70 kg crate tips.
2959
3358
  */
2960
3359
  stepHeight = .35;
3360
+ /**
3361
+ * The steepest ground this character STANDS on, in degrees (0-90).
3362
+ *
3363
+ * At or under it the ground is ground: the character holds its place with no
3364
+ * slide at all, however long you leave it there. Over it the ground stops
3365
+ * counting as ground — no hover spring, no `grounded`, no jump reset — and
3366
+ * the body slips off the face, which is what a slope limit means in every
3367
+ * engine that has one.
3368
+ *
3369
+ * 55° is a person. A mountain goat is 80; something on wheels is 25. `90`
3370
+ * turns the limit off — which is what this controller did before it had one,
3371
+ * and why it slid down a 7° hillside.
3372
+ */
3373
+ slopeLimitDeg = 55;
2961
3374
  mouseLook = true;
2962
3375
  zoomMin = 0;
2963
3376
  zoomMax = 0;
2964
3377
  pitchMin = -1.3;
2965
3378
  pitchMax = 1.5;
3379
+ pitchStart = 0;
2966
3380
  /** Spring-arm: pull the orbit camera in front of walls/ground instead of clipping. */
2967
3381
  cameraCollision = true;
3382
+ camera = "drive";
2968
3383
  /**
2969
3384
  * Ride whatever you are standing on.
2970
3385
  *
@@ -2989,19 +3404,59 @@ var CharacterController3D = class extends Node3D {
2989
3404
  * would fling the player on every step onto one.
2990
3405
  */
2991
3406
  groundCarry(ground, dt) {
3407
+ const body = this.parent;
3408
+ const physics = body?._physics3d;
3409
+ const me = body ? worldPosition(body) : [
3410
+ 0,
3411
+ 0,
3412
+ 0
3413
+ ];
2992
3414
  if (!this.platformCarry || !this.grounded || !ground || dt <= 0) {
2993
3415
  this.carriedBy = ground;
2994
- if (ground) this.carriedFrom = groundXZ(ground);
3416
+ if (ground) {
3417
+ this.carriedFrom = groundXZ(ground);
3418
+ this.carriedRot = physics?.rotationOf(ground) ?? [
3419
+ 0,
3420
+ 0,
3421
+ 0,
3422
+ 1
3423
+ ];
3424
+ this.carriedMe = [me[0] ?? 0, me[2] ?? 0];
3425
+ }
2995
3426
  return [0, 0];
2996
3427
  }
2997
3428
  const at = groundXZ(ground);
3429
+ const rot = physics?.rotationOf(ground) ?? [
3430
+ 0,
3431
+ 0,
3432
+ 0,
3433
+ 1
3434
+ ];
2998
3435
  const same = this.carriedBy === ground;
2999
3436
  const was = this.carriedFrom;
3437
+ const wasRot = this.carriedRot;
3438
+ const wasMe = this.carriedMe;
3000
3439
  this.carriedBy = ground;
3001
3440
  this.carriedFrom = at;
3441
+ this.carriedRot = rot;
3442
+ this.carriedMe = [me[0] ?? 0, me[2] ?? 0];
3002
3443
  if (!same) return [0, 0];
3003
- return [(at[0] - was[0]) / dt, (at[1] - was[1]) / dt];
3004
- }
3444
+ let vx = (at[0] - was[0]) / dt;
3445
+ let vz = (at[1] - was[1]) / dt;
3446
+ const ox = wasMe[0] - was[0];
3447
+ const oz = wasMe[1] - was[1];
3448
+ const [rx, , rz] = rotateXZByDelta(rot, wasRot, ox, oz);
3449
+ vx += (rx - ox) / dt;
3450
+ vz += (rz - oz) / dt;
3451
+ return [vx, vz];
3452
+ }
3453
+ carriedRot = [
3454
+ 0,
3455
+ 0,
3456
+ 0,
3457
+ 1
3458
+ ];
3459
+ carriedMe = [0, 0];
3005
3460
  turnSpeed = 100;
3006
3461
  /** Camera smoothing rate (1-exp(-rate·dt)); side view uses 5. */
3007
3462
  camLerp = 25;
@@ -3016,6 +3471,68 @@ var CharacterController3D = class extends Node3D {
3016
3471
  * character facing away from the camera until it first moves).
3017
3472
  */
3018
3473
  skinYawOffset = 0;
3474
+ lockOnAction = "";
3475
+ lockGroup = "enemy";
3476
+ lockRange = 12;
3477
+ /** What the character is locked onto, or null. */
3478
+ lockTarget = null;
3479
+ /** Lock onto `node`: the camera yaw and the skin follow it from now on. */
3480
+ lock(node) {
3481
+ if (node === this.lockTarget) return;
3482
+ this.lockTarget = node;
3483
+ this.emit("lockChanged", node);
3484
+ }
3485
+ /** Let go. */
3486
+ unlock() {
3487
+ if (!this.lockTarget) return;
3488
+ this.lockTarget = null;
3489
+ this.emit("lockChanged", null);
3490
+ }
3491
+ /** Lock the nearest live, visible member of `lockGroup` within `lockRange` — or let go if locked. */
3492
+ toggleLock() {
3493
+ if (this.lockTarget) {
3494
+ this.unlock();
3495
+ return;
3496
+ }
3497
+ const body = this.parent;
3498
+ if (!body || !hasPositionArray(body)) return;
3499
+ const me = worldPosition(body);
3500
+ let best = null;
3501
+ let bestD = this.lockRange;
3502
+ for (const n of this.tree?.getNodesInGroup(this.lockGroup) ?? []) {
3503
+ if (n === body || !hasPositionArray(n)) continue;
3504
+ if (n.visible === false) continue;
3505
+ if (findHealth(n)?.health.isDead) continue;
3506
+ const p = worldPosition(n);
3507
+ const d = Math.hypot((p[0] ?? 0) - (me[0] ?? 0), (p[2] ?? 0) - (me[2] ?? 0));
3508
+ if (d < bestD) {
3509
+ bestD = d;
3510
+ best = n;
3511
+ }
3512
+ }
3513
+ if (best) this.lock(best);
3514
+ }
3515
+ /** Player → target on the ground plane, unit; null when not locked (and lets go when the lock is stale). */
3516
+ lockDirection() {
3517
+ const t = this.lockTarget;
3518
+ if (!t) return null;
3519
+ const body = this.parent;
3520
+ if (!t.tree || !body || !hasPositionArray(body) || !hasPositionArray(t) || findHealth(t)?.health.isDead) {
3521
+ this.unlock();
3522
+ return null;
3523
+ }
3524
+ const me = worldPosition(body);
3525
+ const p = worldPosition(t);
3526
+ const dx = (p[0] ?? 0) - (me[0] ?? 0);
3527
+ const dz = (p[2] ?? 0) - (me[2] ?? 0);
3528
+ const d = Math.hypot(dx, dz);
3529
+ if (d > this.lockRange * 1.5) {
3530
+ this.unlock();
3531
+ return null;
3532
+ }
3533
+ if (d < .05) return null;
3534
+ return [dx / d, dz / d];
3535
+ }
3019
3536
  /**
3020
3537
  * Movement-state → animation asset map, applied to the `skinPath` model
3021
3538
  * directly — no CharacterAnimator behavior needed:
@@ -3047,9 +3564,33 @@ var CharacterController3D = class extends Node3D {
3047
3564
  /** Camera yaw/pitch in radians (mouse look mutates these). */
3048
3565
  yaw = 0;
3049
3566
  pitch = 0;
3050
- /** idle | walk | run | fastRun | airborne — drive animations from this. */
3567
+ /** idle | walk | run | fastRun | airborne | swim — drive animations from this. */
3051
3568
  state = "idle";
3052
3569
  grounded = false;
3570
+ /** Slope of the ground under the character, degrees — 0 when there is none. */
3571
+ groundSlopeDeg = 0;
3572
+ /** Its outward normal, or null. `[0, 1, 0]` is flat ground. */
3573
+ groundNormal = null;
3574
+ /** In water too deep to stand in — held at the surface by the swim spring. */
3575
+ swimming = false;
3576
+ /** The head is under the surface (diving, or a wave over it). */
3577
+ submerged = false;
3578
+ /** Pulling up onto a ledge out of the water (see the climb in fixedUpdate). */
3579
+ climbing = false;
3580
+ /** On a ladder — an `Area3D` in `ladderGroup` the body overlaps and pushed up into. */
3581
+ onLadder = false;
3582
+ ladders = [];
3583
+ ladderScanIn = 0;
3584
+ ladderLockout = 0;
3585
+ /** Every Water3D in the tree, re-scanned every {@link WATER_SCAN_STEPS}. */
3586
+ waters = [];
3587
+ waterScanIn = 0;
3588
+ /**
3589
+ * A hop out of the water is in progress: the spring may catch a ledge on
3590
+ * the way down (the mantle) — a jump from land never gets that, or the
3591
+ * spring would cushion every landing.
3592
+ */
3593
+ swimHop = false;
3053
3594
  /** @internal Feel timers — see the jump block for what each one buys. */
3054
3595
  coyoteLeft = 0;
3055
3596
  bufferLeft = 0;
@@ -3059,6 +3600,12 @@ var CharacterController3D = class extends Node3D {
3059
3600
  jumping = false;
3060
3601
  /** Seconds of air control still suppressed after a wall kick. */
3061
3602
  wallLockout = 0;
3603
+ /** What the anti-slide has learned it must keep paying to hold a slope. */
3604
+ slideHold = 0;
3605
+ /** Where the body stood last step, for the anti-slide's position error. */
3606
+ slideFrom = null;
3607
+ /** Seconds since the player last asked this character to move. */
3608
+ stillFor = 0;
3062
3609
  /** The outward normal of the wall being pressed into this step, if any. */
3063
3610
  wallNormal = null;
3064
3611
  get body() {
@@ -3068,6 +3615,10 @@ var CharacterController3D = class extends Node3D {
3068
3615
  }
3069
3616
  onEnterTree() {
3070
3617
  this.body;
3618
+ if (this.pitchStart !== 0) {
3619
+ const want = this.pitchStart * Math.PI / 180;
3620
+ this.pitch = Math.min(Math.max(want, -this.pitchMax), -this.pitchMin);
3621
+ }
3071
3622
  if (this.skinPath !== "") {
3072
3623
  const skin = this.getNodeOrNull(this.skinPath);
3073
3624
  if (skin) skin.rotation = [
@@ -3082,9 +3633,15 @@ var CharacterController3D = class extends Node3D {
3082
3633
  const physics = body._physics3d;
3083
3634
  const input = this.tree?.engine?.input;
3084
3635
  if (!physics || !input) return;
3636
+ if (!this.enabled) {
3637
+ this.readAction("moveAction", () => input.getVector(this.moveAction), null, "");
3638
+ this.readAction("jumpAction", () => input.isPressed(this.jumpAction), false, "");
3639
+ this.readAction("sprintAction", () => input.isPressed(this.sprintAction), false, "");
3640
+ return;
3641
+ }
3085
3642
  const radius = body.collider.radius ?? .32;
3086
3643
  const halfHeight = (body.collider.height ?? 1) / 2;
3087
- const pos = worldOf(body);
3644
+ const pos = worldOf$1(body);
3088
3645
  const vel = physics.velocityOf(body);
3089
3646
  const mass = physics.massOf(body);
3090
3647
  const floatingDis = radius + this.floatHeight;
@@ -3093,6 +3650,8 @@ var CharacterController3D = class extends Node3D {
3093
3650
  let toi = null;
3094
3651
  /** What the probe landed on — the platform, when there is one. */
3095
3652
  let ground = null;
3653
+ /** …and which way its surface faces, for the slope limit and the spring. */
3654
+ let normal = null;
3096
3655
  for (const [ox, oz] of [
3097
3656
  [0, 0],
3098
3657
  [radius, 0],
@@ -3109,18 +3668,60 @@ var CharacterController3D = class extends Node3D {
3109
3668
  -1,
3110
3669
  0
3111
3670
  ], rayLen, body);
3112
- if (hit) {
3671
+ if (hit && hit.distance > INSIDE_HIT) {
3113
3672
  const d = hit.distance;
3114
3673
  if (toi === null || d < toi) {
3115
3674
  toi = d;
3116
3675
  ground = hit.node;
3676
+ const n = hit.normal;
3677
+ normal = n ? [
3678
+ n[0] ?? 0,
3679
+ n[1] ?? 1,
3680
+ n[2] ?? 0
3681
+ ] : null;
3117
3682
  }
3118
3683
  if (ox === 0 && oz === 0) break;
3119
3684
  }
3120
3685
  }
3121
- this.grounded = toi !== null && toi < floatingDis * 2;
3122
- const carry = this.groundCarry(ground, dt);
3123
- const footed = toi !== null && toi < floatingDis * 1.3 && (vel[1] ?? 0) <= .5;
3686
+ const upDot = normal ? Math.max(-1, Math.min(1, normal[1] ?? 1)) : 1;
3687
+ const slopeDeg = toi === null ? 0 : Math.acos(Math.max(upDot, 0)) * 180 / Math.PI;
3688
+ this.groundSlopeDeg = slopeDeg;
3689
+ this.groundNormal = toi === null ? null : normal ?? [
3690
+ 0,
3691
+ 1,
3692
+ 0
3693
+ ];
3694
+ const standable = slopeDeg <= this.slopeLimitDeg;
3695
+ /** The spring's target: `floatHeight` of clearance PERPENDICULAR to the ground. */
3696
+ const holdDis = floatingDis / Math.max(upDot, .35);
3697
+ this.grounded = toi !== null && toi < holdDis * 2 && standable;
3698
+ if (!this.grounded) {
3699
+ this.slideHold = 0;
3700
+ this.slideFrom = null;
3701
+ }
3702
+ const carry = this.groundCarry(standable ? ground : null, dt);
3703
+ const footed = toi !== null && toi < holdDis * 1.3 && standable && (vel[1] ?? 0) <= .5;
3704
+ const water = this.swimSpeed > 0 ? this.surfaceAt(pos[0], pos[2]) : null;
3705
+ const surface = water === null ? null : water[0];
3706
+ const swimDepth = this.swimDepth ?? Math.max(halfHeight + radius - HEAD_ABOVE_WATER, .05);
3707
+ const waterline = surface === null ? null : surface - swimDepth;
3708
+ const standY = toi === null ? null : originY - toi + floatingDis + halfHeight;
3709
+ const swimming = waterline !== null && surface !== null && surface > pos[1] - halfHeight && waterline !== null && (standY === null || waterline > standY) && !(this.jumping && (vel[1] ?? 0) > .5);
3710
+ this.swimming = swimming;
3711
+ this.submerged = surface !== null && surface > pos[1] + halfHeight + radius;
3712
+ if (swimming) {
3713
+ this.jumping = false;
3714
+ this.rising = false;
3715
+ this.jumpsUsed = 0;
3716
+ this.coyoteLeft = 0;
3717
+ this.swimHop = false;
3718
+ }
3719
+ const mantling = this.swimHop && (vel[1] ?? 0) <= .5;
3720
+ if (this.lockOnAction !== "") {
3721
+ if (this.readAction("lockOnAction", () => input.justPressed(this.lockOnAction), false, "Lock-on will not toggle.")) this.toggleLock();
3722
+ }
3723
+ const lockDir = this.lockDirection();
3724
+ if (lockDir) this.yaw = Math.atan2(-lockDir[0], -lockDir[1]);
3124
3725
  let mx = 0;
3125
3726
  let mz = 0;
3126
3727
  let moving = false;
@@ -3139,12 +3740,57 @@ var CharacterController3D = class extends Node3D {
3139
3740
  } catch (error) {
3140
3741
  this.reportMissingAction("moveAction", error, "The character will not move.");
3141
3742
  }
3743
+ const hitHealth = findHealth(this.parent)?.health ?? null;
3744
+ const staggered = hitHealth?.staggered ?? false;
3745
+ if (staggered) {
3746
+ mx = 0;
3747
+ mz = 0;
3748
+ moving = false;
3749
+ }
3750
+ const kick = hitHealth?.knocked ? hitHealth.kickVelocity : [];
3751
+ const lift = hitHealth?.knocked ? hitHealth.takeLift() : 0;
3752
+ this.dashCooldownLeft = Math.max(0, this.dashCooldownLeft - dt);
3753
+ if (!staggered && this.dashSpeed > 0 && this.readAction("dashAction", () => input.justPressed(this.dashAction), false, "The character will never dash.") && this.dashLeft <= 0 && this.dashCooldownLeft <= 0 && !this.onLadder && !this.crouching) {
3754
+ this.dashLeft = this.dashSeconds;
3755
+ this.dashCooldownLeft = this.dashSeconds + this.dashCooldown;
3756
+ if (moving) this.dashDir = [mx, mz];
3757
+ else {
3758
+ const skin = this.skinPath !== "" ? this.getNodeOrNull(this.skinPath) : null;
3759
+ if (skin) {
3760
+ const yaw = ((skin.rotation[1] ?? 0) - this.skinYawOffset) * (Math.PI / 180);
3761
+ const back = this.dashBackstep ? -1 : 1;
3762
+ this.dashDir = [Math.sin(yaw) * back, Math.cos(yaw) * back];
3763
+ }
3764
+ }
3765
+ this.emit("dashed");
3766
+ }
3767
+ const dashing = this.dashLeft > 0;
3768
+ if (dashing) this.dashLeft -= dt;
3769
+ const rawUp = -this.readAction("moveAction", () => input.getVector(this.moveAction).y, 0, "The character will not climb.");
3142
3770
  const sprinting = this.readAction("sprintAction", () => input.isPressed(this.sprintAction), false, "Sprint will never engage.");
3143
3771
  const intensity = keyboardIntensity(moving, sprinting);
3144
3772
  const speedMultiplier = 1 + (this.sprintMultiplier - 1) * intensity;
3145
- const targetSpeed = moving ? this.maxSpeed * speedMultiplier : 0;
3773
+ const walkMultiplier = 1 + (this.sprintMultiplier - 1) * keyboardIntensity(true, false);
3774
+ const targetSpeed = !moving ? 0 : swimming ? this.swimSpeed * speedMultiplier / walkMultiplier : this.maxSpeed * speedMultiplier * (this.crouching ? this.crouchSpeedMultiplier : 1);
3775
+ this.ladderLockout = Math.max(0, this.ladderLockout - dt);
3776
+ const ladder = this.climbSpeed > 0 && !swimming && this.ladderLockout <= 0 ? this.ladderUnder(body) : null;
3777
+ let onLadder = false;
3778
+ if (ladder) {
3779
+ const wantsUp = rawUp > .1;
3780
+ const wantsDown = rawUp < -.1;
3781
+ if (this.onLadder) onLadder = !(footed && wantsDown);
3782
+ else if (wantsUp || wantsDown && !footed) onLadder = true;
3783
+ }
3784
+ this.onLadder = onLadder;
3785
+ if (onLadder) {
3786
+ this.jumping = false;
3787
+ this.rising = false;
3788
+ this.jumpsUsed = 0;
3789
+ this.coyoteLeft = 0;
3790
+ this.swimHop = false;
3791
+ }
3146
3792
  this.wallNormal = null;
3147
- if ((this.wallSlideSpeed > 0 || this.wallJumpImpulse.some((v) => v !== 0)) && !this.grounded && moving) {
3793
+ if ((this.wallSlideSpeed > 0 || this.wallJumpImpulse.some((v) => v !== 0)) && !this.grounded && !swimming && !onLadder && moving) {
3148
3794
  const r = body.collider.radius ?? .32;
3149
3795
  const n = (this.tree?.engine?.physics?.castRay([
3150
3796
  pos[0] ?? 0,
@@ -3165,7 +3811,42 @@ var CharacterController3D = class extends Node3D {
3165
3811
  }
3166
3812
  }
3167
3813
  this.wallLockout = Math.max(0, this.wallLockout - dt);
3168
- const control = this.wallLockout > 0 ? 0 : this.grounded ? 1 : this.airControl;
3814
+ let climbing = false;
3815
+ const mantleReach = this.mantleHeight > 0 && this.jumping && (vel[1] ?? 0) <= .5;
3816
+ const reach = mantleReach ? Math.max(this.mantleHeight, this.stepHeight) : this.stepHeight;
3817
+ if (reach > 0 && moving && (swimming || mantling || onLadder || mantleReach || this.climbing)) {
3818
+ const ahead = radius + .12;
3819
+ const stepFrom = water === null ? originY : water[1];
3820
+ const probeTop = stepFrom + reach + .2;
3821
+ const ledge = physics.castRay([
3822
+ pos[0] + mx * ahead,
3823
+ probeTop,
3824
+ pos[2] + mz * ahead
3825
+ ], [
3826
+ 0,
3827
+ -1,
3828
+ 0
3829
+ ], reach + .2 + rayLen, body);
3830
+ if (ledge && ledge.distance > INSIDE_HIT) {
3831
+ const top = probeTop - ledge.distance;
3832
+ const aboveTheBottom = top > originY - radius + .02;
3833
+ const withinReach = top - stepFrom <= reach + .02;
3834
+ const stillBelow = originY < top + floatingDis - .01;
3835
+ if (aboveTheBottom && withinReach && stillBelow) climbing = true;
3836
+ }
3837
+ }
3838
+ this.climbing = climbing;
3839
+ if (climbing && (vel[1] ?? 0) < CLIMB_SPEED) body.applyImpulse([
3840
+ 0,
3841
+ (CLIMB_SPEED - (vel[1] ?? 0)) * .5 * mass,
3842
+ 0
3843
+ ]);
3844
+ if (onLadder && !climbing) body.applyImpulse([
3845
+ -(vel[0] ?? 0) * .3 * mass,
3846
+ (rawUp * this.climbSpeed - (vel[1] ?? 0)) * .5 * mass,
3847
+ -(vel[2] ?? 0) * .3 * mass
3848
+ ]);
3849
+ const control = this.wallLockout > 0 || onLadder && !climbing ? 0 : this.grounded || swimming || climbing ? 1 : this.airControl;
3169
3850
  const ax = (mx * targetSpeed + carry[0] - vel[0]) / ACC_DELTA_TIME * control;
3170
3851
  const az = (mz * targetSpeed + carry[1] - vel[2]) / ACC_DELTA_TIME * control;
3171
3852
  body.applyImpulse([
@@ -3173,13 +3854,40 @@ var CharacterController3D = class extends Node3D {
3173
3854
  0,
3174
3855
  az * mass
3175
3856
  ]);
3176
- if (!moving && this.grounded) body.applyImpulse([
3857
+ if (!moving && (this.grounded || swimming)) body.applyImpulse([
3177
3858
  -(vel[0] - carry[0]) * DRAG_DAMPING * mass,
3178
3859
  0,
3179
3860
  -(vel[2] - carry[1]) * DRAG_DAMPING * mass
3180
3861
  ]);
3862
+ this.stillFor = moving ? 0 : this.stillFor + dt;
3863
+ if (this.grounded && !swimming && !climbing && !onLadder && !this.jumping) {
3864
+ const n = this.groundNormal;
3865
+ const nx = n?.[0] ?? 0;
3866
+ const nz = n?.[2] ?? 0;
3867
+ const sinSlope = Math.hypot(nx, nz);
3868
+ if (sinSlope > .001) {
3869
+ const ux = nx / sinSlope;
3870
+ const uz = nz / sinSlope;
3871
+ const drift = (vel[0] - carry[0]) * ux + (vel[2] - carry[1]) * uz;
3872
+ if (this.stillFor < .1) {
3873
+ this.slideHold = 0;
3874
+ this.slideFrom = null;
3875
+ } else {
3876
+ const from = this.slideFrom;
3877
+ const moved = from === null ? 0 : (pos[0] - from[0]) * ux + (pos[2] - from[1]) * uz;
3878
+ this.slideFrom = [pos[0], pos[2]];
3879
+ this.slideHold = Math.max(-2, Math.min(2, this.slideHold + moved / dt * .5));
3880
+ const hold = drift + this.slideHold;
3881
+ if (hold !== 0) body.applyImpulse([
3882
+ -hold * ux * mass,
3883
+ 0,
3884
+ -hold * uz * mass
3885
+ ]);
3886
+ }
3887
+ }
3888
+ }
3181
3889
  let springToi = toi;
3182
- if (this.stepHeight > 0 && moving && toi !== null && !this.jumping && (vel[1] ?? 0) <= .5) {
3890
+ if (this.stepHeight > 0 && moving && toi !== null && !this.jumping && !swimming && !climbing && (vel[1] ?? 0) <= .5) {
3183
3891
  const ahead = radius + .12;
3184
3892
  const probeTop = originY + this.stepHeight + .05;
3185
3893
  const stepHit = physics.castRay([
@@ -3191,34 +3899,107 @@ var CharacterController3D = class extends Node3D {
3191
3899
  -1,
3192
3900
  0
3193
3901
  ], this.stepHeight + .05 + rayLen, body);
3194
- if (stepHit) {
3902
+ if (stepHit && stepHit.distance > INSIDE_HIT) {
3195
3903
  const rise = probeTop - stepHit.distance - (originY - toi);
3196
3904
  if (rise > .02 && rise <= this.stepHeight + .02) springToi = toi - rise;
3197
3905
  }
3198
3906
  }
3199
- if (springToi !== null && springToi < floatingDis * 2 && !this.jumping && (vel[1] ?? 0) < 2) {
3200
- const spring = SPRING_K * (floatingDis - springToi) - vel[1] * SPRING_DAMPING;
3907
+ if (springToi !== null && springToi < floatingDis * 2 && (!this.jumping || mantling) && !swimming && !climbing && !onLadder && (vel[1] ?? 0) < 2) {
3908
+ const spring = SPRING_K * (holdDis - springToi) - vel[1] * SPRING_DAMPING;
3201
3909
  body.applyImpulse([
3202
3910
  0,
3203
3911
  spring * mass,
3204
3912
  0
3205
3913
  ]);
3206
3914
  }
3915
+ const diving = this.swimSpeed > 0 && this.readAction("diveAction", () => input.isPressed(this.diveAction), false, "The character will never dive.");
3916
+ if (swimming && !climbing && waterline !== null) {
3917
+ const surfacing = this.submerged && this.readAction("jumpAction", () => input.isPressed(this.jumpAction), false, "The character will never surface by choice.");
3918
+ const vy = vel[1] ?? 0;
3919
+ let ay;
3920
+ if (diving) ay = (-this.swimSpeed - vy) / ACC_DELTA_TIME / dt;
3921
+ else if (surfacing) ay = (this.swimSpeed - vy) / ACC_DELTA_TIME / dt;
3922
+ else ay = SWIM_K * (waterline - pos[1]) - SWIM_DAMPING * vy;
3923
+ ay = Math.max(-3 * STANDARD_G, Math.min(SWIM_MAX_G * STANDARD_G, ay));
3924
+ body.applyImpulse([
3925
+ 0,
3926
+ ay * dt * mass,
3927
+ 0
3928
+ ]);
3929
+ }
3207
3930
  if (footed) {
3208
3931
  this.coyoteLeft = this.coyoteSeconds;
3209
3932
  this.jumpsUsed = 0;
3210
3933
  this.rising = false;
3211
3934
  this.jumping = false;
3935
+ this.swimHop = false;
3212
3936
  } else {
3213
3937
  this.coyoteLeft = Math.max(0, this.coyoteLeft - dt);
3214
- if (!this.grounded && this.coyoteLeft <= 0 && this.jumpsUsed === 0) this.jumpsUsed = 1;
3938
+ if (!this.grounded && !swimming && this.coyoteLeft <= 0 && this.jumpsUsed === 0) this.jumpsUsed = 1;
3215
3939
  }
3216
3940
  const pressedJump = this.readAction("jumpAction", () => input.justPressed(this.jumpAction), false, "The character will never jump.");
3217
3941
  if (pressedJump) this.bufferLeft = Math.max(this.jumpBufferSeconds, 0);
3218
3942
  else this.bufferLeft = Math.max(0, this.bufferLeft - dt);
3219
- const wantsJump = this.bufferLeft > 0 || pressedJump;
3943
+ let wantsJump = this.bufferLeft > 0 || pressedJump;
3944
+ if (staggered) wantsJump = false;
3945
+ const wantCrouch = this.crouchHeight > 0 && this.readAction("crouchAction", () => input.isPressed(this.crouchAction), false, "The character will never crouch.");
3946
+ if (!this.crouching) {
3947
+ const standing = halfHeight * 2;
3948
+ if (wantCrouch && this.grounded && this.crouchHeight < standing) {
3949
+ this.standingHeight = standing;
3950
+ this.crouching = true;
3951
+ this.setHeight(body, this.crouchHeight, standing);
3952
+ }
3953
+ } else {
3954
+ if (!wantCrouch) {
3955
+ const rise = this.standingHeight - this.crouchHeight;
3956
+ const top = pos[1] + this.crouchHeight / 2 + radius - .02;
3957
+ if (!physics.castRay([
3958
+ pos[0],
3959
+ top,
3960
+ pos[2]
3961
+ ], [
3962
+ 0,
3963
+ 1,
3964
+ 0
3965
+ ], rise + .06, body)) {
3966
+ this.crouching = false;
3967
+ this.setHeight(body, this.standingHeight, this.crouchHeight);
3968
+ }
3969
+ }
3970
+ if (this.crouching) {
3971
+ wantsJump = false;
3972
+ this.bufferLeft = 0;
3973
+ }
3974
+ }
3220
3975
  const wall = this.wallNormal;
3221
- if (wantsJump && !this.grounded && wall && this.wallJumpImpulse.some((v) => v !== 0)) {
3976
+ if (wantsJump && onLadder && ladder) {
3977
+ const at = worldOf$1(ladder);
3978
+ let ax = pos[0] - at[0];
3979
+ let az = pos[2] - at[2];
3980
+ const len = Math.hypot(ax, az);
3981
+ if (len > .001) {
3982
+ ax /= len;
3983
+ az /= len;
3984
+ } else {
3985
+ ax = -mx;
3986
+ az = -mz;
3987
+ }
3988
+ body.linearVelocity = [
3989
+ ax * 2,
3990
+ 2.5,
3991
+ az * 2
3992
+ ];
3993
+ vel[0] = ax * 2;
3994
+ vel[1] = 2.5;
3995
+ vel[2] = az * 2;
3996
+ this.onLadder = false;
3997
+ this.ladderLockout = LADDER_LOCKOUT;
3998
+ this.bufferLeft = 0;
3999
+ this.rising = false;
4000
+ this.jumping = true;
4001
+ this.jumpsUsed = this.maxJumps;
4002
+ } else if (wantsJump && !this.grounded && wall && this.wallJumpImpulse.some((v) => v !== 0)) {
3222
4003
  const away = this.wallJumpImpulse[0] ?? 0;
3223
4004
  const up = this.wallJumpImpulse[1] ?? 0;
3224
4005
  body.linearVelocity = [
@@ -3236,8 +4017,10 @@ var CharacterController3D = class extends Node3D {
3236
4017
  this.jumping = true;
3237
4018
  this.wallLockout = WALL_LOCKOUT_SECONDS;
3238
4019
  this.emit("wallJumped", wall);
3239
- } else if (wantsJump && (footed || this.coyoteLeft > 0 || this.jumpsUsed < this.maxJumps)) {
3240
- const jumpVel = this.jumpVelocity * (1 + (this.sprintJumpMultiplier - 1) * intensity);
4020
+ } else if (wantsJump && !onLadder && !(swimming && this.submerged) && (footed || swimming || this.coyoteLeft > 0 || this.jumpsUsed < this.maxJumps)) {
4021
+ const hopVel = swimming ? Math.max(this.jumpVelocity, Math.sqrt(2 * this.sceneGravity() * (this.effectiveSwimDepth() + halfHeight + this.stepHeight + .05))) : 0;
4022
+ const jumpVel = swimming ? hopVel : this.jumpVelocity * (1 + (this.sprintJumpMultiplier - 1) * intensity);
4023
+ if (swimming) this.swimHop = true;
3241
4024
  body.linearVelocity = [
3242
4025
  vel[0],
3243
4026
  jumpVel,
@@ -3265,6 +4048,7 @@ var CharacterController3D = class extends Node3D {
3265
4048
  }
3266
4049
  }
3267
4050
  const sliding = this.wallSlideSpeed > 0 && !footed && this.wallNormal !== null && (vel[1] ?? 0) < 0;
4051
+ const atSlideCap = sliding && (vel[1] ?? 0) <= -this.wallSlideSpeed + .01;
3268
4052
  if (sliding && (vel[1] ?? 0) < -this.wallSlideSpeed) {
3269
4053
  body.linearVelocity = [
3270
4054
  vel[0],
@@ -3273,7 +4057,8 @@ var CharacterController3D = class extends Node3D {
3273
4057
  ];
3274
4058
  vel[1] = -this.wallSlideSpeed;
3275
4059
  }
3276
- if ((vel[1] ?? 0) < FALLING_MAX_VEL) body.gravityScale = 0;
4060
+ if (swimming || climbing || atSlideCap || this.onLadder) body.gravityScale = 0;
4061
+ else if ((vel[1] ?? 0) < FALLING_MAX_VEL) body.gravityScale = 0;
3277
4062
  else if ((vel[1] ?? 0) < 0 && !footed) body.gravityScale = sliding ? 1 : this.fallGravity;
3278
4063
  else body.gravityScale = 1;
3279
4064
  if (this.view === "side" && Math.abs(pos[2]) > .01) body.position = [
@@ -3281,23 +4066,46 @@ var CharacterController3D = class extends Node3D {
3281
4066
  body.position[1] ?? 0,
3282
4067
  0
3283
4068
  ];
3284
- const next = movementState(!(this.jumping || !this.grounded), moving ? intensity : 0);
4069
+ if (dashing && !swimming) body.linearVelocity = [
4070
+ this.dashDir[0] * this.dashSpeed,
4071
+ vel[1] ?? 0,
4072
+ this.dashDir[1] * this.dashSpeed
4073
+ ];
4074
+ if (kick.length > 0) {
4075
+ const cur = body.linearVelocity;
4076
+ body.linearVelocity = [
4077
+ kick[0] ?? 0,
4078
+ lift !== 0 ? lift : cur[1] ?? 0,
4079
+ kick[2] ?? 0
4080
+ ];
4081
+ }
4082
+ const airborne = this.jumping || !this.grounded;
4083
+ const next = dashing && !swimming ? "dash" : swimming ? "swim" : this.onLadder || climbing ? "climb" : this.crouching && !airborne ? moving ? "sneak" : "crouch" : movementState(!airborne, moving ? intensity : 0);
3285
4084
  if (next !== this.state) {
3286
4085
  this.state = next;
3287
- const clip = this.animations[next];
4086
+ let clip = this.animations[next];
4087
+ if (!clip) for (const alt of CLIP_FALLBACK[next]) {
4088
+ const c = this.animations[alt];
4089
+ if (c) {
4090
+ clip = c;
4091
+ break;
4092
+ }
4093
+ }
3288
4094
  if (clip && this.skinPath !== "") {
3289
4095
  const skin = this.getNodeOrNull(this.skinPath);
3290
4096
  if (skin && "animation" in skin) skin.animation = clip;
3291
4097
  }
3292
4098
  this.emit("movementStateChanged", next);
3293
4099
  }
3294
- if (moving && this.skinPath !== "") {
4100
+ if ((moving || lockDir) && this.skinPath !== "") {
3295
4101
  const skin = this.getNodeOrNull(this.skinPath);
3296
4102
  if (skin) {
3297
- const target = Math.atan2(mx, mz) * RAD2DEG + this.skinYawOffset;
4103
+ const fx = lockDir ? lockDir[0] : mx;
4104
+ const fz = lockDir ? lockDir[1] : mz;
4105
+ const target = Math.atan2(fx, fz) * RAD2DEG$3 + this.skinYawOffset;
3298
4106
  const current = skin.rotation[1] ?? 0;
3299
4107
  let delta = (target - current + 540) % 360 - 180;
3300
- const step = this.turnSpeed * dt * RAD2DEG;
4108
+ const step = this.turnSpeed * dt * RAD2DEG$3;
3301
4109
  if (Math.abs(delta) > step) delta = Math.sign(delta) * step;
3302
4110
  skin.rotation = [
3303
4111
  skin.rotation[0] ?? 0,
@@ -3310,6 +4118,84 @@ var CharacterController3D = class extends Node3D {
3310
4118
  /** Actions already reported missing, so a per-frame read says it once. */
3311
4119
  reportedActions = /* @__PURE__ */ new Set();
3312
4120
  /**
4121
+ * The water over a world point — `[wave surface, still level]` — or null
4122
+ * when no `Water3D`'s footprint covers it. The tree is re-scanned once a
4123
+ * second, so a lake streamed in later is found; the wave height is the CPU
4124
+ * twin of what is drawn, so a swimmer rides the swell the way a raft does.
4125
+ */
4126
+ /** The ladder the body is overlapping, if any — an Area3D in `ladderGroup`. */
4127
+ ladderUnder(body) {
4128
+ const physics = body._physics3d;
4129
+ if (!physics) return null;
4130
+ if (this.ladderScanIn <= 0) {
4131
+ this.ladders.length = 0;
4132
+ collectAreas(this.getRoot(), this.ladderGroup, this.ladders);
4133
+ this.ladderScanIn = WATER_SCAN_STEPS;
4134
+ }
4135
+ this.ladderScanIn -= 1;
4136
+ for (const ladder of this.ladders) {
4137
+ if (ladder.tree === null) continue;
4138
+ if (physics.overlapping(ladder).includes(body)) return ladder;
4139
+ }
4140
+ return null;
4141
+ }
4142
+ /**
4143
+ * Swap the capsule's height and keep the feet where they are: the body
4144
+ * drops (or rises) by half the difference in the same step, the skin's
4145
+ * authored offset moves the other way so the model's feet stay on the
4146
+ * floor, and the adapter rebuilds the body at the new place next step.
4147
+ */
4148
+ setHeight(body, next, from) {
4149
+ body.collider = {
4150
+ ...body.collider,
4151
+ height: next
4152
+ };
4153
+ const d = (from - next) / 2;
4154
+ const p = body.position;
4155
+ body.position = [
4156
+ p[0] ?? 0,
4157
+ (p[1] ?? 0) - d,
4158
+ p[2] ?? 0
4159
+ ];
4160
+ if (this.skinPath === "") return;
4161
+ const skin = this.getNodeOrNull(this.skinPath);
4162
+ if (!skin) return;
4163
+ if (this.skinRest === null) this.skinRest = skin.position[1] ?? 0;
4164
+ const lift = this.crouching ? (this.standingHeight - this.crouchHeight) / 2 : 0;
4165
+ skin.position = [
4166
+ skin.position[0] ?? 0,
4167
+ this.skinRest + lift,
4168
+ skin.position[2] ?? 0
4169
+ ];
4170
+ }
4171
+ sceneGravity() {
4172
+ const g = this.tree?.engine?.scene?.physics?.gravity;
4173
+ return Math.abs(g?.[1] ?? STANDARD_G) || STANDARD_G;
4174
+ }
4175
+ /** `swimDepth`, or what it is derived to be for this body (see the prop). */
4176
+ effectiveSwimDepth() {
4177
+ if (this.swimDepth !== null) return this.swimDepth;
4178
+ const body = this.parent;
4179
+ const radius = body?.collider?.radius ?? .32;
4180
+ const halfHeight = (body?.collider?.height ?? 1) / 2;
4181
+ return Math.max(halfHeight + radius - HEAD_ABOVE_WATER, .05);
4182
+ }
4183
+ surfaceAt(x, z) {
4184
+ if (this.waterScanIn <= 0) {
4185
+ this.waters.length = 0;
4186
+ collectWaters(this.getRoot(), this.waters);
4187
+ this.waterScanIn = WATER_SCAN_STEPS;
4188
+ }
4189
+ this.waterScanIn -= 1;
4190
+ for (const water of this.waters) {
4191
+ if (water.tree === null) continue;
4192
+ const still = water._waterYAt(x, z);
4193
+ if (still === null) continue;
4194
+ return [water.heightAt(x, z), still];
4195
+ }
4196
+ return null;
4197
+ }
4198
+ /**
3313
4199
  * Read an input action this controller is allowed to be missing.
3314
4200
  *
3315
4201
  * Tolerated because a game may declare no sprint key and no jump; reported
@@ -3333,6 +4219,7 @@ var CharacterController3D = class extends Node3D {
3333
4219
  }, void 0, consequence);
3334
4220
  }
3335
4221
  update(dt) {
4222
+ if (!this.enabled) return;
3336
4223
  const input = this.tree?.engine?.input;
3337
4224
  if (!input) return;
3338
4225
  if (this.mouseLook) {
@@ -3346,11 +4233,12 @@ var CharacterController3D = class extends Node3D {
3346
4233
  const wheel = input.wheelDelta();
3347
4234
  if (wheel !== 0 && this.zoomMax > this.zoomMin) this.camDistance = clamp(this.camDistance + wheel * LOOK_SPEED, this.zoomMin, this.zoomMax);
3348
4235
  }
4236
+ if (this.camera === "none") return;
3349
4237
  const camera = this.findCamera();
3350
4238
  if (!camera) return;
3351
4239
  const body = this.parent instanceof RigidBody3D ? this.parent : null;
3352
4240
  if (!body) return;
3353
- const playerPos = worldOf(body);
4241
+ const playerPos = worldOf$1(body);
3354
4242
  const pivotRate = this.view === "free" ? 11 : 1e3;
3355
4243
  const pk = 1 - Math.exp(-pivotRate * dt);
3356
4244
  const pivot = this.pivot ?? [
@@ -3404,9 +4292,9 @@ var CharacterController3D = class extends Node3D {
3404
4292
  eulerScratch.setFromQuaternion(quatScratch, "XYZ");
3405
4293
  }
3406
4294
  camera.rotation = [
3407
- eulerScratch.x * RAD2DEG,
3408
- eulerScratch.y * RAD2DEG,
3409
- eulerScratch.z * RAD2DEG
4295
+ eulerScratch.x * RAD2DEG$3,
4296
+ eulerScratch.y * RAD2DEG$3,
4297
+ eulerScratch.z * RAD2DEG$3
3410
4298
  ];
3411
4299
  }
3412
4300
  pivot = null;
@@ -3428,9 +4316,368 @@ var CharacterController3D = class extends Node3D {
3428
4316
  return this.cameraCache;
3429
4317
  }
3430
4318
  };
3431
- function clamp(v, lo, hi) {
3432
- return Math.min(hi, Math.max(lo, v));
3433
- }
4319
+ function clamp(v, lo, hi) {
4320
+ return Math.min(hi, Math.max(lo, v));
4321
+ }
4322
+ function collectAreas(node, group, out) {
4323
+ if (node instanceof Area3D && node.isInGroup(group)) out.push(node);
4324
+ for (const child of node.children) collectAreas(child, group, out);
4325
+ }
4326
+ function collectWaters(node, out) {
4327
+ if (node instanceof Water3D) out.push(node);
4328
+ for (const child of node.children) collectWaters(child, out);
4329
+ }
4330
+ function worldOf$1(node) {
4331
+ let x = 0;
4332
+ let y = 0;
4333
+ let z = 0;
4334
+ for (let n = node; n; n = n.parent) {
4335
+ if (!(n instanceof Node3D)) break;
4336
+ x += n.position[0] ?? 0;
4337
+ y += n.position[1] ?? 0;
4338
+ z += n.position[2] ?? 0;
4339
+ }
4340
+ return [
4341
+ x,
4342
+ y,
4343
+ z
4344
+ ];
4345
+ }
4346
+ /**
4347
+ * The EDGE, not the level.
4348
+ *
4349
+ * Jump used to read `isPressed`, so holding the button re-jumped on every frame
4350
+ * the raycast found ground — an auto-hop nobody asked for, and one that makes
4351
+ * coyote time incoherent (a held button would re-fire through the whole
4352
+ * window). Undeclared actions are tolerated, and reported once.
4353
+ */
4354
+ /** A node's world x/z — the platform may itself be parented to something. */
4355
+ function groundXZ(node) {
4356
+ const world = worldPosition(node);
4357
+ return [world[0] ?? 0, world[2] ?? 0];
4358
+ }
4359
+ /** An XZ offset turned by the rotation that takes `qFrom` to `qNow` (quaternions `[x,y,z,w]`). */
4360
+ function rotateXZByDelta(qNow, qFrom, ox, oz) {
4361
+ const [fx, fy, fz, fw] = [
4362
+ -(qFrom[0] ?? 0),
4363
+ -(qFrom[1] ?? 0),
4364
+ -(qFrom[2] ?? 0),
4365
+ qFrom[3] ?? 1
4366
+ ];
4367
+ const [nx, ny, nz, nw] = [
4368
+ qNow[0] ?? 0,
4369
+ qNow[1] ?? 0,
4370
+ qNow[2] ?? 0,
4371
+ qNow[3] ?? 1
4372
+ ];
4373
+ const dx = nw * fx + nx * fw + ny * fz - nz * fy;
4374
+ const dy = nw * fy - nx * fz + ny * fw + nz * fx;
4375
+ const dz = nw * fz + nx * fy - ny * fx + nz * fw;
4376
+ const dw = nw * fw - nx * fx - ny * fy - nz * fz;
4377
+ const oy = 0;
4378
+ const cx = dy * oz - dz * oy;
4379
+ const cy = dz * ox - dx * oz;
4380
+ const cz = dx * oy - dy * ox;
4381
+ return [
4382
+ ox + 2 * (dw * cx + (dy * cz - dz * cy)),
4383
+ oy + 2 * (dw * cy + (dz * cx - dx * cz)),
4384
+ oz + 2 * (dw * cz + (dx * cy - dy * cx))
4385
+ ];
4386
+ }
4387
+ function hasPositionArray(n) {
4388
+ return Array.isArray(n.position);
4389
+ }
4390
+ //#endregion
4391
+ //#region src/3d/nodes/flock-3d.ts
4392
+ const RAD2DEG$2 = 180 / Math.PI;
4393
+ /**
4394
+ * A flock — gulls over a harbor, fish under a boat, bats out of a cave — as
4395
+ * ONE instanced mesh that flies its own instances.
4396
+ *
4397
+ * ```json
4398
+ * { "name": "Gulls", "type": "Flock3D",
4399
+ * "props": { "position": [0, 14, 0], "count": 40, "radius": 30, "height": 6,
4400
+ * "mesh": "box", "size": [0.5, 0.08, 0.3], "follow": "/root/Boat" } }
4401
+ * ```
4402
+ *
4403
+ * Boids (Reynolds 1987): each instance steers away from neighbours closer
4404
+ * than `separation`, toward the average heading of those within `sight`
4405
+ * (`alignment`) and toward their centre (`cohesion`), turns back inside an
4406
+ * ellipsoid `radius` wide and `height` tall around this node — or around the
4407
+ * node at `follow`, offset by this node's position — and never turns faster
4408
+ * than `turn`. Every instance flies at `speed`, +z forward, so a mesh that is
4409
+ * longer along z reads as a body with a beak.
4410
+ *
4411
+ * No physics bodies, no behaviour per bird: forty birds cost one draw call
4412
+ * and forty-squared distance checks a frame. Seeded from `engine.rng`, so a
4413
+ * seeded run flies the same flight. `boids()` answers headless.
4414
+ */
4415
+ var Flock3D = class extends InstancedMesh3D {
4416
+ static typeName = "Flock3D";
4417
+ static props = {
4418
+ ...InstancedMesh3D.props,
4419
+ /** How many. Live — a change reseeds the flock. */
4420
+ count: {
4421
+ default: 30,
4422
+ range: { min: 0 }
4423
+ },
4424
+ /** The bound they turn back from: half-width (m) around the centre… */
4425
+ radius: {
4426
+ default: 20,
4427
+ range: { min: .1 }
4428
+ },
4429
+ /** …and half-height (m). A flat ellipsoid keeps fish under the surface. */
4430
+ height: {
4431
+ default: 8,
4432
+ range: { min: .1 }
4433
+ },
4434
+ /** Every bird's pace (m/s). */
4435
+ speed: {
4436
+ default: 6,
4437
+ range: { min: 0 }
4438
+ },
4439
+ /** The sharpest turn (rad/s) — lower is lazier, gull-like. */
4440
+ turn: {
4441
+ default: 2.5,
4442
+ range: { min: 0 }
4443
+ },
4444
+ /** Closer than this (m) and two birds push apart. */
4445
+ separation: {
4446
+ default: 1.5,
4447
+ range: { min: 0 }
4448
+ },
4449
+ /** How far (m) a bird sees its neighbours for alignment and cohesion. */
4450
+ sight: {
4451
+ default: 5,
4452
+ range: { min: 0 }
4453
+ },
4454
+ /** Weight of matching the neighbours' heading (0 = none). */
4455
+ alignment: {
4456
+ default: 1,
4457
+ range: { min: 0 }
4458
+ },
4459
+ /** Weight of drifting toward the neighbours' centre (0 = none). */
4460
+ cohesion: {
4461
+ default: 1,
4462
+ range: { min: 0 }
4463
+ },
4464
+ /** A node the flock's centre follows, offset by this node's position; '' = this node. */
4465
+ follow: {
4466
+ default: "",
4467
+ nodePath: true
4468
+ }
4469
+ };
4470
+ count = 30;
4471
+ radius = 20;
4472
+ height = 8;
4473
+ speed = 6;
4474
+ turn = 2.5;
4475
+ separation = 1.5;
4476
+ sight = 5;
4477
+ alignment = 1;
4478
+ cohesion = 1;
4479
+ follow = "";
4480
+ px = new Float64Array(0);
4481
+ py = new Float64Array(0);
4482
+ pz = new Float64Array(0);
4483
+ vx = new Float64Array(0);
4484
+ vy = new Float64Array(0);
4485
+ vz = new Float64Array(0);
4486
+ rows = [];
4487
+ static validateJson(node) {
4488
+ InstancedMesh3D.validateJson(node);
4489
+ if (node.collider !== null) throw new IncantoError("BAD_FORMAT", `Flock3D '${node.name}': birds are not solid — a flock takes no "collider" (it flies; a static hull would stay where the first frame put it).`, { prop: "collider" });
4490
+ }
4491
+ /** Every bird right now, in world metres and m/s. */
4492
+ boids() {
4493
+ const [ox, oy, oz] = worldOf(this);
4494
+ const out = [];
4495
+ for (let i = 0; i < this.px.length; i++) out.push({
4496
+ x: (this.px[i] ?? 0) + ox,
4497
+ y: (this.py[i] ?? 0) + oy,
4498
+ z: (this.pz[i] ?? 0) + oz,
4499
+ vx: this.vx[i] ?? 0,
4500
+ vy: this.vy[i] ?? 0,
4501
+ vz: this.vz[i] ?? 0
4502
+ });
4503
+ return out;
4504
+ }
4505
+ update(dt) {
4506
+ const engine = this.tree?.engine;
4507
+ if (!engine) return;
4508
+ const n = Math.max(0, Math.floor(this.count));
4509
+ if (this.px.length !== n) this.seed(engine.rng, n);
4510
+ if (n === 0) {
4511
+ if (this.transforms.length !== 0) this.transforms = [];
4512
+ return;
4513
+ }
4514
+ const [cx, cy, cz] = this.centre();
4515
+ const step = Math.min(dt, 1 / 20);
4516
+ const sight2 = this.sight * this.sight;
4517
+ const sep2 = this.separation * this.separation;
4518
+ const maxTurn = this.turn * step;
4519
+ const { px, py, pz, vx, vy, vz } = this;
4520
+ for (let i = 0; i < n; i++) {
4521
+ const x = px[i] ?? 0;
4522
+ const y = py[i] ?? 0;
4523
+ const z = pz[i] ?? 0;
4524
+ let sx = 0;
4525
+ let sy = 0;
4526
+ let sz = 0;
4527
+ let ax = 0;
4528
+ let ay = 0;
4529
+ let az = 0;
4530
+ let mx = 0;
4531
+ let my = 0;
4532
+ let mz = 0;
4533
+ let seen = 0;
4534
+ for (let j = 0; j < n; j++) {
4535
+ if (j === i) continue;
4536
+ const dx = (px[j] ?? 0) - x;
4537
+ const dy = (py[j] ?? 0) - y;
4538
+ const dz = (pz[j] ?? 0) - z;
4539
+ const d2 = dx * dx + dy * dy + dz * dz;
4540
+ if (d2 > sight2) continue;
4541
+ seen += 1;
4542
+ ax += vx[j] ?? 0;
4543
+ ay += vy[j] ?? 0;
4544
+ az += vz[j] ?? 0;
4545
+ mx += dx;
4546
+ my += dy;
4547
+ mz += dz;
4548
+ if (d2 < sep2 && d2 > 1e-9) {
4549
+ const inv = 1 / d2;
4550
+ sx -= dx * inv;
4551
+ sy -= dy * inv;
4552
+ sz -= dz * inv;
4553
+ }
4554
+ }
4555
+ let fx = sx * this.speed * 2;
4556
+ let fy = sy * this.speed * 2;
4557
+ let fz = sz * this.speed * 2;
4558
+ if (seen > 0) {
4559
+ fx += (ax / seen - (vx[i] ?? 0)) * this.alignment / .5;
4560
+ fy += (ay / seen - (vy[i] ?? 0)) * this.alignment / .5;
4561
+ fz += (az / seen - (vz[i] ?? 0)) * this.alignment / .5;
4562
+ fx += mx / seen * this.cohesion * 2;
4563
+ fy += my / seen * this.cohesion * 2;
4564
+ fz += mz / seen * this.cohesion * 2;
4565
+ }
4566
+ const ex = (x - cx) / this.radius;
4567
+ const ey = (y - cy) / this.height;
4568
+ const ez = (z - cz) / this.radius;
4569
+ const m = Math.sqrt(ex * ex + ey * ey + ez * ez);
4570
+ if (m > .6) {
4571
+ const k = (m - .6) / .4 * this.speed * 8;
4572
+ const gx = ex / this.radius;
4573
+ const gy = ey / this.height;
4574
+ const gz = ez / this.radius;
4575
+ const g = Math.hypot(gx, gy, gz) || 1;
4576
+ fx -= gx / g * k;
4577
+ fy -= gy / g * k;
4578
+ fz -= gz / g * k;
4579
+ }
4580
+ let nx = (vx[i] ?? 0) + fx * step;
4581
+ let ny = (vy[i] ?? 0) + fy * step;
4582
+ let nz = (vz[i] ?? 0) + fz * step;
4583
+ const ov = Math.hypot(vx[i] ?? 0, vy[i] ?? 0, vz[i] ?? 0) || 1;
4584
+ const nv = Math.hypot(nx, ny, nz) || 1;
4585
+ const cos = ((vx[i] ?? 0) * nx + (vy[i] ?? 0) * ny + (vz[i] ?? 0) * nz) / (ov * nv);
4586
+ const angle = Math.acos(Math.max(-1, Math.min(1, cos)));
4587
+ if (angle > maxTurn && angle > 1e-6) {
4588
+ const t = maxTurn / angle;
4589
+ nx = (vx[i] ?? 0) / ov * (1 - t) + nx / nv * t;
4590
+ ny = (vy[i] ?? 0) / ov * (1 - t) + ny / nv * t;
4591
+ nz = (vz[i] ?? 0) / ov * (1 - t) + nz / nv * t;
4592
+ }
4593
+ const len = Math.hypot(nx, ny, nz) || 1;
4594
+ vx[i] = nx / len * this.speed;
4595
+ vy[i] = ny / len * this.speed;
4596
+ vz[i] = nz / len * this.speed;
4597
+ }
4598
+ for (let i = 0; i < n; i++) {
4599
+ px[i] = (px[i] ?? 0) + (vx[i] ?? 0) * step;
4600
+ py[i] = (py[i] ?? 0) + (vy[i] ?? 0) * step;
4601
+ pz[i] = (pz[i] ?? 0) + (vz[i] ?? 0) * step;
4602
+ let row = this.rows[i];
4603
+ if (!row) {
4604
+ row = [
4605
+ 0,
4606
+ 0,
4607
+ 0,
4608
+ 0,
4609
+ 1
4610
+ ];
4611
+ this.rows[i] = row;
4612
+ }
4613
+ row[0] = px[i] ?? 0;
4614
+ row[1] = py[i] ?? 0;
4615
+ row[2] = pz[i] ?? 0;
4616
+ row[3] = Math.atan2(vx[i] ?? 0, vz[i] ?? 0) * RAD2DEG$2;
4617
+ }
4618
+ this.transforms = this.rows;
4619
+ }
4620
+ /** The roam centre in this node's local frame: the followed node's offset, or the origin. */
4621
+ centre() {
4622
+ if (this.follow === "") return [
4623
+ 0,
4624
+ 0,
4625
+ 0
4626
+ ];
4627
+ const target = this.getNodeOrNull(this.follow);
4628
+ if (!(target instanceof Node3D)) return [
4629
+ 0,
4630
+ 0,
4631
+ 0
4632
+ ];
4633
+ const [tx, ty, tz] = worldOf(target);
4634
+ const [ox, oy, oz] = worldOf(this);
4635
+ const [lx, ly, lz] = [
4636
+ this.position[0] ?? 0,
4637
+ this.position[1] ?? 0,
4638
+ this.position[2] ?? 0
4639
+ ];
4640
+ return [
4641
+ tx - ox + lx,
4642
+ ty - oy + ly,
4643
+ tz - oz + lz
4644
+ ];
4645
+ }
4646
+ /** Scatter `n` birds through the bound with random headings — engine-seeded. */
4647
+ seed(rng, n) {
4648
+ this.px = new Float64Array(n);
4649
+ this.py = new Float64Array(n);
4650
+ this.pz = new Float64Array(n);
4651
+ this.vx = new Float64Array(n);
4652
+ this.vy = new Float64Array(n);
4653
+ this.vz = new Float64Array(n);
4654
+ this.rows.length = n;
4655
+ const [cx, cy, cz] = this.centre();
4656
+ for (let i = 0; i < n; i++) {
4657
+ const u = rng.next();
4658
+ const r = .6 * Math.cbrt(u);
4659
+ const theta = rng.next() * Math.PI * 2;
4660
+ const phi = Math.acos(2 * rng.next() - 1);
4661
+ this.px[i] = cx + r * this.radius * Math.sin(phi) * Math.cos(theta);
4662
+ this.py[i] = cy + r * this.height * Math.cos(phi);
4663
+ this.pz[i] = cz + r * this.radius * Math.sin(phi) * Math.sin(theta);
4664
+ const h = rng.next() * Math.PI * 2;
4665
+ const pitch = (rng.next() - .5) * .4;
4666
+ this.vx[i] = Math.cos(pitch) * Math.sin(h) * this.speed;
4667
+ this.vy[i] = Math.sin(pitch) * this.speed;
4668
+ this.vz[i] = Math.cos(pitch) * Math.cos(h) * this.speed;
4669
+ this.rows[i] = [
4670
+ this.px[i] ?? 0,
4671
+ this.py[i] ?? 0,
4672
+ this.pz[i] ?? 0,
4673
+ h * RAD2DEG$2,
4674
+ 1
4675
+ ];
4676
+ }
4677
+ this.transforms = this.rows;
4678
+ }
4679
+ };
4680
+ /** Translation-only world position (the engine's physics rule). */
3434
4681
  function worldOf(node) {
3435
4682
  let x = 0;
3436
4683
  let y = 0;
@@ -3447,19 +4694,6 @@ function worldOf(node) {
3447
4694
  z
3448
4695
  ];
3449
4696
  }
3450
- /**
3451
- * The EDGE, not the level.
3452
- *
3453
- * Jump used to read `isPressed`, so holding the button re-jumped on every frame
3454
- * the raycast found ground — an auto-hop nobody asked for, and one that makes
3455
- * coyote time incoherent (a held button would re-fire through the whole
3456
- * window). Undeclared actions are tolerated, and reported once.
3457
- */
3458
- /** A node's world x/z — the platform may itself be parented to something. */
3459
- function groundXZ(node) {
3460
- const world = worldPosition(node);
3461
- return [world[0] ?? 0, world[2] ?? 0];
3462
- }
3463
4697
  //#endregion
3464
4698
  //#region src/3d/vegetation/grass-placement.ts
3465
4699
  const NOISE_SCALE = .15;
@@ -7625,7 +8859,19 @@ var Particles3D = class Particles3D extends Node3D {
7625
8859
  options: ["add", "normal"]
7626
8860
  },
7627
8861
  depthTest: { default: true },
7628
- maxParticles: { default: 256 }
8862
+ maxParticles: { default: 256 },
8863
+ /** Half-extents (m) of a box the particles are born in, around the node — a cloud, a dusty field; `[0,0,0]` = a point. */
8864
+ emitBox: { default: [
8865
+ 0,
8866
+ 0,
8867
+ 0
8868
+ ] },
8869
+ /** A steady velocity (px/s, y+ = down like `gravity`) every particle is born with — wind on smoke. */
8870
+ drift: { default: [
8871
+ 0,
8872
+ 0,
8873
+ 0
8874
+ ] }
7629
8875
  };
7630
8876
  preset = "custom";
7631
8877
  emitting = true;
@@ -7682,6 +8928,20 @@ var Particles3D = class Particles3D extends Node3D {
7682
8928
  });
7683
8929
  }
7684
8930
  sim = null;
8931
+ emitBox = [
8932
+ 0,
8933
+ 0,
8934
+ 0
8935
+ ];
8936
+ drift = [
8937
+ 0,
8938
+ 0,
8939
+ 0
8940
+ ];
8941
+ /** @internal Forget the sim so the next update rebuilds it from the props (a preset or kind that changed). */
8942
+ _rebuildSim() {
8943
+ this.sim = null;
8944
+ }
7685
8945
  bursted = false;
7686
8946
  finishedEmitted = false;
7687
8947
  /** @internal */
@@ -7702,7 +8962,17 @@ var Particles3D = class Particles3D extends Node3D {
7702
8962
  ],
7703
8963
  drag: this.drag,
7704
8964
  maxParticles: this.maxParticles,
7705
- spreadZ: true
8965
+ spreadZ: true,
8966
+ emitBox: [
8967
+ this.emitBox[0] ?? 0,
8968
+ this.emitBox[1] ?? 0,
8969
+ this.emitBox[2] ?? 0
8970
+ ],
8971
+ drift: [
8972
+ (this.drift[0] ?? 0) * PX_TO_M,
8973
+ (this.drift[1] ?? 0) * PX_TO_M,
8974
+ (this.drift[2] ?? 0) * PX_TO_M
8975
+ ]
7706
8976
  }, rng);
7707
8977
  }
7708
8978
  return this.sim;
@@ -7824,9 +9094,24 @@ var Particles3D = class Particles3D extends Node3D {
7824
9094
  }
7825
9095
  return this.paletteCache;
7826
9096
  }
9097
+ /**
9098
+ * The stamp every particle is drawn with — a soft round dot.
9099
+ *
9100
+ * A subclass overrides it to look like the thing it IS: `Weather3D`'s rain is
9101
+ * a vertical STREAK, because 2,600 round dots read as a dirty lens and not as
9102
+ * a downpour (measured in a browser at intensity 1). The material is rebuilt
9103
+ * when this changes, so a node may return a different stamp over its life.
9104
+ */
9105
+ sprite() {
9106
+ return softSprite();
9107
+ }
9108
+ /** The stamp the current material was built with, so a change can rebuild it. */
9109
+ builtSprite = null;
7827
9110
  syncParticles() {
7828
9111
  const sim = this._ensureSim();
7829
- if (!this.points || (this.positions?.length ?? 0) !== this.maxParticles * 3) {
9112
+ const stamp = this.sprite();
9113
+ if (!this.points || (this.positions?.length ?? 0) !== this.maxParticles * 3 || this.builtSprite !== stamp) {
9114
+ this.builtSprite = stamp;
7830
9115
  this.points?.removeFromParent();
7831
9116
  this.positions = new Float32Array(this.maxParticles * 3);
7832
9117
  this.colors = new Float32Array(this.maxParticles * 4);
@@ -7835,7 +9120,7 @@ var Particles3D = class Particles3D extends Node3D {
7835
9120
  geometry.setAttribute("color", new BufferAttribute(this.colors, 4));
7836
9121
  const material = new PointsMaterial({
7837
9122
  size: (this.sizeStart + this.sizeEnd) / 2 * PX_TO_M,
7838
- map: softSprite(),
9123
+ map: this.sprite(),
7839
9124
  vertexColors: true,
7840
9125
  transparent: true,
7841
9126
  depthWrite: false,
@@ -7942,6 +9227,356 @@ function softSprite() {
7942
9227
  return tex;
7943
9228
  }
7944
9229
  //#endregion
9230
+ //#region src/3d/nodes/ragdoll-3d.ts
9231
+ const RAD2DEG$1 = 180 / Math.PI;
9232
+ const DEG2RAD = Math.PI / 180;
9233
+ /**
9234
+ * The Mixamo rig every agent8 character wears, as ten capsules. Parents
9235
+ * come before children so a joint's target is already in the tree. A limb
9236
+ * whose `to` bone is missing is skipped — except the head, which becomes a
9237
+ * 0.2 m ball when the rig has no `HeadTop_End`.
9238
+ */
9239
+ const RIG = [
9240
+ {
9241
+ name: "Torso",
9242
+ from: "Hips",
9243
+ to: "Neck",
9244
+ radius: .14,
9245
+ share: .32,
9246
+ parent: null
9247
+ },
9248
+ {
9249
+ name: "Head",
9250
+ from: "Head",
9251
+ to: "HeadTop_End",
9252
+ radius: .1,
9253
+ share: .08,
9254
+ parent: "Torso"
9255
+ },
9256
+ {
9257
+ name: "LeftUpperArm",
9258
+ from: "LeftArm",
9259
+ to: "LeftForeArm",
9260
+ radius: .05,
9261
+ share: .05,
9262
+ parent: "Torso"
9263
+ },
9264
+ {
9265
+ name: "LeftForearm",
9266
+ from: "LeftForeArm",
9267
+ to: "LeftHand",
9268
+ radius: .045,
9269
+ share: .04,
9270
+ parent: "LeftUpperArm"
9271
+ },
9272
+ {
9273
+ name: "RightUpperArm",
9274
+ from: "RightArm",
9275
+ to: "RightForeArm",
9276
+ radius: .05,
9277
+ share: .05,
9278
+ parent: "Torso"
9279
+ },
9280
+ {
9281
+ name: "RightForearm",
9282
+ from: "RightForeArm",
9283
+ to: "RightHand",
9284
+ radius: .045,
9285
+ share: .04,
9286
+ parent: "RightUpperArm"
9287
+ },
9288
+ {
9289
+ name: "LeftThigh",
9290
+ from: "LeftUpLeg",
9291
+ to: "LeftLeg",
9292
+ radius: .08,
9293
+ share: .12,
9294
+ parent: "Torso"
9295
+ },
9296
+ {
9297
+ name: "LeftShin",
9298
+ from: "LeftLeg",
9299
+ to: "LeftFoot",
9300
+ radius: .06,
9301
+ share: .07,
9302
+ parent: "LeftThigh"
9303
+ },
9304
+ {
9305
+ name: "RightThigh",
9306
+ from: "RightUpLeg",
9307
+ to: "RightLeg",
9308
+ radius: .08,
9309
+ share: .12,
9310
+ parent: "Torso"
9311
+ },
9312
+ {
9313
+ name: "RightShin",
9314
+ from: "RightLeg",
9315
+ to: "RightFoot",
9316
+ radius: .06,
9317
+ share: .07,
9318
+ parent: "RightThigh"
9319
+ }
9320
+ ];
9321
+ const scratchM = new Matrix4();
9322
+ const scratchM2 = new Matrix4();
9323
+ const scratchQ = new Quaternion();
9324
+ const scratchE = new Euler();
9325
+ const scratchV = new Vector3();
9326
+ const scratchS = new Vector3(1, 1, 1);
9327
+ /**
9328
+ * A ragdoll for a skinned model — the death that falls.
9329
+ *
9330
+ * ```json
9331
+ * { "name": "Body", "type": "CharacterBody3D", "children": [
9332
+ * { "name": "Skin", "type": "ModelInstance3D", "props": { "model": "$base" } },
9333
+ * { "name": "Ragdoll", "type": "Ragdoll3D", "props": { "target": "../Skin", "lifetime": 8 } }
9334
+ * ] }
9335
+ * ```
9336
+ *
9337
+ * Idle it is nothing. `activate()` reads the skeleton at `target` (Mixamo
9338
+ * names, forgiving lookup), builds a capsule `RigidBody3D` per limb where the
9339
+ * bones are, joins each to its parent with a spherical `Joint3D`, gives them
9340
+ * the velocity of the body they fell out of, and from then on writes the
9341
+ * bones' world matrices from the limbs every frame — the model crumples,
9342
+ * slides, hangs off the ledge. Wire it from `Health.died`; turn the body's
9343
+ * collider and its controller off beside it. `reset()` puts the skeleton
9344
+ * back under the animation; `lifetime` does that on its own.
9345
+ *
9346
+ * Headless there is no skeleton: `activate(pose)` takes bone positions, so a
9347
+ * test can ask the same questions the game will.
9348
+ */
9349
+ var Ragdoll3D = class extends Node3D {
9350
+ static typeName = "Ragdoll3D";
9351
+ static props = {
9352
+ /** The ModelInstance3D whose skeleton this is. */
9353
+ target: {
9354
+ default: "../Skin",
9355
+ nodePath: true
9356
+ },
9357
+ /** The whole body's mass (kg), shared out by limb. */
9358
+ mass: {
9359
+ default: 70,
9360
+ range: { min: 0 }
9361
+ },
9362
+ /** Angular damping on every limb — how quickly the flailing dies. */
9363
+ damping: {
9364
+ default: 3,
9365
+ range: { min: 0 }
9366
+ },
9367
+ /** Seconds before the ragdoll frees itself and hands the skeleton back; 0 = never. */
9368
+ lifetime: {
9369
+ default: 0,
9370
+ range: { min: 0 }
9371
+ },
9372
+ /** Limbs collide with each other (adjacent ones never do). */
9373
+ collide: { default: true }
9374
+ };
9375
+ static signals = ["activated", "reset"];
9376
+ target = "../Skin";
9377
+ mass = 70;
9378
+ damping = 3;
9379
+ lifetime = 0;
9380
+ collide = true;
9381
+ /** Read-only: built and driving the bones. */
9382
+ active = false;
9383
+ driven = [];
9384
+ limbNodes = [];
9385
+ left = 0;
9386
+ /** Every limb built right now, empty while idle. */
9387
+ limbs() {
9388
+ return this.limbNodes.slice();
9389
+ }
9390
+ /**
9391
+ * Build the ragdoll where the skeleton is (or where `pose` says the bones
9392
+ * are, headless). Returns false when there is nothing to build from.
9393
+ */
9394
+ activate(pose = void 0) {
9395
+ if (this.active) return true;
9396
+ const root = this.getRoot();
9397
+ const model = this.getNodeOrNull(this.target);
9398
+ const bones = /* @__PURE__ */ new Map();
9399
+ const points = {};
9400
+ if (pose) for (const [k, v] of Object.entries(pose)) points[k] = v;
9401
+ else if (model && typeof model.findBone === "function") for (const spec of RIG) for (const name of [spec.from, spec.to]) {
9402
+ if (points[name]) continue;
9403
+ const bone = model.findBone(name);
9404
+ if (!bone) continue;
9405
+ bone.updateWorldMatrix(true, false);
9406
+ scratchV.setFromMatrixPosition(bone.matrixWorld);
9407
+ points[name] = [
9408
+ scratchV.x,
9409
+ scratchV.y,
9410
+ scratchV.z
9411
+ ];
9412
+ bones.set(name, bone);
9413
+ }
9414
+ if (!points.Hips) {
9415
+ diagnose(this.tree?.engine, "warn", `[incanto] Ragdoll3D '${this.getPath()}': nothing to build from — '${this.target}' has no mounted skeleton (or no Hips bone), and no pose was given.`);
9416
+ return false;
9417
+ }
9418
+ const velocity = this.hostVelocity();
9419
+ const built = /* @__PURE__ */ new Map();
9420
+ for (const spec of RIG) {
9421
+ const from = points[spec.from];
9422
+ if (!from) continue;
9423
+ let to = points[spec.to];
9424
+ if (!to) {
9425
+ if (spec.name !== "Head") continue;
9426
+ to = [
9427
+ from[0],
9428
+ from[1] + .2,
9429
+ from[2]
9430
+ ];
9431
+ }
9432
+ const parent = spec.parent ? built.get(spec.parent) : null;
9433
+ if (spec.parent && !parent) continue;
9434
+ const dir = new Vector3(to[0] - from[0], to[1] - from[1], to[2] - from[2]);
9435
+ const len = Math.max(dir.length(), .05);
9436
+ dir.normalize();
9437
+ const centre = new Vector3(from[0] + dir.x * (len / 2), from[1] + dir.y * (len / 2), from[2] + dir.z * (len / 2));
9438
+ const rz = Math.asin(Math.max(-1, Math.min(1, -dir.x)));
9439
+ const cz = Math.cos(rz);
9440
+ const rx = cz > 1e-4 ? Math.atan2(dir.z / cz, dir.y / cz) : 0;
9441
+ const quat = new Quaternion().setFromEuler(new Euler(rx, 0, rz, "XYZ"));
9442
+ const radius = Math.min(spec.radius, len / 2 - .005);
9443
+ const json = {
9444
+ name: `${this.parent?.name ?? "Ragdoll"}${spec.name}`,
9445
+ type: "RigidBody3D",
9446
+ groups: ["ragdoll"],
9447
+ props: {
9448
+ position: [
9449
+ centre.x,
9450
+ centre.y,
9451
+ centre.z
9452
+ ],
9453
+ rotation: [
9454
+ rx * RAD2DEG$1,
9455
+ 0,
9456
+ rz * RAD2DEG$1
9457
+ ],
9458
+ mass: Math.max(.5, this.mass * spec.share),
9459
+ angularDamping: this.damping,
9460
+ linearDamping: .3,
9461
+ friction: .8,
9462
+ collider: {
9463
+ shape: "capsule",
9464
+ radius,
9465
+ height: Math.max(.02, len - 2 * radius)
9466
+ },
9467
+ linearVelocity: velocity
9468
+ }
9469
+ };
9470
+ if (parent) {
9471
+ const local = new Vector3(from[0], from[1], from[2]).sub(parent.centre);
9472
+ local.applyQuaternion(parent.quat.clone().invert());
9473
+ const children = [];
9474
+ json.children = children;
9475
+ children.push({
9476
+ name: "Joint",
9477
+ type: "Joint3D",
9478
+ props: {
9479
+ type: "spherical",
9480
+ target: parent.node.getPath(),
9481
+ anchor: [
9482
+ 0,
9483
+ -len / 2,
9484
+ 0
9485
+ ],
9486
+ targetAnchor: [
9487
+ local.x,
9488
+ local.y,
9489
+ local.z
9490
+ ],
9491
+ collide: false
9492
+ }
9493
+ });
9494
+ }
9495
+ const node = buildNodeJson(json, rebuildOptions(this));
9496
+ root.addChild(node);
9497
+ built.set(spec.name, {
9498
+ node,
9499
+ centre,
9500
+ quat,
9501
+ len
9502
+ });
9503
+ this.limbNodes.push({
9504
+ name: spec.name,
9505
+ body: node
9506
+ });
9507
+ const bone = bones.get(spec.from) ?? null;
9508
+ const offset = new Matrix4();
9509
+ if (bone) {
9510
+ scratchM.compose(centre, quat, scratchS);
9511
+ offset.copy(scratchM).invert().multiply(bone.matrixWorld);
9512
+ bone.matrixWorldAutoUpdate = false;
9513
+ }
9514
+ this.driven.push({
9515
+ body: node,
9516
+ bone,
9517
+ offset
9518
+ });
9519
+ }
9520
+ if (this.limbNodes.length === 0) return false;
9521
+ this.active = true;
9522
+ this.left = this.lifetime;
9523
+ this.emit("activated");
9524
+ return true;
9525
+ }
9526
+ /** Free the limbs and hand the skeleton back to the animation. */
9527
+ reset() {
9528
+ if (!this.active) return;
9529
+ for (const d of this.driven) {
9530
+ if (d.bone) d.bone.matrixWorldAutoUpdate = true;
9531
+ if (d.body.tree !== null) d.body.queueFree();
9532
+ }
9533
+ this.driven = [];
9534
+ this.limbNodes = [];
9535
+ this.active = false;
9536
+ this.emit("reset");
9537
+ }
9538
+ update(dt) {
9539
+ if (!this.active) return;
9540
+ if (this.lifetime > 0) {
9541
+ this.left -= dt;
9542
+ if (this.left <= 0) {
9543
+ this.reset();
9544
+ return;
9545
+ }
9546
+ }
9547
+ for (const d of this.driven) {
9548
+ if (!d.bone) continue;
9549
+ const p = d.body.position;
9550
+ const r = d.body.rotation;
9551
+ scratchV.set(p[0] ?? 0, p[1] ?? 0, p[2] ?? 0);
9552
+ scratchQ.setFromEuler(scratchE.set((r[0] ?? 0) * DEG2RAD, (r[1] ?? 0) * DEG2RAD, (r[2] ?? 0) * DEG2RAD, "XYZ"));
9553
+ scratchM.compose(scratchV, scratchQ, scratchS);
9554
+ scratchM2.copy(scratchM).multiply(d.offset);
9555
+ d.bone.matrixWorld.copy(scratchM2);
9556
+ }
9557
+ }
9558
+ free() {
9559
+ this.reset();
9560
+ super.free();
9561
+ }
9562
+ /** The velocity of the body this ragdoll falls out of — the first body up the tree. */
9563
+ hostVelocity() {
9564
+ for (let n = this.parent; n; n = n.parent) {
9565
+ const v = n.linearVelocity ?? n.velocity;
9566
+ if (Array.isArray(v)) return [
9567
+ v[0] ?? 0,
9568
+ v[1] ?? 0,
9569
+ v[2] ?? 0
9570
+ ];
9571
+ }
9572
+ return [
9573
+ 0,
9574
+ 0,
9575
+ 0
9576
+ ];
9577
+ }
9578
+ };
9579
+ //#endregion
7945
9580
  //#region src/3d/water/river.ts
7946
9581
  /**
7947
9582
  * Self-carved bed. Sized off the WATER COLUMN, never off the width: a channel's
@@ -8894,6 +10529,15 @@ uniform float uRippleStrength;
8894
10529
  uniform float uFlowRate;
8895
10530
  uniform float uSparkle;
8896
10531
  uniform float uFresnelScale;
10532
+ // the river's own sky cube (river-3d.ts): what the surface mirrors instead of
10533
+ // the painted three-colour gradient, which stays as the headless fallback
10534
+ uniform samplerCube uEnvironmentMap;
10535
+ uniform float uUseEnvironmentMap;
10536
+ // the live ripple layer (fft-gpu.ts), a 6 m patch in the ribbon's own frame
10537
+ uniform float uRippleOn;
10538
+ uniform sampler2D uRippleNormal;
10539
+ uniform float uRipplePatch;
10540
+ uniform float uRippleGain;
8897
10541
  /**
8898
10542
  * Rocks standing in the current: xz = centre, z = radius, w = how much of the
8899
10543
  * water they break (0 = drowned and quiet, 1 = the crest stands proud).
@@ -8936,6 +10580,32 @@ float riverStreak(vec2 p) {
8936
10580
  return riverNoise(q) * 0.5 + riverNoise(q * 3.3 + 7.3) * 0.5;
8937
10581
  }
8938
10582
 
10583
+ /** water-that-reads-as-water: the FINE wavelets — the two ripple octaves are
10584
+ * a hand-span and up, so at the range a player actually stands from a creek
10585
+ * the surface tilted in soft blobs and nothing on it could ever face the sun
10586
+ * (the gel read). A third, much shorter field, differenced at its own step
10587
+ * (the ripple's 0.35 step is wider than these wavelets), gives the normal the
10588
+ * facets that glitter and that shatter the reflection; it fades out where a
10589
+ * pixel would integrate it into speckle. Squeezed downstream like the rest. */
10590
+ // Karis' split-sum fit: what a ROUGH surface reflects at this angle — see the
10591
+ // sea-state fresnel note in shaders.ts. A river at a grazing view is a field
10592
+ // of wavelets, not a mirror, and the flat-Schlick sheet of sky it used to wear
10593
+ // is the plastic look this replaces.
10594
+ vec2 envBRDFApprox(float roughness, float NoV) {
10595
+ const vec4 c0 = vec4(-1.0, -0.0275, -0.572, 0.022);
10596
+ const vec4 c1 = vec4(1.0, 0.0425, 1.04, -0.04);
10597
+ vec4 r = roughness * c0 + c1;
10598
+ float a004 = min(r.x * r.x, exp2(-9.28 * NoV)) * r.x + r.y;
10599
+ return vec2(-1.04, 1.04) * a004 + r.zw;
10600
+ }
10601
+
10602
+ vec2 riverFineSlope(vec2 p) {
10603
+ vec2 q = vec2(p.x * 10.7, p.y * 3.6) + 5.7;
10604
+ const float e = 0.08;
10605
+ float h = riverNoise(q);
10606
+ return vec2(riverNoise(q + vec2(e, 0.0)) - h, riverNoise(q + vec2(0.0, e)) - h) / e;
10607
+ }
10608
+
8939
10609
  void main() {
8940
10610
  float speed = vRiver.x;
8941
10611
  float column = vRiver.y;
@@ -9009,7 +10679,26 @@ void main() {
9009
10679
  // the gradient is a NOISE slope, not a water slope: clamp it before it tilts
9010
10680
  // the normal past a real wavelet (an over-driven normal throws fresnel to its
9011
10681
  // maximum everywhere and the river turns into a sheet of white sky)
9012
- vec2 slopeXY = clamp(ripple.yz * uRippleStrength * lively, vec2(-0.5), vec2(0.5));
10682
+ float camDist = length(cameraPosition - vWorldPosition);
10683
+ float fineFade = (1.0 - smoothstep(6.0, 34.0, camDist)) * lively;
10684
+ vec2 fine = vec2(0.0);
10685
+ if (fineFade > 0.01) {
10686
+ vec2 fineA = riverFineSlope(base - vec2(travel * pA, 0.0));
10687
+ vec2 fineB = riverFineSlope(base - vec2(travel * pB, 0.0));
10688
+ // under the ripple FFT the noise keeps only the sub-pixel flash
10689
+ fine = mix(fineA, fineB, blend) *
10690
+ (0.028 * fineFade * (0.5 + 0.5 * uSparkle) * (uRippleOn > 0.5 ? 0.45 : 1.0));
10691
+ }
10692
+ // the ripple FFT, carried downstream in the ribbon's metres with the same
10693
+ // dual-phase crossfade the noise uses (a field scrolled at a speed that
10694
+ // varies along the river smears; two half-period copies do not)
10695
+ if (uRippleOn > 0.5) {
10696
+ float travelM = speed / max(uFlowRate, 0.001);
10697
+ vec2 rA = texture2D(uRippleNormal, (vFlowUv - vec2(travelM * pA, 0.0)) / uRipplePatch, -1.0).xy;
10698
+ vec2 rB = texture2D(uRippleNormal, (vFlowUv - vec2(travelM * pB, 0.0)) / uRipplePatch, -1.0).xy;
10699
+ fine += mix(rA, rB, blend) * (uRippleGain * lively);
10700
+ }
10701
+ vec2 slopeXY = clamp(ripple.yz * uRippleStrength * lively + fine, vec2(-0.5), vec2(0.5));
9013
10702
  vec3 normal = normalize(vSurfaceNormal - dir3 * slopeXY.x - perp3 * slopeXY.y);
9014
10703
 
9015
10704
  vec3 viewDir = normalize(cameraPosition - vWorldPosition);
@@ -9113,31 +10802,53 @@ void main() {
9113
10802
  // eye is near the water — hold its dark tone well past the horizon line
9114
10803
  vec3 sky = mix(uBankColor, uHorizonColor, smoothstep(0.05, 0.34, reflectDir.y));
9115
10804
  sky = mix(sky, uSkyColor, smoothstep(0.34, 0.85, reflectDir.y));
9116
- float fresnel = clamp(
9117
- uFresnelScale * pow(1.0 - clamp(dot(normal, viewDir), 0.0, 1.0), 5.0),
9118
- 0.0,
9119
- 0.82
9120
- );
10805
+ if (uUseEnvironmentMap > 0.5) {
10806
+ // the REAL sky, from the river's own cube — near-clipped, so the lowest
10807
+ // rays (which meet the bank a few metres off) keep the bank tone
10808
+ vec3 lifted = vec3(reflectDir.x, max(reflectDir.y, 0.06), reflectDir.z);
10809
+ vec3 cube = min(textureCube(uEnvironmentMap, lifted).rgb, vec3(2.5));
10810
+ sky = mix(uBankColor, cube, smoothstep(0.02, 0.2, reflectDir.y));
10811
+ }
10812
+ float rough = clamp(0.045 + 0.28 * smoothstep(8.0, 140.0, camDist), 0.04, 0.35);
10813
+ float NoVf = clamp(dot(normal, viewDir), 0.0, 1.0);
10814
+ float fresnel = uFresnelScale * (0.02 + 0.98 * pow(1.0 - NoVf, 5.0));
10815
+ {
10816
+ vec2 fRough = envBRDFApprox(rough, NoVf);
10817
+ vec2 fSmooth = envBRDFApprox(0.02, NoVf);
10818
+ fresnel *= clamp((0.02 * fRough.x + fRough.y) / max(0.02 * fSmooth.x + fSmooth.y, 1e-3), 0.0, 1.35);
10819
+ }
10820
+ fresnel = clamp(fresnel, 0.0, 0.82);
9121
10821
 
9122
10822
  vec3 sunDir = normalize(uSunDirection);
9123
10823
  vec3 halfVec = normalize(sunDir + viewDir);
9124
- float ndh = max(dot(normal, halfVec), 0.0);
9125
- // Two lobes: a tight glitter spark, plus a whisper of broad sheen so the
9126
- // river still READS as a moving surface from across the valley, where the
9127
- // sparkle is smaller than a pixel.
9128
- //
9129
- // The broad lobe is the dangerous one. At exponent 18 it stays near 1 across
9130
- // a huge stretch of a near-flat surface, and at 0.22 · 1.6 that is a third of
9131
- // full white ADDED to every one of those fragments a sheet of white lying
9132
- // over the water that reads as fog and swallows the bed, the foam and the
9133
- // rocks alike. Narrow it and cut its weight; the sparkle carries the sun.
9134
- float spec = (pow(ndh, 160.0) + pow(ndh, 34.0) * 0.07) * uSunIntensity;
9135
- // glitter: the same advected field gates the highlight so it breaks into
9136
- // travelling sparks instead of one smooth sheen
9137
- float glint = smoothstep(0.55, 0.95, ripple.x);
9138
- spec *= 0.45 + 0.55 * glint * uSparkle;
10824
+ // ── the sun as a microfacet highlight ──────────────────────────────────
10825
+ // water-that-reads-as-water: the two Blinn-Phong lobes (160 + a whisper of
10826
+ // 34) were a hand-shaped sheen gated by the ripple field, capped at 1.2 —
10827
+ // a running creek under a 40°-high sun showed no sun at all. One GGX lobe
10828
+ // over the wavelet normal: resolved facets near the eye flash (the glitter
10829
+ // a riffle throws), and with distance the roughness grows so the same lobe
10830
+ // widens into the soft sheen a river shows from across the valley. HDR by
10831
+ // design the sun's reflection is the brightest thing on the water and
10832
+ // the bloom pass reads it. RIVER_SUN_RADIANCE scales the sun against the
10833
+ // unit-ish sky gradient this shader mirrors.
10834
+ const float RIVER_SUN_RADIANCE = 30.0;
10835
+ float NoL = clamp(dot(normal, sunDir), 0.0, 1.0);
10836
+ float NoV = max(dot(normal, viewDir), 1e-3);
10837
+ float NoH = clamp(dot(normal, halfVec), 0.0, 1.0);
10838
+ float LoH = clamp(dot(sunDir, halfVec), 0.0, 1.0);
10839
+ float a2 = rough * rough;
10840
+ float dD = NoH * NoH * (a2 - 1.0) + 1.0;
10841
+ float D = a2 / (3.14159265 * dD * dD);
10842
+ float gV = NoL * sqrt(NoV * NoV * (1.0 - a2) + a2);
10843
+ float gL = NoV * sqrt(NoL * NoL * (1.0 - a2) + a2);
10844
+ float Vis = 0.5 / max(gV + gL, 1e-4);
10845
+ float Fh = 0.02 + 0.98 * pow(1.0 - LoH, 5.0);
10846
+ float spec = D * Vis * Fh * NoL * RIVER_SUN_RADIANCE * uSunIntensity;
10847
+ spec *= 0.5 + 0.5 * uSparkle; // the ripples dial still tunes how much sun the water throws
9139
10848
  spec *= 1.0 - foam * 0.85; // froth is rough — it kills the mirror
9140
- spec = min(spec, 1.2); // a highlight, never a white-out
10849
+ // a facet under a pixel aliases into a lone flash: the roughness ramp
10850
+ // widens the lobe out there, the cap catches the last isolated ones
10851
+ spec = min(spec, mix(5.0, 1.2, smoothstep(30.0, 200.0, camDist)));
9141
10852
 
9142
10853
  fresnel *= 1.0 - 0.85 * fall; // froth scatters; it does not reflect
9143
10854
  vec3 color = mix(body, sky, fresnel);
@@ -9237,6 +10948,21 @@ const FOAM_COLOR = "#e9f2ef";
9237
10948
  /** Per-frame scratch (no allocation in the render path). */
9238
10949
  const rockCamScratch = new Vector3();
9239
10950
  /** Fixed look constants — the props stay intent-level, these carry the craft. */
10951
+ /** The river's own sky: a slow cube of the world beyond its banks (the sky,
10952
+ * the far hills), refreshed every couple of seconds — what the surface
10953
+ * mirrors instead of a painted three-colour gradient. Near-clipped so the
10954
+ * banks themselves, metres away, never smear across it. */
10955
+ const RIVER_SKY_RESOLUTION = 128;
10956
+ const RIVER_SKY_INTERVAL = 2e3;
10957
+ const RIVER_SKY_NEAR = 60;
10958
+ /** The live ripple layer (see Water3D): a 6 m spectrum patch advected with
10959
+ * the current in the ribbon's own frame. */
10960
+ const RIVER_RIPPLE_SIZE = 128;
10961
+ const RIVER_RIPPLE_PATCH = 6;
10962
+ const RIVER_RIPPLE_FETCH = 150;
10963
+ const RIVER_RIPPLE_SEED = 4242;
10964
+ /** Ripple rms slope per unit of `ripples`. */
10965
+ const RIVER_RIPPLE_SLOPE = .07;
9240
10966
  const LOOK = {
9241
10967
  /** Pattern units per meter of flow space. */
9242
10968
  rippleScale: .85,
@@ -9379,6 +11105,12 @@ var River3D = class River3D extends Node3D {
9379
11105
  0
9380
11106
  ];
9381
11107
  _bodies = [];
11108
+ cubeTarget = null;
11109
+ cubeCamera = null;
11110
+ lastSkyAt = 0;
11111
+ rippleFft = null;
11112
+ rippleSpectrum = null;
11113
+ fftUnsupported = false;
9382
11114
  /** Loader hook: a malformed river fails at LOAD, not as an invisible ribbon. */
9383
11115
  static validateJson(node) {
9384
11116
  rejectScale(node, "the flow query and its collider both");
@@ -9386,7 +11118,7 @@ var River3D = class River3D extends Node3D {
9386
11118
  if (!Array.isArray(r.path)) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' path must be an array of [x, z] points, got ${JSON.stringify(r.path)}.`, { prop: "path" });
9387
11119
  if (r.path.length > 0 && r.path.length < 2) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' path needs at least 2 points (source → mouth), got ${r.path.length}.`, { prop: "path" });
9388
11120
  r.path.forEach((p, i) => {
9389
- if (!Array.isArray(p) || p.length < 2 || !Number.isFinite(p[0]) || !Number.isFinite(p[1])) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' path[${i}] must be [x, z] with finite numbers, got ${JSON.stringify(p)}.`, { prop: "path" });
11121
+ if (!isCoursePoint(p)) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' path[${i}] must be ${COURSE_SHAPE}, got ${JSON.stringify(p)}.`, { prop: "path" });
9390
11122
  });
9391
11123
  if (!(typeof r.width === "number" && r.width > 0)) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' width must be a positive number of meters, got ${JSON.stringify(r.width)}.`, { prop: "width" });
9392
11124
  if (!Array.isArray(r.widths) || r.widths.some((w) => !(typeof w === "number" && w > 0))) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' widths must be a list of positive meters (source→mouth), got ${JSON.stringify(r.widths)}.`, { prop: "widths" });
@@ -9420,8 +11152,14 @@ var River3D = class River3D extends Node3D {
9420
11152
  onExitTree() {
9421
11153
  findDrapeTerrain(this, this.terrain)?._setExternalChannel(this, null);
9422
11154
  }
11155
+ /** Said once when the node has no course to be a river along. */
11156
+ warnedNoPath = false;
9423
11157
  update(dt) {
9424
11158
  this.time += dt;
11159
+ if (!this.warnedNoPath && this.path.length === 0) {
11160
+ this.warnedNoPath = true;
11161
+ this.diagnose("warn", `[incanto] River3D '${this.name}' has no \`path\`, so there is no channel: it draws nothing, carves nothing and pushes nothing, whatever \`width\`, \`flowSpeed\` and \`flowForce\` say. Give it at least two [x, z] control points (source → mouth), in the node's own local frame.`);
11162
+ }
9425
11163
  this.ensureCourse();
9426
11164
  this.syncSpray();
9427
11165
  if (this.flowForce > 0 && this.rings.length > 1) this.sweepBodies(dt);
@@ -9439,8 +11177,68 @@ var River3D = class River3D extends Node3D {
9439
11177
  this.rockScanAt = this.time;
9440
11178
  this.scanRocks();
9441
11179
  }
9442
- const u = this._ensureObject3D().material.uniforms;
11180
+ const mesh = this._ensureObject3D();
11181
+ const u = mesh.material.uniforms;
9443
11182
  this.syncRocks(u, ctx.camera.getWorldPosition(rockCamScratch));
11183
+ if (typeof ctx.gl?.extensions?.has === "function") {
11184
+ this.syncSky(ctx, mesh, u);
11185
+ this.syncRipple(ctx, u);
11186
+ }
11187
+ }
11188
+ /** The sky cube: the world beyond the banks, from above the channel's middle. */
11189
+ syncSky(ctx, mesh, u) {
11190
+ if (!this.cubeTarget) {
11191
+ this.cubeTarget = new WebGLCubeRenderTarget(RIVER_SKY_RESOLUTION, {
11192
+ generateMipmaps: true,
11193
+ minFilter: LinearMipmapLinearFilter,
11194
+ type: HalfFloatType
11195
+ });
11196
+ this.cubeCamera = new CubeCamera(RIVER_SKY_NEAR, 4e3, this.cubeTarget);
11197
+ this.lastSkyAt = 0;
11198
+ }
11199
+ const now = Date.now();
11200
+ if (this.lastSkyAt === 0 || now - this.lastSkyAt >= RIVER_SKY_INTERVAL) {
11201
+ this.lastSkyAt = now;
11202
+ const b = this.ringBounds();
11203
+ const [wx, wy, wz] = this.world;
11204
+ const cam = this.cubeCamera;
11205
+ cam.position.set(wx + (b.minX + b.maxX) / 2, wy + 3, wz + (b.minZ + b.maxZ) / 2);
11206
+ const wasVisible = mesh.visible;
11207
+ mesh.visible = false;
11208
+ cam.update(ctx.gl, ctx.scene);
11209
+ mesh.visible = wasVisible;
11210
+ }
11211
+ if (u.uEnvironmentMap) u.uEnvironmentMap.value = this.cubeTarget.texture;
11212
+ if (u.uUseEnvironmentMap) u.uUseEnvironmentMap.value = 1;
11213
+ }
11214
+ /** The ripple FFT, advected by the fragment in the ribbon's own frame. */
11215
+ syncRipple(ctx, u) {
11216
+ if (this.fftUnsupported) return;
11217
+ if (!this.rippleFft) {
11218
+ if (!seaFftSupported(ctx.gl)) {
11219
+ this.fftUnsupported = true;
11220
+ return;
11221
+ }
11222
+ this.rippleFft = new SeaFft(RIVER_RIPPLE_SIZE, RIVER_RIPPLE_PATCH);
11223
+ this.rippleSpectrum = buildSeaSpectrum({
11224
+ size: RIVER_RIPPLE_SIZE,
11225
+ patch: RIVER_RIPPLE_PATCH,
11226
+ wind: 3,
11227
+ windDirectionDeg: 90,
11228
+ seed: RIVER_RIPPLE_SEED,
11229
+ choppiness: .3,
11230
+ fetch: RIVER_RIPPLE_FETCH,
11231
+ modes: 0
11232
+ });
11233
+ this.rippleFft.setSpectrum(this.rippleSpectrum);
11234
+ }
11235
+ this.rippleFft.update(ctx.gl, this.time);
11236
+ if (u.uRippleNormal) u.uRippleNormal.value = this.rippleFft.normal;
11237
+ if (u.uRippleOn) u.uRippleOn.value = 1;
11238
+ if (u.uRippleGain && this.rippleSpectrum) {
11239
+ const rms = this.rippleSpectrum.rmsSlope;
11240
+ u.uRippleGain.value = rms > 0 ? RIVER_RIPPLE_SLOPE * this.ripples / rms : 0;
11241
+ }
9444
11242
  }
9445
11243
  /**
9446
11244
  * @internal Water surface height over a world point, or null where this
@@ -9529,6 +11327,11 @@ var River3D = class River3D extends Node3D {
9529
11327
  const mesh = this._ensureObject3D();
9530
11328
  mesh.geometry?.dispose();
9531
11329
  mesh.material?.dispose?.();
11330
+ this.cubeTarget?.dispose();
11331
+ this.cubeTarget = null;
11332
+ this.cubeCamera = null;
11333
+ this.rippleFft?.dispose();
11334
+ this.rippleFft = null;
9532
11335
  super.free();
9533
11336
  }
9534
11337
  widthProfile() {
@@ -9552,7 +11355,7 @@ var River3D = class River3D extends Node3D {
9552
11355
  const [tx, , tz] = worldXZY(terrain);
9553
11356
  const hm = terrain._heightmap();
9554
11357
  terrain._setExternalChannel(this, riverCarveChannels({
9555
- path: this.path.map((p) => [(p[0] ?? 0) + wx - tx, (p[1] ?? 0) + wz - tz]),
11358
+ path: course(this.path).map((p) => [p[0] + wx - tx, p[1] + wz - tz]),
9556
11359
  widths: this.widthProfile(),
9557
11360
  depth: this.depth,
9558
11361
  cell: (terrain.size[0] ?? 1) / Math.max(1, hm.segsX),
@@ -9597,7 +11400,7 @@ var River3D = class River3D extends Node3D {
9597
11400
  this.fitted = terrain !== null;
9598
11401
  const widths = this.widthProfile();
9599
11402
  this.rings = buildRiverRings({
9600
- path: this.path,
11403
+ path: course(this.path),
9601
11404
  widths,
9602
11405
  depth: this.depth,
9603
11406
  flowSpeed: this.flowSpeed,
@@ -9688,6 +11491,12 @@ var River3D = class River3D extends Node3D {
9688
11491
  uRockCount: { value: 0 },
9689
11492
  uRocks: { value: Array.from({ length: ROCK_SLOTS }, () => new Vector4()) },
9690
11493
  uFresnelScale: { value: LOOK.fresnelScale },
11494
+ uEnvironmentMap: { value: null },
11495
+ uUseEnvironmentMap: { value: 0 },
11496
+ uRippleOn: { value: 0 },
11497
+ uRippleNormal: { value: null },
11498
+ uRipplePatch: { value: RIVER_RIPPLE_PATCH },
11499
+ uRippleGain: { value: 0 },
9691
11500
  uStandingAmp: { value: LOOK.standingAmp },
9692
11501
  uGlideAmp: { value: LOOK.glideAmp }
9693
11502
  },
@@ -11615,6 +13424,8 @@ function paintVerticalGradient(geometry, base, darkest, brightest, rng) {
11615
13424
  }
11616
13425
  //#endregion
11617
13426
  //#region src/3d/nodes/tree-3d.ts
13427
+ /** How many times a wet draw is re-rolled before the tree is planted anyway. */
13428
+ const DRY_TRIES = 8;
11618
13429
  /** A whole forest in one node is still ≤6 draw calls — but keep it sane. */
11619
13430
  const MAX_TREE_COUNT = 500;
11620
13431
  /** Whole-grove triangle ceiling: count × tris-per-tree must stay under this. */
@@ -11770,6 +13581,7 @@ var Tree3D = class Tree3D extends Node3D {
11770
13581
  default: null,
11771
13582
  kinds: ["null", "boolean"]
11772
13583
  },
13584
+ avoidWater: { default: true },
11773
13585
  terrain: {
11774
13586
  default: "",
11775
13587
  nodePath: true
@@ -11809,6 +13621,19 @@ var Tree3D = class Tree3D extends Node3D {
11809
13621
  * it (keeps retrying until a terrain appears), false disables draping.
11810
13622
  */
11811
13623
  drape = null;
13624
+ /**
13625
+ * Keep trunks out of standing and running water (default true) — a valley a
13626
+ * rill runs through has a grove on its banks, not trees in the current.
13627
+ *
13628
+ * `Foliage3D` and `Flowers3D` have had this since they were written, with the
13629
+ * same default and the same reason; the TREE — the tallest thing in a
13630
+ * landscape and the one that most obviously does not grow midstream — did
13631
+ * not. Measured on a river-valley game: two of twenty-two trunks stood in the
13632
+ * rill.
13633
+ *
13634
+ * `false` is the mangrove, the cypress swamp and the flooded forest.
13635
+ */
13636
+ avoidWater = true;
11812
13637
  /** Node path to the Terrain3D to drape onto ('' = auto-find the first one). */
11813
13638
  terrain = "";
11814
13639
  /** Resolved drape target + the grove's world position, set per rebuild. */
@@ -12062,13 +13887,45 @@ var Tree3D = class Tree3D extends Node3D {
12062
13887
  }, void 0, this.noteTextureError);
12063
13888
  }
12064
13889
  /** Per-instance placement + jitter — ONE rng draw order for both paths. */
13890
+ /**
13891
+ * Where every trunk of this grove stands, in WORLD coordinates.
13892
+ *
13893
+ * `drape` is the prop that puts a grove on the ground it grows from, and
13894
+ * nothing could ask whether it had. `UiMinimap.markers()` and
13895
+ * `HudLayer.focusables()` are the same accessor for the same reason: a
13896
+ * feature whose effect no harness can read is a feature nobody checks.
13897
+ *
13898
+ * Recomputed from the seed rather than read back off the InstancedMesh, so it
13899
+ * answers headless too — where there is no mesh at all.
13900
+ */
13901
+ trunks() {
13902
+ this.resolveDrape();
13903
+ const out = [];
13904
+ const [wx, wy, wz] = worldXZY(this);
13905
+ this.scatter(new Rng(this.seed), (_i, matrix) => {
13906
+ positionScratch.setFromMatrixPosition(matrix);
13907
+ out.push([
13908
+ positionScratch.x + wx,
13909
+ positionScratch.y + wy,
13910
+ positionScratch.z + wz
13911
+ ]);
13912
+ });
13913
+ return out;
13914
+ }
12065
13915
  scatter(rng, place) {
12066
13916
  const halfX = (this.area[0] ?? 0) / 2;
12067
13917
  const halfZ = (this.area[1] ?? 0) / 2;
13918
+ const wet = this.avoidWater ? collectWetSurfaces(this) : [];
13919
+ const [wx, wy, wz] = worldXZY(this);
12068
13920
  for (let i = 0; i < this.count; i++) {
12069
- const lx = this.count === 1 ? 0 : rng.range(-halfX, halfX);
12070
- const lz = this.count === 1 ? 0 : rng.range(-halfZ, halfZ);
12071
- positionScratch.set(lx, this.drape ? this.bedY(lx, lz) : 0, lz);
13921
+ let lx = this.count === 1 ? 0 : rng.range(-halfX, halfX);
13922
+ let lz = this.count === 1 ? 0 : rng.range(-halfZ, halfZ);
13923
+ if (wet.length > 0) for (let tries = 0; tries < DRY_TRIES; tries++) {
13924
+ if (!isUnderWater(wet, lx + wx, lz + wz, this.bedY(lx, lz) + wy)) break;
13925
+ lx = rng.range(-halfX, halfX);
13926
+ lz = rng.range(-halfZ, halfZ);
13927
+ }
13928
+ positionScratch.set(lx, this.bedY(lx, lz), lz);
12072
13929
  quaternionScratch.setFromAxisAngle(Y_AXIS, rng.range(0, Math.PI * 2));
12073
13930
  const h = this.height * rng.range(HEIGHT_JITTER[0], HEIGHT_JITTER[1]);
12074
13931
  const girth = h * rng.range(.9, 1.1);
@@ -12244,6 +14101,195 @@ const quaternionScratch = new Quaternion();
12244
14101
  const scaleScratch = new Vector3();
12245
14102
  const colorScratch = new Color();
12246
14103
  //#endregion
14104
+ //#region src/3d/nodes/vehicle-3d.ts
14105
+ const DEG = Math.PI / 180;
14106
+ const RAD2DEG = 180 / Math.PI;
14107
+ /**
14108
+ * A car in one node — Rapier's raycast vehicle under a `RigidBody3D` chassis.
14109
+ *
14110
+ * ```json
14111
+ * { "name": "Car", "type": "RigidBody3D",
14112
+ * "props": { "mass": 800, "collider": { "shape": "box", "size": [1.8, 0.6, 4] } },
14113
+ * "children": [
14114
+ * { "name": "Drive", "type": "Vehicle3D", "props": { "wheels": [
14115
+ * { "position": [-0.8, -0.1, 1.4], "steer": true },
14116
+ * { "position": [ 0.8, -0.1, 1.4], "steer": true },
14117
+ * { "position": [-0.8, -0.1, -1.4], "drive": true },
14118
+ * { "position": [ 0.8, -0.1, -1.4], "drive": true } ] } },
14119
+ * { "name": "Wheel0", "type": "Node3D", "props": { "position": [-0.8, -0.1, 1.4] },
14120
+ * "children": [ { "name": "Hub", "type": "Node3D", "children": [ …the tyre mesh… ] } ] }
14121
+ * ] }
14122
+ * ```
14123
+ *
14124
+ * The wheels are springs that never touch anything (a ray each), so a car
14125
+ * cannot catch a kerb on a wheel body or disagree with its axle; the throttle,
14126
+ * steer and brake come from the `move` vector and the `brake` button (or from
14127
+ * code, with `moveAction: ""`). Forward is +z, the +Z-forward rule. A child
14128
+ * named `Wheel<i>` is posed every step — it hangs on its spring and turns to
14129
+ * steer — and its child `Hub` spins as the wheel rolls.
14130
+ */
14131
+ var Vehicle3D = class extends Node3D {
14132
+ static typeName = "Vehicle3D";
14133
+ static props = {
14134
+ /** `[{ position, radius?, rest?, steer?, drive? }]` — at least one; defaults radius 0.35, rest 0.3. */
14135
+ wheels: { default: [] },
14136
+ /** Spring stiffness (N/m per kg-ish — 20 soft, 30 a car, 60 a kart). */
14137
+ suspensionStiffness: { default: 30 },
14138
+ suspensionCompression: { default: 2.3 },
14139
+ suspensionRelaxation: { default: 3.5 },
14140
+ /** How far a spring may compress from rest (m). */
14141
+ maxTravel: { default: .3 },
14142
+ /** Tyre grip along the wheel (higher = less spin-out). */
14143
+ friction: { default: 2.5 },
14144
+ /** Tyre grip across the wheel (lower = more drift). */
14145
+ sideFriction: { default: 1 },
14146
+ /** Full lock, degrees. */
14147
+ maxSteerDeg: { default: 32 },
14148
+ /** Force on each driven wheel at full throttle (N). */
14149
+ engineForce: { default: 1500 },
14150
+ /** Brake force on every wheel at full brake. */
14151
+ brakeForce: { default: 40 },
14152
+ /** The stick: up = throttle, down = reverse, left/right = steer. '' = code writes `throttle`/`steer`. */
14153
+ moveAction: { default: "move" },
14154
+ /** Held = full brake. */
14155
+ brakeAction: { default: "brake" },
14156
+ /**
14157
+ * Off = parked: the throttle and steering read nothing and the brake is
14158
+ * held, while the suspension still settles and the wheels still pose.
14159
+ * The switch a game flips while nobody is behind the wheel — both the
14160
+ * car and the character read `move`, so without it one press moves both.
14161
+ */
14162
+ enabled: { default: true }
14163
+ };
14164
+ wheels = [];
14165
+ suspensionStiffness = 30;
14166
+ suspensionCompression = 2.3;
14167
+ suspensionRelaxation = 3.5;
14168
+ maxTravel = .3;
14169
+ friction = 2.5;
14170
+ sideFriction = 1;
14171
+ maxSteerDeg = 32;
14172
+ engineForce = 1500;
14173
+ brakeForce = 40;
14174
+ moveAction = "move";
14175
+ brakeAction = "brake";
14176
+ enabled = true;
14177
+ /** Live inputs, −1..1 (throttle, steer) and 0..1 (brake) — the actions write these, or your code does. */
14178
+ throttle = 0;
14179
+ steer = 0;
14180
+ brake = 0;
14181
+ /** Forward speed, m/s (negative in reverse). */
14182
+ speed = 0;
14183
+ /** @internal Written by the physics adapter each step. */
14184
+ _states = [];
14185
+ reportedActions = /* @__PURE__ */ new Set();
14186
+ /** What wheel `i` is doing. */
14187
+ wheel(i) {
14188
+ return this._states[i] ?? {
14189
+ contact: false,
14190
+ suspension: this.specs()[i]?.rest ?? .3,
14191
+ rotation: 0,
14192
+ steering: 0
14193
+ };
14194
+ }
14195
+ /** The wheels with every default filled in. */
14196
+ specs() {
14197
+ return this.wheels.map((w) => {
14198
+ const raw = w;
14199
+ return {
14200
+ position: [
14201
+ raw.position?.[0] ?? 0,
14202
+ raw.position?.[1] ?? 0,
14203
+ raw.position?.[2] ?? 0
14204
+ ],
14205
+ radius: raw.radius ?? .35,
14206
+ rest: raw.rest ?? .3,
14207
+ steer: raw.steer ?? false,
14208
+ drive: raw.drive ?? false
14209
+ };
14210
+ });
14211
+ }
14212
+ get body() {
14213
+ const parent = this.parent;
14214
+ if (!(parent instanceof RigidBody3D)) throw new IncantoError("TREE_VIOLATION", `Vehicle3D '${this.name}' must be a child of a RigidBody3D — the chassis (parent is '${parent?.name ?? "none"}').`);
14215
+ return parent;
14216
+ }
14217
+ onEnterTree() {
14218
+ this.body;
14219
+ if (!Array.isArray(this.wheels) || this.wheels.length === 0) throw new IncantoError("BAD_FORMAT", `Vehicle3D '${this.getPath()}': "wheels" must list at least one wheel — { "position": [x, y, z], "steer"?, "drive"? }.`);
14220
+ this.wheels.forEach((w, i) => {
14221
+ const p = w.position;
14222
+ if (!Array.isArray(p) || p.length !== 3 || p.some((n) => typeof n !== "number" || !Number.isFinite(n))) throw new IncantoError("BAD_FORMAT", `Vehicle3D '${this.getPath()}': wheel ${i} needs a "position": [x, y, z] in the chassis's frame.`);
14223
+ });
14224
+ }
14225
+ onExitTree() {
14226
+ this.body._physics3d?._freeVehicle(this);
14227
+ }
14228
+ fixedUpdate(dt) {
14229
+ const input = this.tree?.engine?.input;
14230
+ const physics = this.body._physics3d;
14231
+ if (input && this.moveAction !== "") {
14232
+ const v = tolerateUnknownAction({
14233
+ engine: this.tree?.engine,
14234
+ who: this.getPath(),
14235
+ reported: this.reportedActions,
14236
+ consequence: "The car will not move.",
14237
+ authored: this.moveAction !== "move"
14238
+ }, this.moveAction, () => input.getVector(this.moveAction), {
14239
+ x: 0,
14240
+ y: 0
14241
+ });
14242
+ this.throttle = -v.y;
14243
+ this.steer = v.x;
14244
+ this.brake = tolerateUnknownAction({
14245
+ engine: this.tree?.engine,
14246
+ who: this.getPath(),
14247
+ reported: this.reportedActions,
14248
+ consequence: "The car will never brake.",
14249
+ authored: this.brakeAction !== "brake"
14250
+ }, this.brakeAction, () => input.isPressed(this.brakeAction) ? 1 : 0, 0);
14251
+ }
14252
+ if (!this.enabled) {
14253
+ this.throttle = 0;
14254
+ this.steer = 0;
14255
+ this.brake = 1;
14256
+ }
14257
+ if (physics) physics._driveVehicle(this, dt);
14258
+ this.poseWheels();
14259
+ }
14260
+ /** `Wheel<i>` hangs on its spring and turns to steer; its `Hub` spins. */
14261
+ poseWheels() {
14262
+ const specs = this.specs();
14263
+ for (let i = 0; i < specs.length; i++) {
14264
+ const spec = specs[i];
14265
+ if (!spec) continue;
14266
+ const wheel = this.parent?.getNodeOrNull(`Wheel${i}`);
14267
+ if (!wheel || !(wheel instanceof Node3D)) continue;
14268
+ const s = this.wheel(i);
14269
+ wheel.position = [
14270
+ spec.position[0],
14271
+ spec.position[1] - s.suspension,
14272
+ spec.position[2]
14273
+ ];
14274
+ wheel.rotation = [
14275
+ 0,
14276
+ s.steering * RAD2DEG,
14277
+ 0
14278
+ ];
14279
+ const hub = wheel.getNodeOrNull("Hub");
14280
+ if (hub && hub instanceof Node3D) hub.rotation = [
14281
+ s.rotation * RAD2DEG,
14282
+ hub.rotation[1] ?? 0,
14283
+ hub.rotation[2] ?? 0
14284
+ ];
14285
+ }
14286
+ }
14287
+ /** Full lock, radians. */
14288
+ get maxSteer() {
14289
+ return this.maxSteerDeg * DEG;
14290
+ }
14291
+ };
14292
+ //#endregion
12247
14293
  //#region src/3d/nodes/voxel-grid-3d.ts
12248
14294
  /**
12249
14295
  * Face colors per tile (front,right,back,left,top,bottom) — the agent8
@@ -13282,6 +15328,213 @@ void main() {
13282
15328
  }
13283
15329
  `;
13284
15330
  //#endregion
15331
+ //#region src/3d/nodes/weather-3d.ts
15332
+ /**
15333
+ * Rain falls this fast (m/s); snow this slow. Sizes are in px like every
15334
+ * particle prop (×0.01 = world metres, attenuated with distance): a 3 px drop
15335
+ * is 3 cm, sub-pixel ten metres away and invisible against a bright sky —
15336
+ * measured on the rooftops — so a drop is a hand wide and a flake wider.
15337
+ */
15338
+ const RAIN = {
15339
+ speed: [10, 14],
15340
+ size: 55,
15341
+ color: "#eaf3ff",
15342
+ alpha: .75,
15343
+ rate: 1500,
15344
+ spread: 4,
15345
+ cap: 4e3
15346
+ };
15347
+ const SNOW = {
15348
+ speed: [.6, 1.4],
15349
+ size: 14,
15350
+ color: "#ffffff",
15351
+ alpha: .9,
15352
+ rate: 500,
15353
+ spread: 50,
15354
+ cap: 6e3
15355
+ };
15356
+ const M_TO_PX = 100;
15357
+ /** How often the tree is re-scanned for the current camera (fixed steps). */
15358
+ const CAMERA_SCAN_STEPS = 60;
15359
+ /**
15360
+ * Weather — rain or snow that follows the camera.
15361
+ *
15362
+ * The scene format had a sky, a fog, a cloud deck and a sun, and no weather:
15363
+ * rain was forty lines of `Particles3D` tuning an author had to know, plus a
15364
+ * script to keep the emitter over the camera. One node now:
15365
+ *
15366
+ * ```json
15367
+ * { "name": "Rain", "type": "Weather3D", "props": { "kind": "rain", "intensity": 0.7, "wind": [2, 0] } }
15368
+ * ```
15369
+ *
15370
+ * The drops are born anywhere in a box `radius` wide and `height` above
15371
+ * whatever is followed (the current `Camera3D` unless `follow` names a node),
15372
+ * fall in world space, and stay where they fell when the camera moves on.
15373
+ * Every `Particles3D` prop is still here to override (a colour, a size), but
15374
+ * `kind` sets them all and `intensity` scales the rate live.
15375
+ */
15376
+ /**
15377
+ * A raindrop's stamp: a vertical streak with soft ends, not a dot.
15378
+ *
15379
+ * Rain is drawn as point sprites, and a point sprite is SQUARE — so the streak
15380
+ * lives inside the texture: a thin bright column down the middle with
15381
+ * transparent sides. Measured before this existed: a downpour at intensity 1
15382
+ * put 2,600 round dots on screen and read as a dirty lens.
15383
+ */
15384
+ let streakStamp = null;
15385
+ function streakSprite() {
15386
+ if (streakStamp) return streakStamp;
15387
+ const size = 64;
15388
+ const data = new Uint8Array(size * size * 4);
15389
+ const halfW = size * .055;
15390
+ const centre = (size - 1) / 2;
15391
+ for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
15392
+ const dx = Math.abs(x - centre) / halfW;
15393
+ const along = Math.abs(y - centre) / centre;
15394
+ const ends = Math.max(0, 1 - Math.max(0, along - .72) / .28);
15395
+ const across = Math.max(0, 1 - dx * dx);
15396
+ const i = (y * size + x) * 4;
15397
+ data[i] = 255;
15398
+ data[i + 1] = 255;
15399
+ data[i + 2] = 255;
15400
+ data[i + 3] = Math.round(Math.min(1, across * ends) * 255);
15401
+ }
15402
+ const texture = new DataTexture(data, size, size, RGBAFormat);
15403
+ texture.needsUpdate = true;
15404
+ streakStamp = texture;
15405
+ return texture;
15406
+ }
15407
+ var Weather3D = class extends Particles3D {
15408
+ static typeName = "Weather3D";
15409
+ static props = {
15410
+ /** `rain` — fast, straight, a shower; `snow` — slow, drifting flakes. */
15411
+ kind: {
15412
+ default: "rain",
15413
+ options: ["rain", "snow"]
15414
+ },
15415
+ /** How much of it, 0..1 (scales the rate live; 0 = a dry sky). */
15416
+ intensity: {
15417
+ default: .6,
15418
+ range: {
15419
+ min: 0,
15420
+ max: 1
15421
+ }
15422
+ },
15423
+ /** Half-width (m) of the box the drops are born in, around the camera. */
15424
+ radius: { default: 20 },
15425
+ /** How far above the camera they are born (m). */
15426
+ height: { default: 12 },
15427
+ /** A steady drift, m/s, on the ground plane `[x, z]`. */
15428
+ wind: { default: [0, 0] },
15429
+ /** What to follow; '' = the current Camera3D. */
15430
+ follow: {
15431
+ default: "",
15432
+ nodePath: true
15433
+ }
15434
+ };
15435
+ kind = "rain";
15436
+ intensity = .6;
15437
+ radius = 20;
15438
+ height = 12;
15439
+ wind = [0, 0];
15440
+ follow = "";
15441
+ /** Rain is a streak; snow keeps the soft flake the particle system draws. */
15442
+ sprite() {
15443
+ return this.kind === "snow" ? super.sprite() : streakSprite();
15444
+ }
15445
+ tunedFor = "";
15446
+ camera = null;
15447
+ cameraScanIn = 0;
15448
+ onEnterTree() {
15449
+ super.onEnterTree?.();
15450
+ this.tune();
15451
+ }
15452
+ /** Set the inherited particle props from `kind` — once, and again if it changes. */
15453
+ tune() {
15454
+ const t = this.kind === "snow" ? SNOW : RAIN;
15455
+ this.worldSpace = true;
15456
+ this.blend = "normal";
15457
+ this.directionDeg = 90;
15458
+ this.spreadDeg = t.spread;
15459
+ this.speed = [(t.speed[0] ?? 1) * M_TO_PX, (t.speed[1] ?? 1) * M_TO_PX];
15460
+ const fall = t.speed[0] ?? 1;
15461
+ this.lifetime = [(this.height + 2) / fall, (this.height + 6) / fall];
15462
+ this.gravity = [
15463
+ 0,
15464
+ 0,
15465
+ 0
15466
+ ];
15467
+ this.drag = 0;
15468
+ this.sizeStart = t.size;
15469
+ this.sizeEnd = t.size;
15470
+ this.colorStart = t.color;
15471
+ this.colorEnd = t.color;
15472
+ this.alphaStart = t.alpha;
15473
+ this.alphaEnd = t.alpha * .8;
15474
+ this.maxParticles = t.cap;
15475
+ this.emitBox = [
15476
+ this.radius,
15477
+ .5,
15478
+ this.radius
15479
+ ];
15480
+ this.drift = [
15481
+ (this.wind[0] ?? 0) * M_TO_PX,
15482
+ 0,
15483
+ (this.wind[1] ?? 0) * M_TO_PX
15484
+ ];
15485
+ this.rate = t.rate * this.intensity;
15486
+ this.tunedFor = this.tuning();
15487
+ this._rebuildSim();
15488
+ }
15489
+ tuning() {
15490
+ return `${this.kind}/${this.radius}/${this.height}/${this.wind[0] ?? 0}/${this.wind[1] ?? 0}`;
15491
+ }
15492
+ followed() {
15493
+ if (this.follow !== "") return this.getNodeOrNull(this.follow);
15494
+ if (this.cameraScanIn <= 0 || !this.camera || this.camera.tree === null) {
15495
+ this.camera = currentCamera(this.getRoot());
15496
+ this.cameraScanIn = CAMERA_SCAN_STEPS;
15497
+ }
15498
+ this.cameraScanIn -= 1;
15499
+ return this.camera;
15500
+ }
15501
+ update(dt) {
15502
+ if (this.tunedFor !== this.tuning()) this.tune();
15503
+ const t = this.kind === "snow" ? SNOW : RAIN;
15504
+ this.rate = t.rate * Math.max(0, Math.min(1, this.intensity));
15505
+ const target = this.followed();
15506
+ if (target) {
15507
+ const at = worldPosition(target);
15508
+ const parentAt = this.parent ? worldPosition(this.parent) : [
15509
+ 0,
15510
+ 0,
15511
+ 0
15512
+ ];
15513
+ this.position = [
15514
+ (at[0] ?? 0) - (parentAt[0] ?? 0),
15515
+ (at[1] ?? 0) + this.height - (parentAt[1] ?? 0),
15516
+ (at[2] ?? 0) - (parentAt[2] ?? 0)
15517
+ ];
15518
+ }
15519
+ super.update(dt);
15520
+ }
15521
+ };
15522
+ function currentCamera(root) {
15523
+ let first = null;
15524
+ const walk = (n) => {
15525
+ if (n instanceof Camera3D) {
15526
+ if (n.current) return n;
15527
+ first ??= n;
15528
+ }
15529
+ for (const c of n.children) {
15530
+ const hit = walk(c);
15531
+ if (hit) return hit;
15532
+ }
15533
+ return null;
15534
+ };
15535
+ return walk(root) ?? first;
15536
+ }
15537
+ //#endregion
13285
15538
  //#region src/3d/register.ts
13286
15539
  /**
13287
15540
  * Register the 3D node taxonomy (and the core nodes). Call once in your game
@@ -13291,6 +15544,10 @@ function registerNodes3D() {
13291
15544
  registerCoreNodes();
13292
15545
  registerNode(Node3D);
13293
15546
  registerNode(Particles3D);
15547
+ registerNode(Weather3D);
15548
+ registerNode(Flock3D);
15549
+ registerNode(Ragdoll3D);
15550
+ registerNode(Vehicle3D);
13294
15551
  registerNode(MeshInstance3D);
13295
15552
  registerNode(LoftMesh3D);
13296
15553
  registerNode(Sprite3D);
@@ -13353,4 +15610,4 @@ function resolveEnvironmentHdri(env) {
13353
15610
  return null;
13354
15611
  }
13355
15612
  //#endregion
13356
- export { Billboard3D as A, cameraRelative as B, Flowers3D as C, Camera3D as D, CharacterController3D as E, Joint3D as F, acquireOwnTexture as G, movementState as H, InstancedMesh3D as I, acquireTexture as K, MeshInstance3D as L, DEFAULT_TERRAIN_TEXTURE_BASE as M, TERRAIN_THEMES as N, BoneLookAt3D as O, terrainThemeLayers as P, buildMeshGeometry as R, DENSITY_PRESETS as S, FLOWER_VARIETIES as T, rigPose as U, keyboardIntensity as V, TextureCache3D as W, LoftMesh3D as _, Tree3D as a, SpotLight3D as b, buildRiverRings as c, riverCarveChannels as d, riverStepFor as f, ModelInstance3D as g, Particles3D as h, VoxelGrid3D as i, Terrain3D as j, BoneAttachment3D as k, findRiverCoverageGaps as l, traceDownhillPath as m, registerNodes3D as n, Trail3D as o, smoothCourse as p, VOXEL_PALETTE as r, River3D as s, resolveEnvironmentHdri as t, projectToRiver as u, DirectionalLight3D as v, resolveFlowerDensity as w, Foliage3D as x, OmniLight3D as y, QUARTER_PITCH as z };
15613
+ export { CharacterController3D as A, InstancedMesh3D as B, SpotLight3D as C, resolveFlowerDensity as D, Flowers3D as E, Terrain3D as F, keyboardIntensity as G, buildMeshGeometry as H, DEFAULT_TERRAIN_TEXTURE_BASE as I, TextureCache3D as J, movementState as K, TERRAIN_THEMES as L, BoneLookAt3D as M, BoneAttachment3D as N, FLOWER_VARIETIES as O, Billboard3D as P, terrainThemeLayers as R, OmniLight3D as S, DENSITY_PRESETS as T, QUARTER_PITCH as U, MeshInstance3D as V, cameraRelative as W, acquireTexture as X, acquireOwnTexture as Y, Ragdoll3D as _, VoxelGrid3D as a, LoftMesh3D as b, Trail3D as c, findRiverCoverageGaps as d, projectToRiver as f, traceDownhillPath as g, smoothCourse as h, VOXEL_PALETTE as i, Camera3D as j, Flock3D as k, River3D as l, riverStepFor as m, registerNodes3D as n, Vehicle3D as o, riverCarveChannels as p, rigPose as q, Weather3D as r, Tree3D as s, resolveEnvironmentHdri as t, buildRiverRings as u, Particles3D as v, Foliage3D as w, DirectionalLight3D as x, ModelInstance3D as y, Joint3D as z };