Newer
Older
rpg / apps / game / src / data / map.ts
import { createRng, type TileMapData, type TallSpec, type DecorData } from '@rpg/engine';

/** id тайлов луга «Пепельные луга». */
export const TILES = {
    GRASS: 0,
    PATH: 1,
    WATER: 2,
    TREE: 3,
    ASH: 4,
    BELLFLOWER: 5,
    TOWER: 6,
    HOUSE: 7,
    FLOOR: 8,
    WALL: 9,
    WELL: 10,
    // --- акт 2 ---
    /** Бухта кабеля/труб в Ржавой роще (низкий tall-объект). */
    WIRE: 18,
    /** Рельсы Старого разъезда (ходовые: шпалы с нитями). */
    RAIL: 19,
    /** Люк-ступени вниз в зал Машины (ходовой тайл, step-переход). */
    STAIRS: 20,
    /** Сама Старая Машина (tall-объект зала, непроходима). */
    MACHINE: 21,
    // Варианты текстур (микс по хэшу координат) — в tiles никогда не попадают.
    GRASS_V1: 11,
    GRASS_V2: 12,
    ASH_V1: 13,
    PATH_V1: 14,
    /** Деревья других видов (варианты TREE распределителем ниже; в tiles не попадают). */
    TREE_V1: 22,
    TREE_V2: 23,
    /**
     * Дома других фасадов. Обычно раздаются вариантами HOUSE по хэшу координат,
     * но могут ставиться в tiles и явно — деревня Звенец показывает разные
     * фасада намеренно (там немного домов, хэш-микс не гарантирует разнообразия).
     * V1/V2 — низкие (времянка, сарай), V3/V4 — высокие 32x80 (амбар, двухэтажный).
     */
    HOUSE_V1: 24,
    HOUSE_V2: 25,
    HOUSE_V3: 26,
    HOUSE_V4: 27,
    /** Валун — крупный непроходимый камень (луга, пруды, роща, разъезд). */
    BOULDER: 28,
    /** Большой мёртвый куст — непроходимое облако ветвей. */
    BUSH: 29,
    /** Валуны других форм (варианты BOULDER): скол-монолит и плоский камень. */
    BOULDER_2: 30,
    BOULDER_3: 31,
    /** Кусты других видов (варианты BUSH): раскидистый и высокий узкий. */
    BUSH_2: 32,
    BUSH_3: 33
} as const;

const BLOCKED = [
    TILES.WATER,
    TILES.TREE,
    TILES.TOWER,
    TILES.HOUSE,
    TILES.HOUSE_V1,
    TILES.HOUSE_V2,
    TILES.HOUSE_V3,
    TILES.HOUSE_V4,
    TILES.WALL,
    TILES.WELL,
    TILES.WIRE,
    TILES.MACHINE,
    TILES.BOULDER,
    TILES.BUSH,
    TILES.BOULDER_2,
    TILES.BOULDER_3,
    TILES.BUSH_2,
    TILES.BUSH_3
];

/** Все id деревьев (вьюхи крон/частицы/соседство — общий случай). */
export const TREE_TILES: readonly number[] = [TILES.TREE, TILES.TREE_V1, TILES.TREE_V2];

