All checks were successful
Фича-парность со студией (задача 14):
- VehicleManager + VehicleHud (спидометр-стрелка) идентичны студийным.
- game.scene.spawn('vehicle:car'), onVehicleEnter/Exit, hold-F/E, камера follow/V.
- Звук мотора (рокот+LFO), оседание машины на землю (_settle+повторы),
скрытие водителя, респавн при падении, shadow-caster фильтр (фикс FPS).
- incrementPlay(id, userId) — передаём user_id для cooldown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2431 lines
120 KiB
JavaScript
2431 lines
120 KiB
JavaScript
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=<id> — главный сайт прочитает и снова
|
||
// откроет карточку игры (юзер возвращается на ту же страницу).
|
||
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:<id>'), это будет в следующей
|
||
* подфазе (3.6 завершена частично — игрок-юзер пока заходит с дефолтным
|
||
* скином, а сменить body через rublox_outfit shirt/pants — отдельная
|
||
* задача требует rework выбора body-скина игроком).
|
||
*/
|
||
async function _applyOutfitAccessories(scene, userId) {
|
||
// Ждём до 5 сек пока PlayerController с AccessoryManager готов
|
||
let player = null;
|
||
const deadline = Date.now() + 5000;
|
||
while (Date.now() < deadline) {
|
||
const p = scene.player || scene._player;
|
||
if (p && typeof p.equipAccessory === 'function'
|
||
&& typeof p.getAccessoryManager === 'function'
|
||
&& p.getAccessoryManager() != null) {
|
||
player = p;
|
||
break;
|
||
}
|
||
await new Promise((r) => setTimeout(r, 100));
|
||
}
|
||
if (!player) {
|
||
// eslint-disable-next-line no-console
|
||
console.warn('[outfit] player not ready, skipping accessories');
|
||
return;
|
||
}
|
||
let outfit;
|
||
try {
|
||
const r = await Kubikon3DApi.getRubloxOutfit(userId);
|
||
outfit = r?.data;
|
||
} catch (e) {
|
||
// eslint-disable-next-line no-console
|
||
console.warn('[outfit] fetch failed', e);
|
||
return;
|
||
}
|
||
const slots = outfit?.slots || {};
|
||
const items = outfit?.items || {};
|
||
const tasks = [];
|
||
for (const [slotKey, itemId] of Object.entries(slots)) {
|
||
if (!itemId) continue;
|
||
const item = items[itemId];
|
||
if (!item) continue;
|
||
if (item.is_body_skin) continue; // подфаза 3.6 хвост
|
||
if (item.status && item.status !== 'published') continue;
|
||
tasks.push(player.equipAccessory(item).catch((e) => {
|
||
// eslint-disable-next-line no-console
|
||
console.warn('[outfit] equipAccessory failed for slot',
|
||
slotKey, 'item', itemId, e);
|
||
}));
|
||
}
|
||
await Promise.all(tasks);
|
||
}
|
||
|
||
/**
|
||
* Палитра тёмного игрового HUD Рублокса.
|
||
* Подобрана так, чтобы UI хорошо читался поверх любой 3D-сцены, но
|
||
* визуально совпадал с синим акцентом Рублокса (#3357ff).
|
||
*/
|
||
const HUD = {
|
||
bg: 'rgba(10, 14, 26, 0.92)',
|
||
bgGlass: 'rgba(15, 19, 38, 0.78)',
|
||
bgPanel: 'rgba(20, 24, 45, 0.94)',
|
||
bgInput: 'rgba(15, 19, 38, 0.85)',
|
||
border: 'rgba(255, 255, 255, 0.10)',
|
||
borderHover: 'rgba(255, 255, 255, 0.18)',
|
||
borderAcc: 'rgba(51, 87, 255, 0.55)',
|
||
text: '#f1f5fb',
|
||
textMuted: 'rgba(241, 245, 251, 0.62)',
|
||
textDim: 'rgba(241, 245, 251, 0.42)',
|
||
accent: '#3357ff',
|
||
accentBg: 'rgba(51, 87, 255, 0.20)',
|
||
accentSoft: 'rgba(51, 87, 255, 0.12)',
|
||
success: '#22d97a',
|
||
successBg: 'rgba(34, 217, 122, 0.20)',
|
||
danger: '#ff6f7a',
|
||
dangerBg: 'rgba(255, 111, 122, 0.18)',
|
||
gradientBrand: 'linear-gradient(135deg, #3357ff 0%, #1e2da5 100%)',
|
||
gradientHot: 'linear-gradient(135deg, #ec4899 0%, #ef4444 50%, #f59e0b 100%)',
|
||
font: '"Roboto Condensed", system-ui, -apple-system, sans-serif',
|
||
};
|
||
|
||
const HUD_KEYFRAMES = `
|
||
@keyframes hudFadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
|
||
@keyframes hudFadeInScale { from { opacity: 0; transform: scale(0.94); } to { opacity: 1; transform: scale(1); } }
|
||
@keyframes hudPulseRing {
|
||
0% { box-shadow: 0 0 0 0 rgba(51, 87, 255, 0.55); }
|
||
70% { box-shadow: 0 0 0 12px rgba(51, 87, 255, 0); }
|
||
100% { box-shadow: 0 0 0 0 rgba(51, 87, 255, 0); }
|
||
}
|
||
@keyframes hudSpin { to { transform: rotate(360deg); } }
|
||
@keyframes hudFloat {
|
||
0%, 100% { transform: translateY(0); }
|
||
50% { transform: translateY(-8px); }
|
||
}
|
||
`;
|
||
|
||
/**
|
||
* KubikonPlayer — полноэкранная страница для прохождения опубликованной игры.
|
||
* URL: /kubikon/play/:id
|
||
*
|
||
* Не редактор. Только Babylon-canvas + UI игры (HUD, hot-bar, GUI-оверлей).
|
||
* Снизу — компактная панель кнопок: Назад / Лайк / Жалоба / Поделиться.
|
||
*/
|
||
const KubikonPlayer = () => {
|
||
const { id } = useParams();
|
||
const navigate = useNavigate();
|
||
const projectId = Number(id);
|
||
const { isAuthenticated, isLoading: authLoading, user: authUser } = useAuth();
|
||
const device = useDeviceType();
|
||
// На телефонах И планшетах — тач-управление. На десктопе обычное.
|
||
const isTouch = device.isPhone || device.isTablet;
|
||
// Ориентация — для портрет-promp'та на телефонах
|
||
const [isPortrait, setIsPortrait] = useState(
|
||
typeof window !== 'undefined' && window.innerHeight > window.innerWidth,
|
||
);
|
||
useEffect(() => {
|
||
const onResize = () => {
|
||
setIsPortrait(window.innerHeight > window.innerWidth);
|
||
};
|
||
window.addEventListener('resize', onResize);
|
||
window.addEventListener('orientationchange', onResize);
|
||
return () => {
|
||
window.removeEventListener('resize', onResize);
|
||
window.removeEventListener('orientationchange', onResize);
|
||
};
|
||
}, []);
|
||
|
||
// Только зарегистрированные. Если гость / без токена — на rublox.pro/login.
|
||
// В плеере (player.rublox.pro) роута /login нет, поэтому уходим на
|
||
// внешний домен через goLogin().
|
||
useEffect(() => {
|
||
if (authLoading) return;
|
||
if (!isAuthenticated) {
|
||
goLogin();
|
||
}
|
||
}, [isAuthenticated, authLoading]);
|
||
|
||
const canvasRef = useRef(null);
|
||
const sceneRef = useRef(null);
|
||
const viewportRef = useRef(null);
|
||
const hudRef = useRef(null);
|
||
/** Colyseus Room (только если игра мультиплеерная). */
|
||
const roomRef = useRef(null);
|
||
/** MultiplayerSync (мост между room и Babylon-сценой). */
|
||
const mpSyncRef = useRef(null);
|
||
/** Выбранный 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 — открыть/закрыть чат. Игнорируем когда:
|
||
// • уже введён текст в <input>/<textarea>/contenteditable (юзер печатает)
|
||
// • открыто верхнее меню (Esc-меню) — T там может относиться к табам
|
||
useEffect(() => {
|
||
const onKey = (e) => {
|
||
if (e.key !== 't' && e.key !== 'T' && e.key !== 'е' && e.key !== 'Е') return;
|
||
const tgt = e.target;
|
||
if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA' || tgt.isContentEditable)) return;
|
||
const s = sceneRef.current;
|
||
if (!s || !s._isPlaying) return;
|
||
e.preventDefault();
|
||
setChatOpen(v => {
|
||
if (!v) setTopMenuOpen(false);
|
||
return !v;
|
||
});
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, []);
|
||
|
||
/** Переродиться — телепорт на spawn point с восстановлением hp. */
|
||
/**
|
||
* Подключение к мультиплеерной комнате этой игры.
|
||
* Если уже подключены — ничего не делаем.
|
||
* При успехе создаём MultiplayerSync, который двусторонне синхронизирует
|
||
* локального игрока (PlayerController) с сервером и рисует remote-игроков.
|
||
*
|
||
* Защита от race-conditions: если sceneRef ещё не готов или scene/player
|
||
* пропали (HMR во время загрузки), пробуем ещё несколько раз с интервалом
|
||
* 200мс, потом сдаёмся.
|
||
*/
|
||
const connectMultiplayer = useCallback(async (projectMeta, retries = 8) => {
|
||
if (mpSyncRef.current || roomRef.current) return;
|
||
|
||
const sceneObj = sceneRef.current;
|
||
const babylonScene = sceneObj?.scene;
|
||
const player = sceneObj?.player;
|
||
|
||
// Если что-то ещё не готово — повторяем попытку.
|
||
if (!sceneObj || !babylonScene || !player) {
|
||
if (retries > 0) {
|
||
setTimeout(() => connectMultiplayer(projectMeta, retries - 1), 200);
|
||
} else {
|
||
console.warn('[mp] connect aborted — scene/player not ready');
|
||
setMpStatus('failed');
|
||
setMpError('Сцена не готова');
|
||
}
|
||
return;
|
||
}
|
||
|
||
const tokenRaw = localStorage.getItem('Authorization') || '';
|
||
if (!tokenRaw) return;
|
||
|
||
setMpStatus('connecting');
|
||
setMpError(null);
|
||
try {
|
||
const client = new Client(REALTIME_WS);
|
||
// modelType — ПЕРСОНАЛЬНЫЙ скин этого игрока (из rublox_equipped_skin,
|
||
// загружен при старте в skinFolderRef). Сервер всё равно перепроверит
|
||
// скин по userId из JWT и при расхождении возьмёт значение из БД —
|
||
// так каждый игрок виден соперникам в своём реальном скине.
|
||
const modelType = skinFolderRef.current || 'skin_bacon-hair';
|
||
// Если у нас есть валидный reconnectionToken от прошлой сессии —
|
||
// используем Colyseus reconnect (это та же сессия для сервера,
|
||
// allowReconnection(5) её подхватит, не будет +join/-leave цикла).
|
||
// Иначе — обычный joinOrCreate.
|
||
const savedRT = window.__rubloxReconnectToken;
|
||
let room;
|
||
if (savedRT) {
|
||
try {
|
||
room = await client.reconnect(savedRT);
|
||
} catch (e) {
|
||
// токен протух (>5с прошло или сервер уже удалил) — обычный join
|
||
window.__rubloxReconnectToken = null;
|
||
room = await client.joinOrCreate('battle', {
|
||
projectId: projectMeta?.id || projectId,
|
||
token: tokenRaw,
|
||
modelType,
|
||
});
|
||
}
|
||
} else {
|
||
room = await client.joinOrCreate('battle', {
|
||
projectId: projectMeta?.id || projectId,
|
||
token: tokenRaw,
|
||
modelType,
|
||
});
|
||
}
|
||
roomRef.current = room;
|
||
// Сохраняем reconnectionToken для возможного будущего реконнекта.
|
||
window.__rubloxReconnectToken = room.reconnectionToken;
|
||
setMpRoomCode((room.roomId || '').slice(0, 6).toUpperCase());
|
||
|
||
const sync = new MultiplayerSync(
|
||
babylonScene, // Babylon Scene (взяли в начале функции)
|
||
room,
|
||
() => {
|
||
// Колбэк-источник позиции локального игрока
|
||
const p = sceneRef.current?.player?._pos;
|
||
const yaw = sceneRef.current?.player?._yaw;
|
||
if (!p) return null;
|
||
// p.y — это центр игрока (HALF_H выше пола под ногами).
|
||
// Передаём как есть, на клиенте при рендере других уберём HALF_H.
|
||
return { x: p.x, y: p.y || 0, z: p.z, yaw: yaw || 0 };
|
||
},
|
||
{
|
||
onLocalHit: (damage, hpVal, maxHpVal) => {
|
||
// Применяем урон в локальный PlayerController.
|
||
// Сервер — авторитет, синхронизируем hp напрямую,
|
||
// не используя takeDamage (он для локальных зомби,
|
||
// имеет i-frames и считает свой hp — может разойтись
|
||
// с сервером и пропустить смерть).
|
||
const p = sceneRef.current?.player;
|
||
if (p) {
|
||
const wasDead = p.hp <= 0;
|
||
p.hp = hpVal;
|
||
p.maxHp = maxHpVal;
|
||
// Звук «ой»
|
||
try { p._playHurtSound?.(); } catch (e) {}
|
||
// Если только что умерли — debris + спрятать модель
|
||
if (!wasDead && hpVal <= 0) {
|
||
try { p._spawnDeathDebris?.(); } catch (e) {}
|
||
if (p._modelRoot) p._modelRoot.setEnabled(false);
|
||
if (p._onDeath) {
|
||
try { p._onDeath(); } catch (e) {}
|
||
}
|
||
}
|
||
}
|
||
setHurtFlash(Date.now());
|
||
setHp({ hp: hpVal, maxHp: maxHpVal });
|
||
},
|
||
onKilled: (killerName) => {
|
||
console.log(`[mp] killed by ${killerName}`);
|
||
},
|
||
onRespawn: () => {
|
||
const s = sceneRef.current;
|
||
if (s?.player && s._spawnPoint) {
|
||
s.player.healFull?.();
|
||
const sp = s._spawnPoint;
|
||
const halfH = s.player.HALF_H ?? 0.9;
|
||
s.player._pos?.set?.(sp.x, sp.y + halfH + 0.2, sp.z);
|
||
if (s.player._vy != null) s.player._vy = 0;
|
||
}
|
||
},
|
||
onLog: (level, ...parts) => {
|
||
// eslint-disable-next-line no-console
|
||
console.log(`[mp:${level}]`, ...parts);
|
||
},
|
||
},
|
||
);
|
||
sync.start();
|
||
mpSyncRef.current = sync;
|
||
// Прокидываем sync в сцену, чтобы _handlePlayClick шёл и в мультиплеер тоже.
|
||
sceneRef.current?.setMultiplayerSync?.(sync);
|
||
|
||
// Подфаза 3.10: отправляем серверу свой outfit, чтобы соседи
|
||
// увидели наши аксессуары. Делаем async — outfit грузим заново
|
||
// (он мог не успеть к моменту enterPlayMode).
|
||
if (userId) {
|
||
Kubikon3DApi.getRubloxOutfit(userId).then((r) => {
|
||
const slots = r?.data?.slots || {};
|
||
// Серверу шлём только хорошие item-id (без null'ов).
|
||
const clean = {};
|
||
for (const [k, v] of Object.entries(slots)) {
|
||
if (v != null) clean[k] = v;
|
||
}
|
||
sync.sendAccessories(clean);
|
||
}).catch((e) => {
|
||
// eslint-disable-next-line no-console
|
||
console.warn('[KubikonPlayer] sendAccessories failed', e);
|
||
});
|
||
}
|
||
|
||
// === Локальная смерть → сервер ===
|
||
// Когда наш PlayerController сам поставил hp=0 (от куклы Squid Game,
|
||
// ловушки, скриптовой смерти и т.п.) — серверу надо сообщить, чтобы
|
||
// он бродкастил 'kill' всем остальным. Иначе другие клиенты не
|
||
// увидят рассыпание нашего скина на кубики.
|
||
//
|
||
// Сервер защищён от повторных вызовов (isDead-guard), поэтому если
|
||
// умерли от выстрела — повторное 'die' проигнорируется.
|
||
try {
|
||
const p = sceneRef.current?.player;
|
||
if (p && typeof p.setOnDeath === 'function') {
|
||
p.setOnDeath(() => {
|
||
try { room.send('die', {}); } catch (e) { /* room закрыта */ }
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.warn('[mp] setOnDeath failed:', e);
|
||
}
|
||
|
||
// Обновляем счётчик remote-игроков для UI
|
||
const updateCount = () => {
|
||
setMpRemotePlayers(sync.remotePlayers.size);
|
||
};
|
||
// Простой таймер — раз в секунду
|
||
const countTimer = setInterval(updateCount, 1000);
|
||
sync._countTimer = countTimer;
|
||
|
||
room.onLeave((code) => {
|
||
if (sync._countTimer) clearInterval(sync._countTimer);
|
||
setMpRoomCode('');
|
||
// НЕ сбрасываем remotePlayers/status сразу — Colyseus reconnect
|
||
// обычно отрабатывает за <1с, не надо мигать UI. Если за 2с
|
||
// не восстановили — тогда показываем 'connecting'.
|
||
const softFailTimer = setTimeout(() => {
|
||
setMpRemotePlayers(0);
|
||
setMpStatus('connecting');
|
||
}, 2000);
|
||
// Очищаем ref'ы — иначе следующий connectMultiplayer выйдет
|
||
// на if (mpSyncRef.current || roomRef.current) return.
|
||
try { sync.stop?.(); } catch (e) {}
|
||
mpSyncRef.current = null;
|
||
roomRef.current = null;
|
||
// Code 1000 / 1001 — нормальное закрытие. Code >= 4000 — наш
|
||
// явный leave (game-side). Всё остальное — реконнект.
|
||
const isExplicit = window.__rubloxExplicitExit || code === 4000;
|
||
if (isExplicit) {
|
||
clearTimeout(softFailTimer);
|
||
setMpStatus('idle');
|
||
setMpRemotePlayers(0);
|
||
return;
|
||
}
|
||
// Автореконнект с быстрым backoff. Сервер держит сессию через
|
||
// allowReconnection(5с), успеваем в это окно.
|
||
const tries = (window.__mpReconnectTries || 0) + 1;
|
||
window.__mpReconnectTries = tries;
|
||
if (tries > 5) {
|
||
clearTimeout(softFailTimer);
|
||
setMpStatus('failed');
|
||
setMpError('Соединение потеряно');
|
||
setMpRemotePlayers(0);
|
||
return;
|
||
}
|
||
// 200ms / 400ms / 800ms / 1600ms / 3200ms
|
||
const delay = 200 * Math.pow(2, tries - 1);
|
||
setTimeout(() => {
|
||
if (window.__rubloxExplicitExit) return;
|
||
// softFailTimer отменяется в самом connectMultiplayer
|
||
// через присвоение window.__mpSoftFailTimer
|
||
connectMultiplayer(projectMeta);
|
||
}, delay);
|
||
window.__mpSoftFailTimer = softFailTimer;
|
||
});
|
||
|
||
// Успешно подключились — сбрасываем счётчик попыток и отменяем
|
||
// soft-fail таймер (если автореконнект уложился в 2с).
|
||
if (window.__mpSoftFailTimer) {
|
||
clearTimeout(window.__mpSoftFailTimer);
|
||
window.__mpSoftFailTimer = null;
|
||
}
|
||
window.__mpReconnectTries = 0;
|
||
setMpStatus('connected');
|
||
} catch (err) {
|
||
console.error('[mp] connect error:', err);
|
||
setMpStatus('failed');
|
||
setMpError(err?.message || String(err));
|
||
}
|
||
}, [projectId]);
|
||
|
||
/** Запросить fullscreen. Вызывается ИЗ user gesture (клик по кнопке),
|
||
* иначе браузер запретит. Orientation lock НЕ ставим — играть можно
|
||
* и в портрете, и в ландшафте. */
|
||
const handleMobileStart = useCallback(async () => {
|
||
const root = document.documentElement;
|
||
const req = root.requestFullscreen
|
||
|| root.webkitRequestFullscreen
|
||
|| root.mozRequestFullScreen
|
||
|| root.msRequestFullscreen;
|
||
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 <CenteredCard
|
||
icon="🔒"
|
||
title="Игра недоступна"
|
||
text="Эта игра ещё не одобрена модерацией или скрыта автором."
|
||
onBack={() => exitPlayer(id)}
|
||
/>;
|
||
}
|
||
if (error) {
|
||
return <CenteredCard
|
||
icon="⚠️"
|
||
title="Ошибка"
|
||
text={error}
|
||
onBack={() => exitPlayer(id)}
|
||
/>;
|
||
}
|
||
|
||
return (
|
||
<div style={{
|
||
position: 'fixed', inset: 0,
|
||
background: '#070a14',
|
||
zIndex: 1000,
|
||
display: 'flex', flexDirection: 'column',
|
||
fontFamily: HUD.font,
|
||
}}>
|
||
<style>{HUD_KEYFRAMES}</style>
|
||
<div ref={viewportRef} style={{
|
||
flex: 1, position: 'relative', minHeight: 0,
|
||
background: '#070a14',
|
||
}}>
|
||
<canvas
|
||
ref={canvasRef}
|
||
style={{
|
||
width: '100%', height: '100%',
|
||
display: 'block',
|
||
outline: 'none',
|
||
}}
|
||
/>
|
||
{/* Loading-оверлей */}
|
||
{loading && (
|
||
<div style={{
|
||
position: 'absolute', inset: 0,
|
||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||
background:
|
||
'radial-gradient(ellipse at center, rgba(51,87,255,0.22) 0%, rgba(7,10,20,0.96) 60%)',
|
||
gap: 18, color: HUD.text,
|
||
}}>
|
||
<div style={{
|
||
position: 'relative',
|
||
animation: 'hudFloat 3s ease-in-out infinite',
|
||
}}>
|
||
<div style={{
|
||
position: 'absolute', inset: -10,
|
||
borderRadius: 20,
|
||
animation: 'hudPulseRing 1.6s ease-out infinite',
|
||
}} />
|
||
<RublocsLogo size={72} />
|
||
</div>
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: 10,
|
||
fontSize: 15, fontWeight: 700, letterSpacing: 0.3,
|
||
}}>
|
||
<div style={{
|
||
width: 14, height: 14,
|
||
border: `2.5px solid ${HUD.accentBg}`,
|
||
borderTopColor: HUD.accent,
|
||
borderRadius: '50%',
|
||
animation: 'hudSpin 0.8s linear infinite',
|
||
}} />
|
||
Загрузка игры…
|
||
</div>
|
||
<div style={{
|
||
fontSize: 11, color: HUD.textDim,
|
||
textTransform: 'uppercase', letterSpacing: 1.4, fontWeight: 700,
|
||
}}>
|
||
Рублокс • 3D
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */}
|
||
{!loading && (
|
||
<>
|
||
{/* HUD: на мобиле уменьшаем и сдвигаем компактно. */}
|
||
{isTouch ? (
|
||
<>
|
||
{/* HP-бар: вверху слева, scale 0.75 */}
|
||
<div data-mobile-hud="hp" style={{
|
||
position: 'absolute', top: 0, left: 0,
|
||
transform: 'scale(0.75)', transformOrigin: 'top left',
|
||
pointerEvents: 'none', zIndex: 30,
|
||
}}>
|
||
<PlayerHud
|
||
visible={stdHudVisible}
|
||
hp={hp.hp}
|
||
maxHp={hp.maxHp}
|
||
ammo={null}
|
||
damaged={Date.now() - hurtFlash < 350}
|
||
/>
|
||
</div>
|
||
{/* Mini ammo block: правый верх. Большие цифры,
|
||
кнопка R для перезарядки внизу. */}
|
||
{ammo && (
|
||
<MobileAmmoBlock
|
||
ammo={ammo}
|
||
onReload={() => sceneRef.current?.weapons?.reload?.()}
|
||
/>
|
||
)}
|
||
{/* Hotbar — только если в инвентаре есть хоть
|
||
один предмет. Пустой инвентарь не показываем. */}
|
||
{stdHudVisible && (inventoryState.slots || []).some(s => s) && (
|
||
<Hotbar
|
||
visible={stdHudVisible}
|
||
mobileMode
|
||
slots={inventoryState.slots}
|
||
activeIndex={inventoryState.activeIndex}
|
||
onSelect={(i) => sceneRef.current?.setActiveInventorySlot?.(i)}
|
||
/>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
<PlayerHud
|
||
visible={stdHudVisible}
|
||
hp={hp.hp}
|
||
maxHp={hp.maxHp}
|
||
ammo={ammo}
|
||
damaged={Date.now() - hurtFlash < 350}
|
||
/>
|
||
{stdHudVisible && (inventoryState.slots || []).some(s => s) && (
|
||
<Hotbar
|
||
visible={stdHudVisible}
|
||
slots={inventoryState.slots}
|
||
activeIndex={inventoryState.activeIndex}
|
||
onSelect={(i) => sceneRef.current?.setActiveInventorySlot?.(i)}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
<GameHud visible={true} hudRef={hudRef} />
|
||
{/* Performance overlay — клавиша F или клик по бейджу.
|
||
Также фоном шлёт метрики на бэк раз в 5 сек. */}
|
||
<KubikonPerfOverlay
|
||
getScene={() => sceneRef.current}
|
||
projectId={projectId}
|
||
userId={userId}
|
||
/>
|
||
{/* Тост-награда «+200 рейтинга за 1 место в таблице рекордов».
|
||
Появляется когда submitLeaderboard вернул rating_award
|
||
для текущего юзера (попадание в топ-3 впервые).
|
||
Авто-закрытие через 6 сек или клик. */}
|
||
{ratingToast && (
|
||
<RatingAwardToast
|
||
place={ratingToast.place}
|
||
amount={ratingToast.amount}
|
||
onClose={() => setRatingToast(null)}
|
||
/>
|
||
)}
|
||
|
||
{/* Лидерборд (правый верхний угол) — Tab скрыть/показать.
|
||
Сама панель НЕ перехватывает клики/мышь (pointer-events: none),
|
||
чтобы не разблокировать курсор в Play-режиме.
|
||
Только кнопки внутри (✕ закрыть, 🔄 заново) кликабельны. */}
|
||
{leaderboardVisible && leaderboardEnabled && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: 64, right: 12,
|
||
zIndex: 35,
|
||
pointerEvents: 'none',
|
||
animation: 'hudFadeIn 0.3s ease-out',
|
||
display: 'flex', flexDirection: 'column', gap: 8,
|
||
alignItems: 'flex-end',
|
||
}}>
|
||
<div style={{ pointerEvents: 'auto' }}>
|
||
<KubikonLeaderboard
|
||
projectId={projectId}
|
||
limit={5}
|
||
currentUserId={userId}
|
||
currentTimeMs={timerRunning ? timerMs : null}
|
||
onClose={() => setLeaderboardVisible(false)}
|
||
compact={true}
|
||
refreshKey={leaderboardRefreshKey}
|
||
clickable={false}
|
||
onLoaded={(records) => {
|
||
// Если в проекте уже есть рекорды — игра-ранер,
|
||
// включаем виджет даже если timer ещё не стартовал.
|
||
if (records && records.length > 0) {
|
||
setLeaderboardEnabled(true);
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
{/* Кнопка «Заново» — для попытки улучшить рекорд */}
|
||
<button
|
||
onClick={handleRestartGame}
|
||
title="Начать игру заново (сбросить состояние)"
|
||
style={{
|
||
background: 'linear-gradient(135deg, rgba(34,217,122,0.22), rgba(20,24,45,0.92))',
|
||
border: '1px solid rgba(34, 217, 122, 0.55)',
|
||
borderRadius: 10,
|
||
color: '#22d97a',
|
||
padding: '8px 14px',
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
fontWeight: 700, fontSize: 14,
|
||
cursor: 'pointer',
|
||
backdropFilter: 'blur(6px)',
|
||
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||
transition: 'transform 0.12s ease',
|
||
pointerEvents: 'auto',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.transform = 'translateY(0)';
|
||
}}
|
||
>🔄 Заново</button>
|
||
</div>
|
||
)}
|
||
{/* Кнопки «Топ» и «Заново» когда лидерборд скрыт.
|
||
Размещены ниже компактного таймера в правом верхнем углу.
|
||
Кнопка «Топ» показывается только если игра поддерживает таймер. */}
|
||
{!leaderboardVisible && leaderboardEnabled && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: timerRunning ? 60 : 18, right: 12,
|
||
zIndex: 35,
|
||
display: 'flex', flexDirection: 'column', gap: 8,
|
||
alignItems: 'flex-end',
|
||
}}>
|
||
<button
|
||
onClick={() => setLeaderboardVisible(true)}
|
||
title="Показать таблицу лидеров (Tab)"
|
||
style={{
|
||
background: 'rgba(20, 24, 45, 0.85)',
|
||
border: '1px solid rgba(255, 215, 0, 0.55)',
|
||
borderRadius: 10,
|
||
color: '#ffd700',
|
||
padding: '8px 14px',
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
fontWeight: 700, fontSize: 14,
|
||
cursor: 'pointer',
|
||
backdropFilter: 'blur(6px)',
|
||
}}
|
||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(40, 44, 65, 0.95)'; }}
|
||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(20, 24, 45, 0.85)'; }}
|
||
>
|
||
🏆 Топ
|
||
</button>
|
||
<button
|
||
onClick={handleRestartGame}
|
||
title="Начать игру заново"
|
||
style={{
|
||
background: 'rgba(20, 24, 45, 0.85)',
|
||
border: '1px solid rgba(34, 217, 122, 0.55)',
|
||
borderRadius: 10,
|
||
color: '#22d97a',
|
||
padding: '8px 14px',
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
fontWeight: 700, fontSize: 14,
|
||
cursor: 'pointer',
|
||
backdropFilter: 'blur(6px)',
|
||
}}
|
||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(40, 44, 65, 0.95)'; }}
|
||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(20, 24, 45, 0.85)'; }}
|
||
>
|
||
🔄 Заново
|
||
</button>
|
||
</div>
|
||
)}
|
||
{/* Компактный таймер справа сверху над кнопками — когда
|
||
таблица лидеров скрыта (иначе видно в самой таблице). */}
|
||
{timerRunning && !leaderboardVisible && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: 18, right: 12,
|
||
zIndex: 35,
|
||
background: 'rgba(20, 24, 45, 0.92)',
|
||
border: '1px solid rgba(34, 217, 122, 0.55)',
|
||
borderRadius: 10,
|
||
color: '#22d97a',
|
||
padding: '6px 12px',
|
||
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
|
||
fontWeight: 800, fontSize: 16,
|
||
pointerEvents: 'none',
|
||
backdropFilter: 'blur(6px)',
|
||
}}>
|
||
⏱ {formatTimeMs(timerMs)}
|
||
</div>
|
||
)}
|
||
<GuiOverlay
|
||
elements={guiList}
|
||
isPlaying={true}
|
||
containerRef={viewportRef}
|
||
// 2026-05-27: без resolveAsset картинки GUI с
|
||
// imageAsset не показывались в плеере — el.imageAsset
|
||
// резолвился в null. Берём dataURL из assetManager.
|
||
resolveAsset={(id) =>
|
||
sceneRef.current?.assetManager?.getDataUrl?.(id) || null}
|
||
onPlayClick={(gid) => {
|
||
const rt = sceneRef.current?.gameRuntime;
|
||
if (!rt) return;
|
||
rt.routeEvent({ kind: 'gui', id: gid }, 'click', {});
|
||
rt.routeGlobalEvent('guiClick', { id: gid });
|
||
}}
|
||
/>
|
||
{/* Задача 04: модал-overlay (затемнение + spotlight mask) */}
|
||
<ModalOverlay scene={sceneRef.current} />
|
||
{/* Задача 07: встроенный магазин скинов (клавиша B / API) */}
|
||
<SkinShopOverlay scene={sceneRef.current} />
|
||
{/* Мобильное управление — на любых тач-устройствах,
|
||
и в портрете и в ландшафте (ранее был блок portrait,
|
||
убрали по фидбэку — играть можно как угодно). */}
|
||
{isTouch && (
|
||
<KubikonMobileControls getScene={() => sceneRef.current} />
|
||
)}
|
||
</>
|
||
)}
|
||
{/* Кнопка «полный экран» — маленькая, в правом верхнем углу,
|
||
только на тач-устройствах. Браузеры требуют user gesture
|
||
для requestFullscreen() — поэтому без кнопки никак.
|
||
Кнопка автоматически скрывается после входа в fullscreen. */}
|
||
{isTouch && !loading && !document.fullscreenElement && (
|
||
<button
|
||
data-mobile-hud="fullscreen"
|
||
onClick={handleMobileStart}
|
||
style={{
|
||
position: 'absolute',
|
||
top: 8, right: 8,
|
||
width: 36, height: 36,
|
||
borderRadius: 8,
|
||
background: 'rgba(15, 19, 38, 0.78)',
|
||
border: '1px solid rgba(255, 255, 255, 0.18)',
|
||
color: '#fff',
|
||
cursor: 'pointer',
|
||
zIndex: 1100,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
padding: 0,
|
||
WebkitTapHighlightColor: 'transparent',
|
||
}}
|
||
title="Полноэкранный режим"
|
||
>
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"
|
||
stroke="#fff" strokeWidth="2.2"
|
||
strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* === Floating top bar (слева вверху, под HP-баром) === */}
|
||
{!loading && (
|
||
<div data-mobile-hud="topbar" style={{
|
||
position: 'absolute',
|
||
// На мобиле HP-бар сжат scale(0.75) занимает примерно 60px
|
||
// высоты в верху. Кладём top-bar НИЖЕ него, чтобы не перекрывал.
|
||
top: isTouch ? 60 : 96,
|
||
left: isTouch ? 8 : 14,
|
||
zIndex: 1100,
|
||
display: 'flex', gap: isTouch ? 6 : 8, alignItems: 'flex-start',
|
||
animation: 'hudFadeIn 320ms ease',
|
||
transform: isTouch ? 'scale(0.85)' : 'none',
|
||
transformOrigin: 'top left',
|
||
}}>
|
||
{/* Кнопка «Меню» убрана — меню открывается клавишей Esc.
|
||
GameMenu (Roblox-style 5 табов) рендерится как fixed-модалка
|
||
в самом низу плеера, см. блок <GameMenu .../>. */}
|
||
|
||
{/* Toggle чата — круглая иконка-таблетка с T-бейджем
|
||
(как в exe-плеере: BR-стиль кнопки чата над миром). */}
|
||
<ChatToggleButton
|
||
active={chatOpen}
|
||
onClick={() => setChatOpen(v => {
|
||
if (!v) setTopMenuOpen(false);
|
||
return !v;
|
||
})}
|
||
/>
|
||
|
||
{/* Мультиплеер-индикатор (только если игра multiplayer=true) */}
|
||
{meta?.multiplayer && (
|
||
<div style={{
|
||
...topbarBtn,
|
||
cursor: 'default',
|
||
background: mpStatus === 'connected'
|
||
? 'linear-gradient(135deg, #22d97a 0%, #0f9d56 100%)'
|
||
: mpStatus === 'failed'
|
||
? 'linear-gradient(135deg, #ff6f7a 0%, #c0303f 100%)'
|
||
: HUD.bgGlass,
|
||
color: HUD.text,
|
||
borderColor: mpStatus === 'connected' || mpStatus === 'failed'
|
||
? 'transparent'
|
||
: HUD.border,
|
||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||
}}
|
||
title={
|
||
mpStatus === 'connected'
|
||
? `Мультиплеер активен · комната ${mpRoomCode}`
|
||
: mpStatus === 'connecting'
|
||
? 'Подключаемся к мультиплееру…'
|
||
: mpStatus === 'failed'
|
||
? `Мультиплеер не подключён: ${mpError || ''}`
|
||
: 'Мультиплеер'
|
||
}>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="12" cy="12" r="10" />
|
||
<line x1="2" y1="12" x2="22" y2="12" />
|
||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||
</svg>
|
||
<span>
|
||
{mpStatus === 'connected' && (
|
||
<>
|
||
<span style={{ fontWeight: 800 }}>{mpRemotePlayers + 1}</span>
|
||
{mpRoomCode && (
|
||
<span style={{
|
||
marginLeft: 6, fontSize: 10,
|
||
background: 'rgba(0,0,0,0.20)',
|
||
padding: '1px 6px',
|
||
borderRadius: 999,
|
||
fontFamily: 'monospace',
|
||
letterSpacing: 1,
|
||
}}>{mpRoomCode}</span>
|
||
)}
|
||
</>
|
||
)}
|
||
{mpStatus === 'connecting' && '…'}
|
||
{mpStatus === 'failed' && 'offline'}
|
||
{mpStatus === 'idle' && '—'}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* === Компактный чат слева-сверху (под top bar) ===
|
||
Держим компонент СМОНТИРОВАННЫМ всегда (после loading) и
|
||
скрываем через CSS — иначе при unmount теряется история
|
||
сообщений и WS-подписка на 'chat' слетает с realtime-room.
|
||
Когда chatOpen=false — visibility:hidden, pointer-events:none. */}
|
||
{!loading && (
|
||
<div style={{
|
||
visibility: chatOpen ? 'visible' : 'hidden',
|
||
pointerEvents: chatOpen ? 'auto' : 'none',
|
||
}}>
|
||
<KubikonChatPanel
|
||
projectId={projectId}
|
||
compact
|
||
mobileMode={isTouch}
|
||
onClose={() => setChatOpen(false)}
|
||
onRequestAuth={(action) => {
|
||
setChatOpen(false);
|
||
setNeedAuthModal(action);
|
||
}}
|
||
/* Если игра мультиплеерная и мы подключены — чат идёт
|
||
через realtime-room (видят только игроки в этой комнате).
|
||
Иначе fallback к storys-чату игры. */
|
||
realtimeRoom={mpStatus === 'connected' ? roomRef.current : null}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{reportOpen && (
|
||
<ReportModal
|
||
onClose={() => setReportOpen(false)}
|
||
onSubmit={async ({ category, text }) => {
|
||
if (!userId) {
|
||
setReportOpen(false);
|
||
setNeedAuthModal('оставлять жалобы');
|
||
return;
|
||
}
|
||
await Kubikon3DApi.createReport({
|
||
reporter_user_id: userId,
|
||
target_type: 'project',
|
||
target_id: projectId,
|
||
category,
|
||
text,
|
||
});
|
||
setReportOpen(false);
|
||
setInfoModal({
|
||
icon: '✅',
|
||
title: 'Жалоба отправлена',
|
||
text: 'Модераторы рассмотрят её в ближайшее время. Спасибо за внимательность!',
|
||
});
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{infoModal && (
|
||
<InfoModal
|
||
icon={infoModal.icon}
|
||
title={infoModal.title}
|
||
text={infoModal.text}
|
||
onClose={() => setInfoModal(null)}
|
||
/>
|
||
)}
|
||
|
||
{needAuthModal && (
|
||
<NeedAuthModal
|
||
action={needAuthModal}
|
||
onClose={() => setNeedAuthModal(null)}
|
||
onLogin={() => goLogin()}
|
||
/>
|
||
)}
|
||
|
||
|
||
{/* Плавающая кнопка баг-репорта (открывается из ESC-меню).
|
||
Сама кнопка скрыта — данные нужны только её модалке. */}
|
||
{!loading && (
|
||
<KubikonBugReportButton
|
||
bugType="player"
|
||
projectId={projectId}
|
||
bottomOffset={70}
|
||
rightOffset={16}
|
||
hidden
|
||
/>
|
||
)}
|
||
|
||
{/* GameMenu — Roblox-style центральная модалка с 5 табами
|
||
(Участники / Настройки / Захваты / Жалоба / Помощь). 1 в 1
|
||
как в exe-плеере (см. rublox-native/godot/scripts/ui/GameHud.gd).
|
||
Открывается по бургер-кнопке "Меню" и по Esc. */}
|
||
{!loading && (
|
||
<GameMenu
|
||
visible={topMenuOpen}
|
||
onClose={() => {
|
||
setTopMenuOpen(false);
|
||
// Синхронизируем движок (_playerMenuOpen) И возвращаем мышь
|
||
// в игру одним вызовом. Без этого следующий ESC решит, что
|
||
// меню «ещё открыто», и не откроет его.
|
||
try { sceneRef.current?.setPlayerMenuOpen?.(false); } catch {}
|
||
}}
|
||
onExit={() => exitPlayer(id)}
|
||
onRespawn={() => respawnPlayer()}
|
||
gameId={id}
|
||
gameTitle={meta?.title}
|
||
mySkin={skinFolderRef.current}
|
||
sceneRef={sceneRef}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const btn = {
|
||
padding: '8px 16px',
|
||
background: HUD.bgInput,
|
||
border: `1px solid ${HUD.border}`,
|
||
borderRadius: 8,
|
||
color: HUD.text,
|
||
cursor: 'pointer',
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
fontFamily: HUD.font,
|
||
whiteSpace: 'nowrap',
|
||
transition: 'all 150ms ease',
|
||
};
|
||
|
||
const btnPrimary = {
|
||
...btn,
|
||
background: HUD.gradientBrand,
|
||
border: '1px solid transparent',
|
||
color: '#fff',
|
||
fontWeight: 700,
|
||
boxShadow: '0 6px 16px rgba(51,87,255,0.32)',
|
||
};
|
||
|
||
const btnDanger = {
|
||
...btn,
|
||
background: 'linear-gradient(135deg, #ff6f7a 0%, #c0303f 100%)',
|
||
border: '1px solid transparent',
|
||
color: '#fff',
|
||
fontWeight: 700,
|
||
boxShadow: '0 6px 16px rgba(255,111,122,0.30)',
|
||
};
|
||
|
||
/** Кнопка в верхней плашке плеера. */
|
||
const topbarBtn = {
|
||
padding: '8px 14px',
|
||
background: HUD.bgGlass,
|
||
border: `1px solid ${HUD.border}`,
|
||
borderRadius: 999,
|
||
color: HUD.text,
|
||
cursor: 'pointer',
|
||
fontSize: 13, fontWeight: 700,
|
||
fontFamily: HUD.font,
|
||
whiteSpace: 'nowrap',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.4)',
|
||
backdropFilter: 'blur(20px)',
|
||
WebkitBackdropFilter: 'blur(20px)',
|
||
display: 'inline-flex', alignItems: 'center',
|
||
transition: 'all 200ms ease',
|
||
letterSpacing: 0.2,
|
||
};
|
||
|
||
// === Тост «+N рейтинга за место в таблице рекордов» ===
|
||
//
|
||
// Показывается когда submitLeaderboard вернул rating_award для текущего
|
||
// пользователя (попадание в топ-3 впервые на этом проекте).
|
||
// Бэкенд PLACE_RATING = {1: 200, 2: 150, 3: 100} в Kubikon3D.py.
|
||
const RatingAwardToast = ({ place, amount, onClose }) => {
|
||
const meta = {
|
||
1: { medal: '🥇', label: '1 МЕСТО', accent: '#ffd84a', glow: 'rgba(255, 216, 74, 0.55)' },
|
||
2: { medal: '🥈', label: '2 МЕСТО', accent: '#cfd8dc', glow: 'rgba(207, 216, 220, 0.55)' },
|
||
3: { medal: '🥉', label: '3 МЕСТО', accent: '#d29066', glow: 'rgba(210, 144, 102, 0.55)' },
|
||
}[place] || { medal: '🏅', label: 'ТОП-3', accent: '#ffd84a', glow: 'rgba(255, 216, 74, 0.55)' };
|
||
|
||
return (
|
||
<div
|
||
onClick={onClose}
|
||
style={{
|
||
position: 'absolute',
|
||
top: 80,
|
||
left: '50%',
|
||
transform: 'translateX(-50%)',
|
||
zIndex: 60,
|
||
pointerEvents: 'auto',
|
||
cursor: 'pointer',
|
||
background: 'linear-gradient(135deg, rgba(15,22,40,0.96) 0%, rgba(28,40,72,0.96) 100%)',
|
||
border: `2px solid ${meta.accent}`,
|
||
borderRadius: 12,
|
||
padding: '14px 22px',
|
||
display: 'flex', alignItems: 'center', gap: 14,
|
||
boxShadow: `0 12px 40px ${meta.glow}, 0 0 0 1px rgba(255,255,255,0.05) inset`,
|
||
animation: 'hudFadeIn 0.45s ease-out',
|
||
fontFamily: HUD.font,
|
||
color: '#fff',
|
||
minWidth: 280,
|
||
maxWidth: 'calc(100vw - 40px)',
|
||
}}
|
||
title="Кликни чтобы закрыть"
|
||
>
|
||
<div style={{
|
||
fontSize: 42, lineHeight: 1, flexShrink: 0,
|
||
filter: `drop-shadow(0 0 8px ${meta.glow})`,
|
||
}}>
|
||
{meta.medal}
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{
|
||
fontSize: 11, fontWeight: 800,
|
||
color: meta.accent, letterSpacing: 1.5,
|
||
textTransform: 'uppercase',
|
||
}}>
|
||
{meta.label} В ТАБЛИЦЕ РЕКОРДОВ
|
||
</div>
|
||
<div style={{
|
||
fontSize: 22, fontWeight: 900,
|
||
marginTop: 3,
|
||
background: `linear-gradient(90deg, ${meta.accent}, #fff)`,
|
||
WebkitBackgroundClip: 'text',
|
||
WebkitTextFillColor: 'transparent',
|
||
backgroundClip: 'text',
|
||
}}>
|
||
+{amount} рейтинга
|
||
</div>
|
||
<div style={{
|
||
fontSize: 11, opacity: 0.7, marginTop: 2,
|
||
}}>
|
||
зачислено в твой профиль
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
|
||
const CenteredCard = ({ icon, title, text, onBack }) => (
|
||
<div style={{
|
||
position: 'fixed', inset: 0,
|
||
background:
|
||
'radial-gradient(ellipse at center, rgba(51,87,255,0.18) 0%, #070a14 60%)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
flexDirection: 'column', gap: 18,
|
||
color: HUD.text,
|
||
fontFamily: HUD.font,
|
||
padding: 24,
|
||
}}>
|
||
<style>{HUD_KEYFRAMES}</style>
|
||
<div style={{
|
||
fontSize: 76,
|
||
animation: 'hudFloat 3.6s ease-in-out infinite',
|
||
filter: 'drop-shadow(0 8px 24px rgba(0,0,0,0.4))',
|
||
}}>{icon}</div>
|
||
<div style={{
|
||
fontSize: 26, fontWeight: 800, letterSpacing: -0.3,
|
||
}}>{title}</div>
|
||
<div style={{
|
||
fontSize: 14, color: HUD.textMuted, maxWidth: 440,
|
||
textAlign: 'center', lineHeight: 1.55,
|
||
}}>{text}</div>
|
||
<button
|
||
onClick={onBack}
|
||
style={btnPrimary}
|
||
onMouseEnter={e => {
|
||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||
e.currentTarget.style.boxShadow = '0 12px 28px rgba(51,87,255,0.45)';
|
||
}}
|
||
onMouseLeave={e => {
|
||
e.currentTarget.style.transform = 'translateY(0)';
|
||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(51,87,255,0.32)';
|
||
}}
|
||
>
|
||
← В ленту Рублокса
|
||
</button>
|
||
</div>
|
||
);
|
||
|
||
const REPORT_CATEGORIES = [
|
||
{ id: 'profanity', label: 'Мат / нецензурная лексика' },
|
||
{ id: 'inappropriate', label: 'Неподходящий контент / возраст' },
|
||
{ id: 'ad', label: 'Реклама / спам' },
|
||
{ id: 'rights', label: 'Нарушение авторских прав' },
|
||
{ id: 'broken', label: 'Игра сломана / не работает' },
|
||
{ id: 'other', label: 'Другое' },
|
||
];
|
||
|
||
const ReportModal = ({ onClose, onSubmit }) => {
|
||
const [category, setCategory] = useState('inappropriate');
|
||
const [text, setText] = useState('');
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
const [submitError, setSubmitError] = useState(null);
|
||
|
||
const submit = async () => {
|
||
setSubmitting(true);
|
||
setSubmitError(null);
|
||
try {
|
||
await onSubmit({ category, text });
|
||
} catch (e) {
|
||
setSubmitError(e?.message || 'Неизвестная ошибка');
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div onClick={onClose} style={modalBackdrop}>
|
||
<style>{HUD_KEYFRAMES}</style>
|
||
<div onClick={(e) => e.stopPropagation()} style={{
|
||
...modalCard, maxWidth: 480,
|
||
}}>
|
||
{/* Градиентный заголовок */}
|
||
<div style={{
|
||
background: HUD.gradientBrand,
|
||
padding: '20px 24px',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
gap: 12,
|
||
}}>
|
||
<div style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 12,
|
||
}}>
|
||
<div style={{
|
||
width: 40, height: 40, borderRadius: 12,
|
||
background: 'rgba(255,255,255,0.18)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 22,
|
||
}}>🚩</div>
|
||
<h2 style={{
|
||
margin: 0, fontSize: 18, fontWeight: 800, color: '#fff',
|
||
letterSpacing: -0.3,
|
||
}}>Пожаловаться на игру</h2>
|
||
</div>
|
||
<button onClick={onClose} style={iconCloseBtn}>×</button>
|
||
</div>
|
||
|
||
<div style={{ padding: 22 }}>
|
||
<div style={{ marginBottom: 14 }}>
|
||
<div style={fieldLabel}>Категория</div>
|
||
<select
|
||
value={category}
|
||
onChange={(e) => setCategory(e.target.value)}
|
||
style={fieldInput}
|
||
>
|
||
{REPORT_CATEGORIES.map(c =>
|
||
<option key={c.id} value={c.id} style={{ background: '#0f1326' }}>
|
||
{c.label}
|
||
</option>
|
||
)}
|
||
</select>
|
||
</div>
|
||
<div style={{ marginBottom: 14 }}>
|
||
<div style={fieldLabel}>Подробности (необязательно)</div>
|
||
<textarea
|
||
value={text}
|
||
onChange={(e) => setText(e.target.value)}
|
||
placeholder="Опиши, что не так…"
|
||
rows={4}
|
||
style={{
|
||
...fieldInput,
|
||
resize: 'vertical',
|
||
lineHeight: 1.5,
|
||
}}
|
||
/>
|
||
</div>
|
||
{submitError && (
|
||
<div style={{
|
||
background: HUD.dangerBg,
|
||
border: `1px solid ${HUD.danger}`,
|
||
borderRadius: 10,
|
||
padding: '10px 14px',
|
||
marginBottom: 14,
|
||
color: HUD.danger, fontSize: 13, fontWeight: 600,
|
||
}}>
|
||
⚠️ {submitError}
|
||
</div>
|
||
)}
|
||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||
<button onClick={onClose} style={btn} disabled={submitting}>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
onClick={submit}
|
||
disabled={submitting}
|
||
style={btnDanger}
|
||
>
|
||
{submitting ? 'Отправка…' : '🚩 Отправить'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const InfoModal = ({ icon, title, text, onClose }) => (
|
||
<div onClick={onClose} style={modalBackdrop}>
|
||
<style>{HUD_KEYFRAMES}</style>
|
||
<div onClick={(e) => e.stopPropagation()} style={{
|
||
...modalCard, maxWidth: 420, textAlign: 'center', padding: '32px 28px',
|
||
}}>
|
||
<div style={{
|
||
fontSize: 56, marginBottom: 14,
|
||
animation: 'hudFloat 3s ease-in-out infinite',
|
||
}}>{icon}</div>
|
||
<div style={{
|
||
fontSize: 20, fontWeight: 800, color: HUD.text, marginBottom: 10,
|
||
letterSpacing: -0.3,
|
||
}}>
|
||
{title}
|
||
</div>
|
||
{text && (
|
||
<div style={{
|
||
fontSize: 13, color: HUD.textMuted, marginBottom: 22,
|
||
wordBreak: 'break-all', lineHeight: 1.5,
|
||
background: HUD.bgInput,
|
||
border: `1px solid ${HUD.border}`,
|
||
borderRadius: 10,
|
||
padding: '10px 14px',
|
||
}}>
|
||
{text}
|
||
</div>
|
||
)}
|
||
<button onClick={onClose} style={{ ...btnPrimary, minWidth: 140 }}>
|
||
Закрыть
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const NeedAuthModal = ({ action, onClose, onLogin }) => (
|
||
<div onClick={onClose} style={modalBackdrop}>
|
||
<style>{HUD_KEYFRAMES}</style>
|
||
<div onClick={(e) => e.stopPropagation()} style={{
|
||
...modalCard, maxWidth: 420, textAlign: 'center', padding: '32px 28px',
|
||
}}>
|
||
<div style={{
|
||
fontSize: 56, marginBottom: 14,
|
||
animation: 'hudFloat 3s ease-in-out infinite',
|
||
}}>🔒</div>
|
||
<div style={{
|
||
fontSize: 20, fontWeight: 800, color: HUD.text, marginBottom: 10,
|
||
letterSpacing: -0.3,
|
||
}}>
|
||
Нужна авторизация
|
||
</div>
|
||
<div style={{
|
||
fontSize: 14, color: HUD.textMuted, marginBottom: 22, lineHeight: 1.5,
|
||
}}>
|
||
Чтобы {action}, войдите в аккаунт Рублокса.
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}>
|
||
<button onClick={onClose} style={btn}>Отмена</button>
|
||
<button onClick={onLogin} style={btnPrimary}>
|
||
🚀 Войти
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const modalBackdrop = {
|
||
position: 'fixed', inset: 0, zIndex: 2100,
|
||
background: 'rgba(7,10,20,0.78)',
|
||
backdropFilter: 'blur(8px)',
|
||
WebkitBackdropFilter: 'blur(8px)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
|
||
fontFamily: HUD.font,
|
||
animation: 'hudFadeIn 200ms ease',
|
||
};
|
||
|
||
const modalCard = {
|
||
background: HUD.bgPanel,
|
||
border: `1px solid ${HUD.border}`,
|
||
borderRadius: 18,
|
||
width: '100%',
|
||
color: HUD.text,
|
||
overflow: 'hidden',
|
||
boxShadow: '0 24px 60px rgba(0,0,0,0.55), 0 0 0 1px rgba(51,87,255,0.08)',
|
||
animation: 'hudFadeInScale 240ms cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||
};
|
||
|
||
const iconCloseBtn = {
|
||
width: 32, height: 32,
|
||
border: '1px solid rgba(255,255,255,0.25)',
|
||
background: 'rgba(255,255,255,0.10)',
|
||
color: '#fff', borderRadius: 8,
|
||
fontSize: 20, fontWeight: 700, lineHeight: 1,
|
||
cursor: 'pointer', fontFamily: HUD.font,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
transition: 'all 150ms ease',
|
||
};
|
||
|
||
const fieldLabel = {
|
||
fontSize: 11, color: HUD.textMuted, marginBottom: 6,
|
||
textTransform: 'uppercase', letterSpacing: 0.8, fontWeight: 700,
|
||
};
|
||
|
||
const fieldInput = {
|
||
width: '100%', boxSizing: 'border-box',
|
||
background: HUD.bgInput,
|
||
color: HUD.text,
|
||
border: `1px solid ${HUD.border}`,
|
||
borderRadius: 10,
|
||
padding: '10px 12px',
|
||
fontSize: 14, fontFamily: HUD.font,
|
||
outline: 'none',
|
||
transition: 'border-color 150ms ease',
|
||
};
|
||
|
||
/**
|
||
* MobileAmmoBlock — компактный счётчик патронов в правом верхнем углу
|
||
* на мобиле + кнопка R перезарядки. ammo = { magazine, magazineMax,
|
||
* reserve, reloading, reloadProgress }.
|
||
*/
|
||
const MobileAmmoBlock = ({ ammo, onReload }) => {
|
||
if (!ammo) return null;
|
||
const reloading = ammo.reloading;
|
||
const progress = Math.max(0, Math.min(1, ammo.reloadProgress || 0));
|
||
return (
|
||
<div data-mobile-hud="ammo" style={{
|
||
position: 'absolute',
|
||
top: 8, right: 56, // оставляем 48px справа под fullscreen-кнопку
|
||
display: 'flex', alignItems: 'center', gap: 8,
|
||
padding: '6px 10px',
|
||
background: 'rgba(15, 19, 38, 0.78)',
|
||
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||
borderRadius: 12,
|
||
backdropFilter: 'blur(20px)',
|
||
WebkitBackdropFilter: 'blur(20px)',
|
||
zIndex: 30,
|
||
color: '#f1f5fb',
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
pointerEvents: 'auto',
|
||
}}>
|
||
<div style={{ fontSize: 14, opacity: 0.7 }}>🔫</div>
|
||
<div style={{
|
||
fontSize: 18, fontWeight: 800, lineHeight: 1,
|
||
letterSpacing: 0.3, minWidth: 44, textAlign: 'center',
|
||
}}>
|
||
{reloading
|
||
? <span style={{ opacity: 0.7 }}>{Math.round(progress * 100)}%</span>
|
||
: <><span>{ammo.magazine}</span>
|
||
<span style={{ fontSize: 12, opacity: 0.55, fontWeight: 700 }}>
|
||
{' /' + ammo.magazineMax}
|
||
</span>
|
||
</>}
|
||
</div>
|
||
<button
|
||
onClick={onReload}
|
||
onTouchStart={(e) => { e.stopPropagation(); onReload(); }}
|
||
style={{
|
||
width: 30, height: 30,
|
||
borderRadius: 8,
|
||
background: reloading
|
||
? 'rgba(255, 200, 87, 0.30)'
|
||
: 'rgba(51, 87, 255, 0.30)',
|
||
border: '1px solid rgba(255, 255, 255, 0.20)',
|
||
color: '#fff',
|
||
fontSize: 13, fontWeight: 800,
|
||
cursor: 'pointer',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
padding: 0, fontFamily: 'inherit',
|
||
WebkitTapHighlightColor: 'transparent',
|
||
}}
|
||
title="Перезарядка"
|
||
>
|
||
R
|
||
</button>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* ChatToggleButton — круглая кнопка-иконка чата (Roblox-style).
|
||
*
|
||
* Дизайн (см. скрин exe-плеера 2026-05-27):
|
||
* • Синий градиентный круг 44×44 с обводкой
|
||
* • Внутри — белая иконка «облако чата»
|
||
* • Красная точка-индикатор непрочитанных в правом-верхнем
|
||
* • Бейдж «T» в правом-нижнем углу — горячая клавиша
|
||
* • Hover: лёгкий glow + scale
|
||
* • Active: зелёный фон (значит чат открыт)
|
||
*/
|
||
const ChatToggleButton = ({ active, onClick }) => {
|
||
const [hover, setHover] = React.useState(false);
|
||
const bg = active
|
||
? 'linear-gradient(135deg, #22d97a 0%, #0f9d56 100%)'
|
||
: 'linear-gradient(135deg, #3357ff 0%, #1f3acc 100%)';
|
||
const glow = active
|
||
? '0 8px 20px rgba(34, 217, 122, 0.45)'
|
||
: (hover
|
||
? '0 8px 22px rgba(51, 87, 255, 0.55)'
|
||
: '0 4px 14px rgba(51, 87, 255, 0.35)');
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
onMouseEnter={() => setHover(true)}
|
||
onMouseLeave={() => setHover(false)}
|
||
title="Чат (T)"
|
||
style={{
|
||
position: 'relative',
|
||
width: 60, height: 60,
|
||
borderRadius: '50%',
|
||
background: bg,
|
||
border: '2px solid rgba(255, 255, 255, 0.12)',
|
||
cursor: 'pointer',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
boxShadow: glow,
|
||
transform: hover ? 'scale(1.05)' : 'scale(1)',
|
||
transition: 'transform 150ms ease, box-shadow 150ms ease, background 200ms ease',
|
||
padding: 0,
|
||
}}
|
||
>
|
||
{/* Иконка чата (облако) */}
|
||
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="#ffffff"
|
||
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||
</svg>
|
||
{/* Красная точка-уведомление непрочитанных (статичная — пока без счётчика) */}
|
||
<span style={{
|
||
position: 'absolute',
|
||
top: 4, right: 6,
|
||
width: 12, height: 12,
|
||
borderRadius: '50%',
|
||
background: '#ff3a4e',
|
||
border: '2px solid rgba(15, 19, 28, 0.95)',
|
||
boxShadow: '0 0 6px rgba(255, 58, 78, 0.6)',
|
||
}} />
|
||
{/* Бейдж "T" — горячая клавиша */}
|
||
<span style={{
|
||
position: 'absolute',
|
||
bottom: -2, right: -2,
|
||
width: 22, height: 22,
|
||
borderRadius: '50%',
|
||
background: 'rgba(15, 19, 28, 0.95)',
|
||
border: '2px solid rgba(255, 255, 255, 0.18)',
|
||
color: '#ffffff',
|
||
fontSize: 11,
|
||
fontWeight: 900,
|
||
fontFamily: 'Consolas, "Roboto Mono", monospace',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
letterSpacing: 0,
|
||
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.4)',
|
||
}}>T</span>
|
||
</button>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* TopDropdown — выпадающее меню из верхнего бара (Roblox-style).
|
||
* Не модалка, не паузит игру. Закрывается по клику вне или по любому
|
||
* выбранному пункту.
|
||
*/
|
||
const TopDropdown = ({
|
||
meta, vote, likes, dislikes,
|
||
onClose, onLike, onDislike,
|
||
onShare, onReport, onBugReport, onRespawn, onExit,
|
||
isTouch = false,
|
||
}) => {
|
||
const totalVotes = (likes || 0) + (dislikes || 0);
|
||
const ratingPct = totalVotes > 0 ? Math.round(100 * likes / totalVotes) : null;
|
||
|
||
// Закрытие по клику вне (event capture на документе)
|
||
useEffect(() => {
|
||
const onDocClick = (e) => {
|
||
if (!e.target.closest?.('[data-kubikon-top-dropdown]')
|
||
&& !e.target.closest?.('[data-kubikon-top-menu-btn]')) {
|
||
onClose();
|
||
}
|
||
};
|
||
// Регистрируем на следующий тик чтобы клик-открытие не закрыл сразу
|
||
const t = setTimeout(() => {
|
||
document.addEventListener('mousedown', onDocClick);
|
||
}, 0);
|
||
return () => {
|
||
clearTimeout(t);
|
||
document.removeEventListener('mousedown', onDocClick);
|
||
};
|
||
}, [onClose]);
|
||
|
||
return (
|
||
<div
|
||
data-kubikon-top-dropdown
|
||
style={{
|
||
position: 'absolute',
|
||
top: 'calc(100% + 8px)',
|
||
left: 0,
|
||
width: 280,
|
||
background: HUD.bgPanel,
|
||
backdropFilter: 'blur(24px)',
|
||
WebkitBackdropFilter: 'blur(24px)',
|
||
border: `1px solid ${HUD.border}`,
|
||
borderRadius: 16,
|
||
padding: 8,
|
||
zIndex: 1200,
|
||
boxShadow: '0 16px 40px rgba(0,0,0,0.55), 0 0 0 1px rgba(51,87,255,0.10)',
|
||
fontFamily: HUD.font,
|
||
color: HUD.text,
|
||
animation: 'hudFadeInScale 200ms cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||
transformOrigin: 'top left',
|
||
maxHeight: 'calc(100vh - 120px)',
|
||
overflowY: 'auto',
|
||
}}
|
||
>
|
||
{/* Заголовок: thumbnail + имя игры + рейтинг */}
|
||
{meta && (
|
||
<div style={{
|
||
padding: '4px 4px 12px',
|
||
borderBottom: `1px solid ${HUD.border}`,
|
||
marginBottom: 6,
|
||
display: 'flex', gap: 10, alignItems: 'center',
|
||
}}>
|
||
<div style={{
|
||
width: 48, height: 48, borderRadius: 10,
|
||
flexShrink: 0,
|
||
background: meta.thumbnail
|
||
? `url(${meta.thumbnail}) center/cover`
|
||
: HUD.gradientBrand,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 22,
|
||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||
}}>
|
||
{!meta.thumbnail && '🎮'}
|
||
</div>
|
||
<div style={{ minWidth: 0, flex: 1 }}>
|
||
<div style={{
|
||
fontSize: 14, fontWeight: 800, color: HUD.text,
|
||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||
letterSpacing: -0.2,
|
||
}}>{meta.title}</div>
|
||
{ratingPct != null ? (
|
||
<div style={{
|
||
fontSize: 11, color: HUD.textMuted, marginTop: 3,
|
||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||
}}>
|
||
<span style={{
|
||
color: ratingPct >= 70 ? HUD.success
|
||
: ratingPct >= 40 ? '#ffc857' : HUD.danger,
|
||
fontWeight: 800,
|
||
}}>⭐ {ratingPct}%</span>
|
||
<span style={{ color: HUD.textDim }}>
|
||
{(likes || 0) + (dislikes || 0)} {(likes || 0) + (dislikes || 0) === 1 ? 'голос' : 'голосов'}
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<div style={{ fontSize: 11, color: HUD.textDim, marginTop: 3 }}>
|
||
Ещё нет оценок
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Голосование */}
|
||
<div style={{ display: 'flex', gap: 6, padding: '4px 4px 6px' }}>
|
||
<button onClick={onLike} style={voteBtnStyle(vote === 'like', 'like')}>
|
||
<span style={{ fontSize: 16 }}>👍</span>
|
||
<span>{likes || 0}</span>
|
||
</button>
|
||
<button onClick={onDislike} style={voteBtnStyle(vote === 'dislike', 'dislike')}>
|
||
<span style={{ fontSize: 16 }}>👎</span>
|
||
<span>{dislikes || 0}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<DropdownDivider />
|
||
|
||
{/* Действия */}
|
||
<DropdownItem icon="🔄" label="Переродиться" onClick={onRespawn} accent />
|
||
<DropdownItem icon="🔗" label="Поделиться" onClick={onShare} />
|
||
<DropdownItem icon="🚩" label="Пожаловаться" onClick={onReport} />
|
||
<DropdownItem icon="🐛" label="Сообщить об ошибке" onClick={onBugReport} />
|
||
|
||
{/* Горячие клавиши — только на десктопе. На мобиле/планшете
|
||
клавиатуры нет, эта секция бесполезна и занимает место. */}
|
||
{!isTouch && (
|
||
<>
|
||
<DropdownDivider />
|
||
<div style={{
|
||
padding: '8px 12px 6px',
|
||
fontSize: 10, color: HUD.textDim,
|
||
textTransform: 'uppercase', letterSpacing: 1.2, fontWeight: 800,
|
||
}}>
|
||
⌨️ Горячие клавиши
|
||
</div>
|
||
<div style={{
|
||
padding: '0 12px 8px',
|
||
display: 'flex', flexDirection: 'column', gap: 6,
|
||
fontSize: 11.5, color: HUD.textMuted,
|
||
}}>
|
||
<HotkeyRow keys={['W', 'A', 'S', 'D']} label="Движение" />
|
||
<HotkeyRow keys={['Space']} label="Прыжок" />
|
||
<HotkeyRow keys={['Shift']} label="Бег" />
|
||
<HotkeyRow keys={['Мышь']} label="Камера" />
|
||
<HotkeyRow keys={['ЛКМ']} label="Атака / стрельба" />
|
||
<HotkeyRow keys={['R']} label="Перезарядка" />
|
||
<HotkeyRow keys={['1', '…', '5']} label="Слот инвентаря" />
|
||
<HotkeyRow keys={['Q']} label="Бросить предмет" />
|
||
<HotkeyRow keys={['C']} label="1-е / 3-е лицо" />
|
||
<HotkeyRow keys={['Tab']} label="Курсор для GUI" />
|
||
<HotkeyRow keys={['Esc']} label="Меню / отдать мышь" />
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<DropdownDivider />
|
||
<DropdownItem icon="🚪" label="Выйти из игры" onClick={onExit} danger />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const DropdownDivider = () => (
|
||
<div style={{
|
||
height: 1,
|
||
background: `linear-gradient(90deg, transparent 0%, ${HUD.border} 50%, transparent 100%)`,
|
||
margin: '6px 4px',
|
||
}} />
|
||
);
|
||
|
||
const HotkeyRow = ({ keys, label }) => (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<span style={{ flex: 1, color: HUD.textMuted, fontWeight: 600 }}>{label}</span>
|
||
<span style={{ display: 'flex', gap: 3 }}>
|
||
{keys.map((k, i) => (
|
||
<kbd key={i} style={{
|
||
background: 'rgba(255,255,255,0.06)',
|
||
border: `1px solid ${HUD.border}`,
|
||
borderRadius: 4,
|
||
padding: '2px 7px',
|
||
fontSize: 10, fontFamily: HUD.font, fontWeight: 700,
|
||
color: HUD.text,
|
||
minWidth: 18, textAlign: 'center',
|
||
boxShadow: '0 1px 0 rgba(255,255,255,0.04) inset, 0 1px 2px rgba(0,0,0,0.4)',
|
||
}}>{k}</kbd>
|
||
))}
|
||
</span>
|
||
</div>
|
||
);
|
||
|
||
const DropdownItem = ({ icon, label, onClick, danger, accent }) => {
|
||
const [hovered, setHovered] = useState(false);
|
||
const hoverBg = danger
|
||
? HUD.dangerBg
|
||
: accent
|
||
? HUD.accentBg
|
||
: 'rgba(255,255,255,0.06)';
|
||
const color = danger ? HUD.danger
|
||
: accent ? '#fff'
|
||
: HUD.text;
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
onMouseEnter={() => setHovered(true)}
|
||
onMouseLeave={() => setHovered(false)}
|
||
style={{
|
||
width: '100%', padding: '10px 12px',
|
||
background: hovered ? hoverBg : 'transparent',
|
||
border: 'none',
|
||
borderRadius: 10,
|
||
color,
|
||
fontSize: 13, fontWeight: 700,
|
||
cursor: 'pointer', fontFamily: HUD.font,
|
||
textAlign: 'left',
|
||
display: 'flex', alignItems: 'center', gap: 12,
|
||
transition: 'all 150ms ease',
|
||
letterSpacing: 0.1,
|
||
}}
|
||
>
|
||
<span style={{
|
||
fontSize: 16, width: 24, textAlign: 'center',
|
||
filter: hovered ? 'none' : 'grayscale(0.15)',
|
||
}}>{icon}</span>
|
||
<span style={{ flex: 1 }}>{label}</span>
|
||
{hovered && (
|
||
<span style={{ fontSize: 12, color: HUD.textDim }}>→</span>
|
||
)}
|
||
</button>
|
||
);
|
||
};
|
||
|
||
const voteBtnStyle = (active, kind) => {
|
||
const activeBg = kind === 'like'
|
||
? 'linear-gradient(135deg, #22d97a 0%, #0f9d56 100%)'
|
||
: 'linear-gradient(135deg, #ff6f7a 0%, #c0303f 100%)';
|
||
return {
|
||
flex: 1, padding: '10px 8px',
|
||
background: active ? activeBg : 'rgba(255,255,255,0.04)',
|
||
border: `1px solid ${active ? 'transparent' : HUD.border}`,
|
||
borderRadius: 10,
|
||
fontSize: 14, fontWeight: 800,
|
||
color: active ? '#fff' : HUD.text,
|
||
cursor: 'pointer', fontFamily: HUD.font,
|
||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||
transition: 'all 200ms ease',
|
||
boxShadow: active
|
||
? (kind === 'like'
|
||
? '0 6px 16px rgba(34,217,122,0.40)'
|
||
: '0 6px 16px rgba(255,111,122,0.40)')
|
||
: 'none',
|
||
};
|
||
};
|
||
|
||
export default KubikonPlayer;
|