Files
feuerwerk/tests/core/animation-preview.test.ts
T
adminandClaude Fable 5.1 89c46c30a0
game-maker / gate (push) Successful in 1m47s
game-maker / suite (push) Failing after 4m55s
An animation is previewed above its timeline: the root at the playhead, drawn but never written (#99)
The Animation tab shows the root asset over the timeline, with every row's value at the
playhead written onto a copy of it – the sprite, text or container placed at the origin as a
scene would place it, a scene as itself. Rows reach the root through that placed object and its
children through the object's instance edits, the route a placed container's content takes,
so the ids match what the runtime gives them. The runtime player and the preview evaluate a row
through one function (trackWriteAt), relative rows included; past the end of a once-reset pass
the preview shows the starting values, as the player would. Play, pause and the ruler drive the
preview through the timeline's playhead; a drag pans, the wheel zooms, Centre frames it. The
document is never touched and undo never hears of scrubbing.

On animation finished no longer describes itself as hearing "a sprite of the owner".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:00:18 +02:00

186 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { CreateContainerCommand } from '../../src/core/commands/containerCommands';
import { RemoveSceneObjectsCommand } from '../../src/core/commands/sceneObjectCommands';
import { PREVIEW_ROOT_ID, 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(PREVIEW_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(PREVIEW_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(PREVIEW_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([[PREVIEW_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(PREVIEW_ROOT_ID)!.position.x).toBe(90);
expect(drawn(f, current(f, reset.id), 10).get(PREVIEW_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(PREVIEW_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(PREVIEW_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(PREVIEW_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(PREVIEW_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(PREVIEW_ROOT_ID, arm!))).toBe(false);
expect(objects.get(instanceChildId(PREVIEW_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);
});
});