@jgengine/shell 0.6.0 → 0.8.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 (118) hide show
  1. package/CHANGELOG.md +214 -2
  2. package/README.md +26 -1
  3. package/dist/GameHost.d.ts +12 -0
  4. package/dist/GameHost.js +33 -0
  5. package/dist/GamePlayer.d.ts +11 -0
  6. package/dist/GamePlayer.js +24 -0
  7. package/dist/GamePlayerShell.d.ts +33 -1
  8. package/dist/GamePlayerShell.js +641 -59
  9. package/dist/audio/AudioComponents.d.ts +12 -0
  10. package/dist/audio/AudioComponents.js +53 -0
  11. package/dist/audio/audioEngine.d.ts +23 -0
  12. package/dist/audio/audioEngine.js +110 -0
  13. package/dist/behaviour.d.ts +31 -0
  14. package/dist/behaviour.js +63 -0
  15. package/dist/camera/GameCameraRig.d.ts +16 -0
  16. package/dist/camera/GameCameraRig.js +56 -0
  17. package/dist/camera/GameFirstPersonCamera.js +2 -0
  18. package/dist/camera/GameInspectionCamera.d.ts +6 -0
  19. package/dist/camera/GameInspectionCamera.js +24 -0
  20. package/dist/camera/GameOrbitCamera.d.ts +3 -1
  21. package/dist/camera/GameOrbitCamera.js +4 -2
  22. package/dist/camera/cameraRigs.d.ts +26 -0
  23. package/dist/camera/cameraRigs.js +522 -0
  24. package/dist/camera/index.d.ts +7 -0
  25. package/dist/camera/index.js +7 -0
  26. package/dist/camera/inspectionCameraMath.d.ts +25 -0
  27. package/dist/camera/inspectionCameraMath.js +44 -0
  28. package/dist/camera/rigMath.d.ts +169 -0
  29. package/dist/camera/rigMath.js +393 -0
  30. package/dist/camera/rigResolve.d.ts +15 -0
  31. package/dist/camera/rigResolve.js +52 -0
  32. package/dist/camera/shakeChannel.d.ts +29 -0
  33. package/dist/camera/shakeChannel.js +38 -0
  34. package/dist/defineGame.d.ts +16 -0
  35. package/dist/defineGame.js +51 -0
  36. package/dist/devtools/DevtoolsOverlay.d.ts +14 -0
  37. package/dist/devtools/DevtoolsOverlay.js +334 -0
  38. package/dist/environment/Daylight.d.ts +50 -0
  39. package/dist/environment/Daylight.js +100 -0
  40. package/dist/environment/EnvironmentScene.js +18 -26
  41. package/dist/environment/GroundPad.d.ts +7 -0
  42. package/dist/environment/GroundPad.js +12 -0
  43. package/dist/environment/daylightCycle.d.ts +26 -0
  44. package/dist/environment/daylightCycle.js +116 -0
  45. package/dist/environment/groundPadMath.d.ts +13 -0
  46. package/dist/environment/groundPadMath.js +10 -0
  47. package/dist/environment/index.d.ts +2 -0
  48. package/dist/environment/index.js +2 -0
  49. package/dist/map/MapMarkerBeacons.d.ts +12 -0
  50. package/dist/map/MapMarkerBeacons.js +16 -0
  51. package/dist/map/index.d.ts +2 -0
  52. package/dist/map/index.js +2 -0
  53. package/dist/map/terrainMap.d.ts +23 -0
  54. package/dist/map/terrainMap.js +79 -0
  55. package/dist/materialOverride.d.ts +8 -0
  56. package/dist/materialOverride.js +32 -0
  57. package/dist/multiplayer.d.ts +16 -9
  58. package/dist/multiplayer.js +62 -12
  59. package/dist/pointer/PointerOverlays.d.ts +12 -0
  60. package/dist/pointer/PointerOverlays.js +17 -0
  61. package/dist/pointer/PointerProbe.d.ts +4 -0
  62. package/dist/pointer/PointerProbe.js +40 -0
  63. package/dist/pointer/pointerService.d.ts +26 -0
  64. package/dist/pointer/pointerService.js +89 -0
  65. package/dist/registry.d.ts +4 -1
  66. package/dist/registry.js +3 -1
  67. package/dist/render/modelRender.d.ts +14 -0
  68. package/dist/render/modelRender.js +79 -0
  69. package/dist/replay/useSessionRecorder.d.ts +14 -0
  70. package/dist/replay/useSessionRecorder.js +22 -0
  71. package/dist/structures/PlacementGhost.d.ts +8 -0
  72. package/dist/structures/PlacementGhost.js +11 -0
  73. package/dist/structures/index.d.ts +2 -0
  74. package/dist/structures/index.js +2 -0
  75. package/dist/structures.d.ts +1 -0
  76. package/dist/structures.js +1 -0
  77. package/dist/terrain/CarvedTerrain.d.ts +22 -0
  78. package/dist/terrain/CarvedTerrain.js +19 -0
  79. package/dist/terrain/EditableGround.d.ts +11 -0
  80. package/dist/terrain/EditableGround.js +38 -0
  81. package/dist/terrain/TerraformBrushCursor.d.ts +8 -0
  82. package/dist/terrain/TerraformBrushCursor.js +13 -0
  83. package/dist/terrain/index.d.ts +4 -1
  84. package/dist/terrain/index.js +4 -1
  85. package/dist/terrain/terrainMath.d.ts +15 -1
  86. package/dist/terrain/terrainMath.js +87 -1
  87. package/dist/touch/TouchControlsOverlay.d.ts +18 -0
  88. package/dist/touch/TouchControlsOverlay.js +173 -0
  89. package/dist/vision/FrustumSensorHud.d.ts +21 -0
  90. package/dist/vision/FrustumSensorHud.js +67 -0
  91. package/dist/vision/HiddenStateProbeHud.d.ts +11 -0
  92. package/dist/vision/HiddenStateProbeHud.js +11 -0
  93. package/dist/vision/RevealVision.d.ts +28 -0
  94. package/dist/vision/RevealVision.js +53 -0
  95. package/dist/water/OceanShader.d.ts +1 -1
  96. package/dist/water/OceanShader.js +3 -3
  97. package/dist/weather/FireSpreadLayer.d.ts +14 -0
  98. package/dist/weather/FireSpreadLayer.js +68 -0
  99. package/dist/weather/index.d.ts +1 -0
  100. package/dist/weather/index.js +1 -0
  101. package/dist/world/DataObjects.d.ts +44 -0
  102. package/dist/world/DataObjects.js +75 -0
  103. package/dist/world/GridWorldScene.d.ts +10 -0
  104. package/dist/world/GridWorldScene.js +54 -0
  105. package/dist/world/InstancedJoints.d.ts +14 -0
  106. package/dist/world/InstancedJoints.js +32 -0
  107. package/dist/world/WorldHud.d.ts +7 -1
  108. package/dist/world/WorldHud.js +76 -14
  109. package/dist/world/WorldItems.d.ts +5 -0
  110. package/dist/world/WorldItems.js +50 -0
  111. package/dist/world/floatTextStyle.d.ts +14 -0
  112. package/dist/world/floatTextStyle.js +39 -0
  113. package/llms.txt +2382 -0
  114. package/package.json +10 -5
  115. package/dist/demo/demoGame.d.ts +0 -2
  116. package/dist/demo/demoGame.js +0 -208
  117. package/dist/demo/environmentShowcase.d.ts +0 -2
  118. package/dist/demo/environmentShowcase.js +0 -15
