An animation is a timeline of rows over one asset, played on an object rather than worn as a picture (#97)

Part (a) of the animation rework: model, commands, saving, runtime and nodes.

- AnimationAsset holds a root (sprite, text, container or scene) and rows; a row keys one
  property of the root or of an object inside it, keys sit on whole frames.
- The last key decides the length (its frame counts as a frame of its own). The playhead runs
  between frames, so numbers and colors blend every tick even at 10 fps; everything else holds.
- Playback gains `once-reset`, which puts back what was there before the start. A row can be
  relative, adding to where its target stood.
- Play / Pause / Stop animation act on an object; each instance plays on its own, the later
  start wins on a shared property, and a behavior writing the same property still writes last.
- A sprite no longer wears an animation through `texture`. "Create animation from texture"
  builds a sprite plus a texture row keyed frame by frame instead.
- The Animation tab shows a placeholder until the timeline editor (part b) lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-16 20:02:25 +02:00
1 parent d3563897d8
commit 193378e6a9
29 files changed
+1194 -1499

No files matched your search

+3 -3
View File
@@ -10,7 +10,7 @@ import {
behaviorError,
type Trace,
} from '../runtime/systems/BehaviorSystem';
import type { AnimationClock } from '../runtime/Entity';
import type { AnimationPlayer } from '../core/model/animation';
import type { PropertyBag } from '../core/model/Properties';
import { gameClock } from '../core/time';
import { Input } from '../runtime/systems/Input';
@@ -35,7 +35,7 @@ export interface WorldSnapshot {
worldY: number;
parent: string;
props: Record<string, unknown>;
animation: AnimationClock | null;
animations: AnimationPlayer[];
}[];
/** What `global` declarations share this run – no entity carries it, so it is reported apart. */
shared: SharedState;
@@ -221,7 +221,7 @@ export class PlaySession {
worldY: w.y,
parent: e.parent?.objectId ?? '',
props: { ...e.props },
animation: e.animation ? { ...e.animation } : null,
animations: structuredClone(e.animations ?? []),
};
}),
shared: this.behaviors?.shared ?? emptySharedState(),
-439
View File
@@ -1,439 +0,0 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import {
AddAnimationFramesCommand,
MoveAnimationFrameCommand,
RemoveAnimationFramesCommand,
} from '../../core/commands/animationCommands';
import { animationFrameAt, fpsOf, playbackOf } from '../../core/model/animation';
import type { AnimationAsset } from '../../core/model/Asset';
import { findAssetOf } from '../../core/model/Project';
import { findFrame, resolvePicture } from '../../core/model/texture';
import { pictureSize } from '../../core/properties/aspect';
import { textureRefLabel } from '../../core/properties/textureRef';
import {
drawControlBox,
Menu,
type Size,
Theme,
textStyle,
truncate,
ui,
Widget,
zoomFactor,
} from '../../ui';
import { textureLibrary } from '../../viewport/TextureLibrary';
import type { EditorState } from '../state';
import { drawChecker } from '../texture/checker';
import { containRect, type Rect } from '../texture/imageGrid';
import { type SheetView, zoomAbout } from '../texture/sheetView';
import {
gapAt,
maxScroll,
moveTarget,
STRIP_HEIGHT,
STRIP_PAD,
TILE,
TILE_GAP,
tileIndexAt,
tileLeft,
} from './strip';
const PREVIEW_PAD = 16;
const THUMB_PAD = 4;
/** Pixels the pointer must travel before a press on a tile becomes a drag. */
const DRAG_THRESHOLD = 3;
/**
* The Animation tab: a playing preview above, the frames as a strip of tiles below. The selected
* tile is local to the tab rather than an editor selection, so the inspector keeps showing the
* animation's fps and playback while its frames are edited.
*/
export class AnimationCanvas extends Widget {
private readonly bg = new Graphics();
private readonly content = new Container();
private readonly preview = new Graphics();
private readonly strip = new Graphics();
private readonly marks = new Graphics();
private readonly captionLayer = new Container();
private readonly captions: Text[] = [];
private readonly hint = new Text({ text: '', style: textStyle('muted') });
private readonly clip = new Graphics();
private assetId = '';
private time = 0;
private scroll = 0;
private press: { index: number; x: number; moved: boolean } | null = null;
private dropGap = -1;
private previewRect: Rect | null = null;
/** Null fits each frame on its own; once zoomed, every frame is centred on the view's origin at one scale. */
private view: SheetView | null = null;
private panning: { x: number; y: number; view: SheetView } | null = null;
playing = true;
/** -1 for none. */
selected = -1;
onPlayingChange?: () => void;
constructor(private readonly state: EditorState) {
super();
this.content.addChild(this.preview, this.strip, this.captionLayer, this.marks, this.hint);
this.content.eventMode = 'none';
this.content.mask = this.clip;
this.addChild(this.bg, this.content, this.clip);
this.focusable = true;
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
this.on('pointerup', () => this.onPointerUp());
this.on('pointerupoutside', () => this.onPointerUp());
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
state.on('project', () => this.redraw());
textureLibrary.onChange(() => this.redraw());
}
override measure(): Size {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, STRIP_HEIGHT + 120) };
}
get currentAssetId(): string {
return this.assetId;
}
/** A new subject starts playing from its first frame. */
show(assetId: string): void {
if (this.assetId === assetId) return;
this.assetId = assetId;
this.time = 0;
this.selected = -1;
this.scroll = 0;
this.view = null;
this.setPlaying(true);
this.redraw();
}
fit(): void {
this.scroll = 0;
this.view = null;
this.redraw();
}
tick(dt: number): void {
const asset = this.asset();
if (!this.playing || !asset || asset.frames.length === 0) return;
const before = this.shownIndex(asset);
this.time += dt;
const now = animationFrameAt(asset, this.time);
if (now.finished) {
this.selected = now.index;
this.setPlaying(false);
}
if (now.index !== before || now.finished) this.drawDynamic(asset);
}
/** Pausing holds the frame on screen; playing resumes from the selected frame. */
togglePlaying(): void {
const asset = this.asset();
if (!asset) return;
if (this.playing) {
this.selected = this.shownIndex(asset);
this.setPlaying(false);
} else {
const last = asset.frames.length - 1;
const atEnd = playbackOf(asset) === 'once' && this.selected === last;
const from = this.selected >= 0 && !atEnd ? this.selected : 0;
const fps = fpsOf(asset);
this.time = fps > 0 ? from / fps : 0;
this.setPlaying(true);
}
this.redraw();
}
/** Inserted after the selected frame, else at the end; the last one inserted becomes selected. */
addFrames(refs: readonly string[]): void {
const asset = this.asset();
if (!asset || refs.length === 0) return;
const at = this.selected >= 0 ? this.selected + 1 : asset.frames.length;
if (!this.state.execute(new AddAnimationFramesCommand(asset.id, refs, at))) return;
this.selected = at + refs.length - 1;
this.revealSelected();
this.redraw();
}
/** For the check scripts. */
debugInfo(): {
assetId: string;
frames: number;
shown: number;
playing: boolean;
selected: number;
dropGap: number;
preview: Rect | null;
} {
const asset = this.asset();
const origin = this.getGlobalPosition();
const rect = this.previewRect;
return {
assetId: this.assetId,
frames: asset?.frames.length ?? 0,
shown: this.shownIndex(asset),
playing: this.playing,
selected: this.selected,
dropGap: this.dropGap,
preview: rect && { x: origin.x + rect.x, y: origin.y + rect.y, w: rect.w, h: rect.h },
};
}
/** Where a tile's centre is on screen, null while it is scrolled out of view. */
tileCentre(index: number): { x: number; y: number } | null {
const x = tileLeft(index, this.scroll);
if (x < 0 || x + TILE > this.w) return null;
const origin = this.getGlobalPosition();
return { x: origin.x + x + TILE / 2, y: origin.y + this.stripTop() + STRIP_PAD + TILE / 2 };
}
private asset(): AnimationAsset | null {
return (this.assetId && findAssetOf(this.state.project, this.assetId, 'animation')) || null;
}
private setPlaying(playing: boolean): void {
if (this.playing === playing) return;
this.playing = playing;
this.onPlayingChange?.();
}
private shownIndex(asset: AnimationAsset | null): number {
if (!asset || asset.frames.length === 0) return -1;
if (!this.playing && this.selected >= 0) return this.selected;
return animationFrameAt(asset, this.time).index;
}
private stripTop(): number {
return Math.max(0, this.h - STRIP_HEIGHT);
}
/** A tile picked by hand pauses on it, so what is under the pointer is what the preview shows. */
private pick(index: number): void {
this.selected = index;
if (index >= 0) this.setPlaying(false);
this.redraw();
}
private revealSelected(): void {
const x = tileLeft(this.selected, this.scroll);
if (x < STRIP_PAD) this.scroll += x - STRIP_PAD;
else if (x + TILE > this.w - STRIP_PAD) this.scroll += x + TILE - (this.w - STRIP_PAD);
}
private onPointerDown(e: FederatedPointerEvent): void {
ui().focus.focus(this);
const asset = this.asset();
const local = this.toLocal(e.global);
if (!asset) return;
if (local.y < this.stripTop()) {
const view = (e.button === 1 || (e.button === 0 && e.shiftKey)) && this.currentView(asset);
if (view) {
this.panning = { x: local.x, y: local.y, view };
e.stopPropagation();
}
return;
}
const index = tileIndexAt(local.x, asset.frames.length, this.scroll);
e.stopPropagation();
if (e.button === 2) {
if (index < 0) return;
this.pick(index);
this.openMenu(asset, index, e.global.x, e.global.y);
return;
}
if (e.button !== 0) return;
this.pick(index);
if (index >= 0) this.press = { index, x: local.x, moved: false };
}
private onPointerMove(e: FederatedPointerEvent): void {
const press = this.press;
const asset = this.asset();
const local = this.toLocal(e.global);
const panning = this.panning;
if (panning && asset) {
const { view } = panning;
this.view = { ...view, panX: view.panX + local.x - panning.x, panY: view.panY + local.y - panning.y };
this.drawDynamic(asset);
return;
}
if (!press || !asset) return;
if (!press.moved && Math.abs(local.x - press.x) < DRAG_THRESHOLD) return;
press.moved = true;
this.dropGap = gapAt(local.x, asset.frames.length, this.scroll);
this.drawDynamic(asset);
}
private onPointerUp(): void {
const press = this.press;
const gap = this.dropGap;
this.press = null;
this.panning = null;
this.dropGap = -1;
const asset = this.asset();
if (!press?.moved || !asset) return;
const to = moveTarget(press.index, gap);
if (to !== null && this.state.execute(new MoveAnimationFrameCommand(asset.id, press.index, to)))
this.selected = to;
this.redraw();
}
/** Over the preview the wheel zooms about the pointer, over the strip it scrolls the tiles. */
private onWheel(e: FederatedWheelEvent): void {
const asset = this.asset();
if (!asset) return;
const local = this.toLocal(e.global);
if (local.y < this.stripTop()) {
const view = this.currentView(asset);
if (!view) return;
this.view = zoomAbout(view, zoomFactor(e.deltaY), local.x, local.y);
this.drawDynamic(asset);
} else {
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
this.scroll = Math.max(0, Math.min(maxScroll(asset.frames.length, this.w), this.scroll + delta));
this.redraw();
}
e.preventDefault();
}
/** The manual view, or the one that reproduces the fit of the frame on screen; null without a picture. */
private currentView(asset: AnimationAsset): SheetView | null {
if (this.view) return this.view;
const shown = this.shownIndex(asset);
const picture = shown >= 0 ? resolvePicture(this.state.project, asset.frames[shown]!) : null;
const size = picture && pictureSize(picture);
const rect = this.previewRect;
if (!size || !rect) return null;
return { panX: rect.x + rect.w / 2, panY: rect.y + rect.h / 2, zoom: rect.w / size.w };
}
override onKeyDown(event: KeyboardEvent): boolean {
if ((event.key !== 'Delete' && event.key !== 'Backspace') || this.selected < 0) return false;
const asset = this.asset();
if (asset) this.state.execute(new RemoveAnimationFramesCommand(asset.id, [this.selected]));
return true;
}
private openMenu(asset: AnimationAsset, index: number, x: number, y: number): void {
const ref = asset.frames[index]!;
new Menu([
{
label: 'Duplicate frame',
onSelect: () => {
if (this.state.execute(new AddAnimationFramesCommand(asset.id, [ref], index + 1)))
this.pick(index + 1);
},
},
{
label: 'Remove frame',
danger: true,
onSelect: () => this.state.execute(new RemoveAnimationFramesCommand(asset.id, [index])),
},
]).open(x, y);
}
override redraw(): void {
this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport);
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
const asset = this.asset();
const count = asset?.frames.length ?? 0;
this.selected = Math.min(this.selected, count - 1);
this.scroll = Math.max(0, Math.min(maxScroll(count, this.w), this.scroll));
this.drawStrip(asset);
this.drawDynamic(asset);
this.syncHint(asset);
}
/** What changes while playing or dragging: the preview, the playhead and the drop marker. */
private drawDynamic(asset: AnimationAsset | null): void {
this.preview.clear();
this.marks.clear();
this.previewRect = null;
const shown = this.shownIndex(asset);
if (!asset || shown < 0) return;
const box = {
x: PREVIEW_PAD,
y: PREVIEW_PAD,
w: this.w - PREVIEW_PAD * 2,
h: this.stripTop() - PREVIEW_PAD * 2,
};
this.previewRect = this.drawPicture(this.preview, asset.frames[shown]!, box, this.view);
const top = this.stripTop();
const x = tileLeft(shown, this.scroll);
this.marks.rect(x, top + 2, TILE, 3).fill(Theme.colors.hot);
if (this.dropGap >= 0) {
const gx = tileLeft(this.dropGap, this.scroll) - TILE_GAP / 2;
this.marks.rect(gx - 1, top + STRIP_PAD - 2, 2, TILE + 4).fill(Theme.colors.accent);
}
}
private drawStrip(asset: AnimationAsset | null): void {
this.strip.clear();
const top = this.stripTop();
this.strip.rect(0, top, this.w, STRIP_HEIGHT).fill(Theme.colors.panel);
this.strip.rect(0, top, this.w, 1).fill(Theme.colors.border);
const frames = asset?.frames ?? [];
while (this.captions.length < frames.length) {
const caption = new Text({ text: '', style: textStyle('small') });
this.captions.push(caption);
this.captionLayer.addChild(caption);
}
this.captions.forEach((caption, index) => {
const x = tileLeft(index, this.scroll);
const ref = frames[index];
caption.visible = ref !== undefined && x + TILE > 0 && x < this.w;
if (!caption.visible || ref === undefined) return;
const y = top + STRIP_PAD;
const selected = index === this.selected;
drawControlBox(this.strip, TILE, TILE, {
x,
y,
fill: selected ? Theme.colors.selection : Theme.colors.control,
border: selected ? Theme.colors.accent : Theme.colors.border,
});
const inset = { x: x + THUMB_PAD, y: y + THUMB_PAD, w: TILE - THUMB_PAD * 2, h: TILE - THUMB_PAD * 2 };
this.drawPicture(this.strip, ref, inset);
caption.text = truncate(`${index + 1} ${this.shortName(ref)}`, caption.style, TILE);
caption.position.set(x, y + TILE + 1);
});
}
/** The frame's own name – its sheet is usually the same for every tile. */
private shortName(ref: string): string {
return findFrame(this.state.project, ref)?.frame.name ?? textureRefLabel(this.state.project, ref);
}
/** Fitted into `box`, or placed by `view`, over a checkerboard; returns where it landed, null without a picture. */
private drawPicture(g: Graphics, ref: string, box: Rect, view: SheetView | null = null): Rect | null {
const picture = resolvePicture(this.state.project, ref);
const size = picture && pictureSize(picture);
if (!picture || !size || box.w <= 0 || box.h <= 0) return null;
let rect: Rect;
if (view) {
const w = size.w * view.zoom;
const h = size.h * view.zoom;
rect = { x: view.panX - w / 2, y: view.panY - h / 2, w, h };
} else {
const fitted = containRect(size.w, size.h, box.w, box.h);
rect = { x: box.x + fitted.x, y: box.y + fitted.y, w: fitted.w, h: fitted.h };
}
drawChecker(g, rect.x, rect.y, rect.w, rect.h, this.w, this.h);
const texture = textureLibrary.get(picture);
if (texture) g.rect(rect.x, rect.y, rect.w, rect.h).fill({ texture });
return rect;
}
private syncHint(asset: AnimationAsset | null): void {
const text = !asset
? 'No animation selected – create one from a texture (right-click it in the Assets panel).'
: asset.frames.length === 0
? 'No frames yet – add pictures with + Frames.'
: '';
this.hint.text = text;
this.hint.visible = text !== '';
this.hint.position.set(Theme.padding * 2, Theme.padding * 2);
}
}
+50 -49
View File
@@ -1,35 +1,56 @@
import { animationDuration, fpsOf, framesOfTexture } from '../../core/model/animation';
import type { AnimationAsset, Asset, TextureAsset } from '../../core/model/Asset';
import { pictureChoices } from '../../core/properties/textureRef';
import { Button, Dropdown, formatNumber, Menu, type MenuItem } from '../../ui';
import { AssetTabEditor } from '../AssetTabEditor';
import { animationDuration, fpsOf, frameCount } from '../../core/model/animation';
import type { AnimationAsset, Asset } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
import { formatNumber, Label, Theme, VStack } from '../../ui';
import { AssetTabEditor, type TabCanvas } from '../AssetTabEditor';
import type { EditorState } from '../state';
import { AnimationCanvas } from './AnimationCanvas';
/** Room kept for the "N frames · 10 fps · 0.8 s" readout. */
const INFO_WIDTH = 170;
/** Room kept for the "3 rows · 24 frames · 10 fps · 2.4 s" readout. */
const INFO_WIDTH = 220;
export class AnimationEditor extends AssetTabEditor<AnimationAsset, AnimationCanvas> {
/** Public for the check scripts. */
readonly playButton: Button;
readonly addButton: Dropdown;
/**
* Stands where the timeline goes (#97): says what the animation animates and holds the tab's
* subject, so picking, deleting and the readout already work on the new model.
*/
export class TimelinePlaceholder extends VStack implements TabCanvas {
private assetId = '';
private readonly note = new Label('', { style: 'muted' });
constructor(private readonly state: EditorState) {
super({ padding: Theme.padding });
this.add(this.note);
}
get currentAssetId(): string {
return this.assetId;
}
show(assetId: string): void {
this.assetId = assetId;
const asset = findAsset(this.state.project, assetId);
const root = asset?.type === 'animation' ? findAsset(this.state.project, asset.root) : undefined;
this.note.setText(
asset?.type !== 'animation'
? ''
: `Animates ${root ? root.name : 'nothing yet'} – the timeline editor is being built.`,
);
}
fit(): void {}
}
export class AnimationEditor extends AssetTabEditor<AnimationAsset, TimelinePlaceholder> {
constructor(state: EditorState) {
super(state, { noun: 'Animation', infoWidth: INFO_WIDTH });
this.playButton = new Button('❚❚', { width: 36, onClick: () => this.canvas.togglePlaying() });
this.addButton = new Dropdown('+ Frames', { onClick: () => this.openAddMenu() });
this.mount(new AnimationCanvas(state), [this.playButton, this.addButton]);
this.canvas.onPlayingChange = () => this.syncExtras(this.asset());
this.mount(new TimelinePlaceholder(state));
}
/** Only called while the tab is visible: the preview has no clock of its own. */
tick(dt: number): void {
this.canvas.tick(dt);
}
/** Only called while the tab is visible; the timeline brings its own preview clock. */
tick(_dt: number): void {}
/** Whether the preview is running – a moving picture needs a frame without an event. */
get isPlaying(): boolean {
return this.canvas.playing;
return false;
}
protected override selectedId(): string {
@@ -40,36 +61,16 @@ export class AnimationEditor extends AssetTabEditor<AnimationAsset, AnimationCan
return asset.type === 'animation';
}
protected override refresh(): void {
super.refresh();
this.canvas.show(this.canvas.currentAssetId);
}
protected override infoText(asset: AnimationAsset): string {
const count = asset.frames.length;
const rows = asset.tracks.length;
const frames = Math.max(0, frameCount(asset));
const seconds = formatNumber(animationDuration(asset), 1);
const fps = Number(fpsOf(asset).toFixed(2));
return `${count} ${count === 1 ? 'frame' : 'frames'} · ${fps} fps · ${seconds} s`;
}
protected override syncExtras(asset: AnimationAsset | null): void {
this.playButton.setText(this.canvas.playing ? '❚❚' : '▶');
this.playButton.setEnabled(Boolean(asset && asset.frames.length > 0));
this.addButton.setEnabled(asset !== null);
}
/** Each sheet offers all its frames at once before the single pictures. */
private openAddMenu(): void {
const items: MenuItem[] = [];
const sheets = new Set<TextureAsset>();
for (const choice of pictureChoices(this.state.project)) {
const sheet = choice.asset as TextureAsset;
if (!sheets.has(sheet) && sheet.frames.length > 1) {
sheets.add(sheet);
items.push({
label: `${sheet.name} – all ${sheet.frames.length} frames`,
onSelect: () => this.canvas.addFrames(framesOfTexture(sheet)),
});
}
items.push({ label: choice.label, onSelect: () => this.canvas.addFrames([choice.id]) });
}
if (items.length === 0)
items.push({ label: 'No textures yet', disabled: true, onSelect: () => undefined });
new Menu(items, { searchable: true }).openBelow(this.addButton);
return `${rows} ${rows === 1 ? 'row' : 'rows'} · ${frames} ${frames === 1 ? 'frame' : 'frames'} · ${fps} fps · ${seconds} s`;
}
}
-37
View File
@@ -1,37 +0,0 @@
/** Geometry of the Animation tab's frame strip, in strip-local pixels before scrolling. */
export const TILE = 72;
export const TILE_GAP = 6;
export const STRIP_PAD = 8;
export const CAPTION_HEIGHT = 16;
export const STRIP_HEIGHT = STRIP_PAD * 2 + TILE + CAPTION_HEIGHT;
export function tileLeft(index: number, scroll: number): number {
return STRIP_PAD + index * (TILE + TILE_GAP) - scroll;
}
/** The tile under `x`, or -1 for the gaps and everything past the last one. */
export function tileIndexAt(x: number, count: number, scroll: number): number {
const from = x + scroll - STRIP_PAD;
if (from < 0) return -1;
const index = Math.floor(from / (TILE + TILE_GAP));
if (index >= count || from - index * (TILE + TILE_GAP) > TILE) return -1;
return index;
}
/** The gap nearest to `x` a dragged tile would drop into: 0 before the first tile, `count` after the last. */
export function gapAt(x: number, count: number, scroll: number): number {
const from = x + scroll - STRIP_PAD + TILE_GAP / 2;
return Math.max(0, Math.min(count, Math.round(from / (TILE + TILE_GAP))));
}
/** Where a tile dropped into `gap` ends up; null when that leaves it where it is. */
export function moveTarget(from: number, gap: number): number | null {
const to = gap > from ? gap - 1 : gap;
return to === from ? null : to;
}
export function maxScroll(count: number, width: number): number {
const content = STRIP_PAD * 2 + count * TILE + Math.max(0, count - 1) * TILE_GAP;
return Math.max(0, content - width);
}
+2 -5
View File
@@ -461,11 +461,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
rect: () => textureEditor.canvasRect(),
},
animation: {
/** Frames, the one on screen, play state and where the preview picture is drawn. */
current: () => editor.animationEditor.canvas.debugInfo(),
tileCentre: (index: number) => editor.animationEditor.canvas.tileCentre(index),
playButton: () => centreOf(editor.animationEditor.playButton),
playLabel: () => editor.animationEditor.playButton.text,
current: () => editor.animationEditor.currentAssetId,
info: () => editor.animationEditor.shownInfo,
},
container: {
current: () => containerEditor.currentAssetId,
+24 -6
View File
@@ -1,4 +1,8 @@
import { AddAnimationFramesCommand } from '../../core/commands/animationCommands';
import {
AddAnimationTrackCommand,
SetAnimationKeyCommand,
SetAnimationRootCommand,
} from '../../core/commands/animationCommands';
import {
CreateAssetCommand,
SetActiveSceneCommand,
@@ -12,7 +16,6 @@ import {
hasColorSwatch,
isPlaceableType,
} from '../../core/model/AssetTypes';
import { framesOfTexture } from '../../core/model/animation';
import type { TextureAsset } from '../../core/model/Asset';
import type { PropertyBag } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
@@ -131,11 +134,26 @@ export class AssetPanel extends Panel {
if (this.state.execute(command)) this.state.openAsset(command.asset.id);
}
/** One undo step for the asset and its frames. */
/**
* A sprite wearing the texture and an animation over it whose one row keys the frames in order,
* one per frame – what a picture sequence is on a timeline. One undo step for all of it.
*/
private createAnimation(texture: TextureAsset): void {
const create = new CreateAssetCommand('animation', nextAssetName(this.state.project, texture.name));
const frames = new AddAnimationFramesCommand(create.asset.id, framesOfTexture(texture));
if (this.state.execute(oneStep(create.label, [create, frames]))) this.state.openAsset(create.asset.id);
const project = this.state.project;
const sprite = new CreateAssetCommand(
'sprite',
nextAssetName(project, texture.name),
spriteFromTexture(project, texture.id),
);
const create = new CreateAssetCommand('animation', nextAssetName(project, texture.name));
const root = new SetAnimationRootCommand(create.asset.id, sprite.asset.id);
const row = new AddAnimationTrackCommand(create.asset.id, '', 'texture');
const pictures = texture.frames.length > 0 ? texture.frames.map((f) => f.id) : [texture.id];
const keys = pictures.map(
(id, frame) => new SetAnimationKeyCommand(create.asset.id, row.trackId, frame, id),
);
if (this.state.execute(oneStep(create.label, [sprite, create, root, row, ...keys])))
this.state.openAsset(create.asset.id);
}
/**