@jgengine/shell 0.18.1 → 0.21.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 (261) hide show
  1. package/CHANGELOG.md +504 -7
  2. package/dist/GameHost.d.ts +12 -1
  3. package/dist/GameHost.js +14 -1
  4. package/dist/GamePlayerShell.js +132 -17
  5. package/dist/Shell3dPresentation.d.ts +5 -11
  6. package/dist/Shell3dPresentation.js +73 -11
  7. package/dist/ShellChrome.d.ts +2 -1
  8. package/dist/ShellChrome.js +22 -6
  9. package/dist/ShellHudPresentation.d.ts +20 -2
  10. package/dist/ShellHudPresentation.js +41 -6
  11. package/dist/audio/AudioComponents.js +6 -2
  12. package/dist/audio/audioEngine.d.ts +24 -0
  13. package/dist/audio/audioEngine.js +231 -83
  14. package/dist/audio/audioWire.d.ts +3 -0
  15. package/dist/audio/audioWire.js +9 -3
  16. package/dist/audio/loopParams.d.ts +39 -0
  17. package/dist/audio/loopParams.js +43 -0
  18. package/dist/audio/synthEngine.d.ts +7 -1
  19. package/dist/audio/synthEngine.js +103 -38
  20. package/dist/camera/GameCameraRig.js +3 -2
  21. package/dist/camera/GameFirstPersonCamera.d.ts +6 -4
  22. package/dist/camera/GameFirstPersonCamera.js +68 -27
  23. package/dist/camera/Viewports.d.ts +34 -0
  24. package/dist/camera/Viewports.js +123 -0
  25. package/dist/camera/cameraRigs.js +76 -62
  26. package/dist/camera/chaseHeading.d.ts +9 -0
  27. package/dist/camera/chaseHeading.js +10 -0
  28. package/dist/camera/customMuzzle.d.ts +14 -0
  29. package/dist/camera/customMuzzle.js +68 -0
  30. package/dist/camera/index.d.ts +1 -0
  31. package/dist/camera/index.js +1 -0
  32. package/dist/camera/orbitCameraMath.js +2 -2
  33. package/dist/camera/rigMath.d.ts +82 -4
  34. package/dist/camera/rigMath.js +133 -15
  35. package/dist/camera/rigResolve.d.ts +3 -0
  36. package/dist/camera/rigResolve.js +11 -0
  37. package/dist/combat/ProjectileModels.d.ts +31 -0
  38. package/dist/combat/ProjectileModels.js +70 -0
  39. package/dist/commandSink.d.ts +1 -2
  40. package/dist/commandSink.js +3 -2
  41. package/dist/defineGame.d.ts +19 -3
  42. package/dist/defineGame.js +39 -7
  43. package/dist/devtools/DevtoolsOverlay.d.ts +2 -69
  44. package/dist/devtools/DevtoolsOverlay.js +15 -167
  45. package/dist/devtools/DevtoolsRuntime.d.ts +7 -0
  46. package/dist/devtools/DevtoolsRuntime.js +61 -0
  47. package/dist/devtools/agentBridge.d.ts +6 -0
  48. package/dist/devtools/agentBridge.js +16 -2
  49. package/dist/devtools/devtoolsReports.d.ts +68 -0
  50. package/dist/devtools/devtoolsReports.js +100 -0
  51. package/dist/devtools/latencyInstrumentation.d.ts +3 -0
  52. package/dist/devtools/latencyInstrumentation.js +11 -0
  53. package/dist/devtools/playtestProbe.d.ts +4 -0
  54. package/dist/devtools/playtestProbe.js +22 -0
  55. package/dist/diagnostics/FpsCounter.d.ts +17 -0
  56. package/dist/diagnostics/FpsCounter.js +32 -0
  57. package/dist/drivers/FrameDriver.js +35 -20
  58. package/dist/drivers/FrameRateLimiter.d.ts +9 -0
  59. package/dist/drivers/FrameRateLimiter.js +39 -0
  60. package/dist/drivers/HudOnlyDriver.d.ts +6 -1
  61. package/dist/drivers/HudOnlyDriver.js +32 -14
  62. package/dist/environment/DayNightSky.js +5 -5
  63. package/dist/environment/Daylight.d.ts +35 -1
  64. package/dist/environment/Daylight.js +98 -28
  65. package/dist/environment/EnvironmentScene.js +11 -10
  66. package/dist/environment/VolumetricClouds.js +20 -2
  67. package/dist/environment/daylightCycle.d.ts +20 -0
  68. package/dist/environment/daylightCycle.js +59 -7
  69. package/dist/environment/sunShadowMath.d.ts +45 -0
  70. package/dist/environment/sunShadowMath.js +50 -0
  71. package/dist/input/gamepadPoll.d.ts +47 -0
  72. package/dist/input/gamepadPoll.js +142 -0
  73. package/dist/input/gamepadSource.d.ts +11 -1
  74. package/dist/input/gamepadSource.js +117 -71
  75. package/dist/input/mouseLook.js +2 -1
  76. package/dist/input/padHaptics.d.ts +17 -0
  77. package/dist/input/padHaptics.js +32 -0
  78. package/dist/input/pointerLock.d.ts +11 -0
  79. package/dist/input/pointerLock.js +19 -0
  80. package/dist/inputSink.d.ts +6 -6
  81. package/dist/inputSink.js +60 -21
  82. package/dist/lazyShell3dPresentation.d.ts +9 -0
  83. package/dist/lazyShell3dPresentation.js +24 -0
  84. package/dist/materialOverride.d.ts +42 -0
  85. package/dist/materialOverride.js +95 -36
  86. package/dist/multiplayer.d.ts +33 -3
  87. package/dist/multiplayer.js +105 -22
  88. package/dist/pointer/pointerService.d.ts +2 -0
  89. package/dist/pointer/pointerService.js +12 -2
  90. package/dist/postfx/PostProcessing.d.ts +11 -1
  91. package/dist/postfx/PostProcessing.js +14 -42
  92. package/dist/postfx/gradeShader.d.ts +1 -1
  93. package/dist/postfx/gradeShader.js +30 -2
  94. package/dist/postfx/shadowPrepass.d.ts +3 -0
  95. package/dist/postfx/shadowPrepass.js +18 -0
  96. package/dist/postfx/stylizePass.d.ts +23 -0
  97. package/dist/postfx/stylizePass.js +216 -0
  98. package/dist/presentationRecovery.d.ts +75 -0
  99. package/dist/presentationRecovery.js +204 -0
  100. package/dist/presentationRecovery3d.d.ts +4 -0
  101. package/dist/presentationRecovery3d.js +32 -0
  102. package/dist/presentationResolve.d.ts +8 -1
  103. package/dist/presentationResolve.js +5 -2
  104. package/dist/render/CascadedShadows.d.ts +7 -2
  105. package/dist/render/CascadedShadows.js +98 -20
  106. package/dist/render/EnvironmentLighting.js +74 -35
  107. package/dist/render/HairCards.d.ts +7 -0
  108. package/dist/render/HairCards.js +23 -0
  109. package/dist/render/SceneModels.js +52 -78
  110. package/dist/render/SkinnedInstances.d.ts +91 -0
  111. package/dist/render/SkinnedInstances.js +249 -0
  112. package/dist/render/TextureFiltering.d.ts +16 -0
  113. package/dist/render/TextureFiltering.js +64 -0
  114. package/dist/render/authoredSurfaceMaterial.d.ts +43 -0
  115. package/dist/render/authoredSurfaceMaterial.js +108 -0
  116. package/dist/render/displayColor.d.ts +33 -0
  117. package/dist/render/displayColor.js +138 -0
  118. package/dist/render/materialAppearanceSignals.d.ts +23 -0
  119. package/dist/render/materialAppearanceSignals.js +58 -0
  120. package/dist/render/materialAsset.d.ts +98 -0
  121. package/dist/render/materialAsset.js +454 -0
  122. package/dist/render/measureBounds.js +16 -3
  123. package/dist/render/measureCollisionMesh.js +8 -1
  124. package/dist/render/modelAssets.d.ts +8 -0
  125. package/dist/render/modelAssets.js +43 -0
  126. package/dist/render/modelBindPose.d.ts +5 -0
  127. package/dist/render/modelBindPose.js +35 -0
  128. package/dist/render/modelLoad.d.ts +32 -0
  129. package/dist/render/modelLoad.js +70 -1
  130. package/dist/render/modelNodeVisibility.d.ts +6 -0
  131. package/dist/render/modelNodeVisibility.js +18 -0
  132. package/dist/render/modelRender.d.ts +18 -1
  133. package/dist/render/modelRender.js +81 -3
  134. package/dist/render/projectedOverdraw.d.ts +35 -0
  135. package/dist/render/projectedOverdraw.js +204 -0
  136. package/dist/render/resolveModel.d.ts +12 -2
  137. package/dist/render/resolveModel.js +25 -4
  138. package/dist/render/rigNode.d.ts +26 -0
  139. package/dist/render/rigNode.js +32 -0
  140. package/dist/render/spriteRender.d.ts +4 -0
  141. package/dist/render/spriteRender.js +5 -0
  142. package/dist/render/staticShapeInstances.d.ts +34 -0
  143. package/dist/render/staticShapeInstances.js +78 -0
  144. package/dist/render/useFootIk.d.ts +53 -4
  145. package/dist/render/useFootIk.js +262 -70
  146. package/dist/render/useModelAnimation.d.ts +50 -0
  147. package/dist/render/useModelAnimation.js +245 -99
  148. package/dist/render/useModelInstance.d.ts +25 -0
  149. package/dist/render/useModelInstance.js +43 -0
  150. package/dist/scatter/scatterModels.d.ts +3 -2
  151. package/dist/scatter/scatterModels.js +15 -7
  152. package/dist/scene/AuthoredScene.d.ts +23 -6
  153. package/dist/scene/AuthoredScene.js +46 -9
  154. package/dist/scene/CityRenderer.js +6 -6
  155. package/dist/scene/GeneratedAssetRenderer.js +11 -42
  156. package/dist/settings/QuickControls.js +1 -1
  157. package/dist/settings/SettingsMenu.js +11 -18
  158. package/dist/settings/SettingsRuntime.d.ts +5 -0
  159. package/dist/settings/SettingsRuntime.js +29 -2
  160. package/dist/settings/appliedSettings.d.ts +9 -3
  161. package/dist/settings/appliedSettings.js +6 -5
  162. package/dist/settings/settingsController.d.ts +3 -0
  163. package/dist/settings/settingsController.js +47 -3
  164. package/dist/shellControlSuspension.d.ts +18 -0
  165. package/dist/shellControlSuspension.js +28 -0
  166. package/dist/shellInputPublication.d.ts +28 -0
  167. package/dist/shellInputPublication.js +49 -0
  168. package/dist/shellPointerInput.d.ts +18 -0
  169. package/dist/shellPointerInput.js +49 -0
  170. package/dist/structures/BuildingKitBatch.d.ts +3 -1
  171. package/dist/structures/BuildingKitBatch.js +35 -56
  172. package/dist/structures/BuildingRenderBudget.d.ts +23 -0
  173. package/dist/structures/BuildingRenderBudget.js +54 -0
  174. package/dist/structures/GeneratedBuilding.d.ts +3 -15
  175. package/dist/structures/GeneratedBuilding.js +33 -22
  176. package/dist/structures/buildingKitMaterials.d.ts +20 -0
  177. package/dist/structures/buildingKitMaterials.js +70 -0
  178. package/dist/structures/buildingSpatialBatch.d.ts +11 -0
  179. package/dist/structures/buildingSpatialBatch.js +45 -0
  180. package/dist/structures/buildingSurface.d.ts +21 -0
  181. package/dist/structures/buildingSurface.js +134 -0
  182. package/dist/terrain/GrassField.d.ts +2 -1
  183. package/dist/terrain/GrassField.js +59 -5
  184. package/dist/terrain/grassGeometry.d.ts +10 -0
  185. package/dist/terrain/grassGeometry.js +55 -0
  186. package/dist/terrain/grassMaterial.js +2 -2
  187. package/dist/terrain/terrainDetailMaterial.d.ts +3 -3
  188. package/dist/terrain/terrainDetailMaterial.js +7 -7
  189. package/dist/touch/TouchControlsOverlay.js +4 -4
  190. package/dist/useShellMultiplayerSync.d.ts +1 -1
  191. package/dist/useShellMultiplayerSync.js +9 -3
  192. package/dist/vfx/ParticleField.d.ts +23 -20
  193. package/dist/vfx/ParticleField.js +168 -50
  194. package/dist/visibility/CullingProvider.js +2 -62
  195. package/dist/visibility/cullingDriver.d.ts +9 -0
  196. package/dist/visibility/cullingDriver.js +68 -0
  197. package/dist/weather/AuthoredWeatherLayer.d.ts +16 -0
  198. package/dist/weather/AuthoredWeatherLayer.js +31 -0
  199. package/dist/weather/DustField.d.ts +2 -1
  200. package/dist/weather/DustField.js +17 -3
  201. package/dist/weather/FireSpreadLayer.d.ts +4 -2
  202. package/dist/weather/FireSpreadLayer.js +5 -11
  203. package/dist/weather/LightningStrike.d.ts +4 -1
  204. package/dist/weather/LightningStrike.js +13 -17
  205. package/dist/weather/RainField.d.ts +2 -1
  206. package/dist/weather/RainField.js +17 -3
  207. package/dist/weather/RainImpactField.d.ts +16 -0
  208. package/dist/weather/RainImpactField.js +89 -0
  209. package/dist/weather/SnowField.d.ts +2 -1
  210. package/dist/weather/SnowField.js +17 -3
  211. package/dist/weather/WeatherLayer.d.ts +11 -2
  212. package/dist/weather/WeatherLayer.js +30 -6
  213. package/dist/weather/index.d.ts +2 -0
  214. package/dist/weather/index.js +2 -0
  215. package/dist/weather/weatherMath.d.ts +2 -0
  216. package/dist/weather/weatherMath.js +8 -0
  217. package/dist/weather/weatherShelter.d.ts +18 -0
  218. package/dist/weather/weatherShelter.js +75 -0
  219. package/dist/weather/weatherUniforms.d.ts +40 -1
  220. package/dist/weather/weatherUniforms.js +29 -9
  221. package/dist/world/AuthoredFlocks.d.ts +22 -0
  222. package/dist/world/AuthoredFlocks.js +87 -0
  223. package/dist/world/StaticObjectModels.d.ts +8 -0
  224. package/dist/world/StaticObjectModels.js +75 -0
  225. package/dist/world/WorldHud.d.ts +14 -6
  226. package/dist/world/WorldHud.js +25 -12
  227. package/dist/world/WorldParticles.d.ts +10 -23
  228. package/dist/world/WorldParticles.js +210 -62
  229. package/dist/world/WorldScene.d.ts +3 -1
  230. package/dist/world/WorldScene.js +41 -26
  231. package/dist/world/staticObjectBatches.d.ts +35 -0
  232. package/dist/world/staticObjectBatches.js +163 -0
  233. package/dist/world/worldBarSamples.d.ts +37 -33
  234. package/dist/world/worldBarSamples.js +117 -75
  235. package/package.json +4 -4
  236. package/skills/ce-handoff/SKILL.md +2 -2
  237. package/skills/game-design/SKILL.md +2 -2
  238. package/skills/jgengine/SKILL.md +28 -6
  239. package/skills/jgengine/api.md +139 -99
  240. package/skills/jgengine/capabilities.md +28 -0
  241. package/skills/jgengine/recipes/minimal-game.md +10 -2
  242. package/skills/jgengine-ui/SKILL.md +16 -17
  243. package/skills/jgengine-ui/api.md +603 -365
  244. package/skills/jgengine-ui/capabilities.md +131 -1
  245. package/skills/jgengine-ui/reference-primitives.md +179 -0
  246. package/skills/jgengine-ui/reference.md +4 -81
  247. package/skills/jgengine-world/SKILL.md +25 -4
  248. package/skills/jgengine-world/api.md +1045 -655
  249. package/skills/jgengine-world/capabilities.md +246 -9
  250. package/skills/jgengine-world/recipes/character-animation.md +62 -0
  251. package/skills/jgengine-world/recipes/controls.md +48 -0
  252. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  253. package/skills/jgengine-world/recipes/npc-senses.md +55 -0
  254. package/skills/jgengine-world/recipes/vehicle-feel.md +87 -0
  255. package/skills/jgengine-world/reference.md +180 -4
  256. package/skills/jgengine-world/visibility.md +139 -0
  257. package/skills/level-design/SKILL.md +1 -1
  258. package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +0 -15
  259. package/dist/render/testFixtures/proceduralSpriteAtlas.js +0 -30
  260. package/dist/render/testFixtures/proceduralTileLayer.d.ts +0 -13
  261. package/dist/render/testFixtures/proceduralTileLayer.js +0 -30
