116 lines
4.4 KiB
TypeScript
116 lines
4.4 KiB
TypeScript
/**
|
||
* 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<string, string>): 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<string, number>();
|
||
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<string, string> {
|
||
const tiles: unknown[] = Array.isArray(raw.tiles) ? raw.tiles : [];
|
||
const origin = raw.origin && typeof raw.origin === 'object' ? (raw.origin as Record<string, unknown>) : {};
|
||
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<string, string> = {};
|
||
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;
|
||
}
|