Files
feuerwerk/tests/core/tilemap.test.ts
T
2026-09-18 06:41:47 +02:00

434 lines
16 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 } from '../../src/core/commands/autotileCommands';
import {
AddTilemapLayerCommand,
MoveTilemapLayerCommand,
PaintTilesCommand,
RemoveTilemapLayerCommand,
RenameTilemapLayerCommand,
SetTilemapLayerHiddenCommand,
} from '../../src/core/commands/tilemapCommands';
import type { TilemapAsset } from '../../src/core/model/Asset';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
import { AUTOTILE_KIND_KEY } from '../../src/core/model/autotile';
import { findAsset, type Project } from '../../src/core/model/Project';
import {
cellAt,
cellAtPoint,
cellKey,
cellOrigin,
cellsAlong,
cellsInRect,
inGrid,
layerTiles,
neighboursOf,
TILEMAP_COLUMNS_KEY,
TILEMAP_ROWS_KEY,
TILEMAP_TILE_KEY,
tilemapAutotileIds,
tilemapSize,
} from '../../src/core/model/tilemap';
import { tilemapsUsing } from '../../src/core/model/usages';
import { tileMesh, tilesByAutotile } from '../../src/viewport/tilemapMesh';
import { makeProject, roundTrip } from '../helpers';
const FILE = `${'a1b2c3d4'.repeat(8)}.png`;
/** A tilemap with a floor autotile to paint with, both sized like the RPG-Maker sheets. */
function makeTilemap(kind: 'floor' | 'wall' = 'floor') {
const fixture = makeProject();
const texture = new CreateTextureCommand('Outside', {
file: FILE,
width: 96,
height: kind === 'floor' ? 144 : 96,
});
fixture.stack.execute(texture);
const autotile = new CreateAssetCommand('autotile', 'Grass');
fixture.stack.execute(autotile);
fixture.stack.execute(new SetAssetPropertyCommand(autotile.asset.id, AUTOTILE_KIND_KEY, kind));
fixture.stack.execute(new AddAutotilePictureCommand(autotile.asset.id, texture.asset.id));
const tilemap = new CreateAssetCommand('tilemap', 'Ground');
fixture.stack.execute(tilemap);
const assetOf = () => findAsset(fixture.project, tilemap.asset.id) as TilemapAsset;
return {
...fixture,
autotileId: autotile.asset.id,
tilemapId: tilemap.asset.id,
assetOf,
layerId: () => assetOf().layers[0]!.id,
paint: (cells: { x: number; y: number }[], autotileId = autotile.asset.id) =>
fixture.stack.execute(
new PaintTilesCommand(
tilemap.asset.id,
assetOf().layers[0]!.id,
cells.map((at) => ({ ...at, autotileId })),
),
),
};
}
function cellsOf(project: Project, assetId: string): Record<string, string> {
const asset = findAsset(project, assetId) as TilemapAsset;
return asset.layers[0]!.cells;
}
describe('the tilemap asset', () => {
it('is placed, unlike the autotiles it draws with', () => {
expect(isPlaceableType('tilemap')).toBe(true);
expect(isPlaceableType('autotile')).toBe(false);
});
it('starts with one layer and no cells', () => {
const f = makeTilemap();
expect(f.assetOf().layers).toHaveLength(1);
expect(f.assetOf().layers[0]!.cells).toEqual({});
});
it('is as big as its grid says, centred on its position', () => {
const f = makeTilemap();
const props = f.assetOf().props;
expect(tilemapSize(props)).toEqual({ w: 20 * 48, h: 15 * 48 });
expect(cellOrigin(props, 0, 0)).toEqual({ x: -480, y: -360 });
// The centre of the map is the corner of the four middle cells.
expect(cellAtPoint(props, 0, 0)).toEqual({ x: 10, y: 7 });
expect(cellAtPoint(props, -480, -360)).toEqual({ x: 0, y: 0 });
expect(cellAtPoint(props, -1, -1)).toEqual({ x: 9, y: 7 });
});
it('follows the grid and the cell size when they change', () => {
const f = makeTilemap();
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 4));
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 3));
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_TILE_KEY, 10));
expect(tilemapSize(f.assetOf().props)).toEqual({ w: 40, h: 30 });
expect(inGrid(f.assetOf().props, 3, 2)).toBe(true);
expect(inGrid(f.assetOf().props, 4, 2)).toBe(false);
expect(inGrid(f.assetOf().props, -1, 0)).toBe(false);
});
});
describe('painting a tilemap', () => {
it('paints, erases and takes both back as one step', () => {
const f = makeTilemap();
f.paint([
{ x: 1, y: 1 },
{ x: 2, y: 1 },
]);
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '1,1': f.autotileId, '2,1': f.autotileId });
f.stack.execute(new PaintTilesCommand(f.tilemapId, f.layerId(), [{ x: 1, y: 1, autotileId: '' }]));
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '2,1': f.autotileId });
f.stack.undo();
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '1,1': f.autotileId, '2,1': f.autotileId });
f.stack.undo();
expect(cellsOf(f.project, f.tilemapId)).toEqual({});
});
it('puts back what a stroke painted over, not merely empty', () => {
const f = makeTilemap();
const other = new CreateAssetCommand('autotile', 'Sand');
f.stack.execute(other);
f.paint([{ x: 3, y: 3 }]);
f.paint([{ x: 3, y: 3 }], other.asset.id);
expect(cellAt(f.assetOf().layers[0]!, 3, 3)).toBe(other.asset.id);
f.stack.undo();
expect(cellAt(f.assetOf().layers[0]!, 3, 3)).toBe(f.autotileId);
});
it('knows when a stroke would change nothing', () => {
const f = makeTilemap();
f.paint([{ x: 0, y: 0 }]);
const layer = f.assetOf().layers[0]!;
expect(PaintTilesCommand.changes(layer, [{ x: 0, y: 0, autotileId: f.autotileId }])).toBe(false);
expect(PaintTilesCommand.changes(layer, [{ x: 0, y: 0, autotileId: '' }])).toBe(true);
expect(PaintTilesCommand.changes(layer, [{ x: 5, y: 5, autotileId: f.autotileId }])).toBe(true);
});
it('covers the cells between two pointer events', () => {
expect(cellsAlong({ x: 0, y: 0 }, { x: 3, y: 0 })).toEqual([
{ x: 1, y: 0 },
{ x: 2, y: 0 },
{ x: 3, y: 0 },
]);
expect(cellsAlong({ x: 0, y: 0 }, { x: 2, y: 2 })).toEqual([
{ x: 1, y: 1 },
{ x: 2, y: 2 },
]);
expect(cellsAlong({ x: 4, y: 2 }, { x: 4, y: 2 })).toEqual([{ x: 4, y: 2 }]);
});
it('clips a rectangle to the grid', () => {
const f = makeTilemap();
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 3));
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 2));
const cells = cellsInRect(f.assetOf().props, { x: -2, y: 1 }, { x: 9, y: 9 });
expect(cells).toEqual([
{ x: 0, y: 1 },
{ x: 1, y: 1 },
{ x: 2, y: 1 },
]);
});
});
describe('what a painted cell shows', () => {
it('joins only cells holding the same autotile', () => {
const f = makeTilemap();
const other = new CreateAssetCommand('autotile', 'Sand');
f.stack.execute(other);
f.paint([
{ x: 5, y: 5 },
{ x: 6, y: 5 },
]);
f.paint([{ x: 5, y: 4 }], other.asset.id);
const layer = f.assetOf().layers[0]!;
const props = f.assetOf().props;
const around = neighboursOf(props, layer, 5, 5, f.autotileId);
expect(around.e).toBe(true);
// The cell above holds a *different* autotile, so this one closes against it.
expect(around.n).toBe(false);
expect(around.w).toBe(false);
});
it('counts anything outside the grid as empty', () => {
const f = makeTilemap();
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 2));
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 2));
f.paint([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
]);
const asset = f.assetOf();
const around = neighboursOf(asset.props, asset.layers[0]!, 1, 1, f.autotileId);
expect(around.n).toBe(true);
expect(around.w).toBe(true);
expect(around.e).toBe(false);
expect(around.s).toBe(false);
});
it('gives a lone cell the four convex corners and a joined one the inside', () => {
const f = makeTilemap();
f.paint([{ x: 2, y: 2 }]);
const asset = f.assetOf();
const lone = layerTiles(f.project, asset.props, asset.layers[0]!)[0]!;
// The block's convex corners, as `FLOOR_CORNERS` names them.
expect(lone.quadrants).toEqual([
{ x: 0, y: 2 },
{ x: 3, y: 2 },
{ x: 0, y: 5 },
{ x: 3, y: 5 },
]);
f.paint([
{ x: 1, y: 1 },
{ x: 2, y: 1 },
{ x: 3, y: 1 },
{ x: 1, y: 2 },
{ x: 3, y: 2 },
{ x: 1, y: 3 },
{ x: 2, y: 3 },
{ x: 3, y: 3 },
]);
const after = layerTiles(f.project, f.assetOf().props, f.assetOf().layers[0]!);
const middle = after.find((tile) => tile.x === 2 && tile.y === 2)!;
expect(middle.quadrants).toEqual([
{ x: 2, y: 4 },
{ x: 1, y: 4 },
{ x: 2, y: 3 },
{ x: 1, y: 3 },
]);
});
it('leaves out cells outside the grid and cells naming nothing', () => {
const f = makeTilemap();
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 2));
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 2));
f.paint([
{ x: 0, y: 0 },
{ x: 5, y: 0 },
]);
f.stack.execute(new PaintTilesCommand(f.tilemapId, f.layerId(), [{ x: 1, y: 1, autotileId: 'gone' }]));
const asset = f.assetOf();
const tiles = layerTiles(f.project, asset.props, asset.layers[0]!);
expect(tiles.map((t) => `${t.x},${t.y}`)).toEqual(['0,0']);
// The cell naming a deleted autotile is kept in the model – an undo may bring it back.
expect(asset.layers[0]!.cells[cellKey(1, 1)]).toBe('gone');
});
it('reads the same tiles twice, in reading order', () => {
const f = makeTilemap();
f.paint([
{ x: 4, y: 2 },
{ x: 1, y: 1 },
{ x: 2, y: 1 },
]);
const asset = f.assetOf();
const order = layerTiles(f.project, asset.props, asset.layers[0]!).map((t) => `${t.x},${t.y}`);
expect(order).toEqual(['1,1', '2,1', '4,2']);
});
});
describe('the layers of a tilemap', () => {
it('adds, renames, reorders, hides and removes, each with an undo', () => {
const f = makeTilemap();
const add = new AddTilemapLayerCommand(f.tilemapId);
f.stack.execute(add);
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Layer 2']);
f.stack.execute(new RenameTilemapLayerCommand(f.tilemapId, add.layerId, 'Roofs'));
expect(f.assetOf().layers[1]!.name).toBe('Roofs');
f.stack.execute(new MoveTilemapLayerCommand(f.tilemapId, 1, 0));
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Roofs', 'Layer 1']);
f.stack.undo();
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Roofs']);
f.stack.execute(new SetTilemapLayerHiddenCommand(f.tilemapId, add.layerId, true));
expect(f.assetOf().layers[1]!.hidden).toBe(true);
f.stack.undo();
expect(f.assetOf().layers[1]!.hidden).toBeUndefined();
f.stack.execute(new RemoveTilemapLayerCommand(f.tilemapId, add.layerId));
expect(f.assetOf().layers).toHaveLength(1);
f.stack.undo();
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Roofs']);
});
it('keeps the last layer, because there has to be one to paint on', () => {
const f = makeTilemap();
expect(() => f.stack.execute(new RemoveTilemapLayerCommand(f.tilemapId, f.layerId()))).toThrow(
'A tilemap keeps at least one layer',
);
expect(f.assetOf().layers).toHaveLength(1);
});
it('takes the cells of a removed layer with it and brings them back', () => {
const f = makeTilemap();
const add = new AddTilemapLayerCommand(f.tilemapId);
f.stack.execute(add);
f.stack.execute(
new PaintTilesCommand(f.tilemapId, add.layerId, [{ x: 2, y: 2, autotileId: f.autotileId }]),
);
f.stack.execute(new RemoveTilemapLayerCommand(f.tilemapId, add.layerId));
expect(f.assetOf().layers).toHaveLength(1);
f.stack.undo();
expect(f.assetOf().layers[1]!.cells).toEqual({ '2,2': f.autotileId });
});
});
describe('a tilemap in the document', () => {
it('survives a round trip with its layers and cells', () => {
const f = makeTilemap();
const add = new AddTilemapLayerCommand(f.tilemapId);
f.stack.execute(add);
f.stack.execute(new SetTilemapLayerHiddenCommand(f.tilemapId, add.layerId, true));
f.paint([{ x: 1, y: 2 }]);
const back = roundTrip(f.project);
const asset = findAsset(back, f.tilemapId) as TilemapAsset;
expect(asset.layers).toHaveLength(2);
expect(asset.layers[0]!.cells).toEqual({ '1,2': f.autotileId });
expect(asset.layers[1]!.hidden).toBe(true);
});
it('drops cells that are not a pair of whole numbers, and doubled layers', () => {
const f = makeTilemap();
const asset = f.assetOf();
asset.layers[0]!.cells['nonsense'] = f.autotileId;
asset.layers[0]!.cells['1.5,2'] = f.autotileId;
asset.layers[0]!.cells['3,3'] = f.autotileId;
asset.layers.push({ ...asset.layers[0]!, name: 'Twin' });
const back = findAsset(roundTrip(f.project), f.tilemapId) as TilemapAsset;
expect(back.layers).toHaveLength(1);
expect(back.layers[0]!.cells).toEqual({ '3,3': f.autotileId });
});
it('counts as a use of the autotiles painted on it', () => {
const f = makeTilemap();
expect(tilemapsUsing(f.project, [f.autotileId])).toHaveLength(0);
f.paint([{ x: 0, y: 0 }]);
expect(tilemapsUsing(f.project, [f.autotileId]).map((a) => a.id)).toEqual([f.tilemapId]);
expect(tilemapAutotileIds(f.assetOf())).toEqual([f.autotileId]);
});
});
describe('the mesh a layer becomes', () => {
const sheet = { quad: 24, block: { x: 0, y: 0, w: 96, h: 144 }, textureWidth: 96, textureHeight: 144 };
it('is one mesh per autotile, not one per cell', () => {
const f = makeTilemap();
const other = new CreateAssetCommand('autotile', 'Sand');
f.stack.execute(other);
f.paint([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 2, y: 0 },
]);
f.paint([{ x: 4, y: 0 }], other.asset.id);
const asset = f.assetOf();
const groups = tilesByAutotile(layerTiles(f.project, asset.props, asset.layers[0]!));
expect(groups.size).toBe(2);
expect(groups.get(f.autotileId)).toHaveLength(3);
});
it('builds four quads per cell, at the cell it belongs to', () => {
const tiles = [
{
x: 1,
y: 0,
autotileId: 'a',
quadrants: [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
] as [
{ x: number; y: number },
{ x: number; y: number },
{ x: number; y: number },
{ x: number; y: number },
],
},
];
const { positions, uvs, indices } = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: false });
expect(positions).toHaveLength(4 * 4 * 2);
expect(indices).toHaveLength(4 * 6);
// The cell starts at x = 48, and its top-left quarter is 24 wide.
expect([positions[0], positions[1]]).toEqual([48, 0]);
expect([positions[2], positions[3]]).toEqual([72, 0]);
// Quadrant (0, 0) of a 96 × 144 sheet cut into 24 px quarters is the first quarter of the uvs.
expect([uvs[0], uvs[1]]).toEqual([0, 0]);
expect([uvs[2], uvs[3]]).toEqual([24 / 96, 0]);
});
it('pulls the uvs half a texel in when the picture is sampled smoothly', () => {
const tiles = [
{
x: 0,
y: 0,
autotileId: 'a',
quadrants: [
{ x: 0, y: 0 },
{ x: 0, y: 0 },
{ x: 0, y: 0 },
{ x: 0, y: 0 },
] as [
{ x: number; y: number },
{ x: number; y: number },
{ x: number; y: number },
{ x: number; y: number },
],
},
];
const hard = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: false });
const soft = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: true });
expect(hard.uvs[0]).toBe(0);
expect(soft.uvs[0]).toBeCloseTo(0.5 / 96, 6);
// The positions are untouched: only what is sampled moves, never where it lands.
expect([...soft.positions]).toEqual([...hard.positions]);
});
});