@@ -1,6 +1,7 @@
1
1
  import type { EditorDocument } from "@jgengine/core/editor/index";
2
2
  import type { ModelConfig } from "@jgengine/core/game/playableGame";
3
3
  import type { AssetCatalog } from "@jgengine/core/scene/assetCatalog";
4
+ import { type ResolveAuthoredObjectsOptions } from "@jgengine/core/world/authoredObjects";
4
5
  import type { TerrainField } from "@jgengine/core/world/terrain";
5
6
  /**
6
7
  * Resolves the document AuthoredScene renders: when a {@link getDocumentLiveSync} bus is
@@ -23,7 +24,7 @@ export interface AuthoredPathsProps {
23
24
  */
24
25
  export declare function AuthoredPaths({ document, field, kinds }: AuthoredPathsProps): import("react").JSX.Element;
25
26
  /** Props for {@link AuthoredObjects}: document, ground field, and optional lift / onExisting. */
26
- export interface AuthoredObjectsProps {
27
+ export interface AuthoredObjectsProps extends ResolveAuthoredObjectsOptions {
27
28
  document: EditorDocument;
28
29
  field: TerrainField;
29
30
  /** Extra lift in meters on top of each marker's own verticalOffset (default 0). */
@@ -33,16 +34,29 @@ export interface AuthoredObjectsProps {
33
34
  * `placeAuthoredObjects` call wins and remounts stay remount-safe.
34
35
  */
35
36
  onExisting?: "throw" | "replace" | "keep";
37
+ /** Synchronize authored edits and remove previously owned placements absent from the document. */
38
+ synchronize?: boolean;
36
39
  }
37
40
  /**
38
41
  * Places every editor marker with a catalog id into `ctx.scene.object`, grounded on `field`.
39
42
  * WorldScene renders them through the game's `objectModels` seam — no bespoke place loop. Pure
40
43
  * headless twin: `resolveAuthoredObjects` / `placeAuthoredObjects`.
41
44
  */
42
- export declare function AuthoredObjects({ document, field, verticalOffset, onExisting, }: AuthoredObjectsProps): null;
45
+ export declare function AuthoredObjects({ document, field, verticalOffset, onExisting, synchronize, excludeKinds, }: AuthoredObjectsProps): null;
46
+ /**
47
+ * Writes the document's studio solids (city buildings and any kind with a `solids` hook) into
48
+ * `ctx.world.solids`, so what the studios draw also blocks the player, NPCs and physics.
49
+ * @capability authored-solids collision for studio-authored world content such as city volumes
50
+ */
51
+ export declare function AuthoredSolids({ document, field }: {
52
+ document: EditorDocument;
53
+ field: TerrainField;
54
+ }): null;
43
55
  /** Props for {@link AuthoredScene}: the document to render and the ground field to drape/ground on. */
44
56
  export interface AuthoredSceneProps {
45
57
  document: EditorDocument;
58
+ /** Register bounded procedural weather counts in the supported diagnostics probes. */
59
+ diagnostics?: boolean;
46
60
  field: TerrainField;
47
61
  /** Restrict rendered path kinds; default renders every non-scatter path. */
48
62
  pathKinds?: readonly string[];
@@ -52,6 +66,8 @@ export interface AuthoredSceneProps {
52
66
  * dedicated map even if it shares entries with `entityModels`). Requires `assets`.
53
67
  */
54
68
  scatterModels?: Record<string, string | ModelConfig>;
69
+ /** Animated models keyed by authored cosmetic habitat species. */
70
+ flockModels?: Record<string, string | ModelConfig>;
55
71
  /** Catalog `scatterModels` string ids resolve through; required together with `scatterModels`. */
56
72
  assets?: AssetCatalog;
57
73
  /**
@@ -63,11 +79,12 @@ export interface AuthoredSceneProps {
63
79
  /**
64
80
  * Place the document's catalog-id markers into the object
65
81
  * store — WorldScene renders them via the game's `objectModels` seam. Omit when the game places
66
- * props itself in onInit with `placeAuthoredObjects`.
82
+ * props itself in onInit with `placeAuthoredObjects`. `excludeKinds` replaces the resolver's
83
+ * default mob/boss exclusions; include those when adding other game-owned entity spawn kinds.
67
84
  */
68
- placeObjects?: boolean | {
85
+ placeObjects?: boolean | ({
69
86
  verticalOffset?: number;
70
- };
87
+ } & ResolveAuthoredObjectsOptions);
71
88
  /** Terrain surface color sampler forwarded to studio renderers (see `SceneKindRenderContext.groundColorAt`). */
72
89
  groundColorAt?: (x: number, z: number) => string;
73
90
  }
@@ -81,4 +98,4 @@ export interface AuthoredSceneProps {
81
98
  * place markers with your own entity spawns. Pass `scatterModels`+`assets` to resolve palette items to
82
99
  * real catalog GLBs; unmapped items keep the stylized proxy.
83
100
  */
84
- export declare function AuthoredScene({ document, field, pathKinds, scatterModels, assets, live, placeObjects, groundColorAt, }: AuthoredSceneProps): import("react").JSX.Element;
101
+ export declare function AuthoredScene({ document, diagnostics, field, pathKinds, scatterModels, flockModels, assets, live, placeObjects, groundColorAt, }: AuthoredSceneProps): import("react").JSX.Element;
@@ -1,8 +1,11 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Fragment, useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
2
+ import { AuthoredFlocks } from "../world/AuthoredFlocks.js";
3
+ import { AuthoredWeatherLayer } from "../weather/AuthoredWeatherLayer.js";
4
+ import { Fragment, useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
3
5
  import * as THREE from "three";
4
6
  import { getDocumentLiveSync, subscribeDocumentLiveSyncInstall, } from "@jgengine/core/editor/index";
5
- import { placeAuthoredObjects, resolveAuthoredObjects, } from "@jgengine/core/world/authoredObjects";
7
+ import { placeAuthoredObjects, resolveAuthoredObjectsWithDiagnostics, syncAuthoredObjects, } from "@jgengine/core/world/authoredObjects";
8
+ import { syncAuthoredSolids } from "@jgengine/core/world/authoredSolids";
6
9
  import { buildRoadRibbon, GROUND_DECAL_LAYERS, roundPathCorners } from "@jgengine/core/world/roads";
7
10
  import { isScatterPath, resolveScatter } from "@jgengine/core/world/scatterRegion";
8
11
  import { getAssetGenerator } from "@jgengine/core/scene/assetGenerator";
@@ -130,17 +133,47 @@ export function AuthoredPaths({ document, field, kinds }) {
130
133
  * WorldScene renders them through the game's `objectModels` seam — no bespoke place loop. Pure
131
134
  * headless twin: `resolveAuthoredObjects` / `placeAuthoredObjects`.
132
135
  */
133
- export function AuthoredObjects({ document, field, verticalOffset = 0, onExisting = "keep", }) {
136
+ export function AuthoredObjects({ document, field, verticalOffset = 0, onExisting = "keep", synchronize = false, excludeKinds, }) {
134
137
  const ctx = useGameContext();
135
- const objects = useMemo(() => resolveAuthoredObjects(document), [document]);
138
+ const previousObjects = useRef([]);
139
+ const previousStore = useRef(ctx.scene.object);
140
+ const resolved = useMemo(() => resolveAuthoredObjectsWithDiagnostics(document, excludeKinds === undefined ? {} : { excludeKinds }), [document, excludeKinds]);
141
+ const warnedResult = useRef(null);
142
+ const objects = resolved.objects;
136
143
  useEffect(() => {
137
- if (objects.length === 0)
144
+ if (warnedResult.current !== resolved) {
145
+ warnedResult.current = resolved;
146
+ for (const diagnostic of resolved.diagnostics)
147
+ console.warn(`[jgengine] ${diagnostic.path}: ${diagnostic.message}. ${diagnostic.repair}`);
148
+ }
149
+ if (previousStore.current !== ctx.scene.object) {
150
+ previousObjects.current = [];
151
+ previousStore.current = ctx.scene.object;
152
+ }
153
+ if (synchronize) {
154
+ previousObjects.current = syncAuthoredObjects(ctx.scene.object, objects, previousObjects.current, (x, z) => field.sampleHeight(x, z), { verticalOffset });
138
155
  return;
156
+ }
139
157
  placeAuthoredObjects(ctx.scene.object, objects, (x, z) => field.sampleHeight(x, z), {
140
158
  verticalOffset,
141
159
  onExisting,
142
160
  });
143
- }, [ctx.scene.object, field, objects, onExisting, verticalOffset]);
161
+ }, [ctx.scene.object, field, resolved, objects, onExisting, verticalOffset, synchronize]);
162
+ return null;
163
+ }
164
+ /**
165
+ * Writes the document's studio solids (city buildings and any kind with a `solids` hook) into
166
+ * `ctx.world.solids`, so what the studios draw also blocks the player, NPCs and physics.
167
+ * @capability authored-solids collision for studio-authored world content such as city volumes
168
+ */
169
+ export function AuthoredSolids({ document, field }) {
170
+ const ctx = useGameContext();
171
+ useEffect(() => {
172
+ syncAuthoredSolids(ctx.world.solids, document, (x, z) => field.sampleHeight(x, z));
173
+ }, [ctx.world.solids, document, field]);
174
+ useEffect(() => () => {
175
+ syncAuthoredSolids(ctx.world.solids, { markers: [], volumes: [], paths: [] });
176
+ }, [ctx.world.solids]);
144
177
  return null;
145
178
  }
146
179
  /**
@@ -153,16 +186,20 @@ export function AuthoredObjects({ document, field, verticalOffset = 0, onExistin
153
186
  * place markers with your own entity spawns. Pass `scatterModels`+`assets` to resolve palette items to
154
187
  * real catalog GLBs; unmapped items keep the stylized proxy.
155
188
  */
156
- export function AuthoredScene({ document, field, pathKinds, scatterModels, assets, live = true, placeObjects, groundColorAt, }) {
189
+ export function AuthoredScene({ document, diagnostics = false, field, pathKinds, scatterModels, flockModels, assets, live = true, placeObjects, groundColorAt, }) {
190
+ const ctx = useGameContext();
157
191
  const liveDocument = useLiveEditorDocument(document, live);
192
+ // Authoring updates configuration only. Physics remains owned by the fixed game-time stages.
193
+ useEffect(() => { ctx.environment.retune(liveDocument); }, [ctx.environment, liveDocument]);
158
194
  const instances = useMemo(() => resolveScatter(liveDocument, field), [liveDocument, field]);
159
195
  const resolveItem = useMemo(() => createModelMapResolver(scatterModels, assets, "scatterModels"), [scatterModels, assets]);
160
196
  const shouldPlaceObjects = placeObjects === true || (typeof placeObjects === "object" && placeObjects !== null);
161
197
  const objectVerticalOffset = typeof placeObjects === "object" && placeObjects !== null ? (placeObjects.verticalOffset ?? 0) : 0;
162
- return (_jsxs(_Fragment, { children: [_jsx(AuthoredPaths, { document: liveDocument, field: field, ...(pathKinds === undefined ? {} : { kinds: pathKinds }) }), _jsx(InstancedScatter, { instances: instances, ...(resolveItem === undefined ? {} : { resolveItem }) }), _jsx(AuthoredStudios, { document: liveDocument, context: {
198
+ const objectExcludeKinds = typeof placeObjects === "object" && placeObjects !== null ? placeObjects.excludeKinds : undefined;
199
+ return (_jsxs(_Fragment, { children: [_jsx(AuthoredWeatherLayer, { document: liveDocument, heightAt: field.sampleHeight, diagnostics: diagnostics }), flockModels === undefined ? null : _jsx(AuthoredFlocks, { models: flockModels, assets: assets }), _jsx(AuthoredPaths, { document: liveDocument, field: field, ...(pathKinds === undefined ? {} : { kinds: pathKinds }) }), _jsx(InstancedScatter, { instances: instances, ...(resolveItem === undefined ? {} : { resolveItem }) }), _jsx(AuthoredStudios, { document: liveDocument, context: {
163
200
  document: liveDocument,
164
201
  field,
165
202
  ...(assets === undefined ? {} : { assets }),
166
203
  ...(groundColorAt === undefined ? {} : { groundColorAt }),
167
- } }), _jsx(AuthoredGenerators, { document: liveDocument, field: field }), shouldPlaceObjects ? (_jsx(AuthoredObjects, { document: liveDocument, field: field, verticalOffset: objectVerticalOffset })) : null] }));
204
+ } }), _jsx(AuthoredGenerators, { document: liveDocument, field: field }), _jsx(AuthoredSolids, { document: liveDocument, field: field }), shouldPlaceObjects ? (_jsx(AuthoredObjects, { document: liveDocument, field: field, verticalOffset: objectVerticalOffset, synchronize: live && getDocumentLiveSync() !== null, ...(objectExcludeKinds === undefined ? {} : { excludeKinds: objectExcludeKinds }) })) : null] }));
168
205
  }
@@ -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),
@@ -1,49 +1,18 @@
1
- import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useMemo, useRef } from "react";
3
- import * as THREE from "three";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useMemo } from "react";
4
3
  import { resolveGeneratorAsset } from "@jgengine/core/scene/assetGenerator";
5
- const UNIT_BOX = new THREE.BoxGeometry(1, 1, 1);
4
+ import { StaticShapeInstances } from "../render/staticShapeInstances.js";
6
5
  const NEUTRAL = "#b8b0a4";
7
6
  /** All parts of one generated asset, batched into one instanced box draw per color. */
8
7
  function GeneratedParts({ asset }) {
9
- const byColor = useMemo(() => {
10
- const groups = new Map();
11
- for (const part of asset.parts) {
12
- const color = part.color ?? NEUTRAL;
13
- const bucket = groups.get(color);
14
- if (bucket === undefined)
15
- groups.set(color, [part]);
16
- else
17
- bucket.push(part);
18
- }
19
- return [...groups.entries()];
20
- }, [asset]);
21
- return (_jsx(_Fragment, { children: byColor.map(([color, parts]) => (_jsx(ColorBatch, { color: color, parts: parts }, color))) }));
22
- }
23
- function ColorBatch({ color, parts }) {
24
- const meshRef = useRef(null);
25
- useEffect(() => {
26
- const mesh = meshRef.current;
27
- if (mesh === null)
28
- return;
29
- const matrix = new THREE.Matrix4();
30
- const position = new THREE.Vector3();
31
- const quaternion = new THREE.Quaternion();
32
- const euler = new THREE.Euler();
33
- const scale = new THREE.Vector3();
34
- parts.forEach((part, index) => {
35
- position.set(part.position[0], part.position[1], part.position[2]);
36
- euler.set(0, part.rotationY ?? 0, 0);
37
- quaternion.setFromEuler(euler);
38
- scale.set(Math.max(1e-3, part.size[0]), Math.max(1e-3, part.size[1]), Math.max(1e-3, part.size[2]));
39
- matrix.compose(position, quaternion, scale);
40
- mesh.setMatrixAt(index, matrix);
41
- });
42
- mesh.count = parts.length;
43
- mesh.instanceMatrix.needsUpdate = true;
44
- mesh.computeBoundingSphere();
45
- }, [parts]);
46
- return (_jsx("instancedMesh", { ref: meshRef, args: [UNIT_BOX, undefined, Math.max(1, parts.length)], castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: color, roughness: 0.62, metalness: 0.04 }) }));
8
+ const instances = useMemo(() => asset.parts.map(part => ({
9
+ shape: "box",
10
+ position: part.position,
11
+ rotation: [0, part.rotationY ?? 0, 0],
12
+ scale: [Math.max(1e-3, part.size[0]), Math.max(1e-3, part.size[1]), Math.max(1e-3, part.size[2])],
13
+ surface: { color: part.color ?? NEUTRAL, roughness: 0.62, metalness: 0.04 },
14
+ })), [asset]);
15
+ return _jsx(StaticShapeInstances, { instances: instances });
47
16
  }
