Files
feuerwerk/tests/core/tilemap.test.ts
T
adminandClaude Opus 5 bbbf34109b
game-maker / gate (push) Successful in 2m20s
game-maker / suite (push) Successful in 8m25s
Lists duplicate, rename and edit values right in their rows
Name, opacity and Z each sat in a separate field below or beside
the list. You had to select a row first and then look elsewhere to
change it. Duplicating was a separate button or not possible at all.
Now the row itself carries everything.

- SortableList gets three optional callbacks. onDuplicate shows a
  copy button between the eye and the delete button. onRename opens
  a text field on double click, Enter or a click outside commits,
  Esc cancels, an empty name is ignored. value puts a NumberField
  into the row.
- Dragging on the number changes the value and never moves the row.
  A whole drag is one undo step, the canvas follows live.
- Copy and delete only show on the hovered and the selected row.
  Their space stays reserved, so nothing jumps on hover.
- Pixel layers: copy right above with the same pixels on all
  frames, rename, opacity in percent. The Name and Opacity fields
  below the list are gone.
- Pixel frames: copy right after, replaces the Duplicate button.
- Tilemap layers: copy with cells, Z and visibility, rename, Z in
  the row. The Name and Z fields in the panel are gone.
- Vector shapes: copy at the same place and select it, rename.
- New in core: DuplicateVectorShapeCommand,
  DuplicateTilemapLayerCommand, withLayerDuplicated and copyName
  ("X copy", "X copy 2"). copyName lives in src/core/util/names.ts
  because the model never imports from commands/.

Findings:

- Double clicking a shape that is not selected yet only selects it.
  The first click moves the list up, so the second click misses the
  name. Module 47 selects the row first.

Tests:

- ./check.sh: 138 files, 2055 tests, CHECK EXIT 0 (before 2052).
- GM_STEPS=47,48 (fresh, twice): ALL PASSED, 91 checks (before 86).
- GM_STEPS=39,40,47,48: module 40 green, 47 died on the rename
  check, which was a test fault and is fixed.
- Counter probes: a constant copyName fails the shape and pixel
  layer tests. A dead copy hit fails the copy check in 47.
- Check total 1272 in README and wiki, computed (1266 + 6).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 06:02:47 +02:00

