/** * TerrainGenPanel — процедурный генератор ландшафта с live-превью. * * Левая боковая панель в KubikonEditor (открывается через кнопку * «Генератор» в TerrainPanel или отдельно). Позволяет: * * - Настроить параметры noise: scale/octaves/amplitude/exponent * - Видеть live-превью карты на canvas (2D top-down) — обновляется * при изменении любого параметра с debounce 150мс * - Применить готовый пресет (Горы/Лес/Острова/Пустыня) * - Сгенерировать мир: создаёт voxel-террейн через WorldGenerator, * заменяет существующий * * Без сетевого I/O — всё происходит локально в браузере, BabylonScene * получает результат через переданный prop onApply(params). * * Архитектура live-превью: * - Превью использует ТУ ЖЕ WorldGenerator что и реальная генерация * - sampleHeight() + sampleBiome() для каждого пикселя 256×256 canvas * - Цвет = биом × яркость от высоты * - 256×256 = 65 536 noise-вызовов = ~5мс при 0.1мкс/вызов * * Параметры хранятся в локальном state. При нажатии «Сгенерировать» * вызывается onApply(params) — BabylonScene применяет. */ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { WorldGenerator, DEFAULT_GENERATOR_PARAMS } from './engine/voxel/WorldGenerator'; import Icon from './Icon'; import cl from './TerrainGenPanel.module.css'; // ============================================================================ // Inline-стили для карточек выбора режима генерации. // Кнопка-карточка с иконкой слева и текстом справа. Hover-эффект через onMouseEnter // не делаем — используем CSS box-shadow через inline для простоты. // ============================================================================ const modeCardStyle = { display: 'flex', alignItems: 'flex-start', gap: 14, padding: '14px 14px', // Тёмный фон под тёмную тему редактора background: '#2e2e2e', border: '1px solid #3a3a3a', borderRadius: 10, cursor: 'pointer', color: '#e8e8ea', textAlign: 'left', fontFamily: 'inherit', transition: 'all 0.15s ease-out', boxShadow: '0 1px 3px rgba(0, 0, 0, 0.3)', }; const modeIconStyle = { fontSize: 36, lineHeight: 1, filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.10))', flexShrink: 0, }; const modeTitleStyle = { fontSize: 14, fontWeight: 800, letterSpacing: 0.2, color: '#e8e8ea', marginBottom: 6, }; const modeDescStyle = { fontSize: 12, lineHeight: 1.5, color: '#9a9a9e', marginBottom: 10, }; const modeBadgesStyle = { display: 'flex', flexWrap: 'wrap', gap: 4, }; const badgeStyle = (color) => ({ fontSize: 10, fontWeight: 700, padding: '3px 7px', borderRadius: 4, background: `${color}1a`, color: color, border: `1px solid ${color}55`, }); // ============================================================================ // Готовые пресеты — каждый со своим РАЗНЫМ распределением биомов. // Это даёт визуально различимые карты: пустыня — сплошной песок, // горы — серый камень со снегом, и т.п. // ============================================================================ // Стандартные биомы (для большинства пресетов) const BIOME_PLAIN = { id: 'plain', topMaterial: 'grass', softMaterial: 'dirt', hardMaterial: 'rock', features: { trees: 0.3, treeTypes: ['oak'], grass: 0.2 }, }; const BIOME_FOREST = { id: 'forest', topMaterial: 'grass', softMaterial: 'dirt', hardMaterial: 'rock', features: { trees: 0.8, treeTypes: ['oak', 'autumn'], grass: 0.4 }, }; const BIOME_DESERT = { id: 'desert', topMaterial: 'sand', softMaterial: 'sand', hardMaterial: 'sand', features: { trees: 0, treeTypes: [], grass: 0 }, }; const BIOME_MOUNTAIN = { id: 'mountain', topMaterial: 'rock', softMaterial: 'rock', hardMaterial: 'rock', heightBonus: 1.3, // меньше резкости на границе с травой features: { trees: 0.05, treeTypes: ['autumn'] }, }; const BIOME_SNOW = { id: 'snow', topMaterial: 'snow', softMaterial: 'rock', hardMaterial: 'rock', heightBonus: 1.5, features: { trees: 0.02, treeTypes: ['birch'] }, }; const PRESETS = { default: { label: 'Земля', params: { ...DEFAULT_GENERATOR_PARAMS, biomes: [ { ...BIOME_DESERT, threshold: [0.0, 0.15] }, { ...BIOME_PLAIN, threshold: [0.15, 0.5] }, { ...BIOME_FOREST, threshold: [0.5, 0.75] }, { ...BIOME_MOUNTAIN, threshold: [0.75, 0.9] }, { ...BIOME_SNOW, threshold: [0.9, 1.0] }, ], }, }, meadow: { label: 'Луг', params: { ...DEFAULT_GENERATOR_PARAMS, heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 4, exponent: 1.2 }, biomes: [ { ...BIOME_PLAIN, threshold: [0, 0.5], features: { ...BIOME_PLAIN.features, trees: 0.2 } }, { ...BIOME_FOREST, threshold: [0.5, 1.0], features: { ...BIOME_FOREST.features, trees: 0.5 } }, ], }, }, mountains: { label: 'Горы', params: { ...DEFAULT_GENERATOR_PARAMS, heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 30, exponent: 2.0, scale: 0.012 }, biomes: [ { ...BIOME_PLAIN, threshold: [0.0, 0.25] }, { ...BIOME_FOREST, threshold: [0.25, 0.45] }, { ...BIOME_MOUNTAIN, threshold: [0.45, 0.75], heightBonus: 1.5 }, { ...BIOME_SNOW, threshold: [0.75, 1.0], heightBonus: 1.8 }, ], }, }, flat: { label: 'Равнина', params: { ...DEFAULT_GENERATOR_PARAMS, heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 3, exponent: 1.0 }, biomes: [ { ...BIOME_PLAIN, threshold: [0, 0.4], features: { ...BIOME_PLAIN.features, trees: 0.1 } }, { ...BIOME_FOREST, threshold: [0.4, 0.85], features: { ...BIOME_FOREST.features, trees: 0.4 } }, { ...BIOME_DESERT, threshold: [0.85, 1.0] }, ], }, }, islands: { label: 'Острова', params: { ...DEFAULT_GENERATOR_PARAMS, heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 18, exponent: 2.8, baseHeight: -2 }, biomes: [ { ...BIOME_DESERT, threshold: [0.0, 0.4] }, // песчаные пляжи большой части { ...BIOME_PLAIN, threshold: [0.4, 0.75] }, { ...BIOME_FOREST, threshold: [0.75, 1.0], features: { ...BIOME_FOREST.features, trees: 0.6 } }, ], }, }, forest: { label: 'Лес', params: { ...DEFAULT_GENERATOR_PARAMS, structures: { ...DEFAULT_GENERATOR_PARAMS.structures, trees: { enabled: true, density: 0.7, minDistance: 6 } }, biomes: [ { ...BIOME_FOREST, threshold: [0.0, 0.7], features: { trees: 1.0, treeTypes: ['oak', 'autumn'], grass: 0.5 } }, { ...BIOME_FOREST, threshold: [0.7, 1.0], id: 'forest_dense', features: { trees: 1.4, treeTypes: ['oak', 'autumn'], grass: 0.6 } }, ], }, }, desert: { label: 'Пустыня', params: { ...DEFAULT_GENERATOR_PARAMS, biomeMap: { ...DEFAULT_GENERATOR_PARAMS.biomeMap, scale: 0.005 }, heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 6, exponent: 1.2 }, biomes: [ { ...BIOME_DESERT, threshold: [0.0, 0.8] }, { ...BIOME_DESERT, threshold: [0.8, 1.0], id: 'oasis', topMaterial: 'grass', softMaterial: 'dirt', hardMaterial: 'sand', features: { trees: 0.3, treeTypes: ['oak'], grass: 0.2 } }, ], }, }, }; // ============================================================================ // Цвета биомов для preview canvas (top-down view) // ============================================================================ const BIOME_COLOR = { desert: [220, 200, 120], plain: [110, 175, 90], forest: [70, 140, 60], mountain: [140, 140, 140], snow: [240, 245, 250], // Fallback по topMaterial если биом не известен grass: [110, 175, 90], sand: [220, 200, 120], rock: [140, 140, 140], }; // Цвет биома: сначала по id, потом по topMaterial. function biomeColor(biome) { return BIOME_COLOR[biome.id] ?? BIOME_COLOR[biome.topMaterial] ?? [128, 128, 128]; } // ============================================================================ // Компоненты UI // ============================================================================ function Slider({ label, value, min, max, step, onChange, format }) { const display = format ? format(value) : value.toFixed(2); return (
{label} {display}
onChange(parseFloat(e.target.value))} className={cl.slider} />
); } function Section({ title, children, defaultOpen = true }) { const [open, setOpen] = useState(defaultOpen); return (
{open &&
{children}
}
); } // ============================================================================ // Главный компонент // ============================================================================ export default function TerrainGenPanel({ onApply, onApplyRoblox, onClearRoblox, onClose, hasRobloxTerrain }) { // === Режим генерации === // null — экран выбора между Voxel и Smooth // 'voxel' — параметры воксельного генератора // 'smooth' — параметры гладкого ландшафта (Roblox-style) const [genMode, setGenMode] = useState(null); // Авто-выбор 'smooth' при первом обнаружении ландшафта в сцене. // Если genMode уже выбран пользователем — не переопределяем. useEffect(() => { if (hasRobloxTerrain && genMode === null) { setGenMode('smooth'); } }, [hasRobloxTerrain]); // === Roblox-style smooth terrain (новая параллельная подсистема) === // Slider от 30 до 150 cells (= 120-600м карта при 4м/cell). const [robloxSize, setRobloxSize] = useState(50); const [isApplyingRoblox, setIsApplyingRoblox] = useState(false); // State параметров — деструктурируем DEFAULT_GENERATOR_PARAMS чтобы // компонент управлял всеми полями. const [seed, setSeed] = useState(DEFAULT_GENERATOR_PARAMS.seed); const [worldSize, setWorldSize] = useState(160); // в voxel-units (160 = 80м) const [heightmap, setHeightmap] = useState(DEFAULT_GENERATOR_PARAMS.heightmap); const [biomes, setBiomes] = useState(PRESETS.default.params.biomes); const [treesEnabled, setTreesEnabled] = useState(DEFAULT_GENERATOR_PARAMS.structures.trees.enabled); const [treesDensity, setTreesDensity] = useState(DEFAULT_GENERATOR_PARAMS.structures.trees.density); // Размер деревьев — 1.0 базовые, 2.0 большие, 0.5 кусты const [treesSizeScale, setTreesSizeScale] = useState(DEFAULT_GENERATOR_PARAMS.structures.trees.sizeScale ?? 1.0); // Декорации: цветы и трава — раздельно const [flowersDensity, setFlowersDensity] = useState(DEFAULT_GENERATOR_PARAMS.structures.decorations?.flowersDensity ?? 0.015); const [grassDensity, setGrassDensity] = useState(DEFAULT_GENERATOR_PARAMS.structures.decorations?.grassDensity ?? 0.08); // Smooth-режим: плотность деревьев (отдельно от voxel-treesDensity). // 0..1: 0=нет деревьев, 1=густой лес (1 дерево на ~64м² базовый шаг). const [smoothTreesDensity, setSmoothTreesDensity] = useState(0.4); const [previewStats, setPreviewStats] = useState(null); /** Прогресс генерации: { pct: 0..100, phase: 'heightmap'|'surface'|'trees'|'render'|'done' } */ const [progress, setProgress] = useState(null); const canvasRef = useRef(null); const debounceRef = useRef(null); // Глобальный callback который BabylonScene.__voxelGenerate дёргает с прогрессом. // Регистрируем на window чтобы движок видел. useEffect(() => { const handler = (pct, phase) => setProgress({ pct, phase }); window.__voxelGenProgress = handler; return () => { if (window.__voxelGenProgress === handler) { delete window.__voxelGenProgress; } }; }, []); // Собираем params из state. useCallback чтобы не пересоздавать на каждый render. const buildParams = useCallback(() => ({ ...DEFAULT_GENERATOR_PARAMS, seed, heightmap, biomes, structures: { ...DEFAULT_GENERATOR_PARAMS.structures, trees: { ...DEFAULT_GENERATOR_PARAMS.structures.trees, enabled: treesEnabled, density: treesDensity, sizeScale: treesSizeScale, }, decorations: { ...DEFAULT_GENERATOR_PARAMS.structures.decorations, enabled: true, flowersDensity, grassDensity, }, }, }), [seed, heightmap, biomes, treesEnabled, treesDensity, treesSizeScale, flowersDensity, grassDensity]); // Рендер preview canvas — top-down карта. // Каждый пиксель = (x_world, z_world) куда мы сэмплим biome и height. const renderPreview = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); const w = canvas.width; const h = canvas.height; const t0 = performance.now(); const params = buildParams(); const gen = new WorldGenerator(params); // Sample 1 пиксель на каждый x,z. // Voxel: 1 voxel = 0.25м, halfSize = worldSize voxel-units. // Smooth: 1 cell = 4м = 16 voxel-units, halfSize_voxels = robloxSize * 8. const halfSize = genMode === 'smooth' ? robloxSize * 16 / 2 // size cells × 16 voxel-units / 2 : worldSize; const step = (halfSize * 2) / w; const img = ctx.createImageData(w, h); const data = img.data; let minH = Infinity, maxH = -Infinity; const heights = new Float32Array(w * h); // Первый проход — high-min для нормализации. for (let py = 0; py < h; py++) { const wz = -halfSize + py * step; for (let px = 0; px < w; px++) { const wx = -halfSize + px * step; const h_ = gen.sampleHeight(wx, wz); heights[py * w + px] = h_; if (h_ < minH) minH = h_; if (h_ > maxH) maxH = h_; } } const hRange = Math.max(1, maxH - minH); // Второй проход — биом + тень от высоты for (let py = 0; py < h; py++) { const wz = -halfSize + py * step; for (let px = 0; px < w; px++) { const wx = -halfSize + px * step; const biome = gen.sampleBiome(wx, wz); const h_ = heights[py * w + px]; let r, g, b; if (h_ <= 0) { // Вода r = 60; g = 130; b = 200; } else { const base = biomeColor(biome); // Тень от высоты: чем выше — тем светлее (имитация горы) const tHi = (h_ - minH) / hRange; const lighten = 0.7 + tHi * 0.6; r = Math.min(255, Math.round(base[0] * lighten)); g = Math.min(255, Math.round(base[1] * lighten)); b = Math.min(255, Math.round(base[2] * lighten)); } const idx = (py * w + px) * 4; data[idx] = r; data[idx + 1] = g; data[idx + 2] = b; data[idx + 3] = 255; } } ctx.putImageData(img, 0, 0); // Иконки деревьев на preview — только в voxel-режиме. // В smooth ландшафте деревьев пока нет. let treeCount = 0; if (genMode === 'voxel') { ctx.fillStyle = 'rgba(20, 50, 20, 0.85)'; for (let py = 0; py < h; py += 2) { const wz = -halfSize + py * step; for (let px = 0; px < w; px += 2) { const wx = -halfSize + px * step; const tree = gen.sampleTreeAt(Math.floor(wx), Math.floor(wz)); if (tree) { ctx.fillRect(px - 1, py - 1, 2, 2); treeCount++; } } } } const dt = performance.now() - t0; setPreviewStats({ time: Math.round(dt), minH: Math.round(minH), maxH: Math.round(maxH), estimatedTrees: treeCount * 4, // компенсация sample step=2 }); }, [buildParams, worldSize, genMode, robloxSize]); // Debounced re-render preview когда меняются параметры useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(renderPreview, 150); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [renderPreview]); // Применить пресет const applyPreset = (key) => { const p = PRESETS[key]; if (!p) return; setSeed(p.params.seed); setHeightmap(p.params.heightmap); // Биомы — главное визуальное отличие пресетов! // Без передачи biomes все пресеты будут с одинаковым набором. setBiomes(p.params.biomes ?? DEFAULT_GENERATOR_PARAMS.biomes); setTreesEnabled(p.params.structures?.trees?.enabled ?? true); setTreesDensity(p.params.structures?.trees?.density ?? 0.3); }; // Хард-блокировка от двойных кликов: ref устанавливается СРАЗУ // (в отличие от setState который batched/async). const generatingRef = useRef(false); // Сгенерировать мир — отдаём params наверх. onApply async. const handleApply = async () => { if (!onApply) return; if (generatingRef.current) { console.warn('[TerrainGen] already generating, ignoring click'); return; } generatingRef.current = true; setProgress({ pct: 0, phase: 'starting' }); try { await onApply({ params: buildParams(), size: worldSize, }); } catch (e) { console.error('voxelGenerate failed:', e); } finally { generatingRef.current = false; } // Авто-скрытие через 600мс чтобы пользователь увидел "100%" setTimeout(() => setProgress(null), 600); }; const isGenerating = progress !== null && progress.pct < 100; // === Roblox smooth terrain handlers === const robloxRef = useRef(false); const handleApplyRoblox = async () => { if (!onApplyRoblox) return; if (robloxRef.current) { console.warn('[RobloxTerrain] already applying'); return; } robloxRef.current = true; setIsApplyingRoblox(true); try { // Передаём те же params что и для voxel-режима. Сервер // __robloxTest использует их для WorldGenerator (sampleHeight + sampleBiome). // Деревья пока отключены, voxel-декорации тоже (smooth-декорации // обрабатываются отдельно через smoothDeco поле). const p = buildParams(); p.structures = { ...p.structures, trees: { ...p.structures.trees, enabled: false }, decorations: { ...p.structures.decorations, enabled: false }, }; // smoothDeco — плотность для thin-instance декораций гладкого ландшафта. // 0 = выключено, 1 = максимум. Считается hash-функцией в SmoothDecoManager. p.smoothDeco = { flowersDensity, grassDensity, treesDensity: smoothTreesDensity, }; await onApplyRoblox({ size: robloxSize, params: p }); } catch (e) { console.error('robloxApply failed:', e); } finally { robloxRef.current = false; setIsApplyingRoblox(false); } }; const handleClearRoblox = () => { console.log('[TerrainGenPanel] handleClearRoblox CLICK; onClearRoblox=', typeof onClearRoblox); if (onClearRoblox) onClearRoblox(); }; const randomSeed = () => setSeed(Math.floor(Math.random() * 1000000)); // === Экран выбора режима === // Показывается когда genMode === null. Две карточки, объясняющие что есть что. if (genMode === null) { return (
Процедурный ландшафт
Выберите тип создаваемого мира {hasRobloxTerrain ? ' · Сейчас в сцене: гладкий ландшафт' : ''}
{/* Карточка «Воксели» ВРЕМЕННО УБРАНА — генерация воксельного ландшафта сломана (битый рендер деревьев + невидимые правки кистью после генерации). Будет починена позже. Воксельный ландшафт пока доступен только через ручной инструмент «Ландшафт» — там всё работает. */} {/* Карточка: Гладкий ландшафт */} {/* Пояснение про воксельный режим */}
Процедурная генерация воксельного ландшафта временно недоступна — чиним. Воксельный мир пока можно создать вручную через инструмент «Ландшафт».
{/* Если в сцене уже есть гладкий ландшафт — кнопка очистки */} {hasRobloxTerrain && ( )}
{/* Кнопка "Назад" */} {onClose && (
)}
); } // === Экран параметров (после выбора режима) === return (
{/* Sticky top — шапка + превью. Не скроллится при крутке секций. */}
{genMode === 'smooth' ? 'Гладкий ландшафт' : 'Воксельный ландшафт'}
{genMode === 'smooth' ? 'Плавные холмы, Surface Nets, до 600м' : 'Подбери параметры и нажми «Сгенерировать»'}
{/* Превью для обоих режимов — топ-вью карта биомов и высоты. Smooth-режим использует те же params, превью одинаково. */}
{previewStats && (
Превью: {previewStats.time} мс · Y {previewStats.minH}…{previewStats.maxH} {genMode === 'voxel' ? ` · ~${previewStats.estimatedTrees} деревьев` : ''}
)}
{/* Скроллящаяся зона со всеми секциями параметров */}
{genMode === 'voxel' && (<>
{Object.entries(PRESETS).map(([key, p]) => ( ))}
Seed setSeed(parseInt(e.target.value, 10) || 0)} className={cl.seedInput} />
`${(v * 0.25 * 2).toFixed(0)} м`} />
Лимит 100м для воксельных карт. Для больших карт выберите «Гладкий ландшафт» в начале панели.
setHeightmap({ ...heightmap, scale: v })} format={(v) => v.toFixed(3)} /> setHeightmap({ ...heightmap, amplitude: v })} format={(v) => `${v} вокс`} /> setHeightmap({ ...heightmap, octaves: v })} format={(v) => v.toString()} /> setHeightmap({ ...heightmap, exponent: v })} /> setHeightmap({ ...heightmap, baseHeight: v })} format={(v) => v.toString()} />
{treesEnabled && ( <> `×${v.toFixed(1)}`} /> {treesSizeScale > 2.0 && (
Очень большие деревья дают много вокселей.
)} )}
`${(v * 100).toFixed(1)}%`} /> `${(v * 100).toFixed(0)}%`} />
)} {genMode === 'smooth' && (<>
{Object.entries(PRESETS).map(([key, p]) => ( ))}
Seed setSeed(parseInt(e.target.value, 10) || 0)} className={cl.seedInput} />
`${v * 4} м`} />
1 ячейка = 4м. Гладкий ландшафт поддерживает большие карты до 600м.
setHeightmap({ ...heightmap, scale: v })} format={(v) => v.toFixed(4)} /> setHeightmap({ ...heightmap, amplitude: v })} format={(v) => `${(v * 0.25).toFixed(1)} м`} /> setHeightmap({ ...heightmap, octaves: v })} format={(v) => v.toString()} /> setHeightmap({ ...heightmap, exponent: v })} /> setHeightmap({ ...heightmap, baseHeight: v })} format={(v) => v.toString()} />
Деревья}>
Лиственные деревья в лесах/равнинах, хвойные в горах, пальмы на пляжах, кактусы в пустыне.
v === 0 ? 'выкл' : `${(v * 100).toFixed(0)}%`} />
Декорации}>
3D-модели цветов, травы и грибов на поверхности. Размещаются автоматически в подходящих биомах.
v === 0 ? 'выкл' : `${(v * 100).toFixed(1)}%`} /> v === 0 ? 'выкл' : `${(v * 100).toFixed(0)}%`} />
Применить}>
)}
{/* /scrollArea */} {/* Нижняя панель действий: для voxel — большая кнопка «Сгенерировать», для smooth — отдельной кнопки нет (она уже внутри секции). Закрытие/Назад работает всегда. */}
{genMode === 'voxel' && ( )} {onClose && ( )}
{/* Full-screen прогресс-оверлей пока идёт генерация. Перекрывает весь viewport чтобы пользователь не пытался кликать. */} {progress !== null && (
Генерация ландшафта…
{progress.phase === 'heightmap' && 'Расчёт рельефа'} {progress.phase === 'surface' && 'Поверхность'} {progress.phase === 'trees' && 'Расстановка деревьев'} {progress.phase === 'render' && 'Сборка мешей'} {progress.phase === 'done' && 'Готово!'} {progress.phase === 'starting' && 'Старт…'}
{/* Progress bar */}
{progress.pct}%
)}
); }