@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,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
|
+
});
|