@jgengine/shell 0.21.0 → 0.23.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 +114 -0
- package/dist/GamePlayerShell.js +18 -3
- package/dist/GameUiPreview.js +2 -1
- package/dist/Shell3dPresentation.js +13 -8
- package/dist/ShellChrome.d.ts +1 -1
- package/dist/ShellChrome.js +4 -2
- package/dist/ShellHudPresentation.js +2 -1
- package/dist/ShellNotices.d.ts +26 -0
- package/dist/ShellNotices.js +51 -0
- package/dist/audio/AudioComponents.d.ts +3 -1
- package/dist/audio/AudioComponents.js +5 -2
- package/dist/audio/listenerAnchor.d.ts +15 -0
- package/dist/audio/listenerAnchor.js +17 -0
- package/dist/audio/playableAudio.d.ts +9 -0
- package/dist/audio/playableAudio.js +11 -0
- package/dist/commandSink.d.ts +1 -1
- package/dist/commandSink.js +8 -3
- package/dist/defineGame.js +4 -2
- package/dist/devtools/DevtoolsOverlay.js +1 -0
- package/dist/devtools/agentBridge.d.ts +21 -0
- package/dist/devtools/agentBridge.js +95 -3
- package/dist/devtools/devtoolsReports.d.ts +32 -0
- package/dist/devtools/devtoolsReports.js +26 -0
- package/dist/devtools/simStepper.d.ts +21 -0
- package/dist/devtools/simStepper.js +37 -0
- package/dist/drivers/FrameDriver.js +26 -14
- package/dist/drivers/HudOnlyDriver.js +16 -5
- package/dist/environment/EnvironmentScene.js +24 -9
- package/dist/environment/RoadJunctions.d.ts +3 -1
- package/dist/environment/RoadJunctions.js +9 -2
- package/dist/environment/RoadRibbons.d.ts +3 -1
- package/dist/environment/RoadRibbons.js +16 -3
- package/dist/environment/terrainGroundColor.d.ts +5 -4
- package/dist/environment/terrainGroundColor.js +20 -9
- package/dist/materialOverride.js +4 -2
- package/dist/playSurfaceFocus.d.ts +16 -0
- package/dist/playSurfaceFocus.js +42 -0
- package/dist/postfx/PostProcessing.d.ts +3 -1
- package/dist/postfx/PostProcessing.js +14 -6
- package/dist/registry.d.ts +5 -1
- package/dist/render/CascadedShadows.d.ts +2 -0
- package/dist/render/CascadedShadows.js +4 -0
- package/dist/render/EntityPreview.js +1 -1
- package/dist/render/EnvironmentLighting.d.ts +26 -7
- package/dist/render/EnvironmentLighting.js +40 -27
- package/dist/render/SceneLighting.js +2 -2
- package/dist/render/modelLoad.d.ts +7 -0
- package/dist/render/modelLoad.js +13 -0
- package/dist/render/modelRender.d.ts +1 -0
- package/dist/render/modelRender.js +3 -0
- package/dist/render/modelSurface.d.ts +39 -0
- package/dist/render/modelSurface.js +206 -0
- package/dist/render/useModelInstance.d.ts +1 -1
- package/dist/render/useModelInstance.js +2 -2
- package/dist/scatter/scatterModels.js +3 -1
- package/dist/scene/AuthoredScene.d.ts +11 -3
- package/dist/scene/AuthoredScene.js +17 -9
- package/dist/scene/CityRenderer.js +86 -24
- package/dist/scene/GrassFieldRenderer.js +20 -2
- package/dist/scene/cityJunctionSurface.d.ts +7 -0
- package/dist/scene/cityJunctionSurface.js +34 -0
- package/dist/settings/settingsController.js +1 -1
- package/dist/terrain/CarvedTerrain.d.ts +2 -1
- package/dist/terrain/CarvedTerrain.js +2 -2
- package/dist/terrain/GrassField.d.ts +2 -1
- package/dist/terrain/GrassField.js +3 -2
- package/dist/terrain/grassGeometry.d.ts +3 -0
- package/dist/terrain/grassGeometry.js +2 -1
- package/dist/terrain/index.d.ts +5 -0
- package/dist/terrain/index.js +5 -0
- package/dist/terrain/surfaceRibbonMaterial.d.ts +62 -0
- package/dist/terrain/surfaceRibbonMaterial.js +227 -0
- package/dist/terrain/terrainDetailMaterial.d.ts +33 -10
- package/dist/terrain/terrainDetailMaterial.js +149 -63
- package/dist/terrain/terrainMath.d.ts +2 -0
- package/dist/terrain/terrainMath.js +2 -0
- package/dist/terrain/terrainPaintColor.d.ts +20 -0
- package/dist/terrain/terrainPaintColor.js +92 -0
- package/dist/terrain/terrainQuality.d.ts +5 -0
- package/dist/terrain/terrainQuality.js +7 -0
- package/dist/terrain/terrainSurfaceShader.d.ts +16 -0
- package/dist/terrain/terrainSurfaceShader.js +266 -0
- package/dist/terrain/terrainSurfaces.d.ts +59 -0
- package/dist/terrain/terrainSurfaces.js +199 -0
- package/dist/world/StaticObjectModels.d.ts +1 -1
- package/dist/world/StaticObjectModels.js +1 -1
- package/dist/world/WorldScene.js +1 -1
- package/dist/world/staticObjectBatches.d.ts +5 -1
- package/dist/world/staticObjectBatches.js +9 -5
- package/dist/world/worldBarSamples.d.ts +13 -1
- package/dist/world/worldBarSamples.js +40 -8
- package/package.json +5 -5
- package/skills/game-design/SKILL.md +2 -0
- package/skills/jgengine/SKILL.md +1 -0
- package/skills/jgengine/api.md +18 -18
- package/skills/jgengine-ui/SKILL.md +4 -0
- package/skills/jgengine-ui/api.md +242 -34
- package/skills/jgengine-ui/capabilities.md +56 -0
- package/skills/jgengine-ui/reference-primitives.md +4 -4
- package/skills/jgengine-world/api.md +28 -15
- package/skills/jgengine-world/capabilities.md +4 -0
- package/skills/jgengine-world/reference.md +30 -0
package/dist/commandSink.js
CHANGED
|
@@ -11,10 +11,15 @@ export function localCommandSink(ctx) {
|
|
|
11
11
|
/** Sends commands to the authoritative host over the transport; the result replicates back through world sync.
|
|
12
12
|
* @internal
|
|
13
13
|
*/
|
|
14
|
-
export function remoteCommandSink(backend, serverId) {
|
|
14
|
+
export function remoteCommandSink(backend, serverId, events) {
|
|
15
15
|
return {
|
|
16
16
|
run(name, input) {
|
|
17
|
-
void backend.transport.runCommand({ serverId, command: name, input }).
|
|
17
|
+
void backend.transport.runCommand({ serverId, command: name, input }).then((result) => {
|
|
18
|
+
// Host refusals reach the local notice feed the same way local ones do.
|
|
19
|
+
if (!result.ok && result.message !== undefined) {
|
|
20
|
+
events?.emit("command.refused", { command: name, reason: result.reason, message: result.message, actor: null });
|
|
21
|
+
}
|
|
22
|
+
}).catch(() => undefined);
|
|
18
23
|
},
|
|
19
24
|
};
|
|
20
25
|
}
|
|
@@ -25,7 +30,7 @@ export function remoteCommandSink(backend, serverId) {
|
|
|
25
30
|
*/
|
|
26
31
|
export function resolveCommandSink(ctx, opts) {
|
|
27
32
|
if (opts.serverAuthoritative && opts.backend !== null && opts.serverId !== null) {
|
|
28
|
-
return remoteCommandSink(opts.backend, opts.serverId);
|
|
33
|
+
return remoteCommandSink(opts.backend, opts.serverId, ctx.game.events);
|
|
29
34
|
}
|
|
30
35
|
if (opts.serverAuthoritative)
|
|
31
36
|
return { run() { } };
|
package/dist/defineGame.js
CHANGED
|
@@ -42,7 +42,7 @@ function isEnvironmentSource(value) {
|
|
|
42
42
|
function authoredSceneOverlay(document, diagnostics, placement, scatterModels, flockModels, pathKinds, assets, world, Vfx) {
|
|
43
43
|
const terrain = world !== undefined && world.kind === "environment" ? world.terrain : undefined;
|
|
44
44
|
return function AuthoredSceneOverlay(props) {
|
|
45
|
-
const groundColorAt = useMemo(() => terrainGroundColorSampler(terrain, props.ctx.world.ground), [props.ctx.world.ground]);
|
|
45
|
+
const groundColorAt = useMemo(() => terrainGroundColorSampler(terrain, props.ctx.world.ground, document.terrain), [props.ctx.world.ground]);
|
|
46
46
|
return (_jsxs(_Fragment, { children: [_jsx(AuthoredScene, { document: document, diagnostics: diagnostics, field: props.ctx.world.ground, placeObjects: placement, assets: assets, ...(scatterModels === undefined ? {} : { scatterModels }), ...(flockModels === undefined ? {} : { flockModels }), ...(pathKinds === undefined ? {} : { pathKinds }), ...(groundColorAt === undefined ? {} : { groundColorAt }) }), Vfx === undefined ? null : _jsx(Vfx, { ...props })] }));
|
|
47
47
|
};
|
|
48
48
|
}
|
|
@@ -55,7 +55,7 @@ const emptyUi = () => null;
|
|
|
55
55
|
* @capability define-game single public game-authoring path — compose systems, world, presentation, and authored scene in one definition
|
|
56
56
|
*/
|
|
57
57
|
export function defineGame(config) {
|
|
58
|
-
const { content, loop, GameUI, environment, camera, multiplayer, editorLayers, editorCatalogs, scenePlacement, sceneScatterModels, sceneFlockModels, scenePathKinds, WorldOverlay, viewmodel, renderEntity, renderObject, entitySprites, entityModels, objectModels, hotbarSelection, prompts, pointer, touch, gamepad, localPlayers, viewports, orientation, worldHealthBars, nameplates, presentationEffects, audio, entitySounds, objectSounds, worldItem, collision, movement, lighting: authoredLighting, environmentSource, backdrop, look, postProcessing, shadows, graphics, presentation, settings, objectStyles, devtools, capture, platforms, hudFit, ...engineFields } = config;
|
|
58
|
+
const { content, loop, GameUI, environment, camera, multiplayer, editorLayers, editorCatalogs, scenePlacement, sceneScatterModels, sceneFlockModels, scenePathKinds, WorldOverlay, viewmodel, renderEntity, renderObject, entitySprites, entityModels, objectModels, hotbarSelection, prompts, pointer, touch, gamepad, localPlayers, viewports, orientation, worldHealthBars, nameplates, presentationEffects, audio, entitySounds, objectSounds, worldItem, collision, movement, lighting: authoredLighting, environmentSource, backdrop, look, postProcessing, shadows, graphics, presentation, settings, notices, objectStyles, devtools, capture, platforms, hudFit, hudTheme, ...engineFields } = config;
|
|
59
59
|
const game = defineEngineGame({
|
|
60
60
|
...engineFields,
|
|
61
61
|
...(editorLayers === undefined ? {} : { authoredDocument: editorLayers }),
|
|
@@ -133,10 +133,12 @@ export function defineGame(config) {
|
|
|
133
133
|
graphics,
|
|
134
134
|
presentation,
|
|
135
135
|
settings,
|
|
136
|
+
notices,
|
|
136
137
|
objectStyles,
|
|
137
138
|
devtools,
|
|
138
139
|
capture,
|
|
139
140
|
platforms,
|
|
140
141
|
hudFit,
|
|
142
|
+
hudTheme,
|
|
141
143
|
};
|
|
142
144
|
}
|
|
@@ -20,6 +20,7 @@ export function DevtoolsRendererProbe() {
|
|
|
20
20
|
useEffect(() => devtools.probes.register("heavyModels", () => heavyModels()), []);
|
|
21
21
|
const gl = useThree((state) => state.gl);
|
|
22
22
|
const lastSampleAt = useRef(0);
|
|
23
|
+
useEffect(() => devtools.probes.register("shaderPrograms", () => gl.info.programs?.length ?? 0), [gl]);
|
|
23
24
|
useEffect(() => {
|
|
24
25
|
// three resets gl.info after every render() call, so with a multi-pass
|
|
25
26
|
// composer the counters only ever hold the last fullscreen pass. Own the
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { GameContext } from "@jgengine/core/runtime/gameContext";
|
|
1
2
|
import type { PlayableGame } from "../registry.js";
|
|
2
3
|
/** One RPC call into the agent bridge: a verb name plus its verb-specific fields. */
|
|
3
4
|
export type AgentBridgeRequest = {
|
|
@@ -11,6 +12,24 @@ export type AgentBridgeResponse = {
|
|
|
11
12
|
ok: false;
|
|
12
13
|
error: string;
|
|
13
14
|
};
|
|
15
|
+
/**
|
|
16
|
+
* @internal
|
|
17
|
+
* The local player's live pose for `player_pose` / `debug_snapshot`, so a drive can measure movement
|
|
18
|
+
* (position deltas between two RPCs) instead of judging it from screenshots. `null` before the game
|
|
19
|
+
* context exists or before the player entity spawns.
|
|
20
|
+
*/
|
|
21
|
+
export declare function playerPoseReport(ctx: GameContext | null): {
|
|
22
|
+
userId: string;
|
|
23
|
+
position: readonly [number, number, number];
|
|
24
|
+
rotationY: number;
|
|
25
|
+
} | null;
|
|
26
|
+
/**
|
|
27
|
+
* @internal
|
|
28
|
+
* `time_scale` verb: sets the game clock's global timescale (`ctx.time.setTimescale`) so a headless
|
|
29
|
+
* drive can fast-forward timers, economies and day cycles that never finish at capture frame rates.
|
|
30
|
+
* Large scales take large steps — use it for clocks, not to judge movement. Omit `scale` to read.
|
|
31
|
+
*/
|
|
32
|
+
export declare function applyTimeScale(ctx: Pick<GameContext, "time">, scale: unknown): AgentBridgeResponse;
|
|
14
33
|
type EditorHost = {
|
|
15
34
|
gameId: string;
|
|
16
35
|
handle(request: AgentBridgeRequest): unknown;
|
|
@@ -43,5 +62,7 @@ export declare function installAgentBridge(options: {
|
|
|
43
62
|
devtoolsEnabled: boolean;
|
|
44
63
|
isDevtoolsOpen: () => boolean;
|
|
45
64
|
setDevtoolsOpen: (open: boolean) => void;
|
|
65
|
+
/** Live game context, once mounted; feeds `player_pose`. */
|
|
66
|
+
getContext?: () => GameContext | null;
|
|
46
67
|
}): () => void;
|
|
47
68
|
export {};
|
|
@@ -2,7 +2,39 @@ import { devtools } from "@jgengine/core/devtools/devtools";
|
|
|
2
2
|
import { getSaveEndpoint } from "@jgengine/core/devtools/saveEndpoint";
|
|
3
3
|
import { isHudAnchor, listActiveHudLayouts, } from "@jgengine/core/ui/hudLayout";
|
|
4
4
|
import { listHudPanelTypes, resolvePanelResize, resizePanelSize, } from "@jgengine/core/ui/hudDocument";
|
|
5
|
-
import { buildFullReport, buildLeanReport } from "./devtoolsReports.js";
|
|
5
|
+
import { buildFullReport, buildLeanReport, summarizeLeanReport } from "./devtoolsReports.js";
|
|
6
|
+
import { advanceSimulation } from "./simStepper.js";
|
|
7
|
+
/**
|
|
8
|
+
* @internal
|
|
9
|
+
* The local player's live pose for `player_pose` / `debug_snapshot`, so a drive can measure movement
|
|
10
|
+
* (position deltas between two RPCs) instead of judging it from screenshots. `null` before the game
|
|
11
|
+
* context exists or before the player entity spawns.
|
|
12
|
+
*/
|
|
13
|
+
export function playerPoseReport(ctx) {
|
|
14
|
+
if (ctx === null)
|
|
15
|
+
return null;
|
|
16
|
+
const entity = ctx.scene.entity.get(ctx.player.userId);
|
|
17
|
+
if (entity === null || entity === undefined)
|
|
18
|
+
return null;
|
|
19
|
+
const [x, y, z] = entity.position;
|
|
20
|
+
const round = (value) => Math.round(value * 1000) / 1000;
|
|
21
|
+
return { userId: ctx.player.userId, position: [round(x), round(y), round(z)], rotationY: round(entity.rotationY ?? 0) };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* @internal
|
|
25
|
+
* `time_scale` verb: sets the game clock's global timescale (`ctx.time.setTimescale`) so a headless
|
|
26
|
+
* drive can fast-forward timers, economies and day cycles that never finish at capture frame rates.
|
|
27
|
+
* Large scales take large steps — use it for clocks, not to judge movement. Omit `scale` to read.
|
|
28
|
+
*/
|
|
29
|
+
export function applyTimeScale(ctx, scale) {
|
|
30
|
+
if (scale !== undefined) {
|
|
31
|
+
if (typeof scale !== "number" || !Number.isFinite(scale) || scale < 0 || scale > 120) {
|
|
32
|
+
return { ok: false, error: "time_scale needs { scale } between 0 and 120 (1 = normal)" };
|
|
33
|
+
}
|
|
34
|
+
ctx.time.setTimescale(scale);
|
|
35
|
+
}
|
|
36
|
+
return { ok: true, timescale: ctx.time.timescale(), now: Math.round(ctx.time.now() * 1000) / 1000 };
|
|
37
|
+
}
|
|
6
38
|
/**
|
|
7
39
|
* @internal
|
|
8
40
|
* Resolves true once `isLive()` reports a mounted editor host, polling every `intervalMs` up to
|
|
@@ -91,6 +123,7 @@ function panelLayoutSnapshot(placement, extras) {
|
|
|
91
123
|
*/
|
|
92
124
|
export function installAgentBridge(options) {
|
|
93
125
|
const { playable, devtoolsEnabled, isDevtoolsOpen, setDevtoolsOpen } = options;
|
|
126
|
+
const getContext = options.getContext ?? (() => null);
|
|
94
127
|
const handle = (request) => {
|
|
95
128
|
const method = typeof request?.method === "string" ? request.method : "";
|
|
96
129
|
switch (method) {
|
|
@@ -115,8 +148,67 @@ export function installAgentBridge(options) {
|
|
|
115
148
|
setDevtoolsOpen(open);
|
|
116
149
|
return { ok: true, open };
|
|
117
150
|
}
|
|
118
|
-
case "
|
|
119
|
-
|
|
151
|
+
case "debug_summary":
|
|
152
|
+
case "debug_snapshot": {
|
|
153
|
+
const ctx = getContext();
|
|
154
|
+
const lean = buildLeanReport(playable);
|
|
155
|
+
if (method === "debug_summary" || request.detail === "summary") {
|
|
156
|
+
const entities = ctx === null ? null : ctx.scene.entity.ids().length;
|
|
157
|
+
return { ok: true, summary: { ...summarizeLeanReport(lean, entities), player: playerPoseReport(ctx) } };
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, snapshot: { ...lean, player: playerPoseReport(ctx) } };
|
|
160
|
+
}
|
|
161
|
+
case "player_pose": {
|
|
162
|
+
const pose = playerPoseReport(getContext());
|
|
163
|
+
return pose === null ? { ok: false, error: "no local player entity yet" } : { ok: true, ...pose };
|
|
164
|
+
}
|
|
165
|
+
case "time_scale": {
|
|
166
|
+
const ctx = getContext();
|
|
167
|
+
if (ctx === null)
|
|
168
|
+
return { ok: false, error: "no game context yet" };
|
|
169
|
+
return applyTimeScale(ctx, request.scale);
|
|
170
|
+
}
|
|
171
|
+
case "sim_advance": {
|
|
172
|
+
const seconds = request.seconds;
|
|
173
|
+
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0 || seconds > 120) {
|
|
174
|
+
return { ok: false, error: "sim_advance needs { seconds } in (0, 120]" };
|
|
175
|
+
}
|
|
176
|
+
return advanceSimulation(seconds).then((frames) => {
|
|
177
|
+
if (frames === null)
|
|
178
|
+
return { ok: false, error: "no sim driver mounted" };
|
|
179
|
+
const ctx = getContext();
|
|
180
|
+
return { ok: true, frames, now: ctx === null ? null : Math.round(ctx.time.now() * 1000) / 1000 };
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
case "notice_push": {
|
|
184
|
+
const ctx = getContext();
|
|
185
|
+
if (ctx === null)
|
|
186
|
+
return { ok: false, error: "no game context yet" };
|
|
187
|
+
const tone = request.tone;
|
|
188
|
+
if (typeof request.text !== "string" || (tone !== undefined && tone !== "info" && tone !== "success" && tone !== "warn" && tone !== "deny")) {
|
|
189
|
+
return { ok: false, error: "notice_push needs { text, tone?: info|success|warn|deny, ttlMs? }" };
|
|
190
|
+
}
|
|
191
|
+
const ttlMs = typeof request.ttlMs === "number" && request.ttlMs > 0 ? request.ttlMs : undefined;
|
|
192
|
+
const notice = ctx.game.notices.push({ text: request.text, ...(tone === undefined ? {} : { tone }), ...(ttlMs === undefined ? {} : { ttlMs }) });
|
|
193
|
+
return { ok: true, id: notice.id, count: notice.count };
|
|
194
|
+
}
|
|
195
|
+
case "notice_list": {
|
|
196
|
+
const ctx = getContext();
|
|
197
|
+
if (ctx === null)
|
|
198
|
+
return { ok: false, error: "no game context yet" };
|
|
199
|
+
return { ok: true, notices: ctx.game.notices.list(), rendered: document.querySelectorAll("[data-jg-notice]").length, now: Date.now() };
|
|
200
|
+
}
|
|
201
|
+
case "run_command": {
|
|
202
|
+
const ctx = getContext();
|
|
203
|
+
if (ctx === null)
|
|
204
|
+
return { ok: false, error: "no game context yet" };
|
|
205
|
+
if (typeof request.name !== "string")
|
|
206
|
+
return { ok: false, error: "run_command needs { name, input? }" };
|
|
207
|
+
const result = ctx.game.commands.run(request.name, request.input ?? {});
|
|
208
|
+
return result.status === "rejected"
|
|
209
|
+
? { ok: true, status: result.status, reason: result.reason, ...(result.message === undefined ? {} : { message: result.message }) }
|
|
210
|
+
: { ok: true, status: result.status };
|
|
211
|
+
}
|
|
120
212
|
case "debug_report":
|
|
121
213
|
return { ok: true, report: buildFullReport(playable) };
|
|
122
214
|
case "debug_perf_reset":
|
|
@@ -66,3 +66,35 @@ export declare function buildLeanReport(playable: PlayableGame): {
|
|
|
66
66
|
export declare function buildFullReport(playable: PlayableGame): DevtoolsSnapshot & {
|
|
67
67
|
game: string;
|
|
68
68
|
};
|
|
69
|
+
/** Compact `debug_summary` payload: the numbers an agent needs to judge a run, about 1 KB. */
|
|
70
|
+
export interface DebugSummary {
|
|
71
|
+
game: string;
|
|
72
|
+
fps: number | null;
|
|
73
|
+
frameMs: {
|
|
74
|
+
avg: number;
|
|
75
|
+
p95: number;
|
|
76
|
+
} | null;
|
|
77
|
+
simMs: number | null;
|
|
78
|
+
outsideSimMs: number | null;
|
|
79
|
+
drawCalls: number | null;
|
|
80
|
+
triangles: number | null;
|
|
81
|
+
entities: number | null;
|
|
82
|
+
longFrames: number;
|
|
83
|
+
topCulprit: {
|
|
84
|
+
culprit: string;
|
|
85
|
+
count: number;
|
|
86
|
+
} | null;
|
|
87
|
+
topPhase: {
|
|
88
|
+
name: string;
|
|
89
|
+
avgMs: number;
|
|
90
|
+
} | null;
|
|
91
|
+
warnings: number;
|
|
92
|
+
lastWarnings: string[];
|
|
93
|
+
why: string | null;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Reduce a lean report to {@link DebugSummary}: no per-frame probe arrays, no control lists, at most
|
|
97
|
+
* three warning lines of 160 characters. `debug_snapshot` with `detail: "full"` keeps everything.
|
|
98
|
+
* @internal
|
|
99
|
+
*/
|
|
100
|
+
export declare function summarizeLeanReport(lean: ReturnType<typeof buildLeanReport>, entities: number | null): DebugSummary;
|
|
@@ -98,3 +98,29 @@ export function buildLeanReport(playable) {
|
|
|
98
98
|
export function buildFullReport(playable) {
|
|
99
99
|
return { game: playable.game.name, ...devtools.snapshot() };
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Reduce a lean report to {@link DebugSummary}: no per-frame probe arrays, no control lists, at most
|
|
103
|
+
* three warning lines of 160 characters. `debug_snapshot` with `detail: "full"` keeps everything.
|
|
104
|
+
* @internal
|
|
105
|
+
*/
|
|
106
|
+
export function summarizeLeanReport(lean, entities) {
|
|
107
|
+
const frame = lean.frame;
|
|
108
|
+
const logs = lean.logs ?? [];
|
|
109
|
+
const phase = frame?.phases[0];
|
|
110
|
+
return {
|
|
111
|
+
game: lean.game,
|
|
112
|
+
fps: frame?.fps ?? null,
|
|
113
|
+
frameMs: frame === null ? null : { avg: frame.avgFrameMs, p95: frame.p95FrameMs },
|
|
114
|
+
simMs: frame?.avgSimMs ?? null,
|
|
115
|
+
outsideSimMs: frame?.avgOutsideMs ?? null,
|
|
116
|
+
drawCalls: lean.render?.drawCalls ?? null,
|
|
117
|
+
triangles: lean.render?.triangles ?? null,
|
|
118
|
+
entities,
|
|
119
|
+
longFrames: lean.longFrameSummary?.count ?? 0,
|
|
120
|
+
topCulprit: lean.longFrameSummary?.culprits[0] ?? null,
|
|
121
|
+
topPhase: phase === undefined ? null : { name: phase.name, avgMs: phase.avgMs },
|
|
122
|
+
warnings: logs.length,
|
|
123
|
+
lastWarnings: logs.slice(-3).map((entry) => `${entry.level}: ${entry.message}`.slice(0, 160)),
|
|
124
|
+
why: lean.why === null ? null : lean.why.split(/(?<=[.:])\s/)[0].slice(0, 200),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mounted sim driver's per-frame step, registered so the agent bridge can fast-forward the
|
|
3
|
+
* simulation without rendering (`sim_advance`). Headless software rendering runs at well under
|
|
4
|
+
* 1 fps, so timers, waves and economies otherwise never complete inside a drive capture.
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
export type SimStep = (rawDt: number) => void;
|
|
8
|
+
/** Register the active driver's step; returns the unregister. Last registration wins. @internal */
|
|
9
|
+
export declare function registerSimStepper(step: SimStep): () => void;
|
|
10
|
+
/**
|
|
11
|
+
* Step the registered simulation `seconds` of real time in `step`-second frames (each frame runs
|
|
12
|
+
* the full driver step: input, movement, `onTick`, behaviors, bound actions). Frames run in
|
|
13
|
+
* batches of {@link FRAMES_PER_YIELD} with a macrotask between batches, so React commits what the
|
|
14
|
+
* sim spawned along the way — models for entities created mid-advance mount as they would in play,
|
|
15
|
+
* instead of one burst the renderer never catches up with. Capped at 2400 frames per call.
|
|
16
|
+
* Resolves to the frames run, or `null` when no driver is mounted.
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
export declare function advanceSimulation(seconds: number, step?: number, yieldToHost?: () => Promise<void>): Promise<number | null>;
|
|
20
|
+
/** Simulated frames between yields to the host (ten game seconds at 0.05 s frames): each yield can cost a full render frame, which headless software rendering spends seconds on. @internal */
|
|
21
|
+
export declare const FRAMES_PER_YIELD = 200;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
let current = null;
|
|
2
|
+
/** Register the active driver's step; returns the unregister. Last registration wins. @internal */
|
|
3
|
+
export function registerSimStepper(step) {
|
|
4
|
+
current = step;
|
|
5
|
+
return () => {
|
|
6
|
+
if (current === step)
|
|
7
|
+
current = null;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Step the registered simulation `seconds` of real time in `step`-second frames (each frame runs
|
|
12
|
+
* the full driver step: input, movement, `onTick`, behaviors, bound actions). Frames run in
|
|
13
|
+
* batches of {@link FRAMES_PER_YIELD} with a macrotask between batches, so React commits what the
|
|
14
|
+
* sim spawned along the way — models for entities created mid-advance mount as they would in play,
|
|
15
|
+
* instead of one burst the renderer never catches up with. Capped at 2400 frames per call.
|
|
16
|
+
* Resolves to the frames run, or `null` when no driver is mounted.
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
export async function advanceSimulation(seconds, step = 0.05, yieldToHost = defaultYield) {
|
|
20
|
+
if (current === null)
|
|
21
|
+
return null;
|
|
22
|
+
const frame = Math.min(0.05, Math.max(0.001, step));
|
|
23
|
+
const frames = Math.min(2400, Math.max(0, Math.ceil(seconds / frame)));
|
|
24
|
+
for (let index = 0; index < frames; index += 1) {
|
|
25
|
+
if (current === null)
|
|
26
|
+
return index;
|
|
27
|
+
current(frame);
|
|
28
|
+
if ((index + 1) % FRAMES_PER_YIELD === 0 && index + 1 < frames)
|
|
29
|
+
await yieldToHost();
|
|
30
|
+
}
|
|
31
|
+
return frames;
|
|
32
|
+
}
|
|
33
|
+
/** Simulated frames between yields to the host (ten game seconds at 0.05 s frames): each yield can cost a full render frame, which headless software rendering spends seconds on. @internal */
|
|
34
|
+
export const FRAMES_PER_YIELD = 200;
|
|
35
|
+
function defaultYield() {
|
|
36
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
37
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useFrame } from "@react-three/fiber";
|
|
2
|
-
import { useMemo, useRef } from "react";
|
|
2
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
3
3
|
import { RESERVED_INPUT_ACTIONS, dispatchBoundAction, heldActionsFor, shouldFireBoundAction } from "../boundActionDispatch.js";
|
|
4
4
|
import { executeHotbarSlot, findHotbarSlotActions, hotbarIdFor } from "../hotbarActions.js";
|
|
5
5
|
import { pointerAimFor } from "../shellPointer.js";
|
|
@@ -17,6 +17,7 @@ import { DEFAULT_PICKUP_RADIUS } from "@jgengine/core/game/worldItem";
|
|
|
17
17
|
import { playControlsActive } from "@jgengine/core/game/controlGate";
|
|
18
18
|
import { devtools } from "@jgengine/core/devtools/devtools";
|
|
19
19
|
import { collisionDebug } from "../devtools/collisionDebug.js";
|
|
20
|
+
import { registerSimStepper } from "../devtools/simStepper.js";
|
|
20
21
|
import { GAME_SIM_FRAME_PRIORITY } from "../camera/index.js";
|
|
21
22
|
import { NO_ACTIONS } from "../shellConstants.js";
|
|
22
23
|
import { createPredictionBuffer } from "@jgengine/core/runtime/prediction";
|
|
@@ -79,17 +80,10 @@ export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryC
|
|
|
79
80
|
ctx.scene.entity.setPose(playerId, { position: current });
|
|
80
81
|
}, [ctx]);
|
|
81
82
|
authoritativeFrameRef.current = serverAuthoritative ? predictionHandler : null;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
posterElapsedRef.current += Math.min(rawDt, 0.05);
|
|
87
|
-
if (posterElapsedRef.current >= POSTER_SETTLE_SECONDS) {
|
|
88
|
-
posterDoneRef.current = true;
|
|
89
|
-
onPosterSettled();
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
83
|
+
// One frame of simulation — input, movement, onTick, behaviors, bound actions — shared by the
|
|
84
|
+
// render loop and the agent bridge's render-free `sim_advance`.
|
|
85
|
+
/** Returns false when play is gated (menu, pause) and nothing advanced. */
|
|
86
|
+
const simulate = (rawDt) => {
|
|
93
87
|
const sendInput = () => {
|
|
94
88
|
if (!serverAuthoritative)
|
|
95
89
|
return;
|
|
@@ -109,9 +103,8 @@ export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryC
|
|
|
109
103
|
ctx.input.publish(heldActionsFor(tracker, NO_ACTIONS), { reset: true });
|
|
110
104
|
ctx.input.publishAnalog(null);
|
|
111
105
|
sendInput();
|
|
112
|
-
return;
|
|
106
|
+
return false;
|
|
113
107
|
}
|
|
114
|
-
const simStart = performance.now();
|
|
115
108
|
try {
|
|
116
109
|
let endPhase = devtools.profile.begin("time+input");
|
|
117
110
|
const dt = Math.min(rawDt, 0.05);
|
|
@@ -280,6 +273,25 @@ export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryC
|
|
|
280
273
|
onRuntimeError(error, "tick");
|
|
281
274
|
}
|
|
282
275
|
}
|
|
276
|
+
return true;
|
|
277
|
+
};
|
|
278
|
+
const simulateRef = useRef(simulate);
|
|
279
|
+
simulateRef.current = simulate;
|
|
280
|
+
useEffect(() => (poster ? undefined : registerSimStepper((rawDt) => simulateRef.current(rawDt))), [poster]);
|
|
281
|
+
useFrame((_state, rawDt) => {
|
|
282
|
+
if (poster) {
|
|
283
|
+
if (posterDoneRef.current)
|
|
284
|
+
return;
|
|
285
|
+
posterElapsedRef.current += Math.min(rawDt, 0.05);
|
|
286
|
+
if (posterElapsedRef.current >= POSTER_SETTLE_SECONDS) {
|
|
287
|
+
posterDoneRef.current = true;
|
|
288
|
+
onPosterSettled();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const simStart = performance.now();
|
|
293
|
+
if (!simulate(rawDt))
|
|
294
|
+
return;
|
|
283
295
|
devtools.frame.record({ frameMs: rawDt * 1000, simMs: performance.now() - simStart });
|
|
284
296
|
}, GAME_SIM_FRAME_PRIORITY);
|
|
285
297
|
return null;
|
|
@@ -7,6 +7,7 @@ import { isServerAuthoritative } from "@jgengine/core/runtime/adapter";
|
|
|
7
7
|
import { resolveCommandSink } from "../commandSink.js";
|
|
8
8
|
import { inputFramesEqual, resolveInputSink } from "../inputSink.js";
|
|
9
9
|
import { EMPTY_RESERVED, NO_ACTIONS } from "../shellConstants.js";
|
|
10
|
+
import { registerSimStepper } from "../devtools/simStepper.js";
|
|
10
11
|
export function HudOnlyDriver({ ctx, multiplayer, serverIdRef, playable, tracker, pointerAxisRef, gateRef, onRuntimeError, }) {
|
|
11
12
|
const hasReportedTickError = useRef(false);
|
|
12
13
|
const repeatFiredAtRef = useRef(new Map());
|
|
@@ -22,6 +23,14 @@ export function HudOnlyDriver({ ctx, multiplayer, serverIdRef, playable, tracker
|
|
|
22
23
|
lastFrameRef.current = now;
|
|
23
24
|
if (last === null)
|
|
24
25
|
return;
|
|
26
|
+
const rawDt = (now - last) / 1000;
|
|
27
|
+
const simStart = performance.now();
|
|
28
|
+
if (!simulate(rawDt))
|
|
29
|
+
return;
|
|
30
|
+
devtools.frame.record({ frameMs: rawDt * 1000, simMs: performance.now() - simStart });
|
|
31
|
+
};
|
|
32
|
+
/** One frame of simulation; false when play is gated and nothing advanced. Shared with `sim_advance`. */
|
|
33
|
+
const simulate = (rawDt) => {
|
|
25
34
|
const sendInput = () => {
|
|
26
35
|
if (!serverAuthoritative || serverIdRef.current === null)
|
|
27
36
|
return;
|
|
@@ -35,10 +44,8 @@ export function HudOnlyDriver({ ctx, multiplayer, serverIdRef, playable, tracker
|
|
|
35
44
|
ctx.input.publish(heldActionsFor(tracker, NO_ACTIONS), { reset: true });
|
|
36
45
|
ctx.input.publishAnalog(null);
|
|
37
46
|
sendInput();
|
|
38
|
-
return;
|
|
47
|
+
return false;
|
|
39
48
|
}
|
|
40
|
-
const rawDt = (now - last) / 1000;
|
|
41
|
-
const simStart = performance.now();
|
|
42
49
|
try {
|
|
43
50
|
let endPhase = devtools.profile.begin("time+input");
|
|
44
51
|
const dt = Math.min(rawDt, 0.05);
|
|
@@ -74,10 +81,14 @@ export function HudOnlyDriver({ ctx, multiplayer, serverIdRef, playable, tracker
|
|
|
74
81
|
onRuntimeError(error, "tick");
|
|
75
82
|
}
|
|
76
83
|
}
|
|
77
|
-
|
|
84
|
+
return true;
|
|
78
85
|
};
|
|
79
86
|
frameId = requestAnimationFrame(tick);
|
|
80
|
-
|
|
87
|
+
const unregister = registerSimStepper((rawDt) => { simulate(rawDt); });
|
|
88
|
+
return () => {
|
|
89
|
+
cancelAnimationFrame(frameId);
|
|
90
|
+
unregister();
|
|
91
|
+
};
|
|
81
92
|
}, [ctx, playable, tracker, pointerAxisRef, gateRef, onRuntimeError, inputActions, multiplayer, serverIdRef, serverAuthoritative]);
|
|
82
93
|
return null;
|
|
83
94
|
}
|
|
@@ -10,6 +10,8 @@ import { resolveStructureBuildings } from "@jgengine/core/world/environmentSumma
|
|
|
10
10
|
import { findRoadJunctions } from "@jgengine/core/world/streets";
|
|
11
11
|
import { createTerrainPaletteSampler, resolveEnvironmentField, resolveTerrainDetail, resolveTerrainPalette, } from "@jgengine/core/world/terrain";
|
|
12
12
|
import { useDisposable } from "../render/useDisposable.js";
|
|
13
|
+
import { editableTerrainFromSnapshot } from "@jgengine/core/world/terraform";
|
|
14
|
+
import { createTerrainPaintColorSampler } from "../terrain/terrainPaintColor.js";
|
|
13
15
|
import { GroundPad } from "./GroundPad.js";
|
|
14
16
|
import { RoadJunctions } from "./RoadJunctions.js";
|
|
15
17
|
import { buildAuthoredRoadNetwork, RoadRibbons } from "./RoadRibbons.js";
|
|
@@ -18,6 +20,8 @@ import { InstancedBuildings } from "../structures/GeneratedBuilding.js";
|
|
|
18
20
|
import { GrassField } from "../terrain/GrassField.js";
|
|
19
21
|
import { CarvedTerrain } from "../terrain/CarvedTerrain.js";
|
|
20
22
|
import { createTerrainDetailMaterial } from "../terrain/terrainDetailMaterial.js";
|
|
23
|
+
import { useTerrainQuality } from "../terrain/terrainQuality.js";
|
|
24
|
+
import { createTerrainPaintTextures, packTerrainPaintWeights } from "../terrain/terrainSurfaces.js";
|
|
21
25
|
import { Ocean } from "../water/Ocean.js";
|
|
22
26
|
import { RainField } from "../weather/RainField.js";
|
|
23
27
|
import { SnowField } from "../weather/SnowField.js";
|
|
@@ -37,10 +41,20 @@ function TexturedTerrainGround({ detail, layout, }) {
|
|
|
37
41
|
texture.needsUpdate = true;
|
|
38
42
|
}
|
|
39
43
|
}, [textures]);
|
|
40
|
-
const
|
|
44
|
+
const quality = useTerrainQuality();
|
|
45
|
+
const material = useDisposable(() => createTerrainDetailMaterial(detail, textures, { paint: layout.paint, quality }).material, [detail, textures, layout.paint, quality]);
|
|
41
46
|
return (_jsxs(_Fragment, { children: [_jsx(PresentationRecoveredDraw, {}), _jsx(CarvedTerrain, { field: layout.field, size: layout.size, segments: layout.segments, colors: layout.colors, heightRange: layout.heightRange, paletteAt: layout.paletteAt, center: layout.center, roughness: 0.94, surfaceMaterial: material })] }));
|
|
42
47
|
}
|
|
43
|
-
function TerrainGround({ terrain, field, center, }) {
|
|
48
|
+
function TerrainGround({ terrain, field, center, sculpt, }) {
|
|
49
|
+
const detailEnabled = terrain.detail !== false;
|
|
50
|
+
const paint = useMemo(() => {
|
|
51
|
+
if (!detailEnabled || sculpt === undefined)
|
|
52
|
+
return undefined;
|
|
53
|
+
const weights = packTerrainPaintWeights(editableTerrainFromSnapshot(sculpt));
|
|
54
|
+
return weights === undefined ? undefined : createTerrainPaintTextures(weights);
|
|
55
|
+
}, [detailEnabled, sculpt]);
|
|
56
|
+
useEffect(() => () => paint?.dispose(), [paint]);
|
|
57
|
+
const paintColorAt = useMemo(() => detailEnabled || sculpt === undefined ? undefined : createTerrainPaintColorSampler(editableTerrainFromSnapshot(sculpt)), [detailEnabled, sculpt]);
|
|
44
58
|
const palette = useMemo(() => resolveTerrainPalette(terrain), [terrain]);
|
|
45
59
|
const paletteAt = useMemo(() => {
|
|
46
60
|
const hasRegions = terrain.materialRegions !== undefined && terrain.materialRegions.length > 0;
|
|
@@ -63,15 +77,16 @@ function TerrainGround({ terrain, field, center, }) {
|
|
|
63
77
|
const swing = terrain.height * 1.2;
|
|
64
78
|
return [base - swing, base + swing];
|
|
65
79
|
}, [terrain.baseHeight, terrain.height]);
|
|
66
|
-
const resolvedDetail = useMemo(() => (terrain.detail ===
|
|
80
|
+
const resolvedDetail = useMemo(() => (terrain.detail === false ? undefined : resolveTerrainDetail(terrain.detail, terrain.waterLevel)), [terrain.detail, terrain.waterLevel]);
|
|
81
|
+
const quality = useTerrainQuality();
|
|
67
82
|
const proceduralMaterial = useMemo(() => resolvedDetail === undefined || resolvedDetail.material !== undefined
|
|
68
83
|
? undefined
|
|
69
|
-
: createTerrainDetailMaterial(resolvedDetail).material, [resolvedDetail]);
|
|
84
|
+
: createTerrainDetailMaterial(resolvedDetail, undefined, { paint, quality }).material, [resolvedDetail, paint, quality]);
|
|
70
85
|
useEffect(() => () => proceduralMaterial?.dispose(), [proceduralMaterial]);
|
|
71
86
|
if (resolvedDetail?.material !== undefined) {
|
|
72
|
-
return (_jsx(PresentationAssetBoundary, { children: _jsx(TexturedTerrainGround, { detail: resolvedDetail, layout: { field, size, segments: terrain.segments, colors, heightRange, paletteAt, center } }) }));
|
|
87
|
+
return (_jsx(PresentationAssetBoundary, { children: _jsx(TexturedTerrainGround, { detail: resolvedDetail, layout: { field, size, segments: terrain.segments, colors, heightRange, paletteAt, paint, center } }) }));
|
|
73
88
|
}
|
|
74
|
-
return (_jsx(CarvedTerrain, { field: field, size: size, segments: terrain.segments, colors: colors, heightRange: heightRange, paletteAt: paletteAt, center: center, roughness: 0.94, surfaceMaterial: proceduralMaterial }));
|
|
89
|
+
return (_jsx(CarvedTerrain, { field: field, size: size, segments: terrain.segments, colors: colors, heightRange: heightRange, paletteAt: paletteAt, paintColorAt: paintColorAt, center: center, roughness: 0.94, surfaceMaterial: proceduralMaterial }));
|
|
75
90
|
}
|
|
76
91
|
function areaCenter(area) {
|
|
77
92
|
return area.position ?? [0, 0];
|
|
@@ -88,8 +103,8 @@ function Vegetation({ grass, field, groundColor, }) {
|
|
|
88
103
|
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, bladeBend: grass.bladeBend, tuftRadius: grass.tuftRadius, colorVariation: grass.colorVariation, normalLift: grass.normalLift, 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
104
|
}
|
|
90
105
|
/** 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);
|
|
106
|
+
function vegetationGroundColor(terrain, field, grass, sculpt) {
|
|
107
|
+
const sampler = terrainGroundColorSampler(terrain, field, sculpt);
|
|
93
108
|
if (sampler === undefined)
|
|
94
109
|
return undefined;
|
|
95
110
|
const [cx, cz] = areaCenter(grass.area);
|
|
@@ -160,5 +175,5 @@ export function EnvironmentScene({ feature }) {
|
|
|
160
175
|
const roads = feature.roads ?? [];
|
|
161
176
|
const junctions = useMemo(() => findRoadJunctions(roads), [roads]);
|
|
162
177
|
const roadNetwork = useMemo(() => buildAuthoredRoadNetwork(roads, junctions, (x, z) => field.sampleHeight(x, z)), [roads, junctions, field]);
|
|
163
|
-
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, paths: roadNetwork.roadPaths[index] ?? [] }, `road-${index}`))), _jsx(RoadJunctions, { surfaces: roadNetwork.junctionSurfaces, 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] }));
|
|
178
|
+
return (_jsxs(_Fragment, { children: [feature.terrain !== undefined ? _jsx(TerrainGround, { terrain: feature.terrain, field: field, sculpt: feature.sculpt }) : null, islands.map((entry, index) => (_jsx(TerrainGround, { terrain: entry, field: field, center: entry.origin, sculpt: feature.sculpt }, `island-${index}`))), water.map((ocean, index) => (_jsx(Water, { ocean: ocean }, `ocean-${index}`))), roads.map((entry, index) => (_jsx(RoadRibbons, { road: entry, field: field, paths: roadNetwork.roadPaths[index] ?? [] }, `road-${index}`))), _jsx(RoadJunctions, { surfaces: roadNetwork.junctionSurfaces, junctions: junctions, field: field, surfaceDetail: roads.some((road) => road.surface !== false) }), 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, feature.sculpt) }, `grass-${index}`))), feature.weather !== undefined && feature.weather.length > 0 ? (_jsx(Weather, { weather: feature.weather })) : null] }));
|
|
164
179
|
}
|
|
@@ -9,8 +9,10 @@ import type { JunctionSurface } from "./RoadRibbons.js";
|
|
|
9
9
|
* `polygonOffset`/`renderOrder` so they win the depth test on sloped ground.
|
|
10
10
|
* @internal
|
|
11
11
|
*/
|
|
12
|
-
export declare function RoadJunctions({ surfaces, junctions, field, }: {
|
|
12
|
+
export declare function RoadJunctions({ surfaces, junctions, field, surfaceDetail, }: {
|
|
13
13
|
surfaces: readonly JunctionSurface[];
|
|
14
14
|
junctions: readonly RoadJunction[];
|
|
15
15
|
field: TerrainField;
|
|
16
|
+
/** Shade the welded surfaces with the asphalt procedural surface, matching the ribbons. Default true. */
|
|
17
|
+
surfaceDetail?: boolean;
|
|
16
18
|
}): import("react").JSX.Element | null;
|
|
@@ -3,6 +3,13 @@ import { useMemo } from "react";
|
|
|
3
3
|
import { BufferAttribute, BufferGeometry, DoubleSide } from "three";
|
|
4
4
|
import { buildRoadRibbon, GROUND_DECAL_LAYERS } from "@jgengine/core/world/roads";
|
|
5
5
|
import { junctionMarkings } from "@jgengine/core/world/streets";
|
|
6
|
+
import { useSurfaceRibbonMaterial } from "../terrain/surfaceRibbonMaterial.js";
|
|
7
|
+
import { useTerrainQuality } from "../terrain/terrainQuality.js";
|
|
8
|
+
function JunctionPatch({ geometry, color, surfaceDetail }) {
|
|
9
|
+
const quality = useTerrainQuality();
|
|
10
|
+
const material = useSurfaceRibbonMaterial(surfaceDetail ? { surface: "road", color, halfWidth: 1, quality } : null);
|
|
11
|
+
return (_jsx("mesh", { geometry: geometry, receiveShadow: true, ...(material === null ? {} : { material }), children: material === null ? _jsx("meshStandardMaterial", { color: color, roughness: 0.95, metalness: 0, side: DoubleSide }) : null }));
|
|
12
|
+
}
|
|
6
13
|
/** Lane paint sits the table's road→marking gap above the welded junction surface (also polygonOffset). */
|
|
7
14
|
const MARKING_LIFT = GROUND_DECAL_LAYERS.marking - GROUND_DECAL_LAYERS.road;
|
|
8
15
|
function toGeometry(positions, indices) {
|
|
@@ -43,7 +50,7 @@ function mergedBars(bars, width, field, elevation) {
|
|
|
43
50
|
* `polygonOffset`/`renderOrder` so they win the depth test on sloped ground.
|
|
44
51
|
* @internal
|
|
45
52
|
*/
|
|
46
|
-
export function RoadJunctions({ surfaces, junctions, field, }) {
|
|
53
|
+
export function RoadJunctions({ surfaces, junctions, field, surfaceDetail = true, }) {
|
|
47
54
|
const patches = useMemo(() => surfaces
|
|
48
55
|
.map((surface) => ({ color: surface.color, geometry: toGeometry(surface.ribbon.positions, surface.ribbon.indices) }))
|
|
49
56
|
.filter((entry) => entry.geometry !== null), [surfaces]);
|
|
@@ -66,5 +73,5 @@ export function RoadJunctions({ surfaces, junctions, field, }) {
|
|
|
66
73
|
}, [junctions, field]);
|
|
67
74
|
if (patches.length === 0 && markings.stops === null && markings.crosswalks === null)
|
|
68
75
|
return null;
|
|
69
|
-
return (_jsxs("group", { children: [patches.map((patch, index) => (_jsx(
|
|
76
|
+
return (_jsxs("group", { children: [patches.map((patch, index) => (_jsx(JunctionPatch, { geometry: patch.geometry, color: patch.color, surfaceDetail: surfaceDetail }, `junction-${index}`))), markings.stops !== null ? (_jsx("mesh", { geometry: markings.stops, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: "#e6e9ee", roughness: 0.85, metalness: 0, side: DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null, markings.crosswalks !== null ? (_jsx("mesh", { geometry: markings.crosswalks, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: "#e6e9ee", roughness: 0.85, metalness: 0, side: DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null] }));
|
|
70
77
|
}
|
|
@@ -40,7 +40,9 @@ export declare function buildAuthoredRoadNetwork(roads: readonly RoadEnvironment
|
|
|
40
40
|
* by {@link buildAuthoredRoadNetwork}): an asphalt ribbon, a curb/edge strip along each border, an
|
|
41
41
|
* optional dashed centerline, and optional sidewalks. Because the sub-paths already stop at the
|
|
42
42
|
* junction boundary, every layer naturally ends there — no ribbon ploughs through the crossing and
|
|
43
|
-
* the dashed line no longer needs exclusion circles to skip intersections.
|
|
43
|
+
* the dashed line no longer needs exclusion circles to skip intersections. The carriageway, curbs and
|
|
44
|
+
* sidewalks shade with the terrain's procedural surfaces (`road.surface`, default asphalt with tyre
|
|
45
|
+
* wear); `surface: false` keeps them flat.
|
|
44
46
|
*/
|
|
45
47
|
export declare function RoadRibbons({ road, field, paths, }: {
|
|
46
48
|
road: RoadEnvironmentDescriptor;
|