A double-click into a timeline row without keys set the property's
default, while the field in the row header showed the value the target
actually holds. A sprite standing on x 40 got a key with 0, so the
first key moved it. The double-click now keys the value the row shows.
- trackHeld in src/core/model/animation.ts says what a row without keys
holds. The field in the row header and SetAnimationKeyCommand both
ask it, so they cannot drift apart again.
- A relative row still gets 0, a row with keys still takes the value of
its curve at that frame, and a row whose target is gone still only
gives a notice and no key.
- README and the wiki page Development name 2047 checks.
Tests:
- Unit animation: 87 green, 1 new. A sprite on x 40 with the override
scale 2 gets keys with 40 and 2. Red with the old def.default.
- e2e 25 on melektaus-atx: ALL PASSED, 35 checks, 25i-ii new. The arm
stands on x 40 and a double-click on frame 3 keys { frame: 3,
value: 40 }.
- Gate on melektaus-atx: green, 304 files, 4703 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1801 lines
71 KiB
TypeScript
1801 lines
71 KiB
TypeScript
/**
|
||
* 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 { 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('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('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);
|
||
});
|
||
});
|