@jgengine/shell 0.14.0 → 0.15.0-next.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 (114) hide show
  1. package/CHANGELOG.md +483 -1
  2. package/dist/GamePlayerShell.js +25 -5
  3. package/dist/Shell3dPresentation.d.ts +6 -6
  4. package/dist/Shell3dPresentation.js +4 -4
  5. package/dist/camera/ControllerCameraShake.d.ts +31 -0
  6. package/dist/camera/ControllerCameraShake.js +34 -0
  7. package/dist/camera/GameCameraRig.js +10 -5
  8. package/dist/camera/GameOrbitCamera.js +1 -10
  9. package/dist/camera/cameraRigs.js +12 -4
  10. package/dist/camera/index.d.ts +1 -0
  11. package/dist/camera/index.js +1 -0
  12. package/dist/camera/rigMath.d.ts +2 -0
  13. package/dist/camera/rigMath.js +7 -0
  14. package/dist/devtools/CollisionDebugWorld.js +2 -2
  15. package/dist/devtools/DevtoolsOverlay.js +2 -0
  16. package/dist/drivers/FrameDriver.d.ts +4 -1
  17. package/dist/drivers/FrameDriver.js +5 -3
  18. package/dist/environment/DayNightSky.d.ts +33 -0
  19. package/dist/environment/DayNightSky.js +51 -0
  20. package/dist/environment/Daylight.d.ts +3 -1
  21. package/dist/environment/Daylight.js +7 -3
  22. package/dist/environment/EnvironmentScene.js +6 -6
  23. package/dist/environment/RoadJunctions.d.ts +8 -4
  24. package/dist/environment/RoadJunctions.js +16 -15
  25. package/dist/environment/RoadRibbons.d.ts +42 -6
  26. package/dist/environment/RoadRibbons.js +150 -27
  27. package/dist/environment/VolumetricClouds.js +3 -3
  28. package/dist/environment/index.d.ts +1 -0
  29. package/dist/environment/index.js +1 -0
  30. package/dist/gameKit.d.ts +7 -4
  31. package/dist/gameKit.js +8 -5
  32. package/dist/inputSink.js +14 -0
  33. package/dist/materialOverride.js +30 -0
  34. package/dist/postfx/PostProcessing.d.ts +3 -2
  35. package/dist/postfx/PostProcessing.js +11 -3
  36. package/dist/postfx/ScreenEffectsOverlay.d.ts +32 -0
  37. package/dist/postfx/ScreenEffectsOverlay.js +70 -0
  38. package/dist/presentationResolve.d.ts +3 -2
  39. package/dist/presentationResolve.js +11 -6
  40. package/dist/render/CascadedShadows.d.ts +13 -0
  41. package/dist/render/CascadedShadows.js +96 -0
  42. package/dist/render/EnvironmentLighting.d.ts +23 -7
  43. package/dist/render/EnvironmentLighting.js +69 -10
  44. package/dist/render/PartMotion.js +4 -4
  45. package/dist/render/SceneLighting.js +7 -0
  46. package/dist/render/SceneModels.d.ts +37 -1
  47. package/dist/render/SceneModels.js +40 -11
  48. package/dist/render/modelLoad.d.ts +41 -5
  49. package/dist/render/modelLoad.js +85 -18
  50. package/dist/render/sceneCapture.d.ts +27 -0
  51. package/dist/render/sceneCapture.js +43 -0
  52. package/dist/render/useDisposable.d.ts +16 -0
  53. package/dist/render/useDisposable.js +24 -0
  54. package/dist/render/useModelAnimation.js +47 -4
  55. package/dist/scene/AuthoredScene.js +14 -24
  56. package/dist/scene/CityRenderer.js +103 -36
  57. package/dist/scene/GrassFieldRenderer.js +4 -1
  58. package/dist/scene/PoleLineRenderer.js +7 -16
  59. package/dist/settings/QuickControls.js +5 -3
  60. package/dist/settings/SettingsMenu.js +6 -3
  61. package/dist/settings/appliedSettings.d.ts +3 -1
  62. package/dist/settings/appliedSettings.js +17 -5
  63. package/dist/settings/settingsController.js +11 -5
  64. package/dist/shellMovement.d.ts +15 -1
  65. package/dist/shellMovement.js +15 -1
  66. package/dist/structures/GeneratedBuilding.js +4 -5
  67. package/dist/structures/TransformGizmo.js +6 -5
  68. package/dist/terrain/CarvedTerrain.js +3 -7
  69. package/dist/terrain/GrassField.js +2 -2
  70. package/dist/terrain/ProceduralGround.js +3 -5
  71. package/dist/terrain/grassMaterial.d.ts +8 -2
  72. package/dist/terrain/grassMaterial.js +24 -5
  73. package/dist/terrain/index.d.ts +1 -1
  74. package/dist/terrain/terrainDetailMaterial.js +11 -2
  75. package/dist/terrain/terrainMath.d.ts +38 -1
  76. package/dist/terrain/terrainMath.js +86 -64
  77. package/dist/touch/TouchControlsOverlay.d.ts +6 -2
  78. package/dist/touch/TouchControlsOverlay.js +136 -17
  79. package/dist/vfx/ParticleField.d.ts +32 -0
  80. package/dist/vfx/ParticleField.js +91 -0
  81. package/dist/vfx/WorldFloatingText.d.ts +32 -0
  82. package/dist/vfx/WorldFloatingText.js +32 -0
  83. package/dist/water/Ocean.js +3 -6
  84. package/dist/weather/LightningStrike.js +3 -9
  85. package/dist/weather/RainField.js +3 -7
  86. package/dist/weather/SnowField.js +3 -7
  87. package/dist/world/DataObjects.js +3 -6
  88. package/dist/world/GridWorldScene.js +3 -6
  89. package/dist/world/InstancedBodies.js +3 -6
  90. package/dist/world/InstancedJoints.js +2 -2
  91. package/dist/world/SpriteBatch.js +3 -6
  92. package/dist/world/WorldEntityFrames.d.ts +47 -0
  93. package/dist/world/WorldEntityFrames.js +75 -0
  94. package/dist/world/WorldHud.js +7 -5
  95. package/dist/world/WorldPings.d.ts +26 -0
  96. package/dist/world/WorldPings.js +68 -0
  97. package/dist/world/WorldScene.js +9 -4
  98. package/dist/world/pingPulse.d.ts +21 -0
  99. package/dist/world/pingPulse.js +17 -0
  100. package/package.json +5 -5
  101. package/skills/ce-handoff/SKILL.md +149 -0
  102. package/skills/game-design/SKILL.md +2 -0
  103. package/skills/jgengine/SKILL.md +3 -1
  104. package/skills/jgengine/api.md +17 -7
  105. package/skills/jgengine/capabilities.md +12 -0
  106. package/skills/jgengine/recipes/minimal-game.md +7 -4
  107. package/skills/jgengine-ui/SKILL.md +5 -3
  108. package/skills/jgengine-ui/api.md +708 -19
  109. package/skills/jgengine-ui/capabilities.md +362 -1
  110. package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
  111. package/skills/jgengine-ui/reference.md +7 -4
  112. package/skills/jgengine-world/api.md +378 -11
  113. package/skills/jgengine-world/capabilities.md +123 -2
  114. package/skills/jgengine-world/reference.md +15 -1
