1029 lines
47 KiB
JavaScript
1029 lines
47 KiB
JavaScript
/**
|
||
* 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 (
|
||
<div className={cl.sliderRow}>
|
||
<div className={cl.sliderHead}>
|
||
<span className={cl.sliderLabel}>{label}</span>
|
||
<span className={cl.sliderValue}>{display}</span>
|
||
</div>
|
||
<input
|
||
type="range"
|
||
min={min}
|
||
max={max}
|
||
step={step}
|
||
value={value}
|
||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||
className={cl.slider}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Section({ title, children, defaultOpen = true }) {
|
||
const [open, setOpen] = useState(defaultOpen);
|
||
return (
|
||
<div className={cl.section}>
|
||
<button className={cl.sectionHeader} onClick={() => setOpen(!open)} type="button">
|
||
<span className={cl.sectionArrow}>{open ? '▾' : '▸'}</span>
|
||
{title}
|
||
</button>
|
||
{open && <div className={cl.sectionBody}>{children}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Главный компонент
|
||
// ============================================================================
|
||
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 (
|
||
<div className={cl.panel}>
|
||
<div className={cl.stickyTop}>
|
||
<div className={cl.header}>
|
||
<div className={cl.headerTitle}>Процедурный ландшафт</div>
|
||
<div className={cl.headerHint}>
|
||
Выберите тип создаваемого мира
|
||
{hasRobloxTerrain ? ' · Сейчас в сцене: гладкий ландшафт' : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className={cl.scrollArea} style={{ padding: 10, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
{/* Карточка «Воксели» ВРЕМЕННО УБРАНА — генерация воксельного
|
||
ландшафта сломана (битый рендер деревьев + невидимые
|
||
правки кистью после генерации). Будет починена позже.
|
||
Воксельный ландшафт пока доступен только через ручной
|
||
инструмент «Ландшафт» — там всё работает. */}
|
||
|
||
{/* Карточка: Гладкий ландшафт */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setGenMode('smooth')}
|
||
style={modeCardStyle}
|
||
>
|
||
<div style={modeIconStyle}><Icon emoji="🌄" size={26} /></div>
|
||
<div style={{ flex: 1, textAlign: 'left' }}>
|
||
<div style={modeTitleStyle}>Гладкий ландшафт</div>
|
||
<div style={modeDescStyle}>
|
||
Плавные холмы для open-world игр.
|
||
</div>
|
||
<div style={modeBadgesStyle}>
|
||
<span style={badgeStyle('#e6a23a')}>До 600м</span>
|
||
<span style={badgeStyle('#3357ff')}>FPS 60+</span>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
|
||
{/* Пояснение про воксельный режим */}
|
||
<div style={{
|
||
padding: '10px 12px',
|
||
border: '1px solid #3a3a3a',
|
||
borderRadius: 8,
|
||
background: '#1b1b1b',
|
||
fontSize: 12,
|
||
color: '#9a9a9e',
|
||
lineHeight: 1.5,
|
||
}}>
|
||
<Icon emoji="🟩" size={13} /> Процедурная генерация воксельного ландшафта
|
||
временно недоступна — чиним. Воксельный мир пока можно создать
|
||
вручную через инструмент «Ландшафт».
|
||
</div>
|
||
|
||
{/* Если в сцене уже есть гладкий ландшафт — кнопка очистки */}
|
||
{hasRobloxTerrain && (
|
||
<button
|
||
type="button"
|
||
onClick={handleClearRoblox}
|
||
style={{
|
||
marginTop: 4,
|
||
padding: '10px 14px',
|
||
border: '1px solid rgba(239, 68, 68, 0.4)',
|
||
borderRadius: 8,
|
||
background: 'rgba(239, 68, 68, 0.16)',
|
||
color: '#ff6b6b',
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
<Icon emoji="🗑" size={13} /> Очистить текущий гладкий ландшафт
|
||
</button>
|
||
)}
|
||
</div>
|
||
{/* Кнопка "Назад" */}
|
||
{onClose && (
|
||
<div className={cl.actions}>
|
||
<button type="button" className={cl.closeBtn} onClick={onClose}>
|
||
Закрыть
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// === Экран параметров (после выбора режима) ===
|
||
return (
|
||
<div className={cl.panel}>
|
||
{/* Sticky top — шапка + превью. Не скроллится при крутке секций. */}
|
||
<div className={cl.stickyTop}>
|
||
<div className={cl.header}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setGenMode(null)}
|
||
style={{
|
||
background: 'transparent',
|
||
border: 'none',
|
||
color: '#aac3ff',
|
||
cursor: 'pointer',
|
||
padding: '2px 0',
|
||
fontSize: 12,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
}}
|
||
>
|
||
<Icon name="arrow-left" size={13} /> Назад к выбору типа
|
||
</button>
|
||
<div className={cl.headerTitle} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<Icon emoji={genMode === 'smooth' ? '🌄' : '🟩'} size={15} />
|
||
{genMode === 'smooth' ? 'Гладкий ландшафт' : 'Воксельный ландшафт'}
|
||
</div>
|
||
<div className={cl.headerHint}>
|
||
{genMode === 'smooth'
|
||
? 'Плавные холмы, Surface Nets, до 600м'
|
||
: 'Подбери параметры и нажми «Сгенерировать»'}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Превью для обоих режимов — топ-вью карта биомов и высоты.
|
||
Smooth-режим использует те же params, превью одинаково. */}
|
||
<div className={cl.previewWrap}>
|
||
<canvas
|
||
ref={canvasRef}
|
||
width={256}
|
||
height={256}
|
||
className={cl.preview}
|
||
/>
|
||
{previewStats && (
|
||
<div className={cl.previewStats}>
|
||
Превью: {previewStats.time} мс · Y {previewStats.minH}…{previewStats.maxH}
|
||
{genMode === 'voxel'
|
||
? ` · ~${previewStats.estimatedTrees} деревьев`
|
||
: ''}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Скроллящаяся зона со всеми секциями параметров */}
|
||
<div className={cl.scrollArea}>
|
||
|
||
{genMode === 'voxel' && (<>
|
||
<Section title="Пресеты">
|
||
<div className={cl.presetsGrid}>
|
||
{Object.entries(PRESETS).map(([key, p]) => (
|
||
<button
|
||
key={key}
|
||
type="button"
|
||
className={cl.presetBtn}
|
||
onClick={() => applyPreset(key)}
|
||
>
|
||
{p.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</Section>
|
||
|
||
<Section title="Основные параметры">
|
||
<div className={cl.seedRow}>
|
||
<span className={cl.sliderLabel}>Seed</span>
|
||
<input
|
||
type="number"
|
||
value={seed}
|
||
onChange={(e) => setSeed(parseInt(e.target.value, 10) || 0)}
|
||
className={cl.seedInput}
|
||
/>
|
||
<button type="button" onClick={randomSeed} className={cl.diceBtn} title="Случайный"><Icon name="dice" size={15} /></button>
|
||
</div>
|
||
<Slider
|
||
label="Размер карты"
|
||
value={worldSize}
|
||
min={40} max={200} step={20}
|
||
onChange={setWorldSize}
|
||
format={(v) => `${(v * 0.25 * 2).toFixed(0)} м`}
|
||
/>
|
||
<div style={{ fontSize: 10, color: '#7aa3ff', padding: '2px 0' }}>
|
||
Лимит 100м для воксельных карт. Для больших карт выберите «Гладкий ландшафт» в начале панели.
|
||
</div>
|
||
</Section>
|
||
|
||
<Section title="Рельеф (heightmap)">
|
||
<Slider
|
||
label="Масштаб"
|
||
value={heightmap.scale}
|
||
min={0.005} max={0.05} step={0.001}
|
||
onChange={(v) => setHeightmap({ ...heightmap, scale: v })}
|
||
format={(v) => v.toFixed(3)}
|
||
/>
|
||
<Slider
|
||
label="Амплитуда (высота)"
|
||
value={heightmap.amplitude}
|
||
min={1} max={60} step={1}
|
||
onChange={(v) => setHeightmap({ ...heightmap, amplitude: v })}
|
||
format={(v) => `${v} вокс`}
|
||
/>
|
||
<Slider
|
||
label="Октавы"
|
||
value={heightmap.octaves}
|
||
min={1} max={8} step={1}
|
||
onChange={(v) => setHeightmap({ ...heightmap, octaves: v })}
|
||
format={(v) => v.toString()}
|
||
/>
|
||
<Slider
|
||
label="Экспонента"
|
||
value={heightmap.exponent}
|
||
min={0.5} max={3.5} step={0.1}
|
||
onChange={(v) => setHeightmap({ ...heightmap, exponent: v })}
|
||
/>
|
||
<Slider
|
||
label="Базовая высота"
|
||
value={heightmap.baseHeight}
|
||
min={-10} max={20} step={1}
|
||
onChange={(v) => setHeightmap({ ...heightmap, baseHeight: v })}
|
||
format={(v) => v.toString()}
|
||
/>
|
||
</Section>
|
||
|
||
<Section title="Деревья">
|
||
<label className={cl.checkRow}>
|
||
<input
|
||
type="checkbox"
|
||
checked={treesEnabled}
|
||
onChange={(e) => setTreesEnabled(e.target.checked)}
|
||
/>
|
||
<span>Деревья включены</span>
|
||
</label>
|
||
{treesEnabled && (
|
||
<>
|
||
<Slider
|
||
label="Плотность"
|
||
value={treesDensity}
|
||
min={0.05} max={1.5} step={0.05}
|
||
onChange={setTreesDensity}
|
||
/>
|
||
<Slider
|
||
label="Размер (×базы)"
|
||
value={treesSizeScale}
|
||
min={0.5} max={2.5} step={0.1}
|
||
onChange={setTreesSizeScale}
|
||
format={(v) => `×${v.toFixed(1)}`}
|
||
/>
|
||
{treesSizeScale > 2.0 && (
|
||
<div style={{ fontSize: 10, color: '#e69b3a', padding: '2px 0' }}>
|
||
<Icon name="warning" size={11} /> Очень большие деревья дают много вокселей.
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</Section>
|
||
|
||
<Section title="Декорации">
|
||
<Slider
|
||
label="Частота цветов и грибов"
|
||
value={flowersDensity}
|
||
min={0} max={0.1} step={0.005}
|
||
onChange={setFlowersDensity}
|
||
format={(v) => `${(v * 100).toFixed(1)}%`}
|
||
/>
|
||
<Slider
|
||
label="Частота травы"
|
||
value={grassDensity}
|
||
min={0} max={0.5} step={0.01}
|
||
onChange={setGrassDensity}
|
||
format={(v) => `${(v * 100).toFixed(0)}%`}
|
||
/>
|
||
</Section>
|
||
</>)}
|
||
|
||
{genMode === 'smooth' && (<>
|
||
<Section title="Пресеты">
|
||
<div className={cl.presetsGrid}>
|
||
{Object.entries(PRESETS).map(([key, p]) => (
|
||
<button
|
||
key={key}
|
||
type="button"
|
||
className={cl.presetBtn}
|
||
onClick={() => applyPreset(key)}
|
||
>
|
||
{p.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</Section>
|
||
|
||
<Section title="Основные параметры">
|
||
<div className={cl.seedRow}>
|
||
<span className={cl.sliderLabel}>Seed</span>
|
||
<input
|
||
type="number"
|
||
value={seed}
|
||
onChange={(e) => setSeed(parseInt(e.target.value, 10) || 0)}
|
||
className={cl.seedInput}
|
||
/>
|
||
<button type="button" onClick={randomSeed} className={cl.diceBtn} title="Случайный"><Icon name="dice" size={15} /></button>
|
||
</div>
|
||
<Slider
|
||
label="Размер карты"
|
||
value={robloxSize}
|
||
min={30} max={150} step={10}
|
||
onChange={setRobloxSize}
|
||
format={(v) => `${v * 4} м`}
|
||
/>
|
||
<div style={{ fontSize: 10, color: '#7aa3ff', padding: '2px 0' }}>
|
||
1 ячейка = 4м. Гладкий ландшафт поддерживает большие карты до 600м.
|
||
</div>
|
||
</Section>
|
||
|
||
<Section title="Рельеф (heightmap)">
|
||
<Slider
|
||
label="Масштаб"
|
||
value={heightmap.scale}
|
||
min={0.001} max={0.02} step={0.0005}
|
||
onChange={(v) => setHeightmap({ ...heightmap, scale: v })}
|
||
format={(v) => v.toFixed(4)}
|
||
/>
|
||
<Slider
|
||
label="Амплитуда (высота)"
|
||
value={heightmap.amplitude}
|
||
min={5} max={120} step={5}
|
||
onChange={(v) => setHeightmap({ ...heightmap, amplitude: v })}
|
||
format={(v) => `${(v * 0.25).toFixed(1)} м`}
|
||
/>
|
||
<Slider
|
||
label="Октавы"
|
||
value={heightmap.octaves}
|
||
min={1} max={8} step={1}
|
||
onChange={(v) => setHeightmap({ ...heightmap, octaves: v })}
|
||
format={(v) => v.toString()}
|
||
/>
|
||
<Slider
|
||
label="Экспонента"
|
||
value={heightmap.exponent}
|
||
min={0.5} max={3.5} step={0.1}
|
||
onChange={(v) => setHeightmap({ ...heightmap, exponent: v })}
|
||
/>
|
||
<Slider
|
||
label="Базовая высота"
|
||
value={heightmap.baseHeight}
|
||
min={-10} max={20} step={1}
|
||
onChange={(v) => setHeightmap({ ...heightmap, baseHeight: v })}
|
||
format={(v) => v.toString()}
|
||
/>
|
||
</Section>
|
||
|
||
<Section title={<><Icon emoji="🌳" size={14} /> Деревья</>}>
|
||
<div style={{ fontSize: 10, color: '#7aa3ff', padding: '0 0 6px', lineHeight: 1.4 }}>
|
||
Лиственные деревья в лесах/равнинах, хвойные в горах,
|
||
пальмы на пляжах, кактусы в пустыне.
|
||
</div>
|
||
<Slider
|
||
label="Плотность"
|
||
value={smoothTreesDensity}
|
||
min={0} max={1} step={0.05}
|
||
onChange={setSmoothTreesDensity}
|
||
format={(v) => v === 0 ? 'выкл' : `${(v * 100).toFixed(0)}%`}
|
||
/>
|
||
</Section>
|
||
|
||
<Section title={<><Icon emoji="🌼" size={14} /> Декорации</>}>
|
||
<div style={{ fontSize: 10, color: '#7aa3ff', padding: '0 0 6px', lineHeight: 1.4 }}>
|
||
3D-модели цветов, травы и грибов на поверхности.
|
||
Размещаются автоматически в подходящих биомах.
|
||
</div>
|
||
<Slider
|
||
label="Цветы и грибы"
|
||
value={flowersDensity}
|
||
min={0} max={0.1} step={0.005}
|
||
onChange={setFlowersDensity}
|
||
format={(v) => v === 0 ? 'выкл' : `${(v * 100).toFixed(1)}%`}
|
||
/>
|
||
<Slider
|
||
label="Трава"
|
||
value={grassDensity}
|
||
min={0} max={0.5} step={0.01}
|
||
onChange={setGrassDensity}
|
||
format={(v) => v === 0 ? 'выкл' : `${(v * 100).toFixed(0)}%`}
|
||
/>
|
||
</Section>
|
||
|
||
<Section title={<><Icon emoji="🌄" size={14} /> Применить</>}>
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
<button
|
||
type="button"
|
||
className={cl.applyBtn}
|
||
onClick={handleApplyRoblox}
|
||
disabled={isApplyingRoblox}
|
||
style={{
|
||
flex: 1,
|
||
...(isApplyingRoblox ? { opacity: 0.5, cursor: 'wait' } : null),
|
||
}}
|
||
>
|
||
{isApplyingRoblox ? 'Создаю…' : <><Icon emoji="🌄" size={13} /> Создать гладкий ландшафт</>}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={cl.closeBtn}
|
||
onClick={handleClearRoblox}
|
||
disabled={isApplyingRoblox}
|
||
title="Очистить гладкий ландшафт"
|
||
style={{ minWidth: 38 }}
|
||
>
|
||
<Icon name="close" size={14} />
|
||
</button>
|
||
</div>
|
||
</Section>
|
||
</>)}
|
||
|
||
</div>{/* /scrollArea */}
|
||
|
||
{/* Нижняя панель действий: для voxel — большая кнопка
|
||
«Сгенерировать», для smooth — отдельной кнопки нет (она
|
||
уже внутри секции). Закрытие/Назад работает всегда. */}
|
||
<div className={cl.actions}>
|
||
{genMode === 'voxel' && (
|
||
<button
|
||
type="button"
|
||
className={cl.applyBtn}
|
||
onClick={handleApply}
|
||
disabled={isGenerating}
|
||
style={isGenerating ? { opacity: 0.5, cursor: 'wait' } : undefined}
|
||
>
|
||
{isGenerating ? `Генерация… ${progress.pct}%` : 'Сгенерировать'}
|
||
</button>
|
||
)}
|
||
{onClose && (
|
||
<button
|
||
type="button"
|
||
className={cl.closeBtn}
|
||
onClick={onClose}
|
||
disabled={isGenerating || isApplyingRoblox}
|
||
>
|
||
Закрыть
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Full-screen прогресс-оверлей пока идёт генерация.
|
||
Перекрывает весь viewport чтобы пользователь не пытался кликать. */}
|
||
{progress !== null && (
|
||
<div style={{
|
||
position: 'fixed',
|
||
inset: 0,
|
||
background: 'rgba(0, 0, 0, 0.55)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
zIndex: 9999,
|
||
pointerEvents: 'all',
|
||
}}>
|
||
<div style={{
|
||
background: 'rgba(30, 32, 40, 0.95)',
|
||
borderRadius: 12,
|
||
padding: '24px 32px',
|
||
minWidth: 320,
|
||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5)',
|
||
textAlign: 'center',
|
||
}}>
|
||
<div style={{ fontSize: 16, fontWeight: 600, color: '#fff', marginBottom: 14 }}>
|
||
Генерация ландшафта…
|
||
</div>
|
||
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.65)', marginBottom: 16 }}>
|
||
{progress.phase === 'heightmap' && 'Расчёт рельефа'}
|
||
{progress.phase === 'surface' && 'Поверхность'}
|
||
{progress.phase === 'trees' && 'Расстановка деревьев'}
|
||
{progress.phase === 'render' && 'Сборка мешей'}
|
||
{progress.phase === 'done' && 'Готово!'}
|
||
{progress.phase === 'starting' && 'Старт…'}
|
||
</div>
|
||
{/* Progress bar */}
|
||
<div style={{
|
||
width: 280,
|
||
height: 8,
|
||
background: 'rgba(255,255,255,0.12)',
|
||
borderRadius: 4,
|
||
overflow: 'hidden',
|
||
margin: '0 auto',
|
||
}}>
|
||
<div style={{
|
||
width: `${progress.pct}%`,
|
||
height: '100%',
|
||
background: 'linear-gradient(90deg, #3357FF 0%, #4a7aff 100%)',
|
||
transition: 'width 0.15s ease-out',
|
||
}} />
|
||
</div>
|
||
<div style={{ fontSize: 14, color: '#fff', marginTop: 10, fontFamily: 'monospace' }}>
|
||
{progress.pct}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|