@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
@@ -7,48 +7,90 @@ export function createNoiseBuffer(ctx) {
7
7
  data[i] = Math.random() * 2 - 1;
8
8
  return buf;
9
9
  }
10
- function realizeTone(ctx, out, voice, cueGain, at) {
10
+ function realizeTone(ctx, out, voice, cueGain, at, ended) {
11
11
  const t = at + (voice.delay ?? 0);
12
12
  const peak = (voice.gain ?? 1) * cueGain;
13
13
  const attack = voice.attack ?? 0.012;
14
14
  const osc = ctx.createOscillator();
15
- osc.type = voice.wave ?? "sine";
16
- osc.frequency.setValueAtTime(voice.freq, t);
17
- if (voice.slideTo !== undefined)
18
- osc.frequency.exponentialRampToValueAtTime(Math.max(1, voice.slideTo), t + voice.duration);
19
- const sustainEnd = Math.max(attack, voice.sustain ?? 0);
20
- const g = ctx.createGain();
21
- g.gain.setValueAtTime(0, t);
22
- g.gain.linearRampToValueAtTime(peak, t + attack);
23
- if (sustainEnd > attack)
24
- g.gain.setValueAtTime(peak, t + sustainEnd);
25
- g.gain.exponentialRampToValueAtTime(0.001, t + Math.max(voice.duration, sustainEnd + 0.001));
26
- osc.connect(g).connect(out);
27
- osc.start(t);
28
- osc.stop(t + voice.duration + 0.05);
15
+ const nodes = [];
16
+ const stop = trackVoice(osc, nodes, ended);
17
+ try {
18
+ osc.type = voice.wave ?? "sine";
19
+ osc.frequency.setValueAtTime(voice.freq, t);
20
+ if (voice.slideTo !== undefined)
21
+ osc.frequency.exponentialRampToValueAtTime(Math.max(1, voice.slideTo), t + voice.duration);
22
+ const sustainEnd = Math.max(attack, voice.sustain ?? 0);
23
+ const g = ctx.createGain();
24
+ nodes.push(g);
25
+ g.gain.setValueAtTime(0, t);
26
+ g.gain.linearRampToValueAtTime(peak, t + attack);
27
+ if (sustainEnd > attack)
28
+ g.gain.setValueAtTime(peak, t + sustainEnd);
29
+ g.gain.exponentialRampToValueAtTime(0.001, t + Math.max(voice.duration, sustainEnd + 0.001));
30
+ osc.connect(g).connect(out);
31
+ osc.start(t);
32
+ osc.stop(t + voice.duration + 0.05);
33
+ return stop;
34
+ }
35
+ catch (error) {
36
+ stop();
37
+ throw error;
38
+ }
29
39
  }
30
- function realizeNoise(ctx, out, noiseBuf, voice, cueGain, at) {
40
+ function realizeNoise(ctx, out, noiseBuf, voice, cueGain, at, ended) {
31
41
  const t = at + (voice.delay ?? 0);
32
42
  const peak = (voice.gain ?? 1) * cueGain;
33
43
  const decay = voice.decay ?? 0.9;
34
44
  const src = ctx.createBufferSource();
35
- src.buffer = noiseBuf;
36
- src.playbackRate.value = 0.8 + Math.random() * 0.4;
37
- // The shared buffer is 1 s and playback starts at a random offset inside it, so any voice longer
38
- // than the remainder used to run off the end and go silent early. Wrapping keeps it noise throughout;
39
- // the `duration` argument to start() still ends the voice on time.
40
- src.loop = true;
41
- const filter = ctx.createBiquadFilter();
42
- filter.type = voice.filterType ?? "lowpass";
43
- filter.frequency.value = voice.filterFreq;
44
- const sustain = Math.max(0, voice.sustain ?? 0);
45
- const g = ctx.createGain();
46
- g.gain.setValueAtTime(peak, t);
47
- if (sustain > 0)
48
- g.gain.setValueAtTime(peak, t + sustain);
49
- g.gain.exponentialRampToValueAtTime(0.001, t + Math.max(voice.duration * decay, sustain + 0.001));
50
- src.connect(filter).connect(g).connect(out);
51
- src.start(t, Math.random() * 0.5, voice.duration);
45
+ const nodes = [];
46
+ const stop = trackVoice(src, nodes, ended);
47
+ try {
48
+ src.buffer = noiseBuf;
49
+ src.playbackRate.value = 0.8 + Math.random() * 0.4;
50
+ // Loop the shared 1-second buffer so a random offset cannot truncate a longer voice.
51
+ src.loop = true;
52
+ const filter = ctx.createBiquadFilter();
53
+ nodes.push(filter);
54
+ filter.type = voice.filterType ?? "lowpass";
55
+ filter.frequency.value = voice.filterFreq;
56
+ const sustain = Math.max(0, voice.sustain ?? 0);
57
+ const g = ctx.createGain();
58
+ nodes.push(g);
59
+ g.gain.setValueAtTime(peak, t);
60
+ if (sustain > 0)
61
+ g.gain.setValueAtTime(peak, t + sustain);
62
+ g.gain.exponentialRampToValueAtTime(0.001, t + Math.max(voice.duration * decay, sustain + 0.001));
63
+ src.connect(filter).connect(g).connect(out);
64
+ src.start(t, Math.random() * 0.5, voice.duration);
65
+ return stop;
66
+ }
67
+ catch (error) {
68
+ stop();
69
+ throw error;
70
+ }
71
+ }
72
+ function trackVoice(source, nodes, ended) {
73
+ let disposed = false;
74
+ function cleanup() {
75
+ if (disposed)
76
+ return;
77
+ disposed = true;
78
+ source.onended = null;
79
+ source.disconnect();
80
+ for (const node of nodes)
81
+ node.disconnect();
82
+ ended();
83
+ }
84
+ source.onended = cleanup;
85
+ return () => {
86
+ if (disposed)
87
+ return;
88
+ try {
89
+ source.stop();
90
+ }
91
+ catch { }
92
+ cleanup();
93
+ };
52
94
  }
53
95
  /**
54
96
  * Realise a procedural cue on Web Audio: every voice is scheduled at
@@ -58,10 +100,33 @@ function realizeNoise(ctx, out, noiseBuf, voice, cueGain, at) {
58
100
  export function realizeSynthPatch(ctx, out, noiseBuf, patch) {
59
101
  const cueGain = patch.gain ?? 1;
60
102
  const at = ctx.currentTime;
61
- for (const voice of patch.voices) {
62
- if (voice.kind === "tone")
63
- realizeTone(ctx, out, voice, cueGain, at);
64
- else
65
- realizeNoise(ctx, out, noiseBuf, voice, cueGain, at);
103
+ let remaining = patch.voices.length;
104
+ const callbacks = [];
105
+ const stops = [];
106
+ function ended() {
107
+ remaining -= 1;
108
+ if (remaining === 0)
109
+ for (const callback of callbacks.splice(0))
110
+ callback();
111
+ }
112
+ try {
113
+ for (const voice of patch.voices) {
114
+ stops.push(voice.kind === "tone"
115
+ ? realizeTone(ctx, out, voice, cueGain, at, ended)
116
+ : realizeNoise(ctx, out, noiseBuf, voice, cueGain, at, ended));
117
+ }
66
118
  }
119
+ catch (error) {
120
+ for (const stop of stops)
121
+ stop();
122
+ throw error;
123
+ }
124
+ return {
125
+ stop() { for (const stop of stops)
126
+ stop(); },
127
+ onEnded(callback) { if (remaining === 0)
128
+ callback();
129
+ else
130
+ callbacks.push(callback); },
131
+ };
67
132
  }
@@ -5,10 +5,11 @@ import { GameFirstPersonCamera } from "./GameFirstPersonCamera.js";
5
5
  import { GameInspectionCamera } from "./GameInspectionCamera.js";
6
6
  import { GameOrbitCamera } from "./GameOrbitCamera.js";
7
7
  import { resolveDirectedCamera } from "./rigMath.js";
8
- import { resolveRigKind, turntableAsObserver } from "./rigResolve.js";
8
+ import { resolveRigKind, resolveRuntimeCameraConfig, turntableAsObserver } from "./rigResolve.js";
9
9
  import { CameraShakeContext, createCameraShakeChannel } from "./shakeChannel.js";
10
10
  export { resolveRigKind } from "./rigResolve.js";
11
11
  export function GameCameraRig({ yawRef, pitchRef, config, onDragChange, pointerControls, panKeysEnabled, director, viewmodel, }) {
12
+ config = resolveRuntimeCameraConfig(config, director?.rig());
12
13
  const channel = useMemo(() => createCameraShakeChannel(config?.shake?.decayPerSecond), [config?.shake?.decayPerSecond]);
13
14
  const directorVersionRef = useRef(0);
14
15
  const subscribeDirector = useCallback((onStoreChange) => {
@@ -37,7 +38,7 @@ export function GameCameraRig({ yawRef, pitchRef, config, onDragChange, pointerC
37
38
  const kind = resolveRigKind(config);
38
39
  switch (kind) {
39
40
  case "first":
40
- return (_jsx(GameFirstPersonCamera, { yawRef: yawRef, pitchRef: pitchRef, config: config?.firstPerson, followEntityId: followEntityId ?? undefined, viewmodel: viewmodel }));
41
+ return (_jsx(GameFirstPersonCamera, { yawRef: yawRef, pitchRef: pitchRef, config: config?.firstPerson, followEntityId: followEntityId ?? undefined, viewmodel: viewmodel, weapon: config?.weapon }));
41
42
  case "topDown":
42
43
  return _jsx(TopDownRig, { yawRef: yawRef, pitchRef: pitchRef, config: config, followEntityId: followEntityId });
43
44
  case "rts":
@@ -1,9 +1,9 @@
1
1
  import { type ComponentType, type MutableRefObject } from "react";
2
2
  import * as THREE from "three";
3
- import type { FirstPersonCameraConfig } from "@jgengine/core/game/playableGame";
3
+ import type { CameraWeaponView, FirstPersonCameraConfig } from "@jgengine/core/game/playableGame";
4
4
  import type { EntityRenderCues } from "@jgengine/core/combat/renderCues";
5
- /** World position of the first-person weapon muzzle, or false when no viewmodel is mounted. */
6
- export declare function readFirstPersonMuzzle(target: THREE.Vector3): boolean;
5
+ /** Presentation muzzle in world space; camera-scoped when supplied, false leaves target unchanged. */
6
+ export declare function readFirstPersonMuzzle(target: THREE.Vector3, camera?: THREE.Camera): boolean;
7
7
  /** Props handed to a custom viewmodel component (#542): a live cue ref (velocity/bob/firing/reloading/recoil/hit) for the followed entity, driven from your own `useFrame` — read `cuesRef.current` there rather than storing it as render state. */
8
8
  export interface ViewmodelProps {
9
9
  cuesRef: MutableRefObject<EntityRenderCues>;
@@ -15,5 +15,7 @@ export interface GameFirstPersonCameraProps {
15
15
  followEntityId?: string;
16
16
  /** Custom viewmodel component replacing the built-in three-mesh gun, rendered inside the same camera-locked, muzzle-tracked anchor. Ignored when `config.viewmodel === false`. */
17
17
  viewmodel?: ComponentType<ViewmodelProps>;
18
+ /** Held-weapon source (`GameCameraConfig.weapon`): poses the viewmodel and adds recoil to the look. */
19
+ weapon?: (entityId: string) => CameraWeaponView | null;
18
20
  }
19
- export declare function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId, viewmodel, }: GameFirstPersonCameraProps): import("react").JSX.Element | null;
21
+ export declare function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId, viewmodel, weapon, }: GameFirstPersonCameraProps): import("react").JSX.Element | null;
@@ -1,28 +1,25 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useFrame, useThree } from "@react-three/fiber";
3
- import { useEffect, useRef } from "react";
3
+ import { useEffect, useMemo, useRef } from "react";
4
4
  import * as THREE from "three";
