From 89c46c30a0e2746e2272fbd535d9706d5a89a01d Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Wed, 16 Sep 2026 21:00:18 +0200 Subject: [PATCH] An animation is previewed above its timeline: the root at the playhead, drawn but never written (#99) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../graph/nodes/events/OnAnimationFinished.ts | 2 +- src/core/model/animation.ts | 17 ++ src/core/model/animationPreview.ts | 125 ++++++++++++ src/editor/animation/AnimationEditor.ts | 27 ++- src/editor/animation/PreviewCanvas.ts | 140 +++++++++++++ src/editor/animation/TimelineCanvas.ts | 6 + src/editor/debugHook.ts | 15 +- src/runtime/systems/AnimationSystem.ts | 9 +- tests/core/animation-preview.test.ts | 185 ++++++++++++++++++ 9 files changed, 513 insertions(+), 13 deletions(-) create mode 100644 src/core/model/animationPreview.ts create mode 100644 src/editor/animation/PreviewCanvas.ts create mode 100644 tests/core/animation-preview.test.ts 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); + }); +});