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 { 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 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]);

    // При выходе из 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: p.canCollide !== false, visible: p.visible !== false, anchored: true,
                    name: p.name,
                });
                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_bacon-hair');
    const [envPreset, setEnvPresetUI] = useState('day');
    const [dayDurationMin, setDayDurationMinUI] = useState(5);
    const [nightDurationMin, setNightDurationMinUI] = useState(3);
    const [worldSize, setWorldSizeUI] = useState(80);
    const [shadowQuality, setShadowQualityUI] = useState('soft'); // 'off' | 'hard' | 'soft'
    const [ambientId, setAmbientIdUI] = useState('none');
    const [musicId, setMusicIdUI] = useState('none');

    // === Сохранение ===
    const [saveStatus, setSaveStatus] = useState('idle');
    /** Детальное сообщение о текущей фазе сохранения — показывается оверлеем при saving */
    const [saveDetail, setSaveDetail] = useState(null);   // { phase, pct, sizeKB }
    const currentProjectIdRef = useRef(id === 'new' ? null : Number(id));
    const dirtyRef = useRef(false);
    const autoSaveTimerRef = useRef(null);
    const isSavingRef = useRef(false);

    // === Метаданные проекта ===
    // Хранятся в ref-ах чтобы doSave мог их прочитать без зависимостей.
    const metaRef = useRef({
        title: id === 'new' ? 'Новая игра' : 'Загрузка...',
        description: '',
        genre: 'other',
        thumbnail: '',
        is_public: false,
        player_model_type: 'skin_bacon-hair',
    });
    const projectNameRef = useRef(projectName);
    useEffect(() => { projectNameRef.current = projectName; metaRef.current.title = projectName; }, [projectName]);

    // === Модалки ===
    // settingsModalOpen — настройки игры (Roblox Game Settings)
    // initialModalOpen  — инициальный диалог при создании новой игры
    const [settingsModalOpen, setSettingsModalOpen] = useState(false);
    // Задача 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,
                    };
                    // Состояние публикации (этап 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_bacon-hair');
                                const env = sceneRef.current.getEnvironmentState?.();
                                if (env?.preset) setEnvPresetUI(env.preset);
                                if (env?.dayDurationMin) setDayDurationMinUI(env.dayDurationMin);
                                if (env?.nightDurationMin) setNightDurationMinUI(env.nightDurationMin);
                                const wSize = sceneRef.current.getWorldSize?.();
                                if (wSize) setWorldSizeUI(wSize);
                                const sQ = sceneRef.current.getShadowQuality?.();
                                if (sQ) setShadowQualityUI(sQ);
                                const ch = sceneRef.current.getCrosshair?.();
                                if (ch) setCrosshairUI(ch);
                                setFloorEnabledUI(sceneRef.current.isFloorEnabled?.() !== false);
                                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);
        // Задача 12: конфиг экрана загрузки → в сцену (попадёт в project_data.scene
        // через toJSON). Логотип-дефолт = обложка проекта.
        try {
            sceneRef.current?.setLoadingConfig?.(data.loading_screen || null, data.thumbnail);
        } catch (e) { /* ignore */ }
        setSettingsModalOpen(false);
        setInitialModalOpen(false);
        if (autoSaveTimerRef.current) {
            clearTimeout(autoSaveTimerRef.current);
            autoSaveTimerRef.current = null;
        }
        dirtyRef.current = true;
        doSave();
    };

    // Закрыть инициальный диалог: если пользователь не сохранил — возвращаемся в Studio.
    const handleInitialClose = () => {
        setInitialModalOpen(false);
        // Если мы только что открылись на /new и нет id — возвращаемся в Studio.
        if (currentProjectIdRef.current == null) {
            navigate('/');
        }
    };

    const handlePlay = () => {
        const scene = sceneRef.current;
        if (!scene) return;
        if (scene.isPlaying()) {
            scene.exitPlayMode();
            setIsPlaying(false);
            // Сбрасываем HUD (счётчик/таймер/метки) — иначе при следующем
            // Play на экране остаётся счёт прошлой игры. setOnPlayChange
            // дёргается только на Esc-выход, кнопка Стоп — нет.
            hudRef.current?.reset?.();
        } else {
            // Флаш ScriptEditor — иначе при печати → сразу Play игра пойдёт
            // со старым кодом (debounce 600мс ещё не сработал).
            try { scriptEditorFlushRef.current?.(); } catch (_) {}
            // Используем точку спавна установленную пользователем через
            // маркер/Hierarchy/Inspector. Если по координатам спавна стоит
            // блок — поднимаем чуть выше чтобы игрок не появился внутри блока.
            const sp = scene._spawnPoint || { x: 0, y: 5, z: 0 };
            let spawnY = sp.y;
            if (scene.blockManager) {
                // Поднимаем пока в (sp.x, spawnY, sp.z) есть блок
                while (scene.blockManager.hasBlock(Math.round(sp.x), Math.round(spawnY), Math.round(sp.z))
                    && spawnY < sp.y + 50) {
                    spawnY += 1;
                }
            }
            scene.setSpawnPoint(sp.x, spawnY, sp.z);
            scene.enterPlayMode();
            setIsPlaying(true);
            // Если активен таб скрипта — авто-переключение на «Сцена»,
            // чтобы пользователь сразу видел игру.
            setActiveTabId('scene');
        }
    };

    const handleBack = () => {
        if (sceneRef.current?.isPlaying()) {
            sceneRef.current.exitPlayMode();
        }
        // Флаш ScriptEditor — без этого 600мс свежих правок не успеют
        // попасть в _scripts[]/dirtyRef и confirm-диалог не покажется.
        try { scriptEditorFlushRef.current?.(); } catch (_) {}
        // Несохранённые изменения — кастомная модалка с 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}
                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}
            />
            {/* Задача 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;
