@vgai/engine 0.2.0 → 0.4.0-canary.20260715.0

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.
Files changed (123) hide show
  1. package/README.md +3 -1
  2. package/package.json +24 -4
  3. package/schemas/engine-api.json +124 -0
  4. package/schemas/engine-api.md +53 -0
  5. package/schemas/engine-capabilities.json +124 -0
  6. package/schemas/inputmap.schema.json +314 -0
  7. package/schemas/mat.schema.json +286 -0
  8. package/schemas/prefab.schema.json +10148 -0
  9. package/schemas/scn2d.schema.json +475 -0
  10. package/schemas/vgai-game.schema.json +383 -0
  11. package/schemas/vscn.schema.json +11007 -0
  12. package/src/adapter/{world-kind.ts → adapter-surface.ts} +6 -6
  13. package/src/adapter/authoring.ts +77 -0
  14. package/src/adapter/first-party-systems.ts +23 -34
  15. package/src/adapter/game-adapter.ts +8 -8
  16. package/src/adapter/host-context.ts +2 -4
  17. package/src/adapter/index.ts +4 -4
  18. package/src/adapter/system-adapter.ts +88 -22
  19. package/src/adapter/vgai-scene-game-adapter.ts +244 -194
  20. package/src/animation/anim-graph-types.ts +12 -43
  21. package/src/animation/animation-clock.ts +479 -0
  22. package/src/animation/camera-ownership.ts +467 -0
  23. package/src/animation/cinematic-cues.ts +451 -0
  24. package/src/animation/clip-map.ts +41 -0
  25. package/src/animation/gsap-registration.ts +184 -0
  26. package/src/animation/theatre-clock-binding.ts +111 -0
  27. package/src/animation/theatre-director.ts +347 -0
  28. package/src/animation/theatre-object-binding.ts +661 -0
  29. package/src/animation/xstate-animation-binding.ts +436 -0
  30. package/src/animation/xstate-animation-meta.ts +319 -0
  31. package/src/audio/index.ts +39 -7
  32. package/src/audio/tone-clock-binding.ts +98 -0
  33. package/src/audio/tone-context.ts +129 -0
  34. package/src/audio/tone-offline-render.ts +167 -0
  35. package/src/audio/wav-encode.ts +119 -0
  36. package/src/character/cloth-sim.ts +533 -0
  37. package/src/character/spring-chain.ts +307 -0
  38. package/src/core/game-loop.ts +57 -2
  39. package/src/core/seeded-random.ts +161 -0
  40. package/src/core/system-runner.ts +20 -3
  41. package/src/core/types.ts +50 -0
  42. package/src/data/data-asset.ts +167 -0
  43. package/src/data/data-check-core.ts +242 -0
  44. package/src/data/data-ref.ts +145 -0
  45. package/src/data/vite-plugin-data.ts +290 -0
  46. package/src/dev/performance-profiler.ts +213 -0
  47. package/src/dev/webgl-gpu-timer.ts +53 -0
  48. package/src/ecs/component-manager.ts +45 -12
  49. package/src/ecs/game-component.ts +95 -11
  50. package/src/humanoid/bake.operation.ts +326 -0
  51. package/src/humanoid/body.ts +663 -0
  52. package/src/humanoid/clips.ts +149 -0
  53. package/src/humanoid/compose.ts +209 -0
  54. package/src/humanoid/generate.ts +189 -0
  55. package/src/humanoid/index.ts +36 -0
  56. package/src/humanoid/schema.ts +108 -0
  57. package/src/humanoid/skeleton.ts +345 -0
  58. package/src/index.ts +48 -0
  59. package/src/input/input-manager.ts +1886 -33
  60. package/src/input/input-types.ts +158 -3
  61. package/src/input/prompt-labels.ts +122 -0
  62. package/src/input/rebind-controller.ts +105 -0
  63. package/src/input/schema.ts +206 -52
  64. package/src/manifest/index.ts +5 -5
  65. package/src/manifest/load.ts +125 -72
  66. package/src/manifest/schema.ts +362 -255
  67. package/src/react/game-state.tsx +135 -32
  68. package/src/react/root-adapter.tsx +49 -0
  69. package/src/react/unmanaged-root-detector.ts +66 -0
  70. package/src/react/use-data.ts +124 -0
  71. package/src/react/use-selection.tsx +135 -0
  72. package/src/runtime/create-runtime.ts +112 -273
  73. package/src/runtime/debug-bridge.ts +483 -0
  74. package/src/runtime/debug-registry.ts +856 -0
  75. package/src/runtime/game.ts +342 -93
  76. package/src/runtime/gameplay-rng-trap.ts +134 -0
  77. package/src/runtime/input-router.ts +7 -7
  78. package/src/runtime/mount-game.ts +40 -38
  79. package/src/runtime/mount-manifest.ts +169 -37
  80. package/src/runtime/render-audio-control.ts +168 -0
  81. package/src/runtime/render-control.ts +522 -0
  82. package/src/runtime/render-seed.ts +79 -0
  83. package/src/runtime/state-bridge.ts +24 -10
  84. package/src/runtime/types.ts +110 -33
  85. package/src/scene/asset-loaders.ts +10 -36
  86. package/src/scene/asset-paths.ts +0 -2
  87. package/src/scene/asset-ref-check.ts +248 -0
  88. package/src/scene/asset-registry.ts +22 -0
  89. package/src/scene/component-registry.ts +14 -3
  90. package/src/scene/defaults.ts +1 -0
  91. package/src/scene/light-camera-factory.ts +11 -3
  92. package/src/scene/parse.ts +133 -0
  93. package/src/scene/scene-apply.ts +55 -4
  94. package/src/scene/scene-loader.ts +91 -123
  95. package/src/scene/scene-types.ts +0 -1
  96. package/src/scene/schema/animation.ts +30 -79
  97. package/src/scene/schema/entity.ts +20 -0
  98. package/src/scene/schema/index.ts +2 -46
  99. package/src/scene/schema/light.ts +16 -1
  100. package/src/scene/schema/material.ts +96 -91
  101. package/src/scene/schema/scene-file.ts +1 -7
  102. package/src/scene/user-data.ts +22 -10
  103. package/src/setup/setup-renderer.ts +10 -3
  104. package/src/tools/define-tool.ts +191 -0
  105. package/src/world2d/authoring-2d.ts +17 -1
  106. package/src/world2d/collision-2d.ts +1 -1
  107. package/src/world2d/pixi-game-adapter.ts +19 -17
  108. package/src/world2d/scene2d-loader.ts +1 -0
  109. package/src/world2d/types.ts +8 -2
  110. package/src/animation/anim-graph.ts +0 -406
  111. package/src/animation/anim-system.ts +0 -28
  112. package/src/animation/property-track.ts +0 -178
  113. package/src/animation/schema.ts +0 -204
  114. package/src/audio/ambient.ts +0 -300
  115. package/src/audio/impacts.ts +0 -212
  116. package/src/audio/movement.ts +0 -140
  117. package/src/audio/musical.ts +0 -200
  118. package/src/audio/ui-sounds.ts +0 -171
  119. package/src/audio/vehicle.ts +0 -235
  120. package/src/audio/weapons.ts +0 -152
  121. package/src/runtime/scene-ui-bridge.ts +0 -86
  122. package/src/runtime/scene-ui-data.ts +0 -119
  123. package/src/scene/schema/ui.ts +0 -602
