@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.
- package/LICENSE +202 -0
- package/README.md +141 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/animation-diagnostic.unit.test.d.ts +2 -0
- package/dist/__tests__/animation-diagnostic.unit.test.d.ts.map +1 -0
- package/dist/__tests__/animation-recovery.integration.test.d.ts +2 -0
- package/dist/__tests__/animation-recovery.integration.test.d.ts.map +1 -0
- package/dist/__tests__/animation-target-id.browser.test.d.ts +2 -0
- package/dist/__tests__/animation-target-id.browser.test.d.ts.map +1 -0
- package/dist/__tests__/animation-target-id.unit.test.d.ts +2 -0
- package/dist/__tests__/animation-target-id.unit.test.d.ts.map +1 -0
- package/dist/__tests__/animation-target-isolation.integration.test.d.ts +2 -0
- package/dist/__tests__/animation-target-isolation.integration.test.d.ts.map +1 -0
- package/dist/__tests__/animation-target-relationship.unit.test.d.ts +2 -0
- package/dist/__tests__/animation-target-relationship.unit.test.d.ts.map +1 -0
- package/dist/__tests__/animation-target-source-boundary.unit.test.d.ts +2 -0
- package/dist/__tests__/animation-target-source-boundary.unit.test.d.ts.map +1 -0
- package/dist/__tests__/bind-animation-targets.unit.test.d.ts +2 -0
- package/dist/__tests__/bind-animation-targets.unit.test.d.ts.map +1 -0
- package/dist/__tests__/documentation-contract.unit.test.d.ts +2 -0
- package/dist/__tests__/documentation-contract.unit.test.d.ts.map +1 -0
- package/dist/__tests__/ecs-consumer.integration.test.d.ts +2 -0
- package/dist/__tests__/ecs-consumer.integration.test.d.ts.map +1 -0
- package/dist/__tests__/errors.unit.test.d.ts +2 -0
- package/dist/__tests__/errors.unit.test.d.ts.map +1 -0
- package/dist/__tests__/graph-player.integration.test.d.ts +2 -0
- package/dist/__tests__/graph-player.integration.test.d.ts.map +1 -0
- package/dist/__tests__/graph-player.unit.test.d.ts +2 -0
- package/dist/__tests__/graph-player.unit.test.d.ts.map +1 -0
- package/dist/__tests__/handle-errors.unit.test.d.ts +2 -0
- package/dist/__tests__/handle-errors.unit.test.d.ts.map +1 -0
- package/dist/__tests__/loader-boundary.unit.test.d.ts +2 -0
- package/dist/__tests__/loader-boundary.unit.test.d.ts.map +1 -0
- package/dist/__tests__/morph-weights-playback.integration.test.d.ts +2 -0
- package/dist/__tests__/morph-weights-playback.integration.test.d.ts.map +1 -0
- package/dist/__tests__/morph-weights.unit.test.d.ts +2 -0
- package/dist/__tests__/morph-weights.unit.test.d.ts.map +1 -0
- package/dist/__tests__/public-animation-target-api.test-d.d.ts +2 -0
- package/dist/__tests__/public-animation-target-api.test-d.d.ts.map +1 -0
- package/dist/__tests__/resolver-ownership.integration.test.d.ts +2 -0
- package/dist/__tests__/resolver-ownership.integration.test.d.ts.map +1 -0
- package/dist/__tests__/root-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/root-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/scene-instance-target-collection.integration.test.d.ts +2 -0
- package/dist/__tests__/scene-instance-target-collection.integration.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts.map +1 -0
- package/dist/__tests__/time-delta-regression.integration.test.d.ts +2 -0
- package/dist/__tests__/time-delta-regression.integration.test.d.ts.map +1 -0
- package/dist/__tests__/transform-target-playback.integration.test.d.ts +2 -0
- package/dist/__tests__/transform-target-playback.integration.test.d.ts.map +1 -0
- package/dist/animation-diagnostic.d.ts +27 -0
- package/dist/animation-diagnostic.d.ts.map +1 -0
- package/dist/animation-player.d.ts +13 -0
- package/dist/animation-player.d.ts.map +1 -0
- package/dist/animation-target.d.ts +21 -0
- package/dist/animation-target.d.ts.map +1 -0
- package/dist/assets/animation-decoder.d.ts +4 -0
- package/dist/assets/animation-decoder.d.ts.map +1 -0
- package/dist/chunk-DMXAQUPI.mjs +52 -0
- package/dist/chunk-DMXAQUPI.mjs.map +1 -0
- package/dist/errors.d.ts +120 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/graph/define-animation-graph.d.ts +42 -0
- package/dist/graph/define-animation-graph.d.ts.map +1 -0
- package/dist/graph/describe-animation-graph.d.ts +37 -0
- package/dist/graph/describe-animation-graph.d.ts.map +1 -0
- package/dist/graph/serialize-animation-graph.d.ts +27 -0
- package/dist/graph/serialize-animation-graph.d.ts.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1457 -0
- package/dist/index.mjs.map +1 -0
- package/dist/player-errors.d.ts +43 -0
- package/dist/player-errors.d.ts.map +1 -0
- package/dist/plugin.d.ts +13 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/resolve-animation-asset.d.ts +42 -0
- package/dist/resolve-animation-asset.d.ts.map +1 -0
- package/dist/systems/advance-animation-player.d.ts +40 -0
- package/dist/systems/advance-animation-player.d.ts.map +1 -0
- package/dist/systems/evaluate-animation-graph.d.ts +32 -0
- package/dist/systems/evaluate-animation-graph.d.ts.map +1 -0
- package/dist/target-id.d.ts +4 -0
- package/dist/target-id.d.ts.map +1 -0
- package/dist/target-id.mjs +9 -0
- package/dist/target-id.mjs.map +1 -0
- package/package.json +65 -0
- package/src/__tests__/animation-diagnostic.unit.test.ts +237 -0
- package/src/__tests__/animation-recovery.integration.test.ts +125 -0
- package/src/__tests__/animation-target-id.browser.test.ts +22 -0
- package/src/__tests__/animation-target-id.unit.test.ts +41 -0
- package/src/__tests__/animation-target-isolation.integration.test.ts +179 -0
- package/src/__tests__/animation-target-relationship.unit.test.ts +65 -0
- package/src/__tests__/animation-target-source-boundary.unit.test.ts +49 -0
- package/src/__tests__/bind-animation-targets.unit.test.ts +167 -0
- package/src/__tests__/documentation-contract.unit.test.ts +94 -0
- package/src/__tests__/ecs-consumer.integration.test.ts +10 -0
- package/src/__tests__/errors.unit.test.ts +19 -0
- package/src/__tests__/graph-player.integration.test.ts +22 -0
- package/src/__tests__/graph-player.unit.test.ts +12 -0
- package/src/__tests__/handle-errors.unit.test.ts +17 -0
- package/src/__tests__/loader-boundary.unit.test.ts +66 -0
- package/src/__tests__/morph-weights-playback.integration.test.ts +84 -0
- package/src/__tests__/morph-weights.unit.test.ts +26 -0
- package/src/__tests__/public-animation-target-api.test-d.ts +47 -0
- package/src/__tests__/resolver-ownership.integration.test.ts +99 -0
- package/src/__tests__/root-surface.unit.test.ts +10 -0
- package/src/__tests__/scene-instance-target-collection.integration.test.ts +26 -0
- package/src/__tests__/scriptable-pack-consumer.test-d.ts +8 -0
- package/src/__tests__/time-delta-regression.integration.test.ts +79 -0
- package/src/__tests__/transform-target-playback.integration.test.ts +198 -0
- package/src/animation-diagnostic.ts +88 -0
- package/src/animation-player.ts +84 -0
- package/src/animation-target.ts +257 -0
- package/src/assets/animation-decoder.ts +107 -0
- package/src/errors.ts +201 -0
- package/src/graph/define-animation-graph.ts +193 -0
- package/src/graph/describe-animation-graph.ts +74 -0
- package/src/graph/serialize-animation-graph.ts +90 -0
- package/src/index.ts +57 -0
- package/src/player-errors.ts +72 -0
- package/src/plugin.ts +73 -0
- package/src/resolve-animation-asset.ts +128 -0
- package/src/systems/advance-animation-player.ts +971 -0
- package/src/systems/evaluate-animation-graph.ts +261 -0
- package/src/target-id.ts +51 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @ts-expect-error Node built-in types are provided by the Vitest runner.
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
describe('animation target source boundary', () => {
|
|
6
|
+
it('registers animation target components before hello-skin writeback instantiates glTF', () => {
|
|
7
|
+
const source = readFileSync(
|
|
8
|
+
new URL('../../../../apps/hello/skin/scripts/smoke-writeback-dawn.mjs', import.meta.url),
|
|
9
|
+
'utf8',
|
|
10
|
+
);
|
|
11
|
+
const registration = source.indexOf("await import('@forgeax/engine-animation')");
|
|
12
|
+
const instantiate = source.indexOf('worldInstantiateScene(world, sceneHandle)');
|
|
13
|
+
|
|
14
|
+
expect(registration).toBeGreaterThanOrEqual(0);
|
|
15
|
+
expect(instantiate).toBeGreaterThan(registration);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('does not discover animation targets through Skin, Name, or targetPath', () => {
|
|
19
|
+
const source = readFileSync(
|
|
20
|
+
new URL('../systems/advance-animation-player.ts', import.meta.url),
|
|
21
|
+
'utf8',
|
|
22
|
+
);
|
|
23
|
+
expect(source).not.toMatch(/\bSkin\b/);
|
|
24
|
+
expect(source).not.toMatch(/\bName\b/);
|
|
25
|
+
expect(source).not.toContain('targetPath');
|
|
26
|
+
expect(source).toContain('AnimationTargets');
|
|
27
|
+
expect(source).toContain('AnimationTargetId');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('keeps Skin joints limited to palette and post-spawn joint wiring', () => {
|
|
31
|
+
const evaluator = readFileSync(
|
|
32
|
+
new URL('../systems/advance-animation-player.ts', import.meta.url),
|
|
33
|
+
'utf8',
|
|
34
|
+
);
|
|
35
|
+
const palette = readFileSync(
|
|
36
|
+
new URL('../../../render/src/systems/skin-palette-allocator.ts', import.meta.url),
|
|
37
|
+
'utf8',
|
|
38
|
+
);
|
|
39
|
+
const jointWiring = readFileSync(
|
|
40
|
+
new URL('../../../render/src/scene-instances/post-spawn-resolve-joints.ts', import.meta.url),
|
|
41
|
+
'utf8',
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
expect(evaluator).not.toMatch(/\bSkin\.joints\b|world\.get\([^)]*,\s*Skin\b/);
|
|
45
|
+
expect(palette).toContain('Skin.joints');
|
|
46
|
+
expect(jointWiring).toContain('Skin');
|
|
47
|
+
expect(jointWiring).toContain('joints');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { type EntityHandle, World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { ChildOf, Name, Transform } from '@forgeax/engine-scene';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { AnimationPlayer } from '../animation-player';
|
|
5
|
+
import {
|
|
6
|
+
AnimatedBy,
|
|
7
|
+
AnimationTargetId,
|
|
8
|
+
AnimationTargets,
|
|
9
|
+
bindAnimationTargets,
|
|
10
|
+
} from '../animation-target';
|
|
11
|
+
import { deriveAnimationTargetId } from '../target-id';
|
|
12
|
+
|
|
13
|
+
function spawnPlayer(world: World, withMirror = true) {
|
|
14
|
+
if (!withMirror) {
|
|
15
|
+
return world
|
|
16
|
+
.spawn(
|
|
17
|
+
{ component: AnimationPlayer, data: {} },
|
|
18
|
+
{ component: Transform, data: {} },
|
|
19
|
+
{ component: Name, data: { value: 'Root' } },
|
|
20
|
+
)
|
|
21
|
+
.unwrap();
|
|
22
|
+
}
|
|
23
|
+
return world
|
|
24
|
+
.spawn(
|
|
25
|
+
{ component: AnimationPlayer, data: {} },
|
|
26
|
+
{ component: Transform, data: {} },
|
|
27
|
+
{ component: Name, data: { value: 'Root' } },
|
|
28
|
+
{ component: AnimationTargets, data: {} },
|
|
29
|
+
)
|
|
30
|
+
.unwrap();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function spawnTarget(world: World, player: EntityHandle, name = 'Target') {
|
|
34
|
+
return world
|
|
35
|
+
.spawn(
|
|
36
|
+
{ component: Transform, data: {} },
|
|
37
|
+
{ component: Name, data: { value: name } },
|
|
38
|
+
{ component: ChildOf, data: { parent: player } },
|
|
39
|
+
)
|
|
40
|
+
.unwrap();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function expectBindCode(result: ReturnType<typeof bindAnimationTargets>, code: string) {
|
|
44
|
+
expect(result.ok).toBe(false);
|
|
45
|
+
if (result.ok) return;
|
|
46
|
+
expect(result.error.code).toBe(code);
|
|
47
|
+
expect(result.error.hint.length).toBeGreaterThan(0);
|
|
48
|
+
expect(result.error.detail).toBeDefined();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('bindAnimationTargets', () => {
|
|
52
|
+
it('derives missing IDs, preserves existing IDs, and is idempotent', () => {
|
|
53
|
+
const world = new World();
|
|
54
|
+
const player = spawnPlayer(world);
|
|
55
|
+
const derived = spawnTarget(world, player, 'Hip');
|
|
56
|
+
const existing = spawnTarget(world, player, 'Hand');
|
|
57
|
+
const existingId = deriveAnimationTargetId(['authored', 'hand']);
|
|
58
|
+
world
|
|
59
|
+
.addComponent(existing, { component: AnimationTargetId, data: { value: existingId } })
|
|
60
|
+
.unwrap();
|
|
61
|
+
world.removeComponent(existing, Name).unwrap();
|
|
62
|
+
|
|
63
|
+
expect(bindAnimationTargets(world, player, [derived, existing]).ok).toBe(true);
|
|
64
|
+
expect(world.get(derived, AnimationTargetId).unwrap().value).toBe(
|
|
65
|
+
deriveAnimationTargetId(['Root', 'Hip']),
|
|
66
|
+
);
|
|
67
|
+
expect(world.get(existing, AnimationTargetId).unwrap().value).toBe(existingId);
|
|
68
|
+
expect(world.get(derived, AnimatedBy).unwrap().player).toBe(player);
|
|
69
|
+
expect(world.get(existing, AnimatedBy).unwrap().player).toBe(player);
|
|
70
|
+
expect([...world.get(player, AnimationTargets).unwrap().targets]).toEqual([derived, existing]);
|
|
71
|
+
|
|
72
|
+
expect(bindAnimationTargets(world, player, [existing, derived, existing]).ok).toBe(true);
|
|
73
|
+
expect([...world.get(player, AnimationTargets).unwrap().targets]).toEqual([derived, existing]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('creates a missing mirror and replaces a stale owner', () => {
|
|
77
|
+
const world = new World();
|
|
78
|
+
const player = spawnPlayer(world, false);
|
|
79
|
+
const staleOwner = spawnPlayer(world);
|
|
80
|
+
const target = spawnTarget(world, player);
|
|
81
|
+
world.addComponent(target, { component: AnimatedBy, data: { player: staleOwner } }).unwrap();
|
|
82
|
+
world.despawn(staleOwner).unwrap();
|
|
83
|
+
|
|
84
|
+
expect(bindAnimationTargets(world, player, [target]).ok).toBe(true);
|
|
85
|
+
expect(world.get(target, AnimatedBy).unwrap().player).toBe(player);
|
|
86
|
+
expect([...world.get(player, AnimationTargets).unwrap().targets]).toEqual([target]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('reports invalid player, target, hierarchy, and missing-name failures', () => {
|
|
90
|
+
const world = new World();
|
|
91
|
+
const player = spawnPlayer(world);
|
|
92
|
+
const invalidPlayer = world.spawn({ component: Transform, data: {} }).unwrap();
|
|
93
|
+
const outside = world
|
|
94
|
+
.spawn({ component: Transform, data: {} }, { component: Name, data: { value: 'Outside' } })
|
|
95
|
+
.unwrap();
|
|
96
|
+
const noName = world
|
|
97
|
+
.spawn({ component: Transform, data: {} }, { component: ChildOf, data: { parent: player } })
|
|
98
|
+
.unwrap();
|
|
99
|
+
const stale = spawnTarget(world, player);
|
|
100
|
+
world.despawn(stale).unwrap();
|
|
101
|
+
|
|
102
|
+
expectBindCode(
|
|
103
|
+
bindAnimationTargets(world, invalidPlayer, []),
|
|
104
|
+
'animation-target-player-invalid',
|
|
105
|
+
);
|
|
106
|
+
expectBindCode(bindAnimationTargets(world, player, [stale]), 'animation-target-invalid');
|
|
107
|
+
expectBindCode(
|
|
108
|
+
bindAnimationTargets(world, player, [outside]),
|
|
109
|
+
'animation-target-outside-player-root',
|
|
110
|
+
);
|
|
111
|
+
expectBindCode(bindAnimationTargets(world, player, [noName]), 'animation-target-name-missing');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('reports duplicate IDs, owner conflicts, and invalid ID wires', () => {
|
|
115
|
+
const world = new World();
|
|
116
|
+
const player = spawnPlayer(world);
|
|
117
|
+
const otherPlayer = spawnPlayer(world);
|
|
118
|
+
const first = spawnTarget(world, player, 'First');
|
|
119
|
+
const second = spawnTarget(world, player, 'Second');
|
|
120
|
+
const duplicate = deriveAnimationTargetId(['duplicate']);
|
|
121
|
+
world
|
|
122
|
+
.addComponent(first, { component: AnimationTargetId, data: { value: duplicate } })
|
|
123
|
+
.unwrap();
|
|
124
|
+
world
|
|
125
|
+
.addComponent(second, { component: AnimationTargetId, data: { value: duplicate } })
|
|
126
|
+
.unwrap();
|
|
127
|
+
expectBindCode(
|
|
128
|
+
bindAnimationTargets(world, player, [first, second]),
|
|
129
|
+
'animation-target-id-duplicate',
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
world.removeComponent(second, AnimationTargetId).unwrap();
|
|
133
|
+
world.addComponent(second, { component: AnimatedBy, data: { player: otherPlayer } }).unwrap();
|
|
134
|
+
expectBindCode(
|
|
135
|
+
bindAnimationTargets(world, player, [second]),
|
|
136
|
+
'animation-target-player-conflict',
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
world.removeComponent(second, AnimatedBy).unwrap();
|
|
140
|
+
world
|
|
141
|
+
.addComponent(second, { component: AnimationTargetId, data: { value: 'not-a-target-id' } })
|
|
142
|
+
.unwrap();
|
|
143
|
+
expectBindCode(bindAnimationTargets(world, player, [second]), 'animation-target-id-invalid');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('rejects an ID already bound to another live target without changing the world', () => {
|
|
147
|
+
const world = new World();
|
|
148
|
+
const player = spawnPlayer(world);
|
|
149
|
+
const first = spawnTarget(world, player, 'First');
|
|
150
|
+
const second = spawnTarget(world, player, 'Second');
|
|
151
|
+
const duplicate = deriveAnimationTargetId(['duplicate']);
|
|
152
|
+
world
|
|
153
|
+
.addComponent(first, { component: AnimationTargetId, data: { value: duplicate } })
|
|
154
|
+
.unwrap();
|
|
155
|
+
world
|
|
156
|
+
.addComponent(second, { component: AnimationTargetId, data: { value: duplicate } })
|
|
157
|
+
.unwrap();
|
|
158
|
+
expect(bindAnimationTargets(world, player, [first]).ok).toBe(true);
|
|
159
|
+
const beforeTargets = [...world.get(player, AnimationTargets).unwrap().targets];
|
|
160
|
+
|
|
161
|
+
expectBindCode(bindAnimationTargets(world, player, [second]), 'animation-target-id-duplicate');
|
|
162
|
+
|
|
163
|
+
expect(world.get(first, AnimatedBy).unwrap().player).toBe(player);
|
|
164
|
+
expect(world.get(second, AnimatedBy).ok).toBe(false);
|
|
165
|
+
expect([...world.get(player, AnimationTargets).unwrap().targets]).toEqual(beforeTargets);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
// @ts-expect-error Vite provides raw text imports to the test runner.
|
|
3
|
+
import readme from '../../README.md?raw';
|
|
4
|
+
import type { BindAnimationTargetsErrorCode } from '../animation-target';
|
|
5
|
+
|
|
6
|
+
const BIND_ERROR_CODES = [
|
|
7
|
+
'animation-target-player-invalid',
|
|
8
|
+
'animation-target-invalid',
|
|
9
|
+
'animation-target-outside-player-root',
|
|
10
|
+
'animation-target-name-missing',
|
|
11
|
+
'animation-target-id-invalid',
|
|
12
|
+
'animation-target-id-duplicate',
|
|
13
|
+
'animation-target-player-conflict',
|
|
14
|
+
'animation-target-capacity-reserve-failed',
|
|
15
|
+
'animation-target-bind-failed',
|
|
16
|
+
] as const satisfies readonly BindAnimationTargetsErrorCode[];
|
|
17
|
+
|
|
18
|
+
describe('animation target documentation contract', () => {
|
|
19
|
+
it('puts a self-contained direct playback flow first', () => {
|
|
20
|
+
const quickStart = readme.indexOf('## Quick start');
|
|
21
|
+
expect(quickStart).toBeGreaterThan(0);
|
|
22
|
+
expect(quickStart).toBeLessThan(readme.indexOf('## Identity and ownership'));
|
|
23
|
+
const firstSection = readme.slice(quickStart, readme.indexOf('## Identity and ownership'));
|
|
24
|
+
for (const literal of [
|
|
25
|
+
"import { createWorldContext, World } from '@forgeax/engine-ecs'",
|
|
26
|
+
"import { ChildOf, Name, Transform } from '@forgeax/engine-scene'",
|
|
27
|
+
"import type { AnimationClip } from '@forgeax/engine-types'",
|
|
28
|
+
'AnimationTargetId',
|
|
29
|
+
'bindAnimationTargets',
|
|
30
|
+
'AnimationPlayer',
|
|
31
|
+
'const world = new World()',
|
|
32
|
+
'const player =',
|
|
33
|
+
'const target =',
|
|
34
|
+
'const clip =',
|
|
35
|
+
'const clipHandle =',
|
|
36
|
+
'const slots =',
|
|
37
|
+
]) {
|
|
38
|
+
expect(firstSection).toContain(literal);
|
|
39
|
+
}
|
|
40
|
+
for (const externalName of [
|
|
41
|
+
'sceneInstance',
|
|
42
|
+
'repair',
|
|
43
|
+
'directSlots',
|
|
44
|
+
'clipHandle)',
|
|
45
|
+
'defineAnimationGraph',
|
|
46
|
+
]) {
|
|
47
|
+
expect(firstSection).not.toContain(externalName);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('documents wire, structured recovery, demo, and explicit boundaries', () => {
|
|
52
|
+
for (const literal of [
|
|
53
|
+
'32 lowercase hexadecimal',
|
|
54
|
+
'BLAKE3',
|
|
55
|
+
'UUID v8',
|
|
56
|
+
'from the player through the',
|
|
57
|
+
'asset root through each target',
|
|
58
|
+
'synthetic runtime controller',
|
|
59
|
+
'error.code',
|
|
60
|
+
'error.hint',
|
|
61
|
+
'error.detail',
|
|
62
|
+
'animation-target-outside-player-root',
|
|
63
|
+
'animation-target-name-missing',
|
|
64
|
+
'animation-target-id-duplicate',
|
|
65
|
+
'animation-target-player-conflict',
|
|
66
|
+
'apps/bevy/animated-transform',
|
|
67
|
+
'arbitrary component properties',
|
|
68
|
+
'animation FSM',
|
|
69
|
+
'editor UI',
|
|
70
|
+
'targetId',
|
|
71
|
+
'targetPath',
|
|
72
|
+
]) {
|
|
73
|
+
expect(readme).toContain(literal);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const errorRecovery = readme.slice(
|
|
77
|
+
readme.indexOf('## Error recovery'),
|
|
78
|
+
readme.indexOf('## Example and boundaries'),
|
|
79
|
+
);
|
|
80
|
+
const documentedCodes = [...errorRecovery.matchAll(/\| `(animation-target-[a-z-]+)` \|/g)].map(
|
|
81
|
+
(match) => match[1],
|
|
82
|
+
);
|
|
83
|
+
expect(documentedCodes).toEqual(BIND_ERROR_CODES);
|
|
84
|
+
|
|
85
|
+
// Rejected review-round-1 spellings stay here only as negative contract fixtures.
|
|
86
|
+
for (const staleCode of [
|
|
87
|
+
'animation-player-invalid',
|
|
88
|
+
'animation-target-outside-root',
|
|
89
|
+
'animation-target-owner-conflict',
|
|
90
|
+
]) {
|
|
91
|
+
expect(readme).not.toContain(staleCode);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createWorldContext, World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { describe, it } from 'vitest';
|
|
3
|
+
import { animationPlugin } from '../plugin';
|
|
4
|
+
|
|
5
|
+
describe('animation ECS consumer', () => {
|
|
6
|
+
it('accepts the plugin build callback from a real World', async () => {
|
|
7
|
+
const world = new World();
|
|
8
|
+
await createWorldContext(world, [animationPlugin()]);
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { AnimationGraphClipMissingError } from '../errors';
|
|
3
|
+
import { AnimationPlayerSlotLengthMismatchError } from '../player-errors';
|
|
4
|
+
|
|
5
|
+
describe('animation errors', () => {
|
|
6
|
+
it('exposes structured codes and details', () => {
|
|
7
|
+
const graph = new AnimationGraphClipMissingError({ node: 1, clip: 2 });
|
|
8
|
+
const player = new AnimationPlayerSlotLengthMismatchError({
|
|
9
|
+
entity: 1,
|
|
10
|
+
clips: 1,
|
|
11
|
+
times: 0,
|
|
12
|
+
weights: 1,
|
|
13
|
+
speeds: 1,
|
|
14
|
+
});
|
|
15
|
+
expect(graph.code).toBe('animation-graph-clip-missing');
|
|
16
|
+
expect(graph.detail.node).toBe(1);
|
|
17
|
+
expect(player.code).toBe('animation-player-slot-length-mismatch');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createWorldContext, Update, World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { defineAnimationGraph } from '../graph/define-animation-graph';
|
|
4
|
+
import { animationPlugin } from '../index';
|
|
5
|
+
|
|
6
|
+
describe('animation graph World chain', () => {
|
|
7
|
+
it('registers systems on a real World schedule', async () => {
|
|
8
|
+
const world = new World();
|
|
9
|
+
await createWorldContext(world, [animationPlugin()]);
|
|
10
|
+
expect(() => world.update(1 / 60)).not.toThrow();
|
|
11
|
+
void Update;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('keeps durable clip identity across graph rebuild and evaluation', () => {
|
|
15
|
+
const guid = '019e2cc6-0c86-79da-aa76-b0984c86d45c';
|
|
16
|
+
const graph = defineAnimationGraph((builder) => builder.clip(guid));
|
|
17
|
+
|
|
18
|
+
expect(graph.ok).toBe(true);
|
|
19
|
+
if (!graph.ok) return;
|
|
20
|
+
expect(graph.value.nodes[0]).toMatchObject({ type: 'clip', clip: guid });
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { defineAnimationGraph, describeAnimationGraph, serializeAnimationGraph } from '../index';
|
|
3
|
+
|
|
4
|
+
describe('animation graph public behavior', () => {
|
|
5
|
+
it('defines, describes, and serializes a graph', () => {
|
|
6
|
+
const built = defineAnimationGraph((builder) => builder.clip('test/animation-clip'));
|
|
7
|
+
expect(built.ok).toBe(true);
|
|
8
|
+
if (!built.ok) return;
|
|
9
|
+
expect(describeAnimationGraph(built.value).nodes).toHaveLength(1);
|
|
10
|
+
expect(serializeAnimationGraph(built.value, () => 'clip')).toBeDefined();
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { World } from '@forgeax/engine-ecs';
|
|
2
|
+
import type { AnimationClip } from '@forgeax/engine-types';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { resolveAnimationAsset } from '../resolve-animation-asset';
|
|
5
|
+
|
|
6
|
+
describe('animation handle failures', () => {
|
|
7
|
+
it('rejects an empty durable GUID at the consumer boundary', () => {
|
|
8
|
+
const result = resolveAnimationAsset<AnimationClip>(
|
|
9
|
+
new World(),
|
|
10
|
+
'',
|
|
11
|
+
'animation-clip',
|
|
12
|
+
() => undefined,
|
|
13
|
+
);
|
|
14
|
+
expect(result.ok).toBe(false);
|
|
15
|
+
if (!result.ok) expect(result.error.code).toBe('animation-asset-not-found');
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { expect, it } from 'vitest';
|
|
2
|
+
import { animationGraphContribution } from '../assets/animation-decoder';
|
|
3
|
+
import { serializeAnimationGraph } from '../graph/serialize-animation-graph';
|
|
4
|
+
|
|
5
|
+
it('keeps graph payload decoding in the animation owner', () => {
|
|
6
|
+
expect(animationGraphContribution.kind.kind).toBe('animation-graph');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('serializes a durable clip GUID without a World handle or resolver cast', () => {
|
|
10
|
+
const guid = '019e2cc6-0c86-79da-aa76-b0984c86d45c';
|
|
11
|
+
const graph = {
|
|
12
|
+
kind: 'animation-graph',
|
|
13
|
+
nodes: [{ type: 'clip', clip: guid, weight: 1 }],
|
|
14
|
+
root: 0,
|
|
15
|
+
} as never;
|
|
16
|
+
|
|
17
|
+
const result = serializeAnimationGraph(graph, (clip) => clip);
|
|
18
|
+
|
|
19
|
+
expect(result).toEqual({
|
|
20
|
+
payload: {
|
|
21
|
+
nodes: [{ type: 'clip', clip: 0, weight: 1 }],
|
|
22
|
+
root: 0,
|
|
23
|
+
},
|
|
24
|
+
refs: [guid],
|
|
25
|
+
});
|
|
26
|
+
expect(JSON.stringify(result?.payload)).not.toContain('shared');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('rejects a missing durable clip GUID instead of inventing a handle', () => {
|
|
30
|
+
const graph = {
|
|
31
|
+
kind: 'animation-graph',
|
|
32
|
+
nodes: [{ type: 'clip', clip: '', weight: 1 }],
|
|
33
|
+
root: 0,
|
|
34
|
+
} as never;
|
|
35
|
+
|
|
36
|
+
expect(serializeAnimationGraph(graph, (clip) => clip || undefined)).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('rejects the removed numeric clip-handle durable shape', () => {
|
|
40
|
+
const graph = {
|
|
41
|
+
kind: 'animation-graph',
|
|
42
|
+
nodes: [{ type: 'clip', clip: 7, weight: 1 }],
|
|
43
|
+
root: 0,
|
|
44
|
+
} as never;
|
|
45
|
+
|
|
46
|
+
expect(
|
|
47
|
+
serializeAnimationGraph(graph, (clip) => (typeof clip === 'string' ? clip : undefined)),
|
|
48
|
+
).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('keeps the animation owner consumer on durable GUID payloads', () => {
|
|
52
|
+
const graph = {
|
|
53
|
+
kind: 'animation-graph',
|
|
54
|
+
nodes: [{ type: 'clip', clip: '019e2cc6-0c86-79da-aa76-b0984c86d45c', weight: 1 }],
|
|
55
|
+
root: 0,
|
|
56
|
+
} as never;
|
|
57
|
+
|
|
58
|
+
expect(serializeAnimationGraph(graph, (clip) => clip)).toMatchObject({
|
|
59
|
+
refs: ['019e2cc6-0c86-79da-aa76-b0984c86d45c'],
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('exposes the graph owner contribution for the shared decoder map', () => {
|
|
64
|
+
expect(animationGraphContribution.kind.kind).toBe('animation-graph');
|
|
65
|
+
expect(animationGraphContribution.consumer).toBe('evaluateAnimationGraph');
|
|
66
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { EntityHandle } from '@forgeax/engine-ecs';
|
|
2
|
+
import { createWorldContext, World } from '@forgeax/engine-ecs';
|
|
3
|
+
import { ChildOf, MorphWeights, Name, scenePlugin, Transform } from '@forgeax/engine-scene';
|
|
4
|
+
import type { AnimationClip, AnimationTargetIdValue } from '@forgeax/engine-types';
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { AnimationPlayer } from '../animation-player';
|
|
7
|
+
import { AnimationTargetId, bindAnimationTargets } from '../animation-target';
|
|
8
|
+
import { animationPlugin } from '../plugin';
|
|
9
|
+
|
|
10
|
+
const TARGET_ID = 'b95da0ec669189f98273e8f86d8ad9f2' as AnimationTargetIdValue;
|
|
11
|
+
|
|
12
|
+
function weightsClip(): AnimationClip {
|
|
13
|
+
return {
|
|
14
|
+
kind: 'animation-clip',
|
|
15
|
+
duration: 1,
|
|
16
|
+
channels: [
|
|
17
|
+
{
|
|
18
|
+
targetId: TARGET_ID,
|
|
19
|
+
property: 'weights',
|
|
20
|
+
sampler: {
|
|
21
|
+
input: new Float32Array([0, 1]),
|
|
22
|
+
output: new Float32Array([0, 0, 0, 0, 1, 2, 3, 4]),
|
|
23
|
+
interpolation: 'LINEAR',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function setupTarget(
|
|
31
|
+
withWeights: boolean,
|
|
32
|
+
): Promise<{ world: World; player: EntityHandle; target: EntityHandle }> {
|
|
33
|
+
const world = new World();
|
|
34
|
+
await createWorldContext(world, [scenePlugin()]);
|
|
35
|
+
await createWorldContext(world, [animationPlugin()]);
|
|
36
|
+
const clip = world.allocSharedRef('AnimationClip', weightsClip());
|
|
37
|
+
const player = world.spawn({ component: Transform, data: {} }).unwrap() as EntityHandle;
|
|
38
|
+
world.addComponent(player, { component: Name, data: { value: 'Root' } }).unwrap();
|
|
39
|
+
world
|
|
40
|
+
.addComponent(player, {
|
|
41
|
+
component: AnimationPlayer,
|
|
42
|
+
data: { clips: [clip], times: [0], weights: [1], speeds: [0], paused: true },
|
|
43
|
+
})
|
|
44
|
+
.unwrap();
|
|
45
|
+
const target = world.spawn({ component: Transform, data: {} }).unwrap() as EntityHandle;
|
|
46
|
+
world.addComponent(target, { component: Name, data: { value: 'Morph' } }).unwrap();
|
|
47
|
+
world.addComponent(target, { component: ChildOf, data: { parent: player } }).unwrap();
|
|
48
|
+
world.addComponent(target, { component: AnimationTargetId, data: { value: TARGET_ID } }).unwrap();
|
|
49
|
+
if (withWeights) {
|
|
50
|
+
world
|
|
51
|
+
.addComponent(target, { component: MorphWeights, data: { weights: [0, 0, 0, 0] } })
|
|
52
|
+
.unwrap();
|
|
53
|
+
}
|
|
54
|
+
expect(bindAnimationTargets(world, player, [target]).ok).toBe(true);
|
|
55
|
+
return { world, player, target };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
afterEach(() => vi.restoreAllMocks());
|
|
59
|
+
|
|
60
|
+
describe('morph weights animation contract', () => {
|
|
61
|
+
it.each([
|
|
62
|
+
0, 0.25, 0.5, 0.999,
|
|
63
|
+
])('samples four morph elements per-element at t=%s, not quaternion interpolation', async (time) => {
|
|
64
|
+
const { world, player, target } = await setupTarget(true);
|
|
65
|
+
world.set(player, AnimationPlayer, { times: [time] }).unwrap();
|
|
66
|
+
world.update(0);
|
|
67
|
+
const expected = [time, time * 2, time * 3, time * 4];
|
|
68
|
+
expect([...world.get(target, MorphWeights).unwrap().weights]).toEqual(
|
|
69
|
+
expected.map((value) => expect.closeTo(value, 5)),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('emits the exact missing MorphWeights diagnostic and does not draw a base substitute', async () => {
|
|
74
|
+
const { world } = await setupTarget(false);
|
|
75
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
76
|
+
world.update(0.25);
|
|
77
|
+
expect(warn).toHaveBeenCalledWith(
|
|
78
|
+
expect.objectContaining({
|
|
79
|
+
code: 'animation-target-morph-weights-missing',
|
|
80
|
+
detail: expect.objectContaining({ property: 'weights', expectedWeightCount: 4 }),
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { AnimationClip } from '@forgeax/engine-types';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
describe('morph weight animation contract', () => {
|
|
5
|
+
it('exposes a runtime MorphWeights component and preserves frame-major channels', async () => {
|
|
6
|
+
const scene = await import('@forgeax/engine-scene');
|
|
7
|
+
expect(scene.MorphWeights).toBeDefined();
|
|
8
|
+
const clip = {
|
|
9
|
+
kind: 'animation-clip',
|
|
10
|
+
duration: 1,
|
|
11
|
+
channels: [
|
|
12
|
+
{
|
|
13
|
+
targetId: 'b95da0ec669189f98273e8f86d8ad9f2',
|
|
14
|
+
property: 'weights',
|
|
15
|
+
sampler: {
|
|
16
|
+
input: new Float32Array([0, 1]),
|
|
17
|
+
output: new Float32Array([0, 0, 0, 0, 1, 2, 3, 4]),
|
|
18
|
+
interpolation: 'LINEAR',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
} as unknown as AnimationClip;
|
|
23
|
+
expect(clip.channels[0]?.property).toBe('weights');
|
|
24
|
+
expect(clip.channels[0]?.sampler.output.length).toBe(8);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { EntityHandle, World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { ENTITY_NULL_RAW } from '@forgeax/engine-ecs';
|
|
3
|
+
import { expectTypeOf } from 'vitest';
|
|
4
|
+
import type { BindAnimationTargetsErrorCode } from '../index';
|
|
5
|
+
import {
|
|
6
|
+
AnimatedBy,
|
|
7
|
+
AnimationPlayer,
|
|
8
|
+
AnimationTargetId,
|
|
9
|
+
AnimationTargets,
|
|
10
|
+
bindAnimationTargets,
|
|
11
|
+
defineAnimationGraph,
|
|
12
|
+
deriveAnimationTargetId,
|
|
13
|
+
isAnimationTargetId,
|
|
14
|
+
} from '../index';
|
|
15
|
+
|
|
16
|
+
expectTypeOf(deriveAnimationTargetId).parameter(0).toEqualTypeOf<readonly string[]>();
|
|
17
|
+
expectTypeOf(isAnimationTargetId).returns.toEqualTypeOf<boolean>();
|
|
18
|
+
expectTypeOf(AnimationTargetId).toBeObject();
|
|
19
|
+
expectTypeOf(AnimatedBy).toBeObject();
|
|
20
|
+
expectTypeOf(AnimationTargets).toBeObject();
|
|
21
|
+
expectTypeOf(AnimationPlayer).toBeObject();
|
|
22
|
+
expectTypeOf(defineAnimationGraph).toBeFunction();
|
|
23
|
+
expectTypeOf<BindAnimationTargetsErrorCode>().toEqualTypeOf<
|
|
24
|
+
| 'animation-target-player-invalid'
|
|
25
|
+
| 'animation-target-invalid'
|
|
26
|
+
| 'animation-target-outside-player-root'
|
|
27
|
+
| 'animation-target-name-missing'
|
|
28
|
+
| 'animation-target-id-invalid'
|
|
29
|
+
| 'animation-target-id-duplicate'
|
|
30
|
+
| 'animation-target-player-conflict'
|
|
31
|
+
| 'animation-target-capacity-reserve-failed'
|
|
32
|
+
| 'animation-target-bind-failed'
|
|
33
|
+
>();
|
|
34
|
+
|
|
35
|
+
function collectAndBind(
|
|
36
|
+
world: World,
|
|
37
|
+
player: EntityHandle,
|
|
38
|
+
scene: { readonly mapping: ArrayLike<number> },
|
|
39
|
+
): ReturnType<typeof bindAnimationTargets> {
|
|
40
|
+
const targets = Array.from(scene.mapping)
|
|
41
|
+
.filter((raw) => raw !== ENTITY_NULL_RAW)
|
|
42
|
+
.map((raw) => raw as EntityHandle)
|
|
43
|
+
.filter((entity) => world.get(entity, AnimationTargetId).ok);
|
|
44
|
+
return bindAnimationTargets(world, player, targets);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
expectTypeOf(collectAndBind).returns.toEqualTypeOf<ReturnType<typeof bindAnimationTargets>>();
|