/** * 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 }]); }); });