Newer
Older
rpg / v2 / packages / engine / src / models / format.ts
/**
 * Формат воксельной модели (JSON): размер, плотные данные base64,
 * слоты палитры. Модель — данные: генераторы/импорт .vox/ручная правка
 * дают один и тот же формат; валидация — числовая, без рендера.
 *
 * Слой Y растёт вверх; порядок вокселей в данных: x, затем y, затем z
 * (idx = x + sx*(y + sy*z) — как в VoxelGrid).
 */
import { VoxelGrid } from '../voxel/grid';

/**
 * Тайль пиксель-арта на грани вокселя: деталь меньше вокселя (лицо, пряди,
 * пряжка) живёт текстурой, а не геометрией. Пиксель — слот палитры модели,
 * поэтому перекраска персонажа (MannequinPalette) красит и текстуры.
 */
export interface VoxelTile {
    /** Размер грани в текселях 1..8 (грань квадратная). */
    size: number;
    /**
     * 6 граней в порядке +x,−x,+y,−y,+z,−z; каждая — base64 из size² байт,
     * байт = слот палитры (0 запрещён — прозрачных текселей нет).
     */
    faces: string[];
}

export interface VoxelModel {
    /** Размер по осям [sx, sy, sz], каждая > 0. */
    size: [number, number, number];
    /** Слоты палитры: слот-индекс сцены → hex; значения 1..255 — воксели ссылаются на ключи. */
    palette: Record<string, string>;
    /** base64 плотных данных: 1 байт на воксель, 0 = пусто. */
    data: string;
    /** Текстуры граней: ключ = id тайля 1..255. */
    tiles?: Record<string, VoxelTile>;
    /** base64: 1 байт на воксель — id тайля (0 = плоский цвет), длина = объём. */
    tex?: string;
}

const MAX_DIM = 1024;      // предел по оси — защита от мусора и OOM
const MAX_VOXELS = 32 * 1024 * 1024; // ~32 МБ данных

/** Текстурный слой для encodeModel: тайли + id тайля на воксель. */
export interface ModelTexture {
    tiles: Record<number, VoxelTile>;
    /** id тайля на воксель (0 = плоский цвет), длина = объём сетки. */
    tex: Uint8Array;
}

/** Кодирует сетку в модель: палитра из карты слот→hex, текстуры — опционально. */
export function encodeModel(grid: VoxelGrid, palette: Record<string, string>, texture?: ModelTexture): VoxelModel {
    const bytes = new Uint8Array(grid.sx * grid.sy * grid.sz);
    for (let i = 0; i < bytes.length; i++) bytes[i] = grid.data[i];
    const model: VoxelModel = {
        size: [grid.sx, grid.sy, grid.sz],
        palette: { ...palette },
        data: bytesToBase64(bytes),
    };
    if (texture) {
        model.tiles = Object.fromEntries(
            Object.entries(texture.tiles).map(([k, t]) => [k, { size: t.size, faces: [...t.faces] }]),
        );
        model.tex = bytesToBase64(texture.tex);
    }
    return model;
}

/** Id тайля вокселя i (0 = плоский цвет) — модели без текстур отвечают 0. */
export function texIdAt(model: VoxelModel, i: number): number {
    if (!model.tex) return 0;
    return base64ToBytes(model.tex)[i] ?? 0;
}

/** Раскладка tex целиком (для поз: один decode на весь проход) или null. */
export function texBytes(model: VoxelModel): Uint8Array | null {
    return model.tex ? base64ToBytes(model.tex) : null;
}

/** Разрешённый тайль: пиксели граней в hex (по палитре модели) — вход рендера. */
export interface ResolvedTile {
    size: number;
    /** faces[грань][пиксель] = hex. */
    faces: string[][];
}

/** Разворачивает тайли модели в hex-пиксели по её палитре (чистая функция). */
export function resolveTiles(model: VoxelModel): Record<number, ResolvedTile> {
    const out: Record<number, ResolvedTile> = {};
    if (!model.tiles) return out;
    for (const [id, tile] of Object.entries(model.tiles)) {
        out[Number(id)] = {
            size: tile.size,
            faces: tile.faces.map((face) => {
                const bytes = base64ToBytes(face);
                return Array.from(bytes, (b) => model.palette[String(b)] ?? '#000000');
            }),
        };
    }
    return out;
}

/** Декодирует модель в сетку. Бросает на невалидных данных (см. validateModel). */
export function decodeModel(model: VoxelModel): VoxelGrid {
    assertModelValid(model);
    const [sx, sy, sz] = model.size;
    const bytes = base64ToBytes(model.data);
    if (bytes.length !== sx * sy * sz) {
        throw new Error(`decodeModel: данных ${bytes.length} байт, а нужно ${sx * sy * sz}`);
    }
    const grid = new VoxelGrid(sx, sy, sz);
    for (let i = 0; i < bytes.length; i++) grid.data[i] = bytes[i];
    return grid;
}

/**
 * Числовая валидация модели без декодирования. Возвращает список проблем;
 * пустой список = модель валидна. Проверяет: размер, объём, hex-слоты,
 * что данные — валидный base64 нужной длины, что байты ссылаются на слоты.
 */
