incanto 0.13.0 → 0.15.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 (29) hide show
  1. package/dist/3d.d.ts +40 -11
  2. package/dist/3d.js +3 -3
  3. package/dist/{create-game-J6g4QInv.js → create-game-BS-K-GY1.js} +2 -2
  4. package/dist/index.js +1 -1
  5. package/dist/{physics-3d-DhaAaKJo.js → physics-3d-TmOuCy7J.js} +1 -1
  6. package/dist/react.js +1 -1
  7. package/dist/{register-BFw4c83z.js → register-Bh_3Z39N.js} +164 -39
  8. package/dist/test.js +3 -3
  9. package/editor/assets/{agent8-BF8Jfbkl.js → agent8-DqQ2CQ5A.js} +1 -1
  10. package/editor/assets/{index-DSq0gdhE.js → index-CofH0yGY.js} +79 -7
  11. package/editor/index.html +1 -1
  12. package/package.json +1 -1
  13. package/schemas/scene.schema.json +19 -6
  14. package/skills/incanto-environment.md +22 -17
  15. package/skills/incanto-node-reference.md +7 -3
  16. package/templates-app/beacon-isle-3d/PROJECT/Status.md +1 -1
  17. package/templates-app/beacon-isle-3d/generate-world.ts +23 -20
  18. package/templates-app/beacon-isle-3d/package.json +1 -1
  19. package/templates-app/beacon-isle-3d/src/behaviors.ts +70 -4
  20. package/templates-app/beacon-isle-3d/src/game.scene.json +43 -67
  21. package/templates-app/tps-3d/package.json +1 -1
  22. package/templates-app/village-quest-3d/PROJECT/Context.md +1 -1
  23. package/templates-app/village-quest-3d/PROJECT/Requirements.md +1 -1
  24. package/templates-app/village-quest-3d/PROJECT/Structure.md +6 -1
  25. package/templates-app/village-quest-3d/generate-dressing.ts +769 -0
  26. package/templates-app/village-quest-3d/package.json +1 -1
  27. package/templates-app/village-quest-3d/src/behaviors.ts +74 -8
  28. package/templates-app/village-quest-3d/src/grove.scene.json +1 -13
  29. package/templates-app/village-quest-3d/src/village.scene.json +2989 -162
package/editor/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Incanto Scene Editor</title>
7
7
  <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='3' fill='%236ee7dc'/><text x='8' y='12' text-anchor='middle' font-size='11' font-family='monospace' fill='%230e1018'>i</text></svg>" />
8
- <script type="module" crossorigin src="./assets/index-DSq0gdhE.js"></script>
8
+ <script type="module" crossorigin src="./assets/index-CofH0yGY.js"></script>
9
9
  <link rel="modulepreload" crossorigin href="./assets/GameServer-C56iOUgF.js">
10
10
  <link rel="stylesheet" crossorigin href="./assets/index-D8QvwvOm.css">
11
11
  </head>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incanto",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Vibe-coding-first web game engine SDK — JSON-driven scenes on three.js",
5
5
  "keywords": [
6
6
  "game-engine",
@@ -2371,8 +2371,7 @@
2371
2371
  "default": 0.5
2372
2372
  },
2373
2373
  "drape": {
2374
- "type": "boolean",
2375
- "default": false
2374
+ "default": null
2376
2375
  },
2377
2376
  "terrain": {
2378
2377
  "type": "string",
@@ -2559,8 +2558,7 @@
2559
2558
  "default": 1
2560
2559
  },
2561
2560
  "drape": {
2562
- "type": "boolean",
2563
- "default": false
2561
+ "default": null
2564
2562
  },
2565
2563
  "terrain": {
2566
2564
  "type": "string",
@@ -5656,8 +5654,7 @@
5656
5654
  "default": true
5657
5655
  },
5658
5656
  "drape": {
5659
- "type": "boolean",
5660
- "default": false
5657
+ "default": null
5661
5658
  },
