Newer
Older
rpg / v2 / apps / game / src / areas.ts
/**
 * Области «Пепельных луга» на v2: раскладка тайлов v1 (maps.json, референс)
 * → воксельный мир. 1 тайл v1 = 3×3 вокселя (1 юнит = 3 вокселя, как в бою
 * демки). Рельеф — плоский (земля y0-1), объекты — штампы генераторов
 * движка (деревья/валуны) и локальных генераторов контента (кусты, дома,
 * колодец, башня). Позиции v1 (NPC/переходы/квесты, в тайлах) конвертируются
 * на границе: центр тайла = (tile*UNIT + 1.5) в воксельных координатах.
 */
import {
    VoxelWorld, VoxelGrid, stampModel,
    encodeModel, Rng, type Variant,
} from '@rpg/engine';
import type { VoxelModel } from '@rpg/engine';
import mapsData from './data/maps.json';

/** Раскладка области (конвертация v1): тайлы RLE, blocked/tall/decor/props v1. */
export interface AreaLayout {
    width: number;
    height: number;
    /** RLE-пары [id, длина] построчно (idx = y*width + x). */
    tiles: [number, number][];
    blocked: number[];
    tall: Record<string, { height?: number; ground?: number }>;
    decor: { id: number; x: number; y: number }[];
    props: { id: number; x: number; y: number; w?: number; h?: number }[];
}

export const AREA_LAYOUTS = mapsData as unknown as Record<string, AreaLayout>;

/** Вокселей на тайл v1 (1 юнит = 3 вокселя — общий масштаб демки/боя). */
export const UNIT = 3;
/** Точка ног героя в вокселях (верх земли). */
export const GROUND_Y = 2;

// Слоты палитры сцены (арт-библия v1; 11+ — тайлы областей)
const ASH = 11;         // P2 — пепел (тёмный)
const ASH_LIGHT = 12;   // P3 — пепел, пятна
const WATER = 13;       // W1 — вода
const WATER_LIGHT = 14; // W2 — вода, блики
const FLOOR = 15;       // T0 — пол интерьеров
const PATH = 16;        // T1 — тропа
const RAIL = 17;        // M2 — рельсы (металл)
const FLOWER = 9;       // B1 — колокольчик (жизнь!)

// Тайлы раскладки v1 (TILES из data/map.ts v1)
const T = {
    GRASS: 0, PATH: 1, WATER: 2, TREE: 3, ASH: 4, BELLFLOWER: 5, TOWER: 6,
    HOUSE: 7, FLOOR: 8, WALL: 9, WELL: 10, WIRE: 18, RAIL: 19, STAIRS: 20,
    MACHINE: 21, TREE_V1: 22, TREE_V2: 23, HOUSE_V1: 24, HOUSE_V2: 25,
    HOUSE_V3: 26, HOUSE_V4: 27, BOULDER: 28, BUSH: 29, BOULDER_2: 30,
    BOULDER_3: 31, BUSH_2: 32, BUSH_3: 33,
} as const;

const TREES: Record<number, Variant> = { [T.TREE]: 0, [T.TREE_V1]: 1, [T.TREE_V2]: 2 };
const HOUSE_KINDS: readonly number[] = [T.HOUSE, T.HOUSE_V1, T.HOUSE_V2, T.HOUSE_V3, T.HOUSE_V4];
const BOULDER_KINDS: readonly number[] = [T.BOULDER, T.BOULDER_2, T.BOULDER_3];
const BUSH_KINDS: readonly number[] = [T.BUSH, T.BUSH_2, T.BUSH_3];

/** Детерминированное пятно по координате тайла (чистый хэш, без ГПСЧ). */
const mottle = (tx: number, ty: number, salt = 0) => (tx * 7 + ty * 13 + salt * 31) % 11 < 3;

// ---- Генераторы контента игры (деревья/кусты/валуны в тайл 3×3, дома...) ----

/** Дерево карты: высокое (заметно выше юнитов — герой 10), footprint тайла
 *  3×3 (крона не вылезает на соседей — blocked-семантика v1).
 *  Варианты: 0 круглая крона, 1 узкая колонна, 2 ель. */
