Files
feuerwerk/tests/core/animation.test.ts
T
adminandClaude Opus 5 f68941fa3a
game-maker / gate (push) Successful in 1m33s
game-maker / suite (push) Successful in 5m37s
An animation reaches the scene's own background and objects inside placed containers (#109)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 06:48:21 +02:00

529 lines
20 KiB
TypeScript

/**
* Animations (#97): a timeline over one root asset. Each row keys one property of the root or of an
* object inside it; played on an object, the rows reach that object, so every instance plays on its
* own. The value rules are pure; the AnimationSystem gives each object its players.
*/
import { describe, expect, it } from 'vitest';
import {
AddAnimationTrackCommand,
MoveAnimationKeysCommand,
RemoveAnimationKeysCommand,
RetargetAnimationTrackCommand,
SetAnimationKeyCommand,
SetAnimationRootCommand,
SetTrackRelativeCommand,
} from '../../src/core/commands/animationCommands';
import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands';
import { CommandError } from '../../src/core/commands/Command';
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import {
animationDuration,
frameCount,
pauseAnimation,
playAnimation,
playheadAt,
stopAnimation,
trackValueAt,
} from '../../src/core/model/animation';
import type { AnimationAsset, AnimationKey, ContainerAsset } from '../../src/core/model/Asset';
import { instanceChildId } from '../../src/core/model/instance';
import { findAsset, findAssetOf } from '../../src/core/model/Project';
import { animationsUsing } from '../../src/core/model/usages';
import { deserializeProject } from '../../src/core/store/serialize';
import { AnimationSystem } from '../../src/runtime/systems/AnimationSystem';
import { World } from '../../src/runtime/World';
import {
graphEditor,
makeBehavior,
makeSpriteScene,
place,
roundTrip,
type SceneFixture,
startWorld,
} from '../helpers';
const keys = (...pairs: [number, AnimationKey['value']][]): AnimationKey[] =>
pairs.map(([frame, value]) => ({ frame, value }));
/** An animation over `root` in the fixture, props through the schema like any asset's. */
function addAnimation(f: SceneFixture, root: string, props = {}, name = 'Walk'): AnimationAsset {
const create = new CreateAssetCommand('animation', name, props);
f.stack.execute(create);
f.stack.execute(new SetAnimationRootCommand(create.asset.id, root));
return findAssetOf(f.project, create.asset.id, 'animation')!;
}
/** A row on `animation` with a key per pair; returns the row's id. */
function addRow(
f: SceneFixture,
animation: AnimationAsset,
property: string,
pairs: [number, AnimationKey['value']][],
target = '',
): string {
const row = new AddAnimationTrackCommand(animation.id, target, property);
f.stack.execute(row);
for (const [frame, value] of pairs)
f.stack.execute(new SetAnimationKeyCommand(animation.id, row.trackId, frame, value));
return row.trackId;
}
const rowOf = (animation: AnimationAsset, id: string) => animation.tracks.find((t) => t.id === id)!;
describe('where the playhead stands', () => {
it('counts the last key frame as a frame of its own, so keys on 0…9 last one second at 10 fps', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
addRow(f, walk, 'x', [
[0, 0],
[9, 90],
]);
expect(frameCount(walk)).toBe(10);
expect(animationDuration(walk)).toBe(1);
});
it('runs between frames, so a slow rate still moves every tick', () => {
expect(playheadAt(10, 10, 'loop', 0.05).frame).toBeCloseTo(0.5);
expect(playheadAt(10, 10, 'loop', 1.05).frame).toBeCloseTo(0.5);
});
it('finishes only in the two once modes, and ping-pong turns at the end', () => {
expect(playheadAt(10, 10, 'once', 1.2)).toEqual({ frame: 10, finished: true });
expect(playheadAt(10, 10, 'once-reset', 0.5).finished).toBe(false);
expect(playheadAt(10, 10, 'pingpong', 1.5).frame).toBeCloseTo(5);
expect(playheadAt(10, 10, 'loop', 30).finished).toBe(false);
});
});
describe('the value of a row', () => {
const row = keys([10, 100], [20, 200]);
it('holds the first value before the first key and the last after the last', () => {
expect(trackValueAt(row, 'number', 0)).toBe(100);
expect(trackValueAt(row, 'number', 99)).toBe(200);
expect(trackValueAt(keys([4, 7]), 'number', 50)).toBe(7);
expect(trackValueAt([], 'number', 0)).toBeUndefined();
});
it('blends a number linearly, also at a fraction of a frame', () => {
expect(trackValueAt(row, 'number', 15)).toBe(150);
expect(trackValueAt(row, 'number', 10.5)).toBe(105);
});
it('blends a color over its channels', () => {
expect(trackValueAt(keys([0, 0x000000], [10, 0xff8000]), 'color', 5)).toBe(0x804000);
});
it('lets a rotation run the long way, since a number knows no turn', () => {
expect(trackValueAt(keys([0, 350], [10, 10]), 'number', 5)).toBe(180);
});
it('holds what cannot blend until the next key', () => {
const pictures = keys([0, 'a'], [2, 'b']);
expect(trackValueAt(pictures, 'texture', 1.9)).toBe('a');
expect(trackValueAt(pictures, 'texture', 2)).toBe('b');
expect(trackValueAt(keys([0, true], [5, false]), 'boolean', 4)).toBe(true);
});
});
describe('editing the timeline', () => {
it('builds only over a sprite, text, container or scene', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
const behavior = makeBehavior(f);
expect(() => f.stack.execute(new SetAnimationRootCommand(walk.id, behavior.id))).toThrow(CommandError);
expect(findAssetOf(f.project, walk.id, 'animation')!.root).toBe(f.spriteId);
});
it('refuses a property the target lacks, and a second row on the same one', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
addRow(f, walk, 'rotation', []);
expect(() => f.stack.execute(new AddAnimationTrackCommand(walk.id, '', 'fontSize'))).toThrow(
CommandError,
);
expect(() => f.stack.execute(new AddAnimationTrackCommand(walk.id, '', 'rotation'))).toThrow(
CommandError,
);
expect(walk.tracks).toHaveLength(1);
});
it('keys the value the row has there, replaces a key on the same frame, and undoes each step', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
const id = addRow(f, walk, 'x', [
[0, 0],
[10, 100],
]);
f.stack.execute(new SetAnimationKeyCommand(walk.id, id, 4));
expect(rowOf(walk, id).keys).toEqual(keys([0, 0], [4, 40], [10, 100]));
f.stack.execute(new SetAnimationKeyCommand(walk.id, id, 4, 7));
expect(rowOf(walk, id).keys).toEqual(keys([0, 0], [4, 7], [10, 100]));
f.stack.undo();
f.stack.undo();
expect(rowOf(walk, id).keys).toEqual(keys([0, 0], [10, 100]));
});
it('starts an empty row at the property default, a relative one at no offset', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
const scale = addRow(f, walk, 'scale', []);
f.stack.execute(new SetAnimationKeyCommand(walk.id, scale, 3));
const x = addRow(f, walk, 'x', []);
f.stack.execute(new SetTrackRelativeCommand(walk.id, x, true));
f.stack.execute(new SetAnimationKeyCommand(walk.id, x, 0));
expect(rowOf(walk, scale).keys).toEqual(keys([3, 1]));
expect(rowOf(walk, x).keys).toEqual(keys([0, 0]));
});
it('moves keys together; one landing on a key left behind replaces it, and undo brings both back', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
const id = addRow(f, walk, 'x', [
[0, 0],
[2, 20],
[5, 50],
]);
f.stack.execute(
new MoveAnimationKeysCommand(
walk.id,
[
{ trackId: id, frame: 0 },
{ trackId: id, frame: 2 },
],
3,
),
);
expect(rowOf(walk, id).keys).toEqual(keys([3, 0], [5, 20]));
expect(() =>
f.stack.execute(new MoveAnimationKeysCommand(walk.id, [{ trackId: id, frame: 3 }], -4)),
).toThrow(CommandError);
f.stack.undo();
expect(rowOf(walk, id).keys).toEqual(keys([0, 0], [2, 20], [5, 50]));
f.stack.execute(new RemoveAnimationKeysCommand(walk.id, [{ trackId: id, frame: 2 }]));
expect(rowOf(walk, id).keys).toEqual(keys([0, 0], [5, 50]));
});
it('aims a row elsewhere keeping its keys, only at a property of the same kind', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
const id = addRow(f, walk, 'x', [[0, 5]]);
f.stack.execute(new RetargetAnimationTrackCommand(walk.id, id, '', 'rotation'));
expect(rowOf(walk, id)).toMatchObject({ property: 'rotation', keys: keys([0, 5]) });
expect(() => f.stack.execute(new RetargetAnimationTrackCommand(walk.id, id, '', 'color'))).toThrow(
CommandError,
);
expect(() =>
f.stack.execute(new SetTrackRelativeCommand(walk.id, addRow(f, walk, 'color', []), true)),
).toThrow(CommandError);
});
});
describe('saving', () => {
it('keeps root, rows and keys through a round trip, and cleans what a hand-edited file brings', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId, { fps: 12, playback: 'once-reset' });
const id = addRow(f, walk, 'x', [
[0, 0],
[9, 90],
]);
f.stack.execute(new SetTrackRelativeCommand(walk.id, id, true));
expect(findAssetOf(roundTrip(f.project), walk.id, 'animation')).toEqual(walk);
const raw = structuredClone(f.project) as unknown as { assets: Record<string, unknown>[] };
const stored = raw.assets.find((a) => a.id === walk.id)!;
stored.tracks = [
{
id: 'r1',
target: 'gone',
property: 'x',
keys: [
{ frame: 4.4, value: 1 },
{ frame: 1, value: 2 },
{ frame: 'x', value: 3 },
{ frame: 2, value: [1] },
],
},
{ id: 'r2', target: 'gone', property: 'x', keys: [] },
{ id: 'r1', target: '', property: 'y', keys: [] },
{ target: '', property: 'rotation', keys: [] },
];
stored.props = { fps: 10, playback: 'backwards' };
const coerced = findAssetOf(deserializeProject(raw), walk.id, 'animation')!;
expect(coerced.tracks).toEqual([{ id: 'r1', target: 'gone', property: 'x', keys: keys([1, 2], [4, 1]) }]);
expect(coerced.props.playback).toBe('loop');
});
it('counts the animations keying a texture', () => {
const f = makeSpriteScene();
const texture = new CreateTextureCommand('Hero', { file: `${'ab'.repeat(32)}.png`, width: 8, height: 8 });
f.stack.execute(texture);
const walk = addAnimation(f, f.spriteId);
addRow(f, walk, 'texture', [[0, texture.asset.id]]);
addAnimation(f, f.spriteId, {}, 'Other');
expect(animationsUsing(f.project, [texture.asset.id])).toEqual([walk]);
});
});
/** A placed sprite and an animation over it moving `x` 0 → 90 on frames 0…9 at 10 fps. */
function slide(props = {}) {
const f = makeSpriteScene();
const box = place(f, f.spriteId, { x: 0, y: 0 });
const walk = addAnimation(f, f.spriteId, { fps: 10, ...props });
const row = addRow(f, walk, 'x', [
[0, 0],
[9, 90],
]);
const started = startWorld(f, { animations: true });
const sprite = started.world.findByObjectId(box)!;
return { f, walk, row, box, sprite, ...started };
}
describe('the AnimationSystem', () => {
it('shows frame 0 on the tick after the start, then blends every tick', () => {
const { walk, sprite, frame } = slide();
playAnimation(sprite, walk.id);
frame(0.05);
expect(sprite.x).toBe(0);
frame(0.05);
expect(sprite.x).toBeCloseTo(5);
frame(0.5);
expect(sprite.x).toBeCloseTo(55);
});
it('holds the last value when a once animation ends, and reports it exactly once', () => {
const { walk, box, f } = slide({ playback: 'once' });
const world = World.instantiate(f.project, f.sceneId);
const system = new AnimationSystem();
world.addSystem(system);
const sprite = world.findByObjectId(box)!;
playAnimation(sprite, walk.id);
const reports: number[] = [];
for (let i = 0; i < 14; i++) {
world.tick(0.1);
reports.push(system.finished.length);
}
expect(reports.filter((n) => n > 0)).toEqual([1]);
expect(sprite.x).toBe(90);
expect(sprite.animations).toEqual([]);
});
it('puts the starting values back when a once-reset animation ends', () => {
const { walk, sprite, frame } = slide({ playback: 'once-reset' });
sprite.x = 7;
playAnimation(sprite, walk.id);
frame(0.1);
frame(0.5);
expect(sprite.x).toBeCloseTo(50);
for (let i = 0; i < 10; i++) frame(0.1);
expect(sprite.x).toBe(7);
});
it('adds a relative row to where the object stood', () => {
const { f, walk, row, sprite, frame } = slide();
f.stack.execute(new SetTrackRelativeCommand(walk.id, row, true));
sprite.x = 100;
playAnimation(sprite, walk.id);
frame(0.1);
frame(0.3);
expect(sprite.x).toBeCloseTo(130);
});
it('pauses where it stands, runs on when played again, and stop leaves the value', () => {
const { walk, sprite, frame } = slide();
playAnimation(sprite, walk.id);
frame(0.1);
frame(0.2);
pauseAnimation(sprite, walk.id);
frame(0.5);
expect(sprite.x).toBeCloseTo(20);
playAnimation(sprite, walk.id);
frame(0.1);
expect(sprite.x).toBeCloseTo(30);
stopAnimation(sprite, '');
frame(0.5);
expect(sprite.x).toBeCloseTo(30);
});
it('lets the later start win where two animations set one property', () => {
const { f, walk, sprite, frame } = slide();
const hold = addAnimation(f, f.spriteId, {}, 'Hold');
addRow(f, hold, 'x', [[0, -1]]);
playAnimation(sprite, hold.id);
playAnimation(sprite, walk.id);
frame(0.1);
frame(0.2);
expect(sprite.x).toBeCloseTo(20);
});
it('lets a running animation win again when it is played again, without winding it back', () => {
const { f, walk, sprite, frame } = slide();
const hold = addAnimation(f, f.spriteId, {}, 'Hold');
addRow(f, hold, 'x', [[0, -1]]);
playAnimation(sprite, walk.id);
frame(0.1);
playAnimation(sprite, hold.id);
frame(0.1);
expect(sprite.x).toBeCloseTo(-1);
// Wound back it would read 0, still beaten -1: 20 is the walk at its third tick.
playAnimation(sprite, walk.id);
frame(0.1);
expect(sprite.x).toBeCloseTo(20);
});
it('stands still while the object is switched off', () => {
const { f, walk, box } = slide();
f.stack.execute(new SetOverrideCommand(f.sceneId, box, 'enabled', false));
const { world, frame } = startWorld(f, { animations: true });
const sprite = world.findByObjectId(box)!;
playAnimation(sprite, walk.id);
frame(0.1);
frame(0.5);
expect(sprite.animations?.[0]?.time).toBe(0);
});
it('plays a container animation on each instance on its own, and skips a row whose object is gone', () => {
const f = makeSpriteScene();
const inner = place(f, f.spriteId, { x: 0, y: 0 });
const wrap = new CreateContainerCommand(f.sceneId, [inner]);
f.stack.execute(wrap);
const template = (findAsset(f.project, wrap.assetId) as ContainerAsset).objects[0]!;
const second = place(f, wrap.assetId, { x: 500, y: 0 });
const spin = addAnimation(f, wrap.assetId, { fps: 10 });
addRow(
f,
spin,
'rotation',
[
[0, 0],
[10, 100],
],
template.id,
);
spin.tracks.push({ id: 'dangling', target: 'gone', property: 'rotation', keys: keys([0, 5]) });
const { world, frame } = startWorld(f, { animations: true });
const first = world.findByObjectId(wrap.objectId)!;
playAnimation(first, spin.id);
frame(0.1);
frame(0.5);
const rotation = (instance: string) =>
world.findByObjectId(instanceChildId(instance, template.id))!.props.rotation;
expect(rotation(wrap.objectId)).toBeCloseTo(50);
expect(rotation(second)).toBe(0);
});
});
describe('the reach of a played row (#109)', () => {
/** A sprite packed into `Inner`, `Inner` packed into `Outer`, and `Outer` placed in the scene. */
function nested() {
const f = makeSpriteScene();
const arm = place(f, f.spriteId, { x: 0, y: 0 });
const inner = new CreateContainerCommand(f.sceneId, [arm]);
f.stack.execute(inner);
const outer = new CreateContainerCommand(f.sceneId, [inner.objectId]);
f.stack.execute(outer);
const placing = (findAsset(f.project, outer.assetId) as ContainerAsset).objects[0]!;
const armTemplate = (findAsset(f.project, inner.assetId) as ContainerAsset).objects[0]!;
return { f, outer, path: `${placing.id}/${armTemplate.id}` };
}
const slideX: [number, AnimationKey['value']][] = [
[0, 0],
[10, 100],
];
it('reaches a child two containers deep on each instance of the root on its own', () => {
const { f, outer, path } = nested();
const second = place(f, outer.assetId, { x: 500, y: 0 });
const walk = addAnimation(f, outer.assetId, { fps: 10 });
addRow(f, walk, 'x', slideX, path);
const { world, frame } = startWorld(f, { animations: true });
playAnimation(world.findByObjectId(outer.objectId)!, walk.id);
frame(0.1);
frame(0.5);
const x = (instance: string) => world.findByObjectId(instanceChildId(instance, path))!.x;
expect(x(outer.objectId)).toBeCloseTo(50);
expect(x(second)).toBe(0);
});
it('reaches into a container placed in a scene, baked for play or resolved for the editor run', () => {
for (const bakeContainers of [true, false]) {
const { f, outer, path } = nested();
const box = place(f, f.spriteId, { x: 0, y: 0 });
const id = instanceChildId(outer.objectId, path);
const walk = addAnimation(f, f.sceneId, { fps: 10 });
addRow(f, walk, 'x', slideX, id);
const world = World.instantiate(f.project, f.sceneId, { bakeContainers });
world.addSystem(new AnimationSystem());
playAnimation(world.findByObjectId(box)!, walk.id);
world.tick(0.1);
world.tick(0.5);
expect(world.findByObjectId(id)!.x).toBeCloseTo(50);
}
});
it("plays the scene's own background, and a once-reset pass puts it back", () => {
const f = makeSpriteScene();
const box = place(f, f.spriteId, { x: 0, y: 0 });
f.sceneOf().props.backgroundColor = 0x123456;
const walk = addAnimation(f, f.sceneId, { fps: 10, playback: 'once-reset' });
addRow(f, walk, 'backgroundColor', [
[0, 0x000000],
[10, 0xff0000],
]);
const { world, frame } = startWorld(f, { animations: true });
playAnimation(world.findByObjectId(box)!, walk.id);
frame(0.1);
frame(0.5);
expect(world.backgroundColor).toBe(0x800000);
for (let i = 0; i < 10; i++) frame(0.1);
expect(world.backgroundColor).toBe(0x123456);
expect(f.sceneOf().props.backgroundColor).toBe(0x123456);
});
});
describe('the animation nodes', () => {
/** A behavior below the sprite plays `walk` on its owner at start and counts `On animation finished`. */
function listening(filter: 'walk' | 'other') {
const f = makeSpriteScene();
const box = place(f, f.spriteId, { x: 0, y: 0 });
const walk = addAnimation(f, f.spriteId, { fps: 10, playback: 'once' });
addRow(f, walk, 'x', [
[0, 0],
[2, 20],
]);
const other = addAnimation(f, f.spriteId, {}, 'Other');
const behavior = makeBehavior(f, 'Logic', ['done']);
place(f, behavior.id, { x: 0, y: 0 }, box);
const edit = graphEditor(f, behavior);
const start = edit.addNode('OnStart');
const owner = edit.addNode('Owner');
const play = edit.addNode('PlayAnimation', { animation: walk.id });
const finished = edit.addNode('OnAnimationFinished', {
animation: filter === 'walk' ? walk.id : other.id,
});
const store = edit.variables(['done'], { write: true });
const add = edit.addNode('Add', { b: 1 });
edit.wire([
[start, 'exec', play, 'exec'],
[owner, 'object', play, 'object'],
[finished, 'exec', store, 'exec'],
[store, 'done', add, 'a'],
[add, 'result', store, 'done'],
]);
const { world, frame } = startWorld(f, { animations: true, behaviors: true });
for (let i = 0; i < 8; i++) frame(0.1);
const logic = world.entities.find((e) => e.type === 'behavior')!;
return { done: logic.props.done, x: world.findByObjectId(box)!.x };
}
it('plays on the object wired in, and On animation finished hears it once', () => {
expect(listening('walk')).toEqual({ done: 1, x: 20 });
});
it('is not heard by an On animation finished naming another animation', () => {
expect(listening('other').done).toBe(0);
});
});