diff --git a/src/core/graph/nodes/events/OnAnimationFinished.ts b/src/core/graph/nodes/events/OnAnimationFinished.ts index b65987a2..28309975 100644 --- a/src/core/graph/nodes/events/OnAnimationFinished.ts +++ b/src/core/graph/nodes/events/OnAnimationFinished.ts @@ -7,7 +7,7 @@ export const OnAnimationFinished: NodeDef = { label: 'On animation finished', category: 'event', description: - 'Runs once when a sprite of the owner reaches the end of an animation that plays once. Leave Animation at None to hear every animation.', + 'Runs once when an animation played once on the owner, or on an object below it, reaches its end. Leave Animation at None to hear every animation.', inputs: [], outputs: [exec(), obj('object')], params: [{ key: 'animation', label: 'Animation', kind: 'asset', default: '', assetTypes: ['animation'] }], diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 25864f46..f754f01e 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -132,6 +132,23 @@ export function trackValueAt( return keys[keys.length - 1]!.value; } +/** + * What a row writes at a frame: its value, or for a relative number row that value added to `base`, + * what the target held when the animation started. Shared by the runtime player and the tab's + * preview, so both show the same picture. `undefined` for a row without keys. + */ +export function trackWriteAt( + track: AnimationTrack, + kind: string, + frame: number, + base: PropertyValue | undefined, +): PropertyValue | undefined { + const value = trackValueAt(track.keys, kind, frame); + if (value === undefined) return undefined; + const offset = track.relative && kind === 'number' && typeof base === 'number'; + return offset ? base + Number(value) : value; +} + /** The kind a row's property has on an asset of that type; undefined for one the timeline cannot animate. */ export function animatedKind(type: AssetType, key: string): string | undefined { if (isPositionKey(key)) return 'number'; diff --git a/src/core/model/animationPreview.ts b/src/core/model/animationPreview.ts new file mode 100644 index 00000000..9f3bcefc --- /dev/null +++ b/src/core/model/animationPreview.ts @@ -0,0 +1,125 @@ +/** + * What the Animation tab shows above the timeline (#99): the root asset with every row's value at + * the playhead written over it. Nothing here touches the document – the holder returned is built + * fresh on every call from copies, and the preview draws it the way the Scene tab draws a scene. + */ +import { animatedKind, frameCount, isPositionKey, playbackOf, rootObjects, trackWriteAt } from './animation'; +import type { AnimationAsset, AnimationTrack, Asset } from './Asset'; +import { type InstanceEdit, instanceChildId } from './instance'; +import { findAsset, type ObjectHolder, type Project } from './Project'; +import type { PropertyValue } from './Properties'; +import { resolveSceneObjectProps } from '../properties/resolve'; +import type { SceneObject } from './SceneObject'; + +/** The one object the preview places the root on – what a row aimed at the root itself writes to. */ +export const PREVIEW_ROOT_ID = 'preview-root'; + +/** + * The holder the preview draws at `frame`: the scene itself for a scene root, otherwise a holder + * of one object placing the root at the origin, its content resolved from the template like any + * placed container's. Rows reach the root through that object and its children through the + * object's instance edits – the same route a placed container's content takes. `undefined` + * without a root. Past the end of a `once-reset` pass nothing is written: the player has put the + * starting values back by then. + */ +export function previewHolder( + project: Project, + animation: AnimationAsset, + frame: number, +): ObjectHolder | undefined { + const root = findAsset(project, animation.root); + if (!root) return undefined; + const reset = playbackOf(animation) === 'once-reset' && frame >= frameCount(animation); + const tracks = reset ? [] : animation.tracks; + return root.type === 'scene' + ? sceneAt(project, tracks, root, frame) + : placedAt(project, tracks, root, frame); +} + +/** The id the preview draws a row's target under – for a check that wants to look at it. */ +export function previewObjectId(root: Asset | undefined, target: string): string { + if (root?.type === 'scene') return target; + return target === '' ? PREVIEW_ROOT_ID : instanceChildId(PREVIEW_ROOT_ID, target); +} + +function sceneAt( + project: Project, + tracks: readonly AnimationTrack[], + root: ObjectHolder, + frame: number, +): ObjectHolder { + const objects = root.objects.map((o) => ({ + ...o, + position: { ...o.position }, + overrides: { ...o.overrides }, + })); + const byId = new Map(objects.map((o) => [o.id, o])); + for (const track of tracks) { + const object = byId.get(track.target); + const asset = object && findAsset(project, object.assetId); + const kind = asset && animatedKind(asset.type, track.property); + if (!object || !asset || !kind) continue; + const base = isPositionKey(track.property) + ? object.position[track.property] + : resolveSceneObjectProps(project, asset, object)[track.property]; + const value = trackWriteAt(track, kind, frame, base); + if (value === undefined) continue; + if (isPositionKey(track.property)) object.position[track.property] = Number(value); + else object.overrides[track.property] = value; + } + return { ...root, objects }; +} + +function placedAt( + project: Project, + tracks: readonly AnimationTrack[], + root: Asset, + frame: number, +): ObjectHolder { + const placed: SceneObject = { + id: PREVIEW_ROOT_ID, + assetId: root.id, + name: root.name, + position: { x: 0, y: 0, z: 0 }, + editorOffset: { x: 0, y: 0 }, + overrides: {}, + parentId: '', + instanceEdits: {}, + }; + const templates = rootObjects(root); + for (const track of tracks) { + const template = track.target === '' ? undefined : templates.find((o) => o.id === track.target); + const aimed = track.target === '' ? root : template && findAsset(project, template.assetId); + const kind = aimed && animatedKind(aimed.type, track.property); + if (!aimed || !kind) continue; + const base = template + ? baseOf(project, aimed, template, track.property) + : baseOf(project, aimed, placed, track.property); + const value = trackWriteAt(track, kind, frame, base); + if (value === undefined) continue; + if (!template) writeTo(placed, track.property, value); + else + writeEdit( + (placed.instanceEdits[template.id] ??= { position: {}, overrides: {} }), + track.property, + value, + ); + } + return { id: PREVIEW_ROOT_ID, type: 'container', name: root.name, props: {}, objects: [placed] }; +} + +/** What the target holds before the animation writes it: the zero of a relative row. */ +function baseOf(project: Project, asset: Asset, object: SceneObject, key: string): PropertyValue | undefined { + if (isPositionKey(key)) return object.position[key]; + return resolveSceneObjectProps(project, asset, object)[key]; +} + +function writeTo(object: SceneObject, key: string, value: PropertyValue): void { + if (isPositionKey(key)) object.position[key] = Number(value); + else object.overrides[key] = value; +} + +function writeEdit(edit: InstanceEdit, key: string, value: PropertyValue): void { + if (isPositionKey(key)) edit.position[key] = Number(value); + else edit.overrides[key] = value; +} diff --git a/src/editor/animation/AnimationEditor.ts b/src/editor/animation/AnimationEditor.ts index eb525be2..3e0f66b1 100644 --- a/src/editor/animation/AnimationEditor.ts +++ b/src/editor/animation/AnimationEditor.ts @@ -2,9 +2,10 @@ import { SetAnimationRootCommand } from '../../core/commands/animationCommands'; import { ANIMATION_ROOT_TYPES, animationDuration, fpsOf, frameCount } from '../../core/model/animation'; import type { AnimationAsset, Asset } from '../../core/model/Asset'; import { findAsset } from '../../core/model/Project'; -import { Button, Dropdown, formatNumber, Menu, type MenuItem } from '../../ui'; +import { Button, Dropdown, formatNumber, Menu, type MenuItem, VStack, type Widget } from '../../ui'; import { AssetTabEditor } from '../AssetTabEditor'; import type { EditorState } from '../state'; +import { PreviewCanvas } from './PreviewCanvas'; import { openRowDialog, RowDialog } from './RowDialog'; import { TimelineCanvas } from './TimelineCanvas'; @@ -13,22 +14,36 @@ const INFO_WIDTH = 220; const ROOT_WIDTH = 160; /** - * The Animation tab (#97): the header picks what the animation animates and adds rows, the - * canvas below is the timeline. `fps` and `playback` are properties of the asset, edited in the - * inspector like any other. + * The Animation tab (#97): the header picks what the animation animates and adds rows, below it + * the preview shows the root at the playhead (#99) and the timeline under that edits the rows. + * `fps` and `playback` are properties of the asset, edited in the inspector like any other. */ export class AnimationEditor extends AssetTabEditor { /** Public for the check scripts. */ readonly rootPicker: Dropdown; readonly addRowButton: Button; readonly playButton: Button; + readonly centreButton: Button; + readonly preview: PreviewCanvas; constructor(state: EditorState) { super(state, { noun: 'Animation', infoWidth: INFO_WIDTH }); this.rootPicker = new Dropdown('—', { width: ROOT_WIDTH, onClick: () => this.openRootPicker() }); this.addRowButton = new Button('+ Row', { onClick: () => this.openRowDialog() }); this.playButton = new Button('Play', { width: 64, onClick: () => this.togglePlay() }); - this.mount(new TimelineCanvas(state), [this.rootPicker, this.addRowButton, this.playButton]); + this.centreButton = new Button('Centre', { onClick: () => this.preview.fit() }); + const timeline = new TimelineCanvas(state); + this.preview = new PreviewCanvas(state, () => timeline.playhead); + timeline.onPlayhead = () => this.preview.redraw(); + this.mount(timeline, [this.rootPicker, this.addRowButton, this.playButton, this.centreButton]); + } + + /** The preview above, the timeline below, each taking half. */ + protected override body(canvas: TimelineCanvas): Widget { + const stack = new VStack(); + stack.add(this.preview, 1); + stack.add(canvas, 1); + return stack; } /** Only called while the tab is visible. */ @@ -62,6 +77,8 @@ export class AnimationEditor extends AssetTabEditor number, + ) { + super(state); + this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e)); + this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e)); + this.on('pointerup', () => (this.panning = null)); + this.on('pointerupoutside', () => (this.panning = null)); + this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e)); + state.on('load', () => this.show('')); + } + + show(assetId: string): void { + if (this.assetId === assetId) return; + this.assetId = assetId; + this.fitPending = true; + this.redraw(); + } + + /** Frames the content; the origin is always included, since the root is placed there. */ + fit(): void { + const { items } = this.collectFrame(); + const boxes = items.map((item) => { + const e = footprintExtent(item.type, item.props, item.m); + return { id: item.id, x: e.left, y: e.top, w: e.right - e.left, h: e.bottom - e.top }; + }); + const { minX, minY, maxX, maxY } = boundsOf(boxes, { minX: 0, minY: 0, maxX: 0, maxY: 0 })!; + const width = Math.max(maxX - minX, 1); + const height = Math.max(maxY - minY, 1); + this.view = { + x: (minX + maxX) / 2, + y: (minY + maxY) / 2, + zoom: clamp( + Math.min((this.w - FIT_MARGIN * 2) / width, (this.h - FIT_MARGIN * 2) / height), + MIN_ZOOM, + MAX_ZOOM, + ), + }; + this.fitPending = false; + this.redraw(); + } + + /** The values as drawn, for a check: an object's position and property bag at the playhead. */ + itemDrawn(objectId: string): { x: number; y: number; props: Record } | null { + const item = this.lastItems.find((i) => i.id === objectId); + return item ? { x: item.x, y: item.y, props: item.props } : null; + } + + /** Skipped while the tab is hidden, like the Game tab: a frame costs a collect of the root. */ + override redraw(): void { + if (!this.visible) return; + if (this.fitPending && this.w > 0 && this.h > 0) { + this.fit(); + return; + } + super.redraw(); + } + + protected override currentView(): View { + return freeView(this.view.x, this.view.y, this.view.zoom, this.w, this.h); + } + + protected override collectFrame(): Frame { + const asset = this.assetId ? findAssetOf(this.state.project, this.assetId, 'animation') : undefined; + const holder = asset && previewHolder(this.state.project, asset, this.playhead()); + return collectHolderItems(this.state.project, holder, Theme.colors.viewport); + } + + protected override get baseColor(): number { + return Theme.colors.viewport; + } + + private asset(): AnimationAsset | undefined { + return this.assetId ? findAssetOf(this.state.project, this.assetId, 'animation') : undefined; + } + + /** A scene root paints its own background; anything placed on the preview's holder has none. */ + protected override paintsBackground(): boolean { + const asset = this.asset(); + return findAssetOf(this.state.project, asset?.root ?? '', 'scene') !== undefined; + } + + private onPointerDown(e: FederatedPointerEvent): void { + if (e.button !== 0 && e.button !== 1) return; + this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y }; + e.stopPropagation(); + } + + private onPointerMove(e: FederatedPointerEvent): void { + if (!this.panning) return; + const { zoom } = this.view; + this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / zoom; + this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / zoom; + this.redraw(); + } + + private onWheel(e: FederatedWheelEvent): void { + const local = this.toLocal(e.global); + const before = screenToWorld(this.currentView(), local.x, local.y); + this.view.zoom = clamp(this.view.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM); + const after = screenToWorld(this.currentView(), local.x, local.y); + this.view.x += before.x - after.x; + this.view.y += before.y - after.y; + this.redraw(); + e.preventDefault(); + } +} diff --git a/src/editor/animation/TimelineCanvas.ts b/src/editor/animation/TimelineCanvas.ts index 70ac1a5c..360a9f11 100644 --- a/src/editor/animation/TimelineCanvas.ts +++ b/src/editor/animation/TimelineCanvas.ts @@ -86,6 +86,8 @@ export class TimelineCanvas extends Widget { private dragging: KeyDrag | null = null; private band: Band | null = null; private scrubbing = false; + /** Told whenever the playhead moves – the preview above draws the frame it stands on. */ + onPlayhead: () => void = () => undefined; constructor(private readonly state: EditorState) { super(); @@ -146,6 +148,7 @@ export class TimelineCanvas extends Widget { this.time = this.timeOf(asset, this.playhead); this.playingNow = true; this.redraw(); + this.onPlayhead(); } pause(): void { @@ -158,6 +161,7 @@ export class TimelineCanvas extends Widget { this.playingNow = false; this.playhead = 0; this.time = 0; + this.onPlayhead(); } /** Moves the playhead `dt` seconds on; a finished `once` pass pauses at the end. */ @@ -169,6 +173,7 @@ export class TimelineCanvas extends Widget { this.playhead = at.frame; if (at.finished) this.playingNow = false; this.redraw(); + this.onPlayhead(); } /** Puts the playhead on a frame – what the ruler does, and what a check does without a pointer. */ @@ -177,6 +182,7 @@ export class TimelineCanvas extends Widget { const asset = this.asset(); if (asset) this.time = this.timeOf(asset, this.playhead); this.redraw(); + this.onPlayhead(); } /** The seconds at which a loop's playhead first reaches `frame` – so play resumes where it was scrubbed to. */ diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 80a957b8..38d9fcc4 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -1,7 +1,8 @@ import { type Application, Color, type ColorSource, type Container, Mesh, Rectangle, Text } from 'pixi.js'; import { NameTransitionCommand } from '../core/commands/graphCommands'; +import { previewObjectId } from '../core/model/animationPreview'; import { resolveObjects } from '../core/model/instance'; -import { findObjectHolder } from '../core/model/Project'; +import { findAsset, findAssetOf, findObjectHolder } from '../core/model/Project'; import type { FrameRect } from '../core/model/texture'; import { effectiveScale, gameClock } from '../core/time'; import { @@ -481,6 +482,18 @@ export function installDebugHook(app: Application, state: EditorState, editor: E /** The open row dialog: what it offers and holds, and a way to pick without clicking through menus. */ rowDialog: () => currentRowDialog()?.debugInfo() ?? null, rowDialogPick: (target: string, property: string) => currentRowDialog()?.pick(target, property), + /** The preview above the timeline (#99): where a row's target is drawn and with which values. */ + preview: { + /** The id the preview draws a row's target under (`''` = the root itself). */ + objectId: (target: string) => { + const asset = findAssetOf(state.project, editor.animationEditor.currentAssetId, 'animation'); + return previewObjectId(asset && findAsset(state.project, asset.root), target); + }, + rect: (objectId: string) => editor.animationEditor.preview.objectRect(objectId), + item: (objectId: string) => editor.animationEditor.preview.itemDrawn(objectId), + info: () => editor.animationEditor.preview.debugInfo(), + centreAt: () => centreOf(editor.animationEditor.centreButton), + }, }, container: { current: () => containerEditor.currentAssetId, diff --git a/src/runtime/systems/AnimationSystem.ts b/src/runtime/systems/AnimationSystem.ts index dfcb432a..0d45b9ad 100644 --- a/src/runtime/systems/AnimationSystem.ts +++ b/src/runtime/systems/AnimationSystem.ts @@ -5,7 +5,7 @@ import { animationPlayheadAt, isPositionKey, playbackOf, - trackValueAt, + trackWriteAt, } from '../../core/model/animation'; import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset'; import { instanceChildId, splitInstanceId } from '../../core/model/instance'; @@ -57,11 +57,8 @@ export class AnimationSystem implements System { } else if (player.playing && isHandleEnabled(entity)) player.time += dt; const head = animationPlayheadAt(asset, player.time); for (const b of bindings) { - const value = trackValueAt(b.track.keys, b.kind, head.frame); - if (value === undefined) continue; - const base = player.bases[b.base]; - const offset = b.track.relative && b.kind === 'number' && typeof base === 'number'; - write(b.entity, b.track.property, offset ? base + Number(value) : value); + const value = trackWriteAt(b.track, b.kind, head.frame, player.bases[b.base]); + if (value !== undefined) write(b.entity, b.track.property, value); } if (!head.finished) continue; if (playbackOf(asset) === 'once-reset') diff --git a/tests/core/animation-preview.test.ts b/tests/core/animation-preview.test.ts new file mode 100644 index 00000000..2de5b689 --- /dev/null +++ b/tests/core/animation-preview.test.ts @@ -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); + }); +});