studio/src/editor/ModalOverlay.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

102 lines
4.9 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.

/**
* 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})`;
}