/** * A placed tilemap as a running game sees it (#135). A behavior may read and change its cells, but * the cells belong to the asset, and two placements of one map must not paint each other. So a run * keeps what it changed per placement, as edits laid over the asset's cells, and never writes them * back: after Stop the map is the one that was painted. */ import type { TilemapAsset, TilemapLayer } from './Asset'; import { apply, determinant, invert, type Mat } from './matrix'; import type { PropertyBag, Vec2 } from './Properties'; import { type Cell, cellAt, cellAtPoint, cellKey, cellOrigin, inGrid, tilemapTileSize } from './tilemap'; import { cellsVersion } from './tilemapMemo'; /** * What a run changed on one placement: per layer id, the cells it set, `''` for one it emptied. * Replaced rather than changed in place, so whoever worked something out from the old one can tell. */ export type TileEdits = Readonly>>>; /** How a node's empty layer choice reads: the first layer of whatever map it is given. */ export const FIRST_TILE_LAYER = 'First layer'; /** * The layer a node names. By name, not by index, since the index moves when the layers are * reordered; no name is the first layer, which is all a map of one layer needs. Two layers of one * name are the upper one in the list, the same one the panel shows first. */ export function layerNamed(asset: TilemapAsset, name: string): TilemapLayer | undefined { return name === '' ? asset.layers[0] : asset.layers.find((layer) => layer.name === name); } /** The asset's layers with a run's edits laid over them – what is drawn and what blocks. */ export function liveLayers(asset: TilemapAsset, edits: TileEdits | undefined): TilemapLayer[] { if (!edits) return asset.layers; return asset.layers.map((layer) => { const changed = edits[layer.id]; return changed ? editedLayer(layer, changed) : layer; }); } /** * Laid over once per edits and layer content: the same object each frame is what lets the tiles and * solids worked out from it be remembered (`tilemapMemo.ts`). */ function editedLayer(layer: TilemapLayer, changed: Readonly>): TilemapLayer { let byLayer = laidOver.get(changed); if (!byLayer) laidOver.set(changed, (byLayer = new WeakMap())); const version = cellsVersion(layer.cells); const held = byLayer.get(layer); if (held?.version === version && sameBesidesCells(held.layer, layer)) return held.layer; const cells: Record = { ...layer.cells }; for (const [key, tileId] of Object.entries(changed)) { if (tileId) cells[key] = tileId; else delete cells[key]; } const edited = { ...layer, cells }; byLayer.set(layer, { version, layer: edited }); return edited; } /** A layer's settings (name, z, hidden …) are changed in place too, so they are compared one by one. */ function sameBesidesCells(a: TilemapLayer, b: TilemapLayer): boolean { const keys = Object.keys(b) as (keyof TilemapLayer)[]; return keys.length === Object.keys(a).length && keys.every((key) => key === 'cells' || a[key] === b[key]); } const laidOver = new WeakMap>(); /** The asset as a run sees it: the same one when nothing was changed. */ export function liveTilemap(asset: TilemapAsset, edits: TileEdits | undefined): TilemapAsset { return edits ? { ...asset, layers: liveLayers(asset, edits) } : asset; } /** * The tile in a cell right now, `''` for an empty one. Null when there is nothing to ask: no layer * of that name, or a cell outside the grid. */ export function liveTileAt( asset: TilemapAsset, edits: TileEdits | undefined, props: PropertyBag, layerName: string, cell: Cell, ): string | null { const layer = layerNamed(asset, layerName); if (!layer || !inGrid(props, cell.x, cell.y)) return null; const edited = edits?.[layer.id]?.[cellKey(cell.x, cell.y)]; return edited ?? cellAt(layer, cell.x, cell.y); } /** * The edits with one more cell set, `''` emptying it; null when the cell cannot be set (see * `liveTileAt`). Setting what is already there gives the same edits back, so nothing is rebuilt. */ export function withTile( asset: TilemapAsset, edits: TileEdits | undefined, props: PropertyBag, layerName: string, cell: Cell, tileId: string, ): TileEdits | null { const now = liveTileAt(asset, edits, props, layerName, cell); if (now === null) return null; if (now === tileId) return edits ?? {}; const layer = layerNamed(asset, layerName)!; return { ...edits, [layer.id]: { ...edits?.[layer.id], [cellKey(cell.x, cell.y)]: tileId } }; } /** * The cell a world point falls into, and whether that cell is on the grid. The map may be moved, * turned, scaled and mirrored, so the point is taken into the map's own frame first; a map scaled * to nothing has no cells at all. */ export function worldToCell(props: PropertyBag, m: Mat, point: Vec2): Cell & { inside: boolean } { if (Math.abs(determinant(m)) < 1e-12) return { x: 0, y: 0, inside: false }; const local = apply(invert(m), point); const cell = cellAtPoint(props, local.x, local.y); return { ...cell, inside: inGrid(props, cell.x, cell.y) }; } /** The middle of a cell in world units – where something standing on that cell is put. */ export function cellToWorld(props: PropertyBag, m: Mat, cell: Cell): Vec2 { const half = tilemapTileSize(props) / 2; const corner = cellOrigin(props, cell.x, cell.y); return apply(m, { x: corner.x + half, y: corner.y + half }); }