/** Дерево — высокий объект 1.5 мировых юнита (48 px), под ним трава. */
export const TALL_OBJECTS: Record<number, TallSpec> = {
    [TILES.TREE]: { height: 1.5, ground: TILES.GRASS },
    [TILES.TREE_V1]: { height: 1.5, ground: TILES.GRASS },
    [TILES.TREE_V2]: { height: 1.5, ground: TILES.GRASS },
    [TILES.TOWER]: { height: 2.5, ground: TILES.PATH },
    [TILES.HOUSE]: { height: 1.75, ground: TILES.ASH, shadow: true },
    [TILES.HOUSE_V1]: { height: 1.75, ground: TILES.ASH, shadow: true },
    [TILES.HOUSE_V2]: { height: 1.75, ground: TILES.ASH, shadow: true },
    // Высокие дома 32x80 — ростом с башню (2.5 юнита).
    [TILES.HOUSE_V3]: { height: 2.5, ground: TILES.ASH, shadow: true },
    [TILES.HOUSE_V4]: { height: 2.5, ground: TILES.ASH, shadow: true },
    // Валун и куст: приземистые препятствия (высота спрайта ≤ 1.5 — не резать куллингом).
    [TILES.BOULDER]: { height: 1.05, ground: TILES.ASH },
    [TILES.BUSH]: { height: 1.25, ground: TILES.ASH },
    [TILES.BOULDER_2]: { height: 1.25, ground: TILES.ASH },
    [TILES.BOULDER_3]: { height: 0.75, ground: TILES.ASH },
    [TILES.BUSH_2]: { height: 1.0, ground: TILES.ASH },
    [TILES.BUSH_3]: { height: 1.45, ground: TILES.ASH },
    // Стены интерьеров — вертикальные объекты, под ними пол.
    [TILES.WALL]: { height: 1, ground: TILES.FLOOR },
    // Бухта кабеля — низкая груда на пепле.
    [TILES.WIRE]: { height: 0.6, ground: TILES.ASH },
    // Старая Машина — ростом с башню, стоит на полу зала.
    [TILES.MACHINE]: { height: 2.5, ground: TILES.FLOOR }
};

/**
 * Микс текстур наземных тайлов (движок выбирает по хэшу координат):
 * трава и тропа — из трёх/двух вариантов, пепел — с редким вторым оттенком.
 */
const GROUND_VARIANTS: Record<number, number[]> = {
    [TILES.GRASS]: [TILES.GRASS, TILES.GRASS_V1, TILES.GRASS_V2],
    [TILES.PATH]: [TILES.PATH, TILES.PATH_V1],
    [TILES.ASH]: [TILES.ASH, TILES.ASH_V1]
};

/** Микс высоких объектов: деревья трёх видов, дома пяти фасадов, камни/кусты — по три. */
const TALL_VARIANTS: Record<number, number[]> = {
    [TILES.TREE]: [TILES.TREE, TILES.TREE_V1, TILES.TREE_V2],
    [TILES.HOUSE]: [TILES.HOUSE, TILES.HOUSE_V1, TILES.HOUSE_V2, TILES.HOUSE_V3, TILES.HOUSE_V4],
    [TILES.BOULDER]: [TILES.BOULDER, TILES.BOULDER_2, TILES.BOULDER_3],
    [TILES.BUSH]: [TILES.BUSH, TILES.BUSH_2, TILES.BUSH_3]
};

/** Полная таблица вариантов для карт (земля + высокие объекты). */
const TILE_VARIANTS: Record<number, number[]> = { ...GROUND_VARIANTS, ...TALL_VARIANTS };

/** id текстур мелкого декора (не тайлы — в tiles не попадают). */
export const DECOR = {
    /** Микро-кустик (сухая трава). */
    TUFT: 15,
    /** Камешек. */
    STONE: 16,
    /** Проплешина (тёмное пятно на траве). */
    BALD: 17
} as const;

/**
 * Детерминированно набросать декор по готовой карте: кустики и проплешины
 * на траве, камешки на пепле (~10% наземных тайлов). Декор не ставится
 * на тропу/воду/высокие объекты — только GRASS и ASH.
 */
function buildDecor(tiles: number[], width: number, height: number, seed: number): DecorData[] {
    const r = createRng(seed + 7919);
    const out: DecorData[] = [];
    for (let y = 0; y < height; y++) {
        for (let x = 0; x < width; x++) {
            if (r.next() >= 0.1) continue;
            const id = tiles[y * width + x];
            // лёгкое смещение от центра ромба — декор не по сетке
            // (+0: Math.round может вернуть -0, который JSON теряет — fresh-тест разъезжается)
            const dx = Math.round((r.next() - 0.5) * 8) + 0;
            const dy = Math.round((r.next() - 0.5) * 4) + 0;
            if (id === TILES.GRASS) {
                out.push({ id: r.next() < 0.6 ? DECOR.TUFT : DECOR.BALD, x, y, dx, dy });
            } else if (id === TILES.ASH) {
                out.push({ id: DECOR.STONE, x, y, dx, dy });
            }
        }
    }
    return out;
}