export function mapTreeModel(variant: Variant, seed: number): VoxelModel {
    const rng = new Rng(seed ^ 0x7ee1);
    const trunkH = variant === 1 ? rng.range(6, 8) : rng.range(5, 7);
    const crownH = variant === 2 ? rng.range(5, 7) : rng.range(4, 6);
    const g = new VoxelGrid(3, trunkH + crownH + 1, 3);
    for (let y = 0; y < trunkH; y++) g.set(1, y, 1, 4); // ствол
    const r = 1 + rng.next() * 0.35; // джиттер радиуса фиксируем заранее
    for (let dy = 0; dy < crownH; dy++) {
        const y = trunkH - 1 + dy;
        for (let dz = -1; dz <= 1; dz++)
            for (let dx = -1; dx <= 1; dx++) {
                const d2 = dx * dx + dz * dz;
                let ok: boolean;
                if (variant === 0) ok = d2 + dy * dy * 0.6 <= r * r + 1;
                else if (variant === 1) ok = d2 <= 1;
                else ok = d2 <= (dy < crownH / 2 ? 2 : 1); // ель: ярусы сужаются
                if (!ok) continue;
                if (dy > 0 && d2 > 1 && !rng.chance(0.7)) continue; // рваный край
                const light = dy === crownH - 1 || rng.chance(0.25);
                g.set(1 + dx, y, 1 + dz, light ? 5 : 3);
            }
    }
    return encodeModel(g, { 3: '#626c4c', 5: '#7d8662', 4: '#5c3b2e' });
}

/** Куст карты: низкая крона без ствола, ровно в тайл. */
export function mapBushModel(variant: Variant, seed: number): VoxelModel {
    const rng = new Rng(seed ^ 0x5a51);
    const H = variant === 1 ? 3 : 2;
    const g = new VoxelGrid(3, H, 3);
    for (let y = 0; y < H; y++)
        for (let dz = -1; dz <= 1; dz++)
            for (let dx = -1; dx <= 1; dx++) {
                const d2 = dx * dx + dz * dz;
                if (y === 0 ? d2 > 2 : d2 > 1) continue; // низ шире, верх уже
                if (d2 > 0 && !rng.chance(0.8)) continue;
                g.set(1 + dx, y, 1 + dz, y === H - 1 || rng.chance(0.25) ? 5 : 3);
            }
    return encodeModel(g, { 3: '#626c4c', 5: '#7d8662' });
}

/** Валун карты: глыба-купол в тайл (не ниже 2 — не плитка), свет на верхушке. */
export function mapBoulderModel(variant: Variant, seed: number): VoxelModel {
    const rng = new Rng(seed ^ 0x4b0d);
    const H = 2 + Math.min(variant, 1) + (rng.next() < 0.4 ? 1 : 0); // 2..4
    const g = new VoxelGrid(3, H, 3);
    for (let y = 0; y < H; y++) {
        const w = y === 0 ? 3 : y === H - 1 ? 1 : 2; // купол: шире к низу
        const o = (3 - w) >> 1;
        for (let dz = 0; dz < w; dz++)
            for (let dx = 0; dx < w; dx++) {
                if (w === 2 && dx === 1 && dz === 1 && rng.chance(0.5)) continue;
                g.set(o + dx, y, o + dz, y === H - 1 ? 7 : 6);
            }
    }
    return encodeModel(g, { 6: '#5b5b66', 7: '#94949e' });
}

/** Дом: каменные стены с окнами + двускатная ржавая крыша (выше героя). */
export function houseModel(kind: number, _seed: number): VoxelModel {
    const tall = kind === T.HOUSE_V3 || kind === T.HOUSE_V4;
    const W = 8, H = tall ? 14 : 11, D = 6;
    const g = new VoxelGrid(W, H, D);
    for (let y = 0; y < H - 2; y++)
        for (let z = 0; z < D; z++)
            for (let x = 0; x < W; x++) {
                const edge = x === 0 || z === 0 || x === W - 1 || z === D - 1;
                if (!edge) continue;
                const win = y > 1 && y < H - 3 && (x + z) % 3 === 0;
                g.set(x, y, z, win ? 10 : 6);
            }
    for (let dy = 0; dy < 3; dy++) {
        const y = H - 3 + dy;
        for (let z = 0; z < D; z++)
            for (let x = dy; x < W - dy; x++) g.set(x, y, z, 8);
    }
    return encodeModel(g, { 6: '#5b5b66', 8: '#82503a', 10: '#44454e' });
}

/** Колодец: каменное кольцо + ворот с брусом (tall 1 юнит). */
export function wellModel(_seed: number): VoxelModel {
    const g = new VoxelGrid(3, 4, 3);
    for (let z = 0; z < 3; z++)
        for (let x = 0; x < 3; x++) {
            const edge = x === 0 || z === 0 || x === 2 || z === 2;
            if (edge) g.set(x, 0, z, 6);
        }
    g.set(0, 1, 0, 4);
    g.set(2, 1, 0, 4);
    g.set(1, 2, 0, 10); // ворот (металл)
    g.set(1, 3, 0, 4);
    return encodeModel(g, { 6: '#5b5b66', 4: '#5c3b2e', 10: '#44454e' });
}

