lecodes-sdk 1.0.0 → 1.1.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 (78) hide show
  1. package/dist/global.d.ts +18 -4
  2. package/dist/types/animate/tween/Animation.d.ts +69 -0
  3. package/dist/types/animate/tween/Timeline.d.ts +55 -0
  4. package/dist/types/animate/tween/animateValue.d.ts +27 -0
  5. package/dist/types/animate/tween/easing.d.ts +29 -0
  6. package/dist/types/animate/tween/spec.d.ts +178 -0
  7. package/dist/types/g2/Node2D.d.ts +16 -0
  8. package/dist/types/g2/Sprite.d.ts +11 -1
  9. package/dist/types/gl/Camera.d.ts +15 -1
  10. package/dist/types/gl/Foliage.d.ts +47 -0
  11. package/dist/types/gl/Geometry.d.ts +24 -0
  12. package/dist/types/gl/Light.d.ts +25 -7
  13. package/dist/types/gl/Lightmap.d.ts +90 -60
  14. package/dist/types/gl/Material.d.ts +28 -20
  15. package/dist/types/gl/Model.d.ts +7 -5
  16. package/dist/types/gl/Node.d.ts +18 -0
  17. package/dist/types/gl/Particles.d.ts +40 -1
  18. package/dist/types/gl/Scene.d.ts +20 -0
  19. package/dist/types/gl/animation/AnimationClip.d.ts +19 -0
  20. package/dist/types/gl/animation/Animator.d.ts +27 -0
  21. package/dist/types/gl/animation/DynamicBone.d.ts +19 -8
  22. package/dist/types/gl/animation/IK.d.ts +86 -30
  23. package/dist/types/gl/animation/Warp.d.ts +2 -1
  24. package/dist/types/gl/animation/core.d.ts +35 -4
  25. package/dist/types/gl/physics/Ragdoll.d.ts +87 -12
  26. package/dist/types/gl/terrain/Terrain.d.ts +4 -2
  27. package/dist/types/inject.d.ts +8 -2
  28. package/dist/types/scene/defineScene.d.ts +44 -32
  29. package/dist/types/ui/UIButton.d.ts +3 -1
  30. package/dist/types/ui/UIInput.d.ts +5 -1
  31. package/dist/types/ui/UINode.d.ts +24 -24
  32. package/dist/types.json +1 -1
  33. package/package.json +1 -1
  34. package/prompts/core-design.md +27 -4
  35. package/prompts/core.md +35 -6
  36. package/prompts/select.ts +19 -4
  37. package/src/animate/tween/Animation.ts +378 -0
  38. package/src/animate/tween/Timeline.ts +175 -0
  39. package/src/animate/tween/animateValue.ts +100 -0
  40. package/src/animate/tween/easing.ts +172 -0
  41. package/src/animate/tween/spec.ts +479 -0
  42. package/src/bridges.d.ts +226 -65
  43. package/src/compile/__tests__/assetMacro.test.ts +26 -0
  44. package/src/compile/__tests__/detectEntry.test.ts +19 -0
  45. package/src/compile/__tests__/serverSplit.test.ts +27 -0
  46. package/src/compile/bundler.ts +34 -4
  47. package/src/compile/compileProject.ts +31 -1
  48. package/src/compile/detectEntry.ts +8 -3
  49. package/src/compile/index.ts +2 -0
  50. package/src/compile/serverSplit.ts +9 -3
  51. package/src/g2/Node2D.ts +38 -0
  52. package/src/g2/Sprite.ts +20 -1
  53. package/src/gl/Camera.ts +34 -1
  54. package/src/gl/Foliage.ts +102 -0
  55. package/src/gl/Geometry.ts +393 -348
  56. package/src/gl/Light.ts +46 -16
  57. package/src/gl/Lightmap.ts +439 -275
  58. package/src/gl/Material.ts +59 -47
  59. package/src/gl/Model.ts +167 -156
  60. package/src/gl/Node.ts +39 -0
  61. package/src/gl/Particles.ts +61 -2
  62. package/src/gl/Scene.ts +34 -1
  63. package/src/gl/animation/AnimationClip.ts +52 -0
  64. package/src/gl/animation/Animator.ts +42 -2
  65. package/src/gl/animation/DynamicBone.ts +482 -459
  66. package/src/gl/animation/IK.ts +173 -152
  67. package/src/gl/animation/Playback.ts +5 -4
  68. package/src/gl/animation/Warp.ts +5 -2
  69. package/src/gl/animation/core.ts +65 -4
  70. package/src/gl/physics/Ragdoll.ts +451 -272
  71. package/src/gl/terrain/Terrain.ts +4 -2
  72. package/src/inject.ts +12 -2
  73. package/src/scene/defineScene.ts +72 -62
  74. package/src/ui/UIButton.ts +2 -2
  75. package/src/ui/UIInput.ts +3 -3
  76. package/src/ui/UINode.ts +61 -36
  77. package/dist/types/animate/animate.d.ts +0 -20
  78. package/src/animate/animate.ts +0 -238