@@ -0,0 +1,436 @@
1
+ import * as THREE from 'three';
2
+ import type { Actor, AnyActor, AnyStateMachine } from 'xstate';
3
+ import type { createSystemRunner } from '../core/system-runner';
4
+ import { deleteUserData, getUserData, setUserData } from '../scene/user-data';
5
+ import type { BlendTreeDef } from './anim-graph-types';
6
+ import { evaluateBlendTree } from './blend-node';
7
+ import {
8
+ type AnimationBoneMask,
9
+ type AnimationMetaStateNodeLike,
10
+ clipNamesOf,
11
+ collectMachineAnimationMeta,
12
+ isBlendTreeAnimationMeta,
13
+ type MachineAnimationMetaEntry,
14
+ type StateAnimationMeta,
15
+ } from './xstate-animation-meta';
16
+
17
+ /** A binding-time problem such as a missing clip, bone, or ambiguous layer. */
18
+ export class AnimationBindingError extends Error {
19
+ constructor(
20
+ message: string,
21
+ readonly stateId?: string,
22
+ ) {
23
+ super(message);
24
+ this.name = 'AnimationBindingError';
25
+ }
26
+ }
27
+
28
+ export interface XStateAnimationBindingOptions {
29
+ /** Select live numeric/boolean blend-tree parameters from actor context. */
30
+ selectParameters?: (context: unknown) => Record<string, number | boolean>;
31
+ /** Root whose named Bone/Object3D hierarchy is used by `boneMask`. Defaults to mixer root. */
32
+ root?: THREE.Object3D;
33
+ /** Entity that exposes this binding to editor/runtime inspection. Defaults to `root`. */
34
+ owner?: THREE.Object3D;
35
+ /**
36
+ * Engine runner used to register `tick` in the canonical animation phase.
37
+ * Pass `ctx.systems`; disposal removes the callback automatically. When
38
+ * omitted, the caller owns ticking (useful for standalone Three tests).
39
+ */
40
+ systems?: Pick<ReturnType<typeof createSystemRunner>, 'add' | 'remove'>;
41
+ }
42
+
43
+ export interface XStateAnimationLayerState {
44
+ readonly layer: string;
45
+ readonly stateId: string;
46
+ readonly clips: readonly string[];
47
+ readonly weight: number;
48
+ readonly blendMode: 'override' | 'additive';
49
+ readonly boneMask?: AnimationBoneMask;
50
+ }
51
+
52
+ export interface XStateAnimationBinding {
53
+ /** Advance native actions/mixer. Register this in the engine animation phase. */
54
+ tick: (dt: number) => void;
55
+ readonly actor: AnyActor;
56
+ /** Current native composition, useful to the editor and game diagnostics. */
57
+ getActiveLayers: () => readonly XStateAnimationLayerState[];
58
+ /** Unsubscribe, stop/uncache actions, and remove owner inspection data. Idempotent. */
59
+ dispose: () => void;
60
+ }
61
+
62
+ let inspectionVersion = 0;
63
+ const inspectionListeners = new Set<() => void>();
64
+
65
+ /** React/useSyncExternalStore-compatible lifecycle signal for live editor inspection. */
66
+ export function subscribeXStateAnimationBindings(listener: () => void): () => void {
67
+ inspectionListeners.add(listener);
68
+ return () => inspectionListeners.delete(listener);
69
+ }
70
+
71
+ /** Monotonic snapshot changed whenever a binding is attached or disposed. */
72
+ export function getXStateAnimationBindingsVersion(): number {
73
+ return inspectionVersion;
74
+ }
75
+
76
+ function notifyInspectionLifecycle(): void {
77
+ inspectionVersion++;
78
+ for (const listener of inspectionListeners) listener();
79
+ }
80
+
81
+ interface WeightedClip {
82
+ clip: string;
83
+ actionKey: string;
84
+ weight: number;
85
+ }
86
+
87
+ interface ActiveState {
88
+ layer: string;
89
+ stateId: string;
90
+ meta: StateAnimationMeta;
91
+ natural: WeightedClip[];
92
+ dominantActionKey: string;
93
+ crossfadeRemaining: number;
94
+ }
95
+
96
+ interface CachedAction {
97
+ action: THREE.AnimationAction;
98
+ clip: THREE.AnimationClip;
99
+ }
100
+
101
+ const layerOf = (meta: StateAnimationMeta): string => meta.layer ?? 'base';
102
+ const layerWeightOf = (meta: StateAnimationMeta): number => meta.weight ?? 1;
103
+ const blendModeOf = (meta: StateAnimationMeta): 'override' | 'additive' =>
104
+ meta.blendMode ?? 'override';
105
+
106
+ function targetNameOf(track: THREE.KeyframeTrack): string {
107
+ try {
108
+ return THREE.PropertyBinding.parseTrackName(track.name).nodeName ?? '';
109
+ } catch {
110
+ const dot = track.name.indexOf('.');
111
+ return dot < 0 ? track.name : track.name.slice(0, dot);
112
+ }
113
+ }
114
+
115
+ function subtreeNames(root: THREE.Object3D, requested: readonly string[], trackNames: Set<string>) {
116
+ const names = new Set<string>();
117
+ const missing: string[] = [];
118
+ for (const requestedName of requested) {
119
+ let found = false;
120
+ root.traverse((object) => {
121
+ if (object.name !== requestedName) return;
122
+ found = true;
123
+ object.traverse((descendant) => {
124
+ if (descendant.name) names.add(descendant.name);
125
+ });
126
+ });
127
+ // Some exporters target a named node not retained as a discoverable Bone.
128
+ // Exact track-target matches remain useful, but cannot imply descendants.
129
+ if (trackNames.has(requestedName)) {
130
+ found = true;
131
+ names.add(requestedName);
132
+ }
133
+ if (!found) missing.push(requestedName);
134
+ }
135
+ return { names, missing };
136
+ }
137
+
138
+ function maskedClip(
139
+ source: THREE.AnimationClip,
140
+ mask: AnimationBoneMask,
141
+ root: THREE.Object3D,
142
+ stateId: string,
143
+ ): THREE.AnimationClip {
144
+ const trackNames = new Set(source.tracks.map(targetNameOf));
145
+ const included = mask.include ? subtreeNames(root, mask.include, trackNames) : undefined;
146
+ const excluded = mask.exclude ? subtreeNames(root, mask.exclude, trackNames) : undefined;
147
+ const missing = [...(included?.missing ?? []), ...(excluded?.missing ?? [])];
148
+ if (missing.length) {
149
+ throw new AnimationBindingError(
150
+ `[bindXStateAnimation] state "${stateId}" boneMask names unknown bones/objects: ${missing.join(', ')}`,
151
+ stateId,
152
+ );
153
+ }
154
+ const tracks = source.tracks.filter((track) => {
155
+ const name = targetNameOf(track);
156
+ return (!included || included.names.has(name)) && !excluded?.names.has(name);
157
+ });
158
+ if (tracks.length === 0) {
159
+ throw new AnimationBindingError(
160
+ `[bindXStateAnimation] state "${stateId}" boneMask removes every track from clip "${source.name}"`,
161
+ stateId,
162
+ );
163
+ }
164
+ return new THREE.AnimationClip(
165
+ source.name,
166
+ source.duration,
167
+ tracks.map((track) => track.clone()),
168
+ );
169
+ }
170
+
171
+ function dominantOf(weights: WeightedClip[]): WeightedClip {
172
+ return weights.reduce((a, b) => (b.weight > a.weight ? b : a), weights[0]!);
173
+ }
174
+
175
+ /**
176
+ * Bind a native XState actor to native Three actions.
177
+ *
178
+ * One active animated state is allowed per named layer. Parallel XState
179
+ * regions therefore compose lower-body locomotion, upper-body weapon states,
180
+ * facial animation, and other independent layers without introducing a
181
+ * second transition language. Bone masks clone/filter native clip tracks;
182
+ * additive layers use `AnimationUtils.makeClipAdditive` and Three's additive
183
+ * blend mode. Single unlayered machines retain the original clip/action path.
184
+ */
185
+ export function bindXStateAnimation(
186
+ actor: Actor<AnyStateMachine>,
187
+ mixer: THREE.AnimationMixer,
188
+ clips: Map<string, THREE.AnimationClip>,
189
+ options: XStateAnimationBindingOptions = {},
190
+ ): XStateAnimationBinding {
191
+ const mixerRoot = mixer.getRoot();
192
+ const root = options.root ?? (mixerRoot instanceof THREE.Object3D ? mixerRoot : undefined);
193
+ if (!root) {
194
+ throw new AnimationBindingError(
195
+ '[bindXStateAnimation] a THREE.Object3D `root` option is required when the mixer uses AnimationObjectGroup',
196
+ );
197
+ }
198
+ const owner = options.owner ?? root;
199
+ const machineRoot = actor.logic.root as unknown as AnimationMetaStateNodeLike;
200
+ const entries: MachineAnimationMetaEntry[] = collectMachineAnimationMeta(machineRoot);
201
+ const metaByStateId = new Map(entries.map((entry) => [entry.stateId, entry.meta]));
202
+
203
+ for (const entry of entries) {
204
+ for (const clipName of clipNamesOf(entry.meta)) {
205
+ if (!clips.has(clipName)) {
206
+ throw new AnimationBindingError(
207
+ `[bindXStateAnimation] state "${entry.stateId}" references clip "${clipName}", which is ` +
208
+ `not present in the supplied clips map. Known clips: ${[...clips.keys()].join(', ') || '(none)'}`,
209
+ entry.stateId,
210
+ );
211
+ }
212
+ }
213
+ }
214
+
215
+ // Different layers need distinct AnimationActions even when they use the
216
+ // same source clip. A plain base state keeps the original clip identity for
217
+ // backwards-compatible mixer.existingAction(sourceClip) behavior.
218
+ const preparedClips = new Map<string, THREE.AnimationClip>();
219
+ function actionKey(stateId: string, meta: StateAnimationMeta, clipName: string): string {
220
+ const transformed =
221
+ layerOf(meta) !== 'base' || Boolean(meta.boneMask) || blendModeOf(meta) === 'additive';
222
+ return transformed ? `${stateId}\u0000${clipName}` : clipName;
223
+ }
224
+ for (const { stateId, meta } of entries) {
225
+ for (const clipName of clipNamesOf(meta)) {
226
+ const key = actionKey(stateId, meta, clipName);
227
+ if (preparedClips.has(key)) continue;
228
+ const source = clips.get(clipName)!;
229
+ let prepared = meta.boneMask ? maskedClip(source, meta.boneMask, root, stateId) : source;
230
+ if (prepared !== source || key !== clipName) {
231
+ if (prepared === source) prepared = source.clone();
232
+ prepared.name = `${source.name}@${layerOf(meta)}:${stateId}`;
233
+ }
234
+ if (blendModeOf(meta) === 'additive') {
235
+ THREE.AnimationUtils.makeClipAdditive(prepared);
236
+ prepared.blendMode = THREE.AdditiveAnimationBlendMode;
237
+ } else {
238
+ prepared.blendMode = THREE.NormalAnimationBlendMode;
239
+ }
240
+ preparedClips.set(key, prepared);
241
+ }
242
+ }
243
+
244
+ const actionCache = new Map<string, CachedAction>();
245
+ function ensureAction(
246
+ key: string,
247
+ loop: boolean,
248
+ speed: number,
249
+ weight: number,
250
+ ): THREE.AnimationAction {
251
+ let cached = actionCache.get(key);
252
+ if (!cached) {
253
+ const clip = preparedClips.get(key);
254
+ if (!clip)
255
+ throw new AnimationBindingError(`[bindXStateAnimation] prepared clip "${key}" not found`);
256
+ const action = mixer.clipAction(clip);
257
+ action.setEffectiveWeight(0);
258
+ action.play();
259
+ cached = { action, clip };
260
+ actionCache.set(key, cached);
261
+ }
262
+ cached.action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, Infinity);
263
+ cached.action.clampWhenFinished = !loop;
264
+ cached.action.timeScale = speed;
265
+ cached.action.weight = weight;
266
+ return cached.action;
267
+ }
268
+
269
+ function readParameters(context: unknown): Map<string, number | boolean> {
270
+ const raw = options.selectParameters
271
+ ? options.selectParameters(context)
272
+ : (context as Record<string, number | boolean> | null | undefined);
273
+ return new Map(Object.entries(raw ?? {}));
274
+ }
275
+
276
+ function weightsOf(stateId: string, meta: StateAnimationMeta, context: unknown): WeightedClip[] {
277
+ const weights = isBlendTreeAnimationMeta(meta)
278
+ ? evaluateBlendTree(meta.blendTree as unknown as BlendTreeDef, readParameters(context))
279
+ : [{ clip: meta.clip, weight: 1 }];
280
+ return weights.map(({ clip, weight }) => ({
281
+ clip,
282
+ weight,
283
+ actionKey: actionKey(stateId, meta, clip),
284
+ }));
285
+ }
286
+
287
+ const activeLayers = new Map<string, ActiveState>();
288
+ let disposed = false;
289
+
290
+ function silence(weights: WeightedClip[], except: string | undefined, duration: number): void {
291
+ for (const weighted of weights) {
292
+ if (weighted.actionKey === except) continue;
293
+ const action = actionCache.get(weighted.actionKey)?.action;
294
+ if (!action) continue;
295
+ if (duration > 0) action.fadeOut(duration);
296
+ else action.setEffectiveWeight(0);
297
+ }
298
+ }
299
+
300
+ function applyLiveBlendWeights(state: ActiveState, context: unknown): void {
301
+ if (!isBlendTreeAnimationMeta(state.meta)) return;
302
+ state.natural = weightsOf(state.stateId, state.meta, context);
303
+ const layerWeight = layerWeightOf(state.meta);
304
+ for (const weighted of state.natural) {
305
+ actionCache.get(weighted.actionKey)?.action.setEffectiveWeight(weighted.weight * layerWeight);
306
+ }
307
+ }
308
+
309
+ function enterState(stateId: string, meta: StateAnimationMeta, context: unknown): void {
310
+ const layer = layerOf(meta);
311
+ const outgoing = activeLayers.get(layer) ?? null;
312
+ const weights = weightsOf(stateId, meta, context);
313
+ const dominant = dominantOf(weights);
314
+ const loop = isBlendTreeAnimationMeta(meta) ? true : meta.loop;
315
+ const speed = isBlendTreeAnimationMeta(meta) ? 1 : meta.speed;
316
+ const layerWeight = layerWeightOf(meta);
317
+
318
+ for (const weighted of weights) ensureAction(weighted.actionKey, loop, speed, layerWeight);
319
+ const dominantAction = actionCache.get(dominant.actionKey)!.action;
320
+ dominantAction.reset().setEffectiveWeight(layerWeight);
321
+ for (const weighted of weights) {
322
+ if (weighted.actionKey === dominant.actionKey) continue;
323
+ actionCache.get(weighted.actionKey)!.action.reset().setEffectiveWeight(0);
324
+ }
325
+
326
+ const duration = meta.crossfade?.duration ?? 0;
327
+ const warp = meta.crossfade?.warp ?? false;
328
+ if (!outgoing) {
329
+ if (duration > 0) dominantAction.fadeIn(duration);
330
+ } else {
331
+ silence(outgoing.natural, outgoing.dominantActionKey, duration);
332
+ const previous = actionCache.get(outgoing.dominantActionKey)?.action;
333
+ if (previous && duration > 0) previous.crossFadeTo(dominantAction, duration, warp);
334
+ else {
335
+ previous?.setEffectiveWeight(0);
336
+ dominantAction.setEffectiveWeight(layerWeight);
337
+ }
338
+ }
339
+
340
+ const active: ActiveState = {
341
+ layer,
342
+ stateId,
343
+ meta,
344
+ natural: weights,
345
+ dominantActionKey: dominant.actionKey,
346
+ crossfadeRemaining: duration,
347
+ };
348
+ activeLayers.set(layer, active);
349
+ if (isBlendTreeAnimationMeta(meta) && duration <= 0) applyLiveBlendWeights(active, context);
350
+ }
351
+
352
+ function activeStateIds(snapshot: { getMeta: () => Record<string, unknown> }): string[] {
353
+ return Object.keys(snapshot.getMeta()).filter((id) => metaByStateId.has(id));
354
+ }
355
+
356
+ function reconcile(snapshot: { getMeta: () => Record<string, unknown>; context: unknown }): void {
357
+ const nextByLayer = new Map<string, string>();
358
+ for (const stateId of activeStateIds(snapshot)) {
359
+ const meta = metaByStateId.get(stateId)!;
360
+ const layer = layerOf(meta);
361
+ const previous = nextByLayer.get(layer);
362
+ if (previous) {
363
+ throw new AnimationBindingError(
364
+ `[bindXStateAnimation] animated states "${previous}" and "${stateId}" are both active ` +
365
+ `on layer "${layer}". Give parallel regions distinct meta.animation.layer names.`,
366
+ stateId,
367
+ );
368
+ }
369
+ nextByLayer.set(layer, stateId);
370
+ }
371
+
372
+ for (const [layer, active] of activeLayers) {
373
+ if (nextByLayer.has(layer)) continue;
374
+ silence(active.natural, undefined, 0);
375
+ activeLayers.delete(layer);
376
+ }
377
+ for (const [layer, stateId] of nextByLayer) {
378
+ if (activeLayers.get(layer)?.stateId === stateId) continue;
379
+ enterState(stateId, metaByStateId.get(stateId)!, snapshot.context);
380
+ }
381
+ }
382
+
383
+ const initialSnapshot = actor.getSnapshot();
384
+ reconcile(initialSnapshot);
385
+ const subscription = actor.subscribe((snapshot) => {
386
+ if (!disposed) reconcile(snapshot);
387
+ });
388
+
389
+ function tick(dt: number): void {
390
+ if (disposed) return;
391
+ const context = actor.getSnapshot().context;
392
+ for (const active of activeLayers.values()) {
393
+ if (active.crossfadeRemaining > 0) {
394
+ active.crossfadeRemaining = Math.max(0, active.crossfadeRemaining - dt);
395
+ if (active.crossfadeRemaining <= 0) applyLiveBlendWeights(active, context);
396
+ } else {
397
+ applyLiveBlendWeights(active, context);
398
+ }
399
+ }
400
+ mixer.update(dt);
401
+ }
402
+
403
+ const binding: XStateAnimationBinding = {
404
+ tick,
405
+ actor,
406
+ getActiveLayers: () =>
407
+ [...activeLayers.values()].map((active) => ({
408
+ layer: active.layer,
409
+ stateId: active.stateId,
410
+ clips: active.natural.map((weighted) => weighted.clip),
411
+ weight: layerWeightOf(active.meta),
412
+ blendMode: blendModeOf(active.meta),
413
+ ...(active.meta.boneMask ? { boneMask: active.meta.boneMask } : {}),
414
+ })),
415
+ dispose(): void {
416
+ if (disposed) return;
417
+ disposed = true;
418
+ options.systems?.remove('animation', tick);
419
+ subscription.unsubscribe();
420
+ for (const { action, clip } of actionCache.values()) {
421
+ action.stop();
422
+ mixer.uncacheAction(clip, action.getRoot());
423
+ }
424
+ actionCache.clear();
425
+ activeLayers.clear();
426
+ if (getUserData(owner, '_xstateAnimation') === binding) {
427
+ deleteUserData(owner, '_xstateAnimation');
428
+ notifyInspectionLifecycle();
429
+ }
430
+ },
431
+ };
432
+ setUserData(owner, '_xstateAnimation', binding);
433
+ notifyInspectionLifecycle();
434
+ options.systems?.add('animation', tick);
435
+ return binding;
436
+ }