@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
@@ -5,10 +5,12 @@ import { snapPixelPerfectPosition } from "@jgengine/core/game/pixelPerfect";
5
5
  import { usePlayer } from "@jgengine/react/hooks";
6
6
  import { useGameContext } from "@jgengine/react/provider";
7
7
  import { ORBIT_CAMERA_FRAME_PRIORITY } from "./orbitCameraMath.js";
8
- import { bankRollStep, chaseDesiredPosition, chaseLookAt, cinematicSample, clamp, leadFollowPoint, lockOnPose, observerPose, resolveChase, resolveObserver, resolveShoulder, resolveSideScroll, resolveSideScrollPose, resolveTopDown, rtsPanWorldDir, seatPose, shoulderPose, sideScrollFollowBlend, smoothYaw, speedToFov, springArmStep, topDownPose, velocityYawTarget, } from "./rigMath.js";
8
+ import { cinematicSample, clamp, clampBoomToHit, createChaseRigState, lockOnPose, observerPose, resolveChase, resolveObserver, resolveShoulder, resolveSideScroll, resolveSideScrollPose, resolveTopDown, rtsPanWorldDir, seatPose, shoulderPose, sideScrollFollowBlend, smoothYaw, stepChase, topDownPose, } from "./rigMath.js";
9
9
  import { usePlayerFov } from "./PlayerFov.js";
10
10
  import { useCameraShake } from "./shakeChannel.js";
11
11
  import { applyCameraBlendStep, captureCameraBlendFrom, createCameraBlendScratch, } from "./cameraBlendMath.js";
12
+ import { requestRawPointerLock } from "../input/pointerLock.js";
13
+ import { resolveChaseHeading } from "./chaseHeading.js";
12
14
  export { applyCameraBlendStep, captureCameraBlendFrom, createCameraBlendScratch, } from "./cameraBlendMath.js";
13
15
  export const CAMERA_RIG_FRAME_PRIORITY = ORBIT_CAMERA_FRAME_PRIORITY;
14
16
  export const CAMERA_POST_FRAME_PRIORITY = ORBIT_CAMERA_FRAME_PRIORITY + 0.5;
