Files
feuerwerk/tests/core/animation.test.ts
T
2026-09-17 14:19:53 +02:00

1352 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, unknown>[] };
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<string, unknown>[] };
const stored = raw.assets.find((a) => a.id === walk.id)!;
stored.tracks = [
{
id: 'r1',
target: 'gone',
property: 'x',
keys: [
{ frame: 4.4, value: 1 },
{ frame: 1, value: 2 },
{ frame: 'x', value: 3 },
{ frame: 2, value: [1] },
],
},
{ id: 'r2', target: 'gone', property: 'x', keys: [] },
{ id: 'r1', target: '', property: 'y', keys: [] },
{ target: '', property: 'rotation', keys: [] },
];
stored.props = { fps: 10, playback: 'backwards' };
const coerced = findAssetOf(deserializeProject(raw), walk.id, 'animation')!;
expect(coerced.tracks).toEqual([{ id: 'r1', target: 'gone', property: 'x', keys: keys([1, 2], [4, 1]) }]);
expect(coerced.props.playback).toBe('loop');
});
it('counts the animations keying a texture', () => {
const f = makeSpriteScene();
const texture = new CreateTextureCommand('Hero', { file: `${'ab'.repeat(32)}.png`, width: 8, height: 8 });
f.stack.execute(texture);
const walk = addAnimation(f, f.spriteId);
addRow(f, walk, 'texture', [[0, texture.asset.id]]);
addAnimation(f, f.spriteId, {}, 'Other');
expect(animationsUsing(f.project, [texture.asset.id])).toEqual([walk]);
});
});
/** A placed sprite and an animation over it moving `x` 0 → 90 on frames 0…9 at 10 fps. */
function slide(props = {}) {
const f = makeSpriteScene();
const box = place(f, f.spriteId, { x: 0, y: 0 });
const walk = addAnimation(f, f.spriteId, { fps: 10, ...props });
const row = addRow(f, walk, 'x', [
[0, 0],
[9, 90],
]);
const started = startWorld(f, { animations: true });
const sprite = started.world.findByObjectId(box)!;
return { f, walk, row, box, sprite, ...started };
}
describe('the AnimationSystem', () => {
it('shows frame 0 on the tick after the start, then blends every tick', () => {
const { walk, sprite, frame } = slide();
playAnimation(sprite, walk.id);
frame(0.05);
expect(sprite.x).toBe(0);
frame(0.05);
expect(sprite.x).toBeCloseTo(5);
frame(0.5);
expect(sprite.x).toBeCloseTo(55);
});
it('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 } },
]);
});
});