lecodes-cli 0.18.2 → 0.19.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 (57) hide show
  1. package/dist/index.js +1240 -170
  2. package/package.json +3 -2
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk/compile/aspectMacro.ts +86 -0
  5. package/runtime/sdk/compile/assetIconMacro.ts +384 -0
  6. package/runtime/sdk/compile/assetMacro.ts +146 -0
  7. package/runtime/sdk/compile/assetName.ts +50 -0
  8. package/runtime/sdk/compile/bundler.ts +287 -0
  9. package/runtime/sdk/compile/compileProject.ts +129 -0
  10. package/runtime/sdk/compile/detectEntry.ts +128 -0
  11. package/runtime/sdk/compile/fontMacro.ts +459 -0
  12. package/runtime/sdk/compile/fontRegistry.ts +78 -0
  13. package/runtime/sdk/compile/header.ts +67 -0
  14. package/runtime/sdk/compile/index.ts +100 -0
  15. package/runtime/sdk/compile/libraryImports.ts +52 -0
  16. package/runtime/sdk/compile/liteMaterial.ts +247 -0
  17. package/runtime/sdk/compile/sceneEditor.ts +88 -0
  18. package/runtime/sdk/compile/serverSplit.ts +233 -0
  19. package/runtime/sdk/compile/serverTypes.ts +227 -0
  20. package/runtime/sdk/compile/sfnt.ts +98 -0
  21. package/runtime/sdk/compile/shaderSchema.ts +202 -0
  22. package/runtime/sdk/compile/shaderTargets.ts +81 -0
  23. package/runtime/sdk/compile/sourcemap.ts +25 -0
  24. package/runtime/sdk/core/Aspect.ts +65 -9
  25. package/runtime/sdk/core/StateMachine.ts +308 -0
  26. package/runtime/sdk/core/time.ts +3 -0
  27. package/runtime/sdk/gl/CameraPlace.ts +52 -52
  28. package/runtime/sdk/gl/Light.ts +12 -4
  29. package/runtime/sdk/gl/Lightmap.ts +87 -10
  30. package/runtime/sdk/gl/Material.ts +15 -0
  31. package/runtime/sdk/gl/Model.ts +68 -8
  32. package/runtime/sdk/gl/NavAgent.ts +337 -0
  33. package/runtime/sdk/gl/NavMesh.ts +397 -0
  34. package/runtime/sdk/gl/Ragdoll.ts +270 -0
  35. package/runtime/sdk/gl/Scene.ts +7 -0
  36. package/runtime/sdk/gl/Shape.ts +37 -5
  37. package/runtime/sdk/gl/Terrain.ts +1092 -0
  38. package/runtime/sdk/gl/Texture.ts +17 -0
  39. package/runtime/sdk/gl/Trigger.ts +45 -45
  40. package/runtime/sdk/gl/animation/Animator.ts +35 -2
  41. package/runtime/sdk/gl/animation/Layer.ts +3 -1
  42. package/runtime/sdk/gl/animation/Loop.ts +5 -0
  43. package/runtime/sdk/gl/animation/Playback.ts +4 -3
  44. package/runtime/sdk/gl/animation/core.ts +52 -10
  45. package/runtime/sdk/gl/scenarios.ts +291 -291
  46. package/runtime/sdk/gl/state.ts +6 -6
  47. package/runtime/sdk/gl/terrainMesh.ts +219 -0
  48. package/runtime/sdk/inject.ts +12 -1
  49. package/runtime/sdk/net/codec.ts +119 -0
  50. package/runtime/sdk/net/core.ts +384 -0
  51. package/runtime/sdk/net/index.ts +181 -0
  52. package/runtime/sdk/net/replication.ts +622 -0
  53. package/runtime/sdk/scene/defineScene.ts +140 -14
  54. package/runtime/sdk/scene/editorPlugins.ts +18 -0
  55. package/runtime/sdk/scene/gizmos.ts +148 -148
  56. package/runtime/sdk/ui/UINode.ts +4 -0
  57. package/runtime/sdk-types.json +1 -1
@@ -30,6 +30,23 @@ export class Texture {
30
30
  return new Texture(Math.round(canvas.width * canvas.pixelRatio), Math.round(canvas.height * canvas.pixelRatio), canvas._tex3dId)
31
31
  }
32
32
 
