lecodes-cli 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/index.js +1340 -648
  2. package/package.json +3 -3
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk/compile/bundler.ts +1 -1
  5. package/runtime/sdk/compile/compileProject.ts +16 -1
  6. package/runtime/sdk/compile/header.ts +6 -1
  7. package/runtime/sdk/compile/index.ts +16 -0
  8. package/runtime/sdk/compile/liteMaterial.ts +247 -0
  9. package/runtime/sdk/compile/shaderTargets.ts +42 -0
  10. package/runtime/sdk/core/Aspect.ts +255 -244
  11. package/runtime/sdk/g2/CharacterController2D.ts +2 -2
  12. package/runtime/sdk/gl/Camera.ts +41 -0
  13. package/runtime/sdk/gl/CameraPlace.ts +51 -0
  14. package/runtime/sdk/gl/CharacterController.ts +6 -2
  15. package/runtime/sdk/gl/IK.ts +193 -174
  16. package/runtime/sdk/gl/Material.ts +23 -0
  17. package/runtime/sdk/gl/Model.ts +7 -6
  18. package/runtime/sdk/gl/Node.ts +270 -285
  19. package/runtime/sdk/gl/Particles.ts +676 -625
  20. package/runtime/sdk/gl/Physics.ts +53 -1
  21. package/runtime/sdk/gl/Scene.ts +323 -291
  22. package/runtime/sdk/gl/Shape.ts +248 -8
  23. package/runtime/sdk/gl/Trigger.ts +50 -46
  24. package/runtime/sdk/gl/Vehicle.ts +519 -0
  25. package/runtime/sdk/gl/{AnimationClip.ts → animation/AnimationClip.ts} +37 -7
  26. package/runtime/sdk/gl/animation/Animator.ts +87 -0
  27. package/runtime/sdk/gl/animation/Layer.ts +29 -0
  28. package/runtime/sdk/gl/animation/Loop.ts +25 -0
  29. package/runtime/sdk/gl/animation/Playback.ts +43 -0
  30. package/runtime/sdk/gl/animation/core.ts +294 -0
  31. package/runtime/sdk/gl/scenarios.ts +26 -58
  32. package/runtime/sdk/inject.ts +18 -9
  33. package/runtime/sdk/runtime/app.ts +13 -0
  34. package/runtime/sdk/runtime/input.ts +169 -6
  35. package/runtime/sdk/scene/defineScene.ts +182 -26
  36. package/runtime/sdk/scene/gizmos.ts +128 -0
  37. package/runtime/sdk-types.json +1 -1
  38. package/runtime/sdk/gl/Animator.ts +0 -642
  39. package/runtime/sdk/gl/ModelAnimation.ts +0 -95
