@jgengine/shell 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +265 -2
  2. package/dist/GamePlayerShell.js +27 -4
  3. package/dist/JoinGate.d.ts +17 -0
  4. package/dist/JoinGate.js +14 -0
  5. package/dist/Shell3dPresentation.d.ts +9 -8
  6. package/dist/Shell3dPresentation.js +57 -8
  7. package/dist/ShellChrome.d.ts +2 -0
  8. package/dist/ShellChrome.js +6 -0
  9. package/dist/ShellHudPresentation.d.ts +9 -1
  10. package/dist/ShellHudPresentation.js +21 -0
  11. package/dist/TerritoryOverlay.d.ts +21 -0
  12. package/dist/TerritoryOverlay.js +9 -0
  13. package/dist/audio/AudioComponents.js +13 -2
  14. package/dist/audio/audioEngine.d.ts +15 -1
  15. package/dist/audio/audioEngine.js +132 -11
  16. package/dist/audio/audioWire.d.ts +3 -0
  17. package/dist/audio/audioWire.js +7 -1
  18. package/dist/audio/loopParams.d.ts +39 -0
  19. package/dist/audio/loopParams.js +43 -0
  20. package/dist/camera/GameCameraRig.js +1 -1
  21. package/dist/camera/GameFirstPersonCamera.d.ts +4 -2
  22. package/dist/camera/GameFirstPersonCamera.js +52 -15
  23. package/dist/camera/Viewports.d.ts +34 -0
  24. package/dist/camera/Viewports.js +123 -0
  25. package/dist/camera/cameraRigs.js +77 -62
  26. package/dist/camera/orbitCameraMath.js +2 -2
  27. package/dist/camera/rigMath.d.ts +82 -4
  28. package/dist/camera/rigMath.js +133 -15
  29. package/dist/defineGame.d.ts +9 -0
  30. package/dist/defineGame.js +38 -6
  31. package/dist/devtools/DevtoolsOverlay.js +2 -0
  32. package/dist/devtools/KeysPanel.js +3 -3
  33. package/dist/devtools/agentBridge.d.ts +6 -0
  34. package/dist/devtools/agentBridge.js +15 -1
  35. package/dist/diagnostics/FpsCounter.d.ts +17 -0
  36. package/dist/diagnostics/FpsCounter.js +32 -0
  37. package/dist/drivers/FrameDriver.d.ts +5 -1
  38. package/dist/drivers/FrameDriver.js +74 -26
  39. package/dist/drivers/FrameRateLimiter.d.ts +9 -0
  40. package/dist/drivers/FrameRateLimiter.js +39 -0
  41. package/dist/drivers/HudOnlyDriver.js +10 -5
  42. package/dist/environment/DayNightSky.js +5 -5
  43. package/dist/environment/Daylight.d.ts +35 -1
  44. package/dist/environment/Daylight.js +98 -28
  45. package/dist/environment/EnvironmentScene.js +1 -1
  46. package/dist/environment/VolumetricClouds.js +20 -2
  47. package/dist/environment/daylightCycle.d.ts +20 -0
  48. package/dist/environment/daylightCycle.js +59 -7
  49. package/dist/environment/sunShadowMath.d.ts +45 -0
  50. package/dist/environment/sunShadowMath.js +50 -0
  51. package/dist/input/gamepadMerge.d.ts +10 -0
  52. package/dist/input/gamepadMerge.js +12 -0
  53. package/dist/input/gamepadPoll.d.ts +47 -0
  54. package/dist/input/gamepadPoll.js +142 -0
  55. package/dist/input/gamepadSource.d.ts +22 -0
  56. package/dist/input/gamepadSource.js +153 -0
  57. package/dist/input/mouseLook.js +2 -1
  58. package/dist/input/padHaptics.d.ts +17 -0
  59. package/dist/input/padHaptics.js +32 -0
  60. package/dist/input/pointerLock.d.ts +11 -0
  61. package/dist/input/pointerLock.js +19 -0
  62. package/dist/materialOverride.d.ts +4 -1
  63. package/dist/materialOverride.js +10 -1
  64. package/dist/postfx/PostProcessing.d.ts +11 -8
  65. package/dist/postfx/PostProcessing.js +38 -16
  66. package/dist/postfx/gradeShader.js +13 -0
  67. package/dist/postfx/stylizePass.d.ts +23 -0
  68. package/dist/postfx/stylizePass.js +216 -0
  69. package/dist/render/CascadedShadows.d.ts +11 -0
  70. package/dist/render/CascadedShadows.js +19 -8
  71. package/dist/render/EnvironmentLighting.d.ts +4 -1
  72. package/dist/render/EnvironmentLighting.js +45 -9
  73. package/dist/render/SceneLighting.d.ts +16 -2
  74. package/dist/render/SceneLighting.js +56 -6
  75. package/dist/render/SceneModels.js +45 -9
  76. package/dist/render/SkinnedInstances.d.ts +91 -0
  77. package/dist/render/SkinnedInstances.js +249 -0
  78. package/dist/render/TextureFiltering.d.ts +16 -0
  79. package/dist/render/TextureFiltering.js +64 -0
  80. package/dist/render/displayColor.d.ts +33 -0
  81. package/dist/render/displayColor.js +138 -0
  82. package/dist/render/modelLoad.d.ts +39 -0
  83. package/dist/render/modelLoad.js +89 -1
  84. package/dist/render/modelRender.d.ts +3 -0
  85. package/dist/render/modelRender.js +6 -2
  86. package/dist/render/resolveModel.js +4 -0
  87. package/dist/render/useFootIk.d.ts +58 -0
  88. package/dist/render/useFootIk.js +216 -0
  89. package/dist/render/useModelAnimation.d.ts +26 -0
  90. package/dist/render/useModelAnimation.js +184 -107
  91. package/dist/scene/AuthoredScene.d.ts +9 -0
  92. package/dist/scene/AuthoredScene.js +17 -1
  93. package/dist/scene/CityRenderer.js +666 -241
  94. package/dist/scene/cityWindowMaterial.d.ts +8 -6
  95. package/dist/scene/cityWindowMaterial.js +80 -21
  96. package/dist/settings/QuickControls.js +1 -1
  97. package/dist/settings/SettingsMenu.js +1 -1
  98. package/dist/settings/appliedSettings.d.ts +10 -2
  99. package/dist/settings/appliedSettings.js +9 -5
  100. package/dist/settings/settingsController.d.ts +3 -0
  101. package/dist/settings/settingsController.js +47 -3
  102. package/dist/structures/BuildingKitBatch.js +26 -22
  103. package/dist/structures/GeneratedBuilding.d.ts +3 -15
  104. package/dist/structures/GeneratedBuilding.js +21 -15
  105. package/dist/structures/buildingSurface.d.ts +21 -0
  106. package/dist/structures/buildingSurface.js +134 -0
  107. package/dist/terrain/terrainDetailMaterial.js +7 -3
  108. package/dist/useShellMultiplayerSync.d.ts +9 -2
  109. package/dist/useShellMultiplayerSync.js +30 -8
  110. package/dist/visibility/CullingProvider.d.ts +2 -1
  111. package/dist/visibility/CullingProvider.js +7 -2
  112. package/dist/world/GridWorldScene.js +4 -0
  113. package/dist/world/TileLayerRenderer.d.ts +9 -0
  114. package/dist/world/TileLayerRenderer.js +64 -0
  115. package/dist/world/WorldParticles.d.ts +31 -0
  116. package/dist/world/WorldParticles.js +128 -0
  117. package/dist/world/WorldScene.d.ts +2 -1
  118. package/dist/world/WorldScene.js +46 -4
  119. package/dist/world/entityPose.d.ts +4 -0
  120. package/dist/world/entityPose.js +9 -0
  121. package/dist/world/tileLayerInstances.d.ts +11 -0
  122. package/dist/world/tileLayerInstances.js +28 -0
  123. package/dist/worldSync.d.ts +5 -1
  124. package/dist/worldSync.js +25 -4
  125. package/package.json +4 -4
  126. package/skills/ce-handoff/SKILL.md +2 -2
  127. package/skills/game-design/SKILL.md +11 -1
  128. package/skills/jgengine/SKILL.md +9 -5
  129. package/skills/jgengine/api.md +205 -101
  130. package/skills/jgengine/capabilities.md +56 -0
  131. package/skills/jgengine/recipes/minimal-game.md +3 -0
  132. package/skills/jgengine-ui/SKILL.md +20 -15
  133. package/skills/jgengine-ui/api.md +538 -326
  134. package/skills/jgengine-ui/capabilities.md +37 -0
  135. package/skills/jgengine-ui/reference-primitives.md +89 -0
  136. package/skills/jgengine-ui/reference.md +2 -78
  137. package/skills/jgengine-world/SKILL.md +21 -3
  138. package/skills/jgengine-world/api.md +1172 -625
  139. package/skills/jgengine-world/capabilities.md +229 -3
  140. package/skills/jgengine-world/recipes/character-animation.md +39 -0
  141. package/skills/jgengine-world/recipes/controls.md +44 -0
  142. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  143. package/skills/jgengine-world/recipes/npc-senses.md +43 -0
  144. package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
  145. package/skills/jgengine-world/reference.md +31 -2
  146. package/skills/jgengine-world/visibility.md +112 -0
  147. package/skills/level-design/SKILL.md +1 -1
  148. package/skills/jgengine-ui/references/visual-scorecard.md +0 -73