/** Башня звона: колонна с площадкой и колоколом (высокая — доминирует в кадре). */
export function towerModel(_seed: number): VoxelModel {
    const g = new VoxelGrid(3, 13, 3);
    for (let y = 0; y < 10; y++)
        for (let z = 0; z < 3; z++)
            for (let x = 0; x < 3; x++) g.set(x, y, z, 6);
    for (let x = 0; x < 3; x++)
        for (let z = 0; z < 3; z++) g.set(x, 10, z, 7); // площадка, свет камня
    g.set(1, 11, 1, 10); // колокол (металл)
    g.set(1, 12, 1, 4);
    return encodeModel(g, { 6: '#5b5b66', 7: '#94949e', 10: '#44454e', 4: '#5c3b2e' });
}

/** Старая Машина: тяжёлый блок механизма (prop 2×2 тайла, выше героя). */
export function machineModel(_seed: number): VoxelModel {
    const g = new VoxelGrid(8, 12, 7);
    for (let y = 0; y < 8; y++)
        for (let z = 0; z < 7; z++)
            for (let x = 0; x < 8; x++)
                g.set(x, y, z, (x + z + y) % 4 === 0 ? 10 : 6);
    for (let z = 2; z < 5; z++)
        for (let x = 1; x < 7; x++) g.set(x, 8, z, 8);
    for (let z = 2; z < 5; z++) g.set(4, 9, z, 10);
    g.set(4, 10, 3, 9); // тёплая искра в жерле (жизнь!)
    return encodeModel(g, { 6: '#5b5b66', 8: '#82503a', 10: '#44454e', 9: '#d99a32' });
}

/** Бухта кабеля: низкая ржавая спираль (v1 tall 0.6 юнита). */
export function wireModel(_seed: number): VoxelModel {
    const g = new VoxelGrid(4, 3, 3);
    for (let z = 0; z < 3; z++)
        for (let x = 0; x < 4; x++) {
            const rim = x === 0 || x === 3 || z === 1;
            if (rim) g.set(x, 0, z, 8);
            if (rim && (x + z) % 2 === 0) g.set(x, 1, z, 10);
        }
    return encodeModel(g, { 8: '#82503a', 10: '#44454e' });
}

/** Кэш моделей: генерация 1 раз на (тип, тайл) — роща из 1005 деревьев дёшева. */
const cache = new Map<string, VoxelModel>();
function cached(key: string, make: () => VoxelModel): VoxelModel {
    const hit = cache.get(key);
    if (hit) return hit;
    const m = make();
    cache.set(key, m);
    return m;
}

/** Поставить штамп на тайл: модель центрируется на центре тайла
 *  (ствол/фундамент по центру, крона/крыша свисают за край — как в v1). */
function stampAt(world: VoxelWorld, m: VoxelModel, tx: number, ty: number): void {
    const [sx, , sz] = m.size;
    stampModel(
        world, m,
        tx * UNIT + 1 - ((sx - 1) >> 1), GROUND_Y, ty * UNIT + 1 - ((sz - 1) >> 1),
    );
}

/**
 * Сборка воксельного мира области из раскладки v1. Детерминировано:
 * пятна — хэш координат, штампы — генераторы с сидом от координат.
 */
export function buildAreaWorld(l: AreaLayout): VoxelWorld {
    const world = new VoxelWorld(20); // высота чанка 20 (дерево до 15 + запас)
    const tiles = rleDecode(l.tiles, l.width * l.height);
    // 1) земля (все тайлы) — потом декор, потом штампы (объекты поверх)
    for (let ty = 0; ty < l.height; ty++)
        for (let tx = 0; tx < l.width; tx++) {
            const t = tiles[ty * l.width + tx] ?? T.ASH;
            for (let dz = 0; dz < UNIT; dz++)
                for (let dx = 0; dx < UNIT; dx++) {
                    const x = tx * UNIT + dx, z = ty * UNIT + dz;
                    world.set(x, 0, z, 2); // низ земли — тень травы
                    world.set(x, 1, z, groundFor(t, tx, ty, dx, dz));
                    if (t === T.WATER) {
                        // вода — на воксель выше земли (v1: непроходима);
                        // тело героя стоит на y=2 и упирается в этот слой
                        world.set(x, 1, z, WATER);
                        world.set(x, 2, z, groundFor(t, tx, ty, dx, dz));
                    }
                }
        }
    // 1б) декор v1 (мелкие объекты на земле, уровень y=1 — ходьбе не мешает)
    for (const d of l.decor) stampDecor(world, d);
    // 2) штампы объектов
    for (let ty = 0; ty < l.height; ty++)
        for (let tx = 0; tx < l.width; tx++) {
            stampObject(world, tiles[ty * l.width + tx] ?? T.ASH, tx, ty);
        }
    return world;
}

// Декор v1: микро-объекты на земле (не тайлы — в раскладке отдельно)
const DECOR = { TUFT: 15, STONE: 16, BALD: 17 } as const;

