@jgengine/core 0.13.0 → 0.15.0-next.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 (223) hide show
  1. package/CHANGELOG.md +529 -1
  2. package/dist/ai/difficulty.d.ts +154 -0
  3. package/dist/ai/difficulty.js +194 -0
  4. package/dist/ai/driver.d.ts +107 -0
  5. package/dist/ai/driver.js +175 -0
  6. package/dist/ai/flock.js +13 -23
  7. package/dist/ai/laneSelect.js +2 -1
  8. package/dist/ai/mobBrain.js +2 -1
  9. package/dist/ai/waveRunner.d.ts +81 -0
  10. package/dist/ai/waveRunner.js +104 -0
  11. package/dist/authoring.d.ts +6 -4
  12. package/dist/authoring.js +6 -4
  13. package/dist/combat/castRunner.d.ts +13 -1
  14. package/dist/combat/castRunner.js +5 -1
  15. package/dist/combat/comboMeter.d.ts +140 -0
  16. package/dist/combat/comboMeter.js +215 -0
  17. package/dist/combat/comboPoints.d.ts +11 -0
  18. package/dist/combat/comboPoints.js +11 -2
  19. package/dist/combat/damageResolution.d.ts +1 -1
  20. package/dist/combat/defensiveWindow.d.ts +5 -0
  21. package/dist/combat/effects.d.ts +24 -0
  22. package/dist/combat/effects.js +6 -1
  23. package/dist/combat/projectiles.d.ts +6 -0
  24. package/dist/combat/projectiles.js +2 -2
  25. package/dist/combat/regenShield.d.ts +40 -2
  26. package/dist/combat/regenShield.js +66 -18
  27. package/dist/combat/resourcePool.d.ts +19 -0
  28. package/dist/combat/resourcePool.js +20 -2
  29. package/dist/combat/shotOrigin.d.ts +5 -0
  30. package/dist/combat/statusApplication.d.ts +1 -1
  31. package/dist/combat/statusApplication.js +2 -1
  32. package/dist/combat/statusEffectView.d.ts +56 -0
  33. package/dist/combat/statusEffectView.js +52 -0
  34. package/dist/combat.d.ts +4 -2
  35. package/dist/combat.js +2 -0
  36. package/dist/devtools/fallbackSeams.d.ts +3 -2
  37. package/dist/devtools/fallbackSeams.js +1 -1
  38. package/dist/devtools/textureErrors.d.ts +40 -0
  39. package/dist/devtools/textureErrors.js +68 -0
  40. package/dist/economy/shopStock.d.ts +110 -0
  41. package/dist/economy/shopStock.js +157 -0
  42. package/dist/economy/wallet.d.ts +6 -0
  43. package/dist/economy/wallet.js +1 -0
  44. package/dist/game/achievements.d.ts +80 -0
  45. package/dist/game/achievements.js +124 -0
  46. package/dist/game/cameraConfig.d.ts +7 -0
  47. package/dist/game/codex.d.ts +60 -0
  48. package/dist/game/codex.js +89 -0
  49. package/dist/game/credits.d.ts +36 -0
  50. package/dist/game/credits.js +0 -0
  51. package/dist/game/defineGame.d.ts +28 -3
  52. package/dist/game/defineGame.js +5 -0
  53. package/dist/game/dialogueGraph.d.ts +116 -0
  54. package/dist/game/dialogueGraph.js +103 -0
  55. package/dist/game/eventTicker.d.ts +95 -0
  56. package/dist/game/eventTicker.js +91 -0
  57. package/dist/game/events.d.ts +24 -5
  58. package/dist/game/events.js +18 -0
  59. package/dist/game/gamePhase.d.ts +1 -1
  60. package/dist/game/gamePhase.js +1 -1
  61. package/dist/game/leaderboardRank.d.ts +71 -0
  62. package/dist/game/leaderboardRank.js +76 -0
  63. package/dist/game/lootTable.d.ts +10 -1
  64. package/dist/game/lootTable.js +4 -2
  65. package/dist/game/notifications.d.ts +57 -0
  66. package/dist/game/notifications.js +107 -0
  67. package/dist/game/playableGame.d.ts +39 -2
  68. package/dist/game/quest.d.ts +26 -0
  69. package/dist/game/quest.js +31 -0
  70. package/dist/game/runDraft.js +2 -1
  71. package/dist/game/saveSlots.d.ts +102 -0
  72. package/dist/game/saveSlots.js +136 -0
  73. package/dist/game/talentTreeView.d.ts +106 -0
  74. package/dist/game/talentTreeView.js +114 -0
  75. package/dist/game/worldItem.js +2 -1
  76. package/dist/gameplay.d.ts +15 -4
  77. package/dist/gameplay.js +13 -2
  78. package/dist/i18n/i18n.d.ts +51 -0
  79. package/dist/i18n/i18n.js +82 -0
  80. package/dist/input/axisInput.d.ts +1 -1
  81. package/dist/input/axisInput.js +18 -2
  82. package/dist/input/rebindSession.d.ts +141 -0
  83. package/dist/input/rebindSession.js +235 -0
  84. package/dist/input/touchControlsMode.d.ts +16 -0
  85. package/dist/input/touchControlsMode.js +23 -0
  86. package/dist/input/touchScheme.d.ts +30 -1
  87. package/dist/input/touchScheme.js +31 -1
  88. package/dist/interaction/promptRegistry.d.ts +78 -0
  89. package/dist/interaction/promptRegistry.js +130 -0
  90. package/dist/inventory/storageTier.js +3 -2
  91. package/dist/meta/changelog.d.ts +1 -1
  92. package/dist/meta/changelog.js +21 -1
  93. package/dist/movement/movementModel.d.ts +26 -3
  94. package/dist/movement/movementModel.js +121 -15
  95. package/dist/movement/playerMovement.js +161 -25
  96. package/dist/physics/drivableVehicle.js +3 -1
  97. package/dist/physics/kinematicVehicle.d.ts +56 -1
  98. package/dist/physics/kinematicVehicle.js +99 -10
  99. package/dist/physics/vehicleObstacles.js +19 -0
  100. package/dist/random/resolveRng.d.ts +14 -0
  101. package/dist/random/resolveRng.js +31 -0
  102. package/dist/render/postProcessing.d.ts +12 -1
  103. package/dist/runtime/cameraDirector.d.ts +2 -2
  104. package/dist/runtime/context/combat.js +8 -0
  105. package/dist/runtime/context/scene.d.ts +2 -0
  106. package/dist/runtime/context/scene.js +48 -1
  107. package/dist/runtime/gameContext.js +8 -2
  108. package/dist/runtime/gameContextTypes.d.ts +39 -0
  109. package/dist/runtime/inputSnapshot.d.ts +10 -1
  110. package/dist/runtime/inputSnapshot.js +13 -1
  111. package/dist/runtime/runtimeSave.d.ts +3 -3
  112. package/dist/runtime/runtimeSave.js +11 -2
  113. package/dist/scene/assetDiagnostics.js +2 -2
  114. package/dist/scene/autoTarget.js +2 -1
  115. package/dist/scene/builtinSceneKinds.d.ts +6 -1
  116. package/dist/scene/builtinSceneKinds.js +10 -3
  117. package/dist/scene/captureCheck.js +3 -2
  118. package/dist/scene/colliders.d.ts +20 -0
  119. package/dist/scene/colliders.js +78 -0
  120. package/dist/scene/collisionMesh.d.ts +8 -0
  121. package/dist/scene/collisionMesh.js +48 -0
  122. package/dist/scene/entityStore.d.ts +4 -1
  123. package/dist/scene/entityStore.js +3 -0
  124. package/dist/scene/objectStore.d.ts +10 -0
  125. package/dist/scene/objectStore.js +29 -6
  126. package/dist/scene/sceneKinds.d.ts +37 -0
  127. package/dist/scene/sceneKinds.js +19 -0
  128. package/dist/scene/sequenceDirector.d.ts +110 -0
  129. package/dist/scene/sequenceDirector.js +146 -0
  130. package/dist/session/roles.js +3 -2
  131. package/dist/settings/settingsModel.d.ts +1 -0
  132. package/dist/settings/settingsModel.js +1 -0
  133. package/dist/stats/rollCheck.js +4 -2
  134. package/dist/time/dayNightCycle.d.ts +114 -0
  135. package/dist/time/dayNightCycle.js +208 -0
  136. package/dist/time/timerSet.d.ts +132 -0
  137. package/dist/time/timerSet.js +243 -0
  138. package/dist/ui/accessibility.d.ts +51 -0
  139. package/dist/ui/accessibility.js +83 -0
  140. package/dist/ui/coachMarks.d.ts +99 -0
  141. package/dist/ui/coachMarks.js +126 -0
  142. package/dist/ui/floatingText.d.ts +114 -0
  143. package/dist/ui/floatingText.js +203 -0
  144. package/dist/ui/hudScale.d.ts +16 -1
  145. package/dist/ui/hudScale.js +22 -1
  146. package/dist/ui/modalStack.d.ts +143 -0
  147. package/dist/ui/modalStack.js +160 -0
  148. package/dist/ui/objectiveBanner.d.ts +123 -0
  149. package/dist/ui/objectiveBanner.js +162 -0
  150. package/dist/ui/photoMode.d.ts +32 -0
  151. package/dist/ui/photoMode.js +51 -0
  152. package/dist/ui/radialMenu.d.ts +47 -0
  153. package/dist/ui/radialMenu.js +74 -0
  154. package/dist/ui/screenMarkers.d.ts +128 -0
  155. package/dist/ui/screenMarkers.js +118 -0
  156. package/dist/ui.d.ts +8 -0
  157. package/dist/ui.js +8 -0
  158. package/dist/vfx/cameraShake.d.ts +97 -0
  159. package/dist/vfx/cameraShake.js +136 -0
  160. package/dist/vfx/damageDirection.d.ts +102 -0
  161. package/dist/vfx/damageDirection.js +188 -0
  162. package/dist/vfx/particles.d.ts +103 -0
  163. package/dist/vfx/particles.js +256 -0
  164. package/dist/vfx/screenEffects.d.ts +149 -0
  165. package/dist/vfx/screenEffects.js +191 -0
  166. package/dist/world/authoredObjects.d.ts +18 -0
  167. package/dist/world/authoredObjects.js +21 -0
  168. package/dist/world/authoredSpawn.js +19 -0
  169. package/dist/world/buildingLots.d.ts +54 -11
  170. package/dist/world/buildingLots.js +116 -30
  171. package/dist/world/cityBlocks.d.ts +45 -0
  172. package/dist/world/cityBlocks.js +159 -0
  173. package/dist/world/cityContent.d.ts +46 -2
  174. package/dist/world/cityContent.js +160 -2
  175. package/dist/world/cityGenerator.d.ts +261 -23
  176. package/dist/world/cityGenerator.js +643 -33
  177. package/dist/world/cityGeometry.d.ts +20 -0
  178. package/dist/world/cityGeometry.js +91 -0
  179. package/dist/world/cityKind.js +9 -46
  180. package/dist/world/fastTravel.d.ts +64 -0
  181. package/dist/world/fastTravel.js +108 -0
  182. package/dist/world/features.d.ts +16 -0
  183. package/dist/world/features.js +2 -0
  184. package/dist/world/grassKind.js +4 -1
  185. package/dist/world/grounded.d.ts +11 -0
  186. package/dist/world/grounded.js +12 -0
  187. package/dist/world/mapAnnotations.d.ts +75 -0
  188. package/dist/world/mapAnnotations.js +139 -0
  189. package/dist/world/markers.js +1 -0
  190. package/dist/world/resourceNode.d.ts +1 -1
  191. package/dist/world/resourceNode.js +2 -1
  192. package/dist/world/roads.d.ts +294 -5
  193. package/dist/world/roads.js +1262 -21
  194. package/dist/world/scatterCoverage.d.ts +98 -0
  195. package/dist/world/scatterCoverage.js +101 -0
  196. package/dist/world/scatterRegion.js +6 -0
  197. package/dist/world/sharedAnnotations.d.ts +84 -0
  198. package/dist/world/sharedAnnotations.js +76 -0
  199. package/dist/world/spawnOverride.d.ts +37 -0
  200. package/dist/world/spawnOverride.js +50 -0
  201. package/dist/world/streetGenerator.d.ts +60 -11
  202. package/dist/world/streetGenerator.js +1786 -129
  203. package/dist/world/terrain.d.ts +1 -0
  204. package/dist/world/terrain.js +2 -0
  205. package/dist/world/waypoints.d.ts +63 -0
  206. package/dist/world/waypoints.js +137 -0
  207. package/dist/world.d.ts +18 -2
  208. package/dist/world.js +18 -2
  209. package/package.json +2 -2
  210. package/skills/ce-handoff/SKILL.md +149 -0
  211. package/skills/game-design/SKILL.md +2 -0
  212. package/skills/jgengine/SKILL.md +7 -3
  213. package/skills/jgengine/api.md +17 -7
  214. package/skills/jgengine/capabilities.md +12 -0
  215. package/skills/jgengine/recipes/minimal-game.md +7 -4
  216. package/skills/jgengine-combat/api.md +40 -4
  217. package/skills/jgengine-combat/capabilities.md +12 -0
  218. package/skills/jgengine-combat/reference.md +2 -0
  219. package/skills/jgengine-gameplay/api.md +212 -11
  220. package/skills/jgengine-gameplay/capabilities.md +66 -1
  221. package/skills/jgengine-world/api.md +408 -12
  222. package/skills/jgengine-world/capabilities.md +127 -2
  223. package/skills/jgengine-world/reference.md +15 -1