5662
5659
  "terrain": {
5663
5660
  "type": "string",
@@ -6452,6 +6449,22 @@
6452
6449
  "underwater": {
6453
6450
  "type": "boolean",
6454
6451
  "default": true
6452
+ },
6453
+ "swell": {
6454
+ "type": "number",
6455
+ "default": 0
6456
+ },
6457
+ "swellDirectionDeg": {
6458
+ "type": "number",
6459
+ "default": 0
6460
+ },
6461
+ "swellWavelength": {
6462
+ "type": "number",
6463
+ "default": 30
6464
+ },
6465
+ "whitecaps": {
6466
+ "type": "number",
6467
+ "default": 0
6455
6468
  }
6456
6469
  },
6457
6470
  "additionalProperties": false
@@ -358,14 +358,13 @@ tops radially away with a smooth falloff as a character wades through.
358
358
  | `coverage` | `0.85` | mesh: carpet fill in (0, 1] — below 1 noise carves dirt patches (load-time check) |
359
359
  | `flowers` | `0` | DEPRECATED — confetti heads; use a `Flowers3D` node (load-time check 0–0.2) |
360
360
  | `seed` | `1` | placement seed — identical field across runs/machines |
361
- | `drape` | `false` | **drape each blade onto a Terrain3D's surface** so a field follows rolling ground instead of floating on a flat plane — one big field covers a whole terrain |
362
- | `terrain` | `""` | drape target node path; empty = **auto-find the first Terrain3D** in the tree (the zero-config default when `drape` is on) |
361
+ | `drape` | `null` | **AUTO by default**: whenever the scene has a Terrain3D, every blade roots on the ground under it (samples `heightAt`) — omit this prop and rolling terrain just works. `true` forces it (keeps retrying until a terrain appears — streamed worlds), `false` opts out (flat carpet at the node's Y) |
362
+ | `terrain` | `""` | drape target node path; empty = **auto-find the first Terrain3D** in the tree. A non-empty path that is NOT a Terrain3D fails at load — no silent floating |
363
363
 
364
- > **Rolling terrain?** Set `drape: true` and the field's blades root on the
365
- > ground per-instance (it samples the Terrain3D's `heightAt`). Without draping a
366
- > Foliage3D/Flowers3D is a FLAT carpet at the node's Y, so on hills it
367
- > floats/sinks — either drape it, or place small patches at flat spots (the
368
- > generators still do the latter for backward-compatible output).
364
+ > **Rolling terrain just works.** Since 0.14 draping is on by default: with a
365
+ > Terrain3D in the scene the blades root on the ground per-instance, no wiring.
366
+ > Only set `drape: false` if you WANT a flat carpet at the node's Y (e.g. grass
367
+ > on a flat MeshInstance3D platform above the terrain).
369
368
 
370
369
  ## Flowers3D
371
370
 
@@ -415,8 +414,8 @@ billboard confetti. Deterministic from the `seed` PROP.
415
414
  | `height` | `0.45` | plant height, meters (±25% per-plant jitter) |
416
415
  | `clustering` | `0.6` | 0 uniform … 1 tight Voronoi patches (load-time check) |
417
416
  | `sway` | `0.5` | head-bob wind strength; 0 disables |
418
- | `drape` | `false` | drape each plant onto a Terrain3D's surface (rolling terrain) — see the Foliage3D drape note above |
419
- | `terrain` | `""` | drape target node path; empty = auto-find the first Terrain3D |
417
+ | `drape` | `null` | AUTO by default — plants root on the scene's Terrain3D when one exists; `true` forces, `false` opts out — see the Foliage3D drape note above |
418
+ | `terrain` | `""` | drape target node path; empty = auto-find the first Terrain3D (a wrong path fails at load) |
420
419
 
421
420
  ## Tree3D
422
421
  Procedural trees grown by the ported ez-tree generator (MIT, Dan Greenheck;
@@ -484,8 +483,8 @@ Every instance in a grove also gets a deterministic per-instance color jitter (
484
483
  | `leafFadeStart` | `0` | **leaf LOD** — leaf cards begin collapsing toward their branch at this camera distance (m). `0`/`0` disables (full leaves at any range) |
485
484
  | `leafFadeEnd` | `0` | leaf LOD — cards fully collapsed (zero overdraw) by here; tune ≈ the scene `fog.far` so the thinning hides in fog (load-time check: `> leafFadeStart`) |
486
485
  | `leafShadows` | `true` | `false` keeps trunk/branch shadows but drops the expensive alpha-cutout LEAF shadow pass — big FPS in dense forests |
487
- | `drape` | `false` | **drape a scattered grove onto a Terrain3D**: each instance roots at the ground height under it instead of the node's single Y, so a `count > 1` patch on rolling/carved terrain never floats or buries |
488
- | `terrain` | `""` | drape target node path; empty = auto-find the first Terrain3D (the zero-config default when `drape` is on) |
486
+ | `drape` | `null` | AUTO by default: each scattered instance roots at the ground height under it (instead of the node's single Y) whenever the scene has a Terrain3D — a `count > 1` grove on rolling/carved terrain never floats or buries. `true` forces, `false` opts out |
487
+ | `terrain` | `""` | drape target node path; empty = auto-find the first Terrain3D (a wrong path fails at load) |
489
488
 
490
489
  > **Tree LOD / forest FPS.** Leaf overdraw + the leaf shadow pass dominate a
491
490
  > dense-forest frame (~90% in profiling). For big groves set `leafFadeStart`/
@@ -493,11 +492,11 @@ Every instance in a grove also gets a deterministic per-instance color jitter (
493
492
  > leaf cards (branch silhouette stays), and `leafShadows: false` to drop the leaf
494
493
  > shadow pass. Both default off → existing scenes are unchanged.
495
494
 
496
- > **Floating trees?** A scattered grove (`count > 1`) places every instance at
497
- > the node's single Y, so on rolling or basin-carved terrain the far instances
498
- > float or sink. Set `drape: true` (auto-finds the Terrain3D) so each instance
499
- > roots on the ground under it. The drape is a pure heightAt lookup — no rng
500
- > draw, so the seed/grove layout is byte-identical, only the Y shifts.
495
+ > **Floating trees?** Since 0.14 groves drape automatically whenever the scene
496
+ > has a Terrain3D — each instance roots on the ground under it. The drape is a
497
+ > pure heightAt lookup — no rng draw, so the seed/grove layout is
498
+ > byte-identical, only the Y shifts. `drape: false` restores the flat
499
+ > single-Y placement.
501
500
 
502
501
  ## Terrain3D
503
502
  Procedural heightfield terrain with biome texture splatting (in `incanto/3d`,
@@ -610,7 +609,9 @@ non-default composition.
610
609
 
611
610
  A commercial-quality water surface (in `incanto/3d`, registered by
612
611
  `registerNodes3D`). The default `quality: 'fancy'` is a real water shader:
613
- 9-iteration simplex-FBM wave displacement with analytic normals, THREE
612
+ 9-iteration simplex-FBM wave displacement with analytic normals, optional
613
+ DIRECTIONAL TRAVELING SWELL trains + open-water whitecaps (`swell` /
614
+ `whitecaps` — the open-ocean look; see the prop table), THREE
614
615
  animated ANISOTROPIC detail-normal layers — wind-stretched (~2.8× along the
615
616
  wind), ridge-sharpened fine ripple in crossing directions, each layer
616
617
  distance-faded at its own range so the far field goes glassy instead of
@@ -669,6 +670,10 @@ hue reads grey.
669
670
  | `detailStrength` | `0.3` | animated detail-normal strength (≥ 0; 0 = vertex normals only) |
670
671
  | `absorption` | `0.15` | Beer's-law constant per meter of water depth (≥ 0; lower = clearer/wider turquoise band; red absorbs ~3× faster than blue under the hood) |
671
672
  | `refraction` | `true` | screen-space refraction of the submerged scene |
673
+ | `swell` | `0` | **THE open-ocean dial** (fancy only, 0.15.0): directional traveling swell amplitude in meters. Three peaked wave trains (primary + two crossing sets) march across the surface at real deep-water speeds; crests light up toward the peak color and wave FACES get sun-lit/shadow shading, so the swell reads at any distance. 0.15 = lively lake chop, 0.4 = coastal sea, 0.7 = heavy weather. `0` (default) keeps the legacy calm look byte-identical |
674
+ | `swellDirectionDeg` | `0` | compass direction the swell TRAVELS toward (0 = +z, 90 = +x) |
675
+ | `swellWavelength` | `30` | primary swell wavelength, meters (≥ 2, load-time check); the crossing trains derive from it |
676
+ | `whitecaps` | `0` | open-water whitecap foam on the tallest crests, 0–1 (fancy only; load-time check). Pairs with `swell` — the caps ride its crest lines and fade with distance so the horizon stays clean |
672
677
  | `underwater` | `true` | submerged-camera look: ONLY when the camera EYE is below this surface (inside its XZ footprint) — wading with the camera above the water never tints the view — the scene switches to short-range underwater fog + tint, the sky is hidden, and animated **caustics** (depth-aware, projected onto the submerged floor/props) play. `false` disables it; an object `{ color?, visibility?, caustics? }` overrides the murk hue / view distance (m, default 22) / caustics. `caustics` is `true` (default) \| `false` \| `{ color?, intensity?, scale?, speed? }`. The murk color defaults to a darkened shade of `color`. Works for both qualities (the caustics composite runs only while the camera is genuinely underwater) |
673
678
 
674
679
  Performance notes (fancy): beyond drawing the surface, each rendered frame
@@ -330,7 +330,7 @@ Signals: `movementStateChanged(state)`
330
330
  | `height` | `0.45` | number |
331
331
  | `clustering` | `0.6` | number |
332
332
  | `sway` | `0.5` | number |
333
- | `drape` | `false` | boolean |
333
+ | `drape` | `null` | null |
334
334
  | `terrain` | `""` | string |
335
335
 
336
336
  ## `Foliage3D` — `incanto/3d`
@@ -362,7 +362,7 @@ Signals: `movementStateChanged(state)`
362
362
  | `coverage` | `0.85` | number |
363
363
  | `flowers` | `0` | number |
364
364
  | `seed` | `1` | number |
365
- | `drape` | `false` | boolean |
365
+ | `drape` | `null` | null |
366
366
  | `terrain` | `""` | string |
367
367
 
368
368
  ## `HudLayer` — `incanto`
@@ -836,7 +836,7 @@ Signals: `timeout`
836
836
  | `leafFadeStart` | `0` | number |
837
837
  | `leafFadeEnd` | `0` | number |
838
838
  | `leafShadows` | `true` | boolean |
839
- | `drape` | `false` | boolean |
839
+ | `drape` | `null` | null |
840
840
  | `terrain` | `""` | string |
841
841
 
842
842
  ## `UILayer` — `incanto/2d`
@@ -953,6 +953,10 @@ Signals: `blocksChanged`
953
953
  | `absorption` | `0.15` | number |
954
954
  | `refraction` | `true` | boolean |
955
955
  | `underwater` | `true` | boolean |
956
+ | `swell` | `0` | number |
957
+ | `swellDirectionDeg` | `0` | number |
958
+ | `swellWavelength` | `30` | number |
959
+ | `whitecaps` | `0` | number |
956
960
 
957
961
  Signals: `entered(body)` · `exited(body)`
958
962
 
@@ -11,7 +11,7 @@
11
11
  dusk start → each lit ward buys back light → the boss drags the isle to a
12
12
  deep-red horizon (17.8 — sun kept ABOVE the horizon; below it the
13
13
  atmosphere sky and its IBL go black) → victory brings morning.
14
- - **Gliding**: hold JUMP while falling — capped descent + dash trail.
14
+ - **Gliding**: hold JUMP while falling — capped descent; landing thumps a dust puff.
15
15
  - Keeper NPC tracks the player with `BoneLookAt3D`; shoreline rocks are one
16
16
  `InstancedMesh3D` draw call.
17
17
 
@@ -101,7 +101,6 @@ for (let i = 0; i < 400 && grassPlaced < 14; i++) {
101
101
  position: [round2(x), round2(y), round2(z)],
102
102
  area: [34, 34],
103
103
  density: 10,
104
- terrain: '../../Ground/Surface',
105
104
  },
106
105
  });
107
106
  }
@@ -119,7 +118,6 @@ for (let i = 0; i < 300 && flowersPlaced < 9; i++) {
119
118
  position: [round2(x), round2(y), round2(z)],
120
119
  area: [12, 12],
121
120
  seed: Math.floor(rand() * 1e9),
122
- terrain: '../../Ground/Surface',
123
121
  },
124
122
  });
125
123
  }
