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/README.md +20 -1
- package/dist/camera.d.ts.map +1 -1
- package/dist/camera.js +22 -2
- package/dist/engine.d.ts +8 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +48 -4
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/locomotion.d.ts +94 -0
- package/dist/locomotion.d.ts.map +1 -1
- package/dist/locomotion.js +332 -3
- package/dist/model.d.ts +23 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +59 -1
- package/dist/state-machine.d.ts +64 -0
- package/dist/state-machine.d.ts.map +1 -0
- package/dist/state-machine.js +145 -0
- package/package.json +1 -1
- package/src/camera.ts +13 -2
- package/src/engine.ts +48 -4
- package/src/index.ts +5 -1
- package/src/locomotion.ts +430 -3
- package/src/model.ts +65 -1
- package/src/state-machine.ts +201 -0
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.
|
|
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
|
-
|
|
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.
|
|
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)
|
|
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
|
|