studio/src/editor/InspectorPanel.jsx
МИН 76b2afd312
Some checks failed
CI / Lint (pull_request) Failing after 5s
CI / PR size check (pull_request) Successful in 12s
CI / Secret scan (pull_request) Failing after 14m51s
CI / Build (pull_request) Failing after 14m52s
CI / Deploy to S1 + S2 (pull_request) Has been cancelled
feat(10): живые 3D-надписи (attachFace) + витрина-лутбокс + вики
Движок: 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>
2026-06-01 21:15:54 +03:00

2216 lines
130 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)' }}>
Скин персонажа выбирается игроком в&nbsp;магазине скинов.
В&nbsp;игре каждый увидит героя в&nbsp;своём купленном скине
менять его в&nbsp;редакторе нельзя.
</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;