@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.
- package/CHANGELOG.md +52 -0
- package/dist/CombatPresentation.d.ts +5 -0
- package/dist/CombatPresentation.js +8 -0
- package/dist/GameHost.d.ts +27 -1
- package/dist/GameHost.js +83 -4
- package/dist/GamePlayer.d.ts +5 -0
- package/dist/GamePlayer.js +5 -0
- package/dist/GamePlayerShell.d.ts +5 -0
- package/dist/GamePlayerShell.js +63 -359
- package/dist/GameUiPreview.js +3 -0
- package/dist/Shell3dPresentation.d.ts +63 -0
- package/dist/Shell3dPresentation.js +302 -0
- package/dist/ShellChrome.d.ts +51 -0
- package/dist/ShellChrome.js +51 -0
- package/dist/ShellHudPresentation.d.ts +41 -0
- package/dist/ShellHudPresentation.js +22 -0
- package/dist/audio/audioEngine.d.ts +4 -0
- package/dist/audio/audioEngine.js +111 -26
- package/dist/audio/audioWire.d.ts +22 -4
- package/dist/audio/audioWire.js +46 -3
- package/dist/audio/loopParams.d.ts +20 -0
- package/dist/audio/loopParams.js +28 -0
- package/dist/camera/GameCameraRig.js +6 -1
- package/dist/camera/GameOrbitCamera.js +7 -1
- package/dist/camera/cameraCollision.d.ts +28 -0
- package/dist/camera/cameraCollision.js +34 -0
- package/dist/camera/cameraRigs.d.ts +3 -1
- package/dist/camera/cameraRigs.js +24 -10
- package/dist/camera/index.d.ts +1 -0
- package/dist/camera/index.js +1 -0
- package/dist/camera/rigMath.d.ts +27 -0
- package/dist/camera/rigMath.js +45 -0
- package/dist/defineGame.d.ts +15 -1
- package/dist/defineGame.js +26 -4
- package/dist/devtools/DevtoolsOverlay.d.ts +4 -4
- package/dist/devtools/DevtoolsOverlay.js +28 -12
- package/dist/devtools/agentBridge.js +4 -0
- package/dist/devtools/perfDiagnose.js +1 -1
- package/dist/diagnostics/RuntimeDiagnostics.d.ts +7 -0
- package/dist/diagnostics/RuntimeDiagnostics.js +23 -12
- package/dist/environment/EnvironmentScene.js +17 -3
- package/dist/environment/RoadJunctions.d.ts +12 -0
- package/dist/environment/RoadJunctions.js +69 -0
- package/dist/environment/RoadRibbons.d.ts +8 -2
- package/dist/environment/RoadRibbons.js +26 -8
- package/dist/environment/terrainGroundColor.d.ts +9 -0
- package/dist/environment/terrainGroundColor.js +22 -0
- package/dist/gameKit.d.ts +27 -0
- package/dist/gameKit.js +29 -0
- package/dist/map/MapMarkerBeacons.d.ts +3 -2
- package/dist/postfx/PostProcessing.d.ts +9 -1
- package/dist/postfx/PostProcessing.js +11 -4
- package/dist/presentationResolve.d.ts +39 -0
- package/dist/presentationResolve.js +53 -0
- package/dist/registry.d.ts +1 -0
- package/dist/render/PartMotion.d.ts +18 -0
- package/dist/render/PartMotion.js +91 -0
- package/dist/render/SceneModels.d.ts +9 -2
- package/dist/render/SceneModels.js +31 -180
- package/dist/render/assetBase.d.ts +17 -0
- package/dist/render/assetBase.js +35 -0
- package/dist/render/measureBounds.d.ts +34 -0
- package/dist/render/measureBounds.js +127 -0
- package/dist/render/modelLoad.js +4 -1
- package/dist/render/resolveModel.d.ts +15 -0
- package/dist/render/resolveModel.js +43 -2
- package/dist/render/useModelAnimation.d.ts +10 -0
- package/dist/render/useModelAnimation.js +198 -0
- package/dist/scatter/InstancedScatter.js +5 -0
- package/dist/scatter/scatterProxies.js +24 -0
- package/dist/scene/AuthoredScene.d.ts +3 -1
- package/dist/scene/AuthoredScene.js +7 -2
- package/dist/scene/CityRenderer.d.ts +2 -0
- package/dist/scene/CityRenderer.js +1031 -0
- package/dist/scene/GrassFieldRenderer.js +53 -5
- package/dist/scene/PoleLineRenderer.d.ts +2 -0
- package/dist/scene/PoleLineRenderer.js +287 -0
- package/dist/scene/SoilPatchRenderer.js +70 -7
- package/dist/scene/WaterRenderer.js +34 -10
- package/dist/scene/builtinSceneKindRenderers.js +4 -0
- package/dist/scene/cityWindowMaterial.d.ts +16 -0
- package/dist/scene/cityWindowMaterial.js +81 -0
- package/dist/scene/sceneKindRenderers.d.ts +2 -0
- package/dist/settings/appliedSettings.d.ts +2 -1
- package/dist/settings/appliedSettings.js +4 -2
- package/dist/structures/GeneratedBuilding.d.ts +4 -0
- package/dist/structures/GeneratedBuilding.js +17 -7
- package/dist/structures/TransformGizmo.d.ts +11 -1
- package/dist/structures/TransformGizmo.js +9 -5
- package/dist/terrain/GrassField.d.ts +11 -3
- package/dist/terrain/GrassField.js +16 -6
- package/dist/terrain/grassGeometry.d.ts +33 -1
- package/dist/terrain/grassGeometry.js +83 -21
- package/dist/terrain/grassMaterial.d.ts +18 -0
- package/dist/terrain/grassMaterial.js +66 -13
- package/dist/terrain/heightfieldGeometry.d.ts +34 -0
- package/dist/terrain/heightfieldGeometry.js +109 -0
- package/dist/terrain/index.d.ts +1 -0
- package/dist/terrain/index.js +1 -0
- package/dist/terrain/soilPatchMaterial.d.ts +15 -2
- package/dist/terrain/soilPatchMaterial.js +46 -8
- package/dist/terrain/terrainDetailMaterial.js +15 -2
- package/dist/water/Ocean.d.ts +7 -1
- package/dist/water/Ocean.js +16 -3
- package/dist/water/OceanConfig.d.ts +9 -0
- package/dist/water/OceanConfig.js +6 -0
- package/dist/water/OceanMaterial.d.ts +12 -0
- package/dist/water/OceanMaterial.js +9 -0
- package/dist/water/OceanShader.d.ts +2 -2
- package/dist/water/OceanShader.js +78 -10
- package/dist/world/PlaceScene.d.ts +11 -0
- package/dist/world/PlaceScene.js +27 -0
- package/dist/world/WorldHud.d.ts +3 -1
- package/dist/world/WorldHud.js +2 -2
- package/dist/world/WorldScene.js +32 -4
- package/dist/worldSky.d.ts +7 -1
- package/dist/worldSky.js +11 -1
- package/package.json +4 -4
- package/skills/game-design/SKILL.md +28 -0
- package/skills/game-design/agents/openai.yaml +4 -0
- package/skills/game-design/references/application-playbook.md +170 -0
- package/skills/game-design/references/field-guide.md +288 -0
- package/skills/jgengine/SKILL.md +8 -4
- package/skills/jgengine/api.md +182 -81
- package/skills/jgengine/capabilities.md +104 -2
- package/skills/jgengine/recipes/minimal-game.md +88 -0
- package/skills/jgengine-ui/SKILL.md +17 -10
- package/skills/jgengine-ui/api.md +316 -51
- package/skills/jgengine-ui/capabilities.md +135 -2
- package/skills/jgengine-ui/recipes/portable-minimap.md +150 -0
- package/skills/jgengine-ui/reference.md +59 -9
- package/skills/jgengine-ui/references/visual-scorecard.md +1 -1
- package/skills/jgengine-world/SKILL.md +13 -0
- package/skills/jgengine-world/api.md +453 -21
- package/skills/jgengine-world/capabilities.md +112 -2
- package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
- package/skills/jgengine-world/reference.md +43 -4
- package/skills/level-design/SKILL.md +54 -0
- package/skills/level-design/agents/openai.yaml +4 -0
- package/skills/level-design/references/application-playbook.md +192 -0
- package/skills/level-design/references/field-guide.md +337 -0
- package/skills/level-design/references/live-editor-loop.md +179 -0
- package/dist/cartridge/runtime.d.ts +0 -15
- package/dist/cartridge/runtime.js +0 -556
- package/dist/cartridge/spec.d.ts +0 -188
- package/dist/cartridge/spec.js +0 -19
- package/dist/cartridge/validate.d.ts +0 -2
- package/dist/cartridge/validate.js +0 -78
- package/dist/cartridge.d.ts +0 -145
- package/dist/cartridge.js +0 -246
- package/skills/jgengine/reference-cartridge.md +0 -72
package/dist/camera/rigMath.js
CHANGED
|
@@ -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
|
*/
|
package/dist/defineGame.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type
|
|
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 {};
|
package/dist/defineGame.js
CHANGED
|
@@ -1,17 +1,34 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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/
|
|
34
|
-
latency: import("@jgengine/core/devtools/
|
|
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/
|
|
57
|
+
render: import("@jgengine/core/devtools/types").RenderSample | null;
|
|
58
58
|
}[];
|
|
59
59
|
probes: Record<string, unknown>;
|
|
60
|
-
logs: import("@jgengine/core/devtools/
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
4
|
-
|
|
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
|
-
/**
|
|
42
|
-
|
|
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
|
+
}
|