lecodes-cli 0.17.2 → 0.18.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 (37) hide show
  1. package/dist/index.js +1340 -648
  2. package/package.json +3 -3
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk/compile/bundler.ts +1 -1
  5. package/runtime/sdk/compile/compileProject.ts +16 -1
  6. package/runtime/sdk/compile/header.ts +6 -1
  7. package/runtime/sdk/compile/index.ts +16 -0
  8. package/runtime/sdk/compile/liteMaterial.ts +247 -0
  9. package/runtime/sdk/compile/shaderTargets.ts +42 -0
  10. package/runtime/sdk/core/Aspect.ts +255 -244
  11. package/runtime/sdk/g2/CharacterController2D.ts +2 -2
  12. package/runtime/sdk/gl/Camera.ts +41 -0
  13. package/runtime/sdk/gl/CameraPlace.ts +51 -0
  14. package/runtime/sdk/gl/CharacterController.ts +3 -2
  15. package/runtime/sdk/gl/IK.ts +193 -174
  16. package/runtime/sdk/gl/Material.ts +11 -0
  17. package/runtime/sdk/gl/Model.ts +7 -6
  18. package/runtime/sdk/gl/Node.ts +270 -285
  19. package/runtime/sdk/gl/Physics.ts +171 -126
  20. package/runtime/sdk/gl/Scene.ts +27 -3
  21. package/runtime/sdk/gl/Shape.ts +214 -10
  22. package/runtime/sdk/gl/Vehicle.ts +519 -0
  23. package/runtime/sdk/gl/{AnimationClip.ts → animation/AnimationClip.ts} +37 -7
  24. package/runtime/sdk/gl/animation/Animator.ts +87 -0
  25. package/runtime/sdk/gl/animation/Layer.ts +29 -0
  26. package/runtime/sdk/gl/animation/Loop.ts +25 -0
  27. package/runtime/sdk/gl/animation/Playback.ts +43 -0
  28. package/runtime/sdk/gl/animation/core.ts +294 -0
  29. package/runtime/sdk/gl/scenarios.ts +26 -58
  30. package/runtime/sdk/inject.ts +18 -9
  31. package/runtime/sdk/runtime/app.ts +13 -0
  32. package/runtime/sdk/runtime/input.ts +169 -6
  33. package/runtime/sdk/scene/defineScene.ts +182 -26
  34. package/runtime/sdk/scene/gizmos.ts +128 -0
  35. package/runtime/sdk-types.json +1 -1
  36. package/runtime/sdk/gl/Animator.ts +0 -642
  37. package/runtime/sdk/gl/ModelAnimation.ts +0 -95
