Files
feuerwerk/tests/core/tile.test.ts
T
adminandClaude Opus 5.5 c1a702bc39
game-maker / gate (push) Successful in 3m7s
game-maker / suite (push) Successful in 9m54s
Add a terrain autotile kind for 6x4 sheets like TD Plains
Sheets such as "TD - Tileset - Plains" draw an autotile as a 6 x 4
block: a 3 x 3 nine-slice, a vertical and a horizontal strip and a
single tile on the left, the inner corners on the right. The floor
kind only knew the RPG Maker 2 x 3 layout, so these sheets could not
be used as autotiles at all.

- New TileKind "terrain" with 16 px tiles in a 6 x 4 block. All 47
  neighbour combinations are resolved per quarter, so the renderer,
  the collision and the palette stay untouched.
- Whole tiles are used where the sheet draws them: the 16 side cases,
  the four single notches and the two diagonal pairs. Hand-drawn
  strips and single blocks therefore look as drawn.
- Only the missing cases are composed, for example an edge with a
  notch or two notches side by side. They take the side tile and put
  the notch quarters in.
- A notch also takes the quarter above or below from its notch tile
  when fill would stand there. Tall cliff notches keep their wall.
  On ground and stones fill meets fill, so nothing changes there.
- The label rule for floor and terrain now runs through one notch
  function. Floor still passes the MZ table test.

Not touched: the tile board only refits on open and on Fit. Switching
an open tile to a kind with another tile size leaves it small until
Fit is pressed. That was the case before.

Tests:
- ./check.sh: 156 files, 2290 tests, CHECK EXIT 0 (before 2283).
- e2e 39,40: ALL PASSED, 90 checks (before 86). 39j checks 47 tiles,
  pixels of single, notch, pair and composed tiles, and undo to floor.
- Counter-checks: without whole notch and pair tiles one unit test
  and one e2e check fail. Without the taken quarter one of each
  fails. Originals restored by cmp and rebuilt.
- Check sum: 1363 measured by CI plus 4 is 1367, computed only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 06:14:51 +02:00

