@jgengine/shell 0.7.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 (168) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +37 -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 +36 -1
  8. package/dist/GamePlayerShell.js +751 -80
  9. package/dist/GameUiPreview.js +2 -1
  10. package/dist/audio/AudioComponents.js +25 -8
  11. package/dist/audio/audioEngine.d.ts +1 -0
  12. package/dist/audio/audioEngine.js +4 -0
  13. package/dist/behaviour.d.ts +12 -0
  14. package/dist/behaviour.js +27 -0
  15. package/dist/behaviourAttach.d.ts +9 -0
  16. package/dist/behaviourAttach.js +32 -0
  17. package/dist/behaviourDriver.d.ts +7 -0
  18. package/dist/behaviourDriver.js +21 -0
  19. package/dist/camera/GameCameraRig.d.ts +8 -3
  20. package/dist/camera/GameCameraRig.js +28 -14
  21. package/dist/camera/GameFirstPersonCamera.js +16 -0
  22. package/dist/camera/GameInspectionCamera.d.ts +6 -0
  23. package/dist/camera/GameInspectionCamera.js +24 -0
  24. package/dist/camera/GameOrbitCamera.js +10 -1
  25. package/dist/camera/PlayerFov.d.ts +18 -0
  26. package/dist/camera/PlayerFov.js +48 -0
  27. package/dist/camera/cameraBlendMath.d.ts +13 -0
  28. package/dist/camera/cameraBlendMath.js +36 -0
  29. package/dist/camera/cameraRigs.d.ts +8 -1
  30. package/dist/camera/cameraRigs.js +78 -38
  31. package/dist/camera/fovPreference.d.ts +25 -0
  32. package/dist/camera/fovPreference.js +73 -0
  33. package/dist/camera/index.d.ts +7 -2
  34. package/dist/camera/index.js +7 -2
  35. package/dist/camera/inspectionCameraMath.d.ts +25 -0
  36. package/dist/camera/inspectionCameraMath.js +44 -0
  37. package/dist/camera/rigMath.d.ts +50 -1
  38. package/dist/camera/rigMath.js +91 -1
  39. package/dist/camera/rigResolve.d.ts +15 -0
  40. package/dist/camera/rigResolve.js +52 -0
  41. package/dist/camera/shakeChannel.d.ts +3 -17
  42. package/dist/camera/shakeChannel.js +2 -21
  43. package/dist/camera/shakeChannelMath.d.ts +8 -0
  44. package/dist/camera/shakeChannelMath.js +21 -0
  45. package/dist/cartridge.d.ts +145 -0
  46. package/dist/cartridge.js +245 -0
  47. package/dist/defineGame.d.ts +16 -0
  48. package/dist/defineGame.js +52 -0
  49. package/dist/devtools/CollisionDebugWorld.d.ts +5 -0
  50. package/dist/devtools/CollisionDebugWorld.js +180 -0
  51. package/dist/devtools/DevtoolsOverlay.d.ts +78 -0
  52. package/dist/devtools/DevtoolsOverlay.js +676 -0
  53. package/dist/devtools/collisionDebug.d.ts +57 -0
  54. package/dist/devtools/collisionDebug.js +127 -0
  55. package/dist/devtools/collisionDebugMath.d.ts +102 -0
  56. package/dist/devtools/collisionDebugMath.js +128 -0
  57. package/dist/environment/Daylight.d.ts +56 -0
  58. package/dist/environment/Daylight.js +101 -0
  59. package/dist/environment/EnvironmentScene.js +87 -48
  60. package/dist/environment/GroundPad.d.ts +7 -0
  61. package/dist/environment/GroundPad.js +12 -0
  62. package/dist/environment/daylightCycle.d.ts +26 -0
  63. package/dist/environment/daylightCycle.js +116 -0
  64. package/dist/environment/groundPadMath.d.ts +13 -0
  65. package/dist/environment/groundPadMath.js +10 -0
  66. package/dist/environment/index.d.ts +3 -0
  67. package/dist/environment/index.js +3 -0
  68. package/dist/environment/skyLightingPolicy.d.ts +10 -0
  69. package/dist/environment/skyLightingPolicy.js +6 -0
  70. package/dist/input/mouseLook.d.ts +27 -0
  71. package/dist/input/mouseLook.js +35 -0
  72. package/dist/materialOverride.d.ts +6 -0
  73. package/dist/materialOverride.js +28 -0
  74. package/dist/multiplayer.d.ts +16 -9
  75. package/dist/multiplayer.js +62 -12
  76. package/dist/pointer/PointerProbe.js +20 -3
  77. package/dist/pointer/pointerService.d.ts +5 -0
  78. package/dist/pointer/pointerService.js +51 -24
  79. package/dist/registry.d.ts +4 -1
  80. package/dist/registry.js +3 -1
  81. package/dist/render/modelRender.d.ts +23 -0
  82. package/dist/render/modelRender.js +106 -0
  83. package/dist/render/resolveModel.d.ts +14 -0
  84. package/dist/render/resolveModel.js +24 -0
  85. package/dist/settings/QuickControls.d.ts +4 -0
  86. package/dist/settings/QuickControls.js +42 -0
  87. package/dist/settings/SettingsChrome.d.ts +1 -0
  88. package/dist/settings/SettingsChrome.js +10 -0
  89. package/dist/settings/SettingsMenu.d.ts +6 -0
  90. package/dist/settings/SettingsMenu.js +148 -0
  91. package/dist/settings/SettingsRuntime.d.ts +11 -0
  92. package/dist/settings/SettingsRuntime.js +19 -0
  93. package/dist/settings/appliedSettings.d.ts +14 -0
  94. package/dist/settings/appliedSettings.js +27 -0
  95. package/dist/settings/settingsController.d.ts +20 -0
  96. package/dist/settings/settingsController.js +165 -0
  97. package/dist/structures/GeneratedBuilding.d.ts +10 -0
  98. package/dist/structures/GeneratedBuilding.js +96 -8
  99. package/dist/structures/index.d.ts +1 -1
  100. package/dist/structures/index.js +1 -1
  101. package/dist/terrain/CarvedTerrain.d.ts +2 -1
  102. package/dist/terrain/CarvedTerrain.js +3 -3
  103. package/dist/terrain/GrassField.d.ts +3 -1
  104. package/dist/terrain/GrassField.js +4 -2
  105. package/dist/terrain/grassBudget.d.ts +3 -0
  106. package/dist/terrain/grassBudget.js +6 -0
  107. package/dist/terrain/grassGeometry.js +1 -1
  108. package/dist/terrain/index.d.ts +1 -1
  109. package/dist/terrain/index.js +1 -1
  110. package/dist/terrain/terrainMath.d.ts +9 -0
  111. package/dist/terrain/terrainMath.js +17 -2
  112. package/dist/touch/OrientationHint.d.ts +3 -0
  113. package/dist/touch/OrientationHint.js +13 -0
  114. package/dist/touch/TouchControlsOverlay.d.ts +34 -0
  115. package/dist/touch/TouchControlsOverlay.js +279 -0
  116. package/dist/visibility/CullingProvider.d.ts +21 -0
  117. package/dist/visibility/CullingProvider.js +134 -0
  118. package/dist/vision/FrustumSensorHud.d.ts +1 -6
  119. package/dist/vision/FrustumSensorHud.js +25 -27
  120. package/dist/vision/frustumSampleEqual.d.ts +2 -0
  121. package/dist/vision/frustumSampleEqual.js +10 -0
  122. package/dist/water/Ocean.js +1 -1
  123. package/dist/water/OceanConfig.d.ts +13 -0
  124. package/dist/water/OceanConfig.js +25 -17
  125. package/dist/water/OceanShader.d.ts +1 -1
  126. package/dist/water/OceanShader.js +3 -3
  127. package/dist/water/index.d.ts +1 -1
  128. package/dist/water/index.js +1 -1
  129. package/dist/weather/FireSpreadLayer.js +7 -2
  130. package/dist/weather/RainField.d.ts +3 -1
  131. package/dist/weather/RainField.js +4 -4
  132. package/dist/weather/SnowField.d.ts +3 -1
  133. package/dist/weather/SnowField.js +4 -4
  134. package/dist/weather/fireSpreadPose.d.ts +2 -0
  135. package/dist/weather/fireSpreadPose.js +4 -0
  136. package/dist/weather/weatherMath.d.ts +5 -1
  137. package/dist/weather/weatherMath.js +7 -2
  138. package/dist/world/DataObjects.d.ts +44 -0
  139. package/dist/world/DataObjects.js +77 -0
  140. package/dist/world/GridWorldScene.d.ts +10 -0
  141. package/dist/world/GridWorldScene.js +54 -0
  142. package/dist/world/SpriteBatch.d.ts +44 -0
  143. package/dist/world/SpriteBatch.js +112 -0
  144. package/dist/world/WorldHud.d.ts +8 -1
  145. package/dist/world/WorldHud.js +90 -39
  146. package/dist/world/WorldItems.js +21 -2
  147. package/dist/world/entityPose.d.ts +14 -0
  148. package/dist/world/entityPose.js +10 -0
  149. package/dist/world/telegraphPulse.d.ts +1 -0
  150. package/dist/world/telegraphPulse.js +4 -0
  151. package/dist/world/worldBarSamples.d.ts +30 -0
  152. package/dist/world/worldBarSamples.js +51 -0
  153. package/llms.txt +2643 -0
  154. package/package.json +6 -5
  155. package/dist/demo/builderDemo.d.ts +0 -2
  156. package/dist/demo/builderDemo.js +0 -189
  157. package/dist/demo/demoGame.d.ts +0 -2
  158. package/dist/demo/demoGame.js +0 -208
  159. package/dist/demo/environmentShowcase.d.ts +0 -2
  160. package/dist/demo/environmentShowcase.js +0 -15
  161. package/dist/demo/mapDemo.d.ts +0 -2
  162. package/dist/demo/mapDemo.js +0 -206
  163. package/dist/demo/pointerDemo.d.ts +0 -2
  164. package/dist/demo/pointerDemo.js +0 -151
  165. package/dist/demo/sensorShowcase.d.ts +0 -2
  166. package/dist/demo/sensorShowcase.js +0 -131
  167. package/dist/demo/survivalDemo.d.ts +0 -2
  168. package/dist/demo/survivalDemo.js +0 -291
