import type { TileMapData, TallSpec, PropData, DecorData } 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>;
/** Крупные объекты с footprint'ом в несколько тайлов. */
props?: PropData[];
/** Варианты текстур: id -> список id-шников вариантов (ключи — строки JSON). */
variants?: Record<string, number[]>;
/** Мелкий плоский декор на тайлах. */
decor?: DecorData[];
/** '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,
props: data.props,
variants: data.variants
? Object.fromEntries(Object.entries(data.variants).map(([id, ids]) => [String(id), ids]))
: undefined,
decor: data.decor,
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} — похоже, высота задана в пикселях (ожидается мировые юниты)`);
}
}
validateProps(f.props ?? [], f.width, f.height);
const decor = validateDecor(f.decor, f.width, f.height);
const variants = validateVariants(f.variants);
return {
width: f.width,
height: f.height,
tiles,
blocked: f.blocked ?? [],
tall: f.tall,
props: f.props,
decor,
variants
};
}
/** Валидация декора: тайл в границах карты, смещения — числа (пустой список -> undefined). */
function validateDecor(decor: DecorData[] | undefined, width: number, height: number): DecorData[] | undefined {
if (!decor || decor.length === 0) return undefined;
for (const d of decor) {
if (!Number.isInteger(d.id) || !Number.isInteger(d.x) || !Number.isInteger(d.y)) {
throw new Error('Карта: decor — id/x/y должны быть целыми числами');
}
if (d.x < 0 || d.y < 0 || d.x >= width || d.y >= height) {
throw new Error(`Карта: decor (${d.x}, ${d.y}) вне границ карты ${width}×${height}`);
}
if ((d.dx !== undefined && typeof d.dx !== 'number') || (d.dy !== undefined && typeof d.dy !== 'number')) {
throw new Error(`Карта: decor (${d.x}, ${d.y}) — dx/dy должны быть числами`);
}
}
return decor;
}
/** Валидация вариантов: непустые массивы целых id (пустой объект -> undefined). */
function validateVariants(variants: Record<string, number[]> | undefined): Record<number, number[]> | undefined {
if (!variants || Object.keys(variants).length === 0) return undefined;
const out: Record<number, number[]> = {};
for (const [key, ids] of Object.entries(variants)) {
if (!Array.isArray(ids) || ids.length === 0 || ids.some((v) => !Number.isInteger(v))) {
throw new Error(`Карта: variants[${key}] — ожидался непустой массив целых id`);
}
out[Number(key)] = ids;
}
return out;
}
/** Валидация пропов: целые w/h ≥ 1 и footprint целиком внутри карты. */
function validateProps(props: PropData[], width: number, height: number): void {
for (const p of props) {
const w = p.w ?? 1;
const h = p.h ?? 1;
if (!Number.isInteger(w) || !Number.isInteger(h) || w < 1 || h < 1) {
throw new Error(`Карта: prop (${p.x}, ${p.y}) — w/h должны быть целыми ≥ 1, получено ${w}×${h}`);
}
if (p.x < 0 || p.y < 0 || p.x + w > width || p.y + h > height) {
throw new Error(
`Карта: prop (${p.x}, ${p.y}) ${w}×${h} выходит за границы карты ${width}×${height}`
);
}
if (p.height !== undefined && p.height > 16) {
throw new Error(`Карта: prop.height=${p.height} — похоже, высота задана в пикселях (ожидается мировые юниты)`);
}
}
}