import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { jwtDecode } from 'jwt-decode';
import { Client } from 'colyseus.js';
import * as Kubikon3DApi from '../api/Kubikon3DService';
import { BabylonScene } from '../engine/BabylonScene';
import { MIXAMO_SKINS } from '../engine/PlayerController';
import { attachConsoleHook, devlogReset } from '../engine/devlog';
import { MultiplayerSync } from '../engine/MultiplayerSync';
import { REALTIME_WS } from '../api/API';
import GameHud from '../editor-shared/GameHud';
import GuiOverlay from '../editor-shared/GuiOverlay';
import ModalOverlay from '../editor-shared/ModalOverlay';
import SkinShopOverlay from '../editor-shared/SkinShopOverlay';
import KubikonLeaderboard, { formatTimeMs } from '../components/KubikonLeaderboard/KubikonLeaderboard';
import KubikonPerfOverlay from '../components/KubikonPerfOverlay/KubikonPerfOverlay';
import Hotbar from '../editor-shared/Hotbar';
import PlayerHud from '../editor-shared/PlayerHud';
import GameMenu from './GameMenu';
import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton';
import KubikonChatPanel from './KubikonChatPanel';
import { useAuth } from '../auth/PlayerAuth';
import RublocsLogo from '../components/RublocsLogo/RublocsLogo';
import useDeviceType from '../hooks/useDeviceType';
import KubikonMobileControls from './KubikonMobileControls';
import GameLoadingScreen from './GameLoadingScreen';

// В десктоп-приложении (Electron-обёртка rublox-desktop) окно уже на весь
// экран без браузерной панели и без вкладок — fullscreen не нужен (раньше он
// защищал от случайного Ctrl+W/Ctrl+T в браузере; в Electron этого риска нет).
// preload выставляет window.__RUBLOX_DESKTOP__.
const IS_DESKTOP_APP = typeof window !== 'undefined' && !!window.__RUBLOX_DESKTOP__;

// В Android-приложении (Capacitor-обёртка rublox-android) WebView уже на весь
// экран — браузерный fullscreen не нужен, а стартовый оверлей «Нажми чтобы
// играть» избыточен (в браузере он нужен для user-gesture перед FS, в APK
// этого барьера не требуется). Capacitor выставляет window.Capacitor.
const IS_ANDROID_APP = typeof window !== 'undefined'
    && (!!window.Capacitor
        || /RubloxAndroid/i.test(navigator.userAgent || ''));

// Объединённый признак «нативного приложения» (десктоп ИЛИ Android) — там,
// где поведение совпадает (не дёргать fullscreen).
const IS_NATIVE_APP = IS_DESKTOP_APP || IS_ANDROID_APP;

// Плеер живёт на player.rublox.pro — он не знает SPA-роутов Майнкрафтии
// (/kubikon, /login, /auth). Поэтому вместо navigate(...) делаем
// явный window.location.assign на внешний домен.
//
// exitPlayer определяет «куда вернуться»:
//   - если плеер открыли из Майнкрафтии → возврат на minecraftia-school.ru
//   - иначе (rublox.pro или прямой URL) → возврат на rublox.pro/app
//
// document.referrer пуст, если юзер открыл вкладку напрямую или прошло
// слишком много времени — тогда дефолт rublox.pro.
function exitPlayer(gameId) {
    // Юзер явно нажал «Выход» — отключаем browser-confirm о закрытии
    // (флаг читает onBeforeUnload listener ниже).
    try { window.__rubloxExplicitExit = true; } catch {}
    const env = (typeof import.meta !== 'undefined' && import.meta.env) || {};
    const RUBLOX_HOME = (env.VITE_RUBLOX_HOME || 'https://rublox.pro/app').replace(/\/+$/, '');
    if (gameId) {
        // У страницы игры теперь свой URL: /app/game/<id> (им можно делиться).
        // Возвращаемся прямо на него. base RUBLOX_HOME оканчивается на /app.
        const base = RUBLOX_HOME.endsWith('/app') ? RUBLOX_HOME : `${RUBLOX_HOME}/app`;
        window.location.assign(`${base}/game/${gameId}`);
    } else {
        window.location.assign(RUBLOX_HOME);
    }
}

function goLogin() {
    window.location.assign('https://rublox.pro/login');
}

/**
 * Подфаза 3.9 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md.
 * После enterPlayMode грузит outfit пользователя и накладывает все
 * аксессуары на R15-скелет персонажа.
 *
 * Стратегия:
 *   1. Ждём пока PlayerController._loadPlayerModel завершится
 *      (scene.player.getAccessoryManager() != null).
 *   2. GET /rublox/outfit?user_id=...
 *   3. Для каждого item в slots (не is_body_skin) — equipAccessory(item).
 *
 * Body-skins (shirt/pants is_body_skin=true) пропускаем — они должны
 * грузиться через setPlayerModelType('body:<id>'), это будет в следующей
 * подфазе (3.6 завершена частично — игрок-юзер пока заходит с дефолтным
 * скином, а сменить body через rublox_outfit shirt/pants — отдельная
 * задача требует rework выбора body-скина игроком).
 */
async function _applyOutfitAccessories(scene, userId) {
    // Ждём до 5 сек пока PlayerController с AccessoryManager готов
    let player = null;
    const deadline = Date.now() + 5000;
    while (Date.now() < deadline) {
        const p = scene.player || scene._player;
        if (p && typeof p.equipAccessory === 'function'
            && typeof p.getAccessoryManager === 'function'
            && p.getAccessoryManager() != null) {
            player = p;
            break;
        }
        await new Promise((r) => setTimeout(r, 100));
    }
    if (!player) {
        // eslint-disable-next-line no-console
        console.warn('[outfit] player not ready, skipping accessories');
        return;
    }
    let outfit;
    try {
        const r = await Kubikon3DApi.getRubloxOutfit(userId);
        outfit = r?.data;
    } catch (e) {
        // eslint-disable-next-line no-console
        console.warn('[outfit] fetch failed', e);
        return;
    }
    const slots = outfit?.slots || {};
    const items = outfit?.items || {};
    const tasks = [];
    for (const [slotKey, itemId] of Object.entries(slots)) {
        if (!itemId) continue;
        const item = items[itemId];
        if (!item) continue;
        if (item.is_body_skin) continue;     // подфаза 3.6 хвост
        if (item.status && item.status !== 'published') continue;
        tasks.push(player.equipAccessory(item).catch((e) => {
            // eslint-disable-next-line no-console
            console.warn('[outfit] equipAccessory failed for slot',
                         slotKey, 'item', itemId, e);
        }));
    }
    await Promise.all(tasks);
}

/**
 * Палитра тёмного игрового HUD Рублокса.
 * Подобрана так, чтобы UI хорошо читался поверх любой 3D-сцены, но
 * визуально совпадал с синим акцентом Рублокса (#3357ff).
 */
const HUD = {
    bg:          'rgba(10, 14, 26, 0.92)',
    bgGlass:     'rgba(15, 19, 38, 0.78)',
    bgPanel:     'rgba(20, 24, 45, 0.94)',
    bgInput:     'rgba(15, 19, 38, 0.85)',
    border:      'rgba(255, 255, 255, 0.10)',
    borderHover: 'rgba(255, 255, 255, 0.18)',
    borderAcc:   'rgba(51, 87, 255, 0.55)',
    text:        '#f1f5fb',
    textMuted:   'rgba(241, 245, 251, 0.62)',
    textDim:     'rgba(241, 245, 251, 0.42)',
    accent:      '#3357ff',
    accentBg:    'rgba(51, 87, 255, 0.20)',
    accentSoft:  'rgba(51, 87, 255, 0.12)',
    success:     '#22d97a',
    successBg:   'rgba(34, 217, 122, 0.20)',
    danger:      '#ff6f7a',
    dangerBg:    'rgba(255, 111, 122, 0.18)',
    gradientBrand: 'linear-gradient(135deg, #3357ff 0%, #1e2da5 100%)',
    gradientHot:   'linear-gradient(135deg, #ec4899 0%, #ef4444 50%, #f59e0b 100%)',
    font:        '"Roboto Condensed", system-ui, -apple-system, sans-serif',
};

const HUD_KEYFRAMES = `
@keyframes hudFadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
@keyframes hudFadeInScale { from { opacity: 0; transform: scale(0.94); } to { opacity: 1; transform: scale(1); } }
@keyframes hudPulseRing {
    0%   { box-shadow: 0 0 0 0 rgba(51, 87, 255, 0.55); }
    70%  { box-shadow: 0 0 0 12px rgba(51, 87, 255, 0); }
    100% { box-shadow: 0 0 0 0 rgba(51, 87, 255, 0); }
}
@keyframes hudSpin { to { transform: rotate(360deg); } }
@keyframes hudFloat {
    0%, 100% { transform: translateY(0); }
    50%      { transform: translateY(-8px); }
}
`;

/**
 * KubikonPlayer — полноэкранная страница для прохождения опубликованной игры.
 * URL: /kubikon/play/:id
 *
 * Не редактор. Только Babylon-canvas + UI игры (HUD, hot-bar, GUI-оверлей).
 * Снизу — компактная панель кнопок: Назад / Лайк / Жалоба / Поделиться.
 */
