@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
@@ -5,10 +5,11 @@ import { snapPixelPerfectPosition } from "@jgengine/core/game/pixelPerfect";
5
5
  import { usePlayer } from "@jgengine/react/hooks";
6
6
  import { useGameContext } from "@jgengine/react/provider";
7
7
  import { ORBIT_CAMERA_FRAME_PRIORITY } from "./orbitCameraMath.js";
8
- import { bankRollStep, chaseDesiredPosition, chaseLookAt, cinematicSample, clamp, leadFollowPoint, lockOnPose, observerPose, resolveChase, resolveObserver, resolveShoulder, resolveSideScroll, resolveSideScrollPose, resolveTopDown, rtsPanWorldDir, seatPose, shoulderPose, sideScrollFollowBlend, smoothYaw, speedToFov, springArmStep, topDownPose, velocityYawTarget, } from "./rigMath.js";
8
+ import { cinematicSample, clamp, clampBoomToHit, createChaseRigState, lockOnPose, observerPose, resolveChase, resolveObserver, resolveShoulder, resolveSideScroll, resolveSideScrollPose, resolveTopDown, rtsPanWorldDir, seatPose, shoulderPose, sideScrollFollowBlend, smoothYaw, stepChase, topDownPose, } from "./rigMath.js";
9
9
  import { usePlayerFov } from "./PlayerFov.js";
10
10
  import { useCameraShake } from "./shakeChannel.js";
11
11
  import { applyCameraBlendStep, captureCameraBlendFrom, createCameraBlendScratch, } from "./cameraBlendMath.js";
12
+ import { requestRawPointerLock } from "../input/pointerLock.js";
12
13
  export { applyCameraBlendStep, captureCameraBlendFrom, createCameraBlendScratch, } from "./cameraBlendMath.js";
13
14
  export const CAMERA_RIG_FRAME_PRIORITY = ORBIT_CAMERA_FRAME_PRIORITY;
14
15
  export const CAMERA_POST_FRAME_PRIORITY = ORBIT_CAMERA_FRAME_PRIORITY + 0.5;