@@ -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
  },
@@ -0,0 +1,12 @@
1
+ import type { BehaviourWorld } from "@jgengine/core/behaviour/behaviour";
2
+ export { createBehaviourWorldDriver } from "./behaviourDriver.js";
3
+ export { attachObject3D, Object3DBehaviour } from "./behaviourAttach.js";
4
+ /**
5
+ * Bootstraps `world` on mount and dispatches `world.update` every frame at
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
8
+ * receive — pass the game clock's scaling to keep behaviours on game time, or
9
+ * omit for real-time seconds. Games driving `world.update` themselves from
10
+ * `loop.onTick` should not also render this.
11
+ */
12
+ export declare function useBehaviourWorld(world: BehaviourWorld, scaleDt?: (rawDt: number) => number): void;
@@ -0,0 +1,27 @@
1
+ import { useFrame } from "@react-three/fiber";
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";
6
+ /**
7
+ * Bootstraps `world` on mount and dispatches `world.update` every frame at
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
10
+ * receive — pass the game clock's scaling to keep behaviours on game time, or
11
+ * omit for real-time seconds. Games driving `world.update` themselves from
12
+ * `loop.onTick` should not also render this.
13
+ */
14
+ export function useBehaviourWorld(world, scaleDt) {
15
+ const driverRef = useRef(createBehaviourWorldDriver(world));
16
+ useEffect(() => {
17
+ const driver = createBehaviourWorldDriver(world);
18
+ driverRef.current = driver;
19
+ driver.start();
20
+ return () => {
21
+ driver.stop();
22
+ };
23
+ }, [world]);
24
+ useFrame((_state, rawDt) => {
25
+ driverRef.current.step(scaleDt === undefined ? rawDt : scaleDt(rawDt));
26
+ });
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
+ }
@@ -1,11 +1,16 @@
1
1
  import { type MutableRefObject } from "react";