5
5
  import { DEFAULT_EYE_HEIGHT } from "@jgengine/core/combat/shotOrigin";
6
+ import { createWeaponPresentation, viewmodelFovScale, } from "@jgengine/core/combat/weaponPresentation";
6
7
  import { useGameContext } from "@jgengine/react/provider";
7
8
  import { usePlayer } from "@jgengine/react/hooks";
8
9
  import { useEntityRenderCues } from "../render/useEntityRenderCues.js";
9
10
  import { usePlayerFov } from "./PlayerFov.js";
10
11
  import { GAME_SIM_FRAME_PRIORITY, ORBIT_CAMERA_FRAME_PRIORITY } from "./orbitCameraMath.js";
12
+ import { requestRawPointerLock } from "../input/pointerLock.js";
13
+ import { readRegisteredFirstPersonMuzzle, registerTrackedFirstPersonMuzzle } from "./customMuzzle.js";
11
14
  const DEFAULT_SENSITIVITY = 0.0025;
12
15
  const DEFAULT_MAX_PITCH = 1.45;
13
16
  const VIEWMODEL_ORIGIN = new THREE.Vector3(0.34, -0.26, -0.72);
14
17
  const MUZZLE_TIP_LOCAL = new THREE.Vector3(0, 0.03, -0.61);
