@jgengine/core 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (318) hide show
  1. package/CHANGELOG.md +265 -2
  2. package/dist/ai/decisionGraph.d.ts +96 -0
  3. package/dist/ai/decisionGraph.js +212 -0
  4. package/dist/ai/groupAssist.d.ts +10 -0
  5. package/dist/ai/groupAssist.js +11 -0
  6. package/dist/ai/mobBrainGraph.d.ts +3 -0
  7. package/dist/ai/mobBrainGraph.js +25 -0
  8. package/dist/ai/pursuit.d.ts +1 -1
  9. package/dist/ai/pursuit.js +1 -1
  10. package/dist/ai/tacticalQueries.d.ts +30 -0
  11. package/dist/ai/tacticalQueries.js +45 -0
  12. package/dist/ai/threat.d.ts +8 -0
  13. package/dist/ai/threat.js +10 -0
  14. package/dist/anim/animGraph.d.ts +155 -0
  15. package/dist/anim/animGraph.js +414 -0
  16. package/dist/anim/boneTexture.d.ts +55 -0
  17. package/dist/anim/boneTexture.js +43 -0
  18. package/dist/anim/footPlacement.d.ts +57 -0
  19. package/dist/anim/footPlacement.js +67 -0
  20. package/dist/anim/ikSolver.d.ts +51 -0
  21. package/dist/anim/ikSolver.js +124 -0
  22. package/dist/anim/locomotionGraph.d.ts +35 -0
  23. package/dist/anim/locomotionGraph.js +62 -0
  24. package/dist/assets/spriteAtlas.d.ts +21 -0
  25. package/dist/assets/spriteAtlas.js +103 -0
  26. package/dist/audio/audioFalloff.d.ts +12 -0
  27. package/dist/audio/doppler.d.ts +21 -0
  28. package/dist/audio/doppler.js +32 -0
  29. package/dist/audio/engineLayers.d.ts +83 -0
  30. package/dist/audio/engineLayers.js +115 -0
  31. package/dist/combat/abilityKit.d.ts +1 -0
  32. package/dist/combat/abilityKit.js +1 -0
  33. package/dist/combat/death.d.ts +7 -0
  34. package/dist/combat/shotOrigin.d.ts +6 -0
  35. package/dist/combat/shotOrigin.js +6 -0
  36. package/dist/combat/weaponFeedback.d.ts +36 -0
  37. package/dist/combat/weaponFeedback.js +42 -0
  38. package/dist/combat/weaponHandling.d.ts +161 -0
  39. package/dist/combat/weaponHandling.js +202 -0
  40. package/dist/combat/weaponPresentation.d.ts +115 -0
  41. package/dist/combat/weaponPresentation.js +96 -0
  42. package/dist/devtools/warnOnce.d.ts +8 -0
  43. package/dist/devtools/warnOnce.js +18 -0
  44. package/dist/economy/currency.d.ts +16 -2
  45. package/dist/economy/currency.js +55 -14
  46. package/dist/economy/wallet.d.ts +5 -4
  47. package/dist/economy/wallet.js +8 -4
  48. package/dist/editor/commandApply.js +1 -0
  49. package/dist/editor/commands.d.ts +4 -1
  50. package/dist/editor/document.d.ts +2 -1
  51. package/dist/editor/document.js +92 -1
  52. package/dist/editor/environment.d.ts +3 -0
  53. package/dist/editor/environment.js +24 -0
  54. package/dist/editor/index.d.ts +4 -2
  55. package/dist/editor/index.js +1 -1
  56. package/dist/editor/types.d.ts +20 -0
  57. package/dist/faction/reputation.d.ts +6 -0
  58. package/dist/faction/reputation.js +11 -0
  59. package/dist/game/cameraConfig.d.ts +59 -1
  60. package/dist/game/chat.d.ts +22 -1
  61. package/dist/game/chat.js +48 -19
  62. package/dist/game/controlGate.d.ts +6 -0
  63. package/dist/game/controlGate.js +30 -1
  64. package/dist/game/defineGame.d.ts +10 -1
  65. package/dist/game/events.d.ts +14 -1
  66. package/dist/game/lootTable.d.ts +4 -0
  67. package/dist/game/lootTable.js +16 -0
  68. package/dist/game/pixelPerfect.d.ts +25 -0
  69. package/dist/game/pixelPerfect.js +29 -0
  70. package/dist/game/playableGame.d.ts +167 -6
  71. package/dist/game/systemRuntime.js +6 -1
  72. package/dist/game/toasts.d.ts +8 -0
  73. package/dist/game/toasts.js +7 -0
  74. package/dist/game/vfxInstance.d.ts +9 -0
  75. package/dist/game/vfxInstance.js +12 -1
  76. package/dist/game/viewports.d.ts +43 -0
  77. package/dist/game/viewports.js +46 -0
  78. package/dist/game/worldItem.d.ts +5 -1
  79. package/dist/game/worldItem.js +0 -1
  80. package/dist/input/actionBindings.d.ts +17 -2
  81. package/dist/input/actionBindings.js +24 -11
  82. package/dist/input/actionContexts.d.ts +45 -0
  83. package/dist/input/actionContexts.js +113 -0
  84. package/dist/input/axisInput.d.ts +4 -0
  85. package/dist/input/axisInput.js +4 -0
  86. package/dist/input/axisShaper.d.ts +73 -0
  87. package/dist/input/axisShaper.js +105 -0
  88. package/dist/input/gamepadModel.d.ts +79 -0
  89. package/dist/input/gamepadModel.js +108 -0
  90. package/dist/input/haptics.d.ts +50 -0
  91. package/dist/input/haptics.js +74 -0
  92. package/dist/input/inputBuffer.d.ts +40 -0
  93. package/dist/input/inputBuffer.js +59 -0
  94. package/dist/input/lookChannel.d.ts +10 -0
  95. package/dist/input/lookChannel.js +6 -0
  96. package/dist/input/pointer.d.ts +2 -0
  97. package/dist/input/pointer.js +4 -0
  98. package/dist/item/affix.d.ts +6 -0
  99. package/dist/item/affix.js +6 -0
  100. package/dist/meta/changelog.d.ts +1 -1
  101. package/dist/meta/changelog.js +196 -1
  102. package/dist/movement/characterController.d.ts +76 -0
  103. package/dist/movement/characterController.js +216 -0
  104. package/dist/movement/freeFlight.d.ts +119 -0
  105. package/dist/movement/freeFlight.js +294 -0
  106. package/dist/movement/glideModel.d.ts +3 -0
  107. package/dist/movement/glideModel.js +6 -0
  108. package/dist/movement/grappleSwing.d.ts +2 -0
  109. package/dist/movement/grappleSwing.js +5 -1
  110. package/dist/movement/movementModel.d.ts +39 -0
  111. package/dist/movement/movementModel.js +62 -19
  112. package/dist/movement/movementProbe.d.ts +51 -0
  113. package/dist/movement/movementProbe.js +119 -0
  114. package/dist/movement/playerMovement.d.ts +30 -3
  115. package/dist/movement/playerMovement.js +304 -7
  116. package/dist/movement/serverStep.d.ts +5 -0
  117. package/dist/movement/serverStep.js +6 -0
  118. package/dist/movement/solidObstacles.d.ts +39 -8
  119. package/dist/movement/solidObstacles.js +71 -6
  120. package/dist/movement/voxelController.js +7 -7
  121. package/dist/multiplayer/poseSyncGate.d.ts +3 -1
  122. package/dist/multiplayer/poseSyncGate.js +9 -1
  123. package/dist/multiplayer/presenceContract.d.ts +6 -0
  124. package/dist/multiplayer/presenceModel.d.ts +2 -0
  125. package/dist/multiplayer/presenceModel.js +11 -0
  126. package/dist/nav/navFromEnvironment.d.ts +9 -3
  127. package/dist/nav/navFromEnvironment.js +18 -12
  128. package/dist/nav/navMesh.d.ts +74 -0
  129. package/dist/nav/navMesh.js +570 -0
  130. package/dist/physics/aircraftDynamics.d.ts +390 -0
  131. package/dist/physics/aircraftDynamics.js +704 -0
  132. package/dist/physics/boatDynamics.d.ts +116 -0
  133. package/dist/physics/boatDynamics.js +160 -0
  134. package/dist/physics/drivableVehicle.d.ts +22 -8
  135. package/dist/physics/drivableVehicle.js +4 -2
  136. package/dist/physics/flightDynamics.d.ts +29 -4
  137. package/dist/physics/flightDynamics.js +28 -5
  138. package/dist/physics/handlingProbe.d.ts +249 -0
  139. package/dist/physics/handlingProbe.js +606 -0
  140. package/dist/physics/kinematicVehicle.d.ts +37 -2
  141. package/dist/physics/kinematicVehicle.js +15 -0
  142. package/dist/physics/physicsBackend.d.ts +203 -0
  143. package/dist/physics/physicsBackend.js +49 -0
  144. package/dist/physics/physicsBackendConformance.d.ts +27 -0
  145. package/dist/physics/physicsBackendConformance.js +151 -0
  146. package/dist/physics/physicsWorldBackend.d.ts +45 -0
  147. package/dist/physics/physicsWorldBackend.js +518 -0
  148. package/dist/physics/vehicleBackendLink.d.ts +49 -0
  149. package/dist/physics/vehicleBackendLink.js +102 -0
  150. package/dist/physics/vehicleDynamics.d.ts +374 -0
  151. package/dist/physics/vehicleDynamics.js +737 -0
  152. package/dist/physics/worldColliders.d.ts +13 -0
  153. package/dist/physics/worldColliders.js +128 -0
  154. package/dist/puzzle/cellGrid.d.ts +15 -9
  155. package/dist/puzzle/cellGrid.js +13 -9
  156. package/dist/random/rng.d.ts +12 -1
  157. package/dist/random/rng.js +20 -3
  158. package/dist/render/environment.d.ts +23 -0
  159. package/dist/render/environment.js +1 -0
  160. package/dist/render/lookPreset.d.ts +19 -6
  161. package/dist/render/lookPreset.js +56 -4
  162. package/dist/render/postProcessing.d.ts +40 -1
  163. package/dist/render/postProcessing.js +1 -1
  164. package/dist/render/sprite2d.d.ts +38 -0
  165. package/dist/render/sprite2d.js +40 -0
  166. package/dist/runtime/adapter.d.ts +20 -1
  167. package/dist/runtime/adapter.js +8 -1
  168. package/dist/runtime/cameraDirector.d.ts +17 -3
  169. package/dist/runtime/cameraDirector.js +25 -0
  170. package/dist/runtime/commandInput.d.ts +52 -0
  171. package/dist/runtime/commandInput.js +71 -0
  172. package/dist/runtime/commandRunner.d.ts +39 -7
  173. package/dist/runtime/commandRunner.js +35 -5
  174. package/dist/runtime/context/combat.d.ts +1 -0
  175. package/dist/runtime/context/combat.js +2 -1
  176. package/dist/runtime/context/deathLoot.d.ts +2 -0
  177. package/dist/runtime/context/deathLoot.js +1 -0
  178. package/dist/runtime/gameContext.js +18 -1
  179. package/dist/runtime/gameContextTypes.d.ts +33 -7
  180. package/dist/runtime/gameRuntime.d.ts +23 -5
  181. package/dist/runtime/gameRuntime.js +21 -4
  182. package/dist/runtime/headlessRunner.d.ts +10 -0
  183. package/dist/runtime/headlessRunner.js +26 -8
  184. package/dist/runtime/hostPolicy.d.ts +3 -2
  185. package/dist/runtime/hostPolicy.js +8 -10
  186. package/dist/runtime/hostedGameRunner.js +34 -6
  187. package/dist/runtime/hostedWorldSession.d.ts +12 -16
  188. package/dist/runtime/hostedWorldSession.js +29 -6
  189. package/dist/runtime/hostedWorldStore.d.ts +20 -0
  190. package/dist/runtime/hostedWorldStore.js +1 -0
  191. package/dist/runtime/inputRecorder.d.ts +30 -0
  192. package/dist/runtime/inputRecorder.js +59 -0
  193. package/dist/runtime/inputSnapshot.d.ts +14 -0
  194. package/dist/runtime/inputSnapshot.js +11 -0
  195. package/dist/runtime/localPlayers.d.ts +73 -0
  196. package/dist/runtime/localPlayers.js +119 -0
  197. package/dist/runtime/poseInterpolation.d.ts +36 -0
  198. package/dist/runtime/poseInterpolation.js +91 -0
  199. package/dist/runtime/prediction.d.ts +45 -0
  200. package/dist/runtime/prediction.js +62 -0
  201. package/dist/runtime/simContext.d.ts +45 -0
  202. package/dist/runtime/simContext.js +65 -0
  203. package/dist/runtime/simLoop.d.ts +52 -0
  204. package/dist/runtime/simLoop.js +72 -0
  205. package/dist/runtime/simSnapshot.d.ts +32 -0
  206. package/dist/runtime/simSnapshot.js +60 -0
  207. package/dist/runtime/snapshot.d.ts +8 -0
  208. package/dist/runtime/snapshotBuffer.d.ts +33 -0
  209. package/dist/runtime/snapshotBuffer.js +58 -0
  210. package/dist/runtime/transport.d.ts +41 -2
  211. package/dist/runtime/transport.js +7 -1
  212. package/dist/runtime/worldChunks.d.ts +2 -0
  213. package/dist/runtime/worldChunks.js +14 -0
  214. package/dist/runtime/worldProjection.d.ts +9 -0
  215. package/dist/runtime/worldProjection.js +17 -1
  216. package/dist/runtime/worldSnapshot.d.ts +6 -0
  217. package/dist/runtime/worldSnapshot.js +3 -0
  218. package/dist/scene/behaviorRuntime.d.ts +29 -1
  219. package/dist/scene/behaviorRuntime.js +71 -4
  220. package/dist/scene/behaviors.d.ts +12 -1
  221. package/dist/scene/builtinSceneKinds.js +10 -0
  222. package/dist/scene/sceneKinds.d.ts +9 -0
  223. package/dist/scene/vehicleSeat.d.ts +5 -1
  224. package/dist/scene/vehicleSeat.js +5 -1
  225. package/dist/sensor/concealment.d.ts +6 -0
  226. package/dist/sensor/concealment.js +8 -0
  227. package/dist/sensor/freezeMonitor.d.ts +6 -0
  228. package/dist/sensor/freezeMonitor.js +8 -0
  229. package/dist/sensor/perception.d.ts +70 -0
  230. package/dist/sensor/perception.js +189 -0
  231. package/dist/sensor/recordingBuffer.d.ts +3 -0
  232. package/dist/sensor/recordingBuffer.js +8 -0
  233. package/dist/session/roundState.d.ts +4 -0
  234. package/dist/session/roundState.js +11 -0
  235. package/dist/settings/frameRateLimit.d.ts +16 -0
  236. package/dist/settings/frameRateLimit.js +37 -0
  237. package/dist/settings/graphicsProfile.d.ts +21 -0
  238. package/dist/settings/graphicsProfile.js +48 -0
  239. package/dist/settings/graphicsSettings.d.ts +40 -0
  240. package/dist/settings/graphicsSettings.js +81 -0
  241. package/dist/settings/settingsModel.d.ts +9 -1
  242. package/dist/settings/settingsModel.js +10 -0
  243. package/dist/stats/eventMeter.d.ts +1 -1
  244. package/dist/stats/eventMeter.js +1 -1
  245. package/dist/time/accrueSince.d.ts +10 -0
  246. package/dist/time/accrueSince.js +9 -0
  247. package/dist/time/rateWindow.d.ts +16 -0
  248. package/dist/time/rateWindow.js +14 -0
  249. package/dist/time/serverTick.d.ts +21 -3
  250. package/dist/time/serverTick.js +14 -13
  251. package/dist/vfx/feedbackMixer.d.ts +61 -0
  252. package/dist/vfx/feedbackMixer.js +106 -0
  253. package/dist/vfx/particleDirector.d.ts +68 -0
  254. package/dist/vfx/particleDirector.js +81 -0
  255. package/dist/visibility/simulationCulling.d.ts +8 -1
  256. package/dist/visibility/simulationCulling.js +7 -0
  257. package/dist/visibility/spatialIndex.d.ts +21 -0
  258. package/dist/visibility/spatialIndex.js +16 -0
  259. package/dist/world/authoredSolids.d.ts +46 -0
  260. package/dist/world/authoredSolids.js +75 -0
  261. package/dist/world/buildingGenerator.js +2 -2
  262. package/dist/world/buildingKit.d.ts +3 -1
  263. package/dist/world/buildings.d.ts +46 -2
  264. package/dist/world/buildings.js +53 -3
  265. package/dist/world/cityContent.js +103 -25
  266. package/dist/world/cityKind.d.ts +139 -0
  267. package/dist/world/cityKind.js +367 -16
  268. package/dist/world/features.d.ts +56 -3
  269. package/dist/world/features.js +13 -2
  270. package/dist/world/footprintGrid.d.ts +7 -0
  271. package/dist/world/footprintGrid.js +16 -0
  272. package/dist/world/interiors.d.ts +1 -0
  273. package/dist/world/interiors.js +5 -3
  274. package/dist/world/lod.d.ts +6 -0
  275. package/dist/world/lod.js +8 -0
  276. package/dist/world/placement.d.ts +7 -0
  277. package/dist/world/placement.js +2 -0
  278. package/dist/world/placementController.d.ts +1 -1
  279. package/dist/world/placementController.js +7 -0
  280. package/dist/world/raceCircuit.d.ts +168 -0
  281. package/dist/world/raceCircuit.js +797 -0
  282. package/dist/world/terraform.d.ts +8 -0
  283. package/dist/world/terraform.js +7 -0
  284. package/dist/world/terrain.d.ts +5 -1
  285. package/dist/world/terrain.js +5 -0
  286. package/dist/world/territory.d.ts +97 -0
  287. package/dist/world/territory.js +182 -0
  288. package/dist/world/walls.d.ts +7 -0
  289. package/dist/world/walls.js +7 -0
  290. package/dist/world/worldSolids.d.ts +84 -0
  291. package/dist/world/worldSolids.js +240 -0
  292. package/dist/world.d.ts +5 -3
  293. package/dist/world.js +4 -2
  294. package/package.json +1 -1
  295. package/skills/ce-handoff/SKILL.md +2 -2
  296. package/skills/game-design/SKILL.md +11 -1
  297. package/skills/jgengine/SKILL.md +9 -5
  298. package/skills/jgengine/api.md +205 -101
  299. package/skills/jgengine/capabilities.md +56 -0
  300. package/skills/jgengine/recipes/minimal-game.md +3 -0
  301. package/skills/jgengine-combat/SKILL.md +5 -0
  302. package/skills/jgengine-combat/api.md +138 -103
  303. package/skills/jgengine-combat/capabilities.md +29 -1
  304. package/skills/jgengine-combat/recipes/weapon-feel.md +50 -0
  305. package/skills/jgengine-gameplay/SKILL.md +10 -0
  306. package/skills/jgengine-gameplay/api.md +501 -453
  307. package/skills/jgengine-gameplay/capabilities.md +36 -0
  308. package/skills/jgengine-gameplay/reference-systems.md +4 -0
  309. package/skills/jgengine-world/SKILL.md +21 -3
  310. package/skills/jgengine-world/api.md +1172 -625
  311. package/skills/jgengine-world/capabilities.md +229 -3
  312. package/skills/jgengine-world/recipes/character-animation.md +39 -0
  313. package/skills/jgengine-world/recipes/controls.md +44 -0
  314. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  315. package/skills/jgengine-world/recipes/npc-senses.md +43 -0
  316. package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
  317. package/skills/jgengine-world/reference.md +31 -2
  318. package/skills/jgengine-world/visibility.md +112 -0
