studio/src/preview-player/KubikonPlayer.jsx
МИН 61fba4e174
Some checks failed
CI / Lint + Format (push) Failing after 32s
CI / Build (push) Failing after 37s
CI / Secret scan (push) Failing after 37s
CI / PR size check (push) Has been skipped
fix: починка билда + studio.rublox.pro инфра
Большой консолидирующий коммит после поднятия studio.rublox.pro (28 мая 2026).
Содержит изменения которые делались в процессе подготовки прод-окружения:

Фиксы импортов после выноса из minecraftia:
- Массовая замена путей ../../components → ../components (40+ файлов в src/community/, src/admin-preview/)
- Замена ../KubikonEditor/ → ../editor/, ../KubikonStudio/ → ../community/, ../AdminPreview/ → ../admin-preview/
- API.js скопирован из минки целиком (было 8 экспортов, стало 312)
- Добавлены PLAYER_URL, MyButton_1, недостающие компоненты
- Заменены require() на статические ES-imports в BabylonScene, PrimitiveManager, GameRuntime (Vite не поддерживает CJS require)

Структура ассетов:
- public/kubikon-templates/ → public/assets/kubikon-templates/
- public/kubikon-learn/ → public/assets/kubikon-learn/
- (код искал в /assets/, файлы лежали без /assets/)

Навигация роутов внутри студии:
- /kubikon-studio/docs → /docs (90+ навигационных вызовов sed-replaced)
- /kubikon-editor/X → /edit/X, /kubikon/play/X → /play/X, /kubikon/gd/X → /gd/X

