{/* === Нижний ряд кнопок === */}
{ onRespawn?.(); onClose(); }}
onResume={onClose}
/>
);
}
// ════════════════════════════════════════════════════════════════════
// TabBar — верхний ряд из 5 вкладок с индикатором активной
// ════════════════════════════════════════════════════════════════════
function TabBar({ activeTab, onTab }) {
return (
);
}
/**
* FriendButton — кнопка «добавить в друзья» на карточке участника.
*
* Состояния:
* • default — синий +, при hover ярче и scale 1.08
* • pending — иконка часов, серый/тёмный фон, не реагирует на повторный клик
* • (друг — кнопка не рисуется вообще, см. PlayerCard)
*/
function FriendButton({ pending, onClick }) {
const [hover, setHover] = useState(false);
const [sending, setSending] = useState(false);
const disabled = pending || sending;
const handleClick = async (e) => {
e.stopPropagation();
if (disabled || !onClick) return;
setSending(true);
try { await onClick(); } finally { setSending(false); }
};
// Цвета по состоянию
const bg = pending
? 'rgba(60, 65, 80, 0.92)'
: (hover ? 'rgba(51, 87, 255, 0.95)' : 'rgba(51, 87, 255, 0.85)');
const iconColor = pending ? 'rgba(255, 255, 255, 0.70)' : '#ffffff';
const title = pending ? 'Запрос отправлен' : 'Добавить в друзья';
return (
);
}
function PlayerCard({ player, isMe, isFriend, isPending, onAddFriend }) {
const username = String(player.username || '?');
const color = colorForUser(Number(player.user_id || 0), username);
// Аватар: 1) skin PNG (картинка персонажа — bacon/imposter/etc) — главный
// 2) photo_thumb_b64 (аватар майнкрафтии, fallback)
// 3) photo URL (старое поле — fallback)
// 4) буква-инициал
//
// Скины лежат в /kubikon-assets/characters//avatar.png — это PNG
// персонажа в полный рост. Совпадает с Godot/exe-плеером.
let avatarUrl = null;
let isSkin = false;
if (player.skin && typeof player.skin === 'string') {
// cache-bust обязателен: на 2026-05-27 фиксили 404 на этом пути,
// браузеры успели закэшировать негативный ответ
avatarUrl = `/kubikon-assets/characters/${player.skin}/avatar.png?v=2026_05_27`;
isSkin = true;
} else if (player.photo_thumb_b64) {
avatarUrl = player.photo_thumb_b64.startsWith('data:')
? player.photo_thumb_b64
: `data:image/jpeg;base64,${player.photo_thumb_b64}`;
} else if (player.photo && typeof player.photo === 'string') {
// Если photo относительный — резолвим через API_BASE (текущий origin
// на проде, vite-proxy в dev).
const env = (typeof import.meta !== 'undefined' && import.meta.env) || {};
const apiBase = env.VITE_API_BASE
|| (typeof window !== 'undefined' ? window.location.origin : '');
avatarUrl = player.photo.startsWith('http')
? player.photo
: `${apiBase}${player.photo.startsWith('/') ? '' : '/'}${player.photo}`;
}
return (
{/* Аватар-блок */}
{avatarUrl ? (
{ e.currentTarget.style.display = 'none'; }}
style={{
width: '100%',
height: '100%',
// Скин — это персонаж в полный рост, нужно показать
// целиком (contain). Аватарка майнкрафтии — обычно
// квадратное фото, тоже хорошо смотрится в contain.
objectFit: isSkin ? 'contain' : 'cover',
userSelect: 'none',
}}
/>
) : (
{username.slice(0, 1).toUpperCase()}
)}
{/* Лёгкая виньетка снизу */}
{/* Кнопка «добавить в друзья» — НЕ показываем:
1) на своей карточке (isMe)
2) для гостей без user_id
3) если уже в друзьях (isFriend) */}
{!isMe && Number(player.user_id) > 0 && !isFriend && (
)}
{/* Футер: имя + ник */}
{username}
@{username.toLowerCase()}
);
}
// ════════════════════════════════════════════════════════════════════
// TAB: НАСТРОЙКИ
// ════════════════════════════════════════════════════════════════════
function TabSettings({ sceneRef }) {
const [settings, setSettingsState] = useState(() => loadSettings());
// === Применение настроек к engine ===
// Все вызовы безопасны: optional chaining + try/catch.
const applyVolume = useCallback((vol) => {
// vol 0..10. Babylon Audio engine использует 0..1.
try {
const audio = sceneRef?.current?.audioManager;
const v = vol / 10;
if (audio?.setMasterVolume) audio.setMasterVolume(v);
// Глобальный fallback — установить мастер-громкость Babylon
const scene = sceneRef?.current?.scene;
const engine = scene?.getEngine?.();
if (engine?.audioEngine) engine.audioEngine.setGlobalVolume?.(v);
} catch (e) { /* ignore */ }
}, [sceneRef]);
const applyQuality = useCallback((q) => {
// q 1..10. Меняем hardwareScaling (плотность пикселей рендера) и
// качество теней.
//
// ВАЖНО про тени:
// • 'soft' — PCF 1024-2048 (мягкие, плавные края) — то что выглядит
// как «гладкие тени» в exe. Дефолт почти везде.
// • 'hard' — резкие пиксельные тени без фильтрации (быстро, уродливо).
// Используем только для q<=2.
// • 'off' — без теней.
// Раньше на q=5..7 ставился 'hard' — выглядело пиксельно (баг 2026-05-27).
try {
const scene = sceneRef?.current?.scene;
const engine = scene?.getEngine?.();
if (engine?.setHardwareScalingLevel) {
const lvl = q >= 10 ? 1.0
: q >= 8 ? 1.1
: q >= 6 ? 1.25
: q >= 4 ? 1.5
: q >= 2 ? 2.0
: 2.5;
engine.setHardwareScalingLevel(lvl);
}
const bs = sceneRef?.current;
if (bs?.setShadowQuality) {
const shadow = q >= 3 ? 'soft' : q >= 2 ? 'hard' : 'off';
bs.setShadowQuality(shadow);
}
} catch (e) { /* ignore */ }
}, [sceneRef]);
const applyMaxFps = useCallback((fps) => {
// 0 = без лимита (vsync auto). Babylon ограничивает через RAF — нативный
// лимит ставится через engine.targetFps (если есть свойство).
try {
const engine = sceneRef?.current?.scene?.getEngine?.();
if (!engine) return;
if (fps <= 0) {
// Снимаем лимит
if ('targetFps' in engine) delete engine.targetFps;
engine.runRenderLoop && engine._targetFps !== undefined && (engine._targetFps = 0);
} else {
engine._targetFps = fps;
}
} catch (e) { /* ignore */ }
}, [sceneRef]);
const applyShowFps = useCallback((on) => {
// Создаём/удаляем простой div-overlay с FPS из engine.
const existing = document.getElementById('rublox-fps-overlay');
if (!on) {
existing?.remove();
if (window.__rubloxFpsRaf) {
cancelAnimationFrame(window.__rubloxFpsRaf);
window.__rubloxFpsRaf = null;
}
return;
}
if (existing) return;
const el = document.createElement('div');
el.id = 'rublox-fps-overlay';
el.style.cssText = [
'position: fixed', 'top: 8px', 'right: 12px',
'z-index: 5000',
'background: rgba(15, 19, 28, 0.85)',
'color: #22d97a',
'padding: 4px 10px',
'border-radius: 8px',
'font: 700 14px Consolas, "Roboto Mono", monospace',
'pointer-events: none',
'border: 1px solid rgba(255,255,255,0.08)',
].join(';');
document.body.appendChild(el);
const loop = () => {
try {
const engine = sceneRef?.current?.scene?.getEngine?.();
const fps = engine?.getFps?.() || 0;
el.textContent = `${Math.round(fps)} FPS`;
} catch {}
window.__rubloxFpsRaf = requestAnimationFrame(loop);
};
window.__rubloxFpsRaf = requestAnimationFrame(loop);
}, [sceneRef]);
const applyMouseSens = useCallback((v) => {
// 1..10 → MOUSE_SENSITIVITY 0.0010 .. 0.0050 (дефолт 0.0025).
try {
const player = sceneRef?.current?.player;
if (!player) return;
const base = 0.0025;
// 5 = base, 10 = ×2, 1 = ×0.4 (линейно)
const k = v <= 5 ? (0.4 + 0.12 * (v - 1)) : (1.0 + 0.20 * (v - 5));
player.MOUSE_SENSITIVITY = base * k;
} catch (e) { /* ignore */ }
}, [sceneRef]);
const applyInvertCamera = useCallback((on) => {
// Базовая реализация: меняем знак pitch-вклада через флаг на player.
// PlayerController читает _invertCamera в onMouseMove (если реализовано),
// иначе настройка сохранится и применится при следующей версии engine.
try {
const player = sceneRef?.current?.player;
if (player) player._invertCamera = !!on;
} catch (e) { /* ignore */ }
}, [sceneRef]);
const applyCameraMode = useCallback((mode) => {
// 'third' | 'first'. Меняем поле + дёргаем _applyCameraMode чтобы
// PlayerController скрыл/показал модель игрока (в 1st-person иначе
// голова рендерится изнутри и закрывает обзор).
try {
const player = sceneRef?.current?.player;
if (!player) return;
player._cameraMode = mode;
if (typeof player._applyCameraMode === 'function') {
player._applyCameraMode();
} else {
// Fallback: ручное скрытие/показ модели
const visible = mode !== 'first';
for (const m of (player._modelMeshes || [])) {
try { m.setEnabled(visible); } catch {}
}
}
} catch (e) { /* ignore */ }
}, [sceneRef]);
// === При открытии меню — применить все сохранённые настройки ===
useEffect(() => {
applyVolume(settings.volume);
applyQuality(settings.quality);
applyMaxFps(settings.maxFps);
applyShowFps(settings.showFps);
applyMouseSens(settings.mouseSens);
applyInvertCamera(settings.invertCamera);
applyCameraMode(settings.cameraMode);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const update = useCallback((patch, applier) => {
setSettingsState(prev => {
const next = { ...prev, ...patch };
saveSettings(next);
applySettingsToWindow(next);
// Применяем сразу к engine
if (applier) {
const k = Object.keys(patch)[0];
applier(next[k]);
}
return next;
});
}, []);
return (