import type { TileMapData, TallSpec } from './IsometricTileMap';
/**
* Файловый формат карт движка: JSON с RLE-сжатием тайлов.
* RLE — пары [id, длина серии]: карта лугов 28×28 сжимается в разы,
* и её можно править текстовым редактором.
*/
export interface MapFileV1 {
format: 'rpg-map';
version: 1;
width: number;
height: number;
/** id тайлов, которые считаются блокирующими. */
blocked: number[];
/** Высокие объекты: id -> высота или полная спецификация. */
tall?: Record<string, number | TallSpec>;
/** 'rle' — tiles как пары [id, runLength]; 'raw' — плоский массив. */
encoding: 'rle' | 'raw';
tiles: number[] | [number, number][];
}
export type MapFile = MapFileV1;
/** Упаковать тайлы в RLE: последовательности одинаковых id -> пары [id, длина]. */
export function rleEncode(tiles: number[]): [number, number][] {
const out: [number, number][] = [];
let i = 0;
while (i < tiles.length) {
const id = tiles[i]!;
let run = 1;
while (i + run < tiles.length && tiles[i + run] === id) run++;
out.push([id, run]);
i += run;
}
return out;
}
/** Распаковать RLE обратно в плоский массив. */
export function rleDecode(pairs: [number, number][]): number[] {
const out: number[] = [];
for (const [id, run] of pairs) {
for (let k = 0; k < run; k++) out.push(id);
}
return out;
}
/** Сериализовать карту в файловый формат (RLE по умолчанию). */
export function encodeMap(data: TileMapData, encoding: 'rle' | 'raw' = 'rle'): MapFileV1 {
return {
format: 'rpg-map',
version: 1,
width: data.width,
height: data.height,
blocked: data.blocked,
tall: data.tall,
encoding,
tiles: encoding === 'rle' ? rleEncode(data.tiles) : data.tiles
};
}
/** Распарсить файл карты (с валидацией размеров). */
export function parseMap(file: unknown): TileMapData {
if (typeof file !== 'object' || file === null) {
throw new Error('Карта: ожидался JSON-объект');
}
const f = file as Partial<MapFileV1>;
if (f.format !== 'rpg-map') {
throw new Error('Карта: неверный format (ожидался rpg-map)');
}
if (f.version !== 1) {
throw new Error(`Карта: неподдерживаемая версия ${f.version}`);
}
if (typeof f.width !== 'number' || typeof f.height !== 'number' || !Array.isArray(f.tiles)) {
throw new Error('Карта: missing width/height/tiles');
}
let tiles: number[];
if (f.encoding === 'rle') {
tiles = rleDecode(f.tiles as [number, number][]);
} else {
tiles = f.tiles as number[];
if (tiles.some((t) => typeof t !== 'number')) {
throw new Error('Карта: raw-тайлы должны быть числами');
}
}
const expected = f.width * f.height;
if (tiles.length !== expected) {
throw new Error(`Карта: тайлов ${tiles.length}, ожидалось ${expected} (${f.width}×${f.height})`);
}
// Высоты высоких объектов задаются в мировых юнитах (1 юнит = 1 тайл);
// значение больше 16 почти наверняка значит протухший файл с высотой в px.
for (const spec of Object.values(f.tall ?? {})) {
const h = typeof spec === 'number' ? spec : spec.height;
if (h > 16) {
throw new Error(`Карта: tall.height=${h} — похоже, высота задана в пикселях (ожидается мировые юниты)`);
}
}
return {
width: f.width,
height: f.height,
tiles,
blocked: f.blocked ?? [],
tall: f.tall
};
}