Empty a deleted tile's cells on every tilemap (#268)

Deleting a tile left the cells painted with it in the tilemaps. They
drew nothing, blocked nothing and did not show in the delete dialog,
but still read as a tile for "Tile at" and stopped a fill.

- DeleteAssetCommand empties the cells of a deleted tile on every
  layer of every tilemap in the same step. Undo puts the tile and its
  cells back together.
- The delete dialog counts them, for example "3 cell(s) on 1
  tilemap(s) are painted with it and become empty." (cellsPainted).
- presentTile treats a cell naming a missing tile as empty. "Tile at"
  (World.tileAt) and floodFill use it, so cells already in existing
  files read and fill as empty. There is no migration.
- The brush, the rectangle and the fill check the tile in hand. A
  tile deleted after it was picked no longer paints new invisible
  cells, the editor says "Choose a tile to paint with." instead.
- Unchanged on purpose: the picker still reports such a cell as
  naming a tile that no longer exists, and erasing removes it.

Tests:
- ./check.sh: 195 files, 2823 tests, CHECK EXIT 0 (+6).
- GM_STEPS=39,40: ALL PASSED, 100 checks (4 new in 40t).
- Full e2e sum is now 1590 (1586 confirmed by CI on round 8, plus 4).
- Counter-checks, all fail as expected: 5 in unit tests (delete
  without emptying, undo without cells, fill and "Tile at" without
  presentTile, wrong map count) and 3 in the browser (dialog without
  count, delete without emptying, brush without the check).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-29 19:00:22 +02:00
1 parent 14972d057f
commit 9b5eeaab57
11 files changed
+310 -38

No files matched your search