/**
 * Разбросать валуны и кусты по свободной земле (GRASS/ASH), не затирая тропы,
 * воду и высокие объекты. Ставится до расчисток переходов/полян — важные тайлы
 * все равно перезаписываются после. Часть попыток может не найти место — это
 * допустимо (мелкий разброс у плотных карт).
 */
function scatterRocksAndBushes(
    tiles: number[],
    width: number,
    height: number,
    rng: { next: () => number },
    boulders: number,
    bushes: number
): void {
    const free = (x: number, y: number) =>
        tiles[y * width + x] === TILES.GRASS || tiles[y * width + x] === TILES.ASH;
    const place = (id: number, count: number) => {
        for (let i = 0; i < count * 24 && count > 0; i++) {
            const x = 1 + Math.floor(rng.next() * (width - 2));
            const y = 1 + Math.floor(rng.next() * (height - 2));
            if (!free(x, y)) continue;
            tiles[y * width + x] = id;
            count--;
        }
    };
    place(TILES.BOULDER, boulders);
    place(TILES.BUSH, bushes);
}

/**
 * Локация «Выжженные луга»: поляны травы, тропа от северо-западного угла
 * (пруды) через стартовую поляну на восток (Звенец), пара прудов и рощи
 * деревьев по краям.
 */
export function buildMeadowsMap(width = 56, height = 56): TileMapData {
    const seed = 20260905;
    const rng = createRng(seed);
    const tiles: number[] = new Array(width * height).fill(TILES.GRASS);
    const at = (x: number, y: number) => y * width + x;

    // Пятна пепла (декоративные).
    for (let i = 0; i < 110; i++) {
        const x = Math.floor(rng.next() * width);
        const y = Math.floor(rng.next() * height);
        const r = 1 + Math.floor(rng.next() * 2);
        for (let dy = -r; dy <= r; dy++) {
            for (let dx = -r; dx <= r; dx++) {
                const nx = x + dx;
                const ny = y + dy;
                if (nx >= 0 && ny >= 0 && nx < width && ny < height && rng.next() < 0.7) {
                    tiles[at(nx, ny)] = TILES.ASH;
                }
            }
        }
    }

    // Тропа: монотонная ломаная к цели (случайный выбор оси) — детерминированно.
    const walkPath = (sx: number, sy: number, tx: number, ty: number) => {
        let px = sx;
        let py = sy;
        while (px !== tx || py !== ty) {
            tiles[at(px, py)] = TILES.PATH;
            if (px !== tx && (py === ty || rng.next() < 0.5)) px += Math.sign(tx - px);
            else py += Math.sign(ty - py);
        }
        tiles[at(tx, ty)] = TILES.PATH;
    };
    // От северо-западного перехода к стартовой поляне и от неё на восток к Звенцу.
    walkPath(2, 2, 16, 28);
    walkPath(16, 28, 52, 28);

    // Пруды (крупнее, с рваными краями).
    for (const [cx, cy] of [
        [12, 32],
        [38, 14]
    ]) {
        for (let dy = 0; dy < 5; dy++) {
            for (let dx = 0; dx < 7; dx++) {
                if (rng.next() < 0.85) tiles[at(cx + dx, cy + dy)] = TILES.WATER;
            }
        }
    }

    // Лунные колокольчики у берегов прудов (квестовый декор, проходимы).
    for (const [fx, fy] of [
        [20, 34],
        [11, 36],
        [46, 16],
        [35, 11]
    ]) {
        if (tiles[at(fx, fy)] === TILES.GRASS || tiles[at(fx, fy)] === TILES.ASH) {
            tiles[at(fx, fy)] = TILES.BELLFLOWER;
        }
    }

    // Рощи по краям (в основном) и одинокие деревья на полянах.
    for (let i = 0; i < 240; i++) {
        const edge = rng.next() < 0.8;
        const x = edge ? Math.floor(rng.next() * 8) : width - 1 - Math.floor(rng.next() * 8);
        const y = Math.floor(rng.next() * height);
        if (tiles[at(x, y)] === TILES.GRASS || tiles[at(x, y)] === TILES.ASH) {
            tiles[at(x, y)] = TILES.TREE;
        }
    }

    // Валуны и кусты на полянах.
    scatterRocksAndBushes(tiles, width, height, rng, 10, 14);

    // Стартовая поляна в центре-западе всегда свободна.
    const cx = 16;
    const cy = 28;
    for (let dy = -2; dy <= 2; dy++) {
        for (let dx = -2; dx <= 2; dx++) {
            tiles[at(cx + dx, cy + dy)] = TILES.GRASS;
        }
    }

    // Пробуждение стартовой поляны: валун, кусты и деревья в первом кадре от
    // спавна — новое сразу видно. Ставим с проверкой: не затирать тропу/воду.
    const safePlace = (x: number, y: number, id: number) => {
        if (tiles[at(x, y)] !== TILES.PATH && tiles[at(x, y)] !== TILES.WATER) tiles[at(x, y)] = id;
    };
    safePlace(13, 25, TILES.BOULDER); // у северо-западной тропы
    safePlace(20, 32, TILES.BOULDER);
    safePlace(11, 30, TILES.BUSH); // южнее поляны
    safePlace(19, 24, TILES.BUSH); // восточнее, не на тропе (x>16)
    safePlace(12, 21, TILES.TREE); // деревца-ориентиры у поляны
    safePlace(13, 22, TILES.TREE);
    safePlace(21, 26, TILES.TREE);

    // Тропа к Серым прудам: северо-западный угол (переход между локациями).
    // Вокруг тайлов перехода расчищаем поляну — рощи не должны запирать выход.
    for (let dy = -1; dy <= 2; dy++) {
        for (let dx = -1; dx <= 2; dx++) {
            const nx = 2 + dx;
            const ny = 2 + dy;
            if (nx >= 0 && ny >= 0 && nx < width && ny < height) {
                tiles[at(nx, ny)] = dx === dy || dx + 1 === dy ? TILES.PATH : TILES.GRASS;
            }
        }
    }
    tiles[at(2, 2)] = TILES.PATH;
    tiles[at(3, 3)] = TILES.PATH;

    // Тропа в Звенец: восточный край, вокруг тайлов перехода тоже расчищаем.
    for (let dy = -1; dy <= 1; dy++) {
        for (let dx = -2; dx <= 0; dx++) {
            const nx = 54 + dx;
            const ny = 28 + dy;
            if (nx >= 0 && ny >= 0 && nx < width && ny < height) {
                tiles[at(nx, ny)] = dx === -1 && dy === 0 ? TILES.PATH : TILES.GRASS;
            }
        }
    }
    tiles[at(54, 28)] = TILES.PATH;

    // Встречный колодец на лугах: телепорт обратно в Звенец (клик по тайлу).
    tiles[at(44, 40)] = TILES.WELL;

    // Знак у восточной тропы (см. INTERACTABLES.meadows): scatter не должен
    // ставить на его тайл дерево/куст.
    tiles[at(48, 27)] = TILES.ASH;

    return {
        width,
        height,
        tiles,
        blocked: [...BLOCKED],
        tall: TALL_OBJECTS,
        variants: TILE_VARIANTS,
        decor: buildDecor(tiles, width, height, seed)
    };
}