@@ -26,3 +26,46 @@ export function clampLoopGain(gain) {
26
26
  return 0;
27
27
  return Math.min(1, Math.max(0, gain));
28
28
  }
29
+ /** Lowest live filter cutoff, Hz. */
30
+ export const MIN_LOOP_CUTOFF = 10;
31
+ /** Highest live filter cutoff, Hz; the engine also caps it at the context's Nyquist frequency. */
32
+ export const MAX_LOOP_CUTOFF = 22050;
33
+ /**
34
+ * Clamp a loop filter cutoff into `MIN_LOOP_CUTOFF`–`min(MAX_LOOP_CUTOFF, nyquist)` Hz. Non-finite input
35
+ * falls back to `fallback` (the open, transparent end of that filter).
36
+ * @internal — the audio engine applies this; game code sets cutoffs through `ctx.game.audio.setLoop`.
37
+ */
38
+ export function clampLoopCutoff(hz, fallback, nyquist = MAX_LOOP_CUTOFF) {
39
+ const max = Math.min(MAX_LOOP_CUTOFF, nyquist);
40
+ if (!Number.isFinite(hz))
41
+ return Math.min(max, Math.max(MIN_LOOP_CUTOFF, fallback));
42
+ return Math.min(max, Math.max(MIN_LOOP_CUTOFF, hz));
43
+ }
44
+ /** @internal */
45
+ export function createListenerVelocityTrack() {
46
+ return { last: null, velocity: { x: 0, y: 0, z: 0 } };
47
+ }
48
+ /** Camera jumps faster than this (world units/s) are cuts or teleports, not motion, and reset the estimate. */
49
+ const MAX_LISTENER_SPEED = 400;
50
+ /**
51
+ * Advance the listener velocity estimate by one frame: the position delta over `dt`, eased with a
52
+ * ~50 ms time constant so frame-time jitter does not warble doppler. A cut resets it to zero.
53
+ * @internal
54
+ */
55
+ export function stepListenerVelocity(track, position, dt) {
56
+ const last = track.last;
57
+ track.last = { x: position.x, y: position.y, z: position.z };
58
+ if (last === null || !(dt > 0))
59
+ return track.velocity;
60
+ const vx = (position.x - last.x) / dt;
61
+ const vy = (position.y - last.y) / dt;
62
+ const vz = (position.z - last.z) / dt;
63
+ if (Math.hypot(vx, vy, vz) > MAX_LISTENER_SPEED) {
64
+ track.velocity = { x: 0, y: 0, z: 0 };
65
+ return track.velocity;
66
+ }
67
+ const blend = 1 - Math.exp(-dt / 0.05);
68
+ const v = track.velocity;
69
+ track.velocity = { x: v.x + (vx - v.x) * blend, y: v.y + (vy - v.y) * blend, z: v.z + (vz - v.z) * blend };
70
+ return track.velocity;
71
+ }
@@ -37,7 +37,7 @@ export function GameCameraRig({ yawRef, pitchRef, config, onDragChange, pointerC
37
37
  const kind = resolveRigKind(config);
38
38
  switch (kind) {
39
39
  case "first":
40
- return (_jsx(GameFirstPersonCamera, { yawRef: yawRef, pitchRef: pitchRef, config: config?.firstPerson, followEntityId: followEntityId ?? undefined, viewmodel: viewmodel }));
40
+ return (_jsx(GameFirstPersonCamera, { yawRef: yawRef, pitchRef: pitchRef, config: config?.firstPerson, followEntityId: followEntityId ?? undefined, viewmodel: viewmodel, weapon: config?.weapon }));
41
41
  case "topDown":
42
42
  return _jsx(TopDownRig, { yawRef: yawRef, pitchRef: pitchRef, config: config, followEntityId: followEntityId });
43
43
  case "rts":
@@ -1,6 +1,6 @@
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
5
  /** World position of the first-person weapon muzzle, or false when no viewmodel is mounted. */
6
6
  export declare function readFirstPersonMuzzle(target: THREE.Vector3): boolean;
@@ -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,18 +1,19 @@
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";
11
13
  const DEFAULT_SENSITIVITY = 0.0025;
12
14
  const DEFAULT_MAX_PITCH = 1.45;
13
15
  const VIEWMODEL_ORIGIN = new THREE.Vector3(0.34, -0.26, -0.72);
14
16
  const MUZZLE_TIP_LOCAL = new THREE.Vector3(0, 0.03, -0.61);
15
- const MUZZLE_OFFSET = VIEWMODEL_ORIGIN.clone().add(MUZZLE_TIP_LOCAL);
16
17
  const muzzleWorld = new THREE.Vector3();
17
18
  let muzzleTracked = false;
18
19
  /** World position of the first-person weapon muzzle, or false when no viewmodel is mounted. */
@@ -22,7 +23,7 @@ export function readFirstPersonMuzzle(target) {
22
23
  target.copy(muzzleWorld);
23
24
  return true;
24
25
  }
25
- export function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId, viewmodel, }) {
26
+ export function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId, viewmodel, weapon, }) {
26
27
  const eyeHeight = config?.eyeHeight ?? DEFAULT_EYE_HEIGHT;
27
28
  const sensitivity = config?.sensitivity ?? DEFAULT_SENSITIVITY;
28
29
  const maxPitch = config?.maxPitch ?? DEFAULT_MAX_PITCH;
@@ -34,12 +35,16 @@ export function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId
34
35
  const followId = followEntityId ?? userId;
35
36
  const seededRef = useRef(false);
36
37
  const cuesRef = useEntityRenderCues(followId);
38
+ const presentation = useMemo(() => createWeaponPresentation(), []);
39
+ const tuningRef = useRef(undefined);
40
+ const lastLookRef = useRef(null);
41
+ const poseRef = useRef(null);
37
42
  useEffect(() => {
38
43
  const requestLock = () => {
39
44
  if (window.matchMedia?.("(pointer: coarse)").matches)
40
45
  return;
41
46
  if (document.pointerLockElement !== domElement)
42
- void domElement.requestPointerLock?.();
47
+ requestRawPointerLock(domElement);
43
48
  };
44
49
  const onMove = (event) => {
45
50
  if (document.pointerLockElement !== domElement)
@@ -54,7 +59,7 @@ export function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId
54
59
  window.removeEventListener("mousemove", onMove);
55
60
  };
56
61
  }, [domElement, sensitivity, maxPitch, yawRef, pitchRef]);
57
- useFrame(() => {
62
+ useFrame((_, dt) => {
58
63
  const entity = ctx.scene.entity.get(followId);
59
64
  if (entity === null)
60
65
  return;
@@ -62,22 +67,45 @@ export function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId
62
67
  seededRef.current = true;
63
68
  yawRef.current = entity.rotationY;
64
69
  }
65
- const cosPitch = Math.cos(pitchRef.current);
70
+ const view = weapon?.(followId) ?? null;
71
+ const last = (lastLookRef.current ??= { yaw: yawRef.current, pitch: pitchRef.current });
72
+ const step = Math.max(1e-4, dt);
73
+ let pose = null;
74
+ if (view !== null) {
75
+ if (view.presentation !== tuningRef.current) {
76
+ tuningRef.current = view.presentation;
77
+ presentation.retune(view.presentation ?? {});
78
+ }
79
+ pose = presentation.update(dt, {
80
+ lookYawRate: (yawRef.current - last.yaw) / step,
81
+ lookPitchRate: (pitchRef.current - last.pitch) / step,
82
+ speed: cuesRef.current.speed,
83
+ bobPhase: cuesRef.current.bobPhase,
84
+ handling: view.handling,
85
+ });
86
+ }
87
+ last.yaw = yawRef.current;
88
+ last.pitch = pitchRef.current;
89
+ poseRef.current = pose;
90
+ const yaw = yawRef.current - (pose?.lookYaw ?? 0);
91
+ const pitch = Math.max(-maxPitch, Math.min(maxPitch, pitchRef.current + (pose?.lookPitch ?? 0)));
92
+ const cosPitch = Math.cos(pitch);
66
93
  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);
94
+ camera.lookAt(camera.position.x + Math.sin(yaw) * cosPitch, camera.position.y + Math.sin(pitch), camera.position.z + Math.cos(yaw) * cosPitch);
68
95
  if (camera.isPerspectiveCamera === true) {
69
96
  const perspective = camera;
70
- if (Math.abs(perspective.fov - playerFov.fov) > 0.001) {
71
- perspective.fov = playerFov.fov;
97
+ const fov = playerFov.fov * (pose?.fovScale ?? 1);
98
+ if (Math.abs(perspective.fov - fov) > 0.001) {
99
+ perspective.fov = fov;
72
100
  perspective.updateProjectionMatrix();
73
101
  }
74
102
  }
75
103
  }, ORBIT_CAMERA_FRAME_PRIORITY);
76
104
  if (config?.viewmodel === false)
77
105
  return null;
78
- return _jsx(FirstPersonViewmodel, { camera: camera, viewmodel: viewmodel, cuesRef: cuesRef });
106
+ return _jsx(FirstPersonViewmodel, { camera: camera, viewmodel: viewmodel, cuesRef: cuesRef, poseRef: poseRef });
79
107
  }
80
- function FirstPersonViewmodel({ camera, viewmodel: Viewmodel, cuesRef, }) {
108
+ function FirstPersonViewmodel({ camera, viewmodel: Viewmodel, cuesRef, poseRef, }) {
81
109
  const groupRef = useRef(null);
82
110
  useEffect(() => () => {
83
111
  muzzleTracked = false;
@@ -86,12 +114,21 @@ function FirstPersonViewmodel({ camera, viewmodel: Viewmodel, cuesRef, }) {
86
114
  const group = groupRef.current;
87
115
  if (group === null)
88
116
  return;
117
+ const pose = poseRef.current;
118
+ const perspective = camera;
119
+ // Scaling camera-space x/y by this projects the viewmodel at its own FOV without a second render pass.
120
+ const squash = pose?.viewmodelFov == null || perspective.isPerspectiveCamera !== true ? 1 : viewmodelFovScale(perspective.fov, pose.viewmodelFov);
89
121
  group.position.copy(camera.position);
90
122
  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);
123
+ group.translateX((pose?.offset[0] ?? VIEWMODEL_ORIGIN.x) * squash);
124
+ group.translateY((pose?.offset[1] ?? VIEWMODEL_ORIGIN.y) * squash);
125
+ group.translateZ(pose?.offset[2] ?? VIEWMODEL_ORIGIN.z);
126
+ group.rotateX(pose?.pitch ?? 0);
127
+ group.rotateY(pose?.yaw ?? 0);
128
+ group.rotateZ(pose?.roll ?? 0);
129
+ group.scale.set(squash, squash, 1);
130
+ group.updateMatrixWorld();
131
+ group.localToWorld(muzzleWorld.copy(MUZZLE_TIP_LOCAL));
95
132
  muzzleTracked = true;
96
133
  }, GAME_SIM_FRAME_PRIORITY);
97
134
  if (Viewmodel !== undefined) {
@@ -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
+ }
@@ -1,13 +1,15 @@
1
1
  import { useFrame, useThree } from "@react-three/fiber";
2
2
  import { useEffect, useMemo, useRef } from "react";
3
3
  import { PerspectiveCamera, Quaternion, Vector3 } from "three";
4
+ import { snapPixelPerfectPosition } from "@jgengine/core/game/pixelPerfect";
4
5
  import { usePlayer } from "@jgengine/react/hooks";
5
6
  import { useGameContext } from "@jgengine/react/provider";
6
7
  import { ORBIT_CAMERA_FRAME_PRIORITY } from "./orbitCameraMath.js";
7
- 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";
8
9
  import { usePlayerFov } from "./PlayerFov.js";
9
10
  import { useCameraShake } from "./shakeChannel.js";
10
11
  import { applyCameraBlendStep, captureCameraBlendFrom, createCameraBlendScratch, } from "./cameraBlendMath.js";
12
+ import { requestRawPointerLock } from "../input/pointerLock.js";
11
13
  export { applyCameraBlendStep, captureCameraBlendFrom, createCameraBlendScratch, } from "./cameraBlendMath.js";
12
14
  export const CAMERA_RIG_FRAME_PRIORITY = ORBIT_CAMERA_FRAME_PRIORITY;
13
15
  export const CAMERA_POST_FRAME_PRIORITY = ORBIT_CAMERA_FRAME_PRIORITY + 0.5;
@@ -162,6 +164,12 @@ export function SideScrollRig(props) {
162
164
  };
163
165
  followRef.current = follow;
164
166
  const pose = resolveSideScrollPose(follow, resolved, config?.fov ?? currentFov(camera));
167
+ const pixelPerfect = props.config?.projection === "orthographic" ? props.config.pixelPerfect : undefined;
168
+ if (pixelPerfect !== undefined) {
169
+ const snapped = snapPixelPerfectPosition(pose.position, pixelPerfect.pixelsPerUnit);
170
+ pose.position = snapped;
171
+ pose.lookAt = snapPixelPerfectPosition(pose.lookAt, pixelPerfect.pixelsPerUnit);
172
+ }
165
173
  commit(pose, dt);
166
174
  }, CAMERA_RIG_FRAME_PRIORITY);
167
175
  return null;
@@ -299,7 +307,7 @@ export function ShoulderRig(props) {
299
307
  useEffect(() => {
300
308
  const requestLock = () => {
301
309
  if (document.pointerLockElement !== domElement)
302
- void domElement.requestPointerLock?.();
310
+ requestRawPointerLock(domElement);
303
311
  };
304
312
  const onMove = (event) => {
305
313
  if (document.pointerLockElement !== domElement)
@@ -338,8 +346,13 @@ export function ShoulderRig(props) {
338
346
  useFrame((_, dt) => {
339
347
  const sample = readFollow(ctx, followId);
340
348
  const follow = sample?.pos ?? { x: 0, y: 0, z: 0 };
341
- const adsTarget = aimingRef.current ? 1 : 0;
342
- aimRef.current += (adsTarget - aimRef.current) * (1 - Math.exp(-(config?.adsTransitionSpeed ?? 10) * dt));
349
+ const weapon = followId === null ? null : (props.config?.weapon?.(followId) ?? null);
350
+ if (weapon === null) {
351
+ const adsTarget = aimingRef.current ? 1 : 0;
352
+ aimRef.current += (adsTarget - aimRef.current) * (1 - Math.exp(-(config?.adsTransitionSpeed ?? 10) * dt));
353
+ }
354
+ else
355
+ aimRef.current = weapon.handling.adsProgress;
343
356
  const hip = resolveShoulder(config, false);
344
357
  const ads = resolveShoulder(config, true);
345
358
  const blended = {
@@ -348,7 +361,11 @@ export function ShoulderRig(props) {
348
361
  distance: hip.distance + (ads.distance - hip.distance) * aimRef.current,
349
362
  fov: hip.fov + (ads.fov - hip.fov) * aimRef.current,
350
363
  };
351
- const pose = shoulderPose(follow, props.yawRef.current, props.pitchRef.current, sideRef.current, blended);
364
+ const handling = weapon?.handling;
365
+ const yaw = props.yawRef.current - (handling === undefined ? 0 : handling.aimYaw + handling.cameraYaw);
366
+ // This rig's pitch is camera elevation, so a muzzle climb lowers it.
367
+ const pitch = props.pitchRef.current - (handling === undefined ? 0 : handling.aimPitch + handling.cameraPitch);
368
+ const pose = shoulderPose(follow, yaw, pitch, sideRef.current, blended);
352
369
  commit(pose, dt);
353
370
  }, CAMERA_RIG_FRAME_PRIORITY);
354
371
  return null;
@@ -417,73 +434,71 @@ export function ChaseRig(props) {
417
434
  const staticResolved = useMemo(() => resolveChase(config), [config]);
418
435
  const { camera, commit, beginTransition } = useCameraCommit(props, followId);
419
436
  const shake = useCameraShake();
420
- const posRef = useRef(null);
421
- const lastFollowRef = useRef(null);
422
- const lastYawRef = useRef(null);
423
- const anchorYawRef = useRef(null);
424
- const rollRef = useRef(0);
425
- const view = config?.view ?? "chase";
426
- useEffect(beginTransition, [view]);
437
+ const stateRef = useRef(createChaseRigState());
438
+ const viewRef = useRef(null);
427
439
  useFrame((_, dt) => {
428
440
  const tuning = ctx.camera.chaseTuning();
429
- const resolved = tuning === null ? staticResolved : resolveChase({ ...config, ...tuning });
430
- const sample = readFollow(ctx, followId);
431
- const follow = sample?.pos ?? { x: 0, y: 0, z: 0 };
432
- const yaw = sample?.yaw ?? 0;
433
- const last = lastFollowRef.current ?? follow;
434
- const speed = dt > 0 ? Math.hypot(follow.x - last.x, follow.z - last.z) / dt : 0;
435
- lastFollowRef.current = follow;
436
- const lastYaw = lastYawRef.current ?? yaw;
437
- lastYawRef.current = yaw;
438
- rollRef.current = bankRollStep(rollRef.current, yaw, lastYaw, dt, resolved);
439
- const fov = speedToFov(speed, tuning?.fov ?? config?.fov);
441
+ const merged = tuning === null ? config : { ...config, ...tuning };
442
+ const resolved = tuning === null ? staticResolved : resolveChase(merged);
443
+ const view = merged?.view ?? "chase";
444
+ if (viewRef.current !== view) {
445
+ beginTransition();
446
+ viewRef.current = view;
447
+ stateRef.current.offset = null;
448
+ }
449
+ const entity = followId === null ? null : ctx.scene.entity.get(followId);
450
+ const follow = entity === null ? { x: 0, y: 0, z: 0 } : { x: entity.position[0], y: entity.position[1], z: entity.position[2] };
451
+ const yaw = entity?.rotationY ?? 0;
452
+ const v = entity?.velocity;
453
+ const published = v !== undefined && (v[0] !== 0 || v[1] !== 0 || v[2] !== 0);
454
+ const lookBack = resolved.lookBackAction !== null && ctx.input.isDown(resolved.lookBackAction);
455
+ const clampBoom = resolved.collision
456
+ ? (pivot, desired) => {
457
+ const dx = desired.x - pivot.x;
458
+ const dy = desired.y - pivot.y;
459
+ const dz = desired.z - pivot.z;
460
+ const length = Math.hypot(dx, dy, dz);
461
+ if (length <= 1e-6)
462
+ return desired;
463
+ const hit = ctx.scene.raycast({
464
+ origin: [pivot.x, pivot.y, pivot.z],
465
+ direction: [dx / length, dy / length, dz / length],
466
+ maxDistance: length + resolved.collisionRadius,
467
+ excludeInstanceIds: followId === null ? undefined : [followId],
468
+ filter: { entities: false },
469
+ });
470
+ return clampBoomToHit(pivot, desired, hit?.distance ?? null, resolved.collisionRadius, resolved.collisionMinDistance);
471
+ }
472
+ : undefined;
473
+ const step = stepChase(stateRef.current, {
474
+ follow,
475
+ yaw,
476
+ bodyPitch: entity?.rotationX ?? 0,
477
+ velocity: published ? { x: v[0], y: v[1], z: v[2] } : null,
478
+ lookBack: lookBack && view === "chase",
479
+ fovKick: ctx.camera.takeFovKick(),
480
+ }, resolved, dt, view === "chase" ? clampBoom : undefined);
481
+ props.yawRef.current = step.anchorYaw;
440
482
  if (view !== "chase") {
441
483
  const seat = view === "cockpit"
442
- ? config?.seatOffsets?.cockpit ?? { x: 0, y: 1.2, z: 0.1 }
484
+ ? merged?.seatOffsets?.cockpit ?? { x: 0, y: 1.2, z: 0.1 }
443
485
  : view === "hood"
444
- ? config?.seatOffsets?.hood ?? { x: 0, y: 1, z: 1.4 }
445
- : config?.seatOffsets?.rear ?? { x: 0, y: 1.6, z: -1.5 };
446
- const lookBack = view === "rear";
447
- const pose = seatPose(follow, lookBack ? yaw + Math.PI : yaw, seat, fov);
448
- if (lookBack) {
449
- pose.position = seatPose(follow, yaw, seat, fov).position;
450
- }
486
+ ? merged?.seatOffsets?.hood ?? { x: 0, y: 1, z: 1.4 }
487
+ : merged?.seatOffsets?.rear ?? { x: 0, y: 1.6, z: -1.5 };
488
+ const seatLooksBack = view === "rear" || lookBack;
489
+ const pose = seatPose(follow, seatLooksBack ? yaw + Math.PI : yaw, seat, step.pose.fov);
490
+ if (seatLooksBack)
491
+ pose.position = seatPose(follow, yaw, seat, step.pose.fov).position;
451
492
  props.yawRef.current = yaw;
452
493
  commit(pose, dt);
453
494
  return;
454
495
  }
455
- // Anchor yaw always eases toward its target (#1370): with drift-lag configured the target is
456
- // the velocity-blended yaw at velocityYawResponse, otherwise the raw body facing at
457
- // yawResponse — so a strafe-flipped facing arcs the boom around the character instead of
458
- // teleporting it to the far side. yawResponse: Infinity restores the legacy rigid follow.
459
- let targetYaw = yaw;
460
- let response = resolved.yawResponse;
461
- if (resolved.velocityYawBlend > 0) {
462
- const velocity = dt > 0
463
- ? { x: (follow.x - last.x) / dt, y: 0, z: (follow.z - last.z) / dt }
464
- : { x: 0, y: 0, z: 0 };
465
- targetYaw = velocityYawTarget(yaw, velocity, resolved);
466
- response = resolved.velocityYawResponse;
496
+ if (resolved.shakePerSpeed > 0 && step.speed > 0) {
497
+ shake.shake(Math.min(resolved.shakePerSpeed * step.speed * dt, 0.1));
467
498
  }
468
- const anchorYaw = Number.isFinite(response)
469
- ? smoothYaw(anchorYawRef.current ?? targetYaw, targetYaw, response, dt)
470
- : targetYaw;
471
- anchorYawRef.current = anchorYaw;
472
- // Report camera yaw back to the shell like every other player-facing rig, so
473
- // on-foot movement and aim stay camera-relative instead of frozen at yaw 0.
474
- props.yawRef.current = anchorYaw;
475
- const led = leadFollowPoint(follow, last, dt, resolved);
476
- const desired = chaseDesiredPosition(led, anchorYaw, resolved);
477
- const prev = posRef.current ?? desired;
478
- const smoothed = springArmStep(prev, desired, resolved.springDamping, dt);
479
- posRef.current = smoothed;
480
- if (resolved.shakePerSpeed > 0 && speed > 0) {
481
- shake.shake(Math.min(resolved.shakePerSpeed * speed * dt, 0.1));
482
- }
483
- const pose = { position: smoothed, lookAt: chaseLookAt(led, anchorYaw, resolved), fov };
484
- commit(pose, dt);
485
- if (rollRef.current !== 0)
486
- camera.rotateZ(rollRef.current);
499
+ commit(step.pose, dt);
500
+ if (step.roll !== 0)
501
+ camera.rotateZ(step.roll);
487
502
  }, CAMERA_RIG_FRAME_PRIORITY);
488
503
  return null;
489
504
  }
@@ -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,