@jgengine/shell 0.10.0 → 0.12.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 (286) hide show
  1. package/CHANGELOG.md +39 -2
  2. package/LICENSE +203 -661
  3. package/README.md +1 -1
  4. package/dist/CombatPresentation.d.ts +5 -0
  5. package/dist/CombatPresentation.js +8 -0
  6. package/dist/GameHost.d.ts +25 -1
  7. package/dist/GameHost.js +60 -4
  8. package/dist/GamePhaseStamp.d.ts +1 -0
  9. package/dist/GamePhaseStamp.js +9 -0
  10. package/dist/GamePlayer.d.ts +5 -0
  11. package/dist/GamePlayer.js +5 -0
  12. package/dist/GamePlayerShell.d.ts +5 -12
  13. package/dist/GamePlayerShell.js +67 -1332
  14. package/dist/GameUiPreview.js +3 -2
  15. package/dist/Shell3dPresentation.d.ts +63 -0
  16. package/dist/Shell3dPresentation.js +302 -0
  17. package/dist/ShellChrome.d.ts +51 -0
  18. package/dist/ShellChrome.js +51 -0
  19. package/dist/ShellHudPresentation.d.ts +41 -0
  20. package/dist/ShellHudPresentation.js +22 -0
  21. package/dist/audio/audioEngine.d.ts +4 -0
  22. package/dist/audio/audioEngine.js +128 -21
  23. package/dist/audio/audioWire.d.ts +40 -0
  24. package/dist/audio/audioWire.js +62 -0
  25. package/dist/audio/loopParams.d.ts +20 -0
  26. package/dist/audio/loopParams.js +28 -0
  27. package/dist/behaviour.d.ts +2 -1
  28. package/dist/behaviour.js +2 -1
  29. package/dist/behaviourAttach.d.ts +1 -0
  30. package/dist/behaviourAttach.js +1 -0
  31. package/dist/behaviourDriver.d.ts +1 -0
  32. package/dist/behaviourDriver.js +1 -0
  33. package/dist/boundActionDispatch.d.ts +13 -0
  34. package/dist/boundActionDispatch.js +37 -0
  35. package/dist/camera/GameCameraRig.d.ts +5 -2
  36. package/dist/camera/GameCameraRig.js +8 -3
  37. package/dist/camera/GameFirstPersonCamera.d.ts +9 -2
  38. package/dist/camera/GameFirstPersonCamera.js +8 -3
  39. package/dist/camera/GameInspectionCamera.d.ts +1 -1
  40. package/dist/camera/GameInspectionCamera.js +2 -2
  41. package/dist/camera/GameOrbitCamera.js +24 -9
  42. package/dist/camera/cameraBlendMath.d.ts +3 -0
  43. package/dist/camera/cameraBlendMath.js +3 -0
  44. package/dist/camera/cameraRigs.d.ts +3 -1
  45. package/dist/camera/cameraRigs.js +21 -3
  46. package/dist/camera/fovPreference.d.ts +6 -1
  47. package/dist/camera/fovPreference.js +6 -1
  48. package/dist/camera/index.d.ts +4 -7
  49. package/dist/camera/index.js +4 -7
  50. package/dist/camera/inspectionCameraMath.d.ts +7 -2
  51. package/dist/camera/inspectionCameraMath.js +7 -2
  52. package/dist/camera/orbitCameraMath.d.ts +36 -6
  53. package/dist/camera/orbitCameraMath.js +56 -6
  54. package/dist/camera/rigMath.d.ts +114 -24
  55. package/dist/camera/rigMath.js +129 -24
  56. package/dist/camera/rigResolve.d.ts +4 -2
  57. package/dist/camera/rigResolve.js +4 -2
  58. package/dist/camera/shakeChannel.d.ts +6 -2
  59. package/dist/camera/shakeChannel.js +6 -2
  60. package/dist/camera/shakeChannelMath.d.ts +1 -0
  61. package/dist/camera/shakeChannelMath.js +1 -0
  62. package/dist/commandSink.d.ts +8 -3
  63. package/dist/commandSink.js +8 -3
  64. package/dist/defineGame.d.ts +15 -1
  65. package/dist/defineGame.js +50 -9
  66. package/dist/devtools/ColPanel.d.ts +1 -0
  67. package/dist/devtools/ColPanel.js +14 -0
  68. package/dist/devtools/DevtoolsOverlay.d.ts +11 -7
  69. package/dist/devtools/DevtoolsOverlay.js +47 -462
  70. package/dist/devtools/KeysPanel.d.ts +4 -0
  71. package/dist/devtools/KeysPanel.js +31 -0
  72. package/dist/devtools/LogsPanel.d.ts +1 -0
  73. package/dist/devtools/LogsPanel.js +12 -0
  74. package/dist/devtools/NetPanel.d.ts +4 -0
  75. package/dist/devtools/NetPanel.js +10 -0
  76. package/dist/devtools/PerfPanel.d.ts +4 -0
  77. package/dist/devtools/PerfPanel.js +53 -0
  78. package/dist/devtools/TunePanel.d.ts +3 -0
  79. package/dist/devtools/TunePanel.js +309 -0
  80. package/dist/devtools/agentBridge.d.ts +41 -0
  81. package/dist/devtools/agentBridge.js +266 -0
  82. package/dist/devtools/collisionDebug.d.ts +6 -0
  83. package/dist/devtools/collisionDebug.js +6 -0
  84. package/dist/devtools/collisionDebugMath.d.ts +8 -2
  85. package/dist/devtools/collisionDebugMath.js +24 -5
  86. package/dist/devtools/devtoolsOverrides.d.ts +6 -0
  87. package/dist/devtools/devtoolsOverrides.js +48 -0
  88. package/dist/devtools/panelAtoms.d.ts +9 -0
  89. package/dist/devtools/panelAtoms.js +10 -0
  90. package/dist/devtools/perfDiagnose.d.ts +2 -0
  91. package/dist/devtools/perfDiagnose.js +43 -0
  92. package/dist/diagnostics/RuntimeDiagnostics.d.ts +36 -0
  93. package/dist/diagnostics/RuntimeDiagnostics.js +80 -0
  94. package/dist/drivers/FrameDriver.d.ts +37 -0
  95. package/dist/drivers/FrameDriver.js +232 -0
  96. package/dist/drivers/HudOnlyDriver.d.ts +16 -0
  97. package/dist/drivers/HudOnlyDriver.js +60 -0
  98. package/dist/environment/Daylight.d.ts +23 -7
  99. package/dist/environment/Daylight.js +106 -13
  100. package/dist/environment/EnvironmentScene.js +44 -9
  101. package/dist/environment/VolumetricClouds.d.ts +17 -0
  102. package/dist/environment/VolumetricClouds.js +202 -0
  103. package/dist/environment/daylightCycle.d.ts +3 -1
  104. package/dist/environment/daylightCycle.js +3 -1
  105. package/dist/environment/groundPadMath.d.ts +3 -0
  106. package/dist/environment/groundPadMath.js +3 -0
  107. package/dist/environment/index.d.ts +1 -0
  108. package/dist/environment/index.js +1 -0
  109. package/dist/environment/skyLightingPolicy.d.ts +2 -0
  110. package/dist/environment/skyLightingPolicy.js +2 -0
  111. package/dist/environment/terrainGroundColor.d.ts +9 -0
  112. package/dist/environment/terrainGroundColor.js +22 -0
  113. package/dist/gameKit.d.ts +27 -0
  114. package/dist/gameKit.js +29 -0
  115. package/dist/hotbarActions.d.ts +26 -0
  116. package/dist/hotbarActions.js +27 -0
  117. package/dist/inputSink.d.ts +16 -4
  118. package/dist/inputSink.js +57 -7
  119. package/dist/map/MapMarkerBeacons.d.ts +3 -2
  120. package/dist/materialOverride.d.ts +10 -0
  121. package/dist/materialOverride.js +15 -3
  122. package/dist/multiplayer.js +8 -2
  123. package/dist/postfx/PostProcessing.d.ts +9 -1
  124. package/dist/postfx/PostProcessing.js +56 -11
  125. package/dist/postfx/gradeShader.js +9 -1
  126. package/dist/presentationResolve.d.ts +35 -0
  127. package/dist/presentationResolve.js +51 -0
  128. package/dist/registry.d.ts +4 -2
  129. package/dist/render/EnvironmentLighting.d.ts +10 -0
  130. package/dist/render/EnvironmentLighting.js +34 -0
  131. package/dist/render/SceneLighting.d.ts +7 -0
  132. package/dist/render/SceneLighting.js +18 -0
  133. package/dist/render/SceneModels.d.ts +14 -0
  134. package/dist/render/SceneModels.js +337 -0
  135. package/dist/render/assetBase.d.ts +17 -0
  136. package/dist/render/assetBase.js +35 -0
  137. package/dist/render/defaultSurface.d.ts +29 -0
  138. package/dist/render/defaultSurface.js +101 -0
  139. package/dist/render/modelLoad.d.ts +24 -0
  140. package/dist/render/modelLoad.js +76 -0
  141. package/dist/render/modelRender.d.ts +11 -0
  142. package/dist/render/modelRender.js +15 -0
  143. package/dist/render/resolveModel.d.ts +54 -3
  144. package/dist/render/resolveModel.js +116 -4
  145. package/dist/render/useEntityRenderCues.d.ts +13 -0
  146. package/dist/render/useEntityRenderCues.js +62 -0
  147. package/dist/scatter/InstancedScatter.d.ts +24 -0
  148. package/dist/scatter/InstancedScatter.js +135 -0
  149. package/dist/scatter/scatterModels.d.ts +25 -0
  150. package/dist/scatter/scatterModels.js +61 -0
  151. package/dist/scatter/scatterProxies.d.ts +6 -0
  152. package/dist/scatter/scatterProxies.js +118 -0
  153. package/dist/scatter.d.ts +1 -0
  154. package/dist/scatter.js +1 -0
  155. package/dist/scene/AuthoredScene.d.ts +84 -0
  156. package/dist/scene/AuthoredScene.js +178 -0
  157. package/dist/scene/CityRenderer.d.ts +2 -0
  158. package/dist/scene/CityRenderer.js +1031 -0
  159. package/dist/scene/GeneratedAssetRenderer.d.ts +14 -0
  160. package/dist/scene/GeneratedAssetRenderer.js +59 -0
  161. package/dist/scene/GrassFieldRenderer.d.ts +2 -0
  162. package/dist/scene/GrassFieldRenderer.js +73 -0
  163. package/dist/scene/PoleLineRenderer.d.ts +2 -0
  164. package/dist/scene/PoleLineRenderer.js +287 -0
  165. package/dist/scene/SoilPatchRenderer.d.ts +2 -0
  166. package/dist/scene/SoilPatchRenderer.js +84 -0
  167. package/dist/scene/StudioStage.d.ts +37 -0
  168. package/dist/scene/StudioStage.js +42 -0
  169. package/dist/scene/WaterRenderer.d.ts +2 -0
  170. package/dist/scene/WaterRenderer.js +61 -0
  171. package/dist/scene/builtinSceneKindRenderers.d.ts +2 -0
  172. package/dist/scene/builtinSceneKindRenderers.js +26 -0
  173. package/dist/scene/cityWindowMaterial.d.ts +16 -0
  174. package/dist/scene/cityWindowMaterial.js +81 -0
  175. package/dist/scene/sceneKindRenderers.d.ts +30 -0
  176. package/dist/scene/sceneKindRenderers.js +17 -0
  177. package/dist/scene.d.ts +1 -0
  178. package/dist/scene.js +1 -0
  179. package/dist/settings/appliedSettings.d.ts +7 -1
  180. package/dist/settings/appliedSettings.js +13 -2
  181. package/dist/settings/settingsController.d.ts +2 -0
  182. package/dist/settings/settingsController.js +22 -1
  183. package/dist/shellConstants.d.ts +4 -0
  184. package/dist/shellConstants.js +4 -0
  185. package/dist/shellMovement.d.ts +8 -0
  186. package/dist/shellMovement.js +11 -0
  187. package/dist/shellPointer.d.ts +20 -0
  188. package/dist/shellPointer.js +28 -0
  189. package/dist/structures/PlacementGhost.d.ts +4 -0
  190. package/dist/structures/PlacementGhost.js +4 -0
  191. package/dist/structures/TransformGizmo.d.ts +53 -0
  192. package/dist/structures/TransformGizmo.js +59 -0
  193. package/dist/structures/index.d.ts +1 -0
  194. package/dist/structures/index.js +1 -0
  195. package/dist/terrain/GrassField.d.ts +11 -3
  196. package/dist/terrain/GrassField.js +16 -6
  197. package/dist/terrain/TerraformBrushCursor.js +3 -0
  198. package/dist/terrain/grassBudget.d.ts +11 -2
  199. package/dist/terrain/grassBudget.js +20 -4
  200. package/dist/terrain/grassGeometry.d.ts +35 -0
  201. package/dist/terrain/grassGeometry.js +85 -20
  202. package/dist/terrain/grassMaterial.d.ts +20 -0
  203. package/dist/terrain/grassMaterial.js +68 -13
  204. package/dist/terrain/heightfieldGeometry.d.ts +34 -0
  205. package/dist/terrain/heightfieldGeometry.js +109 -0
  206. package/dist/terrain/index.d.ts +1 -0
  207. package/dist/terrain/index.js +1 -0
  208. package/dist/terrain/random.d.ts +3 -0
  209. package/dist/terrain/random.js +3 -0
  210. package/dist/terrain/soilPatchMaterial.d.ts +23 -0
  211. package/dist/terrain/soilPatchMaterial.js +147 -0
  212. package/dist/terrain/terrainDetailMaterial.d.ts +19 -2
  213. package/dist/terrain/terrainDetailMaterial.js +87 -7
  214. package/dist/terrain/terrainMath.d.ts +8 -1
  215. package/dist/terrain/terrainMath.js +8 -1
  216. package/dist/touch/TouchControlsOverlay.d.ts +9 -5
  217. package/dist/touch/TouchControlsOverlay.js +201 -22
  218. package/dist/useShellMultiplayerSync.d.ts +9 -0
  219. package/dist/useShellMultiplayerSync.js +98 -0
  220. package/dist/visibility/CullingProvider.js +1 -1
  221. package/dist/water/Ocean.d.ts +7 -1
  222. package/dist/water/Ocean.js +16 -3
  223. package/dist/water/OceanConfig.d.ts +11 -0
  224. package/dist/water/OceanConfig.js +8 -0
  225. package/dist/water/OceanMaterial.d.ts +12 -0
  226. package/dist/water/OceanMaterial.js +9 -0
  227. package/dist/water/OceanShader.d.ts +2 -2
  228. package/dist/water/OceanShader.js +78 -10
  229. package/dist/weather/fireSpreadPose.d.ts +1 -0
  230. package/dist/weather/fireSpreadPose.js +1 -0
  231. package/dist/weather/weatherGeometry.d.ts +1 -0
  232. package/dist/weather/weatherGeometry.js +1 -0
  233. package/dist/weather/weatherMath.d.ts +3 -0
  234. package/dist/weather/weatherMath.js +3 -0
  235. package/dist/weather/weatherUniforms.d.ts +3 -0
  236. package/dist/weather/weatherUniforms.js +3 -0
  237. package/dist/world/PlaceScene.d.ts +11 -0
  238. package/dist/world/PlaceScene.js +27 -0
  239. package/dist/world/RetainedVfx.d.ts +10 -0
  240. package/dist/world/RetainedVfx.js +136 -0
  241. package/dist/world/WorldHud.d.ts +49 -2
  242. package/dist/world/WorldHud.js +87 -2
  243. package/dist/world/WorldScene.d.ts +21 -0
  244. package/dist/world/WorldScene.js +186 -0
  245. package/dist/world/WorldVfx.d.ts +9 -0
  246. package/dist/world/WorldVfx.js +184 -0
  247. package/dist/world/entityPose.d.ts +2 -0
  248. package/dist/world/entityPose.js +2 -0
  249. package/dist/world/floatTextStyle.d.ts +1 -0
  250. package/dist/world/floatTextStyle.js +1 -0
  251. package/dist/world/telegraphPulse.d.ts +1 -0
  252. package/dist/world/telegraphPulse.js +1 -0
  253. package/dist/world/worldBarSamples.d.ts +25 -0
  254. package/dist/world/worldBarSamples.js +38 -0
  255. package/dist/worldSky.d.ts +11 -0
  256. package/dist/worldSky.js +21 -0
  257. package/dist/worldSync.d.ts +2 -1
  258. package/dist/worldSync.js +2 -1
  259. package/package.json +6 -6
  260. package/skills/game-design/SKILL.md +28 -0
  261. package/skills/game-design/agents/openai.yaml +4 -0
  262. package/skills/game-design/references/application-playbook.md +170 -0
  263. package/skills/game-design/references/field-guide.md +288 -0
  264. package/skills/jgengine/SKILL.md +54 -0
  265. package/skills/jgengine/api.md +527 -0
  266. package/skills/jgengine/capabilities.md +131 -0
  267. package/skills/jgengine/recipes/minimal-game.md +88 -0
  268. package/skills/jgengine-ui/SKILL.md +41 -0
  269. package/skills/jgengine-ui/api.md +2007 -0
  270. package/skills/jgengine-ui/capabilities.md +211 -0
  271. package/skills/jgengine-ui/recipes/portable-minimap.md +150 -0
  272. package/skills/jgengine-ui/reference.md +513 -0
  273. package/skills/jgengine-ui/references/visual-scorecard.md +73 -0
  274. package/skills/jgengine-world/SKILL.md +51 -0
  275. package/skills/jgengine-world/api.md +3056 -0
  276. package/skills/jgengine-world/capabilities.md +476 -0
  277. package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
  278. package/skills/jgengine-world/reference.md +261 -0
  279. package/skills/level-design/SKILL.md +54 -0
  280. package/skills/level-design/agents/openai.yaml +4 -0
  281. package/skills/level-design/references/application-playbook.md +192 -0
  282. package/skills/level-design/references/field-guide.md +337 -0
  283. package/skills/level-design/references/live-editor-loop.md +179 -0
  284. package/dist/cartridge.d.ts +0 -145
  285. package/dist/cartridge.js +0 -246
  286. package/llms.txt +0 -2761
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo, useRef } from "react";
2
+ import { useMemo, useRef, useState, } from "react";
3
3
  import { createGestureSurfaceTracker } from "@jgengine/core/input/gestureSurface";
