/**
* Области «Пепельных луга» на v2: раскладка тайлов v1 (maps.json, референс)
* → воксельный мир. 1 тайл v1 = 10×10 вокселей (1 юнит = 10 вокселей; герой
* 22 ≈ 2.2 тайла ростом — дом ~4 тайла, дерево ~3.6, пропорции «человек
* и мир»). Рельеф — плоский (земля y0-1), объекты — штампы генераторов
* движка (деревья/валуны) и локальных генераторов контента (кусты, дома,
* колодец, башня). Позиции v1 (NPC/переходы/квесты, в тайлах) конвертируются
* на границе: центр тайла = (tile*UNIT + 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 юнит = 10 вокселей — герой 22 ≈ 2.2 тайла ростом). */
export const UNIT = 10;
/** Точка ног героя в вокселях (верх земли). */
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;
// ---- Генераторы контента игры (деревья/кусты/валуны в тайл 10×10, дома...) ----
/** Дерево карты: высокое (в ~1.7 раза выше героя — 22), footprint тайла
* 10×10 (крона не вылезает на соседей — 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(20, 24) : rng.range(16, 21);
const crownH = variant === 2 ? rng.range(14, 17) : rng.range(11, 15);
const g = new VoxelGrid(10, trunkH + crownH + 1, 10);
for (let y = 0; y < trunkH; y++) { // ствол 2×2 — на этом масштабе 1×1 — щепка
g.set(4, y, 4, 4); g.set(5, y, 4, 4);
g.set(4, y, 5, 4); g.set(5, y, 5, 4);
}
const r = 4 + rng.next() * 1; // джиттер радиуса фиксируем заранее
for (let dy = 0; dy < crownH; dy++) {
const y = trunkH - 1 + dy;
for (let dz = -4; dz <= 4; dz++)
for (let dx = -4; dx <= 4; dx++) {
const d2 = dx * dx + dz * dz;
let ok: boolean;
if (variant === 0) ok = d2 + dy * dy * 0.6 <= r * r + 4;
else if (variant === 1) ok = d2 <= 8;
else ok = d2 <= (dy < crownH / 2 ? 16 : 8); // ель: ярусы сужаются
if (!ok) continue;
if (dy > 0 && d2 > 8 && !rng.chance(0.7)) continue; // рваный край
const light = dy === crownH - 1 || rng.chance(0.25);
g.set(5 + dx, y, 5 + 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 ? 7 : 5;
const g = new VoxelGrid(10, H, 10);
for (let y = 0; y < H; y++)
for (let dz = -4; dz <= 4; dz++)
for (let dx = -4; dx <= 4; dx++) {
const d2 = dx * dx + dz * dz;
if (y === 0 ? d2 > 20 : d2 > 8) continue; // низ шире, верх уже
if (d2 > 0 && !rng.chance(0.8)) continue;
g.set(5 + dx, y, 5 + dz, y === H - 1 || rng.chance(0.25) ? 5 : 3);
}
return encodeModel(g, { 3: '#626c4c', 5: '#7d8662' });
}
/** Валун карты: глыба-купол в тайл (не ниже 5 — не плитка), свет на верхушке. */
export function mapBoulderModel(variant: Variant, seed: number): VoxelModel {
const rng = new Rng(seed ^ 0x4b0d);
const H = 5 + Math.min(variant, 1) * 2 + (rng.next() < 0.4 ? 2 : 0); // 5..9
const g = new VoxelGrid(10, H, 10);
for (let y = 0; y < H; y++) {
const w = y === 0 ? 10 : y === H - 1 ? 3 : 7; // купол: шире к низу
const o = (10 - w) >> 1;
for (let dz = 0; dz < w; dz++)
for (let dx = 0; dx < w; dx++) {
if (w === 7 && dx === 3 && dz === 3 && rng.chance(0.5)) continue;
g.set(o + dx, y, o + dz, y === H - 1 ? 7 : 6);
}
}
return encodeModel(g, { 6: '#5b5b66', 7: '#94949e' });
}
/** Дом: каменные стены с окнами + двускатная ржавая крыша (~4 тайла —
* вдвое выше героя: строение, в которое заходят, а не будка). */
export function houseModel(kind: number, _seed: number): VoxelModel {
const tall = kind === T.HOUSE_V3 || kind === T.HOUSE_V4;
const W = 24, H = tall ? 40 : 30, D = 18;
const g = new VoxelGrid(W, H, D);
for (let y = 0; y < H - 4; 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 > 2 && y < H - 6 && (x + z) % 5 === 0;
g.set(x, y, z, win ? 10 : 6);
}
for (let dy = 0; dy < 4; dy++) {
const y = H - 4 + dy;
for (let z = 0; z < D; z++)
for (let x = dy * 2; x < W - dy * 2; 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(10, 12, 10);
for (let z = 0; z < 10; z++)
for (let x = 0; x < 10; x++) {
const edge = x === 0 || z === 0 || x === 9 || z === 9;
if (edge) g.set(x, 0, z, 6);
}
g.set(0, 2, 0, 4);
g.set(9, 2, 0, 4);
g.set(4, 6, 0, 10); // ворот (металл)
g.set(4, 10, 0, 4);
return encodeModel(g, { 6: '#5b5b66', 4: '#5c3b2e', 10: '#44454e' });
}
/** Башня звона: колонна с площадкой и колоколом (высокая — доминирует в кадре). */
export function towerModel(_seed: number): VoxelModel {
const g = new VoxelGrid(10, 47, 10);
for (let y = 0; y < 36; y++)
for (let z = 0; z < 10; z++)
for (let x = 0; x < 10; x++) g.set(x, y, z, 6);
for (let x = 0; x < 10; x++)
for (let z = 0; z < 10; z++) g.set(x, 36, z, 7); // площадка, свет камня
g.set(4, 40, 4, 10); // колокол (металл)
g.set(4, 45, 4, 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(24, 34, 18);
for (let y = 0; y < 22; y++)
for (let z = 0; z < 18; z++)
for (let x = 0; x < 24; x++)
g.set(x, y, z, (x + z + y) % 4 === 0 ? 10 : 6);
for (let z = 6; z < 12; z++)
for (let x = 4; x < 20; x++) g.set(x, 22, z, 8);
for (let z = 6; z < 12; z++) g.set(12, 26, z, 10);
g.set(12, 30, 8, 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(12, 8, 10);
for (let z = 0; z < 10; z++)
for (let x = 0; x < 12; x++) {
const rim = x === 0 || x === 11 || z === 4;
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 + 5 - ((sx - 1) >> 1), GROUND_Y, ty * UNIT + 5 - ((sz - 1) >> 1),
);
}
/**
* Сборка воксельного мира области из раскладки v1. Детерминировано:
* пятна — хэш координат, штампы — генераторы с сидом от координат.
*/
export function buildAreaWorld(l: AreaLayout): VoxelWorld {
const world = new VoxelWorld(52); // высота чанка 52 (башня до 47 + запас)
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(-2, Math.min(2, Math.round((d.dx ?? 0) / 6)));
const oz = Math.max(-2, Math.min(2, Math.round((d.dy ?? 0) / 6)));
const slot = d.id === DECOR.TUFT ? 5 : d.id === DECOR.STONE ? 7 : 2;
world.set(d.x * UNIT + 5 + ox, 1, d.y * UNIT + 5 + 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 = 0; d < UNIT; d++) {
world.set(tx * UNIT + d, GROUND_Y, ty * UNIT + 4, RAIL);
world.set(tx * UNIT + d, GROUND_Y, ty * UNIT + 5, RAIL);
}
}
/** Колокольчик: цветок на пепле (v1 harvest-тайл) — столбик в 2 вокселя,
* на тайле 10×10 одиночный воксель был бы точкой. */
function stampBellflower(world: VoxelWorld, tx: number, ty: number): void {
world.set(tx * UNIT + 5, GROUND_Y, ty * UNIT + 5, FLOWER);
world.set(tx * UNIT + 5, GROUND_Y + 1, ty * UNIT + 5, FLOWER);
}
/** Стена интерьера: колонка камня высотой 13 над полом (обзор сверху
* сохраняем: ниже героя — комнаты читаются, но стена уже не по колено). */
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 + 13; 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 };
}