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); } }