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

334 lines
16 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.

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