From 8b60fd541f02cc2e312760a21eaf976973232862 Mon Sep 17 00:00:00 2001 From: Gregory519 Date: Wed, 3 Jun 2026 12:05:03 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BB=D0=B5=D0=B9=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/KubikonPlayer/KubikonPlayer.jsx | 3 +- src/KubikonPlayer/старый кубиконплеер.jsx | 2428 +++++++++++++++++++++ 2 files changed, 2430 insertions(+), 1 deletion(-) create mode 100644 src/KubikonPlayer/старый кубиконплеер.jsx diff --git a/src/KubikonPlayer/KubikonPlayer.jsx b/src/KubikonPlayer/KubikonPlayer.jsx index 9d96bf8..0f165b7 100644 --- a/src/KubikonPlayer/KubikonPlayer.jsx +++ b/src/KubikonPlayer/KubikonPlayer.jsx @@ -22,7 +22,8 @@ import { useAuth } from '../auth/PlayerAuth'; import RublocsLogo from '../components/RublocsLogo/RublocsLogo'; import useDeviceType from '../hooks/useDeviceType'; import KubikonMobileControls from './KubikonMobileControls'; -// загрузка плейсов начинается на строке 1163 +// тест +// загрузка плейсов начинается на строке 1181 // Плеер живёт на player.rublox.pro — он не знает SPA-роутов Майнкрафтии // (/kubikon, /login, /auth). Поэтому вместо navigate(...) делаем // явный window.location.assign на внешний домен. diff --git a/src/KubikonPlayer/старый кубиконплеер.jsx b/src/KubikonPlayer/старый кубиконплеер.jsx new file mode 100644 index 0000000..9d62987 --- /dev/null +++ b/src/KubikonPlayer/старый кубиконплеер.jsx @@ -0,0 +1,2428 @@ +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); + // Засчитываем плей + Kubikon3DApi.incrementPlay(projectId).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 — открыть/закрыть чат. Игнорируем когда: + // • уже введён текст в /