studio/src/editor/TerrainGenPanel.jsx
МИН d68920b4ce
Some checks failed
CI / Lint + Format (pull_request) Failing after 1m24s
CI / Build (pull_request) Successful in 1m55s
CI / Secret scan (pull_request) Successful in 2m31s
CI / PR size check (pull_request) Successful in 6s
fix(ci): trufflehog без docker + лишняя )} в TerrainGenPanel
2026-05-28 14:18:40 +03:00

1029 lines
47 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.

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