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 '../editor/engine/BabylonScene'; import { attachConsoleHook, devlogReset } from '../editor/engine/devlog'; import { MultiplayerSync } from '../editor/engine/MultiplayerSync'; import { REALTIME_WS } from '../api/API'; import GameHud from '../editor/GameHud'; import GuiOverlay from '../editor/GuiOverlay'; import KubikonLeaderboard, { formatTimeMs } from '../components/KubikonLeaderboard/KubikonLeaderboard'; import KubikonPerfOverlay from '../components/KubikonPerfOverlay/KubikonPerfOverlay'; import Hotbar from '../editor/Hotbar'; import PlayerHud from '../editor/PlayerHud'; import ModalOverlay from '../editor/ModalOverlay'; import SkinShopOverlay from '../editor/SkinShopOverlay'; import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton'; import KubikonChatPanel from './KubikonChatPanel'; import { useAuth } from '../auth/AuthContext.jsx'; import RublocsLogo from '../components/RublocsLogo/RublocsLogo'; import useDeviceType from '../hooks/useDeviceType'; import KubikonMobileControls from './KubikonMobileControls'; /** * Палитра тёмного игрового 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: /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 } = 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); }; }, []); // Только зарегистрированные. Если гость / без токена — на логин. useEffect(() => { if (authLoading) return; if (!isAuthenticated) { navigate('/login', { replace: true }); } }, [isAuthenticated, authLoading, navigate]); 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); // Задача 03: отдельный контроль хотбара/HP — для игр без инвентаря/жизней. const [hotbarVisible, setHotbarVisible] = useState(true); const [hpVisible, setHpVisible] = 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) => { // Показываем подтверждение выхода — браузер не даст случайно // закрыть вкладку Ctrl+W во время игры. 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(''); const userId = (() => { try { const t = localStorage.getItem('Authorization'); if (!t) return null; const p = jwtDecode(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)); // Задача 03: отдельные подписки scene.setOnHotbarVisibilityChange?.((v) => setHotbarVisible(v)); scene.setOnHpVisibilityChange?.((v) => setHpVisible(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 обрабатывается через pointerlockchange-перехват в плеере // (см. отдельный useEffect ниже). Сюда мы попадаем только если // exitPlayMode вызвался по другой причине — тогда просто открываем // меню, чтобы пользователь мог выйти/вернуться, и пересоздаём Play // в UI-cursor режиме. if (!playing) { setTimeout(() => { const s = sceneRef.current; if (!s) return; s.enterPlayMode?.(); s.player?.setUiCursorMode?.(true); }, 30); setChatOpen(false); setTopMenuOpen(true); } }); // Загружаем проект (async () => { try { const res = await Kubikon3DApi.getProjectForPlay(projectId, userId); const 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); // Засчитываем плей Kubikon3DApi.incrementPlay(projectId).catch(() => {}); // Запускаем игру сразу setTimeout(() => { scene.enterPlayMode?.(); // Если игра мультиплеерная — подключаемся к realtime-серверу. // Делаем после enterPlayMode, чтобы scene.player уже был создан. if (data.multiplayer) { 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; // Lock потерян, мы НЕ в UI-cursor mode → пользователь нажал ESC if (!locked && s.player && !s.player._uiCursorMode) { // Синхронно ставим флаг — listener PlayerController сработает // следующим и увидит true, не вызовет _onExitRequest. s.player._uiCursorMode = true; // Открываем меню в следующий тик (state-update React) setChatOpen(false); setTopMenuOpen(true); } }; // capture-фаза, чтобы успеть раньше PlayerController document.addEventListener('pointerlockchange', onLockChange, true); return () => document.removeEventListener('pointerlockchange', onLockChange, true); }, []); // Повторный ESC (когда меню уже открыто) — закрыть меню и вернуть // мышь в игру. useEffect(() => { if (!topMenuOpen) return; const onEsc = (e) => { if (e.key !== 'Escape') return; const s = sceneRef.current; if (!s || !s._isPlaying) return; setTopMenuOpen(false); s.player?.setUiCursorMode?.(false); }; window.addEventListener('keydown', onEsc, true); return () => window.removeEventListener('keydown', onEsc, true); }, [topMenuOpen]); /** Переродиться — телепорт на 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_bacon-hair'; const room = await client.joinOrCreate('battle', { projectId: projectMeta?.id || projectId, token: tokenRaw, modelType, }); roomRef.current = room; 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); // === Локальная смерть → сервер === // Когда наш 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(() => { if (sync._countTimer) clearInterval(sync._countTimer); setMpStatus('idle'); setMpRemotePlayers(0); setMpRoomCode(''); }); 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; if (req) { try { await req.call(root); } catch (e) { /* отменено */ } } setMobileStartTapped(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 navigate('/')} />; } if (error) { return navigate('/')} />; } return (
{/* Loading-оверлей */} {loading && (
Загрузка игры…
Рублокс • 3D
)} {/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */} {!loading && ( <> {/* Задача 04: модал-overlay (затемнение + spotlight mask). */} {/* Задача 07: встроенный магазин скинов (B / API). */} {/* HUD: на мобиле уменьшаем и сдвигаем компактно. */} {isTouch ? ( <> {/* HP-бар: вверху слева, scale 0.75 */}
{/* Mini ammo block: правый верх. Большие цифры, кнопка R для перезарядки внизу. */} {ammo && ( sceneRef.current?.weapons?.reload?.()} /> )} {/* Hotbar — только если в инвентаре есть хоть один предмет. Пустой инвентарь не показываем. */} {stdHudVisible && hotbarVisible && (inventoryState.slots || []).some(s => s) && ( sceneRef.current?.setActiveInventorySlot?.(i)} /> )} ) : ( <> {stdHudVisible && hotbarVisible && (inventoryState.slots || []).some(s => s) && ( sceneRef.current?.setActiveInventorySlot?.(i)} /> )} )} {/* Performance overlay — клавиша F или клик по бейджу. Также фоном шлёт метрики на бэк раз в 5 сек. */} sceneRef.current} projectId={projectId} userId={userId} /> {/* Тост-награда «+200 рейтинга за 1 место в таблице рекордов». Появляется когда submitLeaderboard вернул rating_award для текущего юзера (попадание в топ-3 впервые). Авто-закрытие через 6 сек или клик. */} {ratingToast && ( setRatingToast(null)} /> )} {/* Лидерборд (правый верхний угол) — Tab скрыть/показать. Сама панель НЕ перехватывает клики/мышь (pointer-events: none), чтобы не разблокировать курсор в Play-режиме. Только кнопки внутри (✕ закрыть, 🔄 заново) кликабельны. */} {leaderboardVisible && leaderboardEnabled && (
setLeaderboardVisible(false)} compact={true} refreshKey={leaderboardRefreshKey} clickable={false} onLoaded={(records) => { // Если в проекте уже есть рекорды — игра-ранер, // включаем виджет даже если timer ещё не стартовал. if (records && records.length > 0) { setLeaderboardEnabled(true); } }} />
{/* Кнопка «Заново» — для попытки улучшить рекорд */}
)} {/* Кнопки «Топ» и «Заново» когда лидерборд скрыт. Размещены ниже компактного таймера в правом верхнем углу. Кнопка «Топ» показывается только если игра поддерживает таймер. */} {!leaderboardVisible && leaderboardEnabled && (
)} {/* Компактный таймер справа сверху над кнопками — когда таблица лидеров скрыта (иначе видно в самой таблице). */} {timerRunning && !leaderboardVisible && (
⏱ {formatTimeMs(timerMs)}
)} 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 }); }} /> {/* Мобильное управление — на любых тач-устройствах, и в портрете и в ландшафте (ранее был блок portrait, убрали по фидбэку — играть можно как угодно). */} {isTouch && ( sceneRef.current} /> )} )} {/* Кнопка «полный экран» — маленькая, в правом верхнем углу, только на тач-устройствах. Браузеры требуют user gesture для requestFullscreen() — поэтому без кнопки никак. Кнопка автоматически скрывается после входа в fullscreen. */} {isTouch && !loading && !document.fullscreenElement && ( )}
{/* === Floating top bar (слева вверху, под HP-баром) === */} {!loading && (
{/* Бургер-меню — выпадашка с действиями */}
{topMenuOpen && ( setTopMenuOpen(false)} onLike={() => { handleVote('like'); }} onDislike={() => { handleVote('dislike'); }} onShare={() => { setTopMenuOpen(false); handleShare(); }} onReport={() => { setTopMenuOpen(false); handleReportClick(); }} onBugReport={() => { setTopMenuOpen(false); document.querySelector('[data-kubikon-bug-btn]')?.click(); }} onRespawn={() => { setTopMenuOpen(false); respawnPlayer(); }} onExit={() => navigate('/')} /> )}
{/* Toggle чата */} {/* Мультиплеер-индикатор (только если игра multiplayer=true) */} {meta?.multiplayer && (
🌐 {mpStatus === 'connected' && ( <> {mpRemotePlayers + 1} {mpRoomCode && ( {mpRoomCode} )} )} {mpStatus === 'connecting' && '…'} {mpStatus === 'failed' && 'offline'} {mpStatus === 'idle' && '—'}
)}
)} {/* === Компактный чат слева-сверху (под top bar) === Держим компонент СМОНТИРОВАННЫМ всегда (после loading) и скрываем через CSS — иначе при unmount теряется история сообщений и WS-подписка на 'chat' слетает с realtime-room. Когда chatOpen=false — visibility:hidden, pointer-events:none. */} {!loading && (
setChatOpen(false)} onRequestAuth={(action) => { setChatOpen(false); setNeedAuthModal(action); }} /* Если игра мультиплеерная и мы подключены — чат идёт через realtime-room (видят только игроки в этой комнате). Иначе fallback к storys-чату игры. */ realtimeRoom={mpStatus === 'connected' ? roomRef.current : null} />
)} {reportOpen && ( 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 && ( setInfoModal(null)} /> )} {needAuthModal && ( setNeedAuthModal(null)} onLogin={() => navigate('/auth')} /> )} {/* Плавающая кнопка баг-репорта (открывается из ESC-меню). Сама кнопка скрыта — данные нужны только её модалке. */} {!loading && (
); }; 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 (
{meta.medal}
{meta.label} В ТАБЛИЦЕ РЕКОРДОВ
+{amount} рейтинга
зачислено в твой профиль
); }; const CenteredCard = ({ icon, title, text, onBack }) => (
{icon}
{title}
{text}
); 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 (
e.stopPropagation()} style={{ ...modalCard, maxWidth: 480, }}> {/* Градиентный заголовок */}
🚩

Пожаловаться на игру

Категория
Подробности (необязательно)