lecodes-sdk 0.20.2 → 1.1.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 (129) hide show
  1. package/dist/global.d.ts +35 -9
  2. package/dist/types/animate/tween/Animation.d.ts +69 -0
  3. package/dist/types/animate/tween/Timeline.d.ts +55 -0
  4. package/dist/types/animate/tween/animateValue.d.ts +27 -0
  5. package/dist/types/animate/tween/easing.d.ts +29 -0
  6. package/dist/types/animate/tween/spec.d.ts +178 -0
  7. package/dist/types/canvas/Canvas.d.ts +2 -0
  8. package/dist/types/g2/Node2D.d.ts +16 -0
  9. package/dist/types/g2/Sprite.d.ts +11 -1
  10. package/dist/types/gl/Camera.d.ts +15 -1
  11. package/dist/types/gl/DecalSet.d.ts +48 -3
  12. package/dist/types/gl/Foliage.d.ts +47 -0
  13. package/dist/types/gl/Geometry.d.ts +36 -0
  14. package/dist/types/gl/Light.d.ts +25 -7
  15. package/dist/types/gl/Lightmap.d.ts +90 -51
  16. package/dist/types/gl/Material.d.ts +32 -20
  17. package/dist/types/gl/Mesh.d.ts +7 -1
  18. package/dist/types/gl/Model.d.ts +41 -5
  19. package/dist/types/gl/Node.d.ts +18 -0
  20. package/dist/types/gl/Particles.d.ts +53 -1
  21. package/dist/types/gl/Scene.d.ts +21 -1
  22. package/dist/types/gl/animation/AnimationClip.d.ts +19 -0
  23. package/dist/types/gl/animation/Animator.d.ts +27 -0
  24. package/dist/types/gl/animation/DynamicBone.d.ts +184 -0
  25. package/dist/types/gl/animation/IK.d.ts +109 -0
  26. package/dist/types/gl/{Locomotion.d.ts → animation/Locomotion.d.ts} +6 -6
  27. package/dist/types/gl/animation/Warp.d.ts +2 -1
  28. package/dist/types/gl/animation/core.d.ts +35 -4
  29. package/dist/types/gl/{AudioSource.d.ts → audio/AudioSource.d.ts} +6 -6
  30. package/dist/types/gl/{AudioZone.d.ts → audio/AudioZone.d.ts} +4 -4
  31. package/dist/types/gl/{SceneAudio.d.ts → audio/SceneAudio.d.ts} +1 -1
  32. package/dist/types/gl/{NavAgent.d.ts → nav/NavAgent.d.ts} +4 -4
  33. package/dist/types/gl/{NavMesh.d.ts → nav/NavMesh.d.ts} +4 -4
  34. package/dist/types/gl/{CharacterController.d.ts → physics/CharacterController.d.ts} +5 -5
  35. package/dist/types/gl/{Physics.d.ts → physics/Physics.d.ts} +6 -5
  36. package/dist/types/gl/physics/Ragdoll.d.ts +161 -0
  37. package/dist/types/gl/{Shape.d.ts → physics/Shape.d.ts} +3 -3
  38. package/dist/types/gl/{Trigger.d.ts → physics/Trigger.d.ts} +2 -2
  39. package/dist/types/gl/{Terrain.d.ts → terrain/Terrain.d.ts} +10 -8
  40. package/dist/types/gl/{terrainMesh.d.ts → terrain/terrainMesh.d.ts} +1 -1
  41. package/dist/types/gl/vehicle/Vehicle.d.ts +300 -0
  42. package/dist/types/gl/vehicle/Wheel.d.ts +147 -0
  43. package/dist/types/inject.d.ts +35 -27
  44. package/dist/types/runtime/files.d.ts +24 -1
  45. package/dist/types/scene/defineScene.d.ts +50 -31
  46. package/dist/types/ui/UIButton.d.ts +3 -1
  47. package/dist/types/ui/UIInput.d.ts +5 -1
  48. package/dist/types/ui/UINode.d.ts +24 -24
  49. package/dist/types.json +1 -1
  50. package/package.json +1 -1
  51. package/prompts/README.md +142 -142
  52. package/prompts/core-design.md +27 -4
  53. package/prompts/core.md +35 -6
  54. package/prompts/select.ts +19 -4
  55. package/src/animate/tween/Animation.ts +378 -0
  56. package/src/animate/tween/Timeline.ts +175 -0
  57. package/src/animate/tween/animateValue.ts +100 -0
  58. package/src/animate/tween/easing.ts +172 -0
  59. package/src/animate/tween/spec.ts +479 -0
  60. package/src/bridges.d.ts +1760 -1481
  61. package/src/canvas/Canvas.ts +21 -0
  62. package/src/compile/__tests__/assetMacro.test.ts +26 -0
  63. package/src/compile/__tests__/compile.test.ts +11 -0
  64. package/src/compile/__tests__/detectEntry.test.ts +19 -0
  65. package/src/compile/__tests__/serverSplit.test.ts +27 -0
  66. package/src/compile/bundler.ts +34 -4
  67. package/src/compile/compileProject.ts +31 -1
  68. package/src/compile/detectEntry.ts +8 -3
  69. package/src/compile/header.ts +6 -3
  70. package/src/compile/index.ts +3 -0
  71. package/src/compile/sceneEditor.ts +42 -1
  72. package/src/compile/serverSplit.ts +9 -3
  73. package/src/g2/Node2D.ts +38 -0
  74. package/src/g2/Sprite.ts +20 -1
  75. package/src/gl/Camera.ts +34 -1
  76. package/src/gl/DecalSet.ts +132 -5
  77. package/src/gl/Foliage.ts +102 -0
  78. package/src/gl/Geometry.ts +109 -0
  79. package/src/gl/Light.ts +46 -16
  80. package/src/gl/Lightmap.ts +440 -249
  81. package/src/gl/Material.ts +69 -36
  82. package/src/gl/Mesh.ts +120 -102
  83. package/src/gl/Model.ts +167 -124
  84. package/src/gl/Node.ts +40 -1
  85. package/src/gl/Particles.ts +82 -5
  86. package/src/gl/Scene.ts +35 -2
  87. package/src/gl/animation/AnimationClip.ts +52 -0
  88. package/src/gl/animation/Animator.ts +42 -2
  89. package/src/gl/animation/DynamicBone.ts +482 -0
  90. package/src/gl/animation/IK.ts +214 -0
  91. package/src/gl/{Locomotion.ts → animation/Locomotion.ts} +7 -7
  92. package/src/gl/animation/Playback.ts +5 -4
  93. package/src/gl/animation/Warp.ts +5 -2
  94. package/src/gl/animation/core.ts +65 -4
  95. package/src/gl/{AudioSource.ts → audio/AudioSource.ts} +7 -7
  96. package/src/gl/{AudioZone.ts → audio/AudioZone.ts} +75 -75
  97. package/src/gl/{SceneAudio.ts → audio/SceneAudio.ts} +2 -2
  98. package/src/gl/{NavAgent.ts → nav/NavAgent.ts} +5 -5
  99. package/src/gl/{NavMesh.ts → nav/NavMesh.ts} +8 -8
  100. package/src/gl/{CharacterController.ts → physics/CharacterController.ts} +5 -5
  101. package/src/gl/{Physics.ts → physics/Physics.ts} +12 -5
  102. package/src/gl/physics/Ragdoll.ts +451 -0
  103. package/src/gl/{Shape.ts → physics/Shape.ts} +3 -3
  104. package/src/gl/{Trigger.ts → physics/Trigger.ts} +45 -45
  105. package/src/gl/{physicsEvents.ts → physics/physicsEvents.ts} +1 -1
  106. package/src/gl/{Terrain.ts → terrain/Terrain.ts} +14 -12
  107. package/src/gl/{terrainMesh.ts → terrain/terrainMesh.ts} +1 -1
  108. package/src/gl/vehicle/Vehicle.ts +666 -0
  109. package/src/gl/vehicle/Wheel.ts +290 -0
  110. package/src/inject.ts +236 -224
  111. package/src/runtime/files.ts +32 -2
  112. package/src/scene/defineScene.ts +92 -66
  113. package/src/scene/level.ts +2 -2
  114. package/src/ui/UIButton.ts +2 -2
  115. package/src/ui/UIInput.ts +3 -3
  116. package/src/ui/UINode.ts +61 -36
  117. package/dist/types/animate/animate.d.ts +0 -20
  118. package/dist/types/gl/Gearbox.d.ts +0 -86
  119. package/dist/types/gl/IK.d.ts +0 -53
  120. package/dist/types/gl/Ragdoll.d.ts +0 -86
  121. package/dist/types/gl/Vehicle.d.ts +0 -191
  122. package/dist/types/gl/Wheel.d.ts +0 -95
  123. package/src/animate/animate.ts +0 -238
  124. package/src/gl/Gearbox.ts +0 -212
  125. package/src/gl/IK.ts +0 -193
  126. package/src/gl/Ragdoll.ts +0 -270
  127. package/src/gl/Vehicle.ts +0 -473
  128. package/src/gl/Wheel.ts +0 -240
  129. /package/dist/types/gl/{physicsEvents.d.ts → physics/physicsEvents.d.ts} +0 -0