@@ -16,7 +16,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
16
16
  import { useEffect, useMemo, useRef, useState } from "react";
17
17
  import { useFrame } from "@react-three/fiber";
18
18
  import * as THREE from "three";
19
- import { roundPathCorners, buildRoadRibbon, dashSegments } from "@jgengine/core/world/roads";
19
+ import { roundPathCorners, buildJunctionSurface, buildRoadRibbon, dashSegments, GROUND_DECAL_LAYERS, trimPathAtJunctions, } from "@jgengine/core/world/roads";
20
20
  import { offsetPath } from "@jgengine/core/world/streets";
21
21
  import { triangulatePolygon, triangulateRingBand } from "@jgengine/core/world/cityGeometry";
22
22
  import { resolveCityObject, CITY_KIND, CITY_ZONE_KIND, } from "@jgengine/core/world/cityKind";
@@ -37,6 +37,27 @@ const CURB_COLOR = "#c4c0b6";
37
37
  const HEDGE_COLOR = "#3d6631";
38
38
  const PARK_COLORS = { plaza: "#b3ada0", green: "#4e7c41", meadow: "#6d8a4c", field: "#94805a", courtyard: "#5c8348", buffer: "#63734a" };
39
39
  const CROP_COLORS = ["#5d7a35", "#7a6b41", "#6f7f3a"];
40
+ /**
41
+ * City ground-decal stack. The single-owner {@link GROUND_DECAL_LAYERS} table fixes the authored-road
42
+ * heights, but a generated district drapes many more surface classes (sidewalk/park/median/curb) and
43
+ * wants more clearance over relief, so it keeps its own baseline while mirroring the table's ordering
44
+ * (terrain < road == junction < marking) and its two hard contracts: the welded junction surface
45
+ * shares the road layer (seam-welded, never stacked), and markings ride above the road with
46
+ * `polygonOffset`/`renderOrder` on the material. Values below preserve the district's prior look.
47
+ */
48
+ const SIDEWALK_LAYER = 0.1;
49
+ const PARK_LAYER = 0.12;
50
+ const DRIVEWAY_LAYER = 0.12;
51
+ const BRIDGE_DECK_LIFT = 0.12;
52
+ const PARKING_LAYER = 0.13;
53
+ const PLAZA_LAYER = 0.14;
54
+ const ROAD_LAYER = 0.16; // asphalt/gravel carriageway AND the welded junction surface (seam-shared)
55
+ const CROP_LAYER = 0.2;
56
+ const CURB_LAYER = 0.24;
57
+ const MARKING_LAYER = 0.26;
58
+ const MEDIAN_LAYER = 0.3;
59
+ /** Trim + weld tuning shared by {@link trimPathAtJunctions} and {@link buildJunctionSurface} for the district. */
60
+ const JUNCTION_OPTS = { curbReturnRadius: 3, apronMargin: 0.6, filletSegments: 6, elevation: ROAD_LAYER, maxSegmentLength: 2 };
40
61
  const SPECIES_PROXY = { broadleaf: "oak", conifer: "pine", palm: "palm", cypress: "cypress" };
41
62
  const SPECIES_SCALE = { broadleaf: 2.7, conifer: 2.5, palm: 2.6, cypress: 2.3 };
42
63
  /** Accumulates draped ribbons, patches, discs, and quads into one merged (optionally colored) buffer. */
@@ -64,6 +85,17 @@ class MeshAccumulator {
64
85
  this.indices.push(ribbon.indices[i] + offset);
65
86
  this.pushColor(ribbon.positions.length / 3, color);
66
87
  }
88
+ /** Append an already-draped ribbon/surface (absolute `[x,y,z]` positions) verbatim — e.g. a welded junction. */
89
+ addMesh(ribbon, color = null) {
90
+ if (ribbon.positions.length === 0)
91
+ return;
92
+ const offset = this.positions.length / 3;
93
+ for (let i = 0; i < ribbon.positions.length; i += 1)
94
+ this.positions.push(ribbon.positions[i]);
95
+ for (let i = 0; i < ribbon.indices.length; i += 1)
96
+ this.indices.push(ribbon.indices[i] + offset);
97
+ this.pushColor(ribbon.positions.length / 3, color);
98
+ }
67
99
  /** A ground-draped rotated rectangle, grid-subdivided so large patches follow the relief. */
68
100
  addPatch(center, size, rotationY, sample, elevation, color = null) {
69
101
  const cos = Math.cos(rotationY);
@@ -610,18 +642,43 @@ function OneCity({ object, context }) {
610
642
  ]) {
611
643
  const x = center[0] + along[0] * length * sa + perp[0] * width * sp;
612
644
  const z = center[1] + along[1] * length * sa + perp[1] * width * sp;
613
- markings.positions.push(x, deckSampler(x, z) + 0.26, z);
645
+ markings.positions.push(x, deckSampler(x, z) + MARKING_LAYER, z);
614
646
  }
615
647
  markings.indices.push(base, base + 2, base + 1, base + 1, base + 2, base + 3);
616
648
  };
