@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.
Files changed (112) hide show
  1. package/CHANGELOG.md +158 -7
  2. package/dist/GamePlayerShell.js +20 -3
  3. package/dist/Shell3dPresentation.d.ts +3 -9
  4. package/dist/Shell3dPresentation.js +26 -6
  5. package/dist/ShellHudPresentation.d.ts +9 -1
  6. package/dist/ShellHudPresentation.js +21 -0
  7. package/dist/audio/AudioComponents.js +6 -2
  8. package/dist/audio/audioEngine.d.ts +8 -0
  9. package/dist/audio/audioEngine.js +65 -6
  10. package/dist/audio/audioWire.d.ts +3 -0
  11. package/dist/audio/audioWire.js +7 -1
  12. package/dist/audio/loopParams.d.ts +39 -0
  13. package/dist/audio/loopParams.js +43 -0
  14. package/dist/camera/GameCameraRig.js +1 -1
  15. package/dist/camera/GameFirstPersonCamera.d.ts +4 -2
  16. package/dist/camera/GameFirstPersonCamera.js +52 -15
  17. package/dist/camera/Viewports.d.ts +34 -0
  18. package/dist/camera/Viewports.js +123 -0
  19. package/dist/camera/cameraRigs.js +70 -62
  20. package/dist/camera/orbitCameraMath.js +2 -2
  21. package/dist/camera/rigMath.d.ts +82 -4
  22. package/dist/camera/rigMath.js +133 -15
  23. package/dist/defineGame.d.ts +9 -0
  24. package/dist/defineGame.js +20 -3
  25. package/dist/devtools/DevtoolsOverlay.js +2 -0
  26. package/dist/devtools/agentBridge.d.ts +6 -0
  27. package/dist/devtools/agentBridge.js +15 -1
  28. package/dist/diagnostics/FpsCounter.d.ts +17 -0
  29. package/dist/diagnostics/FpsCounter.js +32 -0
  30. package/dist/drivers/FrameDriver.js +13 -2
  31. package/dist/drivers/FrameRateLimiter.d.ts +9 -0
  32. package/dist/drivers/FrameRateLimiter.js +39 -0
  33. package/dist/drivers/HudOnlyDriver.js +1 -1
  34. package/dist/environment/DayNightSky.js +5 -5
  35. package/dist/environment/Daylight.d.ts +35 -1
  36. package/dist/environment/Daylight.js +98 -28
  37. package/dist/environment/VolumetricClouds.js +20 -2
  38. package/dist/environment/daylightCycle.d.ts +20 -0
  39. package/dist/environment/daylightCycle.js +59 -7
  40. package/dist/environment/sunShadowMath.d.ts +45 -0
  41. package/dist/environment/sunShadowMath.js +50 -0
  42. package/dist/input/gamepadPoll.d.ts +47 -0
  43. package/dist/input/gamepadPoll.js +142 -0
  44. package/dist/input/gamepadSource.d.ts +11 -1
  45. package/dist/input/gamepadSource.js +117 -71
  46. package/dist/input/mouseLook.js +2 -1
  47. package/dist/input/padHaptics.d.ts +17 -0
  48. package/dist/input/padHaptics.js +32 -0
  49. package/dist/input/pointerLock.d.ts +11 -0
  50. package/dist/input/pointerLock.js +19 -0
  51. package/dist/postfx/PostProcessing.d.ts +1 -1
  52. package/dist/postfx/PostProcessing.js +5 -1
  53. package/dist/postfx/stylizePass.d.ts +23 -0
  54. package/dist/postfx/stylizePass.js +216 -0
  55. package/dist/render/SceneModels.js +1 -1
  56. package/dist/render/SkinnedInstances.d.ts +91 -0
  57. package/dist/render/SkinnedInstances.js +249 -0
  58. package/dist/render/TextureFiltering.d.ts +16 -0
  59. package/dist/render/TextureFiltering.js +64 -0
  60. package/dist/render/displayColor.d.ts +33 -0
  61. package/dist/render/displayColor.js +138 -0
  62. package/dist/render/modelLoad.d.ts +29 -0
  63. package/dist/render/modelLoad.js +61 -1
  64. package/dist/render/resolveModel.js +4 -0
  65. package/dist/render/useFootIk.d.ts +53 -4
  66. package/dist/render/useFootIk.js +197 -70
  67. package/dist/render/useModelAnimation.d.ts +24 -0
  68. package/dist/render/useModelAnimation.js +61 -40
  69. package/dist/scene/AuthoredScene.d.ts +9 -0
  70. package/dist/scene/AuthoredScene.js +17 -1
  71. package/dist/scene/CityRenderer.js +6 -6
  72. package/dist/settings/QuickControls.js +1 -1
  73. package/dist/settings/SettingsMenu.js +1 -1
  74. package/dist/settings/appliedSettings.d.ts +9 -3
  75. package/dist/settings/appliedSettings.js +6 -5
  76. package/dist/settings/settingsController.d.ts +3 -0
  77. package/dist/settings/settingsController.js +47 -3
  78. package/dist/structures/BuildingKitBatch.js +26 -22
  79. package/dist/structures/GeneratedBuilding.d.ts +3 -15
  80. package/dist/structures/GeneratedBuilding.js +21 -15
  81. package/dist/structures/buildingSurface.d.ts +21 -0
  82. package/dist/structures/buildingSurface.js +134 -0
  83. package/dist/useShellMultiplayerSync.d.ts +1 -1
  84. package/dist/useShellMultiplayerSync.js +5 -3
  85. package/dist/world/WorldParticles.js +1 -1
  86. package/package.json +4 -4
  87. package/skills/ce-handoff/SKILL.md +1 -1
  88. package/skills/game-design/SKILL.md +2 -2
  89. package/skills/jgengine/SKILL.md +7 -5
  90. package/skills/jgengine/api.md +118 -89
  91. package/skills/jgengine/capabilities.md +28 -0
  92. package/skills/jgengine/recipes/minimal-game.md +3 -0
  93. package/skills/jgengine-ui/SKILL.md +13 -16
  94. package/skills/jgengine-ui/api.md +418 -317
  95. package/skills/jgengine-ui/capabilities.md +16 -0
  96. package/skills/jgengine-ui/reference-primitives.md +89 -0
  97. package/skills/jgengine-ui/reference.md +2 -81
  98. package/skills/jgengine-world/SKILL.md +15 -3
  99. package/skills/jgengine-world/api.md +873 -626
  100. package/skills/jgengine-world/capabilities.md +130 -6
  101. package/skills/jgengine-world/recipes/character-animation.md +39 -0
  102. package/skills/jgengine-world/recipes/controls.md +44 -0
  103. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  104. package/skills/jgengine-world/recipes/npc-senses.md +43 -0
  105. package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
  106. package/skills/jgengine-world/reference.md +31 -2
  107. package/skills/jgengine-world/visibility.md +112 -0
  108. package/skills/level-design/SKILL.md +1 -1
  109. package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +0 -15
  110. package/dist/render/testFixtures/proceduralSpriteAtlas.js +0 -30
  111. package/dist/render/testFixtures/proceduralTileLayer.d.ts +0 -13
  112. 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 { LOCOMOTION_SPEED_PARAM, locomotionGraph } from "@jgengine/core/anim/locomotionGraph";
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 graph = useMemo(() => {
151
- if (animation?.graph !== undefined)
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
- for (const action of playback.actions.values())
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
- const currentPlayback = graphRef.current;
277
- if (currentPlayback !== null && currentPlayback.rootBone !== null && currentPlayback.rootBindPosition !== null) {
278
- currentPlayback.rootBone.position.copy(currentPlayback.rootBindPosition);
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] + rootDelta[0], entity.position[1] + rootDelta[1], entity.position[2] + rootDelta[2]],
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 shrink-0 gap-1 rounded-md bg-neutral-950/60 p-0.5 ring-1 ring-white/10", children: options.map((option) => {
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 shrink-0 gap-1 rounded-lg p-1", style: { background: surfaceDeep }, children: options.map((option) => {
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 { type GraphicsProfile } from "@jgengine/core/settings/graphicsProfile";
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
- export declare function useGraphicsSettings(store: SettingsStore, shadowsDefault: boolean, overrides?: Partial<Record<GraphicsQuality, Partial<GraphicsProfile>>>): {
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, DEFAULT_GRAPHICS_QUALITY, DEFAULT_MASTER_VOLUME, DEFAULT_UI_SCALE, GRAPHICS_QUALITY_DPR, SETTING_IDS, UI_SCALE_MAX, UI_SCALE_MIN, } from "@jgengine/core/settings/settingsModel";
3
- import { resolveGraphicsProfile } from "@jgengine/core/settings/graphicsProfile";
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 rawQuality = store.get(SETTING_IDS.graphicsQuality, DEFAULT_GRAPHICS_QUALITY);
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, DEFAULT_GRAPHICS_QUALITY, DEFAULT_GRAPHICS_SHADOWS, DEFAULT_MASTER_VOLUME, DEFAULT_UI_SCALE, GRAPHICS_QUALITY_OPTIONS, SETTING_IDS, UI_SCALE_MAX, UI_SCALE_MIN, } from "@jgengine/core/settings/settingsModel";
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: store.get(SETTING_IDS.graphicsQuality, DEFAULT_GRAPHICS_QUALITY),
72
+ value: graphics.quality,
70
73
  options: GRAPHICS_QUALITY_OPTIONS,
