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.
@@ -0,0 +1,201 @@
1
+ // Animation state machine: named states (clip loops or delegate pose producers),
2
+ // guarded transitions with crossfades. Sits on Model.setBlendPose like the
3
+ // LocomotionController does — a delegate state can BE a LocomotionController
4
+ // (constructed with autoApply: false) so gameplay states and locomotion mix freely.
5
+ import type { Model } from "./model"
6
+ import type { BlendEntry } from "./animation"
7
+ import { easeInOut } from "./math"
8
+
9
+ const FPS = 30
10
+
11
+ export interface AnimStateDef {
12
+ /** Clip name previously loaded on the model. Mutually exclusive with `entries`. */
13
+ clip?: string
14
+ /** Loop the clip (default true). Non-loop states hold their last frame. */
15
+ loop?: boolean
16
+ /** Clip playback speed multiplier (default 1). */
17
+ speed?: number
18
+ /** Delegate pose producer — return this frame's blend entries (e.g. a
19
+ * LocomotionController with autoApply: false). Return null to contribute
20
+ * nothing (the pose relaxes toward rest per setBlendPose semantics). */
21
+ entries?: (dt: number) => BlendEntry[] | null
22
+ onEnter?: (from: string | null) => void
23
+ onExit?: (to: string) => void
24
+ }
25
+
26
+ export interface AnimTransitionDef {
27
+ /** Source state name, or "*" for any state (never fires into itself). */
28
+ from: string | "*"
29
+ to: string
30
+ /** Condition, checked every update. Omitted = unconditional. */
31
+ when?: () => boolean
32
+ /** Fire only once the state has been active this many seconds. On a clip
33
+ * state with NO `when` and NO `exitTime`, the transition fires when the
34
+ * (non-looping) clip approaches its end — the "skill finished, back to
35
+ * locomotion" pattern. */
36
+ exitTime?: number
37
+ /** Crossfade seconds (default: machine's defaultFade). */
38
+ fade?: number
39
+ }
40
+
41
+ export interface StateMachineOptions {
42
+ initial: string
43
+ /** Crossfade used when a transition does not specify one (default 0.25). */
44
+ defaultFade?: number
45
+ }
46
+
47
+ interface ActiveState {
48
+ name: string
49
+ def: AnimStateDef
50
+ time: number // seconds in state; doubles as the clip clock (pre-speed)
51
+ }
52
+
53
+ export class AnimationStateMachine {
54
+ private readonly model: Model
55
+ private readonly states: Record<string, AnimStateDef>
56
+ private readonly transitions: AnimTransitionDef[]
57
+ private readonly defaultFade: number
58
+
59
+ private current: ActiveState
60
+ /** Outgoing state during a crossfade — keeps playing while it fades. */
61
+ private fading: { state: ActiveState; elapsed: number; duration: number } | null = null
62
+
63
+ // Scratch: per-slot entry objects are reused so downstream caches (blend
64
+ // cursors, clip-event trackers) keyed on entry identity stay warm.
65
+ private readonly merged: BlendEntry[] = []
66
+
67
+ constructor(model: Model, states: Record<string, AnimStateDef>, transitions: AnimTransitionDef[], options: StateMachineOptions) {
68
+ this.model = model
69
+ this.states = states
70
+ this.transitions = transitions
71
+ this.defaultFade = options.defaultFade ?? 0.25
72
+ const def = states[options.initial]
73
+ if (!def) throw new Error(`Unknown initial state "${options.initial}"`)
74
+ this.current = { name: options.initial, def, time: 0 }
75
+ def.onEnter?.(null)
76
+ }
77
+
78
+ get state(): string {
79
+ return this.current.name
80
+ }
81
+
82
+ /** Seconds the current state has been active. */
83
+ get stateTime(): number {
84
+ return this.current.time
85
+ }
86
+
87
+ /** Force a transition now, regardless of the transition table. */
88
+ go(to: string, fade?: number): void {
89
+ this.begin(to, fade ?? this.defaultFade)
90
+ }
91
+
92
+ update(dt: number): void {
93
+ this.current.time += dt
94
+
95
+ // Transitions are not interruptible mid-fade (go() still is).
96
+ if (this.fading === null) {
97
+ for (const t of this.transitions) {
98
+ if (t.to === this.current.name) continue
99
+ if (t.from !== "*" && t.from !== this.current.name) continue
100
+ if (!this.transitionReady(t)) continue
101
+ this.begin(t.to, t.fade ?? this.defaultFade)
102
+ break
103
+ }
104
+ }
105
+
106
+ const inEntries = this.produce(this.current, dt, this.inScratch)
107
+
108
+ if (this.fading !== null) {
109
+ const f = this.fading
110
+ f.elapsed += dt
111
+ if (f.elapsed >= f.duration) {
112
+ this.fading = null
113
+ } else {
114
+ f.state.time += dt
115
+ const outEntries = this.produce(f.state, dt, this.outScratch)
116
+ const w = easeInOut(f.elapsed / f.duration)
117
+ this.apply(outEntries, 1 - w, inEntries, w)
118
+ return
119
+ }
120
+ }
121
+ this.apply(inEntries, 1, null, 0)
122
+ }
123
+
124
+ private transitionReady(t: AnimTransitionDef): boolean {
125
+ const def = this.current.def
126
+ if (t.exitTime !== undefined) {
127
+ if (this.current.time < t.exitTime) return false
128
+ return t.when ? t.when() : true
129
+ }
130
+ if (t.when) return t.when()
131
+ // Unconditional, no exitTime: on a non-looping clip state this means
132
+ // "when the clip is about to end" (start the fade so it lands at the end).
133
+ if (def.clip && def.loop === false) {
134
+ const dur = this.clipDuration(def.clip) / (def.speed ?? 1)
135
+ return this.current.time >= Math.max(0, dur - (t.fade ?? this.defaultFade))
136
+ }
137
+ return true
138
+ }
139
+
140
+ private begin(to: string, fade: number): void {
141
+ const def = this.states[to]
142
+ if (!def) throw new Error(`Unknown state "${to}"`)
143
+ this.current.def.onExit?.(to)
144
+ // go() during an existing fade drops the older outgoing state (v1: no
145
+ // three-way mixes) — the current state becomes the outgoing one.
146
+ this.fading = fade > 0 ? { state: this.current, elapsed: 0, duration: fade } : null
147
+ const from = this.current.name
148
+ this.current = { name: to, def, time: 0 }
149
+ def.onEnter?.(from)
150
+ }
151
+
152
+ /** This frame's entries for a state: clip states own a one-entry pose;
153
+ * delegate states produce their own. Returns null for "no pose". */
154
+ private produce(state: ActiveState, dt: number, scratch: BlendEntry[]): BlendEntry[] | null {
155
+ const def = state.def
156
+ if (def.entries) return def.entries(dt)
157
+ if (!def.clip) return null
158
+ const dur = this.clipDuration(def.clip)
159
+ let t = state.time * (def.speed ?? 1)
160
+ if (dur > 0) t = def.loop === false ? Math.min(t, dur) : t % dur
161
+ scratch[0].name = def.clip
162
+ scratch[0].time = t
163
+ scratch[0].weight = 1
164
+ return scratch
165
+ }
166
+
167
+ private readonly inScratch: BlendEntry[] = [{ name: "", time: 0, weight: 1 }]
168
+ private readonly outScratch: BlendEntry[] = [{ name: "", time: 0, weight: 1 }]
169
+
170
+ /** Merge up to two entry lists scaled by their group weights into the stable
171
+ * scratch array and hand it to the model. */
172
+ private apply(a: BlendEntry[] | null, wa: number, b: BlendEntry[] | null, wb: number): void {
173
+ let n = 0
174
+ const put = (src: BlendEntry[] | null, scale: number) => {
175
+ if (src === null || scale <= 0) return
176
+ for (const e of src) {
177
+ if (!(e.weight > 1e-6)) continue
178
+ let slot = this.merged[n]
179
+ if (!slot) {
180
+ slot = { name: "", time: 0, weight: 0 }
181
+ this.merged[n] = slot
182
+ }
183
+ slot.name = e.name
184
+ slot.time = e.time
185
+ slot.weight = e.weight * scale
186
+ n++
187
+ }
188
+ }
189
+ // Order: incoming FIRST so slot identities stay stable for a given state
190
+ // across the fade's start/end (cursor + event caches key on the objects).
191
+ put(b, wb)
192
+ put(a, wa)
193
+ for (let i = n; i < this.merged.length; i++) this.merged[i].weight = 0
194
+ if (n > 0) this.model.setBlendPose(this.merged)
195
+ }
196
+
197
+ private clipDuration(name: string): number {
198
+ const frames = this.model.getClip(name)?.frameCount ?? 0
199
+ return frames > 1 ? (frames - 1) / FPS : 0
200
+ }
201
+ }