export function validateModel(model: unknown): string[] {
    const problems: string[] = [];
    const m = model as Partial<VoxelModel> | null;
    if (!m || typeof m !== 'object') return ['модель — не объект'];
    if (!Array.isArray(m.size) || m.size.length !== 3 ||
        m.size.some((n) => !Number.isInteger(n) || n <= 0 || n > MAX_DIM)) {
        problems.push(`size должен быть [sx,sy,sz] с целыми 1..${MAX_DIM}`);
        return problems; // без размера дальше проверять нечего
    }
    const [sx, sy, sz] = m.size;
    const volume = sx * sy * sz;
    if (volume > MAX_VOXELS) problems.push(`объём ${volume} > ${MAX_VOXELS}`);

    if (!m.palette || typeof m.palette !== 'object') {
        problems.push('palette должен быть объектом слот→hex');
    } else {
        for (const [slot, hex] of Object.entries(m.palette)) {
            const idx = Number(slot);
            if (!Number.isInteger(idx) || idx < 1 || idx > 255) {
                problems.push(`слот «${slot}» вне 1..255`);
            }
            if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(hex)) {
                problems.push(`слот ${slot}: hex «${hex}» не вида #rgb/#rrggbb`);
            }
        }
    }

    const bytes = safeBase64(m.data);
    if (!bytes) {
        problems.push('data — не валидный base64');
        return problems;
    }
    if (bytes.length !== volume) {
        problems.push(`данных ${bytes.length} байт, а size требует ${volume}`);
        return problems;
    }
    // Ссылки на слоты проверяем только при целостной палитре-объекте
    const palette = m.palette;
    if (palette && typeof palette === 'object' && !Array.isArray(palette)) {
        for (const b of bytes) {
            if (b !== 0 && !(b in palette)) {
                problems.push(`воксель со слотом ${b} отсутствует в палитре`);
                break;
            }
        }
    }

    validateTexture(m, volume, palette, problems);
    return problems;
}

/** Кидает первую проблему валидации (удобно в decode). */
export function assertModelValid(model: unknown): void {
    const problems = validateModel(model);
    if (problems.length > 0) throw new Error(`модель невалидна: ${problems[0]}`);
}

/** Валидация текстурного слоя: тайли (размер/грани/слоты) и раскладка tex. */
function validateTexture(
    m: Partial<VoxelModel>, volume: number,
    palette: Record<string, string> | undefined, problems: string[],
): void {
    if (m.tex !== undefined && m.tiles === undefined) {
        problems.push('tex задан, но tiles отсутствуют');
        return;
    }
    if (m.tiles !== undefined && m.tex === undefined) {
        problems.push('tiles заданы, но tex отсутствует');
        return;
    }
    if (m.tiles === undefined) return; // текстурного слоя нет — норма

    const tiles = m.tiles as Record<string, unknown>;
    if (typeof tiles !== 'object' || Array.isArray(tiles)) {
        problems.push('tiles должен быть объектом id→тайль');
        return;
    }
    for (const [key, tile] of Object.entries(tiles)) {
        const id = Number(key);
        if (!Number.isInteger(id) || id < 1 || id > 255) {
            problems.push(`id тайля «${key}» вне 1..255`);
            continue;
        }
        const t = tile as Partial<VoxelTile> | null;
        if (!t || typeof t !== 'object' || !Number.isInteger(t.size) || t.size! < 1 || t.size! > 8) {
            problems.push(`тайль ${id}: size должен быть целым 1..8`);
            continue;
        }
        if (!Array.isArray(t.faces) || t.faces.length !== 6) {
            problems.push(`тайль ${id}: faces должен быть массивом из 6 граней`);
            continue;
        }
        t.faces.forEach((face, fi) => {
            const bytes = safeBase64(face);
            if (!bytes || bytes.length !== t.size! * t.size!) {
                problems.push(`тайль ${id} грань ${fi}: base64 длины ${t.size!}² ожидается`);
                return;
            }
            for (const b of bytes) {
                if (b === 0) { problems.push(`тайль ${id} грань ${fi}: тексель 0 запрещён`); break; }
                if (!(b in (palette ?? {}))) { problems.push(`тайль ${id} грань ${fi}: слот ${b} не в палитре`); break; }
            }
        });
    }

    const tex = safeBase64(m.tex);
    if (!tex || tex.length !== volume) {
        problems.push(`tex: данных ${tex?.length ?? 'нет'}, а нужно ${volume}`);
        return;
    }
    for (const id of tex) {
        if (id !== 0 && !(id in tiles)) {
            problems.push(`tex ссылается на тайль ${id}, которого нет в tiles`);
            break;
        }
    }
}

function bytesToBase64(bytes: Uint8Array): string {
    let bin = '';
    for (let i = 0; i < bytes.length; i += 0x8000) {
        bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
    }
    return btoa(bin);
}

function base64ToBytes(b64: string): Uint8Array {
    const bin = atob(b64);
    const bytes = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
    return bytes;
}

function safeBase64(s: unknown): Uint8Array | null {
    if (typeof s !== 'string') return null;
    try { return base64ToBytes(s); } catch { return null; }
}