@jgengine/shell 0.11.0 → 0.13.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 (151) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/CombatPresentation.d.ts +5 -0
  3. package/dist/CombatPresentation.js +8 -0
  4. package/dist/GameHost.d.ts +27 -1
  5. package/dist/GameHost.js +83 -4
  6. package/dist/GamePlayer.d.ts +5 -0
  7. package/dist/GamePlayer.js +5 -0
  8. package/dist/GamePlayerShell.d.ts +5 -0
  9. package/dist/GamePlayerShell.js +63 -359
  10. package/dist/GameUiPreview.js +3 -0
  11. package/dist/Shell3dPresentation.d.ts +63 -0
  12. package/dist/Shell3dPresentation.js +302 -0
  13. package/dist/ShellChrome.d.ts +51 -0
  14. package/dist/ShellChrome.js +51 -0
  15. package/dist/ShellHudPresentation.d.ts +41 -0
  16. package/dist/ShellHudPresentation.js +22 -0
  17. package/dist/audio/audioEngine.d.ts +4 -0
  18. package/dist/audio/audioEngine.js +111 -26
  19. package/dist/audio/audioWire.d.ts +22 -4
  20. package/dist/audio/audioWire.js +46 -3
  21. package/dist/audio/loopParams.d.ts +20 -0
  22. package/dist/audio/loopParams.js +28 -0
  23. package/dist/camera/GameCameraRig.js +6 -1
  24. package/dist/camera/GameOrbitCamera.js +7 -1
  25. package/dist/camera/cameraCollision.d.ts +28 -0
  26. package/dist/camera/cameraCollision.js +34 -0
  27. package/dist/camera/cameraRigs.d.ts +3 -1
  28. package/dist/camera/cameraRigs.js +24 -10
  29. package/dist/camera/index.d.ts +1 -0
  30. package/dist/camera/index.js +1 -0
  31. package/dist/camera/rigMath.d.ts +27 -0
  32. package/dist/camera/rigMath.js +45 -0
  33. package/dist/defineGame.d.ts +15 -1
  34. package/dist/defineGame.js +26 -4
  35. package/dist/devtools/DevtoolsOverlay.d.ts +4 -4
  36. package/dist/devtools/DevtoolsOverlay.js +28 -12
  37. package/dist/devtools/agentBridge.js +4 -0
  38. package/dist/devtools/perfDiagnose.js +1 -1
  39. package/dist/diagnostics/RuntimeDiagnostics.d.ts +7 -0
  40. package/dist/diagnostics/RuntimeDiagnostics.js +23 -12
  41. package/dist/environment/EnvironmentScene.js +17 -3
  42. package/dist/environment/RoadJunctions.d.ts +12 -0
  43. package/dist/environment/RoadJunctions.js +69 -0
  44. package/dist/environment/RoadRibbons.d.ts +8 -2
  45. package/dist/environment/RoadRibbons.js +26 -8
  46. package/dist/environment/terrainGroundColor.d.ts +9 -0
  47. package/dist/environment/terrainGroundColor.js +22 -0
  48. package/dist/gameKit.d.ts +27 -0
  49. package/dist/gameKit.js +29 -0
  50. package/dist/map/MapMarkerBeacons.d.ts +3 -2
  51. package/dist/postfx/PostProcessing.d.ts +9 -1
  52. package/dist/postfx/PostProcessing.js +11 -4
  53. package/dist/presentationResolve.d.ts +39 -0
  54. package/dist/presentationResolve.js +53 -0
  55. package/dist/registry.d.ts +1 -0
  56. package/dist/render/PartMotion.d.ts +18 -0
  57. package/dist/render/PartMotion.js +91 -0
  58. package/dist/render/SceneModels.d.ts +9 -2
  59. package/dist/render/SceneModels.js +31 -180
  60. package/dist/render/assetBase.d.ts +17 -0
  61. package/dist/render/assetBase.js +35 -0
  62. package/dist/render/measureBounds.d.ts +34 -0
  63. package/dist/render/measureBounds.js +127 -0
  64. package/dist/render/modelLoad.js +4 -1
  65. package/dist/render/resolveModel.d.ts +15 -0
  66. package/dist/render/resolveModel.js +43 -2
  67. package/dist/render/useModelAnimation.d.ts +10 -0
  68. package/dist/render/useModelAnimation.js +198 -0
  69. package/dist/scatter/InstancedScatter.js +5 -0
  70. package/dist/scatter/scatterProxies.js +24 -0
  71. package/dist/scene/AuthoredScene.d.ts +3 -1
  72. package/dist/scene/AuthoredScene.js +7 -2
  73. package/dist/scene/CityRenderer.d.ts +2 -0
  74. package/dist/scene/CityRenderer.js +1031 -0
  75. package/dist/scene/GrassFieldRenderer.js +53 -5
  76. package/dist/scene/PoleLineRenderer.d.ts +2 -0
  77. package/dist/scene/PoleLineRenderer.js +287 -0
  78. package/dist/scene/SoilPatchRenderer.js +70 -7
  79. package/dist/scene/WaterRenderer.js +34 -10
  80. package/dist/scene/builtinSceneKindRenderers.js +4 -0
  81. package/dist/scene/cityWindowMaterial.d.ts +16 -0
  82. package/dist/scene/cityWindowMaterial.js +81 -0
  83. package/dist/scene/sceneKindRenderers.d.ts +2 -0
  84. package/dist/settings/appliedSettings.d.ts +2 -1
  85. package/dist/settings/appliedSettings.js +4 -2
  86. package/dist/structures/GeneratedBuilding.d.ts +4 -0
  87. package/dist/structures/GeneratedBuilding.js +17 -7
  88. package/dist/structures/TransformGizmo.d.ts +11 -1
  89. package/dist/structures/TransformGizmo.js +9 -5
  90. package/dist/terrain/GrassField.d.ts +11 -3
  91. package/dist/terrain/GrassField.js +16 -6
  92. package/dist/terrain/grassGeometry.d.ts +33 -1
  93. package/dist/terrain/grassGeometry.js +83 -21
  94. package/dist/terrain/grassMaterial.d.ts +18 -0
  95. package/dist/terrain/grassMaterial.js +66 -13
  96. package/dist/terrain/heightfieldGeometry.d.ts +34 -0
  97. package/dist/terrain/heightfieldGeometry.js +109 -0
  98. package/dist/terrain/index.d.ts +1 -0
  99. package/dist/terrain/index.js +1 -0
  100. package/dist/terrain/soilPatchMaterial.d.ts +15 -2
  101. package/dist/terrain/soilPatchMaterial.js +46 -8
  102. package/dist/terrain/terrainDetailMaterial.js +15 -2
  103. package/dist/water/Ocean.d.ts +7 -1
  104. package/dist/water/Ocean.js +16 -3
  105. package/dist/water/OceanConfig.d.ts +9 -0
  106. package/dist/water/OceanConfig.js +6 -0
  107. package/dist/water/OceanMaterial.d.ts +12 -0
  108. package/dist/water/OceanMaterial.js +9 -0
  109. package/dist/water/OceanShader.d.ts +2 -2
  110. package/dist/water/OceanShader.js +78 -10
  111. package/dist/world/PlaceScene.d.ts +11 -0
  112. package/dist/world/PlaceScene.js +27 -0
  113. package/dist/world/WorldHud.d.ts +3 -1
  114. package/dist/world/WorldHud.js +2 -2
  115. package/dist/world/WorldScene.js +32 -4
  116. package/dist/worldSky.d.ts +7 -1
  117. package/dist/worldSky.js +11 -1
  118. package/package.json +4 -4
  119. package/skills/game-design/SKILL.md +28 -0
  120. package/skills/game-design/agents/openai.yaml +4 -0
  121. package/skills/game-design/references/application-playbook.md +170 -0
  122. package/skills/game-design/references/field-guide.md +288 -0
  123. package/skills/jgengine/SKILL.md +8 -4
  124. package/skills/jgengine/api.md +182 -81
  125. package/skills/jgengine/capabilities.md +104 -2
  126. package/skills/jgengine/recipes/minimal-game.md +88 -0
  127. package/skills/jgengine-ui/SKILL.md +17 -10
  128. package/skills/jgengine-ui/api.md +316 -51
  129. package/skills/jgengine-ui/capabilities.md +135 -2
  130. package/skills/jgengine-ui/recipes/portable-minimap.md +150 -0
  131. package/skills/jgengine-ui/reference.md +59 -9
  132. package/skills/jgengine-ui/references/visual-scorecard.md +1 -1
  133. package/skills/jgengine-world/SKILL.md +13 -0
  134. package/skills/jgengine-world/api.md +453 -21
  135. package/skills/jgengine-world/capabilities.md +112 -2
  136. package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
  137. package/skills/jgengine-world/reference.md +43 -4
  138. package/skills/level-design/SKILL.md +54 -0
  139. package/skills/level-design/agents/openai.yaml +4 -0
  140. package/skills/level-design/references/application-playbook.md +192 -0
  141. package/skills/level-design/references/field-guide.md +337 -0
  142. package/skills/level-design/references/live-editor-loop.md +179 -0
  143. package/dist/cartridge/runtime.d.ts +0 -15
  144. package/dist/cartridge/runtime.js +0 -556
  145. package/dist/cartridge/spec.d.ts +0 -188
  146. package/dist/cartridge/spec.js +0 -19
  147. package/dist/cartridge/validate.d.ts +0 -2
  148. package/dist/cartridge/validate.js +0 -78
  149. package/dist/cartridge.d.ts +0 -145
  150. package/dist/cartridge.js +0 -246
  151. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -74,6 +74,22 @@ export function topDownPose(follow, resolved, fov) {
74
74
  fov,
75
75
  };
