Some checks failed
Задача 04 — модальные сцены (затемнение + GUI/3D-анимация + блок ввода): - engine/ModalManager.js (state-машина, fade, spotlight-проекция, HighlightLayer) - editor/ModalOverlay.jsx (CSS-overlay + mask-image для spotlight) - PlayerController.setInputBlocked/setCameraFrozen/captureCameraState/focusOnTarget - game.modal.open/close/update/isOpen/onClose + пресеты bossIntro/lootbox/dialog/confirmation - Фиксы ядра: клик GUI-кнопок (realId↔localId), modalOpened через globalEvent, guard от двойного срабатывания колбэков, кнопка ✓ на последней строке диалога Задача 07 — кастомные скины игрока + магазин: - non-humanoid скины (любая 3D-модель): загрузчик, процедурная анимация, настраиваемый AABB, центрирование через pivot-node - PlayerController.reloadSkin (смена скина в Play) - game.player.setSkin/unlockSkin/getAvailableSkins/onSkinChange/openSkinShop + локальная валюта - editor/SkinShopOverlay.jsx (встроенный магазин, клавиша B) + SkinManagerModal.jsx (вкладка «Скины») - сериализация scene.skins, кнопка «Скины» в тулбаре Вики — категория «Разбор готовых игр»: - docsGames.js: группа g5 + 4 карточки (Двор/Витрина GUI/Сундук/Парк) - docsLessons.jsx: 4 подробные статьи-урока для детей - «Открыть мою копию» создаёт копию проекта (оригинал не трогается) Адаптивная ширина кнопки billboard под длинный текст. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
242 lines
11 KiB
JavaScript
242 lines
11 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) {
|
||
// Расширенный compare — учитываем все поля стилизации.
|
||
if (a === b) return true;
|
||
if (!a || !b) return false;
|
||
const keys = ['x','y','color','size','textSize','bold','bg','border',
|
||
'borderRadius','padding','w','h','textAlign','anchor'];
|
||
for (const k of keys) {
|
||
if (a[k] !== b[k]) return false;
|
||
}
|
||
return true;
|
||
}
|
||
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 || {};
|
||
// Поддерживаем как старый формат opts (x/y в %, color, size),
|
||
// так и расширенный (bg, border, borderRadius, padding,
|
||
// w/h/textSize/bold/textAlign, x/y в пикселях или с '%').
|
||
const hasPercentXY = (typeof o.x === 'number' && o.x <= 100 && typeof o.y === 'number' && o.y <= 100)
|
||
&& (o.bg === undefined && o.w === undefined && o.h === undefined);
|
||
const usePixelPos = (typeof o.x === 'number' && !hasPercentXY)
|
||
|| typeof o.x === 'string';
|
||
const style = {
|
||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||
fontWeight: o.bold ? 800 : DEFAULT_LABEL_STYLE.fontWeight,
|
||
fontSize: o.textSize || o.size || DEFAULT_LABEL_STYLE.fontSize,
|
||
color: o.color || DEFAULT_LABEL_STYLE.color,
|
||
background: o.bg || 'rgba(15,12,8,0.55)',
|
||
padding: o.padding != null ? o.padding : '4px 10px',
|
||
borderRadius: o.borderRadius != null ? o.borderRadius : 5,
|
||
border: o.border || undefined,
|
||
textAlign: o.textAlign || 'center',
|
||
maxWidth: '70vw',
|
||
whiteSpace: 'pre-line',
|
||
wordBreak: 'break-word',
|
||
width: o.w != null ? o.w : undefined,
|
||
height: o.h != null ? o.h : undefined,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: o.textAlign === 'left' ? 'flex-start' : 'center',
|
||
boxSizing: 'border-box',
|
||
};
|
||
if (hasPercentXY) {
|
||
return (
|
||
<div key={id} style={{
|
||
...style,
|
||
position: 'absolute',
|
||
left: `${o.x}%`,
|
||
top: `${o.y}%`,
|
||
transform: 'translate(-50%, -50%)',
|
||
}}>{lbl.text}</div>
|
||
);
|
||
}
|
||
if (usePixelPos) {
|
||
// Якорь: 'center' — translate(-50%,-50%); по умолчанию top-left
|
||
const isCenter = o.anchor === 'center';
|
||
const leftVal = typeof o.x === 'string' ? o.x : `${o.x}px`;
|
||
const topVal = typeof o.y === 'string' ? o.y : `${o.y}px`;
|
||
return (
|
||
<div key={id} style={{
|
||
...style,
|
||
position: 'absolute',
|
||
left: leftVal,
|
||
top: topVal,
|
||
transform: isCenter ? 'translate(-50%, -50%)' : undefined,
|
||
}}>{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;
|