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 (
{/* Счёт + таймер в правом верхнем углу */} {(score || timer) && (
{score && (
{score.text}
)} {timer && (
{timer.text}
)}
)} {/* Произвольные метки */} {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 (
{lbl.text}
); } // Без позиции — стек в левом верхнем углу return (
{lbl.text}
); })} {/* Flash-текст по центру */} {flash && (
{flash.text}
)} {/* Inline keyframes — без CSS-modules */}
); } export default GameHud;