Files
feuerwerk/tests/core/autotile.test.ts
T
adminandClaude Opus 5 1a9f07c4ac
game-maker / gate (push) Successful in 1m50s
game-maker / suite (push) Successful in 5m59s
Autotiles are a new asset type with a tab that shows every tile of the floor (2x3) or wall (2x2) block and an animated sample pattern (#118)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:35:29 +02:00

301 lines
13 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,
SetAssetPropertyCommand,
} from '../../src/core/commands/assetCommands';
import {
AddAutotilePictureCommand,
MoveAutotilePictureCommand,
RemoveAutotilePictureCommand,
} from '../../src/core/commands/autotileCommands';
import { AddFrameCommand } from '../../src/core/commands/frameCommands';
import type { AutotileAsset } from '../../src/core/model/Asset';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
import {
AUTOTILE_KIND_KEY,
autotileCombinations,
autotileKind,
autotilePlayback,
type Neighbours,
neighboursAt,
pictureIndexAt,
quadrantRect,
readAutotile,
SAMPLE_PATTERN,
tileQuadrants,
} from '../../src/core/model/autotile';
import { findAsset, type Project } from '../../src/core/model/Project';
import { autotilesUsing } from '../../src/core/model/usages';
import { makeProject, roundTrip } from '../helpers';
const FILE = `${'a1b2c3d4'.repeat(8)}.png`;
/** A sheet the size of the RPG-Maker sheets the issue was written from: 48 px tiles, 8 × 4 blocks. */
function makeAutotile(width = 768, height = 576) {
const fixture = makeProject();
const texture = new CreateTextureCommand('Outside', { file: FILE, width, height });
fixture.stack.execute(texture);
const autotile = new CreateAssetCommand('autotile', 'Grass');
fixture.stack.execute(autotile);
return {
...fixture,
texture: texture.asset,
autotileId: autotile.asset.id,
assetOf: () => findAsset(fixture.project, autotile.asset.id) as AutotileAsset,
};
}
/** Cuts one block out of the sheet and returns the frame id. */
function cutBlock(
fixture: ReturnType<typeof makeAutotile>,
rect: { x: number; y: number; w: number; h: number },
): string {
const command = new AddFrameCommand(fixture.texture.id, rect);
fixture.stack.execute(command);
return command.frameId;
}
const FLOOR_BLOCK = { x: 96, y: 0, w: 96, h: 144 };
const WALL_BLOCK = { x: 0, y: 0, w: 96, h: 96 };
function around(present: Partial<Neighbours>): Neighbours {
return { n: false, e: false, s: false, w: false, ne: false, se: false, sw: false, nw: false, ...present };
}
describe('autotile blocks', () => {
it('is a library asset like a texture: nothing places it, the tilemap of #128 does', () => {
expect(isPlaceableType('autotile')).toBe(false);
});
it('produces 47 tiles from a floor block and 16 from a wall, each of them different', () => {
const floor = autotileCombinations('floor');
const wall = autotileCombinations('wall');
expect(floor).toHaveLength(47);
expect(wall).toHaveLength(16);
const signature = (quadrants: { x: number; y: number }[]) =>
quadrants.map((q) => `${q.x},${q.y}`).join(' ');
expect(new Set(floor.map((c) => signature(c.quadrants))).size).toBe(47);
expect(new Set(wall.map((c) => signature(c.quadrants))).size).toBe(16);
});
/**
* The four corners of the block's bordered tile, its inside and its notches – the layout read off
* the `Outside_A1`–`A4` sheets. A wrong one of these is the seam #118 is about.
*/
it('takes a lone tile from the four convex corners and a surrounded one from the inside', () => {
expect(tileQuadrants('floor', around({}))).toEqual([
{ x: 0, y: 2 },
{ x: 3, y: 2 },
{ x: 0, y: 5 },
{ x: 3, y: 5 },
]);
const all = { n: true, e: true, s: true, w: true, ne: true, se: true, sw: true, nw: true };
expect(tileQuadrants('floor', all)).toEqual([
{ x: 1, y: 3 },
{ x: 2, y: 3 },
{ x: 1, y: 4 },
{ x: 2, y: 4 },
]);
});
it('draws a straight edge towards the side that is missing', () => {
// Only the north neighbour: the top half is inside, the bottom half is the lower border.
const north = tileQuadrants('floor', around({ n: true }));
expect(north[0]).toEqual({ x: 0, y: 3 });
expect(north[1]).toEqual({ x: 3, y: 3 });
expect(north[2]).toEqual({ x: 0, y: 5 });
expect(north[3]).toEqual({ x: 3, y: 5 });
});
it('puts a notch where both sides are there but the diagonal between them is not', () => {
const withDiagonal = tileQuadrants('floor', around({ n: true, w: true, nw: true }));
const without = tileQuadrants('floor', around({ n: true, w: true }));
// Only the corner the diagonal belongs to changes, and it changes into the notch.
expect(withDiagonal[0]).toEqual({ x: 1, y: 3 });
expect(without[0]).toEqual({ x: 0, y: 0 });
expect(without.slice(1)).toEqual(withDiagonal.slice(1));
});
it('ignores the diagonals on a wall, which has no notch to draw', () => {
const bare = tileQuadrants('wall', around({ n: true, w: true }));
const diagonal = tileQuadrants('wall', around({ n: true, w: true, nw: true }));
expect(bare).toEqual(diagonal);
expect(bare[0]).toEqual({ x: 1, y: 1 });
// Every quarter a wall can use lies in its own 2 × 2 block: four quadrant rows, no more.
for (const combination of autotileCombinations('wall'))
for (const quadrant of combination.quadrants) expect(quadrant.y).toBeLessThan(4);
});
it('labels a tile by the sides it joins and the corners it notches', () => {
const floor = autotileCombinations('floor');
expect(floor[0]?.label).toBe('·');
const surrounded = floor.find(
(c) => c.neighbours.n && c.neighbours.e && c.neighbours.s && c.neighbours.w,
);
expect(surrounded?.label).toBe('NESW-ne,se,sw,nw');
});
it('reads the sample map so that the hole in it asks for notches', () => {
// Row 2, column 4 sits right above the hole: joined to the north, open to the south.
const above = neighboursAt(SAMPLE_PATTERN, 2, 4);
expect(above.n).toBe(true);
expect(above.s).toBe(false);
expect(above.se).toBe(false);
// Row 5, column 3 is the cell below the hole's bottom-left corner: joined north and east,
// with the hole itself sitting in the north-east – exactly the case the notch is drawn for.
const corner = neighboursAt(SAMPLE_PATTERN, 5, 3);
expect([corner.n, corner.e, corner.ne]).toEqual([true, true, false]);
expect(tileQuadrants('floor', corner)[1]).toEqual({ x: 1, y: 0 });
});
});
describe('autotile asset', () => {
it('derives the tile size from the block and accepts a whole texture as one', () => {
const fixture = makeAutotile(96, 144);
fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, fixture.texture.id));
const info = readAutotile(fixture.project, fixture.assetOf());
expect(info.problem).toBe('');
expect(info.tile).toBe(48);
expect(info.quad).toBe(24);
expect(info.pictures).toHaveLength(1);
});
it('cuts a quarter tile out of the frame the picture names', () => {
const fixture = makeAutotile();
const frame = cutBlock(fixture, FLOOR_BLOCK);
fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, frame));
const info = readAutotile(fixture.project, fixture.assetOf());
expect(info.tile).toBe(48);
// The block starts at x = 96, so its third quadrant column starts 48 px further right.
expect(quadrantRect(info.pictures[0]!, info.quad, { x: 2, y: 5 })).toEqual({
x: 96 + 48,
y: 120,
w: 24,
h: 24,
});
});
it('complains about a block that does not fit the kind, without dropping it', () => {
const fixture = makeAutotile();
const wall = cutBlock(fixture, WALL_BLOCK);
fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, wall));
const asFloor = readAutotile(fixture.project, fixture.assetOf());
expect(asFloor.problem).toContain('floor block');
expect(asFloor.tile).toBe(0);
expect(fixture.assetOf().pictures).toEqual([wall]);
// The same picture is a wall block, so switching the kind is all it takes.
fixture.stack.execute(new SetAssetPropertyCommand(fixture.autotileId, AUTOTILE_KIND_KEY, 'wall'));
const asWall = readAutotile(fixture.project, fixture.assetOf());
expect(asWall.problem).toBe('');
expect(asWall.tile).toBe(48);
});
it('rejects an animation whose steps are not the same size, and a picture that is gone', () => {
const fixture = makeAutotile();
const big = cutBlock(fixture, FLOOR_BLOCK);
const small = cutBlock(fixture, { x: 0, y: 0, w: 32, h: 48 });
fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, big));
fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, small));
expect(readAutotile(fixture.project, fixture.assetOf()).problem).toContain('different sizes');
// A dangling id is named as missing rather than silently skipped.
fixture.assetOf().pictures = ['gone'];
expect(readAutotile(fixture.project, fixture.assetOf()).problem).toContain('no longer exist');
fixture.assetOf().pictures = [];
expect(readAutotile(fixture.project, fixture.assetOf()).problem).toContain('No picture yet');
});
it('steps through the pictures, loop and ping-pong, and holds still on a single one', () => {
// Three pictures at 4 fps: a quarter second each.
const loop = [0, 0.26, 0.51, 0.76, 1.01].map((t) => pictureIndexAt(3, 'loop', 4, t));
expect(loop).toEqual([0, 1, 2, 0, 1]);
const pingpong = [0, 0.26, 0.51, 0.76, 1.01, 1.26].map((t) => pictureIndexAt(3, 'pingpong', 4, t));
// Turns on the last picture, so the middle one is never shown twice in a row.
expect(pingpong).toEqual([0, 1, 2, 1, 0, 1]);
expect(pictureIndexAt(1, 'loop', 4, 99)).toBe(0);
expect(pictureIndexAt(3, 'loop', 0, 99)).toBe(0);
});
});
describe('autotile pictures', () => {
it('adds, removes and reorders with undo', () => {
const fixture = makeAutotile();
const a = cutBlock(fixture, FLOOR_BLOCK);
const b = cutBlock(fixture, { ...FLOOR_BLOCK, x: 192 });
const c = cutBlock(fixture, { ...FLOOR_BLOCK, x: 288 });
for (const id of [a, b, c]) fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, id));
expect(fixture.assetOf().pictures).toEqual([a, b, c]);
fixture.stack.execute(new MoveAutotilePictureCommand(fixture.autotileId, 2, 0));
expect(fixture.assetOf().pictures).toEqual([c, a, b]);
fixture.stack.undo();
expect(fixture.assetOf().pictures).toEqual([a, b, c]);
fixture.stack.execute(new RemoveAutotilePictureCommand(fixture.autotileId, 1));
expect(fixture.assetOf().pictures).toEqual([a, c]);
fixture.stack.undo();
expect(fixture.assetOf().pictures).toEqual([a, b, c]);
// Inserting somewhere other than the end, and taking it out again.
fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, c, 1));
expect(fixture.assetOf().pictures).toEqual([a, c, b, c]);
fixture.stack.undo();
expect(fixture.assetOf().pictures).toEqual([a, b, c]);
});
it('takes only textures and frames, and only places that exist', () => {
const fixture = makeAutotile();
const sprite = new CreateAssetCommand('sprite', 'Box');
fixture.stack.execute(sprite);
expect(() =>
new AddAutotilePictureCommand(fixture.autotileId, sprite.asset.id).execute(fixture.project),
).toThrow(/not a texture or a frame/);
expect(() => new RemoveAutotilePictureCommand(fixture.autotileId, 0).execute(fixture.project)).toThrow(
/no picture 1/,
);
fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, fixture.texture.id));
expect(() => new MoveAutotilePictureCommand(fixture.autotileId, 0, 3).execute(fixture.project)).toThrow(
/no place 4/,
);
});
it('counts as a use of the texture, so deleting it is not silent', () => {
const fixture = makeAutotile();
const frame = cutBlock(fixture, FLOOR_BLOCK);
fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, frame));
expect(autotilesUsing(fixture.project, [frame]).map((a) => a.name)).toEqual(['Grass']);
expect(autotilesUsing(fixture.project, ['somewhere else'])).toEqual([]);
});
});
describe('autotile in the document', () => {
it('survives a save and keeps only what it can read', () => {
const fixture = makeAutotile();
const frame = cutBlock(fixture, FLOOR_BLOCK);
fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, frame));
fixture.stack.execute(new SetAssetPropertyCommand(fixture.autotileId, AUTOTILE_KIND_KEY, 'wall'));
const asset = autotileOf(roundTrip(fixture.project));
expect(asset.pictures).toEqual([frame]);
expect(autotileKind(asset)).toBe('wall');
// Junk in the list is dropped, and an unreadable kind or playback falls back to the default.
const raw = JSON.parse(JSON.stringify(fixture.project)) as {
assets: Record<string, unknown>[];
};
const stored = raw.assets.find((a) => a.type === 'autotile')!;
stored.pictures = [frame, 7, '', null, 'gone'];
(stored.props as Record<string, unknown>).kind = 'diagonal';
(stored.props as Record<string, unknown>).playback = 'once';
const read = autotileOf(roundTrip(raw as unknown as Project));
expect(read.pictures).toEqual([frame, 'gone']);
expect(autotileKind(read)).toBe('floor');
expect(autotilePlayback(read)).toBe('loop');
});
});
function autotileOf(project: Project): AutotileAsset {
const asset = project.assets.find((a): a is AutotileAsset => a.type === 'autotile');
if (!asset) throw new Error('no autotile in the project');
return asset;
}