@@ -0,0 +1,666 @@
1
+ // A car, as an aspect on a 3D Node, backed by Jolt's wheeled-vehicle constraint (creator-physics).
2
+ // The node is the CHASSIS: it needs a Shape (its collision body) and must NOT also carry a Physics
3
+ // aspect — Vehicle owns its rigid body, the way CharacterController owns its controller.
4
+ //
5
+ // const car = new Node('car')
6
+ // .add(body, hubFL.aspect(Wheel), hubFR.aspect(Wheel), hubRL.aspect(Wheel), hubRR.aspect(Wheel))
7
+ // .aspect(Shape, { box: [0.9, 0.4, 2.1] })
8
+ // .aspect(Vehicle, { mass: 1200 })
9
+ //
10
+ // class Controls extends System<'controls'> {
11
+ // updateBefore() { // EARLY: this frame's step drives what the keys say
12
+ // const v = car.vehicle
13
+ // const fwd = (Input.key('KeyW') ? 1 : 0) - (Input.key('KeyS') ? 1 : 0)
14
+ // v.throttle = Math.abs(fwd)
15
+ // v.ratio = fwd < 0 ? -1 : 1 // the sign of the ratio IS the direction
16
+ // v.steer = (Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0)
17
+ // for (const w of v.wheels) w.brake = Input.key('Space') && !w.steer ? 4500 : 0
18
+ // }
19
+ // }
20
+ //
21
+ // This aspect is the car as HARDWARE: mass, engine, steering, aero, and the INPUTS a driver writes —
22
+ // `throttle`, `steer`, `steerForce`, `ratio`, `clutch` here, `brake` on each `Wheel`. Everything a
23
+ // DRIVER decides is the game's: which gear, when the clutch bites, whether the engine can stall,
24
+ // when the hands let go of the wheel in a slide, a gyro, ABS, a keyboard's pedal travel. All of
25
+ // those are a few lines over the sensors this class exposes (`rpm`, `speed`, `driftAngle`,
26
+ // `yawRate`, `steerAngle`, `steerTorque`, and per wheel `spin`, `slip`, `load`, the forces…), and
27
+ // the car-demo project carries a full driver as a reference. Anything per-wheel lives on the
28
+ // `Wheel` aspect of the wheel's own node (which is where Jolt keeps it too); Vehicle collects every
29
+ // Wheel under itself and groups them into axles by Z.
30
+ //
31
+ // The engine gets NUMBERS: the blob of settings (`_buildSettings`, live re-tunable) and one input
32
+ // vector per frame. It never shapes them — no taper, no rate limit, no gearbox, no idle governor.
33
+ //
34
+ // Chassis space is forward −Z / up +Y — the SDK's own convention, the one `node.forward` reports: if
35
+ // the model faces the other way, rotate the MODEL (a child), never the chassis node. Physics owns the
36
+ // chassis transform: drive it through the inputs, never per-frame `node.position` writes.
37
+
38
+ import { Aspect, _phaseStamp } from "../../core/Aspect"
39
+ import type { FieldMeta } from "../../core/fields"
40
+ import { Mat4 } from "../../math/mat4"
41
+ import { Vec3, cx, cy, cz, type Vec3Like } from "../../math/vec"
42
+ import { cw, type QuatLike } from "../../math/quat"
43
+ import { Gizmos } from "../../scene/gizmos"
44
+ import { Node } from "../Node"
45
+ import { DEFAULT_FRICTION, Physics } from "../physics/Physics"
46
+ import { Shape } from "../physics/Shape"
47
+ import { STATE_HEADER, SUSPENSION_MIN_FRACTION, WHEEL_STATE, Wheel, resolveTire, type Point } from "./Wheel"
48
+
49
+ /** Which axle the engine drives — the default for every wheel's own `driven` share. */
50
+ export type DriveLayout = "rear" | "front" | "all" | "none"
51
+
52
+ /**
53
+ * How an axle's two wheels share torque.
54
+ * - `lsd` — limited slip (Jolt's 1.4 ratio), the sane default.
55
+ * - `open` — fully open: a lifted or spinning wheel takes ALL the torque.
56
+ * - `locked` — both wheels forced to the same speed. Breaks traction predictably: the drift diff.
57
+ * A number is the raw max/min wheel-speed ratio (> 1; smaller is more locked).
58
+ */
59
+ export type DifferentialMode = "lsd" | "open" | "locked"
60
+
61
+ export interface EngineConfig {
62
+ /** Peak torque in N·m (default 500). The single biggest "how fast is it" knob. */
63
+ torque?: number
64
+ /** Redline in RPM (default 6000) — Jolt's own clamp; a rev limiter's stutter is the game's. */
65
+ maxRpm?: number
66
+ /** Idle RPM (default 1000): the engine's floor. `0` = no floor — the engine can be dragged to a
67
+ * standstill and stall, and holding idle (a governor), cranking it, are the game's to write. */
68
+ idleRpm?: number
69
+ /**
70
+ * The engine's CHARACTER: points `{ x, y }` in NORMALISED units — x a fraction of the redline, y a
71
+ * fraction of `torque`, x ascending. Left out, Jolt's own broad curve is used.
72
+ *
73
+ * `[{ x: 0, y: 0.8 }, { x: 0.66, y: 1 }, { x: 1, y: 0.8 }]` is an ordinary petrol engine (peak two
74
+ * thirds up, falling to the redline); `[{ x: 0, y: 1 }, { x: 1, y: 1 }]` is flat, an electric motor.
75
+ */
76
+ curve?: Point[]
77
+ /** Moment of inertia in kg·m² (default 0.5) — how eagerly it revs when the clutch is out. */
78
+ inertia?: number
79
+ /** Engine braking: angular damping of the engine itself (default 0.2). */
80
+ braking?: number
81
+ }
82
+
83
+ /**
84
+ * The steering wheel as a BODY the tires push back on, simulated by the engine every sub-step. With
85
+ * it, `steer` is where the driver's hands AIM the wheel and `steerForce` how firmly they hold it;
86
+ * where the road wheels end up is physics:
87
+ *
88
+ * I·θ̈ = T_hand + T_align·(1 − powerSteering) + T_stop − (damping + endStop.damping·depth)·θ̇ − parkingFriction(v) − friction
89
+ *
90
+ * `T_align` is the tires' self-aligning torque (`steering.caster` / `trail` / `trailAtPeak` — the
91
+ * same torque `steerTorque` reports without a feedback body, for a force-feedback wheel). `T_hand`
92
+ * is the driver's arms, `steering.driver`. So: heavy at speed, light when the front lets go,
93
+ * self-centring on release, and in a slide a let-go wheel swings into opposite lock by itself.
94
+ * Without `feedback` there is no body: `steer` is the road wheels' angle as a fraction of `maxAngle`.
95
+ */
96
+ export interface SteeringFeedback {
97
+ /** Moment of inertia of wheel + column + rack about the axis, kg·m² (default 1). Heavier = slower
98
+ * off the mark under the hands, but a wheel whipping into counter-steer carries further. */
99
+ inertia?: number
100
+ /** Viscous damping, N·m·s/rad (default 25): what keeps a let-go wheel from ringing about centre. */
101
+ damping?: number
102
+ /** The column's own dry friction, N·m, at any speed (default 8) — rack and bearings: the floor a
103
+ * free wheel's spin decays against, and what keeps a let-go wheel from creeping under a small torque. */
104
+ friction?: number
105
+ /** The contact patch's dry friction while (nearly) parked, N·m (default 40), fading out by 1.5 m/s
106
+ * — why a parked car is hard to steer, and why a wheel left turned stays turned. */
107
+ parkingFriction?: number
108
+ /** Power steering, 0..1 (default 0.5): the share of the tires' aligning torque the wheel never
109
+ * sees. It is NOT a gain on the angle: 0 is a kart (heavy, full of feel, a held key gives up its
110
+ * lock early at speed), 1 is a dead rim that neither centres nor counter-steers. */
111
+ powerSteering?: number
112
+ /** The END STOP before the lock — rubber, not a wall (default `{ band: 16, torque: 120, damping: 150 }`):
113
+ * over the last `band` degrees the rack pushes back toward centre with the SQUARE of the depth,
114
+ * `torque` N·m at the lock, and damps by `damping` N·m·s/rad × depth. A wheel let go on the stop
115
+ * eases back off it a few degrees; `band: 0` = the hard clamp only. */
116
+ endStop?: { band?: number, torque?: number, damping?: number }
117
+ }
118
+
119
+ /** The driver's ARMS on a `feedback` wheel — an actuator with numbers, because a stiff hand spring
120
+ * has to be integrated with the column every sub-step. WHAT the hands do (aim, hold, let go) is
121
+ * the game's, per frame, through `steer` and `steerForce`. */
122
+ export interface SteeringDriver {
123
+ /** The most torque the hands can put on the wheel, N·m (default 130). The ceiling on how much lock
124
+ * a held key keeps at speed against the tires' pull. */
125
+ maxTorque?: number
126
+ /** How stiff the arms are, N·m/rad of the gap between where they aim and where the wheel is (default 500). */
127
+ stiffness?: number
128
+ /** The fastest the hands turn the road wheels, deg/s (default 120): the hands cannot PUSH a wheel
129
+ * that already outruns them, they can still hold and brake it; a let-go wheel is uncapped. */
130
+ maxRate?: number
131
+ /** Seconds the hands' torque takes to build to `maxTorque` (default 0.1): muscles do not step. */
132
+ rampTime?: number
133
+ }
134
+
135
+ /** The column's numbers as the engine gets them. Defaults = the car-demo's verified setup. */
136
+ const FEEDBACK_DEFAULTS = { inertia: 1.0, damping: 25, friction: 8, parkingFriction: 40, powerSteering: 0.5 }
137
+ const END_STOP_DEFAULTS = { band: 16, torque: 120, damping: 150 }
138
+ const DRIVER_DEFAULTS: Required<SteeringDriver> = { maxTorque: 130, stiffness: 500, maxRate: 120, rampTime: 0.1 }
139
+
140
+ export interface SteeringConfig {
141
+ /** Steering lock in degrees (default 35): what `steer = ±1` means. */
142
+ maxAngle?: number
143
+ /** The front axle's GEOMETRY, the source of the tires' self-aligning torque (`steerTorque`, and
144
+ * what a `feedback` wheel is pushed by): the mechanical caster's trail, metres (default 0.03)… */
145
+ caster?: number
146
+ /** …and the pneumatic trail, metres (default 0.025) — the patch's own lever, which collapses as the
147
+ * tire reaches its peak slip angle (the steering going light when the front lets go)… */
148
+ trail?: number
149
+ /** …to this fraction of itself at the peak (default 0.5). */
150
+ trailAtPeak?: number
151
+ /** Simulate the steering wheel as a body the tires push back on (`SteeringFeedback`). `true` = the
152
+ * defaults. Without it `steer` sets the road wheels' angle directly. */
153
+ feedback?: boolean | SteeringFeedback
154
+ /** The driver's arms on a `feedback` wheel (`SteeringDriver`); the defaults without it. */
155
+ driver?: SteeringDriver
156
+ }
157
+
158
+ /**
159
+ * Aerodynamics. Both numbers are quoted the same way — **a fraction of the car's own weight at
160
+ * 30 m/s (108 km/h)** — and both grow with the SQUARE of speed from there, so `0.5` at 30 m/s is
161
+ * `2.0` at 60 m/s. One mental model for both, and no drag coefficients or frontal areas to invent.
162
+ */
163
+ export interface AeroConfig {
164
+ /**
165
+ * Downforce (default 0 — a car with no wing). The knob that decides whether a car corners better
166
+ * or worse the faster it goes: tire grip is a constant, but the force a corner demands grows with
167
+ * v², so with no downforce every car runs out of road at speed. `0.6` roughly doubles a car's
168
+ * cornering at 150 km/h; a road car is nearer `0.1`, a race car well past `1`.
169
+ *
170
+ * It presses along the CHASSIS' own down axis (that is what keeps a car planted over a crest) and
171
+ * only while a wheel is on the ground — a car on its roof is never launched by its own wing.
172
+ */
173
+ downforce?: number
174
+ /** Air resistance (default 0.03 — an ordinary car). What gives a car a top speed at all. */
175
+ drag?: number
176
+ }
177
+
178
+ /** Blob v15 — see bridges.d.ts for the field-by-field layout. */
179
+ const BLOB_VERSION = 15
180
+ const BLOB_HEADER = 34
181
+ const BLOB_WHEEL_FIXED = 15
182
+ /** The input vector's fixed head: throttle, steer, steerForce, ratio, clutch — then one brake per wheel. */
183
+ const INPUT_FIXED = 5
184
+ const DIFFERENTIALS: Record<DifferentialMode, number> = { lsd: 1.4, open: 0, locked: 1 }
185
+ const GRAVITY = 9.81 // only used to place the wheels at their resting height
186
+ const AXLE_EPS = 0.35 // wheels within this many metres in Z are the same axle
187
+ const RAD2DEG = 180 / Math.PI
188
+ /** `driftAngle` fades in over this band of ground speed, m/s: under it a tire's slip angle is the
189
+ * noise of a patch that is not going anywhere. */
190
+ const DRIFT_SPEED_FROM = 1
191
+ const DRIFT_SPEED_FULL = 2
192
+ const clamp = (v: number, lo: number, hi: number): number => (v < lo ? lo : v > hi ? hi : v)
193
+
194
+ export class Vehicle extends Aspect<"vehicle", Node> {
195
+ static readonly aspect = "vehicle"
196
+
197
+ /** Chassis mass in kg (default 1200). Wheels are massless in this model. */
198
+ mass = 1200
199
+ /** Centre of mass height in chassis space (metres, negative = lower). Left out, it drops to halfway
200
+ * down the chassis shape — a car with its mass at the shape's centre tips over. */
201
+ centerOfMass?: number
202
+ /** Which axle the engine drives (default "rear") — the default for every wheel's own `driven`
203
+ * share: 1 on that axle, 0 elsewhere. `none` = no drive at all (a trailer, a pushed cart). */
204
+ driveAxle: DriveLayout = "rear"
205
+ /** Differential lock across each driven axle. `locked` is the drift setup — both wheels turn
206
+ * together, so the pair lets go as a pair. Live: re-configure the aspect to switch it mid-drive. */
207
+ differential: DifferentialMode | number = "lsd"
208
+ /**
209
+ * Anti-roll bars between each axle's wheels — what stops a car rolling onto its inside wheels in a
210
+ * fast corner. `true` (default) is a bar 0.01 as stiff as the wheel's own spring; a number is that
211
+ * ratio directly, and `0` / `false` removes the bars.
212
+ *
213
+ * **The numbers are far smaller than they look and the gain is steep** — Jolt's bar feeds
214
+ * `lengthDifference × stiffness × dt` as a bias into the suspension's own soft constraint, so 4%
215
+ * of the spring already flattens the body. Measured on a 1250 kg car: `0` → 1.3° of roll at 0.86 g,
216
+ * `0.01` → 0.6°, `0.15` → 0.2° (flat), `0.5` → the car HOPS.
217
+ *
218
+ * This is the knob for how much a car visibly LEANS, not for wheel lift: bars change how far the
219
+ * body tips, not how much load transfers. For lift, lower `centerOfMass`.
220
+ */
221
+ antiRoll: boolean | number = true
222
+ /** Pitch/roll limit in degrees (default 60) before the constraint rights the car. 180 turns it off. */
223
+ maxTilt = 60
224
+ /** Friction of the CHASSIS body itself — how it slides when it lands on its roof or side. Nothing
225
+ * to do with the tires; those are the `Wheel`'s `tire` × the ground's friction. */
226
+ friction = DEFAULT_FRICTION
227
+ /** Strength of the clutch when fully engaged (default 10). Small values slip; large ones weld the
228
+ * engine to the wheels. `clutch` (the input) scales it 0..1. */
229
+ clutchStrength = 10
230
+ /** The clutch's torque CAPACITY, N·m (default 0 = none: Jolt's viscous clutch alone, torque =
231
+ * `clutchStrength` × slip speed, no ceiling — rigid at any real slip). Set it and the clutch
232
+ * SLIPS at this much torque × `clutch` — a dry clutch, emulated a physics step at a time: a
233
+ * launch can be feathered, and a dumped clutch in too high a gear drags the engine down instead
234
+ * of lurching. A road car's is ~1.5× the engine's peak torque. */
235
+ clutchCapacity = 0
236
+
237
+ engine: EngineConfig = { torque: 500, maxRpm: 6000, idleRpm: 1000, inertia: 0.5, braking: 0.2 }
238
+ steering: SteeringConfig = { maxAngle: 35 }
239
+ aero: AeroConfig = { downforce: 0, drag: 0.03 }
240
+
241
+ /** Scene-editor inspector: the flat, tunable half. The grouped objects are edited in code. */
242
+ static fields: FieldMeta<Vehicle> = {
243
+ mass: { min: 1, step: 10 },
244
+ driveAxle: { label: "Drive", options: [ "rear", "front", "all", "none" ] },
245
+ differential: { options: [ "lsd", "open", "locked" ] },
246
+ antiRoll: { label: "Anti-roll" },
247
+ maxTilt: { label: "Max tilt", min: 0, max: 180, step: 5 },
248
+ friction: { label: "Chassis friction", min: 0, max: 2, step: 0.05 },
249
+ clutchStrength: { label: "Clutch", min: 0.5, max: 100, step: 0.5 },
250
+ clutchCapacity: { label: "Clutch capacity", min: 0, step: 50 },
251
+ engine: { hidden: true },
252
+ steering: { hidden: true },
253
+ aero: { hidden: true },
254
+ }
255
+ static editor = { rebuild: true }
256
+
257
+ /** Driver systems tick at the default 0, so their inputs land in the SAME early pass, before this flush. */
258
+ protected override updateOrder = 100
259
+
260
+ private _id = 0
261
+ private _bodyId = 0
262
+ private _builtMass = 0
263
+ private _wheels: Wheel[] = []
264
+ private _state = new Float32Array(STATE_HEADER)
265
+ private _stateFrame = -1
266
+ private _throttle = 0
267
+ private _steer = 0
268
+ private _steerForce = 1
269
+ private _ratio = 1
270
+ private _clutch = 1
271
+ private _input = new Float32Array(INPUT_FIXED)
272
+ private _sent = new Float32Array(INPUT_FIXED)
273
+ private _dirty = true // an input write since the last send
274
+ private _retunePending = false
275
+
276
+ onAttach(): void {
277
+ if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
278
+ if (!_creator.vehicleCreate) {
279
+ console.warn("Vehicle needs a newer host (no vehicleCreate) — the car will not move")
280
+ return
281
+ }
282
+ const shape = this.node.get(Shape)
283
+ if (!shape) {
284
+ throw new Error("Vehicle requires a Shape aspect (the chassis collider) — add it first: node.aspect(Shape, { box: […] }).aspect(Vehicle, {…})")
285
+ }
286
+ if (shape._isTriangleMesh) {
287
+ throw new Error("Vehicle: the chassis cannot use Shape { mesh: true } (a triangle mesh can't be a dynamic body) — use { mesh: 'convex' } or a box")
288
+ }
289
+ if (this.node.has(Physics)) {
290
+ throw new Error("Vehicle owns its own rigid body — remove the Physics aspect from the chassis node (put `mass` on the Vehicle instead)")
291
+ }
292
+
293
+ this._wheels = this._collectWheels()
294
+ if (this._wheels.length === 0) {
295
+ throw new Error("Vehicle found no wheels — put a Wheel aspect on each wheel node under the chassis: hubFL.aspect(Wheel)")
296
+ }
297
+
298
+ const shapeId = shape._claim() // the vehicle owns the shape; drop the pick-only body
299
+ this._id = _creator.vehicleCreate(this.node.id, shapeId, this._buildSettings())
300
+ if (!this._id) {
301
+ console.warn(`Vehicle: the engine refused the settings for "${this.node.name}" — check the wheel list`)
302
+ shape._recreatePickBody()
303
+ return
304
+ }
305
+ this._bodyId = _creator.vehicleBodyId ? _creator.vehicleBodyId(this._id) : 0
306
+ // The chassis body owns the node's transform, so route position writes to it (see Node._xf) —
307
+ // `car.position = p` places the car instead of being silently overwritten by the next sync. It is
308
+ // the WEAK form of the operation: it keeps the car's velocity and its roll, so a respawn still
309
+ // wants `reset(p)`, which also uprights it and clears engine and wheel spin.
310
+ this.node._xf = this._bodyId
311
+ this.node._xfKind = this._bodyId ? 2 : 0
312
+ if (this._bodyId && this.friction !== DEFAULT_FRICTION) {
313
+ _creator.physicsSetFriction?.(this._bodyId, this.friction)
314
+ }
315
+ this._builtMass = this.mass
316
+ this._state = new Float32Array(STATE_HEADER + this._wheels.length * WHEEL_STATE)
317
+ this._input = new Float32Array(INPUT_FIXED + this._wheels.length)
318
+ this._sent = new Float32Array(INPUT_FIXED + this._wheels.length)
319
+ this._dirty = true
320
+ }
321
+
322
+ onDetach(): void {
323
+ if (this._id) { _creator.vehicleDestroy(this._id); this._id = 0; this._bodyId = 0 }
324
+ for (const w of this._wheels) w._vehicle = null
325
+ this._wheels = []
326
+ this.node._xf = 0
327
+ this.node._xfKind = 0
328
+ this.node.get(Shape)?._recreatePickBody() // hands the routing back to the pick body
329
+ }
330
+
331
+ /**
332
+ * Re-configuring an attached vehicle retunes it in place — the way a drift button switches the
333
+ * differential without the car so much as blinking:
334
+ *
335
+ * car.aspect(Vehicle, { differential: drift ? 'locked' : 'lsd' })
336
+ *
337
+ * Live: `differential`, `engine`, `steering`, `aero`, `maxTilt`, `friction`, `clutchStrength`,
338
+ * `clutchCapacity`, `antiRoll` (its stiffness, on axles that have a bar), and every `Wheel`'s
339
+ * `tire`. Everything else describes the car's STRUCTURE — `mass`, `centerOfMass`, which wheels
340
+ * exist and where, their suspension, the driven shares — and is baked into the constraint at
341
+ * attach; to change one of those, re-attach the aspect.
342
+ */
343
+ onReconfigure(): void {
344
+ if (!this._id) return
345
+ if (this.mass !== this._builtMass) {
346
+ console.warn(`Vehicle: mass is fixed once the body exists (still ${this._builtMass} kg) — re-add the aspect to change it`)
347
+ this.mass = this._builtMass
348
+ }
349
+ this._retunePending = true
350
+ if (this._bodyId) _creator.physicsSetFriction?.(this._bodyId, this.friction)
351
+ }
352
+
353
+ /** @internal A wheel's tire changed: re-send the tuning before the next step (one send however
354
+ * many wheels changed this frame). */
355
+ _retuneSoon(): void { this._retunePending = true }
356
+
357
+ /** Native vehicle id (0 until attached / no physics support). */
358
+ get id(): number { return this._id }
359
+ /** The chassis rigid body id (for `physicsApplyImpulse`-style calls). */
360
+ get bodyId(): number { return this._bodyId }
361
+ /** The wheels, front axle first, in the order the engine knows them. */
362
+ get wheels(): readonly Wheel[] { return this._wheels }
363
+
364
+ // --- inputs -----------------------------------------------------------------------------------
365
+ // Plain fields a driver writes; the early pass hands them to the engine as one vector before the
366
+ // step (`updateBefore`). Sticky: a value keeps applying until it is written again. Write them from
367
+ // a system's `updateBefore` and the very same physics step drives what the keys say.
368
+
369
+ /** Throttle 0..1 — the engine's pedal. No sign: the direction is `ratio`'s. */
370
+ get throttle(): number { return this._throttle }
371
+ set throttle(v: number) { this._throttle = clamp(v, 0, 1); this._dirty = true }
372
+ /** Steering −1..1. Without `steering.feedback`: the road wheels' angle as a fraction of `maxAngle`,
373
+ * applied as is (a taper with speed, a rate limit, are the game's). With it: where the hands AIM
374
+ * the wheel; the tires decide where the road wheels end up. */
375
+ get steer(): number { return this._steer }
376
+ set steer(v: number) { this._steer = clamp(v, -1, 1); this._dirty = true }
377
+ /** How firmly the hands hold a `feedback` wheel, 0..1 (default 1): the hands' torque is capped at
378
+ * `driver.maxTorque × steerForce`. 0 = let go — the tires alone have the wheel: back to centre in
379
+ * a grip corner, into opposite lock in a slide. Ignored without a feedback wheel. */
380
+ get steerForce(): number { return this._steerForce }
381
+ set steerForce(v: number) { this._steerForce = clamp(v, 0, 1); this._dirty = true }
382
+ /** The ONE gear ratio the engine runs (default 1): engine rpm = wheel rpm × ratio × the final
383
+ * drive (3.42). The SIGN is the direction — negative is reverse — and 0 is neutral. */
384
+ get ratio(): number { return this._ratio }
385
+ set ratio(v: number) { this._ratio = v; this._dirty = true }
386
+ /** Clutch engagement 0..1 (default 1 = home). 0 = out: the engine is free of the wheels. */
387
+ get clutch(): number { return this._clutch }
388
+ set clutch(v: number) { this._clutch = clamp(v, 0, 1); this._dirty = true }
389
+
390
+ /**
391
+ * EARLY phase: hand this frame's inputs to the engine, before the step. The vector is sent when
392
+ * anything changed — and every frame while a throttle, steer or brake is non-zero, because that is
393
+ * what keeps a driven car awake (an idle car with zero input sleeps). The pending re-tune (a
394
+ * config or tire change) goes first, so the step runs on the new numbers.
395
+ */
396
+ protected updateBefore(): void {
397
+ if (!this._id) return
398
+ if (this._retunePending) {
399
+ this._retunePending = false
400
+ _creator.vehicleSetTuning?.(this._id, this._buildSettings())
401
+ }
402
+ const input = this._input
403
+ input[0] = this._throttle
404
+ input[1] = this._steer
405
+ input[2] = this._steerForce
406
+ input[3] = this._ratio
407
+ input[4] = this._clutch
408
+ let active = this._throttle !== 0 || this._steer !== 0
409
+ let changed = this._dirty
410
+ for (let i = 0; i < this._wheels.length; i++) {
411
+ const b = Math.max(0, this._wheels[i]!.brake)
412
+ if (b !== 0) active = true
413
+ if (b !== this._sent[INPUT_FIXED + i]) changed = true
414
+ input[INPUT_FIXED + i] = b
415
+ }
416
+ if (changed || active) {
417
+ _creator.vehicleSetInput(this._id, input)
418
+ this._sent.set(input)
419
+ this._dirty = false
420
+ }
421
+ }
422
+
423
+ /** Teleport upright and clear all motion (velocity, engine rpm, wheel spin). Defaults to the
424
+ * chassis node's current pose — `reset()` alone un-flips a car where it lies. The inputs are
425
+ * kept and re-sent on the next early pass. */
426
+ reset(position?: Vec3Like, rotation?: QuatLike): this {
427
+ if (!this._id) return this
428
+ const p = position ?? this.node.worldPosition
429
+ // No rotation given: keep the heading, drop the roll/pitch — the point of reset is to land upright.
430
+ const wq = this.node.worldQuaternion
431
+ const q: QuatLike = rotation ?? [ 0, wq.y, 0, wq.w ]
432
+ _creator.vehicleReset(this._id, cx(p), cy(p), cz(p), cx(q), cy(q), cz(q), cw(q))
433
+ this._stateFrame = -1
434
+ this._dirty = true // the engine zeroed its copy of the inputs
435
+ return this
436
+ }
437
+
438
+ /** Push the chassis (a boost pad, an explosion) — an instantaneous impulse in kg·m/s. */
439
+ applyImpulse(v: Vec3Like): this {
440
+ if (this._bodyId) _creator.physicsApplyImpulse(this._bodyId, cx(v), cy(v), cz(v))
441
+ return this
442
+ }
443
+
444
+ // --- readouts (one native read per PHASE — early sees the pre-step state, late the settled one —
445
+ // shared by the car and every wheel; keyed on core/Aspect's _phaseStamp) -------------------
446
+
447
+ private _read(): Float32Array {
448
+ if (this._id && this._stateFrame !== _phaseStamp.value) {
449
+ _creator.vehicleGetState(this._id, this._state)
450
+ this._stateFrame = _phaseStamp.value
451
+ }
452
+ return this._state
453
+ }
454
+
455
+ /** @internal One value out of wheel `index`'s slice — see WHEEL_STATE in Wheel.ts. */
456
+ _wheelState(index: number, k: number): number {
457
+ const s = this._read()
458
+ const at = STATE_HEADER + index * WHEEL_STATE + k
459
+ return at < s.length ? s[at]! : 0
460
+ }
461
+
462
+ /** Speed along the car's forward axis in m/s — negative when reversing (× 3.6 for km/h). */
463
+ get speed(): number { return this._read()[0]! }
464
+ /** Engine RPM. */
465
+ get rpm(): number { return this._read()[1]! }
466
+ /** How many wheels touch the ground this frame. */
467
+ get wheelsOnGround(): number { return this._read()[2]! }
468
+ /** At least one wheel on the ground. */
469
+ get grounded(): boolean { return this._read()[2]! > 0 }
470
+ /** Chassis velocity in world space, m/s. */
471
+ get velocity(): Vec3 { const s = this._read(); return new Vec3(s[3]!, s[4]!, s[5]!) }
472
+ /** Chassis angular velocity in world space, rad/s. */
473
+ get angularVelocity(): Vec3 { const s = this._read(); return new Vec3(s[6]!, s[7]!, s[8]!) }
474
+ /** How fast the car is turning about the vertical, in degrees per second — positive turning RIGHT
475
+ * (the same sense as `steer`). The signal a counter-steer assist reads. */
476
+ get yawRate(): number { return -this._read()[7]! * RAD2DEG }
477
+ /**
478
+ * The car's slip angle in degrees: the angle between where the nose points and where the car is
479
+ * actually travelling, positive when it is travelling to the RIGHT of the nose. 0 going straight,
480
+ * ±180 backing up, and past ~10° the rear has stepped out — a drift.
481
+ */
482
+ get slipAngle(): number {
483
+ const s = this._read()
484
+ const vx = s[3]!, vz = s[5]!
485
+ if (vx * vx + vz * vz < 0.25) return 0 // under 0.5 m/s the direction is noise
486
+ const f = this.node.forward
487
+ const along = f.x * vx + f.z * vz
488
+ const right = -f.z * vx + f.x * vz // chassis right is (−f.z, 0, f.x) for forward f
489
+ return Math.atan2(right, along) * RAD2DEG
490
+ }
491
+ /**
492
+ * The angle of the SLIDE, degrees, signed like `slipAngle` (positive = travelling to the right of
493
+ * the nose), −180..180: the angle between the car's axis and where its REAR AXLE travels — the
494
+ * mean of the non-steering wheels (every wheel if all of them steer), its velocity from the
495
+ * body's velocity and spin. Unlike `slipAngle` (the body's, at its centre) this is 0 on a clean
496
+ * corner at ANY speed: at walking pace and full lock the body already travels ~20° off its nose
497
+ * while the rear axle rolls straight along it. It is the rear stepping out — what a gyro comes in
498
+ * on, and what a drift meter should show — and unlike the tires' own `Wheel.slipAngle` it does
499
+ * not fold at 90°: a backward entry reads 110°, not 70°. The axis it is measured from is the one
500
+ * the DRIVETRAIN pushes along — the nose for a positive `ratio`, the tail for a negative one — so
501
+ * honest reversing reads 0, and a car spun round in a forward gear reads past 90°. 0 at a
502
+ * standstill: with no speed a direction is noise, so it fades in over walking pace,
503
+ * `DRIFT_SPEED_FROM`..`DRIFT_SPEED_FULL` of the axle's ground speed.
504
+ */
505
+ get driftAngle(): number {
506
+ if (this._wheels.length === 0) return 0
507
+ // The rear axle in chassis space: the non-steering wheels' mean position (the hubs are the
508
+ // chassis' children, so a wheel node's position IS its chassis-local place).
509
+ let rx = 0, ry = 0, rz = 0, n = 0
510
+ for (const w of this._wheels) if (!w.steer) { const p = w.node.position; rx += p.x; ry += p.y; rz += p.z; n++ }
511
+ if (n === 0) for (const w of this._wheels) { const p = w.node.position; rx += p.x; ry += p.y; rz += p.z; n++ }
512
+ const r = this.node.quaternion.rotateVec3([ rx / n, ry / n, rz / n ])
513
+ // Its velocity: v + ω × r, in the ground plane.
514
+ const st = this._read()
515
+ const vx = st[3]!, vz = st[5]!, wx = st[6]!, wy = st[7]!, wz = st[8]!
516
+ const ax = vx + (wy * r.z - wz * r.y)
517
+ const az = vz + (wx * r.y - wy * r.x)
518
+ const ground = Math.hypot(ax, az)
519
+ const weight = clamp((ground - DRIFT_SPEED_FROM) / (DRIFT_SPEED_FULL - DRIFT_SPEED_FROM), 0, 1)
520
+ if (weight === 0) return 0
521
+ const f = this.node.forward
522
+ const dir = this._ratio < 0 ? -1 : 1
523
+ const along = dir * (f.x * ax + f.z * az) / ground
524
+ const right = dir * (-f.z * ax + f.x * az) / ground // chassis right is (−f.z, 0, f.x) for forward f
525
+ return Math.atan2(right, along) * RAD2DEG * weight
526
+ }
527
+ /** Where the steered wheels actually are, in degrees, right-positive: the angle `steer` set, or
528
+ * with `steering.feedback` where the column has come to. */
529
+ get steerAngle(): number { return this._read()[9]! }
530
+ /** The tires' self-aligning torque on the steering, N·m, positive = it pulls the wheel to the
531
+ * RIGHT — what the driver feels: heavy at speed, light when the front lets go, and the
532
+ * counter-steer in a slide. Reported with or without a `feedback` wheel: the signal a
533
+ * force-feedback steering wheel is driven by. */
534
+ get steerTorque(): number { return this._read()[10]! }
535
+
536
+ // --- setup ----------------------------------------------------------------------------------
537
+
538
+ /** Every Wheel under the chassis, ordered front axle first, with its axle group resolved. */
539
+ private _collectWheels(): Wheel[] {
540
+ const found: Wheel[] = []
541
+ this.node.traverse((n) => { const w = n.get(Wheel); if (w) found.push(w) })
542
+ if (found.length === 0) return found
543
+
544
+ const chassisInv = this.node.worldMatrix.invert()
545
+ const centres = found.map((w) => chassisInv.mul(w.node.worldMatrix).position)
546
+
547
+ // Axles: sort by Z (forward is −Z, so the front axle is the most NEGATIVE) and start a new group
548
+ // whenever the gap is more than AXLE_EPS.
549
+ const order = centres.map((_, i) => i).sort((a, b) => centres[a]!.z - centres[b]!.z)
550
+ const axleOf = new Array<number>(found.length).fill(0)
551
+ let axle = 0
552
+ let ref = centres[order[0]!]!.z
553
+ for (const i of order) {
554
+ if (Math.abs(centres[i]!.z - ref) > AXLE_EPS) { axle++; ref = centres[i]!.z }
555
+ axleOf[i] = found[i]!.axle ?? axle
556
+ }
557
+ const maxAxle = Math.max(...axleOf)
558
+
559
+ // Bind each wheel in front-to-back order, resolving what it did not say.
560
+ const wheels: Wheel[] = []
561
+ for (const i of order) {
562
+ const w = found[i]!
563
+ const a = axleOf[i]!
564
+ const measured = w._measure()
565
+ w.radius ??= measured.radius
566
+ w.width ??= measured.width
567
+ w.steer ??= a === 0
568
+ w.driven ??= this.driveAxle === "none" ? 0 : this.driveAxle === "all" ? 1 : (this.driveAxle === "front" ? a === 0 : a === maxAxle) ? 1 : 0
569
+ w.axle = a
570
+ const rest = centres[i]!
571
+ // Where the suspension hangs from, so the wheel CENTRE ends up where the author put it: the
572
+ // spring sags by g/(2πf)² under its own load (mass-independent), and that sag is what separates
573
+ // the resting length from full droop.
574
+ const travel = w.suspension.travel ?? 0.4
575
+ const stiffness = w.suspension.stiffness ?? 1.4
576
+ const sag = Math.min(GRAVITY / ((2 * Math.PI * stiffness) ** 2), travel * (1 - SUSPENSION_MIN_FRACTION))
577
+ const attach = new Vec3(rest.x, rest.y + (travel - sag), rest.z)
578
+ w._bind(this, wheels.length, rest, attach)
579
+ wheels.push(w)
580
+ }
581
+ return wheels
582
+ }
583
+
584
+ /** Pack the whole configuration into the one blob the host ABI takes (see bridges.d.ts). */
585
+ private _buildSettings(): Float32Array {
586
+ const curve = this.engine.curve ?? []
587
+ const diff = typeof this.differential === "number"
588
+ ? this.differential
589
+ : DIFFERENTIALS[this.differential] ?? DIFFERENTIALS.lsd
590
+ const tires = this._wheels.map((w) => resolveTire(w.tire))
591
+ const st = this.steering
592
+ const lock = Math.max(0, st.maxAngle ?? 35)
593
+ const fb = st.feedback === true ? {} : st.feedback || null
594
+ const stop = fb?.endStop ?? {}
595
+ const drv = st.driver ?? {}
596
+
597
+ let size = BLOB_HEADER + curve.length * 2
598
+ for (const t of tires) size += BLOB_WHEEL_FIXED + (t.side.length + t.forward.length) * 2
599
+ const out = new Float32Array(size)
600
+ out[0] = BLOB_VERSION
601
+ out[1] = this.mass
602
+ out[2] = this.centerOfMass === undefined ? 1 : 0
603
+ out[3] = this.centerOfMass ?? 0
604
+ out[4] = this.engine.torque ?? 500
605
+ out[5] = this.engine.maxRpm ?? 6000
606
+ out[6] = Math.max(0, this.engine.idleRpm ?? 1000)
607
+ out[7] = this.engine.inertia ?? 0.5
608
+ out[8] = this.engine.braking ?? 0.2
609
+ out[9] = this.clutchStrength
610
+ out[10] = diff
611
+ out[11] = this.antiRoll === true ? 0.01 : this.antiRoll === false ? 0 : Math.max(0, this.antiRoll)
612
+ out[12] = this.maxTilt
613
+ out[13] = Math.max(0, this.aero.downforce ?? 0)
614
+ out[14] = Math.max(0, this.aero.drag ?? 0.03)
615
+ out[15] = fb ? 1 : 0
616
+ out[16] = fb?.inertia ?? FEEDBACK_DEFAULTS.inertia
617
+ out[17] = fb?.damping ?? FEEDBACK_DEFAULTS.damping
618
+ out[18] = fb?.parkingFriction ?? FEEDBACK_DEFAULTS.parkingFriction
619
+ out[19] = st.caster ?? 0.03
620
+ out[20] = st.trail ?? 0.025
621
+ out[21] = st.trailAtPeak ?? 0.5
622
+ out[22] = drv.maxTorque ?? DRIVER_DEFAULTS.maxTorque
623
+ out[23] = drv.stiffness ?? DRIVER_DEFAULTS.stiffness
624
+ out[24] = fb?.powerSteering ?? FEEDBACK_DEFAULTS.powerSteering
625
+ out[25] = drv.maxRate ?? DRIVER_DEFAULTS.maxRate
626
+ out[26] = stop.band ?? END_STOP_DEFAULTS.band
627
+ out[27] = stop.torque ?? END_STOP_DEFAULTS.torque
628
+ out[28] = stop.damping ?? END_STOP_DEFAULTS.damping
629
+ out[29] = fb?.friction ?? FEEDBACK_DEFAULTS.friction
630
+ out[30] = drv.rampTime ?? DRIVER_DEFAULTS.rampTime
631
+ out[31] = Math.max(0, this.clutchCapacity)
632
+ out[32] = this._wheels.length
633
+ out[33] = curve.length
634
+ let at = BLOB_HEADER
635
+ for (const p of curve) { out[at++] = p.x; out[at++] = p.y }
636
+ this._wheels.forEach((w, i) => {
637
+ const t = tires[i]!
638
+ out[at++] = w._attach.x
639
+ out[at++] = w._attach.y
640
+ out[at++] = w._attach.z
641
+ out[at++] = w.radius ?? 0.35
642
+ out[at++] = w.width ?? 0.25
643
+ out[at++] = w.steer ? lock : 0
644
+ out[at++] = Math.max(0, w.driven ?? 0)
645
+ out[at++] = w.axle ?? 0
646
+ out[at++] = w.suspension.travel ?? 0.4
647
+ out[at++] = w.suspension.stiffness ?? 1.4
648
+ out[at++] = w.suspension.damping ?? 0.5
649
+ out[at++] = t.traction
650
+ out[at++] = t.circle
651
+ out[at++] = t.side.length
652
+ out[at++] = t.forward.length
653
+ for (const p of t.side) { out[at++] = p.x; out[at++] = p.y }
654
+ for (const p of t.forward) { out[at++] = p.x; out[at++] = p.y }
655
+ })
656
+ return out
657
+ }
658
+
659
+ /** Edit mode: which way the chassis faces. Each wheel draws its own circle and travel range — see
660
+ * `Wheel.rebuild()`. Play mode never calls this. */
661
+ rebuild(): void {
662
+ const w = new Mat4(this.node.worldMatrix)
663
+ const P = (x: number, y: number, z: number): Vec3 => w.transformPoint([ x, y, z ])
664
+ Gizmos.polyline([ P(0, 0, 0), P(0, 0, -1.2), P(-0.15, 0, -1.0), P(0, 0, -1.2), P(0.15, 0, -1.0) ], { color: "#ffffff" })
665
+ }
666
+ }