33
+ /** A texture from raw pixels: `channels` (1–4, default 4) UBYTE per pixel, row-major, `width ×
34
+ * height × channels` bytes; `srgb` (default true for colour) picks the sRGB internal format —
35
+ * pass false for data (a heightmap, a mask). No mips. Throws on a host without the call. */
36
+ static fromPixels(width: number, height: number, data: Uint8Array, opts: { channels?: 1 | 2 | 3 | 4, srgb?: boolean } = {}): Texture {
37
+ if (!_creator.createTexturePixels) throw new Error("Texture.fromPixels needs a newer host")
38
+ const channels = opts.channels ?? 4
39
+ if (data.length < width * height * channels) throw new Error(`Texture.fromPixels: ${data.length} bytes for ${width}×${height}×${channels}`)
40
+ const id = _creator.createTexturePixels(width, height, channels, data, opts.srgb ?? true)
41
+ if (id === 0xFFFFFFFF) throw new Error("Texture.fromPixels: the host refused the texture")
42
+ return new Texture(width, height, id)
43
+ }
44
+
45
+ /** Re-upload a rectangle of a `fromPixels` texture (same channel count). */
46
+ update(x: number, y: number, width: number, height: number, data: Uint8Array): void {
47
+ _creator.updateTexturePixels?.(this._id, x, y, width, height, data)
48
+ }
49
+
33
50
  static load(source: string | FetchResponse | File): Promise<Texture> {
34
51
  if (typeof source === "string") {
35
52
  return fetch(source, { useOnce: true }).then((resp) => {
@@ -1,45 +1,45 @@
1
- // A trigger zone, as an aspect on a 3D Node. Requires a Shape (its geometry); creates a static SENSOR
2
- // body from it, so a physics body overlapping the zone fires the node's 'enter' / 'exit' events
3
- // instead of colliding. A trigger does not block movement, and is still pointer-pickable.
4
- //
5
- // const goal = new Mesh(box(), material)
6
- // .aspect(Shape, { box: [1, 2, 1] })
7
- // .aspect(Trigger)
8
- // goal.addEventListener('enter', other => win(other))
9
- // goal.addEventListener('exit', other => …)
10
-
11
- import { Aspect } from "../core/Aspect"
12
- import { Shape } from "./Shape"
13
- import { ensurePhysicsEvents } from "./physicsEvents"
14
- import type { Node } from "./Node"
15
-
16
- export class Trigger extends Aspect<"trigger", Node> {
17
- static readonly aspect = "trigger"
18
-
19
- private _bodyId = 0
20
- /** Native body id (0 if no physics support). */
21
- get id(): number { return this._bodyId }
22
-
23
- onAttach(): void {
24
- if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
25
- const shape = this.node.get(Shape)
26
- if (!shape) {
27
- throw new Error("Trigger requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Trigger)")
28
- }
29
- const shapeId = shape._claim()
30
- this._bodyId = _creator.physicsCreateBody(this.node.id, shapeId, 0 /* static */, 0, true /* sensor */, false)
31
- shape._ownBody(this._bodyId)
32
- ensurePhysicsEvents() // routes overlap events → the node's 'enter' / 'exit'
33
- }
34
-
35
- onDetach(): void {
36
- if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
37
- const shape = this.node.get(Shape)
38
- if (shape) {
39
- shape._ownBody(0) // the Shape held OUR body id — forget it, or its own detach removes it twice (native crash)
40
- shape._recreatePickBody()
41
- }
42
- }
43
-
44
- // No moveTo(): `node.position = p` moves the node AND the sensor body it owns (see Node._xf).
45
- }
1
+ // A trigger zone, as an aspect on a 3D Node. Requires a Shape (its geometry); creates a static SENSOR
2
+ // body from it, so a physics body overlapping the zone fires the node's 'enter' / 'exit' events
3
+ // instead of colliding. A trigger does not block movement, and is still pointer-pickable.
4
+ //
5
+ // const goal = new Mesh(box(), material)
6
+ // .aspect(Shape, { box: [1, 2, 1] })
7
+ // .aspect(Trigger)
8
+ // goal.addEventListener('enter', other => win(other))
9
+ // goal.addEventListener('exit', other => …)
10
+
11
+ import { Aspect } from "../core/Aspect"
12
+ import { Shape } from "./Shape"
13
+ import { ensurePhysicsEvents } from "./physicsEvents"
14
+ import type { Node } from "./Node"
15
+
16
+ export class Trigger extends Aspect<"trigger", Node> {
17
+ static readonly aspect = "trigger"
18
+
19
+ private _bodyId = 0
20
+ /** Native body id (0 if no physics support). */
21
+ get id(): number { return this._bodyId }
22
+
23
+ onAttach(): void {
24
+ if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
25
+ const shape = this.node.get(Shape)
26
+ if (!shape) {
27
+ throw new Error("Trigger requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Trigger)")
28
+ }
29
+ const shapeId = shape._claim()
30
+ this._bodyId = _creator.physicsCreateBody(this.node.id, shapeId, 0 /* static */, 0, true /* sensor */, false)
31
+ shape._ownBody(this._bodyId)
32
+ ensurePhysicsEvents() // routes overlap events → the node's 'enter' / 'exit'
33
+ }
34
+
35
+ onDetach(): void {
36
+ if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
37
+ const shape = this.node.get(Shape)
38
+ if (shape) {
39
+ shape._ownBody(0) // the Shape held OUR body id — forget it, or its own detach removes it twice (native crash)
40
+ shape._recreatePickBody()
41
+ }
42
+ }
43
+
44
+ // No moveTo(): `node.position = p` moves the node AND the sensor body it owns (see Node._xf).
45
+ }
@@ -21,15 +21,38 @@
21
21
  import { Aspect } from "../../core/Aspect"
22
22
  import type { Node } from "../Node"
23
23
  import type { AnimationClip } from "./AnimationClip"
24
- import { Core, type ClipEventHandler, type LayerOptions, type LoopDef, type LoopOptions, type PlayOptions, type StopOptions } from "./core"
24
+ import { Core, type ActiveClip, type ClipEventHandler, type LayerOptions, type LoopDef, type LoopOptions, type PlayOptions, type StopOptions } from "./core"
25
25
  import type { Loop } from "./Loop"
26
26
  import type { Layer } from "./Layer"
27
27
  import type { Playback } from "./Playback"
28
28
 
29
+ /** Level of detail for a GLB instance (docs/lod-plan.md): `'auto'` = the engine's pick by screen size and
30
+ * visibility, or a fixed level 0 (full) … 3 (coarsest mesh, animation every 4th frame without fingers). */
31
+ export type LodMode = "auto" | 0 | 1 | 2 | 3
32
+
33
+ /** @internal Push a node's LOD override to the host: the Model's mesh level and the Animator's rate
34
+ * (an Animator set to 'full' wins over a model level — the pose stays exact, the mesh may still coarsen). */
35
+ export const _pushLod = (node: Node): void => {
36
+ if (!_creator.setLod) return
37
+ const mesh = (node as { _lodMesh?: number })._lodMesh ?? -1
38
+ const anim = node.get(Animator)
39
+ _creator.setLod(node.id, mesh, anim?._lod === "full" ? 0 : mesh)
40
+ }
41
+
29
42
  export class Animator extends Aspect<"anim", Node> {
30
43
  static readonly aspect = "anim"
31
44
  private _c!: Core
32
45
  private _rootMotion = false
46
+ private _rootRotation = false
47
+ /** @internal */
48
+ _lod: "auto" | "full" = "auto"
49
+
50
+ /** Animation level of detail. `'auto'` (default): a character small on screen or out of view is
51
+ * evaluated every 2nd / 4th frame and stops sampling its finger, toe and twist joints; `'full'`:
52
+ * every frame, every joint, whatever the distance — a hero seen through a scope, a cutscene actor.
53
+ * Independent of `Model.lod` (the mesh level). No-op on hosts without the LOD pass. */
54
+ get lod(): "auto" | "full" { return this._lod }
55
+ set lod(v: "auto" | "full") { this._lod = v; _pushLod(this.node) }
33
56
 
34
57
  onAttach(): void { this._c = new Core(this.node) }
35
58
  onDetach(): void { this._c.destroy() }
@@ -66,6 +89,8 @@ export class Animator extends Aspect<"anim", Node> {
66
89
  get loop(): Loop | undefined { return this._c.layers[0].loop?.view }
67
90
  /** A one-shot on the base layer hasn't handed over yet. */
68
91
  get busy(): boolean { return this._c.busy(this._c.layers[0]) }
92
+ /** Every clip shown this frame, all layers, with its weight — the crossfades' state (a debug overlay's list). */
93
+ get active(): ActiveClip[] { return this._c.layers.flatMap((L) => this._c.active(L)) }
69
94
 
70
95
  // ---- layers ----
71
96
  /** A new layer on top (masked override / additive). The returned object is its handle. */
@@ -83,5 +108,13 @@ export class Animator extends Aspect<"anim", Node> {
83
108
  * or its CharacterController (on this node or an ancestor) as a velocity, so it collides. For
84
109
  * clips whose hips actually travel (Mixamo without "In Place", lunges, rolls). */
85
110
  get rootMotion(): boolean { return this._rootMotion }
86
- set rootMotion(on: boolean) { this._rootMotion = on; this._c.setRootMotion(on) }
111
+ set rootMotion(on: boolean) { this._rootMotion = on; this._c.setRootMotion(on, this._rootRotation) }
112
+ /** With `rootMotion`, the root bone's TURN (its yaw about the node's up, since the clip's first
113
+ * frame) is root motion too: it comes off the pose and turns the node the travel lands on — the
114
+ * character (or the model) faces where a turn-start / turn-in-place / arc clip took it, and the
115
+ * crossfade into the next clip keeps that heading instead of swinging back. Off by default: a
116
+ * walk cycle's hip sway is a turn too, and most rigs want it in the pose; turn it on for a rig
117
+ * whose root bone carries the heading (`lecodes assets retarget --root-rotation yaw`). */
118
+ get rootRotation(): boolean { return this._rootRotation }
119
+ set rootRotation(on: boolean) { this._rootRotation = on; if (this._rootMotion) this._c.setRootMotion(true, on) }
87
120
  }
@@ -2,7 +2,7 @@
2
2
  // `{ additive: true }` for deltas stacked on top). The Animator's own play/stop/playLoop ARE its base
3
3
  // layer; extra layers composite over it in creation order. The object is the layer's only handle.
4
4
 
5
- import type { Core, LayerRec, LoopDef, LoopOptions, PlayOptions, StopOptions } from "./core"
5
+ import type { ActiveClip, Core, LayerRec, LoopDef, LoopOptions, PlayOptions, StopOptions } from "./core"
6
6
  import type { Loop } from "./Loop"
7
7
  import type { Playback } from "./Playback"
8
8
 
@@ -26,4 +26,6 @@ export class Layer {
26
26
  set weight(v: number) { this._r.weight = v; this._c.pushLayer(this._r) }
27
27
  /** A one-shot on this layer hasn't handed over yet. */
28
28
  get busy(): boolean { return this._c.busy(this._r) }
29
+ /** Every clip this layer shows this frame with its weight — the crossfades' state, for a debug overlay. */
30
+ get active(): ActiveClip[] { return this._c.active(this._r) }
29
31
  }
@@ -16,6 +16,11 @@ export class Loop {
16
16
  * even while a one-shot plays (the loop returns at the new mix). */
17
17
  get value(): BlendPosition { return this._r.value }
18
18
  set value(v: BlendPosition) { this._c.setLoopValue(this._r, v) }
19
+ /** Where the loop is in its cycle, 0–1 — one clock for every member (a blend cycles over its
20
+ * weighted period). Set it to jump the cycle: `playLoop(def, { phase })` on the way in, or here
21
+ * to re-sync with something else (a clip whose pose matches the cycle at that phase). */
22
+ get phase(): number { return this._c.loopPhase(this._r) }
23
+ set phase(v: number) { this._c.setLoopPhase(this._r, v) }
19
24
  /** Each member's current contribution, 0–1 (fades included). */
20
25
  get weights(): Record<string, number> {
21
26
  const out: Record<string, number> = {}
@@ -21,13 +21,14 @@ export class Playback implements PromiseLike<boolean> {
21
21
  /** The layer it plays on. */
22
22
  get layer(): Layer { return this._r.slot.layer.view }
23
23
  /** Clock (s). */
24
- get time(): number { return this._c.slotTime(this._r.slot) }
24
+ get time(): number { return this._r.slot.last === this._r ? this._c.slotTime(this._r.slot) : 0 }
25
25
  /** 0–1 through the clip (the current cycle, when looping). */
26
26
  get progress(): number { const d = this.clip.duration; return d > 0 ? this.time / d : 0 }
27
27
  /** Not handed over yet (the clip still owns the layer). */
28
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 }
29
+ /** Current contribution to the pose, 0–1 — moves during crossfades, still falling after the
30
+ * hand-over while the clip fades beneath what took over. */
31
+ get weight(): number { return this._r.slot.last === this._r ? this._c.slotWeight(this._r.slot) : 0 }
31
32
  /** Resolves at the hand-over (`true`) or when cut short (`false`) — the promise `await playback`
32
33
  * uses; the one to return from async functions or put in `Promise.all`. */
33
34
  get done(): Promise<boolean> { return this._r.done }
@@ -36,10 +36,25 @@ export type LoopOptions = {
36
36
  fade?: number
37
37
  /** Playback rate of the loop's clips (default 1). */
38
38
  speed?: number
39
+ /** Where in the cycle to start, 0–1 (default 0): a blend's members share one clock, so this is the
40
+ * phase of every member — e.g. the phase whose pose matches the clip the loop takes over from. */
41
+ phase?: number
39
42
  }
40
43
  /** What a layer loops: one clip (name / index), or a blend space `{ name: position }`. */
41
44
  export type LoopDef = string | number | Record<string, BlendPosition>
42
45
 
46
+ /** One clip the layer shows this frame — `layer.active` / `anim.active` (a debug overlay's list). */
47
+ export type ActiveClip = {
48
+ name: string
49
+ clip: AnimationClip
50
+ /** contribution to the pose, 0–1 — the crossfades' state */
51
+ weight: number
52
+ /** clock (s) */
53
+ time: number
54
+ /** a member of the layer's loop (else a one-shot — playing, or fading after its hand-over) */
55
+ loop: boolean
56
+ }
57
+
43
58
  export type LayerOptions = {
44
59
  /** Bone name(s): the layer drives only these subtrees (`'Spine1'` = upper body). Default: the whole rig. */
45
60
  mask?: string | string[]
@@ -54,9 +69,13 @@ export type ClipEventHandler = (clip: string, layer: Layer) => void
54
69
 
55
70
  // ---- records -------------------------------------------------------------------------------------
56
71
 
57
- export type SlotRec = { name: string, clip: AnimationClip, slot: number, layer: LayerRec, inBlend: boolean, playback?: PlaybackRec }
72
+ /** One INSTANCE of a clip on a layer (an engine slot). A clip has as many as it needs at once: a
73
+ * clip started again while its last instance still fades gets another slot — the old one keeps
74
+ * fading beneath (a rewind in place would snap the pose). `playback` = the one-shot that owns it
75
+ * until its hand-over; `last` = the one that took it most recently (its weight / time after that). */
76
+ export type SlotRec = { name: string, clip: AnimationClip, slot: number, layer: LayerRec, inBlend: boolean, playback?: PlaybackRec, last?: PlaybackRec }
58
77
  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 }
78
+ export type LayerRec = { index: number, view: Layer, slots: SlotRec[], loop?: LoopRec, weight: number, mask: string, additive: boolean }
60
79
  export type LoopRec = { layer: LayerRec, members: { name: string, slot: SlotRec }[], value: BlendPosition, view: Loop, dead: boolean }
61
80
 
62
81
  const handlers = new Map<number, (slot: number, type: number) => void>()
@@ -145,12 +164,12 @@ export class Core {
145
164
  destroy(): void {
146
165
  for (const L of this.layers) for (const s of L.slots) if (s.playback) this.settle(s.playback, false)
147
166
  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 } }
167
+ for (const L of this.layers) { L.slots = []; if (L.loop) { L.loop.dead = true; L.loop = undefined } }
149
168
  }
150
169
 
151
170
  // ---- layers ----
152
171
  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 }
172
+ const rec: LayerRec = { index: this.layers.length, view: undefined as unknown as Layer, slots: [], weight: options?.weight ?? 1, mask: "", additive: options?.additive ?? false }
154
173
  if (options?.mask !== undefined) rec.mask = Array.isArray(options.mask) ? options.mask.join("\n") : options.mask
155
174
  rec.view = new Layer(rec, this)
156
175
  return rec
@@ -169,10 +188,11 @@ export class Core {
169
188
  }
170
189
 
171
190
  // ---- slots ----
172
- /** Bind a clip on a layer (once per name). */
191
+ /** A slot for the clip on the layer: one of its own that is silent now (never shown, or faded
192
+ * out), else a new binding — so a clip still fading can start again OVER itself. */
173
193
  slotFor(L: LayerRec, name: string, clip: AnimationClip): SlotRec | undefined {
174
- const have = L.byName.get(name)
175
- if (have && have.clip === clip) return have
194
+ const have = L.slots.find((s) => s.name === name && s.clip === clip && !s.inBlend && !s.playback && this.slotWeight(s) <= 0)
195
+ if (have) return have
176
196
  const slot = _creator.animatorBind(this.id, clip._set, clip._index, L.index)
177
197
  if (slot < 0) { console.warn(`Animator: could not bind clip '${name}'`); return undefined }
178
198
  const bound = _creator.animatorBoundTracks(this.id, slot)
@@ -180,7 +200,6 @@ export class Core {
180
200
  else if (bound < clip.trackCount) console.warn(`Animator: '${name}' — ${clip.trackCount - bound}/${clip.trackCount} tracks unbound (bone names not found)`)
181
201
  const rec: SlotRec = { name, clip, slot, layer: L, inBlend: false }
182
202
  L.slots.push(rec)
183
- L.byName.set(name, rec)
184
203
  return rec
185
204
  }
186
205
 
@@ -203,7 +222,7 @@ export class Core {
203
222
  let resolve = (_n: boolean): void => {}
204
223
  const done = new Promise<boolean>((r) => { resolve = r })
205
224
  const rec: PlaybackRec = { slot: s, playing: true, done, resolve }
206
- s.playback = rec
225
+ s.playback = rec; s.last = rec
207
226
  _creator.animatorPlay(this.id, s.slot, false, o.speed ?? 1, fadeIn, fadeOut, o.restart ?? false)
208
227
  return new Playback(rec, this)
209
228
  }
@@ -255,8 +274,21 @@ export class Core {
255
274
  }
256
275
  L.loop = rec
257
276
  _creator.animatorSetBlend(this.id, L.index, is2D ? 2 : 1, new Uint16Array(slots), new Float32Array(positions), fade, o.speed ?? 1)
277
+ if (o.phase !== undefined) this.setLoopPhase(rec, o.phase)
258
278
  return rec.view
259
279
  }
280
+ /** The loop's phase through its cycle, 0–1 (the members share one clock). */
281
+ loopPhase(b: LoopRec): number {
282
+ const m = b.members[0]
283
+ if (!m || b.dead) return 0
284
+ const d = m.slot.clip.duration
285
+ return d > 0 ? this.slotTime(m.slot) / d : 0
286
+ }
287
+ setLoopPhase(b: LoopRec, phase: number): void {
288
+ const m = b.members[0]
289
+ if (!m || b.dead) return
290
+ this.seek(m.slot, (((phase % 1) + 1) % 1) * m.slot.clip.duration) // seeking one member moves the group
291
+ }
260
292
  setLoopValue(b: LoopRec, v: BlendPosition): void {
261
293
  if (b.dead) { console.warn("Animator: this loop was replaced — use the object the latest playLoop() returned"); return }
262
294
  b.value = v
@@ -264,13 +296,23 @@ export class Core {
264
296
  const [x, y] = typeof v === "number" ? [ v, 0 ] : [ v[0], v[1] ]
265
297
  _creator.animatorSetBlendValue(this.id, b.layer.index, x, y)
266
298
  }
299
+ /** What the layer shows this frame: every instance with a weight, in binding order (stable rows). */
300
+ active(L: LayerRec): ActiveClip[] {
301
+ const out: ActiveClip[] = []
302
+ for (const s of L.slots) {
303
+ const weight = this.slotWeight(s)
304
+ if (weight <= 0) continue
305
+ out.push({ name: s.name, clip: s.clip, weight, time: this.slotTime(s), loop: s.inBlend })
306
+ }
307
+ return out
308
+ }
267
309
  slotWeight(s: SlotRec): number { return this.id && s.slot >= 0 ? _creator.animatorGetSlotWeight(this.id, s.slot) : 0 }
268
310
  slotTime(s: SlotRec): number { return this.id && s.slot >= 0 ? _creator.animatorGetSlotTime(this.id, s.slot) : 0 }
269
311
  seek(s: SlotRec, time: number): void { if (this.id && s.slot >= 0) _creator.animatorSeek(this.id, s.slot, time) }
270
312
 
271
313
  // ---- props ----
272
314
  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) }
315
+ setRootMotion(on: boolean, rotation = false): void { if (this.ensure()) _creator.animatorSetRootMotion(this.id, on ? "*" : "", (on ? 2 : 0) | (on && rotation ? 4 : 0)) }
274
316
 
275
317
  // ---- events ----
276
318
  on(event: string, cb: ClipEventHandler): void { (this._listeners.get(event) ?? this._listeners.set(event, new Set()).get(event)!).add(cb) }