Newer
Older
rpg / packages / engine / src / map / mapFormat.ts
import type { TileMapData, TallSpec, PropData } 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[];
    /** '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,
        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);
    return {
        width: f.width,
        height: f.height,
        tiles,
        blocked: f.blocked ?? [],
        tall: f.tall,
        props: f.props
    };
}

/** Валидация пропов: целые 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} — похоже, высота задана в пикселях (ожидается мировые юниты)`);
        }
    }
}