/**
 * Локация «Ржавая роща»: мёртвый лес, оплетённый кабелями и трубами.
 * Деревья стоят как механизмы — плотная роща с бухтами кабеля (WIRE),
 * тропа с запада (из Звенца) на восток (к разъезду) через поляну
 * мастерской Клинта в центре.
 */
export function buildRustGroveMap(width = 48, height = 40): TileMapData {
    const seed = 20260908;
    const rng = createRng(seed);
    const tiles: number[] = new Array(width * height).fill(TILES.ASH);
    const at = (x: number, y: number) => y * width + x;

    // Редкие проплешины чахлой травы.
    for (let i = 0; i < 24; i++) {
        const x = Math.floor(rng.next() * width);
        const y = Math.floor(rng.next() * height);
        if (tiles[at(x, y)] === TILES.ASH) tiles[at(x, y)] = TILES.GRASS;
    }

    // Плотная роща: деревья гуще к краям, реже к центру.
    for (let y = 0; y < height; y++) {
        for (let x = 0; x < width; x++) {
            const d = Math.hypot((x - width / 2) / (width / 2), (y - height / 2) / (height / 2));
            if (rng.next() < 0.16 + d * 0.5 && (tiles[at(x, y)] === TILES.ASH || tiles[at(x, y)] === TILES.GRASS)) {
                tiles[at(x, y)] = TILES.TREE;
            }
        }
    }

    // Бухты кабеля: короткие цепочки по 1–3 тайла (как протянутые шлейфы).
    for (const [bx, by, dir] of [
        [10, 8, 1],
        [32, 6, -1],
        [16, 30, 1],
        [36, 28, -1],
        [24, 10, 0]
    ] as [number, number, number][]) {
        for (let i = 0; i < 3; i++) {
            const nx = bx + i * dir;
            const ny = by + (i % 2);
            if (nx >= 0 && ny >= 0 && nx < width && ny < height && tiles[at(nx, ny)] !== TILES.TREE) {
                tiles[at(nx, ny)] = TILES.WIRE;
            }
        }
    }
    // Тон-деревья квеста резонатора (см. QUEST_TONE_TILES в data/quests.ts):
    // гарантируем бухту кабеля на их тайлах — квест не должен зависеть от шума.
    for (const [tx, ty] of [
        [32, 6],
        [12, 10],
        [20, 30]
    ]) {
        tiles[at(tx, ty)] = TILES.WIRE;
    }

    // Валуны и кусты в прогалинах.
    scatterRocksAndBushes(tiles, width, height, rng, 6, 8);

    // Тропа: с запада (вход из Звенца) на восток (к разъезду) — до самого
    // тайла перехода (46,20), чтобы A* всегда доводил.
    for (let x = 1; x <= 46; x++) tiles[at(x, 20)] = TILES.PATH;

    // Спавны врагов расчищаем: роща плотная — дерево могло вырасти на тайле.
    for (const [ex, ey] of [
        [12, 12],
        [34, 14],
        [36, 32],
        [40, 28],
        [14, 30]
    ]) {
        if (tiles[at(ex, ey)] === TILES.TREE || tiles[at(ex, ey)] === TILES.WIRE) {
            tiles[at(ex, ey)] = TILES.ASH;
        }
    }

    // Поляна мастерской Клинта: расчистка и утоптанный пепел вокруг центра.
    for (let dy = -4; dy <= 4; dy++) {
        for (let dx = -6; dx <= 6; dx++) {
            const nx = 24 + dx;
            const ny = 20 + dy;
            if (nx >= 0 && ny >= 0 && nx < width && ny < height && Math.abs(dx) + Math.abs(dy) <= 8) {
                tiles[at(nx, ny)] = TILES.ASH;
            }
        }
    }
    // Пятачок тропы у поляны (сюда встанет резонатор Клинта).
    tiles[at(24, 16)] = TILES.ASH;

    // Входы/выходы: тайлы переходов всегда проходимы.
    tiles[at(2, 20)] = TILES.PATH;
    tiles[at(46, 20)] = TILES.PATH;

    return {
        width,
        height,
        tiles,
        blocked: [...BLOCKED],
        tall: TALL_OBJECTS,
        variants: TILE_VARIANTS,
        decor: buildDecor(tiles, width, height, seed)
    };
}