@@ -0,0 +1,87 @@
1
+ // Animator — THE animation system, reached as `node.anim`. Every Model carries one pre-attached (its
2
+ // clip list = the GLB's embedded clips; nothing native exists until the first play()). The whole
3
+ // per-frame loop — fades, blend spaces, one-shot fade-back, root motion — runs in the engine; the
4
+ // SDK only issues calls. See docs/animator-plan.md.
5
+ //
6
+ // const hero = await Model.load(asset('./hero.glb'))
7
+ // hero.anim.playLoop('Idle') // the layer's LOOP: what it rests on
8
+ // await hero.anim.play('Jump', { fadeIn: 0.1, fadeOut: 0.2 }) // a one-shot over it, back to the loop after
9
+ //
10
+ // hero.anim.addClip('slash', slash) // clips from other files / procedural / sliced
11
+ // const loco = hero.anim.playLoop({ Idle: 0, Walking: 2, 'Fast Run': 6 }) // a blend as the loop
12
+ // loco.value = hero.controller.velocity.length // where we are on the blend axis
13
+ // const kick = hero.anim.play('slash', { fadeIn: 0.1, fadeOut: 0.3 }) // resolves at its hand-over
14
+ // kick.clip.addEvent(0.4, 'hit'); hero.anim.on('hit', () => …)
15
+ // if (await kick) hero.anim.playLoop('Crouch', { fade: 0.3 }) // resolved at the hand-over: what you start now is what it fades into
16
+ // const upper = hero.anim.addLayer({ mask: 'Spine1' })
17
+ // upper.playLoop('Aim', { fade: 0.3 }) // arms aim, legs keep walking
18
+ //
19
+ // Any Node hierarchy works, not just Models: tracks bind to child names (a robot arm of meshes).
20
+
21
+ import { Aspect } from "../../core/Aspect"
22
+ import type { Node } from "../Node"
23
+ import type { AnimationClip } from "./AnimationClip"
24
+ import { Core, type ClipEventHandler, type LayerOptions, type LoopDef, type LoopOptions, type PlayOptions, type StopOptions } from "./core"
25
+ import type { Loop } from "./Loop"
26
+ import type { Layer } from "./Layer"
27
+ import type { Playback } from "./Playback"
28
+
29
+ export class Animator extends Aspect<"anim", Node> {
30
+ static readonly aspect = "anim"
31
+ private _c!: Core
32
+ private _rootMotion = false
33
+
34
+ onAttach(): void { this._c = new Core(this.node) }
35
+ onDetach(): void { this._c.destroy() }
36
+
37
+ // ---- clips ----
38
+ /** The clip list, in order: the GLB's embedded clips (file order), then the clips you added. An
39
+ * added clip with an embedded clip's name takes its place. */
40
+ get clips(): readonly AnimationClip[] { return this._c.clips() }
41
+ /** One clip by name, or by index into `clips` — e.g. to `slice()` it. `undefined` if there is none. */
42
+ clip(ref: string | number): AnimationClip | undefined { return this._c.resolveClip(ref)?.[1] }
43
+ /** Add a clip under a name (default: its own `name`) — from another file, procedural, or sliced.
44
+ * Overrides an embedded clip of the same name. Chainable. */
45
+ addClip(name: string | AnimationClip, clip?: AnimationClip): this {
46
+ const c = typeof name === "string" ? clip : name
47
+ const n = typeof name === "string" ? name : name.name
48
+ if (!c) { console.warn(`Animator.addClip('${n}'): no clip`); return this }
49
+ this._c.addClip(n, c)
50
+ return this
51
+ }
52
+
53
+ // ---- playing (the base layer) ----
54
+ /** Play a one-shot on the base layer — by name, index, or the first clip with no argument.
55
+ * Crossfades per the options (default: cut). Returns a Playback: `await` it — it resolves at the
56
+ * clip's hand-over (`true`; `false` if cut short), and what you start right after is what the clip
57
+ * fades into (another one-shot, `playLoop(next)`; nothing = back to the loop). */
58
+ play(clip?: string | number, options: PlayOptions = {}): Playback { return this._c.play(this._c.layers[0], clip, options) }
59
+ /** Set the base layer's LOOP — what it shows when no one-shot plays: a clip (`playLoop('Idle')`) or
60
+ * a blend space (`playLoop({ Idle: 0, Run: 6 })`, drive the returned object's `value`). Crossfades
61
+ * over `fade` from whatever plays — a one-shot included (it is cut short). `stop()` removes it. */
62
+ playLoop(def: LoopDef, options: LoopOptions = {}): Loop | undefined { return this._c.loop(this._c.layers[0], def, options) }
63
+ /** Fade everything out, on every layer (loops included) → rest pose. */
64
+ stop(options: StopOptions = {}): this { this._c.stopAll(options.fade ?? 0); return this }
65
+ /** The base layer's current loop (the object the last `playLoop()` returned), if any. */
66
+ get loop(): Loop | undefined { return this._c.layers[0].loop?.view }
67
+ /** A one-shot on the base layer hasn't handed over yet. */
68
+ get busy(): boolean { return this._c.busy(this._c.layers[0]) }
69
+
70
+ // ---- layers ----
71
+ /** A new layer on top (masked override / additive). The returned object is its handle. */
72
+ addLayer(options: LayerOptions = {}): Layer { return this._c.addLayer(options) }
73
+
74
+ // ---- clip events (`clip.addEvent(0.4, 'hit')`) ----
75
+ on(event: string, cb: ClipEventHandler): this { this._c.on(event, cb); return this }
76
+ off(event: string, cb: ClipEventHandler): this { this._c.off(event, cb); return this }
77
+
78
+ // ---- props ----
79
+ /** Global playback rate: 0.3 = slow-mo, 0 = pause. */
80
+ get speed(): number { return this._c.speed }
81
+ set speed(v: number) { this._c.setSpeed(v) }
82
+ /** Root motion: the root bone's horizontal travel comes OFF the pose and moves the node instead —
83
+ * or its CharacterController (on this node or an ancestor) as a velocity, so it collides. For
84
+ * clips whose hips actually travel (Mixamo without "In Place", lunges, rolls). */
85
+ get rootMotion(): boolean { return this._rootMotion }
86
+ set rootMotion(on: boolean) { this._rootMotion = on; this._c.setRootMotion(on) }
87
+ }
@@ -0,0 +1,29 @@
1
+ // Layer — one layer of an Animator (`anim.addLayer({ mask: 'Spine1' })` for the upper body,
2
+ // `{ additive: true }` for deltas stacked on top). The Animator's own play/stop/playLoop ARE its base
3
+ // layer; extra layers composite over it in creation order. The object is the layer's only handle.
4
+
5
+ import type { Core, LayerRec, LoopDef, LoopOptions, PlayOptions, StopOptions } from "./core"
6
+ import type { Loop } from "./Loop"
7
+ import type { Playback } from "./Playback"
8
+
9
+ export class Layer {
10
+ private readonly _r: LayerRec
11
+ private readonly _c: Core
12
+ /** @internal — created by the Animator. */
13
+ constructor(rec: LayerRec, core: Core) { this._r = rec; this._c = core }
14
+
15
+ /** Play a one-shot on this layer (crossfading per the options); resolves at its hand-over. */
16
+ play(clip?: string | number, options: PlayOptions = {}): Playback { return this._c.play(this._r, clip, options) }
17
+ /** Set / transition this layer's loop (a clip, or a blend space) — crossfades over `fade` from whatever
18
+ * the layer shows, a one-shot included (it is cut short). */
19
+ playLoop(def: LoopDef, options: LoopOptions = {}): Loop | undefined { return this._c.loop(this._r, def, options) }
20
+ /** Fade everything on this layer out — one-shots and its loop. */
21
+ stop(options: StopOptions = {}): this { this._c.stopLayer(this._r, options.fade ?? 0); return this }
22
+ /** This layer's current loop (the object the last `playLoop()` returned), if any. */
23
+ get loop(): Loop | undefined { return this._r.loop?.view }
24
+ /** Layer contribution 0–1. */
25
+ get weight(): number { return this._r.weight }
26
+ set weight(v: number) { this._r.weight = v; this._c.pushLayer(this._r) }
27
+ /** A one-shot on this layer hasn't handed over yet. */
28
+ get busy(): boolean { return this._c.busy(this._r) }
29
+ }
@@ -0,0 +1,25 @@
1
+ // Loop — a layer's LOOP: what it shows when no one-shot plays (`anim.playLoop('Idle')` — one looping clip,
2
+ // or `anim.playLoop({ Idle: 0, Walking: 2, Run: 6 })` — a blend whose members loop in phase and `value`
3
+ // picks the mix: 1D linear between neighbours, `[x, y]` positions = a 2D gradient band). One-shots fade
4
+ // it out and return to it; `playLoop(next)` crossfades to another; `stop()` removes it. The object
5
+ // returned by `playLoop()` (also `anim.loop`) is the handle; a replaced loop's object goes dead.
6
+
7
+ import type { BlendPosition, Core, LoopRec } from "./core"
8
+
9
+ export class Loop {
10
+ private readonly _r: LoopRec
11
+ private readonly _c: Core
12
+ /** @internal — created by playLoop(). */
13
+ constructor(rec: LoopRec, core: Core) { this._r = rec; this._c = core }
14
+
15
+ /** Position on the axis (number) or plane (`[x, y]`) — units are whatever the positions use. Live
16
+ * even while a one-shot plays (the loop returns at the new mix). */
17
+ get value(): BlendPosition { return this._r.value }
18
+ set value(v: BlendPosition) { this._c.setLoopValue(this._r, v) }
19
+ /** Each member's current contribution, 0–1 (fades included). */
20
+ get weights(): Record<string, number> {
21
+ const out: Record<string, number> = {}
22
+ for (const m of this._r.members) out[m.name] = this._c.slotWeight(m.slot)
23
+ return out
24
+ }
25
+ }
@@ -0,0 +1,43 @@
1
+ // Playback — the handle `play()` returns (docs/animator-plan.md). Thenable: `await anim.play('slash')`
2
+ // resolves at the clip's HAND-OVER — where its fade-back starts (`end − fadeOut`; the end when there
3
+ // is no fadeOut) — with `true`; `false` when it was cut short (replaced / stopped). What
4
+ // you call right after the await is what the clip fades into: `anim.play(next)` (a chained one-shot,
5
+ // crossfaded), `anim.playLoop(next)` — nothing = back to the loop (or hold, on a layer without one).
6
+ // Returning one from an `async` function flattens it into `Promise<boolean>` — return `p.done` (or
7
+ // make the function sync) when the caller needs the handle.
8
+
9
+ import type { AnimationClip } from "./AnimationClip"
10
+ import type { Core, PlaybackRec, StopOptions } from "./core"
11
+ import type { Layer } from "./Layer"
12
+
13
+ export class Playback implements PromiseLike<boolean> {
14
+ private readonly _r: PlaybackRec
15
+ private readonly _c: Core
16
+ /** @internal — created by play(). */
17
+ constructor(rec: PlaybackRec, core: Core) { this._r = rec; this._c = core }
18
+
19
+ /** The clip that plays. */
20
+ get clip(): AnimationClip { return this._r.slot.clip }
21
+ /** The layer it plays on. */
22
+ get layer(): Layer { return this._r.slot.layer.view }
23
+ /** Clock (s). */
24
+ get time(): number { return this._c.slotTime(this._r.slot) }
25
+ /** 0–1 through the clip (the current cycle, when looping). */
26
+ get progress(): number { const d = this.clip.duration; return d > 0 ? this.time / d : 0 }
27
+ /** Not handed over yet (the clip still owns the layer). */
28
+ get playing(): boolean { return this._r.playing }
29
+ /** Current contribution to the pose, 0–1 — moves during crossfades. */
30
+ get weight(): number { return this._r.slot.playback === this._r ? this._c.slotWeight(this._r.slot) : 0 }
31
+ /** Resolves at the hand-over (`true`) or when cut short (`false`) — the promise `await playback`
32
+ * uses; the one to return from async functions or put in `Promise.all`. */
33
+ get done(): Promise<boolean> { return this._r.done }
34
+
35
+ then<A = boolean, B = never>(onFulfilled?: ((v: boolean) => A | PromiseLike<A>) | null, onRejected?: ((e: unknown) => B | PromiseLike<B>) | null): Promise<A | B> {
36
+ return this._r.done.then(onFulfilled, onRejected)
37
+ }
38
+
39
+ /** Move the clock (s). */
40
+ seek(time: number): this { this._c.seek(this._r.slot, time); return this }
41
+ /** Cut this clip short: fade it out (the layer's loop returns if nothing else is coming in). */
42
+ stop(options: StopOptions = {}): this { this._c.stopSlot(this._r.slot, options.fade ?? 0); return this }
43
+ }
@@ -0,0 +1,294 @@
1
+ // The engine-facing core behind Animator / Layer / Blend / Playback (docs/animator-plan.md). NOT exported
2
+ // from the SDK: the public classes are thin views over the records here. The engine runs the whole
3
+ // per-frame loop (fades, blend spaces, one-shot fade-back, root motion); this file binds clips,
4
+ // issues play/stop/blend calls and routes the engine's slot events back to playbacks.
5
+
6
+ import { AnimationClip } from "./AnimationClip"
7
+ import { Loop } from "./Loop"
8
+ import { Layer } from "./Layer"
9
+ import { Playback } from "./Playback"
10
+ import type { Node } from "../Node"
11
+
12
+ /** A position on a blend axis (1D) or plane (2D). */
13
+ export type BlendPosition = number | readonly [number, number]
14
+
15
+ export type PlayOptions = {
16
+ /** Crossfade seconds for BOTH ends (default 0 = cut). */
17
+ fade?: number
18
+ /** Fade-in seconds (overrides `fade`). */
19
+ fadeIn?: number
20
+ /** One-shots: fade-back seconds at the END (overrides `fade`). The return fade starts this long
21
+ * BEFORE the clip ends, while it still plays — no freeze-frame on the last pose. On a layer with
22
+ * no blend to return to, giving `fadeOut` releases the layer at the end instead of holding the
23
+ * last frame. */
24
+ fadeOut?: number
25
+ /** Playback rate for this clip (default 1). */
26
+ speed?: number
27
+ /** Rewind even if the clip is already playing (default: rewind only if it isn't). */
28
+ restart?: boolean
29
+ }
30
+
31
+ export type StopOptions = { fade?: number }
32
+
33
+ export type LoopOptions = {
34
+ /** Crossfade seconds from whatever the layer shows — the previous loop, or a one-shot still playing
35
+ * (default 0 = cut). */
36
+ fade?: number
37
+ /** Playback rate of the loop's clips (default 1). */
38
+ speed?: number
39
+ }
40
+ /** What a layer loops: one clip (name / index), or a blend space `{ name: position }`. */
41
+ export type LoopDef = string | number | Record<string, BlendPosition>
42
+
43
+ export type LayerOptions = {
44
+ /** Bone name(s): the layer drives only these subtrees (`'Spine1'` = upper body). Default: the whole rig. */
45
+ mask?: string | string[]
46
+ /** Blend each clip's DELTA from its own first frame on top of the layers below (lean, breathe, recoil)
47
+ * instead of overriding them. */
48
+ additive?: boolean
49
+ /** Layer contribution 0–1 (default 1). */
50
+ weight?: number
51
+ }
52
+
53
+ export type ClipEventHandler = (clip: string, layer: Layer) => void
54
+
55
+ // ---- records -------------------------------------------------------------------------------------
56
+
57
+ export type SlotRec = { name: string, clip: AnimationClip, slot: number, layer: LayerRec, inBlend: boolean, playback?: PlaybackRec }
58
+ export type PlaybackRec = { slot: SlotRec, playing: boolean, done: Promise<boolean>, resolve: (natural: boolean) => void }
59
+ export type LayerRec = { index: number, view: Layer, slots: SlotRec[], byName: Map<string, SlotRec>, loop?: LoopRec, weight: number, mask: string, additive: boolean }
60
+ export type LoopRec = { layer: LayerRec, members: { name: string, slot: SlotRec }[], value: BlendPosition, view: Loop, dead: boolean }
61
+
62
+ const handlers = new Map<number, (slot: number, type: number) => void>()
63
+ let eventsInstalled = false
64
+ const ensureEvents = (): void => {
65
+ if (eventsInstalled) return
66
+ eventsInstalled = true
67
+ _creator.setOnAnimatorEvent((animatorId, slot, type) => handlers.get(animatorId)?.(slot, type))
68
+ }
69
+
70
+ const settled = (slot: SlotRec): PlaybackRec => {
71
+ let resolve = (_n: boolean): void => {}
72
+ const done = new Promise<boolean>((r) => { resolve = r })
73
+ resolve(false)
74
+ return { slot, playing: false, done, resolve }
75
+ }
76
+
77
+ export class Core {
78
+ id = 0
79
+ /** Added clips by name (`addClip`) — the lookup index; an added name overrides an embedded one. */
80
+ readonly table = new Map<string, AnimationClip>()
81
+ readonly layers: LayerRec[] = []
82
+ speed = 1
83
+ private _embedded?: AnimationClip[]
84
+ private readonly _added: { name: string, clip: AnimationClip }[] = []
85
+ private _list?: AnimationClip[]
86
+ private readonly _listeners = new Map<string, Set<ClipEventHandler>>()
87
+
88
+ readonly node: Node
89
+ constructor(node: Node) { this.node = node; this.layers.push(this.newLayer()) }
90
+
91
+ // ---- clips ----
92
+ embedded(): AnimationClip[] {
93
+ if (!this._embedded) {
94
+ this._embedded = AnimationClip._ofSet(_creator.getGlbClipSet(this.node.id))
95
+ const seen = new Set<string>()
96
+ for (const c of this._embedded) {
97
+ if (seen.has(c.name)) console.warn(`Animator: ${this.node.name || "the node"} has several clips named '${c.name}' — by name you get the first; reach the others by index (anim.clip(n))`)
98
+ seen.add(c.name)
99
+ }
100
+ }
101
+ return this._embedded
102
+ }
103
+ /** The ordered clip list: the GLB's clips in file order (an added clip of the same name takes the
104
+ * embedded one's place), then the remaining added clips in insertion order. Cached until addClip. */
105
+ clips(): AnimationClip[] {
106
+ if (this._list) return this._list
107
+ const out: AnimationClip[] = []
108
+ const placed = new Set<string>()
109
+ for (const c of this.embedded()) {
110
+ const override = this.table.get(c.name)
111
+ if (override && !placed.has(c.name)) { out.push(override); placed.add(c.name) }
112
+ else out.push(c)
113
+ }
114
+ for (const a of this._added) if (!placed.has(a.name)) { out.push(a.clip); placed.add(a.name) }
115
+ return this._list = out
116
+ }
117
+ addClip(name: string, clip: AnimationClip): void {
118
+ const i = this._added.findIndex((a) => a.name === name)
119
+ if (i >= 0) this._added[i] = { name, clip }
120
+ else this._added.push({ name, clip })
121
+ this.table.set(name, clip)
122
+ this._list = undefined
123
+ }
124
+ /** A clip by name (added first, then embedded), or by index into `clips()` (none = the first). */
125
+ resolveClip(ref: string | number | undefined): [string, AnimationClip] | undefined {
126
+ if (ref === undefined || typeof ref === "number") { const c = this.clips()[ref ?? 0]; return c ? [ c.name, c ] : undefined }
127
+ const c = this.table.get(ref) ?? this.embedded().find((e) => e.name === ref)
128
+ return c ? [ ref, c ] : undefined
129
+ }
130
+ private clipNames(): string { return this.clips().map((c) => c.name).join(", ") || "(empty)" }
131
+
132
+ // ---- lifecycle ----
133
+ /** Create the native animator on demand. False = nothing to animate (warned). */
134
+ ensure(): boolean {
135
+ if (this.id) return true
136
+ if (this.embedded().length === 0 && this.table.size === 0) { console.warn(`Animator: no clips on ${this.node.name || "the node"} — nothing to play`); return false }
137
+ ensureEvents()
138
+ this.id = _creator.animatorCreate(this.node.id)
139
+ if (!this.id) { console.warn("Animator: node has no transform hierarchy"); return false }
140
+ handlers.set(this.id, (slot, type) => this.onEvent(slot, type))
141
+ if (this.speed !== 1) _creator.animatorSetGlobal(this.id, this.speed, false)
142
+ for (const L of this.layers) if (L.index > 0) this.pushLayer(L)
143
+ return true
144
+ }
145
+ destroy(): void {
146
+ for (const L of this.layers) for (const s of L.slots) if (s.playback) this.settle(s.playback, false)
147
+ if (this.id) { _creator.animatorDestroy(this.id); handlers.delete(this.id); this.id = 0 }
148
+ for (const L of this.layers) { L.slots = []; L.byName.clear(); if (L.loop) { L.loop.dead = true; L.loop = undefined } }
149
+ }
150
+
151
+ // ---- layers ----
152
+ newLayer(options?: LayerOptions): LayerRec {
153
+ const rec: LayerRec = { index: this.layers.length, view: undefined as unknown as Layer, slots: [], byName: new Map(), weight: options?.weight ?? 1, mask: "", additive: options?.additive ?? false }
154
+ if (options?.mask !== undefined) rec.mask = Array.isArray(options.mask) ? options.mask.join("\n") : options.mask
155
+ rec.view = new Layer(rec, this)
156
+ return rec
157
+ }
158
+ addLayer(options: LayerOptions): Layer {
159
+ if (this.layers.length >= 255) { console.warn("Animator: too many layers"); return this.layers[this.layers.length - 1].view }
160
+ const rec = this.newLayer(options)
161
+ this.layers.push(rec)
162
+ if (this.id) this.pushLayer(rec)
163
+ return rec.view
164
+ }
165
+ pushLayer(L: LayerRec): void {
166
+ if (!this.id) return
167
+ for (const m of L.mask.split("\n")) if (m && !this.node.bone(m)) console.warn(`Animator: layer mask bone '${m}' not found under ${this.node.name || "the node"}`)
168
+ _creator.animatorSetLayer(this.id, L.index, L.weight, L.additive, L.mask)
169
+ }
170
+
171
+ // ---- slots ----
172
+ /** Bind a clip on a layer (once per name). */
173
+ slotFor(L: LayerRec, name: string, clip: AnimationClip): SlotRec | undefined {
174
+ const have = L.byName.get(name)
175
+ if (have && have.clip === clip) return have
176
+ const slot = _creator.animatorBind(this.id, clip._set, clip._index, L.index)
177
+ if (slot < 0) { console.warn(`Animator: could not bind clip '${name}'`); return undefined }
178
+ const bound = _creator.animatorBoundTracks(this.id, slot)
179
+ if (clip.trackCount > 0 && bound === 0) console.warn(`Animator: '${name}' — no track matched a node name under ${this.node.name || "the node"} (different rig?)`)
180
+ else if (bound < clip.trackCount) console.warn(`Animator: '${name}' — ${clip.trackCount - bound}/${clip.trackCount} tracks unbound (bone names not found)`)
181
+ const rec: SlotRec = { name, clip, slot, layer: L, inBlend: false }
182
+ L.slots.push(rec)
183
+ L.byName.set(name, rec)
184
+ return rec
185
+ }
186
+
187
+ // ---- play / stop ----
188
+ play(L: LayerRec, ref: string | number | undefined, o: PlayOptions): Playback {
189
+ const found = this.resolveClip(ref)
190
+ if (!found) {
191
+ console.warn(`Animator: no clip ${ref === undefined ? "to play" : JSON.stringify(ref)} on ${this.node.name || "the node"} — table: ${this.clipNames()}`)
192
+ return new Playback(settled({ name: "", clip: AnimationClip._none(), slot: -1, layer: L, inBlend: false }), this)
193
+ }
194
+ const [name, clip] = found
195
+ if (!this.ensure()) return new Playback(settled({ name, clip, slot: -1, layer: L, inBlend: false }), this)
196
+ const s = this.slotFor(L, name, clip)
197
+ if (!s) return new Playback(settled({ name, clip, slot: -1, layer: L, inBlend: false }), this)
198
+ if (s.inBlend) { console.warn(`Animator: '${name}' is part of this layer's loop — use playLoop() to change what it rests on`); return new Playback(settled(s), this) }
199
+ const fadeIn = o.fadeIn ?? o.fade ?? 0
200
+ const fadeOut = o.fadeOut ?? o.fade ?? -1
201
+ // the other one-shots on this layer are cut short (their promises resolve false)
202
+ for (const other of L.slots) if (other.playback) this.settle(other.playback, false)
203
+ let resolve = (_n: boolean): void => {}
204
+ const done = new Promise<boolean>((r) => { resolve = r })
205
+ const rec: PlaybackRec = { slot: s, playing: true, done, resolve }
206
+ s.playback = rec
207
+ _creator.animatorPlay(this.id, s.slot, false, o.speed ?? 1, fadeIn, fadeOut, o.restart ?? false)
208
+ return new Playback(rec, this)
209
+ }
210
+ /** A one-shot on the layer hasn't handed over yet. */
211
+ busy(L: LayerRec): boolean { return L.slots.some((s) => s.playback?.playing) }
212
+ settle(p: PlaybackRec, natural: boolean): void {
213
+ p.playing = false
214
+ if (p.slot.playback === p) p.slot.playback = undefined
215
+ p.resolve(natural)
216
+ }
217
+ stopSlot(s: SlotRec, fade: number): void {
218
+ if (s.playback) this.settle(s.playback, false)
219
+ if (this.id && s.slot >= 0) _creator.animatorStop(this.id, s.layer.index, s.slot, fade)
220
+ }
221
+ stopLayer(L: LayerRec, fade: number): void {
222
+ for (const s of L.slots) if (s.playback) this.settle(s.playback, false)
223
+ if (L.loop) { L.loop.dead = true; L.loop = undefined }
224
+ if (this.id) _creator.animatorStop(this.id, L.index, -1, fade)
225
+ }
226
+ stopAll(fade: number): void { for (const L of this.layers) this.stopLayer(L, fade) }
227
+
228
+ // ---- loop ----
229
+ loop(L: LayerRec, def: LoopDef, o: LoopOptions): Loop | undefined {
230
+ if (!this.ensure()) return undefined
231
+ const fade = o.fade ?? 0
232
+ if (L.loop) { L.loop.dead = true; for (const m of L.loop.members) m.slot.inBlend = false; L.loop = undefined }
233
+ for (const s of L.slots) if (s.playback) this.settle(s.playback, false) // one-shots on the layer are cut short
234
+ const space: Record<string, BlendPosition> = typeof def === "object" ? def : {}
235
+ if (typeof def !== "object") {
236
+ const found = this.resolveClip(def)
237
+ if (!found) { console.warn(`Animator: no clip ${JSON.stringify(def)} to loop — table: ${this.clipNames()}`); return undefined }
238
+ space[found[0]] = 0
239
+ }
240
+ const rec: LoopRec = { layer: L, members: [], value: 0, view: undefined as unknown as Loop, dead: false }
241
+ rec.view = new Loop(rec, this)
242
+ const slots: number[] = [], positions: number[] = []
243
+ const is2D = Object.values(space).some((p) => typeof p !== "number")
244
+ for (const [name, at] of Object.entries(space)) {
245
+ const found = this.resolveClip(name)
246
+ if (!found) { console.warn(`Animator: loop clip '${name}' is not in the clip table`); continue }
247
+ const s = this.slotFor(L, name, found[1])
248
+ if (!s) continue
249
+ if (s.playback) this.settle(s.playback, false)
250
+ s.inBlend = true
251
+ rec.members.push({ name, slot: s })
252
+ slots.push(s.slot)
253
+ if (is2D) positions.push(typeof at === "number" ? at : at[0], typeof at === "number" ? 0 : at[1])
254
+ else positions.push(at as number)
255
+ }
256
+ L.loop = rec
257
+ _creator.animatorSetBlend(this.id, L.index, is2D ? 2 : 1, new Uint16Array(slots), new Float32Array(positions), fade, o.speed ?? 1)
258
+ return rec.view
259
+ }
260
+ setLoopValue(b: LoopRec, v: BlendPosition): void {
261
+ if (b.dead) { console.warn("Animator: this loop was replaced — use the object the latest playLoop() returned"); return }
262
+ b.value = v
263
+ if (!this.id) return
264
+ const [x, y] = typeof v === "number" ? [ v, 0 ] : [ v[0], v[1] ]
265
+ _creator.animatorSetBlendValue(this.id, b.layer.index, x, y)
266
+ }
267
+ slotWeight(s: SlotRec): number { return this.id && s.slot >= 0 ? _creator.animatorGetSlotWeight(this.id, s.slot) : 0 }
268
+ slotTime(s: SlotRec): number { return this.id && s.slot >= 0 ? _creator.animatorGetSlotTime(this.id, s.slot) : 0 }
269
+ seek(s: SlotRec, time: number): void { if (this.id && s.slot >= 0) _creator.animatorSeek(this.id, s.slot, time) }
270
+
271
+ // ---- props ----
272
+ setSpeed(v: number): void { this.speed = v; if (this.id) _creator.animatorSetGlobal(this.id, v, false) }
273
+ setRootMotion(on: boolean): void { if (this.ensure()) _creator.animatorSetRootMotion(this.id, on ? "*" : "", on ? 2 : 0) }
274
+
275
+ // ---- events ----
276
+ on(event: string, cb: ClipEventHandler): void { (this._listeners.get(event) ?? this._listeners.set(event, new Set()).get(event)!).add(cb) }
277
+ off(event: string, cb: ClipEventHandler): void { this._listeners.get(event)?.delete(cb) }
278
+ /** Native slot event: 0 completed / 1 loop / 2 settled (faded to 0) / 3 hand-over / 4+i clip event i. */
279
+ private onEvent(slotIndex: number, type: number): void {
280
+ let s: SlotRec | undefined
281
+ for (const L of this.layers) { s = L.slots.find((x) => x.slot === slotIndex); if (s) break }
282
+ if (!s) return
283
+ if (type >= 4) {
284
+ const name = s.clip._events[type - 4]?.name
285
+ const cbs = name ? this._listeners.get(name) : undefined
286
+ if (cbs) for (const cb of [ ...cbs ]) cb(s.name, s.layer.view)
287
+ return
288
+ }
289
+ const p = s.playback
290
+ if (!p) return
291
+ if (type === 3) this.settle(p, true) // hand-over: the clip is over; the app's next call redirects the fade
292
+ else if (type === 2) this.settle(p, false) // faded out after a stop / replacement before handing over
293
+ }
294
+ }
@@ -1,9 +1,9 @@
1
1
  // No-code scenario aspects: ready-made behaviors a scene builder attaches in the editor without
