/** * 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[] }; 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 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); }); });