lecodes-cli 0.16.1 → 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.
Files changed (38) hide show
  1. package/README.md +27 -4
  2. package/dist/index.js +170706 -72
  3. package/package.json +11 -6
  4. package/runtime/sdk/compile/bundler.ts +10 -2
  5. package/runtime/sdk/compile/compileProject.ts +10 -2
  6. package/runtime/sdk/compile/detectEntry.ts +5 -2
  7. package/runtime/sdk/compile/index.ts +17 -1
  8. package/runtime/sdk/compile/serverSplit.ts +233 -0
  9. package/runtime/sdk/compile/serverTypes.ts +227 -0
  10. package/runtime/sdk/gl/AnimationClip.ts +174 -0
  11. package/runtime/sdk/gl/Animator.ts +634 -0
  12. package/runtime/sdk/gl/CharacterController.ts +6 -1
  13. package/runtime/sdk/gl/IK.ts +170 -0
  14. package/runtime/sdk/gl/Material.ts +1 -1
  15. package/runtime/sdk/gl/ModelAnimation.ts +34 -3
  16. package/runtime/sdk/gl/Node.ts +21 -0
  17. package/runtime/sdk/inject.ts +10 -0
  18. package/runtime/sdk/plugins/oauth.ts +61 -0
  19. package/runtime/sdk/runtime/rpc.ts +218 -0
  20. package/runtime/sdk/server/auth/appConfig.ts +12 -0
  21. package/runtime/sdk/server/auth/global.ts +80 -0
  22. package/runtime/sdk/server/auth/host.ts +318 -0
  23. package/runtime/sdk/server/auth/models.ts +83 -0
  24. package/runtime/sdk/server/auth/types.ts +50 -0
  25. package/runtime/sdk/server/channel.ts +56 -0
  26. package/runtime/sdk/server/context.ts +36 -0
  27. package/runtime/sdk/server/db/defineDb.ts +237 -0
  28. package/runtime/sdk/server/db/fields.ts +132 -0
  29. package/runtime/sdk/server/db/httpTransport.ts +93 -0
  30. package/runtime/sdk/server/db/index.ts +7 -0
  31. package/runtime/sdk/server/db/marci/query.ts +412 -0
  32. package/runtime/sdk/server/db/types.ts +202 -0
  33. package/runtime/sdk/server/errors.ts +12 -0
  34. package/runtime/sdk/server/host.ts +74 -0
  35. package/runtime/sdk/server/inject.ts +13 -0
  36. package/runtime/sdk/server/runtime.ts +133 -0
  37. package/runtime/sdk/server/validate.ts +87 -0
  38. 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
  }