@@ -0,0 +1,522 @@
1
+ import { useFrame, useThree } from "@react-three/fiber";
2
+ import { useEffect, useMemo, useRef } from "react";
3
+ import { PerspectiveCamera, Quaternion, Vector3 } from "three";
4
+ import { usePlayer } from "@jgengine/react/hooks";
5
+ import { useGameContext } from "@jgengine/react/provider";
6
+ import { ORBIT_CAMERA_FRAME_PRIORITY } from "./orbitCameraMath.js";
7
+ import { chaseDesiredPosition, chaseLookAt, cinematicSample, clamp, lockOnPose, observerPose, resolveChase, resolveObserver, resolveShoulder, resolveSideScroll, resolveSideScrollPose, resolveTopDown, seatPose, shoulderPose, sideScrollFollowBlend, smoothstep, smoothYaw, speedToFov, springArmStep, topDownPose, } from "./rigMath.js";
8
+ import { useCameraShake } from "./shakeChannel.js";
9
+ export const CAMERA_RIG_FRAME_PRIORITY = ORBIT_CAMERA_FRAME_PRIORITY;
10
+ export const CAMERA_POST_FRAME_PRIORITY = ORBIT_CAMERA_FRAME_PRIORITY + 0.5;
11
+ function isPerspective(camera) {
12
+ return camera.isPerspectiveCamera === true;
13
+ }
14
+ function currentFov(camera) {
15
+ return isPerspective(camera) ? camera.fov : 55;
16
+ }
17
+ function applyPose(camera, pose) {
18
+ camera.position.set(pose.position.x, pose.position.y, pose.position.z);
19
+ camera.lookAt(pose.lookAt.x, pose.lookAt.y, pose.lookAt.z);
20
+ if (isPerspective(camera) && Math.abs(camera.fov - pose.fov) > 0.001) {
21
+ camera.fov = pose.fov;
22
+ camera.updateProjectionMatrix();
23
+ }
24
+ }
25
+ function resolveFollowId(explicit, userId) {
26
+ if (explicit === null)
27
+ return null;
28
+ return explicit ?? userId;
29
+ }
30
+ function readFollow(ctx, followId) {
31
+ if (followId === null)
32
+ return null;
33
+ const entity = ctx.scene.entity.get(followId);
34
+ if (entity === null)
35
+ return null;
36
+ return {
37
+ pos: { x: entity.position[0], y: entity.position[1], z: entity.position[2] },
38
+ yaw: entity.rotationY,
39
+ };
40
+ }
41
+ /**
42
+ * Shared per-frame finalizer every rig funnels through: applies the crossfade
43
+ * blend captured on a rig swap, layers the trauma-channel shake, advances the
44
+ * shake clock, and reports `onCameraFollow`. Rigs compute a clean pose; this
45
+ * hook owns shake + transitions so they behave identically across rigs.
46
+ */
47
+ function useCameraCommit(props, followId) {
48
+ const camera = useThree((state) => state.camera);
49
+ const shake = useCameraShake();
50
+ const shakeConfig = props.config?.shake;
51
+ const transitionSeconds = props.config?.transitionSeconds ?? 0.6;
52
+ const blendRef = useRef(null);
53
+ const lastPoseRef = useRef(null);
54
+ const farWarnedRef = useRef(false);
55
+ const beginTransition = () => {
56
+ if (transitionSeconds <= 0)
57
+ return;
58
+ blendRef.current = {
59
+ position: camera.position.clone(),
60
+ quaternion: camera.quaternion.clone(),
61
+ fov: currentFov(camera),
62
+ elapsed: 0,
63
+ duration: transitionSeconds,
64
+ };
65
+ };
66
+ const commit = (pose, dt) => {
67
+ lastPoseRef.current = pose;
68
+ applyPose(camera, pose);
69
+ const lookDistance = Math.hypot(pose.position.x - pose.lookAt.x, pose.position.y - pose.lookAt.y, pose.position.z - pose.lookAt.z);
70
+ if (!farWarnedRef.current && isPerspective(camera) && lookDistance > camera.far) {
71
+ farWarnedRef.current = true;
72
+ console.warn(`[jgengine:camera] the "${props.config?.rig ?? "orbit"}" rig placed the camera ${Math.round(lookDistance)} world units from its look target, beyond the far plane (${camera.far}) — everything near the target is frustum-culled and the world renders empty. Check the rig config (topDown pitch is elevation: PI/2 = straight down, near 0 = grazing) or raise camera.frustum.far.`);
73
+ }
74
+ const blend = blendRef.current;
75
+ if (blend !== null) {
76
+ blend.elapsed += dt;
77
+ const t = blend.duration <= 0 ? 1 : Math.min(blend.elapsed / blend.duration, 1);
78
+ const eased = smoothstep(t);
79
+ const targetPos = camera.position.clone();
80
+ const targetQuat = camera.quaternion.clone();
81
+ camera.position.lerpVectors(blend.position, targetPos, eased);
82
+ camera.quaternion.slerpQuaternions(blend.quaternion, targetQuat, eased);
83
+ if (isPerspective(camera)) {
84
+ camera.fov = blend.fov + (pose.fov - blend.fov) * eased;
85
+ camera.updateProjectionMatrix();
86
+ }
87
+ if (t >= 1)
88
+ blendRef.current = null;
89
+ }
90
+ shake.step(dt);
91
+ const offset = shake.sample(shakeConfig);
92
+ if (offset.x !== 0 || offset.y !== 0 || offset.roll !== 0) {
93
+ camera.translateX(offset.x);
94
+ camera.translateY(offset.y);
95
+ camera.rotateZ(offset.roll);
96
+ }
97
+ props.config?.onCameraFollow?.({
98
+ entityId: followId ?? "",
99
+ target: pose.lookAt,
100
+ camera: { x: camera.position.x, y: camera.position.y, z: camera.position.z },
101
+ distance: Math.hypot(camera.position.x - pose.lookAt.x, camera.position.y - pose.lookAt.y, camera.position.z - pose.lookAt.z),
102
+ });
103
+ };
104
+ return { camera, commit, beginTransition };
105
+ }
106
+ function useWheelZoom(initial, min, max, speed) {
107
+ const domElement = useThree((state) => state.gl.domElement);
108
+ const zoomRef = useRef(initial);
109
+ useEffect(() => {
110
+ zoomRef.current = initial;
111
+ }, [initial]);
112
+ useEffect(() => {
113
+ const onWheel = (event) => {
114
+ if (event.shiftKey)
115
+ return;
116
+ zoomRef.current = clamp(zoomRef.current + Math.sign(event.deltaY) * speed, min, max);
117
+ };
118
+ domElement.addEventListener("wheel", onWheel, { passive: true });
119
+ return () => domElement.removeEventListener("wheel", onWheel);
120
+ }, [domElement, min, max, speed]);
121
+ return zoomRef;
122
+ }
123
+ export function TopDownRig(props) {
124
+ const { userId } = usePlayer();
125
+ const ctx = useGameContext();
126
+ const followId = resolveFollowId(props.followEntityId, userId);
127
+ const config = props.config?.topDown;
128
+ const resolved = useMemo(() => resolveTopDown(config), [config]);
129
+ const { camera, commit, beginTransition } = useCameraCommit(props, followId);
130
+ const followRef = useRef(null);
131
+ const zoomRef = useWheelZoom(resolved.height, (config?.zoom?.min ?? 0.6) * resolved.height, (config?.zoom?.max ?? 1.8) * resolved.height, config?.zoom?.speed ?? resolved.height * 0.08);
132
+ useEffect(beginTransition, []);
133
+ useFrame((_, dt) => {
134
+ const sample = readFollow(ctx, followId);
135
+ const desired = sample?.pos ?? { x: 0, y: 0, z: 0 };
136
+ const prev = followRef.current ?? desired;
137
+ const blend = 1 - Math.exp(-resolved.followSmoothing * dt);
138
+ const follow = {
139
+ x: prev.x + (desired.x - prev.x) * blend,
140
+ y: prev.y + (desired.y - prev.y) * blend,
141
+ z: prev.z + (desired.z - prev.z) * blend,
142
+ };
143
+ followRef.current = follow;
144
+ props.yawRef.current = resolved.yaw;
145
+ const pose = topDownPose(follow, { ...resolved, height: zoomRef.current }, currentFov(camera));
146
+ commit(pose, dt);
147
+ }, CAMERA_RIG_FRAME_PRIORITY);
148
+ return null;
149
+ }
150
+ /** Fixed side-on 2.5D follow rig: watches the followed entity from the perpendicular axis, never reading WASD/mouse-look. */
151
+ export function SideScrollRig(props) {
152
+ const { userId } = usePlayer();
153
+ const ctx = useGameContext();
154
+ const followId = resolveFollowId(props.followEntityId, userId);
155
+ const config = props.config?.sideScroll;
156
+ const resolved = useMemo(() => resolveSideScroll(config), [config]);
157
+ const { camera, commit, beginTransition } = useCameraCommit(props, followId);
158
+ const followRef = useRef(null);
159
+ useEffect(beginTransition, []);
160
+ useFrame((_, dt) => {
161
+ const sample = readFollow(ctx, followId);
162
+ const desired = sample?.pos ?? { x: 0, y: 0, z: 0 };
163
+ const prev = followRef.current ?? desired;
164
+ const blend = sideScrollFollowBlend(resolved.followSmoothing, dt);
165
+ const follow = {
166
+ x: prev.x + (desired.x - prev.x) * blend,
167
+ y: prev.y + (desired.y - prev.y) * blend,
168
+ z: prev.z + (desired.z - prev.z) * blend,
169
+ };
170
+ followRef.current = follow;
171
+ const pose = resolveSideScrollPose(follow, resolved, config?.fov ?? currentFov(camera));
172
+ commit(pose, dt);
173
+ }, CAMERA_RIG_FRAME_PRIORITY);
174
+ return null;
175
+ }
176
+ function useHeldKeys(codes) {
177
+ const held = useRef(new Set());
178
+ useEffect(() => {
179
+ const set = new Set(codes);
180
+ const down = (event) => {
181
+ if (set.has(event.code))
182
+ held.current.add(event.code);
183
+ };
184
+ const up = (event) => {
185
+ held.current.delete(event.code);
186
+ };
187
+ window.addEventListener("keydown", down);
188
+ window.addEventListener("keyup", up);
189
+ return () => {
190
+ window.removeEventListener("keydown", down);
191
+ window.removeEventListener("keyup", up);
192
+ held.current.clear();
193
+ };
194
+ }, [codes.join(",")]);
195
+ return held;
196
+ }
197
+ const RTS_PAN_KEYS = ["KeyW", "KeyA", "KeyS", "KeyD", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "KeyQ", "KeyE"];
198
+ const RTS_WASD_KEYS = new Set(["KeyW", "KeyA", "KeyS", "KeyD"]);
199
+ export function RtsRig(props) {
200
+ const { userId } = usePlayer();
201
+ const ctx = useGameContext();
202
+ const followId = resolveFollowId(props.followEntityId, userId);
203
+ const config = props.config?.rts;
204
+ const resolved = useMemo(() => resolveTopDown(config), [config]);
205
+ const { camera, commit, beginTransition } = useCameraCommit(props, followId);
206
+ const wasdPanEnabled = followId === null && props.panKeysEnabled !== false;
207
+ const heldCodes = useMemo(() => (wasdPanEnabled ? RTS_PAN_KEYS : RTS_PAN_KEYS.filter((code) => !RTS_WASD_KEYS.has(code))), [wasdPanEnabled]);
208
+ const held = useHeldKeys(heldCodes);
209
+ const centerRef = useRef({
210
+ x: config?.start?.x ?? 0,
211
+ y: 0,
212
+ z: config?.start?.z ?? 0,
213
+ });
214
+ const yawRef = useRef(resolved.yaw);
215
+ const pointerRef = useRef(null);
216
+ const domElement = useThree((state) => state.gl.domElement);
217
+ const zoomRef = useWheelZoom(resolved.height, (config?.zoom?.min ?? 0.5) * resolved.height, (config?.zoom?.max ?? 2.2) * resolved.height, config?.zoom?.speed ?? resolved.height * 0.09);
218
+ const edgeScroll = config?.edgeScroll ?? true;
219
+ const edgeMargin = typeof edgeScroll === "object" ? edgeScroll.margin ?? 0.04 : 0.04;
220
+ const panSpeed = config?.panSpeed ?? 24;
221
+ const rotateSpeed = config?.rotateSpeed ?? 1.4;
222
+ useEffect(beginTransition, []);
223
+ useEffect(() => {
224
+ if (edgeScroll === false)
225
+ return;
226
+ const onMove = (event) => {
227
+ const rect = domElement.getBoundingClientRect();
228
+ pointerRef.current = {
229
+ nx: (event.clientX - rect.left) / rect.width,
230
+ ny: (event.clientY - rect.top) / rect.height,
231
+ };
232
+ };
233
+ const onLeave = () => {
234
+ pointerRef.current = null;
235
+ };
236
+ domElement.addEventListener("pointermove", onMove);
237
+ domElement.addEventListener("pointerleave", onLeave);
238
+ return () => {
239
+ domElement.removeEventListener("pointermove", onMove);
240
+ domElement.removeEventListener("pointerleave", onLeave);
241
+ };
242
+ }, [domElement, edgeScroll]);
243
+ useFrame((_, dt) => {
244
+ const keys = held.current;
245
+ if (keys.has("KeyQ"))
246
+ yawRef.current += rotateSpeed * dt;
247
+ if (keys.has("KeyE"))
248
+ yawRef.current -= rotateSpeed * dt;
249
+ let panX = 0;
250
+ let panZ = 0;
251
+ if (keys.has("KeyW") || keys.has("ArrowUp"))
252
+ panZ += 1;
253
+ if (keys.has("KeyS") || keys.has("ArrowDown"))
254
+ panZ -= 1;
255
+ if (keys.has("KeyD") || keys.has("ArrowRight"))
256
+ panX += 1;
257
+ if (keys.has("KeyA") || keys.has("ArrowLeft"))
258
+ panX -= 1;
259
+ const pointer = pointerRef.current;
260
+ if (edgeScroll !== false && pointer !== null) {
261
+ if (pointer.nx < edgeMargin)
262
+ panX -= 1;
263
+ else if (pointer.nx > 1 - edgeMargin)
264
+ panX += 1;
265
+ if (pointer.ny < edgeMargin)
266
+ panZ += 1;
267
+ else if (pointer.ny > 1 - edgeMargin)
268
+ panZ -= 1;
269
+ }
270
+ const center = centerRef.current;
271
+ if (panX !== 0 || panZ !== 0) {
272
+ const cos = Math.cos(yawRef.current);
273
+ const sin = Math.sin(yawRef.current);
274
+ const worldX = panX * cos + panZ * sin;
275
+ const worldZ = panZ * cos - panX * sin;
276
+ const len = Math.hypot(worldX, worldZ) || 1;
277
+ center.x += (worldX / len) * panSpeed * dt;
278
+ center.z += (worldZ / len) * panSpeed * dt;
279
+ }
280
+ else {
281
+ const follow = readFollow(ctx, followId);
282
+ if (follow !== null) {
283
+ center.x += (follow.pos.x - center.x) * (1 - Math.exp(-resolved.followSmoothing * dt));
284
+ center.z += (follow.pos.z - center.z) * (1 - Math.exp(-resolved.followSmoothing * dt));
285
+ }
286
+ }
287
+ const bounds = config?.bounds;
288
+ if (bounds !== undefined) {
289
+ center.x = clamp(center.x, bounds.minX ?? -Infinity, bounds.maxX ?? Infinity);
290
+ center.z = clamp(center.z, bounds.minZ ?? -Infinity, bounds.maxZ ?? Infinity);
291
+ }
292
+ props.yawRef.current = yawRef.current;
293
+ const pose = topDownPose(center, { ...resolved, yaw: yawRef.current, height: zoomRef.current }, currentFov(camera));
294
+ commit(pose, dt);
295
+ }, CAMERA_RIG_FRAME_PRIORITY);
296
+ return null;
297
+ }
298
+ export function ShoulderRig(props) {
299
+ const { userId } = usePlayer();
300
+ const ctx = useGameContext();
301
+ const followId = resolveFollowId(props.followEntityId, userId);
302
+ const config = props.config?.shoulder;
303
+ const { commit, beginTransition } = useCameraCommit(props, followId);
304
+ const domElement = useThree((state) => state.gl.domElement);
305
+ const sideRef = useRef(config?.side === "left" ? -1 : 1);
306
+ const aimRef = useRef(0);
307
+ const aimingRef = useRef(false);
308
+ const sensitivity = config?.sensitivity ?? 0.0025;
309
+ useEffect(beginTransition, []);
310
+ useEffect(() => {
311
+ const requestLock = () => {
312
+ if (document.pointerLockElement !== domElement)
313
+ void domElement.requestPointerLock?.();
314
+ };
315
+ const onMove = (event) => {
316
+ if (document.pointerLockElement !== domElement)
317
+ return;
318
+ props.yawRef.current -= event.movementX * sensitivity;
319
+ props.pitchRef.current = clamp(props.pitchRef.current - event.movementY * sensitivity, -1.2, 1.2);
320
+ };
321
+ const onDown = (event) => {
322
+ if (event.button === 2)
323
+ aimingRef.current = true;
324
+ };
325
+ const onUp = (event) => {
326
+ if (event.button === 2)
327
+ aimingRef.current = false;
328
+ };
329
+ const onKey = (event) => {
330
+ if (event.code === "KeyV")
331
+ sideRef.current *= -1;
332
+ };
333
+ const onContext = (event) => event.preventDefault();
334
+ domElement.addEventListener("click", requestLock);
335
+ window.addEventListener("mousemove", onMove);
336
+ window.addEventListener("mousedown", onDown);
337
+ window.addEventListener("mouseup", onUp);
338
+ window.addEventListener("keydown", onKey);
339
+ domElement.addEventListener("contextmenu", onContext);
340
+ return () => {
341
+ domElement.removeEventListener("click", requestLock);
342
+ window.removeEventListener("mousemove", onMove);
343
+ window.removeEventListener("mousedown", onDown);
344
+ window.removeEventListener("mouseup", onUp);
345
+ window.removeEventListener("keydown", onKey);
346
+ domElement.removeEventListener("contextmenu", onContext);
347
+ };
348
+ }, [domElement, sensitivity, props.yawRef, props.pitchRef]);
349
+ useFrame((_, dt) => {
350
+ const sample = readFollow(ctx, followId);
351
+ const follow = sample?.pos ?? { x: 0, y: 0, z: 0 };
352
+ const adsTarget = aimingRef.current ? 1 : 0;
353
+ aimRef.current += (adsTarget - aimRef.current) * (1 - Math.exp(-(config?.adsTransitionSpeed ?? 10) * dt));
354
+ const hip = resolveShoulder(config, false);
355
+ const ads = resolveShoulder(config, true);
356
+ const blended = {
357
+ shoulderOffset: hip.shoulderOffset + (ads.shoulderOffset - hip.shoulderOffset) * aimRef.current,
358
+ heightOffset: hip.heightOffset + (ads.heightOffset - hip.heightOffset) * aimRef.current,
359
+ distance: hip.distance + (ads.distance - hip.distance) * aimRef.current,
360
+ fov: hip.fov + (ads.fov - hip.fov) * aimRef.current,
361
+ };
362
+ const pose = shoulderPose(follow, props.yawRef.current, props.pitchRef.current, sideRef.current, blended);
363
+ commit(pose, dt);
364
+ }, CAMERA_RIG_FRAME_PRIORITY);
365
+ return null;
366
+ }
367
+ function pickLockTarget(ctx, followId, config) {
368
+ if (config?.targetEntityId !== undefined)
369
+ return config.targetEntityId;
370
+ if (followId !== null) {
371
+ const explicit = ctx.scene.entity.getTarget(followId);
372
+ if (explicit !== null)
373
+ return explicit;
374
+ }
375
+ let nearest = null;
376
+ let best = Infinity;
377
+ const origin = followId === null ? null : ctx.scene.entity.get(followId);
378
+ if (origin === null)
379
+ return null;
380
+ for (const entity of ctx.scene.entity.list()) {
381
+ if (entity.id === followId)
382
+ continue;
383
+ const dx = entity.position[0] - origin.position[0];
384
+ const dz = entity.position[2] - origin.position[2];
385
+ const d = dx * dx + dz * dz;
386
+ if (d < best) {
387
+ best = d;
388
+ nearest = entity.id;
389
+ }
390
+ }
391
+ return nearest;
392
+ }
393
+ export function LockOnRig(props) {
394
+ const { userId } = usePlayer();
395
+ const ctx = useGameContext();
396
+ const followId = resolveFollowId(props.followEntityId, userId);
397
+ const config = props.config?.lockOn;
398
+ const { camera, commit, beginTransition } = useCameraCommit(props, followId);
399
+ const yawRef = useRef(0);
400
+ const seeded = useRef(false);
401
+ useEffect(beginTransition, []);
402
+ useFrame((_, dt) => {
403
+ const sample = readFollow(ctx, followId);
404
+ const player = sample?.pos ?? { x: 0, y: 0, z: 0 };
405
+ const targetId = pickLockTarget(ctx, followId, config);
406
+ const targetEntity = targetId === null ? null : ctx.scene.entity.get(targetId);
407
+ const target = targetEntity === null
408
+ ? { x: player.x, y: player.y, z: player.z + 1 }
409
+ : { x: targetEntity.position[0], y: targetEntity.position[1], z: targetEntity.position[2] };
410
+ const { pose, yaw } = lockOnPose(player, target, config, currentFov(camera));
411
+ if (!seeded.current) {
412
+ yawRef.current = yaw;
413
+ seeded.current = true;
414
+ }
415
+ else {
416
+ yawRef.current = smoothYaw(yawRef.current, yaw, config?.yawSmoothing ?? 9, dt);
417
+ }
418
+ props.yawRef.current = yawRef.current;
419
+ commit(pose, dt);
420
+ }, CAMERA_RIG_FRAME_PRIORITY);
421
+ return null;
422
+ }
423
+ export function ChaseRig(props) {
424
+ const { userId } = usePlayer();
425
+ const ctx = useGameContext();
426
+ const followId = resolveFollowId(props.followEntityId, userId);
427
+ const config = props.config?.chase;
428
+ const resolved = useMemo(() => resolveChase(config), [config]);
429
+ const { commit, beginTransition } = useCameraCommit(props, followId);
430
+ const shake = useCameraShake();
431
+ const posRef = useRef(null);
432
+ const lastFollowRef = useRef(null);
433
+ const view = config?.view ?? "chase";
434
+ useEffect(beginTransition, [view]);
435
+ useFrame((_, dt) => {
436
+ const sample = readFollow(ctx, followId);
437
+ const follow = sample?.pos ?? { x: 0, y: 0, z: 0 };
438
+ const yaw = sample?.yaw ?? 0;
439
+ const last = lastFollowRef.current ?? follow;
440
+ const speed = dt > 0 ? Math.hypot(follow.x - last.x, follow.z - last.z) / dt : 0;
441
+ lastFollowRef.current = follow;
442
+ const fov = speedToFov(speed, config?.fov);
443
+ if (view !== "chase") {
444
+ const seat = view === "cockpit"
445
+ ? config?.seatOffsets?.cockpit ?? { x: 0, y: 1.2, z: 0.1 }
446
+ : view === "hood"
447
+ ? config?.seatOffsets?.hood ?? { x: 0, y: 1, z: 1.4 }
448
+ : config?.seatOffsets?.rear ?? { x: 0, y: 1.6, z: -1.5 };
449
+ const lookBack = view === "rear";
450
+ const pose = seatPose(follow, lookBack ? yaw + Math.PI : yaw, seat, fov);
451
+ if (lookBack) {
452
+ pose.position = seatPose(follow, yaw, seat, fov).position;
453
+ }
454
+ commit(pose, dt);
455
+ return;
456
+ }
457
+ const desired = chaseDesiredPosition(follow, yaw, resolved);
458
+ const prev = posRef.current ?? desired;
459
+ const smoothed = springArmStep(prev, desired, resolved.springDamping, dt);
460
+ posRef.current = smoothed;
461
+ if (resolved.shakePerSpeed > 0 && speed > 0) {
462
+ shake.shake(Math.min(resolved.shakePerSpeed * speed * dt, 0.1));
463
+ }
464
+ const pose = { position: smoothed, lookAt: chaseLookAt(follow, yaw, resolved), fov };
465
+ commit(pose, dt);
466
+ }, CAMERA_RIG_FRAME_PRIORITY);
467
+ return null;
468
+ }
469
+ function observerSubject(ctx, config, followId) {
470
+ const bind = config?.bind ?? (followId !== null ? { kind: "entity", entityId: followId } : undefined);
471
+ if (bind?.kind === "entity") {
472
+ const entity = ctx.scene.entity.get(bind.entityId);
473
+ if (entity !== null) {
474
+ return { subject: { x: entity.position[0], y: entity.position[1], z: entity.position[2] }, boundEntityId: bind.entityId };
475
+ }
476
+ return { subject: { x: 0, y: 0, z: 0 }, boundEntityId: bind.entityId };
477
+ }
478
+ if (bind?.kind === "point")
479
+ return { subject: { ...bind.position }, boundEntityId: null };
480
+ return { subject: { x: 0, y: 0, z: 0 }, boundEntityId: null };
481
+ }
482
+ /**
483
+ * Detached spectator/photo cam (#120): binds to any entity or fixed point and
484
+ * auto-orbits it, reading no player input at all — the van CCTV / photo-mode /
485
+ * kill-cam rig. Distinct from every other rig, which drives from mouse/keys.
486
+ */
487
+ export function ObserverRig(props) {
488
+ const { userId } = usePlayer();
489
+ const ctx = useGameContext();
490
+ const config = props.config?.observer;
491
+ const followId = resolveFollowId(props.followEntityId, userId);
492
+ const resolved = useMemo(() => resolveObserver(config), [config]);
493
+ const angleRef = useRef(config?.startAngle ?? 0);
494
+ const { camera, commit, beginTransition } = useCameraCommit(props, observerSubject(ctx, config, followId).boundEntityId);
495
+ useEffect(beginTransition, []);
496
+ useFrame((_, dt) => {
497
+ angleRef.current += resolved.orbitSpeed * dt;
498
+ const { subject } = observerSubject(ctx, config, followId);
499
+ const pose = observerPose(subject, angleRef.current, resolved, config?.fov ?? currentFov(camera));
500
+ commit(pose, dt);
501
+ }, CAMERA_RIG_FRAME_PRIORITY);
502
+ return null;
503
+ }
504
+ export function CinematicRig(props) {
505
+ const { userId } = usePlayer();
506
+ const followId = resolveFollowId(props.followEntityId, userId);
507
+ const cinematic = props.config?.cinematic;
508
+ const { camera, commit, beginTransition } = useCameraCommit(props, followId);
509
+ const elapsedRef = useRef(0);
510
+ useEffect(() => {
511
+ elapsedRef.current = 0;
512
+ beginTransition();
513
+ }, [cinematic]);
514
+ useFrame((_, dt) => {
515
+ if (cinematic === undefined)
516
+ return;
517
+ elapsedRef.current += dt;
518
+ const sample = cinematicSample(cinematic.keyframes, elapsedRef.current, cinematic.loop ?? false, currentFov(camera));
519
+ commit(sample.pose, dt);
520
+ }, CAMERA_RIG_FRAME_PRIORITY);
521
+ return null;
522
+ }
@@ -1,2 +1,9 @@
1
1
  export { GameOrbitCamera, type CameraFollowListener, type GameOrbitCameraProps } from "./GameOrbitCamera.js";