+17
View File
@@ -183,6 +183,23 @@ describe.each(COLLISION_SYSTEMS)('$system: a tile set in a run (#135)', ({ physi
});
});
describe('a cell naming a tile the project does not have (#268)', () => {
it('reads as empty in a run, the way it is drawn', () => {
const s = setup();
const layerId = s.assetOf().layers[0]!.id;
s.stack.execute(
new PaintTilesCommand(s.mapId, layerId, [
{ x: 1, y: 1, tileId: 'gone' },
{ x: 2, y: 1, tileId: s.wallId },
]),
);
const { world } = startWorld(s);
const map = world.findByObjectId(s.first)!;
expect(world.tileAt(map, '', { x: 1, y: 1 })).toBe('');
expect(world.tileAt(map, '', { x: 2, y: 1 })).toBe(s.wallId);
});
});
describe('a tile set in a run is drawn (#135)', () => {
const cellsDrawn = (tilemap: { layers: { tiles: readonly { x: number; y: number }[] }[] } | null) =>
tilemap?.layers[0]!.tiles.map(({ x, y }) => `${x},${y}`);
+29 -10
View File
@@ -3,8 +3,10 @@
* edge neighbours that hold the same tile – empty included – and nothing outside the grid.
*/
import { describe, expect, it } from 'vitest';
import { buildAsset } from '../../src/core/commands/assetCommands';
import type { TilemapLayer } from '../../src/core/model/Asset';
import { cellKey, floodFill } from '../../src/core/model/tilemap';
import { createEmptyProject } from '../../src/core/model/Project';
import { type Cell, cellKey, floodFill } from '../../src/core/model/tilemap';
/** A layer drawn as text: one row per line, `.` empty, any other letter the tile of that name. */
function layerOf(rows: string[]): TilemapLayer {
@@ -26,22 +28,29 @@ function shown(rows: string[], filled: { x: number; y: number }[]): string[] {
const GRID = { columns: 5, rows: 4 };
/** A project with a tile for each of these letters, so a cell naming one of them names a real tile. */
const PROJECT = createEmptyProject('p', 'P');
for (const id of ['a', 'b', 'g', 'w']) PROJECT.assets.push(buildAsset('tile', id.toUpperCase(), id));
const fill = (props: Record<string, number>, layer: TilemapLayer, start: Cell, tileId: string) =>
floodFill(PROJECT, props, layer, start, tileId);
describe('filling a tilemap layer', () => {
it('takes the connected cells through edges, not through corners', () => {
const rows = ['..w..', '.w.w.', 'w...w', '.w.w.'];
const cells = floodFill(GRID, layerOf(rows), { x: 2, y: 2 }, 'g');
const cells = fill(GRID, layerOf(rows), { x: 2, y: 2 }, 'g');
expect(shown(rows, cells)).toEqual(['..w..', '.w*w.', 'w***w', '.w*w.']);
});
it('fills a whole empty grid and stops at its edge', () => {
const cells = floodFill(GRID, layerOf([]), { x: 0, y: 0 }, 'g');
const cells = fill(GRID, layerOf([]), { x: 0, y: 0 }, 'g');
expect(cells).toHaveLength(20);
expect(cells.every(({ x, y }) => x >= 0 && y >= 0 && x < 5 && y < 4)).toBe(true);
});
it('replaces one tile with another and leaves the empty cells beside it', () => {
const rows = ['aa...', 'a.bb.', 'aaab.', '.....'];
const cells = floodFill(GRID, layerOf(rows), { x: 0, y: 0 }, 'b');
const cells = fill(GRID, layerOf(rows), { x: 0, y: 0 }, 'b');
expect(shown(rows, cells)).toEqual(['**...', '*.bb.', '***b.', '.....']);
});
@@ -49,26 +58,36 @@ describe('filling a tilemap layer', () => {
const layer = layerOf(['aaaaa']);
layer.cells[cellKey(5, 0)] = 'a';
layer.cells[cellKey(-1, 0)] = 'a';
const cells = floodFill(GRID, layer, { x: 2, y: 0 }, 'g');
const cells = fill(GRID, layer, { x: 2, y: 0 }, 'g');
expect(cells.map((c) => c.x).sort()).toEqual([0, 1, 2, 3, 4]);
expect(floodFill(GRID, layer, { x: 5, y: 0 }, 'g')).toEqual([]);
expect(fill(GRID, layer, { x: 5, y: 0 }, 'g')).toEqual([]);
});
it('changes nothing when the cell already holds the tile', () => {
expect(floodFill(GRID, layerOf(['aaaaa']), { x: 1, y: 0 }, 'a')).toEqual([]);
expect(floodFill(GRID, layerOf([]), { x: 1, y: 1 }, '')).toEqual([]);
expect(fill(GRID, layerOf(['aaaaa']), { x: 1, y: 0 }, 'a')).toEqual([]);
expect(fill(GRID, layerOf([]), { x: 1, y: 1 }, '')).toEqual([]);
});
it('can empty a region, too', () => {
const rows = ['aa.a.'];
const cells = floodFill(GRID, layerOf(rows), { x: 1, y: 0 }, '');
const cells = fill(GRID, layerOf(rows), { x: 1, y: 0 }, '');
expect(shown(rows, cells)).toEqual(['**.a.']);
});
it('counts a cell naming a missing tile as empty, from wherever the fill starts (#268)', () => {
// `x` is no tile of the project: those cells draw nothing, so they belong to the empty region.
const rows = ['.x.w.', 'xx.w.', '...w.', 'wwww.'];
const want = ['***w.', '***w.', '***w.', 'wwww.'];
expect(shown(rows, fill(GRID, layerOf(rows), { x: 0, y: 0 }, 'g'))).toEqual(want);
expect(shown(rows, fill(GRID, layerOf(rows), { x: 1, y: 1 }, 'g'))).toEqual(want);
// Emptying from one changes nothing: it is empty already.
expect(fill(GRID, layerOf(rows), { x: 1, y: 0 }, '')).toEqual([]);
});
it('fills a large map in one go', () => {
const props = { columns: 300, rows: 300 };
const started = performance.now();
expect(floodFill(props, layerOf([]), { x: 150, y: 150 }, 'g')).toHaveLength(90_000);
expect(fill(props, layerOf([]), { x: 150, y: 150 }, 'g')).toHaveLength(90_000);
expect(performance.now() - started).toBeLessThan(2000);
});
});
+66 -1
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
CreateAssetCommand,
CreateTextureCommand,
DeleteAssetCommand,
SetAssetPropertyCommand,
} from '../../src/core/commands/assetCommands';
import { AddTilePictureCommand } from '../../src/core/commands/tileCommands';
@@ -37,6 +38,7 @@ import {
tilemapSize,
} from '../../src/core/model/tilemap';
import { DIMMED_LAYER_ALPHA, layerAlpha } from '../../src/viewport/tilemap';
import { cellsPainted } from '../../src/core/model/usages';
import { groupByTile, tileMesh } from '../../src/viewport/tilemapMesh';
import { makeProject, roundTrip } from '../helpers';
@@ -182,7 +184,7 @@ describe('painting a tilemap', () => {
const f = makeTilemap();
f.paint([{ x: 0, y: 0 }]);
const layer = f.assetOf().layers[0]!;
expect(floodFill(f.assetOf().props, layer, { x: 0, y: 0 }, f.tileId)).toEqual([]);
expect(floodFill(f.project, f.assetOf().props, layer, { x: 0, y: 0 }, f.tileId)).toEqual([]);
expect(paintTiles(layer, f.tilemapId, [])).toBeNull();
});
@@ -554,3 +556,66 @@ describe('the mesh a layer becomes', () => {
}
});
});
describe('deleting a tile that is painted (#268)', () => {
/** Grass on two layers of one map and on a second map, with a second tile beside it. */
function painted() {
const f = makeTilemap();
const stone = new CreateAssetCommand('tile', 'Stone');
f.stack.execute(stone);
f.paint([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
]);
f.paint([{ x: 2, y: 0 }], stone.asset.id);
const upper = new AddTilemapLayerCommand(f.tilemapId);
f.stack.execute(upper);
f.stack.execute(new PaintTilesCommand(f.tilemapId, upper.layerId, [{ x: 5, y: 5, tileId: f.tileId }]));
const other = new CreateAssetCommand('tilemap', 'Cave');
f.stack.execute(other);
const otherLayer = (findAsset(f.project, other.asset.id) as TilemapAsset).layers[0]!.id;
f.stack.execute(new PaintTilesCommand(other.asset.id, otherLayer, [{ x: 3, y: 3, tileId: f.tileId }]));
const allCells = () =>
f.project.assets.flatMap((a) => (a.type === 'tilemap' ? a.layers.map((l) => ({ ...l.cells })) : []));
return { ...f, stoneId: stone.asset.id, allCells };
}
it('counts the cells and the maps they are on', () => {
const f = painted();
expect(cellsPainted(f.project, f.tileId)).toEqual({ tilemaps: 2, cells: 4 });
expect(cellsPainted(f.project, f.stoneId)).toEqual({ tilemaps: 1, cells: 1 });
});
it('empties those cells in the same step and leaves every other tile where it was', () => {
const f = painted();
f.stack.execute(new DeleteAssetCommand(f.tileId));
expect(findAsset(f.project, f.tileId)).toBeUndefined();
expect(f.allCells()).toEqual([{ '2,0': f.stoneId }, {}, {}]);
expect(cellsPainted(f.project, f.tileId)).toEqual({ tilemaps: 0, cells: 0 });
});
it('an undo brings the tile and its cells back together, a redo takes both again', () => {
const f = painted();
const before = f.allCells();
f.stack.execute(new DeleteAssetCommand(f.tileId));
f.stack.undo();
expect(findAsset(f.project, f.tileId)?.type).toBe('tile');
expect(f.allCells()).toEqual(before);
const grass = layerTiles(f.project, f.assetOf().props, f.assetOf().layers[0]!).filter(
(tile) => tile.tileId === f.tileId,
);
expect(grass.map(({ x, y }) => `${x},${y}`)).toEqual(['0,0', '1,0']);
f.stack.redo();
expect(f.allCells()).toEqual([{ '2,0': f.stoneId }, {}, {}]);
});
it('deleting a tile nobody painted leaves the maps alone', () => {
const f = painted();
const lone = new CreateAssetCommand('tile', 'Lone');
f.stack.execute(lone);
const layers = f.project.assets.flatMap((a) => (a.type === 'tilemap' ? a.layers : []));
const cellObjects = layers.map((l) => l.cells);
f.stack.execute(new DeleteAssetCommand(lone.asset.id));
expect(layers.every((l, i) => l.cells === cellObjects[i])).toBe(true);
});
});