@@ -40,6 +40,10 @@ const TAG_ANGULAR_VELOCITY = 17
40
40
  const TAG_GROUND = 18
41
41
  const TAG_VELOCITY_LIFE = 19
42
42
  const TAG_ORDER = 20
43
+ const TAG_SMOOTH_PATH = 21
44
+ const TAG_RIBBON_ORIENT = 22
45
+ /** `TrailOptions.orient` → the native axis code (0 = the camera-facing default). */
46
+ const RIBBON_AXIS: Record<"camera" | "x" | "y" | "z", number> = { camera: 0, x: 1, y: 2, z: 3 }
43
47
 
44
48
  /** @internal — exported for the tag-sync test only. */
45
49
  export const _particleTags = {
@@ -49,7 +53,7 @@ export const _particleTags = {
49
53
  SPACE: TAG_SPACE, INHERIT_VELOCITY: TAG_INHERIT_VELOCITY, RATE_DISTANCE: TAG_RATE_DISTANCE,
50
54
  RENDER_MODE: TAG_RENDER_MODE, MESH_GEOMETRY: TAG_MESH_GEOMETRY,
51
55
  ANGULAR_VELOCITY: TAG_ANGULAR_VELOCITY, GROUND: TAG_GROUND, VELOCITY_LIFE: TAG_VELOCITY_LIFE,
52
- ORDER: TAG_ORDER,
56
+ ORDER: TAG_ORDER, SMOOTH_PATH: TAG_SMOOTH_PATH, RIBBON_ORIENT: TAG_RIBBON_ORIENT,
53
57
  }
54
58
 
55
59
  // --- curve builders -----------------------------------------------------------------------------
@@ -286,6 +290,15 @@ export type ParticlesOptions = ParticlesMaterialOptions & {
286
290
  * spread evenly along the path — trail density independent of speed, no per-frame clumps.
287
291
  * Meant for `space: 'world'`. */
288
292
  rateOverDistance?: number
293
+ /** Spread those spawn points along a CURVE through the emitter's recent path instead of the
294
+ * straight line between where it was last frame and where it is now. A straight line cuts the
295
+ * corner by however far the path bows inside one frame, which grows with the frame TIME — so a
296
+ * fast curving emitter looks faceted, and looks worse the slower the machine. Default false
297
+ * (the straight line); costs three stored positions and one curve evaluation per spawn.
298
+ *
299
+ * The curve needs a point one frame AHEAD, so each frame's spawns are placed provisionally and
300
+ * nudged into place on the next frame, once it exists. Nothing else observes them in between. */
301
+ smooth?: boolean
289
302
  /** Draw order among blended systems at the same depth — higher draws later, i.e. on top (Unity's
290
303
  * sortingOrder). Systems are depth-sorted by their node, so the emitters of one effect tie and
291
304
  * without this the engine picks who covers whom per frame (smoke popping over a fireball).
@@ -477,6 +490,7 @@ export class Particles extends Node {
477
490
  if (options.space !== undefined) out.push(TAG_SPACE, 1, options.space === "world" ? 1 : 0)
478
491
  if (options.inheritVelocity !== undefined) out.push(TAG_INHERIT_VELOCITY, 1, options.inheritVelocity)
479
492
  if (options.rateOverDistance !== undefined) out.push(TAG_RATE_DISTANCE, 1, options.rateOverDistance)
493
+ if (options.smooth !== undefined) out.push(TAG_SMOOTH_PATH, 1, options.smooth ? 1 : 0)
480
494
  if (options.order !== undefined) out.push(TAG_ORDER, 1, options.order)
481
495
  if (options.rate !== undefined) out.push(TAG_RATE, 1, options.rate)
482
496
  if (options.shape !== undefined) pushShape(out, options.shape)
@@ -530,6 +544,8 @@ export class Particles extends Node {
530
544
  set space(val: "local" | "world") { this._send([ TAG_SPACE, 1, val === "world" ? 1 : 0 ]) }
531
545
  set inheritVelocity(val: number) { this._send([ TAG_INHERIT_VELOCITY, 1, val ]) }
532
546
  set rateOverDistance(val: number) { this._send([ TAG_RATE_DISTANCE, 1, val ]) }
547
+ /** Lay a frame's spawns along a curve through the emitter's path, not the straight chord. */
548
+ set smooth(val: boolean) { this._send([ TAG_SMOOTH_PATH, 1, val ? 1 : 0 ]) }
533
549
  set order(val: number) { this._send([ TAG_ORDER, 1, val ]) }
534
550
  /** Coarse draw order, 0 … 7 — see `Mesh.renderPriority`. Write-only. */
535
551
  set renderPriority(v: number) {
@@ -620,8 +636,28 @@ export type TrailOptions = Omit<ParticlesMaterialOptions, "render" | "stretch">
620
636
  width?: ParticleValue
621
637
  /** Minimum emitter movement (world units) between recorded points. Default 0.05. */
622
638
  minDistance?: number
623
- /** Point capacity (default 128). */
639
+ /** Point capacity (default 128). A spawn that does not fit is DROPPED, so a fast emitter at a
640
+ * small `minDistance` wants headroom: a sword tip covers metres inside one `time` window. */
624
641
  maxPoints?: number
642
+ /** Follow a CURVE through the emitter's recent path instead of the straight line between frames.
643
+ * A trail is where this shows most — the strip is the path — and it shows worse the lower the
644
+ * frame rate, since a longer frame bows further off its own chord. Default false; see
645
+ * `ParticlesOptions.smooth`. */
646
+ smooth?: boolean
647
+ /** Which way the strip's WIDTH points.
648
+ *
649
+ * `'camera'` (default) rolls the strip about its own length to stay flat to the viewer — it can
650
+ * never disappear, which is why it is the default, but it bears no relation to the thing that drew
651
+ * it. An AXIS (`'x'`/`'y'`/`'z'`) uses that axis of the emitter's own transform, as it was when
652
+ * each point was laid: on a node riding a sword's blade, `'z'` (the blade) makes the strip the
653
+ * surface the blade actually swept, and `width` stops being a made-up number — it is the blade. */
654
+ orient?: "camera" | "x" | "y" | "z"
655
+ /** With `orient` on an axis: the floor under the strip's APPARENT width, 0..1 of its real width,
656
+ * below which it rolls back toward the camera. An honestly oriented strip goes edge-on — and
657
+ * vanishes — whenever the swing happens in the plane of view; this is the hybrid that keeps it
658
+ * readable. 0 = never roll (honest, and it will vanish), 1 = always (the same as `'camera'`).
659
+ * Default 0.4: a sweep still reads as a sweep, and a swing toward the camera still shows. */
660
+ faceCamera?: number
625
661
  color?: ParticleColor
626
662
  /** Opacity along the trail. Defaults to fading the tail out (`curve().to(0)`). */
627
663
  opacity?: ParticleValue
@@ -635,6 +671,8 @@ export type TrailOptions = Omit<ParticlesMaterialOptions, "render" | "stretch">
635
671
  export class Trail extends Node {
636
672
  private _material: Material
637
673
  private _rateDistance: number
674
+ private _orient = 0
675
+ private _faceCamera = 0.4
638
676
 
639
677
  constructor(options: TrailOptions = {}) {
640
678
  super()
@@ -650,6 +688,12 @@ export class Trail extends Node {
650
688
  TAG_RATE_DISTANCE, 1, this._rateDistance,
651
689
  TAG_LIFETIME, 2, time, time,
652
690
  ]
691
+ if (options.smooth) out.push(TAG_SMOOTH_PATH, 1, 1)
692
+ if (options.orient !== undefined || options.faceCamera !== undefined) {
693
+ this._orient = RIBBON_AXIS[options.orient ?? "camera"]
694
+ if (options.faceCamera !== undefined) this._faceCamera = options.faceCamera
695
+ out.push(TAG_RIBBON_ORIENT, 2, this._orient, this._faceCamera)
696
+ }
653
697
  pushParam(out, 0, options.width ?? 0.1)
654
698
  pushParam(out, 2, options.opacity ?? curve().to(0))
655
699
  if (options.color !== undefined) pushColor(out, options.color)
@@ -686,6 +730,21 @@ export class Trail extends Node {
686
730
  this._send([ TAG_RATE_DISTANCE, 1, this._rateDistance ])
687
731
  }
688
732
 
733
+ /** Follow a curve through the emitter's path instead of the straight chord between frames. */
734
+ set smooth(val: boolean) { this._send([ TAG_SMOOTH_PATH, 1, val ? 1 : 0 ]) }
735
+
736
+ /** Which way the strip's width points — see `TrailOptions.orient`. Keeps the current `faceCamera`. */
737
+ set orient(val: "camera" | "x" | "y" | "z") {
738
+ this._orient = RIBBON_AXIS[val]
739
+ this._send([ TAG_RIBBON_ORIENT, 2, this._orient, this._faceCamera ])
740
+ }
741
+
742
+ /** The hybrid's floor — see `TrailOptions.faceCamera`. */
743
+ set faceCamera(val: number) {
744
+ this._faceCamera = val
745
+ this._send([ TAG_RIBBON_ORIENT, 2, this._orient, val ])
746
+ }
747
+
689
748
  /** Pause/resume laying down points — existing ones still age out, so the trail fades naturally
690
749
  * after e.g. a sword swing ends. */
691
750
  set emitting(val: boolean) { this._send([ TAG_RATE_DISTANCE, 1, val ? this._rateDistance : 0 ]) }
package/src/gl/Scene.ts CHANGED
@@ -177,7 +177,8 @@ const installRenderSyncedFrames = (): void => {
177
177
  export class Scene implements Presentable {
178
178
  /** @internal native scene handle. */
179
179
  readonly _id: number
180
- /** @internal the IBL intensity this scene was built with (Lightmap.load's ambient floor). */
180
+ /** @internal what `environmentIntensity` currently asks for — the accessor's backing field, so it
181
+ * answers on hosts whose engine has no live setter too. */
181
182
  _environmentIntensity = 20000
182
183
  readonly camera: Camera
183
184
  /** The listener + global 3D audio knobs (docs/audio-plan.md). */
@@ -309,6 +310,26 @@ export class Scene implements Presentable {
309
310
  setBloom(enabled: boolean, intensity = 0.2): void {
310
311
  _creator.setBloomOptions(this._id, enabled, intensity, 1)
311
312
  }
313
+ /**
314
+ * How bright the environment (IBL) lights the scene, in lux — `SceneOptions.environmentIntensity`
315
+ * after the fact. Live: it changes the probe's intensity, not the probe, so it costs nothing and
316
+ * can be dragged. (`setDefaultIbl`, the call that installs a probe, rebuilds the cubemap from the
317
+ * ktx every time — never drive a slider through a scene option that has to re-open.)
318
+ *
319
+ * `lecodes lightmap bake` reads the same number off the live probe, so a scene dimmed here bakes
320
+ * dimmed. Hosts without the call keep whatever the scene opened with, and the getter still
321
+ * reports what was asked for.
322
+ */
323
+ get environmentIntensity(): number { return this._environmentIntensity }
324
+ set environmentIntensity(lux: number) {
325
+ this._environmentIntensity = lux
326
+ if (_creator.setEnvironmentIntensity) _creator.setEnvironmentIntensity(this._id, lux)
327
+ else if (!Scene._warnedEnvIntensity) {
328
+ Scene._warnedEnvIntensity = true
329
+ console.warn("[scene] this host has no setEnvironmentIntensity — environmentIntensity stays as the scene opened")
330
+ }
331
+ }
332
+ private static _warnedEnvIntensity = false
312
333
  /** The scene's stencil buffer on / off (see `SceneOptions.stencil`). */
313
334
  setStencil(enabled: boolean): void {
314
335
  if (_creator.setSceneStencil) _creator.setSceneStencil(this._id, enabled)
@@ -367,6 +388,18 @@ export class Scene implements Presentable {
367
388
  return new Promise<void>((res) => _creator.warmRender(this._id, res))
368
389
  }
369
390
 
391
+ /** Precompile the scene's shader variants so nothing is compiled mid-game. Every material the host
392
+ * knows is queued for the sun / shadow / fog / skinning variants and the dynamic-light key — the
393
+ * first point light (a muzzle flash, an explosion) would otherwise rebuild every lit shader in view
394
+ * on that frame. Call it once the sun and fog are set, typically under a loading screen; materials
395
+ * loaded later are queued in the background as they are created. Resolves at once on a host
396
+ * without the bridge. */
397
+ precompileShaders(): Promise<void> {
398
+ const fn = _creator.precompileShaders
399
+ if (!fn) return Promise.resolve()
400
+ return new Promise<void>((res) => fn.call(_creator, this._id, res))
401
+ }
402
+
370
403
  // ---- Presentable (docs/navigation-presentable-plan.md) ----
371
404
  // A scene is a destination like a screen: opening it replaces whatever is visible (suspending
372
405
  // an active Router until Router.restore()), and it can be pushed onto the Router stack. The
@@ -82,6 +82,8 @@ export class AnimationClip {
82
82
  readonly _index: number
83
83
  /** @internal events: seconds + name, sorted (the engine gets them normalized via setClipEvents). */
84
84
  _events: { t: number, name: string }[] = []
85
+ /** @internal anchor spans per bone, seconds (the engine gets them normalized via setClipAnchors). */
86
+ _anchors: { bone: string, donor: string, spans: { from: number, to: number, socket: string, rotation: number, pin: number }[] }[] = []
85
87
 
86
88
  private constructor(set: number, index: number, info: ClipInfo) {
87
89
  this._set = set
@@ -119,6 +121,48 @@ export class AnimationClip {
119
121
  _creator.setClipEvents(this._set, this._index, new Float32Array(this._events.map((e) => (d > 0 ? e.t / d : 0))))
120
122
  }
121
123
 
124
+ /** ANCHOR SPANS for a bone — what an anchored IK chain ending in `bone` rides while this clip plays
125
+ * (`IK.TwoBone` with `anchor`; the sockets come from `anim.sockets(...)`). `donor` names the socket
126
+ * set of the gun the take was AUTHORED on ('' = the live set: no delta); `spans` = `[from, to,
127
+ * socket, rotation?, pin?]` in SECONDS — the socket the hand is on through that window (`''` = free:
128
+ * it rides the joint itself), `rotation` 0..1 how much of the socket's turn it takes (default 1),
129
+ * `pin` 0..1 how much it sits ON the socket instead of keeping its own relation to it (default 0: a
130
+ * magazine is REACHED, and the take's motion is what carries the hand there).
131
+ * Gaps between spans = the chain's own socket, pinned by the chain's `anchorPin` — that is where a
132
+ * hand holding the gun is nailed to the grip, so the layers below cannot slide it. A clip WITHOUT
133
+ * anchors takes no part: the hand keeps the take's own relation to the gun. Part of the clip, like
134
+ * events; `AnimationClip.from` carries them re-timed. Chainable.
135
+ * reload.anchors('hand_l', { donor: 'tr15', spans: [[0.9, 1.4, 'mag'], [1.4, 2.0, ''], [2.0, 2.4, 'mag'], [2.4, 2.7, 'bolt']] }) */
136
+ anchors(bone: string, def: { donor?: string, spans?: readonly (readonly [number, number, string, number?, number?])[] }): this {
137
+ const spans = (def.spans ?? []).map(([ from, to, socket, rotation, pin ]) => ({
138
+ from: Math.min(Math.max(0, from), this.duration), to: Math.min(Math.max(0, to), this.duration), socket,
139
+ rotation: rotation ?? 1, pin: pin ?? 0,
140
+ })).filter((s) => s.to > s.from).sort((a, b) => a.from - b.from)
141
+ const rec = { bone, donor: def.donor ?? "", spans }
142
+ const i = this._anchors.findIndex((a) => a.bone === bone)
143
+ if (i >= 0) this._anchors[i] = rec
144
+ else this._anchors.push(rec)
145
+ this._pushAnchors(rec)
146
+ return this
147
+ }
148
+ /** Drop a bone's anchor spans. */
149
+ clearAnchors(bone: string): this {
150
+ this._anchors = this._anchors.filter((a) => a.bone !== bone)
151
+ _creator.setClipAnchors?.(this._set, this._index, bone, "", "", new Float32Array(0))
152
+ return this
153
+ }
154
+ private _pushAnchors(a: { bone: string, donor: string, spans: { from: number, to: number, socket: string, rotation: number, pin: number }[] }): void {
155
+ const d = this.duration
156
+ const spans = new Float32Array(a.spans.length * 4)
157
+ a.spans.forEach((s, i) => {
158
+ spans[i * 4] = d > 0 ? s.from / d : 0
159
+ spans[i * 4 + 1] = d > 0 ? s.to / d : 0
160
+ spans[i * 4 + 2] = s.rotation
161
+ spans[i * 4 + 3] = s.pin
162
+ })
163
+ _creator.setClipAnchors?.(this._set, this._index, a.bone, a.donor, a.spans.map((s) => s.socket).join("\n"), spans)
164
+ }
165
+
122
166
  /** Load ONE clip from a GLB: the file's only/first clip, or the one named / at the given index. */
123
167
  static load(source: string | FetchResponse, clip?: string | number): Promise<AnimationClip> {
124
168
  return AnimationClip._loadSet(source).then(([setId, infos]) => {
@@ -200,6 +244,14 @@ export class AnimationClip {
200
244
  out._events = windowed
201
245
  ? clip._events.filter((ev) => ev.t >= st - 1e-6 && ev.t <= e + 1e-6).map((ev) => ({ t: Math.min(Math.max(0, ev.t - st), e - st), name: ev.name }))
202
246
  : clip._events.map((ev) => ({ ...ev }))
247
+ // the anchor spans too: clipped to the window and re-timed (a mirrored clip keeps its bone names —
248
+ // mirror the sockets yourself if a hand's anchors must swap sides)
249
+ for (const a of clip._anchors) {
250
+ const spans = windowed
251
+ ? a.spans.map((s) => ({ ...s, from: Math.max(0, s.from - st), to: Math.min(e - st, s.to - st) })).filter((s) => s.to > s.from)
252
+ : a.spans.map((s) => ({ ...s }))
253
+ if (spans.length > 0 || a.donor) out.anchors(a.bone, { donor: a.donor, spans: spans.map((s) => [ s.from, s.to, s.socket, s.rotation ] as const) })
254
+ }
203
255
  return out
204
256
  }
205
257
 
@@ -17,9 +17,12 @@
17
17
  // default. A clip may be replaced at any time; a one-shot on a layer with no loop holds its last frame.
18
18
  //
19
19
  // The surface, top to bottom: clips → playing → root motion → the feet and the warp (`anim.feet`,
20
- // `anim.warp` — locomotion's post-processing of the pose) → level of detail → engine-facing.
20
+ // `anim.warp` — locomotion's post-processing of the pose) → sockets (what IK chains ride) → level of
21
+ // detail → engine-facing.
21
22
 
22
23
  import { Aspect } from "../../core/Aspect"
24
+ import { Vec3, type Vec3Like } from "../../math/vec"
25
+ import { Quat, type QuatLike } from "../../math/quat"
23
26
  import type { Node } from "../Node"
24
27
  import type { AnimationClip } from "./AnimationClip"
25
28
  import { Core, type ActiveClip, type ClipInfo, type ClipEventHandler, type LayerOptions, type LoopDef, type LoopOptions, type PlayOptions, type StopOptions } from "./core"
@@ -122,6 +125,41 @@ export class Animator extends Aspect<"anim", Node> {
122
125
  * turn too); on for a rig whose root carries the heading (`lecodes assets retarget --root-rotation yaw`). */
123
126
  get rootRotation(): boolean { return this._rootRotation }
124
127
  set rootRotation(on: boolean) { this._rootRotation = on; if (this._rootMotion) this._c.setRootMotion(true, on) }
128
+ /** The body's WORLD velocity this frame (m/s): what `anim.warp`'s stride and orientation fit the clips
129
+ * to. A `Locomotion` feeds it itself; when you drive the gait by hand set it every frame (the
130
+ * CharacterController's velocity, or your own). Reads back the last value fed. */
131
+ get motion(): readonly [number, number, number] { return this._motion }
132
+ set motion(v: { x: number, y: number, z: number } | readonly [number, number, number]) {
133
+ const [ x, y, z ] = Array.isArray(v) ? v as readonly [number, number, number] : [ (v as { x: number }).x, (v as { y: number }).y, (v as { z: number }).z ]
134
+ this._motion = [ x, y, z ]
135
+ this._c.setMotion(x, y, z)
136
+ }
137
+ private _motion: readonly [number, number, number] = [ 0, 0, 0 ]
138
+
139
+ // ---- sockets (what anchored IK chains ride — IK.ts) ---------------------------------------------
140
+ /** SOCKETS: named frames on a bone of this rig — a weapon's grip / magazine / bolt empties, placed in
141
+ * the scene editor. A NODE socket is read by the engine every frame (a socket on a moving part
142
+ * follows it); a `{ position, quaternion }` one is fixed in the bone's space. The default set is the
143
+ * LIVE one (the weapon in the hands); `set: 'tr15'` names a DONOR set — the same sockets on the gun a
144
+ * take was authored on, which a clip's `anchors()` refer to. Chainable.
145
+ * arms.anim.sockets('ik_hand_gun', { grip_r: w.gripR, grip_l: w.gripL, mag: w.mag })
146
+ * arms.anim.sockets('ik_hand_gun', { grip_l: { position: [0.03, -0.12, -0.05] } }, { set: 'tr15' }) */
147
+ sockets(joint: Node | string, table: Record<string, Node | { position?: Vec3Like, quaternion?: QuatLike }>, options: { set?: string } = {}): this {
148
+ const bone = typeof joint === "string" ? this.node.bone(joint) : joint
149
+ if (!bone) { console.warn(`Animator.sockets: no bone '${String(joint)}' under ${this.node.name || "the node"}`); return this }
150
+ const set = options.set ?? ""
151
+ for (const [ name, at ] of Object.entries(table)) {
152
+ if (typeof (at as Node).id === "number") this._c.setSocket(set, name, bone.id, (at as Node).id, null)
153
+ else {
154
+ const o = at as { position?: Vec3Like, quaternion?: QuatLike }
155
+ const p = new Vec3(o.position ?? [ 0, 0, 0 ]), q = o.quaternion ? new Quat(o.quaternion) : Quat.identity
156
+ this._c.setSocket(set, name, bone.id, 0, new Float32Array([ p.x, p.y, p.z, q.x, q.y, q.z, q.w ]))
157
+ }
158
+ }
159
+ return this
160
+ }
161
+ /** Drop a socket (the live set, or `set`). */
162
+ removeSocket(name: string, set = ""): this { this._c.removeSocket(set, name); return this }
125
163
 
126
164
  // ---- level of detail --------------------------------------------------------------------------
127
165
  /** `'auto'` (default): a character small on screen or out of view is evaluated every 2nd / 4th frame
@@ -130,9 +168,11 @@ export class Animator extends Aspect<"anim", Node> {
130
168
  get lod(): "auto" | "full" { return this._lod }
131
169
  set lod(v: "auto" | "full") { this._lod = v; _pushLod(this.node) }
132
170
 
133
- // ---- engine-facing (a Locomotion, a bench) --------------------------------------------------
171
+ // ---- engine-facing (a Locomotion, a bench, the IK aspects) ----------------------------------
134
172
  /** The native animator's id — 0 until something is played or bound. */
135
173
  get _id(): number { return this._c.id }
174
+ /** @internal the engine-facing core (IK.ts) */
175
+ get _core(): Core { return this._c }
136
176
  /** Bind a clip to the base layer without playing it; its native slot (-1 = no such clip). */
137
177
  _slot(clip: string | number): number { return this._c.slotIndex(clip) }
138
178
  }