import React, { useEffect, useState } from 'react'; import { getBlockType } from './engine/BlockTypes'; import { getModelType } from './engine/ModelTypes'; import { getPrimitiveType } from './engine/PrimitiveTypes'; import Icon from './Icon'; import cl from './InspectorPanel.module.css'; /** * True если nodeId является (косвенным) потомком candidateAncestorId * в дереве GUI-элементов. Защищает от циклов в выпадашке «Родитель». */ function _isAncestorOf(guiList, candidateAncestorId, nodeId) { let cur = guiList.find(g => g.id === nodeId); let safety = 0; while (cur && cur.parentId && safety++ < 100) { if (cur.parentId === candidateAncestorId) return true; cur = guiList.find(g => g.id === cur.parentId); } return false; } /** * AssetPicker — выбор картинки из библиотеки проекта + загрузка новой. * Используется для текстуры примитива и для image-GUI. * * Props: * assetList — [{id, name, dataUrl}] библиотека картинок проекта * value — id выбранного ассета (или null) * onPick(id) — выбрана картинка (id или null чтобы снять) * onUpload(file) → Promise<{ok, id?, error?}> — загрузить файл * onRemove(id) — удалить картинку из библиотеки */ function AssetPicker({ assetList = [], value, onPick, onUpload, onRemove }) { const fileRef = React.useRef(null); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); const handleFile = async (e) => { const file = e.target.files && e.target.files[0]; e.target.value = ''; // позволяем выбрать тот же файл повторно if (!file || !onUpload) return; setError(''); setBusy(true); const res = await onUpload(file); setBusy(false); if (res && res.ok) { onPick?.(res.id); // сразу применяем загруженную картинку } else { setError((res && res.error) || 'Не удалось загрузить'); } }; return (
{/* Сетка превью картинок библиотеки */}
{/* Кнопка «нет картинки» */} {assetList.map(a => (
{onRemove && ( )}
))}
{error && (
{error}
)}
); } /** * GameplayInspector — настройки враг/спавнер из data.gameplay модели. * Меняет данные через onSetModelProps({ gameplayParams: {...} }). */ function GameplayInspector({ selection, onSetModelProps, onAddWeaponToInventory }) { const gp = selection.gameplay; if (!gp) return null; const defaults = gp.defaultParams || {}; const current = selection.gameplayParams || {}; const merged = { ...defaults, ...current }; const setParam = (key, value) => { const next = { ...merged, [key]: value }; onSetModelProps?.({ gameplayParams: next }); }; const numberInput = (label, key, opts = {}) => { const val = merged[key]; return ( ); }; return (
{gp.isZombie ? <> Параметры врага : gp.isZombieSpawner ? <> Параметры спавнера : gp.isWeapon ? (gp.weaponKind === 'melee' ? <> Параметры оружия : <> Параметры оружия) : <> Параметры}
{gp.description}
{gp.isZombie && (
{numberInput('Жизни', 'hp', { min: 1, max: 9999 })} {numberInput('Урон', 'attackDamage', { min: 0, max: 999 })} {numberInput('Скорость', 'speed', { min: 0.1, max: 20, step: 0.1, unit: 'м/с' })} {numberInput('Скорость пат.', 'wanderSpeed', { min: 0.1, max: 20, step: 0.1, unit: 'м/с' })} {numberInput('Радиус обнар.', 'detectionRadius', { min: 1, max: 100, unit: 'м' })} {numberInput('Дистанция атаки', 'attackRange', { min: 0.5, max: 20, step: 0.1, unit: 'м' })} {numberInput('Кулдаун атаки', 'attackCooldown', { min: 0.1, max: 10, step: 0.1, unit: 'с' })} {numberInput('Радиус патруля', 'wanderRadius', { min: 1, max: 100, unit: 'м' })}
)} {gp.isZombieSpawner && (
{numberInput('Лимит живых', 'maxAlive', { min: 1, max: 100 })} {numberInput('Интервал', 'spawnInterval', { min: 0.5, max: 600, step: 0.5, unit: 'с' })} {numberInput('Радиус спавна', 'radius', { min: 1, max: 100, unit: 'м' })} {numberInput('HP зомби', 'zombieHp', { min: 1, max: 9999 })} {numberInput('Скорость зомби', 'zombieSpeed', { min: 0.1, max: 20, step: 0.1, unit: 'м/с' })} {numberInput('Урон зомби', 'zombieDamage', { min: 0, max: 999 })}
)} {gp.isWeapon && ( <>
{numberInput('Урон', 'damage', { min: 0, max: 9999 })} {gp.weaponKind === 'melee' ? ( <> {numberInput('Кулдаун', 'fireRate', { min: 0.05, max: 5, step: 0.05, unit: 'с' })} {numberInput('Дистанция', 'range', { min: 0.5, max: 10, step: 0.1, unit: 'м' })} {numberInput('Угол атаки', 'meleeArc', { min: 0.1, max: 3.14, step: 0.1, unit: 'рад' })} ) : ( <> {numberInput('Скорострел.', 'fireRate', { min: 0.05, max: 5, step: 0.05, unit: 'с' })} {numberInput('Дальность', 'range', { min: 5, max: 500, unit: 'м' })} {numberInput('Магазин', 'magazine', { min: 1, max: 999 })} {numberInput('Запас', 'reserve', { min: 0, max: 9999 })} {numberInput('Перезарядка', 'reloadTime', { min: 0.1, max: 10, step: 0.1, unit: 'с' })} )}
)}
Изменения вступают в силу после старта игры ().
); } const PRIMITIVE_MATERIALS = [ { id: 'matte', name: 'Матовый' }, { id: 'metal', name: 'Металл' }, { id: 'glass', name: 'Стекло' }, { id: 'neon', name: 'Неон' }, { id: 'studs', name: 'Studs' }, // задача 09 — лего-кружки (любой цвет) ]; /** Форматирование массы — без хвоста из десятичных. Целые без запятой. */ function formatMass(m) { const n = Number(m) || 1; if (Math.abs(n - Math.round(n)) < 0.01) return String(Math.round(n)); return n.toFixed(2); } /** * Inspector — свойства выделенного объекта. * Поддерживает редактирование позиции, поворота, масштаба, удаление. * * Props: * selection — { type:'block'|'model', ... } | null * onMoveTo(x, y, z) * onRotateTo(angleRad) * onScaleTo(scale) * onDelete() * onFocus */ const InspectorPanel = ({ selection, onMoveTo, onRotateTo, onScaleTo, onDelete, onFocus, onResizePrimitive, onSetPrimitiveProps, onSetAnchored, onSetMass, onSetModelProps, onSetBlockProps, onSetLightingProps, onSetSoundProps, onSetPlayerProps, onSetFloorProps, onSetGuiProps, onDeleteGui, onAddWeaponToInventory, onEditBillboard, guiElements = [], // Этап 3.6 — библиотека пользовательских картинок. assetList = [], onUploadAsset, onRemoveAsset, }) => { const props = { onSetLightingProps, onSetSoundProps, onSetPlayerProps, onSetFloorProps, onSetGuiProps, onDeleteGui, onAddWeaponToInventory, }; const [localX, setLocalX] = useState(''); const [localY, setLocalY] = useState(''); const [localZ, setLocalZ] = useState(''); const [localRot, setLocalRot] = useState(''); const [localScale, setLocalScale] = useState(''); // Для примитивов — отдельные размеры по осям и свойства const [localSx, setLocalSx] = useState(''); const [localSy, setLocalSy] = useState(''); const [localSz, setLocalSz] = useState(''); const [localColor, setLocalColor] = useState('#888888'); const [localMaterial, setLocalMaterial] = useState('matte'); const [localStudDensity, setLocalStudDensity] = useState(1); // плотность studs // Подпись над объектом (задача 10). const [localLabel, setLocalLabel] = useState(null); // { enabled, binding, params, preset, height } const [localCanCollide, setLocalCanCollide] = useState(true); const [localVisible, setLocalVisible] = useState(true); const [localAnchored, setLocalAnchored] = useState(true); const [localMass, setLocalMass] = useState('1'); const [localOpacity, setLocalOpacity] = useState(1); const [localTint, setLocalTint] = useState(''); const [localBrightness, setLocalBrightness] = useState(1.5); const [localRange, setLocalRange] = useState(12); // Синхронизируем локальное состояние когда меняется selection useEffect(() => { if (!selection) return; if (selection.type === 'block') { setLocalX(String(selection.gridX)); setLocalY(String(selection.gridY)); setLocalZ(String(selection.gridZ)); setLocalAnchored(selection.anchored !== false); setLocalMass(formatMass(selection.mass ?? 1)); setLocalCanCollide(selection.canCollide !== false); setLocalVisible(selection.visible !== false); } else if (selection.type === 'model' || selection.type === 'userModel') { setLocalX(selection.x.toFixed(2)); setLocalY(selection.y.toFixed(2)); setLocalZ(selection.z.toFixed(2)); setLocalRot(((selection.rotationY || 0) * 180 / Math.PI).toFixed(0)); setLocalScale((selection.scale || 1).toFixed(2)); setLocalAnchored(selection.anchored !== false); setLocalMass(formatMass(selection.mass ?? 1)); setLocalCanCollide(selection.canCollide !== false); setLocalVisible(selection.visible !== false); setLocalOpacity(typeof selection.opacity === 'number' ? selection.opacity : 1); setLocalTint(selection.tint || ''); } else if (selection.type === 'spawn') { setLocalX(selection.x.toFixed(2)); setLocalY(selection.y.toFixed(2)); setLocalZ(selection.z.toFixed(2)); } else if (selection.type === 'primitive') { setLocalX(selection.x.toFixed(2)); setLocalY(selection.y.toFixed(2)); setLocalZ(selection.z.toFixed(2)); setLocalSx((selection.sx || 1).toFixed(2)); setLocalSy((selection.sy || 1).toFixed(2)); setLocalSz((selection.sz || 1).toFixed(2)); setLocalColor(selection.color || '#888888'); setLocalMaterial(selection.material || 'matte'); setLocalStudDensity(selection.studDensity || 1); setLocalLabel(selection.label || null); setLocalCanCollide(selection.canCollide !== false); setLocalVisible(selection.visible !== false); setLocalAnchored(selection.anchored !== false); setLocalMass(formatMass(selection.mass ?? 1)); // Параметры лампы setLocalBrightness(selection.brightness ?? 1.5); setLocalRange(selection.range ?? 12); } }, [selection]); if (!selection) { return (
Выберите объект чтобы увидеть его свойства
Инструмент «Выделить» + ЛКМ или клик в Иерархии
); } const isBlock = selection.type === 'block'; const isSpawn = selection.type === 'spawn'; const isPrimitive = selection.type === 'primitive'; const isUserModel = selection.type === 'userModel'; // isModel включает и обычные GLB-модели и пользовательские voxel-модели — // у них одинаковый набор полей (position/rotation/scale/anchored/...). const isModel = selection.type === 'model' || isUserModel; const isLighting = selection.type === 'lighting'; const isScript = selection.type === 'script'; // Жидкие блоки (вода/лава) — у них нет свойств физики; они работают по // своим правилам (canCollide=false, плавание, и т.п.) const isLiquid = isBlock && (selection.blockTypeId === 'water' || selection.blockTypeId === 'lava'); const blockType = isBlock ? getBlockType(selection.blockTypeId) : null; const modelType = isModel ? getModelType(selection.modelTypeId) : null; const primitiveType = isPrimitive ? getPrimitiveType(selection.primitiveType) : null; const commitResize = () => { const sx = parseFloat(localSx), sy = parseFloat(localSy), sz = parseFloat(localSz); if (Number.isFinite(sx) && Number.isFinite(sy) && Number.isFinite(sz) && sx > 0.05 && sy > 0.05 && sz > 0.05) { onResizePrimitive?.(sx, sy, sz); } }; /** Применить введённые X/Y/Z. */ const commitPosition = () => { const x = parseFloat(localX); const y = parseFloat(localY); const z = parseFloat(localZ); if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) { onMoveTo(x, y, z); } }; const commitRotation = () => { const deg = parseFloat(localRot); if (Number.isFinite(deg)) { onRotateTo(deg * Math.PI / 180); } }; const commitScale = () => { const s = parseFloat(localScale); if (Number.isFinite(s) && s > 0.01) { onScaleTo(s); } }; // === Тип «lighting» — отдельный layout без позиции/физики === if (isLighting) { return (
Освещение
Свет, время суток, туман, тени
{/* Время суток */}
Время суток
{[ { id: 'day', label: 'День', iconName: 'sun' }, { id: 'sunset', label: 'Закат', iconName: 'sunset' }, { id: 'night', label: 'Ночь', iconName: 'moon' }, { id: 'dawn', label: 'Рассвет', iconName: 'sunrise' }, { id: 'cycle', label: 'Цикл', iconName: 'cycle' }, ].map(t => ( ))}
{(selection.envPreset || 'day') === 'cycle' && (
День (мин): props.onSetLightingProps?.({ dayDurationMin: parseFloat(e.target.value) || 0 })} style={{ width: 60, padding: '4px 6px', background: '#0f0a05', border: '1px solid #5a4a3a', borderRadius: 3, color: '#f0e6d8' }} /> Ночь (мин): props.onSetLightingProps?.({ nightDurationMin: parseFloat(e.target.value) || 0 })} style={{ width: 60, padding: '4px 6px', background: '#0f0a05', border: '1px solid #5a4a3a', borderRadius: 3, color: '#f0e6d8' }} />
)}
{/* Солнце */}
Солнце
Интенсивность {(selection.sunIntensity || 0.8).toFixed(2)}
props.onSetLightingProps?.({ sunIntensity: parseFloat(e.target.value) })} style={{ width: '100%' }} />
{/* Окружающий свет */}
Окружающий свет
Интенсивность {(selection.hemiIntensity || 0.65).toFixed(2)}
props.onSetLightingProps?.({ hemiIntensity: parseFloat(e.target.value) })} style={{ width: '100%' }} />
Цвет окружающего света подбирается автоматически по времени суток.
{/* Туман */}
Туман
{selection.fogEnabled && ( <>
Плотность {(selection.fogDensity || 0.005).toFixed(3)}
props.onSetLightingProps?.({ fogDensity: parseFloat(e.target.value) })} style={{ width: '100%' }} />
Цвет props.onSetLightingProps?.({ fogColor: e.target.value })} style={{ width: 36, height: 24, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }} />
)}
{/* Тени */}
Тени
{[ { id: 'off', label: 'Выкл' }, { id: 'hard', label: 'Жёст' }, { id: 'soft', label: 'Мягк' }, { id: 'medium', label: 'Сред' }, { id: 'high', label: 'Выс' }, ].map(o => ( ))}
Средние/Высокие — каскадные тени (CSM, тяжелее по FPS).
{/* SSAO — контактные тени */}
Контактные тени (SSAO)
); } // === Тип «sound» — управление амбиентом и музыкой === if (selection.type === 'sound') { return (
Звук
Фоновый амбиент и музыка
Амбиент
{(selection.ambientPresets || []).map(p => ( ))}
Музыка
{(selection.musicPresets || []).map(p => ( ))}
); } // === Тип «player» — скин персонажа (выбор — только через магазин) === if (selection.type === 'player') { return (
Скин игрока
Модель персонажа в режиме игры
Скин
Скин персонажа выбирается игроком в магазине скинов. В игре каждый увидит героя в своём купленном скине — менять его в редакторе нельзя.
); } // === Тип «playerProps» — поведение игрока (прыжок, прицел) === if (selection.type === 'playerProps') { const jumpPower = Number.isFinite(selection.jumpPower) ? selection.jumpPower : 1; const crosshair = selection.crosshair || 'none'; return (
Свойства игрока
Прыжок, прицел и поведение
Сила прыжка
Множитель ×{jumpPower.toFixed(2)}
props.onSetPlayerProps?.({ jumpPower: parseFloat(e.target.value) })} style={{ width: '100%' }} />
1.0 = базовый прыжок, 1.5 = выше, 2.0 = очень высокий.
Прицел в Play
{[ { id: 'none', label: 'Нет' }, { id: 'dot', label: '·' }, { id: 'cross', label: '+' }, { id: 'circle', label: '○' }, ].map(c => ( ))}
В скриптах: game.player.crosshair = 'cross';
); } // === Тип «gui» — UI-элемент (Frame/Text/Button/Image) === if (selection.type === 'gui') { const d = selection.data || {}; const t = d.type; const setProp = (patch) => props.onSetGuiProps?.(d.id, patch); const isText = t === 'text' || t === 'button'; const isImage = t === 'image'; const isTextbox = t === 'textbox'; // Контейнеры (frame/scroll) умеют авто-раскладку детей. const isContainer = t === 'frame' || t === 'scroll'; const typeLabel = t === 'frame' ? 'Контейнер' : t === 'scroll' ? 'Список' : t === 'text' ? 'Надпись' : t === 'button' ? 'Кнопка' : t === 'textbox' ? 'Поле ввода' : t === 'image' ? 'Картинка' : 'Интерфейс'; const typeIconName = t === 'frame' ? 'square' : t === 'scroll' ? 'align-left' : t === 'text' ? 'type' : t === 'button' ? 'circle' : t === 'textbox' ? 'type' : t === 'image' ? 'image' : 'palette'; return (
{typeLabel}
setProp({ name: e.target.value })} className={cl.nameInput} placeholder="Имя элемента" />
Позиция и размер (% экрана)
X setProp({ x: Math.round(parseFloat(e.target.value) || 0) })} className={cl.numInput} /> {Math.round(d.x ?? 50)}% Y setProp({ y: Math.round(parseFloat(e.target.value) || 0) })} className={cl.numInput} /> {Math.round(d.y ?? 50)}% Ш setProp({ w: Math.max(1, Math.round(parseFloat(e.target.value) || 1)) })} className={cl.numInput} /> {Math.round(d.w ?? 20)}% В setProp({ h: Math.max(1, Math.round(parseFloat(e.target.value) || 1)) })} className={cl.numInput} /> {Math.round(d.h ?? 10)}%
Якорь (точка отсчёта)
{/* Родитель — экран или другой контейнер. Координаты считаются относительно него. */}
Родитель
Координаты теперь относительно родителя.
{isText && (
Текст