Files
feuerwerk/tests/core/animation.test.ts
T

955 lines
37 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,
MoveAnimationKeysCommand,
RemoveAnimationKeysCommand,
RetargetAnimationTrackCommand,
SetAnimationKeyCommand,
SetAnimationRootCommand,
SetKeyInterpolationCommand,
SetTrackRelativeCommand,
} 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,
playAnimation,
playheadAt,
stopAnimation,
targetDefs,
trackDef,
trackLabel,
trackValueAt,
} from '../../src/core/model/animation';
import { SHADER_KEY, SHADER_PROPERTY } from '../../src/core/properties/shaders';
import { PREVIEW_ROOT_ID, 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('aims a row elsewhere keeping its keys, only at a property of the same kind', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
const id = addRow(f, walk, 'x', [[0, 5]]);
f.stack.execute(new RetargetAnimationTrackCommand(walk.id, id, '', 'rotation'));
expect(rowOf(walk, id)).toMatchObject({ property: 'rotation', keys: keys([0, 5]) });
expect(() => f.stack.execute(new RetargetAnimationTrackCommand(walk.id, id, '', 'color'))).toThrow(
CommandError,
);
expect(() =>
f.stack.execute(new SetTrackRelativeCommand(walk.id, addRow(f, walk, 'color', []), true)),
).toThrow(CommandError);
});
});
describe('saving', () => {
it('keeps root, rows and keys through a round trip, and cleans what a hand-edited file brings', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId, { fps: 12, playback: 'once-reset' });
const id = addRow(f, walk, 'x', [
[0, 0],
[9, 90],
]);
f.stack.execute(new SetTrackRelativeCommand(walk.id, id, true));
expect(findAssetOf(roundTrip(f.project), walk.id, 'animation')).toEqual(walk);
const raw = structuredClone(f.project) as unknown as { assets: Record<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 === PREVIEW_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('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 });
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]?.blend?.seconds).toBe(0.6);
});
});