597 lines
25 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 {
AddTilePictureCommand,
MoveTilePictureCommand,
RemoveTilePictureCommand,
} from '../../src/core/commands/tileCommands';
import { AddFrameCommand } from '../../src/core/commands/frameCommands';
import type { TileAsset } from '../../src/core/model/Asset';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
import {
autotileCombinations,
loneTileQuadrants,
type Neighbours,
neighboursAt,
SAMPLE_PATTERN,
tileQuadrants,
} from '../../src/core/model/autotile';
import {
pictureIndexAt,
quadrantRect,
readTile,
TILE_KIND_KEY,
type TileKind,
tileKind,
tilePlayback,
} from '../../src/core/model/tile';
import { deserializeProject } from '../../src/core/store/serialize';
import { findAsset, type Project } from '../../src/core/model/Project';
import { tilesUsing } 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 #118 was written from: 48 px tiles, 8 × 4 blocks. The
* tile is switched to a floor block, which is what most of these tests are about.
*/
function makeTile(width = 768, height = 576, kind: TileKind = 'floor') {
const fixture = makeProject();
const texture = new CreateTextureCommand('Outside', { file: FILE, width, height });
fixture.stack.execute(texture);
const tile = new CreateAssetCommand('tile', 'Grass');
fixture.stack.execute(tile);
if (kind !== 'none') fixture.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_KIND_KEY, kind));
return {
...fixture,
texture: texture.asset,
tileId: tile.asset.id,
assetOf: () => findAsset(fixture.project, tile.asset.id) as TileAsset,
};
}
/** Cuts one block out of the sheet and returns the frame id. */
function cutBlock(
fixture: ReturnType<typeof makeTile>,
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;
}
/**
* `Tilemap.FLOOR_AUTOTILE_TABLE` and `Tilemap.WALL_AUTOTILE_TABLE` of RPG Maker MZ, generated from
* its `js/rmmz_core.js` and not typed by hand. Each row is one tile as four quarters – top-left,
* top-right, bottom-left, bottom-right – in quadrant units from the block's top-left corner.
*/
// prettier-ignore
const MZ_FLOOR: readonly (readonly (readonly number[])[])[] = [
[[2, 4], [1, 4], [2, 3], [1, 3]],
[[2, 0], [1, 4], [2, 3], [1, 3]],
[[2, 4], [3, 0], [2, 3], [1, 3]],
[[2, 0], [3, 0], [2, 3], [1, 3]],
[[2, 4], [1, 4], [2, 3], [3, 1]],
[[2, 0], [1, 4], [2, 3], [3, 1]],
[[2, 4], [3, 0], [2, 3], [3, 1]],
[[2, 0], [3, 0], [2, 3], [3, 1]],
[[2, 4], [1, 4], [2, 1], [1, 3]],
[[2, 0], [1, 4], [2, 1], [1, 3]],
[[2, 4], [3, 0], [2, 1], [1, 3]],
[[2, 0], [3, 0], [2, 1], [1, 3]],
[[2, 4], [1, 4], [2, 1], [3, 1]],
[[2, 0], [1, 4], [2, 1], [3, 1]],
[[2, 4], [3, 0], [2, 1], [3, 1]],
[[2, 0], [3, 0], [2, 1], [3, 1]],
[[0, 4], [1, 4], [0, 3], [1, 3]],
[[0, 4], [3, 0], [0, 3], [1, 3]],
[[0, 4], [1, 4], [0, 3], [3, 1]],
[[0, 4], [3, 0], [0, 3], [3, 1]],
[[2, 2], [1, 2], [2, 3], [1, 3]],
[[2, 2], [1, 2], [2, 3], [3, 1]],
[[2, 2], [1, 2], [2, 1], [1, 3]],
[[2, 2], [1, 2], [2, 1], [3, 1]],
[[2, 4], [3, 4], [2, 3], [3, 3]],
[[2, 4], [3, 4], [2, 1], [3, 3]],
[[2, 0], [3, 4], [2, 3], [3, 3]],
[[2, 0], [3, 4], [2, 1], [3, 3]],
[[2, 4], [1, 4], [2, 5], [1, 5]],
[[2, 0], [1, 4], [2, 5], [1, 5]],
[[2, 4], [3, 0], [2, 5], [1, 5]],
[[2, 0], [3, 0], [2, 5], [1, 5]],
[[0, 4], [3, 4], [0, 3], [3, 3]],
[[2, 2], [1, 2], [2, 5], [1, 5]],
[[0, 2], [1, 2], [0, 3], [1, 3]],
[[0, 2], [1, 2], [0, 3], [3, 1]],
[[2, 2], [3, 2], [2, 3], [3, 3]],
[[2, 2], [3, 2], [2, 1], [3, 3]],
[[2, 4], [3, 4], [2, 5], [3, 5]],
[[2, 0], [3, 4], [2, 5], [3, 5]],
[[0, 4], [1, 4], [0, 5], [1, 5]],
[[0, 4], [3, 0], [0, 5], [1, 5]],
[[0, 2], [3, 2], [0, 3], [3, 3]],
[[0, 2], [1, 2], [0, 5], [1, 5]],
[[0, 4], [3, 4], [0, 5], [3, 5]],
[[2, 2], [3, 2], [2, 5], [3, 5]],
[[0, 2], [3, 2], [0, 5], [3, 5]],
[[0, 0], [1, 0], [0, 1], [1, 1]],
];
// prettier-ignore
const MZ_WALL: readonly (readonly (readonly number[])[])[] = [
[[2, 2], [1, 2], [2, 1], [1, 1]],
[[0, 2], [1, 2], [0, 1], [1, 1]],
[[2, 0], [1, 0], [2, 1], [1, 1]],
[[0, 0], [1, 0], [0, 1], [1, 1]],
[[2, 2], [3, 2], [2, 1], [3, 1]],
[[0, 2], [3, 2], [0, 1], [3, 1]],
[[2, 0], [3, 0], [2, 1], [3, 1]],
[[0, 0], [3, 0], [0, 1], [3, 1]],
[[2, 2], [1, 2], [2, 3], [1, 3]],
[[0, 2], [1, 2], [0, 3], [1, 3]],
[[2, 0], [1, 0], [2, 3], [1, 3]],
[[0, 0], [1, 0], [0, 3], [1, 3]],
[[2, 2], [3, 2], [2, 3], [3, 3]],
[[0, 2], [3, 2], [0, 3], [3, 3]],
[[2, 0], [3, 0], [2, 3], [3, 3]],
[[0, 0], [3, 0], [0, 3], [3, 3]],
];
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('tile')).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 whole point of #118: the tiles must come out of the block the way RPG Maker MZ takes them,
* or a seam runs through every joint. `MZ_FLOOR` and `MZ_WALL` below are its tables verbatim, so
* this test fails the moment the model drifts away from them.
*/
it('produces exactly the quarters of the RPG-Maker tables', () => {
const signature = (quadrants: readonly (readonly number[])[]) =>
quadrants.map((q) => `${q[0]},${q[1]}`).join(' ');
const mine = (kind: 'floor' | 'wall') =>
new Set(autotileCombinations(kind).map((c) => signature(c.quadrants.map((q) => [q.x, q.y]))));
// MZ's floor table has one row more than there are neighbourhoods: its last one is the block's
// top-left tile, which is the same picture as the lone tile of row 46.
expect(mine('floor')).toEqual(new Set(MZ_FLOOR.slice(0, 47).map(signature)));
expect(mine('wall')).toEqual(new Set(MZ_WALL.map(signature)));
});
it('takes a lone tile from the four convex corners and a surrounded one from the inside', () => {
// Row 46 of MZ's table.
expect(tileQuadrants('floor', around({}))).toEqual([
{ x: 0, y: 2 },
{ x: 3, y: 2 },
{ x: 0, y: 5 },
{ x: 3, y: 5 },
]);
// Row 0, and the reason a joint is seamless: the inside is taken turned by half a tile, so the
// top-left corner of the tile is the quarter at the bottom right of the block's inside.
const all = { n: true, e: true, s: true, w: true, ne: true, se: true, sw: true, nw: true };
expect(tileQuadrants('floor', all)).toEqual([
{ x: 2, y: 4 },
{ x: 1, y: 4 },
{ x: 2, y: 3 },
{ x: 1, y: 3 },
]);
});
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: 4 });
expect(north[1]).toEqual({ x: 3, y: 4 });
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 – which sits
// in the block's top *right* tile, not the top left one.
expect(withDiagonal[0]).toEqual({ x: 2, y: 4 });
expect(without[0]).toEqual({ x: 2, 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: 2, y: 2 });
// 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: 3, y: 0 });
});
it('shows each kind in the palette as its lone tile, the four convex corners (#139)', () => {
expect(loneTileQuadrants('floor')).toEqual([
{ x: 0, y: 2 },
{ x: 3, y: 2 },
{ x: 0, y: 5 },
{ x: 3, y: 5 },
]);
expect(loneTileQuadrants('wall')).toEqual([
{ x: 0, y: 0 },
{ x: 3, y: 0 },
{ x: 0, y: 3 },
{ x: 3, y: 3 },
]);
// A plain tile is its picture as it is, each corner its own quarter.
expect(loneTileQuadrants('none')).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
]);
});
});
/** The four quarters of the whole tile at tile column `tx`, row `ty`, written out as [x, y]. */
const whole = (tx: number, ty: number) => [
[2 * tx, 2 * ty],
[2 * tx + 1, 2 * ty],
[2 * tx, 2 * ty + 1],
[2 * tx + 1, 2 * ty + 1],
];
const pairs = (quadrants: { x: number; y: number }[]) => quadrants.map((q) => [q.x, q.y]);
const ALL_SIDES = { n: true, e: true, s: true, w: true };
const ALL_AROUND = { ...ALL_SIDES, ne: true, se: true, sw: true, nw: true };
describe('terrain block', () => {
it('produces 47 different tiles, all inside the 6 × 4 block and none from its two empty tiles', () => {
const terrain = autotileCombinations('terrain');
expect(terrain).toHaveLength(47);
for (const { quadrants } of terrain)
for (const q of quadrants) {
expect(q.x).toBeLessThan(12);
expect(q.y).toBeLessThan(8);
expect(q.x >= 8 && q.y >= 6).toBe(false);
}
});
it('takes each of the 16 ways the sides join as a whole tile of the 4 × 4 on the left', () => {
const drawn = new Set<string>();
for (let mask = 0; mask < 16; mask++) {
const sides = { n: !!(mask & 1), e: !!(mask & 2), s: !!(mask & 4), w: !!(mask & 8) };
const quadrants = pairs(
tileQuadrants('terrain', around({ ...sides, ne: true, se: true, sw: true, nw: true })),
);
const [x, y] = quadrants[0]!;
expect(quadrants).toEqual(whole(x! / 2, y! / 2));
expect(x).toBeLessThan(8);
drawn.add(`${x},${y}`);
}
expect(drawn.size).toBe(16);
expect(pairs(tileQuadrants('terrain', around({})))).toEqual(whole(0, 3));
expect(pairs(tileQuadrants('terrain', around({ n: true, s: true })))).toEqual(whole(0, 1));
expect(pairs(tileQuadrants('terrain', around({ e: true, w: true })))).toEqual(whole(2, 3));
expect(pairs(tileQuadrants('terrain', around(ALL_AROUND)))).toEqual(whole(2, 1));
expect(pairs(loneTileQuadrants('terrain'))).toEqual(whole(0, 3));
});
it('takes a single notch and the two diagonal pairs whole from the columns on the right', () => {
expect(pairs(tileQuadrants('terrain', around({ ...ALL_AROUND, se: false })))).toEqual(whole(4, 0));
expect(pairs(tileQuadrants('terrain', around({ ...ALL_AROUND, sw: false })))).toEqual(whole(5, 0));
expect(pairs(tileQuadrants('terrain', around({ ...ALL_AROUND, ne: false })))).toEqual(whole(4, 1));
expect(pairs(tileQuadrants('terrain', around({ ...ALL_AROUND, nw: false })))).toEqual(whole(5, 1));
expect(pairs(tileQuadrants('terrain', around({ ...ALL_SIDES, ne: true, sw: true })))).toEqual(
whole(4, 2),
);
expect(pairs(tileQuadrants('terrain', around({ ...ALL_SIDES, nw: true, se: true })))).toEqual(
whole(5, 2),
);
});
it('puts the notch into an edge tile and brings the quarter over or under it along', () => {
// Joined north, east and south, open to the west and the north-east: the left edge tile, with
// the top-right notch and, below it, the quarter of the same notch tile.
const edge = pairs(tileQuadrants('terrain', around({ n: true, e: true, s: true, se: true })));
expect(edge).toEqual([
[2, 2],
[9, 2],
[2, 3],
[9, 3],
]);
// Two notches side by side, the south-east and the south-west: each brings its top quarter.
const both = pairs(tileQuadrants('terrain', around({ ...ALL_SIDES, ne: true, nw: true })));
expect(both).toEqual([
[10, 0],
[9, 0],
[10, 1],
[9, 1],
]);
});
it('leaves an edge above a notch alone – only a plain inside quarter is brought along', () => {
// Open to the north: the top edge tile keeps its top half, only the south-east notch goes in.
const topEdge = pairs(tileQuadrants('terrain', around({ e: true, s: true, w: true, sw: true })));
expect(topEdge).toEqual([
[4, 0],
[5, 0],
[4, 1],
[9, 1],
]);
});
it('labels the notched corners like a floor does', () => {
const labels = autotileCombinations('terrain').map((c) => c.label);
expect(labels[0]).toBe('·');
expect(labels).toContain('NESW-ne,se,sw,nw');
expect(labels).toContain('NES-ne');
});
it('reads a 6 × 4 block as a terrain tile and says so when the block is the wrong shape', () => {
const fixture = makeTile(96, 192, 'terrain');
fixture.stack.execute(
new AddTilePictureCommand(fixture.tileId, cutBlock(fixture, { x: 0, y: 0, w: 96, h: 64 })),
);
const info = readTile(fixture.project, fixture.assetOf());
expect(info.problem).toBe('');
expect(info.tile).toBe(16);
const wrong = makeTile(96, 192, 'terrain');
wrong.stack.execute(new AddTilePictureCommand(wrong.tileId, wrong.texture.id));
expect(readTile(wrong.project, wrong.assetOf()).problem).toContain('terrain block is 6 × 4 tiles');
});
});
describe('tile asset', () => {
it('derives the tile size from the block and accepts a whole texture as one', () => {
const fixture = makeTile(96, 144);
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, fixture.texture.id));
const info = readTile(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 = makeTile();
const frame = cutBlock(fixture, FLOOR_BLOCK);
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, frame));
const info = readTile(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 = makeTile();
const wall = cutBlock(fixture, WALL_BLOCK);
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, wall));
const asFloor = readTile(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.tileId, TILE_KIND_KEY, 'wall'));
const asWall = readTile(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 = makeTile();
const big = cutBlock(fixture, FLOOR_BLOCK);
const small = cutBlock(fixture, { x: 0, y: 0, w: 32, h: 48 });
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, big));
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, small));
expect(readTile(fixture.project, fixture.assetOf()).problem).toContain('different sizes');
// A dangling id is named as missing rather than silently skipped.
fixture.assetOf().pictures = ['gone'];
expect(readTile(fixture.project, fixture.assetOf()).problem).toContain('no longer exist');
fixture.assetOf().pictures = [];
expect(readTile(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('tile pictures', () => {
it('adds, removes and reorders with undo', () => {
const fixture = makeTile();
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 AddTilePictureCommand(fixture.tileId, id));
expect(fixture.assetOf().pictures).toEqual([a, b, c]);
fixture.stack.execute(new MoveTilePictureCommand(fixture.tileId, 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 RemoveTilePictureCommand(fixture.tileId, 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 AddTilePictureCommand(fixture.tileId, 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 = makeTile();
const sprite = new CreateAssetCommand('sprite', 'Box');
fixture.stack.execute(sprite);
expect(() => new AddTilePictureCommand(fixture.tileId, sprite.asset.id).execute(fixture.project)).toThrow(
/not a texture or a frame/,
);
expect(() => new RemoveTilePictureCommand(fixture.tileId, 0).execute(fixture.project)).toThrow(
/no picture 1/,
);
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, fixture.texture.id));
expect(() => new MoveTilePictureCommand(fixture.tileId, 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 = makeTile();
const frame = cutBlock(fixture, FLOOR_BLOCK);
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, frame));
expect(tilesUsing(fixture.project, [frame]).map((a) => a.name)).toEqual(['Grass']);
expect(tilesUsing(fixture.project, ['somewhere else'])).toEqual([]);
});
});
describe('tile in the document', () => {
it('survives a save and keeps only what it can read', () => {
const fixture = makeTile();
const frame = cutBlock(fixture, FLOOR_BLOCK);
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, frame));
fixture.stack.execute(new SetAssetPropertyCommand(fixture.tileId, TILE_KIND_KEY, 'wall'));
const asset = tileOf(roundTrip(fixture.project));
expect(asset.pictures).toEqual([frame]);
expect(tileKind(asset)).toBe('wall');
// Junk in the list is dropped, and an unreadable kind or playback falls back to the default –
// for the kind that is the plain tile.
const raw = JSON.parse(JSON.stringify(fixture.project)) as {
assets: Record<string, unknown>[];
};
const stored = raw.assets.find((a) => a.type === 'tile')!;
stored.pictures = [frame, 7, '', null, 'gone'];
(stored.props as Record<string, unknown>).kind = 'diagonal';
(stored.props as Record<string, unknown>).playback = 'once';
const read = tileOf(roundTrip(raw as unknown as Project));
expect(read.pictures).toEqual([frame, 'gone']);
expect(tileKind(read)).toBe('none');
expect(tilePlayback(read)).toBe('loop');
});
// No migration any more (#137): `autotile`, the name before #131, is an unknown type like any other.
it('drops an asset of the old type autotile', () => {
const raw = {
id: 'p',
assets: [
{ id: 's', type: 'scene', name: 'Main', props: {}, objects: [] },
{ id: 'a', type: 'autotile', name: 'Old grass', props: { kind: 'floor' }, pictures: ['x'] },
{ id: 'c', type: 'tile', name: 'New', props: {}, pictures: [] },
],
};
expect(deserializeProject(raw).assets.map((a) => a.id)).toEqual(['s', 'c']);
});
});
describe('plain tile', () => {
it('is what a new tile is', () => {
const fixture = makeTile(96, 96, 'none');
expect(tileKind(fixture.assetOf())).toBe('none');
});
it('is one tile that ignores its neighbours', () => {
expect(autotileCombinations('none')).toHaveLength(1);
const lone = tileQuadrants('none', around({}));
const all = { n: true, e: true, s: true, w: true, ne: true, se: true, sw: true, nw: true };
// Every corner takes its own quarter of the picture – the picture drawn as it is.
expect(lone).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
]);
expect(tileQuadrants('none', all)).toEqual(lone);
expect(tileQuadrants('none', around({ n: true, w: true }))).toEqual(lone);
});
it('takes any square picture as the tile, an odd side too', () => {
const fixture = makeTile(768, 576, 'none');
const square = cutBlock(fixture, { x: 48, y: 0, w: 48, h: 48 });
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, square));
const info = readTile(fixture.project, fixture.assetOf());
expect(info.problem).toBe('');
expect(info.tile).toBe(48);
expect(quadrantRect(info.pictures[0]!, info.quad, { x: 1, y: 1 })).toEqual({
x: 72,
y: 24,
w: 24,
h: 24,
});
const odd = makeTile(25, 25, 'none');
odd.stack.execute(new AddTilePictureCommand(odd.tileId, odd.texture.id));
expect(readTile(odd.project, odd.assetOf()).tile).toBe(25);
});
it('complains about a picture that is not square', () => {
const fixture = makeTile(96, 144, 'none');
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, fixture.texture.id));
const info = readTile(fixture.project, fixture.assetOf());
expect(info.problem).toContain('one square tile');
expect(info.problem).toContain('96 × 144');
expect(info.tile).toBe(0);
});
});
function tileOf(project: Project): TileAsset {
const asset = project.assets.find((a): a is TileAsset => a.type === 'tile');
if (!asset) throw new Error('no tile in the project');
return asset;
}