76
76
  }
77
+ /**
78
+ * World-space direction for RTS pan input: `panX` slides along screen-right,
79
+ * `panZ` along the camera's ground-plane forward, for a camera posed by
80
+ * `topDownPose` at the same yaw. Normalized; zero input returns zero.
81
+ * @internal
82
+ */
83
+ export function rtsPanWorldDir(panX, panZ, yaw) {
84
+ const forward = forwardVector(yaw);
85
+ const right = rightVector(yaw);
86
+ const x = panX * right.x + panZ * forward.x;
87
+ const z = panX * right.z + panZ * forward.z;
88
+ const len = Math.hypot(x, z);
89
+ if (len === 0)
90
+ return { x: 0, y: 0, z: 0 };
91
+ return { x: x / len, y: 0, z: z / len };
92
+ }
77
93
  /** Exponential spring-arm approach toward a desired point (frame-rate independent).
78
94
  * @internal
79
95
  */
@@ -242,6 +258,9 @@ export function resolveChase(config) {
242
258
  bankPerYawRate: config?.bank?.perYawRate ?? 0,
243
259
  bankMax: config?.bank?.max ?? 0.35,
244
260
  bankDamping: config?.bank?.damping ?? 8,
261
+ velocityYawBlend: config?.velocityYaw === undefined ? 0 : config.velocityYaw.blend ?? 0.65,
262
+ velocityYawMinSpeed: config?.velocityYaw?.minSpeed ?? 4,
263
+ velocityYawResponse: config?.velocityYaw?.response ?? 6,
245
264
  };
246
265
  }
247
266
  /** Velocity-lead the follow point (#286.9): aim `leadTime` seconds along the target's frame velocity, clamped to `leadMax`.
@@ -323,6 +342,32 @@ export function chaseLookAt(follow, yaw, resolved) {
323
342
  z: follow.z + forward.z * 2,
324
343
  };
325
344
  }
345
+ /**
346
+ * Drift-lag (#1051): the yaw the chase anchor should aim before temporal
347
+ * smoothing. When the target slides, its planar `velocity` diverges from its
348
+ * `heading`; this rotates the anchor from heading partway toward the velocity
349
+ * direction so the camera reveals the car's side while travel stays framed. The
350
+ * traversed fraction is `velocityYawBlend` scaled by how far the slip angle has
351
+ * opened (0 at straight-line travel, full at a 90° slide), so ordinary driving
352
+ * is untouched. Returns the raw heading — no swing — when the feature is off,
353
+ * when planar speed is below `velocityYawMinSpeed`, or when the target is
354
+ * reversing (velocity opposed to heading forward). Blending is shortest-arc, so
355
+ * it stays correct across the ±PI wrap seam.
356
+ * @internal
357
+ */
358
+ export function velocityYawTarget(heading, velocity, resolved) {
359
+ if (resolved.velocityYawBlend <= 0)
360
+ return heading;
361
+ const speed = Math.hypot(velocity.x, velocity.z);
362
+ if (speed < resolved.velocityYawMinSpeed)
363
+ return heading;
364
+ const forward = forwardVector(heading);
365
+ if (forward.x * velocity.x + forward.z * velocity.z < 0)
366
+ return heading;
367
+ const slip = angleDelta(heading, Math.atan2(velocity.x, velocity.z));
368
+ const slipFraction = clamp(Math.abs(slip) / (Math.PI / 2), 0, 1);
369
+ return heading + slip * resolved.velocityYawBlend * slipFraction;
370
+ }
326
371
  /** World-space seat pose (cockpit/hood/rear) rigidly attached to the vehicle.
327
372
  * @internal
328
373
  */
@@ -1,7 +1,8 @@
1
- import type { ComponentType } from "react";
1
+ import { type ComponentType } from "react";
2
2
  import { type GameDefinitionConfig, type GameLoop } from "@jgengine/core/game/defineGame";