2
+ export { GameFirstPersonCamera, type GameFirstPersonCameraProps } from "./GameFirstPersonCamera.js";
3
+ export { GameInspectionCamera, type GameInspectionCameraProps } from "./GameInspectionCamera.js";
4
+ export { GameCameraRig, resolveRigKind, type GameCameraRigProps } from "./GameCameraRig.js";
5
+ export { CAMERA_POST_FRAME_PRIORITY, CAMERA_RIG_FRAME_PRIORITY, ChaseRig, CinematicRig, LockOnRig, ObserverRig, RtsRig, ShoulderRig, SideScrollRig, TopDownRig, type RigProps, } from "./cameraRigs.js";
6
+ export { CameraShakeContext, cameraShake, createCameraShakeChannel, defaultCameraShakeChannel, useCameraShake, type CameraShakeChannel, } from "./shakeChannel.js";
7
+ export { addTrauma, angleDelta, blendShoulder, chaseDesiredPosition, chaseLookAt, cinematicSample, clamp, createTrauma, crossfadePose, forwardVector, lerp, lockOnPose, observerPose, resolveChase, resolveDirectedCamera, resolveObserver, resolveShoulder, resolveSideScroll, resolveSideScrollPose, resolveTopDown, rightVector, rtsPanKeysConflict, seatPose, shakeOffset, shoulderPose, sideScrollFollowBlend, smoothstep, smoothYaw, speedToFov, springArmStep, stepTrauma, topDownPose, yawTo, type CameraPose, type DirectorCameraValues, type ResolvedChase, type ResolvedDirectedCamera, type ResolvedObserver, type ResolvedShoulder, type ResolvedSideScroll, type ResolvedTopDown, type ShakeOffset, type StaticCameraValues, type TraumaState, } from "./rigMath.js";
2
8
  export { cameraFollowStep, cameraLookPitch, DEFAULT_ORBIT_CAMERA, distanceBetween, GAME_SIM_FRAME_PRIORITY, lerpVec3, ORBIT_CAMERA_FRAME_PRIORITY, orbitFollowStep, orbitYawFromCamera, resolveFollowTargetFromPosition, resolveOrbitCameraConfig, resolveTargetSmoothing, seedOrbitFollowState, smoothBlend, type CameraFollowState, type OrbitCameraConfig, type OrbitFollowRuntimeState, type ResolvedOrbitCameraConfig, type Vec3, } from "./orbitCameraMath.js";