2
2
  // writing any code — move to a target, travel a waypoint path, spin, face something, start a GLB
3
3
  // animation. They are ordinary aspects (work from hand-written code too); in the scene editor the
4
- // movement ones double as `static editor` generators that draw their paths as editor-only lines
5
- // (edges meshes under `this.generated` — play mode never calls rebuild(), so the lines cost
6
- // nothing at runtime).
4
+ // movement ones double as `static editor` generators that draw their paths as editor gizmos
5
+ // (`Gizmos.*` in rebuild() — overlay lines, never scene content; play mode never calls rebuild(),
6
+ // so they cost nothing at runtime).
7
7
  //
8
8
  // Conventions shared by the family:
9
9
  // • targets/waypoints are scene nodes — place an Empty, then pick it in the inspector (`ref()`);
@@ -18,8 +18,10 @@ import { Quat } from "../math/quat"
18
18
  import { Node } from "./Node"
19
19
  import { Mesh } from "./Mesh"
20
20
  import { Model } from "./Model"
21
+ import type { Playback } from "./animation/Playback"
21
22
  import { Geometry } from "./Geometry"
22
23
  import { Material } from "./Material"
24
+ import { Gizmos } from "../scene/gizmos"
23
25
 
24
26
  const MOVE_LINE = "#5b8ef0"
