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 { 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'; // Плеер живёт на 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'; if (gameId) { // Передаём gameId через ?game= — главный сайт прочитает и снова // откроет карточку игры (юзер возвращается на ту же страницу). const sep = RUBLOX_HOME.includes('?') ? '&' : '?'; window.location.assign(`${RUBLOX_HOME}${sep}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:'), это будет в следующей * подфазе (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); /** Выбранный R15-скин текущего игрока (из rublox_equipped_skin). * Грузится при старте, уходит в мультиплеер как modelType. */ const skinFolderRef = useRef('skin_bacon-hair'); const [meta, setMeta] = useState(null); // { title, description, user_id, ... } const [forbidden, setForbidden] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); // Раньше была стартовая заглушка «тапни чтобы начать» — убрали по // фидбэку, она бесила. Теперь fullscreen опционально через кнопку // в углу. Этот state остался для совместимости с handleMobileStart. const [mobileStartTapped, setMobileStartTapped] = useState(true); 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-комбинаций которые ломают игру (Ctrl+W = закрыть вкладку, // Ctrl+R = reload, Ctrl+T/N — мешают). Большинство браузеров блокирует // отмену системных шорткатов, но beforeunload даёт пользователю шанс // подтвердить выход. Также превентим preventDefault на keydown для // случаев когда фокус НЕ на window-уровне (Chrome иногда позволяет). useEffect(() => { const onKey = (e) => { if (!e.ctrlKey && !e.metaKey) return; // Список «опасных» в игре сочетаний — превентим const dangerousCodes = ['KeyW', 'KeyR', 'KeyT', 'KeyN']; if (dangerousCodes.includes(e.code)) { e.preventDefault(); e.stopPropagation(); } }; const onBeforeUnload = (e) => { // Если юзер сам нажал «Покинуть» в меню — пропускаем без // подтверждения. Флаг ставит exitPlayer(). if (window.__rubloxExplicitExit) return undefined; // Случайное закрытие вкладки (Ctrl+W, X-кнопка) — показываем // подтверждение чтобы не потерять прогресс игры. e.preventDefault(); e.returnValue = ''; return ''; }; window.addEventListener('keydown', onKey, { capture: true }); window.addEventListener('beforeunload', onBeforeUnload); return () => { window.removeEventListener('keydown', onKey, { capture: true }); window.removeEventListener('beforeunload', onBeforeUnload); }; }, []); /** * 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); if (data.project_data) { const parsed = JSON.parse(data.project_data); initialStateRef.current = parsed; await scene.loadFromState(parsed); } // Ждём пока 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, // чтобы соперники видели наш реальный скин. let mySkin = 'skin_bacon-hair'; if (userId) { try { const skinRes = await Kubikon3DApi.getEquippedSkin(userId); const sf = skinRes?.data?.skin_folder; if (sf && typeof sf === 'string') mySkin = sf; } catch (e) { // Сеть/ошибка — играем с дефолтным скином, не блокируем. console.warn('[KubikonPlayer] equipped-skin load failed', e); } } skinFolderRef.current = mySkin; try { scene.setPlayerModelType?.(mySkin); } catch (e) {} 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 — открыть/закрыть чат. Игнорируем когда: // • уже введён текст в /