Files
feuerwerk/tests/core/frame-commands.test.ts
T
rootandClaude Opus 5 35a1a23af8 Add a Texture tab for defining frames
A new Texture tab next to Scene/Game/Graph shows a texture sheet and lets
frames be drawn on it: drag to create, move, resize via eight handles, edit
name and rect in the inspector, Delete to remove. Every change is a command,
so one gesture is one undo step. "Slice..." cuts a grid from columns and rows
with optional offset and spacing, previewing the resulting frame count and
cell size live.

Sprites pick a frame instead of the whole texture through the same `texture`
property: it now holds either a texture id or a frame id. Frame ids come from
the same generator and are document-wide unique, so one picker, one property
and one set of instance overrides cover both cases, and every sprite written
before this still means the same thing.

A Graphics fill ignores a Pixi sub-texture's frame and draws the whole image,
because it derives its uvs from the shape bounds and the inverted style
matrix. The cutout is therefore a matrix (`viewport/textureFill.ts`), which
keeps a sheet at a single GPU upload no matter how many of its frames are on
screen at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:27:23 +02:00

283 lines
11 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 { describe, expect, it } from 'vitest';
import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands';
import { CommandError } from '../../src/core/commands/Command';
import {
AddFrameCommand,
RemoveFrameCommand,
RenameFrameCommand,
SetFrameRectCommand,
SliceFramesCommand,
} from '../../src/core/commands/frameCommands';
import type { TextureAsset, TextureFrame } from '../../src/core/model/Asset';
import { findAsset } from '../../src/core/model/Project';
import { DEFAULT_SLICE, findFrame, type FrameRect } from '../../src/core/model/texture';
import { makeProject } from '../helpers';
const FILE = `${'ab'.repeat(32)}.png`;
const GRID_2X2 = { ...DEFAULT_SLICE, columns: 2, rows: 2 };
/** A project holding one 64×64 texture, with lookups that survive undo replacing the asset. */
function makeSheet(width = 64, height = 64) {
const fixture = makeProject();
const create = new CreateTextureCommand('Sheet', { file: FILE, width, height });
fixture.stack.execute(create);
const asset = () => findAsset(fixture.project, create.asset.id) as TextureAsset;
return { ...fixture, textureId: create.asset.id, asset, frames: () => asset().frames };
}
/** Adds a frame through the command stack and returns its id. */
function addFrame(sheet: ReturnType<typeof makeSheet>, rect: FrameRect, name?: string): string {
const command = new AddFrameCommand(sheet.textureId, rect, name);
sheet.stack.execute(command);
return command.frameId;
}
/** A detached copy of the frame list, to compare a state against after an undo. */
function snapshot(frames: readonly TextureFrame[]): TextureFrame[] {
return frames.map((f) => ({ ...f }));
}
describe('AddFrameCommand', () => {
it('appends a named frame and removes exactly that one again', () => {
const sheet = makeSheet();
const first = addFrame(sheet, { x: 0, y: 0, w: 16, h: 16 }, 'Idle');
const before = snapshot(sheet.frames());
const second = addFrame(sheet, { x: 16, y: 0, w: 16, h: 16 }, 'Run');
expect(sheet.frames()).toEqual([
{ id: first, name: 'Idle', x: 0, y: 0, w: 16, h: 16 },
{ id: second, name: 'Run', x: 16, y: 0, w: 16, h: 16 },
]);
// The id is known before execution, so the editor can select the frame it just made.
expect(findFrame(sheet.project, second)?.frame.name).toBe('Run');
sheet.stack.undo();
expect(sheet.frames()).toEqual(before);
sheet.stack.redo();
expect(sheet.frames().map((f) => f.id)).toEqual([first, second]);
});
it('names frames apart instead of repeating one', () => {
const sheet = makeSheet();
addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'Idle');
addFrame(sheet, { x: 8, y: 0, w: 8, h: 8 }, 'Idle');
addFrame(sheet, { x: 16, y: 0, w: 8, h: 8 });
addFrame(sheet, { x: 24, y: 0, w: 8, h: 8 });
// Numbered from 2, like every other generated name in a document (see `nextAssetName`).
expect(sheet.frames().map((f) => f.name)).toEqual(['Idle', 'Idle 2', 'Frame', 'Frame 2']);
});
it('stores a fractional, inverted drag as whole pixels with a positive size', () => {
const sheet = makeSheet();
// Dragging up and to the left in a zoomed view: negative extents and fractions.
addFrame(sheet, { x: 10.4, y: 5, w: -6.2, h: 9.9 });
expect(sheet.frames()[0]).toMatchObject({ x: 4, y: 5, w: 6, h: 10 });
});
it('clamps a rectangle dragged past the edge into the image', () => {
const sheet = makeSheet();
addFrame(sheet, { x: 50, y: 50, w: 40, h: 40 });
addFrame(sheet, { x: -20, y: -5, w: 30, h: 30 });
expect(sheet.frames()[0]).toMatchObject({ x: 50, y: 50, w: 14, h: 14 });
expect(sheet.frames()[1]).toMatchObject({ x: 0, y: 0, w: 10, h: 25 });
});
it('rejects a texture that is not there, and does so in execute', () => {
const sheet = makeSheet();
const command = new AddFrameCommand('nope', { x: 0, y: 0, w: 4, h: 4 });
expect(() => sheet.stack.execute(command)).toThrow(CommandError);
expect(sheet.frames()).toHaveLength(0);
});
});
describe('SetFrameRectCommand', () => {
it('moves a frame and puts the old rectangle back', () => {
const sheet = makeSheet();
const id = addFrame(sheet, { x: 0, y: 0, w: 16, h: 16 }, 'Idle');
const before = snapshot(sheet.frames());
sheet.stack.execute(new SetFrameRectCommand(sheet.textureId, id, { x: 20.6, y: 30, w: 10, h: -8 }));
expect(sheet.frames()[0]).toMatchObject({ name: 'Idle', x: 21, y: 22, w: 10, h: 8 });
sheet.stack.undo();
expect(sheet.frames()).toEqual(before);
sheet.stack.redo();
expect(sheet.frames()[0]).toMatchObject({ x: 21, y: 22 });
});
it('records the rectangle the drag started from, which the caller restores first', () => {
const sheet = makeSheet();
const id = addFrame(sheet, { x: 0, y: 0, w: 16, h: 16 });
const frame = sheet.frames()[0]!;
// What a live preview does: write into the frame, then hand the command the previewed
// rectangle and put the starting one back before executing (see `SceneViewport`).
frame.x = 40;
const previewed = { x: frame.x, y: frame.y, w: frame.w, h: frame.h };
frame.x = 0;
sheet.stack.execute(new SetFrameRectCommand(sheet.textureId, id, previewed));
expect(sheet.frames()[0]).toMatchObject({ x: 40 });
sheet.stack.undo();
expect(sheet.frames()[0]).toMatchObject({ x: 0 });
});
it('rejects a frame that is gone', () => {
const sheet = makeSheet();
expect(() =>
sheet.stack.execute(new SetFrameRectCommand(sheet.textureId, 'nope', { x: 0, y: 0, w: 4, h: 4 })),
).toThrow(CommandError);
});
});
describe('RenameFrameCommand', () => {
it('renames with undo and keeps the name unique', () => {
const sheet = makeSheet();
addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'Idle');
const second = addFrame(sheet, { x: 8, y: 0, w: 8, h: 8 }, 'Run');
const before = snapshot(sheet.frames());
sheet.stack.execute(new RenameFrameCommand(sheet.textureId, second, 'Walk'));
expect(sheet.frames().map((f) => f.name)).toEqual(['Idle', 'Walk']);
sheet.stack.execute(new RenameFrameCommand(sheet.textureId, second, 'Idle'));
expect(sheet.frames().map((f) => f.name)).toEqual(['Idle', 'Idle 2']);
sheet.stack.undo();
sheet.stack.undo();
expect(sheet.frames()).toEqual(before);
});
it('leaves a frame its own name to rename to', () => {
const sheet = makeSheet();
const id = addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'Idle');
sheet.stack.execute(new RenameFrameCommand(sheet.textureId, id, 'Idle'));
expect(sheet.frames()[0]?.name).toBe('Idle');
});
it('refuses a blank name instead of showing a nameless frame', () => {
const sheet = makeSheet();
const id = addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'Idle');
expect(() => sheet.stack.execute(new RenameFrameCommand(sheet.textureId, id, ' '))).toThrow(
CommandError,
);
expect(sheet.frames()[0]?.name).toBe('Idle');
});
});
describe('RemoveFrameCommand', () => {
it('puts the frame back at its original index, not at the end', () => {
const sheet = makeSheet();
addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'A');
const middle = addFrame(sheet, { x: 8, y: 0, w: 8, h: 8 }, 'B');
addFrame(sheet, { x: 16, y: 0, w: 8, h: 8 }, 'C');
const before = snapshot(sheet.frames());
sheet.stack.execute(new RemoveFrameCommand(sheet.textureId, middle));
expect(sheet.frames().map((f) => f.name)).toEqual(['A', 'C']);
sheet.stack.undo();
// The frame order is the order of the sheet, so appending it again would reshuffle it.
expect(sheet.frames()).toEqual(before);
sheet.stack.redo();
expect(sheet.frames().map((f) => f.name)).toEqual(['A', 'C']);
});
it('rejects a frame that is not in the texture', () => {
const sheet = makeSheet();
expect(() => sheet.stack.execute(new RemoveFrameCommand(sheet.textureId, 'nope'))).toThrow(CommandError);
});
});
describe('SliceFramesCommand', () => {
it('cuts a grid into equal cells in reading order', () => {
const sheet = makeSheet();
sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false));
expect(sheet.frames().map((f) => ({ x: f.x, y: f.y, w: f.w, h: f.h }))).toEqual([
{ x: 0, y: 0, w: 32, h: 32 },
{ x: 32, y: 0, w: 32, h: 32 },
{ x: 0, y: 32, w: 32, h: 32 },
{ x: 32, y: 32, w: 32, h: 32 },
]);
expect(sheet.frames().map((f) => f.name)).toEqual(['Frame 1', 'Frame 2', 'Frame 3', 'Frame 4']);
sheet.stack.undo();
expect(sheet.frames()).toEqual([]);
sheet.stack.redo();
expect(sheet.frames()).toHaveLength(4);
});
it('keeps existing frames when adding, and replaces them when told to', () => {
const sheet = makeSheet();
addFrame(sheet, { x: 1, y: 2, w: 8, h: 8 }, 'Hand-made');
const before = snapshot(sheet.frames());
sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false));
expect(sheet.frames()).toHaveLength(5);
expect(sheet.frames()[0]).toEqual(before[0]);
sheet.stack.undo();
expect(sheet.frames()).toEqual(before);
sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, true));
expect(sheet.frames()).toHaveLength(4);
expect(sheet.frames().some((f) => f.name === 'Hand-made')).toBe(false);
sheet.stack.undo();
expect(sheet.frames()).toEqual(before);
sheet.stack.redo();
expect(sheet.frames().map((f) => f.name)).toEqual(['Frame 1', 'Frame 2', 'Frame 3', 'Frame 4']);
});
it('gives a redone slice the same frame ids, so a sprite wearing one keeps its picture', () => {
const sheet = makeSheet();
sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false));
const ids = sheet.frames().map((f) => f.id);
sheet.stack.undo();
sheet.stack.redo();
expect(sheet.frames().map((f) => f.id)).toEqual(ids);
});
it('numbers generated names around the ones already taken', () => {
const sheet = makeSheet();
addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'Frame 2');
sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false));
expect(new Set(sheet.frames().map((f) => f.name)).size).toBe(5);
});
it('rejects a slice of an image whose size is unknown rather than doing nothing', () => {
// Width 0 is a texture whose upload has not been measured yet; slicing it would silently
// produce no frames at all.
const sheet = makeSheet(0, 0);
expect(() => sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false))).toThrow(
CommandError,
);
expect(sheet.frames()).toEqual([]);
});
it('rejects a grid whose cells would be smaller than a pixel', () => {
const sheet = makeSheet(4, 4);
const tooFine = { ...DEFAULT_SLICE, columns: 8, rows: 8 };
expect(() => sheet.stack.execute(new SliceFramesCommand(sheet.textureId, tooFine, true))).toThrow(
CommandError,
);
expect(sheet.frames()).toEqual([]);
});
it('refuses an asset that is missing, and one that is not a texture', () => {
const sheet = makeSheet();
const sprite = new CreateAssetCommand('sprite', 'Box');
sheet.stack.execute(sprite);
expect(() => sheet.stack.execute(new SliceFramesCommand('nope', GRID_2X2, false))).toThrow(CommandError);
expect(() => sheet.stack.execute(new SliceFramesCommand(sprite.asset.id, GRID_2X2, false))).toThrow(
/not a texture/,
);
});
});