lecodes-sdk 1.0.0 → 1.2.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 (94) hide show
  1. package/dist/global.d.ts +18 -4
  2. package/dist/inject.js +260 -361
  3. package/dist/types/animate/tween/Animation.d.ts +69 -0
  4. package/dist/types/animate/tween/Timeline.d.ts +55 -0
  5. package/dist/types/animate/tween/animateValue.d.ts +27 -0
  6. package/dist/types/animate/tween/easing.d.ts +29 -0
  7. package/dist/types/animate/tween/spec.d.ts +178 -0
  8. package/dist/types/g2/Node2D.d.ts +16 -0
  9. package/dist/types/g2/Sprite.d.ts +11 -1
  10. package/dist/types/gl/Camera.d.ts +15 -1
  11. package/dist/types/gl/Foliage.d.ts +47 -0
  12. package/dist/types/gl/Geometry.d.ts +24 -0
  13. package/dist/types/gl/Light.d.ts +28 -7
  14. package/dist/types/gl/Lightmap.d.ts +90 -60
  15. package/dist/types/gl/Material.d.ts +28 -20
  16. package/dist/types/gl/Model.d.ts +7 -5
  17. package/dist/types/gl/Node.d.ts +18 -0
  18. package/dist/types/gl/Particles.d.ts +55 -1
  19. package/dist/types/gl/Scene.d.ts +20 -0
  20. package/dist/types/gl/animation/AnimationClip.d.ts +19 -0
  21. package/dist/types/gl/animation/Animator.d.ts +27 -0
  22. package/dist/types/gl/animation/DynamicBone.d.ts +19 -8
  23. package/dist/types/gl/animation/IK.d.ts +86 -30
  24. package/dist/types/gl/animation/Locomotion.d.ts +52 -3
  25. package/dist/types/gl/animation/Warp.d.ts +2 -1
  26. package/dist/types/gl/animation/core.d.ts +35 -4
  27. package/dist/types/gl/physics/Ragdoll.d.ts +87 -12
  28. package/dist/types/gl/terrain/Terrain.d.ts +12 -2
  29. package/dist/types/inject.d.ts +8 -2
  30. package/dist/types/runtime/input.d.ts +7 -0
  31. package/dist/types/scene/defineScene.d.ts +44 -32
  32. package/dist/types/ui/UIButton.d.ts +3 -1
  33. package/dist/types/ui/UIInput.d.ts +5 -1
  34. package/dist/types/ui/UINode.d.ts +24 -24
  35. package/dist/types.json +1 -1
  36. package/package.json +1 -1
  37. package/prompts/README.md +142 -142
  38. package/prompts/core-design.md +27 -4
  39. package/prompts/core.md +35 -6
  40. package/prompts/dist/2d-game.md +197 -408
  41. package/prompts/dist/3d-app.md +166 -491
  42. package/prompts/dist/ar-app.md +163 -373
  43. package/prompts/dist/design.md +87 -83
  44. package/prompts/dist/ui-app.md +136 -325
  45. package/prompts/select.ts +19 -4
  46. package/src/animate/tween/Animation.ts +378 -0
  47. package/src/animate/tween/Timeline.ts +175 -0
  48. package/src/animate/tween/animateValue.ts +100 -0
  49. package/src/animate/tween/easing.ts +172 -0
  50. package/src/animate/tween/spec.ts +479 -0
  51. package/src/audio/audio.ts +161 -161
  52. package/src/bridges.d.ts +235 -65
  53. package/src/compile/__tests__/assetMacro.test.ts +26 -0
  54. package/src/compile/__tests__/detectEntry.test.ts +19 -0
  55. package/src/compile/__tests__/serverSplit.test.ts +27 -0
  56. package/src/compile/bundler.ts +34 -4
  57. package/src/compile/compileProject.ts +31 -1
  58. package/src/compile/detectEntry.ts +8 -3
  59. package/src/compile/index.ts +2 -0
  60. package/src/compile/serverSplit.ts +9 -3
  61. package/src/g2/Node2D.ts +38 -0
  62. package/src/g2/Sprite.ts +20 -1
  63. package/src/gl/Camera.ts +34 -1
  64. package/src/gl/CameraPlace.ts +52 -52
  65. package/src/gl/Foliage.ts +102 -0
  66. package/src/gl/Geometry.ts +393 -348
  67. package/src/gl/Light.ts +49 -16
  68. package/src/gl/Lightmap.ts +439 -275
  69. package/src/gl/Material.ts +59 -47
  70. package/src/gl/Mesh.ts +120 -120
  71. package/src/gl/Model.ts +23 -12
  72. package/src/gl/Node.ts +39 -0
  73. package/src/gl/Particles.ts +80 -2
  74. package/src/gl/Scene.ts +34 -1
  75. package/src/gl/animation/AnimationClip.ts +52 -0
  76. package/src/gl/animation/Animator.ts +42 -2
  77. package/src/gl/animation/DynamicBone.ts +35 -12
  78. package/src/gl/animation/IK.ts +173 -152
  79. package/src/gl/animation/Locomotion.ts +72 -8
  80. package/src/gl/animation/Playback.ts +5 -4
  81. package/src/gl/animation/Warp.ts +5 -2
  82. package/src/gl/animation/core.ts +65 -4
  83. package/src/gl/physics/Ragdoll.ts +451 -272
  84. package/src/gl/scenarios.ts +291 -291
  85. package/src/gl/terrain/Terrain.ts +33 -2
  86. package/src/inject.ts +236 -226
  87. package/src/runtime/input.ts +11 -0
  88. package/src/scene/defineScene.ts +72 -62
  89. package/src/scene/gizmos.ts +148 -148
  90. package/src/ui/UIButton.ts +2 -2
  91. package/src/ui/UIInput.ts +3 -3
  92. package/src/ui/UINode.ts +61 -36
  93. package/dist/types/animate/animate.d.ts +0 -20
  94. package/src/animate/animate.ts +0 -238