48
17
  /**
49
18
  * Renders one placed generator-asset instance by re-resolving its `meta` through the registered
@@ -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
  }) })] }));
@@ -1,5 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from "react";
3
+ import { ScrollRail } from "@jgengine/react/scrollRail";
4
+ import { useDialogBehavior } from "@jgengine/react/dialogBehavior";
3
5
  import { useDebouncedCommit } from "@jgengine/react/useDebouncedCommit";
4
6
  import {} from "./settingsController.js";
5
7
  const surface = "var(--jg-surface, #16161d)";
@@ -40,9 +42,9 @@ function ToggleRow({ row }) {
40
42
  function SelectRow({ row }) {
41
43
  const options = row.options ?? [];
42
44
  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) => {
45
+ return (_jsx(RowShell, { label: row.label, control: _jsx("div", { role: "group", "aria-label": row.label, className: "flex flex-wrap justify-end gap-1 rounded-lg p-1", style: { background: surfaceDeep }, children: options.map((option) => {
44
46
  const selected = option.value === current;
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));
47
+ return (_jsx("button", { type: "button", onClick: () => row.set(option.value), "aria-pressed": selected, 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
48
  }) }) }));
47
49
  }
48
50
  function RowControl({ row }) {
@@ -68,10 +70,10 @@ function ActionRow({ action, onClose }) {
68
70
  }, children: [_jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "text-sm font-semibold", style: { color: isDanger ? danger : text }, children: action.label }), action.description !== undefined ? (_jsx("span", { className: "text-xs", style: { color: textDim }, children: action.description })) : null] }), _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", className: "h-4 w-4 shrink-0", style: { color: isDanger ? danger : textDim }, "aria-hidden": true, children: _jsx("path", { d: "M9 6l6 6-6 6" }) })] }));
69
71
  }
70
72
  function TabButton({ tab, selected, orientation, onSelect }) {
71
- return (_jsx("button", { type: "button", onClick: onSelect, className: `min-h-[40px] whitespace-nowrap rounded-lg px-4 text-sm font-medium transition ${orientation === "col" ? "w-full text-left" : ""} ${selected ? "" : "hover:bg-white/5"}`, style: selected ? { background: accent, color: onAccent } : { color: textDim }, children: tab.label }));
73
+ return (_jsx("button", { type: "button", onClick: onSelect, className: `shrink-0 min-h-[44px] whitespace-nowrap rounded-lg px-4 text-sm font-medium transition ${orientation === "col" ? "w-full text-left" : ""} ${selected ? "" : "hover:bg-white/5"}`, style: selected ? { background: accent, color: onAccent } : { color: textDim }, children: tab.label }));
72
74
  }
73
75
  function Body({ tab, actions, capturing, setCapturing, onClose }) {
74
- return (_jsx("div", { className: "jg-settings-scroll flex-1 space-y-2 overflow-y-auto p-4", children: tab.category === null
76
+ return (_jsx("div", { className: "jg-settings-scroll min-h-0 flex-1 space-y-2 overflow-y-auto p-4", children: tab.category === null
75
77
  ? actions.map((action) => _jsx(ActionRow, { action: action, onClose: onClose }, action.id))
76
78
  : (_jsxs(_Fragment, { children: [tab.category.rows.map((row) => (_jsx(RowControl, { row: row }, row.id))), tab.category.keybinds.map((row) => (_jsx(KeybindRowView, { row: row, capturing: capturing?.action === row.action, onCapture: () => setCapturing(row) }, row.action)))] })) }));
77
79
  }
@@ -95,6 +97,7 @@ export function SettingsMenu({ controller, onClose, initialTab, }) {
95
97
  const [activeId, setActiveId] = useState(initialTab !== undefined && tabs.some((tab) => tab.id === initialTab) ? initialTab : (tabs[0]?.id ?? ""));
96
98
  const [capturing, setCapturing] = useState(null);
97
99
  const active = tabs.find((tab) => tab.id === activeId) ?? tabs[0] ?? null;
100
+ const dialogRef = useDialogBehavior({ open: active !== null, onClose: capturing === null ? onClose : undefined });
98
101
  useEffect(() => {
99
102
  if (capturing === null)
100
103
  return;
@@ -124,28 +127,18 @@ export function SettingsMenu({ controller, onClose, initialTab, }) {
124
127
  window.removeEventListener("contextmenu", onContext, true);
125
128
  };
126
129
  }, [capturing]);
127
- useEffect(() => {
128
- if (capturing !== null)
129
- return;
130
- const onKey = (event) => {
131
- if (event.code === "Escape")
132
- onClose();
133
- };
134
- window.addEventListener("keydown", onKey);
135
- return () => window.removeEventListener("keydown", onKey);
136
- }, [capturing, onClose]);
137
130
  if (active === null)
138
131
  return null;
139
132
  const bodyProps = { tab: active, actions: controller.actions, capturing, setCapturing, onClose };
140
133
  const title = (_jsx("h2", { className: "text-base font-semibold tracking-wide", style: { color: text }, children: "Settings" }));
141
134
  if (controller.variant === "fullscreen") {
142
- return (_jsx("div", { className: "pointer-events-auto absolute inset-0 z-40 flex flex-col", style: { background: surface, color: text, fontFamily: fontDisplay }, children: _jsxs("div", { className: "mx-auto flex h-full w-full max-w-2xl flex-col px-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-3 pb-4 pt-6", children: [_jsx("h2", { className: "text-2xl font-bold tracking-wide", style: { color: text }, children: "Settings" }), _jsx(CloseButton, { onClose: onClose })] }), _jsx("div", { className: "flex gap-1.5 overflow-x-auto pb-4", children: tabs.map((tab) => (_jsx(TabButton, { tab: tab, selected: tab.id === active.id, orientation: "row", onSelect: () => setActiveId(tab.id) }, tab.id))) }), _jsx(Body, { ...bodyProps })] }) }));
135
+ return (_jsx("div", { ref: dialogRef, role: "dialog", "aria-modal": "true", "aria-label": "Settings", tabIndex: -1, className: "pointer-events-auto absolute inset-0 z-40 flex flex-col", style: { background: surface, color: text, fontFamily: fontDisplay }, children: _jsxs("div", { className: "mx-auto flex h-full w-full max-w-2xl flex-col px-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-3 pb-4 pt-6", children: [_jsx("h2", { className: "text-2xl font-bold tracking-wide", style: { color: text }, children: "Settings" }), _jsx(CloseButton, { onClose: onClose })] }), _jsx(ScrollRail, { label: "settings categories", style: { paddingBottom: 16 }, children: tabs.map((tab) => (_jsx(TabButton, { tab: tab, selected: tab.id === active.id, orientation: "row", onSelect: () => setActiveId(tab.id) }, tab.id))) }), _jsx(Body, { ...bodyProps })] }) }));
143
136
  }
144
137
  if (controller.variant === "sidebar") {
145
- return (_jsx(Backdrop, { onClose: onClose, align: "items-center", children: _jsxs("div", { onClick: stop, className: "flex overflow-hidden rounded-2xl shadow-2xl", style: { ...panelStyle, width: "min(94vw, 760px)", height: "min(84vh, 560px)" }, children: [_jsxs("nav", { className: "flex w-44 shrink-0 flex-col gap-1 p-3", style: { background: surfaceDeep, borderRight: `1px solid ${edge}` }, children: [_jsx("span", { className: "px-3 pb-2 pt-1 text-xs font-semibold uppercase tracking-widest", style: { color: textDim }, children: "Settings" }), tabs.map((tab) => (_jsx(TabButton, { tab: tab, selected: tab.id === active.id, orientation: "col", onSelect: () => setActiveId(tab.id) }, tab.id)))] }), _jsxs("div", { className: "flex flex-1 flex-col", children: [_jsxs("div", { className: "flex items-center justify-between gap-3 px-4 py-3", style: { borderBottom: `1px solid ${edge}` }, children: [_jsx("span", { className: "text-sm font-semibold", style: { color: text }, children: active.label }), _jsx(CloseButton, { onClose: onClose })] }), _jsx(Body, { ...bodyProps })] })] }) }));
138
+ return (_jsx(Backdrop, { onClose: onClose, align: "items-center", children: _jsxs("div", { ref: dialogRef, role: "dialog", "aria-modal": "true", "aria-label": "Settings", tabIndex: -1, onClick: stop, className: "flex overflow-hidden rounded-2xl shadow-2xl", style: { ...panelStyle, width: "min(94vw, 760px)", height: "min(84vh, 560px)" }, children: [_jsxs("nav", { className: "flex w-44 shrink-0 flex-col gap-1 overflow-y-auto p-3", style: { background: surfaceDeep, borderRight: `1px solid ${edge}` }, children: [_jsx("span", { className: "px-3 pb-2 pt-1 text-xs font-semibold uppercase tracking-widest", style: { color: textDim }, children: "Settings" }), tabs.map((tab) => (_jsx(TabButton, { tab: tab, selected: tab.id === active.id, orientation: "col", onSelect: () => setActiveId(tab.id) }, tab.id)))] }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("div", { className: "flex items-center justify-between gap-3 px-4 py-3", style: { borderBottom: `1px solid ${edge}` }, children: [_jsx("span", { className: "text-sm font-semibold", style: { color: text }, children: active.label }), _jsx(CloseButton, { onClose: onClose })] }), _jsx(Body, { ...bodyProps })] })] }) }));
146
139
  }
147
140
  if (controller.variant === "sheet") {
148
- return (_jsx(Backdrop, { onClose: onClose, align: "items-end", children: _jsxs("div", { onClick: stop, className: "flex flex-col overflow-hidden rounded-t-3xl shadow-2xl", style: { ...panelStyle, width: "min(100%, 640px)", height: "min(84vh, 620px)" }, children: [_jsx("div", { className: "flex flex-col items-center pt-2.5", children: _jsx("span", { className: "h-1.5 w-10 rounded-full", style: { background: "rgba(255,255,255,0.25)" } }) }), _jsxs("div", { className: "flex items-center justify-between gap-3 px-4 py-3", children: [title, _jsx(CloseButton, { onClose: onClose })] }), _jsx("div", { className: "flex gap-1.5 overflow-x-auto px-4 pb-3", children: tabs.map((tab) => (_jsx(TabButton, { tab: tab, selected: tab.id === active.id, orientation: "row", onSelect: () => setActiveId(tab.id) }, tab.id))) }), _jsx("div", { style: { borderTop: `1px solid ${edge}` }, className: "flex flex-1 flex-col overflow-hidden", children: _jsx(Body, { ...bodyProps }) })] }) }));
141
+ return (_jsx(Backdrop, { onClose: onClose, align: "items-end", children: _jsxs("div", { ref: dialogRef, role: "dialog", "aria-modal": "true", "aria-label": "Settings", tabIndex: -1, onClick: stop, className: "flex flex-col overflow-hidden rounded-t-3xl shadow-2xl", style: { ...panelStyle, width: "min(100%, 640px)", height: "min(84vh, 620px)" }, children: [_jsx("div", { className: "flex flex-col items-center pt-2.5", children: _jsx("span", { className: "h-1.5 w-10 rounded-full", style: { background: "rgba(255,255,255,0.25)" } }) }), _jsxs("div", { className: "flex items-center justify-between gap-3 px-4 py-3", children: [title, _jsx(CloseButton, { onClose: onClose })] }), _jsx(ScrollRail, { label: "settings categories", style: { padding: "0 16px 12px" }, children: tabs.map((tab) => (_jsx(TabButton, { tab: tab, selected: tab.id === active.id, orientation: "row", onSelect: () => setActiveId(tab.id) }, tab.id))) }), _jsx("div", { style: { borderTop: `1px solid ${edge}` }, className: "flex flex-1 flex-col overflow-hidden", children: _jsx(Body, { ...bodyProps }) })] }) }));
149
142
  }
150
- return (_jsx(Backdrop, { onClose: onClose, align: "items-center", children: _jsxs("div", { onClick: stop, className: "flex flex-col overflow-hidden rounded-2xl shadow-2xl", style: { ...panelStyle, width: "min(92vw, 460px)", height: "min(86vh, 580px)" }, children: [_jsxs("div", { className: "flex items-center justify-between gap-3 px-4 py-3.5", style: { borderBottom: `1px solid ${edge}` }, children: [title, _jsx(CloseButton, { onClose: onClose })] }), _jsx("div", { className: "flex gap-1.5 overflow-x-auto px-4 py-3", style: { borderBottom: `1px solid ${edge}` }, children: tabs.map((tab) => (_jsx(TabButton, { tab: tab, selected: tab.id === active.id, orientation: "row", onSelect: () => setActiveId(tab.id) }, tab.id))) }), _jsx(Body, { ...bodyProps })] }) }));
143
+ return (_jsx(Backdrop, { onClose: onClose, align: "items-center", children: _jsxs("div", { ref: dialogRef, role: "dialog", "aria-modal": "true", "aria-label": "Settings", tabIndex: -1, onClick: stop, className: "flex flex-col overflow-hidden rounded-2xl shadow-2xl", style: { ...panelStyle, width: "min(92vw, 460px)", height: "min(86vh, 580px)" }, children: [_jsxs("div", { className: "flex items-center justify-between gap-3 px-4 py-3.5", style: { borderBottom: `1px solid ${edge}` }, children: [title, _jsx(CloseButton, { onClose: onClose })] }), _jsx(ScrollRail, { label: "settings categories", style: { padding: "12px 16px", borderBottom: `1px solid ${edge}` }, children: tabs.map((tab) => (_jsx(TabButton, { tab: tab, selected: tab.id === active.id, orientation: "row", onSelect: () => setActiveId(tab.id) }, tab.id))) }), _jsx(Body, { ...bodyProps })] }) }));
151
144
  }
@@ -1,4 +1,5 @@
1
1
  import { type ReactNode } from "react";
2
+ import type { GameContext } from "@jgengine/core/runtime/gameContext";
2
3
  import type { SettingsSurface, SettingsVariant } from "@jgengine/core/settings/settingsModel";
3
4
  import { type SettingsActionView } from "@jgengine/react/settings";
4
5
  import { type SettingsControllerInput } from "./settingsController.js";
@@ -9,3 +10,7 @@ export interface SettingsRuntimeProps extends SettingsControllerInput {
9
10
  children: ReactNode;
10
11
  }
11
12
  export declare function SettingsRuntime({ variant, surface, actions, children, ...input }: SettingsRuntimeProps): import("react").JSX.Element;
13
+ /** Owns settings' suspension for this live context, including close, replacement and unmount. @internal */
14
+ export declare function SettingsPlayControlGate({ ctx }: {
15
+ ctx: GameContext;
16
+ }): import("react").JSX.Element;
@@ -1,6 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useMemo, useState } from "react";
3
- import { SettingsControllerProvider, } from "@jgengine/react/settings";
2
+ import { useLayoutEffect, useMemo, useRef, useState } from "react";
3
+ import { suspendPlayControls } from "@jgengine/core/game/controlGate";
4
+ import { SettingsControllerProvider, useSettings, } from "@jgengine/react/settings";
5
+ import { createPlaySurfaceFocusOwnership } from "../presentationRecovery.js";
4
6
  import { useSettingsCategories } from "./settingsController.js";
