@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,99 @@
1
+ import { createWorldContext, World } from '@forgeax/engine-ecs';
2
+ import type { AnimationClip } from '@forgeax/engine-types';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { animationPlugin } from '../plugin';
5
+ import { resolveAnimationAsset } from '../resolve-animation-asset';
6
+
7
+ describe('animation resolver ownership', () => {
8
+ it('builds and ticks without a runtime-provided resolver resource', async () => {
9
+ const world = new World();
10
+ await createWorldContext(world, [animationPlugin()]);
11
+ expect(world.hasResource('AnimationAssetResolver')).toBe(false);
12
+ expect(() => world.update(1 / 60).unwrap()).not.toThrow();
13
+ });
14
+
15
+ it('projects one durable GUID separately into each World', () => {
16
+ const world = new World();
17
+ const clip: AnimationClip = {
18
+ kind: 'animation-clip',
19
+ duration: 1,
20
+ channels: [],
21
+ };
22
+ const first = resolveAnimationAsset(world, 'clip-guid', 'animation-clip', () => clip);
23
+ const secondWorld = new World();
24
+ const second = resolveAnimationAsset(secondWorld, 'clip-guid', 'animation-clip', () => clip);
25
+ expect(first.ok).toBe(true);
26
+ expect(second.ok).toBe(true);
27
+ if (first.ok && second.ok) {
28
+ expect(world.sharedRefs.resolve(first.value.handle).ok).toBe(true);
29
+ expect(secondWorld.sharedRefs.resolve(second.value.handle).ok).toBe(true);
30
+ }
31
+ });
32
+
33
+ it.each([
34
+ ['missing', undefined, 'animation-asset-not-found'],
35
+ ['wrong-kind', { kind: 'audio' }, 'animation-asset-kind-mismatch'],
36
+ ['stale', { code: 'stale' }, 'animation-asset-stale'],
37
+ ] as const)('reports %s at the animation owner boundary', (_name, payload, code) => {
38
+ const result = resolveAnimationAsset<AnimationClip>(
39
+ new World(),
40
+ 'clip-guid',
41
+ 'animation-clip',
42
+ () => payload as never,
43
+ );
44
+ expect(result.ok).toBe(false);
45
+ if (!result.ok) expect(result.error.code).toBe(code);
46
+ });
47
+
48
+ it('contains a throwing lookup before publication and retries on the same World', () => {
49
+ const world = new World();
50
+ const guid = 'clip-guid';
51
+ const clip: AnimationClip = {
52
+ kind: 'animation-clip',
53
+ duration: 1,
54
+ channels: [],
55
+ };
56
+ let repaired = false;
57
+ const lookup = vi.fn((_requestedGuid: string) => {
58
+ if (!repaired) throw new Error('controlled lookup sentinel');
59
+ return clip;
60
+ });
61
+ const internSharedRef = vi.spyOn(world, 'internSharedRef');
62
+
63
+ const first = (() => {
64
+ try {
65
+ return { result: resolveAnimationAsset(world, guid, 'animation-clip', lookup) };
66
+ } catch (thrown) {
67
+ return { result: undefined, thrown };
68
+ }
69
+ })();
70
+ expect(first.thrown).toBeUndefined();
71
+ expect(first.result?.ok).toBe(false);
72
+ if (first.result === undefined || first.result.ok) return;
73
+ expect(first.result.error.code).toBe('animation-asset-not-found');
74
+ expect(first.result.error.detail).toEqual({
75
+ guid,
76
+ expectedKind: 'animation-clip',
77
+ lookupCode: 'lookup-threw',
78
+ });
79
+ expect(first.result.error.message).not.toContain('controlled lookup sentinel');
80
+ expect(lookup).toHaveBeenCalledTimes(1);
81
+ expect(internSharedRef).not.toHaveBeenCalled();
82
+
83
+ repaired = true;
84
+ const retried = resolveAnimationAsset(world, guid, 'animation-clip', lookup);
85
+ expect(retried.ok).toBe(true);
86
+ if (!retried.ok) return;
87
+ expect(retried.value.asset).toBe(clip);
88
+ expect(lookup).toHaveBeenCalledTimes(2);
89
+ expect(internSharedRef).toHaveBeenCalledTimes(1);
90
+
91
+ const repeated = resolveAnimationAsset(world, guid, 'animation-clip', lookup);
92
+ expect(repeated.ok).toBe(true);
93
+ if (!repeated.ok) return;
94
+ expect(repeated.value.asset).toBe(clip);
95
+ expect(repeated.value.handle).toBe(retried.value.handle);
96
+ expect(lookup).toHaveBeenCalledTimes(3);
97
+ expect(internSharedRef).toHaveBeenCalledTimes(2);
98
+ });
99
+ });
@@ -0,0 +1,10 @@
1
+ // @perf-budget-skip: intentional public-root cold-import contract gate.
2
+
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ describe('animation graph registration stays plugin-owned', () => {
6
+ it('does not project the plugin-internal registration helper from the public root', async () => {
7
+ const root = (await import('../index')) as Record<string, unknown>;
8
+ expect(root).not.toHaveProperty('registerEvaluateAnimationGraph');
9
+ });
10
+ });
@@ -0,0 +1,26 @@
1
+ // @ts-expect-error Node built-in types are provided by the Vitest runner.
2
+ import { readFileSync } from 'node:fs';
3
+ import { ENTITY_NULL_RAW } from '@forgeax/engine-ecs';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ const APP_SOURCES = [
7
+ '../../../../apps/hello/skin/src/main.ts',
8
+ '../../../../apps/hello/fbx-skin/src/main.ts',
9
+ '../../../../apps/hello/animation-graph/src/main.ts',
10
+ '../../../../apps/collectathon/src/spawn/spawn-player.ts',
11
+ ] as const;
12
+
13
+ describe('SceneInstance animation target collection', () => {
14
+ it('keeps entity zero and skips only ENTITY_NULL_RAW', () => {
15
+ const mapping = new Uint32Array([0, 7, ENTITY_NULL_RAW]);
16
+ expect(Array.from(mapping).filter((raw) => raw !== ENTITY_NULL_RAW)).toEqual([0, 7]);
17
+ });
18
+
19
+ it.each(APP_SOURCES)('%s explicitly binds targets without treating zero as null', (path) => {
20
+ const source = readFileSync(new URL(path, import.meta.url), 'utf8');
21
+ expect(source).toContain('bindAnimationTargets');
22
+ expect(source).toContain('AnimationTargetId');
23
+ expect(source).toContain('ENTITY_NULL_RAW');
24
+ expect(source).not.toMatch(/(?:entRaw|raw)\s*===\s*0/);
25
+ });
26
+ });
@@ -0,0 +1,8 @@
1
+ import type { AnimationGraph } from '@forgeax/engine-types';
2
+ import { expectTypeOf } from 'vitest';
3
+ import { defineAnimationGraph } from '../graph/define-animation-graph';
4
+
5
+ const graph = defineAnimationGraph((builder) =>
6
+ builder.clip('019ffa97-9000-7000-8000-000000000013'),
7
+ );
8
+ if (graph.ok) expectTypeOf(graph.value).toMatchTypeOf<AnimationGraph>();
@@ -0,0 +1,79 @@
1
+ // Regression test for a bug fixed 2026-07-27 (feedback:
2
+ // advance-animation-player-hardcoded-fixed-dt-ignores-time-delta): the
3
+ // `AdvanceAnimationPlayer` / `EvaluateAnimationGraph` system tokens used to call
4
+ // their pure functions with a literal `1 / 60` instead of the World's real
5
+ // `Time.delta`, so animation played back at the wrong speed whenever
6
+ // `world.update(dt)` was driven at anything other than exactly 60Hz.
7
+ //
8
+ // These tests drive `world.update(dt)` at 30Hz / 60Hz / 120Hz through the real
9
+ // default-plugin schedule (not by calling `advanceAnimationPlayer` /
10
+ // `evaluateAnimationGraph` directly with an explicit `dt` — that would only
11
+ // prove the pure functions are dt-aware, not that the systems wire the real
12
+ // frame time into them) and assert the advanced time columns scale with the
13
+ // driven `dt`, not with a fixed `1 / 60`.
14
+
15
+ import type { EntityHandle } from '@forgeax/engine-ecs';
16
+ import { createWorldContext, World } from '@forgeax/engine-ecs';
17
+ import type { AnimationClip } from '@forgeax/engine-types';
18
+ import { describe, expect, it } from 'vitest';
19
+ import { AnimationPlayer } from '../animation-player';
20
+ import { defineAnimationGraph } from '../graph/define-animation-graph';
21
+ import { animationPlugin } from '../plugin';
22
+
23
+ function registerClip(world: World, duration: number) {
24
+ const clip: AnimationClip = { kind: 'animation-clip', duration, channels: [] };
25
+ return world.allocSharedRef('AnimationClip', clip);
26
+ }
27
+
28
+ const FRAME_RATES_HZ = [30, 60, 120] as const;
29
+
30
+ describe('animation systems honor real Time.delta (regression: hardcoded 1/60)', () => {
31
+ it.each(
32
+ FRAME_RATES_HZ,
33
+ )('advanceAnimationPlayer advances times[0] by the real dt at %iHz, not a fixed 1/60', async (hz) => {
34
+ const dt = 1 / hz;
35
+ const world = new World();
36
+ await createWorldContext(world, [animationPlugin()]);
37
+
38
+ const clip = registerClip(world, 10);
39
+ const e = world
40
+ .spawn({
41
+ component: AnimationPlayer,
42
+ data: { clips: [clip], times: [0], weights: [1], speeds: [1] },
43
+ })
44
+ .unwrap() as EntityHandle;
45
+
46
+ world.update(dt);
47
+
48
+ const ap = world.get(e, AnimationPlayer).unwrap() as unknown as { times: Float32Array };
49
+ expect(ap.times[0]).toBeCloseTo(dt, 5);
50
+ });
51
+
52
+ it.each(
53
+ FRAME_RATES_HZ,
54
+ )('evaluateAnimationGraph advances nodeTimes[0] by the real dt at %iHz, not a fixed 1/60', async (hz) => {
55
+ const dt = 1 / hz;
56
+ const world = new World();
57
+ const clipPayload: AnimationClip = { kind: 'animation-clip', duration: 10, channels: [] };
58
+ await createWorldContext(world, [
59
+ animationPlugin((guid) => (guid === 'test/animation-clip-delta' ? clipPayload : undefined)),
60
+ ]);
61
+
62
+ registerClip(world, 10);
63
+ const built = defineAnimationGraph((b) => b.clip('test/animation-clip-delta'));
64
+ expect(built.ok).toBe(true);
65
+ if (!built.ok) return;
66
+ const graphH = world.allocSharedRef('AnimationGraph', built.value);
67
+
68
+ // `nodeSpeeds` defaults to 0 per node when unset (unlike `nodeWeights`,
69
+ // which defaults to 1) — must be explicit or the clip node never advances.
70
+ const e = world
71
+ .spawn({ component: AnimationPlayer, data: { graph: graphH, nodeSpeeds: [1] } })
72
+ .unwrap() as EntityHandle;
73
+
74
+ world.update(dt);
75
+
76
+ const ap = world.get(e, AnimationPlayer).unwrap() as unknown as { nodeTimes: Float32Array };
77
+ expect(ap.nodeTimes[0]).toBeCloseTo(dt, 5);
78
+ });
79
+ });
@@ -0,0 +1,198 @@
1
+ import type { EntityHandle } from '@forgeax/engine-ecs';
2
+ import { createWorldContext, World } from '@forgeax/engine-ecs';
3
+ import { ChildOf, Name, scenePlugin, Transform } from '@forgeax/engine-scene';
4
+ import type { AnimationClip, AnimationTargetIdValue, Handle } from '@forgeax/engine-types';
5
+ import { describe, expect, it } from 'vitest';
6
+ import { AnimationPlayer } from '../animation-player';
7
+ import { AnimationTargetId, bindAnimationTargets } from '../animation-target';
8
+ import { defineAnimationGraph } from '../graph/define-animation-graph';
9
+ import { animationPlugin } from '../plugin';
10
+
11
+ const TARGET_ID = 'a95da0ec669189f98273e8f86d8ad9f2' as AnimationTargetIdValue;
12
+
13
+ function transformClip(interpolation: 'LINEAR' | 'STEP' = 'LINEAR'): AnimationClip {
14
+ return {
15
+ kind: 'animation-clip',
16
+ duration: 1,
17
+ channels: [
18
+ {
19
+ targetId: TARGET_ID,
20
+ property: 'translation',
21
+ sampler: {
22
+ input: new Float32Array([0, 1]),
23
+ output: new Float32Array([0, 0, 0, 4, 8, 12]),
24
+ interpolation,
25
+ },
26
+ },
27
+ {
28
+ targetId: TARGET_ID,
29
+ property: 'rotation',
30
+ sampler: {
31
+ input: new Float32Array([0, 1]),
32
+ output: new Float32Array([0, 0, 0, 1, 0, 0, 1, 0]),
33
+ interpolation,
34
+ },
35
+ },
36
+ {
37
+ targetId: TARGET_ID,
38
+ property: 'scale',
39
+ sampler: {
40
+ input: new Float32Array([0, 1]),
41
+ output: new Float32Array([1, 1, 1, 3, 5, 7]),
42
+ interpolation,
43
+ },
44
+ },
45
+ ],
46
+ };
47
+ }
48
+
49
+ async function setupPlayer(
50
+ clip: AnimationClip,
51
+ graph: boolean,
52
+ ): Promise<{ world: World; player: EntityHandle; target: EntityHandle }> {
53
+ const world = new World();
54
+ await createWorldContext(world, [scenePlugin()]);
55
+ await createWorldContext(world, [animationPlugin(graph ? () => clip : undefined)]);
56
+ const clipHandle = world.allocSharedRef('AnimationClip', clip);
57
+ const player = world
58
+ .spawn({ component: Transform, data: { pos: [10, 0, 0] } })
59
+ .unwrap() as EntityHandle;
60
+ world.addComponent(player, { component: Name, data: { value: 'Root' } }).unwrap();
61
+ if (graph) {
62
+ const built = defineAnimationGraph((builder) => builder.clip('test/animation-clip-target'));
63
+ expect(built.ok).toBe(true);
64
+ if (!built.ok) throw built.error;
65
+ const graphHandle = world.allocSharedRef('AnimationGraph', built.value);
66
+ world
67
+ .addComponent(player, {
68
+ component: AnimationPlayer,
69
+ data: {
70
+ graph: graphHandle,
71
+ nodeTimes: [0],
72
+ nodeWeights: [1],
73
+ nodeSpeeds: [0],
74
+ paused: true,
75
+ looping: false,
76
+ },
77
+ })
78
+ .unwrap();
79
+ } else {
80
+ world
81
+ .addComponent(player, {
82
+ component: AnimationPlayer,
83
+ data: {
84
+ clips: [clipHandle],
85
+ times: [0],
86
+ weights: [1],
87
+ speeds: [0],
88
+ paused: true,
89
+ looping: false,
90
+ },
91
+ })
92
+ .unwrap();
93
+ }
94
+ const target = world.spawn({ component: Transform, data: {} }).unwrap() as EntityHandle;
95
+ world.addComponent(target, { component: Name, data: { value: 'Hip' } }).unwrap();
96
+ world.addComponent(target, { component: ChildOf, data: { parent: player } }).unwrap();
97
+ world
98
+ .addComponent(target, {
99
+ component: AnimationTargetId,
100
+ data: { value: TARGET_ID },
101
+ })
102
+ .unwrap();
103
+ expect(bindAnimationTargets(world, player, [target]).ok).toBe(true);
104
+ return { world, player, target };
105
+ }
106
+
107
+ function expectTrsAt(
108
+ world: World,
109
+ target: EntityHandle,
110
+ time: number,
111
+ interpolation: 'LINEAR' | 'STEP',
112
+ ) {
113
+ const transform = world.get(target, Transform).unwrap();
114
+ const alpha = interpolation === 'STEP' && time < 1 ? 0 : time;
115
+ expect([...transform.pos]).toEqual([
116
+ expect.closeTo(4 * alpha),
117
+ expect.closeTo(8 * alpha),
118
+ expect.closeTo(12 * alpha),
119
+ ]);
120
+ expect([...transform.scale]).toEqual([
121
+ expect.closeTo(1 + 2 * alpha),
122
+ expect.closeTo(1 + 4 * alpha),
123
+ expect.closeTo(1 + 6 * alpha),
124
+ ]);
125
+ expect(transform.quat[2]).toBeCloseTo(Math.sin((Math.PI / 2) * alpha), 4);
126
+ expect(transform.quat[3]).toBeCloseTo(Math.cos((Math.PI / 2) * alpha), 4);
127
+ expect(transform.world[12]).toBeCloseTo(10 + 4 * alpha, 4);
128
+ }
129
+
130
+ describe('generic Transform target playback', () => {
131
+ it.each([
132
+ 'LINEAR',
133
+ 'STEP',
134
+ ] as const)('samples direct translation, rotation, scale, and propagated world values with %s', async (interpolation) => {
135
+ const { world, player, target } = await setupPlayer(transformClip(interpolation), false);
136
+ for (const time of [0, 0.25, 0.5, 1]) {
137
+ world.set(player, AnimationPlayer, { times: [time] }).unwrap();
138
+ world.update(0);
139
+ expectTrsAt(world, target, time, interpolation);
140
+ }
141
+ });
142
+
143
+ it('drives the same Transform path from an AnimationGraph', async () => {
144
+ const { world, player, target } = await setupPlayer(transformClip(), true);
145
+ for (const time of [0, 0.25, 0.5, 1]) {
146
+ world.set(player, AnimationPlayer, { nodeTimes: [time] }).unwrap();
147
+ world.update(0);
148
+ expectTrsAt(world, target, time, 'LINEAR');
149
+ }
150
+ });
151
+
152
+ it('preserves pause, loop, replay, speed, blend, and non-positive weight semantics', async () => {
153
+ const { world, player, target } = await setupPlayer(transformClip(), false);
154
+ const clipHandle = world.get(player, AnimationPlayer).unwrap().clips[0] as unknown as Handle<
155
+ 'AnimationClip',
156
+ 'shared'
157
+ >;
158
+ world.set(player, AnimationPlayer, { paused: false, speeds: [2], times: [0] }).unwrap();
159
+ world.update(0.125);
160
+ expect(world.get(player, AnimationPlayer).unwrap().times[0]).toBeCloseTo(0.2);
161
+
162
+ world.set(player, AnimationPlayer, { looping: true, times: [0.95], speeds: [1] }).unwrap();
163
+ world.update(0.1);
164
+ expect(world.get(player, AnimationPlayer).unwrap().times[0]).toBeCloseTo(0.05);
165
+
166
+ world.set(player, AnimationPlayer, {
167
+ looping: false,
168
+ paused: true,
169
+ clips: [clipHandle, clipHandle],
170
+ times: [0, 1],
171
+ weights: [1, 1],
172
+ speeds: [0, 0],
173
+ });
174
+ world.update(0);
175
+ expect([...world.get(target, Transform).unwrap().pos]).toEqual([
176
+ expect.closeTo(2),
177
+ expect.closeTo(4),
178
+ expect.closeTo(6),
179
+ ]);
180
+
181
+ world.set(player, AnimationPlayer, { times: [1, 1], weights: [0, -1] }).unwrap();
182
+ world.update(0);
183
+ expect([...world.get(target, Transform).unwrap().pos]).toEqual([
184
+ expect.closeTo(2),
185
+ expect.closeTo(4),
186
+ expect.closeTo(6),
187
+ ]);
188
+
189
+ world.set(player, AnimationPlayer, {
190
+ clips: [clipHandle],
191
+ times: [0],
192
+ weights: [1],
193
+ speeds: [0],
194
+ });
195
+ world.update(0);
196
+ expect([...world.get(target, Transform).unwrap().pos]).toEqual([0, 0, 0]);
197
+ });
198
+ });
@@ -0,0 +1,88 @@
1
+ import type { World } from '@forgeax/engine-ecs';
2
+
3
+ export type AnimationDiagnosticCode =
4
+ | 'animation-target-missing'
5
+ | 'animation-target-transform-missing'
6
+ | 'animation-target-id-duplicate'
7
+ | 'animation-target-owner-stale'
8
+ | 'animation-channel-missing'
9
+ | 'animation-target-morph-weights-missing'
10
+ | 'animation-morph-weight-count-mismatch';
11
+
12
+ export interface AnimationDiagnosticDetail {
13
+ readonly player: number;
14
+ readonly clip: number;
15
+ readonly channel: number;
16
+ readonly targetId: string;
17
+ readonly reason:
18
+ | 'target-missing'
19
+ | 'transform-missing'
20
+ | 'target-id-duplicate'
21
+ | 'target-stale'
22
+ | 'channel-missing'
23
+ | 'morph-weights-missing'
24
+ | 'morph-weight-count-mismatch';
25
+ readonly target?: number;
26
+ readonly property?: 'translation' | 'rotation' | 'scale' | 'weights';
27
+ readonly expectedWeightCount?: number;
28
+ readonly actualWeightCount?: number;
29
+ }
30
+
31
+ export interface AnimationDiagnostic {
32
+ readonly code: AnimationDiagnosticCode;
33
+ readonly hint: string;
34
+ readonly detail: Readonly<AnimationDiagnosticDetail>;
35
+ }
36
+
37
+ export type AnimationDiagnosticListener = (
38
+ world: World,
39
+ diagnostic: Readonly<AnimationDiagnostic>,
40
+ ) => void;
41
+
42
+ const emittedKeysByWorld = new WeakMap<World, Set<string>>();
43
+ const listeners = new Set<AnimationDiagnosticListener>();
44
+
45
+ /** Observe unique channel-level diagnostic facts for editor/runtime projection. */
46
+ export function subscribeAnimationDiagnostics(listener: AnimationDiagnosticListener): () => void {
47
+ listeners.add(listener);
48
+ return () => listeners.delete(listener);
49
+ }
50
+
51
+ /** @internal */
52
+ export function isAnimationDevMode(): boolean {
53
+ const proc = (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process;
54
+ if (proc?.env?.NODE_ENV === 'production') return false;
55
+ return Boolean((import.meta as { env?: { DEV?: unknown } }).env?.DEV) || proc !== undefined;
56
+ }
57
+
58
+ export function emitAnimationDiagnostic(world: World, diagnostic: AnimationDiagnostic): void {
59
+ if (!isAnimationDevMode()) return;
60
+ let emitted = emittedKeysByWorld.get(world);
61
+ if (emitted === undefined) {
62
+ emitted = new Set();
63
+ emittedKeysByWorld.set(world, emitted);
64
+ }
65
+ const { player, clip, channel, targetId, reason } = diagnostic.detail;
66
+ const emittedKey = `${player}|${clip}|${channel}|${targetId}|${reason}`;
67
+ if (emitted.has(emittedKey)) return;
68
+ emitted.add(emittedKey);
69
+
70
+ const frozen = Object.freeze({
71
+ ...diagnostic,
72
+ detail: Object.freeze({ ...diagnostic.detail }),
73
+ });
74
+ for (const listener of listeners) {
75
+ try {
76
+ listener(world, frozen);
77
+ } catch {
78
+ // Diagnostics must never break animation evaluation.
79
+ }
80
+ }
81
+
82
+ console.warn(frozen);
83
+ }
84
+
85
+ /** @internal */
86
+ export function _resetAnimationWarnsForTests(world: World): void {
87
+ emittedKeysByWorld.delete(world);
88
+ }
@@ -0,0 +1,84 @@
1
+ // @forgeax/engine-animation - AnimationPlayer component (variable N-way SoA slots).
2
+ //
3
+ // Schema (10 fields, SoA variable arrays):
4
+ // clips: 'array<shared<AnimationClip>>' (simulation-transient asset binding)
5
+ // times: 'array<f32>' (layer-3 zero — empty Float32Array)
6
+ // weights: 'array<f32>' (layer-3 zero — empty Float32Array)
7
+ // speeds: 'array<f32>' (layer-3 zero — empty Float32Array)
8
+ // graph: 'shared<AnimationGraph>' (layer-3 zero — 0, no graph)
9
+ // nodeWeights: 'array<f32>' (layer-3 zero — empty Float32Array)
10
+ // nodeTimes: 'array<f32>' (layer-3 zero — empty Float32Array)
11
+ // nodeSpeeds: 'array<f32>' (layer-3 zero — empty Float32Array)
12
+ // paused: 'bool' (layer-2 false)
13
+ // looping: 'bool' (layer-2 true)
14
+ //
15
+ // The four parallel columns support up to N concurrent clips for crossfade
16
+ // blending — the fixed 4-slot cap is retired (feat-20260713 M1 / w4). Slot i is
17
+ // active when clips[i] != 0 (invalid handle = id=0). advanceAnimationPlayer
18
+ // skips slot i when clips[i]=0.
19
+ //
20
+ // Graph mode (feat-20260713 M3 / w24, plan D-3): a SINGLE component surface hosts
21
+ // both direct-write and graph-driven playback — no second peer component (the
22
+ // requirements forbid a parallel component that splits the evaluation path). The
23
+ // `graph` scalar carries a `shared<AnimationGraph>` handle; the three node-*
24
+ // columns carry per-node runtime knobs (weight / seek-time / speed), indexed by
25
+ // graph node index:
26
+ // - graph == 0 (no handle): clips[] / times[] / weights[] / speeds[] are the
27
+ // DIRECT-WRITE SSOT (the pre-M3 behaviour; consumers own the slots).
28
+ // - graph != 0: evaluateAnimationGraph (a before-advance system) is the SOLE
29
+ // writer of clips[] / times[] / weights[] / speeds[] — they become DERIVED
30
+ // output of the post-order graph evaluation, and any caller-written slot
31
+ // values are overwritten each frame. The effective weight of a node is its
32
+ // runtime weight (nodeWeights, default 1 per node) x its graph static weight
33
+ // (AC-07 orthogonal product); eval owns the seek-time via nodeTimes/nodeSpeeds
34
+ // and parks speeds[]=0 so advance does not re-advance it (D-7).
35
+ //
36
+ // Parallel-length contract (D-5): clips / times / weights / speeds are variable
37
+ // `array<T>` columns set field-by-field (release-then-alloc per field), so the
38
+ // ECS layer cannot cross-check their lengths. Consumers MUST write all four
39
+ // columns at the SAME length every time; advanceAnimationPlayer's evaluation
40
+ // entry validates this once per row and rejects a mismatch with the structured
41
+ // `animation-player-slot-length-mismatch` error (player-errors.ts).
42
+ //
43
+ // Default-fallback contract (engine-ecs three-layer SSOT):
44
+ // layer 1 (caller) — explicit raw, e.g. `data: { clips: [h], weights: [1] }`
45
+ // layer 2 (this file) — `default:` on the field descriptor (paused / looping)
46
+ // layer 3 (engine-ecs) — typeDefault(fieldType): bool->false, array<T>->[]
47
+ //
48
+ // Unlike the retired fixed schema, a variable column does not tail-pad a short
49
+ // write: `clips: [h]` stores length 1, NOT [h, 0, 0, 0]. The minimal call to
50
+ // play one clip on slot 0 writes all four columns length-synced:
51
+ // data: { clips: [h], times: [0], weights: [1], speeds: [1] }
52
+ //
53
+ // Naming: single-semantic component drops the 'Component' suffix
54
+ // (AGENTS.md §Component naming rule #1). Field names mirror common game
55
+ // engine convention (Unity Animator / Godot AnimationPlayer speed/paused
56
+ // /looping idioms).
57
+ //
58
+ // Decision anchors:
59
+ // - requirements IS-1 (SoA arrays) + AC-01 (variable N-slot, 6 field set)
60
+ // - requirements OOS-6 (no ECS-layer change; reuse the existing variable
61
+ // array<T> / array<shared<T>> vocab, mirroring MeshRenderer.materials)
62
+ // - feat-20260713 M1 plan D-1 (variable columns) / D-6 (speeds default [])
63
+ // - feat-20260713 M3 plan D-3 (graph handle + per-node runtime knobs extend
64
+ // the existing component; no second peer component) / D-7 (eval owns time)
65
+ // - charter P4 (consistent abstraction: single component surface)
66
+
67
+ import { defineComponent } from '@forgeax/engine-ecs';
68
+
69
+ export const AnimationPlayer = defineComponent('AnimationPlayer', {
70
+ // The render/animation owner re-resolves clip assets in the target World;
71
+ // playback clocks and weights remain portable simulation state.
72
+ clips: { type: 'array<shared<AnimationClip>>' },
73
+ times: { type: 'array<f32>' },
74
+ weights: { type: 'array<f32>' },
75
+ speeds: { type: 'array<f32>' },
76
+ // The graph evaluator owns this compiled runtime binding; portable playback
77
+ // controls and derived slots remain available to the simulation record.
78
+ graph: { type: 'shared<AnimationGraph>' },
79
+ nodeWeights: { type: 'array<f32>' },
80
+ nodeTimes: { type: 'array<f32>' },
81
+ nodeSpeeds: { type: 'array<f32>' },
82
+ paused: { type: 'bool', default: false },
83
+ looping: { type: 'bool', default: true },
84
+ });