@@ -40,6 +40,11 @@ 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
+ const TAG_LIT = 23
46
+ /** `TrailOptions.orient` → the native axis code (0 = the camera-facing default). */
47
+ const RIBBON_AXIS: Record<"camera" | "x" | "y" | "z", number> = { camera: 0, x: 1, y: 2, z: 3 }
43
48
 
44
49
  /** @internal — exported for the tag-sync test only. */
45
50
  export const _particleTags = {
@@ -49,7 +54,8 @@ export const _particleTags = {
49
54
  SPACE: TAG_SPACE, INHERIT_VELOCITY: TAG_INHERIT_VELOCITY, RATE_DISTANCE: TAG_RATE_DISTANCE,
50
55
  RENDER_MODE: TAG_RENDER_MODE, MESH_GEOMETRY: TAG_MESH_GEOMETRY,
51
56
  ANGULAR_VELOCITY: TAG_ANGULAR_VELOCITY, GROUND: TAG_GROUND, VELOCITY_LIFE: TAG_VELOCITY_LIFE,
52
- ORDER: TAG_ORDER,
57
+ ORDER: TAG_ORDER, SMOOTH_PATH: TAG_SMOOTH_PATH, RIBBON_ORIENT: TAG_RIBBON_ORIENT,
58
+ LIT: TAG_LIT,
53
59
  }
54
60
 
55
61
  // --- curve builders -----------------------------------------------------------------------------
@@ -286,11 +292,29 @@ export type ParticlesOptions = ParticlesMaterialOptions & {
286
292
  * spread evenly along the path — trail density independent of speed, no per-frame clumps.
287
293
  * Meant for `space: 'world'`. */
288
294
  rateOverDistance?: number
295
+ /** Spread those spawn points along a CURVE through the emitter's recent path instead of the
296
+ * straight line between where it was last frame and where it is now. A straight line cuts the
297
+ * corner by however far the path bows inside one frame, which grows with the frame TIME — so a
298
+ * fast curving emitter looks faceted, and looks worse the slower the machine. Default false
299
+ * (the straight line); costs three stored positions and one curve evaluation per spawn.
300
+ *
301
+ * The curve needs a point one frame AHEAD, so each frame's spawns are placed provisionally and
302
+ * nudged into place on the next frame, once it exists. Nothing else observes them in between. */
303
+ smooth?: boolean
289
304
  /** Draw order among blended systems at the same depth — higher draws later, i.e. on top (Unity's
290
305
  * sortingOrder). Systems are depth-sorted by their node, so the emitters of one effect tie and
291
306
  * without this the engine picks who covers whom per frame (smoke popping over a fireball).
292
307
  * Default 0; a fireball wants 2, its smoke 1, a smoke trail -1. */
293
308
  order?: number
309
+ /** Sprites take the light of the PLACE each one is in — the level's baked light grid (`env.lightmap.volume`).
310
+ * A sprite is unlit: its colour is the picture, which is right for fire and wrong for dust — a puff kicked up
311
+ * under an awning glows as if it stood in the sun. With `lit` every particle's colour is multiplied by the light
312
+ * where it is, relative to the level's OPEN ground: exactly the authored colour out in the sun, the ambient's
313
+ * share of it (tinted the way the shade is) under a roof — per particle, so a trail of puffs laid from the sun
314
+ * into the shade is lit along its length. Author the colour for the open; nothing else to tune. Without a grid
315
+ * — a level with no bake, a host that has none — the colours are drawn as authored. Points, quads, stretch and
316
+ * `Trail`; mesh particles are lit by their material. Default false. */
317
+ lit?: boolean
294
318
  /** Coarse draw order among ALL blended draws, 0 (first) … 7 (last) — see `Mesh.renderPriority`.
295
319
  * Default 5: meshes sit at 4 and decals at 3, so smoke covers a car's glass and its skid marks
296
320
  * whatever the camera does (the engine's depth sort compares object centres, and an emitter's
@@ -477,7 +501,9 @@ export class Particles extends Node {
477
501
  if (options.space !== undefined) out.push(TAG_SPACE, 1, options.space === "world" ? 1 : 0)
478
502
  if (options.inheritVelocity !== undefined) out.push(TAG_INHERIT_VELOCITY, 1, options.inheritVelocity)
479
503
  if (options.rateOverDistance !== undefined) out.push(TAG_RATE_DISTANCE, 1, options.rateOverDistance)
504
+ if (options.smooth !== undefined) out.push(TAG_SMOOTH_PATH, 1, options.smooth ? 1 : 0)
480
505
  if (options.order !== undefined) out.push(TAG_ORDER, 1, options.order)
506
+ if (options.lit !== undefined) out.push(TAG_LIT, 1, options.lit ? 1 : 0)
481
507
  if (options.rate !== undefined) out.push(TAG_RATE, 1, options.rate)
482
508
  if (options.shape !== undefined) pushShape(out, options.shape)
483
509
  if (options.velocityOverLife !== undefined) pushVelocityLife(out, options.velocityOverLife)
@@ -530,7 +556,11 @@ export class Particles extends Node {
530
556
  set space(val: "local" | "world") { this._send([ TAG_SPACE, 1, val === "world" ? 1 : 0 ]) }
531
557
  set inheritVelocity(val: number) { this._send([ TAG_INHERIT_VELOCITY, 1, val ]) }
532
558
  set rateOverDistance(val: number) { this._send([ TAG_RATE_DISTANCE, 1, val ]) }
559
+ /** Lay a frame's spawns along a curve through the emitter's path, not the straight chord. */
560
+ set smooth(val: boolean) { this._send([ TAG_SMOOTH_PATH, 1, val ? 1 : 0 ]) }
533
561
  set order(val: number) { this._send([ TAG_ORDER, 1, val ]) }
562
+ /** Colours × the baked light where each particle is — see `ParticlesOptions.lit`. */
563
+ set lit(val: boolean) { this._send([ TAG_LIT, 1, val ? 1 : 0 ]) }
534
564
  /** Coarse draw order, 0 … 7 — see `Mesh.renderPriority`. Write-only. */
535
565
  set renderPriority(v: number) {
536
566
  if (_creator.setRenderPriority) _creator.setRenderPriority(this.id, Math.max(0, Math.min(7, Math.round(v))))
@@ -620,8 +650,31 @@ export type TrailOptions = Omit<ParticlesMaterialOptions, "render" | "stretch">
620
650
  width?: ParticleValue
621
651
  /** Minimum emitter movement (world units) between recorded points. Default 0.05. */
622
652
  minDistance?: number
623
- /** Point capacity (default 128). */
653
+ /** Point capacity (default 128). A spawn that does not fit is DROPPED, so a fast emitter at a
654
+ * small `minDistance` wants headroom: a sword tip covers metres inside one `time` window. */
624
655
  maxPoints?: number
656
+ /** Follow a CURVE through the emitter's recent path instead of the straight line between frames.
657
+ * A trail is where this shows most — the strip is the path — and it shows worse the lower the
658
+ * frame rate, since a longer frame bows further off its own chord. Default false; see
659
+ * `ParticlesOptions.smooth`. */
660
+ smooth?: boolean
661
+ /** The strip takes the baked light of the place each of its points is at — `ParticlesOptions.lit`. Smoke and
662
+ * dust trails; leave it off for a glowing one. Default false. */
663
+ lit?: boolean
664
+ /** Which way the strip's WIDTH points.
665
+ *
666
+ * `'camera'` (default) rolls the strip about its own length to stay flat to the viewer — it can
667
+ * never disappear, which is why it is the default, but it bears no relation to the thing that drew
668
+ * it. An AXIS (`'x'`/`'y'`/`'z'`) uses that axis of the emitter's own transform, as it was when
669
+ * each point was laid: on a node riding a sword's blade, `'z'` (the blade) makes the strip the
670
+ * surface the blade actually swept, and `width` stops being a made-up number — it is the blade. */
671
+ orient?: "camera" | "x" | "y" | "z"
672
+ /** With `orient` on an axis: the floor under the strip's APPARENT width, 0..1 of its real width,
673
+ * below which it rolls back toward the camera. An honestly oriented strip goes edge-on — and
674
+ * vanishes — whenever the swing happens in the plane of view; this is the hybrid that keeps it
675
+ * readable. 0 = never roll (honest, and it will vanish), 1 = always (the same as `'camera'`).
676
+ * Default 0.4: a sweep still reads as a sweep, and a swing toward the camera still shows. */
677
+ faceCamera?: number
625
678
  color?: ParticleColor
626
679
  /** Opacity along the trail. Defaults to fading the tail out (`curve().to(0)`). */
627
680
  opacity?: ParticleValue
@@ -635,6 +688,8 @@ export type TrailOptions = Omit<ParticlesMaterialOptions, "render" | "stretch">
635
688
  export class Trail extends Node {
636
689
  private _material: Material
637
690
  private _rateDistance: number
691
+ private _orient = 0
692
+ private _faceCamera = 0.4
638
693
 
639
694
  constructor(options: TrailOptions = {}) {
640
695
  super()
@@ -650,6 +705,13 @@ export class Trail extends Node {
650
705
  TAG_RATE_DISTANCE, 1, this._rateDistance,
651
706
  TAG_LIFETIME, 2, time, time,
652
707
  ]
708
+ if (options.smooth) out.push(TAG_SMOOTH_PATH, 1, 1)
709
+ if (options.lit) out.push(TAG_LIT, 1, 1)
710
+ if (options.orient !== undefined || options.faceCamera !== undefined) {
711
+ this._orient = RIBBON_AXIS[options.orient ?? "camera"]
712
+ if (options.faceCamera !== undefined) this._faceCamera = options.faceCamera
713
+ out.push(TAG_RIBBON_ORIENT, 2, this._orient, this._faceCamera)
714
+ }
653
715
  pushParam(out, 0, options.width ?? 0.1)
654
716
  pushParam(out, 2, options.opacity ?? curve().to(0))
655
717
  if (options.color !== undefined) pushColor(out, options.color)
@@ -686,6 +748,22 @@ export class Trail extends Node {
686
748
  this._send([ TAG_RATE_DISTANCE, 1, this._rateDistance ])
687
749
  }
688
750
 
751
+ /** Follow a curve through the emitter's path instead of the straight chord between frames. */
752
+ set smooth(val: boolean) { this._send([ TAG_SMOOTH_PATH, 1, val ? 1 : 0 ]) }
753
+ set lit(val: boolean) { this._send([ TAG_LIT, 1, val ? 1 : 0 ]) }
754
+
755
+ /** Which way the strip's width points — see `TrailOptions.orient`. Keeps the current `faceCamera`. */
756
+ set orient(val: "camera" | "x" | "y" | "z") {
757
+ this._orient = RIBBON_AXIS[val]
758
+ this._send([ TAG_RIBBON_ORIENT, 2, this._orient, this._faceCamera ])
759
+ }
760
+
761
+ /** The hybrid's floor — see `TrailOptions.faceCamera`. */
762
+ set faceCamera(val: number) {
763
+ this._faceCamera = val
764
+ this._send([ TAG_RIBBON_ORIENT, 2, this._orient, val ])
765
+ }
766
+
689
767
  /** Pause/resume laying down points — existing ones still age out, so the trail fades naturally
690
768
  * after e.g. a sword swing ends. */
691
769
  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
  }
@@ -36,12 +36,14 @@ export type DynamicBoneFloor = "none" | "probe" | number | Vec3Like
36
36
  * on the standard humanoid bones (hips, spine, head, limbs — the Ragdoll's layout); `'none'`; or the
37
37
  * nodes carrying the colliders to use. */
38
38
  export type DynamicBoneColliders = "auto" | "humanoid" | "none" | Node[]
39
- /** The cloth's OUTSIDE, which makes every collider one-sided: `'none'` = a collider pushes a bone to its
40
- * nearest surface (tails, ropes); `'auto'` = away from the root bone's own axis (a cloak's strips round a
41
- * spine face out from it); a vector = a fixed direction in the root bone's frame (a cape on the back:
42
- * `[0, 0, -1]`). With a side the body is under the cloth by definition: an arm that swung through the
43
- * cape pushes it away, and a bone that ended up inside a capsule leaves it on the outside — never
44
- * through it to be held against the chest from the wrong side. */
39
+ /** The cloth's SIDE of every collider marked `oneSided`: `'none'` = no side, every collider pushes a bone to
40
+ * its nearest surface (tails, ropes); `'auto'` = away from the root bone's own axis (a cloak's strips round
41
+ * a spine face out from it); a vector = a fixed direction in the root bone's frame (a cape hanging BEHIND
42
+ * the arms: the body's backward). A one-sided collider that is moving AWAY from that side — an arm swinging
43
+ * forward under the cape — puts a bone it has run into over to the side instead of carrying it: the arm
44
+ * passes through the cloth and the cape hangs behind it again, instead of being dragged round the body.
45
+ * Still, or moving toward the cloth, it pushes like any other collider, so cloth draped on an arm at rest
46
+ * stays where it is. Two-sided colliders (the body's) never read the side. */
45
47
  export type DynamicBoneSide = "none" | "auto" | Vec3Like
46
48
 
47
49
  const VERSION = 1
@@ -82,12 +84,17 @@ export class DynamicBoneCollider extends Aspect<"dynamicBoneCollider", Node> {
82
84
  end?: Vec3Like
83
85
  /** The bone the capsule reaches (its origin), instead of `end`. */
84
86
  to?: string
87
+ /** ONE-SIDED: the cloth belongs on the chain's `side` of this capsule (see DynamicBone.side). While the capsule
88
+ * moves away from that side it puts a bone it has run into over to the side instead of carrying it — an arm
89
+ * under a cape may push the cape back, never drag it forward round the body. Needs a `side` on the DynamicBone. */
90
+ oneSided = false
85
91
 
86
92
  static fields: FieldMeta<DynamicBoneCollider> = {
87
93
  radius: { min: 0, max: 2, step: 0.005 },
88
94
  offset: { hidden: true },
89
95
  end: { hidden: true },
90
96
  to: { hidden: true },
97
+ oneSided: { label: "One-sided" },
91
98
  }
92
99
 
93
100
  private _id = 0
@@ -109,14 +116,14 @@ export class DynamicBoneCollider extends Aspect<"dynamicBoneCollider", Node> {
109
116
  onAttach(): void {
110
117
  if (!supported()) return
111
118
  const [ a, b ] = this._segment()
112
- this._id = _creator.dynamicBoneColliderCreate!(this.node.id, a.x, a.y, a.z, b.x, b.y, b.z, this.radius)
119
+ this._id = _creator.dynamicBoneColliderCreate!(this.node.id, a.x, a.y, a.z, b.x, b.y, b.z, this.radius, this.oneSided ? 1 : 0)
113
120
  DynamicBone._collidersChanged()
114
121
  }
115
122
 
116
123
  onReconfigure(): void {
117
124
  if (!this._id) return
118
125
  const [ a, b ] = this._segment()
119
- _creator.dynamicBoneColliderSet!(this._id, a.x, a.y, a.z, b.x, b.y, b.z, this.radius)
126
+ _creator.dynamicBoneColliderSet!(this._id, a.x, a.y, a.z, b.x, b.y, b.z, this.radius, this.oneSided ? 1 : 0)
120
127
  }
121
128
 
122
129
  onDetach(): void {
@@ -149,7 +156,8 @@ export class DynamicBone extends Aspect<"dynamicBone", Node> {
149
156
  mass: DynamicBoneCurve = 1
150
157
  /** Bones (by name) that ride the animation exactly while their subtrees still simulate: a sheet's
151
158
  * several roots under one anchor (a cloak's seven strips under the spine), so ONE chain owns them all
152
- * and `link` ties neighbouring strips. */
159
+ * and `link` ties neighbouring strips — neighbours in THIS order, so list the strips round the ring
160
+ * (front-left … back … front-right); the front stays open. */
153
161
  pinned?: string[]
154
162
  /** Metres a PINNED bone may be pushed off its animated place by a collider — a soft pin. Its particle
155
163
  * collides and takes the edge pushes like a free bone (the strip below hangs from where it IS) and is
@@ -365,13 +373,28 @@ export class DynamicBone extends Aspect<"dynamicBone", Node> {
365
373
  }
366
374
  }
367
375
 
368
- /** Neighbour links: the bones at the same depth, in tree order, tied pairwise. */
376
+ /** Neighbour links: the bones at the same depth tied pairwise, the columns in the order of `pinned` (a cloak's
377
+ * strips listed round the ring) — columns not listed there follow in tree order. Tree order alone is the
378
+ * engine's child order, which for a GLB is the file's node order REVERSED (Filament prepends each child), so
379
+ * strips added to a rig later were tied across the body instead of to their neighbours: the ermine's cloak
380
+ * had one true neighbour pair out of eight, the rest were rods through the chest. */
369
381
  private _links(): Uint16Array | undefined {
370
382
  if (this.link <= 0) return undefined
383
+ const index = new Map<Node, number>(this._chain.map((n, i) => [ n, i ]))
384
+ const column = new Int32Array(this._chain.length).fill(-1) // per bone: the chain index of its depth-1 ancestor
385
+ for (let i = 1; i < this._chain.length; i++) {
386
+ const p = index.get(this._chain[i]!.parent!) ?? 0
387
+ column[i] = this._depths[i] === 1 ? i : column[p]!
388
+ }
389
+ const ring = this.pinned ?? []
390
+ const key = (i: number): number => { const k = ring.indexOf(this._chain[column[i]!]!.name); return k < 0 ? ring.length + column[i]! : k }
371
391
  const byDepth = new Map<number, number[]>()
372
392
  this._depths.forEach((d, i) => { if (d > 0) (byDepth.get(d) ?? byDepth.set(d, []).get(d)!).push(i) })
373
393
  const pairs: number[] = []
374
- for (const row of byDepth.values()) for (let k = 0; k + 1 < row.length; k++) pairs.push(row[k]!, row[k + 1]!)
394
+ for (const row of byDepth.values()) {
395
+ row.sort((a, b) => key(a) - key(b))
396
+ for (let k = 0; k + 1 < row.length; k++) pairs.push(row[k]!, row[k + 1]!)
397
+ }
375
398
  return pairs.length ? new Uint16Array(pairs) : undefined
376
399
  }
377
400
 
@@ -447,7 +470,7 @@ export class DynamicBone extends Aspect<"dynamicBone", Node> {
447
470
  const len = local.length()
448
471
  end = len > 1e-6 ? local.scale((p.length ?? 0.2) / len) : new Vec3(0, p.length ?? 0.2, 0)
449
472
  }
450
- const id = _creator.dynamicBoneColliderCreate!(bone.id, 0, 0, 0, end.x, end.y, end.z, p.radius ?? 0.05)
473
+ const id = _creator.dynamicBoneColliderCreate!(bone.id, 0, 0, 0, end.x, end.y, end.z, p.radius ?? 0.05, 0)
451
474
  if (id) this._own.push(id)
452
475
  }
453
476
  }