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
|
@@ -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
|
+
}
|