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)
210 lines
9.0 KiB
JavaScript
210 lines
9.0 KiB
JavaScript
import React, { useEffect, useState, useRef } from 'react';
|
||
import Icon from './Icon';
|
||
|
||
/**
|
||
* GameHud — слой UI поверх viewport в Play-режиме.
|
||
*
|
||
* Получает команды от game.ui.* через ref-API:
|
||
* hudRef.current.handle({ cmd, payload })
|
||
*
|
||
* Поддерживает:
|
||
* - ui.set { id, text, opts? } — показать или обновить именованную метку.
|
||
* Если text == null — убрать.
|
||
* opts: { x, y } в %, { color, size }.
|
||
* Спец. id: '__score' (правый верх), '__timer' (правый верх под счётом).
|
||
* - ui.flash { text, seconds } — большой текст по центру на N секунд.
|
||
* - ui.clear — стереть всё.
|
||
*
|
||
* Props:
|
||
* visible — true в Play-режиме
|
||
* hudRef — useRef для прокидывания handle
|
||
*/
|
||
|
||
function _optsEqual(a, b) {
|
||
if (a === b) return true;
|
||
if (!a || !b) return false;
|
||
return a.x === b.x && a.y === b.y && a.color === b.color && a.size === b.size;
|
||
}
|
||
|
||
const DEFAULT_LABEL_STYLE = {
|
||
fontSize: 18,
|
||
fontWeight: 700,
|
||
color: '#fff',
|
||
textShadow: '0 2px 6px rgba(0,0,0,0.7), 0 0 2px rgba(0,0,0,0.9)',
|
||
pointerEvents: 'none',
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
};
|
||
|
||
function GameHud({ visible, hudRef }) {
|
||
// labels: { id: { text, opts } }
|
||
const [labels, setLabels] = useState({});
|
||
// flash: { text, expiresAt } | null
|
||
const [flash, setFlash] = useState(null);
|
||
|
||
// hide-таймер для flash
|
||
const flashTimerRef = useRef(null);
|
||
|
||
// Регистрируем handle в hudRef чтобы родитель мог посылать команды
|
||
useEffect(() => {
|
||
if (!hudRef) return;
|
||
hudRef.current = {
|
||
handle: ({ cmd, payload }) => {
|
||
if (cmd === 'ui.set') {
|
||
const { id, text, opts } = payload || {};
|
||
if (!id) return;
|
||
setLabels((prev) => {
|
||
const cur = prev[id];
|
||
// Диф: если ничего не поменялось — возвращаем тот же объект,
|
||
// React не перерендерит. Это критично для скриптов которые
|
||
// вызывают ui.set каждый кадр (60 fps) с одним и тем же текстом —
|
||
// без диффа React всё равно перерисовывает HUD каждый кадр.
|
||
if (text == null || text === '') {
|
||
if (!cur) return prev;
|
||
const next = { ...prev };
|
||
delete next[id];
|
||
return next;
|
||
}
|
||
const newOpts = opts || cur?.opts || null;
|
||
if (cur && cur.text === text && _optsEqual(cur.opts, newOpts)) return prev;
|
||
return { ...prev, [id]: { text, opts: newOpts } };
|
||
});
|
||
} else if (cmd === 'ui.flash') {
|
||
const seconds = Number(payload?.seconds) || 2;
|
||
if (flashTimerRef.current) clearTimeout(flashTimerRef.current);
|
||
setFlash({ text: payload?.text || '', key: Date.now() });
|
||
flashTimerRef.current = setTimeout(() => setFlash(null), seconds * 1000);
|
||
} else if (cmd === 'ui.clear') {
|
||
setLabels({});
|
||
setFlash(null);
|
||
if (flashTimerRef.current) clearTimeout(flashTimerRef.current);
|
||
}
|
||
},
|
||
// Полный сброс HUD при exit Play
|
||
reset: () => {
|
||
setLabels({});
|
||
setFlash(null);
|
||
if (flashTimerRef.current) clearTimeout(flashTimerRef.current);
|
||
},
|
||
};
|
||
}, [hudRef]);
|
||
|
||
if (!visible) return null;
|
||
|
||
// Спец. метки (счёт, таймер) — фиксированная позиция в правом верхнем углу.
|
||
// Произвольные метки (с opts.x/y) — позиционируем по их opts.
|
||
// Произвольные метки без opts — стек слева сверху.
|
||
const score = labels['__score'];
|
||
const timer = labels['__timer'];
|
||
const otherIds = Object.keys(labels).filter(id => id !== '__score' && id !== '__timer');
|
||
|
||
return (
|
||
<div style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
pointerEvents: 'none',
|
||
zIndex: 30,
|
||
overflow: 'hidden',
|
||
}}>
|
||
{/* Счёт + таймер в правом верхнем углу */}
|
||
{(score || timer) && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: 14, right: 16,
|
||
display: 'flex', flexDirection: 'column', gap: 4,
|
||
alignItems: 'flex-end',
|
||
...DEFAULT_LABEL_STYLE,
|
||
}}>
|
||
{score && (
|
||
<div style={{ background: 'rgba(15,12,8,0.55)', padding: '6px 14px', borderRadius: 6 }}>
|
||
{score.text}
|
||
</div>
|
||
)}
|
||
{timer && (
|
||
<div style={{
|
||
background: 'rgba(15,12,8,0.55)',
|
||
padding: '6px 14px', borderRadius: 6,
|
||
fontVariantNumeric: 'tabular-nums',
|
||
}}>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||
<Icon name="clock" size={14} /> {timer.text}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Произвольные метки */}
|
||
{otherIds.map((id, i) => {
|
||
const lbl = labels[id];
|
||
const o = lbl.opts || {};
|
||
const hasPos = typeof o.x === 'number' || typeof o.y === 'number';
|
||
const style = {
|
||
...DEFAULT_LABEL_STYLE,
|
||
fontSize: o.size || DEFAULT_LABEL_STYLE.fontSize,
|
||
color: o.color || DEFAULT_LABEL_STYLE.color,
|
||
background: 'rgba(15,12,8,0.55)',
|
||
padding: '4px 10px',
|
||
borderRadius: 5,
|
||
// длинные подписи переносятся и остаются по центру,
|
||
// не вылезая за края экрана
|
||
textAlign: 'center',
|
||
maxWidth: '70vw',
|
||
whiteSpace: 'normal',
|
||
wordBreak: 'break-word',
|
||
};
|
||
if (hasPos) {
|
||
return (
|
||
<div key={id} style={{
|
||
...style,
|
||
position: 'absolute',
|
||
left: typeof o.x === 'number' ? `${o.x}%` : undefined,
|
||
top: typeof o.y === 'number' ? `${o.y}%` : undefined,
|
||
transform: 'translate(-50%, -50%)',
|
||
}}>{lbl.text}</div>
|
||
);
|
||
}
|
||
// Без позиции — стек в левом верхнем углу
|
||
return (
|
||
<div key={id} style={{
|
||
...style,
|
||
position: 'absolute',
|
||
left: 16,
|
||
top: 14 + i * 32,
|
||
}}>{lbl.text}</div>
|
||
);
|
||
})}
|
||
|
||
{/* Flash-текст по центру */}
|
||
{flash && (
|
||
<div
|
||
key={flash.key}
|
||
style={{
|
||
position: 'absolute',
|
||
left: '50%', top: '38%',
|
||
transform: 'translate(-50%, -50%)',
|
||
fontSize: 42,
|
||
fontWeight: 800,
|
||
color: '#fff',
|
||
textShadow: '0 4px 16px rgba(0,0,0,0.8), 0 0 4px rgba(0,0,0,1)',
|
||
textAlign: 'center',
|
||
pointerEvents: 'none',
|
||
animation: 'kubikonHudFlash 0.4s cubic-bezier(0.2, 0.8, 0.3, 1.2)',
|
||
}}
|
||
>
|
||
{flash.text}
|
||
</div>
|
||
)}
|
||
{/* Inline keyframes — без CSS-modules */}
|
||
<style>{`
|
||
@keyframes kubikonHudFlash {
|
||
0% { opacity: 0; transform: translate(-50%, -50%) scale(0.7); }
|
||
60% { opacity: 1; transform: translate(-50%, -50%) scale(1.06); }
|
||
100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||
}
|
||
`}</style>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default GameHud;
|