Files
feuerwerk/src/editor/animation/AnimationCanvas.ts
T
rootandClaude Fable 5.1 0e5e5ef925
game-maker / suite (push) Successful in 8m24s
Shaders draw in the sprite's own pass: a Mesh with the graph's fragment, no filter (#77)
A sprite's picture is now a Mesh (unit quad, sibling of its Graphics node, which keeps
only the hit area); a worn shader replaces Pixi's fragment in that draw, so there is no
intermediate texture and no extra render pass. Measured on the HD 630: 400 shaded
sprites hold 60 fps (was 34), 800 hold 56 (was 20).

Two Pixi facts the code leans on: a mesh's texture is bound only for Pixi's own shader,
so GraphShader.wear hands it over; and MeshPipe caches whether a mesh batches and
re-checks it only in a frame without structure changes, so switching a shader on or
off calls mesh.unload().

The console panel mirrors console.group/groupCollapsed/groupEnd and fills the %s %d
%o %c directives, so Pixi's deprecation warning shows its head, not only its stack.
E2E 12 guards against the addChild-on-Graphics warning, 28 counts shaders instead of
filters.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 15:31:08 +02:00

440 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}