diff --git a/README.md b/README.md index 2c684124..8d513814 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/model/tilemap.ts b/src/core/model/tilemap.ts index f70e1b24..7a1a5f39 100644 --- a/src/core/model/tilemap.ts +++ b/src/core/model/tilemap.ts @@ -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. diff --git a/src/editor/panels/TilemapPanel.ts b/src/editor/panels/TilemapPanel.ts index 6e28959d..47251fb0 100644 --- a/src/editor/panels/TilemapPanel.ts +++ b/src/editor/panels/TilemapPanel.ts @@ -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); } diff --git a/src/editor/state.ts b/src/editor/state.ts index cd0c54be..cfe90352 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -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): diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index d9371559..06843dfb 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -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])); diff --git a/tests/core/tilemap-fill.test.ts b/tests/core/tilemap-fill.test.ts new file mode 100644 index 00000000..41b467a1 --- /dev/null +++ b/tests/core/tilemap-fill.test.ts @@ -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 = {}; + 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); + }); +}); diff --git a/tests/editor/painted-tilemap.test.ts b/tests/editor/painted-tilemap.test.ts index 82515a1e..f4012c22 100644 --- a/tests/editor/painted-tilemap.test.ts +++ b/tests/editor/painted-tilemap.test.ts @@ -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'); diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index 9c515d1b..2cbecdca 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -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(); diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index f8bbbb55..0530aa8c 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -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 },