@jgengine/core 0.19.0 → 0.22.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 (266) hide show
  1. package/CHANGELOG.md +364 -0
  2. package/dist/ai/decisionGraph.d.ts +2 -0
  3. package/dist/ai/decisionGraph.js +6 -0
  4. package/dist/ai/flock.d.ts +1 -1
  5. package/dist/ai/flock.js +7 -4
  6. package/dist/anim/animGraph.d.ts +33 -2
  7. package/dist/anim/animGraph.js +200 -12
  8. package/dist/anim/locomotionGraph.d.ts +11 -3
  9. package/dist/anim/locomotionGraph.js +25 -15
  10. package/dist/audio/audioFalloff.d.ts +4 -0
  11. package/dist/audio/voiceAllocator.d.ts +46 -0
  12. package/dist/audio/voiceAllocator.js +76 -0
  13. package/dist/combat/abilityKit.d.ts +19 -0
  14. package/dist/combat/abilityKit.js +40 -4
  15. package/dist/combat/death.d.ts +4 -3
  16. package/dist/combat/death.js +3 -3
  17. package/dist/combat/magazine.d.ts +9 -0
  18. package/dist/combat/magazine.js +24 -2
  19. package/dist/combat/projectiles.d.ts +94 -2
  20. package/dist/combat/projectiles.js +282 -61
  21. package/dist/combat/regenShield.d.ts +14 -0
  22. package/dist/combat/regenShield.js +23 -7
  23. package/dist/crafting/recipeCatalog.d.ts +16 -0
  24. package/dist/crafting/recipeCatalog.js +55 -0
  25. package/dist/economy/auctionBook.d.ts +1 -1
  26. package/dist/economy/auctionBook.js +11 -1
  27. package/dist/economy/listingBook.d.ts +1 -1
  28. package/dist/economy/listingBook.js +11 -1
  29. package/dist/economy/resourceLedger.js +3 -3
  30. package/dist/economy/shopStock.js +7 -6
  31. package/dist/economy/techTree.js +7 -6
  32. package/dist/economy/unlockPoints.js +3 -6
  33. package/dist/editor/commandApply.js +89 -16
  34. package/dist/editor/commands.d.ts +38 -1
  35. package/dist/editor/commands.js +320 -3
  36. package/dist/editor/creatorStorage.d.ts +59 -0
  37. package/dist/editor/creatorStorage.js +164 -0
  38. package/dist/editor/document.d.ts +4 -0
  39. package/dist/editor/document.js +116 -17
  40. package/dist/editor/index.d.ts +3 -0
  41. package/dist/editor/index.js +1 -0
  42. package/dist/editor/liveSync.d.ts +4 -1
  43. package/dist/editor/liveSync.js +26 -10
  44. package/dist/editor/materialAuthoring.d.ts +14 -0
  45. package/dist/editor/materialAuthoring.js +38 -0
  46. package/dist/editor/modelNodeVisibility.d.ts +11 -0
  47. package/dist/editor/modelNodeVisibility.js +29 -0
  48. package/dist/editor/movementCatalog.d.ts +90 -0
  49. package/dist/editor/movementCatalog.js +144 -0
  50. package/dist/editor/simulation.d.ts +60 -0
  51. package/dist/editor/simulation.js +202 -0
  52. package/dist/editor/staticPrefab.d.ts +17 -0
  53. package/dist/editor/staticPrefab.js +51 -0
  54. package/dist/editor/terrainMaterials.d.ts +10 -0
  55. package/dist/editor/terrainMaterials.js +13 -0
  56. package/dist/editor/types.d.ts +11 -0
  57. package/dist/game/cameraConfig.d.ts +3 -1
  58. package/dist/game/clipRoles.d.ts +2 -1
  59. package/dist/game/clipRoles.js +7 -3
  60. package/dist/game/contentValidation.d.ts +69 -0
  61. package/dist/game/contentValidation.js +118 -0
  62. package/dist/game/controlGate.d.ts +8 -0
  63. package/dist/game/controlGate.js +43 -6
  64. package/dist/game/defineGame.d.ts +32 -3
  65. package/dist/game/dialogueGraph.d.ts +20 -0
  66. package/dist/game/dialogueGraph.js +107 -0
  67. package/dist/game/events.d.ts +5 -0
  68. package/dist/game/keyValueStore.d.ts +17 -4
  69. package/dist/game/keyValueStore.js +35 -23
  70. package/dist/game/lootModifiers.d.ts +17 -0
  71. package/dist/game/lootModifiers.js +26 -0
  72. package/dist/game/narrativeAuthoring.d.ts +67 -0
  73. package/dist/game/narrativeAuthoring.js +122 -0
  74. package/dist/game/playableGame.d.ts +52 -20
  75. package/dist/game/quest.d.ts +15 -0
  76. package/dist/game/quest.js +209 -63
  77. package/dist/game/questCatalog.d.ts +50 -0
  78. package/dist/game/questCatalog.js +205 -0
  79. package/dist/game/saveStore.d.ts +6 -5
  80. package/dist/game/saveStore.js +57 -57
  81. package/dist/game/systemRuntime.d.ts +2 -2
  82. package/dist/game/systemRuntime.js +84 -33
  83. package/dist/gameplay.d.ts +3 -0
  84. package/dist/gameplay.js +3 -0
  85. package/dist/interaction/promptRegistry.d.ts +5 -4
  86. package/dist/interaction/promptRegistry.js +8 -2
  87. package/dist/inventory/inventoryModel.js +9 -6
  88. package/dist/inventory/shapedGrid.d.ts +48 -9
  89. package/dist/inventory/shapedGrid.js +69 -10
  90. package/dist/item/durability.js +4 -0
  91. package/dist/item/itemInstanceRegistry.d.ts +32 -1
  92. package/dist/item/itemInstanceRegistry.js +111 -7
  93. package/dist/item/itemgen.d.ts +1 -1
  94. package/dist/item/itemgen.js +1 -1
  95. package/dist/item/modularItem.js +7 -1
  96. package/dist/material/appearanceSignals.d.ts +53 -0
  97. package/dist/material/appearanceSignals.js +47 -0
  98. package/dist/material/hairCards.d.ts +47 -0
  99. package/dist/material/hairCards.js +136 -0
  100. package/dist/material/materialAsset.d.ts +162 -0
  101. package/dist/material/materialAsset.js +382 -0
  102. package/dist/meta/changelog.d.ts +1 -1
  103. package/dist/meta/changelog.js +348 -1
  104. package/dist/movement/characterController.d.ts +5 -2
  105. package/dist/movement/characterController.js +50 -23
  106. package/dist/movement/freeFlight.d.ts +2 -2
  107. package/dist/movement/freeFlight.js +3 -3
  108. package/dist/movement/movementModel.d.ts +24 -1
  109. package/dist/movement/movementModel.js +74 -7
  110. package/dist/movement/playerMovement.d.ts +28 -1
  111. package/dist/movement/playerMovement.js +425 -102
  112. package/dist/movement/poseState.d.ts +2 -0
  113. package/dist/movement/poseState.js +29 -1
  114. package/dist/movement/solidObstacles.d.ts +3 -1
  115. package/dist/movement/solidObstacles.js +8 -5
  116. package/dist/movement/terrainGrade.d.ts +13 -0
  117. package/dist/movement/terrainGrade.js +33 -0
  118. package/dist/movement/voxelController.d.ts +2 -2
  119. package/dist/movement/voxelController.js +3 -3
  120. package/dist/nav/navGrid.d.ts +19 -1
  121. package/dist/nav/navGrid.js +26 -10
  122. package/dist/nav/solidRoute.d.ts +50 -0
  123. package/dist/nav/solidRoute.js +144 -0
  124. package/dist/physics/ballisticSweep.d.ts +10 -3
  125. package/dist/physics/ballisticSweep.js +53 -29
  126. package/dist/physics/forceVolume.d.ts +41 -0
  127. package/dist/physics/forceVolume.js +88 -0
  128. package/dist/physics/kinematicVehicle.d.ts +8 -3
  129. package/dist/physics/kinematicVehicle.js +2 -2
  130. package/dist/physics/physicsWorldBackend.js +19 -3
  131. package/dist/physics/spatialGrid.d.ts +1 -1
  132. package/dist/physics/spatialGrid.js +8 -9
  133. package/dist/physics/vehicleDynamics.d.ts +3 -2
  134. package/dist/physics/vehicleDynamics.js +2 -2
  135. package/dist/physics/vehicleObstacles.d.ts +3 -2
  136. package/dist/physics/vehicleObstacles.js +29 -5
  137. package/dist/runtime/cameraDirector.d.ts +39 -2
  138. package/dist/runtime/cameraDirector.js +162 -0
  139. package/dist/runtime/commandDispatch.d.ts +14 -0
  140. package/dist/runtime/commandDispatch.js +19 -0
  141. package/dist/runtime/context/combat.d.ts +11 -4
  142. package/dist/runtime/context/combat.js +178 -11
  143. package/dist/runtime/context/deathLoot.d.ts +9 -8
  144. package/dist/runtime/context/deathLoot.js +16 -16
  145. package/dist/runtime/context/scene.d.ts +2 -0
  146. package/dist/runtime/context/scene.js +13 -21
  147. package/dist/runtime/descriptors/baseline.js +34 -3
  148. package/dist/runtime/descriptors/features.d.ts +2 -1
  149. package/dist/runtime/descriptors/features.js +18 -0
  150. package/dist/runtime/gameContext.js +79 -13
  151. package/dist/runtime/gameContextTypes.d.ts +14 -0
  152. package/dist/runtime/headlessRunner.d.ts +3 -1
  153. package/dist/runtime/headlessRunner.js +11 -11
  154. package/dist/runtime/hostedGameRunner.d.ts +24 -1
  155. package/dist/runtime/hostedGameRunner.js +156 -10
  156. package/dist/runtime/hostedWorldSession.d.ts +17 -5
  157. package/dist/runtime/hostedWorldSession.js +154 -16
  158. package/dist/runtime/hostedWorldStore.d.ts +2 -2
  159. package/dist/runtime/inputRecorder.d.ts +1 -1
  160. package/dist/runtime/inputRecorder.js +21 -1
  161. package/dist/runtime/inputSnapshot.d.ts +16 -4
  162. package/dist/runtime/inputSnapshot.js +31 -3
  163. package/dist/runtime/runtimeSave.d.ts +10 -6
  164. package/dist/runtime/runtimeSave.js +11 -5
  165. package/dist/runtime/simContext.d.ts +8 -3
  166. package/dist/runtime/simContext.js +7 -3
  167. package/dist/runtime/simSnapshot.js +2 -0
  168. package/dist/runtime/worldReplication.d.ts +2 -0
  169. package/dist/runtime/worldReplication.js +1 -1
  170. package/dist/scene/assetCatalog.d.ts +2 -0
  171. package/dist/scene/behaviorRuntime.d.ts +26 -5
  172. package/dist/scene/behaviorRuntime.js +263 -16
  173. package/dist/scene/behaviors.d.ts +30 -1
  174. package/dist/scene/behaviors.js +29 -0
  175. package/dist/scene/entityStore.d.ts +14 -3
  176. package/dist/scene/entityStore.js +45 -9
  177. package/dist/scene/mount.d.ts +9 -0
  178. package/dist/scene/mount.js +35 -0
  179. package/dist/scene/possession.d.ts +2 -0
  180. package/dist/scene/possession.js +21 -1
  181. package/dist/scene/sceneRaycast.d.ts +7 -0
  182. package/dist/scene/sceneRaycast.js +55 -0
  183. package/dist/scene/spatial.d.ts +20 -1
  184. package/dist/scene/spatial.js +94 -2
  185. package/dist/scene/vehicleSeat.d.ts +36 -2
  186. package/dist/scene/vehicleSeat.js +89 -2
  187. package/dist/stats/accumulatorMeter.d.ts +10 -0
  188. package/dist/stats/accumulatorMeter.js +6 -0
  189. package/dist/stats/eventMeter.d.ts +9 -2
  190. package/dist/stats/eventMeter.js +5 -2
  191. package/dist/store/defineStore.d.ts +3 -1
  192. package/dist/store/defineStore.js +3 -1
  193. package/dist/store/observableKeyedStore.d.ts +1 -1
  194. package/dist/store/observableKeyedStore.js +8 -6
  195. package/dist/time/simClock.d.ts +6 -2
  196. package/dist/time/simClock.js +5 -0
  197. package/dist/vfx/particleDirector.d.ts +58 -6
  198. package/dist/vfx/particleDirector.js +113 -22
  199. package/dist/vfx/particles.d.ts +68 -5
  200. package/dist/vfx/particles.js +284 -32
  201. package/dist/visibility/assetStreaming.d.ts +21 -3
  202. package/dist/visibility/assetStreaming.js +108 -24
  203. package/dist/visibility/settings.d.ts +8 -2
  204. package/dist/visibility/settings.js +3 -0
  205. package/dist/visibility/visibilitySystem.js +1 -0
  206. package/dist/work/expedition.d.ts +110 -0
  207. package/dist/work/expedition.js +195 -0
  208. package/dist/work/staffedStation.d.ts +29 -0
  209. package/dist/work/staffedStation.js +27 -0
  210. package/dist/world/authoredAnimation.d.ts +38 -0
  211. package/dist/world/authoredAnimation.js +99 -0
  212. package/dist/world/authoredObjects.d.ts +24 -4
  213. package/dist/world/authoredObjects.js +43 -16
  214. package/dist/world/authoredSimulation.d.ts +74 -0
  215. package/dist/world/authoredSimulation.js +302 -0
  216. package/dist/world/authoredWeather.d.ts +120 -0
  217. package/dist/world/authoredWeather.js +314 -0
  218. package/dist/world/buildingKit.d.ts +8 -3
  219. package/dist/world/buildingKit.js +21 -8
  220. package/dist/world/environmentForces.d.ts +21 -0
  221. package/dist/world/environmentForces.js +42 -0
  222. package/dist/world/features.d.ts +3 -3
  223. package/dist/world/footprintGrid.d.ts +55 -0
  224. package/dist/world/footprintGrid.js +201 -0
  225. package/dist/world/grassKind.d.ts +10 -0
  226. package/dist/world/grassKind.js +22 -0
  227. package/dist/world/pathTerrain.d.ts +1 -1
  228. package/dist/world/terraform.d.ts +2 -2
  229. package/dist/world/terraform.js +46 -18
  230. package/dist/world/weather.d.ts +17 -0
  231. package/dist/world/weather.js +116 -20
  232. package/dist/world/worldSolids.d.ts +11 -0
  233. package/dist/world/worldSolids.js +112 -1
  234. package/dist/world.d.ts +5 -5
  235. package/dist/world.js +4 -4
  236. package/package.json +1 -1
  237. package/skills/ce-handoff/SKILL.md +1 -1
  238. package/skills/game-design/SKILL.md +2 -0
  239. package/skills/jgengine/SKILL.md +23 -2
  240. package/skills/jgengine/api.md +28 -17
  241. package/skills/jgengine/recipes/minimal-game.md +7 -2
  242. package/skills/jgengine-combat/SKILL.md +8 -2
  243. package/skills/jgengine-combat/api.md +22 -10
  244. package/skills/jgengine-combat/recipes/portable-runtime-state.md +60 -6
  245. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +37 -3
  246. package/skills/jgengine-combat/reference.md +24 -2
  247. package/skills/jgengine-gameplay/SKILL.md +63 -0
  248. package/skills/jgengine-gameplay/api.md +131 -37
  249. package/skills/jgengine-gameplay/capabilities.md +68 -0
  250. package/skills/jgengine-gameplay/recipes/device-save-status.md +46 -0
  251. package/skills/jgengine-gameplay/recipes/expeditions.md +108 -0
  252. package/skills/jgengine-gameplay/recipes/narrative-authoring.md +76 -0
  253. package/skills/jgengine-gameplay/recipes/paged-item-storage.md +101 -0
  254. package/skills/jgengine-gameplay/recipes/quest-authoring.md +63 -0
  255. package/skills/jgengine-gameplay/recipes/shaped-inventory.md +50 -0
  256. package/skills/jgengine-gameplay/reference-content-validation.md +74 -0
  257. package/skills/jgengine-gameplay/reference-systems.md +119 -0
  258. package/skills/jgengine-world/SKILL.md +12 -3
  259. package/skills/jgengine-world/api.md +201 -58
  260. package/skills/jgengine-world/capabilities.md +116 -3
  261. package/skills/jgengine-world/recipes/character-animation.md +31 -8
  262. package/skills/jgengine-world/recipes/controls.md +4 -0
  263. package/skills/jgengine-world/recipes/npc-senses.md +12 -0
  264. package/skills/jgengine-world/recipes/vehicle-feel.md +3 -1
  265. package/skills/jgengine-world/reference.md +167 -5
  266. package/skills/jgengine-world/visibility.md +28 -1
