@jgengine/shell 0.8.0 → 0.9.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 (127) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +11 -0
  3. package/dist/GamePlayerShell.d.ts +5 -2
  4. package/dist/GamePlayerShell.js +358 -98
  5. package/dist/GameUiPreview.js +2 -1
  6. package/dist/audio/AudioComponents.js +25 -8
  7. package/dist/audio/audioEngine.d.ts +1 -0
  8. package/dist/audio/audioEngine.js +4 -0
  9. package/dist/behaviour.d.ts +5 -24
  10. package/dist/behaviour.js +14 -50
  11. package/dist/behaviourAttach.d.ts +9 -0
  12. package/dist/behaviourAttach.js +32 -0
  13. package/dist/behaviourDriver.d.ts +7 -0
  14. package/dist/behaviourDriver.js +21 -0
  15. package/dist/camera/GameCameraRig.js +1 -1
  16. package/dist/camera/GameFirstPersonCamera.js +14 -0
  17. package/dist/camera/GameOrbitCamera.js +10 -1
  18. package/dist/camera/PlayerFov.d.ts +18 -0
  19. package/dist/camera/PlayerFov.js +48 -0
  20. package/dist/camera/cameraBlendMath.d.ts +13 -0
  21. package/dist/camera/cameraBlendMath.js +36 -0
  22. package/dist/camera/cameraRigs.d.ts +3 -0
  23. package/dist/camera/cameraRigs.js +36 -33
  24. package/dist/camera/fovPreference.d.ts +25 -0
  25. package/dist/camera/fovPreference.js +73 -0
  26. package/dist/camera/index.d.ts +3 -0
  27. package/dist/camera/index.js +3 -0
  28. package/dist/camera/rigMath.d.ts +10 -0
  29. package/dist/camera/rigMath.js +36 -1
  30. package/dist/camera/shakeChannel.d.ts +3 -17
  31. package/dist/camera/shakeChannel.js +2 -21
  32. package/dist/camera/shakeChannelMath.d.ts +8 -0
  33. package/dist/camera/shakeChannelMath.js +21 -0
  34. package/dist/cartridge.d.ts +145 -0
  35. package/dist/cartridge.js +245 -0
  36. package/dist/defineGame.js +2 -1
  37. package/dist/devtools/CollisionDebugWorld.d.ts +5 -0
  38. package/dist/devtools/CollisionDebugWorld.js +180 -0
  39. package/dist/devtools/DevtoolsOverlay.d.ts +65 -1
  40. package/dist/devtools/DevtoolsOverlay.js +383 -41
  41. package/dist/devtools/collisionDebug.d.ts +57 -0
  42. package/dist/devtools/collisionDebug.js +127 -0
  43. package/dist/devtools/collisionDebugMath.d.ts +102 -0
  44. package/dist/devtools/collisionDebugMath.js +128 -0
  45. package/dist/environment/Daylight.d.ts +13 -7
  46. package/dist/environment/Daylight.js +12 -11
  47. package/dist/environment/EnvironmentScene.js +82 -35
  48. package/dist/environment/groundPadMath.d.ts +2 -2
  49. package/dist/environment/groundPadMath.js +1 -1
  50. package/dist/environment/index.d.ts +2 -1
  51. package/dist/environment/index.js +1 -0
  52. package/dist/environment/skyLightingPolicy.d.ts +10 -0
  53. package/dist/environment/skyLightingPolicy.js +6 -0
  54. package/dist/input/mouseLook.d.ts +27 -0
  55. package/dist/input/mouseLook.js +35 -0
  56. package/dist/materialOverride.d.ts +4 -6
  57. package/dist/materialOverride.js +12 -16
  58. package/dist/pointer/PointerProbe.js +6 -2
  59. package/dist/pointer/pointerService.d.ts +3 -0
  60. package/dist/pointer/pointerService.js +6 -0
  61. package/dist/render/modelRender.d.ts +10 -1
  62. package/dist/render/modelRender.js +32 -5
  63. package/dist/render/resolveModel.d.ts +14 -0
  64. package/dist/render/resolveModel.js +24 -0
  65. package/dist/settings/QuickControls.d.ts +4 -0
  66. package/dist/settings/QuickControls.js +42 -0
  67. package/dist/settings/SettingsChrome.d.ts +1 -0
  68. package/dist/settings/SettingsChrome.js +10 -0
  69. package/dist/settings/SettingsMenu.d.ts +6 -0
  70. package/dist/settings/SettingsMenu.js +148 -0
  71. package/dist/settings/SettingsRuntime.d.ts +11 -0
  72. package/dist/settings/SettingsRuntime.js +19 -0
  73. package/dist/settings/appliedSettings.d.ts +14 -0
  74. package/dist/settings/appliedSettings.js +27 -0
  75. package/dist/settings/settingsController.d.ts +20 -0
  76. package/dist/settings/settingsController.js +165 -0
  77. package/dist/structures/GeneratedBuilding.d.ts +10 -0
  78. package/dist/structures/GeneratedBuilding.js +96 -8
  79. package/dist/structures/index.d.ts +1 -1
  80. package/dist/structures/index.js +1 -1
  81. package/dist/terrain/CarvedTerrain.d.ts +2 -1
  82. package/dist/terrain/CarvedTerrain.js +3 -3
  83. package/dist/terrain/GrassField.d.ts +3 -1
  84. package/dist/terrain/GrassField.js +4 -2
  85. package/dist/terrain/grassBudget.d.ts +3 -0
  86. package/dist/terrain/grassBudget.js +6 -0
  87. package/dist/terrain/grassGeometry.js +1 -1
  88. package/dist/terrain/terrainMath.d.ts +8 -0
  89. package/dist/terrain/terrainMath.js +9 -0
  90. package/dist/touch/OrientationHint.d.ts +3 -0
  91. package/dist/touch/OrientationHint.js +13 -0
  92. package/dist/touch/TouchControlsOverlay.d.ts +17 -1
  93. package/dist/touch/TouchControlsOverlay.js +115 -9
  94. package/dist/visibility/CullingProvider.d.ts +21 -0
  95. package/dist/visibility/CullingProvider.js +134 -0
  96. package/dist/vision/FrustumSensorHud.d.ts +1 -6
  97. package/dist/vision/FrustumSensorHud.js +25 -27
  98. package/dist/vision/frustumSampleEqual.d.ts +2 -0
  99. package/dist/vision/frustumSampleEqual.js +10 -0
  100. package/dist/water/Ocean.js +1 -1
  101. package/dist/water/OceanConfig.d.ts +13 -0
  102. package/dist/water/OceanConfig.js +25 -17
  103. package/dist/water/index.d.ts +1 -1
  104. package/dist/water/index.js +1 -1
  105. package/dist/weather/FireSpreadLayer.js +7 -2
  106. package/dist/weather/RainField.d.ts +3 -1
  107. package/dist/weather/RainField.js +4 -4
  108. package/dist/weather/SnowField.d.ts +3 -1
  109. package/dist/weather/SnowField.js +4 -4
  110. package/dist/weather/fireSpreadPose.d.ts +2 -0
  111. package/dist/weather/fireSpreadPose.js +4 -0
  112. package/dist/weather/weatherMath.d.ts +5 -1
  113. package/dist/weather/weatherMath.js +7 -2
  114. package/dist/world/DataObjects.d.ts +1 -1
  115. package/dist/world/DataObjects.js +3 -1
  116. package/dist/world/SpriteBatch.d.ts +44 -0
  117. package/dist/world/SpriteBatch.js +112 -0
  118. package/dist/world/WorldHud.d.ts +3 -0
  119. package/dist/world/WorldHud.js +89 -42
  120. package/dist/world/entityPose.d.ts +14 -0
  121. package/dist/world/entityPose.js +10 -0
  122. package/dist/world/telegraphPulse.d.ts +1 -0
  123. package/dist/world/telegraphPulse.js +4 -0
  124. package/dist/world/worldBarSamples.d.ts +30 -0
  125. package/dist/world/worldBarSamples.js +51 -0
  126. package/llms.txt +1404 -1143
  127. package/package.json +4 -4
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from "react";
3
3
  import { createGameContext } from "@jgengine/core/runtime/gameContext";