5
7
  export function SettingsRuntime({ variant, surface, actions, children, ...input }) {
6
8
  const categories = useSettingsCategories(input);
@@ -17,3 +19,28 @@ export function SettingsRuntime({ variant, surface, actions, children, ...input
17
19
  }), [categories, actions, variant, surface, isOpen]);
18
20
  return _jsx(SettingsControllerProvider, { controller: controller, children: children });
19
21
  }
22
+ /** Owns settings' suspension for this live context, including close, replacement and unmount. @internal */
23
+ export function SettingsPlayControlGate({ ctx }) {
24
+ const { isOpen } = useSettings();
25
+ const anchor = useRef(null);
26
+ const openedSurface = useRef(null);
27
+ const focus = useRef(createPlaySurfaceFocusOwnership());
28
+ useLayoutEffect(() => {
29
+ if (isOpen) {
30
+ openedSurface.current = { owner: ctx, surface: anchor.current?.closest('div[tabindex="0"]') ?? null };
31
+ return;
32
+ }
33
+ const previous = openedSurface.current;
34
+ openedSurface.current = null;
35
+ if (previous?.owner !== ctx)
36
+ return;
37
+ focus.current.request(ctx);
38
+ focus.current.reveal(ctx, previous.surface, document.activeElement, document.body);
39
+ }, [ctx, isOpen]);
40
+ useLayoutEffect(() => {
41
+ if (!isOpen)
42
+ return;
43
+ return suspendPlayControls(ctx);
44
+ }, [ctx, isOpen]);
45
+ return _jsx("span", { hidden: true, ref: anchor });
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 = [
@@ -0,0 +1,18 @@
1
+ import type { GameContext } from "@jgengine/core/runtime/gameContext";
2
+ import type { PointerAxisState } from "@jgengine/core/input/pointerAxis";
3
+ type Ref<T> = {
4
+ current: T;
5
+ };
6
+ /** Clears held input synchronously on play-gate transitions; ordinary context swaps retain it. @internal */
7
+ export declare function attachShellControlSuspension({ ctx, tracker, pointerAxisRef, analogRef, primaryClickRef, cameraDraggingRef, f2HeldRef, }: {
8
+ ctx: GameContext;
9
+ tracker: {
10
+ reset(): void;
11
+ };
12
+ pointerAxisRef: Ref<PointerAxisState | null>;
13
+ analogRef: Ref<Readonly<Record<string, number>> | null>;
14
+ primaryClickRef: Ref<boolean>;
15
+ cameraDraggingRef: Ref<boolean>;
16
+ f2HeldRef: Ref<boolean>;
17
+ }): () => void;
18
+ export {};