game-maker / suite (push) Successful in 8m24s
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>
440 lines
16 KiB
TypeScript
440 lines
16 KiB
TypeScript
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);
|
||
}
|
||
}
|