4
4
  import { GameProvider } from "@jgengine/react/provider";
5
+ import { HudViewportProvider } from "@jgengine/react/hudViewport";
5
6
  const PREVIEW_USER_ID = "ui-preview";
6
7
  const TICK_STEP = 1 / 60;
7
8
  function runTicks(ctx, playable, seconds) {
@@ -59,5 +60,5 @@ export function GameUiPreview({ playable, scenario = defaultUiScenario, }) {
59
60
  if (ctx === null)
60
61
  return _jsx("div", { className: "h-full w-full bg-neutral-900" });
61
62
  const GameUI = playable.GameUI;
62
- return (_jsx("div", { "data-ui-preview-ready": true, className: "relative h-full w-full overflow-hidden", style: { background: "linear-gradient(180deg, #2a3d33 0%, #1a2320 55%, #141b18 100%)" }, children: _jsx(GameProvider, { context: ctx, children: _jsx(GameUI, {}) }) }));
63
+ return (_jsx("div", { "data-ui-preview-ready": true, className: "relative h-full w-full overflow-hidden", style: { background: "linear-gradient(180deg, #2a3d33 0%, #1a2320 55%, #141b18 100%)" }, children: _jsx(GameProvider, { context: ctx, children: _jsx(HudViewportProvider, { platforms: playable.platforms, config: playable.hudFit, children: _jsx(GameUI, {}) }) }) }));
63
64
  }
@@ -1,6 +1,6 @@
1
1
  import { useFrame, useThree } from "@react-three/fiber";
2
2
  import { useEffect, useRef } from "react";
3
- import { useSceneEntities, useSceneObjects } from "@jgengine/react/hooks";
3
+ import { useGameContext } from "@jgengine/react/provider";
4
4
  export function AudioListener({ engine }) {
5
5
  const camera = useThree((state) => state.camera);
6
6
  useFrame(() => {
@@ -8,18 +8,20 @@ export function AudioListener({ engine }) {
8
8
  });
9
9
  return null;
10
10
  }
11
- function useTrackedEmitters(engine, soundByKey, entries) {
11
+ function useTrackedEmitters(engine, soundByKey, readEntries) {
12
12
  const handles = useRef(new Map());
13
+ const lastPose = useRef(new Map());
13
14
  useEffect(() => () => {
14
15
  for (const handle of handles.current.values())
15
16
  handle.stop();
16
17
  handles.current.clear();
18
+ lastPose.current.clear();
17
19
  }, [engine]);
18
20
  useFrame(() => {
19
21
  if (soundByKey === undefined)
20
22
  return;
21
23
  const live = new Set();
22
- for (const entry of entries) {
24
+ for (const entry of readEntries()) {
23
25
  const soundId = soundByKey[entry.key];
24
26
  if (soundId === undefined)
25
27
  continue;
@@ -27,27 +29,42 @@ function useTrackedEmitters(engine, soundByKey, entries) {
27
29
  let handle = handles.current.get(entry.id);
28
30
  if (handle === undefined) {
29
31
  handle = engine.playLoop(soundId, { x: entry.position[0], y: entry.position[1], z: entry.position[2] }) ?? undefined;
30
- if (handle !== undefined)
32
+ if (handle !== undefined) {
31
33
  handles.current.set(entry.id, handle);
34
+ lastPose.current.set(entry.id, entry.position);
35
+ }
36
+ continue;
37
+ }
38
+ const prev = lastPose.current.get(entry.id);
39
+ if (prev !== undefined &&
40
+ prev[0] === entry.position[0] &&
41
+ prev[1] === entry.position[1] &&
42
+ prev[2] === entry.position[2]) {
32
43
  continue;
33
44
  }
34
45
  handle.setPosition({ x: entry.position[0], y: entry.position[1], z: entry.position[2] });
46
+ lastPose.current.set(entry.id, entry.position);
35
47
  }
36
48
  for (const [id, handle] of handles.current) {
37
49
  if (!live.has(id)) {
38
50
  handle.stop();
39
51
  handles.current.delete(id);
52
+ lastPose.current.delete(id);
40
53
  }
41
54
  }
42
55
  });
43
56
  }
44
57
  export function EntityAudioEmitters({ engine, entitySounds, }) {
45
- const entities = useSceneEntities();
46
- useTrackedEmitters(engine, entitySounds, entities.map((entity) => ({ id: entity.id, key: entity.name, position: entity.position })));
58
+ const ctx = useGameContext();
59
+ useTrackedEmitters(engine, entitySounds, () => ctx.scene.entity.list().map((entity) => ({ id: entity.id, key: entity.name, position: entity.position })));
47
60
  return null;
48
61
  }
49
62
  export function ObjectAudioEmitters({ engine, objectSounds, }) {
50
- const objects = useSceneObjects();
51
- useTrackedEmitters(engine, objectSounds, objects.map((object) => ({ id: object.instanceId, key: object.catalogId, position: object.position })));
63
+ const ctx = useGameContext();
64
+ useTrackedEmitters(engine, objectSounds, () => ctx.scene.object.list().map((object) => ({
65
+ id: object.instanceId,
66
+ key: object.catalogId,
67
+ position: object.position,
68
+ })));
52
69
  return null;
53
70
  }
@@ -17,6 +17,7 @@ export interface AudioEngine {
17
17
  playOneShot(soundId: string, position?: Vec3): void;
18
18
  playLoop(soundId: string, position?: Vec3): AudioEmitterHandle | null;
19
19
  setBusGain(busId: string, gain: number): void;
20
+ setMasterGain(gain: number): void;
20
21
  resume(): void;
21
22
  dispose(): void;
22
23
  }
@@ -5,6 +5,7 @@ function createNoopEngine() {
5
5
  playOneShot: () => undefined,
6
6
  playLoop: () => null,
7
7
  setBusGain: () => undefined,
8
+ setMasterGain: () => undefined,
8
9
  resume: () => undefined,
9
10
  dispose: () => undefined,
10
11
  };
@@ -100,6 +101,9 @@ export function createAudioEngine(config = {}) {
100
101
  setBusGain(busId, gain) {
101
102
  busGainNode(busId).gain.value = gain;
102
103
  },
104
+ setMasterGain(gain) {
105
+ masterGain.gain.value = gain;
106
+ },
103
107
  resume() {
104
108
  void context.resume().catch(() => undefined);
105
109
  },
@@ -1,29 +1,10 @@
1
- import type { Object3D } from "three";
2
- import { Behaviour, type BehaviourWorld } from "@jgengine/core/behaviour/behaviour";
3
- /**
4
- * A core `Behaviour` bound to a three.js object. `onBeforeRender`/`onAfterRender`
5
- * ride the object's own three.js render callbacks, so they only fire for renderable
6
- * objects (Mesh, Line, Points, Sprite) that are visible and in frustum — attach to
7
- * the mesh itself, not a parent Group, when you need them.
8
- */
9
- export declare class Object3DBehaviour extends Behaviour {
10
- /** @internal */ _object: Object3D | null;
11
- /** The bound three.js object; available from `onAwake` onward. */
12
- get object(): Object3D;
13
- onBeforeRender(): void;
14
- onAfterRender(): void;
15
- }
16
- /**
17
- * Binds `behaviour` to `object` and attaches it to `world` under `nodeId`
18
- * (default: the object's uuid). Render hooks are chained onto the object's
19
- * existing `onBeforeRender`/`onAfterRender` only when actually overridden, and
20
- * are gated on the behaviour being active; the update lifecycle still flows
21
- * through `world.update`.
22
- */
23
- export declare function attachObject3D<T extends Object3DBehaviour>(world: BehaviourWorld, object: Object3D, behaviour: T, nodeId?: string): T;
1
+ import type { BehaviourWorld } from "@jgengine/core/behaviour/behaviour";
2
+ export { createBehaviourWorldDriver } from "./behaviourDriver.js";
3
+ export { attachObject3D, Object3DBehaviour } from "./behaviourAttach.js";
24
4
  /**
25
5
  * Bootstraps `world` on mount and dispatches `world.update` every frame at
26
- * simulation priority. `scaleDt` maps the raw frame delta to the dt behaviours
6
+ * simulation priority. Stops driving updates on unmount so remount does not
7
+ * double-step. `scaleDt` maps the raw frame delta to the dt behaviours
27
8
  * receive — pass the game clock's scaling to keep behaviours on game time, or
28
9
  * omit for real-time seconds. Games driving `world.update` themselves from
29
10
  * `loop.onTick` should not also render this.
package/dist/behaviour.js CHANGED
@@ -1,63 +1,27 @@
1
1
  import { useFrame } from "@react-three/fiber";
2
- import { useEffect } from "react";
3
- import { Behaviour } from "@jgengine/core/behaviour/behaviour";
4
- /**
5
- * A core `Behaviour` bound to a three.js object. `onBeforeRender`/`onAfterRender`
6
- * ride the object's own three.js render callbacks, so they only fire for renderable
7
- * objects (Mesh, Line, Points, Sprite) that are visible and in frustum — attach to
8
- * the mesh itself, not a parent Group, when you need them.
9
- */
10
- export class Object3DBehaviour extends Behaviour {
11
- /** @internal */ _object = null;
12
- /** The bound three.js object; available from `onAwake` onward. */
13
- get object() {
14
- if (this._object === null)
15
- throw new Error("behaviour is not bound to an Object3D");
16
- return this._object;
17
- }
18
- onBeforeRender() { }
19
- onAfterRender() { }
20
- }
21
- /**
22
- * Binds `behaviour` to `object` and attaches it to `world` under `nodeId`
23
- * (default: the object's uuid). Render hooks are chained onto the object's
24
- * existing `onBeforeRender`/`onAfterRender` only when actually overridden, and
25
- * are gated on the behaviour being active; the update lifecycle still flows
26
- * through `world.update`.
27
- */
28
- export function attachObject3D(world, object, behaviour, nodeId = object.uuid) {
29
- behaviour._object = object;
30
- world.attach(nodeId, behaviour);
31
- if (behaviour.onBeforeRender !== Object3DBehaviour.prototype.onBeforeRender) {
32
- const previous = object.onBeforeRender;
33
- object.onBeforeRender = function chainedBeforeRender(...args) {
34
- previous.apply(this, args);
35
- if (behaviour.isActive)
36
- behaviour.onBeforeRender();
37
- };
38
- }
39
- if (behaviour.onAfterRender !== Object3DBehaviour.prototype.onAfterRender) {
40
- const previous = object.onAfterRender;
41
- object.onAfterRender = function chainedAfterRender(...args) {
42
- previous.apply(this, args);
43
- if (behaviour.isActive)
44
- behaviour.onAfterRender();
45
- };
46
- }
47
- return behaviour;
48
- }
2
+ import { useEffect, useRef } from "react";
3
+ import { createBehaviourWorldDriver } from "./behaviourDriver.js";
4
+ export { createBehaviourWorldDriver } from "./behaviourDriver.js";
5
+ export { attachObject3D, Object3DBehaviour } from "./behaviourAttach.js";
49
6
  /**
50
7
  * Bootstraps `world` on mount and dispatches `world.update` every frame at
51
- * simulation priority. `scaleDt` maps the raw frame delta to the dt behaviours
8
+ * simulation priority. Stops driving updates on unmount so remount does not
9
+ * double-step. `scaleDt` maps the raw frame delta to the dt behaviours
52
10
  * receive — pass the game clock's scaling to keep behaviours on game time, or
53
11
  * omit for real-time seconds. Games driving `world.update` themselves from
54
12
  * `loop.onTick` should not also render this.
55
13
  */
56
14
  export function useBehaviourWorld(world, scaleDt) {
15
+ const driverRef = useRef(createBehaviourWorldDriver(world));
57
16
  useEffect(() => {
58
- world.start();
17
+ const driver = createBehaviourWorldDriver(world);
18
+ driverRef.current = driver;
19
+ driver.start();
20
+ return () => {
21
+ driver.stop();
22
+ };
59
23
  }, [world]);
60
24
  useFrame((_state, rawDt) => {
61
- world.update(scaleDt === undefined ? rawDt : scaleDt(rawDt));
25
+ driverRef.current.step(scaleDt === undefined ? rawDt : scaleDt(rawDt));
62
26
  });
63
27
  }
@@ -0,0 +1,9 @@
1
+ import type { Object3D } from "three";
2
+ import { Behaviour, type BehaviourWorld } from "@jgengine/core/behaviour/behaviour";
3
+ export declare class Object3DBehaviour extends Behaviour {
4
+ /** @internal */ _object: Object3D | null;
5
+ get object(): Object3D;
6
+ onBeforeRender(): void;
7
+ onAfterRender(): void;
8
+ }
9
+ export declare function attachObject3D<T extends Object3DBehaviour>(world: BehaviourWorld, object: Object3D, behaviour: T, nodeId?: string): T;
@@ -0,0 +1,32 @@
1
+ import { Behaviour } from "@jgengine/core/behaviour/behaviour";
2
+ export class Object3DBehaviour extends Behaviour {
3
+ /** @internal */ _object = null;
4
+ get object() {
5
+ if (this._object === null)
6
+ throw new Error("behaviour is not bound to an Object3D");
7
+ return this._object;
8
+ }
9
+ onBeforeRender() { }
10
+ onAfterRender() { }
11
+ }
12
+ export function attachObject3D(world, object, behaviour, nodeId = object.uuid) {
13
+ behaviour._object = object;
14
+ world.attach(nodeId, behaviour);
15
+ if (behaviour.onBeforeRender !== Object3DBehaviour.prototype.onBeforeRender) {
16
+ const previous = object.onBeforeRender;
17
+ object.onBeforeRender = function chainedBeforeRender(...args) {
18
+ previous.apply(this, args);
19
+ if (behaviour.isActive)
20
+ behaviour.onBeforeRender();
21
+ };
22
+ }
23
+ if (behaviour.onAfterRender !== Object3DBehaviour.prototype.onAfterRender) {
24
+ const previous = object.onAfterRender;
25
+ object.onAfterRender = function chainedAfterRender(...args) {
26
+ previous.apply(this, args);
27
+ if (behaviour.isActive)
28
+ behaviour.onAfterRender();
29
+ };
30
+ }
31
+ return behaviour;
32
+ }
@@ -0,0 +1,7 @@
1
+ import type { BehaviourWorld } from "@jgengine/core/behaviour/behaviour";
2
+ export declare function createBehaviourWorldDriver(world: BehaviourWorld): {
3
+ start(): void;
4
+ stop(): void;
5
+ isRunning(): boolean;
6
+ step(dt: number): void;
7
+ };
@@ -0,0 +1,21 @@
1
+ export function createBehaviourWorldDriver(world) {
2
+ let running = false;
3
+ return {
4
+ start() {
5
+ if (!world.started())
6
+ world.start();
7
+ running = true;
8
+ },
9
+ stop() {
10
+ running = false;
11
+ },
12
+ isRunning() {
13
+ return running;
14
+ },
15
+ step(dt) {
16
+ if (!running)
17
+ return;
18
+ world.update(dt);
19
+ },
20
+ };
21
+ }
@@ -22,7 +22,7 @@ export function GameCameraRig({ yawRef, pitchRef, config, onDragChange, pointerC
22
22
  const followEntityId = directed.followEntityId;
23
23
  const rig = (() => {
24
24
  if (directed.cinematic !== undefined) {
25
- return (_jsx(CinematicRig, { yawRef: yawRef, pitchRef: pitchRef, config: { ...config, cinematic: directed.cinematic }, followEntityId: followEntityId }));
25
+ return (_jsx(CinematicRig, { yawRef: yawRef, pitchRef: pitchRef, config: { ...config, cinematic: directed.cinematic }, followEntityId: followEntityId, absoluteFov: true }));
26
26
  }
27
27
  const kind = resolveRigKind(config);
28
28
  switch (kind) {
@@ -4,6 +4,7 @@ import { useEffect, useRef } from "react";
4
4
  import * as THREE from "three";
5
5
  import { useGameContext } from "@jgengine/react/provider";
6
6
  import { usePlayer } from "@jgengine/react/hooks";
7
+ import { usePlayerFov } from "./PlayerFov.js";
7
8
  import { GAME_SIM_FRAME_PRIORITY, ORBIT_CAMERA_FRAME_PRIORITY } from "./orbitCameraMath.js";
8
9
  const DEFAULT_EYE_HEIGHT = 1.6;
9
10
  const DEFAULT_SENSITIVITY = 0.0025;
@@ -14,9 +15,11 @@ export function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId
14
15
  const maxPitch = config?.maxPitch ?? DEFAULT_MAX_PITCH;
15
16
  const { userId } = usePlayer();
16
17
  const ctx = useGameContext();
18
+ const playerFov = usePlayerFov();
17
19
  const camera = useThree((state) => state.camera);
18
20
  const domElement = useThree((state) => state.gl.domElement);
19
21
  const followId = followEntityId ?? userId;
22
+ const seededRef = useRef(false);
20
23
  useEffect(() => {
21
24
  const requestLock = () => {
22
25
  if (window.matchMedia?.("(pointer: coarse)").matches)
@@ -41,9 +44,20 @@ export function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId
41
44
  const entity = ctx.scene.entity.get(followId);
42
45
  if (entity === null)
43
46
  return;
47
+ if (!seededRef.current) {
48
+ seededRef.current = true;
49
+ yawRef.current = entity.rotationY;
50
+ }
44
51
  const cosPitch = Math.cos(pitchRef.current);
45
52
  camera.position.set(entity.position[0], entity.position[1] + eyeHeight, entity.position[2]);
46
53
  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);
54
+ if (camera.isPerspectiveCamera === true) {
55
+ const perspective = camera;
56
+ if (Math.abs(perspective.fov - playerFov.fov) > 0.001) {
57
+ perspective.fov = playerFov.fov;
58
+ perspective.updateProjectionMatrix();
59
+ }
60
+ }
47
61
  }, ORBIT_CAMERA_FRAME_PRIORITY);
48
62
  if (config?.viewmodel === false)
49
63
  return null;
@@ -2,10 +2,11 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { OrbitControls } from "@react-three/drei";
3
3
  import { useFrame, useThree } from "@react-three/fiber";
4
4
  import { useEffect, useRef } from "react";
5
- import { MOUSE, Vector3 } from "three";
5
+ import { MOUSE, PerspectiveCamera, Vector3 } from "three";
6
6
  import { useGameContext } from "@jgengine/react/provider";
7
7
  import { usePlayer } from "@jgengine/react/hooks";
8
8
  import { cameraLookPitch, ORBIT_CAMERA_FRAME_PRIORITY, orbitFollowStep, orbitYawFromCamera, resolveFollowTargetFromPosition, resolveOrbitCameraConfig, seedOrbitFollowState, } from "./orbitCameraMath.js";
9
+ import { usePlayerFov } from "./PlayerFov.js";
9
10
  export function GameOrbitCamera({ yawRef, pitchRef, config: configPatch, followEntityId, resolveFollowTarget, onDragChange, onCameraFollow, pointerControls = false, }) {
10
11
  const config = resolveOrbitCameraConfig(configPatch);
11
12
  const controlsRef = useRef(null);
@@ -13,6 +14,7 @@ export function GameOrbitCamera({ yawRef, pitchRef, config: configPatch, followE
13
14
  const draggingRef = useRef(false);
14
15
  const { userId } = usePlayer();
15
16
  const ctx = useGameContext();
17
+ const playerFov = usePlayerFov();
16
18
  const camera = useThree((state) => state.camera);
17
19
  const followId = followEntityId ?? userId;
18
20
  useEffect(() => {
@@ -64,6 +66,13 @@ export function GameOrbitCamera({ yawRef, pitchRef, config: configPatch, followE
64
66
  if (pitchRef !== undefined) {
65
67
  pitchRef.current = cameraLookPitch({ x: camera.position.x, y: camera.position.y, z: camera.position.z }, stepped.target);
66
68
  }
69
+ if (camera.isPerspectiveCamera === true) {
70
+ const perspective = camera;
71
+ if (Math.abs(perspective.fov - playerFov.fov) > 0.001) {
72
+ perspective.fov = playerFov.fov;
73
+ perspective.updateProjectionMatrix();
74
+ }
75
+ }
67
76
  onCameraFollow?.({
68
77
  entityId: followId,
69
78
  target: stepped.target,
@@ -0,0 +1,18 @@
1
+ import { type ReactNode } from "react";
2
+ import type { GameCameraConfig } from "@jgengine/core/game/playableGame";
3
+ import { type PlayerFovBounds } from "./fovPreference.js";
4
+ export interface PlayerFovState {
5
+ fov: number;
6
+ bounds: PlayerFovBounds;
7
+ enabled: boolean;
8
+ persist: boolean;
9
+ setFov: (value: number) => void;
10
+ compose: (poseFov: number, mode?: "relative" | "absolute") => number;
11
+ }
12
+ export declare function PlayerFovProvider({ config, orthographic, children, }: {
13
+ config?: GameCameraConfig;
14
+ orthographic: boolean;
15
+ children: ReactNode;
16
+ }): import("react").JSX.Element;
17
+ export declare function usePlayerFov(): PlayerFovState;
18
+ export declare function PlayerFovSlider(): import("react").JSX.Element | null;
@@ -0,0 +1,48 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, useCallback, useContext, useMemo, useState, } from "react";
3
+ import { composePlayerFov, loadPlayerFov, resolvePlayerFovBounds, savePlayerFov, } from "./fovPreference.js";
4
+ const PlayerFovContext = createContext(null);
5
+ export function PlayerFovProvider({ config, orthographic, children, }) {
6
+ const bounds = useMemo(() => resolvePlayerFovBounds({
7
+ min: config?.playerFov?.min,
8
+ max: config?.playerFov?.max,
9
+ default: config?.playerFov?.default ?? config?.frustum?.fov,
10
+ }), [config?.playerFov?.min, config?.playerFov?.max, config?.playerFov?.default, config?.frustum?.fov]);
11
+ const persist = config?.playerFov?.persist !== false;
12
+ const showControl = !orthographic && config?.playerFov?.control !== false;
13
+ const [fov, setFovState] = useState(() => persist ? loadPlayerFov(bounds) : bounds.defaultFov);
14
+ const setFov = useCallback((value) => {
15
+ setFovState(persist ? savePlayerFov(value, bounds) : savePlayerFov(value, bounds, null));
16
+ }, [bounds, persist]);
17
+ const compose = useCallback((poseFov, mode = "relative") => orthographic ? poseFov : composePlayerFov(fov, poseFov, mode, bounds), [bounds, fov, orthographic]);
18
+ const value = useMemo(() => ({
19
+ fov,
20
+ bounds,
21
+ enabled: showControl,
22
+ persist,
23
+ setFov,
24
+ compose,
25
+ }), [bounds, compose, fov, persist, setFov, showControl]);
26
+ return _jsx(PlayerFovContext.Provider, { value: value, children: children });
27
+ }
28
+ export function usePlayerFov() {
29
+ const value = useContext(PlayerFovContext);
30
+ if (value === null) {
31
+ const bounds = resolvePlayerFovBounds();
32
+ return {
33
+ fov: bounds.defaultFov,
34
+ bounds,
35
+ enabled: false,
36
+ persist: false,
37
+ setFov: () => undefined,
38
+ compose: (poseFov) => poseFov,
39
+ };
40
+ }
41
+ return value;
42
+ }
43
+ export function PlayerFovSlider() {
44
+ const { fov, setFov, bounds, enabled } = usePlayerFov();
45
+ if (!enabled)
46
+ return null;
47
+ return (_jsxs("label", { className: "pointer-events-auto absolute bottom-3 right-3 z-20 flex items-center gap-2 rounded-md bg-neutral-950/75 px-2.5 py-1.5 text-[11px] font-medium tracking-wide text-neutral-200 shadow-lg ring-1 ring-white/10 backdrop-blur-sm", children: [_jsx("span", { className: "text-neutral-400", children: "FOV" }), _jsx("input", { type: "range", min: bounds.min, max: bounds.max, step: 1, value: fov, "aria-label": "Field of view", className: "h-1 w-24 cursor-pointer accent-emerald-400", onChange: (event) => setFov(Number(event.target.value)) }), _jsx("span", { className: "w-7 tabular-nums text-neutral-100", children: Math.round(fov) })] }));
48
+ }
@@ -0,0 +1,13 @@
1
+ import type { Camera, Quaternion, Vector3 } from "three";
2
+ export interface CameraBlendScratch {
3
+ fromPos: Vector3;
4
+ fromQuat: Quaternion;
5
+ toPos: Vector3;
6
+ toQuat: Quaternion;
7
+ fov: number;
8
+ elapsed: number;
9
+ duration: number;
10
+ }
11
+ export declare function createCameraBlendScratch(Vector3Ctor: new () => Vector3, QuaternionCtor: new () => Quaternion): CameraBlendScratch;
12
+ export declare function captureCameraBlendFrom(scratch: CameraBlendScratch, position: Vector3, quaternion: Quaternion, fov: number, duration: number): void;
13
+ export declare function applyCameraBlendStep(scratch: CameraBlendScratch, camera: Camera, targetFov: number, dt: number): boolean;
@@ -0,0 +1,36 @@
1
+ import { smoothstep } from "./rigMath.js";
2
+ export function createCameraBlendScratch(Vector3Ctor, QuaternionCtor) {
3
+ return {
4
+ fromPos: new Vector3Ctor(),
5
+ fromQuat: new QuaternionCtor(),
6
+ toPos: new Vector3Ctor(),
7
+ toQuat: new QuaternionCtor(),
8
+ fov: 55,
9
+ elapsed: 0,
10
+ duration: 0,
11
+ };
12
+ }
13
+ export function captureCameraBlendFrom(scratch, position, quaternion, fov, duration) {
14
+ scratch.fromPos.copy(position);
15
+ scratch.fromQuat.copy(quaternion);
16
+ scratch.fov = fov;
17
+ scratch.elapsed = 0;
18
+ scratch.duration = duration;
19
+ }
20
+ function isPerspective(camera) {
21
+ return camera.isPerspectiveCamera === true;
22
+ }
23
+ export function applyCameraBlendStep(scratch, camera, targetFov, dt) {
24
+ scratch.elapsed += dt;
25
+ const t = scratch.duration <= 0 ? 1 : Math.min(scratch.elapsed / scratch.duration, 1);
26
+ const eased = smoothstep(t);
27
+ scratch.toPos.copy(camera.position);
28
+ scratch.toQuat.copy(camera.quaternion);
29
+ camera.position.lerpVectors(scratch.fromPos, scratch.toPos, eased);
30
+ camera.quaternion.slerpQuaternions(scratch.fromQuat, scratch.toQuat, eased);
31
+ if (isPerspective(camera)) {
32
+ camera.fov = scratch.fov + (targetFov - scratch.fov) * eased;
33
+ camera.updateProjectionMatrix();
34
+ }
35
+ return t >= 1;
36
+ }
@@ -1,5 +1,6 @@
1
1
  import { type MutableRefObject } from "react";
2
2
  import type { GameCameraConfig } from "@jgengine/core/game/playableGame";
3
+ export { applyCameraBlendStep, captureCameraBlendFrom, createCameraBlendScratch, type CameraBlendScratch, } from "./cameraBlendMath.js";
3
4
  export declare const CAMERA_RIG_FRAME_PRIORITY = -1;
4
5
  export declare const CAMERA_POST_FRAME_PRIORITY: number;
5
6
  export interface RigProps {
@@ -7,6 +8,8 @@ export interface RigProps {
7
8
  pitchRef: MutableRefObject<number>;
8
9
  config?: GameCameraConfig;
9
10
  followEntityId?: string | null;
11
+ /** When true, pose FOV is treated as absolute (cinematic keyframes). */
12
+ absoluteFov?: boolean;
10
13
  }
11
14
  export declare function TopDownRig(props: RigProps): null;
12
15
  /** Fixed side-on 2.5D follow rig: watches the followed entity from the perpendicular axis, never reading WASD/mouse-look. */