/** * 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, copyAnimationKeys, MoveAnimationKeysCommand, PasteAnimationKeysCommand, RemoveAnimationKeysCommand, RetargetAnimationTrackCommand, SetAnimationKeyCommand, SetAnimationRootCommand, SetKeyInterpolationCommand, SetTrackRelativeCommand, ShiftAnimationFramesCommand, shiftedFrame, } from '../../src/core/commands/animationCommands'; import { CreateAssetCommand, CreateTextureCommand, SetAssetPropertyCommand, } from '../../src/core/commands/assetCommands'; import { CommandError } from '../../src/core/commands/Command'; import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; import { AddNodeCommand } from '../../src/core/commands/graphCommands'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { RenameVariableCommand } from '../../src/core/commands/variableCommands'; import { animationDuration, frameCount, pauseAnimation, PLACED_ROOT_ID, playAnimation, playheadAt, stopAnimation, targetDefs, trackDef, trackLabel, trackValueAt, } from '../../src/core/model/animation'; import { SHADER_KEY, SHADER_PROPERTY } from '../../src/core/properties/shaders'; import { previewHolder } from '../../src/core/model/animationPreview'; import type { AnimationAsset, AnimationKey, ContainerAsset, KeyInterpolation, } 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)!; const current = (f: SceneFixture, animation: AnimationAsset) => findAssetOf(f.project, animation.id, 'animation')!; 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 on the last key', () => { expect(playheadAt(10, 10, 'once', 1.2)).toEqual({ frame: 10, finished: true, reverse: false }); expect(playheadAt(10, 10, 'once-reset', 0.5).finished).toBe(false); expect(playheadAt(10, 10, 'pingpong', 0.95)).toMatchObject({ reverse: true }); expect(playheadAt(10, 10, 'pingpong', 0.95).frame).toBeCloseTo(8.5); expect(playheadAt(10, 10, 'pingpong', 1.5).frame).toBeCloseTo(3); expect(playheadAt(10, 10, 'loop', 30).finished).toBe(false); }); it('shows each ping-pong frame once per bounce, the two ends included (#112)', () => { const row = keys(...Array.from({ length: 10 }, (_, i): [number, string] => [i, `f${i}`])); const shown: string[] = []; for (let tick = 0; tick < 18 * 6; tick++) { const head = playheadAt(10, 10, 'pingpong', tick / 60); if (tick % 6 === 0) shown.push(trackValueAt(row, 'texture', head.frame, head.reverse) as string); } expect(shown.join(' ')).toBe('f0 f1 f2 f3 f4 f5 f6 f7 f8 f9 f8 f7 f6 f5 f4 f3 f2 f1'); expect(trackValueAt(row, 'texture', playheadAt(10, 10, 'pingpong', 1.8).frame)).toBe('f0'); }); it('turns a blended ping-pong value on the last key without standing still', () => { const row = keys([0, 0], [9, 90]); const at = (time: number) => { const head = playheadAt(10, 10, 'pingpong', time); return Number(trackValueAt(row, 'number', head.frame, head.reverse)); }; expect(at(0.85)).toBeCloseTo(85); expect(at(0.9)).toBeCloseTo(90); expect(at(0.95)).toBeCloseTo(85); expect(at(1.8)).toBeCloseTo(0); }); }); 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('easing per key (#101)', () => { const eased = (interpolation: KeyInterpolation | undefined, frame: number, reverse = false) => trackValueAt( [ { frame: 0, value: 0, interpolation }, { frame: 10, value: 100 }, ], 'number', frame, reverse, ); it('bends the way to the next key: ease-in lags at half the time, ease-out leads, ease-in-out meets it', () => { expect(eased(undefined, 5)).toBe(50); expect(eased('linear', 5)).toBe(50); expect(eased('ease-in', 5)).toBeCloseTo(25); expect(eased('ease-out', 5)).toBeCloseTo(75); expect(eased('ease-in-out', 5)).toBeCloseTo(50); expect(eased('ease-in-out', 2.5)).toBeCloseTo(12.5); expect(eased('ease-in-out', 7.5)).toBeCloseTo(87.5); for (const interpolation of ['ease-in', 'ease-out', 'ease-in-out'] as const) { expect(eased(interpolation, 0)).toBe(0); expect(eased(interpolation, 10)).toBe(100); } }); it('holds a step key until the next one, and the one above on the way back', () => { expect(eased('step', 9.9)).toBe(0); expect(eased('step', 10)).toBe(100); expect(eased('step', 5, true)).toBe(100); }); it("takes the earlier key's easing for each stretch, and eases a colour over its channels", () => { const row: AnimationKey[] = [ { frame: 0, value: 0 }, { frame: 10, value: 100, interpolation: 'ease-in' }, { frame: 20, value: 200 }, ]; expect(trackValueAt(row, 'number', 5)).toBe(50); expect(trackValueAt(row, 'number', 15)).toBeCloseTo(125); const colours: AnimationKey[] = [ { frame: 0, value: 0x000000, interpolation: 'ease-out' }, { frame: 10, value: 0xff0000 }, ]; expect(trackValueAt(colours, 'color', 5)).toBe(0xbf0000); }); it('sets the easing of picked keys on several rows, skips a row that cannot blend, and undoes', () => { const f = makeSpriteScene(); const walk = addAnimation(f, f.spriteId); const x = addRow(f, walk, 'x', [ [0, 0], [10, 100], ]); const tint = addRow(f, walk, 'color', [[0, 0]]); const shown = addRow(f, walk, 'enabled', [[0, true]]); f.stack.execute( new SetKeyInterpolationCommand( walk.id, [ { trackId: x, frame: 0 }, { trackId: tint, frame: 0 }, { trackId: shown, frame: 0 }, ], 'ease-out', ), ); expect(rowOf(walk, x).keys[0]).toEqual({ frame: 0, value: 0, interpolation: 'ease-out' }); expect(rowOf(walk, tint).keys[0]!.interpolation).toBe('ease-out'); expect(rowOf(walk, shown).keys[0]).toEqual({ frame: 0, value: true }); expect(() => f.stack.execute(new SetKeyInterpolationCommand(walk.id, [{ trackId: shown, frame: 0 }], 'step')), ).toThrow(CommandError); // A new value and a move keep the easing; linear is stored as no field. f.stack.execute(new SetAnimationKeyCommand(walk.id, x, 0, 30)); f.stack.execute(new MoveAnimationKeysCommand(walk.id, [{ trackId: x, frame: 0 }], 2)); expect(rowOf(walk, x).keys[0]).toEqual({ frame: 2, value: 30, interpolation: 'ease-out' }); f.stack.execute(new SetKeyInterpolationCommand(walk.id, [{ trackId: x, frame: 2 }], 'linear')); expect(rowOf(walk, x).keys[0]).not.toHaveProperty('interpolation'); f.stack.undo(); f.stack.undo(); f.stack.undo(); f.stack.undo(); expect(rowOf(walk, x).keys).toEqual(keys([0, 0], [10, 100])); expect(rowOf(walk, tint).keys).toEqual(keys([0, 0])); }); it('keeps the easing through a round trip, and drops linear and what is no easing', () => { const f = makeSpriteScene(); const walk = addAnimation(f, f.spriteId); const x = addRow(f, walk, 'x', [ [0, 0], [10, 100], ]); f.stack.execute(new SetKeyInterpolationCommand(walk.id, [{ trackId: x, frame: 0 }], 'ease-in-out')); 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: '', property: 'x', keys: [ { frame: 0, value: 0, interpolation: 'linear' }, { frame: 1, value: 1, interpolation: 'bouncy' }, { frame: 2, value: 2, interpolation: 'step' }, ], }, ]; const coerced = findAssetOf(deserializeProject(raw), walk.id, 'animation')!; expect(coerced.tracks[0]!.keys).toEqual([ { frame: 0, value: 0 }, { frame: 1, value: 1 }, { frame: 2, value: 2, interpolation: 'step' }, ]); }); }); 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('refuses a key before frame 0 and a key that is not there, changing nothing', () => { const f = makeSpriteScene(); const walk = addAnimation(f, f.spriteId); const id = addRow(f, walk, 'x', [[0, 0]]); const missing = [{ trackId: id, frame: 4 }]; const refused = [ new SetAnimationKeyCommand(walk.id, id, -1), new ShiftAnimationFramesCommand(walk.id, -1, 1), new RemoveAnimationKeysCommand(walk.id, missing), new MoveAnimationKeysCommand(walk.id, missing, 1), new SetKeyInterpolationCommand(walk.id, missing, 'step'), ]; for (const command of refused) expect(() => f.stack.execute(command)).toThrow(CommandError); expect(rowOf(walk, id).keys).toEqual(keys([0, 0])); }); 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('copy, paste, duplicate and frames (#102)', () => { it('pastes relative to the frame, into the same rows, replacing a key there, and undoes in one step', () => { const f = makeSpriteScene(); const walk = addAnimation(f, f.spriteId); const x = addRow(f, walk, 'x', [ [2, 20], [4, 40], [9, 90], ]); const rot = addRow(f, walk, 'rotation', [[3, 1]]); f.stack.execute(new SetKeyInterpolationCommand(walk.id, [{ trackId: x, frame: 2 }], 'ease-in')); const clip = copyAnimationKeys(f.project, walk, [ { trackId: x, frame: 2 }, { trackId: x, frame: 4 }, { trackId: rot, frame: 3 }, ])!; expect(clip.rows.map((r) => r.keys.map((k) => k.offset))).toEqual([[0, 2], [1]]); const paste = new PasteAnimationKeysCommand(walk.id, clip, 7); f.stack.execute(paste); expect(rowOf(walk, x).keys).toEqual([ { frame: 2, value: 20, interpolation: 'ease-in' }, { frame: 4, value: 40 }, { frame: 7, value: 20, interpolation: 'ease-in' }, { frame: 9, value: 40 }, ]); expect(rowOf(walk, rot).keys).toEqual(keys([3, 1], [8, 1])); expect(paste.pasted).toEqual([ { trackId: x, frame: 7 }, { trackId: x, frame: 9 }, { trackId: rot, frame: 8 }, ]); f.stack.undo(); expect(rowOf(walk, x).keys).toHaveLength(3); expect(rowOf(walk, rot).keys).toEqual(keys([3, 1])); }); it('pastes one row into the row picked, only of the same kind', () => { const f = makeSpriteScene(); const walk = addAnimation(f, f.spriteId); const x = addRow(f, walk, 'x', [[0, 5]]); const rot = addRow(f, walk, 'rotation', []); const color = addRow(f, walk, 'color', []); const clip = copyAnimationKeys(f.project, walk, [{ trackId: x, frame: 0 }])!; f.stack.execute(new PasteAnimationKeysCommand(walk.id, clip, 3, rot)); expect(rowOf(walk, rot).keys).toEqual(keys([3, 5])); expect(rowOf(walk, x).keys).toEqual(keys([0, 5])); expect(() => f.stack.execute(new PasteAnimationKeysCommand(walk.id, clip, 3, color))).toThrow( CommandError, ); expect(rowOf(walk, color).keys).toEqual([]); }); it('finds the same property in another animation, and leaves out rows it has not', () => { const f = makeSpriteScene(); const walk = addAnimation(f, f.spriteId); const x = addRow(f, walk, 'x', [[1, 10]]); const rot = addRow(f, walk, 'rotation', [[1, 2]]); const run = addAnimation(f, f.spriteId, {}, 'Run'); const runX = addRow(f, run, 'x', []); const clip = copyAnimationKeys(f.project, walk, [ { trackId: x, frame: 1 }, { trackId: rot, frame: 1 }, ])!; const paste = new PasteAnimationKeysCommand(run.id, clip, 0); f.stack.execute(paste); expect(rowOf(current(f, run), runX).keys).toEqual(keys([0, 10])); expect(paste.skipped).toBe(1); const empty = addAnimation(f, f.spriteId, {}, 'Idle'); expect(() => f.stack.execute(new PasteAnimationKeysCommand(empty.id, clip, 0))).toThrow(CommandError); }); it('duplicates keys: the originals stay, the copies replace what they land on', () => { const f = makeSpriteScene(); const walk = addAnimation(f, f.spriteId); const id = addRow(f, walk, 'x', [ [0, 0], [2, 20], [5, 50], ]); const picked = [ { trackId: id, frame: 0 }, { trackId: id, frame: 2 }, ]; f.stack.execute(new MoveAnimationKeysCommand(walk.id, picked, 3, true)); expect(rowOf(walk, id).keys).toEqual(keys([0, 0], [2, 20], [3, 0], [5, 20])); expect(() => f.stack.execute(new MoveAnimationKeysCommand(walk.id, picked, 0, true))).toThrow( CommandError, ); f.stack.undo(); expect(rowOf(walk, id).keys).toEqual(keys([0, 0], [2, 20], [5, 50])); }); it('inserts frames over every row from the frame on, and removes them taking their keys along', () => { const f = makeSpriteScene(); const walk = addAnimation(f, f.spriteId); const x = addRow(f, walk, 'x', [ [0, 0], [4, 40], [9, 90], ]); const rot = addRow(f, walk, 'rotation', [ [3, 1], [6, 2], ]); f.stack.execute(new ShiftAnimationFramesCommand(walk.id, 4, 3)); expect(rowOf(walk, x).keys).toEqual(keys([0, 0], [7, 40], [12, 90])); expect(rowOf(walk, rot).keys).toEqual(keys([3, 1], [9, 2])); expect(frameCount(walk)).toBe(13); f.stack.undo(); f.stack.execute(new ShiftAnimationFramesCommand(walk.id, 3, -2)); expect(rowOf(walk, x).keys).toEqual(keys([0, 0], [7, 90])); expect(rowOf(walk, rot).keys).toEqual(keys([4, 2])); f.stack.undo(); expect(rowOf(walk, x).keys).toEqual(keys([0, 0], [4, 40], [9, 90])); expect(rowOf(walk, rot).keys).toEqual(keys([3, 1], [6, 2])); // Past the last key nothing would change – no empty undo step. expect(() => f.stack.execute(new ShiftAnimationFramesCommand(walk.id, 10, 5))).toThrow(CommandError); expect(shiftedFrame(4, 3, -2)).toBeNull(); expect(shiftedFrame(5, 3, -2)).toBe(3); expect(shiftedFrame(2, 3, -2)).toBe(2); }); }); 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('eases like the preview in the tab shows it (#101)', () => { const { f, walk, row, sprite, frame } = slide(); f.stack.execute(new SetKeyInterpolationCommand(walk.id, [{ trackId: row, frame: 0 }], 'ease-in')); playAnimation(sprite, walk.id); frame(0.05); frame(0.45); const previewed = previewHolder(f.project, findAssetOf(f.project, walk.id, 'animation')!, 4.5)!; const root = previewed.objects.find((o) => o.id === PLACED_ROOT_ID)!; expect(sprite.x).toBeCloseTo(22.5); expect(root.position.x).toBeCloseTo(sprite.x); }); 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('blending into an animation that takes over (#106)', () => { /** `slide` plus a `Stand` holding `x` at `at`, at 10 fps. */ function standing(at: number, name = 'Stand') { const s = slide(); const stand = addAnimation(s.f, s.f.spriteId, { fps: 10 }, name); addRow(s.f, stand, 'x', [[0, at]]); return { ...s, stand }; } it('mixes from the animation it takes over, linearly over its blend time', () => { const { walk, stand, sprite, frame } = standing(100); playAnimation(sprite, stand.id); frame(0.1); playAnimation(sprite, walk.id, 0.4); const seen: number[] = []; for (let i = 0; i < 5; i++) { frame(0.1); seen.push(sprite.x); } // The walk at frames 0…4 is 0, 10, 20, 30, 40; mixed in by 0, 1/4, 1/2, 3/4, all. [100, 77.5, 60, 47.5, 40].forEach((x, i) => expect(seen[i]).toBeCloseTo(x)); expect(sprite.animations?.find((p) => p.animationId === walk.id)?.blend).toBeUndefined(); }); it('mixes from what the property held when no animation before it writes there – without easing off', () => { const { walk, sprite, frame } = slide(); sprite.x = 100; playAnimation(sprite, walk.id, 0.4); frame(0.1); frame(0.1); expect(sprite.x).toBeCloseTo(77.5); // Mixed from last tick's 77.5 it would read 48.75: the blend runs from the value at its start. frame(0.1); expect(sprite.x).toBeCloseTo(60); }); it('takes its blend time in seconds, whatever the frame rate of the animation', () => { const { walk, sprite, frame } = slide({ fps: 30 }); sprite.x = 100; playAnimation(sprite, walk.id, 0.4); const seen: number[] = []; for (let i = 0; i < 3; i++) { frame(0.1); seen.push(sprite.x); } // The walk at 0, 0.1, 0.2 s is 0, 30, 60; mixed in by 0, 1/4, 1/2 – not by 3/4, 3/2 as frames would. [100, 82.5, 80].forEach((x, i) => expect(seen[i]).toBeCloseTo(x)); }); it('switches a value that cannot blend at once', () => { const { f, walk, stand, sprite, frame } = standing(100); addRow(f, stand, 'mirrorX', [[0, false]]); addRow(f, walk, 'mirrorX', [[0, true]]); playAnimation(sprite, stand.id); frame(0.1); playAnimation(sprite, walk.id, 0.4); frame(0.1); frame(0.1); expect(sprite.props.mirrorX).toBe(true); expect(sprite.x).toBeCloseTo(77.5); }); it('keeps its blend when a graph asks for it every frame', () => { const { walk, sprite, frame } = slide(); sprite.x = 100; const seen: number[] = []; for (let i = 0; i < 3; i++) { playAnimation(sprite, walk.id, 0.4); frame(0.1); seen.push(sprite.x); } [100, 77.5, 60].forEach((x, i) => expect(seen[i]).toBeCloseTo(x)); }); it('blends again when a running animation wins back, and not at all without a blend time', () => { const { walk, stand, sprite, frame } = standing(100); playAnimation(sprite, walk.id); frame(0.1); frame(0.1); playAnimation(sprite, stand.id); frame(0.1); expect(sprite.x).toBe(100); playAnimation(sprite, walk.id, 0.2); frame(0.1); // The walk runs on at frame 3 (30), half blended in from the stand's 100. expect(sprite.x).toBeCloseTo(65); playAnimation(sprite, stand.id, 0); frame(0.1); expect(sprite.x).toBe(100); }); it('lets a third start during a blend mix from the value already mixed, without a jump', () => { const { f, walk, stand, sprite, frame } = standing(100); const duck = addAnimation(f, f.spriteId, { fps: 10 }, 'Duck'); addRow(f, duck, 'x', [[0, -100]]); playAnimation(sprite, stand.id); frame(0.1); playAnimation(sprite, walk.id, 0.4); frame(0.1); frame(0.1); playAnimation(sprite, duck.id, 0.2); const seen: number[] = []; for (let i = 0; i < 3; i++) { frame(0.1); seen.push(sprite.x); } // Walk over stand gives 60, 47.5, 40; the duck takes each in by 0, 1/2, all. [60, -26.25, -100].forEach((x, i) => expect(seen[i]).toBeCloseTo(x)); }); }); describe('fading an animation out (#106)', () => { it('blends a once-reset pass back to where it started, while another animation runs on', () => { const { f, walk, box } = slide({ playback: 'once-reset' }); const bob = addAnimation(f, f.spriteId, { fps: 10, playback: 'pingpong' }, 'Bob'); addRow(f, bob, 'y', [ [0, 0], [4, 40], ]); const world = World.instantiate(f.project, f.sceneId); const system = new AnimationSystem(); world.addSystem(system); const sprite = world.findByObjectId(box)!; sprite.x = 10; playAnimation(sprite, bob.id); playAnimation(sprite, walk.id, 0, 0.4); const reports: number[] = []; const tick = () => { world.tick(0.1); reports.push(system.finished.length); }; for (let i = 0; i < 11; i++) tick(); expect(sprite.x).toBe(90); const xs: number[] = []; const ys: number[] = []; for (let i = 0; i < 4; i++) { tick(); xs.push(sprite.x); ys.push(sprite.y); } // From the last key's 90 back to the starting 10 by 1/4 steps; the bob turns on at 40 meanwhile. [70, 50, 30, 10].forEach((x, i) => expect(xs[i]).toBeCloseTo(x)); [30, 40, 30, 20].forEach((y, i) => expect(ys[i]).toBeCloseTo(y)); expect(reports.filter((n) => n > 0)).toEqual([1]); expect(sprite.animations?.map((p) => p.animationId)).toEqual([bob.id]); }); it('fades a stopped animation back to what an earlier animation writes there, live', () => { const { f, walk, sprite, frame } = slide(); const duck = addAnimation(f, f.spriteId, { fps: 10 }, 'Duck'); addRow(f, duck, 'x', [[0, -100]]); playAnimation(sprite, walk.id); frame(0.1); playAnimation(sprite, duck.id); frame(0.1); frame(0.1); expect(sprite.x).toBe(-100); stopAnimation(sprite, duck.id, 0.4); const seen: number[] = []; for (let i = 0; i < 5; i++) { frame(0.1); seen.push(sprite.x); } // The walk runs on at 30, 40, 50, 60, 70; the duck gives way by 1/4, 1/2, 3/4, all – then is gone. [-67.5, -30, 12.5, 60, 70].forEach((x, i) => expect(seen[i]).toBeCloseTo(x)); expect(sprite.animations?.map((p) => p.animationId)).toEqual([walk.id]); }); it('fades back to the starting value with nothing below, switches what cannot blend at once, also paused', () => { const { f, walk, sprite, frame } = slide(); addRow(f, walk, 'mirrorX', [[0, true]]); sprite.x = 100; playAnimation(sprite, walk.id); frame(0.1); frame(0.2); expect(sprite.props.mirrorX).toBe(true); pauseAnimation(sprite, walk.id); stopAnimation(sprite, walk.id, 0.2); frame(0.1); // Held at 20 by the pause, half way back to 100. expect(sprite.x).toBeCloseTo(60); expect(sprite.props.mirrorX).toBe(false); frame(0.1); expect(sprite.x).toBeCloseTo(100); expect(sprite.animations).toEqual([]); }); it('ends one that has not reached anything yet at once, blend or not', () => { const { walk, sprite } = slide(); playAnimation(sprite, walk.id); stopAnimation(sprite, walk.id, 0.5); expect(sprite.animations).toEqual([]); }); it('turns a fade round without a jump when played again', () => { const { walk, sprite, frame } = slide(); sprite.x = 100; playAnimation(sprite, walk.id); frame(0.1); frame(0.2); stopAnimation(sprite, walk.id, 0.8); frame(0.1); // The walk at 30, 1/8 of the way back to 100. expect(sprite.x).toBeCloseTo(38.75); playAnimation(sprite, walk.id, 1.6); expect(sprite.animations?.[0]?.fade).toBeUndefined(); const seen: number[] = []; for (let i = 0; i < 2; i++) { frame(0.1); seen.push(sprite.x); } // Blending in from 100 it stands at 7/8 – where the fade left it – and runs on from there: walk 40 // at 15/16, walk 50 all in. [43.75, 50].forEach((x, i) => expect(seen[i]).toBeCloseTo(x)); }); it('starts a once-reset pass over when played again while it fades back, from where it stands', () => { const { walk, sprite, frame } = slide({ playback: 'once-reset' }); sprite.x = 10; playAnimation(sprite, walk.id, 0, 0.4); for (let i = 0; i < 13; i++) frame(0.1); expect(sprite.x).toBeCloseTo(50); playAnimation(sprite, walk.id, 0.2, 0.4); const player = sprite.animations?.[0]; expect(player).toMatchObject({ time: 0, blendOut: 0.4 }); expect(player?.fade).toBeUndefined(); frame(0.1); // Frame 1 of the walk (10), blended in half way from the 50 the fade had reached. expect(sprite.x).toBeCloseTo(30); // Relative to the same start: it still goes back to 10 at the end. expect(player?.bases).toEqual({ [`${sprite.id} x`]: 10 }); }); }); describe('restarting an animation that already runs', () => { /** `walk` played for 0.4 s: frame 4, x 40. */ function walked() { const s = slide(); s.sprite.x = 5; playAnimation(s.sprite, s.walk.id); s.frame(0.1); s.frame(0.2); s.frame(0.2); expect(s.sprite.x).toBeCloseTo(40); return s; } it('starts a running one over from its first frame, keeping its starting values', () => { const { walk, sprite, frame } = walked(); const bases = { ...sprite.animations?.[0]?.bases }; playAnimation(sprite, walk.id); frame(0.1); expect(sprite.x).toBeCloseTo(50); playAnimation(sprite, walk.id, 0, 0, true); expect(sprite.animations?.[0]?.time).toBe(0); frame(0.1); expect(sprite.x).toBeCloseTo(10); expect(sprite.animations?.[0]?.bases).toEqual(bases); }); it('starts a paused one over and lets it run', () => { const { walk, sprite, frame } = walked(); pauseAnimation(sprite, walk.id); frame(0.1); playAnimation(sprite, walk.id, 0, 0, true); frame(0.1); frame(0.1); expect(sprite.x).toBeCloseTo(20); }); it('blends the new start in from where it stands', () => { const { walk, sprite, frame } = walked(); playAnimation(sprite, walk.id, 0.2, 0, true); const seen: number[] = []; for (let i = 0; i < 2; i++) { frame(0.1); seen.push(sprite.x); } // Walk 10 half blended in from 40, then walk 20 all in. [25, 20].forEach((x, i) => expect(seen[i]).toBeCloseTo(x)); }); it('starts a fading one over instead of turning it round', () => { const { walk, sprite, frame } = walked(); stopAnimation(sprite, walk.id, 0.8); frame(0.1); playAnimation(sprite, walk.id, 0, 0, true); expect(sprite.animations?.[0]).toMatchObject({ time: 0, playing: true }); expect(sprite.animations?.[0]?.fade).toBeUndefined(); frame(0.1); expect(sprite.x).toBeCloseTo(10); }); }); 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('behavior variables and shader parameters as rows (#104)', () => { /** A box in the scene with a `Mover` behavior below it, and a shader declaring `glow`. */ function rig() { const f = makeSpriteScene(); const box = place(f, f.spriteId, { x: 0, y: 0 }); const mover = makeBehavior(f, 'Mover', ['speed', 'x']); const moverObject = place(f, mover.id, { x: 0, y: 0 }, box); const shader = new CreateAssetCommand('shader', 'Glow'); f.stack.execute(shader); f.stack.execute( new AddNodeCommand(shader.asset.id, SHADER_PROPERTY, 0, 0, { name: 'glow', default: 1, min: 0, max: 100, }), ); const walk = addAnimation(f, f.sceneId, { fps: 10 }); return { f, box, mover, moverObject, shaderId: shader.asset.id, walk }; } const ramp: [number, AnimationKey['value']][] = [ [0, 0], [10, 100], ]; const keysOn = (f: SceneFixture, walk: AnimationAsset, target: string) => targetDefs(f.project, walk, target)!.map((d) => d.key); it("offers a behavior's variables and no position, and a variable `x` is the variable", () => { const { f, moverObject, walk } = rig(); expect(keysOn(f, walk, moverObject)).toEqual(['enabled', 'runInEditor', 'speed', 'x']); const row = addRow(f, walk, 'x', ramp, moverObject); expect(trackLabel(f.project, current(f, walk), rowOf(current(f, walk), row))).toBe('Mover · x'); const { world, frame } = startWorld(f, { animations: true }); const entity = world.findByObjectId(moverObject)!; playAnimation(entity, walk.id); frame(0.1); frame(0.5); expect(entity.props.x).toBeCloseTo(50); expect(entity.x).toBe(0); const drawn = previewHolder(f.project, current(f, walk), 5)!.objects.find((o) => o.id === moverObject)!; expect(drawn.overrides.x).toBeCloseTo(50); expect(drawn.position.x).toBe(0); }); it('writes a variable before the behavior runs, so the behavior reads it this frame', () => { const { f, box, mover, moverObject, walk } = rig(); addRow(f, walk, 'speed', ramp, moverObject); // The graph copies `speed` into `x` every frame: what it reads is what the animation just wrote. const edit = graphEditor(f, mover); const update = edit.addNode('OnUpdate'); const read = edit.variables(['speed']); const write = edit.variables(['x'], { write: true }); edit.wire([ [update, 'exec', write, 'exec'], [read, 'speed', write, 'x'], ]); const { world, frame } = startWorld(f, { animations: true, behaviors: true }); playAnimation(world.findByObjectId(box)!, walk.id); frame(0.1); frame(0.5); const entity = world.findByObjectId(moverObject)!; expect(entity.props.speed).toBeCloseTo(50); expect(entity.props.x).toBeCloseTo(50); }); it('offers the parameters of the shader the object wears – through its own override, too', () => { const { f, box, shaderId, walk } = rig(); const plain = place(f, f.spriteId, { x: 50, y: 0 }); expect(keysOn(f, walk, box)).not.toContain('glow'); f.stack.execute(new SetOverrideCommand(f.sceneId, box, SHADER_KEY, shaderId)); expect(keysOn(f, walk, box)).toContain('glow'); expect(keysOn(f, walk, plain)).not.toContain('glow'); expect(() => f.stack.execute(new AddAnimationTrackCommand(walk.id, plain, 'glow'))).toThrow(CommandError); }); it('plays a shader parameter at runtime and in the preview alike', () => { const { f, box, shaderId, walk } = rig(); f.stack.execute(new SetAssetPropertyCommand(f.spriteId, SHADER_KEY, shaderId)); addRow(f, walk, 'glow', ramp, box); const { world, frame } = startWorld(f, { animations: true }); const entity = world.findByObjectId(box)!; playAnimation(entity, walk.id); frame(0.1); frame(0.5); expect(entity.props.glow).toBeCloseTo(50); const drawn = previewHolder(f.project, current(f, walk), 5)!.objects.find((o) => o.id === box)!; expect(drawn.overrides.glow).toBeCloseTo(50); expect(f.sceneOf().objects.find((o) => o.id === box)!.overrides.glow).toBeUndefined(); }); it('reads as missing once the variable is renamed or the shader taken off, and keeps its keys', () => { const { f, box, mover, moverObject, shaderId, walk } = rig(); f.stack.execute(new SetAssetPropertyCommand(f.spriteId, SHADER_KEY, shaderId)); const speed = addRow(f, walk, 'speed', ramp, moverObject); const glow = addRow(f, walk, 'glow', ramp, box); f.stack.execute(new RenameVariableCommand(mover.id, 'speed', 'pace')); f.stack.execute(new SetAssetPropertyCommand(f.spriteId, SHADER_KEY, '')); const now = current(f, walk); for (const id of [speed, glow]) { expect(trackDef(f.project, now, rowOf(now, id))).toBeUndefined(); expect(trackLabel(f.project, now, rowOf(now, id))).toMatch(/\(missing\)$/); expect(rowOf(now, id).keys).toHaveLength(2); } const { world, frame } = startWorld(f, { animations: true }); playAnimation(world.findByObjectId(box)!, walk.id); frame(0.1); frame(0.5); expect(world.findByObjectId(moverObject)!.props.speed).toBeUndefined(); expect(world.findByObjectId(box)!.props.glow).toBeUndefined(); // A missing row may go anywhere; the kind rule holds again once it aims at something. f.stack.execute(new RetargetAnimationTrackCommand(walk.id, speed, moverObject, 'pace')); expect(() => f.stack.execute(new RetargetAnimationTrackCommand(walk.id, speed, moverObject, 'enabled')), ).toThrow(CommandError); }); }); 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); }); it('passes Blend on to the player it starts (#106)', () => { const f = makeSpriteScene(); const box = place(f, f.spriteId, { x: 0, y: 0 }); const walk = addAnimation(f, f.spriteId, { fps: 10 }); const behavior = makeBehavior(f, 'Logic', []); 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, blend: 0.6, blendOut: 0.3 }); edit.wire([ [start, 'exec', play, 'exec'], [owner, 'object', play, 'object'], ]); const { world, frame } = startWorld(f, { behaviors: true }); frame(0.1); expect(world.findByObjectId(box)!.animations?.[0]).toMatchObject({ blend: { seconds: 0.6 }, blendOut: 0.3, }); }); it('restarts the animation every time when Restart is on, and lets it run on when off', () => { const run = (restart: boolean) => { const f = makeSpriteScene(); const box = place(f, f.spriteId, { x: 0, y: 0 }); const walk = addAnimation(f, f.spriteId, { fps: 10 }); addRow(f, walk, 'x', [ [0, 0], [9, 90], ]); const behavior = makeBehavior(f, 'Logic', []); 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 play = edit.addNode('PlayAnimation', { animation: walk.id, restart }); edit.wire([ [update, 'exec', play, 'exec'], [owner, 'object', play, 'object'], ]); const { world, frame } = startWorld(f, { animations: true, behaviors: true }); for (let i = 0; i < 5; i++) frame(0.1); return world.findByObjectId(box)!.animations?.[0]?.time; }; expect(run(false)).toBeGreaterThan(0.3); expect(run(true)).toBeLessThan(0.15); }); it('passes Blend on to Stop animation, which fades the player out (#106)', () => { const f = makeSpriteScene(); const box = place(f, f.spriteId, { x: 0, y: 0 }); const walk = addAnimation(f, f.spriteId, { fps: 10 }); addRow(f, walk, 'x', [ [0, 0], [9, 90], ]); const cue = addAnimation(f, f.spriteId, { fps: 10, playback: 'once' }, 'Cue'); const behavior = makeBehavior(f, 'Logic', []); 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 playWalk = edit.addNode('PlayAnimation', { animation: walk.id }); const playCue = edit.addNode('PlayAnimation', { animation: cue.id }); const finished = edit.addNode('OnAnimationFinished', { animation: cue.id }); const stop = edit.addNode('StopAnimation', { animation: walk.id, blend: 0.5 }); edit.wire([ [start, 'exec', playWalk, 'exec'], [playWalk, 'exec', playCue, 'exec'], [owner, 'object', playWalk, 'object'], [owner, 'object', playCue, 'object'], [finished, 'exec', stop, 'exec'], [owner, 'object', stop, 'object'], ]); const { world, frame } = startWorld(f, { animations: true, behaviors: true }); for (let i = 0; i < 3; i++) frame(0.1); expect(world.findByObjectId(box)!.animations).toMatchObject([ { animationId: walk.id, fade: { seconds: 0.5 } }, ]); }); });