import React, { useEffect, useState, useRef, useCallback } from 'react'; import Icon from '../editor-shared/Icon'; import { STORYS_addres, USER_addres } from '../api/API'; const getToken = () => { try { return ( localStorage.getItem('player_jwt') || localStorage.getItem('Authorization') || '' ); } catch { return ''; } }; /** * GameMenu — Roblox-style центральная карточка меню в игре. * * Полная копия Godot/exe-меню (см. rublox-native/godot/scripts/ui/GameHud.gd:1304+): * • Карточка 880×620, тёмный полупрозрачный фон, скруглённые углы * • Верхний таб-бар: 5 вкладок (Участники / Настройки / Захваты / Жалоба / Помощь) * • Контент-зона (меняется по таб'у) * • Нижний 3-кнопочный ряд: L Покинуть / R Возродиться / Esc Продолжить * * Не модалка-pause: игра продолжается. ESC закрывает. * * props: * visible, onClose, onExit, onRespawn * gameId, gameTitle — для участников и жалобы * onTakeScreenshot — F12 (опционально) */ const HUD = { cardBg: 'rgba(15, 18, 35, 0.88)', border: 'rgba(255, 255, 255, 0.10)', text: '#f1f5fb', textDim: 'rgba(241, 245, 251, 0.55)', textMuted: 'rgba(241, 245, 251, 0.62)', accent: '#3357ff', accentAlt: '#22d97a', danger: '#ff6f7a', rowBg: 'rgba(255, 255, 255, 0.04)', rowBgHover: 'rgba(255, 255, 255, 0.08)', font: '"Inter", system-ui, -apple-system, sans-serif', }; const TABS = [ { id: 'people', icon: 'users', title: 'Участники' }, { id: 'settings', icon: 'settings', title: 'Настройки' }, { id: 'captures', icon: 'camera', title: 'Захваты' }, { id: 'report', icon: 'flag', title: 'Жалоба' }, { id: 'help', icon: 'info', title: 'Помощь' }, ]; export default function GameMenu({ visible, onClose, onExit, onRespawn, gameId, gameTitle, mySkin, // мой текущий скин (skin_bacon-hair, и т.п.) — берём из KubikonPlayer sceneRef, // ref на BabylonScene для применения настроек в реальном времени }) { const [activeTab, setActiveTab] = useState('people'); // ESC закрывает меню. Регистрируем в capture-фазе чтобы не конфликтовать // с pointer-lock логикой KubikonPlayer. useEffect(() => { if (!visible) return; const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); onClose(); } // L/R hotkeys как в Godot if (e.key === 'l' || e.key === 'L') onExit?.(); if (e.key === 'r' || e.key === 'R') { onRespawn?.(); onClose(); } }; window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true); }, [visible, onClose, onExit, onRespawn]); if (!visible) return null; return (
{ // Клик по фону (dim) — закрыть. По карточке — не закрывать. if (e.target === e.currentTarget) onClose(); }} >
{/* === Таб-бар === */} {/* === Контент === */}
{activeTab === 'people' && } {activeTab === 'settings' && } {activeTab === 'captures' && } {activeTab === 'report' && } {activeTab === 'help' && }
{/* === Нижний ряд кнопок === */} { onRespawn?.(); onClose(); }} onResume={onClose} />
); } // ════════════════════════════════════════════════════════════════════ // TabBar — верхний ряд из 5 вкладок с индикатором активной // ════════════════════════════════════════════════════════════════════ function TabBar({ activeTab, onTab }) { return (
{TABS.map((tab) => { const active = tab.id === activeTab; return ( ); })}
); } // ════════════════════════════════════════════════════════════════════ // BottomBar — 3 кнопки: L Покинуть / R Возродиться / Esc Продолжить // ════════════════════════════════════════════════════════════════════ function BottomBar({ onExit, onRespawn, onResume }) { return (
); } function ActionBtn({ hotkey, label, onClick, variant = 'ghost' }) { const [hover, setHover] = useState(false); const primary = variant === 'primary'; return ( ); } // ════════════════════════════════════════════════════════════════════ // TAB: УЧАСТНИКИ — сетка карточек игроков // ════════════════════════════════════════════════════════════════════ function TabPeople({ gameId, mySkin }) { const [players, setPlayers] = useState(null); const [err, setErr] = useState(null); // Состояния друзей. Узнаём один раз при открытии таба: // friendIds — уже друзья (status='accepted') // pendingOutIds — я отправил запрос (status='pending', from=me) // pendingInIds — мне отправили запрос (status='pending', to=me) const [friendIds, setFriendIds] = useState(new Set()); const [pendingOutIds, setPendingOutIds] = useState(new Set()); // pendingInIds пока не используем визуально (TODO: показать «принять») // const [pendingInIds, setPendingInIds] = useState(new Set()); useEffect(() => { if (!gameId) { setErr('Проект не определён'); return; } let cancelled = false; (async () => { try { const token = getToken(); const res = await fetch( `${STORYS_addres}/kubikon3d/projects/${gameId}/players`, { headers: token ? { Authorization: token } : {}, }, ); if (cancelled) return; if (!res.ok) { // Если эндпоинта нет (404) — показываем хотя бы себя. setPlayers([]); return; } const data = await res.json(); setPlayers(Array.isArray(data?.players) ? data.players : []); } catch (e) { if (!cancelled) setErr('Не удалось загрузить участников'); } })(); return () => { cancelled = true; }; }, [gameId]); // Грузим список друзей + запросы (требуют JWT). useEffect(() => { const token = getToken(); if (!token) return; let cancelled = false; (async () => { try { const [fr, reqs] = await Promise.all([ fetch(`${USER_addres}/api/v1/users/friends`, { headers: { Authorization: token }, }).then(r => r.ok ? r.json() : null), fetch(`${USER_addres}/api/v1/users/friends/requests`, { headers: { Authorization: token }, }).then(r => r.ok ? r.json() : null), ]); if (cancelled) return; if (fr?.friends) { setFriendIds(new Set(fr.friends.map(f => Number(f.user_id)))); } if (reqs) { const out = (reqs.outgoing || []).map(r => Number(r.to_user_id || r.user_id)); setPendingOutIds(new Set(out)); } } catch (e) { // тихо игнорим — кнопки покажутся в дефолтном состоянии } })(); return () => { cancelled = true; }; }, []); const handleSendRequest = useCallback(async (toUid) => { const token = getToken(); if (!token) return false; try { const res = await fetch(`${USER_addres}/api/v1/users/friends/request`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: token, }, body: JSON.stringify({ to_user_id: toUid }), }); if (res.ok) { const data = await res.json().catch(() => null); if (data?.auto_accepted) { // Встречный запрос — мы стали друзьями setFriendIds(prev => new Set(prev).add(toUid)); } else { setPendingOutIds(prev => new Set(prev).add(toUid)); } return true; } // 400 already_requested / already_friends — синхронизируем UI const err = await res.json().catch(() => null); if (err?.error === 'already_requested') { setPendingOutIds(prev => new Set(prev).add(toUid)); } else if (err?.error === 'already_friends') { setFriendIds(prev => new Set(prev).add(toUid)); } return false; } catch (e) { return false; } }, []); // Добавляем самого себя в начало списка const me = getMyProfile(); const combined = (() => { if (!players) return null; if (!me) return players; const myIdx = players.findIndex(p => Number(p.user_id) === Number(me.id)); if (myIdx >= 0) { // Бэк пока не отдаёт skin в /players. Подмешиваем свой mySkin // в свою же карточку из API. const myFromApi = { ...players[myIdx] }; if (!myFromApi.skin && (mySkin || me.skin)) { myFromApi.skin = mySkin || me.skin; } return [myFromApi, ...players.filter((_, i) => i !== myIdx)]; } return [{ user_id: me.id, username: me.username || 'Я', photo: me.photo || '', photo_thumb_b64: me.photo_thumb_b64 || '', // mySkin приходит из KubikonPlayer (skinFolderRef.current). // Это РЕАЛЬНЫЙ выбранный скин, в отличие от пустого me.skin. skin: mySkin || me.skin || '', }, ...players]; })(); return (
{/* Шапка */}
На сервере {combined && ( ● {combined.length} )}
{/* Сетка */}
{err && (
{err}
)} {!err && !combined && (
Загрузка участников...
)} {combined && combined.length === 0 && (
На сервере пока никого нет
)} {combined && combined.length > 0 && (
{combined.map((p, idx) => { const uid = Number(p.user_id || 0); const isFriend = friendIds.has(uid); const isPending = pendingOutIds.has(uid); return ( handleSendRequest(uid)} /> ); })}
)}
); } /** * FriendButton — кнопка «добавить в друзья» на карточке участника. * * Состояния: * • default — синий +, при hover ярче и scale 1.08 * • pending — иконка часов, серый/тёмный фон, не реагирует на повторный клик * • (друг — кнопка не рисуется вообще, см. PlayerCard) */ function FriendButton({ pending, onClick }) { const [hover, setHover] = useState(false); const [sending, setSending] = useState(false); const disabled = pending || sending; const handleClick = async (e) => { e.stopPropagation(); if (disabled || !onClick) return; setSending(true); try { await onClick(); } finally { setSending(false); } }; // Цвета по состоянию const bg = pending ? 'rgba(60, 65, 80, 0.92)' : (hover ? 'rgba(51, 87, 255, 0.95)' : 'rgba(51, 87, 255, 0.85)'); const iconColor = pending ? 'rgba(255, 255, 255, 0.70)' : '#ffffff'; const title = pending ? 'Запрос отправлен' : 'Добавить в друзья'; return ( ); } function PlayerCard({ player, isMe, isFriend, isPending, onAddFriend }) { const username = String(player.username || '?'); const color = colorForUser(Number(player.user_id || 0), username); // Аватар: 1) skin PNG (картинка персонажа — bacon/imposter/etc) — главный // 2) photo_thumb_b64 (аватар майнкрафтии, fallback) // 3) photo URL (старое поле — fallback) // 4) буква-инициал // // Скины лежат в /kubikon-assets/characters//avatar.png — это PNG // персонажа в полный рост. Совпадает с Godot/exe-плеером. let avatarUrl = null; let isSkin = false; if (player.skin && typeof player.skin === 'string') { // cache-bust обязателен: на 2026-05-27 фиксили 404 на этом пути, // браузеры успели закэшировать негативный ответ avatarUrl = `/kubikon-assets/characters/${player.skin}/avatar.png?v=2026_05_27`; isSkin = true; } else if (player.photo_thumb_b64) { avatarUrl = player.photo_thumb_b64.startsWith('data:') ? player.photo_thumb_b64 : `data:image/jpeg;base64,${player.photo_thumb_b64}`; } else if (player.photo && typeof player.photo === 'string') { // Если photo относительный — резолвим через API_BASE (текущий origin // на проде, vite-proxy в dev). const env = (typeof import.meta !== 'undefined' && import.meta.env) || {}; const apiBase = env.VITE_API_BASE || (typeof window !== 'undefined' ? window.location.origin : ''); avatarUrl = player.photo.startsWith('http') ? player.photo : `${apiBase}${player.photo.startsWith('/') ? '' : '/'}${player.photo}`; } return (
{/* Аватар-блок */}
{avatarUrl ? ( {username} { e.currentTarget.style.display = 'none'; }} style={{ width: '100%', height: '100%', // Скин — это персонаж в полный рост, нужно показать // целиком (contain). Аватарка майнкрафтии — обычно // квадратное фото, тоже хорошо смотрится в contain. objectFit: isSkin ? 'contain' : 'cover', userSelect: 'none', }} /> ) : ( {username.slice(0, 1).toUpperCase()} )} {/* Лёгкая виньетка снизу */}
{/* Кнопка «добавить в друзья» — НЕ показываем: 1) на своей карточке (isMe) 2) для гостей без user_id 3) если уже в друзьях (isFriend) */} {!isMe && Number(player.user_id) > 0 && !isFriend && ( )}
{/* Футер: имя + ник */}
{username}
@{username.toLowerCase()}
); } // ════════════════════════════════════════════════════════════════════ // TAB: НАСТРОЙКИ // ════════════════════════════════════════════════════════════════════ function TabSettings({ sceneRef }) { const [settings, setSettingsState] = useState(() => loadSettings()); // === Применение настроек к engine === // Все вызовы безопасны: optional chaining + try/catch. const applyVolume = useCallback((vol) => { // vol 0..10. Babylon Audio engine использует 0..1. try { const audio = sceneRef?.current?.audioManager; const v = vol / 10; if (audio?.setMasterVolume) audio.setMasterVolume(v); // Глобальный fallback — установить мастер-громкость Babylon const scene = sceneRef?.current?.scene; const engine = scene?.getEngine?.(); if (engine?.audioEngine) engine.audioEngine.setGlobalVolume?.(v); } catch (e) { /* ignore */ } }, [sceneRef]); const applyQuality = useCallback((q) => { // q 1..10. Меняем hardwareScaling (плотность пикселей рендера) и // качество теней. // // ВАЖНО про тени: // • 'soft' — PCF 1024-2048 (мягкие, плавные края) — то что выглядит // как «гладкие тени» в exe. Дефолт почти везде. // • 'hard' — резкие пиксельные тени без фильтрации (быстро, уродливо). // Используем только для q<=2. // • 'off' — без теней. // Раньше на q=5..7 ставился 'hard' — выглядело пиксельно (баг 2026-05-27). try { const scene = sceneRef?.current?.scene; const engine = scene?.getEngine?.(); if (engine?.setHardwareScalingLevel) { const lvl = q >= 10 ? 1.0 : q >= 8 ? 1.1 : q >= 6 ? 1.25 : q >= 4 ? 1.5 : q >= 2 ? 2.0 : 2.5; engine.setHardwareScalingLevel(lvl); } const bs = sceneRef?.current; if (bs?.setShadowQuality) { const shadow = q >= 3 ? 'soft' : q >= 2 ? 'hard' : 'off'; bs.setShadowQuality(shadow); } } catch (e) { /* ignore */ } }, [sceneRef]); const applyMaxFps = useCallback((fps) => { // 0 = без лимита (vsync auto). Babylon ограничивает через RAF — нативный // лимит ставится через engine.targetFps (если есть свойство). try { const engine = sceneRef?.current?.scene?.getEngine?.(); if (!engine) return; if (fps <= 0) { // Снимаем лимит if ('targetFps' in engine) delete engine.targetFps; engine.runRenderLoop && engine._targetFps !== undefined && (engine._targetFps = 0); } else { engine._targetFps = fps; } } catch (e) { /* ignore */ } }, [sceneRef]); const applyShowFps = useCallback((on) => { // Создаём/удаляем простой div-overlay с FPS из engine. const existing = document.getElementById('rublox-fps-overlay'); if (!on) { existing?.remove(); if (window.__rubloxFpsRaf) { cancelAnimationFrame(window.__rubloxFpsRaf); window.__rubloxFpsRaf = null; } return; } if (existing) return; const el = document.createElement('div'); el.id = 'rublox-fps-overlay'; el.style.cssText = [ 'position: fixed', 'top: 8px', 'right: 12px', 'z-index: 5000', 'background: rgba(15, 19, 28, 0.85)', 'color: #22d97a', 'padding: 4px 10px', 'border-radius: 8px', 'font: 700 14px Consolas, "Roboto Mono", monospace', 'pointer-events: none', 'border: 1px solid rgba(255,255,255,0.08)', ].join(';'); document.body.appendChild(el); const loop = () => { try { const engine = sceneRef?.current?.scene?.getEngine?.(); const fps = engine?.getFps?.() || 0; el.textContent = `${Math.round(fps)} FPS`; } catch {} window.__rubloxFpsRaf = requestAnimationFrame(loop); }; window.__rubloxFpsRaf = requestAnimationFrame(loop); }, [sceneRef]); const applyMouseSens = useCallback((v) => { // 1..10 → MOUSE_SENSITIVITY 0.0010 .. 0.0050 (дефолт 0.0025). try { const player = sceneRef?.current?.player; if (!player) return; const base = 0.0025; // 5 = base, 10 = ×2, 1 = ×0.4 (линейно) const k = v <= 5 ? (0.4 + 0.12 * (v - 1)) : (1.0 + 0.20 * (v - 5)); player.MOUSE_SENSITIVITY = base * k; } catch (e) { /* ignore */ } }, [sceneRef]); const applyInvertCamera = useCallback((on) => { // Базовая реализация: меняем знак pitch-вклада через флаг на player. // PlayerController читает _invertCamera в onMouseMove (если реализовано), // иначе настройка сохранится и применится при следующей версии engine. try { const player = sceneRef?.current?.player; if (player) player._invertCamera = !!on; } catch (e) { /* ignore */ } }, [sceneRef]); const applyCameraMode = useCallback((mode) => { // 'third' | 'first'. Меняем поле + дёргаем _applyCameraMode чтобы // PlayerController скрыл/показал модель игрока (в 1st-person иначе // голова рендерится изнутри и закрывает обзор). try { const player = sceneRef?.current?.player; if (!player) return; player._cameraMode = mode; if (typeof player._applyCameraMode === 'function') { player._applyCameraMode(); } else { // Fallback: ручное скрытие/показ модели const visible = mode !== 'first'; for (const m of (player._modelMeshes || [])) { try { m.setEnabled(visible); } catch {} } } } catch (e) { /* ignore */ } }, [sceneRef]); // === При открытии меню — применить все сохранённые настройки === useEffect(() => { applyVolume(settings.volume); applyQuality(settings.quality); applyMaxFps(settings.maxFps); applyShowFps(settings.showFps); applyMouseSens(settings.mouseSens); applyInvertCamera(settings.invertCamera); applyCameraMode(settings.cameraMode); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const update = useCallback((patch, applier) => { setSettingsState(prev => { const next = { ...prev, ...patch }; saveSettings(next); applySettingsToWindow(next); // Применяем сразу к engine if (applier) { const k = Object.keys(patch)[0]; applier(next[k]); } return next; }); }, []); return (
update({ volume: v }, applyVolume)} /> { update({ fullscreen: i === 1 }); try { if (i === 1 && document.documentElement.requestFullscreen) { document.documentElement.requestFullscreen(); } else if (i === 0 && document.exitFullscreen) { document.exitFullscreen(); } } catch (e) { /* ignore */ } }} /> update({ quality: v }, applyQuality)} /> { const map = { '30': 30, '60 (стандарт)': 60, '75': 75, '120': 120, '144': 144, 'Без лимита': 0 }; const fps = map[label] ?? 60; update({ maxFpsLabel: label, maxFps: fps }); applyMaxFps(fps); }} /> update({ showFps: i === 1 }, applyShowFps)} /> update({ mouseSens: v }, applyMouseSens)} /> update({ invertCamera: i === 1 }, applyInvertCamera)} /> { const mode = i === 1 ? 'first' : 'third'; update({ cameraMode: mode }); applyCameraMode(mode); }} />
); } function SettingsSection({ title, first }) { return (
{title}
); } function SettingsRowBase({ children }) { return (
{children}
); } function SliderRow({ label, hint, min, max, value, onChange }) { return (
{label}
{hint &&
{hint}
}
onChange(parseInt(e.target.value, 10))} style={{ width: 200, accentColor: HUD.accent }} />
{value}
); } function ArrowsRow({ label, hint, options, index, onChange }) { const handle = (delta) => { const newIdx = (index + delta + options.length) % options.length; onChange(newIdx); }; return (
{label}
{hint &&
{hint}
}
handle(-1)} dir="left" />
{options[index]}
handle(+1)} dir="right" />
); } function ArrowBtn({ onClick, dir }) { const [hover, setHover] = useState(false); return ( ); } function DropdownRow({ label, hint, options, value, onChange }) { return (
{label}
{hint &&
{hint}
}
); } // ════════════════════════════════════════════════════════════════════ // TAB: ЗАХВАТЫ // ════════════════════════════════════════════════════════════════════ function TabCaptures() { const [shots, setShots] = useState(() => loadShots()); const takeShot = useCallback(async () => { try { // Берём канвас Babylon const canvas = document.querySelector('canvas'); if (!canvas) return; const dataUrl = canvas.toDataURL('image/png'); const next = [{ id: Date.now(), dataUrl }, ...shots].slice(0, 30); setShots(next); saveShots(next); } catch (e) { console.warn('Screenshot failed:', e); } }, [shots]); const removeShot = useCallback((id) => { const next = shots.filter(s => s.id !== id); setShots(next); saveShots(next); }, [shots]); const download = useCallback((shot) => { const a = document.createElement('a'); a.href = shot.dataUrl; a.download = `rublox-${shot.id}.png`; a.click(); }, []); return (
Захваты
Снимки экрана из игры. Сохраняются локально в браузере.
Снять сейчас
{shots.length === 0 ? (
Пока нет ни одного снимка.
Нажмите «Снять сейчас» чтобы создать.
) : (
{shots.map((shot) => (
download(shot)} small>Скачать removeShot(shot.id)} small variant="danger">Удалить
))}
)}
); } // ════════════════════════════════════════════════════════════════════ // TAB: ЖАЛОБА // ════════════════════════════════════════════════════════════════════ function TabReport({ gameId, gameTitle }) { const [category, setCategory] = useState('Игра / Плеер'); const [title, setTitle] = useState(''); const [message, setMessage] = useState(''); const [status, setStatus] = useState(null); const [sending, setSending] = useState(false); const send = async () => { if (sending) return; if (!title.trim() || !message.trim()) { setStatus({ text: 'Заполните заголовок и описание', error: true }); return; } setSending(true); setStatus({ text: 'Отправка...', error: false }); try { const token = getToken(); // Бэкенд /kubikon3d/reports требует reporter_user_id и target_id и // принимает поле text. Раньше TabReport слал {title, message, // game_id, game_title} БЕЗ reporter_user_id → бэк отвечал // 400 'reporter_user_id required' → жалоба падала. Приводим к // формату бэкенда (как нижняя кнопка «Жалоба»). const me = getMyProfile(); if (!me || !me.id) { setStatus({ text: 'Войдите, чтобы отправить жалобу.', error: true }); setSending(false); return; } const res = await fetch(`${STORYS_addres}/kubikon3d/reports`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: token } : {}), }, body: JSON.stringify({ reporter_user_id: me.id, target_type: 'project', target_id: gameId || null, category, text: title.trim() + '\n\n' + message.trim() + (gameTitle ? `\n\n(игра: ${gameTitle})` : ''), }), }); if (res.ok) { setStatus({ text: 'Спасибо! Жалоба принята.', error: false }); setTitle(''); setMessage(''); } else { let detail = ''; try { const j = await res.json(); if (j && j.error) detail = ': ' + j.error; } catch (e) {} setStatus({ text: 'Не удалось отправить' + detail + '.', error: true }); } } catch (e) { setStatus({ text: 'Сеть недоступна.', error: true }); } finally { setSending(false); } }; return (
{/* Контейнер ~85% ширины — поля визуально не растянуты на всю карточку, оставляют воздух по бокам. */}
Сообщить о проблеме
Опишите что пошло не так. Мы прочитаем все обращения.
Категория Заголовок (коротко) setTitle(e.target.value)} maxLength={200} placeholder="Например: «Падает FPS на карте Тир»" style={{ width: '100%', background: HUD.rowBg, color: HUD.text, border: `1px solid ${HUD.border}`, borderRadius: 8, padding: '10px 12px', fontFamily: HUD.font, fontSize: 14, boxSizing: 'border-box', }} /> Подробнее (что именно произошло)