@jgengine/shell 0.18.0 → 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 (148) hide show
  1. package/CHANGELOG.md +265 -2
  2. package/dist/GamePlayerShell.js +27 -4
  3. package/dist/JoinGate.d.ts +17 -0
  4. package/dist/JoinGate.js +14 -0
  5. package/dist/Shell3dPresentation.d.ts +9 -8
  6. package/dist/Shell3dPresentation.js +57 -8
  7. package/dist/ShellChrome.d.ts +2 -0
  8. package/dist/ShellChrome.js +6 -0
  9. package/dist/ShellHudPresentation.d.ts +9 -1
  10. package/dist/ShellHudPresentation.js +21 -0
  11. package/dist/TerritoryOverlay.d.ts +21 -0
  12. package/dist/TerritoryOverlay.js +9 -0
  13. package/dist/audio/AudioComponents.js +13 -2
  14. package/dist/audio/audioEngine.d.ts +15 -1
  15. package/dist/audio/audioEngine.js +132 -11
  16. package/dist/audio/audioWire.d.ts +3 -0
  17. package/dist/audio/audioWire.js +7 -1
  18. package/dist/audio/loopParams.d.ts +39 -0
  19. package/dist/audio/loopParams.js +43 -0
  20. package/dist/camera/GameCameraRig.js +1 -1
  21. package/dist/camera/GameFirstPersonCamera.d.ts +4 -2
  22. package/dist/camera/GameFirstPersonCamera.js +52 -15
  23. package/dist/camera/Viewports.d.ts +34 -0
  24. package/dist/camera/Viewports.js +123 -0
  25. package/dist/camera/cameraRigs.js +77 -62
  26. package/dist/camera/orbitCameraMath.js +2 -2
  27. package/dist/camera/rigMath.d.ts +82 -4
  28. package/dist/camera/rigMath.js +133 -15
  29. package/dist/defineGame.d.ts +9 -0
  30. package/dist/defineGame.js +38 -6
  31. package/dist/devtools/DevtoolsOverlay.js +2 -0
  32. package/dist/devtools/KeysPanel.js +3 -3
  33. package/dist/devtools/agentBridge.d.ts +6 -0
  34. package/dist/devtools/agentBridge.js +15 -1
  35. package/dist/diagnostics/FpsCounter.d.ts +17 -0
  36. package/dist/diagnostics/FpsCounter.js +32 -0
  37. package/dist/drivers/FrameDriver.d.ts +5 -1
  38. package/dist/drivers/FrameDriver.js +74 -26
  39. package/dist/drivers/FrameRateLimiter.d.ts +9 -0
  40. package/dist/drivers/FrameRateLimiter.js +39 -0
  41. package/dist/drivers/HudOnlyDriver.js +10 -5
  42. package/dist/environment/DayNightSky.js +5 -5
  43. package/dist/environment/Daylight.d.ts +35 -1
  44. package/dist/environment/Daylight.js +98 -28
  45. package/dist/environment/EnvironmentScene.js +1 -1
  46. package/dist/environment/VolumetricClouds.js +20 -2
  47. package/dist/environment/daylightCycle.d.ts +20 -0
  48. package/dist/environment/daylightCycle.js +59 -7
  49. package/dist/environment/sunShadowMath.d.ts +45 -0
  50. package/dist/environment/sunShadowMath.js +50 -0
  51. package/dist/input/gamepadMerge.d.ts +10 -0
  52. package/dist/input/gamepadMerge.js +12 -0
  53. package/dist/input/gamepadPoll.d.ts +47 -0
  54. package/dist/input/gamepadPoll.js +142 -0
  55. package/dist/input/gamepadSource.d.ts +22 -0
  56. package/dist/input/gamepadSource.js +153 -0
  57. package/dist/input/mouseLook.js +2 -1
  58. package/dist/input/padHaptics.d.ts +17 -0
  59. package/dist/input/padHaptics.js +32 -0
  60. package/dist/input/pointerLock.d.ts +11 -0
  61. package/dist/input/pointerLock.js +19 -0
  62. package/dist/materialOverride.d.ts +4 -1
  63. package/dist/materialOverride.js +10 -1
  64. package/dist/postfx/PostProcessing.d.ts +11 -8
  65. package/dist/postfx/PostProcessing.js +38 -16
  66. package/dist/postfx/gradeShader.js +13 -0
  67. package/dist/postfx/stylizePass.d.ts +23 -0
  68. package/dist/postfx/stylizePass.js +216 -0
  69. package/dist/render/CascadedShadows.d.ts +11 -0
  70. package/dist/render/CascadedShadows.js +19 -8
  71. package/dist/render/EnvironmentLighting.d.ts +4 -1
  72. package/dist/render/EnvironmentLighting.js +45 -9
  73. package/dist/render/SceneLighting.d.ts +16 -2
  74. package/dist/render/SceneLighting.js +56 -6
  75. package/dist/render/SceneModels.js +45 -9
  76. package/dist/render/SkinnedInstances.d.ts +91 -0
  77. package/dist/render/SkinnedInstances.js +249 -0
  78. package/dist/render/TextureFiltering.d.ts +16 -0
  79. package/dist/render/TextureFiltering.js +64 -0
  80. package/dist/render/displayColor.d.ts +33 -0
  81. package/dist/render/displayColor.js +138 -0
  82. package/dist/render/modelLoad.d.ts +39 -0
  83. package/dist/render/modelLoad.js +89 -1
  84. package/dist/render/modelRender.d.ts +3 -0
  85. package/dist/render/modelRender.js +6 -2
  86. package/dist/render/resolveModel.js +4 -0
  87. package/dist/render/useFootIk.d.ts +58 -0
  88. package/dist/render/useFootIk.js +216 -0
  89. package/dist/render/useModelAnimation.d.ts +26 -0
  90. package/dist/render/useModelAnimation.js +184 -107
  91. package/dist/scene/AuthoredScene.d.ts +9 -0
  92. package/dist/scene/AuthoredScene.js +17 -1
  93. package/dist/scene/CityRenderer.js +666 -241
  94. package/dist/scene/cityWindowMaterial.d.ts +8 -6
  95. package/dist/scene/cityWindowMaterial.js +80 -21
  96. package/dist/settings/QuickControls.js +1 -1
  97. package/dist/settings/SettingsMenu.js +1 -1
  98. package/dist/settings/appliedSettings.d.ts +10 -2
  99. package/dist/settings/appliedSettings.js +9 -5
  100. package/dist/settings/settingsController.d.ts +3 -0
  101. package/dist/settings/settingsController.js +47 -3
  102. package/dist/structures/BuildingKitBatch.js +26 -22
  103. package/dist/structures/GeneratedBuilding.d.ts +3 -15
  104. package/dist/structures/GeneratedBuilding.js +21 -15
  105. package/dist/structures/buildingSurface.d.ts +21 -0
  106. package/dist/structures/buildingSurface.js +134 -0
  107. package/dist/terrain/terrainDetailMaterial.js +7 -3
  108. package/dist/useShellMultiplayerSync.d.ts +9 -2
  109. package/dist/useShellMultiplayerSync.js +30 -8
  110. package/dist/visibility/CullingProvider.d.ts +2 -1
  111. package/dist/visibility/CullingProvider.js +7 -2
  112. package/dist/world/GridWorldScene.js +4 -0
  113. package/dist/world/TileLayerRenderer.d.ts +9 -0
  114. package/dist/world/TileLayerRenderer.js +64 -0
  115. package/dist/world/WorldParticles.d.ts +31 -0
  116. package/dist/world/WorldParticles.js +128 -0
  117. package/dist/world/WorldScene.d.ts +2 -1
  118. package/dist/world/WorldScene.js +46 -4
  119. package/dist/world/entityPose.d.ts +4 -0
  120. package/dist/world/entityPose.js +9 -0
  121. package/dist/world/tileLayerInstances.d.ts +11 -0
  122. package/dist/world/tileLayerInstances.js +28 -0
  123. package/dist/worldSync.d.ts +5 -1
  124. package/dist/worldSync.js +25 -4
  125. package/package.json +4 -4
  126. package/skills/ce-handoff/SKILL.md +2 -2
  127. package/skills/game-design/SKILL.md +11 -1
  128. package/skills/jgengine/SKILL.md +9 -5
  129. package/skills/jgengine/api.md +205 -101
  130. package/skills/jgengine/capabilities.md +56 -0
  131. package/skills/jgengine/recipes/minimal-game.md +3 -0
  132. package/skills/jgengine-ui/SKILL.md +20 -15
  133. package/skills/jgengine-ui/api.md +538 -326
  134. package/skills/jgengine-ui/capabilities.md +37 -0
  135. package/skills/jgengine-ui/reference-primitives.md +89 -0
  136. package/skills/jgengine-ui/reference.md +2 -78
  137. package/skills/jgengine-world/SKILL.md +21 -3
  138. package/skills/jgengine-world/api.md +1172 -625
  139. package/skills/jgengine-world/capabilities.md +229 -3
  140. package/skills/jgengine-world/recipes/character-animation.md +39 -0
  141. package/skills/jgengine-world/recipes/controls.md +44 -0
  142. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  143. package/skills/jgengine-world/recipes/npc-senses.md +43 -0
  144. package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
  145. package/skills/jgengine-world/reference.md +31 -2
  146. package/skills/jgengine-world/visibility.md +112 -0
  147. package/skills/level-design/SKILL.md +1 -1
  148. package/skills/jgengine-ui/references/visual-scorecard.md +0 -73
