@jgengine/shell 0.18.1 → 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 (112) hide show
  1. package/CHANGELOG.md +158 -7
  2. package/dist/GamePlayerShell.js +20 -3
  3. package/dist/Shell3dPresentation.d.ts +3 -9
  4. package/dist/Shell3dPresentation.js +26 -6
  5. package/dist/ShellHudPresentation.d.ts +9 -1
  6. package/dist/ShellHudPresentation.js +21 -0
  7. package/dist/audio/AudioComponents.js +6 -2
  8. package/dist/audio/audioEngine.d.ts +8 -0
  9. package/dist/audio/audioEngine.js +65 -6
  10. package/dist/audio/audioWire.d.ts +3 -0
  11. package/dist/audio/audioWire.js +7 -1
  12. package/dist/audio/loopParams.d.ts +39 -0
  13. package/dist/audio/loopParams.js +43 -0
  14. package/dist/camera/GameCameraRig.js +1 -1
  15. package/dist/camera/GameFirstPersonCamera.d.ts +4 -2
  16. package/dist/camera/GameFirstPersonCamera.js +52 -15
  17. package/dist/camera/Viewports.d.ts +34 -0
  18. package/dist/camera/Viewports.js +123 -0
  19. package/dist/camera/cameraRigs.js +70 -62
  20. package/dist/camera/orbitCameraMath.js +2 -2
  21. package/dist/camera/rigMath.d.ts +82 -4
  22. package/dist/camera/rigMath.js +133 -15
  23. package/dist/defineGame.d.ts +9 -0
  24. package/dist/defineGame.js +20 -3
  25. package/dist/devtools/DevtoolsOverlay.js +2 -0
  26. package/dist/devtools/agentBridge.d.ts +6 -0
  27. package/dist/devtools/agentBridge.js +15 -1
  28. package/dist/diagnostics/FpsCounter.d.ts +17 -0
  29. package/dist/diagnostics/FpsCounter.js +32 -0
  30. package/dist/drivers/FrameDriver.js +13 -2
  31. package/dist/drivers/FrameRateLimiter.d.ts +9 -0
  32. package/dist/drivers/FrameRateLimiter.js +39 -0
  33. package/dist/drivers/HudOnlyDriver.js +1 -1
  34. package/dist/environment/DayNightSky.js +5 -5
  35. package/dist/environment/Daylight.d.ts +35 -1
  36. package/dist/environment/Daylight.js +98 -28
  37. package/dist/environment/VolumetricClouds.js +20 -2
  38. package/dist/environment/daylightCycle.d.ts +20 -0
  39. package/dist/environment/daylightCycle.js +59 -7
  40. package/dist/environment/sunShadowMath.d.ts +45 -0
  41. package/dist/environment/sunShadowMath.js +50 -0
  42. package/dist/input/gamepadPoll.d.ts +47 -0
  43. package/dist/input/gamepadPoll.js +142 -0
  44. package/dist/input/gamepadSource.d.ts +11 -1
  45. package/dist/input/gamepadSource.js +117 -71
  46. package/dist/input/mouseLook.js +2 -1
  47. package/dist/input/padHaptics.d.ts +17 -0
  48. package/dist/input/padHaptics.js +32 -0
  49. package/dist/input/pointerLock.d.ts +11 -0
  50. package/dist/input/pointerLock.js +19 -0
  51. package/dist/postfx/PostProcessing.d.ts +1 -1
  52. package/dist/postfx/PostProcessing.js +5 -1
  53. package/dist/postfx/stylizePass.d.ts +23 -0
  54. package/dist/postfx/stylizePass.js +216 -0
  55. package/dist/render/SceneModels.js +1 -1
  56. package/dist/render/SkinnedInstances.d.ts +91 -0
  57. package/dist/render/SkinnedInstances.js +249 -0
  58. package/dist/render/TextureFiltering.d.ts +16 -0
  59. package/dist/render/TextureFiltering.js +64 -0
  60. package/dist/render/displayColor.d.ts +33 -0
  61. package/dist/render/displayColor.js +138 -0
  62. package/dist/render/modelLoad.d.ts +29 -0
  63. package/dist/render/modelLoad.js +61 -1
  64. package/dist/render/resolveModel.js +4 -0
  65. package/dist/render/useFootIk.d.ts +53 -4
  66. package/dist/render/useFootIk.js +197 -70
  67. package/dist/render/useModelAnimation.d.ts +24 -0
  68. package/dist/render/useModelAnimation.js +61 -40
  69. package/dist/scene/AuthoredScene.d.ts +9 -0
  70. package/dist/scene/AuthoredScene.js +17 -1
  71. package/dist/scene/CityRenderer.js +6 -6
  72. package/dist/settings/QuickControls.js +1 -1
  73. package/dist/settings/SettingsMenu.js +1 -1
  74. package/dist/settings/appliedSettings.d.ts +9 -3
  75. package/dist/settings/appliedSettings.js +6 -5
  76. package/dist/settings/settingsController.d.ts +3 -0
  77. package/dist/settings/settingsController.js +47 -3
  78. package/dist/structures/BuildingKitBatch.js +26 -22
  79. package/dist/structures/GeneratedBuilding.d.ts +3 -15
  80. package/dist/structures/GeneratedBuilding.js +21 -15
  81. package/dist/structures/buildingSurface.d.ts +21 -0
  82. package/dist/structures/buildingSurface.js +134 -0
  83. package/dist/useShellMultiplayerSync.d.ts +1 -1
  84. package/dist/useShellMultiplayerSync.js +5 -3
  85. package/dist/world/WorldParticles.js +1 -1
  86. package/package.json +4 -4
  87. package/skills/ce-handoff/SKILL.md +1 -1
  88. package/skills/game-design/SKILL.md +2 -2
  89. package/skills/jgengine/SKILL.md +7 -5
  90. package/skills/jgengine/api.md +118 -89
  91. package/skills/jgengine/capabilities.md +28 -0
  92. package/skills/jgengine/recipes/minimal-game.md +3 -0
  93. package/skills/jgengine-ui/SKILL.md +13 -16
  94. package/skills/jgengine-ui/api.md +418 -317
  95. package/skills/jgengine-ui/capabilities.md +16 -0
  96. package/skills/jgengine-ui/reference-primitives.md +89 -0
  97. package/skills/jgengine-ui/reference.md +2 -81
  98. package/skills/jgengine-world/SKILL.md +15 -3
  99. package/skills/jgengine-world/api.md +873 -626
  100. package/skills/jgengine-world/capabilities.md +130 -6
  101. package/skills/jgengine-world/recipes/character-animation.md +39 -0
  102. package/skills/jgengine-world/recipes/controls.md +44 -0
  103. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  104. package/skills/jgengine-world/recipes/npc-senses.md +43 -0
  105. package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
  106. package/skills/jgengine-world/reference.md +31 -2
  107. package/skills/jgengine-world/visibility.md +112 -0
  108. package/skills/level-design/SKILL.md +1 -1
  109. package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +0 -15
  110. package/dist/render/testFixtures/proceduralSpriteAtlas.js +0 -30
  111. package/dist/render/testFixtures/proceduralTileLayer.d.ts +0 -13
  112. package/dist/render/testFixtures/proceduralTileLayer.js +0 -30