@@ -0,0 +1,519 @@
1
+ // A car, as an aspect on a 3D Node, backed by Jolt's wheeled-vehicle constraint in creator-gl. The
2
+ // node is the CHASSIS: it needs a Shape (its collision body) and must NOT also carry a Physics aspect
3
+ // — Vehicle owns its rigid body, the way CharacterController owns its controller.
4
+ //
5
+ // const car = new Node("car").add(bodyModel, wheelFL, wheelFR, wheelRL, wheelRR)
6
+ // .aspect(Shape, { box: [0.9, 0.4, 2.1] })
7
+ // .aspect(Vehicle, { mass: 1200, wheels: [
8
+ // { node: wheelFL }, { node: wheelFR }, { node: wheelRL }, { node: wheelRR },
9
+ // ] })
10
+ //
11
+ // setLoop(() => {
12
+ // const gas = (Input.key("KeyW") ? 1 : 0) - (Input.key("KeyS") ? 1 : 0)
13
+ // car.vehicle.drive(gas, (Input.key("KeyD") ? 1 : 0) - (Input.key("KeyA") ? 1 : 0), 0, Input.key("Space") ? 1 : 0)
14
+ // hud.text = `${Math.round(car.vehicle.speed * 3.6)} km/h gear ${car.vehicle.gear}`
15
+ // })
16
+ //
17
+ // That is the whole minimum: every wheel property is derived from where the wheel NODE sits. Wheels
18
+ // are grouped into axles by their Z, the FRONT axle steers, the rear one drives and holds the
19
+ // handbrake — override any of it per wheel (`steer`, `powered`, `handbrake`) or per vehicle
20
+ // (`powered: "all"`). Chassis space is forward −Z / up +Y — the SDK's own convention, the one
21
+ // `node.forward` reports: if the model faces the other way, rotate the MODEL (a child), never the
22
+ // chassis node.
23
+ //
24
+ // Physics OWNS the chassis transform while a vehicle is attached — drive it with `drive()`, don't set
25
+ // node.position. Each wheel node's LOCAL transform is overwritten every step (suspension travel, steer
26
+ // angle, spin), so a wheel model that needs a corrective rotation must wear it on a child node.
27
+
28
+ import { Aspect } from "../core/Aspect"
29
+ import type { FieldMeta } from "../core/fields"
30
+ import { Mat4 } from "../math/mat4"
31
+ import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
32
+ import { cw, type QuatLike } from "../math/quat"
33
+ import { Gizmos } from "../scene/gizmos"
34
+ import { glState } from "./state"
35
+ import { Node } from "./Node"
36
+ import { Physics } from "./Physics"
37
+ import { Shape } from "./Shape"
38
+
39
+ /** Which axle the engine drives. Per-wheel `powered` overrides it. */
40
+ export type DriveLayout = "rear" | "front" | "all"
41
+
42
+ export interface WheelConfig {
43
+ /** The node posed by this wheel — a child of the chassis, or its name (looked up under the
44
+ * chassis). Optional: a wheel can be physics-only (no visual). */
45
+ node?: Node | string
46
+ /** Where the wheel CENTRE sits when the car rests on flat ground, in chassis space. Defaults to
47
+ * the wheel node's own position, so a placed model needs nothing here. */
48
+ position?: Vec3Like
49
+ /** Wheel radius in metres (default 0.35). Set it to match the model — too small and the car
50
+ * scrapes, too large and it floats. */
51
+ radius?: number
52
+ /** Wheel width in metres (default 0.25). */
53
+ width?: number
54
+ /** Does this wheel steer? Default: the wheels on the front axle. */
55
+ steer?: boolean
56
+ /** Does the engine drive this wheel? Default: from the vehicle's `powered` layout. */
57
+ powered?: boolean
58
+ /** Foot brake on this wheel (default true). */
59
+ brake?: boolean
60
+ /** Handbrake on this wheel. Default: every non-steering wheel (locking the front wheels of a
61
+ * moving car is not a handbrake turn, it's a crash). */
62
+ handbrake?: boolean
63
+ /** Axle group, front to back. Derived from Z when omitted; set it for a car whose wheels are not
64
+ * in neat left/right pairs. Pairs an axle's wheels for the differential and the anti-roll bar. */
65
+ axle?: number
66
+ }
67
+
68
+ export interface SuspensionConfig {
69
+ /** How far the wheel can travel, in metres (default 0.3). */
70
+ travel?: number
71
+ /** Spring frequency in Hz — bigger is stiffer (default 1.5; a race car is ~2.5, a truck ~1). */
72
+ stiffness?: number
73
+ /** Damping ratio, 0..1 (default 0.5). Low values pogo. */
74
+ damping?: number
75
+ }
76
+
77
+ export interface SteeringConfig {
78
+ /** The lock still available, in degrees, once the car reaches `speed` (default 10). `0` turns the
79
+ * taper off entirely — full lock at any speed. */
80
+ atSpeed?: number
81
+ /** The speed in m/s at which the lock has fallen to `atSpeed` (default 30 ≈ 108 km/h). */
82
+ speed?: number
83
+ /** How fast the front wheels may turn, in degrees per second (default 220). `0` = instantly. */
84
+ rate?: number
85
+ }
86
+
87
+ export interface EngineConfig {
88
+ /** Peak engine torque in N·m (default 500). The single biggest "how fast is it" knob. */
89
+ torque?: number
90
+ /** Redline in RPM (default 6000). */
91
+ maxRpm?: number
92
+ }
93
+
94
+ /** One wheel's live state (`vehicle.wheel(i)`) — the same numbers Jolt's own vehicle debug overlay
95
+ * shows. `gripLateral` saturating while `slipAngle` climbs is what "it will not turn at speed"
96
+ * looks like in data. */
97
+ export interface WheelState {
98
+ /** Touching the ground this step. */
99
+ contact: boolean
100
+ /** Longitudinal slip RATIO: 0 = rolling in sync with the ground, ~1 = spinning up or locked.
101
+ * Unsigned — it does not say whether the tire is driving or braking. */
102
+ slip: number
103
+ /** Slip ANGLE in degrees: how far the tire's travel direction is off from where it points.
104
+ * Unsigned, 0..90. Past the friction curve's peak (~3°) the tire is sliding sideways. */
105
+ slipAngle: number
106
+ /** Longitudinal friction coefficient actually applied this step (curve value × ground friction). */
107
+ gripLong: number
108
+ /** Lateral friction coefficient actually applied this step — the cornering budget. */
109
+ gripLateral: number
110
+ /** Current suspension length in metres (short = compressed). */
111
+ suspension: number
112
+ /** Steer angle in degrees, positive to the right. */
113
+ steer: number
114
+ /** Wheel spin in rad/s (× radius ≈ the speed the tire is laying down). */
115
+ spin: number
116
+ }
117
+
118
+ /** How an axle's two wheels share torque.
119
+ * - `'lsd'` — limited slip (Jolt's 1.4 ratio), the sane default.
120
+ * - `'open'` — fully open: a lifted or spinning wheel takes ALL the torque.
121
+ * - `'locked'` — both wheels forced to the same speed. Breaks traction predictably: the drift diff.
122
+ * A number is the raw max/min wheel-speed ratio (> 1; smaller is more locked). */
123
+ export type DifferentialMode = "lsd" | "open" | "locked"
124
+
125
+ const DEFAULT_GEARS = [ 2.66, 1.78, 1.3, 1.0, 0.74 ]
126
+ /** Floats vehicleGetState writes per wheel — see the WheelState fields. */
127
+ const WHEEL_STATE = 8
128
+ /** Limited-slip ratios: `<= 0` tells the host "fully open". */
129
+ const DIFFERENTIALS: Record<DifferentialMode, number> = { lsd: 1.4, open: 0, locked: 1 }
130
+ const GRAVITY = 9.81 // only used to place the wheels at their resting height
131
+ const AXLE_EPS = 0.35 // wheels within this many metres in Z are the same axle
132
+ const clamp = (v: number, lo: number, hi: number): number => (v < lo ? lo : v > hi ? hi : v)
133
+
134
+ /** A wheel's STRUCTURE, resolved once at attach: where it is, what it does. The tunable numbers
135
+ * (lock in degrees, brake torques) are applied in _buildSettings, so re-tuning picks them up. */
136
+ type ResolvedWheel = {
137
+ entityId: number
138
+ cx: number, cy: number, cz: number // wheel centre at rest, chassis space
139
+ radius: number
140
+ width: number
141
+ steers: boolean
142
+ powered: boolean
143
+ braked: boolean
144
+ handbraked: boolean
145
+ axle: number
146
+ }
147
+
148
+ export class Vehicle extends Aspect<"vehicle", Node> {
149
+ static readonly aspect = "vehicle"
150
+
151
+ /** Chassis mass in kg (default 1200). Wheels are massless in this model. */
152
+ mass = 1200
153
+ /** The wheels, front to back. At least one; four is a car. */
154
+ wheels: WheelConfig[] = []
155
+ /** Which axle the engine drives (default "rear"). */
156
+ powered: DriveLayout = "rear"
157
+ /** Steering lock in degrees (default 35) for the wheels that steer — at a standstill. How much of
158
+ * it survives at speed is `steering`'s job. */
159
+ maxSteer = 35
160
+ /**
161
+ * Speed-sensitive steering — what stops a car from over-driving its own front tires.
162
+ *
163
+ * A tire makes its peak grip at a few degrees of slip angle, and the steering angle a corner
164
+ * actually needs falls with the SQUARE of speed. So holding full lock at 100 km/h asks the front
165
+ * for several times what it can give: the tires saturate, the car ploughs straight on, and the
166
+ * wheel stops answering. `atSpeed` is the lock left once the car reaches `speed`; in between it
167
+ * eases on `(1 − v/speed)²`, which tracks the real curve closely.
168
+ *
169
+ * `rate` is the other half: a keyboard asks for full lock in a single frame, which no steering
170
+ * wheel can do, and that step alone is enough to snap a tire past its peak.
171
+ *
172
+ * Both run inside the physics step, so they behave the same at any frame rate. `{ atSpeed: 0,
173
+ * rate: 0 }` gives the raw lock, instantly — arcade-direct, and how it behaved before this existed.
174
+ */
175
+ steering: SteeringConfig = { atSpeed: 10, speed: 30, rate: 220 }
176
+ /**
177
+ * Tire grip multiplier (default 1). Below 1 slides, above 1 sticks.
178
+ *
179
+ * This scales the tire's own friction curve. The grip a wheel ACTUALLY gets is combined with the
180
+ * ground it stands on — `sqrt(tire × groundFriction)` — and a body's friction defaults to 0.6, so
181
+ * a road you want a car to really corner on wants `Physics { friction: 1 }` (read
182
+ * `wheel(i).gripLateral` to see what the tires actually got).
183
+ */
184
+ grip = 1
185
+ /** Friction of the CHASSIS body itself — how it slides when it lands on its roof or side.
186
+ * Nothing to do with the tires; those are `grip` × the ground's friction. */
187
+ chassisFriction = 0.6
188
+ /** Foot-brake torque per wheel in N·m (default 1500); the handbrake pulls 3× on its wheels. */
189
+ brakeTorque = 1500
190
+ /** Anti-roll bars between each axle's wheels (default on) — what keeps a car from tripping in a
191
+ * fast corner. */
192
+ antiRoll = true
193
+ /** Pitch/roll limit in degrees (default 60) before the constraint rights the car. 180 turns it off. */
194
+ maxTilt = 60
195
+ /** Suspension: one travel distance, one stiffness, one damping. */
196
+ suspension: SuspensionConfig = { travel: 0.3, stiffness: 1.5, damping: 0.5 }
197
+ /** Engine: peak torque + redline. */
198
+ engine: EngineConfig = { torque: 500, maxRpm: 6000 }
199
+ /** Forward gear ratios, shifted automatically. Reverse is fixed. */
200
+ gears: number[] = DEFAULT_GEARS
201
+ /** Differential lock across each driven axle (and between axles on an all-wheel-drive car).
202
+ * `'locked'` is the drift setup — both wheels turn together, so the pair lets go as a pair.
203
+ * Live: re-configure the aspect to switch it mid-drive (see `onReconfigure`). */
204
+ differential: DifferentialMode | number = "lsd"
205
+ /** Centre of mass height in chassis space (metres, negative = lower). Left out, it drops to
206
+ * halfway down the chassis shape — a car with its mass at the shape's centre tips over. */
207
+ centerOfMass?: number
208
+
209
+ /** Scene-editor inspector: the flat, tunable half. */
210
+ static fields: FieldMeta<Vehicle> = {
211
+ mass: { min: 1, step: 10 },
212
+ powered: { label: "Drive", options: [ "rear", "front", "all" ] },
213
+ maxSteer: { label: "Steering lock", min: 0, max: 80, step: 1 },
214
+ grip: { min: 0.1, max: 3, step: 0.05 },
215
+ chassisFriction: { label: "Chassis friction", min: 0, max: 2, step: 0.05 },
216
+ brakeTorque: { label: "Brake torque", min: 0, step: 50 },
217
+ maxTilt: { label: "Max tilt", min: 0, max: 180, step: 5 },
218
+ differential: { options: [ "lsd", "open", "locked" ] },
219
+ wheels: { hidden: true },
220
+ gears: { hidden: true },
221
+ suspension: { hidden: true },
222
+ engine: { hidden: true },
223
+ steering: { hidden: true },
224
+ }
225
+ static editor = { rebuild: true }
226
+
227
+ private _id = 0
228
+ private _bodyId = 0
229
+ private _builtMass = 0
230
+ private _resolved: ResolvedWheel[] = []
231
+ private _state = new Float32Array(7 + WHEEL_STATE)
232
+ private _stateFrame = -1
233
+
234
+ onAttach(): void {
235
+ if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
236
+ if (!_creator.vehicleCreate) {
237
+ console.warn("Vehicle needs a newer host (no vehicleCreate) — the car will not move")
238
+ return
239
+ }
240
+ const shape = this.node.get(Shape)
241
+ if (!shape) {
242
+ throw new Error("Vehicle requires a Shape aspect (the chassis collider) — add it first: node.aspect(Shape, { box: […] }).aspect(Vehicle, {…})")
243
+ }
244
+ if (shape._isTriangleMesh) {
245
+ 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")
246
+ }
247
+ if (this.node.has(Physics)) {
248
+ throw new Error("Vehicle owns its own rigid body — remove the Physics aspect from the chassis node (put `mass` on the Vehicle instead)")
249
+ }
250
+ if (this.wheels.length === 0) throw new Error("Vehicle needs at least one wheel — `wheels: [{ node: … }, …]`")
251
+
252
+ this._resolved = this._resolve()
253
+ const shapeId = shape._claim() // the vehicle owns the shape; drop the pick-only body
254
+ const ids = new Uint32Array(this._resolved.map((w) => w.entityId))
255
+ this._id = _creator.vehicleCreate(this.node.id, shapeId, this._buildSettings(), ids)
256
+ if (!this._id) {
257
+ console.warn(`Vehicle: the engine refused the settings for "${this.node.name}" — check the wheel list`)
258
+ shape._recreatePickBody()
259
+ return
260
+ }
261
+ this._bodyId = _creator.vehicleBodyId ? _creator.vehicleBodyId(this._id) : 0
262
+ if (this._bodyId) _creator.physicsSetFriction?.(this._bodyId, this.chassisFriction)
263
+ this._builtMass = this.mass
264
+ this._state = new Float32Array(7 + this._resolved.length * WHEEL_STATE)
265
+ }
266
+
267
+ onDetach(): void {
268
+ if (this._id) { _creator.vehicleDestroy(this._id); this._id = 0; this._bodyId = 0 }
269
+ this.node.get(Shape)?._recreatePickBody()
270
+ }
271
+
272
+ /**
273
+ * Re-configuring an attached vehicle retunes it in place — the way a drift button switches the
274
+ * differential without the car so much as blinking:
275
+ *
276
+ * car.aspect(Vehicle, { differential: drift ? 'locked' : 'lsd' })
277
+ *
278
+ * Live: `differential`, `grip`, `maxSteer`, `steering`, `engine`, `gears`, `brakeTorque`,
279
+ * `maxTilt`, `chassisFriction`. Everything else describes the car's STRUCTURE — `mass`,
280
+ * `centerOfMass`, `wheels`, `suspension`, `powered`, `antiRoll` — and is baked into the constraint
281
+ * at attach; to change one of those, detach and attach the aspect again.
282
+ */
283
+ onReconfigure(): void {
284
+ if (!this._id) return
285
+ if (this.wheels.length !== this._resolved.length) {
286
+ console.warn("Vehicle: the wheel list can't change on a live car — remove and re-add the aspect")
287
+ return
288
+ }
289
+ if (this.mass !== this._builtMass) {
290
+ console.warn(`Vehicle: mass is fixed once the body exists (still ${this._builtMass} kg) — re-add the aspect to change it`)
291
+ this.mass = this._builtMass
292
+ }
293
+ _creator.vehicleSetTuning?.(this._id, this._buildSettings())
294
+ if (this._bodyId) _creator.physicsSetFriction?.(this._bodyId, this.chassisFriction)
295
+ }
296
+
297
+ /** Native vehicle id (0 until attached / no physics support). */
298
+ get id(): number { return this._id }
299
+ /** The chassis rigid-body id — the same handle the plain body calls take. */
300
+ get bodyId(): number { return this._bodyId }
301
+
302
+ // --- driving --------------------------------------------------------------------------------
303
+
304
+ /**
305
+ * The driver's controls, all in [-1, 1] (brakes in [0, 1]). Sticky: the last values keep applying
306
+ * until the next call, so a held key does not need re-sending. `forward < 0` engages reverse once
307
+ * the car has stopped (the gearbox is automatic).
308
+ */
309
+ drive(forward: number, steer = 0, brake = 0, handbrake = 0): this {
310
+ if (this._id) {
311
+ _creator.vehicleSetInput(this._id, clamp(forward, -1, 1), clamp(steer, -1, 1),
312
+ clamp(brake, 0, 1), clamp(handbrake, 0, 1))
313
+ }
314
+ return this
315
+ }
316
+
317
+ /** Teleport upright and clear all motion (velocity, engine, gearbox, wheel spin). Defaults to the
318
+ * chassis node's current pose — `reset()` alone un-flips a car where it lies. */
319
+ reset(position?: Vec3Like, rotation?: QuatLike): this {
320
+ if (!this._id) return this
321
+ const p = position ?? this.node.worldPosition
322
+ // No rotation given: keep the heading, drop the roll/pitch — the point of reset is to land upright.
323
+ const wq = this.node.worldQuaternion
324
+ const q: QuatLike = rotation ?? [ 0, wq.y, 0, wq.w ]
325
+ _creator.vehicleReset(this._id, cx(p), cy(p), cz(p), cx(q), cy(q), cz(q), cw(q))
326
+ this._stateFrame = -1
327
+ return this
328
+ }
329
+
330
+ /** Push the chassis (a boost pad, an explosion) — an instantaneous impulse in kg·m/s. */
331
+ applyImpulse(v: Vec3Like): this {
332
+ if (this._bodyId) _creator.physicsApplyImpulse(this._bodyId, cx(v), cy(v), cz(v))
333
+ return this
334
+ }
335
+
336
+ // --- readouts (one native read per frame, shared by every getter) ----------------------------
337
+
338
+ private _read(): Float32Array {
339
+ if (this._id && this._stateFrame !== glState.frame) {
340
+ _creator.vehicleGetState(this._id, this._state)
341
+ this._stateFrame = glState.frame
342
+ }
343
+ return this._state
344
+ }
345
+
346
+ /** Speed along the car's forward axis in m/s — negative when reversing (× 3.6 for km/h). */
347
+ get speed(): number { return this._read()[0]! }
348
+ /** Engine revolutions per minute. */
349
+ get rpm(): number { return this._read()[1]! }
350
+ /** Current gear: -1 reverse, 0 neutral, 1… forward. */
351
+ get gear(): number { return this._read()[2]! }
352
+ /** How many wheels are touching the ground. */
353
+ get wheelsOnGround(): number { return this._read()[3]! }
354
+ /** Any wheel on the ground — false while airborne. */
355
+ get grounded(): boolean { return this._read()[3]! > 0 }
356
+ /** Chassis velocity in world units/s (fresh Vec3). */
357
+ get velocity(): Vec3 { const s = this._read(); return new Vec3(s[4]!, s[5]!, s[6]!) }
358
+ /** The steering lock, in degrees, the car is allowed at its current speed — `maxSteer` at rest,
359
+ * falling to `steering.atSpeed`. The same curve the engine applies; useful on a HUD. */
360
+ get steerLock(): number {
361
+ const atSpeed = Math.max(0, this.steering.atSpeed ?? 10)
362
+ if (atSpeed <= 0 || atSpeed >= this.maxSteer) return this.maxSteer
363
+ const full = this.steering.speed ?? 30
364
+ const t = full > 0 ? Math.min(Math.abs(this.speed) / full, 1) : 1
365
+ return atSpeed + (this.maxSteer - atSpeed) * (1 - t) * (1 - t)
366
+ }
367
+
368
+ /** One wheel's live state, by its index in `wheels`. */
369
+ wheel(index: number): WheelState {
370
+ const s = this._read()
371
+ const base = 7 + index * WHEEL_STATE
372
+ if (index < 0 || base + WHEEL_STATE > s.length) {
373
+ return { contact: false, slip: 0, slipAngle: 0, gripLong: 0, gripLateral: 0, suspension: 0, steer: 0, spin: 0 }
374
+ }
375
+ return {
376
+ contact: s[base] !== 0,
377
+ slip: s[base + 1]!,
378
+ slipAngle: s[base + 2]!,
379
+ gripLong: s[base + 3]!,
380
+ gripLateral: s[base + 4]!,
381
+ suspension: s[base + 5]!,
382
+ steer: s[base + 6]!,
383
+ spin: s[base + 7]!,
384
+ }
385
+ }
386
+
387
+ // --- setup ----------------------------------------------------------------------------------
388
+
389
+ /** Resolve every wheel default: node → entity id + centre, Z → axle, layout → steer/power/brakes. */
390
+ private _resolve(): ResolvedWheel[] {
391
+ const chassisInv = this.node.worldMatrix.invert()
392
+ const centres = this.wheels.map((w) => {
393
+ if (w.position) return new Vec3(cx(w.position), cy(w.position), cz(w.position))
394
+ const node = this._wheelNode(w)
395
+ if (!node) {
396
+ throw new Error("Vehicle: a wheel needs either `position` or a `node` to take its position from")
397
+ }
398
+ return chassisInv.mul(node.worldMatrix).position
399
+ })
400
+
401
+ // Axles: sort by Z (front first) and start a new group whenever the gap is more than AXLE_EPS.
402
+ const axleOf = new Array<number>(this.wheels.length).fill(0)
403
+ // Forward is −Z, so the front axle is the most NEGATIVE z.
404
+ const byZ = centres.map((_, i) => i).sort((a, b) => centres[a]!.z - centres[b]!.z)
405
+ let axle = 0
406
+ let ref = centres[byZ[0]!]!.z
407
+ for (const i of byZ) {
408
+ const w = this.wheels[i]!
409
+ if (Math.abs(centres[i]!.z - ref) > AXLE_EPS) { axle++; ref = centres[i]!.z }
410
+ axleOf[i] = w.axle ?? axle
411
+ }
412
+ const maxAxle = Math.max(...axleOf)
413
+
414
+ return this.wheels.map((w, i) => {
415
+ const a = axleOf[i]!
416
+ const steers = w.steer ?? a === 0
417
+ const powered = w.powered ?? (this.powered === "all" || (this.powered === "front" ? a === 0 : a === maxAxle))
418
+ const handbrake = w.handbrake ?? !steers
419
+ const c = centres[i]!
420
+ const node = this._wheelNode(w)
421
+ return {
422
+ entityId: node ? node.id : 0,
423
+ cx: c.x, cy: c.y, cz: c.z,
424
+ radius: w.radius ?? 0.35,
425
+ width: w.width ?? 0.25,
426
+ steers,
427
+ powered,
428
+ braked: w.brake ?? true,
429
+ handbraked: handbrake,
430
+ axle: a,
431
+ }
432
+ })
433
+ }
434
+
435
+ private _wheelNode(w: WheelConfig): Node | null {
436
+ if (!w.node) return null
437
+ if (typeof w.node !== "string") return w.node
438
+ const found = this.node.bone(w.node)
439
+ if (!found) console.warn(`Vehicle: no node named "${w.node}" under "${this.node.name}" — that wheel will be invisible`)
440
+ return found
441
+ }
442
+
443
+ /** Pack the whole configuration into the one blob the host ABI takes (see bridges.d.ts). */
444
+ private _buildSettings(): Float32Array {
445
+ const travel = this.suspension.travel ?? 0.3
446
+ const stiffness = this.suspension.stiffness ?? 1.5
447
+ const damping = this.suspension.damping ?? 0.5
448
+ const maxRpm = this.engine.maxRpm ?? 6000
449
+ const gears = this.gears.length ? this.gears : DEFAULT_GEARS
450
+ // Where the suspension hangs from, so the wheel CENTRE ends up where the author put it: the
451
+ // spring sags by g/(2πf)² under its own load (mass-independent), and that sag is what separates
452
+ // the resting length from full droop.
453
+ const sag = Math.min(GRAVITY / ((2 * Math.PI * stiffness) ** 2), travel * 0.6)
454
+ const attachOffset = travel - sag
455
+
456
+ const diff = typeof this.differential === "number"
457
+ ? this.differential
458
+ : DIFFERENTIALS[this.differential] ?? DIFFERENTIALS.lsd
459
+
460
+ const out = new Float32Array(21 + gears.length + this._resolved.length * 10)
461
+ out[0] = 3 // blob version
462
+ out[1] = this.mass
463
+ out[2] = this.centerOfMass === undefined ? 1 : 0
464
+ out[3] = this.centerOfMass ?? 0
465
+ out[4] = travel
466
+ out[5] = stiffness
467
+ out[6] = damping
468
+ out[7] = this.engine.torque ?? 500
469
+ out[8] = maxRpm / 6
470
+ out[9] = maxRpm
471
+ out[10] = maxRpm * 0.67 // shift up
472
+ out[11] = maxRpm * 0.33 // shift down
473
+ out[12] = this.grip
474
+ out[13] = this.antiRoll ? 1 : 0
475
+ out[14] = this.maxTilt
476
+ out[15] = gears.length
477
+ out[16] = this._resolved.length
478
+ out[17] = diff // v2: limited-slip ratio (<= 0 = open)
479
+ out[18] = Math.max(0, this.steering.atSpeed ?? 10) // v3: speed-sensitive steering
480
+ out[19] = this.steering.speed ?? 30
481
+ out[20] = Math.max(0, this.steering.rate ?? 220)
482
+ let at = 21
483
+ for (const g of gears) out[at++] = g
484
+ for (const w of this._resolved) {
485
+ out[at++] = w.cx
486
+ out[at++] = w.cy + attachOffset
487
+ out[at++] = w.cz
488
+ out[at++] = w.radius
489
+ out[at++] = w.width
490
+ out[at++] = w.steers ? this.maxSteer : 0
491
+ out[at++] = w.powered ? 1 : 0
492
+ out[at++] = w.braked ? this.brakeTorque : 0
493
+ out[at++] = w.handbraked ? this.brakeTorque * 3 : 0
494
+ out[at++] = w.axle
495
+ }
496
+ return out
497
+ }
498
+
499
+ /** Edit mode: wheels as circles at their resting height with their travel range, plus a forward
500
+ * arrow — enough to place a wheel without running the game. Play mode never calls this. */
501
+ rebuild(): void {
502
+ let resolved: ResolvedWheel[]
503
+ try { resolved = this._resolve() } catch { return }
504
+ const world = new Mat4(this.node.worldMatrix)
505
+ const travel = this.suspension.travel ?? 0.3
506
+ const P = (x: number, y: number, z: number): Vec3 => world.transformPoint([ x, y, z ])
507
+ for (const w of resolved) {
508
+ const ring: Vec3[] = []
509
+ for (let i = 0; i < 24; i++) {
510
+ const t = (i / 24) * Math.PI * 2
511
+ ring.push(P(w.cx, w.cy + Math.sin(t) * w.radius, w.cz + Math.cos(t) * w.radius))
512
+ }
513
+ Gizmos.polyline(ring, { color: w.steers ? "#5b8ef0" : "#9aa3b2", closed: true })
514
+ Gizmos.line(P(w.cx, w.cy + travel * 0.6, w.cz), P(w.cx, w.cy - travel * 0.4, w.cz), { color: "#59606d" })
515
+ if (w.powered) Gizmos.cross(P(w.cx, w.cy, w.cz), w.radius * 0.35, { color: "#e0b25a" })
516
+ }
517
+ 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" })
518
+ }
519
+ }
@@ -1,7 +1,8 @@
1
1
  // AnimationClip — the unit of the animation system: immutable data (name, duration, tracks that
