@forgeax/engine-animation 0.1.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.
Files changed (127) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +141 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/animation-diagnostic.unit.test.d.ts +2 -0
  5. package/dist/__tests__/animation-diagnostic.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/animation-recovery.integration.test.d.ts +2 -0
  7. package/dist/__tests__/animation-recovery.integration.test.d.ts.map +1 -0
  8. package/dist/__tests__/animation-target-id.browser.test.d.ts +2 -0
  9. package/dist/__tests__/animation-target-id.browser.test.d.ts.map +1 -0
  10. package/dist/__tests__/animation-target-id.unit.test.d.ts +2 -0
  11. package/dist/__tests__/animation-target-id.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/animation-target-isolation.integration.test.d.ts +2 -0
  13. package/dist/__tests__/animation-target-isolation.integration.test.d.ts.map +1 -0
  14. package/dist/__tests__/animation-target-relationship.unit.test.d.ts +2 -0
  15. package/dist/__tests__/animation-target-relationship.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/animation-target-source-boundary.unit.test.d.ts +2 -0
  17. package/dist/__tests__/animation-target-source-boundary.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/bind-animation-targets.unit.test.d.ts +2 -0
  19. package/dist/__tests__/bind-animation-targets.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/documentation-contract.unit.test.d.ts +2 -0
  21. package/dist/__tests__/documentation-contract.unit.test.d.ts.map +1 -0
  22. package/dist/__tests__/ecs-consumer.integration.test.d.ts +2 -0
  23. package/dist/__tests__/ecs-consumer.integration.test.d.ts.map +1 -0
  24. package/dist/__tests__/errors.unit.test.d.ts +2 -0
  25. package/dist/__tests__/errors.unit.test.d.ts.map +1 -0
  26. package/dist/__tests__/graph-player.integration.test.d.ts +2 -0
  27. package/dist/__tests__/graph-player.integration.test.d.ts.map +1 -0
  28. package/dist/__tests__/graph-player.unit.test.d.ts +2 -0
  29. package/dist/__tests__/graph-player.unit.test.d.ts.map +1 -0
  30. package/dist/__tests__/handle-errors.unit.test.d.ts +2 -0
  31. package/dist/__tests__/handle-errors.unit.test.d.ts.map +1 -0
  32. package/dist/__tests__/loader-boundary.unit.test.d.ts +2 -0
  33. package/dist/__tests__/loader-boundary.unit.test.d.ts.map +1 -0
  34. package/dist/__tests__/morph-weights-playback.integration.test.d.ts +2 -0
  35. package/dist/__tests__/morph-weights-playback.integration.test.d.ts.map +1 -0
  36. package/dist/__tests__/morph-weights.unit.test.d.ts +2 -0
  37. package/dist/__tests__/morph-weights.unit.test.d.ts.map +1 -0
  38. package/dist/__tests__/public-animation-target-api.test-d.d.ts +2 -0
  39. package/dist/__tests__/public-animation-target-api.test-d.d.ts.map +1 -0
  40. package/dist/__tests__/resolver-ownership.integration.test.d.ts +2 -0
  41. package/dist/__tests__/resolver-ownership.integration.test.d.ts.map +1 -0
  42. package/dist/__tests__/root-surface.unit.test.d.ts +2 -0
  43. package/dist/__tests__/root-surface.unit.test.d.ts.map +1 -0
  44. package/dist/__tests__/scene-instance-target-collection.integration.test.d.ts +2 -0
  45. package/dist/__tests__/scene-instance-target-collection.integration.test.d.ts.map +1 -0
  46. package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts +2 -0
  47. package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts.map +1 -0
  48. package/dist/__tests__/time-delta-regression.integration.test.d.ts +2 -0
  49. package/dist/__tests__/time-delta-regression.integration.test.d.ts.map +1 -0
  50. package/dist/__tests__/transform-target-playback.integration.test.d.ts +2 -0
  51. package/dist/__tests__/transform-target-playback.integration.test.d.ts.map +1 -0
  52. package/dist/animation-diagnostic.d.ts +27 -0
  53. package/dist/animation-diagnostic.d.ts.map +1 -0
  54. package/dist/animation-player.d.ts +13 -0
  55. package/dist/animation-player.d.ts.map +1 -0
  56. package/dist/animation-target.d.ts +21 -0
  57. package/dist/animation-target.d.ts.map +1 -0
  58. package/dist/assets/animation-decoder.d.ts +4 -0
  59. package/dist/assets/animation-decoder.d.ts.map +1 -0
  60. package/dist/chunk-DMXAQUPI.mjs +52 -0
  61. package/dist/chunk-DMXAQUPI.mjs.map +1 -0
  62. package/dist/errors.d.ts +120 -0
  63. package/dist/errors.d.ts.map +1 -0
  64. package/dist/graph/define-animation-graph.d.ts +42 -0
  65. package/dist/graph/define-animation-graph.d.ts.map +1 -0
  66. package/dist/graph/describe-animation-graph.d.ts +37 -0
  67. package/dist/graph/describe-animation-graph.d.ts.map +1 -0
  68. package/dist/graph/serialize-animation-graph.d.ts +27 -0
  69. package/dist/graph/serialize-animation-graph.d.ts.map +1 -0
  70. package/dist/index.d.ts +28 -0
  71. package/dist/index.d.ts.map +1 -0
  72. package/dist/index.mjs +1457 -0
  73. package/dist/index.mjs.map +1 -0
  74. package/dist/player-errors.d.ts +43 -0
  75. package/dist/player-errors.d.ts.map +1 -0
  76. package/dist/plugin.d.ts +13 -0
  77. package/dist/plugin.d.ts.map +1 -0
  78. package/dist/resolve-animation-asset.d.ts +42 -0
  79. package/dist/resolve-animation-asset.d.ts.map +1 -0
  80. package/dist/systems/advance-animation-player.d.ts +40 -0
  81. package/dist/systems/advance-animation-player.d.ts.map +1 -0
  82. package/dist/systems/evaluate-animation-graph.d.ts +32 -0
  83. package/dist/systems/evaluate-animation-graph.d.ts.map +1 -0
  84. package/dist/target-id.d.ts +4 -0
  85. package/dist/target-id.d.ts.map +1 -0
  86. package/dist/target-id.mjs +9 -0
  87. package/dist/target-id.mjs.map +1 -0
  88. package/package.json +65 -0
  89. package/src/__tests__/animation-diagnostic.unit.test.ts +237 -0
  90. package/src/__tests__/animation-recovery.integration.test.ts +125 -0
  91. package/src/__tests__/animation-target-id.browser.test.ts +22 -0
  92. package/src/__tests__/animation-target-id.unit.test.ts +41 -0
  93. package/src/__tests__/animation-target-isolation.integration.test.ts +179 -0
  94. package/src/__tests__/animation-target-relationship.unit.test.ts +65 -0
  95. package/src/__tests__/animation-target-source-boundary.unit.test.ts +49 -0
  96. package/src/__tests__/bind-animation-targets.unit.test.ts +167 -0
  97. package/src/__tests__/documentation-contract.unit.test.ts +94 -0
  98. package/src/__tests__/ecs-consumer.integration.test.ts +10 -0
  99. package/src/__tests__/errors.unit.test.ts +19 -0
  100. package/src/__tests__/graph-player.integration.test.ts +22 -0
  101. package/src/__tests__/graph-player.unit.test.ts +12 -0
  102. package/src/__tests__/handle-errors.unit.test.ts +17 -0
  103. package/src/__tests__/loader-boundary.unit.test.ts +66 -0
  104. package/src/__tests__/morph-weights-playback.integration.test.ts +84 -0
  105. package/src/__tests__/morph-weights.unit.test.ts +26 -0
  106. package/src/__tests__/public-animation-target-api.test-d.ts +47 -0
  107. package/src/__tests__/resolver-ownership.integration.test.ts +99 -0
  108. package/src/__tests__/root-surface.unit.test.ts +10 -0
  109. package/src/__tests__/scene-instance-target-collection.integration.test.ts +26 -0
  110. package/src/__tests__/scriptable-pack-consumer.test-d.ts +8 -0
  111. package/src/__tests__/time-delta-regression.integration.test.ts +79 -0
  112. package/src/__tests__/transform-target-playback.integration.test.ts +198 -0
  113. package/src/animation-diagnostic.ts +88 -0
  114. package/src/animation-player.ts +84 -0
  115. package/src/animation-target.ts +257 -0
  116. package/src/assets/animation-decoder.ts +107 -0
  117. package/src/errors.ts +201 -0
  118. package/src/graph/define-animation-graph.ts +193 -0
  119. package/src/graph/describe-animation-graph.ts +74 -0
  120. package/src/graph/serialize-animation-graph.ts +90 -0
  121. package/src/index.ts +57 -0
  122. package/src/player-errors.ts +72 -0
  123. package/src/plugin.ts +73 -0
  124. package/src/resolve-animation-asset.ts +128 -0
  125. package/src/systems/advance-animation-player.ts +971 -0
  126. package/src/systems/evaluate-animation-graph.ts +261 -0
  127. package/src/target-id.ts +51 -0