@@ -1,11 +1,13 @@
1
1
  import { distance3, resolveEmitterGain } from "@jgengine/core/audio/audioFalloff";
2
+ import { dopplerRate } from "@jgengine/core/audio/doppler";
2
3
  import { patchDuration } from "@jgengine/core/audio/synth";
3
4
  import { createDisposer } from "@jgengine/core/game/defineGame";
4
- import { clampLoopGain, clampLoopRate } from "./loopParams.js";
5
+ import { clampLoopCutoff, clampLoopGain, clampLoopRate, MIN_LOOP_CUTOFF, MAX_LOOP_CUTOFF } from "./loopParams.js";
5
6
  import { MusicDirector } from "./musicDirector.js";
6
7
  import { createNoiseBuffer, realizeSynthPatch } from "./synthEngine.js";
7
8
  /** setTargetAtTime time constant (~20 ms) for zipper-free live rate/gain ramps on retained loops (#1051). */
8
9
  const LOOP_PARAM_SMOOTH_TC = 0.02;
10
+ const ZERO_VELOCITY = { x: 0, y: 0, z: 0 };
9
11
  function createNoopEngine() {
10
12
  return {
11
13
  setListenerPose: () => undefined,
@@ -140,12 +142,17 @@ export function createAudioEngine(config = {}) {
140
142
  return pending;
141
143
  }
142
144
  let listenerPosition = { x: 0, y: 0, z: 0 };
145
+ let listenerVelocity = { x: 0, y: 0, z: 0 };
146
+ const nyquist = context.sampleRate / 2;
143
147
  const activeSpatialUpdaters = new Set();
144
148
  const activeLoops = new Set();
145
149
  const NOOP_HANDLE = {
146
150
  setPosition: () => undefined,
147
151
  setRate: () => undefined,
148
152
  setGain: () => undefined,
153
+ setLowpass: () => undefined,
154
+ setHighpass: () => undefined,
155
+ setVelocity: () => undefined,
149
156
  stop: () => undefined,
150
157
  };
151
158
  function playInternal(soundId, position, loop) {
@@ -187,16 +194,34 @@ export function createAudioEngine(config = {}) {
187
194
  // Live control state, applied on source creation so updates that race the async buffer load stick.
188
195
  let currentRate = 1;
189
196
  let currentUserGain = 1;
197
+ let currentLowpass = MAX_LOOP_CUTOFF;
198
+ let currentHighpass = MIN_LOOP_CUTOFF;
199
+ let currentVelocity = { x: 0, y: 0, z: 0 };
200
+ const dopplerFactor = loop ? sound.doppler ?? 0 : 0;
190
201
  let sourceNode = null;
202
+ let lowpassNode = null;
203
+ let highpassNode = null;
191
204
  let userGainNode = null;
192
205
  let falloffGain = null;
193
206
  let pannerNode = null;
207
+ function effectiveRate() {
208
+ if (dopplerFactor === 0)
209
+ return currentRate;
210
+ const shift = dopplerRate([listenerPosition.x, listenerPosition.y, listenerPosition.z], [listenerVelocity.x, listenerVelocity.y, listenerVelocity.z], [currentPosition.x, currentPosition.y, currentPosition.z], [currentVelocity.x, currentVelocity.y, currentVelocity.z], { factor: dopplerFactor });
211
+ return clampLoopRate(currentRate * shift);
212
+ }
213
+ function applyRate() {
214
+ if (sourceNode !== null)
215
+ sourceNode.playbackRate.setTargetAtTime(effectiveRate(), context.currentTime, LOOP_PARAM_SMOOTH_TC);
216
+ }
194
217
  function updateFalloff() {
195
218
  if (falloffGain !== null && sound.spatial === undefined) {
196
219
  falloffGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
197
220
  }
198
221
  if (pannerNode !== null)
199
222
  setPannerPosition(pannerNode, currentPosition);
223
+ if (dopplerFactor !== 0)
224
+ applyRate();
200
225
  }
201
226
  void bufferPromise.then((buffer) => {
202
227
  if (buffer === null)
@@ -204,13 +229,28 @@ export function createAudioEngine(config = {}) {
204
229
  const src = context.createBufferSource();
205
230
  src.buffer = buffer;
206
231
  src.loop = loop || (sound.loop ?? false);
207
- src.playbackRate.value = currentRate;
208
- // source → per-loop user gain (setGain) → distance falloff → bus
232
+ src.playbackRate.value = effectiveRate();
233
+ // source → [lowpass → highpass, loops only] → per-loop user gain (setGain) → distance falloff → bus
209
234
  const uGain = context.createGain();
210
235
  uGain.gain.value = currentUserGain;
211
236
  const fGain = context.createGain();
212
237
  fGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
213
- src.connect(uGain);
238
+ if (loop) {
239
+ const lp = context.createBiquadFilter();
240
+ lp.type = "lowpass";
241
+ lp.frequency.value = clampLoopCutoff(currentLowpass, MAX_LOOP_CUTOFF, nyquist);
242
+ const hp = context.createBiquadFilter();
243
+ hp.type = "highpass";
244
+ hp.frequency.value = clampLoopCutoff(currentHighpass, MIN_LOOP_CUTOFF, nyquist);
245
+ src.connect(lp);
246
+ lp.connect(hp);
247
+ hp.connect(uGain);
248
+ lowpassNode = lp;
249
+ highpassNode = hp;
250
+ }
251
+ else {
252
+ src.connect(uGain);
253
+ }
214
254
  uGain.connect(fGain);
215
255
  if (sound.spatial !== undefined) {
216
256
  const panner = createPanner(context, sound.spatial, currentPosition);
@@ -233,10 +273,14 @@ export function createAudioEngine(config = {}) {
233
273
  catch {
234
274
  }
235
275
  src.disconnect();
276
+ lowpassNode?.disconnect();
277
+ highpassNode?.disconnect();
236
278
  uGain.disconnect();
237
279
  fGain.disconnect();
238
280
  pannerNode?.disconnect();
239
281
  sourceNode = null;
282
+ lowpassNode = null;
283
+ highpassNode = null;
240
284
  userGainNode = null;
241
285
  falloffGain = null;
242
286
  });
@@ -250,8 +294,22 @@ export function createAudioEngine(config = {}) {
250
294
  },
251
295
  setRate(rate) {
252
296
  currentRate = clampLoopRate(rate);
253
- if (sourceNode !== null)
254
- sourceNode.playbackRate.setTargetAtTime(currentRate, context.currentTime, LOOP_PARAM_SMOOTH_TC);
297
+ applyRate();
298
+ },
299
+ setLowpass(hz) {
300
+ currentLowpass = clampLoopCutoff(hz, MAX_LOOP_CUTOFF, nyquist);
301
+ if (lowpassNode !== null)
302
+ lowpassNode.frequency.setTargetAtTime(currentLowpass, context.currentTime, LOOP_PARAM_SMOOTH_TC);
303
+ },
304
+ setHighpass(hz) {
305
+ currentHighpass = clampLoopCutoff(hz, MIN_LOOP_CUTOFF, nyquist);
306
+ if (highpassNode !== null)
307
+ highpassNode.frequency.setTargetAtTime(currentHighpass, context.currentTime, LOOP_PARAM_SMOOTH_TC);
308
+ },
309
+ setVelocity(velocity) {
310
+ currentVelocity = velocity;
311
+ if (dopplerFactor !== 0)
312
+ applyRate();
255
313
  },
256
314
  setGain(gain) {
257
315
  currentUserGain = clampLoopGain(gain);
@@ -272,6 +330,7 @@ export function createAudioEngine(config = {}) {
272
330
  setListenerPose(position) {
273
331
  const pose = "position" in position ? position : { position, forward: { x: 0, y: 0, z: -1 }, up: { x: 0, y: 1, z: 0 } };
274
332
  listenerPosition = pose.position;
333
+ listenerVelocity = "velocity" in pose && pose.velocity !== undefined ? pose.velocity : ZERO_VELOCITY;
275
334
  const listener = context.listener;
276
335
  setAudioParam(listener.positionX, pose.position.x);
277
336
  setAudioParam(listener.positionY, pose.position.y);
@@ -17,6 +17,9 @@ type AudioLoopSetPayload = {
17
17
  rate?: number;
18
18
  gain?: number;
19
19
  at?: readonly [number, number, number];
20
+ velocity?: readonly [number, number, number];
21
+ lowpass?: number;
22
+ highpass?: number;
20
23
  };
21
24
  type AudioLoopStopPayload = {
22
25
  id: string;
@@ -29,7 +29,7 @@ export function attachAudioEventWire(events, audioEngine) {
29
29
  if (handle !== null)
30
30
  loops.set(id, { sound, handle });
31
31
  });
32
- const offLoopSet = events.on("audio.loopSet", ({ id, rate, gain, at }) => {
32
+ const offLoopSet = events.on("audio.loopSet", ({ id, rate, gain, at, velocity, lowpass, highpass }) => {
33
33
  const entry = loops.get(id);
34
34
  // Unknown id: a live update may race a stop — silently drop it.
35
35
  if (entry === undefined)
@@ -40,6 +40,12 @@ export function attachAudioEventWire(events, audioEngine) {
40
40
  entry.handle.setGain(gain);
41
41
  if (at !== undefined)
42
42
  entry.handle.setPosition({ x: at[0], y: at[1], z: at[2] });
43
+ if (velocity !== undefined)
44
+ entry.handle.setVelocity({ x: velocity[0], y: velocity[1], z: velocity[2] });
45
+ if (lowpass !== undefined)
46
+ entry.handle.setLowpass(lowpass);
47
+ if (highpass !== undefined)
48
+ entry.handle.setHighpass(highpass);
43
49
  });
44
50
  const offLoopStop = events.on("audio.loopStop", ({ id }) => {
45
51
  const entry = loops.get(id);
@@ -18,3 +18,42 @@ export declare function clampLoopRate(rate: number): number;
18
18
  * @internal — the audio engine applies this; game code sets gain through `ctx.game.audio.setLoop`.
19
19
  */
20
20
  export declare function clampLoopGain(gain: number): number;
21
+ /** Lowest live filter cutoff, Hz. */
22
+ export declare const MIN_LOOP_CUTOFF = 10;
23
+ /** Highest live filter cutoff, Hz; the engine also caps it at the context's Nyquist frequency. */
24
+ export declare const MAX_LOOP_CUTOFF = 22050;
25
+ /**
26
+ * Clamp a loop filter cutoff into `MIN_LOOP_CUTOFF`–`min(MAX_LOOP_CUTOFF, nyquist)` Hz. Non-finite input
27
+ * falls back to `fallback` (the open, transparent end of that filter).
28
+ * @internal — the audio engine applies this; game code sets cutoffs through `ctx.game.audio.setLoop`.
29
+ */
30
+ export declare function clampLoopCutoff(hz: number, fallback: number, nyquist?: number): number;
31
+ /** Smoothed listener velocity estimate from per-frame camera positions. @internal */
32
+ export interface ListenerVelocityTrack {
33
+ last: {
34
+ x: number;
35
+ y: number;
36
+ z: number;
37
+ } | null;
38
+ velocity: {
39
+ x: number;
40
+ y: number;
41
+ z: number;
42
+ };
43
+ }
44
+ /** @internal */
45
+ export declare function createListenerVelocityTrack(): ListenerVelocityTrack;
46
+ /**
47
+ * Advance the listener velocity estimate by one frame: the position delta over `dt`, eased with a
48
+ * ~50 ms time constant so frame-time jitter does not warble doppler. A cut resets it to zero.
49
+ * @internal
50
+ */
51
+ export declare function stepListenerVelocity(track: ListenerVelocityTrack, position: {
52
+ x: number;
53
+ y: number;
54
+ z: number;
55
+ }, dt: number): {
56
+ x: number;
57
+ y: number;
58
+ z: number;
59
+ };
@@ -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
+ }