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:'. */ // Типы 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 (
Перетащи элемент на экран игры — он появится там, куда отпустишь. Или кликни — добавится в центр.
{GUI_PALETTE_ITEMS.map(item => (
{ 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', }} >
{item.name} {item.hint}
))}
); }; 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 ( ); } return ( ); }; const KubikonEditor = () => { const navigate = useNavigate(); const { id } = useParams(); const { isAuthenticated, isLoading } = useAuth(); const { isDesktop } = useDeviceType(); // 6.5: Preview-режим тестирования материала дизайнером. // URL: /gamepage/editor/__preview_material_ // 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:'). 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 ; } return (
{/* === Верхняя панель === */}
setProjectName(e.target.value)} placeholder="Название игры" /> { 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' ? '' : 'Открыть историю публикации'} >
{saveStatus === 'saved' && <> Сохранено} {saveStatus === 'saving' && <> Сохранение...} {saveStatus === 'dirty' && <>● Несохранено} {saveStatus === 'error' && <> Ошибка} {saveStatus === 'idle' && '—'} {/* Кнопка баг-репорта в шапке — кликает по скрытой плавающей. */}
{/* Уведомление от модератора: если игру отправили на доработку (`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 (
Игра отправлена на доработку
Модератор оставил комментарий — внеси правки и опубликуй игру снова.
{projectStatus.moderator_comment}
); })()} {/* === Roblox-style лента инструментов === */} { // Манипулятор и инструмент создания взаимоисключающие. // Выбор любого манипулятора (включая 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 и всё ломается. */}
{/* Левая панель — показывается ТОЛЬКО когда выбран инструмент * которому нужно дополнительное место для параметров: * • block → палитра блоков * • primitive → список примитивов * • model → недавние модели + кнопка тулбокса * • terrain → редактор ландшафта * • gen → процедурный генератор * • gui → палитра 2D-интерфейса * При других инструментах (select, erase, manipulators) * панели нет совсем — viewport растягивается. */} {(['block', 'primitive', 'model', 'terrain', 'gen', 'gui'].includes(activeTool)) && ( )} {/* Viewport — содержит табы Сцена/Скрипты + canvas */}
{ // Для скрипт-табов вычисляем 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} />
{ // Принимаем 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(); }} > {isMaterialPreview && (
Режим тестирования материала: ставь блоки, проверяй tile — ничего не сохраняется в БД
)}
{isPlaying ? ( <>
WASD — идти · Space — прыжок · Shift — спринт
C — вид · Tab — курсор для GUI · Esc — выйти
) : activeTool === 'select' ? 'ЛКМ — выбрать · R — повернуть модель · Del — удалить · Esc — снять · WASD — летать · ПКМ — повернуть камеру' : activeTool === 'model' ? 'ЛКМ — поставить модель · R — повернуть превью на 90° · Shift+ЛКМ — удалить · WASD — летать' : activeTool === 'gui' ? 'Перетащи элемент из палитры на экран · элемент на экране — тяни мышью, ручки по краям — размер' : 'ЛКМ — поставить · Shift+ЛКМ — удалить · WASD — летать · ПКМ — повернуть · Колесо — зум · F — фокус' }
{/* GUI-элементы (Frame/Text/Button/Image) — видны и в редакторе, и в Play. В редакторе можно временно скрыть через глазок в Иерархии (guiOverlayHidden). В Play-режиме скрытие не действует — игроку всё видно. */} {activeTabId === 'scene' && (!guiOverlayHidden || isPlaying) && ( 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 && (
Курсор для GUI · Tab — вернуть управление камерой
)} {/* Loading overlay — пока сцена грузится */} {sceneLoading && (
Загрузка проекта…
{/* Progress bar + label */}
{loadProgress.percent}% · {loadProgress.label || 'Подготовка…'}
)} {/* Player HUD: HP + ammo (только в Play, и если скрипт не скрыл) */} {/* Hot-bar инвентаря (виден только в Play, и если скрипт не скрыл) */} sceneRef.current?.setActiveInventorySlot?.(i)} /> {/* HUD от game.ui.* — поверх viewport, только в Play */} {/* Мини-карта — показывается когда включён terrain-streaming * (большая карта, рендерится только видимая часть). * Помогает ориентироваться на огромных мирах. */} {/* Оверлей детального прогресса сохранения — для больших карт, * где сохранение может занимать несколько секунд. */} {saveDetail && (
{saveDetail.error ? 'Ошибка сохранения' : 'Сохранение игры'}
{saveDetail.phase}
{!saveDetail.error && (
)}
)} {/* Прицел поверх viewport — настраивается в Inspector «Игрок» */} {isPlaying && crosshair !== 'none' && (
{crosshair === 'dot' && (
)} {crosshair === 'cross' && (
)} {crosshair === 'circle' && (
)}
)} {/* Кнопка вкл/выкл консоли скриптов (этап 2.1) */} setScriptLogs([])} onClose={() => setConsoleOpen(false)} /> {/* Monaco-редактор скрипта (этап 2.2). Активен когда выбран таб со скриптом. */} {activeTabId !== 'scene' && (() => { const sc = (sceneRef.current?.getScripts?.() || []).find(s => s.id === activeTabId); if (!sc) return null; return ( { 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); }} /> ); })()}
{/* Правая панель — Hierarchy + Inspector */}
Блоков: {blockCount} Моделей: {modelCount} { { select: 'Выделить', block: 'Блок', model: 'Модель', erase: 'Удалить' }[activeTool] || activeTool } {isPlaying ? <> РЕЖИМ ИГРЫ : <> Редактор} Babylon.js
{/* === Модалки === */} setSettingsModalOpen(false)} onSave={handleSettingsSave} onCaptureScreenshot={captureSceneScreenshot} /> 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) && (
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', }} >
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 */}
Публикация заблокирована
Модератор временно ограничил твою возможность публиковать игры. Уже опубликованные игры остаются доступны.
Причина
{publishBan?.reason || '—'}
{publishBan?.expires_at ? `Действует до: ${publishBan.expires_at}` : (publishBan ? 'Срок: не указан (постоянный)' : 'Действует временное ограничение')} {publishBan?.banned_at && <> {' · '} Выдан: {publishBan.banned_at}}
)} {/* Окно «подтвердите email» — публиковать игры можно только с подтверждённым аккаунтом. */} setEmailNotice(false)} action="публиковать игры" /> {/* Модалка истории модерации (открывается кликом по бейджу статуса) */} {historyModalOpen && (
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', }} >
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', }} >

История публикации

)} {/* Этап 3 редактора моделей: полноэкранный режим создания модели. Открывается из TopRibbon → Модель → "Воксельная" / "Гладкая". Текущая реализация — каркас + placeholder; Этап 4 наполнит Babylon-сценой и инструментами рисования. */} {modelEditorMode && ( { 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 при клике на иконку ⚙️ на карточке. */} 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 || 'Не удалось сохранить настройки'); } }} /> { // Этап 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:' и // обрабатываются в BabylonScene через UserModelManager // (Этап 5). Активный тип модели работает одинаково. // userModelObj передаётся только для пользовательских // моделей — нужен чтобы сохранить их в «Недавние модели». setActiveModelType(id); setActiveTool('model'); setGizmoMode('select'); setPaletteTab('models'); pushRecentModel(id, userModelObj); }} /> {/* Скрытая кнопка баг-репорта — триггерится из шапки редактора через document.querySelector('[data-kubikon-bug-btn]').click(). */}
); }; export default KubikonEditor;