9
+ export { resolveInspectionCameraConfig, resolveInspectionZoomToCursor, seedInspectionCamera, type InspectionCameraConfig, type InspectionZoomAnchor, type ResolvedInspectionCameraConfig, } from "./inspectionCameraMath.js";
@@ -1,2 +1,9 @@
1
1
  export { GameOrbitCamera } from "./GameOrbitCamera.js";
2
+ export { GameFirstPersonCamera } from "./GameFirstPersonCamera.js";
3
+ export { GameInspectionCamera } from "./GameInspectionCamera.js";
4
+ export { GameCameraRig, resolveRigKind } from "./GameCameraRig.js";
5
+ export { CAMERA_POST_FRAME_PRIORITY, CAMERA_RIG_FRAME_PRIORITY, ChaseRig, CinematicRig, LockOnRig, ObserverRig, RtsRig, ShoulderRig, SideScrollRig, TopDownRig, } from "./cameraRigs.js";
6
+ export { CameraShakeContext, cameraShake, createCameraShakeChannel, defaultCameraShakeChannel, useCameraShake, } from "./shakeChannel.js";
7
+ export { addTrauma, angleDelta, blendShoulder, chaseDesiredPosition, chaseLookAt, cinematicSample, clamp, createTrauma, crossfadePose, forwardVector, lerp, lockOnPose, observerPose, resolveChase, resolveDirectedCamera, resolveObserver, resolveShoulder, resolveSideScroll, resolveSideScrollPose, resolveTopDown, rightVector, rtsPanKeysConflict, seatPose, shakeOffset, shoulderPose, sideScrollFollowBlend, smoothstep, smoothYaw, speedToFov, springArmStep, stepTrauma, topDownPose, yawTo, } from "./rigMath.js";
2
8
  export { cameraFollowStep, cameraLookPitch, DEFAULT_ORBIT_CAMERA, distanceBetween, GAME_SIM_FRAME_PRIORITY, lerpVec3, ORBIT_CAMERA_FRAME_PRIORITY, orbitFollowStep, orbitYawFromCamera, resolveFollowTargetFromPosition, resolveOrbitCameraConfig, resolveTargetSmoothing, seedOrbitFollowState, smoothBlend, } from "./orbitCameraMath.js";