3
3
  import type { GameContext, GameContextContent } from "@jgengine/core/runtime/gameContext";
4
4
  import type { ModelAssetRef } from "@jgengine/core/scene/assetCatalog";
5
+ import type { ModelConfig } from "@jgengine/core/game/playableGame";
5
6
  import type { PlayableGame } from "./registry.js";
6
7
  type EngineFields<TAssetRef extends ModelAssetRef> = Omit<GameDefinitionConfig<TAssetRef>, "loop" | "ui" | "multiplayer"> & {
7
8
  multiplayer?: GameDefinitionConfig<TAssetRef>["multiplayer"];
@@ -10,7 +11,20 @@ type PresentationFields = Omit<PlayableGame, "game" | "content" | "loop" | "Game
10
11
  content?: GameContextContent;
11
12
  loop?: Partial<GameLoop<GameContext>>;
12
13
  GameUI?: ComponentType;
14
+ /** Tunes how the auto-mounted `AuthoredScene` places the document's catalog-id markers into the object store. Default `true`; pass `false` when the game spawns its placed content as entities itself (`placeAuthoredObjects`) to avoid a double render. */
15
+ scenePlacement?: boolean | {
16
+ verticalOffset?: number;
17
+ };
18
+ /** GLB models for the auto-mounted scene's scatter palette items, keyed by palette item id; string ids resolve through the game's asset catalog. Unmatched items keep the built-in proxy meshes. */
19
+ sceneScatterModels?: Record<string, string | ModelConfig>;
13
20
  };
14
21
  export type GameConfig<TAssetRef extends ModelAssetRef = ModelAssetRef> = EngineFields<TAssetRef> & PresentationFields;
22
+ /**
23
+ * The one public authoring entry point: compose engine fields (systems, world, physics, input) and
24
+ * presentation fields (camera, HUD, audio, authored scene) into a `PlayableGame` ready for `GameHost`.
25
+ * Defaults to solo/offline multiplayer; `editorLayers` auto-mounts the authored scene document.
26
+ *
27
+ * @capability define-game single public game-authoring path — compose systems, world, presentation, and authored scene in one definition
28
+ */
15
29
  export declare function defineGame<TAssetRef extends ModelAssetRef = ModelAssetRef>(config: GameConfig<TAssetRef>): PlayableGame;
16
30
  export {};
@@ -1,17 +1,34 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { defineGame as defineEngineGame, } from "@jgengine/core/game/defineGame";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo } from "react";
3
+ import { defineGameDefinition as defineEngineGame, } from "@jgengine/core/game/defineGame";
3
4
  import { syncLifecyclePhase } from "@jgengine/core/game/gamePhase";
4
5
  import { offline } from "@jgengine/core/runtime/adapter";
5
6
  import { EnvironmentScene } from "./environment.js";
7
+ import { terrainGroundColorSampler } from "./environment/terrainGroundColor.js";
8
+ import { AuthoredScene } from "./scene/AuthoredScene.js";
6
9
  const noop = () => { };
7
10
  function worldBackdrop(feature) {
8
11
  return function WorldBackdrop() {
9
12
  return _jsx(EnvironmentScene, { feature: feature });
10
13
  };
11
14
  }
15
+ function authoredSceneOverlay(document, placement, scatterModels, assets, world, Vfx) {
16
+ const terrain = world !== undefined && world.kind === "environment" ? world.terrain : undefined;
17
+ return function AuthoredSceneOverlay(props) {
18
+ const groundColorAt = useMemo(() => terrainGroundColorSampler(terrain, props.ctx.world.ground), [props.ctx.world.ground]);
19
+ return (_jsxs(_Fragment, { children: [_jsx(AuthoredScene, { document: document, field: props.ctx.world.ground, placeObjects: placement, ...(scatterModels === undefined ? {} : { scatterModels, assets }), ...(groundColorAt === undefined ? {} : { groundColorAt }) }), Vfx === undefined ? null : _jsx(Vfx, { ...props })] }));
20
+ };
21
+ }
12
22
  const emptyUi = () => null;