@@ -0,0 +1,606 @@
1
+ const KMH100 = 100 / 3.6;
2
+ const RAD_TO_DEG = 180 / Math.PI;
3
+ function input(throttle, brake, steer, handbrake = 0) {
4
+ return { throttle, brake, steer, handbrake };
5
+ }
6
+ function sideslipOf(step) {
7
+ return Math.hypot(step.forwardSpeed, step.lateralSpeed) < 1 ? 0 : Math.atan2(Math.abs(step.lateralSpeed), step.forwardSpeed);
8
+ }
9
+ function holdSpeed(target, current) {
10
+ const error = target - current;
11
+ return error >= 0 ? { throttle: Math.min(1, error * 0.6 + 0.25), brake: 0 } : { throttle: 0, brake: Math.min(1, -error * 0.3) };
12
+ }
13
+ function speedOf(subject) {
14
+ const [vx, vz] = subject.velocity();
15
+ return Math.hypot(vx, vz);
16
+ }
17
+ function launchTo(subject, speed, dt) {
18
+ let last = 0;
19
+ for (let i = 0; i < Math.ceil(60 / dt); i += 1) {
20
+ last = subject.tick(dt, input(1, 0, 0)).forwardSpeed;
21
+ if (last >= speed)
22
+ break;
23
+ }
24
+ for (let i = 0; i < Math.ceil(0.5 / dt); i += 1) {
25
+ const hold = holdSpeed(speed, last);
26
+ last = subject.tick(dt, input(hold.throttle, hold.brake, 0)).forwardSpeed;
27
+ }
28
+ return last;
29
+ }
30
+ /**
31
+ * Drives fresh instances from `create` through fixed scenarios (launch, top speed, braking, a slow steer
32
+ * ramp, a step steer, a mid-corner lift-off, a handbrake pull, full throttle with full steer) and reports the feel metrics a test can
33
+ * assert. Deterministic: the same subject and options always produce the same report, so a tuning change
34
+ * shows up as a number moving, not an opinion.
35
+ * @capability handling-metrics measure a vehicle's feel as numbers — 0–100, braking, lateral g, turn-in, sideslip, spin
36
+ */
37
+ export function measureHandling(create, options = {}) {
38
+ const dt = options.dt ?? 1 / 60;
39
+ const cornerSpeed = options.cornerSpeed ?? 25;
40
+ const stepSteer = options.stepSteer ?? 1;
41
+ const liftOffSteer = options.liftOffSteer ?? 0.3;
42
+ let zeroTo100 = Number.POSITIVE_INFINITY;
43
+ let topSpeed = 0;
44
+ {
45
+ const car = create();
46
+ for (let i = 1; i <= Math.ceil(40 / dt); i += 1) {
47
+ const step = car.tick(dt, input(1, 0, 0));
48
+ if (zeroTo100 === Number.POSITIVE_INFINITY && step.forwardSpeed >= KMH100)
49
+ zeroTo100 = i * dt;
50
+ topSpeed = step.forwardSpeed;
51
+ }
52
+ }
53
+ let brake100To0 = Number.POSITIVE_INFINITY;
54
+ {
55
+ const car = create();
56
+ if (launchTo(car, KMH100, dt) >= KMH100 - 1) {
57
+ let distance = 0;
58
+ for (let i = 0; i < Math.ceil(20 / dt); i += 1) {
59
+ const step = car.tick(dt, input(0, 1, 0));
60
+ distance += Math.max(0, step.forwardSpeed) * dt;
61
+ if (step.forwardSpeed <= 0.3) {
62
+ brake100To0 = distance;
63
+ break;
64
+ }
65
+ }
66
+ }
67
+ }
68
+ let maxLateralG = 0;
69
+ {
70
+ const car = create();
71
+ let speed = launchTo(car, 20, dt);
72
+ let previous = car.velocity();
73
+ let filtered = 0;
74
+ for (let i = 0; i < Math.ceil(6 / dt); i += 1) {
75
+ const steer = Math.min(1, (i * dt) / 6);
76
+ const hold = holdSpeed(20, speed);
77
+ const step = car.tick(dt, input(hold.throttle, hold.brake, steer));
78
+ speed = step.forwardSpeed;
79
+ const velocity = car.velocity();
80
+ const ax = (velocity[0] - previous[0]) / dt;
81
+ const az = (velocity[1] - previous[1]) / dt;
82
+ previous = velocity;
83
+ const travel = Math.max(1e-6, Math.hypot(velocity[0], velocity[1]));
84
+ const lateral = Math.abs(ax * velocity[1] - az * velocity[0]) / travel;
85
+ filtered += (lateral - filtered) * 0.1;
86
+ if (sideslipOf(step) < 12 / RAD_TO_DEG)
87
+ maxLateralG = Math.max(maxLateralG, filtered / 9.81);
88
+ }
89
+ }
90
+ let turnIn = Number.POSITIVE_INFINITY;
91
+ let stepPeakSideslipDeg = 0;
92
+ let stepSteadyYawRate = 0;
93
+ let stepSpeedRetained = 0;
94
+ let spun = false;
95
+ {
96
+ const car = create();
97
+ const entry = launchTo(car, cornerSpeed, dt);
98
+ const yaw = [];
99
+ let last = { forwardSpeed: entry, lateralSpeed: 0, yawRate: 0 };
100
+ for (let i = 0; i < Math.ceil(3 / dt); i += 1) {
101
+ last = car.tick(dt, input(0.35, 0, stepSteer));
102
+ yaw.push(Math.abs(last.yawRate));
103
+ stepPeakSideslipDeg = Math.max(stepPeakSideslipDeg, sideslipOf(last) * RAD_TO_DEG);
104
+ }
105
+ const window = yaw.slice(0, Math.ceil(1.5 / dt));
106
+ const peak = Math.max(...window);
107
+ const index = window.findIndex((value) => value >= peak * 0.9);
108
+ if (peak > 1e-3 && index >= 0)
109
+ turnIn = (index + 1) * dt;
110
+ stepSteadyYawRate = Math.abs(last.yawRate);
111
+ stepSpeedRetained = entry > 0 ? speedOf(car) / entry : 0;
112
+ spun = sideslipOf(last) > 60 / RAD_TO_DEG || last.forwardSpeed < 0;
113
+ }
114
+ let liftOffYawGain = 1;
115
+ {
116
+ const car = create();
117
+ let speed = launchTo(car, cornerSpeed, dt);
118
+ let yawBefore = 0;
119
+ for (let i = 0; i < Math.ceil(2.5 / dt); i += 1) {
120
+ const hold = holdSpeed(cornerSpeed, speed);
121
+ const step = car.tick(dt, input(hold.throttle, hold.brake, liftOffSteer));
122
+ speed = step.forwardSpeed;
123
+ yawBefore = Math.abs(step.yawRate);
124
+ }
125
+ let yawAfter = yawBefore;
126
+ for (let i = 0; i < Math.ceil(0.5 / dt); i += 1)
127
+ yawAfter = Math.abs(car.tick(dt, input(0, 0, liftOffSteer)).yawRate);
128
+ liftOffYawGain = yawBefore > 1e-3 ? yawAfter / yawBefore : 1;
129
+ }
130
+ let handbrakePeakSideslipDeg = 0;
131
+ let handbrakeRecoverySeconds = Number.POSITIVE_INFINITY;
132
+ {
133
+ const car = create();
134
+ launchTo(car, 20, dt);
135
+ const pullTicks = Math.ceil(0.6 / dt);
136
+ let settledSince = -1;
137
+ for (let i = 0; i < pullTicks + Math.ceil(3 / dt); i += 1) {
138
+ const pulling = i < pullTicks;
139
+ const step = car.tick(dt, input(0, 0, pulling ? 1 : 0, pulling ? 1 : 0));
140
+ const slip = sideslipOf(step) * RAD_TO_DEG;
141
+ if (i * dt < 1.5)
142
+ handbrakePeakSideslipDeg = Math.max(handbrakePeakSideslipDeg, slip);
143
+ if (pulling)
144
+ continue;
145
+ if (slip >= 5)
146
+ settledSince = -1;
147
+ else if (settledSince < 0)
148
+ settledSince = i;
149
+ if (settledSince >= 0 && (i - settledSince) * dt >= 0.3) {
150
+ handbrakeRecoverySeconds = (settledSince - pullTicks) * dt;
151
+ break;
152
+ }
153
+ }
154
+ }
155
+ let powerSteerPeakSideslipDeg = 0;
156
+ let powerSteerSpun = false;
157
+ {
158
+ const car = create();
159
+ launchTo(car, 12, dt);
160
+ let last = { forwardSpeed: 0, lateralSpeed: 0, yawRate: 0 };
161
+ for (let i = 0; i < Math.ceil(2 / dt); i += 1) {
162
+ last = car.tick(dt, input(1, 0, 1));
163
+ powerSteerPeakSideslipDeg = Math.max(powerSteerPeakSideslipDeg, sideslipOf(last) * RAD_TO_DEG);
164
+ }
165
+ powerSteerSpun = sideslipOf(last) > 60 / RAD_TO_DEG || last.forwardSpeed < 0;
166
+ }
167
+ return {
168
+ zeroTo100,
169
+ topSpeed,
170
+ brake100To0,
171
+ maxLateralG,
172
+ turnIn,
173
+ stepPeakSideslipDeg,
174
+ stepSteadyYawRate,
175
+ stepSpeedRetained,
176
+ spun,
177
+ liftOffYawGain,
178
+ handbrakePeakSideslipDeg,
179
+ handbrakeRecoverySeconds,
180
+ powerSteerPeakSideslipDeg,
181
+ powerSteerSpun,
182
+ };
183
+ }
184
+ /**
185
+ * Drives fresh sprung vehicles from `create` through a vertical kick, a hard stop, a steady corner and a
186
+ * launch-and-land, and reports how the body moves: how fast it settles, how much it dives and rolls, and
187
+ * whether a landing bounces.
188
+ * @capability ride-metrics measure a sprung vehicle's ride — settle time, dive, roll per g, landing bounce
189
+ */
190
+ export function measureRide(create, options = {}) {
191
+ const dt = options.dt ?? 1 / 60;
192
+ const neutral = input(0, 0, 0);
193
+ let settleSeconds = Number.POSITIVE_INFINITY;
194
+ let heaveOvershoot = 0;
195
+ {
196
+ const car = create();
197
+ const rest = car.tick(dt, neutral).position[1];
198
+ car.applyImpulse(0, 0, 1);
199
+ let lastOutside = 0;
200
+ let rise = 0;
201
+ let drop = 0;
202
+ const ticks = Math.ceil(6 / dt);
203
+ for (let i = 1; i <= ticks; i += 1) {
204
+ const offset = car.tick(dt, neutral).position[1] - rest;
205
+ rise = Math.max(rise, offset);
206
+ drop = Math.max(drop, -offset);
207
+ if (Math.abs(offset) > 0.005)
208
+ lastOutside = i;
209
+ }
210
+ if (lastOutside < ticks)
211
+ settleSeconds = lastOutside * dt;
212
+ heaveOvershoot = rise > 0 ? drop / rise : 0;
213
+ }
214
+ let brakeDiveDeg = 0;
215
+ {
216
+ const car = create();
217
+ let speed = 0;
218
+ for (let i = 0; i < Math.ceil(30 / dt) && speed < 25; i += 1)
219
+ speed = car.tick(dt, input(1, 0, 0)).forwardSpeed;
220
+ for (let i = 0; i < Math.ceil(1.5 / dt); i += 1)
221
+ brakeDiveDeg = Math.max(brakeDiveDeg, car.tick(dt, input(0, 1, 0)).bodyPitch * RAD_TO_DEG);
222
+ }
223
+ let rollGradient = 0;
224
+ {
225
+ const car = create();
226
+ let speed = 0;
227
+ for (let i = 0; i < Math.ceil(30 / dt) && speed < 20; i += 1)
228
+ speed = car.tick(dt, input(1, 0, 0)).forwardSpeed;
229
+ let step = car.tick(dt, neutral);
230
+ for (let i = 0; i < Math.ceil(2.5 / dt); i += 1) {
231
+ const hold = holdSpeed(20, step.forwardSpeed);
232
+ step = car.tick(dt, input(hold.throttle, hold.brake, 0.5));
233
+ }
234
+ const g = Math.abs(step.lateralAccel) / 9.81;
235
+ rollGradient = g > 0.05 ? (Math.abs(step.bodyRoll) * RAD_TO_DEG) / g : 0;
236
+ }
237
+ let landingBounces = 0;
238
+ {
239
+ const car = create();
240
+ car.tick(dt, neutral);
241
+ car.applyImpulse(0, 0, 4);
242
+ let wasAirborne = false;
243
+ let landed = false;
244
+ for (let i = 0; i < Math.ceil(4 / dt); i += 1) {
245
+ const airborne = car.tick(dt, neutral).airborne;
246
+ if (wasAirborne && !airborne)
247
+ landed = true;
248
+ else if (landed && !wasAirborne && airborne)
249
+ landingBounces += 1;
250
+ wasAirborne = airborne;
251
+ }
252
+ }
253
+ return { settleSeconds, heaveOvershoot, brakeDiveDeg, rollGradient, landingBounces };
254
+ }
255
+ /**
256
+ * Jumps a fresh vehicle from rest and holds full air input on each axis, reporting jump height and timing,
257
+ * double-jump height, and how fast the body rotates in the air.
258
+ * @capability air-metrics measure a vehicle's jumps and air control — apex height and time, double jump, air rotation rates
259
+ */
260
+ export function measureAir(create, options = {}) {
261
+ const dt = options.dt ?? 1 / 60;
262
+ const neutral = input(0, 0, 0);
263
+ const flight = (secondJumpAtApex) => {
264
+ const car = create();
265
+ const rest = car.tick(dt, neutral).position[1];
266
+ car.jump();
267
+ let apex = 0;
268
+ let apexTime = 0;
269
+ let rising = true;
270
+ let last = rest;
271
+ for (let i = 1; i <= Math.ceil(4 / dt); i += 1) {
272
+ const y = car.tick(dt, neutral).position[1];
273
+ if (rising && y < last) {
274
+ rising = false;
275
+ if (secondJumpAtApex)
276
+ car.jump();
277
+ }
278
+ if (y - rest > apex) {
279
+ apex = y - rest;
280
+ apexTime = i * dt;
281
+ }
282
+ last = y;
283
+ }
284
+ return { apex, apexTime };
285
+ };
286
+ const single = flight(false);
287
+ const double = flight(true);
288
+ const rateAfter = (air, key) => {
289
+ const car = create();
290
+ car.tick(dt, neutral);
291
+ car.jump();
292
+ for (let i = 0; i < Math.ceil(0.4 / dt); i += 1)
293
+ car.tick(dt, neutral, { air });
294
+ return Math.abs(car.snapshot()[key]);
295
+ };
296
+ return {
297
+ jumpApexHeight: single.apex,
298
+ jumpApexTime: single.apexTime,
299
+ doubleJumpApexHeight: double.apex,
300
+ airPitchRate: rateAfter({ pitch: 1, yaw: 0, roll: 0 }, "pitchRate"),
301
+ airYawRate: rateAfter({ pitch: 0, yaw: 1, roll: 0 }, "yawRate"),
302
+ airRollRate: rateAfter({ pitch: 0, yaw: 0, roll: 1 }, "rollRate"),
303
+ };
304
+ }
305
+ function wrapAngle(angle) {
306
+ let a = angle;
307
+ while (a > Math.PI)
308
+ a -= 2 * Math.PI;
309
+ while (a < -Math.PI)
310
+ a += 2 * Math.PI;
311
+ return a;
312
+ }
313
+ /**
314
+ * Drives a fresh vehicle from `create` through a steer ramp, a skidpad and a slalom with simple
315
+ * deterministic drivers, and reports understeer gradient, skidpad g and the fastest clean slalom.
316
+ * @capability course-metrics measure a vehicle on driven tests — understeer gradient, skidpad g, slalom speed
317
+ */
318
+ export function measureCourse(create, options) {
319
+ const dt = options.dt ?? 1 / 60;
320
+ const wheelbase = options.wheelbase;
321
+ const radius = options.skidpadRadius ?? 30;
322
+ const spacing = options.slalomSpacing ?? 18;
323
+ const offset = options.slalomOffset ?? 3.5;
324
+ let understeerGradient = Number.NaN;
325
+ {
326
+ const car = create();
327
+ let speed = launchTo(car, 20, dt);
328
+ let sum = 0;
329
+ let count = 0;
330
+ for (let i = 0; i < Math.ceil(8 / dt); i += 1) {
331
+ const steer = Math.min(1, (i * dt) / 8) * 0.5;
332
+ const hold = holdSpeed(20, speed);
333
+ const step = car.tick(dt, input(hold.throttle, hold.brake, steer));
334
+ speed = step.forwardSpeed;
335
+ const lateralG = Math.abs(step.yawRate * speed) / 9.81;
336
+ if (lateralG < 0.1 || lateralG > 0.4 || speed < 5 || sideslipOf(step) > 4 / RAD_TO_DEG)
337
+ continue;
338
+ const geometric = Math.atan((wheelbase * Math.abs(step.yawRate)) / speed);
339
+ sum += ((Math.abs(step.steerAngle) - geometric) * RAD_TO_DEG) / lateralG;
340
+ count += 1;
341
+ }
342
+ if (count > 0)
343
+ understeerGradient = sum / count;
344
+ }
345
+ let skidpadG = 0;
346
+ {
347
+ const car = create();
348
+ let speed = launchTo(car, 8, dt);
349
+ let target = 8;
350
+ let steer = 0;
351
+ for (let i = 0; i < Math.ceil(60 / dt); i += 1) {
352
+ target += 0.25 * dt;
353
+ const hold = holdSpeed(target, speed);
354
+ const step = car.tick(dt, input(hold.throttle, hold.brake, steer));
355
+ speed = step.forwardSpeed;
356
+ const wanted = speed / radius;
357
+ const actual = -step.yawRate;
358
+ steer = Math.max(0, Math.min(1, steer + 1.5 * (wanted - actual) * dt));
359
+ const holding = Math.abs(actual - wanted) < wanted * 0.03 && sideslipOf(step) < 12 / RAD_TO_DEG;
360
+ if (i * dt > 3 && holding)
361
+ skidpadG = Math.max(skidpadG, (speed * speed) / radius / 9.81);
362
+ if (steer >= 1 && actual < wanted * 0.9)
363
+ break;
364
+ if (sideslipOf(step) > 45 / RAD_TO_DEG)
365
+ break;
366
+ }
367
+ }
368
+ let slalomSpeed = 0;
369
+ const lineX = (z) => offset * Math.sin((Math.PI * z) / spacing);
370
+ const cleanRun = (entry) => {
371
+ const car = create();
372
+ let speed = launchTo(car, entry, dt);
373
+ const start = car.pose().position;
374
+ let cone = 0;
375
+ for (let i = 0; i < Math.ceil(30 / dt); i += 1) {
376
+ const pose = car.pose();
377
+ const z = pose.position[2] - start[2];
378
+ const coneZ = spacing * (cone + 0.5);
379
+ if (z >= coneZ) {
380
+ const side = Math.sign(lineX(coneZ));
381
+ if ((pose.position[0] - start[0]) * side < 1.2)
382
+ return false;
383
+ cone += 1;
384
+ if (cone === 8)
385
+ return true;
386
+ }
387
+ const lookahead = Math.max(5, speed * 0.35);
388
+ const tx = start[0] + lineX(z + lookahead);
389
+ const tz = pose.position[2] + lookahead;
390
+ const bearing = Math.atan2(tx - pose.position[0], tz - pose.position[2]);
391
+ const error = wrapAngle(bearing - pose.heading);
392
+ const hold = holdSpeed(entry, speed);
393
+ const step = car.tick(dt, input(hold.throttle, hold.brake, Math.max(-1, Math.min(1, -error * 3))));
394
+ speed = step.forwardSpeed;
395
+ if (sideslipOf(step) > 30 / RAD_TO_DEG)
396
+ return false;
397
+ }
398
+ return false;
399
+ };
400
+ for (let entry = 8; entry <= 40; entry += 1) {
401
+ if (!cleanRun(entry))
402
+ break;
403
+ slalomSpeed = entry;
404
+ }
405
+ return { understeerGradient, skidpadG, slalomSpeed };
406
+ }
407
+ /**
408
+ * Brings a fresh bike to `speed` and holds full steer, reporting how far and how fast it leans and how much it
409
+ * tips the other way first.
410
+ * @capability lean-metrics measure a motorcycle's lean — steady lean, time to lean, countersteer tip-in
411
+ */
412
+ export function measureLean(create, options = {}) {
413
+ const dt = options.dt ?? 1 / 60;
414
+ const speed = options.speed ?? 20;
415
+ const bike = create();
416
+ let current = launchTo(bike, speed, dt);
417
+ const leans = [];
418
+ for (let i = 0; i < Math.ceil(2 / dt); i += 1) {
419
+ const hold = holdSpeed(speed, current);
420
+ const step = bike.tick(dt, input(hold.throttle, hold.brake, 1));
421
+ current = step.forwardSpeed;
422
+ leans.push(step.lean);
423
+ }
424
+ const steady = leans[leans.length - 1] ?? 0;
425
+ const index = leans.findIndex((lean) => Math.sign(lean) === Math.sign(steady) && Math.abs(lean) >= Math.abs(steady) * 0.9);
426
+ let counter = 0;
427
+ for (let i = 0; i < Math.min(leans.length, Math.ceil(0.4 / dt)); i += 1) {
428
+ if (Math.sign(leans[i]) === -Math.sign(steady))
429
+ counter = Math.max(counter, Math.abs(leans[i]));
430
+ }
431
+ return {
432
+ steadyLeanDeg: Math.abs(steady) * RAD_TO_DEG,
433
+ timeToLean: index < 0 ? Number.POSITIVE_INFINITY : (index + 1) * dt,
434
+ counterLeanDeg: counter * RAD_TO_DEG,
435
+ };
436
+ }
437
+ function flightInput(throttle, pitch, roll, collective = 0) {
438
+ return { throttle, pitch: Math.max(-1, Math.min(1, pitch)), roll: Math.max(-1, Math.min(1, roll)), yaw: 0, collective };
439
+ }
440
+ function wrapPi(angle) {
441
+ let a = angle;
442
+ while (a > Math.PI)
443
+ a -= 2 * Math.PI;
444
+ while (a < -Math.PI)
445
+ a += 2 * Math.PI;
446
+ return a;
447
+ }
448
+ /** A PI loop on vertical speed toward a target altitude, with pitch-rate damping; returns the pitch command. */
449
+ function createAltitudeHold(targetY) {
450
+ let integral = 0;
451
+ return (step, dt) => {
452
+ const climbTarget = Math.max(-5, Math.min(5, 0.2 * (targetY - step.position[1])));
453
+ const error = climbTarget - step.velocity[1];
454
+ integral = Math.max(-1, Math.min(1, integral + 0.03 * error * dt));
455
+ return integral + 0.05 * error - 0.3 * step.pitchRate;
456
+ };
457
+ }
458
+ function bankHold(step, target) {
459
+ return 2 * wrapPi(target - step.bank) - 0.5 * step.rollRate;
460
+ }
461
+ /**
462
+ * Flies fresh aircraft from `create` through fixed scenarios with simple deterministic autopilots: a full-aileron
463
+ * roll, banked turns at full throttle, an idle deceleration holding altitude, a full-throttle climb at cruise
464
+ * speed, a throttle step from idle, and a hands-off hover. `create` gets a spawn to pass straight into
465
+ * `createRigidAircraft`. Deterministic, so a tuning change shows up as a number moving.
466
+ * @capability flight-metrics measure an aircraft's feel as numbers — roll rate, sustained turn, stall speed, climb, throttle response, hover drift
467
+ */
468
+ export function measureFlight(create, options = {}) {
469
+ const dt = options.dt ?? 1 / 60;
470
+ const cruise = options.cruiseSpeed ?? 60;
471
+ const probe = create({ position: [0, 0, 0], velocity: [0, 0, 0] });
472
+ const tuning = probe.tuning();
473
+ const rotor = tuning.rotor;
474
+ const steps = (seconds) => Math.ceil(seconds / dt);
475
+ if (rotor !== undefined) {
476
+ const altitude = options.altitude ?? 50;
477
+ const collective = options.hoverCollective ?? Math.min(1, (tuning.massKg * 9.81) / rotor.maxThrust);
478
+ const hovering = () => {
479
+ const aircraft = create({ position: [0, altitude, 0], velocity: [0, 0, 0] });
480
+ aircraft.restore({ ...aircraft.snapshot(), rotorSpeed: 1 });
481
+ return aircraft;
482
+ };
483
+ let rollRate = 0;
484
+ {
485
+ const aircraft = hovering();
486
+ for (let i = 0; i < steps(2); i += 1)
487
+ rollRate = Math.max(rollRate, Math.abs(aircraft.tick(dt, flightInput(1, 0, 1, collective)).rollRate));
488
+ }
489
+ let climbRate = 0;
490
+ {
491
+ const aircraft = hovering();
492
+ for (let i = 0; i < steps(5); i += 1)
493
+ climbRate = aircraft.tick(dt, flightInput(1, 0, 0, 1)).velocity[1];
494
+ }
495
+ let drift = 0;
496
+ {
497
+ const aircraft = hovering();
498
+ let step = aircraft.tick(dt, flightInput(1, 0, 0, collective));
499
+ for (let i = 1; i < steps(10); i += 1)
500
+ step = aircraft.tick(dt, flightInput(1, 0, 0, collective));
501
+ drift = Math.hypot(step.position[0], step.position[2]);
502
+ }
503
+ const response = throttleStep(() => {
504
+ const aircraft = create({ position: [0, altitude, 0], velocity: [0, 0, 0] });
505
+ aircraft.restore({ ...aircraft.snapshot(), rotorSpeed: 0 });
506
+ return aircraft;
507
+ }, (step) => (step.rotor?.speed ?? 0) ** 2, collective, dt);
508
+ return {
509
+ rollRateDeg: rollRate * RAD_TO_DEG,
510
+ sustainedTurnRateDeg: Number.NaN,
511
+ stallSpeed: Number.NaN,
512
+ climbRate,
513
+ throttleResponse: response,
514
+ hoverDriftMeters: drift,
515
+ };
516
+ }
517
+ const altitude = options.altitude ?? 1000;
518
+ const cruising = () => create({ position: [0, altitude, 0], velocity: [0, 0, cruise] });
519
+ let rollRate = 0;
520
+ {
521
+ const aircraft = cruising();
522
+ for (let i = 0; i < steps(3); i += 1)
523
+ rollRate = Math.max(rollRate, Math.abs(aircraft.tick(dt, flightInput(0.5, 0, 1)).rollRate));
524
+ }
525
+ let sustained = 0;
526
+ for (let bankDeg = 10; bankDeg <= 85; bankDeg += 5) {
527
+ const aircraft = cruising();
528
+ const hold = createAltitudeHold(altitude);
529
+ const bank = bankDeg / RAD_TO_DEG;
530
+ let step = aircraft.tick(dt, flightInput(1, 0, 0));
531
+ let speedAt20 = 0;
532
+ let headingAt20 = 0;
533
+ let turned = 0;
534
+ let failed = false;
535
+ for (let i = 1; i < steps(30); i += 1) {
536
+ const previous = step.heading;
537
+ step = aircraft.tick(dt, flightInput(1, hold(step, dt), bankHold(step, bank)));
538
+ turned += wrapPi(step.heading - previous);
539
+ if (i === steps(20)) {
540
+ speedAt20 = step.airspeed;
541
+ headingAt20 = turned;
542
+ }
543
+ if (step.stalled || Math.abs(step.position[1] - altitude) > 60)
544
+ failed = true;
545
+ }
546
+ if (failed || step.airspeed < speedAt20 - 2)
547
+ continue;
548
+ sustained = Math.max(sustained, Math.abs(turned - headingAt20) / 10);
549
+ }
550
+ let stallSpeed = Number.NaN;
551
+ {
552
+ const aircraft = cruising();
553
+ const hold = createAltitudeHold(altitude);
554
+ let step = aircraft.tick(dt, flightInput(0, 0, 0));
555
+ for (let i = 0; i < steps(120); i += 1) {
556
+ step = aircraft.tick(dt, flightInput(0, hold(step, dt), bankHold(step, 0)));
557
+ if (step.stalled || step.velocity[1] < -3) {
558
+ stallSpeed = step.airspeed;
559
+ break;
560
+ }
561
+ }
562
+ }
563
+ let climbRate = 0;
564
+ {
565
+ const aircraft = cruising();
566
+ let integral = 0;
567
+ let step = aircraft.tick(dt, flightInput(1, 0, 0));
568
+ let sum = 0;
569
+ for (let i = 1; i < steps(30); i += 1) {
570
+ // Speed above cruise pitches the nose up (trading it for climb); an attitude loop flies that pitch.
571
+ const error = step.airspeed - cruise;
572
+ integral = Math.max(-0.3, Math.min(0.6, integral + 0.005 * error * dt));
573
+ const pitchTarget = Math.max(-0.3, Math.min(0.8, integral + 0.02 * error));
574
+ step = aircraft.tick(dt, flightInput(1, 3 * (pitchTarget - step.pitch) - 0.5 * step.pitchRate, bankHold(step, 0)));
575
+ if (i >= steps(25))
576
+ sum += step.velocity[1];
577
+ }
578
+ climbRate = sum / (steps(30) - steps(25));
579
+ }
580
+ const response = tuning.engine === undefined
581
+ ? Number.NaN
582
+ : throttleStep(() => {
583
+ const aircraft = cruising();
584
+ aircraft.restore({ ...aircraft.snapshot(), spool: 0 });
585
+ return aircraft;
586
+ }, (step) => step.thrust, 0, dt);
587
+ return {
588
+ rollRateDeg: rollRate * RAD_TO_DEG,
589
+ sustainedTurnRateDeg: sustained * RAD_TO_DEG,
590
+ stallSpeed,
591
+ climbRate,
592
+ throttleResponse: response,
593
+ hoverDriftMeters: Number.NaN,
594
+ };
595
+ }
596
+ function throttleStep(create, thrustOf, collective, dt) {
597
+ const trace = [];
598
+ const aircraft = create();
599
+ for (let i = 0; i < Math.ceil(20 / dt); i += 1)
600
+ trace.push(thrustOf(aircraft.tick(dt, flightInput(1, 0, 0, collective))));
601
+ const final = trace[trace.length - 1] ?? 0;
602
+ if (final <= 0)
603
+ return Number.POSITIVE_INFINITY;
604
+ const index = trace.findIndex((thrust) => thrust >= final * 0.9);
605
+ return index < 0 ? Number.POSITIVE_INFINITY : (index + 1) * dt;
606
+ }
@@ -1,4 +1,5 @@
1
- import type { AxisInput } from "../input/axisInput.js";
1
+ import type { AxisBinding, AxisInput } from "../input/axisInput.js";
2
+ import type { ChaseCameraTuning } from "../runtime/cameraDirector.js";
2
3
  import { type GripCurve } from "./vehicleBody.js";
