lecodes-sdk 0.19.2 → 0.20.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/src/gl/Trigger.ts CHANGED
@@ -1,45 +1,45 @@
1
- // A trigger zone, as an aspect on a 3D Node. Requires a Shape (its geometry); creates a static SENSOR
2
- // body from it, so a physics body overlapping the zone fires the node's 'enter' / 'exit' events
3
- // instead of colliding. A trigger does not block movement, and is still pointer-pickable.
4
- //
5
- // const goal = new Mesh(box(), material)
6
- // .aspect(Shape, { box: [1, 2, 1] })
7
- // .aspect(Trigger)
8
- // goal.addEventListener('enter', other => win(other))
9
- // goal.addEventListener('exit', other => …)
10
-
11
- import { Aspect } from "../core/Aspect"
12
- import { Shape } from "./Shape"
13
- import { ensurePhysicsEvents } from "./physicsEvents"
14
- import type { Node } from "./Node"
15
-
16
- export class Trigger extends Aspect<"trigger", Node> {
17
- static readonly aspect = "trigger"
18
-
19
- private _bodyId = 0
20
- /** Native body id (0 if no physics support). */
21
- get id(): number { return this._bodyId }
22
-
23
- onAttach(): void {
24
- if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
25
- const shape = this.node.get(Shape)
26
- if (!shape) {
27
- throw new Error("Trigger requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Trigger)")
28
- }
29
- const shapeId = shape._claim()
30
- this._bodyId = _creator.physicsCreateBody(this.node.id, shapeId, 0 /* static */, 0, true /* sensor */, false)
31
- shape._ownBody(this._bodyId)
32
- ensurePhysicsEvents() // routes overlap events → the node's 'enter' / 'exit'
33
- }
34
-
35
- onDetach(): void {
36
- if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
37
- const shape = this.node.get(Shape)
38
- if (shape) {
39
- shape._ownBody(0) // the Shape held OUR body id — forget it, or its own detach removes it twice (native crash)
40
- shape._recreatePickBody()
41
- }
42
- }
43
-
44
- // No moveTo(): `node.position = p` moves the node AND the sensor body it owns (see Node._xf).
45
- }
1
+ // A trigger zone, as an aspect on a 3D Node. Requires a Shape (its geometry); creates a static SENSOR
2
+ // body from it, so a physics body overlapping the zone fires the node's 'enter' / 'exit' events
3
+ // instead of colliding. A trigger does not block movement, and is still pointer-pickable.
4
+ //
5
+ // const goal = new Mesh(box(), material)
6
+ // .aspect(Shape, { box: [1, 2, 1] })
7
+ // .aspect(Trigger)
8
+ // goal.addEventListener('enter', other => win(other))
9
+ // goal.addEventListener('exit', other => …)
10
+
11
+ import { Aspect } from "../core/Aspect"
12
+ import { Shape } from "./Shape"
13
+ import { ensurePhysicsEvents } from "./physicsEvents"
14
+ import type { Node } from "./Node"
15
+
16
+ export class Trigger extends Aspect<"trigger", Node> {
17
+ static readonly aspect = "trigger"
18
+
19
+ private _bodyId = 0
20
+ /** Native body id (0 if no physics support). */
21
+ get id(): number { return this._bodyId }
22
+
23
+ onAttach(): void {
24
+ if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
25
+ const shape = this.node.get(Shape)
26
+ if (!shape) {
27
+ throw new Error("Trigger requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Trigger)")
28
+ }
29
+ const shapeId = shape._claim()
30
+ this._bodyId = _creator.physicsCreateBody(this.node.id, shapeId, 0 /* static */, 0, true /* sensor */, false)
31
+ shape._ownBody(this._bodyId)
32
+ ensurePhysicsEvents() // routes overlap events → the node's 'enter' / 'exit'
33
+ }
34
+
35
+ onDetach(): void {
36
+ if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
37
+ const shape = this.node.get(Shape)
38
+ if (shape) {
39
+ shape._ownBody(0) // the Shape held OUR body id — forget it, or its own detach removes it twice (native crash)
40
+ shape._recreatePickBody()
41
+ }
42
+ }
43
+
44
+ // No moveTo(): `node.position = p` moves the node AND the sensor body it owns (see Node._xf).
45
+ }
@@ -1,329 +1,137 @@
1
- // Animator — THE animation system, reached as `node.anim`. Every Model carries one pre-attached (its
2
- // clip list = the GLB's embedded clips; nothing native exists until the first play()). The whole
3
- // per-frame loop — transitions, blend spaces, one-shot hand-over, root motion — runs in the engine;
4
- // the SDK only issues calls. See docs/animation-v2-plan.md.
5
- //
6
- // A layer shows ONE source at a time (its one-shot, its loop, or nothing) and every switch is an
7
- // INERTIALIZED transition: the pose the layer showed does not fade away, its difference to the new
8
- // source decays over `fade` (default 0.2 s; `fade: 0` = cut). Nothing stacks, so a clip may be
9
- // replaced however far along it is, as often as you like — a planted foot the two clips share stays
10
- // planted through the transition.
11
- //
12
- // const hero = await Model.load(asset('./hero.glb'))
13
- // hero.anim.playLoop('Idle') // the layer's LOOP: what it rests on
14
- // await hero.anim.play('Jump', { fadeIn: 0.1, fadeOut: 0.2 }) // a one-shot over it, back to the loop after
15
- //
16
- // hero.anim.addClip('slash', slash) // clips from other files / procedural / sliced
17
- // const loco = hero.anim.playLoop({ Idle: 0, Walking: 2, 'Fast Run': 6 }) // a blend as the loop
18
- // loco.value = hero.controller.velocity.length // where we are on the blend axis
19
- // const kick = hero.anim.play('slash', { fadeIn: 0.1, fadeOut: 0.3 }) // resolves at its hand-over
20
- // kick.clip.addEvent(0.4, 'hit'); hero.anim.on('hit', () => …)
21
- // if (await kick) hero.anim.playLoop('Crouch', { fade: 0.3 }) // resolved at the hand-over: what you start now is what it fades into
22
- // const upper = hero.anim.addLayer({ mask: 'Spine1' })
23
- // upper.playLoop('Aim', { fade: 0.3 }) // arms aim, legs keep walking
24
- //
25
- // Any Node hierarchy works, not just Models: tracks bind to child names (a robot arm of meshes).
26
-
27
- import { Aspect } from "../../core/Aspect"
28
- import { Vec3 } from "../../math/vec"
29
- import type { Node } from "../Node"
30
- import type { AnimationClip } from "./AnimationClip"
31
- import { Core, type ActiveClip, type ClipCurves, type ClipEventHandler, type KneeAxisReport, type LayerOptions, type LoopDef, type LoopOptions, type PlayOptions, type StepHandler, type StopOptions } from "./core"
32
- import type { Loop } from "./Loop"
33
- import type { Layer } from "./Layer"
34
- import type { Playback } from "./Playback"
35
-
36
- /** Level of detail for a GLB instance (docs/lod-plan.md): `'auto'` = the engine's pick by screen size and
37
- * visibility, or a fixed level 0 (full) … 3 (coarsest mesh, animation every 4th frame without fingers). */
38
- export type LodMode = "auto" | 0 | 1 | 2 | 3
39
-
40
- /** @internal Push a node's LOD override to the host: the Model's mesh level and the Animator's rate
41
- * (an Animator set to 'full' wins over a model level — the pose stays exact, the mesh may still coarsen). */
42
- export const _pushLod = (node: Node): void => {
43
- if (!_creator.setLod) return
44
- const mesh = (node as { _lodMesh?: number })._lodMesh ?? -1
45
- const anim = node.get(Animator)
46
- _creator.setLod(node.id, mesh, anim?._lod === "full" ? 0 : mesh)
47
- }
48
-
49
- /** What `Animator.warp` turns on. Speeds are m/s, angles degrees, distances metres. */
50
- export type WarpOptions = {
51
- /** Fit the stride to the speed the body actually travels at. `[min, max]` clamps the scale
52
- * (default 0.85…1.2). It is a CORRECTION: a pack whose takes already read right at the speeds it
53
- * is played at wants none of this, and a wide range only lets the legs be stretched into shapes
54
- * nobody recorded. Open it for a pack that must cover speeds it was never recorded at. */
55
- stride?: boolean | [number, number]
56
- /** Turn the lower body toward where the body really travels; a number caps the turn in degrees
57
- * (default 20). The spine counter-turns, so the chest keeps facing where it faced — the whole twist
58
- * lives in one joint, which is why a few degrees read as a lean and a lot reads as a broken back.
59
- * Only applied while the gait LOOP shows: a start, a turn or a stop walks a path of its own. */
60
- orientation?: boolean | number
61
- /** Below this speed — the game's or the clip's — both warps are off (default 0.2). */
62
- minSpeed?: number
63
- /** How far the pelvis may drop to keep a stretched leg from locking straight (default 0.25). */
64
- pelvis?: number
65
- /** The stride scale's own spring, seconds (default 0.15). The body's speed is continuous but the
66
- * shown clip's recorded one steps at every switch, so the scale is smoothed rather than followed. */
67
- strideTime?: number
68
- }
69
-
70
- const WARP = { STRIDE: 0, STRIDE_MIN: 1, STRIDE_MAX: 2, ORIENT: 3, ORIENT_MAX: 4, ORIENT_TIME: 5, MIN_SPEED: 6, PELVIS: 7, STRIDE_TIME: 8, COUNT: 9 }
71
-
72
- /** What `Animator.feet` sets: which bones the feet are, and what the engine does with them. Distances
73
- * are metres, times seconds. Every key is optional and only the keys given change — set the bones
74
- * once, switch the lock on somewhere else. */
75
- export type FeetOptions = {
76
- /** The contact bones per side — `'LeftFoot'`, or with a toe / ball `['LeftFoot', 'LeftToeBase']`.
77
- * Default: classified from the bone names (Mixamo / Unity / Blender / UE). Set them for a rig the
78
- * classifier misses; it re-bakes every clip's contacts and phase. */
79
- left?: string | string[]
80
- right?: string | string[]
81
- /** FOOT LOCK: a foot the shown clip calls planted (its baked contacts, else a runtime detector) is
82
- * pinned where it landed — heel to ball, rolling as the clip rolls — and the leg re-solved to keep it
83
- * there while the body moves on. What hides the last of a transition's slide: the pose the new clip
84
- * starts from is not the one the old clip ended in, and the difference used to be dragged out of
85
- * the standing foot over the blend. Off by default; a `Locomotion` turns it on. */
86
- lock?: boolean
87
- /** GROUND IK: each foot is put on the ground the engine probes under it (stairs, a slope, a kerb),
88
- * aligned to its normal, and the pelvis lowered so the lower leg can reach — the feet stop hanging
89
- * in the air on a step down and sinking into a step up. Needs a physics world to probe; without
90
- * one the ground is the node's own plane. Off by default. */
91
- ik?: boolean
92
- /** How far the pelvis may drop for the ground (default 0.35). */
93
- pelvis?: number
94
- /** The anchor's leash: a locked foot never absorbs more residual than this — beyond it the anchor
95
- * follows the animation instead of fighting it (default 0.10). */
96
- unlockDistance?: number
97
- /** The lock's ease in / out, seconds (default 0.08 / 0.12). */
98
- lockIn?: number
99
- lockOut?: number
100
- /** 0..1: how much the foot tilts onto the ground normal (default 1). */
101
- align?: number
102
- /** The probe ray's reach above and below the ankle (default 0.6) — a step taller than this is a
103
- * hole to the probe. */
104
- probe?: number
105
- /** The lock plants only once the animated ankle moves slower than this, m/s (default 0.2: a foot
106
- * the take itself holds still). Raise it to pin a foot a transition is still dragging — a run
107
- * entered from standing lands its first foot while the offset from the idle still decays. */
108
- plantSpeed?: number
109
- }
110
-
111
- /** One foot after this frame's evaluation (`Animator.foot`): whether the lock holds it, the lock's
112
- * weight (eased 0…1), where it was pinned and where the leg was asked to put the ankle — all world. */
113
- export type FootState = { locked: boolean, weight: number, anchor: Vec3, target: Vec3 }
114
-
115
- const FEET = { IK: 0, LOCK: 1, PELVIS: 2, UNLOCK_DIST: 3, LOCK_IN: 4, LOCK_OUT: 5, PELVIS_TIME: 6, ALIGN: 7, PROBE: 8, DETECT_SPEED: 9, DETECT_HEIGHT: 10, PLANT_SPEED: 11, COUNT: 12 }
116
- const footBuf = new Float32Array(8)
117
-
118
- export class Animator extends Aspect<"anim", Node> {
119
- static readonly aspect = "anim"
120
- private _c!: Core
121
- private _rootMotion = false
122
- private _rootRotation = false
123
- /** @internal */
124
- _lod: "auto" | "full" = "auto"
125
-
126
- /** Animation level of detail. `'auto'` (default): a character small on screen or out of view is
127
- * evaluated every 2nd / 4th frame and stops sampling its finger, toe and twist joints; `'full'`:
128
- * every frame, every joint, whatever the distance — a hero seen through a scope, a cutscene actor.
129
- * Independent of `Model.lod` (the mesh level). No-op on hosts without the LOD pass. */
130
- get lod(): "auto" | "full" { return this._lod }
131
- set lod(v: "auto" | "full") { this._lod = v; _pushLod(this.node) }
132
-
133
- onAttach(): void { this._c = new Core(this.node) }
134
- onDetach(): void { this._c.destroy() }
135
-
136
- // ---- clips ----
137
- /** The clip list, in order: the GLB's embedded clips (file order), then the clips you added. An
138
- * added clip with an embedded clip's name takes its place. */
139
- get clips(): readonly AnimationClip[] { return this._c.clips() }
140
- /** One clip by name, or by index into `clips` — e.g. to `slice()` it. `undefined` if there is none. */
141
- clip(ref: string | number): AnimationClip | undefined { return this._c.resolveClip(ref)?.[1] }
142
- /** Add a clip under a name (default: its own `name`) — from another file, procedural, or sliced.
143
- * Overrides an embedded clip of the same name. Chainable. */
144
- addClip(name: string | AnimationClip, clip?: AnimationClip): this {
145
- const c = typeof name === "string" ? clip : name
146
- const n = typeof name === "string" ? name : name.name
147
- if (!c) { console.warn(`Animator.addClip('${n}'): no clip`); return this }
148
- this._c.addClip(n, c)
149
- return this
150
- }
151
-
152
- // ---- playing (the base layer) ----
153
- /** Play a one-shot on the base layer — by name, index, or the first clip with no argument. It
154
- * takes the layer over from whatever it showed, transitioned over `fade` (default 0.2 s).
155
- * Returns a Playback: `await` it — it resolves at the clip's hand-over (`true`; `false` if cut
156
- * short), and what you start right after is what the clip hands the layer to (another one-shot,
157
- * `playLoop(next)`; nothing = back to the loop). */
158
- play(clip?: string | number, options: PlayOptions = {}): Playback { return this._c.play(this._c.layers[0], clip, options) }
159
- /** Set the base layer's LOOP — what it shows when no one-shot plays: a clip (`playLoop('Idle')`) or
160
- * a blend space (`playLoop({ Idle: 0, Run: 6 })`, drive the returned object's `value`). Takes the
161
- * layer over from whatever plays — a one-shot included (it is cut short) — transitioned over
162
- * `fade`. `stop()` removes it. */
163
- playLoop(def: LoopDef, options: LoopOptions = {}): Loop | undefined { return this._c.loop(this._c.layers[0], def, options) }
164
- /** Fade everything out, on every layer (loops included) → rest pose. */
165
- stop(options: StopOptions = {}): this { this._c.stopAll(options.fade ?? 0); return this }
166
- /** The base layer's current loop (the object the last `playLoop()` returned), if any. */
167
- get loop(): Loop | undefined { return this._c.layers[0].loop?.view }
168
- /** A one-shot on the base layer hasn't handed over yet. */
169
- get busy(): boolean { return this._c.busy(this._c.layers[0]) }
170
- /** What every layer shows this frame with its weight: one one-shot, or a loop's members with their
171
- * blend shares (a debug overlay's list). */
172
- get active(): ActiveClip[] { return this._c.layers.flatMap((L) => this._c.active(L)) }
173
-
174
- /** Where the base layer is in the GAIT CYCLE: 0 at a left-foot-down, 0.5 at a right-foot-down,
175
- * whether a loop or a one-shot shows it. -1 when what plays has no cycle (an idle, a hit reaction).
176
- * This is the number `play(clip, { phase: 'match' })` matches against. */
177
- get phase(): number { return this._c.layerPhase(this._c.layers[0]) }
178
-
179
- /** The native animator's id — 0 until something is played or bound. Engine-facing code only. */
180
- get _id(): number { return this._c.id }
181
- /** Bind a clip to the base layer without playing it and return its native slot (-1 = no such clip);
182
- * creates the native animator on first use. What a Locomotion registers its set with. */
183
- _slot(clip: string | number): number { return this._c.slotIndex(clip) }
184
-
185
- /** What the engine measured on a clip once it was bound to this skeleton: foot contacts, the gait
186
- * phase φ(t), and the root's travel / yaw / speed — a controller reads these instead of shipping
187
- * measured tables. Binds the clip on first ask. `undefined` if there is no such clip. */
188
- curves(clip: string | number): ClipCurves | undefined { return this._c.curves(clip) }
189
-
190
- /** The skeleton's calibrated KNEE HINGE AXIS for a side (thigh-local, unit) with its confidence
191
- * report — measured once over every bound clip's knee rotation track; the ground truth
192
- * `curves(clip).kneePoleAt` predicts bend planes from. Undefined = no leg chain, or no knee
193
- * motion bound to calibrate from. */
194
- kneeAxis(side: "left" | "right"): KneeAxisReport | undefined { return this._c.kneeAxis(side) }
195
-
196
- /** STEP WARP v2 knobs (the warp rewrite): `stride` scales each foot's travel-direction offset
197
- * from its hip (the step shortens / lengthens), `lift` = metres ADDED to its height
198
- * (swing-gated; 0 = neutral, negative = a shuffle; half of what it adds raises the pelvis —
199
- * the body steps higher with the foot), `pitch` (degrees, + = toes up) rotates each foot about
200
- * its lateral axis, `slope` (degrees, + = ascending) the invisible staircase — feet on the
201
- * incline + auto pitch; raise the character by tan(slope) × the stride-scaled clip travel to
202
- * hold each planted foot on its tread. Solved in the calibrated knee hinge plane.
203
- * Omit / null = off. A tuning bench's dial — locomotion will drive this itself later. */
204
- setStepWarp(options?: { stride?: number, lift?: number, pitch?: number, slope?: number } | null): void { this._c.setStepWarp(options) }
205
-
206
- /** Scrub: set `clip`'s playhead directly, seconds — for inspectors and debug boards (pair with
207
- * `speed = 0`). The clip should be the one showing; nothing is faded or re-picked. */
208
- seek(clip: string | number, time: number): void { this._c.seekTime(clip, time) }
209
- /** `clip`'s current playhead, seconds (-1 = not bound). */
210
- time(clip: string | number): number { return this._c.timeOf(clip) }
211
- /** Re-aim a playing turn clip's warp (`play({ turn })`) to `deg` for the whole clip — the scale lands on what is
212
- * still to come; `undefined` = off. */
213
- setTurn(clip: string | number, deg: number | undefined): void { this._c.setTurnOf(clip, deg) }
214
-
215
- /** THE FEET (docs/animation-v2-plan.md §2.9): which bones they are, and what the engine does with
216
- * them after the clips are composited — the foot LOCK (a planted foot stays where it landed while the
217
- * body moves on) and GROUND IK (each foot on the ground probed under it, the pelvis lowered). Only
218
- * the keys given change, so the bones and the behaviour can be set from different places:
219
- *
220
- * model.anim.feet = { left: 'LeftFoot', right: 'RightFoot' } // a rig the classifier misses
221
- * model.anim.feet = { lock: true } // (a Locomotion does this itself)
222
- * model.anim.feet = { lock: true, ik: true, pelvis: 0.3 } // stairs and slopes
223
- *
224
- * The engine probes the ground itself (against what a character can stand on) and reads the
225
- * CharacterController's ground state; nothing is fed per frame. See `FeetOptions`. */
226
- set feet(f: FeetOptions) {
227
- const o = this._feetOpts
228
- Object.assign(o, f)
229
- if (f.left !== undefined || f.right !== undefined) {
230
- const join = (v: string | string[] | undefined): string => v === undefined ? "" : Array.isArray(v) ? v.join("\n") : v
231
- this._c.setFeet(join(o.left), join(o.right))
232
- }
233
- const p = new Float32Array(FEET.COUNT)
234
- // the engine's own defaults (canimAnimatorFeetDefaults)
235
- p[FEET.PELVIS] = 0.35; p[FEET.UNLOCK_DIST] = 0.10; p[FEET.LOCK_IN] = 0.08; p[FEET.LOCK_OUT] = 0.12
236
- p[FEET.PELVIS_TIME] = 0.15; p[FEET.ALIGN] = 1; p[FEET.PROBE] = 0.6; p[FEET.DETECT_SPEED] = 0.35; p[FEET.DETECT_HEIGHT] = 0.12
237
- p[FEET.PLANT_SPEED] = 0.2
238
- if (o.ik) p[FEET.IK] = 1
239
- if (o.lock) p[FEET.LOCK] = 1
240
- if (o.pelvis !== undefined) p[FEET.PELVIS] = o.pelvis
241
- if (o.unlockDistance !== undefined) p[FEET.UNLOCK_DIST] = o.unlockDistance
242
- if (o.lockIn !== undefined) p[FEET.LOCK_IN] = o.lockIn
243
- if (o.lockOut !== undefined) p[FEET.LOCK_OUT] = o.lockOut
244
- if (o.align !== undefined) p[FEET.ALIGN] = o.align
245
- if (o.probe !== undefined) p[FEET.PROBE] = o.probe
246
- if (o.plantSpeed !== undefined) p[FEET.PLANT_SPEED] = o.plantSpeed
247
- this._c.setFeetParams(p)
248
- }
249
- private readonly _feetOpts: FeetOptions = {}
250
-
251
- /** One foot's state after this frame's evaluation — where the lock holds it and with what weight
252
- * (a debug beam under the foot). `undefined` on a host without the feet stage, or before anything
253
- * played. */
254
- foot(side: "left" | "right"): FootState | undefined {
255
- if (!this._c.footState(side === "left" ? 0 : 1, footBuf)) return undefined
256
- return { locked: footBuf[0] > 0.5, weight: footBuf[1], anchor: new Vec3(footBuf[2], footBuf[3], footBuf[4]), target: new Vec3(footBuf[5], footBuf[6], footBuf[7]) }
257
- }
258
-
259
- /** WARPING (docs/animation-v2-plan.md §2.7) — the pose is fitted to what the body actually does,
260
- * after the clips are composited and before the feet:
261
- *
262
- * `stride` — each leg's hip→foot vector is scaled ALONG the travel direction by the ratio of the
263
- * game speed to the shown clip's own (clamped, default 0.6…1.5), the foot height kept, and the
264
- * pelvis lowered when that would overextend a leg. A walk played at 1.9 m/s stops skating; a
265
- * speed between two gaits becomes continuous instead of "two clips and a crossfade".
266
- * `orientation` — the lower body turns from the clip's travel direction toward the one the body
267
- * really travels in (capped, default 60°) and the spine counter-turns, so the chest keeps its
268
- * facing. Arcs and strafing stop needing a clip per angle.
269
- *
270
- * Both need to know the body's motion, which a `Locomotion` feeds every frame; without one, set
271
- * `_creator.animatorSetMotion` yourself or leave warping off. Off by default.
272
- *
273
- * model.anim.warp = true // both, with the defaults
274
- * model.anim.warp = { stride: [0.7, 1.4], orientation: 45 }
275
- */
276
- set warp(w: WarpOptions | boolean) {
277
- const o: WarpOptions = w === true ? { stride: true, orientation: true } : w === false ? {} : w
278
- const p = new Float32Array(WARP.COUNT)
279
- // the engine's own defaults (canimAnimatorWarpDefaults): what is not asked for stays neutral
280
- p[WARP.STRIDE_MIN] = 0.85; p[WARP.STRIDE_MAX] = 1.2; p[WARP.ORIENT_MAX] = 20
281
- p[WARP.ORIENT_TIME] = 0.15; p[WARP.MIN_SPEED] = 0.2; p[WARP.PELVIS] = 0.25
282
- p[WARP.STRIDE_TIME] = 0.15
283
- if (o.stride) {
284
- p[WARP.STRIDE] = 1
285
- if (Array.isArray(o.stride)) { p[WARP.STRIDE_MIN] = o.stride[0]; p[WARP.STRIDE_MAX] = o.stride[1] }
286
- }
287
- if (o.orientation) {
288
- p[WARP.ORIENT] = 1
289
- if (typeof o.orientation === "number") p[WARP.ORIENT_MAX] = o.orientation
290
- }
291
- if (o.minSpeed !== undefined) p[WARP.MIN_SPEED] = o.minSpeed
292
- if (o.pelvis !== undefined) p[WARP.PELVIS] = o.pelvis
293
- if (o.strideTime !== undefined) p[WARP.STRIDE_TIME] = o.strideTime
294
- this._c.setWarp(p)
295
- }
296
-
297
- // ---- footsteps ----
298
- /** A foot planted (world position) — audio, dust, decals. Fires for what the BASE layer shows,
299
- * from the clip's own contacts; a clip with no contacts fires nothing. */
300
- onStep(cb: StepHandler): this { this._c.onStepHandler(cb); return this }
301
- offStep(cb: StepHandler): this { this._c.offStepHandler(cb); return this }
302
-
303
- // ---- layers ----
304
- /** A new layer on top (masked override / additive). The returned object is its handle. */
305
- addLayer(options: LayerOptions = {}): Layer { return this._c.addLayer(options) }
306
-
307
- // ---- clip events (`clip.addEvent(0.4, 'hit')`) ----
308
- on(event: string, cb: ClipEventHandler): this { this._c.on(event, cb); return this }
309
- off(event: string, cb: ClipEventHandler): this { this._c.off(event, cb); return this }
310
-
311
- // ---- props ----
312
- /** Global playback rate: 0.3 = slow-mo, 0 = pause. */
313
- get speed(): number { return this._c.speed }
314
- set speed(v: number) { this._c.setSpeed(v) }
315
- /** Root motion: the root bone's horizontal travel comes OFF the pose and moves the node instead —
316
- * or its CharacterController (on this node or an ancestor) as a velocity, so it collides. For
317
- * clips whose hips actually travel (Mixamo without "In Place", lunges, rolls). A character that
318
- * moves under a `Locomotion` gets this from its displacement mode instead. */
319
- get rootMotion(): boolean { return this._rootMotion }
320
- set rootMotion(on: boolean) { this._rootMotion = on; this._c.setRootMotion(on, this._rootRotation) }
321
- /** With `rootMotion`, the root bone's TURN (its yaw about the node's up, since the clip's first
322
- * frame) is root motion too: it comes off the pose and turns the node the travel lands on — the
323
- * character (or the model) faces where a turn-start / turn-in-place / arc clip took it, and the
324
- * crossfade into the next clip keeps that heading instead of swinging back. Off by default: a
325
- * walk cycle's hip sway is a turn too, and most rigs want it in the pose; turn it on for a rig
326
- * whose root bone carries the heading (`lecodes assets retarget --root-rotation yaw`). */
327
- get rootRotation(): boolean { return this._rootRotation }
328
- set rootRotation(on: boolean) { this._rootRotation = on; if (this._rootMotion) this._c.setRootMotion(true, on) }
329
- }
1
+ // Animator — the animation system, reached as `node.anim`. Every Model carries one (its clip list = the
2
+ // GLB's embedded clips); nothing native exists until the first play(). The per-frame work — transitions,
3
+ // blend spaces, hand-overs, root motion — runs in the engine; the SDK only issues calls.
4
+ // Docs: sdk/docs/3d/animation.md (the contract), docs/animation-v2-plan.md (the engine).
5
+ //
6
+ // const hero = await Model.load(asset('./hero.glb'))
7
+ // hero.anim.playLoop('Idle') // the loop: what the character rests on
8
+ // await hero.anim.play('Jump', { fadeIn: 0.1, fadeOut: 0.2 }) // a one-shot over it, back to the loop after
9
+ //
10
+ // const loco = hero.anim.playLoop({ Idle: 0, Walk: 2, Run: 6 }) // a blend space as the loop
11
+ // loco.value = hero.controller.velocity.length // where we are on its axis
12
+ // const upper = hero.anim.addLayer({ mask: 'Spine1' })
13
+ // upper.playLoop('Aim') // arms aim, legs keep walking
14
+ //
15
+ // Every switch is an inertialized transition: the pose shown does not fade away, its difference to the
16
+ // new source decays over `fade` (default 0.2 s; 0 = cut). A clip may be replaced at any time.
17
+ //
18
+ // The surface, top to bottom: clips → playing → root motion → the feet and the warp (`anim.feet`,
19
+ // `anim.warp` — locomotion's post-processing of the pose) → level of detail → engine-facing.
20
+
21
+ import { Aspect } from "../../core/Aspect"
22
+ import type { Node } from "../Node"
23
+ import type { AnimationClip } from "./AnimationClip"
24
+ import { Core, type ActiveClip, type ClipInfo, type ClipEventHandler, type LayerOptions, type LoopDef, type LoopOptions, type PlayOptions, type StopOptions } from "./core"
25
+ import { Feet } from "./Feet"
26
+ import { Warp } from "./Warp"
27
+ import type { Loop } from "./Loop"
28
+ import type { Layer } from "./Layer"
29
+ import type { Playback } from "./Playback"
30
+
31
+ /** Level of detail for a GLB instance (docs/lod-plan.md): `'auto'` = the engine's pick by screen size and
32
+ * visibility, or a fixed level 0 (full) … 3 (coarsest mesh, animation every 4th frame without fingers). */
33
+ export type LodMode = "auto" | 0 | 1 | 2 | 3
34
+
35
+ /** @internal Push a node's LOD override to the host: the Model's mesh level and the Animator's rate
36
+ * (an Animator set to 'full' wins over a model level — the pose stays exact, the mesh may still coarsen). */
37
+ export const _pushLod = (node: Node): void => {
38
+ if (!_creator.setLod) return
39
+ const mesh = (node as { _lodMesh?: number })._lodMesh ?? -1
40
+ const anim = node.get(Animator)
41
+ _creator.setLod(node.id, mesh, anim?._lod === "full" ? 0 : mesh)
42
+ }
43
+
44
+ export class Animator extends Aspect<"anim", Node> {
45
+ static readonly aspect = "anim"
46
+ private _c!: Core
47
+ private _rootMotion = false
48
+ private _rootRotation = false
49
+ /** @internal */
50
+ _lod: "auto" | "full" = "auto"
51
+ /** The feet: the contact bones, the foot lock, ground IK, footstep events. See `Feet`. */
52
+ feet!: Feet
53
+ /** The warp: stride and orientation fitted to the body's real motion, the step warp dials. See `Warp`. */
54
+ warp!: Warp
55
+
56
+ onAttach(): void { this._c = new Core(this.node); this.feet = new Feet(this._c); this.warp = new Warp(this._c) }
57
+ onDetach(): void { this._c.destroy() }
58
+
59
+ // ---- clips ----------------------------------------------------------------------------------
60
+ /** The clip list, in order: the GLB's embedded clips, then the clips you added (an added clip with an
61
+ * embedded clip's name takes its place). */
62
+ get clips(): readonly AnimationClip[] { return this._c.clips() }
63
+ /** One clip by name or index — the resource: its name, duration, tracks, events. `undefined` if none. */
64
+ clip(ref: string | number): AnimationClip | undefined { return this._c.resolveClip(ref)?.[1] }
65
+ /** Add a clip under a name (default: its own) — from another file, procedural, or sliced. Chainable. */
66
+ addClip(name: string | AnimationClip, clip?: AnimationClip): this {
67
+ const c = typeof name === "string" ? clip : name
68
+ const n = typeof name === "string" ? name : name.name
69
+ if (!c) { console.warn(`Animator.addClip('${n}'): no clip`); return this }
70
+ this._c.addClip(n, c)
71
+ return this
72
+ }
73
+ /** What the engine measured about a clip on THIS skeleton (unlike `clip()`, which is the file's data):
74
+ * speed, travel, turn, the foot contacts, the gait phase, its cycle, and comparisons with other clips.
75
+ * Binds the clip on first ask. `undefined` if there is no such clip. */
76
+ clipInfo(clip: string | number): ClipInfo | undefined { return this._c.clipInfo(clip) }
77
+
78
+ // ---- playing (the base layer; `addLayer()` for more) ---------------------------------------
79
+ /** Play a one-shot: by name, index, or the first clip. It takes the layer over from whatever it showed,
80
+ * transitioned over `fade`. `await` the Playback: it resolves at the hand-over (`true`, or `false` if cut
81
+ * short), and what you start right then is what the clip hands over to (nothing = back to the loop). */
82
+ play(clip?: string | number, options: PlayOptions = {}): Playback { return this._c.play(this._c.layers[0], clip, options) }
83
+ /** Set the LOOP — what shows when no one-shot plays: a clip, or a blend space (`{ Idle: 0, Run: 6 }`, drive
84
+ * the returned object's `value`). Takes the layer over, a one-shot included. `stop()` removes it. */
85
+ playLoop(def: LoopDef, options: LoopOptions = {}): Loop | undefined { return this._c.loop(this._c.layers[0], def, options) }
86
+ /** Fade everything out, on every layer → the rest pose. */
87
+ stop(options: StopOptions = {}): this { this._c.stopAll(options.fade ?? 0); return this }
88
+ /** The current loop (the object the last `playLoop()` returned), if any. */
89
+ get loop(): Loop | undefined { return this._c.layers[0].loop?.view }
90
+ /** A one-shot hasn't handed over yet. */
91
+ get busy(): boolean { return this._c.busy(this._c.layers[0]) }
92
+ /** What every layer shows this frame, with weights: a one-shot, or a loop's members with their shares. */
93
+ get active(): ActiveClip[] { return this._c.layers.flatMap((L) => this._c.active(L)) }
94
+ /** Where the base layer is in the GAIT CYCLE: 0 at a left-foot-down, 0.5 at a right-foot-down; -1 when
95
+ * what plays has no cycle. What `play(clip, { phase: 'match' })` matches against. */
96
+ get phase(): number { return this._c.layerPhase(this._c.layers[0]) }
97
+ /** A clip's playhead, seconds (-1 = not bound). */
98
+ time(clip: string | number): number { return this._c.timeOf(clip) }
99
+ /** Scrub a clip's playhead, seconds — inspectors and debug boards (pair with `speed = 0`). Nothing is
100
+ * faded or re-picked. */
101
+ seek(clip: string | number, time: number): void { this._c.seekTime(clip, time) }
102
+ /** Re-aim a playing turn clip's warp (`play({ turn })`) to `deg` for the rest of the clip; `undefined` = off. */
103
+ setTurn(clip: string | number, deg: number | undefined): void { this._c.setTurnOf(clip, deg) }
104
+ /** Global playback rate: 0.3 = slow-mo, 0 = pause. */
105
+ get speed(): number { return this._c.speed }
106
+ set speed(v: number) { this._c.setSpeed(v) }
107
+ /** Clip events (`clip.addEvent(0.4, 'hit')` → `anim.on('hit', …)`). */
108
+ on(event: string, cb: ClipEventHandler): this { this._c.on(event, cb); return this }
109
+ off(event: string, cb: ClipEventHandler): this { this._c.off(event, cb); return this }
110
+ /** A new layer on top (masked override / additive); the returned object is its handle. */
111
+ addLayer(options: LayerOptions = {}): Layer { return this._c.addLayer(options) }
112
+
113
+ // ---- root motion ----------------------------------------------------------------------------
114
+ /** The root bone's horizontal travel comes OFF the pose and moves the node — or its CharacterController
115
+ * (on this node or an ancestor) as a velocity, so it collides. For clips whose hips actually travel. A
116
+ * character under a `Locomotion` gets this from its displacement mode instead. */
117
+ get rootMotion(): boolean { return this._rootMotion }
118
+ set rootMotion(on: boolean) { this._rootMotion = on; this._c.setRootMotion(on, this._rootRotation) }
119
+ /** With `rootMotion`: the root bone's TURN is root motion too — it comes off the pose and turns the node,
120
+ * so a turn clip leaves the character facing where it took it. Off by default (a walk's hip sway is a
121
+ * turn too); on for a rig whose root carries the heading (`lecodes assets retarget --root-rotation yaw`). */
122
+ get rootRotation(): boolean { return this._rootRotation }
123
+ set rootRotation(on: boolean) { this._rootRotation = on; if (this._rootMotion) this._c.setRootMotion(true, on) }
124
+
125
+ // ---- level of detail --------------------------------------------------------------------------
126
+ /** `'auto'` (default): a character small on screen or out of view is evaluated every 2nd / 4th frame
127
+ * without its finger, toe and twist joints; `'full'`: every frame, every joint (a hero seen through a
128
+ * scope). Independent of `Model.lod`, the mesh level. */
129
+ get lod(): "auto" | "full" { return this._lod }
130
+ set lod(v: "auto" | "full") { this._lod = v; _pushLod(this.node) }
131
+
132
+ // ---- engine-facing (a Locomotion, a bench) --------------------------------------------------
133
+ /** The native animator's id — 0 until something is played or bound. */
134
+ get _id(): number { return this._c.id }
135
+ /** Bind a clip to the base layer without playing it; its native slot (-1 = no such clip). */
136
+ _slot(clip: string | number): number { return this._c.slotIndex(clip) }
137
+ }