/**
 * Локация «Старый разъезд»: руины узловой станции. Через карту идут рельсы,
 * вдоль них — вагоны-времянки (HOUSE) и редкие сухие деревья. Южнее стрелки —
 * люк-ступени (STAIRS) вниз в зал Старой Машины.
 */
export function buildOldSidingMap(width = 48, height = 40): TileMapData {
    const seed = 20260909;
    const rng = createRng(seed);
    const tiles: number[] = new Array(width * height).fill(TILES.ASH);
    const at = (x: number, y: number) => y * width + x;

    // Редкие проплешины травы.
    for (let i = 0; i < 20; i++) {
        const x = Math.floor(rng.next() * width);
        const y = Math.floor(rng.next() * height);
        if (tiles[at(x, y)] === TILES.ASH) tiles[at(x, y)] = TILES.GRASS;
    }

    // Рельсы: главная линия поперёк карты (вертикальная нить в изометрии).
    for (let y = 6; y <= 32; y++) {
        tiles[at(22, y)] = TILES.RAIL;
        tiles[at(23, y)] = TILES.RAIL;
    }
    // Стрелка: боковая ветка к северо-востоку.
    for (let x = 24; x <= 38; x++) tiles[at(x, 6 + (x - 24))] = TILES.RAIL;

    // Руины станции: пара вагонов-времянок и останки стены.
    tiles[at(16, 12)] = TILES.HOUSE;
    tiles[at(32, 24)] = TILES.HOUSE;
    tiles[at(10, 26)] = TILES.WALL;

    // Редкие сухие деревья по краям.
    for (let i = 0; i < 44; i++) {
        const edge = rng.next() < 0.5;
        const x = edge ? Math.floor(rng.next() * 6) : width - 1 - Math.floor(rng.next() * 6);
        const y = Math.floor(rng.next() * height);
        if (tiles[at(x, y)] === TILES.ASH || tiles[at(x, y)] === TILES.GRASS) {
            tiles[at(x, y)] = TILES.TREE;
        }
    }

    // Валуны и кусты среди руин.
    scatterRocksAndBushes(tiles, width, height, rng, 6, 6);

    // Тропа от западного входа к рельсам, через них и к люку.
    for (let x = 1; x <= 20; x++) tiles[at(x, 20)] = TILES.PATH;
    for (let x = 24; x <= 26; x++) tiles[at(x, 20)] = TILES.PATH;
    for (let y = 21; y <= 30; y++) tiles[at(26, y)] = TILES.PATH;
    tiles[at(27, 30)] = TILES.PATH;
    tiles[at(27, 31)] = TILES.PATH;

    // Люк-ступени вниз (step-переход в зал Машины), рядом тропа.
    tiles[at(28, 31)] = TILES.STAIRS;

    // Вход всегда проходим; спавны врагов (см. AREAS.old_siding.enemies)
    // scatter не должен закрывать валуном/кустом.
    tiles[at(2, 20)] = TILES.PATH;
    for (const [x, y] of [[14, 18], [36, 16], [36, 32]]) tiles[at(x, y)] = TILES.ASH;

    return {
        width,
        height,
        tiles,
        blocked: [...BLOCKED],
        tall: TALL_OBJECTS,
        variants: TILE_VARIANTS,
        decor: buildDecor(tiles, width, height, seed)
    };
}

