feat(week4): модальные сцены, кастомные скины игрока и вики-гайды по 4 играм
Some checks failed
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>
This commit is contained in:
parent
d6cc986aa9
commit
42be04def9
@ -394,31 +394,45 @@ const LessonPage = ({ game, navigate }) => {
|
||||
const [state, setState] = useState('idle');
|
||||
|
||||
// Создаёт НОВУЮ копию игры-урока на текущем пользователе и
|
||||
// открывает её в редакторе. Исходник (билдер) при этом цел.
|
||||
// открывает её в редакторе. Оригинал при этом ВСЕГДА цел.
|
||||
const openInEditor = async () => {
|
||||
const userId = getCurrentUserId();
|
||||
if (!userId) {
|
||||
setState('error');
|
||||
return;
|
||||
}
|
||||
const project = buildGameProject(game.id);
|
||||
if (!project) { setState('error'); return; }
|
||||
setState('creating');
|
||||
try {
|
||||
// project_data копии берём двумя способами:
|
||||
// - у обычных уроков (1-50) — собираем из билдера;
|
||||
// - у разбора готовых игр (g5) — ЗАГРУЖАЕМ project_data
|
||||
// оригинала из БД и копируем его (оригинал не трогаем!).
|
||||
let projectDataStr;
|
||||
if (game.openProjectId) {
|
||||
const orig = await Kubikon3DApi.getProjectWithRetry(game.openProjectId, userId);
|
||||
const pd = orig && orig.data && orig.data.project_data;
|
||||
if (!pd) { setState('error'); return; }
|
||||
// project_data может прийти строкой или объектом — нормализуем в строку.
|
||||
projectDataStr = typeof pd === 'string' ? pd : JSON.stringify(pd);
|
||||
} else {
|
||||
const project = buildGameProject(game.id);
|
||||
if (!project) { setState('error'); return; }
|
||||
projectDataStr = JSON.stringify(project);
|
||||
}
|
||||
const res = await Kubikon3DApi.createProject(userId, {
|
||||
user_id: userId,
|
||||
title: 'Урок: ' + game.title,
|
||||
description: 'Игра-урок из вики Рублокса. Можешь свободно её менять.',
|
||||
title: 'Моя копия: ' + game.title,
|
||||
description: 'Игра-урок из вики Рублокса. Это твоя копия — меняй как хочешь, оригинал не пострадает.',
|
||||
genre: 'other',
|
||||
thumbnail: '',
|
||||
is_public: false,
|
||||
project_data: JSON.stringify(project),
|
||||
project_data: projectDataStr,
|
||||
});
|
||||
const newId = res.data && res.data.id;
|
||||
if (newId) navigate('/edit/' + newId);
|
||||
else setState('error');
|
||||
} catch (e) {
|
||||
console.error('[LessonPage] createProject error:', e);
|
||||
console.error('[LessonPage] openInEditor error:', e);
|
||||
setState('error');
|
||||
}
|
||||
};
|
||||
@ -441,8 +455,8 @@ const LessonPage = ({ game, navigate }) => {
|
||||
<div className="lessonOpen">
|
||||
<div className="lessonOpen__text">
|
||||
<b>Хочешь сразу посмотреть готовую игру?</b><br />
|
||||
Открой её в редакторе — создастся <b>твоя копия</b>, можешь
|
||||
свободно её менять и разбираться, как всё устроено.
|
||||
Открой её в редакторе — создастся <b>твоя личная копия</b>.
|
||||
Меняй её как хочешь, нажимай «Играть» — <b>оригинал не пострадает</b>.
|
||||
</div>
|
||||
<button
|
||||
className="lessonOpen__btn"
|
||||
@ -451,7 +465,7 @@ const LessonPage = ({ game, navigate }) => {
|
||||
>
|
||||
{state === 'creating'
|
||||
? 'Создаём копию…'
|
||||
: <><Icon name="play" size={15} /> Открыть игру в редакторе</>}
|
||||
: <><Icon name="play" size={15} /> Открыть мою копию в редакторе</>}
|
||||
</button>
|
||||
</div>
|
||||
{state === 'error' && (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -30,6 +30,8 @@ export const GAME_GROUPS = [
|
||||
hint: 'Комбинации механик, GUI, состояние игры, NPC-логика.' },
|
||||
{ id: 'g4', title: 'Группа 4 — Сложные', stars: 3,
|
||||
hint: 'Полные игры, мультиплеер, продвинутые системы.' },
|
||||
{ id: 'g5', title: 'Разбор готовых игр', stars: 2,
|
||||
hint: 'Настоящие игры из студии по косточкам: камера и таблички, анимации интерфейса, модальные сцены и кастомные скины. У каждой — кнопка «Открыть оригинал в редакторе».' },
|
||||
];
|
||||
|
||||
export const GAMES = [
|
||||
@ -292,4 +294,28 @@ export const GAMES = [
|
||||
desc: 'Гайд: как придумать и собрать собственную игру с нуля.',
|
||||
mechanics: ['проектирование игры', 'все механики вместе'],
|
||||
ready: false },
|
||||
|
||||
// ── Группа 5 — Разбор готовых игр ─────────────────────────────
|
||||
// Это НАСТОЯЩИЕ игры из студии. У карточек есть openProjectId —
|
||||
// кнопка открывает оригинал игры в редакторе (а не строит из билдера).
|
||||
{ id: 'guide-dvor', num: 51, group: 'g5', stars: 1, icon: 'camera',
|
||||
title: 'Двор с табличкой',
|
||||
desc: 'Учимся крутить камеру мышкой как в Roblox и нажимать на 3D-таблички прямо в мире.',
|
||||
mechanics: ['камера и мышь', 'ПКМ-orbit и зум', 'Shift-Lock (L)', '3D-таблички'],
|
||||
previewShot: 'guide-dvor-scene.png', openProjectId: 1991, ready: true },
|
||||
{ id: 'guide-vitrina', num: 52, group: 'g5', stars: 2, icon: 'palette',
|
||||
title: 'Витрина GUI',
|
||||
desc: 'Живые кнопки магазина: градиенты, пульсация, поворот и плавные твины при нажатии.',
|
||||
mechanics: ['GUI-кнопки', 'анимации (pulse/rotate)', 'твины', 'счётчик монет'],
|
||||
previewShot: 'guide-vitrina-scene.png', openProjectId: 1995, ready: true },
|
||||
{ id: 'guide-sunduk', num: 53, group: 'g5', stars: 2, icon: 'scroll',
|
||||
title: 'Тайна старого сундука',
|
||||
desc: 'Кат-сцены и диалоги: затемнение, прожектор на сундуке, выбор приза и финальная победа.',
|
||||
mechanics: ['game.modal', 'диалог по строкам', 'прожектор + камера', 'лутбокс'],
|
||||
previewShot: 'guide-sunduk-scene.png', openProjectId: 2037, ready: true },
|
||||
{ id: 'guide-zoo', num: 54, group: 'g5', stars: 2, icon: 'gamepad',
|
||||
title: 'Парк животных',
|
||||
desc: 'Кастомные скины: герой превращается в пончик, машину, пришельца. Магазин скинов на B.',
|
||||
mechanics: ['game.player.setSkin', 'non-humanoid скины', 'магазин скинов', 'таблички'],
|
||||
previewShot: 'guide-zoo-scene.png', openProjectId: 2046, ready: true },
|
||||
];
|
||||
|
||||
@ -7318,6 +7318,464 @@ game.self.onTouch(() => {
|
||||
),
|
||||
},
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// РАЗБОР ИГР · Двор с табличкой (оригинал id=1991)
|
||||
// ════════════════════════════════════════════════════
|
||||
'guide-dvor': {
|
||||
body: (
|
||||
<>
|
||||
<h3 className="lessonH">Что получится</h3>
|
||||
<p>
|
||||
Маленький уютный двор: зелёный газон, деревянный забор,
|
||||
деревья и большая <b>3D-табличка</b> в центре. По табличке
|
||||
можно нажать мышкой прямо в игре — и что-то произойдёт.
|
||||
А ещё двор учит главному: как крутить камеру вокруг героя,
|
||||
как в настоящем Roblox.
|
||||
</p>
|
||||
<Shot src="guide-dvor-scene.png"
|
||||
caption="Двор в редакторе: газон в заборе, табличка и плитки" />
|
||||
|
||||
<h3 className="lessonH">Чему научишься</h3>
|
||||
<ul>
|
||||
<li><b>Камера и мышь</b> — зажми <b>правую кнопку мыши</b> и
|
||||
веди, чтобы осмотреться вокруг героя;</li>
|
||||
<li><b>Зум</b> — колесо мыши приближает и отдаляет. Совсем
|
||||
близко — игра сама переходит в вид «от первого лица»;</li>
|
||||
<li><b>Shift-Lock</b> на клавише <kbd className="kbd">L</kbd> —
|
||||
герой всегда смотрит туда же, куда камера;</li>
|
||||
<li><b>Клик по 3D-табличке</b> — как сделать кнопку прямо
|
||||
в игровом мире (<code>game.billboard.onClick</code>).</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="lessonH">Шаг 1. Двор</h3>
|
||||
<Step n="1">
|
||||
Инструментом <kbd className="kbd">Блок</kbd> построй площадку
|
||||
из травы примерно 10×10. Это газон.
|
||||
</Step>
|
||||
<Step n="2">
|
||||
По краю поставь забор из блоков-брёвен в 2 блока высотой,
|
||||
оставив спереди проход.
|
||||
</Step>
|
||||
<Step n="3">
|
||||
Из палитры моделей добавь пару деревьев — для красоты.
|
||||
На вкладке <b>Игра</b> поставь <b>точку спавна</b> в центре.
|
||||
</Step>
|
||||
|
||||
<h3 className="lessonH">Шаг 2. Табличка</h3>
|
||||
<p>
|
||||
Табличка — это особый примитив <b>«3D-табличка»</b>
|
||||
(биллборд). У неё есть кнопка, на которую можно нажимать.
|
||||
</p>
|
||||
<Step n="1">
|
||||
Выбери <kbd className="kbd">Примитив</kbd> →
|
||||
категория <b>Геймплей</b> → <b>3D-табличка</b>. Поставь её
|
||||
в центр двора.
|
||||
</Step>
|
||||
<Step n="2">
|
||||
Выдели табличку. В инспекторе справа нажми
|
||||
<b> «Редактировать табличку»</b> — откроется окно, где можно
|
||||
задать текст, иконку, цвет и кнопку.
|
||||
</Step>
|
||||
<Step n="3">
|
||||
Запомни <b>номер таблички</b> — кликни по ней в Иерархии,
|
||||
он выглядит как <code>primitive:41</code> (число у тебя может
|
||||
быть другое).
|
||||
</Step>
|
||||
|
||||
<h3 className="lessonH">Шаг 3. Скрипт клика</h3>
|
||||
<p>
|
||||
Теперь сделаем, чтобы по нажатию на табличку менялся цвет неба.
|
||||
</p>
|
||||
<ScriptKind kind="global" />
|
||||
<Code>{`// === ДВОР С ТАБЛИЧКОЙ — главный скрипт ===
|
||||
|
||||
// Подписываемся на клик по кнопке таблички.
|
||||
// 'primitive:41' — номер твоей таблички, 'buy' — её кнопка.
|
||||
game.billboard.onClick('primitive:41', 'buy', () => {
|
||||
game.environment.setSkyColor('#88c0ff'); // небо стало голубым
|
||||
game.log('По табличке нажали!');
|
||||
});`}</Code>
|
||||
<p>
|
||||
<code>game.billboard.onClick(номер, кнопка, функция)</code> —
|
||||
«когда нажмут на эту кнопку, выполни функцию». Внутри мы
|
||||
меняем цвет неба командой
|
||||
<code> game.environment.setSkyColor</code>.
|
||||
</p>
|
||||
|
||||
<h3 className="lessonH">Шаг 4. Проверка</h3>
|
||||
<Shot src="guide-dvor-play.png"
|
||||
caption="Двор в игре: герой от третьего лица, камера крутится мышкой" />
|
||||
<Step n="1">
|
||||
Нажми <kbd className="kbd">Играть</kbd>. Походи по двору
|
||||
на <kbd className="kbd">W</kbd><kbd className="kbd">A</kbd><kbd className="kbd">S</kbd><kbd className="kbd">D</kbd>.
|
||||
</Step>
|
||||
<Step n="2">
|
||||
Зажми <b>правую кнопку мыши</b> и веди — камера крутится
|
||||
вокруг героя. Покрути колесо — приближается и отдаляется.
|
||||
</Step>
|
||||
<Step n="3">
|
||||
Наведи курсор на кнопку таблички и кликни — небо поменяет цвет.
|
||||
</Step>
|
||||
<Note>
|
||||
Камера и мышь — это фундамент почти любой игры. Разберёшься
|
||||
здесь — дальше будет намного легче.
|
||||
</Note>
|
||||
<Try>
|
||||
поставь рядом ещё две таблички с разными цветами неба и
|
||||
сделай переключатель «утро — день — ночь» из трёх кнопок.
|
||||
</Try>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// РАЗБОР ИГР · Витрина GUI (оригинал id=1995)
|
||||
// ════════════════════════════════════════════════════
|
||||
'guide-vitrina': {
|
||||
body: (
|
||||
<>
|
||||
<h3 className="lessonH">Что получится</h3>
|
||||
<p>
|
||||
Витрина магазина, как в играх-кликерах. 3D-мира почти нет —
|
||||
весь экран это <b>интерфейс</b>: счётчик монет и яркие кнопки.
|
||||
И все кнопки <b>живые</b>: пульсируют, крутятся, увеличиваются
|
||||
при наведении и «вдавливаются» при нажатии.
|
||||
</p>
|
||||
<Shot src="guide-vitrina-scene.png"
|
||||
caption="Витрина: счётчик монет, кнопки магазина, радужная «X2 ДЕНЕГ»" />
|
||||
|
||||
<h3 className="lessonH">Чему научишься</h3>
|
||||
<ul>
|
||||
<li><b>GUI-кнопки</b> с градиентом, обводкой текста и
|
||||
скруглением;</li>
|
||||
<li><b>Анимация-пресет</b> — кнопка пульсирует сама по себе
|
||||
(свойство «Анимация: pulse»);</li>
|
||||
<li><b>Твин</b> — плавное изменение: кнопка плавно крутится
|
||||
с 0° до 360° (<code>game.gui.tween</code>);</li>
|
||||
<li><b>Связь кнопок и счётчика</b> через сообщения
|
||||
(<code>game.broadcast</code> и <code>game.onMessage</code>).</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="lessonH">Шаг 1. Счётчик и кнопки</h3>
|
||||
<Step n="1">
|
||||
Поставь маленький пол и точку спавна (мир мы почти не видим).
|
||||
</Step>
|
||||
<Step n="2">
|
||||
На вкладке <b>Интерфейс</b> добавь надпись-счётчик слева
|
||||
сверху — это монеты.
|
||||
</Step>
|
||||
<Step n="3">
|
||||
Добавь кнопку. В инспекторе задай ей <b>градиент фона</b>,
|
||||
<b> обводку текста</b> и скругление углов. Размер задаётся
|
||||
в процентах: ширина <code>18</code> — это 18% экрана.
|
||||
</Step>
|
||||
<Note>
|
||||
Поля кнопок задаются <b>в процентах</b> от экрана, а не в
|
||||
пикселях. Так интерфейс одинаково выглядит на любом мониторе.
|
||||
Если поставить ширину 220 — кнопка растянется на весь экран!
|
||||
</Note>
|
||||
|
||||
<h3 className="lessonH">Шаг 2. Живые анимации</h3>
|
||||
<Step n="1">
|
||||
Выдели кнопку и в инспекторе выбери свойство
|
||||
<b> «Анимация: pulse»</b> — в игре она начнёт пульсировать сама.
|
||||
</Step>
|
||||
<Step n="2">
|
||||
Там же есть <b>hover</b> (что делать при наведении мышкой,
|
||||
например увеличиться) и <b>active</b> (при нажатии — сжаться).
|
||||
</Step>
|
||||
|
||||
<h3 className="lessonH">Шаг 3. Скрипт кнопки «X2»</h3>
|
||||
<p>
|
||||
Повесим на кнопку «X2 денег» скрипт: при клике она
|
||||
эффектно крутится и на 5 секунд удваивает награду.
|
||||
</p>
|
||||
<ScriptKind kind="object" on="кнопку «X2 денег»" />
|
||||
<Code>{`// === Скрипт кнопки X2 ===
|
||||
game.self.onClick(() => {
|
||||
// сначала вернём поворот в 0, потом плавно крутанём на 360°
|
||||
game.gui.update(game.self, { rotation: 0 });
|
||||
game.gui.tween(game.self, { rotation: 360 }, { duration: 0.5 });
|
||||
|
||||
// включаем множитель x2 на 5 секунд
|
||||
game.broadcast('multiplier_set', 2);
|
||||
game.after(5, () => game.broadcast('multiplier_set', 1));
|
||||
});`}</Code>
|
||||
<p>
|
||||
<code>game.gui.tween(объект, что-меняем, как-долго)</code> —
|
||||
это и есть плавная анимация. Без неё кнопка прыгнула бы резко,
|
||||
а с твином крутится гладко, как в дорогих играх.
|
||||
</p>
|
||||
|
||||
<h3 className="lessonH">Шаг 4. Главный скрипт-счётчик</h3>
|
||||
<ScriptKind kind="global" />
|
||||
<Code>{`// === Витрина GUI — главный скрипт ===
|
||||
let coins = 0; // монеты
|
||||
let multiplier = 1; // множитель награды
|
||||
|
||||
game.hud.setHotbarVisible(false); // в этой игре инвентарь не нужен
|
||||
|
||||
// Кнопки шлют 'coin_add' — добавляем монеты с учётом множителя.
|
||||
game.onMessage('coin_add', (amount) => {
|
||||
coins = coins + amount * multiplier;
|
||||
game.gui.update('hud_coins', { text: 'Монеты: ' + coins });
|
||||
});
|
||||
|
||||
// Кнопка X2 шлёт 'multiplier_set' — меняем множитель.
|
||||
game.onMessage('multiplier_set', (m) => { multiplier = m; });`}</Code>
|
||||
|
||||
<h3 className="lessonH">Шаг 5. Проверка</h3>
|
||||
<Step n="1">Нажми <kbd className="kbd">Играть</kbd>.</Step>
|
||||
<Step n="2">Жми кнопки — счётчик монет растёт.</Step>
|
||||
<Step n="3">Нажми «X2» — кнопка крутится, и 5 секунд монеты идут вдвое быстрее.</Step>
|
||||
<Try>
|
||||
сделай кнопку, которая при наведении мышкой меняет цвет
|
||||
(свойство <b>hover</b>), а при клике плавно уезжает за край
|
||||
экрана через твин по координате X.
|
||||
</Try>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// РАЗБОР ИГР · Тайна старого сундука (оригинал id=2037)
|
||||
// ════════════════════════════════════════════════════
|
||||
'guide-sunduk': {
|
||||
body: (
|
||||
<>
|
||||
<h3 className="lessonH">Что получится</h3>
|
||||
<p>
|
||||
Маленькое приключение. Лесная поляна с каменными руинами,
|
||||
светящийся сундук в центре и страж рядом. Игра показывает
|
||||
<b> модальные сцены</b> — это когда мир затемняется, всё
|
||||
замирает, и на экране появляется что-то важное: диалог,
|
||||
выбор приза или большая надпись.
|
||||
</p>
|
||||
<Shot src="guide-sunduk-scene.png"
|
||||
caption="Поляна с руинами: страж слева, светящийся сундук в центре" />
|
||||
|
||||
<h3 className="lessonH">Чему научишься</h3>
|
||||
<ul>
|
||||
<li><b>Диалог</b> по фразам с кнопкой ▶
|
||||
(<code>game.modal.dialog</code>);</li>
|
||||
<li><b>Кат-сцена</b> — затемнить мир, оставить «прожектор»
|
||||
на объекте, подлететь камерой (<code>game.modal.open</code>);</li>
|
||||
<li><b>Вопрос Да/Нет</b> (<code>game.modal.confirmation</code>);</li>
|
||||
<li><b>Лутбокс</b> — выбор приза из карточек
|
||||
(<code>game.modal.lootbox</code>);</li>
|
||||
<li>как <b>находить объект по имени</b> и следить за
|
||||
расстоянием до него.</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="lessonH">Шаг 1. Поляна, сундук и страж</h3>
|
||||
<Step n="1">
|
||||
Построй каменную площадку (greystone) с обломками стен и
|
||||
колоннами вокруг — это руины.
|
||||
</Step>
|
||||
<Step n="2">
|
||||
В центр поставь сундук (можно собрать из примитива-куба) и
|
||||
в инспекторе дай ему <b>имя «chest»</b>.
|
||||
</Step>
|
||||
<Step n="3">
|
||||
Слева поставь стража (из примитивов: цилиндр-тело, сфера-голова,
|
||||
конус-шлем) с <b>именем «guard»</b>.
|
||||
</Step>
|
||||
|
||||
<h3 className="lessonH">Шаг 2. Диалог со стражем</h3>
|
||||
<p>
|
||||
В обычном Roblox такую сцену собирают из 5-6 кусков вручную.
|
||||
У нас — одна команда. Главный скрипт следит за расстоянием
|
||||
до стража и запускает диалог.
|
||||
</p>
|
||||
<ScriptKind kind="global" />
|
||||
<Code>{`// === ТАЙНА СУНДУКА — главный скрипт ===
|
||||
game.hud.setHotbarVisible(false);
|
||||
|
||||
let phase = 'start'; // этап квеста
|
||||
|
||||
game.onTick((dt) => {
|
||||
if (game.modal.isOpen()) return; // во время модала ничего не триггерим
|
||||
const p = game.player.position;
|
||||
|
||||
// расстояние до стража (он в точке -6, 4)
|
||||
if (phase === 'start') {
|
||||
const d = Math.hypot(p.x - (-6), p.z - 4);
|
||||
if (d < 4) {
|
||||
phase = 'talked';
|
||||
game.modal.dialog('Страж Руин', [
|
||||
'Стой, путник. Это место хранит тайну веков...',
|
||||
'В центре руин дремлет Старый сундук.',
|
||||
'Он заперт магией. Подойди — и он сам откроется.',
|
||||
], () => game.log('Иди к сундуку!'));
|
||||
}
|
||||
}
|
||||
});`}</Code>
|
||||
<Shot src="guide-sunduk-play.png"
|
||||
caption="Диалог в игре: фраза стража внизу и кнопка ▶ для продолжения" />
|
||||
<Note>
|
||||
Имя объекта (например «chest») ищи не сразу при старте, а
|
||||
внутри <code>game.onTick</code> — сцена «появляется» не
|
||||
мгновенно, и в первую секунду объект ещё не найден.
|
||||
</Note>
|
||||
|
||||
<h3 className="lessonH">Шаг 3. Кат-сцена сундука</h3>
|
||||
<p>
|
||||
Подошёл к сундуку — затемняем мир, но сундук оставляем ярким
|
||||
(вокруг него прожектор), камера сама подлетает.
|
||||
</p>
|
||||
<Code>{`// (продолжение onTick) — подошёл к сундуку
|
||||
if (phase === 'talked') {
|
||||
const d = Math.hypot(p.x - 0, p.z - (-7));
|
||||
if (d < 5) {
|
||||
phase = 'open';
|
||||
const chest = game.scene.findOne('chest');
|
||||
game.modal.open({
|
||||
darken: 0.72, // затемнить мир на 72%
|
||||
spotlights: [chest], // сундук остаётся ярким (прожектор)
|
||||
cameraOverride: { target: chest, distance: 9, duration: 0.7 },
|
||||
blockInput: true, // заблокировать управление
|
||||
content: { elements: [
|
||||
{ kind: 'text', x: 50, y: 20, text: 'Старый сундук',
|
||||
textSize: 50, textColor: '#ffd700', animationPreset: 'glow' },
|
||||
]},
|
||||
});
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="lessonH">Шаг 4. Выбор приза и победа</h3>
|
||||
<p>
|
||||
Готовые сцены, которые вызываются одной строкой:
|
||||
</p>
|
||||
<ul>
|
||||
<li><code>game.modal.confirmation('Открыть?', 'текст', наДа, наНет)</code> — вопрос Да/Нет;</li>
|
||||
<li><code>game.modal.lootbox([призы], выбор)</code> — карточки призов;</li>
|
||||
<li><code>game.modal.bossIntro(имя, hp, [враги])</code> — заставка перед боссом.</li>
|
||||
</ul>
|
||||
<Code>{`// Лутбокс — четыре приза, игрок выбирает один
|
||||
game.modal.lootbox([
|
||||
{ name: 'Меч зари', icon: '⚔', color: '#c0392b' },
|
||||
{ name: 'Щит луны', icon: '🛡', color: '#2c5fb3' },
|
||||
{ name: 'Кошель злата', icon: '💰', color: '#b8860b' },
|
||||
{ name: 'Перо феникса', icon: '🔥', color: '#8e44ad' },
|
||||
], (item) => {
|
||||
game.log('Игрок выбрал: ' + item.name);
|
||||
});`}</Code>
|
||||
|
||||
<h3 className="lessonH">Шаг 5. Проверка</h3>
|
||||
<Step n="1">Нажми <kbd className="kbd">Играть</kbd> и подойди к стражу — пойдёт диалог.</Step>
|
||||
<Step n="2">Иди к сундуку — мир затемнится, камера подлетит к нему.</Step>
|
||||
<Step n="3">Ответь «Да», выбери приз — увидишь финальную надпись.</Step>
|
||||
<Note>
|
||||
Кнопка диалога на последней фразе сама превращается из ▶
|
||||
в галочку ✓ — это значит «закрыть диалог».
|
||||
</Note>
|
||||
<Try>
|
||||
добавь второго стража с другим квестом и сделай так, чтобы
|
||||
сундук открывался только после разговора с обоими.
|
||||
</Try>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// РАЗБОР ИГР · Парк животных (оригинал id=2046)
|
||||
// ════════════════════════════════════════════════════
|
||||
'guide-zoo': {
|
||||
body: (
|
||||
<>
|
||||
<h3 className="lessonH">Что получится</h3>
|
||||
<p>
|
||||
Самая весёлая игра! Ты начинаешь её в виде... <b>пончика</b>.
|
||||
По парку стоят таблички: нажми на кнопку таблички — и твой
|
||||
герой <b>превращается</b> в бургер, болид, пришельца, рыбу
|
||||
или человечка. А по клавише <kbd className="kbd">B</kbd>
|
||||
открывается <b>магазин скинов</b>.
|
||||
</p>
|
||||
<Shot src="guide-zoo-scene.png"
|
||||
caption="Парк: 6 табличек со скинами и баннер «Примерочная скинов»" />
|
||||
|
||||
<h3 className="lessonH">Чему научишься</h3>
|
||||
<ul>
|
||||
<li><b>Кастомный скин</b> — герой может быть любой 3D-моделью,
|
||||
не только человечком (<code>game.player.setSkin</code>);</li>
|
||||
<li><b>Смена скина в игре</b> — без перезапуска, прямо на ходу;</li>
|
||||
<li><b>Магазин скинов</b> — встроенный, открывается клавишей
|
||||
<kbd className="kbd">B</kbd>;</li>
|
||||
<li>снова <b>3D-таблички</b> — те же, что в игре «Двор», но
|
||||
теперь их кнопка меняет скин.</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="lessonH">Шаг 1. Парк и стартовый скин</h3>
|
||||
<Step n="1">
|
||||
Построй полянку с деревьями и поставь точку спавна.
|
||||
</Step>
|
||||
<Step n="2">
|
||||
Нажми кнопку <b>«Скины»</b> в шапке редактора. Выбери
|
||||
<b> стартовый скин</b> (например пончик), включи галочку
|
||||
<b> «Магазин скинов»</b> и задай стартовые рублики (например 200).
|
||||
</Step>
|
||||
<Step n="3">
|
||||
Поставь несколько 3D-табличек (как в игре «Двор») — по одной
|
||||
на каждый скин. Запомни их номера (<code>primitive:10</code> и т.д.).
|
||||
</Step>
|
||||
|
||||
<h3 className="lessonH">Шаг 2. Скрипт превращений</h3>
|
||||
<p>
|
||||
Главный скрипт подписывается на клик по каждой табличке и
|
||||
меняет скин одной командой.
|
||||
</p>
|
||||
<ScriptKind kind="global" />
|
||||
<Code>{`// === ПАРК ЖИВОТНЫХ — главный скрипт ===
|
||||
game.player.setSkinCoins(200); // 200 рубликов на магазин
|
||||
|
||||
// Каждая табличка при клике надевает свой скин.
|
||||
game.billboard.onClick('primitive:10', 'buy', () => game.player.setSkin('burger'));
|
||||
game.billboard.onClick('primitive:11', 'buy', () => game.player.setSkin('car-race'));
|
||||
game.billboard.onClick('primitive:12', 'buy', () => game.player.setSkin('alien'));
|
||||
game.billboard.onClick('primitive:13', 'buy', () => game.player.setSkin('fish'));
|
||||
game.billboard.onClick('primitive:14', 'buy', () => game.player.setSkin('character-a'));
|
||||
|
||||
// Узнать, когда скин сменился:
|
||||
game.player.onSkinChange((newSkin) => {
|
||||
game.log('Теперь я: ' + newSkin);
|
||||
});`}</Code>
|
||||
<p>
|
||||
<code>game.player.setSkin('burger')</code> — одна строчка
|
||||
меняет всё тело героя на новую модель. Имена скинов
|
||||
(burger, car-race, alien…) видны в окне «Скины».
|
||||
</p>
|
||||
|
||||
<h3 className="lessonH">Шаг 3. Магазин скинов</h3>
|
||||
<p>
|
||||
Магазин уже встроен — мы включили его галочкой в окне «Скины».
|
||||
В игре он открывается клавишей <kbd className="kbd">B</kbd>.
|
||||
Командами скинами тоже можно управлять:
|
||||
</p>
|
||||
<Code>{`game.player.unlockSkin('alien'); // открыть скин игроку
|
||||
game.player.openSkinShop(); // открыть магазин из кода
|
||||
game.player.setSkinCoins(500); // задать баланс рубликов`}</Code>
|
||||
<Shot src="guide-zoo-play.png"
|
||||
caption="Старт игры: твой герой — пончик! Он покачивается на ходу" />
|
||||
|
||||
<h3 className="lessonH">Шаг 4. Проверка</h3>
|
||||
<Step n="1">Нажми <kbd className="kbd">Играть</kbd> — ты появишься пончиком.</Step>
|
||||
<Step n="2">Походи — пончик смешно покачивается на ходу.</Step>
|
||||
<Step n="3">Нажми на кнопку таблички — превратишься в другого героя.</Step>
|
||||
<Step n="4">Нажми <kbd className="kbd">B</kbd> — откроется магазин скинов.</Step>
|
||||
<Note>
|
||||
Скины бывают двух видов: <b>человечки</b> (умеют махать,
|
||||
прыгать, танцевать) и <b>модели</b> (пончик, машинка — они
|
||||
просто покачиваются). Свою модель <code>.glb</code> тоже можно
|
||||
загрузить в окне «Скины».
|
||||
</Note>
|
||||
<Try>
|
||||
сделай скин платным: дай игроку 100 рубликов, поставь скину
|
||||
цену 50 в магазине и проверь — хватит ли денег его купить.
|
||||
</Try>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
/** Есть ли готовый текст урока для игры с таким id. */
|
||||
|
||||
@ -21,11 +21,16 @@ import Icon from './Icon';
|
||||
*/
|
||||
|
||||
function _optsEqual(a, b) {
|
||||
// Расширенный compare — учитываем все поля стилизации.
|
||||
if (a === b) return true;
|
||||
if (!a || !b) return false;
|
||||
return a.x === b.x && a.y === b.y && a.color === b.color && a.size === b.size;
|
||||
const keys = ['x','y','color','size','textSize','bold','bg','border',
|
||||
'borderRadius','padding','w','h','textAlign','anchor'];
|
||||
for (const k of keys) {
|
||||
if (a[k] !== b[k]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const DEFAULT_LABEL_STYLE = {
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
@ -137,32 +142,59 @@ function GameHud({ visible, hudRef }) {
|
||||
{otherIds.map((id, i) => {
|
||||
const lbl = labels[id];
|
||||
const o = lbl.opts || {};
|
||||
const hasPos = typeof o.x === 'number' || typeof o.y === 'number';
|
||||
// Поддерживаем как старый формат opts (x/y в %, color, size),
|
||||
// так и расширенный (bg, border, borderRadius, padding,
|
||||
// w/h/textSize/bold/textAlign, x/y в пикселях или с '%').
|
||||
const hasPercentXY = (typeof o.x === 'number' && o.x <= 100 && typeof o.y === 'number' && o.y <= 100)
|
||||
&& (o.bg === undefined && o.w === undefined && o.h === undefined);
|
||||
const usePixelPos = (typeof o.x === 'number' && !hasPercentXY)
|
||||
|| typeof o.x === 'string';
|
||||
const style = {
|
||||
...DEFAULT_LABEL_STYLE,
|
||||
fontSize: o.size || DEFAULT_LABEL_STYLE.fontSize,
|
||||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||||
fontWeight: o.bold ? 800 : DEFAULT_LABEL_STYLE.fontWeight,
|
||||
fontSize: o.textSize || o.size || DEFAULT_LABEL_STYLE.fontSize,
|
||||
color: o.color || DEFAULT_LABEL_STYLE.color,
|
||||
background: 'rgba(15,12,8,0.55)',
|
||||
padding: '4px 10px',
|
||||
borderRadius: 5,
|
||||
// длинные подписи переносятся и остаются по центру,
|
||||
// не вылезая за края экрана
|
||||
textAlign: 'center',
|
||||
background: o.bg || 'rgba(15,12,8,0.55)',
|
||||
padding: o.padding != null ? o.padding : '4px 10px',
|
||||
borderRadius: o.borderRadius != null ? o.borderRadius : 5,
|
||||
border: o.border || undefined,
|
||||
textAlign: o.textAlign || 'center',
|
||||
maxWidth: '70vw',
|
||||
whiteSpace: 'normal',
|
||||
whiteSpace: 'pre-line',
|
||||
wordBreak: 'break-word',
|
||||
width: o.w != null ? o.w : undefined,
|
||||
height: o.h != null ? o.h : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: o.textAlign === 'left' ? 'flex-start' : 'center',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
if (hasPos) {
|
||||
if (hasPercentXY) {
|
||||
return (
|
||||
<div key={id} style={{
|
||||
...style,
|
||||
position: 'absolute',
|
||||
left: typeof o.x === 'number' ? `${o.x}%` : undefined,
|
||||
top: typeof o.y === 'number' ? `${o.y}%` : undefined,
|
||||
left: `${o.x}%`,
|
||||
top: `${o.y}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}>{lbl.text}</div>
|
||||
);
|
||||
}
|
||||
if (usePixelPos) {
|
||||
// Якорь: 'center' — translate(-50%,-50%); по умолчанию top-left
|
||||
const isCenter = o.anchor === 'center';
|
||||
const leftVal = typeof o.x === 'string' ? o.x : `${o.x}px`;
|
||||
const topVal = typeof o.y === 'string' ? o.y : `${o.y}px`;
|
||||
return (
|
||||
<div key={id} style={{
|
||||
...style,
|
||||
position: 'absolute',
|
||||
left: leftVal,
|
||||
top: topVal,
|
||||
transform: isCenter ? 'translate(-50%, -50%)' : undefined,
|
||||
}}>{lbl.text}</div>
|
||||
);
|
||||
}
|
||||
// Без позиции — стек в левом верхнем углу
|
||||
return (
|
||||
<div key={id} style={{
|
||||
|
||||
@ -264,10 +264,25 @@ function GuiElement({ el, allElements, childrenMap, isPlaying, selectedId, onSel
|
||||
// textbox в Play кликабелен (для фокуса и ввода), как и кнопка
|
||||
const pointerEvents = isPlaying ? ((isButton || isTextbox) ? 'auto' : 'none') : 'auto';
|
||||
|
||||
// В Play на кнопке — лёгкий hover/pressed эффект
|
||||
// В Play на кнопке — hover/pressed эффект (Задача 03).
|
||||
// Если у элемента задан el.hover/el.active — используем их параметры,
|
||||
// иначе дефолтные значения.
|
||||
const playInteractive = isPlaying && isButton;
|
||||
const playFilter = pressed ? 'brightness(0.85)' : (hover ? 'brightness(1.15)' : 'none');
|
||||
const playTransform = pressed ? `${style.transform || ''} scale(0.97)` : style.transform;
|
||||
const hoverCfg = el.hover || { scale: 1.08, brightness: 1.15, rotation: 0 };
|
||||
const activeCfg = el.active || { scale: 0.94 };
|
||||
const hoverBrightness = (typeof hoverCfg.brightness === 'number') ? hoverCfg.brightness : 1.15;
|
||||
const hoverScale = (typeof hoverCfg.scale === 'number') ? hoverCfg.scale : 1.08;
|
||||
const hoverRotation = (typeof hoverCfg.rotation === 'number') ? hoverCfg.rotation : 0;
|
||||
const activeScale = (typeof activeCfg.scale === 'number') ? activeCfg.scale : 0.94;
|
||||
const playFilter = pressed
|
||||
? 'brightness(0.85)'
|
||||
: (hover ? `brightness(${hoverBrightness})` : 'none');
|
||||
const dynScale = pressed ? activeScale : (hover ? hoverScale : 1);
|
||||
const dynRot = hover ? hoverRotation : 0;
|
||||
let extraTr = '';
|
||||
if (dynScale !== 1) extraTr += ` scale(${dynScale})`;
|
||||
if (dynRot) extraTr += ` rotate(${dynRot}deg)`;
|
||||
const playTransform = (style.transform || '') + extraTr;
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -340,28 +355,82 @@ function GuiElement({ el, allElements, childrenMap, isPlaying, selectedId, onSel
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{isText && (el.text != null) && (
|
||||
<div style={{
|
||||
width: '100%', height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: el.textAlign === 'left' ? 'flex-start'
|
||||
: el.textAlign === 'right' ? 'flex-end' : 'center',
|
||||
color: el.textColor || '#f0e6d8',
|
||||
fontSize: el.textSize || 16,
|
||||
fontWeight: el.fontWeight || 500,
|
||||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||||
padding: '4px 8px',
|
||||
boxSizing: 'border-box',
|
||||
textAlign: el.textAlign || 'center',
|
||||
lineHeight: 1.2,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
{el.text}
|
||||
</div>
|
||||
)}
|
||||
{isText && (el.text != null) && (() => {
|
||||
// Задача 03: text-stroke (обводка текста). Через -webkit-text-stroke
|
||||
// (хорошая поддержка, чётко на крупном шрифте) + paint-order
|
||||
// (stroke под fill чтобы текст не «сжимался»).
|
||||
const ts = el.textStroke;
|
||||
const strokeStyle = (ts && ts.color && Number.isFinite(ts.width))
|
||||
? {
|
||||
WebkitTextStroke: `${ts.width}px ${ts.color}`,
|
||||
paintOrder: 'stroke fill',
|
||||
}
|
||||
: null;
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: el.textAlign === 'left' ? 'flex-start'
|
||||
: el.textAlign === 'right' ? 'flex-end' : 'center',
|
||||
color: el.textColor || '#f0e6d8',
|
||||
fontSize: el.textSize || 16,
|
||||
fontWeight: el.fontWeight || 500,
|
||||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||||
padding: '4px 8px',
|
||||
boxSizing: 'border-box',
|
||||
textAlign: el.textAlign || 'center',
|
||||
lineHeight: 1.2,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
pointerEvents: 'none',
|
||||
...(strokeStyle || {}),
|
||||
}}>
|
||||
{el.text}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Задача 03: Бейдж в углу — отдельный absolute-элемент. */}
|
||||
{el.badge && (() => {
|
||||
const b = el.badge;
|
||||
const corner = b.corner || 'top-right';
|
||||
const cornerStyle = {
|
||||
'top-right': { top: -6, right: -6 },
|
||||
'top-left': { top: -6, left: -6 },
|
||||
'bottom-right': { bottom: -6, right: -6 },
|
||||
'bottom-left': { bottom: -6, left: -6 },
|
||||
}[corner] || { top: -6, right: -6 };
|
||||
const icons = {
|
||||
exclamation: '!',
|
||||
star: '★',
|
||||
plus: '+',
|
||||
new: 'NEW',
|
||||
sale: '%',
|
||||
};
|
||||
const text = b.text != null ? b.text : (icons[b.icon] || '!');
|
||||
const big = b.icon === 'new';
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
...cornerStyle,
|
||||
minWidth: big ? 32 : 22,
|
||||
height: 22,
|
||||
padding: '0 6px',
|
||||
background: b.color || '#fbbf24',
|
||||
color: '#3a1a00',
|
||||
borderRadius: big ? 6 : 11,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: big ? 11 : 14,
|
||||
fontWeight: 800,
|
||||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.4)',
|
||||
border: '2px solid #fff',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 5,
|
||||
transform: 'rotate(8deg)',
|
||||
}}>{text}</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* TextBox — настоящий <input> в Play (принимает ввод),
|
||||
в редакторе — статичный вид с placeholder. */}
|
||||
@ -663,14 +732,42 @@ function elementToStyle(el) {
|
||||
case 'center':
|
||||
default: left = `${el.x ?? 50}%`; top = `${el.y ?? 50}%`; break;
|
||||
}
|
||||
// Задача 03: rotation + scale через transform. Добавляются ПОСЛЕ translate.
|
||||
// hoverScale/activeScale хранятся в el._dynScale (выставляется hover-handler'ом
|
||||
// в GuiElement через mutate-ref). При штатном рендере читаем el.scaleX/scaleY.
|
||||
const sx = (typeof el._dynScaleX === 'number' ? el._dynScaleX : 1)
|
||||
* (typeof el.scaleX === 'number' ? el.scaleX : 1);
|
||||
const sy = (typeof el._dynScaleY === 'number' ? el._dynScaleY : 1)
|
||||
* (typeof el.scaleY === 'number' ? el.scaleY : 1);
|
||||
const rot = (typeof el._dynRotation === 'number' ? el._dynRotation : 0)
|
||||
+ (typeof el.rotation === 'number' ? el.rotation : 0);
|
||||
const brightness = (typeof el._dynBrightness === 'number' ? el._dynBrightness : 1);
|
||||
transform = `translate(${tx}%, ${ty}%)`;
|
||||
if (sx !== 1 || sy !== 1) transform += ` scale(${sx}, ${sy})`;
|
||||
if (rot) transform += ` rotate(${rot}deg)`;
|
||||
let bg = el.bgColor || '#1f1810';
|
||||
const opacity = el.bgOpacity == null ? 1 : Math.max(0, Math.min(1, el.bgOpacity));
|
||||
if (bg === 'transparent' || opacity === 0) bg = 'transparent';
|
||||
else bg = hexToRgba(bg, opacity);
|
||||
// Задача 03: bgGradient — { stops: ['#a','#b'] | [{c,p}], angle: 0..360 }.
|
||||
// Если задан — перебиваем background.
|
||||
if (el.bgGradient && Array.isArray(el.bgGradient.stops) && el.bgGradient.stops.length >= 2) {
|
||||
const angle = Number.isFinite(el.bgGradient.angle) ? el.bgGradient.angle : 90;
|
||||
const parts = el.bgGradient.stops.map((s, i, arr) => {
|
||||
if (typeof s === 'string') {
|
||||
const p = (i / (arr.length - 1)) * 100;
|
||||
return `${s} ${p.toFixed(1)}%`;
|
||||
}
|
||||
const c = s.c || '#000';
|
||||
const p = typeof s.p === 'number' ? s.p * 100 : (i / (arr.length - 1)) * 100;
|
||||
return `${c} ${p.toFixed(1)}%`;
|
||||
});
|
||||
bg = `linear-gradient(${angle}deg, ${parts.join(', ')})`;
|
||||
}
|
||||
return {
|
||||
position: 'absolute',
|
||||
left, top, transform,
|
||||
transformOrigin: 'center center',
|
||||
width: w, height: h,
|
||||
background: bg,
|
||||
border: el.borderWidth > 0
|
||||
@ -678,14 +775,11 @@ function elementToStyle(el) {
|
||||
: 'none',
|
||||
borderRadius: (el.borderRadius || 0) + 'px',
|
||||
boxSizing: 'border-box',
|
||||
// Тень: явный флаг shadow → мягкая drop-shadow; у кнопок —
|
||||
// лёгкая тень по умолчанию (как было). shadow=true усиливает.
|
||||
boxShadow: el.shadow
|
||||
? '0 6px 16px rgba(0,0,0,0.45)'
|
||||
: (el.type === 'button' ? '0 2px 6px rgba(0,0,0,0.3)' : 'none'),
|
||||
// Frame обрезает детей по своей границе (как ScreenGui в Roblox).
|
||||
// Для не-frame оставляем visible чтобы текст не клипался.
|
||||
overflow: el.type === 'frame' ? 'hidden' : 'visible',
|
||||
filter: brightness !== 1 ? `brightness(${brightness})` : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -294,6 +294,7 @@ const InspectorPanel = ({
|
||||
onSetAnchored, onSetMass, onSetModelProps, onSetBlockProps,
|
||||
onSetLightingProps, onSetSoundProps, onSetPlayerProps, onSetFloorProps, onSetGuiProps, onDeleteGui,
|
||||
onAddWeaponToInventory,
|
||||
onEditBillboard,
|
||||
guiElements = [],
|
||||
// Этап 3.6 — библиотека пользовательских картинок.
|
||||
assetList = [],
|
||||
@ -1111,6 +1112,201 @@ const InspectorPanel = ({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* === Задача 03: Градиент фона === */}
|
||||
<div className={cl.section}>
|
||||
<div className={cl.sectionTitle}><Icon name="palette" size={12} /> Градиент фона</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!d.bgGradient}
|
||||
onChange={(e) => setProp({
|
||||
bgGradient: e.target.checked
|
||||
? { stops: [d.bgColor || '#ff5a5a', '#ff8a3d'], angle: 90 }
|
||||
: null,
|
||||
})}
|
||||
/>
|
||||
Включить градиент (заменяет фон)
|
||||
</label>
|
||||
{d.bgGradient && (
|
||||
<div style={{ marginTop: 6, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{(d.bgGradient.stops || []).map((s, i) => {
|
||||
const c = typeof s === 'string' ? s : (s.c || '#000');
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 12 }}>
|
||||
<span>#{i + 1}</span>
|
||||
<input type="color" value={c}
|
||||
onChange={(e) => {
|
||||
const stops = [...(d.bgGradient.stops || [])];
|
||||
stops[i] = e.target.value;
|
||||
setProp({ bgGradient: { ...d.bgGradient, stops } });
|
||||
}} />
|
||||
{(d.bgGradient.stops || []).length > 2 && (
|
||||
<button onClick={() => {
|
||||
const stops = [...d.bgGradient.stops]; stops.splice(i, 1);
|
||||
setProp({ bgGradient: { ...d.bgGradient, stops } });
|
||||
}} className={cl.smallBtn}>×</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button className={cl.smallBtn} onClick={() => {
|
||||
const stops = [...(d.bgGradient.stops || []), '#80a0ff'];
|
||||
setProp({ bgGradient: { ...d.bgGradient, stops } });
|
||||
}}>+ цвет</button>
|
||||
<label style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 12 }}>
|
||||
Угол
|
||||
<input type="range" min="0" max="360" value={d.bgGradient.angle ?? 90}
|
||||
onChange={(e) => setProp({ bgGradient: { ...d.bgGradient, angle: parseInt(e.target.value, 10) } })}
|
||||
style={{ flex: 1 }} />
|
||||
<span style={{ width: 36, textAlign: 'right' }}>{d.bgGradient.angle ?? 90}°</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* === Задача 03: Обводка текста, поворот, scale === */}
|
||||
{(isText) && (
|
||||
<div className={cl.section}>
|
||||
<div className={cl.sectionTitle}><Icon name="type" size={12} /> Обводка текста</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={!!d.textStroke}
|
||||
onChange={(e) => setProp({
|
||||
textStroke: e.target.checked ? { color: '#000000', width: 2 } : null,
|
||||
})} />
|
||||
Контур
|
||||
</label>
|
||||
{d.textStroke && (
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginTop: 4, fontSize: 12 }}>
|
||||
<input type="color" value={d.textStroke.color || '#000000'}
|
||||
onChange={(e) => setProp({ textStroke: { ...d.textStroke, color: e.target.value } })} />
|
||||
<span>Толщ.</span>
|
||||
<input type="number" min="1" max="8" value={d.textStroke.width || 2}
|
||||
onChange={(e) => setProp({ textStroke: { ...d.textStroke, width: parseInt(e.target.value, 10) || 1 } })}
|
||||
className={cl.numInput} style={{ width: 50 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cl.section}>
|
||||
<div className={cl.sectionTitle}><Icon name="rotate-cw" size={12} /> Поворот / масштаб</div>
|
||||
<label style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 12 }}>
|
||||
Поворот
|
||||
<input type="range" min="-180" max="180" value={d.rotation ?? 0}
|
||||
onChange={(e) => setProp({ rotation: parseInt(e.target.value, 10) })}
|
||||
style={{ flex: 1 }} />
|
||||
<span style={{ width: 44, textAlign: 'right' }}>{d.rotation ?? 0}°</span>
|
||||
</label>
|
||||
<label style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 12, marginTop: 4 }}>
|
||||
Scale
|
||||
<input type="range" min="0.1" max="3" step="0.05" value={d.scaleX ?? 1}
|
||||
onChange={(e) => {
|
||||
const v = parseFloat(e.target.value) || 1;
|
||||
setProp({ scaleX: v, scaleY: v });
|
||||
}}
|
||||
style={{ flex: 1 }} />
|
||||
<span style={{ width: 44, textAlign: 'right' }}>{(d.scaleX ?? 1).toFixed(2)}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* === Задача 03: Бейдж в углу === */}
|
||||
<div className={cl.section}>
|
||||
<div className={cl.sectionTitle}><Icon name="award" size={12} /> Бейдж</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={!!d.badge}
|
||||
onChange={(e) => setProp({
|
||||
badge: e.target.checked ? { corner: 'top-right', icon: 'exclamation', color: '#fbbf24' } : null,
|
||||
})} />
|
||||
Показать бейдж
|
||||
</label>
|
||||
{d.badge && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginTop: 4, fontSize: 12 }}>
|
||||
<label>Иконка
|
||||
<select value={d.badge.icon || 'exclamation'}
|
||||
onChange={(e) => setProp({ badge: { ...d.badge, icon: e.target.value } })}
|
||||
style={{ width: '100%' }}>
|
||||
<option value="exclamation">!</option>
|
||||
<option value="star">★</option>
|
||||
<option value="plus">+</option>
|
||||
<option value="new">NEW</option>
|
||||
<option value="sale">% (sale)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Угол
|
||||
<select value={d.badge.corner || 'top-right'}
|
||||
onChange={(e) => setProp({ badge: { ...d.badge, corner: e.target.value } })}
|
||||
style={{ width: '100%' }}>
|
||||
<option value="top-right">↗</option>
|
||||
<option value="top-left">↖</option>
|
||||
<option value="bottom-right">↘</option>
|
||||
<option value="bottom-left">↙</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
Цвет <input type="color" value={d.badge.color || '#fbbf24'}
|
||||
onChange={(e) => setProp({ badge: { ...d.badge, color: e.target.value } })} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* === Задача 03: Hover/Active (только для button) === */}
|
||||
{t === 'button' && (
|
||||
<div className={cl.section}>
|
||||
<div className={cl.sectionTitle}><Icon name="mouse" size={12} /> Реакция на мышь</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={!!d.hover}
|
||||
onChange={(e) => setProp({
|
||||
hover: e.target.checked ? { scale: 1.08, brightness: 1.15, duration: 0.15 } : null,
|
||||
})} />
|
||||
Hover (при наведении)
|
||||
</label>
|
||||
{d.hover && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, marginTop: 4, fontSize: 12 }}>
|
||||
<label>Scale {(d.hover.scale ?? 1.08).toFixed(2)}
|
||||
<input type="range" min="1" max="1.5" step="0.01" value={d.hover.scale ?? 1.08}
|
||||
onChange={(e) => setProp({ hover: { ...d.hover, scale: parseFloat(e.target.value) } })}
|
||||
style={{ width: '100%' }} />
|
||||
</label>
|
||||
<label>Яркость {(d.hover.brightness ?? 1.15).toFixed(2)}
|
||||
<input type="range" min="0.7" max="1.5" step="0.01" value={d.hover.brightness ?? 1.15}
|
||||
onChange={(e) => setProp({ hover: { ...d.hover, brightness: parseFloat(e.target.value) } })}
|
||||
style={{ width: '100%' }} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, cursor: 'pointer', marginTop: 6 }}>
|
||||
<input type="checkbox" checked={!!d.active}
|
||||
onChange={(e) => setProp({
|
||||
active: e.target.checked ? { scale: 0.94, duration: 0.08 } : null,
|
||||
})} />
|
||||
Active (при нажатии)
|
||||
</label>
|
||||
{d.active && (
|
||||
<label style={{ fontSize: 12 }}>Scale {(d.active.scale ?? 0.94).toFixed(2)}
|
||||
<input type="range" min="0.8" max="1" step="0.01" value={d.active.scale ?? 0.94}
|
||||
onChange={(e) => setProp({ active: { ...d.active, scale: parseFloat(e.target.value) } })}
|
||||
style={{ width: '100%' }} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* === Задача 03: Анимация-пресет === */}
|
||||
<div className={cl.section}>
|
||||
<div className={cl.sectionTitle}><Icon name="play" size={12} /> Анимация (в Play)</div>
|
||||
<select value={d.animationPreset || 'none'}
|
||||
onChange={(e) => setProp({ animationPreset: e.target.value })}
|
||||
style={{ width: '100%', padding: '4px 6px' }}>
|
||||
<option value="none">Без анимации</option>
|
||||
<option value="pulse">Пульсация (1.0 ↔ 1.1)</option>
|
||||
<option value="rotate">Вращение (360° за 3с)</option>
|
||||
<option value="sway">Качание (-8° ↔ +8°)</option>
|
||||
<option value="glow">Подсветка (opacity 1 ↔ 0.7)</option>
|
||||
<option value="bounce">Прыжок (y -1 ↔ 0)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={cl.section}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input
|
||||
|
||||
@ -10,6 +10,7 @@ import { PRIMITIVE_TYPES, PALETTE_PRIMITIVE_TYPES } from './engine/PrimitiveType
|
||||
import { getModelThumbnail } from './engine/ModelThumbnails';
|
||||
import * as Kubikon3DApi from '../api/Kubikon3DService';
|
||||
import GameSettingsModal from './GameSettingsModal';
|
||||
import SkinManagerModal from './SkinManagerModal';
|
||||
import PublishModal from './PublishModal';
|
||||
import EmailConfirmNotice from '../components/EmailConfirmNotice/EmailConfirmNotice';
|
||||
import PublishStatusBadge from './PublishStatusBadge';
|
||||
@ -31,6 +32,8 @@ import MinimapOverlay from './MinimapOverlay';
|
||||
import GuiOverlay from './GuiOverlay';
|
||||
import Hotbar from './Hotbar';
|
||||
import PlayerHud from './PlayerHud';
|
||||
import ModalOverlay from './ModalOverlay';
|
||||
import SkinShopOverlay from './SkinShopOverlay';
|
||||
import useDeviceType from '../hooks/useDeviceType';
|
||||
import KubikonDesktopOnlyStub from '../community/KubikonDesktopOnlyStub';
|
||||
import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton';
|
||||
@ -188,6 +191,109 @@ const MODEL_ITEM_ICON = (modelId) => {
|
||||
* иначе иконка-кубик. id таких моделей в формате 'user:<numericId>'.
|
||||
*/
|
||||
// Типы GUI-элементов для палитры визуального редактора UI (этап 3.9).
|
||||
/** Задача 03: раскрытие GUI-шаблона из палитры в {type, opts}. */
|
||||
function _expandGuiTemplate(typeOrTemplate) {
|
||||
const tpl = (typeof typeOrTemplate === 'string' && typeOrTemplate.startsWith('template:'))
|
||||
? typeOrTemplate.slice('template:'.length) : null;
|
||||
if (!tpl) return { type: typeOrTemplate, opts: {} };
|
||||
if (tpl === 'big-icon') return {
|
||||
type: 'button',
|
||||
opts: {
|
||||
text: 'Магазин', name: 'Кнопка-иконка',
|
||||
w: 14, h: 16,
|
||||
bgGradient: { stops: ['#5ab3ff', '#3d6cff'], angle: 135 },
|
||||
borderRadius: 16, borderWidth: 3, borderColor: '#1e3a8a',
|
||||
shadow: true,
|
||||
textColor: '#ffffff', textSize: 18, fontWeight: 800,
|
||||
textStroke: { color: '#1e3a8a', width: 2 },
|
||||
hover: { scale: 1.08, brightness: 1.15, duration: 0.15 },
|
||||
active: { scale: 0.94, duration: 0.08 },
|
||||
},
|
||||
};
|
||||
if (tpl === 'price') return {
|
||||
type: 'button',
|
||||
opts: {
|
||||
text: 'X2 ДЕНЕГ', name: 'Кнопка с ценой',
|
||||
w: 28, h: 8,
|
||||
bgGradient: { stops: ['#ff5a5a', '#ffd166', '#9eff7a', '#5ab3ff'], angle: 90 },
|
||||
borderRadius: 14, borderWidth: 3, borderColor: '#000',
|
||||
textColor: '#ffffff', textSize: 22, fontWeight: 900,
|
||||
textStroke: { color: '#000', width: 3 },
|
||||
shadow: true,
|
||||
hover: { scale: 1.06, brightness: 1.1, duration: 0.15 },
|
||||
active: { scale: 0.94, duration: 0.08 },
|
||||
},
|
||||
};
|
||||
if (tpl === 'hud-counter') return {
|
||||
type: 'text',
|
||||
opts: {
|
||||
text: '💰 0', name: 'HUD-счётчик',
|
||||
x: 5, y: 5, w: 14, h: 6, anchor: 'top-left',
|
||||
bgColor: '#0a0a0a', bgOpacity: 0.7,
|
||||
borderRadius: 10, borderWidth: 2, borderColor: '#ffd166',
|
||||
textColor: '#ffd166', textSize: 18, fontWeight: 800,
|
||||
},
|
||||
};
|
||||
if (tpl === 'card') return {
|
||||
type: 'frame',
|
||||
opts: {
|
||||
name: 'Карточка', w: 24, h: 14,
|
||||
bgGradient: { stops: ['#3a3a8a', '#1a1a4a'], angle: 135 },
|
||||
borderRadius: 12, borderWidth: 2, borderColor: '#5050a0',
|
||||
shadow: true,
|
||||
},
|
||||
};
|
||||
// Задача 04: шаблон «Модальное окно» — батч из нескольких элементов.
|
||||
// Затемняющий фрейм во весь экран + центральная карточка + заголовок + Закрыть.
|
||||
// Раскрывается батчем (тип = '_batch', elements = [...]).
|
||||
if (tpl === 'modal') return {
|
||||
type: '_batch',
|
||||
opts: {
|
||||
elements: [
|
||||
{
|
||||
type: 'frame', name: 'Модал затемнение',
|
||||
x: 50, y: 50, w: 100, h: 100, anchor: 'center',
|
||||
bgColor: '#000000', bgOpacity: 0.6,
|
||||
borderRadius: 0, borderWidth: 0,
|
||||
},
|
||||
{
|
||||
type: 'frame', name: 'Модал карточка',
|
||||
x: 50, y: 50, w: 40, h: 35, anchor: 'center',
|
||||
bgGradient: { stops: ['#2a2a5a', '#0a0a1a'], angle: 135 },
|
||||
borderRadius: 18, borderWidth: 3, borderColor: '#ffd700',
|
||||
shadow: true,
|
||||
},
|
||||
{
|
||||
type: 'text', name: 'Модал заголовок',
|
||||
x: 50, y: 40, w: 36, h: 6, anchor: 'center',
|
||||
text: 'Заголовок', textSize: 28, fontWeight: 900,
|
||||
textColor: '#ffffff', textStroke: { color: '#000', width: 2 },
|
||||
bgColor: 'transparent', bgOpacity: 0,
|
||||
},
|
||||
{
|
||||
type: 'text', name: 'Модал текст',
|
||||
x: 50, y: 52, w: 34, h: 10, anchor: 'center',
|
||||
text: 'Описание модального окна. Замени на свой текст.',
|
||||
textSize: 16, fontWeight: 500, textColor: '#cfd0e8',
|
||||
bgColor: 'transparent', bgOpacity: 0,
|
||||
},
|
||||
{
|
||||
type: 'button', name: 'Модал закрыть',
|
||||
x: 50, y: 62, w: 14, h: 6, anchor: 'center',
|
||||
text: 'Закрыть',
|
||||
bgGradient: { stops: ['#22ff66', '#0a803a'], angle: 90 },
|
||||
borderRadius: 10, borderWidth: 2, borderColor: '#000',
|
||||
textColor: '#fff', textSize: 18, fontWeight: 900,
|
||||
textStroke: { color: '#000', width: 1 },
|
||||
hover: { scale: 1.08, brightness: 1.2, duration: 0.15 },
|
||||
active: { scale: 0.94, duration: 0.08 },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
return { type: typeOrTemplate, opts: {} };
|
||||
}
|
||||
|
||||
const GUI_PALETTE_ITEMS = [
|
||||
{ type: 'frame', icon: 'square', name: 'Контейнер', hint: 'Прямоугольная панель — рамка для других элементов' },
|
||||
{ type: 'scroll', icon: 'align-left', name: 'Список', hint: 'Прокручиваемая панель — для длинных списков и меню' },
|
||||
@ -195,6 +301,13 @@ const GUI_PALETTE_ITEMS = [
|
||||
{ type: 'button', icon: 'component', name: 'Кнопка', hint: 'Кликабельная кнопка — реагирует в скрипте' },
|
||||
{ type: 'textbox', icon: 'edit', name: 'Поле ввода', hint: 'Поле для ввода текста игроком' },
|
||||
{ type: 'image', icon: 'image', name: 'Картинка', hint: 'Картинка из библиотеки или по ссылке' },
|
||||
// === Задача 03: готовые шаблоны Roblox-стиля ===
|
||||
{ type: 'template:big-icon', icon: 'gamepad', name: 'Кнопка-иконка', hint: 'Большая квадратная (как «Магазин» в Roblox) — градиент + обводка текста + hover' },
|
||||
{ type: 'template:price', icon: 'coin', name: 'Кнопка с ценой', hint: 'Длинная радужная (как «X2 ДЕНЕГ») — 4-цветный градиент, обводка' },
|
||||
{ type: 'template:hud-counter', icon: 'star', name: 'HUD-счётчик', hint: 'Маленький бейдж в углу — иконка + число' },
|
||||
{ type: 'template:card', icon: 'square', name: 'Карточка', hint: 'Крупная плашка с градиентом и тенью — для меню' },
|
||||
// === Задача 04: шаблон модального окна (батч) ===
|
||||
{ type: 'template:modal', icon: 'message-square', name: 'Модальное окно', hint: 'Затемнение во весь экран + карточка с заголовком + кнопка «Закрыть» — для boss-intro, лутбоксов, диалогов' },
|
||||
];
|
||||
|
||||
/**
|
||||
@ -466,6 +579,9 @@ const KubikonEditor = () => {
|
||||
// Скрипт через game.hud.setVisible(false) может полностью скрыть HUD
|
||||
// движка (HP, hotbar, ...) — для своего меню через game.gui.*.
|
||||
const [stdHudVisible, setStdHudVisible] = useState(true);
|
||||
// Задача 03: отдельный контроль хотбара и HP — для игр без инвентаря/жизней.
|
||||
const [hotbarVisible, setHotbarVisible] = useState(true);
|
||||
const [hpVisible, setHpVisible] = useState(true);
|
||||
// Кнопка-глазок в Иерархии "Интерфейс" — временно скрывает все GUI-элементы
|
||||
// в редакторе (только в редакторе, в Play они видны как и раньше).
|
||||
const [guiOverlayHidden, setGuiOverlayHidden] = useState(false);
|
||||
@ -696,6 +812,9 @@ const KubikonEditor = () => {
|
||||
// settingsModalOpen — настройки игры (Roblox Game Settings)
|
||||
// initialModalOpen — инициальный диалог при создании новой игры
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
// Задача 07: модал управления скинами проекта + список всех скинов (манифест).
|
||||
const [skinManagerOpen, setSkinManagerOpen] = useState(false);
|
||||
const [allSkinsList, setAllSkinsList] = useState([]);
|
||||
const [publishModalOpen, setPublishModalOpen] = useState(false);
|
||||
const [historyModalOpen, setHistoryModalOpen] = useState(false);
|
||||
const [moderationHistory, setModerationHistory] = useState([]);
|
||||
@ -822,29 +941,30 @@ const KubikonEditor = () => {
|
||||
// значением (lastLoaded===0) И сцена сейчас пустая по ВСЕМ
|
||||
// коллекциям — значит загрузка не отработала. Сохранять нечего,
|
||||
// блокируем (иначе пустышка затрёт реальный проект в БД).
|
||||
if (currentProjectIdRef.current != null && lastLoaded === 0
|
||||
&& !userWasEditing) {
|
||||
if (currentProjectIdRef.current != null) {
|
||||
const s = sceneRef.current;
|
||||
const blockN = s.blockManager?.blocks?.size ?? 0;
|
||||
const primN = s.primitiveManager?.instances?.size ?? 0;
|
||||
const modelN = s.modelManager?.instances?.size ?? 0;
|
||||
const umN = s.userModelManager?.instances?.size ?? 0;
|
||||
// Скрипты и GUI — тоже контент: проект может быть «пустая сцена +
|
||||
// скрипты» (например, игра целиком на game.scene.spawn из кода).
|
||||
// demo-скрипт добавляется автоматически — не считаем его за контент.
|
||||
const scriptN = (s._scripts || []).filter(x => x && x.id !== 'demo').length;
|
||||
const guiN = s.guiManager?.getAll?.()?.length ?? 0;
|
||||
const totalContent = currentVoxels + blockN + primN + modelN + umN
|
||||
+ scriptN + guiN;
|
||||
// ЖЁСТКАЯ защита: если сцена ПОЛНОСТЬЮ пустая (0 блоков, 0 примитивов,
|
||||
// 0 моделей, 0 скриптов, 0 GUI) — НИКОГДА не сохраняем поверх
|
||||
// существующего проекта. Даже если userWasEditing — это значит юзер
|
||||
// нажал «Очистить» (удалил всё) или произошёл HMR-reset.
|
||||
// Если человек реально хочет пустую игру — создаст новый проект.
|
||||
if (totalContent === 0) {
|
||||
console.error(
|
||||
'[KubikonEditor] SAVE BLOCKED: существующий проект, '
|
||||
+ 'но сцена пустая по всем коллекциям и lastLoaded=0 — '
|
||||
+ 'загрузка не отработала. Перезагрузите страницу.'
|
||||
'[KubikonEditor] SAVE BLOCKED: сцена пустая по всем коллекциям. '
|
||||
+ 'Существующий проект не будет затёрт пустышкой. '
|
||||
+ 'Перезагрузите страницу, чтобы вернуть содержимое из БД.'
|
||||
);
|
||||
setSaveStatus('error');
|
||||
setSaveDetail({
|
||||
phase: 'Сохранение заблокировано: сцена пустая (загрузка не отработала). Перезагрузите страницу!',
|
||||
phase: 'Сохранение заблокировано: сцена пустая. Перезагрузите страницу!',
|
||||
pct: 0, error: true,
|
||||
});
|
||||
setTimeout(() => setSaveDetail(null), 8000);
|
||||
@ -1003,14 +1123,27 @@ const KubikonEditor = () => {
|
||||
fetch('/kubikon-assets/characters/skins_manifest.json')
|
||||
.then(r => r.json())
|
||||
.then(json => {
|
||||
// Задача 07: и человекоподобные R15, и non-humanoid скины
|
||||
// (животные/машины/еда/роботы) доступны как стартовый скин.
|
||||
// category для группировки в Inspector: 'Персонажи' (люди) или
|
||||
// 'Скины-животные' (всё остальное).
|
||||
const catLabel = (c) => (!c || c === 'human') ? 'Персонажи' : 'Скины-фигуры';
|
||||
const skinOpts = (json.skins || []).map(s => ({
|
||||
id: s.id, // 'skin_bacon-hair'
|
||||
id: s.id, // 'skin_bacon-hair' / 'skin_squirrel-donut'
|
||||
name: s.name || s.slug,
|
||||
category: 'Персонажи',
|
||||
category: catLabel(s.category),
|
||||
skinKind: s.kind || 'r15',
|
||||
skinCategory: s.category || 'human',
|
||||
}));
|
||||
if (skinOpts.length > 0 && sceneRef.current) {
|
||||
sceneRef.current.setPlayerOptions([...baseChars, ...skinOpts]);
|
||||
}
|
||||
// Задача 07: полный список скинов для SkinManagerModal.
|
||||
setAllSkinsList((json.skins || []).map(s => ({
|
||||
id: s.id, slug: s.slug || (s.id || '').replace(/^skin_/, ''),
|
||||
name: s.name || s.slug, kind: s.kind || 'r15',
|
||||
category: s.category || 'human', price: Number.isFinite(s.price) ? s.price : 0,
|
||||
})));
|
||||
})
|
||||
.catch(e => console.warn('[KubikonEditor] R15 skins manifest load failed:', e));
|
||||
|
||||
@ -1132,6 +1265,9 @@ const KubikonEditor = () => {
|
||||
}
|
||||
// Подписка на изменение видимости стандартного HUD от game.hud.setVisible
|
||||
scene.setOnStdHudVisibilityChange?.((v) => setStdHudVisible(v));
|
||||
// Задача 03: отдельные подписки на хотбар и HP
|
||||
scene.setOnHotbarVisibilityChange?.((v) => setHotbarVisible(v));
|
||||
scene.setOnHpVisibilityChange?.((v) => setHpVisible(v));
|
||||
// Подписка на смену cursor-режима из скрипта (game.input.setCursorMode)
|
||||
scene.setOnCursorModeChange?.((mode) => setUiCursorMode(mode === 'ui'));
|
||||
|
||||
@ -1621,6 +1757,14 @@ const KubikonEditor = () => {
|
||||
>
|
||||
<Icon name="settings" size={13} /> Настройки
|
||||
</button>
|
||||
<button
|
||||
className={cl.toolbarBtn}
|
||||
onClick={() => setSkinManagerOpen(true)}
|
||||
title="Скины игрока: стартовый скин, магазин, рублики, свои .glb"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
<Icon name="user-square" size={13} /> Скины
|
||||
</button>
|
||||
<button className={cl.toolbarBtn} onClick={handleSave}><Icon name="save" size={13} /> Сохранить</button>
|
||||
<button
|
||||
className={cl.toolbarBtn}
|
||||
@ -2153,12 +2297,28 @@ const KubikonEditor = () => {
|
||||
</div>
|
||||
) : paletteTab === 'gui' ? (
|
||||
<GuiPalette
|
||||
onPlaceCenter={(type) => {
|
||||
// Клик по карточке — добавить в центр экрана.
|
||||
const id = sceneRef.current?.createGuiElement?.(type, {});
|
||||
if (id) {
|
||||
sceneRef.current?.selection?.selectGui?.(id);
|
||||
setActiveTool('select');
|
||||
onPlaceCenter={(typeOrTemplate) => {
|
||||
const { type, opts } = _expandGuiTemplate(typeOrTemplate);
|
||||
// Задача 04: батч-шаблон (модальное окно) — создаём несколько элементов.
|
||||
if (type === '_batch' && Array.isArray(opts?.elements)) {
|
||||
let lastId = null;
|
||||
for (const el of opts.elements) {
|
||||
const elType = el.type || el.kind || 'frame';
|
||||
const elOpts = { ...el };
|
||||
delete elOpts.type; delete elOpts.kind;
|
||||
const id = sceneRef.current?.createGuiElement?.(elType, elOpts);
|
||||
if (id) lastId = id;
|
||||
}
|
||||
if (lastId) {
|
||||
sceneRef.current?.selection?.selectGui?.(lastId);
|
||||
setActiveTool('select');
|
||||
}
|
||||
} else {
|
||||
const id = sceneRef.current?.createGuiElement?.(type, opts);
|
||||
if (id) {
|
||||
sceneRef.current?.selection?.selectGui?.(id);
|
||||
setActiveTool('select');
|
||||
}
|
||||
}
|
||||
markDirty();
|
||||
}}
|
||||
@ -2353,20 +2513,39 @@ const KubikonEditor = () => {
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
const type = e.dataTransfer.getData('application/x-kubikon-gui');
|
||||
if (!type) return;
|
||||
const rawType = e.dataTransfer.getData('application/x-kubikon-gui');
|
||||
if (!rawType) return;
|
||||
e.preventDefault();
|
||||
// Позиция отпускания → проценты от viewport (центр элемента).
|
||||
const rect = viewportRef.current?.getBoundingClientRect();
|
||||
if (!rect || rect.width === 0) return;
|
||||
const px = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const py = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
const x = Math.max(0, Math.min(100, Math.round(px)));
|
||||
const y = Math.max(0, Math.min(100, Math.round(py)));
|
||||
const id = sceneRef.current?.createGuiElement?.(type, { x, y, anchor: 'center' });
|
||||
if (id) {
|
||||
sceneRef.current?.selection?.selectGui?.(id);
|
||||
setActiveTool('select');
|
||||
// Задача 03: раскрытие шаблона если type начинается с 'template:'.
|
||||
const { type, opts } = _expandGuiTemplate(rawType);
|
||||
// Задача 04: батч-шаблон — несколько элементов.
|
||||
if (type === '_batch' && Array.isArray(opts?.elements)) {
|
||||
let lastId = null;
|
||||
for (const el of opts.elements) {
|
||||
const elType = el.type || el.kind || 'frame';
|
||||
const elOpts = { ...el };
|
||||
delete elOpts.type; delete elOpts.kind;
|
||||
const id = sceneRef.current?.createGuiElement?.(elType, elOpts);
|
||||
if (id) lastId = id;
|
||||
}
|
||||
if (lastId) {
|
||||
sceneRef.current?.selection?.selectGui?.(lastId);
|
||||
setActiveTool('select');
|
||||
}
|
||||
} else {
|
||||
const id = sceneRef.current?.createGuiElement?.(type, {
|
||||
...opts, x, y, anchor: 'center',
|
||||
});
|
||||
if (id) {
|
||||
sceneRef.current?.selection?.selectGui?.(id);
|
||||
setActiveTool('select');
|
||||
}
|
||||
}
|
||||
markDirty();
|
||||
}}
|
||||
@ -2568,8 +2747,15 @@ const KubikonEditor = () => {
|
||||
</div>
|
||||
)}
|
||||
{/* Player HUD: HP + ammo (только в Play, и если скрипт не скрыл) */}
|
||||
{/* Задача 04: модал-overlay (затемнение). Рендерится ПЕРЕД HUD/GUI
|
||||
чтобы при target='scene' HUD оставался поверх (zIndex=25 у
|
||||
ModalOverlay при scene, у GuiOverlay/HUD выше). При
|
||||
target='screen' ModalOverlay сам прыгает на zIndex=50. */}
|
||||
{isPlaying && <ModalOverlay scene={sceneRef.current} />}
|
||||
{/* Задача 07: встроенный магазин скинов (открывается по B / API) */}
|
||||
{isPlaying && <SkinShopOverlay scene={sceneRef.current} />}
|
||||
<PlayerHud
|
||||
visible={isPlaying && stdHudVisible}
|
||||
visible={isPlaying && stdHudVisible && hpVisible}
|
||||
hp={playerHp.hp}
|
||||
maxHp={playerHp.maxHp}
|
||||
ammo={weaponAmmo}
|
||||
@ -2577,7 +2763,7 @@ const KubikonEditor = () => {
|
||||
/>
|
||||
{/* Hot-bar инвентаря (виден только в Play, и если скрипт не скрыл) */}
|
||||
<Hotbar
|
||||
visible={isPlaying && stdHudVisible}
|
||||
visible={isPlaying && stdHudVisible && hotbarVisible}
|
||||
slots={inventoryState.slots}
|
||||
activeIndex={inventoryState.activeIndex}
|
||||
onSelect={(i) => sceneRef.current?.setActiveInventorySlot?.(i)}
|
||||
@ -2953,11 +3139,12 @@ const KubikonEditor = () => {
|
||||
onSetPrimitiveProps={(patch) =>
|
||||
sceneRef.current?.setSelectedPrimitivePropsTo(patch)}
|
||||
onEditBillboard={() => {
|
||||
// Открываем модалку с данными выделенного billboard-примитива
|
||||
const s = sceneRef.current;
|
||||
const sel = s?.selection?._selection;
|
||||
const sel = s?.selection?.getSelection?.();
|
||||
console.log('[EditBillboard] click, sel=', sel);
|
||||
if (!sel || sel.type !== 'primitive') return;
|
||||
const data = s?.primitiveManager?.instances?.get(sel.id);
|
||||
console.log('[EditBillboard] data=', data?.type, 'id=', data?.id);
|
||||
if (!data || data.type !== 'billboard') return;
|
||||
setBillboardEditorData({
|
||||
id: data.id,
|
||||
@ -3107,6 +3294,31 @@ const KubikonEditor = () => {
|
||||
onSave={handleSettingsSave}
|
||||
onCaptureScreenshot={captureSceneScreenshot}
|
||||
/>
|
||||
{/* Задача 07: управление скинами проекта (стартовый, магазин, рублики, кастомные .glb) */}
|
||||
<SkinManagerModal
|
||||
open={skinManagerOpen}
|
||||
allSkins={allSkinsList}
|
||||
skinsConfig={sceneRef.current?._skinsConfig || null}
|
||||
onClose={() => setSkinManagerOpen(false)}
|
||||
onSave={(config) => {
|
||||
const sc = sceneRef.current;
|
||||
if (sc) {
|
||||
sc._skinsConfig = config;
|
||||
// Стартовый скин синхронизируем с playerModelType движка/UI.
|
||||
if (config.default) {
|
||||
const d = config.default;
|
||||
const pmt = (d.startsWith('character-') || d.startsWith('skin_') || d.startsWith('customskin:'))
|
||||
? d : ('skin_' + d);
|
||||
try { sc.setPlayerModelType?.(pmt); } catch (e) {}
|
||||
setPlayerModelTypeUI(pmt);
|
||||
}
|
||||
// Кастомные .glb: dataUrl хранится прямо в config.customGlbs,
|
||||
// движок резолвит их через scene.getAssetDataUrl(slug).
|
||||
}
|
||||
markDirty();
|
||||
setSkinManagerOpen(false);
|
||||
}}
|
||||
/>
|
||||
<PublishModal
|
||||
open={publishModalOpen}
|
||||
project={{
|
||||
|
||||
101
src/editor/ModalOverlay.jsx
Normal file
101
src/editor/ModalOverlay.jsx
Normal file
@ -0,0 +1,101 @@
|
||||
/**
|
||||
* ModalOverlay — рендерит затемнение модальной сцены.
|
||||
* Задача 04. Подписан на ModalManager.setOnChange — получает state.
|
||||
*
|
||||
* Архитектура:
|
||||
* - Слой ПОД GUI-overlay (z-index ниже GuiOverlay) но НАД Babylon-канвасом.
|
||||
* - Если target='screen' — слой поверх ВСЕГО (включая GUI). z-index выше.
|
||||
* - Spotlights через CSS mask-image: radial-gradient(...) — вырезает «дырки».
|
||||
* - pointer-events: auto когда модал открыт (перехватывает клики кроме GUI).
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export default function ModalOverlay({ scene }) {
|
||||
const [state, setState] = useState(null);
|
||||
|
||||
// Поллинг — надёжнее чем setOnChange callback, который может перетереться
|
||||
// или не вызваться если scene изменился на следующем кадре.
|
||||
useEffect(() => {
|
||||
if (!scene?.modalManager) return;
|
||||
let cancelled = false;
|
||||
const tick = () => {
|
||||
if (cancelled) return;
|
||||
const s = scene.modalManager.getState?.();
|
||||
// Снимок shallow-clone — иначе React не увидит изменение
|
||||
setState(s ? {
|
||||
id: s.id,
|
||||
fadePhase: s.fadePhase,
|
||||
currentAlpha: s.currentAlpha,
|
||||
opts: s.opts,
|
||||
spotlightScreens: s.spotlightScreens,
|
||||
} : null);
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
return () => { cancelled = true; };
|
||||
}, [scene]);
|
||||
|
||||
if (!state || state.fadePhase === 'closed') return null;
|
||||
if (state.currentAlpha <= 0.001) return null;
|
||||
console.log('[ModalOverlay] RENDERING alpha=', state.currentAlpha.toFixed(2), 'phase=', state.fadePhase, 'target=', state.opts?.target);
|
||||
|
||||
const opts = state.opts;
|
||||
const isScreen = opts.target === 'screen';
|
||||
const color = opts.darkenColor || '#000000';
|
||||
const alpha = Math.max(0, Math.min(1, state.currentAlpha));
|
||||
// RGBA bg
|
||||
const bg = _hexToRgba(color, alpha);
|
||||
|
||||
// mask-image для spotlights (только для target='scene' — на 'screen' нет смысла)
|
||||
let maskStyle = {};
|
||||
if (!isScreen && Array.isArray(state.spotlightScreens) && state.spotlightScreens.length) {
|
||||
const softEdge = opts.spotlightSoftEdge ?? 40;
|
||||
const gradients = state.spotlightScreens.map(s => {
|
||||
const inner = Math.max(0, s.r - softEdge);
|
||||
const outer = s.r;
|
||||
// mask-image: внутри круга — transparent (вырезаем), снаружи — black (показываем затемнение)
|
||||
return `radial-gradient(circle at ${s.x.toFixed(0)}px ${s.y.toFixed(0)}px, transparent ${inner}px, black ${outer}px)`;
|
||||
});
|
||||
maskStyle = {
|
||||
WebkitMaskImage: gradients.join(', '),
|
||||
maskImage: gradients.join(', '),
|
||||
WebkitMaskComposite: 'source-in',
|
||||
maskComposite: 'intersect',
|
||||
};
|
||||
}
|
||||
|
||||
// ВАЖНО pointer-events: none — иначе overlay перехватывает клики и кнопки модала не работают.
|
||||
// Затемнение — это просто визуальный фильтр, blockInput реализован в PlayerController.
|
||||
// zIndex:
|
||||
// target='scene' → 24 (под GuiOverlay zIndex=25 чтобы GUI был ВИДЕН поверх затемнения)
|
||||
// target='screen' → 60 (поверх GUI — закрывает ВСЁ)
|
||||
// Для 'screen' GUI модала всё равно поверх (GuiOverlay zIndex=25, наш ScreenOverlay 60,
|
||||
// GUI элементы модала рендерятся в GuiOverlay — поэтому надо ставить их в отдельный
|
||||
// слой ВЫШЕ overlay). Простой фикс: для screen ставим overlay на 24 тоже.
|
||||
const zIdx = 24;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: bg,
|
||||
zIndex: zIdx,
|
||||
pointerEvents: 'none', // НЕ перехватываем клики — иначе кнопки не работают
|
||||
transition: 'background-color 0.05s linear',
|
||||
...maskStyle,
|
||||
}}
|
||||
data-modal-overlay={state.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function _hexToRgba(hex, a) {
|
||||
if (typeof hex !== 'string' || !hex.startsWith('#')) return `rgba(0,0,0,${a})`;
|
||||
let h = hex.slice(1);
|
||||
if (h.length === 3) h = h.split('').map(c => c + c).join('');
|
||||
if (h.length !== 6) return `rgba(0,0,0,${a})`;
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
return `rgba(${r},${g},${b},${a})`;
|
||||
}
|
||||
713
src/editor/SkinManagerModal.jsx
Normal file
713
src/editor/SkinManagerModal.jsx
Normal file
@ -0,0 +1,713 @@
|
||||
/**
|
||||
* SkinManagerModal — модал управления скинами проекта (задача 07).
|
||||
*
|
||||
* Здесь автор игры настраивает, какие скины доступны игрокам:
|
||||
* - выбирает СТАРТОВЫЙ скин (default) — с него игрок начинает;
|
||||
* - отмечает, какие скины разблокированы по умолчанию (unlocked) —
|
||||
* их игрок носит бесплатно без покупки в магазине;
|
||||
* - включает/выключает встроенный магазин скинов (клавиша B в Play);
|
||||
* - задаёт стартовый баланс рубликов игрока (coins);
|
||||
* - добавляет СВОИ скины из .glb-файлов (customGlbs) — каждый со своим
|
||||
* масштабом (scale) и высотой бёдер (hipHeight) для правильной посадки.
|
||||
*
|
||||
* Итог сохраняется в конфиг проекта:
|
||||
* { default, unlocked:[slug], shopVisible, coins, customGlbs:[{...}] }
|
||||
*
|
||||
* Стиль повторяет SkinShopOverlay/GameSettingsModal (тёмная тема, акцент
|
||||
* #3b6cff, шрифт Roboto Condensed). Иконки — самописные inline-SVG
|
||||
* (правило проекта: НИКОГДА не эмодзи в UI).
|
||||
*
|
||||
* Props:
|
||||
* open — bool, показывать ли модал (false → null);
|
||||
* onClose() — закрыть без сохранения;
|
||||
* onSave(cfg) — сохранить собранный конфиг;
|
||||
* allSkins — [{ id, slug, name, kind, category, price }] — полный манифест;
|
||||
* skinsConfig — текущая конфигурация проекта (может быть null).
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
// ---- Палитра по категориям (дубль из SkinShopOverlay, чтобы не связывать модули) ----
|
||||
const CAT_THEME = {
|
||||
human: { from: '#3b6cff', to: '#1e2da5', label: 'Люди' },
|
||||
animal: { from: '#46b34a', to: '#1f6b2a', label: 'Животные' },
|
||||
food: { from: '#ff8a3d', to: '#c2410c', label: 'Еда' },
|
||||
vehicle: { from: '#9b59ff', to: '#5b21b6', label: 'Транспорт' },
|
||||
robot: { from: '#22c6d6', to: '#0e7490', label: 'Роботы' },
|
||||
custom: { from: '#fbbf24', to: '#b45309', label: 'Свои' },
|
||||
};
|
||||
const CAT_ORDER = ['all', 'human', 'animal', 'food', 'vehicle', 'robot', 'custom'];
|
||||
|
||||
const MAX_GLB_BYTES = 4 * 1024 * 1024; // 4 МБ — потолок на кастомный .glb
|
||||
|
||||
// ---- Самописные SVG-иконки категорий (дубль из SkinShopOverlay) ----
|
||||
function CatGlyph({ cat, size = 46 }) {
|
||||
const st = { fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' };
|
||||
let body;
|
||||
switch (cat) {
|
||||
case 'human':
|
||||
body = (<><circle cx="12" cy="7" r="3.2" {...st} /><path d="M5 21c0-4 3.2-7 7-7s7 3 7 7" {...st} /></>);
|
||||
break;
|
||||
case 'animal':
|
||||
body = (<><path d="M5 6l2.5 3M19 6l-2.5 3" {...st} /><circle cx="12" cy="13" r="7" {...st} /><circle cx="9.5" cy="12" r="0.9" fill="currentColor" stroke="none" /><circle cx="14.5" cy="12" r="0.9" fill="currentColor" stroke="none" /><path d="M10.5 16c1 0.8 2 0.8 3 0" {...st} /></>);
|
||||
break;
|
||||
case 'food':
|
||||
body = (<><circle cx="12" cy="12" r="8" {...st} /><circle cx="12" cy="12" r="2.6" {...st} /><path d="M7 8.5l0.5 1M16.5 9l-0.7 0.9M9 16l0.6-1M15.5 15.5l-0.7-0.9" {...st} /></>);
|
||||
break;
|
||||
case 'vehicle':
|
||||
body = (<><path d="M3 14l1.5-4.5A2 2 0 0 1 6.4 8h11.2a2 2 0 0 1 1.9 1.5L21 14v3h-2" {...st} /><path d="M3 14v3h2" {...st} /><circle cx="7.5" cy="17" r="1.8" {...st} /><circle cx="16.5" cy="17" r="1.8" {...st} /></>);
|
||||
break;
|
||||
case 'robot':
|
||||
body = (<><rect x="6" y="8" width="12" height="10" rx="2" {...st} /><path d="M12 8V5M9 5h6" {...st} /><circle cx="9.5" cy="13" r="1" fill="currentColor" stroke="none" /><circle cx="14.5" cy="13" r="1" fill="currentColor" stroke="none" /></>);
|
||||
break;
|
||||
default: // custom — звезда
|
||||
body = (<path d="M12 4l2.2 4.8L19 9.4l-3.6 3.3 1 5-4.4-2.5L7.6 17.7l1-5L5 9.4l4.8-0.6z" {...st} />);
|
||||
}
|
||||
return (<svg width={size} height={size} viewBox="0 0 24 24" style={{ display: 'block' }}>{body}</svg>);
|
||||
}
|
||||
|
||||
// ---- Монета-рублик (дубль из SkinShopOverlay) ----
|
||||
function CoinIcon({ size = 16 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" style={{ display: 'inline-block', verticalAlign: 'middle' }}>
|
||||
<circle cx="12" cy="12" r="9" fill="#ffd24a" stroke="#a86b00" strokeWidth="1.6" />
|
||||
<text x="12" y="16" textAnchor="middle" fontSize="11" fontWeight="900" fill="#7a4d00">₽</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Мелкие самописные иконки управления ----
|
||||
function XIcon({ size = 14 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" style={{ display: 'block' }}>
|
||||
<path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function CheckIcon({ size = 12 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" style={{ display: 'block' }}>
|
||||
<path d="M5 12.5l4.5 4.5L19 6.5" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function PlusIcon({ size = 14 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" style={{ display: 'block' }}>
|
||||
<path d="M12 5v14M5 12h14" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_FALLBACK = 'skin_bacon-hair';
|
||||
|
||||
export default function SkinManagerModal({ open, onClose, onSave, allSkins, skinsConfig }) {
|
||||
const manifest = useMemo(() => Array.isArray(allSkins) ? allSkins : [], [allSkins]);
|
||||
|
||||
// ----- Локальный state конфигурации (заполняется при открытии) -----
|
||||
const [defaultSlug, setDefaultSlug] = useState(DEFAULT_FALLBACK);
|
||||
const [unlocked, setUnlocked] = useState([]); // массив slug
|
||||
const [shopVisible, setShopVisible] = useState(true);
|
||||
const [coins, setCoins] = useState(0);
|
||||
const [customGlbs, setCustomGlbs] = useState([]); // [{slug,name,kind,category,scale,hipHeight,dataUrl,price}]
|
||||
|
||||
// ----- UI-state -----
|
||||
const [cat, setCat] = useState('all');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// ----- Форма добавления кастомного скина -----
|
||||
const [draftName, setDraftName] = useState('');
|
||||
const [draftScale, setDraftScale] = useState(1.5);
|
||||
const [draftHip, setDraftHip] = useState(0.4);
|
||||
const [draftDataUrl, setDraftDataUrl] = useState(''); // dataURL выбранного .glb (ещё не добавлен)
|
||||
const [draftFileName, setDraftFileName] = useState(''); // имя файла для подсказки
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
// Заполняем поля ОДИН РАЗ при открытии (паттерн как в GameSettingsModal:
|
||||
// зависимость только от [open], чтобы новый литерал-объект родителя
|
||||
// не сбрасывал состояние при каждом ре-рендере).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const cfg = skinsConfig || {};
|
||||
// дефолтный скин: из конфига → иначе первый человекоподобный → иначе фолбэк-slug
|
||||
let def = cfg.default;
|
||||
if (!def) {
|
||||
const firstHuman = manifest.find(s => (s.category || 'human') === 'human');
|
||||
def = firstHuman ? firstHuman.slug : DEFAULT_FALLBACK;
|
||||
}
|
||||
setDefaultSlug(def);
|
||||
setUnlocked(Array.isArray(cfg.unlocked) ? [...cfg.unlocked] : []);
|
||||
setShopVisible(cfg.shopVisible !== undefined ? !!cfg.shopVisible : true);
|
||||
setCoins(typeof cfg.coins === 'number' ? cfg.coins : 0);
|
||||
setCustomGlbs(Array.isArray(cfg.customGlbs) ? cfg.customGlbs.map(g => ({ ...g })) : []);
|
||||
// сброс UI/формы
|
||||
setCat('all');
|
||||
setError('');
|
||||
setDraftName('');
|
||||
setDraftScale(1.5);
|
||||
setDraftHip(0.4);
|
||||
setDraftDataUrl('');
|
||||
setDraftFileName('');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
// Объединённый список скинов: манифест + кастомные (custom-категория).
|
||||
const combined = useMemo(() => {
|
||||
const customCards = customGlbs.map(g => ({
|
||||
id: 'skin_' + g.slug,
|
||||
slug: g.slug,
|
||||
name: g.name,
|
||||
kind: g.kind || 'non-humanoid-mesh',
|
||||
category: 'custom',
|
||||
price: g.price || 0,
|
||||
__custom: true,
|
||||
}));
|
||||
return [...manifest, ...customCards];
|
||||
}, [manifest, customGlbs]);
|
||||
|
||||
// Видимые категории (только присутствующие) для табов-фильтра.
|
||||
const cats = useMemo(() => {
|
||||
const present = new Set(combined.map(s => s.category || 'human'));
|
||||
return CAT_ORDER.filter(c => c === 'all' || present.has(c));
|
||||
}, [combined]);
|
||||
|
||||
// Фильтрованная сетка.
|
||||
const visible = useMemo(() => {
|
||||
if (cat === 'all') return combined;
|
||||
return combined.filter(s => (s.category || 'human') === cat);
|
||||
}, [combined, cat]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const unlockedSet = new Set(unlocked);
|
||||
|
||||
// Выбрать карточку как стартовый скин.
|
||||
const pickDefault = (slug) => {
|
||||
setDefaultSlug(slug);
|
||||
};
|
||||
|
||||
// Переключить «разблокирован по умолчанию» (стартовый — всегда включён).
|
||||
const toggleUnlock = (slug) => {
|
||||
if (slug === defaultSlug) return; // стартовый нельзя снять — он всегда unlocked
|
||||
setUnlocked(prev => prev.includes(slug)
|
||||
? prev.filter(x => x !== slug)
|
||||
: [...prev, slug]);
|
||||
};
|
||||
|
||||
// Выбор .glb → читаем как dataURL, проверяем размер.
|
||||
const handleFile = (e) => {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
if (!file) return;
|
||||
if (!/\.glb$/i.test(file.name)) {
|
||||
setError('Нужен файл с расширением .glb');
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_GLB_BYTES) {
|
||||
setError('Файл слишком большой (макс. 4 МБ)');
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
setDraftDataUrl(ev.target.result);
|
||||
setDraftFileName(file.name);
|
||||
// предзаполним имя из имени файла, если поле пустое
|
||||
setDraftName(prev => prev || file.name.replace(/\.glb$/i, ''));
|
||||
setError('');
|
||||
};
|
||||
reader.onerror = () => setError('Не удалось прочитать файл');
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
// Подтвердить добавление кастомного скина в список.
|
||||
const addCustom = () => {
|
||||
if (!draftDataUrl) {
|
||||
setError('Сначала выбери .glb-файл');
|
||||
return;
|
||||
}
|
||||
const name = draftName.trim();
|
||||
if (!name) {
|
||||
setError('Введи имя скина');
|
||||
return;
|
||||
}
|
||||
const entry = {
|
||||
slug: 'custom-' + Date.now(),
|
||||
name,
|
||||
kind: 'non-humanoid-mesh',
|
||||
category: 'custom',
|
||||
scale: Math.max(0.5, Math.min(3, Number(draftScale) || 1.5)),
|
||||
hipHeight: Math.max(0, Math.min(1, Number(draftHip) || 0.4)),
|
||||
dataUrl: draftDataUrl,
|
||||
price: 0,
|
||||
};
|
||||
setCustomGlbs(prev => [...prev, entry]);
|
||||
// кастомные по умолчанию разблокированы (иначе игрок не сможет их надеть бесплатно)
|
||||
setUnlocked(prev => prev.includes(entry.slug) ? prev : [...prev, entry.slug]);
|
||||
// сброс формы
|
||||
setDraftName('');
|
||||
setDraftScale(1.5);
|
||||
setDraftHip(0.4);
|
||||
setDraftDataUrl('');
|
||||
setDraftFileName('');
|
||||
setError('');
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
// Удалить кастомный скин.
|
||||
const removeCustom = (slug) => {
|
||||
setCustomGlbs(prev => prev.filter(g => g.slug !== slug));
|
||||
setUnlocked(prev => prev.filter(x => x !== slug));
|
||||
if (defaultSlug === slug) {
|
||||
// если удалили стартовый — откатываемся на первый человекоподобный/фолбэк
|
||||
const firstHuman = manifest.find(s => (s.category || 'human') === 'human');
|
||||
setDefaultSlug(firstHuman ? firstHuman.slug : DEFAULT_FALLBACK);
|
||||
}
|
||||
};
|
||||
|
||||
// Собрать и сохранить.
|
||||
const handleSave = () => {
|
||||
// гарантируем, что стартовый скин всегда в unlocked
|
||||
const finalUnlocked = unlocked.includes(defaultSlug)
|
||||
? [...unlocked]
|
||||
: [...unlocked, defaultSlug];
|
||||
const config = {
|
||||
default: defaultSlug,
|
||||
unlocked: finalUnlocked,
|
||||
shopVisible: !!shopVisible,
|
||||
coins: Math.max(0, Math.min(100000, Number(coins) || 0)),
|
||||
customGlbs: customGlbs.map(g => ({ ...g })),
|
||||
};
|
||||
onSave && onSave(config);
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
// ====================== РЕНДЕР ======================
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 10000,
|
||||
background: 'rgba(6, 9, 20, 0.78)',
|
||||
backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 20,
|
||||
fontFamily: '"Roboto Condensed", system-ui, -apple-system, sans-serif',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose && onClose(); }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 'min(960px, 94vw)', maxHeight: '92vh',
|
||||
background: 'linear-gradient(160deg, #161b2e 0%, #0c1020 100%)',
|
||||
border: '2px solid #2b3a66', borderRadius: 20,
|
||||
boxShadow: '0 28px 70px rgba(0,0,0,0.6)',
|
||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
color: '#e8ecf8',
|
||||
}}
|
||||
>
|
||||
{/* ---------- Шапка ---------- */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||
background: 'linear-gradient(90deg, rgba(59,108,255,0.20), transparent)',
|
||||
}}>
|
||||
<div style={{ fontSize: 22, fontWeight: 900, color: '#fff', letterSpacing: 0.3 }}>
|
||||
Скины игрока
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button
|
||||
onClick={onClose}
|
||||
title="Закрыть"
|
||||
style={{
|
||||
width: 34, height: 34, borderRadius: 10, cursor: 'pointer',
|
||||
background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.16)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
><XIcon size={16} /></button>
|
||||
</div>
|
||||
|
||||
{/* ---------- Тело (скролл) ---------- */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', overflowY: 'auto', flex: 1, minHeight: 0 }}>
|
||||
|
||||
{/* Подсказка */}
|
||||
<div style={{
|
||||
padding: '12px 20px 0', color: '#8a93b4', fontSize: 13, lineHeight: 1.45,
|
||||
}}>
|
||||
Кликни по карточке, чтобы выбрать <b style={{ color: '#22ff88' }}>стартовый скин</b>.
|
||||
Галочкой отметь скины, которые игрок носит бесплатно с самого начала.
|
||||
Остальные он покупает в магазине за рублики.
|
||||
</div>
|
||||
|
||||
{/* Табы категорий */}
|
||||
<div style={{ display: 'flex', gap: 8, padding: '12px 20px 4px', flexWrap: 'wrap' }}>
|
||||
{cats.map(c => {
|
||||
const active = c === cat;
|
||||
const label = c === 'all' ? 'Все' : (CAT_THEME[c]?.label || c);
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCat(c)}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 999, cursor: 'pointer',
|
||||
fontSize: 13, fontWeight: 800,
|
||||
background: active ? 'linear-gradient(135deg, #3b6cff, #1e2da5)' : 'rgba(255,255,255,0.06)',
|
||||
border: active ? '1px solid #6b8cff' : '1px solid rgba(255,255,255,0.12)',
|
||||
color: active ? '#fff' : '#aab4d4',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>{label}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Сетка карточек */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||
gap: 12, padding: 20,
|
||||
}}>
|
||||
{visible.map(s => {
|
||||
const theme = CAT_THEME[s.category] || CAT_THEME.human;
|
||||
const isDefault = defaultSlug === s.slug;
|
||||
const isUnlocked = isDefault || unlockedSet.has(s.slug);
|
||||
const price = s.price || 0;
|
||||
const isHuman = (s.kind || 'r15') === 'r15';
|
||||
return (
|
||||
<div
|
||||
key={s.slug}
|
||||
onClick={() => pickDefault(s.slug)}
|
||||
style={{
|
||||
borderRadius: 14, overflow: 'hidden', cursor: 'pointer',
|
||||
border: isDefault ? '3px solid #22ff88' : '2px solid rgba(255,255,255,0.10)',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
transition: 'transform 0.1s, border-color 0.15s',
|
||||
position: 'relative',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isDefault) e.currentTarget.style.transform = 'translateY(-3px)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'none'; }}
|
||||
>
|
||||
{/* Превью-плашка */}
|
||||
<div style={{
|
||||
height: 84, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: `linear-gradient(150deg, ${theme.from}, ${theme.to})`,
|
||||
color: 'rgba(255,255,255,0.92)', position: 'relative',
|
||||
}}>
|
||||
<CatGlyph cat={s.category || 'human'} size={44} />
|
||||
{/* Бейдж «Старт» */}
|
||||
{isDefault && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 6, right: 6,
|
||||
background: '#22ff88', color: '#04361b',
|
||||
fontSize: 10, fontWeight: 900, padding: '2px 7px', borderRadius: 999,
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.4)',
|
||||
}}>Старт</div>
|
||||
)}
|
||||
{/* Бейдж категории для не-человекоподобных */}
|
||||
{!isHuman && !isDefault && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 6, left: 6,
|
||||
background: 'rgba(0,0,0,0.35)', color: '#fff',
|
||||
fontSize: 10, fontWeight: 800, padding: '2px 7px', borderRadius: 999,
|
||||
}}>{CAT_THEME[s.category]?.label || s.category}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Низ карточки */}
|
||||
<div style={{ padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: 6, flex: 1 }}>
|
||||
<div style={{
|
||||
color: '#fff', fontWeight: 800, fontSize: 13,
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>{s.name || s.slug}</div>
|
||||
|
||||
{/* Цена (если > 0) */}
|
||||
{price > 0 && (
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
color: '#ffd24a', fontWeight: 900, fontSize: 13,
|
||||
}}>
|
||||
<CoinIcon size={14} /> {price}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Тогл «разблокирован по умолчанию» */}
|
||||
<label
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
cursor: isDefault ? 'default' : 'pointer',
|
||||
marginTop: 'auto',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
onClick={() => { if (!isDefault) toggleUnlock(s.slug); }}
|
||||
style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: isUnlocked ? (isDefault ? '#1f6b2a' : '#3b6cff') : 'transparent',
|
||||
border: isUnlocked ? '1px solid transparent' : '1.5px solid rgba(255,255,255,0.3)',
|
||||
color: '#fff',
|
||||
opacity: isDefault ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{isUnlocked && <CheckIcon size={11} />}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 600,
|
||||
color: isDefault ? '#7fe0a0' : (isUnlocked ? '#aab4d4' : '#6b76a0'),
|
||||
}}>
|
||||
{isDefault ? 'всегда включён' : 'разблокирован'}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{visible.length === 0 && (
|
||||
<div style={{ color: '#8a93b4', gridColumn: '1 / -1', textAlign: 'center', padding: 30 }}>
|
||||
В этой категории пока нет скинов
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---------- Настройки магазина ---------- */}
|
||||
<div style={{
|
||||
margin: '0 20px 16px', padding: 16, borderRadius: 14,
|
||||
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)',
|
||||
display: 'flex', flexDirection: 'column', gap: 14,
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 900, color: '#9fb0d8', textTransform: 'uppercase', letterSpacing: 0.8 }}>
|
||||
Магазин и экономика
|
||||
</div>
|
||||
|
||||
{/* Чекбокс магазина */}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }}>
|
||||
<span
|
||||
onClick={() => setShopVisible(v => !v)}
|
||||
style={{
|
||||
width: 18, height: 18, borderRadius: 5, flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: shopVisible ? '#3b6cff' : 'transparent',
|
||||
border: shopVisible ? '1px solid transparent' : '1.5px solid rgba(255,255,255,0.3)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{shopVisible && <CheckIcon size={12} />}
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ fontWeight: 800, color: '#fff', fontSize: 14 }}>Встроенный магазин скинов</span>
|
||||
<span style={{ display: 'block', fontSize: 12, color: '#8a93b4', marginTop: 2 }}>
|
||||
Игрок открывает его клавишей <b style={{ color: '#aab4d4' }}>B</b> прямо в игре
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Стартовые рублики */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 800, color: '#9fb0d8', textTransform: 'uppercase', letterSpacing: 0.8 }}>
|
||||
Стартовые рублики игрока
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<CoinIcon size={18} />
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100000}
|
||||
step={1}
|
||||
value={coins}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value === '' ? 0 : Number(e.target.value);
|
||||
setCoins(Math.max(0, Math.min(100000, isNaN(v) ? 0 : v)));
|
||||
}}
|
||||
style={{
|
||||
width: 160,
|
||||
background: '#0c1020', border: '1.5px solid #2b3a66', borderRadius: 10,
|
||||
padding: '8px 12px', color: '#fff', fontSize: 14, fontFamily: 'inherit', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: '#6b76a0' }}>от 0 до 100000</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---------- Свои скины (.glb) ---------- */}
|
||||
<div style={{
|
||||
margin: '0 20px 20px', padding: 16, borderRadius: 14,
|
||||
background: 'rgba(251,191,36,0.06)', border: '1px solid rgba(251,191,36,0.22)',
|
||||
display: 'flex', flexDirection: 'column', gap: 14,
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 900, color: '#fbbf24', textTransform: 'uppercase', letterSpacing: 0.8 }}>
|
||||
Свои скины
|
||||
</div>
|
||||
|
||||
{/* Список уже добавленных */}
|
||||
{customGlbs.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{customGlbs.map(g => (
|
||||
<div key={g.slug} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '8px 12px', borderRadius: 10,
|
||||
background: 'rgba(0,0,0,0.25)', border: '1px solid rgba(255,255,255,0.08)',
|
||||
}}>
|
||||
<div style={{ color: '#fbbf24', display: 'flex' }}><CatGlyph cat="custom" size={22} /></div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
color: '#fff', fontWeight: 800, fontSize: 13,
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>{g.name}</div>
|
||||
<div style={{ fontSize: 11, color: '#8a93b4' }}>
|
||||
масштаб {g.scale}× · высота бёдер {g.hipHeight}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeCustom(g.slug)}
|
||||
title="Удалить"
|
||||
style={{
|
||||
width: 30, height: 30, borderRadius: 8, cursor: 'pointer',
|
||||
background: 'rgba(239,68,68,0.16)', border: '1px solid rgba(239,68,68,0.5)',
|
||||
color: '#ff7a7a', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
><XIcon size={14} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Форма добавления */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".glb"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFile}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current && fileInputRef.current.click()}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8, alignSelf: 'flex-start',
|
||||
padding: '9px 16px', borderRadius: 10, cursor: 'pointer',
|
||||
background: draftDataUrl ? 'rgba(34,255,136,0.14)' : 'rgba(255,255,255,0.06)',
|
||||
border: draftDataUrl ? '1px solid rgba(34,255,136,0.5)' : '1px solid rgba(255,255,255,0.16)',
|
||||
color: draftDataUrl ? '#7fe0a0' : '#e8ecf8',
|
||||
fontSize: 13, fontWeight: 800, fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<PlusIcon size={14} />
|
||||
{draftDataUrl ? `Файл выбран: ${draftFileName}` : 'Выбрать свой скин (.glb)'}
|
||||
</button>
|
||||
|
||||
{/* Поля параметров появляются после выбора файла */}
|
||||
{draftDataUrl && (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 12,
|
||||
padding: 12, borderRadius: 10,
|
||||
background: 'rgba(0,0,0,0.25)', border: '1px solid rgba(255,255,255,0.08)',
|
||||
}}>
|
||||
{/* Имя */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 800, color: '#9fb0d8', textTransform: 'uppercase', letterSpacing: 0.8 }}>
|
||||
Имя скина
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value.slice(0, 60))}
|
||||
placeholder="Например, Мой дракон"
|
||||
style={{
|
||||
background: '#0c1020', border: '1.5px solid #2b3a66', borderRadius: 10,
|
||||
padding: '8px 12px', color: '#fff', fontSize: 14, fontFamily: 'inherit', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Масштаб */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 800, color: '#9fb0d8', textTransform: 'uppercase', letterSpacing: 0.8 }}>
|
||||
Масштаб модели
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: '#ffd24a', fontWeight: 800 }}>{Number(draftScale).toFixed(2)}×</span>
|
||||
</div>
|
||||
<input
|
||||
type="range" min={0.5} max={3} step={0.1}
|
||||
value={draftScale}
|
||||
onChange={(e) => setDraftScale(Number(e.target.value))}
|
||||
style={{ width: '100%', accentColor: '#3b6cff', cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Высота бёдер */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 800, color: '#9fb0d8', textTransform: 'uppercase', letterSpacing: 0.8 }}>
|
||||
Высота бёдер
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: '#ffd24a', fontWeight: 800 }}>{Number(draftHip).toFixed(2)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range" min={0} max={1} step={0.05}
|
||||
value={draftHip}
|
||||
onChange={(e) => setDraftHip(Number(e.target.value))}
|
||||
style={{ width: '100%', accentColor: '#3b6cff', cursor: 'pointer' }}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: '#6b76a0' }}>
|
||||
Насколько приподнять модель над землёй, чтобы ноги не уходили в пол.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={addCustom}
|
||||
style={{
|
||||
alignSelf: 'flex-start',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||
padding: '9px 18px', borderRadius: 10, cursor: 'pointer',
|
||||
background: 'linear-gradient(135deg, #fbbf24, #b45309)',
|
||||
border: '1px solid transparent', color: '#1a1205',
|
||||
fontSize: 14, fontWeight: 900, fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<PlusIcon size={14} /> Добавить скин
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ошибка */}
|
||||
{error && (
|
||||
<div style={{
|
||||
margin: '0 20px 16px', padding: '10px 14px', borderRadius: 10,
|
||||
background: 'rgba(239,68,68,0.16)', border: '1px solid rgba(239,68,68,0.4)',
|
||||
color: '#ff9d9d', fontSize: 13, fontWeight: 700,
|
||||
}}>{error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---------- Подвал ---------- */}
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'flex-end', gap: 10,
|
||||
padding: '14px 20px', borderTop: '1px solid rgba(255,255,255,0.08)',
|
||||
background: 'rgba(0,0,0,0.25)',
|
||||
}}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.16)',
|
||||
borderRadius: 10, color: '#e8ecf8', fontSize: 14, fontWeight: 700,
|
||||
padding: '10px 20px', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>Отмена</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b6cff, #1e2da5)', border: '1px solid transparent',
|
||||
borderRadius: 10, color: '#fff', fontSize: 14, fontWeight: 800,
|
||||
padding: '10px 24px', cursor: 'pointer', fontFamily: 'inherit',
|
||||
boxShadow: '0 6px 16px rgba(59,108,255,0.32)',
|
||||
}}
|
||||
>Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
294
src/editor/SkinShopOverlay.jsx
Normal file
294
src/editor/SkinShopOverlay.jsx
Normal file
@ -0,0 +1,294 @@
|
||||
/**
|
||||
* SkinShopOverlay — встроенный магазин скинов игрока (задача 07).
|
||||
*
|
||||
* Готовый GUI-кит: полноэкранная витрина карточек скинов. Открывается
|
||||
* клавишей B в Play или через game.player.openSkinShop(). Логика покупки
|
||||
* (списание локальных рубликов проекта, unlock, setSkin) живёт в GameRuntime;
|
||||
* этот компонент только рендерит состояние и шлёт намерение «купить/надеть».
|
||||
*
|
||||
* Подписка на состояние — rAF-поллинг scene.getSkinShopState() (как ModalOverlay):
|
||||
* { open, rev, data: { all:[{slug,name,kind,category,price}], unlocked:[slug],
|
||||
* current, coins, shopVisible } }
|
||||
*
|
||||
* Превью скина — цветная плашка по категории + крупная самописная SVG-иконка
|
||||
* (правило проекта: без эмодзи в UI). Категории: human/animal/food/vehicle/robot.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
|
||||
// Палитра градиентов по категории — чтобы витрина была живой и читаемой.
|
||||
const CAT_THEME = {
|
||||
human: { from: '#3b6cff', to: '#1e2da5', label: 'Люди' },
|
||||
animal: { from: '#46b34a', to: '#1f6b2a', label: 'Животные' },
|
||||
food: { from: '#ff8a3d', to: '#c2410c', label: 'Еда' },
|
||||
vehicle: { from: '#9b59ff', to: '#5b21b6', label: 'Транспорт' },
|
||||
robot: { from: '#22c6d6', to: '#0e7490', label: 'Роботы' },
|
||||
custom: { from: '#fbbf24', to: '#b45309', label: 'Свои' },
|
||||
};
|
||||
const CAT_ORDER = ['all', 'human', 'animal', 'food', 'vehicle', 'robot', 'custom'];
|
||||
|
||||
// Самописные SVG-иконки категорий (viewBox 24×24, обводка currentColor).
|
||||
function CatGlyph({ cat, size = 46 }) {
|
||||
const st = { fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' };
|
||||
let body;
|
||||
switch (cat) {
|
||||
case 'human':
|
||||
body = (<><circle cx="12" cy="7" r="3.2" {...st} /><path d="M5 21c0-4 3.2-7 7-7s7 3 7 7" {...st} /></>);
|
||||
break;
|
||||
case 'animal': // мордочка зверя с ушами
|
||||
body = (<><path d="M5 6l2.5 3M19 6l-2.5 3" {...st} /><circle cx="12" cy="13" r="7" {...st} /><circle cx="9.5" cy="12" r="0.9" fill="currentColor" stroke="none" /><circle cx="14.5" cy="12" r="0.9" fill="currentColor" stroke="none" /><path d="M10.5 16c1 0.8 2 0.8 3 0" {...st} /></>);
|
||||
break;
|
||||
case 'food': // пончик
|
||||
body = (<><circle cx="12" cy="12" r="8" {...st} /><circle cx="12" cy="12" r="2.6" {...st} /><path d="M7 8.5l0.5 1M16.5 9l-0.7 0.9M9 16l0.6-1M15.5 15.5l-0.7-0.9" {...st} /></>);
|
||||
break;
|
||||
case 'vehicle': // машинка
|
||||
body = (<><path d="M3 14l1.5-4.5A2 2 0 0 1 6.4 8h11.2a2 2 0 0 1 1.9 1.5L21 14v3h-2" {...st} /><path d="M3 14v3h2" {...st} /><circle cx="7.5" cy="17" r="1.8" {...st} /><circle cx="16.5" cy="17" r="1.8" {...st} /></>);
|
||||
break;
|
||||
case 'robot': // голова робота
|
||||
body = (<><rect x="6" y="8" width="12" height="10" rx="2" {...st} /><path d="M12 8V5M9 5h6" {...st} /><circle cx="9.5" cy="13" r="1" fill="currentColor" stroke="none" /><circle cx="14.5" cy="13" r="1" fill="currentColor" stroke="none" /></>);
|
||||
break;
|
||||
default: // custom — звезда
|
||||
body = (<path d="M12 4l2.2 4.8L19 9.4l-3.6 3.3 1 5-4.4-2.5L7.6 17.7l1-5L5 9.4l4.8-0.6z" {...st} />);
|
||||
}
|
||||
return (<svg width={size} height={size} viewBox="0 0 24 24" style={{ display: 'block' }}>{body}</svg>);
|
||||
}
|
||||
|
||||
// Монета-рублик (для баланса/цены).
|
||||
function CoinIcon({ size = 16 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" style={{ display: 'inline-block', verticalAlign: 'middle' }}>
|
||||
<circle cx="12" cy="12" r="9" fill="#ffd24a" stroke="#a86b00" strokeWidth="1.6" />
|
||||
<text x="12" y="16" textAnchor="middle" fontSize="11" fontWeight="900" fill="#7a4d00">₽</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SkinShopOverlay({ scene }) {
|
||||
const [snap, setSnap] = useState(null);
|
||||
const [cat, setCat] = useState('all');
|
||||
|
||||
// rAF-поллинг состояния магазина из сцены.
|
||||
useEffect(() => {
|
||||
if (!scene?.getSkinShopState) return;
|
||||
let cancelled = false;
|
||||
let lastRev = -1;
|
||||
const tick = () => {
|
||||
if (cancelled) return;
|
||||
const s = scene.getSkinShopState?.();
|
||||
if (s && s.rev !== lastRev) {
|
||||
lastRev = s.rev;
|
||||
setSnap({
|
||||
open: s.open,
|
||||
data: s.data,
|
||||
buyResult: s.buyResult,
|
||||
});
|
||||
} else if (!s && lastRev !== -1) {
|
||||
lastRev = -1;
|
||||
setSnap(null);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
return () => { cancelled = true; };
|
||||
}, [scene]);
|
||||
|
||||
const data = snap?.data || null;
|
||||
|
||||
// Список скинов с категориями (фильтрованный).
|
||||
const skins = useMemo(() => {
|
||||
const all = (data?.all) || [];
|
||||
if (cat === 'all') return all;
|
||||
return all.filter(s => (s.category || 'human') === cat);
|
||||
}, [data, cat]);
|
||||
|
||||
// Какие категории реально есть — для табов.
|
||||
const cats = useMemo(() => {
|
||||
const present = new Set((data?.all || []).map(s => s.category || 'human'));
|
||||
return CAT_ORDER.filter(c => c === 'all' || present.has(c));
|
||||
}, [data]);
|
||||
|
||||
if (!snap || !snap.open || !data) return null;
|
||||
|
||||
const unlocked = new Set(data.unlocked || []);
|
||||
const current = data.current;
|
||||
const coins = data.coins || 0;
|
||||
|
||||
const close = () => { try { scene._closeSkinShop?.(); } catch (e) {} };
|
||||
const onCardClick = (s) => {
|
||||
const owned = unlocked.has(s.slug);
|
||||
const price = s.price || 0;
|
||||
if (!owned && coins < price) return; // не хватает — карточка покажет это
|
||||
try { scene.requestBuySkin?.(s.slug, price); } catch (e) {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute', inset: 0, zIndex: 55,
|
||||
background: 'rgba(6, 9, 20, 0.72)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
|
||||
}}
|
||||
onClick={close}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: 'min(880px, 92vw)', maxHeight: '86vh',
|
||||
background: 'linear-gradient(160deg, #161b2e 0%, #0c1020 100%)',
|
||||
border: '2px solid #2b3a66', borderRadius: 20,
|
||||
boxShadow: '0 24px 60px rgba(0,0,0,0.55)',
|
||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Шапка */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||
background: 'linear-gradient(90deg, rgba(59,108,255,0.18), transparent)',
|
||||
}}>
|
||||
<div style={{ fontSize: 22, fontWeight: 900, color: '#fff', letterSpacing: 0.3 }}>
|
||||
Магазин скинов
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
{/* Баланс */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: 'rgba(255, 210, 74, 0.14)',
|
||||
border: '1px solid rgba(255, 210, 74, 0.4)',
|
||||
borderRadius: 999, padding: '6px 14px',
|
||||
color: '#ffd24a', fontWeight: 900, fontSize: 16,
|
||||
}}>
|
||||
<CoinIcon size={18} /> {coins}
|
||||
</div>
|
||||
{/* Закрыть */}
|
||||
<button
|
||||
onClick={close}
|
||||
style={{
|
||||
width: 34, height: 34, borderRadius: 10, cursor: 'pointer',
|
||||
background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.16)',
|
||||
color: '#fff', fontSize: 18, fontWeight: 700, lineHeight: 1,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
title="Закрыть (B / Esc)"
|
||||
>×</button>
|
||||
</div>
|
||||
|
||||
{/* Табы категорий */}
|
||||
<div style={{ display: 'flex', gap: 8, padding: '12px 20px 4px', flexWrap: 'wrap' }}>
|
||||
{cats.map(c => {
|
||||
const active = c === cat;
|
||||
const label = c === 'all' ? 'Все' : (CAT_THEME[c]?.label || c);
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCat(c)}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 999, cursor: 'pointer',
|
||||
fontSize: 13, fontWeight: 800,
|
||||
background: active ? 'linear-gradient(135deg, #3b6cff, #1e2da5)' : 'rgba(255,255,255,0.06)',
|
||||
border: active ? '1px solid #6b8cff' : '1px solid rgba(255,255,255,0.12)',
|
||||
color: active ? '#fff' : '#aab4d4',
|
||||
}}
|
||||
>{label}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Сетка карточек */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
|
||||
gap: 14, padding: 20, overflowY: 'auto',
|
||||
}}>
|
||||
{skins.map(s => {
|
||||
const theme = CAT_THEME[s.category] || CAT_THEME.human;
|
||||
const owned = unlocked.has(s.slug);
|
||||
const isActive = current === s.slug;
|
||||
const price = s.price || 0;
|
||||
const canAfford = owned || coins >= price;
|
||||
return (
|
||||
<div
|
||||
key={s.slug}
|
||||
onClick={() => onCardClick(s)}
|
||||
style={{
|
||||
borderRadius: 16, overflow: 'hidden', cursor: canAfford ? 'pointer' : 'not-allowed',
|
||||
border: isActive ? '3px solid #22ff88' : '2px solid rgba(255,255,255,0.10)',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
opacity: canAfford ? 1 : 0.55,
|
||||
transition: 'transform 0.1s, border-color 0.15s',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (canAfford) e.currentTarget.style.transform = 'translateY(-3px)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'none'; }}
|
||||
>
|
||||
{/* Превью-плашка с иконкой категории */}
|
||||
<div style={{
|
||||
height: 96, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: `linear-gradient(150deg, ${theme.from}, ${theme.to})`,
|
||||
color: 'rgba(255,255,255,0.92)',
|
||||
}}>
|
||||
<CatGlyph cat={s.category || 'human'} size={50} />
|
||||
</div>
|
||||
{/* Бейдж активного/купленного */}
|
||||
{isActive && (
|
||||
<div style={badgeStyle('#22ff88', '#04361b')}>Надет</div>
|
||||
)}
|
||||
{!isActive && owned && (
|
||||
<div style={badgeStyle('#ffd24a', '#5a3a00')}>Куплено</div>
|
||||
)}
|
||||
{/* Низ карточки: имя + цена/статус */}
|
||||
<div style={{ padding: '10px 12px' }}>
|
||||
<div style={{
|
||||
color: '#fff', fontWeight: 800, fontSize: 14,
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>{s.name || s.slug}</div>
|
||||
<div style={{ marginTop: 6, minHeight: 22 }}>
|
||||
{isActive ? (
|
||||
<span style={{ color: '#22ff88', fontWeight: 800, fontSize: 13 }}>Активен</span>
|
||||
) : owned ? (
|
||||
<span style={{ color: '#9fb0d8', fontWeight: 700, fontSize: 13 }}>Нажми, чтобы надеть</span>
|
||||
) : price === 0 ? (
|
||||
<span style={{ color: '#7fe0a0', fontWeight: 800, fontSize: 13 }}>Бесплатно</span>
|
||||
) : (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
color: canAfford ? '#ffd24a' : '#ff7a7a', fontWeight: 900, fontSize: 14,
|
||||
}}>
|
||||
<CoinIcon size={15} /> {price}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{skins.length === 0 && (
|
||||
<div style={{ color: '#8a93b4', gridColumn: '1 / -1', textAlign: 'center', padding: 30 }}>
|
||||
В этой категории пока нет скинов
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Подвал-подсказка */}
|
||||
<div style={{
|
||||
padding: '10px 20px', borderTop: '1px solid rgba(255,255,255,0.08)',
|
||||
color: '#6b76a0', fontSize: 12, textAlign: 'center',
|
||||
}}>
|
||||
Нажми <b style={{ color: '#aab4d4' }}>B</b> или <b style={{ color: '#aab4d4' }}>Esc</b>, чтобы закрыть
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function badgeStyle(bg, fg) {
|
||||
return {
|
||||
position: 'absolute', top: 8, right: 8,
|
||||
background: bg, color: fg,
|
||||
fontSize: 11, fontWeight: 900, padding: '3px 8px', borderRadius: 999,
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.4)',
|
||||
};
|
||||
}
|
||||
@ -58,6 +58,7 @@ import { BillboardUiManager } from './BillboardUiManager';
|
||||
import { getPrimitiveType } from './PrimitiveTypes';
|
||||
import { FolderManager } from './FolderManager';
|
||||
import { GuiManager } from './GuiManager';
|
||||
import { ModalManager } from './ModalManager';
|
||||
import { InventoryManager } from './InventoryManager';
|
||||
import { WeaponSystem } from './WeaponSystem';
|
||||
import { ZombieManager } from './ZombieManager';
|
||||
@ -1244,6 +1245,9 @@ export class BabylonScene {
|
||||
this.primitiveManager.billboardUiManager = this.billboardUiManager;
|
||||
this.folderManager = new FolderManager(this.blockManager, this.modelManager, this.primitiveManager);
|
||||
this.guiManager = new GuiManager();
|
||||
this.modalManager = new ModalManager();
|
||||
this.modalManager.attachScene(this);
|
||||
this.modalManager.attachGui(this.guiManager);
|
||||
this.inventory = new InventoryManager();
|
||||
this.physics = new PhysicsAABB(this.blockManager);
|
||||
this.physics.setPrimitiveManager(this.primitiveManager);
|
||||
@ -1279,35 +1283,42 @@ export class BabylonScene {
|
||||
// в pointer-lock) → ищем под ним меш типа billboard → переводим точку
|
||||
// пересечения в UV → BillboardUiManager.pickButtonAt → fireClick.
|
||||
// Работает только когда _isPlaying=true (в редакторе клик гизмо или ничего).
|
||||
this.scene.onPointerObservable.add((info) => {
|
||||
if (info.type !== PointerEventTypes.POINTERDOWN) return;
|
||||
if (info.event && info.event.button !== 0) return; // только ЛКМ
|
||||
// Прямой capture-phase mousedown на canvas — раньше PlayerController.
|
||||
// Babylon onPointerObservable не получает события в pointer-lock,
|
||||
// поэтому ловим сами и стреляем лучом по табличкам в Play.
|
||||
const canvasEl = this.canvas;
|
||||
const onBillboardMouseDown = (e) => {
|
||||
if (!this._isPlaying) return;
|
||||
// Для pointer-lock (FPS-камера) — стреляем из центра экрана.
|
||||
// Иначе — используем pickInfo от Babylon (он уже от курсора).
|
||||
let pi = info.pickInfo;
|
||||
if (e.button !== 0) return;
|
||||
const inLock = (document.pointerLockElement != null);
|
||||
let px, py;
|
||||
if (inLock) {
|
||||
const cx = this.engine.getRenderWidth() / 2;
|
||||
const cy = this.engine.getRenderHeight() / 2;
|
||||
pi = this.scene.pick(cx, cy, (m) => {
|
||||
return m.metadata?.isPrimitive
|
||||
&& this.primitiveManager.instances.get(m.metadata.primitiveId)?.type === 'billboard';
|
||||
});
|
||||
px = this.engine.getRenderWidth() / 2;
|
||||
py = this.engine.getRenderHeight() / 2;
|
||||
} else {
|
||||
const rect = canvasEl.getBoundingClientRect();
|
||||
px = e.clientX - rect.left;
|
||||
py = e.clientY - rect.top;
|
||||
}
|
||||
const pi = this.scene.pick(px, py, (m) => {
|
||||
return m.metadata?.isPrimitive
|
||||
&& this.primitiveManager.instances.get(m.metadata.primitiveId)?.type === 'billboard';
|
||||
});
|
||||
if (!pi || !pi.hit || !pi.pickedMesh) return;
|
||||
const meta = pi.pickedMesh.metadata;
|
||||
if (!meta || !meta.isPrimitive) return;
|
||||
const data = this.primitiveManager.instances.get(meta.primitiveId);
|
||||
if (!data || data.type !== 'billboard') return;
|
||||
// UV точка пересечения с мешем (Babylon знает, если есть UV-координаты).
|
||||
const uv = pi.getTextureCoordinates ? pi.getTextureCoordinates() : null;
|
||||
if (!uv) return;
|
||||
const buttonId = this.billboardUiManager.pickButtonAt(data, uv.x, uv.y);
|
||||
if (buttonId) {
|
||||
this.billboardUiManager.fireClick(data, buttonId);
|
||||
// Предотвращаем PlayerController-обработчик (pointer-lock и т.д.)
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
};
|
||||
canvasEl.addEventListener('mousedown', onBillboardMouseDown, true /* capture */);
|
||||
|
||||
// GizmoController — управляет 3 типами гизмо (move/rotate/scale).
|
||||
// UtilityLayer — отдельный слой, чтобы гизмо рисовалось поверх сцены.
|
||||
@ -1481,6 +1492,10 @@ export class BabylonScene {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Задача 04: modalManager.tick — независимо от runtime'а
|
||||
if (this._isPlaying && this.modalManager?.tick) {
|
||||
try { this.modalManager.tick(dt); } catch (e) {}
|
||||
}
|
||||
// Tick пользовательских скриптов: в Play-режиме или в solo-debug
|
||||
if (this.gameRuntime && (this._isPlaying || this.gameRuntime.isSolo?.())) {
|
||||
this.gameRuntime.tick(dt);
|
||||
@ -5240,6 +5255,8 @@ export class BabylonScene {
|
||||
// По умолчанию стандартный HUD видим в Play.
|
||||
// Скрипт может скрыть через game.hud.setVisible(false).
|
||||
this._setStdHudVisible(true);
|
||||
this._setHotbarVisible(true);
|
||||
this._setHpVisible(true);
|
||||
|
||||
// Включаем picking voxel-террейна — иначе камера _clampCameraToWorld
|
||||
// не «видит» воксели в Ray-каст и пролетает сквозь стены.
|
||||
@ -5273,6 +5290,11 @@ export class BabylonScene {
|
||||
// Создаём PlayerController и стартуем
|
||||
this.player = new PlayerController(this.scene, this.canvas, this.physics, this);
|
||||
this.player.setModelType(this._playerModelType);
|
||||
// Задача 04: модал нуждается в player и audio для блока ввода/freeze камеры/duck
|
||||
try {
|
||||
this.modalManager?.attachPlayer?.(this.player);
|
||||
this.modalManager?.attachAudio?.(this.audioManager);
|
||||
} catch (e) {}
|
||||
this.player._jumpPowerMul = this._jumpPowerMul ?? 1;
|
||||
// Применяем дефолтную камеру если задана в сцене
|
||||
if (this._defaultCameraMode && this._defaultCameraMode !== this.player._cameraMode) {
|
||||
@ -5281,6 +5303,18 @@ export class BabylonScene {
|
||||
// На тач-устройствах отключаем pointer-lock и mouse-камеру
|
||||
if (this._touchMode) this.player.setTouchMode(true);
|
||||
this.player.setOnExitRequest(() => {
|
||||
// Задача 07: магазин скинов открыт → Esc закрывает его (приоритет выше модала).
|
||||
if (this._skinShop?.open) {
|
||||
this._closeSkinShop();
|
||||
return;
|
||||
}
|
||||
// Задача 04: если открыт модал — первый Esc закрывает его,
|
||||
// второй Esc уже выходит из Play. Так юзер не теряет состояние игры
|
||||
// случайно при попытке скрыть модал.
|
||||
if (this.modalManager?.isOpen?.()) {
|
||||
this.modalManager.close();
|
||||
return;
|
||||
}
|
||||
this.exitPlayMode();
|
||||
if (this._onPlayChange) this._onPlayChange(false);
|
||||
});
|
||||
@ -5292,6 +5326,7 @@ export class BabylonScene {
|
||||
// Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену,
|
||||
// поэтому скрипты стартуем в следующем кадре.
|
||||
this.gameRuntime = new GameRuntime(this);
|
||||
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
|
||||
// Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime.
|
||||
// ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным
|
||||
// this.audioManager (AudioManager — ambient/music для всех проектов).
|
||||
@ -5785,6 +5820,7 @@ export class BabylonScene {
|
||||
if (!sc) return false;
|
||||
if (!this.gameRuntime) {
|
||||
this.gameRuntime = new GameRuntime(this);
|
||||
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
|
||||
if (!this.gameAudioManager) {
|
||||
this.gameAudioManager = new GameAudioManager();
|
||||
}
|
||||
@ -5906,6 +5942,24 @@ export class BabylonScene {
|
||||
this._stdHudVisible = !!visible;
|
||||
try { this._onStdHudVisibilityChange?.(this._stdHudVisible); } catch (e) {}
|
||||
}
|
||||
/** Задача 03: отдельный контроль хотбара (5 слотов инвентаря снизу).
|
||||
* Дёргается из game.hud.setHotbarVisible(bool). */
|
||||
setOnHotbarVisibilityChange(cb) {
|
||||
this._onHotbarVisibilityChange = cb;
|
||||
}
|
||||
_setHotbarVisible(visible) {
|
||||
this._hotbarVisible = !!visible;
|
||||
try { this._onHotbarVisibilityChange?.(this._hotbarVisible); } catch (e) {}
|
||||
}
|
||||
/** Задача 03: отдельный контроль HP-индикатора (полоска слева сверху).
|
||||
* Дёргается из game.hud.setHpVisible(bool). */
|
||||
setOnHpVisibilityChange(cb) {
|
||||
this._onHpVisibilityChange = cb;
|
||||
}
|
||||
_setHpVisible(visible) {
|
||||
this._hpVisible = !!visible;
|
||||
try { this._onHpVisibilityChange?.(this._hpVisible); } catch (e) {}
|
||||
}
|
||||
|
||||
/** Колбэк смены cursor-режима (ui/game) скриптом через game.input.setCursorMode.
|
||||
* Редактор подписан чтобы синхронизировать React-state uiCursorMode (для бейджа). */
|
||||
@ -6054,6 +6108,71 @@ export class BabylonScene {
|
||||
return this.guiManager ? this.guiManager.getAll() : [];
|
||||
}
|
||||
|
||||
// ===== Задача 07: встроенный магазин скинов (React-оверлей) =====
|
||||
// Состояние держим тут; React-компонент SkinShopOverlay поллит getSkinShopState().
|
||||
_ensureSkinShopState() {
|
||||
if (!this._skinShop) {
|
||||
this._skinShop = {
|
||||
open: false,
|
||||
rev: 0, // ревизия — React видит изменение
|
||||
data: { all: [], unlocked: [], current: null, coins: 0, manifestFull: [] },
|
||||
buyResult: null, // последний результат покупки {slug, ok, reason}
|
||||
};
|
||||
}
|
||||
return this._skinShop;
|
||||
}
|
||||
/** Снимок состояния магазина для React (поллинг через rAF). */
|
||||
getSkinShopState() { return this._skinShop || null; }
|
||||
/** Открыть/закрыть магазин (из скрипта или клавиши B). */
|
||||
_openSkinShop() {
|
||||
const s = this._ensureSkinShopState();
|
||||
// Отключён в проекте? (скрипт всё равно может открыть через API —
|
||||
// shopVisible:false запрещает только клавишу B, см. toggleSkinShop).
|
||||
s.open = true; s.rev++;
|
||||
}
|
||||
_closeSkinShop() {
|
||||
const s = this._ensureSkinShopState();
|
||||
s.open = false; s.rev++;
|
||||
}
|
||||
toggleSkinShop() {
|
||||
const s = this._ensureSkinShopState();
|
||||
if (s.open) { this._closeSkinShop(); return; }
|
||||
// Клавиша B открывает магазин только если он включён в проекте.
|
||||
if (this._skinsConfig && this._skinsConfig.shopVisible === false) return;
|
||||
this._openSkinShop();
|
||||
}
|
||||
/** Данные скинов от GameRuntime (манифест + unlocked + coins). */
|
||||
_setSkinShopData(data) {
|
||||
const s = this._ensureSkinShopState();
|
||||
s.data = { ...s.data, ...data };
|
||||
s.rev++;
|
||||
}
|
||||
_onSkinBuyResult(res) {
|
||||
const s = this._ensureSkinShopState();
|
||||
s.buyResult = { ...res, t: (this.scene?.getEngine?.()?.getFps?.() || 0) };
|
||||
s.rev++;
|
||||
}
|
||||
/** Намерение купить/надеть скин — шлём в runtime (он списывает рублики). */
|
||||
requestBuySkin(slug, price) {
|
||||
const rt = this.gameRuntime;
|
||||
if (!rt) return;
|
||||
try { rt._handleCommand?.(null, 'player.buySkin', { slug, price: price || 0 }); } catch (e) {}
|
||||
}
|
||||
/** Данные из загруженных проектом кастомных .glb-скинов (data-URL по slug). */
|
||||
getAssetDataUrl(slug) {
|
||||
try {
|
||||
// Кастомные скины хранят dataUrl прямо в _skinsConfig.customGlbs.
|
||||
const list = this._skinsConfig?.customGlbs || [];
|
||||
const rec = list.find(g => g && g.slug === slug);
|
||||
if (rec && rec.dataUrl) return rec.dataUrl;
|
||||
} catch (e) {}
|
||||
return null;
|
||||
}
|
||||
_onPlayerSkinChanged(slug) {
|
||||
const s = this._ensureSkinShopState();
|
||||
if (s.data) { s.data.current = slug; s.rev++; }
|
||||
}
|
||||
|
||||
// ===== Библиотека пользовательских картинок (этап 3.6) =====
|
||||
|
||||
/** Список картинок проекта [{id, name, dataUrl}]. */
|
||||
@ -6724,6 +6843,13 @@ export class BabylonScene {
|
||||
inventory: this.inventory ? this.inventory.serialize() : null,
|
||||
spawnPoint: { ...this._spawnPoint },
|
||||
playerModelType: this._playerModelType,
|
||||
skins: this._skinsConfig ? {
|
||||
default: this._skinsConfig.default || null,
|
||||
unlocked: this._skinsConfig.unlocked || [],
|
||||
shopVisible: this._skinsConfig.shopVisible !== false,
|
||||
coins: this._skinsConfig.coins || 0,
|
||||
customGlbs: this._skinsConfig.customGlbs || [],
|
||||
} : undefined,
|
||||
worldSize: this._worldHalf * 2,
|
||||
floorEnabled: this._floorEnabled !== false,
|
||||
jumpPowerMul: this._jumpPowerMul ?? 1,
|
||||
@ -7161,6 +7287,24 @@ export class BabylonScene {
|
||||
this._playerModelType = pmt;
|
||||
}
|
||||
}
|
||||
// Задача 07: конфиг скинов проекта { default, unlocked, shopVisible, coins, customGlbs }.
|
||||
if (state.scene.skins && typeof state.scene.skins === 'object') {
|
||||
this._skinsConfig = {
|
||||
default: state.scene.skins.default || null,
|
||||
unlocked: Array.isArray(state.scene.skins.unlocked) ? state.scene.skins.unlocked.slice() : [],
|
||||
shopVisible: state.scene.skins.shopVisible !== false,
|
||||
coins: Number.isFinite(state.scene.skins.coins) ? state.scene.skins.coins : 0,
|
||||
customGlbs: Array.isArray(state.scene.skins.customGlbs) ? state.scene.skins.customGlbs.slice() : [],
|
||||
};
|
||||
// Стартовый скин из skins.default имеет приоритет над playerModelType.
|
||||
if (this._skinsConfig.default) {
|
||||
const d = this._skinsConfig.default;
|
||||
this._playerModelType = d.startsWith('character-') || d.startsWith('skin_') || d.startsWith('customskin:')
|
||||
? d : ('skin_' + d);
|
||||
}
|
||||
} else {
|
||||
this._skinsConfig = null;
|
||||
}
|
||||
// Пользовательские скрипты
|
||||
if (Array.isArray(state.scene.scripts)) {
|
||||
this._scripts = state.scene.scripts
|
||||
@ -7197,6 +7341,8 @@ export class BabylonScene {
|
||||
exitPlayMode() {
|
||||
if (!this._isPlaying) return;
|
||||
this._isPlaying = false;
|
||||
// Задача 04: закрываем любой активный модал чтобы не «висел» при стопе
|
||||
try { this.modalManager?._instantClose?.(); } catch (e) {}
|
||||
// Сбрасываем таймер прохождения
|
||||
this._timerRunning = false;
|
||||
this._timerStartedAt = null;
|
||||
|
||||
@ -114,21 +114,15 @@ export class BillboardUiManager {
|
||||
mesh.metadata._billboardMaterial = mat;
|
||||
}
|
||||
|
||||
// Ориентация на камеру. Babylon-quirk: BILLBOARDMODE_ALL игнорирует
|
||||
// mesh.scaling.x=-1 и mesh.rotation.y=π — невозможно отзеркалить
|
||||
// плоскость. Делаем ручной поворот в onBeforeRenderObservable:
|
||||
// нацеливаем mesh на камеру + ставим rotation.y += π, тогда мы
|
||||
// видим back-side нормально (т.к. фактически она стала front).
|
||||
// Ориентация. Babylon-quirk: CreatePlane FRONT в -Z (left-handed),
|
||||
// юзер ставит билборд так чтобы лицо смотрело в +Z мира → +π.
|
||||
mesh.billboardMode = Mesh.BILLBOARDMODE_NONE;
|
||||
// Снимаем старую подписку (на случай пере-applyToMesh)
|
||||
if (mesh.metadata._billboardLookObs) {
|
||||
this.scene.onBeforeRenderObservable.remove(mesh.metadata._billboardLookObs);
|
||||
mesh.metadata._billboardLookObs = null;
|
||||
}
|
||||
if (face === 'camera') {
|
||||
// Ручной look-at вместо BILLBOARDMODE.
|
||||
// CreatePlane FRONT в -Z (Babylon left-handed), поэтому +π —
|
||||
// чтобы FRONT смотрел на камеру.
|
||||
// Ручной look-at — каждый кадр поворачиваем front к камере.
|
||||
const obs = this.scene.onBeforeRenderObservable.add(() => {
|
||||
if (mesh.isDisposed()) return;
|
||||
const cam = this.scene.activeCamera;
|
||||
@ -138,9 +132,47 @@ export class BillboardUiManager {
|
||||
mesh.rotation.y = Math.atan2(dx, dz) + Math.PI;
|
||||
});
|
||||
mesh.metadata._billboardLookObs = obs;
|
||||
} else {
|
||||
// Фиксированная ориентация: front в +Z + пользовательский rotationY.
|
||||
const userY = Number.isFinite(billboardOpts.rotationY) ? billboardOpts.rotationY : 0;
|
||||
mesh.rotation.y = Math.PI + userY;
|
||||
// Двусторонняя табличка: рамка стоит, но при взгляде сзади
|
||||
// флипаем UV таблички чтобы текст не был зеркальным.
|
||||
const mat = mesh.material;
|
||||
if (mat) {
|
||||
// Включаем рендер обеих сторон (back-face визуализируется).
|
||||
mat.backFaceCulling = false;
|
||||
}
|
||||
const obs = this.scene.onBeforeRenderObservable.add(() => {
|
||||
if (mesh.isDisposed()) return;
|
||||
const cam = this.scene.activeCamera;
|
||||
if (!cam) return;
|
||||
// Локальная нормаль FRONT plane = +Z. Поворот mesh.rotation.y
|
||||
// переводит её в world: normalWorld = (sin(ry), 0, cos(ry)).
|
||||
const ry = mesh.rotation.y;
|
||||
const nWx = Math.sin(ry);
|
||||
const nWz = Math.cos(ry);
|
||||
// Вектор от mesh к камере
|
||||
const vx = cam.position.x - mesh.position.x;
|
||||
const vz = cam.position.z - mesh.position.z;
|
||||
// Скалярное произведение: >0 — камера смотрит на FRONT,
|
||||
// <0 — на BACK (зеркальная UV). Для BACK инвертируем uScale.
|
||||
const dot = nWx * vx + nWz * vz;
|
||||
const dyn = mesh.metadata?._billboardTexture;
|
||||
if (dyn) {
|
||||
// dot > 0 — камера со стороны FRONT-нормали → flip
|
||||
// dot < 0 — камера сзади → нормально
|
||||
if (dot > 0) {
|
||||
if (dyn.uScale !== -1) { dyn.uScale = -1; dyn.uOffset = 1; }
|
||||
} else {
|
||||
if (dyn.uScale !== 1) { dyn.uScale = 1; dyn.uOffset = 0; }
|
||||
}
|
||||
}
|
||||
});
|
||||
mesh.metadata._billboardLookObs = obs;
|
||||
}
|
||||
mesh.scaling.x = Math.abs(mesh.scaling.x || 1);
|
||||
mesh.metadata._billboardMirrorX = false; // canvas-mirror не нужен
|
||||
mesh.metadata._billboardMirrorX = false;
|
||||
|
||||
// Сохраняем state в data для сериализации и для hit-теста кликов.
|
||||
data.billboard = {
|
||||
@ -157,11 +189,43 @@ export class BillboardUiManager {
|
||||
|
||||
/**
|
||||
* Обновить контент билборда (без пересоздания текстуры).
|
||||
* patch — частичные изменения к content (например {sub: '2 > 3', price: '$20,000'}).
|
||||
* Две формы:
|
||||
* 1) update(data, { sub: '2 > 3', price: '$20,000' }) — patch content
|
||||
* 2) update(data, 'buy', { text: '$15,000' }) — patch конкретного элемента
|
||||
* по id (для elements-режима ИЛИ для known-id пресета: 'buy', 'title',
|
||||
* 'sub', 'price', 'icon', 'gradient' маппятся на поля content).
|
||||
*/
|
||||
update(data, patch) {
|
||||
update(data, elementIdOrPatch, patchMaybe) {
|
||||
if (!data.billboard) return;
|
||||
data.billboard.content = { ...data.billboard.content, ...patch };
|
||||
// Форма 2: 3 аргумента (data, elementId, patch)
|
||||
if (typeof elementIdOrPatch === 'string' && typeof patchMaybe === 'object' && patchMaybe !== null) {
|
||||
const elId = elementIdOrPatch;
|
||||
const patch = patchMaybe;
|
||||
// Кастомные elements: ищем элемент по id и обновляем его поля.
|
||||
if (Array.isArray(data.billboard.elements)) {
|
||||
data.billboard.elements = data.billboard.elements.map(el =>
|
||||
el && el.id === elId ? { ...el, ...patch } : el);
|
||||
} else {
|
||||
// Пресет: мапим известные elementId → ключ content.
|
||||
// 'buy' → content.price; 'title'/'sub'/'icon'/'gradient' → одноимённый ключ.
|
||||
const c = { ...(data.billboard.content || {}) };
|
||||
if (elId === 'buy' && 'text' in patch) {
|
||||
c.price = patch.text;
|
||||
} else if (elId in c) {
|
||||
// Если patch имеет text — кладём в content[elId], иначе мерджим поля.
|
||||
if ('text' in patch) c[elId] = patch.text;
|
||||
else Object.assign(c, patch);
|
||||
} else {
|
||||
Object.assign(c, patch);
|
||||
}
|
||||
data.billboard.content = c;
|
||||
}
|
||||
} else if (typeof elementIdOrPatch === 'object' && elementIdOrPatch !== null) {
|
||||
// Форма 1: 2 аргумента (data, patchContent)
|
||||
data.billboard.content = { ...data.billboard.content, ...elementIdOrPatch };
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
const dyn = data.mesh?.metadata?._billboardTexture;
|
||||
if (dyn) {
|
||||
this._render(dyn, data.billboard.template, data.billboard.content, data.billboard.elements);
|
||||
@ -185,16 +249,28 @@ export class BillboardUiManager {
|
||||
*/
|
||||
pickButtonAt(data, uvX, uvY) {
|
||||
if (!data.billboard) return null;
|
||||
// Текстура рисуется напрямую — UV из raycast соответствует canvas-пикселю.
|
||||
const px = uvX * TEXTURE_W;
|
||||
const py = (1 - uvY) * TEXTURE_H; // UV.y перевёрнута относительно canvas
|
||||
// Если текстура в данный момент отзеркалена (face=fixed, смотрим
|
||||
// на back-side), uScale=-1 — инвертим UV.x чтобы попасть в правильный
|
||||
// canvas-пиксель.
|
||||
const dyn = data.mesh?.metadata?._billboardTexture;
|
||||
const flipped = dyn && dyn.uScale === -1;
|
||||
const uX = flipped ? (1 - uvX) : uvX;
|
||||
const px = uX * TEXTURE_W;
|
||||
const py = (1 - uvY) * TEXTURE_H;
|
||||
// Кастомные elements имеют приоритет (если заданы)
|
||||
if (data.billboard.elements) {
|
||||
return this._hitTestElements(data.billboard.elements, px, py);
|
||||
}
|
||||
const tmpl = data.billboard.template;
|
||||
if (tmpl === 'shop-item' || tmpl === 'shop-purchase') {
|
||||
const b = SHOP_ITEM_BUTTON;
|
||||
// Кнопка адаптивной ширины — пересчитываем её rect по тексту
|
||||
// именно ЭТОЙ таблички (тем же _computeBuyRect, что и при рисовании).
|
||||
const label = (data.billboard.content && data.billboard.content.price) || '$0';
|
||||
let b = SHOP_ITEM_BUTTON;
|
||||
try {
|
||||
const measCtx = (dyn && dyn.getContext && dyn.getContext()) || null;
|
||||
if (measCtx) b = this._computeBuyRect(measCtx, label, SHOP_ITEM_BUTTON);
|
||||
} catch (e) { /* fallback на базовый rect */ }
|
||||
if (px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h) {
|
||||
return 'buy';
|
||||
}
|
||||
@ -216,13 +292,24 @@ export class BillboardUiManager {
|
||||
|
||||
_flashButton(data, buttonId) {
|
||||
if (!data.billboard) return;
|
||||
// Перерисовываем с pressed=true, через 100мс — обратно.
|
||||
const dyn = data.mesh?.metadata?._billboardTexture;
|
||||
if (!dyn) return;
|
||||
// Перерисовываем pressed=true. ВАЖНО: используем СВЕЖИЙ content в callback'е
|
||||
// (на момент 120мс content уже может быть обновлён через update — берём
|
||||
// актуальный, иначе откатим к старому).
|
||||
// Также гарантируем 1 flash на табличку — если предыдущий ещё крутится,
|
||||
// отменяем его таймер.
|
||||
if (data._flashTimer) {
|
||||
clearTimeout(data._flashTimer);
|
||||
data._flashTimer = null;
|
||||
}
|
||||
this._render(dyn, data.billboard.template, data.billboard.content,
|
||||
data.billboard.elements, /* pressed */ buttonId);
|
||||
setTimeout(() => {
|
||||
if (data.mesh?.metadata?._billboardTexture === dyn) {
|
||||
data._flashTimer = setTimeout(() => {
|
||||
data._flashTimer = null;
|
||||
// Берём АКТУАЛЬНЫЕ data.billboard.content/elements — могли обновиться
|
||||
// через game.billboard.update() ВО ВРЕМЯ flash'а.
|
||||
if (data.mesh?.metadata?._billboardTexture === dyn && data.billboard) {
|
||||
this._render(dyn, data.billboard.template, data.billboard.content,
|
||||
data.billboard.elements, null);
|
||||
}
|
||||
@ -344,21 +431,52 @@ export class BillboardUiManager {
|
||||
ctx.fillText(content.sub, 200, 105);
|
||||
}
|
||||
|
||||
// Кнопка цены — жёлтый прямоугольник внизу справа
|
||||
const b = SHOP_ITEM_BUTTON;
|
||||
// Кнопка цены — жёлтый прямоугольник внизу справа.
|
||||
// Ширина адаптивная: длинный текст («ПРИМЕРИТЬ»/«НАДЕТО») расширяет
|
||||
// кнопку ВЛЕВО (правый край прижат к краю таблички), шрифт ужимается
|
||||
// если даже расширенная кнопка узка. Фактический rect → _buyRect для hit-теста.
|
||||
const pressed = pressedButtonId === 'buy';
|
||||
this._roundedGradientRect(ctx, b.x, b.y, b.w, b.h, {
|
||||
const label = content.price || '$0';
|
||||
const rect = this._computeBuyRect(ctx, label, SHOP_ITEM_BUTTON);
|
||||
this._roundedGradientRect(ctx, rect.x, rect.y, rect.w, rect.h, {
|
||||
gradient: pressed
|
||||
? ['#d97706', '#92400e']
|
||||
: ['#fbbf24', '#f59e0b'],
|
||||
radius: 16,
|
||||
stroke: { color: '#000', width: 3 },
|
||||
});
|
||||
ctx.font = 'bold 36px Arial, sans-serif';
|
||||
ctx.font = `bold ${rect.fontSize}px Arial, sans-serif`;
|
||||
ctx.fillStyle = pressed ? '#fef3c7' : '#1c1917';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(content.price || '$0', b.x + b.w / 2, b.y + b.h / 2);
|
||||
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Подобрать прямоугольник кнопки «buy» под текст: правый край прижат к
|
||||
* правому краю таблички (как в базовом SHOP_ITEM_BUTTON), ширина растёт
|
||||
* влево под длину текста, шрифт ужимается если упёрлись в макс-ширину.
|
||||
* Возвращает { x, y, w, h, fontSize }.
|
||||
*/
|
||||
_computeBuyRect(ctx, label, base) {
|
||||
const PAD = 36; // отступы текста по бокам
|
||||
const MAX_W = 300; // макс ширина кнопки (не залезать на title)
|
||||
const rightEdge = base.x + base.w; // правый край держим на месте
|
||||
let fontSize = 36;
|
||||
ctx.font = `bold ${fontSize}px Arial, sans-serif`;
|
||||
let textW = ctx.measureText(label).width;
|
||||
let w = Math.max(base.w, textW + PAD * 2);
|
||||
if (w > MAX_W) {
|
||||
// Ужимаем шрифт чтобы текст влез в MAX_W.
|
||||
w = MAX_W;
|
||||
const inner = MAX_W - PAD * 2;
|
||||
while (fontSize > 20 && textW > inner) {
|
||||
fontSize -= 2;
|
||||
ctx.font = `bold ${fontSize}px Arial, sans-serif`;
|
||||
textW = ctx.measureText(label).width;
|
||||
}
|
||||
}
|
||||
return { x: rightEdge - w, y: base.y, w, h: base.h, fontSize };
|
||||
}
|
||||
|
||||
/** Рендер пресета shop-purchase: похож на shop-item, но иконка крупнее и центрирована. */
|
||||
@ -388,21 +506,22 @@ export class BillboardUiManager {
|
||||
ctx.fillText(content.sub, 200, 100);
|
||||
}
|
||||
|
||||
// Кнопка-цена
|
||||
const b = SHOP_ITEM_BUTTON;
|
||||
// Кнопка-цена (адаптивная ширина под длинный текст, см. _computeBuyRect).
|
||||
const pressed = pressedButtonId === 'buy';
|
||||
this._roundedGradientRect(ctx, b.x, b.y, b.w, b.h, {
|
||||
const label = content.price || '0 R';
|
||||
const rect = this._computeBuyRect(ctx, label, SHOP_ITEM_BUTTON);
|
||||
this._roundedGradientRect(ctx, rect.x, rect.y, rect.w, rect.h, {
|
||||
gradient: pressed
|
||||
? ['#9333ea', '#6b21a8']
|
||||
: ['#a855f7', '#7c3aed'],
|
||||
radius: 16,
|
||||
stroke: { color: '#000', width: 3 },
|
||||
});
|
||||
ctx.font = 'bold 34px Arial, sans-serif';
|
||||
ctx.font = `bold ${rect.fontSize}px Arial, sans-serif`;
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(content.price || '0 R', b.x + b.w / 2, b.y + b.h / 2);
|
||||
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
|
||||
}
|
||||
|
||||
/** Рендер пресета banner: одна крупная фраза по центру. */
|
||||
@ -441,11 +560,32 @@ export class BillboardUiManager {
|
||||
stroke: { color: '#fff', width: 4 },
|
||||
});
|
||||
|
||||
ctx.font = 'bold 64px Arial, sans-serif';
|
||||
// Заголовок крупно сверху
|
||||
ctx.font = 'bold 44px Arial, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillText(this._truncate(content.title || '', 14), TEXTURE_W / 2, TEXTURE_H / 2);
|
||||
ctx.fillStyle = '#ffd166';
|
||||
const title = content.title || '';
|
||||
const subText = content.sub || '';
|
||||
if (subText) {
|
||||
// Заголовок сверху, sub-строки списком ниже
|
||||
ctx.fillText(this._truncate(title, 18), TEXTURE_W / 2, 50);
|
||||
// Sub — многострочный, выравнивание по левому краю
|
||||
ctx.font = '20px Arial, sans-serif';
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
const lines = String(subText).split('\n');
|
||||
const startY = 95;
|
||||
const lineH = 30;
|
||||
const leftX = 38;
|
||||
for (let i = 0; i < lines.length && i < 8; i++) {
|
||||
ctx.fillText(this._truncate(lines[i], 36), leftX, startY + i * lineH);
|
||||
}
|
||||
} else {
|
||||
ctx.font = 'bold 64px Arial, sans-serif';
|
||||
ctx.fillText(this._truncate(title, 14), TEXTURE_W / 2, TEXTURE_H / 2);
|
||||
}
|
||||
}
|
||||
|
||||
/** Рендер кастомного списка элементов: фон + список text/image/button.
|
||||
|
||||
@ -163,6 +163,9 @@ export class GameRuntime {
|
||||
this._broadcastSceneSnapshot();
|
||||
this._broadcastGuiSnapshot();
|
||||
this._broadcastTerrainHeightmap();
|
||||
this._broadcastSkinsSnapshot(); // задача 07
|
||||
// Задача 03: запустить animationPreset для GUI-элементов с пресетом ≠ 'none'.
|
||||
this._startGuiAnimationPresets();
|
||||
};
|
||||
if (typeof requestAnimationFrame !== 'undefined') {
|
||||
requestAnimationFrame(sendInitial);
|
||||
@ -171,6 +174,60 @@ export class GameRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/** Задача 03: запустить пресеты анимаций для GUI-элементов в Play. */
|
||||
_startGuiAnimationPresets() {
|
||||
const gm = this.scene3d?.guiManager;
|
||||
if (!gm) return;
|
||||
if (!this._guiTweens) this._guiTweens = [];
|
||||
for (const el of (gm.elements || [])) {
|
||||
const preset = el.animationPreset;
|
||||
if (!preset || preset === 'none') continue;
|
||||
const id = el.id;
|
||||
// Каждый пресет = одна tween-запись с reverses+repeat=-1
|
||||
switch (preset) {
|
||||
case 'pulse':
|
||||
this._guiTweens.push(this._mkGuiPreset(id, el,
|
||||
{ scaleX: 1.1, scaleY: 1.1 }, 0.7, 'ease', true, -1));
|
||||
break;
|
||||
case 'rotate':
|
||||
this._guiTweens.push(this._mkGuiPreset(id, el,
|
||||
{ rotation: (el.rotation || 0) + 360 }, 3, 'linear', false, -1));
|
||||
break;
|
||||
case 'sway':
|
||||
this._guiTweens.push(this._mkGuiPreset(id, el,
|
||||
{ rotation: (el.rotation || 0) + 8 }, 0.6, 'ease', true, -1));
|
||||
this._guiTweens[this._guiTweens.length - 1].start.rotation = (el.rotation || 0) - 8;
|
||||
break;
|
||||
case 'glow':
|
||||
this._guiTweens.push(this._mkGuiPreset(id, el,
|
||||
{ bgOpacity: 0.6 }, 0.8, 'ease', true, -1));
|
||||
break;
|
||||
case 'bounce':
|
||||
this._guiTweens.push(this._mkGuiPreset(id, el,
|
||||
{ y: (el.y || 50) - 1 }, 0.5, 'ease', true, -1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_mkGuiPreset(id, el, targetProps, duration, easing, reverses, repeat) {
|
||||
const start = {};
|
||||
for (const k of Object.keys(targetProps)) {
|
||||
if (k === 'scaleX' || k === 'scaleY') start[k] = el[k] || 1;
|
||||
else if (k === 'rotation') start[k] = el.rotation || 0;
|
||||
else if (k === 'bgOpacity') start[k] = el.bgOpacity == null ? 1 : el.bgOpacity;
|
||||
else start[k] = el[k] || 0;
|
||||
}
|
||||
return {
|
||||
tweenId: ++this._tweenSeq || (this._tweenSeq = 1),
|
||||
scriptId: '__preset__',
|
||||
realId: id,
|
||||
start, target: targetProps,
|
||||
elapsed: 0, delay: 0,
|
||||
duration, easing,
|
||||
repeat, reverses, iter: 0, dir: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Разослать карту высот гладкого ландшафта всем sandbox'ам.
|
||||
* Нужно для game.scene.surfaceY(x,z). Снимается raycast'ом по
|
||||
@ -197,6 +254,43 @@ export class GameRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Задача 07: разослать снапшот скинов всем sandbox'ам — чтобы
|
||||
* game.player.getAvailableSkins/getAllSkins работали синхронно.
|
||||
* Манифест грузится через fetch (кешируется браузером), затем
|
||||
* объединяется с разблокированными скинами из scene.skins.
|
||||
*/
|
||||
async _broadcastSkinsSnapshot() {
|
||||
try {
|
||||
this._ensureSkinState();
|
||||
let manifest = this._skinManifestCache;
|
||||
if (!manifest) {
|
||||
const resp = await fetch('/kubikon-assets/characters/skins_manifest.json');
|
||||
const json = await resp.json();
|
||||
manifest = (json.skins || []).map(s => ({
|
||||
slug: s.slug || (s.id || '').replace(/^skin_/, ''),
|
||||
name: s.name || s.slug,
|
||||
kind: s.kind || 'r15',
|
||||
category: s.category || 'human',
|
||||
price: Number.isFinite(s.price) ? s.price : 0,
|
||||
}));
|
||||
// Встроенные «человеки» character-a..g тоже добавим как базовый выбор.
|
||||
this._skinManifestCache = manifest;
|
||||
}
|
||||
const payload = {
|
||||
all: manifest,
|
||||
unlocked: Array.from(this._skinState.unlocked),
|
||||
current: this._skinState.current,
|
||||
coins: this._skinState.coins,
|
||||
};
|
||||
for (const sb of this.sandboxes) sb.sendSkinsSnapshot(payload);
|
||||
// Также отдать снапшот в scene для React-магазина.
|
||||
try { this.scene3d?._setSkinShopData?.({ ...payload, manifestFull: this._skinManifestCache }); } catch (e) {}
|
||||
} catch (e) {
|
||||
// манифест недоступен — не критично, скрипт получит пустой список
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить позицию объекта по его target (для зеркалирования в worker).
|
||||
*/
|
||||
@ -370,6 +464,10 @@ export class GameRuntime {
|
||||
}
|
||||
// Анимации game.tween
|
||||
if (this._tweens.length > 0) this._updateTweens(dt);
|
||||
// Задача 03: GUI tweens
|
||||
if (this._guiTweens && this._guiTweens.length > 0) this._updateGuiTweens(dt);
|
||||
// Задача 04: модал-сцены — tick вынесен в BabylonScene.onBeforeRender
|
||||
// (не зависит от наличия скриптов).
|
||||
|
||||
// ProximityPrompt — подсказка [E] над ближайшим интерактивным объектом
|
||||
if (this._interactables.length > 0) this._updateInteractables();
|
||||
@ -566,6 +664,67 @@ export class GameRuntime {
|
||||
}
|
||||
|
||||
/** Прокрутка всех активных твинов на dt секунд. */
|
||||
/** Задача 03: обновление GUI-tweens. Простая реализация без _applyTweenFrame
|
||||
* (там 3D-логика с rotationY/sx/cy/color через babylon-объекты). */
|
||||
_updateGuiTweens(dt) {
|
||||
const gm = this.scene3d?.guiManager;
|
||||
if (!gm) return;
|
||||
for (let i = this._guiTweens.length - 1; i >= 0; i--) {
|
||||
const tw = this._guiTweens[i];
|
||||
if (tw.delay > 0) { tw.delay -= dt; if (tw.delay > 0) continue; }
|
||||
tw.elapsed += dt;
|
||||
let t = tw.elapsed / tw.duration;
|
||||
let done = false;
|
||||
if (t >= 1) { t = 1; done = true; }
|
||||
const raw = tw.dir === -1 ? 1 - t : t;
|
||||
const k = GameRuntime._ease(tw.easing, raw);
|
||||
// Применяем
|
||||
const el = gm.elements.find(e => e.id === tw.realId);
|
||||
if (!el) { this._guiTweens.splice(i, 1); continue; }
|
||||
const patch = {};
|
||||
for (const key of Object.keys(tw.target)) {
|
||||
const from = tw.start[key];
|
||||
const to = tw.target[key];
|
||||
if (typeof from === 'number' && typeof to === 'number') {
|
||||
patch[key] = from + (to - from) * k;
|
||||
} else if (typeof from === 'string' && typeof to === 'string'
|
||||
&& from.startsWith('#') && to.startsWith('#')) {
|
||||
patch[key] = GameRuntime._lerpColor(from, to, k);
|
||||
} else {
|
||||
// Прочее — на конце ставим целевое
|
||||
if (done) patch[key] = to;
|
||||
}
|
||||
}
|
||||
// Throttle: обновляем не чаще чем раз в 32мс (~30 FPS).
|
||||
tw._lastApply = tw._lastApply || 0;
|
||||
tw._lastApply += dt;
|
||||
if (tw._lastApply >= 0.032 || done) {
|
||||
tw._lastApply = 0;
|
||||
try { gm.update(tw.realId, patch); } catch (e) {}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
if (tw.reverses && tw.dir === 1) {
|
||||
tw.dir = -1;
|
||||
tw.elapsed = 0;
|
||||
continue;
|
||||
}
|
||||
tw.iter++;
|
||||
if (tw.repeat === -1 || tw.iter < tw.repeat) {
|
||||
// повтор
|
||||
tw.elapsed = 0;
|
||||
tw.dir = 1;
|
||||
continue;
|
||||
}
|
||||
// готово
|
||||
this._guiTweens.splice(i, 1);
|
||||
// onDone callback в worker
|
||||
const sb = this.sandboxes.find(s => s.scriptId === tw.scriptId);
|
||||
if (sb) sb.sendGlobalEvent({ type: 'tweenDone', tweenId: tw.tweenId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_updateTweens(dt) {
|
||||
for (let i = this._tweens.length - 1; i >= 0; i--) {
|
||||
const tw = this._tweens[i];
|
||||
@ -920,16 +1079,58 @@ export class GameRuntime {
|
||||
* Глобальное событие — доставляется ВСЕМ sandbox'ам (не зависит от target).
|
||||
* Используется для onKey, onClick (глобальный), onPlayerTouch.
|
||||
*/
|
||||
/**
|
||||
* Задача 07: состояние скинов на стороне runtime.
|
||||
* Инициализируется из scene.skins (default/unlocked/shopVisible) при первом
|
||||
* обращении. Держит множество разблокированных скинов и текущий.
|
||||
*/
|
||||
_ensureSkinState() {
|
||||
if (this._skinState) return this._skinState;
|
||||
const sk = this.scene3d?._skinsConfig || {};
|
||||
const def = sk.default || this.scene3d?._playerModelType || 'character-a';
|
||||
const defSlug = this._slugFromTypeId(def);
|
||||
const unlocked = new Set(Array.isArray(sk.unlocked) ? sk.unlocked : []);
|
||||
unlocked.add(defSlug);
|
||||
this._skinState = {
|
||||
unlocked,
|
||||
current: defSlug,
|
||||
shopVisible: sk.shopVisible !== false,
|
||||
coins: Number.isFinite(sk.coins) ? sk.coins : 0,
|
||||
};
|
||||
return this._skinState;
|
||||
}
|
||||
|
||||
/** slug → _modelTypeId движка. Встроенные → 'skin_<slug>', character-* как есть. */
|
||||
_resolveSkinTypeId(slug) {
|
||||
if (!slug) return 'character-a';
|
||||
if (slug.startsWith('character-')) return slug;
|
||||
if (slug.startsWith('skin_') || slug.startsWith('customskin:')) return slug;
|
||||
return 'skin_' + slug;
|
||||
}
|
||||
|
||||
/** _modelTypeId → slug (обратно). */
|
||||
_slugFromTypeId(typeId) {
|
||||
if (!typeId) return 'character-a';
|
||||
if (typeId.startsWith('skin_')) return typeId.slice('skin_'.length);
|
||||
return typeId;
|
||||
}
|
||||
|
||||
routeGlobalEvent(eventType, extra = {}) {
|
||||
if (!eventType) return;
|
||||
// Спецслучай: guiClick приходит с realId, но worker подписан на localRef
|
||||
// (потому что gui.create() возвращает worker'у только localRef).
|
||||
// Резолвим обратно по реверс-карте.
|
||||
// Спецслучай: guiClick приходит с realId. Worker мог подписаться двумя
|
||||
// способами:
|
||||
// 1) по локальному ref, который вернул gui.create() — '_gui_local_N'
|
||||
// 2) по ЯВНОМУ id, который сам передал в gui.create({ id: 'fight' }),
|
||||
// или по name элемента.
|
||||
// Раньше мы ПЕРЕЗАПИСЫВАЛИ extra.id на localRef — это ломало вариант (2),
|
||||
// потому что worker искал handler по localRef, а юзер подписался по
|
||||
// явному id. Теперь шлём ОБА id (`id` = реальный + `localId` = ref),
|
||||
// worker матчит по обоим (см. _guiHandlerKeys в ScriptSandboxWorker).
|
||||
if ((eventType === 'guiClick' || eventType === 'guiSubmit'
|
||||
|| eventType === 'guiTextChange')
|
||||
&& extra && extra.id != null && this._guiRealToLocal) {
|
||||
const local = this._guiRealToLocal.get(extra.id);
|
||||
if (local) extra = { ...extra, id: local };
|
||||
if (local && local !== extra.id) extra = { ...extra, localId: local };
|
||||
}
|
||||
// ProximityPrompt: keydown клавиши взаимодействия → событие interact
|
||||
if (eventType === 'keydown' && extra && extra.key
|
||||
@ -1102,6 +1303,20 @@ export class GameRuntime {
|
||||
return map[code] || code.toLowerCase();
|
||||
}
|
||||
|
||||
/** Слить отложенные команды для конкретного только что зарезолвленного ref. */
|
||||
_drainPendingResolveQueue(resolvedLocalRef) {
|
||||
if (!this._pendingResolveQueue || !this._pendingResolveQueue.length) return;
|
||||
const stay = [];
|
||||
for (const item of this._pendingResolveQueue) {
|
||||
if (item.payload?.ref === resolvedLocalRef) {
|
||||
this._handleCommand(item.scriptId, item.cmd, item.payload);
|
||||
} else {
|
||||
stay.push(item);
|
||||
}
|
||||
}
|
||||
this._pendingResolveQueue = stay;
|
||||
}
|
||||
|
||||
/** Команда от Worker'а пришла — применяем на сцене. */
|
||||
_handleCommand(scriptId, cmd, payload) {
|
||||
if (cmd === 'log') {
|
||||
@ -1779,6 +1994,20 @@ export class GameRuntime {
|
||||
} catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'hud.setHotbarVisible') {
|
||||
try {
|
||||
const v = !!payload?.visible;
|
||||
this.scene3d?._setHotbarVisible?.(v);
|
||||
} catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'hud.setHpVisible') {
|
||||
try {
|
||||
const v = !!payload?.visible;
|
||||
this.scene3d?._setHpVisible?.(v);
|
||||
} catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'input.setCursorMode') {
|
||||
try {
|
||||
const mode = payload?.mode === 'ui' ? 'ui' : 'game';
|
||||
@ -1945,17 +2174,183 @@ export class GameRuntime {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// === Задача 07: скины игрока ===
|
||||
if (cmd === 'player.setSkin') {
|
||||
const player = this.scene3d?.player;
|
||||
const slug = payload?.slug;
|
||||
if (player && typeof slug === 'string' && slug) {
|
||||
const typeId = this._resolveSkinTypeId(slug);
|
||||
// Помечаем доступным (setSkin неявно разблокирует).
|
||||
this._ensureSkinState();
|
||||
this._skinState.unlocked.add(slug);
|
||||
this._skinState.current = slug;
|
||||
// Асинхронная перезагрузка модели; по завершении шлём skinChanged.
|
||||
Promise.resolve(player.reloadSkin?.(typeId)).then(() => {
|
||||
this.routeGlobalEvent?.('skinChanged', { slug });
|
||||
try { this.scene3d?._onPlayerSkinChanged?.(slug); } catch (e) {}
|
||||
}).catch((e) => {
|
||||
this._log('error', 'setSkin failed: ' + (e?.message || e));
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'player.unlockSkin') {
|
||||
const slug = payload?.slug;
|
||||
if (typeof slug === 'string' && slug) {
|
||||
this._ensureSkinState();
|
||||
this._skinState.unlocked.add(slug);
|
||||
this.routeGlobalEvent?.('skinUnlocked', { slug });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'player.openSkinShop') {
|
||||
this._ensureSkinState();
|
||||
try { this.scene3d?._openSkinShop?.(); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'player.closeSkinShop') {
|
||||
try { this.scene3d?._closeSkinShop?.(); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'player.setSkinCoins') {
|
||||
this._ensureSkinState();
|
||||
const n = Number(payload?.amount);
|
||||
if (Number.isFinite(n)) {
|
||||
this._skinState.coins = Math.max(0, Math.floor(n));
|
||||
this._broadcastSkinsSnapshot();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Покупка скина из встроенного магазина (намерение от React-оверлея
|
||||
// или из скрипта). Списывает локальные рублики, разблокирует, надевает.
|
||||
if (cmd === 'player.buySkin') {
|
||||
this._ensureSkinState();
|
||||
const slug = payload?.slug;
|
||||
const price = Number(payload?.price) || 0;
|
||||
if (typeof slug !== 'string' || !slug) return;
|
||||
const st = this._skinState;
|
||||
const owned = st.unlocked.has(slug);
|
||||
if (owned) {
|
||||
// Уже куплен — просто надеть.
|
||||
this._handleCommand(scriptId, 'player.setSkin', { slug });
|
||||
return;
|
||||
}
|
||||
if (st.coins < price) {
|
||||
// Не хватает — сообщаем оверлею.
|
||||
try { this.scene3d?._onSkinBuyResult?.({ slug, ok: false, reason: 'no_coins' }); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
st.coins -= price;
|
||||
st.unlocked.add(slug);
|
||||
this._handleCommand(scriptId, 'player.setSkin', { slug });
|
||||
this._broadcastSkinsSnapshot();
|
||||
try { this.scene3d?._onSkinBuyResult?.({ slug, ok: true }); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'player.setCameraMode') {
|
||||
const player = this.scene3d?.player;
|
||||
if (player && typeof payload?.mode === 'string') {
|
||||
const valid = ['first', 'third', 'front', 'sideview'];
|
||||
const valid = ['first', 'third', 'front', 'sideview', 'lockfirst'];
|
||||
if (valid.includes(payload.mode)) {
|
||||
player._cameraMode = payload.mode;
|
||||
const wasFirst = (player._cameraMode === 'first' || player._cameraMode === 'lockfirst');
|
||||
player._cameraMode = (payload.mode === 'lockfirst') ? 'first' : payload.mode;
|
||||
player._lockFirstPerson = (payload.mode === 'lockfirst');
|
||||
try { player._applyCameraMode?.(); } catch (e) {}
|
||||
// Запросить/снять lock в зависимости от нового режима
|
||||
const isFirst = (player._cameraMode === 'first');
|
||||
if (isFirst && !wasFirst) player._requestPointerLockSafe?.();
|
||||
else if (!isFirst && wasFirst && !player._shiftLock) {
|
||||
if (document.pointerLockElement === player.canvas) {
|
||||
try { document.exitPointerLock(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
try { player._applyCursorVisibility?.(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Задача 02: setCameraZoom / setCameraZoomLimits / setShiftLock
|
||||
if (cmd === 'player.setCameraZoom') {
|
||||
const player = this.scene3d?.player;
|
||||
if (player && typeof player.setCameraZoom === 'function') {
|
||||
try { player.setCameraZoom(payload?.distance); } catch (e) {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'player.setCameraZoomLimits') {
|
||||
const player = this.scene3d?.player;
|
||||
if (player && typeof player.setCameraZoomLimits === 'function') {
|
||||
try { player.setCameraZoomLimits(payload?.min, payload?.max); } catch (e) {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'player.setShiftLock') {
|
||||
const player = this.scene3d?.player;
|
||||
if (player && typeof player.setShiftLock === 'function') {
|
||||
try { player.setShiftLock(payload?.on); } catch (e) {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Задача 02: input.setMouseBehavior / setMouseIconVisible
|
||||
if (cmd === 'input.setMouseBehavior') {
|
||||
const player = this.scene3d?.player;
|
||||
if (player && typeof player.setMouseBehavior === 'function') {
|
||||
try { player.setMouseBehavior(payload?.mode); } catch (e) {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'input.setMouseIconVisible') {
|
||||
const player = this.scene3d?.player;
|
||||
if (player && typeof player.setMouseIconVisible === 'function') {
|
||||
try { player.setMouseIconVisible(payload?.visible); } catch (e) {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Задача 02: environment API
|
||||
if (cmd === 'environment.setSkyColor') {
|
||||
try {
|
||||
const hex = String(payload?.color || '');
|
||||
const scene = this.scene3d?.scene;
|
||||
if (scene && hex) {
|
||||
// Парсим #rrggbb → Color4
|
||||
const m = hex.match(/^#?([0-9a-f]{6})$/i);
|
||||
if (m) {
|
||||
const n = parseInt(m[1], 16);
|
||||
const r = ((n >> 16) & 0xff) / 255;
|
||||
const g = ((n >> 8) & 0xff) / 255;
|
||||
const b = (n & 0xff) / 255;
|
||||
// Color4 импортирован в начале файла
|
||||
if (scene.clearColor) {
|
||||
scene.clearColor.r = r;
|
||||
scene.clearColor.g = g;
|
||||
scene.clearColor.b = b;
|
||||
scene.clearColor.a = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this._log('error', 'environment.setSkyColor failed: ' + (e?.message || e));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'environment.setFog') {
|
||||
try {
|
||||
const env = this.scene3d?.environment;
|
||||
if (env && typeof env.setFog === 'function') {
|
||||
env.setFog(payload?.enabled, payload?.color, payload?.density);
|
||||
}
|
||||
} catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'environment.setTimeOfDay') {
|
||||
try {
|
||||
const env = this.scene3d?.environment;
|
||||
if (env && typeof env.setTimeOfDay === 'function') {
|
||||
env.setTimeOfDay(payload?.hours);
|
||||
}
|
||||
} catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'player.setCrouch') {
|
||||
const player = this.scene3d?.player;
|
||||
if (player) {
|
||||
@ -2527,6 +2922,114 @@ export class GameRuntime {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// === Задача 03: GUI tween ===
|
||||
if (cmd === 'gui.tween') {
|
||||
try {
|
||||
const guiId = payload?.id;
|
||||
if (typeof guiId !== 'string' || !guiId) return;
|
||||
const gm = this.scene3d?.guiManager;
|
||||
if (!gm) return;
|
||||
// Резолв localRef → realId если есть
|
||||
let realId = guiId;
|
||||
if (this._guiLocalToReal?.has(guiId)) realId = this._guiLocalToReal.get(guiId);
|
||||
const el = gm.elements?.find(e => e.id === realId);
|
||||
if (!el) return;
|
||||
if (!this._guiTweens) this._guiTweens = [];
|
||||
// Снимок начальных значений по тем ключам что есть в props
|
||||
const props = payload.props || {};
|
||||
const propKeys = Object.keys(props);
|
||||
// Перебивка: убрать существующие tween'ы на ТОТ ЖЕ id,
|
||||
// которые анимируют ХОТЯ БЫ ОДИН из этих же ключей.
|
||||
// Это поведение Roblox TweenService: новый tween на тот же ключ перебивает старый.
|
||||
for (let j = this._guiTweens.length - 1; j >= 0; j--) {
|
||||
const old = this._guiTweens[j];
|
||||
if (old.realId !== realId) continue;
|
||||
const oldKeys = Object.keys(old.target);
|
||||
const overlap = oldKeys.some(k => propKeys.includes(k));
|
||||
if (overlap) this._guiTweens.splice(j, 1);
|
||||
}
|
||||
const start = {};
|
||||
for (const k of propKeys) {
|
||||
if (k in el) start[k] = el[k];
|
||||
else if (k === 'scaleX' || k === 'scaleY' || k === 'rotation') start[k] = el[k] || (k === 'rotation' ? 0 : 1);
|
||||
}
|
||||
this._guiTweens.push({
|
||||
tweenId: payload.tweenId,
|
||||
scriptId,
|
||||
realId,
|
||||
start, target: { ...props },
|
||||
elapsed: 0,
|
||||
duration: Math.max(0.001, Number(payload.duration) || 0.5),
|
||||
delay: Math.max(0, Number(payload.delay) || 0),
|
||||
easing: payload.easing || 'ease',
|
||||
repeat: Number.isFinite(payload.repeat) ? payload.repeat : 0,
|
||||
reverses: !!payload.reverses,
|
||||
iter: 0,
|
||||
dir: 1, // 1 = вперёд, -1 = обратно (для reverses)
|
||||
});
|
||||
} catch (e) {
|
||||
this._log('error', 'gui.tween failed: ' + (e?.message || e));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'gui.cancelTween') {
|
||||
const tid = payload?.tweenId;
|
||||
if (tid != null && this._guiTweens) {
|
||||
const i = this._guiTweens.findIndex(t => t.tweenId === tid);
|
||||
if (i >= 0) this._guiTweens.splice(i, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// === Задача 04: модал-сцены ===
|
||||
if (cmd === 'modal.open') {
|
||||
try {
|
||||
const mm = this.scene3d?.modalManager;
|
||||
if (!mm) return;
|
||||
// Резолв spotlights: строки-id → ref-объекты {kind:'primitive', id} как обычно
|
||||
const opts = { ...(payload?.opts || {}) };
|
||||
if (Array.isArray(opts.spotlights)) {
|
||||
opts.spotlights = opts.spotlights.map(r => this._normRef ? this._normRef(r) : r);
|
||||
}
|
||||
if (opts.cameraOverride && opts.cameraOverride.target) {
|
||||
opts.cameraOverride = {
|
||||
...opts.cameraOverride,
|
||||
target: this._normRef ? this._normRef(opts.cameraOverride.target) : opts.cameraOverride.target,
|
||||
};
|
||||
}
|
||||
const modalId = mm.open(opts);
|
||||
// Подписка чтобы автоматически слать tweenDone-стиль событий
|
||||
// на конкретный скрипт (тот кто открыл) — для onClose.
|
||||
if (!mm._runtimeBoundOnClose) {
|
||||
mm._runtimeBoundOnClose = true;
|
||||
mm.onClose((closedId) => {
|
||||
// Шлём событие всем sandbox'ам — они роутят к зарегистрированным fn
|
||||
this.routeGlobalEvent?.('modalClosed', { id: closedId });
|
||||
});
|
||||
}
|
||||
// Ответ обратно в worker: фактический modalId (юзер мог вернуть из open)
|
||||
const sb = this.sandboxes.find(s => s.scriptId === scriptId);
|
||||
if (sb && payload?.replyId != null) {
|
||||
sb.sendGlobalEvent({ type: 'modalOpened', replyId: payload.replyId, modalId });
|
||||
}
|
||||
} catch (e) {
|
||||
this._log('error', 'modal.open failed: ' + (e?.message || e));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'modal.close') {
|
||||
try {
|
||||
const mm = this.scene3d?.modalManager;
|
||||
mm?.close?.(payload?.modalId);
|
||||
} catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'modal.update') {
|
||||
try {
|
||||
const mm = this.scene3d?.modalManager;
|
||||
mm?.update?.(payload?.modalId, payload?.patch);
|
||||
} catch (e) {}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'scene.setTexture') {
|
||||
// Установить динамическую текстуру примитива из dataURL.
|
||||
// Используется GD-скинами куба (canvas-фабрика в скрипте → dataURL → текстура).
|
||||
@ -2672,11 +3175,19 @@ export class GameRuntime {
|
||||
}
|
||||
// === Billboard 3D-таблички (см. BillboardUiManager) ===
|
||||
if (cmd === 'billboard.set' || cmd === 'billboard.update' || cmd === 'billboard.onClick') {
|
||||
// Резолв ref → primitiveId.
|
||||
// Worker может прислать ref сразу после game.scene.spawn — до
|
||||
// того как main spawn'нул примитив и обновил _localToReal.
|
||||
// Откладываем команду до резолва.
|
||||
let ref = payload?.ref;
|
||||
if (typeof ref === 'string' && ref.includes('_local_')
|
||||
&& !this._localToReal?.has(ref)) {
|
||||
this._pendingResolveQueue = this._pendingResolveQueue || [];
|
||||
this._pendingResolveQueue.push({ cmd, payload, scriptId });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Резолв ref → primitiveId
|
||||
let ref = payload?.ref;
|
||||
if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref);
|
||||
// ref имеет формат 'primitive:NN' — выделяем числовой id
|
||||
let id = null;
|
||||
if (typeof ref === 'string' && ref.startsWith('primitive:')) {
|
||||
id = Number(ref.slice('primitive:'.length));
|
||||
@ -2698,17 +3209,22 @@ export class GameRuntime {
|
||||
});
|
||||
this.scheduleSceneSnapshot?.();
|
||||
} else if (cmd === 'billboard.update') {
|
||||
mgr.update(data, payload.patch || {});
|
||||
// 2 формы: с elementId (точечно) или без (patch content)
|
||||
if (typeof payload.elementId === 'string') {
|
||||
mgr.update(data, payload.elementId, payload.patch || {});
|
||||
} else {
|
||||
mgr.update(data, payload.patch || {});
|
||||
}
|
||||
this.scheduleSceneSnapshot?.();
|
||||
} else if (cmd === 'billboard.onClick') {
|
||||
const buttonId = String(payload.buttonId || 'buy');
|
||||
// Регистрируем handler: при клике эмитим event в worker,
|
||||
// worker найдёт зарегистрированный JS-callback по (ref,button).
|
||||
const realRef = 'primitive:' + id;
|
||||
mgr.onClick(data, buttonId, () => {
|
||||
const sb = this.sandboxes.find(s => s.scriptId === scriptId);
|
||||
if (sb && typeof sb.sendEvent === 'function') {
|
||||
sb.sendEvent({
|
||||
if (sb && typeof sb.sendGlobalEvent === 'function') {
|
||||
// billboardClick роутится в worker'е через globalEvent-ветку
|
||||
// (см. ScriptSandboxWorker.js cmd === 'globalEvent').
|
||||
sb.sendGlobalEvent({
|
||||
type: 'billboardClick',
|
||||
ref: realRef,
|
||||
button: buttonId,
|
||||
@ -2877,6 +3393,7 @@ export class GameRuntime {
|
||||
if (id != null) {
|
||||
this._localToReal.set(ref, 'primitive:' + id);
|
||||
this._notifySpawnResolved(ref, 'primitive:' + id);
|
||||
this._drainPendingResolveQueue?.(ref);
|
||||
const data = this.scene3d?.primitiveManager?.instances?.get(id);
|
||||
if (data) {
|
||||
// Помечаем как заспавненный скриптом — движок шлёт
|
||||
|
||||
@ -140,6 +140,25 @@ export class GuiManager {
|
||||
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,
|
||||
|
||||
398
src/editor/engine/ModalManager.js
Normal file
398
src/editor/engine/ModalManager.js
Normal file
@ -0,0 +1,398 @@
|
||||
/**
|
||||
* 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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -106,11 +106,25 @@ export class PlayerController {
|
||||
this.GRAVITY = -22;
|
||||
this.MOUSE_SENSITIVITY = 0.0025;
|
||||
|
||||
// 3rd person camera
|
||||
this.THIRD_DISTANCE_MIN = 2.5;
|
||||
this.THIRD_DISTANCE_MAX = 12;
|
||||
// 3rd person camera (Roblox-style: 0.5 .. 32)
|
||||
this.THIRD_DISTANCE_MIN = 0.5;
|
||||
this.THIRD_DISTANCE_MAX = 32;
|
||||
this.THIRD_DISTANCE_DEFAULT = 5;
|
||||
this.THIRD_HEIGHT_OFFSET = 1.0; // на сколько выше плеча игрока
|
||||
// Порог перехода third ↔ first при зуме внутрь (Roblox: ~0.5)
|
||||
this.FIRST_PERSON_ZOOM_THRESHOLD = 0.7;
|
||||
// Lockfirst-режим: нельзя выйти из first-person зумом наружу
|
||||
this._lockFirstPerson = false;
|
||||
// Shift-Lock: курсор в центре, камера через плечо, корпус доворачивается к камере
|
||||
// (включается клавишей L по дефолту, или game.player.setShiftLock(true))
|
||||
this._shiftLock = false;
|
||||
// Видимость курсора по умолчанию (game.input.setMouseIconVisible)
|
||||
this._mouseIconVisible = true;
|
||||
// Mouse behavior: 'default' (свободный) / 'lockcenter' (зафиксирован)
|
||||
// / 'lockcurrent' (зафиксирован на текущей позиции)
|
||||
this._mouseBehavior = 'default';
|
||||
// Флаг: ПКМ зажата прямо сейчас (для orbit-камеры в third)
|
||||
this._rmbHeld = false;
|
||||
|
||||
this.camera = null;
|
||||
this._active = false;
|
||||
@ -277,6 +291,44 @@ export class PlayerController {
|
||||
this._modelTypeId = typeId || 'character-a';
|
||||
}
|
||||
|
||||
/**
|
||||
* Сменить скин ИГРОКА в Play-режиме без перезапуска сцены (задача 07).
|
||||
* Выгружает текущую модель/скелет/аниматор, сбрасывает AABB к дефолту,
|
||||
* грузит новую модель (R15 или non-humanoid). Возвращает Promise.
|
||||
*
|
||||
* Используется из game.player.setSkin(slug).
|
||||
*/
|
||||
async reloadSkin(typeId) {
|
||||
if (!this._active) return false;
|
||||
const newType = typeId || 'character-a';
|
||||
if (newType === this._modelTypeId && this._modelRoot) return true; // уже этот скин
|
||||
// 1) Выгрузить текущую модель и связанные аниматоры.
|
||||
try {
|
||||
if (this._modelRoot) { this._modelRoot.dispose(false, true); }
|
||||
} catch (e) { /* ignore */ }
|
||||
this._modelRoot = null;
|
||||
this._modelMeshes = [];
|
||||
this._rightArmMeshes = [];
|
||||
this._r15Skeleton = null;
|
||||
this._r15Animator = null;
|
||||
this._isR15 = false;
|
||||
this._modelKind = 'r15';
|
||||
this._modelHipHeight = null;
|
||||
this._nonHumanoidBox = null;
|
||||
// 2) Сбросить AABB к дефолтному «человеку» (non-humanoid его переопределит).
|
||||
this.HALF_W = 0.3;
|
||||
this.HALF_H = 0.9;
|
||||
this.HALF_D = 0.3;
|
||||
this.HALF_H_NORMAL = 0.9;
|
||||
this.EYE_HEIGHT = 0.7;
|
||||
// 3) Поднять игрока на запас, чтобы новый AABB не оказался в полу.
|
||||
this._pos.y += 0.5;
|
||||
// 4) Загрузить новую модель.
|
||||
this._modelTypeId = newType;
|
||||
await this._loadPlayerModel();
|
||||
return !!this._modelRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Запустить режим игры.
|
||||
* spawnPos — точка спавна. Если не указано — (0, 5, 0).
|
||||
@ -317,10 +369,37 @@ export class PlayerController {
|
||||
this._beforeRender = () => this._tick();
|
||||
this.scene.registerBeforeRender(this._beforeRender);
|
||||
|
||||
// Сразу запросить Pointer Lock. Promise-форма (новые Chrome) может
|
||||
// отклониться с SecurityError если предыдущий lock ещё не отпущен —
|
||||
// в этом случае ждём отпускания и пробуем снова.
|
||||
this._requestPointerLockSafe();
|
||||
// Pointer-lock запрашиваем ТОЛЬКО для режимов где он нужен сразу:
|
||||
// - first / lockfirst — постоянный lock
|
||||
// - sideview (GD) — раньше тоже лочил, оставляем для авто-управления
|
||||
// Для third — НЕ лочим (Roblox-style: курсор виден, ПКМ = orbit).
|
||||
// ШС-lock (_shiftLock) обрабатывается отдельно через keydown 'L'.
|
||||
const needLockAtStart = (
|
||||
this._cameraMode === 'first' ||
|
||||
this._cameraMode === 'lockfirst' ||
|
||||
this._cameraMode === 'sideview' ||
|
||||
this._shiftLock
|
||||
);
|
||||
if (needLockAtStart) {
|
||||
this._requestPointerLockSafe();
|
||||
}
|
||||
// Применяем видимость курсора (по умолчанию виден в third).
|
||||
this._applyCursorVisibility();
|
||||
}
|
||||
|
||||
/**
|
||||
* Установить курсор видимым/скрытым через CSS на canvas.
|
||||
* Pointer-lock сам прячет курсор когда активен, но в third без lock
|
||||
* мы можем скрыть курсор через `cursor:none` если разработчик
|
||||
* выключил его через setMouseIconVisible(false).
|
||||
*/
|
||||
_applyCursorVisibility() {
|
||||
if (!this.canvas) return;
|
||||
const locked = (document.pointerLockElement === this.canvas);
|
||||
// Если lock активен — курсор и так скрыт. Иначе зависит от настроек.
|
||||
if (locked) return;
|
||||
const show = this._mouseIconVisible && !this._shiftLock;
|
||||
this.canvas.style.cursor = show ? '' : 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
@ -578,22 +657,51 @@ export class PlayerController {
|
||||
const manifest = await this._loadSkinManifest();
|
||||
const entry = manifest.find((s) => s.id === typeId);
|
||||
if (entry) {
|
||||
// kind определяет систему анимации:
|
||||
// 'r15' → R15-скелет (как раньше)
|
||||
// 'non-humanoid-mesh' → single-mesh, процедурное покачивание
|
||||
// 'non-humanoid-rigged' → свой скелет + встроенные AnimationGroup
|
||||
// Отсутствие kind = 'r15' (обратная совместимость со старым манифестом).
|
||||
const kind = entry.kind || 'r15';
|
||||
return {
|
||||
file: '/kubikon-assets/' + entry.file,
|
||||
isR15: true,
|
||||
isR15: kind === 'r15',
|
||||
kind,
|
||||
overrides: entry.overrides || {},
|
||||
scaleManifest: Number.isFinite(entry.scale) ? entry.scale : null,
|
||||
hipHeight: Number.isFinite(entry.hipHeight) ? entry.hipHeight : null,
|
||||
rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0,
|
||||
};
|
||||
}
|
||||
// нет в манифесте — пробуем прямой путь
|
||||
// нет в манифесте — пробуем прямой путь (старые R15-скины)
|
||||
return {
|
||||
file: `/kubikon-assets/characters/${typeId}/body.glb`,
|
||||
isR15: true,
|
||||
kind: 'r15',
|
||||
overrides: {},
|
||||
};
|
||||
}
|
||||
// Кастомный .glb пользователя: 'customskin:<slug>'. dataUrl + метаданные
|
||||
// (scale/hipHeight) лежат в scene._skinsConfig.customGlbs.
|
||||
if (typeId.startsWith('customskin:')) {
|
||||
const slug = typeId.slice('customskin:'.length);
|
||||
const list = this._scene3d?._skinsConfig?.customGlbs || [];
|
||||
const meta = list.find(g => g && g.slug === slug) || null;
|
||||
const url = this._scene3d?.getAssetDataUrl?.(slug) || (meta && meta.dataUrl) || null;
|
||||
if (url) {
|
||||
return {
|
||||
file: url, isR15: false, kind: 'non-humanoid-mesh', overrides: {},
|
||||
scaleManifest: meta?.scale ?? 1.5,
|
||||
hipHeight: meta?.hipHeight ?? 0.4,
|
||||
rotationYOffset: meta?.rotationYOffset ?? 0,
|
||||
isDataUrl: true,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const modelType = getModelType(typeId);
|
||||
if (!modelType) return null;
|
||||
return { file: modelType.file, isR15: false, overrides: {} };
|
||||
return { file: modelType.file, isR15: false, kind: 'r15', overrides: {} };
|
||||
}
|
||||
|
||||
/** Загрузить GLB-модель персонажа и его анимации. */
|
||||
@ -607,13 +715,22 @@ export class PlayerController {
|
||||
// что и зомби (через _loadPrototype), повторный
|
||||
// instantiateModelsToScene давал меши с битыми материалами.
|
||||
// Babylon HTTP-кэш всё равно убирает сетевые запросы.
|
||||
const lastSlash = source.file.lastIndexOf('/');
|
||||
const rootUrl = source.file.substring(0, lastSlash + 1);
|
||||
const filename = source.file.substring(lastSlash + 1);
|
||||
let rootUrl, filename;
|
||||
if (source.isDataUrl) {
|
||||
// Кастомный скин — data:URL. SceneLoader принимает его как rootUrl=''
|
||||
// и filename=data:... с подсказкой расширения через ?name=.
|
||||
rootUrl = '';
|
||||
filename = source.file;
|
||||
} else {
|
||||
const lastSlash = source.file.lastIndexOf('/');
|
||||
rootUrl = source.file.substring(0, lastSlash + 1);
|
||||
filename = source.file.substring(lastSlash + 1);
|
||||
}
|
||||
let container;
|
||||
try {
|
||||
container = await SceneLoader.LoadAssetContainerAsync(
|
||||
rootUrl, filename, this.scene
|
||||
rootUrl, filename, this.scene,
|
||||
null, source.isDataUrl ? '.glb' : undefined
|
||||
);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
@ -634,10 +751,20 @@ export class PlayerController {
|
||||
// с торчащими волосами/плащами (как у bacon-hair).
|
||||
// - Kenney-модели: старый 0.72.
|
||||
// - overrides.scale_mult — per-skin множитель из манифеста.
|
||||
let modelScale = source.isR15 ? 0.301 : this._modelScale;
|
||||
const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0;
|
||||
modelScale *= scaleMult;
|
||||
const isNonHumanoid = source.kind === 'non-humanoid-mesh'
|
||||
|| source.kind === 'non-humanoid-rigged';
|
||||
let modelScale;
|
||||
if (isNonHumanoid) {
|
||||
// Non-humanoid: базовый размер берём из манифеста (scale), а если
|
||||
// нет — нормализуем по bounding box к ~1.6 ед высоты (как игрок).
|
||||
modelScale = Number.isFinite(source.scaleManifest) ? source.scaleManifest : 1.0;
|
||||
} else {
|
||||
modelScale = source.isR15 ? 0.301 : this._modelScale;
|
||||
const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0;
|
||||
modelScale *= scaleMult;
|
||||
}
|
||||
root.scaling = new Vector3(modelScale, modelScale, modelScale);
|
||||
if (source.rotationYOffset) root.rotation.y = source.rotationYOffset;
|
||||
const inst = container.instantiateModelsToScene(
|
||||
(name) => `player_${name}`,
|
||||
/*cloneAnimations*/ true,
|
||||
@ -647,6 +774,15 @@ export class PlayerController {
|
||||
r.parent = root;
|
||||
}
|
||||
this._modelRoot = root;
|
||||
this._modelKind = source.kind || 'r15';
|
||||
// hipHeight: на сколько центр модели поднят от «низа ног».
|
||||
// Используется и для позиционирования модели, и для камеры/AABB.
|
||||
this._modelHipHeight = Number.isFinite(source.hipHeight) ? source.hipHeight : null;
|
||||
|
||||
// Non-humanoid: нормализуем размер и опускаем модель на «ноги».
|
||||
if (isNonHumanoid) {
|
||||
this._setupNonHumanoidModel(root, modelScale, source);
|
||||
}
|
||||
|
||||
// === R15-скин: детекция скелета ===
|
||||
// R15-скины приходят с встроенным скелетом Mixamo. Babylon
|
||||
@ -786,6 +922,121 @@ export class PlayerController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Настройка non-humanoid модели (животное/машина/еда): нормализация
|
||||
* размера и опускание на «низ ног». В отличие от R15 (нормализованы
|
||||
* пайплайном), эти модели произвольного размера, поэтому считаем bbox.
|
||||
*
|
||||
* Локальные координаты root: модель должна стоять так, чтобы её низ был
|
||||
* на y=0 (там «ноги»). PlayerController позиционирует root в точке
|
||||
* `_pos.y - HALF_H` (низ AABB) — модель «садится» на землю.
|
||||
*/
|
||||
_setupNonHumanoidModel(root, scaleApplied, source) {
|
||||
try {
|
||||
// Считаем bounding box всех дочерних мешей в world-координатах ПОСЛЕ
|
||||
// применения scaling root'а. Babylon refreshBoundingInfo нужен после
|
||||
// инстансинга.
|
||||
const meshes = root.getChildMeshes(false).filter(m => m.getBoundingInfo && m.getTotalVertices && m.getTotalVertices() > 0);
|
||||
if (!meshes.length) return;
|
||||
root.computeWorldMatrix(true);
|
||||
let minY = Infinity, maxY = -Infinity, maxDim = 0;
|
||||
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
|
||||
for (const m of meshes) {
|
||||
m.computeWorldMatrix(true);
|
||||
// refreshBoundingInfo(true) — пересчитать bbox с учётом возможного
|
||||
// скелета/морфов; без него minimumWorld у инстансов часто нулевой
|
||||
// или из исходной позы → центр считался неверно (баг пришельца/робота).
|
||||
try { m.refreshBoundingInfo(true); } catch (e) { try { m.refreshBoundingInfo(); } catch (e2) {} }
|
||||
const bi = m.getBoundingInfo();
|
||||
const bb = bi.boundingBox;
|
||||
const lo = bb.minimumWorld, hi = bb.maximumWorld;
|
||||
if (!lo || !hi) continue;
|
||||
minY = Math.min(minY, lo.y); maxY = Math.max(maxY, hi.y);
|
||||
minX = Math.min(minX, lo.x); maxX = Math.max(maxX, hi.x);
|
||||
minZ = Math.min(minZ, lo.z); maxZ = Math.max(maxZ, hi.z);
|
||||
}
|
||||
if (!Number.isFinite(minX) || !Number.isFinite(minY)) return;
|
||||
const h = maxY - minY;
|
||||
const w = maxX - minX;
|
||||
const d = maxZ - minZ;
|
||||
maxDim = Math.max(h, w, d);
|
||||
// === Центрирование модели через pivot-node ===
|
||||
// Многие Kenney-модели имеют origin НЕ в геометрическом центре
|
||||
// (в углу/ноге) → при повороте модель «облетает» вокруг смещённого
|
||||
// origin (баг пришельца/робота). Ручной сдвиг детей с делением на
|
||||
// scaleApplied неверен если у детей свой scale/rotation. Надёжно:
|
||||
// вставляем промежуточный pivot между root и моделью и смещаем pivot
|
||||
// на -localCenter (через инверсию world-матрицы root — точно при
|
||||
// любом scale/rotation).
|
||||
const worldCenter = new Vector3(
|
||||
(minX + maxX) / 2, // центр X
|
||||
minY, // низ Y (модель «садится» на ноги)
|
||||
(minZ + maxZ) / 2 // центр Z
|
||||
);
|
||||
// world-центр → локальные координаты root
|
||||
const invRoot = root.getWorldMatrix().clone().invert();
|
||||
const localCenter = Vector3.TransformCoordinates(worldCenter, invRoot);
|
||||
const pivot = new TransformNode('playerModelPivot', this.scene);
|
||||
pivot.parent = root;
|
||||
pivot.position.set(-localCenter.x, -localCenter.y, -localCenter.z);
|
||||
// Перевешиваем все прямые дочерние ноды root (кроме самого pivot) на pivot.
|
||||
for (const ch of root.getChildren().slice()) {
|
||||
if (ch === pivot) continue;
|
||||
ch.parent = pivot;
|
||||
}
|
||||
// Сохраняем размеры для настраиваемого AABB и камеры.
|
||||
// hipHeight из манифеста — приоритетно; иначе берём низ модели.
|
||||
this._nonHumanoidBox = { w, h, d };
|
||||
this._modelBaseHeight = h;
|
||||
// AABB подгоняем под модель (плоская/широкая для машин, узкая для еды).
|
||||
// Ограничиваем разумными пределами чтобы не проваливаться/застревать.
|
||||
this.HALF_W = Math.max(0.25, Math.min(0.9, w / 2));
|
||||
this.HALF_D = Math.max(0.25, Math.min(1.1, d / 2));
|
||||
const halfH = Math.max(0.3, Math.min(1.0, h / 2));
|
||||
this.HALF_H = halfH;
|
||||
this.HALF_H_NORMAL = halfH;
|
||||
this.EYE_HEIGHT = halfH * 0.7;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[PlayerController] non-humanoid setup:', this._modelTypeId,
|
||||
'kind=' + source.kind, 'bbox(w/h/d)=' + w.toFixed(2) + '/' + h.toFixed(2) + '/' + d.toFixed(2),
|
||||
'AABB(W/H/D)=' + this.HALF_W.toFixed(2) + '/' + this.HALF_H.toFixed(2) + '/' + this.HALF_D.toFixed(2));
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[PlayerController] _setupNonHumanoidModel failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Процедурная анимация single-mesh скина (нет скелета — нечего анимировать
|
||||
* костями). Лёгкое покачивание вверх-вниз при движении + наклон вперёд при
|
||||
* беге + наклон в воздухе. Вызывается каждый кадр из _tick.
|
||||
* baseY — локальная база модели (опущена на ноги в _setupNonHumanoidModel).
|
||||
*/
|
||||
_animateNonHumanoidMesh(dt) {
|
||||
const root = this._modelRoot;
|
||||
if (!root) return;
|
||||
const t = (typeof performance !== 'undefined' && performance.now)
|
||||
? performance.now() / 1000 : Date.now() / 1000;
|
||||
const speed = this._lastFrameSpeed || 0;
|
||||
// Базовое вращение по yaw уже выставляет _tick (он крутит модель под
|
||||
// направление движения/камеры). Мы добавляем ТОЛЬКО процедурный bob/tilt
|
||||
// поверх — храним их в отдельных полях, чтобы _tick их не перетёр.
|
||||
let bobY = 0, tiltX = 0;
|
||||
if (!this._isGrounded) {
|
||||
tiltX = 0.2; // в воздухе — нос вверх
|
||||
} else if (speed > 0.1) {
|
||||
const bobFreq = 8 * Math.min(2, speed / 4);
|
||||
bobY = Math.sin(t * bobFreq) * 0.06;
|
||||
tiltX = Math.min(speed * 0.04, 0.13);
|
||||
} else {
|
||||
bobY = Math.sin(t * 1.5) * 0.015; // лёгкое «дыхание» в покое
|
||||
}
|
||||
// Применяем поверх позиции, которую _tick уже выставил в root.position.y.
|
||||
root.position.y += bobY;
|
||||
// tilt по локальной оси X модели. rotation.y уже выставлен _tick'ом.
|
||||
root.rotation.x = tiltX;
|
||||
}
|
||||
|
||||
/** AABB игрока пересекает хотя бы один блок-воду. */
|
||||
_isInWater() {
|
||||
const bm = this._scene3d?.blockManager;
|
||||
@ -1487,15 +1738,228 @@ export class PlayerController {
|
||||
const idx = CAMERA_MODES.indexOf(this._cameraMode);
|
||||
this._cameraMode = CAMERA_MODES[(idx + 1) % CAMERA_MODES.length];
|
||||
this._applyCameraMode();
|
||||
// При переходе в first сразу лочим, при выходе — снимаем lock (если нет shift-lock)
|
||||
if (this._cameraMode === 'first') {
|
||||
this._requestPointerLockSafe();
|
||||
} else if (!this._shiftLock && document.pointerLockElement === this.canvas) {
|
||||
try { document.exitPointerLock(); } catch (e) {}
|
||||
}
|
||||
this._applyCursorVisibility?.();
|
||||
}
|
||||
|
||||
/** Включить/выключить Shift-Lock (Roblox-style: курсор зафиксирован, корпус
|
||||
* всегда лицом к камере, камера через плечо).
|
||||
*/
|
||||
setShiftLock(on) {
|
||||
this._shiftLock = !!on;
|
||||
if (this._shiftLock) {
|
||||
// Запросить pointer-lock — курсор в центре
|
||||
this._requestPointerLockSafe();
|
||||
} else {
|
||||
// Снять lock если он есть и нет других причин держать (first/sideview)
|
||||
const needPermLock = (
|
||||
this._cameraMode === 'first' ||
|
||||
this._cameraMode === 'lockfirst' ||
|
||||
this._cameraMode === 'sideview'
|
||||
);
|
||||
if (!needPermLock && document.pointerLockElement === this.canvas) {
|
||||
try { document.exitPointerLock(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
this._applyCursorVisibility?.();
|
||||
}
|
||||
isShiftLock() { return !!this._shiftLock; }
|
||||
|
||||
/** Задача 04: блокирует игровой ввод (WASD/Space/Ctrl/Shift/etc).
|
||||
* Не блокирует Esc/Tab/Enter (нужны для GUI).
|
||||
* Также сбрасывает накопленные клавиши чтобы движение остановилось. */
|
||||
setInputBlocked(blocked) {
|
||||
this._inputBlocked = !!blocked;
|
||||
if (this._inputBlocked) {
|
||||
try { this._codes?.clear(); } catch (e) {}
|
||||
this._shift = false;
|
||||
// Снимаем pointer-lock — иначе мышь застрянет «в режиме игры»
|
||||
try {
|
||||
if (document.pointerLockElement === this.canvas) document.exitPointerLock();
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
isInputBlocked() { return !!this._inputBlocked; }
|
||||
|
||||
/** Задача 04: замораживает камеру (не вращается мышью, не зумится колесом). */
|
||||
setCameraFrozen(frozen) {
|
||||
this._cameraFrozen = !!frozen;
|
||||
}
|
||||
isCameraFrozen() { return !!this._cameraFrozen; }
|
||||
|
||||
/** Задача 04: снимок состояния камеры — для восстановления после модала. */
|
||||
captureCameraState() {
|
||||
return {
|
||||
yaw: this._yaw,
|
||||
pitch: this._pitch,
|
||||
cameraMode: this._cameraMode,
|
||||
thirdDistance: this._thirdDistance,
|
||||
fov: this.scene?.activeCamera?.fov,
|
||||
playerPos: this._pos ? {
|
||||
x: this._pos.x, y: this._pos.y, z: this._pos.z
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Задача 04: восстановить состояние камеры из снимка. */
|
||||
restoreCameraState(s) {
|
||||
if (!s) return;
|
||||
if (Number.isFinite(s.yaw)) this._yaw = s.yaw;
|
||||
if (Number.isFinite(s.pitch)) this._pitch = s.pitch;
|
||||
if (s.cameraMode) {
|
||||
this._cameraMode = s.cameraMode;
|
||||
try { this._applyCameraMode?.(); } catch (e) {}
|
||||
}
|
||||
if (Number.isFinite(s.thirdDistance)) this._thirdDistance = s.thirdDistance;
|
||||
if (Number.isFinite(s.fov) && this.scene?.activeCamera) {
|
||||
this.scene.activeCamera.fov = s.fov;
|
||||
}
|
||||
}
|
||||
|
||||
/** Задача 04: камера-фокус на reference (cube/npc/cam-target).
|
||||
* ref может быть: строка-id, ref-объект ({kind, id}), Mesh, или {x,y,z}.
|
||||
* Использует уже существующий механизм camera.focus в GameRuntime, но
|
||||
* здесь упрощённая обёртка: ставим yaw/pitch так, чтобы смотреть на цель,
|
||||
* и зум на distance. */
|
||||
focusOnTarget(ref, opts) {
|
||||
opts = opts || {};
|
||||
const distance = Number.isFinite(opts.distance) ? opts.distance : 8;
|
||||
const height = Number.isFinite(opts.height) ? opts.height : 3;
|
||||
const fov = Number.isFinite(opts.fov) ? opts.fov : null;
|
||||
let target = null;
|
||||
if (ref && typeof ref === 'object' && 'x' in ref && 'y' in ref && 'z' in ref) {
|
||||
target = ref;
|
||||
} else {
|
||||
const m = this._resolveTargetMesh(ref);
|
||||
if (m) {
|
||||
const p = m.getAbsolutePosition?.() || m.position;
|
||||
target = { x: p.x, y: p.y, z: p.z };
|
||||
}
|
||||
}
|
||||
if (!target) return;
|
||||
// Прицельный взгляд: позиция камеры за игроком на distance, направление — на target
|
||||
// Но мы во freezeCamera режиме — лучше через прямой override yaw/pitch.
|
||||
if (!this._pos) return;
|
||||
const dx = target.x - this._pos.x;
|
||||
const dz = target.z - this._pos.z;
|
||||
const dy = target.y - this._pos.y;
|
||||
const horiz = Math.hypot(dx, dz);
|
||||
this._yaw = Math.atan2(dx, dz);
|
||||
this._pitch = Math.max(-1.4, Math.min(1.4, -Math.atan2(dy, horiz)));
|
||||
this._thirdDistance = distance;
|
||||
if (this._cameraMode !== 'third') {
|
||||
this._cameraMode = 'third';
|
||||
try { this._applyCameraMode?.(); } catch (e) {}
|
||||
}
|
||||
if (fov && this.scene?.activeCamera) {
|
||||
this.scene.activeCamera.fov = fov * Math.PI / 180;
|
||||
}
|
||||
}
|
||||
|
||||
_resolveTargetMesh(ref) {
|
||||
if (!ref) return null;
|
||||
if (ref.getScene && typeof ref.getScene === 'function') return ref;
|
||||
const sc = this._scene3d || this.scene3d;
|
||||
const idStr = (typeof ref === 'string') ? ref : (ref.id || ref.refId || null);
|
||||
if (!idStr || !sc) return null;
|
||||
const tries = [
|
||||
() => sc.primitiveManager?.getMesh?.(idStr),
|
||||
() => sc.modelManager?.getInstanceMeshes?.(idStr)?.[0],
|
||||
() => sc.scene?.getMeshByName?.(idStr),
|
||||
() => sc.npcManager?.getMeshes?.(idStr)?.[0],
|
||||
];
|
||||
for (const fn of tries) {
|
||||
try { const r = fn(); if (r) return r; } catch (e) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Прямо установить дистанцию камеры (для third). Кламп в min/max. */
|
||||
setCameraZoom(distance) {
|
||||
const d = Number(distance);
|
||||
if (!Number.isFinite(d)) return;
|
||||
this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN,
|
||||
Math.min(this.THIRD_DISTANCE_MAX, d));
|
||||
// Авто-переход third↔first если пересекли порог
|
||||
if (this._thirdDistance <= this.FIRST_PERSON_ZOOM_THRESHOLD
|
||||
&& this._cameraMode === 'third') {
|
||||
this._cameraMode = 'first';
|
||||
this._applyCameraMode?.();
|
||||
this._requestPointerLockSafe();
|
||||
} else if (this._thirdDistance > this.FIRST_PERSON_ZOOM_THRESHOLD
|
||||
&& this._cameraMode === 'first' && !this._lockFirstPerson) {
|
||||
this._cameraMode = 'third';
|
||||
this._applyCameraMode?.();
|
||||
if (!this._shiftLock && document.pointerLockElement === this.canvas) {
|
||||
try { document.exitPointerLock(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Установить границы зума колеса. */
|
||||
setCameraZoomLimits(min, max) {
|
||||
const mn = Number(min), mx = Number(max);
|
||||
if (Number.isFinite(mn) && mn >= 0) this.THIRD_DISTANCE_MIN = mn;
|
||||
if (Number.isFinite(mx) && mx > 0) this.THIRD_DISTANCE_MAX = mx;
|
||||
// Перекламп текущей дистанции
|
||||
this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN,
|
||||
Math.min(this.THIRD_DISTANCE_MAX, this._thirdDistance));
|
||||
}
|
||||
/** Поведение мыши: default / lockcenter / lockcurrent.
|
||||
* default — свободный курсор (стандартный browser cursor).
|
||||
* lockcenter — pointer-lock (курсор скрыт, mousemove даёт movementX/Y).
|
||||
* lockcurrent — pointer-lock, но без скрытия (визуально как default,
|
||||
* реально движение отслеживается через movementX/Y).
|
||||
*/
|
||||
setMouseBehavior(mode) {
|
||||
if (mode !== 'default' && mode !== 'lockcenter' && mode !== 'lockcurrent') return;
|
||||
this._mouseBehavior = mode;
|
||||
if (mode === 'default') {
|
||||
// Снимаем lock если ничто другое не требует его
|
||||
const needPermLock = (
|
||||
this._cameraMode === 'first' ||
|
||||
this._cameraMode === 'lockfirst' ||
|
||||
this._cameraMode === 'sideview' ||
|
||||
this._shiftLock
|
||||
);
|
||||
if (!needPermLock && document.pointerLockElement === this.canvas) {
|
||||
try { document.exitPointerLock(); } catch (e) {}
|
||||
}
|
||||
} else {
|
||||
this._requestPointerLockSafe();
|
||||
}
|
||||
this._applyCursorVisibility?.();
|
||||
}
|
||||
/** Видимость курсора (для third без lock). */
|
||||
setMouseIconVisible(visible) {
|
||||
this._mouseIconVisible = !!visible;
|
||||
this._applyCursorVisibility?.();
|
||||
}
|
||||
|
||||
_setupInput() {
|
||||
const canvas = this.canvas;
|
||||
|
||||
const onCanvasClick = () => {
|
||||
// В UI-режиме клик по канвасу НЕ перехватывает мышь
|
||||
// В UI-режиме клик не перехватывает мышь.
|
||||
if (this._uiCursorMode) return;
|
||||
if (this._active && document.pointerLockElement !== canvas) {
|
||||
if (!this._active) return;
|
||||
// Roblox-style: в third-person ЛКМ-клик НЕ должен лочить курсор —
|
||||
// курсор остаётся свободным для GUI/3D-onClick. Lock запрашиваем
|
||||
// ТОЛЬКО для режимов где курсор постоянно скрыт (first/lockfirst/
|
||||
// sideview/shiftLock), и только если по какой-то причине lock сняли
|
||||
// (например, юзер нажал Esc в first-режиме — надо вернуть lock).
|
||||
const needPermLock = (
|
||||
this._cameraMode === 'first' ||
|
||||
this._cameraMode === 'lockfirst' ||
|
||||
this._cameraMode === 'sideview' ||
|
||||
this._shiftLock
|
||||
);
|
||||
if (!needPermLock) return;
|
||||
if (document.pointerLockElement !== canvas) {
|
||||
try {
|
||||
const p = canvas.requestPointerLock?.();
|
||||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||
@ -1504,6 +1968,54 @@ export class PlayerController {
|
||||
};
|
||||
canvas.addEventListener('click', onCanvasClick);
|
||||
|
||||
// === ПКМ: в third-person удержание ПКМ запускает orbit-камеру ===
|
||||
// Roblox-style: зажал ПКМ → курсор скрыт, мышь крутит камеру.
|
||||
// Отпустил → курсор вернулся на ту же позицию (браузер сам ставит).
|
||||
const onCanvasMouseDownGlobal = (e) => {
|
||||
if (!this._active || this._uiCursorMode) return;
|
||||
if (e.button !== 2) return; // только ПКМ
|
||||
// В режимах с постоянным lock'ом ПКМ ничего не делает
|
||||
const needPermLock = (
|
||||
this._cameraMode === 'first' ||
|
||||
this._cameraMode === 'lockfirst' ||
|
||||
this._cameraMode === 'sideview' ||
|
||||
this._shiftLock
|
||||
);
|
||||
if (needPermLock) return;
|
||||
// Запрашиваем lock — теперь mouseMove будет крутить камеру.
|
||||
this._rmbHeld = true;
|
||||
if (document.pointerLockElement !== canvas) {
|
||||
try {
|
||||
const p = canvas.requestPointerLock?.();
|
||||
if (p && typeof p.catch === 'function') p.catch(() => {});
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
const onWindowMouseUpGlobal = (e) => {
|
||||
if (e.button !== 2) return;
|
||||
if (!this._rmbHeld) return;
|
||||
this._rmbHeld = false;
|
||||
// Отпускаем lock только если он был включён нами для orbit-камеры
|
||||
// (т.е. сейчас НЕ режим с постоянным lock).
|
||||
const needPermLock = (
|
||||
this._cameraMode === 'first' ||
|
||||
this._cameraMode === 'lockfirst' ||
|
||||
this._cameraMode === 'sideview' ||
|
||||
this._shiftLock
|
||||
);
|
||||
if (needPermLock) return;
|
||||
if (document.pointerLockElement === canvas) {
|
||||
try { document.exitPointerLock(); } catch (err) { /* ignore */ }
|
||||
}
|
||||
};
|
||||
canvas.addEventListener('mousedown', onCanvasMouseDownGlobal);
|
||||
window.addEventListener('mouseup', onWindowMouseUpGlobal);
|
||||
// Подавляем контекстное меню браузера на canvas (ПКМ — наш orbit-trigger).
|
||||
canvas.addEventListener('contextmenu', (e) => {
|
||||
if (this._active) e.preventDefault();
|
||||
});
|
||||
|
||||
// === UI-режим: mousedown / mouseup → callback (для drag-игр) ===
|
||||
const onCanvasMouseDown = (e) => {
|
||||
if (!this._uiCursorMode) return;
|
||||
@ -1543,6 +2055,8 @@ export class PlayerController {
|
||||
if (document.pointerLockElement !== canvas) return;
|
||||
// Кубикон Dash: в sideview мышь не вращает камеру.
|
||||
if (this._cameraMode === 'sideview') return;
|
||||
// Задача 04: модал с freezeCamera — мышь не вращает.
|
||||
if (this._cameraFrozen) return;
|
||||
this._yaw += e.movementX * this.MOUSE_SENSITIVITY;
|
||||
this._pitch += e.movementY * this.MOUSE_SENSITIVITY;
|
||||
const lim = Math.PI / 2 - 0.05;
|
||||
@ -1551,13 +2065,46 @@ export class PlayerController {
|
||||
};
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
|
||||
// Колесо в 3rd-person — меняет дистанцию
|
||||
// Колесо: zoom в third + авто-переключение third ↔ first.
|
||||
// Roblox-style: дистанция ≤ FIRST_PERSON_ZOOM_THRESHOLD → first-person
|
||||
// (с pointer-lock). Колесо наружу из first → возврат в third.
|
||||
const onWheel = (e) => {
|
||||
if (!this._active) return;
|
||||
if (this._cameraMode === 'sideview') return;
|
||||
// Задача 04: модал с freezeCamera — колесо не зумит.
|
||||
if (this._cameraFrozen) { e.preventDefault(); return; }
|
||||
// В first-режиме колесо вверх НЕ работает (если lockfirst), вниз
|
||||
// выходит обратно в third (если zoomable first, не lockfirst).
|
||||
if (this._cameraMode === 'first') {
|
||||
if (this._lockFirstPerson) { e.preventDefault(); return; }
|
||||
if (e.deltaY > 0) {
|
||||
// Колесо вниз → отдалить → переход в third
|
||||
this._cameraMode = 'third';
|
||||
this._thirdDistance = this.FIRST_PERSON_ZOOM_THRESHOLD + 0.5;
|
||||
this._applyCameraMode?.();
|
||||
// Снять pointer-lock — в third без shift-lock курсор виден
|
||||
if (!this._shiftLock && document.pointerLockElement === canvas) {
|
||||
try { document.exitPointerLock(); } catch (err) {}
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (this._cameraMode !== 'third') return;
|
||||
this._thirdDistance += Math.sign(e.deltaY) * 0.5;
|
||||
// Шаг зума — пропорционален текущей дистанции (экспоненциальный фил)
|
||||
const step = Math.max(0.3, this._thirdDistance * 0.15);
|
||||
this._thirdDistance += Math.sign(e.deltaY) * step;
|
||||
if (this._thirdDistance < this.THIRD_DISTANCE_MIN) this._thirdDistance = this.THIRD_DISTANCE_MIN;
|
||||
if (this._thirdDistance > this.THIRD_DISTANCE_MAX) this._thirdDistance = this.THIRD_DISTANCE_MAX;
|
||||
// Авто-переход в first при близком зуме (Roblox-style)
|
||||
if (this._thirdDistance <= this.FIRST_PERSON_ZOOM_THRESHOLD) {
|
||||
this._cameraMode = 'first';
|
||||
this._applyCameraMode?.();
|
||||
// Запросить pointer-lock — first всегда залочен
|
||||
if (!this._shiftLock && document.pointerLockElement !== canvas) {
|
||||
this._requestPointerLockSafe();
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
canvas.addEventListener('wheel', onWheel, { passive: false });
|
||||
@ -1567,10 +2114,31 @@ export class PlayerController {
|
||||
const locked = document.pointerLockElement === canvas;
|
||||
if (locked) {
|
||||
wasLocked = true;
|
||||
this._rmbHeld = true; // если попал в lock — ПКМ удерживается
|
||||
} else if (wasLocked && this._active) {
|
||||
// Если мы САМИ переключились в UI-cursor mode — не выходим из Play
|
||||
if (this._uiCursorMode) return;
|
||||
if (this._onExitRequest) this._onExitRequest();
|
||||
// pointer-lock снят. Причин три:
|
||||
// 1) пользователь сам в UI-режиме (game.input.setCursorMode('ui'))
|
||||
// 2) ПКМ отпущена в third-person (orbit-камера завершена)
|
||||
// 3) Esc → выход из Play (если был в first/lockfirst/sideview)
|
||||
this._rmbHeld = false;
|
||||
if (this._uiCursorMode) {
|
||||
this._applyCursorVisibility();
|
||||
return;
|
||||
}
|
||||
const needPermLock = (
|
||||
this._cameraMode === 'first' ||
|
||||
this._cameraMode === 'lockfirst' ||
|
||||
this._cameraMode === 'sideview' ||
|
||||
this._shiftLock
|
||||
);
|
||||
if (needPermLock) {
|
||||
// Был режим с постоянным lock'ом и его сняли → Esc → выход
|
||||
if (this._onExitRequest) this._onExitRequest();
|
||||
} else {
|
||||
// Third-person: пользователь просто отпустил ПКМ. Курсор
|
||||
// возвращается там же где был — это нормально, остаёмся в Play.
|
||||
this._applyCursorVisibility();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerlockchange', onPointerLockChange);
|
||||
@ -1584,6 +2152,23 @@ export class PlayerController {
|
||||
const onKeyDown = (e) => {
|
||||
if (!this._active) return;
|
||||
if (isTypingTarget(e.target)) return;
|
||||
// Задача 04: Esc в Play — приоритет закрытие модала через _onExitRequest
|
||||
// (там стоит проверка modalManager.isOpen). Без явного перехвата Esc
|
||||
// в third (без pointer-lock) сразу выходил из Play.
|
||||
if (e.code === 'Escape') {
|
||||
if (this._onExitRequest) {
|
||||
this._onExitRequest();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Задача 04: модал блокирует игровой ввод (WASD/Space/Ctrl/Shift и т.п.),
|
||||
// но НЕ блокирует Esc (нужен для закрытия модала через Esc-обработчик),
|
||||
// и НЕ блокирует Tab/Enter (могут понадобиться в GUI-модалах).
|
||||
if (this._inputBlocked && e.code !== 'Escape' && e.code !== 'Tab' && e.code !== 'Enter') {
|
||||
// Глотаем preventDefault только для игровых клавиш
|
||||
if (['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
|
||||
return;
|
||||
}
|
||||
this._codes.add(e.code);
|
||||
if (e.shiftKey) this._shift = true;
|
||||
// C — переключение first/third. Отключаем в GD-режиме (автобег > 0)
|
||||
@ -1593,6 +2178,17 @@ export class PlayerController {
|
||||
|| this._shipMode || this._ufoMode || this._waveMode || this._robotMode;
|
||||
if (!inGdMode) this._toggleCameraMode();
|
||||
}
|
||||
// L — Shift-Lock (Roblox по умолчанию на Shift, у нас Shift = бег,
|
||||
// поэтому переназначено на L). Курсор центрируется, корпус всегда
|
||||
// лицом к камере, камера через плечо.
|
||||
if (e.code === 'KeyL') {
|
||||
this.setShiftLock(!this._shiftLock);
|
||||
}
|
||||
// B — встроенный магазин скинов (задача 07). Открывается только если
|
||||
// включён в проекте (scene.skins.shopVisible). Toggle.
|
||||
if (e.code === 'KeyB' && !this._inputBlocked) {
|
||||
try { this._scene3d?.toggleSkinShop?.(); } catch (err) {}
|
||||
}
|
||||
// Tab — переключить «UI-режим курсора» (для кликов по GUI в Play)
|
||||
if (e.code === 'Tab') {
|
||||
e.preventDefault();
|
||||
@ -2116,20 +2712,41 @@ export class PlayerController {
|
||||
this._modelYaw += Math.sign(diff) * maxStep;
|
||||
}
|
||||
} else {
|
||||
const dxReal = this._pos.x - beforeX;
|
||||
const dzReal = this._pos.z - beforeZ;
|
||||
const movedHorizontal = Math.abs(dxReal) > 0.001 || Math.abs(dzReal) > 0.001;
|
||||
if (movedHorizontal) {
|
||||
const targetYaw = Math.atan2(dxReal, dzReal);
|
||||
// Roblox-style: в first/lockfirst/shiftLock корпус мгновенно
|
||||
// следует за yaw камеры (AutoRotate привязан к камере).
|
||||
// В third — корпус доворачивается под РЕАЛЬНОЕ направление движения.
|
||||
const followCamera = (
|
||||
this._cameraMode === 'first' ||
|
||||
this._cameraMode === 'lockfirst' ||
|
||||
this._shiftLock
|
||||
);
|
||||
if (followCamera) {
|
||||
const targetYaw = this._yaw;
|
||||
let diff = targetYaw - this._modelYaw;
|
||||
while (diff > Math.PI) diff -= Math.PI * 2;
|
||||
while (diff < -Math.PI) diff += Math.PI * 2;
|
||||
const maxStep = this.MODEL_TURN_SPEED * dt;
|
||||
const maxStep = this.MODEL_TURN_SPEED * dt * 3; // быстрее чем при ходьбе
|
||||
if (Math.abs(diff) <= maxStep) {
|
||||
this._modelYaw = targetYaw;
|
||||
} else {
|
||||
this._modelYaw += Math.sign(diff) * maxStep;
|
||||
}
|
||||
} else {
|
||||
const dxReal = this._pos.x - beforeX;
|
||||
const dzReal = this._pos.z - beforeZ;
|
||||
const movedHorizontal = Math.abs(dxReal) > 0.001 || Math.abs(dzReal) > 0.001;
|
||||
if (movedHorizontal) {
|
||||
const targetYaw = Math.atan2(dxReal, dzReal);
|
||||
let diff = targetYaw - this._modelYaw;
|
||||
while (diff > Math.PI) diff -= Math.PI * 2;
|
||||
while (diff < -Math.PI) diff += Math.PI * 2;
|
||||
const maxStep = this.MODEL_TURN_SPEED * dt;
|
||||
if (Math.abs(diff) <= maxStep) {
|
||||
this._modelYaw = targetYaw;
|
||||
} else {
|
||||
this._modelYaw += Math.sign(diff) * maxStep;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Применяем yaw + swim-tilt.
|
||||
@ -2188,6 +2805,17 @@ export class PlayerController {
|
||||
this._tickDebris(dt);
|
||||
|
||||
// === Анимации ===
|
||||
// Снимок скорости/опоры для процедурной анимации non-humanoid скинов.
|
||||
this._lastFrameSpeed = (isMoving ? (isSprinting ? this.WALK_SPEED * this.SPRINT_MULT : this.WALK_SPEED) : 0) * (this._speedMul || 1);
|
||||
this._isGrounded = !!result.onGround;
|
||||
|
||||
// Non-humanoid single-mesh скин: костей нет — анимируем процедурно
|
||||
// (покачивание/наклон). Делаем ДО R15-ветки, т.к. _isR15=false для них.
|
||||
if (this._modelKind === 'non-humanoid-mesh' && this._modelRoot) {
|
||||
this._animateNonHumanoidMesh(dt);
|
||||
return;
|
||||
}
|
||||
|
||||
// R15-скин: процедурный аниматор (нет glTF AnimationGroups).
|
||||
// Состояния: idle/walk/run/jump/fall. sprint → run.
|
||||
if (this._isR15 && this._r15Animator) {
|
||||
|
||||
@ -151,9 +151,10 @@ export class PrimitiveManager {
|
||||
// serialize мог записать их обратно в JSON проекта.
|
||||
const billboardOpts = {
|
||||
template: opts.template || 'shop-item',
|
||||
face: opts.face || 'camera',
|
||||
face: opts.face || 'fixed',
|
||||
content: opts.content || null,
|
||||
elements: opts.elements || null,
|
||||
rotationY: opts.rotationY,
|
||||
};
|
||||
this.billboardUiManager.applyToMesh(data, billboardOpts);
|
||||
// billboardOpts хранится в data.billboard после applyToMesh.
|
||||
@ -731,7 +732,11 @@ export class PrimitiveManager {
|
||||
|
||||
/** Все инстансы как массив (для Hierarchy). */
|
||||
getAll() {
|
||||
return Array.from(this.instances.values()).map(d => ({
|
||||
return Array.from(this.instances.values())
|
||||
// Исключаем скриптовые спавны — они эфемерные и не должны
|
||||
// попадать в project_data (иначе при каждом Play копятся дубли).
|
||||
.filter(d => !d._scriptSpawned)
|
||||
.map(d => ({
|
||||
id: d.id, type: d.type,
|
||||
x: d.x, y: d.y, z: d.z,
|
||||
sx: d.sx, sy: d.sy, sz: d.sz,
|
||||
|
||||
@ -77,7 +77,7 @@ export class ScriptSandbox {
|
||||
_handleMessage(e) {
|
||||
if (this._isStopped) return;
|
||||
const { cmd, payload } = e.data || {};
|
||||
if (cmd === 'boot') return; // Worker boot, ничего не делаем
|
||||
if (cmd === 'boot') return;
|
||||
if (cmd === 'ready') {
|
||||
this._isReady = true;
|
||||
// Доставим pending snapshot'ы (приходили до ready)
|
||||
@ -97,6 +97,10 @@ export class ScriptSandbox {
|
||||
try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: this._pendingDataSnapshot }); } catch (e) {}
|
||||
this._pendingDataSnapshot = null;
|
||||
}
|
||||
if (this._pendingSkinsSnapshot) {
|
||||
try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: this._pendingSkinsSnapshot }); } catch (e) {}
|
||||
this._pendingSkinsSnapshot = null;
|
||||
}
|
||||
// Доставим события которые пришли до готовности
|
||||
if (this._pendingEvents.length > 0) {
|
||||
for (const ev of this._pendingEvents) {
|
||||
@ -175,6 +179,16 @@ export class ScriptSandbox {
|
||||
try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: snapshot }); } catch (e) {}
|
||||
}
|
||||
|
||||
/** Задача 07: снапшот скинов — для game.player.getAvailableSkins/getAllSkins. */
|
||||
sendSkinsSnapshot(snapshot) {
|
||||
if (!this.worker) return;
|
||||
if (!this._isReady) {
|
||||
this._pendingSkinsSnapshot = snapshot;
|
||||
return;
|
||||
}
|
||||
try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: snapshot }); } catch (e) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Карта высот гладкого ландшафта — для game.scene.surfaceY(x,z).
|
||||
* Шлётся один раз (террейн не меняется в Play). Формат:
|
||||
|
||||
@ -99,6 +99,14 @@ let _selfUntouchHandlers = [];
|
||||
let _selfInteractHandlers = [];
|
||||
// Зеркало всех GUI-элементов (синхронизируется main через cmd='guiSnapshot')
|
||||
let _guiIndex = [];
|
||||
// Задача 07: зеркало скинов (синхронизируется через 'skinsSnapshot').
|
||||
// _skinsIndex — все встроенные скины [{slug,name,kind,category,price}].
|
||||
// _unlockedSkins — slug'и доступные игроку. _currentSkin — активный.
|
||||
let _skinsIndex = [];
|
||||
let _unlockedSkins = [];
|
||||
let _currentSkin = null;
|
||||
let _skinChangeHandlers = [];
|
||||
let _skinCoins = 0; // локальная валюта магазина скинов (рублики проекта)
|
||||
// Подписки game.gui.onClick(id, fn)
|
||||
let _guiClickHandlers = {};
|
||||
// Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter)
|
||||
@ -112,10 +120,14 @@ let _billboardClickHandlers = {};
|
||||
// Для GUI-события с реальным id вернуть набор ключей, под которыми
|
||||
// могли быть зарегистрированы handlers: сам id + имя элемента (скрипт
|
||||
// часто подписывается через game.gui.onClick('ИмяКнопки', fn)).
|
||||
function _guiHandlerKeys(id) {
|
||||
function _guiHandlerKeys(id, localId) {
|
||||
const keys = [id];
|
||||
// localId — ref который вернул gui.create() ('_gui_local_N'); скрипт мог
|
||||
// подписаться по нему, если не задавал явный id.
|
||||
if (localId != null && localId !== id) keys.push(localId);
|
||||
// name элемента — скрипт часто подписывается game.gui.onClick('ИмяКнопки', fn).
|
||||
const el = _guiIndex.find(g => g.id === id);
|
||||
if (el && el.name && el.name !== id) keys.push(el.name);
|
||||
if (el && el.name && el.name !== id && !keys.includes(el.name)) keys.push(el.name);
|
||||
return keys;
|
||||
}
|
||||
|
||||
@ -918,6 +930,69 @@ const game = {
|
||||
setSkinVisible(visible) {
|
||||
_send('player.setSkinVisible', { visible: !!visible });
|
||||
},
|
||||
/**
|
||||
* === Задача 07: скины игрока (любая 3D-модель + магазин) ===
|
||||
* Сменить активный скин в Play (без перезагрузки сцены).
|
||||
* game.player.setSkin('squirrel-donut'); // встроенный
|
||||
* game.player.setSkin('character-a'); // человек
|
||||
* Возвращает «локальный Promise» (объект с .then) — реальная смена
|
||||
* асинхронна (грузится .glb). Для большинства игр можно не ждать.
|
||||
*/
|
||||
setSkin(slug) {
|
||||
if (typeof slug !== 'string' || !slug) return;
|
||||
_currentSkin = slug;
|
||||
if (_unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug);
|
||||
_send('player.setSkin', { slug });
|
||||
},
|
||||
/** Дать игроку скин (разблокировать — например после покупки). */
|
||||
unlockSkin(slug) {
|
||||
if (typeof slug !== 'string' || !slug) return;
|
||||
if (_unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug);
|
||||
_send('player.unlockSkin', { slug });
|
||||
},
|
||||
/** Список slug'ов скинов, доступных игроку (разблокированных). */
|
||||
getAvailableSkins() {
|
||||
return _unlockedSkins.slice();
|
||||
},
|
||||
/** Все встроенные скины с метаданными: [{slug,name,kind,category,price}]. */
|
||||
getAllSkins() {
|
||||
return _skinsIndex.map(s => ({ ...s }));
|
||||
},
|
||||
/** Текущий активный скин (slug). */
|
||||
getCurrentSkin() {
|
||||
return _currentSkin;
|
||||
},
|
||||
/** Подписка на смену скина: fn(slug). */
|
||||
onSkinChange(fn) {
|
||||
if (typeof fn === 'function') _skinChangeHandlers.push(fn);
|
||||
},
|
||||
/** Открыть встроенный GUI-магазин скинов (если включён в проекте). */
|
||||
openSkinShop() {
|
||||
_send('player.openSkinShop', {});
|
||||
},
|
||||
/** Закрыть магазин скинов. */
|
||||
closeSkinShop() {
|
||||
_send('player.closeSkinShop', {});
|
||||
},
|
||||
/** Игровая валюта магазина скинов (рублики проекта, ЛОКАЛЬНЫЕ —
|
||||
* не путать с серверной экономикой game.economy). */
|
||||
getSkinCoins() {
|
||||
return _skinCoins;
|
||||
},
|
||||
/** Задать баланс валюты магазина (например стартовые 200). */
|
||||
setSkinCoins(amount) {
|
||||
const n = Number(amount);
|
||||
if (!Number.isFinite(n)) return;
|
||||
_skinCoins = Math.max(0, Math.floor(n));
|
||||
_send('player.setSkinCoins', { amount: _skinCoins });
|
||||
},
|
||||
/** Добавить валюту магазина (награда за что-то). */
|
||||
addSkinCoins(amount) {
|
||||
const n = Number(amount);
|
||||
if (!Number.isFinite(n)) return;
|
||||
_skinCoins = Math.max(0, _skinCoins + Math.floor(n));
|
||||
_send('player.setSkinCoins', { amount: _skinCoins });
|
||||
},
|
||||
/**
|
||||
* Режим камеры: 'first' | 'third' | 'front' | 'sideview'.
|
||||
* 'sideview' нужен для Кубикон Dash — камера фиксируется сбоку,
|
||||
@ -927,6 +1002,22 @@ const game = {
|
||||
if (typeof mode !== 'string') return;
|
||||
_send('player.setCameraMode', { mode });
|
||||
},
|
||||
/** Задача 02: установить дистанцию камеры (для third-person). */
|
||||
setCameraZoom(distance) {
|
||||
const d = Number(distance);
|
||||
if (!Number.isFinite(d)) return;
|
||||
_send('player.setCameraZoom', { distance: d });
|
||||
},
|
||||
/** Задача 02: границы зума колесом мыши. По умолчанию 0.5..32. */
|
||||
setCameraZoomLimits(min, max) {
|
||||
const mn = Number(min), mx = Number(max);
|
||||
if (!Number.isFinite(mn) || !Number.isFinite(mx)) return;
|
||||
_send('player.setCameraZoomLimits', { min: mn, max: mx });
|
||||
},
|
||||
/** Задача 02: Shift-Lock — курсор в центре, корпус лицом к камере. */
|
||||
setShiftLock(on) {
|
||||
_send('player.setShiftLock', { on: !!on });
|
||||
},
|
||||
/**
|
||||
* Присед: уменьшает высоту хитбокса игрока с 1.8 до 0.9 ед.
|
||||
* Используется чтобы пройти под низким потолком.
|
||||
@ -1311,6 +1402,11 @@ const game = {
|
||||
if (typeof id !== 'string' || !id) return;
|
||||
_send('ui.set', { id, text: null });
|
||||
},
|
||||
/** Алиас remove. */
|
||||
delete(id) {
|
||||
if (typeof id !== 'string' || !id) return;
|
||||
_send('ui.set', { id, text: null });
|
||||
},
|
||||
/** Убрать весь HUD. */
|
||||
clear() {
|
||||
_state.score = null;
|
||||
@ -2114,6 +2210,32 @@ const game = {
|
||||
if (typeof id !== 'string' || typeof fn !== 'function') return;
|
||||
(_guiSubmitHandlers[id] = _guiSubmitHandlers[id] || []).push(fn);
|
||||
},
|
||||
/** Задача 03: tween свойства GUI-элемента.
|
||||
* props: { x, y, w, h, rotation, scaleX, scaleY, bgOpacity, textSize,
|
||||
* bgColor, textColor, borderColor } (любое числовое или hex-цвет).
|
||||
* opts: { duration, easing, delay, repeat, reverses, onDone } */
|
||||
tween(id, props, opts) {
|
||||
if (typeof id !== 'string' || !id) return null;
|
||||
if (!props || typeof props !== 'object') return null;
|
||||
opts = opts || {};
|
||||
const tid = ++_tweenSeq;
|
||||
if (typeof opts.onDone === 'function') _tweenCallbacks[tid] = opts.onDone;
|
||||
_send('gui.tween', {
|
||||
tweenId: tid, id, props,
|
||||
duration: Number.isFinite(Number(opts.duration)) ? Number(opts.duration) : 0.5,
|
||||
easing: typeof opts.easing === 'string' ? opts.easing : 'ease',
|
||||
delay: Number.isFinite(Number(opts.delay)) ? Number(opts.delay) : 0,
|
||||
repeat: Number.isFinite(Number(opts.repeat)) ? Number(opts.repeat) : 0,
|
||||
reverses: !!opts.reverses,
|
||||
});
|
||||
return tid;
|
||||
},
|
||||
/** Отменить tween по id (возвращённому из game.gui.tween). */
|
||||
cancelTween(tweenId) {
|
||||
if (!Number.isFinite(tweenId)) return;
|
||||
_send('gui.cancelTween', { tweenId });
|
||||
delete _tweenCallbacks[tweenId];
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Камера — тряска, FOV, привязка к объекту, катсцены (Фаза 5.7).
|
||||
@ -2187,6 +2309,283 @@ const game = {
|
||||
setVisible(visible) {
|
||||
_send('hud.setVisible', { visible: !!visible });
|
||||
},
|
||||
/** Скрыть/показать только хотбар (5 слотов инвентаря снизу).
|
||||
* В играх где инвентарь не нужен (магазин/головоломка/симулятор кликера). */
|
||||
setHotbarVisible(visible) {
|
||||
_send('hud.setHotbarVisible', { visible: !!visible });
|
||||
},
|
||||
/** Скрыть/показать только HP-индикатор (полоска жизней слева сверху). */
|
||||
setHpVisible(visible) {
|
||||
_send('hud.setHpVisible', { visible: !!visible });
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Задача 04: модальные сцены (затемнение + GUI поверх + блок ввода).
|
||||
*
|
||||
* Типовой кейс: boss-fight intro, открытие лутбокса, диалог с NPC, получил питомца.
|
||||
*
|
||||
* const m = game.modal.open({
|
||||
* darken: 0.7, // 0..1 — насколько затемнить (по умолчанию 0.5)
|
||||
* darkenColor: '#000', // цвет затемнения
|
||||
* target: 'scene', // 'scene' (HUD виден) | 'screen' (всё затемнено)
|
||||
* blockInput: true, // блокирует WASD/Space/Ctrl (Esc/Tab/Enter работают)
|
||||
* freezeCamera: true, // камера замирает
|
||||
* fadeIn: 0.4, // секунды до полного затемнения
|
||||
* fadeOut: 0.3,
|
||||
* spotlights: [boss, h1, h2], // 3D-рефы, которые остаются яркими (вырезка mask)
|
||||
* spotlightRadius: 120, // пиксели — радиус «прожектора»
|
||||
* pauseSimulation: false, // ставит весь GameRuntime на паузу (мобы замирают)
|
||||
* muteWorld: false, // приглушает ambient/sfx
|
||||
* cameraOverride: { // фокус камеры на цель
|
||||
* target: boss, distance: 8, height: 3, fov: 60, duration: 0.5,
|
||||
* },
|
||||
* content: { elements: [ // временные GUI поверх модала, удалятся при close
|
||||
* { kind: 'text', x: 50, y: 25, text: 'Франкенслим', textSize: 48,
|
||||
* textStroke: { color: '#000', width: 3 }, textColor: '#fff' },
|
||||
* { kind: 'button', id: 'fight', x: 50, y: 80, w: 20, h: 6, text: 'В бой!' },
|
||||
* ]},
|
||||
* });
|
||||
* game.gui.onClick('fight', () => game.modal.close(m));
|
||||
*
|
||||
* Готовые пресеты:
|
||||
* game.modal.bossIntro(name, hp, refs) — intro босса с HP-баром
|
||||
* game.modal.lootbox(items, onPick) — открытие лутбокса
|
||||
* game.modal.dialog(npcName, lines, onDone) — диалог с NPC построчно
|
||||
* game.modal.confirmation(title, body, onYes, onNo) — Да/Нет
|
||||
*
|
||||
* Только ОДИН модал одновременно. Повторный open мгновенно закрывает предыдущий.
|
||||
*/
|
||||
modal: {
|
||||
_localSeq: 0,
|
||||
_localToReal: new Map(), // localId → реальный modalId (приходит в modalOpened)
|
||||
_onCloseFns: [],
|
||||
open(opts) {
|
||||
opts = opts || {};
|
||||
const localId = ++this._localSeq;
|
||||
const replyId = '_mopen_' + localId;
|
||||
_send('modal.open', { opts, replyId });
|
||||
// Возвращаем локальный id — реальный придёт асинхронно через modalOpened-event
|
||||
return localId;
|
||||
},
|
||||
close(modalId) {
|
||||
// Резолвим локальный id → реальный. Если modalId — локальное число, но
|
||||
// реальный ещё не пришёл (гонка modalOpened-event), шлём null: модал
|
||||
// одиночный, null закрывает активный. Передавать локальный id нельзя —
|
||||
// ModalManager.close сверяет его со своим _state.id и молча игнорит.
|
||||
let real = null;
|
||||
if (typeof modalId === 'number') {
|
||||
real = this._localToReal.has(modalId) ? this._localToReal.get(modalId) : null;
|
||||
} else if (modalId != null) {
|
||||
real = modalId; // уже реальный id (строка/число от runtime)
|
||||
}
|
||||
_send('modal.close', { modalId: real });
|
||||
},
|
||||
update(modalId, patch) {
|
||||
let real = null;
|
||||
if (typeof modalId === 'number') {
|
||||
real = this._localToReal.has(modalId) ? this._localToReal.get(modalId) : null;
|
||||
} else if (modalId != null) {
|
||||
real = modalId;
|
||||
}
|
||||
_send('modal.update', { modalId: real, patch: patch || {} });
|
||||
},
|
||||
isOpen() { return !!this._isOpenLocal; },
|
||||
onClose(fn) {
|
||||
if (typeof fn === 'function') this._onCloseFns.push(fn);
|
||||
},
|
||||
|
||||
// === Пресеты ===
|
||||
/** Intro босса с именем + HP-баром + кнопкой «В бой!» через delay секунд. */
|
||||
bossIntro(name, hp, refs, opts) {
|
||||
opts = opts || {};
|
||||
const startBtnDelay = Number.isFinite(opts.startBtnDelay) ? opts.startBtnDelay : 2;
|
||||
const buttonText = opts.buttonText || 'В бой!';
|
||||
const onStart = opts.onStart;
|
||||
const elements = [
|
||||
{ kind: 'text', id: '_boss_name', x: 50, y: 22, w: 60, h: 8, anchor: 'center',
|
||||
text: String(name || 'Босс'), textSize: 48, fontWeight: 900, textColor: '#ffffff',
|
||||
textStroke: { color: '#000', width: 3 }, bgColor: 'transparent', bgOpacity: 0,
|
||||
animationPreset: 'glow' },
|
||||
{ kind: 'text', id: '_boss_hp', x: 50, y: 30, w: 30, h: 6, anchor: 'center',
|
||||
text: '❤ ' + hp + ' / ' + hp, textSize: 28, fontWeight: 800, textColor: '#22ff66',
|
||||
textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0 },
|
||||
];
|
||||
const m = this.open({
|
||||
darken: 0.7, target: 'scene',
|
||||
blockInput: true, freezeCamera: true,
|
||||
spotlights: Array.isArray(refs) ? refs : (refs ? [refs] : []),
|
||||
cameraOverride: refs ? { target: Array.isArray(refs) ? refs[0] : refs,
|
||||
distance: 8, height: 3, fov: 60, duration: 0.5 } : null,
|
||||
content: { elements },
|
||||
});
|
||||
const _modal = this;
|
||||
const _afterTid = ++_timerSeq;
|
||||
_timers.push({ id: _afterTid, fn: () => {
|
||||
_send('gui.create', { type: 'button', opts: {
|
||||
id: '_boss_start', x: 50, y: 78, w: 20, h: 7, anchor: 'center',
|
||||
text: buttonText,
|
||||
bgGradient: { stops: ['#ff5454','#a00000'], angle: 90 },
|
||||
borderColor: '#000', borderWidth: 3, borderRadius: 14,
|
||||
textColor: '#fff', textSize: 22, fontWeight: 900,
|
||||
textStroke: { color: '#000', width: 2 },
|
||||
hover: { scale: 1.08, brightness: 1.2, duration: 0.15 },
|
||||
active: { scale: 0.94, duration: 0.08 },
|
||||
animationPreset: 'pulse',
|
||||
}, localRef: '_boss_start' });
|
||||
let _started = false;
|
||||
_guiClickHandlers['_boss_start'] = [() => {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
delete _guiClickHandlers['_boss_start'];
|
||||
_modal.close(m);
|
||||
if (typeof onStart === 'function') { try { onStart(); } catch (e) {} }
|
||||
}];
|
||||
}, delay: startBtnDelay, elapsed: 0, repeat: false });
|
||||
return m;
|
||||
},
|
||||
/** Открытие лутбокса. items: [{name, color, icon, rarity}]. onPick(item). */
|
||||
lootbox(items, onPick) {
|
||||
items = Array.isArray(items) ? items.slice(0, 5) : [];
|
||||
const elements = [
|
||||
{ kind: 'frame', id: '_lb_bg', x: 50, y: 50, w: 70, h: 50, anchor: 'center',
|
||||
bgGradient: { stops: ['#1a1a3a','#0a0a1a'], angle: 135 },
|
||||
borderColor: '#ffd700', borderWidth: 3, borderRadius: 18 },
|
||||
{ kind: 'text', id: '_lb_title', x: 50, y: 28, w: 60, h: 8, anchor: 'center',
|
||||
text: '✨ Лутбокс ✨', textSize: 32, fontWeight: 900, textColor: '#ffd700',
|
||||
textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0,
|
||||
animationPreset: 'glow' },
|
||||
];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i];
|
||||
const x = 50 + (i - (items.length - 1) / 2) * 13;
|
||||
elements.push({
|
||||
kind: 'button', id: '_lb_item_' + i,
|
||||
x: x, y: 50, w: 11, h: 16, anchor: 'center',
|
||||
text: (it.icon || '*') + '\\n' + (it.name || 'Приз'),
|
||||
bgColor: it.color || '#3a3a5a', borderRadius: 12,
|
||||
borderColor: '#ffd700', borderWidth: 2,
|
||||
textColor: '#fff', textSize: 14, fontWeight: 700,
|
||||
hover: { scale: 1.1, brightness: 1.3, duration: 0.15 },
|
||||
active: { scale: 0.94, duration: 0.08 },
|
||||
animationPreset: 'pulse',
|
||||
});
|
||||
}
|
||||
const m = this.open({
|
||||
darken: 0.6, target: 'screen', blockInput: true,
|
||||
content: { elements },
|
||||
});
|
||||
const _modal = this;
|
||||
// _picked: после первого выбора остальные карточки не должны срабатывать,
|
||||
// пока модал доигрывает fadeOut (иначе onPick зовётся несколько раз).
|
||||
let _picked = false;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const id = '_lb_item_' + i;
|
||||
const it = items[i];
|
||||
_guiClickHandlers[id] = [() => {
|
||||
if (_picked) return;
|
||||
_picked = true;
|
||||
for (let j = 0; j < items.length; j++) delete _guiClickHandlers['_lb_item_' + j];
|
||||
_modal.close(m);
|
||||
if (typeof onPick === 'function') { try { onPick(it); } catch (e) {} }
|
||||
}];
|
||||
}
|
||||
return m;
|
||||
},
|
||||
/** Диалог с NPC построчно. lines: ['Привет', 'Как дела?']. onDone() в конце. */
|
||||
dialog(npcName, lines, onDone) {
|
||||
lines = Array.isArray(lines) ? lines : [String(lines || '')];
|
||||
let idx = 0;
|
||||
const elements = [
|
||||
{ kind: 'frame', id: '_dlg_bg', x: 50, y: 80, w: 80, h: 25, anchor: 'center',
|
||||
bgGradient: { stops: ['#1a1a3a','#0a0a1a'], angle: 90 },
|
||||
borderColor: '#fff', borderWidth: 2, borderRadius: 12 },
|
||||
{ kind: 'text', id: '_dlg_name', x: 14, y: 71, w: 22, h: 6, anchor: 'center',
|
||||
text: String(npcName || 'NPC'), textSize: 20, fontWeight: 900,
|
||||
textColor: '#ffd700', textStroke: { color: '#000', width: 2 },
|
||||
bgColor: 'transparent', bgOpacity: 0 },
|
||||
{ kind: 'text', id: '_dlg_line', x: 50, y: 80, w: 76, h: 12, anchor: 'center',
|
||||
text: lines[0], textSize: 22, fontWeight: 600, textColor: '#fff',
|
||||
textAlign: 'left', bgColor: 'transparent', bgOpacity: 0 },
|
||||
{ kind: 'button', id: '_dlg_next', x: 88, y: 88, w: 8, h: 6, anchor: 'center',
|
||||
// На ПОСЛЕДНЕЙ строке кнопка сразу показывает галочку «завершить»,
|
||||
// на остальных — стрелку «дальше».
|
||||
text: lines.length > 1 ? '▶' : '✓', textSize: 22, fontWeight: 900,
|
||||
bgColor: '#ffd700', textColor: '#000', borderRadius: 8,
|
||||
borderColor: '#000', borderWidth: 2,
|
||||
hover: { scale: 1.1, brightness: 1.2 }, active: { scale: 0.92 },
|
||||
animationPreset: 'pulse' },
|
||||
];
|
||||
const m = this.open({
|
||||
darken: 0.5, target: 'scene', blockInput: true, freezeCamera: true,
|
||||
content: { elements },
|
||||
});
|
||||
const _modal = this;
|
||||
// _done защищает от повторного срабатывания: game.modal.close() доигрывает
|
||||
// fadeOut асинхронно, кнопка остаётся кликабельной — без guard'а каждый
|
||||
// лишний клик снова звал onDone (баг «Диалог завершён ×7»).
|
||||
let _done = false;
|
||||
_guiClickHandlers['_dlg_next'] = [() => {
|
||||
if (_done) return;
|
||||
idx++;
|
||||
if (idx < lines.length) {
|
||||
_send('gui.update', { id: '_dlg_line', patch: { text: lines[idx] } });
|
||||
// Последняя строка достигнута — превращаем «дальше» в «завершить».
|
||||
if (idx === lines.length - 1) {
|
||||
_send('gui.update', { id: '_dlg_next', patch: { text: '✓' } });
|
||||
}
|
||||
} else {
|
||||
_done = true;
|
||||
delete _guiClickHandlers['_dlg_next']; // снимаем handler сразу
|
||||
_modal.close(m);
|
||||
if (typeof onDone === 'function') { try { onDone(); } catch (e) {} }
|
||||
}
|
||||
}];
|
||||
return m;
|
||||
},
|
||||
/** Подтверждение Да/Нет. */
|
||||
confirmation(title, body, onYes, onNo) {
|
||||
const elements = [
|
||||
{ kind: 'frame', id: '_cf_bg', x: 50, y: 50, w: 50, h: 30, anchor: 'center',
|
||||
bgGradient: { stops: ['#2a2a4a','#0a0a1a'], angle: 135 },
|
||||
borderColor: '#fff', borderWidth: 2, borderRadius: 14 },
|
||||
{ kind: 'text', id: '_cf_title', x: 50, y: 41, w: 44, h: 6, anchor: 'center',
|
||||
text: String(title || 'Подтверждение'), textSize: 28, fontWeight: 900,
|
||||
textColor: '#fff', textStroke: { color: '#000', width: 2 },
|
||||
bgColor: 'transparent', bgOpacity: 0 },
|
||||
{ kind: 'text', id: '_cf_body', x: 50, y: 50, w: 44, h: 8, anchor: 'center',
|
||||
text: String(body || ''), textSize: 16, fontWeight: 500,
|
||||
textColor: '#cfd0e8', bgColor: 'transparent', bgOpacity: 0 },
|
||||
{ kind: 'button', id: '_cf_yes', x: 38, y: 60, w: 14, h: 6, anchor: 'center',
|
||||
text: 'Да', bgGradient: { stops: ['#22ff66','#0a803a'], angle: 90 },
|
||||
borderColor: '#000', borderWidth: 2, borderRadius: 10,
|
||||
textColor: '#fff', textSize: 18, fontWeight: 900,
|
||||
hover: { scale: 1.08, brightness: 1.2 }, active: { scale: 0.94 } },
|
||||
{ kind: 'button', id: '_cf_no', x: 62, y: 60, w: 14, h: 6, anchor: 'center',
|
||||
text: 'Нет', bgGradient: { stops: ['#ff5454','#a00000'], angle: 90 },
|
||||
borderColor: '#000', borderWidth: 2, borderRadius: 10,
|
||||
textColor: '#fff', textSize: 18, fontWeight: 900,
|
||||
hover: { scale: 1.08, brightness: 1.2 }, active: { scale: 0.94 } },
|
||||
];
|
||||
const m = this.open({
|
||||
darken: 0.6, target: 'screen', blockInput: true,
|
||||
content: { elements },
|
||||
});
|
||||
const _modal = this;
|
||||
// _answered: одна кнопка снимает обе (Да/Нет) сразу, чтобы пока модал
|
||||
// доигрывает fadeOut нельзя было нажать вторую и продублировать ответ.
|
||||
let _answered = false;
|
||||
const _finish = (cb) => {
|
||||
if (_answered) return;
|
||||
_answered = true;
|
||||
delete _guiClickHandlers['_cf_yes'];
|
||||
delete _guiClickHandlers['_cf_no'];
|
||||
_modal.close(m);
|
||||
if (typeof cb === 'function') { try { cb(); } catch (e) {} }
|
||||
};
|
||||
_guiClickHandlers['_cf_yes'] = [() => _finish(onYes)];
|
||||
_guiClickHandlers['_cf_no'] = [() => _finish(onNo)];
|
||||
return m;
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Инвентарь игрока (Фаза 4.2) — 5 слотов hot-bar.
|
||||
@ -2694,17 +3093,38 @@ const game = {
|
||||
* opts — { template?, face?, content?, elements? }
|
||||
*/
|
||||
set(ref, opts) {
|
||||
if (!ref || typeof opts !== 'object' || opts == null) return;
|
||||
_send('billboard.set', { ref, ...opts });
|
||||
const refStr = _normRef(ref);
|
||||
if (!refStr || typeof opts !== 'object' || opts == null) return;
|
||||
_send('billboard.set', { ref: refStr, ...opts });
|
||||
},
|
||||
/**
|
||||
* Частичное обновление content. Самое частое — после клика поменять
|
||||
* sub-строку и цену.
|
||||
* patch — частичный content: { sub, price, title, icon, gradient }
|
||||
* Частичное обновление таблички.
|
||||
* Две формы:
|
||||
* 1) update(ref, patch)
|
||||
* patch — частичный content: { sub, price, title, icon, gradient }
|
||||
* Применяется к content пресета (shop-item/banner/sign).
|
||||
* 2) update(ref, elementId, patch)
|
||||
* Обновляет конкретный элемент по id (только для template:'card'
|
||||
* или elements-режима). Например: update(ref, 'buy', { text: '$15,000' }).
|
||||
* Для пресетов: 'title'|'sub'|'price'|'icon'|'gradient' тоже
|
||||
* работают как ключи content.
|
||||
*/
|
||||
update(ref, patch) {
|
||||
if (!ref || typeof patch !== 'object' || patch == null) return;
|
||||
_send('billboard.update', { ref, patch });
|
||||
update(ref, secondArg, thirdArg) {
|
||||
const refStr = _normRef(ref);
|
||||
if (!refStr) return;
|
||||
// 3-аргументная форма: update(ref, elementId, patch)
|
||||
if (typeof secondArg === 'string' && typeof thirdArg === 'object' && thirdArg !== null) {
|
||||
_send('billboard.update', {
|
||||
ref: refStr,
|
||||
elementId: secondArg,
|
||||
patch: thirdArg,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 2-аргументная форма: update(ref, patch)
|
||||
if (typeof secondArg === 'object' && secondArg !== null) {
|
||||
_send('billboard.update', { ref: refStr, patch: secondArg });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Подписаться на клик по кнопке таблички (shop-item: buttonId='buy';
|
||||
@ -2715,18 +3135,18 @@ const game = {
|
||||
*/
|
||||
onClick(ref, buttonId, fn) {
|
||||
if (typeof fn !== 'function') {
|
||||
// Поддержка вызова с 2 аргументами — buttonId по умолчанию 'buy'.
|
||||
fn = buttonId;
|
||||
buttonId = 'buy';
|
||||
}
|
||||
if (!ref || typeof fn !== 'function') return;
|
||||
// Принудительная нормализация ref в plain-string: Instance-Proxy
|
||||
// не сериализуется через postMessage (DataCloneError).
|
||||
const refStr = _normRef(ref);
|
||||
if (!refStr || typeof fn !== 'function') return;
|
||||
const bid = String(buttonId || 'buy');
|
||||
const key = ref + ':' + bid;
|
||||
const key = refStr + ':' + bid;
|
||||
if (!_billboardClickHandlers[key]) _billboardClickHandlers[key] = [];
|
||||
_billboardClickHandlers[key].push(fn);
|
||||
// Уведомляем main о подписке (чтобы он зарегистрировал hit-listener
|
||||
// в BillboardUiManager и слал нам billboardClick события).
|
||||
_send('billboard.onClick', { ref, buttonId: bid });
|
||||
_send('billboard.onClick', { ref: refStr, buttonId: bid });
|
||||
},
|
||||
},
|
||||
/**
|
||||
@ -2743,6 +3163,19 @@ const game = {
|
||||
if (mode !== 'ui' && mode !== 'game') return;
|
||||
_send('input.setCursorMode', { mode });
|
||||
},
|
||||
/** Задача 02: Roblox-style MouseBehavior.
|
||||
* 'default' — свободный курсор (по умолчанию в third-person).
|
||||
* 'lockcenter' — pointer-lock (мышь крутит камеру, курсор скрыт).
|
||||
* 'lockcurrent' — pointer-lock без скрытия курсора (визуально-default).
|
||||
*/
|
||||
setMouseBehavior(mode) {
|
||||
if (mode !== 'default' && mode !== 'lockcenter' && mode !== 'lockcurrent') return;
|
||||
_send('input.setMouseBehavior', { mode });
|
||||
},
|
||||
/** Задача 02: показать/скрыть иконку курсора. */
|
||||
setMouseIconVisible(visible) {
|
||||
_send('input.setMouseIconVisible', { visible: !!visible });
|
||||
},
|
||||
/**
|
||||
* Подписаться на движение мыши в UI-режиме.
|
||||
* fn(x, y) — нормализованные координаты [0..1] относительно канваса.
|
||||
@ -2791,6 +3224,25 @@ const game = {
|
||||
* game.save.merge('progress', { increment: { attempts: 1 } });
|
||||
* game.save.leaderboard('progress', 'gold', 'desc', fn(entries) {...});
|
||||
*/
|
||||
/** Окружение: небо, туман, время суток. */
|
||||
environment: {
|
||||
/** Установить цвет неба (clearColor сцены). hex или 'r,g,b'. */
|
||||
setSkyColor(color) {
|
||||
if (typeof color !== 'string') return;
|
||||
_send('environment.setSkyColor', { color });
|
||||
},
|
||||
/** Установить туман: {enabled, color, density}. */
|
||||
setFog(opts) {
|
||||
if (typeof opts !== 'object' || !opts) return;
|
||||
_send('environment.setFog', opts);
|
||||
},
|
||||
/** Установить время суток (часы, 0..24). */
|
||||
setTimeOfDay(hours) {
|
||||
const h = Number(hours);
|
||||
if (!Number.isFinite(h)) return;
|
||||
_send('environment.setTimeOfDay', { hours: h });
|
||||
},
|
||||
},
|
||||
save: {
|
||||
/** Прочитать namespace. fn(data) — data это сохранённый объект или null. */
|
||||
get(namespace, fn) {
|
||||
@ -3013,14 +3465,10 @@ self.onmessage = (e) => {
|
||||
if (payload.selfPosition) _selfPosition = payload.selfPosition;
|
||||
_selfApi = _buildSelfApi();
|
||||
}
|
||||
// modules: { 'имя': 'код' } — все скрипты проекта, доступные через game.require
|
||||
if (payload && payload.modules && typeof payload.modules === 'object') {
|
||||
_moduleCode = payload.modules;
|
||||
}
|
||||
try {
|
||||
// exports передаём всегда — скрипт может быть и модулем (пишет в
|
||||
// exports), и обычным скриптом (игнорирует его). Без этого
|
||||
// скрипт-модуль падает с 'exports is not defined' при прямом запуске.
|
||||
const exportsObj = {};
|
||||
const userFn = new Function('game', 'exports', '"use strict";\\n' + payload.code);
|
||||
userFn(game, exportsObj);
|
||||
@ -3259,17 +3707,27 @@ self.onmessage = (e) => {
|
||||
for (const fn of arr) _safeCall(fn, payload.data, 'onMessage:' + name);
|
||||
} else if (t === 'guiClick') {
|
||||
const id = String(payload.id || '');
|
||||
// Собираем handlers и по id, и по имени элемента — скрипт
|
||||
// мог подписаться через game.gui.onClick('ИмяКнопки', fn).
|
||||
for (const key of _guiHandlerKeys(id)) {
|
||||
const arr = _guiClickHandlers[key] || [];
|
||||
const localId = payload.localId != null ? String(payload.localId) : null;
|
||||
// Собираем handlers по id, по локальному ref и по имени элемента —
|
||||
// скрипт мог подписаться любым из этих ключей.
|
||||
// _matched защищает от двойного вызова если несколько ключей ведут
|
||||
// к одному и тому же массиву handlers.
|
||||
const _matched = new Set();
|
||||
for (const key of _guiHandlerKeys(id, localId)) {
|
||||
const arr = _guiClickHandlers[key];
|
||||
if (!arr || _matched.has(arr)) continue;
|
||||
_matched.add(arr);
|
||||
for (const fn of arr) _safeCall(fn, { id }, 'gui.onClick:' + key);
|
||||
}
|
||||
} else if (t === 'guiSubmit') {
|
||||
const id = String(payload.id || '');
|
||||
const localId = payload.localId != null ? String(payload.localId) : null;
|
||||
const val = payload.value != null ? String(payload.value) : '';
|
||||
for (const key of _guiHandlerKeys(id)) {
|
||||
const arr = _guiSubmitHandlers[key] || [];
|
||||
const _matched = new Set();
|
||||
for (const key of _guiHandlerKeys(id, localId)) {
|
||||
const arr = _guiSubmitHandlers[key];
|
||||
if (!arr || _matched.has(arr)) continue;
|
||||
_matched.add(arr);
|
||||
for (const fn of arr) _safeCall(fn, val, 'gui.onSubmit:' + key);
|
||||
}
|
||||
} else if (t === 'billboardClick') {
|
||||
@ -3291,6 +3749,41 @@ self.onmessage = (e) => {
|
||||
for (const fn of arr) _safeCall(fn, { ref: realRef, button },
|
||||
'billboard.onClick:' + key);
|
||||
}
|
||||
} else if (t === 'modalOpened') {
|
||||
// Задача 04: реальный modalId от runtime. worker сразу вернул скрипту
|
||||
// локальный id (чтобы он мог его сохранить и звать close/update); здесь
|
||||
// запоминаем маппинг local→real, иначе close(m) уходит с локальным id
|
||||
// и ModalManager.close его не узнаёт (баг «закрывается только по Esc»).
|
||||
try {
|
||||
const mm = (typeof game !== 'undefined') && game.modal;
|
||||
if (mm && payload && payload.replyId) {
|
||||
const localId = Number(String(payload.replyId).replace(/^_mopen_/, ''));
|
||||
if (Number.isFinite(localId) && payload.modalId != null) {
|
||||
mm._localToReal.set(localId, payload.modalId);
|
||||
mm._isOpenLocal = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
} else if (t === 'modalClosed') {
|
||||
// Модал закрыт (fadeOut доиграл) — снимаем флаг и зовём onClose-подписчиков.
|
||||
try {
|
||||
const mm = (typeof game !== 'undefined') && game.modal;
|
||||
if (mm) {
|
||||
mm._isOpenLocal = false;
|
||||
const cbs = mm._onCloseFns || [];
|
||||
for (const fn of cbs) _safeCall(fn, payload && payload.id, 'modal.onClose');
|
||||
}
|
||||
} catch (e) {}
|
||||
} else if (t === 'skinChanged') {
|
||||
// Задача 07: скин игрока сменился — обновляем зеркало и зовём подписчиков.
|
||||
const slug = payload && payload.slug;
|
||||
if (slug) {
|
||||
_currentSkin = slug;
|
||||
for (const fn of _skinChangeHandlers) _safeCall(fn, slug, 'player.onSkinChange');
|
||||
}
|
||||
} else if (t === 'skinUnlocked') {
|
||||
const slug = payload && payload.slug;
|
||||
if (slug && _unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug);
|
||||
}
|
||||
} else if (cmd === 'sceneSnapshot') {
|
||||
// payload: { blocks, models, primitives } — массивы { ref, x, y, z, name?, type? }
|
||||
@ -3306,6 +3799,14 @@ self.onmessage = (e) => {
|
||||
} else if (cmd === 'guiSnapshot') {
|
||||
// payload: массив всех GUI-элементов (для game.gui.find/get/all)
|
||||
_guiIndex = Array.isArray(payload) ? payload : [];
|
||||
} else if (cmd === 'skinsSnapshot') {
|
||||
// Задача 07: payload { all:[{slug,name,kind,category,price}], unlocked:[slug], current }
|
||||
if (payload && typeof payload === 'object') {
|
||||
_skinsIndex = Array.isArray(payload.all) ? payload.all : [];
|
||||
_unlockedSkins = Array.isArray(payload.unlocked) ? payload.unlocked.slice() : [];
|
||||
_currentSkin = payload.current || _currentSkin;
|
||||
if (Number.isFinite(payload.coins)) _skinCoins = payload.coins;
|
||||
}
|
||||
} else if (cmd === 'dataSnapshot') {
|
||||
// payload: { ref: { key: value } } — атрибуты всех объектов
|
||||
_dataIndex = payload && typeof payload === 'object' ? payload : {};
|
||||
@ -3403,6 +3904,8 @@ _send('boot', null);
|
||||
* Создаёт URL Worker-кода для new Worker(url).
|
||||
*/
|
||||
export function getWorkerSourceUrl() {
|
||||
const blob = new Blob([SOURCE], { type: 'application/javascript' });
|
||||
// type: 'application/javascript; charset=utf-8' — без charset Chrome иногда
|
||||
// декодирует blob как Latin-1, и surrogate pair (эмодзи) ломаются в SyntaxError.
|
||||
const blob = new Blob([SOURCE], { type: 'application/javascript; charset=utf-8' });
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
@ -13,6 +13,8 @@ import KubikonLeaderboard, { formatTimeMs } from '../components/KubikonLeaderboa
|
||||
import KubikonPerfOverlay from '../components/KubikonPerfOverlay/KubikonPerfOverlay';
|
||||
import Hotbar from '../editor/Hotbar';
|
||||
import PlayerHud from '../editor/PlayerHud';
|
||||
import ModalOverlay from '../editor/ModalOverlay';
|
||||
import SkinShopOverlay from '../editor/SkinShopOverlay';
|
||||
import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton';
|
||||
import KubikonChatPanel from './KubikonChatPanel';
|
||||
import { useAuth } from '../auth/AuthContext.jsx';
|
||||
@ -125,6 +127,9 @@ const KubikonPlayer = () => {
|
||||
const [hp, setHp] = useState({ hp: 100, maxHp: 100 });
|
||||
// Скрипт через game.hud.setVisible(false) полностью скрывает стандартный HUD.
|
||||
const [stdHudVisible, setStdHudVisible] = useState(true);
|
||||
// Задача 03: отдельный контроль хотбара/HP — для игр без инвентаря/жизней.
|
||||
const [hotbarVisible, setHotbarVisible] = useState(true);
|
||||
const [hpVisible, setHpVisible] = useState(true);
|
||||
const [ammo, setAmmo] = useState(null);
|
||||
const [hurtFlash, setHurtFlash] = useState(0);
|
||||
const [inventoryState, setInventoryState] = useState({ slots: [], activeIndex: 0 });
|
||||
@ -320,6 +325,9 @@ const KubikonPlayer = () => {
|
||||
if (projectId) scene.setCurrentProjectId(projectId);
|
||||
// game.hud.setVisible(false) скроет HP-бар/hotbar для своего меню
|
||||
scene.setOnStdHudVisibilityChange?.((v) => setStdHudVisible(v));
|
||||
// Задача 03: отдельные подписки
|
||||
scene.setOnHotbarVisibilityChange?.((v) => setHotbarVisible(v));
|
||||
scene.setOnHpVisibilityChange?.((v) => setHpVisible(v));
|
||||
|
||||
// Колбэки HUD
|
||||
scene.setOnPlayerHpChange?.((h) => {
|
||||
@ -909,6 +917,10 @@ const KubikonPlayer = () => {
|
||||
{/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */}
|
||||
{!loading && (
|
||||
<>
|
||||
{/* Задача 04: модал-overlay (затемнение + spotlight mask). */}
|
||||
<ModalOverlay scene={sceneRef.current} />
|
||||
{/* Задача 07: встроенный магазин скинов (B / API). */}
|
||||
<SkinShopOverlay scene={sceneRef.current} />
|
||||
{/* HUD: на мобиле уменьшаем и сдвигаем компактно. */}
|
||||
{isTouch ? (
|
||||
<>
|
||||
@ -919,7 +931,7 @@ const KubikonPlayer = () => {
|
||||
pointerEvents: 'none', zIndex: 30,
|
||||
}}>
|
||||
<PlayerHud
|
||||
visible={stdHudVisible}
|
||||
visible={stdHudVisible && hpVisible}
|
||||
hp={hp.hp}
|
||||
maxHp={hp.maxHp}
|
||||
ammo={null}
|
||||
@ -936,9 +948,9 @@ const KubikonPlayer = () => {
|
||||
)}
|
||||
{/* Hotbar — только если в инвентаре есть хоть
|
||||
один предмет. Пустой инвентарь не показываем. */}
|
||||
{stdHudVisible && (inventoryState.slots || []).some(s => s) && (
|
||||
{stdHudVisible && hotbarVisible && (inventoryState.slots || []).some(s => s) && (
|
||||
<Hotbar
|
||||
visible={stdHudVisible}
|
||||
visible={stdHudVisible && hotbarVisible}
|
||||
mobileMode
|
||||
slots={inventoryState.slots}
|
||||
activeIndex={inventoryState.activeIndex}
|
||||
@ -949,15 +961,15 @@ const KubikonPlayer = () => {
|
||||
) : (
|
||||
<>
|
||||
<PlayerHud
|
||||
visible={stdHudVisible}
|
||||
visible={stdHudVisible && hpVisible}
|
||||
hp={hp.hp}
|
||||
maxHp={hp.maxHp}
|
||||
ammo={ammo}
|
||||
damaged={Date.now() - hurtFlash < 350}
|
||||
/>
|
||||
{stdHudVisible && (inventoryState.slots || []).some(s => s) && (
|
||||
{stdHudVisible && hotbarVisible && (inventoryState.slots || []).some(s => s) && (
|
||||
<Hotbar
|
||||
visible={stdHudVisible}
|
||||
visible={stdHudVisible && hotbarVisible}
|
||||
slots={inventoryState.slots}
|
||||
activeIndex={inventoryState.activeIndex}
|
||||
onSelect={(i) => sceneRef.current?.setActiveInventorySlot?.(i)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user