4
4
  import { createTouchGestureTracker } from "@jgengine/core/input/touchGestures";
5
- import { touchCode } from "@jgengine/core/input/touchScheme";
5
+ import { touchCode, } from "@jgengine/core/input/touchScheme";
6
6
  import { GameIcon, iconForAction, isGameIconName } from "@jgengine/react/gameIcons";
7
7
  import { useRegisterLayoutRegion } from "@jgengine/react/gameViewport";
8
8
  const MOVEMENT_ZONE = { id: "control:movement", kind: "control", collisionPolicy: "forbid", collisionGroup: "touch-dock" };
@@ -14,6 +14,7 @@ const JOYSTICK_DEADZONE = 0.28;
14
14
  const JOYSTICK_AXIS_THRESHOLD = 0.42;
15
15
  const LOOK_TAP_TUNING = { tapMoveThresholdPx: 12, tapMaxMs: 280, longPressMs: 450 };
16
16
  const BUTTON_SIZE = 56;
17
+ const RAIL_BUTTON_SIZE = 64;
17
18
  const RING1_RADIUS = 112;
18
19
  const RING2_RADIUS = 178;
19
20
  const RING1_CAPACITY = 3;
@@ -21,6 +22,74 @@ const RING2_CAPACITY = 5;
21
22
  const RING1_ANGLES = [[], [40], [16, 64], [12, 44, 76]];
