);
}
// ============================================================================
// Главный компонент
// ============================================================================
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 && (
)}
{/* /scrollArea */}
{/* Нижняя панель действий: для voxel — большая кнопка
«Сгенерировать», для smooth — отдельной кнопки нет (она
уже внутри секции). Закрытие/Назад работает всегда. */}
{genMode === 'voxel' && (
)}
{onClose && (
)}
{/* Full-screen прогресс-оверлей пока идёт генерация.
Перекрывает весь viewport чтобы пользователь не пытался кликать. */}
{progress !== null && (