Open-source веб-студия для создания игр Рублокса, двойная лицензия AGPL-3.0 + Коммерческая. Главное: - Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16 - Самодостаточный движок ~28к строк (66 файлов): BlockManager, TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов - Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco) - Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn) - Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt) - 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.) - Конфигурируемый бэкенд через VITE_API_BASE — работает со staging (dev-api.rublox.pro) без настройки - Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка - Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG - ESLint + Prettier + EditorConfig - Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md - Issue templates: bug_report, feature_request, security_disclosure Перед публикацией: - Все импорты из minecraftia заменены на локальные - Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env - Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо) - AdminKubikonModeration не публикуется (модерация — в team.rublox.pro) - 93 МБ ассетов public/kubikon-assets вынесены в .gitignore (раздаются через release artifact)
521 lines
29 KiB
JavaScript
521 lines
29 KiB
JavaScript
/**
|
||
* TerrainPanel — левая боковая панель редактора ландшафта.
|
||
*
|
||
* Дизайн повторяет Roblox Studio → Terrain Editor: сетка 4×N кнопок-инструментов
|
||
* (иконка + подпись), снизу — секция «Параметры инструмента» (пока пустая,
|
||
* заглушка под brush size / strength / material).
|
||
*
|
||
* Полный список инструментов (как в RS, перевод на русский):
|
||
* 1. Выделить (Select) — выбор региона
|
||
* 2. Преобразовать (Transform) — move/scale/rotate выбранного региона
|
||
* 3. Заполнить (Fill) — залить регион материалом
|
||
* 4. Уровень моря (Sea Level) — поставить плоскость воды на высоту Y
|
||
* 5. Рисовать (Draw) — кистью добавлять voxels
|
||
* 6. Скульпт (Sculpt) — поднимать/опускать поверхность
|
||
* 7. Сгладить (Smooth) — сглаживать неровности
|
||
* 8. Раскрасить (Paint) — менять материал voxel'ов без изменения формы
|
||
* 9. Выровнять (Flatten) — выровнять по плоскости
|
||
*
|
||
* Все обработчики — заглушки (console.log). Подключение к движку — следующий этап.
|
||
*
|
||
* Иконки — inline SVG (никаких новых зависимостей, цвет наследуется через
|
||
* currentColor). Стиль монохромный, под тёмную тему Кубикона.
|
||
*/
|
||
import React, { useState, useEffect } from 'react';
|
||
import Icon from './Icon';
|
||
import cl from './TerrainPanel.module.css';
|
||
|
||
// ============================================================================
|
||
// Inline-SVG иконки. Все 24×24, stroke 1.6, currentColor. Стилизованы под
|
||
// Roblox Studio Terrain Editor (минималистичные пиктограммы).
|
||
// ============================================================================
|
||
const Ic = {
|
||
Select: () => (
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M5 3l4 14 2.5-5.5L17 9z" />
|
||
</svg>
|
||
),
|
||
Transform: () => (
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M5 7l4-4 4 4M19 17l-4 4-4-4M5 7v10M19 7v10M5 17l4 4M15 3h4v4" />
|
||
</svg>
|
||
),
|
||
Fill: () => (
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M4 12l8-8 8 8-8 8z" />
|
||
<path d="M4 12c0 2 2 4 4 4M19 17a2 2 0 1 1-4 0c0-1.5 2-4 2-4s2 2.5 2 4z" />
|
||
</svg>
|
||
),
|
||
SeaLevel: () => (
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M3 4l18 4-18 4z" />
|
||
<path d="M3 16c3 0 3-1 6-1s3 1 6 1 3-1 6-1" />
|
||
<path d="M3 20c3 0 3-1 6-1s3 1 6 1 3-1 6-1" />
|
||
</svg>
|
||
),
|
||
Draw: () => (
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M4 20l5-1 11-11-4-4L5 15z" />
|
||
<path d="M15 5l4 4" />
|
||
<path d="M17 3l1.5 1.5" />
|
||
</svg>
|
||
),
|
||
Sculpt: () => (
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M3 19l5-9 4 5 3-4 6 8z" />
|
||
<circle cx="17" cy="6" r="2.2" />
|
||
</svg>
|
||
),
|
||
Smooth: () => (
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M3 16c3 0 3-6 6-6s3 6 6 6 3-6 6-6" />
|
||
<path d="M14 4l3 3-1.5 1.5" />
|
||
</svg>
|
||
),
|
||
Paint: () => (
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M3 20l4-1 11-11-3-3L4 16z" />
|
||
<path d="M15 5l3 3" />
|
||
<circle cx="19" cy="5" r="1.5" />
|
||
</svg>
|
||
),
|
||
Flatten: () => (
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M3 15h18" strokeDasharray="2 2" />
|
||
<path d="M5 19l3-5 4 3 3-6 4 4" />
|
||
</svg>
|
||
),
|
||
};
|
||
|
||
// ============================================================================
|
||
// Описание всех инструментов — единое место правды, чтобы добавлять/убирать
|
||
// инструменты было удобно.
|
||
// ============================================================================
|
||
// Полный набор инструментов для VOXEL-режима.
|
||
const TOOLS_VOXEL = [
|
||
{ id: 'draw', label: 'Рисовать', icon: Ic.Draw, desc: 'Кисть: добавлять voxel\'ы материала' },
|
||
{ id: 'sculpt', label: 'Скульпт', icon: Ic.Sculpt, desc: 'Поднимать или опускать поверхность кистью (Shift — опускать)' },
|
||
{ id: 'smooth', label: 'Сгладить', icon: Ic.Smooth, desc: 'Сгладить неровности поверхности (использует существующие материалы)' },
|
||
{ id: 'paint', label: 'Раскрасить', icon: Ic.Paint, desc: 'Изменить материал voxel\'ов без изменения формы' },
|
||
{ id: 'flatten', label: 'Выровнять', icon: Ic.Flatten, desc: 'Выровнять воxel\'ы по горизонтальной плоскости' },
|
||
{ id: 'erase', label: 'Стереть', icon: Ic.Draw, desc: 'Удалить voxel\'ы в зоне кисти' },
|
||
// === Plant-кисти: расстановка декораций ===
|
||
{ id: 'plantGrass', label: 'Трава', icon: Ic.Draw, desc: 'Посадить пучки травы (Shift — удалить декорации)' },
|
||
{ id: 'plantFlower', label: 'Цветы', icon: Ic.Paint, desc: 'Посадить цветы (Shift — удалить декорации)' },
|
||
{ id: 'plantMushroom', label: 'Грибы', icon: Ic.Smooth, desc: 'Посадить грибы (Shift — удалить)' },
|
||
{ id: 'plantTree', label: 'Деревья', icon: Ic.Sculpt, desc: 'Посадить деревья (Shift — удалить)' },
|
||
];
|
||
|
||
// Инструменты для SMOOTH-режима (как в Roblox Studio Terrain Editor).
|
||
const TOOLS_SMOOTH = [
|
||
{ id: 'sculpt', label: 'Скульпт', icon: Ic.Sculpt, desc: 'Поднимать поверхность (Shift — опускать)' },
|
||
{ id: 'smooth', label: 'Сгладить', icon: Ic.Smooth, desc: 'Сгладить неровности' },
|
||
{ id: 'paint', label: 'Раскрасить', icon: Ic.Paint, desc: 'Перекрасить поверхность' },
|
||
{ id: 'flatten', label: 'Выровнять', icon: Ic.Flatten, desc: 'Выровнять по плоскости первой точки клика' },
|
||
{ id: 'fill', label: 'Заполнить', icon: Ic.Fill, desc: 'Залить сферу density=255' },
|
||
{ id: 'erase', label: 'Стереть', icon: Ic.Draw, desc: 'Удалить материал в сфере' },
|
||
// === Plant-кисти: расстановка декораций ===
|
||
{ id: 'plantGrass', label: 'Трава', icon: Ic.Draw, desc: 'Посадить пучки травы (Shift — удалить декорации)' },
|
||
{ id: 'plantFlower', label: 'Цветы', icon: Ic.Paint, desc: 'Посадить цветы (Shift — удалить декорации)' },
|
||
{ id: 'plantMushroom', label: 'Грибы', icon: Ic.Smooth, desc: 'Посадить грибы (Shift — удалить)' },
|
||
{ id: 'plantTree', label: 'Деревья', icon: Ic.Sculpt, desc: 'Посадить деревья (Shift — удалить)' },
|
||
// === Поштучный выбор/удаление декораций ===
|
||
{ id: 'pickDeco', label: 'Выбрать деко', icon: Ic.Draw, desc: 'Кликнуть по дереву/кусту/цветку — выделить, Del — удалить выбранное' },
|
||
];
|
||
|
||
// Список материалов террейна. У каждого:
|
||
// id — соответствует ключу в TerrainManager.TERRAIN_MATERIALS
|
||
// label — отображаемое имя
|
||
// color — fallback-цвет для случая если PNG-текстура не загрузилась
|
||
// preview — путь к текстуре для иконки в палитре (32×32 px Kenney pack).
|
||
// Для grass — берём grass_top.png, для остальных — основную.
|
||
const TEX = '/kubikon-assets/textures';
|
||
// Материалы террейна. Деко-материалы (листва/ствол/цветы/гриб/трава/мох)
|
||
// убраны — они ставятся отдельными plant-инструментами (Трава/Цветы/Грибы/Деревья).
|
||
const TERRAIN_MATERIALS = [
|
||
{ id: 'grass', label: 'Трава', color: '#52b15a', preview: `${TEX}/grass_top.png` },
|
||
{ id: 'rock', label: 'Камень', color: '#7e7e7e', preview: `${TEX}/greystone.png` },
|
||
{ id: 'sand', label: 'Песок', color: '#e6d27a', preview: `${TEX}/sand.png` },
|
||
{ id: 'snow', label: 'Снег', color: '#f5f7fb', preview: `${TEX}/snow.png` },
|
||
{ id: 'dirt', label: 'Земля', color: '#7c5430', preview: `${TEX}/dirt.png` },
|
||
{ id: 'water', label: 'Вода', color: '#3a8fd6', preview: `${TEX}/water.png` },
|
||
{ id: 'asphalt', label: 'Асфальт', color: '#3b3b3b', preview: `${TEX}/stone.png` },
|
||
{ id: 'concrete', label: 'Бетон', color: '#b8b8b8', preview: `${TEX}/greystone.png` },
|
||
{ id: 'wood', label: 'Дерево', color: '#a06a3a', preview: `${TEX}/wood.png` },
|
||
{ id: 'glacier', label: 'Ледник', color: '#c8e6f5', preview: `${TEX}/ice.png` },
|
||
{ id: 'salt', label: 'Соль', color: '#ecedef', preview: `${TEX}/snow.png` },
|
||
{ id: 'mud', label: 'Грязь', color: '#553a25', preview: `${TEX}/gravel_dirt.png` },
|
||
];
|
||
|
||
export default function TerrainPanel({ onBrushChange, onAction }) {
|
||
// terrainMode: null = экран выбора режима, 'voxel' | 'smooth' = редактор.
|
||
const [terrainMode, setTerrainMode] = useState(null);
|
||
const [tool, setTool] = useState('sculpt');
|
||
const [material, setMaterial] = useState('grass');
|
||
const [brushSize, setBrushSize] = useState(4);
|
||
const [strength, setStrength] = useState(50);
|
||
const [shape, setShape] = useState('sphere');
|
||
|
||
// Каждый раз когда любое поле кисти меняется — пушим в движок.
|
||
// terrainMode тоже передаём — движок переключает логику.
|
||
useEffect(() => {
|
||
if (!onBrushChange) return;
|
||
// Не пушим пока пользователь не выбрал режим — иначе скоулптится
|
||
// мимо ожидаемого таргета.
|
||
if (terrainMode === null) return;
|
||
onBrushChange({ tool, material, brushSize, strength, shape, terrainMode });
|
||
}, [onBrushChange, tool, material, brushSize, strength, shape, terrainMode]);
|
||
|
||
// При переключении в smooth-режим — если текущий material не поддерживается,
|
||
// переключаем на grass.
|
||
useEffect(() => {
|
||
if (terrainMode === 'smooth') {
|
||
const supported = ['grass', 'rock', 'sand', 'snow', 'dirt', 'water', 'wood', 'glacier'];
|
||
if (!supported.includes(material)) {
|
||
setMaterial('grass');
|
||
}
|
||
}
|
||
}, [terrainMode, material]);
|
||
|
||
// Hotkeys: только пока панель открыта.
|
||
// 1..9 0 (=10) — выбор материала из палитры (первые 10)
|
||
// Q/W/E/R/T/Y/U/I/O — выбор инструмента в порядке TOOLS
|
||
// Ctrl+Z / Ctrl+Y — undo / redo
|
||
// [ / ] — уменьшить / увеличить размер кисти
|
||
useEffect(() => {
|
||
const onKey = (e) => {
|
||
// Игнорируем когда печатают в input/textarea
|
||
const tag = (e.target?.tagName || '').toLowerCase();
|
||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||
|
||
// Ctrl+Z / Ctrl+Y
|
||
if ((e.ctrlKey || e.metaKey) && e.code === 'KeyZ' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
onAction?.('undo');
|
||
return;
|
||
}
|
||
if ((e.ctrlKey || e.metaKey) && (e.code === 'KeyY' || (e.code === 'KeyZ' && e.shiftKey))) {
|
||
e.preventDefault();
|
||
onAction?.('redo');
|
||
return;
|
||
}
|
||
// Escape снимает выделение региона
|
||
if (e.code === 'Escape') {
|
||
onAction?.('clear-region');
|
||
return;
|
||
}
|
||
|
||
// [ ] — размер кисти
|
||
if (e.code === 'BracketLeft') {
|
||
e.preventDefault();
|
||
setBrushSize((s) => Math.max(1, s - 1));
|
||
return;
|
||
}
|
||
if (e.code === 'BracketRight') {
|
||
e.preventDefault();
|
||
setBrushSize((s) => Math.min(32, s + 1));
|
||
return;
|
||
}
|
||
|
||
// Цифры — выбор материала (1..9, 0 → 10-й)
|
||
if (e.code && e.code.startsWith('Digit')) {
|
||
const n = parseInt(e.code.slice(5), 10);
|
||
const idx = n === 0 ? 9 : n - 1;
|
||
if (idx >= 0 && idx < TERRAIN_MATERIALS.length) {
|
||
e.preventDefault();
|
||
setMaterial(TERRAIN_MATERIALS[idx].id);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// T/Y/U/I/O/P — основные инструменты (draw/sculpt/smooth/paint/flatten/erase)
|
||
// F/C/M/V — plant-кисти (Flora/Cvety/Mushrooms/Vegetation-trees)
|
||
const toolKeyMap = {
|
||
KeyT: 'draw', KeyY: 'sculpt', KeyU: 'smooth', KeyI: 'paint',
|
||
KeyO: 'flatten', KeyP: 'erase',
|
||
KeyF: 'plantGrass', KeyC: 'plantFlower', KeyM: 'plantMushroom', KeyV: 'plantTree',
|
||
};
|
||
if (toolKeyMap[e.code] && !e.ctrlKey && !e.metaKey) {
|
||
e.preventDefault();
|
||
setTool(toolKeyMap[e.code]);
|
||
}
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [onAction]);
|
||
|
||
const handleTool = (id) => setTool(id);
|
||
const handleMaterial = (id) => setMaterial(id);
|
||
const handleShape = (id) => setShape(id);
|
||
|
||
// === Экран выбора режима ===
|
||
if (terrainMode === null) {
|
||
const modeCard = {
|
||
display: 'flex', alignItems: 'flex-start', gap: 12,
|
||
padding: '14px 16px', background: '#2e2e2e',
|
||
border: '1px solid #3a3a3a', borderRadius: 10,
|
||
cursor: 'pointer', color: '#e8e8ea', textAlign: 'left',
|
||
fontFamily: 'inherit', boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
|
||
width: '100%',
|
||
};
|
||
const modeTitle = { fontSize: 14, fontWeight: 800, color: '#e8e8ea', marginBottom: 6 };
|
||
const modeDesc = { fontSize: 12, lineHeight: 1.4, color: '#9a9a9e' };
|
||
return (
|
||
<div className={cl.panel}>
|
||
<div className={cl.header}>
|
||
<div className={cl.headerTitle}>Редактор ландшафта</div>
|
||
<div className={cl.headerHint}>Выберите тип редактирования</div>
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 10 }}>
|
||
<button type="button" onClick={() => setTerrainMode('voxel')} style={modeCard}>
|
||
<div style={{ display: 'flex' }}><Icon emoji="🟩" size={32} /></div>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={modeTitle}>Воксели</div>
|
||
<div style={modeDesc}>
|
||
Кубический ландшафт с чёткими блоками 0.25м.
|
||
Все инструменты Roblox Studio.
|
||
</div>
|
||
</div>
|
||
</button>
|
||
<button type="button" onClick={() => setTerrainMode('smooth')} style={modeCard}>
|
||
<div style={{ display: 'flex' }}><Icon emoji="🌄" size={32} /></div>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={modeTitle}>Гладкий ландшафт</div>
|
||
<div style={modeDesc}>
|
||
Плавные холмы без ступенек, как в Roblox.
|
||
Sculpt/Smooth/Paint работают на DensityGrid 4м.
|
||
</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Активный список инструментов зависит от режима.
|
||
const activeTools = terrainMode === 'smooth' ? TOOLS_SMOOTH : TOOLS_VOXEL;
|
||
// Палитра smooth-режима: 8 материалов через color (vec4) + tangent (vec4).
|
||
// color: grass / rock / sand / snow
|
||
// tangent: dirt / water / wood / glacier
|
||
// Остальные voxel-материалы маппятся на ближайший из 8 в Mesher.
|
||
const SMOOTH_MATERIALS_8 = ['grass', 'rock', 'sand', 'snow', 'dirt', 'water', 'wood', 'glacier'];
|
||
const activeMaterials = terrainMode === 'smooth'
|
||
? TERRAIN_MATERIALS.filter((m) => SMOOTH_MATERIALS_8.includes(m.id))
|
||
: TERRAIN_MATERIALS;
|
||
|
||
return (
|
||
<div className={cl.panel}>
|
||
{/* Заголовок панели */}
|
||
<div className={cl.header}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setTerrainMode(null)}
|
||
style={{
|
||
background: 'transparent', border: 'none', color: '#aac3ff',
|
||
cursor: 'pointer', padding: '2px 0', fontSize: 12,
|
||
textAlign: 'left',
|
||
}}
|
||
>
|
||
<Icon name="arrow-left" size={13} /> Назад к выбору типа
|
||
</button>
|
||
<div className={cl.headerTitle} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<Icon emoji={terrainMode === 'smooth' ? '🌄' : '🟩'} size={15} />
|
||
{terrainMode === 'smooth' ? 'Гладкий ландшафт' : 'Воксели'}
|
||
</div>
|
||
<div className={cl.headerHint}>
|
||
{terrainMode === 'smooth'
|
||
? 'Кисти редактируют гладкую поверхность'
|
||
: 'Создавайте холмы, реки и пещеры'}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Сетка инструментов — 4 столбца, как в Roblox Studio */}
|
||
<div className={cl.toolsGrid}>
|
||
{activeTools.map((t) => {
|
||
const Icon = t.icon;
|
||
const active = tool === t.id;
|
||
return (
|
||
<button
|
||
key={t.id}
|
||
type="button"
|
||
className={`${cl.toolBtn} ${active ? cl.toolBtnActive : ''}`}
|
||
onClick={() => handleTool(t.id)}
|
||
title={t.desc}
|
||
>
|
||
<div className={cl.toolIcon}>
|
||
<Icon />
|
||
</div>
|
||
<div className={cl.toolLabel}>{t.label}</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Разделитель */}
|
||
<div className={cl.divider} />
|
||
|
||
{/* Секция «Материал» — показывается только для инструментов
|
||
* которые ставят/красят. Для erase/smooth/select/transform/plant —
|
||
* палитра не нужна. */}
|
||
{!new Set([
|
||
'erase', 'smooth', 'select', 'transform',
|
||
'plantGrass', 'plantFlower', 'plantMushroom', 'plantTree',
|
||
'pickDeco',
|
||
]).has(tool) && (
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}>Материал</div>
|
||
{terrainMode === 'smooth' && (
|
||
<div style={{ fontSize: 10, color: '#7aa3ff', padding: '0 0 6px', lineHeight: 1.4 }}>
|
||
Гладкий ландшафт поддерживает 8 материалов. Остальные
|
||
материалы доступны только в режиме «Воксели».
|
||
</div>
|
||
)}
|
||
<div className={cl.materialsGrid}>
|
||
{activeMaterials.map((m) => (
|
||
<button
|
||
key={m.id}
|
||
type="button"
|
||
className={`${cl.matBtn} ${material === m.id ? cl.matBtnActive : ''}`}
|
||
// Fallback цвет — если PNG не загрузился (404), плашка останется
|
||
// окрашенной и юзер сможет понять что выбрал.
|
||
style={{ background: m.color }}
|
||
onClick={() => handleMaterial(m.id)}
|
||
title={m.label}
|
||
>
|
||
{/* Превью текстуры. image-rendering: pixelated даёт sharp
|
||
* pixel-art-стиль (как в Minecraft), без размытия при scale. */}
|
||
<img
|
||
src={m.preview}
|
||
alt=""
|
||
className={cl.matPreview}
|
||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||
/>
|
||
<span className={cl.matLabel}>{m.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Разделитель */}
|
||
<div className={cl.divider} />
|
||
|
||
{/* Секция «Кисть» — slider'ы размера и силы. Пока без onChange-логики
|
||
* (только локальный state). */}
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}>Кисть</div>
|
||
|
||
<div className={cl.row}>
|
||
<label className={cl.rowLabel}>Размер</label>
|
||
<input
|
||
type="range"
|
||
className={cl.slider}
|
||
min="1" max="32" step="1"
|
||
value={brushSize}
|
||
onChange={(e) => setBrushSize(Number(e.target.value))}
|
||
/>
|
||
<span className={cl.rowValue}>{brushSize}</span>
|
||
</div>
|
||
|
||
<div className={cl.row}>
|
||
<label className={cl.rowLabel}>Сила</label>
|
||
<input
|
||
type="range"
|
||
className={cl.slider}
|
||
min="1" max="100" step="1"
|
||
value={strength}
|
||
onChange={(e) => setStrength(Number(e.target.value))}
|
||
/>
|
||
<span className={cl.rowValue}>{strength}</span>
|
||
</div>
|
||
|
||
{/* Форма кисти — переключатель сфера/куб/цилиндр. Иконки
|
||
* пока эмодзи, под будущую логику. */}
|
||
<div className={cl.row}>
|
||
<label className={cl.rowLabel}>Форма</label>
|
||
<div className={cl.shapeBtns}>
|
||
<button type="button"
|
||
className={`${cl.shapeBtn} ${shape === 'sphere' ? cl.shapeBtnActive : ''}`}
|
||
onClick={() => handleShape('sphere')}
|
||
title="Сфера">●</button>
|
||
<button type="button"
|
||
className={`${cl.shapeBtn} ${shape === 'cube' ? cl.shapeBtnActive : ''}`}
|
||
onClick={() => handleShape('cube')}
|
||
title="Куб">■</button>
|
||
<button type="button"
|
||
className={`${cl.shapeBtn} ${shape === 'cylinder' ? cl.shapeBtnActive : ''}`}
|
||
onClick={() => handleShape('cylinder')}
|
||
title="Цилиндр">▮</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Разделитель */}
|
||
<div className={cl.divider} />
|
||
|
||
{/* Секция «Действия» — Undo/Redo/Очистить */}
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}>Действия</div>
|
||
<div className={cl.actionsRow}>
|
||
<button
|
||
type="button"
|
||
className={cl.actionBtn}
|
||
onClick={() => onAction?.('undo')}
|
||
title="Откатить последний мазок (Ctrl+Z)"
|
||
>
|
||
↶ Назад
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={cl.actionBtn}
|
||
onClick={() => onAction?.('redo')}
|
||
title="Вернуть откат (Ctrl+Y)"
|
||
>
|
||
↷ Вперёд
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={`${cl.actionBtn} ${cl.actionBtnDanger}`}
|
||
onClick={() => onAction?.('clear-terrain')}
|
||
title="Удалить весь ландшафт (модели и блоки остаются)"
|
||
>
|
||
Очистить весь ландшафт
|
||
</button>
|
||
{/* Снять карту высот — только для гладкого ландшафта.
|
||
* Делает raycast по реальному мешу поверхности и
|
||
* скачивает JSON с точными высотами. Нужно для точного
|
||
* размещения объектов/блоков на земле. */}
|
||
{terrainMode === 'smooth' && (
|
||
<button
|
||
type="button"
|
||
className={cl.actionBtn}
|
||
onClick={() => onAction?.('export-heightmap')}
|
||
title="Снять точную карту высот поверхности и скачать JSON"
|
||
style={{ marginTop: 6 }}
|
||
>
|
||
<Icon name="ruler" size={13} /> Снять карту высот
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Подсказка по hotkey'ам — школьники быстро освоятся если видят. */}
|
||
<div className={cl.hotkeysHint}>
|
||
<div><b>1–9, 0</b> — выбор материала</div>
|
||
<div><b>[ ]</b> — размер кисти</div>
|
||
<div><b>Ctrl+Z / Ctrl+Y</b> — отмена / возврат</div>
|
||
<div><b>Shift+ЛКМ</b> — стереть</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|