The tilemap panel gains Fill and Pick: Fill floods the connected cells of the active layer that carry the same tile (edge neighbours only, bounded by the grid) as one undo step, Pick takes the tile of the clicked cell on the active layer, and holding Alt picks while any tool is active (#133)
game-maker / gate (push) Successful in 1m41s
game-maker / suite (push) Successful in 6m7s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 12:31:19 +02:00
1 parent c2fd536aaf
commit 0b32d3cbf3
9 files changed
+350 -20

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
/**
* The fill of a tilemap layer (#133): the cells reached from the one clicked through their four
* edge neighbours that hold the same tile – empty included – and nothing outside the grid.
*/
import { describe, expect, it } from 'vitest';
import type { TilemapLayer } from '../../src/core/model/Asset';
import { 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 {
const cells: Record<string, string> = {};
rows.forEach((row, y) =>
[...row].forEach((c, x) => {
if (c !== '.') cells[cellKey(x, y)] = c;
}),
);
return { id: 'l', name: 'Layer', cells };
}
/** The filled cells as text again, `*` where the fill landed – easier to read than a list. */
function shown(rows: string[], filled: { x: number; y: number }[]): string[] {
const out = rows.map((row) => [...row]);
for (const { x, y } of filled) out[y]![x] = '*';
return out.map((row) => row.join(''));
}
const GRID = { columns: 5, rows: 4 };
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');
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');
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');
expect(shown(rows, cells)).toEqual(['**...', '*.bb.', '***b.', '.....']);
});
it('ignores cells outside the grid, even when they hold the same tile', () => {
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');
expect(cells.map((c) => c.x).sort()).toEqual([0, 1, 2, 3, 4]);
expect(floodFill(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([]);
});
it('can empty a region, too', () => {
const rows = ['aa.a.'];
const cells = floodFill(GRID, layerOf(rows), { x: 1, y: 0 }, '');
expect(shown(rows, cells)).toEqual(['**.a.']);
});
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(performance.now() - started).toBeLessThan(2000);
});
});
+50 -1
View File
@@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { PlaceAssetCommand } from '../../src/core/commands/sceneObjectCommands';
import { createEmptyProject } from '../../src/core/model/Project';
import { paintedTilemap } from '../../src/editor/panels/TilemapPanel';
import { chooseTool, holdPick, paintedTilemap, pickTile } from '../../src/editor/panels/TilemapPanel';
import { EditorState, objectSelection } from '../../src/editor/state';
function setup() {
@@ -61,6 +61,55 @@ describe('the painted tilemap', () => {
expect(paintedTilemap(state)?.objectId).toBe(map);
});
it('picks with the Pick tool and hands the tile to the painting tool before it (#133)', () => {
const { state, sceneId, tilemapId, place } = setup();
state.select(objectSelection(sceneId, [place(sceneId, tilemapId)]));
chooseTool(state, 'rect');
chooseTool(state, 'pick');
pickTile(state, 'grass');
expect(state.tilemapPaint).toMatchObject({ tool: 'rect', tileId: 'grass' });
// From a tool that does not paint with a tile, the pick goes to the brush.
chooseTool(state, 'erase');
chooseTool(state, 'pick');
pickTile(state, 'wall');
expect(state.tilemapPaint).toMatchObject({ tool: 'brush', tileId: 'wall' });
});
it('picks while Alt is held and goes back to the tool before it on release', () => {
const { state, sceneId, tilemapId, place } = setup();
state.select(objectSelection(sceneId, [place(sceneId, tilemapId)]));
chooseTool(state, 'fill');
expect(holdPick(state, true)).toBe(true);
expect(state.tilemapPaint.tool).toBe('pick');
pickTile(state, 'grass');
expect(state.tilemapPaint).toMatchObject({ tool: 'pick', tileId: 'grass' });
expect(holdPick(state, false)).toBe(true);
expect(state.tilemapPaint).toMatchObject({ tool: 'fill', tileId: 'grass' });
// Held from Select, too: the release brings Select back.
chooseTool(state, 'select');
holdPick(state, true);
holdPick(state, false);
expect(state.tilemapPaint.tool).toBe('select');
});
it('leaves Alt alone without a map, and a tool chosen while holding it stays', () => {
const { state, sceneId, tilemapId, place } = setup();
expect(holdPick(state, true)).toBe(false);
expect(state.tilemapPaint.tool).toBe('select');
const map = place(sceneId, tilemapId);
state.select(objectSelection(sceneId, [map]));
chooseTool(state, 'brush');
holdPick(state, true);
chooseTool(state, 'rect');
expect(holdPick(state, false)).toBe(false);
expect(state.tilemapPaint.tool).toBe('rect');
// Released after the map was deselected: nothing to paint on, so Select.
holdPick(state, true);
state.select(null);
holdPick(state, false);
expect(state.tilemapPaint.tool).toBe('select');
});
it('is none for a map in a scene that is not the one showing', () => {
const { state, tilemapId, create, place } = setup();
const otherScene = create('scene', 'Elsewhere');