@@ -306,7 +308,7 @@ export function ShoulderRig(props) {
306
308
  useEffect(() => {
307
309
  const requestLock = () => {
308
310
  if (document.pointerLockElement !== domElement)
309
- void domElement.requestPointerLock?.();
311
+ requestRawPointerLock(domElement);
310
312
  };
311
313
  const onMove = (event) => {
312
314
  if (document.pointerLockElement !== domElement)
@@ -345,8 +347,13 @@ export function ShoulderRig(props) {
345
347
  useFrame((_, dt) => {
346
348
  const sample = readFollow(ctx, followId);
347
349
  const follow = sample?.pos ?? { x: 0, y: 0, z: 0 };
348
- const adsTarget = aimingRef.current ? 1 : 0;
349
- aimRef.current += (adsTarget - aimRef.current) * (1 - Math.exp(-(config?.adsTransitionSpeed ?? 10) * dt));
350
+ const weapon = followId === null ? null : (props.config?.weapon?.(followId) ?? null);
351
+ if (weapon === null) {
352
+ const adsTarget = aimingRef.current ? 1 : 0;
353
+ aimRef.current += (adsTarget - aimRef.current) * (1 - Math.exp(-(config?.adsTransitionSpeed ?? 10) * dt));
354
+ }
355
+ else
356
+ aimRef.current = weapon.handling.adsProgress;
350
357
  const hip = resolveShoulder(config, false);
351
358
  const ads = resolveShoulder(config, true);
352
359
  const blended = {
@@ -355,7 +362,11 @@ export function ShoulderRig(props) {
355
362
  distance: hip.distance + (ads.distance - hip.distance) * aimRef.current,
356
363
  fov: hip.fov + (ads.fov - hip.fov) * aimRef.current,
357
364
  };
358
- const pose = shoulderPose(follow, props.yawRef.current, props.pitchRef.current, sideRef.current, blended);
365
+ const handling = weapon?.handling;
366
+ const yaw = props.yawRef.current - (handling === undefined ? 0 : handling.aimYaw + handling.cameraYaw);
367
+ // This rig's pitch is camera elevation, so a muzzle climb lowers it.
368
+ const pitch = props.pitchRef.current - (handling === undefined ? 0 : handling.aimPitch + handling.cameraPitch);
369
+ const pose = shoulderPose(follow, yaw, pitch, sideRef.current, blended);
359
370
  commit(pose, dt);
360
371
  }, CAMERA_RIG_FRAME_PRIORITY);
361
372
  return null;
@@ -424,73 +435,76 @@ export function ChaseRig(props) {
424
435
  const staticResolved = useMemo(() => resolveChase(config), [config]);
425
436
  const { camera, commit, beginTransition } = useCameraCommit(props, followId);
426
437
  const shake = useCameraShake();
427
- const posRef = useRef(null);
428
- const lastFollowRef = useRef(null);
429
- const lastYawRef = useRef(null);
430
- const anchorYawRef = useRef(null);
431
- const rollRef = useRef(0);
432
- const view = config?.view ?? "chase";
433
- useEffect(beginTransition, [view]);
438
+ const stateRef = useRef(createChaseRigState());
439
+ const viewRef = useRef(null);
440
+ const inputSeededRef = useRef(false);
434
441
  useFrame((_, dt) => {
435
442
  const tuning = ctx.camera.chaseTuning();
436
- const resolved = tuning === null ? staticResolved : resolveChase({ ...config, ...tuning });
437
- const sample = readFollow(ctx, followId);
438
- const follow = sample?.pos ?? { x: 0, y: 0, z: 0 };
439
- const yaw = sample?.yaw ?? 0;
440
- const last = lastFollowRef.current ?? follow;
441
- const speed = dt > 0 ? Math.hypot(follow.x - last.x, follow.z - last.z) / dt : 0;
442
- lastFollowRef.current = follow;
443
- const lastYaw = lastYawRef.current ?? yaw;
444
- lastYawRef.current = yaw;
445
- rollRef.current = bankRollStep(rollRef.current, yaw, lastYaw, dt, resolved);
446
- const fov = speedToFov(speed, tuning?.fov ?? config?.fov);
443
+ const merged = tuning === null ? config : { ...config, ...tuning };
444
+ const resolved = tuning === null ? staticResolved : resolveChase(merged);
445
+ const view = merged?.view ?? "chase";
446
+ if (viewRef.current !== view) {
447
+ beginTransition();
448
+ viewRef.current = view;
449
+ stateRef.current.offset = null;
450
+ }
451
+ const entity = followId === null ? null : ctx.scene.entity.get(followId);
452
+ const follow = entity === null ? { x: 0, y: 0, z: 0 } : { x: entity.position[0], y: entity.position[1], z: entity.position[2] };
453
+ if (!inputSeededRef.current && merged?.headingSource === "input" && view === "chase" && (entity !== null || props.config?.initialYaw !== undefined)) {
454
+ props.yawRef.current = resolveChaseHeading(merged, entity?.rotationY ?? 0, undefined, props.config?.initialYaw);
455
+ inputSeededRef.current = true;
456
+ }
457
+ const yaw = resolveChaseHeading(merged, entity?.rotationY ?? 0, props.yawRef.current);
458
+ const v = entity?.velocity;
459
+ const published = v !== undefined && (v[0] !== 0 || v[1] !== 0 || v[2] !== 0);
460
+ const lookBack = resolved.lookBackAction !== null && ctx.input.isDown(resolved.lookBackAction);
461
+ const clampBoom = resolved.collision
462
+ ? (pivot, desired) => {
463
+ const dx = desired.x - pivot.x;
464
+ const dy = desired.y - pivot.y;
465
+ const dz = desired.z - pivot.z;
466
+ const length = Math.hypot(dx, dy, dz);
467
+ if (length <= 1e-6)
468
+ return desired;
469
+ const hit = ctx.scene.raycast({
470
+ origin: [pivot.x, pivot.y, pivot.z],
471
+ direction: [dx / length, dy / length, dz / length],
472
+ maxDistance: length + resolved.collisionRadius,
473
+ excludeInstanceIds: followId === null ? undefined : [followId],
474
+ filter: { entities: false },
475
+ });
476
+ return clampBoomToHit(pivot, desired, hit?.distance ?? null, resolved.collisionRadius, resolved.collisionMinDistance);
477
+ }
478
+ : undefined;
479
+ const step = stepChase(stateRef.current, {
480
+ follow,
481
+ yaw,
482
+ bodyPitch: entity?.rotationX ?? 0,
483
+ velocity: published ? { x: v[0], y: v[1], z: v[2] } : null,
484
+ lookBack: lookBack && view === "chase",
485
+ fovKick: ctx.camera.takeFovKick(),
486
+ }, resolved, dt, view === "chase" ? clampBoom : undefined);
487
+ props.yawRef.current = resolveChaseHeading(merged, step.anchorYaw, props.yawRef.current);
447
488
  if (view !== "chase") {
448
489
  const seat = view === "cockpit"
449
- ? config?.seatOffsets?.cockpit ?? { x: 0, y: 1.2, z: 0.1 }
490
+ ? merged?.seatOffsets?.cockpit ?? { x: 0, y: 1.2, z: 0.1 }
450
491
  : view === "hood"
451
- ? config?.seatOffsets?.hood ?? { x: 0, y: 1, z: 1.4 }
452
- : config?.seatOffsets?.rear ?? { x: 0, y: 1.6, z: -1.5 };
453
- const lookBack = view === "rear";
454
- const pose = seatPose(follow, lookBack ? yaw + Math.PI : yaw, seat, fov);
455
- if (lookBack) {
456
- pose.position = seatPose(follow, yaw, seat, fov).position;
457
- }
492
+ ? merged?.seatOffsets?.hood ?? { x: 0, y: 1, z: 1.4 }
493
+ : merged?.seatOffsets?.rear ?? { x: 0, y: 1.6, z: -1.5 };
494
+ const seatLooksBack = view === "rear" || lookBack;
495
+ const pose = seatPose(follow, seatLooksBack ? yaw + Math.PI : yaw, seat, step.pose.fov);
496
+ if (seatLooksBack)
497
+ pose.position = seatPose(follow, yaw, seat, step.pose.fov).position;
458
498
  props.yawRef.current = yaw;
459
499
  commit(pose, dt);
460
500
  return;
461
501
  }
462
- // Anchor yaw always eases toward its target (#1370): with drift-lag configured the target is
463
- // the velocity-blended yaw at velocityYawResponse, otherwise the raw body facing at
464
- // yawResponse — so a strafe-flipped facing arcs the boom around the character instead of
465
- // teleporting it to the far side. yawResponse: Infinity restores the legacy rigid follow.
466
- let targetYaw = yaw;
467
- let response = resolved.yawResponse;
468
- if (resolved.velocityYawBlend > 0) {
469
- const velocity = dt > 0
470
- ? { x: (follow.x - last.x) / dt, y: 0, z: (follow.z - last.z) / dt }
471
- : { x: 0, y: 0, z: 0 };
472
- targetYaw = velocityYawTarget(yaw, velocity, resolved);
473
- response = resolved.velocityYawResponse;
502
+ if (resolved.shakePerSpeed > 0 && step.speed > 0) {
503
+ shake.shake(Math.min(resolved.shakePerSpeed * step.speed * dt, 0.1));
474
504
  }
475
- const anchorYaw = Number.isFinite(response)
476
- ? smoothYaw(anchorYawRef.current ?? targetYaw, targetYaw, response, dt)
477
- : targetYaw;
478
- anchorYawRef.current = anchorYaw;
479
- // Report camera yaw back to the shell like every other player-facing rig, so
480
- // on-foot movement and aim stay camera-relative instead of frozen at yaw 0.
481
- props.yawRef.current = anchorYaw;
482
- const led = leadFollowPoint(follow, last, dt, resolved);
483
- const desired = chaseDesiredPosition(led, anchorYaw, resolved);
484
- const prev = posRef.current ?? desired;
485
- const smoothed = springArmStep(prev, desired, resolved.springDamping, dt);
486
- posRef.current = smoothed;
487
- if (resolved.shakePerSpeed > 0 && speed > 0) {
488
- shake.shake(Math.min(resolved.shakePerSpeed * speed * dt, 0.1));
489
- }
490
- const pose = { position: smoothed, lookAt: chaseLookAt(led, anchorYaw, resolved), fov };
491
- commit(pose, dt);
492
- if (rollRef.current !== 0)
493
- camera.rotateZ(rollRef.current);
505
+ commit(step.pose, dt);
506
+ if (step.roll !== 0)
507
+ camera.rotateZ(step.roll);
494
508
  }, CAMERA_RIG_FRAME_PRIORITY);
495
509
  return null;
496
510
  }
@@ -0,0 +1,9 @@
1
+ import type { ChaseCameraConfig } from "@jgengine/core/game/playableGame";
2
+ /**
3
+ * Select a chase heading without feeding target facing or camera smoothing back into input-owned yaw.
4
+ * An undefined input yaw seeds from `initialYaw`, or the acquired target's body heading.
5
+ * Seat views always use body heading; omitting `headingSource` preserves body follow.
6
+ *
7
+ * @capability chase-heading keep camera-relative input heading independent of body facing while retaining vehicle and seat camera follow
8
+ */
9
+ export declare function resolveChaseHeading(config: Pick<ChaseCameraConfig, "headingSource" | "view"> | undefined, bodyYaw: number, inputYaw: number | undefined, initialYaw?: number): number;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Select a chase heading without feeding target facing or camera smoothing back into input-owned yaw.
3
+ * An undefined input yaw seeds from `initialYaw`, or the acquired target's body heading.
4
+ * Seat views always use body heading; omitting `headingSource` preserves body follow.
5
+ *
6
+ * @capability chase-heading keep camera-relative input heading independent of body facing while retaining vehicle and seat camera follow
7
+ */
8
+ export function resolveChaseHeading(config, bodyYaw, inputYaw, initialYaw) {
9
+ return config?.headingSource === "input" && (config.view ?? "chase") === "chase" ? inputYaw ?? initialYaw ?? bodyYaw : bodyYaw;
10
+ }
@@ -0,0 +1,14 @@
1
+ import { Camera, Vector3 } from "three";
2
+ /** Writes a live presentation muzzle in world space; false means this rig has no muzzle available. */
3
+ export type FirstPersonMuzzleReader = (target: Vector3) => boolean;
4
+ /**
5
+ * Register a custom rig's presentation muzzle for its Three.js camera. Newest available custom
6
+ * reader wins over that camera's stock muzzle; call the returned cleanup on unmount.
7
+ *
8
+ * @capability custom-viewmodel-muzzle align tracer origins with a custom viewmodel's live muzzle without changing authoritative shots
9
+ */
10
+ export declare function registerFirstPersonMuzzle(camera: Camera, reader: FirstPersonMuzzleReader): () => void;
11
+ /** @internal Stock rig registration shares camera isolation and owner-scoped cleanup. */
12
+ export declare function registerTrackedFirstPersonMuzzle(camera: Camera, reader: FirstPersonMuzzleReader): () => void;
13
+ /** @internal Unscoped legacy reads consider the newest 64 registrations per priority. */
14
+ export declare function readRegisteredFirstPersonMuzzle(target: Vector3, camera?: Camera): boolean;
@@ -0,0 +1,68 @@
1
+ import { Camera, Vector3 } from "three";
2
+ const MAX_READERS = 64;
3
+ const custom = new WeakMap();
4
+ const tracked = new WeakMap();
5
+ const legacyCustom = [];
6
+ const legacyTracked = [];
7
+ function register(camera, reader, scoped, legacy) {
8
+ const readers = scoped.get(camera) ?? [];
9
+ if (readers.length >= MAX_READERS)
10
+ throw new RangeError("Too many muzzle readers for one camera");
11
+ const entry = { reader, scratch: new Vector3(), reading: false, disposed: false };
12
+ readers.push(entry);
13
+ scoped.set(camera, readers);
14
+ legacy.push(entry);
15
+ let disposed = false;
16
+ return () => {
17
+ if (disposed)
18
+ return;
19
+ disposed = true;
20
+ entry.disposed = true;
21
+ readers.splice(readers.indexOf(entry), 1);
22
+ legacy.splice(legacy.indexOf(entry), 1);
23
+ if (readers.length === 0)
24
+ scoped.delete(camera);
25
+ };
26
+ }
27
+ /**
28
+ * Register a custom rig's presentation muzzle for its Three.js camera. Newest available custom
29
+ * reader wins over that camera's stock muzzle; call the returned cleanup on unmount.
30
+ *
31
+ * @capability custom-viewmodel-muzzle align tracer origins with a custom viewmodel's live muzzle without changing authoritative shots
32
+ */
33
+ export function registerFirstPersonMuzzle(camera, reader) {
34
+ return register(camera, reader, custom, legacyCustom);
35
+ }
36
+ /** @internal Stock rig registration shares camera isolation and owner-scoped cleanup. */
37
+ export function registerTrackedFirstPersonMuzzle(camera, reader) {
38
+ return register(camera, reader, tracked, legacyTracked);
39
+ }
40
+ function read(readers, target) {
41
+ if (readers === undefined)
42
+ return false;
43
+ const pending = readers.slice(-MAX_READERS);
44
+ for (let i = pending.length - 1; i >= 0; i--) {
45
+ const entry = pending[i];
46
+ if (entry.reading || entry.disposed)
47
+ continue;
48
+ entry.reading = true;
49
+ try {
50
+ if (!entry.reader(entry.scratch))
51
+ continue;
52
+ const { x, y, z } = entry.scratch;
53
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z))
54
+ continue;
55
+ target.copy(entry.scratch);
56
+ return true;
57
+ }
58
+ finally {
59
+ entry.reading = false;
60
+ }
61
+ }
62
+ return false;
63
+ }
64
+ /** @internal Unscoped legacy reads consider the newest 64 registrations per priority. */
65
+ export function readRegisteredFirstPersonMuzzle(target, camera) {
66
+ return read(camera === undefined ? legacyCustom : custom.get(camera), target) ||
67
+ read(camera === undefined ? legacyTracked : tracked.get(camera), target);
68
+ }
@@ -9,3 +9,4 @@ export { ControllerCameraShake, type ControllerCameraShakeProps } from "./Contro
9
9
  export { rtsPanKeysConflict } from "./rigMath.js";
10
10
  export { GAME_SIM_FRAME_PRIORITY } from "./orbitCameraMath.js";
11
11
  export { CAMERA_TRANSPARENT_USERDATA, isCameraOccluderTransparent, type CameraOccluder, } from "./cameraCollision.js";
12
+ export { registerFirstPersonMuzzle, type FirstPersonMuzzleReader } from "./customMuzzle.js";
@@ -9,3 +9,4 @@ export { ControllerCameraShake } from "./ControllerCameraShake.js";
9
9
  export { rtsPanKeysConflict } from "./rigMath.js";
10
10
  export { GAME_SIM_FRAME_PRIORITY } from "./orbitCameraMath.js";
11
11
  export { CAMERA_TRANSPARENT_USERDATA, isCameraOccluderTransparent, } from "./cameraCollision.js";
12
+ export { registerFirstPersonMuzzle } from "./customMuzzle.js";
@@ -17,8 +17,8 @@ export const DEFAULT_ORBIT_CAMERA = {
17
17
  minDistance: 4,
18
18
  maxDistance: 28,
19
19
  targetHeight: 1.2,
20
- initialDistance: 9,
21
- initialHeight: 5.5,
20
+ initialDistance: 7,
21
+ initialHeight: 2.4,
22
22
  followLock: true,
23
23
  followEnabled: true,
24
24
  rotateSpeed: 0.22,
@@ -136,6 +136,23 @@ export interface ResolvedChase {
136
136
  velocityYawResponse: number;
137
137
  /** Exponential smoothing rate the anchor yaw follows the body's facing with (#1370). `Infinity` restores the legacy rigid follow. */
138
138
  yawResponse: number;
139
+ fovBase: number;
140
+ fovMax: number;
141
+ fovSpeedForMax: number;
142
+ /** Exponential FOV smoothing rate; `Infinity` applies the curve unsmoothed. */
143
+ fovResponse: number;
144
+ distanceExtra: number;
145
+ distanceSpeedForMax: number;
146
+ /** Fraction of body pitch the boom follows; 0 keeps a level boom. */
147
+ pitchBlend: number;
148
+ pitchMax: number;
149
+ pitchResponse: number;
150
+ fovKickDecay: number;
151
+ fovKickMax: number;
152
+ lookBackAction: string | null;
153
+ collision: boolean;
154
+ collisionRadius: number;
155
+ collisionMinDistance: number;
139
156
  }
140
157
  /** @internal */
141
158
  export declare function resolveChase(config: ChaseCameraConfig | undefined): ResolvedChase;
@@ -143,6 +160,10 @@ export declare function resolveChase(config: ChaseCameraConfig | undefined): Res
143
160
  * @internal
144
161
  */
145
162
  export declare function leadFollowPoint(follow: Vec3, previous: Vec3, dt: number, resolved: ResolvedChase): Vec3;
163
+ /** Velocity-lead the follow point along a known planar velocity, clamped to `leadMax`.
164
+ * @internal
165
+ */
166
+ export declare function leadFromVelocity(follow: Vec3, velocity: Vec3, resolved: ResolvedChase): Vec3;
146
167
  /** One smoothing step of the chase rig's turn-bank roll (#286.10): roll opposes yaw rate, clamped and exponentially damped.
147
168
  * @internal
148
169
  */
@@ -159,14 +180,71 @@ export declare function resolveObserver(config: ObserverCameraConfig | undefined
159
180
  * @internal
160
181
  */
161
182
  export declare function observerPose(subject: Vec3, angle: number, resolved: ResolvedObserver, fov: number): CameraPose;
162
- /** Desired chase-camera position behind a vehicle facing `yaw` (before spring smoothing).
183
+ /** Desired chase-camera position behind a vehicle facing `yaw` (before spring smoothing). `pitch` (nose-up positive) tilts the boom around the vehicle's right axis.
184
+ * @internal
185
+ */
186
+ export declare function chaseDesiredPosition(follow: Vec3, yaw: number, resolved: ResolvedChase, pitch?: number, distance?: number): Vec3;
187
+ /** Camera offset from the follow point for a boom `distance` back and `height` up, tilted by `pitch` (nose-up positive).
188
+ * @internal
189
+ */
190
+ export declare function chaseBoomOffset(yaw: number, pitch: number, distance: number, height: number): Vec3;
191
+ /** Look point ahead of / above a chased vehicle, tilted by `pitch` like the boom.
163
192
  * @internal
164
193
  */
165
- export declare function chaseDesiredPosition(follow: Vec3, yaw: number, resolved: ResolvedChase): Vec3;
166
- /** Look point ahead of / above a chased vehicle.
194
+ export declare function chaseLookAt(follow: Vec3, yaw: number, resolved: ResolvedChase, pitch?: number): Vec3;
195
+ /** Mutable per-rig chase state; {@link stepChase} advances it in place. */
196
+ export interface ChaseRigState {
197
+ /** Smoothed camera offset from the led follow point; springing the offset, not the world position, keeps the boom length at any constant speed. */
198
+ offset: Vec3 | null;
199
+ anchorYaw: number | null;
200
+ lastYaw: number | null;
201
+ lastFollow: Vec3 | null;
202
+ roll: number;
203
+ pitch: number;
204
+ fov: number | null;
205
+ kick: number;
206
+ lookingBack: boolean;
207
+ }
208
+ /** @internal */
209
+ export declare function createChaseRigState(): ChaseRigState;
210
+ /** What the chase rig reads from its target each frame. */
211
+ export interface ChaseSample {
212
+ follow: Vec3;
213
+ /** Body heading (radians). */
214
+ yaw: number;
215
+ /** Body `rotationX` (three.js convention: positive = nose down). */
216
+ bodyPitch: number;
217
+ /** Sim velocity when the entity publishes one; `null` falls back to the frame-to-frame position delta. */
218
+ velocity: Vec3 | null;
219
+ lookBack: boolean;
220
+ /** FOV impulse (degrees) queued since the last frame. */
221
+ fovKick: number;
222
+ }
223
+ /** One frame of chase output from {@link stepChase}. */
224
+ export interface ChaseStepResult {
225
+ pose: CameraPose;
226
+ /** Camera roll to apply after the pose, radians. */
227
+ roll: number;
228
+ /** Forward chase yaw to report to the shell; unaffected by look-back. */
229
+ anchorYaw: number;
230
+ /** Planar speed used for FOV, distance and shake. */
231
+ speed: number;
232
+ }
233
+ /** Pulls a desired camera position in toward `pivot` when something blocks the boom. */
234
+ export type ChaseBoomClamp = (pivot: Vec3, desired: Vec3) => Vec3;
235
+ /**
236
+ * One frame of the chase rig: speed from sim velocity, smoothed FOV plus decaying kicks, drift-lag
237
+ * anchor yaw, pitch follow, distance by speed, look-back, and a boom spring that eases the offset
238
+ * from the target rather than its world position, so the camera holds its distance at speed.
239
+ * @internal
240
+ */
241
+ export declare function stepChase(state: ChaseRigState, sample: ChaseSample, resolved: ResolvedChase, dt: number, clampBoom?: ChaseBoomClamp): ChaseStepResult;
242
+ /**
243
+ * Where a blocked boom should put the camera: `hitDistance` along the pivot→desired ray, less
244
+ * `radius`, but never nearer the pivot than `minDistance`. `null` hit returns `desired` unchanged.
167
245
  * @internal
168
246
  */
169
- export declare function chaseLookAt(follow: Vec3, yaw: number, resolved: ResolvedChase): Vec3;
247
+ export declare function clampBoomToHit(pivot: Vec3, desired: Vec3, hitDistance: number | null, radius: number, minDistance: number): Vec3;
170
248
  /**
171
249
  * Drift-lag (#1051): the yaw the chase anchor should aim before temporal
172
250
  * smoothing. When the target slides, its planar `velocity` diverges from its
@@ -253,6 +253,9 @@ export function lockOnPose(player, target, config, fov) {
253
253
  const DEFAULT_CHASE_YAW_RESPONSE = 5;
254
254
  /** @internal */
255
255
  export function resolveChase(config) {
256
+ const fovSpeedForMax = config?.fov?.speedForMax ?? 24;
257
+ const collision = config?.collision ?? true;
258
+ const collisionBlock = typeof collision === "object" ? collision : undefined;
256
259
  return {
257
260
  distance: config?.distance ?? 6,
258
261
  height: config?.height ?? 2.6,
@@ -268,16 +271,39 @@ export function resolveChase(config) {
268
271
  velocityYawMinSpeed: config?.velocityYaw?.minSpeed ?? 4,
269
272
  velocityYawResponse: config?.velocityYaw?.response ?? 6,
270
273
  yawResponse: config?.yawResponse ?? DEFAULT_CHASE_YAW_RESPONSE,
274
+ fovBase: config?.fov?.base ?? 55,
275
+ fovMax: config?.fov?.max ?? 78,
276
+ fovSpeedForMax,
277
+ fovResponse: config?.fov?.response ?? 6,
278
+ distanceExtra: config?.distanceBySpeed?.extra ?? 0,
279
+ distanceSpeedForMax: config?.distanceBySpeed?.speedForMax ?? fovSpeedForMax,
280
+ pitchBlend: config?.pitchFollow === undefined ? 0 : config.pitchFollow.blend ?? 1,
281
+ pitchMax: config?.pitchFollow?.max ?? 0.6,
282
+ pitchResponse: config?.pitchFollow?.response ?? 5,
283
+ fovKickDecay: config?.fovKick?.decay ?? 7,
284
+ fovKickMax: config?.fovKick?.max ?? 20,
285
+ lookBackAction: config?.lookBackAction ?? null,
286
+ collision: collision !== false,
287
+ collisionRadius: collisionBlock?.radius ?? 0.3,
288
+ collisionMinDistance: collisionBlock?.minDistance ?? 0.8,
271
289
  };
272
290
  }
273
291
  /** Velocity-lead the follow point (#286.9): aim `leadTime` seconds along the target's frame velocity, clamped to `leadMax`.
274
292
  * @internal
275
293
  */
276
294
  export function leadFollowPoint(follow, previous, dt, resolved) {
277
- if (resolved.leadTime <= 0 || dt <= 0)
295
+ if (dt <= 0)
278
296
  return follow;
279
- let leadX = ((follow.x - previous.x) / dt) * resolved.leadTime;
280
- let leadZ = ((follow.z - previous.z) / dt) * resolved.leadTime;
297
+ return leadFromVelocity(follow, { x: (follow.x - previous.x) / dt, y: 0, z: (follow.z - previous.z) / dt }, resolved);
298
+ }
299
+ /** Velocity-lead the follow point along a known planar velocity, clamped to `leadMax`.
300
+ * @internal
301
+ */
302
+ export function leadFromVelocity(follow, velocity, resolved) {
303
+ if (resolved.leadTime <= 0)
304
+ return follow;
305
+ let leadX = velocity.x * resolved.leadTime;
306
+ let leadZ = velocity.z * resolved.leadTime;
281
307
  const magnitude = Math.hypot(leadX, leadZ);
282
308
  if (magnitude > resolved.leadMax) {
283
309
  leadX *= resolved.leadMax / magnitude;
@@ -327,28 +353,120 @@ export function observerPose(subject, angle, resolved, fov) {
327
353
  fov,
328
354
  };
329
355
  }
330
- /** Desired chase-camera position behind a vehicle facing `yaw` (before spring smoothing).
356
+ /** Desired chase-camera position behind a vehicle facing `yaw` (before spring smoothing). `pitch` (nose-up positive) tilts the boom around the vehicle's right axis.
331
357
  * @internal
332
358
  */
333
- export function chaseDesiredPosition(follow, yaw, resolved) {
334
- const back = forwardVector(yaw);
359
+ export function chaseDesiredPosition(follow, yaw, resolved, pitch = 0, distance = resolved.distance) {
360
+ const offset = chaseBoomOffset(yaw, pitch, distance, resolved.height);
361
+ return { x: follow.x + offset.x, y: follow.y + offset.y, z: follow.z + offset.z };
362
+ }
363
+ /** Camera offset from the follow point for a boom `distance` back and `height` up, tilted by `pitch` (nose-up positive).
364
+ * @internal
365
+ */
366
+ export function chaseBoomOffset(yaw, pitch, distance, height) {
367
+ const forward = forwardVector(yaw);
368
+ const cos = Math.cos(pitch);
369
+ const sin = Math.sin(pitch);
370
+ const along = -distance * cos - height * sin;
371
+ return { x: forward.x * along, y: -distance * sin + height * cos, z: forward.z * along };
372
+ }
373
+ /** Look point ahead of / above a chased vehicle, tilted by `pitch` like the boom.
374
+ * @internal
375
+ */
376
+ export function chaseLookAt(follow, yaw, resolved, pitch = 0) {
377
+ const forward = forwardVector(yaw);
378
+ const cos = Math.cos(pitch);
379
+ const sin = Math.sin(pitch);
380
+ const along = 2 * cos - resolved.lookHeight * sin;
335
381
  return {
336
- x: follow.x - back.x * resolved.distance,
337
- y: follow.y + resolved.height,
338
- z: follow.z - back.z * resolved.distance,
382
+ x: follow.x + forward.x * along,
383
+ y: follow.y + 2 * sin + resolved.lookHeight * cos,
384
+ z: follow.z + forward.z * along,
339
385
  };
340
386
  }
341
- /** Look point ahead of / above a chased vehicle.
387
+ /** @internal */
388
+ export function createChaseRigState() {
389
+ return { offset: null, anchorYaw: null, lastYaw: null, lastFollow: null, roll: 0, pitch: 0, fov: null, kick: 0, lookingBack: false };
390
+ }
391
+ /**
392
+ * One frame of the chase rig: speed from sim velocity, smoothed FOV plus decaying kicks, drift-lag
393
+ * anchor yaw, pitch follow, distance by speed, look-back, and a boom spring that eases the offset
394
+ * from the target rather than its world position, so the camera holds its distance at speed.
342
395
  * @internal
343
396
  */
344
- export function chaseLookAt(follow, yaw, resolved) {
345
- const forward = forwardVector(yaw);
397
+ export function stepChase(state, sample, resolved, dt, clampBoom) {
398
+ const { follow, yaw } = sample;
399
+ const last = state.lastFollow ?? follow;
400
+ const velocity = sample.velocity ?? (dt > 0 ? { x: (follow.x - last.x) / dt, y: 0, z: (follow.z - last.z) / dt } : { x: 0, y: 0, z: 0 });
401
+ state.lastFollow = follow;
402
+ const speed = Math.hypot(velocity.x, velocity.z);
403
+ const lastYaw = state.lastYaw ?? yaw;
404
+ state.lastYaw = yaw;
405
+ state.roll = bankRollStep(state.roll, yaw, lastYaw, dt, resolved);
406
+ const targetFov = speedToFov(speed, { base: resolved.fovBase, max: resolved.fovMax, speedForMax: resolved.fovSpeedForMax });
407
+ state.fov =
408
+ state.fov === null || !Number.isFinite(resolved.fovResponse)
409
+ ? targetFov
410
+ : state.fov + (targetFov - state.fov) * smoothBlend(dt, resolved.fovResponse);
411
+ state.kick = clamp(state.kick * Math.exp(-resolved.fovKickDecay * dt) + sample.fovKick, -resolved.fovKickMax, resolved.fovKickMax);
412
+ let targetYaw = yaw;
413
+ let response = resolved.yawResponse;
414
+ if (resolved.velocityYawBlend > 0) {
415
+ targetYaw = velocityYawTarget(yaw, velocity, resolved);
416
+ response = resolved.velocityYawResponse;
417
+ }
418
+ const anchorYaw = Number.isFinite(response)
419
+ ? smoothYaw(state.anchorYaw ?? targetYaw, targetYaw, response, dt)
420
+ : targetYaw;
421
+ state.anchorYaw = anchorYaw;
422
+ const pitchTarget = clamp(-sample.bodyPitch * resolved.pitchBlend, -resolved.pitchMax, resolved.pitchMax);
423
+ state.pitch =
424
+ resolved.pitchBlend <= 0 ? 0 : state.pitch + (pitchTarget - state.pitch) * smoothBlend(dt, resolved.pitchResponse);
425
+ const snap = state.offset === null || sample.lookBack !== state.lookingBack;
426
+ state.lookingBack = sample.lookBack;
427
+ const boomYaw = sample.lookBack ? anchorYaw + Math.PI : anchorYaw;
428
+ const boomPitch = sample.lookBack ? -state.pitch : state.pitch;
429
+ const extra = resolved.distanceSpeedForMax > 0 ? resolved.distanceExtra * clamp(speed / resolved.distanceSpeedForMax, 0, 1) : 0;
430
+ const led = sample.lookBack ? follow : leadFromVelocity(follow, velocity, resolved);
431
+ const desiredOffset = chaseBoomOffset(boomYaw, boomPitch, resolved.distance + extra, resolved.height);
432
+ let offset = snap || state.offset === null ? desiredOffset : springArmStep(state.offset, desiredOffset, resolved.springDamping, dt);
433
+ if (clampBoom !== undefined) {
434
+ const up = chaseLookAt(led, boomYaw, resolved, boomPitch);
435
+ const pivot = { x: led.x, y: up.y, z: led.z };
436
+ const wanted = { x: led.x + offset.x, y: led.y + offset.y, z: led.z + offset.z };
437
+ const allowed = clampBoom(pivot, wanted);
438
+ offset = { x: allowed.x - led.x, y: allowed.y - led.y, z: allowed.z - led.z };
439
+ }
440
+ state.offset = offset;
346
441
  return {
347
- x: follow.x + forward.x * 2,
348
- y: follow.y + resolved.lookHeight,
349
- z: follow.z + forward.z * 2,
442
+ pose: {
443
+ position: { x: led.x + offset.x, y: led.y + offset.y, z: led.z + offset.z },
444
+ lookAt: chaseLookAt(led, boomYaw, resolved, boomPitch),
445
+ fov: state.fov + state.kick,
446
+ },
447
+ roll: sample.lookBack ? -state.roll : state.roll,
448
+ anchorYaw,
449
+ speed,
350
450
  };
351
451
  }
452
+ /**
453
+ * Where a blocked boom should put the camera: `hitDistance` along the pivot→desired ray, less
454
+ * `radius`, but never nearer the pivot than `minDistance`. `null` hit returns `desired` unchanged.
455
+ * @internal
456
+ */
457
+ export function clampBoomToHit(pivot, desired, hitDistance, radius, minDistance) {
458
+ if (hitDistance === null)
459
+ return desired;
460
+ const dx = desired.x - pivot.x;
461
+ const dy = desired.y - pivot.y;
462
+ const dz = desired.z - pivot.z;
463
+ const length = Math.hypot(dx, dy, dz);
464
+ if (length <= 1e-6)
465
+ return desired;
466
+ const allowed = clamp(hitDistance - radius, Math.min(minDistance, length), length);
467
+ const scale = allowed / length;
468
+ return { x: pivot.x + dx * scale, y: pivot.y + dy * scale, z: pivot.z + dz * scale };
469
+ }
352
470
  /**
353
471
  * Drift-lag (#1051): the yaw the chase anchor should aim before temporal
354
472
  * smoothing. When the target slides, its planar `velocity` diverges from its
@@ -1,4 +1,7 @@
1
1
  import type { CameraRigKind, GameCameraConfig } from "@jgengine/core/game/playableGame";
2
+ import type { CameraRigOverride } from "@jgengine/core/runtime/cameraDirector";
3
+ /** @internal */
4
+ export declare function resolveRuntimeCameraConfig(config: GameCameraConfig | undefined, override: CameraRigOverride | null | undefined): GameCameraConfig | undefined;
2
5
  /**
3
6
  * Resolves which rig mounts from a `GameCameraConfig`. Precedence, most to least
4
7
  * specific: an explicit `rig` field always wins; then `perspective: "first"`
@@ -1,3 +1,14 @@
1
+ /** @internal */
2
+ export function resolveRuntimeCameraConfig(config, override) {
3
+ if (override === null || override === undefined)
4
+ return config;
5
+ const merged = { ...config, ...override.config, rig: override.kind };
6
+ for (const key of ["firstPerson", "topDown", "rts", "shoulder", "lockOn", "chase", "observer", "turntable", "sideScroll", "inspection", "shake"]) {
7
+ if (override.config[key] !== undefined)
8
+ Object.assign(merged, { [key]: { ...config?.[key], ...override.config[key] } });
9
+ }
10
+ return merged;
11
+ }
1
12
  /**
2
13
  * Resolves which rig mounts from a `GameCameraConfig`. Precedence, most to least
3
14
  * specific: an explicit `rig` field always wins; then `perspective: "first"`