UI:
- Новый компонент StudioHeader (61px, как в минке) + копия favicon
- WithHeader wrapper в App.jsx для всех страниц кроме fullscreen-редактора/плеера
- SSO ticket-flow в AuthContext (auto-redeem #ticket= при загрузке)
- Тёмная тема карточек игр в ВИКИ (фон #1c2231 вместо #fff, картинка впритык)

Документация:
- docs/ONBOARDING.md — путь нового контрибьютора от нуля до PR
- docs/TUTORIAL_ADD_SCRIPT_API.md — как добавить game.* API
- API_USAGE.md — список эндпоинтов backend
- README в подпапках engine/, engine/terrain/, engine/voxel/, engine/robloxterrain/, engine/types/

.gitignore:
- public/wiki/ исключён (73МБ PNG, будут на CDN отдельной задачей)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 05:01:13 +03:00

2110 lines
101 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 '../editor/engine/BabylonScene';
import { attachConsoleHook, devlogReset } from '../editor/engine/devlog';
import { MultiplayerSync } from '../editor/engine/MultiplayerSync';
import { REALTIME_WS } from '../api/API';
import GameHud from '../editor/GameHud';
import GuiOverlay from '../editor/GuiOverlay';
import KubikonLeaderboard, { formatTimeMs } from '../components/KubikonLeaderboard/KubikonLeaderboard';
import KubikonPerfOverlay from '../components/KubikonPerfOverlay/KubikonPerfOverlay';
import Hotbar from '../editor/Hotbar';
import PlayerHud from '../editor/PlayerHud';
import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton';
import KubikonChatPanel from './KubikonChatPanel';
import { useAuth } from '../auth/AuthContext.jsx';
import RublocsLogo from '../components/RublocsLogo/RublocsLogo';
import useDeviceType from '../hooks/useDeviceType';
import KubikonMobileControls from './KubikonMobileControls';
/**
* Палитра тёмного игрового HUD Рублокса.
* Подобрана так, чтобы UI хорошо читался поверх любой 3D-сцены, но
* визуально совпадал с синим акцентом Рублокса (#3357ff).
*/
const HUD = {
bg: 'rgba(10, 14, 26, 0.92)',
bgGlass: 'rgba(15, 19, 38, 0.78)',
bgPanel: 'rgba(20, 24, 45, 0.94)',
bgInput: 'rgba(15, 19, 38, 0.85)',
border: 'rgba(255, 255, 255, 0.10)',
borderHover: 'rgba(255, 255, 255, 0.18)',
borderAcc: 'rgba(51, 87, 255, 0.55)',
text: '#f1f5fb',
textMuted: 'rgba(241, 245, 251, 0.62)',
textDim: 'rgba(241, 245, 251, 0.42)',
accent: '#3357ff',
accentBg: 'rgba(51, 87, 255, 0.20)',
accentSoft: 'rgba(51, 87, 255, 0.12)',
success: '#22d97a',
successBg: 'rgba(34, 217, 122, 0.20)',
danger: '#ff6f7a',
dangerBg: 'rgba(255, 111, 122, 0.18)',
gradientBrand: 'linear-gradient(135deg, #3357ff 0%, #1e2da5 100%)',
gradientHot: 'linear-gradient(135deg, #ec4899 0%, #ef4444 50%, #f59e0b 100%)',
font: '"Roboto Condensed", system-ui, -apple-system, sans-serif',
};
const HUD_KEYFRAMES = `
@keyframes hudFadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
@keyframes hudFadeInScale { from { opacity: 0; transform: scale(0.94); } to { opacity: 1; transform: scale(1); } }
@keyframes hudPulseRing {
0% { box-shadow: 0 0 0 0 rgba(51, 87, 255, 0.55); }
70% { box-shadow: 0 0 0 12px rgba(51, 87, 255, 0); }
100% { box-shadow: 0 0 0 0 rgba(51, 87, 255, 0); }
}
@keyframes hudSpin { to { transform: rotate(360deg); } }
@keyframes hudFloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
`;
/**
* KubikonPlayer — полноэкранная страница для прохождения опубликованной игры.
* URL: /play/:id
*
* Не редактор. Только Babylon-canvas + UI игры (HUD, hot-bar, GUI-оверлей).
* Снизу — компактная панель кнопок: Назад / Лайк / Жалоба / Поделиться.
*/
const KubikonPlayer = () => {
const { id } = useParams();
const navigate = useNavigate();
const projectId = Number(id);
const { isAuthenticated, isLoading: authLoading } = useAuth();
const device = useDeviceType();
// На телефонах И планшетах — тач-управление. На десктопе обычное.
const isTouch = device.isPhone || device.isTablet;
// Ориентация — для портрет-promp'та на телефонах
const [isPortrait, setIsPortrait] = useState(
typeof window !== 'undefined' && window.innerHeight > window.innerWidth,
);
useEffect(() => {
const onResize = () => {
setIsPortrait(window.innerHeight > window.innerWidth);
};
window.addEventListener('resize', onResize);
window.addEventListener('orientationchange', onResize);
return () => {
window.removeEventListener('resize', onResize);
window.removeEventListener('orientationchange', onResize);
};
}, []);
// Только зарегистрированные. Если гость / без токена — на логин.
useEffect(() => {
if (authLoading) return;
if (!isAuthenticated) {
navigate('/login', { replace: true });
}
}, [isAuthenticated, authLoading, navigate]);
const canvasRef = useRef(null);
const sceneRef = useRef(null);
const viewportRef = useRef(null);
const hudRef = useRef(null);
/** Colyseus Room (только если игра мультиплеерная). */
const roomRef = useRef(null);
/** MultiplayerSync (мост между room и Babylon-сценой). */
const mpSyncRef = useRef(null);
/** Выбранный R15-скин текущего игрока (из rublox_equipped_skin).
* Грузится при старте, уходит в мультиплеер как modelType. */
const skinFolderRef = useRef('skin_bacon-hair');
const [meta, setMeta] = useState(null); // { title, description, user_id, ... }
const [forbidden, setForbidden] = useState(false);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
// Раньше была стартовая заглушка «тапни чтобы начать» — убрали по
// фидбэку, она бесила. Теперь fullscreen опционально через кнопку
// в углу. Этот state остался для совместимости с handleMobileStart.
const [mobileStartTapped, setMobileStartTapped] = useState(true);
const [hp, setHp] = useState({ hp: 100, maxHp: 100 });
// Скрипт через game.hud.setVisible(false) полностью скрывает стандартный HUD.
const [stdHudVisible, setStdHudVisible] = useState(true);
const [ammo, setAmmo] = useState(null);
const [hurtFlash, setHurtFlash] = useState(0);
const [inventoryState, setInventoryState] = useState({ slots: [], activeIndex: 0 });
const [guiList, setGuiList] = useState([]);
const [isPlaying, setIsPlaying] = useState(false);
const [liked, setLiked] = useState(false);
const [likesCount, setLikesCount] = useState(0);
const [reportOpen, setReportOpen] = useState(false);
const [infoModal, setInfoModal] = useState(null); // { title, text, icon } | null
const [needAuthModal, setNeedAuthModal] = useState(null); // строка-описание действия | null
// Чат свёрнут по умолчанию — открывается кнопкой ☰ в верхнем баре.
const [chatOpen, setChatOpen] = useState(false);
// Roblox-style выпадашка из верхнего бара (☰)
const [topMenuOpen, setTopMenuOpen] = useState(false);
// Голос пользователя: 'like' | 'dislike' | null
const [vote, setVote] = useState(null);
const [dislikesCount, setDislikesCount] = useState(0);
// === Лидерборд / таймер прохождения ===
const [leaderboardVisible, setLeaderboardVisible] = useState(true);
const [timerMs, setTimerMs] = useState(0);
const [timerRunning, setTimerRunning] = useState(false);
const [leaderboardRefreshKey, setLeaderboardRefreshKey] = useState(0);
// Есть ли в игре таймер прохождения / рекорды (на не-ранерах нет)
const [leaderboardEnabled, setLeaderboardEnabled] = useState(false);
// Тост «+200 рейтинга за 1 место в таблице рекордов».
// Появляется после submitLeaderboard если бэк выдал rating_award текущему юзеру.
// null | { place: 1|2|3, amount: number }
const [ratingToast, setRatingToast] = useState(null);
const timerRafRef = useRef(null);
/** Кэш загруженного project_data для soft-restart игры. */
const initialStateRef = useRef(null);
// rAF-цикл обновления таймера в HUD (только когда таймер запущен)
useEffect(() => {
if (!timerRunning) return;
let cancelled = false;
const tick = () => {
if (cancelled) return;
const s = sceneRef.current;
if (s?.getTimerMs) {
setTimerMs(s.getTimerMs());
}
timerRafRef.current = requestAnimationFrame(tick);
};
timerRafRef.current = requestAnimationFrame(tick);
return () => {
cancelled = true;
if (timerRafRef.current) cancelAnimationFrame(timerRafRef.current);
};
}, [timerRunning]);
// Tab — переключение видимости таблицы лидеров
useEffect(() => {
const onKey = (e) => {
if (e.code === 'Tab' && !e.ctrlKey && !e.altKey) {
const tag = (e.target && e.target.tagName) || '';
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
e.preventDefault();
setLeaderboardVisible(v => !v);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
// Проверяем при монтировании: есть ли в проекте рекорды? Если да —
// игра-ранер (поддерживает таймер), показываем виджет лидерборда.
// Иначе — игра без таймера (зомби, песочница и т.п.) — скрываем.
useEffect(() => {
if (!projectId) return;
let active = true;
Kubikon3DApi.getLeaderboard(projectId, 1)
.then(r => {
if (!active) return;
const list = r.data?.records || [];
if (list.length > 0) setLeaderboardEnabled(true);
})
.catch(() => {});
return () => { active = false; };
}, [projectId]);
// Перехват Ctrl-комбинаций которые ломают игру (Ctrl+W = закрыть вкладку,
// Ctrl+R = reload, Ctrl+T/N — мешают). Большинство браузеров блокирует
// отмену системных шорткатов, но beforeunload даёт пользователю шанс
// подтвердить выход. Также превентим preventDefault на keydown для
// случаев когда фокус НЕ на window-уровне (Chrome иногда позволяет).
useEffect(() => {
const onKey = (e) => {
if (!e.ctrlKey && !e.metaKey) return;
// Список «опасных» в игре сочетаний — превентим
const dangerousCodes = ['KeyW', 'KeyR', 'KeyT', 'KeyN'];
if (dangerousCodes.includes(e.code)) {
e.preventDefault();
e.stopPropagation();
}
};
const onBeforeUnload = (e) => {
// Показываем подтверждение выхода — браузер не даст случайно
// закрыть вкладку Ctrl+W во время игры.
e.preventDefault();
e.returnValue = '';
return '';
};
window.addEventListener('keydown', onKey, { capture: true });
window.addEventListener('beforeunload', onBeforeUnload);
return () => {
window.removeEventListener('keydown', onKey, { capture: true });
window.removeEventListener('beforeunload', onBeforeUnload);
};
}, []);
/**
* Soft-restart игры: выходим из Play, перезагружаем оригинальный
* project_data (восстанавливая удалённые/скрытые скриптом примитивы),
* заходим в Play заново. Полностью сбрасывает игровое состояние —
* полезно после прохождения если игрок хочет улучшить рекорд.
*/
const handleRestartGame = useCallback(async () => {
const scene = sceneRef.current;
const initial = initialStateRef.current;
if (!scene || !initial) return;
try {
if (scene.isPlaying?.()) {
scene.exitPlayMode?.();
}
// Перезагрузка состояния — сбрасывает все примитивы/блоки/модели
await scene.loadFromState(initial);
// Сбросим UI-состояние таймера
setTimerMs(0);
setTimerRunning(false);
// Ждём кадр и заходим в Play
await new Promise((r) => setTimeout(r, 50));
scene.enterPlayMode?.();
} catch (e) {
console.warn('[KubikonPlayer] restart failed', e);
}
}, []);
// === Мультиплеер ===
/** 'idle' | 'connecting' | 'connected' | 'failed'. */
const [mpStatus, setMpStatus] = useState('idle');
/** Сообщение об ошибке (если 'failed'). */
const [mpError, setMpError] = useState(null);
/** Кол-во удалённых игроков в комнате (для UI индикатора). */
const [mpRemotePlayers, setMpRemotePlayers] = useState(0);
/** Код комнаты для UI «🔑 ABC123». */
const [mpRoomCode, setMpRoomCode] = useState('');
const userId = (() => {
try {
const t = localStorage.getItem('Authorization');
if (!t) return null;
const p = jwtDecode(t);
return p?.id || p?.user_id || null;
} catch (e) { return null; }
})();
// === Инициализация сцены и загрузка проекта ===
useEffect(() => {
if (!canvasRef.current) return;
// Dev-logging: на localhost console.* шлётся в devlog.txt
// для разработки. На проде запросы тихо игнорируются.
try { devlogReset(); attachConsoleHook(); } catch (e) {}
const scene = new BabylonScene(canvasRef.current);
scene.init();
// Тач-режим включаем ДО enterPlayMode — чтобы PlayerController
// создался без pointer-lock. На десктопе оставляем false.
try { scene.setTouchMode?.(isTouch); } catch (e) {}
sceneRef.current = scene;
// В плеере пол редактора не нужен — пользовательская сцена
// принесёт свой пол / блоки; сразу скрываем editorGround.
try { scene.setFloorEnabled(false); } catch (e) {}
// В плеере мы не редактируем — отключаем все инструменты редактора:
// блоки, модели, эраза. Иначе случайный клик мог поставить блок в мир.
scene._activeTool = 'select';
scene._activeBlockType = null;
scene._activeModelType = null;
// Флаг для нашего кода — на случай если потребуется проверка позже
scene._isPlayerOnly = true;
// === User-models API: критично для рендера воксельных моделей ===
// Без этих двух вызовов UserModelManager._api остаётся null и при
// загрузке проекта _loadModelData() тихо возвращает null для каждой
// воксельной модели в scene.userModels — игрок видит сцену БЕЗ
// воксельных моделей (кристаллы, животные, артефакты и т.д.).
// В редакторе это делается в KubikonEditor.jsx сразу после init().
scene.setUserModelsApi(Kubikon3DApi);
scene.setCurrentUserId(userId);
// projectId нужен для game.save.* (универсальные сейвы).
if (projectId) scene.setCurrentProjectId(projectId);
// game.hud.setVisible(false) скроет HP-бар/hotbar для своего меню
scene.setOnStdHudVisibilityChange?.((v) => setStdHudVisible(v));
// Колбэки HUD
scene.setOnPlayerHpChange?.((h) => {
setHp({ hp: h.hp, maxHp: h.maxHp });
if (h.damaged) setHurtFlash(Date.now());
});
scene.setOnPlayerDeath?.(() => {
setHp({ hp: 0, maxHp: 100 });
setTimeout(() => {
const s = sceneRef.current;
if (!s?.player) return;
s.player.healFull?.();
if (s.player._pos && s._spawnPoint) {
const sp = s._spawnPoint;
const halfH = s.player.HALF_H ?? 0.9;
s.player._pos.set(sp.x, sp.y + halfH + 0.2, sp.z);
s.player._vy = 0;
}
}, 2000);
});
scene.setOnAmmoChange?.((a) => setAmmo(a));
scene.setOnInventoryChange?.(() =>
setInventoryState(scene.getInventoryState?.() || { slots: [], activeIndex: 0 })
);
scene.setOnGuiChange?.(() => setGuiList(scene.getGuiElements?.() || []));
scene.setOnScriptHud?.((event) => hudRef.current?.handle?.(event));
// === Таймер прохождения для лидерборда ===
scene.setOnTimer?.(({ state, timeMs }) => {
// Если скрипт зовёт timer — игра поддерживает таблицу лидеров
if (state === 'start' || state === 'submit' || state === 'stop') {
setLeaderboardEnabled(true);
}
if (state === 'start') {
setTimerMs(0);
setTimerRunning(true);
} else if (state === 'stop') {
setTimerMs(timeMs);
setTimerRunning(false);
} else if (state === 'submit') {
setTimerMs(timeMs);
setTimerRunning(false);
// Шлём результат на сервер. В ответе бэк возвращает
// rating_awards = [{user_id, place, rating_amount}, ...] —
// если попал в топ-3 (и раньше за это место рейтинг не получал),
// показываем тост и обновляем глобальный счётчик профиля.
if (userId && projectId && timeMs > 0) {
Kubikon3DApi.submitLeaderboard(projectId, userId, timeMs)
.then((res) => {
setLeaderboardRefreshKey(k => k + 1);
const awards = res?.data?.rating_awards;
if (Array.isArray(awards)) {
const mine = awards.find(
(a) => Number(a.user_id) === Number(userId)
);
if (mine && mine.rating_amount > 0) {
setRatingToast({
place: mine.place,
amount: mine.rating_amount,
});
// авто-скрытие через 6 сек
setTimeout(() => setRatingToast(null), 6000);
}
}
})
.catch((e) => console.warn('[leaderboard] submit failed', e));
}
}
});
scene.setOnPlayChange?.((playing) => {
setIsPlaying(playing);
// ESC обрабатывается через pointerlockchange-перехват в плеере
// (см. отдельный useEffect ниже). Сюда мы попадаем только если
// exitPlayMode вызвался по другой причине — тогда просто открываем
// меню, чтобы пользователь мог выйти/вернуться, и пересоздаём Play
// в UI-cursor режиме.
if (!playing) {
setTimeout(() => {
const s = sceneRef.current;
if (!s) return;
s.enterPlayMode?.();
s.player?.setUiCursorMode?.(true);
}, 30);
setChatOpen(false);
setTopMenuOpen(true);
}
});
// Загружаем проект
(async () => {
try {
const res = await Kubikon3DApi.getProjectForPlay(projectId, userId);
const data = res.data;
setMeta(data);
setLikesCount(data.likes_count || 0);
setDislikesCount(data.dislikes_count || 0);
if (data.project_data) {
const parsed = JSON.parse(data.project_data);
initialStateRef.current = parsed;
await scene.loadFromState(parsed);
}
// Ждём пока Babylon реально загрузит и скомпилит все
// материалы/текстуры/GLB. Без этого при первом кадре
// половина мешей рисуется без текстур (chess-pattern fallback)
// и постепенно подтягивается — игрок видит «пустых зомби».
await new Promise((resolve) => {
if (!scene.scene) { resolve(); return; }
let done = false;
const finish = () => { if (!done) { done = true; resolve(); } };
scene.scene.executeWhenReady(finish);
// Safety-таймаут: 3с максимум, чтобы не залип в loading навсегда
// если что-то не загрузилось.
setTimeout(finish, 3000);
});
// === Персональный скин игрока ===
// Грузим выбранный скин из БД (rublox_equipped_skin) и
// применяем его к локальному игроку ДО enterPlayMode —
// тогда player.setModelType подхватит правильный скин.
// Этот же skinFolder уйдёт в мультиплеер как modelType,
// чтобы соперники видели наш реальный скин.
let mySkin = 'skin_bacon-hair';
if (userId) {
try {
const skinRes = await Kubikon3DApi.getEquippedSkin(userId);
const sf = skinRes?.data?.skin_folder;
if (sf && typeof sf === 'string') mySkin = sf;
} catch (e) {
// Сеть/ошибка — играем с дефолтным скином, не блокируем.
console.warn('[KubikonPlayer] equipped-skin load failed', e);
}
}
skinFolderRef.current = mySkin;
try { scene.setPlayerModelType?.(mySkin); } catch (e) {}
setLoading(false);
// Засчитываем плей
Kubikon3DApi.incrementPlay(projectId).catch(() => {});
// Запускаем игру сразу
setTimeout(() => {
scene.enterPlayMode?.();
// Если игра мультиплеерная — подключаемся к realtime-серверу.
// Делаем после enterPlayMode, чтобы scene.player уже был создан.
if (data.multiplayer) {
setTimeout(() => connectMultiplayer(data), 300);
}
}, 200);
} catch (e) {
if (e?.response?.status === 403) {
setForbidden(true);
} else {
setError(e?.message || 'Ошибка загрузки');
}
setLoading(false);
}
})();
// Проверим голос пользователя (like/dislike)
if (userId) {
Kubikon3DApi.getLikeStatus(projectId, userId)
.then(r => {
setVote(r.data?.vote || null);
setLiked(r.data?.vote === 'like');
})
.catch(() => {});
}
return () => {
// Сначала рвём мультиплеер — иначе sync будет тыкаться в disposed scene
try { scene.setMultiplayerSync?.(null); } catch (e) {}
if (mpSyncRef.current) {
try { mpSyncRef.current.dispose(); } catch (e) {}
if (mpSyncRef.current._countTimer) {
clearInterval(mpSyncRef.current._countTimer);
}
mpSyncRef.current = null;
}
if (roomRef.current) {
try { roomRef.current.leave(true); } catch (e) {}
roomRef.current = null;
}
try { scene.dispose(); } catch (e) {}
sceneRef.current = null;
};
}, [projectId, userId, navigate]);
// session_id живёт в течение одной игровой сессии; шлём его в heartbeat
const sessionId = useMemo(() => {
const a = new Uint32Array(4);
(window.crypto || window.msCrypto)?.getRandomValues?.(a);
return Array.from(a).map(x => x.toString(16).padStart(8, '0')).join('');
}, []);
// Heartbeat каждые 30 секунд для онлайн-метрики админки.
useEffect(() => {
if (!projectId || loading) return;
let alive = true;
const beat = () => {
if (!alive) return;
Kubikon3DApi.playHeartbeat(sessionId, projectId, userId).catch(() => {});
};
beat(); // первый сразу после загрузки
const t = setInterval(beat, 30_000);
return () => { alive = false; clearInterval(t); };
}, [projectId, userId, sessionId, loading]);
// Хоткеи 1-5 для слотов инвентаря.
// Babylon ловит ввод на canvas — слушаем в capture-phase на window
// и не привязываемся к isPlaying (state-флаг может быть ещё false на старте).
useEffect(() => {
const onKey = (e) => {
if (e.repeat) return;
const code = e.code || '';
if (code.startsWith('Digit')) {
const n = parseInt(code.slice(5), 10);
if (n >= 1 && n <= 5) sceneRef.current?.setActiveInventorySlot?.(n - 1);
} else if (code.startsWith('Numpad') && code.length === 7) {
const n = parseInt(code.slice(6), 10);
if (n >= 1 && n <= 5) sceneRef.current?.setActiveInventorySlot?.(n - 1);
}
};
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, []);
// ESC во время игры — открыть меню (без выхода из Play).
//
// Тонкость: в большинстве браузеров ESC, нажатый при активном
// pointer-lock'е, обрабатывается нативно — снимает lock БЕЗ keydown
// (или с keydown позже pointerlockchange). Поэтому keydown-listener
// не успевает выставить _uiCursorMode перед pointerlockchange.
//
// Решение: слушаем сам pointerlockchange. Когда lock теряется во время
// активной игры — это значит игрок нажал ESC, и нам нужно:
// 1) пометить player как UI-cursor mode (чтобы Babylon не сделал
// exitPlayMode → respawn),
// 2) открыть наше меню.
// Этот listener должен быть в capture-фазе, чтобы сработать раньше
// listener'а PlayerController.
useEffect(() => {
const onLockChange = () => {
const s = sceneRef.current;
if (!s || !s._isPlaying) return;
const locked = !!document.pointerLockElement;
// Lock потерян, мы НЕ в UI-cursor mode → пользователь нажал ESC
if (!locked && s.player && !s.player._uiCursorMode) {
// Синхронно ставим флаг — listener PlayerController сработает
// следующим и увидит true, не вызовет _onExitRequest.
s.player._uiCursorMode = true;
// Открываем меню в следующий тик (state-update React)
setChatOpen(false);
setTopMenuOpen(true);
}
};
// capture-фаза, чтобы успеть раньше PlayerController
document.addEventListener('pointerlockchange', onLockChange, true);
return () => document.removeEventListener('pointerlockchange', onLockChange, true);
}, []);
// Повторный ESC (когда меню уже открыто) — закрыть меню и вернуть
// мышь в игру.
useEffect(() => {
if (!topMenuOpen) return;
const onEsc = (e) => {
if (e.key !== 'Escape') return;
const s = sceneRef.current;
if (!s || !s._isPlaying) return;
setTopMenuOpen(false);
s.player?.setUiCursorMode?.(false);
};
window.addEventListener('keydown', onEsc, true);
return () => window.removeEventListener('keydown', onEsc, true);
}, [topMenuOpen]);
/** Переродиться — телепорт на spawn point с восстановлением hp. */
/**
* Подключение к мультиплеерной комнате этой игры.
* Если уже подключены — ничего не делаем.
* При успехе создаём MultiplayerSync, который двусторонне синхронизирует
* локального игрока (PlayerController) с сервером и рисует remote-игроков.
*
* Защита от race-conditions: если sceneRef ещё не готов или scene/player
* пропали (HMR во время загрузки), пробуем ещё несколько раз с интервалом
* 200мс, потом сдаёмся.
*/
const connectMultiplayer = useCallback(async (projectMeta, retries = 8) => {
if (mpSyncRef.current || roomRef.current) return;
const sceneObj = sceneRef.current;
const babylonScene = sceneObj?.scene;
const player = sceneObj?.player;
// Если что-то ещё не готово — повторяем попытку.
if (!sceneObj || !babylonScene || !player) {
if (retries > 0) {
setTimeout(() => connectMultiplayer(projectMeta, retries - 1), 200);
} else {
console.warn('[mp] connect aborted — scene/player not ready');
setMpStatus('failed');
setMpError('Сцена не готова');
}
return;
}
const tokenRaw = localStorage.getItem('Authorization') || '';
if (!tokenRaw) return;
setMpStatus('connecting');
setMpError(null);
try {
const client = new Client(REALTIME_WS);
// modelType — ПЕРСОНАЛЬНЫЙ скин этого игрока (из rublox_equipped_skin,
// загружен при старте в skinFolderRef). Сервер всё равно перепроверит
// скин по userId из JWT и при расхождении возьмёт значение из БД —
// так каждый игрок виден соперникам в своём реальном скине.
const modelType = skinFolderRef.current || 'skin_bacon-hair';
const room = await client.joinOrCreate('battle', {
projectId: projectMeta?.id || projectId,
token: tokenRaw,
modelType,
});
roomRef.current = room;
setMpRoomCode((room.roomId || '').slice(0, 6).toUpperCase());
const sync = new MultiplayerSync(
babylonScene, // Babylon Scene (взяли в начале функции)
room,
() => {
// Колбэк-источник позиции локального игрока
const p = sceneRef.current?.player?._pos;
const yaw = sceneRef.current?.player?._yaw;
if (!p) return null;
// p.y — это центр игрока (HALF_H выше пола под ногами).
// Передаём как есть, на клиенте при рендере других уберём HALF_H.
return { x: p.x, y: p.y || 0, z: p.z, yaw: yaw || 0 };
},
{
onLocalHit: (damage, hpVal, maxHpVal) => {
// Применяем урон в локальный PlayerController.
// Сервер — авторитет, синхронизируем hp напрямую,
// не используя takeDamage (он для локальных зомби,
// имеет i-frames и считает свой hp — может разойтись
// с сервером и пропустить смерть).
const p = sceneRef.current?.player;
if (p) {
const wasDead = p.hp <= 0;
p.hp = hpVal;
p.maxHp = maxHpVal;
// Звук «ой»
try { p._playHurtSound?.(); } catch (e) {}
// Если только что умерли — debris + спрятать модель
if (!wasDead && hpVal <= 0) {
try { p._spawnDeathDebris?.(); } catch (e) {}
if (p._modelRoot) p._modelRoot.setEnabled(false);
if (p._onDeath) {
try { p._onDeath(); } catch (e) {}
}
}
}
setHurtFlash(Date.now());
setHp({ hp: hpVal, maxHp: maxHpVal });
},
onKilled: (killerName) => {
console.log(`[mp] killed by ${killerName}`);
},
onRespawn: () => {
const s = sceneRef.current;
if (s?.player && s._spawnPoint) {
s.player.healFull?.();
const sp = s._spawnPoint;
const halfH = s.player.HALF_H ?? 0.9;
s.player._pos?.set?.(sp.x, sp.y + halfH + 0.2, sp.z);
if (s.player._vy != null) s.player._vy = 0;
}
},
onLog: (level, ...parts) => {
// eslint-disable-next-line no-console
console.log(`[mp:${level}]`, ...parts);
},
},
);
sync.start();
mpSyncRef.current = sync;
// Прокидываем sync в сцену, чтобы _handlePlayClick шёл и в мультиплеер тоже.
sceneRef.current?.setMultiplayerSync?.(sync);
// === Локальная смерть → сервер ===
// Когда наш PlayerController сам поставил hp=0 (от куклы Squid Game,
// ловушки, скриптовой смерти и т.п.) — серверу надо сообщить, чтобы
// он бродкастил 'kill' всем остальным. Иначе другие клиенты не
// увидят рассыпание нашего скина на кубики.
//
// Сервер защищён от повторных вызовов (isDead-guard), поэтому если
// умерли от выстрела — повторное 'die' проигнорируется.
try {
const p = sceneRef.current?.player;
if (p && typeof p.setOnDeath === 'function') {
p.setOnDeath(() => {
try { room.send('die', {}); } catch (e) { /* room закрыта */ }
});
}
} catch (e) {
console.warn('[mp] setOnDeath failed:', e);
}
// Обновляем счётчик remote-игроков для UI
const updateCount = () => {
setMpRemotePlayers(sync.remotePlayers.size);
};
// Простой таймер — раз в секунду
const countTimer = setInterval(updateCount, 1000);
sync._countTimer = countTimer;
room.onLeave(() => {
if (sync._countTimer) clearInterval(sync._countTimer);
setMpStatus('idle');
setMpRemotePlayers(0);
setMpRoomCode('');
});
setMpStatus('connected');
} catch (err) {
console.error('[mp] connect error:', err);
setMpStatus('failed');
setMpError(err?.message || String(err));
}
}, [projectId]);
/** Запросить fullscreen. Вызывается ИЗ user gesture (клик по кнопке),
* иначе браузер запретит. Orientation lock НЕ ставим — играть можно
* и в портрете, и в ландшафте. */
const handleMobileStart = useCallback(async () => {
const root = document.documentElement;
const req = root.requestFullscreen
|| root.webkitRequestFullscreen
|| root.mozRequestFullScreen
|| root.msRequestFullscreen;
if (req) {
try { await req.call(root); } catch (e) { /* отменено */ }
}
setMobileStartTapped(true);
}, []);
// При выходе со страницы — снимаем fullscreen / orientation lock,
// чтобы возврат в школу не остался залочен в landscape.
useEffect(() => {
return () => {
try {
if (document.fullscreenElement) document.exitFullscreen?.();
else if (document.webkitFullscreenElement) document.webkitExitFullscreen?.();
} catch (e) {}
try { window.screen?.orientation?.unlock?.(); } catch (e) {}
};
}, []);
const respawnPlayer = useCallback(() => {
const s = sceneRef.current;
if (s?.player && s._spawnPoint) {
s.player.healFull?.();
const sp = s._spawnPoint;
const halfH = s.player.HALF_H ?? 0.9;
s.player._pos?.set?.(sp.x, sp.y + halfH + 0.2, sp.z);
if (s.player._vy != null) s.player._vy = 0;
}
// Возвращаем мышь в игру
if (s) {
setTimeout(() => {
if (!s._isPlaying) s.enterPlayMode?.();
s.player?.setUiCursorMode?.(false);
}, 30);
}
}, []);
const handleVote = useCallback(async (kind /* 'like' | 'dislike' */) => {
if (!userId) {
setNeedAuthModal(kind === 'dislike' ? 'оценивать игры' : 'лайкать игры');
return;
}
try {
const res = await Kubikon3DApi.toggleLike(projectId, userId, kind);
setVote(res.data?.vote || null);
setLiked(res.data?.vote === 'like');
setLikesCount(res.data?.likes_count || 0);
setDislikesCount(res.data?.dislikes_count || 0);
} catch (e) { /* ignore */ }
}, [projectId, userId]);
const handleShare = useCallback(() => {
const url = window.location.href;
try { navigator.clipboard?.writeText(url); } catch (e) {}
setInfoModal({
icon: '🔗',
title: 'Ссылка скопирована',
text: url,
});
}, []);
const handleReportClick = useCallback(() => {
if (!userId) {
setNeedAuthModal('оставлять жалобы');
return;
}
setReportOpen(true);
}, [userId]);
if (forbidden) {
return <CenteredCard
icon="🔒"
title="Игра недоступна"
text="Эта игра ещё не одобрена модерацией или скрыта автором."
onBack={() => navigate('/')}
/>;
}
if (error) {
return <CenteredCard
icon="⚠️"
title="Ошибка"
text={error}
onBack={() => navigate('/')}
/>;
}
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 (вставленные через AssetManager) не
// показывались в плеере — 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 });
}}
/>
{/* Мобильное управление — на любых тач-устройствах,
и в портрете и в ландшафте (ранее был блок 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',
}}>
{/* Бургер-меню — выпадашка с действиями */}
<div style={{ position: 'relative' }}>
<button
data-kubikon-top-menu-btn
onClick={() => setTopMenuOpen(v => {
if (!v) setChatOpen(false);
return !v;
})}
style={{
...topbarBtn,
background: topMenuOpen
? HUD.gradientBrand
: HUD.bgGlass,
color: HUD.text,
borderColor: topMenuOpen ? 'transparent' : HUD.border,
boxShadow: topMenuOpen
? '0 8px 24px rgba(51,87,255,0.45)'
: '0 2px 8px rgba(0,0,0,0.4)',
}}
title="Меню"
>
<span style={{ fontSize: 16, marginRight: 6 }}></span>
Меню
</button>
{topMenuOpen && (
<TopDropdown
meta={meta}
vote={vote}
likes={likesCount}
dislikes={dislikesCount}
isTouch={isTouch}
onClose={() => setTopMenuOpen(false)}
onLike={() => { handleVote('like'); }}
onDislike={() => { handleVote('dislike'); }}
onShare={() => { setTopMenuOpen(false); handleShare(); }}
onReport={() => { setTopMenuOpen(false); handleReportClick(); }}
onBugReport={() => {
setTopMenuOpen(false);
document.querySelector('[data-kubikon-bug-btn]')?.click();
}}
onRespawn={() => { setTopMenuOpen(false); respawnPlayer(); }}
onExit={() => navigate('/')}
/>
)}
</div>
{/* Toggle чата */}
<button
onClick={() => setChatOpen(v => {
if (!v) setTopMenuOpen(false);
return !v;
})}
style={{
...topbarBtn,
padding: '8px 12px',
background: chatOpen
? 'linear-gradient(135deg, #22d97a 0%, #0f9d56 100%)'
: HUD.bgGlass,
color: HUD.text,
borderColor: chatOpen ? 'transparent' : HUD.border,
boxShadow: chatOpen
? '0 8px 24px rgba(34,217,122,0.40)'
: '0 2px 8px rgba(0,0,0,0.4)',
}}
title="Показать/скрыть чат"
>
<span style={{ fontSize: 16 }}>💬</span>
</button>
{/* Мультиплеер-индикатор (только если игра 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 || ''}`
: 'Мультиплеер'
}>
<span style={{ fontSize: 14 }}>🌐</span>
<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={() => navigate('/auth')}
/>
)}
{/* Плавающая кнопка баг-репорта (открывается из ESC-меню).
Сама кнопка скрыта — данные нужны только её модалке. */}
{!loading && (
<KubikonBugReportButton
bugType="player"
projectId={projectId}
bottomOffset={70}
rightOffset={16}
hidden
/>
)}
</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>
);
};
/**
* 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;