/** * A tilemap is a grid of cells, and every cell names the tile lying there. Which of that tile's * quarters a cell shows is *not* stored: it follows from the neighbours and is worked out * while drawing (#128), so painting one cell fixes the edges of the ones around it by itself. * * Cells are sparse – a map from `"x,y"` to a tile id – rather than one entry per grid square. * A map is what painting, erasing and reading a neighbour all want, and changing the grid size then * moves nothing: what falls outside is simply not drawn and comes back when the grid grows again. * The project file stores them more compactly (#134, `store/tilemapCells.ts`). */ import type { TileAsset, TilemapLayer } from './Asset'; import { type Neighbours, type TileQuadrants, tileQuadrants } from './autotile'; import { type TileKind, tileKind } from './tile'; import type { PropertyBag } from './Properties'; import { findAssetOf, type Project } from './Project'; import { cellsTileIds, cellsVersion, KeyedMemo } from './tilemapMemo'; export const TILEMAP_COLUMNS_KEY = 'columns'; export const TILEMAP_ROWS_KEY = 'rows'; export const TILEMAP_TILE_KEY = 'tileSize'; /** The most columns or rows a grid may have – the schema's limit, and the edge buttons' too. */ export const TILEMAP_MAX_CELLS = 1000; /** * The grid is read from a property bag rather than from the asset: a placed tilemap may override * its size like any other row, and the footprint the scene view picks and frames by has nothing * but the bag (see `properties/bounds.ts`). */ export function tilemapColumns(props: PropertyBag): number { return wholeAbove(props[TILEMAP_COLUMNS_KEY], 20); } export function tilemapRows(props: PropertyBag): number { return wholeAbove(props[TILEMAP_ROWS_KEY], 15); } /** Side of one cell in world units; the tile's own pictures are scaled onto it. */ export function tilemapTileSize(props: PropertyBag): number { const size = Number(props[TILEMAP_TILE_KEY]); return Number.isFinite(size) && size > 0 ? size : 48; } function wholeAbove(value: unknown, fallback: number): number { const count = Math.floor(Number(value)); return Number.isFinite(count) && count >= 1 ? count : fallback; } export function tilemapSize(props: PropertyBag): { w: number; h: number } { const tile = tilemapTileSize(props); return { w: tilemapColumns(props) * tile, h: tilemapRows(props) * tile }; } /** A cell of the grid by column and row; `0,0` is the top-left corner. */ export interface Cell { x: number; y: number; } export function cellKey(x: number, y: number): string { return `${x},${y}`; } /** Read without `split`: a map reads every key of a layer again after each painted cell (#347). */ export function parseCellKey(key: string): Cell | null { const comma = key.indexOf(','); if (comma < 0) return null; const end = key.indexOf(',', comma + 1); const x = Number(key.slice(0, comma)); const y = Number(end < 0 ? key.slice(comma + 1) : key.slice(comma + 1, end)); return Number.isInteger(x) && Number.isInteger(y) ? { x, y } : null; } export function cellAt(layer: TilemapLayer, x: number, y: number): string { return layer.cells[cellKey(x, y)] ?? ''; } /** * The tile a cell holds as far as anything but the file is concerned: `''` for an empty cell and for * one naming a tile the project does not have (#268). Such a cell draws and blocks nothing, so it * reads and fills as empty too. */ export function presentTile(project: Project, tileId: string): string { return tileId !== '' && findAssetOf(project, tileId, 'tile') ? tileId : ''; } /** The cells without those naming `tileId`, or null when none does. */ export function cellsWithout(cells: Record, tileId: string): Record | null { if (!Object.values(cells).includes(tileId)) return null; return Object.fromEntries(Object.entries(cells).filter(([, id]) => id !== tileId)); } export function isLayerVisible(layer: TilemapLayer): boolean { return layer.hidden !== true; } /** How far a layer is drawn above its tilemap's own `z` (#174); a layer never given one sits on it. */ export function layerZ(layer: TilemapLayer): number { return layer.z ?? 0; } export function findLayer(asset: { layers: TilemapLayer[] }, layerId: string): TilemapLayer | undefined { return asset.layers.find((layer) => layer.id === layerId); } export function inGrid(props: PropertyBag, x: number, y: number): boolean { return x >= 0 && y >= 0 && x < tilemapColumns(props) && y < tilemapRows(props); } /** * Which neighbours a cell is joined to. Only the *same* tile joins: a different one counts as * empty, so every autotile draws its own border against everything else – what the RPG-Maker sheets * are drawn for. Outside the grid counts as empty too, so a map ends with a proper edge. */ export function neighboursOf( props: PropertyBag, layer: TilemapLayer, x: number, y: number, tileId: string, ): Neighbours { const same = (dx: number, dy: number): boolean => inGrid(props, x + dx, y + dy) && cellAt(layer, x + dx, y + dy) === tileId; return { n: same(0, -1), e: same(1, 0), s: same(0, 1), w: same(-1, 0), ne: same(1, -1), se: same(1, 1), sw: same(-1, 1), nw: same(-1, -1), }; } /** * A layer's painted cells inside the grid, read once per content and grid size: in reading order, * and by place. Reading the `x,y` keys again for every cell and each of its neighbours was most of * what one painted cell cost a big map (#347). */ export interface LayerCells { columns: number; rows: number; list: readonly { x: number; y: number; tileId: string }[]; /** Tile id by `y * columns + x`. */ at: ReadonlyMap; } const indexed = new WeakMap, LayerCells & { version: number }>(); export function layerCells(props: PropertyBag, layer: TilemapLayer): LayerCells { const columns = tilemapColumns(props); const rows = tilemapRows(props); const version = cellsVersion(layer.cells); const held = indexed.get(layer.cells); if (held && held.version === version && held.columns === columns && held.rows === rows) return held; const at = new Map(); for (const [key, tileId] of Object.entries(layer.cells)) { const cell = parseCellKey(key); if (!tileId || !cell || cell.x < 0 || cell.y < 0 || cell.x >= columns || cell.y >= rows) continue; at.set(cell.y * columns + cell.x, tileId); } const list = [...at.keys()] .sort((a, b) => a - b) .map((i) => ({ x: i % columns, y: Math.floor(i / columns), tileId: at.get(i)! })); const fresh = { columns, rows, list, at, version }; indexed.set(layer.cells, fresh); return fresh; } /** `neighboursOf`, read from the index. */ export function neighboursIn(cells: LayerCells, x: number, y: number, tileId: string): Neighbours { const { columns, rows, at } = cells; const same = (dx: number, dy: number): boolean => { const nx = x + dx; const ny = y + dy; return nx >= 0 && ny >= 0 && nx < columns && ny < rows && at.get(ny * columns + nx) === tileId; }; return { n: same(0, -1), e: same(1, 0), s: same(0, 1), w: same(-1, 0), ne: same(1, -1), se: same(1, 1), sw: same(-1, 1), nw: same(-1, -1), }; } /** One cell as it is drawn: where it sits, which tile it is and which quarters build it. */ export interface TileDraw { x: number; y: number; tileId: string; quadrants: TileQuadrants; } /** * Every cell of one layer that is inside the grid and names a tile that exists, in reading order * so the result is the same list twice. A cell naming a tile the project does not have is skipped * (see `presentTile`). */ export function layerTiles(project: Project, props: PropertyBag, layer: TilemapLayer): readonly TileDraw[] { const kinds = new Map(); for (const id of cellsTileIds(layer.cells)) { const tile: TileAsset | undefined = findAssetOf(project, id, 'tile'); if (tile) kinds.set(id, tileKind(tile)); } const key = [ tilemapColumns(props), tilemapRows(props), cellsVersion(layer.cells), [...kinds].map(([id, kind]) => `${id}:${kind}`).join(','), ].join('|'); return drawn.get(key, () => workOutLayerTiles(props, layer, kinds)); } const drawn = new KeyedMemo(); function workOutLayerTiles( props: PropertyBag, layer: TilemapLayer, kinds: Map, ): TileDraw[] { const cells = layerCells(props, layer); const tiles: TileDraw[] = []; for (const { x, y, tileId } of cells.list) { const kind = kinds.get(tileId); if (!kind) continue; tiles.push({ x, y, tileId, quadrants: tileQuadrants(kind, neighboursIn(cells, x, y, tileId)) }); } return tiles; } /** * The layer names of every tilemap in the project, each once, in asset and layer order. A node * picks a layer by name before it knows which map it will be given (#135), so it is offered them all. */ export function tilemapLayerNames(project: Project): string[] { const names = project.assets.flatMap((asset) => asset.type === 'tilemap' ? asset.layers.map((layer) => layer.name) : [], ); return [...new Set(names)]; } /** * The cell a point in the tilemap's own frame falls into. The grid is centred on the object's * position, as every footprint is, so the top-left corner lies half a map up and to the left. */ export function cellAtPoint(props: PropertyBag, x: number, y: number): Cell { const tile = tilemapTileSize(props); const { w, h } = tilemapSize(props); return { x: Math.floor((x + w / 2) / tile), y: Math.floor((y + h / 2) / tile) }; } /** Where the top-left corner of a cell sits in the tilemap's own frame, in world units. */ export function cellOrigin(props: PropertyBag, x: number, y: number): { x: number; y: number } { const tile = tilemapTileSize(props); const { w, h } = tilemapSize(props); return { x: x * tile - w / 2, y: y * tile - h / 2 }; } /** A side of the grid that grows or shrinks (#136). */ export type TilemapEdge = 'left' | 'top' | 'right' | 'bottom'; export const TILEMAP_EDGES: readonly TilemapEdge[] = ['left', 'top', 'right', 'bottom']; /** Left and right change the columns, top and bottom the rows. */ export function edgeSizeKey(edge: TilemapEdge): string { return edge === 'left' || edge === 'right' ? TILEMAP_COLUMNS_KEY : TILEMAP_ROWS_KEY; } /** * One layer's cells moved by whole cells. Growing at the left or top edge is this: cell `0,0` is * always the top-left corner, so what was painted moves one column or row further in. Nothing is * dropped – a cell pushed below zero lies outside the grid and comes back when the edge grows again. */ export function shiftedCells(cells: Record, dx: number, dy: number): Record { const moved: Record = {}; for (const [key, tileId] of Object.entries(cells)) { const at = parseCellKey(key); if (at) moved[cellKey(at.x + dx, at.y + dy)] = tileId; } return moved; } /** * How far the origin moves, in the tilemap's own frame, for the painted cells to stay where they are * when `edge` gains `count` cells (negative removes them). The grid is centred on the origin, so * every added column pushes the content half a cell away from that edge; the origin follows by as * much the other way. */ export function edgeCompensation( edge: TilemapEdge, count: number, tileSize: number, ): { x: number; y: number } { const half = (count * tileSize) / 2; switch (edge) { case 'left': return { x: -half, y: 0 }; case 'right': return { x: half, y: 0 }; case 'top': return { x: 0, y: -half }; case 'bottom': return { x: 0, y: half }; } } /** The cells of the rectangle between two corners, both included and clipped to the grid. */ export function cellsInRect(props: PropertyBag, a: Cell, b: Cell): Cell[] { const cells: Cell[] = []; const x0 = Math.max(0, Math.min(a.x, b.x)); const x1 = Math.min(tilemapColumns(props) - 1, Math.max(a.x, b.x)); const y0 = Math.max(0, Math.min(a.y, b.y)); const y1 = Math.min(tilemapRows(props) - 1, Math.max(a.y, b.y)); for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) cells.push({ x, y }); 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. A cell naming a missing tile is empty here. */ export function floodFill( project: Project, props: PropertyBag, layer: TilemapLayer, start: Cell, tileId: string, ): Cell[] { if (!inGrid(props, start.x, start.y)) return []; const present = new Map(); const tileAt = (x: number, y: number): string => { const id = cellAt(layer, x, y); let shown = present.get(id); if (shown === undefined) present.set(id, (shown = presentTile(project, id))); return shown; }; const from = tileAt(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) || tileAt(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. */ export function cellsAlong(from: Cell, to: Cell): Cell[] { const steps = Math.max(Math.abs(to.x - from.x), Math.abs(to.y - from.y)); if (steps === 0) return [{ ...to }]; const cells: Cell[] = []; for (let i = 1; i <= steps; i++) cells.push({ x: Math.round(from.x + ((to.x - from.x) * i) / steps), y: Math.round(from.y + ((to.y - from.y) * i) / steps), }); return cells; }