/**
 * Зал Старой Машины (подземелье разъезда): тесный интерьер с плитами пола,
 * у северной стены — сама Машина (два тайла корпуса). Рельсы подходят к ней
 * со стороны входа. Дверной проём снизу (kind 'return' наверх).
 */
export function buildMachineHallMap(): TileMapData {
    const width = 26;
    const height = 22;
    const tiles: number[] = new Array(width * height).fill(TILES.WALL);
    const at = (x: number, y: number) => y * width + x;
    for (let y = 1; y < height - 1; y++) {
        for (let x = 1; x < width - 1; x++) {
            tiles[at(x, y)] = TILES.FLOOR;
        }
    }
    // Машина: корпус в два тайла у северной стены.
    tiles[at(12, 1)] = TILES.MACHINE;
    tiles[at(13, 1)] = TILES.MACHINE;
    // Рельсы подходят к Машине — её кормили составами.
    for (let y = 3; y <= 19; y++) tiles[at(13, y)] = TILES.RAIL;
    // Дверной проём в нижней стене (step-переход kind 'return').
    tiles[at(13, height - 1)] = TILES.FLOOR;
    return {
        width,
        height,
        tiles,
        blocked: [...BLOCKED],
        tall: TALL_OBJECTS
    };
}

/**
 * Локация «Серые пруды»: опасная зона — большая гладь воды, туман,
 * много пепла и сгустков. Тропа начинается в северо-западном углу
 * (обратный переход на луга).
 */
