studio/src/editor/engine/voxel/WorldGenerator.js
МИН 31adbf151b Initial public release: Студия Рублокса v1.0
Open-source веб-студия для создания игр Рублокса, двойная лицензия
AGPL-3.0 + Коммерческая.

Главное:
- Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16
- Самодостаточный движок ~28к строк (66 файлов): BlockManager,
  TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController,
  ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов
- Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco)
- Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn)
- Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt)
- 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.)
- Конфигурируемый бэкенд через VITE_API_BASE — работает со staging
  (dev-api.rublox.pro) без настройки
- Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка
- Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING,
  SECURITY, CHANGELOG
- ESLint + Prettier + EditorConfig
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Перед публикацией:
- Все импорты из minecraftia заменены на локальные
- Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env
- Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо)
- AdminKubikonModeration не публикуется (модерация — в team.rublox.pro)
- 93 МБ ассетов public/kubikon-assets вынесены в .gitignore
  (раздаются через release artifact)
2026-05-27 23:41:10 +03:00

739 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
}
}