Files
feuerwerk/tests/core/tile.test.ts
T

501 lines
21 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 },
]);
});
});
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');
});
/**
* #131 renamed the asset and made a plain tile the default. A project saved before that holds
* `autotile` assets, and one of them without a stored kind meant a floor block: it has to open as
* the same floor, not turn into a plain tile.
*/
it('reads an autotile of an older project as the tile it was', () => {
const raw = {
id: 'p',
assets: [
{ id: 's', type: 'scene', name: 'Main', props: {}, objects: [] },
{ id: 'a', type: 'autotile', name: 'Old grass', props: {}, pictures: ['x'] },
{ id: 'b', type: 'autotile', name: 'Old wall', props: { kind: 'wall', fps: 2 }, pictures: [] },
{ id: 'c', type: 'tile', name: 'New', props: {}, pictures: [] },
],
};
const project = deserializeProject(raw);
const byId = (id: string) => project.assets.find((a): a is TileAsset => a.id === id && a.type === 'tile');
expect(byId('a')?.pictures).toEqual(['x']);
expect(tileKind(byId('a')!)).toBe('floor');
expect(tileKind(byId('b')!)).toBe('wall');
expect(byId('b')?.props.fps).toBe(2);
expect(tileKind(byId('c')!)).toBe('none');
expect(project.assets.some((a) => (a.type as string) === 'autotile')).toBe(false);
});
});
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;
}