Большой консолидирующий коммит после поднятия studio.rublox.pro (28 мая 2026). Содержит изменения которые делались в процессе подготовки прод-окружения: Фиксы импортов после выноса из minecraftia: - Массовая замена путей ../../components → ../components (40+ файлов в src/community/, src/admin-preview/) - Замена ../KubikonEditor/ → ../editor/, ../KubikonStudio/ → ../community/, ../AdminPreview/ → ../admin-preview/ - API.js скопирован из минки целиком (было 8 экспортов, стало 312) - Добавлены PLAYER_URL, MyButton_1, недостающие компоненты - Заменены require() на статические ES-imports в BabylonScene, PrimitiveManager, GameRuntime (Vite не поддерживает CJS require) Структура ассетов: - public/kubikon-templates/ → public/assets/kubikon-templates/ - public/kubikon-learn/ → public/assets/kubikon-learn/ - (код искал в /assets/, файлы лежали без /assets/) Навигация роутов внутри студии: - /kubikon-studio/docs → /docs (90+ навигационных вызовов sed-replaced) - /kubikon-editor/X → /edit/X, /kubikon/play/X → /play/X, /kubikon/gd/X → /gd/X UI: - Новый компонент StudioHeader (61px, как в минке) + копия favicon - WithHeader wrapper в App.jsx для всех страниц кроме fullscreen-редактора/плеера - SSO ticket-flow в AuthContext (auto-redeem #ticket= при загрузке) - Тёмная тема карточек игр в ВИКИ (фон #1c2231 вместо #fff, картинка впритык) Документация: - docs/ONBOARDING.md — путь нового контрибьютора от нуля до PR - docs/TUTORIAL_ADD_SCRIPT_API.md — как добавить game.* API - API_USAGE.md — список эндпоинтов backend - README в подпапках engine/, engine/terrain/, engine/voxel/, engine/robloxterrain/, engine/types/ .gitignore: - public/wiki/ исключён (73МБ PNG, будут на CDN отдельной задачей) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
3453 lines
194 KiB
JavaScript
3453 lines
194 KiB
JavaScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||
import { useNavigate, useParams } from 'react-router-dom';
|
||
import { jwtDecode } from 'jwt-decode';
|
||
import { useAuth } from '../auth/AuthContext.jsx';
|
||
import { useSanctions } from '../auth/SanctionsContext.jsx';
|
||
import { BabylonScene } from './engine/BabylonScene';
|
||
import { BLOCK_TYPES, BLOCK_CATEGORIES, blockPreview, registerCustomBlockType } from './engine/BlockTypes';
|
||
import { MODEL_TYPES, MODEL_CATEGORIES, getModelType } from './engine/ModelTypes';
|
||
import { PRIMITIVE_TYPES, PALETTE_PRIMITIVE_TYPES } from './engine/PrimitiveTypes';
|
||
import { getModelThumbnail } from './engine/ModelThumbnails';
|
||
import * as Kubikon3DApi from '../api/Kubikon3DService';
|
||
import GameSettingsModal from './GameSettingsModal';
|
||
import PublishModal from './PublishModal';
|
||
import EmailConfirmNotice from '../components/EmailConfirmNotice/EmailConfirmNotice';
|
||
import PublishStatusBadge from './PublishStatusBadge';
|
||
import ModerationHistory from './ModerationHistory';
|
||
import ToolboxModal from './ToolboxModal';
|
||
import HierarchyPanel from './HierarchyPanel';
|
||
import InspectorPanel from './InspectorPanel';
|
||
import TopRibbon from './TopRibbon';
|
||
import TerrainPanel from './TerrainPanel';
|
||
import ModelEditorScreen from './ModelEditorScreen';
|
||
import ModelSaveModal from './ModelSaveModal';
|
||
import TerrainGenPanel from './TerrainGenPanel';
|
||
import ScriptConsole from './ScriptConsole';
|
||
import SceneTabs from './SceneTabs';
|
||
import ScriptEditor from './ScriptEditor';
|
||
import GameHud from './GameHud';
|
||
import MinimapOverlay from './MinimapOverlay';
|
||
import GuiOverlay from './GuiOverlay';
|
||
import Hotbar from './Hotbar';
|
||
import PlayerHud from './PlayerHud';
|
||
import useDeviceType from '../hooks/useDeviceType';
|
||
import KubikonDesktopOnlyStub from '../community/KubikonDesktopOnlyStub';
|
||
import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton';
|
||
import cl from './KubikonEditor.module.css';
|
||
import Icon from './Icon';
|
||
|
||
const AUTOSAVE_DEBOUNCE_MS = 10000; // 10 секунд тишины → авто-сохранение
|
||
|
||
// Шаблон глобального скрипта.
|
||
const NEW_SCRIPT_TEMPLATE = `// Новый скрипт
|
||
// Доступно:
|
||
// game.player.position — {x, y, z}
|
||
// game.player.forward — {x, y, z} вектор куда смотрит игрок
|
||
// game.player.yaw — поворот игрока в радианах
|
||
// game.player.teleport(x,y,z)
|
||
// game.onTick(fn) — каждый кадр
|
||
// game.onKey(key, fn) — нажатие клавиши ('w', 'space', ...)
|
||
// game.onClick(fn) — клик в Play
|
||
// game.scene.spawn(...) — создать объект
|
||
// game.scene.delete(ref)
|
||
// game.log(...)
|
||
|
||
game.log('Скрипт запущен');
|
||
|
||
game.onKey('e', () => {
|
||
// Поставить блок травы в 3 клетках ПЕРЕД игроком (по направлению взгляда)
|
||
const p = game.player.position;
|
||
const f = game.player.forward;
|
||
game.scene.spawn('block:grass', {
|
||
x: Math.round(p.x + f.x * 3),
|
||
y: Math.round(p.y),
|
||
z: Math.round(p.z + f.z * 3),
|
||
});
|
||
game.log('Поставлен блок');
|
||
});`;
|
||
|
||
// Шаблон скрипта, привязанного к объекту (game.self).
|
||
const NEW_OBJECT_SCRIPT_TEMPLATE = `// Скрипт привязан к объекту: game.self
|
||
// API объекта:
|
||
// game.self.position — {x, y, z}
|
||
// game.self.onClick(fn) — клик игрока
|
||
// game.self.onTouch(fn) — касание игроком
|
||
// game.self.move(x,y,z) — переместить (только модели/примитивы)
|
||
// game.self.delete() — удалить себя
|
||
|
||
game.self.onClick(() => {
|
||
game.log('Клик по объекту!');
|
||
});
|
||
|
||
game.self.onTouch(() => {
|
||
game.log('Игрок коснулся объекта');
|
||
});`;
|
||
|
||
// ЭТАП 2.1 — демо-скрипт. Активируется если в проекте нет своих скриптов.
|
||
// Удалится после этапа 2.2 (UI редактирования).
|
||
const DEMO_SCRIPT = `// Этап 2.1 — пробный скрипт
|
||
// Доступно: game.player.position, game.player.teleport(x,y,z),
|
||
// game.onTick(fn), game.log(...)
|
||
game.log('Привет, Рублокс! Скрипты работают.');
|
||
|
||
let elapsed = 0;
|
||
let logged = false;
|
||
game.onTick((dt) => {
|
||
elapsed += dt;
|
||
// Каждые 2 секунды логируем позицию игрока
|
||
if (elapsed >= 2 && !logged) {
|
||
const p = game.player.position;
|
||
game.log('Игрок:', 'x=' + p.x.toFixed(1), 'y=' + p.y.toFixed(1), 'z=' + p.z.toFixed(1));
|
||
logged = true;
|
||
}
|
||
if (elapsed >= 4) {
|
||
elapsed = 0;
|
||
logged = false;
|
||
}
|
||
});`;
|
||
|
||
/** Получить ID текущего пользователя из JWT. */
|
||
function getCurrentUserId() {
|
||
try {
|
||
const token = localStorage.getItem('Authorization');
|
||
if (!token) return null;
|
||
return jwtDecode(token).id;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Иконки-эмодзи для категорий моделей (превью в палитре).
|
||
*/
|
||
// Имена иконок (см. Icon.jsx) для категорий и типов моделей.
|
||
const MODEL_CATEGORY_ICON = {
|
||
'Природа': 'trees',
|
||
'Двери': 'door',
|
||
'Заборы': 'construction',
|
||
'Мебель': 'box',
|
||
'Механизмы': 'settings',
|
||
'Предметы': 'star',
|
||
'Оружие': 'swords',
|
||
'Персонажи': 'user',
|
||
};
|
||
|
||
const MODEL_ITEM_ICON = (modelId) => {
|
||
if (modelId.startsWith('tree')) return 'trees';
|
||
if (modelId.startsWith('flower')) return 'flower';
|
||
if (modelId === 'mushrooms') return 'mushroom';
|
||
if (modelId === 'plant') return 'sprout';
|
||
if (modelId === 'grass') return 'grass';
|
||
if (modelId === 'hedge') return 'bush';
|
||
if (modelId.includes('rock') || modelId.includes('stone')) return 'rock';
|
||
if (modelId.startsWith('door')) return 'door';
|
||
if (modelId === 'gate') return 'building';
|
||
if (modelId.startsWith('fence')) return 'construction';
|
||
if (modelId === 'chest') return 'box';
|
||
if (modelId === 'barrel') return 'cylinder';
|
||
if (modelId === 'crate') return 'box';
|
||
if (modelId === 'column') return 'building';
|
||
if (modelId === 'banner' || modelId === 'flag') return 'flag';
|
||
if (modelId === 'sign') return 'clipboard';
|
||
if (modelId.startsWith('ladder')) return 'ladder';
|
||
if (modelId === 'stairs') return 'ruler';
|
||
if (modelId.startsWith('wall')) return 'boxes';
|
||
if (modelId.startsWith('floor')) return 'square';
|
||
if (modelId.includes('wood-')) return 'box';
|
||
if (modelId === 'lever') return 'sliders';
|
||
if (modelId.startsWith('button')) return 'component';
|
||
if (modelId === 'spring') return 'spawner';
|
||
if (modelId === 'saw') return 'settings';
|
||
if (modelId.startsWith('trap')) return 'warning';
|
||
if (modelId === 'spike-block') return 'warning';
|
||
if (modelId === 'pipe') return 'wrench';
|
||
if (modelId === 'poles') return 'flag';
|
||
if (modelId === 'bomb') return 'bomb';
|
||
if (modelId.startsWith('coin')) return 'prim-coin';
|
||
if (modelId === 'jewel') return 'sparkle';
|
||
if (modelId === 'star') return 'star';
|
||
if (modelId === 'heart') return 'heart';
|
||
if (modelId === 'key') return 'tag';
|
||
if (modelId === 'lock') return 'locked';
|
||
if (modelId === 'arrow') return 'target';
|
||
if (modelId.startsWith('weapon-sword')) return 'sword';
|
||
if (modelId.startsWith('weapon-spear')) return 'sword';
|
||
if (modelId.startsWith('shield')) return 'shield';
|
||
if (modelId.startsWith('character')) return 'user';
|
||
return 'box';
|
||
};
|
||
|
||
/**
|
||
* Мини-карточка модели в истории «недавних». Рендерит миниатюру лениво
|
||
* (когда попала в viewport) и кэширует через ModelThumbnails.
|
||
*
|
||
* Поддерживает два типа моделей:
|
||
* - стандартные (из MODEL_TYPES) — превью через getModelThumbnail(id)
|
||
* - пользовательские (isUserModel) — превью из thumbnail_b64 (если есть),
|
||
* иначе иконка-кубик. id таких моделей в формате 'user:<numericId>'.
|
||
*/
|
||
// Типы GUI-элементов для палитры визуального редактора UI (этап 3.9).
|
||
const GUI_PALETTE_ITEMS = [
|
||
{ type: 'frame', icon: 'square', name: 'Контейнер', hint: 'Прямоугольная панель — рамка для других элементов' },
|
||
{ type: 'scroll', icon: 'align-left', name: 'Список', hint: 'Прокручиваемая панель — для длинных списков и меню' },
|
||
{ type: 'text', icon: 'type', name: 'Надпись', hint: 'Текстовая надпись (счёт, подсказки)' },
|
||
{ type: 'button', icon: 'component', name: 'Кнопка', hint: 'Кликабельная кнопка — реагирует в скрипте' },
|
||
{ type: 'textbox', icon: 'edit', name: 'Поле ввода', hint: 'Поле для ввода текста игроком' },
|
||
{ type: 'image', icon: 'image', name: 'Картинка', hint: 'Картинка из библиотеки или по ссылке' },
|
||
];
|
||
|
||
/**
|
||
* GuiPalette — палитра 2D-интерфейса (этап 3.9, визуальный редактор UI).
|
||
* Карточку можно ПЕРЕТАЩИТЬ на превью (drag-n-drop) — элемент появится
|
||
* там, куда отпустили. Клик по карточке — добавить в центр экрана.
|
||
*/
|
||
const GuiPalette = ({ onPlaceCenter }) => {
|
||
return (
|
||
<div style={{ padding: '8px 8px', overflowY: 'auto' }}>
|
||
<div style={{
|
||
fontSize: 11, color: 'var(--text-dim)',
|
||
marginBottom: 8, lineHeight: 1.4,
|
||
}}>
|
||
Перетащи элемент на экран игры — он появится там, куда
|
||
отпустишь. Или кликни — добавится в центр.
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||
{GUI_PALETTE_ITEMS.map(item => (
|
||
<div
|
||
key={item.type}
|
||
draggable
|
||
onDragStart={(e) => {
|
||
e.dataTransfer.setData('application/x-kubikon-gui', item.type);
|
||
e.dataTransfer.effectAllowed = 'copy';
|
||
}}
|
||
onClick={() => onPlaceCenter?.(item.type)}
|
||
title={item.hint}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: 8,
|
||
padding: '8px 10px', borderRadius: 8,
|
||
border: '1px solid var(--border)',
|
||
background: 'var(--bg-mid)',
|
||
color: 'var(--text)',
|
||
cursor: 'grab', userSelect: 'none',
|
||
}}
|
||
>
|
||
<span style={{ flexShrink: 0, display: 'flex', color: 'var(--accent)' }}>
|
||
<Icon name={item.icon} size={22} />
|
||
</span>
|
||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text)' }}>
|
||
{item.name}
|
||
</span>
|
||
<span style={{ fontSize: 10, color: 'var(--text-dim)', lineHeight: 1.3 }}>
|
||
{item.hint}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const RecentModelThumb = ({ model, active, onClick }) => {
|
||
const ref = useRef(null);
|
||
const [thumb, setThumb] = useState(null);
|
||
const requestedRef = useRef(false);
|
||
const aliveRef = useRef(true);
|
||
|
||
const isUserModel = !!model?.isUserModel;
|
||
|
||
useEffect(() => {
|
||
aliveRef.current = true;
|
||
return () => { aliveRef.current = false; };
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
// Пользовательские модели не грузятся через getModelThumbnail —
|
||
// у них превью встроено в thumbnail_b64 (или его нет вовсе).
|
||
if (isUserModel) return;
|
||
const el = ref.current;
|
||
if (!el || thumb || !model?.file) return;
|
||
const io = new IntersectionObserver((entries) => {
|
||
for (const e of entries) {
|
||
if (e.isIntersecting && !requestedRef.current) {
|
||
requestedRef.current = true;
|
||
getModelThumbnail(model.id).then(url => {
|
||
if (!aliveRef.current) return;
|
||
setThumb(url || 'failed');
|
||
}).catch(() => {
|
||
if (aliveRef.current) setThumb('failed');
|
||
});
|
||
}
|
||
}
|
||
}, { rootMargin: '50px' });
|
||
io.observe(el);
|
||
return () => io.disconnect();
|
||
}, [model?.id, model?.file, thumb, isUserModel]);
|
||
|
||
if (!model) return null;
|
||
|
||
// Превью для пользовательской модели: встроенный thumbnail_b64 или иконка.
|
||
if (isUserModel) {
|
||
const kindIcon = model.kind === 'smooth' ? 'circle' : 'cube';
|
||
return (
|
||
<button
|
||
ref={ref}
|
||
className={`${cl.recentThumbCard} ${active ? cl.recentThumbCardActive : ''}`}
|
||
onClick={onClick}
|
||
title={`${model.name || model.title} · моя модель`}
|
||
>
|
||
{model.thumbnail_b64 ? (
|
||
<img
|
||
src={model.thumbnail_b64}
|
||
alt={model.name || model.title}
|
||
className={cl.recentThumbImg}
|
||
/>
|
||
) : (
|
||
<span className={cl.recentThumbIcon}>
|
||
<Icon name={kindIcon} size={26} />
|
||
</span>
|
||
)}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<button
|
||
ref={ref}
|
||
className={`${cl.recentThumbCard} ${active ? cl.recentThumbCardActive : ''}`}
|
||
onClick={onClick}
|
||
title={`${model.name} · ${model.category}`}
|
||
>
|
||
{thumb && thumb !== 'failed' ? (
|
||
<img src={thumb} alt={model.name} className={cl.recentThumbImg} />
|
||
) : (
|
||
<span className={cl.recentThumbIcon}>
|
||
<Icon name={MODEL_ITEM_ICON(model.id)} size={26} />
|
||
</span>
|
||
)}
|
||
</button>
|
||
);
|
||
};
|
||
|
||
const KubikonEditor = () => {
|
||
const navigate = useNavigate();
|
||
const { id } = useParams();
|
||
const { isAuthenticated, isLoading } = useAuth();
|
||
const { isDesktop } = useDeviceType();
|
||
|
||
// 6.5: Preview-режим тестирования материала дизайнером.
|
||
// URL: /gamepage/editor/__preview_material_<id>
|
||
// Studio не грузит проект, создаёт пустую сцену с custom-блоком,
|
||
// активирует его — дизайнер сразу может ставить блоки с этой текстурой.
|
||
const previewMaterialId = React.useMemo(() => {
|
||
if (typeof id !== 'string') return null;
|
||
const m = id.match(/^__preview_material_(\d+)$/);
|
||
return m ? Number(m[1]) : null;
|
||
}, [id]);
|
||
const isMaterialPreview = previewMaterialId !== null;
|
||
|
||
const canvasRef = useRef(null);
|
||
const sceneRef = useRef(null);
|
||
// Флаш pending-debounce ScriptEditor. Зовём перед каждым doSave/перед уходом
|
||
// со страницы — иначе последние 600мс правок скрипта потеряются.
|
||
const scriptEditorFlushRef = useRef(null);
|
||
const hudRef = useRef(null); // ref → GameHud.handle({cmd, payload})
|
||
const viewportRef = useRef(null); // контейнер viewport для расчёта % → px в GUI
|
||
// Ref-зеркало активного таба чтобы interval мог скипать тяжёлые обновления
|
||
// когда таб скрипта (нет смысла обновлять Hierarchy если её не видно).
|
||
const activeTabRef = useRef('scene');
|
||
// Флаг "иерархия изменилась, списки нужно пересобрать". Ставится из
|
||
// scene.setOnSceneChange, сбрасывается после пересборки в 250мс-interval.
|
||
// БЕЗ него interval КАЖДЫЕ 250мс строил новые массивы блоков/моделей и
|
||
// дёргал 8 setState → React перерендеривал весь огромный KubikonEditor,
|
||
// даже если в сцене НИЧЕГО не менялось. Это давало idle_ms~28мс и
|
||
// просадку FPS до 30 в редакторе (в Play было 60 — там сцена статична).
|
||
// Начальное true — первый тик после загрузки соберёт списки.
|
||
const hierarchyDirtyRef = useRef(true);
|
||
|
||
const [projectName, setProjectName] = useState(id === 'new' ? 'Новая игра' : 'Загрузка...');
|
||
const [activeTool, setActiveTool] = useState('select');
|
||
// Этап 3 редактора моделей: режим создания собственной модели.
|
||
// null = обычный режим, 'voxel' | 'smooth' = открыт редактор соответствующего типа.
|
||
// Внутри ModelEditorScreen происходит создание/редактирование модели,
|
||
// основная сцена игры на это время скрыта/паузится (как в Roblox при
|
||
// открытии Asset Manager → Edit).
|
||
const [modelEditorMode, setModelEditorMode] = useState(null);
|
||
// Этап 6: id существующей модели для редактирования (открывает ModelEditorScreen
|
||
// с подгруженной model_data). null = создание новой.
|
||
const [editingUserModelId, setEditingUserModelId] = useState(null);
|
||
// Состояние для модалки "Настройки модели" (имя/описание/публичность)
|
||
const [settingsModalModel, setSettingsModalModel] = useState(null);
|
||
// Bumper для обновления списков в Toolbox после edit/settings/delete.
|
||
const [userModelsRefreshKey, setUserModelsRefreshKey] = useState(0);
|
||
// Bump-счётчик: инкрементируется при создании/очистке гладкого
|
||
// ландшафта, чтобы TerrainGenPanel пере-вычислил hasRobloxTerrain.
|
||
const [robloxTerrainBump, setRobloxTerrainBump] = useState(0);
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
// UI-режим курсора в Play (для кликов по GUI). Переключается клавишей Tab.
|
||
const [uiCursorMode, setUiCursorMode] = useState(false);
|
||
// Console (этап 2.1): логи скриптов и кнопка показать/скрыть
|
||
const [scriptLogs, setScriptLogs] = useState([]);
|
||
const [consoleOpen, setConsoleOpen] = useState(false);
|
||
// Список скриптов для Hierarchy (синхронизируется из BabylonScene периодически)
|
||
const [scriptsList, setScriptsList] = useState([]);
|
||
// GUI-элементы (2D-UI поверх viewport)
|
||
const [guiList, setGuiList] = useState([]);
|
||
// Библиотека пользовательских картинок (этап 3.6 — текстуры/GUI-image).
|
||
// Зеркалит sceneRef.current.assetManager в React-state.
|
||
const [assetList, setAssetList] = useState([]);
|
||
// Библиотека пользовательских звуков (Фаза 5.5).
|
||
const [soundList, setSoundList] = useState([]);
|
||
// Импортированные .glb-модели (Фаза 5.8).
|
||
const [glbList, setGlbList] = useState([]);
|
||
// input[file] для импорта .glb.
|
||
const glbFileRef = useRef(null);
|
||
// Инвентарь игрока (для hot-bar)
|
||
const [inventoryState, setInventoryState] = useState({ slots: [], activeIndex: 0 });
|
||
// Идёт ли загрузка проекта (для overlay-крутилки)
|
||
const [sceneLoading, setSceneLoading] = useState(true);
|
||
// Прогресс загрузки (читается из window.__kubikonLoadProgress через polling)
|
||
const [loadProgress, setLoadProgress] = useState({ percent: 0, label: 'Подготовка…' });
|
||
// Polling прогресса пока sceneLoading=true. BabylonScene/TerrainManager
|
||
// выставляют window.__kubikonLoadProgress = { percent, label }.
|
||
useEffect(() => {
|
||
if (!sceneLoading) return;
|
||
// Сбрасываем при старте новой загрузки
|
||
if (typeof window !== 'undefined') window.__kubikonLoadProgress = { percent: 0, label: 'Подготовка…' };
|
||
const interval = setInterval(() => {
|
||
if (typeof window === 'undefined') return;
|
||
const p = window.__kubikonLoadProgress;
|
||
if (p && typeof p.percent === 'number') {
|
||
setLoadProgress({ percent: p.percent, label: p.label || '' });
|
||
}
|
||
}, 100);
|
||
// Safety net: если 60 сек прошло и overlay всё ещё висит — снимаем.
|
||
// Защита от случая когда async loadFromState упал тихо, finally
|
||
// не отработал, или setSceneLoading(false) попал в обход (race).
|
||
const safetyTimer = setTimeout(() => {
|
||
console.warn('[KubikonEditor] safety timer: forcing setSceneLoading(false) after 60s');
|
||
setSceneLoading(false);
|
||
}, 60000);
|
||
return () => {
|
||
clearInterval(interval);
|
||
clearTimeout(safetyTimer);
|
||
};
|
||
}, [sceneLoading]);
|
||
/** True если getProject упал — в этом случае запрещаем auto-save,
|
||
* чтобы не затереть существующий проект пустой сценой. */
|
||
const loadFailedRef = useRef(false);
|
||
/**
|
||
* Сколько voxels было в проекте при загрузке (для защиты от wipe).
|
||
* Если при save voxels.size упал больше чем в 2 раза — БЛОКИРУЕМ save:
|
||
* это значит что-то пошло не так (hot reload не загрузил данные).
|
||
* 0 — защита неактивна (новый проект или загрузка ещё не завершена).
|
||
*/
|
||
const lastLoadedVoxelCountRef = useRef(0);
|
||
/**
|
||
* Сколько smooth-deco инстансов (деревья/трава/цветы) было при загрузке.
|
||
* Отдельный guard: воксели террейна могут остаться на месте, а деко —
|
||
* пропасть (если loadFromState упал на этапе deco). Без этого guard'а
|
||
* автосейв сохранял сцену с террейном но БЕЗ деревьев — деко терялось.
|
||
* 0 — защита неактивна.
|
||
*/
|
||
const lastLoadedDecoCountRef = useRef(0);
|
||
// HP игрока + патроны
|
||
const [playerHp, setPlayerHp] = useState({ hp: 100, maxHp: 100 });
|
||
// Скрипт через game.hud.setVisible(false) может полностью скрыть HUD
|
||
// движка (HP, hotbar, ...) — для своего меню через game.gui.*.
|
||
const [stdHudVisible, setStdHudVisible] = useState(true);
|
||
// Кнопка-глазок в Иерархии "Интерфейс" — временно скрывает все GUI-элементы
|
||
// в редакторе (только в редакторе, в Play они видны как и раньше).
|
||
const [guiOverlayHidden, setGuiOverlayHidden] = useState(false);
|
||
const [weaponAmmo, setWeaponAmmo] = useState(null);
|
||
const [hurtFlash, setHurtFlash] = useState(0); // ms timestamp последнего урона
|
||
// ID скрипта запущенного в solo-debug режиме (или null)
|
||
const [soloScriptId, setSoloScriptId] = useState(null);
|
||
// Тип прицела в Play: 'none' | 'dot' | 'cross' | 'circle'. По умолчанию выключен.
|
||
const [crosshair, setCrosshairUI] = useState('none');
|
||
// Видимость пола в иерархии
|
||
const [floorEnabled, setFloorEnabledUI] = useState(true);
|
||
// Табы над viewport (Roblox-style): «🎬 Сцена» + открытые скрипты
|
||
const [openTabs, setOpenTabs] = useState([{ id: 'scene', kind: 'scene', title: 'Сцена' }]);
|
||
const [activeTabId, setActiveTabId] = useState('scene');
|
||
|
||
// Тик прогресса перезарядки (для плавного бара)
|
||
useEffect(() => {
|
||
if (!isPlaying) return;
|
||
const t = setInterval(() => {
|
||
const ammo = sceneRef.current?.weapons?.getAmmoState?.();
|
||
if (ammo) setWeaponAmmo(ammo);
|
||
}, 60);
|
||
return () => clearInterval(t);
|
||
}, [isPlaying]);
|
||
|
||
// При выходе из Play сбросим HP к полному (для следующего захода)
|
||
useEffect(() => {
|
||
if (!isPlaying) {
|
||
setPlayerHp({ hp: 100, maxHp: 100 });
|
||
setWeaponAmmo(null);
|
||
setHurtFlash(0);
|
||
}
|
||
}, [isPlaying]);
|
||
|
||
// Хоткеи 1-9 + колесо мыши для смены активного слота инвентаря в Play
|
||
useEffect(() => {
|
||
if (!isPlaying) return;
|
||
const onKey = (e) => {
|
||
if (e.code && e.code.startsWith('Digit')) {
|
||
const n = parseInt(e.code.slice(5), 10);
|
||
if (n >= 1 && n <= 5) {
|
||
sceneRef.current?.setActiveInventorySlot?.(n - 1);
|
||
}
|
||
}
|
||
};
|
||
const onWheel = (e) => {
|
||
const tag = (e.target?.tagName || '').toLowerCase();
|
||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||
// В third-person колесо меняет дистанцию камеры (PlayerController)
|
||
// — мы НЕ перехватываем. В first-person — переключаем слоты.
|
||
const player = sceneRef.current?.player;
|
||
if (player && player._cameraMode !== 'first') return;
|
||
const cur = sceneRef.current?.inventory?.activeIndex ?? 0;
|
||
const dir = Math.sign(e.deltaY);
|
||
let next = cur + dir;
|
||
if (next < 0) next = 4;
|
||
if (next > 4) next = 0;
|
||
sceneRef.current?.setActiveInventorySlot?.(next);
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
// Колесо вешаем на canvas чтобы не конфликтовало с UI
|
||
const canvas = canvasRef.current;
|
||
if (canvas) canvas.addEventListener('wheel', onWheel, { passive: true });
|
||
return () => {
|
||
window.removeEventListener('keydown', onKey);
|
||
if (canvas) canvas.removeEventListener('wheel', onWheel);
|
||
};
|
||
}, [isPlaying]);
|
||
|
||
// Сбрасываем UI-режим курсора при выходе из Play
|
||
useEffect(() => {
|
||
if (!isPlaying) { setUiCursorMode(false); return; }
|
||
// Переключение Tab → синхронизация React-state с PlayerController
|
||
const onKey = (e) => {
|
||
if (e.code !== 'Tab') return;
|
||
// PlayerController сам обрабатывает — мы только читаем
|
||
// На следующем тике берём актуальное значение из контроллера
|
||
requestAnimationFrame(() => {
|
||
const v = sceneRef.current?.player?.isUiCursorMode?.() || false;
|
||
setUiCursorMode(v);
|
||
});
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [isPlaying]);
|
||
|
||
// Когда активен таб скрипта — Babylon canvas скрыт, рендерить смысла нет.
|
||
// Пауза release'ит CPU/GPU и Monaco перестаёт лагать на ввод.
|
||
// ИСКЛЮЧЕНИЕ: Play-режим — игроку нужно видеть canvas, не паузим.
|
||
useEffect(() => {
|
||
activeTabRef.current = activeTabId;
|
||
const s = sceneRef.current;
|
||
if (!s) return;
|
||
const shouldPause = activeTabId !== 'scene' && !isPlaying;
|
||
if (shouldPause) s.pauseRendering?.();
|
||
else s.resumeRendering?.();
|
||
}, [activeTabId, isPlaying]);
|
||
|
||
const openScriptTab = (scriptId) => {
|
||
const all = sceneRef.current?.getScripts?.() || [];
|
||
const sc = all.find(s => s.id === scriptId);
|
||
if (!sc) return;
|
||
const title = sc.id === 'demo' ? 'Демо-скрипт' : sc.id;
|
||
setOpenTabs(prev => {
|
||
if (prev.some(t => t.id === scriptId)) return prev;
|
||
return [...prev, { id: scriptId, kind: 'script', title }];
|
||
});
|
||
setActiveTabId(scriptId);
|
||
};
|
||
const closeTab = (id) => {
|
||
if (id === 'scene') return;
|
||
setOpenTabs(prev => prev.filter(t => t.id !== id));
|
||
setActiveTabId(prev => prev === id ? 'scene' : prev);
|
||
};
|
||
const [activeBlockType, setActiveBlockType] = useState(BLOCK_TYPES[0]?.id);
|
||
const [activeModelType, setActiveModelType] = useState(MODEL_TYPES[0]?.id);
|
||
const [activePrimitiveType, setActivePrimitiveType] = useState(PALETTE_PRIMITIVE_TYPES[0]?.id);
|
||
|
||
// История последних использованных моделей (id, ≤ 10, новые в начало).
|
||
// id могут быть как стандартными (строка из MODEL_TYPES), так и
|
||
// пользовательскими ('user:<numericId>').
|
||
const RECENT_MODELS_KEY = 'kubikonRecentModels';
|
||
// Метаданные пользовательских моделей для истории — id → {title,kind}.
|
||
// Стандартные модели находятся в MODEL_TYPES, для них кэш не нужен.
|
||
const RECENT_USER_META_KEY = 'kubikonRecentUserModelMeta';
|
||
const RECENT_MODELS_MAX = 10;
|
||
const [recentModels, setRecentModels] = useState(() => {
|
||
try {
|
||
const raw = localStorage.getItem(RECENT_MODELS_KEY);
|
||
if (!raw) return [];
|
||
const arr = JSON.parse(raw);
|
||
if (!Array.isArray(arr)) return [];
|
||
// Оставляем и стандартные (есть в MODEL_TYPES), и пользовательские
|
||
// ('user:'-префикс) — последние проверяются по кэшу метаданных.
|
||
return arr
|
||
.filter(id => typeof id === 'string' &&
|
||
(id.startsWith('user:') || MODEL_TYPES.some(m => m.id === id)))
|
||
.slice(0, RECENT_MODELS_MAX);
|
||
} catch (e) { return []; }
|
||
});
|
||
const [recentUserMeta, setRecentUserMeta] = useState(() => {
|
||
try {
|
||
const raw = localStorage.getItem(RECENT_USER_META_KEY);
|
||
if (!raw) return {};
|
||
const obj = JSON.parse(raw);
|
||
return (obj && typeof obj === 'object') ? obj : {};
|
||
} catch (e) { return {}; }
|
||
});
|
||
// pushRecentModel(modelId, userModelObj?) — userModelObj передаётся
|
||
// только для пользовательских моделей (из Toolbox), чтобы сохранить
|
||
// их title/kind/thumbnail для отображения в истории.
|
||
const pushRecentModel = useCallback((modelId, userModelObj = null) => {
|
||
if (!modelId) return;
|
||
setRecentModels(prev => {
|
||
const next = [modelId, ...prev.filter(x => x !== modelId)].slice(0, RECENT_MODELS_MAX);
|
||
try { localStorage.setItem(RECENT_MODELS_KEY, JSON.stringify(next)); } catch (e) { /* ignore */ }
|
||
return next;
|
||
});
|
||
if (userModelObj && typeof modelId === 'string' && modelId.startsWith('user:')) {
|
||
setRecentUserMeta(prev => {
|
||
const next = {
|
||
...prev,
|
||
[modelId]: {
|
||
id: modelId,
|
||
title: userModelObj.title || 'Модель',
|
||
kind: userModelObj.kind || 'voxel',
|
||
thumbnail_b64: userModelObj.thumbnail_b64 || null,
|
||
},
|
||
};
|
||
try { localStorage.setItem(RECENT_USER_META_KEY, JSON.stringify(next)); } catch (e) { /* ignore */ }
|
||
return next;
|
||
});
|
||
}
|
||
}, []);
|
||
const [paletteTab, setPaletteTab] = useState('blocks'); // 'blocks' | 'models' | 'primitives'
|
||
const [blockCount, setBlockCount] = useState(0);
|
||
const [modelCount, setModelCount] = useState(0);
|
||
|
||
// === Hierarchy / Inspector ===
|
||
const [selection, setSelection] = useState(null);
|
||
const [blocksList, setBlocksList] = useState([]);
|
||
const [modelsList, setModelsList] = useState([]);
|
||
const [primitivesList, setPrimitivesList] = useState([]);
|
||
const [foldersList, setFoldersList] = useState([]);
|
||
|
||
// === TopRibbon ===
|
||
const [ribbonTab, setRibbonTab] = useState('home');
|
||
const [gizmoMode, setGizmoMode] = useState('select'); // 'select'|'move'|'rotate'|'scale'
|
||
const [snapStep, setSnapStep] = useState(1.0);
|
||
const [blockCategory, setBlockCategory] = useState(BLOCK_CATEGORIES[0]);
|
||
const [modelCategory, setModelCategory] = useState(MODEL_CATEGORIES[0]);
|
||
const [search, setSearch] = useState('');
|
||
|
||
// === Game settings inline в TopRibbon (вкладка Тест) ===
|
||
// Дефолт — R15-скин bacon-hair (классический Roblox-вид).
|
||
const [playerModelType, setPlayerModelTypeUI] = useState('skin_bacon-hair');
|
||
const [envPreset, setEnvPresetUI] = useState('day');
|
||
const [dayDurationMin, setDayDurationMinUI] = useState(5);
|
||
const [nightDurationMin, setNightDurationMinUI] = useState(3);
|
||
const [worldSize, setWorldSizeUI] = useState(80);
|
||
const [shadowQuality, setShadowQualityUI] = useState('soft'); // 'off' | 'hard' | 'soft'
|
||
const [ambientId, setAmbientIdUI] = useState('none');
|
||
const [musicId, setMusicIdUI] = useState('none');
|
||
|
||
// === Сохранение ===
|
||
const [saveStatus, setSaveStatus] = useState('idle');
|
||
/** Детальное сообщение о текущей фазе сохранения — показывается оверлеем при saving */
|
||
const [saveDetail, setSaveDetail] = useState(null); // { phase, pct, sizeKB }
|
||
const currentProjectIdRef = useRef(id === 'new' ? null : Number(id));
|
||
const dirtyRef = useRef(false);
|
||
const autoSaveTimerRef = useRef(null);
|
||
const isSavingRef = useRef(false);
|
||
|
||
// === Метаданные проекта ===
|
||
// Хранятся в ref-ах чтобы doSave мог их прочитать без зависимостей.
|
||
const metaRef = useRef({
|
||
title: id === 'new' ? 'Новая игра' : 'Загрузка...',
|
||
description: '',
|
||
genre: 'other',
|
||
thumbnail: '',
|
||
is_public: false,
|
||
player_model_type: 'skin_bacon-hair',
|
||
});
|
||
const projectNameRef = useRef(projectName);
|
||
useEffect(() => { projectNameRef.current = projectName; metaRef.current.title = projectName; }, [projectName]);
|
||
|
||
// === Модалки ===
|
||
// settingsModalOpen — настройки игры (Roblox Game Settings)
|
||
// initialModalOpen — инициальный диалог при создании новой игры
|
||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||
const [publishModalOpen, setPublishModalOpen] = useState(false);
|
||
const [historyModalOpen, setHistoryModalOpen] = useState(false);
|
||
const [moderationHistory, setModerationHistory] = useState([]);
|
||
// Текущее состояние публикации проекта (загружается из БД)
|
||
const [projectStatus, setProjectStatus] = useState({
|
||
status: 'draft', age_rating: 12, rank_requested: 2, moderator_comment: null,
|
||
});
|
||
// Бан публикаций для текущего пользователя (этап 3.10)
|
||
const [publishBan, setPublishBan] = useState(null); // null | { reason, expires_at, ... }
|
||
// Глобальные санкции из storys (timed-ban и др). isCantPublish=true если
|
||
// запрещено публиковать что-либо. Дополняет publishBan для случая когда
|
||
// у юзера user_timed_ban (а не publish_ban отдельно).
|
||
const { isCantPublish } = useSanctions();
|
||
const [banWarningOpen, setBanWarningOpen] = useState(false);
|
||
// Окно «подтвердите email» — публикация требует подтверждённого аккаунта
|
||
const [emailNotice, setEmailNotice] = useState(false);
|
||
|
||
// Подгружаем актуальный статус бана публикаций при монтировании.
|
||
// Бан активен → кнопку «Опубликовать» дизейблим и показываем warning-модалку
|
||
// вместо publish-modal'ки.
|
||
useEffect(() => {
|
||
const userId = getCurrentUserId();
|
||
if (!userId) { setPublishBan(null); return; }
|
||
let alive = true;
|
||
Kubikon3DApi.getPublishBanStatus(userId)
|
||
.then(res => {
|
||
if (!alive) return;
|
||
setPublishBan(res.data?.banned ? res.data.ban : null);
|
||
})
|
||
.catch(() => { /* нет ответа — не блокируем */ });
|
||
return () => { alive = false; };
|
||
}, []);
|
||
const [toolboxOpen, setToolboxOpen] = useState(false);
|
||
// Раздел, на котором открыть Toolbox: 'standard' | 'mine' | 'community'.
|
||
const [toolboxInitialSection, setToolboxInitialSection] = useState('standard');
|
||
const [initialModalOpen, setInitialModalOpen] = useState(
|
||
id === 'new' && !isMaterialPreview);
|
||
|
||
// Фильтрация блоков
|
||
const filteredBlocks = React.useMemo(() => {
|
||
const q = search.trim().toLowerCase();
|
||
if (q) return BLOCK_TYPES.filter(b => b.name.toLowerCase().includes(q));
|
||
return BLOCK_TYPES.filter(b => b.category === blockCategory);
|
||
}, [blockCategory, search]);
|
||
|
||
// Фильтрация моделей (hidden — не показываем в палитре)
|
||
const filteredModels = React.useMemo(() => {
|
||
const q = search.trim().toLowerCase();
|
||
const visible = MODEL_TYPES.filter(m => !m.hidden);
|
||
if (q) return visible.filter(m => m.name.toLowerCase().includes(q));
|
||
return visible.filter(m => m.category === modelCategory);
|
||
}, [modelCategory, search]);
|
||
|
||
// Защита: только admin
|
||
useEffect(() => {
|
||
if (isLoading) return;
|
||
// Доступ открыт всем авторизованным (раньше — только admin)
|
||
if (!isAuthenticated) {
|
||
navigate('/login', { replace: true });
|
||
}
|
||
}, [isAuthenticated, isLoading, navigate]);
|
||
|
||
/**
|
||
* Реальное сохранение на сервер.
|
||
* Если currentProjectId === null → POST /kubikon3d/projects (создание),
|
||
* после успеха меняет currentProjectId и URL.
|
||
* Если есть id → PUT /kubikon3d/projects/:id.
|
||
*/
|
||
const doSave = useCallback(async () => {
|
||
if (isSavingRef.current) return;
|
||
if (!sceneRef.current) return;
|
||
// 6.5: в preview-режиме материала ничего не сохраняем — это
|
||
// тестовая песочница, проекта в БД нет.
|
||
if (isMaterialPreview) return;
|
||
// ФЛАШ ScriptEditor: если юзер сейчас печатает в Monaco — debounce 600мс
|
||
// ещё может крутиться. Без явного flush последние правки не попадут
|
||
// в this._scripts[] до сериализации.
|
||
try { scriptEditorFlushRef.current?.(); } catch (_) {}
|
||
// Если загрузка упала — не сохраняем. Иначе пустая сцена
|
||
// (с дефолтным state до load) затрёт существующий проект.
|
||
if (loadFailedRef.current) {
|
||
console.warn('[KubikonEditor] save: skip (load failed)');
|
||
return;
|
||
}
|
||
const userId = getCurrentUserId();
|
||
if (!userId) {
|
||
console.warn('[KubikonEditor] save: no userId');
|
||
return;
|
||
}
|
||
|
||
// === ЗАЩИТА ОТ WIPE ===
|
||
// Срабатывает ТОЛЬКО на катастрофические потери — когда сцена почти
|
||
// пустая (≤ 5% от загруженного), И пользователь НЕ редактировал
|
||
// активно (dirtyRef отслеживает изменения). Это значит:
|
||
// - HMR reload оставил пустой scene
|
||
// - loadFromState упал по середине
|
||
// - hot reload во время dev'а
|
||
// Намеренные действия пользователя (стереть, очистить, sculpt down)
|
||
// НЕ блокируются — он сам знает что делает.
|
||
// 2026-05-13: реальный инцидент — карта 5.7M voxels была wipe'нута
|
||
// auto-save'ом после hot-reload.
|
||
const tmVoxels = sceneRef.current.terrainManager?.voxels?.size ?? 0;
|
||
const tmeshVoxels = sceneRef.current._terrainMesh?.getVoxelCount?.() ?? 0;
|
||
const rtCells = sceneRef.current._robloxTerrain?.getStats?.().solidCells ?? 0;
|
||
const currentVoxels = tmVoxels + tmeshVoxels + rtCells;
|
||
const lastLoaded = lastLoadedVoxelCountRef.current;
|
||
// dirtyRef === true → пользователь реально редактировал; такие
|
||
// сохранения никогда не блокируем (он знает что делает).
|
||
const userWasEditing = dirtyRef.current === true;
|
||
|
||
// === ЗАЩИТА ОТ WIPE: загрузка не отработала ===
|
||
// Реальный инцидент с проектом 226: loadFromState не догрузил
|
||
// robloxTerrain (lastLoaded остался 0), guard ниже пропустил
|
||
// (0 > 1000 = false), и автосейв затёр игру пустой сценой.
|
||
//
|
||
// Если это СУЩЕСТВУЮЩИЙ проект (id есть), guard "вооружён" пустым
|
||
// значением (lastLoaded===0) И сцена сейчас пустая по ВСЕМ
|
||
// коллекциям — значит загрузка не отработала. Сохранять нечего,
|
||
// блокируем (иначе пустышка затрёт реальный проект в БД).
|
||
if (currentProjectIdRef.current != null && lastLoaded === 0
|
||
&& !userWasEditing) {
|
||
const s = sceneRef.current;
|
||
const blockN = s.blockManager?.blocks?.size ?? 0;
|
||
const primN = s.primitiveManager?.instances?.size ?? 0;
|
||
const modelN = s.modelManager?.instances?.size ?? 0;
|
||
const umN = s.userModelManager?.instances?.size ?? 0;
|
||
// Скрипты и GUI — тоже контент: проект может быть «пустая сцена +
|
||
// скрипты» (например, игра целиком на game.scene.spawn из кода).
|
||
// demo-скрипт добавляется автоматически — не считаем его за контент.
|
||
const scriptN = (s._scripts || []).filter(x => x && x.id !== 'demo').length;
|
||
const guiN = s.guiManager?.getAll?.()?.length ?? 0;
|
||
const totalContent = currentVoxels + blockN + primN + modelN + umN
|
||
+ scriptN + guiN;
|
||
if (totalContent === 0) {
|
||
console.error(
|
||
'[KubikonEditor] SAVE BLOCKED: существующий проект, '
|
||
+ 'но сцена пустая по всем коллекциям и lastLoaded=0 — '
|
||
+ 'загрузка не отработала. Перезагрузите страницу.'
|
||
);
|
||
setSaveStatus('error');
|
||
setSaveDetail({
|
||
phase: 'Сохранение заблокировано: сцена пустая (загрузка не отработала). Перезагрузите страницу!',
|
||
pct: 0, error: true,
|
||
});
|
||
setTimeout(() => setSaveDetail(null), 8000);
|
||
return;
|
||
}
|
||
}
|
||
// Падение >95% (= потеря >95% содержимого) И scene считается чистой
|
||
// (не было user-edits с момента load) → блок.
|
||
const isCatastrophic = lastLoaded > 1000 && currentVoxels < lastLoaded * 0.05;
|
||
if (isCatastrophic && !userWasEditing) {
|
||
console.error(
|
||
`[KubikonEditor] SAVE BLOCKED: voxels dropped from ${lastLoaded} to ${currentVoxels} ` +
|
||
`(catastrophic loss, user wasn't editing). Reload page if this is wrong.`
|
||
);
|
||
setSaveStatus('error');
|
||
setSaveDetail({
|
||
phase: `Сохранение заблокировано: воксели упали с ${lastLoaded.toLocaleString()} до ${currentVoxels.toLocaleString()}. Перезагрузите страницу!`,
|
||
pct: 0,
|
||
error: true,
|
||
});
|
||
setTimeout(() => setSaveDetail(null), 8000);
|
||
return;
|
||
}
|
||
|
||
// === ЗАЩИТА ОТ WIPE: smooth-deco (деревья/трава/цветы) ===
|
||
// Отдельная проверка: воксели могли уцелеть, а деко — пропасть.
|
||
// Так и потерялся проект 222: loadFromState не догрузил деко,
|
||
// автосейв сохранил сцену с террейном но без деревьев.
|
||
const currentDeco = sceneRef.current._smoothDecoManager?.getStats?.().total ?? 0;
|
||
const lastDeco = lastLoadedDecoCountRef.current;
|
||
// Деко пропало почти полностью (>95%) И пользователь не редактировал.
|
||
const decoCatastrophic = lastDeco > 100 && currentDeco < lastDeco * 0.05;
|
||
if (decoCatastrophic && !userWasEditing) {
|
||
console.error(
|
||
`[KubikonEditor] SAVE BLOCKED: smooth-deco dropped from ${lastDeco} to ${currentDeco} ` +
|
||
`(catastrophic loss, user wasn't editing). Reload page if this is wrong.`
|
||
);
|
||
setSaveStatus('error');
|
||
setSaveDetail({
|
||
phase: `Сохранение заблокировано: декорации пропали (${lastDeco.toLocaleString()} → ${currentDeco.toLocaleString()}). Перезагрузите страницу!`,
|
||
pct: 0,
|
||
error: true,
|
||
});
|
||
setTimeout(() => setSaveDetail(null), 8000);
|
||
return;
|
||
}
|
||
|
||
isSavingRef.current = true;
|
||
setSaveStatus('saving');
|
||
setSaveDetail({ phase: 'Сбор данных сцены…', pct: 5 });
|
||
// Yield чтобы UI отрисовал оверлей перед тяжёлым serialize
|
||
await new Promise(r => setTimeout(r, 0));
|
||
|
||
// ФАЗА 1: serialize. Для большой карты с тысячами voxel'ов
|
||
// serialize строит RLE-формат — может занять ~500мс на 250м карте.
|
||
const state = sceneRef.current.serialize();
|
||
const voxelCount = sceneRef.current.terrainManager?.voxels?.size ?? 0;
|
||
setSaveDetail({ phase: voxelCount > 5000 ? 'Сжатие ландшафта (RLE)…' : 'Сериализация…', pct: 25 });
|
||
await new Promise(r => setTimeout(r, 0));
|
||
|
||
// ФАЗА 2: JSON.stringify. Для RLE-формата ~1.5МБ → ~30мс.
|
||
// Для legacy на больших картах это блокирует браузер на секунды!
|
||
const jsonStr = JSON.stringify(state);
|
||
const sizeKB = Math.round(jsonStr.length / 1024);
|
||
setSaveDetail({ phase: `Подготовка JSON (${sizeKB} КБ)…`, pct: 50, sizeKB });
|
||
await new Promise(r => setTimeout(r, 0));
|
||
|
||
const meta = metaRef.current;
|
||
const payload = {
|
||
user_id: userId,
|
||
title: (meta.title || projectNameRef.current || 'Новая игра').trim(),
|
||
description: meta.description || '',
|
||
genre: meta.genre || 'other',
|
||
thumbnail: meta.thumbnail || '',
|
||
is_public: !!meta.is_public,
|
||
multiplayer: !!meta.multiplayer,
|
||
max_players: Math.max(2, Math.min(50, Number(meta.max_players) || 10)),
|
||
project_data: jsonStr,
|
||
};
|
||
|
||
// ФАЗА 3: отправка на сервер. Для больших карт может занимать секунды.
|
||
setSaveDetail({ phase: `Отправка на сервер (${sizeKB} КБ)…`, pct: 75, sizeKB });
|
||
|
||
try {
|
||
if (currentProjectIdRef.current == null) {
|
||
const res = await Kubikon3DApi.createProject(userId, payload);
|
||
const newId = res.data?.id;
|
||
if (newId) {
|
||
currentProjectIdRef.current = newId;
|
||
window.history.replaceState({}, '', `/edit/${newId}`);
|
||
}
|
||
} else {
|
||
await Kubikon3DApi.updateProject(currentProjectIdRef.current, payload);
|
||
}
|
||
dirtyRef.current = false;
|
||
setSaveStatus('saved');
|
||
setSaveDetail({ phase: 'Готово!', pct: 100, sizeKB });
|
||
// Обновляем guard: то что сохранили — теперь "новый базовый" уровень
|
||
lastLoadedVoxelCountRef.current = voxelCount;
|
||
lastLoadedDecoCountRef.current =
|
||
sceneRef.current?._smoothDecoManager?.getStats?.().total ?? 0;
|
||
// Скрываем детали через 800мс
|
||
setTimeout(() => setSaveDetail(null), 800);
|
||
} catch (err) {
|
||
console.error('[KubikonEditor] save error:', err);
|
||
setSaveStatus('error');
|
||
setSaveDetail({
|
||
phase: `Ошибка: ${err?.message || 'не удалось сохранить'}`,
|
||
pct: 0,
|
||
sizeKB,
|
||
error: true,
|
||
});
|
||
// Ошибка остаётся на 4 сек чтобы пользователь успел прочитать
|
||
setTimeout(() => setSaveDetail(null), 4000);
|
||
} finally {
|
||
isSavingRef.current = false;
|
||
}
|
||
}, []);
|
||
|
||
/**
|
||
* Пометить что есть несохранённые изменения, перезапустить debounce-таймер.
|
||
*/
|
||
const markDirty = useCallback(() => {
|
||
dirtyRef.current = true;
|
||
setSaveStatus('dirty');
|
||
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
|
||
autoSaveTimerRef.current = setTimeout(() => {
|
||
autoSaveTimerRef.current = null;
|
||
if (dirtyRef.current) doSave();
|
||
}, AUTOSAVE_DEBOUNCE_MS);
|
||
}, [doSave]);
|
||
|
||
// Инициализация Babylon + загрузка проекта (если редактируем существующий)
|
||
useEffect(() => {
|
||
// RACE FIX: пока isLoading=true (auth ещё грузится), компонент
|
||
// рендерит `return null` — canvas НЕ в DOM, canvasRef.current=null.
|
||
// Раньше эффект в этом случае молча выходил и БОЛЬШЕ НЕ
|
||
// ПЕРЕЗАПУСКАЛСЯ (isLoading не было в deps) → страница навсегда
|
||
// висла на "Загрузка проекта… 0%". Теперь isLoading в deps: когда
|
||
// auth догрузится, компонент отрендерит canvas, эффект
|
||
// перезапустится и canvas будет на месте.
|
||
if (isLoading) return;
|
||
if (!isAuthenticated) return;
|
||
if (!canvasRef.current) return;
|
||
|
||
const scene = new BabylonScene(canvasRef.current);
|
||
scene.init();
|
||
sceneRef.current = scene;
|
||
|
||
// Передаём список моделей-персонажей в scene — Inspector их берёт через
|
||
// selection.selectPlayer(). Сначала Kenney-персонажи, затем R15-скины
|
||
// из манифеста (bacon-hair и др. — glTF с встроенным R15-скелетом).
|
||
const baseChars = MODEL_TYPES.filter(m => m.category === 'Персонажи');
|
||
scene.setPlayerOptions(baseChars);
|
||
// Догружаем R15-скины асинхронно — манифест в public.
|
||
fetch('/kubikon-assets/characters/skins_manifest.json')
|
||
.then(r => r.json())
|
||
.then(json => {
|
||
const skinOpts = (json.skins || []).map(s => ({
|
||
id: s.id, // 'skin_bacon-hair'
|
||
name: s.name || s.slug,
|
||
category: 'Персонажи',
|
||
}));
|
||
if (skinOpts.length > 0 && sceneRef.current) {
|
||
sceneRef.current.setPlayerOptions([...baseChars, ...skinOpts]);
|
||
}
|
||
})
|
||
.catch(e => console.warn('[KubikonEditor] R15 skins manifest load failed:', e));
|
||
|
||
scene.setActiveTool('select');
|
||
scene.setActiveBlockType(BLOCK_TYPES[0]?.id);
|
||
scene.setActiveModelType(MODEL_TYPES[0]?.id);
|
||
|
||
scene.setOnPlayChange((playing) => {
|
||
setIsPlaying(playing);
|
||
// При входе в Play отменяем solo-debug — Play имеет приоритет.
|
||
if (playing) setSoloScriptId(null);
|
||
// При выходе из Play — сбрасываем HUD
|
||
if (!playing) hudRef.current?.reset?.();
|
||
});
|
||
|
||
// Подписка на логи пользовательских скриптов (этап 2.1)
|
||
scene.setOnScriptLog((entry) => {
|
||
setScriptLogs((prev) => {
|
||
// Ограничиваем буфер 500 записей
|
||
const next = prev.length >= 500 ? prev.slice(-499) : prev.slice();
|
||
next.push(entry);
|
||
return next;
|
||
});
|
||
// Автоматически открываем консоль на ошибках
|
||
if (entry.level === 'error') setConsoleOpen(true);
|
||
});
|
||
|
||
// HUD от game.ui.* — пробрасываем в hudRef.current.handle({cmd, payload})
|
||
scene.setOnScriptHud((event) => {
|
||
hudRef.current?.handle?.(event);
|
||
});
|
||
|
||
// Скрипт сменил прицел через game.player.crosshair = '...'
|
||
scene.setOnScriptCrosshair?.((type) => {
|
||
setCrosshairUI(type);
|
||
});
|
||
|
||
// GUI-элементы — мгновенная синхронизация при создании/удалении/drag
|
||
scene.setOnGuiChange?.(() => {
|
||
setGuiList(scene.getGuiElements?.() || []);
|
||
// Библиотека картинок меняется редко (загрузил/удалил/переименовал).
|
||
// Чтобы не копировать base64-массив на каждый drag GUI, обновляем
|
||
// assetList только когда сменилась его сигнатура (id+name).
|
||
const assets = scene.getAssets?.() || [];
|
||
const sig = assets.map(a => a.id + ':' + a.name).join('|');
|
||
setAssetList((prev) => {
|
||
const prevSig = prev.map(a => a.id + ':' + a.name).join('|');
|
||
return prevSig === sig ? prev : assets;
|
||
});
|
||
// Библиотека звуков — та же логика (по сигнатуре id+name).
|
||
const sounds = scene.getSounds?.() || [];
|
||
const ssig = sounds.map(s => s.id + ':' + s.name).join('|');
|
||
setSoundList((prev) => {
|
||
const prevSig = prev.map(s => s.id + ':' + s.name).join('|');
|
||
return prevSig === ssig ? prev : sounds;
|
||
});
|
||
// Библиотека .glb-моделей — по сигнатуре id+name.
|
||
const glbs = scene.getGlbModels?.() || [];
|
||
const gsig = glbs.map(g => g.id + ':' + g.name).join('|');
|
||
setGlbList((prev) => {
|
||
const prevSig = prev.map(g => g.id + ':' + g.name).join('|');
|
||
return prevSig === gsig ? prev : glbs;
|
||
});
|
||
});
|
||
|
||
// Инвентарь — синхронизация для hot-bar
|
||
scene.setOnInventoryChange?.(() => {
|
||
setInventoryState(scene.getInventoryState?.() || { slots: [], activeIndex: 0 });
|
||
});
|
||
|
||
// Esc в редакторе — возвращаемся к инструменту «Выделить»
|
||
scene.setOnEditorEscape?.(() => {
|
||
setActiveTool('select');
|
||
});
|
||
|
||
// HP игрока
|
||
scene.setOnPlayerHpChange?.((hp) => {
|
||
setPlayerHp({ hp: hp.hp, maxHp: hp.maxHp });
|
||
if (hp.damaged) setHurtFlash(Date.now());
|
||
});
|
||
scene.setOnPlayerDeath?.(() => {
|
||
setPlayerHp({ hp: 0, maxHp: 100 });
|
||
// Через 2 сек автоматический респавн без overlay'я
|
||
setTimeout(() => {
|
||
const s = sceneRef.current;
|
||
if (!s || !s.player) return;
|
||
s.player.healFull?.();
|
||
if (s.player._pos && s._spawnPoint) {
|
||
const sp = s._spawnPoint;
|
||
const halfH = s.player.HALF_H ?? 0.9;
|
||
s.player._pos.set(sp.x, sp.y + halfH + 0.2, sp.z);
|
||
s.player._vy = 0;
|
||
}
|
||
}, 2000);
|
||
});
|
||
|
||
// Патроны
|
||
scene.setOnAmmoChange?.((ammo) => {
|
||
setWeaponAmmo(ammo);
|
||
// Прогресс перезарядки нужно обновлять плавно — будем тикать в render-loop через interval
|
||
});
|
||
|
||
// Подписка на любые изменения сцены → markDirty → перезапуск debounce.
|
||
scene.setOnSceneChange(() => {
|
||
markDirty();
|
||
// Иерархия изменилась — interval пересоберёт списки на след. тике.
|
||
hierarchyDirtyRef.current = true;
|
||
});
|
||
|
||
// Этап 5: подключаем API пользовательских моделей в BabylonScene,
|
||
// чтобы UserModelManager мог получить model_data по id через
|
||
// getUserModel и инкрементировать счётчик использований.
|
||
scene.setUserModelsApi(Kubikon3DApi);
|
||
scene.setCurrentUserId(getCurrentUserId());
|
||
// projectId нужен для game.save.* (универсальные сейвы).
|
||
// 'new' = ещё не сохранённый проект — сейвы не работают пока не сохранится.
|
||
if (id && id !== 'new') {
|
||
scene.setCurrentProjectId(Number(id));
|
||
}
|
||
// Подписка на изменение видимости стандартного HUD от game.hud.setVisible
|
||
scene.setOnStdHudVisibilityChange?.((v) => setStdHudVisible(v));
|
||
// Подписка на смену cursor-режима из скрипта (game.input.setCursorMode)
|
||
scene.setOnCursorModeChange?.((mode) => setUiCursorMode(mode === 'ui'));
|
||
|
||
// Подписка на изменение выделения — обновляем React-state для Inspector
|
||
scene.setOnSelectionChange((sel) => setSelection(sel));
|
||
|
||
// После постановки объекта — переключаемся на «Выделить» и оставляем
|
||
// выделение на поставленном (его уже сделал BabylonScene).
|
||
// Это позволяет сразу таскать его гизмо.
|
||
// ИСКЛЮЧЕНИЕ: для модели не сбрасываем выбор — чтобы можно было
|
||
// ставить много экземпляров подряд без повторного выбора в палитре.
|
||
scene.setOnPostPlace(() => {
|
||
setActiveTool(prev => prev === 'model' ? 'model' : 'select');
|
||
});
|
||
|
||
// Для нового проекта — создаём шаблон папок workspace
|
||
if (id === 'new') {
|
||
try {
|
||
const fm = sceneRef.current?.folderManager;
|
||
if (fm && fm.getAll().length === 0) {
|
||
fm.createFolder('Окружение', null);
|
||
fm.createFolder('Свет', null);
|
||
fm.createFolder('Звуки', null);
|
||
fm.createFolder('UI', null);
|
||
}
|
||
// ЭТАП 2.1 — демо-скрипт для нового проекта
|
||
sceneRef.current?.upsertScript('demo', DEMO_SCRIPT);
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
// 6.5 Preview-режим материала: грузим материал из БД через
|
||
// дизайнерский эндпоинт, динамически регистрируем кастомный
|
||
// блок с этой текстурой и делаем его активным. Авто-сейв
|
||
// ОТКЛЮЧАЕМ ниже (см. dirtyRef логика — она триггерится только
|
||
// когда id-числовой).
|
||
if (isMaterialPreview) {
|
||
(async () => {
|
||
try {
|
||
setProjectName('Тестирование материала');
|
||
// JWT minecraftia (тот же user_db, что и для team) хранится
|
||
// в localStorage['Authorization']. Bearer-префикс уже есть
|
||
// внутри строки (см. AuthContext.setItem).
|
||
const authHeader = localStorage.getItem('Authorization') || '';
|
||
const r = await fetch('/api-storys/designer/materials/' + previewMaterialId, {
|
||
headers: { 'Authorization': authHeader },
|
||
});
|
||
if (!r.ok) {
|
||
console.warn('[KubikonEditor preview-material] HTTP', r.status);
|
||
return;
|
||
}
|
||
const mat = await r.json();
|
||
const code = mat.code || ('mat' + previewMaterialId);
|
||
const blockId = 'preview_mat_' + previewMaterialId;
|
||
registerCustomBlockType({
|
||
id: blockId,
|
||
name: 'Тест: ' + (mat.name || code),
|
||
category: 'Тест',
|
||
texture: mat.file_path,
|
||
});
|
||
setProjectName('Тест материала: ' + (mat.name || code));
|
||
// Делаем блок активным + переключаемся на инструмент «блок»
|
||
setActiveBlockType(blockId);
|
||
setBlockCategory('Тест');
|
||
setActiveTool('block');
|
||
sceneRef.current?.setActiveBlockType?.(blockId);
|
||
sceneRef.current?.setActiveTool?.('block');
|
||
} catch (e) {
|
||
console.warn('[KubikonEditor preview-material] load failed', e);
|
||
}
|
||
})();
|
||
}
|
||
|
||
// Если редактируем существующий проект — грузим из БД.
|
||
if (id !== 'new' && /^\d+$/.test(id)) {
|
||
(async () => {
|
||
try {
|
||
// Передаём userId — иначе сервер не определит owner для
|
||
// чужих/своих черновиков и вернёт 403.
|
||
// getProjectWithRetry: если запрос подвис (dev-proxy,
|
||
// лаг сети) — повторяем с коротким таймаутом, чтобы
|
||
// загрузка не замирала на "0%" до 30-60с safety-timer'а.
|
||
const res = await Kubikon3DApi.getProjectWithRetry(
|
||
Number(id),
|
||
getCurrentUserId(),
|
||
);
|
||
if (!sceneRef.current) return;
|
||
const data = res.data;
|
||
setProjectName(data.title || 'Без названия');
|
||
metaRef.current = {
|
||
title: data.title || '',
|
||
description: data.description || '',
|
||
genre: data.genre || 'other',
|
||
thumbnail: data.thumbnail || '',
|
||
is_public: !!data.is_public,
|
||
multiplayer: !!data.multiplayer,
|
||
max_players: typeof data.max_players === 'number'
|
||
? data.max_players : 10,
|
||
};
|
||
// Состояние публикации (этап 3)
|
||
setProjectStatus({
|
||
status: data.status || 'draft',
|
||
age_rating: data.age_rating || 12,
|
||
rank_requested: data.rank_requested || 2,
|
||
moderator_comment: data.moderator_comment || null,
|
||
});
|
||
if (data.project_data) {
|
||
let parsed = null;
|
||
try { parsed = JSON.parse(data.project_data); }
|
||
catch (e) { console.warn('[KubikonEditor] bad project_data JSON', e); }
|
||
if (parsed) {
|
||
await sceneRef.current.loadFromState(parsed);
|
||
// Запоминаем сколько voxels было ЗАГРУЖЕНО — защита от
|
||
// wipe в auto-save. Считаем из обоих источников.
|
||
try {
|
||
const tm = sceneRef.current.terrainManager?.voxels?.size ?? 0;
|
||
const tmesh = sceneRef.current._terrainMesh?.getVoxelCount?.() ?? 0;
|
||
const rt = sceneRef.current._robloxTerrain?.getStats?.().solidCells ?? 0;
|
||
lastLoadedVoxelCountRef.current = tm + tmesh + rt;
|
||
// Отдельный guard для smooth-deco (деревья/трава/цветы).
|
||
// Воксели террейна могут уцелеть, а деко — пропасть,
|
||
// если loadFromState упал на этапе deco. Без этого
|
||
// автосейв затирал деко (реальный инцидент с проектом 222).
|
||
const deco = sceneRef.current._smoothDecoManager?.getStats?.().total ?? 0;
|
||
lastLoadedDecoCountRef.current = deco;
|
||
console.log(`[KubikonEditor] guard armed: lastLoadedVoxelCount=${tm + tmesh + rt} (legacy=${tm}, tmesh=${tmesh}, roblox=${rt}), deco=${deco}`);
|
||
// Триггерим пересчёт hasRobloxTerrain в TerrainGenPanel
|
||
if (rt > 0) setRobloxTerrainBump((n) => n + 1);
|
||
} catch (e) {}
|
||
// После loadFromState менеджеры дернули _onChange
|
||
// → markDirty. Сбрасываем — это "загрузка", не правка.
|
||
dirtyRef.current = false;
|
||
setSaveStatus('saved');
|
||
// Сбрасываем историю — загруженное состояние = новая база
|
||
sceneRef.current.history?.initialize();
|
||
// Синхронизируем UI-state TopRibbon из загруженной сцены
|
||
try {
|
||
setPlayerModelTypeUI(sceneRef.current.getPlayerModelType?.() || 'skin_bacon-hair');
|
||
const env = sceneRef.current.getEnvironmentState?.();
|
||
if (env?.preset) setEnvPresetUI(env.preset);
|
||
if (env?.dayDurationMin) setDayDurationMinUI(env.dayDurationMin);
|
||
if (env?.nightDurationMin) setNightDurationMinUI(env.nightDurationMin);
|
||
const wSize = sceneRef.current.getWorldSize?.();
|
||
if (wSize) setWorldSizeUI(wSize);
|
||
const sQ = sceneRef.current.getShadowQuality?.();
|
||
if (sQ) setShadowQualityUI(sQ);
|
||
const ch = sceneRef.current.getCrosshair?.();
|
||
if (ch) setCrosshairUI(ch);
|
||
setFloorEnabledUI(sceneRef.current.isFloorEnabled?.() !== false);
|
||
const a = sceneRef.current.getAudioState?.();
|
||
if (a?.ambientId) setAmbientIdUI(a.ambientId);
|
||
if (a?.musicId) setMusicIdUI(a.musicId);
|
||
} catch (e) { /* ignore */ }
|
||
// Не добавляем демо-скрипт автоматически в существующий
|
||
// проект: пользователь мог сознательно его удалить.
|
||
// Демо-скрипт ставится только при создании нового проекта (id='new').
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('[KubikonEditor] load error:', err);
|
||
setProjectName(`Игра #${id} (ошибка загрузки)`);
|
||
// Запрещаем auto-save — иначе пустая сцена и поломанный
|
||
// title затрут оригинальный проект через PUT.
|
||
loadFailedRef.current = true;
|
||
} finally {
|
||
console.log(`[KubikonEditor] setSceneLoading(false) — load finally (id=${id})`);
|
||
setSceneLoading(false);
|
||
}
|
||
})();
|
||
} else {
|
||
// Новый проект или некорректный id — загрузка не нужна
|
||
console.log(`[KubikonEditor] setSceneLoading(false) — new/invalid id=${id}`);
|
||
setSceneLoading(false);
|
||
}
|
||
|
||
const intervalId = setInterval(() => {
|
||
const s = sceneRef.current;
|
||
if (!s) return;
|
||
// Когда активен таб скрипта — Hierarchy не видна, не тратим CPU
|
||
// на пересборку списков (250мс цикл по сотням блоков заметно лагает).
|
||
if (activeTabRef.current !== 'scene') return;
|
||
// ОПТИМИЗАЦИЯ FPS: пересобираем списки иерархии ТОЛЬКО когда сцена
|
||
// реально менялась. Иначе interval каждые 250мс строил массивы +
|
||
// дёргал 8 setState → полный re-render KubikonEditor на пустом
|
||
// месте → FPS в редакторе падал вдвое (idle_ms ~28мс).
|
||
if (!hierarchyDirtyRef.current) return;
|
||
hierarchyDirtyRef.current = false;
|
||
setBlockCount(s.getBlockCount());
|
||
setModelCount(s.getModelCount());
|
||
|
||
// Списки для Hierarchy
|
||
if (s.blockManager) {
|
||
const arr = [];
|
||
for (const mesh of s.blockManager.blocks.values()) {
|
||
const m = mesh.metadata;
|
||
arr.push({
|
||
gridX: m.gridX, gridY: m.gridY, gridZ: m.gridZ,
|
||
blockTypeId: m.blockTypeId,
|
||
folderId: m.folderId ?? null,
|
||
});
|
||
}
|
||
setBlocksList(arr);
|
||
}
|
||
if (s.modelManager) {
|
||
const arr = [];
|
||
for (const data of s.modelManager.instances.values()) {
|
||
arr.push({
|
||
instanceId: data.instanceId,
|
||
modelTypeId: data.modelTypeId,
|
||
x: data.x, y: data.y, z: data.z,
|
||
rotationY: data.rotationY,
|
||
folderId: data.folderId ?? null,
|
||
name: data.name || null,
|
||
});
|
||
}
|
||
setModelsList(arr);
|
||
}
|
||
if (s.primitiveManager) {
|
||
// getAll() не включает folderId — добавляем вручную
|
||
const arr = s.primitiveManager.getAll();
|
||
for (const item of arr) {
|
||
const data = s.primitiveManager.instances.get(item.id);
|
||
item.folderId = data?.folderId ?? null;
|
||
}
|
||
setPrimitivesList(arr);
|
||
}
|
||
if (s.folderManager) {
|
||
setFoldersList(s.folderManager.getAll());
|
||
}
|
||
// Скрипты — синхронизируем для Hierarchy
|
||
const sc = s.getScripts?.() || [];
|
||
setScriptsList(sc);
|
||
// GUI-элементы
|
||
const gl = s.getGuiElements?.() || [];
|
||
setGuiList(gl);
|
||
// Библиотека картинок — синхронизируем по сигнатуре (id+name),
|
||
// чтобы при загрузке проекта список картинок появился в UI.
|
||
const assets = s.getAssets?.() || [];
|
||
const sig = assets.map(a => a.id + ':' + a.name).join('|');
|
||
setAssetList((prev) => {
|
||
const prevSig = prev.map(a => a.id + ':' + a.name).join('|');
|
||
return prevSig === sig ? prev : assets;
|
||
});
|
||
// Библиотека звуков — по сигнатуре (id+name).
|
||
const sounds = s.getSounds?.() || [];
|
||
const ssig = sounds.map(x => x.id + ':' + x.name).join('|');
|
||
setSoundList((prev) => {
|
||
const prevSig = prev.map(x => x.id + ':' + x.name).join('|');
|
||
return prevSig === ssig ? prev : sounds;
|
||
});
|
||
// Библиотека .glb-моделей — по сигнатуре (id+name).
|
||
const glbs = s.getGlbModels?.() || [];
|
||
const gsig = glbs.map(x => x.id + ':' + x.name).join('|');
|
||
setGlbList((prev) => {
|
||
const prevSig = prev.map(x => x.id + ':' + x.name).join('|');
|
||
return prevSig === gsig ? prev : glbs;
|
||
});
|
||
// Инвентарь
|
||
const inv = s.getInventoryState?.() || { slots: [], activeIndex: 0 };
|
||
setInventoryState(inv);
|
||
}, 250);
|
||
|
||
return () => {
|
||
clearInterval(intervalId);
|
||
if (autoSaveTimerRef.current) {
|
||
clearTimeout(autoSaveTimerRef.current);
|
||
autoSaveTimerRef.current = null;
|
||
}
|
||
scene.dispose();
|
||
sceneRef.current = null;
|
||
};
|
||
// isLoading в deps — без него эффект мог стрельнуть пока canvas
|
||
// ещё не в DOM (isLoading=true → компонент рендерит null) и больше
|
||
// не перезапускался → вечная "Загрузка проекта… 0%".
|
||
}, [isAuthenticated, isLoading, id, markDirty]);
|
||
|
||
// beforeunload — браузерный диалог нельзя кастомизировать (API запрещает).
|
||
// Auto-save срабатывает на каждое изменение через ~1.5 сек, поэтому риск
|
||
// потерять данные минимален. При закрытии вкладки — пробуем дотянуть
|
||
// отложенный auto-save синхронно. Без диалога-предупреждения.
|
||
useEffect(() => {
|
||
const onBeforeUnload = () => {
|
||
// Флаш скрипта первым: иначе он только пометит dirty, но doSave не успеет.
|
||
try { scriptEditorFlushRef.current?.(); } catch (_) {}
|
||
if (dirtyRef.current && autoSaveTimerRef.current) {
|
||
clearTimeout(autoSaveTimerRef.current);
|
||
autoSaveTimerRef.current = null;
|
||
try { doSave(); } catch (e) { /* ignore */ }
|
||
}
|
||
};
|
||
window.addEventListener('beforeunload', onBeforeUnload);
|
||
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
||
}, [doSave]);
|
||
|
||
// Изменение имени проекта — тоже считается dirty
|
||
useEffect(() => {
|
||
if (saveStatus === 'idle') return; // первый рендер — не триггерим
|
||
markDirty();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [projectName]);
|
||
|
||
useEffect(() => {
|
||
if (sceneRef.current) sceneRef.current.setActiveTool(activeTool);
|
||
}, [activeTool]);
|
||
|
||
useEffect(() => {
|
||
if (sceneRef.current) sceneRef.current.setActiveBlockType(activeBlockType);
|
||
}, [activeBlockType]);
|
||
|
||
useEffect(() => {
|
||
if (sceneRef.current) sceneRef.current.setActiveModelType(activeModelType);
|
||
}, [activeModelType]);
|
||
|
||
useEffect(() => {
|
||
if (sceneRef.current) sceneRef.current.setActivePrimitiveType(activePrimitiveType);
|
||
}, [activePrimitiveType]);
|
||
|
||
useEffect(() => {
|
||
if (sceneRef.current) sceneRef.current.setGizmoMode(gizmoMode);
|
||
}, [gizmoMode]);
|
||
|
||
useEffect(() => {
|
||
if (sceneRef.current) sceneRef.current.setGizmoSnap(snapStep);
|
||
}, [snapStep]);
|
||
|
||
if (isLoading || !isAuthenticated) {
|
||
return null;
|
||
}
|
||
|
||
const handleSave = () => {
|
||
// Немедленное сохранение по кнопке. Сбрасываем debounce.
|
||
if (autoSaveTimerRef.current) {
|
||
clearTimeout(autoSaveTimerRef.current);
|
||
autoSaveTimerRef.current = null;
|
||
}
|
||
doSave();
|
||
};
|
||
|
||
// Снять кадр текущей сцены для иконки игры (используется в модалке).
|
||
// Не useCallback — здесь только ref-доступ, мемоизация не нужна;
|
||
// плюс хук не должен идти после early-return.
|
||
const captureSceneScreenshot = () =>
|
||
sceneRef.current?.captureThumbnail(256) || null;
|
||
|
||
// Сохранить настройки из модалки (⚙ Настройки или ✨ Создать игру).
|
||
// В модалке теперь только: title, description, genre, thumbnail, is_public.
|
||
// Остальное (персонаж/время/аудио) — через TopRibbon.
|
||
const handleSettingsSave = (data) => {
|
||
metaRef.current = { ...metaRef.current, ...data };
|
||
setProjectName(data.title);
|
||
setSettingsModalOpen(false);
|
||
setInitialModalOpen(false);
|
||
if (autoSaveTimerRef.current) {
|
||
clearTimeout(autoSaveTimerRef.current);
|
||
autoSaveTimerRef.current = null;
|
||
}
|
||
dirtyRef.current = true;
|
||
doSave();
|
||
};
|
||
|
||
// Закрыть инициальный диалог: если пользователь не сохранил — возвращаемся в Studio.
|
||
const handleInitialClose = () => {
|
||
setInitialModalOpen(false);
|
||
// Если мы только что открылись на /new и нет id — возвращаемся в Studio.
|
||
if (currentProjectIdRef.current == null) {
|
||
navigate('/');
|
||
}
|
||
};
|
||
|
||
const handlePlay = () => {
|
||
const scene = sceneRef.current;
|
||
if (!scene) return;
|
||
if (scene.isPlaying()) {
|
||
scene.exitPlayMode();
|
||
setIsPlaying(false);
|
||
// Сбрасываем HUD (счётчик/таймер/метки) — иначе при следующем
|
||
// Play на экране остаётся счёт прошлой игры. setOnPlayChange
|
||
// дёргается только на Esc-выход, кнопка Стоп — нет.
|
||
hudRef.current?.reset?.();
|
||
} else {
|
||
// Флаш ScriptEditor — иначе при печати → сразу Play игра пойдёт
|
||
// со старым кодом (debounce 600мс ещё не сработал).
|
||
try { scriptEditorFlushRef.current?.(); } catch (_) {}
|
||
// Используем точку спавна установленную пользователем через
|
||
// маркер/Hierarchy/Inspector. Если по координатам спавна стоит
|
||
// блок — поднимаем чуть выше чтобы игрок не появился внутри блока.
|
||
const sp = scene._spawnPoint || { x: 0, y: 5, z: 0 };
|
||
let spawnY = sp.y;
|
||
if (scene.blockManager) {
|
||
// Поднимаем пока в (sp.x, spawnY, sp.z) есть блок
|
||
while (scene.blockManager.hasBlock(Math.round(sp.x), Math.round(spawnY), Math.round(sp.z))
|
||
&& spawnY < sp.y + 50) {
|
||
spawnY += 1;
|
||
}
|
||
}
|
||
scene.setSpawnPoint(sp.x, spawnY, sp.z);
|
||
scene.enterPlayMode();
|
||
setIsPlaying(true);
|
||
// Если активен таб скрипта — авто-переключение на «Сцена»,
|
||
// чтобы пользователь сразу видел игру.
|
||
setActiveTabId('scene');
|
||
}
|
||
};
|
||
|
||
const handleBack = () => {
|
||
if (sceneRef.current?.isPlaying()) {
|
||
sceneRef.current.exitPlayMode();
|
||
}
|
||
// Флаш ScriptEditor — без этого 600мс свежих правок не успеют
|
||
// попасть в _scripts[]/dirtyRef и confirm-диалог не покажется.
|
||
try { scriptEditorFlushRef.current?.(); } catch (_) {}
|
||
// Несохранённые изменения — спрашиваем
|
||
if (dirtyRef.current) {
|
||
const ok = window.confirm('Есть несохранённые изменения. Сохранить перед выходом?');
|
||
if (ok) {
|
||
doSave().finally(() => navigate('/'));
|
||
return;
|
||
}
|
||
}
|
||
navigate('/');
|
||
};
|
||
|
||
const activeBlockObj = BLOCK_TYPES.find(b => b.id === activeBlockType);
|
||
const activeModelObj = MODEL_TYPES.find(m => m.id === activeModelType);
|
||
const activeCategories = paletteTab === 'blocks' ? BLOCK_CATEGORIES : MODEL_CATEGORIES;
|
||
const activeCategoryState = paletteTab === 'blocks' ? blockCategory : modelCategory;
|
||
const setActiveCategoryState = paletteTab === 'blocks' ? setBlockCategory : setModelCategory;
|
||
|
||
// Редактор не работает на тач-устройствах — заглушка ПОСЛЕ всех хуков
|
||
// (rules-of-hooks: одинаковый порядок вызовов в каждом рендере).
|
||
if (!isDesktop) {
|
||
return <KubikonDesktopOnlyStub feature="Редактор Рублокса" />;
|
||
}
|
||
|
||
return (
|
||
<div className={cl.editor}>
|
||
{/* === Верхняя панель === */}
|
||
<header className={cl.topBar}>
|
||
<button className={cl.backBtn} onClick={handleBack} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||
<Icon name="arrow-left" size={14} /> Studio
|
||
</button>
|
||
|
||
<div className={cl.projectName} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<input
|
||
className={cl.nameInput}
|
||
value={projectName}
|
||
onChange={e => setProjectName(e.target.value)}
|
||
placeholder="Название игры"
|
||
/>
|
||
<span
|
||
onClick={async () => {
|
||
if (projectStatus.status === 'draft') return;
|
||
const pid = currentProjectIdRef.current;
|
||
if (!pid) return;
|
||
try {
|
||
const res = await Kubikon3DApi.getModerationHistory(pid);
|
||
setModerationHistory(res.data?.history || []);
|
||
setHistoryModalOpen(true);
|
||
} catch (e) {
|
||
console.warn('[KubikonEditor] history load error:', e);
|
||
}
|
||
}}
|
||
style={{
|
||
cursor: projectStatus.status === 'draft' ? 'default' : 'pointer',
|
||
}}
|
||
title={projectStatus.status === 'draft' ? '' : 'Открыть историю публикации'}
|
||
>
|
||
<PublishStatusBadge
|
||
status={projectStatus.status}
|
||
comment={projectStatus.moderator_comment}
|
||
/>
|
||
</span>
|
||
</div>
|
||
|
||
<div className={cl.topActions}>
|
||
<span className={`${cl.saveStatus} ${cl[`saveStatus_${saveStatus}`] || ''}`} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||
{saveStatus === 'saved' && <><Icon name="check" size={12} /> Сохранено</>}
|
||
{saveStatus === 'saving' && <><Icon name="loading" size={12} /> Сохранение...</>}
|
||
{saveStatus === 'dirty' && <>● Несохранено</>}
|
||
{saveStatus === 'error' && <><Icon name="warning" size={12} /> Ошибка</>}
|
||
{saveStatus === 'idle' && '—'}
|
||
</span>
|
||
<button
|
||
className={cl.toolbarBtn}
|
||
onClick={() => setSettingsModalOpen(true)}
|
||
title="Настройки игры"
|
||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||
>
|
||
<Icon name="settings" size={13} /> Настройки
|
||
</button>
|
||
<button className={cl.toolbarBtn} onClick={handleSave}><Icon name="save" size={13} /> Сохранить</button>
|
||
<button
|
||
className={cl.toolbarBtn}
|
||
onClick={async () => {
|
||
if (publishBan || isCantPublish) {
|
||
setBanWarningOpen(true);
|
||
return;
|
||
}
|
||
// Перед публикацией принудительно сохраняем
|
||
try { await doSave(); } catch (e) { /* ignore */ }
|
||
setPublishModalOpen(true);
|
||
}}
|
||
style={(publishBan || isCantPublish)
|
||
? { background: '#2e2e2e', color: '#7a7a7e', borderColor: '#3a3a3a',
|
||
cursor: 'not-allowed' }
|
||
: { background: 'linear-gradient(135deg, #4f74ff 0%, #3a57d8 100%)',
|
||
color: '#fff', borderColor: 'transparent',
|
||
boxShadow: '0 6px 16px rgba(79, 116, 255, 0.36)' }}
|
||
title={(publishBan || isCantPublish)
|
||
? 'Публикация недоступна — действует ограничение'
|
||
: 'Опубликовать игру в ленте'}
|
||
>
|
||
{(publishBan || isCantPublish) ? <><Icon name="hidden" size={13} /> Запрещено</> : <><Icon name="upload" size={13} /> Опубликовать</>}
|
||
</button>
|
||
{/* Кнопка баг-репорта в шапке — кликает по скрытой плавающей. */}
|
||
<button
|
||
onClick={() => document.querySelector('[data-kubikon-bug-btn]')?.click()}
|
||
title="Сообщить об ошибке в редакторе"
|
||
style={{
|
||
width: 38, height: 38,
|
||
border: '1px solid rgba(239, 68, 68, 0.4)',
|
||
background: 'rgba(239, 68, 68, 0.16)',
|
||
color: '#ff6b6b',
|
||
borderRadius: 10,
|
||
fontSize: 18,
|
||
cursor: 'pointer',
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||
transition: 'all 200ms cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||
flexShrink: 0,
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.background = '#ef4444';
|
||
e.currentTarget.style.color = '#fff';
|
||
e.currentTarget.style.borderColor = '#ef4444';
|
||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||
e.currentTarget.style.boxShadow = '0 8px 20px rgba(239, 68, 68, 0.40)';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.background = '#fef2f2';
|
||
e.currentTarget.style.color = '#ef4444';
|
||
e.currentTarget.style.borderColor = '#fecaca';
|
||
e.currentTarget.style.transform = 'translateY(0)';
|
||
e.currentTarget.style.boxShadow = 'none';
|
||
}}
|
||
>
|
||
<Icon name="bug" size={15} />
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Уведомление от модератора: если игру отправили на доработку
|
||
(`status='draft'` + `moderator_comment` задан) — показываем
|
||
видный баннер чтобы автор НЕ пропустил замечание.
|
||
Только tooltip на бейдже статуса плохо заметен — поэтому
|
||
делаем явный жёлтый блок с возможностью закрыть.
|
||
Закрытие сохраняется в sessionStorage по тексту комментария:
|
||
если модератор написал НОВЫЙ комментарий, баннер снова покажется. */}
|
||
{projectStatus.status === 'draft' && projectStatus.moderator_comment && (() => {
|
||
const dismissKey = `mod_comment_dismissed:${currentProjectIdRef.current}:${(projectStatus.moderator_comment || '').slice(0, 50)}`;
|
||
const dismissed = typeof sessionStorage !== 'undefined' && sessionStorage.getItem(dismissKey) === '1';
|
||
if (dismissed) return null;
|
||
return (
|
||
<div style={{
|
||
margin: '12px 16px 0',
|
||
padding: '14px 18px',
|
||
background: 'linear-gradient(135deg, #fff8e1 0%, #ffe0a0 100%)',
|
||
border: '2px solid #f59e0b',
|
||
borderRadius: 12,
|
||
display: 'flex',
|
||
alignItems: 'flex-start',
|
||
gap: 14,
|
||
position: 'relative',
|
||
boxShadow: '0 4px 14px rgba(245, 158, 11, 0.25)',
|
||
}}>
|
||
<div style={{ fontSize: 30, lineHeight: 1, flexShrink: 0, color: '#92400e' }}>
|
||
<Icon name="warning" size={28} />
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{
|
||
fontSize: 15, fontWeight: 800, color: '#5d3a0b',
|
||
marginBottom: 4, lineHeight: 1.3,
|
||
}}>
|
||
Игра отправлена на доработку
|
||
</div>
|
||
<div style={{
|
||
fontSize: 13, color: '#5d3a0b', marginBottom: 8,
|
||
opacity: 0.8, lineHeight: 1.4,
|
||
}}>
|
||
Модератор оставил комментарий — внеси правки и опубликуй игру снова.
|
||
</div>
|
||
<div style={{
|
||
fontSize: 14, color: '#2c1d05',
|
||
background: 'rgba(255,255,255,0.6)',
|
||
padding: '10px 14px',
|
||
borderRadius: 8,
|
||
borderLeft: '3px solid #92400e',
|
||
whiteSpace: 'pre-wrap',
|
||
wordBreak: 'break-word',
|
||
lineHeight: 1.5,
|
||
}}>
|
||
{projectStatus.moderator_comment}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
try { sessionStorage.setItem(dismissKey, '1'); } catch {}
|
||
// принудительный re-render через изменение projectStatus
|
||
setProjectStatus(s => ({ ...s }));
|
||
}}
|
||
aria-label="Скрыть"
|
||
title="Скрыть"
|
||
style={{
|
||
position: 'absolute',
|
||
top: 8, right: 10,
|
||
width: 28, height: 28,
|
||
background: 'transparent', border: 'none',
|
||
cursor: 'pointer', fontSize: 22,
|
||
color: '#92400e', lineHeight: 1,
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* === Roblox-style лента инструментов === */}
|
||
<TopRibbon
|
||
activeTab={ribbonTab}
|
||
onTabChange={setRibbonTab}
|
||
gizmoMode={gizmoMode}
|
||
onGizmoModeChange={(m) => {
|
||
// Манипулятор и инструмент создания взаимоисключающие.
|
||
// Выбор любого манипулятора (включая select) → activeTool='select'.
|
||
setGizmoMode(m);
|
||
setActiveTool('select');
|
||
}}
|
||
snap={snapStep}
|
||
onSnapChange={setSnapStep}
|
||
activeTool={activeTool}
|
||
onToolChange={(t) => {
|
||
// Инструмент создания → отключаем манипулятор-гизмо.
|
||
setActiveTool(t);
|
||
setGizmoMode('select');
|
||
if (t === 'block') setPaletteTab('blocks');
|
||
if (t === 'model') setPaletteTab('models');
|
||
if (t === 'primitive') setPaletteTab('primitives');
|
||
if (t === 'gui') setPaletteTab('gui');
|
||
}}
|
||
isPlaying={isPlaying}
|
||
onPlayToggle={handlePlay}
|
||
onSetSpawn={() => {
|
||
sceneRef.current?.setSpawnAtCamera();
|
||
}}
|
||
hasSelection={!!selection}
|
||
onDuplicate={() => sceneRef.current?.duplicateSelected()}
|
||
onAlignToFloor={() => sceneRef.current?.alignSelectedToFloor()}
|
||
onDelete={() => sceneRef.current?.deleteSelected()}
|
||
onClearScene={() => {
|
||
// Двухступенчатый диалог:
|
||
// 1. «Удалить всё в сцене?» → если ДА:
|
||
// 2. «Удалить и ландшафт тоже?» → если ДА — стираем террейн;
|
||
// если НЕТ — только модели/блоки/примитивы.
|
||
if (!window.confirm('Удалить всё в сцене? Это очистит блоки, модели и примитивы.')) {
|
||
return;
|
||
}
|
||
const s = sceneRef.current;
|
||
if (!s) return;
|
||
s.blockManager?.clear();
|
||
s.modelManager?.clear();
|
||
s.primitiveManager?.clear();
|
||
s.userModelManager?.clear();
|
||
s.folderManager?.clear();
|
||
const hasTerrain = (s.terrainManager?.count?.() || 0) > 0;
|
||
const hasDecorations = (s.decoManager?.count?.() || 0) > 0;
|
||
// Smooth terrain (Roblox-style) — отдельная подсистема
|
||
const hasSmoothTerrain = !!s._robloxTerrain
|
||
&& (s._robloxTerrain.getStats?.().solidCells ?? 0) > 0;
|
||
if ((hasTerrain || hasDecorations || hasSmoothTerrain)
|
||
&& window.confirm('Удалить также весь ландшафт и декорации?')) {
|
||
s._terrainHistoryOpen?.();
|
||
s.terrainManager?.clear();
|
||
s._terrainHistoryClose?.();
|
||
// Этап 6: decorations тоже чистим. Раньше они оставались
|
||
// в сцене как «висящие в воздухе» цветы/грибы.
|
||
s.decoManager?.clear();
|
||
// Гладкий ландшафт чистим тем же публичным методом, что
|
||
// и кнопка ✖ в TerrainGenPanel.
|
||
if (s.clearRobloxTerrain) {
|
||
s.clearRobloxTerrain();
|
||
setRobloxTerrainBump((n) => n + 1);
|
||
}
|
||
}
|
||
}}
|
||
onViewPreset={(p) => sceneRef.current?.setViewPreset(p)}
|
||
onCreateVoxelModel={() => {
|
||
setEditingUserModelId(null);
|
||
setModelEditorMode('voxel');
|
||
}}
|
||
onCreateSmoothModel={() => {
|
||
setEditingUserModelId(null);
|
||
setModelEditorMode('smooth');
|
||
}}
|
||
onOpenStandardModels={() => {
|
||
setToolboxInitialSection('standard');
|
||
setToolboxOpen(true);
|
||
}}
|
||
onOpenMyModels={() => {
|
||
setToolboxInitialSection('mine');
|
||
setToolboxOpen(true);
|
||
}}
|
||
onOpenCommunityModels={() => {
|
||
setToolboxInitialSection('community');
|
||
setToolboxOpen(true);
|
||
}}
|
||
playerModelType={playerModelType}
|
||
onPlayerModelChange={(id) => {
|
||
setPlayerModelTypeUI(id);
|
||
if (sceneRef.current) {
|
||
sceneRef.current.setPlayerModelType(id);
|
||
metaRef.current.player_model_type = id;
|
||
markDirty();
|
||
}
|
||
}}
|
||
envPreset={envPreset}
|
||
onEnvPresetChange={(p) => {
|
||
setEnvPresetUI(p);
|
||
if (sceneRef.current) {
|
||
sceneRef.current.setEnvironmentPreset(p);
|
||
metaRef.current.env_preset = p;
|
||
markDirty();
|
||
}
|
||
}}
|
||
dayDurationMin={dayDurationMin}
|
||
nightDurationMin={nightDurationMin}
|
||
onDayDurationChange={(v) => {
|
||
if (!Number.isFinite(v) || v <= 0) return;
|
||
setDayDurationMinUI(v);
|
||
if (sceneRef.current) {
|
||
sceneRef.current.setCycleDuration(v, nightDurationMin);
|
||
markDirty();
|
||
}
|
||
}}
|
||
onNightDurationChange={(v) => {
|
||
if (!Number.isFinite(v) || v <= 0) return;
|
||
setNightDurationMinUI(v);
|
||
if (sceneRef.current) {
|
||
sceneRef.current.setCycleDuration(dayDurationMin, v);
|
||
markDirty();
|
||
}
|
||
}}
|
||
ambientId={ambientId}
|
||
onAmbientChange={(id) => {
|
||
setAmbientIdUI(id);
|
||
if (sceneRef.current) {
|
||
sceneRef.current.setAmbientAudio({ preset: id });
|
||
metaRef.current.ambient_id = id;
|
||
markDirty();
|
||
}
|
||
}}
|
||
musicId={musicId}
|
||
onMusicChange={(id) => {
|
||
setMusicIdUI(id);
|
||
if (sceneRef.current) {
|
||
sceneRef.current.setMusicAudio({ preset: id });
|
||
metaRef.current.music_id = id;
|
||
markDirty();
|
||
}
|
||
}}
|
||
worldSize={worldSize}
|
||
onWorldSizeChange={(sz) => {
|
||
if (!Number.isFinite(sz) || sz <= 0) return;
|
||
setWorldSizeUI(sz);
|
||
if (sceneRef.current) {
|
||
sceneRef.current.setWorldSize(sz);
|
||
markDirty();
|
||
}
|
||
}}
|
||
shadowQuality={shadowQuality}
|
||
onShadowQualityChange={(q) => {
|
||
setShadowQualityUI(q);
|
||
if (sceneRef.current) {
|
||
sceneRef.current.setShadowQuality?.(q);
|
||
markDirty();
|
||
}
|
||
}}
|
||
/>
|
||
|
||
{/* gridTemplateColumns зависит от того, открыта ли левая панель:
|
||
* panel + viewport + inspector → '240px 1fr 280px'
|
||
* просто viewport + inspector → '1fr 280px'
|
||
* Иначе viewport уходит в первую колонку 240px и всё ломается. */}
|
||
<div
|
||
className={cl.workspace}
|
||
style={{
|
||
gridTemplateColumns: (['block', 'primitive', 'model', 'terrain', 'gen', 'gui'].includes(activeTool))
|
||
? '240px minmax(0, 1fr) 280px'
|
||
: 'minmax(0, 1fr) 280px',
|
||
}}
|
||
>
|
||
{/* Левая панель — показывается ТОЛЬКО когда выбран инструмент
|
||
* которому нужно дополнительное место для параметров:
|
||
* • block → палитра блоков
|
||
* • primitive → список примитивов
|
||
* • model → недавние модели + кнопка тулбокса
|
||
* • terrain → редактор ландшафта
|
||
* • gen → процедурный генератор
|
||
* • gui → палитра 2D-интерфейса
|
||
* При других инструментах (select, erase, manipulators)
|
||
* панели нет совсем — viewport растягивается. */}
|
||
{(['block', 'primitive', 'model', 'terrain', 'gen', 'gui'].includes(activeTool)) && (
|
||
<aside className={cl.leftPanel}>
|
||
{activeTool === 'gen' ? (
|
||
<TerrainGenPanel
|
||
// Реактивный признак: есть ли в сцене гладкий ландшафт.
|
||
// robloxTerrainBump — счётчик-триггер пересчёта.
|
||
hasRobloxTerrain={
|
||
(robloxTerrainBump >= 0)
|
||
&& !!sceneRef.current?._robloxTerrain
|
||
&& (sceneRef.current._robloxTerrain.getStats?.().solidCells ?? 0) > 0
|
||
}
|
||
onApply={async (opts) => {
|
||
try {
|
||
const s = sceneRef.current;
|
||
if (!s || !window.__voxelGenerate) return;
|
||
await window.__voxelGenerate(opts);
|
||
} catch (e) { console.warn('voxelGenerate failed:', e); }
|
||
}}
|
||
onApplyRoblox={async (opts) => {
|
||
try {
|
||
if (!window.__robloxTest) {
|
||
console.warn('__robloxTest not available yet');
|
||
return;
|
||
}
|
||
await window.__robloxTest(opts?.size ?? 50, opts?.params);
|
||
setRobloxTerrainBump((n) => n + 1);
|
||
// Обновляем wipe-guard: новый ландшафт — это
|
||
// НОВОЕ намерение пользователя, не wipe старого.
|
||
// Иначе save BLOCKED: voxels dropped from X to Y.
|
||
try {
|
||
const s = sceneRef.current;
|
||
const tm = s?.terrainManager?.voxels?.size ?? 0;
|
||
const tmesh = s?._terrainMesh?.getVoxelCount?.() ?? 0;
|
||
const rt = s?._robloxTerrain?.getStats?.().solidCells ?? 0;
|
||
lastLoadedVoxelCountRef.current = tm + tmesh + rt;
|
||
console.log(`[KubikonEditor] guard reset after robloxTest: ${tm + tmesh + rt}`);
|
||
} catch (e) {}
|
||
} catch (e) { console.warn('robloxTest failed:', e); }
|
||
}}
|
||
onClearRoblox={() => {
|
||
try {
|
||
const s = sceneRef.current;
|
||
console.log('[KubikonEditor] onClearRoblox click; scene=', !!s, 'method=', !!s?.clearRobloxTerrain);
|
||
if (s?.clearRobloxTerrain) {
|
||
s.clearRobloxTerrain();
|
||
} else if (window.__robloxTestClear) {
|
||
window.__robloxTestClear();
|
||
}
|
||
setRobloxTerrainBump((n) => n + 1);
|
||
// Сбрасываем guard — пользователь намеренно очистил.
|
||
lastLoadedVoxelCountRef.current = 0;
|
||
} catch (e) { console.warn('clearRoblox failed:', e); }
|
||
}}
|
||
onClose={() => setActiveTool('select')}
|
||
/>
|
||
) : activeTool === 'terrain' ? (
|
||
<TerrainPanel
|
||
onBrushChange={(patch) => {
|
||
try { sceneRef.current?.setTerrainBrush?.(patch); } catch (e) {}
|
||
}}
|
||
onAction={(name) => {
|
||
const s = sceneRef.current;
|
||
if (!s) return;
|
||
try {
|
||
if (name === 'undo') s.undoTerrain?.();
|
||
else if (name === 'redo') s.redoTerrain?.();
|
||
else if (name === 'clear-region') s.clearTerrainRegion?.();
|
||
else if (name === 'clear-terrain') {
|
||
if (window.confirm('Удалить весь ландшафт?')) {
|
||
s._terrainHistoryOpen?.();
|
||
s.terrainManager?.clear?.();
|
||
s._terrainHistoryClose?.();
|
||
}
|
||
}
|
||
else if (name === 'export-heightmap') {
|
||
// Снять точную карту высот гладкого ландшафта
|
||
// (raycast по реальному мешу) и скачать JSON.
|
||
const hm = s.exportRobloxHeightmap?.(2);
|
||
if (!hm) {
|
||
alert('Гладкий ландшафт не найден. Сначала создайте или загрузите гладкий ландшафт.');
|
||
} else {
|
||
const blob = new Blob([JSON.stringify(hm)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `heightmap_${hm.cols}x${hm.rows}.json`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
const hit = hm.heights.filter(h => h !== null).length;
|
||
alert(`Карта высот снята: ${hm.cols}×${hm.rows} точек (шаг ${hm.step}м), ${hit} попаданий. Файл скачан.`);
|
||
}
|
||
}
|
||
} catch (e) { console.error('[TerrainPanel onAction]', name, e); }
|
||
}}
|
||
/>
|
||
) : (
|
||
<>
|
||
{/* Заголовок панели — показывает что именно сейчас активно
|
||
* (раньше тут были табы Блоки/Примитивы/Модели, теперь
|
||
* выбор делается в TopRibbon, а здесь только название). */}
|
||
<div className={cl.paletteHeader}>
|
||
<span className={cl.paletteHeaderIcon}>
|
||
<Icon
|
||
name={paletteTab === 'blocks' ? 'boxes'
|
||
: paletteTab === 'primitives' ? 'cube'
|
||
: 'trees'}
|
||
size={15}
|
||
/>
|
||
</span>
|
||
<span className={cl.paletteHeaderTitle}>
|
||
{paletteTab === 'blocks' && `Блоки (${BLOCK_TYPES.length})`}
|
||
{paletteTab === 'primitives' && `Примитивы (${PALETTE_PRIMITIVE_TYPES.length})`}
|
||
{paletteTab === 'models' && `Модели (${MODEL_TYPES.length})`}
|
||
{paletteTab === 'gui' && 'Интерфейс'}
|
||
</span>
|
||
</div>
|
||
|
||
{(paletteTab === 'blocks' || paletteTab === 'primitives') && (
|
||
<div className={cl.searchBox}>
|
||
<input
|
||
type="text"
|
||
className={cl.searchInput}
|
||
placeholder="Поиск..."
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{!search && paletteTab === 'blocks' && (
|
||
<div className={cl.categoryTabs}>
|
||
{activeCategories.map(cat => (
|
||
<button
|
||
key={cat}
|
||
className={`${cl.categoryTab} ${activeCategoryState === cat ? cl.categoryTabActive : ''}`}
|
||
onClick={() => setActiveCategoryState(cat)}
|
||
title={cat}
|
||
>
|
||
{cat}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Сетка блоков или моделей в зависимости от вкладки */}
|
||
{paletteTab === 'blocks' ? (
|
||
<div className={cl.blocksGrid}>
|
||
{filteredBlocks.length === 0 ? (
|
||
<div className={cl.blocksEmpty}>Ничего не найдено</div>
|
||
) : (
|
||
filteredBlocks.map(b => (
|
||
<button
|
||
key={b.id}
|
||
className={`${cl.blockSwatch} ${activeBlockType === b.id ? cl.blockSwatchActive : ''}`}
|
||
onClick={() => {
|
||
setActiveBlockType(b.id);
|
||
setActiveTool('block');
|
||
setGizmoMode('select'); // отключаем гизмо чтобы не мешал ставить
|
||
}}
|
||
title={b.name}
|
||
>
|
||
<img
|
||
src={blockPreview(b)}
|
||
alt={b.name}
|
||
className={cl.blockSwatchImg}
|
||
onError={e => { e.target.style.display = 'none'; }}
|
||
/>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
) : paletteTab === 'primitives' ? (
|
||
<div className={cl.modelsGrid}>
|
||
{PALETTE_PRIMITIVE_TYPES.map(p => (
|
||
<button
|
||
key={p.id}
|
||
className={`${cl.modelCard} ${activePrimitiveType === p.id ? cl.modelCardActive : ''}`}
|
||
onClick={() => {
|
||
setActivePrimitiveType(p.id);
|
||
setActiveTool('primitive');
|
||
setGizmoMode('select');
|
||
}}
|
||
title={p.name}
|
||
>
|
||
<span className={cl.modelIcon}>
|
||
<Icon name={p.icon} size={26} />
|
||
</span>
|
||
<span className={cl.modelName}>{p.name}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : paletteTab === 'gui' ? (
|
||
<GuiPalette
|
||
onPlaceCenter={(type) => {
|
||
// Клик по карточке — добавить в центр экрана.
|
||
const id = sceneRef.current?.createGuiElement?.(type, {});
|
||
if (id) {
|
||
sceneRef.current?.selection?.selectGui?.(id);
|
||
setActiveTool('select');
|
||
}
|
||
markDirty();
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className={cl.modelsTabContent}>
|
||
{/* Кнопка открытия каталога — сверху (задача: кнопка
|
||
выше истории, чтобы доступ к каталогу был на виду). */}
|
||
<button
|
||
className={cl.activeModelChangeBtn}
|
||
onClick={() => {
|
||
setToolboxInitialSection('standard');
|
||
setToolboxOpen(true);
|
||
}}
|
||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, justifyContent: 'center' }}
|
||
>
|
||
<Icon name="package" size={13} /> Открыть модели
|
||
</button>
|
||
|
||
{/* Мои модели (.glb) — импортированные пользователем (Фаза 5.8). */}
|
||
<div style={{ marginTop: 10 }}>
|
||
<div className={cl.activeModelLabel}>Мои модели (.glb)</div>
|
||
{glbList.map(m => (
|
||
<div
|
||
key={m.id}
|
||
onClick={() => {
|
||
// Выбрать импортированную модель для постановки.
|
||
setActiveModelType('glb:' + m.id);
|
||
setActiveTool('model');
|
||
setGizmoMode('select');
|
||
}}
|
||
title={'Поставить «' + m.name + '»'}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: 6,
|
||
padding: '5px 8px', borderRadius: 5, marginTop: 3,
|
||
cursor: 'pointer', fontSize: 12,
|
||
border: '1px solid var(--border)',
|
||
background: activeModelType === 'glb:' + m.id
|
||
? 'var(--accent)' : 'var(--bg-mid)',
|
||
color: activeModelType === 'glb:' + m.id
|
||
? '#fff' : 'var(--text)',
|
||
}}
|
||
>
|
||
<Icon name="package" size={13} />
|
||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{m.name}
|
||
</span>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
sceneRef.current?.removeGlb(m.id);
|
||
setGlbList(sceneRef.current?.getGlbModels?.() || []);
|
||
markDirty();
|
||
}}
|
||
title="Удалить модель"
|
||
style={{
|
||
width: 16, height: 16, borderRadius: 8, border: 'none',
|
||
background: '#c0392b', color: '#fff', fontSize: 10,
|
||
lineHeight: '16px', cursor: 'pointer', padding: 0,
|
||
}}
|
||
>×</button>
|
||
</div>
|
||
))}
|
||
<button
|
||
onClick={() => glbFileRef.current?.click()}
|
||
style={{
|
||
width: '100%', marginTop: 4, padding: '6px 8px',
|
||
borderRadius: 4, border: '1px dashed var(--accent)',
|
||
background: 'transparent', color: 'var(--accent)',
|
||
cursor: 'pointer', fontSize: 12,
|
||
}}
|
||
>
|
||
+ Импортировать .glb
|
||
</button>
|
||
<input
|
||
ref={glbFileRef}
|
||
type="file"
|
||
accept=".glb,.gltf,model/gltf-binary"
|
||
style={{ display: 'none' }}
|
||
onChange={async (e) => {
|
||
const f = e.target.files && e.target.files[0];
|
||
e.target.value = '';
|
||
if (!f) return;
|
||
const s = sceneRef.current;
|
||
if (!s) return;
|
||
const res = await s.addGlbFromFile(f);
|
||
if (res && res.ok) {
|
||
setGlbList(s.getGlbModels?.() || []);
|
||
markDirty();
|
||
} else if (res && res.error) {
|
||
alert('Не удалось импортировать модель: ' + res.error);
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* История недавних моделей — под кнопкой. Показывает
|
||
и стандартные модели, и созданные вручную ('user:'). */}
|
||
{recentModels.length > 0 && (
|
||
<div className={cl.recentModelsBlock} style={{ marginTop: 10 }}>
|
||
<div className={cl.activeModelLabel}>Недавние модели</div>
|
||
<div className={cl.recentModelsGrid}>
|
||
{recentModels.map(rid => {
|
||
// Пользовательская модель — строим объект из кэша метаданных.
|
||
if (typeof rid === 'string' && rid.startsWith('user:')) {
|
||
const meta = recentUserMeta[rid];
|
||
if (!meta) return null;
|
||
return (
|
||
<RecentModelThumb
|
||
key={rid}
|
||
model={{
|
||
id: rid,
|
||
isUserModel: true,
|
||
name: meta.title,
|
||
title: meta.title,
|
||
kind: meta.kind,
|
||
thumbnail_b64: meta.thumbnail_b64,
|
||
}}
|
||
active={activeModelType === rid}
|
||
onClick={() => {
|
||
setActiveModelType(rid);
|
||
setActiveTool('model');
|
||
setGizmoMode('select');
|
||
pushRecentModel(rid, meta);
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
// Стандартная модель из MODEL_TYPES.
|
||
const m = MODEL_TYPES.find(x => x.id === rid);
|
||
if (!m) return null;
|
||
return (
|
||
<RecentModelThumb
|
||
key={rid}
|
||
model={m}
|
||
active={activeModelType === rid}
|
||
onClick={() => {
|
||
setActiveModelType(rid);
|
||
setActiveTool('model');
|
||
setGizmoMode('select');
|
||
pushRecentModel(rid);
|
||
}}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{paletteTab !== 'gui' && (
|
||
<div className={cl.activeBlockInfo}>
|
||
Выбрано: <b>{
|
||
paletteTab === 'blocks' ? (activeBlockObj?.name || '—')
|
||
: paletteTab === 'primitives'
|
||
? (PRIMITIVE_TYPES.find(p => p.id === activePrimitiveType)?.name || '—')
|
||
: (activeModelObj?.name || '—')
|
||
}</b>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</aside>
|
||
)}
|
||
|
||
{/* Viewport — содержит табы Сцена/Скрипты + canvas */}
|
||
<div className={cl.viewport} style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<SceneTabs
|
||
tabs={openTabs.map(t => {
|
||
// Для скрипт-табов вычисляем title на лету по актуальному state,
|
||
// чтобы переименование подхватывалось без обновления openTabs.
|
||
if (t.kind === 'script') {
|
||
const sc = scriptsList.find(s => s.id === t.id);
|
||
const title = sc?.name || (t.id === 'demo' ? 'Демо-скрипт' : t.id);
|
||
return { ...t, title };
|
||
}
|
||
return t;
|
||
})}
|
||
activeId={activeTabId}
|
||
onSelect={setActiveTabId}
|
||
onClose={closeTab}
|
||
/>
|
||
<div
|
||
ref={viewportRef}
|
||
style={{ flex: 1, position: 'relative', minHeight: 0 }}
|
||
onDragOver={(e) => {
|
||
// Принимаем drag только GUI-карточек из палитры.
|
||
if (e.dataTransfer.types.includes('application/x-kubikon-gui')) {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'copy';
|
||
}
|
||
}}
|
||
onDrop={(e) => {
|
||
const type = e.dataTransfer.getData('application/x-kubikon-gui');
|
||
if (!type) return;
|
||
e.preventDefault();
|
||
// Позиция отпускания → проценты от viewport (центр элемента).
|
||
const rect = viewportRef.current?.getBoundingClientRect();
|
||
if (!rect || rect.width === 0) return;
|
||
const px = ((e.clientX - rect.left) / rect.width) * 100;
|
||
const py = ((e.clientY - rect.top) / rect.height) * 100;
|
||
const x = Math.max(0, Math.min(100, Math.round(px)));
|
||
const y = Math.max(0, Math.min(100, Math.round(py)));
|
||
const id = sceneRef.current?.createGuiElement?.(type, { x, y, anchor: 'center' });
|
||
if (id) {
|
||
sceneRef.current?.selection?.selectGui?.(id);
|
||
setActiveTool('select');
|
||
}
|
||
markDirty();
|
||
}}
|
||
>
|
||
<canvas
|
||
ref={canvasRef}
|
||
className={cl.canvas}
|
||
style={{ visibility: activeTabId === 'scene' ? 'visible' : 'hidden' }}
|
||
/>
|
||
{isMaterialPreview && (
|
||
<div style={{
|
||
position: 'absolute', top: 10,
|
||
left: '50%', transform: 'translateX(-50%)',
|
||
background: '#06b6d4',
|
||
color: '#fff', padding: '8px 16px',
|
||
borderRadius: 8, fontSize: 13, fontWeight: 600,
|
||
zIndex: 50,
|
||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||
pointerEvents: 'none',
|
||
}}>
|
||
Режим тестирования материала: ставь блоки, проверяй tile —
|
||
ничего не сохраняется в БД
|
||
</div>
|
||
)}
|
||
<div
|
||
className={cl.viewportHint}
|
||
style={isPlaying ? {
|
||
bottom: 12,
|
||
left: 12,
|
||
transform: 'none',
|
||
maxWidth: 360,
|
||
lineHeight: 1.5,
|
||
} : undefined}
|
||
>
|
||
{isPlaying ? (
|
||
<>
|
||
<div><Icon name="gamepad" size={13} /> WASD — идти · Space — прыжок · Shift — спринт</div>
|
||
<div>C — вид · Tab — курсор для GUI · Esc — выйти</div>
|
||
</>
|
||
) : activeTool === 'select'
|
||
? 'ЛКМ — выбрать · R — повернуть модель · Del — удалить · Esc — снять · WASD — летать · ПКМ — повернуть камеру'
|
||
: activeTool === 'model'
|
||
? 'ЛКМ — поставить модель · R — повернуть превью на 90° · Shift+ЛКМ — удалить · WASD — летать'
|
||
: activeTool === 'gui'
|
||
? 'Перетащи элемент из палитры на экран · элемент на экране — тяни мышью, ручки по краям — размер'
|
||
: 'ЛКМ — поставить · Shift+ЛКМ — удалить · WASD — летать · ПКМ — повернуть · Колесо — зум · F — фокус'
|
||
}
|
||
</div>
|
||
{/* GUI-элементы (Frame/Text/Button/Image) — видны и в редакторе, и в Play.
|
||
В редакторе можно временно скрыть через глазок в Иерархии (guiOverlayHidden).
|
||
В Play-режиме скрытие не действует — игроку всё видно. */}
|
||
{activeTabId === 'scene' && (!guiOverlayHidden || isPlaying) && (
|
||
<GuiOverlay
|
||
elements={guiList}
|
||
isPlaying={isPlaying}
|
||
selectedId={selection?.type === 'gui' ? selection.id : null}
|
||
containerRef={viewportRef}
|
||
resolveAsset={(id) =>
|
||
sceneRef.current?.assetManager?.getDataUrl?.(id) || null}
|
||
onSelect={(id) => {
|
||
sceneRef.current?.selection?.selectGui?.(id);
|
||
}}
|
||
onUpdate={(id, patch) => {
|
||
sceneRef.current?.updateGuiElement?.(id, patch);
|
||
// Re-select чтобы Inspector обновил координаты в полях
|
||
if (sceneRef.current?.selection?._selection?.type === 'gui'
|
||
&& sceneRef.current.selection._selection.id === id) {
|
||
sceneRef.current.selection.selectGui(id);
|
||
}
|
||
markDirty();
|
||
}}
|
||
onDelete={(id) => {
|
||
sceneRef.current?.removeGuiElement?.(id);
|
||
markDirty();
|
||
}}
|
||
onPlayClick={(id, kind, value) => {
|
||
const rt = sceneRef.current?.gameRuntime;
|
||
if (!rt) return;
|
||
if (kind === 'textchange') {
|
||
// TextBox: текст изменился — обновляем элемент,
|
||
// чтобы game.gui.get(id).text возвращал актуальное значение
|
||
sceneRef.current?.updateGuiElement?.(id, { text: value });
|
||
rt.routeGlobalEvent('guiTextChange', { id, value });
|
||
return;
|
||
}
|
||
if (kind === 'submit') {
|
||
// TextBox: нажат Enter — событие game.gui.onSubmit
|
||
sceneRef.current?.updateGuiElement?.(id, { text: value });
|
||
rt.routeGlobalEvent('guiSubmit', { id, value });
|
||
return;
|
||
}
|
||
// Клик по кнопке в Play → routeEvent во все скрипты с target = этой кнопкой
|
||
rt.routeEvent({ kind: 'gui', id }, 'click', {});
|
||
// Глобальное событие для game.gui.onClick(id, fn)
|
||
rt.routeGlobalEvent('guiClick', { id });
|
||
}}
|
||
/>
|
||
)}
|
||
{/* Индикатор UI-режима курсора (Tab) */}
|
||
{isPlaying && uiCursorMode && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: 14, left: '50%',
|
||
transform: 'translateX(-50%)',
|
||
background: 'linear-gradient(135deg, #3357ff 0%, #1e2da5 100%)',
|
||
color: '#fff',
|
||
padding: '8px 16px',
|
||
borderRadius: 999,
|
||
fontSize: 13,
|
||
fontWeight: 800,
|
||
letterSpacing: 0.2,
|
||
pointerEvents: 'none',
|
||
zIndex: 32,
|
||
boxShadow: '0 8px 20px rgba(51, 87, 255, 0.45)',
|
||
backdropFilter: 'blur(8px)',
|
||
border: '1px solid rgba(255,255,255,0.18)',
|
||
}}>
|
||
<Icon name="cursor" size={13} /> Курсор для GUI · Tab — вернуть управление камерой
|
||
</div>
|
||
)}
|
||
{/* Loading overlay — пока сцена грузится */}
|
||
{sceneLoading && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
background:
|
||
'radial-gradient(ellipse at center, rgba(51, 87, 255, 0.20) 0%, rgba(7, 10, 20, 0.94) 65%)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 18,
|
||
zIndex: 50,
|
||
backdropFilter: 'blur(8px)',
|
||
}}>
|
||
<div style={{
|
||
width: 56, height: 56,
|
||
border: '4px solid rgba(51, 87, 255, 0.20)',
|
||
borderTopColor: '#3357ff',
|
||
borderRadius: '50%',
|
||
animation: 'kubikonSpin 0.9s linear infinite',
|
||
filter: 'drop-shadow(0 0 10px rgba(51, 87, 255, 0.45))',
|
||
}} />
|
||
<div style={{
|
||
color: '#f1f5fb',
|
||
fontSize: 15,
|
||
fontWeight: 800,
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
textShadow: '0 2px 8px rgba(0,0,0,0.45)',
|
||
letterSpacing: 0.3,
|
||
}}>
|
||
Загрузка проекта…
|
||
</div>
|
||
{/* Progress bar + label */}
|
||
<div style={{
|
||
width: 320,
|
||
maxWidth: '80vw',
|
||
marginTop: 4,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 8,
|
||
alignItems: 'center',
|
||
}}>
|
||
<div style={{
|
||
width: '100%',
|
||
height: 8,
|
||
background: 'rgba(255,255,255,0.10)',
|
||
borderRadius: 999,
|
||
overflow: 'hidden',
|
||
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.35)',
|
||
}}>
|
||
<div style={{
|
||
width: `${Math.max(2, Math.min(100, loadProgress.percent))}%`,
|
||
height: '100%',
|
||
background: 'linear-gradient(90deg, #3357ff 0%, #6b8cff 100%)',
|
||
borderRadius: 999,
|
||
transition: 'width 0.25s ease-out',
|
||
boxShadow: '0 0 12px rgba(51, 87, 255, 0.55)',
|
||
}} />
|
||
</div>
|
||
<div style={{
|
||
color: '#aac3ff',
|
||
fontSize: 12,
|
||
fontWeight: 700,
|
||
letterSpacing: 0.2,
|
||
textShadow: '0 1px 4px rgba(0,0,0,0.5)',
|
||
textAlign: 'center',
|
||
fontVariantNumeric: 'tabular-nums',
|
||
}}>
|
||
{loadProgress.percent}% · {loadProgress.label || 'Подготовка…'}
|
||
</div>
|
||
</div>
|
||
<style>{`
|
||
@keyframes kubikonSpin {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
`}</style>
|
||
</div>
|
||
)}
|
||
{/* Player HUD: HP + ammo (только в Play, и если скрипт не скрыл) */}
|
||
<PlayerHud
|
||
visible={isPlaying && stdHudVisible}
|
||
hp={playerHp.hp}
|
||
maxHp={playerHp.maxHp}
|
||
ammo={weaponAmmo}
|
||
damaged={Date.now() - hurtFlash < 350}
|
||
/>
|
||
{/* Hot-bar инвентаря (виден только в Play, и если скрипт не скрыл) */}
|
||
<Hotbar
|
||
visible={isPlaying && stdHudVisible}
|
||
slots={inventoryState.slots}
|
||
activeIndex={inventoryState.activeIndex}
|
||
onSelect={(i) => sceneRef.current?.setActiveInventorySlot?.(i)}
|
||
/>
|
||
{/* HUD от game.ui.* — поверх viewport, только в Play */}
|
||
<GameHud visible={isPlaying} hudRef={hudRef} />
|
||
{/* Мини-карта — показывается когда включён terrain-streaming
|
||
* (большая карта, рендерится только видимая часть).
|
||
* Помогает ориентироваться на огромных мирах. */}
|
||
<MinimapOverlay scene={sceneRef.current} />
|
||
|
||
{/* Оверлей детального прогресса сохранения — для больших карт,
|
||
* где сохранение может занимать несколько секунд. */}
|
||
{saveDetail && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: 60,
|
||
left: '50%',
|
||
transform: 'translateX(-50%)',
|
||
background: saveDetail.error
|
||
? 'rgba(180, 40, 40, 0.95)'
|
||
: 'rgba(30, 32, 40, 0.95)',
|
||
border: '1px solid rgba(255,255,255,0.15)',
|
||
borderRadius: 8,
|
||
padding: '12px 20px',
|
||
minWidth: 320,
|
||
zIndex: 100,
|
||
boxShadow: '0 6px 20px rgba(0,0,0,0.4)',
|
||
pointerEvents: 'none',
|
||
}}>
|
||
<div style={{ fontSize: 13, color: '#fff', fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<Icon name={saveDetail.error ? 'warning' : 'save'} size={14} />
|
||
{saveDetail.error ? 'Ошибка сохранения' : 'Сохранение игры'}
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.75)', marginBottom: 8 }}>
|
||
{saveDetail.phase}
|
||
</div>
|
||
{!saveDetail.error && (
|
||
<div style={{
|
||
width: '100%',
|
||
height: 6,
|
||
background: 'rgba(255,255,255,0.12)',
|
||
borderRadius: 3,
|
||
overflow: 'hidden',
|
||
}}>
|
||
<div style={{
|
||
width: `${saveDetail.pct}%`,
|
||
height: '100%',
|
||
background: 'linear-gradient(90deg, #3357FF 0%, #4a7aff 100%)',
|
||
transition: 'width 0.2s ease-out',
|
||
}} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* Прицел поверх viewport — настраивается в Inspector «Игрок» */}
|
||
{isPlaying && crosshair !== 'none' && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
left: '50%', top: '50%',
|
||
transform: 'translate(-50%, -50%)',
|
||
pointerEvents: 'none',
|
||
zIndex: 31,
|
||
color: '#fff',
|
||
mixBlendMode: 'difference',
|
||
}}>
|
||
{crosshair === 'dot' && (
|
||
<div style={{
|
||
width: 5, height: 5, borderRadius: '50%',
|
||
background: '#fff',
|
||
boxShadow: '0 0 0 1px rgba(0,0,0,0.7)',
|
||
}} />
|
||
)}
|
||
{crosshair === 'cross' && (
|
||
<div style={{ position: 'relative', width: 22, height: 22 }}>
|
||
<div style={{ position: 'absolute', left: '50%', top: 0, width: 2, height: 22, marginLeft: -1, background: '#fff', boxShadow: '0 0 0 1px rgba(0,0,0,0.7)' }} />
|
||
<div style={{ position: 'absolute', top: '50%', left: 0, height: 2, width: 22, marginTop: -1, background: '#fff', boxShadow: '0 0 0 1px rgba(0,0,0,0.7)' }} />
|
||
</div>
|
||
)}
|
||
{crosshair === 'circle' && (
|
||
<div style={{
|
||
width: 18, height: 18, borderRadius: '50%',
|
||
border: '2px solid #fff',
|
||
boxShadow: '0 0 0 1px rgba(0,0,0,0.7), inset 0 0 0 1px rgba(0,0,0,0.7)',
|
||
}} />
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* Кнопка вкл/выкл консоли скриптов (этап 2.1) */}
|
||
<button
|
||
onClick={() => setConsoleOpen(v => !v)}
|
||
title="Консоль скриптов"
|
||
style={{
|
||
position: 'absolute',
|
||
right: 14, bottom: 14,
|
||
padding: '10px 16px',
|
||
borderRadius: 999,
|
||
background: consoleOpen
|
||
? 'linear-gradient(135deg, #3357ff 0%, #1e2da5 100%)'
|
||
: 'rgba(20, 24, 45, 0.85)',
|
||
color: '#fff',
|
||
border: '1px solid ' + (consoleOpen ? 'transparent' : 'rgba(255, 255, 255, 0.12)'),
|
||
cursor: 'pointer',
|
||
fontSize: 12,
|
||
fontWeight: 800,
|
||
letterSpacing: 0.2,
|
||
zIndex: 40,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
backdropFilter: 'blur(20px)',
|
||
WebkitBackdropFilter: 'blur(20px)',
|
||
boxShadow: consoleOpen
|
||
? '0 8px 20px rgba(51, 87, 255, 0.45)'
|
||
: '0 4px 12px rgba(0, 0, 0, 0.35)',
|
||
transition: 'all 200ms cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
}}
|
||
>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="clipboard" size={13} /> Консоль</span>
|
||
{scriptLogs.length > 0 && !consoleOpen && (
|
||
<span style={{
|
||
background: 'linear-gradient(135deg, #ff6f7a 0%, #c0303f 100%)',
|
||
color: '#fff',
|
||
fontSize: 10, padding: '2px 8px',
|
||
borderRadius: 999, fontWeight: 800,
|
||
boxShadow: '0 2px 6px rgba(239, 68, 68, 0.40)',
|
||
}}>
|
||
{scriptLogs.filter(l => l.level === 'error').length || scriptLogs.length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
<ScriptConsole
|
||
visible={consoleOpen}
|
||
logs={scriptLogs}
|
||
onClear={() => setScriptLogs([])}
|
||
onClose={() => setConsoleOpen(false)}
|
||
/>
|
||
|
||
{/* Monaco-редактор скрипта (этап 2.2). Активен когда выбран таб со скриптом. */}
|
||
{activeTabId !== 'scene' && (() => {
|
||
const sc = (sceneRef.current?.getScripts?.() || []).find(s => s.id === activeTabId);
|
||
if (!sc) return null;
|
||
return (
|
||
<ScriptEditor
|
||
scriptId={sc.id}
|
||
value={sc.code}
|
||
target={sc.target}
|
||
flushRef={scriptEditorFlushRef}
|
||
isSoloRunning={soloScriptId === sc.id}
|
||
onSave={(code) => {
|
||
sceneRef.current?.upsertScript(sc.id, code, sc.target);
|
||
setScriptsList(sceneRef.current?.getScripts?.() || []);
|
||
markDirty();
|
||
}}
|
||
onRunSolo={(code) => {
|
||
if (!sceneRef.current) return;
|
||
// Если уже идёт solo — останавливаем
|
||
if (sceneRef.current.isSoloRunning?.() && soloScriptId === sc.id) {
|
||
sceneRef.current.stopSoloScript?.();
|
||
setSoloScriptId(null);
|
||
return;
|
||
}
|
||
// Сохранить актуальный код перед запуском
|
||
sceneRef.current.upsertScript(sc.id, code, sc.target);
|
||
sceneRef.current.startSoloScript?.(sc.id);
|
||
setSoloScriptId(sc.id);
|
||
setConsoleOpen(true);
|
||
}}
|
||
/>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Правая панель — Hierarchy + Inspector */}
|
||
<aside className={cl.rightPanel}>
|
||
<div className={cl.panelTitle}>Объекты сцены</div>
|
||
<HierarchyPanel
|
||
blocks={blocksList}
|
||
models={modelsList}
|
||
primitives={primitivesList}
|
||
folders={foldersList}
|
||
scripts={scriptsList}
|
||
onSelectScript={(scriptId) => {
|
||
sceneRef.current?.selection?.selectScript?.(scriptId);
|
||
setActiveTool('select');
|
||
openScriptTab(scriptId);
|
||
}}
|
||
onCreateScript={(target) => {
|
||
// target: null (глобальный) или {kind, x/y/z или id}
|
||
const id = `script_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||
// Нормализуем target в формат {kind, ref|id}
|
||
let normalized = null;
|
||
if (target) {
|
||
if (target.kind === 'block') {
|
||
normalized = { kind: 'block', ref: { x: target.x, y: target.y, z: target.z } };
|
||
} else if (target.kind === 'model' || target.kind === 'primitive') {
|
||
normalized = { kind: target.kind, id: target.id };
|
||
}
|
||
}
|
||
const tpl = normalized ? NEW_OBJECT_SCRIPT_TEMPLATE : NEW_SCRIPT_TEMPLATE;
|
||
sceneRef.current?.upsertScript(id, tpl, normalized);
|
||
markDirty();
|
||
setScriptsList(sceneRef.current?.getScripts?.() || []);
|
||
sceneRef.current?.selection?.selectScript?.(id);
|
||
openScriptTab(id);
|
||
}}
|
||
onDeleteScript={(scriptId) => {
|
||
sceneRef.current?.removeScript(scriptId);
|
||
markDirty();
|
||
setScriptsList(sceneRef.current?.getScripts?.() || []);
|
||
// Закрыть таб если был открыт
|
||
closeTab(scriptId);
|
||
// Снять выделение если был выбран
|
||
if (selection?.type === 'script' && selection?.scriptId === scriptId) {
|
||
sceneRef.current?.clearSelection?.();
|
||
}
|
||
}}
|
||
onRenameScript={(scriptId, name) => {
|
||
if (sceneRef.current?.renameScript(scriptId, name)) {
|
||
setScriptsList(sceneRef.current?.getScripts?.() || []);
|
||
// Title таба пересчитывается автоматически из scriptsList
|
||
// (см. <SceneTabs tabs={...map(...)} />).
|
||
markDirty();
|
||
}
|
||
}}
|
||
onRenameModel={(instanceId, name) => {
|
||
if (sceneRef.current?.renameModel(instanceId, name)) {
|
||
markDirty();
|
||
}
|
||
}}
|
||
onRenamePrimitive={(id, name) => {
|
||
if (sceneRef.current?.renamePrimitive(id, name)) {
|
||
markDirty();
|
||
}
|
||
}}
|
||
onSelectSound={() => {
|
||
sceneRef.current?.selection?.selectSound?.();
|
||
setActiveTool('select');
|
||
}}
|
||
soundList={soundList}
|
||
onUploadSound={async (file) => {
|
||
const s = sceneRef.current;
|
||
if (!s) return;
|
||
const res = await s.addSoundFromFile(file);
|
||
if (res && res.ok) {
|
||
setSoundList(s.getSounds?.() || []);
|
||
markDirty();
|
||
} else if (res && res.error) {
|
||
alert('Не удалось загрузить звук: ' + res.error);
|
||
}
|
||
}}
|
||
onRemoveSound={(id) => {
|
||
const s = sceneRef.current;
|
||
if (!s) return;
|
||
s.removeSound(id);
|
||
setSoundList(s.getSounds?.() || []);
|
||
markDirty();
|
||
}}
|
||
onSelectPlayer={() => {
|
||
sceneRef.current?.selection?.selectPlayer?.();
|
||
setActiveTool('select');
|
||
}}
|
||
onSelectPlayerProps={() => {
|
||
sceneRef.current?.selection?.selectPlayerProps?.();
|
||
setActiveTool('select');
|
||
}}
|
||
floorEnabled={floorEnabled}
|
||
onSelectFloor={() => {
|
||
sceneRef.current?.selection?.selectFloor?.();
|
||
setActiveTool('select');
|
||
}}
|
||
onCreateFloor={() => {
|
||
sceneRef.current?.setFloorEnabled?.(true);
|
||
setFloorEnabledUI(true);
|
||
markDirty();
|
||
}}
|
||
onDeleteFloor={() => {
|
||
sceneRef.current?.setFloorEnabled?.(false);
|
||
setFloorEnabledUI(false);
|
||
markDirty();
|
||
}}
|
||
guiElements={guiList}
|
||
onSelectGui={(id) => {
|
||
sceneRef.current?.selection?.selectGui?.(id);
|
||
setActiveTool('select');
|
||
}}
|
||
guiOverlayHidden={guiOverlayHidden}
|
||
onToggleGuiOverlay={() => setGuiOverlayHidden(v => !v)}
|
||
onCreateGui={(type, opts) => {
|
||
const id = sceneRef.current?.createGuiElement?.(type, opts || {});
|
||
if (id) {
|
||
sceneRef.current?.selection?.selectGui?.(id);
|
||
setActiveTool('select');
|
||
}
|
||
markDirty();
|
||
}}
|
||
onSetGuiParent={(id, parentId) => {
|
||
sceneRef.current?.guiManager?.setParent?.(id, parentId);
|
||
markDirty();
|
||
}}
|
||
onDeleteGui={(id) => {
|
||
sceneRef.current?.removeGuiElement?.(id);
|
||
markDirty();
|
||
}}
|
||
onRenameGui={(id, name) => {
|
||
sceneRef.current?.renameGuiElement?.(id, name);
|
||
markDirty();
|
||
}}
|
||
onMoveGuiZ={(id, dir) => {
|
||
sceneRef.current?.moveGuiElementZ?.(id, dir);
|
||
markDirty();
|
||
}}
|
||
selection={selection}
|
||
onSelectBlock={(x, y, z) => {
|
||
sceneRef.current?.selectBlockAt(x, y, z);
|
||
setActiveTool('select');
|
||
}}
|
||
onSelectModel={(id) => {
|
||
sceneRef.current?.selectModelByInstanceId(id);
|
||
setActiveTool('select');
|
||
}}
|
||
onSelectPrimitive={(id) => {
|
||
sceneRef.current?.selection?.selectPrimitiveById(id);
|
||
setActiveTool('select');
|
||
}}
|
||
onSelectSpawn={() => {
|
||
sceneRef.current?.selection?.selectSpawn();
|
||
setActiveTool('select');
|
||
// Активируем гизмо «Двигать» чтобы можно было сразу таскать
|
||
setGizmoMode('move');
|
||
}}
|
||
onSelectLighting={() => {
|
||
sceneRef.current?.selection?.selectLighting();
|
||
setActiveTool('select');
|
||
}}
|
||
onDeleteBlock={(x, y, z) => {
|
||
sceneRef.current?.blockManager?.removeBlock(x, y, z);
|
||
sceneRef.current?.clearSelection();
|
||
}}
|
||
onDeleteModel={(id) => {
|
||
sceneRef.current?.modelManager?.removeInstance(id);
|
||
sceneRef.current?.clearSelection();
|
||
}}
|
||
onDeletePrimitive={(id) => {
|
||
sceneRef.current?.primitiveManager?.removeInstance(id);
|
||
sceneRef.current?.clearSelection();
|
||
}}
|
||
onFocusSelection={() => sceneRef.current?.focusOnSelection()}
|
||
onCreateFolder={(name, parentId) =>
|
||
sceneRef.current?.createFolder(name, parentId)}
|
||
onRenameFolder={(id, name) =>
|
||
sceneRef.current?.renameFolder(id, name)}
|
||
onRemoveFolder={(id, deleteContent) =>
|
||
sceneRef.current?.removeFolder(id, deleteContent)}
|
||
onSetFolderVisible={(id, visible) =>
|
||
sceneRef.current?.setFolderVisible(id, visible)}
|
||
onAssignToFolder={(kind, ref, folderId) =>
|
||
sceneRef.current?.assignToFolder(kind, ref, folderId)}
|
||
/>
|
||
|
||
<div className={cl.panelTitle}>Свойства</div>
|
||
<InspectorPanel
|
||
selection={selection}
|
||
onMoveTo={(x, y, z) => sceneRef.current?.moveSelectedTo(x, y, z)}
|
||
onRotateTo={(rad) => sceneRef.current?.rotateSelectedModelTo(rad)}
|
||
onScaleTo={(s) => sceneRef.current?.scaleSelectedModelTo(s)}
|
||
onDelete={() => sceneRef.current?.deleteSelected()}
|
||
onFocus={() => sceneRef.current?.focusOnSelection()}
|
||
onResizePrimitive={(sx, sy, sz) =>
|
||
sceneRef.current?.resizeSelectedPrimitiveTo(sx, sy, sz)}
|
||
onSetPrimitiveProps={(patch) =>
|
||
sceneRef.current?.setSelectedPrimitivePropsTo(patch)}
|
||
onSetAnchored={(val) =>
|
||
sceneRef.current?.setSelectedAnchored(val)}
|
||
onSetMass={(val) =>
|
||
sceneRef.current?.setSelectedMass(val)}
|
||
onSetModelProps={(patch) =>
|
||
sceneRef.current?.setSelectedModelProps(patch)}
|
||
onAddWeaponToInventory={(modelTypeId, params) => {
|
||
const inv = sceneRef.current?.inventory;
|
||
if (!inv) return;
|
||
const mt = getModelType(modelTypeId);
|
||
const id = inv.add({
|
||
kind: 'weapon',
|
||
modelTypeId,
|
||
name: mt?.name || 'Оружие',
|
||
params: { ...params },
|
||
});
|
||
if (id < 0) {
|
||
alert('Инвентарь полон. Освободите слот.');
|
||
}
|
||
markDirty();
|
||
}}
|
||
onSetBlockProps={(patch) =>
|
||
sceneRef.current?.setSelectedBlockProps(patch)}
|
||
onSetLightingProps={(patch) => {
|
||
sceneRef.current?.setLightingProps?.(patch);
|
||
// Синхронизируем UI ribbon
|
||
if (patch.shadowQuality) setShadowQualityUI(patch.shadowQuality);
|
||
if (patch.envPreset) setEnvPresetUI(patch.envPreset);
|
||
if (typeof patch.dayDurationMin === 'number' && patch.dayDurationMin > 0) {
|
||
setDayDurationMinUI(patch.dayDurationMin);
|
||
}
|
||
if (typeof patch.nightDurationMin === 'number' && patch.nightDurationMin > 0) {
|
||
setNightDurationMinUI(patch.nightDurationMin);
|
||
}
|
||
markDirty();
|
||
}}
|
||
onSetSoundProps={(patch) => {
|
||
sceneRef.current?.setSoundProps?.(patch);
|
||
if (patch.ambientId !== undefined) setAmbientIdUI(patch.ambientId);
|
||
if (patch.musicId !== undefined) setMusicIdUI(patch.musicId);
|
||
markDirty();
|
||
}}
|
||
onSetPlayerProps={(patch) => {
|
||
const s = sceneRef.current;
|
||
s?.setPlayerProps?.(patch);
|
||
if (patch.playerModelType) setPlayerModelTypeUI(patch.playerModelType);
|
||
if (typeof patch.crosshair === 'string') setCrosshairUI(patch.crosshair);
|
||
// Re-select чтобы Inspector обновил значения слайдера/кнопок
|
||
const t = s?.selection?._selection?.type;
|
||
if (t === 'player') s.selection.selectPlayer();
|
||
else if (t === 'playerProps') s.selection.selectPlayerProps();
|
||
markDirty();
|
||
}}
|
||
onSetFloorProps={(patch) => {
|
||
const s = sceneRef.current;
|
||
if (!s) return;
|
||
if (typeof patch.enabled === 'boolean') {
|
||
s.setFloorEnabled(patch.enabled);
|
||
setFloorEnabledUI(patch.enabled);
|
||
}
|
||
if (typeof patch.worldSize === 'number' && patch.worldSize > 0) {
|
||
s.setWorldSize(patch.worldSize);
|
||
setWorldSizeUI(patch.worldSize);
|
||
}
|
||
// Re-select чтобы Inspector обновил значения
|
||
if (s.selection?._selection?.type === 'floor') s.selection.selectFloor();
|
||
markDirty();
|
||
}}
|
||
guiElements={guiList}
|
||
onSetGuiProps={(id, patch) => {
|
||
const s = sceneRef.current;
|
||
if (!s) return;
|
||
s.updateGuiElement?.(id, patch);
|
||
// Re-select чтобы Inspector синхронно показал новые значения
|
||
if (s.selection?._selection?.type === 'gui' && s.selection._selection.id === id) {
|
||
s.selection.selectGui(id);
|
||
}
|
||
markDirty();
|
||
}}
|
||
onDeleteGui={(id) => {
|
||
sceneRef.current?.removeGuiElement?.(id);
|
||
markDirty();
|
||
}}
|
||
assetList={assetList}
|
||
onUploadAsset={async (file) => {
|
||
const s = sceneRef.current;
|
||
if (!s) return { ok: false, error: 'сцена не готова' };
|
||
const res = await s.addAssetFromFile(file);
|
||
if (res.ok) {
|
||
setAssetList(s.getAssets?.() || []);
|
||
markDirty();
|
||
}
|
||
return res;
|
||
}}
|
||
onRemoveAsset={(id) => {
|
||
const s = sceneRef.current;
|
||
if (!s) return;
|
||
s.removeAsset(id);
|
||
setAssetList(s.getAssets?.() || []);
|
||
// Инспектор мог показывать снятую текстуру — обновим выделение.
|
||
const sel = s.selection?._selection;
|
||
if (sel?.type === 'primitive') s.selection.selectPrimitive?.(sel.id);
|
||
else if (sel?.type === 'gui') s.selection.selectGui?.(sel.id);
|
||
markDirty();
|
||
}}
|
||
/>
|
||
</aside>
|
||
</div>
|
||
|
||
<footer className={cl.statusBar}>
|
||
<span className={cl.statusItem}><Icon name="boxes" size={13} /> Блоков: {blockCount}</span>
|
||
<span className={cl.statusItem}><Icon name="trees" size={13} /> Моделей: {modelCount}</span>
|
||
<span className={cl.statusItem}><Icon name="wrench" size={13} /> {
|
||
{ select: 'Выделить', block: 'Блок', model: 'Модель', erase: 'Удалить' }[activeTool] || activeTool
|
||
}</span>
|
||
<span className={cl.statusItem}>
|
||
{isPlaying ? <><Icon name="gamepad" size={13} /> РЕЖИМ ИГРЫ</> : <><Icon name="rename" size={13} /> Редактор</>}
|
||
</span>
|
||
<span className={cl.statusItem}><Icon name="circle" size={13} /> Babylon.js</span>
|
||
</footer>
|
||
|
||
{/* === Модалки === */}
|
||
<GameSettingsModal
|
||
open={initialModalOpen}
|
||
mode="create"
|
||
initial={{ title: '', description: '', genre: 'other', thumbnail: '', is_public: false }}
|
||
onClose={handleInitialClose}
|
||
onSave={handleSettingsSave}
|
||
onCaptureScreenshot={captureSceneScreenshot}
|
||
/>
|
||
<GameSettingsModal
|
||
open={settingsModalOpen}
|
||
mode="edit"
|
||
initial={metaRef.current}
|
||
onClose={() => setSettingsModalOpen(false)}
|
||
onSave={handleSettingsSave}
|
||
onCaptureScreenshot={captureSceneScreenshot}
|
||
/>
|
||
<PublishModal
|
||
open={publishModalOpen}
|
||
project={{
|
||
title: metaRef.current.title,
|
||
description: metaRef.current.description,
|
||
status: projectStatus.status,
|
||
age_rating: projectStatus.age_rating,
|
||
}}
|
||
onClose={() => setPublishModalOpen(false)}
|
||
onSubmit={async ({ age_rating }) => {
|
||
const userId = getCurrentUserId();
|
||
const pid = currentProjectIdRef.current;
|
||
if (!pid) {
|
||
alert('Сначала сохраните проект');
|
||
return;
|
||
}
|
||
try {
|
||
// Умная лента: rank_requested больше не отправляем.
|
||
const res = await Kubikon3DApi.publishProject(pid, {
|
||
user_id: userId,
|
||
age_rating,
|
||
});
|
||
if (res.data?.project) {
|
||
const p = res.data.project;
|
||
setProjectStatus({
|
||
status: p.status,
|
||
age_rating: p.age_rating,
|
||
rank_requested: p.rank_requested,
|
||
moderator_comment: p.moderator_comment,
|
||
});
|
||
}
|
||
// res.review === true → скрипты ушли на ручную проверку.
|
||
if (res.data?.review) {
|
||
alert('Игра отправлена на быструю проверку — в её '
|
||
+ 'скриптах есть что проверить вручную. Как '
|
||
+ 'только проверим, она появится в ленте.');
|
||
}
|
||
// res.too_empty === true → игра опубликована, но
|
||
// слишком пустая, чтобы показываться в ленте.
|
||
else if (res.data?.too_empty) {
|
||
alert('Игра опубликована, но пока слишком пустая для '
|
||
+ 'ленты. Добавь больше блоков, моделей или '
|
||
+ 'скриптов — и она появится в ленте. Сейчас её '
|
||
+ 'можно открыть по ссылке и найти в поиске.');
|
||
}
|
||
} catch (e) {
|
||
// Бэк вернул бан — закроем publish-modal и покажем баннер с причиной.
|
||
if (e?.response?.status === 403
|
||
&& e.response.data?.error === 'publish_banned') {
|
||
setPublishBan(e.response.data.ban || { reason: '—' });
|
||
setPublishModalOpen(false);
|
||
setBanWarningOpen(true);
|
||
return;
|
||
}
|
||
// Email не подтверждён — публиковать игры нельзя.
|
||
if (e?.response?.status === 403
|
||
&& e.response.data?.error === 'email_not_confirmed') {
|
||
setPublishModalOpen(false);
|
||
setEmailNotice(true);
|
||
return;
|
||
}
|
||
throw e;
|
||
}
|
||
}}
|
||
/>
|
||
{/* Предупреждение о бане публикаций. Показывается вместо PublishModal,
|
||
если у пользователя активный бан. */}
|
||
{banWarningOpen && (publishBan || isCantPublish) && (
|
||
<div
|
||
onClick={() => setBanWarningOpen(false)}
|
||
style={{
|
||
position: 'fixed', inset: 0, zIndex: 10001,
|
||
background: 'rgba(7,10,20,0.78)', backdropFilter: 'blur(10px)',
|
||
WebkitBackdropFilter: 'blur(10px)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
padding: 20,
|
||
animation: 'kubikonFadeIn 200ms ease',
|
||
}}
|
||
>
|
||
<div
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={{
|
||
background: '#252525',
|
||
border: '1px solid #3a3a3a',
|
||
borderRadius: 22,
|
||
padding: 0,
|
||
maxWidth: 480, width: '100%',
|
||
color: '#e8e8ea',
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
overflow: 'hidden',
|
||
boxShadow: '0 24px 60px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(239, 68, 68, 0.16)',
|
||
}}
|
||
>
|
||
{/* Красный градиент-header */}
|
||
<div style={{
|
||
background: 'linear-gradient(135deg, #ff6f7a 0%, #c0303f 100%)',
|
||
padding: '22px 24px',
|
||
textAlign: 'center',
|
||
position: 'relative',
|
||
overflow: 'hidden',
|
||
}}>
|
||
<div style={{
|
||
fontSize: 56, marginBottom: 6,
|
||
animation: 'kubikonFloat 3.6s ease-in-out infinite',
|
||
filter: 'drop-shadow(0 4px 12px rgba(0,0,0,0.30))',
|
||
}}><Icon name="hidden" size={14} /></div>
|
||
<div style={{
|
||
fontSize: 20, fontWeight: 800, color: '#fff',
|
||
letterSpacing: -0.3,
|
||
}}>
|
||
Публикация заблокирована
|
||
</div>
|
||
</div>
|
||
<div style={{ padding: 24 }}>
|
||
<div style={{
|
||
fontSize: 14, color: '#9a9a9e', marginBottom: 18,
|
||
lineHeight: 1.55, textAlign: 'center',
|
||
}}>
|
||
Модератор временно ограничил твою возможность публиковать игры.
|
||
Уже опубликованные игры остаются доступны.
|
||
</div>
|
||
<div style={{
|
||
background: 'rgba(239, 68, 68, 0.12)',
|
||
border: '1px solid rgba(239, 68, 68, 0.32)',
|
||
borderRadius: 12,
|
||
padding: '14px 16px', marginBottom: 18,
|
||
}}>
|
||
<div style={{
|
||
fontSize: 11, color: '#7a7a7e', marginBottom: 6,
|
||
textTransform: 'uppercase', letterSpacing: 0.8, fontWeight: 800,
|
||
}}>
|
||
Причина
|
||
</div>
|
||
<div style={{
|
||
fontSize: 14, color: '#e8e8ea', whiteSpace: 'pre-wrap',
|
||
fontWeight: 600, lineHeight: 1.5,
|
||
}}>
|
||
{publishBan?.reason || '—'}
|
||
</div>
|
||
<div style={{
|
||
fontSize: 11, color: '#7a7a7e', marginTop: 10,
|
||
fontWeight: 600,
|
||
}}>
|
||
{publishBan?.expires_at
|
||
? `Действует до: ${publishBan.expires_at}`
|
||
: (publishBan ? 'Срок: не указан (постоянный)' : 'Действует временное ограничение')}
|
||
{publishBan?.banned_at && <> {' · '} Выдан: {publishBan.banned_at}</>}
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => setBanWarningOpen(false)}
|
||
style={{
|
||
width: '100%',
|
||
padding: '12px 22px',
|
||
background: 'linear-gradient(135deg, #4f74ff 0%, #3a57d8 100%)',
|
||
color: '#fff',
|
||
border: '1px solid transparent',
|
||
borderRadius: 10,
|
||
fontSize: 14, fontWeight: 800, cursor: 'pointer',
|
||
fontFamily: 'inherit',
|
||
boxShadow: '0 6px 16px rgba(79, 116, 255, 0.36)',
|
||
letterSpacing: 0.2,
|
||
transition: 'all 200ms cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||
e.currentTarget.style.boxShadow = '0 12px 28px rgba(79, 116, 255, 0.5)';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.transform = 'translateY(0)';
|
||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(79, 116, 255, 0.36)';
|
||
}}
|
||
>
|
||
Понятно
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Окно «подтвердите email» — публиковать игры можно только
|
||
с подтверждённым аккаунтом. */}
|
||
<EmailConfirmNotice
|
||
open={emailNotice}
|
||
onClose={() => setEmailNotice(false)}
|
||
action="публиковать игры"
|
||
/>
|
||
|
||
{/* Модалка истории модерации (открывается кликом по бейджу статуса) */}
|
||
{historyModalOpen && (
|
||
<div
|
||
onClick={() => setHistoryModalOpen(false)}
|
||
style={{
|
||
position: 'fixed', inset: 0, zIndex: 10000,
|
||
background: 'rgba(7,10,20,0.78)',
|
||
backdropFilter: 'blur(10px)',
|
||
WebkitBackdropFilter: 'blur(10px)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
padding: 20,
|
||
animation: 'kubikonFadeIn 200ms ease',
|
||
}}
|
||
>
|
||
<div
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={{
|
||
background: '#252525',
|
||
border: '1px solid #3a3a3a',
|
||
borderRadius: 22,
|
||
width: '100%', maxWidth: 600,
|
||
maxHeight: '85vh',
|
||
overflow: 'hidden',
|
||
display: 'flex', flexDirection: 'column',
|
||
boxShadow: '0 24px 60px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(79, 116, 255, 0.12)',
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
}}
|
||
>
|
||
<div style={{
|
||
padding: '20px 24px',
|
||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||
background: 'linear-gradient(135deg, #4f74ff 0%, #3a57d8 100%)',
|
||
color: '#fff',
|
||
position: 'relative',
|
||
overflow: 'hidden',
|
||
}}>
|
||
<h2 style={{
|
||
margin: 0, fontSize: 20, color: '#fff', fontWeight: 800,
|
||
letterSpacing: -0.3,
|
||
display: 'inline-flex', alignItems: 'center', gap: 12,
|
||
position: 'relative', zIndex: 1,
|
||
}}>
|
||
<span style={{
|
||
width: 36, height: 36, borderRadius: 10,
|
||
background: 'rgba(255, 255, 255, 0.18)',
|
||
border: '1px solid rgba(255, 255, 255, 0.25)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 18,
|
||
}}><Icon name="script" size={14} /></span>
|
||
История публикации
|
||
</h2>
|
||
<button
|
||
onClick={() => setHistoryModalOpen(false)}
|
||
style={{
|
||
width: 34, height: 34,
|
||
background: 'rgba(255, 255, 255, 0.16)',
|
||
border: '1px solid rgba(255, 255, 255, 0.25)',
|
||
borderRadius: 10,
|
||
color: '#fff',
|
||
fontSize: 22, fontWeight: 700, lineHeight: 1,
|
||
cursor: 'pointer',
|
||
fontFamily: 'inherit',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
transition: 'all 150ms ease',
|
||
position: 'relative', zIndex: 1,
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.28)';
|
||
e.currentTarget.style.transform = 'scale(1.05)';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.16)';
|
||
e.currentTarget.style.transform = 'scale(1)';
|
||
}}
|
||
>×</button>
|
||
</div>
|
||
<div style={{ padding: 22, overflowY: 'auto' }}>
|
||
<ModerationHistory history={moderationHistory} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Этап 3 редактора моделей: полноэкранный режим создания модели.
|
||
Открывается из TopRibbon → Модель → "Воксельная" / "Гладкая".
|
||
Текущая реализация — каркас + placeholder; Этап 4 наполнит
|
||
Babylon-сценой и инструментами рисования. */}
|
||
{modelEditorMode && (
|
||
<ModelEditorScreen
|
||
mode={modelEditorMode}
|
||
userId={getCurrentUserId()}
|
||
editingModelId={editingUserModelId}
|
||
onClose={() => {
|
||
const editedId = editingUserModelId;
|
||
setModelEditorMode(null);
|
||
setEditingUserModelId(null);
|
||
if (editedId != null) {
|
||
// Обновим Toolbox и пересоздадим все инстансы
|
||
// этой модели в сцене с новой геометрией/текстурами.
|
||
setUserModelsRefreshKey((n) => n + 1);
|
||
sceneRef.current?.refreshUserModel?.(editedId)
|
||
.catch?.((err) => console.warn('refreshUserModel failed:', err));
|
||
}
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Этап 6: модалка настроек существующей модели — открывается из
|
||
Toolbox при клике на иконку ⚙️ на карточке. */}
|
||
<ModelSaveModal
|
||
open={!!settingsModalModel}
|
||
mode="edit"
|
||
initial={settingsModalModel ? {
|
||
title: settingsModalModel.title,
|
||
description: settingsModalModel.description || '',
|
||
is_public: !!settingsModalModel.is_public,
|
||
thumbnail: settingsModalModel.thumbnail_b64 || '',
|
||
} : null}
|
||
onClose={() => setSettingsModalModel(null)}
|
||
onCaptureThumbnail={null}
|
||
onSave={async (data) => {
|
||
const m = settingsModalModel;
|
||
if (!m) return;
|
||
const uid = getCurrentUserId();
|
||
if (!uid) return;
|
||
try {
|
||
// Сначала обновляем title/description/thumbnail
|
||
await Kubikon3DApi.updateUserModel(m.id, uid, {
|
||
title: data.title,
|
||
description: data.description,
|
||
thumbnail_b64: data.thumbnail,
|
||
});
|
||
// Затем синхронизируем флаг публичности отдельным запросом
|
||
if (data.is_public && !m.is_public) {
|
||
await Kubikon3DApi.publishUserModel(m.id, uid);
|
||
} else if (!data.is_public && m.is_public) {
|
||
await Kubikon3DApi.unpublishUserModel(m.id, uid);
|
||
}
|
||
setSettingsModalModel(null);
|
||
setUserModelsRefreshKey((n) => n + 1);
|
||
} catch (err) {
|
||
console.error('[Toolbox] settings save failed:', err);
|
||
// eslint-disable-next-line no-alert
|
||
alert(err.response?.data?.error || 'Не удалось сохранить настройки');
|
||
}
|
||
}}
|
||
/>
|
||
<ToolboxModal
|
||
open={toolboxOpen}
|
||
activeId={activeModelType}
|
||
userId={getCurrentUserId()}
|
||
userModelsRefreshKey={userModelsRefreshKey}
|
||
initialSection={toolboxInitialSection}
|
||
onEditUserModel={(m) => {
|
||
// Этап 6: открыть редактор с предзагрузкой model_data.
|
||
setToolboxOpen(false);
|
||
setEditingUserModelId(m.id);
|
||
setModelEditorMode(m.kind === 'smooth' ? 'smooth' : 'voxel');
|
||
}}
|
||
onUserModelSettings={(m) => {
|
||
// Открываем модалку настроек (имя/описание/публичность).
|
||
setSettingsModalModel(m);
|
||
}}
|
||
onDeleteUserModel={async (m) => {
|
||
// eslint-disable-next-line no-alert
|
||
if (!window.confirm(`Удалить модель «${m.title}»? Это действие нельзя отменить.`)) return;
|
||
const uid = getCurrentUserId();
|
||
if (!uid) return;
|
||
try {
|
||
await Kubikon3DApi.deleteUserModel(m.id, uid);
|
||
// Дёргаем refresh — Toolbox перезагрузит список
|
||
setUserModelsRefreshKey((n) => n + 1);
|
||
} catch (err) {
|
||
console.error('[Toolbox] delete failed:', err);
|
||
// eslint-disable-next-line no-alert
|
||
alert('Не удалось удалить модель. Проверьте подключение.');
|
||
}
|
||
}}
|
||
onClose={() => setToolboxOpen(false)}
|
||
onPick={(id, userModelObj = null) => {
|
||
// Пользовательские модели имеют префикс 'user:<id>' и
|
||
// обрабатываются в BabylonScene через UserModelManager
|
||
// (Этап 5). Активный тип модели работает одинаково.
|
||
// userModelObj передаётся только для пользовательских
|
||
// моделей — нужен чтобы сохранить их в «Недавние модели».
|
||
setActiveModelType(id);
|
||
setActiveTool('model');
|
||
setGizmoMode('select');
|
||
setPaletteTab('models');
|
||
pushRecentModel(id, userModelObj);
|
||
}}
|
||
/>
|
||
{/* Скрытая кнопка баг-репорта — триггерится из шапки редактора через
|
||
document.querySelector('[data-kubikon-bug-btn]').click(). */}
|
||
<KubikonBugReportButton
|
||
bugType="editor"
|
||
projectId={id === 'new' ? null : Number(id)}
|
||
hidden
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default KubikonEditor;
|