reze-engine 0.29.1 → 0.30.1

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/model.ts CHANGED
@@ -313,6 +313,21 @@ export class Model {
313
313
  private morphTrackIndices: Map<string, number> = new Map()
314
314
  private lastAppliedClip: AnimationClip | null = null
315
315
 
316
+ // One-shot action layer: plays a clip ONCE over whatever else drives the pose
317
+ // (locomotion blend, a playing clip, a crossfade, or rest), with fade-in/out
318
+ // envelopes — the background keeps advancing and is what the fade-out returns to.
319
+ private oneShot: {
320
+ name: string
321
+ time: number
322
+ duration: number
323
+ fadeIn: number
324
+ fadeOut: number
325
+ cancelW: number
326
+ cancelling: boolean
327
+ onEnd: (() => void) | null
328
+ } | null = null
329
+ private readonly oneShotEntries: BlendEntry[] = []
330
+
316
331
  // Blended pose: declarative N-clip mix (setBlendPose) or a running crossfade.
317
332
  // Cursor caches are per clip so sampling several clips in one frame doesn't
318
333
  // thrash the single-clip caches above; WeakMap so removed clips can collect.
@@ -1329,6 +1344,7 @@ export class Model {
1329
1344
  }
1330
1345
  this.blendEntries = null
1331
1346
  this.crossfade = null
1347
+ this.oneShot = null
1332
1348
  this.resetAllBones()
1333
1349
  this.resetAllMorphs()
1334
1350
  return this.animationState.play(name, options)
@@ -1337,6 +1353,7 @@ export class Model {
1337
1353
  show(name: string): void {
1338
1354
  this.blendEntries = null
1339
1355
  this.crossfade = null
1356
+ this.oneShot = null
1340
1357
  this.resetAllBones()
1341
1358
  this.resetAllMorphs()
1342
1359
  this.animationState.show(name)
@@ -1357,6 +1374,37 @@ export class Model {
1357
1374
  this.blendEntries = null
1358
1375
  }
1359
1376
 
1377
+ /** Play a clip ONCE over whatever currently drives the pose — the locomotion blend,
1378
+ * a playing clip, a crossfade, or rest. The background keeps advancing underneath;
1379
+ * fade-in ramps the one-shot over it and fade-out returns to whatever the
1380
+ * background is doing by then. Replaces any active one-shot immediately. */
1381
+ playOneShot(name: string, options?: { fadeIn?: number; fadeOut?: number; onEnd?: () => void }): boolean {
1382
+ const clip = this.animationState.getAnimationClip(name)
1383
+ if (!clip || clip.frameCount <= 0 || !Number.isFinite(clip.frameCount)) return false
1384
+ const duration = clip.frameCount / FPS
1385
+ const fadeIn = Math.max(0, options?.fadeIn ?? 0.15)
1386
+ const fadeOut = Math.max(0, Math.min(options?.fadeOut ?? 0.25, duration))
1387
+ this.oneShot = { name, time: 0, duration, fadeIn, fadeOut, cancelW: 1, cancelling: false, onEnd: options?.onEnd ?? null }
1388
+ this.clipApplySuspended = false
1389
+ return true
1390
+ }
1391
+
1392
+ /** Fade the active one-shot out early over `fadeOut` seconds (default 0.2). */
1393
+ cancelOneShot(fadeOut = 0.2): void {
1394
+ if (!this.oneShot) return
1395
+ if (fadeOut <= 0) {
1396
+ this.oneShot = null
1397
+ return
1398
+ }
1399
+ this.oneShot.cancelling = true
1400
+ this.oneShot.fadeOut = fadeOut
1401
+ }
1402
+
1403
+ /** Name of the active one-shot, or null. */
1404
+ getOneShot(): string | null {
1405
+ return this.oneShot?.name ?? null
1406
+ }
1407
+
1360
1408
  /** Fade from the currently playing clip (or from the rest pose when nothing plays)
1361
1409
  * into `name` over `seconds`. The target starts at frame 0 and becomes the current
1362
1410
  * clip immediately — progress, looping and the camera clock report the target for
@@ -1364,6 +1412,7 @@ export class Model {
1364
1412
  crossfadeTo(name: string, seconds: number, options?: { loop?: boolean }): boolean {
1365
1413
  if (!this.animationState.hasAnimation(name)) return false
1366
1414
  this.blendEntries = null
1415
+ this.oneShot = null
1367
1416
  this.clipApplySuspended = false
1368
1417
 
1369
1418
  const fromName = this.animationState.getCurrentAnimation()
@@ -1397,6 +1446,7 @@ export class Model {
1397
1446
  stop(): void {
1398
1447
  this.blendEntries = null
1399
1448
  this.crossfade = null
1449
+ this.oneShot = null
1400
1450
  this.animationState.stop()
1401
1451
  }
1402
1452
 
@@ -1411,6 +1461,7 @@ export class Model {
1411
1461
  clearAnimation(): void {
1412
1462
  this.blendEntries = null
1413
1463
  this.crossfade = null
1464
+ this.oneShot = null
1414
1465
  this.animationState.clear()
1415
1466
  }
1416
1467
 
@@ -1727,6 +1778,59 @@ export class Model {
1727
1778
  }
1728
1779
  }
1729
1780
 
1781
+ /** One one-shot step: weight envelope from fade-in/out (easeInOut-shaped), the
1782
+ * background entries scaled by 1-w underneath, the one-shot clip at w on top. */
1783
+ private applyOneShot(deltaTime: number): void {
1784
+ const os = this.oneShot
1785
+ if (os === null) return
1786
+ os.time += deltaTime
1787
+
1788
+ // Envelope: min of the fade-in ramp and the fade-out ramp (or the cancel ramp).
1789
+ const wIn = os.fadeIn > 0 ? Math.min(1, os.time / os.fadeIn) : 1
1790
+ let wOut = 1
1791
+ if (os.cancelling) {
1792
+ os.cancelW -= deltaTime / os.fadeOut
1793
+ wOut = Math.max(0, os.cancelW)
1794
+ } else if (os.fadeOut > 0) {
1795
+ wOut = Math.max(0, Math.min(1, (os.duration - os.time) / os.fadeOut))
1796
+ }
1797
+ const w = easeInOut(Math.min(wIn, wOut))
1798
+
1799
+ // Background entries at (1 - w): the still-advancing blend, the current clip, or
1800
+ // nothing (rest fill). Copied into a pooled array — never scale caller-owned weights.
1801
+ const pool = this.oneShotEntries
1802
+ let n = 0
1803
+ const put = (name: string, time: number, weight: number) => {
1804
+ if (pool.length <= n) pool.push({ name: "", time: 0, weight: 0 })
1805
+ const e = pool[n++]
1806
+ e.name = name
1807
+ e.time = time
1808
+ e.weight = weight
1809
+ }
1810
+ const bg = 1 - w
1811
+ if (bg > 1e-6) {
1812
+ if (this.blendEntries !== null && this.blendEntries.length > 0) {
1813
+ for (const e of this.blendEntries) put(e.name, e.time, e.weight * bg)
1814
+ } else {
1815
+ const clip = this.animationState.getCurrentClip()
1816
+ const name = this.animationState.getCurrentAnimation()
1817
+ if (clip !== null && name !== null && name !== os.name) {
1818
+ put(name, this.animationState.getCurrentFrame() / FPS, bg)
1819
+ }
1820
+ // else: rest pose fills the remainder inside applyBlendedPose
1821
+ }
1822
+ }
1823
+ put(os.name, Math.min(os.time, os.duration), w)
1824
+ for (let i = n; i < pool.length; i++) pool[i].weight = 0
1825
+ this.applyBlendedPose(pool)
1826
+
1827
+ if (os.time >= os.duration || (os.cancelling && os.cancelW <= 0)) {
1828
+ const onEnd = os.onEnd
1829
+ this.oneShot = null
1830
+ onEnd?.()
1831
+ }
1832
+ }
1833
+
1730
1834
  /** One crossfade step: advance the outgoing clock (the target's clock lives in
1731
1835
  * animationState, already ticked by update), shape the weight with easeInOut,
1732
1836
  * and hand both to the blend sampler. Holds in place while paused. */
@@ -1781,7 +1885,9 @@ export class Model {
1781
1885
 
1782
1886
  this.animationState.update(deltaTime)
1783
1887
  if (!this.clipApplySuspended) {
1784
- if (this.blendEntries !== null && this.blendEntries.length > 0) {
1888
+ if (this.oneShot !== null) {
1889
+ this.applyOneShot(deltaTime)
1890
+ } else if (this.blendEntries !== null && this.blendEntries.length > 0) {
1785
1891
  this.applyBlendedPose(this.blendEntries)
1786
1892
  } else if (this.crossfade !== null) {
1787
1893
  this.applyCrossfade(deltaTime)