15
- const MUZZLE_OFFSET = VIEWMODEL_ORIGIN.clone().add(MUZZLE_TIP_LOCAL);
16
- const muzzleWorld = new THREE.Vector3();
17
- let muzzleTracked = false;
18
- /** World position of the first-person weapon muzzle, or false when no viewmodel is mounted. */
19
- export function readFirstPersonMuzzle(target) {
20
- if (!muzzleTracked)
21
- return false;
22
- target.copy(muzzleWorld);
23
- return true;
18
+ /** Presentation muzzle in world space; camera-scoped when supplied, false leaves target unchanged. */
19
+ export function readFirstPersonMuzzle(target, camera) {
20
+ return readRegisteredFirstPersonMuzzle(target, camera);
24
21
  }
25
- export function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId, viewmodel, }) {
22
+ export function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId, viewmodel, weapon, }) {
26
23
  const eyeHeight = config?.eyeHeight ?? DEFAULT_EYE_HEIGHT;
27
24
  const sensitivity = config?.sensitivity ?? DEFAULT_SENSITIVITY;
28
25
  const maxPitch = config?.maxPitch ?? DEFAULT_MAX_PITCH;
@@ -34,12 +31,16 @@ export function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId
34
31
  const followId = followEntityId ?? userId;
35
32
  const seededRef = useRef(false);
36
33
  const cuesRef = useEntityRenderCues(followId);
34
+ const presentation = useMemo(() => createWeaponPresentation(), []);
35
+ const tuningRef = useRef(undefined);
36
+ const lastLookRef = useRef(null);
37
+ const poseRef = useRef(null);
37
38
  useEffect(() => {
38
39
  const requestLock = () => {
39
40
  if (window.matchMedia?.("(pointer: coarse)").matches)
40
41
  return;
41
42
  if (document.pointerLockElement !== domElement)
42
- void domElement.requestPointerLock?.();
43
+ requestRawPointerLock(domElement);
43
44
  };
44
45
  const onMove = (event) => {
45
46
  if (document.pointerLockElement !== domElement)
@@ -54,7 +55,7 @@ export function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId
54
55
  window.removeEventListener("mousemove", onMove);
55
56
  };
56
57
  }, [domElement, sensitivity, maxPitch, yawRef, pitchRef]);
57
- useFrame(() => {
58
+ useFrame((_, dt) => {
58
59
  const entity = ctx.scene.entity.get(followId);
59
60
  if (entity === null)
60
61
  return;
@@ -62,37 +63,77 @@ export function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId
62
63
  seededRef.current = true;
63
64
  yawRef.current = entity.rotationY;
64
65
  }
65
- const cosPitch = Math.cos(pitchRef.current);
66
+ const view = weapon?.(followId) ?? null;
67
+ const last = (lastLookRef.current ??= { yaw: yawRef.current, pitch: pitchRef.current });
68
+ const step = Math.max(1e-4, dt);
69
+ let pose = null;
70
+ if (view !== null) {
71
+ if (view.presentation !== tuningRef.current) {
72
+ tuningRef.current = view.presentation;
73
+ presentation.retune(view.presentation ?? {});
74
+ }
75
+ pose = presentation.update(dt, {
76
+ lookYawRate: (yawRef.current - last.yaw) / step,
77
+ lookPitchRate: (pitchRef.current - last.pitch) / step,
78
+ speed: cuesRef.current.speed,
79
+ bobPhase: cuesRef.current.bobPhase,
80
+ handling: view.handling,
81
+ });
82
+ }
83
+ last.yaw = yawRef.current;
84
+ last.pitch = pitchRef.current;
85
+ poseRef.current = pose;
86
+ const yaw = yawRef.current - (pose?.lookYaw ?? 0);
87
+ const pitch = Math.max(-maxPitch, Math.min(maxPitch, pitchRef.current + (pose?.lookPitch ?? 0)));
88
+ const cosPitch = Math.cos(pitch);
66
89
  camera.position.set(entity.position[0], entity.position[1] + eyeHeight, entity.position[2]);
67
- camera.lookAt(camera.position.x + Math.sin(yawRef.current) * cosPitch, camera.position.y + Math.sin(pitchRef.current), camera.position.z + Math.cos(yawRef.current) * cosPitch);
90
+ camera.lookAt(camera.position.x + Math.sin(yaw) * cosPitch, camera.position.y + Math.sin(pitch), camera.position.z + Math.cos(yaw) * cosPitch);
68
91
  if (camera.isPerspectiveCamera === true) {
69
92
  const perspective = camera;
70
- if (Math.abs(perspective.fov - playerFov.fov) > 0.001) {
71
- perspective.fov = playerFov.fov;
93
+ const fov = playerFov.fov * (pose?.fovScale ?? 1);
94
+ if (Math.abs(perspective.fov - fov) > 0.001) {
95
+ perspective.fov = fov;
72
96
  perspective.updateProjectionMatrix();
73
97
  }
74
98
  }
75
99
  }, ORBIT_CAMERA_FRAME_PRIORITY);
76
100
  if (config?.viewmodel === false)
77
101
  return null;
78
- return _jsx(FirstPersonViewmodel, { camera: camera, viewmodel: viewmodel, cuesRef: cuesRef });
102
+ return _jsx(FirstPersonViewmodel, { camera: camera, viewmodel: viewmodel, cuesRef: cuesRef, poseRef: poseRef });
79
103
  }
80
- function FirstPersonViewmodel({ camera, viewmodel: Viewmodel, cuesRef, }) {
104
+ function FirstPersonViewmodel({ camera, viewmodel: Viewmodel, cuesRef, poseRef, }) {
81
105
  const groupRef = useRef(null);
82
- useEffect(() => () => {
83
- muzzleTracked = false;
84
- }, []);
106
+ const muzzleWorld = useRef(new THREE.Vector3());
107
+ const muzzleTracked = useRef(false);
108
+ useEffect(() => {
109
+ muzzleTracked.current = false;
110
+ return registerTrackedFirstPersonMuzzle(camera, (target) => {
111
+ if (!muzzleTracked.current)
112
+ return false;
113
+ target.copy(muzzleWorld.current);
114
+ return true;
115
+ });
116
+ }, [camera]);
85
117
  useFrame(() => {
86
118
  const group = groupRef.current;
87
119
  if (group === null)
88
120
  return;
121
+ const pose = poseRef.current;
122
+ const perspective = camera;
123
+ // Scaling camera-space x/y by this projects the viewmodel at its own FOV without a second render pass.
124
+ const squash = pose?.viewmodelFov == null || perspective.isPerspectiveCamera !== true ? 1 : viewmodelFovScale(perspective.fov, pose.viewmodelFov);
89
125
  group.position.copy(camera.position);
90
126
  group.quaternion.copy(camera.quaternion);
91
- group.translateX(VIEWMODEL_ORIGIN.x);
92
- group.translateY(VIEWMODEL_ORIGIN.y);
93
- group.translateZ(VIEWMODEL_ORIGIN.z);
94
- muzzleWorld.copy(MUZZLE_OFFSET).applyQuaternion(camera.quaternion).add(camera.position);
95
- muzzleTracked = true;
127
+ group.translateX((pose?.offset[0] ?? VIEWMODEL_ORIGIN.x) * squash);
128
+ group.translateY((pose?.offset[1] ?? VIEWMODEL_ORIGIN.y) * squash);
129
+ group.translateZ(pose?.offset[2] ?? VIEWMODEL_ORIGIN.z);
130
+ group.rotateX(pose?.pitch ?? 0);
131
+ group.rotateY(pose?.yaw ?? 0);
132
+ group.rotateZ(pose?.roll ?? 0);
133
+ group.scale.set(squash, squash, 1);
134
+ group.updateMatrixWorld();
135
+ group.localToWorld(muzzleWorld.current.copy(MUZZLE_TIP_LOCAL));
136
+ muzzleTracked.current = true;
96
137
  }, GAME_SIM_FRAME_PRIORITY);
97
138
  if (Viewmodel !== undefined) {
98
139
  return (_jsx("group", { ref: groupRef, children: _jsx(Viewmodel, { cuesRef: cuesRef }) }));
@@ -0,0 +1,34 @@
1
+ import { type ReactNode } from "react";
2
+ import type { GameCameraConfig } from "@jgengine/core/game/playableGame";
3
+ import { type ViewportDef, type ViewportsConfig } from "@jgengine/core/game/viewports";
4
+ import type { GameContext } from "@jgengine/core/runtime/gameContext";
5
+ import { type LocalPlayerSlot } from "@jgengine/core/runtime/localPlayers";
6
+ /** Camera yaw the split-screen renderer last used for a seat's user, so the walk controller moves that seat camera-relative. @internal */
7
+ export declare function seatCameraYaw(ctx: GameContext, userId: string): number | undefined;
8
+ /**
9
+ * The viewports for the seats currently joined, re-resolved when a seat joins or leaves.
10
+ * @internal
11
+ */
12
+ export declare function useSeatViewports(ctx: GameContext, config: ViewportsConfig | undefined): readonly ViewportDef[];
13
+ /**
14
+ * Draws one scissored render per viewport. The primary seat keeps the main camera and its rig; every
15
+ * other seat gets a follow camera (chase or top-down) on its possessed entity. Mounted only while more
16
+ * than one viewport is drawn, since a render-priority frame callback takes over R3F's own render.
17
+ * @internal
18
+ */
19
+ export declare function SplitScreenRenderer({ ctx, viewports, config, mainCamera, }: {
20
+ ctx: GameContext;
21
+ viewports: readonly ViewportDef[];
22
+ config: ViewportsConfig | undefined;
23
+ mainCamera: GameCameraConfig | undefined;
24
+ }): null;
25
+ /**
26
+ * One absolutely-positioned HUD root per viewport, laid over the canvas: render each seat's own
27
+ * health, prompts or score with `children(slot)`. Pass the same `viewports` config the game gave `defineGame`.
28
+ * @capability split-screen-hud Per-seat HUD roots positioned over each split-screen viewport.
29
+ */
30
+ export declare function ViewportHuds({ ctx, config, children, }: {
31
+ ctx: GameContext;
32
+ config?: ViewportsConfig;
33
+ children: (slot: LocalPlayerSlot, viewport: ViewportDef) => ReactNode;
34
+ }): import("react").JSX.Element;
@@ -0,0 +1,123 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useFrame, useThree } from "@react-three/fiber";
3
+ import { useMemo, useRef, useSyncExternalStore } from "react";
4
+ import * as THREE from "three";
5
+ import { resolveViewports, viewportPixels, } from "@jgengine/core/game/viewports";
6
+ import { localPlayers } from "@jgengine/core/runtime/localPlayers";
7
+ import { createChaseRigState, resolveChase, resolveTopDown, stepChase, topDownPose } from "./rigMath.js";
8
+ const SEAT_YAW = new WeakMap();
9
+ /** Camera yaw the split-screen renderer last used for a seat's user, so the walk controller moves that seat camera-relative. @internal */
10
+ export function seatCameraYaw(ctx, userId) {
11
+ return SEAT_YAW.get(ctx)?.get(userId);
12
+ }
13
+ function seatSlotIds(ctx) {
14
+ return localPlayers(ctx).slots().map((slot) => slot.slotId);
15
+ }
16
+ /**
17
+ * The viewports for the seats currently joined, re-resolved when a seat joins or leaves.
18
+ * @internal
19
+ */
20
+ export function useSeatViewports(ctx, config) {
21
+ const seats = localPlayers(ctx);
22
+ const key = useSyncExternalStore(seats.subscribe, () => seatSlotIds(ctx).join("|"), () => seatSlotIds(ctx).join("|"));
23
+ return useMemo(() => resolveViewports(config, key === "" ? [] : key.split("|")), [config, key]);
24
+ }
25
+ function seatRigKind(camera, main) {
26
+ return camera?.rig ?? (main?.rig === "topDown" ? "topDown" : "chase");
27
+ }
28
+ /**
29
+ * Draws one scissored render per viewport. The primary seat keeps the main camera and its rig; every
30
+ * other seat gets a follow camera (chase or top-down) on its possessed entity. Mounted only while more
31
+ * than one viewport is drawn, since a render-priority frame callback takes over R3F's own render.
32
+ * @internal
33
+ */
34
+ export function SplitScreenRenderer({ ctx, viewports, config, mainCamera, }) {
35
+ const gl = useThree((state) => state.gl);
36
+ const scene = useThree((state) => state.scene);
37
+ const camera = useThree((state) => state.camera);
38
+ const size = useThree((state) => state.size);
39
+ const rigs = useRef(new Map());
40
+ useFrame((_state, dt) => {
41
+ const seats = localPlayers(ctx);
42
+ let yaws = SEAT_YAW.get(ctx);
43
+ if (yaws === undefined) {
44
+ yaws = new Map();
45
+ SEAT_YAW.set(ctx, yaws);
46
+ }
47
+ gl.setScissorTest(true);
48
+ for (const def of viewports) {
49
+ const slot = seats.slot(def.slot);
50
+ if (slot === null)
51
+ continue;
52
+ const pixels = viewportPixels(def.rect, size.width, size.height);
53
+ const view = slot.index === 0 ? camera : seatCamera(slot, def.camera ?? config?.camera, dt, yaws);
54
+ if (view instanceof THREE.PerspectiveCamera) {
55
+ view.aspect = pixels.width / pixels.height;
56
+ view.updateProjectionMatrix();
57
+ }
58
+ gl.setViewport(pixels.x, pixels.y, pixels.width, pixels.height);
59
+ gl.setScissor(pixels.x, pixels.y, pixels.width, pixels.height);
60
+ gl.render(scene, view);
61
+ }
62
+ gl.setScissorTest(false);
63
+ gl.setViewport(0, 0, size.width, size.height);
64
+ if (camera instanceof THREE.PerspectiveCamera) {
65
+ camera.aspect = size.width / size.height;
66
+ camera.updateProjectionMatrix();
67
+ }
68
+ }, 1);
69
+ function seatCamera(slot, cameraConfig, dt, yaws) {
70
+ let rig = rigs.current.get(slot.slotId);
71
+ if (rig === undefined) {
72
+ const created = new THREE.PerspectiveCamera();
73
+ if (camera instanceof THREE.PerspectiveCamera) {
74
+ created.near = camera.near;
75
+ created.far = camera.far;
76
+ }
77
+ rig = { camera: created, chase: createChaseRigState() };
78
+ rigs.current.set(slot.slotId, rig);
79
+ }
80
+ const entity = ctx.scene.entity.get(ctx.player.possession.active(slot.userId));
81
+ const follow = entity === null ? { x: 0, y: 0, z: 0 } : { x: entity.position[0], y: entity.position[1], z: entity.position[2] };
82
+ let pose;
83
+ if (seatRigKind(cameraConfig, mainCamera) === "topDown") {
84
+ const resolved = resolveTopDown(cameraConfig?.topDown ?? mainCamera?.topDown);
85
+ pose = topDownPose(follow, resolved, rig.camera.fov);
86
+ yaws.set(slot.userId, resolved.yaw);
87
+ }
88
+ else {
89
+ const velocity = entity?.velocity;
90
+ const step = stepChase(rig.chase, {
91
+ follow,
92
+ yaw: entity?.rotationY ?? 0,
93
+ bodyPitch: entity?.rotationX ?? 0,
94
+ velocity: velocity === undefined ? null : { x: velocity[0], y: velocity[1], z: velocity[2] },
95
+ lookBack: false,
96
+ fovKick: 0,
97
+ }, resolveChase(cameraConfig?.chase ?? mainCamera?.chase), dt);
98
+ pose = step.pose;
99
+ yaws.set(slot.userId, step.anchorYaw);
100
+ }
101
+ rig.camera.position.set(pose.position.x, pose.position.y, pose.position.z);
102
+ rig.camera.fov = pose.fov;
103
+ rig.camera.lookAt(pose.lookAt.x, pose.lookAt.y, pose.lookAt.z);
104
+ return rig.camera;
105
+ }
106
+ return null;
107
+ }
108
+ /**
109
+ * One absolutely-positioned HUD root per viewport, laid over the canvas: render each seat's own
110
+ * health, prompts or score with `children(slot)`. Pass the same `viewports` config the game gave `defineGame`.
111
+ * @capability split-screen-hud Per-seat HUD roots positioned over each split-screen viewport.
112
+ */
113
+ export function ViewportHuds({ ctx, config, children, }) {
114
+ const viewports = useSeatViewports(ctx, config);
115
+ const seats = localPlayers(ctx);
116
+ return (_jsx(_Fragment, { children: viewports.map((viewport) => {
117
+ const slot = seats.slot(viewport.slot);
118
+ if (slot === null)
119
+ return null;
120
+ const [x, y, width, height] = viewport.rect;
121
+ return (_jsx("div", { "data-jg-viewport": viewport.slot, className: "pointer-events-none absolute overflow-hidden", style: { left: `${x * 100}%`, top: `${y * 100}%`, width: `${width * 100}%`, height: `${height * 100}%` }, children: children(slot, viewport) }, viewport.slot));
122
+ }) }));
123
+ }