2
- import type { CameraRigKind, GameCameraConfig } from "@jgengine/core/game/playableGame";
2
+ import type { GameCameraConfig } from "@jgengine/core/game/playableGame";
3
+ import type { CameraDirector } from "@jgengine/core/runtime/cameraDirector";
4
+ export { resolveRigKind } from "./rigResolve.js";
3
5
  export interface GameCameraRigProps {
4
6
  yawRef: MutableRefObject<number>;
5
7
  pitchRef: MutableRefObject<number>;
6
8
  config?: GameCameraConfig;
7
9
  onDragChange?: (dragging: boolean) => void;
8
10
  pointerControls?: boolean;
11
+ /** False when the game's own input map already claims WASD, so the RTS rig should stop panning on those keys. */
12
+ panKeysEnabled?: boolean;
13
+ /** Runtime follow/cinematic override (#196.2). While present it wins over the static `config` for any field it reports non-`undefined`/non-`null`; absent (or an all-`undefined` snapshot) is a no-op. */
14
+ director?: CameraDirector;
9
15
  }
10
- export declare function resolveRigKind(config: GameCameraConfig | undefined): CameraRigKind;
11
- export declare function GameCameraRig({ yawRef, pitchRef, config, onDragChange, pointerControls }: GameCameraRigProps): import("react").JSX.Element;
16
+ export declare function GameCameraRig({ yawRef, pitchRef, config, onDragChange, pointerControls, panKeysEnabled, director, }: GameCameraRigProps): import("react").JSX.Element;
@@ -1,22 +1,28 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useMemo } from "react";
3
- import { ChaseRig, CinematicRig, LockOnRig, ObserverRig, RtsRig, ShoulderRig, TopDownRig, } from "./cameraRigs.js";
2
+ import { useEffect, useMemo, useReducer } from "react";
3
+ import { ChaseRig, CinematicRig, LockOnRig, ObserverRig, RtsRig, ShoulderRig, SideScrollRig, TopDownRig, } from "./cameraRigs.js";
4
4
  import { GameFirstPersonCamera } from "./GameFirstPersonCamera.js";
