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:
1 parent
14972d057f
commit
9b5eeaab57
11 files changed
+310
-38
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<SoundAsset> {
|
||||
|
||||
/**
|
||||
* 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<Asset>;
|
||||
private removedObjects: { holderId: string; removed: Removed<SceneObject>[] }[] = [];
|
||||
private emptied: { layer: TilemapLayer; cells: Record<string, string> }[] = [];
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string>, tileId: string): Record<string, string> | 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<string, TileKind>();
|
||||
@@ -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<string, string>();
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+21
-12
@@ -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)) },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)');
|
||||
|
||||
Reference in new issue
Block a user