lecodes-sdk 0.19.2 → 0.20.2
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/global.d.ts +62 -0
- package/dist/host.d.ts +3 -0
- package/dist/types/audio/Bus.d.ts +45 -0
- package/dist/types/audio/Sound.d.ts +28 -0
- package/dist/types/audio/Voice.d.ts +27 -0
- package/dist/types/audio/audio.d.ts +83 -0
- package/dist/types/audio/support.d.ts +1 -0
- package/dist/types/gl/AudioSource.d.ts +60 -0
- package/dist/types/gl/AudioZone.d.ts +32 -0
- package/dist/types/gl/DecalSet.d.ts +103 -0
- package/dist/types/gl/Geometry.d.ts +5 -0
- package/dist/types/gl/Light.d.ts +7 -0
- package/dist/types/gl/Locomotion.d.ts +3 -1
- package/dist/types/gl/Material.d.ts +86 -2
- package/dist/types/gl/Mesh.d.ts +11 -0
- package/dist/types/gl/Scene.d.ts +23 -0
- package/dist/types/gl/SceneAudio.d.ts +11 -0
- package/dist/types/gl/Texture.d.ts +29 -1
- package/dist/types/gl/animation/AnimationClip.d.ts +25 -12
- package/dist/types/gl/animation/Animator.d.ts +51 -183
- package/dist/types/gl/animation/Feet.d.ts +85 -0
- package/dist/types/gl/animation/Warp.d.ts +53 -0
- package/dist/types/gl/animation/core.d.ts +61 -17
- package/dist/types/gl/state.d.ts +0 -1
- package/dist/types/inject.d.ts +17 -2
- package/dist/types/plugins/map.d.ts +174 -0
- package/dist/types/runtime/input.d.ts +11 -0
- package/dist/types/ui/UIImage.d.ts +15 -5
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/src/audio/Bus.ts +102 -0
- package/src/audio/Sound.ts +96 -0
- package/src/audio/Voice.ts +102 -0
- package/src/audio/audio.ts +161 -0
- package/src/audio/support.ts +6 -0
- package/src/bridges.d.ts +1481 -1345
- package/src/compile/__tests__/compile.test.ts +12 -0
- package/src/compile/compileProject.ts +35 -15
- package/src/compile/index.ts +3 -0
- package/src/core/Aspect.ts +34 -9
- package/src/g2/Scene2D.ts +7 -0
- package/src/gl/AudioSource.ts +113 -0
- package/src/gl/AudioZone.ts +75 -0
- package/src/gl/DecalSet.ts +233 -0
- package/src/gl/Geometry.ts +5 -0
- package/src/gl/Light.ts +16 -0
- package/src/gl/Lightmap.ts +3 -2
- package/src/gl/Locomotion.ts +7 -5
- package/src/gl/Material.ts +152 -4
- package/src/gl/Mesh.ts +20 -1
- package/src/gl/Particles.ts +3 -3
- package/src/gl/Scene.ts +42 -8
- package/src/gl/SceneAudio.ts +26 -0
- package/src/gl/Texture.ts +43 -3
- package/src/gl/Vehicle.ts +5 -5
- package/src/gl/animation/AnimationClip.ts +43 -20
- package/src/gl/animation/Animator.ts +138 -329
- package/src/gl/animation/Feet.ts +134 -0
- package/src/gl/animation/Loop.ts +3 -1
- package/src/gl/animation/Warp.ts +96 -0
- package/src/gl/animation/core.ts +741 -670
- package/src/gl/state.ts +6 -6
- package/src/host.d.ts +3 -0
- package/src/inject.ts +23 -2
- package/src/plugins/map.ts +396 -0
- package/src/runtime/input.ts +6 -1
- package/src/ui/UIImage.ts +21 -7
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
// AnimationClip.load(asset('./run.glb')),
|
|
11
11
|
// ])
|
|
12
12
|
// const slash = await AnimationClip.load(asset('./attacks.glb'), 'Slash') // one out of many
|
|
13
|
-
// const bob = AnimationClip.
|
|
13
|
+
// const bob = AnimationClip.fromCurves({ tracks: { Hips: { position: [[0, [0,0,0]], [1, [0,0.05,0]]] } } })
|
|
14
|
+
// const stopM = AnimationClip.from(stop, { mirror: true }) // the other side of the body
|
|
15
|
+
// const kick = AnimationClip.from(take, { from: 0.2, to: 1.1 }) // a window of a longer take
|
|
14
16
|
|
|
15
17
|
import { fetch, type FetchResponse } from "../../runtime/fetch"
|
|
16
18
|
import type { Vec3Like } from "../../math/vec"
|
|
@@ -37,6 +39,20 @@ export type ClipDef = {
|
|
|
37
39
|
|
|
38
40
|
export type ClipInfo = { name: string, duration: number, trackCount: number }
|
|
39
41
|
|
|
42
|
+
/** What `AnimationClip.from(clip, …)` derives from a clip. */
|
|
43
|
+
export type DeriveOptions = {
|
|
44
|
+
/** The clip MIRRORED — left ↔ right, the motion on the other side of the body (a stop that brakes on the
|
|
45
|
+
* left foot brakes on the right). Derived on the rig of the clip's own file (joints pair by name, the
|
|
46
|
+
* sagittal plane comes from the rest pose), so the result is the same on every model: its contacts,
|
|
47
|
+
* phase, root motion and heading are the mirrored ones, its name is `<name>_M`. Only clips from a GLB
|
|
48
|
+
* carry a rig; a curve-built clip cannot be mirrored. */
|
|
49
|
+
mirror?: boolean
|
|
50
|
+
/** Start of the window, seconds of the source (default 0). */
|
|
51
|
+
from?: number
|
|
52
|
+
/** End of the window, seconds of the source (default the clip's end). */
|
|
53
|
+
to?: number
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
const PATH = { position: 0, rotation: 1, scale: 2 } as const
|
|
41
57
|
const INTERP = { linear: 0, step: 1 } as const
|
|
42
58
|
|
|
@@ -90,8 +106,8 @@ export class AnimationClip {
|
|
|
90
106
|
|
|
91
107
|
/** Mark a moment of the clip (SECONDS from its start) with an event name: `kick.addEvent(0.4, 'hit')`
|
|
92
108
|
* → `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; `
|
|
94
|
-
* the
|
|
109
|
+
* Events are part of the clip: every model playing it gets them; `AnimationClip.from(clip, { from, to })`
|
|
110
|
+
* keeps the ones inside the window, re-timed. Chainable. */
|
|
95
111
|
addEvent(time: number, name: string): this {
|
|
96
112
|
this._events.push({ t: Math.min(Math.max(0, time), this.duration), name })
|
|
97
113
|
this._events.sort((a, b) => a.t - b.t)
|
|
@@ -128,7 +144,7 @@ export class AnimationClip {
|
|
|
128
144
|
|
|
129
145
|
/** Build a clip from curves in code — no DCC needed. Keys are `[time, value]`; a track binds to the
|
|
130
146
|
* node of that name when the clip is used by an Animator (bones, or any child node). */
|
|
131
|
-
static
|
|
147
|
+
static fromCurves(def: ClipDef): AnimationClip {
|
|
132
148
|
const names: string[] = []
|
|
133
149
|
const data: number[] = [0, 0]
|
|
134
150
|
let trackCount = 0
|
|
@@ -153,30 +169,37 @@ export class AnimationClip {
|
|
|
153
169
|
if (def.duration !== undefined) duration = def.duration
|
|
154
170
|
data[1] = duration
|
|
155
171
|
const setId = _creator.createClipFromTracks(names.join("\n"), new Float32Array(data))
|
|
156
|
-
if (setId === 0) throw new Error("AnimationClip.
|
|
172
|
+
if (setId === 0) throw new Error("AnimationClip.fromCurves: invalid track data")
|
|
157
173
|
const info = readInfo(setId)[0] ?? { name: "clip", duration, trackCount }
|
|
158
174
|
return new AnimationClip(setId, 0, info)
|
|
159
175
|
}
|
|
160
176
|
|
|
161
|
-
/** A
|
|
162
|
-
* (`
|
|
163
|
-
* action,
|
|
177
|
+
/** A NEW clip derived from `clip`: its mirror (`{ mirror: true }` — the other side of the body), a window
|
|
178
|
+
* of it (`{ from, to }` seconds of the source, re-timed to 0; array-slice semantics), or both. Cut a
|
|
179
|
+
* too-long take down to the action, carve several sub-clips out of one packed timeline, get the
|
|
180
|
+
* left-footed stop from the right-footed one:
|
|
164
181
|
*
|
|
165
|
-
* clips: { Kick:
|
|
182
|
+
* clips: { Kick: AnimationClip.from(kick, { from: 0.2, to: 1.1 }), StopM: AnimationClip.from(stop, { mirror: true }) }
|
|
166
183
|
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
const st = Math.max(0,
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
184
|
+
* A window drops the keys outside it and interpolates exact boundary values in, so the clip starts and
|
|
185
|
+
* ends precisely on the source's pose at the cut points. Everything downstream (blend spaces, events'
|
|
186
|
+
* times, root motion, foot contacts, phase sync) sees a normal clip. */
|
|
187
|
+
static from(clip: AnimationClip, options: DeriveOptions): AnimationClip {
|
|
188
|
+
const mirror = options.mirror ?? false
|
|
189
|
+
const windowed = options.from !== undefined || options.to !== undefined
|
|
190
|
+
const st = windowed ? Math.max(0, options.from ?? 0) : -1
|
|
191
|
+
const e = windowed ? Math.min(options.to ?? clip.duration, clip.duration) : -1
|
|
192
|
+
const setId = _creator.deriveClip(clip._set, clip._index, mirror, st, e)
|
|
193
|
+
if (setId === 0) {
|
|
194
|
+
const what = windowed ? `bad range ${options.from ?? 0}–${options.to ?? clip.duration}` : "cannot mirror (no rig in the clip's file, or nothing to mirror)"
|
|
195
|
+
throw new Error(`AnimationClip.from: ${what} of '${clip.name}' (${clip.duration.toFixed(2)}s)`)
|
|
196
|
+
}
|
|
197
|
+
const info = readInfo(setId)[0] ?? { name: mirror ? `${clip.name}_M` : clip.name, duration: windowed ? e - st : clip.duration, trackCount: clip.trackCount }
|
|
177
198
|
const out = new AnimationClip(setId, 0, info)
|
|
178
199
|
// the engine re-timed the event TIMES; carry the names the same way
|
|
179
|
-
out._events =
|
|
200
|
+
out._events = windowed
|
|
201
|
+
? clip._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 }))
|
|
202
|
+
: clip._events.map((ev) => ({ ...ev }))
|
|
180
203
|
return out
|
|
181
204
|
}
|
|
182
205
|
|
|
@@ -1,329 +1,138 @@
|
|
|
1
|
-
// Animator —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// const
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import type {
|
|
30
|
-
import type {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
*
|
|
38
|
-
export
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
*
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
* the
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
* every frame, every joint
|
|
129
|
-
* Independent of `Model.lod
|
|
130
|
-
get lod(): "auto" | "full" { return this._lod }
|
|
131
|
-
set lod(v: "auto" | "full") { this._lod = v; _pushLod(this.node) }
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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', { fade: 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 (a loop, a one-shot, rest) does not fade
|
|
16
|
+
// away, its difference to the new source decays over `fade` — and no `fade` means a cut, never a
|
|
17
|
+
// default. A clip may be replaced at any time; a one-shot on a layer with no loop holds its last frame.
|
|
18
|
+
//
|
|
19
|
+
// The surface, top to bottom: clips → playing → root motion → the feet and the warp (`anim.feet`,
|
|
20
|
+
// `anim.warp` — locomotion's post-processing of the pose) → level of detail → engine-facing.
|
|
21
|
+
|
|
22
|
+
import { Aspect } from "../../core/Aspect"
|
|
23
|
+
import type { Node } from "../Node"
|
|
24
|
+
import type { AnimationClip } from "./AnimationClip"
|
|
25
|
+
import { Core, type ActiveClip, type ClipInfo, type ClipEventHandler, type LayerOptions, type LoopDef, type LoopOptions, type PlayOptions, type StopOptions } from "./core"
|
|
26
|
+
import { Feet } from "./Feet"
|
|
27
|
+
import { Warp } from "./Warp"
|
|
28
|
+
import type { Loop } from "./Loop"
|
|
29
|
+
import type { Layer } from "./Layer"
|
|
30
|
+
import type { Playback } from "./Playback"
|
|
31
|
+
|
|
32
|
+
/** Level of detail for a GLB instance (docs/lod-plan.md): `'auto'` = the engine's pick by screen size and
|
|
33
|
+
* visibility, or a fixed level 0 (full) … 3 (coarsest mesh, animation every 4th frame without fingers). */
|
|
34
|
+
export type LodMode = "auto" | 0 | 1 | 2 | 3
|
|
35
|
+
|
|
36
|
+
/** @internal Push a node's LOD override to the host: the Model's mesh level and the Animator's rate
|
|
37
|
+
* (an Animator set to 'full' wins over a model level — the pose stays exact, the mesh may still coarsen). */
|
|
38
|
+
export const _pushLod = (node: Node): void => {
|
|
39
|
+
if (!_creator.setLod) return
|
|
40
|
+
const mesh = (node as { _lodMesh?: number })._lodMesh ?? -1
|
|
41
|
+
const anim = node.get(Animator)
|
|
42
|
+
_creator.setLod(node.id, mesh, anim?._lod === "full" ? 0 : mesh)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class Animator extends Aspect<"anim", Node> {
|
|
46
|
+
static readonly aspect = "anim"
|
|
47
|
+
private _c!: Core
|
|
48
|
+
private _rootMotion = false
|
|
49
|
+
private _rootRotation = false
|
|
50
|
+
/** @internal */
|
|
51
|
+
_lod: "auto" | "full" = "auto"
|
|
52
|
+
/** The feet: the contact bones, the foot lock, ground IK, footstep events. See `Feet`. */
|
|
53
|
+
feet!: Feet
|
|
54
|
+
/** The warp: stride and orientation fitted to the body's real motion, the step warp dials. See `Warp`. */
|
|
55
|
+
warp!: Warp
|
|
56
|
+
|
|
57
|
+
onAttach(): void { this._c = new Core(this.node); this.feet = new Feet(this._c); this.warp = new Warp(this._c) }
|
|
58
|
+
onDetach(): void { this._c.destroy() }
|
|
59
|
+
|
|
60
|
+
// ---- clips ----------------------------------------------------------------------------------
|
|
61
|
+
/** The clip list, in order: the GLB's embedded clips, then the clips you added (an added clip with an
|
|
62
|
+
* embedded clip's name takes its place). */
|
|
63
|
+
get clips(): readonly AnimationClip[] { return this._c.clips() }
|
|
64
|
+
/** One clip by name or index — the resource: its name, duration, tracks, events. `undefined` if none. */
|
|
65
|
+
clip(ref: string | number): AnimationClip | undefined { return this._c.resolveClip(ref)?.[1] }
|
|
66
|
+
/** Add a clip under a name (default: its own) — from another file, procedural, or sliced. Chainable. */
|
|
67
|
+
addClip(name: string | AnimationClip, clip?: AnimationClip): this {
|
|
68
|
+
const c = typeof name === "string" ? clip : name
|
|
69
|
+
const n = typeof name === "string" ? name : name.name
|
|
70
|
+
if (!c) { console.warn(`Animator.addClip('${n}'): no clip`); return this }
|
|
71
|
+
this._c.addClip(n, c)
|
|
72
|
+
return this
|
|
73
|
+
}
|
|
74
|
+
/** What the engine measured about a clip on THIS skeleton (unlike `clip()`, which is the file's data):
|
|
75
|
+
* speed, travel, turn, the foot contacts, the gait phase, its cycle, and comparisons with other clips.
|
|
76
|
+
* Binds the clip on first ask. `undefined` if there is no such clip. */
|
|
77
|
+
clipInfo(clip: string | number): ClipInfo | undefined { return this._c.clipInfo(clip) }
|
|
78
|
+
|
|
79
|
+
// ---- playing (the base layer; `addLayer()` for more) ---------------------------------------
|
|
80
|
+
/** Play a one-shot: by name, index, or the first clip. It takes the layer over from whatever it showed,
|
|
81
|
+
* transitioned over `fade`. `await` the Playback: it resolves at the hand-over (`true`, or `false` if cut
|
|
82
|
+
* short), and what you start right then is what the clip hands over to (nothing = back to the loop). */
|
|
83
|
+
play(clip?: string | number, options: PlayOptions = {}): Playback { return this._c.play(this._c.layers[0], clip, options) }
|
|
84
|
+
/** Set the LOOP — what shows when no one-shot plays: a clip, or a blend space (`{ Idle: 0, Run: 6 }`, drive
|
|
85
|
+
* the returned object's `value`). Takes the layer over, a one-shot included. `stop()` removes it. */
|
|
86
|
+
playLoop(def: LoopDef, options: LoopOptions = {}): Loop | undefined { return this._c.loop(this._c.layers[0], def, options) }
|
|
87
|
+
/** Fade everything out, on every layer → the rest pose. */
|
|
88
|
+
stop(options: StopOptions = {}): this { this._c.stopAll(options.fade ?? 0); return this }
|
|
89
|
+
/** The current loop (the object the last `playLoop()` returned), if any. */
|
|
90
|
+
get loop(): Loop | undefined { return this._c.layers[0].loop?.view }
|
|
91
|
+
/** A one-shot hasn't handed over yet. */
|
|
92
|
+
get busy(): boolean { return this._c.busy(this._c.layers[0]) }
|
|
93
|
+
/** What every layer shows this frame, with weights: a one-shot, or a loop's members with their shares. */
|
|
94
|
+
get active(): ActiveClip[] { return this._c.layers.flatMap((L) => this._c.active(L)) }
|
|
95
|
+
/** Where the base layer is in the GAIT CYCLE: 0 at a left-foot-down, 0.5 at a right-foot-down; -1 when
|
|
96
|
+
* what plays has no cycle. What `play(clip, { phase: 'match' })` matches against. */
|
|
97
|
+
get phase(): number { return this._c.layerPhase(this._c.layers[0]) }
|
|
98
|
+
/** A clip's playhead, seconds (-1 = not bound). */
|
|
99
|
+
time(clip: string | number): number { return this._c.timeOf(clip) }
|
|
100
|
+
/** Scrub a clip's playhead, seconds — inspectors and debug boards (pair with `speed = 0`). Nothing is
|
|
101
|
+
* faded or re-picked. */
|
|
102
|
+
seek(clip: string | number, time: number): void { this._c.seekTime(clip, time) }
|
|
103
|
+
/** Re-aim a playing turn clip's warp (`play({ turn })`) to `deg` for the rest of the clip; `undefined` = off. */
|
|
104
|
+
setTurn(clip: string | number, deg: number | undefined): void { this._c.setTurnOf(clip, deg) }
|
|
105
|
+
/** Global playback rate: 0.3 = slow-mo, 0 = pause. */
|
|
106
|
+
get speed(): number { return this._c.speed }
|
|
107
|
+
set speed(v: number) { this._c.setSpeed(v) }
|
|
108
|
+
/** Clip events (`clip.addEvent(0.4, 'hit')` → `anim.on('hit', …)`). */
|
|
109
|
+
on(event: string, cb: ClipEventHandler): this { this._c.on(event, cb); return this }
|
|
110
|
+
off(event: string, cb: ClipEventHandler): this { this._c.off(event, cb); return this }
|
|
111
|
+
/** A new layer on top (masked override / additive); the returned object is its handle. */
|
|
112
|
+
addLayer(options: LayerOptions = {}): Layer { return this._c.addLayer(options) }
|
|
113
|
+
|
|
114
|
+
// ---- root motion ----------------------------------------------------------------------------
|
|
115
|
+
/** The root bone's horizontal travel comes OFF the pose and moves the node — or its CharacterController
|
|
116
|
+
* (on this node or an ancestor) as a velocity, so it collides. For clips whose hips actually travel. A
|
|
117
|
+
* character under a `Locomotion` gets this from its displacement mode instead. */
|
|
118
|
+
get rootMotion(): boolean { return this._rootMotion }
|
|
119
|
+
set rootMotion(on: boolean) { this._rootMotion = on; this._c.setRootMotion(on, this._rootRotation) }
|
|
120
|
+
/** With `rootMotion`: the root bone's TURN is root motion too — it comes off the pose and turns the node,
|
|
121
|
+
* so a turn clip leaves the character facing where it took it. Off by default (a walk's hip sway is a
|
|
122
|
+
* turn too); on for a rig whose root carries the heading (`lecodes assets retarget --root-rotation yaw`). */
|
|
123
|
+
get rootRotation(): boolean { return this._rootRotation }
|
|
124
|
+
set rootRotation(on: boolean) { this._rootRotation = on; if (this._rootMotion) this._c.setRootMotion(true, on) }
|
|
125
|
+
|
|
126
|
+
// ---- level of detail --------------------------------------------------------------------------
|
|
127
|
+
/** `'auto'` (default): a character small on screen or out of view is evaluated every 2nd / 4th frame
|
|
128
|
+
* without its finger, toe and twist joints; `'full'`: every frame, every joint (a hero seen through a
|
|
129
|
+
* scope). Independent of `Model.lod`, the mesh level. */
|
|
130
|
+
get lod(): "auto" | "full" { return this._lod }
|
|
131
|
+
set lod(v: "auto" | "full") { this._lod = v; _pushLod(this.node) }
|
|
132
|
+
|
|
133
|
+
// ---- engine-facing (a Locomotion, a bench) --------------------------------------------------
|
|
134
|
+
/** The native animator's id — 0 until something is played or bound. */
|
|
135
|
+
get _id(): number { return this._c.id }
|
|
136
|
+
/** Bind a clip to the base layer without playing it; its native slot (-1 = no such clip). */
|
|
137
|
+
_slot(clip: string | number): number { return this._c.slotIndex(clip) }
|
|
138
|
+
}
|