Tilemap cells are stored compactly in the project file: each layer keeps a tile list, an origin and one run-length encoded string per row; older projects with a cell map still load (#134)
game-maker / gate (push) Successful in 1m40s
game-maker / suite (push) Successful in 5m59s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 09:17:49 +02:00
1 parent 732f6518ae
commit 10b49fac46
6 files changed
+319 -4

No files matched your search

+1
View File
@@ -6,6 +6,7 @@
* 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';
+16 -3
View File
@@ -28,6 +28,7 @@ import type {
TilemapLayer,
} from '../model/Asset';
import { parseCellKey } from '../model/tilemap';
import { decodeCells, encodeCells, isStoredCells } from './tilemapCells';
import { isAssetType } from '../model/AssetTypes';
import { isFontFile } from '../model/font';
import { isSoundFile } from '../model/sound';
@@ -218,9 +219,19 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
return project;
}
/** Plain JSON-ready copy of the project (deep clone, stable key order for readable diffs). */
/**
* Plain JSON-ready copy of the project (deep clone, stable key order for readable diffs). The cells
* of a tilemap are the one thing written differently from how they are held: see `tilemapCells.ts`.
*/
export function serializeProject(project: Project): unknown {
return JSON.parse(JSON.stringify(project));
const copy = JSON.parse(JSON.stringify(project)) as Project;
for (const asset of copy.assets)
if (asset.type === 'tilemap')
(asset as { layers: unknown[] }).layers = asset.layers.map(({ cells, ...layer }) => ({
...layer,
...encodeCells(cells),
}));
return copy;
}
function normalizeObjects(
@@ -513,8 +524,10 @@ function normalizeLayers(raw: unknown): TilemapLayer[] {
for (const item of Array.isArray(raw) ? raw : []) {
const l = asRecord(item);
if (typeof l.id !== 'string' || !l.id || layers.some((o) => o.id === l.id)) continue;
// Projects saved before #134 hold the map itself; it is taken over as it is.
const stored = isStoredCells(l) ? decodeCells(l) : asRecord(l.cells);
const cells: Record<string, string> = {};
for (const [key, value] of Object.entries(asRecord(l.cells)))
for (const [key, value] of Object.entries(stored))
if (parseCellKey(key) && typeof value === 'string' && value !== '') cells[key] = value;
const layer: TilemapLayer = { id: l.id, name: typeof l.name === 'string' ? l.name : 'Layer', cells };
if (l.hidden === true) layer.hidden = true;
+120
View File
@@ -0,0 +1,120 @@
/**
* 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;
}
/** Whether a stored layer is in this format rather than the map of the first version (#128). */
export function isStoredCells(raw: Record<string, unknown>): boolean {
return Array.isArray(raw.rows);
}