@jgengine/shell 0.18.1 → 0.19.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 +158 -7
- package/dist/GamePlayerShell.js +20 -3
- package/dist/Shell3dPresentation.d.ts +3 -9
- package/dist/Shell3dPresentation.js +26 -6
- package/dist/ShellHudPresentation.d.ts +9 -1
- package/dist/ShellHudPresentation.js +21 -0
- package/dist/audio/AudioComponents.js +6 -2
- package/dist/audio/audioEngine.d.ts +8 -0
- package/dist/audio/audioEngine.js +65 -6
- package/dist/audio/audioWire.d.ts +3 -0
- package/dist/audio/audioWire.js +7 -1
- package/dist/audio/loopParams.d.ts +39 -0
- package/dist/audio/loopParams.js +43 -0
- package/dist/camera/GameCameraRig.js +1 -1
- package/dist/camera/GameFirstPersonCamera.d.ts +4 -2
- package/dist/camera/GameFirstPersonCamera.js +52 -15
- package/dist/camera/Viewports.d.ts +34 -0
- package/dist/camera/Viewports.js +123 -0
- package/dist/camera/cameraRigs.js +70 -62
- package/dist/camera/orbitCameraMath.js +2 -2
- package/dist/camera/rigMath.d.ts +82 -4
- package/dist/camera/rigMath.js +133 -15
- package/dist/defineGame.d.ts +9 -0
- package/dist/defineGame.js +20 -3
- package/dist/devtools/DevtoolsOverlay.js +2 -0
- package/dist/devtools/agentBridge.d.ts +6 -0
- package/dist/devtools/agentBridge.js +15 -1
- package/dist/diagnostics/FpsCounter.d.ts +17 -0
- package/dist/diagnostics/FpsCounter.js +32 -0
- package/dist/drivers/FrameDriver.js +13 -2
- package/dist/drivers/FrameRateLimiter.d.ts +9 -0
- package/dist/drivers/FrameRateLimiter.js +39 -0
- package/dist/drivers/HudOnlyDriver.js +1 -1
- package/dist/environment/DayNightSky.js +5 -5
- package/dist/environment/Daylight.d.ts +35 -1
- package/dist/environment/Daylight.js +98 -28
- package/dist/environment/VolumetricClouds.js +20 -2
- package/dist/environment/daylightCycle.d.ts +20 -0
- package/dist/environment/daylightCycle.js +59 -7
- package/dist/environment/sunShadowMath.d.ts +45 -0
- package/dist/environment/sunShadowMath.js +50 -0
- package/dist/input/gamepadPoll.d.ts +47 -0
- package/dist/input/gamepadPoll.js +142 -0
- package/dist/input/gamepadSource.d.ts +11 -1
- package/dist/input/gamepadSource.js +117 -71
- package/dist/input/mouseLook.js +2 -1
- package/dist/input/padHaptics.d.ts +17 -0
- package/dist/input/padHaptics.js +32 -0
- package/dist/input/pointerLock.d.ts +11 -0
- package/dist/input/pointerLock.js +19 -0
- package/dist/postfx/PostProcessing.d.ts +1 -1
- package/dist/postfx/PostProcessing.js +5 -1
- package/dist/postfx/stylizePass.d.ts +23 -0
- package/dist/postfx/stylizePass.js +216 -0
- package/dist/render/SceneModels.js +1 -1
- package/dist/render/SkinnedInstances.d.ts +91 -0
- package/dist/render/SkinnedInstances.js +249 -0
- package/dist/render/TextureFiltering.d.ts +16 -0
- package/dist/render/TextureFiltering.js +64 -0
- package/dist/render/displayColor.d.ts +33 -0
- package/dist/render/displayColor.js +138 -0
- package/dist/render/modelLoad.d.ts +29 -0
- package/dist/render/modelLoad.js +61 -1
- package/dist/render/resolveModel.js +4 -0
- package/dist/render/useFootIk.d.ts +53 -4
- package/dist/render/useFootIk.js +197 -70
- package/dist/render/useModelAnimation.d.ts +24 -0
- package/dist/render/useModelAnimation.js +61 -40
- package/dist/scene/AuthoredScene.d.ts +9 -0
- package/dist/scene/AuthoredScene.js +17 -1
- package/dist/scene/CityRenderer.js +6 -6
- package/dist/settings/QuickControls.js +1 -1
- package/dist/settings/SettingsMenu.js +1 -1
- package/dist/settings/appliedSettings.d.ts +9 -3
- package/dist/settings/appliedSettings.js +6 -5
- package/dist/settings/settingsController.d.ts +3 -0
- package/dist/settings/settingsController.js +47 -3
- package/dist/structures/BuildingKitBatch.js +26 -22
- package/dist/structures/GeneratedBuilding.d.ts +3 -15
- package/dist/structures/GeneratedBuilding.js +21 -15
- package/dist/structures/buildingSurface.d.ts +21 -0
- package/dist/structures/buildingSurface.js +134 -0
- package/dist/useShellMultiplayerSync.d.ts +1 -1
- package/dist/useShellMultiplayerSync.js +5 -3
- package/dist/world/WorldParticles.js +1 -1
- package/package.json +4 -4
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/game-design/SKILL.md +2 -2
- package/skills/jgengine/SKILL.md +7 -5
- package/skills/jgengine/api.md +118 -89
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine/recipes/minimal-game.md +3 -0
- package/skills/jgengine-ui/SKILL.md +13 -16
- package/skills/jgengine-ui/api.md +418 -317
- package/skills/jgengine-ui/capabilities.md +16 -0
- package/skills/jgengine-ui/reference-primitives.md +89 -0
- package/skills/jgengine-ui/reference.md +2 -81
- package/skills/jgengine-world/SKILL.md +15 -3
- package/skills/jgengine-world/api.md +873 -626
- package/skills/jgengine-world/capabilities.md +130 -6
- package/skills/jgengine-world/recipes/character-animation.md +39 -0
- package/skills/jgengine-world/recipes/controls.md +44 -0
- package/skills/jgengine-world/recipes/flight-feel.md +76 -0
- package/skills/jgengine-world/recipes/npc-senses.md +43 -0
- package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
- package/skills/jgengine-world/reference.md +31 -2
- package/skills/jgengine-world/visibility.md +112 -0
- package/skills/level-design/SKILL.md +1 -1
- package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +0 -15
- package/dist/render/testFixtures/proceduralSpriteAtlas.js +0 -30
- package/dist/render/testFixtures/proceduralTileLayer.d.ts +0 -13
- package/dist/render/testFixtures/proceduralTileLayer.js +0 -30
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
import * as THREE from "three";
|
|
2
2
|
import type { ModelAnimationConfig } from "@jgengine/core/game/playableGame";
|
|
3
|
+
import { type AnimClipOutput, type AnimGraph } from "@jgengine/core/anim/animGraph";
|
|
4
|
+
/**
|
|
5
|
+
* For a frame where a `rootMotion` state is current: pins the root bone's horizontal translation to
|
|
6
|
+
* its bind pose, so the clip plays in place, and returns that step's root travel as a world-space
|
|
7
|
+
* horizontal delta (through the rig's parent transform, so the entity's facing and the model's
|
|
8
|
+
* scale apply). The root bone's vertical motion stays in the clip.
|
|
9
|
+
*/
|
|
10
|
+
export declare function takeRootMotion(rootBone: THREE.Object3D, bind: THREE.Vector3, localDelta: readonly [number, number, number] | undefined, out: THREE.Vector3): THREE.Vector3;
|
|
11
|
+
/** A mixer set up to show {@link AnimGraph} output on a rig; see {@link createGraphPose}. */
|
|
12
|
+
export interface GraphPose {
|
|
13
|
+
/** Clip durations read from the rig, the `clips` argument `runtime.advance` expects. */
|
|
14
|
+
durations: Readonly<Record<string, {
|
|
15
|
+
duration: number;
|
|
16
|
+
}>>;
|
|
17
|
+
/** Poses the rig with one advance's clip weights and times. */
|
|
18
|
+
apply(clips: readonly AnimClipOutput[]): void;
|
|
19
|
+
dispose(): void;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Binds a graph's clips to a rig exactly as `useModelAnimation` does (masked layers get filtered
|
|
23
|
+
* clips, additive layers additive ones) so a host that runs its own `createAnimGraphRuntime`, such as
|
|
24
|
+
* the editor's graph preview, poses the rig from the runtime's output.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createGraphPose(scene: THREE.Object3D, graph: AnimGraph, clips: THREE.AnimationClip[]): GraphPose;
|
|
3
27
|
/**
|
|
4
28
|
* The engine's model animation driver as a standalone hook — the same mixer `EntityModel` runs,
|
|
5
29
|
* for games that render a cloned scene themselves (custom materials, procedural composition).
|
|
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef } from "react";
|
|
|
3
3
|
import * as THREE from "three";
|
|
4
4
|
import { resolveAnimationConfig } from "@jgengine/core/game/clipRoles";
|
|
5
5
|
import { ANIM_PARAMS_KEY, createAnimGraphRuntime, } from "@jgengine/core/anim/animGraph";
|
|
6
|
-
import {
|
|
6
|
+
import { animGraphFromConfig, LOCOMOTION_SPEED_PARAM } from "@jgengine/core/anim/locomotionGraph";
|
|
7
7
|
import { useOptionalGameContext } from "@jgengine/react/provider";
|
|
8
8
|
function graphClipNames(graph) {
|
|
9
9
|
const names = new Set();
|
|
@@ -66,6 +66,56 @@ function warnMissingClips(animation, clips) {
|
|
|
66
66
|
function actionKey(layer, clip) {
|
|
67
67
|
return `${layer}:${clip}`;
|
|
68
68
|
}
|
|
69
|
+
const rootTravel = new THREE.Vector3();
|
|
70
|
+
const rootBasis = new THREE.Matrix3();
|
|
71
|
+
/**
|
|
72
|
+
* For a frame where a `rootMotion` state is current: pins the root bone's horizontal translation to
|
|
73
|
+
* its bind pose, so the clip plays in place, and returns that step's root travel as a world-space
|
|
74
|
+
* horizontal delta (through the rig's parent transform, so the entity's facing and the model's
|
|
75
|
+
* scale apply). The root bone's vertical motion stays in the clip.
|
|
76
|
+
*/
|
|
77
|
+
export function takeRootMotion(rootBone, bind, localDelta, out) {
|
|
78
|
+
rootBone.position.x = bind.x;
|
|
79
|
+
rootBone.position.z = bind.z;
|
|
80
|
+
out.set(0, 0, 0);
|
|
81
|
+
if (localDelta === undefined || rootBone.parent === null)
|
|
82
|
+
return out;
|
|
83
|
+
rootBone.parent.updateWorldMatrix(true, false);
|
|
84
|
+
out.set(localDelta[0], localDelta[1], localDelta[2]).applyMatrix3(rootBasis.setFromMatrix4(rootBone.parent.matrixWorld));
|
|
85
|
+
out.y = 0;
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
function applyGraphClips(actions, clips) {
|
|
89
|
+
for (const action of actions.values())
|
|
90
|
+
action.weight = 0;
|
|
91
|
+
for (const entry of clips) {
|
|
92
|
+
const action = actions.get(actionKey(entry.layer, entry.clip));
|
|
93
|
+
if (action === undefined)
|
|
94
|
+
continue;
|
|
95
|
+
action.weight += entry.weight;
|
|
96
|
+
action.time = entry.time;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Binds a graph's clips to a rig exactly as `useModelAnimation` does (masked layers get filtered
|
|
101
|
+
* clips, additive layers additive ones) so a host that runs its own `createAnimGraphRuntime`, such as
|
|
102
|
+
* the editor's graph preview, poses the rig from the runtime's output.
|
|
103
|
+
*/
|
|
104
|
+
export function createGraphPose(scene, graph, clips) {
|
|
105
|
+
const mixer = new THREE.AnimationMixer(scene);
|
|
106
|
+
const playback = buildGraphPlayback(scene, mixer, graph, clips);
|
|
107
|
+
return {
|
|
108
|
+
durations: playback.durations,
|
|
109
|
+
apply(output) {
|
|
110
|
+
applyGraphClips(playback.actions, output);
|
|
111
|
+
mixer.update(0);
|
|
112
|
+
},
|
|
113
|
+
dispose() {
|
|
114
|
+
mixer.stopAllAction();
|
|
115
|
+
mixer.uncacheRoot(scene);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
69
119
|
function buildGraphPlayback(scene, mixer, graph, clips) {
|
|
70
120
|
const actions = new Map();
|
|
71
121
|
const durations = {};
|
|
@@ -147,27 +197,8 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
|
|
|
147
197
|
const graphRef = useRef(null);
|
|
148
198
|
const states = animation?.states;
|
|
149
199
|
const oneShots = animation?.oneShots;
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
return animation.graph;
|
|
153
|
-
if (states === undefined)
|
|
154
|
-
return undefined;
|
|
155
|
-
const graphOneShots = {};
|
|
156
|
-
for (const [event, spec] of Object.entries(oneShots ?? {})) {
|
|
157
|
-
const clip = typeof spec === "string" ? spec : spec[0];
|
|
158
|
-
if (clip !== undefined)
|
|
159
|
-
graphOneShots[event] = clip;
|
|
160
|
-
}
|
|
161
|
-
return locomotionGraph({
|
|
162
|
-
idle: states.idle,
|
|
163
|
-
walk: states.walk,
|
|
164
|
-
...(states.run === undefined ? {} : { run: states.run }),
|
|
165
|
-
walkSpeed: states.walkSpeed,
|
|
166
|
-
runSpeed: states.runSpeed,
|
|
167
|
-
fadeSec: states.fadeSec,
|
|
168
|
-
...(Object.keys(graphOneShots).length === 0 ? {} : { oneShots: graphOneShots }),
|
|
169
|
-
});
|
|
170
|
-
}, [animation?.graph, states, oneShots]);
|
|
200
|
+
const authoredGraph = animation?.graph;
|
|
201
|
+
const graph = useMemo(() => animGraphFromConfig({ graph: authoredGraph, states, oneShots }), [authoredGraph, states, oneShots]);
|
|
171
202
|
useEffect(() => {
|
|
172
203
|
if (animation === undefined || clips.length === 0) {
|
|
173
204
|
mixerRef.current = null;
|
|
@@ -263,29 +294,19 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
|
|
|
263
294
|
}
|
|
264
295
|
params[LOCOMOTION_SPEED_PARAM] = playback.smoothedSpeed;
|
|
265
296
|
const out = playback.runtime.advance(delta * (animation?.timeScale ?? 1), params, playback.durations);
|
|
266
|
-
|
|
267
|
-
action.weight = 0;
|
|
268
|
-
for (const entry of out.clips) {
|
|
269
|
-
const action = playback.actions.get(actionKey(entry.layer, entry.clip));
|
|
270
|
-
if (action === undefined)
|
|
271
|
-
continue;
|
|
272
|
-
action.weight += entry.weight;
|
|
273
|
-
action.time = entry.time;
|
|
274
|
-
}
|
|
297
|
+
applyGraphClips(playback.actions, out.clips);
|
|
275
298
|
mixerRef.current.update(0);
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
if (ctx !== null && instanceId !== undefined) {
|
|
281
|
-
const rootDelta = out.rootDelta;
|
|
282
|
-
const entity = rootDelta === undefined ? null : ctx.scene.entity.get(instanceId);
|
|
283
|
-
if (entity !== null && rootDelta !== undefined) {
|
|
299
|
+
if (out.rootMotion === true && playback.rootBone !== null && playback.rootBindPosition !== null) {
|
|
300
|
+
const travel = takeRootMotion(playback.rootBone, playback.rootBindPosition, out.rootDelta, rootTravel);
|
|
301
|
+
const entity = ctx === null || instanceId === undefined || (travel.x === 0 && travel.z === 0) ? null : ctx.scene.entity.get(instanceId);
|
|
302
|
+
if (entity !== null && ctx !== null && instanceId !== undefined) {
|
|
284
303
|
ctx.scene.entity.setPose(instanceId, {
|
|
285
|
-
position: [entity.position[0] +
|
|
304
|
+
position: [entity.position[0] + travel.x, entity.position[1], entity.position[2] + travel.z],
|
|
286
305
|
dt: delta,
|
|
287
306
|
});
|
|
288
307
|
}
|
|
308
|
+
}
|
|
309
|
+
if (ctx !== null && instanceId !== undefined) {
|
|
289
310
|
for (const event of out.events)
|
|
290
311
|
ctx.game.events.emit("animation.event", { instanceId, name: event.name, clip: event.clip });
|
|
291
312
|
}
|
|
@@ -40,6 +40,15 @@ export interface AuthoredObjectsProps {
|
|
|
40
40
|
* headless twin: `resolveAuthoredObjects` / `placeAuthoredObjects`.
|
|
41
41
|
*/
|
|
42
42
|
export declare function AuthoredObjects({ document, field, verticalOffset, onExisting, }: AuthoredObjectsProps): null;
|
|
43
|
+
/**
|
|
44
|
+
* Writes the document's studio solids (city buildings and any kind with a `solids` hook) into
|
|
45
|
+
* `ctx.world.solids`, so what the studios draw also blocks the player, NPCs and physics.
|
|
46
|
+
* @capability authored-solids collision for studio-authored world content such as city volumes
|
|
47
|
+
*/
|
|
48
|
+
export declare function AuthoredSolids({ document, field }: {
|
|
49
|
+
document: EditorDocument;
|
|
50
|
+
field: TerrainField;
|
|
51
|
+
}): null;
|
|
43
52
|
/** Props for {@link AuthoredScene}: the document to render and the ground field to drape/ground on. */
|
|
44
53
|
export interface AuthoredSceneProps {
|
|
45
54
|
document: EditorDocument;
|
|
@@ -3,6 +3,7 @@ import { Fragment, useCallback, useEffect, useMemo, useSyncExternalStore } from
|
|
|
3
3
|
import * as THREE from "three";
|
|
4
4
|
import { getDocumentLiveSync, subscribeDocumentLiveSyncInstall, } from "@jgengine/core/editor/index";
|
|
5
5
|
import { placeAuthoredObjects, resolveAuthoredObjects, } from "@jgengine/core/world/authoredObjects";
|
|
6
|
+
import { syncAuthoredSolids } from "@jgengine/core/world/authoredSolids";
|
|
6
7
|
import { buildRoadRibbon, GROUND_DECAL_LAYERS, roundPathCorners } from "@jgengine/core/world/roads";
|
|
7
8
|
import { isScatterPath, resolveScatter } from "@jgengine/core/world/scatterRegion";
|
|
8
9
|
import { getAssetGenerator } from "@jgengine/core/scene/assetGenerator";
|
|
@@ -143,6 +144,21 @@ export function AuthoredObjects({ document, field, verticalOffset = 0, onExistin
|
|
|
143
144
|
}, [ctx.scene.object, field, objects, onExisting, verticalOffset]);
|
|
144
145
|
return null;
|
|
145
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Writes the document's studio solids (city buildings and any kind with a `solids` hook) into
|
|
149
|
+
* `ctx.world.solids`, so what the studios draw also blocks the player, NPCs and physics.
|
|
150
|
+
* @capability authored-solids collision for studio-authored world content such as city volumes
|
|
151
|
+
*/
|
|
152
|
+
export function AuthoredSolids({ document, field }) {
|
|
153
|
+
const ctx = useGameContext();
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
syncAuthoredSolids(ctx.world.solids, document, (x, z) => field.sampleHeight(x, z));
|
|
156
|
+
}, [ctx.world.solids, document, field]);
|
|
157
|
+
useEffect(() => () => {
|
|
158
|
+
syncAuthoredSolids(ctx.world.solids, { markers: [], volumes: [], paths: [] });
|
|
159
|
+
}, [ctx.world.solids]);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
146
162
|
/**
|
|
147
163
|
* Renders an editor document's scene content — draped paths plus GPU-instanced foliage — from one
|
|
148
164
|
* mount, grounded on the live `field`. The runtime counterpart to authoring a scene in the editor:
|
|
@@ -164,5 +180,5 @@ export function AuthoredScene({ document, field, pathKinds, scatterModels, asset
|
|
|
164
180
|
field,
|
|
165
181
|
...(assets === undefined ? {} : { assets }),
|
|
166
182
|
...(groundColorAt === undefined ? {} : { groundColorAt }),
|
|
167
|
-
} }), _jsx(AuthoredGenerators, { document: liveDocument, field: field }), shouldPlaceObjects ? (_jsx(AuthoredObjects, { document: liveDocument, field: field, verticalOffset: objectVerticalOffset })) : null] }));
|
|
183
|
+
} }), _jsx(AuthoredGenerators, { document: liveDocument, field: field }), _jsx(AuthoredSolids, { document: liveDocument, field: field }), shouldPlaceObjects ? (_jsx(AuthoredObjects, { document: liveDocument, field: field, verticalOffset: objectVerticalOffset })) : null] }));
|
|
168
184
|
}
|
|
@@ -22,7 +22,7 @@ import { offsetPath } from "@jgengine/core/world/streets";
|
|
|
22
22
|
import { triangulatePolygon, triangulateRingBand } from "@jgengine/core/world/cityGeometry";
|
|
23
23
|
import { resolveCityObject, CITY_KIND, CITY_ZONE_KIND, } from "@jgengine/core/world/cityKind";
|
|
24
24
|
import { zoneBand, zoneMetric } from "@jgengine/core/world/cityContent";
|
|
25
|
-
import { generateBuilding, resolveBuildingPalette, resolveBuildingWallTones, } from "@jgengine/core/world/buildings";
|
|
25
|
+
import { generateBuilding, buildingSurfaceColor, resolveBuildingPalette, resolveBuildingWallTones, } from "@jgengine/core/world/buildings";
|
|
26
26
|
import { hashString } from "@jgengine/core/random/rng";
|
|
27
27
|
import { buildScatterProxy } from "../scatter/scatterProxies.js";
|
|
28
28
|
import { InstancedBuildings } from "../structures/GeneratedBuilding.js";
|
|
@@ -327,12 +327,12 @@ function unitGeometries() {
|
|
|
327
327
|
const ROLE_JITTER = { wall: 1, roof: 0.5, trim: 0.35, accent: 0.6 };
|
|
328
328
|
function roleColor(palette, role) {
|
|
329
329
|
if (role === "wall")
|
|
330
|
-
return palette.wall;
|
|
330
|
+
return buildingSurfaceColor(palette.wall);
|
|
331
331
|
if (role === "roof")
|
|
332
|
-
return palette.roof;
|
|
332
|
+
return buildingSurfaceColor(palette.roof);
|
|
333
333
|
if (role === "trim")
|
|
334
|
-
return palette.corner;
|
|
335
|
-
return palette.awning;
|
|
334
|
+
return buildingSurfaceColor(palette.corner);
|
|
335
|
+
return buildingSurfaceColor(palette.awning);
|
|
336
336
|
}
|
|
337
337
|
const DETAIL_RADIUS = 95;
|
|
338
338
|
const DETAIL_MAX = 12;
|
|
@@ -547,7 +547,7 @@ function OneCity({ object, context }) {
|
|
|
547
547
|
return mesh;
|
|
548
548
|
};
|
|
549
549
|
const plain = new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0.04 });
|
|
550
|
-
const banded = createCityWindowMaterial({ floorHeight: resolved.rules.floorHeight, windowColor: palette.window });
|
|
550
|
+
const banded = createCityWindowMaterial({ floorHeight: resolved.rules.floorHeight, windowColor: buildingSurfaceColor(palette.window) });
|
|
551
551
|
const meshes = [
|
|
552
552
|
makeMesh("box", units.box, plain),
|
|
553
553
|
makeMesh("banded", units.box, banded),
|
|
@@ -14,7 +14,7 @@ function QuickToggle({ row }) {
|
|
|
14
14
|
function QuickSelect({ row }) {
|
|
15
15
|
const options = row.options ?? [];
|
|
16
16
|
const current = String(row.value);
|
|
17
|
-
return (_jsxs("div", { className: "flex items-center justify-between gap-2 text-xs text-neutral-300", children: [_jsx("span", { className: "truncate", children: row.label }), _jsx("div", { className: "flex
|
|
17
|
+
return (_jsxs("div", { className: "flex items-center justify-between gap-2 text-xs text-neutral-300", children: [_jsx("span", { className: "truncate", children: row.label }), _jsx("div", { className: "flex flex-wrap justify-end gap-1 rounded-md bg-neutral-950/60 p-0.5 ring-1 ring-white/10", children: options.map((option) => {
|
|
18
18
|
const selected = option.value === current;
|
|
19
19
|
return (_jsx("button", { type: "button", onClick: () => row.set(option.value), className: `rounded px-2 py-1 font-medium transition ${selected ? "bg-emerald-500 text-neutral-950" : "text-neutral-300 hover:bg-neutral-800"}`, children: option.label }, option.value));
|
|
20
20
|
}) })] }));
|
|
@@ -40,7 +40,7 @@ function ToggleRow({ row }) {
|
|
|
40
40
|
function SelectRow({ row }) {
|
|
41
41
|
const options = row.options ?? [];
|
|
42
42
|
const current = String(row.value);
|
|
43
|
-
return (_jsx(RowShell, { label: row.label, control: _jsx("div", { className: "flex
|
|
43
|
+
return (_jsx(RowShell, { label: row.label, control: _jsx("div", { className: "flex flex-wrap justify-end gap-1 rounded-lg p-1", style: { background: surfaceDeep }, children: options.map((option) => {
|
|
44
44
|
const selected = option.value === current;
|
|
45
45
|
return (_jsx("button", { type: "button", onClick: () => row.set(option.value), className: "min-h-[34px] rounded-md px-3 text-sm font-medium transition", style: selected ? { background: accent, color: onAccent } : { color: textDim }, children: option.label }, option.value));
|
|
46
46
|
}) }) }));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AudioBusDef } from "@jgengine/core/audio/audioFalloff";
|
|
2
2
|
import { type GraphicsQuality, type SettingsStore } from "@jgengine/core/settings/settingsModel";
|
|
3
|
-
import {
|
|
3
|
+
import type { GraphicsProfile } from "@jgengine/core/settings/graphicsProfile";
|
|
4
|
+
import { type GraphicsProfileOverrides } from "@jgengine/core/settings/graphicsSettings";
|
|
4
5
|
import { type TouchJoystickVariant, type TouchStyle } from "@jgengine/core/input/touchScheme";
|
|
5
6
|
import type { AudioEngine } from "../audio/audioEngine.js";
|
|
6
7
|
/** Sentinel Controls value meaning "defer to the game's suggested touch skin". */
|
|
@@ -10,13 +11,18 @@ export declare function useTouchStyle(store: SettingsStore, fallback: TouchStyle
|
|
|
10
11
|
/** Shell-internal: binds the SettingsStore joystick-variant choice (fixed vs floating) to the touch renderer. @internal */
|
|
11
12
|
export declare function useTouchJoystickVariant(store: SettingsStore): TouchJoystickVariant;
|
|
12
13
|
export declare function useSettingsRevision(store: SettingsStore): number;
|
|
13
|
-
|
|
14
|
+
/** The player's graphics choices as the shell applies them. */
|
|
15
|
+
export interface AppliedGraphicsSettings {
|
|
14
16
|
shadows: boolean;
|
|
15
17
|
dpr: number;
|
|
16
18
|
uiScale: number;
|
|
17
19
|
quality: GraphicsQuality;
|
|
18
20
|
profile: GraphicsProfile;
|
|
19
|
-
|
|
21
|
+
/** Frames-per-second cap; 0 renders every display refresh. */
|
|
22
|
+
frameRateLimit: number;
|
|
23
|
+
showFps: boolean;
|
|
24
|
+
}
|
|
25
|
+
export declare function useGraphicsSettings(store: SettingsStore, shadowsDefault: boolean, overrides?: GraphicsProfileOverrides): AppliedGraphicsSettings;
|
|
20
26
|
export declare function AudioSettingsBridge({ store, engine, buses, }: {
|
|
21
27
|
store: SettingsStore;
|
|
22
28
|
engine: AudioEngine;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
|
2
|
-
import { busVolumeSettingId,
|
|
3
|
-
import {
|
|
2
|
+
import { busVolumeSettingId, DEFAULT_MASTER_VOLUME, DEFAULT_UI_SCALE, SETTING_IDS, UI_SCALE_MAX, UI_SCALE_MIN, } from "@jgengine/core/settings/settingsModel";
|
|
3
|
+
import { readGraphicsSettings } from "@jgengine/core/settings/graphicsSettings";
|
|
4
|
+
import { readFrameRateLimit } from "@jgengine/core/settings/frameRateLimit";
|
|
4
5
|
import { DEFAULT_TOUCH_JOYSTICK_VARIANT, TOUCH_JOYSTICK_VARIANTS, TOUCH_STYLES, } from "@jgengine/core/input/touchScheme";
|
|
5
6
|
/** Sentinel Controls value meaning "defer to the game's suggested touch skin". */
|
|
6
7
|
export const TOUCH_STYLE_AUTO = "auto";
|
|
@@ -29,9 +30,7 @@ export function useSettingsRevision(store) {
|
|
|
29
30
|
}
|
|
30
31
|
export function useGraphicsSettings(store, shadowsDefault, overrides) {
|
|
31
32
|
useSettingsRevision(store);
|
|
32
|
-
const
|
|
33
|
-
const quality = GRAPHICS_QUALITY_DPR[rawQuality] !== undefined ? rawQuality : "high";
|
|
34
|
-
const profile = resolveGraphicsProfile(quality, overrides?.[quality]);
|
|
33
|
+
const { quality, profile } = readGraphicsSettings(store, overrides);
|
|
35
34
|
const rawUiScale = store.get(SETTING_IDS.graphicsUiScale, DEFAULT_UI_SCALE);
|
|
36
35
|
return {
|
|
37
36
|
shadows: store.get(SETTING_IDS.graphicsShadows, shadowsDefault),
|
|
@@ -39,6 +38,8 @@ export function useGraphicsSettings(store, shadowsDefault, overrides) {
|
|
|
39
38
|
uiScale: Math.min(UI_SCALE_MAX, Math.max(UI_SCALE_MIN, rawUiScale)),
|
|
40
39
|
quality,
|
|
41
40
|
profile,
|
|
41
|
+
frameRateLimit: readFrameRateLimit(store),
|
|
42
|
+
showFps: store.get(SETTING_IDS.graphicsShowFps, false),
|
|
42
43
|
};
|
|
43
44
|
}
|
|
44
45
|
export function AudioSettingsBridge({ store, engine, buses, }) {
|
|
@@ -2,6 +2,7 @@ import { bindingLabel, type ActionCodesMap } from "@jgengine/core/input/actionBi
|
|
|
2
2
|
import type { BindingOverrides } from "@jgengine/core/input/bindingOverrides";
|
|
3
3
|
import type { AudioBusDef } from "@jgengine/core/audio/audioFalloff";
|
|
4
4
|
import { type GameSettingDef, type SettingCategory, type SettingCategoryDef } from "@jgengine/core/settings/settingsModel";
|
|
5
|
+
import { type GraphicsProfileOverrides } from "@jgengine/core/settings/graphicsSettings";
|
|
5
6
|
import { type SettingsActionView, type SettingsKeybindRow, type SettingsCategoryView, type SettingsController, type SettingsRow } from "@jgengine/react/settings";
|
|
6
7
|
export type { SettingsActionView, SettingsKeybindRow, SettingsCategoryView, SettingsController, SettingsRow };
|
|
7
8
|
export interface SettingsControllerInput {
|
|
@@ -11,6 +12,8 @@ export interface SettingsControllerInput {
|
|
|
11
12
|
categories: readonly SettingCategoryDef[];
|
|
12
13
|
hide: readonly SettingCategory[];
|
|
13
14
|
fovEnabled: boolean;
|
|
15
|
+
/** The game's per-tier profile overrides (`defineGame({ graphics })`), so tier defaults match what renders. */
|
|
16
|
+
graphics?: GraphicsProfileOverrides;
|
|
14
17
|
hideBindings: readonly string[];
|
|
15
18
|
/** Show the touch-controls skin selector in Controls (touch device with a dock). */
|
|
16
19
|
touchStyle: boolean;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { actionLabel, bindingLabel } from "@jgengine/core/input/actionBindings";
|
|
2
|
-
import { BUILT_IN_SETTING_CATEGORIES, busVolumeSettingId,
|
|
2
|
+
import { BUILT_IN_SETTING_CATEGORIES, busVolumeSettingId, DEFAULT_GRAPHICS_SHADOWS, DEFAULT_MASTER_VOLUME, DEFAULT_UI_SCALE, GRAPHICS_QUALITY_OPTIONS, SETTING_IDS, UI_SCALE_MAX, UI_SCALE_MIN, } from "@jgengine/core/settings/settingsModel";
|
|
3
|
+
import { ANISOTROPY_OPTIONS, applyGraphicsQuality, GRAPHICS_POST_STAGE_LABELS, GRAPHICS_POST_STAGES, graphicsPostStageSettingId, readGraphicsSettings, RENDER_SCALE_MAX, RENDER_SCALE_MIN, RENDER_SCALE_STEP, } from "@jgengine/core/settings/graphicsSettings";
|
|
4
|
+
import { FRAME_RATE_LIMIT_DISPLAY, FRAME_RATE_LIMIT_OPTIONS } from "@jgengine/core/settings/frameRateLimit";
|
|
3
5
|
import { DEFAULT_TOUCH_JOYSTICK_VARIANT, TOUCH_JOYSTICK_VARIANT_OPTIONS, TOUCH_STYLE_OPTIONS, } from "@jgengine/core/input/touchScheme";
|
|
4
6
|
import { TOUCH_STYLE_AUTO, useSettingsRevision } from "./appliedSettings.js";
|
|
5
7
|
import { useSettingsStore, } from "@jgengine/react/settings";
|
|
@@ -61,14 +63,42 @@ export function useSettingsCategories(config) {
|
|
|
61
63
|
}),
|
|
62
64
|
...extrasFor("sound"),
|
|
63
65
|
];
|
|
66
|
+
const graphics = readGraphicsSettings(store, config.graphics);
|
|
64
67
|
const graphicsRows = [
|
|
65
68
|
{
|
|
66
69
|
id: SETTING_IDS.graphicsQuality,
|
|
67
70
|
label: "Quality",
|
|
68
71
|
kind: "select",
|
|
69
|
-
value:
|
|
72
|
+
value: graphics.quality,
|
|
70
73
|
options: GRAPHICS_QUALITY_OPTIONS,
|
|
71
|
-
set: (value) => store
|
|
74
|
+
set: (value) => applyGraphicsQuality(store, String(value), config.graphics),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: SETTING_IDS.graphicsRenderScale,
|
|
78
|
+
label: "Render scale",
|
|
79
|
+
kind: "slider",
|
|
80
|
+
value: graphics.profile.renderScale,
|
|
81
|
+
min: RENDER_SCALE_MIN,
|
|
82
|
+
max: RENDER_SCALE_MAX,
|
|
83
|
+
step: RENDER_SCALE_STEP,
|
|
84
|
+
format: percent,
|
|
85
|
+
set: (value) => store.set(SETTING_IDS.graphicsRenderScale, value),
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: SETTING_IDS.graphicsFrameRateLimit,
|
|
89
|
+
label: "Frame rate limit",
|
|
90
|
+
kind: "select",
|
|
91
|
+
value: store.get(SETTING_IDS.graphicsFrameRateLimit, FRAME_RATE_LIMIT_DISPLAY),
|
|
92
|
+
options: FRAME_RATE_LIMIT_OPTIONS,
|
|
93
|
+
set: (value) => store.set(SETTING_IDS.graphicsFrameRateLimit, String(value)),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: SETTING_IDS.graphicsAnisotropy,
|
|
97
|
+
label: "Texture filtering",
|
|
98
|
+
kind: "select",
|
|
99
|
+
value: String(graphics.profile.anisotropy),
|
|
100
|
+
options: ANISOTROPY_OPTIONS,
|
|
101
|
+
set: (value) => store.set(SETTING_IDS.graphicsAnisotropy, String(value)),
|
|
72
102
|
},
|
|
73
103
|
{
|
|
74
104
|
id: SETTING_IDS.graphicsShadows,
|
|
@@ -77,6 +107,13 @@ export function useSettingsCategories(config) {
|
|
|
77
107
|
value: store.get(SETTING_IDS.graphicsShadows, DEFAULT_GRAPHICS_SHADOWS),
|
|
78
108
|
set: (value) => store.set(SETTING_IDS.graphicsShadows, value),
|
|
79
109
|
},
|
|
110
|
+
...GRAPHICS_POST_STAGES.map((stage) => ({
|
|
111
|
+
id: graphicsPostStageSettingId(stage),
|
|
112
|
+
label: GRAPHICS_POST_STAGE_LABELS[stage],
|
|
113
|
+
kind: "toggle",
|
|
114
|
+
value: graphics.profile.postStages[stage],
|
|
115
|
+
set: (value) => store.set(graphicsPostStageSettingId(stage), value),
|
|
116
|
+
})),
|
|
80
117
|
{
|
|
81
118
|
id: SETTING_IDS.graphicsUiScale,
|
|
82
119
|
label: "UI scale",
|
|
@@ -88,6 +125,13 @@ export function useSettingsCategories(config) {
|
|
|
88
125
|
format: percent,
|
|
89
126
|
set: (value) => store.set(SETTING_IDS.graphicsUiScale, value),
|
|
90
127
|
},
|
|
128
|
+
{
|
|
129
|
+
id: SETTING_IDS.graphicsShowFps,
|
|
130
|
+
label: "Show FPS",
|
|
131
|
+
kind: "toggle",
|
|
132
|
+
value: store.get(SETTING_IDS.graphicsShowFps, false),
|
|
133
|
+
set: (value) => store.set(SETTING_IDS.graphicsShowFps, value),
|
|
134
|
+
},
|
|
91
135
|
...extrasFor("graphics"),
|
|
92
136
|
];
|
|
93
137
|
const gameplayRows = [
|
|
@@ -5,6 +5,7 @@ import * as THREE from "three";
|
|
|
5
5
|
import { sharedGltfLoader } from "../render/modelLoad.js";
|
|
6
6
|
import { buildScatterModelSources, disposeScatterModelSources, } from "../scatter/scatterModels.js";
|
|
7
7
|
import { composeBuildingKitMatrix, measureBuildingKitModel, } from "./buildingKitFit.js";
|
|
8
|
+
import { applyBuildingSurface, surfaceKey, useBuildingSurfaceTextures } from "./buildingSurface.js";
|
|
8
9
|
function KitSourceInstances({ source, material, matrices, }) {
|
|
9
10
|
const meshRef = useRef(null);
|
|
10
11
|
useEffect(() => {
|
|
@@ -22,16 +23,21 @@ function KitSourceInstances({ source, material, matrices, }) {
|
|
|
22
23
|
}, [matrices, source]);
|
|
23
24
|
return (_jsx("instancedMesh", { ref: meshRef, args: [source.geometry, material, matrices.length], castShadow: true, receiveShadow: true }, matrices.length));
|
|
24
25
|
}
|
|
25
|
-
function
|
|
26
|
+
function styleOne(material, tint, surface, textures) {
|
|
26
27
|
const clone = material.clone();
|
|
27
28
|
const tintable = clone;
|
|
28
|
-
if (tintable.color !== undefined)
|
|
29
|
-
tintable.color.copy(
|
|
29
|
+
if (tint !== undefined && tintable.color !== undefined)
|
|
30
|
+
tintable.color.copy(tint);
|
|
31
|
+
if (surface !== undefined && clone.isMeshStandardMaterial === true) {
|
|
32
|
+
applyBuildingSurface(clone, surface, textures);
|
|
33
|
+
}
|
|
30
34
|
return clone;
|
|
31
35
|
}
|
|
32
|
-
function
|
|
33
|
-
const color = new THREE.Color(tint);
|
|
34
|
-
return Array.isArray(material)
|
|
36
|
+
function styleMaterial(material, tint, surface, textures) {
|
|
37
|
+
const color = tint === "" ? undefined : new THREE.Color(tint);
|
|
38
|
+
return Array.isArray(material)
|
|
39
|
+
? material.map((entry) => styleOne(entry, color, surface, textures))
|
|
40
|
+
: styleOne(material, color, surface, textures);
|
|
35
41
|
}
|
|
36
42
|
function disposeMaterial(material) {
|
|
37
43
|
if (Array.isArray(material))
|
|
@@ -52,27 +58,25 @@ export function BuildingKitBatch({ url, instances }) {
|
|
|
52
58
|
useEffect(() => () => disposeScatterModelSources(root), [root]);
|
|
53
59
|
const bounds = useMemo(() => measureBuildingKitModel(root), [root]);
|
|
54
60
|
const groups = useMemo(() => {
|
|
55
|
-
const
|
|
61
|
+
const byKey = new Map();
|
|
56
62
|
for (const instance of instances) {
|
|
57
63
|
const matrix = composeBuildingKitMatrix(instance, bounds, new THREE.Matrix4());
|
|
58
64
|
const tint = instance.part.tint ?? "";
|
|
59
|
-
const
|
|
65
|
+
const surface = instance.part.material;
|
|
66
|
+
const key = `${tint}|${surfaceKey(surface)}`;
|
|
67
|
+
const bucket = byKey.get(key);
|
|
60
68
|
if (bucket === undefined)
|
|
61
|
-
|
|
69
|
+
byKey.set(key, { key, tint, surface, matrices: [matrix] });
|
|
62
70
|
else
|
|
63
|
-
bucket.push(matrix);
|
|
71
|
+
bucket.matrices.push(matrix);
|
|
64
72
|
}
|
|
65
|
-
return [...
|
|
73
|
+
return [...byKey.values()];
|
|
66
74
|
}, [instances, bounds]);
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return map;
|
|
75
|
-
}, [groups, sources]);
|
|
76
|
-
useEffect(() => () => tinted.forEach((list) => list.forEach(disposeMaterial)), [tinted]);
|
|
77
|
-
return (_jsx(_Fragment, { children: groups.map(([tint, matrices]) => sources.map((source, index) => (_jsx(KitSourceInstances, { source: source, material: tinted.get(tint)?.[index] ?? source.material, matrices: matrices }, `${tint}:${index}`)))) }));
|
|
75
|
+
return (_jsx(_Fragment, { children: groups.map((group) => group.tint === "" && group.surface === undefined ? (sources.map((source, index) => (_jsx(KitSourceInstances, { source: source, material: source.material, matrices: group.matrices }, `${group.key}:${index}`)))) : (_jsx(StyledKitSources, { sources: sources, group: group }, group.key))) }));
|
|
76
|
+
}
|
|
77
|
+
function StyledKitSources({ sources, group }) {
|
|
78
|
+
const textures = useBuildingSurfaceTextures(group.surface);
|
|
79
|
+
const styled = useMemo(() => sources.map((source) => styleMaterial(source.material, group.tint, group.surface, textures)), [sources, group.tint, group.surface, textures]);
|
|
80
|
+
useEffect(() => () => styled.forEach(disposeMaterial), [styled]);
|
|
81
|
+
return (_jsx(_Fragment, { children: sources.map((source, index) => (_jsx(KitSourceInstances, { source: source, material: styled[index], matrices: group.matrices }, index))) }));
|
|
78
82
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
|
-
import type
|
|
2
|
+
import { type BuildingKitSlot, type BuildingSurface } from "@jgengine/core/world/buildings";
|
|
3
3
|
import type { BuildingKit } from "@jgengine/core/world/buildingKit";
|
|
4
4
|
export type BuildingFacade = "front" | "back" | "left" | "right" | "roof";
|
|
5
5
|
export type BuildingPartKind = "wall" | "window" | "awning" | "airConditioner" | "clothesline" | "storefront" | "shutter" | "storeSign" | "roof" | "roofProp" | "guardrail" | "corner";
|
|
@@ -17,20 +17,8 @@ export interface GeneratedBuildingData {
|
|
|
17
17
|
id: string;
|
|
18
18
|
parts: readonly BuildingPartPlacement[];
|
|
19
19
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
window?: string;
|
|
23
|
-
awning?: string;
|
|
24
|
-
airConditioner?: string;
|
|
25
|
-
clothesline?: string;
|
|
26
|
-
storefront?: string;
|
|
27
|
-
shutter?: string;
|
|
28
|
-
storeSign?: string;
|
|
29
|
-
roof?: string;
|
|
30
|
-
roofProp?: string;
|
|
31
|
-
guardrail?: string;
|
|
32
|
-
corner?: string;
|
|
33
|
-
}
|
|
20
|
+
/** Per part kind: a hex colour, or a colour plus tiled PBR maps (`BuildingSurfaceMaterial`). */
|
|
21
|
+
export type BuildingMaterialPalette = Partial<Record<BuildingPartKind, BuildingSurface>>;
|
|
34
22
|
export interface BuildingKitRenderer {
|
|
35
23
|
renderPart?: (part: BuildingPartPlacement) => ReactNode | undefined;
|
|
36
24
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Suspense, useLayoutEffect, useMemo, useRef } from "react";
|
|
3
3
|
import * as THREE from "three";
|
|
4
|
+
import { buildingSurfaceColor, resolveBuildingSurface, } from "@jgengine/core/world/buildings";
|
|
4
5
|
import { useDisposable } from "../render/useDisposable.js";
|
|
5
6
|
import { BuildingKitBatch } from "./BuildingKitBatch.js";
|
|
6
7
|
import { bucketBuildingParts, normalFor, outwardOffset } from "./buildingKitFit.js";
|
|
8
|
+
import { applyBuildingSurface, useBuildingSurfaceTextures } from "./buildingSurface.js";
|
|
7
9
|
const DEFAULT_PALETTE = {
|
|
8
10
|
wall: "#83766a",
|
|
9
11
|
window: "#8ecae6",
|
|
@@ -18,8 +20,12 @@ const DEFAULT_PALETTE = {
|
|
|
18
20
|
guardrail: "#a8a29e",
|
|
19
21
|
corner: "#6b6258",
|
|
20
22
|
};
|
|
23
|
+
function surfaceFor(kind, palette) {
|
|
24
|
+
const surface = resolveBuildingSurface(palette?.[kind] ?? DEFAULT_PALETTE[kind]);
|
|
25
|
+
return surface.color === undefined ? { ...surface, color: DEFAULT_PALETTE[kind] } : surface;
|
|
26
|
+
}
|
|
21
27
|
function colorFor(kind, palette) {
|
|
22
|
-
return palette?.[kind]
|
|
28
|
+
return buildingSurfaceColor(palette?.[kind], DEFAULT_PALETTE[kind]);
|
|
23
29
|
}
|
|
24
30
|
function materialFor(part, palette) {
|
|
25
31
|
const color = colorFor(part.kind, palette);
|
|
@@ -34,22 +40,22 @@ function materialFor(part, palette) {
|
|
|
34
40
|
}
|
|
35
41
|
return _jsx("meshStandardMaterial", { color: color, roughness: 0.88, metalness: 0 });
|
|
36
42
|
}
|
|
37
|
-
function batchMaterialFor(kind,
|
|
38
|
-
const color =
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
return new THREE.MeshStandardMaterial({ color, roughness: 0.88, metalness: 0 });
|
|
43
|
+
function batchMaterialFor(kind, surface, textures) {
|
|
44
|
+
const color = surface.color ?? DEFAULT_PALETTE[kind];
|
|
45
|
+
const base = kind === "window" || kind === "storefront"
|
|
46
|
+
? new THREE.MeshPhysicalMaterial({ color, roughness: 0.12, metalness: 0, transparent: true, opacity: 0.56 })
|
|
47
|
+
: kind === "storeSign"
|
|
48
|
+
? new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.6, roughness: 0.5 })
|
|
49
|
+
: kind === "roofProp"
|
|
50
|
+
? new THREE.MeshStandardMaterial({ color, roughness: 0.65, metalness: 0.1 })
|
|
51
|
+
: new THREE.MeshStandardMaterial({ color, roughness: 0.88, metalness: 0 });
|
|
52
|
+
return applyBuildingSurface(base, surface, textures);
|
|
49
53
|
}
|
|
50
|
-
function BuildingKindBatch({ kind, matrices, palette, geometry
|
|
54
|
+
function BuildingKindBatch({ kind, matrices, palette, geometry }) {
|
|
55
|
+
const surface = useMemo(() => surfaceFor(kind, palette), [kind, palette]);
|
|
56
|
+
const textures = useBuildingSurfaceTextures(surface);
|
|
51
57
|
const meshRef = useRef(null);
|
|
52
|
-
const material = useDisposable(() => batchMaterialFor(kind,
|
|
58
|
+
const material = useDisposable(() => batchMaterialFor(kind, surface, textures), [kind, surface, textures]);
|
|
53
59
|
useLayoutEffect(() => {
|
|
54
60
|
const mesh = meshRef.current;
|
|
55
61
|
if (mesh === null)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import type { BuildingSurfaceMaps, BuildingSurfaceMaterial } from "@jgengine/core/world/buildings";
|
|
3
|
+
import type { MaterialOverrideTextures } from "../materialOverride.js";
|
|
4
|
+
/** A surface that names at least one map URL. */
|
|
5
|
+
export type TexturedBuildingSurface = BuildingSurfaceMaterial & {
|
|
6
|
+
maps: BuildingSurfaceMaps;
|
|
7
|
+
};
|
|
8
|
+
/** True when the surface names at least one map URL, so the batch needs the textured path. */
|
|
9
|
+
export declare function surfaceHasMaps(surface: BuildingSurfaceMaterial | undefined): surface is TexturedBuildingSurface;
|
|
10
|
+
/** A stable key for bucketing instances that share one surface (same colour, maps, tiling, and response). */
|
|
11
|
+
export declare function surfaceKey(surface: BuildingSurfaceMaterial | undefined): string;
|
|
12
|
+
/**
|
|
13
|
+
* Loads a surface's PBR maps and returns per-caller clones configured to tile `repeat` times per
|
|
14
|
+
* slot, or `undefined` until the first map arrives so the batch draws its flat colour meanwhile.
|
|
15
|
+
* Clones share the decoded image, so two kinds using one map at different repeats cost one
|
|
16
|
+
* download. Maps whose file is missing are skipped rather than failing the whole surface.
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
export declare function useBuildingSurfaceTextures(surface: BuildingSurfaceMaterial | undefined): MaterialOverrideTextures | undefined;
|
|
20
|
+
/** Applies a surface's colour, response, and loaded maps onto a standard or physical material in place. @internal */
|
|
21
|
+
export declare function applyBuildingSurface(material: THREE.MeshStandardMaterial, surface: BuildingSurfaceMaterial, textures: MaterialOverrideTextures | undefined): THREE.MeshStandardMaterial;
|