studio/src/editor/KubikonEditor.jsx
min e3b4968338 fix(skin): валидация скина в Play-режиме редактора (legacy bacon → y-bot)
KubikonEditor подмешивал skin_folder из БД в hash без проверки.
Для ~22 legacy-юзеров БД отдаёт skin_bacon-hair (модель удалена) →
в Play-режиме студии играл бекон. Теперь невалидный скин (не в
MIXAMO_SKINS и не customskin:) подменяется на skin_y-bot, как в
плеере (KubikonPlayer/GameMenu) и кабинете. Дефолты bacon → y-bot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:46:02 +03:00

4408 lines
255 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, redirectToLogin } from '../auth/AuthContext.jsx';
import { useSanctions } from '../auth/SanctionsContext.jsx';
import { BabylonScene } from './engine/BabylonScene';
import { MIXAMO_SKINS } from './engine/PlayerController';
import { StudioCollab } from './engine/StudioCollab';
import { CollabOverlay } from './engine/CollabOverlay';
import { BLOCK_TYPES, BLOCK_CATEGORIES, blockPreview, registerCustomBlockType } from './engine/BlockTypes';
import { MODEL_TYPES, MODEL_CATEGORIES, getModelType } from './engine/ModelTypes';
import { getKit } from './engine/GameplayKits';
import { PRIMITIVE_TYPES, PALETTE_PRIMITIVE_TYPES } from './engine/PrimitiveTypes';
import { getModelThumbnail } from './engine/ModelThumbnails';
import * as Kubikon3DApi from '../api/Kubikon3DService';
import { REALTIME_HTTP } from '../api/API';
import GameSettingsModal from './GameSettingsModal';
import GameDecorModal from './GameDecorModal';
import SkinManagerModal from './SkinManagerModal';
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 BillboardEditorModal from './BillboardEditorModal';
import TerrainGenPanel from './TerrainGenPanel';
import ScriptConsole from './ScriptConsole';
import SceneTabs from './SceneTabs';
import ScriptEditor, { LUA_TEMPLATE_PART, LUA_TEMPLATE_GLOBAL, JS_TEMPLATE_GLOBAL } from './ScriptEditor';
import GameHud from './GameHud';
import MinimapOverlay from './MinimapOverlay';
import GuiOverlay from './GuiOverlay';
import Hotbar from './Hotbar';
import PlayerHud from './PlayerHud';
import ModalOverlay from './ModalOverlay';
import SkinShopOverlay from './SkinShopOverlay';
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';
import ConfirmModal from './ConfirmModal';
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).
/** Задача 03: раскрытие GUI-шаблона из палитры в {type, opts}. */
function _expandGuiTemplate(typeOrTemplate) {
const tpl = (typeof typeOrTemplate === 'string' && typeOrTemplate.startsWith('template:'))
? typeOrTemplate.slice('template:'.length) : null;
if (!tpl) return { type: typeOrTemplate, opts: {} };
if (tpl === 'big-icon') return {
type: 'button',
opts: {
text: 'Магазин', name: 'Кнопка-иконка',
w: 14, h: 16,
bgGradient: { stops: ['#5ab3ff', '#3d6cff'], angle: 135 },
borderRadius: 16, borderWidth: 3, borderColor: '#1e3a8a',
shadow: true,
textColor: '#ffffff', textSize: 18, fontWeight: 800,
textStroke: { color: '#1e3a8a', width: 2 },
hover: { scale: 1.08, brightness: 1.15, duration: 0.15 },
active: { scale: 0.94, duration: 0.08 },
},
};
if (tpl === 'price') return {
type: 'button',
opts: {
text: 'X2 ДЕНЕГ', name: 'Кнопка с ценой',
w: 28, h: 8,
bgGradient: { stops: ['#ff5a5a', '#ffd166', '#9eff7a', '#5ab3ff'], angle: 90 },
borderRadius: 14, borderWidth: 3, borderColor: '#000',
textColor: '#ffffff', textSize: 22, fontWeight: 900,
textStroke: { color: '#000', width: 3 },
shadow: true,
hover: { scale: 1.06, brightness: 1.1, duration: 0.15 },
active: { scale: 0.94, duration: 0.08 },
},
};
if (tpl === 'hud-counter') return {
type: 'text',
opts: {
text: '💰 0', name: 'HUD-счётчик',
x: 5, y: 5, w: 14, h: 6, anchor: 'top-left',
bgColor: '#0a0a0a', bgOpacity: 0.7,
borderRadius: 10, borderWidth: 2, borderColor: '#ffd166',
textColor: '#ffd166', textSize: 18, fontWeight: 800,
},
};
if (tpl === 'card') return {
type: 'frame',
opts: {
name: 'Карточка', w: 24, h: 14,
bgGradient: { stops: ['#3a3a8a', '#1a1a4a'], angle: 135 },
borderRadius: 12, borderWidth: 2, borderColor: '#5050a0',
shadow: true,
},
};
// Задача 04: шаблон «Модальное окно» — батч из нескольких элементов.
// Затемняющий фрейм во весь экран + центральная карточка + заголовок + Закрыть.
// Раскрывается батчем (тип = '_batch', elements = [...]).
if (tpl === 'modal') return {
type: '_batch',
opts: {
elements: [
{
type: 'frame', name: 'Модал затемнение',
x: 50, y: 50, w: 100, h: 100, anchor: 'center',
bgColor: '#000000', bgOpacity: 0.6,
borderRadius: 0, borderWidth: 0,
},
{
type: 'frame', name: 'Модал карточка',
x: 50, y: 50, w: 40, h: 35, anchor: 'center',
bgGradient: { stops: ['#2a2a5a', '#0a0a1a'], angle: 135 },
borderRadius: 18, borderWidth: 3, borderColor: '#ffd700',
shadow: true,
},
{
type: 'text', name: 'Модал заголовок',
x: 50, y: 40, w: 36, h: 6, anchor: 'center',
text: 'Заголовок', textSize: 28, fontWeight: 900,
textColor: '#ffffff', textStroke: { color: '#000', width: 2 },
bgColor: 'transparent', bgOpacity: 0,
},
{
type: 'text', name: 'Модал текст',
x: 50, y: 52, w: 34, h: 10, anchor: 'center',
text: 'Описание модального окна. Замени на свой текст.',
textSize: 16, fontWeight: 500, textColor: '#cfd0e8',
bgColor: 'transparent', bgOpacity: 0,
},
{
type: 'button', name: 'Модал закрыть',
x: 50, y: 62, w: 14, h: 6, anchor: 'center',
text: 'Закрыть',
bgGradient: { stops: ['#22ff66', '#0a803a'], angle: 90 },
borderRadius: 10, borderWidth: 2, borderColor: '#000',
textColor: '#fff', textSize: 18, fontWeight: 900,
textStroke: { color: '#000', width: 1 },
hover: { scale: 1.08, brightness: 1.2, duration: 0.15 },
active: { scale: 0.94, duration: 0.08 },
},
],
},
};
return { type: typeOrTemplate, opts: {} };
}
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: 'Картинка из библиотеки или по ссылке' },
// === Задача 03: готовые шаблоны Roblox-стиля ===
{ type: 'template:big-icon', icon: 'gamepad', name: 'Кнопка-иконка', hint: 'Большая квадратная (как «Магазин» в Roblox) — градиент + обводка текста + hover' },
{ type: 'template:price', icon: 'coin', name: 'Кнопка с ценой', hint: 'Длинная радужная (как «X2 ДЕНЕГ») — 4-цветный градиент, обводка' },
{ type: 'template:hud-counter', icon: 'star', name: 'HUD-счётчик', hint: 'Маленький бейдж в углу — иконка + число' },
{ type: 'template:card', icon: 'square', name: 'Карточка', hint: 'Крупная плашка с градиентом и тенью — для меню' },
// === Задача 04: шаблон модального окна (батч) ===
{ type: 'template:modal', icon: 'message-square', name: 'Модальное окно', hint: 'Затемнение во весь экран + карточка с заголовком + кнопка «Закрыть» — для boss-intro, лутбоксов, диалогов' },
];
/**
* 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);
// Team Create — клиент совместного редактирования + presence-overlay.
const collabRef = useRef(null);
const collabOverlayRef = useRef(null);
// ref на initCollab — чтобы вызвать его из doSave (объявлен раньше initCollab)
// после создания нового проекта, не дожидаясь перезагрузки страницы.
const initCollabRef = useRef(null);
const [collabActive, setCollabActive] = useState(false); // подключены к комнате
const [collabPeers, setCollabPeers] = useState(0); // сколько ДРУГИХ соавторов
// Роль в коллабе: 'owner' (владелец) | 'collab' (приглашённый по ссылке).
// Приглашённый — гость: не может менять настройки/сохранять/публиковать.
const [collabRole, setCollabRole] = useState('owner');
const isInvitedGuest = collabActive && collabRole !== 'owner';
// Флаш 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);
// BillboardEditorModal — открывается из инспектора при клике
// «Редактировать табличку…». Содержит primitiveData выделенного билборда.
const [billboardEditorData, setBillboardEditorData] = useState(null);
// ConfirmModal — кастомная модалка вместо window.confirm.
const [confirmState, setConfirmState] = 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);
// Ref-зеркало для sceneLoading — нужно doSave/markDirty, чтобы блокировать
// автосейв до окончания загрузки (иначе пустая стартовая сцена перетрёт
// реальный проект в БД при reload страницы).
const sceneLoadingRef = useRef(true);
useEffect(() => { sceneLoadingRef.current = sceneLoading; }, [sceneLoading]);
// Прогресс загрузки (читается из 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');
// Загрузка не завершилась штатно за 60с (медленная сеть / таймаут
// getProject / частично загруженные модели) → помечаем как СБОЙ
// загрузки, чтобы автосейв НЕ затёр проект частичной/пустой сценой.
// Без этого terrain мог загрузиться частично (напр. 3 из 13173) и
// автосейв писал эту пустышку в БД (инцидент 2026-06-02).
loadFailedRef.current = true;
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);
// Guard от потери воксельных моделей и скриптов при частичной загрузке:
// terrain мог догрузиться, а userModels/scripts — нет (getProject упал по
// таймауту на середине), и автосейв затирал их. Запоминаем сколько было
// ЗАГРУЖЕНО — если стало 0 при ненулевом базовом, блокируем сейв.
const lastLoadedUserModelCountRef = useRef(0);
const lastLoadedScriptCountRef = 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);
// Задача 03: отдельный контроль хотбара и HP — для игр без инвентаря/жизней.
const [hotbarVisible, setHotbarVisible] = useState(true);
const [hpVisible, setHpVisible] = 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);
const [spawnEnabledUI, setSpawnEnabledUI] = 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]);
// 2026-06-14: блокировка системных Ctrl-хоткеев во время Play.
// F-клавиши и Ctrl+W/D/T/R/S/A/P/F/U/J/H/L/O/G + Ctrl+1..9 + Ctrl+Tab.
// В fullscreen Chrome даёт preventDefault'иться. WASD-хоткеи
// (Ctrl+W/A/S/D) НЕ stopPropagation — PlayerController должен их видеть
// (одновременный crouch+движение).
useEffect(() => {
if (!isPlaying) return;
const onKey = (e) => {
if (e.code === 'F5' || e.code === 'F3' || e.code === 'F6' || e.code === 'F7') {
e.preventDefault(); e.stopPropagation(); return;
}
if (e.ctrlKey || e.metaKey) {
const wasd = ['KeyW', 'KeyA', 'KeyS', 'KeyD'];
if (wasd.includes(e.code)) {
e.preventDefault();
return;
}
const blocked = ['KeyR','KeyT','KeyN','KeyP','KeyU','KeyJ','KeyH',
'KeyF','KeyG','KeyL','KeyO','Tab',
'Digit1','Digit2','Digit3','Digit4','Digit5',
'Digit6','Digit7','Digit8','Digit9'];
if (blocked.includes(e.code)) {
e.preventDefault();
e.stopPropagation();
}
}
};
window.addEventListener('keydown', onKey, { capture: true });
return () => window.removeEventListener('keydown', onKey, { capture: true });
}, [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;
});
}
}, []);
// Задача 17: вставить готовую механику (kit) из Тулбокса в проект.
// prims[] → создаём примитивы перед камерой; on-target скрипт → привязываем
// к первому созданному примитиву; global скрипт → добавляем как скрипт игры.
const insertGameplayKit = useCallback((kitId) => {
const kit = getKit(kitId);
const s = sceneRef.current;
if (!kit || !s) return;
// Точка вставки — на ТВЁРДОЙ поверхности под центром экрана (пол/объект),
// чтобы предмет встал на землю в фокусе камеры, а не висел под камерой.
let px = 0, pz = 0, py = 0;
try {
const gp = s.getPlacementPointAtCenter?.();
if (gp) { px = gp.x; pz = gp.z; py = gp.y; }
else {
const cam = s.camera;
const fwd = cam?.getForwardRay ? cam.getForwardRay().direction : null;
if (cam && fwd) { px = cam.position.x + fwd.x * 6; pz = cam.position.z + fwd.z * 6; }
}
} catch (e) { /* ignore */ }
// 1) Создаём примитивы кита. Запоминаем все id (первый — для on-target скрипта).
let firstPrimId = null;
const createdIds = [];
if (Array.isArray(kit.prims)) {
for (const p of kit.prims) {
const newId = s.primitiveManager?.addInstance(p.type || 'cube', {
x: px + (p.x || 0), y: py + (p.y != null ? p.y : 1), z: pz + (p.z || 0),
sx: p.sx, sy: p.sy, sz: p.sz,
color: p.color, material: p.material,
// canCollide: явный false уважаем; для лестницы оставляем
// undefined → addInstance применит свой дефолт (false, чтобы
// в неё можно было войти и лезть). Для прочих — true.
canCollide: p.canCollide === false ? false
: (p.type === 'ladder_vertical' ? undefined : true),
visible: p.visible !== false, anchored: true,
name: p.name,
// stepCount — высота лестницы (только для ladder_vertical).
...(p.stepCount != null ? { stepCount: p.stepCount } : {}),
});
if (newId != null) {
createdIds.push(newId);
if (firstPrimId == null) firstPrimId = newId;
}
}
}
// Если кит состоит из НЕСКОЛЬКИХ частей — кладём их в общую папку
// (объекты из нескольких частей всегда сгруппированы).
let kitFolderId = null;
if (createdIds.length > 1 && s.folderManager) {
kitFolderId = s.folderManager.createFolder(kit.name);
for (const pid of createdIds) {
s.folderManager.assignToFolder('primitive', pid, kitFolderId);
}
}
// 2) Добавляем скрипты кита — с понятным именем (название кита).
if (Array.isArray(kit.scripts)) {
kit.scripts.forEach((sc, idx) => {
const sid = `script_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
// Имя = название кита (+ номер, если скриптов несколько).
const nm = kit.scripts.length > 1 ? `${kit.name} (${idx + 1})` : kit.name;
if (sc.attachTo === 'on-target' && firstPrimId != null) {
s.upsertScript(sid, sc.code, { kind: 'primitive', id: firstPrimId }, nm);
} else {
s.upsertScript(sid, sc.code, null, nm); // глобальный
}
});
}
markDirty();
hierarchyDirtyRef.current = true; // пересобрать дерево (примитивы с folderId)
setScriptsList(s.getScripts?.() || []);
if (s.folderManager) setFoldersList(s.folderManager.getAll());
// Выделим созданное и наведём камеру (видно, куда добавилось).
try {
setActiveTool('select');
if (kitFolderId != null) {
s.selection?.selectFolder?.(kitFolderId); // группа из нескольких частей
setGizmoMode('move');
} else if (firstPrimId != null) {
s.selection?.selectPrimitiveById(firstPrimId);
}
s.focusOnSelection?.();
} catch (e) {}
// Тост-уведомление (showToast будет подключён позже — заглушка,
// чтобы не падал eslint no-undef и CI оставался зелёным).
try {
if (typeof window !== 'undefined' && typeof window.showToast === 'function') {
window.showToast(`Механика «${kit.name}» добавлена`);
}
} catch (e) {}
}, []);
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 [userModelsList, setUserModelsList] = 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]);
// Цвет для окрашиваемых блоков (studs-block, задача 09).
const [activeBlockColor, setActiveBlockColor] = useState('#3a7aff');
const [modelCategory, setModelCategory] = useState(MODEL_CATEGORIES[0]);
const [search, setSearch] = useState('');
// === Game settings inline в TopRibbon (вкладка Тест) ===
// Дефолт — R15-скин bacon-hair (классический Roblox-вид).
const [playerModelType, setPlayerModelTypeUI] = useState('skin_y-bot');
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_y-bot',
});
const projectNameRef = useRef(projectName);
useEffect(() => { projectNameRef.current = projectName; metaRef.current.title = projectName; }, [projectName]);
// === Модалки ===
// settingsModalOpen — настройки игры (Roblox Game Settings)
// initialModalOpen — инициальный диалог при создании новой игры
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
// Модал «Оформление» (графика/стартовый экран/экран загрузки) — из вкладки «Игра».
const [decorModalOpen, setDecorModalOpen] = useState(false);
const [decorSection, setDecorSection] = useState('graphics');
// Задача 07: модал управления скинами проекта + список всех скинов (манифест).
const [skinManagerOpen, setSkinManagerOpen] = useState(false);
const [allSkinsList, setAllSkinsList] = useState([]);
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)
// Нет своей страницы /login — уходим на вход минки (SSO) с возвратом
// на текущий URL (важно для инвайт-ссылок ?collab=<token>).
if (!isAuthenticated) {
redirectToLogin();
}
}, [isAuthenticated, isLoading]);
/**
* Реальное сохранение на сервер.
* Если 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 (_) {}
// Если загрузка ещё не завершилась — НЕЛЬЗЯ сохранять.
// Иначе пустая стартовая сцена (до loadFromState) затрёт реальный
// проект в БД при reload страницы (баг 2026-05-29).
if (sceneLoadingRef.current) {
console.warn('[KubikonEditor] save: skip (scene still loading)');
return;
}
// Если загрузка упала — не сохраняем. Иначе пустая сцена
// (с дефолтным state до load) затрёт существующий проект.
if (loadFailedRef.current) {
console.warn('[KubikonEditor] save: skip (load failed)');
return;
}
// Team Create: в комнате совместного редактирования сохраняет ТОЛЬКО host
// (authoritative). Иначе два соавтора autosave'ят наперегонки → last-write-wins
// затирает чужие правки. Не-host просто не пишет в БД, его изменения уже
// у host через операции.
if (collabRef.current?.connected && !collabRef.current?.isHost) {
console.log('[KubikonEditor] save: skip (collab non-host, host saves)');
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) {
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;
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;
// ЖЁСТКАЯ защита: если сцена ПОЛНОСТЬЮ пустая (0 блоков, 0 примитивов,
// 0 моделей, 0 скриптов, 0 GUI) — НИКОГДА не сохраняем поверх
// существующего проекта. Даже если userWasEditing — это значит юзер
// нажал «Очистить» (удалил всё) или произошёл HMR-reset.
// Если человек реально хочет пустую игру — создаст новый проект.
if (totalContent === 0) {
console.error(
'[KubikonEditor] SAVE BLOCKED: сцена пустая по всем коллекциям. '
+ 'Существующий проект не будет затёрт пустышкой. '
+ 'Перезагрузите страницу, чтобы вернуть содержимое из БД.'
);
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;
}
// === ЗАЩИТА ОТ WIPE: воксельные модели и скрипты ===
// terrain мог догрузиться, а userModels/scripts — нет (частичная загрузка
// из-за таймаута getProject), и автосейв затирал их пустотой. Если было
// загружено N (>0), а сейчас 0 И пользователь не редактировал — блок.
// Реальный инцидент 2026-06-02: игра «Мой завод» (2345) потеряла все
// 8 моделей и скрипт магазина после частичной загрузки.
if (!userWasEditing) {
const s2 = sceneRef.current;
const curUm = s2.userModelManager?.instances?.size ?? 0;
const curScr = (s2._scripts || []).filter(x => x && x.id !== 'demo').length;
const lostUm = lastLoadedUserModelCountRef.current > 0 && curUm === 0;
const lostScr = lastLoadedScriptCountRef.current > 0 && curScr === 0;
if (lostUm || lostScr) {
console.error(
`[KubikonEditor] SAVE BLOCKED: потеря userModels(${lastLoadedUserModelCountRef.current}${curUm}) `
+ `или scripts(${lastLoadedScriptCountRef.current}${curScr}) — вероятно частичная загрузка. Перезагрузите страницу.`
);
setSaveStatus('error');
setSaveDetail({
phase: 'Сохранение заблокировано: пропали модели/скрипты (неполная загрузка). Перезагрузите страницу!',
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)),
is_test: !!meta.is_test,
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}`);
// Team Create: проект только что получил реальный ID — поднимаем
// совместную сессию сразу, без перезагрузки страницы. Иначе
// индикатор соавторов и инвайт не появлялись, пока юзер не
// поставит блок → сохранит → обновит (баг 2026-06-08).
try { initCollabRef.current?.(Number(newId)); } catch (_) {}
}
} 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]);
/**
* Team Create: подключиться к комнате совместного редактирования.
* Зовётся ПОСЛЕ загрузки сцены (scene готова). projectIdNum — числовой id.
* Подключаемся если: владелец проекта (всегда) ИЛИ есть ?collab=<token> в URL.
*/
const initCollab = useCallback(async (projectIdNum) => {
try {
if (!sceneRef.current || !projectIdNum) return;
if (collabRef.current) return; // уже подключены
const collabToken = new URLSearchParams(window.location.search).get('collab') || null;
const tokenRaw = localStorage.getItem('Authorization')
|| localStorage.getItem('jwt') || '';
if (!tokenRaw) return;
// Подключаемся только если есть инвайт ИЛИ владелец (бэкенд решит в onAuth).
// Если не владелец и нет инвайта — onAuth вернёт 403, ловим тихо.
const collab = new StudioCollab(sceneRef.current, {
projectId: projectIdNum,
token: tokenRaw,
collabToken,
callbacks: {
onConnected: ({ isHost, role }) => {
setCollabActive(true);
setCollabRole(role || (isHost ? 'owner' : 'collab'));
collabOverlayRef.current?.toast(isHost
? 'Совместное редактирование включено. Пригласи друга кнопкой 👥'
: 'Ты подключился к совместному редактированию!');
},
onError: (msg) => {
// 403 (нет доступа) — норм для не-приглашённого; просто не коллабим.
console.warn('[collab] error:', msg);
},
onLeft: () => { setCollabActive(false); },
onPresenceChange: (list) => {
collabOverlayRef.current?.updatePresence(list);
setCollabPeers(Math.max(0, list.filter(c => !c.me).length));
},
onOpRejected: (m) => {
collabOverlayRef.current?.toast('Этот объект сейчас редактирует другой соавтор');
},
onChat: (m) => { /* чат соавторов — на этап 2 */ },
// host отдаёт текущую сцену новому соавтору
onSnapshotRequest: (replyFn) => {
try { replyFn(sceneRef.current.serialize()); } catch (e) { /* ignore */ }
},
// новый соавтор получил сцену от host — грузим
onRemoteSnapshot: async (state) => {
try {
if (state) {
await sceneRef.current.loadFromState(state);
dirtyRef.current = false;
}
} catch (e) { console.warn('[collab] snapshot load failed', e); }
},
},
});
await collab.connect();
collab.installInterceptors();
collabRef.current = collab;
// presence-overlay
const ov = new CollabOverlay(sceneRef.current);
ov.mount();
collabOverlayRef.current = ov;
// курсор-трекинг: шлём точку под мышью на сцене (raycast по pointermove)
_wireCursorTracking(sceneRef.current, collab);
} catch (e) {
// 403/нет доступа/realtime недоступен — работаем соло, не падаем.
console.warn('[collab] init skipped:', e?.message || e);
}
}, []);
// Держим актуальную ссылку на initCollab для вызова из doSave (см. выше).
initCollabRef.current = initCollab;
/**
* Team Create: «Пригласить» — запросить collab-токен у realtime, собрать
* ссылку studio.rublox.pro/edit/<id>?collab=<token> и скопировать в буфер.
*/
const handleInvite = useCallback(async () => {
try {
// Реальный ID берём из ref (он обновляется после сохранения нового
// проекта), а НЕ из URL-параметра id — при создании нового проекта
// URL остаётся /edit/new даже после успешного сохранения, поэтому
// проверка по id давала вечное «Сначала сохрани» (баг 2026-06-08).
let pid = currentProjectIdRef.current;
// Не сохранён ещё? Попробуем сохранить прямо сейчас, затем пригласить.
if (pid == null) {
try {
await doSave?.();
} catch (_) { /* ignore — проверим pid ниже */ }
pid = currentProjectIdRef.current;
}
if (pid == null || !/^\d+$/.test(String(pid))) {
alert('Сначала сохрани проект — добавь хотя бы один объект на сцену и нажми «Сохранить».');
return;
}
const tokenRaw = localStorage.getItem('Authorization') || localStorage.getItem('jwt') || '';
const base = (REALTIME_HTTP || '').replace(/\/$/, '');
const res = await fetch(`${base}/studio-invite/${pid}`, {
method: 'POST',
headers: { Authorization: tokenRaw, 'Content-Type': 'application/json' },
});
if (!res.ok) {
if (res.status === 403) { alert('Только автор проекта может приглашать соавторов.'); return; }
alert('Не удалось создать приглашение (' + res.status + ').');
return;
}
const { token } = await res.json();
const link = `${window.location.origin}/edit/${pid}?collab=${token}`;
try {
await navigator.clipboard.writeText(link);
collabOverlayRef.current?.toast('Ссылка-приглашение скопирована! Отправь другу.');
} catch (e) {
window.prompt('Скопируй ссылку-приглашение для друга:', link);
}
} catch (e) {
console.warn('[collab] invite failed', e);
alert('Не удалось создать приглашение. Realtime недоступен?');
}
}, [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);
// Студия-редактор: тестовый «Запуск» НЕ должен показывать стартовый
// экран загрузки игры (он нужен только в плеере на rublox.pro, чтобы
// дать время подгрузить ассеты). В студии всё уже в памяти.
scene._editorMode = true;
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 => {
// Задача 07: и человекоподобные R15, и non-humanoid скины
// (животные/машины/еда/роботы) доступны как стартовый скин.
// category для группировки в Inspector: 'Персонажи' (люди) или
// 'Скины-животные' (всё остальное).
const catLabel = (c) => (!c || c === 'human') ? 'Персонажи' : 'Скины-фигуры';
const skinOpts = (json.skins || []).map(s => ({
id: s.id, // 'skin_bacon-hair' / 'skin_squirrel-donut'
name: s.name || s.slug,
category: catLabel(s.category),
skinKind: s.kind || 'r15',
skinCategory: s.category || 'human',
}));
if (skinOpts.length > 0 && sceneRef.current) {
sceneRef.current.setPlayerOptions([...baseChars, ...skinOpts]);
}
// Задача 07: полный список скинов для SkinManagerModal.
setAllSkinsList((json.skins || []).map(s => ({
id: s.id, slug: s.slug || (s.id || '').replace(/^skin_/, ''),
name: s.name || s.slug, kind: s.kind || 'r15',
category: s.category || 'human', price: Number.isFinite(s.price) ? s.price : 0,
})));
})
.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;
// Синк флага точки спавна (например после Delete-клавиши).
try { setSpawnEnabledUI(scene.hasSpawn?.() !== false); } catch (e) {}
});
// Этап 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));
// Задача 03: отдельные подписки на хотбар и HP
scene.setOnHotbarVisibilityChange?.((v) => setHotbarVisible(v));
scene.setOnHpVisibilityChange?.((v) => setHpVisible(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');
});
// DEV-хук (только localhost): грузим готовую тест-сцену через ?dev=<имя>
// → fetch /dev-<имя>.json из public/. Работает на ЛЮБОМ id (включая
// существующий проект — dev-сцена заменяет его, БД-загрузка пропускается),
// чтобы не упираться в модал «Новая игра». Позволяет тестировать локально
// без БД (S2/прод). На проде неактивен.
const _isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const _devParam = _isLocalhost ? new URLSearchParams(window.location.search).get('dev') : null;
if (_isLocalhost && _devParam) {
fetch('/dev-' + _devParam + '.json')
.then(r => (r.ok ? r.json() : null))
.then(async (parsed) => {
if (!parsed) return;
try {
await sceneRef.current.loadFromState(parsed);
setProjectName('DEV: ' + (parsed.__devName || 'тест-сцена'));
dirtyRef.current = false;
} catch (e) { console.warn('[DEV scene load] failed', e); }
})
.catch(() => {});
}
// Для нового проекта — создаём шаблон папок 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);
}
})();
}
// Если редактируем существующий проект — грузим из БД.
// ПРОПУСКАЕМ при активном DEV-хуке (?dev=) — там сцена грузится из файла.
if (id !== 'new' && /^\d+$/.test(id) && !_devParam) {
(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,
is_test: !!data.is_test,
// Задача 12: конфиг экрана загрузки из scene-JSON (для модала настроек).
loading_screen: (data.project_data && (() => {
try {
const pd = typeof data.project_data === 'string' ? JSON.parse(data.project_data) : data.project_data;
return pd?.scene?.loadingScreen || null;
} catch { return null; }
})()) || null,
// Графика/эффекты из scene-JSON (для модала настроек).
graphics: (data.project_data && (() => {
try {
const pd = typeof data.project_data === 'string' ? JSON.parse(data.project_data) : data.project_data;
return pd?.scene?.graphics || null;
} catch { return null; }
})()) || null,
};
// Состояние публикации (этап 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);
// Задача 12: дефолтный логотип экрана загрузки = обложка
// проекта (thumbnail в meta, а не в scene-JSON).
try { sceneRef.current._projectThumbnail = data.thumbnail || null; } catch (e) { /* ignore */ }
// Запоминаем сколько 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;
// Guard для воксельных моделей и скриптов (см. ref выше).
const umLoaded = sceneRef.current.userModelManager?.instances?.size ?? 0;
const scrLoaded = (sceneRef.current._scripts || [])
.filter(x => x && x.id !== 'demo').length;
lastLoadedUserModelCountRef.current = umLoaded;
lastLoadedScriptCountRef.current = scrLoaded;
console.log(`[KubikonEditor] guard armed: lastLoadedVoxelCount=${tm + tmesh + rt} (legacy=${tm}, tmesh=${tmesh}, roblox=${rt}), deco=${deco}, userModels=${umLoaded}, scripts=${scrLoaded}`);
// Триггерим пересчёт 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_y-bot');
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);
setSpawnEnabledUI(sceneRef.current.hasSpawn?.() !== 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);
// Team Create: после загрузки сцены — подключиться к комнате
// совместного редактирования (владелец или по ?collab-инвайту).
if (/^\d+$/.test(id)) initCollab(Number(id));
}
})();
} 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.userModelManager) {
const arr = [];
for (const data of s.userModelManager.instances.values()) {
arr.push({
instanceId: data.instanceId,
userModelTypeId: data.userModelTypeId,
userModelId: data.userModelId,
x: data.x, y: data.y, z: data.z,
rotationY: data.rotationY,
folderId: data.folderId ?? null,
name: data.name || null,
});
}
setUserModelsList(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;
}
// Team Create: отключиться от комнаты + снять overlay.
try {
if (collabRef.current?.__cursorHandler && collabRef.current?.__cursorCanvas) {
collabRef.current.__cursorCanvas.removeEventListener('pointermove', collabRef.current.__cursorHandler);
}
collabRef.current?.dispose();
collabOverlayRef.current?.dispose();
} catch (e) { /* ignore */ }
collabRef.current = null;
collabOverlayRef.current = null;
scene.dispose();
sceneRef.current = null;
};
// isLoading в deps — без него эффект мог стрельнуть пока canvas
// ещё не в DOM (isLoading=true → компонент рендерит null) и больше
// не перезапускался → вечная "Загрузка проекта… 0%".
}, [isAuthenticated, isLoading, id, markDirty, initCollab]);
// 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]);
// Цвет окрашиваемого блока → в сцену (для studs-block при постановке).
useEffect(() => {
if (sceneRef.current) sceneRef.current.setActiveBlockColor?.(activeBlockColor);
}, [activeBlockColor]);
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();
};
// Сохранить «Оформление» (графика / стартовый экран / экран загрузки) из
// модала, открытого во вкладке «Игра». Применяем сразу (превью) + в сцену.
const handleDecorSave = (data) => {
try {
sceneRef.current?.setLoadingConfig?.(
data.loading_screen || null, metaRef.current?.thumbnail);
} catch (e) { /* ignore */ }
try {
if (data.graphics) sceneRef.current?.setGraphics?.(data.graphics);
} catch (e) { /* ignore */ }
// запомним в metaRef, чтобы модал открылся с актуальными значениями
metaRef.current = {
...metaRef.current,
loading_screen: data.loading_screen,
graphics: data.graphics,
};
setDecorModalOpen(false);
dirtyRef.current = true;
doSave();
};
const openDecor = (sec) => { setDecorSection(sec); setDecorModalOpen(true); };
// Закрыть инициальный диалог: если пользователь не сохранил — возвращаемся в Studio.
const handleInitialClose = () => {
setInitialModalOpen(false);
// Если мы только что открылись на /new и нет id — возвращаемся в Studio.
if (currentProjectIdRef.current == null) {
navigate('/');
}
};
const handlePlay = async () => {
const scene = sceneRef.current;
if (!scene) return;
if (scene.isPlaying()) {
scene.exitPlayMode();
setIsPlaying(false);
// Сбрасываем HUD (счётчик/таймер/метки) — иначе при следующем
// Play на экране остаётся счёт прошлой игры. setOnPlayChange
// дёргается только на Esc-выход, кнопка Стоп — нет.
hudRef.current?.reset?.();
} else {
// 2026-06-14: Перед входом в Play подтягиваем СКИН ЮЗЕРА из БД
// (если ещё не передан в URL #skin=). Источник:
// 1) URL hash #skin=<id> (если уже есть — не трогаем)
// 2) БД (rublox_equipped_skin) через /equipped-skin GET
// BabylonScene.enterPlayMode сам прочитает hash, поэтому
// записываем туда найденный скин.
try {
const hasHashSkin = /[#&]skin=/.test(window.location.hash || '');
if (!hasHashSkin) {
const uid = getCurrentUserId();
if (uid) {
const r = await Kubikon3DApi.getEquippedSkin(uid);
let sf = r?.data?.skin_folder;
// ВАЛИДАЦИЯ: legacy R15-скины (bacon-hair и пр.) больше
// не существуют. Если БД отдала невалидный — подменяем
// на skin_y-bot (как в плеере и кабинете).
if (sf && typeof sf === 'string'
&& !MIXAMO_SKINS.has(sf)
&& !sf.startsWith('customskin:')) {
console.log('[KubikonEditor] skin', sf, 'не валиден → skin_y-bot');
sf = 'skin_y-bot';
}
if (sf && typeof sf === 'string') {
// Подмешиваем в hash так чтобы не сломать ticket=...
const cur = window.location.hash || '';
const sep = cur && !cur.endsWith('&') ? '&' : '';
const newHash = cur
? `${cur}${sep}skin=${encodeURIComponent(sf)}`
: `#skin=${encodeURIComponent(sf)}`;
// history.replaceState чтобы не сломать react-router
window.history.replaceState(
null, '',
window.location.pathname + window.location.search + newHash,
);
console.log('[KubikonEditor] play skin from DB:', sf);
}
}
}
} catch (e) {
console.warn('[KubikonEditor] equipped-skin fetch failed:', e?.message || e);
}
// Флаш 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);
// 2026-06-14: при входе в Play автоматически запрашиваем
// fullscreen — иначе Ctrl+W/Ctrl+D случайно закрывают вкладку
// в режиме игры. Это user gesture (клик по кнопке Play),
// поэтому requestFullscreen() разрешён.
try {
const root = document.documentElement;
const req = root.requestFullscreen
|| root.webkitRequestFullscreen
|| root.mozRequestFullScreen
|| root.msRequestFullscreen;
if (req && !document.fullscreenElement) {
req.call(root).catch(() => {});
}
} catch (e) { /* юзер запретил — играем без FS */ }
// Если активен таб скрипта — авто-переключение на «Сцена»,
// чтобы пользователь сразу видел игру.
setActiveTabId('scene');
}
};
const handleBack = () => {
if (sceneRef.current?.isPlaying()) {
sceneRef.current.exitPlayMode();
}
// Флаш ScriptEditor — без этого 600мс свежих правок не успеют
// попасть в _scripts[]/dirtyRef и confirm-диалог не покажется.
try { scriptEditorFlushRef.current?.(); } catch (_) {}
// Несохранённые изменения — кастомная модалка с 3 кнопками:
// Сохранить (по умолчанию), Не сохранять, Отмена.
if (dirtyRef.current) {
setConfirmState({
title: 'Несохранённые изменения',
message: 'Сохранить проект перед выходом? Если выйти без сохранения — последние правки пропадут.',
confirmLabel: 'Сохранить и выйти',
cancelLabel: 'Выйти без сохранения',
confirmTone: 'primary',
onConfirm: () => doSave().finally(() => navigate('/')),
onCancel: () => 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>
{/* Гость-соавтор (приглашённый по ссылке) НЕ может менять
настройки/сохранять/публиковать — это делает только владелец. */}
{isInvitedGuest ? (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 8,
background: 'rgba(92,214,138,0.15)', color: '#5cd68a',
font: '600 12px system-ui',
}} title="Ты редактируешь вместе с автором. Сохраняет и публикует автор.">
<Icon name="users" size={13} /> Совместное редактирование
</span>
) : (
<>
<button
className={cl.toolbarBtn}
onClick={() => setSettingsModalOpen(true)}
title="Настройки игры"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
>
<Icon name="settings" size={13} /> Настройки
</button>
{/* Кнопка «Скины» переехала в TopRibbon → вкладка «Игра». */}
<button className={cl.toolbarBtn} onClick={handleSave}><Icon name="save" size={13} /> Сохранить</button>
<button
className={cl.toolbarBtn}
onClick={async () => {
if (publishBan || isCantPublish) {
setBanWarningOpen(true);
return;
}
// Перед публикацией: обязательная обложка.
// Не пускаем в ленту игры без превью — выглядит как
// мусор и убивает рекомендательную выдачу.
const thumb = metaRef.current?.thumbnail;
if (!thumb || typeof thumb !== 'string' || thumb.length < 100) {
alert(
'Чтобы опубликовать игру, добавь обложку.\n\n'
+ 'Открой «Настройки» → раздел «Обложка» → '
+ 'либо нажми «Снять текущий вид», либо загрузи '
+ 'свою картинку. После этого вернись и нажми '
+ '«Опубликовать».'
);
setSettingsModalOpen(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();
setSpawnEnabledUI(true);
}}
onSkins={() => setSkinManagerOpen(true)}
onInvite={handleInvite}
onGraphics={() => openDecor('graphics')}
onStartScreen={() => openDecor('startscreen')}
onLoadingScreen={() => openDecor('loadingscreen')}
collabActive={collabActive}
collabPeers={collabPeers}
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>
)}
{/* Color-пикер для окрашиваемых блоков (studs-block, задача 09).
Показываем когда активный блок имеет colorable:true. */}
{paletteTab === 'blocks'
&& BLOCK_TYPES.find(b => b.id === activeBlockType)?.colorable && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 4px', flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, opacity: 0.8 }}>Цвет блока:</span>
{['#e02a2a', '#e07a30', '#f0c020', '#5cba35', '#3a7aff', '#9b5cf0', '#ffffff', '#222428'].map(c => (
<button
key={c}
onClick={() => setActiveBlockColor(c)}
title={c}
style={{
width: 22, height: 22, borderRadius: 5, background: c, cursor: 'pointer',
border: activeBlockColor === c ? '2px solid #fff' : '1px solid rgba(255,255,255,0.25)',
boxShadow: activeBlockColor === c ? '0 0 0 1px var(--accent)' : 'none',
}}
/>
))}
<input
type="color"
value={activeBlockColor}
onChange={e => setActiveBlockColor(e.target.value)}
title="Свой цвет"
style={{ width: 28, height: 24, padding: 0, border: 'none', background: 'none', cursor: 'pointer' }}
/>
</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={(typeOrTemplate) => {
const { type, opts } = _expandGuiTemplate(typeOrTemplate);
// Задача 04: батч-шаблон (модальное окно) — создаём несколько элементов.
if (type === '_batch' && Array.isArray(opts?.elements)) {
let lastId = null;
for (const el of opts.elements) {
const elType = el.type || el.kind || 'frame';
const elOpts = { ...el };
delete elOpts.type; delete elOpts.kind;
const id = sceneRef.current?.createGuiElement?.(elType, elOpts);
if (id) lastId = id;
}
if (lastId) {
sceneRef.current?.selection?.selectGui?.(lastId);
setActiveTool('select');
}
} else {
const id = sceneRef.current?.createGuiElement?.(type, opts);
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 rawType = e.dataTransfer.getData('application/x-kubikon-gui');
if (!rawType) return;
e.preventDefault();
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)));
// Задача 03: раскрытие шаблона если type начинается с 'template:'.
const { type, opts } = _expandGuiTemplate(rawType);
// Задача 04: батч-шаблон — несколько элементов.
if (type === '_batch' && Array.isArray(opts?.elements)) {
let lastId = null;
for (const el of opts.elements) {
const elType = el.type || el.kind || 'frame';
const elOpts = { ...el };
delete elOpts.type; delete elOpts.kind;
const id = sceneRef.current?.createGuiElement?.(elType, elOpts);
if (id) lastId = id;
}
if (lastId) {
sceneRef.current?.selection?.selectGui?.(lastId);
setActiveTool('select');
}
} else {
const id = sceneRef.current?.createGuiElement?.(type, {
...opts, 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' }}
/>
{/* Кнопка «Пригласить» переехала в TopRibbon → вкладка «Игра» → группа «Вместе». */}
{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, и если скрипт не скрыл) */}
{/* Задача 04: модал-overlay (затемнение). Рендерится ПЕРЕД HUD/GUI
чтобы при target='scene' HUD оставался поверх (zIndex=25 у
ModalOverlay при scene, у GuiOverlay/HUD выше). При
target='screen' ModalOverlay сам прыгает на zIndex=50. */}
{isPlaying && <ModalOverlay scene={sceneRef.current} />}
{/* Задача 07: встроенный магазин скинов (открывается по B / API) */}
{isPlaying && <SkinShopOverlay scene={sceneRef.current} />}
<PlayerHud
visible={isPlaying && stdHudVisible && hpVisible}
hp={playerHp.hp}
maxHp={playerHp.maxHp}
ammo={weaponAmmo}
damaged={Date.now() - hurtFlash < 350}
/>
{/* Hot-bar инвентаря (виден только в Play, и если скрипт не скрыл) */}
<Hotbar
visible={isPlaying && stdHudVisible && hotbarVisible}
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)}
onOpenScript={(scriptId) => {
// Открыть скрипт-источник ошибки в редакторе.
try { sceneRef.current?.selection?.selectScript?.(scriptId); } catch (e) {}
openScriptTab(scriptId);
}}
/>
{/* 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}
language={sc.language || 'js'}
flushRef={scriptEditorFlushRef}
isSoloRunning={soloScriptId === sc.id}
onLanguageChange={(lang, currentEditorCode) => {
// Два слота: code_js и code_lua живут в самом скрипте.
// При переключении: сохраняем текущий код в слот ТЕКУЩЕГО
// языка, достаём слот ЦЕЛЕВОГО языка (или шаблон если пусто).
const fromLang = sc.language === 'lua' ? 'lua' : 'js';
if (fromLang === lang) return;
const fromSlotKey = fromLang === 'lua' ? 'code_lua' : 'code_js';
const toSlotKey = lang === 'lua' ? 'code_lua' : 'code_js';
// Сохраняем текущий редактируемый код в слот текущего языка
const savedSlots = {
...(sc.code_js !== undefined ? { code_js: sc.code_js } : {}),
...(sc.code_lua !== undefined ? { code_lua: sc.code_lua } : {}),
[fromSlotKey]: currentEditorCode || '',
};
// Достаём слот целевого языка или подставляем шаблон
let nextCode = savedSlots[toSlotKey];
if (nextCode === undefined || nextCode === '') {
nextCode = lang === 'lua'
? (sc.target ? LUA_TEMPLATE_PART : LUA_TEMPLATE_GLOBAL)
: JS_TEMPLATE_GLOBAL;
}
sceneRef.current?.upsertScript(
sc.id, nextCode, undefined, undefined, lang, savedSlots
);
setScriptsList(sceneRef.current?.getScripts?.() || []);
markDirty();
}}
onSave={(code) => {
// Зеркалим в слот активного языка чтобы при swap не потерять.
const slotKey = (sc.language === 'lua') ? 'code_lua' : 'code_js';
sceneRef.current?.upsertScript(
sc.id, code, sc.target, undefined, undefined,
{ [slotKey]: code }
);
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}
userModels={userModelsList}
primitives={primitivesList}
folders={foldersList}
scripts={scriptsList}
onSelectUserModel={(id) => {
sceneRef.current?.selection?.selectUserModelByInstanceId(id);
setActiveTool('select');
}}
onDeleteUserModel={(id) => {
sceneRef.current?.userModelManager?.removeInstance(id);
sceneRef.current?.clearSelection();
}}
onRenameUserModel={(id, name) => {
if (sceneRef.current?.renameUserModel?.(id, name)) markDirty();
}}
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' || target.kind === 'userModel') {
normalized = { kind: target.kind, id: target.id };
}
}
const tpl = normalized ? NEW_OBJECT_SCRIPT_TEMPLATE : NEW_SCRIPT_TEMPLATE;
// Понятное имя по умолчанию (а не сырой id).
const existing = sceneRef.current?.getScripts?.() || [];
const nm = normalized
? `Скрипт объекта ${existing.filter(s => s.target && s.target !== 'game').length + 1}`
: `Скрипт ${existing.filter(s => !s.target || s.target === 'game').length + 1}`;
sceneRef.current?.upsertScript(id, tpl, normalized, nm);
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}
spawnEnabled={spawnEnabledUI}
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');
}}
onDeleteSpawn={() => {
sceneRef.current?.deleteSpawn?.();
sceneRef.current?.clearSelection?.();
setSpawnEnabledUI(false);
markDirty();
}}
onSelectFolder={(folderId) => {
sceneRef.current?.selection?.selectFolder?.(folderId);
setActiveTool('select');
// Активируем gizmo «Двигать» чтобы сразу таскать всю группу.
setGizmoMode('move');
}}
onSelectLighting={() => {
sceneRef.current?.selection?.selectLighting();
setActiveTool('select');
}}
onDeleteBlock={(x, y, z) => {
sceneRef.current?.blockManager?.removeBlock(x, y, z);
sceneRef.current?.clearSelection();
markDirty();
hierarchyDirtyRef.current = true;
}}
onDeleteModel={(id) => {
sceneRef.current?.modelManager?.removeInstance(id);
sceneRef.current?._cleanupOrphanScripts?.();
sceneRef.current?.clearSelection();
setScriptsList(sceneRef.current?.getScripts?.() || []);
markDirty();
hierarchyDirtyRef.current = true;
}}
onDeletePrimitive={(id) => {
sceneRef.current?.primitiveManager?.removeInstance(id);
sceneRef.current?._cleanupOrphanScripts?.();
sceneRef.current?.clearSelection();
setScriptsList(sceneRef.current?.getScripts?.() || []);
markDirty();
hierarchyDirtyRef.current = true;
}}
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)}
onEditBillboard={() => {
const s = sceneRef.current;
const sel = s?.selection?.getSelection?.();
console.log('[EditBillboard] click, sel=', sel);
if (!sel || sel.type !== 'primitive') return;
const data = s?.primitiveManager?.instances?.get(sel.id);
console.log('[EditBillboard] data=', data?.type, 'id=', data?.id);
if (!data || data.type !== 'billboard') return;
setBillboardEditorData({
id: data.id,
billboard: data.billboard ? {
template: data.billboard.template,
face: data.billboard.face,
content: { ...data.billboard.content },
elements: data.billboard.elements,
} : null,
});
}}
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}
/>
{/* Оформление: графика / стартовый экран / экран загрузки (вкладка «Игра») */}
<GameDecorModal
open={decorModalOpen}
section={decorSection}
initial={metaRef.current}
onClose={() => setDecorModalOpen(false)}
onSave={handleDecorSave}
/>
{/* Задача 07: управление скинами проекта (стартовый, магазин, рублики, кастомные .glb) */}
<SkinManagerModal
open={skinManagerOpen}
allSkins={allSkinsList}
skinsConfig={sceneRef.current?._skinsConfig || null}
onClose={() => setSkinManagerOpen(false)}
onSave={(config) => {
const sc = sceneRef.current;
if (sc) {
sc._skinsConfig = config;
// Стартовый скин синхронизируем с playerModelType движка/UI.
if (config.default) {
const d = config.default;
const pmt = (d.startsWith('character-') || d.startsWith('skin_') || d.startsWith('customskin:'))
? d : ('skin_' + d);
try { sc.setPlayerModelType?.(pmt); } catch (e) {}
setPlayerModelTypeUI(pmt);
}
// Кастомные .glb: dataUrl хранится прямо в config.customGlbs,
// движок резолвит их через scene.getAssetDataUrl(slug).
}
markDirty();
setSkinManagerOpen(false);
}}
/>
<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;
}
// Обложки нет — бэк-страховка (фронт уже не должен
// дойти сюда, но если кто-то старый клиент шлёт — словим).
if (e?.response?.status === 400
&& e.response.data?.error === 'thumbnail_required') {
setPublishModalOpen(false);
alert(e.response.data.message || 'Добавь обложку игры в настройках.');
setSettingsModalOpen(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) => {
// Задача 17: готовая механика из Тулбокса (kit:<id>).
// Вставляем её скрипты/примитивы в проект одним кликом.
if (typeof id === 'string' && id.startsWith('kit:')) {
insertGameplayKit(id.slice(4));
return;
}
// Пользовательские модели имеют префикс '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
/>
{/* Редактор контента 3D-таблички (billboard primitive) */}
<BillboardEditorModal
open={billboardEditorData != null}
primitiveData={billboardEditorData}
onClose={() => setBillboardEditorData(null)}
onApply={(opts) => {
// Применяем к выделенному билборду через setSelectedPrimitivePropsTo
// (там логика прокидывания в PrimitiveManager + persist).
sceneRef.current?.setSelectedPrimitivePropsTo({
billboardOpts: opts,
});
markDirty();
setBillboardEditorData(null);
}}
/>
{/* Кастомная модалка подтверждения вместо window.confirm. */}
{confirmState && (
<ConfirmModal
{...confirmState}
onClose={() => setConfirmState(null)}
/>
)}
</div>
);
};
/**
* Team Create: слать соавторам точку под мышью на сцене (raycast по pointermove).
* Throttle уже внутри collab.sendCursor. Также шлём позицию камеры.
*/
function _wireCursorTracking(scene, collab) {
try {
const canvas = scene.canvas;
if (!canvas) return;
const onMove = () => {
const bScene = scene.scene;
if (!bScene) return;
try {
const pick = bScene.pick(bScene.pointerX, bScene.pointerY);
if (pick && pick.hit && pick.pickedPoint) {
collab.sendCursor(pick.pickedPoint.x, pick.pickedPoint.y, pick.pickedPoint.z);
}
} catch (e) { /* ignore */ }
// камера (throttle внутри)
try {
const c = scene.camera;
if (c && c.position) collab.sendCamera(c.position.x, c.position.y, c.position.z);
} catch (e) { /* ignore */ }
};
canvas.addEventListener('pointermove', onMove);
// сохраним для снятия (необязательно — canvas живёт с редактором)
collab.__cursorHandler = onMove;
collab.__cursorCanvas = canvas;
} catch (e) { /* ignore */ }
}
export default KubikonEditor;