/**
* Области «Пепельных луга» на 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 };
}