export function buildPondsMap(width = 48, height = 48): TileMapData {
    const seed = 20260906;
    const rng = createRng(seed);
    const tiles: number[] = new Array(width * height).fill(TILES.ASH);
    const at = (x: number, y: number) => y * width + x;

    // Пятна чахлой травы.
    for (let i = 0; i < 50; i++) {
        const x = Math.floor(rng.next() * width);
        const y = Math.floor(rng.next() * height);
        const r = 1 + Math.floor(rng.next() * 2);
        for (let dy = -r; dy <= r; dy++) {
            for (let dx = -r; dx <= r; dx++) {
                const nx = x + dx;
                const ny = y + dy;
                if (nx >= 0 && ny >= 0 && nx < width && ny < height && rng.next() < 0.6) {
                    tiles[at(nx, ny)] = TILES.GRASS;
                }
            }
        }
    }

    // Большая гладь в центре с рваными берегами.
    const cx = Math.floor(width / 2);
    const cy = Math.floor(height / 2);
    for (let y = 0; y < height; y++) {
        for (let x = 0; x < width; x++) {
            const d = Math.hypot((x - cx) / 15, (y - cy) / 11);
            if (d + (rng.next() - 0.5) * 0.35 < 1) tiles[at(x, y)] = TILES.WATER;
        }
    }

    // Лунные колокольчики у берегов (квестовые цветы растут только здесь).
    for (const [fx, fy] of [
        [8, 16],
        [38, 12],
        [36, 34],
        [12, 34],
        [24, 42]
    ]) {
        if (tiles[at(fx, fy)] !== TILES.WATER) tiles[at(fx, fy)] = TILES.BELLFLOWER;
    }

    // Тропа от северо-западного входа к берегу.
    let px = 4;
    let py = 4;
    while (py < 30 || px < 30) {
        tiles[at(px, py)] = TILES.PATH;
        if (rng.next() < 0.45 && px < 30) px++;
        else if (py < 30) py++;
        else px++;
    }

    // Сухие деревья по краям.
    for (let i = 0; i < 92; i++) {
        const edge = rng.next() < 0.5;
        const x = edge ? Math.floor(rng.next() * 6) : width - 1 - Math.floor(rng.next() * 6);
        const y = Math.floor(rng.next() * height);
        if (tiles[at(x, y)] !== TILES.WATER) tiles[at(x, y)] = TILES.TREE;
    }

    // Валуны и кусты у берегов.
    scatterRocksAndBushes(tiles, width, height, rng, 8, 10);

    // Поляна у входа свободна; тайлы перехода — тропа.
    for (let dy = 0; dy <= 1; dy++) {
        for (let dx = 0; dx <= 1; dx++) {
            tiles[at(4 + dx, 4 + dy)] = dy + dx === 0 ? TILES.PATH : TILES.GRASS;
        }
    }
    tiles[at(2, 2)] = TILES.PATH;
    tiles[at(4, 4)] = TILES.PATH;

    return {
        width,
        height,
        tiles,
        blocked: [...BLOCKED],
        tall: TALL_OBJECTS,
        variants: TILE_VARIANTS,
        decor: buildDecor(tiles, width, height, seed)
    };
}
/**
 * Интерьер 11×9: рамка WALL, пол FLOOR, дверной проём снизу (5,8).
 * Мебель (сундуки/прилавки) — интерактивные объекты на тайлах пола
 * (см. data/interactables.ts), не отдельные тайлы.
 */
function buildInteriorMap(): TileMapData {
    const width = 11;
    const height = 9;
    const tiles: number[] = new Array(width * height).fill(TILES.WALL);
    const at = (x: number, y: number) => y * width + x;
    for (let y = 1; y < height - 1; y++) {
        for (let x = 1; x < width - 1; x++) {
            tiles[at(x, y)] = TILES.FLOOR;
        }
    }
    // Дверной проём в нижней стене (step-переход kind 'return' наружу).
    tiles[at(5, height - 1)] = TILES.FLOOR;
    return {
        width,
        height,
        tiles,
        blocked: [...BLOCKED],
        tall: TALL_OBJECTS
    };
}

/** Дом Ирвина: сундук, очаг в северной стене, записка у восточной стены. */
export function buildHouseElderMap(): TileMapData {
    return buildInteriorMap();
}

/** Лавка Милы: прилавок в центре, полки вдоль северной стены. */
export function buildShopMap(): TileMapData {
    return buildInteriorMap();
}

/**
 * Локация «Звенец»: деревня-мастерская вокруг старой железнодорожной
 * водонапорной башни с колоколом. Дома из вагонных щитов, крыши из
 * платформ, посреди площади — звонное место. Безопасный хаб (без врагов).
 */
