reze-engine 0.30.2 → 0.31.2

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/src/locomotion.ts CHANGED
@@ -17,6 +17,41 @@ export interface StrafeClipEntry {
17
17
  speed: number
18
18
  }
19
19
 
20
+ export interface TurnClipEntry {
21
+ /** Clip name previously loaded on the model. */
22
+ clip: string
23
+ /** Signed yaw the clip turns through, radians (+ = the character's right). */
24
+ angle: number
25
+ /** Clip-local seconds at which the yaw is complete (the settle tail is skipped). */
26
+ exitTime: number
27
+ }
28
+
29
+ export interface RunTurnClipEntry {
30
+ /** Clip name previously loaded on the model. */
31
+ clip: string
32
+ /** Signed total yaw, radians (+ = the character's right; reversals are ±PI). */
33
+ angle: number
34
+ /** Clip-local seconds at which the yaw is complete (settle tail skipped). */
35
+ exitTime: number
36
+ /** Uniform samples over [0, exitTime] of the clip's authored forward displacement
37
+ * (MMD units along the heading at trigger) — the overrun-plant-return curve. */
38
+ forward: number[]
39
+ gear: "run" | "sprint"
40
+ foot: "L" | "R"
41
+ }
42
+
43
+ export interface StopClipEntry {
44
+ /** Clip name previously loaded on the model. */
45
+ clip: string
46
+ /** Clip-local seconds at which the deceleration settles (idle tail skipped). */
47
+ exitTime: number
48
+ /** Uniform samples over [0, exitTime] of the authored forward displacement
49
+ * (MMD units along the heading at release) — the skid-to-plant curve. */
50
+ forward: number[]
51
+ gear: "run" | "sprint"
52
+ foot: "L" | "R"
53
+ }
54
+
20
55
  export interface LocomotionClips {
21
56
  /** Clip names previously loaded on the model (loadVmd/loadClip). */
22
57
  idle: string
@@ -26,9 +61,26 @@ export interface LocomotionClips {
26
61
  * blends the two ring clips nearest the local move angle. */
27
62
  strafeRun?: StrafeClipEntry[]
28
63
  strafeSprint?: StrafeClipEntry[]
64
+ /** Authored turn-in-place clips: reversal-class direction changes from near-
65
+ * standstill play the nearest clip (yaw baked in the bones, root held) and
66
+ * transfer its angle to the root at exitTime — instead of the eased pivot. */
67
+ turnInPlace?: TurnClipEntry[]
68
+ /** Authored RUNNING reversals (plant-and-turn): while moving fast with a
69
+ * reversal-class heading error, the matching clip plays with the root driven
70
+ * along its measured forward profile; yaw transfers at exitTime and she runs
71
+ * out along the new heading. */
72
+ runTurn?: RunTurnClipEntry[]
73
+ /** Authored stops: releasing input at speed plays the gear/foot-matched stop with
74
+ * the root driven along its measured skid profile, instead of a blend to idle.
75
+ * Re-pressing input interrupts the stop and resumes locomotion. */
76
+ stop?: StopClipEntry[]
29
77
  }
30
78
 
31
79
  export interface LocomotionOptions {
80
+ /** When false, update() computes the pose but does NOT call setBlendPose —
81
+ * read it with getBlendEntries(). For embedding in an AnimationStateMachine
82
+ * delegate state, which owns the final blend. Default true. */
83
+ autoApply?: boolean
32
84
  /** Ground speed in MMD units/second at full run (default 67 — measured from the Unity
33
85
  * locomotion pack's root motion before the in-place strip; ≈5.4 m/s at MMD scale).
34
86
  * Match this to the clips or the feet slide. */
@@ -42,6 +94,14 @@ export interface LocomotionOptions {
42
94
  /** Heading error (radians) beyond which the character pivots strictly in place —
43
95
  * no translation until the body is back within this cone (default PI/4). */
44
96
  turnInPlaceThreshold?: number
97
+ /** Minimum heading error (radians) for an authored turn clip to fire (default
98
+ * ~100°: reversals only — smaller corrections keep the instant pivot). */
99
+ turnClipMinAngle?: number
100
+ /** Playback rate for turn clips (default 1.4 — the authored turns are deliberate;
101
+ * game pacing wants them brisker). */
102
+ turnTimeScale?: number
103
+ /** Playback rate for stop clips (default 1.25) — same reasoning. */
104
+ stopTimeScale?: number
45
105
  /** Tank-mode steering rate, radians/second (default 2.5 ≈ 143°/s). */
46
106
  steerRate?: number
47
107
  /** Backpedal speed as a fraction of run speed in tank mode (default 0.5). */
@@ -74,12 +134,47 @@ function wrapAngle(a: number): number {
74
134
 
75
135
  export class LocomotionController {
76
136
  private readonly model: Model
137
+ private readonly autoApply: boolean
138
+ private lastEntries: BlendEntry[] | null = null
77
139
  private readonly clips: LocomotionClips
78
140
  private readonly runSpeed: number
79
141
  private readonly sprintSpeed: number
80
142
  private readonly speedResponse: number
81
143
  private readonly turnResponse: number
82
144
  private readonly cosTurnThreshold: number
145
+ private readonly turnClipMinAngle: number
146
+ private readonly turnTimeScale: number
147
+ private readonly stopTimeScale: number
148
+ private turning: { entry: TurnClipEntry; time: number } | null = null
149
+ private runTurning: {
150
+ entry: RunTurnClipEntry
151
+ time: number
152
+ startX: number
153
+ startZ: number
154
+ dirX: number
155
+ dirZ: number
156
+ } | null = null
157
+ /** An interrupted authored clip fading out OVER resumed locomotion, so breaking
158
+ * out of a stop is instantly responsive without a pose pop. */
159
+ private exitGhost: { clip: string; clipTime: number; elapsed: number } | null = null
160
+ private stopping: {
161
+ entry: StopClipEntry
162
+ time: number
163
+ startX: number
164
+ startZ: number
165
+ dirX: number
166
+ dirZ: number
167
+ startLevel: number
168
+ /** Idle's share of the blend at release (level < 1 shows part idle) — the
169
+ * crossfade source keeps this exact mix or the first stop frame snaps. */
170
+ fromIdleW: number
171
+ /** The gear clip that was visibly playing at release — the crossfade source
172
+ * (fading over `run` after a SPRINT release would snap the pose). It keeps
173
+ * advancing through the fade so the motion never freezes. */
174
+ fromClip: string
175
+ fromTime: number
176
+ } | null = null
177
+ private readonly turnEntries: BlendEntry[]
83
178
  private readonly yawOffset: number
84
179
 
85
180
  private inputX = 0
@@ -93,6 +188,10 @@ export class LocomotionController {
93
188
  private readonly backpedalScale: number
94
189
 
95
190
  private speedLevel = 0
191
+ /** Peak-hold of speedLevel (decays ~1.5/s): reversal triggers read this, because
192
+ * keyboard direction flips pass through a dead moment (W+S cancel, key gap) that
193
+ * dips the instantaneous level right when the reversal input lands. */
194
+ private recentSpeed = 0
96
195
  private yaw = 0
97
196
  // Movement direction = the INPUT heading, not the body yaw. The body turns
98
197
  // cosmetically toward it; translating along the (sweeping) body yaw instead
@@ -118,8 +217,17 @@ export class LocomotionController {
118
217
  this.runSpeed = options?.runSpeed ?? 67
119
218
  this.sprintSpeed = options?.sprintSpeed ?? 92
120
219
  this.speedResponse = options?.speedResponse ?? 5
220
+ this.autoApply = options?.autoApply ?? true
121
221
  this.turnResponse = options?.turnResponse ?? 10
122
222
  this.cosTurnThreshold = Math.cos(options?.turnInPlaceThreshold ?? Math.PI / 4)
223
+ this.turnClipMinAngle = options?.turnClipMinAngle ?? (100 * Math.PI) / 180
224
+ this.turnTimeScale = options?.turnTimeScale ?? 1.4
225
+ this.stopTimeScale = options?.stopTimeScale ?? 1.25
226
+ this.turnEntries = [
227
+ { name: clips.idle, time: 0, weight: 0 },
228
+ { name: clips.idle, time: 0, weight: 1 },
229
+ { name: clips.idle, time: 0, weight: 0 },
230
+ ]
123
231
  this.steerRate = options?.steerRate ?? 2.5
124
232
  this.backpedalScale = options?.backpedalScale ?? 0.5
125
233
  this.yawOffset = options?.yawOffset ?? Math.PI
@@ -127,6 +235,7 @@ export class LocomotionController {
127
235
  { name: clips.idle, time: 0, weight: 1 },
128
236
  { name: clips.run, time: 0, weight: 0 },
129
237
  { name: clips.sprint ?? clips.run, time: 0, weight: 0 },
238
+ { name: clips.idle, time: 0, weight: 0 }, // fading ghost of an interrupted clip
130
239
  ]
131
240
  const byAngle = (a: StrafeClipEntry, b: StrafeClipEntry) => a.angle - b.angle
132
241
  this.strafeRun = clips.strafeRun ? [...clips.strafeRun].sort(byAngle) : null
@@ -181,7 +290,19 @@ export class LocomotionController {
181
290
 
182
291
  /** Stop driving the model's pose (the blend is cleared; the single-clip player resumes). */
183
292
  detach(): void {
184
- this.model.clearBlendPose()
293
+ this.lastEntries = null
294
+ if (this.autoApply) this.model.clearBlendPose()
295
+ }
296
+
297
+ /** The most recent update()'s blend entries (null before the first update or
298
+ * after detach). The array is reused between frames — read, don't hold. */
299
+ getBlendEntries(): BlendEntry[] | null {
300
+ return this.lastEntries
301
+ }
302
+
303
+ private emit(entries: BlendEntry[]): void {
304
+ this.lastEntries = entries
305
+ if (this.autoApply) this.model.setBlendPose(entries)
185
306
  }
186
307
 
187
308
  private clipDuration(name: string): number {
@@ -198,6 +319,16 @@ export class LocomotionController {
198
319
  return this.updateStrafe(dt)
199
320
  }
200
321
 
322
+ if (this.turning !== null) {
323
+ return this.updateTurnClip(dt)
324
+ }
325
+ if (this.runTurning !== null) {
326
+ return this.updateRunTurn(dt)
327
+ }
328
+ if (this.stopping !== null) {
329
+ return this.updateStop(dt)
330
+ }
331
+
201
332
  const hasSprint = this.clips.sprint !== undefined
202
333
  let moving: boolean
203
334
  let align = 1
@@ -218,12 +349,97 @@ export class LocomotionController {
218
349
  } else {
219
350
  const m = Math.hypot(this.inputX, this.inputY)
220
351
  moving = m > 0.05
352
+ this.recentSpeed = Math.max(this.speedLevel, this.recentSpeed - dt * 1.5)
353
+ // Release at speed: play the authored stop instead of blending to idle.
354
+ const stops = this.clips.stop
355
+ // Gate on the peak-hold speed: the release decay (throttle/analog) drains the
356
+ // instantaneous level below any threshold before `moving` goes false. The
357
+ // small floor on the actual level keeps standstill taps from faking a skid.
358
+ if (!moving && stops && stops.length > 0 && this.recentSpeed >= 0.7 && this.speedLevel >= 0.35) {
359
+ const gear = this.recentSpeed > 1.4 ? "sprint" : "run"
360
+ const foot = this.gaitPhase < 0.5 ? "L" : "R"
361
+ // Gear must match exactly: supplying stops for only one gear means the
362
+ // other gear keeps the default quick blend-to-idle.
363
+ let best: StopClipEntry | null = null
364
+ let bestScore = -1
365
+ for (const e of stops) {
366
+ if (e.gear !== gear) continue
367
+ const score = e.foot === foot ? 1 : 0
368
+ if (score > bestScore) {
369
+ best = e
370
+ bestScore = score
371
+ }
372
+ }
373
+ if (best) {
374
+ const fromClip = gear === "sprint" && this.clips.sprint ? this.clips.sprint : this.clips.run
375
+ this.stopping = {
376
+ entry: best,
377
+ time: 0,
378
+ startX: this.position.x,
379
+ startZ: this.position.z,
380
+ dirX: this.dirX,
381
+ dirZ: this.dirZ,
382
+ startLevel: Math.min(this.recentSpeed, 2),
383
+ fromIdleW: Math.max(0, 1 - this.speedLevel),
384
+ fromClip,
385
+ fromTime: this.gaitPhase * this.clipDuration(fromClip),
386
+ }
387
+ return this.updateStop(dt)
388
+ }
389
+ }
221
390
  // Yaw eases toward the input heading only while there is one. Turns are strictly
222
391
  // in place: zero translation outside the threshold cone, ramping smoothly to full
223
392
  // speed as the body aligns — so direction reversals (L-R-L) cannot drift.
224
393
  if (moving) {
225
394
  const desired = Math.atan2(this.inputX, this.inputY)
226
395
  const err = wrapAngle(desired - this.yaw)
396
+ // Running reversal: while moving fast with a reversal-class error, play the
397
+ // authored plant-and-turn for the current gear and gait foot.
398
+ const runTurns = this.clips.runTurn
399
+ if (runTurns && runTurns.length > 0 && this.recentSpeed >= 0.6 && Math.abs(err) >= (130 * Math.PI) / 180) {
400
+ const gear = this.recentSpeed > 1.4 ? "sprint" : "run"
401
+ const foot = this.gaitPhase < 0.5 ? "L" : "R"
402
+ const side = err < 0 ? -1 : 1
403
+ let best: RunTurnClipEntry | null = null
404
+ let bestScore = -1
405
+ for (const e of runTurns) {
406
+ if (Math.sign(e.angle) !== side) continue
407
+ const score = (e.gear === gear ? 2 : 0) + (e.foot === foot ? 1 : 0)
408
+ if (score > bestScore) {
409
+ best = e
410
+ bestScore = score
411
+ }
412
+ }
413
+ if (best) {
414
+ // Restore the pre-dip speed so she runs OUT of the turn at pace.
415
+ this.speedLevel = Math.max(this.speedLevel, Math.min(this.recentSpeed, 2))
416
+ this.runTurning = {
417
+ entry: best,
418
+ time: 0,
419
+ startX: this.position.x,
420
+ startZ: this.position.z,
421
+ dirX: Math.sin(this.yaw),
422
+ dirZ: Math.cos(this.yaw),
423
+ }
424
+ return this.updateRunTurn(dt)
425
+ }
426
+ }
427
+ // Reversal-class turn from near-standstill: play the authored turn clip
428
+ // whose angle is nearest the error instead of the eased pivot.
429
+ const turnClips = this.clips.turnInPlace
430
+ if (turnClips && turnClips.length > 0 && this.speedLevel < 0.3 && Math.abs(err) >= this.turnClipMinAngle) {
431
+ let best = turnClips[0]
432
+ let bestD = Math.abs(wrapAngle(err - best.angle))
433
+ for (const e of turnClips) {
434
+ const d = Math.abs(wrapAngle(err - e.angle))
435
+ if (d < bestD) {
436
+ best = e
437
+ bestD = d
438
+ }
439
+ }
440
+ this.turning = { entry: best, time: 0 }
441
+ return this.updateTurnClip(dt)
442
+ }
227
443
  this.yaw = wrapAngle(this.yaw + err * Math.min(1, this.turnResponse * dt))
228
444
  align = Math.max(0, (Math.cos(err) - this.cosTurnThreshold) / (1 - this.cosTurnThreshold))
229
445
  this.dirX = this.inputX / m
@@ -278,7 +494,26 @@ export class LocomotionController {
278
494
  this.entries[1].weight = wRun
279
495
  this.entries[2].time = this.gaitPhase * sprintDur
280
496
  this.entries[2].weight = wSprint
281
- this.model.setBlendPose(this.entries)
497
+ const GHOST_FADE = 0.25
498
+ if (this.exitGhost) {
499
+ const g = this.exitGhost
500
+ g.elapsed += dt
501
+ const wGhost = Math.max(0, 1 - g.elapsed / GHOST_FADE)
502
+ if (wGhost <= 0) {
503
+ this.exitGhost = null
504
+ this.entries[3].weight = 0
505
+ } else {
506
+ this.entries[0].weight *= 1 - wGhost
507
+ this.entries[1].weight *= 1 - wGhost
508
+ this.entries[2].weight *= 1 - wGhost
509
+ this.entries[3].name = g.clip
510
+ this.entries[3].time = g.clipTime + g.elapsed // the clip's real tail keeps playing
511
+ this.entries[3].weight = wGhost
512
+ }
513
+ } else {
514
+ this.entries[3].weight = 0
515
+ }
516
+ this.emit(this.entries)
282
517
 
283
518
  const ry = this.yaw + this.yawOffset
284
519
  const half = ry * 0.5
@@ -386,7 +621,7 @@ export class LocomotionController {
386
621
  e[4].name = sprintB.clip
387
622
  e[4].time = this.gaitPhase * sprintDur
388
623
  e[4].weight = wSprint * sprintT
389
- this.model.setBlendPose(e)
624
+ this.emit(e)
390
625
 
391
626
  const ry = this.yaw + this.yawOffset
392
627
  const half = ry * 0.5
@@ -395,4 +630,196 @@ export class LocomotionController {
395
630
  this.pose.speedLevel = level
396
631
  return this.pose
397
632
  }
633
+
634
+ /** Authored turn-in-place frame: the clip rotates the body through its bones while
635
+ * the root yaw stays frozen; at exitTime the measured angle transfers to the root
636
+ * in the same instant the pose hands back to idle — the same orientation expressed
637
+ * two ways, so the cut is seamless. No translation during the turn. */
638
+ private updateTurnClip(dt: number): LocomotionPose {
639
+ const t = this.turning!
640
+ t.time += dt * this.turnTimeScale
641
+
642
+ // Speed level settles toward 0 while turning (we were near-standstill already).
643
+ const maxStep = this.speedResponse * dt
644
+ this.speedLevel += Math.abs(-this.speedLevel) <= maxStep ? -this.speedLevel : -Math.sign(this.speedLevel) * maxStep
645
+
646
+ const idleDur = this.clipDuration(this.clips.idle)
647
+ this.idleTime = (this.idleTime + dt) % idleDur
648
+
649
+ if (t.time >= t.entry.exitTime) {
650
+ // Transfer the angle to the root and hand the pose to idle IN THE SAME frame —
651
+ // leaving the previous blend up would double the rotation for one frame.
652
+ this.yaw = wrapAngle(this.yaw + t.entry.angle)
653
+ this.turning = null
654
+ const e = this.turnEntries
655
+ e[0].weight = 0
656
+ e[1].name = this.clips.idle
657
+ e[1].time = this.idleTime
658
+ e[1].weight = 1
659
+ e[2].weight = 0
660
+ this.emit(e)
661
+ } else {
662
+ // Fade the turn clip over idle at the edges so entry doesn't pop.
663
+ const w = Math.min(1, t.time / 0.12)
664
+ const e = this.turnEntries
665
+ e[0].name = t.entry.clip
666
+ e[0].time = Math.min(t.time, t.entry.exitTime)
667
+ e[0].weight = w
668
+ e[1].name = this.clips.idle
669
+ e[1].time = this.idleTime
670
+ e[1].weight = 1 - w
671
+ e[2].weight = 0
672
+ this.emit(e)
673
+ }
674
+
675
+ const ry = this.yaw + this.yawOffset
676
+ const half = ry * 0.5
677
+ this.rotation.setXYZW(0, Math.sin(half), 0, Math.cos(half))
678
+ this.pose.yaw = this.yaw
679
+ this.pose.speedLevel = this.speedLevel
680
+ return this.pose
681
+ }
682
+
683
+ /** Authored-stop frame: root follows the clip's measured skid profile along the
684
+ * release heading; input returning interrupts and resumes locomotion at a level
685
+ * proportional to how much of the stop remains. */
686
+ private updateStop(dt: number): LocomotionPose {
687
+ const t = this.stopping!
688
+ const entry = t.entry
689
+
690
+ // Interrupt: input came back — locomotion resumes instantly from a LOW level
691
+ // (a stop is committed; breaking out is a fresh walk-up, never a drift), while
692
+ // the stop pose fades out as a ghost instead of hard-cutting.
693
+ if (Math.hypot(this.inputX, this.inputY) > 0.05) {
694
+ this.speedLevel = Math.min(t.startLevel * (1 - Math.min(1, t.time / entry.exitTime)), 0.3)
695
+ this.recentSpeed = this.speedLevel
696
+ this.exitGhost = { clip: entry.clip, clipTime: Math.min(t.time, entry.exitTime), elapsed: 0 }
697
+ this.stopping = null
698
+ return this.update(dt)
699
+ }
700
+
701
+ t.time += dt * this.stopTimeScale
702
+ const clipT = Math.min(t.time, entry.exitTime)
703
+ const fwd = LocomotionController.profileAt(entry.forward, clipT / entry.exitTime)
704
+ this.position.x = t.startX + t.dirX * fwd
705
+ this.position.z = t.startZ + t.dirZ * fwd
706
+
707
+ this.speedLevel = t.startLevel * Math.max(0, 1 - t.time / entry.exitTime)
708
+
709
+ const idleDur = this.clipDuration(this.clips.idle)
710
+ this.idleTime = (this.idleTime + dt) % idleDur
711
+
712
+ const FADE_OUT = 0.35
713
+ if (t.time >= entry.exitTime + FADE_OUT) {
714
+ this.stopping = null
715
+ this.speedLevel = 0
716
+ this.gaitPhase = 0
717
+ const e = this.turnEntries
718
+ e[0].weight = 0
719
+ e[1].name = this.clips.idle
720
+ e[1].time = this.idleTime
721
+ e[1].weight = 1
722
+ e[2].weight = 0
723
+ this.emit(e)
724
+ } else if (t.time >= entry.exitTime) {
725
+ // Settle tail: the root is already still, so keep playing the clip's own
726
+ // recovery (it exists past exitTime) while fading to idle — no hard cut.
727
+ const wOut = (t.time - entry.exitTime) / FADE_OUT
728
+ const e = this.turnEntries
729
+ e[0].name = entry.clip
730
+ e[0].time = t.time // real tail frames beyond exitTime
731
+ e[0].weight = 1 - wOut
732
+ e[1].name = this.clips.idle
733
+ e[1].time = this.idleTime
734
+ e[1].weight = wOut
735
+ e[2].weight = 0
736
+ this.emit(e)
737
+ } else {
738
+ const fromDur = this.clipDuration(t.fromClip)
739
+ t.fromTime = (t.fromTime + dt) % fromDur
740
+ const w = Math.min(1, t.time / 0.25)
741
+ const e = this.turnEntries
742
+ e[0].name = entry.clip
743
+ e[0].time = clipT
744
+ e[0].weight = w
745
+ e[1].name = t.fromClip
746
+ e[1].time = t.fromTime
747
+ e[1].weight = (1 - w) * (1 - t.fromIdleW)
748
+ e[2].name = this.clips.idle
749
+ e[2].time = this.idleTime
750
+ e[2].weight = (1 - w) * t.fromIdleW
751
+ this.emit(e)
752
+ }
753
+
754
+ const ry = this.yaw + this.yawOffset
755
+ const half = ry * 0.5
756
+ this.rotation.setXYZW(0, Math.sin(half), 0, Math.cos(half))
757
+ this.pose.yaw = this.yaw
758
+ this.pose.speedLevel = this.speedLevel
759
+ return this.pose
760
+ }
761
+
762
+ /** Linear interpolation over a uniformly sampled profile at t in [0, 1]. */
763
+ private static profileAt(samples: number[], t: number): number {
764
+ const n = samples.length - 1
765
+ if (n <= 0) return 0
766
+ const x = Math.min(Math.max(t, 0), 1) * n
767
+ const i = Math.min(n - 1, Math.floor(x))
768
+ return samples[i] + (samples[i + 1] - samples[i]) * (x - i)
769
+ }
770
+
771
+ /** Running-reversal frame: bones carry the turn while the root travels the clip's
772
+ * AUTHORED forward profile along the trigger heading (overrun, plant, return) —
773
+ * no fabricated motion, so the feet stay glued. At exitTime the yaw transfers
774
+ * and the normal path runs her out along the new heading. */
775
+ private updateRunTurn(dt: number): LocomotionPose {
776
+ const t = this.runTurning!
777
+ t.time += dt
778
+ const entry = t.entry
779
+ const clipT = Math.min(t.time, entry.exitTime)
780
+
781
+ const fwd = LocomotionController.profileAt(entry.forward, clipT / entry.exitTime)
782
+ this.position.x = t.startX + t.dirX * fwd
783
+ this.position.z = t.startZ + t.dirZ * fwd
784
+
785
+ const idleDur = this.clipDuration(this.clips.idle)
786
+ this.idleTime = (this.idleTime + dt) % idleDur
787
+
788
+ if (t.time >= entry.exitTime) {
789
+ this.yaw = wrapAngle(this.yaw + entry.angle)
790
+ this.runTurning = null
791
+ // She exits mid-stride: keep the speed level, restart the gait cleanly, and
792
+ // point the travel direction along the new heading so the next frame runs out.
793
+ this.gaitPhase = 0
794
+ this.dirX = Math.sin(this.yaw)
795
+ this.dirZ = Math.cos(this.yaw)
796
+ const e = this.turnEntries
797
+ e[0].name = this.clips.run
798
+ e[0].time = 0
799
+ e[0].weight = Math.min(1, this.speedLevel)
800
+ e[1].name = this.clips.idle
801
+ e[1].time = this.idleTime
802
+ e[1].weight = 1 - Math.min(1, this.speedLevel)
803
+ e[2].weight = 0
804
+ this.emit(e)
805
+ } else {
806
+ const w = Math.min(1, t.time / 0.1)
807
+ const e = this.turnEntries
808
+ e[0].name = entry.clip
809
+ e[0].time = clipT
810
+ e[0].weight = w
811
+ e[1].name = this.clips.run
812
+ e[1].time = this.gaitPhase * this.clipDuration(this.clips.run)
813
+ e[1].weight = 1 - w
814
+ e[2].weight = 0
815
+ this.emit(e)
816
+ }
817
+
818
+ const ry = this.yaw + this.yawOffset
819
+ const half = ry * 0.5
820
+ this.rotation.setXYZW(0, Math.sin(half), 0, Math.cos(half))
821
+ this.pose.yaw = this.yaw
822
+ this.pose.speedLevel = this.speedLevel
823
+ return this.pose
824
+ }
398
825
  }
package/src/model.ts CHANGED
@@ -32,6 +32,14 @@ const _convMat = new Float32Array(16)
32
32
  // Blend-path scratch: per-entry sample target and the crossfade's two fixed entries.
33
33
  const _blendQ = new Quat(0, 0, 0, 1)
34
34
  const _blendT = new Vec3(0, 0, 0)
35
+ export interface ClipEventInfo {
36
+ clip: string
37
+ /** The registered event time, seconds. */
38
+ time: number
39
+ /** The clip's blend weight at the moment of firing. */
40
+ weight: number
41
+ }
42
+
35
43
  const _fadeEntries: BlendEntry[] = [
36
44
  { name: "", time: 0, weight: 0 },
37
45
  { name: "", time: 0, weight: 0 },
@@ -327,6 +335,10 @@ export class Model {
327
335
  onEnd: (() => void) | null
328
336
  } | null = null
329
337
  private readonly oneShotEntries: BlendEntry[] = []
338
+ /** Time-triggered clip callbacks (footsteps, skill timing). Fired by every
339
+ * playback path when a clip's cursor crosses the event time with weight. */
340
+ private readonly clipEvents = new Map<string, { time: number; minWeight: number; callback: (e: ClipEventInfo) => void }[]>()
341
+ private readonly entryEventPrev = new WeakMap<BlendEntry, { name: string; time: number }>()
330
342
 
331
343
  // Blended pose: declarative N-clip mix (setBlendPose) or a running crossfade.
332
344
  // Cursor caches are per clip so sampling several clips in one frame doesn't
@@ -1405,6 +1417,50 @@ export class Model {
1405
1417
  return this.oneShot?.name ?? null
1406
1418
  }
1407
1419
 
1420
+ /** Fire `callback` whenever `clip`'s playback crosses `time` (seconds) with at
1421
+ * least `minWeight` influence (default 0.1) — on any path: blend entries,
1422
+ * one-shots, crossfades, or plain play. Loop wraps fire correctly; hard cursor
1423
+ * jumps may occasionally fire or skip (events are for sfx-grade timing, not
1424
+ * logic). Returns an unsubscribe function. */
1425
+ addClipEvent(clip: string, time: number, callback: (e: ClipEventInfo) => void, options?: { minWeight?: number }): () => void {
1426
+ let list = this.clipEvents.get(clip)
1427
+ if (!list) {
1428
+ list = []
1429
+ this.clipEvents.set(clip, list)
1430
+ }
1431
+ const def = { time, minWeight: options?.minWeight ?? 0.1, callback }
1432
+ list.push(def)
1433
+ return () => {
1434
+ const i = list.indexOf(def)
1435
+ if (i >= 0) list.splice(i, 1)
1436
+ }
1437
+ }
1438
+
1439
+ private fireClipEvents(name: string, prev: number, now: number, weight: number): void {
1440
+ const evs = this.clipEvents.get(name)
1441
+ if (!evs || evs.length === 0) return
1442
+ const wrapped = now < prev
1443
+ for (let i = 0; i < evs.length; i++) {
1444
+ const ev = evs[i]
1445
+ if (weight < ev.minWeight) continue
1446
+ const hit = wrapped ? ev.time > prev || ev.time <= now : ev.time > prev && ev.time <= now
1447
+ if (hit) ev.callback({ clip: name, time: ev.time, weight })
1448
+ }
1449
+ }
1450
+
1451
+ /** Per-entry cursor memory for event crossing detection; keyed on entry object
1452
+ * identity (controllers reuse their arrays, so this stays warm). */
1453
+ private trackEntryEvents(e: BlendEntry): void {
1454
+ const prev = this.entryEventPrev.get(e)
1455
+ if (prev === undefined) {
1456
+ this.entryEventPrev.set(e, { name: e.name, time: e.time })
1457
+ return
1458
+ }
1459
+ if (prev.name === e.name && prev.time !== e.time) this.fireClipEvents(e.name, prev.time, e.time, e.weight)
1460
+ prev.name = e.name
1461
+ prev.time = e.time
1462
+ }
1463
+
1408
1464
  /** Fade from the currently playing clip (or from the rest pose when nothing plays)
1409
1465
  * into `name` over `seconds`. The target starts at frame 0 and becomes the current
1410
1466
  * clip immediately — progress, looping and the camera clock report the target for
@@ -1695,6 +1751,7 @@ export class Model {
1695
1751
  if (!clip) continue
1696
1752
  const w = e.weight * norm
1697
1753
  const frame = e.time * FPS
1754
+ if (this.clipEvents.size > 0) this.trackEntryEvents(e)
1698
1755
 
1699
1756
  let cursors = this.blendBoneCursors.get(clip)
1700
1757
  if (!cursors) {
@@ -1883,6 +1940,8 @@ export class Model {
1883
1940
  // Update all active tweens (rotations, translations, morphs)
1884
1941
  const tweensChangedMorphs = this.updateTweens()
1885
1942
 
1943
+ const evWatch = this.clipEvents.size > 0 ? this.animationState.getCurrentAnimation() : null
1944
+ const evPrevFrame = evWatch !== null ? this.animationState.getCurrentFrame() : 0
1886
1945
  this.animationState.update(deltaTime)
1887
1946
  if (!this.clipApplySuspended) {
1888
1947
  if (this.oneShot !== null) {
@@ -1893,7 +1952,12 @@ export class Model {
1893
1952
  this.applyCrossfade(deltaTime)
1894
1953
  } else {
1895
1954
  const clip = this.animationState.getCurrentClip()
1896
- if (clip !== null) this.applyPoseFromClip(clip, this.animationState.getCurrentFrame())
1955
+ if (clip !== null) {
1956
+ this.applyPoseFromClip(clip, this.animationState.getCurrentFrame())
1957
+ if (evWatch !== null && evWatch === this.animationState.getCurrentAnimation()) {
1958
+ this.fireClipEvents(evWatch, evPrevFrame / FPS, this.animationState.getCurrentFrame() / FPS, 1)
1959
+ }
1960
+ }
1897
1961
  }
1898
1962
  }
1899
1963