@@ -180,7 +178,7 @@ const ward = (i: number, [x, y, z]: [number, number, number]): unknown => ({
180
178
  type: 'MeshInstance3D',
181
179
  props: {
182
180
  mesh: 'cylinder',
183
- size: [1.6, 1.2, 1.6],
181
+ size: [0.8, 1.2, 0.8], // RADIUS (cylinder size[0]) = collider box half-extent
184
182
  material: { color: '#7d8494', roughness: 0.9 },
185
183
  castShadow: true,
186
184
  receiveShadow: true,
@@ -319,7 +317,7 @@ const scene = {
319
317
  fog: { near: 110, far: 560 },
320
318
  shadows: { mapSize: 2048, radius: 1.2 },
321
319
  exposure: 1.12,
322
- ambient: { color: '#ffe8d4', intensity: 0.66 },
320
+ ambient: { color: '#ffe8d4', intensity: 0.85 },
323
321
  iblIntensity: 0.9,
324
322
  bloom: { threshold: 1.2, strength: 0.55 },
325
323
  post: { vignette: 0.28, saturation: 1.12, contrast: 1.02 },
@@ -382,7 +380,24 @@ const scene = {
382
380
  // must keep animating — auditScene flags exactly this mistake.
383
381
  children: (terrain.children ?? []).map((c) =>
384
382
  c.type === 'Water3D' || c.name === 'Clouds'
385
- ? c
383
+ ? c.name === 'Sea'
384
+ ? {
385
+ ...c,
386
+ // the OPEN-OCEAN dial (0.15.0): directional swell trains +
387
+ // whitecaps + deeper absorption — the sea moves, banded and
388
+ // alive, instead of a flat noise sheet
389
+ props: {
390
+ ...(c.props ?? {}),
391
+ swell: 0.55,
392
+ swellWavelength: 42,
393
+ swellDirectionDeg: 262,
394
+ whitecaps: 0.45,
395
+ absorption: 0.28,
396
+ reflectionInterval: 400,
397
+ colors: { trough: '#0c3d58', surface: '#1a7d9c', peak: '#9adfe0' },
398
+ },
399
+ }
400
+ : c
386
401
  : { ...c, props: { ...(c.props ?? {}), static: true } },
387
402
  ),
388
403
  },
@@ -409,7 +424,7 @@ const scene = {
409
424
  type: 'MeshInstance3D',
410
425
  props: {
411
426
  mesh: 'cylinder',
412
- size: [3, 11, 3],
427
+ size: [1.5, 11, 1.5], // RADIUS = collider half-extent (was 3 → camera clipped inside the 1.5 m shell)
413
428
  material: { color: '#e8e2d4', roughness: 0.8 },
414
429
  castShadow: true,
415
430
  receiveShadow: true,
@@ -420,7 +435,7 @@ const scene = {
420
435
  type: 'MeshInstance3D',
421
436
  props: {
422
437
  mesh: 'cylinder',
423
- size: [3.06, 1.6, 3.06],
438
+ size: [1.56, 1.6, 1.56],
424
439
  position: [0, 1.2, 0],
425
440
  material: { color: '#b8452e', roughness: 0.8 },
426
441
  },
@@ -584,18 +599,6 @@ const scene = {
584
599
  },
585
600
  ],
586
601
  },
587
- {
588
- name: 'DashTrail',
589
- type: 'Trail3D',
590
- props: {
591
- width: 0.5,
592
- color: '#ffffff',
593
- opacity: 0.28,
594
- seconds: 0.45,
595
- emitting: false,
596
- position: [0, -0.4, 0],
597
- },
598
- },
599
602
  {
600
603
  name: 'SwingSfx',
601
604
  type: 'AudioPlayer',
@@ -609,7 +612,7 @@ const scene = {
609
612
  type: 'Node',
610
613
  script: {
611
614
  name: 'DayNight',
612
- props: { paused: true, startHour: 17.5, nightDarkness: 0.42, maxSunElevationDeg: 48 },
615
+ props: { paused: true, startHour: 17.25, nightDarkness: 0.32, maxSunElevationDeg: 48 },
613
616
  },
614
617
  },
615
618
  {
@@ -14,7 +14,7 @@
14
14
  "@dimforge/rapier2d-compat": "0.19.3",
15
15
  "@dimforge/rapier3d-compat": "0.19.3",
16
16
  "@pixiv/three-vrm": "^3.5.3",
17
- "incanto": "^0.13.0",
17
+ "incanto": "^0.15.0",
18
18
  "three": "^0.184.0"
19
19
  },
20
20
  "devDependencies": {
@@ -21,7 +21,7 @@ import type {
21
21
  Voice,
22
22
  } from 'incanto';
23
23
  import { Behavior, findPath } from 'incanto';
24
- import type { MeshInstance3D, Node3D, Terrain3D, Trail3D } from 'incanto/3d';
24
+ import type { Camera3D, MeshInstance3D, Node3D, Terrain3D, Trail3D } from 'incanto/3d';
25
25
  import { buildTerrainNav, Particles3D, type TerrainNav } from 'incanto/3d';
26
26
  import { type DayNight, goToScene } from 'incanto/gameplay';
27
27
  import gameJsonRaw from './game.scene.json';
@@ -54,6 +54,39 @@ const PATROL_SPEED = 2.6;
54
54
  const HUNT_SPEED = 3.6;
55
55
  const WARD_LIGHT_RANGE = 4;
56
56
 
57
+ /** Sprint feel — see sprintFeel(): FOV kick + per-stride dust. */
58
+ const FOV_BASE = 60;
59
+ const FOV_SPRINT = 66.5;
60
+ const STRIDE_SECONDS = 0.22;
61
+
62
+ /**
63
+ * One kicked-up earth puff, world-anchored at the foot spot (one-shot,
64
+ * self-removing). Non-additive expanding dust — NOT a glow: it reads as
65
+ * dirt leaving the ground, then thins out.
66
+ */
67
+ function spawnFootDust(root: Node, at: [number, number, number], scale = 1): void {
68
+ const puff = new Particles3D('FootDust');
69
+ puff.position = [at[0], at[1], at[2]];
70
+ puff.rate = 0;
71
+ puff.burst = Math.round(4 * scale);
72
+ puff.blend = 'normal';
73
+ puff.lifetime = [0.3, 0.5];
74
+ puff.speed = [8, 34];
75
+ puff.directionDeg = -90; // up out of the ground…
76
+ puff.spreadDeg = 75;
77
+ puff.gravity = [0, 30]; // …then settle back down
78
+ puff.drag = 2.5;
79
+ puff.sizeStart = 11 * scale;
80
+ puff.sizeEnd = 30 * scale; // dust EXPANDS as it dissipates
81
+ puff.colorStart = '#c2b394'; // dry peat kicked into the dusk light
82
+ puff.colorEnd = '#8a7c66';
83
+ puff.alphaStart = 0.24;
84
+ puff.alphaEnd = 0;
85
+ puff.maxParticles = 24;
86
+ puff.on('finished', () => puff.free());
87
+ root.addChild(puff);
88
+ }
89
+
57
90
  export class IsleDirector extends Behavior {
58
91
  private wind: Voice | null = null;
59
92
  private nav: TerrainNav | null = null;
@@ -63,6 +96,9 @@ export class IsleDirector extends Behavior {
63
96
  private bobClock = 0;
64
97
  private fireworksIn = -1;
65
98
  private glideActive = false;
99
+ private strideClock = 0;
100
+ private wasAirborne = false;
101
+ private fov = FOV_BASE;
66
102
 
67
103
  private get dialogue(): UiDialogue {
68
104
  return this.node.getNode('HUD/Dialogue') as UiDialogue;
@@ -139,6 +175,37 @@ export class IsleDirector extends Behavior {
139
175
  }
140
176
  }
141
177
 
178
+ /**
179
+ * Sprint FEEL — speed you can see instead of a ribbon glued to the hips:
180
+ * - a soft camera FOV kick while at full sprint (eased both ways)
181
+ * - kicked-up earth at the feet, one puff per stride
182
+ * - a bigger thump-puff on landing after airtime (jump or glide)
183
+ */
184
+ private sprintFeel(dt: number, state: string): void {
185
+ const camera = this.node.getNodeOrNull('Camera') as Camera3D | null;
186
+ if (camera) {
187
+ const target = state === 'fastRun' ? FOV_SPRINT : FOV_BASE;
188
+ this.fov += (target - this.fov) * Math.min(1, dt * 6);
189
+ camera.fov = this.fov;
190
+ }
191
+ const player = this.node.getNodeOrNull('Player') as (Node3D & Positioned) | null;
192
+ if (!player) return;
193
+ const [px, py, pz] = player.position;
194
+ const feet: [number, number, number] = [px ?? 0, (py ?? 0) - 0.62, pz ?? 0];
195
+ const airborne = state === 'airborne';
196
+ if (this.wasAirborne && !airborne) spawnFootDust(this.node, feet, 1.7); // landing thump
197
+ this.wasAirborne = airborne;
198
+ if (state === 'fastRun') {
199
+ this.strideClock -= dt;
200
+ if (this.strideClock <= 0) {
201
+ this.strideClock = STRIDE_SECONDS;
202
+ spawnFootDust(this.node, feet);
203
+ }
204
+ } else {
205
+ this.strideClock = 0;
206
+ }
207
+ }
208
+
142
209
  private ambient(dt: number, engine: Engine | null | undefined): void {
143
210
  const controller = this.node.getNode('Player/Controller') as unknown as {
144
211
  state: string;
@@ -152,8 +219,7 @@ export class IsleDirector extends Behavior {
152
219
  : { pitch: 0.85, volume: 0.2 };
153
220
  this.wind.set(w);
154
221
  }
155
- const dash = this.node.getNodeOrNull('Player/DashTrail') as Trail3D | null;
156
- if (dash && controller) dash.emitting = controller.state === 'fastRun';
222
+ if (controller) this.sprintFeel(dt, controller.state);
157
223
  // shades hover-bob and face their travel
158
224
  this.bobClock += dt;
159
225
  for (const node of this.node.tree?.getNodesInGroup('shade') ?? []) {
@@ -241,7 +307,7 @@ export class IsleDirector extends Behavior {
241
307
  ? 8.7
242
308
  : quest.bossRisen
243
309
  ? 17.8 // sun ~+2 deg — deep red horizon, still lit (below 18 the sky dies)
244
- : 17.5 - quest.lit.size * 0.35; // each ward buys back the light
310
+ : 17.25 - quest.lit.size * 0.4; // each ward buys back the light
245
311
  sky.hour += (targetHour - sky.hour) * Math.min(1, dt * 0.4);
246
312
  }
247
313