Open-source web player for Rublox games, dual-licensed under AGPL-3.0 + Commercial. Highlights: - Babylon.js 7 + React 18 + Vite 5 stack - Self-contained engine (~46k lines): BlockManager, ModelManager, PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD gamemodes - Configurable backend via VITE_API_BASE and friends — works against staging (dev-api.rublox.pro) out of the box - Standalone mode (VITE_STANDALONE=true) loads a bundled sample game for first-run without any backend - Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG - Lint + format scaffolding (ESLint + Prettier + EditorConfig) - Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md - Issue templates: bug_report, feature_request, security_disclosure Removed before public release: - frontend_deploy.py (contained production SSH credentials) - ~27 admin endpoints (kept in private repo) - Hard-coded internal URLs and IPs - All previous git history (clean repo init)
1785 lines
72 KiB
JavaScript
1785 lines
72 KiB
JavaScript
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();
|
||
const res = await fetch(`${STORYS_addres}/kubikon3d/reports`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...(token ? { Authorization: token } : {}),
|
||
},
|
||
body: JSON.stringify({
|
||
category,
|
||
title: title.trim(),
|
||
message: message.trim(),
|
||
game_id: gameId || null,
|
||
game_title: gameTitle || null,
|
||
}),
|
||
});
|
||
if (res.ok) {
|
||
setStatus({ text: 'Спасибо! Жалоба принята.', error: false });
|
||
setTitle('');
|
||
setMessage('');
|
||
} else {
|
||
setStatus({ text: 'Не удалось отправить. Попробуйте позже.', 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);
|
||
}
|
||
`;
|