71
- set: (value) => store.set(SETTING_IDS.graphicsQuality, value),
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 tintOne(material, color) {
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(color);
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 tintMaterial(material, tint) {
33
- const color = new THREE.Color(tint);
34
- return Array.isArray(material) ? material.map((entry) => tintOne(entry, color)) : tintOne(material, color);
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 byTint = new Map();
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 bucket = byTint.get(tint);
65
+ const surface = instance.part.material;
66
+ const key = `${tint}|${surfaceKey(surface)}`;
67
+ const bucket = byKey.get(key);
60
68
  if (bucket === undefined)
61
- byTint.set(tint, [matrix]);
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 [...byTint.entries()];
73
+ return [...byKey.values()];
66
74
  }, [instances, bounds]);
67
- const tinted = useMemo(() => {
68
- const map = new Map();
69
- for (const [tint] of groups) {
70
- if (tint === "")
71
- continue;
72
- map.set(tint, sources.map((source) => tintMaterial(source.material, tint)));
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 { BuildingKitSlot } from "@jgengine/core/world/buildings";
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
- export interface BuildingMaterialPalette {
21
- wall?: string;
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] ?? DEFAULT_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, palette) {
38
- const color = colorFor(kind, palette);
39
- if (kind === "window" || kind === "storefront") {
40
- return new THREE.MeshPhysicalMaterial({ color, roughness: 0.12, metalness: 0, transparent: true, opacity: 0.56 });
41
- }
42
- if (kind === "storeSign") {
43
- return new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.6, roughness: 0.5 });
44
- }
45
- if (kind === "roofProp") {
46
- return new THREE.MeshStandardMaterial({ color, roughness: 0.65, metalness: 0.1 });
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, palette), [kind, palette]);
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;