@@ -1,5 +1,46 @@
1
1
  import type { PhysicsBounds, PhysicsWorld } from "./physicsWorld.js";
2
2
  export type ForceMode = "impulse" | "velocity" | "accelerate";
3
+ /** World-space force position, axis or acceleration vector. */
4
+ export type ForceVector = readonly [number, number, number];
5
+ /** Renderer-free localized acceleration shared by physical actors and cosmetic particles. */
6
+ export interface ForceFieldConfig {
7
+ center: ForceVector;
8
+ shape: {
9
+ kind: "sphere";
10
+ radius: number;
11
+ } | {
12
+ kind: "box";
13
+ halfExtents: ForceVector;
14
+ };
15
+ /** Positive attracts toward the center; negative repels. Units/s². */
16
+ strength: number;
17
+ /** Edge-to-center falloff exponent; zero is uniform. Default 1. */
18
+ attenuation?: number;
19
+ /** Blend radial attraction with `direction`, from 0 to 1. */
20
+ directionality?: number;
21
+ /** World-space direction; default [0, 0, -1]. */
22
+ direction?: ForceVector;
23
+ /** Signed tangential acceleration around a world-space axis. */
24
+ vortex?: {
25
+ axis: ForceVector;
26
+ strength: number;
27
+ /** Independent signed acceleration along the normalized axis, in units/s². */
28
+ lift?: number;
29
+ };
30
+ /** Affected target bits; omission affects every target. */
31
+ mask?: number;
32
+ /** Cap the combined acceleration magnitude. */
33
+ maxAcceleration?: number;
34
+ }
35
+ /** Validate force authoring once before sampling in a hot loop.
36
+ * Coordinates stay within the safe integer range; acceleration magnitudes stay within 1e12 units/s².
37
+ * @capability force-field-validation reject invalid localized force authoring before simulation
38
+ */
39
+ export declare function validateForceField(config: ForceFieldConfig): void;
40
+ /** Sample localized acceleration; `out` permits allocation-free integration.
41
+ * @capability localized-forces sample signed attraction, directional force, vortex spin and axial lift with target masks and caps
42
+ */
43
+ export declare function sampleForceField(config: ForceFieldConfig, position: ForceVector, targetMask?: number, out?: [number, number, number]): [number, number, number];
3
44
  export interface ForceVolumeConfig {
4
45
  /** Region a body's center must be inside to be affected. */
5
46
  bounds: PhysicsBounds;
@@ -1,3 +1,91 @@
1
+ const DEFAULT_FORCE_DIRECTION = [0, 0, -1];
2
+ /** Validate force authoring once before sampling in a hot loop.
3
+ * Coordinates stay within the safe integer range; acceleration magnitudes stay within 1e12 units/s².
4
+ * @capability force-field-validation reject invalid localized force authoring before simulation
5
+ */
6
+ export function validateForceField(config) {
7
+ const vector = (value) => Array.isArray(value) && value.length === 3 && value.every(component => typeof component === "number" && Number.isFinite(component) && Math.abs(component) <= Number.MAX_SAFE_INTEGER);
8
+ const finite = (value, min = -1e12, max = 1e12) => typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
9
+ if (config === null || typeof config !== "object" || !vector(config.center) || !finite(config.strength))
10
+ throw new RangeError("Force field requires safe finite coordinates and strength in -1e12..1e12");
11
+ if (config.shape?.kind === "sphere") {
12
+ if (!finite(config.shape.radius, Number.MIN_VALUE))
13
+ throw new RangeError("Force sphere radius must be finite and positive");
14
+ }
15
+ else if (config.shape?.kind === "box") {
16
+ if (!vector(config.shape.halfExtents) || !config.shape.halfExtents.every(value => value > 0))
17
+ throw new RangeError("Force box extents must be finite and positive");
18
+ }
19
+ else
20
+ throw new RangeError("Force field requires sphere or box shape");
21
+ if (config.attenuation !== undefined && !finite(config.attenuation, 0))
22
+ throw new RangeError("Force attenuation must be finite and nonnegative");
23
+ if (config.directionality !== undefined && !finite(config.directionality, 0, 1))
24
+ throw new RangeError("Force directionality must be in 0..1");
25
+ if (config.direction !== undefined && (!vector(config.direction) || Math.hypot(...config.direction) === 0))
26
+ throw new RangeError("Force direction must be finite and nonzero");
27
+ if (config.vortex !== undefined && (!vector(config.vortex.axis) || Math.hypot(...config.vortex.axis) === 0 || !finite(config.vortex.strength)))
28
+ throw new RangeError("Force vortex requires a finite nonzero axis and finite strength");
29
+ if (config.vortex?.lift !== undefined && !finite(config.vortex.lift))
30
+ throw new RangeError("Force vortex lift must be finite");
31
+ if (config.mask !== undefined && (!finite(config.mask, 0, 0xffffffff) || !Number.isInteger(config.mask)))
32
+ throw new RangeError("Force mask must be an unsigned 32-bit integer");
33
+ if (config.maxAcceleration !== undefined && !finite(config.maxAcceleration, 0))
34
+ throw new RangeError("Force acceleration cap must be finite and nonnegative");
35
+ }
36
+ /** Sample localized acceleration; `out` permits allocation-free integration.
37
+ * @capability localized-forces sample signed attraction, directional force, vortex spin and axial lift with target masks and caps
38
+ */
39
+ export function sampleForceField(config, position, targetMask = 0xffffffff, out = [0, 0, 0]) {
40
+ out[0] = out[1] = out[2] = 0;
41
+ if (((config.mask ?? 0xffffffff) & targetMask) === 0)
42
+ return out;
43
+ const x = position[0] - config.center[0];
44
+ const y = position[1] - config.center[1];
45
+ const z = position[2] - config.center[2];
46
+ const distance = Math.hypot(x, y, z);
47
+ const extent = config.shape.kind === "sphere"
48
+ ? distance / config.shape.radius
49
+ : Math.max(Math.abs(x) / config.shape.halfExtents[0], Math.abs(y) / config.shape.halfExtents[1], Math.abs(z) / config.shape.halfExtents[2]);
50
+ if (!Number.isFinite(extent) || extent >= 1)
51
+ return out;
52
+ const envelope = Math.pow(1 - extent, Math.max(0, config.attenuation ?? 1));
53
+ const directional = Math.max(0, Math.min(1, config.directionality ?? 0));
54
+ const direction = config.direction ?? DEFAULT_FORCE_DIRECTION;
55
+ const directionLength = Math.hypot(...direction);
56
+ const radialScale = distance > 1e-9 ? -config.strength * (1 - directional) / distance : 0;
57
+ const directionScale = directionLength > 1e-9 ? config.strength * directional / directionLength : 0;
58
+ out[0] = x * radialScale + direction[0] * directionScale;
59
+ out[1] = y * radialScale + direction[1] * directionScale;
60
+ out[2] = z * radialScale + direction[2] * directionScale;
61
+ if (config.vortex !== undefined) {
62
+ const [ax, ay, az] = config.vortex.axis;
63
+ const axisLength = Math.hypot(ax, ay, az);
64
+ if (axisLength > 1e-9) {
65
+ const liftScale = (config.vortex.lift ?? 0) / axisLength;
66
+ out[0] += ax * liftScale;
67
+ out[1] += ay * liftScale;
68
+ out[2] += az * liftScale;
69
+ }
70
+ const tx = ay * z - az * y;
71
+ const ty = az * x - ax * z;
72
+ const tz = ax * y - ay * x;
73
+ const tangentLength = Math.hypot(tx, ty, tz);
74
+ if (tangentLength > 1e-9) {
75
+ const scale = config.vortex.strength / tangentLength;
76
+ out[0] += tx * scale;
77
+ out[1] += ty * scale;
78
+ out[2] += tz * scale;
79
+ }
80
+ }
81
+ const magnitude = Math.hypot(...out) * envelope;
82
+ const cap = Math.max(0, config.maxAcceleration ?? Number.POSITIVE_INFINITY);
83
+ const scale = magnitude > cap ? envelope * cap / magnitude : envelope;
84
+ out[0] = out[0] === 0 ? 0 : out[0] * scale;
85
+ out[1] = out[1] === 0 ? 0 : out[1] * scale;
86
+ out[2] = out[2] === 0 ? 0 : out[2] * scale;
87
+ return out;
88
+ }
1
89
  function inside(bounds, x, y, z) {
2
90
  return (x >= bounds.min[0] &&
3
91
  x <= bounds.max[0] &&
@@ -130,10 +130,15 @@ export interface KinematicVehicleOptions {
130
130
  /**
131
131
  * Veto or clamp a planar move before it commits — walls, closed gates, arena bounds. Given the
132
132
  * attempted `from`→`to` on the XZ plane, return the destination actually allowed; velocity is
133
- * rederived from the permitted displacement, so motion blocked on an axis stops on that axis
134
- * instead of grinding into the obstacle. Default: no clamp (the move always commits).
133
+ * rederived from the permitted displacement, so motion blocked on an axis stops on that axis.
134
+ * A result may carry `motion`, the permitted XZ displacement in world units before position
135
+ * recovery. Use it when the endpoint also depenetrates the body: recovery must not add velocity.
136
+ * Plain tuple results retain displacement-based velocity. Called once per tick; no purity required.
137
+ * Default: no clamp (the move always commits).
135
138
  */
136
- clampMove?: (from: readonly [number, number], to: readonly [number, number]) => readonly [number, number];
139
+ clampMove?: (from: readonly [number, number], to: readonly [number, number]) => readonly [number, number] & {
140
+ readonly motion?: readonly [number, number];
141
+ };
137
142
  }
138
143
  /**
139
144
  * Per-tick multipliers layered over the base tuning — the transient overrides games apply for one
@@ -236,8 +236,8 @@ export function createKinematicVehicle(initialTuning, options = {}) {
236
236
  const toZ = z + vz * dt;
237
237
  if (clampMove !== undefined && dt > 0) {
238
238
  const allowed = clampMove([x, z], [toX, toZ]);
239
- vx = (allowed[0] - x) / dt;
240
- vz = (allowed[1] - z) / dt;
239
+ vx = (allowed.motion?.[0] ?? allowed[0] - x) / dt;
240
+ vz = (allowed.motion?.[1] ?? allowed[1] - z) / dt;
241
241
  x = allowed[0];
242
242
  z = allowed[1];
243
243
  }
@@ -1,5 +1,10 @@
1
1
  import { IDENTITY_QUAT, isIdentityQuat, shapeHalfExtents, } from "./physicsBackend.js";
2
2
  import { PhysicsWorld } from "./physicsWorld.js";
3
+ /** Exact touching is not overlap; account for the same few-ulp face rounding as shapecast. */
4
+ function axisOverlaps(center, bodyCenter, combinedHalf) {
5
+ const tolerance = Number.EPSILON * 8 * Math.max(1, Math.abs(center), Math.abs(bodyCenter - combinedHalf), Math.abs(bodyCenter + combinedHalf));
6
+ return combinedHalf - Math.abs(bodyCenter - center) > tolerance;
7
+ }
3
8
  const ALL_LAYERS = 0xffffffff;
4
9
  const CAPABILITIES = {
5
10
  rotation: false,
@@ -408,6 +413,17 @@ export function createPhysicsWorldBackend(options) {
408
413
  least === 2 ? -1 : least === 3 ? 1 : 0,
409
414
  least === 4 ? -1 : least === 5 ? 1 : 0,
410
415
  ];
416
+ // Expanding an AABB by a capsule's half-height can round exact floor contact inside
417
+ // by a few ulps. Keep entering contacts and real penetration, but allow departing or
418
+ // tangent motion through this numerical touch so it cannot mask a later wall/ceiling.
419
+ const faceScale = least < 2
420
+ ? Math.max(1, Math.abs(ox), Math.abs(minX), Math.abs(maxX))
421
+ : least < 4
422
+ ? Math.max(1, Math.abs(oy), Math.abs(minY), Math.abs(maxY))
423
+ : Math.max(1, Math.abs(oz), Math.abs(minZ), Math.abs(maxZ));
424
+ const touchTolerance = Number.EPSILON * 8 * faceScale;
425
+ if (motionLen > 0 && pens[least] <= touchTolerance && mx * normal[0] + my * normal[1] + mz * normal[2] >= 0)
426
+ continue;
411
427
  }
412
428
  else {
413
429
  if (!(motionLen > 0))
@@ -437,11 +453,11 @@ export function createPhysicsWorldBackend(options) {
437
453
  if (!bodyMatches(rec, desc.mask, desc.exclude, handle))
438
454
  continue;
439
455
  const i = rec.index;
440
- if (Math.abs(world.posX[i] - desc.position[0]) >= rec.half[0] + half[0])
456
+ if (!axisOverlaps(desc.position[0], world.posX[i], rec.half[0] + half[0]))
441
457
  continue;
442
- if (Math.abs(world.posY[i] - desc.position[1]) >= rec.half[1] + half[1])
458
+ if (!axisOverlaps(desc.position[1], world.posY[i], rec.half[1] + half[1]))
443
459
  continue;
444
- if (Math.abs(world.posZ[i] - desc.position[2]) >= rec.half[2] + half[2])
460
+ if (!axisOverlaps(desc.position[2], world.posZ[i], rec.half[2] + half[2]))
445
461
  continue;
446
462
  out.push(handle);
447
463
  }
@@ -36,6 +36,6 @@ export declare class SpatialGrid {
36
36
  rebuild(count: number, xs: Float32Array, zs: Float32Array): void;
37
37
  /** Fill `out` with every entity within `radius` of (x,z); returns the hit count (capped at `out.length`). */
38
38
  queryCircle(x: number, z: number, radius: number, out: Int32Array): number;
39
- /** Invoke `cb(a, b)` once for each unordered pair within `maxDistance` (mutual separation/overlap). */
39
+ /** Invoke `cb(a, b)` once for each unordered pair within finite, nonnegative `maxDistance`, including distances larger than `cellSize`. */
40
40
  forEachPair(maxDistance: number, cb: (a: number, b: number) => void): void;
41
41
  }
@@ -103,25 +103,24 @@ export class SpatialGrid {
103
103
  }
104
104
  return n;
105
105
  }
106
- /** Invoke `cb(a, b)` once for each unordered pair within `maxDistance` (mutual separation/overlap). */
106
+ /** Invoke `cb(a, b)` once for each unordered pair within finite, nonnegative `maxDistance`, including distances larger than `cellSize`. */
107
107
  forEachPair(maxDistance, cb) {
108
+ if (!Number.isFinite(maxDistance) || maxDistance < 0) {
109
+ throw new RangeError("SpatialGrid: maxDistance must be finite and nonnegative");
110
+ }
108
111
  const xs = this.xs;
109
112
  const zs = this.zs;
110
113
  if (xs === null || zs === null)
111
114
  return;
112
115
  const d2 = maxDistance * maxDistance;
113
116
  const nx = this.nx;
114
- const nz = this.nz;
115
117
  for (let i = 0; i < this.entityCount; i += 1) {
116
- const c = this.cellOfBody[i];
117
- const cz = (c / nx) | 0;
118
- const cx = c - cz * nx;
119
118
  const x = xs[i];
120
119
  const z = zs[i];
121
- const z0 = cz > 0 ? cz - 1 : cz;
122
- const z1 = cz < nz - 1 ? cz + 1 : cz;
123
- const x0 = cx > 0 ? cx - 1 : cx;
124
- const x1 = cx < nx - 1 ? cx + 1 : cx;
120
+ const z0 = this.cellZ(z - maxDistance);
121
+ const z1 = this.cellZ(z + maxDistance);
122
+ const x0 = this.cellX(x - maxDistance);
123
+ const x1 = this.cellX(x + maxDistance);
125
124
  for (let gz = z0; gz <= z1; gz += 1) {
126
125
  const rowBase = gz * nx;
127
126
  for (let gx = x0; gx <= x1; gx += 1) {
@@ -1,4 +1,5 @@
1
1
  import type { AxisInput } from "../input/axisInput.js";
2
+ import type { KinematicVehicleOptions } from "./kinematicVehicle.js";
2
3
  import type { GripCurve } from "./vehicleBody.js";
3
4
  /**
4
5
  * One axle's tire, in physical terms. Lateral force follows a simplified Pacejka curve
@@ -243,8 +244,8 @@ export interface VehicleDynamicsOptions {
243
244
  heading?: number;
244
245
  /** Grip multiplier by world position (gravel, ice, wet paint); default `1`. */
245
246
  surfaceFriction?: (x: number, z: number) => number;
246
- /** Clamp a planar move (walls, bounds); velocity is rederived from the allowed displacement. */
247
- clampMove?: (from: readonly [number, number], to: readonly [number, number]) => readonly [number, number];
247
+ /** Clamp a planar move; optional result `motion` separates permitted displacement from position recovery. Called once per physics substep. */
248
+ clampMove?: KinematicVehicleOptions["clampMove"];
248
249
  /** Terrain height under a point; read per wheel when `suspension` is set (ramps, hills, ledges). Default flat at the spawn height. */
249
250
  groundHeight?: (x: number, z: number) => number;
250
251
  }
@@ -390,8 +390,8 @@ export function createVehicleDynamics(initialTuning, options = {}) {
390
390
  const toZ = state.z + nvz * h;
391
391
  if (clampMove !== undefined && h > 0) {
392
392
  const allowed = clampMove([state.x, state.z], [toX, toZ]);
393
- nvx = (allowed[0] - state.x) / h;
394
- nvz = (allowed[1] - state.z) / h;
393
+ nvx = (allowed.motion?.[0] ?? allowed[0] - state.x) / h;
394
+ nvz = (allowed.motion?.[1] ?? allowed[1] - state.z) / h;
395
395
  state.x = allowed[0];
396
396
  state.z = allowed[1];
397
397
  }
@@ -1,4 +1,5 @@
1
1
  import { type CollisionObstacle } from "../movement/movementModel.js";
2
+ import type { KinematicVehicleOptions } from "./kinematicVehicle.js";
2
3
  /**
3
4
  * A single blocked-move event a car can react to (#1051): crash damage, screen shake, a metal-crunch
4
5
  * cue. Reported once, when a move is first vetoed, then consumed by {@link VehicleObstacleClamp.takeImpact}.
@@ -17,8 +18,8 @@ export interface VehicleImpact {
17
18
  * {@link takeImpact} once per tick for the crash it produced.
18
19
  */
19
20
  export interface VehicleObstacleClamp {
20
- /** Resolve an attempted `from`→`to` XZ move to the destination actually allowed (slide-along). */
21
- clampMove(from: readonly [number, number], to: readonly [number, number]): readonly [number, number];
21
+ /** Resolve an attempted XZ move; recovery endpoints carry permitted world-unit displacement in `motion`. */
22
+ clampMove: NonNullable<KinematicVehicleOptions["clampMove"]>;
22
23
  /** Impact recorded since last take, consumed on read (null when none). Closing speed is the velocity component into the obstacle at block time, units/s. */
23
24
  takeImpact(): VehicleImpact | null;
24
25
  }
@@ -106,11 +106,30 @@ export function createVehicleObstacleClamp(options) {
106
106
  return to;
107
107
  const attemptedX = to[0] - from[0];
108
108
  const attemptedZ = to[1] - from[1];
109
- const step = resolveObstacleStep([from[0], CAR_BODY_MID_Y, from[1]], attemptedX, attemptedZ, vehicleView(obstacles), radius);
110
- const blockedX = attemptedX - step.stepX;
111
- const blockedZ = attemptedZ - step.stepZ;
112
- if (blockedX === 0 && blockedZ === 0)
109
+ const current = [from[0], CAR_BODY_MID_Y, from[1]];
110
+ const solids = vehicleView(obstacles);
111
+ const step = resolveObstacleStep(current, attemptedX, attemptedZ, solids, radius);
112
+ if (attemptedX === step.stepX && attemptedZ === step.stepZ)
113
113
  return to;
114
+ // The resolver's zero-step escape is the same positional recovery included in `step`.
115
+ // Separate it from motion using this tick's already sampled solids, never a second callback.
116
+ // This preserves the resolver's tangential projection, including diagonal/compound contacts.
117
+ const escape = resolveObstacleStep(current, 0, 0, solids, radius);
118
+ const recovering = escape.stepX !== 0 || escape.stepZ !== 0;
119
+ // Recover first, then collide the motor move at that safe origin. Otherwise the resolver
120
+ // excludes the penetrated box and permits driving straight back into it on every tick.
121
+ const motion = recovering
122
+ ? resolveObstacleStep([from[0] + escape.stepX, CAR_BODY_MID_Y, from[1] + escape.stepZ], attemptedX, attemptedZ, solids, radius)
123
+ : step;
124
+ // One bounded recovery pass may meet another overlapping solid. Keep that second escape
125
+ // positional too; overlapping/inconsistent solids never become an unbounded recovery loop.
126
+ const secondaryEscape = recovering
127
+ ? resolveObstacleStep([from[0] + escape.stepX, CAR_BODY_MID_Y, from[1] + escape.stepZ], 0, 0, solids, radius)
128
+ : { stepX: 0, stepZ: 0 };
129
+ const motionX = motion.stepX - secondaryEscape.stepX;
130
+ const motionZ = motion.stepZ - secondaryEscape.stepZ;
131
+ const blockedX = attemptedX - motionX;
132
+ const blockedZ = attemptedZ - motionZ;
114
133
  const blockedMag = Math.hypot(blockedX, blockedZ);
115
134
  if (blockedMag > BLOCK_EPSILON) {
116
135
  const dt = options.dt();
@@ -123,7 +142,12 @@ export function createVehicleObstacleClamp(options) {
123
142
  };
124
143
  }
125
144
  }
126
- return [from[0] + step.stepX, from[1] + step.stepZ];
145
+ const result = [from[0] + escape.stepX + motion.stepX, from[1] + escape.stepZ + motion.stepZ];
146
+ if (recovering) {
147
+ // Per-result tuples stay stable if a host retains a prior result for diagnostics.
148
+ return Object.assign(result, { motion: [motionX, motionZ] });
149
+ }
150
+ return result;
127
151
  },
128
152
  takeImpact() {
129
153
  const impact = pending;
@@ -1,6 +1,32 @@
1
- import type { ChaseCameraConfig, ChaseView, CinematicCameraConfig } from "../game/playableGame.js";
1
+ import type { CameraRigKind, ChaseCameraConfig, ChaseView, CinematicCameraConfig, GameCameraConfig } from "../game/playableGame.js";
2
+ import type { GameContext } from "./gameContext.js";
3
+ /** Data overlay for an existing rig; callbacks and Canvas projection/preferences remain configured by the game. */
4
+ export type CameraRigConfig = Omit<GameCameraConfig, "rig" | "projection" | "pixelPerfect" | "frustum" | "playerFov" | "weapon" | "onCameraFollow">;
5
+ /** Runtime rig selection and detached data overlay over the configured camera. */
6
+ export interface CameraRigOverride {
7
+ kind: CameraRigKind;
8
+ config: CameraRigConfig;
9
+ }
10
+ /** Camera policy state before the JSON-safe snapshot number encoding. */
11
+ export interface CameraDirectorState {
12
+ followEntityId?: string | null;
13
+ rig: CameraRigOverride | null;
14
+ cinematic: CinematicCameraConfig | null;
15
+ chase: ChaseCameraTuning | null;
16
+ }
17
+ type CameraSerialized<T> = T extends number ? number | {
18
+ $cameraNumber: "Infinity";
19
+ } : T extends object ? {
20
+ [Key in keyof T]: CameraSerialized<T[Key]>;
21
+ } : T;
22
+ /** JSON-safe camera policy; documented instant chase responses use an explicit Infinity marker. */
23
+ export type CameraDirectorSnapshot = CameraSerialized<CameraDirectorState>;
24
+ /** @internal */
25
+ export declare function validateCameraRig(kind: CameraRigKind, config?: CameraRigConfig): void;
26
+ /** @internal */
27
+ export declare function validateChaseCameraTuning(tuning: ChaseCameraTuning | null): void;
2
28
  /** Runtime patch over the static `camera.chase` config — distance/height/fov retuning from gameplay events (#286.11), a whole driving-feel overlay applied only while a vehicle is piloted (#1299), or a `view` switch between chase and seat cameras. */
3
- export type ChaseCameraTuning = Partial<Pick<ChaseCameraConfig, "distance" | "height" | "lookHeight" | "springDamping" | "fov" | "lead" | "bank" | "shakePerSpeed" | "velocityYaw" | "yawResponse" | "distanceBySpeed" | "pitchFollow" | "fovKick" | "lookBackAction" | "collision" | "view">>;
29
+ export type ChaseCameraTuning = Partial<Pick<ChaseCameraConfig, "distance" | "height" | "lookHeight" | "springDamping" | "fov" | "lead" | "bank" | "shakePerSpeed" | "velocityYaw" | "yawResponse" | "headingSource" | "distanceBySpeed" | "pitchFollow" | "fovKick" | "lookBackAction" | "collision" | "view">>;
4
30
  /** Default order {@link nextChaseView} cycles through. */
5
31
  export declare const CHASE_VIEWS: readonly ChaseView[];
6
32
  /**
@@ -15,6 +41,9 @@ export interface CameraDirector {
15
41
  follow(entityId: string | null): void;
16
42
  /** `undefined` means no runtime override — the shell falls back to the static `playable.camera.followEntityId`. `null` means explicitly follow nothing. */
17
43
  followedEntityId(): string | null | undefined;
44
+ /** Replace the runtime rig overlay; `null` restores the game's configured rig/config. Existing chase tuning remains independent. */
45
+ setRig(kind: CameraRigKind | null, config?: CameraRigConfig): void;
46
+ rig(): CameraRigOverride | null;
18
47
  setCinematic(config: CinematicCameraConfig | null): void;
19
48
  cinematic(): CinematicCameraConfig | null;
20
49
  /** Overlay a runtime patch on the chase rig's config — a boss-intro pull-back, drift zoom-out; `null` restores the static config. */
@@ -24,7 +53,15 @@ export interface CameraDirector {
24
53
  kickFov(degrees: number): void;
25
54
  /** Drain the FOV impulses queued since the last call; the shell's chase rig calls this once per frame. */
26
55
  takeFovKick(): number;
56
+ snapshot(): CameraDirectorSnapshot;
57
+ /** Invalid state throws before changing any camera field. */
58
+ restore(state: CameraDirectorSnapshot): void;
59
+ /** Clear runtime policy and transient FOV kicks; configured camera data remains untouched. */
60
+ reset(): void;
27
61
  subscribe(listener: () => void): () => void;
28
62
  }
29
63
  /** @internal */
30
64
  export declare function createCameraDirector(): CameraDirector;
65
+ /** Local camera policy is saved, never broadcast to another player's presentation. @internal */
66
+ export declare function installCameraPersistence(ctx: GameContext): void;
67
+ export {};
@@ -1,3 +1,122 @@
1
+ const RIG_KINDS = ["orbit", "first", "topDown", "rts", "shoulder", "lockOn", "chase", "observer", "turntable", "sideScroll", "inspection", "none"];
2
+ const number = (value) => typeof value === "number" && Number.isFinite(value);
3
+ const response = (value) => number(value) || value === Number.POSITIVE_INFINITY;
4
+ const boolean = (value) => typeof value === "boolean";
5
+ const string = (value) => typeof value === "string";
6
+ const oneOf = (...values) => (value) => typeof value === "string" && values.includes(value);
7
+ const shape = (fields, required = []) => (value) => {
8
+ if (value === null || typeof value !== "object" || Array.isArray(value))
9
+ return false;
10
+ if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
11
+ return false;
12
+ const row = value;
13
+ return required.every((key) => row[key] !== undefined) && Object.entries(row).every(([key, field]) => Object.hasOwn(fields, key) && (field === undefined || fields[key](field)));
14
+ };
15
+ function numbers(...keys) {
16
+ return Object.fromEntries(keys.map((key) => [key, number]));
17
+ }
18
+ const point = shape(numbers("x", "y", "z"));
19
+ const target3d = shape(numbers("x", "y", "z"), ["x", "y", "z"]);
20
+ const topDownFields = {
21
+ ...numbers("height", "pitch", "yaw", "followSmoothing"), targetOffset: point,
22
+ zoom: shape(numbers("min", "max", "speed")),
23
+ };
24
+ const chaseFields = (allowInfinity) => ({
25
+ ...numbers("distance", "height", "lookHeight", "springDamping", "shakePerSpeed"),
26
+ headingSource: oneOf("body", "input"), view: oneOf("chase", "cockpit", "hood", "rear"),
27
+ yawResponse: allowInfinity ? response : number,
28
+ fov: shape({ ...numbers("base", "max", "speedForMax"), response: allowInfinity ? response : number }),
29
+ distanceBySpeed: shape(numbers("extra", "speedForMax"), ["extra"]),
30
+ pitchFollow: shape(numbers("blend", "max", "response")), fovKick: shape(numbers("decay", "max")),
31
+ lookBackAction: string,
32
+ collision: (value) => boolean(value) || shape(numbers("radius", "minDistance"))(value),
33
+ lead: shape(numbers("time", "max"), ["time"]), bank: shape(numbers("perYawRate", "max", "damping"), ["perYawRate"]),
34
+ velocityYaw: shape(numbers("blend", "minSpeed", "response")),
35
+ });
36
+ const cinematic = shape({
37
+ loop: boolean,
38
+ keyframes: (value) => Array.isArray(value) && value.every(shape({
39
+ position: target3d, lookAt: target3d, ...numbers("fov", "duration"), ease: oneOf("linear", "smooth"),
40
+ }, ["position", "lookAt"])),
41
+ }, ["keyframes"]);
42
+ const rigConfig = shape({
43
+ ...numbers("transitionSeconds", "minDistance", "maxDistance", "targetHeight", "initialDistance", "initialHeight", "initialYaw", "initialPitch", "rotateSpeed", "zoomSpeed", "dampingFactor", "targetSmoothing", "dragTargetSmoothing", "distanceSmoothing", "minPolarAngle", "maxPolarAngle"),
44
+ perspective: oneOf("third", "first"), targetOffset: point, followLock: boolean, followEnabled: boolean,
45
+ followEntityId: (value) => value === null || string(value),
46
+ pitchClamp: (value) => Array.isArray(value) && value.length === 2 && value.every(number),
47
+ collision: shape({ enabled: boolean, ...numbers("padding", "minTargetDistance") }),
48
+ firstPerson: shape({ ...numbers("eyeHeight", "sensitivity", "maxPitch"), reticle: boolean, viewmodel: boolean }),
49
+ topDown: shape(topDownFields),
50
+ rts: shape({ ...topDownFields, ...numbers("panSpeed", "rotateSpeed"), pan: boolean,
51
+ edgeScroll: (value) => boolean(value) || shape(numbers("margin", "speed"))(value),
52
+ bounds: shape(numbers("minX", "maxX", "minZ", "maxZ")), start: shape(numbers("x", "z")),
53
+ }),
54
+ shoulder: shape({ ...numbers("shoulderOffset", "heightOffset", "distance", "adsTransitionSpeed", "sensitivity", "fov"),
55
+ side: oneOf("left", "right"), ads: shape(numbers("distance", "shoulderOffset", "fov", "heightOffset")), reticleOffset: shape(numbers("x", "y")),
56
+ }),
57
+ lockOn: shape({ ...numbers("distance", "height", "lookHeight", "framingBias", "yawSmoothing"), targetEntityId: string }),
58
+ chase: shape({ ...chaseFields(true), seatOffsets: shape({ cockpit: point, hood: point, rear: point }) }),
59
+ observer: shape({ ...numbers("distance", "height", "lookHeight", "orbitSpeed", "startAngle", "fov"),
60
+ bind: (value) => shape({ kind: oneOf("entity"), entityId: string }, ["kind", "entityId"])(value) ||
61
+ shape({ kind: oneOf("point"), position: target3d }, ["kind", "position"])(value),
62
+ }),
63
+ turntable: shape({ ...numbers("distance", "height", "lookHeight", "orbitSpeed", "startAngle", "fov"), target: target3d }),
64
+ sideScroll: shape({ ...numbers("distance", "height", "lookHeight", "followSmoothing", "fov"), axis: oneOf("x", "z") }),
65
+ inspection: shape({ ...numbers("initialDistance", "minDistance", "maxDistance", "minPolarAngle", "maxPolarAngle", "rotateSpeed", "zoomSpeed", "dampingFactor"),
66
+ anchor: oneOf("target", "cursor", "center"), target: point, initialPosition: point, pan: boolean,
67
+ }),
68
+ shake: shape(numbers("maxOffset", "maxRoll", "decayPerSecond", "exponent", "frequency")), cinematic,
69
+ });
70
+ /** @internal */
71
+ export function validateCameraRig(kind, config = {}) {
72
+ if (!RIG_KINDS.includes(kind) || !rigConfig(config))
73
+ throw new Error("invalid camera rig override");
74
+ }
75
+ /** @internal */
76
+ export function validateChaseCameraTuning(tuning) {
77
+ if (tuning !== null && !shape(chaseFields(true))(tuning))
78
+ throw new Error("invalid chase camera tuning");
79
+ }
80
+ function reviveNumbers(raw) {
81
+ if (Array.isArray(raw))
82
+ return raw.map(reviveNumbers);
83
+ if (raw === null || typeof raw !== "object")
84
+ return raw;
85
+ if (Object.getPrototypeOf(raw) !== Object.prototype && Object.getPrototypeOf(raw) !== null)
86
+ return raw;
87
+ if (Object.keys(raw).length === 1 && "$cameraNumber" in raw && raw.$cameraNumber === "Infinity")
88
+ return Infinity;
89
+ return Object.fromEntries(Object.entries(raw).map(([key, value]) => [key, reviveNumbers(value)]));
90
+ }
91
+ function decodeCameraState(raw) {
92
+ try {
93
+ raw = reviveNumbers(raw);
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ if (raw === null || typeof raw !== "object")
99
+ return null;
100
+ const state = raw;
101
+ if (state.followEntityId !== undefined && state.followEntityId !== null && typeof state.followEntityId !== "string")
102
+ return null;
103
+ if (!("rig" in state) || !("cinematic" in state) || !("chase" in state))
104
+ return null;
105
+ try {
106
+ if (state.rig !== null) {
107
+ if (!shape({ kind: oneOf(...RIG_KINDS), config: rigConfig }, ["kind", "config"])(state.rig))
108
+ return null;
109
+ }
110
+ if (state.cinematic !== null && !cinematic(state.cinematic))
111
+ return null;
112
+ if (state.chase !== null && !shape(chaseFields(true))(state.chase))
113
+ return null;
114
+ return structuredClone(state);
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ }
1
120
  /** Default order {@link nextChaseView} cycles through. */
2
121
  export const CHASE_VIEWS = ["chase", "hood", "cockpit"];
3
122
  /**
@@ -19,6 +138,7 @@ export function createCameraDirector() {
19
138
  let followEntity = undefined;
20
139
  let cinematicConfig = null;
21
140
  let chaseTuningPatch = null;
141
+ let rigOverride = null;
22
142
  let pendingFovKick = 0;
23
143
  function notify() {
24
144
  for (const listener of listeners)
@@ -30,6 +150,13 @@ export function createCameraDirector() {
30
150
  notify();
31
151
  },
32
152
  followedEntityId: () => followEntity,
153
+ setRig(kind, config = {}) {
154
+ if (kind !== null)
155
+ validateCameraRig(kind, config);
156
+ rigOverride = kind === null ? null : { kind, config: structuredClone(config) };
157
+ notify();
158
+ },
159
+ rig: () => rigOverride,
33
160
  setCinematic(config) {
34
161
  cinematicConfig = config;
35
162
  notify();
@@ -49,9 +176,44 @@ export function createCameraDirector() {
49
176
  pendingFovKick = 0;
50
177
  return kick;
51
178
  },
179
+ snapshot: () => JSON.parse(JSON.stringify({ ...(followEntity === undefined ? {} : { followEntityId: followEntity }), rig: rigOverride, cinematic: cinematicConfig, chase: chaseTuningPatch }, (_key, value) => {
180
+ if (value === Infinity)
181
+ return { $cameraNumber: "Infinity" };
182
+ if (typeof value === "number" && !Number.isFinite(value))
183
+ throw new Error("invalid camera number");
184
+ return value;
185
+ })),
186
+ restore(state) {
187
+ const decoded = decodeCameraState(state);
188
+ if (decoded === null)
189
+ throw new Error("invalid camera director state");
190
+ followEntity = decoded.followEntityId;
191
+ rigOverride = decoded.rig;
192
+ cinematicConfig = decoded.cinematic;
193
+ chaseTuningPatch = decoded.chase;
194
+ pendingFovKick = 0;
195
+ notify();
196
+ },
197
+ reset() {
198
+ followEntity = undefined;
199
+ rigOverride = null;
200
+ cinematicConfig = null;
201
+ chaseTuningPatch = null;
202
+ pendingFovKick = 0;
203
+ notify();
204
+ },
52
205
  subscribe(listener) {
53
206
  listeners.add(listener);
54
207
  return () => listeners.delete(listener);
55
208
  },
56
209
  };
57
210
  }
211
+ /** Local camera policy is saved, never broadcast to another player's presentation. @internal */
212
+ export function installCameraPersistence(ctx) {
213
+ ctx.game.registerSave?.({
214
+ key: "camera",
215
+ snapshot: ctx.camera.snapshot,
216
+ hydrate: ctx.camera.restore,
217
+ decode: decodeCameraState,
218
+ });
219
+ }
@@ -0,0 +1,14 @@
1
+ import type { CommandResult } from "../commands/commandRegistry.js";
2
+ import type { GameContext } from "./gameContext.js";
3
+ import type { LiveGameBackend } from "./transport.js";
4
+ /** A UI command completes locally or after its authoritative host acknowledges it. */
5
+ export type DispatchedCommandResult = CommandResult<GameContext> | Promise<CommandResult<GameContext>>;
6
+ type Route = {
7
+ transport: LiveGameBackend["transport"];
8
+ serverId: () => string | null;
9
+ };
10
+ /** Bind UI command dispatch to the host for this context. A missing join rejects; it never mutates the local replica. */
11
+ export declare function bindCommandTransport(ctx: GameContext, route: Route): () => void;
12
+ /** Dispatch a player's command through the bound host, or locally for an unbound offline context. Await the result before showing success. */
13
+ export declare function dispatchCommand(ctx: GameContext, name: string, input: unknown): DispatchedCommandResult;
14
+ export {};