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.
- package/dist/index.js +1340 -648
- package/package.json +3 -3
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/compile/bundler.ts +1 -1
- package/runtime/sdk/compile/compileProject.ts +16 -1
- package/runtime/sdk/compile/header.ts +6 -1
- package/runtime/sdk/compile/index.ts +16 -0
- package/runtime/sdk/compile/liteMaterial.ts +247 -0
- package/runtime/sdk/compile/shaderTargets.ts +42 -0
- package/runtime/sdk/core/Aspect.ts +255 -244
- package/runtime/sdk/g2/CharacterController2D.ts +2 -2
- package/runtime/sdk/gl/Camera.ts +41 -0
- package/runtime/sdk/gl/CameraPlace.ts +51 -0
- package/runtime/sdk/gl/CharacterController.ts +3 -2
- package/runtime/sdk/gl/IK.ts +193 -174
- package/runtime/sdk/gl/Material.ts +11 -0
- package/runtime/sdk/gl/Model.ts +7 -6
- package/runtime/sdk/gl/Node.ts +270 -285
- package/runtime/sdk/gl/Physics.ts +171 -126
- package/runtime/sdk/gl/Scene.ts +27 -3
- package/runtime/sdk/gl/Shape.ts +214 -10
- package/runtime/sdk/gl/Vehicle.ts +519 -0
- package/runtime/sdk/gl/{AnimationClip.ts → animation/AnimationClip.ts} +37 -7
- package/runtime/sdk/gl/animation/Animator.ts +87 -0
- package/runtime/sdk/gl/animation/Layer.ts +29 -0
- package/runtime/sdk/gl/animation/Loop.ts +25 -0
- package/runtime/sdk/gl/animation/Playback.ts +43 -0
- package/runtime/sdk/gl/animation/core.ts +294 -0
- package/runtime/sdk/gl/scenarios.ts +26 -58
- package/runtime/sdk/inject.ts +18 -9
- package/runtime/sdk/runtime/app.ts +13 -0
- package/runtime/sdk/runtime/input.ts +169 -6
- package/runtime/sdk/scene/defineScene.ts +182 -26
- package/runtime/sdk/scene/gizmos.ts +128 -0
- package/runtime/sdk-types.json +1 -1
- package/runtime/sdk/gl/Animator.ts +0 -642
- 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.
|
|
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 "
|
|
15
|
-
import type { Vec3Like } from "
|
|
16
|
-
import type { QuatLike } from "
|
|
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 —
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|