2
2
  // target bone NAMES, not entities). One representation for a GLB's embedded clips (model.anim.clips),
3
3
  // clips loaded from other files (Mixamo: one GLB per animation) and procedural clips built from
4
- // curves. An Animator binds clips to a skeleton by name. See docs/animation-plan.md.
4
+ // curves. An Animator binds clips to a skeleton by name. Clip EVENTS (`addEvent`) live here too —
5
+ // a fact about the clip's timeline, shared wherever it plays. See docs/animator-plan.md.
5
6
  //
6
7
  // const [hero, idle, run] = await Promise.all([
7
8
  // Model.load(asset('./hero.glb')),
@@ -11,9 +12,9 @@
11
12
  // const slash = await AnimationClip.load(asset('./attacks.glb'), 'Slash') // one out of many
12
13
  // const bob = AnimationClip.from({ tracks: { Hips: { position: [[0, [0,0,0]], [1, [0,0.05,0]]] } } })
13
14
 
14
- import { fetch, type FetchResponse } from "../runtime/fetch"
15
- import type { Vec3Like } from "../math/vec"
16
- import type { QuatLike } from "../math/quat"
15
+ import { fetch, type FetchResponse } from "../../runtime/fetch"
16
+ import type { Vec3Like } from "../../math/vec"
17
+ import type { QuatLike } from "../../math/quat"
17
18
 
