Newer
Older
rpg / packages / engine / src / map / mapFormat.ts
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})`);
    }
    return {
        width: f.width,
        height: f.height,
        tiles,
        blocked: f.blocked ?? [],
        tall: f.tall
    };
}