/** * How the cells of a tilemap layer are written to the project file (#134). In memory a layer keeps * its sparse map from `"x,y"` to a tile id – what painting and reading a neighbour want – but in the * file every painted cell would then carry its own key and the full tile id, and the server writes * the file pretty-printed, one entry per line. * * So the file holds, per layer: * - `tiles`: every tile id the layer names, once, in the order they first appear; * - `origin`: the top-left cell of the smallest rectangle around all painted cells; * - `rows`: one string per row of that rectangle, as runs `index*count` (a single cell drops the * count), separated by spaces. `index` counts from 1 into `tiles`, `0` is an empty cell, and * empty cells at the end of a row are left out. * * A row of forty grass cells, three empty ones and a single path cell reads `1*40 0*3 2`. The * rectangle is taken from the cells, not from the grid, so cells outside the grid – kept so that * they come back when the grid grows again – are stored as they are. */ import { cellKey, parseCellKey } from '../model/tilemap'; export interface StoredCells { tiles: string[]; origin: { x: number; y: number }; rows: string[]; } /** * The longest run a row may state. A file is untrusted: `1*999999999` would otherwise build a * billion cells. No grid gets near this, so a real map never meets it. */ export const MAX_RUN = 65536; const RUN = /^(\d+)(?:\*(\d+))?$/; export function encodeCells(cells: Record): StoredCells { const painted: { x: number; y: number; id: string }[] = []; for (const [key, id] of Object.entries(cells)) { const at = parseCellKey(key); if (at && id) painted.push({ ...at, id }); } if (painted.length === 0) return { tiles: [], origin: { x: 0, y: 0 }, rows: [] }; painted.sort((a, b) => a.y - b.y || a.x - b.x); const tiles: string[] = []; const index = new Map(); for (const { id } of painted) if (!index.has(id)) { tiles.push(id); index.set(id, tiles.length); } const x0 = painted.reduce((min, c) => Math.min(min, c.x), Infinity); const y0 = painted[0]!.y; const y1 = painted[painted.length - 1]!.y; const rows: string[] = []; let next = 0; for (let y = y0; y <= y1; y++) { const runs: string[] = []; let x = x0; const push = (value: number, count: number): void => { runs.push(count === 1 ? String(value) : `${value}*${count}`); }; while (next < painted.length && painted[next]!.y === y) { const cell = painted[next]!; if (cell.x > x) push(0, cell.x - x); const value = index.get(cell.id)!; let count = 1; while ( next + count < painted.length && painted[next + count]!.y === y && painted[next + count]!.x === cell.x + count && painted[next + count]!.id === cell.id ) count++; push(value, count); x = cell.x + count; next += count; } rows.push(runs.join(' ')); } return { tiles, origin: { x: x0, y: y0 }, rows }; } /** * The cells a stored layer describes. Read leniently, as the rest of the file is: a run that does * not parse ends its row, because every run after it would land in the wrong column; an index * naming no tile is an empty cell. */ export function decodeCells(raw: { tiles?: unknown; origin?: unknown; rows?: unknown; }): Record { const tiles: unknown[] = Array.isArray(raw.tiles) ? raw.tiles : []; const origin = raw.origin && typeof raw.origin === 'object' ? (raw.origin as Record) : {}; const x0 = Number.isSafeInteger(origin.x) ? (origin.x as number) : 0; const y0 = Number.isSafeInteger(origin.y) ? (origin.y as number) : 0; const cells: Record = {}; const rows = Array.isArray(raw.rows) ? raw.rows : []; rows.forEach((row, r) => { if (typeof row !== 'string') return; let x = x0; for (const token of row.split(/\s+/)) { if (token === '') continue; const match = RUN.exec(token); const value = match ? Number(match[1]) : NaN; const count = match ? Number(match[2] ?? 1) : NaN; if (!Number.isSafeInteger(value) || !(count >= 1 && count <= MAX_RUN)) return; const id = value >= 1 ? tiles[value - 1] : undefined; if (typeof id === 'string' && id !== '') for (let i = 0; i < count; i++) cells[cellKey(x + i, y0 + r)] = id; x += count; } }); return cells; }