18
19
  /** A keyframe: `[time (s), value]`. */
19
20
  export type ClipKey<V> = [number, V]
@@ -39,6 +40,8 @@ export type ClipInfo = { name: string, duration: number, trackCount: number }
39
40
  const PATH = { position: 0, rotation: 1, scale: 2 } as const
40
41
  const INTERP = { linear: 0, step: 1 } as const
41
42
 
43
+ const setCache = new Map<number, AnimationClip[]>()
44
+
42
45
  const readInfo = (setId: number): ClipInfo[] =>
43
46
  ((_creator.getClipSetInfo(setId) ?? []) as ClipInfo[]).map((c) => ({ name: c.name, duration: c.duration, trackCount: c.trackCount ?? 0 }))
44
47
 
@@ -61,6 +64,8 @@ export class AnimationClip {
61
64
  readonly _set: number
62
65
  /** @internal */
63
66
  readonly _index: number
67
+ /** @internal events: seconds + name, sorted (the engine gets them normalized via setClipEvents). */
68
+ _events: { t: number, name: string }[] = []
64
69
 
65
70
  private constructor(set: number, index: number, info: ClipInfo) {
66
71
  this._set = set
@@ -70,10 +75,32 @@ export class AnimationClip {
70
75
  this.trackCount = info.trackCount
71
76
  }
72
77
 
73
- /** @internal — wrap a clip of an existing native set (a GLB's embedded clips). */
78
+ /** @internal — a placeholder for a Playback that never started (unknown clip). */
79
+ static _none(): AnimationClip { return new AnimationClip(0, 0, { name: "", duration: 0, trackCount: 0 }) }
80
+
81
+ /** @internal — wrap the clips of an existing native set (a GLB's embedded clips). Cached per set
82
+ * so every model / clone sharing the set gets the SAME objects (events added on one apply to all —
83
+ * as they do in the engine). */
74
84
  static _ofSet(setId: number): AnimationClip[] {
75
85
  if (!setId) return []
76
- return readInfo(setId).map((info, i) => new AnimationClip(setId, i, info))
86
+ let clips = setCache.get(setId)
87
+ if (!clips) { clips = readInfo(setId).map((info, i) => new AnimationClip(setId, i, info)); setCache.set(setId, clips) }
88
+ return clips
89
+ }
90
+
91
+ /** Mark a moment of the clip (SECONDS from its start) with an event name: `kick.addEvent(0.4, 'hit')`
92
+ * → `anim.on('hit', (clip, layer) => …)` fires when the playhead crosses it, loops included.
93
+ * Events are part of the clip: every model playing it gets them; `slice()` keeps the ones inside
94
+ * the range, re-timed. Chainable. */
95
+ addEvent(time: number, name: string): this {
96
+ this._events.push({ t: Math.min(Math.max(0, time), this.duration), name })
97
+ this._events.sort((a, b) => a.t - b.t)
98
+ this._pushEvents()
99
+ return this
100
+ }
101
+ private _pushEvents(): void {
102
+ const d = this.duration
103
+ _creator.setClipEvents(this._set, this._index, new Float32Array(this._events.map((e) => (d > 0 ? e.t / d : 0))))
77
104
  }
78
105
 
79
106
  /** Load ONE clip from a GLB: the file's only/first clip, or the one named / at the given index. */
@@ -147,7 +174,10 @@ export class AnimationClip {
147
174
  const setId = _creator.sliceClip(this._set, this._index, st, e)
148
175
  if (setId === 0) throw new Error(`AnimationClip.slice: bad range ${start}${end !== undefined ? `–${end}` : ""} of '${this.name}' (${this.duration.toFixed(2)}s)`)
149
176
  const info = readInfo(setId)[0] ?? { name: this.name, duration: e - st, trackCount: this.trackCount }
150
- return new AnimationClip(setId, 0, info)
177
+ const out = new AnimationClip(setId, 0, info)
178
+ // the engine re-timed the event TIMES; carry the names the same way
179
+ out._events = this._events.filter((ev) => ev.t >= st - 1e-6 && ev.t <= e + 1e-6).map((ev) => ({ t: Math.min(Math.max(0, ev.t - st), e - st), name: ev.name }))
180
+ return out
151
181
  }
152
182
 
153
183
  private static _label(source: string | FetchResponse): string {