Some checks failed
Движок: LabelManager attachFace (текст плоско на грань примитива, FRONTSIDE без зеркала), tilt, 5 пресетов, richText; GameRuntime scene.move/scene.rotate для моделей и примитивов; ScriptSandboxWorker obj.move/obj.rotate в Instance- proxy; InspectorPanel настройки label. Вики: карточка #57 guide-dynamic-label (Часовая башня) + полная статья-урок с разбором attachFace/obj.move/format.money. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2216 lines
130 KiB
JavaScript
2216 lines
130 KiB
JavaScript
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 (
|
||
<div>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||
style={{ display: 'none' }}
|
||
onChange={handleFile}
|
||
/>
|
||
{/* Сетка превью картинок библиотеки */}
|
||
<div style={{
|
||
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 4,
|
||
marginBottom: 6,
|
||
}}>
|
||
{/* Кнопка «нет картинки» */}
|
||
<button
|
||
type="button"
|
||
title="Без картинки"
|
||
onClick={() => onPick?.(null)}
|
||
style={{
|
||
aspectRatio: '1', borderRadius: 4, cursor: 'pointer',
|
||
border: !value ? '2px solid var(--accent)' : '1px solid var(--border)',
|
||
background: 'var(--bg-light)', color: 'var(--text-dim)',
|
||
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
}}
|
||
>∅</button>
|
||
{assetList.map(a => (
|
||
<div key={a.id} style={{ position: 'relative' }}>
|
||
<button
|
||
type="button"
|
||
title={a.name}
|
||
onClick={() => onPick?.(a.id)}
|
||
style={{
|
||
width: '100%', aspectRatio: '1', borderRadius: 4, cursor: 'pointer',
|
||
border: value === a.id ? '2px solid var(--accent)' : '1px solid var(--border, #4a3a2a)',
|
||
background: '#0008', padding: 0, overflow: 'hidden',
|
||
}}
|
||
>
|
||
<img
|
||
src={a.dataUrl}
|
||
alt={a.name}
|
||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||
/>
|
||
</button>
|
||
{onRemove && (
|
||
<button
|
||
type="button"
|
||
title="Удалить картинку из проекта"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (window.confirm(`Удалить картинку «${a.name}»? Она пропадёт со всех объектов.`)) {
|
||
onRemove(a.id);
|
||
}
|
||
}}
|
||
style={{
|
||
position: 'absolute', top: -4, right: -4,
|
||
width: 16, height: 16, borderRadius: 8,
|
||
border: 'none', background: '#c0392b', color: '#fff',
|
||
fontSize: 10, lineHeight: '16px', cursor: 'pointer', padding: 0,
|
||
}}
|
||
>×</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => fileRef.current?.click()}
|
||
disabled={busy}
|
||
style={{
|
||
width: '100%', padding: '6px 8px', borderRadius: 4,
|
||
border: '1px dashed var(--accent)', background: 'transparent',
|
||
color: 'var(--accent)', cursor: busy ? 'wait' : 'pointer', fontSize: 12,
|
||
}}
|
||
>
|
||
{busy ? 'Загрузка…' : '+ Загрузить картинку'}
|
||
</button>
|
||
{error && (
|
||
<div style={{ color: '#e07a5a', fontSize: 11, marginTop: 4 }}>{error}</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<label style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
<span style={{ fontSize: 11, opacity: 0.75 }}>
|
||
{label}
|
||
{opts.unit && <span style={{ opacity: 0.5 }}> ({opts.unit})</span>}
|
||
</span>
|
||
<input
|
||
type="number"
|
||
value={val ?? ''}
|
||
onChange={(e) => {
|
||
const v = e.target.value === '' ? defaults[key] : parseFloat(e.target.value);
|
||
if (Number.isFinite(v)) setParam(key, v);
|
||
}}
|
||
min={opts.min}
|
||
max={opts.max}
|
||
step={opts.step ?? 1}
|
||
className={cl.numInput}
|
||
/>
|
||
</label>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}>
|
||
{gp.isZombie ? <><Icon name="zombie" size={12} /> Параметры врага</>
|
||
: gp.isZombieSpawner ? <><Icon name="spawner" size={12} /> Параметры спавнера</>
|
||
: gp.isWeapon ? (gp.weaponKind === 'melee'
|
||
? <><Icon name="sword" size={12} /> Параметры оружия</>
|
||
: <><Icon name="crosshair" size={12} /> Параметры оружия</>)
|
||
: <><Icon name="settings" size={12} /> Параметры</>}
|
||
</div>
|
||
<div style={{
|
||
fontSize: 11, color: 'var(--text-dim)', fontStyle: 'italic',
|
||
marginBottom: 8, lineHeight: 1.4,
|
||
}}>
|
||
{gp.description}
|
||
</div>
|
||
|
||
{gp.isZombie && (
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
|
||
{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: 'м' })}
|
||
</div>
|
||
)}
|
||
|
||
{gp.isZombieSpawner && (
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
|
||
{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 })}
|
||
</div>
|
||
)}
|
||
|
||
{gp.isWeapon && (
|
||
<>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
|
||
{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: 'с' })}
|
||
</>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={() => 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',
|
||
}}
|
||
>
|
||
<Icon name="add" size={13} /> Добавить в инвентарь игрока
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text-dim)', fontStyle: 'italic' }}>
|
||
Изменения вступают в силу после старта игры (<Icon name="play" size={12} />).
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className={cl.empty}>
|
||
Выберите объект чтобы увидеть его свойства
|
||
<div className={cl.emptyHint} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<Icon name="cursor" size={13} /> Инструмент «Выделить» + ЛКМ или клик в Иерархии
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className={cl.inspector}>
|
||
<div className={cl.headerCard}>
|
||
<div className={cl.iconBig}><Icon name="sun" size={32} strokeWidth={1.8} /></div>
|
||
<div className={cl.headerText}>
|
||
<div className={cl.typeLabel}>Освещение</div>
|
||
<div className={cl.typeName}>Свет, время суток, туман, тени</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Время суток */}
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="clock" size={12} /> Время суток</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginBottom: 8 }}>
|
||
{[
|
||
{ 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 => (
|
||
<button
|
||
key={t.id}
|
||
className={cl.smallBtn}
|
||
style={{
|
||
background: (selection.envPreset || 'day') === t.id ? 'var(--accent-bright, #2E7D32)' : undefined,
|
||
color: (selection.envPreset || 'day') === t.id ? '#fff' : undefined,
|
||
fontSize: 11,
|
||
display: 'inline-flex', alignItems: 'center', gap: 4, justifyContent: 'center',
|
||
}}
|
||
onClick={() => props.onSetLightingProps?.({ envPreset: t.id })}
|
||
>
|
||
<Icon name={t.iconName} size={12} /> {t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{(selection.envPreset || 'day') === 'cycle' && (
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '6px 8px', alignItems: 'center', fontSize: 12 }}>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="sun" size={12} /> День (мин):</span>
|
||
<input
|
||
type="number" min="0.5" step="0.5"
|
||
value={selection.dayDurationMin ?? 5}
|
||
onChange={(e) => props.onSetLightingProps?.({ dayDurationMin: parseFloat(e.target.value) || 0 })}
|
||
style={{ width: 60, padding: '4px 6px', background: '#0f0a05', border: '1px solid #5a4a3a', borderRadius: 3, color: '#f0e6d8' }}
|
||
/>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="moon" size={12} /> Ночь (мин):</span>
|
||
<input
|
||
type="number" min="0.5" step="0.5"
|
||
value={selection.nightDurationMin ?? 3}
|
||
onChange={(e) => props.onSetLightingProps?.({ nightDurationMin: parseFloat(e.target.value) || 0 })}
|
||
style={{ width: 60, padding: '4px 6px', background: '#0f0a05', border: '1px solid #5a4a3a', borderRadius: 3, color: '#f0e6d8' }}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Солнце */}
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="sun" size={12} /> Солнце</div>
|
||
<div style={{ padding: '4px 0' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
|
||
<span>Интенсивность</span>
|
||
<span style={{ opacity: 0.6 }}>{(selection.sunIntensity || 0.8).toFixed(2)}</span>
|
||
</div>
|
||
<input
|
||
type="range" min="0" max="2" step="0.05"
|
||
value={selection.sunIntensity ?? 0.8}
|
||
onChange={(e) => props.onSetLightingProps?.({ sunIntensity: parseFloat(e.target.value) })}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Окружающий свет */}
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="cloud" size={12} /> Окружающий свет</div>
|
||
<div style={{ padding: '4px 0' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
|
||
<span>Интенсивность</span>
|
||
<span style={{ opacity: 0.6 }}>{(selection.hemiIntensity || 0.65).toFixed(2)}</span>
|
||
</div>
|
||
<input
|
||
type="range" min="0" max="2" step="0.05"
|
||
value={selection.hemiIntensity ?? 0.65}
|
||
onChange={(e) => props.onSetLightingProps?.({ hemiIntensity: parseFloat(e.target.value) })}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-dim)', fontStyle: 'italic', marginTop: 4 }}>
|
||
<Icon name="sparkle" size={11} /> Цвет окружающего света подбирается автоматически по времени суток.
|
||
</div>
|
||
</div>
|
||
|
||
{/* Туман */}
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="fog" size={12} /> Туман</div>
|
||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer', padding: '4px 0' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={selection.fogEnabled || false}
|
||
onChange={(e) => props.onSetLightingProps?.({ fogEnabled: e.target.checked })}
|
||
/>
|
||
<span>Включён</span>
|
||
</label>
|
||
{selection.fogEnabled && (
|
||
<>
|
||
<div style={{ padding: '4px 0' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
|
||
<span>Плотность</span>
|
||
<span style={{ opacity: 0.6 }}>{(selection.fogDensity || 0.005).toFixed(3)}</span>
|
||
</div>
|
||
<input
|
||
type="range" min="0.001" max="0.05" step="0.001"
|
||
value={selection.fogDensity ?? 0.005}
|
||
onChange={(e) => props.onSetLightingProps?.({ fogDensity: parseFloat(e.target.value) })}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>
|
||
<span style={{ fontSize: 12, flex: 1 }}>Цвет</span>
|
||
<input
|
||
type="color"
|
||
value={selection.fogColor || '#b0c8e6'}
|
||
onChange={(e) => props.onSetLightingProps?.({ fogColor: e.target.value })}
|
||
style={{ width: 36, height: 24, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Тени */}
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="moon" size={12} /> Тени</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 4 }}>
|
||
{[
|
||
{ id: 'off', label: 'Выкл' },
|
||
{ id: 'hard', label: 'Жёст' },
|
||
{ id: 'soft', label: 'Мягк' },
|
||
{ id: 'medium', label: 'Сред' },
|
||
{ id: 'high', label: 'Выс' },
|
||
].map(o => (
|
||
<button
|
||
key={o.id}
|
||
className={cl.smallBtn}
|
||
style={{
|
||
background: (selection.shadowQuality || 'soft') === o.id ? 'var(--accent-bright, #2E7D32)' : undefined,
|
||
color: (selection.shadowQuality || 'soft') === o.id ? '#fff' : undefined,
|
||
}}
|
||
onClick={() => props.onSetLightingProps?.({ shadowQuality: o.id })}
|
||
>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div style={{ fontSize: 11, opacity: 0.7, marginTop: 4 }}>
|
||
Средние/Высокие — каскадные тени (CSM, тяжелее по FPS).
|
||
</div>
|
||
</div>
|
||
|
||
{/* SSAO — контактные тени */}
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="moon" size={12} /> Контактные тени (SSAO)</div>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={!!selection.ssaoEnabled}
|
||
onChange={(e) => props.onSetLightingProps?.({ ssaoEnabled: e.target.checked })}
|
||
/>
|
||
<span>Тёмные углы и стыки (-15% FPS)</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// === Тип «sound» — управление амбиентом и музыкой ===
|
||
if (selection.type === 'sound') {
|
||
return (
|
||
<div className={cl.inspector}>
|
||
<div className={cl.headerCard}>
|
||
<div className={cl.iconBig}><Icon name="music" size={32} strokeWidth={1.8} /></div>
|
||
<div className={cl.headerText}>
|
||
<div className={cl.typeLabel}>Звук</div>
|
||
<div className={cl.typeName}>Фоновый амбиент и музыка</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="tree" size={12} /> Амбиент</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4 }}>
|
||
{(selection.ambientPresets || []).map(p => (
|
||
<button
|
||
key={p.id}
|
||
className={cl.smallBtn}
|
||
style={{
|
||
background: (selection.ambientId || 'none') === p.id ? 'var(--accent-bright, #2E7D32)' : undefined,
|
||
color: (selection.ambientId || 'none') === p.id ? '#fff' : undefined,
|
||
fontSize: 11,
|
||
}}
|
||
onClick={() => props.onSetSoundProps?.({ ambientId: p.id })}
|
||
>
|
||
{p.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="music2" size={12} /> Музыка</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4 }}>
|
||
{(selection.musicPresets || []).map(p => (
|
||
<button
|
||
key={p.id}
|
||
className={cl.smallBtn}
|
||
style={{
|
||
background: (selection.musicId || 'none') === p.id ? 'var(--accent-bright, #2E7D32)' : undefined,
|
||
color: (selection.musicId || 'none') === p.id ? '#fff' : undefined,
|
||
fontSize: 11,
|
||
}}
|
||
onClick={() => props.onSetSoundProps?.({ musicId: p.id })}
|
||
>
|
||
{p.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// === Тип «player» — скин персонажа (выбор — только через магазин) ===
|
||
if (selection.type === 'player') {
|
||
return (
|
||
<div className={cl.inspector}>
|
||
<div className={cl.headerCard}>
|
||
<div className={cl.iconBig}><Icon name="user" size={32} strokeWidth={1.8} /></div>
|
||
<div className={cl.headerText}>
|
||
<div className={cl.typeLabel}>Скин игрока</div>
|
||
<div className={cl.typeName}>Модель персонажа в режиме игры</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="user" size={12} /> Скин</div>
|
||
<div style={{
|
||
display: 'flex', gap: 10, alignItems: 'flex-start',
|
||
padding: '12px 12px', borderRadius: 10,
|
||
background: 'var(--bg-mid, #2e2e2e)',
|
||
border: '1px solid var(--border, #3a3a3a)',
|
||
}}>
|
||
<span style={{ flexShrink: 0, color: 'var(--accent, #4f74ff)', display: 'flex' }}>
|
||
<Icon name="info" size={18} />
|
||
</span>
|
||
<div style={{ fontSize: 12, lineHeight: 1.5, color: 'var(--text-dim, #9a9a9e)' }}>
|
||
Скин персонажа выбирается игроком в магазине скинов.
|
||
В игре каждый увидит героя в своём купленном скине —
|
||
менять его в редакторе нельзя.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// === Тип «playerProps» — поведение игрока (прыжок, прицел) ===
|
||
if (selection.type === 'playerProps') {
|
||
const jumpPower = Number.isFinite(selection.jumpPower) ? selection.jumpPower : 1;
|
||
const crosshair = selection.crosshair || 'none';
|
||
return (
|
||
<div className={cl.inspector}>
|
||
<div className={cl.headerCard}>
|
||
<div className={cl.iconBig}><Icon name="settings" size={32} strokeWidth={1.8} /></div>
|
||
<div className={cl.headerText}>
|
||
<div className={cl.typeLabel}>Свойства игрока</div>
|
||
<div className={cl.typeName}>Прыжок, прицел и поведение</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="zap" size={12} /> Сила прыжка</div>
|
||
<div style={{ padding: '4px 0' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
|
||
<span>Множитель</span>
|
||
<span style={{ opacity: 0.6 }}>×{jumpPower.toFixed(2)}</span>
|
||
</div>
|
||
<input
|
||
type="range" min="0.5" max="3" step="0.1"
|
||
value={jumpPower}
|
||
onChange={(e) => props.onSetPlayerProps?.({ jumpPower: parseFloat(e.target.value) })}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-dim)', fontStyle: 'italic' }}>
|
||
1.0 = базовый прыжок, 1.5 = выше, 2.0 = очень высокий.
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="target" size={12} /> Прицел в Play</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 4 }}>
|
||
{[
|
||
{ id: 'none', label: 'Нет' },
|
||
{ id: 'dot', label: '·' },
|
||
{ id: 'cross', label: '+' },
|
||
{ id: 'circle', label: '○' },
|
||
].map(c => (
|
||
<button
|
||
key={c.id}
|
||
className={cl.smallBtn}
|
||
style={{
|
||
background: crosshair === c.id ? 'var(--accent-bright, #2E7D32)' : undefined,
|
||
color: crosshair === c.id ? '#fff' : undefined,
|
||
fontSize: c.id === 'none' ? 11 : 18,
|
||
fontWeight: c.id === 'none' ? 600 : 400,
|
||
padding: '6px',
|
||
}}
|
||
onClick={() => props.onSetPlayerProps?.({ crosshair: c.id })}
|
||
>
|
||
{c.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-dim)', fontStyle: 'italic', marginTop: 6 }}>
|
||
В скриптах: <code>game.player.crosshair = 'cross';</code>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// === Тип «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 (
|
||
<div className={cl.inspector}>
|
||
<div className={cl.headerCard}>
|
||
<div className={cl.iconBig}><Icon name={typeIconName} size={32} strokeWidth={1.8} /></div>
|
||
<div className={cl.headerText}>
|
||
<div className={cl.typeLabel}>{typeLabel}</div>
|
||
<input
|
||
type="text"
|
||
value={d.name || ''}
|
||
onChange={(e) => setProp({ name: e.target.value })}
|
||
className={cl.nameInput}
|
||
placeholder="Имя элемента"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="ruler" size={12} /> Позиция и размер (% экрана)</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr 1fr', gap: 4, alignItems: 'center', fontSize: 12 }}>
|
||
<span>X</span>
|
||
<input type="number" step="1" value={Math.round(d.x ?? 50)}
|
||
onChange={(e) => setProp({ x: Math.round(parseFloat(e.target.value) || 0) })}
|
||
className={cl.numInput} />
|
||
<span style={{ opacity: 0.6, fontSize: 10 }}>{Math.round(d.x ?? 50)}%</span>
|
||
|
||
<span>Y</span>
|
||
<input type="number" step="1" value={Math.round(d.y ?? 50)}
|
||
onChange={(e) => setProp({ y: Math.round(parseFloat(e.target.value) || 0) })}
|
||
className={cl.numInput} />
|
||
<span style={{ opacity: 0.6, fontSize: 10 }}>{Math.round(d.y ?? 50)}%</span>
|
||
|
||
<span>Ш</span>
|
||
<input type="number" step="1" min="1" value={Math.round(d.w ?? 20)}
|
||
onChange={(e) => setProp({ w: Math.max(1, Math.round(parseFloat(e.target.value) || 1)) })}
|
||
className={cl.numInput} />
|
||
<span style={{ opacity: 0.6, fontSize: 10 }}>{Math.round(d.w ?? 20)}%</span>
|
||
|
||
<span>В</span>
|
||
<input type="number" step="1" min="1" value={Math.round(d.h ?? 10)}
|
||
onChange={(e) => setProp({ h: Math.max(1, Math.round(parseFloat(e.target.value) || 1)) })}
|
||
className={cl.numInput} />
|
||
<span style={{ opacity: 0.6, fontSize: 10 }}>{Math.round(d.h ?? 10)}%</span>
|
||
</div>
|
||
<div style={{ marginTop: 6 }}>
|
||
<div style={{ fontSize: 11, opacity: 0.7, marginBottom: 4 }}>Якорь (точка отсчёта)</div>
|
||
<select
|
||
value={d.anchor || 'center'}
|
||
onChange={(e) => setProp({ anchor: e.target.value })}
|
||
className={cl.numInput}
|
||
style={{ width: '100%' }}
|
||
>
|
||
<option value="center">Центр</option>
|
||
<option value="top-left">Верх-лево</option>
|
||
<option value="top-right">Верх-право</option>
|
||
<option value="bottom-left">Низ-лево</option>
|
||
<option value="bottom-right">Низ-право</option>
|
||
</select>
|
||
</div>
|
||
{/* Родитель — экран или другой контейнер.
|
||
Координаты считаются относительно него. */}
|
||
<div style={{ marginTop: 6 }}>
|
||
<div style={{ fontSize: 11, opacity: 0.7, marginBottom: 4 }}>Родитель</div>
|
||
<select
|
||
value={d.parentId || ''}
|
||
onChange={(e) => setProp({ parentId: e.target.value || null })}
|
||
className={cl.numInput}
|
||
style={{ width: '100%' }}
|
||
>
|
||
<option value="">Экран (корень)</option>
|
||
{guiElements
|
||
.filter(g => g.type === 'frame' && g.id !== d.id && !_isAncestorOf(guiElements, d.id, g.id))
|
||
.map(g => (
|
||
<option key={g.id} value={g.id}>{g.name}</option>
|
||
))}
|
||
</select>
|
||
<div style={{ fontSize: 10, color: 'var(--text-dim)', fontStyle: 'italic', marginTop: 2 }}>
|
||
Координаты теперь относительно родителя.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{isText && (
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="rename" size={12} /> Текст</div>
|
||
<textarea
|
||
value={d.text || ''}
|
||
onChange={(e) => setProp({ text: e.target.value })}
|
||
rows={2}
|
||
style={{
|
||
width: '100%', fontFamily: 'inherit', fontSize: 13,
|
||
padding: 6, background: 'var(--bg-input, #2a1f15)',
|
||
color: 'var(--text-primary, #f0e6d8)',
|
||
border: '1px solid var(--border, #5a4a3a)', borderRadius: 4, resize: 'vertical',
|
||
}}
|
||
/>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4, marginTop: 6, fontSize: 12 }}>
|
||
<label style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
Цвет текста
|
||
<input type="color"
|
||
value={d.textColor || '#f0e6d8'}
|
||
onChange={(e) => setProp({ textColor: e.target.value })}
|
||
style={{ height: 28, width: '100%', padding: 0, border: 0, background: 'transparent' }}
|
||
/>
|
||
</label>
|
||
<label style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
Размер
|
||
<input type="number" min="6" max="80" value={d.textSize ?? 16}
|
||
onChange={(e) => setProp({ textSize: parseInt(e.target.value, 10) || 16 })}
|
||
className={cl.numInput} />
|
||
</label>
|
||
<label style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
Выравнивание
|
||
<select value={d.textAlign || 'center'}
|
||
onChange={(e) => setProp({ textAlign: e.target.value })}
|
||
className={cl.numInput}>
|
||
<option value="left">Слева</option>
|
||
<option value="center">По центру</option>
|
||
<option value="right">Справа</option>
|
||
</select>
|
||
</label>
|
||
<label style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
Жирность
|
||
<select value={d.fontWeight || 500}
|
||
onChange={(e) => setProp({ fontWeight: parseInt(e.target.value, 10) })}
|
||
className={cl.numInput}>
|
||
<option value={300}>Тонкий</option>
|
||
<option value={500}>Обычный</option>
|
||
<option value={700}>Жирный</option>
|
||
<option value={900}>Очень жирный</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Авто-раскладка детей — для контейнеров (Фаза 5.3). */}
|
||
{isContainer && (
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}>
|
||
<Icon name="align-left" size={12} /> Раскладка детей
|
||
</div>
|
||
<div className={cl.row3}>
|
||
{[
|
||
{ id: 'none', name: 'Свободно' },
|
||
{ id: 'vertical', name: 'В столбик' },
|
||
{ id: 'horizontal', name: 'В строку' },
|
||
].map(opt => (
|
||
<button
|
||
key={opt.id}
|
||
type="button"
|
||
className={cl.smallBtn}
|
||
onClick={() => 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}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{(d.layout === 'vertical' || d.layout === 'horizontal') && (
|
||
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
|
||
<label style={{ flex: 1, fontSize: 11 }}>
|
||
Отступ
|
||
<input
|
||
type="number" min="0" max="20" step="0.5"
|
||
value={d.layoutGap ?? 2}
|
||
onChange={(e) => setProp({ layoutGap: parseFloat(e.target.value) || 0 })}
|
||
className={cl.numInput}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</label>
|
||
<label style={{ flex: 1, fontSize: 11 }}>
|
||
Внутр. поле
|
||
<input
|
||
type="number" min="0" max="20" step="0.5"
|
||
value={d.layoutPad ?? 3}
|
||
onChange={(e) => setProp({ layoutPad: parseFloat(e.target.value) || 0 })}
|
||
className={cl.numInput}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</label>
|
||
</div>
|
||
)}
|
||
{t === 'scroll' && (
|
||
<div style={{ fontSize: 11, color: 'var(--text-dim)', fontStyle: 'italic', marginTop: 6 }}>
|
||
Список прокручивается колесом мыши в игре.
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{isImage && (
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="image" size={12} /> Картинка</div>
|
||
<AssetPicker
|
||
assetList={assetList}
|
||
value={d.imageAsset || null}
|
||
onPick={(id) => setProp({ imageAsset: id })}
|
||
onUpload={onUploadAsset}
|
||
onRemove={onRemoveAsset}
|
||
/>
|
||
<div style={{ fontSize: 11, color: 'var(--text-dim)', fontStyle: 'italic', margin: '8px 0 4px' }}>
|
||
…или вставь ссылку на картинку:
|
||
</div>
|
||
<input
|
||
type="text"
|
||
value={d.imageUrl || ''}
|
||
onChange={(e) => setProp({ imageUrl: e.target.value })}
|
||
placeholder="URL картинки (https://...)"
|
||
className={cl.numInput}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{isTextbox && (
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="type" size={12} /> Поле ввода</div>
|
||
<div style={{ fontSize: 11, opacity: 0.7, marginBottom: 4 }}>
|
||
Подсказка (серый текст в пустом поле)
|
||
</div>
|
||
<input
|
||
type="text"
|
||
value={d.placeholder || ''}
|
||
onChange={(e) => setProp({ placeholder: e.target.value })}
|
||
placeholder="Введите текст…"
|
||
className={cl.numInput}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
<div style={{ fontSize: 11, opacity: 0.7, margin: '8px 0 4px' }}>
|
||
Начальный текст
|
||
</div>
|
||
<input
|
||
type="text"
|
||
value={d.text || ''}
|
||
onChange={(e) => setProp({ text: e.target.value })}
|
||
placeholder="(пусто)"
|
||
className={cl.numInput}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4, marginTop: 6, fontSize: 12 }}>
|
||
<label style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
Цвет текста
|
||
<input type="color"
|
||
value={d.textColor || '#f0e6d8'}
|
||
onChange={(e) => setProp({ textColor: e.target.value })}
|
||
style={{ height: 28, width: '100%', padding: 0, border: 0, background: 'transparent' }}
|
||
/>
|
||
</label>
|
||
<label style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
Размер
|
||
<input type="number" min="6" max="80" value={d.textSize ?? 16}
|
||
onChange={(e) => setProp({ textSize: parseInt(e.target.value, 10) || 16 })}
|
||
className={cl.numInput} />
|
||
</label>
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-dim)', fontStyle: 'italic', marginTop: 6 }}>
|
||
В скрипте: game.gui.onSubmit(id, fn) — fn получит
|
||
введённый текст когда игрок нажмёт Enter.
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="palette" size={12} /> Фон и граница</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4, fontSize: 12 }}>
|
||
<label style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
Цвет фона
|
||
<input type="color"
|
||
value={d.bgColor && d.bgColor !== 'transparent' ? d.bgColor : '#1f1810'}
|
||
onChange={(e) => setProp({ bgColor: e.target.value })}
|
||
style={{ height: 28, width: '100%', padding: 0, border: 0, background: 'transparent' }}
|
||
/>
|
||
</label>
|
||
<label style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
Прозрачность
|
||
<input type="range" min="0" max="1" step="0.05"
|
||
value={d.bgOpacity ?? 1}
|
||
onChange={(e) => setProp({ bgOpacity: parseFloat(e.target.value) })}
|
||
/>
|
||
</label>
|
||
<label style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
Цвет границы
|
||
<input type="color"
|
||
value={d.borderColor || '#5a4a3a'}
|
||
onChange={(e) => setProp({ borderColor: e.target.value })}
|
||
style={{ height: 28, width: '100%', padding: 0, border: 0, background: 'transparent' }}
|
||
/>
|
||
</label>
|
||
<label style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
Толщина гр.
|
||
<input type="number" min="0" max="10" value={d.borderWidth ?? 0}
|
||
onChange={(e) => setProp({ borderWidth: parseInt(e.target.value, 10) || 0 })}
|
||
className={cl.numInput} />
|
||
</label>
|
||
<label style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
Скругление
|
||
<input type="number" min="0" max="40" value={d.borderRadius ?? 0}
|
||
onChange={(e) => setProp({ borderRadius: parseInt(e.target.value, 10) || 0 })}
|
||
className={cl.numInput} />
|
||
</label>
|
||
<button
|
||
onClick={() => setProp({ bgColor: 'transparent', bgOpacity: 0 })}
|
||
className={cl.smallBtn}
|
||
style={{ alignSelf: 'flex-end' }}
|
||
title="Сделать фон полностью прозрачным"
|
||
>
|
||
Без фона
|
||
</button>
|
||
</div>
|
||
{/* Тень под элементом (Фаза 5.4). */}
|
||
<label style={{
|
||
display: 'flex', alignItems: 'center', gap: 6,
|
||
fontSize: 12, cursor: 'pointer', marginTop: 8,
|
||
}}>
|
||
<input
|
||
type="checkbox"
|
||
checked={!!d.shadow}
|
||
onChange={(e) => setProp({ shadow: e.target.checked })}
|
||
/>
|
||
Тень под элементом
|
||
</label>
|
||
</div>
|
||
|
||
{/* === Задача 03: Градиент фона === */}
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="palette" size={12} /> Градиент фона</div>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, cursor: 'pointer' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={!!d.bgGradient}
|
||
onChange={(e) => setProp({
|
||
bgGradient: e.target.checked
|
||
? { stops: [d.bgColor || '#ff5a5a', '#ff8a3d'], angle: 90 }
|
||
: null,
|
||
})}
|
||
/>
|
||
Включить градиент (заменяет фон)
|
||
</label>
|
||
{d.bgGradient && (
|
||
<div style={{ marginTop: 6, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||
{(d.bgGradient.stops || []).map((s, i) => {
|
||
const c = typeof s === 'string' ? s : (s.c || '#000');
|
||
return (
|
||
<div key={i} style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 12 }}>
|
||
<span>#{i + 1}</span>
|
||
<input type="color" value={c}
|
||
onChange={(e) => {
|
||
const stops = [...(d.bgGradient.stops || [])];
|
||
stops[i] = e.target.value;
|
||
setProp({ bgGradient: { ...d.bgGradient, stops } });
|
||
}} />
|
||
{(d.bgGradient.stops || []).length > 2 && (
|
||
<button onClick={() => {
|
||
const stops = [...d.bgGradient.stops]; stops.splice(i, 1);
|
||
setProp({ bgGradient: { ...d.bgGradient, stops } });
|
||
}} className={cl.smallBtn}>×</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
<button className={cl.smallBtn} onClick={() => {
|
||
const stops = [...(d.bgGradient.stops || []), '#80a0ff'];
|
||
setProp({ bgGradient: { ...d.bgGradient, stops } });
|
||
}}>+ цвет</button>
|
||
<label style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 12 }}>
|
||
Угол
|
||
<input type="range" min="0" max="360" value={d.bgGradient.angle ?? 90}
|
||
onChange={(e) => setProp({ bgGradient: { ...d.bgGradient, angle: parseInt(e.target.value, 10) } })}
|
||
style={{ flex: 1 }} />
|
||
<span style={{ width: 36, textAlign: 'right' }}>{d.bgGradient.angle ?? 90}°</span>
|
||
</label>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* === Задача 03: Обводка текста, поворот, scale === */}
|
||
{(isText) && (
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="type" size={12} /> Обводка текста</div>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={!!d.textStroke}
|
||
onChange={(e) => setProp({
|
||
textStroke: e.target.checked ? { color: '#000000', width: 2 } : null,
|
||
})} />
|
||
Контур
|
||
</label>
|
||
{d.textStroke && (
|
||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginTop: 4, fontSize: 12 }}>
|
||
<input type="color" value={d.textStroke.color || '#000000'}
|
||
onChange={(e) => setProp({ textStroke: { ...d.textStroke, color: e.target.value } })} />
|
||
<span>Толщ.</span>
|
||
<input type="number" min="1" max="8" value={d.textStroke.width || 2}
|
||
onChange={(e) => setProp({ textStroke: { ...d.textStroke, width: parseInt(e.target.value, 10) || 1 } })}
|
||
className={cl.numInput} style={{ width: 50 }} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="rotate-cw" size={12} /> Поворот / масштаб</div>
|
||
<label style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 12 }}>
|
||
Поворот
|
||
<input type="range" min="-180" max="180" value={d.rotation ?? 0}
|
||
onChange={(e) => setProp({ rotation: parseInt(e.target.value, 10) })}
|
||
style={{ flex: 1 }} />
|
||
<span style={{ width: 44, textAlign: 'right' }}>{d.rotation ?? 0}°</span>
|
||
</label>
|
||
<label style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 12, marginTop: 4 }}>
|
||
Scale
|
||
<input type="range" min="0.1" max="3" step="0.05" value={d.scaleX ?? 1}
|
||
onChange={(e) => {
|
||
const v = parseFloat(e.target.value) || 1;
|
||
setProp({ scaleX: v, scaleY: v });
|
||
}}
|
||
style={{ flex: 1 }} />
|
||
<span style={{ width: 44, textAlign: 'right' }}>{(d.scaleX ?? 1).toFixed(2)}</span>
|
||
</label>
|
||
</div>
|
||
|
||
{/* === Задача 03: Бейдж в углу === */}
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="award" size={12} /> Бейдж</div>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={!!d.badge}
|
||
onChange={(e) => setProp({
|
||
badge: e.target.checked ? { corner: 'top-right', icon: 'exclamation', color: '#fbbf24' } : null,
|
||
})} />
|
||
Показать бейдж
|
||
</label>
|
||
{d.badge && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginTop: 4, fontSize: 12 }}>
|
||
<label>Иконка
|
||
<select value={d.badge.icon || 'exclamation'}
|
||
onChange={(e) => setProp({ badge: { ...d.badge, icon: e.target.value } })}
|
||
style={{ width: '100%' }}>
|
||
<option value="exclamation">!</option>
|
||
<option value="star">★</option>
|
||
<option value="plus">+</option>
|
||
<option value="new">NEW</option>
|
||
<option value="sale">% (sale)</option>
|
||
</select>
|
||
</label>
|
||
<label>Угол
|
||
<select value={d.badge.corner || 'top-right'}
|
||
onChange={(e) => setProp({ badge: { ...d.badge, corner: e.target.value } })}
|
||
style={{ width: '100%' }}>
|
||
<option value="top-right">↗</option>
|
||
<option value="top-left">↖</option>
|
||
<option value="bottom-right">↘</option>
|
||
<option value="bottom-left">↙</option>
|
||
</select>
|
||
</label>
|
||
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||
Цвет <input type="color" value={d.badge.color || '#fbbf24'}
|
||
onChange={(e) => setProp({ badge: { ...d.badge, color: e.target.value } })} />
|
||
</label>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* === Задача 03: Hover/Active (только для button) === */}
|
||
{t === 'button' && (
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="mouse" size={12} /> Реакция на мышь</div>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={!!d.hover}
|
||
onChange={(e) => setProp({
|
||
hover: e.target.checked ? { scale: 1.08, brightness: 1.15, duration: 0.15 } : null,
|
||
})} />
|
||
Hover (при наведении)
|
||
</label>
|
||
{d.hover && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, marginTop: 4, fontSize: 12 }}>
|
||
<label>Scale {(d.hover.scale ?? 1.08).toFixed(2)}
|
||
<input type="range" min="1" max="1.5" step="0.01" value={d.hover.scale ?? 1.08}
|
||
onChange={(e) => setProp({ hover: { ...d.hover, scale: parseFloat(e.target.value) } })}
|
||
style={{ width: '100%' }} />
|
||
</label>
|
||
<label>Яркость {(d.hover.brightness ?? 1.15).toFixed(2)}
|
||
<input type="range" min="0.7" max="1.5" step="0.01" value={d.hover.brightness ?? 1.15}
|
||
onChange={(e) => setProp({ hover: { ...d.hover, brightness: parseFloat(e.target.value) } })}
|
||
style={{ width: '100%' }} />
|
||
</label>
|
||
</div>
|
||
)}
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, cursor: 'pointer', marginTop: 6 }}>
|
||
<input type="checkbox" checked={!!d.active}
|
||
onChange={(e) => setProp({
|
||
active: e.target.checked ? { scale: 0.94, duration: 0.08 } : null,
|
||
})} />
|
||
Active (при нажатии)
|
||
</label>
|
||
{d.active && (
|
||
<label style={{ fontSize: 12 }}>Scale {(d.active.scale ?? 0.94).toFixed(2)}
|
||
<input type="range" min="0.8" max="1" step="0.01" value={d.active.scale ?? 0.94}
|
||
onChange={(e) => setProp({ active: { ...d.active, scale: parseFloat(e.target.value) } })}
|
||
style={{ width: '100%' }} />
|
||
</label>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* === Задача 03: Анимация-пресет === */}
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="play" size={12} /> Анимация (в Play)</div>
|
||
<select value={d.animationPreset || 'none'}
|
||
onChange={(e) => setProp({ animationPreset: e.target.value })}
|
||
style={{ width: '100%', padding: '4px 6px' }}>
|
||
<option value="none">Без анимации</option>
|
||
<option value="pulse">Пульсация (1.0 ↔ 1.1)</option>
|
||
<option value="rotate">Вращение (360° за 3с)</option>
|
||
<option value="sway">Качание (-8° ↔ +8°)</option>
|
||
<option value="glow">Подсветка (opacity 1 ↔ 0.7)</option>
|
||
<option value="bounce">Прыжок (y -1 ↔ 0)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className={cl.section}>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, cursor: 'pointer' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={d.visible !== false}
|
||
onChange={(e) => setProp({ visible: e.target.checked })}
|
||
/>
|
||
<span>Виден на сцене</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className={cl.section}>
|
||
<button
|
||
onClick={() => {
|
||
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)',
|
||
}}
|
||
>
|
||
<Icon name="delete" size={13} /> Удалить элемент
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (selection.type === 'floor') {
|
||
const enabled = selection.enabled !== false;
|
||
const ws = selection.worldSize || 80;
|
||
return (
|
||
<div className={cl.inspector}>
|
||
<div className={cl.headerCard}>
|
||
<div className={cl.iconBig}><Icon name="square" size={32} strokeWidth={1.8} /></div>
|
||
<div className={cl.headerText}>
|
||
<div className={cl.typeLabel}>Пол</div>
|
||
<div className={cl.typeName}>Плоскость с сеткой в редакторе</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="visible" size={12} /> Видимость</div>
|
||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer', padding: '4px 0' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={enabled}
|
||
onChange={(e) => props.onSetFloorProps?.({ enabled: e.target.checked })}
|
||
/>
|
||
<span>Пол виден</span>
|
||
</label>
|
||
<div style={{ fontSize: 11, color: 'var(--text-dim)', fontStyle: 'italic', marginTop: 4 }}>
|
||
<Icon name="sparkle" size={11} /> При выключенном поле игрок будет падать в пустоту — эффектно для платформера.
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="ruler" size={12} /> Размер</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 4 }}>
|
||
{[50, 100, 200, 500].map(sz => (
|
||
<button
|
||
key={sz}
|
||
className={cl.smallBtn}
|
||
style={{
|
||
background: ws === sz ? 'var(--accent-bright, #2E7D32)' : undefined,
|
||
color: ws === sz ? '#fff' : undefined,
|
||
fontSize: 12,
|
||
}}
|
||
onClick={() => props.onSetFloorProps?.({ worldSize: sz })}
|
||
>
|
||
{sz}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-dim)', fontStyle: 'italic', marginTop: 4 }}>
|
||
Сторона прямоугольного пола в юнитах.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Тип «script» — лёгкий header без редактора кода (код редактируется в табе).
|
||
if (isScript) {
|
||
const targetLabel = (() => {
|
||
const t = selection.target;
|
||
if (!t) return 'Глобальный скрипт';
|
||
if (t.kind === 'block') {
|
||
const r = t.ref || t;
|
||
return `Привязан к блоку (${r.x}, ${r.y}, ${r.z})`;
|
||
}
|
||
const id = t.id ?? t.ref;
|
||
if (t.kind === 'model') return `Привязан к модели #${id}`;
|
||
if (t.kind === 'primitive') return `Привязан к примитиву #${id}`;
|
||
return 'Привязан к объекту';
|
||
})();
|
||
return (
|
||
<div className={cl.inspector}>
|
||
<div className={cl.headerCard}>
|
||
<div className={cl.iconBig}><Icon name="script" size={32} strokeWidth={1.8} /></div>
|
||
<div className={cl.headerText}>
|
||
<div className={cl.typeLabel}>Скрипт</div>
|
||
<div className={cl.typeName}>
|
||
{selection.scriptId === 'demo' ? 'Демо-скрипт' : selection.scriptId}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}>ℹ️ Информация</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-dim)', lineHeight: 1.6 }}>
|
||
{targetLabel}
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-dim)', fontStyle: 'italic', marginTop: 10 }}>
|
||
<Icon name="sparkle" size={11} /> Редактирование — в табе скрипта над сценой.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={cl.inspector}>
|
||
{/* Заголовок объекта */}
|
||
<div className={cl.headerCard}>
|
||
<div className={cl.iconBig}>
|
||
{isSpawn
|
||
? <Icon name="flag" size={32} strokeWidth={1.8} />
|
||
: isBlock
|
||
? <Icon name="boxes" size={32} strokeWidth={1.8} />
|
||
: isPrimitive
|
||
? <Icon name={primitiveType?.icon || 'cube'} size={32} strokeWidth={1.8} />
|
||
: <Icon name="trees" size={32} strokeWidth={1.8} />}
|
||
</div>
|
||
<div className={cl.headerText}>
|
||
<div className={cl.typeLabel}>
|
||
{isSpawn ? 'Точка спавна'
|
||
: isBlock ? 'Блок'
|
||
: isPrimitive ? (
|
||
primitiveType?.kind === 'trigger' ? 'Триггер' :
|
||
primitiveType?.kind === 'checkpoint' ? 'Чек-поинт' :
|
||
'Примитив'
|
||
)
|
||
: 'Модель'}
|
||
</div>
|
||
<div className={cl.typeName}>
|
||
{isSpawn ? 'Где появляется игрок'
|
||
: isBlock ? blockType?.name
|
||
: isPrimitive ? primitiveType?.name
|
||
: isUserModel ? `Моя модель #${selection.userModelId}`
|
||
: modelType?.name || '—'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Позиция */}
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="pin" size={12} /> Позиция</div>
|
||
<div className={cl.row3}>
|
||
<label className={cl.field}>
|
||
<span className={cl.axisLabel} style={{color:'#e85a5a'}}>X</span>
|
||
<input
|
||
type="number"
|
||
step={isBlock ? '1' : '0.1'}
|
||
className={cl.input}
|
||
value={localX}
|
||
onChange={(e) => setLocalX(e.target.value)}
|
||
onBlur={commitPosition}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') commitPosition(); }}
|
||
/>
|
||
</label>
|
||
<label className={cl.field}>
|
||
<span className={cl.axisLabel} style={{color:'#5ae85a'}}>Y</span>
|
||
<input
|
||
type="number"
|
||
step={isBlock ? '1' : '0.1'}
|
||
className={cl.input}
|
||
value={localY}
|
||
onChange={(e) => setLocalY(e.target.value)}
|
||
onBlur={commitPosition}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') commitPosition(); }}
|
||
/>
|
||
</label>
|
||
<label className={cl.field}>
|
||
<span className={cl.axisLabel} style={{color:'#5a8ce8'}}>Z</span>
|
||
<input
|
||
type="number"
|
||
step={isBlock ? '1' : '0.1'}
|
||
className={cl.input}
|
||
value={localZ}
|
||
onChange={(e) => setLocalZ(e.target.value)}
|
||
onBlur={commitPosition}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') commitPosition(); }}
|
||
/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Свойства блока — Сталкивается / Видимый. Жидкости пропускаем. */}
|
||
{isBlock && !isLiquid && (
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="settings" size={12} /> Свойства</div>
|
||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer', padding: '4px 0' }}>
|
||
<input type="checkbox"
|
||
checked={localCanCollide}
|
||
onChange={(e) => {
|
||
setLocalCanCollide(e.target.checked);
|
||
onSetBlockProps?.({ canCollide: e.target.checked });
|
||
}} />
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="shield" size={12} /> Сталкивается</span>
|
||
</label>
|
||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer', padding: '4px 0' }}>
|
||
<input type="checkbox"
|
||
checked={localVisible}
|
||
onChange={(e) => {
|
||
setLocalVisible(e.target.checked);
|
||
onSetBlockProps?.({ visible: e.target.checked });
|
||
}} />
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="visible" size={12} /> Видимый</span>
|
||
</label>
|
||
</div>
|
||
)}
|
||
|
||
{/* Подсказка для жидкостей */}
|
||
{isLiquid && (
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="water" size={12} /> Жидкость</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-dim)', lineHeight: 1.5 }}>
|
||
{selection.blockTypeId === 'water'
|
||
? 'Через воду можно проходить и плавать. В режиме игры персонаж принимает горизонтальную позу.'
|
||
: 'Лава наносит урон при касании (скоро). Через неё можно проходить.'}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Поворот / масштаб — только для моделей (не для блоков, spawn, primitive) */}
|
||
{isModel && (
|
||
<>
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="refresh" size={12} /> Поворот по Y (°)</div>
|
||
<div className={cl.rowFull}>
|
||
<input
|
||
type="number"
|
||
step="15"
|
||
className={cl.input}
|
||
value={localRot}
|
||
onChange={(e) => setLocalRot(e.target.value)}
|
||
onBlur={commitRotation}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') commitRotation(); }}
|
||
/>
|
||
<button
|
||
className={cl.smallBtn}
|
||
onClick={() => {
|
||
const cur = parseFloat(localRot) || 0;
|
||
const next = (cur + 90) % 360;
|
||
setLocalRot(String(next));
|
||
onRotateTo(next * Math.PI / 180);
|
||
}}
|
||
title="Повернуть на 90° (клавиша R)"
|
||
>
|
||
+90°
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="ruler" size={12} /> Масштаб</div>
|
||
<div className={cl.rowFull}>
|
||
<input
|
||
type="number"
|
||
step="0.1"
|
||
min="0.1"
|
||
max="10"
|
||
className={cl.input}
|
||
value={localScale}
|
||
onChange={(e) => setLocalScale(e.target.value)}
|
||
onBlur={commitScale}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') commitScale(); }}
|
||
/>
|
||
<button className={cl.smallBtn} onClick={() => { setLocalScale('1'); onScaleTo(1); }} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||
<Icon name="refresh" size={12} /> 1x
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Свойства модели */}
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="settings" size={12} /> Свойства</div>
|
||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer', padding: '4px 0' }}>
|
||
<input type="checkbox"
|
||
checked={localCanCollide}
|
||
onChange={(e) => {
|
||
setLocalCanCollide(e.target.checked);
|
||
onSetModelProps?.({ canCollide: e.target.checked });
|
||
}} />
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="shield" size={12} /> Сталкивается</span>
|
||
</label>
|
||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer', padding: '4px 0' }}>
|
||
<input type="checkbox"
|
||
checked={localVisible}
|
||
onChange={(e) => {
|
||
setLocalVisible(e.target.checked);
|
||
onSetModelProps?.({ visible: e.target.checked });
|
||
}} />
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="visible" size={12} /> Видимый</span>
|
||
</label>
|
||
</div>
|
||
|
||
{/* Внешний вид: прозрачность и тинт.
|
||
Скрываем для user-моделей — там per-vertex color не
|
||
поддерживает opacity/tint через StandardMaterial.alpha. */}
|
||
{!isUserModel && (
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="palette" size={12} /> Внешний вид</div>
|
||
<div style={{ padding: '4px 0' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
|
||
<span>Прозрачность</span>
|
||
<span style={{ opacity: 0.6 }}>{Math.round(localOpacity * 100)}%</span>
|
||
</div>
|
||
<input
|
||
type="range" min="0" max="1" step="0.05"
|
||
value={localOpacity}
|
||
onChange={(e) => {
|
||
const v = parseFloat(e.target.value);
|
||
setLocalOpacity(v);
|
||
onSetModelProps?.({ opacity: v });
|
||
}}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>
|
||
<span style={{ fontSize: 12, flex: 1 }}>Цветовой тинт</span>
|
||
<input
|
||
type="color"
|
||
value={localTint || '#ffffff'}
|
||
onChange={(e) => {
|
||
setLocalTint(e.target.value);
|
||
onSetModelProps?.({ tint: e.target.value });
|
||
}}
|
||
style={{ width: 36, height: 24, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }}
|
||
/>
|
||
<button className={cl.smallBtn}
|
||
onClick={() => { setLocalTint(''); onSetModelProps?.({ tint: null }); }}
|
||
title="Убрать тинт"
|
||
><Icon name="refresh" size={13} /></button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* === GAMEPLAY — настройки врагов, спавнеров, оружия === */}
|
||
{selection.gameplay && (
|
||
<GameplayInspector
|
||
selection={selection}
|
||
onSetModelProps={onSetModelProps}
|
||
onAddWeaponToInventory={props.onAddWeaponToInventory}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Размер и материал — только для примитивов */}
|
||
{isPrimitive && (
|
||
<>
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="ruler" size={12} /> Размер</div>
|
||
<div className={cl.row3}>
|
||
<label className={cl.field}>
|
||
<span className={cl.axisLabel} style={{color:'#e85a5a'}}>X</span>
|
||
<input
|
||
type="number" step="0.1" min="0.1"
|
||
className={cl.input}
|
||
value={localSx}
|
||
onChange={(e) => setLocalSx(e.target.value)}
|
||
onBlur={commitResize}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') commitResize(); }}
|
||
/>
|
||
</label>
|
||
<label className={cl.field}>
|
||
<span className={cl.axisLabel} style={{color:'#5ae85a'}}>Y</span>
|
||
<input
|
||
type="number" step="0.1" min="0.1"
|
||
className={cl.input}
|
||
value={localSy}
|
||
onChange={(e) => setLocalSy(e.target.value)}
|
||
onBlur={commitResize}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') commitResize(); }}
|
||
/>
|
||
</label>
|
||
<label className={cl.field}>
|
||
<span className={cl.axisLabel} style={{color:'#5a8ce8'}}>Z</span>
|
||
<input
|
||
type="number" step="0.1" min="0.1"
|
||
className={cl.input}
|
||
value={localSz}
|
||
onChange={(e) => setLocalSz(e.target.value)}
|
||
onBlur={commitResize}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') commitResize(); }}
|
||
/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Цвет и материал — только для геометрических примитивов */}
|
||
{primitiveType?.kind === 'geometry' && (
|
||
<>
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="palette" size={12} /> Цвет</div>
|
||
<div className={cl.rowFull}>
|
||
<input
|
||
type="color"
|
||
className={cl.input}
|
||
value={localColor}
|
||
onChange={(e) => {
|
||
setLocalColor(e.target.value);
|
||
onSetPrimitiveProps?.({ color: e.target.value });
|
||
}}
|
||
style={{ height: 36, padding: 0, cursor: 'pointer' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="sparkles" size={12} /> Материал</div>
|
||
<div className={cl.row3}>
|
||
{PRIMITIVE_MATERIALS.map(m => (
|
||
<button
|
||
key={m.id}
|
||
type="button"
|
||
className={cl.smallBtn}
|
||
onClick={() => {
|
||
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}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Размер studs — плотность лего-кружков (только для material studs) */}
|
||
{localMaterial === 'studs' && (
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="grid" size={12} /> Размер studs</div>
|
||
<div className={cl.row3}>
|
||
{[
|
||
{ label: 'Крупные', d: 0.5 },
|
||
{ label: 'Средние', d: 1 },
|
||
{ label: 'Мелкие', d: 2 },
|
||
{ label: 'Меньше', d: 4 },
|
||
].map(opt => (
|
||
<button
|
||
key={opt.d}
|
||
type="button"
|
||
className={cl.smallBtn}
|
||
onClick={() => {
|
||
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}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Подпись над объектом (задача 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 (
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="type" size={12} /> Подпись над объектом</div>
|
||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer', padding: '2px 0' }}>
|
||
<input type="checkbox" checked={!!L.enabled}
|
||
onChange={(e) => applyLabel({ enabled: e.target.checked })} />
|
||
<span style={{ fontSize: 13 }}>Показывать подпись</span>
|
||
</label>
|
||
{L.enabled && (<>
|
||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginTop: 6 }}>
|
||
<span style={{ fontSize: 12, flex: 1 }}>Связать с</span>
|
||
<select value={L.binding} onChange={(e) => applyLabel({ binding: e.target.value })} style={{ ...inp, flex: 1.5 }}>
|
||
<option value="static">Статический текст</option>
|
||
<option value="timer">Таймер</option>
|
||
<option value="save">Счётчик из save</option>
|
||
<option value="hp">HP</option>
|
||
<option value="formula">Своя формула</option>
|
||
</select>
|
||
</div>
|
||
{(L.binding === 'static' || L.binding === 'formula') && (
|
||
<input type="text" placeholder="Текст" value={L.params?.text || ''}
|
||
onChange={(e) => applyLabel({ params: { text: e.target.value } })}
|
||
style={{ ...inp, marginTop: 6 }} />
|
||
)}
|
||
{L.binding === 'timer' && (
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginTop: 6 }}>
|
||
<label style={{ fontSize: 11, display: 'flex', flexDirection: 'column', gap: 2 }}>Секунд
|
||
<input type="number" value={L.params?.duration ?? 960}
|
||
onChange={(e) => applyLabel({ params: { duration: Number(e.target.value) } })} style={inp} /></label>
|
||
<label style={{ fontSize: 11, display: 'flex', flexDirection: 'column', gap: 2 }}>Формат
|
||
<select value={L.params?.format || 'mm:ss'} onChange={(e) => applyLabel({ params: { format: e.target.value } })} style={inp}>
|
||
<option value="mm:ss">мм:сс</option>
|
||
<option value="hh:mm:ss">чч:мм:сс</option>
|
||
<option value="auto">авто</option>
|
||
</select></label>
|
||
<input type="text" placeholder="Префикс" value={L.params?.prefix || ''}
|
||
onChange={(e) => applyLabel({ params: { prefix: e.target.value } })} style={{ ...inp, gridColumn: '1 / 3' }} />
|
||
</div>
|
||
)}
|
||
{L.binding === 'save' && (
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginTop: 6 }}>
|
||
<input type="text" placeholder="Ключ (coins)" value={L.params?.key || ''}
|
||
onChange={(e) => applyLabel({ params: { key: e.target.value } })} style={inp} />
|
||
<input type="text" placeholder="Суффикс" value={L.params?.suffix || ''}
|
||
onChange={(e) => applyLabel({ params: { suffix: e.target.value } })} style={inp} />
|
||
</div>
|
||
)}
|
||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginTop: 6 }}>
|
||
<span style={{ fontSize: 12, flex: 1 }}>Стиль</span>
|
||
<select value={L.preset || 'gameui'} onChange={(e) => applyLabel({ preset: e.target.value })} style={{ ...inp, flex: 1.5 }}>
|
||
<option value="gameui">Игровой (синий/жёлтый)</option>
|
||
<option value="warning">Предупреждение</option>
|
||
<option value="reward">Награда (золото)</option>
|
||
<option value="boss-hp">Босс HP</option>
|
||
<option value="plain">Простой</option>
|
||
</select>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginTop: 6 }}>
|
||
<span style={{ fontSize: 12, flex: 1 }}>Крепление</span>
|
||
<select value={L.attachFace || 'over'}
|
||
onChange={(e) => {
|
||
const v = e.target.value;
|
||
// 'over' — billboard над верхом (к камере); иначе — на грань.
|
||
applyLabel({ attachFace: v === 'over' ? null : v });
|
||
}}
|
||
style={{ ...inp, flex: 1.5 }}>
|
||
<option value="over">Над объектом (к камере)</option>
|
||
<option value="front">На грань: перёд</option>
|
||
<option value="back">На грань: зад</option>
|
||
<option value="left">На грань: лево</option>
|
||
<option value="right">На грань: право</option>
|
||
<option value="top">На грань: верх</option>
|
||
<option value="bottom">На грань: низ</option>
|
||
</select>
|
||
</div>
|
||
<label style={{ fontSize: 11, display: 'flex', flexDirection: 'column', gap: 2, marginTop: 6 }}>
|
||
{L.attachFace ? 'Отступ от грани' : 'Высота над объектом'}
|
||
<input type="number" step={L.attachFace ? 0.05 : 0.5}
|
||
value={L.height ?? (L.attachFace ? 0.05 : 2.5)}
|
||
onChange={(e) => applyLabel({ height: Number(e.target.value) })} style={inp} /></label>
|
||
</>)}
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* Текстура — своя картинка на гранях примитива */}
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}>
|
||
<Icon name="image" size={12} /> Текстура
|
||
</div>
|
||
<AssetPicker
|
||
assetList={assetList}
|
||
value={selection.textureAsset || null}
|
||
onPick={(id) => onSetPrimitiveProps?.({ textureAsset: id })}
|
||
onUpload={onUploadAsset}
|
||
onRemove={onRemoveAsset}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Лампа — цвет, яркость, радиус свечения */}
|
||
{primitiveType?.kind === 'light' && (
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="sparkle" size={12} /> Лампа</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>
|
||
<span style={{ fontSize: 12, flex: 1 }}>Цвет света</span>
|
||
<input
|
||
type="color"
|
||
value={localColor || '#ffe9a0'}
|
||
onChange={(e) => {
|
||
setLocalColor(e.target.value);
|
||
onSetPrimitiveProps?.({ color: e.target.value });
|
||
}}
|
||
style={{ width: 36, height: 24, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }}
|
||
/>
|
||
</div>
|
||
<div style={{ padding: '4px 0' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
|
||
<span>Яркость</span>
|
||
<span style={{ opacity: 0.6 }}>{Number(localBrightness).toFixed(1)}</span>
|
||
</div>
|
||
<input
|
||
type="range" min="0" max="6" step="0.25"
|
||
value={localBrightness}
|
||
onChange={(e) => {
|
||
const v = parseFloat(e.target.value);
|
||
setLocalBrightness(v);
|
||
onSetPrimitiveProps?.({ brightness: v });
|
||
}}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
<div style={{ padding: '4px 0' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
|
||
<span>Радиус (м)</span>
|
||
<span style={{ opacity: 0.6 }}>{Math.round(localRange)}</span>
|
||
</div>
|
||
<input
|
||
type="range" min="2" max="50" step="1"
|
||
value={localRange}
|
||
onChange={(e) => {
|
||
const v = parseFloat(e.target.value);
|
||
setLocalRange(v);
|
||
onSetPrimitiveProps?.({ range: v });
|
||
}}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Эмиттер частиц — выбор эффекта + цвет */}
|
||
{primitiveType?.kind === 'emitter' && (
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="sparkle" size={12} /> Эффект частиц</div>
|
||
<div className={cl.row3}>
|
||
{[
|
||
{ id: 'fire', name: 'Огонь' },
|
||
{ id: 'smoke', name: 'Дым' },
|
||
{ id: 'sparks', name: 'Искры' },
|
||
{ id: 'magic', name: 'Магия' },
|
||
].map(eff => (
|
||
<button
|
||
key={eff.id}
|
||
type="button"
|
||
className={cl.smallBtn}
|
||
onClick={() => 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}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0' }}>
|
||
<span style={{ fontSize: 12, flex: 1 }}>Цвет частиц</span>
|
||
<input
|
||
type="color"
|
||
value={localColor || '#ff8833'}
|
||
onChange={(e) => {
|
||
setLocalColor(e.target.value);
|
||
onSetPrimitiveProps?.({ color: e.target.value });
|
||
}}
|
||
style={{ width: 36, height: 24, padding: 0, border: 'none', background: 'transparent', cursor: 'pointer' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 3D-табличка (billboard) — кнопка «Редактировать табличку» */}
|
||
{primitiveType?.kind === 'billboard' && (
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="prim-billboard" size={12} /> Контент таблички</div>
|
||
<button
|
||
type="button"
|
||
className={cl.smallBtn}
|
||
onClick={() => onEditBillboard?.()}
|
||
style={{ width: '100%', padding: '8px 12px', fontWeight: 600 }}
|
||
>
|
||
Редактировать табличку…
|
||
</button>
|
||
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 6, lineHeight: 1.4 }}>
|
||
{(selection.template || 'shop-item')} · {(selection.face || 'camera') === 'camera' ? 'смотрит на камеру' : 'фиксирована'}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Стрелка-указатель (задача 08) — пресет, источник, цель, дуга */}
|
||
{primitiveType?.kind === 'pointer' && (
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="arrow-right" size={12} /> Стрелка-указатель</div>
|
||
{/* Пресет внешнего вида */}
|
||
<div style={{ fontSize: 12, marginBottom: 4 }}>Стиль</div>
|
||
<div className={cl.row3}>
|
||
{[
|
||
{ id: 'guide', name: 'Красная' },
|
||
{ id: 'quest', name: 'Жёлтая' },
|
||
{ id: 'danger', name: 'Молнии' },
|
||
{ id: 'gift', name: 'Радуга' },
|
||
].map(pr => (
|
||
<button
|
||
key={pr.id}
|
||
type="button"
|
||
className={cl.smallBtn}
|
||
onClick={() => 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}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{/* Откуда */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0' }}>
|
||
<span style={{ fontSize: 12, flex: 1 }}>Откуда</span>
|
||
<select
|
||
value={selection.pointerFrom ?? 'player'}
|
||
onChange={(e) => onSetPrimitiveProps?.({ pointerFrom: e.target.value })}
|
||
style={{ flex: 1.4 }}
|
||
>
|
||
<option value="player">Игрок</option>
|
||
<option value="">Эта точка</option>
|
||
</select>
|
||
</div>
|
||
{/* Куда — id объекта-цели или эта точка */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0' }}>
|
||
<span style={{ fontSize: 12, flex: 1 }}>Куда (id)</span>
|
||
<input
|
||
type="text"
|
||
placeholder="напр. 42 или player"
|
||
value={selection.pointerTo ?? ''}
|
||
onChange={(e) => onSetPrimitiveProps?.({ pointerTo: e.target.value })}
|
||
style={{ flex: 1.4, fontSize: 12, padding: '4px 6px' }}
|
||
/>
|
||
</div>
|
||
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2, lineHeight: 1.4 }}>
|
||
Пусто = стрелка указывает вперёд (+4 по Z). Можно вписать id примитива/модели или «player».
|
||
</div>
|
||
{/* Скорость анимации текстуры */}
|
||
<div style={{ padding: '6px 0' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
|
||
<span>Скорость бега</span>
|
||
<span style={{ opacity: 0.6 }}>{(selection.textureSpeed ?? 3)}</span>
|
||
</div>
|
||
<input
|
||
type="range" min="0" max="8" step="0.5"
|
||
value={selection.textureSpeed ?? 3}
|
||
onChange={(e) => onSetPrimitiveProps?.({ textureSpeed: parseFloat(e.target.value) })}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
{/* Дугой */}
|
||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer', padding: '4px 0' }}>
|
||
<input type="checkbox"
|
||
checked={!!selection.curved}
|
||
onChange={(e) => onSetPrimitiveProps?.({ curved: e.target.checked })} />
|
||
<span style={{ fontSize: 12 }}>Изогнуть дугой</span>
|
||
</label>
|
||
{selection.curved && (
|
||
<div style={{ padding: '4px 0' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
|
||
<span>Высота дуги</span>
|
||
<span style={{ opacity: 0.6 }}>{(selection.curveHeight ?? 2)}</span>
|
||
</div>
|
||
<input
|
||
type="range" min="1" max="10" step="0.5"
|
||
value={selection.curveHeight ?? 2}
|
||
onChange={(e) => onSetPrimitiveProps?.({ curveHeight: parseFloat(e.target.value) })}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 6, fontStyle: 'italic', lineHeight: 1.4 }}>
|
||
Стрелка появляется при запуске игры (▶). В редакторе показан маркер позиции.
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Свойства — для всех примитивов */}
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="settings" size={12} /> Свойства</div>
|
||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer', padding: '4px 0' }}>
|
||
<input type="checkbox"
|
||
checked={localCanCollide}
|
||
onChange={(e) => {
|
||
setLocalCanCollide(e.target.checked);
|
||
onSetPrimitiveProps?.({ canCollide: e.target.checked });
|
||
}} />
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="shield" size={12} /> Сталкивается</span>
|
||
</label>
|
||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer', padding: '4px 0' }}>
|
||
<input type="checkbox"
|
||
checked={localVisible}
|
||
onChange={(e) => {
|
||
setLocalVisible(e.target.checked);
|
||
onSetPrimitiveProps?.({ visible: e.target.checked });
|
||
}} />
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="visible" size={12} /> Видимый</span>
|
||
</label>
|
||
{/* Заблокировать — защита от выделения/перемещения (Фаза 5.11). */}
|
||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer', padding: '4px 0' }}>
|
||
<input type="checkbox"
|
||
checked={!!selection.locked}
|
||
onChange={(e) => onSetPrimitiveProps?.({ locked: e.target.checked })} />
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||
<Icon name="settings" size={12} /> Заблокировать
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Физика — для всех типов кроме spawn */}
|
||
{!isSpawn && !isLiquid && (
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}><Icon name="anchor" size={12} /> Физика</div>
|
||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer', padding: '4px 0' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={localAnchored}
|
||
onChange={(e) => {
|
||
setLocalAnchored(e.target.checked);
|
||
onSetAnchored?.(e.target.checked);
|
||
}}
|
||
/>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="anchor" size={12} /> Заякорен</span>
|
||
</label>
|
||
<div style={{ fontSize: 11, color: 'var(--text-dim)', padding: '2px 0 6px 26px', lineHeight: 1.4 }}>
|
||
{localAnchored
|
||
? 'Объект неподвижен. В режиме игры стоит на месте.'
|
||
: 'Объект падает под гравитацией и реагирует на столкновения.'}
|
||
</div>
|
||
{!localAnchored && (
|
||
<>
|
||
<div className={cl.row}>
|
||
<label className={cl.label} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="sliders" size={12} /> Вес</label>
|
||
<input
|
||
type="number"
|
||
className={cl.input}
|
||
step="0.1"
|
||
min="0.1"
|
||
value={localMass}
|
||
onChange={(e) => 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));
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-dim)', padding: '2px 0 0 26px', lineHeight: 1.4 }}>
|
||
Чем больше вес, тем слабее толкается. Мячик 0.1, ящик 1, валун 10.
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
|
||
{/* Действия */}
|
||
<div className={cl.actions}>
|
||
<button className={cl.actionBtn} onClick={onFocus} title="Сфокусировать камеру" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, justifyContent: 'center' }}>
|
||
<Icon name="target" size={13} /> Фокус
|
||
</button>
|
||
{/* Спавн и освещение нельзя удалить */}
|
||
{!isSpawn && !isLighting && !isScript && (
|
||
<button className={`${cl.actionBtn} ${cl.deleteBtn}`} onClick={onDelete} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, justifyContent: 'center' }}>
|
||
<Icon name="delete" size={13} /> Удалить
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className={cl.hint}>
|
||
<Icon name="sparkle" size={11} /> R — поворот · Del — удалить · Esc — снять выделение
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default InspectorPanel;
|