lecodes-cli 0.16.0 → 0.16.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/README.md +27 -4
- package/dist/index.js +170706 -72
- package/package.json +11 -6
- package/runtime/sdk/compile/bundler.ts +10 -2
- package/runtime/sdk/compile/compileProject.ts +10 -2
- package/runtime/sdk/compile/detectEntry.ts +5 -2
- package/runtime/sdk/compile/index.ts +17 -1
- package/runtime/sdk/compile/serverSplit.ts +233 -0
- package/runtime/sdk/compile/serverTypes.ts +227 -0
- package/runtime/sdk/gl/AnimationClip.ts +174 -0
- package/runtime/sdk/gl/Animator.ts +634 -0
- package/runtime/sdk/gl/CharacterController.ts +6 -1
- package/runtime/sdk/gl/IK.ts +170 -0
- package/runtime/sdk/gl/Material.ts +1 -1
- package/runtime/sdk/gl/ModelAnimation.ts +34 -3
- package/runtime/sdk/gl/Node.ts +21 -0
- package/runtime/sdk/inject.ts +10 -0
- package/runtime/sdk/plugins/oauth.ts +61 -0
- package/runtime/sdk/runtime/rpc.ts +218 -0
- package/runtime/sdk/server/auth/appConfig.ts +12 -0
- package/runtime/sdk/server/auth/global.ts +80 -0
- package/runtime/sdk/server/auth/host.ts +318 -0
- package/runtime/sdk/server/auth/models.ts +83 -0
- package/runtime/sdk/server/auth/types.ts +50 -0
- package/runtime/sdk/server/channel.ts +56 -0
- package/runtime/sdk/server/context.ts +36 -0
- package/runtime/sdk/server/db/defineDb.ts +237 -0
- package/runtime/sdk/server/db/fields.ts +132 -0
- package/runtime/sdk/server/db/httpTransport.ts +93 -0
- package/runtime/sdk/server/db/index.ts +7 -0
- package/runtime/sdk/server/db/marci/query.ts +412 -0
- package/runtime/sdk/server/db/types.ts +202 -0
- package/runtime/sdk/server/errors.ts +12 -0
- package/runtime/sdk/server/host.ts +74 -0
- package/runtime/sdk/server/inject.ts +13 -0
- package/runtime/sdk/server/runtime.ts +133 -0
- package/runtime/sdk/server/validate.ts +87 -0
- package/runtime/sdk-types.json +1 -1
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
// Animator — the full animation system as an OPT-IN aspect (model.anim stays the simple baked-clip
|
|
2
|
+
// player). A clip TABLE (the model's embedded clips ∪ your `clips`) bound by bone name to this
|
|
3
|
+
// node's hierarchy, evaluated natively every frame as weighted slots grouped in LAYERS: crossfades,
|
|
4
|
+
// 1D/2D blend spaces (phase-synced members), one-shots that return to the blend, masked / additive
|
|
5
|
+
// layers, clip events, completion promises. Owns the skeleton while attached (model.anim is
|
|
6
|
+
// suspended). See docs/animation-plan.md.
|
|
7
|
+
//
|
|
8
|
+
// const [hero, idle, run, slash] = await Promise.all([
|
|
9
|
+
// Model.load(asset('./hero.glb')),
|
|
10
|
+
// AnimationClip.load(asset('./idle.glb')), AnimationClip.load(asset('./run.glb')),
|
|
11
|
+
// AnimationClip.load(asset('./attacks.glb'), 'Slash'),
|
|
12
|
+
// ])
|
|
13
|
+
// hero.aspect(Animator, { clips: { idle, run, slash }, blend: { idle: 0, run: 6 } })
|
|
14
|
+
// hero.animator.value = hero.controller.velocity.length // where we are on the blend axis
|
|
15
|
+
// await hero.animator.play('slash', { fade: 0.1 }) // one-shot; the blend fades back after
|
|
16
|
+
// hero.animator.on('completed', (name) => ...)
|
|
17
|
+
//
|
|
18
|
+
// // layers: the flat API above IS the 'base' layer; more layers stack on top of it
|
|
19
|
+
// hero.aspect(Animator, {
|
|
20
|
+
// clips: { idle, walk, run, aim, breathe },
|
|
21
|
+
// layers: {
|
|
22
|
+
// base: { blend: { idle: [0, 0], walk: [0, 1], run: [0, 2], strafeL: [-1, 1] } }, // 2D: value = [x, y]
|
|
23
|
+
// upper: { mask: 'Spine1' }, // override layer over the upper body only
|
|
24
|
+
// body: { additive: true, clip: 'breathe' }, // delta clip on top of everything
|
|
25
|
+
// },
|
|
26
|
+
// })
|
|
27
|
+
// hero.animator.layer('upper').play('aim', { loop: true }) // arms aim, legs keep walking
|
|
28
|
+
// hero.animator.layer('upper').stop()
|
|
29
|
+
//
|
|
30
|
+
// Any Node hierarchy works, not just Models: tracks bind to child names (a robot arm of meshes).
|
|
31
|
+
|
|
32
|
+
import { Aspect } from "../core/Aspect"
|
|
33
|
+
import { Emitter } from "../core/events"
|
|
34
|
+
import { Registry } from "../core/registry"
|
|
35
|
+
import { AnimationClip } from "./AnimationClip"
|
|
36
|
+
import type { Node } from "./Node"
|
|
37
|
+
import type { ModelAnimation } from "./ModelAnimation"
|
|
38
|
+
import type { CharacterController } from "./CharacterController"
|
|
39
|
+
import { Vec3 } from "../math/vec"
|
|
40
|
+
|
|
41
|
+
/** A position on a layer's blend axis (1D) or plane (2D). */
|
|
42
|
+
export type BlendPosition = number | readonly [number, number]
|
|
43
|
+
|
|
44
|
+
export type AnimatorPlayOptions = {
|
|
45
|
+
/** Crossfade seconds for BOTH ends (default: `animator.fade`, 0.2). 0 = cut. */
|
|
46
|
+
fade?: number
|
|
47
|
+
/** Fade-in seconds (overrides `fade`). */
|
|
48
|
+
fadeIn?: number
|
|
49
|
+
/** One-shots only: fade-back seconds at the END (overrides `fade`). The return fade starts this
|
|
50
|
+
* long BEFORE the clip ends, while it still plays — no freeze-frame on the last pose. On a layer
|
|
51
|
+
* with no blend to return to, an explicit `fadeOut` releases the layer at the end instead of
|
|
52
|
+
* holding the last frame. */
|
|
53
|
+
fadeOut?: number
|
|
54
|
+
/** Loop the clip (default false for one-shots; blend members always loop). */
|
|
55
|
+
loop?: boolean
|
|
56
|
+
/** Playback rate for this clip (default 1). */
|
|
57
|
+
speed?: number
|
|
58
|
+
/** Restart from t=0 even if the clip is already playing (default: restart only if it isn't). */
|
|
59
|
+
restart?: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type AnimatorLayerOptions = {
|
|
63
|
+
/** Blend space: clip name → position, 1D (`{ idle: 0, run: 6 }`) or 2D (`{ idle: [0,0], fwd: [0,1] }`).
|
|
64
|
+
* Members loop in phase with each other; the layer's `value` picks the mix. */
|
|
65
|
+
blend?: Record<string, BlendPosition>
|
|
66
|
+
/** A clip to start looping at attach (an ambient layer: breathing, an aim pose). */
|
|
67
|
+
clip?: string
|
|
68
|
+
/** Bone name(s): the layer drives only these subtrees (`'Spine1'` = upper body). Default: the whole rig. */
|
|
69
|
+
mask?: string | string[]
|
|
70
|
+
/** Blend each clip's DELTA from its own first frame on top of the layers below (lean, breathe, recoil)
|
|
71
|
+
* instead of overriding them. */
|
|
72
|
+
additive?: boolean
|
|
73
|
+
/** Layer contribution 0–1 (default 1). A layer with nothing playing contributes nothing regardless. */
|
|
74
|
+
weight?: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type AnimatorEvents = {
|
|
78
|
+
/** A non-looping clip reached its end. */
|
|
79
|
+
completed: (clip: string, layer: string) => void
|
|
80
|
+
/** A looping clip wrapped around (once per cycle for a blend space). */
|
|
81
|
+
loop: (clip: string, layer: string) => void
|
|
82
|
+
/** Clip events declared in `events: { clip: { 0.4: 'hit' } }`. */
|
|
83
|
+
[event: string]: (clip: string, layer: string) => void
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
class AnimatorEmitter extends Emitter<AnimatorEvents> {
|
|
87
|
+
emit<K extends keyof AnimatorEvents>(channel: K, ...args: Parameters<AnimatorEvents[K]>): void { this.dispatch(channel, ...args) }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type SlotState = {
|
|
91
|
+
name: string
|
|
92
|
+
clip: AnimationClip
|
|
93
|
+
slot: number
|
|
94
|
+
layer: AnimatorLayer
|
|
95
|
+
/** current / target contribution (0–1); blend members use blendW × the layer's blend group weight */
|
|
96
|
+
weight: number
|
|
97
|
+
target: number
|
|
98
|
+
rate: number // weight units per second toward target (Infinity = cut)
|
|
99
|
+
blendW: number // this frame's blend-space weight (members only)
|
|
100
|
+
playing: boolean
|
|
101
|
+
loop: boolean
|
|
102
|
+
inBlend: boolean
|
|
103
|
+
events: string[] // clip event names, index = native event type − 2
|
|
104
|
+
evTimes: number[] // the declared events' normalized times (to re-push with the fade-out sentinel)
|
|
105
|
+
outNorm: number // fade-out sentinel's normalized time as pushed (−1 = none)
|
|
106
|
+
fadeOut: number // seconds for this play's scheduled fade-back
|
|
107
|
+
explicitOut: boolean // options.fadeOut was given → release even on a blendless layer
|
|
108
|
+
resolvers: (() => void)[]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const animatorRegistry = new Registry<Animator>()
|
|
112
|
+
let eventsInstalled = false
|
|
113
|
+
const ensureEvents = (): void => {
|
|
114
|
+
if (eventsInstalled) return
|
|
115
|
+
eventsInstalled = true
|
|
116
|
+
_creator.setOnAnimatorEvent((animatorId, slot, type) => animatorRegistry.get(animatorId)?._onSlotEvent(slot, type))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const EPS = 0.0005
|
|
120
|
+
const asXY = (p: BlendPosition): [number, number] => typeof p === "number" ? [p, 0] : [p[0], p[1]]
|
|
121
|
+
|
|
122
|
+
/** One layer of an Animator. The Animator's own play/stop/value/… ARE its `base` layer. */
|
|
123
|
+
export class AnimatorLayer {
|
|
124
|
+
/** Position on the blend axis (number) or plane ([x, y]) — units are whatever `blend` uses. */
|
|
125
|
+
value: BlendPosition = 0
|
|
126
|
+
/** Layer contribution 0–1. */
|
|
127
|
+
weight = 1
|
|
128
|
+
/** Bone name(s) whose subtrees this layer drives; undefined = the whole rig. */
|
|
129
|
+
mask?: string | string[]
|
|
130
|
+
/** Blend deltas (vs each clip's first frame) on top of the layers below instead of overriding. */
|
|
131
|
+
additive = false
|
|
132
|
+
/** The blend space this layer was declared with (read at attach). */
|
|
133
|
+
blend?: Record<string, BlendPosition>
|
|
134
|
+
|
|
135
|
+
/** @internal */ _clip?: string
|
|
136
|
+
/** @internal */ _slots: SlotState[] = []
|
|
137
|
+
/** @internal */ _byName = new Map<string, SlotState>()
|
|
138
|
+
/** @internal */ _members: { at: [number, number], slot: SlotState }[] = []
|
|
139
|
+
/** @internal */ _is2D = false
|
|
140
|
+
/** @internal */ _blendWeight = 1
|
|
141
|
+
/** @internal */ _blendTarget = 1
|
|
142
|
+
/** @internal */ _blendRate = Infinity
|
|
143
|
+
/** @internal */ _lastValue: [number, number] = [NaN, NaN]
|
|
144
|
+
/** @internal */ _lastWeight = NaN
|
|
145
|
+
/** @internal */ _lastMask = ""
|
|
146
|
+
/** @internal */ _lastAdditive: boolean | undefined
|
|
147
|
+
|
|
148
|
+
/** The layer's name (`'base'` for the Animator's own). */
|
|
149
|
+
readonly name: string
|
|
150
|
+
/** @internal native layer index (0 = base) */
|
|
151
|
+
readonly index: number
|
|
152
|
+
private readonly _a: Animator
|
|
153
|
+
|
|
154
|
+
/** @internal — created by the Animator (`animator.layer(name)`), never by hand. */
|
|
155
|
+
constructor(name: string, index: number, animator: Animator) { this.name = name; this.index = index; this._a = animator }
|
|
156
|
+
|
|
157
|
+
/** The clip names bound on this layer so far. */
|
|
158
|
+
get names(): string[] { return [...this._byName.keys()] }
|
|
159
|
+
|
|
160
|
+
/** Play a clip on this layer (crossfading from whatever it plays). A blend member brings the whole
|
|
161
|
+
* blend back; anything else is a one-shot that returns to the blend when it ends (unless `loop`),
|
|
162
|
+
* or holds its last frame on a layer without a blend (call `stop()`). Resolves when the clip
|
|
163
|
+
* completes or is faded out by a later play()/stop(). */
|
|
164
|
+
play(name: string, options: AnimatorPlayOptions = {}): Promise<void> {
|
|
165
|
+
const s = this._resolve(name)
|
|
166
|
+
if (!s) return Promise.resolve()
|
|
167
|
+
const fade = options.fadeIn ?? options.fade ?? this._a.fade
|
|
168
|
+
const restart = options.restart ?? !s.playing
|
|
169
|
+
if (!s.inBlend) s.loop = options.loop ?? false
|
|
170
|
+
s.playing = true
|
|
171
|
+
_creator.animatorSetSlot(this._a._id, s.slot, true, s.loop, options.speed ?? 1, restart ? 0 : -1)
|
|
172
|
+
this._scheduleOut(s, options)
|
|
173
|
+
const rate = fade > 0 ? 1 / fade : Infinity
|
|
174
|
+
for (const o of this._slots) {
|
|
175
|
+
if (o.inBlend || o === s) continue
|
|
176
|
+
if (o.target !== 0) { o.target = 0; o.rate = rate }
|
|
177
|
+
}
|
|
178
|
+
if (s.inBlend) { this._blendTarget = 1; this._blendRate = rate }
|
|
179
|
+
else { s.target = 1; s.rate = rate; this._blendTarget = 0; this._blendRate = rate }
|
|
180
|
+
return new Promise<void>((resolve) => { s.resolvers.push(resolve) })
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Fade a clip out (or, with no name, everything on this layer — the blend included). */
|
|
184
|
+
stop(name?: string, options: { fade?: number, fadeOut?: number } = {}): this {
|
|
185
|
+
const fade = options.fadeOut ?? options.fade ?? this._a.fade
|
|
186
|
+
const rate = fade > 0 ? 1 / fade : Infinity
|
|
187
|
+
if (name === undefined) {
|
|
188
|
+
for (const o of this._slots) if (!o.inBlend) { o.target = 0; o.rate = rate }
|
|
189
|
+
this._blendTarget = 0; this._blendRate = rate
|
|
190
|
+
return this
|
|
191
|
+
}
|
|
192
|
+
const s = this._byName.get(name)
|
|
193
|
+
if (!s) return this
|
|
194
|
+
if (s.inBlend) { this._blendTarget = 0; this._blendRate = rate; return this }
|
|
195
|
+
s.target = 0; s.rate = rate
|
|
196
|
+
// nothing else on the way in → the blend returns
|
|
197
|
+
if (this._members.length > 0 && !this._slots.some((o) => !o.inBlend && o.target > 0)) { this._blendTarget = 1; this._blendRate = rate }
|
|
198
|
+
return this
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Whether the clip is playing on this layer (its clock advancing). No name = anything on this layer. */
|
|
202
|
+
playing(name?: string): boolean {
|
|
203
|
+
if (name === undefined) return this._slots.some((s) => s.playing && this._effective(s) > 0)
|
|
204
|
+
const s = this._byName.get(name)
|
|
205
|
+
return !!s && s.playing
|
|
206
|
+
}
|
|
207
|
+
/** Current time (s) of a clip's clock. */
|
|
208
|
+
time(name: string): number {
|
|
209
|
+
const s = this._byName.get(name)
|
|
210
|
+
return s ? _creator.animatorGetSlotTime(this._a._id, s.slot) : 0
|
|
211
|
+
}
|
|
212
|
+
/** Set a clip's clock (s). Seeking a blend member moves the whole blend (they share one phase). */
|
|
213
|
+
seek(name: string, time: number): this {
|
|
214
|
+
const s = this._byName.get(name)
|
|
215
|
+
if (s) _creator.animatorSetSlot(this._a._id, s.slot, s.playing, s.loop, 1, Math.max(0, time))
|
|
216
|
+
return this
|
|
217
|
+
}
|
|
218
|
+
/** 0–1 progress through a clip. */
|
|
219
|
+
progress(name: string): number {
|
|
220
|
+
const s = this._byName.get(name)
|
|
221
|
+
return s && s.clip.duration > 0 ? this.time(name) / s.clip.duration : 0
|
|
222
|
+
}
|
|
223
|
+
/** Current contribution (0–1) of a clip to this layer's pose. */
|
|
224
|
+
weightOf(name: string): number {
|
|
225
|
+
const s = this._byName.get(name)
|
|
226
|
+
return s ? this._effective(s) : 0
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---- internals ----
|
|
230
|
+
/** @internal */
|
|
231
|
+
_attach(opts: AnimatorLayerOptions | undefined): void {
|
|
232
|
+
if (opts) {
|
|
233
|
+
if (opts.blend) this.blend = opts.blend
|
|
234
|
+
if (opts.mask !== undefined) this.mask = opts.mask
|
|
235
|
+
if (opts.additive !== undefined) this.additive = opts.additive
|
|
236
|
+
if (opts.weight !== undefined) this.weight = opts.weight
|
|
237
|
+
this._clip = opts.clip
|
|
238
|
+
}
|
|
239
|
+
if (this.blend) {
|
|
240
|
+
this._is2D = Object.values(this.blend).some((p) => typeof p !== "number")
|
|
241
|
+
for (const [name, at] of Object.entries(this.blend)) {
|
|
242
|
+
const clip = this._a._table.get(name)
|
|
243
|
+
if (!clip) { console.warn(`Animator: blend clip '${name}' is not in the clip table`); continue }
|
|
244
|
+
const s = this._a._bind(name, clip, this, this.index + 1) // phase group per layer
|
|
245
|
+
if (!s) continue
|
|
246
|
+
s.inBlend = true
|
|
247
|
+
s.loop = true
|
|
248
|
+
s.playing = true
|
|
249
|
+
_creator.animatorSetSlot(this._a._id, s.slot, true, true, 1, 0)
|
|
250
|
+
this._members.push({ at: asXY(at), slot: s })
|
|
251
|
+
}
|
|
252
|
+
if (!this._is2D) this._members.sort((a, b) => a.at[0] - b.at[0])
|
|
253
|
+
this._computeBlend()
|
|
254
|
+
}
|
|
255
|
+
if (this._clip) {
|
|
256
|
+
const s = this._resolve(this._clip)
|
|
257
|
+
if (s) {
|
|
258
|
+
s.loop = true; s.playing = true; s.weight = 1; s.target = 1
|
|
259
|
+
_creator.animatorSetSlot(this._a._id, s.slot, true, true, 1, 0)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
this._pushLayer()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** @internal — advance fades / blend value / layer config; true if slot weights changed. */
|
|
266
|
+
_tick(dt: number): boolean {
|
|
267
|
+
let changed = false
|
|
268
|
+
for (const s of this._slots) {
|
|
269
|
+
if (s.inBlend || s.weight === s.target) continue
|
|
270
|
+
s.weight = step(s.weight, s.target, s.rate * dt)
|
|
271
|
+
changed = true
|
|
272
|
+
if (s.weight === 0 && s.target === 0) this._a._settle(s)
|
|
273
|
+
}
|
|
274
|
+
if (this._blendWeight !== this._blendTarget) {
|
|
275
|
+
this._blendWeight = step(this._blendWeight, this._blendTarget, this._blendRate * dt)
|
|
276
|
+
changed = true
|
|
277
|
+
}
|
|
278
|
+
if (this._members.length > 0) {
|
|
279
|
+
const [x, y] = asXY(this.value)
|
|
280
|
+
if (x !== this._lastValue[0] || y !== this._lastValue[1]) { this._computeBlend(); changed = true }
|
|
281
|
+
}
|
|
282
|
+
const mask = this._maskString()
|
|
283
|
+
if (this.weight !== this._lastWeight || this.additive !== this._lastAdditive || mask !== this._lastMask) this._pushLayer()
|
|
284
|
+
return changed
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** @internal */
|
|
288
|
+
_effective(s: SlotState): number { return s.inBlend ? s.blendW * this._blendWeight : s.weight }
|
|
289
|
+
|
|
290
|
+
private _maskString(): string {
|
|
291
|
+
const m = this.mask
|
|
292
|
+
return m === undefined ? "" : Array.isArray(m) ? m.join("\n") : (m as string)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private _pushLayer(): void {
|
|
296
|
+
const mask = this._maskString()
|
|
297
|
+
if (mask !== this._lastMask) {
|
|
298
|
+
for (const m of mask.split("\n")) if (m && !this._a.node.bone(m)) console.warn(`Animator: layer '${this.name}' mask bone '${m}' not found under ${this._a.node.name || "the node"}`)
|
|
299
|
+
}
|
|
300
|
+
this._lastWeight = this.weight; this._lastAdditive = this.additive; this._lastMask = mask
|
|
301
|
+
_creator.animatorSetLayer(this._a._id, this.index, this.weight, this.additive, mask)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** One-shots: plant a hidden slot event where the fade-back must START so the return crossfade
|
|
305
|
+
* finishes right at the clip's end while it is still moving (no freeze-frame). Loops and blend
|
|
306
|
+
* members get none; `_onSlotEvent` keeps the reactive at-completion fade as the fallback. */
|
|
307
|
+
private _scheduleOut(s: SlotState, options: AnimatorPlayOptions): void {
|
|
308
|
+
s.fadeOut = options.fadeOut ?? options.fade ?? this._a.fade
|
|
309
|
+
s.explicitOut = options.fadeOut !== undefined
|
|
310
|
+
const auto = !s.inBlend && !s.loop && (this._members.length > 0 || s.explicitOut)
|
|
311
|
+
const dur = s.clip.duration
|
|
312
|
+
let norm = -1
|
|
313
|
+
if (auto && s.fadeOut > 0 && dur > 0) norm = Math.min(0.999, Math.max(0.001, 1 - (s.fadeOut * (options.speed ?? 1)) / dur))
|
|
314
|
+
if (norm === s.outNorm) return
|
|
315
|
+
s.outNorm = norm
|
|
316
|
+
const times = norm >= 0 ? [...s.evTimes, norm] : s.evTimes
|
|
317
|
+
_creator.animatorSetSlotEvents(this._a._id, s.slot, new Float32Array(times))
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private _resolve(name: string): SlotState | undefined {
|
|
321
|
+
let s = this._byName.get(name)
|
|
322
|
+
if (!s) {
|
|
323
|
+
const clip = this._a._table.get(name) ?? this._a.clips[name]
|
|
324
|
+
if (clip) s = this._a._bind(name, clip, this, 0)
|
|
325
|
+
}
|
|
326
|
+
if (!s) console.warn(`Animator: no clip '${name}' — table: ${[...this._a._table.keys()].join(", ") || "(empty)"}`)
|
|
327
|
+
return s
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Blend-space weights for the current value → each member's blendW. 1D: linear between the two
|
|
331
|
+
* neighbours. 2D: gradient-band interpolation (exact at samples, smooth in between, no triangulation). */
|
|
332
|
+
private _computeBlend(): void {
|
|
333
|
+
const ms = this._members
|
|
334
|
+
const [x, y] = asXY(this.value)
|
|
335
|
+
this._lastValue = [x, y]
|
|
336
|
+
if (ms.length === 0) return
|
|
337
|
+
if (ms.length === 1) { ms[0].slot.blendW = 1; return }
|
|
338
|
+
if (!this._is2D) {
|
|
339
|
+
const v = Math.min(Math.max(x, ms[0].at[0]), ms[ms.length - 1].at[0])
|
|
340
|
+
for (const m of ms) m.slot.blendW = 0
|
|
341
|
+
for (let i = 0; i < ms.length - 1; i++) {
|
|
342
|
+
const a = ms[i], b = ms[i + 1]
|
|
343
|
+
if (v <= b.at[0]) {
|
|
344
|
+
const t = b.at[0] > a.at[0] ? (v - a.at[0]) / (b.at[0] - a.at[0]) : 0
|
|
345
|
+
a.slot.blendW = 1 - t; b.slot.blendW += t
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
ms[ms.length - 1].slot.blendW = 1
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
let sum = 0
|
|
353
|
+
let nearest = ms[0], nearestD = Infinity
|
|
354
|
+
for (const mi of ms) {
|
|
355
|
+
const [px, py] = mi.at
|
|
356
|
+
const dx = x - px, dy = y - py
|
|
357
|
+
const d2 = dx * dx + dy * dy
|
|
358
|
+
if (d2 < nearestD) { nearestD = d2; nearest = mi }
|
|
359
|
+
let h = 1
|
|
360
|
+
for (const mj of ms) {
|
|
361
|
+
if (mj === mi) continue
|
|
362
|
+
const ex = mj.at[0] - px, ey = mj.at[1] - py
|
|
363
|
+
const len2 = ex * ex + ey * ey
|
|
364
|
+
if (len2 === 0) continue
|
|
365
|
+
const t = 1 - (dx * ex + dy * ey) / len2
|
|
366
|
+
h = Math.min(h, Math.max(0, Math.min(1, t)))
|
|
367
|
+
if (h === 0) break
|
|
368
|
+
}
|
|
369
|
+
mi.slot.blendW = h
|
|
370
|
+
sum += h
|
|
371
|
+
}
|
|
372
|
+
if (sum > 0) for (const m of ms) m.slot.blendW /= sum
|
|
373
|
+
else { for (const m of ms) m.slot.blendW = 0; nearest.slot.blendW = 1 }
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export class Animator extends Aspect<"animator", Node> {
|
|
378
|
+
static readonly aspect = "animator"
|
|
379
|
+
|
|
380
|
+
/** Clips from other files / procedural clips, keyed by the name you play them with. Read at attach;
|
|
381
|
+
* the table also contains the model's embedded clips under their own names. */
|
|
382
|
+
clips: Record<string, AnimationClip> = {}
|
|
383
|
+
/** Blend space of the base layer (shorthand for `layers: { base: { blend } }`). Read at attach. */
|
|
384
|
+
blend?: Record<string, BlendPosition>
|
|
385
|
+
/** Layers on top of `base` (declared in stacking order). Read at attach; `layer(name)` reaches them —
|
|
386
|
+
* and creates an undeclared one on first use. */
|
|
387
|
+
layers?: Record<string, AnimatorLayerOptions>
|
|
388
|
+
/** Clip events: clip name → { normalizedTime: eventName } → `animator.on(eventName, (clip) => …)`. Read at attach. */
|
|
389
|
+
events?: Record<string, Record<number | string, string>>
|
|
390
|
+
/** Global playback rate (slow-mo). */
|
|
391
|
+
speed = 1
|
|
392
|
+
/** Freeze every clock (game pause). */
|
|
393
|
+
paused = false
|
|
394
|
+
/** Default crossfade seconds for play()/stop(). */
|
|
395
|
+
fade = 0.2
|
|
396
|
+
/** Root motion: strip the root bone's horizontal travel from the pose and move the NODE with it
|
|
397
|
+
* instead (or drive its CharacterController when it has one) — for clips whose hips actually travel
|
|
398
|
+
* (Mixamo without "In Place", lunges, rolls). `true` = auto-detect the root bone (the shallowest bone
|
|
399
|
+
* a clip translates), or a bone name. Read at attach. */
|
|
400
|
+
rootMotion?: boolean | string
|
|
401
|
+
/** Apply the extracted root motion to the node/controller each frame (default). Set false to read
|
|
402
|
+
* `rootDelta` yourself. */
|
|
403
|
+
applyRootMotion = true
|
|
404
|
+
|
|
405
|
+
updateBeforePhysics = true // push this frame's weights BEFORE the native evaluator runs
|
|
406
|
+
order = -10 // and before CharacterController (root motion feeds its velocity)
|
|
407
|
+
|
|
408
|
+
private _rootBuf = new Float32Array(3)
|
|
409
|
+
private _rootDelta = new Vec3()
|
|
410
|
+
/** Last frame's root-motion delta in the node's local space (zero without `rootMotion`). */
|
|
411
|
+
get rootDelta(): Vec3 { return this._rootDelta }
|
|
412
|
+
|
|
413
|
+
/** @internal */ _id = 0
|
|
414
|
+
/** @internal */ _table = new Map<string, AnimationClip>()
|
|
415
|
+
private _base = new AnimatorLayer("base", 0, this)
|
|
416
|
+
private _layers: AnimatorLayer[] = [ this._base ]
|
|
417
|
+
private _all: SlotState[] = [] // by native slot index
|
|
418
|
+
private _lastSpeed = 1
|
|
419
|
+
private _lastPaused = false
|
|
420
|
+
private _weights = new Float32Array(0)
|
|
421
|
+
private _events = new AnimatorEmitter()
|
|
422
|
+
|
|
423
|
+
/** Position on the base layer's blend axis/plane. */
|
|
424
|
+
get value(): BlendPosition { return this._base.value }
|
|
425
|
+
set value(v: BlendPosition) { this._base.value = v }
|
|
426
|
+
|
|
427
|
+
onAttach(): void {
|
|
428
|
+
if (typeof _creator.animatorCreate !== "function") {
|
|
429
|
+
console.warn("Animator: this host has no Animator support yet — falling back to model.anim where possible")
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
ensureEvents()
|
|
433
|
+
this._id = _creator.animatorCreate(this.node.id)
|
|
434
|
+
if (this._id === 0) { console.warn("Animator: node has no transform hierarchy"); return }
|
|
435
|
+
animatorRegistry.set(this._id, this)
|
|
436
|
+
|
|
437
|
+
// clip table = embedded ∪ clips (yours override)
|
|
438
|
+
for (const c of AnimationClip._ofSet(_creator.getGlbClipSet(this.node.id))) this._table.set(c.name, c)
|
|
439
|
+
for (const [name, clip] of Object.entries(this.clips)) if (clip) this._table.set(name, clip)
|
|
440
|
+
|
|
441
|
+
// suspend the base player — one writer
|
|
442
|
+
const anim = (this.node as unknown as { anim?: ModelAnimation }).anim
|
|
443
|
+
anim?._suspend(true)
|
|
444
|
+
|
|
445
|
+
// layers: base (flat `blend` or layers.base) + the rest in declaration order
|
|
446
|
+
const declared = this.layers ?? {}
|
|
447
|
+
const baseOpts: AnimatorLayerOptions = { ...(declared.base ?? {}) }
|
|
448
|
+
if (!baseOpts.blend && this.blend) baseOpts.blend = this.blend
|
|
449
|
+
this._base._attach(baseOpts)
|
|
450
|
+
for (const [name, opts] of Object.entries(declared)) {
|
|
451
|
+
if (name === "base") continue
|
|
452
|
+
this.layer(name)._attach(opts)
|
|
453
|
+
}
|
|
454
|
+
// clips not bound by any layer yet get bound on base on first play(); bind the rest eagerly so
|
|
455
|
+
// warnings about unbound tracks show up at attach, not mid-game
|
|
456
|
+
for (const [name, clip] of this._table) if (!this._base._byName.has(name) && !this._layers.some((l) => l._byName.has(name))) this._bind(name, clip, this._base, 0)
|
|
457
|
+
|
|
458
|
+
this._lastSpeed = this.speed
|
|
459
|
+
this._lastPaused = this.paused
|
|
460
|
+
_creator.animatorSetGlobal(this._id, this.speed, this.paused)
|
|
461
|
+
if (this.rootMotion) _creator.animatorSetRootMotion?.(this._id, this.rootMotion === true ? "*" : this.rootMotion)
|
|
462
|
+
this._push(true)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
onDetach(): void {
|
|
466
|
+
if (this._id === 0) return
|
|
467
|
+
for (const s of this._all) this._settle(s)
|
|
468
|
+
this._controller()?._setExternalVelocity(0, 0, 0)
|
|
469
|
+
_creator.animatorDestroy(this._id)
|
|
470
|
+
animatorRegistry.delete(this._id)
|
|
471
|
+
this._id = 0
|
|
472
|
+
const anim = (this.node as unknown as { anim?: ModelAnimation }).anim
|
|
473
|
+
anim?._suspend(false)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** The names in the clip table. */
|
|
477
|
+
get names(): string[] { return [...this._table.keys()] }
|
|
478
|
+
|
|
479
|
+
/** A layer by name (`'base'` is the Animator itself). An undeclared name creates a new override
|
|
480
|
+
* layer on top; configure it through its properties (`mask`, `additive`, `weight`). */
|
|
481
|
+
layer(name: string): AnimatorLayer {
|
|
482
|
+
for (const l of this._layers) if (l.name === name) return l
|
|
483
|
+
if (this._layers.length >= 255) { console.warn("Animator: too many layers"); return this._base }
|
|
484
|
+
const l = new AnimatorLayer(name, this._layers.length, this)
|
|
485
|
+
this._layers.push(l)
|
|
486
|
+
return l
|
|
487
|
+
}
|
|
488
|
+
/** All layers, bottom to top. */
|
|
489
|
+
get layerList(): readonly AnimatorLayer[] { return this._layers }
|
|
490
|
+
|
|
491
|
+
/** Play a clip on the base layer (crossfading from whatever is playing there). A blend member brings
|
|
492
|
+
* the whole blend back; anything else is a one-shot that returns to the blend when it ends (unless
|
|
493
|
+
* `loop`). Resolves when the clip completes or is faded out by a later play()/stop(). */
|
|
494
|
+
play(name: string, options: AnimatorPlayOptions = {}): Promise<void> { return this._base.play(name, options) }
|
|
495
|
+
/** Fade a clip out on the base layer — or, with no name, everything on every layer. */
|
|
496
|
+
stop(name?: string, options: { fade?: number } = {}): this {
|
|
497
|
+
if (name === undefined) { for (const l of this._layers) l.stop(undefined, options); return this }
|
|
498
|
+
this._base.stop(name, options)
|
|
499
|
+
return this
|
|
500
|
+
}
|
|
501
|
+
/** Whether the clip is playing on the base layer. No name = anything playing on any layer. */
|
|
502
|
+
playing(name?: string): boolean {
|
|
503
|
+
if (name === undefined) return this._layers.some((l) => l.playing())
|
|
504
|
+
return this._base.playing(name)
|
|
505
|
+
}
|
|
506
|
+
/** Current time (s) of a base-layer clip's clock. */
|
|
507
|
+
time(name: string): number { return this._base.time(name) }
|
|
508
|
+
/** Set a base-layer clip's clock (s). */
|
|
509
|
+
seek(name: string, time: number): this { this._base.seek(name, time); return this }
|
|
510
|
+
/** 0–1 progress through a base-layer clip. */
|
|
511
|
+
progress(name: string): number { return this._base.progress(name) }
|
|
512
|
+
/** Current contribution (0–1) of a base-layer clip to the pose. */
|
|
513
|
+
weightOf(name: string): number { return this._base.weightOf(name) }
|
|
514
|
+
|
|
515
|
+
on<K extends keyof AnimatorEvents>(channel: K, cb: AnimatorEvents[K]): this { this._events.addEventListener(channel, cb); return this }
|
|
516
|
+
off<K extends keyof AnimatorEvents>(channel: K, cb: AnimatorEvents[K]): this { this._events.removeEventListener(channel, cb); return this }
|
|
517
|
+
|
|
518
|
+
// ---- per frame (EARLY phase: weights land before the native evaluator) ----
|
|
519
|
+
update(dt: number): void {
|
|
520
|
+
if (this._id === 0) return
|
|
521
|
+
let changed = false
|
|
522
|
+
for (const l of this._layers) if (l._tick(dt)) changed = true
|
|
523
|
+
if (this.speed !== this._lastSpeed || this.paused !== this._lastPaused) {
|
|
524
|
+
this._lastSpeed = this.speed; this._lastPaused = this.paused
|
|
525
|
+
_creator.animatorSetGlobal(this._id, this.speed, this.paused)
|
|
526
|
+
}
|
|
527
|
+
if (changed) this._push(false)
|
|
528
|
+
if (this.rootMotion) this._applyRootMotion(dt)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private _controller(): CharacterController | undefined {
|
|
532
|
+
return (this.node as unknown as { controller?: CharacterController }).controller
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Root motion (last native frame's delta, node-local, horizontal): move the node, or feed the
|
|
536
|
+
* CharacterController's velocity so it collides/slides like any other movement. */
|
|
537
|
+
private _applyRootMotion(dt: number): void {
|
|
538
|
+
if (typeof _creator.animatorGetRootMotion !== "function") return
|
|
539
|
+
const b = this._rootBuf
|
|
540
|
+
_creator.animatorGetRootMotion(this._id, b)
|
|
541
|
+
this._rootDelta = new Vec3(b[0], b[1], b[2])
|
|
542
|
+
if (!this.applyRootMotion || (b[0] === 0 && b[1] === 0 && b[2] === 0)) { this._controller()?._setExternalVelocity(0, 0, 0); return }
|
|
543
|
+
const controller = this._controller()
|
|
544
|
+
if (controller && controller.id) {
|
|
545
|
+
const w = this.node.worldMatrix.transformDirection(this._rootDelta)
|
|
546
|
+
const inv = dt > 0 ? 1 / dt : 0
|
|
547
|
+
controller._setExternalVelocity(w.x * inv, 0, w.z * inv)
|
|
548
|
+
} else {
|
|
549
|
+
const d = this.node.matrix.transformDirection(this._rootDelta) // → the parent's space (rotation + scale)
|
|
550
|
+
this.node.position = this.node.position.add(d)
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ---- internals ----
|
|
555
|
+
/** @internal */
|
|
556
|
+
_bind(name: string, clip: AnimationClip, layer: AnimatorLayer, sync: number): SlotState | undefined {
|
|
557
|
+
if (this._id === 0) return undefined // host without Animator support (warned at attach)
|
|
558
|
+
const slot = _creator.animatorBind(this._id, clip._set, clip._index, layer.index, sync)
|
|
559
|
+
if (slot < 0) { console.warn(`Animator: could not bind clip '${name}'`); return undefined }
|
|
560
|
+
const bound = _creator.animatorBoundTracks(this._id, slot)
|
|
561
|
+
if (clip.trackCount > 0 && bound === 0) console.warn(`Animator: '${name}' — no track matched a node name under ${this.node.name || "the node"} (different rig?)`)
|
|
562
|
+
else if (bound < clip.trackCount) console.warn(`Animator: '${name}' — ${clip.trackCount - bound}/${clip.trackCount} tracks unbound (bone names not found)`)
|
|
563
|
+
const s: SlotState = { name, clip, slot, layer, weight: 0, target: 0, rate: Infinity, blendW: 0, playing: false, loop: false, inBlend: false, events: [], evTimes: [], outNorm: -1, fadeOut: 0, explicitOut: false, resolvers: [] }
|
|
564
|
+
while (this._all.length < slot) this._all.push(undefined as unknown as SlotState)
|
|
565
|
+
this._all[slot] = s
|
|
566
|
+
layer._slots.push(s)
|
|
567
|
+
layer._byName.set(name, s)
|
|
568
|
+
this._weights = new Float32Array(this._all.length)
|
|
569
|
+
const ev = this.events?.[name]
|
|
570
|
+
if (ev) {
|
|
571
|
+
const entries = Object.entries(ev).map(([t, e]) => [ Number(t), e ] as [number, string]).filter(([t]) => isFinite(t)).sort((a, b) => a[0] - b[0])
|
|
572
|
+
s.events = entries.map(([, e]) => e)
|
|
573
|
+
s.evTimes = entries.map(([t]) => Math.min(1, Math.max(0, t)))
|
|
574
|
+
_creator.animatorSetSlotEvents(this._id, slot, new Float32Array(s.evTimes))
|
|
575
|
+
}
|
|
576
|
+
return s
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** @internal — a faded-out one-shot: stop its clock and settle awaiting promises. */
|
|
580
|
+
_settle(s: SlotState): void {
|
|
581
|
+
if (s.playing && !s.inBlend) { s.playing = false; _creator.animatorSetSlot(this._id, s.slot, false, s.loop, 1, -1) }
|
|
582
|
+
const rs = s.resolvers; s.resolvers = []
|
|
583
|
+
for (const r of rs) r()
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
private _push(force: boolean): void {
|
|
587
|
+
const w = this._weights
|
|
588
|
+
let any = force
|
|
589
|
+
for (let i = 0; i < this._all.length; i++) {
|
|
590
|
+
const s = this._all[i]
|
|
591
|
+
const v = s ? s.layer._effective(s) : 0
|
|
592
|
+
if (Math.abs(w[i] - v) > EPS || (v === 0 && w[i] !== 0)) { w[i] = v; any = true }
|
|
593
|
+
}
|
|
594
|
+
if (any) _creator.animatorSetWeights(this._id, w)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** @internal — native slot event: 0 completed (non-loop end), 1 loop wrapped, 2+i clip event i. */
|
|
598
|
+
_onSlotEvent(slot: number, type: number): void {
|
|
599
|
+
const s = this._all[slot]
|
|
600
|
+
if (!s) return
|
|
601
|
+
if (type === 1) { this._events.emit("loop", s.name, s.layer.name); return }
|
|
602
|
+
if (type >= 2) {
|
|
603
|
+
const i = type - 2
|
|
604
|
+
if (i === s.evTimes.length && s.outNorm >= 0) { // the fade-out sentinel
|
|
605
|
+
if (s.playing && !s.inBlend && !s.loop && s.target > 0) this._startOut(s)
|
|
606
|
+
return
|
|
607
|
+
}
|
|
608
|
+
const ev = s.events[i]
|
|
609
|
+
if (ev) this._events.emit(ev, s.name, s.layer.name)
|
|
610
|
+
return
|
|
611
|
+
}
|
|
612
|
+
// completed: hold the last frame only if nothing returns; with a blend, fade back to it
|
|
613
|
+
s.playing = false
|
|
614
|
+
this._events.emit("completed", s.name, s.layer.name)
|
|
615
|
+
const rs = s.resolvers; s.resolvers = []
|
|
616
|
+
for (const r of rs) r()
|
|
617
|
+
if ((s.layer._members.length > 0 || s.explicitOut) && s.target > 0) this._startOut(s) // fallback (resumed past the sentinel / cut)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** @internal — begin a one-shot's fade-back: the slot out over its fadeOut, the blend (if any) in. */
|
|
621
|
+
_startOut(s: SlotState): void {
|
|
622
|
+
const rate = s.fadeOut > 0 ? 1 / s.fadeOut : Infinity
|
|
623
|
+
s.target = 0; s.rate = rate
|
|
624
|
+
const L = s.layer
|
|
625
|
+
if (L._members.length > 0) { L._blendTarget = 1; L._blendRate = rate }
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const step = (v: number, target: number, maxDelta: number): number => {
|
|
630
|
+
if (!isFinite(maxDelta)) return target
|
|
631
|
+
const d = target - v
|
|
632
|
+
if (Math.abs(d) <= maxDelta) return target
|
|
633
|
+
return v + Math.sign(d) * maxDelta
|
|
634
|
+
}
|
|
@@ -40,6 +40,8 @@ export class CharacterController extends Aspect<"controller", Node> {
|
|
|
40
40
|
private _mz = 0
|
|
41
41
|
private _jumpQueued = false
|
|
42
42
|
private _vy = 0
|
|
43
|
+
private _ex = 0 // external horizontal velocity (world units/s) — root motion from an Animator
|
|
44
|
+
private _ez = 0
|
|
43
45
|
|
|
44
46
|
onAttach(): void {
|
|
45
47
|
if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
|
|
@@ -65,6 +67,9 @@ export class CharacterController extends Aspect<"controller", Node> {
|
|
|
65
67
|
/** Queue a jump; consumed on the next frame if grounded. */
|
|
66
68
|
jump(): void { this._jumpQueued = true }
|
|
67
69
|
|
|
70
|
+
/** @internal — extra world-space horizontal velocity added on top of `move()` this frame (root motion). */
|
|
71
|
+
_setExternalVelocity(x: number, _y: number, z: number): void { this._ex = x; this._ez = z }
|
|
72
|
+
|
|
68
73
|
/** True while standing on walkable ground (OnGround). */
|
|
69
74
|
get grounded(): boolean {
|
|
70
75
|
return this._charId ? _creator.characterGetGroundState(this._charId) === 0 : false
|
|
@@ -96,6 +101,6 @@ export class CharacterController extends Aspect<"controller", Node> {
|
|
|
96
101
|
if (this._jumpQueued && grounded) this._vy = this.jumpSpeed
|
|
97
102
|
this._jumpQueued = false
|
|
98
103
|
this._vy += this.gravity * dt // integrate gravity (CharacterVirtual is kinematic)
|
|
99
|
-
_creator.characterSetVelocity(this._charId, this._mx * this.speed, this._vy, this._mz * this.speed)
|
|
104
|
+
_creator.characterSetVelocity(this._charId, this._mx * this.speed + this._ex, this._vy, this._mz * this.speed + this._ez)
|
|
100
105
|
}
|
|
101
106
|
}
|