649
+ // Trim carriageways back to their junction boundaries and weld one surface per crossing (no
650
+ // overlapping ribbons hidden under a floating disc). Street endpoints coincide bitwise with
651
+ // junction nodes, so `trimPathAtJunctions` cuts each rounded ribbon exactly at the crossing; the
652
+ // terminal corners feed `buildJunctionSurface` per intersection. Any crossing that gets no trimmed
653
+ // approach (e.g. sliced past the intersection cap) falls back to the old rounded outline below.
654
+ const intersectionInputs = resolved.intersections.map((cross) => ({
655
+ x: cross.x,
656
+ z: cross.z,
657
+ arms: cross.arms.map((arm) => ({ angle: arm.angle, width: arm.width })),
658
+ }));
659
+ const approachesByCross = new Map();
617
660
  for (const street of resolved.streets) {
618
661
  if (street.points.length < 2)
619
662
  continue;
620
663
  const rounded = roundPathCorners(street.points, Math.max(2.5, street.width * 1.15), 9);
621
664
  const surface = street.surface === "gravel" ? gravel : asphalt;
622
- surface.addRibbon(rounded, street.width, deckSampler, 0.16);
665
+ for (const sub of trimPathAtJunctions(rounded, street.width, intersectionInputs, JUNCTION_OPTS)) {
666
+ surface.addRibbon(sub.path, street.width, deckSampler, ROAD_LAYER);
667
+ for (const cut of sub.cuts) {
668
+ const approach = {
669
+ center: cut.center,
670
+ left: [cut.left[0], deckSampler(cut.left[0], cut.left[1]) + ROAD_LAYER, cut.left[1]],
671
+ right: [cut.right[0], deckSampler(cut.right[0], cut.right[1]) + ROAD_LAYER, cut.right[1]],
672
+ };
673
+ const list = approachesByCross.get(cut.junctionIndex);
674
+ if (list)
675
+ list.push(approach);
676
+ else
677
+ approachesByCross.set(cut.junctionIndex, [approach]);
678
+ }
679
+ }
623
680
  if (street.sidewalk && !useBlockSidewalks)
624
- sidewalks.addRibbon(rounded, street.width + 3.6, sample, 0.1);
681
+ sidewalks.addRibbon(rounded, street.width + 3.6, sample, SIDEWALK_LAYER);
625
682
  // Curbs + painted edge/lane lines make a flat ribbon read as a real carriageway. Both break at
626
683
  // junctions (the crossing owns its own pavement), so clip the centerline first.
627
684
  if (street.surface === "asphalt") {
@@ -631,26 +688,26 @@ function OneCity({ object, context }) {
631
688
  continue;
632
689
  const left = offsetPath(run, half);
633
690
  const right = offsetPath(run, -half);
634
- curbs.addRibbon(left, 0.55, deckSampler, 0.24);
635
- curbs.addRibbon(right, 0.55, deckSampler, 0.24);
691
+ curbs.addRibbon(left, 0.55, deckSampler, CURB_LAYER);
692
+ curbs.addRibbon(right, 0.55, deckSampler, CURB_LAYER);
636
693
  if (street.level !== "lane") {
637
694
  // White edge (fog) lines just inside the curb.
638
- markings.addRibbon(offsetPath(run, half - 0.75), 0.16, deckSampler, 0.26);
639
- markings.addRibbon(offsetPath(run, -(half - 0.75)), 0.16, deckSampler, 0.26);
695
+ markings.addRibbon(offsetPath(run, half - 0.75), 0.16, deckSampler, MARKING_LAYER);
696
+ markings.addRibbon(offsetPath(run, -(half - 0.75)), 0.16, deckSampler, MARKING_LAYER);
640
697
  // Avenue+ get dashed lane dividers either side of the centerline.
641
698
  if (street.level === "avenue") {
642
699
  for (const dash of dashSegments(offsetPath(run, half * 0.5), 2.4, 3.2))
643
- markings.addRibbon(dash, 0.13, deckSampler, 0.26);
700
+ markings.addRibbon(dash, 0.13, deckSampler, MARKING_LAYER);
644
701
  for (const dash of dashSegments(offsetPath(run, -half * 0.5), 2.4, 3.2))
645
- markings.addRibbon(dash, 0.13, deckSampler, 0.26);
702
+ markings.addRibbon(dash, 0.13, deckSampler, MARKING_LAYER);
646
703
  }
647
704
  }
648
705
  }
649
706
  }
650
707
  if (street.bulb !== undefined) {
651
- (street.surface === "gravel" ? gravel : asphalt).addDisc(street.bulb, street.width * 1.15, sample, 0.16);
708
+ (street.surface === "gravel" ? gravel : asphalt).addDisc(street.bulb, street.width * 1.15, sample, ROAD_LAYER);
652
709
  if (street.sidewalk)
653
- sidewalks.addDisc(street.bulb, street.width * 1.15 + 1.8, sample, 0.1);
710
+ sidewalks.addDisc(street.bulb, street.width * 1.15 + 1.8, sample, SIDEWALK_LAYER);
654
711
  }
655
712
  if (street.surface === "asphalt" && street.level !== "lane") {
656
713
  if (street.level === "boulevard") {
@@ -664,9 +721,9 @@ function OneCity({ object, context }) {
664
721
  if (runLength < 7)
665
722
  continue;
666
723
  const width = Math.max(1.3, street.width * 0.14);
667
- medians.addRibbon(run, width, deckSampler, 0.3);
668
- medians.addDisc(run[0], width / 2, deckSampler, 0.3, null, 8);
669
- medians.addDisc(run[run.length - 1], width / 2, deckSampler, 0.3, null, 8);
724
+ medians.addRibbon(run, width, deckSampler, MEDIAN_LAYER);
725
+ medians.addDisc(run[0], width / 2, deckSampler, MEDIAN_LAYER, null, 8);
726
+ medians.addDisc(run[run.length - 1], width / 2, deckSampler, MEDIAN_LAYER, null, 8);
670
727
  }
671
728
  }
672
729
  else {
@@ -681,40 +738,50 @@ function OneCity({ object, context }) {
681
738
  }
682
739
  }
683
740
  if (!inCross)
684
- markings.addRibbon(dash, 0.18, deckSampler, 0.26);
741
+ markings.addRibbon(dash, 0.18, deckSampler, MARKING_LAYER);
685
742
  }
686
743
  }
687
744
  }
688
745
  }
746
+ // NOTE: streetGenerator's `Street.sidewalks` offset polylines are NOT surfaced here — this renderer
747
+ // consumes `resolveCityObject`'s `CityStreet` (which carries only a `sidewalk` boolean), and its
748
+ // sidewalks come from richer block curb→land bands below. Wiring the raw offset polylines would be a
749
+ // second, redundant sidewalk source, so it is intentionally skipped for this component's data flow.
689
750
  // Sidewalk band per block: the region between the curb ring (outer) and the land ring (inner).
690
751
  // Blocks whose two rings coincide (no sidewalk) drop out — every band triangle is degenerate,
691
752
  // so `addRingBand` emits nothing.
692
753
  if (useBlockSidewalks) {
693
754
  for (const block of resolved.blocks) {
694
- sidewalks.addRingBand(block.curb, block.polygon, sample, 0.1);
755
+ sidewalks.addRingBand(block.curb, block.polygon, sample, SIDEWALK_LAYER);
695
756
  }
696
757
  }
697
758
  // Junctions: corner curb-return apron (sidewalk ring peeking out under the asphalt patch), the
698
759
  // patch itself, and a zebra crossing on every REAL arm — but only where the crossing warrants
699
760
  // it. Avenue-and-up arms (or a boulevard/avenue junction) get zebra stripes and the apron;
700
761
  // small residential crossings get just the asphalt patch.
