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

+1 -1
View File
@@ -38,7 +38,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, 1034 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1038 checks (GM_STEPS=20 for one step)
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
+37
View File
@@ -178,6 +178,43 @@ export function cellsInRect(
return cells;
}
/**
* 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.
*/
export function floodFill(
props: PropertyBag,
layer: TilemapLayer,
start: { x: number; y: number },
tileId: string,
): { x: number; y: number }[] {
if (!inGrid(props, start.x, start.y)) return [];
const from = cellAt(layer, 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 }];
const seen = new Set([cellKey(start.x, start.y)]);
for (let index = 0; index < cells.length; index++) {
const cell = cells[index]!;
for (const [dx, dy] of [
[0, -1],
[1, 0],
[0, 1],
[-1, 0],
] as const) {
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;
seen.add(key);
cells.push({ x, y });
}
}
return cells;
}
/**
* The cells a stroke from one cell to another covers. A pointer jumps between events, so without
* this a fast drag would paint dots instead of a line.
+72 -13
View File
@@ -48,8 +48,55 @@ const TOOLS: { tool: TilemapTool; label: string; hint: string }[] = [
{ tool: 'brush', label: 'Brush', hint: 'Drag over the map to paint a line of cells.' },
{ tool: 'erase', label: 'Erase', hint: 'Drag over the map to clear cells.' },
{ tool: 'rect', label: 'Rect', hint: 'Drag to fill the box you span.' },
{ tool: 'fill', label: 'Fill', hint: 'Click to fill the connected cells of the same tile.' },
{ tool: 'pick', label: 'Pick', hint: 'Click a cell to take its tile and paint on with it.' },
];
/**
* While Alt is held the tool is Pick, and this is the tool it came from – what releasing Alt
* brings back (#133). Null while Alt is up.
*/
let heldFrom: TilemapTool | null = null;
/** The painting tool the Pick button hands a picked tile to: the one in use before it. */
let pickReturn: TilemapTool = 'brush';
function paintingTool(tool: TilemapTool): TilemapTool {
return tool === 'brush' || tool === 'rect' || tool === 'fill' ? tool : 'brush';
}
/** A tool button: a choice made by hand, so it also ends a held Alt – releasing it changes nothing. */
export function chooseTool(state: EditorState, tool: TilemapTool): void {
heldFrom = null;
if (tool === 'pick' && state.tilemapPaint.tool !== 'pick')
pickReturn = paintingTool(state.tilemapPaint.tool);
state.setTilemapPaint({ tool });
}
/**
* A tile taken from the map. Picking is choosing a tile, so like a click in the palette it goes
* on painting with it: back to the tool before Pick – unless Alt is held, whose release does that.
*/
export function pickTile(state: EditorState, tileId: string): void {
state.setTilemapPaint(heldFrom ? { tileId } : { tileId, tool: pickReturn });
}
/** Alt pressed or released (or the window left with it down, which never reports the release). */
export function holdPick(state: EditorState, held: boolean): boolean {
if (held) {
const tool = state.tilemapPaint.tool;
if (heldFrom || tool === 'pick' || !paintedTilemap(state)) return false;
heldFrom = tool;
state.setTilemapPaint({ tool: 'pick' });
return true;
}
const back = heldFrom;
heldFrom = null;
if (!back || state.tilemapPaint.tool !== 'pick') return false;
// With the map deselected meanwhile the panel is gone, and so is any tool but Select.
state.setTilemapPaint({ tool: paintedTilemap(state) ? back : 'select' });
return true;
}
/**
* The tilemap that is painted on: the one scene object selected, placed in the active scene. A
* second object in the selection means there is no single map to paint, so there is none (#142).
@@ -128,6 +175,14 @@ export class TilemapPanel extends Panel {
ui().focus.changeHandlers.push(() => {
if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.sync();
});
// Holding Alt picks (#133). The focus manager only hears presses, so the release has its own
// listener; a handled Alt is kept from the browser, which would otherwise open its menu bar.
const onAlt = (held: boolean) => (event: KeyboardEvent) => {
if (event.key === 'Alt' && !event.repeat && holdPick(state, held)) event.preventDefault();
};
window.addEventListener('keydown', onAlt(true));
window.addEventListener('keyup', onAlt(false));
window.addEventListener('blur', () => holdPick(state, false));
this.sync();
}
@@ -158,6 +213,7 @@ export class TilemapPanel extends Panel {
const target = paintedTilemap(this.state);
// With the panel gone nothing shows the brush any more, and a click on the next map would paint.
// The tile and the layer stay as they were: they are what one comes back to.
if (!target) heldFrom = null;
if (!target && this.state.tilemapPaint.tool !== 'select') {
this.state.setTilemapPaint({ tool: 'select' });
return;
@@ -210,27 +266,30 @@ function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): void {
built = widgets;
addTitle(host, 'PAINTING');
const tools = new HStack({ gap: Theme.gap });
for (const { tool, label } of TOOLS) {
const button = new Button(label, { onClick: () => state.setTilemapPaint({ tool }) });
button.toggled = paint.tool === tool;
widgets.tools.set(tool, button);
tools.add(button, 1);
// Two rows of three: six in one row cut "Select" down to "Se…" at the panel's starting width.
for (let start = 0; start < TOOLS.length; start += 3) {
const tools = new HStack({ gap: Theme.gap });
for (const { tool, label } of TOOLS.slice(start, start + 3)) {
const button = new Button(label, { onClick: () => chooseTool(state, tool) });
button.toggled = paint.tool === tool;
widgets.tools.set(tool, button);
tools.add(button, 1);
}
host.body.add(tools);
}
host.body.add(tools);
addNote(host, TOOLS.find((t) => t.tool === paint.tool)?.hint ?? '', 'small');
addNote(host, 'Shift-drag still pans the view.', 'small');
addNote(host, 'Hold Alt to pick a tile. Shift-drag still pans the view.', 'small');
addTitle(host, 'TILES');
const tiles = tileIdsOf(state);
if (tiles.length === 0) addNote(host, 'The project has no tile yet.');
else {
// Choosing a tile is how one starts painting, so it reaches for the brush by itself.
// Choosing a tile is how one starts painting, so it reaches for the brush by itself – and a
// tile chosen while picking is a pick, too.
widgets.palette = new TilePalette(state, tiles, paint.tileId, (tileId) =>
state.setTilemapPaint({
tileId,
tool: paint.tool === 'select' || paint.tool === 'erase' ? 'brush' : paint.tool,
}),
paint.tool === 'pick'
? pickTile(state, tileId)
: state.setTilemapPaint({ tileId, tool: paintingTool(paint.tool) }),
);
host.body.add(widgets.palette);
}
+4 -3
View File
@@ -141,10 +141,11 @@ function pickIds(input: readonly string[], primary?: string): { ids: string[]; p
/**
* Select leaves the scene view as it is – the map is picked up and moved like any other object.
* The other three paint: brush the chosen tile cell by cell, erase clears, rect fills the box
* a drag spans. Painting is a mode on purpose; a map one cannot move would be worse than a click.
* The others paint: brush the chosen tile cell by cell, erase clears, rect fills the box a drag
* spans, fill the connected cells of one tile (#133). Pick paints nothing – it takes the tile of the
* cell clicked. Painting is a mode on purpose; a map one cannot move would be worse than a click.
*/
export type TilemapTool = 'select' | 'brush' | 'erase' | 'rect';
export type TilemapTool = 'select' | 'brush' | 'erase' | 'rect' | 'fill' | 'pick';
/**
* How the scene view shows the layers of the selected tilemap next to the one painted on (#138):
+55 -2
View File
@@ -24,12 +24,14 @@ import { placementRejection } from '../../core/model/placement';
import type { Position, Vec2 } from '../../core/model/Properties';
import { findAsset, findAssetOf, type ObjectHolder } from '../../core/model/Project';
import {
cellAt,
cellAtPoint,
cellKey,
cellOrigin,
cellsAlong,
cellsInRect,
findLayer,
floodFill,
inGrid,
tilemapColumns,
tilemapRows,
@@ -39,7 +41,7 @@ import type { CellRect } from '../../core/model/tilemapCollision';
import { footprintCorners } from '../../core/properties/bounds';
import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform';
import { type EditorState, objectSelection, type TilemapTool } from '../state';
import { paintedLayerId, paintedTilemap } from '../panels/TilemapPanel';
import { paintedLayerId, paintedTilemap, pickTile } from '../panels/TilemapPanel';
import { activeDrag, drawBand, drawGrid, endDrag, onDragChange, Theme, textStyle } from '../../ui';
import { collectHolderItems, type DrawItem } from '../../viewport/items';
import type { LayerFocus } from '../../viewport/tilemap';
@@ -363,6 +365,57 @@ export class SceneViewport extends FreeViewport {
return cellAtPoint(item.props, inMap.x, inMap.y);
}
/**
* What a press with a painting tool does. Fill and pick are done with the click – a drag adds
* nothing to either – so only the other three start a stroke. True when the press was used.
*/
private usePaintTool(
target: { item: DrawItem; asset: TilemapAsset; layerId: string },
holderId: string,
e: FederatedPointerEvent,
): boolean {
const tool = this.state.tilemapPaint.tool;
if (tool !== 'fill' && tool !== 'pick') return this.startStroke(target, holderId, e);
const layer = findLayer(target.asset, target.layerId);
const at = this.cellUnder(target.item, e);
if (!layer || !inGrid(target.item.props, at.x, at.y)) return true;
if (tool === 'pick') this.pickCell(layer, at);
else this.fillFrom(target, layer, at);
return true;
}
/** Takes the tile of a cell on the painted layer – the one painted on next (#133). */
private pickCell(layer: TilemapLayer, at: Vec2): void {
const tileId = cellAt(layer, at.x, at.y);
if (!tileId) this.state.setNotice('This cell is empty on the active layer.');
else if (!findAssetOf(this.state.project, tileId, 'tile'))
this.state.setNotice('This cell names a tile that no longer exists.');
else pickTile(this.state, tileId);
}
/** Fills the connected cells of one tile in a single command, so one click is one undo step. */
private fillFrom(
target: { item: DrawItem; asset: TilemapAsset; layerId: string },
layer: TilemapLayer,
at: Vec2,
): void {
const tileId = this.state.tilemapPaint.tileId;
if (!tileId) {
this.state.setNotice('Choose a tile to paint with.');
return;
}
const cells = floodFill(target.item.props, layer, at, tileId);
if (cells.length > 0)
this.state.execute(
new PaintTilesCommand(
target.asset.id,
target.layerId,
cells.map((cell) => ({ ...cell, tileId })),
'Fill tiles',
),
);
}
private startStroke(
target: { item: DrawItem; asset: TilemapAsset; layerId: string },
holderId: string,
@@ -462,7 +515,7 @@ export class SceneViewport extends FreeViewport {
}
// A painting tool on the selected tilemap: the press starts a stroke instead of a move.
const target = this.paintTargetOf(objectId);
if (target && this.startStroke(target, holder.id, e)) return;
if (target && this.usePaintTool(target, holder.id, e)) return;
// Clicking a selected object keeps the selection so the whole group can be dragged.
if (!this.state.selectedObjectIds.includes(objectId))
this.state.select(objectSelection(holder.id, [objectId]));
+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');
+56
View File
@@ -317,6 +317,62 @@ export async function tilemap(t) {
await undo();
await settle();
// 40n. Fill and pick (#133). The 3 × 3 block sits in the corner of the 6 × 4 grid, so the empty
// cells around it are one region of 15: a click fills exactly those, in one undo step.
await clickInPanel(t, '__gm.tilemap.toolAt("fill")');
await settle();
const empty = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 5, 3)`);
await click(empty.x, empty.y);
await settle();
cells = await painted();
check(
'the fill tool fills the connected empty cells up to the edge of the grid',
Object.keys(cells).length === COLUMNS * ROWS && Object.values(cells).every((id) => id === 'at-grass'),
JSON.stringify(Object.keys(cells).sort()),
);
await undo();
await settle();
check(
'and the whole fill is one undo step',
Object.keys(await painted()).length === 9,
JSON.stringify(Object.keys(await painted()).sort()),
);
// With no tile chosen, whatever tile the paint state holds afterwards came from the map.
await evaluate("__gm.state.setTilemapPaint({ tileId: '' })");
await clickInPanel(t, '__gm.tilemap.toolAt("pick")');
await settle();
const inBlock = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 1, 1)`);
await click(inBlock.x, inBlock.y);
await settle();
const picked = await json('__gm.tilemap.paint()');
check(
'the pick tool takes the tile of the cell and hands it back to the tool before it',
picked.tileId === 'at-grass' && picked.tool === 'fill' && Object.keys(await painted()).length === 9,
JSON.stringify(picked),
);
// Held Alt: Pick for as long as it is down, the brush again once it is up.
await clickInPanel(t, '__gm.tilemap.toolAt("brush")');
await evaluate("__gm.state.setTilemapPaint({ tileId: '' })");
await settle();
await t.keyDown('Alt');
await settle();
const holding = await json('__gm.tilemap.paint()');
await click(inBlock.x, inBlock.y, 'left', { alt: true });
const whileHeld = await json('__gm.tilemap.paint()');
await t.keyUp('Alt');
await settle();
const released = await json('__gm.tilemap.paint()');
check(
'holding Alt picks, and letting go of it brings the brush back',
holding.tool === 'pick' &&
whileHeld.tool === 'pick' &&
whileHeld.tileId === 'at-grass' &&
released.tool === 'brush' &&
released.tileId === 'at-grass' &&
Object.keys(await painted()).length === 9,
JSON.stringify({ holding, whileHeld, released }),
);
// 40h. A second layer draws above the first, and hiding it leaves its cells alone.
await clickInPanel(t, '__gm.tilemap.addLayerAt()');
await settle();
+1
View File
@@ -43,6 +43,7 @@ const KEYS = {
ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 },
ArrowDown: { key: 'ArrowDown', code: 'ArrowDown', windowsVirtualKeyCode: 40 },
Delete: { key: 'Delete', code: 'Delete', windowsVirtualKeyCode: 46 },
Alt: { key: 'Alt', code: 'AltLeft', windowsVirtualKeyCode: 18 },
a: { key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65 },
c: { key: 'c', code: 'KeyC', windowsVirtualKeyCode: 67 },
d: { key: 'd', code: 'KeyD', windowsVirtualKeyCode: 68 },