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>
399 lines
17 KiB
JavaScript
399 lines
17 KiB
JavaScript
/**
|
||
* 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) {}
|
||
}
|
||
}
|
||
}
|