An animation asset is a list of pictures (whole textures or frames) with frames per second and Loop/Once/Ping-pong playback. A sprite wears it through its texture property, so the picker, instance overrides and the Properties node work unchanged; there is no dedicated play node. A different animation starts from its first frame, the same one set again runs on, so a graph may choose Run or Idle every frame. The clock lives on the entity, not in props, which the world writes back as overrides. The AnimationSystem ticks before behaviors, and On animation finished fires once when a Once animation reaches its end. The Animation tab plays a preview above a frame strip: reorder by dragging, Delete, duplicate, + Frames, pause/resume, and wheel zoom about the pointer with pan and Fit. Scene and Container tabs show the first frame; edit mode has no clock. Right-clicking a texture creates an animation from its frames. Button now measures its label with the bold font it draws in, which cut the Animation tab and red dialog buttons short. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
354 lines
15 KiB
TypeScript
354 lines
15 KiB
TypeScript
/**
|
||
* Animations: an asset listing pictures, worn by a sprite through `texture` like a texture or a
|
||
* frame. The rule picking a frame is pure; the AnimationSystem gives each sprite its own clock.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
AddAnimationFramesCommand,
|
||
MoveAnimationFrameCommand,
|
||
RemoveAnimationFramesCommand,
|
||
} from '../../src/core/commands/animationCommands';
|
||
import {
|
||
CreateAssetCommand,
|
||
CreateTextureCommand,
|
||
SetAssetPropertyCommand,
|
||
} from '../../src/core/commands/assetCommands';
|
||
import { CommandError } from '../../src/core/commands/Command';
|
||
import { SliceFramesCommand } from '../../src/core/commands/frameCommands';
|
||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||
import { OBJECT_TYPE_PARAM } from '../../src/core/graph';
|
||
import { frameAt, framesOfTexture } from '../../src/core/model/animation';
|
||
import type { AnimationAsset, TextureAsset } from '../../src/core/model/Asset';
|
||
import { findAssetOf } from '../../src/core/model/Project';
|
||
import { resolveTexture } from '../../src/core/model/texture';
|
||
import { deserializeProject } from '../../src/core/store/serialize';
|
||
import { animationsUsing, findPropertyUsages } from '../../src/core/model/usages';
|
||
import { texturePixelSize } from '../../src/core/properties/aspect';
|
||
import { pictureChoices, textureRefChoices, textureRefLabel } from '../../src/core/properties/textureRef';
|
||
import { AnimationSystem } from '../../src/runtime/systems/AnimationSystem';
|
||
import { World } from '../../src/runtime/World';
|
||
import { collectWorldItems } from '../../src/viewport/items';
|
||
import {
|
||
graphEditor,
|
||
makeBehavior,
|
||
makeSpriteScene,
|
||
place,
|
||
roundTrip,
|
||
type SceneFixture,
|
||
startWorld,
|
||
} from '../helpers';
|
||
|
||
const FILE = `${'a1b2c3d4'.repeat(8)}.png`;
|
||
|
||
/** A 64×48 sheet cut into four 32×24 frames in reading order. */
|
||
function addSheet(f: SceneFixture, name = 'Hero'): TextureAsset {
|
||
const create = new CreateTextureCommand(name, { file: FILE, width: 64, height: 48 });
|
||
f.stack.execute(create);
|
||
f.stack.execute(
|
||
new SliceFramesCommand(
|
||
create.asset.id,
|
||
{ columns: 2, rows: 2, offsetX: 0, offsetY: 0, spacingX: 0, spacingY: 0 },
|
||
false,
|
||
),
|
||
);
|
||
return create.asset;
|
||
}
|
||
|
||
function addAnimation(f: SceneFixture, frames: readonly string[], props = {}, name = 'Walk'): AnimationAsset {
|
||
const create = new CreateAssetCommand('animation', name, props);
|
||
f.stack.execute(create);
|
||
if (frames.length > 0) f.stack.execute(new AddAnimationFramesCommand(create.asset.id, frames));
|
||
return findAssetOf(f.project, create.asset.id, 'animation')!;
|
||
}
|
||
|
||
/** Index of the sheet frame a rectangle is, in reading order: 0 1 / 2 3. */
|
||
const cell = (rect: { x: number; y: number } | null | undefined) =>
|
||
rect ? rect.x / 32 + (rect.y / 24) * 2 : -1;
|
||
|
||
describe('which frame shows when', () => {
|
||
it('loops through the frames at the given rate', () => {
|
||
const at = (time: number) => frameAt(4, 10, 'loop', time).index;
|
||
expect([0, 0.05, 0.1, 0.25, 0.35, 0.4, 0.95].map(at)).toEqual([0, 0, 1, 2, 3, 0, 1]);
|
||
});
|
||
|
||
it('plays once, then holds the last frame and says so', () => {
|
||
expect(frameAt(3, 10, 'once', 0.25)).toEqual({ index: 2, finished: false });
|
||
expect(frameAt(3, 10, 'once', 0.3)).toEqual({ index: 2, finished: true });
|
||
expect(frameAt(3, 10, 'once', 5)).toEqual({ index: 2, finished: true });
|
||
});
|
||
|
||
it('goes back and forth without repeating the turning frames', () => {
|
||
const steps = [0, 1, 2, 3, 4, 5, 6].map((step) => frameAt(3, 1, 'pingpong', step).index);
|
||
expect(steps).toEqual([0, 1, 2, 1, 0, 1, 2]);
|
||
expect(frameAt(1, 10, 'pingpong', 3).index).toBe(0);
|
||
});
|
||
|
||
it('lands on a frame boundary reached by summed ticks, which the float sum misses by a hair', () => {
|
||
let time = 0;
|
||
for (let i = 0; i < 6; i++) time += 1 / 60;
|
||
expect(time * 10).toBeLessThan(1);
|
||
expect(frameAt(4, 10, 'loop', time).index).toBe(1);
|
||
});
|
||
|
||
it('has no frame without frames, and stands still without a rate', () => {
|
||
expect(frameAt(0, 10, 'loop', 1)).toEqual({ index: -1, finished: false });
|
||
expect(frameAt(3, 0, 'once', 100)).toEqual({ index: 0, finished: false });
|
||
});
|
||
});
|
||
|
||
describe('a sprite wearing an animation', () => {
|
||
it('shows the frame at the time asked for', () => {
|
||
const f = makeSpriteScene();
|
||
const sheet = addSheet(f);
|
||
const walk = addAnimation(f, framesOfTexture(sheet));
|
||
const at = (time: number) => cell(resolveTexture(f.project, { texture: walk.id }, time)?.rect);
|
||
expect([at(0), at(0.1), at(0.3), at(0.4)]).toEqual([0, 1, 3, 0]);
|
||
});
|
||
|
||
it('shows nothing for an empty animation, a frame that dangles, or an animation inside one', () => {
|
||
const f = makeSpriteScene();
|
||
const sheet = addSheet(f);
|
||
const empty = addAnimation(f, [], {}, 'Empty');
|
||
const dangling = addAnimation(f, ['gone'], {}, 'Dangling');
|
||
const inner = addAnimation(f, [sheet.frames[1]!.id], {}, 'Inner');
|
||
const nested = addAnimation(f, [sheet.id], {}, 'Outer');
|
||
nested.frames.push(inner.id);
|
||
expect(resolveTexture(f.project, { texture: empty.id })).toBeNull();
|
||
expect(resolveTexture(f.project, { texture: dangling.id })).toBeNull();
|
||
expect(resolveTexture(f.project, { texture: nested.id }, 0.1)).toBeNull();
|
||
expect(resolveTexture(f.project, { texture: nested.id }, 0)?.file).toBe(FILE);
|
||
});
|
||
|
||
it('is sized by its first frame when set to its pixel size', () => {
|
||
const f = makeSpriteScene();
|
||
const sheet = addSheet(f);
|
||
const walk = addAnimation(f, [sheet.frames[0]!.id, sheet.id]);
|
||
expect(texturePixelSize(f.project, { texture: walk.id })).toEqual({ w: 32, h: 24 });
|
||
});
|
||
|
||
it('is offered by the texture picker ahead of the pictures, and named by its name', () => {
|
||
const f = makeSpriteScene();
|
||
const sheet = addSheet(f);
|
||
const walk = addAnimation(f, [sheet.id]);
|
||
const ids = textureRefChoices(f.project).map((c) => c.id);
|
||
expect(ids[0]).toBe(walk.id);
|
||
expect(ids.slice(1)).toEqual(pictureChoices(f.project).map((c) => c.id));
|
||
expect(pictureChoices(f.project).some((c) => c.id === walk.id)).toBe(false);
|
||
expect(textureRefLabel(f.project, walk.id)).toBe('Walk');
|
||
});
|
||
});
|
||
|
||
describe('editing the frames', () => {
|
||
it('inserts where asked and takes exactly those back on undo', () => {
|
||
const f = makeSpriteScene();
|
||
const sheet = addSheet(f);
|
||
const [a, b, c] = sheet.frames.map((fr) => fr.id);
|
||
const walk = addAnimation(f, [a!, c!]);
|
||
f.stack.execute(new AddAnimationFramesCommand(walk.id, [b!, b!], 1));
|
||
expect(walk.frames).toEqual([a, b, b, c]);
|
||
f.stack.undo();
|
||
expect(walk.frames).toEqual([a, c]);
|
||
});
|
||
|
||
it('removes several frames and puts each back at its old place', () => {
|
||
const f = makeSpriteScene();
|
||
const sheet = addSheet(f);
|
||
const ids = sheet.frames.map((fr) => fr.id);
|
||
const walk = addAnimation(f, ids);
|
||
f.stack.execute(new RemoveAnimationFramesCommand(walk.id, [3, 0, 1]));
|
||
expect(walk.frames).toEqual([ids[2]]);
|
||
f.stack.undo();
|
||
expect(walk.frames).toEqual(ids);
|
||
});
|
||
|
||
it('moves a frame to where it is dropped and back on undo', () => {
|
||
const f = makeSpriteScene();
|
||
const ids = addSheet(f).frames.map((fr) => fr.id);
|
||
const walk = addAnimation(f, ids);
|
||
f.stack.execute(new MoveAnimationFrameCommand(walk.id, 0, 2));
|
||
expect(walk.frames).toEqual([ids[1], ids[2], ids[0], ids[3]]);
|
||
f.stack.undo();
|
||
expect(walk.frames).toEqual(ids);
|
||
});
|
||
|
||
it('refuses an animation as a frame, and indices that are not there', () => {
|
||
const f = makeSpriteScene();
|
||
const walk = addAnimation(f, [addSheet(f).id]);
|
||
const run = addAnimation(f, [], {}, 'Run');
|
||
expect(() => new AddAnimationFramesCommand(run.id, [walk.id]).execute(f.project)).toThrow(CommandError);
|
||
expect(() => new RemoveAnimationFramesCommand(walk.id, [1]).execute(f.project)).toThrow(CommandError);
|
||
expect(() => new MoveAnimationFrameCommand(walk.id, 0, 1).execute(f.project)).toThrow(CommandError);
|
||
expect(run.frames).toEqual([]);
|
||
expect(walk.frames).toHaveLength(1);
|
||
});
|
||
|
||
it('made from a texture plays its frames in order, or the whole picture when it has none', () => {
|
||
const f = makeSpriteScene();
|
||
const sheet = addSheet(f);
|
||
expect(framesOfTexture(sheet)).toEqual(sheet.frames.map((fr) => fr.id));
|
||
const plain = new CreateTextureCommand('Plain', { file: FILE, width: 8, height: 8 });
|
||
f.stack.execute(plain);
|
||
expect(framesOfTexture(plain.asset)).toEqual([plain.asset.id]);
|
||
});
|
||
});
|
||
|
||
describe('saving and deleting', () => {
|
||
it('keeps the frames through a round trip, dangling ones included, and coerces its settings', () => {
|
||
const f = makeSpriteScene();
|
||
const sheet = addSheet(f);
|
||
const walk = addAnimation(f, [sheet.id, 'gone', sheet.id], { fps: 12, playback: 'pingpong' });
|
||
const back = findAssetOf(roundTrip(f.project), walk.id, 'animation')!;
|
||
expect(back.frames).toEqual([sheet.id, 'gone', sheet.id]);
|
||
expect(back.props).toEqual({ fps: 12, playback: 'pingpong' });
|
||
|
||
const raw = structuredClone(f.project) as unknown as { assets: Record<string, unknown>[] };
|
||
const stored = raw.assets.find((a) => a.id === walk.id)!;
|
||
stored.frames = [sheet.id, 7, '', null];
|
||
stored.props = { fps: -3, playback: 'backwards' };
|
||
const coerced = findAssetOf(deserializeProject(raw), walk.id, 'animation')!;
|
||
expect(coerced.frames).toEqual([sheet.id]);
|
||
expect(coerced.props).toEqual({ fps: 0.1, playback: 'loop' });
|
||
});
|
||
|
||
it('counts the animations playing a texture, and the sprites wearing an animation', () => {
|
||
const f = makeSpriteScene();
|
||
const sheet = addSheet(f);
|
||
const walk = addAnimation(f, [sheet.frames[1]!.id]);
|
||
addAnimation(f, [], {}, 'Unrelated');
|
||
expect(animationsUsing(f.project, [sheet.id, ...sheet.frames.map((fr) => fr.id)])).toEqual([walk]);
|
||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'texture', walk.id));
|
||
expect(findPropertyUsages(f.project, [walk.id])).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
/** A placed sprite wearing `walk`, in a world with the AnimationSystem. */
|
||
function playing(frames = 4, props = {}) {
|
||
const f = makeSpriteScene();
|
||
const sheet = addSheet(f);
|
||
const walk = addAnimation(f, framesOfTexture(sheet).slice(0, frames), props);
|
||
const run = addAnimation(f, [...framesOfTexture(sheet)].reverse(), {}, 'Run');
|
||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'texture', walk.id));
|
||
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||
return { f, walk, run, box };
|
||
}
|
||
|
||
describe('the AnimationSystem', () => {
|
||
const drawnCell = (world: ReturnType<typeof startWorld>['world']) =>
|
||
cell(collectWorldItems(world).items.find((i) => i.type === 'sprite')?.texture?.rect);
|
||
|
||
it('runs a clock per sprite, which is what the drawn frame follows', () => {
|
||
const { f } = playing();
|
||
const { world, frame } = startWorld(f, { animations: true });
|
||
const seen = [drawnCell(world)];
|
||
for (let i = 0; i < 4; i++) {
|
||
frame(0.1);
|
||
seen.push(drawnCell(world));
|
||
}
|
||
expect(seen).toEqual([0, 0, 1, 2, 3]);
|
||
});
|
||
|
||
it('starts a different animation over, and lets the same one set again run on', () => {
|
||
const { f, walk, run } = playing();
|
||
const { world, frame } = startWorld(f, { animations: true });
|
||
const sprite = world.entities.find((e) => e.type === 'sprite')!;
|
||
frame(0.1);
|
||
frame(0.1);
|
||
frame(0.1);
|
||
sprite.props.texture = walk.id;
|
||
frame(0.1);
|
||
expect(sprite.animation?.time).toBeCloseTo(0.3);
|
||
sprite.props.texture = run.id;
|
||
expect(drawnCell(world)).toBe(3);
|
||
frame(0.1);
|
||
expect(sprite.animation).toEqual({ id: run.id, time: 0, finished: false });
|
||
});
|
||
|
||
it('reports a once animation finished exactly once', () => {
|
||
const { f } = playing(2, { playback: 'once' });
|
||
const world = World.instantiate(f.project, f.sceneId);
|
||
const system = new AnimationSystem();
|
||
world.addSystem(system);
|
||
const reports: number[] = [];
|
||
for (let i = 0; i < 6; i++) {
|
||
world.tick(0.1);
|
||
reports.push(system.finished.length);
|
||
}
|
||
expect(reports).toEqual([0, 0, 1, 0, 0, 0]);
|
||
});
|
||
|
||
it('stands still while the sprite is switched off', () => {
|
||
const { f, box } = playing();
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, box, 'enabled', false));
|
||
const { world, frame } = startWorld(f, { animations: true });
|
||
frame(0.1);
|
||
frame(0.5);
|
||
expect(world.entities.find((e) => e.type === 'sprite')!.animation?.time).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('On animation finished', () => {
|
||
/** The sprite plays `walk` once; a behavior below `holder` counts the event in `done`, the sprite's name in `who`. */
|
||
function listening(options: { holder?: 'sprite' | 'other' | 'none'; filter?: 'walk' | 'run' } = {}) {
|
||
const { f, walk, run, box } = playing(2, { playback: 'once' });
|
||
const behavior = makeBehavior(f, 'Logic', ['done']);
|
||
f.stack.execute(new AddVariableCommand(behavior.id, 'who', 'text'));
|
||
const other = options.holder === 'other' ? place(f, f.spriteId, { x: 300, y: 0 }) : '';
|
||
if (other) f.stack.execute(new SetOverrideCommand(f.sceneId, other, 'texture', ''));
|
||
const parent = options.holder === 'none' ? '' : options.holder === 'other' ? other : box;
|
||
place(f, behavior.id, { x: 0, y: 0 }, parent);
|
||
const edit = graphEditor(f, behavior);
|
||
const filter = options.filter === 'walk' ? walk.id : options.filter === 'run' ? run.id : '';
|
||
const event = edit.addNode('OnAnimationFinished', { animation: filter });
|
||
const store = edit.variables(['done', 'who'], { write: true });
|
||
const add = edit.addNode('Add', { b: 1 });
|
||
const name = edit.addNode('NameOf');
|
||
edit.wire([
|
||
[event, 'exec', store, 'exec'],
|
||
[store, 'done', add, 'a'],
|
||
[add, 'result', store, 'done'],
|
||
[event, 'object', name, 'object'],
|
||
[name, 'name', store, 'who'],
|
||
]);
|
||
const started = startWorld(f, { animations: true, behaviors: true });
|
||
for (let i = 0; i < 8; i++) started.frame(0.1);
|
||
const logic = started.world.entities.find((e) => e.type === 'behavior')!;
|
||
return { done: logic.props.done, who: logic.props.who };
|
||
}
|
||
|
||
it('fires once for the sprite the behavior belongs to, handing the sprite out', () => {
|
||
expect(listening()).toEqual({ done: 1, who: 'Box' });
|
||
});
|
||
|
||
it('fires only for the animation it names, when it names one', () => {
|
||
expect(listening({ filter: 'walk' }).done).toBe(1);
|
||
expect(listening({ filter: 'run' }).done).toBe(0);
|
||
});
|
||
|
||
it('is not heard below another sprite, and heard everywhere by a behavior without an owner', () => {
|
||
expect(listening({ holder: 'other' }).done).toBe(0);
|
||
expect(listening({ holder: 'none' }).done).toBe(1);
|
||
});
|
||
|
||
it('keeps a graph that sets the animation every frame from restarting it', () => {
|
||
const { f, walk } = playing();
|
||
const behavior = makeBehavior(f, 'Logic');
|
||
const box = f.sceneOf().objects.find((o) => o.assetId === f.spriteId)!.id;
|
||
place(f, behavior.id, { x: 0, y: 0 }, box);
|
||
const edit = graphEditor(f, behavior);
|
||
const update = edit.addNode('OnUpdate');
|
||
const owner = edit.addNode('Owner');
|
||
const wear = edit.properties([{ key: 'texture', kind: 'text', write: true }], {
|
||
[OBJECT_TYPE_PARAM]: 'sprite',
|
||
texture: walk.id,
|
||
});
|
||
edit.wire([
|
||
[update, 'exec', wear, 'exec'],
|
||
[owner, 'object', wear, 'object'],
|
||
]);
|
||
const { world, frame } = startWorld(f, { animations: true, behaviors: true });
|
||
for (let i = 0; i < 5; i++) frame(0.1);
|
||
expect(world.entities.find((e) => e.type === 'sprite')!.animation?.time).toBeCloseTo(0.4);
|
||
});
|
||
});
|