5
+ import { GameInspectionCamera } from "./GameInspectionCamera.js";
5
6
  import { GameOrbitCamera } from "./GameOrbitCamera.js";
7
+ import { resolveDirectedCamera } from "./rigMath.js";
8
+ import { resolveRigKind, turntableAsObserver } from "./rigResolve.js";
6
9
  import { CameraShakeContext, createCameraShakeChannel } from "./shakeChannel.js";
7
- export function resolveRigKind(config) {
8
- if (config?.rig !== undefined)
9
- return config.rig;
10
- if (config?.perspective === "first")
11
- return "first";
12
- return "orbit";
13
- }
14
- export function GameCameraRig({ yawRef, pitchRef, config, onDragChange, pointerControls }) {
10
+ export { resolveRigKind } from "./rigResolve.js";
11
+ export function GameCameraRig({ yawRef, pitchRef, config, onDragChange, pointerControls, panKeysEnabled, director, }) {
15
12
  const channel = useMemo(() => createCameraShakeChannel(config?.shake?.decayPerSecond), [config?.shake?.decayPerSecond]);
16
- const followEntityId = config?.followEntityId;
13
+ const [, notifyDirectorChange] = useReducer((count) => count + 1, 0);
14
+ useEffect(() => {
15
+ if (director === undefined)
16
+ return undefined;
17
+ return director.subscribe(notifyDirectorChange);
18
+ }, [director]);
19
+ const directed = resolveDirectedCamera(director === undefined
20
+ ? undefined
21
+ : { followEntityId: director.followedEntityId(), cinematic: director.cinematic() }, { followEntityId: config?.followEntityId, cinematic: config?.cinematic });
22
+ const followEntityId = directed.followEntityId;
17
23
  const rig = (() => {
18
- if (config?.cinematic !== undefined) {
19
- return _jsx(CinematicRig, { yawRef: yawRef, pitchRef: pitchRef, config: config, followEntityId: followEntityId });
24
+ if (directed.cinematic !== undefined) {
25
+ return (_jsx(CinematicRig, { yawRef: yawRef, pitchRef: pitchRef, config: { ...config, cinematic: directed.cinematic }, followEntityId: followEntityId, absoluteFov: true }));
20
26
  }
21
27
  const kind = resolveRigKind(config);
22
28
  switch (kind) {
@@ -25,7 +31,7 @@ export function GameCameraRig({ yawRef, pitchRef, config, onDragChange, pointerC
25
31
  case "topDown":
26
32
  return _jsx(TopDownRig, { yawRef: yawRef, pitchRef: pitchRef, config: config, followEntityId: followEntityId });
27
33
  case "rts":
28
- return _jsx(RtsRig, { yawRef: yawRef, pitchRef: pitchRef, config: config, followEntityId: followEntityId });
34
+ return (_jsx(RtsRig, { yawRef: yawRef, pitchRef: pitchRef, config: config, followEntityId: followEntityId, panKeysEnabled: panKeysEnabled }));
29
35
  case "shoulder":
30
36
  return _jsx(ShoulderRig, { yawRef: yawRef, pitchRef: pitchRef, config: config, followEntityId: followEntityId });
31
37
  case "lockOn":
@@ -34,6 +40,14 @@ export function GameCameraRig({ yawRef, pitchRef, config, onDragChange, pointerC
34
40
  return _jsx(ChaseRig, { yawRef: yawRef, pitchRef: pitchRef, config: config, followEntityId: followEntityId });
35
41
  case "observer":
36
42
  return _jsx(ObserverRig, { yawRef: yawRef, pitchRef: pitchRef, config: config, followEntityId: followEntityId });
43
+ case "turntable":
44
+ return (_jsx(ObserverRig, { yawRef: yawRef, pitchRef: pitchRef, config: turntableAsObserver(config), followEntityId: followEntityId }));
45
+ case "sideScroll":
46
+ return _jsx(SideScrollRig, { yawRef: yawRef, pitchRef: pitchRef, config: config, followEntityId: followEntityId });
47
+ case "inspection":
48
+ return _jsx(GameInspectionCamera, { config: config?.inspection });
49
+ case "none":
50
+ return null;
37
51
  default:
38
52
  return (_jsx(GameOrbitCamera, { yawRef: yawRef, pitchRef: pitchRef, config: config, followEntityId: followEntityId ?? undefined, onCameraFollow: config?.onCameraFollow, onDragChange: onDragChange, pointerControls: pointerControls }));
39
53
  }
@@ -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,11 +15,15 @@ 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 = () => {
25
+ if (window.matchMedia?.("(pointer: coarse)").matches)
26
+ return;
22
27
  if (document.pointerLockElement !== domElement)
23
28
  void domElement.requestPointerLock?.();
24
29
  };
@@ -39,9 +44,20 @@ export function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId
39
44
  const entity = ctx.scene.entity.get(followId);
40
45
  if (entity === null)
41
46
  return;
47
+ if (!seededRef.current) {
48
+ seededRef.current = true;
49
+ yawRef.current = entity.rotationY;
50
+ }
42
51
  const cosPitch = Math.cos(pitchRef.current);
43
52
  camera.position.set(entity.position[0], entity.position[1] + eyeHeight, entity.position[2]);
44
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
+ }
45
61
  }, ORBIT_CAMERA_FRAME_PRIORITY);
46
62
  if (config?.viewmodel === false)
47
63
  return null;
@@ -0,0 +1,6 @@
1
+ import { type InspectionCameraConfig } from "./inspectionCameraMath.js";
2
+ export interface GameInspectionCameraProps {
3
+ config?: InspectionCameraConfig;
4
+ }
5
+ /** Model-viewer style rig (#207.7): left-drag orbit, middle/right-drag pan, scroll zoom toward a configurable anchor. Orbits a fixed `target`; never reads player/entity state. */
6
+ export declare function GameInspectionCamera({ config: configPatch }: GameInspectionCameraProps): import("react").JSX.Element;
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { OrbitControls } from "@react-three/drei";
3
+ import { useThree } from "@react-three/fiber";
4
+ import { useEffect, useRef } from "react";
5
+ import { MOUSE } from "three";
6
+ import { resolveInspectionCameraConfig, resolveInspectionZoomToCursor, seedInspectionCamera, } from "./inspectionCameraMath.js";
7
+ /** Model-viewer style rig (#207.7): left-drag orbit, middle/right-drag pan, scroll zoom toward a configurable anchor. Orbits a fixed `target`; never reads player/entity state. */
8
+ export function GameInspectionCamera({ config: configPatch }) {
9
+ const config = resolveInspectionCameraConfig(configPatch);
10
+ const controlsRef = useRef(null);
11
+ const camera = useThree((state) => state.camera);
12
+ const seededRef = useRef(false);
13
+ useEffect(() => {
14
+ if (seededRef.current)
15
+ return;
16
+ const seeded = seedInspectionCamera(config);
17
+ camera.position.set(seeded.camera.x, seeded.camera.y, seeded.camera.z);
18
+ camera.lookAt(seeded.target.x, seeded.target.y, seeded.target.z);
19
+ controlsRef.current?.target.set(seeded.target.x, seeded.target.y, seeded.target.z);
20
+ controlsRef.current?.update();
21
+ seededRef.current = true;
22
+ }, [camera, config]);
23
+ return (_jsx(OrbitControls, { ref: controlsRef, enableDamping: true, dampingFactor: config.dampingFactor, rotateSpeed: config.rotateSpeed, zoomSpeed: config.zoomSpeed, zoomToCursor: resolveInspectionZoomToCursor(config.anchor), enablePan: config.pan, enableRotate: true, enableZoom: true, minDistance: config.minDistance, maxDistance: config.maxDistance, minPolarAngle: config.minPolarAngle, maxPolarAngle: config.maxPolarAngle, screenSpacePanning: true, mouseButtons: { LEFT: MOUSE.ROTATE, MIDDLE: MOUSE.PAN, RIGHT: MOUSE.PAN } }));
24
+ }
@@ -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,9 +8,15 @@ 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
- export declare function RtsRig(props: RigProps): null;
15
+ /** Fixed side-on 2.5D follow rig: watches the followed entity from the perpendicular axis, never reading WASD/mouse-look. */
16
+ export declare function SideScrollRig(props: RigProps): null;
17
+ export declare function RtsRig(props: RigProps & {
18
+ panKeysEnabled?: boolean;
19
+ }): null;
13
20
  export declare function ShoulderRig(props: RigProps): null;
14
21
  export declare function LockOnRig(props: RigProps): null;
15
22
  export declare function ChaseRig(props: RigProps): null;