@@ -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,6 +3,8 @@ 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, skyFromDocument } from "@jgengine/core/editor/environment";
7
+ import { resolveGameLook } from "@jgengine/core/render/lookPreset";
6
8
  import { EnvironmentScene } from "./environment.js";
7
9
  import { terrainGroundColorSampler } from "./environment/terrainGroundColor.js";
8
10
  import { AuthoredScene } from "./scene/AuthoredScene.js";
@@ -12,6 +14,23 @@ function worldBackdrop(feature) {
12
14
  return _jsx(EnvironmentScene, { feature: feature });
13
15
  };
14
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
+ }
31
+ function isEnvironmentSource(value) {
32
+ return typeof value === "object" && value !== null && "kind" in value && ["gradient", "hdri", "cube"].includes(value.kind);
33
+ }
15
34
  function authoredSceneOverlay(document, placement, scatterModels, assets, world, Vfx) {
16
35
  const terrain = world !== undefined && world.kind === "environment" ? world.terrain : undefined;
17
36
  return function AuthoredSceneOverlay(props) {
@@ -28,12 +47,19 @@ const emptyUi = () => null;
28
47
  * @capability define-game single public game-authoring path — compose systems, world, presentation, and authored scene in one definition
29
48
  */
30
49
  export function defineGame(config) {
31
- 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, backdrop, postProcessing, shadows, 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;
32
51
  const game = defineEngineGame({
33
52
  ...engineFields,
34
53
  multiplayer: multiplayer ?? offline(),
35
54
  loop,
36
55
  });
56
+ const resolvedLook = resolveGameLook({
57
+ look,
58
+ lighting: authoredLighting,
59
+ backdrop: withDocumentSky(backdrop, editorLayers),
60
+ postProcessing,
61
+ hasWorldSky: editorLayers?.environment?.preset !== undefined,
62
+ });
37
63
  function withPhaseSync(inner) {
38
64
  return (ctx, ...args) => {
39
65
  inner?.(ctx, ...args);
@@ -53,8 +79,9 @@ export function defineGame(config) {
53
79
  onDispose: composed?.onDispose?.bind(composed) ?? noop,
54
80
  },
55
81
  GameUI: GameUI ?? emptyUi,
56
- environment: environment ??
57
- (game.world?.kind === "environment" ? worldBackdrop(game.world) : undefined),
82
+ environment: isEnvironmentSource(environment) ? undefined :
83
+ environment ??
84
+ (game.world?.kind === "environment" ? worldBackdrop(game.world) : undefined),
58
85
  camera: camera ?? { perspective: "third" },
59
86
  editorLayers,
60
87
  editorCatalogs,
@@ -71,6 +98,9 @@ export function defineGame(config) {
71
98
  prompts,
72
99
  pointer,
73
100
  touch,
101
+ gamepad,
102
+ localPlayers,
103
+ viewports,
74
104
  orientation,
75
105
  worldHealthBars,
76
106
  nameplates,
@@ -81,10 +111,12 @@ export function defineGame(config) {
81
111
  worldItem,
82
112
  collision,
83
113
  movement,
84
- lighting,
85
- backdrop,
86
- postProcessing,
114
+ lighting: resolvedLook.lighting ?? (editorLayers === undefined ? undefined : lightingFromDocument(editorLayers) ?? (editorLayers.environment?.pointLights === undefined ? undefined : { point: editorLayers.environment.pointLights })),
115
+ environmentSource: environmentSource ?? (isEnvironmentSource(environment) ? environment : undefined),
116
+ backdrop: resolvedLook.backdrop,
117
+ postProcessing: resolvedLook.postProcessing,
87
118
  shadows,
119
+ graphics,
88
120
  presentation,
89
121
  objectStyles,
90
122
  devtools,
@@ -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)
@@ -2,15 +2,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { bindingLabel } from "@jgengine/core/input/actionBindings";
3
3
  function flattenCodes(codes) {
4
4
  if (Array.isArray(codes)) {
5
- return [{ codes: codes.map(bindingLabel).join(" / "), mode: "press" }];
5
+ return [{ codes: codes.map((code) => bindingLabel(code)).join(" / "), mode: "press" }];
6
6
  }
7
7
  const modes = codes;
8
8
  const result = [];
9
9
  if (modes.hold !== undefined && modes.hold.length > 0) {
10
- result.push({ codes: modes.hold.map(bindingLabel).join(" / "), mode: "hold" });
10
+ result.push({ codes: modes.hold.map((code) => bindingLabel(code)).join(" / "), mode: "hold" });
11
11
  }
12
12
  if (modes.toggle !== undefined && modes.toggle.length > 0) {
13
- result.push({ codes: modes.toggle.map(bindingLabel).join(" / "), mode: "toggle" });
13
+ result.push({ codes: modes.toggle.map((code) => bindingLabel(code)).join(" / "), mode: "toggle" });
14
14
  }
15
15
  return result;
16
16
  }
@@ -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;
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useFrame } from "@react-three/fiber";
3
+ import { useEffect, useRef } from "react";
4
+ /** Counts rendered frames. Mount inside the Canvas. @internal */
5
+ export function FpsProbe({ tally }) {
6
+ useFrame(() => {
7
+ tally.frames += 1;
8
+ });
9
+ return null;
10
+ }
11
+ /**
12
+ * Player-facing frames-per-second readout, sampled twice a second from rendered frames (not
13
+ * display refreshes), so it reflects the frame-rate limit and GPU load.
14
+ * @internal shell-internal; shown by the `graphics.showFps` setting.
15
+ */
16
+ export function FpsCounter({ tally }) {
17
+ const label = useRef(null);
18
+ useEffect(() => {
19
+ let last = performance.now();
20
+ tally.frames = 0;
21
+ const timer = setInterval(() => {
22
+ const now = performance.now();
23
+ const fps = (tally.frames * 1000) / Math.max(1, now - last);
24
+ tally.frames = 0;
25
+ last = now;
26
+ if (label.current !== null)
27
+ label.current.textContent = `${fps < 10 ? fps.toFixed(1) : Math.round(fps)} FPS`;
28
+ }, 500);
29
+ return () => clearInterval(timer);
30
+ }, [tally]);
31
+ return (_jsx("div", { className: "pointer-events-none absolute bottom-1 right-1 z-50 rounded bg-black/60 px-2 py-0.5 font-mono text-xs tabular-nums text-emerald-300", children: _jsx("span", { ref: label, children: "-- FPS" }) }));
32
+ }
@@ -4,8 +4,9 @@ import type { ActionStateTracker } from "@jgengine/core/input/actionBindings";
4
4
  import type { PointerService } from "../pointer/pointerService.js";
5
5
  import type { ShellMultiplayer } from "../multiplayer.js";
6
6
  import type { PlayableGame } from "../registry.js";
7
+ import type { AuthoritativeFrameHandler } from "../worldSync.js";
7
8
  export declare const POSTER_SETTLE_SECONDS = 1.6;
8
- export declare function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef, pointerAxisRef, analogRef, gateRef, onRuntimeError, multiplayer, serverIdRef, pointerService, pointerAim, pingCommand, poster, onPosterSettled, }: {
9
+ export declare function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryClickRef, pointerAxisRef, analogRef, gateRef, onRuntimeError, multiplayer, serverIdRef, pointerService, pointerAim, pingCommand, poster, onPosterSettled, authoritativeFrameRef, }: {
9
10
  ctx: GameContext;
10
11
  playable: PlayableGame;
11
12
  tracker: ActionStateTracker<string>;
@@ -37,4 +38,7 @@ export declare function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef,
37
38
  pingCommand: string | undefined;
38
39
  poster: boolean;
39
40
  onPosterSettled: () => void;
41
+ authoritativeFrameRef: {
42
+ current: AuthoritativeFrameHandler | null;
43
+ };
40
44
  }): null;