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

+22 -5
View File
@@ -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<AnimationAsset, TimelineCanvas> {
/** 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<AnimationAsset, TimelineCanv
protected override syncExtras(asset: AnimationAsset | null): void {
const root = asset ? findAsset(this.state.project, asset.root) : undefined;
this.preview.show(asset?.id ?? '');
this.centreButton.setEnabled(Boolean(root));
this.rootPicker.setText(root ? root.name : asset ? 'Animates…' : '—');
this.rootPicker.setEnabled(asset !== null);
// A row needs something to aim at.
+140
View File
@@ -0,0 +1,140 @@
import type { FederatedPointerEvent, FederatedWheelEvent } from 'pixi.js';
import { previewHolder } from '../../core/model/animationPreview';
import type { AnimationAsset } from '../../core/model/Asset';
import { findAssetOf } from '../../core/model/Project';
import { footprintExtent } from '../../core/properties/bounds';
import { boundsOf } from '../framing';
import type { EditorState } from '../state';
import { clamp, Theme, zoomFactor } from '../../ui';
import { collectHolderItems } from '../../viewport/items';
import { freeView, screenToWorld, type View } from '../../viewport/view';
import { type Frame, WorldViewport } from '../viewport/WorldViewport';
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 8;
/** Breathing room left around the content by `fit()`, in screen pixels. */
const FIT_MARGIN = 40;
/**
* The picture above the timeline (#99): the animation's root with every row's value at the
* playhead written over it, drawn like the Scene tab draws a scene but never edited – a drag pans,
* the wheel zooms, nothing is picked. What it draws comes from `previewHolder`, so the document
* stays untouched and undo never hears of it.
*/
export class PreviewCanvas extends WorldViewport {
/** World point at the viewport centre, and zoom. */
private view = { x: 0, y: 0, zoom: 1 };
private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null;
private assetId = '';
/** Frames the content on the first draw with a size – a fresh subject gets a whole view. */
private fitPending = false;
constructor(
state: EditorState,
/** Where the timeline's playhead stands, in frames. */
private readonly playhead: () => 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<string, unknown> } | 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();
}
}
+6
View File
@@ -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. */
+14 -1
View File
@@ -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,