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:
1 parent
d3563897d8
commit
193378e6a9
29 files changed
+1194
-1499
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user