feat(week4): модальные сцены, кастомные скины игрока и вики-гайды по 4 играм
Some checks failed
CI / Lint (pull_request) Failing after 1m10s
CI / Build (pull_request) Successful in 2m2s
CI / Secret scan (pull_request) Successful in 2m36s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped

Задача 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:
МИН 2026-05-30 00:50:56 +03:00
parent d6cc986aa9
commit 42be04def9
21 changed files with 7124 additions and 2602 deletions

View File

@ -394,31 +394,45 @@ const LessonPage = ({ game, navigate }) => {
const [state, setState] = useState('idle'); const [state, setState] = useState('idle');
// Создаёт НОВУЮ копию игры-урока на текущем пользователе и // Создаёт НОВУЮ копию игры-урока на текущем пользователе и
// открывает её в редакторе. Исходник (билдер) при этом цел. // открывает её в редакторе. Оригинал при этом ВСЕГДА цел.
const openInEditor = async () => { const openInEditor = async () => {
const userId = getCurrentUserId(); const userId = getCurrentUserId();
if (!userId) { if (!userId) {
setState('error'); setState('error');
return; return;
} }
const project = buildGameProject(game.id);
if (!project) { setState('error'); return; }
setState('creating'); setState('creating');
try { 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, { const res = await Kubikon3DApi.createProject(userId, {
user_id: userId, user_id: userId,
title: 'Урок: ' + game.title, title: 'Моя копия: ' + game.title,
description: 'Игра-урок из вики Рублокса. Можешь свободно её менять.', description: 'Игра-урок из вики Рублокса. Это твоя копия — меняй как хочешь, оригинал не пострадает.',
genre: 'other', genre: 'other',
thumbnail: '', thumbnail: '',
is_public: false, is_public: false,
project_data: JSON.stringify(project), project_data: projectDataStr,
}); });
const newId = res.data && res.data.id; const newId = res.data && res.data.id;
if (newId) navigate('/edit/' + newId); if (newId) navigate('/edit/' + newId);
else setState('error'); else setState('error');
} catch (e) { } catch (e) {
console.error('[LessonPage] createProject error:', e); console.error('[LessonPage] openInEditor error:', e);
setState('error'); setState('error');
} }
}; };
@ -441,8 +455,8 @@ const LessonPage = ({ game, navigate }) => {
<div className="lessonOpen"> <div className="lessonOpen">
<div className="lessonOpen__text"> <div className="lessonOpen__text">
<b>Хочешь сразу посмотреть готовую игру?</b><br /> <b>Хочешь сразу посмотреть готовую игру?</b><br />
Открой её в редакторе создастся <b>твоя копия</b>, можешь Открой её в редакторе создастся <b>твоя личная копия</b>.
свободно её менять и разбираться, как всё устроено. Меняй её как хочешь, нажимай «Играть» <b>оригинал не пострадает</b>.
</div> </div>
<button <button
className="lessonOpen__btn" className="lessonOpen__btn"
@ -451,7 +465,7 @@ const LessonPage = ({ game, navigate }) => {
> >
{state === 'creating' {state === 'creating'
? 'Создаём копию…' ? 'Создаём копию…'
: <><Icon name="play" size={15} /> Открыть игру в редакторе</>} : <><Icon name="play" size={15} /> Открыть мою копию в редакторе</>}
</button> </button>
</div> </div>
{state === 'error' && ( {state === 'error' && (

View File

@ -30,6 +30,8 @@ export const GAME_GROUPS = [
hint: 'Комбинации механик, GUI, состояние игры, NPC-логика.' }, hint: 'Комбинации механик, GUI, состояние игры, NPC-логика.' },
{ id: 'g4', title: 'Группа 4 — Сложные', stars: 3, { id: 'g4', title: 'Группа 4 — Сложные', stars: 3,
hint: 'Полные игры, мультиплеер, продвинутые системы.' }, hint: 'Полные игры, мультиплеер, продвинутые системы.' },
{ id: 'g5', title: 'Разбор готовых игр', stars: 2,
hint: 'Настоящие игры из студии по косточкам: камера и таблички, анимации интерфейса, модальные сцены и кастомные скины. У каждой — кнопка «Открыть оригинал в редакторе».' },
]; ];
export const GAMES = [ export const GAMES = [
@ -292,4 +294,28 @@ export const GAMES = [
desc: 'Гайд: как придумать и собрать собственную игру с нуля.', desc: 'Гайд: как придумать и собрать собственную игру с нуля.',
mechanics: ['проектирование игры', 'все механики вместе'], mechanics: ['проектирование игры', 'все механики вместе'],
ready: false }, 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 },
]; ];

View File

@ -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. */ /** Есть ли готовый текст урока для игры с таким id. */

View File

@ -21,11 +21,16 @@ import Icon from './Icon';
*/ */
function _optsEqual(a, b) { function _optsEqual(a, b) {
// Расширенный compare учитываем все поля стилизации.
if (a === b) return true; if (a === b) return true;
if (!a || !b) return false; 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 = { const DEFAULT_LABEL_STYLE = {
fontSize: 18, fontSize: 18,
fontWeight: 700, fontWeight: 700,
@ -137,32 +142,59 @@ function GameHud({ visible, hudRef }) {
{otherIds.map((id, i) => { {otherIds.map((id, i) => {
const lbl = labels[id]; const lbl = labels[id];
const o = lbl.opts || {}; 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 = { const style = {
...DEFAULT_LABEL_STYLE, fontFamily: '"Roboto Condensed", system-ui, sans-serif',
fontSize: o.size || DEFAULT_LABEL_STYLE.fontSize, fontWeight: o.bold ? 800 : DEFAULT_LABEL_STYLE.fontWeight,
fontSize: o.textSize || o.size || DEFAULT_LABEL_STYLE.fontSize,
color: o.color || DEFAULT_LABEL_STYLE.color, color: o.color || DEFAULT_LABEL_STYLE.color,
background: 'rgba(15,12,8,0.55)', background: o.bg || 'rgba(15,12,8,0.55)',
padding: '4px 10px', padding: o.padding != null ? o.padding : '4px 10px',
borderRadius: 5, borderRadius: o.borderRadius != null ? o.borderRadius : 5,
// длинные подписи переносятся и остаются по центру, border: o.border || undefined,
// не вылезая за края экрана textAlign: o.textAlign || 'center',
textAlign: 'center',
maxWidth: '70vw', maxWidth: '70vw',
whiteSpace: 'normal', whiteSpace: 'pre-line',
wordBreak: 'break-word', 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 ( return (
<div key={id} style={{ <div key={id} style={{
...style, ...style,
position: 'absolute', position: 'absolute',
left: typeof o.x === 'number' ? `${o.x}%` : undefined, left: `${o.x}%`,
top: typeof o.y === 'number' ? `${o.y}%` : undefined, top: `${o.y}%`,
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
}}>{lbl.text}</div> }}>{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 ( return (
<div key={id} style={{ <div key={id} style={{

View File

@ -264,10 +264,25 @@ function GuiElement({ el, allElements, childrenMap, isPlaying, selectedId, onSel
// textbox в Play кликабелен (для фокуса и ввода), как и кнопка // textbox в Play кликабелен (для фокуса и ввода), как и кнопка
const pointerEvents = isPlaying ? ((isButton || isTextbox) ? 'auto' : 'none') : 'auto'; const pointerEvents = isPlaying ? ((isButton || isTextbox) ? 'auto' : 'none') : 'auto';
// В Play на кнопке лёгкий hover/pressed эффект // В Play на кнопке hover/pressed эффект (Задача 03).
// Если у элемента задан el.hover/el.active используем их параметры,
// иначе дефолтные значения.
const playInteractive = isPlaying && isButton; const playInteractive = isPlaying && isButton;
const playFilter = pressed ? 'brightness(0.85)' : (hover ? 'brightness(1.15)' : 'none'); const hoverCfg = el.hover || { scale: 1.08, brightness: 1.15, rotation: 0 };
const playTransform = pressed ? `${style.transform || ''} scale(0.97)` : style.transform; 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 ( return (
<div <div
@ -340,28 +355,82 @@ function GuiElement({ el, allElements, childrenMap, isPlaying, selectedId, onSel
/> />
); );
})()} })()}
{isText && (el.text != null) && ( {isText && (el.text != null) && (() => {
<div style={{ // Задача 03: text-stroke (обводка текста). Через -webkit-text-stroke
width: '100%', height: '100%', // (хорошая поддержка, чётко на крупном шрифте) + paint-order
display: 'flex', // (stroke под fill чтобы текст не «сжимался»).
alignItems: 'center', const ts = el.textStroke;
justifyContent: el.textAlign === 'left' ? 'flex-start' const strokeStyle = (ts && ts.color && Number.isFinite(ts.width))
: el.textAlign === 'right' ? 'flex-end' : 'center', ? {
color: el.textColor || '#f0e6d8', WebkitTextStroke: `${ts.width}px ${ts.color}`,
fontSize: el.textSize || 16, paintOrder: 'stroke fill',
fontWeight: el.fontWeight || 500, }
fontFamily: '"Roboto Condensed", system-ui, sans-serif', : null;
padding: '4px 8px', return (
boxSizing: 'border-box', <div style={{
textAlign: el.textAlign || 'center', width: '100%', height: '100%',
lineHeight: 1.2, display: 'flex',
whiteSpace: 'pre-wrap', alignItems: 'center',
wordBreak: 'break-word', justifyContent: el.textAlign === 'left' ? 'flex-start'
pointerEvents: 'none', : el.textAlign === 'right' ? 'flex-end' : 'center',
}}> color: el.textColor || '#f0e6d8',
{el.text} fontSize: el.textSize || 16,
</div> 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 (принимает ввод), {/* TextBox настоящий <input> в Play (принимает ввод),
в редакторе статичный вид с placeholder. */} в редакторе статичный вид с placeholder. */}
@ -663,14 +732,42 @@ function elementToStyle(el) {
case 'center': case 'center':
default: left = `${el.x ?? 50}%`; top = `${el.y ?? 50}%`; break; 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}%)`; transform = `translate(${tx}%, ${ty}%)`;
if (sx !== 1 || sy !== 1) transform += ` scale(${sx}, ${sy})`;
if (rot) transform += ` rotate(${rot}deg)`;
let bg = el.bgColor || '#1f1810'; let bg = el.bgColor || '#1f1810';
const opacity = el.bgOpacity == null ? 1 : Math.max(0, Math.min(1, el.bgOpacity)); const opacity = el.bgOpacity == null ? 1 : Math.max(0, Math.min(1, el.bgOpacity));
if (bg === 'transparent' || opacity === 0) bg = 'transparent'; if (bg === 'transparent' || opacity === 0) bg = 'transparent';
else bg = hexToRgba(bg, opacity); 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 { return {
position: 'absolute', position: 'absolute',
left, top, transform, left, top, transform,
transformOrigin: 'center center',
width: w, height: h, width: w, height: h,
background: bg, background: bg,
border: el.borderWidth > 0 border: el.borderWidth > 0
@ -678,14 +775,11 @@ function elementToStyle(el) {
: 'none', : 'none',
borderRadius: (el.borderRadius || 0) + 'px', borderRadius: (el.borderRadius || 0) + 'px',
boxSizing: 'border-box', boxSizing: 'border-box',
// Тень: явный флаг shadow мягкая drop-shadow; у кнопок
// лёгкая тень по умолчанию (как было). shadow=true усиливает.
boxShadow: el.shadow boxShadow: el.shadow
? '0 6px 16px rgba(0,0,0,0.45)' ? '0 6px 16px rgba(0,0,0,0.45)'
: (el.type === 'button' ? '0 2px 6px rgba(0,0,0,0.3)' : 'none'), : (el.type === 'button' ? '0 2px 6px rgba(0,0,0,0.3)' : 'none'),
// Frame обрезает детей по своей границе (как ScreenGui в Roblox).
// Для не-frame оставляем visible чтобы текст не клипался.
overflow: el.type === 'frame' ? 'hidden' : 'visible', overflow: el.type === 'frame' ? 'hidden' : 'visible',
filter: brightness !== 1 ? `brightness(${brightness})` : undefined,
}; };
} }

View File

@ -294,6 +294,7 @@ const InspectorPanel = ({
onSetAnchored, onSetMass, onSetModelProps, onSetBlockProps, onSetAnchored, onSetMass, onSetModelProps, onSetBlockProps,
onSetLightingProps, onSetSoundProps, onSetPlayerProps, onSetFloorProps, onSetGuiProps, onDeleteGui, onSetLightingProps, onSetSoundProps, onSetPlayerProps, onSetFloorProps, onSetGuiProps, onDeleteGui,
onAddWeaponToInventory, onAddWeaponToInventory,
onEditBillboard,
guiElements = [], guiElements = [],
// Этап 3.6 библиотека пользовательских картинок. // Этап 3.6 библиотека пользовательских картинок.
assetList = [], assetList = [],
@ -1111,6 +1112,201 @@ const InspectorPanel = ({
</label> </label>
</div> </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}> <div className={cl.section}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, cursor: 'pointer' }}> <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, cursor: 'pointer' }}>
<input <input

View File

@ -10,6 +10,7 @@ import { PRIMITIVE_TYPES, PALETTE_PRIMITIVE_TYPES } from './engine/PrimitiveType
import { getModelThumbnail } from './engine/ModelThumbnails'; import { getModelThumbnail } from './engine/ModelThumbnails';
import * as Kubikon3DApi from '../api/Kubikon3DService'; import * as Kubikon3DApi from '../api/Kubikon3DService';
import GameSettingsModal from './GameSettingsModal'; import GameSettingsModal from './GameSettingsModal';
import SkinManagerModal from './SkinManagerModal';
import PublishModal from './PublishModal'; import PublishModal from './PublishModal';
import EmailConfirmNotice from '../components/EmailConfirmNotice/EmailConfirmNotice'; import EmailConfirmNotice from '../components/EmailConfirmNotice/EmailConfirmNotice';
import PublishStatusBadge from './PublishStatusBadge'; import PublishStatusBadge from './PublishStatusBadge';
@ -31,6 +32,8 @@ import MinimapOverlay from './MinimapOverlay';
import GuiOverlay from './GuiOverlay'; import GuiOverlay from './GuiOverlay';
import Hotbar from './Hotbar'; import Hotbar from './Hotbar';
import PlayerHud from './PlayerHud'; import PlayerHud from './PlayerHud';
import ModalOverlay from './ModalOverlay';
import SkinShopOverlay from './SkinShopOverlay';
import useDeviceType from '../hooks/useDeviceType'; import useDeviceType from '../hooks/useDeviceType';
import KubikonDesktopOnlyStub from '../community/KubikonDesktopOnlyStub'; import KubikonDesktopOnlyStub from '../community/KubikonDesktopOnlyStub';
import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton'; import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton';
@ -188,6 +191,109 @@ const MODEL_ITEM_ICON = (modelId) => {
* иначе иконка-кубик. id таких моделей в формате 'user:<numericId>'. * иначе иконка-кубик. id таких моделей в формате 'user:<numericId>'.
*/ */
// Типы GUI-элементов для палитры визуального редактора UI (этап 3.9). // Типы 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 = [ const GUI_PALETTE_ITEMS = [
{ type: 'frame', icon: 'square', name: 'Контейнер', hint: 'Прямоугольная панель — рамка для других элементов' }, { type: 'frame', icon: 'square', name: 'Контейнер', hint: 'Прямоугольная панель — рамка для других элементов' },
{ type: 'scroll', icon: 'align-left', name: 'Список', hint: 'Прокручиваемая панель — для длинных списков и меню' }, { type: 'scroll', icon: 'align-left', name: 'Список', hint: 'Прокручиваемая панель — для длинных списков и меню' },
@ -195,6 +301,13 @@ const GUI_PALETTE_ITEMS = [
{ type: 'button', icon: 'component', name: 'Кнопка', hint: 'Кликабельная кнопка — реагирует в скрипте' }, { type: 'button', icon: 'component', name: 'Кнопка', hint: 'Кликабельная кнопка — реагирует в скрипте' },
{ type: 'textbox', icon: 'edit', name: 'Поле ввода', hint: 'Поле для ввода текста игроком' }, { type: 'textbox', icon: 'edit', name: 'Поле ввода', hint: 'Поле для ввода текста игроком' },
{ type: 'image', icon: 'image', 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 // Скрипт через game.hud.setVisible(false) может полностью скрыть HUD
// движка (HP, hotbar, ...) для своего меню через game.gui.*. // движка (HP, hotbar, ...) для своего меню через game.gui.*.
const [stdHudVisible, setStdHudVisible] = useState(true); const [stdHudVisible, setStdHudVisible] = useState(true);
// Задача 03: отдельный контроль хотбара и HP для игр без инвентаря/жизней.
const [hotbarVisible, setHotbarVisible] = useState(true);
const [hpVisible, setHpVisible] = useState(true);
// Кнопка-глазок в Иерархии "Интерфейс" временно скрывает все GUI-элементы // Кнопка-глазок в Иерархии "Интерфейс" временно скрывает все GUI-элементы
// в редакторе (только в редакторе, в Play они видны как и раньше). // в редакторе (только в редакторе, в Play они видны как и раньше).
const [guiOverlayHidden, setGuiOverlayHidden] = useState(false); const [guiOverlayHidden, setGuiOverlayHidden] = useState(false);
@ -696,6 +812,9 @@ const KubikonEditor = () => {
// settingsModalOpen настройки игры (Roblox Game Settings) // settingsModalOpen настройки игры (Roblox Game Settings)
// initialModalOpen инициальный диалог при создании новой игры // initialModalOpen инициальный диалог при создании новой игры
const [settingsModalOpen, setSettingsModalOpen] = useState(false); const [settingsModalOpen, setSettingsModalOpen] = useState(false);
// Задача 07: модал управления скинами проекта + список всех скинов (манифест).
const [skinManagerOpen, setSkinManagerOpen] = useState(false);
const [allSkinsList, setAllSkinsList] = useState([]);
const [publishModalOpen, setPublishModalOpen] = useState(false); const [publishModalOpen, setPublishModalOpen] = useState(false);
const [historyModalOpen, setHistoryModalOpen] = useState(false); const [historyModalOpen, setHistoryModalOpen] = useState(false);
const [moderationHistory, setModerationHistory] = useState([]); const [moderationHistory, setModerationHistory] = useState([]);
@ -822,29 +941,30 @@ const KubikonEditor = () => {
// значением (lastLoaded===0) И сцена сейчас пустая по ВСЕМ // значением (lastLoaded===0) И сцена сейчас пустая по ВСЕМ
// коллекциям значит загрузка не отработала. Сохранять нечего, // коллекциям значит загрузка не отработала. Сохранять нечего,
// блокируем (иначе пустышка затрёт реальный проект в БД). // блокируем (иначе пустышка затрёт реальный проект в БД).
if (currentProjectIdRef.current != null && lastLoaded === 0 if (currentProjectIdRef.current != null) {
&& !userWasEditing) {
const s = sceneRef.current; const s = sceneRef.current;
const blockN = s.blockManager?.blocks?.size ?? 0; const blockN = s.blockManager?.blocks?.size ?? 0;
const primN = s.primitiveManager?.instances?.size ?? 0; const primN = s.primitiveManager?.instances?.size ?? 0;
const modelN = s.modelManager?.instances?.size ?? 0; const modelN = s.modelManager?.instances?.size ?? 0;
const umN = s.userModelManager?.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 scriptN = (s._scripts || []).filter(x => x && x.id !== 'demo').length;
const guiN = s.guiManager?.getAll?.()?.length ?? 0; const guiN = s.guiManager?.getAll?.()?.length ?? 0;
const totalContent = currentVoxels + blockN + primN + modelN + umN const totalContent = currentVoxels + blockN + primN + modelN + umN
+ scriptN + guiN; + scriptN + guiN;
// ЖЁСТКАЯ защита: если сцена ПОЛНОСТЬЮ пустая (0 блоков, 0 примитивов,
// 0 моделей, 0 скриптов, 0 GUI) НИКОГДА не сохраняем поверх
// существующего проекта. Даже если userWasEditing это значит юзер
// нажал «Очистить» (удалил всё) или произошёл HMR-reset.
// Если человек реально хочет пустую игру создаст новый проект.
if (totalContent === 0) { if (totalContent === 0) {
console.error( console.error(
'[KubikonEditor] SAVE BLOCKED: существующий проект, ' '[KubikonEditor] SAVE BLOCKED: сцена пустая по всем коллекциям. '
+ 'но сцена пустая по всем коллекциям и lastLoaded=0 — ' + 'Существующий проект не будет затёрт пустышкой. '
+ 'загрузка не отработала. Перезагрузите страницу.' + 'Перезагрузите страницу, чтобы вернуть содержимое из БД.'
); );
setSaveStatus('error'); setSaveStatus('error');
setSaveDetail({ setSaveDetail({
phase: 'Сохранение заблокировано: сцена пустая (загрузка не отработала). Перезагрузите страницу!', phase: 'Сохранение заблокировано: сцена пустая. Перезагрузите страницу!',
pct: 0, error: true, pct: 0, error: true,
}); });
setTimeout(() => setSaveDetail(null), 8000); setTimeout(() => setSaveDetail(null), 8000);
@ -1003,14 +1123,27 @@ const KubikonEditor = () => {
fetch('/kubikon-assets/characters/skins_manifest.json') fetch('/kubikon-assets/characters/skins_manifest.json')
.then(r => r.json()) .then(r => r.json())
.then(json => { .then(json => {
// Задача 07: и человекоподобные R15, и non-humanoid скины
// (животные/машины/еда/роботы) доступны как стартовый скин.
// category для группировки в Inspector: 'Персонажи' (люди) или
// 'Скины-животные' (всё остальное).
const catLabel = (c) => (!c || c === 'human') ? 'Персонажи' : 'Скины-фигуры';
const skinOpts = (json.skins || []).map(s => ({ 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, name: s.name || s.slug,
category: 'Персонажи', category: catLabel(s.category),
skinKind: s.kind || 'r15',
skinCategory: s.category || 'human',
})); }));
if (skinOpts.length > 0 && sceneRef.current) { if (skinOpts.length > 0 && sceneRef.current) {
sceneRef.current.setPlayerOptions([...baseChars, ...skinOpts]); 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)); .catch(e => console.warn('[KubikonEditor] R15 skins manifest load failed:', e));
@ -1132,6 +1265,9 @@ const KubikonEditor = () => {
} }
// Подписка на изменение видимости стандартного HUD от game.hud.setVisible // Подписка на изменение видимости стандартного HUD от game.hud.setVisible
scene.setOnStdHudVisibilityChange?.((v) => setStdHudVisible(v)); scene.setOnStdHudVisibilityChange?.((v) => setStdHudVisible(v));
// Задача 03: отдельные подписки на хотбар и HP
scene.setOnHotbarVisibilityChange?.((v) => setHotbarVisible(v));
scene.setOnHpVisibilityChange?.((v) => setHpVisible(v));
// Подписка на смену cursor-режима из скрипта (game.input.setCursorMode) // Подписка на смену cursor-режима из скрипта (game.input.setCursorMode)
scene.setOnCursorModeChange?.((mode) => setUiCursorMode(mode === 'ui')); scene.setOnCursorModeChange?.((mode) => setUiCursorMode(mode === 'ui'));
@ -1621,6 +1757,14 @@ const KubikonEditor = () => {
> >
<Icon name="settings" size={13} /> Настройки <Icon name="settings" size={13} /> Настройки
</button> </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} onClick={handleSave}><Icon name="save" size={13} /> Сохранить</button>
<button <button
className={cl.toolbarBtn} className={cl.toolbarBtn}
@ -2153,12 +2297,28 @@ const KubikonEditor = () => {
</div> </div>
) : paletteTab === 'gui' ? ( ) : paletteTab === 'gui' ? (
<GuiPalette <GuiPalette
onPlaceCenter={(type) => { onPlaceCenter={(typeOrTemplate) => {
// Клик по карточке добавить в центр экрана. const { type, opts } = _expandGuiTemplate(typeOrTemplate);
const id = sceneRef.current?.createGuiElement?.(type, {}); // Задача 04: батч-шаблон (модальное окно) создаём несколько элементов.
if (id) { if (type === '_batch' && Array.isArray(opts?.elements)) {
sceneRef.current?.selection?.selectGui?.(id); let lastId = null;
setActiveTool('select'); 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(); markDirty();
}} }}
@ -2353,20 +2513,39 @@ const KubikonEditor = () => {
} }
}} }}
onDrop={(e) => { onDrop={(e) => {
const type = e.dataTransfer.getData('application/x-kubikon-gui'); const rawType = e.dataTransfer.getData('application/x-kubikon-gui');
if (!type) return; if (!rawType) return;
e.preventDefault(); e.preventDefault();
// Позиция отпускания проценты от viewport (центр элемента).
const rect = viewportRef.current?.getBoundingClientRect(); const rect = viewportRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return; if (!rect || rect.width === 0) return;
const px = ((e.clientX - rect.left) / rect.width) * 100; const px = ((e.clientX - rect.left) / rect.width) * 100;
const py = ((e.clientY - rect.top) / rect.height) * 100; const py = ((e.clientY - rect.top) / rect.height) * 100;
const x = Math.max(0, Math.min(100, Math.round(px))); const x = Math.max(0, Math.min(100, Math.round(px)));
const y = Math.max(0, Math.min(100, Math.round(py))); const y = Math.max(0, Math.min(100, Math.round(py)));
const id = sceneRef.current?.createGuiElement?.(type, { x, y, anchor: 'center' }); // Задача 03: раскрытие шаблона если type начинается с 'template:'.
if (id) { const { type, opts } = _expandGuiTemplate(rawType);
sceneRef.current?.selection?.selectGui?.(id); // Задача 04: батч-шаблон несколько элементов.
setActiveTool('select'); 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(); markDirty();
}} }}
@ -2568,8 +2747,15 @@ const KubikonEditor = () => {
</div> </div>
)} )}
{/* Player HUD: HP + ammo (только в Play, и если скрипт не скрыл) */} {/* 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 <PlayerHud
visible={isPlaying && stdHudVisible} visible={isPlaying && stdHudVisible && hpVisible}
hp={playerHp.hp} hp={playerHp.hp}
maxHp={playerHp.maxHp} maxHp={playerHp.maxHp}
ammo={weaponAmmo} ammo={weaponAmmo}
@ -2577,7 +2763,7 @@ const KubikonEditor = () => {
/> />
{/* Hot-bar инвентаря (виден только в Play, и если скрипт не скрыл) */} {/* Hot-bar инвентаря (виден только в Play, и если скрипт не скрыл) */}
<Hotbar <Hotbar
visible={isPlaying && stdHudVisible} visible={isPlaying && stdHudVisible && hotbarVisible}
slots={inventoryState.slots} slots={inventoryState.slots}
activeIndex={inventoryState.activeIndex} activeIndex={inventoryState.activeIndex}
onSelect={(i) => sceneRef.current?.setActiveInventorySlot?.(i)} onSelect={(i) => sceneRef.current?.setActiveInventorySlot?.(i)}
@ -2953,11 +3139,12 @@ const KubikonEditor = () => {
onSetPrimitiveProps={(patch) => onSetPrimitiveProps={(patch) =>
sceneRef.current?.setSelectedPrimitivePropsTo(patch)} sceneRef.current?.setSelectedPrimitivePropsTo(patch)}
onEditBillboard={() => { onEditBillboard={() => {
// Открываем модалку с данными выделенного billboard-примитива
const s = sceneRef.current; 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; if (!sel || sel.type !== 'primitive') return;
const data = s?.primitiveManager?.instances?.get(sel.id); const data = s?.primitiveManager?.instances?.get(sel.id);
console.log('[EditBillboard] data=', data?.type, 'id=', data?.id);
if (!data || data.type !== 'billboard') return; if (!data || data.type !== 'billboard') return;
setBillboardEditorData({ setBillboardEditorData({
id: data.id, id: data.id,
@ -3107,6 +3294,31 @@ const KubikonEditor = () => {
onSave={handleSettingsSave} onSave={handleSettingsSave}
onCaptureScreenshot={captureSceneScreenshot} 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 <PublishModal
open={publishModalOpen} open={publishModalOpen}
project={{ project={{

101
src/editor/ModalOverlay.jsx Normal file
View 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})`;
}

View 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>
);
}

View 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)',
};
}

View File

@ -58,6 +58,7 @@ import { BillboardUiManager } from './BillboardUiManager';
import { getPrimitiveType } from './PrimitiveTypes'; import { getPrimitiveType } from './PrimitiveTypes';
import { FolderManager } from './FolderManager'; import { FolderManager } from './FolderManager';
import { GuiManager } from './GuiManager'; import { GuiManager } from './GuiManager';
import { ModalManager } from './ModalManager';
import { InventoryManager } from './InventoryManager'; import { InventoryManager } from './InventoryManager';
import { WeaponSystem } from './WeaponSystem'; import { WeaponSystem } from './WeaponSystem';
import { ZombieManager } from './ZombieManager'; import { ZombieManager } from './ZombieManager';
@ -1244,6 +1245,9 @@ export class BabylonScene {
this.primitiveManager.billboardUiManager = this.billboardUiManager; this.primitiveManager.billboardUiManager = this.billboardUiManager;
this.folderManager = new FolderManager(this.blockManager, this.modelManager, this.primitiveManager); this.folderManager = new FolderManager(this.blockManager, this.modelManager, this.primitiveManager);
this.guiManager = new GuiManager(); this.guiManager = new GuiManager();
this.modalManager = new ModalManager();
this.modalManager.attachScene(this);
this.modalManager.attachGui(this.guiManager);
this.inventory = new InventoryManager(); this.inventory = new InventoryManager();
this.physics = new PhysicsAABB(this.blockManager); this.physics = new PhysicsAABB(this.blockManager);
this.physics.setPrimitiveManager(this.primitiveManager); this.physics.setPrimitiveManager(this.primitiveManager);
@ -1279,35 +1283,42 @@ export class BabylonScene {
// в pointer-lock) → ищем под ним меш типа billboard → переводим точку // в pointer-lock) → ищем под ним меш типа billboard → переводим точку
// пересечения в UV → BillboardUiManager.pickButtonAt → fireClick. // пересечения в UV → BillboardUiManager.pickButtonAt → fireClick.
// Работает только когда _isPlaying=true (в редакторе клик гизмо или ничего). // Работает только когда _isPlaying=true (в редакторе клик гизмо или ничего).
this.scene.onPointerObservable.add((info) => { // Прямой capture-phase mousedown на canvas — раньше PlayerController.
if (info.type !== PointerEventTypes.POINTERDOWN) return; // Babylon onPointerObservable не получает события в pointer-lock,
if (info.event && info.event.button !== 0) return; // только ЛКМ // поэтому ловим сами и стреляем лучом по табличкам в Play.
const canvasEl = this.canvas;
const onBillboardMouseDown = (e) => {
if (!this._isPlaying) return; if (!this._isPlaying) return;
// Для pointer-lock (FPS-камера) — стреляем из центра экрана. if (e.button !== 0) return;
// Иначе — используем pickInfo от Babylon (он уже от курсора).
let pi = info.pickInfo;
const inLock = (document.pointerLockElement != null); const inLock = (document.pointerLockElement != null);
let px, py;
if (inLock) { if (inLock) {
const cx = this.engine.getRenderWidth() / 2; px = this.engine.getRenderWidth() / 2;
const cy = this.engine.getRenderHeight() / 2; py = this.engine.getRenderHeight() / 2;
pi = this.scene.pick(cx, cy, (m) => { } else {
return m.metadata?.isPrimitive const rect = canvasEl.getBoundingClientRect();
&& this.primitiveManager.instances.get(m.metadata.primitiveId)?.type === 'billboard'; 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; if (!pi || !pi.hit || !pi.pickedMesh) return;
const meta = pi.pickedMesh.metadata; const meta = pi.pickedMesh.metadata;
if (!meta || !meta.isPrimitive) return;
const data = this.primitiveManager.instances.get(meta.primitiveId); const data = this.primitiveManager.instances.get(meta.primitiveId);
if (!data || data.type !== 'billboard') return; if (!data || data.type !== 'billboard') return;
// UV точка пересечения с мешем (Babylon знает, если есть UV-координаты).
const uv = pi.getTextureCoordinates ? pi.getTextureCoordinates() : null; const uv = pi.getTextureCoordinates ? pi.getTextureCoordinates() : null;
if (!uv) return; if (!uv) return;
const buttonId = this.billboardUiManager.pickButtonAt(data, uv.x, uv.y); const buttonId = this.billboardUiManager.pickButtonAt(data, uv.x, uv.y);
if (buttonId) { if (buttonId) {
this.billboardUiManager.fireClick(data, buttonId); this.billboardUiManager.fireClick(data, buttonId);
// Предотвращаем PlayerController-обработчик (pointer-lock и т.д.)
e.stopPropagation();
e.preventDefault();
} }
}); };
canvasEl.addEventListener('mousedown', onBillboardMouseDown, true /* capture */);
// GizmoController — управляет 3 типами гизмо (move/rotate/scale). // GizmoController — управляет 3 типами гизмо (move/rotate/scale).
// UtilityLayer — отдельный слой, чтобы гизмо рисовалось поверх сцены. // 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 // Tick пользовательских скриптов: в Play-режиме или в solo-debug
if (this.gameRuntime && (this._isPlaying || this.gameRuntime.isSolo?.())) { if (this.gameRuntime && (this._isPlaying || this.gameRuntime.isSolo?.())) {
this.gameRuntime.tick(dt); this.gameRuntime.tick(dt);
@ -5240,6 +5255,8 @@ export class BabylonScene {
// По умолчанию стандартный HUD видим в Play. // По умолчанию стандартный HUD видим в Play.
// Скрипт может скрыть через game.hud.setVisible(false). // Скрипт может скрыть через game.hud.setVisible(false).
this._setStdHudVisible(true); this._setStdHudVisible(true);
this._setHotbarVisible(true);
this._setHpVisible(true);
// Включаем picking voxel-террейна — иначе камера _clampCameraToWorld // Включаем picking voxel-террейна — иначе камера _clampCameraToWorld
// не «видит» воксели в Ray-каст и пролетает сквозь стены. // не «видит» воксели в Ray-каст и пролетает сквозь стены.
@ -5273,6 +5290,11 @@ export class BabylonScene {
// Создаём PlayerController и стартуем // Создаём PlayerController и стартуем
this.player = new PlayerController(this.scene, this.canvas, this.physics, this); this.player = new PlayerController(this.scene, this.canvas, this.physics, this);
this.player.setModelType(this._playerModelType); 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; this.player._jumpPowerMul = this._jumpPowerMul ?? 1;
// Применяем дефолтную камеру если задана в сцене // Применяем дефолтную камеру если задана в сцене
if (this._defaultCameraMode && this._defaultCameraMode !== this.player._cameraMode) { if (this._defaultCameraMode && this._defaultCameraMode !== this.player._cameraMode) {
@ -5281,6 +5303,18 @@ export class BabylonScene {
// На тач-устройствах отключаем pointer-lock и mouse-камеру // На тач-устройствах отключаем pointer-lock и mouse-камеру
if (this._touchMode) this.player.setTouchMode(true); if (this._touchMode) this.player.setTouchMode(true);
this.player.setOnExitRequest(() => { 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(); this.exitPlayMode();
if (this._onPlayChange) this._onPlayChange(false); if (this._onPlayChange) this._onPlayChange(false);
}); });
@ -5292,6 +5326,7 @@ export class BabylonScene {
// Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену, // Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену,
// поэтому скрипты стартуем в следующем кадре. // поэтому скрипты стартуем в следующем кадре.
this.gameRuntime = new GameRuntime(this); this.gameRuntime = new GameRuntime(this);
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
// Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime. // Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime.
// ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным // ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным
// this.audioManager (AudioManager — ambient/music для всех проектов). // this.audioManager (AudioManager — ambient/music для всех проектов).
@ -5785,6 +5820,7 @@ export class BabylonScene {
if (!sc) return false; if (!sc) return false;
if (!this.gameRuntime) { if (!this.gameRuntime) {
this.gameRuntime = new GameRuntime(this); this.gameRuntime = new GameRuntime(this);
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
if (!this.gameAudioManager) { if (!this.gameAudioManager) {
this.gameAudioManager = new GameAudioManager(); this.gameAudioManager = new GameAudioManager();
} }
@ -5906,6 +5942,24 @@ export class BabylonScene {
this._stdHudVisible = !!visible; this._stdHudVisible = !!visible;
try { this._onStdHudVisibilityChange?.(this._stdHudVisible); } catch (e) {} 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. /** Колбэк смены cursor-режима (ui/game) скриптом через game.input.setCursorMode.
* Редактор подписан чтобы синхронизировать React-state uiCursorMode (для бейджа). */ * Редактор подписан чтобы синхронизировать React-state uiCursorMode (для бейджа). */
@ -6054,6 +6108,71 @@ export class BabylonScene {
return this.guiManager ? this.guiManager.getAll() : []; 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) ===== // ===== Библиотека пользовательских картинок (этап 3.6) =====
/** Список картинок проекта [{id, name, dataUrl}]. */ /** Список картинок проекта [{id, name, dataUrl}]. */
@ -6724,6 +6843,13 @@ export class BabylonScene {
inventory: this.inventory ? this.inventory.serialize() : null, inventory: this.inventory ? this.inventory.serialize() : null,
spawnPoint: { ...this._spawnPoint }, spawnPoint: { ...this._spawnPoint },
playerModelType: this._playerModelType, 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, worldSize: this._worldHalf * 2,
floorEnabled: this._floorEnabled !== false, floorEnabled: this._floorEnabled !== false,
jumpPowerMul: this._jumpPowerMul ?? 1, jumpPowerMul: this._jumpPowerMul ?? 1,
@ -7161,6 +7287,24 @@ export class BabylonScene {
this._playerModelType = pmt; 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)) { if (Array.isArray(state.scene.scripts)) {
this._scripts = state.scene.scripts this._scripts = state.scene.scripts
@ -7197,6 +7341,8 @@ export class BabylonScene {
exitPlayMode() { exitPlayMode() {
if (!this._isPlaying) return; if (!this._isPlaying) return;
this._isPlaying = false; this._isPlaying = false;
// Задача 04: закрываем любой активный модал чтобы не «висел» при стопе
try { this.modalManager?._instantClose?.(); } catch (e) {}
// Сбрасываем таймер прохождения // Сбрасываем таймер прохождения
this._timerRunning = false; this._timerRunning = false;
this._timerStartedAt = null; this._timerStartedAt = null;

View File

@ -114,21 +114,15 @@ export class BillboardUiManager {
mesh.metadata._billboardMaterial = mat; mesh.metadata._billboardMaterial = mat;
} }
// Ориентация на камеру. Babylon-quirk: BILLBOARDMODE_ALL игнорирует // Ориентация. Babylon-quirk: CreatePlane FRONT в -Z (left-handed),
// mesh.scaling.x=-1 и mesh.rotation.y=π — невозможно отзеркалить // юзер ставит билборд так чтобы лицо смотрело в +Z мира → +π.
// плоскость. Делаем ручной поворот в onBeforeRenderObservable:
// нацеливаем mesh на камеру + ставим rotation.y += π, тогда мы
// видим back-side нормально (т.к. фактически она стала front).
mesh.billboardMode = Mesh.BILLBOARDMODE_NONE; mesh.billboardMode = Mesh.BILLBOARDMODE_NONE;
// Снимаем старую подписку (на случай пере-applyToMesh)
if (mesh.metadata._billboardLookObs) { if (mesh.metadata._billboardLookObs) {
this.scene.onBeforeRenderObservable.remove(mesh.metadata._billboardLookObs); this.scene.onBeforeRenderObservable.remove(mesh.metadata._billboardLookObs);
mesh.metadata._billboardLookObs = null; mesh.metadata._billboardLookObs = null;
} }
if (face === 'camera') { if (face === 'camera') {
// Ручной look-at вместо BILLBOARDMODE. // Ручной look-at — каждый кадр поворачиваем front к камере.
// CreatePlane FRONT в -Z (Babylon left-handed), поэтому +π —
// чтобы FRONT смотрел на камеру.
const obs = this.scene.onBeforeRenderObservable.add(() => { const obs = this.scene.onBeforeRenderObservable.add(() => {
if (mesh.isDisposed()) return; if (mesh.isDisposed()) return;
const cam = this.scene.activeCamera; const cam = this.scene.activeCamera;
@ -138,9 +132,47 @@ export class BillboardUiManager {
mesh.rotation.y = Math.atan2(dx, dz) + Math.PI; mesh.rotation.y = Math.atan2(dx, dz) + Math.PI;
}); });
mesh.metadata._billboardLookObs = obs; 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.scaling.x = Math.abs(mesh.scaling.x || 1);
mesh.metadata._billboardMirrorX = false; // canvas-mirror не нужен mesh.metadata._billboardMirrorX = false;
// Сохраняем state в data для сериализации и для hit-теста кликов. // Сохраняем state в data для сериализации и для hit-теста кликов.
data.billboard = { 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; 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; const dyn = data.mesh?.metadata?._billboardTexture;
if (dyn) { if (dyn) {
this._render(dyn, data.billboard.template, data.billboard.content, data.billboard.elements); this._render(dyn, data.billboard.template, data.billboard.content, data.billboard.elements);
@ -185,16 +249,28 @@ export class BillboardUiManager {
*/ */
pickButtonAt(data, uvX, uvY) { pickButtonAt(data, uvX, uvY) {
if (!data.billboard) return null; if (!data.billboard) return null;
// Текстура рисуется напрямую — UV из raycast соответствует canvas-пикселю. // Если текстура в данный момент отзеркалена (face=fixed, смотрим
const px = uvX * TEXTURE_W; // на back-side), uScale=-1 — инвертим UV.x чтобы попасть в правильный
const py = (1 - uvY) * TEXTURE_H; // UV.y перевёрнута относительно canvas // 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 имеют приоритет (если заданы) // Кастомные elements имеют приоритет (если заданы)
if (data.billboard.elements) { if (data.billboard.elements) {
return this._hitTestElements(data.billboard.elements, px, py); return this._hitTestElements(data.billboard.elements, px, py);
} }
const tmpl = data.billboard.template; const tmpl = data.billboard.template;
if (tmpl === 'shop-item' || tmpl === 'shop-purchase') { 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) { if (px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h) {
return 'buy'; return 'buy';
} }
@ -216,13 +292,24 @@ export class BillboardUiManager {
_flashButton(data, buttonId) { _flashButton(data, buttonId) {
if (!data.billboard) return; if (!data.billboard) return;
// Перерисовываем с pressed=true, через 100мс — обратно.
const dyn = data.mesh?.metadata?._billboardTexture; const dyn = data.mesh?.metadata?._billboardTexture;
if (!dyn) return; 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, this._render(dyn, data.billboard.template, data.billboard.content,
data.billboard.elements, /* pressed */ buttonId); data.billboard.elements, /* pressed */ buttonId);
setTimeout(() => { data._flashTimer = setTimeout(() => {
if (data.mesh?.metadata?._billboardTexture === dyn) { 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, this._render(dyn, data.billboard.template, data.billboard.content,
data.billboard.elements, null); data.billboard.elements, null);
} }
@ -344,21 +431,52 @@ export class BillboardUiManager {
ctx.fillText(content.sub, 200, 105); ctx.fillText(content.sub, 200, 105);
} }
// Кнопка цены — жёлтый прямоугольник внизу справа // Кнопка цены — жёлтый прямоугольник внизу справа.
const b = SHOP_ITEM_BUTTON; // Ширина адаптивная: длинный текст («ПРИМЕРИТЬ»/«НАДЕТО») расширяет
// кнопку ВЛЕВО (правый край прижат к краю таблички), шрифт ужимается
// если даже расширенная кнопка узка. Фактический rect → _buyRect для hit-теста.
const pressed = pressedButtonId === 'buy'; 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 gradient: pressed
? ['#d97706', '#92400e'] ? ['#d97706', '#92400e']
: ['#fbbf24', '#f59e0b'], : ['#fbbf24', '#f59e0b'],
radius: 16, radius: 16,
stroke: { color: '#000', width: 3 }, 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.fillStyle = pressed ? '#fef3c7' : '#1c1917';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; 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, но иконка крупнее и центрирована. */ /** Рендер пресета shop-purchase: похож на shop-item, но иконка крупнее и центрирована. */
@ -388,21 +506,22 @@ export class BillboardUiManager {
ctx.fillText(content.sub, 200, 100); ctx.fillText(content.sub, 200, 100);
} }
// Кнопка-цена // Кнопка-цена (адаптивная ширина под длинный текст, см. _computeBuyRect).
const b = SHOP_ITEM_BUTTON;
const pressed = pressedButtonId === 'buy'; 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 gradient: pressed
? ['#9333ea', '#6b21a8'] ? ['#9333ea', '#6b21a8']
: ['#a855f7', '#7c3aed'], : ['#a855f7', '#7c3aed'],
radius: 16, radius: 16,
stroke: { color: '#000', width: 3 }, 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.fillStyle = '#fff';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; 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: одна крупная фраза по центру. */ /** Рендер пресета banner: одна крупная фраза по центру. */
@ -441,11 +560,32 @@ export class BillboardUiManager {
stroke: { color: '#fff', width: 4 }, stroke: { color: '#fff', width: 4 },
}); });
ctx.font = 'bold 64px Arial, sans-serif'; // Заголовок крупно сверху
ctx.font = 'bold 44px Arial, sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillStyle = '#fff'; ctx.fillStyle = '#ffd166';
ctx.fillText(this._truncate(content.title || '', 14), TEXTURE_W / 2, TEXTURE_H / 2); 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. /** Рендер кастомного списка элементов: фон + список text/image/button.

View File

@ -163,6 +163,9 @@ export class GameRuntime {
this._broadcastSceneSnapshot(); this._broadcastSceneSnapshot();
this._broadcastGuiSnapshot(); this._broadcastGuiSnapshot();
this._broadcastTerrainHeightmap(); this._broadcastTerrainHeightmap();
this._broadcastSkinsSnapshot(); // задача 07
// Задача 03: запустить animationPreset для GUI-элементов с пресетом ≠ 'none'.
this._startGuiAnimationPresets();
}; };
if (typeof requestAnimationFrame !== 'undefined') { if (typeof requestAnimationFrame !== 'undefined') {
requestAnimationFrame(sendInitial); 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'ам. * Разослать карту высот гладкого ландшафта всем sandbox'ам.
* Нужно для game.scene.surfaceY(x,z). Снимается raycast'ом по * Нужно для 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). * Получить позицию объекта по его target (для зеркалирования в worker).
*/ */
@ -370,6 +464,10 @@ export class GameRuntime {
} }
// Анимации game.tween // Анимации game.tween
if (this._tweens.length > 0) this._updateTweens(dt); 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] над ближайшим интерактивным объектом // ProximityPrompt — подсказка [E] над ближайшим интерактивным объектом
if (this._interactables.length > 0) this._updateInteractables(); if (this._interactables.length > 0) this._updateInteractables();
@ -566,6 +664,67 @@ export class GameRuntime {
} }
/** Прокрутка всех активных твинов на dt секунд. */ /** Прокрутка всех активных твинов на 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) { _updateTweens(dt) {
for (let i = this._tweens.length - 1; i >= 0; i--) { for (let i = this._tweens.length - 1; i >= 0; i--) {
const tw = this._tweens[i]; const tw = this._tweens[i];
@ -920,16 +1079,58 @@ export class GameRuntime {
* Глобальное событие доставляется ВСЕМ sandbox'ам (не зависит от target). * Глобальное событие доставляется ВСЕМ sandbox'ам (не зависит от target).
* Используется для onKey, onClick (глобальный), onPlayerTouch. * Используется для 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 = {}) { routeGlobalEvent(eventType, extra = {}) {
if (!eventType) return; if (!eventType) return;
// Спецслучай: guiClick приходит с realId, но worker подписан на localRef // Спецслучай: guiClick приходит с realId. Worker мог подписаться двумя
// (потому что gui.create() возвращает worker'у только localRef). // способами:
// Резолвим обратно по реверс-карте. // 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' if ((eventType === 'guiClick' || eventType === 'guiSubmit'
|| eventType === 'guiTextChange') || eventType === 'guiTextChange')
&& extra && extra.id != null && this._guiRealToLocal) { && extra && extra.id != null && this._guiRealToLocal) {
const local = this._guiRealToLocal.get(extra.id); 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 // ProximityPrompt: keydown клавиши взаимодействия → событие interact
if (eventType === 'keydown' && extra && extra.key if (eventType === 'keydown' && extra && extra.key
@ -1102,6 +1303,20 @@ export class GameRuntime {
return map[code] || code.toLowerCase(); 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'а пришла — применяем на сцене. */ /** Команда от Worker'а пришла — применяем на сцене. */
_handleCommand(scriptId, cmd, payload) { _handleCommand(scriptId, cmd, payload) {
if (cmd === 'log') { if (cmd === 'log') {
@ -1779,6 +1994,20 @@ export class GameRuntime {
} catch (e) {} } catch (e) {}
return; 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') { if (cmd === 'input.setCursorMode') {
try { try {
const mode = payload?.mode === 'ui' ? 'ui' : 'game'; const mode = payload?.mode === 'ui' ? 'ui' : 'game';
@ -1945,17 +2174,183 @@ export class GameRuntime {
} }
return; 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') { if (cmd === 'player.setCameraMode') {
const player = this.scene3d?.player; const player = this.scene3d?.player;
if (player && typeof payload?.mode === 'string') { if (player && typeof payload?.mode === 'string') {
const valid = ['first', 'third', 'front', 'sideview']; const valid = ['first', 'third', 'front', 'sideview', 'lockfirst'];
if (valid.includes(payload.mode)) { 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) {} 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; 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') { if (cmd === 'player.setCrouch') {
const player = this.scene3d?.player; const player = this.scene3d?.player;
if (player) { if (player) {
@ -2527,6 +2922,114 @@ export class GameRuntime {
} }
return; 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') { if (cmd === 'scene.setTexture') {
// Установить динамическую текстуру примитива из dataURL. // Установить динамическую текстуру примитива из dataURL.
// Используется GD-скинами куба (canvas-фабрика в скрипте → dataURL → текстура). // Используется GD-скинами куба (canvas-фабрика в скрипте → dataURL → текстура).
@ -2672,11 +3175,19 @@ export class GameRuntime {
} }
// === Billboard 3D-таблички (см. BillboardUiManager) === // === Billboard 3D-таблички (см. BillboardUiManager) ===
if (cmd === 'billboard.set' || cmd === 'billboard.update' || cmd === 'billboard.onClick') { 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 { try {
// Резолв ref → primitiveId
let ref = payload?.ref;
if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref); if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref);
// ref имеет формат 'primitive:NN' — выделяем числовой id
let id = null; let id = null;
if (typeof ref === 'string' && ref.startsWith('primitive:')) { if (typeof ref === 'string' && ref.startsWith('primitive:')) {
id = Number(ref.slice('primitive:'.length)); id = Number(ref.slice('primitive:'.length));
@ -2698,17 +3209,22 @@ export class GameRuntime {
}); });
this.scheduleSceneSnapshot?.(); this.scheduleSceneSnapshot?.();
} else if (cmd === 'billboard.update') { } 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?.(); this.scheduleSceneSnapshot?.();
} else if (cmd === 'billboard.onClick') { } else if (cmd === 'billboard.onClick') {
const buttonId = String(payload.buttonId || 'buy'); const buttonId = String(payload.buttonId || 'buy');
// Регистрируем handler: при клике эмитим event в worker,
// worker найдёт зарегистрированный JS-callback по (ref,button).
const realRef = 'primitive:' + id; const realRef = 'primitive:' + id;
mgr.onClick(data, buttonId, () => { mgr.onClick(data, buttonId, () => {
const sb = this.sandboxes.find(s => s.scriptId === scriptId); const sb = this.sandboxes.find(s => s.scriptId === scriptId);
if (sb && typeof sb.sendEvent === 'function') { if (sb && typeof sb.sendGlobalEvent === 'function') {
sb.sendEvent({ // billboardClick роутится в worker'е через globalEvent-ветку
// (см. ScriptSandboxWorker.js cmd === 'globalEvent').
sb.sendGlobalEvent({
type: 'billboardClick', type: 'billboardClick',
ref: realRef, ref: realRef,
button: buttonId, button: buttonId,
@ -2877,6 +3393,7 @@ export class GameRuntime {
if (id != null) { if (id != null) {
this._localToReal.set(ref, 'primitive:' + id); this._localToReal.set(ref, 'primitive:' + id);
this._notifySpawnResolved(ref, 'primitive:' + id); this._notifySpawnResolved(ref, 'primitive:' + id);
this._drainPendingResolveQueue?.(ref);
const data = this.scene3d?.primitiveManager?.instances?.get(id); const data = this.scene3d?.primitiveManager?.instances?.get(id);
if (data) { if (data) {
// Помечаем как заспавненный скриптом — движок шлёт // Помечаем как заспавненный скриптом — движок шлёт

View File

@ -140,6 +140,25 @@ export class GuiManager {
scrollY: opts.scrollY ?? 0, scrollY: opts.scrollY ?? 0,
// Тень под элементом (Фаза 5.4) — мягкая drop-shadow. // Тень под элементом (Фаза 5.4) — мягкая drop-shadow.
shadow: opts.shadow ?? false, 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) — НЕ сериализуется // Создан скриптом в Play (game.gui.create) — НЕ сериализуется
// в проект, удаляется при Stop. // в проект, удаляется при Stop.
_scriptCreated: opts._scriptCreated === true, _scriptCreated: opts._scriptCreated === true,

View 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) {}
}
}
}

View File

@ -106,11 +106,25 @@ export class PlayerController {
this.GRAVITY = -22; this.GRAVITY = -22;
this.MOUSE_SENSITIVITY = 0.0025; this.MOUSE_SENSITIVITY = 0.0025;
// 3rd person camera // 3rd person camera (Roblox-style: 0.5 .. 32)
this.THIRD_DISTANCE_MIN = 2.5; this.THIRD_DISTANCE_MIN = 0.5;
this.THIRD_DISTANCE_MAX = 12; this.THIRD_DISTANCE_MAX = 32;
this.THIRD_DISTANCE_DEFAULT = 5; this.THIRD_DISTANCE_DEFAULT = 5;
this.THIRD_HEIGHT_OFFSET = 1.0; // на сколько выше плеча игрока 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.camera = null;
this._active = false; this._active = false;
@ -277,6 +291,44 @@ export class PlayerController {
this._modelTypeId = typeId || 'character-a'; 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). * spawnPos точка спавна. Если не указано (0, 5, 0).
@ -317,10 +369,37 @@ export class PlayerController {
this._beforeRender = () => this._tick(); this._beforeRender = () => this._tick();
this.scene.registerBeforeRender(this._beforeRender); this.scene.registerBeforeRender(this._beforeRender);
// Сразу запросить Pointer Lock. Promise-форма (новые Chrome) может // Pointer-lock запрашиваем ТОЛЬКО для режимов где он нужен сразу:
// отклониться с SecurityError если предыдущий lock ещё не отпущен — // - first / lockfirst — постоянный lock
// в этом случае ждём отпускания и пробуем снова. // - sideview (GD) — раньше тоже лочил, оставляем для авто-управления
this._requestPointerLockSafe(); // Для 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 manifest = await this._loadSkinManifest();
const entry = manifest.find((s) => s.id === typeId); const entry = manifest.find((s) => s.id === typeId);
if (entry) { if (entry) {
// kind определяет систему анимации:
// 'r15' → R15-скелет (как раньше)
// 'non-humanoid-mesh' → single-mesh, процедурное покачивание
// 'non-humanoid-rigged' → свой скелет + встроенные AnimationGroup
// Отсутствие kind = 'r15' (обратная совместимость со старым манифестом).
const kind = entry.kind || 'r15';
return { return {
file: '/kubikon-assets/' + entry.file, file: '/kubikon-assets/' + entry.file,
isR15: true, isR15: kind === 'r15',
kind,
overrides: entry.overrides || {}, 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 { return {
file: `/kubikon-assets/characters/${typeId}/body.glb`, file: `/kubikon-assets/characters/${typeId}/body.glb`,
isR15: true, isR15: true,
kind: 'r15',
overrides: {}, 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); const modelType = getModelType(typeId);
if (!modelType) return null; if (!modelType) return null;
return { file: modelType.file, isR15: false, overrides: {} }; return { file: modelType.file, isR15: false, kind: 'r15', overrides: {} };
} }
/** Загрузить GLB-модель персонажа и его анимации. */ /** Загрузить GLB-модель персонажа и его анимации. */
@ -607,13 +715,22 @@ export class PlayerController {
// что и зомби (через _loadPrototype), повторный // что и зомби (через _loadPrototype), повторный
// instantiateModelsToScene давал меши с битыми материалами. // instantiateModelsToScene давал меши с битыми материалами.
// Babylon HTTP-кэш всё равно убирает сетевые запросы. // Babylon HTTP-кэш всё равно убирает сетевые запросы.
const lastSlash = source.file.lastIndexOf('/'); let rootUrl, filename;
const rootUrl = source.file.substring(0, lastSlash + 1); if (source.isDataUrl) {
const filename = source.file.substring(lastSlash + 1); // Кастомный скин — 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; let container;
try { try {
container = await SceneLoader.LoadAssetContainerAsync( container = await SceneLoader.LoadAssetContainerAsync(
rootUrl, filename, this.scene rootUrl, filename, this.scene,
null, source.isDataUrl ? '.glb' : undefined
); );
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@ -634,10 +751,20 @@ export class PlayerController {
// с торчащими волосами/плащами (как у bacon-hair). // с торчащими волосами/плащами (как у bacon-hair).
// - Kenney-модели: старый 0.72. // - Kenney-модели: старый 0.72.
// - overrides.scale_mult — per-skin множитель из манифеста. // - overrides.scale_mult — per-skin множитель из манифеста.
let modelScale = source.isR15 ? 0.301 : this._modelScale; const isNonHumanoid = source.kind === 'non-humanoid-mesh'
const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0; || source.kind === 'non-humanoid-rigged';
modelScale *= scaleMult; 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); root.scaling = new Vector3(modelScale, modelScale, modelScale);
if (source.rotationYOffset) root.rotation.y = source.rotationYOffset;
const inst = container.instantiateModelsToScene( const inst = container.instantiateModelsToScene(
(name) => `player_${name}`, (name) => `player_${name}`,
/*cloneAnimations*/ true, /*cloneAnimations*/ true,
@ -647,6 +774,15 @@ export class PlayerController {
r.parent = root; r.parent = root;
} }
this._modelRoot = 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-скин: детекция скелета ===
// R15-скины приходят с встроенным скелетом Mixamo. Babylon // 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 игрока пересекает хотя бы один блок-воду. */ /** AABB игрока пересекает хотя бы один блок-воду. */
_isInWater() { _isInWater() {
const bm = this._scene3d?.blockManager; const bm = this._scene3d?.blockManager;
@ -1487,15 +1738,228 @@ export class PlayerController {
const idx = CAMERA_MODES.indexOf(this._cameraMode); const idx = CAMERA_MODES.indexOf(this._cameraMode);
this._cameraMode = CAMERA_MODES[(idx + 1) % CAMERA_MODES.length]; this._cameraMode = CAMERA_MODES[(idx + 1) % CAMERA_MODES.length];
this._applyCameraMode(); 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() { _setupInput() {
const canvas = this.canvas; const canvas = this.canvas;
const onCanvasClick = () => { const onCanvasClick = () => {
// В UI-режиме клик по канвасу НЕ перехватывает мышь // В UI-режиме клик не перехватывает мышь.
if (this._uiCursorMode) return; 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 { try {
const p = canvas.requestPointerLock?.(); const p = canvas.requestPointerLock?.();
if (p && typeof p.catch === 'function') p.catch(() => {}); if (p && typeof p.catch === 'function') p.catch(() => {});
@ -1504,6 +1968,54 @@ export class PlayerController {
}; };
canvas.addEventListener('click', onCanvasClick); 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-игр) === // === UI-режим: mousedown / mouseup → callback (для drag-игр) ===
const onCanvasMouseDown = (e) => { const onCanvasMouseDown = (e) => {
if (!this._uiCursorMode) return; if (!this._uiCursorMode) return;
@ -1543,6 +2055,8 @@ export class PlayerController {
if (document.pointerLockElement !== canvas) return; if (document.pointerLockElement !== canvas) return;
// Кубикон Dash: в sideview мышь не вращает камеру. // Кубикон Dash: в sideview мышь не вращает камеру.
if (this._cameraMode === 'sideview') return; if (this._cameraMode === 'sideview') return;
// Задача 04: модал с freezeCamera — мышь не вращает.
if (this._cameraFrozen) return;
this._yaw += e.movementX * this.MOUSE_SENSITIVITY; this._yaw += e.movementX * this.MOUSE_SENSITIVITY;
this._pitch += e.movementY * this.MOUSE_SENSITIVITY; this._pitch += e.movementY * this.MOUSE_SENSITIVITY;
const lim = Math.PI / 2 - 0.05; const lim = Math.PI / 2 - 0.05;
@ -1551,13 +2065,46 @@ export class PlayerController {
}; };
document.addEventListener('mousemove', onMouseMove); 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) => { const onWheel = (e) => {
if (!this._active) return; 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; 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_MIN) this._thirdDistance = this.THIRD_DISTANCE_MIN;
if (this._thirdDistance > this.THIRD_DISTANCE_MAX) this._thirdDistance = this.THIRD_DISTANCE_MAX; 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(); e.preventDefault();
}; };
canvas.addEventListener('wheel', onWheel, { passive: false }); canvas.addEventListener('wheel', onWheel, { passive: false });
@ -1567,10 +2114,31 @@ export class PlayerController {
const locked = document.pointerLockElement === canvas; const locked = document.pointerLockElement === canvas;
if (locked) { if (locked) {
wasLocked = true; wasLocked = true;
this._rmbHeld = true; // если попал в lock — ПКМ удерживается
} else if (wasLocked && this._active) { } else if (wasLocked && this._active) {
// Если мы САМИ переключились в UI-cursor mode — не выходим из Play // pointer-lock снят. Причин три:
if (this._uiCursorMode) return; // 1) пользователь сам в UI-режиме (game.input.setCursorMode('ui'))
if (this._onExitRequest) this._onExitRequest(); // 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); document.addEventListener('pointerlockchange', onPointerLockChange);
@ -1584,6 +2152,23 @@ export class PlayerController {
const onKeyDown = (e) => { const onKeyDown = (e) => {
if (!this._active) return; if (!this._active) return;
if (isTypingTarget(e.target)) 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); this._codes.add(e.code);
if (e.shiftKey) this._shift = true; if (e.shiftKey) this._shift = true;
// C — переключение first/third. Отключаем в GD-режиме (автобег > 0) // C — переключение first/third. Отключаем в GD-режиме (автобег > 0)
@ -1593,6 +2178,17 @@ export class PlayerController {
|| this._shipMode || this._ufoMode || this._waveMode || this._robotMode; || this._shipMode || this._ufoMode || this._waveMode || this._robotMode;
if (!inGdMode) this._toggleCameraMode(); 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) // Tab — переключить «UI-режим курсора» (для кликов по GUI в Play)
if (e.code === 'Tab') { if (e.code === 'Tab') {
e.preventDefault(); e.preventDefault();
@ -2116,20 +2712,41 @@ export class PlayerController {
this._modelYaw += Math.sign(diff) * maxStep; this._modelYaw += Math.sign(diff) * maxStep;
} }
} else { } else {
const dxReal = this._pos.x - beforeX; // Roblox-style: в first/lockfirst/shiftLock корпус мгновенно
const dzReal = this._pos.z - beforeZ; // следует за yaw камеры (AutoRotate привязан к камере).
const movedHorizontal = Math.abs(dxReal) > 0.001 || Math.abs(dzReal) > 0.001; // В third — корпус доворачивается под РЕАЛЬНОЕ направление движения.
if (movedHorizontal) { const followCamera = (
const targetYaw = Math.atan2(dxReal, dzReal); this._cameraMode === 'first' ||
this._cameraMode === 'lockfirst' ||
this._shiftLock
);
if (followCamera) {
const targetYaw = this._yaw;
let diff = targetYaw - this._modelYaw; let diff = targetYaw - this._modelYaw;
while (diff > Math.PI) diff -= Math.PI * 2; while (diff > Math.PI) diff -= Math.PI * 2;
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) { if (Math.abs(diff) <= maxStep) {
this._modelYaw = targetYaw; this._modelYaw = targetYaw;
} else { } else {
this._modelYaw += Math.sign(diff) * maxStep; 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. // Применяем yaw + swim-tilt.
@ -2188,6 +2805,17 @@ export class PlayerController {
this._tickDebris(dt); 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). // R15-скин: процедурный аниматор (нет glTF AnimationGroups).
// Состояния: idle/walk/run/jump/fall. sprint → run. // Состояния: idle/walk/run/jump/fall. sprint → run.
if (this._isR15 && this._r15Animator) { if (this._isR15 && this._r15Animator) {

View File

@ -151,9 +151,10 @@ export class PrimitiveManager {
// serialize мог записать их обратно в JSON проекта. // serialize мог записать их обратно в JSON проекта.
const billboardOpts = { const billboardOpts = {
template: opts.template || 'shop-item', template: opts.template || 'shop-item',
face: opts.face || 'camera', face: opts.face || 'fixed',
content: opts.content || null, content: opts.content || null,
elements: opts.elements || null, elements: opts.elements || null,
rotationY: opts.rotationY,
}; };
this.billboardUiManager.applyToMesh(data, billboardOpts); this.billboardUiManager.applyToMesh(data, billboardOpts);
// billboardOpts хранится в data.billboard после applyToMesh. // billboardOpts хранится в data.billboard после applyToMesh.
@ -731,7 +732,11 @@ export class PrimitiveManager {
/** Все инстансы как массив (для Hierarchy). */ /** Все инстансы как массив (для Hierarchy). */
getAll() { 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, id: d.id, type: d.type,
x: d.x, y: d.y, z: d.z, x: d.x, y: d.y, z: d.z,
sx: d.sx, sy: d.sy, sz: d.sz, sx: d.sx, sy: d.sy, sz: d.sz,

View File

@ -77,7 +77,7 @@ export class ScriptSandbox {
_handleMessage(e) { _handleMessage(e) {
if (this._isStopped) return; if (this._isStopped) return;
const { cmd, payload } = e.data || {}; const { cmd, payload } = e.data || {};
if (cmd === 'boot') return; // Worker boot, ничего не делаем if (cmd === 'boot') return;
if (cmd === 'ready') { if (cmd === 'ready') {
this._isReady = true; this._isReady = true;
// Доставим pending snapshot'ы (приходили до ready) // Доставим pending snapshot'ы (приходили до ready)
@ -97,6 +97,10 @@ export class ScriptSandbox {
try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: this._pendingDataSnapshot }); } catch (e) {} try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: this._pendingDataSnapshot }); } catch (e) {}
this._pendingDataSnapshot = null; 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) { if (this._pendingEvents.length > 0) {
for (const ev of this._pendingEvents) { for (const ev of this._pendingEvents) {
@ -175,6 +179,16 @@ export class ScriptSandbox {
try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: snapshot }); } catch (e) {} 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). * Карта высот гладкого ландшафта для game.scene.surfaceY(x,z).
* Шлётся один раз (террейн не меняется в Play). Формат: * Шлётся один раз (террейн не меняется в Play). Формат:

View File

@ -99,6 +99,14 @@ let _selfUntouchHandlers = [];
let _selfInteractHandlers = []; let _selfInteractHandlers = [];
// Зеркало всех GUI-элементов (синхронизируется main через cmd='guiSnapshot') // Зеркало всех GUI-элементов (синхронизируется main через cmd='guiSnapshot')
let _guiIndex = []; 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) // Подписки game.gui.onClick(id, fn)
let _guiClickHandlers = {}; let _guiClickHandlers = {};
// Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter) // Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter)
@ -112,10 +120,14 @@ let _billboardClickHandlers = {};
// Для GUI-события с реальным id вернуть набор ключей, под которыми // Для GUI-события с реальным id вернуть набор ключей, под которыми
// могли быть зарегистрированы handlers: сам id + имя элемента (скрипт // могли быть зарегистрированы handlers: сам id + имя элемента (скрипт
// часто подписывается через game.gui.onClick('ИмяКнопки', fn)). // часто подписывается через game.gui.onClick('ИмяКнопки', fn)).
function _guiHandlerKeys(id) { function _guiHandlerKeys(id, localId) {
const keys = [id]; 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); 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; return keys;
} }
@ -918,6 +930,69 @@ const game = {
setSkinVisible(visible) { setSkinVisible(visible) {
_send('player.setSkinVisible', { visible: !!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'. * Режим камеры: 'first' | 'third' | 'front' | 'sideview'.
* 'sideview' нужен для Кубикон Dash камера фиксируется сбоку, * 'sideview' нужен для Кубикон Dash камера фиксируется сбоку,
@ -927,6 +1002,22 @@ const game = {
if (typeof mode !== 'string') return; if (typeof mode !== 'string') return;
_send('player.setCameraMode', { mode }); _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 ед. * Присед: уменьшает высоту хитбокса игрока с 1.8 до 0.9 ед.
* Используется чтобы пройти под низким потолком. * Используется чтобы пройти под низким потолком.
@ -1311,6 +1402,11 @@ const game = {
if (typeof id !== 'string' || !id) return; if (typeof id !== 'string' || !id) return;
_send('ui.set', { id, text: null }); _send('ui.set', { id, text: null });
}, },
/** Алиас remove. */
delete(id) {
if (typeof id !== 'string' || !id) return;
_send('ui.set', { id, text: null });
},
/** Убрать весь HUD. */ /** Убрать весь HUD. */
clear() { clear() {
_state.score = null; _state.score = null;
@ -2114,6 +2210,32 @@ const game = {
if (typeof id !== 'string' || typeof fn !== 'function') return; if (typeof id !== 'string' || typeof fn !== 'function') return;
(_guiSubmitHandlers[id] = _guiSubmitHandlers[id] || []).push(fn); (_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). * Камера тряска, FOV, привязка к объекту, катсцены (Фаза 5.7).
@ -2187,6 +2309,283 @@ const game = {
setVisible(visible) { setVisible(visible) {
_send('hud.setVisible', { visible: !!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. * Инвентарь игрока (Фаза 4.2) 5 слотов hot-bar.
@ -2694,17 +3093,38 @@ const game = {
* opts { template?, face?, content?, elements? } * opts { template?, face?, content?, elements? }
*/ */
set(ref, opts) { set(ref, opts) {
if (!ref || typeof opts !== 'object' || opts == null) return; const refStr = _normRef(ref);
_send('billboard.set', { ref, ...opts }); 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) { update(ref, secondArg, thirdArg) {
if (!ref || typeof patch !== 'object' || patch == null) return; const refStr = _normRef(ref);
_send('billboard.update', { ref, patch }); 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'; * Подписаться на клик по кнопке таблички (shop-item: buttonId='buy';
@ -2715,18 +3135,18 @@ const game = {
*/ */
onClick(ref, buttonId, fn) { onClick(ref, buttonId, fn) {
if (typeof fn !== 'function') { if (typeof fn !== 'function') {
// Поддержка вызова с 2 аргументами — buttonId по умолчанию 'buy'.
fn = buttonId; fn = buttonId;
buttonId = 'buy'; 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 bid = String(buttonId || 'buy');
const key = ref + ':' + bid; const key = refStr + ':' + bid;
if (!_billboardClickHandlers[key]) _billboardClickHandlers[key] = []; if (!_billboardClickHandlers[key]) _billboardClickHandlers[key] = [];
_billboardClickHandlers[key].push(fn); _billboardClickHandlers[key].push(fn);
// Уведомляем main о подписке (чтобы он зарегистрировал hit-listener _send('billboard.onClick', { ref: refStr, buttonId: bid });
// в BillboardUiManager и слал нам billboardClick события).
_send('billboard.onClick', { ref, buttonId: bid });
}, },
}, },
/** /**
@ -2743,6 +3163,19 @@ const game = {
if (mode !== 'ui' && mode !== 'game') return; if (mode !== 'ui' && mode !== 'game') return;
_send('input.setCursorMode', { mode }); _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-режиме. * Подписаться на движение мыши в UI-режиме.
* fn(x, y) нормализованные координаты [0..1] относительно канваса. * fn(x, y) нормализованные координаты [0..1] относительно канваса.
@ -2791,6 +3224,25 @@ const game = {
* game.save.merge('progress', { increment: { attempts: 1 } }); * game.save.merge('progress', { increment: { attempts: 1 } });
* game.save.leaderboard('progress', 'gold', 'desc', fn(entries) {...}); * 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: { save: {
/** Прочитать namespace. fn(data) — data это сохранённый объект или null. */ /** Прочитать namespace. fn(data) — data это сохранённый объект или null. */
get(namespace, fn) { get(namespace, fn) {
@ -3013,14 +3465,10 @@ self.onmessage = (e) => {
if (payload.selfPosition) _selfPosition = payload.selfPosition; if (payload.selfPosition) _selfPosition = payload.selfPosition;
_selfApi = _buildSelfApi(); _selfApi = _buildSelfApi();
} }
// modules: { 'имя': 'код' } — все скрипты проекта, доступные через game.require
if (payload && payload.modules && typeof payload.modules === 'object') { if (payload && payload.modules && typeof payload.modules === 'object') {
_moduleCode = payload.modules; _moduleCode = payload.modules;
} }
try { try {
// exports передаём всегда — скрипт может быть и модулем (пишет в
// exports), и обычным скриптом (игнорирует его). Без этого
// скрипт-модуль падает с 'exports is not defined' при прямом запуске.
const exportsObj = {}; const exportsObj = {};
const userFn = new Function('game', 'exports', '"use strict";\\n' + payload.code); const userFn = new Function('game', 'exports', '"use strict";\\n' + payload.code);
userFn(game, exportsObj); userFn(game, exportsObj);
@ -3259,17 +3707,27 @@ self.onmessage = (e) => {
for (const fn of arr) _safeCall(fn, payload.data, 'onMessage:' + name); for (const fn of arr) _safeCall(fn, payload.data, 'onMessage:' + name);
} else if (t === 'guiClick') { } else if (t === 'guiClick') {
const id = String(payload.id || ''); const id = String(payload.id || '');
// Собираем handlers и по id, и по имени элемента — скрипт const localId = payload.localId != null ? String(payload.localId) : null;
// мог подписаться через game.gui.onClick('ИмяКнопки', fn). // Собираем handlers по id, по локальному ref и по имени элемента —
for (const key of _guiHandlerKeys(id)) { // скрипт мог подписаться любым из этих ключей.
const arr = _guiClickHandlers[key] || []; // _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); for (const fn of arr) _safeCall(fn, { id }, 'gui.onClick:' + key);
} }
} else if (t === 'guiSubmit') { } else if (t === 'guiSubmit') {
const id = String(payload.id || ''); const id = String(payload.id || '');
const localId = payload.localId != null ? String(payload.localId) : null;
const val = payload.value != null ? String(payload.value) : ''; const val = payload.value != null ? String(payload.value) : '';
for (const key of _guiHandlerKeys(id)) { const _matched = new Set();
const arr = _guiSubmitHandlers[key] || []; 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); for (const fn of arr) _safeCall(fn, val, 'gui.onSubmit:' + key);
} }
} else if (t === 'billboardClick') { } else if (t === 'billboardClick') {
@ -3291,6 +3749,41 @@ self.onmessage = (e) => {
for (const fn of arr) _safeCall(fn, { ref: realRef, button }, for (const fn of arr) _safeCall(fn, { ref: realRef, button },
'billboard.onClick:' + key); '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') { } else if (cmd === 'sceneSnapshot') {
// payload: { blocks, models, primitives } — массивы { ref, x, y, z, name?, type? } // payload: { blocks, models, primitives } — массивы { ref, x, y, z, name?, type? }
@ -3306,6 +3799,14 @@ self.onmessage = (e) => {
} else if (cmd === 'guiSnapshot') { } else if (cmd === 'guiSnapshot') {
// payload: массив всех GUI-элементов (для game.gui.find/get/all) // payload: массив всех GUI-элементов (для game.gui.find/get/all)
_guiIndex = Array.isArray(payload) ? payload : []; _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') { } else if (cmd === 'dataSnapshot') {
// payload: { ref: { key: value } } — атрибуты всех объектов // payload: { ref: { key: value } } — атрибуты всех объектов
_dataIndex = payload && typeof payload === 'object' ? payload : {}; _dataIndex = payload && typeof payload === 'object' ? payload : {};
@ -3403,6 +3904,8 @@ _send('boot', null);
* Создаёт URL Worker-кода для new Worker(url). * Создаёт URL Worker-кода для new Worker(url).
*/ */
export function getWorkerSourceUrl() { 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); return URL.createObjectURL(blob);
} }

View File

@ -13,6 +13,8 @@ import KubikonLeaderboard, { formatTimeMs } from '../components/KubikonLeaderboa
import KubikonPerfOverlay from '../components/KubikonPerfOverlay/KubikonPerfOverlay'; import KubikonPerfOverlay from '../components/KubikonPerfOverlay/KubikonPerfOverlay';
import Hotbar from '../editor/Hotbar'; import Hotbar from '../editor/Hotbar';
import PlayerHud from '../editor/PlayerHud'; import PlayerHud from '../editor/PlayerHud';
import ModalOverlay from '../editor/ModalOverlay';
import SkinShopOverlay from '../editor/SkinShopOverlay';
import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton'; import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton';
import KubikonChatPanel from './KubikonChatPanel'; import KubikonChatPanel from './KubikonChatPanel';
import { useAuth } from '../auth/AuthContext.jsx'; import { useAuth } from '../auth/AuthContext.jsx';
@ -125,6 +127,9 @@ const KubikonPlayer = () => {
const [hp, setHp] = useState({ hp: 100, maxHp: 100 }); const [hp, setHp] = useState({ hp: 100, maxHp: 100 });
// Скрипт через game.hud.setVisible(false) полностью скрывает стандартный HUD. // Скрипт через game.hud.setVisible(false) полностью скрывает стандартный HUD.
const [stdHudVisible, setStdHudVisible] = useState(true); const [stdHudVisible, setStdHudVisible] = useState(true);
// Задача 03: отдельный контроль хотбара/HP для игр без инвентаря/жизней.
const [hotbarVisible, setHotbarVisible] = useState(true);
const [hpVisible, setHpVisible] = useState(true);
const [ammo, setAmmo] = useState(null); const [ammo, setAmmo] = useState(null);
const [hurtFlash, setHurtFlash] = useState(0); const [hurtFlash, setHurtFlash] = useState(0);
const [inventoryState, setInventoryState] = useState({ slots: [], activeIndex: 0 }); const [inventoryState, setInventoryState] = useState({ slots: [], activeIndex: 0 });
@ -320,6 +325,9 @@ const KubikonPlayer = () => {
if (projectId) scene.setCurrentProjectId(projectId); if (projectId) scene.setCurrentProjectId(projectId);
// game.hud.setVisible(false) скроет HP-бар/hotbar для своего меню // game.hud.setVisible(false) скроет HP-бар/hotbar для своего меню
scene.setOnStdHudVisibilityChange?.((v) => setStdHudVisible(v)); scene.setOnStdHudVisibilityChange?.((v) => setStdHudVisible(v));
// Задача 03: отдельные подписки
scene.setOnHotbarVisibilityChange?.((v) => setHotbarVisible(v));
scene.setOnHpVisibilityChange?.((v) => setHpVisible(v));
// Колбэки HUD // Колбэки HUD
scene.setOnPlayerHpChange?.((h) => { scene.setOnPlayerHpChange?.((h) => {
@ -909,6 +917,10 @@ const KubikonPlayer = () => {
{/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */} {/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */}
{!loading && ( {!loading && (
<> <>
{/* Задача 04: модал-overlay (затемнение + spotlight mask). */}
<ModalOverlay scene={sceneRef.current} />
{/* Задача 07: встроенный магазин скинов (B / API). */}
<SkinShopOverlay scene={sceneRef.current} />
{/* HUD: на мобиле уменьшаем и сдвигаем компактно. */} {/* HUD: на мобиле уменьшаем и сдвигаем компактно. */}
{isTouch ? ( {isTouch ? (
<> <>
@ -919,7 +931,7 @@ const KubikonPlayer = () => {
pointerEvents: 'none', zIndex: 30, pointerEvents: 'none', zIndex: 30,
}}> }}>
<PlayerHud <PlayerHud
visible={stdHudVisible} visible={stdHudVisible && hpVisible}
hp={hp.hp} hp={hp.hp}
maxHp={hp.maxHp} maxHp={hp.maxHp}
ammo={null} ammo={null}
@ -936,9 +948,9 @@ const KubikonPlayer = () => {
)} )}
{/* Hotbar только если в инвентаре есть хоть {/* Hotbar только если в инвентаре есть хоть
один предмет. Пустой инвентарь не показываем. */} один предмет. Пустой инвентарь не показываем. */}
{stdHudVisible && (inventoryState.slots || []).some(s => s) && ( {stdHudVisible && hotbarVisible && (inventoryState.slots || []).some(s => s) && (
<Hotbar <Hotbar
visible={stdHudVisible} visible={stdHudVisible && hotbarVisible}
mobileMode mobileMode
slots={inventoryState.slots} slots={inventoryState.slots}
activeIndex={inventoryState.activeIndex} activeIndex={inventoryState.activeIndex}
@ -949,15 +961,15 @@ const KubikonPlayer = () => {
) : ( ) : (
<> <>
<PlayerHud <PlayerHud
visible={stdHudVisible} visible={stdHudVisible && hpVisible}
hp={hp.hp} hp={hp.hp}
maxHp={hp.maxHp} maxHp={hp.maxHp}
ammo={ammo} ammo={ammo}
damaged={Date.now() - hurtFlash < 350} damaged={Date.now() - hurtFlash < 350}
/> />
{stdHudVisible && (inventoryState.slots || []).some(s => s) && ( {stdHudVisible && hotbarVisible && (inventoryState.slots || []).some(s => s) && (
<Hotbar <Hotbar
visible={stdHudVisible} visible={stdHudVisible && hotbarVisible}
slots={inventoryState.slots} slots={inventoryState.slots}
activeIndex={inventoryState.activeIndex} activeIndex={inventoryState.activeIndex}
onSelect={(i) => sceneRef.current?.setActiveInventorySlot?.(i)} onSelect={(i) => sceneRef.current?.setActiveInventorySlot?.(i)}