lecodes-cli 0.17.2 → 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.
- package/dist/index.js +1340 -648
- package/package.json +3 -3
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/compile/bundler.ts +1 -1
- package/runtime/sdk/compile/compileProject.ts +16 -1
- package/runtime/sdk/compile/header.ts +6 -1
- package/runtime/sdk/compile/index.ts +16 -0
- package/runtime/sdk/compile/liteMaterial.ts +247 -0
- package/runtime/sdk/compile/shaderTargets.ts +42 -0
- package/runtime/sdk/core/Aspect.ts +255 -244
- package/runtime/sdk/g2/CharacterController2D.ts +2 -2
- package/runtime/sdk/gl/Camera.ts +41 -0
- package/runtime/sdk/gl/CameraPlace.ts +51 -0
- package/runtime/sdk/gl/CharacterController.ts +3 -2
- package/runtime/sdk/gl/IK.ts +193 -174
- package/runtime/sdk/gl/Material.ts +11 -0
- package/runtime/sdk/gl/Model.ts +7 -6
- package/runtime/sdk/gl/Node.ts +270 -285
- package/runtime/sdk/gl/Physics.ts +171 -126
- package/runtime/sdk/gl/Scene.ts +27 -3
- package/runtime/sdk/gl/Shape.ts +214 -10
- package/runtime/sdk/gl/Vehicle.ts +519 -0
- package/runtime/sdk/gl/{AnimationClip.ts → animation/AnimationClip.ts} +37 -7
- package/runtime/sdk/gl/animation/Animator.ts +87 -0
- package/runtime/sdk/gl/animation/Layer.ts +29 -0
- package/runtime/sdk/gl/animation/Loop.ts +25 -0
- package/runtime/sdk/gl/animation/Playback.ts +43 -0
- package/runtime/sdk/gl/animation/core.ts +294 -0
- package/runtime/sdk/gl/scenarios.ts +26 -58
- package/runtime/sdk/inject.ts +18 -9
- package/runtime/sdk/runtime/app.ts +13 -0
- package/runtime/sdk/runtime/input.ts +169 -6
- package/runtime/sdk/scene/defineScene.ts +182 -26
- package/runtime/sdk/scene/gizmos.ts +128 -0
- package/runtime/sdk-types.json +1 -1
- package/runtime/sdk/gl/Animator.ts +0 -642
- 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
|
|
5
|
-
// (
|
|
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
|
|
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
|
|
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
|
-
|
|
152
|
-
|
|
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.
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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.
|
|
323
|
-
anim.play(this.clip === "" ? 0 : this.clip, {
|
|
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
|
|
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(
|
|
314
|
+
ui.info(playing ? "previewing…" : duration !== undefined ? `${duration.toFixed(2)}s` : "")
|
|
347
315
|
}
|
|
348
316
|
}
|
|
349
317
|
}
|