An animation is previewed above its timeline: the root at the playhead, drawn but never written (#99)
game-maker / gate (push) Successful in 1m47s
game-maker / suite (push) Failing after 4m55s

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>
This commit is contained in:
adminandClaude Fable 5.1 committed 2026-09-16 21:00:18 +02:00
1 parent 4ed2edd0f0
commit 89c46c30a0
9 files changed
+513 -13

No files matched your search

+185
View File
@@ -0,0 +1,185 @@
/**
* 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);
});
});