player/src/KubikonPlayer/KubikonPlayer.jsx
min eb6430182b
All checks were successful
CI / Lint (pull_request) Successful in 57s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
CI / Build (pull_request) Successful in 1m34s
CI / Secret scan (pull_request) Successful in 2m30s
CI / PR size check (pull_request) Successful in 7s
feat(14): Vehicle System V1+V2 — порт в плеер
Фича-парность со студией (задача 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>
2026-06-03 02:25:15 +03:00

2431 lines
120 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;