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>
258 lines
11 KiB
TypeScript
258 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { CreateTextureCommand } from '../../src/core/commands/assetCommands';
|
||
import type { TextureAsset } from '../../src/core/model/Asset';
|
||
import { findAsset } from '../../src/core/model/Project';
|
||
import { findPropertyUsages, findUsages } from '../../src/core/model/usages';
|
||
import {
|
||
DEFAULT_SLICE,
|
||
isTextureFile,
|
||
nextFrameNames,
|
||
resolveTexture,
|
||
sliceFrames,
|
||
textureRefIds,
|
||
uniqueFrameName,
|
||
} from '../../src/core/model/texture';
|
||
import { deserializeProject } from '../../src/core/store/serialize';
|
||
import { makeSpriteScene, place, roundTrip } from '../helpers';
|
||
|
||
/** A syntactically valid stored file name (64 hex characters plus an extension). */
|
||
const FILE = `${'a1b2c3d4'.repeat(8)}.png`;
|
||
const OTHER_FILE = `${'0f'.repeat(32)}.webp`;
|
||
|
||
function addTexture(fixture: ReturnType<typeof makeSpriteScene>, file = FILE, name = 'Bricks') {
|
||
const command = new CreateTextureCommand(name, { file, width: 64, height: 48 });
|
||
fixture.stack.execute(command);
|
||
return command.asset;
|
||
}
|
||
|
||
describe('texture file names', () => {
|
||
it('accepts the content-addressed form the server produces', () => {
|
||
expect(isTextureFile(FILE)).toBe(true);
|
||
expect(isTextureFile(OTHER_FILE)).toBe(true);
|
||
expect(isTextureFile(`${'ab'.repeat(32)}.jpg`)).toBe(true);
|
||
});
|
||
|
||
it('rejects anything a request must not be built from', () => {
|
||
expect(isTextureFile('')).toBe(false);
|
||
expect(isTextureFile('bricks.png')).toBe(false);
|
||
// Too short, wrong case, an unsupported extension, and the two traversal shapes.
|
||
expect(isTextureFile('abc.png')).toBe(false);
|
||
expect(isTextureFile(`${'A1'.repeat(32)}.png`)).toBe(false);
|
||
expect(isTextureFile(`${'ab'.repeat(32)}.svg`)).toBe(false);
|
||
expect(isTextureFile(`../${'ab'.repeat(32)}.png`)).toBe(false);
|
||
expect(isTextureFile(`${'ab'.repeat(32)}.png/../x`)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('resolveTexture', () => {
|
||
it('resolves a sprite property to the file and the filter', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
const sprite = findAsset(fixture.project, fixture.spriteId)!;
|
||
sprite.props.texture = texture.id;
|
||
|
||
// A whole-texture reference carries no rectangle.
|
||
const whole = { file: FILE, rect: null, sourceWidth: 64, sourceHeight: 48, slice: null };
|
||
expect(resolveTexture(fixture.project, sprite.props)).toEqual({ ...whole, smooth: true });
|
||
texture.props.filter = 'nearest';
|
||
expect(resolveTexture(fixture.project, sprite.props)).toEqual({ ...whole, smooth: false });
|
||
});
|
||
|
||
it('resolves a frame id to the rectangle it cuts, not to the whole picture', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
texture.frames.push({ id: 'f1', name: 'Idle', x: 16, y: 8, w: 32, h: 24 });
|
||
const sprite = findAsset(fixture.project, fixture.spriteId)!;
|
||
|
||
sprite.props.texture = 'f1';
|
||
expect(resolveTexture(fixture.project, sprite.props)).toEqual({
|
||
file: FILE,
|
||
smooth: true,
|
||
rect: { x: 16, y: 8, w: 32, h: 24 },
|
||
sourceWidth: 64,
|
||
sourceHeight: 48,
|
||
// An unsliced frame carries no slice: what every document written before #41 says.
|
||
slice: null,
|
||
});
|
||
|
||
// Sliced, it carries the borders and the fill the renderer needs, measured in the frame.
|
||
texture.frames[0]!.border = { left: 4, top: 3, right: 4, bottom: 3 };
|
||
texture.frames[0]!.fill = 'repeat';
|
||
expect(resolveTexture(fixture.project, sprite.props)?.slice).toEqual({
|
||
border: { left: 4, top: 3, right: 4, bottom: 3 },
|
||
fill: 'repeat',
|
||
sourceW: 32,
|
||
sourceH: 24,
|
||
});
|
||
delete texture.frames[0]!.border;
|
||
delete texture.frames[0]!.fill;
|
||
|
||
// A frame that is gone dangles like any other reference: no picture, not a wrong one.
|
||
texture.frames.length = 0;
|
||
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
|
||
});
|
||
|
||
it('yields nothing rather than throwing when the reference cannot be used', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
const sprite = findAsset(fixture.project, fixture.spriteId)!;
|
||
|
||
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
|
||
sprite.props.texture = 'does-not-exist';
|
||
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
|
||
// Pointing at an asset that is not a texture at all.
|
||
sprite.props.texture = fixture.sceneId;
|
||
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
|
||
// A texture whose upload never produced a file.
|
||
sprite.props.texture = texture.id;
|
||
texture.file = '';
|
||
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
|
||
});
|
||
|
||
it('survives the round trip, and a hand-written file name does not', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
findAsset(fixture.project, fixture.spriteId)!.props.texture = texture.id;
|
||
|
||
const loaded = roundTrip(fixture.project);
|
||
const reloaded = findAsset(loaded, texture.id) as TextureAsset;
|
||
expect(reloaded.file).toBe(FILE);
|
||
expect(reloaded.width).toBe(64);
|
||
expect(resolveTexture(loaded, findAsset(loaded, fixture.spriteId)!.props)?.file).toBe(FILE);
|
||
|
||
const tampered = deserializeProject({
|
||
assets: [{ id: 't', type: 'texture', name: 'X', file: '/etc/passwd', width: -3, height: 'big' }],
|
||
});
|
||
const dropped = findAsset(tampered, 't') as TextureAsset;
|
||
expect(dropped.file).toBe('');
|
||
expect(dropped.width).toBe(0);
|
||
expect(dropped.height).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('CreateTextureCommand', () => {
|
||
it('adds and removes the asset', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
expect(findAsset(fixture.project, texture.id)).toBeDefined();
|
||
|
||
fixture.stack.undo();
|
||
expect(findAsset(fixture.project, texture.id)).toBeUndefined();
|
||
fixture.stack.redo();
|
||
expect((findAsset(fixture.project, texture.id) as TextureAsset).file).toBe(FILE);
|
||
});
|
||
|
||
it('refuses a file name that was never uploaded', () => {
|
||
const fixture = makeSpriteScene();
|
||
expect(() =>
|
||
fixture.stack.execute(new CreateTextureCommand('Bad', { file: 'bricks.png', width: 1, height: 1 })),
|
||
).toThrow();
|
||
});
|
||
});
|
||
|
||
describe('findPropertyUsages', () => {
|
||
it('finds a texture that is worn but never placed', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
const sprite = findAsset(fixture.project, fixture.spriteId)!;
|
||
sprite.props.texture = texture.id;
|
||
const objectId = place(fixture, fixture.spriteId, { x: 0, y: 0 });
|
||
|
||
// A texture is never placed, so `findUsages` is blind to it – hence this function.
|
||
expect(findUsages(fixture.project, texture.id)).toHaveLength(0);
|
||
|
||
const usages = findPropertyUsages(fixture.project, texture.id);
|
||
expect(usages).toHaveLength(1);
|
||
expect(usages[0]).toMatchObject({ on: 'asset', key: 'texture' });
|
||
|
||
fixture.object(objectId).overrides.texture = texture.id;
|
||
expect(findPropertyUsages(fixture.project, texture.id)).toHaveLength(2);
|
||
expect(findPropertyUsages(fixture.project, texture.id).some((u) => u.on === 'object')).toBe(true);
|
||
});
|
||
|
||
it('counts sprites wearing a frame of the texture, not just the texture itself', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
texture.frames.push({ id: 'f1', name: 'Idle', x: 0, y: 0, w: 8, h: 8 });
|
||
findAsset(fixture.project, fixture.spriteId)!.props.texture = 'f1';
|
||
|
||
// The asset id alone under-reports sliced sheets, so deleting one asks about every id it owns.
|
||
expect(findPropertyUsages(fixture.project, texture.id)).toHaveLength(0);
|
||
expect(findPropertyUsages(fixture.project, textureRefIds(texture))).toHaveLength(1);
|
||
});
|
||
|
||
it('ignores unrelated ids and an empty reference', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
findAsset(fixture.project, fixture.spriteId)!.props.texture = texture.id;
|
||
|
||
expect(findPropertyUsages(fixture.project, 'other')).toHaveLength(0);
|
||
// An empty reference must not report every sprite that has no texture.
|
||
expect(findPropertyUsages(fixture.project, '')).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe('slicing a sheet', () => {
|
||
it('cuts a grid in reading order and derives the cell size from the picture', () => {
|
||
const rects = sliceFrames(64, 64, { ...DEFAULT_SLICE, columns: 2, rows: 2 });
|
||
expect(rects).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 },
|
||
]);
|
||
});
|
||
|
||
it('accounts for an offset and for spacing between cells', () => {
|
||
// 2 columns with a 2 px border and 4 px between them: (64 − 2 − 4) / 2 = 29 px cells.
|
||
const rects = sliceFrames(64, 64, {
|
||
...DEFAULT_SLICE,
|
||
columns: 2,
|
||
rows: 1,
|
||
offsetX: 2,
|
||
spacingX: 4,
|
||
});
|
||
expect(rects).toEqual([
|
||
{ x: 2, y: 0, w: 29, h: 64 },
|
||
{ x: 35, y: 0, w: 29, h: 64 },
|
||
]);
|
||
});
|
||
|
||
it('refuses rather than guessing when the numbers cannot describe a grid', () => {
|
||
// An unmeasured picture, more cells than pixels, and the values a form can produce.
|
||
expect(sliceFrames(0, 0, DEFAULT_SLICE)).toEqual([]);
|
||
expect(sliceFrames(4, 4, { ...DEFAULT_SLICE, columns: 8, rows: 8 })).toEqual([]);
|
||
expect(sliceFrames(64, 64, { ...DEFAULT_SLICE, columns: 0 })).toEqual([]);
|
||
// NaN passes every `<` comparison, so it has to be rejected in its own right.
|
||
expect(sliceFrames(64, 64, { ...DEFAULT_SLICE, columns: NaN })).toEqual([]);
|
||
expect(sliceFrames(64, 64, { ...DEFAULT_SLICE, spacingX: NaN })).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('frame names', () => {
|
||
const sheet = (...names: string[]): TextureAsset => ({
|
||
id: 't',
|
||
type: 'texture',
|
||
name: 'Sheet',
|
||
props: {},
|
||
file: '',
|
||
width: 64,
|
||
height: 64,
|
||
frames: names.map((name, i) => ({ id: `f${i}`, name, x: 0, y: 0, w: 1, h: 1 })),
|
||
});
|
||
|
||
it('numbers a clashing name from 2, the way asset names are numbered', () => {
|
||
expect(uniqueFrameName(sheet(), 'Idle')).toBe('Idle');
|
||
expect(uniqueFrameName(sheet('Idle'), 'Idle')).toBe('Idle 2');
|
||
expect(uniqueFrameName(sheet('Idle', 'Idle 2'), 'Idle')).toBe('Idle 3');
|
||
// A frame keeps its own name available, so renaming it to itself is not a clash.
|
||
expect(uniqueFrameName(sheet('Idle'), 'Idle', 'f0')).toBe('Idle');
|
||
});
|
||
|
||
it('numbers a whole slice from 1 and steps over names already taken', () => {
|
||
expect(nextFrameNames(sheet(), 3)).toEqual(['Frame 1', 'Frame 2', 'Frame 3']);
|
||
// Re-slicing a sheet that keeps its frames must not append a number to a number.
|
||
expect(nextFrameNames(sheet('Frame 2'), 3)).toEqual(['Frame 1', 'Frame 3', 'Frame 4']);
|
||
});
|
||
});
|