studio/src/editor/engine/ModalManager.js
МИН 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

399 lines
17 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.

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