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