701
- for (const cross of resolved.intersections) {
762
+ resolved.intersections.forEach((cross, crossIndex) => {
702
763
  const bigLevel = cross.level === "boulevard" || cross.level === "avenue";
703
764
  const armZebra = (width) => bigLevel || width >= resolved.rules.streetWidth * 1.4;
704
765
  const paved = resolved.rules.sidewalks && cross.level !== "lane";
705
- // The junction pavement is the roads' union with rounded curb corners — a real crossing, not a
706
- // stamped circle. A slightly larger apron underneath reads as the wrapped sidewalk/curb.
707
- const outline = junctionOutline(cross);
708
- if (outline !== null) {
709
- if (paved) {
710
- const apron = junctionOutline(cross, 1.15, resolved.rules.sidewalkWidth + 0.4);
711
- if (apron !== null)
712
- sidewalks.addPolygon(apron, sample, 0.1);
713
- }
714
- patches.addPolygon(outline, deckSampler, 0.18);
766
+ // A slightly larger apron underneath the crossing reads as the wrapped sidewalk/curb.
767
+ if (paved) {
768
+ const apron = junctionOutline(cross, 1.15, resolved.rules.sidewalkWidth + 0.4);
769
+ if (apron !== null)
770
+ sidewalks.addPolygon(apron, sample, SIDEWALK_LAYER);
771
+ }
772
+ // The junction pavement is welded onto the trimmed ribbon ends (seam-shared, no floating disc).
773
+ // Crossings that got no trimmed approach fall back to the rounded outline / disc, so coverage
774
+ // never regresses.
775
+ const approaches = approachesByCross.get(crossIndex);
776
+ if (approaches !== undefined && approaches.length > 0) {
777
+ patches.addMesh(buildJunctionSurface({ x: cross.x, z: cross.z }, approaches, deckSampler, JUNCTION_OPTS));
715
778
  }
716
779
  else {
717
- patches.addDisc([cross.x, cross.z], cross.radius, deckSampler, 0.18);
780
+ const outline = junctionOutline(cross);
781
+ if (outline !== null)
782
+ patches.addPolygon(outline, deckSampler, ROAD_LAYER);
783
+ else
784
+ patches.addDisc([cross.x, cross.z], cross.radius, deckSampler, ROAD_LAYER);
718
785
  }
719
786
  if (paved) {
720
787
  const maxHalf = Math.max(...cross.arms.map((arm) => arm.width / 2));
@@ -733,12 +800,12 @@ function OneCity({ object, context }) {
733
800
  }
734
801
  }
735
802
  }
736
- }
803
+ });
737
804
  for (const drive of resolved.driveways) {
738
- (drive.surface === "gravel" ? gravelDrives : pavementDrives).addRibbon(drive.points, drive.width, sample, 0.12);
805
+ (drive.surface === "gravel" ? gravelDrives : pavementDrives).addRibbon(drive.points, drive.width, sample, DRIVEWAY_LAYER);
739
806
  }
