game-maker / suite (push) Successful in 6m21s
TextureFrame carries optional `border` (left/right/top/bottom) and `fill` (stretch | repeat); absent keys mean "not sliced", so no format bump and undo restores the plain frame instead of four zeroes. The sliced sprite builds its own geometry instead of the shared UNIT_QUAD: a 4x4 grid, and for `repeat` one quad per tile carrying the frame cell's real UVs -- tiling in the geometry, not in the sampler, so a frame out of a sheet cannot pull its neighbour in. Above 64 tiles per axis it stretches, to bound the buffer. A sprite narrower than left+right scales both borders down together until the middle is exactly 0, so the corners meet at a seam and never overlap. The texture editor gets four draggable border lines plus the same four numbers in the inspector. Hit order is resize handles, then the lines, then the frame body -- handleAt reports 'move' anywhere inside a frame, so lines placed after it were unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
314 lines
12 KiB
TypeScript
314 lines
12 KiB
TypeScript
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,
|
||
SetFrameSliceCommand,
|
||
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 };
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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: hand the command the previewed rectangle, with the starting
|
||
// one put 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 an unmeasured upload; 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('slices a frame, and undo takes the keys away again rather than zeroing them', () => {
|
||
const sheet = makeSheet();
|
||
const frameId = addFrame(sheet, { x: 0, y: 0, w: 24, h: 24 });
|
||
const frame = () => sheet.frames().find((f) => f.id === frameId)!;
|
||
|
||
sheet.stack.execute(
|
||
new SetFrameSliceCommand(sheet.textureId, frameId, {
|
||
border: { left: 8, top: 8, right: 8, bottom: 8 },
|
||
fill: 'repeat',
|
||
}),
|
||
);
|
||
expect(frame().border).toEqual({ left: 8, top: 8, right: 8, bottom: 8 });
|
||
expect(frame().fill).toBe('repeat');
|
||
|
||
sheet.stack.undo();
|
||
// Absent, not zero: a frame nobody sliced saves exactly as it did before #41.
|
||
expect(frame().border).toBeUndefined();
|
||
expect(frame().fill).toBeUndefined();
|
||
});
|
||
|
||
it('keeps two opposite borders from eating the whole frame', () => {
|
||
const sheet = makeSheet();
|
||
const frameId = addFrame(sheet, { x: 0, y: 0, w: 24, h: 24 });
|
||
sheet.stack.execute(
|
||
new SetFrameSliceCommand(sheet.textureId, frameId, {
|
||
border: { left: 40, top: -5, right: 40, bottom: 2.6 },
|
||
fill: 'stretch',
|
||
}),
|
||
);
|
||
// Scaled down together to leave one pixel between them; a negative one is none.
|
||
expect(sheet.frames()[0]?.border).toEqual({ left: 12, top: 0, right: 11, bottom: 3 });
|
||
});
|
||
|
||
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/,
|
||
);
|
||
});
|
||
});
|