player/src/editor-shared/GameHud.jsx
МИН 87444ee2c8 Initial public release: Rublox Player v1.0
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)
2026-05-27 23:04:04 +03:00

210 lines
9.0 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 } 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;