Files
adminandClaude Opus 5.5 37779a2070
feuerwerk / gate (push) Successful in 5m21s
feuerwerk / suite (push) Successful in 20m7s
Fold object groups in the timeline, with summary keys (#414)
Long animations with many objects fill the timeline with rows. A
group per object can now fold away, and its header row still shows
where the group has keys, so they stay reachable while folded.

- A click on an object header or its triangle folds the group. The
  groups of the objects below it fold along.
- Which groups fold along follows the real parent in the hierarchy,
  not the indent. The root shows at the level of its children, so
  folding it hides only its own rows.
- Every header shows a summary diamond on each frame where a row of
  the group or of a group below has a key. Click selects all those
  keys, drag moves them, Ctrl+drag duplicates, a band catches them
  and Delete removes them, each through the existing commands.
- The fold state is a view of the session per animation. It is no
  undo step and is not saved.
- Selecting a row without keys inside a folded group unfolds it, so
  a fresh row from + Row does not stay hidden.
- The fold rules live in the new timelineFold.ts. keyAt became
  keysAt, which returns every key behind a summary diamond.

Found on the way: the triangle moved the object name to x 22 while
its rows stayed at x 20, left of the name (#408 check in e2e 25).
Rows now sit one step behind the name, as in the hierarchy.

Tests:
- Unit animation + timeline-view 114/114, 6 new
- 6 counter-checks, all red in the matching test
- e2e 25 (atx) ALL PASSED, 41 checks, 4 new (25k)
- Gate (atx) CHECK EXIT 0, 324 files, 5008 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:01:30 +02:00

1944 lines
76 KiB
TypeScript
Raw Permalink 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, SetVariablePrivateCommand } from '../../src/core/commands/variableCommands';
import {
animatableDefs,
type AnimationClip,
animationDuration,
type AnimationPlayer,
frameCount,
isPositionRow,
leadingPlayer,
lineValueAt,
NOT_PLAYING,
offeredDefs,
pauseAnimation,
PLACED_ROOT_ID,
playAnimation,
type Playback,
playerStatus,
type PlayerStatus,
playheadAt,
startersOf,
stopAnimation,
targetDefs,
targetNamePath,
type TimelineLine,
timelineLines,
trackDef,
trackLabel,
trackValueAt,
} from '../../src/core/model/animation';
import { SHADER_KEY, SHADER_PROPERTY } from '../../src/core/properties/shaders';
import { foldLines, type ShownLine, unfoldedFor } from '../../src/editor/animation/timelineFold';
import { summaryFrames, summaryKeys } from '../../src/editor/animation/timelineView';
import { previewHolder } from '../../src/core/model/animationPreview';
import { graphLookup } from '../../src/core/model/graphLookup';
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 { drawSlots } from '../../src/viewport/drawOrder';
import { collectWorldItems } from '../../src/viewport/items';
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('starts an empty row at what its header shows, the value the object holds (#412)', () => {
const f = makeSpriteScene();
const a = place(f, f.spriteId, { x: 40, y: 0 });
f.stack.execute(new SetOverrideCommand(f.sceneId, a, 'scale', 2));
const walk = addAnimation(f, f.sceneId);
const x = addRow(f, walk, 'x', [], a);
const scale = addRow(f, walk, 'scale', [], a);
const shown = timelineLines(f.project, current(f, walk)).flatMap((l) =>
l.kind === 'row' ? [lineValueAt(l, 0)] : [],
);
f.stack.execute(new SetAnimationKeyCommand(walk.id, x, 0));
f.stack.execute(new SetAnimationKeyCommand(walk.id, scale, 0));
expect(shown).toEqual([40, 2]);
expect(rowOf(current(f, walk), x).keys).toEqual(keys([0, 40]));
expect(rowOf(current(f, walk), scale).keys).toEqual(keys([0, 2]));
});
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("the timeline's header (#408)", () => {
/** A scene with `A` and `C` at the top and `B` under `A`, placed in the order A, C, B. */
function scene() {
const f = makeSpriteScene();
const a = place(f, f.spriteId, { x: 5, y: 0 });
const c = place(f, f.spriteId, { x: 0, y: 0 });
const b = place(f, f.spriteId, { x: 0, y: 0 }, a);
const named = (id: string, name: string) => (f.sceneOf().objects.find((o) => o.id === id)!.name = name);
named(a, 'A');
named(b, 'B');
named(c, 'C');
return { f, a, b, c };
}
const shape = (lines: TimelineLine[]) =>
lines.map((l) => (l.kind === 'group' ? `${l.name}@${l.depth}` : ` ${l.track.property}`));
it('groups the rows by object in the order of the hierarchy, each in the order of its properties', () => {
const { f, a, b, c } = scene();
const walk = addAnimation(f, f.sceneId);
addRow(f, walk, 'rotation', [], c);
addRow(f, walk, 'scale', [], a);
addRow(f, walk, 'rotation', [], b);
addRow(f, walk, 'backgroundColor', []);
addRow(f, walk, 'x', [], a);
addRow(f, walk, 'enabled', [], a);
current(f, walk).tracks.push({ id: 'lost', target: 'gone', property: 'x', keys: [] });
expect(shape(timelineLines(f.project, current(f, walk)))).toEqual([
`${f.sceneOf().name}@0`,
' backgroundColor',
'A@0',
' x',
' enabled',
' scale',
'B@1',
' rotation',
'C@0',
' rotation',
'(missing)@0',
' x',
]);
// Only the view is sorted.
expect(current(f, walk).tracks.map((t) => t.property)[0]).toBe('rotation');
});
it('shows the value at the playhead, what the object holds before the first key, and an offset when relative', () => {
const { f, a } = scene();
const walk = addAnimation(f, f.sceneId);
addRow(f, walk, 'x', [], a);
addRow(
f,
walk,
'scale',
[
[0, 1],
[10, 3],
],
a,
);
const y = addRow(f, walk, 'y', [], a);
f.stack.execute(new SetTrackRelativeCommand(walk.id, y, true));
const row = (property: string) =>
timelineLines(f.project, current(f, walk)).find(
(l): l is TimelineLine & { kind: 'row' } => l.kind === 'row' && l.track.property === property,
)!;
expect(lineValueAt(row('x'), 4)).toBe(5);
expect(lineValueAt(row('y'), 4)).toBe(0);
expect(lineValueAt(row('scale'), 5)).toBeCloseTo(2);
current(f, walk).tracks.push({ id: 'lost', target: 'gone', property: 'x', keys: [] });
expect(lineValueAt(row('x'), 4)).toBe(5);
});
});
describe("folding the timeline's groups (#414)", () => {
/** `A` at the top with `B` under it; `D` at the top, not animated, with `C` under it. */
function scene() {
const f = makeSpriteScene();
const a = place(f, f.spriteId, { x: 0, y: 0 });
const b = place(f, f.spriteId, { x: 0, y: 0 }, a);
const d = place(f, f.spriteId, { x: 0, y: 0 });
const c = place(f, f.spriteId, { x: 0, y: 0 }, d);
for (const [id, name] of [
[a, 'A'],
[b, 'B'],
[c, 'C'],
[d, 'D'],
] as const)
f.sceneOf().objects.find((o) => o.id === id)!.name = name;
const walk = addAnimation(f, f.sceneId);
const ax = addRow(
f,
walk,
'x',
[
[0, 0],
[6, 60],
],
a,
);
const ay = addRow(
f,
walk,
'y',
[
[2, 0],
[6, 10],
],
a,
);
const br = addRow(f, walk, 'rotation', [[4, 90]], b);
const cx = addRow(f, walk, 'x', [[6, 1]], c);
return { f, walk, a, b, c, d, ax, ay, br, cx };
}
const names = (lines: ShownLine[]) =>
lines.map((l) => (l.kind === 'group' ? `${l.name}${l.folded ? '+' : ''}` : ` ${l.track.property}`));
const head = (lines: ShownLine[], name: string) => {
const line = lines.find((l) => l.kind === 'group' && l.name === name);
if (line?.kind !== 'group') throw new Error(`no head ${name}`);
return line;
};
it('knows the objects a head sits under, by the hierarchy rather than the depth it is drawn at', () => {
const { f, walk, a, d } = scene();
const lines = timelineLines(f.project, current(f, walk));
const above = (name: string) => lines.find((l) => l.kind === 'group' && l.name === name);
expect(above('B')).toMatchObject({ above: [a], depth: 1 });
expect(above('C')).toMatchObject({ above: [d], depth: 1 });
expect(above('A')).toMatchObject({ above: [] });
});
it("folding a head hides its rows and its children's groups, not a deeper group of another object", () => {
const { f, walk, a } = scene();
const lines = timelineLines(f.project, current(f, walk));
expect(names(foldLines(lines, new Set()))).toEqual(['A', ' x', ' y', 'B', ' rotation', 'C', ' x']);
expect(names(foldLines(lines, new Set([a])))).toEqual(['A+', 'C', ' x']);
});
it("a head's diamonds are the frames any row of its group or below it has a key on", () => {
const { f, walk, a, ax, ay, br } = scene();
const shown = foldLines(timelineLines(f.project, current(f, walk)), new Set([a]));
const lane = head(shown, 'A');
expect(summaryFrames(lane)).toEqual([0, 2, 4, 6]);
expect(summaryKeys(lane, 6)).toEqual([
{ trackId: ax, frame: 6 },
{ trackId: ay, frame: 6 },
]);
expect(summaryKeys(lane, 4)).toEqual([{ trackId: br, frame: 4 }]);
// Open, the head sums up the same keys.
expect(
summaryFrames(head(foldLines(timelineLines(f.project, current(f, walk)), new Set()), 'A')),
).toEqual([0, 2, 4, 6]);
});
it("moving a head's diamond moves every key of its group on that frame, and one undo takes it back", () => {
const { f, walk, a, ax, ay, cx } = scene();
const lane = head(foldLines(timelineLines(f.project, current(f, walk)), new Set([a])), 'A');
const before = JSON.stringify(current(f, walk).tracks);
f.stack.execute(new MoveAnimationKeysCommand(walk.id, summaryKeys(lane, 6), 2));
expect(rowOf(current(f, walk), ax).keys).toEqual(keys([0, 0], [8, 60]));
expect(rowOf(current(f, walk), ay).keys).toEqual(keys([2, 0], [8, 10]));
// C's key on frame 6 belongs to another group and stays.
expect(rowOf(current(f, walk), cx).keys).toEqual(keys([6, 1]));
f.stack.undo();
expect(JSON.stringify(current(f, walk).tracks)).toBe(before);
});
it('a row to be seen opens its own head and those above it, and leaves the others folded', () => {
const { f, walk, a, b, c } = scene();
const lines = timelineLines(f.project, current(f, walk));
expect([...unfoldedFor(lines, new Set([a, b, c]), b)]).toEqual([c]);
});
});
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('finds the object by its names on a container built alike, and skips a name missing or taken twice', () => {
/** The `x` of `Body` in a second container after half a second of an animation built over the first. */
const played = (names: string[]) => {
const f = makeSpriteScene();
const [slime, skeleton] = [0, 500].map((x) => {
const wrap = new CreateContainerCommand(f.sceneId, [place(f, f.spriteId, { x, y: 0 })]);
f.stack.execute(wrap);
const objects = (findAsset(f.project, wrap.assetId) as ContainerAsset).objects;
objects[0]!.name = 'Body';
return { wrap, objects };
});
const body = skeleton!.objects[0]!;
skeleton!.objects.splice(0, 1, ...names.map((name, i) => ({ ...body, id: `${body.id}${i}`, name })));
const walk = addAnimation(f, slime!.wrap.assetId, { fps: 10 });
addRow(f, walk, 'x', slideX, slime!.objects[0]!.id);
const { world, frame } = startWorld(f, { animations: true });
const instance = world.findByObjectId(skeleton!.wrap.objectId)!;
const target = world.childrenOf(instance)[0]!;
const before = target.x;
playAnimation(instance, walk.id);
frame(0.1);
frame(0.5);
return { before, after: target.x };
};
expect(played(['Body']).after).toBeCloseTo(50);
const renamed = played(['Arm']);
expect(renamed.after).toBe(renamed.before);
const twice = played(['Body', 'Body']);
expect(twice.after).toBe(twice.before);
});
it('names the way down to a row two containers deep', () => {
const { f, outer, path } = nested();
const root = findAsset(f.project, outer.assetId) as ContainerAsset;
const placing = root.objects[0]!;
placing.name = 'Hand';
const inner = findAsset(f.project, placing.assetId) as ContainerAsset;
inner.objects[0]!.name = 'Arm';
const names = (target: string) => targetNamePath(f.project, root, target);
expect(names(path)).toEqual(['Hand', 'Arm']);
expect(names(placing.id)).toEqual(['Hand']);
expect(names('')).toBeUndefined();
expect(names('gone')).toBeUndefined();
});
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', 'runWhilePaused', '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);
});
it('does not offer a private variable for a new row, and a row on one keeps playing (#270)', () => {
const { f, box, mover, moverObject, walk } = rig();
const offered = (changing: string | null = null) =>
offeredDefs(
f.project,
current(f, walk),
moverObject,
changing === null ? null : rowOf(current(f, walk), changing),
).map((d) => d.key);
const row = addRow(f, walk, 'speed', ramp, moverObject);
expect(offered()).toEqual(['enabled', 'runInEditor', 'runWhilePaused', 'x']);
f.stack.execute(new SetVariablePrivateCommand(mover.id, 'x', true));
f.stack.execute(new SetVariablePrivateCommand(mover.id, 'speed', true));
expect(offered()).toEqual(['enabled', 'runInEditor', 'runWhilePaused']);
// The row being changed keeps its own property on offer, and no other private one of its kind.
expect(offered(row)).toEqual(['speed']);
expect(keysOn(f, walk, moverObject)).toContain('speed');
expect(trackLabel(f.project, current(f, walk), rowOf(current(f, walk), row))).toBe('Mover · speed');
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).toBeCloseTo(50);
f.stack.execute(new SetVariablePrivateCommand(mover.id, 'x', false));
expect(offered()).toContain('x');
});
});
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);
});
/**
* Two arms below a box, a behavior on each. The first plays `walk` on the box, its owner's parent;
* the second only listens, or plays it too. Both count `On animation finished`.
*/
function above(second: 'listens' | 'plays too', times = 8) {
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 counts = (name: string, plays: boolean) => {
const arm = place(f, f.spriteId, { x: 0, y: 0 }, box);
const behavior = makeBehavior(f, name, ['done']);
place(f, behavior.id, { x: 0, y: 0 }, arm);
const edit = graphEditor(f, behavior);
const finished = edit.addNode('OnAnimationFinished');
const store = edit.variables(['done'], { write: true });
const add = edit.addNode('Add', { b: 1 });
edit.wire([
[finished, 'exec', store, 'exec'],
[store, 'done', add, 'a'],
[add, 'result', store, 'done'],
]);
if (!plays) return;
const start = edit.addNode('OnStart');
const owner = edit.addNode('Owner');
const parent = edit.addNode('Parent');
const play = edit.addNode('PlayAnimation', { animation: walk.id });
edit.wire([
[start, 'exec', play, 'exec'],
[owner, 'object', parent, 'object'],
[parent, 'parent', play, 'object'],
]);
};
counts('First', true);
counts('Second', second === 'plays too');
const { world, frame } = startWorld(f, { animations: true, behaviors: true });
for (let i = 0; i < times; i++) frame(0.1);
const done = world.entities.filter((e) => e.type === 'behavior').map((e) => e.props.done);
return { done, x: world.findByObjectId(box)!.x };
}
it('hears an animation it played on the parent of its owner, and a bystander does not', () => {
expect(above('listens')).toEqual({ done: [1, 0], x: 20 });
});
it('is heard by each behavior that played it, once', () => {
expect(above('plays too')).toEqual({ done: [1, 1], x: 20 });
});
it('keeps who asked beside the player, which stays plain data', () => {
// What asks is an entity of the run: it reaches the holder again, as a behavior reaches its owner.
const holder: { animations?: AnimationPlayer[]; children: object[] } = { children: [] };
const asker = { parent: holder };
holder.children.push(asker);
playAnimation(holder, 'walk', 0, 0, false, asker);
playAnimation(holder, 'walk', 0, 0, false, asker);
const player = holder.animations![0]!;
expect(startersOf(player)).toEqual([asker]);
expect(Object.values(player).every((value) => typeof value !== 'object')).toBe(true);
expect(JSON.parse(JSON.stringify(holder.animations))).toEqual([{ ...player }]);
expect(structuredClone(holder.animations)).toEqual([{ ...player }]);
playAnimation(holder, 'idle');
expect(startersOf(holder.animations![1]!)).toEqual([]);
});
it('shows frame 0 in the frame a spawned behavior plays it from On start, before it is drawn', () => {
const f = makeSpriteScene();
const box = place(f, f.spriteId, { x: 7, y: 0 });
const walk = addAnimation(f, f.spriteId, { fps: 10 });
addRow(f, walk, 'x', [
[0, 0],
[9, 90],
]);
const logic = makeBehavior(f, 'Logic', []);
const edit = graphEditor(f, logic);
const start = edit.addNode('OnStart');
const owner = edit.addNode('Owner');
const play = edit.addNode('PlayAnimation', { animation: walk.id });
edit.wire([
[start, 'exec', play, 'exec'],
[owner, 'object', play, 'object'],
]);
// A spawner below the box puts Logic below the box too on a key.
const spawner = makeBehavior(f, 'Spawner', []);
place(f, spawner.id, { x: 0, y: 0 }, box);
const spawning = graphEditor(f, spawner);
const key = spawning.addNode('OnKeyDown', { key: 'x' });
const here = spawning.addNode('Owner');
const spawn = spawning.addNode('Spawn', { asset: logic.id });
spawning.wire([
[key, 'exec', spawn, 'exec'],
[here, 'object', spawn, 'parent'],
]);
const { world, input, frame } = startWorld(f, { animations: true, behaviors: true });
const sprite = world.findByObjectId(box)!;
frame(0.1);
expect(sprite.x).toBe(7);
input.press('x');
frame(0.1);
expect(sprite.animations).toHaveLength(1);
expect(sprite.x).toBe(0);
frame(0.1);
expect(sprite.x).toBeCloseTo(10);
});
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 } },
]);
});
});
describe('asking whether an animation still runs (#233)', () => {
const clip = (playback: Playback): AnimationClip => ({ frames: 10, fps: 10, playback });
const at = (time: number, more = {}): AnimationPlayer => ({
animationId: 'a',
time,
playing: true,
order: 1,
...more,
});
const close = (status: PlayerStatus, progress: number, timeLeft: number) => {
expect(status.progress).toBeCloseTo(progress, 4);
expect(status.timeLeft).toBeCloseTo(timeLeft, 4);
};
/** What the node answers for the object, the lookup being the project's own. */
const asked = (f: SceneFixture, holder: { animations?: AnimationPlayer[] }, animationId: string) => {
const player = leadingPlayer(holder, animationId);
const known = player && graphLookup(f.project).animation(player.animationId);
return known ? playerStatus(known, player) : NOT_PLAYING;
};
it('says where the playhead stands in its pass, in every playback', () => {
close(playerStatus(clip('once'), at(0.25)), 0.25, 0.75);
close(playerStatus(clip('loop'), at(1.25)), 0.25, 0.75);
// Ping-pong turns on the last key: 9 frames up, and on the way back the progress falls again.
close(playerStatus(clip('pingpong'), at(0.3)), 1 / 3, 0.6);
close(playerStatus(clip('pingpong'), at(1.2)), 2 / 3, 0.6);
close(playerStatus(clip('once-reset'), at(0.25, { blendOut: 0.4 })), 0.25, 1.15);
close(playerStatus(clip('once'), at(0.25, { blendOut: 0.4 })), 0.25, 0.75);
expect(playerStatus({ frames: 0, fps: 10, playback: 'loop' }, at(3))).toEqual({
playing: true,
progress: 0,
timeLeft: 0,
});
});
it('still runs while a once-reset pass blends out, and is gone after', () => {
const { f, walk, sprite, frame } = slide({ playback: 'once-reset' });
expect(asked(f, sprite, walk.id)).toEqual(NOT_PLAYING);
playAnimation(sprite, walk.id, 0, 0.4);
for (let i = 0; i < 6; i++) frame(0.1);
expect(asked(f, sprite, walk.id).playing).toBe(true);
close(asked(f, sprite, walk.id), 0.5, 0.9);
for (let i = 0; i < 5; i++) frame(0.1);
expect(asked(f, sprite, walk.id).playing).toBe(true);
close(asked(f, sprite, walk.id), 1, 0.4);
frame(0.1);
close(asked(f, sprite, walk.id), 1, 0.3);
for (let i = 0; i < 3; i++) frame(0.1);
expect(asked(f, sprite, walk.id)).toEqual(NOT_PLAYING);
});
it('does not run paused or stopped, and keeps its place', () => {
const { f, walk, sprite, frame } = slide();
playAnimation(sprite, walk.id);
for (let i = 0; i < 4; i++) frame(0.1);
pauseAnimation(sprite, walk.id);
frame(0.5);
expect(asked(f, sprite, walk.id).playing).toBe(false);
close(asked(f, sprite, walk.id), 0.3, 0.7);
playAnimation(sprite, walk.id);
expect(asked(f, sprite, walk.id).playing).toBe(true);
stopAnimation(sprite, walk.id, 0.5);
expect(asked(f, sprite, walk.id).playing).toBe(false);
close(asked(f, sprite, walk.id), 0.3, 0);
});
it('answers for the one started last when no animation is named, a running one first', () => {
const { f, walk, sprite } = slide();
const bob = addAnimation(f, f.spriteId, { fps: 10 }, 'Bob');
playAnimation(sprite, walk.id);
playAnimation(sprite, bob.id);
expect(leadingPlayer(sprite, '')?.animationId).toBe(bob.id);
expect(leadingPlayer(sprite, walk.id)?.animationId).toBe(walk.id);
pauseAnimation(sprite, bob.id);
expect(leadingPlayer(sprite, '')?.animationId).toBe(walk.id);
pauseAnimation(sprite, walk.id);
expect(leadingPlayer(sprite, '')?.animationId).toBe(bob.id);
expect(asked(f, sprite, '').playing).toBe(false);
expect(leadingPlayer({}, '')).toBeUndefined();
});
/**
* A behavior below the sprite plays `walk` on its owner at start, and every frame writes down what
* `Animation playing` says: `ran` counts the frames it ran, `at` and `left` are the last answer.
*/
function watching(playback: Playback, frames: number, named = true) {
const f = makeSpriteScene();
const box = place(f, f.spriteId, { x: 0, y: 0 });
const walk = addAnimation(f, f.spriteId, { fps: 10, playback });
addRow(f, walk, 'x', [
[0, 0],
[9, 90],
]);
const behavior = makeBehavior(f, 'Logic', ['ran', 'at', 'left', 'length']);
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 update = edit.addNode('OnUpdate');
const playing = edit.addNode('AnimationPlaying', { animation: named ? walk.id : '' });
const length = edit.addNode('AnimationLength', { animation: walk.id });
const note = edit.variables(['at', 'left', 'length'], { write: true });
const branch = edit.addNode('Branch');
const count = edit.variables(['ran'], { write: true });
const add = edit.addNode('Add', { b: 1 });
edit.wire([
[start, 'exec', play, 'exec'],
[owner, 'object', play, 'object'],
[owner, 'object', playing, 'object'],
[play, 'exec', note, 'exec'],
[update, 'exec', note, 'exec'],
[playing, 'progress', note, 'at'],
[playing, 'timeLeft', note, 'left'],
[length, 'seconds', note, 'length'],
[note, 'exec', branch, 'exec'],
[playing, 'playing', branch, 'condition'],
[branch, 'then', count, 'exec'],
[count, 'ran', add, 'a'],
[add, 'result', count, 'ran'],
]);
const { world, frame } = startWorld(f, { animations: true, behaviors: true });
for (let i = 0; i < frames; i++) frame(0.1);
return world.entities.find((e) => e.type === 'behavior')!.props;
}
it('answers in a graph: true in the very pulse that plays it, false once a once pass ended', () => {
// The start plays it and asks at once; the pass ends in frame 11, before the behaviors run.
expect(watching('once', 1).ran).toBe(2);
const midway = watching('once', 6);
expect(midway.ran).toBe(7);
expect(midway.at).toBeCloseTo(0.5, 4);
expect(midway.left).toBeCloseTo(0.5, 4);
expect(midway.length).toBe(1);
const over = watching('once', 20);
expect(over).toMatchObject({ ran: 11, at: 0, left: 0 });
});
it('answers for whatever runs on the object when the node names no animation', () => {
expect(watching('loop', 6, false)).toMatchObject({ ran: 7 });
});
});
describe('a row on z, the draw order', () => {
/** The sprite placed twice: `box` at z 0 plays a row moving z 0 → 10, `wall` stands at z 5. */
function climb() {
const f = makeSpriteScene();
const box = place(f, f.spriteId, { x: 0, y: 0, z: 0 });
const wall = place(f, f.spriteId, { x: 0, y: 0, z: 5 });
const walk = addAnimation(f, f.spriteId, { fps: 10 });
addRow(f, walk, 'z', [
[0, 0],
[10, 10],
]);
const started = startWorld(f, { animations: true });
const order = () =>
drawSlots(collectWorldItems(started.world).items)
.map((slot) => (slot.id === box ? 'box' : slot.id === wall ? 'wall' : ''))
.filter(Boolean);
return { f, walk, box, sprite: started.world.findByObjectId(box)!, order, ...started };
}
it('is offered after X and Y on a placed asset, and not on a behavior', () => {
expect(animatableDefs('sprite').slice(0, 3)).toMatchObject([
{ key: 'x', label: 'X' },
{ key: 'y', label: 'Y' },
{ key: 'z', label: 'Z', kind: 'number' },
]);
expect(animatableDefs('text').some((d) => d.key === 'z')).toBe(true);
expect(animatableDefs('behavior').some((d) => d.key === 'z')).toBe(false);
expect(isPositionRow('sprite', 'z')).toBe(true);
expect(isPositionRow('scene', 'z')).toBe(false);
});
it('moves the object behind and in front of another one while the game runs', () => {
const { walk, sprite, frame, order } = climb();
expect(order()).toEqual(['box', 'wall']);
playAnimation(sprite, walk.id);
frame(0.05);
frame(0.3);
expect(sprite.z).toBeCloseTo(3);
expect(order()).toEqual(['box', 'wall']);
frame(0.4);
expect(sprite.z).toBeCloseTo(7);
expect(order()).toEqual(['wall', 'box']);
});
it('writes z into the scene as the preview shows it', () => {
const { f, walk, box, sprite, world, frame } = climb();
playAnimation(sprite, walk.id);
frame(0.05);
frame(0.45);
world.syncToModel();
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.z).toBeCloseTo(4.5);
expect(root.position.z).toBeCloseTo(4.5);
expect(f.sceneOf().objects.find((o) => o.id === box)!.position.z).toBeCloseTo(4.5);
});
});