/** * WorldGenerator — процедурная генерация мира по параметрам. * * Использует SimplexNoise для heightmap, biome map, density. Структуры * (деревья, скалы) расставляются детерминистически по координатам. * * Архитектура: * - WorldGenerator(params) — создаёт инстанс с фиксированными параметрами * - generateColumn(x, z) — высокоуровневая: возвращает данные одного * столбца (массив voxel'ов с Y) * - generateIntoLayer(layer, bbox) — основная: заполняет slice мира * в указанном bbox (нужно для редактора) * - sampleHeight(x, z) — только высота земли (для preview canvas) * - sampleBiome(x, z) — только биом (для preview) * * Параметры (GeneratorParams): * { * seed: number, // глобальный seed * worldSize: 'infinite' | { width, height }, * heightmap: { * scale, octaves, persistence, lacunarity, * amplitude, // макс высота в метрах * baseHeight, // средний уровень * exponent // >1 = плоские долины + резкие пики * }, * domainWarp: { enabled, scale, strength }, * biomes: [ {id, threshold:[lo,hi], topMaterial, softMaterial, hardMaterial, * features: {trees, treeTypes, bushes, flowers, mushrooms, grass}} ], * biomeMap: { scale, seed }, * structures: { trees: {enabled, density, minDistance}, ... }, * decorations: { grass: ..., flowers: ..., mushrooms: ... }, * } */ import { SimplexNoise } from './SimplexNoise'; // Дефолтные параметры — Voxlands-стиль земного мира. export const DEFAULT_GENERATOR_PARAMS = { seed: 1337, worldSize: { width: 320, height: 320 }, // в voxel-units (160м × 0.25) heightmap: { scale: 0.012, // плавнее (меньше частота → крупнее холмы) octaves: 3, // меньше деталей — плавнее поверхность persistence: 0.45, lacunarity: 2.0, amplitude: 8, // макс ~8 voxel = 2м. Уютные пологие холмы baseHeight: 1, exponent: 1.8, // высокая экспонента → большая часть карты плоская }, domainWarp: { enabled: false, scale: 0.05, strength: 5, }, biomeMap: { scale: 0.005, // крупные биомы (плавные переходы на больших картах) seedOffset: 100, }, // Дефолтное распределение биомов: 80% травы (plain+forest), 10% песка, // 10% гор. Без снега по умолчанию — слишком серо для теста. biomes: [ { id: 'desert', threshold: [0.0, 0.1], // 10% topMaterial: 'sand', softMaterial: 'sand', hardMaterial: 'sand', features: { trees: 0.01, treeTypes: ['oak'], grass: 0 }, }, { id: 'plain', threshold: [0.1, 0.55], // 45% topMaterial: 'grass', softMaterial: 'dirt', hardMaterial: 'rock', features: { trees: 0.3, treeTypes: ['oak'], grass: 0.2 }, }, { id: 'forest', threshold: [0.55, 0.85], // 30% topMaterial: 'grass', softMaterial: 'dirt', hardMaterial: 'rock', features: { trees: 0.8, treeTypes: ['oak', 'autumn'], grass: 0.4 }, }, { id: 'mountain', threshold: [0.85, 1.0], // 15% — горы в крайних местах карты topMaterial: 'rock', softMaterial: 'rock', hardMaterial: 'rock', heightBonus: 1.4, features: { trees: 0.1, treeTypes: ['autumn'] }, }, ], structures: { trees: { enabled: true, density: 0.3, minDistance: 8, sizeScale: 1.0 }, rocks: { enabled: false, density: 0.1 }, lakes: { enabled: true, threshold: -0.2 }, // Декорации — Этап 6 voxel-движка. Цветы/грибы и трава имеют РАЗНУЮ // частоту: цветы — заметные, редкие; трава — частая россыпь. // PERFORMANCE: понижено grass с 15% до 8% — это ×2 меньше декораций. // При radius=25м для LOD это даст ~50К deco voxel'ов вместо ~100К. decorations: { enabled: true, flowersDensity: 0.015, // 1.5% — цветы и грибы grassDensity: 0.05, // 5% — пучки травы (было 8%, ещё снижено для FPS) }, }, decorations: { // Пока не используется до Этапа 6 (deco-слой 0.05м) grass: { enabled: false, density: 0.1 }, flowers: { enabled: false, density: 0.04, colors: ['flower_red', 'flower_blue', 'flower_yellow'] }, mushrooms: { enabled: false, density: 0.02 }, }, }; /** * Простая хэш-функция: детерминированный псевдослучайный float [0..1) * по 2D-координатам и seed. Используется для расставления структур * (деревьев) детерминистически. */ function hash2(x, y, seed) { let h = (x * 374761393 + y * 668265263 + seed * 1442695040) | 0; h = Math.imul(h ^ (h >>> 13), 1274126177); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; } export class WorldGenerator { /** * @param {Object} params - GeneratorParams (использует DEFAULT при отсутствии) */ constructor(params = {}) { this.params = Object.assign({}, DEFAULT_GENERATOR_PARAMS, params); // Глубокое клонирование подобъектов чтобы не мутировать дефолты this.params.heightmap = { ...DEFAULT_GENERATOR_PARAMS.heightmap, ...(params.heightmap ?? {}) }; this.params.domainWarp = { ...DEFAULT_GENERATOR_PARAMS.domainWarp, ...(params.domainWarp ?? {}) }; this.params.biomeMap = { ...DEFAULT_GENERATOR_PARAMS.biomeMap, ...(params.biomeMap ?? {}) }; this.params.biomes = params.biomes ?? DEFAULT_GENERATOR_PARAMS.biomes; this.params.structures = { ...DEFAULT_GENERATOR_PARAMS.structures, ...(params.structures ?? {}) }; // Создаём 2 независимых noise: один для heightmap, второй для biomes this.heightNoise = new SimplexNoise(this.params.seed); this.biomeNoise = new SimplexNoise(this.params.seed + this.params.biomeMap.seedOffset); } /** * Получить «сырую» высоту в нормализованном [-1..1]. * Применяется domain warping если включён. */ rawHeight(x, z) { const hp = this.params.heightmap; let wx = x, wz = z; if (this.params.domainWarp.enabled) { const ws = this.params.domainWarp.scale; const wstr = this.params.domainWarp.strength; wx = x + this.heightNoise.noise2(x * ws, z * ws) * wstr; wz = z + this.heightNoise.noise2(x * ws + 100, z * ws + 100) * wstr; } const n = this.heightNoise.fbm2( wx * hp.scale, wz * hp.scale, hp.octaves, hp.persistence, hp.lacunarity, ); return n; // [-1, +1] примерно } /** * Получить итоговую высоту земли в Y-voxel-units (целое). * * Использует SMOOTH biome blending: heightBonus интерполируется между * соседними биомами в transition zone (зона BIOME_BLEND_WIDTH вокруг * threshold). Без этого карта имеет вертикальные обрывы на границах * биом-зон (например trees-биом с heightBonus=1 → mountain с * heightBonus=1.8 даёт ступеньку 15+ voxel'ов). */ sampleHeight(x, z) { const hp = this.params.heightmap; let n = this.rawHeight(x, z); n = (n + 1) * 0.5; n = Math.pow(Math.max(0, Math.min(1, n)), hp.exponent); // SMOOTH biome bonus: вычисляем bonus как взвешенную сумму // соседних биомов по biome-noise значению. const bonus = this._smoothBiomeBonus(x, z); const h = Math.round(hp.baseHeight + n * hp.amplitude * bonus); return h; } /** * Гладкая интерполяция heightBonus между биомами. * Берём biome-noise в (x,z) и считаем взвешенный bonus двух ближайших * биомов (того кому принадлежит точка + соседнего по threshold). */ _smoothBiomeBonus(x, z) { const BLEND_WIDTH = 0.08; // зона смешивания (в biome-noise space) const bm = this.params.biomeMap; const n = (this.biomeNoise.fbm2(x * bm.scale, z * bm.scale, 3, 0.5, 2.0) + 1) * 0.5; const biomes = this.params.biomes; // Найти текущий и соседний биом let currentIdx = -1; for (let i = 0; i < biomes.length; i++) { const b = biomes[i]; if (n >= b.threshold[0] && n < b.threshold[1]) { currentIdx = i; break; } } if (currentIdx === -1) currentIdx = biomes.length - 1; const cur = biomes[currentIdx]; const curBonus = cur.heightBonus ?? 1; const t0 = cur.threshold[0]; const t1 = cur.threshold[1]; // Близко ли к началу (граница с предыдущим биомом)? const distFromStart = n - t0; // Близко ли к концу (граница со следующим)? const distFromEnd = t1 - n; if (distFromStart < BLEND_WIDTH && currentIdx > 0) { // Близко к нижней границе — смешиваем с предыдущим const prev = biomes[currentIdx - 1]; const prevBonus = prev.heightBonus ?? 1; // t: 0 на границе, 1 при distFromStart=BLEND_WIDTH const t = distFromStart / BLEND_WIDTH; // smoothstep для плавности const ts = t * t * (3 - 2 * t); return prevBonus * (1 - ts) + curBonus * ts; } if (distFromEnd < BLEND_WIDTH && currentIdx < biomes.length - 1) { // Близко к верхней границе — смешиваем со следующим const next = biomes[currentIdx + 1]; const nextBonus = next.heightBonus ?? 1; const t = distFromEnd / BLEND_WIDTH; const ts = t * t * (3 - 2 * t); return nextBonus * (1 - ts) + curBonus * ts; } // Внутри биома — без интерполяции return curBonus; } /** * Какой биом в координате (x,z) — возвращает объект из biomes[]. */ sampleBiome(x, z) { const bm = this.params.biomeMap; // Используем второй noise с диапазоном [0..1] const n = (this.biomeNoise.fbm2(x * bm.scale, z * bm.scale, 3, 0.5, 2.0) + 1) * 0.5; for (const b of this.params.biomes) { if (n >= b.threshold[0] && n < b.threshold[1]) return b; } // fallback к последнему return this.params.biomes[this.params.biomes.length - 1]; } /** * Будет ли тут стоять дерево? Детерминистически по (x,z) и параметрам. * Возвращает {type} или null. */ sampleTreeAt(x, z) { const s = this.params.structures.trees; if (!s || !s.enabled) return null; const biome = this.sampleBiome(x, z); const treeProb = (biome.features?.trees ?? 0) * s.density; if (treeProb <= 0) return null; // density поделена на minDistance² чтобы плотность была интуитивной const grid = Math.max(2, s.minDistance); const gx = Math.floor(x / grid); const gz = Math.floor(z / grid); // Проверяем что в этой grid-ячейке стоит дерево const r = hash2(gx, gz, this.params.seed + 999); if (r >= treeProb) return null; // Точная позиция дерева внутри grid-ячейки (детерминистично) const ox = Math.floor(hash2(gx, gz, this.params.seed + 1001) * grid); const oz = Math.floor(hash2(gx, gz, this.params.seed + 1002) * grid); if (gx * grid + ox !== x || gz * grid + oz !== z) return null; // Выбираем тип дерева const treeTypes = biome.features?.treeTypes ?? ['oak']; const tt = Math.floor(hash2(gx, gz, this.params.seed + 1003) * treeTypes.length); return { type: treeTypes[tt % treeTypes.length] }; } /** * Сгенерировать voxel-данные в указанный bbox (в voxel-units). * Заполняет уже существующий VoxelLayer. * * Использует SURFACE EXTRACTION (как в v5 Python-генератор): сохраняем * только voxel'ы, которые игрок может УВИДЕТЬ: * - верхний слой (topMaterial) — h-1 * - 2 soft слоя под ним — h-2, h-3 * - hard на обрывах: y между min(neighbors) и h-3 * * Внутренние массивы (глубокий камень) пропускаются — игрок их никогда * не увидит, а surface culling всё равно скроет грани. Это даёт * 5-10× меньше voxel'ов чем заполнение каждого столбца сплошняком. * * Без этой оптимизации карта 160×160м даёт 11М voxel'ов вместо ~1.5М. * * @param {VoxelLayer} layer - целевой слой terrain * @param {Object} bbox - {x0, z0, x1, z1} в voxel-units (целые) * @param {Function} [progressCb] - опциональный (done, total) callback * @returns {Object} stats */ async generateIntoLayer(layer, bbox, progressCb = null) { const t0 = (typeof performance !== 'undefined' ? performance.now() : Date.now()); const { x0, z0, x1, z1 } = bbox; const width = x1 - x0; const depth = z1 - z0; // 4 фазы: heightmap (1×), surface extraction (1×), trees (~0.3×). // Считаем total с весом фаз чтобы прогресс линейный. const totalCells = width * depth; const totalWeighted = totalCells * 2 + totalCells * 0.3; let done = 0; let stats = { columnsGenerated: 0, treesPlaced: 0 }; // Yield (отдать управление браузеру) каждые ~30мс чтобы UI не висел. let lastYield = t0; const yieldToBrowser = async () => { const now = (typeof performance !== 'undefined' ? performance.now() : Date.now()); if (now - lastYield > 30) { await new Promise(r => setTimeout(r, 0)); lastYield = (typeof performance !== 'undefined' ? performance.now() : Date.now()); } }; // Шаг 1: heightmap. Самая дорогая фаза на больших картах // (3 noise-вызова на клетку × октавы). const heightmap = new Map(); for (let z = z0; z < z1; z++) { for (let x = x0; x < x1; x++) { const h = this.sampleHeight(x, z); const biome = this.sampleBiome(x, z); heightmap.set(`${x},${z}`, { h, biome }); } done += width; if ((z - z0) % 16 === 0) { if (progressCb) progressCb(done, totalWeighted, 'heightmap'); await yieldToBrowser(); } } // Шаг 2: surface extraction. Сохраняем только видимые voxel'ы. for (let z = z0; z < z1; z++) { for (let x = x0; x < x1; x++) { const info = heightmap.get(`${x},${z}`); const h = info.h; const biome = info.biome; // Соседние высоты (за пределами bbox → 0 = пустота, видимый обрыв) const nb = []; for (const [dx, dz] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) { const nbInfo = heightmap.get(`${x + dx},${z + dz}`); nb.push(nbInfo ? nbInfo.h : 0); } const minNbH = Math.min(...nb); if (h <= 0) { // Озеро / болото — только 1 voxel внизу layer.setVoxel(x, 0, z, biome.softMaterial); done++; continue; } // Верхний — topMaterial (h-1) layer.setVoxel(x, h - 1, z, biome.topMaterial); // 2 soft слоя под верхом — только если они выше или равно // minNbH (видны на боковых стенах обрыва). Если в массиве — // невидимо, пропускаем. if (h >= 2 && (h - 2) >= minNbH) { layer.setVoxel(x, h - 2, z, biome.softMaterial); } if (h >= 3 && (h - 3) >= minNbH) { layer.setVoxel(x, h - 3, z, biome.softMaterial); } // Hard на обрывах: y между minNbH и h-3. // Если y < minNbH — соседний столбец той же высоты или выше // → voxel невидим, пропускаем (главная оптимизация surface // extraction, без неё карта в 10-20× больше). // Если y >= h-3 — это уже soft-слой, не пишем сюда hard. const bottomVisible = Math.max(0, minNbH); const topVisible = h - 3; // exclusive for (let y = bottomVisible; y < topVisible; y++) { layer.setVoxel(x, y, z, biome.hardMaterial); } // Гарантируем что Y=0 заполнено если столбец возвышается над пустотой // (нужно чтобы был «пол» под краями карты). if (minNbH <= 0 && !layer.getVoxel(x, 0, z)) { layer.setVoxel(x, 0, z, biome.hardMaterial); } done++; } stats.columnsGenerated += width; if ((z - z0) % 16 === 0) { if (progressCb) progressCb(done, totalWeighted, 'surface'); await yieldToBrowser(); } } // Шаг 3: деревья (используем кешированную heightmap) if (this.params.structures.trees.enabled) { for (let z = z0; z < z1; z++) { for (let x = x0; x < x1; x++) { const tree = this.sampleTreeAt(x, z); if (!tree) continue; const info = heightmap.get(`${x},${z}`); if (!info || info.h < 1) continue; if (info.biome.topMaterial !== 'grass') continue; this._placeTree(layer, x, info.h - 1, z, tree.type); stats.treesPlaced++; } done += width * 0.3; if ((z - z0) % 32 === 0) { if (progressCb) progressCb(done, totalWeighted, 'trees'); await yieldToBrowser(); } } } // Шаг 4: deco — мини-воксельные цветы/грибы/трава по биомам. // Используются ДВА порога: // flowersDensity — цветы и грибы (редкие, заметные ~1-3%) // grassDensity — пучки травы (частые ~10-20%) // Это разделение даёт разный визуальный эффект: цветы как акценты, // трава как фоновая россыпь. const decoParams = this.params.structures.decorations ?? {}; const flowersDensity = decoParams.flowersDensity ?? 0.015; const grassDensity = decoParams.grassDensity ?? 0.15; stats.decorations = []; if ((flowersDensity > 0 || grassDensity > 0) && decoParams.enabled !== false) { // Раздельные пулы — цветы и трава отдельно const FLOWERS_BY_BIOME = { grass: ['daisy', 'cornflower', 'poppy', 'dandelion'], forest: ['daisy', 'fly_mushroom', 'brown_mushroom', 'fly_mushroom'], plain: ['dandelion', 'cornflower', 'daisy'], }; // Пул моделей травы с весами (одинаковые повторы = чаще). // Дублирует GRASS_MODELS_POOL из DecoModels.js — здесь захардкожен // чтобы не импортировать UI-зависимости. const GRASS_POOL = [ 'grass_short', 'grass_short', 'grass_short', 'grass_short', 'grass_tuft', 'grass_tuft', 'grass_tuft', 'grass_blade', 'grass_blade', 'grass_wide', 'grass_wide', 'grass_tall', 'grass_clump', 'grass_dry', ]; for (let z = z0; z < z1; z++) { for (let x = x0; x < x1; x++) { const info = heightmap.get(`${x},${z}`); if (!info || info.h < 1) continue; if (info.biome.topMaterial !== 'grass') continue; const topY = info.h - 1; const aboveMat = layer.getVoxel(x, topY + 1, z); if (aboveMat) continue; // Мировые координаты const wx = (x + 0.5) * 0.25; const wy = info.h * 0.25; const wz = (z + 0.5) * 0.25; // Сначала пробуем цветок (приоритет — он более заметный). const rFlower = hash2(x, z, this.params.seed + 5001); if (rFlower < flowersDensity) { const biomeId = info.biome.id; const pool = FLOWERS_BY_BIOME[biomeId] || FLOWERS_BY_BIOME.plain; if (pool && pool.length > 0) { const modelIdx = Math.floor(hash2(x, z, this.params.seed + 5002) * pool.length); const modelId = pool[modelIdx]; const rotation = Math.floor(hash2(x, z, this.params.seed + 5003) * 4); stats.decorations.push({ x: wx, y: wy, z: wz, modelId, rotation }); continue; // не ставим траву поверх цветка } } // Иначе — пробуем траву. // Случайно выбираем модель из пула (5-7 разных) // + случайный scale 0.7-1.4 для разнообразия размеров. const rGrass = hash2(x, z, this.params.seed + 5004); if (rGrass < grassDensity) { const modelIdx = Math.floor(hash2(x, z, this.params.seed + 5005) * GRASS_POOL.length); const modelId = GRASS_POOL[modelIdx]; const rotation = Math.floor(hash2(x, z, this.params.seed + 5006) * 4); // Scale 0.7..1.4 — каждый кустик чуть другого размера const scale = 0.7 + hash2(x, z, this.params.seed + 5007) * 0.7; stats.decorations.push({ x: wx, y: wy, z: wz, modelId, rotation, scale }); } } if ((z - z0) % 32 === 0) await yieldToBrowser(); } } const dt = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - t0; stats.timeMs = Math.round(dt); if (progressCb) progressCb(totalWeighted, totalWeighted, 'done'); return stats; } /** * Поставить voxel-дерево в указанной точке. * Тип: 'oak' | 'birch' | 'autumn'. * * Архитектура: * 1. Ствол — основной столб + утолщение у основания (для oak/autumn) * + ветви, отходящие в стороны на 60-80% высоты * 2. Крона — пятнистая, mix двух материалов (leaves + leaves_orange) * в разных пропорциях для типа дерева. Шум определяет какой * voxel какого цвета — даёт «пятна» как на Roblox-скринах. * 3. Для крупных деревьев — несколько шаров кроны на ветвях. * * Палитра по типам: * oak — trunk + leaves (mix 5% leaves_orange = редкие жёлтые пятна) * birch — trunk_white + leaves (mix 10% leaves_orange = лёгкая желтизна) * autumn — trunk + leaves_orange (mix 25% leaves = тёмные зелёные кляксы) */ _placeTree(layer, tx, topY, tz, type) { const r1 = hash2(tx, tz, this.params.seed + 2001); const r2 = hash2(tx, tz, this.params.seed + 2002); const r3 = hash2(tx, tz, this.params.seed + 2003); const sizeScale = Math.max(0.3, Math.min(5.0, this.params.structures?.trees?.sizeScale ?? 1.0)); // === Параметры по типу === let primaryTrunk, secondaryTrunk; let primaryLeaf, secondaryLeaf; let secondaryLeafChance; let trunkH, crownR, crownSquish; // Толщина ствола: 0=один voxel, 1=крест 3×3 без углов, 2=полный 3×3, 3=5×5 let trunkThickness; if (type === 'birch') { primaryTrunk = 'trunk_white'; secondaryTrunk = 'trunk_white'; primaryLeaf = 'leaves'; secondaryLeaf = 'leaves_orange'; secondaryLeafChance = 0.10; trunkH = Math.round((10 + r1 * 4) * sizeScale); crownR = Math.round(3 * sizeScale); crownSquish = 1.5; // Берёза тонкая по жизни trunkThickness = sizeScale < 1.5 ? 0 : 1; } else if (type === 'autumn') { primaryTrunk = 'trunk'; secondaryTrunk = 'wood'; primaryLeaf = 'leaves_orange'; secondaryLeaf = 'leaves'; secondaryLeafChance = 0.25; trunkH = Math.round((9 + r1 * 4) * sizeScale); crownR = Math.round((4 + r2 * 2) * sizeScale); crownSquish = 0.85; // Толщина растёт с размером trunkThickness = sizeScale < 0.8 ? 0 : sizeScale < 1.5 ? 1 : sizeScale < 2.5 ? 2 : 3; } else { // oak primaryTrunk = 'trunk'; secondaryTrunk = 'wood'; primaryLeaf = 'leaves'; secondaryLeaf = 'leaves_orange'; secondaryLeafChance = 0.07; trunkH = Math.round((8 + r1 * 4) * sizeScale); crownR = Math.round((3 + r2 * 2) * sizeScale); crownSquish = 1.0; trunkThickness = sizeScale < 0.8 ? 0 : sizeScale < 1.5 ? 1 : sizeScale < 2.5 ? 2 : 3; } if (trunkH < 3) trunkH = 3; if (crownR < 2) crownR = 2; const placeLeaf = (px, py, pz, noise) => { const isSecondary = noise < secondaryLeafChance; layer.setVoxel(px, py, pz, isSecondary ? secondaryLeaf : primaryLeaf); }; const placeTrunk = (px, py, pz, noise) => { const isSecondary = (type !== 'birch') && noise < 0.05; layer.setVoxel(px, py, pz, isSecondary ? secondaryTrunk : primaryTrunk); }; // === Толстый ствол === // Шаблоны толщины — какие voxel'и вокруг (tx, tz) считать стволом. // thickness=0: только (0,0) // thickness=1: крест-3×3 (без углов) // thickness=2: полный 3×3 // thickness=3: 5×5 крест без 4-х угловых const trunkOffsets = this._trunkOffsets(trunkThickness); // Высота сужения — на верхушке ствол снова тонкий перед кроной const taperStart = Math.floor(trunkH * 0.7); for (let dy = 1; dy <= trunkH; dy++) { // На вершине ствол сужается до thickness 0 (это даёт силуэт «бутылки») const thicknessAtY = dy < taperStart ? trunkThickness : Math.max(0, trunkThickness - Math.floor((dy - taperStart) * 0.5)); const offsets = this._trunkOffsets(thicknessAtY); for (const [ox, oz] of offsets) { const noise = hash2(tx + ox * 13, tz + oz * 17 + dy * 7, this.params.seed + 2100); placeTrunk(tx + ox, topY + dy, tz + oz, noise); } } // === Утолщение у основания (только для thickness >= 2) === if (trunkThickness >= 2) { const baseH = Math.min(3, Math.floor(trunkH / 5)); for (let dy = 1; dy <= baseH; dy++) { // Дополнительное кольцо корней for (const [ox, oz] of [[2, 0], [-2, 0], [0, 2], [0, -2], [1, 1], [-1, -1], [1, -1], [-1, 1]]) { if (hash2(tx + ox * 13, tz + oz * 17 + dy, this.params.seed + 2200) > 0.55) continue; placeTrunk(tx + ox, topY + dy, tz + oz, 0.5); } } } // === Ветви — зигзаг из 4-7 voxel'ов === const branchTips = []; // Количество ветвей зависит от размера дерева const numBranches = type === 'birch' ? (sizeScale < 1.5 ? 2 : 3) : Math.max(2, Math.min(8, Math.round((3 + r3 * 2) * Math.sqrt(sizeScale)))); const branchLenBase = Math.max(2, Math.floor(crownR * 0.8 + sizeScale)); for (let b = 0; b < numBranches; b++) { // Угол + jitter const baseAngle = (b / numBranches) * Math.PI * 2; const angleJitter = (hash2(tx + b * 31, tz + b * 53, this.params.seed + 2300) - 0.5) * 0.6; const angle = baseAngle + angleJitter + r1 * 0.5; // Высота старта ветви — 50-95% от ствола const startYFrac = 0.5 + (b / numBranches) * 0.45; const branchStartY = Math.max(2, Math.floor(trunkH * startYFrac)); // Длина ветви с jitter const branchLen = branchLenBase + Math.floor(hash2(tx + b, tz, this.params.seed + 2400) * 3); // Зигзаг — отклоняемся в плоскости с каждым шагом let bx = tx, by = topY + branchStartY, bz = tz; const dirX = Math.cos(angle); const dirZ = Math.sin(angle); for (let step = 1; step <= branchLen; step++) { // Идём в основном направлении + случайное отклонение bx = Math.round(tx + dirX * step); bz = Math.round(tz + dirZ * step); // Y — поднимается + случайные изломы const upBias = step * 0.4; const jitterY = (hash2(bx * 7, bz * 11, this.params.seed + 2500 + step) - 0.3) * 1.5; by = topY + branchStartY + Math.floor(upBias + jitterY); const noise = hash2(bx, bz + step * 3, this.params.seed + 2310); placeTrunk(bx, by, bz, noise); // Иногда ставим второй voxel рядом (утолщение ветви для толстых деревьев) if (trunkThickness >= 2 && step <= branchLen / 2) { // Боковой voxel перпендикулярно направлению const sx = Math.round(bx - dirZ * 0.7); const sz = Math.round(bz + dirX * 0.7); if (sx !== bx || sz !== bz) { placeTrunk(sx, by, sz, noise + 0.1); } } } branchTips.push({ x: bx, y: by, z: bz }); } // === Раздробленная крона === // Главная сфера — меньше чем была, потому что её дополняют сферы на ветвях. const crownCy = topY + trunkH; const mainCrownR = Math.max(2, Math.floor(crownR * 0.85)); this._placeCrown(layer, tx, crownCy, tz, mainCrownR, crownSquish, placeLeaf); // Сферы кроны на каждой ветке — это и есть «крона хаотично расходится» // как ты хотел. Размер 50-80% главной сферы. const subCrownRBase = Math.max(2, Math.floor(crownR * 0.6)); for (const tip of branchTips) { const sizeJitter = hash2(tip.x, tip.z, this.params.seed + 2600) * 0.4 - 0.2; const subR = Math.max(2, Math.round(subCrownRBase * (1.0 + sizeJitter))); this._placeCrown(layer, tip.x, tip.y + 1, tip.z, subR, crownSquish, placeLeaf); } } /** * Возвращает массив [dx, dz] смещений для ствола заданной толщины. * 0: один voxel * 1: крест 5 voxel'ов (центр + 4 стороны) * 2: полный 3×3 (9 voxel'ов) * 3: 5×5 без 4-х углов (~21 voxel) */ _trunkOffsets(thickness) { if (thickness <= 0) return [[0, 0]]; if (thickness === 1) return [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]; if (thickness === 2) { return [ [0, 0], [1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [-1, -1], [1, -1], [-1, 1], ]; } // thickness >= 3: 5×5 без 4-х углов const out = []; for (let dx = -2; dx <= 2; dx++) { for (let dz = -2; dz <= 2; dz++) { // Пропускаем 4 угла if (Math.abs(dx) === 2 && Math.abs(dz) === 2) continue; out.push([dx, dz]); } } return out; } /** * Сфера кроны радиусом r с пятнистой раскраской. * placeLeaf(x, y, z, noise) — callback который ставит voxel * листвы. noise ∈ [0..1) — детерминированный шум для выбора цвета. */ _placeCrown(layer, cx, cy, cz, r, squish, placeLeaf) { const r2sq = r * r; const innerR2 = r > 2 ? (r - 2) * (r - 2) : 0; const yRange = Math.floor(r * squish) + 2; for (let dx = -r - 1; dx <= r + 1; dx++) { for (let dy = -Math.floor(r / 2); dy <= yRange; dy++) { for (let dz = -r - 1; dz <= r + 1; dz++) { const dyEff = dy / squish; const d2 = dx * dx + dyEff * dyEff + dz * dz; if (d2 > r2sq + 2) continue; if (d2 < innerR2) continue; // полая крона (экономия voxel'ов) const edgeThresh = r2sq - 1.5; if (d2 > edgeThresh) { // Лохматый край — пропускаем часть voxel'ов if (hash2(cx + dx, cz + dz, this.params.seed + 3000 + dy) > 0.5) continue; } if (dx === 0 && dz === 0 && dy < 0) continue; // Шум для выбора цвета листа — даёт пятна const colorNoise = hash2(cx + dx, cz + dz + dy * 11, this.params.seed + 3500); placeLeaf(cx + dx, cy + dy, cz + dz, colorNoise); } } } } /** * Полная регенерация мира в указанный bbox — очищает слой и * генерирует заново. Используется при изменении параметров в UI. */ async regenerateLayer(layer, bbox, progressCb = null) { layer.clear(); return await this.generateIntoLayer(layer, bbox, progressCb); } }