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)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c2fd536aaf
commit
0b32d3cbf3
9 files changed
+350
-20
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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]));
|
||||
|
||||
Reference in new issue
Block a user