/** * Painting a tilemap and managing its layers (#128). One stroke is one command whatever it covers: * the pointer decides where a stroke ends, so an undo has to take back the whole line and not the * last cell of it. The grid size and the cell size are ordinary properties and go through * `SetAssetPropertyCommand`; on a placed tilemap the grid size goes through * `ResizeTilemapEdgeCommand`, which keeps the painted cells where they are in the scene (#136). */ import { newId } from '../ids'; import type { Asset, TilemapLayer } from '../model/Asset'; import type { ObjectRef } from '../model/instance'; import { apply } from '../model/matrix'; import type { Position } from '../model/Properties'; import type { SceneObject } from '../model/SceneObject'; import { type Cell, cellAt, cellKey, edgeCompensation, edgeSizeKey, shiftedCells, TILEMAP_COLUMNS_KEY, TILEMAP_MAX_CELLS, type TilemapEdge, tilemapColumns, tilemapRows, tilemapTileSize, } from '../model/tilemap'; import type { Project } from '../model/Project'; import { cellsChanged } from '../model/tilemapMemo'; import { resolveSceneObjectProps } from '../properties/resolve'; import { localMatrixOf, transformOfObject } from '../properties/transform'; import { moveItem, type Removed, removeById, removeFirst, restoreAt } from '../util/list'; import { copyName } from '../util/names'; import { nextNumberedName } from './naming'; import { type Command, CommandError, MoveCommand } from './Command'; import { requireTilemap, requireTilemapLayer } from './require'; import { ObjectEditCommand } from './sceneObjectCommands'; /** One cell as a stroke wants it: `tileId` empty erases. */ export interface CellEdit extends Cell { tileId: string; } /** * Sets a run of cells at once. What each one held before is remembered per cell, so an undo puts * a stroke over another tile back exactly – not merely empty. */ export class PaintTilesCommand implements Command { readonly label: string; private previous = new Map(); constructor( private readonly assetId: string, private readonly layerId: string, private readonly edits: readonly CellEdit[], label?: string, ) { this.label = label ?? 'Paint tiles'; } /** Nothing to do when every cell already holds what the stroke would write – no empty undo step. */ static changes(layer: TilemapLayer, edits: readonly CellEdit[]): boolean { return edits.some((edit) => cellAt(layer, edit.x, edit.y) !== edit.tileId); } execute(project: Project): void { const layer = requireTilemapLayer(project, this.assetId, this.layerId); this.previous = new Map(); for (const edit of this.edits) { const key = cellKey(edit.x, edit.y); if (!this.previous.has(key)) this.previous.set(key, layer.cells[key] ?? ''); if (edit.tileId) layer.cells[key] = edit.tileId; else delete layer.cells[key]; } cellsChanged(layer.cells); } undo(project: Project): void { const layer = requireTilemapLayer(project, this.assetId, this.layerId); for (const [key, value] of this.previous) { if (value) layer.cells[key] = value; else delete layer.cells[key]; } cellsChanged(layer.cells); } } /** * The command for a finished stroke or fill, or null when it would change no cell: such a step * would undo nothing and still count as an edit of the project (#212). */ export function paintTiles( layer: TilemapLayer, assetId: string, edits: readonly CellEdit[], label?: string, ): PaintTilesCommand | null { return PaintTilesCommand.changes(layer, edits) ? new PaintTilesCommand(assetId, layer.id, edits, label) : null; } /** * Where a placed tilemap's origin goes when `edge` gains `count` cells, starting from `from`, so the * painted cells stay where they are in the scene (#136). The shift is worked out in the tilemap's * own frame and carried through its turn, size and mirror, so a rotated map holds still as well. */ export function heldPosition( asset: Asset, object: SceneObject, from: Position, edge: TilemapEdge, count: number, tileSize: number, ): Position { const at = apply( localMatrixOf(from, transformOfObject(asset, object)), edgeCompensation(edge, count, tileSize), ); return { x: at.x, y: at.y, z: from.z }; } /** The placed tilemap's current columns or rows, whichever `key` names. */ export function placedGridSize(project: Project, asset: Asset, object: SceneObject, key: string): number { const props = resolveSceneObjectProps(project, asset, object); return key === TILEMAP_COLUMNS_KEY ? tilemapColumns(props) : tilemapRows(props); } /** * Adds (`count` > 0) or removes columns or rows at one edge of a placed tilemap (#136), as one undo * step. The size is the placement's own, as when Columns and Rows are edited in the inspector. At the * left and top edge the cells of every layer move along, because cell `0,0` is always the top-left * corner – and cells live on the asset, so they move in every other placement too. Removing throws * nothing away: what falls off lies outside the grid and comes back with the edge. */ export class ResizeTilemapEdgeCommand extends ObjectEditCommand { readonly label: string; private shifted: { layer: TilemapLayer; cells: Record }[] = []; constructor( holderId: string, objectId: string, private readonly edge: TilemapEdge, private readonly count: number, ) { super(holderId, objectId); const what = edgeSizeKey(edge) === TILEMAP_COLUMNS_KEY ? 'column' : 'row'; this.label = `${count >= 0 ? 'Add' : 'Remove'} ${what}${Math.abs(count) === 1 ? '' : 's'} (${edge})`; } protected override apply(ref: ObjectRef, project: Project): void { const asset = requireTilemap(project, ref.object.assetId); const key = edgeSizeKey(this.edge); const size = placedGridSize(project, asset, ref.object, key) + this.count; if (size < 1) throw new CommandError('A tilemap keeps at least one column and one row'); if (size > TILEMAP_MAX_CELLS) throw new CommandError(`A tilemap has at most ${TILEMAP_MAX_CELLS} columns and rows`); const tile = tilemapTileSize(resolveSceneObjectProps(project, asset, ref.object)); ref.setPosition(heldPosition(asset, ref.object, ref.object.position, this.edge, this.count, tile)); ref.setOverride(key, size); this.shifted = []; if (this.edge !== 'left' && this.edge !== 'top') return; const dx = this.edge === 'left' ? this.count : 0; const dy = this.edge === 'top' ? this.count : 0; for (const layer of asset.layers) { this.shifted.push({ layer, cells: layer.cells }); layer.cells = shiftedCells(layer.cells, dx, dy); } } protected override revert(): void { for (const { layer, cells } of this.shifted) layer.cells = cells; } } export class AddTilemapLayerCommand implements Command { readonly label = 'Add layer'; readonly layerId = newId(); constructor(private readonly assetId: string) {} execute(project: Project): void { const asset = requireTilemap(project, this.assetId); const name = nextNumberedName( asset.layers.map((layer) => layer.name), 'Layer', ); asset.layers.push({ id: this.layerId, name, cells: {} }); } undo(project: Project): void { const asset = requireTilemap(project, this.assetId); asset.layers = removeById(asset.layers, this.layerId); } } /** A copy of a layer right above it, with everything painted on it, its z and whether it is shown. */ export class DuplicateTilemapLayerCommand implements Command { readonly label = 'Duplicate layer'; readonly layerId = newId(); constructor( private readonly assetId: string, private readonly sourceId: string, ) {} execute(project: Project): void { const asset = requireTilemap(project, this.assetId); const index = asset.layers.findIndex((layer) => layer.id === this.sourceId); const source = asset.layers[index]; if (!source) throw new CommandError(`Layer ${this.sourceId} not found`); const name = copyName( asset.layers.map((layer) => layer.name), source.name, ); asset.layers.splice(index + 1, 0, { ...source, id: this.layerId, name, cells: { ...source.cells } }); } undo(project: Project): void { const asset = requireTilemap(project, this.assetId); asset.layers = removeById(asset.layers, this.layerId); } } /** Takes a layer out with everything painted on it; the last layer stays, there must be one to paint on. */ export class RemoveTilemapLayerCommand implements Command { readonly label = 'Remove layer'; private removed: Removed | undefined; constructor( private readonly assetId: string, private readonly layerId: string, ) {} execute(project: Project): void { const asset = requireTilemap(project, this.assetId); if (asset.layers.length <= 1) throw new CommandError('A tilemap keeps at least one layer'); const removed = removeFirst(asset.layers, (layer) => layer.id === this.layerId); if (!removed) throw new CommandError(`Layer ${this.layerId} not found`); this.removed = removed; } undo(project: Project): void { if (this.removed) restoreAt(requireTilemap(project, this.assetId).layers, [this.removed]); } } /** Moves a layer in the drawing order; the last layer in the list is drawn on top. */ export class MoveTilemapLayerCommand extends MoveCommand { constructor( private readonly assetId: string, from: number, to: number, ) { super('Reorder layers', from, to); } protected move(project: Project, from: number, to: number): void { if (!moveItem(requireTilemap(project, this.assetId).layers, from, to)) throw new CommandError('No such layer'); } } export class RenameTilemapLayerCommand implements Command { readonly label = 'Rename layer'; private previous = ''; constructor( private readonly assetId: string, private readonly layerId: string, private readonly name: string, ) {} execute(project: Project): void { const layer = requireTilemapLayer(project, this.assetId, this.layerId); const name = this.name.trim(); if (!name) throw new CommandError('A layer needs a name'); this.previous = layer.name; layer.name = name; } undo(project: Project): void { requireTilemapLayer(project, this.assetId, this.layerId).name = this.previous; } } /** A layer's `z` above its tilemap's (#174); 0 is stored as nothing, the way a layer starts out. */ export class SetTilemapLayerZCommand implements Command { readonly label = 'Set layer z'; private previous: number | undefined; constructor( private readonly assetId: string, private readonly layerId: string, private readonly z: number, ) {} execute(project: Project): void { if (!Number.isFinite(this.z)) throw new CommandError('A layer z is a number'); const layer = requireTilemapLayer(project, this.assetId, this.layerId); this.previous = layer.z; setLayerZ(layer, this.z); } undo(project: Project): void { setLayerZ(requireTilemapLayer(project, this.assetId, this.layerId), this.previous ?? 0); } } function setLayerZ(layer: TilemapLayer, z: number): void { if (z === 0) delete layer.z; else layer.z = z; } /** Whether a layer is drawn row by row and sorted in by Y (#228); off is stored as nothing. */ export class SetTilemapLayerSortByYCommand implements Command { readonly label: string; constructor( private readonly assetId: string, private readonly layerId: string, private readonly sortByY: boolean, ) { this.label = sortByY ? 'Sort layer by Y' : 'Stop sorting layer by Y'; } execute(project: Project): void { this.apply(project, this.sortByY); } undo(project: Project): void { this.apply(project, !this.sortByY); } private apply(project: Project, sortByY: boolean): void { const layer = requireTilemapLayer(project, this.assetId, this.layerId); if (sortByY) layer.sortByY = true; else delete layer.sortByY; } } /** A hidden layer keeps its cells and is left out of the drawing, in the editor and in the game alike. */ export class SetTilemapLayerHiddenCommand implements Command { readonly label: string; constructor( private readonly assetId: string, private readonly layerId: string, private readonly hidden: boolean, ) { this.label = hidden ? 'Hide layer' : 'Show layer'; } execute(project: Project): void { this.apply(project, this.hidden); } undo(project: Project): void { this.apply(project, !this.hidden); } private apply(project: Project, hidden: boolean): void { const layer = requireTilemapLayer(project, this.assetId, this.layerId); if (hidden) layer.hidden = true; else delete layer.hidden; } }