/** * 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'; export const TILEMAP_COLUMNS_KEY = 'columns'; export const TILEMAP_ROWS_KEY = 'rows'; export const TILEMAP_TILE_KEY = 'tileSize'; /** * 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 }; } export function cellKey(x: number, y: number): string { return `${x},${y}`; } export function parseCellKey(key: string): { x: number; y: number } | null { const [x, y] = key.split(',').map(Number); return Number.isInteger(x) && Number.isInteger(y) ? { x: x as number, y: y as number } : null; } export function cellAt(layer: TilemapLayer, x: number, y: number): string { return layer.cells[cellKey(x, y)] ?? ''; } export function isLayerVisible(layer: TilemapLayer): boolean { return layer.hidden !== true; } 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), }; } /** 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. Cells naming a deleted tile are skipped rather than * dropped from the model: an undo brings the tile, and with it the drawing, back. */ export function layerTiles(project: Project, props: PropertyBag, layer: TilemapLayer): TileDraw[] { const kinds = new Map(); const kindOf = (id: string): TileKind | null => { const known = kinds.get(id); if (known) return known; const tile: TileAsset | undefined = findAssetOf(project, id, 'tile'); if (!tile) return null; const kind = tileKind(tile); kinds.set(id, kind); return kind; }; const tiles: TileDraw[] = []; for (const [key, tileId] of Object.entries(layer.cells)) { const at = parseCellKey(key); if (!at || !tileId || !inGrid(props, at.x, at.y)) continue; const kind = kindOf(tileId); if (!kind) continue; tiles.push({ x: at.x, y: at.y, tileId, quadrants: tileQuadrants(kind, neighboursOf(props, layer, at.x, at.y, tileId)), }); } return tiles.sort((a, b) => a.y - b.y || a.x - b.x); } /** The tiles a tilemap paints with, in the order they first appear – its palette in use. */ export function tilemapTileIds(asset: { layers: TilemapLayer[] }): string[] { const ids: string[] = []; for (const layer of asset.layers) for (const id of Object.values(layer.cells)) if (id && !ids.includes(id)) ids.push(id); return ids; } /** * 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): { x: number; y: number } { 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 }; } /** The cells of the rectangle between two corners, both included and clipped to the grid. */ export function cellsInRect( props: PropertyBag, a: { x: number; y: number }, b: { x: number; y: number }, ): { x: number; y: number }[] { const cells: { x: number; y: number }[] = []; 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 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: { x: number; y: number }, to: { x: number; y: number }, ): { x: number; y: number }[] { const steps = Math.max(Math.abs(to.x - from.x), Math.abs(to.y - from.y)); if (steps === 0) return [{ ...to }]; const cells: { x: number; y: number }[] = []; 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; }