@forgeax/engine-animation 0.0.0-dev.8d955ade1c79
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 +1459 -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 +66 -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 +49 -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 +263 -0
- package/src/target-id.ts +51 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { EntityHandle } from '@forgeax/engine-ecs';
|
|
2
|
+
import { createWorldContext, World } from '@forgeax/engine-ecs';
|
|
3
|
+
import { Transform } from '@forgeax/engine-scene';
|
|
4
|
+
import type { AnimationClip, AnimationTargetIdValue, Handle } from '@forgeax/engine-types';
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { subscribeAnimationDiagnostics } from '../animation-diagnostic';
|
|
7
|
+
import { AnimationPlayer } from '../animation-player';
|
|
8
|
+
import { AnimatedBy, AnimationTargetId, AnimationTargets } from '../animation-target';
|
|
9
|
+
import { animationPlugin } from '../plugin';
|
|
10
|
+
import { _resetAnimationWarnsForTests } from '../systems/advance-animation-player';
|
|
11
|
+
|
|
12
|
+
const TARGET_ID = 'a95da0ec669189f98273e8f86d8ad9f2' as AnimationTargetIdValue;
|
|
13
|
+
|
|
14
|
+
function clip(targetId = TARGET_ID): AnimationClip {
|
|
15
|
+
return {
|
|
16
|
+
kind: 'animation-clip',
|
|
17
|
+
duration: 1,
|
|
18
|
+
channels: [
|
|
19
|
+
{
|
|
20
|
+
targetId,
|
|
21
|
+
property: 'translation',
|
|
22
|
+
sampler: {
|
|
23
|
+
input: new Float32Array([0, 1]),
|
|
24
|
+
output: new Float32Array([0, 0, 0, 1, 1, 1]),
|
|
25
|
+
interpolation: 'LINEAR',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function playerWithClip(world: World, animationClip = clip()): Promise<EntityHandle> {
|
|
33
|
+
await createWorldContext(world, [animationPlugin()]);
|
|
34
|
+
const handle = world.allocSharedRef('AnimationClip', animationClip);
|
|
35
|
+
return world
|
|
36
|
+
.spawn({
|
|
37
|
+
component: AnimationPlayer,
|
|
38
|
+
data: { clips: [handle], times: [0], weights: [1], speeds: [1] },
|
|
39
|
+
})
|
|
40
|
+
.unwrap() as EntityHandle;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function diagnostics(spy: ReturnType<typeof vi.spyOn>) {
|
|
44
|
+
return spy.mock.calls.map((call: unknown[]) => call[0]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
vi.restoreAllMocks();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('animation runtime diagnostics', () => {
|
|
52
|
+
it('projects unique channels to subscribers while preserving per-channel console facts', async () => {
|
|
53
|
+
const world = new World();
|
|
54
|
+
const singleChannel = clip();
|
|
55
|
+
const firstChannel = singleChannel.channels[0];
|
|
56
|
+
if (firstChannel === undefined) throw new Error('fixture requires one animation channel');
|
|
57
|
+
const twoChannels: AnimationClip = {
|
|
58
|
+
...singleChannel,
|
|
59
|
+
channels: [...singleChannel.channels, { ...firstChannel, property: 'rotation' }],
|
|
60
|
+
};
|
|
61
|
+
const player = await playerWithClip(world, twoChannels);
|
|
62
|
+
const observed: unknown[] = [];
|
|
63
|
+
const unsubscribe = subscribeAnimationDiagnostics((source, diagnostic) => {
|
|
64
|
+
if (source === world) observed.push(diagnostic);
|
|
65
|
+
});
|
|
66
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
67
|
+
|
|
68
|
+
world.update(0.25);
|
|
69
|
+
world.update(0.25);
|
|
70
|
+
unsubscribe();
|
|
71
|
+
|
|
72
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
73
|
+
expect(observed).toHaveLength(2);
|
|
74
|
+
expect(observed).toEqual([
|
|
75
|
+
expect.objectContaining({
|
|
76
|
+
detail: expect.objectContaining({
|
|
77
|
+
player: player as number,
|
|
78
|
+
channel: 0,
|
|
79
|
+
targetId: TARGET_ID,
|
|
80
|
+
}),
|
|
81
|
+
}),
|
|
82
|
+
expect.objectContaining({
|
|
83
|
+
detail: expect.objectContaining({
|
|
84
|
+
player: player as number,
|
|
85
|
+
channel: 1,
|
|
86
|
+
targetId: TARGET_ID,
|
|
87
|
+
}),
|
|
88
|
+
}),
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('emits a readonly structured missing-target diagnostic once per full tuple and World', async () => {
|
|
93
|
+
const first = new World();
|
|
94
|
+
const second = new World();
|
|
95
|
+
const firstPlayer = await playerWithClip(first);
|
|
96
|
+
await playerWithClip(second);
|
|
97
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
98
|
+
|
|
99
|
+
first.update(0.25);
|
|
100
|
+
first.update(0.25);
|
|
101
|
+
second.update(0.25);
|
|
102
|
+
|
|
103
|
+
expect(diagnostics(warn)).toHaveLength(2);
|
|
104
|
+
expect(diagnostics(warn)[0]).toEqual({
|
|
105
|
+
code: 'animation-target-missing',
|
|
106
|
+
hint: expect.any(String),
|
|
107
|
+
detail: {
|
|
108
|
+
player: firstPlayer as number,
|
|
109
|
+
clip: expect.any(Number),
|
|
110
|
+
channel: 0,
|
|
111
|
+
targetId: TARGET_ID,
|
|
112
|
+
reason: 'target-missing',
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
expect(Object.isFrozen(diagnostics(warn)[0])).toBe(true);
|
|
116
|
+
|
|
117
|
+
_resetAnimationWarnsForTests(first);
|
|
118
|
+
first.update(0.25);
|
|
119
|
+
expect(diagnostics(warn)).toHaveLength(3);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('reuses stable target bindings until relationship inputs change', async () => {
|
|
123
|
+
const world = new World();
|
|
124
|
+
const player = await playerWithClip(world);
|
|
125
|
+
const target = world.spawn({ component: Transform, data: {} }).unwrap() as EntityHandle;
|
|
126
|
+
world
|
|
127
|
+
.addComponent(target, { component: AnimationTargetId, data: { value: TARGET_ID } })
|
|
128
|
+
.unwrap();
|
|
129
|
+
world.addComponent(target, { component: AnimatedBy, data: { player } }).unwrap();
|
|
130
|
+
const get = vi.spyOn(world, 'get');
|
|
131
|
+
|
|
132
|
+
world.update(0.25);
|
|
133
|
+
const bindingReadsAfterFirstUpdate = get.mock.calls.filter(
|
|
134
|
+
([, component]) => component === AnimationTargets || component === AnimationTargetId,
|
|
135
|
+
).length;
|
|
136
|
+
world.update(0.25);
|
|
137
|
+
const bindingReadsAfterStableUpdate = get.mock.calls.filter(
|
|
138
|
+
([, component]) => component === AnimationTargets || component === AnimationTargetId,
|
|
139
|
+
).length;
|
|
140
|
+
|
|
141
|
+
expect(bindingReadsAfterFirstUpdate).toBeGreaterThan(0);
|
|
142
|
+
expect(bindingReadsAfterStableUpdate).toBe(bindingReadsAfterFirstUpdate);
|
|
143
|
+
|
|
144
|
+
world.removeComponent(target, Transform).unwrap();
|
|
145
|
+
world.update(0.25);
|
|
146
|
+
const bindingReadsAfterStructuralChange = get.mock.calls.filter(
|
|
147
|
+
([, component]) => component === AnimationTargets || component === AnimationTargetId,
|
|
148
|
+
).length;
|
|
149
|
+
expect(bindingReadsAfterStructuralChange).toBe(bindingReadsAfterStableUpdate);
|
|
150
|
+
|
|
151
|
+
world.addComponent(target, { component: Transform, data: {} }).unwrap();
|
|
152
|
+
world.set(target, AnimationTargetId, { value: 'b'.repeat(32) }).unwrap();
|
|
153
|
+
world.update(0.25);
|
|
154
|
+
const bindingReadsAfterIdChange = get.mock.calls.filter(
|
|
155
|
+
([, component]) => component === AnimationTargets || component === AnimationTargetId,
|
|
156
|
+
).length;
|
|
157
|
+
expect(bindingReadsAfterIdChange).toBeGreaterThan(bindingReadsAfterStructuralChange);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('emits structured diagnostics for missing Transform, duplicate IDs, and missing channels', async () => {
|
|
161
|
+
const world = new World();
|
|
162
|
+
const player = await playerWithClip(world);
|
|
163
|
+
const target = world.spawn({ component: Transform, data: {} }).unwrap() as EntityHandle;
|
|
164
|
+
world
|
|
165
|
+
.addComponent(target, { component: AnimationTargetId, data: { value: TARGET_ID } })
|
|
166
|
+
.unwrap();
|
|
167
|
+
world.addComponent(target, { component: AnimatedBy, data: { player } }).unwrap();
|
|
168
|
+
world.removeComponent(target, Transform).unwrap();
|
|
169
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
170
|
+
|
|
171
|
+
world.update(0.25);
|
|
172
|
+
|
|
173
|
+
expect(diagnostics(warn)).toContainEqual(
|
|
174
|
+
expect.objectContaining({
|
|
175
|
+
code: 'animation-target-transform-missing',
|
|
176
|
+
detail: expect.objectContaining({ reason: 'transform-missing', targetId: TARGET_ID }),
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
world.addComponent(target, { component: Transform, data: {} }).unwrap();
|
|
181
|
+
const duplicate = world.spawn({ component: Transform, data: {} }).unwrap() as EntityHandle;
|
|
182
|
+
world
|
|
183
|
+
.addComponent(duplicate, { component: AnimationTargetId, data: { value: TARGET_ID } })
|
|
184
|
+
.unwrap();
|
|
185
|
+
world.addComponent(duplicate, { component: AnimatedBy, data: { player } }).unwrap();
|
|
186
|
+
world.update(0.25);
|
|
187
|
+
expect(diagnostics(warn)).toContainEqual(
|
|
188
|
+
expect.objectContaining({
|
|
189
|
+
code: 'animation-target-id-duplicate',
|
|
190
|
+
detail: expect.objectContaining({ reason: 'target-id-duplicate', targetId: TARGET_ID }),
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
world.despawn(duplicate).unwrap();
|
|
195
|
+
world.update(0.25);
|
|
196
|
+
|
|
197
|
+
const secondClip: AnimationClip = { kind: 'animation-clip', duration: 1, channels: [] };
|
|
198
|
+
const secondHandle = world.allocSharedRef('AnimationClip', secondClip);
|
|
199
|
+
world.set(player, AnimationPlayer, {
|
|
200
|
+
clips: [
|
|
201
|
+
world.get(player, AnimationPlayer).unwrap().clips[0] as unknown as Handle<
|
|
202
|
+
'AnimationClip',
|
|
203
|
+
'shared'
|
|
204
|
+
>,
|
|
205
|
+
secondHandle,
|
|
206
|
+
],
|
|
207
|
+
times: [0, 0],
|
|
208
|
+
weights: [1, 1],
|
|
209
|
+
speeds: [1, 1],
|
|
210
|
+
});
|
|
211
|
+
world.update(0.25);
|
|
212
|
+
expect(diagnostics(warn)).toContainEqual(
|
|
213
|
+
expect.objectContaining({
|
|
214
|
+
code: 'animation-channel-missing',
|
|
215
|
+
detail: expect.objectContaining({ reason: 'channel-missing', targetId: TARGET_ID }),
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('emits no diagnostic in production', async () => {
|
|
221
|
+
const proc = globalThis as { process?: { env: { NODE_ENV?: string } } };
|
|
222
|
+
const saved = proc.process?.env.NODE_ENV;
|
|
223
|
+
if (proc.process !== undefined) proc.process.env.NODE_ENV = 'production';
|
|
224
|
+
try {
|
|
225
|
+
const world = new World();
|
|
226
|
+
await playerWithClip(world);
|
|
227
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
228
|
+
world.update(0.25);
|
|
229
|
+
expect(warn).not.toHaveBeenCalled();
|
|
230
|
+
} finally {
|
|
231
|
+
if (proc.process !== undefined) {
|
|
232
|
+
if (saved === undefined) delete proc.process.env.NODE_ENV;
|
|
233
|
+
else proc.process.env.NODE_ENV = saved;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
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 } from '@forgeax/engine-types';
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { subscribeAnimationDiagnostics } from '../animation-diagnostic';
|
|
7
|
+
import { AnimationPlayer } from '../animation-player';
|
|
8
|
+
import { AnimationTargetId, bindAnimationTargets } from '../animation-target';
|
|
9
|
+
import { animationPlugin } from '../plugin';
|
|
10
|
+
|
|
11
|
+
const TARGET_ID = 'a95da0ec669189f98273e8f86d8ad9f2' as AnimationTargetIdValue;
|
|
12
|
+
const INVALID_TARGET_ID = 'f'.repeat(32) as AnimationTargetIdValue;
|
|
13
|
+
|
|
14
|
+
function clip(): AnimationClip {
|
|
15
|
+
return {
|
|
16
|
+
kind: 'animation-clip',
|
|
17
|
+
duration: 1,
|
|
18
|
+
channels: [
|
|
19
|
+
{
|
|
20
|
+
targetId: TARGET_ID,
|
|
21
|
+
property: 'translation',
|
|
22
|
+
sampler: {
|
|
23
|
+
input: new Float32Array([0, 1]),
|
|
24
|
+
output: new Float32Array([0, 0, 0, 4, 0, 0]),
|
|
25
|
+
interpolation: 'LINEAR',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function spawnAnimatedPair(world: World): Promise<{
|
|
33
|
+
brokenPlayer: EntityHandle;
|
|
34
|
+
brokenTarget: EntityHandle;
|
|
35
|
+
healthyTarget: EntityHandle;
|
|
36
|
+
}> {
|
|
37
|
+
const handle = world.allocSharedRef('AnimationClip', clip());
|
|
38
|
+
const spawn = (name: string) => {
|
|
39
|
+
const player = world
|
|
40
|
+
.spawn(
|
|
41
|
+
{ component: Transform, data: {} },
|
|
42
|
+
{ component: Name, data: { value: name } },
|
|
43
|
+
{
|
|
44
|
+
component: AnimationPlayer,
|
|
45
|
+
data: { clips: [handle], times: [0], weights: [1], speeds: [1] },
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
.unwrap() as EntityHandle;
|
|
49
|
+
const target = world
|
|
50
|
+
.spawn(
|
|
51
|
+
{ component: Transform, data: {} },
|
|
52
|
+
{ component: Name, data: { value: 'Joint' } },
|
|
53
|
+
{ component: ChildOf, data: { parent: player } },
|
|
54
|
+
{ component: AnimationTargetId, data: { value: TARGET_ID } },
|
|
55
|
+
)
|
|
56
|
+
.unwrap() as EntityHandle;
|
|
57
|
+
expect(bindAnimationTargets(world, player, [target]).ok).toBe(true);
|
|
58
|
+
return { player, target };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const broken = spawn('Broken');
|
|
62
|
+
const healthy = spawn('Healthy');
|
|
63
|
+
return {
|
|
64
|
+
brokenPlayer: broken.player,
|
|
65
|
+
brokenTarget: broken.target,
|
|
66
|
+
healthyTarget: healthy.target,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
vi.restoreAllMocks();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
function positionX(world: World, entity: EntityHandle): number {
|
|
75
|
+
return world.get(entity, Transform).unwrap().pos[0] ?? 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('same-world animation binding recovery', () => {
|
|
79
|
+
it('keeps healthy siblings alive and repairs a missing target binding without rebuilding the world', async () => {
|
|
80
|
+
const world = new World();
|
|
81
|
+
await createWorldContext(world, [scenePlugin()]);
|
|
82
|
+
await createWorldContext(world, [animationPlugin()]);
|
|
83
|
+
const { brokenPlayer, brokenTarget, healthyTarget } = await spawnAnimatedPair(world);
|
|
84
|
+
const diagnostics: unknown[] = [];
|
|
85
|
+
const unsubscribe = subscribeAnimationDiagnostics((source, diagnostic) => {
|
|
86
|
+
if (source === world) diagnostics.push(diagnostic);
|
|
87
|
+
});
|
|
88
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
89
|
+
|
|
90
|
+
world.update(0.2);
|
|
91
|
+
const healthyBeforeFault = positionX(world, healthyTarget);
|
|
92
|
+
const brokenBeforeFault = positionX(world, brokenTarget);
|
|
93
|
+
|
|
94
|
+
world.set(brokenTarget, AnimationTargetId, { value: INVALID_TARGET_ID }).unwrap();
|
|
95
|
+
world.update(0.2);
|
|
96
|
+
|
|
97
|
+
const healthyDuringFault = positionX(world, healthyTarget);
|
|
98
|
+
const brokenDuringFault = positionX(world, brokenTarget);
|
|
99
|
+
expect(healthyDuringFault).toBeGreaterThan(healthyBeforeFault);
|
|
100
|
+
expect(brokenDuringFault).toBeCloseTo(brokenBeforeFault);
|
|
101
|
+
expect(diagnostics).toContainEqual(
|
|
102
|
+
expect.objectContaining({
|
|
103
|
+
code: 'animation-target-missing',
|
|
104
|
+
detail: expect.objectContaining({
|
|
105
|
+
player: brokenPlayer as number,
|
|
106
|
+
targetId: TARGET_ID,
|
|
107
|
+
reason: 'target-missing',
|
|
108
|
+
}),
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const diagnosticCountAtFault = diagnostics.length;
|
|
113
|
+
world.set(brokenTarget, AnimationTargetId, { value: TARGET_ID }).unwrap();
|
|
114
|
+
world.update(0.2);
|
|
115
|
+
|
|
116
|
+
const brokenAfterRepair = positionX(world, brokenTarget);
|
|
117
|
+
expect(brokenAfterRepair).toBeGreaterThan(brokenDuringFault);
|
|
118
|
+
expect(diagnostics).toHaveLength(diagnosticCountAtFault);
|
|
119
|
+
expect(warn).toHaveBeenCalledTimes(diagnosticCountAtFault);
|
|
120
|
+
|
|
121
|
+
unsubscribe();
|
|
122
|
+
unsubscribe();
|
|
123
|
+
expect(world.despawn(brokenPlayer).ok).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { deriveAnimationTargetId, isAnimationTargetId } from '../target-id';
|
|
3
|
+
|
|
4
|
+
const VECTORS = [
|
|
5
|
+
[['Root', 'Hip'], 'a95da0ec669189f98273e8f86d8ad9f2'],
|
|
6
|
+
[['ab'], 'f9435481ae45880387567b7a1b26b41c'],
|
|
7
|
+
[['a', 'b'], '2a4725924b1c84a5adf2f7933aa10c63'],
|
|
8
|
+
[['Root', 'A/B'], '72262f23f7148acaaf24c50e22924ca7'],
|
|
9
|
+
[['\u6839', '\u9aa8'], '06749e3fb5a88f3a8945fb13c1d7a6b1'],
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
describe('animation target id browser vectors', () => {
|
|
13
|
+
it.each(VECTORS)('derives %j', (path, expected) => {
|
|
14
|
+
const actual = deriveAnimationTargetId(path);
|
|
15
|
+
expect(actual).toBe(expected);
|
|
16
|
+
expect(isAnimationTargetId(actual)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('preserves segment boundaries in the browser', () => {
|
|
20
|
+
expect(deriveAnimationTargetId(['ab'])).not.toBe(deriveAnimationTargetId(['a', 'b']));
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { deriveAnimationTargetId, isAnimationTargetId } from '../target-id';
|
|
3
|
+
|
|
4
|
+
const VECTORS = [
|
|
5
|
+
[['Root', 'Hip'], 'a95da0ec669189f98273e8f86d8ad9f2'],
|
|
6
|
+
[['ab'], 'f9435481ae45880387567b7a1b26b41c'],
|
|
7
|
+
[['a', 'b'], '2a4725924b1c84a5adf2f7933aa10c63'],
|
|
8
|
+
[['Root', 'A/B'], '72262f23f7148acaaf24c50e22924ca7'],
|
|
9
|
+
[['\u6839', '\u9aa8'], '06749e3fb5a88f3a8945fb13c1d7a6b1'],
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
describe('animation target id', () => {
|
|
13
|
+
it.each(VECTORS)('derives %j', (path, expected) => {
|
|
14
|
+
const actual = deriveAnimationTargetId(path);
|
|
15
|
+
expect(actual).toBe(expected);
|
|
16
|
+
expect(actual[12]).toBe('8');
|
|
17
|
+
expect(Number.parseInt(actual[16] ?? '', 16) & 0xc).toBe(0x8);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('preserves segment, slash, and UTF-8 boundaries', () => {
|
|
21
|
+
expect(deriveAnimationTargetId(['ab'])).not.toBe(deriveAnimationTargetId(['a', 'b']));
|
|
22
|
+
expect(deriveAnimationTargetId(['Root', 'A/B'])).not.toBe(
|
|
23
|
+
deriveAnimationTargetId(['Root', 'A', 'B']),
|
|
24
|
+
);
|
|
25
|
+
expect(deriveAnimationTargetId(['\u6839', '\u9aa8'])).toBe(VECTORS[4][1]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it.each([
|
|
29
|
+
'',
|
|
30
|
+
'a'.repeat(31),
|
|
31
|
+
'a'.repeat(33),
|
|
32
|
+
'A95da0ec669159f98273e8f86d8ad9f',
|
|
33
|
+
'g95da0ec669159f98273e8f86d8ad9f',
|
|
34
|
+
])('rejects invalid wire %j', (wire) => {
|
|
35
|
+
expect(isAnimationTargetId(wire)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('accepts a 32-character lowercase hexadecimal wire', () => {
|
|
39
|
+
expect(isAnimationTargetId(VECTORS[0][1])).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { EntityHandle } from '@forgeax/engine-ecs';
|
|
2
|
+
import { createWorldContext, Entity, World } from '@forgeax/engine-ecs';
|
|
3
|
+
import { ChildOf, scenePlugin, Transform } from '@forgeax/engine-scene';
|
|
4
|
+
import type { AnimationClip, AnimationTargetIdValue, Handle } from '@forgeax/engine-types';
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { AnimationPlayer } from '../animation-player';
|
|
7
|
+
import {
|
|
8
|
+
AnimatedBy,
|
|
9
|
+
AnimationTargetId,
|
|
10
|
+
AnimationTargets,
|
|
11
|
+
bindAnimationTargets,
|
|
12
|
+
} from '../animation-target';
|
|
13
|
+
import { animationPlugin } from '../plugin';
|
|
14
|
+
|
|
15
|
+
const TARGET_ID = 'a95da0ec669189f98273e8f86d8ad9f2' as AnimationTargetIdValue;
|
|
16
|
+
|
|
17
|
+
function clip(targetId = TARGET_ID): AnimationClip {
|
|
18
|
+
return {
|
|
19
|
+
kind: 'animation-clip',
|
|
20
|
+
duration: 1,
|
|
21
|
+
channels: [
|
|
22
|
+
{
|
|
23
|
+
targetId,
|
|
24
|
+
property: 'translation',
|
|
25
|
+
sampler: {
|
|
26
|
+
input: new Float32Array([0, 1]),
|
|
27
|
+
output: new Float32Array([0, 0, 0, 10, 0, 0]),
|
|
28
|
+
interpolation: 'LINEAR',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function spawnPair(world: World, clipHandle: Handle<'AnimationClip', 'shared'>) {
|
|
36
|
+
const player = world
|
|
37
|
+
.spawn({
|
|
38
|
+
component: Transform,
|
|
39
|
+
data: {},
|
|
40
|
+
})
|
|
41
|
+
.unwrap() as EntityHandle;
|
|
42
|
+
world
|
|
43
|
+
.addComponent(player, {
|
|
44
|
+
component: AnimationPlayer,
|
|
45
|
+
data: { clips: [clipHandle], times: [0], weights: [1], speeds: [1], looping: false },
|
|
46
|
+
})
|
|
47
|
+
.unwrap();
|
|
48
|
+
const target = world.spawn({ component: Transform, data: {} }).unwrap() as EntityHandle;
|
|
49
|
+
world.addComponent(target, { component: ChildOf, data: { parent: player } }).unwrap();
|
|
50
|
+
world.addComponent(target, { component: AnimationTargetId, data: { value: TARGET_ID } }).unwrap();
|
|
51
|
+
expect(bindAnimationTargets(world, player, [target]).ok).toBe(true);
|
|
52
|
+
return { player, target };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('animation target isolation and lifecycle', () => {
|
|
56
|
+
it('skips five malformed targets or channels while a valid sibling player continues', async () => {
|
|
57
|
+
const world = new World();
|
|
58
|
+
await createWorldContext(world, [animationPlugin()]);
|
|
59
|
+
const clipHandle = world.allocSharedRef('AnimationClip', clip());
|
|
60
|
+
const valid = await spawnPair(world, clipHandle);
|
|
61
|
+
|
|
62
|
+
await spawnPair(
|
|
63
|
+
world,
|
|
64
|
+
world.allocSharedRef('AnimationClip', clip('00000000000000000000000000000000' as never)),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const noTransform = await spawnPair(world, clipHandle);
|
|
68
|
+
world.removeComponent(noTransform.target, Transform).unwrap();
|
|
69
|
+
|
|
70
|
+
const duplicate = await spawnPair(world, clipHandle);
|
|
71
|
+
const duplicateTarget = world
|
|
72
|
+
.spawn({ component: Transform, data: {} })
|
|
73
|
+
.unwrap() as EntityHandle;
|
|
74
|
+
world
|
|
75
|
+
.addComponent(duplicateTarget, { component: AnimationTargetId, data: { value: TARGET_ID } })
|
|
76
|
+
.unwrap();
|
|
77
|
+
world
|
|
78
|
+
.addComponent(duplicateTarget, { component: AnimatedBy, data: { player: duplicate.player } })
|
|
79
|
+
.unwrap();
|
|
80
|
+
|
|
81
|
+
const stale = await spawnPair(world, clipHandle);
|
|
82
|
+
const staleRaw = stale.target;
|
|
83
|
+
world.despawn(staleRaw).unwrap();
|
|
84
|
+
|
|
85
|
+
const emptyClip = world.allocSharedRef('AnimationClip', {
|
|
86
|
+
kind: 'animation-clip',
|
|
87
|
+
duration: 1,
|
|
88
|
+
channels: [],
|
|
89
|
+
} satisfies AnimationClip);
|
|
90
|
+
const missingChannel = await spawnPair(world, clipHandle);
|
|
91
|
+
world.set(missingChannel.player, AnimationPlayer, {
|
|
92
|
+
clips: [clipHandle, emptyClip],
|
|
93
|
+
times: [0, 0],
|
|
94
|
+
weights: [1, 1],
|
|
95
|
+
speeds: [1, 1],
|
|
96
|
+
});
|
|
97
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
98
|
+
|
|
99
|
+
world.update(0.1);
|
|
100
|
+
|
|
101
|
+
expect(world.get(valid.target, Transform).unwrap().pos[0]).toBeCloseTo(1);
|
|
102
|
+
expect(new Set(warn.mock.calls.map(([value]) => (value as { code?: string }).code))).toEqual(
|
|
103
|
+
new Set([
|
|
104
|
+
'animation-target-missing',
|
|
105
|
+
'animation-target-transform-missing',
|
|
106
|
+
'animation-target-id-duplicate',
|
|
107
|
+
'animation-channel-missing',
|
|
108
|
+
]),
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('isolates two players sharing one Clip and TargetId while pause ownership swaps', async () => {
|
|
113
|
+
const world = new World();
|
|
114
|
+
await createWorldContext(world, [scenePlugin()]);
|
|
115
|
+
await createWorldContext(world, [animationPlugin()]);
|
|
116
|
+
const clipHandle = world.allocSharedRef('AnimationClip', clip());
|
|
117
|
+
const a = await spawnPair(world, clipHandle);
|
|
118
|
+
const b = await spawnPair(world, clipHandle);
|
|
119
|
+
world.set(a.player, AnimationPlayer, { paused: true }).unwrap();
|
|
120
|
+
|
|
121
|
+
world.update(0.1);
|
|
122
|
+
expect(world.get(a.target, Transform).unwrap().pos[0]).toBe(0);
|
|
123
|
+
expect(world.get(b.target, Transform).unwrap().pos[0]).toBeCloseTo(1);
|
|
124
|
+
|
|
125
|
+
world.set(a.player, AnimationPlayer, { paused: false }).unwrap();
|
|
126
|
+
world.set(b.player, AnimationPlayer, { paused: true }).unwrap();
|
|
127
|
+
world.update(0.1);
|
|
128
|
+
expect(world.get(a.target, Transform).unwrap().pos[0]).toBeCloseTo(1);
|
|
129
|
+
expect(world.get(b.target, Transform).unwrap().pos[0]).toBeCloseTo(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('performs at most one final Transform set per target per update', async () => {
|
|
133
|
+
const world = new World();
|
|
134
|
+
await createWorldContext(world, [animationPlugin()]);
|
|
135
|
+
const clipHandle = world.allocSharedRef('AnimationClip', clip());
|
|
136
|
+
const { target } = await spawnPair(world, clipHandle);
|
|
137
|
+
const set = vi.spyOn(world, 'set');
|
|
138
|
+
|
|
139
|
+
world.update(0.1);
|
|
140
|
+
|
|
141
|
+
const targetSets = set.mock.calls.filter(
|
|
142
|
+
([entity, component]) => entity === target && component === Transform,
|
|
143
|
+
);
|
|
144
|
+
expect(targetSets).toHaveLength(1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('keeps targets alive after player despawn and permits stale-owner recovery', async () => {
|
|
148
|
+
const world = new World();
|
|
149
|
+
await createWorldContext(world, [animationPlugin()]);
|
|
150
|
+
const clipHandle = world.allocSharedRef('AnimationClip', clip());
|
|
151
|
+
const first = await spawnPair(world, clipHandle);
|
|
152
|
+
world.removeComponent(first.target, ChildOf).unwrap();
|
|
153
|
+
world.despawn(first.player).unwrap();
|
|
154
|
+
expect(world.get(first.target, Entity).ok).toBe(true);
|
|
155
|
+
expect(world.get(first.target, AnimatedBy).unwrap().player).toBe(first.player);
|
|
156
|
+
|
|
157
|
+
const replacement = world
|
|
158
|
+
.spawn({
|
|
159
|
+
component: AnimationPlayer,
|
|
160
|
+
data: { clips: [clipHandle], times: [0], weights: [1], speeds: [1] },
|
|
161
|
+
})
|
|
162
|
+
.unwrap() as EntityHandle;
|
|
163
|
+
world
|
|
164
|
+
.addComponent(first.target, { component: ChildOf, data: { parent: replacement } })
|
|
165
|
+
.unwrap();
|
|
166
|
+
expect(bindAnimationTargets(world, replacement, [first.target]).ok).toBe(true);
|
|
167
|
+
expect(world.get(first.target, AnimatedBy).unwrap().player).toBe(replacement);
|
|
168
|
+
expect([...world.get(replacement, AnimationTargets).unwrap().targets]).toEqual([first.target]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it.each([30, 60, 120])('reaches the same target pose after one second at %iHz', async (hz) => {
|
|
172
|
+
const world = new World();
|
|
173
|
+
await createWorldContext(world, [animationPlugin()]);
|
|
174
|
+
const clipHandle = world.allocSharedRef('AnimationClip', clip());
|
|
175
|
+
const { target } = await spawnPair(world, clipHandle);
|
|
176
|
+
for (let frame = 0; frame < hz; frame++) world.update(1 / hz);
|
|
177
|
+
expect(world.get(target, Transform).unwrap().pos[0]).toBeCloseTo(10, 4);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Entity, World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { componentSchema } from '@forgeax/engine-ecs/internal';
|
|
3
|
+
import { Transform } from '@forgeax/engine-scene';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { AnimationPlayer } from '../animation-player';
|
|
6
|
+
import { AnimatedBy, AnimationTargetId, AnimationTargets } from '../animation-target';
|
|
7
|
+
|
|
8
|
+
function spawnPlayer(world: World) {
|
|
9
|
+
return world
|
|
10
|
+
.spawn(
|
|
11
|
+
{ component: AnimationPlayer, data: {} },
|
|
12
|
+
{ component: AnimationTargets, data: {} },
|
|
13
|
+
{ component: Transform, data: {} },
|
|
14
|
+
)
|
|
15
|
+
.unwrap();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function spawnTarget(world: World) {
|
|
19
|
+
return world
|
|
20
|
+
.spawn(
|
|
21
|
+
{ component: AnimationTargetId, data: { value: 'a'.repeat(32) } },
|
|
22
|
+
{ component: Transform, data: {} },
|
|
23
|
+
)
|
|
24
|
+
.unwrap();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('animation target relationship', () => {
|
|
28
|
+
it('declares an exclusive non-linked relationship', () => {
|
|
29
|
+
expect(componentSchema(AnimationTargets).targets).toBe('array<entity>');
|
|
30
|
+
expect(componentSchema(AnimatedBy).player).toBe('entity');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('keeps add, remove, rebind, and target despawn mirrors synchronized', () => {
|
|
34
|
+
const world = new World();
|
|
35
|
+
const first = spawnPlayer(world);
|
|
36
|
+
const second = spawnPlayer(world);
|
|
37
|
+
const target = spawnTarget(world);
|
|
38
|
+
|
|
39
|
+
world.addComponent(target, { component: AnimatedBy, data: { player: first } }).unwrap();
|
|
40
|
+
expect([...world.get(first, AnimationTargets).unwrap().targets]).toEqual([target]);
|
|
41
|
+
|
|
42
|
+
world.addComponent(target, { component: AnimatedBy, data: { player: second } }).unwrap();
|
|
43
|
+
expect([...world.get(first, AnimationTargets).unwrap().targets]).toEqual([]);
|
|
44
|
+
expect([...world.get(second, AnimationTargets).unwrap().targets]).toEqual([target]);
|
|
45
|
+
|
|
46
|
+
world.removeComponent(target, AnimatedBy).unwrap();
|
|
47
|
+
expect([...world.get(second, AnimationTargets).unwrap().targets]).toEqual([]);
|
|
48
|
+
|
|
49
|
+
world.addComponent(target, { component: AnimatedBy, data: { player: first } }).unwrap();
|
|
50
|
+
world.despawn(target).unwrap();
|
|
51
|
+
expect([...world.get(first, AnimationTargets).unwrap().targets]).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('does not despawn targets with the player and preserves a stale owner', () => {
|
|
55
|
+
const world = new World();
|
|
56
|
+
const player = spawnPlayer(world);
|
|
57
|
+
const target = spawnTarget(world);
|
|
58
|
+
world.addComponent(target, { component: AnimatedBy, data: { player } }).unwrap();
|
|
59
|
+
|
|
60
|
+
world.despawn(player).unwrap();
|
|
61
|
+
|
|
62
|
+
expect(world.get(target, Entity).ok).toBe(true);
|
|
63
|
+
expect(world.get(target, AnimatedBy).unwrap().player).toBe(player);
|
|
64
|
+
});
|
|
65
|
+
});
|