studio/src/editor/GameHud.jsx
МИН 42be04def9
Some checks failed
CI / Lint (pull_request) Failing after 1m10s
CI / Build (pull_request) Successful in 2m2s
CI / Secret scan (pull_request) Successful in 2m36s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
feat(week4): модальные сцены, кастомные скины игрока и вики-гайды по 4 играм
Задача 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>
2026-05-30 00:50:56 +03:00

242 lines
11 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) {
// Расширенный 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;