const KubikonPlayer = () => {
    const { id } = useParams();
    const navigate = useNavigate();
    const projectId = Number(id);
    const { isAuthenticated, isLoading: authLoading, user: authUser } = useAuth();
    const device = useDeviceType();
    // На телефонах И планшетах — тач-управление. На десктопе обычное.
    const isTouch = device.isPhone || device.isTablet;
    // Ориентация — для портрет-promp'та на телефонах
    const [isPortrait, setIsPortrait] = useState(
        typeof window !== 'undefined' && window.innerHeight > window.innerWidth,
    );
    useEffect(() => {
        const onResize = () => {
            setIsPortrait(window.innerHeight > window.innerWidth);
        };
        window.addEventListener('resize', onResize);
        window.addEventListener('orientationchange', onResize);
        return () => {
            window.removeEventListener('resize', onResize);
            window.removeEventListener('orientationchange', onResize);
        };
    }, []);

    // Только зарегистрированные. Если гость / без токена — на rublox.pro/login.
    // В плеере (player.rublox.pro) роута /login нет, поэтому уходим на
    // внешний домен через goLogin().
    useEffect(() => {
        if (authLoading) return;
        if (!isAuthenticated) {
            goLogin();
        }
    }, [isAuthenticated, authLoading]);

    const canvasRef = useRef(null);
    const sceneRef = useRef(null);
    const viewportRef = useRef(null);
    const hudRef = useRef(null);
    /** Colyseus Room (только если игра мультиплеерная). */
    const roomRef = useRef(null);
    /** MultiplayerSync (мост между room и Babylon-сценой). */
    const mpSyncRef = useRef(null);
    /** Выбранный Mixamo-скин текущего игрока (из rublox_equipped_skin).
     *  Грузится при старте, уходит в мультиплеер как modelType.
     *  2026-06-13: дефолт сменён с skin_bacon-hair на skin_y-bot
     *  (Игрек-Бот, новый Mixamo-каталог). */
    const skinFolderRef = useRef('skin_y-bot');

    const [meta, setMeta] = useState(null);          // { title, description, user_id, ... }
    const [forbidden, setForbidden] = useState(false);
    const [error, setError] = useState(null);
    const [loading, setLoading] = useState(true);
    // Задача 05: конфиг красивого экрана загрузки (из project_data.scene.loadingScreen).
    const [loadingScreenCfg, setLoadingScreenCfg] = useState(null);
    const [loadProgress, setLoadProgress] = useState(0);
    // Раньше была стартовая заглушка «тапни чтобы начать» — убрали по
    // фидбэку, она бесила. Этот state остался для совместимости.
    const [mobileStartTapped, setMobileStartTapped] = useState(true);
    // 2026-06-14: вернулся стартовый клик-экран — теперь нужен чтобы
    // ВКЛЮЧИТЬ fullscreen и заблокировать Ctrl+W/Ctrl+T и др. системные
    // хоткеи. Без этого браузер закрывает вкладку при случайном Ctrl+W.
    // requestFullscreen() требует user gesture — поэтому без клика никак.
    // В нативном приложении (Electron/Capacitor) fullscreen не нужен (окно и
    // так на весь экран), поэтому стартовый оверлей пропускаем — игра
    // запускается сразу.
    const [gameStarted, setGameStarted] = useState(IS_NATIVE_APP);
    const [hp, setHp] = useState({ hp: 100, maxHp: 100 });
    // Скрипт через game.hud.setVisible(false) полностью скрывает стандартный HUD.
    const [stdHudVisible, setStdHudVisible] = useState(true);
    const [ammo, setAmmo] = useState(null);
    const [hurtFlash, setHurtFlash] = useState(0);
    const [inventoryState, setInventoryState] = useState({ slots: [], activeIndex: 0 });
    const [guiList, setGuiList] = useState([]);
    const [isPlaying, setIsPlaying] = useState(false);
    const [liked, setLiked] = useState(false);
    const [likesCount, setLikesCount] = useState(0);
    const [reportOpen, setReportOpen] = useState(false);
    const [infoModal, setInfoModal] = useState(null);   // { title, text, icon } | null
    const [needAuthModal, setNeedAuthModal] = useState(null); // строка-описание действия | null
    // Чат свёрнут по умолчанию — открывается кнопкой ☰ в верхнем баре.
    const [chatOpen, setChatOpen] = useState(false);
    // Roblox-style выпадашка из верхнего бара (☰)
    const [topMenuOpen, setTopMenuOpen] = useState(false);
    // Голос пользователя: 'like' | 'dislike' | null
    const [vote, setVote] = useState(null);
    const [dislikesCount, setDislikesCount] = useState(0);

    // === Лидерборд / таймер прохождения ===
    const [leaderboardVisible, setLeaderboardVisible] = useState(true);
    const [timerMs, setTimerMs] = useState(0);
    const [timerRunning, setTimerRunning] = useState(false);
    const [leaderboardRefreshKey, setLeaderboardRefreshKey] = useState(0);
    // Есть ли в игре таймер прохождения / рекорды (на не-ранерах нет)
    const [leaderboardEnabled, setLeaderboardEnabled] = useState(false);
    // Тост «+200 рейтинга за 1 место в таблице рекордов».
    // Появляется после submitLeaderboard если бэк выдал rating_award текущему юзеру.
    // null | { place: 1|2|3, amount: number }
    const [ratingToast, setRatingToast] = useState(null);
    const timerRafRef = useRef(null);
    /** Кэш загруженного project_data для soft-restart игры. */
    const initialStateRef = useRef(null);

    // rAF-цикл обновления таймера в HUD (только когда таймер запущен)
    useEffect(() => {
        if (!timerRunning) return;
        let cancelled = false;
        const tick = () => {
            if (cancelled) return;
            const s = sceneRef.current;
            if (s?.getTimerMs) {
                setTimerMs(s.getTimerMs());
            }
            timerRafRef.current = requestAnimationFrame(tick);
        };
        timerRafRef.current = requestAnimationFrame(tick);
        return () => {
            cancelled = true;
            if (timerRafRef.current) cancelAnimationFrame(timerRafRef.current);
        };
    }, [timerRunning]);

    // Tab — переключение видимости таблицы лидеров
    useEffect(() => {
        const onKey = (e) => {
            if (e.code === 'Tab' && !e.ctrlKey && !e.altKey) {
                const tag = (e.target && e.target.tagName) || '';
                if (tag === 'INPUT' || tag === 'TEXTAREA') return;
                e.preventDefault();
                setLeaderboardVisible(v => !v);
            }
        };
        window.addEventListener('keydown', onKey);
        return () => window.removeEventListener('keydown', onKey);
    }, []);

    // Проверяем при монтировании: есть ли в проекте рекорды? Если да —
    // игра-ранер (поддерживает таймер), показываем виджет лидерборда.
    // Иначе — игра без таймера (зомби, песочница и т.п.) — скрываем.
    useEffect(() => {
        if (!projectId) return;
        let active = true;
        Kubikon3DApi.getLeaderboard(projectId, 1)
            .then(r => {
                if (!active) return;
                const list = r.data?.records || [];
                if (list.length > 0) setLeaderboardEnabled(true);
            })
            .catch(() => {});
        return () => { active = false; };
    }, [projectId]);

    // Перехват системных Ctrl-комбинаций которые в WASD-игре регулярно
    // нажимаются случайно и приводят к закрытию вкладки / открытию диалогов.
    // В fullscreen Chrome даёт большинству этих хоткеев preventDefault'иться.
    //
    // 2026-06-14: добавлены KeyD (закладка), KeyS (сохранить страницу),
    // KeyA (выделить всё), KeyP (печать), KeyU (исходник), KeyJ/KeyH (история).
    // Все буквы которые могут зажиматься с Ctrl во время WASD-управления.
    useEffect(() => {
        const onKey = (e) => {
            // 1. Системные F-клавиши и навигация в любой момент:
            //    F5 = reload, F11 = fullscreen toggle (нужен Esc-way),
            //    Backspace = browser back (если фокус не на input),
            //    Tab — мешает фокусом UI.
            // F11 ОСТАВЛЯЕМ — даёт юзеру способ выйти из fullscreen.
            if (e.code === 'F5' || e.code === 'F3' || e.code === 'F6'
                || e.code === 'F7') {
                e.preventDefault(); e.stopPropagation(); return;
            }
            // 2. Ctrl/Cmd-комбинации. WASD-клавиши ОБРАБАТЫВАЕМ отдельно:
            // блокируем системное действие браузера (preventDefault), но
            // НЕ stopPropagation — иначе PlayerController не увидит ввод.
            // Игрок часто приседает (Ctrl) и одновременно идёт (W/A/S/D).
            if (e.ctrlKey || e.metaKey) {
                const wasd = ['KeyW', 'KeyA', 'KeyS', 'KeyD'];
                if (wasd.includes(e.code)) {
                    e.preventDefault();   // блокируем Ctrl+W (закрытие), Ctrl+D (закладка) и т.д.
                    return;                // НЕ stopPropagation — пусть PlayerController увидит
                }
                const blocked = [
                    'KeyR',  // reload
                    'KeyT',  // new tab
                    'KeyN',  // new window
                    'KeyP',  // print
                    'KeyU',  // view source
                    'KeyJ',  // downloads
                    'KeyH',  // history
                    'KeyF',  // find on page
                    'KeyG',  // find next
                    'KeyL',  // focus address bar
                    'KeyO',  // open file
                    'Tab',   // switch tab
                    'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5',
                    'Digit6', 'Digit7', 'Digit8', 'Digit9',
                ];
                if (blocked.includes(e.code)) {
                    e.preventDefault();
                    e.stopPropagation();
                }
            }
        };
        window.addEventListener('keydown', onKey, { capture: true });
        return () => {
            window.removeEventListener('keydown', onKey, { capture: true });
        };
    }, []);

    /**
     * Soft-restart игры: выходим из Play, перезагружаем оригинальный
     * project_data (восстанавливая удалённые/скрытые скриптом примитивы),
     * заходим в Play заново. Полностью сбрасывает игровое состояние —
     * полезно после прохождения если игрок хочет улучшить рекорд.
     */
    const handleRestartGame = useCallback(async () => {
        const scene = sceneRef.current;
        const initial = initialStateRef.current;
        if (!scene || !initial) return;
        try {
            if (scene.isPlaying?.()) {
                scene.exitPlayMode?.();
            }
            // Перезагрузка состояния — сбрасывает все примитивы/блоки/модели
            await scene.loadFromState(initial);
            // Сбросим UI-состояние таймера
            setTimerMs(0);
            setTimerRunning(false);
            // Ждём кадр и заходим в Play
            await new Promise((r) => setTimeout(r, 50));
            scene.enterPlayMode?.();
        } catch (e) {
            console.warn('[KubikonPlayer] restart failed', e);
        }
    }, []);

    // === Мультиплеер ===
    /** 'idle' | 'connecting' | 'connected' | 'failed'. */
    const [mpStatus, setMpStatus] = useState('idle');
    /** Сообщение об ошибке (если 'failed'). */
    const [mpError, setMpError] = useState(null);
    /** Кол-во удалённых игроков в комнате (для UI индикатора). */
    const [mpRemotePlayers, setMpRemotePlayers] = useState(0);
    /** Код комнаты для UI «🔑 ABC123». */
    const [mpRoomCode, setMpRoomCode] = useState('');

    // ВАЖНО: берём userId из PlayerAuth-контекста, а НЕ из
    // localStorage напрямую. Раньше код Майнкрафтии читал
    // localStorage['Authorization'] здесь — в плеере JWT лежит ещё и
    // в 'player_jwt', и при первом маунте после ticket-redeem
    // 'Authorization' мог быть не синхронизирован → userId=null →
    // engine не знает кто играет → savegame API возвращает null →
    // GD-скрипты получают пустой gd_progress (нет skin/color/coins).
    // useAuth() гарантированно отдаёт user.id если isAuthenticated=true.
    //
    // Fallback на jwtDecode из localStorage оставлен на случай если
    // контекст ещё не успел подняться (теоретически невозможно потому
    // что App.jsx не рендерит KubikonPlayer до isAuthenticated=true,
    // но дёшево и страхует).
    const userId = (() => {
        if (authUser?.id != null) return authUser.id;
        try {
            const t = localStorage.getItem('player_jwt') || localStorage.getItem('Authorization');
            if (!t) return null;
            const p = jwtDecode(t.startsWith('Bearer ') ? t.slice(7) : t);
            return p?.id || p?.user_id || null;
        } catch (e) { return null; }
    })();

    // === Инициализация сцены и загрузка проекта ===
    useEffect(() => {
        if (!canvasRef.current) return;
        // Dev-logging: на localhost console.* шлётся в devlog.txt
        // для разработки. На проде запросы тихо игнорируются.
        try { devlogReset(); attachConsoleHook(); } catch (e) {}
        const scene = new BabylonScene(canvasRef.current);
        scene.init();
        // Тач-режим включаем ДО enterPlayMode — чтобы PlayerController
        // создался без pointer-lock. На десктопе оставляем false.
        try { scene.setTouchMode?.(isTouch); } catch (e) {}
        sceneRef.current = scene;

        // В плеере пол редактора не нужен — пользовательская сцена
        // принесёт свой пол / блоки; сразу скрываем editorGround.
        try { scene.setFloorEnabled(false); } catch (e) {}

        // В плеере мы не редактируем — отключаем все инструменты редактора:
        // блоки, модели, эраза. Иначе случайный клик мог поставить блок в мир.
        scene._activeTool = 'select';
        scene._activeBlockType = null;
        scene._activeModelType = null;
        // Флаг для нашего кода — на случай если потребуется проверка позже
        scene._isPlayerOnly = true;

        // === User-models API: критично для рендера воксельных моделей ===
        // Без этих двух вызовов UserModelManager._api остаётся null и при
        // загрузке проекта _loadModelData() тихо возвращает null для каждой
        // воксельной модели в scene.userModels — игрок видит сцену БЕЗ
        // воксельных моделей (кристаллы, животные, артефакты и т.д.).
        // В редакторе это делается в KubikonEditor.jsx сразу после init().
        scene.setUserModelsApi(Kubikon3DApi);
        scene.setCurrentUserId(userId);
        // projectId нужен для game.save.* (универсальные сейвы).
        if (projectId) scene.setCurrentProjectId(projectId);
        // game.hud.setVisible(false) скроет HP-бар/hotbar для своего меню
        scene.setOnStdHudVisibilityChange?.((v) => setStdHudVisible(v));

        // Колбэки HUD
        scene.setOnPlayerHpChange?.((h) => {
            setHp({ hp: h.hp, maxHp: h.maxHp });
            if (h.damaged) setHurtFlash(Date.now());
        });
        scene.setOnPlayerDeath?.(() => {
            setHp({ hp: 0, maxHp: 100 });
            setTimeout(() => {
                const s = sceneRef.current;
                if (!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?.((a) => setAmmo(a));
        scene.setOnInventoryChange?.(() =>
            setInventoryState(scene.getInventoryState?.() || { slots: [], activeIndex: 0 })
        );
        scene.setOnGuiChange?.(() => setGuiList(scene.getGuiElements?.() || []));
        scene.setOnScriptHud?.((event) => hudRef.current?.handle?.(event));
        // === Таймер прохождения для лидерборда ===
        scene.setOnTimer?.(({ state, timeMs }) => {
            // Если скрипт зовёт timer — игра поддерживает таблицу лидеров
            if (state === 'start' || state === 'submit' || state === 'stop') {
                setLeaderboardEnabled(true);
            }
            if (state === 'start') {
                setTimerMs(0);
                setTimerRunning(true);
            } else if (state === 'stop') {
                setTimerMs(timeMs);
                setTimerRunning(false);
            } else if (state === 'submit') {
                setTimerMs(timeMs);
                setTimerRunning(false);
                // Шлём результат на сервер. В ответе бэк возвращает
                // rating_awards = [{user_id, place, rating_amount}, ...] —
                // если попал в топ-3 (и раньше за это место рейтинг не получал),
                // показываем тост и обновляем глобальный счётчик профиля.
                if (userId && projectId && timeMs > 0) {
                    Kubikon3DApi.submitLeaderboard(projectId, userId, timeMs)
                        .then((res) => {
                            setLeaderboardRefreshKey(k => k + 1);
                            const awards = res?.data?.rating_awards;
                            if (Array.isArray(awards)) {
                                const mine = awards.find(
                                    (a) => Number(a.user_id) === Number(userId)
                                );
                                if (mine && mine.rating_amount > 0) {
                                    setRatingToast({
                                        place: mine.place,
                                        amount: mine.rating_amount,
                                    });
                                    // авто-скрытие через 6 сек
                                    setTimeout(() => setRatingToast(null), 6000);
                                }
                            }
                        })
                        .catch((e) => console.warn('[leaderboard] submit failed', e));
                }
            }
        });
        scene.setOnPlayChange?.((playing) => {
            setIsPlaying(playing);
            // ВНИМАНИЕ: при обычном ESC сюда мы больше НЕ попадаем — ESC теперь
            // открывает меню через setOnEscMenu (ниже), не выходя из Play.
            // exitPlayMode(false) случается только по-настоящему (напр. движок
            // сам остановил Play). В этом случае просто открываем меню, чтобы
            // юзер мог выйти/перезапустить. НЕ пересоздаём Play автоматически —
            // повторный enterPlayMode респавнил игрока и перезапускал скрипты
            // («перезапуск плейса» при ESC). Перезапуск делается явной кнопкой.
            if (!playing) {
                const s = sceneRef.current;
                s?.player?.setUiCursorMode?.(true);
                setChatOpen(false);
                setTopMenuOpen(true);
                try { if (s) s._playerMenuOpen = true; } catch (e) { /* ignore */ }
            }
        });
        // ESC в Play → TOGGLE меню-оверлея поверх ЖИВОЙ игры (Roblox-style).
        // Движок сам решает open/close (единый источник истины _playerMenuOpen)
        // и передаёт сюда. Это убирает гонку двух ESC-обработчиков, из-за которой
        // меню открывалось поверх меню, а orbit-камера по ПКМ зависала.
        scene.setOnEscMenu?.((open) => {
            if (open) {
                setChatOpen(false);
                setTopMenuOpen(true);
            } else {
                setTopMenuOpen(false);
            }
        });

        // Загружаем проект.
        // STANDALONE-режим (VITE_STANDALONE=true) подсовывает встроенный
        // fixture вместо запроса к API — для разработчиков без бэкенда.
        const _env = (typeof import.meta !== 'undefined' && import.meta.env) || {};
        const IS_STANDALONE = String(_env.VITE_STANDALONE).toLowerCase() === 'true';
        (async () => {
            try {
                let data;
                if (IS_STANDALONE) {
                    const sample = await import('../fixtures/sample-game.json');
                    data = sample.default || sample;
                } else {
                    const res = await Kubikon3DApi.getProjectForPlay(projectId, userId);
                    data = res.data;
                }
                setMeta(data);
                setLikesCount(data.likes_count || 0);
                setDislikesCount(data.dislikes_count || 0);
                setLoadProgress(0.3);

                if (data.project_data) {
                    const parsed = JSON.parse(data.project_data);
                    initialStateRef.current = parsed;
                    // Задача 05: красивый экран загрузки — конфиг автора (если задан в студии).
                    try {
                        const lsc = parsed?.scene?.loadingScreen;
                        if (lsc && typeof lsc === 'object' && lsc.enabled !== false) setLoadingScreenCfg(lsc);
                    } catch (e) { /* ignore */ }
                    await scene.loadFromState(parsed);
                    setLoadProgress(0.7);
                }

                // Ждём пока Babylon реально загрузит и скомпилит все
                // материалы/текстуры/GLB. Без этого при первом кадре
                // половина мешей рисуется без текстур (chess-pattern fallback)
                // и постепенно подтягивается — игрок видит «пустых зомби».
                await new Promise((resolve) => {
                    if (!scene.scene) { resolve(); return; }
                    let done = false;
                    const finish = () => { if (!done) { done = true; resolve(); } };
                    scene.scene.executeWhenReady(finish);
                    // Safety-таймаут: 3с максимум, чтобы не залип в loading навсегда
                    // если что-то не загрузилось.
                    setTimeout(finish, 3000);
                });

                // === Персональный скин игрока ===
                // Грузим выбранный скин из БД (rublox_equipped_skin) и
                // применяем его к локальному игроку ДО enterPlayMode —
                // тогда player.setModelType подхватит правильный скин.
                // Этот же skinFolder уйдёт в мультиплеер как modelType,
                // чтобы соперники видели наш реальный скин.
                //
                // LOCAL DEV (localhost): берём скин из URL #skin=<id>
                // (передаётся сайтом 3000 при нажатии «Играть»), потом из
                // localStorage самого плеера, потом дефолт. В БД НЕ лезем —
                // прод-БД хранит легаси скины (skin_sigma-labubu и др.),
                // которые мы не хотим грузить локально.
                //
                // PROD: только БД (rublox_equipped_skin).
                let mySkin = 'skin_y-bot';
                const isLocalDev = (typeof window !== 'undefined'
                    && (window.location.hostname === 'localhost'
                        || window.location.hostname === '127.0.0.1'));
                // Источник скина по приоритету:
                //   1) hash-параметр #skin=<id> в URL (передаёт сайт при play-ticket;
                //      работает и на localhost и на проде)
                //   2) БД через /equipped-skin (если есть userId)
                //   3) localStorage самого плеера (fallback на localhost для отладки)
                //   4) skin_y-bot (дефолт)
                try {
                    console.log('[KubikonPlayer] hash=', window.location.hash,
                        '| LS rublox_selected_skin=', (typeof localStorage !== 'undefined' ? localStorage.getItem('rublox_selected_skin') : '?'));
                    const m = window.location.hash.match(/[#&]skin=([\w-]+)/);
                    if (m && m[1]) {
                        mySkin = m[1];
                        console.log('[KubikonPlayer] skin from URL:', mySkin);
                    }
                } catch (e) {}
                if (mySkin === 'skin_y-bot' && userId) {
                    // 2) Лезем в БД (через прод-API). Бэк отдаёт либо
                    //    выбранный валидный скин, либо дефолт по полу.
                    try {
                        const skinRes = await Kubikon3DApi.getEquippedSkin(userId);
                        const sf = skinRes?.data?.skin_folder;
                        if (sf && typeof sf === 'string') {
                            mySkin = sf;
                            console.log('[KubikonPlayer] skin from DB:', mySkin);
                        }
                    } catch (e) {
                        console.warn('[KubikonPlayer] equipped-skin load failed', e);
                    }
                }
                if (mySkin === 'skin_y-bot' && isLocalDev) {
                    // 3) Локальный fallback на localStorage плеера.
                    try {
                        const localPick = localStorage.getItem('rublox_selected_skin');
                        if (localPick && typeof localPick === 'string') {
                            mySkin = localPick;
                            console.log('[KubikonPlayer] skin from local LS:', mySkin);
                        }
                    } catch (e) {}
                }
                // ВАЛИДАЦИЯ: если скин не из валидного набора Mixamo-скинов
                // (legacy bacon-hair/sigma-labubu/cop и пр. — их больше нет,
                // или бэкенд вернул дефолтный bacon) — fallback на skin_y-bot.
                // Это защита: персонаж не должен пытаться загрузить несуществующий
                // скин. См. БД rublox_equipped_skin (22+ юзеров с bacon-hair).
                if (mySkin && !MIXAMO_SKINS.has(mySkin)
                    && !mySkin.startsWith('customskin:')) {
                    console.log('[KubikonPlayer] skin', mySkin, 'не валиден → skin_y-bot');
                    mySkin = 'skin_y-bot';
                }
                skinFolderRef.current = mySkin;
                try { scene.setPlayerModelType?.(mySkin); } catch (e) {}

                setLoadProgress(1);
                setLoading(false);
                // Засчитываем плей. Передаём user_id (если залогинен) —
                // это активирует self-cooldown (автор не накручивает себе)
                // и user-cooldown на бэке (см. /play в Kubikon3D.py).
                Kubikon3DApi.incrementPlay(projectId, userId).catch(() => {});
                // Запускаем игру сразу
                setTimeout(() => {
                    scene.enterPlayMode?.();
                    // Подфаза 3.9: после enterPlayMode грузим outfit
                    // и накладываем все аксессуары на персонажа.
                    if (userId) {
                        _applyOutfitAccessories(scene, userId).catch((e) => {
                            // eslint-disable-next-line no-console
                            console.warn('[KubikonPlayer] outfit accessories failed', e);
                        });
                    }
                    // Если игра мультиплеерная — подключаемся к realtime-серверу.
                    // Делаем после enterPlayMode, чтобы scene.player уже был создан.
                    //
                    // ВАЖНО: для GD-уровней (296-315, 350-358, 295-лаунчер)
                    // mp ОТКЛЮЧАЕМ принудительно, даже если БД говорит
                    // multiplayer=true (это исторический косяк данных:
                    // первые GD-уровни заводили из шутера-template и не
                    // сменили флаг). GD по своей сути single-player,
                    // mp-зомби-игроки только жрут CPU (R15Animator апдейт
                    // на каждом удалённом игроке = stutter, видно в логе
                    // dt=0.0939 ~ 94мс на кадр).
                    const pid = Number(projectId);
                    const isGdProject = (pid >= 296 && pid <= 315)
                        || (pid >= 350 && pid <= 358)
                        || pid === 295;
                    if (data.multiplayer && !isGdProject) {
                        setTimeout(() => connectMultiplayer(data), 300);
                    }
                }, 200);
            } catch (e) {
                if (e?.response?.status === 403) {
                    setForbidden(true);
                } else {
                    setError(e?.message || 'Ошибка загрузки');
                }
                setLoading(false);
            }
        })();

        // Проверим голос пользователя (like/dislike)
        if (userId) {
            Kubikon3DApi.getLikeStatus(projectId, userId)
                .then(r => {
                    setVote(r.data?.vote || null);
                    setLiked(r.data?.vote === 'like');
                })
                .catch(() => {});
        }

        return () => {
            // Сначала рвём мультиплеер — иначе sync будет тыкаться в disposed scene
            try { scene.setMultiplayerSync?.(null); } catch (e) {}
            if (mpSyncRef.current) {
                try { mpSyncRef.current.dispose(); } catch (e) {}
                if (mpSyncRef.current._countTimer) {
                    clearInterval(mpSyncRef.current._countTimer);
                }
                mpSyncRef.current = null;
            }
            if (roomRef.current) {
                try { roomRef.current.leave(true); } catch (e) {}
                roomRef.current = null;
            }
            try { scene.dispose(); } catch (e) {}
            sceneRef.current = null;
        };
    }, [projectId, userId, navigate]);

    // session_id живёт в течение одной игровой сессии; шлём его в heartbeat
    const sessionId = useMemo(() => {
        const a = new Uint32Array(4);
        (window.crypto || window.msCrypto)?.getRandomValues?.(a);
        return Array.from(a).map(x => x.toString(16).padStart(8, '0')).join('');
    }, []);

    // Heartbeat каждые 30 секунд для онлайн-метрики админки.
    useEffect(() => {
        if (!projectId || loading) return;
        let alive = true;
        const beat = () => {
            if (!alive) return;
            Kubikon3DApi.playHeartbeat(sessionId, projectId, userId).catch(() => {});
        };
        beat();   // первый сразу после загрузки
        const t = setInterval(beat, 30_000);
        return () => { alive = false; clearInterval(t); };
    }, [projectId, userId, sessionId, loading]);

    // Хоткеи 1-5 для слотов инвентаря.
    // Babylon ловит ввод на canvas — слушаем в capture-phase на window
    // и не привязываемся к isPlaying (state-флаг может быть ещё false на старте).
    useEffect(() => {
        const onKey = (e) => {
            if (e.repeat) return;
            const code = e.code || '';
            if (code.startsWith('Digit')) {
                const n = parseInt(code.slice(5), 10);
                if (n >= 1 && n <= 5) sceneRef.current?.setActiveInventorySlot?.(n - 1);
            } else if (code.startsWith('Numpad') && code.length === 7) {
                const n = parseInt(code.slice(6), 10);
                if (n >= 1 && n <= 5) sceneRef.current?.setActiveInventorySlot?.(n - 1);
            }
        };
        window.addEventListener('keydown', onKey, true);
        return () => window.removeEventListener('keydown', onKey, true);
    }, []);

    // ESC во время игры — открыть меню (без выхода из Play).
    //
    // Тонкость: в большинстве браузеров ESC, нажатый при активном
    // pointer-lock'е, обрабатывается нативно — снимает lock БЕЗ keydown
    // (или с keydown позже pointerlockchange). Поэтому keydown-listener
    // не успевает выставить _uiCursorMode перед pointerlockchange.
    //
    // Решение: слушаем сам pointerlockchange. Когда lock теряется во время
    // активной игры — это значит игрок нажал ESC, и нам нужно:
    //   1) пометить player как UI-cursor mode (чтобы Babylon не сделал
    //      exitPlayMode → respawn),
    //   2) открыть наше меню.
    // Этот listener должен быть в capture-фазе, чтобы сработать раньше
    // listener'а PlayerController.
    useEffect(() => {
        const onLockChange = () => {
            const s = sceneRef.current;
            if (!s || !s._isPlaying) return;
            const locked = !!document.pointerLockElement;
            if (locked || !s.player || s.player._uiCursorMode) return;
            // Lock потерян. НЕ всякая потеря = ESC! В third-person отпускание
            // ПКМ (orbit-камера) тоже снимает lock — это НЕ выход в меню.
            // Меню открываем ТОЛЬКО если lock был «постоянным» (perma-режим:
            // first/lockfirst/sideview/shiftLock) — там потеря lock = реальный ESC.
            const p = s.player;
            const permaLock = (
                p._cameraMode === 'first' ||
                p._cameraMode === 'lockfirst' ||
                p._cameraMode === 'sideview' ||
                p._shiftLock
            );
            // _rmbHeld был выставлен при входе в lock; если ПКМ отпущена в third —
            // это orbit-завершение, не меню.
            if (!permaLock) return;
            // Реальный ESC в perma-режиме → открываем меню.
            p._uiCursorMode = true;
            setChatOpen(false);
            setTopMenuOpen(true);
            // Синхронизируем единый флаг меню в движке, чтобы следующий ESC
            // сработал как toggle-закрытие (а не открыл второе меню).
            try { s._playerMenuOpen = true; } catch (e) { /* ignore */ }
        };
        // capture-фаза, чтобы успеть раньше PlayerController
        document.addEventListener('pointerlockchange', onLockChange, true);
        return () => document.removeEventListener('pointerlockchange', onLockChange, true);
    }, []);

    // Повторный ESC (toggle закрытие) теперь обрабатывает движок через
    // setOnExitRequest → _onEscMenu(false). Отдельный React-обработчик ESC
    // УБРАН — он слушал тот же ESC, что и движок, и создавал гонку:
    // меню открывалось поверх себя, а _uiCursorMode застревал в true
    // (orbit-камера по ПКМ переставала работать после закрытия меню).

    // Горячая клавиша T — открыть/закрыть чат. Игнорируем когда:
    //   • уже введён текст в <input>/<textarea>/contenteditable (юзер печатает)
    //   • открыто верхнее меню (Esc-меню) — T там может относиться к табам
    useEffect(() => {
        const onKey = (e) => {
            if (e.key !== 't' && e.key !== 'T' && e.key !== 'е' && e.key !== 'Е') return;
            const tgt = e.target;
            if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA' || tgt.isContentEditable)) return;
            const s = sceneRef.current;
            if (!s || !s._isPlaying) return;
            e.preventDefault();
            setChatOpen(v => {
                if (!v) setTopMenuOpen(false);
                return !v;
            });
        };
        window.addEventListener('keydown', onKey);
        return () => window.removeEventListener('keydown', onKey);
    }, []);

    /** Переродиться — телепорт на spawn point с восстановлением hp. */
    /**
     * Подключение к мультиплеерной комнате этой игры.
     * Если уже подключены — ничего не делаем.
     * При успехе создаём MultiplayerSync, который двусторонне синхронизирует
     * локального игрока (PlayerController) с сервером и рисует remote-игроков.
     *
     * Защита от race-conditions: если sceneRef ещё не готов или scene/player
     * пропали (HMR во время загрузки), пробуем ещё несколько раз с интервалом
     * 200мс, потом сдаёмся.
     */
    const connectMultiplayer = useCallback(async (projectMeta, retries = 8) => {
        if (mpSyncRef.current || roomRef.current) return;

        const sceneObj = sceneRef.current;
        const babylonScene = sceneObj?.scene;
        const player = sceneObj?.player;

        // Если что-то ещё не готово — повторяем попытку.
        if (!sceneObj || !babylonScene || !player) {
            if (retries > 0) {
                setTimeout(() => connectMultiplayer(projectMeta, retries - 1), 200);
            } else {
                console.warn('[mp] connect aborted — scene/player not ready');
                setMpStatus('failed');
                setMpError('Сцена не готова');
            }
            return;
        }

        const tokenRaw = localStorage.getItem('Authorization') || '';
        if (!tokenRaw) return;

        setMpStatus('connecting');
        setMpError(null);
        try {
            const client = new Client(REALTIME_WS);
            // modelType — ПЕРСОНАЛЬНЫЙ скин этого игрока (из rublox_equipped_skin,
            // загружен при старте в skinFolderRef). Сервер всё равно перепроверит
            // скин по userId из JWT и при расхождении возьмёт значение из БД —
            // так каждый игрок виден соперникам в своём реальном скине.
            const modelType = skinFolderRef.current || 'skin_y-bot';
            // Если у нас есть валидный reconnectionToken от прошлой сессии —
            // используем Colyseus reconnect (это та же сессия для сервера,
            // allowReconnection(5) её подхватит, не будет +join/-leave цикла).
            // Иначе — обычный joinOrCreate.
            const savedRT = window.__rubloxReconnectToken;
            let room;
            if (savedRT) {
                try {
                    room = await client.reconnect(savedRT);
                } catch (e) {
                    // токен протух (>5с прошло или сервер уже удалил) — обычный join
                    window.__rubloxReconnectToken = null;
                    room = await client.joinOrCreate('battle', {
                        projectId: projectMeta?.id || projectId,
                        token: tokenRaw,
                        modelType,
                    });
                }
            } else {
                room = await client.joinOrCreate('battle', {
                    projectId: projectMeta?.id || projectId,
                    token: tokenRaw,
                    modelType,
                });
            }
            roomRef.current = room;
            // Сохраняем reconnectionToken для возможного будущего реконнекта.
            window.__rubloxReconnectToken = room.reconnectionToken;
            setMpRoomCode((room.roomId || '').slice(0, 6).toUpperCase());

            const sync = new MultiplayerSync(
                babylonScene,   // Babylon Scene (взяли в начале функции)
                room,
                () => {
                    // Колбэк-источник позиции локального игрока
                    const p = sceneRef.current?.player?._pos;
                    const yaw = sceneRef.current?.player?._yaw;
                    if (!p) return null;
                    // p.y — это центр игрока (HALF_H выше пола под ногами).
                    // Передаём как есть, на клиенте при рендере других уберём HALF_H.
                    return { x: p.x, y: p.y || 0, z: p.z, yaw: yaw || 0 };
                },
                {
                    onLocalHit: (damage, hpVal, maxHpVal) => {
                        // Применяем урон в локальный PlayerController.
                        // Сервер — авторитет, синхронизируем hp напрямую,
                        // не используя takeDamage (он для локальных зомби,
                        // имеет i-frames и считает свой hp — может разойтись
                        // с сервером и пропустить смерть).
                        const p = sceneRef.current?.player;
                        if (p) {
                            const wasDead = p.hp <= 0;
                            p.hp = hpVal;
                            p.maxHp = maxHpVal;
                            // Звук «ой»
                            try { p._playHurtSound?.(); } catch (e) {}
                            // Если только что умерли — debris + спрятать модель
                            if (!wasDead && hpVal <= 0) {
                                try { p._spawnDeathDebris?.(); } catch (e) {}
                                if (p._modelRoot) p._modelRoot.setEnabled(false);
                                if (p._onDeath) {
                                    try { p._onDeath(); } catch (e) {}
                                }
                            }
                        }
                        setHurtFlash(Date.now());
                        setHp({ hp: hpVal, maxHp: maxHpVal });
                    },
                    onKilled: (killerName) => {
                        console.log(`[mp] killed by ${killerName}`);
                    },
                    onRespawn: () => {
                        const s = sceneRef.current;
                        if (s?.player && s._spawnPoint) {
                            s.player.healFull?.();
                            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);
                            if (s.player._vy != null) s.player._vy = 0;
                        }
                    },
                    onLog: (level, ...parts) => {
                        // eslint-disable-next-line no-console
                        console.log(`[mp:${level}]`, ...parts);
                    },
                },
            );
            sync.start();
            mpSyncRef.current = sync;
            // Прокидываем sync в сцену, чтобы _handlePlayClick шёл и в мультиплеер тоже.
            sceneRef.current?.setMultiplayerSync?.(sync);

            // Подфаза 3.10: отправляем серверу свой outfit, чтобы соседи
            // увидели наши аксессуары. Делаем async — outfit грузим заново
            // (он мог не успеть к моменту enterPlayMode).
            if (userId) {
                Kubikon3DApi.getRubloxOutfit(userId).then((r) => {
                    const slots = r?.data?.slots || {};
                    // Серверу шлём только хорошие item-id (без null'ов).
                    const clean = {};
                    for (const [k, v] of Object.entries(slots)) {
                        if (v != null) clean[k] = v;
                    }
                    sync.sendAccessories(clean);
                }).catch((e) => {
                    // eslint-disable-next-line no-console
                    console.warn('[KubikonPlayer] sendAccessories failed', e);
                });
            }

            // === Локальная смерть → сервер ===
            // Когда наш PlayerController сам поставил hp=0 (от куклы Squid Game,
            // ловушки, скриптовой смерти и т.п.) — серверу надо сообщить, чтобы
            // он бродкастил 'kill' всем остальным. Иначе другие клиенты не
            // увидят рассыпание нашего скина на кубики.
            //
            // Сервер защищён от повторных вызовов (isDead-guard), поэтому если
            // умерли от выстрела — повторное 'die' проигнорируется.
            try {
                const p = sceneRef.current?.player;
                if (p && typeof p.setOnDeath === 'function') {
                    p.setOnDeath(() => {
                        try { room.send('die', {}); } catch (e) { /* room закрыта */ }
                    });
                }
            } catch (e) {
                console.warn('[mp] setOnDeath failed:', e);
            }

            // Обновляем счётчик remote-игроков для UI
            const updateCount = () => {
                setMpRemotePlayers(sync.remotePlayers.size);
            };
            // Простой таймер — раз в секунду
            const countTimer = setInterval(updateCount, 1000);
            sync._countTimer = countTimer;

            room.onLeave((code) => {
                if (sync._countTimer) clearInterval(sync._countTimer);
                setMpRoomCode('');
                // НЕ сбрасываем remotePlayers/status сразу — Colyseus reconnect
                // обычно отрабатывает за <1с, не надо мигать UI. Если за 2с
                // не восстановили — тогда показываем 'connecting'.
                const softFailTimer = setTimeout(() => {
                    setMpRemotePlayers(0);
                    setMpStatus('connecting');
                }, 2000);
                // Очищаем ref'ы — иначе следующий connectMultiplayer выйдет
                // на if (mpSyncRef.current || roomRef.current) return.
                try { sync.stop?.(); } catch (e) {}
                // ВАЖНО: dispose() сносит ВСЕ старые меши remote-игроков со
                // сцены. Без этого при auto-reconnect (Colyseus rejoin) новый
                // MultiplayerSync видит пустую Map и при +remote создаёт
                // дубль-меш на каждый кадр (см. фикс 2026-06-05).
                try { sync.dispose?.(); } catch (e) {}
                mpSyncRef.current = null;
                roomRef.current = null;
                // Code 1000 / 1001 — нормальное закрытие. Code >= 4000 — наш
                // явный leave (game-side). Всё остальное — реконнект.
                const isExplicit = window.__rubloxExplicitExit || code === 4000;
                if (isExplicit) {
                    clearTimeout(softFailTimer);
                    setMpStatus('idle');
                    setMpRemotePlayers(0);
                    return;
                }
                // Автореконнект с быстрым backoff. Сервер держит сессию через
                // allowReconnection(5с), успеваем в это окно.
                const tries = (window.__mpReconnectTries || 0) + 1;
                window.__mpReconnectTries = tries;
                if (tries > 5) {
                    clearTimeout(softFailTimer);
                    setMpStatus('failed');
                    setMpError('Соединение потеряно');
                    setMpRemotePlayers(0);
                    return;
                }
                // 200ms / 400ms / 800ms / 1600ms / 3200ms
                const delay = 200 * Math.pow(2, tries - 1);
                setTimeout(() => {
                    if (window.__rubloxExplicitExit) return;
                    // softFailTimer отменяется в самом connectMultiplayer
                    // через присвоение window.__mpSoftFailTimer
                    connectMultiplayer(projectMeta);
                }, delay);
                window.__mpSoftFailTimer = softFailTimer;
            });

            // Успешно подключились — сбрасываем счётчик попыток и отменяем
            // soft-fail таймер (если автореконнект уложился в 2с).
            if (window.__mpSoftFailTimer) {
                clearTimeout(window.__mpSoftFailTimer);
                window.__mpSoftFailTimer = null;
            }
            window.__mpReconnectTries = 0;
            setMpStatus('connected');
        } catch (err) {
            console.error('[mp] connect error:', err);
            setMpStatus('failed');
            setMpError(err?.message || String(err));
        }
    }, [projectId]);

    /** Запросить fullscreen. Вызывается ИЗ user gesture (клик по кнопке),
     *  иначе браузер запретит. Orientation lock НЕ ставим — играть можно
     *  и в портрете, и в ландшафте. */
    const handleMobileStart = useCallback(async () => {
        const root = document.documentElement;
        const req = root.requestFullscreen
            || root.webkitRequestFullscreen
            || root.mozRequestFullScreen
            || root.msRequestFullscreen;
        // В нативном приложении (Electron/Capacitor) окно и так на весь
        // экран — FS не нужен.
        if (req && !IS_NATIVE_APP) {
            try { await req.call(root); } catch (e) { /* отменено */ }
        }
        setMobileStartTapped(true);
    }, []);

    /** Стартовый клик «Начать игру» — запрашивает fullscreen
     *  (Chrome блокирует Ctrl+W/Ctrl+T в fullscreen) и снимает оверлей.
     *  В нативном приложении (Electron/Capacitor) FS не нужен. */
    const handleGameStart = useCallback(async () => {
        const root = document.documentElement;
        const req = root.requestFullscreen
            || root.webkitRequestFullscreen
            || root.mozRequestFullScreen
            || root.msRequestFullscreen;
        if (req && !IS_NATIVE_APP) {
            try { await req.call(root); } catch (e) { /* юзер запретил — играем без FS */ }
        }
        setGameStarted(true);
    }, []);

    // При выходе со страницы — снимаем fullscreen / orientation lock,
    // чтобы возврат в школу не остался залочен в landscape.
    useEffect(() => {
        return () => {
            try {
                if (document.fullscreenElement) document.exitFullscreen?.();
                else if (document.webkitFullscreenElement) document.webkitExitFullscreen?.();
            } catch (e) {}
            try { window.screen?.orientation?.unlock?.(); } catch (e) {}
        };
    }, []);

    const respawnPlayer = useCallback(() => {
        const s = sceneRef.current;
        if (s?.player && s._spawnPoint) {
            s.player.healFull?.();
            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);
            if (s.player._vy != null) s.player._vy = 0;
        }
        // Возвращаем мышь в игру
        if (s) {
            setTimeout(() => {
                if (!s._isPlaying) s.enterPlayMode?.();
                s.player?.setUiCursorMode?.(false);
            }, 30);
        }
    }, []);

    const handleVote = useCallback(async (kind /* 'like' | 'dislike' */) => {
        if (!userId) {
            setNeedAuthModal(kind === 'dislike' ? 'оценивать игры' : 'лайкать игры');
            return;
        }
        try {
            const res = await Kubikon3DApi.toggleLike(projectId, userId, kind);
            setVote(res.data?.vote || null);
            setLiked(res.data?.vote === 'like');
            setLikesCount(res.data?.likes_count || 0);
            setDislikesCount(res.data?.dislikes_count || 0);
        } catch (e) { /* ignore */ }
    }, [projectId, userId]);

    const handleShare = useCallback(() => {
        const url = window.location.href;
        try { navigator.clipboard?.writeText(url); } catch (e) {}
        setInfoModal({
            icon: '🔗',
            title: 'Ссылка скопирована',
            text: url,
        });
    }, []);

    const handleReportClick = useCallback(() => {
        if (!userId) {
            setNeedAuthModal('оставлять жалобы');
            return;
        }
        setReportOpen(true);
    }, [userId]);

    if (forbidden) {
        return <CenteredCard
            icon="🔒"
            title="Игра недоступна"
            text="Эта игра ещё не одобрена модерацией или скрыта автором."
            onBack={() => exitPlayer(id)}
        />;
    }
    if (error) {
        return <CenteredCard
            icon="⚠️"
            title="Ошибка"
            text={error}
            onBack={() => exitPlayer(id)}
        />;
    }

    return (
        <div style={{
            position: 'fixed', inset: 0,
            background: '#070a14',
            zIndex: 1000,
            display: 'flex', flexDirection: 'column',
            fontFamily: HUD.font,
        }}>
            <style>{HUD_KEYFRAMES}</style>
            <div ref={viewportRef} style={{
                flex: 1, position: 'relative', minHeight: 0,
                background: '#070a14',
            }}>
                <canvas
                    ref={canvasRef}
                    style={{
                        width: '100%', height: '100%',
                        display: 'block',
                        outline: 'none',
                    }}
                />
                {/* GameLoadingScreen НЕ показывается при загрузке плейса.
                 *  Появляется ТОЛЬКО когда автор вызовет его из скрипта игры
                 *  (через game.showLoadingScreen или аналог). По дефолту — игра
                 *  открывается сразу, как в Roblox. */}

                {/* 2026-06-14: стартовый оверлей. Один клик → fullscreen →
                 *  Chrome блокирует Ctrl+W/Ctrl+T/Ctrl+R и др. Без него
                 *  юзер случайно закрывает вкладку, теряет прогресс. */}
                {!loading && !gameStarted && (
                    <div
                        onClick={handleGameStart}
                        style={{
                            position: 'absolute',
                            inset: 0,
                            background: 'rgba(7, 11, 26, 0.86)',
                            backdropFilter: 'blur(6px)',
                            zIndex: 2000,
                            display: 'flex',
                            flexDirection: 'column',
                            alignItems: 'center',
                            justifyContent: 'center',
                            color: '#fff',
                            cursor: 'pointer',
                            userSelect: 'none',
                        }}
                    >
                        <div style={{
                            fontSize: 36,
                            fontWeight: 800,
                            marginBottom: 14,
                            letterSpacing: '0.5px',
                        }}>
                            Нажми чтобы играть
                        </div>
                        <div style={{
                            fontSize: 16,
                            opacity: 0.75,
                            maxWidth: 480,
                            textAlign: 'center',
                            lineHeight: 1.4,
                            padding: '0 24px',
                        }}>
                            {IS_DESKTOP_APP ? (
                                <>Управление: <b>WASD</b> — движение, <b>пробел</b> — прыжок,
                                мышь — камера.</>
                            ) : (
                                <>
                                    Игра откроется в полноэкранном режиме —
                                    это защитит от случайного закрытия вкладки
                                    (Ctrl+W, Ctrl+T и др.).
                                    <br />
                                    Выход: <b>Esc</b> или <b>F11</b>.
                                </>
                            )}
                        </div>
                        <button
                            type="button"
                            style={{
                                marginTop: 28,
                                padding: '14px 38px',
                                fontSize: 18,
                                fontWeight: 700,
                                background: 'linear-gradient(135deg,#4f7df0,#2563eb)',
                                border: 'none',
                                borderRadius: 12,
                                color: '#fff',
                                cursor: 'pointer',
                                boxShadow: '0 6px 18px rgba(37,99,235,0.45)',
                            }}
                        >
                            ▶ Начать
                        </button>
                    </div>
                )}

                {/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */}
                {!loading && (
                    <>
                        {/* HUD: на мобиле уменьшаем и сдвигаем компактно. */}
                        {isTouch ? (
                            <>
                                {/* HP-бар: вверху слева, scale 0.75 */}
                                <div data-mobile-hud="hp" style={{
                                    position: 'absolute', top: 0, left: 0,
                                    transform: 'scale(0.75)', transformOrigin: 'top left',
                                    pointerEvents: 'none', zIndex: 30,
                                }}>
                                    <PlayerHud
                                        visible={stdHudVisible}
                                        hp={hp.hp}
                                        maxHp={hp.maxHp}
                                        ammo={null}
                                        damaged={Date.now() - hurtFlash < 350}
                                    />
                                </div>
                                {/* Mini ammo block: правый верх. Большие цифры,
                                    кнопка R для перезарядки внизу. */}
                                {ammo && (
                                    <MobileAmmoBlock
                                        ammo={ammo}
                                        onReload={() => sceneRef.current?.weapons?.reload?.()}
                                    />
                                )}
                                {/* Hotbar — только если в инвентаре есть хоть
                                    один предмет. Пустой инвентарь не показываем. */}
                                {stdHudVisible && (inventoryState.slots || []).some(s => s) && (
                                    <Hotbar
                                        visible={stdHudVisible}
                                        mobileMode
                                        slots={inventoryState.slots}
                                        activeIndex={inventoryState.activeIndex}
                                        onSelect={(i) => sceneRef.current?.setActiveInventorySlot?.(i)}
                                    />
                                )}
                            </>
                        ) : (
                            <>
                                <PlayerHud
                                    visible={stdHudVisible}
                                    hp={hp.hp}
                                    maxHp={hp.maxHp}
                                    ammo={ammo}
                                    damaged={Date.now() - hurtFlash < 350}
                                />
                                {stdHudVisible && (inventoryState.slots || []).some(s => s) && (
                                    <Hotbar
                                        visible={stdHudVisible}
                                        slots={inventoryState.slots}
                                        activeIndex={inventoryState.activeIndex}
                                        onSelect={(i) => sceneRef.current?.setActiveInventorySlot?.(i)}
                                    />
                                )}
                            </>
                        )}
                        <GameHud visible={true} hudRef={hudRef} />
                        {/* Performance overlay — клавиша F или клик по бейджу.
                            Также фоном шлёт метрики на бэк раз в 5 сек. */}
                        <KubikonPerfOverlay
                            getScene={() => sceneRef.current}
                            projectId={projectId}
                            userId={userId}
                        />
                        {/* Тост-награда «+200 рейтинга за 1 место в таблице рекордов».
                            Появляется когда submitLeaderboard вернул rating_award
                            для текущего юзера (попадание в топ-3 впервые).
                            Авто-закрытие через 6 сек или клик. */}
                        {ratingToast && (
                            <RatingAwardToast
                                place={ratingToast.place}
                                amount={ratingToast.amount}
                                onClose={() => setRatingToast(null)}
                            />
                        )}

                        {/* Лидерборд (правый верхний угол) — Tab скрыть/показать.
                            Сама панель НЕ перехватывает клики/мышь (pointer-events: none),
                            чтобы не разблокировать курсор в Play-режиме.
                            Только кнопки внутри (✕ закрыть, 🔄 заново) кликабельны. */}
                        {leaderboardVisible && leaderboardEnabled && (
                            <div style={{
                                position: 'absolute',
                                top: 64, right: 12,
                                zIndex: 35,
                                pointerEvents: 'none',
                                animation: 'hudFadeIn 0.3s ease-out',
                                display: 'flex', flexDirection: 'column', gap: 8,
                                alignItems: 'flex-end',
                            }}>
                                <div style={{ pointerEvents: 'auto' }}>
                                    <KubikonLeaderboard
                                        projectId={projectId}
                                        limit={5}
                                        currentUserId={userId}
                                        currentTimeMs={timerRunning ? timerMs : null}
                                        onClose={() => setLeaderboardVisible(false)}
                                        compact={true}
                                        refreshKey={leaderboardRefreshKey}
                                        clickable={false}
                                        onLoaded={(records) => {
                                            // Если в проекте уже есть рекорды — игра-ранер,
                                            // включаем виджет даже если timer ещё не стартовал.
                                            if (records && records.length > 0) {
                                                setLeaderboardEnabled(true);
                                            }
                                        }}
                                    />
                                </div>
                                {/* Кнопка «Заново» — для попытки улучшить рекорд */}
                                <button
                                    onClick={handleRestartGame}
                                    title="Начать игру заново (сбросить состояние)"
                                    style={{
                                        background: 'linear-gradient(135deg, rgba(34,217,122,0.22), rgba(20,24,45,0.92))',
                                        border: '1px solid rgba(34, 217, 122, 0.55)',
                                        borderRadius: 10,
                                        color: '#22d97a',
                                        padding: '8px 14px',
                                        fontFamily: '"Roboto Condensed", system-ui, sans-serif',
                                        fontWeight: 700, fontSize: 14,
                                        cursor: 'pointer',
                                        backdropFilter: 'blur(6px)',
                                        boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
                                        transition: 'transform 0.12s ease',
                                        pointerEvents: 'auto',
                                    }}
                                    onMouseEnter={(e) => {
                                        e.currentTarget.style.transform = 'translateY(-2px)';
                                    }}
                                    onMouseLeave={(e) => {
                                        e.currentTarget.style.transform = 'translateY(0)';
                                    }}
                                >🔄 Заново</button>
                            </div>
                        )}
                        {/* Кнопки «Топ» и «Заново» когда лидерборд скрыт.
                            Размещены ниже компактного таймера в правом верхнем углу.
                            Кнопка «Топ» показывается только если игра поддерживает таймер. */}
                        {!leaderboardVisible && leaderboardEnabled && (
                            <div style={{
                                position: 'absolute',
                                top: timerRunning ? 60 : 18, right: 12,
                                zIndex: 35,
                                display: 'flex', flexDirection: 'column', gap: 8,
                                alignItems: 'flex-end',
                            }}>
                                <button
                                    onClick={() => setLeaderboardVisible(true)}
                                    title="Показать таблицу лидеров (Tab)"
                                    style={{
                                        background: 'rgba(20, 24, 45, 0.85)',
                                        border: '1px solid rgba(255, 215, 0, 0.55)',
                                        borderRadius: 10,
                                        color: '#ffd700',
                                        padding: '8px 14px',
                                        fontFamily: '"Roboto Condensed", system-ui, sans-serif',
                                        fontWeight: 700, fontSize: 14,
                                        cursor: 'pointer',
                                        backdropFilter: 'blur(6px)',
                                    }}
                                    onMouseEnter={e => { e.currentTarget.style.background = 'rgba(40, 44, 65, 0.95)'; }}
                                    onMouseLeave={e => { e.currentTarget.style.background = 'rgba(20, 24, 45, 0.85)'; }}
                                >
                                    🏆 Топ
                                </button>
                                <button
                                    onClick={handleRestartGame}
                                    title="Начать игру заново"
                                    style={{
                                        background: 'rgba(20, 24, 45, 0.85)',
                                        border: '1px solid rgba(34, 217, 122, 0.55)',
                                        borderRadius: 10,
                                        color: '#22d97a',
                                        padding: '8px 14px',
                                        fontFamily: '"Roboto Condensed", system-ui, sans-serif',
                                        fontWeight: 700, fontSize: 14,
                                        cursor: 'pointer',
                                        backdropFilter: 'blur(6px)',
                                    }}
                                    onMouseEnter={e => { e.currentTarget.style.background = 'rgba(40, 44, 65, 0.95)'; }}
                                    onMouseLeave={e => { e.currentTarget.style.background = 'rgba(20, 24, 45, 0.85)'; }}
                                >
                                    🔄 Заново
                                </button>
                            </div>
                        )}
                        {/* Компактный таймер справа сверху над кнопками — когда
                            таблица лидеров скрыта (иначе видно в самой таблице). */}
                        {timerRunning && !leaderboardVisible && (
                            <div style={{
                                position: 'absolute',
                                top: 18, right: 12,
                                zIndex: 35,
                                background: 'rgba(20, 24, 45, 0.92)',
                                border: '1px solid rgba(34, 217, 122, 0.55)',
                                borderRadius: 10,
                                color: '#22d97a',
                                padding: '6px 12px',
                                fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
                                fontWeight: 800, fontSize: 16,
                                pointerEvents: 'none',
                                backdropFilter: 'blur(6px)',
                            }}>
                                ⏱ {formatTimeMs(timerMs)}
                            </div>
                        )}
                        <GuiOverlay
                            elements={guiList}
                            isPlaying={true}
                            containerRef={viewportRef}
                            // 2026-05-27: без resolveAsset картинки GUI с
                            // imageAsset не показывались в плеере — el.imageAsset
                            // резолвился в null. Берём dataURL из assetManager.
                            resolveAsset={(id) =>
                                sceneRef.current?.assetManager?.getDataUrl?.(id) || null}
                            onPlayClick={(gid) => {
                                const rt = sceneRef.current?.gameRuntime;
                                if (!rt) return;
                                rt.routeEvent({ kind: 'gui', id: gid }, 'click', {});
                                rt.routeGlobalEvent('guiClick', { id: gid });
                            }}
                        />
                        {/* Задача 04: модал-overlay (затемнение + spotlight mask) */}
                        <ModalOverlay scene={sceneRef.current} />
                        {/* Задача 07: встроенный магазин скинов (клавиша B / API) */}
                        <SkinShopOverlay scene={sceneRef.current} />
                        {/* Мобильное управление — на любых тач-устройствах,
                            и в портрете и в ландшафте (ранее был блок portrait,
                            убрали по фидбэку — играть можно как угодно). */}
                        {isTouch && (
                            <KubikonMobileControls getScene={() => sceneRef.current} />
                        )}
                    </>
                )}
                {/* Кнопка «полный экран» — маленькая, в правом верхнем углу.
                    Показывается на ВСЕХ устройствах (desktop + touch):
                    - touch — нужна чтобы скрыть UI браузера на телефоне
                    - desktop — в fullscreen Chrome блокирует Ctrl+W/Ctrl+T
                      и прочие системные хоткеи, которые иначе закрывают вкладку.
                    Браузеры требуют user gesture для requestFullscreen() —
                    поэтому без кнопки никак. */}
                {!loading && !document.fullscreenElement && (
                    <button
                        data-mobile-hud="fullscreen"
                        onClick={handleMobileStart}
                        style={{
                            position: 'absolute',
                            top: 8, right: 8,
                            width: 36, height: 36,
                            borderRadius: 8,
                            background: 'rgba(15, 19, 38, 0.78)',
                            border: '1px solid rgba(255, 255, 255, 0.18)',
                            color: '#fff',
                            cursor: 'pointer',
                            zIndex: 1100,
                            display: 'flex', alignItems: 'center', justifyContent: 'center',
                            padding: 0,
                            WebkitTapHighlightColor: 'transparent',
                        }}
                        title="Полный экран (блокирует Ctrl+W и др. системные хоткеи)"
                    >
                        <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
                            <path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"
                                stroke="#fff" strokeWidth="2.2"
                                strokeLinecap="round" strokeLinejoin="round" />
                        </svg>
                    </button>
                )}
            </div>

            {/* === Floating top bar (слева вверху, под HP-баром) === */}
            {!loading && (
                <div data-mobile-hud="topbar" style={{
                    position: 'absolute',
                    // На мобиле HP-бар сжат scale(0.75) занимает примерно 60px
                    // высоты в верху. Кладём top-bar НИЖЕ него, чтобы не перекрывал.
                    top: isTouch ? 60 : 96,
                    left: isTouch ? 8 : 14,
                    zIndex: 1100,
                    display: 'flex', gap: isTouch ? 6 : 8, alignItems: 'flex-start',
                    animation: 'hudFadeIn 320ms ease',
                    transform: isTouch ? 'scale(0.85)' : 'none',
                    transformOrigin: 'top left',
                }}>
                    {/* Кнопка «Меню» убрана — меню открывается клавишей Esc.
                        GameMenu (Roblox-style 5 табов) рендерится как fixed-модалка
                        в самом низу плеера, см. блок <GameMenu .../>. */}

                    {/* Toggle чата — круглая иконка-таблетка с T-бейджем
                        (как в exe-плеере: BR-стиль кнопки чата над миром). */}
                    <ChatToggleButton
                        active={chatOpen}
                        onClick={() => setChatOpen(v => {
                            if (!v) setTopMenuOpen(false);
                            return !v;
                        })}
                    />

                    {/* Мультиплеер-индикатор (только если игра multiplayer=true) */}
                    {meta?.multiplayer && (
                        <div style={{
                            ...topbarBtn,
                            cursor: 'default',
                            background: mpStatus === 'connected'
                                ? 'linear-gradient(135deg, #22d97a 0%, #0f9d56 100%)'
                                : mpStatus === 'failed'
                                    ? 'linear-gradient(135deg, #ff6f7a 0%, #c0303f 100%)'
                                    : HUD.bgGlass,
                            color: HUD.text,
                            borderColor: mpStatus === 'connected' || mpStatus === 'failed'
                                ? 'transparent'
                                : HUD.border,
                            display: 'inline-flex', alignItems: 'center', gap: 6,
                        }}
                        title={
                            mpStatus === 'connected'
                                ? `Мультиплеер активен · комната ${mpRoomCode}`
                                : mpStatus === 'connecting'
                                    ? 'Подключаемся к мультиплееру…'
                                    : mpStatus === 'failed'
                                        ? `Мультиплеер не подключён: ${mpError || ''}`
                                        : 'Мультиплеер'
                        }>
                            <svg width="14" height="14" viewBox="0 0 24 24" fill="none"
                                stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                                <circle cx="12" cy="12" r="10" />
                                <line x1="2" y1="12" x2="22" y2="12" />
                                <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
                            </svg>
                            <span>
                                {mpStatus === 'connected' && (
                                    <>
                                        <span style={{ fontWeight: 800 }}>{mpRemotePlayers + 1}</span>
                                        {mpRoomCode && (
                                            <span style={{
                                                marginLeft: 6, fontSize: 10,
                                                background: 'rgba(0,0,0,0.20)',
                                                padding: '1px 6px',
                                                borderRadius: 999,
                                                fontFamily: 'monospace',
                                                letterSpacing: 1,
                                            }}>{mpRoomCode}</span>
                                        )}
                                    </>
                                )}
                                {mpStatus === 'connecting' && '…'}
                                {mpStatus === 'failed' && 'offline'}
                                {mpStatus === 'idle' && '—'}
                            </span>
                        </div>
                    )}
                </div>
            )}

            {/* === Компактный чат слева-сверху (под top bar) ===
                Держим компонент СМОНТИРОВАННЫМ всегда (после loading) и
                скрываем через CSS — иначе при unmount теряется история
                сообщений и WS-подписка на 'chat' слетает с realtime-room.
                Когда chatOpen=false — visibility:hidden, pointer-events:none. */}
            {!loading && (
                <div style={{
                    visibility: chatOpen ? 'visible' : 'hidden',
                    pointerEvents: chatOpen ? 'auto' : 'none',
                }}>
                    <KubikonChatPanel
                        projectId={projectId}
                        compact
                        mobileMode={isTouch}
                        onClose={() => setChatOpen(false)}
                        onRequestAuth={(action) => {
                            setChatOpen(false);
                            setNeedAuthModal(action);
                        }}
                        /* Если игра мультиплеерная и мы подключены — чат идёт
                           через realtime-room (видят только игроки в этой комнате).
                           Иначе fallback к storys-чату игры. */
                        realtimeRoom={mpStatus === 'connected' ? roomRef.current : null}
                    />
                </div>
            )}

            {reportOpen && (
                <ReportModal
                    onClose={() => setReportOpen(false)}
                    onSubmit={async ({ category, text }) => {
                        if (!userId) {
                            setReportOpen(false);
                            setNeedAuthModal('оставлять жалобы');
                            return;
                        }
                        await Kubikon3DApi.createReport({
                            reporter_user_id: userId,
                            target_type: 'project',
                            target_id: projectId,
                            category,
                            text,
                        });
                        setReportOpen(false);
                        setInfoModal({
                            icon: '✅',
                            title: 'Жалоба отправлена',
                            text: 'Модераторы рассмотрят её в ближайшее время. Спасибо за внимательность!',
                        });
                    }}
                />
            )}

            {infoModal && (
                <InfoModal
                    icon={infoModal.icon}
                    title={infoModal.title}
                    text={infoModal.text}
                    onClose={() => setInfoModal(null)}
                />
            )}

            {needAuthModal && (
                <NeedAuthModal
                    action={needAuthModal}
                    onClose={() => setNeedAuthModal(null)}
                    onLogin={() => goLogin()}
                />
            )}


            {/* Плавающая кнопка баг-репорта (открывается из ESC-меню).
                Сама кнопка скрыта — данные нужны только её модалке. */}
            {!loading && (
                <KubikonBugReportButton
                    bugType="player"
                    projectId={projectId}
                    bottomOffset={70}
                    rightOffset={16}
                    hidden
                />
            )}

            {/* GameMenu — Roblox-style центральная модалка с 5 табами
                (Участники / Настройки / Захваты / Жалоба / Помощь). 1 в 1
                как в exe-плеере (см. rublox-native/godot/scripts/ui/GameHud.gd).
                Открывается по бургер-кнопке "Меню" и по Esc. */}
            {!loading && (
                <GameMenu
                    visible={topMenuOpen}
                    onClose={() => {
                        setTopMenuOpen(false);
                        // Синхронизируем движок (_playerMenuOpen) И возвращаем мышь
                        // в игру одним вызовом. Без этого следующий ESC решит, что
                        // меню «ещё открыто», и не откроет его.
                        try { sceneRef.current?.setPlayerMenuOpen?.(false); } catch {}
                    }}
                    onExit={() => exitPlayer(id)}
                    onRespawn={() => respawnPlayer()}
                    gameId={id}
                    gameTitle={meta?.title}
                    mySkin={skinFolderRef.current}
                    sceneRef={sceneRef}
                />
            )}
        </div>
    );
};

const btn = {
    padding: '8px 16px',
    background: HUD.bgInput,
    border: `1px solid ${HUD.border}`,
    borderRadius: 8,
    color: HUD.text,
    cursor: 'pointer',
    fontSize: 13,
    fontWeight: 600,
    fontFamily: HUD.font,
    whiteSpace: 'nowrap',
    transition: 'all 150ms ease',
};

const btnPrimary = {
    ...btn,
    background: HUD.gradientBrand,
    border: '1px solid transparent',
    color: '#fff',
    fontWeight: 700,
    boxShadow: '0 6px 16px rgba(51,87,255,0.32)',
};

const btnDanger = {
    ...btn,
    background: 'linear-gradient(135deg, #ff6f7a 0%, #c0303f 100%)',
    border: '1px solid transparent',
    color: '#fff',
    fontWeight: 700,
    boxShadow: '0 6px 16px rgba(255,111,122,0.30)',
};

/** Кнопка в верхней плашке плеера. */
const topbarBtn = {
    padding: '8px 14px',
    background: HUD.bgGlass,
    border: `1px solid ${HUD.border}`,
    borderRadius: 999,
    color: HUD.text,
    cursor: 'pointer',
    fontSize: 13, fontWeight: 700,
    fontFamily: HUD.font,
    whiteSpace: 'nowrap',
    boxShadow: '0 2px 8px rgba(0,0,0,0.4)',
    backdropFilter: 'blur(20px)',
    WebkitBackdropFilter: 'blur(20px)',
    display: 'inline-flex', alignItems: 'center',
    transition: 'all 200ms ease',
    letterSpacing: 0.2,
};

// === Тост «+N рейтинга за место в таблице рекордов» ===
//
// Показывается когда submitLeaderboard вернул rating_award для текущего
// пользователя (попадание в топ-3 впервые на этом проекте).
// Бэкенд PLACE_RATING = {1: 200, 2: 150, 3: 100} в Kubikon3D.py.
const RatingAwardToast = ({ place, amount, onClose }) => {
    const meta = {
        1: { medal: '🥇', label: '1 МЕСТО', accent: '#ffd84a', glow: 'rgba(255, 216, 74, 0.55)' },
        2: { medal: '🥈', label: '2 МЕСТО', accent: '#cfd8dc', glow: 'rgba(207, 216, 220, 0.55)' },
        3: { medal: '🥉', label: '3 МЕСТО', accent: '#d29066', glow: 'rgba(210, 144, 102, 0.55)' },
    }[place] || { medal: '🏅', label: 'ТОП-3', accent: '#ffd84a', glow: 'rgba(255, 216, 74, 0.55)' };

    return (
        <div
            onClick={onClose}
            style={{
                position: 'absolute',
                top: 80,
                left: '50%',
                transform: 'translateX(-50%)',
                zIndex: 60,
                pointerEvents: 'auto',
                cursor: 'pointer',
                background: 'linear-gradient(135deg, rgba(15,22,40,0.96) 0%, rgba(28,40,72,0.96) 100%)',
                border: `2px solid ${meta.accent}`,
                borderRadius: 12,
                padding: '14px 22px',
                display: 'flex', alignItems: 'center', gap: 14,
                boxShadow: `0 12px 40px ${meta.glow}, 0 0 0 1px rgba(255,255,255,0.05) inset`,
                animation: 'hudFadeIn 0.45s ease-out',
                fontFamily: HUD.font,
                color: '#fff',
                minWidth: 280,
                maxWidth: 'calc(100vw - 40px)',
            }}
            title="Кликни чтобы закрыть"
        >
            <div style={{
                fontSize: 42, lineHeight: 1, flexShrink: 0,
                filter: `drop-shadow(0 0 8px ${meta.glow})`,
            }}>
                {meta.medal}
            </div>
            <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{
                    fontSize: 11, fontWeight: 800,
                    color: meta.accent, letterSpacing: 1.5,
                    textTransform: 'uppercase',
                }}>
                    {meta.label} В ТАБЛИЦЕ РЕКОРДОВ
                </div>
                <div style={{
                    fontSize: 22, fontWeight: 900,
                    marginTop: 3,
                    background: `linear-gradient(90deg, ${meta.accent}, #fff)`,
                    WebkitBackgroundClip: 'text',
                    WebkitTextFillColor: 'transparent',
                    backgroundClip: 'text',
                }}>
                    +{amount} рейтинга
                </div>
                <div style={{
                    fontSize: 11, opacity: 0.7, marginTop: 2,
                }}>
                    зачислено в твой профиль
                </div>
            </div>
        </div>
    );
};


const CenteredCard = ({ icon, title, text, onBack }) => (
    <div style={{
        position: 'fixed', inset: 0,
        background:
            'radial-gradient(ellipse at center, rgba(51,87,255,0.18) 0%, #070a14 60%)',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        flexDirection: 'column', gap: 18,
        color: HUD.text,
        fontFamily: HUD.font,
        padding: 24,
    }}>
        <style>{HUD_KEYFRAMES}</style>
        <div style={{
            fontSize: 76,
            animation: 'hudFloat 3.6s ease-in-out infinite',
            filter: 'drop-shadow(0 8px 24px rgba(0,0,0,0.4))',
        }}>{icon}</div>
        <div style={{
            fontSize: 26, fontWeight: 800, letterSpacing: -0.3,
        }}>{title}</div>
        <div style={{
            fontSize: 14, color: HUD.textMuted, maxWidth: 440,
            textAlign: 'center', lineHeight: 1.55,
        }}>{text}</div>
        <button
            onClick={onBack}
            style={btnPrimary}
            onMouseEnter={e => {
                e.currentTarget.style.transform = 'translateY(-2px)';
                e.currentTarget.style.boxShadow = '0 12px 28px rgba(51,87,255,0.45)';
            }}
            onMouseLeave={e => {
                e.currentTarget.style.transform = 'translateY(0)';
                e.currentTarget.style.boxShadow = '0 6px 16px rgba(51,87,255,0.32)';
            }}
        >
            ← В ленту Рублокса
        </button>
    </div>
);

const REPORT_CATEGORIES = [
    { id: 'profanity', label: 'Мат / нецензурная лексика' },
    { id: 'inappropriate', label: 'Неподходящий контент / возраст' },
    { id: 'ad', label: 'Реклама / спам' },
    { id: 'rights', label: 'Нарушение авторских прав' },
    { id: 'broken', label: 'Игра сломана / не работает' },
    { id: 'other', label: 'Другое' },
];

const ReportModal = ({ onClose, onSubmit }) => {
    const [category, setCategory] = useState('inappropriate');
    const [text, setText] = useState('');
    const [submitting, setSubmitting] = useState(false);

    const [submitError, setSubmitError] = useState(null);

    const submit = async () => {
        setSubmitting(true);
        setSubmitError(null);
        try {
            await onSubmit({ category, text });
        } catch (e) {
            setSubmitError(e?.message || 'Неизвестная ошибка');
            setSubmitting(false);
        }
    };

    return (
        <div onClick={onClose} style={modalBackdrop}>
            <style>{HUD_KEYFRAMES}</style>
            <div onClick={(e) => e.stopPropagation()} style={{
                ...modalCard, maxWidth: 480,
            }}>
                {/* Градиентный заголовок */}
                <div style={{
                    background: HUD.gradientBrand,
                    padding: '20px 24px',
                    display: 'flex', alignItems: 'center', justifyContent: 'space-between',
                    gap: 12,
                }}>
                    <div style={{
                        display: 'inline-flex', alignItems: 'center', gap: 12,
                    }}>
                        <div style={{
                            width: 40, height: 40, borderRadius: 12,
                            background: 'rgba(255,255,255,0.18)',
                            display: 'flex', alignItems: 'center', justifyContent: 'center',
                            fontSize: 22,
                        }}>🚩</div>
                        <h2 style={{
                            margin: 0, fontSize: 18, fontWeight: 800, color: '#fff',
                            letterSpacing: -0.3,
                        }}>Пожаловаться на игру</h2>
                    </div>
                    <button onClick={onClose} style={iconCloseBtn}>×</button>
                </div>

                <div style={{ padding: 22 }}>
                    <div style={{ marginBottom: 14 }}>
                        <div style={fieldLabel}>Категория</div>
                        <select
                            value={category}
                            onChange={(e) => setCategory(e.target.value)}
                            style={fieldInput}
                        >
                            {REPORT_CATEGORIES.map(c =>
                                <option key={c.id} value={c.id} style={{ background: '#0f1326' }}>
                                    {c.label}
                                </option>
                            )}
                        </select>
                    </div>
                    <div style={{ marginBottom: 14 }}>
                        <div style={fieldLabel}>Подробности (необязательно)</div>
                        <textarea
                            value={text}
                            onChange={(e) => setText(e.target.value)}
                            placeholder="Опиши, что не так…"
                            rows={4}
                            style={{
                                ...fieldInput,
                                resize: 'vertical',
                                lineHeight: 1.5,
                            }}
                        />
                    </div>
                    {submitError && (
                        <div style={{
                            background: HUD.dangerBg,
                            border: `1px solid ${HUD.danger}`,
                            borderRadius: 10,
                            padding: '10px 14px',
                            marginBottom: 14,
                            color: HUD.danger, fontSize: 13, fontWeight: 600,
                        }}>
                            ⚠️ {submitError}
                        </div>
                    )}
                    <div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
                        <button onClick={onClose} style={btn} disabled={submitting}>
                            Отмена
                        </button>
                        <button
                            onClick={submit}
                            disabled={submitting}
                            style={btnDanger}
                        >
                            {submitting ? 'Отправка…' : '🚩 Отправить'}
                        </button>
                    </div>
                </div>
            </div>
        </div>
    );
};

const InfoModal = ({ icon, title, text, onClose }) => (
    <div onClick={onClose} style={modalBackdrop}>
        <style>{HUD_KEYFRAMES}</style>
        <div onClick={(e) => e.stopPropagation()} style={{
            ...modalCard, maxWidth: 420, textAlign: 'center', padding: '32px 28px',
        }}>
            <div style={{
                fontSize: 56, marginBottom: 14,
                animation: 'hudFloat 3s ease-in-out infinite',
            }}>{icon}</div>
            <div style={{
                fontSize: 20, fontWeight: 800, color: HUD.text, marginBottom: 10,
                letterSpacing: -0.3,
            }}>
                {title}
            </div>
            {text && (
                <div style={{
                    fontSize: 13, color: HUD.textMuted, marginBottom: 22,
                    wordBreak: 'break-all', lineHeight: 1.5,
                    background: HUD.bgInput,
                    border: `1px solid ${HUD.border}`,
                    borderRadius: 10,
                    padding: '10px 14px',
                }}>
                    {text}
                </div>
            )}
            <button onClick={onClose} style={{ ...btnPrimary, minWidth: 140 }}>
                Закрыть
            </button>
        </div>
    </div>
);

const NeedAuthModal = ({ action, onClose, onLogin }) => (
    <div onClick={onClose} style={modalBackdrop}>
        <style>{HUD_KEYFRAMES}</style>
        <div onClick={(e) => e.stopPropagation()} style={{
            ...modalCard, maxWidth: 420, textAlign: 'center', padding: '32px 28px',
        }}>
            <div style={{
                fontSize: 56, marginBottom: 14,
                animation: 'hudFloat 3s ease-in-out infinite',
            }}>🔒</div>
            <div style={{
                fontSize: 20, fontWeight: 800, color: HUD.text, marginBottom: 10,
                letterSpacing: -0.3,
            }}>
                Нужна авторизация
            </div>
            <div style={{
                fontSize: 14, color: HUD.textMuted, marginBottom: 22, lineHeight: 1.5,
            }}>
                Чтобы {action}, войдите в аккаунт Рублокса.
            </div>
            <div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}>
                <button onClick={onClose} style={btn}>Отмена</button>
                <button onClick={onLogin} style={btnPrimary}>
                    🚀 Войти
                </button>
            </div>
        </div>
    </div>
);

const modalBackdrop = {
    position: 'fixed', inset: 0, zIndex: 2100,
    background: 'rgba(7,10,20,0.78)',
    backdropFilter: 'blur(8px)',
    WebkitBackdropFilter: 'blur(8px)',
    display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
    fontFamily: HUD.font,
    animation: 'hudFadeIn 200ms ease',
};

const modalCard = {
    background: HUD.bgPanel,
    border: `1px solid ${HUD.border}`,
    borderRadius: 18,
    width: '100%',
    color: HUD.text,
    overflow: 'hidden',
    boxShadow: '0 24px 60px rgba(0,0,0,0.55), 0 0 0 1px rgba(51,87,255,0.08)',
    animation: 'hudFadeInScale 240ms cubic-bezier(0.34, 1.56, 0.64, 1)',
};

const iconCloseBtn = {
    width: 32, height: 32,
    border: '1px solid rgba(255,255,255,0.25)',
    background: 'rgba(255,255,255,0.10)',
    color: '#fff', borderRadius: 8,
    fontSize: 20, fontWeight: 700, lineHeight: 1,
    cursor: 'pointer', fontFamily: HUD.font,
    display: 'flex', alignItems: 'center', justifyContent: 'center',
    transition: 'all 150ms ease',
};

const fieldLabel = {
    fontSize: 11, color: HUD.textMuted, marginBottom: 6,
    textTransform: 'uppercase', letterSpacing: 0.8, fontWeight: 700,
};

const fieldInput = {
    width: '100%', boxSizing: 'border-box',
    background: HUD.bgInput,
    color: HUD.text,
    border: `1px solid ${HUD.border}`,
    borderRadius: 10,
    padding: '10px 12px',
    fontSize: 14, fontFamily: HUD.font,
    outline: 'none',
    transition: 'border-color 150ms ease',
};

/**
 * MobileAmmoBlock — компактный счётчик патронов в правом верхнем углу
 * на мобиле + кнопка R перезарядки. ammo = { magazine, magazineMax,
 * reserve, reloading, reloadProgress }.
 */
const MobileAmmoBlock = ({ ammo, onReload }) => {
    if (!ammo) return null;
    const reloading = ammo.reloading;
    const progress = Math.max(0, Math.min(1, ammo.reloadProgress || 0));
    return (
        <div data-mobile-hud="ammo" style={{
            position: 'absolute',
            top: 8, right: 56,    // оставляем 48px справа под fullscreen-кнопку
            display: 'flex', alignItems: 'center', gap: 8,
            padding: '6px 10px',
            background: 'rgba(15, 19, 38, 0.78)',
            border: '1px solid rgba(255, 255, 255, 0.12)',
            borderRadius: 12,
            backdropFilter: 'blur(20px)',
            WebkitBackdropFilter: 'blur(20px)',
            zIndex: 30,
            color: '#f1f5fb',
            fontFamily: '"Roboto Condensed", system-ui, sans-serif',
            pointerEvents: 'auto',
        }}>
            <div style={{ fontSize: 14, opacity: 0.7 }}>🔫</div>
            <div style={{
                fontSize: 18, fontWeight: 800, lineHeight: 1,
                letterSpacing: 0.3, minWidth: 44, textAlign: 'center',
            }}>
                {reloading
                    ? <span style={{ opacity: 0.7 }}>{Math.round(progress * 100)}%</span>
                    : <><span>{ammo.magazine}</span>
                        <span style={{ fontSize: 12, opacity: 0.55, fontWeight: 700 }}>
                            {' /' + ammo.magazineMax}
                        </span>
                    </>}
            </div>
            <button
                onClick={onReload}
                onTouchStart={(e) => { e.stopPropagation(); onReload(); }}
                style={{
                    width: 30, height: 30,
                    borderRadius: 8,
                    background: reloading
                        ? 'rgba(255, 200, 87, 0.30)'
                        : 'rgba(51, 87, 255, 0.30)',
                    border: '1px solid rgba(255, 255, 255, 0.20)',
                    color: '#fff',
                    fontSize: 13, fontWeight: 800,
                    cursor: 'pointer',
                    display: 'flex', alignItems: 'center', justifyContent: 'center',
                    padding: 0, fontFamily: 'inherit',
                    WebkitTapHighlightColor: 'transparent',
                }}
                title="Перезарядка"
            >
                R
            </button>
        </div>
    );
};

/**
 * ChatToggleButton — круглая кнопка-иконка чата (Roblox-style).
 *
 * Дизайн (см. скрин exe-плеера 2026-05-27):
 *   • Синий градиентный круг 44×44 с обводкой
 *   • Внутри — белая иконка «облако чата»
 *   • Красная точка-индикатор непрочитанных в правом-верхнем
 *   • Бейдж «T» в правом-нижнем углу — горячая клавиша
 *   • Hover: лёгкий glow + scale
 *   • Active: зелёный фон (значит чат открыт)
 */
const ChatToggleButton = ({ active, onClick }) => {
    const [hover, setHover] = React.useState(false);
    const bg = active
        ? 'linear-gradient(135deg, #22d97a 0%, #0f9d56 100%)'
        : 'linear-gradient(135deg, #3357ff 0%, #1f3acc 100%)';
    const glow = active
        ? '0 8px 20px rgba(34, 217, 122, 0.45)'
        : (hover
            ? '0 8px 22px rgba(51, 87, 255, 0.55)'
            : '0 4px 14px rgba(51, 87, 255, 0.35)');
    return (
        <button
            onClick={onClick}
            onMouseEnter={() => setHover(true)}
            onMouseLeave={() => setHover(false)}
            title="Чат (T)"
            style={{
                position: 'relative',
                width: 60, height: 60,
                borderRadius: '50%',
                background: bg,
                border: '2px solid rgba(255, 255, 255, 0.12)',
                cursor: 'pointer',
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                boxShadow: glow,
                transform: hover ? 'scale(1.05)' : 'scale(1)',
                transition: 'transform 150ms ease, box-shadow 150ms ease, background 200ms ease',
                padding: 0,
            }}
        >
            {/* Иконка чата (облако) */}
            <svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="#ffffff"
                 strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
                <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
            </svg>
            {/* Красная точка-уведомление непрочитанных (статичная — пока без счётчика) */}
            <span style={{
                position: 'absolute',
                top: 4, right: 6,
                width: 12, height: 12,
                borderRadius: '50%',
                background: '#ff3a4e',
                border: '2px solid rgba(15, 19, 28, 0.95)',
                boxShadow: '0 0 6px rgba(255, 58, 78, 0.6)',
            }} />
            {/* Бейдж "T" — горячая клавиша */}
            <span style={{
                position: 'absolute',
                bottom: -2, right: -2,
                width: 22, height: 22,
                borderRadius: '50%',
                background: 'rgba(15, 19, 28, 0.95)',
                border: '2px solid rgba(255, 255, 255, 0.18)',
                color: '#ffffff',
                fontSize: 11,
                fontWeight: 900,
                fontFamily: 'Consolas, "Roboto Mono", monospace',
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                letterSpacing: 0,
                boxShadow: '0 2px 6px rgba(0, 0, 0, 0.4)',
            }}>T</span>
        </button>
    );
};

/**
 * TopDropdown — выпадающее меню из верхнего бара (Roblox-style).
 * Не модалка, не паузит игру. Закрывается по клику вне или по любому
 * выбранному пункту.
 */
const TopDropdown = ({
    meta, vote, likes, dislikes,
    onClose, onLike, onDislike,
    onShare, onReport, onBugReport, onRespawn, onExit,
    isTouch = false,
}) => {
    const totalVotes = (likes || 0) + (dislikes || 0);
    const ratingPct = totalVotes > 0 ? Math.round(100 * likes / totalVotes) : null;

    // Закрытие по клику вне (event capture на документе)
    useEffect(() => {
        const onDocClick = (e) => {
            if (!e.target.closest?.('[data-kubikon-top-dropdown]')
                && !e.target.closest?.('[data-kubikon-top-menu-btn]')) {
                onClose();
            }
        };
        // Регистрируем на следующий тик чтобы клик-открытие не закрыл сразу
        const t = setTimeout(() => {
            document.addEventListener('mousedown', onDocClick);
        }, 0);
        return () => {
            clearTimeout(t);
            document.removeEventListener('mousedown', onDocClick);
        };
    }, [onClose]);

    return (
        <div
            data-kubikon-top-dropdown
            style={{
                position: 'absolute',
                top: 'calc(100% + 8px)',
                left: 0,
                width: 280,
                background: HUD.bgPanel,
                backdropFilter: 'blur(24px)',
                WebkitBackdropFilter: 'blur(24px)',
                border: `1px solid ${HUD.border}`,
                borderRadius: 16,
                padding: 8,
                zIndex: 1200,
                boxShadow: '0 16px 40px rgba(0,0,0,0.55), 0 0 0 1px rgba(51,87,255,0.10)',
                fontFamily: HUD.font,
                color: HUD.text,
                animation: 'hudFadeInScale 200ms cubic-bezier(0.34, 1.56, 0.64, 1)',
                transformOrigin: 'top left',
                maxHeight: 'calc(100vh - 120px)',
                overflowY: 'auto',
            }}
        >
            {/* Заголовок: thumbnail + имя игры + рейтинг */}
            {meta && (
                <div style={{
                    padding: '4px 4px 12px',
                    borderBottom: `1px solid ${HUD.border}`,
                    marginBottom: 6,
                    display: 'flex', gap: 10, alignItems: 'center',
                }}>
                    <div style={{
                        width: 48, height: 48, borderRadius: 10,
                        flexShrink: 0,
                        background: meta.thumbnail
                            ? `url(${meta.thumbnail}) center/cover`
                            : HUD.gradientBrand,
                        display: 'flex', alignItems: 'center', justifyContent: 'center',
                        fontSize: 22,
                        boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
                    }}>
                        {!meta.thumbnail && '🎮'}
                    </div>
                    <div style={{ minWidth: 0, flex: 1 }}>
                        <div style={{
                            fontSize: 14, fontWeight: 800, color: HUD.text,
                            whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
                            letterSpacing: -0.2,
                        }}>{meta.title}</div>
                        {ratingPct != null ? (
                            <div style={{
                                fontSize: 11, color: HUD.textMuted, marginTop: 3,
                                display: 'inline-flex', alignItems: 'center', gap: 8,
                            }}>
                                <span style={{
                                    color: ratingPct >= 70 ? HUD.success
                                        : ratingPct >= 40 ? '#ffc857' : HUD.danger,
                                    fontWeight: 800,
                                }}>⭐ {ratingPct}%</span>
                                <span style={{ color: HUD.textDim }}>
                                    {(likes || 0) + (dislikes || 0)} {(likes || 0) + (dislikes || 0) === 1 ? 'голос' : 'голосов'}
                                </span>
                            </div>
                        ) : (
                            <div style={{ fontSize: 11, color: HUD.textDim, marginTop: 3 }}>
                                Ещё нет оценок
                            </div>
                        )}
                    </div>
                </div>
            )}

            {/* Голосование */}
            <div style={{ display: 'flex', gap: 6, padding: '4px 4px 6px' }}>
                <button onClick={onLike} style={voteBtnStyle(vote === 'like', 'like')}>
                    <span style={{ fontSize: 16 }}>👍</span>
                    <span>{likes || 0}</span>
                </button>
                <button onClick={onDislike} style={voteBtnStyle(vote === 'dislike', 'dislike')}>
                    <span style={{ fontSize: 16 }}>👎</span>
                    <span>{dislikes || 0}</span>
                </button>
            </div>

            <DropdownDivider />

            {/* Действия */}
            <DropdownItem icon="🔄" label="Переродиться" onClick={onRespawn} accent />
            <DropdownItem icon="🔗" label="Поделиться"  onClick={onShare} />
            <DropdownItem icon="🚩" label="Пожаловаться" onClick={onReport} />
            <DropdownItem icon="🐛" label="Сообщить об ошибке" onClick={onBugReport} />

            {/* Горячие клавиши — только на десктопе. На мобиле/планшете
                клавиатуры нет, эта секция бесполезна и занимает место. */}
            {!isTouch && (
                <>
                    <DropdownDivider />
                    <div style={{
                        padding: '8px 12px 6px',
                        fontSize: 10, color: HUD.textDim,
                        textTransform: 'uppercase', letterSpacing: 1.2, fontWeight: 800,
                    }}>
                        ⌨️ Горячие клавиши
                    </div>
                    <div style={{
                        padding: '0 12px 8px',
                        display: 'flex', flexDirection: 'column', gap: 6,
                        fontSize: 11.5, color: HUD.textMuted,
                    }}>
                        <HotkeyRow keys={['W', 'A', 'S', 'D']} label="Движение" />
                        <HotkeyRow keys={['Space']} label="Прыжок" />
                        <HotkeyRow keys={['Shift']} label="Бег" />
                        <HotkeyRow keys={['Мышь']} label="Камера" />
                        <HotkeyRow keys={['ЛКМ']} label="Атака / стрельба" />
                        <HotkeyRow keys={['R']} label="Перезарядка" />
                        <HotkeyRow keys={['1', '…', '5']} label="Слот инвентаря" />
                        <HotkeyRow keys={['Q']} label="Бросить предмет" />
                        <HotkeyRow keys={['C']} label="1-е / 3-е лицо" />
                        <HotkeyRow keys={['Tab']} label="Курсор для GUI" />
                        <HotkeyRow keys={['Esc']} label="Меню / отдать мышь" />
                    </div>
                </>
            )}

            <DropdownDivider />
            <DropdownItem icon="🚪" label="Выйти из игры" onClick={onExit} danger />
        </div>
    );
};

const DropdownDivider = () => (
    <div style={{
        height: 1,
        background: `linear-gradient(90deg, transparent 0%, ${HUD.border} 50%, transparent 100%)`,
        margin: '6px 4px',
    }} />
);

const HotkeyRow = ({ keys, label }) => (
    <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
        <span style={{ flex: 1, color: HUD.textMuted, fontWeight: 600 }}>{label}</span>
        <span style={{ display: 'flex', gap: 3 }}>
            {keys.map((k, i) => (
                <kbd key={i} style={{
                    background: 'rgba(255,255,255,0.06)',
                    border: `1px solid ${HUD.border}`,
                    borderRadius: 4,
                    padding: '2px 7px',
                    fontSize: 10, fontFamily: HUD.font, fontWeight: 700,
                    color: HUD.text,
                    minWidth: 18, textAlign: 'center',
                    boxShadow: '0 1px 0 rgba(255,255,255,0.04) inset, 0 1px 2px rgba(0,0,0,0.4)',
                }}>{k}</kbd>
            ))}
        </span>
    </div>
);

const DropdownItem = ({ icon, label, onClick, danger, accent }) => {
    const [hovered, setHovered] = useState(false);
    const hoverBg = danger
        ? HUD.dangerBg
        : accent
            ? HUD.accentBg
            : 'rgba(255,255,255,0.06)';
    const color = danger ? HUD.danger
        : accent ? '#fff'
        : HUD.text;
    return (
        <button
            onClick={onClick}
            onMouseEnter={() => setHovered(true)}
            onMouseLeave={() => setHovered(false)}
            style={{
                width: '100%', padding: '10px 12px',
                background: hovered ? hoverBg : 'transparent',
                border: 'none',
                borderRadius: 10,
                color,
                fontSize: 13, fontWeight: 700,
                cursor: 'pointer', fontFamily: HUD.font,
                textAlign: 'left',
                display: 'flex', alignItems: 'center', gap: 12,
                transition: 'all 150ms ease',
                letterSpacing: 0.1,
            }}
        >
            <span style={{
                fontSize: 16, width: 24, textAlign: 'center',
                filter: hovered ? 'none' : 'grayscale(0.15)',
            }}>{icon}</span>
            <span style={{ flex: 1 }}>{label}</span>
            {hovered && (
                <span style={{ fontSize: 12, color: HUD.textDim }}>→</span>
            )}
        </button>
    );
};

const voteBtnStyle = (active, kind) => {
    const activeBg = kind === 'like'
        ? 'linear-gradient(135deg, #22d97a 0%, #0f9d56 100%)'
        : 'linear-gradient(135deg, #ff6f7a 0%, #c0303f 100%)';
    return {
        flex: 1, padding: '10px 8px',
        background: active ? activeBg : 'rgba(255,255,255,0.04)',
        border: `1px solid ${active ? 'transparent' : HUD.border}`,
        borderRadius: 10,
        fontSize: 14, fontWeight: 800,
        color: active ? '#fff' : HUD.text,
        cursor: 'pointer', fontFamily: HUD.font,
        display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
        transition: 'all 200ms ease',
        boxShadow: active
            ? (kind === 'like'
                ? '0 6px 16px rgba(34,217,122,0.40)'
                : '0 6px 16px rgba(255,111,122,0.40)')
            : 'none',
    };
};

export default KubikonPlayer;
