Files
feuerwerk/tests/core/animation-preview.test.ts

277 lines
12 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.
/**
* The Animation tab's preview (#99): the root with every row's value at the playhead written over
* a copy of it, drawn like a scene and never written back.
*/
import { describe, expect, it } from 'vitest';
import {
AddAnimationTrackCommand,
SetAnimationKeyCommand,
SetAnimationRootCommand,
SetTrackRelativeCommand,
} from '../../src/core/commands/animationCommands';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { CommandError } from '../../src/core/commands/Command';
import {
animatableDefs,
animationTargets,
PLACED_ROOT_ID,
targetAssetOf,
trackLabel,
} from '../../src/core/model/animation';
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
import { RemoveSceneObjectsCommand } from '../../src/core/commands/sceneObjectCommands';
import { previewHolder, previewObjectId } from '../../src/core/model/animationPreview';
import type { AnimationAsset, AnimationKey, ContainerAsset } from '../../src/core/model/Asset';
import { instanceChildId, resolveObjects } from '../../src/core/model/instance';
import { findAsset, findAssetOf } from '../../src/core/model/Project';
import { resolveSceneObjectProps } from '../../src/core/properties/resolve';
import { collectHolderItems } from '../../src/viewport/items';
import { makeSpriteScene, place, type SceneFixture } from '../helpers';
function addAnimation(f: SceneFixture, root: string, props = {}): AnimationAsset {
const create = new CreateAssetCommand('animation', 'Walk', props);
f.stack.execute(create);
f.stack.execute(new SetAnimationRootCommand(create.asset.id, root));
return findAssetOf(f.project, create.asset.id, 'animation')!;
}
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 current = (f: SceneFixture, id: string) => findAssetOf(f.project, id, 'animation')!;
/** The objects the preview would draw, by id – resolved like the viewport resolves them. */
function drawn(f: SceneFixture, animation: AnimationAsset, frame: number) {
const holder = previewHolder(f.project, animation, frame)!;
return new Map(resolveObjects(f.project, holder).map((o) => [o.id, o]));
}
describe('the preview of an animation over a sprite', () => {
it('places the root at the origin and writes a row of the root onto that object', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
addRow(f, walk, 'x', [
[0, 0],
[10, 100],
]);
const root = drawn(f, current(f, walk.id), 5).get(PLACED_ROOT_ID)!;
expect(root.assetId).toBe(f.spriteId);
expect(root.position).toEqual({ x: 50, y: 0, z: 0 });
expect(previewObjectId(findAsset(f.project, f.spriteId), '')).toBe(PLACED_ROOT_ID);
});
it('holds the first value before the first key and the last one after the last', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
addRow(f, walk, 'rotation', [
[2, 10],
[4, 30],
]);
const at = (frame: number) =>
drawn(f, current(f, walk.id), frame).get(PLACED_ROOT_ID)!.overrides.rotation;
expect(at(0)).toBe(10);
expect(at(3)).toBe(20);
expect(at(9)).toBe(30);
});
it('draws the values through the viewport like any placed object', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
addRow(f, walk, 'y', [[0, 40]]);
const holder = previewHolder(f.project, current(f, walk.id), 0);
const { items } = collectHolderItems(f.project, holder, 0);
expect(items.map((i) => [i.id, i.type, i.y])).toEqual([[PLACED_ROOT_ID, 'sprite', 40]]);
});
it('never writes into the document', () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.spriteId);
addRow(f, walk, 'x', [[0, 77]]);
const before = JSON.stringify(f.project);
previewHolder(f.project, current(f, walk.id), 0);
expect(JSON.stringify(f.project)).toBe(before);
});
it('shows the starting values once a once-reset pass is over, and the last ones for once', () => {
const f = makeSpriteScene();
const reset = addAnimation(f, f.spriteId, { playback: 'once-reset' });
addRow(f, reset, 'x', [
[0, 0],
[9, 90],
]);
expect(drawn(f, current(f, reset.id), 9).get(PLACED_ROOT_ID)!.position.x).toBe(90);
expect(drawn(f, current(f, reset.id), 10).get(PLACED_ROOT_ID)!.position.x).toBe(0);
const hold = addAnimation(f, f.spriteId, { playback: 'once' });
addRow(f, hold, 'x', [
[0, 0],
[9, 90],
]);
expect(drawn(f, current(f, hold.id), 10).get(PLACED_ROOT_ID)!.position.x).toBe(90);
});
});
describe('the preview of an animation over a container', () => {
/** A container packed around one placed sprite; `at` is where the template keeps it. */
function withChild() {
const f = makeSpriteScene();
const inner = place(f, f.spriteId, { x: 10, y: 20 });
const wrap = new CreateContainerCommand(f.sceneId, [inner]);
f.stack.execute(wrap);
const container = findAsset(f.project, wrap.assetId) as ContainerAsset;
const template = container.objects[0]!;
return { f, container, arm: template.id, at: { ...template.position } };
}
it('reaches a child through the placed root, under the id a placed container gives it', () => {
const { f, container, arm, at } = withChild();
const walk = addAnimation(f, container.id);
addRow(f, walk, 'x', [[0, 55]], arm);
addRow(f, walk, 'rotation', [[0, 45]]);
const objects = drawn(f, current(f, walk.id), 0);
const childId = instanceChildId(PLACED_ROOT_ID, arm);
expect(previewObjectId(findAsset(f.project, container.id), arm)).toBe(childId);
expect(objects.get(childId)!.position).toEqual({ ...at, x: 55 });
expect(objects.get(PLACED_ROOT_ID)!.overrides.rotation).toBe(45);
});
it('adds a relative row to what the child holds in the template', () => {
const { f, container, arm, at } = withChild();
const walk = addAnimation(f, container.id);
const row = addRow(f, walk, 'x', [[0, 5]], arm);
f.stack.execute(new SetTrackRelativeCommand(walk.id, row, true));
const child = drawn(f, current(f, walk.id), 0).get(instanceChildId(PLACED_ROOT_ID, arm))!;
expect(child.position.x).toBe(at.x + 5);
});
it('skips a row whose target is gone, and keeps the rest', () => {
const f = makeSpriteScene();
const wrap = new CreateContainerCommand(f.sceneId, [
place(f, f.spriteId, { x: 10, y: 20 }),
place(f, f.spriteId, { x: 0, y: 0 }),
]);
f.stack.execute(wrap);
const [arm, other] = (findAsset(f.project, wrap.assetId) as ContainerAsset).objects.map((o) => o.id);
const walk = addAnimation(f, wrap.assetId);
addRow(f, walk, 'x', [[0, 55]], arm);
addRow(f, walk, 'x', [[0, 66]], other);
// The first template object goes: its row stays and aims at nothing.
f.stack.execute(new RemoveSceneObjectsCommand(wrap.assetId, [arm!]));
const objects = drawn(f, current(f, walk.id), 0);
expect(objects.has(instanceChildId(PLACED_ROOT_ID, arm!))).toBe(false);
expect(objects.get(instanceChildId(PLACED_ROOT_ID, other!))!.position.x).toBe(66);
});
});
describe('the preview of an animation over a scene', () => {
it('is the scene itself, with the rows written onto copies of its objects', () => {
const f = makeSpriteScene();
const box = place(f, f.spriteId, { x: 1, y: 2 });
const walk = addAnimation(f, f.sceneId);
addRow(f, walk, 'y', [[0, 200]], box);
addRow(f, walk, 'color', [[0, 0xff0000]], box);
const holder = previewHolder(f.project, current(f, walk.id), 0)!;
expect(holder.id).toBe(f.sceneId);
expect(previewObjectId(findAsset(f.project, f.sceneId), box)).toBe(box);
const object = holder.objects.find((o) => o.id === box)!;
expect(object.position.y).toBe(200);
expect(resolveSceneObjectProps(f.project, findAsset(f.project, f.spriteId)!, object).color).toBe(
0xff0000,
);
expect(f.sceneOf().objects.find((o) => o.id === box)!.position.y).toBe(2);
});
});
/** 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: 10, y: 20 });
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, inner, outer, placing, armTemplate, path: `${placing.id}/${armTemplate.id}` };
}
describe('the reach of a row (#109)', () => {
it('offers what a container placed inside the root holds, as a path of template ids', () => {
const { f, outer, placing, path } = nested();
const targets = animationTargets(f.project, findAsset(f.project, outer.assetId));
expect(targets.map((t) => [t.id, t.depth])).toEqual([
[placing.id, 0],
[path, 1],
]);
});
it('offers a scene the content of its placed containers under the ids the running scene uses', () => {
const { f, outer, placing, path } = nested();
const targets = animationTargets(f.project, findAsset(f.project, f.sceneId));
expect(targets.map((t) => t.id)).toEqual([
outer.objectId,
instanceChildId(outer.objectId, placing.id),
instanceChildId(outer.objectId, path),
]);
});
it('draws a row aimed into a nested container on the child, through the instance edits of a copy', () => {
const { f, outer, path, armTemplate } = nested();
const walk = addAnimation(f, outer.assetId);
addRow(f, walk, 'x', [[0, 55]], path);
const before = JSON.stringify(f.project);
const child = drawn(f, current(f, walk.id), 0).get(instanceChildId(PLACED_ROOT_ID, path))!;
expect(child.position).toEqual({ ...armTemplate.position, x: 55 });
expect(JSON.stringify(f.project)).toBe(before);
});
it('draws a scene row aimed into a placed container without writing the scene', () => {
const { f, outer, path } = nested();
const id = instanceChildId(outer.objectId, path);
const walk = addAnimation(f, f.sceneId);
addRow(f, walk, 'y', [[0, 300]], id);
const before = JSON.stringify(f.project);
const holder = previewHolder(f.project, current(f, walk.id), 0)!;
expect(resolveObjects(f.project, holder).find((o) => o.id === id)!.position.y).toBe(300);
expect(JSON.stringify(f.project)).toBe(before);
});
it("keys the scene's own properties on the scene itself", () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.sceneId);
expect(animatableDefs('scene').map((d) => d.key)).toEqual(['backgroundColor', 'gravityX', 'gravityY']);
expect(() => f.stack.execute(new AddAnimationTrackCommand(walk.id, '', 'x'))).toThrow(CommandError);
addRow(f, walk, 'backgroundColor', [
[0, 0x000000],
[10, 0xff0000],
]);
const before = f.sceneOf().props.backgroundColor;
const holder = previewHolder(f.project, current(f, walk.id), 5)!;
expect(holder.props.backgroundColor).toBe(0x800000);
expect(collectHolderItems(f.project, holder, 0).background).toBe(0x800000);
expect(f.sceneOf().props.backgroundColor).toBe(before);
});
it('reads a row as missing once the template loses its target, and keeps its keys', () => {
const { f, outer, inner, path, armTemplate } = nested();
const walk = addAnimation(f, outer.assetId);
const row = addRow(f, walk, 'x', [[0, 55]], path);
f.stack.execute(new RemoveSceneObjectsCommand(inner.assetId, [armTemplate.id]));
const now = current(f, walk.id);
const track = now.tracks.find((t) => t.id === row)!;
expect(targetAssetOf(f.project, now, path)).toBeUndefined();
expect(trackLabel(f.project, now, track)).toBe('(missing) · x');
expect(track.keys).toEqual([{ frame: 0, value: 55 }]);
});
});