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)
542 lines
22 KiB
JavaScript
542 lines
22 KiB
JavaScript
/**
|
||
* TerrainGenWorker.js — Web Worker для процедурной генерации ландшафта.
|
||
*
|
||
* Архитектура (Этап C оптимизации):
|
||
* - main thread → отправляет params + bbox → worker
|
||
* - worker (фоновый поток) → сэмплит heightmap, surface extraction,
|
||
* деревья, decorations
|
||
* - worker → отправляет результат: Array<{x,y,z,m}> + decorations[]
|
||
* - main thread → передаёт в terrainManager.loadFromArray + decoManager
|
||
*
|
||
* Worker устраняет 5-15 секунд блокировки UI на больших картах.
|
||
* UI остаётся отзывчивым — progress-bar анимируется плавно.
|
||
*
|
||
* Worker код — self-contained, без импортов. Загружается через blob URL.
|
||
*
|
||
* НЕ импортируется напрямую. Используй getTerrainGenWorkerUrl().
|
||
*/
|
||
|
||
const SOURCE = `
|
||
"use strict";
|
||
|
||
// ============================================================================
|
||
// SimplexNoise (inline копия из engine/voxel/SimplexNoise.js)
|
||
// ============================================================================
|
||
function mulberry32(seed) {
|
||
let t = seed | 0;
|
||
return function () {
|
||
t = (t + 0x6D2B79F5) | 0;
|
||
let r = Math.imul(t ^ (t >>> 15), 1 | t);
|
||
r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
|
||
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
const GRAD2 = [
|
||
1, 1, -1, 1, 1, -1, -1, -1,
|
||
1, 0, -1, 0, 1, 0, -1, 0,
|
||
0, 1, 0, -1, 0, 1, 0, -1,
|
||
1, 1, 0, 0, -1, 1, 0, 0,
|
||
];
|
||
const F2 = 0.5 * (Math.sqrt(3) - 1);
|
||
const G2 = (3 - Math.sqrt(3)) / 6;
|
||
|
||
class SimplexNoise {
|
||
constructor(seed) {
|
||
this.seed = seed;
|
||
const rand = mulberry32(seed);
|
||
const perm = new Uint8Array(512);
|
||
const p = new Uint8Array(256);
|
||
for (let i = 0; i < 256; i++) p[i] = i;
|
||
for (let i = 255; i > 0; i--) {
|
||
const j = Math.floor(rand() * (i + 1));
|
||
const tmp = p[i]; p[i] = p[j]; p[j] = tmp;
|
||
}
|
||
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
|
||
this.perm = perm;
|
||
}
|
||
noise2(xin, yin) {
|
||
const perm = this.perm;
|
||
const s = (xin + yin) * F2;
|
||
const i = Math.floor(xin + s);
|
||
const j = Math.floor(yin + s);
|
||
const t = (i + j) * G2;
|
||
const X0 = i - t, Y0 = j - t;
|
||
const x0 = xin - X0, y0 = yin - Y0;
|
||
let i1, j1;
|
||
if (x0 > y0) { i1 = 1; j1 = 0; } else { i1 = 0; j1 = 1; }
|
||
const x1 = x0 - i1 + G2, y1 = y0 - j1 + G2;
|
||
const x2 = x0 - 1 + 2 * G2, y2 = y0 - 1 + 2 * G2;
|
||
const ii = i & 255, jj = j & 255;
|
||
const gi0 = perm[ii + perm[jj]] & 15;
|
||
const gi1 = perm[ii + i1 + perm[jj + j1]] & 15;
|
||
const gi2 = perm[ii + 1 + perm[jj + 1]] & 15;
|
||
let n0 = 0, n1 = 0, n2 = 0;
|
||
let t0 = 0.5 - x0 * x0 - y0 * y0;
|
||
if (t0 >= 0) { t0 *= t0; n0 = t0 * t0 * (GRAD2[gi0*2]*x0 + GRAD2[gi0*2+1]*y0); }
|
||
let t1m = 0.5 - x1 * x1 - y1 * y1;
|
||
if (t1m >= 0) { t1m *= t1m; n1 = t1m * t1m * (GRAD2[gi1*2]*x1 + GRAD2[gi1*2+1]*y1); }
|
||
let t2m = 0.5 - x2 * x2 - y2 * y2;
|
||
if (t2m >= 0) { t2m *= t2m; n2 = t2m * t2m * (GRAD2[gi2*2]*x2 + GRAD2[gi2*2+1]*y2); }
|
||
return 70 * (n0 + n1 + n2);
|
||
}
|
||
fbm2(x, y, octaves, persistence, lacunarity) {
|
||
let total = 0, amp = 1, freq = 1, norm = 0;
|
||
for (let i = 0; i < octaves; i++) {
|
||
total += this.noise2(x * freq, y * freq) * amp;
|
||
norm += amp;
|
||
amp *= persistence;
|
||
freq *= lacunarity;
|
||
}
|
||
return total / norm;
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Хэш для детерминированной расстановки структур
|
||
// ============================================================================
|
||
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;
|
||
}
|
||
|
||
// ============================================================================
|
||
// WorldGenerator (упрощённая копия — sample + bbox)
|
||
// ============================================================================
|
||
let G_PARAMS = null;
|
||
let G_HEIGHT_NOISE = null;
|
||
let G_BIOME_NOISE = null;
|
||
|
||
function setupGenerator(params) {
|
||
G_PARAMS = params;
|
||
G_HEIGHT_NOISE = new SimplexNoise(params.seed);
|
||
G_BIOME_NOISE = new SimplexNoise(params.seed + (params.biomeMap?.seedOffset ?? 100));
|
||
}
|
||
|
||
function rawHeight(x, z) {
|
||
const hp = G_PARAMS.heightmap;
|
||
let wx = x, wz = z;
|
||
if (G_PARAMS.domainWarp?.enabled) {
|
||
const ws = G_PARAMS.domainWarp.scale;
|
||
const wstr = G_PARAMS.domainWarp.strength;
|
||
wx = x + G_HEIGHT_NOISE.noise2(x * ws, z * ws) * wstr;
|
||
wz = z + G_HEIGHT_NOISE.noise2(x * ws + 100, z * ws + 100) * wstr;
|
||
}
|
||
return G_HEIGHT_NOISE.fbm2(wx * hp.scale, wz * hp.scale, hp.octaves, hp.persistence, hp.lacunarity);
|
||
}
|
||
|
||
function sampleBiome(x, z) {
|
||
const bm = G_PARAMS.biomeMap;
|
||
const n = (G_BIOME_NOISE.fbm2(x * bm.scale, z * bm.scale, 3, 0.5, 2.0) + 1) * 0.5;
|
||
const biomes = G_PARAMS.biomes;
|
||
for (let i = 0; i < biomes.length; i++) {
|
||
const b = biomes[i];
|
||
if (n >= b.threshold[0] && n < b.threshold[1]) return b;
|
||
}
|
||
return biomes[biomes.length - 1];
|
||
}
|
||
|
||
function smoothBiomeBonus(x, z) {
|
||
const BLEND_WIDTH = 0.08;
|
||
const bm = G_PARAMS.biomeMap;
|
||
const n = (G_BIOME_NOISE.fbm2(x * bm.scale, z * bm.scale, 3, 0.5, 2.0) + 1) * 0.5;
|
||
const biomes = G_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;
|
||
const tt = distFromStart / BLEND_WIDTH;
|
||
const ts = tt * tt * (3 - 2 * tt);
|
||
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 tt = distFromEnd / BLEND_WIDTH;
|
||
const ts = tt * tt * (3 - 2 * tt);
|
||
return nextBonus * (1 - ts) + curBonus * ts;
|
||
}
|
||
return curBonus;
|
||
}
|
||
|
||
function sampleHeight(x, z) {
|
||
const hp = G_PARAMS.heightmap;
|
||
let n = rawHeight(x, z);
|
||
n = (n + 1) * 0.5;
|
||
n = Math.pow(Math.max(0, Math.min(1, n)), hp.exponent);
|
||
const bonus = smoothBiomeBonus(x, z);
|
||
return Math.round(hp.baseHeight + n * hp.amplitude * bonus);
|
||
}
|
||
|
||
function sampleTreeAt(x, z) {
|
||
const s = G_PARAMS.structures?.trees;
|
||
if (!s || !s.enabled) return null;
|
||
const biome = sampleBiome(x, z);
|
||
const treeProb = (biome.features?.trees ?? 0) * s.density;
|
||
if (treeProb <= 0) return null;
|
||
const grid = Math.max(2, s.minDistance);
|
||
const gx = Math.floor(x / grid);
|
||
const gz = Math.floor(z / grid);
|
||
const r = hash2(gx, gz, G_PARAMS.seed + 999);
|
||
if (r >= treeProb) return null;
|
||
const ox = Math.floor(hash2(gx, gz, G_PARAMS.seed + 1001) * grid);
|
||
const oz = Math.floor(hash2(gx, gz, G_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, G_PARAMS.seed + 1003) * treeTypes.length);
|
||
return { type: treeTypes[tt % treeTypes.length] };
|
||
}
|
||
|
||
// ============================================================================
|
||
// Полная генерация: возвращает voxels[] + decorations[]
|
||
// ============================================================================
|
||
function generateWorld(bbox, progressCb) {
|
||
const { x0, z0, x1, z1 } = bbox;
|
||
const width = x1 - x0;
|
||
const depth = z1 - z0;
|
||
const total = width * depth;
|
||
let done = 0;
|
||
const totalW = total * 2 + total * 0.3;
|
||
|
||
// === Шаг 1: heightmap ===
|
||
const heightmap = new Map();
|
||
for (let z = z0; z < z1; z++) {
|
||
for (let x = x0; x < x1; x++) {
|
||
const h = sampleHeight(x, z);
|
||
const biome = sampleBiome(x, z);
|
||
heightmap.set(x + ',' + z, { h, biome });
|
||
}
|
||
done += width;
|
||
if ((z - z0) % 16 === 0 && progressCb) progressCb(done, totalW, 'heightmap');
|
||
}
|
||
|
||
// === Шаг 2: surface extraction ===
|
||
const voxelMap = new Map(); // "x,y,z" → matId
|
||
const setVoxel = (x, y, z, m) => voxelMap.set(x + ',' + y + ',' + z, m);
|
||
const getVoxel = (x, y, z) => voxelMap.get(x + ',' + y + ',' + z);
|
||
|
||
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;
|
||
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) {
|
||
setVoxel(x, 0, z, biome.softMaterial);
|
||
done++;
|
||
continue;
|
||
}
|
||
setVoxel(x, h - 1, z, biome.topMaterial);
|
||
if (h >= 2 && (h - 2) >= minNbH) setVoxel(x, h - 2, z, biome.softMaterial);
|
||
if (h >= 3 && (h - 3) >= minNbH) setVoxel(x, h - 3, z, biome.softMaterial);
|
||
const bottomVisible = Math.max(0, minNbH);
|
||
const topVisible = h - 3;
|
||
for (let y = bottomVisible; y < topVisible; y++) {
|
||
setVoxel(x, y, z, biome.hardMaterial);
|
||
}
|
||
if (minNbH <= 0 && !getVoxel(x, 0, z)) {
|
||
setVoxel(x, 0, z, biome.hardMaterial);
|
||
}
|
||
done++;
|
||
}
|
||
if ((z - z0) % 16 === 0 && progressCb) progressCb(done, totalW, 'surface');
|
||
}
|
||
|
||
// === Шаг 3: деревья ===
|
||
let treesPlaced = 0;
|
||
if (G_PARAMS.structures?.trees?.enabled) {
|
||
for (let z = z0; z < z1; z++) {
|
||
for (let x = x0; x < x1; x++) {
|
||
const tree = sampleTreeAt(x, z);
|
||
if (!tree) continue;
|
||
const info = heightmap.get(x + ',' + z);
|
||
if (!info || info.h < 1) continue;
|
||
if (info.biome.topMaterial !== 'grass') continue;
|
||
placeTree(x, info.h - 1, z, tree.type, setVoxel);
|
||
treesPlaced++;
|
||
}
|
||
done += width * 0.3;
|
||
if ((z - z0) % 32 === 0 && progressCb) progressCb(done, totalW, 'trees');
|
||
}
|
||
}
|
||
|
||
// === Шаг 4: decorations ===
|
||
const decorations = [];
|
||
const decoParams = G_PARAMS.structures?.decorations ?? {};
|
||
const flowersDensity = decoParams.flowersDensity ?? 0.015;
|
||
const grassDensity = decoParams.grassDensity ?? 0.05;
|
||
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'],
|
||
};
|
||
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 = 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, G_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, G_PARAMS.seed + 5002) * pool.length);
|
||
const modelId = pool[modelIdx];
|
||
const rotation = Math.floor(hash2(x, z, G_PARAMS.seed + 5003) * 4);
|
||
decorations.push({ x: wx, y: wy, z: wz, modelId, rotation });
|
||
continue;
|
||
}
|
||
}
|
||
const rGrass = hash2(x, z, G_PARAMS.seed + 5004);
|
||
if (rGrass < grassDensity) {
|
||
const modelIdx = Math.floor(hash2(x, z, G_PARAMS.seed + 5005) * GRASS_POOL.length);
|
||
const modelId = GRASS_POOL[modelIdx];
|
||
const rotation = Math.floor(hash2(x, z, G_PARAMS.seed + 5006) * 4);
|
||
const scale = 0.7 + hash2(x, z, G_PARAMS.seed + 5007) * 0.7;
|
||
decorations.push({ x: wx, y: wy, z: wz, modelId, rotation, scale });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Конвертируем Map в массив для возврата
|
||
const voxels = [];
|
||
for (const [key, m] of voxelMap) {
|
||
const lastComma = key.lastIndexOf(',');
|
||
const midComma = key.lastIndexOf(',', lastComma - 1);
|
||
voxels.push({
|
||
x: parseInt(key.slice(0, midComma), 10),
|
||
y: parseInt(key.slice(midComma + 1, lastComma), 10),
|
||
z: parseInt(key.slice(lastComma + 1), 10),
|
||
m,
|
||
});
|
||
}
|
||
return { voxels, decorations, treesPlaced };
|
||
}
|
||
|
||
// ============================================================================
|
||
// _placeTree — копия из engine/voxel/WorldGenerator.js (упрощённая)
|
||
// ============================================================================
|
||
function 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],
|
||
];
|
||
}
|
||
const out = [];
|
||
for (let dx = -2; dx <= 2; dx++) {
|
||
for (let dz = -2; dz <= 2; dz++) {
|
||
if (Math.abs(dx) === 2 && Math.abs(dz) === 2) continue;
|
||
out.push([dx, dz]);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function placeCrown(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;
|
||
const edgeThresh = r2sq - 1.5;
|
||
if (d2 > edgeThresh) {
|
||
if (hash2(cx + dx, cz + dz, G_PARAMS.seed + 3000 + dy) > 0.5) continue;
|
||
}
|
||
if (dx === 0 && dz === 0 && dy < 0) continue;
|
||
const colorNoise = hash2(cx + dx, cz + dz + dy * 11, G_PARAMS.seed + 3500);
|
||
placeLeaf(cx + dx, cy + dy, cz + dz, colorNoise);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function placeTree(tx, topY, tz, type, setVoxel) {
|
||
const r1 = hash2(tx, tz, G_PARAMS.seed + 2001);
|
||
const r2 = hash2(tx, tz, G_PARAMS.seed + 2002);
|
||
const r3 = hash2(tx, tz, G_PARAMS.seed + 2003);
|
||
const sizeScale = Math.max(0.3, Math.min(5.0, G_PARAMS.structures?.trees?.sizeScale ?? 1.0));
|
||
let primaryTrunk, secondaryTrunk, primaryLeaf, secondaryLeaf;
|
||
let secondaryLeafChance, trunkH, crownR, crownSquish, 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 {
|
||
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;
|
||
setVoxel(px, py, pz, isSecondary ? secondaryLeaf : primaryLeaf);
|
||
};
|
||
const placeTrunk = (px, py, pz, noise) => {
|
||
const isSecondary = (type !== 'birch') && noise < 0.05;
|
||
setVoxel(px, py, pz, isSecondary ? secondaryTrunk : primaryTrunk);
|
||
};
|
||
|
||
const taperStart = Math.floor(trunkH * 0.7);
|
||
for (let dy = 1; dy <= trunkH; dy++) {
|
||
const thicknessAtY = dy < taperStart
|
||
? trunkThickness
|
||
: Math.max(0, trunkThickness - Math.floor((dy - taperStart) * 0.5));
|
||
const offsets = trunkOffsets(thicknessAtY);
|
||
for (const [ox, oz] of offsets) {
|
||
const noise = hash2(tx + ox * 13, tz + oz * 17 + dy * 7, G_PARAMS.seed + 2100);
|
||
placeTrunk(tx + ox, topY + dy, tz + oz, noise);
|
||
}
|
||
}
|
||
|
||
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, G_PARAMS.seed + 2200) > 0.55) continue;
|
||
placeTrunk(tx + ox, topY + dy, tz + oz, 0.5);
|
||
}
|
||
}
|
||
}
|
||
|
||
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++) {
|
||
const baseAngle = (b / numBranches) * Math.PI * 2;
|
||
const angleJitter = (hash2(tx + b * 31, tz + b * 53, G_PARAMS.seed + 2300) - 0.5) * 0.6;
|
||
const angle = baseAngle + angleJitter + r1 * 0.5;
|
||
const startYFrac = 0.5 + (b / numBranches) * 0.45;
|
||
const branchStartY = Math.max(2, Math.floor(trunkH * startYFrac));
|
||
const branchLen = branchLenBase + Math.floor(hash2(tx + b, tz, G_PARAMS.seed + 2400) * 3);
|
||
let bx = tx, by = topY + branchStartY, bz = tz;
|
||
const dirX = Math.cos(angle), dirZ = Math.sin(angle);
|
||
for (let step = 1; step <= branchLen; step++) {
|
||
bx = Math.round(tx + dirX * step);
|
||
bz = Math.round(tz + dirZ * step);
|
||
const upBias = step * 0.4;
|
||
const jitterY = (hash2(bx * 7, bz * 11, G_PARAMS.seed + 2500 + step) - 0.3) * 1.5;
|
||
by = topY + branchStartY + Math.floor(upBias + jitterY);
|
||
const noise = hash2(bx, bz + step * 3, G_PARAMS.seed + 2310);
|
||
placeTrunk(bx, by, bz, noise);
|
||
if (trunkThickness >= 2 && step <= branchLen / 2) {
|
||
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));
|
||
placeCrown(tx, crownCy, tz, mainCrownR, crownSquish, placeLeaf);
|
||
|
||
const subCrownRBase = Math.max(2, Math.floor(crownR * 0.6));
|
||
for (const tip of branchTips) {
|
||
const sizeJitter = hash2(tip.x, tip.z, G_PARAMS.seed + 2600) * 0.4 - 0.2;
|
||
const subR = Math.max(2, Math.round(subCrownRBase * (1.0 + sizeJitter)));
|
||
placeCrown(tip.x, tip.y + 1, tip.z, subR, crownSquish, placeLeaf);
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Worker message handler
|
||
// ============================================================================
|
||
self.onmessage = function(e) {
|
||
const msg = e.data;
|
||
if (msg.type === 'generate') {
|
||
try {
|
||
setupGenerator(msg.params);
|
||
const t0 = performance.now();
|
||
const result = generateWorld(msg.bbox, (done, total, phase) => {
|
||
self.postMessage({ type: 'progress', done, total, phase });
|
||
});
|
||
const dt = performance.now() - t0;
|
||
// Передаём voxels — может быть большой массив, лучше JSON через
|
||
// postMessage (не Transferable т.к. это plain objects).
|
||
self.postMessage({
|
||
type: 'done',
|
||
voxels: result.voxels,
|
||
decorations: result.decorations,
|
||
treesPlaced: result.treesPlaced,
|
||
timeMs: Math.round(dt),
|
||
});
|
||
} catch (err) {
|
||
self.postMessage({ type: 'error', message: err.message, stack: err.stack });
|
||
}
|
||
}
|
||
};
|
||
`;
|
||
|
||
/**
|
||
* Создаёт Blob URL Worker'а. Использовать в new Worker(url).
|
||
*/
|
||
export function getTerrainGenWorkerUrl() {
|
||
const blob = new Blob([SOURCE], { type: 'application/javascript' });
|
||
return URL.createObjectURL(blob);
|
||
}
|