3
4
  export interface KinematicVehicleTuning {
4
5
  engineAccel: number;
@@ -36,7 +37,10 @@ export interface KinematicVehicleTuning {
36
37
  hopGravity?: number;
37
38
  /** Optional higher-fidelity powertrain. Omit to preserve the direct arcade acceleration model. */
38
39
  powertrain?: KinematicPowertrainTuning;
39
- /** Optional bicycle-model steering with speed-sensitive lock and smoothed rack response. */
40
+ /**
41
+ * Optional bicycle-model steering with speed-sensitive lock and smoothed rack response.
42
+ * @deprecated Heading here is still commanded from steer, so it can't produce real understeer or oversteer. Use `createVehicleDynamics` (`physics/vehicleDynamics`) when handling matters.
43
+ */
40
44
  steering?: KinematicSteeringTuning;
41
45
  /** Optional aerodynamic drag/downforce and electronic driver assists. */
42
46
  dynamics?: KinematicDynamicsTuning;
@@ -45,8 +49,13 @@ export interface KinematicVehicleTuning {
45
49
  * with force/mass dynamics — a tire friction budget, lateral weight transfer, and engine braking — so
46
50
  * vehicles of different mass, drive/brake force, and center-of-gravity drive measurably differently.
47
51
  * Omit to preserve the direct arcade acceleration model exactly.
52
+ * @deprecated The friction budget clamps lateral speed while heading keeps turning at the commanded rate, so a held steer at speed spins the car. Use `createVehicleDynamics` (`physics/vehicleDynamics`), whose yaw comes from tire forces.
48
53
  */
49
54
  chassis?: KinematicChassisTuning;
55
+ /** Per-axis bindings for this vehicle — which actions drive throttle/brake/steer/handbrake. Unlisted axes keep car defaults (W/S/A/D/Space). */
56
+ bindings?: Partial<Record<"throttle" | "brake" | "steer" | "handbrake", AxisBinding>>;
57
+ /** Camera POV while this vehicle is piloted. Nothing applies it automatically: pass it to `ctx.camera.setChaseTuning` when the driver enters and `null` when they exit. */
58
+ camera?: ChaseCameraTuning | null;
50
59
  }
51
60
  /**
52
61
  * Mass-and-force chassis layer (#1051); when present it supersedes `engineAccel`/`brakeAccel` with
@@ -179,6 +188,25 @@ export interface KinematicVehicleStep {
179
188
  /** `true` while `airOffset > 0` — gate landing effects, air control, and trick scoring off this. */
180
189
  airborne: boolean;
181
190
  }
191
+ /** Serializable integrator state of a {@link KinematicVehicle}: what `restore` needs to resume bit-for-bit. */
192
+ export interface KinematicVehicleState {
193
+ x: number;
194
+ y: number;
195
+ z: number;
196
+ heading: number;
197
+ vx: number;
198
+ vz: number;
199
+ gearIndex: number;
200
+ rpm: number;
201
+ shiftRemaining: number;
202
+ steerAngle: number;
203
+ yawRate: number;
204
+ previousForwardSpeed: number;
205
+ bodyPitch: number;
206
+ bodyRoll: number;
207
+ airOffset: number;
208
+ verticalVelocity: number;
209
+ }
182
210
  /**
183
211
  * The pure-kinematic arcade car every racing game hand-rolled (#282.1): steer-yaw scaled by speed,
184
212
  * throttle/brake acceleration, and a grip-curve lateral-slip bleed — no `PhysicsWorld`, no wheels,
@@ -207,8 +235,15 @@ export interface KinematicVehicle {
207
235
  * would. For a change that lasts a single tick, pass {@link KinematicVehicleModifiers} instead.
208
236
  */
209
237
  retune(next: KinematicVehicleTuning): void;
238
+ snapshot(): KinematicVehicleState;
239
+ restore(state: KinematicVehicleState): void;
210
240
  resetTo(position: readonly [number, number, number], heading: number): void;
211
241
  }
242
+ /**
243
+ * Arcade ground-vehicle sim (car, kart, bike) with throttle, brake, steer, drift and retunable handling.
244
+ *
245
+ * @capability kinematic-vehicle arcade car/kart driving physics with throttle, brake, steer and retunable handling
246
+ */
212
247
  export declare function createKinematicVehicle(initialTuning: KinematicVehicleTuning, options?: KinematicVehicleOptions): KinematicVehicle;
213
248
  /** Default reverse drive force as a fraction of forward `engineForce` / `engineAccel`. */
214
249
  export declare const DEFAULT_REVERSE_FORCE_SCALE = 0.48;