studio/src/editor/KubikonEditor.jsx
МИН 61fba4e174
Some checks failed
CI / Lint + Format (push) Failing after 32s
CI / Build (push) Failing after 37s
CI / Secret scan (push) Failing after 37s
CI / PR size check (push) Has been skipped
fix: починка билда + studio.rublox.pro инфра
Большой консолидирующий коммит после поднятия 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>
2026-05-28 05:01:13 +03:00

3453 lines
194 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import React, { 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;