@@ -0,0 +1,193 @@
1
+ // @forgeax/engine-animation -- defineAnimationGraph builder + construction-time
2
+ // topology validation.
3
+ //
4
+ // feat-20260713-animation-state-machine-plugin M2 / w14 (plan D-4 landing (2),
5
+ // D-5 construct-time validation):
6
+ //
7
+ // const g = defineAnimationGraph((b) => {
8
+ // const walk = b.clip(walkGuid);
9
+ // const run = b.clip(runGuid);
10
+ // const loco = b.blend([walk, run]); // normalizing
11
+ // const overlay = b.clip(overlayGuid, 0.3);
12
+ // return b.add(loco, [overlay]); // non-normalizing; returns root
13
+ // }); // -> Result<AnimationGraph, AnimationGraphError>
14
+ // if (!g.ok) return; // g.error.code / g.error.hint (AC-11)
15
+ //
16
+ // This is a pure library: NO evaluation system, NO AnimationPlayer change, NO
17
+ // loader/serialization (those are M3/M4). The builder assembles a flat node
18
+ // array and validates topology BEFORE returning ok(graph) -- an illegal graph
19
+ // (empty / out-of-range ref / cycle / invalid weight) fails fast with a
20
+ // structured error (charter P3, requirements AC-11) so it can never be minted
21
+ // into a GUID handle or serialized into a pack (AC-14).
22
+
23
+ import type { AnimationGraph, AnimationGraphNode } from '@forgeax/engine-types';
24
+ import { err, ok, type Result } from '@forgeax/engine-types';
25
+ import {
26
+ AnimationGraphCycleError,
27
+ AnimationGraphEmptyError,
28
+ type AnimationGraphError,
29
+ AnimationGraphNodeOutOfRangeError,
30
+ AnimationGraphNodeWeightInvalidError,
31
+ } from '../errors';
32
+
33
+ /**
34
+ * Opaque reference to a node inside the graph under construction. Branded so a
35
+ * raw number cannot be passed where a node ref is expected without an explicit
36
+ * cast (the illegal-graph tests forge refs deliberately). At runtime it is the
37
+ * node's index in {@link AnimationGraph.nodes}.
38
+ */
39
+ export type AnimationGraphNodeRef = number & {
40
+ readonly __animationGraphNodeRef: unique symbol;
41
+ };
42
+
43
+ /**
44
+ * Declarative builder handed to the `defineAnimationGraph` callback. Exposes
45
+ * exactly the three node kinds (clip / blend / add) + nesting -- no FSM /
46
+ * transition / mask surface is reserved (OOS-1..4, charter F4). Each method
47
+ * appends a node and returns its {@link AnimationGraphNodeRef}.
48
+ */
49
+ export interface AnimationGraphBuilder {
50
+ /** Add a Clip leaf sampling `clip` with the given static `weight` (default 1). */
51
+ clip(clip: string, weight?: number): AnimationGraphNodeRef;
52
+ /** Add a Blend node (normalizing lerp) over `children` with static `weight` (default 1). */
53
+ blend(children: readonly AnimationGraphNodeRef[], weight?: number): AnimationGraphNodeRef;
54
+ /** Add an Add node (non-normalizing) stacking `additive` layers onto `base`; static `weight` default 1. */
55
+ add(
56
+ base: AnimationGraphNodeRef,
57
+ additive: readonly AnimationGraphNodeRef[],
58
+ weight?: number,
59
+ ): AnimationGraphNodeRef;
60
+ }
61
+
62
+ /**
63
+ * Enumerate the child node indices a node references: clip -> none; blend ->
64
+ * its children; add -> `[base, ...additive]`. Exhaustive over the closed node
65
+ * union (charter P3). Shared by the validator here and describeAnimationGraph.
66
+ */
67
+ export function animationGraphNodeChildren(node: AnimationGraphNode): readonly number[] {
68
+ switch (node.type) {
69
+ case 'clip':
70
+ return [];
71
+ case 'blend':
72
+ return node.children;
73
+ case 'add':
74
+ return [node.base, ...node.additive];
75
+ }
76
+ }
77
+
78
+ function makeBuilder(nodes: AnimationGraphNode[]): AnimationGraphBuilder {
79
+ return {
80
+ clip(clip, weight = 1) {
81
+ const index = nodes.length;
82
+ nodes.push({ type: 'clip', clip, weight });
83
+ return index as AnimationGraphNodeRef;
84
+ },
85
+ blend(children, weight = 1) {
86
+ const index = nodes.length;
87
+ nodes.push({ type: 'blend', children: children.slice(), weight });
88
+ return index as AnimationGraphNodeRef;
89
+ },
90
+ add(base, additive, weight = 1) {
91
+ const index = nodes.length;
92
+ nodes.push({ type: 'add', base, additive: additive.slice(), weight });
93
+ return index as AnimationGraphNodeRef;
94
+ },
95
+ };
96
+ }
97
+
98
+ function findInvalidWeight(
99
+ nodes: readonly AnimationGraphNode[],
100
+ ): AnimationGraphNodeWeightInvalidError | null {
101
+ for (let i = 0; i < nodes.length; i++) {
102
+ const weight = nodes[i]?.weight ?? Number.NaN;
103
+ if (!Number.isFinite(weight) || weight < 0) {
104
+ return new AnimationGraphNodeWeightInvalidError({ node: i, weight });
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+
110
+ function isRef(ref: number, nodeCount: number): boolean {
111
+ return Number.isInteger(ref) && ref >= 0 && ref < nodeCount;
112
+ }
113
+
114
+ function findOutOfRangeRef(
115
+ nodes: readonly AnimationGraphNode[],
116
+ root: number,
117
+ ): AnimationGraphNodeOutOfRangeError | null {
118
+ const nodeCount = nodes.length;
119
+ if (!isRef(root, nodeCount)) {
120
+ return new AnimationGraphNodeOutOfRangeError({ node: root, ref: root, nodeCount });
121
+ }
122
+ for (let i = 0; i < nodeCount; i++) {
123
+ const node = nodes[i];
124
+ if (node === undefined) continue;
125
+ for (const ref of animationGraphNodeChildren(node)) {
126
+ if (!isRef(ref, nodeCount)) {
127
+ return new AnimationGraphNodeOutOfRangeError({ node: i, ref, nodeCount });
128
+ }
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+
134
+ function findCycle(nodes: readonly AnimationGraphNode[]): AnimationGraphCycleError | null {
135
+ const WHITE = 0;
136
+ const GRAY = 1;
137
+ const BLACK = 2;
138
+ const color = new Uint8Array(nodes.length);
139
+ let cycleNode = -1;
140
+
141
+ const visit = (i: number): boolean => {
142
+ color[i] = GRAY;
143
+ const node = nodes[i];
144
+ const children = node === undefined ? [] : animationGraphNodeChildren(node);
145
+ for (const child of children) {
146
+ if (color[child] === GRAY) {
147
+ cycleNode = child;
148
+ return true;
149
+ }
150
+ if (color[child] === WHITE && visit(child)) return true;
151
+ }
152
+ color[i] = BLACK;
153
+ return false;
154
+ };
155
+
156
+ for (let i = 0; i < nodes.length; i++) {
157
+ if (color[i] === WHITE && visit(i)) {
158
+ return new AnimationGraphCycleError({ node: cycleNode });
159
+ }
160
+ }
161
+ return null;
162
+ }
163
+
164
+ /**
165
+ * Validate a graph's topology (order: empty -> invalid weight -> out-of-range
166
+ * ref -> cycle). Returns the first structured error found, or null when the
167
+ * graph is a well-formed DAG. Runs before a handle is minted (plan D-5).
168
+ */
169
+ function validateAnimationGraph(
170
+ nodes: readonly AnimationGraphNode[],
171
+ root: number,
172
+ ): AnimationGraphError | null {
173
+ if (nodes.length === 0) return new AnimationGraphEmptyError();
174
+ return findInvalidWeight(nodes) ?? findOutOfRangeRef(nodes, root) ?? findCycle(nodes);
175
+ }
176
+
177
+ /**
178
+ * Construct an {@link AnimationGraph} declaratively. The callback receives a
179
+ * builder, adds clip/blend/add nodes, and returns the ref of the root node.
180
+ * The graph's topology is validated at construction time; on success the POD is
181
+ * returned in `ok(graph)` ready to be minted into a `shared<AnimationGraph>`
182
+ * handle (AC-02) and serialized (AC-14). On an illegal topology a structured
183
+ * {@link AnimationGraphError} is returned in `err(...)` (AC-11).
184
+ */
185
+ export function defineAnimationGraph(
186
+ build: (builder: AnimationGraphBuilder) => AnimationGraphNodeRef,
187
+ ): Result<AnimationGraph, AnimationGraphError> {
188
+ const nodes: AnimationGraphNode[] = [];
189
+ const root = build(makeBuilder(nodes)) as number;
190
+ const error = validateAnimationGraph(nodes, root);
191
+ if (error) return err(error);
192
+ return ok({ kind: 'animation-graph', nodes, root });
193
+ }
@@ -0,0 +1,74 @@
1
+ // @forgeax/engine-animation -- describeAnimationGraph machine-readable introspection.
2
+ //
3
+ // feat-20260713-animation-state-machine-plugin M2 / w15 (plan §8.4, AC-12):
4
+ //
5
+ // const desc = describeAnimationGraph(graph);
6
+ // desc.root; // index of the output node (construction order)
7
+ // desc.nodes; // per-node { index, type, weight, children }
8
+ // desc.staticWeights; // per-node static weight, construction order
9
+ //
10
+ // AI users discover a graph's structure (nodes / topology / root / per-node
11
+ // static weights) WITHOUT reading implementation source -- mirroring the
12
+ // World-local component catalog reflection (research Finding H, charter F1/P1).
13
+ //
14
+ // Presentation order matches the graph POD construction order so that
15
+ // `desc.nodes[i].index === i` aligns with runtime `nodeWeights[i]` /
16
+ // `nodeTimes[i]` / `nodeSpeeds[i]` (verify V-1: introspect-then-drive path for
17
+ // shared/deserialized graphs must not silently remap indices). The builder must
18
+ // create a child ref before referencing it, so construction order is already a
19
+ // valid topological order. This is a pure, read-only projection of the validated
20
+ // POD -- no evaluation, no mutation.
21
+
22
+ import type { AnimationGraph, AnimationGraphNode } from '@forgeax/engine-types';
23
+ import { animationGraphNodeChildren } from './define-animation-graph';
24
+
25
+ /**
26
+ * Machine-readable description of a single graph node: its index in the
27
+ * construction order, its `type` discriminant, its static `weight`, and the
28
+ * child node indices it references (construction order, canonical child ordering).
29
+ */
30
+ export interface AnimationGraphNodeDescription {
31
+ /** Index of this node in {@link AnimationGraphDescription.nodes} (construction order). */
32
+ readonly index: number;
33
+ /** Node kind discriminant (clip / blend / add). */
34
+ readonly type: AnimationGraphNode['type'];
35
+ /** The node's static weight (effective weight = runtime weight x static). */
36
+ readonly weight: number;
37
+ /** Child node indices referenced by this node (clip -> []), construction order. */
38
+ readonly children: readonly number[];
39
+ }
40
+
41
+ /**
42
+ * Machine-readable description of an entire {@link AnimationGraph}: the ordered
43
+ * per-node descriptions (construction order), the root node index, and the
44
+ * per-node static weights (a flat projection of each node's `weight`).
45
+ */
46
+ export interface AnimationGraphDescription {
47
+ /** Per-node descriptions, in graph POD construction order. */
48
+ readonly nodes: readonly AnimationGraphNodeDescription[];
49
+ /** Index of the graph's root (output) node, in construction order. */
50
+ readonly root: number;
51
+ /** Per-node static weights, in construction order. */
52
+ readonly staticWeights: readonly number[];
53
+ }
54
+
55
+ /**
56
+ * Enumerate a validated {@link AnimationGraph}'s nodes, topology, root, and
57
+ * per-node static weights for machine-readable introspection (AC-12). Pure and
58
+ * read-only -- returns a fresh description derived from the POD in construction
59
+ * order so described indices match runtime `nodeWeights` indices.
60
+ */
61
+ export function describeAnimationGraph(graph: AnimationGraph): AnimationGraphDescription {
62
+ const nodes = graph.nodes.map((node, index) => ({
63
+ index,
64
+ type: node.type,
65
+ weight: node.weight,
66
+ children: animationGraphNodeChildren(node),
67
+ }));
68
+
69
+ return {
70
+ nodes,
71
+ root: graph.root,
72
+ staticWeights: nodes.map((node) => node.weight),
73
+ };
74
+ }
@@ -0,0 +1,90 @@
1
+ // @forgeax/engine-animation -- AnimationGraph POD -> pack payload serialization.
2
+ //
3
+ // feat-20260713-animation-state-machine-plugin M4 / w29 (plan D-4 landing (2),
4
+ // §6.3 serialize seam):
5
+ //
6
+ // const out = serializeAnimationGraph(graph, (clipGuid) => clipGuid);
7
+ // if (out === undefined) return; // a durable clip GUID was unavailable
8
+ // // out.payload -> the pack `payload` object; out.refs -> the pack `refs[]`.
9
+ //
10
+ // The inverse of the animation owner's graph contribution:
11
+ // serialize rewrites each Clip leaf's durable GUID into a deduped `refs[]`
12
+ // array (the payload stores the refs INDEX, not a runtime handle) -- the exact
13
+ // "GUID in refs, index in payload" contract
14
+ // the scene/material serialize paths use (plan D-4, asset-registry D-19). Blend
15
+ // and Add nodes carry only intra-graph node indices, which are position-stable
16
+ // under round-trip, so they pass through unchanged. `root` is a node index and
17
+ // passes through too.
18
+ //
19
+ // OOS-7: this serializes only the topology of an engine-authored
20
+ // `defineAnimationGraph` graph (nodes / static weights / clip refs); there is no
21
+ // DCC-import metadata surface.
22
+
23
+ import type { AnimationGraph } from '@forgeax/engine-types';
24
+
25
+ /**
26
+ * Resolves a Clip leaf's durable GUID string to the form stored in Pack
27
+ * `refs[]`. Returns `undefined` when the GUID is unavailable, which aborts
28
+ * serialization.
29
+ * The caller supplies this so serialize stays free of AssetRegistry / World
30
+ * coupling; the graph already carries durable GUID strings at this boundary.
31
+ */
32
+ export type ClipGuidResolver = (clip: string) => string | undefined;
33
+
34
+ /**
35
+ * Output of {@link serializeAnimationGraph}: the pack `payload` object (a flat
36
+ * `{ nodes, root }` shape where Clip leaves reference `refs[]` by index) plus the
37
+ * deduped `refs[]` array of clip GUID strings.
38
+ */
39
+ export interface SerializedAnimationGraph {
40
+ readonly payload: Record<string, unknown>;
41
+ readonly refs: readonly string[];
42
+ }
43
+
44
+ /**
45
+ * Serialize an {@link AnimationGraph} POD into a pack payload + refs pair. Clip
46
+ * leaves are rewritten from durable GUIDs to deduped `refs[]` GUID indices via
47
+ * `resolveClipGuid`; Blend/Add node references and `root` (all intra-graph node
48
+ * indices) pass through. Returns `undefined` if any durable clip GUID cannot be
49
+ * resolved -- an unresolvable clip means the graph cannot be persisted losslessly.
50
+ */
51
+ export function serializeAnimationGraph(
52
+ graph: AnimationGraph,
53
+ resolveClipGuid: ClipGuidResolver = (clip) => clip,
54
+ ): SerializedAnimationGraph | undefined {
55
+ const refs: string[] = [];
56
+ const guidToIndex = new Map<string, number>();
57
+ const internRef = (guid: string): number => {
58
+ const existing = guidToIndex.get(guid);
59
+ if (existing !== undefined) return existing;
60
+ const index = refs.length;
61
+ refs.push(guid);
62
+ guidToIndex.set(guid, index);
63
+ return index;
64
+ };
65
+
66
+ const nodes: Array<Record<string, unknown>> = [];
67
+ for (const node of graph.nodes) {
68
+ switch (node.type) {
69
+ case 'clip': {
70
+ const guid = resolveClipGuid(node.clip);
71
+ if (guid === undefined || guid.length === 0) return undefined;
72
+ nodes.push({ type: 'clip', clip: internRef(guid), weight: node.weight });
73
+ break;
74
+ }
75
+ case 'blend':
76
+ nodes.push({ type: 'blend', children: [...node.children], weight: node.weight });
77
+ break;
78
+ case 'add':
79
+ nodes.push({
80
+ type: 'add',
81
+ base: node.base,
82
+ additive: [...node.additive],
83
+ weight: node.weight,
84
+ });
85
+ break;
86
+ }
87
+ }
88
+
89
+ return { payload: { nodes, root: graph.root }, refs };
90
+ }
package/src/index.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Animation graphs, players, and stable Transform targets.
3
+ *
4
+ * AI consumers should collect explicit entities, call
5
+ * {@link bindAnimationTargets} once, then configure the existing
6
+ * {@link AnimationPlayer} directly or through {@link defineAnimationGraph}.
7
+ * Binding failures expose stable `code`, `hint`, and `detail` fields.
8
+ *
9
+ * @see ../README.md
10
+ */
11
+ export type {
12
+ AnimationDiagnostic,
13
+ AnimationDiagnosticCode,
14
+ AnimationDiagnosticDetail,
15
+ AnimationDiagnosticListener,
16
+ } from './animation-diagnostic';
17
+ export { subscribeAnimationDiagnostics } from './animation-diagnostic';
18
+ export { AnimationPlayer } from './animation-player';
19
+ /** Stable target identity, explicit ownership, and atomic batch binding. */
20
+ export {
21
+ AnimatedBy,
22
+ AnimationTargetId,
23
+ AnimationTargets,
24
+ type BindAnimationTargetsErrorCode,
25
+ bindAnimationTargets,
26
+ } from './animation-target';
27
+ export { animationClipContribution, animationGraphContribution } from './assets/animation-decoder';
28
+ /** Build the existing graph-to-player slot path; this is not an animation FSM. */
29
+ export {
30
+ type AnimationGraphNodeRef,
31
+ animationGraphNodeChildren,
32
+ defineAnimationGraph,
33
+ } from './graph/define-animation-graph';
34
+ export { describeAnimationGraph } from './graph/describe-animation-graph';
35
+ export { serializeAnimationGraph } from './graph/serialize-animation-graph';
36
+ export { animationPayloadsPlugin, animationPlugin, animationRuntimePlugin } from './plugin';
37
+ export {
38
+ AnimationAssetError,
39
+ type AnimationAssetErrorCode,
40
+ type AnimationAssetErrorDetail,
41
+ resolveAnimationAsset,
42
+ } from './resolve-animation-asset';
43
+ export {
44
+ _resetAnimationWarnsForTests,
45
+ ADVANCE_ANIMATION_PLAYER_SYSTEM,
46
+ AdvanceAnimationPlayer,
47
+ AnimationSet,
48
+ advanceAnimationPlayer,
49
+ registerAdvanceAnimationPlayer,
50
+ } from './systems/advance-animation-player';
51
+ export type { AnimationPayloadLookup } from './systems/evaluate-animation-graph';
52
+ export {
53
+ EvaluateAnimationGraph,
54
+ evaluateAnimationGraph,
55
+ } from './systems/evaluate-animation-graph';
56
+ /** Derive or validate the canonical 32-lowercase-hex animation target wire. */
57
+ export { deriveAnimationTargetId, isAnimationTargetId } from './target-id';
@@ -0,0 +1,72 @@
1
+ // @forgeax/engine-animation -- AnimationPlayer cluster error classes.
2
+ //
3
+ // feat-20260713-animation-state-machine-plugin M1 / w5: the AnimationPlayer SoA
4
+ // columns (clips / times / weights / speeds) became variable `array<T>` when the
5
+ // fixed 4-slot cap was retired (w4). Variable columns are set field-by-field
6
+ // (release-then-alloc per field, plan D-5), so the ECS layer cannot cross-check
7
+ // that the four parallel columns stay length-synced. advanceAnimationPlayer
8
+ // validates the four lengths once per row at its evaluation entry (the single
9
+ // chokepoint, D-5) and rejects a mismatch with the structured error below rather
10
+ // than silently zero-padding or truncating (requirements AC-11).
11
+
12
+ // -- AnimationPlayerSlotLengthMismatchError --------------------------------------
13
+
14
+ /**
15
+ * Detail for `'animation-player-slot-length-mismatch'`.
16
+ *
17
+ * Carries the observed length of each of the four parallel SoA columns so an
18
+ * AI consumer can see which column desynced without parsing prose.
19
+ */
20
+ export interface AnimationPlayerSlotLengthMismatchDetail {
21
+ readonly entity: number;
22
+ readonly clips: number;
23
+ readonly times: number;
24
+ readonly weights: number;
25
+ readonly speeds: number;
26
+ }
27
+
28
+ /**
29
+ * Structured error for AnimationPlayer parallel-column length disagreement.
30
+ *
31
+ * Emitted at advanceAnimationPlayer's evaluation entry when an entity's
32
+ * `clips` / `times` / `weights` / `speeds` columns do not all share the same
33
+ * length. Four-field surface (mirrors the errors/skin.ts convention):
34
+ * - `.code = 'animation-player-slot-length-mismatch'`
35
+ * - `.expected` -- clips/times/weights/speeds share one length
36
+ * - `.hint` -- write all four columns length-synced on every AnimationPlayer
37
+ * write (variable columns do not tail-pad a short write)
38
+ * - `.detail = { entity, clips, times, weights, speeds }`
39
+ */
40
+ export class AnimationPlayerSlotLengthMismatchError extends Error {
41
+ readonly code = 'animation-player-slot-length-mismatch' as const;
42
+ readonly expected: string;
43
+ readonly hint: string;
44
+ readonly detail: AnimationPlayerSlotLengthMismatchDetail;
45
+
46
+ constructor(detail: AnimationPlayerSlotLengthMismatchDetail) {
47
+ const { entity, clips, times, weights, speeds } = detail;
48
+ const expected = 'AnimationPlayer clips/times/weights/speeds columns share one length';
49
+ const hint = `entity ${entity} AnimationPlayer columns are length-desynced (clips=${clips}, times=${times}, weights=${weights}, speeds=${speeds}); write all four parallel columns at the same length on every AnimationPlayer write -- a variable array<T> column does not tail-pad a short write`;
50
+ super(
51
+ `AnimationPlayer slot length mismatch on entity ${entity}: clips=${clips}, times=${times}, weights=${weights}, speeds=${speeds}`,
52
+ );
53
+ this.name = 'AnimationPlayerSlotLengthMismatchError';
54
+ this.expected = expected;
55
+ this.hint = hint;
56
+ this.detail = detail;
57
+ }
58
+ }
59
+
60
+ // -- AnimationPlayerErrorCode / AnimationPlayerError closed unions ----------------
61
+
62
+ /**
63
+ * Closed union of AnimationPlayer-cluster error codes. AI users perform
64
+ * exhaustive `switch (err.code)` without default; TS guards completeness.
65
+ */
66
+ export type AnimationPlayerErrorCode = 'animation-player-slot-length-mismatch';
67
+
68
+ /**
69
+ * Closed union of the AnimationPlayer-cluster structured error classes, each
70
+ * carrying an `AnimationPlayerErrorCode` discriminant on `.code`.
71
+ */
72
+ export type AnimationPlayerError = AnimationPlayerSlotLengthMismatchError;
package/src/plugin.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type { Component, World } from '@forgeax/engine-ecs';
2
+ import type { Plugin } from '@forgeax/engine-plugin';
3
+ import { AnimationPlayer } from './animation-player';
4
+ import { AnimatedBy, AnimationTargetId, AnimationTargets } from './animation-target';
5
+ import { registerAdvanceAnimationPlayer } from './systems/advance-animation-player';
6
+ import {
7
+ type AnimationPayloadLookup,
8
+ registerEvaluateAnimationGraph,
9
+ } from './systems/evaluate-animation-graph';
10
+
11
+ const ANIMATION_COMPONENTS: readonly Component[] = [
12
+ AnimationPlayer,
13
+ AnimatedBy,
14
+ AnimationTargetId,
15
+ AnimationTargets,
16
+ ];
17
+
18
+ function registerAnimationComponents(world: World): () => void {
19
+ const leases = ANIMATION_COMPONENTS.map((component) =>
20
+ world.components.register(component).unwrap(),
21
+ );
22
+ return () => {
23
+ for (let index = leases.length - 1; index >= 0; index -= 1) leases[index]?.dispose();
24
+ };
25
+ }
26
+
27
+ declare module '@forgeax/engine-plugin' {
28
+ interface EngineContextServices {
29
+ animationPayloads?: AnimationPayloadLookup;
30
+ }
31
+ }
32
+
33
+ /** Provide the realm's asset-backed animation payload resolver. */
34
+ export function animationPayloadsPlugin(lookup: AnimationPayloadLookup): Plugin {
35
+ return {
36
+ name: 'animation-payloads',
37
+ provide: 'animationPayloads',
38
+ apply(ctx) {
39
+ ctx.provide('animationPayloads', lookup);
40
+ },
41
+ };
42
+ }
43
+
44
+ /** Install animation behavior from declared World and payload services. */
45
+ export function animationRuntimePlugin(): Plugin {
46
+ return {
47
+ name: 'animation',
48
+ inject: ['world', 'animationPayloads'],
49
+ apply(ctx) {
50
+ ctx.effect(() => registerAnimationComponents(ctx.world), 'animation/components');
51
+ ctx.effect(
52
+ () => registerEvaluateAnimationGraph(ctx.world, ctx.animationPayloads),
53
+ 'animation/evaluate-graph',
54
+ );
55
+ ctx.effect(() => registerAdvanceAnimationPlayer(ctx.world), 'animation/advance-player');
56
+ },
57
+ };
58
+ }
59
+
60
+ export function animationPlugin(lookup?: AnimationPayloadLookup): Plugin {
61
+ return {
62
+ name: 'animation',
63
+ inject: ['world'],
64
+ apply(ctx) {
65
+ ctx.effect(() => registerAnimationComponents(ctx.world), 'animation/components');
66
+ ctx.effect(
67
+ () => registerEvaluateAnimationGraph(ctx.world, lookup),
68
+ 'animation/evaluate-graph',
69
+ );
70
+ ctx.effect(() => registerAdvanceAnimationPlayer(ctx.world), 'animation/advance-player');
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,128 @@
1
+ import type { World } from '@forgeax/engine-ecs';
2
+ import type { Asset, Handle, Result } from '@forgeax/engine-types';
3
+ import { err, ok } from '@forgeax/engine-types';
4
+
5
+ /** Closed animation-domain failure codes for durable GUID projection. */
6
+ export type AnimationAssetErrorCode =
7
+ | 'animation-asset-not-found'
8
+ | 'animation-asset-stale'
9
+ | 'animation-asset-kind-mismatch';
10
+
11
+ export interface AnimationAssetErrorDetail {
12
+ readonly guid: string;
13
+ readonly expectedKind: string;
14
+ readonly actualKind?: string;
15
+ readonly lookupCode: string;
16
+ }
17
+
18
+ type AnimationAssetTarget<T extends Asset> = T extends { readonly kind: 'animation-clip' }
19
+ ? 'AnimationClip'
20
+ : 'AnimationGraph';
21
+
22
+ /** Structured failure at the animation consumer boundary. */
23
+ export class AnimationAssetError extends Error {
24
+ readonly code: AnimationAssetErrorCode;
25
+ readonly expected: string;
26
+ readonly hint: string;
27
+ readonly detail: AnimationAssetErrorDetail;
28
+
29
+ constructor(args: {
30
+ code: AnimationAssetErrorCode;
31
+ expected: string;
32
+ hint: string;
33
+ detail: AnimationAssetErrorDetail;
34
+ }) {
35
+ super(`[AnimationAssetError ${args.code}] expected: ${args.expected}; hint: ${args.hint}`);
36
+ this.name = 'AnimationAssetError';
37
+ this.code = args.code;
38
+ this.expected = args.expected;
39
+ this.hint = args.hint;
40
+ this.detail = args.detail;
41
+ }
42
+ }
43
+
44
+ export interface ResolvedAnimationAsset<T extends Asset> {
45
+ readonly guid: string;
46
+ readonly asset: T;
47
+ readonly handle: Handle<AnimationAssetTarget<T>, 'shared'>;
48
+ }
49
+
50
+ export type AnimationAssetLookup<T extends Asset> = T | { readonly code: 'stale' } | undefined;
51
+
52
+ /**
53
+ * Resolve a durable GUID through the caller's existing payload owner, then
54
+ * project that payload into the current World. The callback is deliberately a
55
+ * lookup seam, not a registry: AssetRegistry remains GUID -> payload only.
56
+ */
57
+ export function resolveAnimationAsset<T extends Asset>(
58
+ world: World,
59
+ guid: string,
60
+ expectedKind: T['kind'],
61
+ lookup: (guid: string) => AnimationAssetLookup<T>,
62
+ ): Result<ResolvedAnimationAsset<T>, AnimationAssetError> {
63
+ if (guid.length === 0) {
64
+ return err(
65
+ new AnimationAssetError({
66
+ code: 'animation-asset-not-found',
67
+ expected: `a durable ${expectedKind} GUID`,
68
+ hint: 'load the animation asset by GUID before evaluating the graph',
69
+ detail: { guid, expectedKind, lookupCode: 'guid-empty' },
70
+ }),
71
+ );
72
+ }
73
+ let asset: AnimationAssetLookup<T>;
74
+ try {
75
+ asset = lookup(guid);
76
+ } catch {
77
+ return err(
78
+ new AnimationAssetError({
79
+ code: 'animation-asset-not-found',
80
+ expected: `a loaded ${expectedKind} payload for GUID ${guid}`,
81
+ hint: 'repair the animation asset lookup provider and retry resolution',
82
+ detail: { guid, expectedKind, lookupCode: 'lookup-threw' },
83
+ }),
84
+ );
85
+ }
86
+ if (asset !== undefined && 'code' in asset && asset.code === 'stale') {
87
+ return err(
88
+ new AnimationAssetError({
89
+ code: 'animation-asset-stale',
90
+ expected: `a current ${expectedKind} payload for GUID ${guid}`,
91
+ hint: 'rebuild the stale asset projection before evaluating the graph',
92
+ detail: { guid, expectedKind, lookupCode: 'asset-stale' },
93
+ }),
94
+ );
95
+ }
96
+ if (asset === undefined) {
97
+ return err(
98
+ new AnimationAssetError({
99
+ code: 'animation-asset-not-found',
100
+ expected: `a loaded ${expectedKind} payload for GUID ${guid}`,
101
+ hint: 'load or retain the referenced animation asset before evaluating the graph',
102
+ detail: { guid, expectedKind, lookupCode: 'asset-not-found' },
103
+ }),
104
+ );
105
+ }
106
+ const payload = asset as T;
107
+ if (payload.kind !== expectedKind) {
108
+ return err(
109
+ new AnimationAssetError({
110
+ code: 'animation-asset-kind-mismatch',
111
+ expected: `asset kind '${expectedKind}'`,
112
+ hint: `replace GUID ${guid} with a loaded ${expectedKind} asset`,
113
+ detail: {
114
+ guid,
115
+ expectedKind,
116
+ actualKind: payload.kind,
117
+ lookupCode: 'asset-kind-mismatch',
118
+ },
119
+ }),
120
+ );
121
+ }
122
+ const target = expectedKind === 'animation-clip' ? 'AnimationClip' : 'AnimationGraph';
123
+ const handle = world.internSharedRef(target, payload) as Handle<
124
+ AnimationAssetTarget<T>,
125
+ 'shared'
126
+ >;
127
+ return ok({ guid, asset: payload, handle });
128
+ }