/**
* Области «Пепельных луга» на v2: раскладка тайлов v1 (maps.json, референс)
* → воксельный мир. 1 тайл v1 = 10×10 вокселей (1 юнит = 10 вокселей; герой
* 16 ≈ 1.6 тайла ростом — дом ~2.5 тайла, дерево ~2.2–3.5, пропорции «человек
* и мир»). Рельеф — плоский (земля y0-1), объекты — штампы генераторов
* движка (деревья/валуны) и локальных генераторов контента (кусты, дома,
* колодец, башня). Позиции v1 (NPC/переходы/квесты, в тайлах) конвертируются
* на границе: центр тайла = (tile*UNIT + 5) в воксельных координатах.
*/
import {
VoxelWorld, VoxelGrid, stampModel,
encodeModel, Rng, type Variant,
} from '@rpg/engine';
import type { VoxelModel, VoxelTile, ModelTexture } from '@rpg/engine';
import mapsData from './data/maps.json';
import { texRemapFor } from './worldTex';
/** Раскладка области (конвертация 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 вокселей — герой 16 ≈ 1.6 тайла ростом). */
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, дома...) ----
/** Дерево карты: высокое (в ~2.3 раза выше героя — 16), 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 = 3.6 + rng.next() * 0.9; // джиттер радиуса фиксируем заранее
for (let dy = 0; dy < crownH; dy++) {
const y = trunkH - 1 + dy;
const t = dy / crownH; // 0..1 по высоте кроны
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) {
const rdy = r * (1.15 - t * 0.9); // низ вздут, верх сходит на нет
ok = d2 <= rdy * rdy + 1;
} else if (variant === 1) {
ok = d2 <= (t > 0.6 ? 3 : 6); // колонна: к верху уже
} else {
// ель: ярусы — каждый третий слой шире (ступень), выше уже
ok = d2 <= (dy % 3 === 0 ? 16 : t < 0.5 ? 9 : 4);
}
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' });
}
/** Куст карты (редизайн): приземистый (0), высокий с уступами (1) или
* малый — четверть площади обычного (2); рваный край по сиду. */
export function mapBushModel(variant: Variant, seed: number): VoxelModel {
const rng = new Rng(seed ^ 0x5a51);
// профиль ряда (порог d²): низ шире, верх уже
const PROFILE = variant === 1 ? [20, 14, 12, 9, 7, 5, 3, 2]
: variant === 2 ? [5, 4, 2] : [18, 10, 8, 4, 2];
const H = PROFILE.length;
const R = variant === 2 ? 2 : 4; // малый — четверть площади обычного
const g = new VoxelGrid(10, H, 10);
for (let y = 0; y < H; y++)
for (let dz = -R; dz <= R; dz++)
for (let dx = -R; dx <= R; dx++) {
const d2 = dx * dx + dz * dz;
if (d2 > PROFILE[y]) continue;
if (d2 > 0 && !rng.chance(0.85)) continue; // рваный край
g.set(5 + dx, y, 5 + dz, y === H - 1 || rng.chance(0.2) ? 5 : 3);
}
return encodeModel(g, { 3: '#626c4c', 5: '#7d8662' });
}
/** Валун карты (редизайн): купол со скруглённым силуэтом (радиус тает
* к верхушке), рваный край, трещина по боку, мох на верхушке (жизнь!);
* вариант — крупность, сид — форма. */
export function mapBoulderModel(variant: Variant, seed: number): VoxelModel {
const rng = new Rng(seed ^ 0x4b0d);
const H = 6 + (variant % 3); // 6..8 — глыба, не плитка
const g = new VoxelGrid(10, H, 10);
for (let y = 0; y < H; y++) {
const t = y / (H - 1); // 0..1 по высоте
const r = 4.7 * Math.sqrt(1 - t * t * 0.9) + 0.3; // купол
for (let dz = -5; dz <= 4; dz++)
for (let dx = -5; dx <= 4; dx++) {
const d = Math.sqrt(dx * dx + dz * dz);
if (d > r + (rng.next() - 0.5)) continue; // рваный край
const crack = y > 0 && y < H - 1 && dx === ((y * 3 + seed) % 5) - 2 && Math.abs(dz) < 2;
const moss = y >= H - 3 && rng.next() < 0.3;
g.set(5 + dx, y, 5 + dz, moss ? 23 : crack ? 18 : y >= H - 3 ? 7 : 6);
}
}
return encodeModel(g, { 6: '#5b5b66', 7: '#94949e', 18: '#47474f', 23: '#5e6b4a' });
}
// ---- Строения (редизайн трека 29): стены в трёх стилях по сиду (кладка,
// доски, фахверк), цоколь, дверь с косяками, окна с рамами, двускатная
// крыша с коньком, труба. Слоты — палитра сцены (6/7 камень, 4/8 дерево
// и ржавчина, 10 стекло, 18 цоколь, 19 дверь, 20 доска). ----
/** Тайль скина из 4 строк слотов по 4 текселя (строка 0 — верх грани),
* одинаковый на все 6 граней. Тексель = слот палитры модели. */
function tile4(rows: number[]): VoxelTile {
const face = btoa(String.fromCharCode(...rows));
return { size: 4, faces: [face, face, face, face, face, face] };
}
// Тайли скинов дома (трек 29): тексели ссылаются на слоты стен/крыши —
// перекраска слота красит и текстуру.
const T_BRICK = tile4([ // кладка: тёмные швы (18), вертикальные со сдвигом
6, 6, 18, 6,
6, 6, 18, 6,
18, 18, 18, 18,
18, 6, 6, 6,
]);
const T_PLANK = tile4([ // доски: горизонтальные венцы с тёмным швом (4)
20, 20, 20, 20,
20, 20, 20, 20,
4, 4, 4, 4,
20, 20, 20, 20,
]);
const T_PLASTER = tile4([ // штукатурка фахверка: редкие тёмные вкрапления (6)
7, 7, 7, 7,
7, 6, 7, 7,
7, 7, 7, 7,
7, 7, 6, 7,
]);
const T_SHINGLE = tile4([ // дранка/ржавый металл: тёмные ряды (4)
8, 8, 8, 8,
4, 4, 4, 4,
8, 8, 8, 8,
8, 8, 8, 8,
]);
const T_WOODSHINGLE = tile4([ // деревянная дранка (крыша слота 4): шов 8
4, 4, 4, 4,
8, 8, 8, 8,
4, 4, 4, 4,
4, 4, 4, 4,
]);
const T_DOOR = tile4([ // дверное полотно: вертикальные доски со швом (4)
19, 19, 4, 19,
19, 19, 4, 19,
19, 19, 4, 19,
19, 19, 4, 19,
]);
const T_STONE = tile4([ // замковые камни углов: крупный блок с гранью (6)
7, 7, 7, 7,
7, 7, 7, 7,
7, 7, 6, 7,
7, 7, 7, 7,
]);
/** Дом 24×18 (высокие подварианты 40): стиль стен и крыши, труба — по сиду;
* дверь на фасаде z=D−1, x10..13 сохранена (точка захода героя). Скин —
* текстурный слой: тайли кладки/досок/штукатурки/дранки на гранях стен. */
export function houseModel(kind: number, seed: number): VoxelModel {
const tall = kind === T.HOUSE_V3 || kind === T.HOUSE_V4;
const rng = new Rng(seed ^ 0x1d0f);
const W = 24, H = tall ? 40 : 30, D = 18, ROOF = 5;
const wallH = H - ROOF;
const style = Math.floor(rng.next() * 3); // 0 кладка, 1 доски, 2 фахверк
const g = new VoxelGrid(W, H, D);
houseWalls(g, W, wallH, D, style);
houseDoor(g, D);
houseWindows(g, W, wallH, D, style, tall);
houseRoof(g, W, H, D, ROOF, rng.next() < 0.6);
if (rng.next() < 0.6) houseChimney(g, W, H, ROOF);
return encodeModel(g, {
6: '#5b5b66', 7: '#94949e', 4: '#5c3b2e', 8: '#82503a', 10: '#44454e',
18: '#47474f', 19: '#6e4a30', 20: '#74553e',
}, houseTexture(g, W, H, D, ROOF, style));
}
/** Текстурный слой дома: тайли по слоту и зоне вокселя (стены/крыша/дверь).
* Тайль стены — только на базовом слоте стиля (6/20/7); тёмные балки и
* венцы (слот 4) остаются плоскими, иначе пропадают в арте. */
function houseTexture(
g: VoxelGrid, W: number, H: number, D: number, ROOF: number, style: number,
): ModelTexture {
const baseSlot = [6, 20, 7][style];
const tiles: Record<number, VoxelTile> = {
1: [T_BRICK, T_PLANK, T_PLASTER][style], 2: T_STONE, 3: T_SHINGLE,
4: T_WOODSHINGLE, 5: T_DOOR, 6: T_BRICK,
};
const tex = new Uint8Array(g.data.length);
for (let y = 0; y < H; y++)
for (let z = 0; z < D; z++)
for (let x = 0; x < W; x++) {
const slot = g.get(x, y, z);
if (slot === 0) continue;
let id = 0;
if (y >= H - ROOF) id = slot === 4 ? 4 : slot === 8 ? 3 : 0; // крыша
else if (slot === baseSlot) id = 1; // стена стиля
else if (slot === 4 && (x === 0 || x === W - 1) && (z === 0 || z === D - 1)) id = 2; // углы — замковые камни
else if (slot === 19) id = 5; // дверное полотно
else if (slot === 6) id = 6; // труба (камень)
if (id !== 0) tex[x + W * (y + H * z)] = id;
}
return { tiles, tex };
}
/** Слот стены по стилю: u — координата вдоль грани, y — высота. */
function wallSlot(x: number, y: number, z: number, W: number, D: number, wallH: number, style: number): number {
const u = x === 0 || x === W - 1 ? z : x; // вдоль грани
const corner = (x === 0 || x === W - 1) && (z === 0 || z === D - 1);
if (corner) return style === 1 ? 4 : 7; // углы: тёмные доски / замковые камни
if (style === 0) return y % 3 === 2 && (u + (y / 3 | 0) * 2) % 4 === 0 ? 7 : 6; // швы кладки
if (style === 1) return y === wallH - 1 ? 4 : u % 3 === 0 ? 4 : 20; // доски + венец
return y === wallH - 1 || u % 6 === 2 ? 4 : 7; // фахверк: стойки в штукатурке
}
/** Коробка стен (только рёбра) с цоколем в 2 вокселя. */
function houseWalls(g: VoxelGrid, W: number, wallH: number, D: number, style: number): void {
for (let y = 0; y < wallH; 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;
g.set(x, y, z, y < 2 ? 18 : wallSlot(x, y, z, W, D, wallH, style));
}
}
/** Дверь на фасаде (z = D−1): тёплое полотно, тёмные косяки, ручка. */
function houseDoor(g: VoxelGrid, D: number): void {
for (let y = 0; y <= 4; y++) for (let x = 10; x <= 13; x++) g.set(x, y, D - 1, 19);
for (let y = 0; y <= 5; y++) { g.set(9, y, D - 1, 4); g.set(14, y, D - 1, 4); }
for (let x = 9; x <= 14; x++) g.set(x, 5, D - 1, 4); // притолока
g.set(13, 3, D - 1, 4); // ручка
}
/** Окно 4×4 с рамой на плоскости стены (axis — фиксированная координата). */
function windowOn(g: VoxelGrid, axis: 'x' | 'z', fix: number, u0: number, v0: number, frame: number): void {
for (let du = 0; du < 4; du++)
for (let dv = 0; dv < 4; dv++) {
const u = u0 + du, v = v0 + dv;
const border = du === 0 || dv === 0 || du === 3 || dv === 3;
const [x, y, z] = axis === 'x' ? [fix, v, u] : [u, v, fix];
g.set(x, y, z, border ? frame : 10);
}
}
/** Окна: пара на фасаде + пара на боковой стене; у высоких — второй ряд. */
function houseWindows(g: VoxelGrid, W: number, wallH: number, D: number, style: number, tall: boolean): void {
const frame = style === 0 ? 7 : 4;
const y0 = Math.min(6, wallH - 6);
windowOn(g, 'z', D - 1, 2, y0, frame);
windowOn(g, 'z', D - 1, W - 6, y0, frame);
windowOn(g, 'x', 0, 4, y0, frame);
windowOn(g, 'x', W - 1, D - 8, y0, frame);
if (tall) {
windowOn(g, 'z', D - 1, 2, y0 + 8, frame);
windowOn(g, 'z', D - 1, W - 6, y0 + 8, frame);
}
}
/** Двускатная крыша (конёк вдоль X, свес за стены): ржавый металл или
* тёмная дранка по сиду, ряд конька светлее. */
function houseRoof(g: VoxelGrid, W: number, H: number, D: number, ROOF: number, rust: boolean): void {
const roof = rust ? 8 : 4;
for (let dy = 0; dy < ROOF; dy++) {
const y = H - ROOF + dy;
for (let z = Math.max(0, dy * 2 - 1); z < Math.min(D, D - dy * 2); z++)
for (let x = Math.max(0, dy - 1); x < Math.min(W, W - dy + 1); x++)
g.set(x, y, z, dy === ROOF - 1 ? 20 : roof);
}
}
/** Труба 2×2 над скатом: камень с тёмным жерлом на срезе. */
function houseChimney(g: VoxelGrid, W: number, H: number, ROOF: number): void {
for (let y = H - ROOF - 1; y < H; y++)
for (let dz = 4; dz <= 5; dz++)
for (let dx = W - 6; dx <= W - 5; dx++)
g.set(dx, y, dz, y === H - 1 ? 10 : 6);
}
/** Колодец (редизайн): сруб из брёвен с выпусками по углам, тёмная вода
* в шахте, деревянные стойки с металлическим воротом и ручкой,
* двускатная крыша на стойках (ржавый гонт, конёк — дерево). */
export function wellModel(_seed: number): VoxelModel {
const g = new VoxelGrid(10, 12, 10);
// сруб: три ряда брёвен, торцы по углам светлее через ряд (выпуски)
for (let y = 0; y < 3; y++)
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, y, z, 4);
}
for (const [cx, cz] of [[0, 0], [9, 0], [0, 9], [9, 9]] as const)
for (let y = 0; y < 3; y += 2) g.set(cx, y, cz, 20);
// шахта: тёмная вода внутри кольца
for (let z = 3; z <= 6; z++) for (let x = 3; x <= 6; x++) g.set(x, 1, z, 22);
// стойки, ворот (металл) с ручкой
for (let y = 3; y <= 8; y++) for (const z of [4, 5]) { g.set(1, y, z, 4); g.set(8, y, z, 4); }
for (let x = 2; x <= 7; x++) g.set(x, 7, 4, 10);
g.set(2, 8, 4, 10);
g.set(2, 8, 3, 4); // ручка ворота
// крыша: двускатная вдоль X (скат по z), свес за сруб
const spans: Array<[number, number]> = [[0, 9], [2, 7], [4, 5]];
for (let dy = 0; dy < 3; dy++)
for (let z = spans[dy][0]; z <= spans[dy][1]; z++)
for (let x = 0; x < 10; x++) g.set(x, 9 + dy, z, dy === 2 ? 4 : 8);
return encodeModel(g, { 4: '#5c3b2e', 20: '#74553e', 10: '#44454e', 8: '#82503a', 22: '#3a4550' });
}
/** Башня звона (редизайн): круглая кладка r≈4.5 с угловыми контрфорсами
* (углы тайла заняты — blocked-семантика сохраняется), цоколь, швы кладки,
* бойницы в два яруса, площадка с зубцами, колокол на деревянной раме. */
export function towerModel(_seed: number): VoxelModel {
const g = new VoxelGrid(10, 47, 10);
// ствол: кольцо стены + 2×2 контрфорсы по углам, цоколь 2 вокселя
for (let y = 0; y < 36; y++)
for (let z = 0; z < 10; z++)
for (let x = 0; x < 10; x++) {
const d2 = (x - 4.5) ** 2 + (z - 4.5) ** 2;
const pier = (x < 2 || x > 7) && (z < 2 || z > 7);
if (!(pier || (d2 <= 20.25 && d2 >= 10))) continue;
if (y < 2) { g.set(x, y, z, 18); continue; }
const seam = y % 3 === 2 && (x + z + (y / 3 | 0)) % 4 === 0;
g.set(x, y, z, pier || seam ? 7 : 6);
}
// бойницы: щели 1×3 в два яруса по сторонам света
for (const [sx, sz] of [[4, 0], [0, 4], [9, 4], [4, 9]] as const)
for (const y of [14, 15, 16, 24, 25, 26]) g.set(sx, y, sz, 10);
// площадка (свет камня) + зубцы через один по кольцу
for (let z = 0; z < 10; z++)
for (let x = 0; x < 10; x++) {
const d2 = (x - 4.5) ** 2 + (z - 4.5) ** 2;
const pier = (x < 2 || x > 7) && (z < 2 || z > 7);
if (d2 <= 20.25 || pier) g.set(x, 36, z, 7);
if (d2 <= 20.25 && d2 >= 10 && (x + z) % 2 === 0) { g.set(x, 37, z, 6); g.set(x, 38, z, 6); }
}
// рама колокола: стойки и поперечный брус, колокол — металл расширяется вниз
for (let y = 37; y <= 42; y++) for (const z of [4, 5]) { g.set(1, y, z, 4); g.set(8, y, z, 4); }
for (let x = 1; x <= 8; x++) for (const z of [4, 5]) g.set(x, 42, z, 4);
for (let x = 4; x <= 5; x++) for (let z = 4; z <= 5; z++) g.set(x, 41, z, 10);
for (let x = 3; x <= 6; x++) for (let z = 3; z <= 6; z++) {
g.set(x, 40, z, 10);
g.set(x, 39, z, (x + z) % 2 === 0 ? 21 : 10); // нижний пояс — светлее
}
g.set(4, 38, 4, 21); // язык колокола
return encodeModel(g, { 6: '#5b5b66', 7: '#94949e', 10: '#44454e', 21: '#56575f', 4: '#5c3b2e', 18: '#47474f' });
}
/** Старая Машина (редизайн): каменный цоколь шире корпуса, клёпаный
* металлический корпус со ржавыми швами, боковая труба с коленом к
* жерлу-дымоходу, топка с углями на фасаде, манометр-точка. */
export function machineModel(_seed: number): VoxelModel {
const g = new VoxelGrid(24, 34, 18);
// цоколь (тёмный камень с редкими светлыми камнями)
for (let y = 0; y < 2; y++)
for (let z = 0; z < 18; z++)
for (let x = 0; x < 24; x++) g.set(x, y, z, (x + y * 2 + z) % 5 === 0 ? 6 : 18);
// корпус: клёпаный металл, швы ржавчиной через 5 рядов
for (let y = 2; y <= 20; y++)
for (let z = 1; z <= 16; z++)
for (let x = 1; x <= 22; x++) {
const edge = x === 1 || z === 1 || x === 22 || z === 16;
if (!edge) continue;
const seam = y % 5 === 0;
g.set(x, y, z, seam ? 8 : !seam && y % 4 === 2 && (x + z) % 4 === 1 ? 21 : 10);
}
// верхняя плита (ржавая)
for (let z = 3; z <= 14; z++) for (let x = 3; x <= 20; x++) g.set(x, 21, z, 8);
// труба с коленом: вверх по левой стенке, поворот к дымоходу
for (let y = 4; y <= 26; y++) for (const z of [3, 4]) g.set(2, y, z, 10);
for (let x = 2; x <= 11; x++) for (const z of [3, 4]) g.set(x, 26, z, 10);
// жерло-дымоход: ржавый верх, тёплая искра внутри (жизнь!)
for (let y = 27; y <= 31; y++)
for (let z = 3; z <= 6; z++)
for (let x = 10; x <= 13; x++) g.set(x, y, z, y >= 30 ? 8 : 10);
g.set(11, 30, 4, 9);
g.set(12, 30, 5, 9);
// топка на фасаде (z=16): ржавая рама, тёмный проём, угли
for (let y = 2; y <= 7; y++)
for (let x = 8; x <= 15; x++) {
const border = y === 7 || x === 8 || x === 15;
g.set(x, y, 16, border ? 8 : y <= 3 ? 9 : 18);
}
g.set(18, 12, 16, 9); // манометр (тёплая точка)
return encodeModel(g, { 6: '#5b5b66', 18: '#47474f', 10: '#44454e', 21: '#56575f', 8: '#82503a', 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).
* Текстурный слой модели (тайли скинов) идёт в мир через реестр worldTex. */
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),
{}, { texRemap: texRemapFor(m) },
);
}
/**
* Сборка воксельного мира области из раскладки 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; // под объектами
}
}
/** Штамп объекта тайла (дерево/дом/валун/куст/башня/колодец/рельсы...).
* Дома кэшируются по (тип, сид) — каждый дом со своим стилем; валунам и
* кустам вариант тоже выбирается сидом (в раскладке v1 только базовые
* тайлы 28/29 — вариативность живёт в сиде). */
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}:${seed}`, () => houseModel(t, seed)), tx, ty);
else if (BOULDER_KINDS.includes(t))
stampAt(world, cached(`boul${seed}`, () => mapBoulderModel(boulderVariant(seed), seed)), tx, ty);
else if (BUSH_KINDS.includes(t))
stampAt(world, cached(`bush${seed}`, () => mapBushModel(bushVariant(seed), 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);
}
/** Вариант валуна по сиду: 0/1/2 — крупность. */
function boulderVariant(seed: number): Variant {
return (((seed ^ 0x4b0d) >>> 3) % 3) as Variant;
}
/** Вариант куста по сиду: чаще обычный, реже высокий или малый. */
function bushVariant(seed: number): Variant {
const h = ((seed ^ 0x5a51) >>> 5) % 8;
return (h === 7 ? 2 : h === 6 ? 1 : 0) as Variant;
}
/** Рельсы: металлическая нить по земле (полоса тайлов даёт линию). */
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 };
}