Files
feuerwerk/src/editor/animation/AnimationCanvas.ts
T
rootandClaude Opus 5 05f7a2745e
game-maker / suite (push) Successful in 8m18s
Frames: cut each frame into its own GPU texture, no more fill matrix (#47)
A frame drawn straight from the sheet showed a hairline of the neighbouring
cell along any edge that fell inside a screen pixel: with antialias on, a
partly covered pixel is still evaluated at its centre, past the edge. On top
of that Pixi rewrites a Graphics fill texture from clamp-to-edge to repeat at
draw time (generateTextureFillMatrix.mjs), so the top row of a sheet read its
bottom row.

TextureLibrary.get(ref) now cuts the rectangle out of the loaded sheet with an
OffscreenCanvas on first use and uploads the cut (key file|x,y,w,h, kept for
the session). Address mode is mirror-repeat: Pixi only rewrites clamp-to-edge,
and within a texel of the border mirror behaves like clamp, for linear as well
as nearest. Loaded sheets get it too, since whole pictures wrapped at their own
borders the same way. viewport/textureFill.ts and its test are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 13:25:49 +02:00

441 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 { spriteFill } from '../../viewport/symbols';
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(spriteFill(texture, 0xffffff));
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);
}
}