export function buildZvenetsMap(width = 56, height = 40): TileMapData {
    const seed = 20260907;
    const rng = createRng(seed);
    const tiles: number[] = new Array(width * height).fill(TILES.ASH);
    const at = (x: number, y: number) => y * width + x;

    // Пятна чахлой травы по краям.
    for (let i = 0; i < 40; i++) {
        const x = Math.floor(rng.next() * width);
        const y = Math.floor(rng.next() * height);
        const r = 1 + Math.floor(rng.next() * 2);
        for (let dy = -r; dy <= r; dy++) {
            for (let dx = -r; dx <= r; dx++) {
                const nx = x + dx;
                const ny = y + dy;
                if (nx >= 0 && ny >= 0 && nx < width && ny < height && rng.next() < 0.6) {
                    tiles[at(nx, ny)] = TILES.GRASS;
                }
            }
        }
    }

    // Площадь: утоптанный пепел в центре карты.
    for (let y = 12; y <= 27; y++) {
        for (let x = 18; x <= 39; x++) {
            if (rng.next() < 0.9) tiles[at(x, y)] = TILES.PATH;
        }
    }

    // Знак у входа (см. INTERACTABLES.zvenets): scatter не занял бы его деревом.
    tiles[at(3, 19)] = TILES.ASH;

    // Тропа: от западного края (переход на луга) к площади.
    let px = 2;
    let py = 20;
    while (px < 18 || py !== 20) {
        tiles[at(px, py)] = TILES.PATH;
        if (px < 18 && (py === 20 || rng.next() < 0.6)) px++;
        else if (py !== 20) py += py < 20 ? 1 : -1;
        else px++;
    }

    // Сухие деревья по краям.
    for (let i = 0; i < 60; i++) {
        const edge = rng.next() < 0.5;
        const x = edge ? Math.floor(rng.next() * 6) : width - 1 - Math.floor(rng.next() * 6);
        const y = Math.floor(rng.next() * height);
        if (tiles[at(x, y)] === TILES.ASH || tiles[at(x, y)] === TILES.GRASS) {
            tiles[at(x, y)] = TILES.TREE;
        }
    }

    // Пара кустов по краю деревни (валун один — у рощи за площадью).
    scatterRocksAndBushes(tiles, width, height, rng, 2, 4);

    // Башня с колоколом — север площади.
    tiles[at(28, 14)] = TILES.TOWER;

    // Дома деревни: все фасады показаны намеренно (вариантные id прямо в
    // тайлах — хэш-микс не гарантирует разнообразия). Дом Ирвина у колодца,
    // двухэтажный на севере, амбар-лавка Милы на востоке, пара времянок югом.
    tiles[at(13, 12)] = TILES.HOUSE;
    tiles[at(37, 10)] = TILES.HOUSE_V4;
    tiles[at(43, 19)] = TILES.HOUSE_V3;
    tiles[at(23, 28)] = TILES.HOUSE_V1;
    tiles[at(33, 28)] = TILES.HOUSE_V2;

    // Старый колодец у дома Ирвина: телепорт на луга (см. AREAS, transitions).
    tiles[at(16, 13)] = TILES.WELL;

    // Стартовая поляна и тайл перехода всегда свободны.
    tiles[at(28, 20)] = TILES.GRASS;
    tiles[at(2, 20)] = TILES.PATH;
    tiles[at(3, 20)] = TILES.PATH;

    // Тропа в Ржавую рощу: восточный край (вокруг тоже расчищаем).
    for (let dy = -1; dy <= 1; dy++) {
        for (let dx = -2; dx <= 0; dx++) {
            const nx = 54 + dx;
            const ny = 20 + dy;
            if (nx >= 0 && ny >= 0 && nx < width && ny < height) {
                tiles[at(nx, ny)] = dx === -1 && dy === 0 ? TILES.PATH : TILES.ASH;
            }
        }
    }
    tiles[at(54, 20)] = TILES.PATH;

    return {
        width,
        height,
        tiles,
        blocked: [...BLOCKED],
        tall: TALL_OBJECTS,
        variants: TILE_VARIANTS,
        decor: buildDecor(tiles, width, height, seed)
    };
}