23
+ /**
24
+ * The one public authoring entry point: compose engine fields (systems, world, physics, input) and
25
+ * presentation fields (camera, HUD, audio, authored scene) into a `PlayableGame` ready for `GameHost`.
26
+ * Defaults to solo/offline multiplayer; `editorLayers` auto-mounts the authored scene document.
27
+ *
28
+ * @capability define-game single public game-authoring path — compose systems, world, presentation, and authored scene in one definition
29
+ */
13
30
  export function defineGame(config) {
14
- const { content, loop, GameUI, environment, camera, multiplayer, WorldOverlay, viewmodel, renderEntity, renderObject, entitySprites, entityModels, objectModels, hotbarSelection, prompts, pointer, touch, orientation, worldHealthBars, nameplates, audio, entitySounds, objectSounds, worldItem, collision, movement, lighting, backdrop, postProcessing, shadows, presentation, objectStyles, devtools, capture, platforms, hudFit, ...engineFields } = config;
31
+ const { content, loop, GameUI, environment, camera, multiplayer, editorLayers, editorCatalogs, scenePlacement, sceneScatterModels, WorldOverlay, viewmodel, renderEntity, renderObject, entitySprites, entityModels, objectModels, hotbarSelection, prompts, pointer, touch, orientation, worldHealthBars, nameplates, presentationEffects, audio, entitySounds, objectSounds, worldItem, collision, movement, lighting, backdrop, postProcessing, shadows, presentation, objectStyles, devtools, capture, platforms, hudFit, ...engineFields } = config;
15
32
  const game = defineEngineGame({
16
33
  ...engineFields,
17
34
  multiplayer: multiplayer ?? offline(),
@@ -39,7 +56,11 @@ export function defineGame(config) {
39
56
  environment: environment ??
40
57
  (game.world?.kind === "environment" ? worldBackdrop(game.world) : undefined),
41
58
  camera: camera ?? { perspective: "third" },
42
- WorldOverlay,
59
+ editorLayers,
60
+ editorCatalogs,
61
+ WorldOverlay: editorLayers === undefined
62
+ ? WorldOverlay
63
+ : authoredSceneOverlay(editorLayers, scenePlacement ?? true, sceneScatterModels, game.assets, game.world, WorldOverlay),
43
64
  viewmodel,
44
65
  renderEntity,
45
66
  renderObject,
@@ -53,6 +74,7 @@ export function defineGame(config) {
53
74
  orientation,
54
75
  worldHealthBars,
55
76
  nameplates,
77
+ presentationEffects,
56
78
  audio,
57
79
  entitySounds,
58
80
  objectSounds,
@@ -30,8 +30,8 @@ export declare function buildLeanReport(playable: PlayableGame): {
30
30
  pctOfSim: number;
31
31
  }[];
32
32
  } | null;
33
- render: import("@jgengine/core/devtools/devtools").RenderSample | null;
34
- latency: import("@jgengine/core/devtools/devtools").LatencyStats | null;
33
+ render: import("@jgengine/core/devtools/types").RenderSample | null;
34
+ latency: import("@jgengine/core/devtools/types").LatencyStats | null;
35
35
  longFrameSummary: {
36
36
  count: number;
37
37
  culprits: {
@@ -54,10 +54,10 @@ export declare function buildLeanReport(playable: PlayableGame): {
54
54
  ms: number;
55
55
  }[];
56
56
  probes: Record<string, unknown>;
57
- render: import("@jgengine/core/devtools/devtools").RenderSample | null;
57
+ render: import("@jgengine/core/devtools/types").RenderSample | null;
58
58
  }[];
59
59
  probes: Record<string, unknown>;
60
- logs: import("@jgengine/core/devtools/devtools").DevtoolsLogEntry[] | undefined;
60
+ logs: import("@jgengine/core/devtools/types").DevtoolsLogEntry[] | undefined;
61
61
  controls: {
62
62
  name: string;
63
63
  value: unknown;
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useFrame } from "@react-three/fiber";
2
+ import { useFrame, useThree } from "@react-three/fiber";
3
3
  import { useEffect, useRef, useState, useSyncExternalStore } from "react";
4
4
  import { devtools, instrumentLatency, } from "@jgengine/core/devtools/devtools";
5
+ import { fallbackSeamsSnapshot } from "@jgengine/core/devtools/fallbackSeams";
5
6
  import { MOVEMENT_TUNING } from "@jgengine/core/movement/movementModel";
6
7
  import { collisionDebug } from "./collisionDebug.js";
7
8
  import { readStoredOverrides } from "./devtoolsOverrides.js";
@@ -24,21 +25,35 @@ export function withDevtoolsLatency(multiplayer) {
24
25
  },
25
26
  };
26
27
  }
28
+ const RENDER_SAMPLE_MS = 500;
29
+ const RENDER_PROBE_FRAME_PRIORITY = -10;
27
30
  /** @internal */
28
31
  export function DevtoolsRendererProbe() {
29
- const frameCounter = useRef(0);
32
+ const gl = useThree((state) => state.gl);
33
+ const lastSampleAt = useRef(0);
34
+ useEffect(() => {
35
+ // three resets gl.info after every render() call, so with a multi-pass
36
+ // composer the counters only ever hold the last fullscreen pass. Own the
37
+ // reset instead: accumulate across the whole frame, reset at frame start.
38
+ gl.info.autoReset = false;
39
+ return () => {
40
+ gl.info.autoReset = true;
41
+ };
42
+ }, [gl]);
30
43
  useFrame((state) => {
31
- frameCounter.current += 1;
32
- if (frameCounter.current % 30 !== 0)
33
- return;
34
44
  const info = state.gl.info;
35
- devtools.render.record({
36
- drawCalls: info.render.calls,
37
- triangles: info.render.triangles,
38
- geometries: info.memory.geometries,
39
- textures: info.memory.textures,
40
- });
41
- });
45
+ const now = performance.now();
46
+ if (now - lastSampleAt.current >= RENDER_SAMPLE_MS) {
47
+ lastSampleAt.current = now;
48
+ devtools.render.record({
49
+ drawCalls: info.render.calls,
50
+ triangles: info.render.triangles,
51
+ geometries: info.memory.geometries,
52
+ textures: info.memory.textures,
53
+ });
54
+ }
55
+ info.reset();
56
+ }, RENDER_PROBE_FRAME_PRIORITY);
42
57
  return null;
43
58
  }
44
59
  const TABS = [
@@ -194,6 +209,7 @@ export function DevtoolsOverlay({ open, ctx, playable, multiplayer, }) {
194
209
  const disposers = [
195
210
  devtools.probes.register("entities", () => ctx.scene.entity.list().length),
196
211
  devtools.probes.register("objects", () => ctx.scene.object.list().length),
212
+ devtools.probes.register("fallbacks", () => fallbackSeamsSnapshot()),
197
213
  ];
198
214
  return () => {
199
215
  for (const dispose of disposers)
@@ -1,3 +1,4 @@
1
+ import { devtools } from "@jgengine/core/devtools/devtools";
1
2
  import { getSaveEndpoint } from "@jgengine/core/devtools/saveEndpoint";
2
3
  import { isHudAnchor, listActiveHudLayouts, } from "@jgengine/core/ui/hudLayout";
3
4
  import { listHudPanelTypes, resolvePanelResize, resizePanelSize, } from "@jgengine/core/ui/hudDocument";
@@ -104,6 +105,9 @@ export function installAgentBridge(options) {
104
105
  return { ok: true, snapshot: buildLeanReport(playable) };
105
106
  case "debug_report":
106
107
  return { ok: true, report: buildFullReport(playable) };
108
+ case "debug_perf_reset":
109
+ devtools.frame.reset();
110
+ return { ok: true };
107
111
  case "canvas_state":
108
112
  return { ok: true, layouts: canvasPanels() };
109
113
  case "canvas_set_editing": {
@@ -1,7 +1,7 @@
1
1
  import { devtools, LONG_FRAME_MS } from "@jgengine/core/devtools/devtools";
2
2
  import { ms } from "./panelAtoms.js";
3
3
  const CULPRIT_HINTS = {
4
- "outside-sim": "Cost is outside the sim driver — three.js render, React commit, GPU, GC, or tab throttle.",
4
+ "outside-sim": "Cost is outside the sim driver — three.js render, React commit, GPU, GC, or tab throttle. Check the render sample: high draw calls/triangles mean scene cost (instancing, culling, terrain density); moderate counts with slow frames mean fill cost (dpr, AO/DOF passes, bloom) — try Quality low/medium.",
5
5
  sim: "Sim is slow but no named phase stands out — wrap hot onTick work with measure(\"name\", fn).",
6
6
  onTick: "Game loop.onTick is the hotspot — profile inside it with measure(\"physics\"|\"ai\"|…, fn).",
7
7
  pose: "Shell movement / collision / voxel step is expensive.",
@@ -23,6 +23,13 @@ export declare class GameUiErrorBoundary extends Component<{
23
23
  componentDidCatch(error: unknown, info: ErrorInfo): void;
24
24
  render(): ReactNode;
25
25
  }
26
+ /** Standalone error footer text (page, browser, engine) shared by every error surface. */
27
+ export declare function errorReportContext(): string;
28
+ /** "Copy error" + "File issue" buttons for any error surface. `report` is the full text to copy / prefill. */
29
+ export declare function ErrorReportActions({ report, issueTitle }: {
30
+ report: string;
31
+ issueTitle: string;
32
+ }): import("react").JSX.Element;
26
33
  export declare function DiagnosticOverlay({ diagnostics, gameName }: {
27
34
  diagnostics: RuntimeDiagnostic[];
28
35
  gameName: string;
@@ -32,16 +32,35 @@ function expandedReactError(message) {
32
32
  ? "Maximum update depth exceeded. A component is repeatedly updating state during render or after every update."
33
33
  : null;
34
34
  }
35
+ const ISSUES_NEW_URL = "https://github.com/Noisemaker111/jgengine/issues/new";
36
+ /** Standalone error footer text (page, browser, engine) shared by every error surface. */
37
+ export function errorReportContext() {
38
+ return [
39
+ `Engine: ${VERSION}`,
40
+ `Page: ${window.location.origin}${window.location.pathname}`,
41
+ `Browser: ${navigator.userAgent}`,
42
+ ].join("\n");
43
+ }
44
+ /** "Copy error" + "File issue" buttons for any error surface. `report` is the full text to copy / prefill. */
45
+ export function ErrorReportActions({ report, issueTitle }) {
46
+ const [copied, setCopied] = useState(false);
47
+ const issueBody = report.length > 8000 ? `${report.slice(0, 8000)}\n\n[Report truncated; use Copy error for the full report.]` : report;
48
+ const issueUrl = `${ISSUES_NEW_URL}?title=${encodeURIComponent(`[BUG] ${issueTitle.slice(0, 120)}`)}&body=${encodeURIComponent(issueBody)}`;
49
+ return (_jsxs("div", { className: "flex gap-2", children: [_jsx("button", { className: "rounded border border-red-300/50 bg-red-900 px-2 py-1 text-xs font-semibold text-red-50 hover:bg-red-800", type: "button", onClick: () => {
50
+ void navigator.clipboard.writeText(report).then(() => {
51
+ setCopied(true);
52
+ window.setTimeout(() => setCopied(false), 2000);
53
+ });
54
+ }, children: copied ? "Copied" : "Copy error" }), _jsx("a", { className: "rounded border border-red-300/50 bg-red-900 px-2 py-1 text-xs font-semibold text-red-50 hover:bg-red-800", href: issueUrl, rel: "noreferrer", target: "_blank", children: "File issue" })] }));
55
+ }
35
56
  function diagnosticReport(diagnostic, gameName) {
36
57
  const explanation = expandedReactError(diagnostic.message);
37
58
  return [
38
59
  "JGengine runtime error",
39
60
  `Game: ${gameName}`,
40
- `Engine: ${VERSION}`,
41
61
  `Phase: ${diagnostic.phase}`,
42
62
  `Time: ${diagnostic.capturedAt}`,
43
- `Page: ${window.location.origin}${window.location.pathname}`,
44
- `Browser: ${navigator.userAgent}`,
63
+ errorReportContext(),
45
64
  explanation === null ? null : `Explanation: ${explanation}`,
46
65
  "",
47
66
  `Message: ${diagnostic.message}`,
@@ -52,18 +71,10 @@ function diagnosticReport(diagnostic, gameName) {
52
71
  .join("\n");
53
72
  }
54
73
  export function DiagnosticOverlay({ diagnostics, gameName }) {
55
- const [copied, setCopied] = useState(false);
56
74
  if (diagnostics.length === 0)
57
75
  return null;
58
76
  const latest = diagnostics[diagnostics.length - 1];
59
77
  const explanation = expandedReactError(latest.message);
60
78
  const report = diagnosticReport(latest, gameName);
61
- const issueBody = report.length > 8000 ? `${report.slice(0, 8000)}\n\n[Report truncated; use Copy error for the full report.]` : report;
62
- const issueUrl = `https://github.com/Noisemaker111/jgengine/issues/new?title=${encodeURIComponent(`[BUG] ${latest.phase}: ${latest.message.slice(0, 100)}`)}&body=${encodeURIComponent(issueBody)}`;
63
- return (_jsxs("div", { className: "pointer-events-auto absolute right-4 top-4 z-50 max-w-lg rounded border border-red-400/60 bg-red-950/95 p-3 text-xs text-red-50 shadow-2xl", children: [_jsx("div", { className: "mb-1 font-semibold uppercase tracking-wide text-red-200", children: "JG engine error" }), _jsxs("div", { className: "font-mono text-[11px] text-red-100", children: ["[", latest.phase, "] ", latest.message] }), explanation !== null ? _jsx("div", { className: "mt-2 text-red-100", children: explanation }) : null, latest.stack !== undefined ? (_jsx("pre", { className: "mt-2 max-h-36 overflow-auto whitespace-pre-wrap text-[10px] text-red-200/80", children: latest.stack })) : null, latest.componentStack !== undefined ? (_jsx("pre", { className: "mt-2 max-h-36 overflow-auto whitespace-pre-wrap text-[10px] text-red-200/80", children: latest.componentStack })) : null, _jsxs("div", { className: "mt-3 flex gap-2", children: [_jsx("button", { className: "rounded border border-red-300/50 bg-red-900 px-2 py-1 font-semibold hover:bg-red-800", type: "button", onClick: () => {
64
- void navigator.clipboard.writeText(report).then(() => {
65
- setCopied(true);
66
- window.setTimeout(() => setCopied(false), 2000);
67
- });
68
- }, children: copied ? "Copied" : "Copy error" }), _jsx("a", { className: "rounded border border-red-300/50 bg-red-900 px-2 py-1 font-semibold hover:bg-red-800", href: issueUrl, rel: "noreferrer", target: "_blank", children: "File issue" })] })] }));
79
+ return (_jsxs("div", { className: "pointer-events-auto absolute right-4 top-4 z-50 max-w-lg select-text rounded border border-red-400/60 bg-red-950/95 p-3 text-xs text-red-50 shadow-2xl", children: [_jsx("div", { className: "mb-1 font-semibold uppercase tracking-wide text-red-200", children: "JG engine error" }), _jsxs("div", { className: "font-mono text-[11px] text-red-100", children: ["[", latest.phase, "] ", latest.message] }), explanation !== null ? _jsx("div", { className: "mt-2 text-red-100", children: explanation }) : null, latest.stack !== undefined ? (_jsx("pre", { className: "mt-2 max-h-36 overflow-auto whitespace-pre-wrap text-[10px] text-red-200/80", children: latest.stack })) : null, latest.componentStack !== undefined ? (_jsx("pre", { className: "mt-2 max-h-36 overflow-auto whitespace-pre-wrap text-[10px] text-red-200/80", children: latest.componentStack })) : null, _jsx("div", { className: "mt-3", children: _jsx(ErrorReportActions, { report: report, issueTitle: `${latest.phase}: ${latest.message.slice(0, 100)}` }) })] }));
69
80
  }
@@ -6,9 +6,12 @@ import * as THREE from "three";
6
6
  import { useGameContext } from "@jgengine/react/provider";
7
7
  import { resolveBuildingPalette } from "@jgengine/core/world/buildings";
8
8
  import { resolveStructureBuildings } from "@jgengine/core/world/environmentSummary";
9
+ import { findRoadJunctions, junctionExclusions } from "@jgengine/core/world/streets";
9
10
  import { createTerrainPaletteSampler, resolveEnvironmentField, resolveTerrainDetail, resolveTerrainPalette, } from "@jgengine/core/world/terrain";
10
11
  import { GroundPad } from "./GroundPad.js";
12
+ import { RoadJunctions } from "./RoadJunctions.js";
11
13
  import { RoadRibbons } from "./RoadRibbons.js";
14
+ import { terrainGroundColorSampler } from "./terrainGroundColor.js";
12
15
  import { InstancedBuildings } from "../structures/GeneratedBuilding.js";
13
16
  import { GrassField } from "../terrain/GrassField.js";
14
17
  import { CarvedTerrain } from "../terrain/CarvedTerrain.js";
@@ -73,7 +76,7 @@ function TerrainGround({ terrain, field, center, }) {
73
76
  function areaCenter(area) {
74
77
  return area.position ?? [0, 0];
75
78
  }
76
- function Vegetation({ grass, field }) {
79
+ function Vegetation({ grass, field, groundColor, }) {
77
80
  const [cx, cz] = areaCenter(grass.area);
78
81
  const heightAt = useMemo(() => cx === 0 && cz === 0
79
82
  ? field.sampleHeight
@@ -82,7 +85,15 @@ function Vegetation({ grass, field }) {
82
85
  const wind = useMemo(() => ({ strength: grass.windStrength }), [grass.windStrength]);
83
86
  const colorBase = grass.colors[0];
84
87
  const colorTip = grass.colors[grass.colors.length - 1];
85
- return (_jsx("group", { position: [cx, 0, cz], children: _jsx(GrassField, { area: area, density: grass.density, seed: grass.seed, bladeHeight: grass.bladeHeight, bladeWidth: grass.bladeWidth, heightAt: heightAt, colorBase: colorBase, colorTip: colorTip, wind: wind, frustumCulled: true }) }));
88
+ return (_jsx("group", { position: [cx, 0, cz], children: _jsx(GrassField, { area: area, count: Math.max(1500, Math.min(250000, Math.ceil(grass.density * grass.area.w * grass.area.d))), density: grass.density, seed: grass.seed, bladeHeight: grass.bladeHeight, bladeWidth: grass.bladeWidth, heightAt: heightAt, colorBase: colorBase, colorTip: colorTip, colorGround: groundColor, wind: wind, edgeFeather: Math.min(8, Math.max(1.5, Math.min(grass.area.w, grass.area.d) * 0.12)), frustumCulled: true }) }));
89
+ }
90
+ /** Terrain color under a grass patch center — so blade roots blend into the ground they stand on. */
91
+ function vegetationGroundColor(terrain, field, grass) {
92
+ const sampler = terrainGroundColorSampler(terrain, field);
93
+ if (sampler === undefined)
94
+ return undefined;
95
+ const [cx, cz] = areaCenter(grass.area);
96
+ return sampler(cx, cz);
86
97
  }
87
98
  function weatherVolume(area) {
88
99
  return [area.w, area.h ?? 60, area.d];
@@ -133,6 +144,7 @@ function Structures({ structures, field }) {
133
144
  const placements = useMemo(() => resolveStructureBuildings(structures).map((building) => ({
134
145
  building,
135
146
  position: [0, field.sampleHeight(building.center[0], building.center[1]), 0],
147
+ ...(building.rotationY === undefined ? {} : { rotationY: building.rotationY, pivot: building.center }),
136
148
  })), [structures, field]);
137
149
  const palette = useMemo(() => resolveBuildingPalette(structures.style, structures.palette), [structures.style, structures.palette]);
138
150
  return _jsx(InstancedBuildings, { buildings: placements, palette: palette });
@@ -145,5 +157,7 @@ export function EnvironmentScene({ feature }) {
145
157
  const pads = feature.pads ?? [];
146
158
  const islands = feature.islands ?? [];
147
159
  const roads = feature.roads ?? [];
148
- return (_jsxs(_Fragment, { children: [feature.terrain !== undefined ? _jsx(TerrainGround, { terrain: feature.terrain, field: field }) : null, islands.map((entry, index) => (_jsx(TerrainGround, { terrain: entry, field: field, center: entry.origin }, `island-${index}`))), water.map((ocean, index) => (_jsx(Water, { ocean: ocean }, `ocean-${index}`))), roads.map((entry, index) => (_jsx(RoadRibbons, { road: entry, field: field }, `road-${index}`))), structures.map((entry, index) => (_jsx(Structures, { structures: entry, field: field }, `structures-${index}`))), pads.map((entry, index) => (_jsx(GroundPad, { pad: entry, field: field }, `pad-${index}`))), vegetation.map((grass, index) => (_jsx(Vegetation, { grass: grass, field: field }, `grass-${index}`))), feature.weather !== undefined && feature.weather.length > 0 ? (_jsx(Weather, { weather: feature.weather })) : null] }));
160
+ const junctions = useMemo(() => findRoadJunctions(roads), [roads]);
161
+ const exclusions = useMemo(() => junctionExclusions(junctions), [junctions]);
162
+ return (_jsxs(_Fragment, { children: [feature.terrain !== undefined ? _jsx(TerrainGround, { terrain: feature.terrain, field: field }) : null, islands.map((entry, index) => (_jsx(TerrainGround, { terrain: entry, field: field, center: entry.origin }, `island-${index}`))), water.map((ocean, index) => (_jsx(Water, { ocean: ocean }, `ocean-${index}`))), roads.map((entry, index) => (_jsx(RoadRibbons, { road: entry, field: field, exclusions: exclusions }, `road-${index}`))), _jsx(RoadJunctions, { junctions: junctions, field: field }), structures.map((entry, index) => (_jsx(Structures, { structures: entry, field: field }, `structures-${index}`))), pads.map((entry, index) => (_jsx(GroundPad, { pad: entry, field: field }, `pad-${index}`))), vegetation.map((grass, index) => (_jsx(Vegetation, { grass: grass, field: field, groundColor: vegetationGroundColor(feature.terrain, field, grass) }, `grass-${index}`))), feature.weather !== undefined && feature.weather.length > 0 ? (_jsx(Weather, { weather: feature.weather })) : null] }));
149
163
  }
@@ -0,0 +1,12 @@
1
+ import { type RoadJunction } from "@jgengine/core/world/streets";
2
+ import type { TerrainField } from "@jgengine/core/world/terrain";
3
+ /**
4
+ * Renders welded road intersections: one asphalt disc patch per junction (covering the crossing
5
+ * ribbons so they read as one intersection instead of stacking/z-fighting), plus white stop lines
6
+ * and crosswalk bars at each approach. Fed the junctions the scene already computed from its roads.
7
+ * @internal
8
+ */
9
+ export declare function RoadJunctions({ junctions, field, }: {
10
+ junctions: readonly RoadJunction[];
11
+ field: TerrainField;
12
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,69 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo } from "react";
3
+ import { BufferAttribute, BufferGeometry, DoubleSide } from "three";
4
+ import { buildJunctionPatch, buildRoadRibbon } from "@jgengine/core/world/roads";
5
+ import { junctionMarkings } from "@jgengine/core/world/streets";
6
+ function toGeometry(positions, indices) {
7
+ if (positions.length === 0)
8
+ return null;
9
+ const geometry = new BufferGeometry();
10
+ geometry.setAttribute("position", new BufferAttribute(positions, 3));
11
+ geometry.setIndex(new BufferAttribute(indices, 1));
12
+ geometry.computeVertexNormals();
13
+ return geometry;
14
+ }
15
+ /** Merge many thin marking ribbons (stop lines + crosswalk bars) into one buffer geometry. */
16
+ function mergedBars(bars, width, field, elevation) {
17
+ const ribbons = bars.map((bar) => buildRoadRibbon(bar, width, (x, z) => field.sampleHeight(x, z), { elevation }));
18
+ const vertexCount = ribbons.reduce((sum, r) => sum + r.positions.length, 0);
19
+ const indexCount = ribbons.reduce((sum, r) => sum + r.indices.length, 0);
20
+ if (vertexCount === 0)
21
+ return null;
22
+ const positions = new Float32Array(vertexCount);
23
+ const indices = new Uint32Array(indexCount);
24
+ let vertexOffset = 0;
25
+ let indexOffset = 0;
26
+ for (const ribbon of ribbons) {
27
+ positions.set(ribbon.positions, vertexOffset);
28
+ const base = vertexOffset / 3;
29
+ for (let i = 0; i < ribbon.indices.length; i += 1)
30
+ indices[indexOffset + i] = ribbon.indices[i] + base;
31
+ vertexOffset += ribbon.positions.length;
32
+ indexOffset += ribbon.indices.length;
33
+ }
34
+ return toGeometry(positions, indices);
35
+ }
36
+ /**
37
+ * Renders welded road intersections: one asphalt disc patch per junction (covering the crossing
38
+ * ribbons so they read as one intersection instead of stacking/z-fighting), plus white stop lines
39
+ * and crosswalk bars at each approach. Fed the junctions the scene already computed from its roads.
40
+ * @internal
41
+ */
42
+ export function RoadJunctions({ junctions, field, }) {
43
+ const patches = useMemo(() => junctions
44
+ .map((junction) => {
45
+ const patch = buildJunctionPatch(junction.center, junction.radius, (x, z) => field.sampleHeight(x, z), { elevation: junction.elevation + 0.02 });
46
+ return { color: junction.color, geometry: toGeometry(patch.positions, patch.indices) };
47
+ })
48
+ .filter((entry) => entry.geometry !== null), [junctions, field]);
49
+ const markings = useMemo(() => {
50
+ const stopBars = [];
51
+ const crossBars = [];
52
+ let elevation = 0;
53
+ for (const junction of junctions) {
54
+ elevation = Math.max(elevation, junction.elevation);
55
+ const marks = junctionMarkings(junction);
56
+ for (const bar of marks.stopLines)
57
+ stopBars.push(bar);
58
+ for (const bar of marks.crosswalkBars)
59
+ crossBars.push(bar);
60
+ }
61
+ return {
62
+ stops: mergedBars(stopBars, 0.5, field, elevation + 0.05),
63
+ crosswalks: mergedBars(crossBars, 0.55, field, elevation + 0.05),
64
+ };
65
+ }, [junctions, field]);
66
+ if (patches.length === 0)
67
+ return null;
68
+ return (_jsxs("group", { children: [patches.map((patch, index) => (_jsx("mesh", { geometry: patch.geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: patch.color, roughness: 0.95, metalness: 0, side: DoubleSide }) }, `junction-${index}`))), markings.stops !== null ? (_jsx("mesh", { geometry: markings.stops, children: _jsx("meshStandardMaterial", { color: "#e6e9ee", roughness: 0.85, metalness: 0, side: DoubleSide }) })) : null, markings.crosswalks !== null ? (_jsx("mesh", { geometry: markings.crosswalks, children: _jsx("meshStandardMaterial", { color: "#e6e9ee", roughness: 0.85, metalness: 0, side: DoubleSide }) })) : null] }));
69
+ }
@@ -1,7 +1,13 @@
1
1
  import type { RoadEnvironmentDescriptor } from "@jgengine/core/world/features";
2
+ import { type DashExclusion } from "@jgengine/core/world/roads";
2
3
  import type { TerrainField } from "@jgengine/core/world/terrain";
3
- /** Renders one `road()` descriptor: an asphalt ribbon plus optional dashed centerline, draped on the terrain. */
4
- export declare function RoadRibbons({ road, field }: {
4
+ /**
5
+ * Renders one `road()` descriptor: an asphalt ribbon, a curb/edge strip along each border, plus an
6
+ * optional dashed centerline (draped on the terrain, and interrupted through any `exclusions`
7
+ * — the welded junction patches — so the center line does not paint across intersections).
8
+ */
9
+ export declare function RoadRibbons({ road, field, exclusions, }: {
5
10
  road: RoadEnvironmentDescriptor;
6
11
  field: TerrainField;
12
+ exclusions?: readonly DashExclusion[];
7
13
  }): import("react").JSX.Element | null;
@@ -1,8 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo } from "react";
3
- import { BufferAttribute, BufferGeometry, DoubleSide } from "three";
3
+ import { BufferAttribute, BufferGeometry, Color, DoubleSide } from "three";
4
4
  import { buildRoadRibbon, dashSegments } from "@jgengine/core/world/roads";
5
- import { offsetPath, sidewalkWidthOf } from "@jgengine/core/world/streets";
5
+ import { curbPaths, offsetPath, sidewalkWidthOf } from "@jgengine/core/world/streets";
6
+ /** Darken an asphalt color toward black for a curb/edge strip (deterministic, no alloc churn). */
7
+ function darken(hex, amount) {
8
+ const c = new Color(hex);
9
+ c.multiplyScalar(1 - amount);
10
+ return `#${c.getHexString()}`;
11
+ }
6
12
  function toGeometry(positions, indices) {
7
13
  if (positions.length === 0)
8
14
  return null;
@@ -16,9 +22,9 @@ function ribbonGeometry(path, width, field, elevation) {
16
22
  const ribbon = buildRoadRibbon(path, width, (x, z) => field.sampleHeight(x, z), { elevation });
17
23
  return toGeometry(ribbon.positions, ribbon.indices);
18
24
  }
19
- function mergedDashGeometry(road, field) {
25
+ function mergedDashGeometry(road, field, exclusions) {
20
26
  const width = Math.max(0.25, road.width * 0.035);
21
- const ribbons = dashSegments(road.path, 3.2, 4).map((dash) => buildRoadRibbon(dash, width, (x, z) => field.sampleHeight(x, z), { elevation: road.elevation + 0.04 }));
27
+ const ribbons = dashSegments(road.path, 3.2, 4, exclusions).map((dash) => buildRoadRibbon(dash, width, (x, z) => field.sampleHeight(x, z), { elevation: road.elevation + 0.04 }));
22
28
  const vertexCount = ribbons.reduce((sum, r) => sum + r.positions.length, 0);
23
29
  const indexCount = ribbons.reduce((sum, r) => sum + r.indices.length, 0);
24
30
  if (vertexCount === 0)
@@ -38,10 +44,22 @@ function mergedDashGeometry(road, field) {
38
44
  }
39
45
  return toGeometry(positions, indices);
40
46
  }
41
- /** Renders one `road()` descriptor: an asphalt ribbon plus optional dashed centerline, draped on the terrain. */
42
- export function RoadRibbons({ road, field }) {
47
+ /**
48
+ * Renders one `road()` descriptor: an asphalt ribbon, a curb/edge strip along each border, plus an
49
+ * optional dashed centerline (draped on the terrain, and interrupted through any `exclusions`
50
+ * — the welded junction patches — so the center line does not paint across intersections).
51
+ */
52
+ export function RoadRibbons({ road, field, exclusions = [], }) {
43
53
  const asphalt = useMemo(() => ribbonGeometry(road.path, road.width, field, road.elevation), [road, field]);
44
- const dashes = useMemo(() => (road.markings ? mergedDashGeometry(road, field) : null), [road, field]);
54
+ const dashes = useMemo(() => (road.markings ? mergedDashGeometry(road, field, exclusions) : null), [road, field, exclusions]);
55
+ const curbColor = useMemo(() => darken(road.color, 0.45), [road.color]);
56
+ const curbs = useMemo(() => {
57
+ // A thin strip straddling each asphalt edge, lifted just above the ribbon.
58
+ const strip = Math.max(0.4, road.width * 0.09);
59
+ return curbPaths(road, strip / 2)
60
+ .map((path) => ribbonGeometry(path, strip, field, road.elevation + 0.02))
61
+ .filter((geometry) => geometry !== null);
62
+ }, [road, field]);
45
63
  const sidewalks = useMemo(() => {
46
64
  const width = sidewalkWidthOf(road);
47
65
  if (width <= 0)
@@ -53,5 +71,5 @@ export function RoadRibbons({ road, field }) {
53
71
  }, [road, field]);
54
72
  if (asphalt === null)
55
73
  return null;
56
- return (_jsxs("group", { children: [_jsx("mesh", { geometry: asphalt, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: road.color, roughness: 0.95, metalness: 0, side: DoubleSide }) }), dashes !== null ? (_jsx("mesh", { geometry: dashes, children: _jsx("meshStandardMaterial", { color: road.markingColor, roughness: 0.8, metalness: 0, side: DoubleSide }) })) : null, sidewalks.map((geometry, index) => (_jsx("mesh", { geometry: geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: road.sidewalk === false ? "#a7adb8" : road.sidewalk.color, roughness: 0.9, metalness: 0, side: DoubleSide }) }, `walk-${index}`)))] }));
74
+ return (_jsxs("group", { children: [_jsx("mesh", { geometry: asphalt, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: road.color, roughness: 0.95, metalness: 0, side: DoubleSide }) }), curbs.map((geometry, index) => (_jsx("mesh", { geometry: geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: curbColor, roughness: 0.9, metalness: 0, side: DoubleSide }) }, `curb-${index}`))), dashes !== null ? (_jsx("mesh", { geometry: dashes, children: _jsx("meshStandardMaterial", { color: road.markingColor, roughness: 0.8, metalness: 0, side: DoubleSide }) })) : null, sidewalks.map((geometry, index) => (_jsx("mesh", { geometry: geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: road.sidewalk === false ? "#a7adb8" : road.sidewalk.color, roughness: 0.9, metalness: 0, side: DoubleSide }) }, `walk-${index}`)))] }));
57
75
  }
@@ -0,0 +1,9 @@
1
+ import type { TerrainEnvironmentDescriptor } from "@jgengine/core/world/features";
2
+ import { type TerrainField } from "@jgengine/core/world/terrain";
3
+ /**
4
+ * Terrain surface color at a world XZ — the palette low/high lerped by the sampled ground height,
5
+ * mirroring how `CarvedTerrain` shades its vertices. The seam that lets everything planted on the
6
+ * ground (grass roots, soil-patch borders) blend into the exact color it stands on.
7
+ * @internal
8
+ */
9
+ export declare function terrainGroundColorSampler(terrain: Omit<TerrainEnvironmentDescriptor, "kind"> | undefined, field: TerrainField): ((x: number, z: number) => string) | undefined;
@@ -0,0 +1,22 @@
1
+ import * as THREE from "three";
2
+ import { resolveTerrainPalette } from "@jgengine/core/world/terrain";
3
+ /**
4
+ * Terrain surface color at a world XZ — the palette low/high lerped by the sampled ground height,
5
+ * mirroring how `CarvedTerrain` shades its vertices. The seam that lets everything planted on the
6
+ * ground (grass roots, soil-patch borders) blend into the exact color it stands on.
7
+ * @internal
8
+ */
9
+ export function terrainGroundColorSampler(terrain, field) {
10
+ if (terrain === undefined)
11
+ return undefined;
12
+ const palette = resolveTerrainPalette(terrain);
13
+ const low = new THREE.Color(palette.low);
14
+ const high = new THREE.Color(palette.high);
15
+ const base = terrain.baseHeight ?? 0;
16
+ const swing = terrain.height * 1.2;
17
+ const scratch = new THREE.Color();
18
+ return (x, z) => {
19
+ const t = swing <= 0 ? 0.5 : THREE.MathUtils.clamp((field.sampleHeight(x, z) - (base - swing)) / (swing * 2), 0, 1);
20
+ return `#${scratch.copy(low).lerp(high, t).getHexString()}`;
21
+ };
22
+ }