player/src/KubikonPlayer/GameMenu.jsx
МИН 8f0524cbb3 feat: порт 3D-стрелки-указателя в плеер (фича-парность) + dev JWT-панель
- game.fx.pointer + расширенный game.fx.beam: BeamManager (текстуры/curved/
  градиент/quest-marker), ScriptSandboxWorker (_normFxPoint от DataCloneError),
  GameRuntime (fx.createPointer/pointerTarget/pointerUpdate/beamUpdate/
  beamVisible), BabylonScene._activatePointers. 1-в-1 со студией.
- Dev JWT-панель на экране «Нужен JWT» (только localhost): кнопка → инпут →
  localStorage.player_jwt + reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:46:24 +03:00

1799 lines
73 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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, 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 (
<div
data-rublox-game-menu
style={{
position: 'fixed',
inset: 0,
zIndex: 4000,
background: 'rgba(0, 0, 0, 0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: HUD.font,
color: HUD.text,
animation: 'rmFadeIn 180ms ease',
}}
onClick={(e) => {
// Клик по фону (dim) — закрыть. По карточке — не закрывать.
if (e.target === e.currentTarget) onClose();
}}
>
<style>{KEYFRAMES}</style>
<div
style={{
width: 880,
height: 620,
maxWidth: 'calc(100vw - 32px)',
maxHeight: 'calc(100vh - 32px)',
background: HUD.cardBg,
border: `1px solid ${HUD.border}`,
borderRadius: 14,
boxShadow: '0 16px 48px rgba(0, 0, 0, 0.6)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
animation: 'rmSlideUp 220ms cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
>
{/* === Таб-бар === */}
<TabBar activeTab={activeTab} onTab={setActiveTab} />
{/* === Контент === */}
<div
style={{
flex: 1,
minHeight: 0,
padding: '12px 16px',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
{activeTab === 'people' && <TabPeople gameId={gameId} mySkin={mySkin} />}
{activeTab === 'settings' && <TabSettings sceneRef={sceneRef} />}
{activeTab === 'captures' && <TabCaptures />}
{activeTab === 'report' && <TabReport gameId={gameId} gameTitle={gameTitle} />}
{activeTab === 'help' && <TabHelp />}
</div>
{/* === Нижний ряд кнопок === */}
<BottomBar
onExit={onExit}
onRespawn={() => { onRespawn?.(); onClose(); }}
onResume={onClose}
/>
</div>
</div>
);
}
// ════════════════════════════════════════════════════════════════════
// TabBar — верхний ряд из 5 вкладок с индикатором активной
// ════════════════════════════════════════════════════════════════════
function TabBar({ activeTab, onTab }) {
return (
<div
style={{
position: 'relative',
height: 56,
display: 'flex',
borderBottom: `1px solid ${HUD.border}`,
padding: '0 16px',
}}
>
{TABS.map((tab) => {
const active = tab.id === activeTab;
return (
<button
key={tab.id}
onClick={() => onTab(tab.id)}
style={{
flex: 1,
position: 'relative',
background: 'transparent',
border: 'none',
cursor: 'pointer',
color: active ? HUD.text : HUD.textMuted,
fontWeight: 700,
fontSize: 15,
fontFamily: HUD.font,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
transition: 'color 150ms ease',
}}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.color = HUD.text; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.color = HUD.textMuted; }}
>
<Icon name={tab.icon} size={16} />
{tab.title}
{/* Индикатор активной вкладки — белая полоска 92% ширины */}
{active && (
<span
style={{
position: 'absolute',
bottom: -1,
left: '4%',
width: '92%',
height: 3,
background: HUD.text,
borderRadius: 2,
}}
/>
)}
</button>
);
})}
</div>
);
}
// ════════════════════════════════════════════════════════════════════
// BottomBar — 3 кнопки: L Покинуть / R Возродиться / Esc Продолжить
// ════════════════════════════════════════════════════════════════════
function BottomBar({ onExit, onRespawn, onResume }) {
return (
<div
style={{
height: 64,
borderTop: `1px solid ${HUD.border}`,
padding: '12px 16px',
display: 'flex',
gap: 12,
}}
>
<ActionBtn hotkey="L" label="Покинуть" onClick={onExit} variant="ghost" />
<ActionBtn hotkey="R" label="Возродиться" onClick={onRespawn} variant="ghost" />
<ActionBtn hotkey="Esc" label="Продолжить" onClick={onResume} variant="primary" />
</div>
);
}
function ActionBtn({ hotkey, label, onClick, variant = 'ghost' }) {
const [hover, setHover] = useState(false);
const primary = variant === 'primary';
return (
<button
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
flex: 1,
height: 40,
background: primary
? (hover ? '#4868ff' : HUD.accent)
: (hover ? HUD.rowBgHover : HUD.rowBg),
border: primary ? 'none' : `1px solid ${HUD.border}`,
borderRadius: 10,
color: HUD.text,
fontWeight: 700,
fontSize: 14,
fontFamily: HUD.font,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
transition: 'background 150ms ease',
}}
>
<span
style={{
padding: '2px 8px',
fontSize: 11,
fontWeight: 800,
background: 'rgba(255, 255, 255, 0.18)',
borderRadius: 6,
letterSpacing: 0.5,
}}
>
{hotkey}
</span>
{label}
</button>
);
}
// ════════════════════════════════════════════════════════════════════
// 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 (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
{/* Шапка */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, height: 38 }}>
<span style={{ fontWeight: 800, fontSize: 16, color: HUD.text }}>На сервере</span>
{combined && (
<span style={{ color: HUD.accentAlt, fontWeight: 800, fontSize: 12 }}>
{combined.length}
</span>
)}
</div>
{/* Сетка */}
<div
style={{
flex: 1,
overflowY: 'auto',
marginTop: 8,
paddingRight: 6,
}}
className="rm-scroll"
>
{err && (
<div style={{ color: HUD.danger, textAlign: 'center', padding: 40 }}>
{err}
</div>
)}
{!err && !combined && (
<div style={{ color: HUD.textMuted, textAlign: 'center', padding: 40 }}>
Загрузка участников...
</div>
)}
{combined && combined.length === 0 && (
<div style={{ color: HUD.textMuted, textAlign: 'center', padding: 40 }}>
На сервере пока никого нет
</div>
)}
{combined && combined.length > 0 && (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(5, 1fr)',
gap: 12,
}}
>
{combined.map((p, idx) => {
const uid = Number(p.user_id || 0);
const isFriend = friendIds.has(uid);
const isPending = pendingOutIds.has(uid);
return (
<PlayerCard
key={`${uid || idx}-${idx}`}
player={p}
isMe={me && uid === Number(me.id)}
isFriend={isFriend}
isPending={isPending}
onAddFriend={() => handleSendRequest(uid)}
/>
);
})}
</div>
)}
</div>
</div>
);
}
/**
* 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 (
<button
title={title}
disabled={disabled}
onClick={handleClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
position: 'absolute',
bottom: 8,
right: 8,
width: 36,
height: 36,
borderRadius: '50%',
background: bg,
border: pending
? '1px solid rgba(255, 255, 255, 0.12)'
: '1px solid rgba(80, 120, 255, 0.55)',
color: iconColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: disabled ? 'default' : 'pointer',
boxShadow: pending
? '0 2px 6px rgba(0, 0, 0, 0.45)'
: (hover
? '0 4px 14px rgba(51, 87, 255, 0.55)'
: '0 2px 8px rgba(0, 0, 0, 0.5)'),
transform: (hover && !pending) ? 'scale(1.08)' : 'scale(1)',
transition: 'background 150ms ease, transform 150ms ease, box-shadow 150ms ease',
padding: 0,
}}
>
{pending ? (
/* Иконка «часы» — заявка отправлена */
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
) : (
/* Плюс */
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12h14" />
</svg>
)}
</button>
);
}
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/<slug>/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 (
<div
style={{
width: '100%',
height: 210,
background: HUD.rowBg,
border: `1px solid ${HUD.border}`,
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.35)',
}}
>
{/* Аватар-блок */}
<div
style={{
height: 152,
background: darken(color, 0.45),
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt={username}
draggable={false}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
style={{
width: '100%',
height: '100%',
// Скин — это персонаж в полный рост, нужно показать
// целиком (contain). Аватарка майнкрафтии — обычно
// квадратное фото, тоже хорошо смотрится в contain.
objectFit: isSkin ? 'contain' : 'cover',
userSelect: 'none',
}}
/>
) : (
<span
style={{
fontSize: 64,
fontWeight: 900,
color: 'rgba(255, 255, 255, 0.95)',
textShadow: '0 2px 12px rgba(0, 0, 0, 0.4)',
letterSpacing: -1,
}}
>
{username.slice(0, 1).toUpperCase()}
</span>
)}
{/* Лёгкая виньетка снизу */}
<div
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '40%',
background: 'linear-gradient(to bottom, transparent, rgba(0,0,0,0.4))',
}}
/>
{/* Кнопка «добавить в друзья» — НЕ показываем:
1) на своей карточке (isMe)
2) для гостей без user_id
3) если уже в друзьях (isFriend) */}
{!isMe && Number(player.user_id) > 0 && !isFriend && (
<FriendButton
pending={isPending}
onClick={onAddFriend}
/>
)}
</div>
{/* Футер: имя + ник */}
<div style={{ padding: '8px 10px', flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 800,
fontSize: 14,
color: HUD.text,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{username}
</div>
<div
style={{
fontSize: 11,
color: HUD.textDim,
marginTop: 2,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
@{username.toLowerCase()}
</div>
</div>
</div>
);
}
// ════════════════════════════════════════════════════════════════════
// 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 (
<div style={{ flex: 1, overflowY: 'auto', paddingRight: 6 }} className="rm-scroll">
<SettingsSection title="Звук" first />
<SliderRow
label="Громкость" hint="Звуковые эффекты и музыка"
min={0} max={10}
value={settings.volume}
onChange={(v) => update({ volume: v }, applyVolume)}
/>
<SettingsSection title="Экран" />
<ArrowsRow
label="Полноэкранный режим"
hint="Развернуть игру на весь экран"
options={['Выкл', 'Вкл']}
index={settings.fullscreen ? 1 : 0}
onChange={(i) => {
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 */ }
}}
/>
<SliderRow
label="Качество графики"
hint="Разрешение рендера и тени"
min={1} max={10}
value={settings.quality}
onChange={(v) => update({ quality: v }, applyQuality)}
/>
<DropdownRow
label="Макс. FPS"
hint="Лимит частоты кадров"
options={['30', '60 (стандарт)', '75', '120', '144', 'Без лимита']}
value={settings.maxFpsLabel}
onChange={(label) => {
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);
}}
/>
<ArrowsRow
label="Показывать FPS"
hint="Счётчик кадров в углу экрана"
options={['Выкл', 'Вкл']}
index={settings.showFps ? 1 : 0}
onChange={(i) => update({ showFps: i === 1 }, applyShowFps)}
/>
<SettingsSection title="Камера и управление" />
<SliderRow
label="Чувствительность мыши"
hint="Скорость поворота камеры"
min={1} max={10}
value={settings.mouseSens}
onChange={(v) => update({ mouseSens: v }, applyMouseSens)}
/>
<ArrowsRow
label="Инвертировать камеру"
hint="Вверх/вниз становится наоборот"
options={['Нет', 'Да']}
index={settings.invertCamera ? 1 : 0}
onChange={(i) => update({ invertCamera: i === 1 }, applyInvertCamera)}
/>
<ArrowsRow
label="Режим камеры"
hint="Применяется сразу"
options={['От 3-го лица', 'От 1-го лица']}
index={settings.cameraMode === 'first' ? 1 : 0}
onChange={(i) => {
const mode = i === 1 ? 'first' : 'third';
update({ cameraMode: mode });
applyCameraMode(mode);
}}
/>
</div>
);
}
function SettingsSection({ title, first }) {
return (
<div
style={{
fontWeight: 800,
fontSize: 13,
color: HUD.text,
textTransform: 'uppercase',
letterSpacing: 1,
paddingTop: first ? 4 : 16,
paddingBottom: 8,
opacity: 0.75,
}}
>
{title}
</div>
);
}
function SettingsRowBase({ children }) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
padding: '10px 0',
borderBottom: `1px solid ${HUD.border}`,
gap: 16,
}}
>
{children}
</div>
);
}
function SliderRow({ label, hint, min, max, value, onChange }) {
return (
<SettingsRowBase>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: HUD.text }}>{label}</div>
{hint && <div style={{ fontSize: 12, color: HUD.textDim, marginTop: 2 }}>{hint}</div>}
</div>
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
style={{ width: 200, accentColor: HUD.accent }}
/>
<div style={{ width: 28, textAlign: 'right', color: HUD.text, fontWeight: 700, fontSize: 14 }}>
{value}
</div>
</SettingsRowBase>
);
}
function ArrowsRow({ label, hint, options, index, onChange }) {
const handle = (delta) => {
const newIdx = (index + delta + options.length) % options.length;
onChange(newIdx);
};
return (
<SettingsRowBase>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: HUD.text }}>{label}</div>
{hint && <div style={{ fontSize: 12, color: HUD.textDim, marginTop: 2 }}>{hint}</div>}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<ArrowBtn onClick={() => handle(-1)} dir="left" />
<div
style={{
minWidth: 110,
textAlign: 'center',
fontWeight: 700,
fontSize: 14,
color: HUD.text,
}}
>
{options[index]}
</div>
<ArrowBtn onClick={() => handle(+1)} dir="right" />
</div>
</SettingsRowBase>
);
}
function ArrowBtn({ onClick, dir }) {
const [hover, setHover] = useState(false);
return (
<button
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
width: 28,
height: 28,
borderRadius: 8,
background: hover ? HUD.rowBgHover : HUD.rowBg,
border: `1px solid ${HUD.border}`,
color: HUD.text,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 12,
fontWeight: 800,
}}
>
{dir === 'left' ? '' : ''}
</button>
);
}
function DropdownRow({ label, hint, options, value, onChange }) {
return (
<SettingsRowBase>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: HUD.text }}>{label}</div>
{hint && <div style={{ fontSize: 12, color: HUD.textDim, marginTop: 2 }}>{hint}</div>}
</div>
<div style={{ minWidth: 180 }}>
<CustomSelect value={value} options={options} onChange={onChange} />
</div>
</SettingsRowBase>
);
}
// ════════════════════════════════════════════════════════════════════
// 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 (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, paddingBottom: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 800, fontSize: 18, color: HUD.text }}>Захваты</div>
<div style={{ fontSize: 12, color: HUD.textDim, marginTop: 2 }}>
Снимки экрана из игры. Сохраняются локально в браузере.
</div>
</div>
<PillBtn onClick={takeShot} variant="primary">Снять сейчас</PillBtn>
</div>
{shots.length === 0 ? (
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: HUD.textMuted,
fontSize: 14,
textAlign: 'center',
}}
>
Пока нет ни одного снимка.<br />
Нажмите «Снять сейчас» чтобы создать.
</div>
) : (
<div
style={{
flex: 1,
overflowY: 'auto',
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 10,
paddingRight: 6,
}}
className="rm-scroll"
>
{shots.map((shot) => (
<div
key={shot.id}
style={{
position: 'relative',
aspectRatio: '16 / 9',
background: '#000',
borderRadius: 8,
overflow: 'hidden',
border: `1px solid ${HUD.border}`,
}}
>
<img
src={shot.dataUrl}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
<div style={{
position: 'absolute',
left: 0, right: 0, bottom: 0,
background: 'linear-gradient(to top, rgba(0,0,0,0.85), transparent)',
padding: '20px 8px 8px',
display: 'flex',
gap: 6,
}}>
<PillBtn onClick={() => download(shot)} small>Скачать</PillBtn>
<PillBtn onClick={() => removeShot(shot.id)} small variant="danger">Удалить</PillBtn>
</div>
</div>
))}
</div>
)}
</div>
);
}
// ════════════════════════════════════════════════════════════════════
// 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 (
<div style={{ flex: 1, overflowY: 'auto', paddingRight: 6 }} className="rm-scroll">
{/* Контейнер ~85% ширины — поля визуально не растянуты на всю
карточку, оставляют воздух по бокам. */}
<div style={{ maxWidth: 640, marginLeft: 'auto', marginRight: 'auto' }}>
<div style={{ fontWeight: 800, fontSize: 18, color: HUD.text }}>Сообщить о проблеме</div>
<div style={{ fontSize: 12, color: HUD.textDim, marginTop: 2 }}>
Опишите что пошло не так. Мы прочитаем все обращения.
</div>
<FormLabel>Категория</FormLabel>
<CustomSelect
value={category}
options={['Игра / Плеер', 'Редактор', 'Сайт', 'Другое']}
onChange={setCategory}
/>
<FormLabel>Заголовок (коротко)</FormLabel>
<input
value={title}
onChange={(e) => 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',
}}
/>
<FormLabel>Подробнее (что именно произошло)</FormLabel>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
maxLength={2000}
rows={5}
placeholder="Что вы делали? Что произошло? Что должно было быть?"
style={{
width: '100%',
background: HUD.rowBg,
color: HUD.text,
border: `1px solid ${HUD.border}`,
borderRadius: 8,
padding: '10px 12px',
boxSizing: 'border-box',
fontFamily: HUD.font,
fontSize: 14,
resize: 'vertical',
minHeight: 110,
}}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16 }}>
{status && (
<div
style={{
flex: 1,
color: status.error ? HUD.danger : HUD.accentAlt,
fontWeight: 700,
fontSize: 13,
}}
>
{status.text}
</div>
)}
<PillBtn onClick={send} variant="primary" disabled={sending}>
{sending ? 'Отправка...' : 'Отправить'}
</PillBtn>
</div>
</div>
</div>
);
}
function FormLabel({ children }) {
return (
<div
style={{
fontWeight: 700,
fontSize: 13,
color: HUD.text,
marginTop: 16,
marginBottom: 8,
}}
>
{children}
</div>
);
}
/**
* CustomSelect — кастомный дропдаун (вместо нативного <select>).
*
* Нативный <select> в Chrome рендерит опции силами ОС: фон/цвет текста
* прописаны системной темой. В тёмной теме сайта — белый текст на белом
* фоне (баг 2026-05-27). Делаем свой через позиционированный popover.
*/
function CustomSelect({ value, options, onChange, fullWidth = true }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const onDoc = (e) => {
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
};
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDoc);
document.removeEventListener('keydown', onKey);
};
}, [open]);
return (
<div ref={ref} style={{ position: 'relative', width: fullWidth ? '100%' : 'auto' }}>
<button
type="button"
onClick={() => setOpen(o => !o)}
style={{
width: '100%',
background: HUD.rowBg,
color: HUD.text,
border: `1px solid ${HUD.border}`,
borderRadius: 8,
padding: '10px 36px 10px 12px',
fontFamily: HUD.font,
fontSize: 14,
fontWeight: 700,
cursor: 'pointer',
textAlign: 'left',
position: 'relative',
boxSizing: 'border-box',
}}
>
{value}
<span
style={{
position: 'absolute',
right: 12,
top: '50%',
transform: `translateY(-50%) rotate(${open ? 180 : 0}deg)`,
transition: 'transform 150ms ease',
color: HUD.textMuted,
fontSize: 10,
pointerEvents: 'none',
}}
></span>
</button>
{open && (
<div
style={{
position: 'absolute',
top: 'calc(100% + 4px)',
left: 0,
right: 0,
background: 'rgba(20, 24, 45, 0.98)',
border: `1px solid ${HUD.border}`,
borderRadius: 8,
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.55)',
zIndex: 50,
overflow: 'hidden',
maxHeight: 240,
overflowY: 'auto',
}}
className="rm-scroll"
>
{options.map((o) => {
const active = o === value;
return (
<CustomSelectItem
key={o}
label={o}
active={active}
onClick={() => { onChange(o); setOpen(false); }}
/>
);
})}
</div>
)}
</div>
);
}
function CustomSelectItem({ label, active, onClick }) {
const [hover, setHover] = useState(false);
return (
<button
type="button"
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
width: '100%',
background: active
? HUD.accent
: (hover ? HUD.rowBgHover : 'transparent'),
color: HUD.text,
border: 'none',
padding: '10px 14px',
fontFamily: HUD.font,
fontSize: 14,
fontWeight: 700,
cursor: 'pointer',
textAlign: 'left',
display: 'block',
}}
>
{label}
</button>
);
}
// ════════════════════════════════════════════════════════════════════
// TAB: ПОМОЩЬ
// ════════════════════════════════════════════════════════════════════
function TabHelp() {
return (
<div style={{ flex: 1, overflowY: 'auto', paddingRight: 6 }} className="rm-scroll">
<div style={{ fontWeight: 800, fontSize: 18, color: HUD.text, marginBottom: 14 }}>
Управление
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
<HelpSection title="Движение" rows={[
['Вперёд', 'W'],
['Назад', 'S'],
['Влево', 'A'],
['Вправо', 'D'],
['Прыжок', 'Space'],
['Бег', 'Shift'],
]} />
<HelpSection title="Камера и инвентарь" rows={[
['Поворот камеры', 'ПКМ + мышь'],
['Зум', 'Колесо'],
['1-е / 3-е лицо', 'F5'],
['Слоты', '1 9'],
['Стрельба', 'ЛКМ'],
['Перезарядка', 'R'],
]} />
<HelpSection title="Меню" rows={[
['Чат', 'T'],
['Скриншот', 'F12'],
['Меню', 'Esc'],
['Закрыть чат', 'Esc'],
['Курсор', 'Alt + M'],
]} />
</div>
<div style={{ fontWeight: 800, fontSize: 16, color: HUD.text, marginTop: 24, marginBottom: 10 }}>
О Рублоксе
</div>
<div
style={{
background: HUD.rowBg,
border: `1px solid ${HUD.border}`,
borderRadius: 8,
padding: '14px 16px',
color: HUD.textMuted,
fontSize: 13,
lineHeight: 1.55,
}}
>
Рублокс платформа 3D-игр от онлайн-школы Майнкрафтия. Если что-то
не работает нажмите «Жалоба» в меню и опишите проблему.
Мы прочитаем все обращения.
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<a
href="https://rublox.pro"
target="_blank"
rel="noreferrer"
style={linkBtn}
>
Сайт Рублокса
</a>
<a
href="https://minecraftia-school.ru"
target="_blank"
rel="noreferrer"
style={linkBtn}
>
Майнкрафтия
</a>
</div>
</div>
</div>
);
}
function HelpSection({ title, rows }) {
return (
<div
style={{
background: HUD.rowBg,
border: `1px solid ${HUD.border}`,
borderRadius: 8,
padding: '12px 14px',
}}
>
<div style={{ fontWeight: 800, fontSize: 13, color: HUD.text, marginBottom: 10 }}>
{title}
</div>
{rows.map(([label, key]) => (
<div
key={label}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 0',
borderBottom: `1px solid rgba(255, 255, 255, 0.04)`,
}}
>
<span style={{ color: HUD.textMuted, fontSize: 12 }}>{label}</span>
<span
style={{
padding: '2px 8px',
background: 'rgba(255, 255, 255, 0.10)',
border: `1px solid ${HUD.border}`,
borderRadius: 6,
color: HUD.text,
fontWeight: 700,
fontSize: 11,
fontFamily: 'Consolas, "Roboto Mono", monospace',
}}
>
{key}
</span>
</div>
))}
</div>
);
}
const linkBtn = {
padding: '6px 12px',
background: HUD.rowBg,
border: `1px solid ${HUD.border}`,
borderRadius: 6,
color: HUD.text,
fontSize: 12,
fontWeight: 700,
textDecoration: 'none',
};
// ════════════════════════════════════════════════════════════════════
// ОБЩИЕ компоненты
// ════════════════════════════════════════════════════════════════════
function PillBtn({ children, onClick, variant = 'ghost', disabled, small }) {
const [hover, setHover] = useState(false);
const colors = {
primary: { bg: HUD.accent, bgHover: '#4868ff', color: '#fff' },
ghost: { bg: HUD.rowBg, bgHover: HUD.rowBgHover, color: HUD.text },
danger: { bg: 'rgba(255, 111, 122, 0.20)', bgHover: 'rgba(255, 111, 122, 0.35)', color: HUD.danger },
}[variant] || { bg: HUD.rowBg, bgHover: HUD.rowBgHover, color: HUD.text };
return (
<button
onClick={onClick}
disabled={disabled}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
padding: small ? '4px 10px' : '10px 18px',
background: disabled ? 'rgba(255, 255, 255, 0.05)' : (hover ? colors.bgHover : colors.bg),
color: disabled ? HUD.textDim : colors.color,
border: variant === 'primary' ? 'none' : `1px solid ${HUD.border}`,
borderRadius: 10,
fontWeight: 700,
fontSize: small ? 12 : 14,
fontFamily: HUD.font,
cursor: disabled ? 'not-allowed' : 'pointer',
transition: 'background 150ms ease',
}}
>
{children}
</button>
);
}
// ════════════════════════════════════════════════════════════════════
// Утилиты
// ════════════════════════════════════════════════════════════════════
const SETTINGS_KEY = 'rublox_game_settings';
const SHOTS_KEY = 'rublox_game_shots';
const DEFAULT_SETTINGS = {
volume: 7,
fullscreen: false,
quality: 7,
maxFps: 60,
maxFpsLabel: '60 (стандарт)',
showFps: false,
mouseSens: 5,
invertCamera: false,
cameraMode: 'third',
};
function loadSettings() {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
if (!raw) return { ...DEFAULT_SETTINGS };
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
} catch {
return { ...DEFAULT_SETTINGS };
}
}
function saveSettings(s) {
try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)); } catch {}
}
function applySettingsToWindow(s) {
// Глобальные настройки — engine может их прочитать.
try {
window.__rubloxSettings = s;
} catch {}
}
function loadShots() {
try {
const raw = localStorage.getItem(SHOTS_KEY);
return raw ? JSON.parse(raw) : [];
} catch { return []; }
}
function saveShots(shots) {
try {
// Ограничиваем 30 снимков чтобы не переполнять localStorage.
const trimmed = shots.slice(0, 30);
localStorage.setItem(SHOTS_KEY, JSON.stringify(trimmed));
} catch (e) {
// localStorage переполнен — удаляем половину.
try {
const trimmed = shots.slice(0, 10);
localStorage.setItem(SHOTS_KEY, JSON.stringify(trimmed));
} catch {}
}
}
function getMyProfile() {
// rublox-player хранит юзера в JWT (player_jwt), не в localStorage.Profile.
// Декодируем payload чтобы получить id/username — без проверки подписи,
// только для отображения self-карточки.
try {
const jwt = (
localStorage.getItem('player_jwt') ||
localStorage.getItem('Authorization') ||
''
);
if (jwt) {
const parts = jwt.split('.');
if (parts.length >= 2) {
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const json = atob(b64 + '==='.slice((b64.length + 3) % 4));
const p = JSON.parse(decodeURIComponent(escape(json)));
const uid = Number(p.id || p.userId || p.user_id || p.sub);
if (uid) {
return {
id: uid,
username: p.firstName || p.username || p.email?.split('@')[0] || 'Я',
photo: '',
photo_thumb_b64: '',
skin: '',
};
}
}
}
} catch { /* fall through */ }
// Legacy fallback (на случай если где-то Profile есть)
try {
const raw = localStorage.getItem('Profile') || localStorage.getItem('profile');
if (!raw) return null;
const p = JSON.parse(raw);
return {
id: p.id || p.userId,
username: p.firstName || p.username || p.email?.split('@')[0],
photo: p.photo || '',
photo_thumb_b64: p.photo_thumb_b64 || '',
skin: p.skin || '',
};
} catch { return null; }
}
function colorForUser(uid, name) {
// Хеш-цвет для аватара. Простой деривация.
let h = 0;
const s = String(uid || name || '?');
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) & 0xffffffff;
h = Math.abs(h);
const palette = [
'#3357ff', '#22d97a', '#ffc857', '#ff6f7a', '#a06aff',
'#ff8a3d', '#5ec8ff', '#dc6aff', '#7adb6c', '#ffd84a',
];
return palette[h % palette.length];
}
function darken(hex, k) {
// hex #rrggbb → ослабляем на k (0..1)
const c = hex.replace('#', '');
const r = parseInt(c.slice(0, 2), 16);
const g = parseInt(c.slice(2, 4), 16);
const b = parseInt(c.slice(4, 6), 16);
const f = 1 - k;
return `rgb(${Math.round(r * f)}, ${Math.round(g * f)}, ${Math.round(b * f)})`;
}
const KEYFRAMES = `
@keyframes rmFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes rmSlideUp {
from { opacity: 0; transform: translateY(20px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.rm-scroll::-webkit-scrollbar {
width: 8px;
}
.rm-scroll::-webkit-scrollbar-track {
background: transparent;
}
.rm-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 4px;
}
.rm-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.20);
}
`;