@@ -1,9 +1,9 @@
1
1
  import { steerYaw } from "../movement/steering.js";
2
2
  import { DEFAULT_GRIP_CURVE, sampleGripCurve } from "./vehicleBody.js";
3
- export function createKinematicVehicle(tuning, options = {}) {
4
- const grip = tuning.grip ?? DEFAULT_GRIP_CURVE;
5
- const chassis = tuning.chassis;
6
- const rollingResistance = tuning.rollingResistance ?? 0;
3
+ export function createKinematicVehicle(initialTuning, options = {}) {
4
+ // Mutable so `retune` can swap the whole block without disturbing the integrator's state; every
5
+ // read below is per-tick rather than captured at construction for the same reason.
6
+ let tuning = initialTuning;
7
7
  const surfaceFriction = options.surfaceFriction ?? (() => 1);
8
8
  const dragAt = options.dragAt ?? (() => 0);
9
9
  const clampMove = options.clampMove;
@@ -21,11 +21,17 @@ export function createKinematicVehicle(tuning, options = {}) {
21
21
  let previousForwardSpeed = 0;
22
22
  let bodyPitch = 0;
23
23
  let bodyRoll = 0;
24
+ let airOffset = 0;
25
+ let verticalVelocity = 0;
24
26
  function forward() {
25
27
  return [Math.sin(heading), Math.cos(heading)];
26
28
  }
27
29
  return {
28
30
  tick(dt, axis, modifiers) {
31
+ const grip = tuning.grip ?? DEFAULT_GRIP_CURVE;
32
+ const chassis = tuning.chassis;
33
+ const rollingResistance = tuning.rollingResistance ?? 0;
34
+ const hopGravity = tuning.hopGravity ?? 0;
29
35
  const topSpeed = tuning.topSpeed * (modifiers?.topSpeedScale ?? 1);
30
36
  const engineAccel = tuning.engineAccel * (modifiers?.accelScale ?? 1);
31
37
  const turnRate = tuning.turnRate * (modifiers?.turnRateScale ?? 1);
@@ -50,6 +56,20 @@ export function createKinematicVehicle(tuning, options = {}) {
50
56
  yawRate += (targetYaw - yawRate) * yawResponse;
51
57
  heading += yawRate * dt;
52
58
  }
59
+ // Handbrake rear-lock oversteer: pure grip bleed leaves a bicycle car going mostly straight
60
+ // while sliding. Extra yaw from steer×handbrake is what makes drifts initiate and hold
61
+ // (GTA/NFS-style street feel) without needing a dual-axle tire model.
62
+ if (axis.handbrake > 0.05 && Math.abs(speed) > HANDBRAKE_OVERSTEER_MIN_SPEED && Math.abs(axis.steer) > 0.02) {
63
+ const speedFactor = Math.min(1, Math.abs(speed) / HANDBRAKE_OVERSTEER_REF_SPEED);
64
+ const oversteerYaw = -Math.sign(speed === 0 ? 1 : speed) *
65
+ axis.steer *
66
+ axis.handbrake *
67
+ HANDBRAKE_OVERSTEER_YAW *
68
+ speedFactor;
69
+ // Integrate immediately so this tick's heading (and next-frame velocity basis) rotate with the slide.
70
+ heading += oversteerYaw * dt;
71
+ yawRate += oversteerYaw * 0.65;
72
+ }
53
73
  const [fx, fz] = forward();
54
74
  let accel = 0;
55
75
  let tractionLimited = false;
@@ -81,6 +101,15 @@ export function createKinematicVehicle(tuning, options = {}) {
81
101
  const normalizedRpm = (rpm - powertrain.idleRpm) / Math.max(1, powertrain.redlineRpm - powertrain.idleRpm);
82
102
  const torque = sampleGripCurve(powertrain.torqueCurve, normalizedRpm);
83
103
  torqueFactor = torque * (shiftRemaining > 0 ? 0.32 : 1);
104
+ // Launch clutch floor: at crawl speeds the idle end of a torque curve under-reads what a
105
+ // clutched street car actually puts down. Fade the floor out by {@link LAUNCH_TORQUE_FADE_SPEED}
106
+ // so mid-range power still comes from the curve.
107
+ if (axis.throttle > 0) {
108
+ const launchBlend = 1 - Math.min(1, Math.abs(speed) / LAUNCH_TORQUE_FADE_SPEED);
109
+ if (launchBlend > 0) {
110
+ torqueFactor = Math.max(torqueFactor, LAUNCH_TORQUE_FLOOR * launchBlend + torqueFactor * (1 - launchBlend));
111
+ }
112
+ }
84
113
  }
85
114
  if (axis.throttle > 0 && speed < topSpeed) {
86
115
  accel += chassis === undefined
@@ -90,16 +119,20 @@ export function createKinematicVehicle(tuning, options = {}) {
90
119
  if (axis.brake > 0) {
91
120
  if (speed > 0.2) {
92
121
  const abs = tuning.dynamics?.abs ?? 0;
93
- const brakeScale = Math.max(0.15, 1 - abs * Math.max(0, Math.abs(speed) / Math.max(1, topSpeed) - 0.72));
94
- absActive = brakeScale < 0.999;
122
+ const brakeScale = Math.max(0.15, 1 - abs * Math.max(0, Math.abs(speed) / Math.max(1, topSpeed) - 0.72)) *
123
+ (modifiers?.brakeScale ?? 1);
124
+ absActive = brakeScale < 0.999 * (modifiers?.brakeScale ?? 1);
95
125
  accel -= chassis === undefined
96
126
  ? axis.brake * tuning.brakeAccel * brakeScale
97
127
  : (axis.brake * chassis.brakeForce * brakeScale) / chassis.massKg;
98
128
  }
99
129
  else if (speed > -tuning.reverseSpeed) {
130
+ // Reverse is intentionally softer than forward — full engineForce made performance cars
131
+ // hit reverse top in ~2 s and felt like a second highway gear.
132
+ const reverseScale = chassis?.reverseForceScale ?? tuning.reverseForceScale ?? DEFAULT_REVERSE_FORCE_SCALE;
100
133
  accel -= chassis === undefined
101
- ? axis.brake * engineAccel
102
- : (axis.brake * chassis.engineForce * (modifiers?.accelScale ?? 1)) / chassis.massKg;
134
+ ? axis.brake * engineAccel * reverseScale
135
+ : (axis.brake * chassis.engineForce * reverseScale * (modifiers?.accelScale ?? 1)) / chassis.massKg;
103
136
  }
104
137
  }
105
138
  const traction = tuning.dynamics?.tractionControl ?? 0;
@@ -121,11 +154,27 @@ export function createKinematicVehicle(tuning, options = {}) {
121
154
  // never push the car backwards.
122
155
  if (chassis !== undefined && dt > 0 && Math.abs(speed) > 1e-4) {
123
156
  let resistDecel = ROLLING_RESISTANCE_COEFFICIENT * GRAVITY;
124
- if (axis.throttle <= 0 && tuning.powertrain !== undefined) {
157
+ // Engine braking is a coast/lift-off load only. Applying it while the brake axis is driving
158
+ // reverse (speed ≤ 0.2 + brake) would fight reverse accel and leave cars crawling backwards.
159
+ const reverseDriving = axis.brake > 0 && speed <= 0.2;
160
+ if (axis.throttle <= 0 && !reverseDriving && tuning.powertrain !== undefined) {
125
161
  resistDecel += ENGINE_BRAKE_COEFFICIENT * GRAVITY * gearLeverage;
126
162
  }
127
163
  accel -= Math.sign(speed) * Math.min(resistDecel, Math.abs(speed) / dt);
128
164
  }
165
+ // Arcade coast road-load: a flat deceleration while genuinely coasting — no throttle and no
166
+ // brake — capped at |speed|/dt so it can never drag the vehicle backwards through zero. The
167
+ // `chassis` path above derives the same feel from mass and gearing, so the two are mutually
168
+ // exclusive by construction rather than by convention.
169
+ const coastDeceleration = tuning.coastDeceleration ?? 0;
170
+ if (chassis === undefined &&
171
+ coastDeceleration > 0 &&
172
+ dt > 0 &&
173
+ axis.throttle <= 0 &&
174
+ axis.brake <= 0 &&
175
+ Math.abs(speed) > 1e-4) {
176
+ accel -= Math.sign(speed) * Math.min(coastDeceleration, Math.abs(speed) / dt);
177
+ }
129
178
  vx += fx * accel * dt;
130
179
  vz += fz * accel * dt;
131
180
  let forwardSpeed = vx * fx + vz * fz;
@@ -136,7 +185,8 @@ export function createKinematicVehicle(tuning, options = {}) {
136
185
  const downforce = 1 + (tuning.dynamics?.downforce ?? 0) * Math.min(1, Math.abs(forwardSpeed) / Math.max(1, topSpeed));
137
186
  const gripValue = sampleGripCurve(grip, slip) * surface * handbrakeFactor * downforce;
138
187
  const keep = Math.max(0, 1 - gripValue * tuning.gripStrength * dt);
139
- const stability = tuning.dynamics?.stabilityControl ?? 0;
188
+ // ESC fights intentional slides — ease it off while the handbrake is held so drift stays steerable.
189
+ const stability = (tuning.dynamics?.stabilityControl ?? 0) * (1 - 0.85 * axis.handbrake);
140
190
  let keptLateral = lateralSpeed * keep * Math.max(0, 1 - stability * Math.abs(axis.steer) * dt);
141
191
  if (chassis !== undefined) {
142
192
  // Friction budget (#1051): total tire force F = μeff * m * g, with μeff = tireGrip * gripValue
@@ -212,6 +262,17 @@ export function createKinematicVehicle(tuning, options = {}) {
212
262
  const targetRoll = clampSigned(-yawRate * forwardSpeed * (tuning.dynamics?.bodyRollFactor ?? 0) * comScale, tuning.dynamics?.maxBodyRoll ?? 0.16);
213
263
  bodyPitch += (targetPitch - bodyPitch) * attitudeResponse;
214
264
  bodyRoll += (targetRoll - bodyRoll) * attitudeResponse;
265
+ // Hop/air state is a pure vertical offset above the ground plane, deliberately independent of
266
+ // the planar sim: a hopping vehicle still steers, still slides, and still follows the terrain
267
+ // underneath it. Without `hopGravity` there is no state to integrate and `airOffset` stays 0.
268
+ if (hopGravity > 0 && (airOffset > 0 || verticalVelocity > 0)) {
269
+ verticalVelocity -= hopGravity * dt;
270
+ airOffset += verticalVelocity * dt;
271
+ if (airOffset <= 0) {
272
+ airOffset = 0;
273
+ verticalVelocity = 0;
274
+ }
275
+ }
215
276
  return {
216
277
  position: [x, y, z],
217
278
  heading,
@@ -229,6 +290,8 @@ export function createKinematicVehicle(tuning, options = {}) {
229
290
  wheelspin,
230
291
  bodyPitch,
231
292
  bodyRoll,
293
+ airOffset,
294
+ airborne: airOffset > 0,
232
295
  };
233
296
  },
234
297
  pose: () => ({ position: [x, y, z], heading }),
@@ -237,6 +300,15 @@ export function createKinematicVehicle(tuning, options = {}) {
237
300
  vx *= factor;
238
301
  vz *= factor;
239
302
  },
303
+ hop(impulse) {
304
+ if ((tuning.hopGravity ?? 0) <= 0 || airOffset > 0 || verticalVelocity > 0)
305
+ return false;
306
+ verticalVelocity = impulse;
307
+ return true;
308
+ },
309
+ retune(next) {
310
+ tuning = next;
311
+ },
240
312
  resetTo(position, nextHeading) {
241
313
  x = position[0];
242
314
  y = position[1];
@@ -252,11 +324,28 @@ export function createKinematicVehicle(tuning, options = {}) {
252
324
  previousForwardSpeed = 0;
253
325
  bodyPitch = 0;
254
326
  bodyRoll = 0;
327
+ airOffset = 0;
328
+ verticalVelocity = 0;
255
329
  },
256
330
  };
257
331
  }
258
332
  /** Gravitational acceleration, m/s², for the chassis friction budget and weight-transfer ratio (#1051). */
259
333
  const GRAVITY = 9.81;
334
+ /** Default reverse drive force as a fraction of forward `engineForce` / `engineAccel`. */
335
+ export const DEFAULT_REVERSE_FORCE_SCALE = 0.48;
336
+ /**
337
+ * Minimum torque-curve factor under throttle near standstill (launch clutch). Fades out by
338
+ * {@link LAUNCH_TORQUE_FADE_SPEED} so it only fills the weak idle end of the curve.
339
+ */
340
+ const LAUNCH_TORQUE_FLOOR = 0.9;
341
+ /** Forward speed (world units/s) by which the launch torque floor has fully faded. */
342
+ const LAUNCH_TORQUE_FADE_SPEED = 9;
343
+ /** Minimum |forward speed| before handbrake oversteer yaw kicks in. */
344
+ const HANDBRAKE_OVERSTEER_MIN_SPEED = 2.5;
345
+ /** Speed at which handbrake oversteer reaches full strength. */
346
+ const HANDBRAKE_OVERSTEER_REF_SPEED = 12;
347
+ /** Peak extra yaw rate (rad/s) from full steer + full handbrake at {@link HANDBRAKE_OVERSTEER_REF_SPEED}. */
348
+ const HANDBRAKE_OVERSTEER_YAW = 3.1;
260
349
  /**
261
350
  * Tyre rolling-resistance coefficient μ_rr (#1051). The always-present coast force is `μ_rr · m · g`
262
351
  * (the road-load floor) — a little high vs. a real ~0.013 tyre so a lifted-off car settles to a stop
@@ -29,6 +29,7 @@ export function createVehicleObstacleClamp(options) {
29
29
  // Grow-only scratch of Y-normalised solids, reused across ticks so a sustained crash allocates nothing
30
30
  // after warm-up. Per-clamp (closure-local) so concurrent cars never share it.
31
31
  const scratch = [];
32
+ const offsetScratch = [];
32
33
  let pending = null;
33
34
  function vehicleView(source) {
34
35
  const n = source.length;
@@ -43,6 +44,24 @@ export function createVehicleObstacleClamp(options) {
43
44
  entry.position[1] = CAR_BODY_MID_Y;
44
45
  entry.position[2] = from.position[2];
45
46
  entry.halfExtents = from.halfExtents;
47
+ // Keep the collider's XZ offset (a fitted body centred away from the placement point) but zero
48
+ // its Y: with the body pinned to CAR_BODY_MID_Y a vertical offset would push the box out of the
49
+ // stepper's pedestrian-span gate and the car would drive through a tall building's collider.
50
+ const fromOffset = from.offset;
51
+ if (fromOffset !== undefined && (fromOffset[0] !== 0 || fromOffset[2] !== 0)) {
52
+ let flat = offsetScratch[i];
53
+ if (flat === undefined) {
54
+ flat = [0, 0, 0];
55
+ offsetScratch[i] = flat;
56
+ }
57
+ flat[0] = fromOffset[0];
58
+ flat[2] = fromOffset[2];
59
+ entry.offset = flat;
60
+ }
61
+ else {
62
+ entry.offset = undefined;
63
+ }
64
+ entry.boxes = from.boxes;
46
65
  }
47
66
  if (scratch.length > n)
48
67
  scratch.length = n;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Resolves an optional injected `[0,1)` generator, falling back to `Math.random` — and says so, once
3
+ * per call site.
4
+ *
5
+ * Every primitive that rolls dice takes an `rng`, but the default was silent: a caller who forgot to
6
+ * pass `ctx.rng` got a simulation that cannot replay, lockstep, roll back, or be pinned by a test,
7
+ * with nothing on screen or in the log to say why. The fallback stays (a portable helper called
8
+ * outside a game context is a real use), but it is now loud instead of invisible.
9
+ *
10
+ * @capability resolve-rng resolve an optional injected RNG, warning once per site when it falls back to `Math.random`
11
+ */
12
+ export declare function resolveRng(provided: (() => number) | undefined, site: string): () => number;
13
+ /** Clears the warned-site memo. Tests only — the warning is once-per-site for the life of the process. */
14
+ export declare function resetRngFallbackWarnings(): void;
@@ -0,0 +1,31 @@
1
+ import { isProductionEnvironment } from "../devtools/saveEndpoint.js";
2
+ const warned = new Set();
3
+ /** Narrow structural view of the ambient console — core never pulls in the DOM lib. */
4
+ function consoleWarn(message) {
5
+ const host = globalThis;
6
+ host.console?.warn?.(message);
7
+ }
8
+ /**
9
+ * Resolves an optional injected `[0,1)` generator, falling back to `Math.random` — and says so, once
10
+ * per call site.
11
+ *
12
+ * Every primitive that rolls dice takes an `rng`, but the default was silent: a caller who forgot to
13
+ * pass `ctx.rng` got a simulation that cannot replay, lockstep, roll back, or be pinned by a test,
14
+ * with nothing on screen or in the log to say why. The fallback stays (a portable helper called
15
+ * outside a game context is a real use), but it is now loud instead of invisible.
16
+ *
17
+ * @capability resolve-rng resolve an optional injected RNG, warning once per site when it falls back to `Math.random`
18
+ */
19
+ export function resolveRng(provided, site) {
20
+ if (provided !== undefined)
21
+ return provided;
22
+ if (!warned.has(site) && !isProductionEnvironment()) {
23
+ warned.add(site);
24
+ consoleWarn(`[jgengine:rng] ${site} fell back to Math.random — this run cannot replay, lockstep, roll back, or be pinned by a test. Pass \`ctx.rng\` (the per-world seeded stream), or \`seededRng(seed)\` outside a game context.`);
25
+ }
26
+ return Math.random;
27
+ }
28
+ /** Clears the warned-site memo. Tests only — the warning is once-per-site for the life of the process. */
29
+ export function resetRngFallbackWarnings() {
30
+ warned.clear();
31
+ }
@@ -47,7 +47,13 @@ export interface DofConfig {
47
47
  maxBlur?: number;
48
48
  }
49
49
  /**
50
- * Declarative post-processing chain (RenderPass → AO → Bloom → tone-map output →
50
+ * Edge antialiasing for the post chain. SMAA runs in linear-sRGB before the output
51
+ * tone-map and cleans alpha-tested foliage/particles that MSAA samples alone leave crawling.
52
+ * `"msaa"` keeps only the render-target multisample resolve; `false` disables AA stages.
53
+ */
54
+ export type PostAaMode = "smaa" | "msaa" | false;
55
+ /**
56
+ * Declarative post-processing chain (RenderPass → AO → Bloom → SMAA → tone-map output →
51
57
  * Grade). Present on a game means the shell mounts an `EffectComposer` and owns
52
58
  * the render; absent means the renderer draws directly (unchanged). Each stage is
53
59
  * a config object, `false` to skip, or omitted for its default. Pure data — no
@@ -60,6 +66,11 @@ export interface PostProcessingConfig {
60
66
  toneMapping?: ToneMappingMode;
61
67
  /** Exposure multiplier applied before tone mapping. Default 1. */
62
68
  exposure?: number;
69
+ /**
70
+ * Antialiasing. Default `"smaa"` (plus a light MSAA resolve on the beauty target).
71
+ * Set `"msaa"` to skip the SMAA pass, or `false` for neither.
72
+ */
73
+ aa?: PostAaMode;
63
74
  bloom?: BloomConfig | false;
64
75
  /** Ambient occlusion. Heavier than the other stages — omit or `false` on low-end targets. Default off (omitted). */
65
76
  ao?: AoConfig | false;
@@ -1,6 +1,6 @@
1
1
  import type { ChaseCameraConfig, CinematicCameraConfig } from "../game/playableGame.js";
2
- /** Runtime patch over the static `camera.chase` config — distance/height/fov retuning from gameplay events (#286.11). */
3
- export type ChaseCameraTuning = Partial<Pick<ChaseCameraConfig, "distance" | "height" | "lookHeight" | "springDamping" | "fov" | "lead" | "bank">>;
2
+ /** Runtime patch over the static `camera.chase` config — distance/height/fov retuning from gameplay events (#286.11), or a whole driving-feel overlay (speed→FOV, lead, bank, speed shake, drift-lag) applied only while a vehicle is piloted (#1299). */
3
+ export type ChaseCameraTuning = Partial<Pick<ChaseCameraConfig, "distance" | "height" | "lookHeight" | "springDamping" | "fov" | "lead" | "bank" | "shakePerSpeed" | "velocityYaw" | "yawResponse">>;
4
4
  export interface CameraDirector {
5
5
  follow(entityId: string | null): void;
6
6
  /** `undefined` means no runtime override — the shell falls back to the static `playable.camera.followEntityId`. `null` means explicitly follow nothing. */
@@ -34,6 +34,13 @@ export function createCombatSubsystem(d) {
34
34
  statPools: createEntityStatsApi((instanceId) => statsByInstance.get(instanceId)),
35
35
  getStat: weapon.getStat,
36
36
  spatial: combatSpatial,
37
+ resolveSlainIdentity(instanceId) {
38
+ const entity = entities.get(instanceId);
39
+ if (entity === null)
40
+ return null;
41
+ // `entity.name` is the spawn kind/catalog id, matching the `entity.died` event.
42
+ return { catalogId: entity.name, name: entity.name };
43
+ },
37
44
  onLethal(instanceId, lethalCtx) {
38
45
  // Capture identity + onDeath *before* resolveDeath despawns the entity.
39
46
  const dyingEntity = entities.get(instanceId);
@@ -117,6 +124,7 @@ export function createCombatSubsystem(d) {
117
124
  at: [report.at[0], report.at[1], report.at[2]],
118
125
  effect: report.effect,
119
126
  hit: report.hit,
127
+ ballistic: report.ballistic,
120
128
  });
121
129
  },
122
130
  }), ["fireProjectile", "settleProjectile"], signalNotify);
@@ -5,6 +5,7 @@ import { type EntityStatsApi, type StatValueMap } from "../../scene/entityStats.
5
5
  import { type EntityPosition, type EntityStore, type SceneEntity, type SpawnOptions } from "../../scene/entityStore.js";
6
6
  import { type Forms } from "../../scene/form.js";
7
7
  import { type EntityColliderSet, type MeasuredBounds } from "../../scene/colliders.js";
8
+ import { type CollisionMeshSource } from "../../scene/collisionMesh.js";
8
9
  import { type ObjectStore } from "../../scene/objectStore.js";
9
10
  import { type PaintLayer } from "../../scene/paintLayer.js";
10
11
  import { type SceneRaycastApi } from "../../scene/sceneRaycast.js";
@@ -42,6 +43,7 @@ export interface SceneSubsystem {
42
43
  entityCollidersOf: (instanceId: string) => EntityColliderSet | null;
43
44
  objectCollidersOf: (instanceId: string) => EntityColliderSet | null;
44
45
  reportEntityBounds: (kind: string, bounds: MeasuredBounds | null) => boolean;
46
+ reportEntityCollisionMesh: (kind: string, mesh: CollisionMeshSource | null) => boolean;
45
47
  entityVisualScaleOf: (instanceId: string) => number;
46
48
  forms: Forms;
47
49
  paintLayer: PaintLayer;
@@ -2,7 +2,8 @@ import { createBodyBind } from "../../scene/bodyBind.js";
2
2
  import { createEntityStatsApi, seedStatValues, } from "../../scene/entityStats.js";
3
3
  import { createEntityStore, } from "../../scene/entityStore.js";
4
4
  import { createForms } from "../../scene/form.js";
5
- import { fittedEntityColliders, fittedObjectColliders, measuredEntityColliders, measuredObjectColliders, scaledEntityColliders, scaledObjectColliders, } from "../../scene/colliders.js";
5
+ import { fittedEntityColliders, fittedObjectColliders, hasMeshColliderShape, measuredEntityColliders, measuredObjectColliders, meshEntityColliders, meshObjectColliders, scaledEntityColliders, scaledObjectColliders, } from "../../scene/colliders.js";
6
+ import { prepareCollisionMeshSource } from "../../scene/collisionMesh.js";
6
7
  import { raycastObjects, raycastObjectsAll } from "../../scene/objectQuery.js";
7
8
  import { createObjectStore, objectVisualScale } from "../../scene/objectStore.js";
8
9
  import { createPaintLayer } from "../../scene/paintLayer.js";
@@ -98,6 +99,38 @@ export function createSceneSubsystem(d) {
98
99
  signalNotify();
99
100
  return true;
100
101
  }
102
+ const meshEntityByKind = new Map();
103
+ const meshObjectByCatalogId = new Map();
104
+ function reportEntityCollisionMesh(kind, mesh) {
105
+ if (mesh === null) {
106
+ const existed = meshEntityByKind.delete(kind);
107
+ if (existed)
108
+ signalNotify();
109
+ return existed;
110
+ }
111
+ const prepared = prepareCollisionMeshSource(mesh);
112
+ const set = prepared === null ? null : meshEntityColliders(prepared);
113
+ if (set === null)
114
+ return false;
115
+ meshEntityByKind.set(kind, set);
116
+ signalNotify();
117
+ return true;
118
+ }
119
+ function reportObjectCollisionMesh(catalogId, mesh) {
120
+ if (mesh === null) {
121
+ const existed = meshObjectByCatalogId.delete(catalogId);
122
+ if (existed)
123
+ signalNotify();
124
+ return existed;
125
+ }
126
+ const prepared = prepareCollisionMeshSource(mesh);
127
+ const set = prepared === null ? null : meshObjectColliders(prepared);
128
+ if (set === null)
129
+ return false;
130
+ meshObjectByCatalogId.set(catalogId, set);
131
+ signalNotify();
132
+ return true;
133
+ }
101
134
  function reportObjectBounds(catalogId, bounds) {
102
135
  if (bounds === null) {
103
136
  const existed = measuredObjectByCatalogId.delete(catalogId);
@@ -122,6 +155,13 @@ export function createSceneSubsystem(d) {
122
155
  const kind = entities.get(instanceId)?.name;
123
156
  if (kind !== undefined) {
124
157
  const fitted = fittedEntitySetFor(kind);
158
+ // Index-derived mesh shapes win (identical on a headless host); otherwise a runtime-reported
159
+ // collision mesh upgrades the fitted/measured bounding box to the rendered triangles.
160
+ if (fitted !== null && hasMeshColliderShape(fitted))
161
+ return fitted;
162
+ const runtimeMesh = meshEntityByKind.get(kind);
163
+ if (runtimeMesh !== undefined)
164
+ return runtimeMesh;
125
165
  if (fitted !== null)
126
166
  return fitted;
127
167
  const measured = measuredEntityByKind.get(kind);
@@ -149,6 +189,11 @@ export function createSceneSubsystem(d) {
149
189
  if (object === null)
150
190
  return null;
151
191
  const fitted = fittedObjectSetFor(object.catalogId);
192
+ if (fitted !== null && hasMeshColliderShape(fitted))
193
+ return fitted;
194
+ const runtimeMesh = meshObjectByCatalogId.get(object.catalogId);
195
+ if (runtimeMesh !== undefined)
196
+ return runtimeMesh;
152
197
  if (fitted !== null)
153
198
  return fitted;
154
199
  const measured = measuredObjectByCatalogId.get(object.catalogId);
@@ -291,6 +336,7 @@ export function createSceneSubsystem(d) {
291
336
  },
292
337
  collidersOf: objectCollidersOf,
293
338
  reportBounds: reportObjectBounds,
339
+ reportCollisionMesh: reportObjectCollisionMesh,
294
340
  selection: objectSelection,
295
341
  };
296
342
  return {
@@ -310,6 +356,7 @@ export function createSceneSubsystem(d) {
310
356
  entityCollidersOf,
311
357
  objectCollidersOf,
312
358
  reportEntityBounds,
359
+ reportEntityCollisionMesh,
313
360
  entityVisualScaleOf,
314
361
  forms,
315
362
  paintLayer,
@@ -19,6 +19,7 @@ import { createRuntimeSave } from "./runtimeSave.js";
19
19
  import { isOffline } from "./adapter.js";
20
20
  import { localSaveBackend, memorySaveBackend } from "../game/saveStore.js";
21
21
  import { createSimClock } from "../time/simClock.js";
22
+ import { seededRng } from "../random/rng.js";
22
23
  import { createCameraDirector } from "./cameraDirector.js";
23
24
  import { createInputSnapshot } from "./inputSnapshot.js";
24
25
  import { baselineDescriptors } from "./descriptors/baseline.js";
@@ -32,6 +33,9 @@ export function createGameContext(options) {
32
33
  const { definition, content, player } = options;
33
34
  const now = options.now ?? Date.now;
34
35
  const occluder = options.occluder;
36
+ // Per-world deterministic stream — never Math.random on simulation paths (#1545).
37
+ const rng = options.rng ??
38
+ seededRng(options.seed ?? `jg:${definition.name.trim().length > 0 ? definition.name : "game"}`);
35
39
  const signal = createChangeSignal();
36
40
  let actingUserId = null;
37
41
  const activeUserId = () => actingUserId ?? player.userId;
@@ -56,11 +60,11 @@ export function createGameContext(options) {
56
60
  ...(occluder !== undefined ? { occluder } : {}),
57
61
  ...(options.models !== undefined ? { models: options.models } : {}),
58
62
  });
59
- const { entities, objects, statsByInstance, entityStats, ensureInstanceStats, catalogEntry, spatial, combatSpatial, sceneRaycast, targeting, entityColliders, entityCollidersOf, objectCollidersOf, reportEntityBounds, entityVisualScaleOf, forms, paintLayer, spawnEntity, despawnEntity, moveEntityTowardCommit, resetAllToSpawn, bind, sceneObjects, } = scene;
63
+ const { entities, objects, statsByInstance, entityStats, ensureInstanceStats, catalogEntry, spatial, combatSpatial, sceneRaycast, targeting, entityColliders, entityCollidersOf, objectCollidersOf, reportEntityBounds, reportEntityCollisionMesh, entityVisualScaleOf, forms, paintLayer, spawnEntity, despawnEntity, moveEntityTowardCommit, resetAllToSpawn, bind, sceneObjects, } = scene;
60
64
  // --- Shared game services (weapon, feed, features bag, commands, item use) ---
61
65
  const weapon = createWeaponStats((itemId) => content.itemById?.(itemId));
62
66
  const feed = createGameFeed(definition.feed);
63
- const lootRegistry = createLootRegistry();
67
+ const lootRegistry = createLootRegistry({ rng });
64
68
  /**
65
69
  * Single feature-activation path (G9): `definition.features` is already the final map after
66
70
  * `defineGame` OR-merged explicit boolean sugar (`features: { quest: true }`) with system-implied
@@ -322,6 +326,7 @@ export function createGameContext(options) {
322
326
  }
323
327
  // --- Assemble public GameContext ---
324
328
  const ctx = {
329
+ rng,
325
330
  scene: {
326
331
  object: sceneObjects,
327
332
  entity: {
@@ -369,6 +374,7 @@ export function createGameContext(options) {
369
374
  },
370
375
  collidersOf: entityCollidersOf,
371
376
  reportBounds: reportEntityBounds,
377
+ reportCollisionMesh: reportEntityCollisionMesh,
372
378
  visualScaleOf: entityVisualScaleOf,
373
379
  form: forms,
374
380
  paint: paintLayer,
@@ -35,6 +35,7 @@ import type { EntityStatsApi, StatCatalog } from "../scene/entityStats.js";
35
35
  import type { EntityBlackboard, EntityPose, EntityPosition, EntityStore, SceneEntity, SpawnOptions, SpawnPose } from "../scene/entityStore.js";
36
36
  import type { Forms } from "../scene/form.js";
37
37
  import type { EntityColliderSet, MeasuredBounds, ModelBodySource } from "../scene/colliders.js";
38
+ import type { CollisionMeshSource } from "../scene/collisionMesh.js";
38
39
  import type { ObjectRaycastHit, ObjectRaycastInput } from "../scene/objectQuery.js";
39
40
  import type { ObjectStore } from "../scene/objectStore.js";
40
41
  import type { Roster } from "../scene/roster.js";
@@ -106,6 +107,16 @@ export interface GameContextOptions<TAssetRef extends ModelAssetRef = ModelAsset
106
107
  };
107
108
  now?: () => number;
108
109
  occluder?: (from: EntityPosition, to: EntityPosition) => boolean;
110
+ /**
111
+ * Seed for {@link GameContext.rng}. Defaults to the game name so two contexts of the same
112
+ * definition share a stream unless a host passes a unique seed per world/session.
113
+ */
114
+ seed?: string | number;
115
+ /**
116
+ * Injected `[0,1)` generator for this world. Defaults to a {@link seededRng} from {@link seed}.
117
+ * Never use `Math.random` on simulation paths — pass `ctx.rng` into pure helpers instead.
118
+ */
119
+ rng?: () => number;
109
120
  /** Bind `ctx.game.save` to a pluggable backend (offline/single-player whole-world save). The shell resolves this from `defineGame({ save })`; multiplayer leaves it off (the host persists). */
110
121
  save?: RuntimeSaveOptions;
111
122
  /**
@@ -145,6 +156,15 @@ export interface SceneObjectContext extends ObjectStore {
145
156
  * @capability measured-colliders hitboxes and blocking bodies wrap what the renderer actually mounted — custom render props and dims-less models stop falling back to fixed-size boxes.
146
157
  */
147
158
  reportBounds(catalogId: string, bounds: MeasuredBounds | null): boolean;
159
+ /**
160
+ * Report the entity-local triangle soup the renderer actually mounted for `catalogId` — stored per
161
+ * catalog id as a blocking physical body whose raycasts hit the real surface (a BVH over the
162
+ * triangles) while movement obstruction keeps the conservative AABB. Consulted right after
163
+ * authored `colliders` / index collision meshes and before the fitted-box, measured-bounds, and
164
+ * visual-scale fallbacks. The shell reports rendered models automatically. Pass `null` to clear.
165
+ * Returns `false` when no valid triangle survives.
166
+ */
167
+ reportCollisionMesh(catalogId: string, mesh: CollisionMeshSource | null): boolean;
148
168
  /**
149
169
  * Per-instance selection/highlight state for placed objects — the reactive counterpart to
150
170
  * `worldHealthBars`/`nameplates` (entities), so a build-mode or RTS selection ring reads
@@ -275,6 +295,17 @@ export interface SceneEntityContext {
275
295
  * @capability measured-hitboxes an entity rendered with a custom mesh takes hits in a box matching its rendered bounds, not the fixed humanoid rectangle.
276
296
  */
277
297
  reportBounds(kind: string, bounds: MeasuredBounds | null): boolean;
298
+ /**
299
+ * Report the entity-local triangle soup the renderer actually mounted for `kind` — stored per kind
300
+ * as a damage hitbox that raycasts the model's own triangles (BVH), so shots aimed between a
301
+ * character's feet or past its shoulder miss instead of hitting a bounding box. Consulted right
302
+ * after authored `colliders` / index collision meshes and before the fitted-box and
303
+ * measured-bounds fallbacks. The shell reports rendered models automatically; authoritative hosts
304
+ * that never render should author `colliders` or index collision meshes for identical results.
305
+ * Pass `null` to clear. Returns `false` when no valid triangle survives.
306
+ * @capability conforming-hitboxes shots aimed between a character's feet or past its shoulder MISS — damage raycasts hit the rendered model's triangles, not its bounding box.
307
+ */
308
+ reportCollisionMesh(kind: string, mesh: CollisionMeshSource | null): boolean;
278
309
  /** Uniform visual scale from the entity's catalog entry (1 when unscaled). */
279
310
  visualScaleOf(instanceId: string): number;
280
311
  form: Forms;
@@ -393,6 +424,14 @@ export interface GameContext {
393
424
  raycastAll(input: SceneRaycastInput): readonly SceneRaycastHit[];
394
425
  };
395
426
  world: GameContextWorld;
427
+ /**
428
+ * Deterministic `[0,1)` stream for this world — loot, AI, combat rolls, and any sim path that
429
+ * needs randomness. Same seed → same sequence. Never fall back to `Math.random` in systems;
430
+ * pass this into pure helpers (`rollCheck`, `autoTarget`, loot tables, …).
431
+ *
432
+ * @capability game-context-rng per-world seeded randomness for replay, lockstep, and multi-world hosts
433
+ */
434
+ rng: () => number;
396
435
  game: {
397
436
  commands: GameContextCommands;
398
437
  events: GameEvents;
@@ -4,12 +4,16 @@ import { type AxisBinding, type AxisRange } from "../input/axisInput.js";
4
4
  export interface InputFrame {
5
5
  held: readonly string[];
6
6
  pointer: PointerAxisState | null;
7
+ /** Analog per-action magnitudes (0..1) for actions driven by a continuous source (virtual joystick, gamepad stick); absent/null means every held action is fully pressed. */
8
+ analog?: Readonly<Record<string, number>> | null;
7
9
  }
8
10
  export interface InputSnapshot {
9
11
  /** Replaces the held-action set for this frame, rolling the previous held set forward for edge detection (#671). Called by the shell before `onTick` each frame; does not bump `ctx.version()` or notify `ctx.subscribe` listeners — per-frame publishes would storm subscribers. */
10
12
  publish(held: readonly string[]): void;
11
13
  /** Replaces the normalized pointer-position state for this frame (#293). Same no-notify contract as `publish`. */
12
14
  publishPointer(state: PointerAxisState | null): void;
15
+ /** Replaces the analog per-action magnitudes for this frame (#1370) — the virtual joystick's continuous vector, split into 0..1 values per movement action. `null` clears back to digital-only. Same no-notify contract as `publish`. */
16
+ publishAnalog(values: Readonly<Record<string, number>> | null): void;
13
17
  isDown(action: string): boolean;
14
18
  /** The held-action set for this frame — an owned, frozen array; the array {@link publish} was called with is never aliased or returned. */
15
19
  held(): readonly string[];
@@ -17,13 +21,18 @@ export interface InputSnapshot {
17
21
  justPressed(action: string): boolean;
18
22
  /** True only on the frame `action` transitions from down to up (#671); mirrors {@link justPressed}. */
19
23
  justReleased(action: string): boolean;
24
+ /** This frame's magnitude for `action`: its published analog value when one exists, else 1 while held / 0 while up. What `axis()` samples per code, so an analog stick steers proportionally where a key is all-or-nothing. */
25
+ value(action: string): number;
26
+ /** The published analog map for this frame, `null` when input is digital-only — an owned, frozen copy. */
27
+ analog(): Readonly<Record<string, number>> | null;
20
28
  /** Pointer position over the play surface, `[-1, 1]` per axis with `+y` down, published by the shell each frame; `null` until the first pointer move. Frozen — an owned copy of what {@link publishPointer} was called with. */
21
29
  pointer(): Readonly<PointerAxisState> | null;
22
30
  /**
23
31
  * Instantaneous analog axis sample against the held-action set and current pointer (#533.7) — bind
24
32
  * throttle/steer/handbrake (or any axes) to *action names*, not raw key codes, since the held set is
25
33
  * semantic actions. Feed the result into an `AxisChannel` for smoothing; unlisted ranges default to
26
- * bipolar `[-1, 1]`, so pass `{ min: 0, max: 1 }` for one-directional pedals.
34
+ * bipolar `[-1, 1]`, so pass `{ min: 0, max: 1 }` for one-directional pedals. Actions with a published
35
+ * analog magnitude contribute that value instead of 0/1, so a virtual joystick steers proportionally.
27
36
  */
28
37
  axis<TAxes extends string>(bindings: Record<TAxes, AxisBinding>, ranges?: Partial<Record<TAxes, AxisRange>>): Record<TAxes, number>;
29
38
  }
@@ -4,6 +4,13 @@ export function createInputSnapshot() {
4
4
  let previousHeldSet = new Set();
5
5
  let heldList = [];
6
6
  let pointerState = null;
7
+ let analogValues = null;
8
+ const value = (action) => {
9
+ const analog = analogValues?.[action];
10
+ if (analog !== undefined)
11
+ return analog;
12
+ return heldSet.has(action) ? 1 : 0;
13
+ };
7
14
  return {
8
15
  publish(held) {
9
16
  previousHeldSet = heldSet;
@@ -13,10 +20,15 @@ export function createInputSnapshot() {
13
20
  publishPointer(state) {
14
21
  pointerState = state === null ? null : Object.freeze({ ...state });
15
22
  },
23
+ publishAnalog(values) {
24
+ analogValues = values === null ? null : Object.freeze({ ...values });
25
+ },
16
26
  isDown: (action) => heldSet.has(action),
17
27
  held: () => heldList,
18
28
  pointer: () => pointerState,
19
- axis: (bindings, ranges) => sampleAxisBindings(bindings, (action) => heldSet.has(action), pointerState, ranges),
29
+ value,
30
+ analog: () => analogValues,
31
+ axis: (bindings, ranges) => sampleAxisBindings(bindings, (action) => heldSet.has(action), pointerState, ranges, value),
20
32
  justPressed: (action) => heldSet.has(action) && !previousHeldSet.has(action),
21
33
  justReleased: (action) => !heldSet.has(action) && previousHeldSet.has(action),
22
34
  };