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>
334 lines
16 KiB
JavaScript
334 lines
16 KiB
JavaScript
/**
|
||
* GuiManager — слой UI-элементов поверх viewport (как ScreenGui в Roblox).
|
||
*
|
||
* Хранит массив элементов: Frame, TextLabel, TextButton, ImageLabel.
|
||
* Не зависит от Babylon — просто состояние, которое рендерит GuiOverlay (React).
|
||
*
|
||
* Координаты позиции/размера хранятся в процентах (0..100), чтобы интерфейс
|
||
* масштабировался под любое разрешение. Также можно задавать в пикселях через
|
||
* pxX/pxY/pxW/pxH (если нужна абсолютная привязка к углам).
|
||
*
|
||
* Элемент:
|
||
* {
|
||
* id: 'gui_1', — уникальный
|
||
* type: 'frame'|'text'|'button'|'image',
|
||
* name: 'Frame 1',
|
||
* x: 50, y: 50, — % позиции центра по экрану (по умолчанию)
|
||
* w: 30, h: 20, — % размера
|
||
* anchor: 'center', — 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center'
|
||
* visible: true,
|
||
* // визуальные стили
|
||
* bgColor: '#1f1810',
|
||
* bgOpacity: 0.85,
|
||
* borderColor: '#5a4a3a',
|
||
* borderWidth: 1,
|
||
* borderRadius: 6,
|
||
* // text/button only:
|
||
* text: 'Hello',
|
||
* textColor: '#f0e6d8',
|
||
* textSize: 16,
|
||
* textAlign: 'center', — 'left' | 'center' | 'right'
|
||
* fontWeight: 600,
|
||
* // image only:
|
||
* imageUrl: '', — внешний URL картинки
|
||
* imageAsset: null, — id картинки из AssetManager (приоритет над imageUrl)
|
||
* // зарезервировано для следующих этапов
|
||
* onClickScriptId: null, — скрипт по клику (для button)
|
||
* }
|
||
*/
|
||
|
||
let _seq = 1;
|
||
|
||
/** True если элемент может быть родителем (контейнер): frame или scroll. */
|
||
function _isContainer(el) {
|
||
return !!el && (el.type === 'frame' || el.type === 'scroll');
|
||
}
|
||
|
||
export class GuiManager {
|
||
constructor() {
|
||
/** @type {Array<object>} */
|
||
this.elements = [];
|
||
this._onChange = null;
|
||
}
|
||
|
||
setOnChange(cb) { this._onChange = cb; }
|
||
_notify() { if (this._onChange) try { this._onChange(); } catch (e) { /* ignore */ } }
|
||
|
||
/** Создать элемент. type: 'frame'|'text'|'button'|'image'. Возвращает id.
|
||
* opts.parentId — id Frame'а в который вкладывается элемент. По умолчанию null (на экране). */
|
||
create(type, opts = {}) {
|
||
const id = opts.id || `gui_${Date.now()}_${(_seq++).toString(36)}`;
|
||
// Валидация parentId — должен существовать и быть Frame'ом
|
||
let parentId = opts.parentId || null;
|
||
if (parentId) {
|
||
const parent = this.elements.find(e => e.id === parentId);
|
||
if (!_isContainer(parent)) parentId = null;
|
||
}
|
||
// === GD HUD layout override (2026-05-19) ===
|
||
// В скриптах L1-L20 счётчики 🪙 (Coins) и 💎 (Diamonds) изначально
|
||
// налезали на кнопку меню ☰ (x=94, w=5.5). Сдвигаем без правки БД.
|
||
// Узнаём по тексту-эмодзи (другие скрипты эти эмодзи не используют).
|
||
//
|
||
// Целевой layout HUD-полосы:
|
||
// Title (центр): x=20 w=30 → [20..50]
|
||
// Diamonds: x=52 w=10 → [52..62] right-align
|
||
// Coins: x=62 w=30 → [62..92] right-align
|
||
// Menu ☰: x=94 w=5.5 → [94..99.5] (не трогаем)
|
||
if (type === 'text' && typeof opts.text === 'string') {
|
||
// Coins: '🪙 N/3' на x=76 w=22 → x=62 w=30
|
||
if (opts.text.indexOf('🪙') >= 0 && opts.x === 76 && opts.w === 22) {
|
||
opts = { ...opts, x: 62, w: 30 };
|
||
}
|
||
// Diamonds: '💎 N/M' на x=58 w=18 → x=52 w=10 (узкая колонка)
|
||
if ((opts.text.indexOf('\u{1F48E}') >= 0 || opts.text.indexOf('💎') >= 0)
|
||
&& opts.x === 58 && opts.w === 18) {
|
||
opts = { ...opts, x: 52, w: 10 };
|
||
}
|
||
// Title 'L4 · Быстрее ветра' на x=30 w=40 → x=20 w=30 (центр сдвинут влево)
|
||
if (/^L\d+\s/.test(opts.text) && opts.x === 30 && opts.w === 40) {
|
||
opts = { ...opts, x: 20, w: 30 };
|
||
}
|
||
}
|
||
const base = {
|
||
id,
|
||
type,
|
||
name: opts.name || _defaultName(type, this.elements.length + 1),
|
||
parentId,
|
||
x: opts.x ?? 50,
|
||
y: opts.y ?? 50,
|
||
w: opts.w ?? _defaultSize(type).w,
|
||
h: opts.h ?? _defaultSize(type).h,
|
||
anchor: opts.anchor || 'center',
|
||
// Phase 6.3.1: AnchorPoint -- точка ВНУТРИ элемента, относительно
|
||
// которой считается позиция x/y. По умолчанию НЕ задан -- рендерер
|
||
// вычислит дефолт из anchor (center → {0.5,0.5}, top-left → {0,0}, ...).
|
||
// Юзер может override через opts.anchorPoint = {x:0..1, y:0..1}.
|
||
anchorPoint: opts.anchorPoint || null,
|
||
visible: opts.visible !== false,
|
||
bgColor: opts.bgColor ?? _defaultBgColor(type),
|
||
bgOpacity: opts.bgOpacity ?? _defaultBgOpacity(type),
|
||
borderColor: opts.borderColor ?? '#5a4a3a',
|
||
borderWidth: opts.borderWidth ?? (type === 'frame' || type === 'button' ? 1 : 0),
|
||
borderRadius: opts.borderRadius ?? 6,
|
||
text: opts.text ?? _defaultText(type),
|
||
textColor: opts.textColor ?? '#f0e6d8',
|
||
textSize: opts.textSize ?? 16,
|
||
textAlign: opts.textAlign ?? (type === 'textbox' ? 'left' : 'center'),
|
||
fontWeight: opts.fontWeight ?? (type === 'button' ? 700 : 500),
|
||
imageUrl: opts.imageUrl ?? '',
|
||
// imageAsset — id картинки из AssetManager (этап 3.6). Если задан,
|
||
// имеет приоритет над imageUrl: GuiOverlay показывает dataURL ассета.
|
||
imageAsset: opts.imageAsset ?? null,
|
||
// placeholder — серый текст-подсказка в пустом поле ввода (textbox)
|
||
placeholder: opts.placeholder ?? (type === 'textbox' ? 'Введите текст…' : ''),
|
||
onClickScriptId: opts.onClickScriptId ?? null,
|
||
zIndex: opts.zIndex ?? this.elements.length + 1,
|
||
// Авто-раскладка детей (Фаза 5.3 + 6.3.2):
|
||
// 'none' -- дети как есть (по своим x/y);
|
||
// 'vertical' -- дети в столбик; 'horizontal' -- в строку;
|
||
// 'grid' -- сетка (требует layoutCellW/H/Cols).
|
||
layout: opts.layout ?? 'none',
|
||
// Отступ между детьми и внутреннее поле контейнера (в % размера контейнера).
|
||
layoutGap: opts.layoutGap ?? 2,
|
||
layoutPad: opts.layoutPad ?? 3,
|
||
// Phase 6.3.2: параметры grid-layout.
|
||
// cellW/H -- размер каждой ячейки в %; cols -- количество колонок (0 = авто).
|
||
layoutCellW: opts.layoutCellW ?? 18,
|
||
layoutCellH: opts.layoutCellH ?? 18,
|
||
layoutCols: opts.layoutCols ?? 0,
|
||
// Текущая прокрутка scroll-контейнера (в %, только для type='scroll').
|
||
scrollY: opts.scrollY ?? 0,
|
||
// Тень под элементом (Фаза 5.4) — мягкая drop-shadow.
|
||
shadow: opts.shadow ?? false,
|
||
// === Задача 03: расширения для красивого UI + анимаций ===
|
||
// Линейный градиент фона. Формат: { stops: ['#a','#b'] | [{c,p}], angle: 0..360 }.
|
||
bgGradient: opts.bgGradient ?? null,
|
||
// Обводка текста (для крупных подписей "X2 ДЕНЕГ"). { color, width }.
|
||
textStroke: opts.textStroke ?? null,
|
||
// Поворот элемента в градусах (transform: rotate).
|
||
rotation: opts.rotation ?? 0,
|
||
// Scale-множитель (transform: scale). 1 = нормальный размер.
|
||
scaleX: opts.scaleX ?? 1,
|
||
scaleY: opts.scaleY ?? 1,
|
||
// Бейдж-маркер в углу: { corner, icon, color, text }.
|
||
badge: opts.badge ?? null,
|
||
// Hover-реакция (только для button/image-button): { scale, rotation, brightness, duration, easing }.
|
||
hover: opts.hover ?? null,
|
||
// Active-реакция (зажатие ЛКМ): { scale, duration }.
|
||
active: opts.active ?? null,
|
||
// Анимация-пресет: 'none'|'pulse'|'rotate'|'sway'|'glow'|'bounce'|'custom'.
|
||
// Раскрывается в реальный tween при applyAnimationPreset(id) в Play.
|
||
animationPreset: opts.animationPreset ?? 'none',
|
||
// Создан скриптом в Play (game.gui.create) — НЕ сериализуется
|
||
// в проект, удаляется при Stop.
|
||
_scriptCreated: opts._scriptCreated === true,
|
||
};
|
||
this.elements.push(base);
|
||
this._notify();
|
||
return id;
|
||
}
|
||
|
||
get(id) {
|
||
return this.elements.find(e => e.id === id) || null;
|
||
}
|
||
|
||
update(id, patch) {
|
||
const el = this.get(id);
|
||
if (!el) return;
|
||
// Валидация parentId — нельзя сделать элемент потомком самого себя
|
||
// или своего же потомка (циклы), и parent должен быть Frame
|
||
if ('parentId' in patch) {
|
||
const next = patch.parentId || null;
|
||
if (next === id) { delete patch.parentId; }
|
||
else if (next != null && this._isDescendant(next, id)) { delete patch.parentId; }
|
||
else if (next != null) {
|
||
const p = this.get(next);
|
||
if (!_isContainer(p)) delete patch.parentId;
|
||
}
|
||
}
|
||
Object.assign(el, patch);
|
||
this._notify();
|
||
}
|
||
|
||
/** True если `candidate` является (косвенным) потомком `ancestorId`. */
|
||
_isDescendant(candidate, ancestorId) {
|
||
let cur = this.get(candidate);
|
||
let safety = 0;
|
||
while (cur && cur.parentId && safety++ < 100) {
|
||
if (cur.parentId === ancestorId) return true;
|
||
cur = this.get(cur.parentId);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** Сменить родителя элемента. */
|
||
setParent(id, parentId) {
|
||
this.update(id, { parentId: parentId || null });
|
||
}
|
||
|
||
/** Список прямых детей frame-id (или корневых элементов если parentId === null). */
|
||
children(parentId) {
|
||
return this.elements.filter(e => (e.parentId || null) === (parentId || null));
|
||
}
|
||
|
||
remove(id) {
|
||
const i = this.elements.findIndex(e => e.id === id);
|
||
if (i < 0) return;
|
||
// Каскадно удаляем всех потомков
|
||
const queue = [id];
|
||
const toDelete = new Set();
|
||
while (queue.length > 0) {
|
||
const cur = queue.shift();
|
||
toDelete.add(cur);
|
||
for (const e of this.elements) {
|
||
if (e.parentId === cur) queue.push(e.id);
|
||
}
|
||
}
|
||
this.elements = this.elements.filter(e => !toDelete.has(e.id));
|
||
this._notify();
|
||
}
|
||
|
||
rename(id, name) {
|
||
const el = this.get(id);
|
||
if (!el) return;
|
||
el.name = String(name || '').slice(0, 80);
|
||
this._notify();
|
||
}
|
||
|
||
/** Поднять/опустить элемент в z-order (вверх = ближе к зрителю). */
|
||
moveZ(id, direction) {
|
||
const i = this.elements.findIndex(e => e.id === id);
|
||
if (i < 0) return;
|
||
const target = direction === 'up' ? i + 1 : i - 1;
|
||
if (target < 0 || target >= this.elements.length) return;
|
||
const tmp = this.elements[i];
|
||
this.elements[i] = this.elements[target];
|
||
this.elements[target] = tmp;
|
||
// Перенумеровываем zIndex по порядку
|
||
this.elements.forEach((e, idx) => { e.zIndex = idx + 1; });
|
||
this._notify();
|
||
}
|
||
|
||
getAll() { return this.elements.slice(); }
|
||
|
||
serialize() {
|
||
// Элементы, созданные скриптом в Play (game.gui.create), НЕ
|
||
// сохраняем в проект — иначе автосейв запишет их в БД и они
|
||
// «вернутся» после Stop. Служебное поле _scriptCreated не пишем.
|
||
return this.elements
|
||
.filter(e => !e._scriptCreated)
|
||
.map(e => {
|
||
const c = { ...e };
|
||
delete c._scriptCreated;
|
||
return c;
|
||
});
|
||
}
|
||
|
||
loadFromArray(arr) {
|
||
this.elements = Array.isArray(arr)
|
||
? arr.filter(e => e && typeof e.id === 'string' && typeof e.type === 'string')
|
||
.map(e => ({ ...e, parentId: e.parentId || null }))
|
||
: [];
|
||
// Чистим висящие parentId на несуществующих или не-frame родителей
|
||
const ids = new Set(this.elements.map(e => e.id));
|
||
for (const el of this.elements) {
|
||
if (el.parentId && !ids.has(el.parentId)) { el.parentId = null; }
|
||
else if (el.parentId) {
|
||
const p = this.elements.find(e => e.id === el.parentId);
|
||
if (!_isContainer(p)) el.parentId = null;
|
||
}
|
||
}
|
||
this._notify();
|
||
}
|
||
|
||
clear() {
|
||
if (this.elements.length === 0) return;
|
||
this.elements = [];
|
||
this._notify();
|
||
}
|
||
}
|
||
|
||
function _defaultName(type, n) {
|
||
if (type === 'frame') return `Контейнер ${n}`;
|
||
if (type === 'text') return `Надпись ${n}`;
|
||
if (type === 'button') return `Кнопка ${n}`;
|
||
if (type === 'image') return `Картинка ${n}`;
|
||
if (type === 'textbox') return `Поле ввода ${n}`;
|
||
if (type === 'scroll') return `Список ${n}`;
|
||
return `Элемент ${n}`;
|
||
}
|
||
|
||
function _defaultSize(type) {
|
||
if (type === 'frame') return { w: 30, h: 20 };
|
||
if (type === 'text') return { w: 20, h: 6 };
|
||
if (type === 'button') return { w: 18, h: 8 };
|
||
if (type === 'image') return { w: 15, h: 15 };
|
||
if (type === 'textbox') return { w: 24, h: 8 };
|
||
if (type === 'scroll') return { w: 30, h: 40 };
|
||
return { w: 20, h: 10 };
|
||
}
|
||
|
||
function _defaultText(type) {
|
||
if (type === 'text') return 'Текст';
|
||
if (type === 'button') return 'Кнопка';
|
||
return ''; // textbox стартует пустым
|
||
}
|
||
|
||
function _defaultBgColor(type) {
|
||
if (type === 'frame') return '#1f1810';
|
||
if (type === 'button') return '#5a8c3e';
|
||
if (type === 'text') return 'transparent';
|
||
if (type === 'image') return 'transparent';
|
||
if (type === 'textbox') return '#2a2218'; // поле ввода — тёмный фон
|
||
if (type === 'scroll') return '#1f1810';
|
||
return '#1f1810';
|
||
}
|
||
|
||
function _defaultBgOpacity(type) {
|
||
if (type === 'frame') return 0.85;
|
||
if (type === 'button') return 0.95;
|
||
if (type === 'textbox') return 0.95;
|
||
if (type === 'scroll') return 0.85;
|
||
return 1;
|
||
}
|