diff --git a/README.md b/README.md index ded69073..c8bfecb2 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1586 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1590 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 2fa47d74..658bdc47 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -6,6 +6,7 @@ import type { SoundAsset, StateMachineAsset, TextureAsset, + TilemapLayer, VariablesAsset, } from '../model/Asset'; import type { AssetType } from '../model/AssetTypes'; @@ -26,6 +27,7 @@ import { } from '../model/Scope'; import type { SceneObject } from '../model/SceneObject'; import { isTextureFile } from '../model/texture'; +import { cellsWithout } from '../model/tilemap'; import { coerceValue, defaultsFor, initialOf, initialPropsFor } from '../properties/PropertySchema'; import { type Removed, removeById, removeFirst, restoreAt } from '../util/list'; import { type Command, CommandError } from './Command'; @@ -209,19 +211,32 @@ export class CreateSoundCommand extends ImportFileCommand { /** * Deletes an asset and, as a cascade, every scene object referencing it, together with the objects - * parented below those – in container templates as well as in scenes. + * parented below those – in container templates as well as in scenes. A tile takes the cells it is + * painted on along (#268), which would otherwise stay in the file and draw nothing. */ export class DeleteAssetCommand implements Command { readonly label = 'Delete asset'; private removed?: Removed; private removedObjects: { holderId: string; removed: Removed[] }[] = []; + private emptied: { layer: TilemapLayer; cells: Record }[] = []; private previousActiveScene = ''; constructor(private readonly assetId: string) {} execute(project: Project): void { - requireAsset(project, this.assetId); + const asset = requireAsset(project, this.assetId); this.removedObjects = []; + this.emptied = []; + if (asset.type === 'tile') + for (const map of project.assets) { + if (map.type !== 'tilemap') continue; + for (const layer of map.layers) { + const kept = cellsWithout(layer.cells, this.assetId); + if (!kept) continue; + this.emptied.push({ layer, cells: layer.cells }); + layer.cells = kept; + } + } // The cascade runs while the asset is still in the list: a container asset is an object // holder itself, so removing it first would change what `objectHolders` reports. for (const holder of objectHolders(project)) { @@ -245,6 +260,7 @@ export class DeleteAssetCommand implements Command { const holder = holders.get(entry.holderId); if (holder) restoreAt(holder.objects, entry.removed); } + for (const { layer, cells } of this.emptied) layer.cells = cells; project.activeSceneId = this.previousActiveScene; } } diff --git a/src/core/model/tilemap.ts b/src/core/model/tilemap.ts index 897586af..a09b0371 100644 --- a/src/core/model/tilemap.ts +++ b/src/core/model/tilemap.ts @@ -69,6 +69,21 @@ export function cellAt(layer: TilemapLayer, x: number, y: number): string { return layer.cells[cellKey(x, y)] ?? ''; } +/** + * The tile a cell holds as far as anything but the file is concerned: `''` for an empty cell and for + * one naming a tile the project does not have (#268). Such a cell draws and blocks nothing, so it + * reads and fills as empty too. + */ +export function presentTile(project: Project, tileId: string): string { + return tileId !== '' && findAssetOf(project, tileId, 'tile') ? tileId : ''; +} + +/** The cells without those naming `tileId`, or null when none does. */ +export function cellsWithout(cells: Record, tileId: string): Record | null { + if (!Object.values(cells).includes(tileId)) return null; + return Object.fromEntries(Object.entries(cells).filter(([, id]) => id !== tileId)); +} + export function isLayerVisible(layer: TilemapLayer): boolean { return layer.hidden !== true; } @@ -122,8 +137,8 @@ export interface TileDraw { /** * Every cell of one layer that is inside the grid and names a tile that exists, in reading order - * so the result is the same list twice. Cells naming a deleted tile are skipped rather than - * dropped from the model: an undo brings the tile, and with it the drawing, back. + * so the result is the same list twice. A cell naming a tile the project does not have is skipped + * (see `presentTile`). */ export function layerTiles(project: Project, props: PropertyBag, layer: TilemapLayer): readonly TileDraw[] { const kinds = new Map(); @@ -255,11 +270,24 @@ export function cellsInRect(props: PropertyBag, a: Cell, b: Cell): Cell[] { * The cells a fill from `start` changes (#133): every cell reached through its four edge * neighbours that holds what `start` holds – empty included – clipped to the grid. Diagonals do not * count, or a fill would leak through the gap between two corners. Filling with the tile already - * there changes nothing, so that returns no cells. + * there changes nothing, so that returns no cells. A cell naming a missing tile is empty here. */ -export function floodFill(props: PropertyBag, layer: TilemapLayer, start: Cell, tileId: string): Cell[] { +export function floodFill( + project: Project, + props: PropertyBag, + layer: TilemapLayer, + start: Cell, + tileId: string, +): Cell[] { if (!inGrid(props, start.x, start.y)) return []; - const from = cellAt(layer, start.x, start.y); + const present = new Map(); + const tileAt = (x: number, y: number): string => { + const id = cellAt(layer, x, y); + let shown = present.get(id); + if (shown === undefined) present.set(id, (shown = presentTile(project, id))); + return shown; + }; + const from = tileAt(start.x, start.y); if (from === tileId) return []; // The list is its own queue: everything in it is filled, and it is walked while it grows. const cells = [{ x: start.x, y: start.y }]; @@ -275,7 +303,7 @@ export function floodFill(props: PropertyBag, layer: TilemapLayer, start: Cell, const x = cell.x + dx; const y = cell.y + dy; const key = cellKey(x, y); - if (seen.has(key) || !inGrid(props, x, y) || cellAt(layer, x, y) !== from) continue; + if (seen.has(key) || !inGrid(props, x, y) || tileAt(x, y) !== from) continue; seen.add(key); cells.push({ x, y }); } diff --git a/src/core/model/usages.ts b/src/core/model/usages.ts index 2bd44f11..1502ffb5 100644 --- a/src/core/model/usages.ts +++ b/src/core/model/usages.ts @@ -68,6 +68,22 @@ export function tilesUsing(project: Project, ids: readonly string[]): TileAsset[ ); } +/** The cells a tile is painted on, and on how many tilemaps – the cells are the maps' own data. */ +export function cellsPainted(project: Project, tileId: string): { tilemaps: number; cells: number } { + let tilemaps = 0; + let cells = 0; + for (const asset of project.assets) { + if (asset.type !== 'tilemap') continue; + const here = asset.layers.reduce( + (sum, layer) => sum + Object.values(layer.cells).filter((id) => id === tileId).length, + 0, + ); + if (here > 0) tilemaps++; + cells += here; + } + return { tilemaps, cells }; +} + /** The animations keying any of these ids – intrinsic data, which `findPropertyUsages` does not see. */ export function animationsUsing(project: Project, ids: readonly string[]): AnimationAsset[] { const wanted = new Set(ids); diff --git a/src/editor/actions.ts b/src/editor/actions.ts index a1dc6cb5..33fd8ddf 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -28,7 +28,13 @@ import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../c import type { SceneObject } from '../core/model/SceneObject'; import { pixelArtRefIds } from '../core/model/pixelart'; import { textureRefIds } from '../core/model/texture'; -import { animationsUsing, tilesUsing, findPropertyUsages, findUsages } from '../core/model/usages'; +import { + animationsUsing, + cellsPainted, + tilesUsing, + findPropertyUsages, + findUsages, +} from '../core/model/usages'; import { Dialog } from '../ui'; import { type EditorState, objectSelection } from './state'; @@ -189,20 +195,23 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void { const typed = asset.type === 'struct' ? structTypeUsages(state.project, assetId) : { places: 0, lists: 0, nodes: 0 }; const lists = typed.lists > 0 ? `, ${typed.lists} list(s)` : ''; + const painted = asset.type === 'tile' ? cellsPainted(state.project, assetId) : { tilemaps: 0, cells: 0 }; const detail = asset.type === 'scene' ? `The scene and its ${asset.objects.length} object(s) are removed.` - : eased.keys > 0 || eased.nodes > 0 - ? `${eased.keys} animation key(s) and ${eased.nodes} Lerp node(s) use it and blend linearly instead.` - : typed.places > 0 || typed.lists > 0 || typed.nodes > 0 - ? `${typed.places} struct(s)${lists} and ${typed.nodes} node(s) use it and keep its fields as their own.` - : references > 0 || animations > 0 || tiles > 0 - ? wornDetail(references, animations, tiles, asset.type) - : usages === 0 - ? 'This cannot be undone with Ctrl+Z after reload.' - : asset.type === 'container' - ? `${usages} placed container(s) are removed together with their content.` - : `${usages} placed instance(s) are removed as well (scenes and container templates).`; + : painted.cells > 0 + ? `${painted.cells} cell(s) on ${painted.tilemaps} tilemap(s) are painted with it and become empty.` + : eased.keys > 0 || eased.nodes > 0 + ? `${eased.keys} animation key(s) and ${eased.nodes} Lerp node(s) use it and blend linearly instead.` + : typed.places > 0 || typed.lists > 0 || typed.nodes > 0 + ? `${typed.places} struct(s)${lists} and ${typed.nodes} node(s) use it and keep its fields as their own.` + : references > 0 || animations > 0 || tiles > 0 + ? wornDetail(references, animations, tiles, asset.type) + : usages === 0 + ? 'This cannot be undone with Ctrl+Z after reload.' + : asset.type === 'container' + ? `${usages} placed container(s) are removed together with their content.` + : `${usages} placed instance(s) are removed as well (scenes and container templates).`; new Dialog(`Delete "${asset.name}"?`, detail, [ { label: 'Cancel' }, { label: 'Delete', danger: true, onClick: () => state.execute(new DeleteAssetCommand(assetId)) }, diff --git a/src/editor/viewport/tilemapPainting.ts b/src/editor/viewport/tilemapPainting.ts index bf6cfd3a..3ba8c480 100644 --- a/src/editor/viewport/tilemapPainting.ts +++ b/src/editor/viewport/tilemapPainting.ts @@ -14,6 +14,7 @@ import { findLayer, floodFill, inGrid, + presentTile, tilemapColumns, tilemapRows, tilemapTileSize, @@ -140,12 +141,12 @@ export class TilemapPainter { /** Fills the connected cells of one tile in a single command, so one click is one undo step. */ private fillFrom(target: PaintTarget, layer: TilemapLayer, at: Vec2): void { const state = this.host.state; - const tileId = state.tilemapPaint.tileId; + const tileId = presentTile(state.project, state.tilemapPaint.tileId); if (!tileId) { state.setNotice('Choose a tile to paint with.'); return; } - const cells = floodFill(target.item.props, layer, at, tileId); + const cells = floodFill(state.project, target.item.props, layer, at, tileId); const command = paintTiles( layer, target.asset.id, @@ -157,7 +158,9 @@ export class TilemapPainter { private startStroke(target: PaintTarget, global: Vec2): boolean { const paint = this.host.state.tilemapPaint; - const tileId = paint.tool === 'erase' ? '' : paint.tileId; + // The tile in hand may have been deleted since it was picked; painting it would leave cells that + // draw nothing (#268). + const tileId = paint.tool === 'erase' ? '' : presentTile(this.host.state.project, paint.tileId); if (paint.tool !== 'erase' && !tileId) { this.host.state.setNotice('Choose a tile to paint with.'); return false; diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 82874e4a..5af59283 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -8,7 +8,7 @@ import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '. import { groupNamed, treeOrder } from '../core/model/hierarchy'; import { liveTileAt, withTile } from '../core/model/liveTilemap'; import { referenceCandidates } from '../core/model/objectRefs'; -import type { Cell } from '../core/model/tilemap'; +import { type Cell, presentTile } from '../core/model/tilemap'; import { descendantsBelow } from '../core/model/walk'; import { findAsset, findAssetOf, findScene, type Project } from '../core/model/Project'; import { createSceneObject, objectName, type SceneObject } from '../core/model/SceneObject'; @@ -189,10 +189,14 @@ export class World { return this.tilemapAssetOf(entity) !== undefined; } - /** The tile in a cell of a placed tilemap as the run left it (see `model/liveTilemap.ts`). */ + /** + * The tile in a cell of a placed tilemap as the run left it (see `model/liveTilemap.ts`), `''` for + * a cell naming a tile the project does not have, as it is drawn. + */ tileAt(entity: Entity, layer: string, cell: Cell): string | null { const asset = this.tilemapAssetOf(entity); - return asset ? liveTileAt(asset, entity.tileEdits, entity.props, layer, cell) : null; + const tile = asset ? liveTileAt(asset, entity.tileEdits, entity.props, layer, cell) : null; + return tile === null ? null : presentTile(this.project, tile); } /** Sets a cell of this one placement for the rest of the run; `''` empties it. */ diff --git a/tests/core/tile-access.test.ts b/tests/core/tile-access.test.ts index 2405166b..571291fe 100644 --- a/tests/core/tile-access.test.ts +++ b/tests/core/tile-access.test.ts @@ -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}`); diff --git a/tests/core/tilemap-fill.test.ts b/tests/core/tilemap-fill.test.ts index 41b467a1..51e0458b 100644 --- a/tests/core/tilemap-fill.test.ts +++ b/tests/core/tilemap-fill.test.ts @@ -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, 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); }); }); diff --git a/tests/core/tilemap.test.ts b/tests/core/tilemap.test.ts index 3866a014..4fbc2944 100644 --- a/tests/core/tilemap.test.ts +++ b/tests/core/tilemap.test.ts @@ -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); + }); +}); diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index 2d6730c8..4fa292be 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -1291,6 +1291,101 @@ export async function tilemap(t) { __gm.state.select(null)`); await settle(); + // 40t. Deleting a tile that is painted (#268): the dialog counts its cells, the same step empties + // them, a brush still holding the tile paints nothing, and an undo brings tile and cells back + // together. A tile of its own on the empty bottom row, so the cells above stay as they are. + await mutate(` + const assets = __gm.state.project.assets; + const doomed = structuredClone(assets.find((a) => a.id === 'at-grass')); + Object.assign(doomed, { id: 'tm-doomed', name: 'Doomed' }); + assets.push(doomed);`); + const selectMap = async () => { + await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT], objectId: MAP_OBJECT }); + await evaluate(`__gm.lookAt(0, ${LANE})`); + await settle(); + }; + const doomedCells = () => + json( + `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).layers` + + ".flatMap((l) => Object.keys(l.cells).filter((key) => l.cells[key] === 'tm-doomed')).sort().join(' ')", + ); + await selectMap(); + await clickInPanel(t, '__gm.tilemap.tileButtonAt("tm-doomed")'); + await clickInPanel(t, '__gm.tilemap.toolAt("brush")'); + await settle(); + const rowStart = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 3)`); + const rowEnd = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 2, 3)`); + await drag(rowStart.x, rowStart.y, rowEnd.x, rowEnd.y); + await settle(); + const paintedRow = await doomedCells(); + const doomedRow = await json("__gm.assetRow('tm-doomed')"); + await click(doomedRow.x, doomedRow.y, 'right'); + await settle(); + const deleteEntry = await t.locate('Delete…', true); + await click(deleteEntry.x, deleteEntry.y); + await settle(); + const counted = await t.has('3 cell(s) on 1 tilemap(s) are painted with it and become empty.'); + await shot('40-delete-painted-tile'); + check( + 'the delete dialog of a painted tile counts its cells and the maps they lie on', + paintedRow === '0,3 1,3 2,3' && counted, + JSON.stringify({ paintedRow, overlay: await json('__gm.overlayTexts()') }), + ); + const confirm = await t.locate('Delete', true); + await click(confirm.x, confirm.y); + await settle(); + const afterDelete = { + tile: await json("__gm.state.project.assets.some((a) => a.id === 'tm-doomed')"), + cells: await doomedCells(), + }; + check( + 'deleting the tile empties its cells in the same step', + afterDelete.tile === false && afterDelete.cells === '', + JSON.stringify(afterDelete), + ); + // The brush still holds the deleted tile; a click with it on a cell never painted must leave no + // such cell behind. + await selectMap(); + await clickInPanel(t, '__gm.tilemap.toolAt("brush")'); + await settle(); + const inHand = await json('__gm.tilemap.paint()'); + const untouched = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 4, 3)`); + await click(untouched.x, untouched.y); + await settle(); + const afterClick = { + inHand, + cell: await json( + `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).layers.map((l) => l.cells['4,3'] ?? '')`, + ), + notice: await json('__gm.state.notice'), + }; + check( + 'a brush still holding the deleted tile paints nothing and asks for a tile', + inHand.tool === 'brush' && + inHand.tileId === 'tm-doomed' && + afterClick.cell.every((id) => id === '') && + afterClick.notice === 'Choose a tile to paint with.', + JSON.stringify(afterClick), + ); + await undo(); + await settle(); + const restored = { + tile: await json("__gm.state.project.assets.some((a) => a.id === 'tm-doomed')"), + cells: await doomedCells(), + }; + check( + 'an undo brings the tile and its cells back together', + restored.tile === true && restored.cells === '0,3 1,3 2,3', + JSON.stringify(restored), + ); + // The stroke goes the same way, and the tile the mutate brought in goes as it came. + await undo(); + await settle(); + await mutate(` + const p = __gm.state.project; + p.assets = p.assets.filter((a) => a.id !== 'tm-doomed');`); + await settle(); + // Leave the editor as the rest of the suite expects to find it. await evaluate("__gm.state.setTilemapPaint({ tool: 'select' })"); await evaluate('__gm.state.select(null)');