277 lines
12 KiB
TypeScript
277 lines
12 KiB
TypeScript
/**
|
||
* 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']);
|
||
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 }]);
|
||
});
|
||
});
|