22
23
  const DOCK_BASE_PADDING = 20;
23
24
  const UTILITY_ROW_HEIGHT = 28;
25
+ /** width:height footprint of each silhouette; the longer side equals the button size. */
26
+ const SHAPE_ASPECT = {
27
+ circle: { w: 1, h: 1 },
28
+ square: { w: 1, h: 1 },
29
+ wheel: { w: 1, h: 1 },
30
+ pedal: { w: 0.78, h: 1 },
31
+ lever: { w: 0.64, h: 1 },
32
+ trigger: { w: 0.92, h: 1 },
33
+ tab: { w: 1.5, h: 0.72 },
34
+ };
35
+ const STYLE_TOKENS = {
36
+ glass: {
37
+ restFill: "rgba(255,255,255,0.10)",
38
+ restStroke: "rgba(255,255,255,0.28)",
39
+ strokeWidth: 2,
40
+ activeFill: "rgba(255,255,255,0.34)",
41
+ activeStroke: "rgba(255,255,255,0.7)",
42
+ text: "rgba(255,255,255,0.92)",
43
+ wrapper: "drop-shadow-[0_1px_3px_rgba(0,0,0,0.35)]",
44
+ activeWrapper: "drop-shadow-[0_1px_5px_rgba(0,0,0,0.4)]",
45
+ joyBase: { border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)", backdropFilter: "blur(4px)" },
46
+ joyThumb: { border: "1px solid rgba(255,255,255,0.3)", background: "rgba(255,255,255,0.25)" },
47
+ chipRest: { border: "1px solid rgba(255,255,255,0.2)", background: "rgba(0,0,0,0.4)", color: "rgba(255,255,255,0.8)", backdropFilter: "blur(4px)" },
48
+ chipActive: { border: "1px solid rgba(255,255,255,0.5)", background: "rgba(255,255,255,0.25)", color: "#fff" },
49
+ },
50
+ arcade: {
51
+ restFill: "rgba(9,12,20,0.82)",
52
+ restStroke: "rgba(120,224,255,0.75)",
53
+ strokeWidth: 3,
54
+ activeFill: "rgba(120,224,255,0.9)",
55
+ activeStroke: "rgba(240,255,255,0.95)",
56
+ text: "rgba(190,244,255,0.95)",
57
+ wrapper: "drop-shadow-[0_0_9px_rgba(80,200,255,0.5)]",
58
+ activeWrapper: "drop-shadow-[0_0_16px_rgba(120,224,255,0.85)]",
59
+ joyBase: { border: "3px solid rgba(120,224,255,0.7)", background: "rgba(9,12,20,0.8)", boxShadow: "0 0 16px rgba(80,200,255,0.4)" },
60
+ joyThumb: { border: "2px solid rgba(240,255,255,0.9)", background: "rgba(120,224,255,0.85)" },
61
+ chipRest: { border: "2px solid rgba(120,224,255,0.6)", background: "rgba(9,12,20,0.85)", color: "rgba(190,244,255,0.95)", fontWeight: 700 },
62
+ chipActive: { border: "2px solid rgba(240,255,255,0.95)", background: "rgba(120,224,255,0.9)", color: "#04121a", fontWeight: 700 },
63
+ },
64
+ mechanical: {
65
+ restFill: "rgba(63,68,78,0.9)",
66
+ restStroke: "rgba(210,214,222,0.4)",
67
+ strokeWidth: 2.5,
68
+ activeFill: "rgba(120,126,138,0.95)",
69
+ activeStroke: "rgba(240,242,246,0.7)",
70
+ text: "rgba(232,234,240,0.95)",
71
+ wrapper: "drop-shadow-[0_2px_4px_rgba(0,0,0,0.55)]",
72
+ activeWrapper: "drop-shadow-[0_1px_2px_rgba(0,0,0,0.7)]",
73
+ joyBase: { border: "2px solid rgba(210,214,222,0.35)", background: "linear-gradient(180deg,rgba(78,84,94,0.92),rgba(44,48,56,0.92))", boxShadow: "0 3px 8px rgba(0,0,0,0.5)" },
74
+ joyThumb: { border: "2px solid rgba(240,242,246,0.5)", background: "linear-gradient(180deg,rgba(150,156,166,0.95),rgba(96,102,112,0.95))" },
75
+ chipRest: { border: "1px solid rgba(210,214,222,0.35)", background: "linear-gradient(180deg,rgba(72,78,88,0.92),rgba(40,44,52,0.92))", color: "rgba(232,234,240,0.95)" },
76
+ chipActive: { border: "1px solid rgba(240,242,246,0.6)", background: "rgba(120,126,138,0.95)", color: "#fff" },
77
+ },
78
+ minimal: {
79
+ restFill: "rgba(0,0,0,0.001)",
80
+ restStroke: "rgba(255,255,255,0.45)",
81
+ strokeWidth: 1.75,
82
+ activeFill: "rgba(255,255,255,0.14)",
83
+ activeStroke: "rgba(255,255,255,0.85)",
84
+ text: "rgba(255,255,255,0.85)",
85
+ wrapper: "",
86
+ activeWrapper: "",
87
+ joyBase: { border: "1.5px solid rgba(255,255,255,0.4)", background: "transparent" },
88
+ joyThumb: { border: "1.5px solid rgba(255,255,255,0.6)", background: "rgba(255,255,255,0.1)" },
89
+ chipRest: { border: "1px solid rgba(255,255,255,0.35)", background: "transparent", color: "rgba(255,255,255,0.8)" },
90
+ chipActive: { border: "1px solid rgba(255,255,255,0.8)", background: "rgba(255,255,255,0.14)", color: "#fff" },
91
+ },
92
+ };
24
93
  function polar(radius, deg, buttonSize) {
25
94
  const rad = (deg * Math.PI) / 180;
26
95
  return {
@@ -63,23 +132,38 @@ function primaryClusterExtent(count, scale = 1) {
63
132
  return (RING2_RADIUS + BUTTON_SIZE / 2) * scale;
64
133
  return Math.ceil(count / 4) * (BUTTON_SIZE + 12) * scale;
65
134
  }
135
+ const BOTTOM_ANCHORS = new Set(["bottom-left", "bottom-center", "bottom-right"]);
136
+ const ARC_ANCHORS = new Set(["bottom-left", "bottom-right"]);
66
137
  /**
67
- * Vertical space (px, excluding device safe areas) the dock occupies above
68
- * the bottom edge. The shell publishes it as `--jg-hud-dock-clearance` so
69
- * `HudCanvas` regions never collide with touch controls.
138
+ * Vertical space (px, excluding device safe areas) that *bottom-docked* clusters
139
+ * occupy above the bottom edge. The shell publishes it as
140
+ * `--jg-hud-dock-clearance` so `HudCanvas` regions never collide with touch
141
+ * controls. Side rails and top clusters reserve their own rectangles through the
142
+ * layout registry instead of this scalar.
70
143
  */
71
144
  export function touchDockClearance(scheme, scale = 1) {
72
145
  if (scheme === null)
73
146
  return 0;
147
+ const layout = scheme.layout;
74
148
  let primary = 0;
75
149
  let utility = 0;
76
150
  for (const button of scheme.buttons) {
151
+ if (button.anchor !== null)
152
+ continue;
77
153
  if (button.kind === "utility")
78
154
  utility += 1;
79
155
  else
80
156
  primary += 1;
81
157
  }
82
- const tallest = Math.max(scheme.joystick !== null ? JOYSTICK_SIZE * scale : 0, primaryClusterExtent(primary, scale), utility > 0 ? UTILITY_ROW_HEIGHT : 0);
158
+ const heights = [];
159
+ if (scheme.joystick !== null && BOTTOM_ANCHORS.has(layout.movement))
160
+ heights.push(JOYSTICK_SIZE * scale);
161
+ if (primary > 0 && BOTTOM_ANCHORS.has(layout.actions)) {
162
+ heights.push(ARC_ANCHORS.has(layout.actions) ? primaryClusterExtent(primary, scale) : RAIL_BUTTON_SIZE * scale + 12);
163
+ }
164
+ if (utility > 0 && BOTTOM_ANCHORS.has(layout.utility))
165
+ heights.push(UTILITY_ROW_HEIGHT);
166
+ const tallest = heights.length === 0 ? 0 : Math.max(...heights);
83
167
  if (tallest === 0)
84
168
  return 0;
85
169
  return tallest + DOCK_BASE_PADDING + 12;
@@ -158,7 +242,7 @@ function joystickDirections(joystick, nx, ny) {
158
242
  active.add(joystick.right);
159
243
  return active;
160
244
  }
161
- function VirtualJoystick({ joystick, sink, scale = 1 }) {
245
+ function VirtualJoystick({ joystick, sink, tokens, scale = 1, }) {
162
246
  const baseRef = useRef(null);
163
247
  const thumbRef = useRef(null);
164
248
  const pointerIdRef = useRef(null);
@@ -200,7 +284,7 @@ function VirtualJoystick({ joystick, sink, scale = 1 }) {
200
284
  if (thumbRef.current !== null)
201
285
  thumbRef.current.style.transform = "translate(0px, 0px)";
202
286
  };
203
- return (_jsx("div", { ref: baseRef, className: "pointer-events-auto relative flex touch-none select-none items-center justify-center rounded-full border border-white/20 bg-white/10 backdrop-blur-sm", style: { width: size, height: size }, onPointerDown: (event) => {
287
+ return (_jsx("div", { ref: baseRef, className: "pointer-events-auto relative flex touch-none select-none items-center justify-center rounded-full", style: { width: size, height: size, ...tokens.joyBase }, onPointerDown: (event) => {
204
288
  if (pointerIdRef.current !== null)
205
289
  return;
206
290
  pointerIdRef.current = event.pointerId;
@@ -216,7 +300,7 @@ function VirtualJoystick({ joystick, sink, scale = 1 }) {
216
300
  return;
217
301
  event.stopPropagation();
218
302
  releaseAll();
219
- }, onPointerCancel: () => releaseAll(), children: _jsx("div", { ref: thumbRef, className: "rounded-full border border-white/30 bg-white/25", style: { width: thumbSize, height: thumbSize } }) }));
303
+ }, onPointerCancel: () => releaseAll(), children: _jsx("div", { ref: thumbRef, className: "rounded-full", style: { width: thumbSize, height: thumbSize, ...tokens.joyThumb } }) }));
220
304
  }
221
305
  function touchButtonIcon(button) {
222
306
  if (button.icon === false)
@@ -225,65 +309,160 @@ function touchButtonIcon(button) {
225
309
  return button.icon;
226
310
  return iconForAction(button.action);
227
311
  }
228
- function TouchActionButton({ button, sink, scale = 1 }) {
312
+ function ShapeSilhouette({ shape, width, height, fill, stroke, strokeWidth, }) {
313
+ const common = { fill, stroke, strokeWidth, strokeLinejoin: "round", strokeLinecap: "round" };
314
+ const body = (() => {
315
+ switch (shape) {
316
+ case "circle":
317
+ return _jsx("circle", { cx: 50, cy: 50, r: 46, ...common });
318
+ case "square":
319
+ return _jsx("rect", { x: 7, y: 7, width: 86, height: 86, rx: 20, ...common });
320
+ case "wheel":
321
+ return (_jsxs("g", { ...common, children: [_jsx("circle", { cx: 50, cy: 50, r: 44, fill: fill }), _jsx("circle", { cx: 50, cy: 50, r: 16, fill: "none" }), _jsx("path", { d: "M50 34 V18 M36 58 L22 72 M64 58 L78 72", fill: "none" })] }));
322
+ case "pedal":
323
+ return (_jsxs("g", { ...common, children: [_jsx("path", { d: "M30 6 h40 a10 10 0 0 1 10 10 v58 a16 16 0 0 1 -16 16 h-38 a16 16 0 0 1 -16 -16 v-58 a10 10 0 0 1 10 -10 Z" }), _jsx("path", { d: "M26 26 h48 M26 42 h48 M26 58 h48", fill: "none", strokeWidth: strokeWidth * 0.8 })] }));
324
+ case "lever":
325
+ return (_jsxs("g", { ...common, children: [_jsx("circle", { cx: 50, cy: 22, r: 18 }), _jsx("path", { d: "M42 36 h16 v52 a8 8 0 0 1 -16 0 Z" })] }));
326
+ case "trigger":
327
+ return _jsx("path", { d: "M18 20 h44 a24 24 0 0 1 24 24 v10 a30 30 0 0 1 -30 30 h-24 a14 14 0 0 1 -14 -14 v-40 a10 10 0 0 1 0 -10 Z", ...common });
328
+ case "tab":
329
+ return _jsx("path", { d: "M6 18 a12 12 0 0 1 12 -12 h124 a12 12 0 0 1 12 12 v20 a12 12 0 0 1 -12 12 h-124 a12 12 0 0 1 -12 -12 Z", ...common });
330
+ }
331
+ })();
332
+ const viewBox = shape === "tab" ? "0 0 160 56" : "0 0 100 100";
333
+ return (_jsx("svg", { width: width, height: height, viewBox: viewBox, className: "pointer-events-none absolute inset-0", children: body }));
334
+ }
335
+ function TouchActionButton({ button, sink, tokens, size, fit, }) {
229
336
  const pointerIdRef = useRef(null);
337
+ const [pressed, setPressed] = useState(false);
230
338
  const icon = touchButtonIcon(button);
231
- const size = BUTTON_SIZE * scale;
339
+ const aspect = SHAPE_ASPECT[button.shape];
340
+ const svgW = size * aspect.w;
341
+ const svgH = size * aspect.h;
342
+ const boxW = fit === "box" ? size : svgW;
343
+ const boxH = fit === "box" ? size : svgH;
344
+ const iconSize = Math.round(Math.min(svgW, svgH) * 0.46);
232
345
  const releaseIfHeld = () => {
233
346
  if (pointerIdRef.current === null)
234
347
  return;
235
348
  pointerIdRef.current = null;
349
+ setPressed(false);
236
350
  sink.onCodeUp(touchCode(button.action));
237
351
  };
238
- return (_jsx("button", { type: "button", "aria-label": button.label, style: { width: size, height: size }, className: "pointer-events-auto flex touch-none select-none items-center justify-center rounded-full border border-white/25 bg-white/10 text-center text-[10px] font-semibold uppercase leading-tight tracking-wide text-white/90 backdrop-blur-sm active:border-white/50 active:bg-white/30", onPointerDown: (event) => {
352
+ return (_jsx("button", { type: "button", "aria-label": button.label, "data-touch-shape": button.shape, style: { width: boxW, height: boxH, transform: pressed ? "scale(0.93)" : "scale(1)", transition: "transform 90ms" }, className: `pointer-events-auto relative flex touch-none select-none items-center justify-center ${pressed ? tokens.activeWrapper : tokens.wrapper}`, onPointerDown: (event) => {
239
353
  if (pointerIdRef.current !== null)
240
354
  return;
241
355
  pointerIdRef.current = event.pointerId;
242
356
  event.currentTarget.setPointerCapture(event.pointerId);
357
+ setPressed(true);
243
358
  sink.onCodeDown(touchCode(button.action));
244
359
  }, onPointerUp: (event) => {
245
360
  event.stopPropagation();
246
361
  releaseIfHeld();
247
- }, onPointerCancel: () => releaseIfHeld(), onContextMenu: (event) => event.preventDefault(), children: icon !== null ? _jsx(GameIcon, { name: icon, size: Math.round(26 * scale) }) : _jsx("span", { className: "px-1", children: button.label }) }));
362
+ }, onPointerCancel: () => releaseIfHeld(), onContextMenu: (event) => event.preventDefault(), children: _jsxs("div", { className: "relative", style: { width: svgW, height: svgH }, children: [button.image !== null ? (_jsx("img", { src: button.image, alt: "", draggable: false, className: "pointer-events-none absolute inset-0 h-full w-full select-none object-contain", style: { filter: pressed ? "brightness(1.25)" : undefined } })) : (_jsx(ShapeSilhouette, { shape: button.shape, width: svgW, height: svgH, fill: pressed ? tokens.activeFill : tokens.restFill, stroke: pressed ? tokens.activeStroke : tokens.restStroke, strokeWidth: tokens.strokeWidth })), _jsx("span", { className: "absolute inset-0 flex items-center justify-center text-center text-[10px] font-semibold uppercase leading-tight tracking-wide", style: { color: tokens.text }, children: icon !== null ? _jsx(GameIcon, { name: icon, size: iconSize }) : _jsx("span", { className: "px-1", children: button.label }) })] }) }));
248
363
  }
249
- function TouchUtilityChip({ button, sink }) {
364
+ function TouchUtilityChip({ button, sink, tokens }) {
250
365
  const pointerIdRef = useRef(null);
366
+ const [pressed, setPressed] = useState(false);
251
367
  const releaseIfHeld = () => {
252
368
  if (pointerIdRef.current === null)
253
369
  return;
254
370
  pointerIdRef.current = null;
371
+ setPressed(false);
255
372
  sink.onCodeUp(touchCode(button.action));
256
373
  };
257
- return (_jsx("button", { type: "button", "aria-label": button.label, className: "pointer-events-auto flex h-7 touch-none select-none items-center rounded-full border border-white/20 bg-black/40 px-3 text-[10px] font-semibold uppercase tracking-wide text-white/80 backdrop-blur-sm active:border-white/50 active:bg-white/25", onPointerDown: (event) => {
374
+ return (_jsx("button", { type: "button", "aria-label": button.label, style: pressed ? tokens.chipActive : tokens.chipRest, className: "pointer-events-auto flex h-7 touch-none select-none items-center rounded-full px-3 text-[10px] font-semibold uppercase tracking-wide", onPointerDown: (event) => {
258
375
  if (pointerIdRef.current !== null)
259
376
  return;
260
377
  pointerIdRef.current = event.pointerId;
261
378
  event.currentTarget.setPointerCapture(event.pointerId);
379
+ setPressed(true);
262
380
  sink.onCodeDown(touchCode(button.action));
263
381
  }, onPointerUp: (event) => {
264
382
  event.stopPropagation();
265
383
  releaseIfHeld();
266
384
  }, onPointerCancel: () => releaseIfHeld(), onContextMenu: (event) => event.preventDefault(), children: button.label }));
267
385
  }
268
- function PrimaryButtonCluster({ buttons, sink, scale = 1 }) {
386
+ function ThumbArcCluster({ buttons, sink, tokens, scale = 1, }) {
269
387
  const offsets = primaryButtonOffsets(buttons.length, scale);
270
388
  if (offsets === null) {
271
- return (_jsx("div", { className: "flex max-w-[60%] flex-wrap items-end justify-end gap-3", children: buttons.map((button) => (_jsx(TouchActionButton, { button: button, sink: sink, scale: scale }, button.action))) }));
389
+ return (_jsx("div", { className: "flex max-w-[60%] flex-wrap items-end justify-end gap-3", children: buttons.map((button) => (_jsx(TouchActionButton, { button: button, sink: sink, tokens: tokens, size: BUTTON_SIZE * scale, fit: "box" }, button.action))) }));
272
390
  }
273
391
  const extent = primaryClusterExtent(buttons.length, scale);
274
392
  return (_jsx("div", { className: "pointer-events-none relative", style: { width: extent, height: extent }, children: buttons.map((button, index) => {
275
393
  const offset = offsets[index];
276
- return (_jsx("div", { className: "absolute", style: { right: offset?.right ?? 0, bottom: offset?.bottom ?? 0 }, children: _jsx(TouchActionButton, { button: button, sink: sink, scale: scale }) }, button.action));
394
+ return (_jsx("div", { className: "absolute", style: { right: offset?.right ?? 0, bottom: offset?.bottom ?? 0 }, children: _jsx(TouchActionButton, { button: button, sink: sink, tokens: tokens, size: BUTTON_SIZE * scale, fit: "box" }) }, button.action));
277
395
  }) }));
278
396
  }
279
- export function TouchControlsDock({ scheme, sink, scale = 1 }) {
280
- const primary = scheme.buttons.filter((button) => button.kind !== "utility");
281
- const utility = scheme.buttons.filter((button) => button.kind === "utility");
397
+ /** Vertical rail (`left`/`right`) or a wrapping row for the remaining anchors. */
398
+ function StackCluster({ buttons, utility, sink, tokens, column, scale = 1, }) {
399
+ return (_jsxs("div", { className: `flex ${column ? "flex-col" : "flex-row flex-wrap"} items-center justify-center gap-3`, children: [buttons.map((button) => (_jsx(TouchActionButton, { button: button, sink: sink, tokens: tokens, size: RAIL_BUTTON_SIZE * scale, fit: "natural" }, button.action))), utility.map((button) => (_jsx(TouchUtilityChip, { button: button, sink: sink, tokens: tokens }, button.action)))] }));
400
+ }
401
+ const ANCHOR_POSITION = {
402
+ "bottom-left": { bottom: 0, left: 0 },
403
+ "bottom-center": { bottom: 0, left: "50%", transform: "translateX(-50%)" },
404
+ "bottom-right": { bottom: 0, right: 0 },
405
+ left: { top: "50%", left: 0, transform: "translateY(-50%)" },
406
+ right: { top: "50%", right: 0, transform: "translateY(-50%)" },
407
+ "top-left": { top: 0, left: 0 },
408
+ "top-center": { top: 0, left: "50%", transform: "translateX(-50%)" },
409
+ "top-right": { top: 0, right: 0 },
410
+ };
411
+ function anchorInset(anchor) {
412
+ const safeBottom = "env(safe-area-inset-bottom, 0px)";
413
+ const safeTop = "env(safe-area-inset-top, 0px)";
414
+ const safeLeft = "env(safe-area-inset-left, 0px)";
415
+ const safeRight = "env(safe-area-inset-right, 0px)";
416
+ const pad = `${DOCK_BASE_PADDING}px`;
417
+ const style = {};
418
+ if (anchor.startsWith("bottom"))
419
+ style.paddingBottom = `calc(${safeBottom} + ${pad})`;
420
+ if (anchor.startsWith("top"))
421
+ style.paddingTop = `calc(${safeTop} + ${pad})`;
422
+ if (anchor === "left" || anchor.endsWith("left"))
423
+ style.paddingLeft = `calc(${safeLeft} + ${pad})`;
424
+ if (anchor === "right" || anchor.endsWith("right"))
425
+ style.paddingRight = `calc(${safeRight} + ${pad})`;
426
+ if (anchor === "bottom-center" || anchor === "top-center") {
427
+ style.paddingLeft = `calc(${safeLeft} + ${pad})`;
428
+ style.paddingRight = `calc(${safeRight} + ${pad})`;
429
+ }
430
+ return style;
431
+ }
432
+ function AnchoredSlot({ anchor, children, slotRef, }) {
433
+ return (_jsx("div", { className: "pointer-events-none absolute z-40", style: { ...ANCHOR_POSITION[anchor], ...anchorInset(anchor) }, children: _jsx("div", { ref: slotRef, children: children }) }));
434
+ }
435
+ function bucketByAnchor(buttons) {
436
+ const map = new Map();
437
+ for (const button of buttons) {
438
+ if (button.anchor === null)
439
+ continue;
440
+ const bucket = map.get(button.anchor) ?? { primary: [], utility: [] };
441
+ if (button.kind === "utility")
442
+ bucket.utility.push(button);
443
+ else
444
+ bucket.primary.push(button);
445
+ map.set(button.anchor, bucket);
446
+ }
447
+ return map;
448
+ }
449
+ export function TouchControlsDock({ scheme, sink, style, scale = 1, }) {
450
+ const tokens = STYLE_TOKENS[style ?? scheme.style];
451
+ const layout = scheme.layout;
452
+ const docked = scheme.buttons.filter((button) => button.anchor === null);
453
+ const primary = docked.filter((button) => button.kind !== "utility");
454
+ const utility = docked.filter((button) => button.kind === "utility");
455
+ const extras = useMemo(() => bucketByAnchor(scheme.buttons), [scheme.buttons]);
282
456
  const joystickRef = useRef(null);
283
457
  const actionsRef = useRef(null);
284
458
  const utilityRef = useRef(null);
285
459
  useRegisterLayoutRegion(MOVEMENT_ZONE, joystickRef, scheme.joystick !== null);
286
460
  useRegisterLayoutRegion(ACTIONS_ZONE, actionsRef, primary.length > 0);
287
461
  useRegisterLayoutRegion(UTILITY_ZONE, utilityRef, utility.length > 0);
288
- return (_jsxs("div", { className: "pointer-events-none absolute inset-x-0 bottom-0 z-40", style: { paddingBottom: `calc(env(safe-area-inset-bottom, 0px) + ${DOCK_BASE_PADDING}px)` }, children: [_jsxs("div", { className: "flex items-end justify-between gap-4 px-5", children: [_jsx("div", { ref: joystickRef, children: scheme.joystick !== null ? _jsx(VirtualJoystick, { joystick: scheme.joystick, sink: sink, scale: scale }) : null }), _jsx("div", { ref: actionsRef, children: _jsx(PrimaryButtonCluster, { buttons: primary, sink: sink, scale: scale }) })] }), utility.length > 0 ? (_jsx("div", { className: "pointer-events-none absolute inset-x-0 flex justify-center", style: { bottom: "calc(env(safe-area-inset-bottom, 0px) + 8px)" }, children: _jsx("div", { ref: utilityRef, className: "flex gap-2", children: utility.map((button) => (_jsx(TouchUtilityChip, { button: button, sink: sink }, button.action))) }) })) : null] }));
462
+ const actionsColumn = layout.actions === "left" || layout.actions === "right";
463
+ const utilityColumn = layout.utility === "left" || layout.utility === "right";
464
+ return (_jsxs(ResolvedLayout, { children: [scheme.joystick !== null ? (_jsx(AnchoredSlot, { anchor: layout.movement, slotRef: joystickRef, children: _jsx(VirtualJoystick, { joystick: scheme.joystick, sink: sink, tokens: tokens, scale: scale }) })) : null, primary.length > 0 ? (_jsx(AnchoredSlot, { anchor: layout.actions, slotRef: actionsRef, children: ARC_ANCHORS.has(layout.actions) ? (_jsx(ThumbArcCluster, { buttons: primary, sink: sink, tokens: tokens, scale: scale })) : (_jsx(StackCluster, { buttons: primary, utility: [], sink: sink, tokens: tokens, column: actionsColumn, scale: scale })) })) : null, utility.length > 0 ? (_jsx(AnchoredSlot, { anchor: layout.utility, slotRef: utilityRef, children: _jsx(StackCluster, { buttons: [], utility: utility, sink: sink, tokens: tokens, column: utilityColumn, scale: scale }) })) : null, [...extras.entries()].map(([anchor, bucket]) => (_jsx(AnchoredSlot, { anchor: anchor, children: _jsx(StackCluster, { buttons: bucket.primary, utility: bucket.utility, sink: sink, tokens: tokens, column: anchor === "left" || anchor === "right", scale: scale }) }, anchor)))] }));
465
+ }
466
+ function ResolvedLayout({ children }) {
467
+ return _jsx("div", { className: "pointer-events-none absolute inset-0 z-40", children: children });
289
468
  }
@@ -0,0 +1,9 @@
1
+ import { type Dispatch, type SetStateAction } from "react";
2
+ import type { GameContext } from "@jgengine/core/runtime/gameContext";
3
+ import type { PresencePoseRow } from "@jgengine/core/runtime/transport";
4
+ import type { ShellMultiplayer } from "./multiplayer.js";
5
+ import type { PlayableGame } from "./registry.js";
6
+ /** Joins the multiplayer server for the live context and wires presence, feed relay, and chat sync until teardown. */
7
+ export declare function useShellMultiplayerSync(ctx: GameContext | null, multiplayer: ShellMultiplayer | null, playable: PlayableGame, serverIdRef: {
8
+ current: string | null;
9
+ }, setRemotePlayers: Dispatch<SetStateAction<PresencePoseRow[]>>): void;
@@ -0,0 +1,98 @@
1
+ import { useEffect } from "react";
2
+ import { isServerAuthoritative } from "@jgengine/core/runtime/adapter";
3
+ import { attachWorldSync } from "./worldSync.js";
4
+ /** Joins the multiplayer server for the live context and wires presence, feed relay, and chat sync until teardown. */
5
+ export function useShellMultiplayerSync(ctx, multiplayer, playable, serverIdRef, setRemotePlayers) {
6
+ useEffect(() => {
7
+ if (ctx === null || multiplayer === null)
8
+ return;
9
+ let disposed = false;
10
+ const cleanups = [];
11
+ void multiplayer.backend.transport
12
+ .joinServer({ gameId: multiplayer.gameId })
13
+ .then((joined) => {
14
+ if (disposed) {
15
+ void multiplayer.backend.transport.leaveServer({ serverId: joined.serverId });
16
+ return;
17
+ }
18
+ serverIdRef.current = joined.serverId;
19
+ if (isServerAuthoritative(playable.game.multiplayer) && multiplayer.backend.feeds !== undefined) {
20
+ cleanups.push(attachWorldSync(multiplayer.backend.feeds, joined.serverId, ctx));
21
+ }
22
+ cleanups.push(multiplayer.backend.presenceSync.subscribe(joined.serverId, (rows) => {
23
+ setRemotePlayers(rows.filter((row) => row.userId !== multiplayer.userId));
24
+ }));
25
+ const seen = new Set();
26
+ let injecting = false;
27
+ for (const action of multiplayer.feedActions) {
28
+ cleanups.push(ctx.game.feed.subscribe(action, (entry) => {
29
+ if (injecting)
30
+ return;
31
+ void multiplayer.backend
32
+ .pushFeedEntry({
33
+ serverId: joined.serverId,
34
+ action,
35
+ entry: { at: entry.at, data: entry.data, from: multiplayer.userId },
36
+ })
37
+ .catch(() => undefined);
38
+ }));
39
+ const remoteUnsub = multiplayer.backend.feeds?.subscribeFeed({ serverId: joined.serverId, action }, (view) => {
40
+ for (const raw of view.entries) {
41
+ const remote = raw;
42
+ if (typeof remote.from !== "string" || remote.from === multiplayer.userId)
43
+ continue;
44
+ const key = `${action}|${remote.from}|${remote.at ?? 0}`;
45
+ if (seen.has(key))
46
+ continue;
47
+ seen.add(key);
48
+ injecting = true;
49
+ ctx.game.feed.push(action, remote.data);
50
+ injecting = false;
51
+ }
52
+ });
53
+ if (remoteUnsub !== undefined)
54
+ cleanups.push(remoteUnsub);
55
+ }
56
+ const chatSync = multiplayer.backend.chatSyncFor?.(joined.serverId);
57
+ if (chatSync !== undefined && ctx.game.chat !== undefined) {
58
+ const chat = ctx.game.chat;
59
+ const globalChannelIds = new Set(chat
60
+ .channels()
61
+ .filter((channel) => channel.kind === "global")
62
+ .map((channel) => channel.id));
63
+ const seenRemoteChat = new Set();
64
+ cleanups.push(ctx.game.events.subscribe("chat.message", (event) => {
65
+ if (event.fromUserId !== multiplayer.userId)
66
+ return;
67
+ if (!globalChannelIds.has(event.channelId))
68
+ return;
69
+ void chatSync.send(event.channelId, event.body).catch(() => undefined);
70
+ }));
71
+ for (const channelId of globalChannelIds) {
72
+ cleanups.push(chatSync.subscribe(channelId, (messages) => {
73
+ for (const message of messages) {
74
+ if (message.fromUserId === multiplayer.userId)
75
+ continue;
76
+ if (seenRemoteChat.has(message.id))
77
+ continue;
78
+ seenRemoteChat.add(message.id);
79
+ chat.send(message.fromUserId, message.channelId, message.body);
80
+ }
81
+ }));
82
+ }
83
+ }
84
+ })
85
+ .catch(() => undefined);
86
+ return () => {
87
+ disposed = true;
88
+ for (const cleanup of cleanups)
89
+ cleanup();
90
+ const serverId = serverIdRef.current;
91
+ serverIdRef.current = null;
92
+ setRemotePlayers([]);
93
+ if (serverId !== null) {
94
+ void multiplayer.backend.transport.leaveServer({ serverId }).catch(() => undefined);
95
+ }
96
+ };
97
+ }, [ctx, multiplayer, playable]);
98
+ }
@@ -4,7 +4,7 @@ import { useThree, useFrame } from "@react-three/fiber";
4
4
  import * as THREE from "three";
5
5
  import { useGameContext } from "@jgengine/react/provider";
6
6
  import { createVisibilitySystem } from "@jgengine/core/visibility/visibilitySystem";
7
- import { CAMERA_POST_FRAME_PRIORITY } from "../camera/index.js";
7
+ import { CAMERA_POST_FRAME_PRIORITY } from "../camera/cameraRigs.js";
8
8
  const ALWAYS_VISIBLE = () => true;
9
9
  const alwaysVisibleRef = { current: ALWAYS_VISIBLE };
10
10
  const CullingContext = createContext(null);
@@ -2,5 +2,11 @@ import { type ThreeElements } from "@react-three/fiber";
2
2
  import { type OceanConfig } from "./OceanConfig.js";
3
3
  export interface OceanProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material"> {
4
4
  config?: OceanConfig;
5
+ /**
6
+ * Water-column depth (m) under a point of the sheet, in the sheet's local XZ. When provided,
7
+ * the shader shades shallow→deep by real depth, foams and turns transparent at the shore, and
8
+ * damps waves as the bed shoals. Omit for an open ocean (everywhere-deep behavior).
9
+ */
10
+ depthAt?: (x: number, z: number) => number;
5
11
  }
6
- export declare function Ocean({ config, ...meshProps }: OceanProps): import("react").JSX.Element;
12
+ export declare function Ocean({ config, depthAt, ...meshProps }: OceanProps): import("react").JSX.Element;
@@ -4,16 +4,29 @@ import { useEffect, useMemo, useRef } from "react";
4
4
  import * as THREE from "three";
5
5
  import { createOceanConfig } from "./OceanConfig.js";
6
6
  import { createOceanMaterial, syncOceanMaterial } from "./OceanMaterial.js";
7
- function createOceanGeometry(config) {
7
+ /** aDepth when no sampler is given: everywhere-deep, matching the pre-depth shader look. */
8
+ const OCEAN_DEFAULT_DEPTH = 1000;
9
+ function createOceanGeometry(config, depthAt) {
8
10
  const geometry = new THREE.PlaneGeometry(config.size, config.depth, config.resolution, config.resolution);
9
11
  geometry.rotateX(-Math.PI / 2);
12
+ const positions = geometry.getAttribute("position");
13
+ const depths = new Float32Array(positions.count);
14
+ if (depthAt === undefined) {
15
+ depths.fill(OCEAN_DEFAULT_DEPTH);
16
+ }
17
+ else {
18
+ for (let i = 0; i < positions.count; i += 1) {
19
+ depths[i] = Math.max(0, depthAt(positions.getX(i), positions.getZ(i)));
20
+ }
21
+ }
22
+ geometry.setAttribute("aDepth", new THREE.BufferAttribute(depths, 1));
10
23
  geometry.computeBoundingSphere();
11
24
  return geometry;
12
25
  }
13
- export function Ocean({ config, ...meshProps }) {
26
+ export function Ocean({ config, depthAt, ...meshProps }) {
14
27
  const resolved = useMemo(() => createOceanConfig(config), [config]);
15
28
  const elapsedRef = useRef(0);
16
- const geometry = useMemo(() => createOceanGeometry(resolved), [resolved]);
29
+ const geometry = useMemo(() => createOceanGeometry(resolved, depthAt), [resolved, depthAt]);
17
30
  const material = useMemo(() => createOceanMaterial(resolved), [resolved]);
18
31
  useEffect(() => {
19
32
  return () => geometry.dispose();
@@ -21,12 +21,18 @@ export interface OceanColorConfig {
21
21
  opacity?: number;
22
22
  fresnelStrength?: number;
23
23
  horizonBlend?: number;
24
+ /** Water depth (m) at which the body color reaches the full `deep` tone. Default 2.6. */
25
+ depthRange?: number;
26
+ /** Sun-glint sparkle intensity, 0 disables. Default 0.35. */
27
+ sparkle?: number;
24
28
  }
25
29
  export interface OceanFoamConfig {
26
30
  crestThreshold?: number;
27
31
  softness?: number;
28
32
  intensity?: number;
29
33
  coverage?: number;
34
+ /** Shoreline foam lick width (m of water depth it covers). Default 0.5. */
35
+ shoreWidth?: number;
30
36
  }
31
37
  export interface OceanConfig {
32
38
  quality?: OceanQualityPreset;
@@ -59,12 +65,15 @@ export interface ResolvedOceanColorConfig {
59
65
  opacity: number;
60
66
  fresnelStrength: number;
61
67
  horizonBlend: number;
68
+ depthRange: number;
69
+ sparkle: number;
62
70
  }
63
71
  export interface ResolvedOceanFoamConfig {
64
72
  crestThreshold: number;
65
73
  softness: number;
66
74
  intensity: number;
67
75
  coverage: number;
76
+ shoreWidth: number;
68
77
  }
69
78
  export interface ResolvedOceanWaveConfig {
70
79
  amplitude: number;
@@ -96,7 +105,9 @@ export declare const OCEAN_QUALITY_PRESETS: Record<OceanQualityPreset, {
96
105
  resolution: number;
97
106
  }>;
98
107
  export declare const DEFAULT_OCEAN_CONFIG: ResolvedOceanConfig;
108
+ /** @internal */
99
109
  export declare function createOceanConfig(patch?: OceanConfig): ResolvedOceanConfig;
110
+ /** @internal */
100
111
  export declare function buildOceanWaveUniforms(config: ResolvedOceanConfig): {
101
112
  directions: THREE.Vector2[];
102
113
  params: THREE.Vector4[];
@@ -28,12 +28,15 @@ export const DEFAULT_OCEAN_CONFIG = {
28
28
  opacity: 0.88,
29
29
  fresnelStrength: 0.7,
30
30
  horizonBlend: 0.38,
31
+ depthRange: 2.6,
32
+ sparkle: 0.35,
31
33
  },
32
34
  foam: {
33
35
  crestThreshold: 0.64,
34
36
  softness: 0.22,
35
37
  intensity: 0.8,
36
38
  coverage: 0.45,
39
+ shoreWidth: 0.5,
37
40
  },
38
41
  waves: [],
39
42
  };
@@ -87,6 +90,7 @@ function resolveWave(wave, fallback, config, customWaves) {
87
90
  steepness: clamp(finiteOr(wave?.steepness, fallback.steepness) * config.steepness, 0, 1.2),
88
91
  };
89
92
  }
93
+ /** @internal */
90
94
  export function createOceanConfig(patch = {}) {
91
95
  const quality = patch.quality ?? DEFAULT_OCEAN_CONFIG.quality;
92
96
  const preset = OCEAN_QUALITY_PRESETS[quality];
@@ -110,6 +114,8 @@ export function createOceanConfig(patch = {}) {
110
114
  opacity: clamp(finiteOr(patch.color?.opacity, DEFAULT_OCEAN_CONFIG.color.opacity), 0, 1),
111
115
  fresnelStrength: Math.max(0, finiteOr(patch.color?.fresnelStrength, DEFAULT_OCEAN_CONFIG.color.fresnelStrength)),
112
116
  horizonBlend: clamp(finiteOr(patch.color?.horizonBlend, DEFAULT_OCEAN_CONFIG.color.horizonBlend), 0, 1),
117
+ depthRange: Math.max(0.05, finiteOr(patch.color?.depthRange, DEFAULT_OCEAN_CONFIG.color.depthRange)),
118
+ sparkle: Math.max(0, finiteOr(patch.color?.sparkle, DEFAULT_OCEAN_CONFIG.color.sparkle)),
113
119
  },
114
120
  foam: {
115
121
  ...DEFAULT_OCEAN_CONFIG.foam,
@@ -118,6 +124,7 @@ export function createOceanConfig(patch = {}) {
118
124
  softness: clamp(finiteOr(patch.foam?.softness, DEFAULT_OCEAN_CONFIG.foam.softness), 0.001, 1),
119
125
  intensity: Math.max(0, finiteOr(patch.foam?.intensity, DEFAULT_OCEAN_CONFIG.foam.intensity)),
120
126
  coverage: clamp(finiteOr(patch.foam?.coverage, DEFAULT_OCEAN_CONFIG.foam.coverage), 0, 1),
127
+ shoreWidth: Math.max(0, finiteOr(patch.foam?.shoreWidth, DEFAULT_OCEAN_CONFIG.foam.shoreWidth)),
121
128
  },
122
129
  };
123
130
  const defaultWaves = createDefaultWaves(withoutWaves);
@@ -126,6 +133,7 @@ export function createOceanConfig(patch = {}) {
126
133
  const waves = Array.from({ length: MAX_OCEAN_WAVES }, (_, index) => resolveWave(inputWaves[index], defaultWaves[index], withoutWaves, customWaves));
127
134
  return { ...withoutWaves, waves };
128
135
  }
136
+ /** @internal */
129
137
  export function buildOceanWaveUniforms(config) {
130
138
  const directions = [];
131
139
  const params = [];
@@ -46,6 +46,18 @@ export interface OceanMaterialUniforms {
46
46
  uFoamCoverage: {
47
47
  value: number;
48
48
  };
49
+ uDepthRange: {
50
+ value: number;
51
+ };
52
+ uShoreFoamWidth: {
53
+ value: number;
54
+ };
55
+ uSparkle: {
56
+ value: number;
57
+ };
58
+ uSunDirection: {
59
+ value: THREE.Vector3;
60
+ };
49
61
  }
50
62
  export type OceanShaderMaterial = THREE.ShaderMaterial & {
51
63
  uniforms: OceanMaterialUniforms;