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>
102 lines
4.9 KiB
JavaScript
102 lines
4.9 KiB
JavaScript
/**
|
||
* ModalOverlay — рендерит затемнение модальной сцены.
|
||
* Задача 04. Подписан на ModalManager.setOnChange — получает state.
|
||
*
|
||
* Архитектура:
|
||
* - Слой ПОД GUI-overlay (z-index ниже GuiOverlay) но НАД Babylon-канвасом.
|
||
* - Если target='screen' — слой поверх ВСЕГО (включая GUI). z-index выше.
|
||
* - Spotlights через CSS mask-image: radial-gradient(...) — вырезает «дырки».
|
||
* - pointer-events: auto когда модал открыт (перехватывает клики кроме GUI).
|
||
*/
|
||
|
||
import React, { useEffect, useState } from 'react';
|
||
|
||
export default function ModalOverlay({ scene }) {
|
||
const [state, setState] = useState(null);
|
||
|
||
// Поллинг — надёжнее чем setOnChange callback, который может перетереться
|
||
// или не вызваться если scene изменился на следующем кадре.
|
||
useEffect(() => {
|
||
if (!scene?.modalManager) return;
|
||
let cancelled = false;
|
||
const tick = () => {
|
||
if (cancelled) return;
|
||
const s = scene.modalManager.getState?.();
|
||
// Снимок shallow-clone — иначе React не увидит изменение
|
||
setState(s ? {
|
||
id: s.id,
|
||
fadePhase: s.fadePhase,
|
||
currentAlpha: s.currentAlpha,
|
||
opts: s.opts,
|
||
spotlightScreens: s.spotlightScreens,
|
||
} : null);
|
||
requestAnimationFrame(tick);
|
||
};
|
||
tick();
|
||
return () => { cancelled = true; };
|
||
}, [scene]);
|
||
|
||
if (!state || state.fadePhase === 'closed') return null;
|
||
if (state.currentAlpha <= 0.001) return null;
|
||
console.log('[ModalOverlay] RENDERING alpha=', state.currentAlpha.toFixed(2), 'phase=', state.fadePhase, 'target=', state.opts?.target);
|
||
|
||
const opts = state.opts;
|
||
const isScreen = opts.target === 'screen';
|
||
const color = opts.darkenColor || '#000000';
|
||
const alpha = Math.max(0, Math.min(1, state.currentAlpha));
|
||
// RGBA bg
|
||
const bg = _hexToRgba(color, alpha);
|
||
|
||
// mask-image для spotlights (только для target='scene' — на 'screen' нет смысла)
|
||
let maskStyle = {};
|
||
if (!isScreen && Array.isArray(state.spotlightScreens) && state.spotlightScreens.length) {
|
||
const softEdge = opts.spotlightSoftEdge ?? 40;
|
||
const gradients = state.spotlightScreens.map(s => {
|
||
const inner = Math.max(0, s.r - softEdge);
|
||
const outer = s.r;
|
||
// mask-image: внутри круга — transparent (вырезаем), снаружи — black (показываем затемнение)
|
||
return `radial-gradient(circle at ${s.x.toFixed(0)}px ${s.y.toFixed(0)}px, transparent ${inner}px, black ${outer}px)`;
|
||
});
|
||
maskStyle = {
|
||
WebkitMaskImage: gradients.join(', '),
|
||
maskImage: gradients.join(', '),
|
||
WebkitMaskComposite: 'source-in',
|
||
maskComposite: 'intersect',
|
||
};
|
||
}
|
||
|
||
// ВАЖНО pointer-events: none — иначе overlay перехватывает клики и кнопки модала не работают.
|
||
// Затемнение — это просто визуальный фильтр, blockInput реализован в PlayerController.
|
||
// zIndex:
|
||
// target='scene' → 24 (под GuiOverlay zIndex=25 чтобы GUI был ВИДЕН поверх затемнения)
|
||
// target='screen' → 60 (поверх GUI — закрывает ВСЁ)
|
||
// Для 'screen' GUI модала всё равно поверх (GuiOverlay zIndex=25, наш ScreenOverlay 60,
|
||
// GUI элементы модала рендерятся в GuiOverlay — поэтому надо ставить их в отдельный
|
||
// слой ВЫШЕ overlay). Простой фикс: для screen ставим overlay на 24 тоже.
|
||
const zIdx = 24;
|
||
return (
|
||
<div
|
||
style={{
|
||
position: 'absolute', inset: 0,
|
||
background: bg,
|
||
zIndex: zIdx,
|
||
pointerEvents: 'none', // НЕ перехватываем клики — иначе кнопки не работают
|
||
transition: 'background-color 0.05s linear',
|
||
...maskStyle,
|
||
}}
|
||
data-modal-overlay={state.id}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function _hexToRgba(hex, a) {
|
||
if (typeof hex !== 'string' || !hex.startsWith('#')) return `rgba(0,0,0,${a})`;
|
||
let h = hex.slice(1);
|
||
if (h.length === 3) h = h.split('').map(c => c + c).join('');
|
||
if (h.length !== 6) return `rgba(0,0,0,${a})`;
|
||
const r = parseInt(h.slice(0, 2), 16);
|
||
const g = parseInt(h.slice(2, 4), 16);
|
||
const b = parseInt(h.slice(4, 6), 16);
|
||
return `rgba(${r},${g},${b},${a})`;
|
||
}
|