25
27
  const LOOK_LINE = "#d9a13f"
@@ -47,10 +49,10 @@ const toParentLocal = (node: Node, world: Vec3): Vec3 => {
47
49
  return parent ? parent.worldMatrix.invert().transformPoint(world) : world
48
50
  }
49
51
 
50
- // ---- editor line drawing (edit mode only — see rebuild() on the aspects below) -------------------
52
+ // ---- editor marker meshes (defineScene's empty/camera markers — real meshes, so they pick) -------
51
53
 
52
54
  /** @internal An "edges" line mesh from flat segment endpoints ([x,y,z, x,y,z] per segment) —
53
- * editor-only visuals (path lines here, node markers in defineScene). */
55
+ * the scene editor's node markers (defineScene). Path lines are `Gizmos` calls instead. */
54
56
  export const edgesMesh = (segments: number[], color: string): Mesh => {
55
57
  const vertices = new Float32Array(segments)
56
58
  const count = vertices.length / 3
@@ -66,25 +68,6 @@ export const edgesMesh = (segments: number[], color: string): Mesh => {
66
68
  return mesh
67
69
  }
68
70
 
69
- const pushCross = (segments: number[], p: Vec3, s: number): void => {
70
- segments.push(p.x - s, p.y, p.z, p.x + s, p.y, p.z)
71
- segments.push(p.x, p.y - s, p.z, p.x, p.y + s, p.z)
72
- segments.push(p.x, p.y, p.z - s, p.x, p.y, p.z + s)
73
- }
74
-
75
- /** `this.generated` hangs UNDER the host node, so line points (world space) must be expressed in
76
- * the host's local frame; the host is an editor-run dep of its own aspects, so dragging it
77
- * re-runs rebuild() and the conversion stays fresh. */
78
- const worldSegmentsToLocal = (node: Node, segments: number[]): number[] => {
79
- const inv = node.worldMatrix.invert()
80
- const out: number[] = []
81
- for (let i = 0; i < segments.length; i += 3) {
82
- const p = inv.transformPoint([ segments[i]!, segments[i + 1]!, segments[i + 2]! ])
83
- out.push(p.x, p.y, p.z)
84
- }
85
- return out
86
- }
87
-
88
71
  /** A path node's waypoints: its children in scene-FILE order (`_sceneChildOrder`, stamped by
89
72
  * defineScene — the engine's live child order is insertion-based and may differ), editor helper
90
73
  * nodes (`__`-prefixed) excluded. Runtime-added children append in engine order. */
@@ -143,14 +126,10 @@ export class MoveTo extends Aspect<"moveTo"> {
143
126
 
144
127
  /** Editor-only path line (edit mode; play mode never calls this). */
145
128
  rebuild(): void {
146
- this.generated.clear()
147
129
  if (!this.target) return
148
- const segments: number[] = []
149
- const a = this.node.worldPosition
150
130
  const b = this.target.worldPosition
151
- segments.push(a.x, a.y, a.z, b.x, b.y, b.z)
152
- pushCross(segments, b, 0.09)
153
- this.generated.add(edgesMesh(worldSegmentsToLocal(this.node, segments), MOVE_LINE))
131
+ Gizmos.line(this.node.worldPosition, b, { color: MOVE_LINE })
132
+ Gizmos.cross(b, 0.09, { color: MOVE_LINE })
154
133
  }
155
134
  }
156
135
 
@@ -223,16 +202,9 @@ export class FollowPath extends Aspect<"followPath"> {
223
202
 
224
203
  /** Editor-only waypoint polyline (edit mode; play mode never calls this). */
225
204
  rebuild(): void {
226
- this.generated.clear()
227
- const points = this._points()
228
- if (points.length < 2) return
229
- const segments: number[] = []
230
- for (let i = 0; i < points.length - 1; i++) {
231
- const a = points[i]!, b = points[i + 1]!
232
- segments.push(a.x, a.y, a.z, b.x, b.y, b.z)
233
- }
234
- for (const p of points) pushCross(segments, p, 0.07)
235
- this.generated.add(edgesMesh(worldSegmentsToLocal(this.node, segments), MOVE_LINE))
205
+ const points = this._points() // loop mode already appends the first point again
206
+ Gizmos.polyline(points, { color: MOVE_LINE })
207
+ for (const p of points) Gizmos.cross(p, 0.07, { color: MOVE_LINE })
236
208
  }
237
209
  }
238
210
 
@@ -289,14 +261,10 @@ export class LookAt extends Aspect<"facing"> {
289
261
 
290
262
  /** Editor-only sight line (edit mode; play mode never calls this). */
291
263
  rebuild(): void {
292
- this.generated.clear()
293
264
  if (!this.target) return
294
- const segments: number[] = []
295
- const a = this.node.worldPosition
296
265
  const b = this.target.worldPosition
297
- segments.push(a.x, a.y, a.z, b.x, b.y, b.z)
298
- pushCross(segments, b, 0.07)
299
- this.generated.add(edgesMesh(worldSegmentsToLocal(this.node, segments), LOOK_LINE))
266
+ Gizmos.line(this.node.worldPosition, b, { color: LOOK_LINE })
267
+ Gizmos.cross(b, 0.07, { color: LOOK_LINE })
300
268
  }
301
269
  }
302
270
 
@@ -316,16 +284,18 @@ export class PlayAnimation extends Aspect<"playAnimation", Model> {
316
284
  speed: { min: 0.05, max: 5, step: 0.05 },
317
285
  }
318
286
 
287
+ private _preview?: Playback
288
+
319
289
  onAttach(): void {
320
290
  const anim = (this.node as Model | undefined)?.anim
321
291
  if (!anim) return
322
- anim.speed = this.speed
323
- anim.play(this.clip === "" ? 0 : this.clip, { loop: this.loop })
292
+ if (this.loop) anim.playLoop(this.clip === "" ? 0 : this.clip, { speed: this.speed })
293
+ else anim.play(this.clip === "" ? 0 : this.clip, { speed: this.speed })
324
294
  }
325
295
 
326
296
  static inspector(ui: InspectorUI, a: PlayAnimation): void {
327
297
  const anim = (a.node as Model | undefined)?.anim
328
- const clips = anim?.clips ?? []
298
+ const clips = anim ? anim.clips : []
329
299
  if (clips.length === 0) {
330
300
  ui.warn("No animation clips — attach this to a GLB model node")
331
301
  return
@@ -333,17 +303,15 @@ export class PlayAnimation extends Aspect<"playAnimation", Model> {
333
303
  ui.select("clip", clips.map((c) => c.name))
334
304
  ui.auto("loop", "speed")
335
305
  if (anim) {
336
- if (ui.button(anim.playing ? "Restart preview" : "Preview")) {
337
- anim.speed = a.speed
338
- anim.play(a.clip === "" ? 0 : a.clip, { loop: a.loop })
339
- }
340
- if (anim.playing && ui.button("Stop")) {
341
- anim.stop()
342
- anim.time = 0
343
- }
344
306
  const clip = a.clip === "" ? clips[0]?.name : a.clip
307
+ const playing = a._preview?.playing === true
308
+ if (ui.button(playing ? "Restart preview" : "Preview")) {
309
+ if (a.loop) { anim.playLoop(clip, { speed: a.speed }); a._preview = undefined }
310
+ else a._preview = anim.play(clip, { speed: a.speed, restart: true })
311
+ }
312
+ if (playing && ui.button("Stop")) { anim.stop(); a._preview = undefined }
345
313
  const duration = clips.find((c) => c.name === clip)?.duration
346
- ui.info(anim.playing ? "previewing…" : duration !== undefined ? `${duration.toFixed(2)}s` : "")
314
+ ui.info(playing ? "previewing…" : duration !== undefined ? `${duration.toFixed(2)}s` : "")
347
315
  }
348
316
  }
349
317
  }