@@ -306,7 +307,7 @@ export function ShoulderRig(props) {
306
307
  useEffect(() => {
307
308
  const requestLock = () => {
308
309
  if (document.pointerLockElement !== domElement)
309
- void domElement.requestPointerLock?.();
310
+ requestRawPointerLock(domElement);
310
311
  };
311
312
  const onMove = (event) => {
312
313
  if (document.pointerLockElement !== domElement)
@@ -345,8 +346,13 @@ export function ShoulderRig(props) {
345
346
  useFrame((_, dt) => {
346
347
  const sample = readFollow(ctx, followId);
347
348
  const follow = sample?.pos ?? { x: 0, y: 0, z: 0 };
348
- const adsTarget = aimingRef.current ? 1 : 0;
349
- aimRef.current += (adsTarget - aimRef.current) * (1 - Math.exp(-(config?.adsTransitionSpeed ?? 10) * dt));
349
+ const weapon = followId === null ? null : (props.config?.weapon?.(followId) ?? null);
350
+ if (weapon === null) {
351
+ const adsTarget = aimingRef.current ? 1 : 0;
352
+ aimRef.current += (adsTarget - aimRef.current) * (1 - Math.exp(-(config?.adsTransitionSpeed ?? 10) * dt));
353
+ }
354
+ else
355
+ aimRef.current = weapon.handling.adsProgress;
350
356
  const hip = resolveShoulder(config, false);
351
357
  const ads = resolveShoulder(config, true);
352
358
  const blended = {
@@ -355,7 +361,11 @@ export function ShoulderRig(props) {
355
361
  distance: hip.distance + (ads.distance - hip.distance) * aimRef.current,
356
362
  fov: hip.fov + (ads.fov - hip.fov) * aimRef.current,
357
363
  };
358
- const pose = shoulderPose(follow, props.yawRef.current, props.pitchRef.current, sideRef.current, blended);
364
+ const handling = weapon?.handling;
365
+ const yaw = props.yawRef.current - (handling === undefined ? 0 : handling.aimYaw + handling.cameraYaw);
366
+ // This rig's pitch is camera elevation, so a muzzle climb lowers it.
367
+ const pitch = props.pitchRef.current - (handling === undefined ? 0 : handling.aimPitch + handling.cameraPitch);
368
+ const pose = shoulderPose(follow, yaw, pitch, sideRef.current, blended);
359
369
  commit(pose, dt);
360
370
  }, CAMERA_RIG_FRAME_PRIORITY);
361
371
  return null;
@@ -424,73 +434,71 @@ export function ChaseRig(props) {
424
434
  const staticResolved = useMemo(() => resolveChase(config), [config]);
425
435
  const { camera, commit, beginTransition } = useCameraCommit(props, followId);
426
436
  const shake = useCameraShake();
427
- const posRef = useRef(null);
428
- const lastFollowRef = useRef(null);
429
- const lastYawRef = useRef(null);
430
- const anchorYawRef = useRef(null);
431
- const rollRef = useRef(0);
432
- const view = config?.view ?? "chase";
433
- useEffect(beginTransition, [view]);
437
+ const stateRef = useRef(createChaseRigState());
438
+ const viewRef = useRef(null);
434
439
  useFrame((_, dt) => {
435
440
  const tuning = ctx.camera.chaseTuning();
436
- const resolved = tuning === null ? staticResolved : resolveChase({ ...config, ...tuning });
437
- const sample = readFollow(ctx, followId);
438
- const follow = sample?.pos ?? { x: 0, y: 0, z: 0 };
439
- const yaw = sample?.yaw ?? 0;
440
- const last = lastFollowRef.current ?? follow;
441
- const speed = dt > 0 ? Math.hypot(follow.x - last.x, follow.z - last.z) / dt : 0;
442
- lastFollowRef.current = follow;
443
- const lastYaw = lastYawRef.current ?? yaw;
444
- lastYawRef.current = yaw;
445
- rollRef.current = bankRollStep(rollRef.current, yaw, lastYaw, dt, resolved);
446
- const fov = speedToFov(speed, tuning?.fov ?? config?.fov);
441
+ const merged = tuning === null ? config : { ...config, ...tuning };
442
+ const resolved = tuning === null ? staticResolved : resolveChase(merged);
443
+ const view = merged?.view ?? "chase";
444
+ if (viewRef.current !== view) {
445
+ beginTransition();
446
+ viewRef.current = view;
447
+ stateRef.current.offset = null;
448
+ }
449
+ const entity = followId === null ? null : ctx.scene.entity.get(followId);
450
+ const follow = entity === null ? { x: 0, y: 0, z: 0 } : { x: entity.position[0], y: entity.position[1], z: entity.position[2] };
451
+ const yaw = entity?.rotationY ?? 0;
452
+ const v = entity?.velocity;
453
+ const published = v !== undefined && (v[0] !== 0 || v[1] !== 0 || v[2] !== 0);
454
+ const lookBack = resolved.lookBackAction !== null && ctx.input.isDown(resolved.lookBackAction);
455
+ const clampBoom = resolved.collision
456
+ ? (pivot, desired) => {
457
+ const dx = desired.x - pivot.x;
458
+ const dy = desired.y - pivot.y;
459
+ const dz = desired.z - pivot.z;
460
+ const length = Math.hypot(dx, dy, dz);
461
+ if (length <= 1e-6)
462
+ return desired;
463
+ const hit = ctx.scene.raycast({
464
+ origin: [pivot.x, pivot.y, pivot.z],
465
+ direction: [dx / length, dy / length, dz / length],
466
+ maxDistance: length + resolved.collisionRadius,
467
+ excludeInstanceIds: followId === null ? undefined : [followId],
468
+ filter: { entities: false },
469
+ });
470
+ return clampBoomToHit(pivot, desired, hit?.distance ?? null, resolved.collisionRadius, resolved.collisionMinDistance);
471
+ }
472
+ : undefined;
473
+ const step = stepChase(stateRef.current, {
474
+ follow,
475
+ yaw,
476
+ bodyPitch: entity?.rotationX ?? 0,
477
+ velocity: published ? { x: v[0], y: v[1], z: v[2] } : null,
478
+ lookBack: lookBack && view === "chase",
479
+ fovKick: ctx.camera.takeFovKick(),
480
+ }, resolved, dt, view === "chase" ? clampBoom : undefined);
481
+ props.yawRef.current = step.anchorYaw;
447
482
  if (view !== "chase") {
448
483
  const seat = view === "cockpit"
449
- ? config?.seatOffsets?.cockpit ?? { x: 0, y: 1.2, z: 0.1 }
484
+ ? merged?.seatOffsets?.cockpit ?? { x: 0, y: 1.2, z: 0.1 }
450
485
  : view === "hood"
451
- ? config?.seatOffsets?.hood ?? { x: 0, y: 1, z: 1.4 }
452
- : config?.seatOffsets?.rear ?? { x: 0, y: 1.6, z: -1.5 };
453
- const lookBack = view === "rear";
454
- const pose = seatPose(follow, lookBack ? yaw + Math.PI : yaw, seat, fov);
455
- if (lookBack) {
456
- pose.position = seatPose(follow, yaw, seat, fov).position;
457
- }
486
+ ? merged?.seatOffsets?.hood ?? { x: 0, y: 1, z: 1.4 }
487
+ : merged?.seatOffsets?.rear ?? { x: 0, y: 1.6, z: -1.5 };
488
+ const seatLooksBack = view === "rear" || lookBack;
489
+ const pose = seatPose(follow, seatLooksBack ? yaw + Math.PI : yaw, seat, step.pose.fov);
490
+ if (seatLooksBack)
491
+ pose.position = seatPose(follow, yaw, seat, step.pose.fov).position;
458
492
  props.yawRef.current = yaw;
459
493
  commit(pose, dt);
460
494
  return;
461
495
  }
462
- // Anchor yaw always eases toward its target (#1370): with drift-lag configured the target is
463
- // the velocity-blended yaw at velocityYawResponse, otherwise the raw body facing at
464
- // yawResponse — so a strafe-flipped facing arcs the boom around the character instead of
465
- // teleporting it to the far side. yawResponse: Infinity restores the legacy rigid follow.
466
- let targetYaw = yaw;
467
- let response = resolved.yawResponse;
468
- if (resolved.velocityYawBlend > 0) {
469
- const velocity = dt > 0
470
- ? { x: (follow.x - last.x) / dt, y: 0, z: (follow.z - last.z) / dt }
471
- : { x: 0, y: 0, z: 0 };
472
- targetYaw = velocityYawTarget(yaw, velocity, resolved);
473
- response = resolved.velocityYawResponse;
496
+ if (resolved.shakePerSpeed > 0 && step.speed > 0) {
497
+ shake.shake(Math.min(resolved.shakePerSpeed * step.speed * dt, 0.1));
474
498
  }
475
- const anchorYaw = Number.isFinite(response)
476
- ? smoothYaw(anchorYawRef.current ?? targetYaw, targetYaw, response, dt)
477
- : targetYaw;
478
- anchorYawRef.current = anchorYaw;
479
- // Report camera yaw back to the shell like every other player-facing rig, so
480
- // on-foot movement and aim stay camera-relative instead of frozen at yaw 0.
481
- props.yawRef.current = anchorYaw;
482
- const led = leadFollowPoint(follow, last, dt, resolved);
483
- const desired = chaseDesiredPosition(led, anchorYaw, resolved);
484
- const prev = posRef.current ?? desired;
485
- const smoothed = springArmStep(prev, desired, resolved.springDamping, dt);
486
- posRef.current = smoothed;
487
- if (resolved.shakePerSpeed > 0 && speed > 0) {
488
- shake.shake(Math.min(resolved.shakePerSpeed * speed * dt, 0.1));
489
- }
490
- const pose = { position: smoothed, lookAt: chaseLookAt(led, anchorYaw, resolved), fov };
491
- commit(pose, dt);
492
- if (rollRef.current !== 0)
493
- camera.rotateZ(rollRef.current);
499
+ commit(step.pose, dt);
500
+ if (step.roll !== 0)
501
+ camera.rotateZ(step.roll);
494
502
  }, CAMERA_RIG_FRAME_PRIORITY);
495
503
  return null;
496
504
  }
@@ -17,8 +17,8 @@ export const DEFAULT_ORBIT_CAMERA = {
17
17
  minDistance: 4,
18
18
  maxDistance: 28,
19
19
  targetHeight: 1.2,
20
- initialDistance: 9,
21
- initialHeight: 5.5,
20
+ initialDistance: 7,
21
+ initialHeight: 2.4,
22
22
  followLock: true,
23
23
  followEnabled: true,
24
24
  rotateSpeed: 0.22,
@@ -136,6 +136,23 @@ export interface ResolvedChase {
136
136
  velocityYawResponse: number;
137
137
  /** Exponential smoothing rate the anchor yaw follows the body's facing with (#1370). `Infinity` restores the legacy rigid follow. */
138
138
  yawResponse: number;
139
+ fovBase: number;
140
+ fovMax: number;
141
+ fovSpeedForMax: number;
142
+ /** Exponential FOV smoothing rate; `Infinity` applies the curve unsmoothed. */
143
+ fovResponse: number;
144
+ distanceExtra: number;
145
+ distanceSpeedForMax: number;
146
+ /** Fraction of body pitch the boom follows; 0 keeps a level boom. */
147
+ pitchBlend: number;
148
+ pitchMax: number;
149
+ pitchResponse: number;
150
+ fovKickDecay: number;
151
+ fovKickMax: number;
152
+ lookBackAction: string | null;
153
+ collision: boolean;
154
+ collisionRadius: number;
155
+ collisionMinDistance: number;
139
156
  }
140
157
  /** @internal */
141
158
  export declare function resolveChase(config: ChaseCameraConfig | undefined): ResolvedChase;
@@ -143,6 +160,10 @@ export declare function resolveChase(config: ChaseCameraConfig | undefined): Res
143
160
  * @internal
144
161
  */
145
162
  export declare function leadFollowPoint(follow: Vec3, previous: Vec3, dt: number, resolved: ResolvedChase): Vec3;
163
+ /** Velocity-lead the follow point along a known planar velocity, clamped to `leadMax`.
164
+ * @internal
165
+ */
166
+ export declare function leadFromVelocity(follow: Vec3, velocity: Vec3, resolved: ResolvedChase): Vec3;
146
167
  /** One smoothing step of the chase rig's turn-bank roll (#286.10): roll opposes yaw rate, clamped and exponentially damped.
147
168
  * @internal
148
169
  */
@@ -159,14 +180,71 @@ export declare function resolveObserver(config: ObserverCameraConfig | undefined
159
180
  * @internal
160
181
  */
161
182
  export declare function observerPose(subject: Vec3, angle: number, resolved: ResolvedObserver, fov: number): CameraPose;
162
- /** Desired chase-camera position behind a vehicle facing `yaw` (before spring smoothing).
183
+ /** Desired chase-camera position behind a vehicle facing `yaw` (before spring smoothing). `pitch` (nose-up positive) tilts the boom around the vehicle's right axis.
184
+ * @internal
185
+ */
186
+ export declare function chaseDesiredPosition(follow: Vec3, yaw: number, resolved: ResolvedChase, pitch?: number, distance?: number): Vec3;
187
+ /** Camera offset from the follow point for a boom `distance` back and `height` up, tilted by `pitch` (nose-up positive).
188
+ * @internal
189
+ */
190
+ export declare function chaseBoomOffset(yaw: number, pitch: number, distance: number, height: number): Vec3;
191
+ /** Look point ahead of / above a chased vehicle, tilted by `pitch` like the boom.
163
192
  * @internal
164
193
  */
165
- export declare function chaseDesiredPosition(follow: Vec3, yaw: number, resolved: ResolvedChase): Vec3;
166
- /** Look point ahead of / above a chased vehicle.
194
+ export declare function chaseLookAt(follow: Vec3, yaw: number, resolved: ResolvedChase, pitch?: number): Vec3;
195
+ /** Mutable per-rig chase state; {@link stepChase} advances it in place. */
196
+ export interface ChaseRigState {
197
+ /** Smoothed camera offset from the led follow point; springing the offset, not the world position, keeps the boom length at any constant speed. */
198
+ offset: Vec3 | null;
199
+ anchorYaw: number | null;
200
+ lastYaw: number | null;
201
+ lastFollow: Vec3 | null;
202
+ roll: number;
203
+ pitch: number;
204
+ fov: number | null;
205
+ kick: number;
206
+ lookingBack: boolean;
207
+ }
208
+ /** @internal */
209
+ export declare function createChaseRigState(): ChaseRigState;
210
+ /** What the chase rig reads from its target each frame. */
211
+ export interface ChaseSample {
212
+ follow: Vec3;
213
+ /** Body heading (radians). */
214
+ yaw: number;
215
+ /** Body `rotationX` (three.js convention: positive = nose down). */
216
+ bodyPitch: number;
217
+ /** Sim velocity when the entity publishes one; `null` falls back to the frame-to-frame position delta. */
218
+ velocity: Vec3 | null;
219
+ lookBack: boolean;
220
+ /** FOV impulse (degrees) queued since the last frame. */
221
+ fovKick: number;
222
+ }
223
+ /** One frame of chase output from {@link stepChase}. */
224
+ export interface ChaseStepResult {
225
+ pose: CameraPose;
226
+ /** Camera roll to apply after the pose, radians. */
227
+ roll: number;
228
+ /** Forward chase yaw to report to the shell; unaffected by look-back. */
229
+ anchorYaw: number;
230
+ /** Planar speed used for FOV, distance and shake. */
231
+ speed: number;
232
+ }
233
+ /** Pulls a desired camera position in toward `pivot` when something blocks the boom. */
234
+ export type ChaseBoomClamp = (pivot: Vec3, desired: Vec3) => Vec3;
235
+ /**
236
+ * One frame of the chase rig: speed from sim velocity, smoothed FOV plus decaying kicks, drift-lag
237
+ * anchor yaw, pitch follow, distance by speed, look-back, and a boom spring that eases the offset
238
+ * from the target rather than its world position, so the camera holds its distance at speed.
239
+ * @internal
240
+ */
241
+ export declare function stepChase(state: ChaseRigState, sample: ChaseSample, resolved: ResolvedChase, dt: number, clampBoom?: ChaseBoomClamp): ChaseStepResult;
242
+ /**
243
+ * Where a blocked boom should put the camera: `hitDistance` along the pivot→desired ray, less
244
+ * `radius`, but never nearer the pivot than `minDistance`. `null` hit returns `desired` unchanged.
167
245
  * @internal
168
246
  */
169
- export declare function chaseLookAt(follow: Vec3, yaw: number, resolved: ResolvedChase): Vec3;
247
+ export declare function clampBoomToHit(pivot: Vec3, desired: Vec3, hitDistance: number | null, radius: number, minDistance: number): Vec3;
170
248
  /**
171
249
  * Drift-lag (#1051): the yaw the chase anchor should aim before temporal
172
250
  * smoothing. When the target slides, its planar `velocity` diverges from its
@@ -253,6 +253,9 @@ export function lockOnPose(player, target, config, fov) {
253
253
  const DEFAULT_CHASE_YAW_RESPONSE = 5;
254
254
  /** @internal */
255
255
  export function resolveChase(config) {
256
+ const fovSpeedForMax = config?.fov?.speedForMax ?? 24;
257
+ const collision = config?.collision ?? true;
258
+ const collisionBlock = typeof collision === "object" ? collision : undefined;
256
259
  return {
257
260
  distance: config?.distance ?? 6,
258
261
  height: config?.height ?? 2.6,
@@ -268,16 +271,39 @@ export function resolveChase(config) {
268
271
  velocityYawMinSpeed: config?.velocityYaw?.minSpeed ?? 4,
269
272
  velocityYawResponse: config?.velocityYaw?.response ?? 6,
270
273
  yawResponse: config?.yawResponse ?? DEFAULT_CHASE_YAW_RESPONSE,
274
+ fovBase: config?.fov?.base ?? 55,
275
+ fovMax: config?.fov?.max ?? 78,
276
+ fovSpeedForMax,
277
+ fovResponse: config?.fov?.response ?? 6,
278
+ distanceExtra: config?.distanceBySpeed?.extra ?? 0,
279
+ distanceSpeedForMax: config?.distanceBySpeed?.speedForMax ?? fovSpeedForMax,
280
+ pitchBlend: config?.pitchFollow === undefined ? 0 : config.pitchFollow.blend ?? 1,
281
+ pitchMax: config?.pitchFollow?.max ?? 0.6,
282
+ pitchResponse: config?.pitchFollow?.response ?? 5,
283
+ fovKickDecay: config?.fovKick?.decay ?? 7,
284
+ fovKickMax: config?.fovKick?.max ?? 20,
285
+ lookBackAction: config?.lookBackAction ?? null,
286
+ collision: collision !== false,
287
+ collisionRadius: collisionBlock?.radius ?? 0.3,
288
+ collisionMinDistance: collisionBlock?.minDistance ?? 0.8,
271
289
  };
272
290
  }
273
291
  /** Velocity-lead the follow point (#286.9): aim `leadTime` seconds along the target's frame velocity, clamped to `leadMax`.
274
292
  * @internal
275
293
  */
276
294
  export function leadFollowPoint(follow, previous, dt, resolved) {
277
- if (resolved.leadTime <= 0 || dt <= 0)
295
+ if (dt <= 0)
278
296
  return follow;
279
- let leadX = ((follow.x - previous.x) / dt) * resolved.leadTime;
280
- let leadZ = ((follow.z - previous.z) / dt) * resolved.leadTime;
297
+ return leadFromVelocity(follow, { x: (follow.x - previous.x) / dt, y: 0, z: (follow.z - previous.z) / dt }, resolved);
298
+ }
299
+ /** Velocity-lead the follow point along a known planar velocity, clamped to `leadMax`.
300
+ * @internal
301
+ */
302
+ export function leadFromVelocity(follow, velocity, resolved) {
303
+ if (resolved.leadTime <= 0)
304
+ return follow;
305
+ let leadX = velocity.x * resolved.leadTime;
306
+ let leadZ = velocity.z * resolved.leadTime;
281
307
  const magnitude = Math.hypot(leadX, leadZ);
282
308
  if (magnitude > resolved.leadMax) {
283
309
  leadX *= resolved.leadMax / magnitude;
@@ -327,28 +353,120 @@ export function observerPose(subject, angle, resolved, fov) {
327
353
  fov,
328
354
  };
329
355
  }
330
- /** Desired chase-camera position behind a vehicle facing `yaw` (before spring smoothing).
356
+ /** Desired chase-camera position behind a vehicle facing `yaw` (before spring smoothing). `pitch` (nose-up positive) tilts the boom around the vehicle's right axis.
331
357
  * @internal
332
358
  */
333
- export function chaseDesiredPosition(follow, yaw, resolved) {
334
- const back = forwardVector(yaw);
359
+ export function chaseDesiredPosition(follow, yaw, resolved, pitch = 0, distance = resolved.distance) {
360
+ const offset = chaseBoomOffset(yaw, pitch, distance, resolved.height);
361
+ return { x: follow.x + offset.x, y: follow.y + offset.y, z: follow.z + offset.z };
362
+ }
363
+ /** Camera offset from the follow point for a boom `distance` back and `height` up, tilted by `pitch` (nose-up positive).
364
+ * @internal
365
+ */
366
+ export function chaseBoomOffset(yaw, pitch, distance, height) {
367
+ const forward = forwardVector(yaw);
368
+ const cos = Math.cos(pitch);
369
+ const sin = Math.sin(pitch);
370
+ const along = -distance * cos - height * sin;
371
+ return { x: forward.x * along, y: -distance * sin + height * cos, z: forward.z * along };
372
+ }
373
+ /** Look point ahead of / above a chased vehicle, tilted by `pitch` like the boom.
374
+ * @internal
375
+ */
376
+ export function chaseLookAt(follow, yaw, resolved, pitch = 0) {
377
+ const forward = forwardVector(yaw);
378
+ const cos = Math.cos(pitch);
379
+ const sin = Math.sin(pitch);
380
+ const along = 2 * cos - resolved.lookHeight * sin;
335
381
  return {
336
- x: follow.x - back.x * resolved.distance,
337
- y: follow.y + resolved.height,
338
- z: follow.z - back.z * resolved.distance,
382
+ x: follow.x + forward.x * along,
383
+ y: follow.y + 2 * sin + resolved.lookHeight * cos,
384
+ z: follow.z + forward.z * along,
339
385
  };
340
386
  }
341
- /** Look point ahead of / above a chased vehicle.
387
+ /** @internal */
388
+ export function createChaseRigState() {
389
+ return { offset: null, anchorYaw: null, lastYaw: null, lastFollow: null, roll: 0, pitch: 0, fov: null, kick: 0, lookingBack: false };
390
+ }
391
+ /**
392
+ * One frame of the chase rig: speed from sim velocity, smoothed FOV plus decaying kicks, drift-lag
393
+ * anchor yaw, pitch follow, distance by speed, look-back, and a boom spring that eases the offset
394
+ * from the target rather than its world position, so the camera holds its distance at speed.
342
395
  * @internal
343
396
  */
344
- export function chaseLookAt(follow, yaw, resolved) {
345
- const forward = forwardVector(yaw);
397
+ export function stepChase(state, sample, resolved, dt, clampBoom) {
398
+ const { follow, yaw } = sample;
399
+ const last = state.lastFollow ?? follow;
400
+ const velocity = sample.velocity ?? (dt > 0 ? { x: (follow.x - last.x) / dt, y: 0, z: (follow.z - last.z) / dt } : { x: 0, y: 0, z: 0 });
401
+ state.lastFollow = follow;
402
+ const speed = Math.hypot(velocity.x, velocity.z);
403
+ const lastYaw = state.lastYaw ?? yaw;
404
+ state.lastYaw = yaw;
405
+ state.roll = bankRollStep(state.roll, yaw, lastYaw, dt, resolved);
406
+ const targetFov = speedToFov(speed, { base: resolved.fovBase, max: resolved.fovMax, speedForMax: resolved.fovSpeedForMax });
407
+ state.fov =
408
+ state.fov === null || !Number.isFinite(resolved.fovResponse)
409
+ ? targetFov
410
+ : state.fov + (targetFov - state.fov) * smoothBlend(dt, resolved.fovResponse);
411
+ state.kick = clamp(state.kick * Math.exp(-resolved.fovKickDecay * dt) + sample.fovKick, -resolved.fovKickMax, resolved.fovKickMax);
412
+ let targetYaw = yaw;
413
+ let response = resolved.yawResponse;
414
+ if (resolved.velocityYawBlend > 0) {
415
+ targetYaw = velocityYawTarget(yaw, velocity, resolved);
416
+ response = resolved.velocityYawResponse;
417
+ }
418
+ const anchorYaw = Number.isFinite(response)
419
+ ? smoothYaw(state.anchorYaw ?? targetYaw, targetYaw, response, dt)
420
+ : targetYaw;
421
+ state.anchorYaw = anchorYaw;
422
+ const pitchTarget = clamp(-sample.bodyPitch * resolved.pitchBlend, -resolved.pitchMax, resolved.pitchMax);
423
+ state.pitch =
424
+ resolved.pitchBlend <= 0 ? 0 : state.pitch + (pitchTarget - state.pitch) * smoothBlend(dt, resolved.pitchResponse);
425
+ const snap = state.offset === null || sample.lookBack !== state.lookingBack;
426
+ state.lookingBack = sample.lookBack;
427
+ const boomYaw = sample.lookBack ? anchorYaw + Math.PI : anchorYaw;
428
+ const boomPitch = sample.lookBack ? -state.pitch : state.pitch;
429
+ const extra = resolved.distanceSpeedForMax > 0 ? resolved.distanceExtra * clamp(speed / resolved.distanceSpeedForMax, 0, 1) : 0;
430
+ const led = sample.lookBack ? follow : leadFromVelocity(follow, velocity, resolved);
431
+ const desiredOffset = chaseBoomOffset(boomYaw, boomPitch, resolved.distance + extra, resolved.height);
432
+ let offset = snap || state.offset === null ? desiredOffset : springArmStep(state.offset, desiredOffset, resolved.springDamping, dt);
433
+ if (clampBoom !== undefined) {
434
+ const up = chaseLookAt(led, boomYaw, resolved, boomPitch);
435
+ const pivot = { x: led.x, y: up.y, z: led.z };
436
+ const wanted = { x: led.x + offset.x, y: led.y + offset.y, z: led.z + offset.z };
437
+ const allowed = clampBoom(pivot, wanted);
438
+ offset = { x: allowed.x - led.x, y: allowed.y - led.y, z: allowed.z - led.z };
439
+ }
440
+ state.offset = offset;
346
441
  return {
347
- x: follow.x + forward.x * 2,
348
- y: follow.y + resolved.lookHeight,
349
- z: follow.z + forward.z * 2,
442
+ pose: {
443
+ position: { x: led.x + offset.x, y: led.y + offset.y, z: led.z + offset.z },
444
+ lookAt: chaseLookAt(led, boomYaw, resolved, boomPitch),
445
+ fov: state.fov + state.kick,
446
+ },
447
+ roll: sample.lookBack ? -state.roll : state.roll,
448
+ anchorYaw,
449
+ speed,
350
450
  };
351
451
  }
452
+ /**
453
+ * Where a blocked boom should put the camera: `hitDistance` along the pivot→desired ray, less
454
+ * `radius`, but never nearer the pivot than `minDistance`. `null` hit returns `desired` unchanged.
455
+ * @internal
456
+ */
457
+ export function clampBoomToHit(pivot, desired, hitDistance, radius, minDistance) {
458
+ if (hitDistance === null)
459
+ return desired;
460
+ const dx = desired.x - pivot.x;
461
+ const dy = desired.y - pivot.y;
462
+ const dz = desired.z - pivot.z;
463
+ const length = Math.hypot(dx, dy, dz);
464
+ if (length <= 1e-6)
465
+ return desired;
466
+ const allowed = clamp(hitDistance - radius, Math.min(minDistance, length), length);
467
+ const scale = allowed / length;
468
+ return { x: pivot.x + dx * scale, y: pivot.y + dy * scale, z: pivot.z + dz * scale };
469
+ }
352
470
  /**
353
471
  * Drift-lag (#1051): the yaw the chase anchor should aim before temporal
354
472
  * smoothing. When the target slides, its planar `velocity` diverges from its
@@ -1,5 +1,7 @@
1
1
  import { type ComponentType } from "react";
2
+ import type { EditorDocument } from "@jgengine/core/editor/types";
2
3
  import { type GameDefinitionConfig, type GameLoop } from "@jgengine/core/game/defineGame";
4
+ import type { BackdropConfig } from "@jgengine/core/game/playableGame";
3
5
  import type { GameContext, GameContextContent } from "@jgengine/core/runtime/gameContext";
4
6
  import type { ModelAssetRef } from "@jgengine/core/scene/assetCatalog";
5
7
  import type { ModelConfig } from "@jgengine/core/game/playableGame";
@@ -19,6 +21,13 @@ type PresentationFields = Omit<PlayableGame, "game" | "content" | "loop" | "Game
19
21
  sceneScatterModels?: Record<string, string | ModelConfig>;
20
22
  };
21
23
  export type GameConfig<TAssetRef extends ModelAssetRef = ModelAssetRef> = EngineFields<TAssetRef> & PresentationFields;
24
+ /**
25
+ * The scene document's authored sky fills `backdrop.sky` when the game did not set one, so editor
26
+ * lighting-workspace edits render in `place()` worlds too. A document environment carrying only
27
+ * point lights yields no sky fields and leaves the world's sky alone.
28
+ * @internal
29
+ */
30
+ export declare function withDocumentSky(backdrop: BackdropConfig | undefined, doc: EditorDocument | undefined): BackdropConfig | undefined;
22
31
  /**
23
32
  * The one public authoring entry point: compose engine fields (systems, world, physics, input) and
24
33
  * presentation fields (camera, HUD, audio, authored scene) into a `PlayableGame` ready for `GameHost`.
@@ -3,7 +3,7 @@ import { useMemo } from "react";
3
3
  import { defineGameDefinition as defineEngineGame, } from "@jgengine/core/game/defineGame";
4
4
  import { syncLifecyclePhase } from "@jgengine/core/game/gamePhase";
5
5
  import { offline } from "@jgengine/core/runtime/adapter";
6
- import { lightingFromDocument } from "@jgengine/core/editor/environment";
6
+ import { lightingFromDocument, skyFromDocument } from "@jgengine/core/editor/environment";
7
7
  import { resolveGameLook } from "@jgengine/core/render/lookPreset";
8
8
  import { EnvironmentScene } from "./environment.js";
9
9
  import { terrainGroundColorSampler } from "./environment/terrainGroundColor.js";
@@ -14,6 +14,20 @@ function worldBackdrop(feature) {
14
14
  return _jsx(EnvironmentScene, { feature: feature });
15
15
  };
16
16
  }
17
+ /**
18
+ * The scene document's authored sky fills `backdrop.sky` when the game did not set one, so editor
19
+ * lighting-workspace edits render in `place()` worlds too. A document environment carrying only
20
+ * point lights yields no sky fields and leaves the world's sky alone.
21
+ * @internal
22
+ */
23
+ export function withDocumentSky(backdrop, doc) {
24
+ if (backdrop?.sky !== undefined || doc === undefined)
25
+ return backdrop;
26
+ const sky = skyFromDocument(doc);
27
+ if (sky === undefined || Object.keys(sky).length === 0)
28
+ return backdrop;
29
+ return { ...backdrop, sky };
30
+ }
17
31
  function isEnvironmentSource(value) {
18
32
  return typeof value === "object" && value !== null && "kind" in value && ["gradient", "hdri", "cube"].includes(value.kind);
19
33
  }
@@ -33,7 +47,7 @@ const emptyUi = () => null;
33
47
  * @capability define-game single public game-authoring path — compose systems, world, presentation, and authored scene in one definition
34
48
  */
35
49
  export function defineGame(config) {
36
- const { content, loop, GameUI, environment, camera, multiplayer, editorLayers, editorCatalogs, scenePlacement, sceneScatterModels, WorldOverlay, viewmodel, renderEntity, renderObject, entitySprites, entityModels, objectModels, hotbarSelection, prompts, pointer, touch, orientation, worldHealthBars, nameplates, presentationEffects, audio, entitySounds, objectSounds, worldItem, collision, movement, lighting: authoredLighting, environmentSource, backdrop, look, postProcessing, shadows, graphics, presentation, objectStyles, devtools, capture, platforms, hudFit, ...engineFields } = config;
50
+ const { content, loop, GameUI, environment, camera, multiplayer, editorLayers, editorCatalogs, scenePlacement, sceneScatterModels, WorldOverlay, viewmodel, renderEntity, renderObject, entitySprites, entityModels, objectModels, hotbarSelection, prompts, pointer, touch, gamepad, localPlayers, viewports, orientation, worldHealthBars, nameplates, presentationEffects, audio, entitySounds, objectSounds, worldItem, collision, movement, lighting: authoredLighting, environmentSource, backdrop, look, postProcessing, shadows, graphics, presentation, objectStyles, devtools, capture, platforms, hudFit, ...engineFields } = config;
37
51
  const game = defineEngineGame({
38
52
  ...engineFields,
39
53
  multiplayer: multiplayer ?? offline(),
@@ -42,7 +56,7 @@ export function defineGame(config) {
42
56
  const resolvedLook = resolveGameLook({
43
57
  look,
44
58
  lighting: authoredLighting,
45
- backdrop,
59
+ backdrop: withDocumentSky(backdrop, editorLayers),
46
60
  postProcessing,
47
61
  hasWorldSky: editorLayers?.environment?.preset !== undefined,
48
62
  });
@@ -84,6 +98,9 @@ export function defineGame(config) {
84
98
  prompts,
85
99
  pointer,
86
100
  touch,
101
+ gamepad,
102
+ localPlayers,
103
+ viewports,
87
104
  orientation,
88
105
  worldHealthBars,
89
106
  nameplates,
@@ -5,6 +5,7 @@ import { devtools, instrumentLatency, } from "@jgengine/core/devtools/devtools";
5
5
  import { fallbackSeamsSnapshot } from "@jgengine/core/devtools/fallbackSeams";
6
6
  import { textureErrorsSnapshot } from "@jgengine/core/devtools/textureErrors";
7
7
  import { MOVEMENT_TUNING } from "@jgengine/core/movement/movementModel";
8
+ import { heavyModels } from "../render/modelLoad.js";
8
9
  import { collisionDebug } from "./collisionDebug.js";
9
10
  import { readStoredOverrides } from "./devtoolsOverrides.js";
10
11
  import { diagnose } from "./perfDiagnose.js";
@@ -212,6 +213,7 @@ export function DevtoolsOverlay({ open, ctx, playable, multiplayer, }) {
212
213
  devtools.probes.register("objects", () => ctx.scene.object.list().length),
213
214
  devtools.probes.register("fallbacks", () => fallbackSeamsSnapshot()),
214
215
  devtools.probes.register("textureErrors", () => textureErrorsSnapshot()),
216
+ devtools.probes.register("heavyModels", () => heavyModels()),
215
217
  ];
216
218
  return () => {
217
219
  for (const dispose of disposers)
@@ -15,6 +15,12 @@ type EditorHost = {
15
15
  gameId: string;
16
16
  handle(request: AgentBridgeRequest): unknown;
17
17
  };
18
+ /**
19
+ * @internal
20
+ * Resolves true once `isLive()` reports a mounted editor host, polling every `intervalMs` up to
21
+ * `timeoutMs`. `editor_summon` awaits this so the next RPC verb in a `drive` batch finds the editor.
22
+ */
23
+ export declare function waitForEditorHost(isLive: () => boolean, timeoutMs?: number, intervalMs?: number, sleep?: (ms: number) => Promise<void>): Promise<boolean>;
18
24
  declare global {
19
25
  interface Window {
20
26
  __jgengineAgent?: {
@@ -3,6 +3,20 @@ import { getSaveEndpoint } from "@jgengine/core/devtools/saveEndpoint";
3
3
  import { isHudAnchor, listActiveHudLayouts, } from "@jgengine/core/ui/hudLayout";
4
4
  import { listHudPanelTypes, resolvePanelResize, resizePanelSize, } from "@jgengine/core/ui/hudDocument";
5
5
  import { buildFullReport, buildLeanReport } from "./DevtoolsOverlay.js";
6
+ /**
7
+ * @internal
8
+ * Resolves true once `isLive()` reports a mounted editor host, polling every `intervalMs` up to
9
+ * `timeoutMs`. `editor_summon` awaits this so the next RPC verb in a `drive` batch finds the editor.
10
+ */
11
+ export async function waitForEditorHost(isLive, timeoutMs = 15_000, intervalMs = 100, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
12
+ for (let waited = 0;; waited += intervalMs) {
13
+ if (isLive())
14
+ return true;
15
+ if (waited >= timeoutMs)
16
+ return false;
17
+ await sleep(intervalMs);
18
+ }
19
+ }
6
20
  async function saveScene(editorHost) {
7
21
  const endpoint = getSaveEndpoint();
8
22
  if (endpoint === null)
@@ -239,7 +253,7 @@ export function installAgentBridge(options) {
239
253
  return { ok: false, error: "no editor summoner on this page — open with ?mode=editor" };
240
254
  }
241
255
  window.__jgengineSummonEditor();
242
- return { ok: true, live: false };
256
+ return waitForEditorHost(() => window.__jgengineEditorHost !== undefined).then((live) => live ? { ok: true, live: true } : { ok: false, error: "editor did not mount within 15s" });
243
257
  }
244
258
  case "save_scene": {
245
259
  const editorHost = window.__jgengineEditorHost;
@@ -0,0 +1,17 @@
1
+ import { type ReactElement } from "react";
2
+ /** Mutable frame tally shared between the in-Canvas probe and the DOM readout. */
3
+ export interface FrameTally {
4
+ frames: number;
5
+ }
6
+ /** Counts rendered frames. Mount inside the Canvas. @internal */
7
+ export declare function FpsProbe({ tally }: {
8
+ tally: FrameTally;
9
+ }): null;
10
+ /**
11
+ * Player-facing frames-per-second readout, sampled twice a second from rendered frames (not
12
+ * display refreshes), so it reflects the frame-rate limit and GPU load.
13
+ * @internal shell-internal; shown by the `graphics.showFps` setting.
14
+ */
15
+ export declare function FpsCounter({ tally }: {
16
+ tally: FrameTally;
17
+ }): ReactElement;