/** Декор: светлый пиксель на земле (кустик травы / камешек / проплешина). */
function stampDecor(
    world: VoxelWorld,
    d: { id: number; x: number; y: number; dx?: number; dy?: number },
): void {
    const ox = Math.max(-1, Math.min(1, Math.round((d.dx ?? 0) / 3)));
    const oz = Math.max(-1, Math.min(1, Math.round((d.dy ?? 0) / 3)));
    const slot = d.id === DECOR.TUFT ? 5 : d.id === DECOR.STONE ? 7 : 2;
    world.set(d.x * UNIT + 1 + ox, 1, d.y * UNIT + 1 + oz, slot);
}

/** RLE-пары → плоский массив (недостающие — пепел). */
export function rleDecode(pairs: [number, number][], total: number, fallback = T.ASH): number[] {
    const out: number[] = [];
    for (const [id, run] of pairs) for (let k = 0; k < run; k++) out.push(id);
    while (out.length < total) out.push(fallback);
    return out;
}

/** Слот поверхности тайла (день: трава пятнистая, пепел, тропа, вода, пол). */
function groundFor(t: number, tx: number, ty: number, dx: number, dz: number): number {
    switch (t) {
        case T.GRASS: return mottle(tx, ty, dx + dz) ? 3 : 1;   // G1 + пятна G2
        case T.ASH: return mottle(tx, ty, dx + dz) ? ASH_LIGHT : ASH;
        case T.PATH: return PATH;
        case T.WATER: return mottle(tx * 2 + dx, ty * 2 + dz, dz) ? WATER_LIGHT : WATER;
        case T.FLOOR: return FLOOR;
        case T.RAIL: return mottle(tx, ty, dx) ? PATH : FLOOR;  // шпалы
        case T.STAIRS: return ASH; // люк: тёмный провал
        case T.WALL: return FLOOR; // стены интерьеров стоят на полу
        default: return mottle(tx, ty, dx + dz) ? ASH_LIGHT : ASH; // под объектами
    }
}

/** Штамп объекта тайла (дерево/дом/валун/куст/башня/колодец/рельсы...). */
function stampObject(world: VoxelWorld, t: number, tx: number, ty: number): void {
    const seed = tx * 131 + ty * 17;
    if (t in TREES) stampAt(world, cached(`tree${t}`, () => mapTreeModel(TREES[t]!, seed)), tx, ty);
    else if (HOUSE_KINDS.includes(t)) stampAt(world, cached(`house${t}`, () => houseModel(t, seed)), tx, ty);
    else if (BOULDER_KINDS.includes(t))
        stampAt(world, cached(`boul${t}`, () => mapBoulderModel((t - T.BOULDER) as Variant, seed)), tx, ty);
    else if (BUSH_KINDS.includes(t))
        stampAt(world, cached(`bush${t}`, () => mapBushModel((t - T.BUSH) as Variant, seed)), tx, ty);
    else if (t === T.TOWER) stampAt(world, cached('tower', () => towerModel(seed)), tx, ty);
    else if (t === T.WELL) stampAt(world, cached('well', () => wellModel(seed)), tx, ty);
    else if (t === T.WIRE) stampAt(world, cached('wire', () => wireModel(seed)), tx, ty);
    else if (t === T.MACHINE) stampAt(world, cached('machine', () => machineModel(seed)), tx, ty);
    else if (t === T.RAIL) stampRail(world, tx, ty);
    else if (t === T.BELLFLOWER) stampBellflower(world, tx, ty);
    else if (t === T.WALL) stampWall(world, tx, ty);
}

/** Рельсы: металлическая нить по земле (полоса тайлов даёт линию). */
function stampRail(world: VoxelWorld, tx: number, ty: number): void {
    for (let d = 1; d < UNIT; d++) world.set(tx * UNIT + d, GROUND_Y, ty * UNIT + 1, RAIL);
}

/** Колокольчик: цветок на пепле (v1 harvest-тайл). */
function stampBellflower(world: VoxelWorld, tx: number, ty: number): void {
    world.set(tx * UNIT + 1, GROUND_Y, ty * UNIT + 1, FLOWER);
}

/** Стена интерьера: колонка камня высотой 4 над полом (v1 tall 1 юнит). */
function stampWall(world: VoxelWorld, tx: number, ty: number): void {
    for (let dz = 0; dz < UNIT; dz++)
        for (let dx = 0; dx < UNIT; dx++)
            for (let y = GROUND_Y; y < GROUND_Y + 4; y++)
                world.set(tx * UNIT + dx, y, ty * UNIT + dz, 6);
}

/** Центр тайла в вокселях (позиции v1 → мир v2, на границе конвертации). */
export function tileCenter(tx: number, ty: number): { x: number; z: number } {
    return { x: tx * UNIT + UNIT / 2, z: ty * UNIT + UNIT / 2 };
}