9
+ export { resolveInspectionCameraConfig, resolveInspectionZoomToCursor, seedInspectionCamera, } from "./inspectionCameraMath.js";
@@ -0,0 +1,25 @@
1
+ import type { InspectionCameraConfig, InspectionZoomAnchor } from "@jgengine/core/game/playableGame";
2
+ import type { Vec3 } from "./orbitCameraMath.js";
3
+ export type { InspectionCameraConfig, InspectionZoomAnchor };
4
+ export interface ResolvedInspectionCameraConfig {
5
+ anchor: InspectionZoomAnchor;
6
+ target: Vec3;
7
+ initialDistance: number;
8
+ initialPosition: Vec3 | null;
9
+ minDistance: number;
10
+ maxDistance: number;
11
+ minPolarAngle: number;
12
+ maxPolarAngle: number;
13
+ pan: boolean;
14
+ rotateSpeed: number;
15
+ zoomSpeed: number;
16
+ dampingFactor: number;
17
+ }
18
+ export declare function resolveInspectionCameraConfig(config?: InspectionCameraConfig): ResolvedInspectionCameraConfig;
19
+ /** Seeds the camera/target world position before OrbitControls mounts. Falls back to `initialDistance` behind `target` on the -Z axis, raised by 40% of that distance, when `initialPosition` is unset. */
20
+ export declare function seedInspectionCamera(config: ResolvedInspectionCameraConfig): {
21
+ camera: Vec3;
22
+ target: Vec3;
23
+ };
24
+ /** Maps the anchor mode onto three-stdlib OrbitControls' native `zoomToCursor` flag. */
25
+ export declare function resolveInspectionZoomToCursor(anchor: InspectionZoomAnchor): boolean;
@@ -0,0 +1,44 @@
1
+ export function resolveInspectionCameraConfig(config) {
2
+ return {
3
+ anchor: config?.anchor ?? "target",
4
+ target: {
5
+ x: config?.target?.x ?? 0,
6
+ y: config?.target?.y ?? 0,
7
+ z: config?.target?.z ?? 0,
8
+ },
9
+ initialDistance: config?.initialDistance ?? 6,
10
+ initialPosition: config?.initialPosition === undefined
11
+ ? null
12
+ : {
13
+ x: config.initialPosition.x ?? 0,
14
+ y: config.initialPosition.y ?? 0,
15
+ z: config.initialPosition.z ?? 0,
16
+ },
17
+ minDistance: config?.minDistance ?? 2,
18
+ maxDistance: config?.maxDistance ?? 20,
19
+ minPolarAngle: config?.minPolarAngle ?? 0,
20
+ maxPolarAngle: config?.maxPolarAngle ?? Math.PI,
21
+ pan: config?.pan ?? true,
22
+ rotateSpeed: config?.rotateSpeed ?? 0.6,
23
+ zoomSpeed: config?.zoomSpeed ?? 0.6,
24
+ dampingFactor: config?.dampingFactor ?? 0.08,
25
+ };
26
+ }
27
+ /** Seeds the camera/target world position before OrbitControls mounts. Falls back to `initialDistance` behind `target` on the -Z axis, raised by 40% of that distance, when `initialPosition` is unset. */
28
+ export function seedInspectionCamera(config) {
29
+ if (config.initialPosition !== null) {
30
+ return { camera: config.initialPosition, target: config.target };
31
+ }
32
+ return {
33
+ camera: {
34
+ x: config.target.x,
35
+ y: config.target.y + config.initialDistance * 0.4,
36
+ z: config.target.z - config.initialDistance,
37
+ },
38
+ target: config.target,
39
+ };
40
+ }
41
+ /** Maps the anchor mode onto three-stdlib OrbitControls' native `zoomToCursor` flag. */
42
+ export function resolveInspectionZoomToCursor(anchor) {
43
+ return anchor === "cursor";
44
+ }