/** * ModalManager — модальные сцены (затемнение + GUI поверх + блок ввода). * * Задача 04 из «1 - Неделя 4/ЗАДАЧИ РУБЛОКС/04_modal_cutscene.md». * * Типовой кейс: boss-fight intro / открытие лутбокса / диалог с NPC / получил * питомца. Скрипт зовёт `game.modal.open(opts)` → весь 3D-мир затемняется * (но HUD остаётся), управление блокируется, поверх показывается контент. * * Координирует: * - DOM overlay (рендерится в KubikonEditor/KubikonPlayer) * - PlayerController.setInputBlocked / setCameraFrozen * - HighlightLayer Babylon (spotlight-объекты светятся) * - GameRuntime.paused (опционально, через pauseSimulation) * - AudioManager.duck (опционально, через muteWorld) * - GuiManager (временные элементы создаются/удаляются с модалом) * * Не зависит от React — просто состояние и колбэки. * * Архитектура: * _state = { * id, opts, * fadePhase: 'in'|'visible'|'out'|'closed', * fadeStart: ms, fadeFrom: 0..1, fadeTo: 0..1, * currentAlpha: 0..1, * tempGuiIds: [...], — id-шники созданных временных GUI-элементов * spotlightScreens: [{x,y,r}], — позиции spotlight'ов в экранных координатах * } * * Активен только ОДИН модал одновременно (Roblox-style). Повторный open * автоматически закрывает предыдущий (через close+open). */ let _seq = 1; export class ModalManager { constructor() { /** @type {object|null} текущий модал, null если закрыт */ this._state = null; /** @type {Function|null} вызывается когда меняется state — UI пере-рендерится */ this._onChange = null; /** Babylon scene нужна для HighlightLayer и Vector3.Project */ this._scene = null; /** PlayerController для блока ввода/freeze камеры */ this._player = null; /** GuiManager для temp-элементов */ this._gui = null; /** GameRuntime для pauseSimulation */ this._runtime = null; /** AudioManager для muteWorld */ this._audio = null; /** HighlightLayer Babylon — создаётся лениво при первом spotlight */ this._highlight = null; /** Колбэки onClose — массив функций (modalId) => void */ this._closeCallbacks = []; /** Прежний WASD-state и FOV — для восстановления */ this._savedCameraState = null; } setOnChange(cb) { this._onChange = cb; } _notify() { if (this._onChange) try { this._onChange(this._state); } catch (e) {} } attachScene(scene) { this._scene = scene; } attachPlayer(player) { this._player = player; } attachGui(gui) { this._gui = gui; } attachRuntime(runtime) { this._runtime = runtime; } attachAudio(audio) { this._audio = audio; } /** Открыт ли сейчас модал. */ isOpen() { return !!this._state && this._state.fadePhase !== 'closed'; } /** Получить текущий state (для UI-overlay). */ getState() { return this._state; } /** * Открыть модал. opts — см. doc по задаче 04. * Возвращает modalId (число). */ open(opts) { opts = opts || {}; console.log('[ModalManager] open called, opts:', opts); // Если уже открыт — мгновенно закрываем (без fadeOut, чтобы не плодить // одновременных модалов). if (this.isOpen()) this._instantClose(); const id = ++_seq; const norm = { darken: Number.isFinite(opts.darken) ? Math.max(0, Math.min(1, opts.darken)) : 0.5, darkenColor: typeof opts.darkenColor === 'string' ? opts.darkenColor : '#000000', target: opts.target === 'screen' ? 'screen' : 'scene', blockInput: opts.blockInput !== false, // по умолчанию true freezeCamera: !!opts.freezeCamera, cameraOverride: opts.cameraOverride || null, fadeIn: Number.isFinite(opts.fadeIn) ? Math.max(0, opts.fadeIn) : 0.3, fadeOut: Number.isFinite(opts.fadeOut) ? Math.max(0, opts.fadeOut) : 0.3, spotlights: Array.isArray(opts.spotlights) ? opts.spotlights.slice() : [], spotlightRadius: Number.isFinite(opts.spotlightRadius) ? opts.spotlightRadius : 120, spotlightSoftEdge: Number.isFinite(opts.spotlightSoftEdge) ? opts.spotlightSoftEdge : 40, pauseSimulation: !!opts.pauseSimulation, muteWorld: !!opts.muteWorld, content: opts.content || null, }; this._state = { id, opts: norm, fadePhase: 'in', fadeStart: this._now(), fadeFrom: 0, fadeTo: norm.darken, currentAlpha: 0, tempGuiIds: [], spotlightScreens: [], }; // 1) Block input if (norm.blockInput) { try { this._player?.setInputBlocked?.(true); } catch (e) {} } // 2) Freeze camera (сохраняем текущее состояние для восстановления) if (norm.freezeCamera) { try { this._savedCameraState = this._player?.captureCameraState?.() || null; this._player?.setCameraFrozen?.(true); } catch (e) {} } // 3) Camera override — переключение на focusOn if (norm.cameraOverride && this._scene) { this._applyCameraOverride(norm.cameraOverride); } // 4) Pause simulation if (norm.pauseSimulation && this._runtime) { try { this._runtime.paused = true; } catch (e) {} } // 5) Mute world audio if (norm.muteWorld && this._audio) { try { this._audio.duck?.(0.3); } catch (e) {} } // 6) Highlight spotlight-объектов в Babylon if (norm.spotlights.length && norm.target === 'scene' && this._scene) { this._applyHighlight(norm.spotlights); } // 7) content.elements — создать временные GUI-элементы if (norm.content?.elements && this._gui) { this._createTempGui(norm.content.elements); } this._notify(); return id; } /** Закрыть модал. Если modalId передан и не совпадает — игнор. */ close(modalId) { if (!this._state) return; if (modalId != null && this._state.id !== modalId) return; if (this._state.fadePhase === 'out' || this._state.fadePhase === 'closed') return; this._state.fadePhase = 'out'; this._state.fadeStart = this._now(); this._state.fadeFrom = this._state.currentAlpha; this._state.fadeTo = 0; this._notify(); } /** Поменять параметры на лету. */ update(modalId, patch) { if (!this._state) return; if (modalId != null && this._state.id !== modalId) return; if (!patch || typeof patch !== 'object') return; Object.assign(this._state.opts, patch); // Если поменяли darken — плавно tween-им currentAlpha к новому значению if (Number.isFinite(patch.darken) && this._state.fadePhase !== 'out') { this._state.fadeFrom = this._state.currentAlpha; this._state.fadeTo = patch.darken; this._state.fadeStart = this._now(); this._state.fadePhase = 'in'; } this._notify(); } /** Подписаться на закрытие. fn получает modalId. */ onClose(fn) { if (typeof fn === 'function') this._closeCallbacks.push(fn); } /** Обновление за кадр — двигает fade-phase и spotlight-screens. dt в секундах. */ tick(dt) { if (!this._state) return; const st = this._state; if (!this._tickLogged) { this._tickLogged = true; console.log('[ModalManager] first tick, phase:', st.fadePhase, 'alpha:', st.currentAlpha); } // 1) Fade-tween if (st.fadePhase === 'in' || st.fadePhase === 'out') { const dur = st.fadePhase === 'in' ? st.opts.fadeIn : st.opts.fadeOut; const elapsed = (this._now() - st.fadeStart) / 1000; const t = dur > 0 ? Math.min(1, elapsed / dur) : 1; // ease-out cubic const k = 1 - Math.pow(1 - t, 3); st.currentAlpha = st.fadeFrom + (st.fadeTo - st.fadeFrom) * k; if (t >= 1) { if (st.fadePhase === 'in') { st.fadePhase = 'visible'; } else { // close завершился — финальная уборка st.fadePhase = 'closed'; this._teardown(); } } } // 2) Обновить экранные координаты spotlight'ов (объекты могут двигаться) if (st.fadePhase !== 'closed' && st.opts.spotlights.length && st.opts.target === 'scene') { st.spotlightScreens = this._computeSpotlightScreens(st.opts.spotlights); } this._notify(); } // ===== private ===== _now() { return (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); } _instantClose() { if (!this._state) return; this._teardown(); this._state = null; } _teardown() { const st = this._state; if (!st) return; // 1) Unblock input if (st.opts.blockInput) { try { this._player?.setInputBlocked?.(false); } catch (e) {} } // 2) Unfreeze camera if (st.opts.freezeCamera) { try { this._player?.setCameraFrozen?.(false); } catch (e) {} } // 3) Camera reset — только если был cameraOverride if (st.opts.cameraOverride && this._savedCameraState) { try { this._player?.restoreCameraState?.(this._savedCameraState); } catch (e) {} this._savedCameraState = null; } // 4) Unpause if (st.opts.pauseSimulation && this._runtime) { try { this._runtime.paused = false; } catch (e) {} } // 5) Unmute if (st.opts.muteWorld && this._audio) { try { this._audio.unduck?.(); } catch (e) {} } // 6) Снять highlight if (this._highlight) { try { this._highlight.removeAllMeshes(); } catch (e) {} } // 7) Удалить temp GUI if (st.tempGuiIds.length && this._gui) { for (const id of st.tempGuiIds) { try { this._gui.remove(id); } catch (e) {} } } // 8) Колбэки onClose for (const cb of this._closeCallbacks) { try { cb(st.id); } catch (e) {} } } _applyCameraOverride(co) { // Используем существующий camera.focusOn механизм из BabylonScene/PlayerController try { const ref = co.target; const distance = Number.isFinite(co.distance) ? co.distance : 8; const height = Number.isFinite(co.height) ? co.height : 3; const fov = Number.isFinite(co.fov) ? co.fov : null; const duration = Number.isFinite(co.duration) ? co.duration : 0.5; if (this._player?.focusOnTarget) { this._player.focusOnTarget(ref, { distance, height, fov, duration }); } else if (this._scene?._gameRuntime?._handleCommand) { // fallback через runtime — отправляем camera.focus this._scene._gameRuntime._handleCommand(null, 'camera.focus', { ref, distance, height, fov, duration, }); } } catch (e) {} } _applyHighlight(refs) { if (!this._scene) return; // Лениво создаём HighlightLayer if (!this._highlight) { try { const BABYLON = window.BABYLON || (typeof globalThis !== 'undefined' ? globalThis.BABYLON : null); if (BABYLON?.HighlightLayer && this._scene.scene) { this._highlight = new BABYLON.HighlightLayer('modal-spotlight', this._scene.scene); this._highlight.innerGlow = false; this._highlight.outerGlow = true; } } catch (e) {} } if (!this._highlight) return; try { this._highlight.removeAllMeshes(); } catch (e) {} const BABYLON = window.BABYLON; const glowColor = (BABYLON && BABYLON.Color3) ? new BABYLON.Color3(1, 1, 0.6) : null; for (const ref of refs) { const meshes = this._resolveMeshes(ref); for (const m of meshes) { try { if (glowColor) this._highlight.addMesh(m, glowColor); } catch (e) {} } } } /** Резолв ref → массив Babylon-мешей. * ref может быть: строка-id, объект ref-обёртка ({kind, id}), либо сам Mesh. */ _resolveMeshes(ref) { if (!ref || !this._scene) return []; // Уже Mesh-инстанс if (ref.getScene && typeof ref.getScene === 'function') return [ref]; const sc = this._scene; const idStr = (typeof ref === 'string') ? ref : (ref.id || ref.refId || null); if (!idStr) return []; // Пробуем разные менеджеры const tryGetters = [ () => sc.primitiveManager?.getMesh?.(idStr), () => sc.modelManager?.getInstanceMeshes?.(idStr), () => sc.scene?.getMeshByName?.(idStr), () => sc.npcManager?.getMeshes?.(idStr), () => sc.zombieManager?.getMeshes?.(idStr), ]; for (const g of tryGetters) { try { const r = g(); if (!r) continue; if (Array.isArray(r)) return r; return [r]; } catch (e) {} } return []; } /** Проектируем 3D-позиции spotlight-refs в экранные координаты для CSS-mask. */ _computeSpotlightScreens(refs) { if (!this._scene?.scene) return []; const out = []; const BABYLON = window.BABYLON; if (!BABYLON) return []; const engine = this._scene.scene.getEngine(); const camera = this._scene.scene.activeCamera; if (!camera || !engine) return []; const w = engine.getRenderWidth(); const h = engine.getRenderHeight(); const matrix = camera.getTransformationMatrix(); const viewport = camera.viewport.toGlobal(w, h); for (const ref of refs) { const meshes = this._resolveMeshes(ref); if (!meshes.length) continue; const m = meshes[0]; try { const pos = m.getAbsolutePosition?.() || m.position; if (!pos) continue; // Center проектируем const proj = BABYLON.Vector3.Project(pos, BABYLON.Matrix.Identity(), matrix, viewport); // Если за камерой — скип (z вне 0..1) if (proj.z < 0 || proj.z > 1) continue; // Радиус — фиксированный из opts (можно потом масштабировать по distance/size) out.push({ x: proj.x, y: proj.y, r: this._state.opts.spotlightRadius }); } catch (e) {} } return out; } _createTempGui(elements) { if (!Array.isArray(elements) || !this._gui) return; for (const el of elements) { if (!el || typeof el !== 'object') continue; const kind = el.kind || el.type || 'frame'; const opts = { ...el }; delete opts.kind; delete opts.type; try { const id = this._gui.create(kind, opts); if (id) this._state.tempGuiIds.push(id); } catch (e) {} } } }