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 (
{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: 'с' })}
>
)}
onAddWeaponToInventory?.(selection.modelTypeId, merged)}
className={cl.smallBtn}
style={{
marginTop: 10,
width: '100%',
padding: '8px',
background: 'rgba(102, 176, 74, 0.18)',
color: '#7bb84e',
border: '1px solid #5a8c3e',
borderRadius: 4,
fontWeight: 600,
cursor: 'pointer',
}}
>
Добавить в инвентарь игрока
>
)}
Изменения вступают в силу после старта игры ( ).
);
}
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 (
Освещение
Свет, время суток, туман, тени
{/* Время суток */}
Время суток
{[
{ 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 => (
props.onSetLightingProps?.({ envPreset: t.id })}
>
{t.label}
))}
{(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' }}
/>
)}
{/* Солнце */}
{/* Окружающий свет */}
Окружающий свет
Цвет окружающего света подбирается автоматически по времени суток.
{/* Туман */}
{/* Тени */}
Тени
{[
{ id: 'off', label: 'Выкл' },
{ id: 'hard', label: 'Жёст' },
{ id: 'soft', label: 'Мягк' },
{ id: 'medium', label: 'Сред' },
{ id: 'high', label: 'Выс' },
].map(o => (
props.onSetLightingProps?.({ shadowQuality: o.id })}
>
{o.label}
))}
Средние/Высокие — каскадные тени (CSM, тяжелее по FPS).
{/* SSAO — контактные тени */}
);
}
// === Тип «sound» — управление амбиентом и музыкой ===
if (selection.type === 'sound') {
return (
{isText && (
)}
{/* Авто-раскладка детей — для контейнеров (Фаза 5.3). */}
{isContainer && (
Раскладка детей
{[
{ id: 'none', name: 'Свободно' },
{ id: 'vertical', name: 'В столбик' },
{ id: 'horizontal', name: 'В строку' },
].map(opt => (
setProp({ layout: opt.id })}
style={{
fontWeight: (d.layout || 'none') === opt.id ? 700 : 400,
background: (d.layout || 'none') === opt.id ? 'var(--accent)' : undefined,
color: (d.layout || 'none') === opt.id ? '#fff' : undefined,
}}
>
{opt.name}
))}
{(d.layout === 'vertical' || d.layout === 'horizontal') && (
Отступ
setProp({ layoutGap: parseFloat(e.target.value) || 0 })}
className={cl.numInput}
style={{ width: '100%' }}
/>
Внутр. поле
setProp({ layoutPad: parseFloat(e.target.value) || 0 })}
className={cl.numInput}
style={{ width: '100%' }}
/>
)}
{t === 'scroll' && (
Список прокручивается колесом мыши в игре.
)}
)}
{isImage && (
)}
{isTextbox && (
)}
Фон и граница
Цвет фона
setProp({ bgColor: e.target.value })}
style={{ height: 28, width: '100%', padding: 0, border: 0, background: 'transparent' }}
/>
Прозрачность
setProp({ bgOpacity: parseFloat(e.target.value) })}
/>
Цвет границы
setProp({ borderColor: e.target.value })}
style={{ height: 28, width: '100%', padding: 0, border: 0, background: 'transparent' }}
/>
Толщина гр.
setProp({ borderWidth: parseInt(e.target.value, 10) || 0 })}
className={cl.numInput} />
Скругление
setProp({ borderRadius: parseInt(e.target.value, 10) || 0 })}
className={cl.numInput} />
setProp({ bgColor: 'transparent', bgOpacity: 0 })}
className={cl.smallBtn}
style={{ alignSelf: 'flex-end' }}
title="Сделать фон полностью прозрачным"
>
Без фона
{/* Тень под элементом (Фаза 5.4). */}
setProp({ shadow: e.target.checked })}
/>
Тень под элементом
{/* === Задача 03: Градиент фона === */}
Градиент фона
setProp({
bgGradient: e.target.checked
? { stops: [d.bgColor || '#ff5a5a', '#ff8a3d'], angle: 90 }
: null,
})}
/>
Включить градиент (заменяет фон)
{d.bgGradient && (
)}
{/* === Задача 03: Обводка текста, поворот, scale === */}
{(isText) && (
)}
{/* === Задача 03: Бейдж в углу === */}
{/* === Задача 03: Hover/Active (только для button) === */}
{t === 'button' && (
)}
{/* === Задача 03: Анимация-пресет === */}
Анимация (в Play)
setProp({ animationPreset: e.target.value })}
style={{ width: '100%', padding: '4px 6px' }}>
Без анимации
Пульсация (1.0 ↔ 1.1)
Вращение (360° за 3с)
Качание (-8° ↔ +8°)
Подсветка (opacity 1 ↔ 0.7)
Прыжок (y -1 ↔ 0)
setProp({ visible: e.target.checked })}
/>
Виден на сцене
{
if (window.confirm('Удалить элемент?')) props.onDeleteGui?.(d.id);
}}
className={cl.smallBtn}
style={{
width: '100%',
background: 'rgba(168, 50, 50, 0.18)',
color: '#e88a8a',
border: '1px solid rgba(168, 50, 50, 0.4)',
}}
>
Удалить элемент
);
}
if (selection.type === 'floor') {
const enabled = selection.enabled !== false;
const ws = selection.worldSize || 80;
return (
{/* Заголовок объекта */}
{isSpawn
?
: isBlock
?
: isPrimitive
?
: }
{isSpawn ? 'Точка спавна'
: isBlock ? 'Блок'
: isPrimitive ? (
primitiveType?.kind === 'trigger' ? 'Триггер' :
primitiveType?.kind === 'checkpoint' ? 'Чек-поинт' :
'Примитив'
)
: 'Модель'}
{isSpawn ? 'Где появляется игрок'
: isBlock ? blockType?.name
: isPrimitive ? primitiveType?.name
: isUserModel ? `Моя модель #${selection.userModelId}`
: modelType?.name || '—'}
{/* Позиция */}
{/* Свойства блока — Сталкивается / Видимый. Жидкости пропускаем. */}
{isBlock && !isLiquid && (
)}
{/* Подсказка для жидкостей */}
{isLiquid && (
Жидкость
{selection.blockTypeId === 'water'
? 'Через воду можно проходить и плавать. В режиме игры персонаж принимает горизонтальную позу.'
: 'Лава наносит урон при касании (скоро). Через неё можно проходить.'}
)}
{/* Поворот / масштаб — только для моделей (не для блоков, spawn, primitive) */}
{isModel && (
<>
{/* Свойства модели */}
{/* Внешний вид: прозрачность и тинт.
Скрываем для user-моделей — там per-vertex color не
поддерживает opacity/tint через StandardMaterial.alpha. */}
{!isUserModel && (
)}
{/* === GAMEPLAY — настройки врагов, спавнеров, оружия === */}
{selection.gameplay && (
)}
>
)}
{/* Размер и материал — только для примитивов */}
{isPrimitive && (
<>
{/* Цвет и материал — только для геометрических примитивов */}
{primitiveType?.kind === 'geometry' && (
<>
Материал
{PRIMITIVE_MATERIALS.map(m => (
{
setLocalMaterial(m.id);
onSetPrimitiveProps?.({ material: m.id });
}}
style={{
fontWeight: localMaterial === m.id ? 700 : 400,
background: localMaterial === m.id ? 'var(--accent)' : undefined,
color: localMaterial === m.id ? '#fff' : undefined,
}}
>
{m.name}
))}
{/* Размер studs — плотность лего-кружков (только для material studs) */}
{localMaterial === 'studs' && (
Размер studs
{[
{ label: 'Крупные', d: 0.5 },
{ label: 'Средние', d: 1 },
{ label: 'Мелкие', d: 2 },
{ label: 'Меньше', d: 4 },
].map(opt => (
{
setLocalStudDensity(opt.d);
onSetPrimitiveProps?.({ studDensity: opt.d });
}}
style={{
fontWeight: localStudDensity === opt.d ? 700 : 400,
background: localStudDensity === opt.d ? 'var(--accent)' : undefined,
color: localStudDensity === opt.d ? '#fff' : undefined,
}}
>
{opt.label}
))}
)}
{/* Подпись над объектом (задача 10) */}
{(() => {
const L = localLabel || { enabled: false, binding: 'static', params: {}, preset: 'gameui', height: 2.5 };
const applyLabel = (patch) => {
const next = { ...L, ...patch, params: { ...(L.params || {}), ...(patch.params || {}) } };
setLocalLabel(next);
onSetPrimitiveProps?.({ label: next });
};
const inp = { background: 'var(--bg-input,#2a1f15)', color: 'var(--text-primary,#f0e6d8)', border: '1px solid var(--border,#5a4a3a)', borderRadius: 4, padding: '3px 6px', fontSize: 12, width: '100%' };
return (
);
})()}
{/* Текстура — своя картинка на гранях примитива */}
Текстура
onSetPrimitiveProps?.({ textureAsset: id })}
onUpload={onUploadAsset}
onRemove={onRemoveAsset}
/>
>
)}
{/* Лампа — цвет, яркость, радиус свечения */}
{primitiveType?.kind === 'light' && (
)}
{/* Эмиттер частиц — выбор эффекта + цвет */}
{primitiveType?.kind === 'emitter' && (
Эффект частиц
{[
{ id: 'fire', name: 'Огонь' },
{ id: 'smoke', name: 'Дым' },
{ id: 'sparks', name: 'Искры' },
{ id: 'magic', name: 'Магия' },
].map(eff => (
onSetPrimitiveProps?.({ effect: eff.id })}
style={{
fontWeight: (selection.effect || 'fire') === eff.id ? 700 : 400,
background: (selection.effect || 'fire') === eff.id ? 'var(--accent)' : undefined,
color: (selection.effect || 'fire') === eff.id ? '#fff' : undefined,
}}
>
{eff.name}
))}
Цвет частиц
{
setLocalColor(e.target.value);
onSetPrimitiveProps?.({ color: e.target.value });
}}
style={{ width: 36, height: 24, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }}
/>
)}
{/* 3D-табличка (billboard) — кнопка «Редактировать табличку» */}
{primitiveType?.kind === 'billboard' && (
Контент таблички
onEditBillboard?.()}
style={{ width: '100%', padding: '8px 12px', fontWeight: 600 }}
>
Редактировать табличку…
{(selection.template || 'shop-item')} · {(selection.face || 'camera') === 'camera' ? 'смотрит на камеру' : 'фиксирована'}
)}
{/* Стрелка-указатель (задача 08) — пресет, источник, цель, дуга */}
{primitiveType?.kind === 'pointer' && (
Стрелка-указатель
{/* Пресет внешнего вида */}
Стиль
{[
{ id: 'guide', name: 'Красная' },
{ id: 'quest', name: 'Жёлтая' },
{ id: 'danger', name: 'Молнии' },
{ id: 'gift', name: 'Радуга' },
].map(pr => (
onSetPrimitiveProps?.({ pointerPreset: pr.id })}
style={{
fontWeight: (selection.pointerPreset || 'guide') === pr.id ? 700 : 400,
background: (selection.pointerPreset || 'guide') === pr.id ? 'var(--accent)' : undefined,
color: (selection.pointerPreset || 'guide') === pr.id ? '#fff' : undefined,
}}
>
{pr.name}
))}
{/* Откуда */}
Откуда
onSetPrimitiveProps?.({ pointerFrom: e.target.value })}
style={{ flex: 1.4 }}
>
Игрок
Эта точка
{/* Куда — id объекта-цели или эта точка */}
Куда (id)
onSetPrimitiveProps?.({ pointerTo: e.target.value })}
style={{ flex: 1.4, fontSize: 12, padding: '4px 6px' }}
/>
Пусто = стрелка указывает вперёд (+4 по Z). Можно вписать id примитива/модели или «player».
{/* Скорость анимации текстуры */}
{/* Дугой */}
onSetPrimitiveProps?.({ curved: e.target.checked })} />
Изогнуть дугой
{selection.curved && (
)}
Стрелка появляется при запуске игры (▶). В редакторе показан маркер позиции.
)}
{/* Свойства — для всех примитивов */}
>
)}
{/* Физика — для всех типов кроме spawn */}
{!isSpawn && !isLiquid && (
Физика
{
setLocalAnchored(e.target.checked);
onSetAnchored?.(e.target.checked);
}}
/>
Заякорен
{localAnchored
? 'Объект неподвижен. В режиме игры стоит на месте.'
: 'Объект падает под гравитацией и реагирует на столкновения.'}
{!localAnchored && (
<>
Вес
setLocalMass(e.target.value)}
onBlur={() => {
const m = parseFloat(localMass);
if (Number.isFinite(m) && m > 0) {
const rounded = Math.round(m * 100) / 100;
onSetMass?.(rounded);
setLocalMass(formatMass(rounded));
} else {
setLocalMass(formatMass(selection.mass ?? 1));
}
}}
/>
Чем больше вес, тем слабее толкается. Мячик 0.1, ящик 1, валун 10.
>
)}
)}
{/* Действия */}
Фокус
{/* Спавн и освещение нельзя удалить */}
{!isSpawn && !isLighting && !isScript && (
Удалить
)}
R — поворот · Del — удалить · Esc — снять выделение
);
};
export default InspectorPanel;