497 lines
18 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 } from '../../src/core/commands/tileCommands';
import {
AddTilemapLayerCommand,
DuplicateTilemapLayerCommand,
MoveTilemapLayerCommand,
PaintTilesCommand,
RemoveTilemapLayerCommand,
RenameTilemapLayerCommand,
SetTilemapLayerHiddenCommand,
SetTilemapLayerZCommand,
} from '../../src/core/commands/tilemapCommands';
import type { TilemapAsset } from '../../src/core/model/Asset';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
import { TILE_KIND_KEY, type TileKind } from '../../src/core/model/tile';
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,
tilemapTileIds,
tilemapSize,
} from '../../src/core/model/tilemap';
import { tilemapsUsing } from '../../src/core/model/usages';
import { DIMMED_LAYER_ALPHA, layerAlpha } from '../../src/viewport/tilemap';
import { groupByTile, tileMesh } 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: TileKind = 'floor') {
const fixture = makeProject();
const texture = new CreateTextureCommand('Outside', {
file: FILE,
width: 96,
height: kind === 'floor' ? 144 : 96,
});
fixture.stack.execute(texture);
const tile = new CreateAssetCommand('tile', 'Grass');
fixture.stack.execute(tile);
fixture.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_KIND_KEY, kind));
fixture.stack.execute(new AddTilePictureCommand(tile.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,
tileId: tile.asset.id,
tilemapId: tilemap.asset.id,
assetOf,
layerId: () => assetOf().layers[0]!.id,
paint: (cells: { x: number; y: number }[], tileId = tile.asset.id) =>
fixture.stack.execute(
new PaintTilesCommand(
tilemap.asset.id,
assetOf().layers[0]!.id,
cells.map((at) => ({ ...at, tileId })),
),
),
};
}
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 tiles it draws with', () => {
expect(isPlaceableType('tilemap')).toBe(true);
expect(isPlaceableType('tile')).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.tileId, '2,1': f.tileId });
f.stack.execute(new PaintTilesCommand(f.tilemapId, f.layerId(), [{ x: 1, y: 1, tileId: '' }]));
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '2,1': f.tileId });
f.stack.undo();
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '1,1': f.tileId, '2,1': f.tileId });
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('tile', '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.tileId);
});
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, tileId: f.tileId }])).toBe(false);
expect(PaintTilesCommand.changes(layer, [{ x: 0, y: 0, tileId: '' }])).toBe(true);
expect(PaintTilesCommand.changes(layer, [{ x: 5, y: 5, tileId: f.tileId }])).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 tile', () => {
const f = makeTilemap();
const other = new CreateAssetCommand('tile', '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.tileId);
expect(around.e).toBe(true);
// The cell above holds a *different* tile, 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.tileId);
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('draws a plain tile the same in every cell, whatever lies around it', () => {
const f = makeTilemap('none');
f.paint([
{ x: 1, y: 1 },
{ x: 2, y: 1 },
{ x: 1, y: 2 },
{ x: 5, y: 5 },
]);
const asset = f.assetOf();
const tiles = layerTiles(f.project, asset.props, asset.layers[0]!);
expect(tiles).toHaveLength(4);
for (const tile of tiles)
expect(tile.quadrants).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
]);
});
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, tileId: '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 tile 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('duplicates a layer right above it, with its cells, its z and a name of its own', () => {
const f = makeTilemap();
f.paint([{ x: 1, y: 1 }]);
f.stack.execute(new SetTilemapLayerZCommand(f.tilemapId, f.layerId(), 3));
f.stack.execute(new AddTilemapLayerCommand(f.tilemapId));
const copy = new DuplicateTilemapLayerCommand(f.tilemapId, f.layerId());
f.stack.execute(copy);
const layers = f.assetOf().layers;
expect(layers.map((l) => l.name)).toEqual(['Layer 1', 'Layer 1 copy', 'Layer 2']);
expect(layers[1]).toMatchObject({ id: copy.layerId, z: 3, cells: layers[0]!.cells });
// Cells of its own: painting on the copy leaves the original alone.
f.stack.execute(new PaintTilesCommand(f.tilemapId, copy.layerId, [{ x: 1, y: 1, tileId: '' }]));
expect(f.assetOf().layers[0]!.cells[cellKey(1, 1)]).toBeTruthy();
f.stack.undo();
f.stack.undo();
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Layer 2']);
});
it('renames a layer without the spaces around it, refuses a blank name and undoes to the old one', () => {
const f = makeTilemap();
f.stack.execute(new RenameTilemapLayerCommand(f.tilemapId, f.layerId(), ' Ground '));
expect(f.assetOf().layers[0]!.name).toBe('Ground');
expect(() => f.stack.execute(new RenameTilemapLayerCommand(f.tilemapId, f.layerId(), ' '))).toThrow(
'A layer needs a name',
);
expect(f.assetOf().layers[0]!.name).toBe('Ground');
f.stack.undo();
expect(f.assetOf().layers[0]!.name).toBe('Layer 1');
});
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, tileId: f.tileId }]));
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.tileId });
});
});
describe('how the scene view shows the layers next to the painted one', () => {
it('draws all of them whole, fades the others or leaves them out', () => {
expect(layerAlpha('a', null)).toBe(1);
expect(layerAlpha('b', { layerId: 'a', mode: 'all' })).toBe(1);
expect(layerAlpha('a', { layerId: 'a', mode: 'dim' })).toBe(1);
expect(layerAlpha('b', { layerId: 'a', mode: 'dim' })).toBe(DIMMED_LAYER_ALPHA);
expect(layerAlpha('a', { layerId: 'a', mode: 'solo' })).toBe(1);
expect(layerAlpha('b', { layerId: 'a', mode: 'solo' })).toBe(0);
});
});
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.tileId });
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.tileId;
asset.layers[0]!.cells['1.5,2'] = f.tileId;
asset.layers[0]!.cells['3,3'] = f.tileId;
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.tileId });
});
it('counts as a use of the tiles painted on it', () => {
const f = makeTilemap();
expect(tilemapsUsing(f.project, [f.tileId])).toHaveLength(0);
f.paint([{ x: 0, y: 0 }]);
expect(tilemapsUsing(f.project, [f.tileId]).map((a) => a.id)).toEqual([f.tilemapId]);
expect(tilemapTileIds(f.assetOf())).toEqual([f.tileId]);
});
});
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 tile, not one per cell', () => {
const f = makeTilemap();
const other = new CreateAssetCommand('tile', '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 = groupByTile(layerTiles(f.project, asset.props, asset.layers[0]!));
expect(groups.size).toBe(2);
expect(groups.get(f.tileId)).toHaveLength(3);
});
it('builds four quads per cell, at the cell it belongs to', () => {
const tiles = [
{
x: 1,
y: 0,
tileId: '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,
tileId: '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]);
});
});