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)
game-maker / gate (push) Successful in 1m50s
game-maker / suite (push) Successful in 5m59s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-17 21:35:29 +02:00
1 parent c17b2b5426
commit 1a9f07c4ac
24 files changed
+1797 -18

No files matched your search

+300
View File
@@ -0,0 +1,300 @@
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;
}