studio/src/editor/engine/TerrainGenWorker.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

542 lines
22 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.

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