740
807
  for (const pad of resolved.parkingLots) {
741
- pavementDrives.addPatch(pad.center, pad.size, pad.rotationY, sample, 0.13);
808
+ pavementDrives.addPatch(pad.center, pad.size, pad.rotationY, sample, PARKING_LAYER);
742
809
  // Stall separators along the pad.
743
810
  const cos = Math.cos(pad.rotationY);
744
811
  const sin = Math.sin(pad.rotationY);
@@ -753,7 +820,7 @@ function OneCity({ object, context }) {
753
820
  for (const park of resolved.parks) {
754
821
  parkColor.set(PARK_COLORS[park.type]);
755
822
  parkColor.multiplyScalar(0.92 + park.jitter * 0.16);
756
- const elevation = park.type === "plaza" ? 0.14 : 0.12;
823
+ const elevation = park.type === "plaza" ? PLAZA_LAYER : PARK_LAYER;
757
824
  // Block-pipeline parks carry a real road-derived polygon; the rect is only a coarse proxy.
758
825
  if (park.polygon !== undefined)
759
826
  parks.addPolygon(park.polygon, sample, elevation, parkColor);
@@ -763,7 +830,7 @@ function OneCity({ object, context }) {
763
830
  const crop = new THREE.Color(CROP_COLORS[Math.floor(park.jitter * CROP_COLORS.length) % CROP_COLORS.length]);
764
831
  crop.multiplyScalar(0.9 + park.jitter * 0.2);
765
832
  for (const row of park.rows)
766
- crops.addRibbon(row, 1.05, sample, 0.2, crop);
833
+ crops.addRibbon(row, 1.05, sample, CROP_LAYER, crop);
767
834
  }
768
835
  }
769
836
  return {
@@ -817,7 +884,7 @@ function OneCity({ object, context }) {
817
884
  const arch = bridge.style === "arch" ? Math.sin(t * Math.PI) * Math.min(1.6, span * 0.03) : 0.25;
818
885
  return hA + (hB - hA) * t + arch;
819
886
  };
820
- decks.addRibbon(bridge.points, bridge.width + 1.2, deckY, 0.12);
887
+ decks.addRibbon(bridge.points, bridge.width + 1.2, deckY, BRIDGE_DECK_LIFT);
821
888
  // Curb strips give the deck visible thickness from the side.
822
889
  const tangent = [abx / span, abz / span];
823
890
  const normal = [-tangent[1], tangent[0]];
@@ -1005,7 +1072,7 @@ function OneCity({ object, context }) {
1005
1072
  }, [furniture]);
1006
1073
  if (resolved === null)
1007
1074
  return null;
1008
- return (_jsxs("group", { children: [ground?.asphalt != null ? (_jsx("mesh", { geometry: ground.asphalt, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.gravel != null ? (_jsx("mesh", { geometry: ground.gravel, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: GRAVEL_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.sidewalks != null ? (_jsx("mesh", { geometry: ground.sidewalks, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: SIDEWALK_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.patches != null ? (_jsx("mesh", { geometry: ground.patches, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.curbs != null ? (_jsx("mesh", { geometry: ground.curbs, receiveShadow: true, castShadow: true, children: _jsx("meshStandardMaterial", { color: CURB_COLOR, roughness: 0.9, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.markings != null ? (_jsx("mesh", { geometry: ground.markings, children: _jsx("meshStandardMaterial", { color: MARKING_COLOR, roughness: 0.8, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.medians != null ? (_jsx("mesh", { geometry: ground.medians, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: MEDIAN_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.pavementDrives != null ? (_jsx("mesh", { geometry: ground.pavementDrives, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: PAVEMENT_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.gravelDrives != null ? (_jsx("mesh", { geometry: ground.gravelDrives, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: GRAVEL_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.parks != null ? (_jsx("mesh", { geometry: ground.parks, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.crops != null ? (_jsx("mesh", { geometry: ground.crops, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, bridges?.deck != null ? (_jsx("mesh", { geometry: bridges.deck, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: CONCRETE_COLOR, roughness: 0.85, metalness: 0.05, side: THREE.DoubleSide }) })) : null, bridges !== null && bridges.concrete.length > 0 ? (_jsx(InstancedBoxes, { matrices: bridges.concrete, color: CONCRETE_COLOR })) : null, bridges !== null && bridges.steel.length > 0 ? _jsx(InstancedBoxes, { matrices: bridges.steel, color: STEEL_COLOR }) : null, tunnels?.roof != null ? (_jsx("mesh", { geometry: tunnels.roof, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: TUNNEL_COLOR, roughness: 0.95, metalness: 0, side: THREE.DoubleSide }) })) : null, tunnels !== null && tunnels.stone.length > 0 ? _jsx(InstancedBoxes, { matrices: tunnels.stone, color: TUNNEL_COLOR }) : null, massing !== null ? massing.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `massing:${i}`)) : null, furniture !== null ? furniture.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `furniture:${i}`)) : null, _jsx(DetailBuildings, { buildings: detail.buildings, palette: palette })] }));
1075
+ return (_jsxs("group", { children: [ground?.asphalt != null ? (_jsx("mesh", { geometry: ground.asphalt, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.gravel != null ? (_jsx("mesh", { geometry: ground.gravel, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: GRAVEL_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.sidewalks != null ? (_jsx("mesh", { geometry: ground.sidewalks, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: SIDEWALK_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.patches != null ? (_jsx("mesh", { geometry: ground.patches, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.curbs != null ? (_jsx("mesh", { geometry: ground.curbs, receiveShadow: true, castShadow: true, children: _jsx("meshStandardMaterial", { color: CURB_COLOR, roughness: 0.9, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.markings != null ? (_jsx("mesh", { geometry: ground.markings, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: MARKING_COLOR, roughness: 0.8, metalness: 0, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null, ground?.medians != null ? (_jsx("mesh", { geometry: ground.medians, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: MEDIAN_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.pavementDrives != null ? (_jsx("mesh", { geometry: ground.pavementDrives, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: PAVEMENT_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.gravelDrives != null ? (_jsx("mesh", { geometry: ground.gravelDrives, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: GRAVEL_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.parks != null ? (_jsx("mesh", { geometry: ground.parks, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.crops != null ? (_jsx("mesh", { geometry: ground.crops, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, bridges?.deck != null ? (_jsx("mesh", { geometry: bridges.deck, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: CONCRETE_COLOR, roughness: 0.85, metalness: 0.05, side: THREE.DoubleSide }) })) : null, bridges !== null && bridges.concrete.length > 0 ? (_jsx(InstancedBoxes, { matrices: bridges.concrete, color: CONCRETE_COLOR })) : null, bridges !== null && bridges.steel.length > 0 ? _jsx(InstancedBoxes, { matrices: bridges.steel, color: STEEL_COLOR }) : null, tunnels?.roof != null ? (_jsx("mesh", { geometry: tunnels.roof, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: TUNNEL_COLOR, roughness: 0.95, metalness: 0, side: THREE.DoubleSide }) })) : null, tunnels !== null && tunnels.stone.length > 0 ? _jsx(InstancedBoxes, { matrices: tunnels.stone, color: TUNNEL_COLOR }) : null, massing !== null ? massing.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `massing:${i}`)) : null, furniture !== null ? furniture.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `furniture:${i}`)) : null, _jsx(DetailBuildings, { buildings: detail.buildings, palette: palette })] }));
1009
1076
  }
1010
1077
  /** One instanced unit-box mesh from precomposed matrices (bridge parts). */
1011
1078
  function InstancedBoxes({ matrices, color }) {
@@ -63,7 +63,10 @@ function OneGrass({ object, context }) {
63
63
  return null;
64
64
  const { rules, center } = resolved;
65
65
  // Size the instance buffer to the authored patch (bounded): the default 1500-blade buffer reads
66
- // as stubble on anything bigger than a lawn, silently ignoring the authored density.
66
+ // as stubble on anything bigger than a lawn, silently ignoring the authored density. The 250k cap
67
+ // is independent of the (now wider) camera distance fade — instances are placed across the patch
68
+ // footprint, and the fade only thins how many *draw* at range in the vertex shader — so a deeper
69
+ // meadow reads expansive without raising the instance ceiling.
67
70
  const instanceCount = Math.max(1500, Math.min(250000, Math.ceil(rules.density * area[0] * area[1])));
68
71
  return (_jsx(GrassField, { position: [center[0], center[1], center[2]], area: area, count: instanceCount, density: rules.density, bladeHeight: bladeHeight, colorBase: rules.colorBase, colorTip: rules.colorTip, colorGround: context.groundColorAt?.(center[0], center[2]), colorVariation: rules.colorVariation, roughness: rules.roughness, seed: hashSeed(rules.seed.length > 0 ? rules.seed : object.id), heightAt: heightAt, wind: wind, edgeFeather: 2.2, exclude: exclude }));
69
72
  }
@@ -13,6 +13,7 @@ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
13
13
  import { parseParams } from "@jgengine/core/scene/sceneKinds";
14
14
  import { readNamedSockets } from "@jgengine/core/scene/modelSockets";
15
15
  import { POLE_LINE_KIND, POLE_LINE_SCHEMA, resolvePoleLine } from "@jgengine/core/world/poleLineKind";
16
+ import { useDisposable } from "../render/useDisposable.js";
16
17
  import { registerSceneKindRenderer } from "./sceneKindRenderers.js";
17
18
  function Cables({ cables }) {
18
19
  const geometry = useMemo(() => {
@@ -139,25 +140,15 @@ function ProxyPoles({ poles, height, wireCount, wireSpacing, }) {
139
140
  const insulatorRef = useRef(null);
140
141
  const armLength = wireCount > 1 ? (wireCount - 1) * wireSpacing + 0.6 : 0.8;
141
142
  // Chunkier members than a real-scale pole: stylized worlds read thin cylinders as wire, not wood.
142
- const trunkGeometry = useMemo(() => new THREE.CylinderGeometry(0.13, 0.22, height, 10), [height]);
143
- const armGeometry = useMemo(() => new THREE.BoxGeometry(armLength, 0.17, 0.14), [armLength]);
144
- const braceGeometry = useMemo(() => {
143
+ const trunkGeometry = useDisposable(() => new THREE.CylinderGeometry(0.13, 0.22, height, 10), [height]);
144
+ const armGeometry = useDisposable(() => new THREE.BoxGeometry(armLength, 0.17, 0.14), [armLength]);
145
+ const braceGeometry = useDisposable(() => {
145
146
  const braceSpan = Math.hypot(armLength * 0.32, 0.7);
146
147
  return new THREE.BoxGeometry(0.07, braceSpan, 0.06);
147
148
  }, [armLength]);
148
- const insulatorGeometry = useMemo(() => new THREE.CylinderGeometry(0.06, 0.085, 0.24, 8), []);
149
- const trunkMaterial = useMemo(() => createWeatheredWoodMaterial("#6b533a"), []);
150
- const armMaterial = useMemo(() => createWeatheredWoodMaterial("#59452e", "x"), []);
151
- useEffect(() => () => {
152
- trunkGeometry.dispose();
153
- armGeometry.dispose();
154
- braceGeometry.dispose();
155
- insulatorGeometry.dispose();
156
- }, [trunkGeometry, armGeometry, braceGeometry, insulatorGeometry]);
157
- useEffect(() => () => {
158
- trunkMaterial.dispose();
159
- armMaterial.dispose();
160
- }, [trunkMaterial, armMaterial]);
149
+ const insulatorGeometry = useDisposable(() => new THREE.CylinderGeometry(0.06, 0.085, 0.24, 8), []);
150
+ const trunkMaterial = useDisposable(() => createWeatheredWoodMaterial("#6b533a"), []);
151
+ const armMaterial = useDisposable(() => createWeatheredWoodMaterial("#59452e", "x"), []);
161
152
  useEffect(() => {
162
153
  const trunk = trunkRef.current;
163
154
  const arm = armRef.current;
@@ -1,9 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
+ import { useDebouncedCommit } from "@jgengine/react/useDebouncedCommit";
3
4
  function QuickSlider({ row }) {
4
- const value = Number(row.value);
5
- const readout = row.format?.(value) ?? String(value);
6
- return (_jsxs("label", { className: "flex items-center gap-2 text-xs text-neutral-300", children: [_jsx("span", { className: "w-16 shrink-0 truncate", children: row.label }), _jsx("input", { type: "range", min: row.min, max: row.max, step: row.step, value: value, "aria-label": row.label, className: "h-1.5 flex-1 cursor-pointer accent-emerald-400", onChange: (event) => row.set(Number(event.target.value)) }), _jsx("span", { className: "w-9 shrink-0 text-right tabular-nums text-neutral-100", children: readout })] }));
5
+ // Live-mirror the thumb; debounce the persisting `set`, flush on release/blur (#1372).
6
+ const { value: local, onInput, flush } = useDebouncedCommit(Number(row.value), (next) => row.set(next));
7
+ const readout = row.format?.(local) ?? String(local);
8
+ return (_jsxs("label", { className: "flex items-center gap-2 text-xs text-neutral-300", children: [_jsx("span", { className: "w-16 shrink-0 truncate", children: row.label }), _jsx("input", { type: "range", min: row.min, max: row.max, step: row.step, value: local, "aria-label": row.label, className: "h-1.5 flex-1 cursor-pointer accent-emerald-400", onChange: (event) => onInput(Number(event.target.value)), onPointerUp: flush, onKeyUp: flush, onBlur: flush }), _jsx("span", { className: "w-9 shrink-0 text-right tabular-nums text-neutral-100", children: readout })] }));
7
9
  }
8
10
  function QuickToggle({ row }) {
9
11
  const on = Boolean(row.value);
@@ -1,5 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from "react";
3
+ import { useDebouncedCommit } from "@jgengine/react/useDebouncedCommit";
3
4
  import {} from "./settingsController.js";
4
5
  const surface = "var(--jg-surface, #16161d)";
5
6
  const surfaceDeep = "var(--jg-surface-deep, #0b0b11)";
@@ -26,9 +27,11 @@ function RowShell({ label, control }) {
26
27
  return (_jsxs("div", { className: "flex items-center justify-between gap-4 rounded-xl px-3.5 py-3", style: rowStyle, children: [_jsx("span", { className: "text-sm", style: { color: text }, children: label }), control] }));
27
28
  }
28
29
  function SliderRow({ row }) {
29
- const value = Number(row.value);
30
- const readout = row.format?.(value) ?? String(value);
31
- return (_jsx(RowShell, { label: row.label, control: _jsxs("div", { className: "flex flex-1 items-center gap-3 pl-4", children: [_jsx("input", { type: "range", min: row.min, max: row.max, step: row.step, value: value, "aria-label": row.label, className: "h-1.5 flex-1 cursor-pointer", style: { accentColor: accent }, onChange: (event) => row.set(Number(event.target.value)) }), _jsx("span", { className: "w-12 shrink-0 text-right text-sm tabular-nums", style: { color: text }, children: readout })] }) }));
30
+ // Mirror the thumb + readout locally; debounce the persisting `set` so a drag doesn't storm the
31
+ // settings store (and any graphics rebuild it drives), flushing on release/blur (#1372).
32
+ const { value: local, onInput, flush } = useDebouncedCommit(Number(row.value), (next) => row.set(next));
33
+ const readout = row.format?.(local) ?? String(local);
34
+ return (_jsx(RowShell, { label: row.label, control: _jsxs("div", { className: "flex flex-1 items-center gap-3 pl-4", children: [_jsx("input", { type: "range", min: row.min, max: row.max, step: row.step, value: local, "aria-label": row.label, className: "h-1.5 flex-1 cursor-pointer", style: { accentColor: accent }, onChange: (event) => onInput(Number(event.target.value)), onPointerUp: flush, onKeyUp: flush, onBlur: flush }), _jsx("span", { className: "w-12 shrink-0 text-right text-sm tabular-nums", style: { color: text }, children: readout })] }) }));
32
35
  }
33
36
  function ToggleRow({ row }) {
34
37
  const on = Boolean(row.value);
@@ -1,11 +1,13 @@
1
1
  import type { AudioBusDef } from "@jgengine/core/audio/audioFalloff";
2
2
  import { type GraphicsQuality, type SettingsStore } from "@jgengine/core/settings/settingsModel";
3
- import { type TouchStyle } from "@jgengine/core/input/touchScheme";
3
+ import { type TouchJoystickVariant, type TouchStyle } from "@jgengine/core/input/touchScheme";
4
4
  import type { AudioEngine } from "../audio/audioEngine.js";
5
5
  /** Sentinel Controls value meaning "defer to the game's suggested touch skin". */
6
6
  export declare const TOUCH_STYLE_AUTO = "auto";
7
7
  /** Shell-internal: binds the SettingsStore touch-style choice to the touch renderer. @internal */
8
8
  export declare function useTouchStyle(store: SettingsStore, fallback: TouchStyle): TouchStyle;
9
+ /** Shell-internal: binds the SettingsStore joystick-variant choice (fixed vs floating) to the touch renderer. @internal */
10
+ export declare function useTouchJoystickVariant(store: SettingsStore): TouchJoystickVariant;
9
11
  export declare function useSettingsRevision(store: SettingsStore): number;
10
12
  export declare function useGraphicsSettings(store: SettingsStore, shadowsDefault: boolean): {
11
13
  shadows: boolean;
@@ -1,6 +1,6 @@
1
- import { useEffect, useReducer } from "react";
1
+ import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
2
2
  import { busVolumeSettingId, DEFAULT_GRAPHICS_QUALITY, DEFAULT_MASTER_VOLUME, DEFAULT_UI_SCALE, GRAPHICS_QUALITY_DPR, SETTING_IDS, UI_SCALE_MAX, UI_SCALE_MIN, } from "@jgengine/core/settings/settingsModel";
3
- import { TOUCH_STYLES } from "@jgengine/core/input/touchScheme";
3
+ import { DEFAULT_TOUCH_JOYSTICK_VARIANT, TOUCH_JOYSTICK_VARIANTS, TOUCH_STYLES, } from "@jgengine/core/input/touchScheme";
4
4
  /** Sentinel Controls value meaning "defer to the game's suggested touch skin". */
5
5
  export const TOUCH_STYLE_AUTO = "auto";
6
6
  /** Shell-internal: binds the SettingsStore touch-style choice to the touch renderer. @internal */
@@ -9,10 +9,22 @@ export function useTouchStyle(store, fallback) {
9
9
  const raw = store.get(SETTING_IDS.touchStyle, TOUCH_STYLE_AUTO);
10
10
  return TOUCH_STYLES.includes(raw) ? raw : fallback;
11
11
  }
12
+ /** Shell-internal: binds the SettingsStore joystick-variant choice (fixed vs floating) to the touch renderer. @internal */
13
+ export function useTouchJoystickVariant(store) {
14
+ useSettingsRevision(store);
15
+ const raw = store.get(SETTING_IDS.touchJoystick, DEFAULT_TOUCH_JOYSTICK_VARIANT);
16
+ return TOUCH_JOYSTICK_VARIANTS.includes(raw)
17
+ ? raw
18
+ : DEFAULT_TOUCH_JOYSTICK_VARIANT;
19
+ }
12
20
  export function useSettingsRevision(store) {
13
- const [rev, bump] = useReducer((n) => n + 1, 0);
14
- useEffect(() => store.subscribe(() => bump()), [store]);
15
- return rev;
21
+ const versionRef = useRef(0);
22
+ const subscribe = useCallback((onStoreChange) => store.subscribe(() => {
23
+ versionRef.current += 1;
24
+ onStoreChange();
25
+ }), [store]);
26
+ const getVersion = useCallback(() => versionRef.current, []);
27
+ return useSyncExternalStore(subscribe, getVersion, getVersion);
16
28
  }
17
29
  export function useGraphicsSettings(store, shadowsDefault) {
18
30
  useSettingsRevision(store);
@@ -1,8 +1,7 @@
1
- import { useEffect, useReducer } from "react";
2
1
  import { actionLabel, bindingLabel } from "@jgengine/core/input/actionBindings";
3
2
  import { BUILT_IN_SETTING_CATEGORIES, busVolumeSettingId, DEFAULT_GRAPHICS_QUALITY, DEFAULT_GRAPHICS_SHADOWS, DEFAULT_MASTER_VOLUME, DEFAULT_UI_SCALE, GRAPHICS_QUALITY_OPTIONS, SETTING_IDS, UI_SCALE_MAX, UI_SCALE_MIN, } from "@jgengine/core/settings/settingsModel";
4
- import { TOUCH_STYLE_OPTIONS } from "@jgengine/core/input/touchScheme";
5
- import { TOUCH_STYLE_AUTO } from "./appliedSettings.js";
3
+ import { DEFAULT_TOUCH_JOYSTICK_VARIANT, TOUCH_JOYSTICK_VARIANT_OPTIONS, TOUCH_STYLE_OPTIONS, } from "@jgengine/core/input/touchScheme";
4
+ import { TOUCH_STYLE_AUTO, useSettingsRevision } from "./appliedSettings.js";
6
5
  import { useSettingsStore, } from "@jgengine/react/settings";
7
6
  import { usePlayerFov } from "../camera/PlayerFov.js";
8
7
  const DEFAULT_CATEGORY_LABELS = {
@@ -19,8 +18,7 @@ const TOUCH_STYLE_SELECT_OPTIONS = [
19
18
  export function useSettingsCategories(config) {
20
19
  const store = useSettingsStore();
21
20
  const fov = usePlayerFov();
22
- const [, force] = useReducer((n) => n + 1, 0);
23
- useEffect(() => store.subscribe(() => force()), [store]);
21
+ useSettingsRevision(store);
24
22
  const hidden = new Set(config.hide);
25
23
  const extrasFor = (category) => config.extra
26
24
  .filter((def) => def.category === category)
@@ -136,6 +134,14 @@ export function useSettingsCategories(config) {
136
134
  options: TOUCH_STYLE_SELECT_OPTIONS,
137
135
  set: (value) => store.set(SETTING_IDS.touchStyle, value),
138
136
  },
137
+ {
138
+ id: SETTING_IDS.touchJoystick,
139
+ label: "Joystick",
140
+ kind: "select",
141
+ value: store.get(SETTING_IDS.touchJoystick, DEFAULT_TOUCH_JOYSTICK_VARIANT),
142
+ options: TOUCH_JOYSTICK_VARIANT_OPTIONS.map((option) => ({ value: option.value, label: option.label })),
143
+ set: (value) => store.set(SETTING_IDS.touchJoystick, value),
144
+ },
139
145
  ]
140
146
  : []),
141
147
  ...extrasFor("controls"),
@@ -1,5 +1,19 @@
1
- /** Movement action names the shell drives via the walk controller. @internal */
1
+ /** Movement action names the shell drives via the walk controller. */
2
2
  export declare const SHELL_MOVEMENT_ACTIONS: readonly ["moveForward", "moveBack", "moveLeft", "moveRight", "jump"];
3
+ /**
4
+ * Default keyboard codes for the shell walk controller (WASD + Space).
5
+ * Use as the base of `defineGame({ input: { ...DEFAULT_WALK_CODES, interact: ["KeyE"] } })`
6
+ * instead of re-typing the same five bindings in every game.
7
+ *
8
+ * @capability default-walk-codes stock WASD + jump key codes for the shell walk controller
9
+ */
10
+ export declare const DEFAULT_WALK_CODES: {
11
+ readonly moveForward: readonly ["KeyW"];
12
+ readonly moveBack: readonly ["KeyS"];
13
+ readonly moveLeft: readonly ["KeyA"];
14
+ readonly moveRight: readonly ["KeyD"];
15
+ readonly jump: readonly ["Space"];
16
+ };
3
17
  /**
4
18
  * True when the game's input map binds any walk-controller action — the shell owns pose
5
19
  * for those games. Games that omit all five drive pose themselves.
@@ -1,5 +1,19 @@
1
- /** Movement action names the shell drives via the walk controller. @internal */
1
+ /** Movement action names the shell drives via the walk controller. */
2
2
  export const SHELL_MOVEMENT_ACTIONS = ["moveForward", "moveBack", "moveLeft", "moveRight", "jump"];
3
+ /**
4
+ * Default keyboard codes for the shell walk controller (WASD + Space).
5
+ * Use as the base of `defineGame({ input: { ...DEFAULT_WALK_CODES, interact: ["KeyE"] } })`
6
+ * instead of re-typing the same five bindings in every game.
7
+ *
8
+ * @capability default-walk-codes stock WASD + jump key codes for the shell walk controller
9
+ */
10
+ export const DEFAULT_WALK_CODES = {
11
+ moveForward: ["KeyW"],
12
+ moveBack: ["KeyS"],
13
+ moveLeft: ["KeyA"],
14
+ moveRight: ["KeyD"],
15
+ jump: ["Space"],
16
+ };
3
17
  /**
4
18
  * True when the game's input map binds any walk-controller action — the shell owns pose
5
19
  * for those games. Games that omit all five drive pose themselves.
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
2
+ import { useLayoutEffect, useMemo, useRef } from "react";
3
3
  import * as THREE from "three";
4
+ import { useDisposable } from "../render/useDisposable.js";
4
5
  const DEFAULT_PALETTE = {
5
6
  wall: "#83766a",
6
7
  window: "#8ecae6",
@@ -116,8 +117,7 @@ function bucketPartMatrices(buildings, visible) {
116
117
  }
117
118
  function BuildingKindBatch({ kind, matrices, palette, geometry, }) {
118
119
  const meshRef = useRef(null);
119
- const material = useMemo(() => batchMaterialFor(kind, palette), [kind, palette]);
120
- useEffect(() => () => material.dispose(), [material]);
120
+ const material = useDisposable(() => batchMaterialFor(kind, palette), [kind, palette]);
121
121
  useLayoutEffect(() => {
122
122
  const mesh = meshRef.current;
123
123
  if (mesh === null)
@@ -129,8 +129,7 @@ function BuildingKindBatch({ kind, matrices, palette, geometry, }) {
129
129
  return (_jsx("instancedMesh", { ref: meshRef, args: [geometry, material, matrices.length], castShadow: true, receiveShadow: true }, matrices.length));
130
130
  }
131
131
  export function InstancedBuildings({ buildings, palette, visibleKinds }) {
132
- const geometry = useMemo(() => new THREE.BoxGeometry(1, 1, 1), []);
133
- useEffect(() => () => geometry.dispose(), [geometry]);
132
+ const geometry = useDisposable(() => new THREE.BoxGeometry(1, 1, 1), []);
134
133
  const buckets = useMemo(() => {
135
134
  const visible = visibleKinds === undefined ? null : new Set(visibleKinds);
136
135
  return bucketPartMatrices(buildings, visible);
@@ -1,7 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { TransformControls } from "@react-three/drei";
3
3
  import { useThree } from "@react-three/fiber";
4
- import { memo, useEffect, useRef, useState } from "react";
4
+ import { memo, useCallback, useEffect, useRef, useState } from "react";
5
5
  /** Default rotation snap increment (radians) when callers don't configure one. */
6
6
  export const DEFAULT_ROTATION_SNAP = Math.PI / 12;
7
7
  /**
@@ -15,9 +15,10 @@ export const TransformGizmo = memo(function TransformGizmo({ position, rotationY
15
15
  const draggingRef = useRef(false);
16
16
  const controls = useThree((state) => state.controls);
17
17
  const anchorKey = `${position.x}:${position.y}:${position.z}:${rotationY}:${mode}`;
18
- useEffect(() => {
19
- setObject(groupRef.current);
20
- }, [anchorKey]);
18
+ const anchorRef = useCallback((node) => {
19
+ groupRef.current = node;
20
+ setObject(node);
21
+ }, []);
21
22
  useEffect(() => {
22
23
  const group = groupRef.current;
23
24
  if (group === null || draggingRef.current)
@@ -50,7 +51,7 @@ export const TransformGizmo = memo(function TransformGizmo({ position, rotationY
50
51
  scale: { x: current.scale.x, y: current.scale.y, z: current.scale.z },
51
52
  });
52
53
  };
53
- return (_jsxs(_Fragment, { children: [_jsx("group", { ref: groupRef }), object !== null ? (_jsx(TransformControls, { object: object, mode: mode, size: size, space: space, ...snapProps, onMouseDown: () => {
54
+ return (_jsxs(_Fragment, { children: [_jsx("group", { ref: anchorRef }), object !== null ? (_jsx(TransformControls, { object: object, mode: mode, size: size, space: space, ...snapProps, onMouseDown: () => {
54
55
  draggingRef.current = true;
55
56
  if (controls !== null && "enabled" in controls)
56
57
  controls.enabled = false;
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import {} from "@react-three/fiber";
3
- import { useEffect, useMemo } from "react";
4
3
  import * as THREE from "three";
4
+ import { useDisposable } from "../render/useDisposable.js";
5
5
  import { createFieldGroundGeometry } from "./terrainMath.js";
6
6
  /**
7
7
  * Renders a `TerrainField` as a deformed ground mesh — the crater/mound view for destructible terrain.
@@ -9,12 +9,8 @@ import { createFieldGroundGeometry } from "./terrainMath.js";
9
9
  * once `epoch` changes. Pair with `InstancedBodies` to see debris resting in the crater it blasted.
10
10
  */
11
11
  export function CarvedTerrain({ field, size, segments, center, colors, heightRange, paletteAt, roughness = 0.95, metalness = 0, surfaceMaterial, receiveShadow = true, epoch = 0, ...meshProps }) {
12
- const geometry = useMemo(() => createFieldGroundGeometry(field, { size, segments, center, colors, heightRange, paletteAt }),
13
- // eslint-disable-next-line react-hooks/exhaustive-deps
14
- [field, size, segments, center, colors, heightRange, paletteAt, epoch]);
15
- const defaultMaterial = useMemo(() => new THREE.MeshStandardMaterial({ color: "#ffffff", roughness, metalness, vertexColors: true }), [metalness, roughness]);
12
+ const geometry = useDisposable(() => createFieldGroundGeometry(field, { size, segments, center, colors, heightRange, paletteAt }), [field, size, segments, center, colors, heightRange, paletteAt, epoch]);
13
+ const defaultMaterial = useDisposable(() => new THREE.MeshStandardMaterial({ color: "#ffffff", roughness, metalness, vertexColors: true }), [metalness, roughness]);
16
14
  const material = surfaceMaterial ?? defaultMaterial;
17
- useEffect(() => () => geometry.dispose(), [geometry]);
18
- useEffect(() => () => defaultMaterial.dispose(), [defaultMaterial]);
19
15
  return _jsx("mesh", { ...meshProps, geometry: geometry, material: material, receiveShadow: receiveShadow });
20
16
  }
@@ -1,13 +1,14 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useFrame } from "@react-three/fiber";
3
3
  import { useEffect, useMemo } from "react";
4
+ import { useDisposable } from "../render/useDisposable.js";
4
5
  import { createGrassBladeGeometry, grassTuftCount, GRASS_TUFT_BLADES, } from "./grassGeometry.js";
5
6
  import { createGrassMaterial, } from "./grassMaterial.js";
6
7
  import { DEFAULT_GRASS_COUNT, DEFAULT_GRASS_DENSITY, resolveGrassInstanceBudget, } from "./grassBudget.js";
7
8
  export { DEFAULT_GRASS_COUNT, DEFAULT_GRASS_DENSITY, resolveGrassInstanceBudget } from "./grassBudget.js";
8
9
  export { GRASS_TUFT_BLADES } from "./grassGeometry.js";
9
10
  export function GrassField({ count = DEFAULT_GRASS_COUNT, density = DEFAULT_GRASS_DENSITY, budget, area = 40, seed = 1, segments = 4, bladeHeight, bladeWidth, bladeBend, tuftBlades = GRASS_TUFT_BLADES, tuftRadius, edgeFeather, exclude, heightAt, colorBase, colorTip, colorGround, colorVariation, wind, distanceFade, normalLift, roughness, castShadow = false, receiveShadow = true, frustumCulled = true, ...meshProps }) {
10
- const geometry = useMemo(() => createGrassBladeGeometry({
11
+ const geometry = useDisposable(() => createGrassBladeGeometry({
11
12
  count,
12
13
  area,
13
14
  seed,
@@ -37,7 +38,6 @@ export function GrassField({ count = DEFAULT_GRASS_COUNT, density = DEFAULT_GRAS
37
38
  useFrame((state) => {
38
39
  handle.uniforms.uTime.value = state.clock.elapsedTime;
39
40
  });
40
- useEffect(() => () => geometry.dispose(), [geometry]);
41
41
  useEffect(() => () => handle.material.dispose(), [handle]);
42
42
  return (_jsx("mesh", { ...meshProps, geometry: geometry, material: handle.material, castShadow: castShadow, receiveShadow: receiveShadow, frustumCulled: frustumCulled }));
43
43
  }