@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
package/dist/index.mjs ADDED
@@ -0,0 +1,1457 @@
1
+ import {
2
+ deriveAnimationTargetId,
3
+ isAnimationTargetId
4
+ } from "./chunk-DMXAQUPI.mjs";
5
+
6
+ // src/animation-diagnostic.ts
7
+ var emittedKeysByWorld = /* @__PURE__ */ new WeakMap();
8
+ var listeners = /* @__PURE__ */ new Set();
9
+ function subscribeAnimationDiagnostics(listener) {
10
+ listeners.add(listener);
11
+ return () => listeners.delete(listener);
12
+ }
13
+ function isAnimationDevMode() {
14
+ const proc = globalThis.process;
15
+ if (proc?.env?.NODE_ENV === "production") return false;
16
+ return Boolean(import.meta.env?.DEV) || proc !== void 0;
17
+ }
18
+ function emitAnimationDiagnostic(world, diagnostic) {
19
+ if (!isAnimationDevMode()) return;
20
+ let emitted = emittedKeysByWorld.get(world);
21
+ if (emitted === void 0) {
22
+ emitted = /* @__PURE__ */ new Set();
23
+ emittedKeysByWorld.set(world, emitted);
24
+ }
25
+ const { player, clip, channel, targetId, reason } = diagnostic.detail;
26
+ const emittedKey = `${player}|${clip}|${channel}|${targetId}|${reason}`;
27
+ if (emitted.has(emittedKey)) return;
28
+ emitted.add(emittedKey);
29
+ const frozen = Object.freeze({
30
+ ...diagnostic,
31
+ detail: Object.freeze({ ...diagnostic.detail })
32
+ });
33
+ for (const listener of listeners) {
34
+ try {
35
+ listener(world, frozen);
36
+ } catch {
37
+ }
38
+ }
39
+ console.warn(frozen);
40
+ }
41
+ function _resetAnimationWarnsForTests(world) {
42
+ emittedKeysByWorld.delete(world);
43
+ }
44
+
45
+ // src/animation-player.ts
46
+ import { defineComponent } from "@forgeax/engine-ecs";
47
+ var AnimationPlayer = defineComponent("AnimationPlayer", {
48
+ // The render/animation owner re-resolves clip assets in the target World;
49
+ // playback clocks and weights remain portable simulation state.
50
+ clips: { type: "array<shared<AnimationClip>>" },
51
+ times: { type: "array<f32>" },
52
+ weights: { type: "array<f32>" },
53
+ speeds: { type: "array<f32>" },
54
+ // The graph evaluator owns this compiled runtime binding; portable playback
55
+ // controls and derived slots remain available to the simulation record.
56
+ graph: { type: "shared<AnimationGraph>" },
57
+ nodeWeights: { type: "array<f32>" },
58
+ nodeTimes: { type: "array<f32>" },
59
+ nodeSpeeds: { type: "array<f32>" },
60
+ paused: { type: "bool", default: false },
61
+ looping: { type: "bool", default: true }
62
+ });
63
+
64
+ // src/animation-target.ts
65
+ import {
66
+ defineComponent as defineComponent2,
67
+ defineRelationship,
68
+ Entity
69
+ } from "@forgeax/engine-ecs";
70
+ import { ChildOf, Name, Transform } from "@forgeax/engine-scene";
71
+ import { err, ok } from "@forgeax/engine-types";
72
+ var AnimationTargetId = defineComponent2("AnimationTargetId", {
73
+ value: { type: "string" }
74
+ });
75
+ var { target: AnimationTargets, source: AnimatedBy } = defineRelationship({
76
+ sourceName: "AnimatedBy",
77
+ sourceField: "player",
78
+ targetName: "AnimationTargets",
79
+ targetField: "targets",
80
+ exclusive: true,
81
+ linkedSpawn: false,
82
+ allowSelf: true
83
+ });
84
+ var BindAnimationTargetsError = class extends Error {
85
+ constructor(code, expected, hint, detail) {
86
+ super(`[BindAnimationTargetsError ${code}] expected: ${expected}; hint: ${hint}`);
87
+ this.code = code;
88
+ this.expected = expected;
89
+ this.hint = hint;
90
+ this.detail = detail;
91
+ }
92
+ code;
93
+ expected;
94
+ hint;
95
+ detail;
96
+ name = "BindAnimationTargetsError";
97
+ };
98
+ function bindError(code, expected, hint, detail) {
99
+ return err(new BindAnimationTargetsError(code, expected, hint, detail));
100
+ }
101
+ function targetLineage(world, player, target) {
102
+ const reversed = [];
103
+ const visited = /* @__PURE__ */ new Set();
104
+ let current = target;
105
+ while (!visited.has(current)) {
106
+ visited.add(current);
107
+ reversed.push(current);
108
+ if (current === player) return ok(reversed.reverse());
109
+ const parent = world.get(current, ChildOf);
110
+ if (!parent.ok || parent.value.parent === null) break;
111
+ current = parent.value.parent;
112
+ }
113
+ return bindError(
114
+ "animation-target-outside-player-root",
115
+ "the target to be the player itself or one of its descendants",
116
+ "parent the target below the animation player or bind it to the correct player",
117
+ { player, target }
118
+ );
119
+ }
120
+ function inspectTarget(world, player, target) {
121
+ if (!world.get(target, Entity).ok || !world.get(target, Transform).ok) {
122
+ return bindError(
123
+ "animation-target-invalid",
124
+ "a live target entity carrying Transform",
125
+ "spawn or retain the Transform target before binding it",
126
+ { player, target }
127
+ );
128
+ }
129
+ const lineage = targetLineage(world, player, target);
130
+ if (!lineage.ok) return lineage;
131
+ const storedId = world.get(target, AnimationTargetId);
132
+ let id;
133
+ if (storedId.ok) {
134
+ if (!isAnimationTargetId(storedId.value.value)) {
135
+ return bindError(
136
+ "animation-target-id-invalid",
137
+ "a 32-character lowercase hexadecimal AnimationTargetId wire",
138
+ "replace the stored value with deriveAnimationTargetId(path)",
139
+ { target, value: storedId.value.value }
140
+ );
141
+ }
142
+ id = storedId.value.value;
143
+ } else {
144
+ const path = [];
145
+ for (const entity of lineage.value) {
146
+ const name = world.get(entity, Name);
147
+ if (!name.ok) {
148
+ return bindError(
149
+ "animation-target-name-missing",
150
+ "a Name on every entity from the animation root through the target",
151
+ "attach Name before deriving the target ID",
152
+ { player, target, entity }
153
+ );
154
+ }
155
+ path.push(name.value.value);
156
+ }
157
+ id = deriveAnimationTargetId(path);
158
+ }
159
+ const owner = world.get(target, AnimatedBy);
160
+ if (owner.ok && owner.value.player !== null && owner.value.player !== player && world.get(owner.value.player, Entity).ok) {
161
+ return bindError(
162
+ "animation-target-player-conflict",
163
+ "an unowned target, a stale owner, or the same animation player",
164
+ "remove AnimatedBy or bind the target through its current live owner",
165
+ {
166
+ player,
167
+ target,
168
+ owner: owner.value.player
169
+ }
170
+ );
171
+ }
172
+ return ok({
173
+ entity: target,
174
+ id,
175
+ needsId: !storedId.ok,
176
+ owner: owner.ok ? owner.value.player : void 0
177
+ });
178
+ }
179
+ function bindAnimationTargets(world, player, targets) {
180
+ if (!world.get(player, Entity).ok || !world.get(player, AnimationPlayer).ok) {
181
+ return bindError(
182
+ "animation-target-player-invalid",
183
+ "a live entity carrying AnimationPlayer",
184
+ "spawn or retain the AnimationPlayer before binding targets",
185
+ { player }
186
+ );
187
+ }
188
+ const uniqueTargets = [...new Map(targets.map((target) => [target, target])).values()];
189
+ const mirror = world.get(player, AnimationTargets);
190
+ const candidates = [];
191
+ const ids = /* @__PURE__ */ new Map();
192
+ if (mirror.ok) {
193
+ for (const targetRaw of mirror.value.targets) {
194
+ const target = targetRaw;
195
+ if (!world.get(target, Entity).ok) continue;
196
+ const storedId = world.get(target, AnimationTargetId);
197
+ if (!storedId.ok || !isAnimationTargetId(storedId.value.value)) continue;
198
+ const matching = ids.get(storedId.value.value);
199
+ if (matching) {
200
+ if (!matching.includes(target)) matching.push(target);
201
+ } else {
202
+ ids.set(storedId.value.value, [target]);
203
+ }
204
+ }
205
+ }
206
+ for (const target of uniqueTargets) {
207
+ const candidate = inspectTarget(world, player, target);
208
+ if (!candidate.ok) return candidate;
209
+ const matching = ids.get(candidate.value.id);
210
+ const previous = matching?.find((entity) => entity !== target);
211
+ if (previous !== void 0) {
212
+ return bindError(
213
+ "animation-target-id-duplicate",
214
+ "one target entity per AnimationTargetId within a batch",
215
+ "rename one target path or preserve distinct authored target IDs",
216
+ {
217
+ id: candidate.value.id,
218
+ first: previous,
219
+ second: target
220
+ }
221
+ );
222
+ }
223
+ if (matching) {
224
+ if (!matching.includes(target)) matching.push(target);
225
+ } else {
226
+ ids.set(candidate.value.id, [target]);
227
+ }
228
+ candidates.push(candidate.value);
229
+ }
230
+ const existing = new Set(mirror.ok ? [...mirror.value.targets] : []);
231
+ for (const candidate of candidates) {
232
+ if (candidate.needsId) {
233
+ const added = world.addComponent(candidate.entity, {
234
+ component: AnimationTargetId,
235
+ data: { value: candidate.id }
236
+ });
237
+ if (!added.ok) {
238
+ return bindError(
239
+ "animation-target-bind-failed",
240
+ "the preflighted AnimationTargetId write to succeed",
241
+ "inspect the ECS error before retrying the batch",
242
+ { target: candidate.entity, cause: added.error.code }
243
+ );
244
+ }
245
+ }
246
+ if (candidate.owner !== player || !existing.has(candidate.entity)) {
247
+ const added = world.addComponent(candidate.entity, {
248
+ component: AnimatedBy,
249
+ data: { player }
250
+ });
251
+ if (!added.ok) {
252
+ return bindError(
253
+ "animation-target-bind-failed",
254
+ "the preflighted AnimatedBy write to succeed",
255
+ "inspect the ECS error before retrying the batch",
256
+ { target: candidate.entity, cause: added.error.code }
257
+ );
258
+ }
259
+ }
260
+ }
261
+ return ok(void 0);
262
+ }
263
+
264
+ // src/assets/animation-decoder.ts
265
+ import {
266
+ err as err2,
267
+ ok as ok2
268
+ } from "@forgeax/engine-types";
269
+ var invalid = (guid, expected) => err2({
270
+ code: "asset-package-invalid",
271
+ expected,
272
+ hint: "recook the animation asset and publish its complete payload",
273
+ detail: { guid, reason: "animation owner validation failed" }
274
+ });
275
+ function refGuid(value, refs) {
276
+ if (typeof value === "string" && value.length > 0) return value;
277
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) {
278
+ return refs[value];
279
+ }
280
+ return void 0;
281
+ }
282
+ function graphNode(value, refs) {
283
+ if (value === null || typeof value !== "object") return void 0;
284
+ const source = value;
285
+ if (typeof source.weight !== "number" || !Number.isFinite(source.weight)) return void 0;
286
+ if (source.type === "clip") {
287
+ const clip = refGuid(source.clip, refs);
288
+ return clip === void 0 ? void 0 : { type: "clip", clip, weight: source.weight };
289
+ }
290
+ if (source.type === "blend") {
291
+ return Array.isArray(source.children) && source.children.every((child) => Number.isSafeInteger(child) && child >= 0) ? { type: "blend", children: source.children, weight: source.weight } : void 0;
292
+ }
293
+ if (source.type === "add") {
294
+ return typeof source.base === "number" && Number.isSafeInteger(source.base) && source.base >= 0 && Array.isArray(source.additive) && source.additive.every((child) => Number.isSafeInteger(child) && child >= 0) ? {
295
+ type: "add",
296
+ base: source.base,
297
+ additive: source.additive,
298
+ weight: source.weight
299
+ } : void 0;
300
+ }
301
+ return void 0;
302
+ }
303
+ var animationClipContribution = {
304
+ kind: { kind: "animation-clip" },
305
+ consumer: "AnimationPlayer",
306
+ decoder: {
307
+ async decode({ envelope }) {
308
+ return envelope.payload.kind === "animation-clip" && Array.isArray(envelope.payload.channels) && Number.isFinite(envelope.payload.duration) && envelope.payload.duration >= 0 ? ok2(envelope.payload) : invalid(envelope.guid, "an animation clip with channels and non-negative duration");
309
+ }
310
+ }
311
+ };
312
+ var animationGraphContribution = {
313
+ kind: { kind: "animation-graph" },
314
+ consumer: "evaluateAnimationGraph",
315
+ decoder: {
316
+ async decode({ envelope }) {
317
+ const payload = envelope.payload;
318
+ if (payload === null || typeof payload !== "object") {
319
+ return invalid(envelope.guid, "an animation graph with at least one node");
320
+ }
321
+ const source = payload;
322
+ if (source.kind !== "animation-graph" || !Array.isArray(source.nodes) || source.nodes.length === 0 || !Number.isSafeInteger(source.root) || source.root < 0) {
323
+ return invalid(envelope.guid, "an animation graph with at least one node");
324
+ }
325
+ const nodes = [];
326
+ for (const value of source.nodes) {
327
+ const node = graphNode(value, envelope.refs);
328
+ if (node === void 0) {
329
+ return invalid(envelope.guid, "an animation graph with resolvable node references");
330
+ }
331
+ nodes.push(node);
332
+ }
333
+ return ok2({ kind: "animation-graph", nodes, root: source.root });
334
+ }
335
+ }
336
+ };
337
+
338
+ // src/graph/define-animation-graph.ts
339
+ import { err as err3, ok as ok3 } from "@forgeax/engine-types";
340
+
341
+ // src/errors.ts
342
+ var AnimationGraphEmptyError = class extends Error {
343
+ code = "animation-graph-empty";
344
+ expected;
345
+ hint;
346
+ constructor() {
347
+ super("AnimationGraph is empty: the builder produced zero nodes");
348
+ this.name = "AnimationGraphEmptyError";
349
+ this.expected = "an AnimationGraph declares at least one node";
350
+ this.hint = "declare at least one clip/blend/add node inside defineAnimationGraph(...) and return its ref as the root";
351
+ }
352
+ };
353
+ var AnimationGraphNodeOutOfRangeError = class extends Error {
354
+ code = "animation-graph-node-out-of-range";
355
+ expected;
356
+ hint;
357
+ detail;
358
+ constructor(detail) {
359
+ const { node, ref, nodeCount } = detail;
360
+ super(
361
+ `AnimationGraph node ${node} references out-of-range node ${ref} (graph has ${nodeCount} nodes)`
362
+ );
363
+ this.name = "AnimationGraphNodeOutOfRangeError";
364
+ this.expected = "every node reference is a valid index in [0, nodeCount)";
365
+ this.hint = `node ${node} references index ${ref}, but valid indices are 0..${nodeCount - 1}; reference only node refs returned by the builder in this graph`;
366
+ this.detail = detail;
367
+ }
368
+ };
369
+ var AnimationGraphNodeWeightInvalidError = class extends Error {
370
+ code = "animation-graph-node-weight-invalid";
371
+ expected;
372
+ hint;
373
+ detail;
374
+ constructor(detail) {
375
+ const { node, weight } = detail;
376
+ super(`AnimationGraph node ${node} has an invalid static weight ${weight}`);
377
+ this.name = "AnimationGraphNodeWeightInvalidError";
378
+ this.expected = "every node static weight is a finite number >= 0";
379
+ this.hint = `node ${node} has weight ${weight}; pass a finite non-negative static weight (Add layers may sum above 1, but a single node weight must be >= 0)`;
380
+ this.detail = detail;
381
+ }
382
+ };
383
+ var AnimationGraphCycleError = class extends Error {
384
+ code = "animation-graph-cycle";
385
+ expected;
386
+ hint;
387
+ detail;
388
+ constructor(detail) {
389
+ const { node } = detail;
390
+ super(`AnimationGraph contains a cycle reachable from node ${node}`);
391
+ this.name = "AnimationGraphCycleError";
392
+ this.expected = "the AnimationGraph is a DAG (no node reaches itself)";
393
+ this.hint = `node ${node} participates in a cycle; a graph must be acyclic -- remove the self/back reference so no node transitively references itself`;
394
+ this.detail = detail;
395
+ }
396
+ };
397
+
398
+ // src/graph/define-animation-graph.ts
399
+ function animationGraphNodeChildren(node) {
400
+ switch (node.type) {
401
+ case "clip":
402
+ return [];
403
+ case "blend":
404
+ return node.children;
405
+ case "add":
406
+ return [node.base, ...node.additive];
407
+ }
408
+ }
409
+ function makeBuilder(nodes) {
410
+ return {
411
+ clip(clip, weight = 1) {
412
+ const index = nodes.length;
413
+ nodes.push({ type: "clip", clip, weight });
414
+ return index;
415
+ },
416
+ blend(children, weight = 1) {
417
+ const index = nodes.length;
418
+ nodes.push({ type: "blend", children: children.slice(), weight });
419
+ return index;
420
+ },
421
+ add(base, additive, weight = 1) {
422
+ const index = nodes.length;
423
+ nodes.push({ type: "add", base, additive: additive.slice(), weight });
424
+ return index;
425
+ }
426
+ };
427
+ }
428
+ function findInvalidWeight(nodes) {
429
+ for (let i = 0; i < nodes.length; i++) {
430
+ const weight = nodes[i]?.weight ?? Number.NaN;
431
+ if (!Number.isFinite(weight) || weight < 0) {
432
+ return new AnimationGraphNodeWeightInvalidError({ node: i, weight });
433
+ }
434
+ }
435
+ return null;
436
+ }
437
+ function isRef(ref, nodeCount) {
438
+ return Number.isInteger(ref) && ref >= 0 && ref < nodeCount;
439
+ }
440
+ function findOutOfRangeRef(nodes, root) {
441
+ const nodeCount = nodes.length;
442
+ if (!isRef(root, nodeCount)) {
443
+ return new AnimationGraphNodeOutOfRangeError({ node: root, ref: root, nodeCount });
444
+ }
445
+ for (let i = 0; i < nodeCount; i++) {
446
+ const node = nodes[i];
447
+ if (node === void 0) continue;
448
+ for (const ref of animationGraphNodeChildren(node)) {
449
+ if (!isRef(ref, nodeCount)) {
450
+ return new AnimationGraphNodeOutOfRangeError({ node: i, ref, nodeCount });
451
+ }
452
+ }
453
+ }
454
+ return null;
455
+ }
456
+ function findCycle(nodes) {
457
+ const WHITE = 0;
458
+ const GRAY = 1;
459
+ const BLACK = 2;
460
+ const color = new Uint8Array(nodes.length);
461
+ let cycleNode = -1;
462
+ const visit = (i) => {
463
+ color[i] = GRAY;
464
+ const node = nodes[i];
465
+ const children = node === void 0 ? [] : animationGraphNodeChildren(node);
466
+ for (const child of children) {
467
+ if (color[child] === GRAY) {
468
+ cycleNode = child;
469
+ return true;
470
+ }
471
+ if (color[child] === WHITE && visit(child)) return true;
472
+ }
473
+ color[i] = BLACK;
474
+ return false;
475
+ };
476
+ for (let i = 0; i < nodes.length; i++) {
477
+ if (color[i] === WHITE && visit(i)) {
478
+ return new AnimationGraphCycleError({ node: cycleNode });
479
+ }
480
+ }
481
+ return null;
482
+ }
483
+ function validateAnimationGraph(nodes, root) {
484
+ if (nodes.length === 0) return new AnimationGraphEmptyError();
485
+ return findInvalidWeight(nodes) ?? findOutOfRangeRef(nodes, root) ?? findCycle(nodes);
486
+ }
487
+ function defineAnimationGraph(build) {
488
+ const nodes = [];
489
+ const root = build(makeBuilder(nodes));
490
+ const error = validateAnimationGraph(nodes, root);
491
+ if (error) return err3(error);
492
+ return ok3({ kind: "animation-graph", nodes, root });
493
+ }
494
+
495
+ // src/graph/describe-animation-graph.ts
496
+ function describeAnimationGraph(graph) {
497
+ const nodes = graph.nodes.map((node, index) => ({
498
+ index,
499
+ type: node.type,
500
+ weight: node.weight,
501
+ children: animationGraphNodeChildren(node)
502
+ }));
503
+ return {
504
+ nodes,
505
+ root: graph.root,
506
+ staticWeights: nodes.map((node) => node.weight)
507
+ };
508
+ }
509
+
510
+ // src/graph/serialize-animation-graph.ts
511
+ function serializeAnimationGraph(graph, resolveClipGuid = (clip) => clip) {
512
+ const refs = [];
513
+ const guidToIndex = /* @__PURE__ */ new Map();
514
+ const internRef = (guid) => {
515
+ const existing = guidToIndex.get(guid);
516
+ if (existing !== void 0) return existing;
517
+ const index = refs.length;
518
+ refs.push(guid);
519
+ guidToIndex.set(guid, index);
520
+ return index;
521
+ };
522
+ const nodes = [];
523
+ for (const node of graph.nodes) {
524
+ switch (node.type) {
525
+ case "clip": {
526
+ const guid = resolveClipGuid(node.clip);
527
+ if (guid === void 0 || guid.length === 0) return void 0;
528
+ nodes.push({ type: "clip", clip: internRef(guid), weight: node.weight });
529
+ break;
530
+ }
531
+ case "blend":
532
+ nodes.push({ type: "blend", children: [...node.children], weight: node.weight });
533
+ break;
534
+ case "add":
535
+ nodes.push({
536
+ type: "add",
537
+ base: node.base,
538
+ additive: [...node.additive],
539
+ weight: node.weight
540
+ });
541
+ break;
542
+ }
543
+ }
544
+ return { payload: { nodes, root: graph.root }, refs };
545
+ }
546
+
547
+ // src/systems/advance-animation-player.ts
548
+ import { Time, Update } from "@forgeax/engine-ecs";
549
+ import { defineSystem, defineSystemSet, ENTITY_NULL_RAW } from "@forgeax/engine-ecs";
550
+ import { createWorldProjection } from "@forgeax/engine-ecs/projection";
551
+ import { MorphWeights, Transform as Transform2 } from "@forgeax/engine-scene";
552
+ import { toShared } from "@forgeax/engine-types";
553
+
554
+ // src/player-errors.ts
555
+ var AnimationPlayerSlotLengthMismatchError = class extends Error {
556
+ code = "animation-player-slot-length-mismatch";
557
+ expected;
558
+ hint;
559
+ detail;
560
+ constructor(detail) {
561
+ const { entity, clips, times, weights, speeds } = detail;
562
+ const expected = "AnimationPlayer clips/times/weights/speeds columns share one length";
563
+ 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`;
564
+ super(
565
+ `AnimationPlayer slot length mismatch on entity ${entity}: clips=${clips}, times=${times}, weights=${weights}, speeds=${speeds}`
566
+ );
567
+ this.name = "AnimationPlayerSlotLengthMismatchError";
568
+ this.expected = expected;
569
+ this.hint = hint;
570
+ this.detail = detail;
571
+ }
572
+ };
573
+
574
+ // src/systems/advance-animation-player.ts
575
+ var ADVANCE_ANIMATION_PLAYER_SYSTEM = "advanceAnimationPlayer";
576
+ var AnimationSet = defineSystemSet({ name: "animation" });
577
+ function _resetAnimationWarnsForTests2(world) {
578
+ _resetAnimationWarnsForTests(world);
579
+ targetMapCacheByWorld.delete(world);
580
+ }
581
+ function advanceAnimationPlayer(world, dt) {
582
+ const query = world.query({ with: [AnimationPlayer] }).unwrap();
583
+ const entities = [];
584
+ for (const row of query) entities.push(row.entity);
585
+ for (const entityRaw of entities) {
586
+ advanceOnePlayer(world, entityRaw, dt);
587
+ }
588
+ }
589
+ function advanceOnePlayer(world, entityRaw, dt) {
590
+ const entity = entityRaw;
591
+ const apRes = world.get(entity, AnimationPlayer);
592
+ if (!apRes.ok) return;
593
+ const ap = apRes.value;
594
+ const count = ap.clips.length;
595
+ if (ap.times.length !== count || ap.weights.length !== count || ap.speeds.length !== count) {
596
+ throw new AnimationPlayerSlotLengthMismatchError({
597
+ entity: entityRaw,
598
+ clips: count,
599
+ times: ap.times.length,
600
+ weights: ap.weights.length,
601
+ speeds: ap.speeds.length
602
+ });
603
+ }
604
+ if (count === 0) return;
605
+ const newTimes = new Float32Array(ap.times);
606
+ const activeSlots = collectActiveSlotsAndAdvanceTimes(world, ap, newTimes, dt);
607
+ world.set(entity, AnimationPlayer, { times: newTimes });
608
+ if (activeSlots.length === 0) return;
609
+ tickEntityTargets(world, entity, entityRaw, activeSlots);
610
+ }
611
+ function collectActiveSlotsAndAdvanceTimes(world, ap, newTimes, dt) {
612
+ const paused = ap.paused;
613
+ const looping = ap.looping;
614
+ const count = ap.clips.length;
615
+ const activeSlots = [];
616
+ for (let i = 0; i < count; i++) {
617
+ const clipHandleRaw = ap.clips[i] ?? 0;
618
+ if (clipHandleRaw === 0) continue;
619
+ const clipLookup = world.sharedRefs.resolve(
620
+ toShared(clipHandleRaw)
621
+ );
622
+ if (!clipLookup.ok) throw clipLookup.error;
623
+ const clip = clipLookup.value;
624
+ const speed = ap.speeds[i] ?? 0;
625
+ let newTime = paused ? ap.times[i] ?? 0 : (ap.times[i] ?? 0) + speed * dt;
626
+ const duration = clip.duration;
627
+ if (duration > 0) {
628
+ if (looping) {
629
+ newTime = newTime % duration;
630
+ if (newTime < 0) newTime += duration;
631
+ } else if (newTime > duration) {
632
+ newTime = duration;
633
+ } else if (newTime < 0) {
634
+ newTime = 0;
635
+ }
636
+ }
637
+ newTimes[i] = newTime;
638
+ const wRaw = ap.weights[i] ?? 0;
639
+ const w = wRaw > 0 ? wRaw : 0;
640
+ if (w === 0) continue;
641
+ activeSlots.push({ clip, clipHandleRaw, weight: w, time: newTime, slotIdx: i });
642
+ }
643
+ return activeSlots;
644
+ }
645
+ var targetMapCacheByWorld = /* @__PURE__ */ new WeakMap();
646
+ function targetMapForPlayer(world, player) {
647
+ let cache = targetMapCacheByWorld.get(world);
648
+ if (cache === void 0) {
649
+ cache = {
650
+ projection: createWorldProjection(world, {
651
+ components: [AnimationTargets, AnimationTargetId]
652
+ }),
653
+ players: /* @__PURE__ */ new Map()
654
+ };
655
+ targetMapCacheByWorld.set(world, cache);
656
+ } else {
657
+ const changes = cache.projection.poll();
658
+ if (changes.status === "rebuild" || changes.changes.length > 0) {
659
+ cache = {
660
+ projection: createWorldProjection(world, {
661
+ components: [AnimationTargets, AnimationTargetId]
662
+ }),
663
+ players: /* @__PURE__ */ new Map()
664
+ };
665
+ targetMapCacheByWorld.set(world, cache);
666
+ }
667
+ }
668
+ const cached = cache.players.get(player);
669
+ if (cached !== void 0) return cached;
670
+ const built = buildTargetMap(world, player);
671
+ cache.players.set(player, built);
672
+ return built;
673
+ }
674
+ function buildTargetMap(world, player) {
675
+ const targets = world.get(player, AnimationTargets);
676
+ if (!targets.ok) {
677
+ return {
678
+ entities: /* @__PURE__ */ new Map(),
679
+ missingTransforms: /* @__PURE__ */ new Map(),
680
+ duplicateIds: /* @__PURE__ */ new Set(),
681
+ hasStaleTarget: false,
682
+ resolvedClips: /* @__PURE__ */ new WeakMap()
683
+ };
684
+ }
685
+ const result = /* @__PURE__ */ new Map();
686
+ const missingTransforms = /* @__PURE__ */ new Map();
687
+ const seen = /* @__PURE__ */ new Set();
688
+ const ambiguous = /* @__PURE__ */ new Set();
689
+ let hasStaleTarget = false;
690
+ for (const raw of targets.value.targets) {
691
+ if (raw === ENTITY_NULL_RAW) continue;
692
+ const target = raw;
693
+ const id = world.get(target, AnimationTargetId);
694
+ if (!id.ok) {
695
+ hasStaleTarget = true;
696
+ continue;
697
+ }
698
+ const targetId = id.value.value;
699
+ if (ambiguous.has(targetId)) continue;
700
+ if (seen.has(targetId)) {
701
+ result.delete(targetId);
702
+ missingTransforms.delete(targetId);
703
+ ambiguous.add(targetId);
704
+ continue;
705
+ }
706
+ seen.add(targetId);
707
+ if (world.get(target, Transform2).ok) result.set(targetId, target);
708
+ else missingTransforms.set(targetId, target);
709
+ }
710
+ return {
711
+ entities: result,
712
+ missingTransforms,
713
+ duplicateIds: ambiguous,
714
+ hasStaleTarget,
715
+ resolvedClips: /* @__PURE__ */ new WeakMap()
716
+ };
717
+ }
718
+ function tickEntityTargets(world, entity, entityRaw, activeSlots) {
719
+ const targetMap = targetMapForPlayer(world, entity);
720
+ const accumulators = /* @__PURE__ */ new Map();
721
+ const morphAccumulators = /* @__PURE__ */ new Map();
722
+ const slotCoverage = [];
723
+ const wantsCoverage = activeSlots.length >= 2 && isAnimationDevMode();
724
+ if (wantsCoverage) {
725
+ for (let i = 0; i < activeSlots.length; i++) slotCoverage.push(/* @__PURE__ */ new Map());
726
+ }
727
+ for (let slotIdx = 0; slotIdx < activeSlots.length; slotIdx++) {
728
+ const slot = activeSlots[slotIdx];
729
+ const resolvedTargets = resolveClipTargets(world, entityRaw, slot, targetMap);
730
+ for (let chIdx = 0; chIdx < slot.clip.channels.length; chIdx++) {
731
+ const channel = slot.clip.channels[chIdx];
732
+ const sampled = sampleChannel(channel.sampler, slot.time, channel.property);
733
+ if (sampled === void 0) continue;
734
+ const target = channel.property === "weights" ? resolveChannelTarget(
735
+ world,
736
+ entityRaw,
737
+ slot.clipHandleRaw,
738
+ chIdx,
739
+ channel.targetId,
740
+ channel.property,
741
+ sampled.length,
742
+ targetMap
743
+ ) : resolvedTargets[chIdx];
744
+ if (target === void 0) continue;
745
+ const targetRaw = target;
746
+ if (channel.property === "weights") {
747
+ let weights = morphAccumulators.get(targetRaw);
748
+ if (weights === void 0) {
749
+ weights = { values: new Float32Array(sampled.length), sumW: 0 };
750
+ morphAccumulators.set(targetRaw, weights);
751
+ }
752
+ if (weights.values.length !== sampled.length) continue;
753
+ for (let i = 0; i < sampled.length; i++) {
754
+ weights.values[i] = (weights.values[i] ?? 0) + slot.weight * (sampled[i] ?? 0);
755
+ }
756
+ weights.sumW += slot.weight;
757
+ if (wantsCoverage) {
758
+ const coverage = slotCoverage[slotIdx];
759
+ if (coverage !== void 0) {
760
+ recordSlotCoverage(coverage, channel.targetId, channel.property, chIdx);
761
+ }
762
+ }
763
+ continue;
764
+ }
765
+ let acc = accumulators.get(targetRaw);
766
+ if (acc === void 0) {
767
+ acc = createAccumulator();
768
+ accumulators.set(targetRaw, acc);
769
+ }
770
+ foldChannelIntoAccumulator(acc, channel.property, sampled, slot.weight);
771
+ if (wantsCoverage) {
772
+ recordSlotCoverage(slotCoverage[slotIdx], channel.targetId, channel.property, chIdx);
773
+ }
774
+ }
775
+ }
776
+ if (wantsCoverage) {
777
+ emitMissingOnSomeSlotWarns(world, entityRaw, activeSlots, slotCoverage);
778
+ }
779
+ for (const [targetRaw, acc] of accumulators) {
780
+ const target = targetRaw;
781
+ const partial = finalizeAccumulator(acc);
782
+ if (Object.keys(partial).length > 0) {
783
+ world.set(target, Transform2, partial);
784
+ }
785
+ }
786
+ for (const [targetRaw, acc] of morphAccumulators) {
787
+ if (acc.sumW <= 0) continue;
788
+ const target = targetRaw;
789
+ const weights = world.get(target, MorphWeights);
790
+ if (weights.ok && weights.value.weights.length === acc.values.length) {
791
+ const next = new Float32Array(acc.values.length);
792
+ for (let i = 0; i < next.length; i++) next[i] = (acc.values[i] ?? 0) / acc.sumW;
793
+ world.set(target, MorphWeights, { weights: next });
794
+ }
795
+ }
796
+ }
797
+ function resolveClipTargets(world, player, slot, targetMap) {
798
+ const cached = targetMap.resolvedClips.get(slot.clip);
799
+ if (cached !== void 0) return cached;
800
+ const targets = slot.clip.channels.map(
801
+ (channel, channelIndex) => channel.property === "weights" ? void 0 : resolveChannelTarget(
802
+ world,
803
+ player,
804
+ slot.clipHandleRaw,
805
+ channelIndex,
806
+ channel.targetId,
807
+ channel.property,
808
+ void 0,
809
+ targetMap
810
+ )
811
+ );
812
+ targetMap.resolvedClips.set(slot.clip, targets);
813
+ return targets;
814
+ }
815
+ function resolveChannelTarget(world, player, clip, channel, targetId, property, expectedWeightCount, targetMap) {
816
+ if (targetMap.duplicateIds.has(targetId)) {
817
+ emitTargetDiagnostic(
818
+ world,
819
+ player,
820
+ clip,
821
+ channel,
822
+ targetId,
823
+ "animation-target-id-duplicate",
824
+ "target-id-duplicate",
825
+ "assign a unique AnimationTargetId to each target owned by this player"
826
+ );
827
+ return void 0;
828
+ }
829
+ const target = targetMap.entities.get(targetId);
830
+ if (target === void 0) {
831
+ const transformMissingTarget = targetMap.missingTransforms.get(targetId);
832
+ if (transformMissingTarget !== void 0) {
833
+ emitTargetDiagnostic(
834
+ world,
835
+ player,
836
+ clip,
837
+ channel,
838
+ targetId,
839
+ "animation-target-transform-missing",
840
+ "transform-missing",
841
+ "attach Transform to the bound animation target",
842
+ transformMissingTarget
843
+ );
844
+ return void 0;
845
+ }
846
+ emitTargetDiagnostic(
847
+ world,
848
+ player,
849
+ clip,
850
+ channel,
851
+ targetId,
852
+ targetMap.hasStaleTarget ? "animation-target-owner-stale" : "animation-target-missing",
853
+ targetMap.hasStaleTarget ? "target-stale" : "target-missing",
854
+ targetMap.hasStaleTarget ? "remove the stale target relation or bind a live replacement" : "bind the matching AnimationTargetId to this player"
855
+ );
856
+ return void 0;
857
+ }
858
+ if (property === "weights") {
859
+ const weights = world.get(target, MorphWeights);
860
+ if (!weights.ok) {
861
+ emitAnimationDiagnostic(world, {
862
+ code: "animation-target-morph-weights-missing",
863
+ hint: "attach MorphWeights to the morph target entity before playing a weights channel",
864
+ detail: {
865
+ player,
866
+ clip,
867
+ channel,
868
+ targetId,
869
+ reason: "morph-weights-missing",
870
+ target,
871
+ property,
872
+ ...expectedWeightCount === void 0 ? {} : { expectedWeightCount }
873
+ }
874
+ });
875
+ return void 0;
876
+ }
877
+ if (expectedWeightCount !== void 0 && weights.value.weights.length !== expectedWeightCount) {
878
+ emitAnimationDiagnostic(world, {
879
+ code: "animation-morph-weight-count-mismatch",
880
+ hint: "make MorphWeights.length equal the animation channel output width",
881
+ detail: {
882
+ player,
883
+ clip,
884
+ channel,
885
+ targetId,
886
+ reason: "morph-weight-count-mismatch",
887
+ target,
888
+ property,
889
+ expectedWeightCount,
890
+ actualWeightCount: weights.value.weights.length
891
+ }
892
+ });
893
+ return void 0;
894
+ }
895
+ return target;
896
+ }
897
+ if (!world.get(target, Transform2).ok) {
898
+ emitTargetDiagnostic(
899
+ world,
900
+ player,
901
+ clip,
902
+ channel,
903
+ targetId,
904
+ "animation-target-transform-missing",
905
+ "transform-missing",
906
+ "attach Transform to the bound animation target",
907
+ target
908
+ );
909
+ return void 0;
910
+ }
911
+ return target;
912
+ }
913
+ function recordSlotCoverage(cov, targetId, kind, chIdx) {
914
+ let perTarget = cov.get(targetId);
915
+ if (perTarget === void 0) {
916
+ perTarget = /* @__PURE__ */ new Map();
917
+ cov.set(targetId, perTarget);
918
+ }
919
+ if (!perTarget.has(kind)) perTarget.set(kind, chIdx);
920
+ }
921
+ function emitTargetDiagnostic(world, entityRaw, clipHandleRaw, chIdx, targetId, code, reason, hint, target) {
922
+ emitAnimationDiagnostic(world, {
923
+ code,
924
+ hint,
925
+ detail: {
926
+ player: entityRaw,
927
+ clip: clipHandleRaw,
928
+ channel: chIdx,
929
+ targetId,
930
+ reason,
931
+ ...target === void 0 ? {} : { target }
932
+ }
933
+ });
934
+ }
935
+ function emitMissingOnSomeSlotWarns(world, entityRaw, activeSlots, slotCoverage) {
936
+ const union = /* @__PURE__ */ new Map();
937
+ for (const cov of slotCoverage) {
938
+ for (const [targetId, kindMap] of cov) {
939
+ let set = union.get(targetId);
940
+ if (set === void 0) {
941
+ set = /* @__PURE__ */ new Set();
942
+ union.set(targetId, set);
943
+ }
944
+ for (const kind of kindMap.keys()) set.add(kind);
945
+ }
946
+ }
947
+ for (const [targetId, unionKinds] of union) {
948
+ for (let slotIdx = 0; slotIdx < activeSlots.length; slotIdx++) {
949
+ const cov = slotCoverage[slotIdx];
950
+ const slotKinds = cov.get(targetId);
951
+ for (const kind of unionKinds) {
952
+ if (slotKinds?.has(kind)) continue;
953
+ for (let coveringIdx = 0; coveringIdx < activeSlots.length; coveringIdx++) {
954
+ if (coveringIdx === slotIdx) continue;
955
+ const coveringCov = slotCoverage[coveringIdx];
956
+ const coveringKinds = coveringCov.get(targetId);
957
+ if (coveringKinds === void 0) continue;
958
+ const chIdx = coveringKinds.get(kind);
959
+ if (chIdx === void 0) continue;
960
+ const coveringSlot = activeSlots[coveringIdx];
961
+ emitAnimationDiagnostic(world, {
962
+ code: "animation-channel-missing",
963
+ hint: `author the missing ${kind} channel on the slot whose clip lacks it`,
964
+ detail: {
965
+ player: entityRaw,
966
+ clip: coveringSlot.clipHandleRaw,
967
+ channel: chIdx,
968
+ targetId,
969
+ reason: "channel-missing",
970
+ property: kind
971
+ }
972
+ });
973
+ break;
974
+ }
975
+ }
976
+ }
977
+ }
978
+ }
979
+ function foldChannelIntoAccumulator(acc, property, sampled, weight) {
980
+ if (property === "weights") return;
981
+ if (property === "translation" && sampled.length >= 3) {
982
+ acc.posX += weight * (sampled[0] ?? 0);
983
+ acc.posY += weight * (sampled[1] ?? 0);
984
+ acc.posZ += weight * (sampled[2] ?? 0);
985
+ acc.sumWPos += weight;
986
+ acc.hasPos = true;
987
+ return;
988
+ }
989
+ if (property === "rotation" && sampled.length >= 4) {
990
+ const qx = sampled[0] ?? 0;
991
+ const qy = sampled[1] ?? 0;
992
+ const qz = sampled[2] ?? 0;
993
+ const qw = sampled[3] ?? 1;
994
+ if (!acc.hasQuat) {
995
+ acc.refQX = qx;
996
+ acc.refQY = qy;
997
+ acc.refQZ = qz;
998
+ acc.refQW = qw;
999
+ acc.quatX = weight * qx;
1000
+ acc.quatY = weight * qy;
1001
+ acc.quatZ = weight * qz;
1002
+ acc.quatW = weight * qw;
1003
+ acc.hasQuat = true;
1004
+ } else {
1005
+ const dot = acc.refQX * qx + acc.refQY * qy + acc.refQZ * qz + acc.refQW * qw;
1006
+ const sign = dot < 0 ? -1 : 1;
1007
+ acc.quatX += weight * sign * qx;
1008
+ acc.quatY += weight * sign * qy;
1009
+ acc.quatZ += weight * sign * qz;
1010
+ acc.quatW += weight * sign * qw;
1011
+ }
1012
+ acc.sumWQuat += weight;
1013
+ return;
1014
+ }
1015
+ if (property === "scale" && sampled.length >= 3) {
1016
+ acc.scaleX += weight * (sampled[0] ?? 1);
1017
+ acc.scaleY += weight * (sampled[1] ?? 1);
1018
+ acc.scaleZ += weight * (sampled[2] ?? 1);
1019
+ acc.sumWScale += weight;
1020
+ acc.hasScale = true;
1021
+ }
1022
+ }
1023
+ function finalizeAccumulator(acc) {
1024
+ const partial = {};
1025
+ if (acc.hasPos && acc.sumWPos > 0) {
1026
+ partial.pos = [acc.posX / acc.sumWPos, acc.posY / acc.sumWPos, acc.posZ / acc.sumWPos];
1027
+ }
1028
+ if (acc.hasQuat && acc.sumWQuat > 0) {
1029
+ const qx = acc.quatX / acc.sumWQuat;
1030
+ const qy = acc.quatY / acc.sumWQuat;
1031
+ const qz = acc.quatZ / acc.sumWQuat;
1032
+ const qw = acc.quatW / acc.sumWQuat;
1033
+ const len = Math.sqrt(qx * qx + qy * qy + qz * qz + qw * qw);
1034
+ if (len > 0) {
1035
+ partial.quat = [qx / len, qy / len, qz / len, qw / len];
1036
+ }
1037
+ }
1038
+ if (acc.hasScale && acc.sumWScale > 0) {
1039
+ partial.scale = [
1040
+ acc.scaleX / acc.sumWScale,
1041
+ acc.scaleY / acc.sumWScale,
1042
+ acc.scaleZ / acc.sumWScale
1043
+ ];
1044
+ }
1045
+ return partial;
1046
+ }
1047
+ function createAccumulator() {
1048
+ return {
1049
+ posX: 0,
1050
+ posY: 0,
1051
+ posZ: 0,
1052
+ sumWPos: 0,
1053
+ hasPos: false,
1054
+ refQX: 0,
1055
+ refQY: 0,
1056
+ refQZ: 0,
1057
+ refQW: 1,
1058
+ quatX: 0,
1059
+ quatY: 0,
1060
+ quatZ: 0,
1061
+ quatW: 0,
1062
+ sumWQuat: 0,
1063
+ hasQuat: false,
1064
+ scaleX: 0,
1065
+ scaleY: 0,
1066
+ scaleZ: 0,
1067
+ sumWScale: 0,
1068
+ hasScale: false
1069
+ };
1070
+ }
1071
+ function sampleChannel(sampler, time, property) {
1072
+ const { input, output, interpolation } = sampler;
1073
+ if (input.length === 0) return void 0;
1074
+ const elementCount = output.length / input.length;
1075
+ if (time <= input[0]) {
1076
+ return sliceOutput(output, 0, elementCount);
1077
+ }
1078
+ const lastIdx = input.length - 1;
1079
+ if (time >= input[lastIdx]) {
1080
+ return sliceOutput(output, lastIdx, elementCount);
1081
+ }
1082
+ let lo = 0;
1083
+ let hi = input.length - 1;
1084
+ while (hi - lo > 1) {
1085
+ const mid = lo + hi >> 1;
1086
+ if (input[mid] <= time) {
1087
+ lo = mid;
1088
+ } else {
1089
+ hi = mid;
1090
+ }
1091
+ }
1092
+ const prev = lo;
1093
+ const next = hi;
1094
+ if (interpolation === "STEP") {
1095
+ return sliceOutput(output, prev, elementCount);
1096
+ }
1097
+ const t0 = input[prev];
1098
+ const t1 = input[next];
1099
+ const alpha = (time - t0) / (t1 - t0);
1100
+ const prevValues = sliceOutput(output, prev, elementCount);
1101
+ const nextValues = sliceOutput(output, next, elementCount);
1102
+ if (property === "rotation") {
1103
+ const px = prevValues[0] ?? 0;
1104
+ const py = prevValues[1] ?? 0;
1105
+ const pz = prevValues[2] ?? 0;
1106
+ const pw = prevValues[3] ?? 1;
1107
+ let nx = nextValues[0] ?? 0;
1108
+ let ny = nextValues[1] ?? 0;
1109
+ let nz = nextValues[2] ?? 0;
1110
+ let nw = nextValues[3] ?? 1;
1111
+ let dot = px * nx + py * ny + pz * nz + pw * nw;
1112
+ if (dot < 0) {
1113
+ nx = -nx;
1114
+ ny = -ny;
1115
+ nz = -nz;
1116
+ nw = -nw;
1117
+ dot = -dot;
1118
+ }
1119
+ if (dot > 0.9995) {
1120
+ const lx = px + alpha * (nx - px);
1121
+ const ly = py + alpha * (ny - py);
1122
+ const lz = pz + alpha * (nz - pz);
1123
+ const lw = pw + alpha * (nw - pw);
1124
+ const len = Math.sqrt(lx * lx + ly * ly + lz * lz + lw * lw);
1125
+ return len > 0 ? [lx / len, ly / len, lz / len, lw / len] : [0, 0, 0, 1];
1126
+ }
1127
+ const theta = Math.acos(dot);
1128
+ const sinTheta = Math.sin(theta);
1129
+ const sa = Math.sin((1 - alpha) * theta) / sinTheta;
1130
+ const sb = Math.sin(alpha * theta) / sinTheta;
1131
+ return [px * sa + nx * sb, py * sa + ny * sb, pz * sa + nz * sb, pw * sa + nw * sb];
1132
+ }
1133
+ return prevValues.map((value, index) => value + alpha * ((nextValues[index] ?? value) - value));
1134
+ }
1135
+ function sliceOutput(output, index, elementCount) {
1136
+ const result = [];
1137
+ const base = index * elementCount;
1138
+ for (let i = 0; i < elementCount; i++) {
1139
+ result.push(output[base + i]);
1140
+ }
1141
+ return result;
1142
+ }
1143
+ var AdvanceAnimationPlayer = defineSystem({
1144
+ name: ADVANCE_ANIMATION_PLAYER_SYSTEM,
1145
+ queries: [],
1146
+ before: ["propagateTransforms"],
1147
+ fn: (world) => {
1148
+ advanceAnimationPlayer(world, world.getResource(Time).delta);
1149
+ }
1150
+ });
1151
+ function registerAdvanceAnimationPlayer(world) {
1152
+ world.addSystems(Update, AnimationSet, [AdvanceAnimationPlayer]).unwrap();
1153
+ return () => {
1154
+ world.removeSystem(Update, ADVANCE_ANIMATION_PLAYER_SYSTEM);
1155
+ };
1156
+ }
1157
+
1158
+ // src/systems/evaluate-animation-graph.ts
1159
+ import { defineSystem as defineSystem2, Time as Time2, Update as Update2 } from "@forgeax/engine-ecs";
1160
+
1161
+ // src/resolve-animation-asset.ts
1162
+ import { err as err4, ok as ok4 } from "@forgeax/engine-types";
1163
+ var AnimationAssetError = class extends Error {
1164
+ code;
1165
+ expected;
1166
+ hint;
1167
+ detail;
1168
+ constructor(args) {
1169
+ super(`[AnimationAssetError ${args.code}] expected: ${args.expected}; hint: ${args.hint}`);
1170
+ this.name = "AnimationAssetError";
1171
+ this.code = args.code;
1172
+ this.expected = args.expected;
1173
+ this.hint = args.hint;
1174
+ this.detail = args.detail;
1175
+ }
1176
+ };
1177
+ function resolveAnimationAsset(world, guid, expectedKind, lookup) {
1178
+ if (guid.length === 0) {
1179
+ return err4(
1180
+ new AnimationAssetError({
1181
+ code: "animation-asset-not-found",
1182
+ expected: `a durable ${expectedKind} GUID`,
1183
+ hint: "load the animation asset by GUID before evaluating the graph",
1184
+ detail: { guid, expectedKind, lookupCode: "guid-empty" }
1185
+ })
1186
+ );
1187
+ }
1188
+ let asset;
1189
+ try {
1190
+ asset = lookup(guid);
1191
+ } catch {
1192
+ return err4(
1193
+ new AnimationAssetError({
1194
+ code: "animation-asset-not-found",
1195
+ expected: `a loaded ${expectedKind} payload for GUID ${guid}`,
1196
+ hint: "repair the animation asset lookup provider and retry resolution",
1197
+ detail: { guid, expectedKind, lookupCode: "lookup-threw" }
1198
+ })
1199
+ );
1200
+ }
1201
+ if (asset !== void 0 && "code" in asset && asset.code === "stale") {
1202
+ return err4(
1203
+ new AnimationAssetError({
1204
+ code: "animation-asset-stale",
1205
+ expected: `a current ${expectedKind} payload for GUID ${guid}`,
1206
+ hint: "rebuild the stale asset projection before evaluating the graph",
1207
+ detail: { guid, expectedKind, lookupCode: "asset-stale" }
1208
+ })
1209
+ );
1210
+ }
1211
+ if (asset === void 0) {
1212
+ return err4(
1213
+ new AnimationAssetError({
1214
+ code: "animation-asset-not-found",
1215
+ expected: `a loaded ${expectedKind} payload for GUID ${guid}`,
1216
+ hint: "load or retain the referenced animation asset before evaluating the graph",
1217
+ detail: { guid, expectedKind, lookupCode: "asset-not-found" }
1218
+ })
1219
+ );
1220
+ }
1221
+ const payload = asset;
1222
+ if (payload.kind !== expectedKind) {
1223
+ return err4(
1224
+ new AnimationAssetError({
1225
+ code: "animation-asset-kind-mismatch",
1226
+ expected: `asset kind '${expectedKind}'`,
1227
+ hint: `replace GUID ${guid} with a loaded ${expectedKind} asset`,
1228
+ detail: {
1229
+ guid,
1230
+ expectedKind,
1231
+ actualKind: payload.kind,
1232
+ lookupCode: "asset-kind-mismatch"
1233
+ }
1234
+ })
1235
+ );
1236
+ }
1237
+ const target = expectedKind === "animation-clip" ? "AnimationClip" : "AnimationGraph";
1238
+ const handle = world.internSharedRef(target, payload);
1239
+ return ok4({ guid, asset: payload, handle });
1240
+ }
1241
+
1242
+ // src/systems/evaluate-animation-graph.ts
1243
+ var EVALUATE_ANIMATION_GRAPH_SYSTEM = "evaluateAnimationGraph";
1244
+ function readAt(arr, i, dflt) {
1245
+ return i >= 0 && i < arr.length ? arr[i] ?? dflt : dflt;
1246
+ }
1247
+ function wrapTime(time, duration, looping) {
1248
+ if (duration <= 0) return time;
1249
+ if (looping) {
1250
+ let wrapped = time % duration;
1251
+ if (wrapped < 0) wrapped += duration;
1252
+ return wrapped;
1253
+ }
1254
+ if (time > duration) return duration;
1255
+ if (time < 0) return 0;
1256
+ return time;
1257
+ }
1258
+ function evaluateAnimationGraph(world, dt, lookup = () => void 0) {
1259
+ const query = world.query({ with: [AnimationPlayer] }).unwrap();
1260
+ const entities = [];
1261
+ for (const row of query) entities.push(row.entity);
1262
+ for (const entityRaw of entities) {
1263
+ evaluateOneEntity(world, entityRaw, dt, lookup);
1264
+ }
1265
+ }
1266
+ function evaluateOneEntity(world, entityRaw, dt, lookup) {
1267
+ const entity = entityRaw;
1268
+ const apRes = world.get(entity, AnimationPlayer);
1269
+ if (!apRes.ok) return;
1270
+ const ap = apRes.value;
1271
+ const graphRaw = ap.graph;
1272
+ if (graphRaw === 0) return;
1273
+ const graphLookup = world.sharedRefs.resolve(
1274
+ graphRaw
1275
+ );
1276
+ if (!graphLookup.ok) throw graphLookup.error;
1277
+ const graph = graphLookup.value;
1278
+ const nodes = graph.nodes;
1279
+ if (nodes.length === 0) return;
1280
+ const clipNodeIndices = [];
1281
+ const clipHandles = [];
1282
+ const clipDurations = [];
1283
+ for (let i = 0; i < nodes.length; i++) {
1284
+ const node = nodes[i];
1285
+ if (node === void 0 || node.type !== "clip") continue;
1286
+ const clipLookup = resolveAnimationAsset(
1287
+ world,
1288
+ node.clip,
1289
+ "animation-clip",
1290
+ (guid) => {
1291
+ return lookup(guid);
1292
+ }
1293
+ );
1294
+ if (!clipLookup.ok) throw clipLookup.error;
1295
+ const handle = clipLookup.value.handle;
1296
+ clipNodeIndices.push(i);
1297
+ clipHandles.push(handle);
1298
+ clipDurations.push(clipLookup.value.asset.duration);
1299
+ }
1300
+ const effByNode = /* @__PURE__ */ new Map();
1301
+ const runtimeWeight = (n) => readAt(ap.nodeWeights, n, 1);
1302
+ const evalNode = (nodeIndex, incoming) => {
1303
+ const node = nodes[nodeIndex];
1304
+ if (node === void 0) return;
1305
+ const eff = incoming * runtimeWeight(nodeIndex) * node.weight;
1306
+ switch (node.type) {
1307
+ case "clip":
1308
+ effByNode.set(nodeIndex, (effByNode.get(nodeIndex) ?? 0) + eff);
1309
+ return;
1310
+ case "blend": {
1311
+ let total = 0;
1312
+ for (const child of node.children)
1313
+ total += runtimeWeight(child) * (nodes[child]?.weight ?? 0);
1314
+ if (total > 0) {
1315
+ for (const child of node.children) evalNode(child, eff / total);
1316
+ }
1317
+ return;
1318
+ }
1319
+ case "add":
1320
+ evalNode(node.base, eff);
1321
+ for (const layer of node.additive) evalNode(layer, eff);
1322
+ return;
1323
+ }
1324
+ };
1325
+ evalNode(graph.root, 1);
1326
+ const newNodeTimes = new Float32Array(nodes.length);
1327
+ for (let i = 0; i < nodes.length; i++) newNodeTimes[i] = readAt(ap.nodeTimes, i, 0);
1328
+ for (let k = 0; k < clipNodeIndices.length; k++) {
1329
+ const nodeIndex = clipNodeIndices[k];
1330
+ const duration = clipDurations[k];
1331
+ const current = readAt(ap.nodeTimes, nodeIndex, 0);
1332
+ const speed = readAt(ap.nodeSpeeds, nodeIndex, 0);
1333
+ const advanced = ap.paused ? current : current + speed * dt;
1334
+ newNodeTimes[nodeIndex] = wrapTime(advanced, duration, ap.looping);
1335
+ }
1336
+ const slotCount = clipNodeIndices.length;
1337
+ const times = new Float32Array(slotCount);
1338
+ const weights = new Float32Array(slotCount);
1339
+ const speeds = new Float32Array(slotCount);
1340
+ for (let k = 0; k < slotCount; k++) {
1341
+ const nodeIndex = clipNodeIndices[k];
1342
+ weights[k] = effByNode.get(nodeIndex) ?? 0;
1343
+ times[k] = newNodeTimes[nodeIndex] ?? 0;
1344
+ speeds[k] = 0;
1345
+ }
1346
+ world.set(entity, AnimationPlayer, {
1347
+ clips: clipHandles,
1348
+ times,
1349
+ weights,
1350
+ speeds,
1351
+ nodeTimes: newNodeTimes
1352
+ });
1353
+ }
1354
+ var EvaluateAnimationGraph = defineSystem2({
1355
+ name: EVALUATE_ANIMATION_GRAPH_SYSTEM,
1356
+ queries: [],
1357
+ before: [ADVANCE_ANIMATION_PLAYER_SYSTEM],
1358
+ fn: (world) => {
1359
+ evaluateAnimationGraph(world, world.getResource(Time2).delta);
1360
+ }
1361
+ });
1362
+ function registerEvaluateAnimationGraph(world, lookup = () => void 0) {
1363
+ world.addSystem(Update2, {
1364
+ name: EVALUATE_ANIMATION_GRAPH_SYSTEM,
1365
+ queries: [],
1366
+ before: [ADVANCE_ANIMATION_PLAYER_SYSTEM],
1367
+ fn: (world2) => {
1368
+ evaluateAnimationGraph(world2, world2.getResource(Time2).delta, lookup);
1369
+ }
1370
+ }).unwrap();
1371
+ return () => {
1372
+ world.removeSystem(Update2, EVALUATE_ANIMATION_GRAPH_SYSTEM);
1373
+ };
1374
+ }
1375
+
1376
+ // src/plugin.ts
1377
+ var ANIMATION_COMPONENTS = [
1378
+ AnimationPlayer,
1379
+ AnimatedBy,
1380
+ AnimationTargetId,
1381
+ AnimationTargets
1382
+ ];
1383
+ function registerAnimationComponents(world) {
1384
+ const leases = ANIMATION_COMPONENTS.map(
1385
+ (component) => world.components.register(component).unwrap()
1386
+ );
1387
+ return () => {
1388
+ for (let index = leases.length - 1; index >= 0; index -= 1) leases[index]?.dispose();
1389
+ };
1390
+ }
1391
+ function animationPayloadsPlugin(lookup) {
1392
+ return {
1393
+ name: "animation-payloads",
1394
+ provide: "animationPayloads",
1395
+ apply(ctx) {
1396
+ ctx.provide("animationPayloads", lookup);
1397
+ }
1398
+ };
1399
+ }
1400
+ function animationRuntimePlugin() {
1401
+ return {
1402
+ name: "animation",
1403
+ inject: ["world", "animationPayloads"],
1404
+ apply(ctx) {
1405
+ ctx.effect(() => registerAnimationComponents(ctx.world), "animation/components");
1406
+ ctx.effect(
1407
+ () => registerEvaluateAnimationGraph(ctx.world, ctx.animationPayloads),
1408
+ "animation/evaluate-graph"
1409
+ );
1410
+ ctx.effect(() => registerAdvanceAnimationPlayer(ctx.world), "animation/advance-player");
1411
+ }
1412
+ };
1413
+ }
1414
+ function animationPlugin(lookup) {
1415
+ return {
1416
+ name: "animation",
1417
+ inject: ["world"],
1418
+ apply(ctx) {
1419
+ ctx.effect(() => registerAnimationComponents(ctx.world), "animation/components");
1420
+ ctx.effect(
1421
+ () => registerEvaluateAnimationGraph(ctx.world, lookup),
1422
+ "animation/evaluate-graph"
1423
+ );
1424
+ ctx.effect(() => registerAdvanceAnimationPlayer(ctx.world), "animation/advance-player");
1425
+ }
1426
+ };
1427
+ }
1428
+ export {
1429
+ ADVANCE_ANIMATION_PLAYER_SYSTEM,
1430
+ AdvanceAnimationPlayer,
1431
+ AnimatedBy,
1432
+ AnimationAssetError,
1433
+ AnimationPlayer,
1434
+ AnimationSet,
1435
+ AnimationTargetId,
1436
+ AnimationTargets,
1437
+ EvaluateAnimationGraph,
1438
+ _resetAnimationWarnsForTests2 as _resetAnimationWarnsForTests,
1439
+ advanceAnimationPlayer,
1440
+ animationClipContribution,
1441
+ animationGraphContribution,
1442
+ animationGraphNodeChildren,
1443
+ animationPayloadsPlugin,
1444
+ animationPlugin,
1445
+ animationRuntimePlugin,
1446
+ bindAnimationTargets,
1447
+ defineAnimationGraph,
1448
+ deriveAnimationTargetId,
1449
+ describeAnimationGraph,
1450
+ evaluateAnimationGraph,
1451
+ isAnimationTargetId,
1452
+ registerAdvanceAnimationPlayer,
1453
+ resolveAnimationAsset,
1454
+ serializeAnimationGraph,
1455
+ subscribeAnimationDiagnostics
1456
+ };
1457
+ //# sourceMappingURL=index.mjs.map