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)
739 lines
37 KiB
JavaScript
739 lines
37 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|
||
}
|