From 42be04def95d4f9253c37bbffb280e816f07600f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=98=D0=9D?= Date: Sat, 30 May 2026 00:50:56 +0300 Subject: [PATCH] =?UTF-8?q?feat(week4):=20=D0=BC=D0=BE=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=81=D1=86=D0=B5=D0=BD=D1=8B,=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=81=D1=82=D0=BE=D0=BC=D0=BD=D1=8B=D0=B5=20=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D0=BD=D1=8B=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B8=20=D0=B2=D0=B8=D0=BA=D0=B8-=D0=B3=D0=B0=D0=B9=D0=B4?= =?UTF-8?q?=D1=8B=20=D0=BF=D0=BE=204=20=D0=B8=D0=B3=D1=80=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Задача 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) --- src/community/KubikonDocs.jsx | 34 +- src/community/docsData.jsx | 4784 +++++++++++----------- src/community/docsGames.js | 26 + src/community/docsLessons.jsx | 458 +++ src/editor/GameHud.jsx | 62 +- src/editor/GuiOverlay.jsx | 152 +- src/editor/InspectorPanel.jsx | 196 + src/editor/KubikonEditor.jsx | 268 +- src/editor/ModalOverlay.jsx | 101 + src/editor/SkinManagerModal.jsx | 713 ++++ src/editor/SkinShopOverlay.jsx | 294 ++ src/editor/engine/BabylonScene.js | 176 +- src/editor/engine/BillboardUiManager.js | 206 +- src/editor/engine/GameRuntime.js | 545 ++- src/editor/engine/GuiManager.js | 19 + src/editor/engine/ModalManager.js | 398 ++ src/editor/engine/PlayerController.js | 688 +++- src/editor/engine/PrimitiveManager.js | 9 +- src/editor/engine/ScriptSandbox.js | 16 +- src/editor/engine/ScriptSandboxWorker.js | 557 ++- src/preview-player/KubikonPlayer.jsx | 24 +- 21 files changed, 7124 insertions(+), 2602 deletions(-) create mode 100644 src/editor/ModalOverlay.jsx create mode 100644 src/editor/SkinManagerModal.jsx create mode 100644 src/editor/SkinShopOverlay.jsx create mode 100644 src/editor/engine/ModalManager.js diff --git a/src/community/KubikonDocs.jsx b/src/community/KubikonDocs.jsx index 37de97b..5fe7f51 100644 --- a/src/community/KubikonDocs.jsx +++ b/src/community/KubikonDocs.jsx @@ -394,31 +394,45 @@ const LessonPage = ({ game, navigate }) => { const [state, setState] = useState('idle'); // Создаёт НОВУЮ копию игры-урока на текущем пользователе и - // открывает её в редакторе. Исходник (билдер) при этом цел. + // открывает её в редакторе. Оригинал при этом ВСЕГДА цел. const openInEditor = async () => { const userId = getCurrentUserId(); if (!userId) { setState('error'); return; } - const project = buildGameProject(game.id); - if (!project) { setState('error'); return; } setState('creating'); try { + // project_data копии берём двумя способами: + // - у обычных уроков (1-50) — собираем из билдера; + // - у разбора готовых игр (g5) — ЗАГРУЖАЕМ project_data + // оригинала из БД и копируем его (оригинал не трогаем!). + let projectDataStr; + if (game.openProjectId) { + const orig = await Kubikon3DApi.getProjectWithRetry(game.openProjectId, userId); + const pd = orig && orig.data && orig.data.project_data; + if (!pd) { setState('error'); return; } + // project_data может прийти строкой или объектом — нормализуем в строку. + projectDataStr = typeof pd === 'string' ? pd : JSON.stringify(pd); + } else { + const project = buildGameProject(game.id); + if (!project) { setState('error'); return; } + projectDataStr = JSON.stringify(project); + } const res = await Kubikon3DApi.createProject(userId, { user_id: userId, - title: 'Урок: ' + game.title, - description: 'Игра-урок из вики Рублокса. Можешь свободно её менять.', + title: 'Моя копия: ' + game.title, + description: 'Игра-урок из вики Рублокса. Это твоя копия — меняй как хочешь, оригинал не пострадает.', genre: 'other', thumbnail: '', is_public: false, - project_data: JSON.stringify(project), + project_data: projectDataStr, }); const newId = res.data && res.data.id; if (newId) navigate('/edit/' + newId); else setState('error'); } catch (e) { - console.error('[LessonPage] createProject error:', e); + console.error('[LessonPage] openInEditor error:', e); setState('error'); } }; @@ -441,8 +455,8 @@ const LessonPage = ({ game, navigate }) => {
Хочешь сразу посмотреть готовую игру?
- Открой её в редакторе — создастся твоя копия, можешь - свободно её менять и разбираться, как всё устроено. + Открой её в редакторе — создастся твоя личная копия. + Меняй её как хочешь, нажимай «Играть» — оригинал не пострадает.
{state === 'error' && ( diff --git a/src/community/docsData.jsx b/src/community/docsData.jsx index 938ffcb..36730e1 100644 --- a/src/community/docsData.jsx +++ b/src/community/docsData.jsx @@ -1,2392 +1,2392 @@ -import React from 'react'; -import DocIcon from './docsIcons'; - -/** - * docsData.jsx — контент вики редактора Рублокс (разделы A-J). - * - * Структура: DOCS = массив глав. Каждая глава — - * { id, icon, title, summary, sections: [{ id, title, body }] } - * - icon — имя SVG-иконки из docsIcons.jsx (НЕ эмодзи). - * - summary — короткое описание для карточки на главной вики. - * - * Хелперы для оформления: - * - — код-блок (тёмный, моноширинный) - * - — плашка «куда писать скрипт» - * - — шаг инструкции - * - — жёлтая плашка-подсказка - * - — зелёная плашка «попробуй сам» - * - * Контент написан для детей 5 класса+. Каждый пример — рабочий код, - * который можно скопировать в свою игру. Эмодзи в UI не используются — - * только SVG-иконки (см. docsIcons.jsx). - */ - -// ── Код-блок ────────────────────────────────────────────────────── -export const Code = ({ children }) => ( -
{children}
-); - -// ── Плашка «куда писать скрипт» ─────────────────────────────────── -// kind="global" — глобальный скрипт (создаётся в категории «Скрипты») -// kind="object" — скрипт привязан к объекту (передай on="название объекта") -export const ScriptKind = ({ kind, on }) => { - if (kind === 'object') { - return ( -
- - - Куда писать: этот скрипт нужно повесить на объект - {on ? <> — на {on} : null}. Выдели объект на сцене - и создай скрипт прямо на нём. Тогда внутри скрипта работает - слово game.self — это и есть твой объект. - -
- ); - } - return ( -
- - - Куда писать: это глобальный скрипт. Создай его - в иерархии в категории Скрипты (кнопка «+»). Он не привязан - ни к какому объекту и запускается один раз при старте игры. - -
- ); -}; - -// ── Шаг инструкции ──────────────────────────────────────────────── -export const Step = ({ n, children }) => ( -
- {n} -
{children}
-
-); - -// ── Жёлтая плашка-подсказка ─────────────────────────────────────── -export const Note = ({ children }) => ( -
- -
{children}
-
-); - -// ── Зелёная плашка «попробуй сам» ───────────────────────────────── -export const Try = ({ children }) => ( -
- -
- Попробуй сам: {children} -
-
-); - -// ── Скриншот интерфейса с подписью ──────────────────────────────── -// src — имя файла из public/wiki/, caption — подпись под картинкой. -// wide — для широких скринов (обзор, лента): растянуть на всю ширину. -export const Shot = ({ src, caption, wide }) => ( -
- {caption - {caption &&
{caption}
} -
-); - -// ══════════════════════════════════════════════════════════════════ -// DOCS — разделы вики A-J -// ══════════════════════════════════════════════════════════════════ - -export const DOCS = [ - // ════════════════════════════════════════════════════ - // РАЗДЕЛ A — ОСНОВЫ - // ════════════════════════════════════════════════════ - { - id: 'basics', - icon: 'rocket', - title: 'Основы', - summary: 'С чего начать: интерфейс редактора, инструменты, первая игра за 5 минут.', - sections: [ - { - id: 'what-is-rublox', - title: 'A1. Что такое Рублокс и редактор игр', - body: ( - <> -

- Рублокс — это платформа, где можно играть в 3D-игры - и создавать свои собственные. Всё работает прямо в браузере: - ничего скачивать и устанавливать не нужно. -

-

- Редактор игр (его ещё называют Studio) — это место, - где ты строишь игру. Ты ставишь блоки и модели, рисуешь - кнопки, пишешь скрипты — а потом нажимаешь «Играть» - и сразу проверяешь, что получилось. -

-

В Рублоксе можно сделать почти любую игру:

-
    -
  • паркур и платформеры — прыгай по платформам;
  • -
  • гонки — мчись к финишу на время;
  • -
  • стрелялки и арены — сражайся с врагами;
  • -
  • головоломки и квесты — решай загадки;
  • -
  • выживалки и целые RPG с героями и заданиями.
  • -
-

- Как устроена эта вика. Разделы A-C научат строить - мир и интерфейс. Разделы D-G — писать скрипты (код, который - оживляет игру). Раздел H — справочник всех команд. Раздел I — - словарик непонятных слов. Раздел J — что делать, если - что-то сломалось. Раздел K — 50 готовых игр-уроков. -

- - Не нужно читать всё подряд. Пройди раздел «Основы», - а дальше открывай то, что нужно прямо сейчас. - - - ), - }, - { - id: 'editor-interface', - title: 'A2. Интерфейс редактора', - body: ( - <> -

- Когда ты открываешь игру в редакторе, экран делится - на части. Разберём каждую: -

- -
    -
  • - 1 — Шапка сверху — название игры и кнопки: - Настройки, - Сохранить, - Играть, - Опубликовать. -
  • -
  • - 2 — Лента инструментов под шапкой — вкладки - Главная / Модель / Игра / Вид. На каждой вкладке - свои кнопки и инструменты. -
  • -
  • - 3 — 3D-сцена (вьюпорт) — твой игровой мир - в центре экрана. Тут ты всё и строишь. -
  • -
  • - 4 — Правая панель — сверху Иерархия (список - всех объектов), снизу Инспектор (свойства того - объекта, который ты выделил мышкой). -
  • -
-

- Когда ты выбираешь инструмент «Блок» или «Примитив», - слева открывается ещё одна палитра — там лежат - фигуры, которые можно ставить на сцену. -

- - -

Как двигать камеру в редакторе:

- - - - - - -
Правая кнопка мыши + движениеосмотреться по сторонам
WASDлететь вперёд / влево / назад / вправо
Колесо мышиприблизить / отдалить
- - Камера редактора и камера игрока — это разные камеры. - То, как ты летаешь по сцене сейчас, не влияет на то, как - будет видеть мир игрок. - - - ), - }, - { - id: 'first-game-5min', - title: 'A3. Первая игра за 5 минут', - body: ( - <> -

- Соберём самую простую игру — площадку, по которой можно - ходить. Делай по шагам. -

- - Открой редактор и создай новую игру - (или выбери пустой шаблон). - - - На вкладке Главная выбери инструмент - Блок. В левой палитре кликни - на блок травы — он станет выбранным. - - - Кликай по сцене — блоки будут вставать один за другим. - Собери небольшую площадку примерно 6×6 блоков. - - - Перейди на вкладку Игра и выбери - Ставить спавн. Кликни - на площадку — там появится точка, где игрок начнёт игру. - - - Нажми Играть в шапке. Ты - окажешься на своей площадке и сможешь по ней ходить! - - - Нажми Esc, чтобы вернуться - в редактор, и Сохранить. - -

- Поздравляем — это уже работающая игра. Дальше ты добавишь - в неё препятствия, врагов, монетки и логику. -

- - добавь по краям площадки стены из блоков, чтобы нельзя - было упасть. И поставь в центре несколько блоков-ступенек. - - - ), - }, - { - id: 'creation-tools', - title: 'A4. Инструменты: блок, примитив, модель, ландшафт', - body: ( - <> -

На вкладке Главная есть инструменты создания:

- -
    -
  • - Блок — ставит кубический блок (трава, камень, - дерево...). Блоки ровно встают по сетке — из них удобно - строить дома и стены, как из кубиков Лего. -
  • -
  • - Примитив — простая фигура: куб, сфера, цилиндр, - конус, плоскость, тор, клин. У примитива можно свободно - менять размер по каждой оси и красить в любой цвет. -
  • -
  • - Модель — готовая красивая 3D-модель из библиотеки - (дерево, бочка, машина, оружие). Можно загружать и свои - модели в формате .glb. -
  • -
  • - Ландшафт — инструмент лепки рельефа: холмы, горы, - пещеры. Об этом — раздел B4. -
  • -
  • - Стереть — удаляет блок или объект под курсором. -
  • -
-

- Когда выбираешь «Блок» или «Примитив», слева открывается - палитра — выбери в ней фигуру, а потом кликай - по сцене, чтобы её поставить. -

- -

- Шаг привязки (1.0 / 0.5 / 0.25 / Выкл) — задаёт, - насколько мелко объект «прилипает» к сетке, когда ты его - двигаешь. Шаг 1.0 — объект двигается крупными шагами, - ровно по клеткам. Маленький шаг 0.25 — точнее, но дольше. -

- - - Чем отличаются блок и примитив-куб? Блок всегда одного - размера и быстро ставится сеткой — он для стройки. - Примитив-куб можно растянуть в длинную платформу или - тонкую стенку — он для геймплея. - - - ), - }, - { - id: 'gizmo', - title: 'A5. Гизмо-манипуляторы: двигать, вращать, масштаб', - body: ( - <> -

- Гизмо — это цветные стрелки и кольца, которые - появляются на выделенном объекте. Они помогают точно - его двигать, поворачивать и менять размер. -

-

- Режим гизмо выбирается в ленте инструментов — группа - «Манипуляторы»: -

- - - - - - - - -
Выделитьобычный режим, клик выбирает объект
Двигатьтри стрелки X / Y / Z — тяни, объект едет по оси
Вращатьтри кольца — тяни, объект поворачивается
Масштабкубики на осях — тяни, объект растягивается
-

- Вот как выглядит гизмо «Двигать» на выделенном кубе — - три цветные стрелки: -

- -

- Оси всегда одни и те же и покрашены одинаково: -

-
    -
  • X (красная) — влево / вправо;
  • -
  • Y (зелёная) — вверх / вниз;
  • -
  • Z (синяя) — вперёд / назад.
  • -
- - Эти же буквы X, Y, Z ты увидишь в скриптах. Когда команда - пишет {`{ x: 5, y: 2, z: 0 }`} — это точка - в мире: 5 вправо, 2 вверх, 0 вперёд. - - - ), - }, - { - id: 'hierarchy', - title: 'A6. Иерархия объектов и папки', - body: ( - <> -

- Иерархия — это список всех объектов твоей игры - в правой панели. Когда сцена большая, найти нужный куб - мышкой трудно — а в списке он всегда под рукой. -

- -

Объекты сгруппированы по категориям:

-
    -
  • Сцена — точка спавна, окружение, свет;
  • -
  • Игрок — скин персонажа;
  • -
  • Интерфейс — GUI-элементы (кнопки, надписи);
  • -
  • Скрипты — твой код.
  • -
-

- Имя объекта. У каждого объекта есть имя — его видно - в иерархии и можно изменить в инспекторе. Имена очень важны - для скриптов: команда game.scene.findOne('Дверь') - находит объект по имени. Давай объектам понятные - имена: «Дверь», «Монетка1», «Босс». -

-

- Папки. Объекты можно складывать в свои папки — - например, папка «Уровень 1», папка «Враги». Это как - наводить порядок в шкафу: всё на своих полках. Двойной - клик по объекту в списке — камера прилетит прямо к нему. -

- - ), - }, - { - id: 'hotkeys', - title: 'A7. Горячие клавиши', - body: ( - <> -

Горячие клавиши экономят кучу времени:

- - - - - - - - - - -
Ctrl+SСохранить игру
Ctrl+ZОтменить последнее действие
Ctrl+DДублировать выделенный объект
DelУдалить выделенное
RПовернуть объект на 90°
EscСнять выделение / выйти из режима
FНавести камеру на выделенное
- - Самая полезная привычка — почаще жать - Ctrl+S. - Сохраняться нужно не «когда закончил», а каждые пару минут. - - - ), - }, - { - id: 'play-mode', - title: 'A8. Режим игры: HP, смерть, респаун', - body: ( - <> -

- Кнопка Запустить в правой части - ленты запускает игру. Сцена «оживает»: включается физика, - начинают работать скрипты, появляется HUD (счётчики поверх - экрана). Чтобы остановить игру — кнопка - Стоп или клавиша - Esc. -

- -

Управление в игре:

- - - - - - - - - - - -
WASDИдти
SpaceПрыжок
ShiftБег
МышьПоворот камеры
ЛКМАтака / стрельба
EВзаимодействовать
15Слот инвентаря
C1-е / 3-е лицо
-

- HP (здоровье) игрока видно в левом верхнем углу. - Когда игрок получает урон, полоска краснеет. Если HP падает - до нуля — игрок «умирает» и через пару секунд - воскресает (респаун) на точке спавна с полным - здоровьем. -

-

- Всем этим можно управлять из скриптов: наносить урон, - лечить, ставить чекпоинты. Об этом — раздел F1. -

- - ), - }, - { - id: 'save-publish', - title: 'A9. Сохранение и публикация', - body: ( - <> -

- Сохранение — кнопка Сохранить - или Ctrl+S. - Игра автоматически сохраняется и сама время от времени, - но лучше не лениться и сохранять руками. -

-

- Публикация — когда игра готова, нажми - Опубликовать. Выбери - возрастной рейтинг (6+, 12+, 16+, 18+) и куда отправить: -

-
    -
  • - В главную ленту — игру увидят все ученики. - Модерация строже. -
  • -
  • - Только в профиле — игра доступна по ссылке - и в твоём профиле, но в общей ленте её не будет. -
  • -
-

- После отправки игру проверит модератор (обычно за 24-48 - часов). Игра не должна содержать матов, рекламы, - чужого контента (модели из Roblox/Minecraft) и жестокости - не по возрасту. -

- - ), - }, - ], - }, - - // ════════════════════════════════════════════════════ - // РАЗДЕЛ B — ОБЪЕКТЫ СЦЕНЫ - // ════════════════════════════════════════════════════ - { - id: 'objects', - icon: 'cube', - title: 'Объекты сцены', - summary: 'Блоки, примитивы, модели, ландшафт, свет, частицы и свойства объектов.', - sections: [ - { - id: 'blocks', - title: 'B1. Блоки и их типы', - body: ( - <> -

- Блок — это кубик одного размера. Блоки ровно - встают по сетке, и из них удобно строить — как из кубиков - Лего. В палитре слева есть разные типы: трава, камень, - песок, дерево, кирпич, цветные блоки, блоки со снегом. -

-

- Чтобы поставить блок — выбери инструмент - Блок, кликни нужный тип - в палитре и кликай по сцене. Чтобы убрать — инструмент - Стереть. -

- -

- У блока в инспекторе можно включить/выключить - столкновение (твёрдый он или сквозь него можно - пройти) и видимость. -

- - Невидимый блок с включённым столкновением — это уже - готовый «невидимый барьер». Игрок в него упрётся, - но не увидит. Так делают границы уровня. - - - ), - }, - { - id: 'primitives', - title: 'B2. Примитивы', - body: ( - <> -

- Примитив — простая 3D-фигура. В отличие от блока, - у примитива можно свободно менять размер по каждой оси - и красить в любой цвет. Виды: -

- - - - - - - - - - -
Кубстены, платформы, ящики
Сферамячи, монетки, планеты
Цилиндрколонны, бочки, трубы
Конусшипы, ёлки, шляпы
Плоскостьтонкий лист — пол, экран
Торкольцо (как бублик)
Клиннаклонная фигура — пандус
-

- Примитивы — главный «строительный материал» для игр. - Из растянутых кубов делают платформы для паркура, - из сфер — собираемые монетки, из конусов — смертельные - шипы. -

- - Чтобы в скрипте было легко найти примитив — дай ему - понятное имя в инспекторе. Например, «Платформа1». Потом - скрипт найдёт её командой - game.scene.findOne('Платформа1'). - - - ), - }, - { - id: 'models', - title: 'B3. Готовые модели и импорт своих', - body: ( - <> -

- Модель — готовая красивая 3D-фигура: дерево, камень, - бочка, машина, оружие, мебель. Их не нужно строить из - кубиков — просто бери из библиотеки и ставь на сцену. -

-

- Инструмент Модель открывает - каталог. Модели разбиты по категориям (природа, город, - оружие...). -

- -

- Свои модели. Можно загрузить собственную 3D-модель - в формате .glb. Такие файлы делают в бесплатных - редакторах вроде Blender или берут на сайтах с бесплатными - моделями. После загрузки твоя модель появится в палитре - рядом с остальными. -

- - Не бери модели из других игр (Roblox, Minecraft) — - это чужой контент, игру с ним не пропустит модерация. - Бери только бесплатные модели «для свободного использования». - - - ), - }, - { - id: 'terrain', - title: 'B4. Ландшафт: воксельный и гладкий', - body: ( - <> -

- Ландшафт — это рельеф мира: холмы, горы, ямы, пещеры. - В Рублоксе два режима: -

-
    -
  • - Воксельный — рельеф из маленьких кубиков-вокселей. - Получается «ступенчатый», как в Minecraft. Удобно - рисовать пещеры и обрывы. -
  • -
  • - Гладкий — рельеф из плавных холмов без ступенек. - Похоже на настоящую землю. -
  • -
-

- Инструменты лепки: поднять, опустить, - разгладить, покрасить. Есть и кисти-растения — - рисуешь по земле, и сразу вырастают деревья и трава. -

- - Если в игре есть гладкий ландшафт, ставь точку спавна - прямо на его поверхности. Если спавн окажется ниже земли — - игрок при старте провалится. Высоту земли в скрипте можно - узнать командой game.scene.surfaceY(x, z). - - - ), - }, - { - id: 'spawn-checkpoints', - title: 'B5. Точка спавна и чекпоинты', - body: ( - <> -

- Точка спавна — место, где игрок появляется в начале - игры и куда возвращается после смерти. Ставится на вкладке - Игра → Ставить спавн. Точка спавна одна. -

-

- Чекпоинт (контрольная точка) — промежуточное место - сохранения в длинных уровнях. Когда игрок дошёл до чекпоинта, - при следующей смерти он воскреснет уже там, а не в самом - начале. -

-

- Чекпоинт делают скриптом на флажке-объекте: когда игрок - касается флажка, скрипт запоминает это место как новый спавн. -

- - {`// Когда игрок коснётся флажка — -// это место станет новой точкой возрождения. -game.self.onTouch(() => { - // game.self.position — координаты самого флажка - game.player.setSpawn(game.self.position); - game.ui.showText('Чекпоинт сохранён!', 1.5); - game.sound.play('pickup'); -});`} -

- Что тут происходит: onTouch срабатывает, - когда игрок дотронулся до флажка. setSpawn - запоминает точку возрождения. showText - показывает надпись на 1.5 секунды. Готово — игрок - не начнёт уровень заново. -

- - ), - }, - { - id: 'lamps', - title: 'B6. Лампы (источники света)', - body: ( - <> -

- Лампа — это источник света. Кроме общего солнца, - можно поставить точечные лампы, которые освещают всё рядом - с собой. Они нужны для пещер, ночных уровней, подсветки - важных мест. -

-

- У лампы настраиваются цвет, яркость - и радиус (как далеко достаёт свет). Лампу можно - создать и из скрипта: -

- - {`// Создаём тёплую лампу над сценой -game.scene.spawn('light:point', { - x: 0, y: 4, z: 0, // где висит лампа - color: '#ffdd88', // тёплый жёлтый свет - brightness: 2, // яркость - range: 12 // радиус освещения -});`} - - ), - }, - { - id: 'particles', - title: 'B7. Эмиттеры частиц', - body: ( - <> -

- Частицы — это много маленьких летящих точек: - искры, дым, огонь, магия. Объект, который их создаёт, - называется эмиттер. -

-

- Частицы делают игру живой: костёр дымит, при победе летит - конфетти, у портала кружится магия. Из скрипта это команда - game.scene.spawnParticles: -

- - {`// Залп конфетти над центром сцены -game.scene.spawnParticles( - 'confetti', // тип эффекта - { x: 0, y: 3, z: 0 }, // где появятся частицы - { duration: 2, count: 3 } // длительность и густота -);`} -

- Типы эффектов: fire (огонь), - smoke (дым), sparks (искры), - magic (магия), explosion (взрыв), - confetti (конфетти). -

- - ), - }, - { - id: 'triggers', - title: 'B8. Триггеры', - body: ( - <> -

- Триггер — невидимая зона, которая что-то запускает, - когда игрок в неё входит. Например: игрок зашёл в зону — - открылась дверь, заиграла музыка, появился враг. -

-

Как сделать триггер:

- - Поставь примитив-куб нужного размера — это и будет зона. - - - В инспекторе выключи столкновение (чтобы игрок - проходил сквозь) и можно выключить видимость - (чтобы зону не было видно). - - - Повесь на этот куб скрипт, который ловит касание игрока: - - - {`// Игрок вошёл в зону — показываем надпись -game.self.onTouch(() => { - game.ui.showText('Ты вошёл в опасную зону!', 2); -}); - -// Игрок вышел из зоны -game.self.onUntouch(() => { - game.ui.showText('Ты в безопасности', 1.5); -});`} - - ), - }, - { - id: 'object-properties', - title: 'B9. Свойства объекта: цвет, материал, физика, замок', - body: ( - <> -

- Когда ты выделяешь объект, в Инспекторе (правая - панель снизу) появляются его свойства: -

- - - - - - - - - - - -
Цветзакрасить примитив в любой цвет
Материалобычный, металл, стекло, неон — как объект блестит
Текстураналожить свою картинку на поверхность
Столкновениетвёрдый объект или сквозь него можно пройти
Видимостьпоказать или спрятать объект
Закреплёнесли выключить — объект падает под действием физики
Замок (Lock)заблокировать, чтобы случайно не сдвинуть
- - Свойство «Закреплён» — частая причина бага «объект - провалился сквозь пол». Для платформ, стен и декораций - всегда оставляй «Закреплён» включённым. Падать должны - только ящики и мячи, которым это нужно. - - - ), - }, - ], - }, - - // ════════════════════════════════════════════════════ - // РАЗДЕЛ C — ИНТЕРФЕЙС ИГРЫ (GUI) - // ════════════════════════════════════════════════════ - { - id: 'gui', - icon: 'window', - title: 'Интерфейс игры', - summary: 'GUI: кнопки, надписи, поля ввода, меню и счётчики поверх экрана.', - sections: [ - { - id: 'gui-what-is', - title: 'C1. Что такое GUI и редактор интерфейса', - body: ( - <> -

- GUI (читается «гуи») — это интерфейс игры: - всё, что нарисовано поверх 3D-сцены. Кнопки, надписи, - счётчик очков, полоска здоровья, меню — это всё GUI. -

-

- GUI не находится внутри игрового мира — он «приклеен» - к экрану. Куда бы ни смотрел игрок, кнопка остаётся - на том же месте экрана. -

-

- В редакторе есть визуальный редактор UI: ты - перетаскиваешь элементы мышкой, ставишь их в нужное место, - меняешь цвет и текст — и сразу видишь результат. Открыть - его можно через инструмент Интерфейс. -

- - Не путай GUI и HUD. GUI — это элементы, - которые ты сам нарисовал в редакторе интерфейса. - HUD — стандартные счётчики (game.ui.score, - game.ui.timer), которые рисует сама игра - по команде из скрипта. - - - ), - }, - { - id: 'gui-elements', - title: 'C2. Контейнер, надпись, кнопка, поле ввода, картинка', - body: ( - <> -

Из чего собирается интерфейс:

- -
    -
  • - Контейнер (Frame) — прямоугольник-коробка. - Сам по себе это просто фон, но внутрь него кладут - другие элементы. Контейнер — основа любого меню. -
  • -
  • - Надпись (Label) — текст на экране. Счёт, имя - игрока, подсказки. -
  • -
  • - Кнопка (Button) — на неё можно нажать. По клику - в скрипте срабатывает действие. -
  • -
  • - Поле ввода (TextBox) — сюда игрок печатает текст - или число. Например, ввести код от двери. -
  • -
  • - Картинка (Image) — изображение: иконка, логотип, - фон меню. -
  • -
-

- Имя элемента. Как и у объектов сцены, у GUI-элемента - есть имя. Скрипт находит элемент по имени командой - game.gui.find('Кнопка старта'). Давай - кнопкам и надписям понятные имена. -

- - ), - }, - { - id: 'gui-script', - title: 'C3. Как оживить кнопку скриптом', - body: ( - <> -

- Нарисованная кнопка сама по себе ничего не делает — - нужен скрипт. Самый простой способ — повесить скрипт - прямо на кнопку. -

- - {`// Скрипт висит на кнопке. -// game.self — это сама кнопка. -game.self.onClick(() => { - game.ui.showText('Кнопка нажата!', 2); - game.sound.play('click'); -});`} -

- Можно и наоборот — управлять кнопкой из глобального - скрипта, если найти её по имени: -

- - {`// Находим кнопку по имени и вешаем на неё клик -const btnId = game.gui.find('Кнопка старта'); - -game.gui.onClick(btnId, () => { - game.ui.showText('Игра началась!', 2); - // спрятать кнопку после нажатия - game.gui.hide(btnId); -});`} -

- Что тут происходит: game.gui.find ищет - элемент по имени и возвращает его id («адрес»). - game.gui.onClick вешает на этот id действие. - game.gui.hide прячет кнопку, чтобы её нельзя - было нажать второй раз. -

- - ), - }, - { - id: 'gui-textbox', - title: 'C4. Поле ввода: дверь по коду', - body: ( - <> -

- Поле ввода позволяет игроку напечатать ответ. - Когда он нажмёт Enter, срабатывает событие - onSubmit — и скрипт получает введённый текст. -

- - {`// Игрок вводит код. Правильный код — 1234. -const boxId = game.gui.find('Поле кода'); - -game.gui.onSubmit(boxId, (text) => { - if (text === '1234') { - game.ui.showText('Верно! Дверь открыта', 2); - // двигаем дверь вверх, чтобы освободить проход - const door = game.scene.findOne('Дверь'); - game.tween(door, { y: 8 }, { duration: 1 }); - } else { - game.ui.showText('Неверный код', 1.5); - } -});`} -

- Разберём построчно: onSubmit даёт переменную - text — это то, что напечатал игрок. - if (text === '1234') — проверяем, совпал ли - код. Если да — открываем дверь твином (плавно поднимаем). - Если нет — пишем «Неверный код». -

- - Две одинарные кавычки '1234' означают, - что это текст, а не число. Игрок печатает в поле - всегда текст, поэтому и сравнивать нужно с текстом. - - - ), - }, - { - id: 'gui-styles', - title: 'C5. Стили и загрузка картинок', - body: ( - <> -

- У каждого GUI-элемента в инспекторе настраивается внешний - вид: цвет фона и прозрачность, граница - (рамка, её цвет и толщина), скругление углов - (большое скругление делает кнопку «таблеткой»), - тень (мягкая тень под элементом), - цвет и размер текста. -

-

- В элемент Картинка можно загрузить своё изображение - с компьютера (PNG или JPG): логотип игры, иконку кнопки, - фон главного меню. -

- - Хороший интерфейс — это аккуратный интерфейс. Один стиль - для всех кнопок, один шрифт, выровненные отступы. Картинки - бери небольшие — огромные файлы делают игру тяжёлой. - - - ), - }, - ], - }, - - // ════════════════════════════════════════════════════ - // РАЗДЕЛ D — СКРИПТЫ — ОСНОВЫ - // ════════════════════════════════════════════════════ - { - id: 'scripts-basics', - icon: 'code', - title: 'Скрипты — основы', - summary: 'Самый важный раздел: что такое скрипт, переменные, события, таймеры.', - sections: [ - { - id: 'what-is-script', - title: 'D1. Что такое скрипт и как его создать', - body: ( - <> -

- Скрипт — это набор команд, который оживляет игру. - Блоки и модели сами по себе просто стоят. Чтобы монетка - собиралась, дверь открывалась, а враг гнался за игроком — - нужен скрипт. -

-

- Скрипты пишут на языке JavaScript — одном из самых - популярных языков в мире. Не пугайся: начнём с простого, - а редактор подсказывает команды по ходу набора. -

-

Как создать первый скрипт:

- - В иерархии (правая панель) найди категорию Скрипты - и нажми кнопку «+». - - - - Откроется окно кода. Напиши в нём одну строку: - - {`game.log('Привет! Игра запустилась.');`} - - - Нажми Играть. Внизу справа - открой Консоль — там появится твоё сообщение. - -

- Это твой первый работающий скрипт. Команда - game.log(...) печатает сообщение в консоль. -

- - Каждая команда заканчивается точкой с запятой - ; — как точка в конце предложения. Текст - пишут в кавычках: 'привет'. Забыл кавычки - или точку с запятой — будет ошибка. - - - ), - }, - { - id: 'global-vs-object', - title: 'D2. Глобальный скрипт и скрипт на объекте', - body: ( - <> -

- Это очень важно понять с самого начала. Скрипты бывают - двух видов, и в каждом уроке вики написано, какой - именно нужен. -

- -

- Глобальный скрипт — это «мозг игры». Он не привязан - ни к чему. Запускается один раз при старте. В нём пишут - общие правила: подсчёт очков, таймер уровня, проверку - победы. -

- -

- Скрипт на объекте относится к конкретному кубу, модели - или кнопке. Внутри такого скрипта работает волшебное слово - game.self — это и есть тот объект, на котором - висит скрипт. Через него ловят клик по объекту или касание - игроком. -

-

- Как привязать скрипт к объекту: выдели объект - на сцене, потом создай скрипт — он автоматически привяжется - к выделенному объекту. Или укажи носителя в настройках - скрипта. -

- - Простое правило: если в коде урока есть - game.self — это скрипт на объекте. - Если game.self нет — скрипт глобальный. - Плашка в начале каждого урока всегда подскажет. - - - ), - }, - { - id: 'variables', - title: 'D3. Переменные — память скрипта', - body: ( - <> -

- Переменная — это «коробочка с именем», в которой - скрипт хранит значение. Например, количество очков, - имя игрока, выбранный уровень. -

- {`// Создаём переменную и кладём в неё число -let score = 0; - -// Меняем значение -score = score + 10; // теперь в score лежит 10 -score = score + 5; // теперь 15 - -game.log('Очков:', score); // напечатает: Очков: 15`} -

- let — это слово «создать переменную». Пишут - его только один раз, когда коробочку заводят. Дальше - меняют значение уже без let. -

-

В переменную можно класть не только числа:

- {`let name = 'Герой'; // текст — в кавычках -let isWin = false; // да/нет — true или false -let coinCount = 0; // число — без кавычек`} - - Если значение никогда не меняется — вместо - let можно писать const - («постоянная»). Например, найденную один раз дверь: - const door = game.scene.findOne('Дверь'); - - - ), - }, - { - id: 'game-object', - title: 'D4. Объект game — главный инструмент', - body: ( - <> -

- В каждом скрипте есть одно главное волшебное слово — - game. Через него ты управляешь всей игрой. - У game много «отделов»: -

- - - - - - - - - - -
game.playerуправление игроком
game.sceneобъекты сцены
game.uiсчётчики и текст на экране
game.guiкнопки и меню
game.soundзвуки
game.physicsлучи, импульсы, взрывы
game.selfобъект-носитель скрипта
-

- Запись через точку читается слева направо. - game.player.teleport(0, 5, 0) читается так: - «у игры, у игрока, выполни телепорт - в точку 0, 5, 0». -

-

- Полный список всех команд каждого отдела — в Справочнике - (раздел H). Не нужно его заучивать: при наборе кода - редактор сам показывает подсказки. -

- - ), - }, - { - id: 'log-console', - title: 'D5. game.log, консоль, отладка', - body: ( - <> -

- Консоль — окошко в правом нижнем углу редактора. - Туда выводятся все сообщения и ошибки скриптов. -

-

- Команда game.log(...) печатает в консоль - что угодно. Это главный инструмент отладки — - проверки, что код работает правильно: -

- - {`let score = 0; -score = score + 10; -game.log('Очки сейчас:', score); // Очки сейчас: 10 - -let pos = game.player.position; -game.log('Игрок стоит в точке:', pos);`} -

- Если игра ведёт себя странно — расставь - game.log по коду и посмотри, какие значения - печатаются. Так ты увидишь, где именно что-то пошло не так. -

- - Если в скрипте опечатка — текст ошибки появится - в Консоли красным, и там же будет написан номер - строки с ошибкой. Всегда заглядывай в Консоль первым делом. - - - ), - }, - { - id: 'events', - title: 'D6. События: onTick, onKey, onClick, onTouch', - body: ( - <> -

- Событие — это «что-то случилось». Скрипт может - ждать событие и реагировать на него. Самые важные: -

- - - - - - - -
game.onTick(fn)каждый кадр (60 раз в секунду)
game.onKey('space', fn)игрок нажал клавишу
game.self.onClick(fn)игрок кликнул по объекту
game.self.onTouch(fn)игрок коснулся объекта
-

Пример — куб, который исчезает по клику:

- - {`game.self.onClick(() => { - game.self.delete(); // удалить сам себя - game.log('Куб удалён!'); -});`} -

- Что такое {`() => { ... }`}? Это - «функция» — набор команд, упакованных вместе. Команды - внутри фигурных скобок выполнятся не сразу, а только - когда случится событие. То есть «когда кликнули — тогда - удалить и напечатать». -

- - onTick выполняется ОЧЕНЬ часто — 60 раз - в секунду. Не делай внутри него тяжёлых вещей. Подробнее - об этой ошибке — раздел J4. - - - ), - }, - { - id: 'conditions', - title: 'D7. Условия: if / else', - body: ( - <> -

- Условие — это развилка: «если что-то верно — - сделай одно, иначе — другое». В JavaScript это - слова if («если») и else - («иначе»). -

- - {`let coins = 7; - -if (coins >= 10) { - game.ui.showText('Хватает на покупку!', 2); -} else { - game.ui.showText('Нужно больше монет', 2); -}`} -

- Тут проверяется: coins {'>'}= 10 — «монет - 10 или больше?». Сейчас монет 7, значит условие неверно, - и сработает ветка else. -

-

Знаки сравнения:

- - - - - - - - - -
a === ba равно b
a !== ba не равно b
a {'>'} ba больше b
a {'<'} ba меньше b
a {'>'}= ba больше или равно b
a {'<'}= ba меньше или равно b
- - Для проверки «равно» пишут три знака равенства - ===, а не один. Один знак = — - это «положить значение в переменную», совсем другое - действие. - - - ), - }, - { - id: 'timers', - title: 'D8. Таймеры: after, every, cancel', - body: ( - <> -

- Таймеры запускают команды не сразу, а потом: -

-
    -
  • - game.after(сек, fn) — выполнить - один раз через несколько секунд; -
  • -
  • - game.every(сек, fn) — выполнять - снова и снова каждые несколько секунд; -
  • -
  • - game.cancel(id) — остановить таймер. -
  • -
- - {`// Через 3 секунды показать текст -game.after(3, () => { - game.ui.showText('Игра началась!', 2); -}); - -// Каждую секунду прибавлять очко. -// every возвращает номер таймера — запомним его. -const ticker = game.every(1, () => { - game.ui.score = (game.ui.score || 0) + 1; -}); - -// Через 10 секунд остановить начисление очков -game.after(10, () => { - game.cancel(ticker); - game.ui.showText('Время вышло!', 2); -});`} -

- Запись (game.ui.score || 0) читается так: - «возьми счёт, а если его ещё нет — возьми 0». Это защита - от ошибки в самом начале, когда счётчик ещё пустой. -

- - ), - }, - ], - }, - - // ════════════════════════════════════════════════════ - // РАЗДЕЛ E — СКРИПТЫ — ДВИЖЕНИЕ И АНИМАЦИЯ - // ════════════════════════════════════════════════════ - { - id: 'scripts-motion', - icon: 'run', - title: 'Движение и анимация', - summary: 'Управление игроком, плавные твины, спавн и перемещение объектов.', - sections: [ - { - id: 'player-control', - title: 'E1. Управление игроком: скорость, прыжок, гравитация', - body: ( - <> -

- Скриптом можно менять, как двигается игрок. Эти команды - принимают множитель: 1 — обычно, 2 — в два раза - сильнее, 0.5 — в два раза слабее. -

- - - - - - - - -
setSpeed(mul)скорость бега
setJumpPower(mul)сила прыжка
setGravityMul(mul)сила притяжения
setDoubleJump(true)разрешить двойной прыжок
teleport(x,y,z)мгновенно переставить
-

Пример — «зелье скорости» при касании сферы:

- - {`game.self.onTouch(() => { - // ускоряем игрока в 2 раза - game.player.setSpeed(2); - game.ui.showText('Скорость x2 на 5 секунд!', 2); - game.sound.play('pickup'); - - // зелье исчезает - game.self.delete(); - - // через 5 секунд скорость снова обычная - game.after(5, () => { - game.player.setSpeed(1); - }); -});`} - - Не забывай возвращать скорость обратно командой - setSpeed(1). Иначе игрок останется быстрым - навсегда — а это может сломать твой уровень. - - - ), - }, - { - id: 'player-animations', - title: 'E2. Анимации-эмоции персонажа', - body: ( - <> -

- Персонаж умеет показывать эмоции. Команда - game.player.playAnimation(имя) проигрывает - анимацию: 'wave' (помахать), - 'dance' (танец), 'cheer' - (радость), 'sit' (сесть). -

- - {`// При победе персонаж радуется -game.player.playAnimation('cheer'); - -// Через 3 секунды перестать -game.after(3, () => { - game.player.stopAnimation(); -});`} - - ), - }, - { - id: 'tweens', - title: 'E3. Твины — плавные движения', - body: ( - <> -

- Твин — это плавное изменение чего-либо за время. - Если просто переставить объект командой move — - он телепортируется рывком. А твин плавно доедет - из точки в точку. -

-

Команда: game.tween(объект, что менять, настройки)

- - {`// Находим платформу-лифт по имени -const lift = game.scene.findOne('Лифт'); - -// Платформа за 2 секунды плавно поднимается на высоту 10 -game.tween(lift, { y: 10 }, { - duration: 2, // длительность в секундах - easing: 'ease' // характер движения -});`} -

- Твином можно менять позицию (x, y, z), - поворот, размер, цвет, прозрачность. -

-

Полезные настройки твина:

- - - - - - - - -
durationсколько секунд длится
easing'linear' (ровно), 'ease' (плавно), 'bounce' (с отскоком)
repeatсколько раз повторить
yoyo: trueдвигаться туда-обратно
onDoneчто сделать, когда твин закончится
- {`// Платформа вечно ездит вверх-вниз -const plat = game.scene.findOne('Качалка'); -game.tween(plat, { y: 8 }, { - duration: 2, - yoyo: true, // обратно вниз - repeat: 999 // повторять почти бесконечно -});`} - - ), - }, - { - id: 'spawn-delete', - title: 'E4. Спавн и удаление объектов', - body: ( - <> -

- Спавн — создание нового объекта прямо во время игры. - Команда game.scene.spawn(тип, настройки): -

- - {`// Создаём золотую монетку-сферу -const coin = game.scene.spawn('primitive:sphere', { - x: 5, y: 1, z: 0, // где появится - color: '#ffd700' // золотой цвет -}); - -game.log('Создали монетку, её адрес:', coin);`} -

- Тип бывает 'block:трава', - 'primitive:cube', 'model:tree'. - Команда возвращает ref — это «адрес» объекта, - по которому к нему можно обращаться (двигать, удалять). -

-

Удаление объекта:

- {`// удалить сразу -game.scene.delete(coin); - -// удалить через 3 секунды -game.scene.deleteAfter(coin, 3);`} - - Запоминай ref в переменную (let coin - = ...). Без адреса ты потом не сможешь объект - ни подвинуть, ни удалить. - - - ), - }, - { - id: 'move-objects', - title: 'E5. Перемещение объектов', - body: ( - <> -

Передвинуть объект скриптом можно несколькими способами:

- - - - - - - -
game.scene.move(ref,x,y,z)мгновенно переставить
game.scene.rotate(ref,угол)повернуть
game.self.move(x,y,z)скрипт двигает сам себя
game.tween(...)плавное перемещение (E3)
-

Пример — дверь уезжает вверх и освобождает проход:

- - {`const door = game.scene.findOne('Дверь'); - -// плавно поднимаем дверь на 6 единиц вверх -game.tween(door, { y: 6 }, { duration: 1 });`} - - ), - }, - ], - }, - - // ════════════════════════════════════════════════════ - // РАЗДЕЛ F — СКРИПТЫ — ИГРОВАЯ ЛОГИКА - // ════════════════════════════════════════════════════ - { - id: 'scripts-logic', - icon: 'target', - title: 'Игровая логика', - summary: 'HP и урон, физика, теги, взаимодействие по E, связи между объектами.', - sections: [ - { - id: 'player-hp', - title: 'F1. HP игрока: урон, лечение, смерть, чекпоинт', - body: ( - <> -

Команды для здоровья игрока:

- - - - - - - - - -
game.player.hpтекущее здоровье (можно читать)
game.player.damage(n)нанести урон
game.player.heal(n)вылечить
game.player.kill()мгновенно убить
game.player.respawn()воскресить на спавне
game.player.setSpawn(точка)новая точка возрождения
-

Пример 1 — шипы наносят урон:

- - {`game.self.onTouch(() => { - game.player.damage(20); // отнять 20 здоровья - game.sound.play('hit'); -});`} -

Пример 2 — аптечка лечит:

- - {`game.self.onTouch(() => { - game.player.heal(50); // добавить 50 здоровья - game.ui.showText('+50 HP', 1.5); - game.self.delete(); // аптечка исчезает -});`} - - ), - }, - { - id: 'physics', - title: 'F2. Физика: raycast, импульсы, взрывы', - body: ( - <> -

- Отдел game.physics отвечает за «настоящую» - физику: -

-
    -
  • - raycast(откуда, куда, опции) — пустить - невидимый луч и узнать, во что он попал. Так делают - стрельбу; -
  • -
  • - applyImpulse(ref, сила) — толкнуть объект - (он должен быть не закреплён); -
  • -
  • - explode(точка, радиус, опции) — взрыв. -
  • -
-

Пример — стрельба лучом из камеры игрока:

- - {`// При клике мышкой пускаем луч туда, куда смотрит игрок -game.onClick(() => { - const p = game.player.position; - - const hit = game.physics.raycast( - { x: p.x, y: p.y + 1.5, z: p.z }, // откуда (от головы) - game.player.forward, // куда (взгляд) - { maxDistance: 50 } // как далеко - ); - - if (hit.hit) { - game.log('Попал в объект:', hit.ref); - game.sound.play('hit'); - } -});`} -

- hit.hit — попал ли луч во что-нибудь - (да/нет). hit.ref — адрес объекта, в который - попали. -

- - ), - }, - { - id: 'attributes', - title: 'F3. Атрибуты объектов (setData / getData)', - body: ( - <> -

- Атрибут — это значение, которое ты «приклеиваешь» - к объекту. Например, сколько здоровья у конкретного врага - или сколько монет стоит товар. -

- - {`// При старте игры запоминаем цену прямо на товаре -game.scene.setData(game.self.ref, 'price', 50); - -// Когда игрок кликает по товару — читаем цену -game.self.onClick(() => { - const price = game.scene.getData(game.self.ref, 'price'); - game.ui.showText('Этот товар стоит ' + price + ' монет', 2); -});`} -

- Чем атрибут лучше обычной переменной? Переменная одна - на весь скрипт. А атрибут — свой у каждого объекта. - Один и тот же скрипт можно повесить на 10 разных товаров, - и у каждого будет своя цена. -

- - ), - }, - { - id: 'tags', - title: 'F4. Теги объектов', - body: ( - <> -

- Тег — это «ярлык», который можно повесить сразу - на много объектов. Потом одной командой можно найти их все. -

- - - - - - - -
tag(ref, 'звезда')повесить тег
untag(ref, 'звезда')снять тег
hasTag(ref, 'звезда')есть ли тег
getTagged('звезда')все объекты с тегом
-

Пример — игра «собери все звёзды»:

- - {`// Этот скрипт висит на звезде. -// При старте помечаем звезду тегом. -game.scene.tag(game.self.ref, 'звезда'); - -// Когда игрок коснулся — звезда собрана -game.self.onTouch(() => { - game.self.delete(); - game.sound.play('coin'); - - // сколько звёзд ещё осталось на сцене? - const left = game.scene.getTagged('звезда').length; - if (left === 0) { - game.ui.showText('Все звёзды собраны! Победа!', 3); - } else { - game.ui.showText('Осталось звёзд: ' + left, 1.5); - } -});`} - - Снятие тега убирает только ярлык. Цвет, размер и другие - свойства объекта при этом не меняются. - - - ), - }, - { - id: 'proximity', - title: 'F5. ProximityPrompt — взаимодействие по клавише E', - body: ( - <> -

- Часто игра просит «подойди и нажми E»: открыть сундук, - поговорить с торговцем, дёрнуть рычаг. Это делается - командой game.self.onInteract: -

- - {`game.self.onInteract(() => { - game.ui.showText('Сундук открыт!', 2); - game.scene.spawnParticles('sparks', - game.self.position, { duration: 1 }); - game.sound.play('pickup'); -}, { - text: 'Открыть сундук', // подсказка над объектом - distance: 4 // на сколько метров подойти -});`} -

- Когда игрок подойдёт ближе чем на distance - метров, над объектом появится подсказка с текстом. - Нажатие E запустит функцию. -

- - ), - }, - { - id: 'billboard', - title: 'F6. Billboard-метки над объектами', - body: ( - <> -

- Billboard — это текст-табличка, которая висит - над объектом в 3D-мире и всегда повёрнута к игроку. - Так показывают имена врагов, их HP, названия мест. -

- - {`// Допустим, npc — это адрес созданного NPC. -// Вешаем над ним табличку с именем. -game.scene.setLabel(npc.ref, 'Торговец Боб', { - color: '#ffffff', - height: 2.5 // на 2.5 метра над объектом -}); - -// Позже можно убрать табличку -game.scene.clearLabel(npc.ref);`} - - ), - }, - { - id: 'pass-through', - title: 'F7. Проходимость объектов (passThrough)', - body: ( - <> -

- Иногда стена должна стать проходимой — призрачная стена, - секретный проход, исчезающий мост. Команда - game.physics.passThrough(ref, true) делает - объект «бесплотным»: видно его, но игрок проходит насквозь. -

- - {`// Когда игрок кликнет по стене — она пропустит сквозь себя -game.self.onClick(() => { - game.physics.passThrough(game.self.ref, true); - game.scene.setOpacity(game.self.ref, 0.3); // полупрозрачная - game.ui.showText('Секретный проход открыт!', 2); -});`} - - Если сделать стену снова твёрдой, пока игрок стоит внутри - неё — игра аккуратно вытолкнет его наружу, он не застрянет. - - - ), - }, - { - id: 'constraints', - title: 'F8. Связи: склейка, петля, пружина', - body: ( - <> -

- Связи (constraints) соединяют объекты, чтобы они - двигались вместе или по правилам физики. Отдел — - game.constraints: -

-
    -
  • - Склейка (weld) — намертво приклеивает один - объект к другому; -
  • -
  • - Петля (hinge) — объект вращается вокруг оси, - как дверь на петлях или качели; -
  • -
  • - Пружина (spring) — объект упруго колеблется, - как батут. -
  • -
-

Пример — качели на петле:

- - {`const swing = game.scene.findOne('Качели'); - -// делаем качели на петле -const h = game.constraints.hinge(swing, { - pivotX: 0, pivotZ: 0, // ось вращения - angle: 30 // наклон на 30 градусов -}); - -// раскачиваем в другую сторону каждую секунду -let dir = -30; -game.every(1, () => { - h.setAngle(dir); - dir = -dir; // меняем знак: 30 → -30 → 30 ... -});`} - - ), - }, - ], - }, - - // ════════════════════════════════════════════════════ - // РАЗДЕЛ G — СКРИПТЫ — БОЛЬШИЕ СИСТЕМЫ - // ════════════════════════════════════════════════════ - { - id: 'scripts-systems', - icon: 'gear', - title: 'Большие системы', - summary: 'NPC, инвентарь и оружие, звук, камера и катсцены, мультиплеер.', - sections: [ - { - id: 'npc', - title: 'G1. NPC: создание, движение, диалоги', - body: ( - <> -

- NPC (неигровой персонаж) — это житель твоей игры: - торговец, враг, проводник. Создаётся командой - game.scene.spawnNpc(модель, опции). -

- - {`// Создаём NPC по имени Боб -const bob = game.scene.spawnNpc('character-a', { - x: 5, y: 0, z: 0, - name: 'Боб', - hp: 100, - speed: 3 -}); - -// Боб говорит реплику над головой (3 секунды) -bob.say('Привет, путник!', 3); - -// Боб идёт в точку (x = 10, z = 0) -bob.moveTo(10, 0);`} -

Что умеет NPC:

- - - - - - - - - - -
moveTo(x, z)идти в точку
follow('player')гнаться за игроком
stop()остановиться
say(текст, сек)реплика над головой
damage(n)нанести урон NPC
remove()убрать со сцены
onDeath(fn)что сделать при гибели
-

Пример — враг гонится за игроком:

- {`const enemy = game.scene.spawnNpc('character-b', { - x: 0, y: 0, z: 20, name: 'Враг', hp: 50, speed: 2 -}); - -enemy.follow('player'); // началась погоня - -enemy.onDeath(() => { - game.ui.showText('Враг побеждён!', 2); - game.scene.spawnParticles('explosion', - enemy.position, { duration: 1 }); -});`} - - ), - }, - { - id: 'inventory-tools', - title: 'G2. Инвентарь и инструменты', - body: ( - <> -

- Инвентарь — это сумка предметов внизу экрана. - Инструмент — предмет, который игрок берёт в руку: - меч, фонарик, лопата. -

- - {`// Выдать игроку меч прямо в руку -game.player.giveTool('sword', { - name: 'Стальной меч', - equip: true // сразу взять в руку -}); - -// Ловим, когда игрок применил инструмент (ЛКМ) -game.player.onToolUse((e) => { - game.log('Игрок применил:', e.tool); -});`} -

- Команды отдела game.inventory: - add(item) — добавить предмет, - remove(имя) — убрать, - has(имя) — есть ли предмет, - list() — список всех предметов. -

-

Пример — игра «ключ и сундук»:

- - {`game.self.onInteract(() => { - // проверяем, есть ли у игрока ключ - if (game.inventory.has('Ключ')) { - game.ui.showText('Сундук открыт!', 2); - game.inventory.remove('Ключ'); // ключ потрачен - } else { - game.ui.showText('Нужен ключ', 1.5); - } -}, { text: 'Открыть', distance: 4 });`} - - ), - }, - { - id: 'sound', - title: 'G3. Звук: свои звуки и 3D-позиционный звук', - body: ( - <> -

- Звук оживляет игру. Команда - game.sound.play(id, опции). -

- - {`// Готовые звуки-пресеты -game.sound.play('coin'); // звон монетки -game.sound.play('win'); // победа -game.sound.play('jump'); // прыжок -game.sound.play('hit'); // удар - -// Свой загруженный звук, потише -game.sound.play('sound_1', { volume: 0.7 });`} -

- Пресеты: jump, pickup, - win, lose, click, - hit, coin. -

-

- 3D-звук — если указать опцию at, - звук пойдёт из точки в мире: чем дальше игрок, тем тише. -

- {`// Звук костра — слышен только когда подходишь близко -game.sound.play('sound_2', { - at: { x: 0, y: 1, z: 0 }, - loop: true // звук повторяется по кругу -});`} - - Звук в играх обязателен — игра без звука кажется - «мёртвой». Но не запускай длинную музыку в самом начале: - это скучно и тормозит старт. Звуки вешай на события: - прыжок, попадание, победа. - - - ), - }, - { - id: 'camera', - title: 'G4. Камера: FOV, привязка, катсцены', - body: ( - <> -

Отдел game.camera управляет видом игрока:

- - - - - - - - -
setFov(градусы)угол обзора — больше «шире» видно
shake(сила, сек)тряска камеры (взрыв, удар)
focusOn(ref)навести камеру на объект
cutscene(точки, опции)пролёт камеры по точкам
reset()вернуть камеру игроку
-

Пример — облёт уровня при старте игры:

- - {`// камера плавно пролетает через три точки -game.camera.cutscene([ - { x: 0, y: 20, z: -30 }, - { x: 0, y: 15, z: 0 }, - { x: 0, y: 10, z: 30 } -], { segDuration: 2 }); // 2 секунды на отрезок - -// когда облёт закончится — отдать камеру игроку -game.onCutsceneDone(() => { - game.ui.showText('Поехали!', 2); -});`} - - ), - }, - { - id: 'beam-trail', - title: 'G5. Лучи и следы (Beam и Trail)', - body: ( - <> -

- Отдел game.fx создаёт красивые эффекты-линии: - Beam — светящаяся линия между двумя точками - (лазер, мост света), Trail — шлейф за движущимся - объектом (след за ракетой). -

- - {`// Лазер между двумя башнями -const t1 = game.scene.findOne('Башня1'); -const t2 = game.scene.findOne('Башня2'); - -const laser = game.fx.beam({ - from: t1, - to: t2, - color: '#ff3344', - width: 0.3 -});`} - - ), - }, - { - id: 'multiplayer', - title: 'G6. Мультиплеер: игроки, комната, команды', - body: ( - <> -

- В Рублоксе можно сделать игру на несколько игроков - в одной комнате. Главные отделы: -

-
    -
  • - game.players — список игроков: - all(), count(), - me() (это я); -
  • -
  • - game.room — общее состояние комнаты, - которое видят все игроки; -
  • -
  • - game.teams — команды. -
  • -
- - {`// Общий счёт команды — виден всем игрокам в комнате -game.room.set('totalScore', 0); - -// когда счёт меняется — обновляем надпись у всех -game.room.onChange('totalScore', (val) => { - game.ui.set('score', 'Счёт команды: ' + val); -}); - -// сколько игроков сейчас в игре -game.log('Игроков в комнате:', game.players.count()); - -// когда новый игрок зашёл -game.onPlayerJoin((p) => { - game.ui.showText(p.name + ' присоединился!', 2); -});`} - - ), - }, - ], - }, - - // ════════════════════════════════════════════════════ - // РАЗДЕЛ H — СПРАВОЧНИК game.* - // ════════════════════════════════════════════════════ - { - id: 'reference', - icon: 'book', - title: 'Справочник game.*', - summary: 'Шпаргалка: все команды game.* списком по отделам.', - sections: [ - { - id: 'cheatsheet', - title: 'H1. Шпаргалка — все команды списком', - body: ( - <> -

- Здесь собраны все команды game.* по отделам. - Это шпаргалка — не нужно её запоминать, держи под рукой. -

- -

game.player — игрок

- - - - - - - - - - - - - - - - - - -
positionпозиция игрока {`{x,y,z}`}
hp / maxHpздоровье и максимум
aliveжив ли игрок (да/нет)
forwardкуда смотрит {`{x,y,z}`}
teleport(x,y,z)телепорт
damage(n) / heal(n)урон / лечение
kill() / respawn()убить / воскресить
setSpawn(точка)новая точка возрождения
setSpeed(mul)скорость бега
setJumpPower(mul)сила прыжка
setGravityMul(mul)сила гравитации
setDoubleJump(on)двойной прыжок
playAnimation(имя)эмоция персонажа
giveTool(тип,опции)дать инструмент
isKeyDown(клавиша)зажата ли клавиша сейчас
- -

game.scene — объекты сцены

- - - - - - - - - - - - - - - - - - - - - -
spawn(тип,опции)создать объект → ref
delete(ref)удалить
deleteAfter(ref,сек)удалить через N секунд
move(ref,x,y,z)переместить
rotate(ref,угол)повернуть
setColor(ref,цвет)сменить цвет
setCollide(ref,да)твёрдость
setVisible(ref,да)видимость
setOpacity(ref,0..1)прозрачность
find(имя) / findOne(имя)поиск по имени
all(тип)все объекты типа
getPosition(ref)позиция объекта
setData/getDataатрибуты объекта
tag/untag/hasTagтеги
getTagged(тег)все объекты с тегом
setLabel/clearLabelтекст-метка над объектом
spawnNpc(модель,опции)создать NPC
spawnParticles(тип,...)частицы
- -

game.self — объект-носитель скрипта

- - - - - - - - - - - -
ref / positionадрес и позиция объекта
onClick(fn)клик по объекту
onTouch(fn)игрок коснулся
onUntouch(fn)игрок вышел из объекта
onInteract(fn,опции)взаимодействие по E
move(x,y,z)переместить себя
delete()удалить себя
setText(t)сменить текст (для GUI)
- -

game.ui — счётчики и текст

- - - - - - - -
score / timerсчётчики в углу
showText(текст,сек)текст по центру
set(id,текст,опции)своя метка на экране
remove(id) / clear()убрать метку / всё
- -

game.gui — кнопки и меню

- - - - - - - - -
find(имя) / get(id)найти элемент
update(id,patch)изменить свойства
show(id) / hide(id)показать / скрыть
onClick(id,fn)клик по кнопке
onSubmit(id,fn)ввод в поле завершён
- -

physics, fx, constraints

- - - - - - - - - - - - -
physics.raycast(...)луч — во что попал
physics.applyImpulse(...)толкнуть объект
physics.explode(...)взрыв
physics.passThrough(...)проходимость
fx.beam(опции)светящийся луч
fx.trail(ref,опции)след за объектом
constraints.weld(a,b)склейка
constraints.hinge(...)петля
constraints.spring(...)пружина
- -

camera, sound

- - - - - - - - -
camera.setFov(град)угол обзора
camera.shake(сила,сек)тряска
camera.cutscene(...)пролёт камеры
camera.reset()вернуть камеру
sound.play(id,опции)проиграть звук
- -

События и таймеры

- - - - - - - - - - -
onTick(fn)каждый кадр
onKey/onKeyUp(клавиша,fn)клавиатура
onClick(fn)клик в игре
after(сек,fn)через N секунд
every(сек,fn)каждые N секунд
cancel(id)отменить таймер
tween(ref,св-ва,опции)плавная анимация
- -

Утилиты

- - - - - - - - - -
random(min,max)случайное число
distance(a,b)расстояние между точками
clamp(v,min,max)зажать число в границах
lerp(a,b,t)плавный переход a→b
log(...)напечатать в консоль
broadcast/onMessageсообщения между скриптами
- - ), - }, - ], - }, - - // ════════════════════════════════════════════════════ - // РАЗДЕЛ I — ГЛОССАРИЙ - // ════════════════════════════════════════════════════ - { - id: 'glossary', - icon: 'glossary', - title: 'Глоссарий', - summary: 'Словарик: все непонятные слова из вики простым языком.', - sections: [ - { - id: 'terms', - title: 'I1. Термины простым языком', - body: ( - <> -

Словарик слов, которые встречаются в вики:

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ПримитивПростая 3D-фигура: куб, сфера, цилиндр. Главный строительный материал.
МодельГотовая красивая 3D-фигура из библиотеки (дерево, машина).
БлокКубик одного размера, ровно встаёт по сетке.
СценаВесь игровой мир — всё, что ты построил.
ВьюпортОкно с 3D-сценой в центре редактора.
ГизмоЦветные стрелки и кольца для перемещения объектов.
ИерархияСписок всех объектов игры в правой панели.
ИнспекторПанель со свойствами выделенного объекта.
СкриптНабор команд (код), который оживляет игру.
JavaScriptЯзык программирования, на котором пишут скрипты.
Глобальный скриптСкрипт-«мозг», не привязан к объекту, запускается один раз.
Скрипт на объектеСкрипт конкретного объекта, в нём работает game.self.
Переменная«Коробочка с именем» — скрипт хранит в ней значение.
ФункцияНабор команд, который выполнится, когда его позовут.
Событие«Что-то случилось» — клик, касание, нажатие клавиши.
Условие (if)Развилка: если что-то верно — сделай одно, иначе — другое.
СпавнПоявление: точка спавна — где появляется игрок; «заспавнить» — создать объект.
РеспаунВоскрешение игрока после смерти.
ЧекпоинтКонтрольная точка — место, откуда игрок воскреснет.
HPЗдоровье игрока. При HP=0 он умирает.
ref«Адрес» объекта. Команда spawn возвращает ref, по нему обращаются к объекту.
ТвинПлавное изменение свойства за время (плавное движение).
ТегЯрлык на объекте. По тегу можно найти все помеченные объекты.
АтрибутСвоё значение, приклеенное к объекту (через setData).
КонстрейнтСвязь между объектами: склейка, петля, пружина.
ЭмиттерОбъект, создающий частицы (искры, дым, огонь).
ЧастицыМного маленьких летящих точек для эффектов.
GUIИнтерфейс: кнопки, надписи, меню поверх 3D-сцены.
HUDСчётчики и индикаторы поверх экрана (счёт, HP).
NPCНеигровой персонаж: торговец, враг, проводник.
ТриггерНевидимая зона, которая что-то запускает при входе игрока.
RaycastНевидимый луч — узнать, во что он попал (для стрельбы).
FOVУгол обзора камеры. Больше — «шире» видно.
КатсценаВидеовставка: камера сама пролетает по точкам.
МультиплеерИгра на нескольких игроков в одной комнате.
МодерацияПроверка игры перед публикацией в общей ленте.
- - ), - }, - ], - }, - - // ════════════════════════════════════════════════════ - // РАЗДЕЛ J — ЧАСТЫЕ ОШИБКИ - // ════════════════════════════════════════════════════ - { - id: 'mistakes', - icon: 'bug', - title: 'Частые ошибки', - summary: 'Что делать, если скрипт не работает или игра ведёт себя странно.', - sections: [ - { - id: 'script-not-working', - title: 'J1. Скрипт не работает или не сохраняется', - body: ( - <> -

- Первым делом — проверь Консоль. Если в скрипте - опечатка, её текст появится в Консоли красным — там - написано, в какой строке ошибка. -

-

Самые частые опечатки:

-
    -
  • забыли точку с запятой ; в конце команды;
  • -
  • - не закрыли скобку — открывающих и закрывающих - ( ), {`{ }`} должно быть - поровну; -
  • -
  • - имя команды с ошибкой: game.player.teelport - вместо teleport; -
  • -
  • - русская буква вместо английской (с виду одинаковы: - русская с и английская c). -
  • -
-

- После правки скрипта — сохрани игру - (Ctrl+S) - и перезапусти Play. -

- - ), - }, - { - id: 'fell-through-floor', - title: 'J2. Объект провалился сквозь пол', - body: ( -

- Если объект не закреплён (свойство «Закреплён» - выключено), он падает под действием физики. Для платформ, - стен и декораций включи «Закреплён» в инспекторе — - тогда объект будет висеть на месте. Падать должны только те - объекты, которым это нужно (ящики, мячи). -

- ), - }, - { - id: 'findone-null', - title: 'J3. findOne вернул «ничего» (null) на старте', - body: ( - <> -

- Если позвать game.scene.findOne(...) в самой - первой строке скрипта — объект может быть ещё не готов, - и команда вернёт null («ничего»). Потом - обращение к этому null сломает скрипт. -

-

- Решение: ищи объект не на старте, а внутри - onTick или после небольшой задержки: -

- {`let door = null; -game.onTick(() => { - // ищем дверь, пока не найдём - if (!door) door = game.scene.findOne('Дверь'); - // ... работаем с door, когда он уже найден -});`} - - ), - }, - { - id: 'ui-set-every-frame', - title: 'J4. game.ui.set каждый кадр — игра лагает', - body: ( - <> -

- onTick выполняется 60 раз в секунду. Если - внутри него на каждом кадре звать game.ui.set(...), - интерфейс будет обновляться слишком часто и игра начнёт - тормозить. -

-

- Решение: обновляй интерфейс только когда значение - реально изменилось: -

- {`let lastScore = -1; -game.onTick(() => { - const s = game.ui.score || 0; - if (s !== lastScore) { // значение изменилось? - game.ui.set('hud', 'Счёт: ' + s); - lastScore = s; - } -});`} - - ), - }, - { - id: 'other-mistakes', - title: 'J5. Прочие типичные грабли', - body: ( -
    -
  • - Враг идёт сквозь стены — проверь, что у стен - включено «Столкновение». -
  • -
  • - Звук не играет — проверь громкость - (volume) и что звук загружен. Не запускай - длинный звук в самом начале — это тормозит старт. -
  • -
  • - Кнопка не нажимается — проверь, что на неё повешен - game.gui.onClick(...) с правильным id, - и что у элемента правильное имя. -
  • -
  • - Объект не двигается твином — у твина свойство - должно быть числом ({`{ y: 10 }`}), а первым - аргументом — настоящий ref объекта. -
  • -
  • - Скрипт на объекте, но game.self пустой — значит - скрипт не привязан. Выдели объект и пересоздай скрипт - на нём, либо укажи носителя в настройках скрипта. -
  • -
  • - Игрок застрял в стене — не делай стену твёрдой, - пока игрок внутри. Используй passThrough - аккуратно. -
  • -
- ), - }, - ], - }, -]; +import React from 'react'; +import DocIcon from './docsIcons'; + +/** + * docsData.jsx — контент вики редактора Рублокс (разделы A-J). + * + * Структура: DOCS = массив глав. Каждая глава — + * { id, icon, title, summary, sections: [{ id, title, body }] } + * - icon — имя SVG-иконки из docsIcons.jsx (НЕ эмодзи). + * - summary — короткое описание для карточки на главной вики. + * + * Хелперы для оформления: + * - — код-блок (тёмный, моноширинный) + * - — плашка «куда писать скрипт» + * - — шаг инструкции + * - — жёлтая плашка-подсказка + * - — зелёная плашка «попробуй сам» + * + * Контент написан для детей 5 класса+. Каждый пример — рабочий код, + * который можно скопировать в свою игру. Эмодзи в UI не используются — + * только SVG-иконки (см. docsIcons.jsx). + */ + +// ── Код-блок ────────────────────────────────────────────────────── +export const Code = ({ children }) => ( +
{children}
+); + +// ── Плашка «куда писать скрипт» ─────────────────────────────────── +// kind="global" — глобальный скрипт (создаётся в категории «Скрипты») +// kind="object" — скрипт привязан к объекту (передай on="название объекта") +export const ScriptKind = ({ kind, on }) => { + if (kind === 'object') { + return ( +
+ + + Куда писать: этот скрипт нужно повесить на объект + {on ? <> — на {on} : null}. Выдели объект на сцене + и создай скрипт прямо на нём. Тогда внутри скрипта работает + слово game.self — это и есть твой объект. + +
+ ); + } + return ( +
+ + + Куда писать: это глобальный скрипт. Создай его + в иерархии в категории Скрипты (кнопка «+»). Он не привязан + ни к какому объекту и запускается один раз при старте игры. + +
+ ); +}; + +// ── Шаг инструкции ──────────────────────────────────────────────── +export const Step = ({ n, children }) => ( +
+ {n} +
{children}
+
+); + +// ── Жёлтая плашка-подсказка ─────────────────────────────────────── +export const Note = ({ children }) => ( +
+ +
{children}
+
+); + +// ── Зелёная плашка «попробуй сам» ───────────────────────────────── +export const Try = ({ children }) => ( +
+ +
+ Попробуй сам: {children} +
+
+); + +// ── Скриншот интерфейса с подписью ──────────────────────────────── +// src — имя файла из public/wiki/, caption — подпись под картинкой. +// wide — для широких скринов (обзор, лента): растянуть на всю ширину. +export const Shot = ({ src, caption, wide }) => ( +
+ {caption + {caption &&
{caption}
} +
+); + +// ══════════════════════════════════════════════════════════════════ +// DOCS — разделы вики A-J +// ══════════════════════════════════════════════════════════════════ + +export const DOCS = [ + // ════════════════════════════════════════════════════ + // РАЗДЕЛ A — ОСНОВЫ + // ════════════════════════════════════════════════════ + { + id: 'basics', + icon: 'rocket', + title: 'Основы', + summary: 'С чего начать: интерфейс редактора, инструменты, первая игра за 5 минут.', + sections: [ + { + id: 'what-is-rublox', + title: 'A1. Что такое Рублокс и редактор игр', + body: ( + <> +

+ Рублокс — это платформа, где можно играть в 3D-игры + и создавать свои собственные. Всё работает прямо в браузере: + ничего скачивать и устанавливать не нужно. +

+

+ Редактор игр (его ещё называют Studio) — это место, + где ты строишь игру. Ты ставишь блоки и модели, рисуешь + кнопки, пишешь скрипты — а потом нажимаешь «Играть» + и сразу проверяешь, что получилось. +

+

В Рублоксе можно сделать почти любую игру:

+
    +
  • паркур и платформеры — прыгай по платформам;
  • +
  • гонки — мчись к финишу на время;
  • +
  • стрелялки и арены — сражайся с врагами;
  • +
  • головоломки и квесты — решай загадки;
  • +
  • выживалки и целые RPG с героями и заданиями.
  • +
+

+ Как устроена эта вика. Разделы A-C научат строить + мир и интерфейс. Разделы D-G — писать скрипты (код, который + оживляет игру). Раздел H — справочник всех команд. Раздел I — + словарик непонятных слов. Раздел J — что делать, если + что-то сломалось. Раздел K — 50 готовых игр-уроков. +

+ + Не нужно читать всё подряд. Пройди раздел «Основы», + а дальше открывай то, что нужно прямо сейчас. + + + ), + }, + { + id: 'editor-interface', + title: 'A2. Интерфейс редактора', + body: ( + <> +

+ Когда ты открываешь игру в редакторе, экран делится + на части. Разберём каждую: +

+ +
    +
  • + 1 — Шапка сверху — название игры и кнопки: + Настройки, + Сохранить, + Играть, + Опубликовать. +
  • +
  • + 2 — Лента инструментов под шапкой — вкладки + Главная / Модель / Игра / Вид. На каждой вкладке + свои кнопки и инструменты. +
  • +
  • + 3 — 3D-сцена (вьюпорт) — твой игровой мир + в центре экрана. Тут ты всё и строишь. +
  • +
  • + 4 — Правая панель — сверху Иерархия (список + всех объектов), снизу Инспектор (свойства того + объекта, который ты выделил мышкой). +
  • +
+

+ Когда ты выбираешь инструмент «Блок» или «Примитив», + слева открывается ещё одна палитра — там лежат + фигуры, которые можно ставить на сцену. +

+ + +

Как двигать камеру в редакторе:

+ + + + + + +
Правая кнопка мыши + движениеосмотреться по сторонам
WASDлететь вперёд / влево / назад / вправо
Колесо мышиприблизить / отдалить
+ + Камера редактора и камера игрока — это разные камеры. + То, как ты летаешь по сцене сейчас, не влияет на то, как + будет видеть мир игрок. + + + ), + }, + { + id: 'first-game-5min', + title: 'A3. Первая игра за 5 минут', + body: ( + <> +

+ Соберём самую простую игру — площадку, по которой можно + ходить. Делай по шагам. +

+ + Открой редактор и создай новую игру + (или выбери пустой шаблон). + + + На вкладке Главная выбери инструмент + Блок. В левой палитре кликни + на блок травы — он станет выбранным. + + + Кликай по сцене — блоки будут вставать один за другим. + Собери небольшую площадку примерно 6×6 блоков. + + + Перейди на вкладку Игра и выбери + Ставить спавн. Кликни + на площадку — там появится точка, где игрок начнёт игру. + + + Нажми Играть в шапке. Ты + окажешься на своей площадке и сможешь по ней ходить! + + + Нажми Esc, чтобы вернуться + в редактор, и Сохранить. + +

+ Поздравляем — это уже работающая игра. Дальше ты добавишь + в неё препятствия, врагов, монетки и логику. +

+ + добавь по краям площадки стены из блоков, чтобы нельзя + было упасть. И поставь в центре несколько блоков-ступенек. + + + ), + }, + { + id: 'creation-tools', + title: 'A4. Инструменты: блок, примитив, модель, ландшафт', + body: ( + <> +

На вкладке Главная есть инструменты создания:

+ +
    +
  • + Блок — ставит кубический блок (трава, камень, + дерево...). Блоки ровно встают по сетке — из них удобно + строить дома и стены, как из кубиков Лего. +
  • +
  • + Примитив — простая фигура: куб, сфера, цилиндр, + конус, плоскость, тор, клин. У примитива можно свободно + менять размер по каждой оси и красить в любой цвет. +
  • +
  • + Модель — готовая красивая 3D-модель из библиотеки + (дерево, бочка, машина, оружие). Можно загружать и свои + модели в формате .glb. +
  • +
  • + Ландшафт — инструмент лепки рельефа: холмы, горы, + пещеры. Об этом — раздел B4. +
  • +
  • + Стереть — удаляет блок или объект под курсором. +
  • +
+

+ Когда выбираешь «Блок» или «Примитив», слева открывается + палитра — выбери в ней фигуру, а потом кликай + по сцене, чтобы её поставить. +

+ +

+ Шаг привязки (1.0 / 0.5 / 0.25 / Выкл) — задаёт, + насколько мелко объект «прилипает» к сетке, когда ты его + двигаешь. Шаг 1.0 — объект двигается крупными шагами, + ровно по клеткам. Маленький шаг 0.25 — точнее, но дольше. +

+ + + Чем отличаются блок и примитив-куб? Блок всегда одного + размера и быстро ставится сеткой — он для стройки. + Примитив-куб можно растянуть в длинную платформу или + тонкую стенку — он для геймплея. + + + ), + }, + { + id: 'gizmo', + title: 'A5. Гизмо-манипуляторы: двигать, вращать, масштаб', + body: ( + <> +

+ Гизмо — это цветные стрелки и кольца, которые + появляются на выделенном объекте. Они помогают точно + его двигать, поворачивать и менять размер. +

+

+ Режим гизмо выбирается в ленте инструментов — группа + «Манипуляторы»: +

+ + + + + + + + +
Выделитьобычный режим, клик выбирает объект
Двигатьтри стрелки X / Y / Z — тяни, объект едет по оси
Вращатьтри кольца — тяни, объект поворачивается
Масштабкубики на осях — тяни, объект растягивается
+

+ Вот как выглядит гизмо «Двигать» на выделенном кубе — + три цветные стрелки: +

+ +

+ Оси всегда одни и те же и покрашены одинаково: +

+
    +
  • X (красная) — влево / вправо;
  • +
  • Y (зелёная) — вверх / вниз;
  • +
  • Z (синяя) — вперёд / назад.
  • +
+ + Эти же буквы X, Y, Z ты увидишь в скриптах. Когда команда + пишет {`{ x: 5, y: 2, z: 0 }`} — это точка + в мире: 5 вправо, 2 вверх, 0 вперёд. + + + ), + }, + { + id: 'hierarchy', + title: 'A6. Иерархия объектов и папки', + body: ( + <> +

+ Иерархия — это список всех объектов твоей игры + в правой панели. Когда сцена большая, найти нужный куб + мышкой трудно — а в списке он всегда под рукой. +

+ +

Объекты сгруппированы по категориям:

+
    +
  • Сцена — точка спавна, окружение, свет;
  • +
  • Игрок — скин персонажа;
  • +
  • Интерфейс — GUI-элементы (кнопки, надписи);
  • +
  • Скрипты — твой код.
  • +
+

+ Имя объекта. У каждого объекта есть имя — его видно + в иерархии и можно изменить в инспекторе. Имена очень важны + для скриптов: команда game.scene.findOne('Дверь') + находит объект по имени. Давай объектам понятные + имена: «Дверь», «Монетка1», «Босс». +

+

+ Папки. Объекты можно складывать в свои папки — + например, папка «Уровень 1», папка «Враги». Это как + наводить порядок в шкафу: всё на своих полках. Двойной + клик по объекту в списке — камера прилетит прямо к нему. +

+ + ), + }, + { + id: 'hotkeys', + title: 'A7. Горячие клавиши', + body: ( + <> +

Горячие клавиши экономят кучу времени:

+ + + + + + + + + + +
Ctrl+SСохранить игру
Ctrl+ZОтменить последнее действие
Ctrl+DДублировать выделенный объект
DelУдалить выделенное
RПовернуть объект на 90°
EscСнять выделение / выйти из режима
FНавести камеру на выделенное
+ + Самая полезная привычка — почаще жать + Ctrl+S. + Сохраняться нужно не «когда закончил», а каждые пару минут. + + + ), + }, + { + id: 'play-mode', + title: 'A8. Режим игры: HP, смерть, респаун', + body: ( + <> +

+ Кнопка Запустить в правой части + ленты запускает игру. Сцена «оживает»: включается физика, + начинают работать скрипты, появляется HUD (счётчики поверх + экрана). Чтобы остановить игру — кнопка + Стоп или клавиша + Esc. +

+ +

Управление в игре:

+ + + + + + + + + + + +
WASDИдти
SpaceПрыжок
ShiftБег
МышьПоворот камеры
ЛКМАтака / стрельба
EВзаимодействовать
15Слот инвентаря
C1-е / 3-е лицо
+

+ HP (здоровье) игрока видно в левом верхнем углу. + Когда игрок получает урон, полоска краснеет. Если HP падает + до нуля — игрок «умирает» и через пару секунд + воскресает (респаун) на точке спавна с полным + здоровьем. +

+

+ Всем этим можно управлять из скриптов: наносить урон, + лечить, ставить чекпоинты. Об этом — раздел F1. +

+ + ), + }, + { + id: 'save-publish', + title: 'A9. Сохранение и публикация', + body: ( + <> +

+ Сохранение — кнопка Сохранить + или Ctrl+S. + Игра автоматически сохраняется и сама время от времени, + но лучше не лениться и сохранять руками. +

+

+ Публикация — когда игра готова, нажми + Опубликовать. Выбери + возрастной рейтинг (6+, 12+, 16+, 18+) и куда отправить: +

+
    +
  • + В главную ленту — игру увидят все ученики. + Модерация строже. +
  • +
  • + Только в профиле — игра доступна по ссылке + и в твоём профиле, но в общей ленте её не будет. +
  • +
+

+ После отправки игру проверит модератор (обычно за 24-48 + часов). Игра не должна содержать матов, рекламы, + чужого контента (модели из Roblox/Minecraft) и жестокости + не по возрасту. +

+ + ), + }, + ], + }, + + // ════════════════════════════════════════════════════ + // РАЗДЕЛ B — ОБЪЕКТЫ СЦЕНЫ + // ════════════════════════════════════════════════════ + { + id: 'objects', + icon: 'cube', + title: 'Объекты сцены', + summary: 'Блоки, примитивы, модели, ландшафт, свет, частицы и свойства объектов.', + sections: [ + { + id: 'blocks', + title: 'B1. Блоки и их типы', + body: ( + <> +

+ Блок — это кубик одного размера. Блоки ровно + встают по сетке, и из них удобно строить — как из кубиков + Лего. В палитре слева есть разные типы: трава, камень, + песок, дерево, кирпич, цветные блоки, блоки со снегом. +

+

+ Чтобы поставить блок — выбери инструмент + Блок, кликни нужный тип + в палитре и кликай по сцене. Чтобы убрать — инструмент + Стереть. +

+ +

+ У блока в инспекторе можно включить/выключить + столкновение (твёрдый он или сквозь него можно + пройти) и видимость. +

+ + Невидимый блок с включённым столкновением — это уже + готовый «невидимый барьер». Игрок в него упрётся, + но не увидит. Так делают границы уровня. + + + ), + }, + { + id: 'primitives', + title: 'B2. Примитивы', + body: ( + <> +

+ Примитив — простая 3D-фигура. В отличие от блока, + у примитива можно свободно менять размер по каждой оси + и красить в любой цвет. Виды: +

+ + + + + + + + + + +
Кубстены, платформы, ящики
Сферамячи, монетки, планеты
Цилиндрколонны, бочки, трубы
Конусшипы, ёлки, шляпы
Плоскостьтонкий лист — пол, экран
Торкольцо (как бублик)
Клиннаклонная фигура — пандус
+

+ Примитивы — главный «строительный материал» для игр. + Из растянутых кубов делают платформы для паркура, + из сфер — собираемые монетки, из конусов — смертельные + шипы. +

+ + Чтобы в скрипте было легко найти примитив — дай ему + понятное имя в инспекторе. Например, «Платформа1». Потом + скрипт найдёт её командой + game.scene.findOne('Платформа1'). + + + ), + }, + { + id: 'models', + title: 'B3. Готовые модели и импорт своих', + body: ( + <> +

+ Модель — готовая красивая 3D-фигура: дерево, камень, + бочка, машина, оружие, мебель. Их не нужно строить из + кубиков — просто бери из библиотеки и ставь на сцену. +

+

+ Инструмент Модель открывает + каталог. Модели разбиты по категориям (природа, город, + оружие...). +

+ +

+ Свои модели. Можно загрузить собственную 3D-модель + в формате .glb. Такие файлы делают в бесплатных + редакторах вроде Blender или берут на сайтах с бесплатными + моделями. После загрузки твоя модель появится в палитре + рядом с остальными. +

+ + Не бери модели из других игр (Roblox, Minecraft) — + это чужой контент, игру с ним не пропустит модерация. + Бери только бесплатные модели «для свободного использования». + + + ), + }, + { + id: 'terrain', + title: 'B4. Ландшафт: воксельный и гладкий', + body: ( + <> +

+ Ландшафт — это рельеф мира: холмы, горы, ямы, пещеры. + В Рублоксе два режима: +

+
    +
  • + Воксельный — рельеф из маленьких кубиков-вокселей. + Получается «ступенчатый», как в Minecraft. Удобно + рисовать пещеры и обрывы. +
  • +
  • + Гладкий — рельеф из плавных холмов без ступенек. + Похоже на настоящую землю. +
  • +
+

+ Инструменты лепки: поднять, опустить, + разгладить, покрасить. Есть и кисти-растения — + рисуешь по земле, и сразу вырастают деревья и трава. +

+ + Если в игре есть гладкий ландшафт, ставь точку спавна + прямо на его поверхности. Если спавн окажется ниже земли — + игрок при старте провалится. Высоту земли в скрипте можно + узнать командой game.scene.surfaceY(x, z). + + + ), + }, + { + id: 'spawn-checkpoints', + title: 'B5. Точка спавна и чекпоинты', + body: ( + <> +

+ Точка спавна — место, где игрок появляется в начале + игры и куда возвращается после смерти. Ставится на вкладке + Игра → Ставить спавн. Точка спавна одна. +

+

+ Чекпоинт (контрольная точка) — промежуточное место + сохранения в длинных уровнях. Когда игрок дошёл до чекпоинта, + при следующей смерти он воскреснет уже там, а не в самом + начале. +

+

+ Чекпоинт делают скриптом на флажке-объекте: когда игрок + касается флажка, скрипт запоминает это место как новый спавн. +

+ + {`// Когда игрок коснётся флажка — +// это место станет новой точкой возрождения. +game.self.onTouch(() => { + // game.self.position — координаты самого флажка + game.player.setSpawn(game.self.position); + game.ui.showText('Чекпоинт сохранён!', 1.5); + game.sound.play('pickup'); +});`} +

+ Что тут происходит: onTouch срабатывает, + когда игрок дотронулся до флажка. setSpawn + запоминает точку возрождения. showText + показывает надпись на 1.5 секунды. Готово — игрок + не начнёт уровень заново. +

+ + ), + }, + { + id: 'lamps', + title: 'B6. Лампы (источники света)', + body: ( + <> +

+ Лампа — это источник света. Кроме общего солнца, + можно поставить точечные лампы, которые освещают всё рядом + с собой. Они нужны для пещер, ночных уровней, подсветки + важных мест. +

+

+ У лампы настраиваются цвет, яркость + и радиус (как далеко достаёт свет). Лампу можно + создать и из скрипта: +

+ + {`// Создаём тёплую лампу над сценой +game.scene.spawn('light:point', { + x: 0, y: 4, z: 0, // где висит лампа + color: '#ffdd88', // тёплый жёлтый свет + brightness: 2, // яркость + range: 12 // радиус освещения +});`} + + ), + }, + { + id: 'particles', + title: 'B7. Эмиттеры частиц', + body: ( + <> +

+ Частицы — это много маленьких летящих точек: + искры, дым, огонь, магия. Объект, который их создаёт, + называется эмиттер. +

+

+ Частицы делают игру живой: костёр дымит, при победе летит + конфетти, у портала кружится магия. Из скрипта это команда + game.scene.spawnParticles: +

+ + {`// Залп конфетти над центром сцены +game.scene.spawnParticles( + 'confetti', // тип эффекта + { x: 0, y: 3, z: 0 }, // где появятся частицы + { duration: 2, count: 3 } // длительность и густота +);`} +

+ Типы эффектов: fire (огонь), + smoke (дым), sparks (искры), + magic (магия), explosion (взрыв), + confetti (конфетти). +

+ + ), + }, + { + id: 'triggers', + title: 'B8. Триггеры', + body: ( + <> +

+ Триггер — невидимая зона, которая что-то запускает, + когда игрок в неё входит. Например: игрок зашёл в зону — + открылась дверь, заиграла музыка, появился враг. +

+

Как сделать триггер:

+ + Поставь примитив-куб нужного размера — это и будет зона. + + + В инспекторе выключи столкновение (чтобы игрок + проходил сквозь) и можно выключить видимость + (чтобы зону не было видно). + + + Повесь на этот куб скрипт, который ловит касание игрока: + + + {`// Игрок вошёл в зону — показываем надпись +game.self.onTouch(() => { + game.ui.showText('Ты вошёл в опасную зону!', 2); +}); + +// Игрок вышел из зоны +game.self.onUntouch(() => { + game.ui.showText('Ты в безопасности', 1.5); +});`} + + ), + }, + { + id: 'object-properties', + title: 'B9. Свойства объекта: цвет, материал, физика, замок', + body: ( + <> +

+ Когда ты выделяешь объект, в Инспекторе (правая + панель снизу) появляются его свойства: +

+ + + + + + + + + + + +
Цветзакрасить примитив в любой цвет
Материалобычный, металл, стекло, неон — как объект блестит
Текстураналожить свою картинку на поверхность
Столкновениетвёрдый объект или сквозь него можно пройти
Видимостьпоказать или спрятать объект
Закреплёнесли выключить — объект падает под действием физики
Замок (Lock)заблокировать, чтобы случайно не сдвинуть
+ + Свойство «Закреплён» — частая причина бага «объект + провалился сквозь пол». Для платформ, стен и декораций + всегда оставляй «Закреплён» включённым. Падать должны + только ящики и мячи, которым это нужно. + + + ), + }, + ], + }, + + // ════════════════════════════════════════════════════ + // РАЗДЕЛ C — ИНТЕРФЕЙС ИГРЫ (GUI) + // ════════════════════════════════════════════════════ + { + id: 'gui', + icon: 'window', + title: 'Интерфейс игры', + summary: 'GUI: кнопки, надписи, поля ввода, меню и счётчики поверх экрана.', + sections: [ + { + id: 'gui-what-is', + title: 'C1. Что такое GUI и редактор интерфейса', + body: ( + <> +

+ GUI (читается «гуи») — это интерфейс игры: + всё, что нарисовано поверх 3D-сцены. Кнопки, надписи, + счётчик очков, полоска здоровья, меню — это всё GUI. +

+

+ GUI не находится внутри игрового мира — он «приклеен» + к экрану. Куда бы ни смотрел игрок, кнопка остаётся + на том же месте экрана. +

+

+ В редакторе есть визуальный редактор UI: ты + перетаскиваешь элементы мышкой, ставишь их в нужное место, + меняешь цвет и текст — и сразу видишь результат. Открыть + его можно через инструмент Интерфейс. +

+ + Не путай GUI и HUD. GUI — это элементы, + которые ты сам нарисовал в редакторе интерфейса. + HUD — стандартные счётчики (game.ui.score, + game.ui.timer), которые рисует сама игра + по команде из скрипта. + + + ), + }, + { + id: 'gui-elements', + title: 'C2. Контейнер, надпись, кнопка, поле ввода, картинка', + body: ( + <> +

Из чего собирается интерфейс:

+ +
    +
  • + Контейнер (Frame) — прямоугольник-коробка. + Сам по себе это просто фон, но внутрь него кладут + другие элементы. Контейнер — основа любого меню. +
  • +
  • + Надпись (Label) — текст на экране. Счёт, имя + игрока, подсказки. +
  • +
  • + Кнопка (Button) — на неё можно нажать. По клику + в скрипте срабатывает действие. +
  • +
  • + Поле ввода (TextBox) — сюда игрок печатает текст + или число. Например, ввести код от двери. +
  • +
  • + Картинка (Image) — изображение: иконка, логотип, + фон меню. +
  • +
+

+ Имя элемента. Как и у объектов сцены, у GUI-элемента + есть имя. Скрипт находит элемент по имени командой + game.gui.find('Кнопка старта'). Давай + кнопкам и надписям понятные имена. +

+ + ), + }, + { + id: 'gui-script', + title: 'C3. Как оживить кнопку скриптом', + body: ( + <> +

+ Нарисованная кнопка сама по себе ничего не делает — + нужен скрипт. Самый простой способ — повесить скрипт + прямо на кнопку. +

+ + {`// Скрипт висит на кнопке. +// game.self — это сама кнопка. +game.self.onClick(() => { + game.ui.showText('Кнопка нажата!', 2); + game.sound.play('click'); +});`} +

+ Можно и наоборот — управлять кнопкой из глобального + скрипта, если найти её по имени: +

+ + {`// Находим кнопку по имени и вешаем на неё клик +const btnId = game.gui.find('Кнопка старта'); + +game.gui.onClick(btnId, () => { + game.ui.showText('Игра началась!', 2); + // спрятать кнопку после нажатия + game.gui.hide(btnId); +});`} +

+ Что тут происходит: game.gui.find ищет + элемент по имени и возвращает его id («адрес»). + game.gui.onClick вешает на этот id действие. + game.gui.hide прячет кнопку, чтобы её нельзя + было нажать второй раз. +

+ + ), + }, + { + id: 'gui-textbox', + title: 'C4. Поле ввода: дверь по коду', + body: ( + <> +

+ Поле ввода позволяет игроку напечатать ответ. + Когда он нажмёт Enter, срабатывает событие + onSubmit — и скрипт получает введённый текст. +

+ + {`// Игрок вводит код. Правильный код — 1234. +const boxId = game.gui.find('Поле кода'); + +game.gui.onSubmit(boxId, (text) => { + if (text === '1234') { + game.ui.showText('Верно! Дверь открыта', 2); + // двигаем дверь вверх, чтобы освободить проход + const door = game.scene.findOne('Дверь'); + game.tween(door, { y: 8 }, { duration: 1 }); + } else { + game.ui.showText('Неверный код', 1.5); + } +});`} +

+ Разберём построчно: onSubmit даёт переменную + text — это то, что напечатал игрок. + if (text === '1234') — проверяем, совпал ли + код. Если да — открываем дверь твином (плавно поднимаем). + Если нет — пишем «Неверный код». +

+ + Две одинарные кавычки '1234' означают, + что это текст, а не число. Игрок печатает в поле + всегда текст, поэтому и сравнивать нужно с текстом. + + + ), + }, + { + id: 'gui-styles', + title: 'C5. Стили и загрузка картинок', + body: ( + <> +

+ У каждого GUI-элемента в инспекторе настраивается внешний + вид: цвет фона и прозрачность, граница + (рамка, её цвет и толщина), скругление углов + (большое скругление делает кнопку «таблеткой»), + тень (мягкая тень под элементом), + цвет и размер текста. +

+

+ В элемент Картинка можно загрузить своё изображение + с компьютера (PNG или JPG): логотип игры, иконку кнопки, + фон главного меню. +

+ + Хороший интерфейс — это аккуратный интерфейс. Один стиль + для всех кнопок, один шрифт, выровненные отступы. Картинки + бери небольшие — огромные файлы делают игру тяжёлой. + + + ), + }, + ], + }, + + // ════════════════════════════════════════════════════ + // РАЗДЕЛ D — СКРИПТЫ — ОСНОВЫ + // ════════════════════════════════════════════════════ + { + id: 'scripts-basics', + icon: 'code', + title: 'Скрипты — основы', + summary: 'Самый важный раздел: что такое скрипт, переменные, события, таймеры.', + sections: [ + { + id: 'what-is-script', + title: 'D1. Что такое скрипт и как его создать', + body: ( + <> +

+ Скрипт — это набор команд, который оживляет игру. + Блоки и модели сами по себе просто стоят. Чтобы монетка + собиралась, дверь открывалась, а враг гнался за игроком — + нужен скрипт. +

+

+ Скрипты пишут на языке JavaScript — одном из самых + популярных языков в мире. Не пугайся: начнём с простого, + а редактор подсказывает команды по ходу набора. +

+

Как создать первый скрипт:

+ + В иерархии (правая панель) найди категорию Скрипты + и нажми кнопку «+». + + + + Откроется окно кода. Напиши в нём одну строку: + + {`game.log('Привет! Игра запустилась.');`} + + + Нажми Играть. Внизу справа + открой Консоль — там появится твоё сообщение. + +

+ Это твой первый работающий скрипт. Команда + game.log(...) печатает сообщение в консоль. +

+ + Каждая команда заканчивается точкой с запятой + ; — как точка в конце предложения. Текст + пишут в кавычках: 'привет'. Забыл кавычки + или точку с запятой — будет ошибка. + + + ), + }, + { + id: 'global-vs-object', + title: 'D2. Глобальный скрипт и скрипт на объекте', + body: ( + <> +

+ Это очень важно понять с самого начала. Скрипты бывают + двух видов, и в каждом уроке вики написано, какой + именно нужен. +

+ +

+ Глобальный скрипт — это «мозг игры». Он не привязан + ни к чему. Запускается один раз при старте. В нём пишут + общие правила: подсчёт очков, таймер уровня, проверку + победы. +

+ +

+ Скрипт на объекте относится к конкретному кубу, модели + или кнопке. Внутри такого скрипта работает волшебное слово + game.self — это и есть тот объект, на котором + висит скрипт. Через него ловят клик по объекту или касание + игроком. +

+

+ Как привязать скрипт к объекту: выдели объект + на сцене, потом создай скрипт — он автоматически привяжется + к выделенному объекту. Или укажи носителя в настройках + скрипта. +

+ + Простое правило: если в коде урока есть + game.self — это скрипт на объекте. + Если game.self нет — скрипт глобальный. + Плашка в начале каждого урока всегда подскажет. + + + ), + }, + { + id: 'variables', + title: 'D3. Переменные — память скрипта', + body: ( + <> +

+ Переменная — это «коробочка с именем», в которой + скрипт хранит значение. Например, количество очков, + имя игрока, выбранный уровень. +

+ {`// Создаём переменную и кладём в неё число +let score = 0; + +// Меняем значение +score = score + 10; // теперь в score лежит 10 +score = score + 5; // теперь 15 + +game.log('Очков:', score); // напечатает: Очков: 15`} +

+ let — это слово «создать переменную». Пишут + его только один раз, когда коробочку заводят. Дальше + меняют значение уже без let. +

+

В переменную можно класть не только числа:

+ {`let name = 'Герой'; // текст — в кавычках +let isWin = false; // да/нет — true или false +let coinCount = 0; // число — без кавычек`} + + Если значение никогда не меняется — вместо + let можно писать const + («постоянная»). Например, найденную один раз дверь: + const door = game.scene.findOne('Дверь'); + + + ), + }, + { + id: 'game-object', + title: 'D4. Объект game — главный инструмент', + body: ( + <> +

+ В каждом скрипте есть одно главное волшебное слово — + game. Через него ты управляешь всей игрой. + У game много «отделов»: +

+ + + + + + + + + + +
game.playerуправление игроком
game.sceneобъекты сцены
game.uiсчётчики и текст на экране
game.guiкнопки и меню
game.soundзвуки
game.physicsлучи, импульсы, взрывы
game.selfобъект-носитель скрипта
+

+ Запись через точку читается слева направо. + game.player.teleport(0, 5, 0) читается так: + «у игры, у игрока, выполни телепорт + в точку 0, 5, 0». +

+

+ Полный список всех команд каждого отдела — в Справочнике + (раздел H). Не нужно его заучивать: при наборе кода + редактор сам показывает подсказки. +

+ + ), + }, + { + id: 'log-console', + title: 'D5. game.log, консоль, отладка', + body: ( + <> +

+ Консоль — окошко в правом нижнем углу редактора. + Туда выводятся все сообщения и ошибки скриптов. +

+

+ Команда game.log(...) печатает в консоль + что угодно. Это главный инструмент отладки — + проверки, что код работает правильно: +

+ + {`let score = 0; +score = score + 10; +game.log('Очки сейчас:', score); // Очки сейчас: 10 + +let pos = game.player.position; +game.log('Игрок стоит в точке:', pos);`} +

+ Если игра ведёт себя странно — расставь + game.log по коду и посмотри, какие значения + печатаются. Так ты увидишь, где именно что-то пошло не так. +

+ + Если в скрипте опечатка — текст ошибки появится + в Консоли красным, и там же будет написан номер + строки с ошибкой. Всегда заглядывай в Консоль первым делом. + + + ), + }, + { + id: 'events', + title: 'D6. События: onTick, onKey, onClick, onTouch', + body: ( + <> +

+ Событие — это «что-то случилось». Скрипт может + ждать событие и реагировать на него. Самые важные: +

+ + + + + + + +
game.onTick(fn)каждый кадр (60 раз в секунду)
game.onKey('space', fn)игрок нажал клавишу
game.self.onClick(fn)игрок кликнул по объекту
game.self.onTouch(fn)игрок коснулся объекта
+

Пример — куб, который исчезает по клику:

+ + {`game.self.onClick(() => { + game.self.delete(); // удалить сам себя + game.log('Куб удалён!'); +});`} +

+ Что такое {`() => { ... }`}? Это + «функция» — набор команд, упакованных вместе. Команды + внутри фигурных скобок выполнятся не сразу, а только + когда случится событие. То есть «когда кликнули — тогда + удалить и напечатать». +

+ + onTick выполняется ОЧЕНЬ часто — 60 раз + в секунду. Не делай внутри него тяжёлых вещей. Подробнее + об этой ошибке — раздел J4. + + + ), + }, + { + id: 'conditions', + title: 'D7. Условия: if / else', + body: ( + <> +

+ Условие — это развилка: «если что-то верно — + сделай одно, иначе — другое». В JavaScript это + слова if («если») и else + («иначе»). +

+ + {`let coins = 7; + +if (coins >= 10) { + game.ui.showText('Хватает на покупку!', 2); +} else { + game.ui.showText('Нужно больше монет', 2); +}`} +

+ Тут проверяется: coins {'>'}= 10 — «монет + 10 или больше?». Сейчас монет 7, значит условие неверно, + и сработает ветка else. +

+

Знаки сравнения:

+ + + + + + + + + +
a === ba равно b
a !== ba не равно b
a {'>'} ba больше b
a {'<'} ba меньше b
a {'>'}= ba больше или равно b
a {'<'}= ba меньше или равно b
+ + Для проверки «равно» пишут три знака равенства + ===, а не один. Один знак = — + это «положить значение в переменную», совсем другое + действие. + + + ), + }, + { + id: 'timers', + title: 'D8. Таймеры: after, every, cancel', + body: ( + <> +

+ Таймеры запускают команды не сразу, а потом: +

+
    +
  • + game.after(сек, fn) — выполнить + один раз через несколько секунд; +
  • +
  • + game.every(сек, fn) — выполнять + снова и снова каждые несколько секунд; +
  • +
  • + game.cancel(id) — остановить таймер. +
  • +
+ + {`// Через 3 секунды показать текст +game.after(3, () => { + game.ui.showText('Игра началась!', 2); +}); + +// Каждую секунду прибавлять очко. +// every возвращает номер таймера — запомним его. +const ticker = game.every(1, () => { + game.ui.score = (game.ui.score || 0) + 1; +}); + +// Через 10 секунд остановить начисление очков +game.after(10, () => { + game.cancel(ticker); + game.ui.showText('Время вышло!', 2); +});`} +

+ Запись (game.ui.score || 0) читается так: + «возьми счёт, а если его ещё нет — возьми 0». Это защита + от ошибки в самом начале, когда счётчик ещё пустой. +

+ + ), + }, + ], + }, + + // ════════════════════════════════════════════════════ + // РАЗДЕЛ E — СКРИПТЫ — ДВИЖЕНИЕ И АНИМАЦИЯ + // ════════════════════════════════════════════════════ + { + id: 'scripts-motion', + icon: 'run', + title: 'Движение и анимация', + summary: 'Управление игроком, плавные твины, спавн и перемещение объектов.', + sections: [ + { + id: 'player-control', + title: 'E1. Управление игроком: скорость, прыжок, гравитация', + body: ( + <> +

+ Скриптом можно менять, как двигается игрок. Эти команды + принимают множитель: 1 — обычно, 2 — в два раза + сильнее, 0.5 — в два раза слабее. +

+ + + + + + + + +
setSpeed(mul)скорость бега
setJumpPower(mul)сила прыжка
setGravityMul(mul)сила притяжения
setDoubleJump(true)разрешить двойной прыжок
teleport(x,y,z)мгновенно переставить
+

Пример — «зелье скорости» при касании сферы:

+ + {`game.self.onTouch(() => { + // ускоряем игрока в 2 раза + game.player.setSpeed(2); + game.ui.showText('Скорость x2 на 5 секунд!', 2); + game.sound.play('pickup'); + + // зелье исчезает + game.self.delete(); + + // через 5 секунд скорость снова обычная + game.after(5, () => { + game.player.setSpeed(1); + }); +});`} + + Не забывай возвращать скорость обратно командой + setSpeed(1). Иначе игрок останется быстрым + навсегда — а это может сломать твой уровень. + + + ), + }, + { + id: 'player-animations', + title: 'E2. Анимации-эмоции персонажа', + body: ( + <> +

+ Персонаж умеет показывать эмоции. Команда + game.player.playAnimation(имя) проигрывает + анимацию: 'wave' (помахать), + 'dance' (танец), 'cheer' + (радость), 'sit' (сесть). +

+ + {`// При победе персонаж радуется +game.player.playAnimation('cheer'); + +// Через 3 секунды перестать +game.after(3, () => { + game.player.stopAnimation(); +});`} + + ), + }, + { + id: 'tweens', + title: 'E3. Твины — плавные движения', + body: ( + <> +

+ Твин — это плавное изменение чего-либо за время. + Если просто переставить объект командой move — + он телепортируется рывком. А твин плавно доедет + из точки в точку. +

+

Команда: game.tween(объект, что менять, настройки)

+ + {`// Находим платформу-лифт по имени +const lift = game.scene.findOne('Лифт'); + +// Платформа за 2 секунды плавно поднимается на высоту 10 +game.tween(lift, { y: 10 }, { + duration: 2, // длительность в секундах + easing: 'ease' // характер движения +});`} +

+ Твином можно менять позицию (x, y, z), + поворот, размер, цвет, прозрачность. +

+

Полезные настройки твина:

+ + + + + + + + +
durationсколько секунд длится
easing'linear' (ровно), 'ease' (плавно), 'bounce' (с отскоком)
repeatсколько раз повторить
yoyo: trueдвигаться туда-обратно
onDoneчто сделать, когда твин закончится
+ {`// Платформа вечно ездит вверх-вниз +const plat = game.scene.findOne('Качалка'); +game.tween(plat, { y: 8 }, { + duration: 2, + yoyo: true, // обратно вниз + repeat: 999 // повторять почти бесконечно +});`} + + ), + }, + { + id: 'spawn-delete', + title: 'E4. Спавн и удаление объектов', + body: ( + <> +

+ Спавн — создание нового объекта прямо во время игры. + Команда game.scene.spawn(тип, настройки): +

+ + {`// Создаём золотую монетку-сферу +const coin = game.scene.spawn('primitive:sphere', { + x: 5, y: 1, z: 0, // где появится + color: '#ffd700' // золотой цвет +}); + +game.log('Создали монетку, её адрес:', coin);`} +

+ Тип бывает 'block:трава', + 'primitive:cube', 'model:tree'. + Команда возвращает ref — это «адрес» объекта, + по которому к нему можно обращаться (двигать, удалять). +

+

Удаление объекта:

+ {`// удалить сразу +game.scene.delete(coin); + +// удалить через 3 секунды +game.scene.deleteAfter(coin, 3);`} + + Запоминай ref в переменную (let coin + = ...). Без адреса ты потом не сможешь объект + ни подвинуть, ни удалить. + + + ), + }, + { + id: 'move-objects', + title: 'E5. Перемещение объектов', + body: ( + <> +

Передвинуть объект скриптом можно несколькими способами:

+ + + + + + + +
game.scene.move(ref,x,y,z)мгновенно переставить
game.scene.rotate(ref,угол)повернуть
game.self.move(x,y,z)скрипт двигает сам себя
game.tween(...)плавное перемещение (E3)
+

Пример — дверь уезжает вверх и освобождает проход:

+ + {`const door = game.scene.findOne('Дверь'); + +// плавно поднимаем дверь на 6 единиц вверх +game.tween(door, { y: 6 }, { duration: 1 });`} + + ), + }, + ], + }, + + // ════════════════════════════════════════════════════ + // РАЗДЕЛ F — СКРИПТЫ — ИГРОВАЯ ЛОГИКА + // ════════════════════════════════════════════════════ + { + id: 'scripts-logic', + icon: 'target', + title: 'Игровая логика', + summary: 'HP и урон, физика, теги, взаимодействие по E, связи между объектами.', + sections: [ + { + id: 'player-hp', + title: 'F1. HP игрока: урон, лечение, смерть, чекпоинт', + body: ( + <> +

Команды для здоровья игрока:

+ + + + + + + + + +
game.player.hpтекущее здоровье (можно читать)
game.player.damage(n)нанести урон
game.player.heal(n)вылечить
game.player.kill()мгновенно убить
game.player.respawn()воскресить на спавне
game.player.setSpawn(точка)новая точка возрождения
+

Пример 1 — шипы наносят урон:

+ + {`game.self.onTouch(() => { + game.player.damage(20); // отнять 20 здоровья + game.sound.play('hit'); +});`} +

Пример 2 — аптечка лечит:

+ + {`game.self.onTouch(() => { + game.player.heal(50); // добавить 50 здоровья + game.ui.showText('+50 HP', 1.5); + game.self.delete(); // аптечка исчезает +});`} + + ), + }, + { + id: 'physics', + title: 'F2. Физика: raycast, импульсы, взрывы', + body: ( + <> +

+ Отдел game.physics отвечает за «настоящую» + физику: +

+
    +
  • + raycast(откуда, куда, опции) — пустить + невидимый луч и узнать, во что он попал. Так делают + стрельбу; +
  • +
  • + applyImpulse(ref, сила) — толкнуть объект + (он должен быть не закреплён); +
  • +
  • + explode(точка, радиус, опции) — взрыв. +
  • +
+

Пример — стрельба лучом из камеры игрока:

+ + {`// При клике мышкой пускаем луч туда, куда смотрит игрок +game.onClick(() => { + const p = game.player.position; + + const hit = game.physics.raycast( + { x: p.x, y: p.y + 1.5, z: p.z }, // откуда (от головы) + game.player.forward, // куда (взгляд) + { maxDistance: 50 } // как далеко + ); + + if (hit.hit) { + game.log('Попал в объект:', hit.ref); + game.sound.play('hit'); + } +});`} +

+ hit.hit — попал ли луч во что-нибудь + (да/нет). hit.ref — адрес объекта, в который + попали. +

+ + ), + }, + { + id: 'attributes', + title: 'F3. Атрибуты объектов (setData / getData)', + body: ( + <> +

+ Атрибут — это значение, которое ты «приклеиваешь» + к объекту. Например, сколько здоровья у конкретного врага + или сколько монет стоит товар. +

+ + {`// При старте игры запоминаем цену прямо на товаре +game.scene.setData(game.self.ref, 'price', 50); + +// Когда игрок кликает по товару — читаем цену +game.self.onClick(() => { + const price = game.scene.getData(game.self.ref, 'price'); + game.ui.showText('Этот товар стоит ' + price + ' монет', 2); +});`} +

+ Чем атрибут лучше обычной переменной? Переменная одна + на весь скрипт. А атрибут — свой у каждого объекта. + Один и тот же скрипт можно повесить на 10 разных товаров, + и у каждого будет своя цена. +

+ + ), + }, + { + id: 'tags', + title: 'F4. Теги объектов', + body: ( + <> +

+ Тег — это «ярлык», который можно повесить сразу + на много объектов. Потом одной командой можно найти их все. +

+ + + + + + + +
tag(ref, 'звезда')повесить тег
untag(ref, 'звезда')снять тег
hasTag(ref, 'звезда')есть ли тег
getTagged('звезда')все объекты с тегом
+

Пример — игра «собери все звёзды»:

+ + {`// Этот скрипт висит на звезде. +// При старте помечаем звезду тегом. +game.scene.tag(game.self.ref, 'звезда'); + +// Когда игрок коснулся — звезда собрана +game.self.onTouch(() => { + game.self.delete(); + game.sound.play('coin'); + + // сколько звёзд ещё осталось на сцене? + const left = game.scene.getTagged('звезда').length; + if (left === 0) { + game.ui.showText('Все звёзды собраны! Победа!', 3); + } else { + game.ui.showText('Осталось звёзд: ' + left, 1.5); + } +});`} + + Снятие тега убирает только ярлык. Цвет, размер и другие + свойства объекта при этом не меняются. + + + ), + }, + { + id: 'proximity', + title: 'F5. ProximityPrompt — взаимодействие по клавише E', + body: ( + <> +

+ Часто игра просит «подойди и нажми E»: открыть сундук, + поговорить с торговцем, дёрнуть рычаг. Это делается + командой game.self.onInteract: +

+ + {`game.self.onInteract(() => { + game.ui.showText('Сундук открыт!', 2); + game.scene.spawnParticles('sparks', + game.self.position, { duration: 1 }); + game.sound.play('pickup'); +}, { + text: 'Открыть сундук', // подсказка над объектом + distance: 4 // на сколько метров подойти +});`} +

+ Когда игрок подойдёт ближе чем на distance + метров, над объектом появится подсказка с текстом. + Нажатие E запустит функцию. +

+ + ), + }, + { + id: 'billboard', + title: 'F6. Billboard-метки над объектами', + body: ( + <> +

+ Billboard — это текст-табличка, которая висит + над объектом в 3D-мире и всегда повёрнута к игроку. + Так показывают имена врагов, их HP, названия мест. +

+ + {`// Допустим, npc — это адрес созданного NPC. +// Вешаем над ним табличку с именем. +game.scene.setLabel(npc.ref, 'Торговец Боб', { + color: '#ffffff', + height: 2.5 // на 2.5 метра над объектом +}); + +// Позже можно убрать табличку +game.scene.clearLabel(npc.ref);`} + + ), + }, + { + id: 'pass-through', + title: 'F7. Проходимость объектов (passThrough)', + body: ( + <> +

+ Иногда стена должна стать проходимой — призрачная стена, + секретный проход, исчезающий мост. Команда + game.physics.passThrough(ref, true) делает + объект «бесплотным»: видно его, но игрок проходит насквозь. +

+ + {`// Когда игрок кликнет по стене — она пропустит сквозь себя +game.self.onClick(() => { + game.physics.passThrough(game.self.ref, true); + game.scene.setOpacity(game.self.ref, 0.3); // полупрозрачная + game.ui.showText('Секретный проход открыт!', 2); +});`} + + Если сделать стену снова твёрдой, пока игрок стоит внутри + неё — игра аккуратно вытолкнет его наружу, он не застрянет. + + + ), + }, + { + id: 'constraints', + title: 'F8. Связи: склейка, петля, пружина', + body: ( + <> +

+ Связи (constraints) соединяют объекты, чтобы они + двигались вместе или по правилам физики. Отдел — + game.constraints: +

+
    +
  • + Склейка (weld) — намертво приклеивает один + объект к другому; +
  • +
  • + Петля (hinge) — объект вращается вокруг оси, + как дверь на петлях или качели; +
  • +
  • + Пружина (spring) — объект упруго колеблется, + как батут. +
  • +
+

Пример — качели на петле:

+ + {`const swing = game.scene.findOne('Качели'); + +// делаем качели на петле +const h = game.constraints.hinge(swing, { + pivotX: 0, pivotZ: 0, // ось вращения + angle: 30 // наклон на 30 градусов +}); + +// раскачиваем в другую сторону каждую секунду +let dir = -30; +game.every(1, () => { + h.setAngle(dir); + dir = -dir; // меняем знак: 30 → -30 → 30 ... +});`} + + ), + }, + ], + }, + + // ════════════════════════════════════════════════════ + // РАЗДЕЛ G — СКРИПТЫ — БОЛЬШИЕ СИСТЕМЫ + // ════════════════════════════════════════════════════ + { + id: 'scripts-systems', + icon: 'gear', + title: 'Большие системы', + summary: 'NPC, инвентарь и оружие, звук, камера и катсцены, мультиплеер.', + sections: [ + { + id: 'npc', + title: 'G1. NPC: создание, движение, диалоги', + body: ( + <> +

+ NPC (неигровой персонаж) — это житель твоей игры: + торговец, враг, проводник. Создаётся командой + game.scene.spawnNpc(модель, опции). +

+ + {`// Создаём NPC по имени Боб +const bob = game.scene.spawnNpc('character-a', { + x: 5, y: 0, z: 0, + name: 'Боб', + hp: 100, + speed: 3 +}); + +// Боб говорит реплику над головой (3 секунды) +bob.say('Привет, путник!', 3); + +// Боб идёт в точку (x = 10, z = 0) +bob.moveTo(10, 0);`} +

Что умеет NPC:

+ + + + + + + + + + +
moveTo(x, z)идти в точку
follow('player')гнаться за игроком
stop()остановиться
say(текст, сек)реплика над головой
damage(n)нанести урон NPC
remove()убрать со сцены
onDeath(fn)что сделать при гибели
+

Пример — враг гонится за игроком:

+ {`const enemy = game.scene.spawnNpc('character-b', { + x: 0, y: 0, z: 20, name: 'Враг', hp: 50, speed: 2 +}); + +enemy.follow('player'); // началась погоня + +enemy.onDeath(() => { + game.ui.showText('Враг побеждён!', 2); + game.scene.spawnParticles('explosion', + enemy.position, { duration: 1 }); +});`} + + ), + }, + { + id: 'inventory-tools', + title: 'G2. Инвентарь и инструменты', + body: ( + <> +

+ Инвентарь — это сумка предметов внизу экрана. + Инструмент — предмет, который игрок берёт в руку: + меч, фонарик, лопата. +

+ + {`// Выдать игроку меч прямо в руку +game.player.giveTool('sword', { + name: 'Стальной меч', + equip: true // сразу взять в руку +}); + +// Ловим, когда игрок применил инструмент (ЛКМ) +game.player.onToolUse((e) => { + game.log('Игрок применил:', e.tool); +});`} +

+ Команды отдела game.inventory: + add(item) — добавить предмет, + remove(имя) — убрать, + has(имя) — есть ли предмет, + list() — список всех предметов. +

+

Пример — игра «ключ и сундук»:

+ + {`game.self.onInteract(() => { + // проверяем, есть ли у игрока ключ + if (game.inventory.has('Ключ')) { + game.ui.showText('Сундук открыт!', 2); + game.inventory.remove('Ключ'); // ключ потрачен + } else { + game.ui.showText('Нужен ключ', 1.5); + } +}, { text: 'Открыть', distance: 4 });`} + + ), + }, + { + id: 'sound', + title: 'G3. Звук: свои звуки и 3D-позиционный звук', + body: ( + <> +

+ Звук оживляет игру. Команда + game.sound.play(id, опции). +

+ + {`// Готовые звуки-пресеты +game.sound.play('coin'); // звон монетки +game.sound.play('win'); // победа +game.sound.play('jump'); // прыжок +game.sound.play('hit'); // удар + +// Свой загруженный звук, потише +game.sound.play('sound_1', { volume: 0.7 });`} +

+ Пресеты: jump, pickup, + win, lose, click, + hit, coin. +

+

+ 3D-звук — если указать опцию at, + звук пойдёт из точки в мире: чем дальше игрок, тем тише. +

+ {`// Звук костра — слышен только когда подходишь близко +game.sound.play('sound_2', { + at: { x: 0, y: 1, z: 0 }, + loop: true // звук повторяется по кругу +});`} + + Звук в играх обязателен — игра без звука кажется + «мёртвой». Но не запускай длинную музыку в самом начале: + это скучно и тормозит старт. Звуки вешай на события: + прыжок, попадание, победа. + + + ), + }, + { + id: 'camera', + title: 'G4. Камера: FOV, привязка, катсцены', + body: ( + <> +

Отдел game.camera управляет видом игрока:

+ + + + + + + + +
setFov(градусы)угол обзора — больше «шире» видно
shake(сила, сек)тряска камеры (взрыв, удар)
focusOn(ref)навести камеру на объект
cutscene(точки, опции)пролёт камеры по точкам
reset()вернуть камеру игроку
+

Пример — облёт уровня при старте игры:

+ + {`// камера плавно пролетает через три точки +game.camera.cutscene([ + { x: 0, y: 20, z: -30 }, + { x: 0, y: 15, z: 0 }, + { x: 0, y: 10, z: 30 } +], { segDuration: 2 }); // 2 секунды на отрезок + +// когда облёт закончится — отдать камеру игроку +game.onCutsceneDone(() => { + game.ui.showText('Поехали!', 2); +});`} + + ), + }, + { + id: 'beam-trail', + title: 'G5. Лучи и следы (Beam и Trail)', + body: ( + <> +

+ Отдел game.fx создаёт красивые эффекты-линии: + Beam — светящаяся линия между двумя точками + (лазер, мост света), Trail — шлейф за движущимся + объектом (след за ракетой). +

+ + {`// Лазер между двумя башнями +const t1 = game.scene.findOne('Башня1'); +const t2 = game.scene.findOne('Башня2'); + +const laser = game.fx.beam({ + from: t1, + to: t2, + color: '#ff3344', + width: 0.3 +});`} + + ), + }, + { + id: 'multiplayer', + title: 'G6. Мультиплеер: игроки, комната, команды', + body: ( + <> +

+ В Рублоксе можно сделать игру на несколько игроков + в одной комнате. Главные отделы: +

+
    +
  • + game.players — список игроков: + all(), count(), + me() (это я); +
  • +
  • + game.room — общее состояние комнаты, + которое видят все игроки; +
  • +
  • + game.teams — команды. +
  • +
+ + {`// Общий счёт команды — виден всем игрокам в комнате +game.room.set('totalScore', 0); + +// когда счёт меняется — обновляем надпись у всех +game.room.onChange('totalScore', (val) => { + game.ui.set('score', 'Счёт команды: ' + val); +}); + +// сколько игроков сейчас в игре +game.log('Игроков в комнате:', game.players.count()); + +// когда новый игрок зашёл +game.onPlayerJoin((p) => { + game.ui.showText(p.name + ' присоединился!', 2); +});`} + + ), + }, + ], + }, + + // ════════════════════════════════════════════════════ + // РАЗДЕЛ H — СПРАВОЧНИК game.* + // ════════════════════════════════════════════════════ + { + id: 'reference', + icon: 'book', + title: 'Справочник game.*', + summary: 'Шпаргалка: все команды game.* списком по отделам.', + sections: [ + { + id: 'cheatsheet', + title: 'H1. Шпаргалка — все команды списком', + body: ( + <> +

+ Здесь собраны все команды game.* по отделам. + Это шпаргалка — не нужно её запоминать, держи под рукой. +

+ +

game.player — игрок

+ + + + + + + + + + + + + + + + + + +
positionпозиция игрока {`{x,y,z}`}
hp / maxHpздоровье и максимум
aliveжив ли игрок (да/нет)
forwardкуда смотрит {`{x,y,z}`}
teleport(x,y,z)телепорт
damage(n) / heal(n)урон / лечение
kill() / respawn()убить / воскресить
setSpawn(точка)новая точка возрождения
setSpeed(mul)скорость бега
setJumpPower(mul)сила прыжка
setGravityMul(mul)сила гравитации
setDoubleJump(on)двойной прыжок
playAnimation(имя)эмоция персонажа
giveTool(тип,опции)дать инструмент
isKeyDown(клавиша)зажата ли клавиша сейчас
+ +

game.scene — объекты сцены

+ + + + + + + + + + + + + + + + + + + + + +
spawn(тип,опции)создать объект → ref
delete(ref)удалить
deleteAfter(ref,сек)удалить через N секунд
move(ref,x,y,z)переместить
rotate(ref,угол)повернуть
setColor(ref,цвет)сменить цвет
setCollide(ref,да)твёрдость
setVisible(ref,да)видимость
setOpacity(ref,0..1)прозрачность
find(имя) / findOne(имя)поиск по имени
all(тип)все объекты типа
getPosition(ref)позиция объекта
setData/getDataатрибуты объекта
tag/untag/hasTagтеги
getTagged(тег)все объекты с тегом
setLabel/clearLabelтекст-метка над объектом
spawnNpc(модель,опции)создать NPC
spawnParticles(тип,...)частицы
+ +

game.self — объект-носитель скрипта

+ + + + + + + + + + + +
ref / positionадрес и позиция объекта
onClick(fn)клик по объекту
onTouch(fn)игрок коснулся
onUntouch(fn)игрок вышел из объекта
onInteract(fn,опции)взаимодействие по E
move(x,y,z)переместить себя
delete()удалить себя
setText(t)сменить текст (для GUI)
+ +

game.ui — счётчики и текст

+ + + + + + + +
score / timerсчётчики в углу
showText(текст,сек)текст по центру
set(id,текст,опции)своя метка на экране
remove(id) / clear()убрать метку / всё
+ +

game.gui — кнопки и меню

+ + + + + + + + +
find(имя) / get(id)найти элемент
update(id,patch)изменить свойства
show(id) / hide(id)показать / скрыть
onClick(id,fn)клик по кнопке
onSubmit(id,fn)ввод в поле завершён
+ +

physics, fx, constraints

+ + + + + + + + + + + + +
physics.raycast(...)луч — во что попал
physics.applyImpulse(...)толкнуть объект
physics.explode(...)взрыв
physics.passThrough(...)проходимость
fx.beam(опции)светящийся луч
fx.trail(ref,опции)след за объектом
constraints.weld(a,b)склейка
constraints.hinge(...)петля
constraints.spring(...)пружина
+ +

camera, sound

+ + + + + + + + +
camera.setFov(град)угол обзора
camera.shake(сила,сек)тряска
camera.cutscene(...)пролёт камеры
camera.reset()вернуть камеру
sound.play(id,опции)проиграть звук
+ +

События и таймеры

+ + + + + + + + + + +
onTick(fn)каждый кадр
onKey/onKeyUp(клавиша,fn)клавиатура
onClick(fn)клик в игре
after(сек,fn)через N секунд
every(сек,fn)каждые N секунд
cancel(id)отменить таймер
tween(ref,св-ва,опции)плавная анимация
+ +

Утилиты

+ + + + + + + + + +
random(min,max)случайное число
distance(a,b)расстояние между точками
clamp(v,min,max)зажать число в границах
lerp(a,b,t)плавный переход a→b
log(...)напечатать в консоль
broadcast/onMessageсообщения между скриптами
+ + ), + }, + ], + }, + + // ════════════════════════════════════════════════════ + // РАЗДЕЛ I — ГЛОССАРИЙ + // ════════════════════════════════════════════════════ + { + id: 'glossary', + icon: 'glossary', + title: 'Глоссарий', + summary: 'Словарик: все непонятные слова из вики простым языком.', + sections: [ + { + id: 'terms', + title: 'I1. Термины простым языком', + body: ( + <> +

Словарик слов, которые встречаются в вики:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ПримитивПростая 3D-фигура: куб, сфера, цилиндр. Главный строительный материал.
МодельГотовая красивая 3D-фигура из библиотеки (дерево, машина).
БлокКубик одного размера, ровно встаёт по сетке.
СценаВесь игровой мир — всё, что ты построил.
ВьюпортОкно с 3D-сценой в центре редактора.
ГизмоЦветные стрелки и кольца для перемещения объектов.
ИерархияСписок всех объектов игры в правой панели.
ИнспекторПанель со свойствами выделенного объекта.
СкриптНабор команд (код), который оживляет игру.
JavaScriptЯзык программирования, на котором пишут скрипты.
Глобальный скриптСкрипт-«мозг», не привязан к объекту, запускается один раз.
Скрипт на объектеСкрипт конкретного объекта, в нём работает game.self.
Переменная«Коробочка с именем» — скрипт хранит в ней значение.
ФункцияНабор команд, который выполнится, когда его позовут.
Событие«Что-то случилось» — клик, касание, нажатие клавиши.
Условие (if)Развилка: если что-то верно — сделай одно, иначе — другое.
СпавнПоявление: точка спавна — где появляется игрок; «заспавнить» — создать объект.
РеспаунВоскрешение игрока после смерти.
ЧекпоинтКонтрольная точка — место, откуда игрок воскреснет.
HPЗдоровье игрока. При HP=0 он умирает.
ref«Адрес» объекта. Команда spawn возвращает ref, по нему обращаются к объекту.
ТвинПлавное изменение свойства за время (плавное движение).
ТегЯрлык на объекте. По тегу можно найти все помеченные объекты.
АтрибутСвоё значение, приклеенное к объекту (через setData).
КонстрейнтСвязь между объектами: склейка, петля, пружина.
ЭмиттерОбъект, создающий частицы (искры, дым, огонь).
ЧастицыМного маленьких летящих точек для эффектов.
GUIИнтерфейс: кнопки, надписи, меню поверх 3D-сцены.
HUDСчётчики и индикаторы поверх экрана (счёт, HP).
NPCНеигровой персонаж: торговец, враг, проводник.
ТриггерНевидимая зона, которая что-то запускает при входе игрока.
RaycastНевидимый луч — узнать, во что он попал (для стрельбы).
FOVУгол обзора камеры. Больше — «шире» видно.
КатсценаВидеовставка: камера сама пролетает по точкам.
МультиплеерИгра на нескольких игроков в одной комнате.
МодерацияПроверка игры перед публикацией в общей ленте.
+ + ), + }, + ], + }, + + // ════════════════════════════════════════════════════ + // РАЗДЕЛ J — ЧАСТЫЕ ОШИБКИ + // ════════════════════════════════════════════════════ + { + id: 'mistakes', + icon: 'bug', + title: 'Частые ошибки', + summary: 'Что делать, если скрипт не работает или игра ведёт себя странно.', + sections: [ + { + id: 'script-not-working', + title: 'J1. Скрипт не работает или не сохраняется', + body: ( + <> +

+ Первым делом — проверь Консоль. Если в скрипте + опечатка, её текст появится в Консоли красным — там + написано, в какой строке ошибка. +

+

Самые частые опечатки:

+
    +
  • забыли точку с запятой ; в конце команды;
  • +
  • + не закрыли скобку — открывающих и закрывающих + ( ), {`{ }`} должно быть + поровну; +
  • +
  • + имя команды с ошибкой: game.player.teelport + вместо teleport; +
  • +
  • + русская буква вместо английской (с виду одинаковы: + русская с и английская c). +
  • +
+

+ После правки скрипта — сохрани игру + (Ctrl+S) + и перезапусти Play. +

+ + ), + }, + { + id: 'fell-through-floor', + title: 'J2. Объект провалился сквозь пол', + body: ( +

+ Если объект не закреплён (свойство «Закреплён» + выключено), он падает под действием физики. Для платформ, + стен и декораций включи «Закреплён» в инспекторе — + тогда объект будет висеть на месте. Падать должны только те + объекты, которым это нужно (ящики, мячи). +

+ ), + }, + { + id: 'findone-null', + title: 'J3. findOne вернул «ничего» (null) на старте', + body: ( + <> +

+ Если позвать game.scene.findOne(...) в самой + первой строке скрипта — объект может быть ещё не готов, + и команда вернёт null («ничего»). Потом + обращение к этому null сломает скрипт. +

+

+ Решение: ищи объект не на старте, а внутри + onTick или после небольшой задержки: +

+ {`let door = null; +game.onTick(() => { + // ищем дверь, пока не найдём + if (!door) door = game.scene.findOne('Дверь'); + // ... работаем с door, когда он уже найден +});`} + + ), + }, + { + id: 'ui-set-every-frame', + title: 'J4. game.ui.set каждый кадр — игра лагает', + body: ( + <> +

+ onTick выполняется 60 раз в секунду. Если + внутри него на каждом кадре звать game.ui.set(...), + интерфейс будет обновляться слишком часто и игра начнёт + тормозить. +

+

+ Решение: обновляй интерфейс только когда значение + реально изменилось: +

+ {`let lastScore = -1; +game.onTick(() => { + const s = game.ui.score || 0; + if (s !== lastScore) { // значение изменилось? + game.ui.set('hud', 'Счёт: ' + s); + lastScore = s; + } +});`} + + ), + }, + { + id: 'other-mistakes', + title: 'J5. Прочие типичные грабли', + body: ( +
    +
  • + Враг идёт сквозь стены — проверь, что у стен + включено «Столкновение». +
  • +
  • + Звук не играет — проверь громкость + (volume) и что звук загружен. Не запускай + длинный звук в самом начале — это тормозит старт. +
  • +
  • + Кнопка не нажимается — проверь, что на неё повешен + game.gui.onClick(...) с правильным id, + и что у элемента правильное имя. +
  • +
  • + Объект не двигается твином — у твина свойство + должно быть числом ({`{ y: 10 }`}), а первым + аргументом — настоящий ref объекта. +
  • +
  • + Скрипт на объекте, но game.self пустой — значит + скрипт не привязан. Выдели объект и пересоздай скрипт + на нём, либо укажи носителя в настройках скрипта. +
  • +
  • + Игрок застрял в стене — не делай стену твёрдой, + пока игрок внутри. Используй passThrough + аккуратно. +
  • +
+ ), + }, + ], + }, +]; diff --git a/src/community/docsGames.js b/src/community/docsGames.js index eb40761..3e577db 100644 --- a/src/community/docsGames.js +++ b/src/community/docsGames.js @@ -30,6 +30,8 @@ export const GAME_GROUPS = [ hint: 'Комбинации механик, GUI, состояние игры, NPC-логика.' }, { id: 'g4', title: 'Группа 4 — Сложные', stars: 3, hint: 'Полные игры, мультиплеер, продвинутые системы.' }, + { id: 'g5', title: 'Разбор готовых игр', stars: 2, + hint: 'Настоящие игры из студии по косточкам: камера и таблички, анимации интерфейса, модальные сцены и кастомные скины. У каждой — кнопка «Открыть оригинал в редакторе».' }, ]; export const GAMES = [ @@ -292,4 +294,28 @@ export const GAMES = [ desc: 'Гайд: как придумать и собрать собственную игру с нуля.', mechanics: ['проектирование игры', 'все механики вместе'], ready: false }, + + // ── Группа 5 — Разбор готовых игр ───────────────────────────── + // Это НАСТОЯЩИЕ игры из студии. У карточек есть openProjectId — + // кнопка открывает оригинал игры в редакторе (а не строит из билдера). + { id: 'guide-dvor', num: 51, group: 'g5', stars: 1, icon: 'camera', + title: 'Двор с табличкой', + desc: 'Учимся крутить камеру мышкой как в Roblox и нажимать на 3D-таблички прямо в мире.', + mechanics: ['камера и мышь', 'ПКМ-orbit и зум', 'Shift-Lock (L)', '3D-таблички'], + previewShot: 'guide-dvor-scene.png', openProjectId: 1991, ready: true }, + { id: 'guide-vitrina', num: 52, group: 'g5', stars: 2, icon: 'palette', + title: 'Витрина GUI', + desc: 'Живые кнопки магазина: градиенты, пульсация, поворот и плавные твины при нажатии.', + mechanics: ['GUI-кнопки', 'анимации (pulse/rotate)', 'твины', 'счётчик монет'], + previewShot: 'guide-vitrina-scene.png', openProjectId: 1995, ready: true }, + { id: 'guide-sunduk', num: 53, group: 'g5', stars: 2, icon: 'scroll', + title: 'Тайна старого сундука', + desc: 'Кат-сцены и диалоги: затемнение, прожектор на сундуке, выбор приза и финальная победа.', + mechanics: ['game.modal', 'диалог по строкам', 'прожектор + камера', 'лутбокс'], + previewShot: 'guide-sunduk-scene.png', openProjectId: 2037, ready: true }, + { id: 'guide-zoo', num: 54, group: 'g5', stars: 2, icon: 'gamepad', + title: 'Парк животных', + desc: 'Кастомные скины: герой превращается в пончик, машину, пришельца. Магазин скинов на B.', + mechanics: ['game.player.setSkin', 'non-humanoid скины', 'магазин скинов', 'таблички'], + previewShot: 'guide-zoo-scene.png', openProjectId: 2046, ready: true }, ]; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 6d3017e..a43772f 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -7318,6 +7318,464 @@ game.self.onTouch(() => { ), }, + // ════════════════════════════════════════════════════ + // РАЗБОР ИГР · Двор с табличкой (оригинал id=1991) + // ════════════════════════════════════════════════════ + 'guide-dvor': { + body: ( + <> +

Что получится

+

+ Маленький уютный двор: зелёный газон, деревянный забор, + деревья и большая 3D-табличка в центре. По табличке + можно нажать мышкой прямо в игре — и что-то произойдёт. + А ещё двор учит главному: как крутить камеру вокруг героя, + как в настоящем Roblox. +

+ + +

Чему научишься

+
    +
  • Камера и мышь — зажми правую кнопку мыши и + веди, чтобы осмотреться вокруг героя;
  • +
  • Зум — колесо мыши приближает и отдаляет. Совсем + близко — игра сама переходит в вид «от первого лица»;
  • +
  • Shift-Lock на клавише L — + герой всегда смотрит туда же, куда камера;
  • +
  • Клик по 3D-табличке — как сделать кнопку прямо + в игровом мире (game.billboard.onClick).
  • +
+ +

Шаг 1. Двор

+ + Инструментом Блок построй площадку + из травы примерно 10×10. Это газон. + + + По краю поставь забор из блоков-брёвен в 2 блока высотой, + оставив спереди проход. + + + Из палитры моделей добавь пару деревьев — для красоты. + На вкладке Игра поставь точку спавна в центре. + + +

Шаг 2. Табличка

+

+ Табличка — это особый примитив «3D-табличка» + (биллборд). У неё есть кнопка, на которую можно нажимать. +

+ + Выбери Примитив → + категория Геймплей3D-табличка. Поставь её + в центр двора. + + + Выдели табличку. В инспекторе справа нажми + «Редактировать табличку» — откроется окно, где можно + задать текст, иконку, цвет и кнопку. + + + Запомни номер таблички — кликни по ней в Иерархии, + он выглядит как primitive:41 (число у тебя может + быть другое). + + +

Шаг 3. Скрипт клика

+

+ Теперь сделаем, чтобы по нажатию на табличку менялся цвет неба. +

+ + {`// === ДВОР С ТАБЛИЧКОЙ — главный скрипт === + +// Подписываемся на клик по кнопке таблички. +// 'primitive:41' — номер твоей таблички, 'buy' — её кнопка. +game.billboard.onClick('primitive:41', 'buy', () => { + game.environment.setSkyColor('#88c0ff'); // небо стало голубым + game.log('По табличке нажали!'); +});`} +

+ game.billboard.onClick(номер, кнопка, функция) — + «когда нажмут на эту кнопку, выполни функцию». Внутри мы + меняем цвет неба командой + game.environment.setSkyColor. +

+ +

Шаг 4. Проверка

+ + + Нажми Играть. Походи по двору + на WASD. + + + Зажми правую кнопку мыши и веди — камера крутится + вокруг героя. Покрути колесо — приближается и отдаляется. + + + Наведи курсор на кнопку таблички и кликни — небо поменяет цвет. + + + Камера и мышь — это фундамент почти любой игры. Разберёшься + здесь — дальше будет намного легче. + + + поставь рядом ещё две таблички с разными цветами неба и + сделай переключатель «утро — день — ночь» из трёх кнопок. + + + ), + }, + + // ════════════════════════════════════════════════════ + // РАЗБОР ИГР · Витрина GUI (оригинал id=1995) + // ════════════════════════════════════════════════════ + 'guide-vitrina': { + body: ( + <> +

Что получится

+

+ Витрина магазина, как в играх-кликерах. 3D-мира почти нет — + весь экран это интерфейс: счётчик монет и яркие кнопки. + И все кнопки живые: пульсируют, крутятся, увеличиваются + при наведении и «вдавливаются» при нажатии. +

+ + +

Чему научишься

+
    +
  • GUI-кнопки с градиентом, обводкой текста и + скруглением;
  • +
  • Анимация-пресет — кнопка пульсирует сама по себе + (свойство «Анимация: pulse»);
  • +
  • Твин — плавное изменение: кнопка плавно крутится + с 0° до 360° (game.gui.tween);
  • +
  • Связь кнопок и счётчика через сообщения + (game.broadcast и game.onMessage).
  • +
+ +

Шаг 1. Счётчик и кнопки

+ + Поставь маленький пол и точку спавна (мир мы почти не видим). + + + На вкладке Интерфейс добавь надпись-счётчик слева + сверху — это монеты. + + + Добавь кнопку. В инспекторе задай ей градиент фона, + обводку текста и скругление углов. Размер задаётся + в процентах: ширина 18 — это 18% экрана. + + + Поля кнопок задаются в процентах от экрана, а не в + пикселях. Так интерфейс одинаково выглядит на любом мониторе. + Если поставить ширину 220 — кнопка растянется на весь экран! + + +

Шаг 2. Живые анимации

+ + Выдели кнопку и в инспекторе выбери свойство + «Анимация: pulse» — в игре она начнёт пульсировать сама. + + + Там же есть hover (что делать при наведении мышкой, + например увеличиться) и active (при нажатии — сжаться). + + +

Шаг 3. Скрипт кнопки «X2»

+

+ Повесим на кнопку «X2 денег» скрипт: при клике она + эффектно крутится и на 5 секунд удваивает награду. +

+ + {`// === Скрипт кнопки 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)); +});`} +

+ game.gui.tween(объект, что-меняем, как-долго) — + это и есть плавная анимация. Без неё кнопка прыгнула бы резко, + а с твином крутится гладко, как в дорогих играх. +

+ +

Шаг 4. Главный скрипт-счётчик

+ + {`// === Витрина 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; });`} + +

Шаг 5. Проверка

+ Нажми Играть. + Жми кнопки — счётчик монет растёт. + Нажми «X2» — кнопка крутится, и 5 секунд монеты идут вдвое быстрее. + + сделай кнопку, которая при наведении мышкой меняет цвет + (свойство hover), а при клике плавно уезжает за край + экрана через твин по координате X. + + + ), + }, + + // ════════════════════════════════════════════════════ + // РАЗБОР ИГР · Тайна старого сундука (оригинал id=2037) + // ════════════════════════════════════════════════════ + 'guide-sunduk': { + body: ( + <> +

Что получится

+

+ Маленькое приключение. Лесная поляна с каменными руинами, + светящийся сундук в центре и страж рядом. Игра показывает + модальные сцены — это когда мир затемняется, всё + замирает, и на экране появляется что-то важное: диалог, + выбор приза или большая надпись. +

+ + +

Чему научишься

+
    +
  • Диалог по фразам с кнопкой ▶ + (game.modal.dialog);
  • +
  • Кат-сцена — затемнить мир, оставить «прожектор» + на объекте, подлететь камерой (game.modal.open);
  • +
  • Вопрос Да/Нет (game.modal.confirmation);
  • +
  • Лутбокс — выбор приза из карточек + (game.modal.lootbox);
  • +
  • как находить объект по имени и следить за + расстоянием до него.
  • +
+ +

Шаг 1. Поляна, сундук и страж

+ + Построй каменную площадку (greystone) с обломками стен и + колоннами вокруг — это руины. + + + В центр поставь сундук (можно собрать из примитива-куба) и + в инспекторе дай ему имя «chest». + + + Слева поставь стража (из примитивов: цилиндр-тело, сфера-голова, + конус-шлем) с именем «guard». + + +

Шаг 2. Диалог со стражем

+

+ В обычном Roblox такую сцену собирают из 5-6 кусков вручную. + У нас — одна команда. Главный скрипт следит за расстоянием + до стража и запускает диалог. +

+ + {`// === ТАЙНА СУНДУКА — главный скрипт === +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('Иди к сундуку!')); + } + } +});`} + + + Имя объекта (например «chest») ищи не сразу при старте, а + внутри game.onTick — сцена «появляется» не + мгновенно, и в первую секунду объект ещё не найден. + + +

Шаг 3. Кат-сцена сундука

+

+ Подошёл к сундуку — затемняем мир, но сундук оставляем ярким + (вокруг него прожектор), камера сама подлетает. +

+ {`// (продолжение 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' }, + ]}, + }); + } +}`} + +

Шаг 4. Выбор приза и победа

+

+ Готовые сцены, которые вызываются одной строкой: +

+
    +
  • game.modal.confirmation('Открыть?', 'текст', наДа, наНет) — вопрос Да/Нет;
  • +
  • game.modal.lootbox([призы], выбор) — карточки призов;
  • +
  • game.modal.bossIntro(имя, hp, [враги]) — заставка перед боссом.
  • +
+ {`// Лутбокс — четыре приза, игрок выбирает один +game.modal.lootbox([ + { name: 'Меч зари', icon: '⚔', color: '#c0392b' }, + { name: 'Щит луны', icon: '🛡', color: '#2c5fb3' }, + { name: 'Кошель злата', icon: '💰', color: '#b8860b' }, + { name: 'Перо феникса', icon: '🔥', color: '#8e44ad' }, +], (item) => { + game.log('Игрок выбрал: ' + item.name); +});`} + +

Шаг 5. Проверка

+ Нажми Играть и подойди к стражу — пойдёт диалог. + Иди к сундуку — мир затемнится, камера подлетит к нему. + Ответь «Да», выбери приз — увидишь финальную надпись. + + Кнопка диалога на последней фразе сама превращается из ▶ + в галочку ✓ — это значит «закрыть диалог». + + + добавь второго стража с другим квестом и сделай так, чтобы + сундук открывался только после разговора с обоими. + + + ), + }, + + // ════════════════════════════════════════════════════ + // РАЗБОР ИГР · Парк животных (оригинал id=2046) + // ════════════════════════════════════════════════════ + 'guide-zoo': { + body: ( + <> +

Что получится

+

+ Самая весёлая игра! Ты начинаешь её в виде... пончика. + По парку стоят таблички: нажми на кнопку таблички — и твой + герой превращается в бургер, болид, пришельца, рыбу + или человечка. А по клавише B + открывается магазин скинов. +

+ + +

Чему научишься

+
    +
  • Кастомный скин — герой может быть любой 3D-моделью, + не только человечком (game.player.setSkin);
  • +
  • Смена скина в игре — без перезапуска, прямо на ходу;
  • +
  • Магазин скинов — встроенный, открывается клавишей + B;
  • +
  • снова 3D-таблички — те же, что в игре «Двор», но + теперь их кнопка меняет скин.
  • +
+ +

Шаг 1. Парк и стартовый скин

+ + Построй полянку с деревьями и поставь точку спавна. + + + Нажми кнопку «Скины» в шапке редактора. Выбери + стартовый скин (например пончик), включи галочку + «Магазин скинов» и задай стартовые рублики (например 200). + + + Поставь несколько 3D-табличек (как в игре «Двор») — по одной + на каждый скин. Запомни их номера (primitive:10 и т.д.). + + +

Шаг 2. Скрипт превращений

+

+ Главный скрипт подписывается на клик по каждой табличке и + меняет скин одной командой. +

+ + {`// === ПАРК ЖИВОТНЫХ — главный скрипт === +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); +});`} +

+ game.player.setSkin('burger') — одна строчка + меняет всё тело героя на новую модель. Имена скинов + (burger, car-race, alien…) видны в окне «Скины». +

+ +

Шаг 3. Магазин скинов

+

+ Магазин уже встроен — мы включили его галочкой в окне «Скины». + В игре он открывается клавишей B. + Командами скинами тоже можно управлять: +

+ {`game.player.unlockSkin('alien'); // открыть скин игроку +game.player.openSkinShop(); // открыть магазин из кода +game.player.setSkinCoins(500); // задать баланс рубликов`} + + +

Шаг 4. Проверка

+ Нажми Играть — ты появишься пончиком. + Походи — пончик смешно покачивается на ходу. + Нажми на кнопку таблички — превратишься в другого героя. + Нажми B — откроется магазин скинов. + + Скины бывают двух видов: человечки (умеют махать, + прыгать, танцевать) и модели (пончик, машинка — они + просто покачиваются). Свою модель .glb тоже можно + загрузить в окне «Скины». + + + сделай скин платным: дай игроку 100 рубликов, поставь скину + цену 50 в магазине и проверь — хватит ли денег его купить. + + + ), + }, + }; /** Есть ли готовый текст урока для игры с таким id. */ diff --git a/src/editor/GameHud.jsx b/src/editor/GameHud.jsx index 140f6a0..cbefb2c 100644 --- a/src/editor/GameHud.jsx +++ b/src/editor/GameHud.jsx @@ -21,11 +21,16 @@ import Icon from './Icon'; */ function _optsEqual(a, b) { + // Расширенный compare — учитываем все поля стилизации. if (a === b) return true; if (!a || !b) return false; - return a.x === b.x && a.y === b.y && a.color === b.color && a.size === b.size; + const keys = ['x','y','color','size','textSize','bold','bg','border', + 'borderRadius','padding','w','h','textAlign','anchor']; + for (const k of keys) { + if (a[k] !== b[k]) return false; + } + return true; } - const DEFAULT_LABEL_STYLE = { fontSize: 18, fontWeight: 700, @@ -137,32 +142,59 @@ function GameHud({ visible, hudRef }) { {otherIds.map((id, i) => { const lbl = labels[id]; const o = lbl.opts || {}; - const hasPos = typeof o.x === 'number' || typeof o.y === 'number'; + // Поддерживаем как старый формат opts (x/y в %, color, size), + // так и расширенный (bg, border, borderRadius, padding, + // w/h/textSize/bold/textAlign, x/y в пикселях или с '%'). + const hasPercentXY = (typeof o.x === 'number' && o.x <= 100 && typeof o.y === 'number' && o.y <= 100) + && (o.bg === undefined && o.w === undefined && o.h === undefined); + const usePixelPos = (typeof o.x === 'number' && !hasPercentXY) + || typeof o.x === 'string'; const style = { - ...DEFAULT_LABEL_STYLE, - fontSize: o.size || DEFAULT_LABEL_STYLE.fontSize, + fontFamily: '"Roboto Condensed", system-ui, sans-serif', + fontWeight: o.bold ? 800 : DEFAULT_LABEL_STYLE.fontWeight, + fontSize: o.textSize || o.size || DEFAULT_LABEL_STYLE.fontSize, color: o.color || DEFAULT_LABEL_STYLE.color, - background: 'rgba(15,12,8,0.55)', - padding: '4px 10px', - borderRadius: 5, - // длинные подписи переносятся и остаются по центру, - // не вылезая за края экрана - textAlign: 'center', + background: o.bg || 'rgba(15,12,8,0.55)', + padding: o.padding != null ? o.padding : '4px 10px', + borderRadius: o.borderRadius != null ? o.borderRadius : 5, + border: o.border || undefined, + textAlign: o.textAlign || 'center', maxWidth: '70vw', - whiteSpace: 'normal', + whiteSpace: 'pre-line', wordBreak: 'break-word', + width: o.w != null ? o.w : undefined, + height: o.h != null ? o.h : undefined, + display: 'flex', + alignItems: 'center', + justifyContent: o.textAlign === 'left' ? 'flex-start' : 'center', + boxSizing: 'border-box', }; - if (hasPos) { + if (hasPercentXY) { return (
{lbl.text}
); } + 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 ( +
{lbl.text}
+ ); + } // Без позиции — стек в левом верхнем углу return (
); })()} - {isText && (el.text != null) && ( -
- {el.text} -
- )} + {isText && (el.text != null) && (() => { + // Задача 03: text-stroke (обводка текста). Через -webkit-text-stroke + // (хорошая поддержка, чётко на крупном шрифте) + paint-order + // (stroke под fill чтобы текст не «сжимался»). + const ts = el.textStroke; + const strokeStyle = (ts && ts.color && Number.isFinite(ts.width)) + ? { + WebkitTextStroke: `${ts.width}px ${ts.color}`, + paintOrder: 'stroke fill', + } + : null; + return ( +
+ {el.text} +
+ ); + })()} + {/* Задача 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 ( +
{text}
+ ); + })()} {/* TextBox — настоящий в Play (принимает ввод), в редакторе — статичный вид с placeholder. */} @@ -663,14 +732,42 @@ function elementToStyle(el) { case 'center': default: left = `${el.x ?? 50}%`; top = `${el.y ?? 50}%`; break; } + // Задача 03: rotation + scale через transform. Добавляются ПОСЛЕ translate. + // hoverScale/activeScale хранятся в el._dynScale (выставляется hover-handler'ом + // в GuiElement через mutate-ref). При штатном рендере читаем el.scaleX/scaleY. + const sx = (typeof el._dynScaleX === 'number' ? el._dynScaleX : 1) + * (typeof el.scaleX === 'number' ? el.scaleX : 1); + const sy = (typeof el._dynScaleY === 'number' ? el._dynScaleY : 1) + * (typeof el.scaleY === 'number' ? el.scaleY : 1); + const rot = (typeof el._dynRotation === 'number' ? el._dynRotation : 0) + + (typeof el.rotation === 'number' ? el.rotation : 0); + const brightness = (typeof el._dynBrightness === 'number' ? el._dynBrightness : 1); transform = `translate(${tx}%, ${ty}%)`; + if (sx !== 1 || sy !== 1) transform += ` scale(${sx}, ${sy})`; + if (rot) transform += ` rotate(${rot}deg)`; let bg = el.bgColor || '#1f1810'; const opacity = el.bgOpacity == null ? 1 : Math.max(0, Math.min(1, el.bgOpacity)); if (bg === 'transparent' || opacity === 0) bg = 'transparent'; else bg = hexToRgba(bg, opacity); + // Задача 03: bgGradient — { stops: ['#a','#b'] | [{c,p}], angle: 0..360 }. + // Если задан — перебиваем background. + if (el.bgGradient && Array.isArray(el.bgGradient.stops) && el.bgGradient.stops.length >= 2) { + const angle = Number.isFinite(el.bgGradient.angle) ? el.bgGradient.angle : 90; + const parts = el.bgGradient.stops.map((s, i, arr) => { + if (typeof s === 'string') { + const p = (i / (arr.length - 1)) * 100; + return `${s} ${p.toFixed(1)}%`; + } + const c = s.c || '#000'; + const p = typeof s.p === 'number' ? s.p * 100 : (i / (arr.length - 1)) * 100; + return `${c} ${p.toFixed(1)}%`; + }); + bg = `linear-gradient(${angle}deg, ${parts.join(', ')})`; + } return { position: 'absolute', left, top, transform, + transformOrigin: 'center center', width: w, height: h, background: bg, border: el.borderWidth > 0 @@ -678,14 +775,11 @@ function elementToStyle(el) { : 'none', borderRadius: (el.borderRadius || 0) + 'px', boxSizing: 'border-box', - // Тень: явный флаг shadow → мягкая drop-shadow; у кнопок — - // лёгкая тень по умолчанию (как было). shadow=true усиливает. boxShadow: el.shadow ? '0 6px 16px rgba(0,0,0,0.45)' : (el.type === 'button' ? '0 2px 6px rgba(0,0,0,0.3)' : 'none'), - // Frame обрезает детей по своей границе (как ScreenGui в Roblox). - // Для не-frame оставляем visible чтобы текст не клипался. overflow: el.type === 'frame' ? 'hidden' : 'visible', + filter: brightness !== 1 ? `brightness(${brightness})` : undefined, }; } diff --git a/src/editor/InspectorPanel.jsx b/src/editor/InspectorPanel.jsx index 594b9e3..28423b7 100644 --- a/src/editor/InspectorPanel.jsx +++ b/src/editor/InspectorPanel.jsx @@ -294,6 +294,7 @@ const InspectorPanel = ({ onSetAnchored, onSetMass, onSetModelProps, onSetBlockProps, onSetLightingProps, onSetSoundProps, onSetPlayerProps, onSetFloorProps, onSetGuiProps, onDeleteGui, onAddWeaponToInventory, + onEditBillboard, guiElements = [], // Этап 3.6 — библиотека пользовательских картинок. assetList = [], @@ -1111,6 +1112,201 @@ const InspectorPanel = ({
+ {/* === Задача 03: Градиент фона === */} +
+
Градиент фона
+ + {d.bgGradient && ( +
+ {(d.bgGradient.stops || []).map((s, i) => { + const c = typeof s === 'string' ? s : (s.c || '#000'); + return ( +
+ #{i + 1} + { + const stops = [...(d.bgGradient.stops || [])]; + stops[i] = e.target.value; + setProp({ bgGradient: { ...d.bgGradient, stops } }); + }} /> + {(d.bgGradient.stops || []).length > 2 && ( + + )} +
+ ); + })} + + +
+ )} +
+ + {/* === Задача 03: Обводка текста, поворот, scale === */} + {(isText) && ( +
+
Обводка текста
+ + {d.textStroke && ( +
+ setProp({ textStroke: { ...d.textStroke, color: e.target.value } })} /> + Толщ. + setProp({ textStroke: { ...d.textStroke, width: parseInt(e.target.value, 10) || 1 } })} + className={cl.numInput} style={{ width: 50 }} /> +
+ )} +
+ )} + +
+
Поворот / масштаб
+ + +
+ + {/* === Задача 03: Бейдж в углу === */} +
+
Бейдж
+ + {d.badge && ( +
+ + + +
+ )} +
+ + {/* === Задача 03: Hover/Active (только для button) === */} + {t === 'button' && ( +
+
Реакция на мышь
+ + {d.hover && ( +
+ + +
+ )} + + {d.active && ( + + )} +
+ )} + + {/* === Задача 03: Анимация-пресет === */} +
+
Анимация (в Play)
+ +
+
) : paletteTab === 'gui' ? ( { - // Клик по карточке — добавить в центр экрана. - const id = sceneRef.current?.createGuiElement?.(type, {}); - if (id) { - sceneRef.current?.selection?.selectGui?.(id); - setActiveTool('select'); + onPlaceCenter={(typeOrTemplate) => { + const { type, opts } = _expandGuiTemplate(typeOrTemplate); + // Задача 04: батч-шаблон (модальное окно) — создаём несколько элементов. + if (type === '_batch' && Array.isArray(opts?.elements)) { + let lastId = null; + for (const el of opts.elements) { + const elType = el.type || el.kind || 'frame'; + const elOpts = { ...el }; + delete elOpts.type; delete elOpts.kind; + const id = sceneRef.current?.createGuiElement?.(elType, elOpts); + if (id) lastId = id; + } + if (lastId) { + sceneRef.current?.selection?.selectGui?.(lastId); + setActiveTool('select'); + } + } else { + const id = sceneRef.current?.createGuiElement?.(type, opts); + if (id) { + sceneRef.current?.selection?.selectGui?.(id); + setActiveTool('select'); + } } markDirty(); }} @@ -2353,20 +2513,39 @@ const KubikonEditor = () => { } }} onDrop={(e) => { - const type = e.dataTransfer.getData('application/x-kubikon-gui'); - if (!type) return; + const rawType = e.dataTransfer.getData('application/x-kubikon-gui'); + if (!rawType) return; e.preventDefault(); - // Позиция отпускания → проценты от viewport (центр элемента). const rect = viewportRef.current?.getBoundingClientRect(); if (!rect || rect.width === 0) return; const px = ((e.clientX - rect.left) / rect.width) * 100; const py = ((e.clientY - rect.top) / rect.height) * 100; const x = Math.max(0, Math.min(100, Math.round(px))); const y = Math.max(0, Math.min(100, Math.round(py))); - const id = sceneRef.current?.createGuiElement?.(type, { x, y, anchor: 'center' }); - if (id) { - sceneRef.current?.selection?.selectGui?.(id); - setActiveTool('select'); + // Задача 03: раскрытие шаблона если type начинается с 'template:'. + const { type, opts } = _expandGuiTemplate(rawType); + // Задача 04: батч-шаблон — несколько элементов. + if (type === '_batch' && Array.isArray(opts?.elements)) { + let lastId = null; + for (const el of opts.elements) { + const elType = el.type || el.kind || 'frame'; + const elOpts = { ...el }; + delete elOpts.type; delete elOpts.kind; + const id = sceneRef.current?.createGuiElement?.(elType, elOpts); + if (id) lastId = id; + } + if (lastId) { + sceneRef.current?.selection?.selectGui?.(lastId); + setActiveTool('select'); + } + } else { + const id = sceneRef.current?.createGuiElement?.(type, { + ...opts, x, y, anchor: 'center', + }); + if (id) { + sceneRef.current?.selection?.selectGui?.(id); + setActiveTool('select'); + } } markDirty(); }} @@ -2568,8 +2747,15 @@ const KubikonEditor = () => { )} {/* Player HUD: HP + ammo (только в Play, и если скрипт не скрыл) */} + {/* Задача 04: модал-overlay (затемнение). Рендерится ПЕРЕД HUD/GUI + чтобы при target='scene' HUD оставался поверх (zIndex=25 у + ModalOverlay при scene, у GuiOverlay/HUD выше). При + target='screen' ModalOverlay сам прыгает на zIndex=50. */} + {isPlaying && } + {/* Задача 07: встроенный магазин скинов (открывается по B / API) */} + {isPlaying && } { /> {/* Hot-bar инвентаря (виден только в Play, и если скрипт не скрыл) */} sceneRef.current?.setActiveInventorySlot?.(i)} @@ -2953,11 +3139,12 @@ const KubikonEditor = () => { onSetPrimitiveProps={(patch) => sceneRef.current?.setSelectedPrimitivePropsTo(patch)} onEditBillboard={() => { - // Открываем модалку с данными выделенного billboard-примитива const s = sceneRef.current; - const sel = s?.selection?._selection; + const sel = s?.selection?.getSelection?.(); + console.log('[EditBillboard] click, sel=', sel); if (!sel || sel.type !== 'primitive') return; const data = s?.primitiveManager?.instances?.get(sel.id); + console.log('[EditBillboard] data=', data?.type, 'id=', data?.id); if (!data || data.type !== 'billboard') return; setBillboardEditorData({ id: data.id, @@ -3107,6 +3294,31 @@ const KubikonEditor = () => { onSave={handleSettingsSave} onCaptureScreenshot={captureSceneScreenshot} /> + {/* Задача 07: управление скинами проекта (стартовый, магазин, рублики, кастомные .glb) */} + 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); + }} + /> { + 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 ( +
+ ); +} + +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})`; +} diff --git a/src/editor/SkinManagerModal.jsx b/src/editor/SkinManagerModal.jsx new file mode 100644 index 0000000..4c2e4f2 --- /dev/null +++ b/src/editor/SkinManagerModal.jsx @@ -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 = (<>); + break; + case 'animal': + body = (<>); + break; + case 'food': + body = (<>); + break; + case 'vehicle': + body = (<>); + break; + case 'robot': + body = (<>); + break; + default: // custom — звезда + body = (); + } + return ({body}); +} + +// ---- Монета-рублик (дубль из SkinShopOverlay) ---- +function CoinIcon({ size = 16 }) { + return ( + + + + + ); +} + +// ---- Мелкие самописные иконки управления ---- +function XIcon({ size = 14 }) { + return ( + + + + ); +} +function CheckIcon({ size = 12 }) { + return ( + + + + ); +} +function PlusIcon({ size = 14 }) { + return ( + + + + ); +} + +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 ( +
{ if (e.target === e.currentTarget) onClose && onClose(); }} + > +
+ {/* ---------- Шапка ---------- */} +
+
+ Скины игрока +
+
+ +
+ + {/* ---------- Тело (скролл) ---------- */} +
+ + {/* Подсказка */} +
+ Кликни по карточке, чтобы выбрать стартовый скин. + Галочкой отметь скины, которые игрок носит бесплатно с самого начала. + Остальные он покупает в магазине за рублики. +
+ + {/* Табы категорий */} +
+ {cats.map(c => { + const active = c === cat; + const label = c === 'all' ? 'Все' : (CAT_THEME[c]?.label || c); + return ( + + ); + })} +
+ + {/* Сетка карточек */} +
+ {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 ( +
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'; }} + > + {/* Превью-плашка */} +
+ + {/* Бейдж «Старт» */} + {isDefault && ( +
Старт
+ )} + {/* Бейдж категории для не-человекоподобных */} + {!isHuman && !isDefault && ( +
{CAT_THEME[s.category]?.label || s.category}
+ )} +
+ + {/* Низ карточки */} +
+
{s.name || s.slug}
+ + {/* Цена (если > 0) */} + {price > 0 && ( +
+ {price} +
+ )} + + {/* Тогл «разблокирован по умолчанию» */} + +
+
+ ); + })} + {visible.length === 0 && ( +
+ В этой категории пока нет скинов +
+ )} +
+ + {/* ---------- Настройки магазина ---------- */} +
+
+ Магазин и экономика +
+ + {/* Чекбокс магазина */} + + + {/* Стартовые рублики */} +
+
+ Стартовые рублики игрока +
+
+ + { + 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', + }} + /> + от 0 до 100000 +
+
+
+ + {/* ---------- Свои скины (.glb) ---------- */} +
+
+ Свои скины +
+ + {/* Список уже добавленных */} + {customGlbs.length > 0 && ( +
+ {customGlbs.map(g => ( +
+
+
+
{g.name}
+
+ масштаб {g.scale}× · высота бёдер {g.hipHeight} +
+
+ +
+ ))} +
+ )} + + {/* Форма добавления */} +
+ + + + {/* Поля параметров появляются после выбора файла */} + {draftDataUrl && ( +
+ {/* Имя */} +
+
+ Имя скина +
+ 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', + }} + /> +
+ + {/* Масштаб */} +
+
+ + Масштаб модели + + {Number(draftScale).toFixed(2)}× +
+ setDraftScale(Number(e.target.value))} + style={{ width: '100%', accentColor: '#3b6cff', cursor: 'pointer' }} + /> +
+ + {/* Высота бёдер */} +
+
+ + Высота бёдер + + {Number(draftHip).toFixed(2)} +
+ setDraftHip(Number(e.target.value))} + style={{ width: '100%', accentColor: '#3b6cff', cursor: 'pointer' }} + /> +
+ Насколько приподнять модель над землёй, чтобы ноги не уходили в пол. +
+
+ + +
+ )} +
+
+ + {/* Ошибка */} + {error && ( +
{error}
+ )} +
+ + {/* ---------- Подвал ---------- */} +
+ + +
+
+
+ ); +} diff --git a/src/editor/SkinShopOverlay.jsx b/src/editor/SkinShopOverlay.jsx new file mode 100644 index 0000000..df18ea8 --- /dev/null +++ b/src/editor/SkinShopOverlay.jsx @@ -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 = (<>); + break; + case 'animal': // мордочка зверя с ушами + body = (<>); + break; + case 'food': // пончик + body = (<>); + break; + case 'vehicle': // машинка + body = (<>); + break; + case 'robot': // голова робота + body = (<>); + break; + default: // custom — звезда + body = (); + } + return ({body}); +} + +// Монета-рублик (для баланса/цены). +function CoinIcon({ size = 16 }) { + return ( + + + + + ); +} + +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 ( +
+
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', + }} + > + {/* Шапка */} +
+
+ Магазин скинов +
+
+ {/* Баланс */} +
+ {coins} +
+ {/* Закрыть */} + +
+ + {/* Табы категорий */} +
+ {cats.map(c => { + const active = c === cat; + const label = c === 'all' ? 'Все' : (CAT_THEME[c]?.label || c); + return ( + + ); + })} +
+ + {/* Сетка карточек */} +
+ {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 ( +
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'; }} + > + {/* Превью-плашка с иконкой категории */} +
+ +
+ {/* Бейдж активного/купленного */} + {isActive && ( +
Надет
+ )} + {!isActive && owned && ( +
Куплено
+ )} + {/* Низ карточки: имя + цена/статус */} +
+
{s.name || s.slug}
+
+ {isActive ? ( + Активен + ) : owned ? ( + Нажми, чтобы надеть + ) : price === 0 ? ( + Бесплатно + ) : ( + + {price} + + )} +
+
+
+ ); + })} + {skins.length === 0 && ( +
+ В этой категории пока нет скинов +
+ )} +
+ + {/* Подвал-подсказка */} +
+ Нажми B или Esc, чтобы закрыть +
+
+
+ ); +} + +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)', + }; +} diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index e6cd579..350b76f 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -58,6 +58,7 @@ import { BillboardUiManager } from './BillboardUiManager'; import { getPrimitiveType } from './PrimitiveTypes'; import { FolderManager } from './FolderManager'; import { GuiManager } from './GuiManager'; +import { ModalManager } from './ModalManager'; import { InventoryManager } from './InventoryManager'; import { WeaponSystem } from './WeaponSystem'; import { ZombieManager } from './ZombieManager'; @@ -1244,6 +1245,9 @@ export class BabylonScene { this.primitiveManager.billboardUiManager = this.billboardUiManager; this.folderManager = new FolderManager(this.blockManager, this.modelManager, this.primitiveManager); this.guiManager = new GuiManager(); + this.modalManager = new ModalManager(); + this.modalManager.attachScene(this); + this.modalManager.attachGui(this.guiManager); this.inventory = new InventoryManager(); this.physics = new PhysicsAABB(this.blockManager); this.physics.setPrimitiveManager(this.primitiveManager); @@ -1279,35 +1283,42 @@ export class BabylonScene { // в pointer-lock) → ищем под ним меш типа billboard → переводим точку // пересечения в UV → BillboardUiManager.pickButtonAt → fireClick. // Работает только когда _isPlaying=true (в редакторе клик гизмо или ничего). - this.scene.onPointerObservable.add((info) => { - if (info.type !== PointerEventTypes.POINTERDOWN) return; - if (info.event && info.event.button !== 0) return; // только ЛКМ + // Прямой capture-phase mousedown на canvas — раньше PlayerController. + // Babylon onPointerObservable не получает события в pointer-lock, + // поэтому ловим сами и стреляем лучом по табличкам в Play. + const canvasEl = this.canvas; + const onBillboardMouseDown = (e) => { if (!this._isPlaying) return; - // Для pointer-lock (FPS-камера) — стреляем из центра экрана. - // Иначе — используем pickInfo от Babylon (он уже от курсора). - let pi = info.pickInfo; + if (e.button !== 0) return; const inLock = (document.pointerLockElement != null); + let px, py; if (inLock) { - const cx = this.engine.getRenderWidth() / 2; - const cy = this.engine.getRenderHeight() / 2; - pi = this.scene.pick(cx, cy, (m) => { - return m.metadata?.isPrimitive - && this.primitiveManager.instances.get(m.metadata.primitiveId)?.type === 'billboard'; - }); + px = this.engine.getRenderWidth() / 2; + py = this.engine.getRenderHeight() / 2; + } else { + const rect = canvasEl.getBoundingClientRect(); + px = e.clientX - rect.left; + py = e.clientY - rect.top; } + const pi = this.scene.pick(px, py, (m) => { + return m.metadata?.isPrimitive + && this.primitiveManager.instances.get(m.metadata.primitiveId)?.type === 'billboard'; + }); if (!pi || !pi.hit || !pi.pickedMesh) return; const meta = pi.pickedMesh.metadata; - if (!meta || !meta.isPrimitive) return; const data = this.primitiveManager.instances.get(meta.primitiveId); if (!data || data.type !== 'billboard') return; - // UV точка пересечения с мешем (Babylon знает, если есть UV-координаты). const uv = pi.getTextureCoordinates ? pi.getTextureCoordinates() : null; if (!uv) return; const buttonId = this.billboardUiManager.pickButtonAt(data, uv.x, uv.y); if (buttonId) { this.billboardUiManager.fireClick(data, buttonId); + // Предотвращаем PlayerController-обработчик (pointer-lock и т.д.) + e.stopPropagation(); + e.preventDefault(); } - }); + }; + canvasEl.addEventListener('mousedown', onBillboardMouseDown, true /* capture */); // GizmoController — управляет 3 типами гизмо (move/rotate/scale). // UtilityLayer — отдельный слой, чтобы гизмо рисовалось поверх сцены. @@ -1481,6 +1492,10 @@ export class BabylonScene { } } } + // Задача 04: modalManager.tick — независимо от runtime'а + if (this._isPlaying && this.modalManager?.tick) { + try { this.modalManager.tick(dt); } catch (e) {} + } // Tick пользовательских скриптов: в Play-режиме или в solo-debug if (this.gameRuntime && (this._isPlaying || this.gameRuntime.isSolo?.())) { this.gameRuntime.tick(dt); @@ -5240,6 +5255,8 @@ export class BabylonScene { // По умолчанию стандартный HUD видим в Play. // Скрипт может скрыть через game.hud.setVisible(false). this._setStdHudVisible(true); + this._setHotbarVisible(true); + this._setHpVisible(true); // Включаем picking voxel-террейна — иначе камера _clampCameraToWorld // не «видит» воксели в Ray-каст и пролетает сквозь стены. @@ -5273,6 +5290,11 @@ export class BabylonScene { // Создаём PlayerController и стартуем this.player = new PlayerController(this.scene, this.canvas, this.physics, this); this.player.setModelType(this._playerModelType); + // Задача 04: модал нуждается в player и audio для блока ввода/freeze камеры/duck + try { + this.modalManager?.attachPlayer?.(this.player); + this.modalManager?.attachAudio?.(this.audioManager); + } catch (e) {} this.player._jumpPowerMul = this._jumpPowerMul ?? 1; // Применяем дефолтную камеру если задана в сцене if (this._defaultCameraMode && this._defaultCameraMode !== this.player._cameraMode) { @@ -5281,6 +5303,18 @@ export class BabylonScene { // На тач-устройствах отключаем pointer-lock и mouse-камеру if (this._touchMode) this.player.setTouchMode(true); this.player.setOnExitRequest(() => { + // Задача 07: магазин скинов открыт → Esc закрывает его (приоритет выше модала). + if (this._skinShop?.open) { + this._closeSkinShop(); + return; + } + // Задача 04: если открыт модал — первый Esc закрывает его, + // второй Esc уже выходит из Play. Так юзер не теряет состояние игры + // случайно при попытке скрыть модал. + if (this.modalManager?.isOpen?.()) { + this.modalManager.close(); + return; + } this.exitPlayMode(); if (this._onPlayChange) this._onPlayChange(false); }); @@ -5292,6 +5326,7 @@ export class BabylonScene { // Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену, // поэтому скрипты стартуем в следующем кадре. this.gameRuntime = new GameRuntime(this); + try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {} // Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime. // ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным // this.audioManager (AudioManager — ambient/music для всех проектов). @@ -5785,6 +5820,7 @@ export class BabylonScene { if (!sc) return false; if (!this.gameRuntime) { this.gameRuntime = new GameRuntime(this); + try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {} if (!this.gameAudioManager) { this.gameAudioManager = new GameAudioManager(); } @@ -5906,6 +5942,24 @@ export class BabylonScene { this._stdHudVisible = !!visible; try { this._onStdHudVisibilityChange?.(this._stdHudVisible); } catch (e) {} } + /** Задача 03: отдельный контроль хотбара (5 слотов инвентаря снизу). + * Дёргается из game.hud.setHotbarVisible(bool). */ + setOnHotbarVisibilityChange(cb) { + this._onHotbarVisibilityChange = cb; + } + _setHotbarVisible(visible) { + this._hotbarVisible = !!visible; + try { this._onHotbarVisibilityChange?.(this._hotbarVisible); } catch (e) {} + } + /** Задача 03: отдельный контроль HP-индикатора (полоска слева сверху). + * Дёргается из game.hud.setHpVisible(bool). */ + setOnHpVisibilityChange(cb) { + this._onHpVisibilityChange = cb; + } + _setHpVisible(visible) { + this._hpVisible = !!visible; + try { this._onHpVisibilityChange?.(this._hpVisible); } catch (e) {} + } /** Колбэк смены cursor-режима (ui/game) скриптом через game.input.setCursorMode. * Редактор подписан чтобы синхронизировать React-state uiCursorMode (для бейджа). */ @@ -6054,6 +6108,71 @@ export class BabylonScene { return this.guiManager ? this.guiManager.getAll() : []; } + // ===== Задача 07: встроенный магазин скинов (React-оверлей) ===== + // Состояние держим тут; React-компонент SkinShopOverlay поллит getSkinShopState(). + _ensureSkinShopState() { + if (!this._skinShop) { + this._skinShop = { + open: false, + rev: 0, // ревизия — React видит изменение + data: { all: [], unlocked: [], current: null, coins: 0, manifestFull: [] }, + buyResult: null, // последний результат покупки {slug, ok, reason} + }; + } + return this._skinShop; + } + /** Снимок состояния магазина для React (поллинг через rAF). */ + getSkinShopState() { return this._skinShop || null; } + /** Открыть/закрыть магазин (из скрипта или клавиши B). */ + _openSkinShop() { + const s = this._ensureSkinShopState(); + // Отключён в проекте? (скрипт всё равно может открыть через API — + // shopVisible:false запрещает только клавишу B, см. toggleSkinShop). + s.open = true; s.rev++; + } + _closeSkinShop() { + const s = this._ensureSkinShopState(); + s.open = false; s.rev++; + } + toggleSkinShop() { + const s = this._ensureSkinShopState(); + if (s.open) { this._closeSkinShop(); return; } + // Клавиша B открывает магазин только если он включён в проекте. + if (this._skinsConfig && this._skinsConfig.shopVisible === false) return; + this._openSkinShop(); + } + /** Данные скинов от GameRuntime (манифест + unlocked + coins). */ + _setSkinShopData(data) { + const s = this._ensureSkinShopState(); + s.data = { ...s.data, ...data }; + s.rev++; + } + _onSkinBuyResult(res) { + const s = this._ensureSkinShopState(); + s.buyResult = { ...res, t: (this.scene?.getEngine?.()?.getFps?.() || 0) }; + s.rev++; + } + /** Намерение купить/надеть скин — шлём в runtime (он списывает рублики). */ + requestBuySkin(slug, price) { + const rt = this.gameRuntime; + if (!rt) return; + try { rt._handleCommand?.(null, 'player.buySkin', { slug, price: price || 0 }); } catch (e) {} + } + /** Данные из загруженных проектом кастомных .glb-скинов (data-URL по slug). */ + getAssetDataUrl(slug) { + try { + // Кастомные скины хранят dataUrl прямо в _skinsConfig.customGlbs. + const list = this._skinsConfig?.customGlbs || []; + const rec = list.find(g => g && g.slug === slug); + if (rec && rec.dataUrl) return rec.dataUrl; + } catch (e) {} + return null; + } + _onPlayerSkinChanged(slug) { + const s = this._ensureSkinShopState(); + if (s.data) { s.data.current = slug; s.rev++; } + } + // ===== Библиотека пользовательских картинок (этап 3.6) ===== /** Список картинок проекта [{id, name, dataUrl}]. */ @@ -6724,6 +6843,13 @@ export class BabylonScene { inventory: this.inventory ? this.inventory.serialize() : null, spawnPoint: { ...this._spawnPoint }, playerModelType: this._playerModelType, + skins: this._skinsConfig ? { + default: this._skinsConfig.default || null, + unlocked: this._skinsConfig.unlocked || [], + shopVisible: this._skinsConfig.shopVisible !== false, + coins: this._skinsConfig.coins || 0, + customGlbs: this._skinsConfig.customGlbs || [], + } : undefined, worldSize: this._worldHalf * 2, floorEnabled: this._floorEnabled !== false, jumpPowerMul: this._jumpPowerMul ?? 1, @@ -7161,6 +7287,24 @@ export class BabylonScene { this._playerModelType = pmt; } } + // Задача 07: конфиг скинов проекта { default, unlocked, shopVisible, coins, customGlbs }. + if (state.scene.skins && typeof state.scene.skins === 'object') { + this._skinsConfig = { + default: state.scene.skins.default || null, + unlocked: Array.isArray(state.scene.skins.unlocked) ? state.scene.skins.unlocked.slice() : [], + shopVisible: state.scene.skins.shopVisible !== false, + coins: Number.isFinite(state.scene.skins.coins) ? state.scene.skins.coins : 0, + customGlbs: Array.isArray(state.scene.skins.customGlbs) ? state.scene.skins.customGlbs.slice() : [], + }; + // Стартовый скин из skins.default имеет приоритет над playerModelType. + if (this._skinsConfig.default) { + const d = this._skinsConfig.default; + this._playerModelType = d.startsWith('character-') || d.startsWith('skin_') || d.startsWith('customskin:') + ? d : ('skin_' + d); + } + } else { + this._skinsConfig = null; + } // Пользовательские скрипты if (Array.isArray(state.scene.scripts)) { this._scripts = state.scene.scripts @@ -7197,6 +7341,8 @@ export class BabylonScene { exitPlayMode() { if (!this._isPlaying) return; this._isPlaying = false; + // Задача 04: закрываем любой активный модал чтобы не «висел» при стопе + try { this.modalManager?._instantClose?.(); } catch (e) {} // Сбрасываем таймер прохождения this._timerRunning = false; this._timerStartedAt = null; diff --git a/src/editor/engine/BillboardUiManager.js b/src/editor/engine/BillboardUiManager.js index 5c85917..0eb5e17 100644 --- a/src/editor/engine/BillboardUiManager.js +++ b/src/editor/engine/BillboardUiManager.js @@ -114,21 +114,15 @@ export class BillboardUiManager { mesh.metadata._billboardMaterial = mat; } - // Ориентация на камеру. Babylon-quirk: BILLBOARDMODE_ALL игнорирует - // mesh.scaling.x=-1 и mesh.rotation.y=π — невозможно отзеркалить - // плоскость. Делаем ручной поворот в onBeforeRenderObservable: - // нацеливаем mesh на камеру + ставим rotation.y += π, тогда мы - // видим back-side нормально (т.к. фактически она стала front). + // Ориентация. Babylon-quirk: CreatePlane FRONT в -Z (left-handed), + // юзер ставит билборд так чтобы лицо смотрело в +Z мира → +π. mesh.billboardMode = Mesh.BILLBOARDMODE_NONE; - // Снимаем старую подписку (на случай пере-applyToMesh) if (mesh.metadata._billboardLookObs) { this.scene.onBeforeRenderObservable.remove(mesh.metadata._billboardLookObs); mesh.metadata._billboardLookObs = null; } if (face === 'camera') { - // Ручной look-at вместо BILLBOARDMODE. - // CreatePlane FRONT в -Z (Babylon left-handed), поэтому +π — - // чтобы FRONT смотрел на камеру. + // Ручной look-at — каждый кадр поворачиваем front к камере. const obs = this.scene.onBeforeRenderObservable.add(() => { if (mesh.isDisposed()) return; const cam = this.scene.activeCamera; @@ -138,9 +132,47 @@ export class BillboardUiManager { mesh.rotation.y = Math.atan2(dx, dz) + Math.PI; }); mesh.metadata._billboardLookObs = obs; + } else { + // Фиксированная ориентация: front в +Z + пользовательский rotationY. + const userY = Number.isFinite(billboardOpts.rotationY) ? billboardOpts.rotationY : 0; + mesh.rotation.y = Math.PI + userY; + // Двусторонняя табличка: рамка стоит, но при взгляде сзади + // флипаем UV таблички чтобы текст не был зеркальным. + const mat = mesh.material; + if (mat) { + // Включаем рендер обеих сторон (back-face визуализируется). + mat.backFaceCulling = false; + } + const obs = this.scene.onBeforeRenderObservable.add(() => { + if (mesh.isDisposed()) return; + const cam = this.scene.activeCamera; + if (!cam) return; + // Локальная нормаль FRONT plane = +Z. Поворот mesh.rotation.y + // переводит её в world: normalWorld = (sin(ry), 0, cos(ry)). + const ry = mesh.rotation.y; + const nWx = Math.sin(ry); + const nWz = Math.cos(ry); + // Вектор от mesh к камере + const vx = cam.position.x - mesh.position.x; + const vz = cam.position.z - mesh.position.z; + // Скалярное произведение: >0 — камера смотрит на FRONT, + // <0 — на BACK (зеркальная UV). Для BACK инвертируем uScale. + const dot = nWx * vx + nWz * vz; + const dyn = mesh.metadata?._billboardTexture; + if (dyn) { + // dot > 0 — камера со стороны FRONT-нормали → flip + // dot < 0 — камера сзади → нормально + if (dot > 0) { + if (dyn.uScale !== -1) { dyn.uScale = -1; dyn.uOffset = 1; } + } else { + if (dyn.uScale !== 1) { dyn.uScale = 1; dyn.uOffset = 0; } + } + } + }); + mesh.metadata._billboardLookObs = obs; } mesh.scaling.x = Math.abs(mesh.scaling.x || 1); - mesh.metadata._billboardMirrorX = false; // canvas-mirror не нужен + mesh.metadata._billboardMirrorX = false; // Сохраняем state в data для сериализации и для hit-теста кликов. data.billboard = { @@ -157,11 +189,43 @@ export class BillboardUiManager { /** * Обновить контент билборда (без пересоздания текстуры). - * patch — частичные изменения к content (например {sub: '2 > 3', price: '$20,000'}). + * Две формы: + * 1) update(data, { sub: '2 > 3', price: '$20,000' }) — patch content + * 2) update(data, 'buy', { text: '$15,000' }) — patch конкретного элемента + * по id (для elements-режима ИЛИ для known-id пресета: 'buy', 'title', + * 'sub', 'price', 'icon', 'gradient' маппятся на поля content). */ - update(data, patch) { + update(data, elementIdOrPatch, patchMaybe) { if (!data.billboard) return; - data.billboard.content = { ...data.billboard.content, ...patch }; + // Форма 2: 3 аргумента (data, elementId, patch) + if (typeof elementIdOrPatch === 'string' && typeof patchMaybe === 'object' && patchMaybe !== null) { + const elId = elementIdOrPatch; + const patch = patchMaybe; + // Кастомные elements: ищем элемент по id и обновляем его поля. + if (Array.isArray(data.billboard.elements)) { + data.billboard.elements = data.billboard.elements.map(el => + el && el.id === elId ? { ...el, ...patch } : el); + } else { + // Пресет: мапим известные elementId → ключ content. + // 'buy' → content.price; 'title'/'sub'/'icon'/'gradient' → одноимённый ключ. + const c = { ...(data.billboard.content || {}) }; + if (elId === 'buy' && 'text' in patch) { + c.price = patch.text; + } else if (elId in c) { + // Если patch имеет text — кладём в content[elId], иначе мерджим поля. + if ('text' in patch) c[elId] = patch.text; + else Object.assign(c, patch); + } else { + Object.assign(c, patch); + } + data.billboard.content = c; + } + } else if (typeof elementIdOrPatch === 'object' && elementIdOrPatch !== null) { + // Форма 1: 2 аргумента (data, patchContent) + data.billboard.content = { ...data.billboard.content, ...elementIdOrPatch }; + } else { + return; + } const dyn = data.mesh?.metadata?._billboardTexture; if (dyn) { this._render(dyn, data.billboard.template, data.billboard.content, data.billboard.elements); @@ -185,16 +249,28 @@ export class BillboardUiManager { */ pickButtonAt(data, uvX, uvY) { if (!data.billboard) return null; - // Текстура рисуется напрямую — UV из raycast соответствует canvas-пикселю. - const px = uvX * TEXTURE_W; - const py = (1 - uvY) * TEXTURE_H; // UV.y перевёрнута относительно canvas + // Если текстура в данный момент отзеркалена (face=fixed, смотрим + // на back-side), uScale=-1 — инвертим UV.x чтобы попасть в правильный + // canvas-пиксель. + const dyn = data.mesh?.metadata?._billboardTexture; + const flipped = dyn && dyn.uScale === -1; + const uX = flipped ? (1 - uvX) : uvX; + const px = uX * TEXTURE_W; + const py = (1 - uvY) * TEXTURE_H; // Кастомные elements имеют приоритет (если заданы) if (data.billboard.elements) { return this._hitTestElements(data.billboard.elements, px, py); } const tmpl = data.billboard.template; if (tmpl === 'shop-item' || tmpl === 'shop-purchase') { - const b = SHOP_ITEM_BUTTON; + // Кнопка адаптивной ширины — пересчитываем её rect по тексту + // именно ЭТОЙ таблички (тем же _computeBuyRect, что и при рисовании). + const label = (data.billboard.content && data.billboard.content.price) || '$0'; + let b = SHOP_ITEM_BUTTON; + try { + const measCtx = (dyn && dyn.getContext && dyn.getContext()) || null; + if (measCtx) b = this._computeBuyRect(measCtx, label, SHOP_ITEM_BUTTON); + } catch (e) { /* fallback на базовый rect */ } if (px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h) { return 'buy'; } @@ -216,13 +292,24 @@ export class BillboardUiManager { _flashButton(data, buttonId) { if (!data.billboard) return; - // Перерисовываем с pressed=true, через 100мс — обратно. const dyn = data.mesh?.metadata?._billboardTexture; if (!dyn) return; + // Перерисовываем pressed=true. ВАЖНО: используем СВЕЖИЙ content в callback'е + // (на момент 120мс content уже может быть обновлён через update — берём + // актуальный, иначе откатим к старому). + // Также гарантируем 1 flash на табличку — если предыдущий ещё крутится, + // отменяем его таймер. + if (data._flashTimer) { + clearTimeout(data._flashTimer); + data._flashTimer = null; + } this._render(dyn, data.billboard.template, data.billboard.content, data.billboard.elements, /* pressed */ buttonId); - setTimeout(() => { - if (data.mesh?.metadata?._billboardTexture === dyn) { + data._flashTimer = setTimeout(() => { + data._flashTimer = null; + // Берём АКТУАЛЬНЫЕ data.billboard.content/elements — могли обновиться + // через game.billboard.update() ВО ВРЕМЯ flash'а. + if (data.mesh?.metadata?._billboardTexture === dyn && data.billboard) { this._render(dyn, data.billboard.template, data.billboard.content, data.billboard.elements, null); } @@ -344,21 +431,52 @@ export class BillboardUiManager { ctx.fillText(content.sub, 200, 105); } - // Кнопка цены — жёлтый прямоугольник внизу справа - const b = SHOP_ITEM_BUTTON; + // Кнопка цены — жёлтый прямоугольник внизу справа. + // Ширина адаптивная: длинный текст («ПРИМЕРИТЬ»/«НАДЕТО») расширяет + // кнопку ВЛЕВО (правый край прижат к краю таблички), шрифт ужимается + // если даже расширенная кнопка узка. Фактический rect → _buyRect для hit-теста. const pressed = pressedButtonId === 'buy'; - this._roundedGradientRect(ctx, b.x, b.y, b.w, b.h, { + const label = content.price || '$0'; + const rect = this._computeBuyRect(ctx, label, SHOP_ITEM_BUTTON); + this._roundedGradientRect(ctx, rect.x, rect.y, rect.w, rect.h, { gradient: pressed ? ['#d97706', '#92400e'] : ['#fbbf24', '#f59e0b'], radius: 16, stroke: { color: '#000', width: 3 }, }); - ctx.font = 'bold 36px Arial, sans-serif'; + ctx.font = `bold ${rect.fontSize}px Arial, sans-serif`; ctx.fillStyle = pressed ? '#fef3c7' : '#1c1917'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText(content.price || '$0', b.x + b.w / 2, b.y + b.h / 2); + ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2); + } + + /** + * Подобрать прямоугольник кнопки «buy» под текст: правый край прижат к + * правому краю таблички (как в базовом SHOP_ITEM_BUTTON), ширина растёт + * влево под длину текста, шрифт ужимается если упёрлись в макс-ширину. + * Возвращает { x, y, w, h, fontSize }. + */ + _computeBuyRect(ctx, label, base) { + const PAD = 36; // отступы текста по бокам + const MAX_W = 300; // макс ширина кнопки (не залезать на title) + const rightEdge = base.x + base.w; // правый край держим на месте + let fontSize = 36; + ctx.font = `bold ${fontSize}px Arial, sans-serif`; + let textW = ctx.measureText(label).width; + let w = Math.max(base.w, textW + PAD * 2); + if (w > MAX_W) { + // Ужимаем шрифт чтобы текст влез в MAX_W. + w = MAX_W; + const inner = MAX_W - PAD * 2; + while (fontSize > 20 && textW > inner) { + fontSize -= 2; + ctx.font = `bold ${fontSize}px Arial, sans-serif`; + textW = ctx.measureText(label).width; + } + } + return { x: rightEdge - w, y: base.y, w, h: base.h, fontSize }; } /** Рендер пресета shop-purchase: похож на shop-item, но иконка крупнее и центрирована. */ @@ -388,21 +506,22 @@ export class BillboardUiManager { ctx.fillText(content.sub, 200, 100); } - // Кнопка-цена - const b = SHOP_ITEM_BUTTON; + // Кнопка-цена (адаптивная ширина под длинный текст, см. _computeBuyRect). const pressed = pressedButtonId === 'buy'; - this._roundedGradientRect(ctx, b.x, b.y, b.w, b.h, { + const label = content.price || '0 R'; + const rect = this._computeBuyRect(ctx, label, SHOP_ITEM_BUTTON); + this._roundedGradientRect(ctx, rect.x, rect.y, rect.w, rect.h, { gradient: pressed ? ['#9333ea', '#6b21a8'] : ['#a855f7', '#7c3aed'], radius: 16, stroke: { color: '#000', width: 3 }, }); - ctx.font = 'bold 34px Arial, sans-serif'; + ctx.font = `bold ${rect.fontSize}px Arial, sans-serif`; ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText(content.price || '0 R', b.x + b.w / 2, b.y + b.h / 2); + ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2); } /** Рендер пресета banner: одна крупная фраза по центру. */ @@ -441,11 +560,32 @@ export class BillboardUiManager { stroke: { color: '#fff', width: 4 }, }); - ctx.font = 'bold 64px Arial, sans-serif'; + // Заголовок крупно сверху + ctx.font = 'bold 44px Arial, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillStyle = '#fff'; - ctx.fillText(this._truncate(content.title || '', 14), TEXTURE_W / 2, TEXTURE_H / 2); + ctx.fillStyle = '#ffd166'; + const title = content.title || ''; + const subText = content.sub || ''; + if (subText) { + // Заголовок сверху, sub-строки списком ниже + ctx.fillText(this._truncate(title, 18), TEXTURE_W / 2, 50); + // Sub — многострочный, выравнивание по левому краю + ctx.font = '20px Arial, sans-serif'; + ctx.fillStyle = '#fff'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + const lines = String(subText).split('\n'); + const startY = 95; + const lineH = 30; + const leftX = 38; + for (let i = 0; i < lines.length && i < 8; i++) { + ctx.fillText(this._truncate(lines[i], 36), leftX, startY + i * lineH); + } + } else { + ctx.font = 'bold 64px Arial, sans-serif'; + ctx.fillText(this._truncate(title, 14), TEXTURE_W / 2, TEXTURE_H / 2); + } } /** Рендер кастомного списка элементов: фон + список text/image/button. diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index a1f4243..f065393 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -163,6 +163,9 @@ export class GameRuntime { this._broadcastSceneSnapshot(); this._broadcastGuiSnapshot(); this._broadcastTerrainHeightmap(); + this._broadcastSkinsSnapshot(); // задача 07 + // Задача 03: запустить animationPreset для GUI-элементов с пресетом ≠ 'none'. + this._startGuiAnimationPresets(); }; if (typeof requestAnimationFrame !== 'undefined') { requestAnimationFrame(sendInitial); @@ -171,6 +174,60 @@ export class GameRuntime { } } + /** Задача 03: запустить пресеты анимаций для GUI-элементов в Play. */ + _startGuiAnimationPresets() { + const gm = this.scene3d?.guiManager; + if (!gm) return; + if (!this._guiTweens) this._guiTweens = []; + for (const el of (gm.elements || [])) { + const preset = el.animationPreset; + if (!preset || preset === 'none') continue; + const id = el.id; + // Каждый пресет = одна tween-запись с reverses+repeat=-1 + switch (preset) { + case 'pulse': + this._guiTweens.push(this._mkGuiPreset(id, el, + { scaleX: 1.1, scaleY: 1.1 }, 0.7, 'ease', true, -1)); + break; + case 'rotate': + this._guiTweens.push(this._mkGuiPreset(id, el, + { rotation: (el.rotation || 0) + 360 }, 3, 'linear', false, -1)); + break; + case 'sway': + this._guiTweens.push(this._mkGuiPreset(id, el, + { rotation: (el.rotation || 0) + 8 }, 0.6, 'ease', true, -1)); + this._guiTweens[this._guiTweens.length - 1].start.rotation = (el.rotation || 0) - 8; + break; + case 'glow': + this._guiTweens.push(this._mkGuiPreset(id, el, + { bgOpacity: 0.6 }, 0.8, 'ease', true, -1)); + break; + case 'bounce': + this._guiTweens.push(this._mkGuiPreset(id, el, + { y: (el.y || 50) - 1 }, 0.5, 'ease', true, -1)); + break; + } + } + } + _mkGuiPreset(id, el, targetProps, duration, easing, reverses, repeat) { + const start = {}; + for (const k of Object.keys(targetProps)) { + if (k === 'scaleX' || k === 'scaleY') start[k] = el[k] || 1; + else if (k === 'rotation') start[k] = el.rotation || 0; + else if (k === 'bgOpacity') start[k] = el.bgOpacity == null ? 1 : el.bgOpacity; + else start[k] = el[k] || 0; + } + return { + tweenId: ++this._tweenSeq || (this._tweenSeq = 1), + scriptId: '__preset__', + realId: id, + start, target: targetProps, + elapsed: 0, delay: 0, + duration, easing, + repeat, reverses, iter: 0, dir: 1, + }; + } + /** * Разослать карту высот гладкого ландшафта всем sandbox'ам. * Нужно для game.scene.surfaceY(x,z). Снимается raycast'ом по @@ -197,6 +254,43 @@ export class GameRuntime { } } + /** + * Задача 07: разослать снапшот скинов всем sandbox'ам — чтобы + * game.player.getAvailableSkins/getAllSkins работали синхронно. + * Манифест грузится через fetch (кешируется браузером), затем + * объединяется с разблокированными скинами из scene.skins. + */ + async _broadcastSkinsSnapshot() { + try { + this._ensureSkinState(); + let manifest = this._skinManifestCache; + if (!manifest) { + const resp = await fetch('/kubikon-assets/characters/skins_manifest.json'); + const json = await resp.json(); + manifest = (json.skins || []).map(s => ({ + slug: s.slug || (s.id || '').replace(/^skin_/, ''), + name: s.name || s.slug, + kind: s.kind || 'r15', + category: s.category || 'human', + price: Number.isFinite(s.price) ? s.price : 0, + })); + // Встроенные «человеки» character-a..g тоже добавим как базовый выбор. + this._skinManifestCache = manifest; + } + const payload = { + all: manifest, + unlocked: Array.from(this._skinState.unlocked), + current: this._skinState.current, + coins: this._skinState.coins, + }; + for (const sb of this.sandboxes) sb.sendSkinsSnapshot(payload); + // Также отдать снапшот в scene для React-магазина. + try { this.scene3d?._setSkinShopData?.({ ...payload, manifestFull: this._skinManifestCache }); } catch (e) {} + } catch (e) { + // манифест недоступен — не критично, скрипт получит пустой список + } + } + /** * Получить позицию объекта по его target (для зеркалирования в worker). */ @@ -370,6 +464,10 @@ export class GameRuntime { } // Анимации game.tween if (this._tweens.length > 0) this._updateTweens(dt); + // Задача 03: GUI tweens + if (this._guiTweens && this._guiTweens.length > 0) this._updateGuiTweens(dt); + // Задача 04: модал-сцены — tick вынесен в BabylonScene.onBeforeRender + // (не зависит от наличия скриптов). // ProximityPrompt — подсказка [E] над ближайшим интерактивным объектом if (this._interactables.length > 0) this._updateInteractables(); @@ -566,6 +664,67 @@ export class GameRuntime { } /** Прокрутка всех активных твинов на dt секунд. */ + /** Задача 03: обновление GUI-tweens. Простая реализация без _applyTweenFrame + * (там 3D-логика с rotationY/sx/cy/color через babylon-объекты). */ + _updateGuiTweens(dt) { + const gm = this.scene3d?.guiManager; + if (!gm) return; + for (let i = this._guiTweens.length - 1; i >= 0; i--) { + const tw = this._guiTweens[i]; + if (tw.delay > 0) { tw.delay -= dt; if (tw.delay > 0) continue; } + tw.elapsed += dt; + let t = tw.elapsed / tw.duration; + let done = false; + if (t >= 1) { t = 1; done = true; } + const raw = tw.dir === -1 ? 1 - t : t; + const k = GameRuntime._ease(tw.easing, raw); + // Применяем + const el = gm.elements.find(e => e.id === tw.realId); + if (!el) { this._guiTweens.splice(i, 1); continue; } + const patch = {}; + for (const key of Object.keys(tw.target)) { + const from = tw.start[key]; + const to = tw.target[key]; + if (typeof from === 'number' && typeof to === 'number') { + patch[key] = from + (to - from) * k; + } else if (typeof from === 'string' && typeof to === 'string' + && from.startsWith('#') && to.startsWith('#')) { + patch[key] = GameRuntime._lerpColor(from, to, k); + } else { + // Прочее — на конце ставим целевое + if (done) patch[key] = to; + } + } + // Throttle: обновляем не чаще чем раз в 32мс (~30 FPS). + tw._lastApply = tw._lastApply || 0; + tw._lastApply += dt; + if (tw._lastApply >= 0.032 || done) { + tw._lastApply = 0; + try { gm.update(tw.realId, patch); } catch (e) {} + } + + if (done) { + if (tw.reverses && tw.dir === 1) { + tw.dir = -1; + tw.elapsed = 0; + continue; + } + tw.iter++; + if (tw.repeat === -1 || tw.iter < tw.repeat) { + // повтор + tw.elapsed = 0; + tw.dir = 1; + continue; + } + // готово + this._guiTweens.splice(i, 1); + // onDone callback в worker + const sb = this.sandboxes.find(s => s.scriptId === tw.scriptId); + if (sb) sb.sendGlobalEvent({ type: 'tweenDone', tweenId: tw.tweenId }); + } + } + } + _updateTweens(dt) { for (let i = this._tweens.length - 1; i >= 0; i--) { const tw = this._tweens[i]; @@ -920,16 +1079,58 @@ export class GameRuntime { * Глобальное событие — доставляется ВСЕМ sandbox'ам (не зависит от target). * Используется для onKey, onClick (глобальный), onPlayerTouch. */ + /** + * Задача 07: состояние скинов на стороне runtime. + * Инициализируется из scene.skins (default/unlocked/shopVisible) при первом + * обращении. Держит множество разблокированных скинов и текущий. + */ + _ensureSkinState() { + if (this._skinState) return this._skinState; + const sk = this.scene3d?._skinsConfig || {}; + const def = sk.default || this.scene3d?._playerModelType || 'character-a'; + const defSlug = this._slugFromTypeId(def); + const unlocked = new Set(Array.isArray(sk.unlocked) ? sk.unlocked : []); + unlocked.add(defSlug); + this._skinState = { + unlocked, + current: defSlug, + shopVisible: sk.shopVisible !== false, + coins: Number.isFinite(sk.coins) ? sk.coins : 0, + }; + return this._skinState; + } + + /** slug → _modelTypeId движка. Встроенные → 'skin_', character-* как есть. */ + _resolveSkinTypeId(slug) { + if (!slug) return 'character-a'; + if (slug.startsWith('character-')) return slug; + if (slug.startsWith('skin_') || slug.startsWith('customskin:')) return slug; + return 'skin_' + slug; + } + + /** _modelTypeId → slug (обратно). */ + _slugFromTypeId(typeId) { + if (!typeId) return 'character-a'; + if (typeId.startsWith('skin_')) return typeId.slice('skin_'.length); + return typeId; + } + routeGlobalEvent(eventType, extra = {}) { if (!eventType) return; - // Спецслучай: guiClick приходит с realId, но worker подписан на localRef - // (потому что gui.create() возвращает worker'у только localRef). - // Резолвим обратно по реверс-карте. + // Спецслучай: guiClick приходит с realId. Worker мог подписаться двумя + // способами: + // 1) по локальному ref, который вернул gui.create() — '_gui_local_N' + // 2) по ЯВНОМУ id, который сам передал в gui.create({ id: 'fight' }), + // или по name элемента. + // Раньше мы ПЕРЕЗАПИСЫВАЛИ extra.id на localRef — это ломало вариант (2), + // потому что worker искал handler по localRef, а юзер подписался по + // явному id. Теперь шлём ОБА id (`id` = реальный + `localId` = ref), + // worker матчит по обоим (см. _guiHandlerKeys в ScriptSandboxWorker). if ((eventType === 'guiClick' || eventType === 'guiSubmit' || eventType === 'guiTextChange') && extra && extra.id != null && this._guiRealToLocal) { const local = this._guiRealToLocal.get(extra.id); - if (local) extra = { ...extra, id: local }; + if (local && local !== extra.id) extra = { ...extra, localId: local }; } // ProximityPrompt: keydown клавиши взаимодействия → событие interact if (eventType === 'keydown' && extra && extra.key @@ -1102,6 +1303,20 @@ export class GameRuntime { return map[code] || code.toLowerCase(); } + /** Слить отложенные команды для конкретного только что зарезолвленного ref. */ + _drainPendingResolveQueue(resolvedLocalRef) { + if (!this._pendingResolveQueue || !this._pendingResolveQueue.length) return; + const stay = []; + for (const item of this._pendingResolveQueue) { + if (item.payload?.ref === resolvedLocalRef) { + this._handleCommand(item.scriptId, item.cmd, item.payload); + } else { + stay.push(item); + } + } + this._pendingResolveQueue = stay; + } + /** Команда от Worker'а пришла — применяем на сцене. */ _handleCommand(scriptId, cmd, payload) { if (cmd === 'log') { @@ -1779,6 +1994,20 @@ export class GameRuntime { } catch (e) {} return; } + if (cmd === 'hud.setHotbarVisible') { + try { + const v = !!payload?.visible; + this.scene3d?._setHotbarVisible?.(v); + } catch (e) {} + return; + } + if (cmd === 'hud.setHpVisible') { + try { + const v = !!payload?.visible; + this.scene3d?._setHpVisible?.(v); + } catch (e) {} + return; + } if (cmd === 'input.setCursorMode') { try { const mode = payload?.mode === 'ui' ? 'ui' : 'game'; @@ -1945,17 +2174,183 @@ export class GameRuntime { } return; } + // === Задача 07: скины игрока === + if (cmd === 'player.setSkin') { + const player = this.scene3d?.player; + const slug = payload?.slug; + if (player && typeof slug === 'string' && slug) { + const typeId = this._resolveSkinTypeId(slug); + // Помечаем доступным (setSkin неявно разблокирует). + this._ensureSkinState(); + this._skinState.unlocked.add(slug); + this._skinState.current = slug; + // Асинхронная перезагрузка модели; по завершении шлём skinChanged. + Promise.resolve(player.reloadSkin?.(typeId)).then(() => { + this.routeGlobalEvent?.('skinChanged', { slug }); + try { this.scene3d?._onPlayerSkinChanged?.(slug); } catch (e) {} + }).catch((e) => { + this._log('error', 'setSkin failed: ' + (e?.message || e)); + }); + } + return; + } + if (cmd === 'player.unlockSkin') { + const slug = payload?.slug; + if (typeof slug === 'string' && slug) { + this._ensureSkinState(); + this._skinState.unlocked.add(slug); + this.routeGlobalEvent?.('skinUnlocked', { slug }); + } + return; + } + if (cmd === 'player.openSkinShop') { + this._ensureSkinState(); + try { this.scene3d?._openSkinShop?.(); } catch (e) {} + return; + } + if (cmd === 'player.closeSkinShop') { + try { this.scene3d?._closeSkinShop?.(); } catch (e) {} + return; + } + if (cmd === 'player.setSkinCoins') { + this._ensureSkinState(); + const n = Number(payload?.amount); + if (Number.isFinite(n)) { + this._skinState.coins = Math.max(0, Math.floor(n)); + this._broadcastSkinsSnapshot(); + } + return; + } + // Покупка скина из встроенного магазина (намерение от React-оверлея + // или из скрипта). Списывает локальные рублики, разблокирует, надевает. + if (cmd === 'player.buySkin') { + this._ensureSkinState(); + const slug = payload?.slug; + const price = Number(payload?.price) || 0; + if (typeof slug !== 'string' || !slug) return; + const st = this._skinState; + const owned = st.unlocked.has(slug); + if (owned) { + // Уже куплен — просто надеть. + this._handleCommand(scriptId, 'player.setSkin', { slug }); + return; + } + if (st.coins < price) { + // Не хватает — сообщаем оверлею. + try { this.scene3d?._onSkinBuyResult?.({ slug, ok: false, reason: 'no_coins' }); } catch (e) {} + return; + } + st.coins -= price; + st.unlocked.add(slug); + this._handleCommand(scriptId, 'player.setSkin', { slug }); + this._broadcastSkinsSnapshot(); + try { this.scene3d?._onSkinBuyResult?.({ slug, ok: true }); } catch (e) {} + return; + } if (cmd === 'player.setCameraMode') { const player = this.scene3d?.player; if (player && typeof payload?.mode === 'string') { - const valid = ['first', 'third', 'front', 'sideview']; + const valid = ['first', 'third', 'front', 'sideview', 'lockfirst']; if (valid.includes(payload.mode)) { - player._cameraMode = payload.mode; + const wasFirst = (player._cameraMode === 'first' || player._cameraMode === 'lockfirst'); + player._cameraMode = (payload.mode === 'lockfirst') ? 'first' : payload.mode; + player._lockFirstPerson = (payload.mode === 'lockfirst'); try { player._applyCameraMode?.(); } catch (e) {} + // Запросить/снять lock в зависимости от нового режима + const isFirst = (player._cameraMode === 'first'); + if (isFirst && !wasFirst) player._requestPointerLockSafe?.(); + else if (!isFirst && wasFirst && !player._shiftLock) { + if (document.pointerLockElement === player.canvas) { + try { document.exitPointerLock(); } catch (e) {} + } + } + try { player._applyCursorVisibility?.(); } catch (e) {} } } return; } + // Задача 02: setCameraZoom / setCameraZoomLimits / setShiftLock + if (cmd === 'player.setCameraZoom') { + const player = this.scene3d?.player; + if (player && typeof player.setCameraZoom === 'function') { + try { player.setCameraZoom(payload?.distance); } catch (e) {} + } + return; + } + if (cmd === 'player.setCameraZoomLimits') { + const player = this.scene3d?.player; + if (player && typeof player.setCameraZoomLimits === 'function') { + try { player.setCameraZoomLimits(payload?.min, payload?.max); } catch (e) {} + } + return; + } + if (cmd === 'player.setShiftLock') { + const player = this.scene3d?.player; + if (player && typeof player.setShiftLock === 'function') { + try { player.setShiftLock(payload?.on); } catch (e) {} + } + return; + } + // Задача 02: input.setMouseBehavior / setMouseIconVisible + if (cmd === 'input.setMouseBehavior') { + const player = this.scene3d?.player; + if (player && typeof player.setMouseBehavior === 'function') { + try { player.setMouseBehavior(payload?.mode); } catch (e) {} + } + return; + } + if (cmd === 'input.setMouseIconVisible') { + const player = this.scene3d?.player; + if (player && typeof player.setMouseIconVisible === 'function') { + try { player.setMouseIconVisible(payload?.visible); } catch (e) {} + } + return; + } + // Задача 02: environment API + if (cmd === 'environment.setSkyColor') { + try { + const hex = String(payload?.color || ''); + const scene = this.scene3d?.scene; + if (scene && hex) { + // Парсим #rrggbb → Color4 + const m = hex.match(/^#?([0-9a-f]{6})$/i); + if (m) { + const n = parseInt(m[1], 16); + const r = ((n >> 16) & 0xff) / 255; + const g = ((n >> 8) & 0xff) / 255; + const b = (n & 0xff) / 255; + // Color4 импортирован в начале файла + if (scene.clearColor) { + scene.clearColor.r = r; + scene.clearColor.g = g; + scene.clearColor.b = b; + scene.clearColor.a = 1; + } + } + } + } catch (e) { + this._log('error', 'environment.setSkyColor failed: ' + (e?.message || e)); + } + return; + } + if (cmd === 'environment.setFog') { + try { + const env = this.scene3d?.environment; + if (env && typeof env.setFog === 'function') { + env.setFog(payload?.enabled, payload?.color, payload?.density); + } + } catch (e) {} + return; + } + if (cmd === 'environment.setTimeOfDay') { + try { + const env = this.scene3d?.environment; + if (env && typeof env.setTimeOfDay === 'function') { + env.setTimeOfDay(payload?.hours); + } + } catch (e) {} + return; + } if (cmd === 'player.setCrouch') { const player = this.scene3d?.player; if (player) { @@ -2527,6 +2922,114 @@ export class GameRuntime { } return; } + // === Задача 03: GUI tween === + if (cmd === 'gui.tween') { + try { + const guiId = payload?.id; + if (typeof guiId !== 'string' || !guiId) return; + const gm = this.scene3d?.guiManager; + if (!gm) return; + // Резолв localRef → realId если есть + let realId = guiId; + if (this._guiLocalToReal?.has(guiId)) realId = this._guiLocalToReal.get(guiId); + const el = gm.elements?.find(e => e.id === realId); + if (!el) return; + if (!this._guiTweens) this._guiTweens = []; + // Снимок начальных значений по тем ключам что есть в props + const props = payload.props || {}; + const propKeys = Object.keys(props); + // Перебивка: убрать существующие tween'ы на ТОТ ЖЕ id, + // которые анимируют ХОТЯ БЫ ОДИН из этих же ключей. + // Это поведение Roblox TweenService: новый tween на тот же ключ перебивает старый. + for (let j = this._guiTweens.length - 1; j >= 0; j--) { + const old = this._guiTweens[j]; + if (old.realId !== realId) continue; + const oldKeys = Object.keys(old.target); + const overlap = oldKeys.some(k => propKeys.includes(k)); + if (overlap) this._guiTweens.splice(j, 1); + } + const start = {}; + for (const k of propKeys) { + if (k in el) start[k] = el[k]; + else if (k === 'scaleX' || k === 'scaleY' || k === 'rotation') start[k] = el[k] || (k === 'rotation' ? 0 : 1); + } + this._guiTweens.push({ + tweenId: payload.tweenId, + scriptId, + realId, + start, target: { ...props }, + elapsed: 0, + duration: Math.max(0.001, Number(payload.duration) || 0.5), + delay: Math.max(0, Number(payload.delay) || 0), + easing: payload.easing || 'ease', + repeat: Number.isFinite(payload.repeat) ? payload.repeat : 0, + reverses: !!payload.reverses, + iter: 0, + dir: 1, // 1 = вперёд, -1 = обратно (для reverses) + }); + } catch (e) { + this._log('error', 'gui.tween failed: ' + (e?.message || e)); + } + return; + } + if (cmd === 'gui.cancelTween') { + const tid = payload?.tweenId; + if (tid != null && this._guiTweens) { + const i = this._guiTweens.findIndex(t => t.tweenId === tid); + if (i >= 0) this._guiTweens.splice(i, 1); + } + return; + } + // === Задача 04: модал-сцены === + if (cmd === 'modal.open') { + try { + const mm = this.scene3d?.modalManager; + if (!mm) return; + // Резолв spotlights: строки-id → ref-объекты {kind:'primitive', id} как обычно + const opts = { ...(payload?.opts || {}) }; + if (Array.isArray(opts.spotlights)) { + opts.spotlights = opts.spotlights.map(r => this._normRef ? this._normRef(r) : r); + } + if (opts.cameraOverride && opts.cameraOverride.target) { + opts.cameraOverride = { + ...opts.cameraOverride, + target: this._normRef ? this._normRef(opts.cameraOverride.target) : opts.cameraOverride.target, + }; + } + const modalId = mm.open(opts); + // Подписка чтобы автоматически слать tweenDone-стиль событий + // на конкретный скрипт (тот кто открыл) — для onClose. + if (!mm._runtimeBoundOnClose) { + mm._runtimeBoundOnClose = true; + mm.onClose((closedId) => { + // Шлём событие всем sandbox'ам — они роутят к зарегистрированным fn + this.routeGlobalEvent?.('modalClosed', { id: closedId }); + }); + } + // Ответ обратно в worker: фактический modalId (юзер мог вернуть из open) + const sb = this.sandboxes.find(s => s.scriptId === scriptId); + if (sb && payload?.replyId != null) { + sb.sendGlobalEvent({ type: 'modalOpened', replyId: payload.replyId, modalId }); + } + } catch (e) { + this._log('error', 'modal.open failed: ' + (e?.message || e)); + } + return; + } + if (cmd === 'modal.close') { + try { + const mm = this.scene3d?.modalManager; + mm?.close?.(payload?.modalId); + } catch (e) {} + return; + } + if (cmd === 'modal.update') { + try { + const mm = this.scene3d?.modalManager; + mm?.update?.(payload?.modalId, payload?.patch); + } catch (e) {} + return; + } if (cmd === 'scene.setTexture') { // Установить динамическую текстуру примитива из dataURL. // Используется GD-скинами куба (canvas-фабрика в скрипте → dataURL → текстура). @@ -2672,11 +3175,19 @@ export class GameRuntime { } // === Billboard 3D-таблички (см. BillboardUiManager) === if (cmd === 'billboard.set' || cmd === 'billboard.update' || cmd === 'billboard.onClick') { + // Резолв ref → primitiveId. + // Worker может прислать ref сразу после game.scene.spawn — до + // того как main spawn'нул примитив и обновил _localToReal. + // Откладываем команду до резолва. + let ref = payload?.ref; + if (typeof ref === 'string' && ref.includes('_local_') + && !this._localToReal?.has(ref)) { + this._pendingResolveQueue = this._pendingResolveQueue || []; + this._pendingResolveQueue.push({ cmd, payload, scriptId }); + return; + } try { - // Резолв ref → primitiveId - let ref = payload?.ref; if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref); - // ref имеет формат 'primitive:NN' — выделяем числовой id let id = null; if (typeof ref === 'string' && ref.startsWith('primitive:')) { id = Number(ref.slice('primitive:'.length)); @@ -2698,17 +3209,22 @@ export class GameRuntime { }); this.scheduleSceneSnapshot?.(); } else if (cmd === 'billboard.update') { - mgr.update(data, payload.patch || {}); + // 2 формы: с elementId (точечно) или без (patch content) + if (typeof payload.elementId === 'string') { + mgr.update(data, payload.elementId, payload.patch || {}); + } else { + mgr.update(data, payload.patch || {}); + } this.scheduleSceneSnapshot?.(); } else if (cmd === 'billboard.onClick') { const buttonId = String(payload.buttonId || 'buy'); - // Регистрируем handler: при клике эмитим event в worker, - // worker найдёт зарегистрированный JS-callback по (ref,button). const realRef = 'primitive:' + id; mgr.onClick(data, buttonId, () => { const sb = this.sandboxes.find(s => s.scriptId === scriptId); - if (sb && typeof sb.sendEvent === 'function') { - sb.sendEvent({ + if (sb && typeof sb.sendGlobalEvent === 'function') { + // billboardClick роутится в worker'е через globalEvent-ветку + // (см. ScriptSandboxWorker.js cmd === 'globalEvent'). + sb.sendGlobalEvent({ type: 'billboardClick', ref: realRef, button: buttonId, @@ -2877,6 +3393,7 @@ export class GameRuntime { if (id != null) { this._localToReal.set(ref, 'primitive:' + id); this._notifySpawnResolved(ref, 'primitive:' + id); + this._drainPendingResolveQueue?.(ref); const data = this.scene3d?.primitiveManager?.instances?.get(id); if (data) { // Помечаем как заспавненный скриптом — движок шлёт diff --git a/src/editor/engine/GuiManager.js b/src/editor/engine/GuiManager.js index 9bcb531..b68b8b9 100644 --- a/src/editor/engine/GuiManager.js +++ b/src/editor/engine/GuiManager.js @@ -140,6 +140,25 @@ export class GuiManager { scrollY: opts.scrollY ?? 0, // Тень под элементом (Фаза 5.4) — мягкая drop-shadow. shadow: opts.shadow ?? false, + // === Задача 03: расширения для красивого UI + анимаций === + // Линейный градиент фона. Формат: { stops: ['#a','#b'] | [{c,p}], angle: 0..360 }. + bgGradient: opts.bgGradient ?? null, + // Обводка текста (для крупных подписей "X2 ДЕНЕГ"). { color, width }. + textStroke: opts.textStroke ?? null, + // Поворот элемента в градусах (transform: rotate). + rotation: opts.rotation ?? 0, + // Scale-множитель (transform: scale). 1 = нормальный размер. + scaleX: opts.scaleX ?? 1, + scaleY: opts.scaleY ?? 1, + // Бейдж-маркер в углу: { corner, icon, color, text }. + badge: opts.badge ?? null, + // Hover-реакция (только для button/image-button): { scale, rotation, brightness, duration, easing }. + hover: opts.hover ?? null, + // Active-реакция (зажатие ЛКМ): { scale, duration }. + active: opts.active ?? null, + // Анимация-пресет: 'none'|'pulse'|'rotate'|'sway'|'glow'|'bounce'|'custom'. + // Раскрывается в реальный tween при applyAnimationPreset(id) в Play. + animationPreset: opts.animationPreset ?? 'none', // Создан скриптом в Play (game.gui.create) — НЕ сериализуется // в проект, удаляется при Stop. _scriptCreated: opts._scriptCreated === true, diff --git a/src/editor/engine/ModalManager.js b/src/editor/engine/ModalManager.js new file mode 100644 index 0000000..2c8f29a --- /dev/null +++ b/src/editor/engine/ModalManager.js @@ -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) {} + } + } +} diff --git a/src/editor/engine/PlayerController.js b/src/editor/engine/PlayerController.js index 439118d..03c29ab 100644 --- a/src/editor/engine/PlayerController.js +++ b/src/editor/engine/PlayerController.js @@ -106,11 +106,25 @@ export class PlayerController { this.GRAVITY = -22; this.MOUSE_SENSITIVITY = 0.0025; - // 3rd person camera - this.THIRD_DISTANCE_MIN = 2.5; - this.THIRD_DISTANCE_MAX = 12; + // 3rd person camera (Roblox-style: 0.5 .. 32) + this.THIRD_DISTANCE_MIN = 0.5; + this.THIRD_DISTANCE_MAX = 32; this.THIRD_DISTANCE_DEFAULT = 5; this.THIRD_HEIGHT_OFFSET = 1.0; // на сколько выше плеча игрока + // Порог перехода third ↔ first при зуме внутрь (Roblox: ~0.5) + this.FIRST_PERSON_ZOOM_THRESHOLD = 0.7; + // Lockfirst-режим: нельзя выйти из first-person зумом наружу + this._lockFirstPerson = false; + // Shift-Lock: курсор в центре, камера через плечо, корпус доворачивается к камере + // (включается клавишей L по дефолту, или game.player.setShiftLock(true)) + this._shiftLock = false; + // Видимость курсора по умолчанию (game.input.setMouseIconVisible) + this._mouseIconVisible = true; + // Mouse behavior: 'default' (свободный) / 'lockcenter' (зафиксирован) + // / 'lockcurrent' (зафиксирован на текущей позиции) + this._mouseBehavior = 'default'; + // Флаг: ПКМ зажата прямо сейчас (для orbit-камеры в third) + this._rmbHeld = false; this.camera = null; this._active = false; @@ -277,6 +291,44 @@ export class PlayerController { this._modelTypeId = typeId || 'character-a'; } + /** + * Сменить скин ИГРОКА в Play-режиме без перезапуска сцены (задача 07). + * Выгружает текущую модель/скелет/аниматор, сбрасывает AABB к дефолту, + * грузит новую модель (R15 или non-humanoid). Возвращает Promise. + * + * Используется из game.player.setSkin(slug). + */ + async reloadSkin(typeId) { + if (!this._active) return false; + const newType = typeId || 'character-a'; + if (newType === this._modelTypeId && this._modelRoot) return true; // уже этот скин + // 1) Выгрузить текущую модель и связанные аниматоры. + try { + if (this._modelRoot) { this._modelRoot.dispose(false, true); } + } catch (e) { /* ignore */ } + this._modelRoot = null; + this._modelMeshes = []; + this._rightArmMeshes = []; + this._r15Skeleton = null; + this._r15Animator = null; + this._isR15 = false; + this._modelKind = 'r15'; + this._modelHipHeight = null; + this._nonHumanoidBox = null; + // 2) Сбросить AABB к дефолтному «человеку» (non-humanoid его переопределит). + this.HALF_W = 0.3; + this.HALF_H = 0.9; + this.HALF_D = 0.3; + this.HALF_H_NORMAL = 0.9; + this.EYE_HEIGHT = 0.7; + // 3) Поднять игрока на запас, чтобы новый AABB не оказался в полу. + this._pos.y += 0.5; + // 4) Загрузить новую модель. + this._modelTypeId = newType; + await this._loadPlayerModel(); + return !!this._modelRoot; + } + /** * Запустить режим игры. * spawnPos — точка спавна. Если не указано — (0, 5, 0). @@ -317,10 +369,37 @@ export class PlayerController { this._beforeRender = () => this._tick(); this.scene.registerBeforeRender(this._beforeRender); - // Сразу запросить Pointer Lock. Promise-форма (новые Chrome) может - // отклониться с SecurityError если предыдущий lock ещё не отпущен — - // в этом случае ждём отпускания и пробуем снова. - this._requestPointerLockSafe(); + // Pointer-lock запрашиваем ТОЛЬКО для режимов где он нужен сразу: + // - first / lockfirst — постоянный lock + // - sideview (GD) — раньше тоже лочил, оставляем для авто-управления + // Для third — НЕ лочим (Roblox-style: курсор виден, ПКМ = orbit). + // ШС-lock (_shiftLock) обрабатывается отдельно через keydown 'L'. + const needLockAtStart = ( + this._cameraMode === 'first' || + this._cameraMode === 'lockfirst' || + this._cameraMode === 'sideview' || + this._shiftLock + ); + if (needLockAtStart) { + this._requestPointerLockSafe(); + } + // Применяем видимость курсора (по умолчанию виден в third). + this._applyCursorVisibility(); + } + + /** + * Установить курсор видимым/скрытым через CSS на canvas. + * Pointer-lock сам прячет курсор когда активен, но в third без lock + * мы можем скрыть курсор через `cursor:none` если разработчик + * выключил его через setMouseIconVisible(false). + */ + _applyCursorVisibility() { + if (!this.canvas) return; + const locked = (document.pointerLockElement === this.canvas); + // Если lock активен — курсор и так скрыт. Иначе зависит от настроек. + if (locked) return; + const show = this._mouseIconVisible && !this._shiftLock; + this.canvas.style.cursor = show ? '' : 'none'; } /** @@ -578,22 +657,51 @@ export class PlayerController { const manifest = await this._loadSkinManifest(); const entry = manifest.find((s) => s.id === typeId); if (entry) { + // kind определяет систему анимации: + // 'r15' → R15-скелет (как раньше) + // 'non-humanoid-mesh' → single-mesh, процедурное покачивание + // 'non-humanoid-rigged' → свой скелет + встроенные AnimationGroup + // Отсутствие kind = 'r15' (обратная совместимость со старым манифестом). + const kind = entry.kind || 'r15'; return { file: '/kubikon-assets/' + entry.file, - isR15: true, + isR15: kind === 'r15', + kind, overrides: entry.overrides || {}, + scaleManifest: Number.isFinite(entry.scale) ? entry.scale : null, + hipHeight: Number.isFinite(entry.hipHeight) ? entry.hipHeight : null, + rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0, }; } - // нет в манифесте — пробуем прямой путь + // нет в манифесте — пробуем прямой путь (старые R15-скины) return { file: `/kubikon-assets/characters/${typeId}/body.glb`, isR15: true, + kind: 'r15', overrides: {}, }; } + // Кастомный .glb пользователя: 'customskin:'. dataUrl + метаданные + // (scale/hipHeight) лежат в scene._skinsConfig.customGlbs. + if (typeId.startsWith('customskin:')) { + const slug = typeId.slice('customskin:'.length); + const list = this._scene3d?._skinsConfig?.customGlbs || []; + const meta = list.find(g => g && g.slug === slug) || null; + const url = this._scene3d?.getAssetDataUrl?.(slug) || (meta && meta.dataUrl) || null; + if (url) { + return { + file: url, isR15: false, kind: 'non-humanoid-mesh', overrides: {}, + scaleManifest: meta?.scale ?? 1.5, + hipHeight: meta?.hipHeight ?? 0.4, + rotationYOffset: meta?.rotationYOffset ?? 0, + isDataUrl: true, + }; + } + return null; + } const modelType = getModelType(typeId); if (!modelType) return null; - return { file: modelType.file, isR15: false, overrides: {} }; + return { file: modelType.file, isR15: false, kind: 'r15', overrides: {} }; } /** Загрузить GLB-модель персонажа и его анимации. */ @@ -607,13 +715,22 @@ export class PlayerController { // что и зомби (через _loadPrototype), повторный // instantiateModelsToScene давал меши с битыми материалами. // Babylon HTTP-кэш всё равно убирает сетевые запросы. - const lastSlash = source.file.lastIndexOf('/'); - const rootUrl = source.file.substring(0, lastSlash + 1); - const filename = source.file.substring(lastSlash + 1); + let rootUrl, filename; + if (source.isDataUrl) { + // Кастомный скин — data:URL. SceneLoader принимает его как rootUrl='' + // и filename=data:... с подсказкой расширения через ?name=. + rootUrl = ''; + filename = source.file; + } else { + const lastSlash = source.file.lastIndexOf('/'); + rootUrl = source.file.substring(0, lastSlash + 1); + filename = source.file.substring(lastSlash + 1); + } let container; try { container = await SceneLoader.LoadAssetContainerAsync( - rootUrl, filename, this.scene + rootUrl, filename, this.scene, + null, source.isDataUrl ? '.glb' : undefined ); } catch (e) { // eslint-disable-next-line no-console @@ -634,10 +751,20 @@ export class PlayerController { // с торчащими волосами/плащами (как у bacon-hair). // - Kenney-модели: старый 0.72. // - overrides.scale_mult — per-skin множитель из манифеста. - let modelScale = source.isR15 ? 0.301 : this._modelScale; - const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0; - modelScale *= scaleMult; + const isNonHumanoid = source.kind === 'non-humanoid-mesh' + || source.kind === 'non-humanoid-rigged'; + let modelScale; + if (isNonHumanoid) { + // Non-humanoid: базовый размер берём из манифеста (scale), а если + // нет — нормализуем по bounding box к ~1.6 ед высоты (как игрок). + modelScale = Number.isFinite(source.scaleManifest) ? source.scaleManifest : 1.0; + } else { + modelScale = source.isR15 ? 0.301 : this._modelScale; + const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0; + modelScale *= scaleMult; + } root.scaling = new Vector3(modelScale, modelScale, modelScale); + if (source.rotationYOffset) root.rotation.y = source.rotationYOffset; const inst = container.instantiateModelsToScene( (name) => `player_${name}`, /*cloneAnimations*/ true, @@ -647,6 +774,15 @@ export class PlayerController { r.parent = root; } this._modelRoot = root; + this._modelKind = source.kind || 'r15'; + // hipHeight: на сколько центр модели поднят от «низа ног». + // Используется и для позиционирования модели, и для камеры/AABB. + this._modelHipHeight = Number.isFinite(source.hipHeight) ? source.hipHeight : null; + + // Non-humanoid: нормализуем размер и опускаем модель на «ноги». + if (isNonHumanoid) { + this._setupNonHumanoidModel(root, modelScale, source); + } // === R15-скин: детекция скелета === // R15-скины приходят с встроенным скелетом Mixamo. Babylon @@ -786,6 +922,121 @@ export class PlayerController { } } + /** + * Настройка non-humanoid модели (животное/машина/еда): нормализация + * размера и опускание на «низ ног». В отличие от R15 (нормализованы + * пайплайном), эти модели произвольного размера, поэтому считаем bbox. + * + * Локальные координаты root: модель должна стоять так, чтобы её низ был + * на y=0 (там «ноги»). PlayerController позиционирует root в точке + * `_pos.y - HALF_H` (низ AABB) — модель «садится» на землю. + */ + _setupNonHumanoidModel(root, scaleApplied, source) { + try { + // Считаем bounding box всех дочерних мешей в world-координатах ПОСЛЕ + // применения scaling root'а. Babylon refreshBoundingInfo нужен после + // инстансинга. + const meshes = root.getChildMeshes(false).filter(m => m.getBoundingInfo && m.getTotalVertices && m.getTotalVertices() > 0); + if (!meshes.length) return; + root.computeWorldMatrix(true); + let minY = Infinity, maxY = -Infinity, maxDim = 0; + let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity; + for (const m of meshes) { + m.computeWorldMatrix(true); + // refreshBoundingInfo(true) — пересчитать bbox с учётом возможного + // скелета/морфов; без него minimumWorld у инстансов часто нулевой + // или из исходной позы → центр считался неверно (баг пришельца/робота). + try { m.refreshBoundingInfo(true); } catch (e) { try { m.refreshBoundingInfo(); } catch (e2) {} } + const bi = m.getBoundingInfo(); + const bb = bi.boundingBox; + const lo = bb.minimumWorld, hi = bb.maximumWorld; + if (!lo || !hi) continue; + minY = Math.min(minY, lo.y); maxY = Math.max(maxY, hi.y); + minX = Math.min(minX, lo.x); maxX = Math.max(maxX, hi.x); + minZ = Math.min(minZ, lo.z); maxZ = Math.max(maxZ, hi.z); + } + if (!Number.isFinite(minX) || !Number.isFinite(minY)) return; + const h = maxY - minY; + const w = maxX - minX; + const d = maxZ - minZ; + maxDim = Math.max(h, w, d); + // === Центрирование модели через pivot-node === + // Многие Kenney-модели имеют origin НЕ в геометрическом центре + // (в углу/ноге) → при повороте модель «облетает» вокруг смещённого + // origin (баг пришельца/робота). Ручной сдвиг детей с делением на + // scaleApplied неверен если у детей свой scale/rotation. Надёжно: + // вставляем промежуточный pivot между root и моделью и смещаем pivot + // на -localCenter (через инверсию world-матрицы root — точно при + // любом scale/rotation). + const worldCenter = new Vector3( + (minX + maxX) / 2, // центр X + minY, // низ Y (модель «садится» на ноги) + (minZ + maxZ) / 2 // центр Z + ); + // world-центр → локальные координаты root + const invRoot = root.getWorldMatrix().clone().invert(); + const localCenter = Vector3.TransformCoordinates(worldCenter, invRoot); + const pivot = new TransformNode('playerModelPivot', this.scene); + pivot.parent = root; + pivot.position.set(-localCenter.x, -localCenter.y, -localCenter.z); + // Перевешиваем все прямые дочерние ноды root (кроме самого pivot) на pivot. + for (const ch of root.getChildren().slice()) { + if (ch === pivot) continue; + ch.parent = pivot; + } + // Сохраняем размеры для настраиваемого AABB и камеры. + // hipHeight из манифеста — приоритетно; иначе берём низ модели. + this._nonHumanoidBox = { w, h, d }; + this._modelBaseHeight = h; + // AABB подгоняем под модель (плоская/широкая для машин, узкая для еды). + // Ограничиваем разумными пределами чтобы не проваливаться/застревать. + this.HALF_W = Math.max(0.25, Math.min(0.9, w / 2)); + this.HALF_D = Math.max(0.25, Math.min(1.1, d / 2)); + const halfH = Math.max(0.3, Math.min(1.0, h / 2)); + this.HALF_H = halfH; + this.HALF_H_NORMAL = halfH; + this.EYE_HEIGHT = halfH * 0.7; + // eslint-disable-next-line no-console + console.log('[PlayerController] non-humanoid setup:', this._modelTypeId, + 'kind=' + source.kind, 'bbox(w/h/d)=' + w.toFixed(2) + '/' + h.toFixed(2) + '/' + d.toFixed(2), + 'AABB(W/H/D)=' + this.HALF_W.toFixed(2) + '/' + this.HALF_H.toFixed(2) + '/' + this.HALF_D.toFixed(2)); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[PlayerController] _setupNonHumanoidModel failed:', e); + } + } + + /** + * Процедурная анимация single-mesh скина (нет скелета — нечего анимировать + * костями). Лёгкое покачивание вверх-вниз при движении + наклон вперёд при + * беге + наклон в воздухе. Вызывается каждый кадр из _tick. + * baseY — локальная база модели (опущена на ноги в _setupNonHumanoidModel). + */ + _animateNonHumanoidMesh(dt) { + const root = this._modelRoot; + if (!root) return; + const t = (typeof performance !== 'undefined' && performance.now) + ? performance.now() / 1000 : Date.now() / 1000; + const speed = this._lastFrameSpeed || 0; + // Базовое вращение по yaw уже выставляет _tick (он крутит модель под + // направление движения/камеры). Мы добавляем ТОЛЬКО процедурный bob/tilt + // поверх — храним их в отдельных полях, чтобы _tick их не перетёр. + let bobY = 0, tiltX = 0; + if (!this._isGrounded) { + tiltX = 0.2; // в воздухе — нос вверх + } else if (speed > 0.1) { + const bobFreq = 8 * Math.min(2, speed / 4); + bobY = Math.sin(t * bobFreq) * 0.06; + tiltX = Math.min(speed * 0.04, 0.13); + } else { + bobY = Math.sin(t * 1.5) * 0.015; // лёгкое «дыхание» в покое + } + // Применяем поверх позиции, которую _tick уже выставил в root.position.y. + root.position.y += bobY; + // tilt по локальной оси X модели. rotation.y уже выставлен _tick'ом. + root.rotation.x = tiltX; + } + /** AABB игрока пересекает хотя бы один блок-воду. */ _isInWater() { const bm = this._scene3d?.blockManager; @@ -1487,15 +1738,228 @@ export class PlayerController { const idx = CAMERA_MODES.indexOf(this._cameraMode); this._cameraMode = CAMERA_MODES[(idx + 1) % CAMERA_MODES.length]; this._applyCameraMode(); + // При переходе в first сразу лочим, при выходе — снимаем lock (если нет shift-lock) + if (this._cameraMode === 'first') { + this._requestPointerLockSafe(); + } else if (!this._shiftLock && document.pointerLockElement === this.canvas) { + try { document.exitPointerLock(); } catch (e) {} + } + this._applyCursorVisibility?.(); + } + + /** Включить/выключить Shift-Lock (Roblox-style: курсор зафиксирован, корпус + * всегда лицом к камере, камера через плечо). + */ + setShiftLock(on) { + this._shiftLock = !!on; + if (this._shiftLock) { + // Запросить pointer-lock — курсор в центре + this._requestPointerLockSafe(); + } else { + // Снять lock если он есть и нет других причин держать (first/sideview) + const needPermLock = ( + this._cameraMode === 'first' || + this._cameraMode === 'lockfirst' || + this._cameraMode === 'sideview' + ); + if (!needPermLock && document.pointerLockElement === this.canvas) { + try { document.exitPointerLock(); } catch (e) {} + } + } + this._applyCursorVisibility?.(); + } + isShiftLock() { return !!this._shiftLock; } + + /** Задача 04: блокирует игровой ввод (WASD/Space/Ctrl/Shift/etc). + * Не блокирует Esc/Tab/Enter (нужны для GUI). + * Также сбрасывает накопленные клавиши чтобы движение остановилось. */ + setInputBlocked(blocked) { + this._inputBlocked = !!blocked; + if (this._inputBlocked) { + try { this._codes?.clear(); } catch (e) {} + this._shift = false; + // Снимаем pointer-lock — иначе мышь застрянет «в режиме игры» + try { + if (document.pointerLockElement === this.canvas) document.exitPointerLock(); + } catch (e) {} + } + } + isInputBlocked() { return !!this._inputBlocked; } + + /** Задача 04: замораживает камеру (не вращается мышью, не зумится колесом). */ + setCameraFrozen(frozen) { + this._cameraFrozen = !!frozen; + } + isCameraFrozen() { return !!this._cameraFrozen; } + + /** Задача 04: снимок состояния камеры — для восстановления после модала. */ + captureCameraState() { + return { + yaw: this._yaw, + pitch: this._pitch, + cameraMode: this._cameraMode, + thirdDistance: this._thirdDistance, + fov: this.scene?.activeCamera?.fov, + playerPos: this._pos ? { + x: this._pos.x, y: this._pos.y, z: this._pos.z + } : null, + }; + } + + /** Задача 04: восстановить состояние камеры из снимка. */ + restoreCameraState(s) { + if (!s) return; + if (Number.isFinite(s.yaw)) this._yaw = s.yaw; + if (Number.isFinite(s.pitch)) this._pitch = s.pitch; + if (s.cameraMode) { + this._cameraMode = s.cameraMode; + try { this._applyCameraMode?.(); } catch (e) {} + } + if (Number.isFinite(s.thirdDistance)) this._thirdDistance = s.thirdDistance; + if (Number.isFinite(s.fov) && this.scene?.activeCamera) { + this.scene.activeCamera.fov = s.fov; + } + } + + /** Задача 04: камера-фокус на reference (cube/npc/cam-target). + * ref может быть: строка-id, ref-объект ({kind, id}), Mesh, или {x,y,z}. + * Использует уже существующий механизм camera.focus в GameRuntime, но + * здесь упрощённая обёртка: ставим yaw/pitch так, чтобы смотреть на цель, + * и зум на distance. */ + focusOnTarget(ref, opts) { + opts = opts || {}; + const distance = Number.isFinite(opts.distance) ? opts.distance : 8; + const height = Number.isFinite(opts.height) ? opts.height : 3; + const fov = Number.isFinite(opts.fov) ? opts.fov : null; + let target = null; + if (ref && typeof ref === 'object' && 'x' in ref && 'y' in ref && 'z' in ref) { + target = ref; + } else { + const m = this._resolveTargetMesh(ref); + if (m) { + const p = m.getAbsolutePosition?.() || m.position; + target = { x: p.x, y: p.y, z: p.z }; + } + } + if (!target) return; + // Прицельный взгляд: позиция камеры за игроком на distance, направление — на target + // Но мы во freezeCamera режиме — лучше через прямой override yaw/pitch. + if (!this._pos) return; + const dx = target.x - this._pos.x; + const dz = target.z - this._pos.z; + const dy = target.y - this._pos.y; + const horiz = Math.hypot(dx, dz); + this._yaw = Math.atan2(dx, dz); + this._pitch = Math.max(-1.4, Math.min(1.4, -Math.atan2(dy, horiz))); + this._thirdDistance = distance; + if (this._cameraMode !== 'third') { + this._cameraMode = 'third'; + try { this._applyCameraMode?.(); } catch (e) {} + } + if (fov && this.scene?.activeCamera) { + this.scene.activeCamera.fov = fov * Math.PI / 180; + } + } + + _resolveTargetMesh(ref) { + if (!ref) return null; + if (ref.getScene && typeof ref.getScene === 'function') return ref; + const sc = this._scene3d || this.scene3d; + const idStr = (typeof ref === 'string') ? ref : (ref.id || ref.refId || null); + if (!idStr || !sc) return null; + const tries = [ + () => sc.primitiveManager?.getMesh?.(idStr), + () => sc.modelManager?.getInstanceMeshes?.(idStr)?.[0], + () => sc.scene?.getMeshByName?.(idStr), + () => sc.npcManager?.getMeshes?.(idStr)?.[0], + ]; + for (const fn of tries) { + try { const r = fn(); if (r) return r; } catch (e) {} + } + return null; + } + + /** Прямо установить дистанцию камеры (для third). Кламп в min/max. */ + setCameraZoom(distance) { + const d = Number(distance); + if (!Number.isFinite(d)) return; + this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN, + Math.min(this.THIRD_DISTANCE_MAX, d)); + // Авто-переход third↔first если пересекли порог + if (this._thirdDistance <= this.FIRST_PERSON_ZOOM_THRESHOLD + && this._cameraMode === 'third') { + this._cameraMode = 'first'; + this._applyCameraMode?.(); + this._requestPointerLockSafe(); + } else if (this._thirdDistance > this.FIRST_PERSON_ZOOM_THRESHOLD + && this._cameraMode === 'first' && !this._lockFirstPerson) { + this._cameraMode = 'third'; + this._applyCameraMode?.(); + if (!this._shiftLock && document.pointerLockElement === this.canvas) { + try { document.exitPointerLock(); } catch (e) {} + } + } + } + /** Установить границы зума колеса. */ + setCameraZoomLimits(min, max) { + const mn = Number(min), mx = Number(max); + if (Number.isFinite(mn) && mn >= 0) this.THIRD_DISTANCE_MIN = mn; + if (Number.isFinite(mx) && mx > 0) this.THIRD_DISTANCE_MAX = mx; + // Перекламп текущей дистанции + this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN, + Math.min(this.THIRD_DISTANCE_MAX, this._thirdDistance)); + } + /** Поведение мыши: default / lockcenter / lockcurrent. + * default — свободный курсор (стандартный browser cursor). + * lockcenter — pointer-lock (курсор скрыт, mousemove даёт movementX/Y). + * lockcurrent — pointer-lock, но без скрытия (визуально как default, + * реально движение отслеживается через movementX/Y). + */ + setMouseBehavior(mode) { + if (mode !== 'default' && mode !== 'lockcenter' && mode !== 'lockcurrent') return; + this._mouseBehavior = mode; + if (mode === 'default') { + // Снимаем lock если ничто другое не требует его + const needPermLock = ( + this._cameraMode === 'first' || + this._cameraMode === 'lockfirst' || + this._cameraMode === 'sideview' || + this._shiftLock + ); + if (!needPermLock && document.pointerLockElement === this.canvas) { + try { document.exitPointerLock(); } catch (e) {} + } + } else { + this._requestPointerLockSafe(); + } + this._applyCursorVisibility?.(); + } + /** Видимость курсора (для third без lock). */ + setMouseIconVisible(visible) { + this._mouseIconVisible = !!visible; + this._applyCursorVisibility?.(); } _setupInput() { const canvas = this.canvas; const onCanvasClick = () => { - // В UI-режиме клик по канвасу НЕ перехватывает мышь + // В UI-режиме клик не перехватывает мышь. if (this._uiCursorMode) return; - if (this._active && document.pointerLockElement !== canvas) { + if (!this._active) return; + // Roblox-style: в third-person ЛКМ-клик НЕ должен лочить курсор — + // курсор остаётся свободным для GUI/3D-onClick. Lock запрашиваем + // ТОЛЬКО для режимов где курсор постоянно скрыт (first/lockfirst/ + // sideview/shiftLock), и только если по какой-то причине lock сняли + // (например, юзер нажал Esc в first-режиме — надо вернуть lock). + const needPermLock = ( + this._cameraMode === 'first' || + this._cameraMode === 'lockfirst' || + this._cameraMode === 'sideview' || + this._shiftLock + ); + if (!needPermLock) return; + if (document.pointerLockElement !== canvas) { try { const p = canvas.requestPointerLock?.(); if (p && typeof p.catch === 'function') p.catch(() => {}); @@ -1504,6 +1968,54 @@ export class PlayerController { }; canvas.addEventListener('click', onCanvasClick); + // === ПКМ: в third-person удержание ПКМ запускает orbit-камеру === + // Roblox-style: зажал ПКМ → курсор скрыт, мышь крутит камеру. + // Отпустил → курсор вернулся на ту же позицию (браузер сам ставит). + const onCanvasMouseDownGlobal = (e) => { + if (!this._active || this._uiCursorMode) return; + if (e.button !== 2) return; // только ПКМ + // В режимах с постоянным lock'ом ПКМ ничего не делает + const needPermLock = ( + this._cameraMode === 'first' || + this._cameraMode === 'lockfirst' || + this._cameraMode === 'sideview' || + this._shiftLock + ); + if (needPermLock) return; + // Запрашиваем lock — теперь mouseMove будет крутить камеру. + this._rmbHeld = true; + if (document.pointerLockElement !== canvas) { + try { + const p = canvas.requestPointerLock?.(); + if (p && typeof p.catch === 'function') p.catch(() => {}); + } catch (err) { /* ignore */ } + } + e.preventDefault(); + }; + const onWindowMouseUpGlobal = (e) => { + if (e.button !== 2) return; + if (!this._rmbHeld) return; + this._rmbHeld = false; + // Отпускаем lock только если он был включён нами для orbit-камеры + // (т.е. сейчас НЕ режим с постоянным lock). + const needPermLock = ( + this._cameraMode === 'first' || + this._cameraMode === 'lockfirst' || + this._cameraMode === 'sideview' || + this._shiftLock + ); + if (needPermLock) return; + if (document.pointerLockElement === canvas) { + try { document.exitPointerLock(); } catch (err) { /* ignore */ } + } + }; + canvas.addEventListener('mousedown', onCanvasMouseDownGlobal); + window.addEventListener('mouseup', onWindowMouseUpGlobal); + // Подавляем контекстное меню браузера на canvas (ПКМ — наш orbit-trigger). + canvas.addEventListener('contextmenu', (e) => { + if (this._active) e.preventDefault(); + }); + // === UI-режим: mousedown / mouseup → callback (для drag-игр) === const onCanvasMouseDown = (e) => { if (!this._uiCursorMode) return; @@ -1543,6 +2055,8 @@ export class PlayerController { if (document.pointerLockElement !== canvas) return; // Кубикон Dash: в sideview мышь не вращает камеру. if (this._cameraMode === 'sideview') return; + // Задача 04: модал с freezeCamera — мышь не вращает. + if (this._cameraFrozen) return; this._yaw += e.movementX * this.MOUSE_SENSITIVITY; this._pitch += e.movementY * this.MOUSE_SENSITIVITY; const lim = Math.PI / 2 - 0.05; @@ -1551,13 +2065,46 @@ export class PlayerController { }; document.addEventListener('mousemove', onMouseMove); - // Колесо в 3rd-person — меняет дистанцию + // Колесо: zoom в third + авто-переключение third ↔ first. + // Roblox-style: дистанция ≤ FIRST_PERSON_ZOOM_THRESHOLD → first-person + // (с pointer-lock). Колесо наружу из first → возврат в third. const onWheel = (e) => { if (!this._active) return; + if (this._cameraMode === 'sideview') return; + // Задача 04: модал с freezeCamera — колесо не зумит. + if (this._cameraFrozen) { e.preventDefault(); return; } + // В first-режиме колесо вверх НЕ работает (если lockfirst), вниз + // выходит обратно в third (если zoomable first, не lockfirst). + if (this._cameraMode === 'first') { + if (this._lockFirstPerson) { e.preventDefault(); return; } + if (e.deltaY > 0) { + // Колесо вниз → отдалить → переход в third + this._cameraMode = 'third'; + this._thirdDistance = this.FIRST_PERSON_ZOOM_THRESHOLD + 0.5; + this._applyCameraMode?.(); + // Снять pointer-lock — в third без shift-lock курсор виден + if (!this._shiftLock && document.pointerLockElement === canvas) { + try { document.exitPointerLock(); } catch (err) {} + } + } + e.preventDefault(); + return; + } if (this._cameraMode !== 'third') return; - this._thirdDistance += Math.sign(e.deltaY) * 0.5; + // Шаг зума — пропорционален текущей дистанции (экспоненциальный фил) + const step = Math.max(0.3, this._thirdDistance * 0.15); + this._thirdDistance += Math.sign(e.deltaY) * step; if (this._thirdDistance < this.THIRD_DISTANCE_MIN) this._thirdDistance = this.THIRD_DISTANCE_MIN; if (this._thirdDistance > this.THIRD_DISTANCE_MAX) this._thirdDistance = this.THIRD_DISTANCE_MAX; + // Авто-переход в first при близком зуме (Roblox-style) + if (this._thirdDistance <= this.FIRST_PERSON_ZOOM_THRESHOLD) { + this._cameraMode = 'first'; + this._applyCameraMode?.(); + // Запросить pointer-lock — first всегда залочен + if (!this._shiftLock && document.pointerLockElement !== canvas) { + this._requestPointerLockSafe(); + } + } e.preventDefault(); }; canvas.addEventListener('wheel', onWheel, { passive: false }); @@ -1567,10 +2114,31 @@ export class PlayerController { const locked = document.pointerLockElement === canvas; if (locked) { wasLocked = true; + this._rmbHeld = true; // если попал в lock — ПКМ удерживается } else if (wasLocked && this._active) { - // Если мы САМИ переключились в UI-cursor mode — не выходим из Play - if (this._uiCursorMode) return; - if (this._onExitRequest) this._onExitRequest(); + // pointer-lock снят. Причин три: + // 1) пользователь сам в UI-режиме (game.input.setCursorMode('ui')) + // 2) ПКМ отпущена в third-person (orbit-камера завершена) + // 3) Esc → выход из Play (если был в first/lockfirst/sideview) + this._rmbHeld = false; + if (this._uiCursorMode) { + this._applyCursorVisibility(); + return; + } + const needPermLock = ( + this._cameraMode === 'first' || + this._cameraMode === 'lockfirst' || + this._cameraMode === 'sideview' || + this._shiftLock + ); + if (needPermLock) { + // Был режим с постоянным lock'ом и его сняли → Esc → выход + if (this._onExitRequest) this._onExitRequest(); + } else { + // Third-person: пользователь просто отпустил ПКМ. Курсор + // возвращается там же где был — это нормально, остаёмся в Play. + this._applyCursorVisibility(); + } } }; document.addEventListener('pointerlockchange', onPointerLockChange); @@ -1584,6 +2152,23 @@ export class PlayerController { const onKeyDown = (e) => { if (!this._active) return; if (isTypingTarget(e.target)) return; + // Задача 04: Esc в Play — приоритет закрытие модала через _onExitRequest + // (там стоит проверка modalManager.isOpen). Без явного перехвата Esc + // в third (без pointer-lock) сразу выходил из Play. + if (e.code === 'Escape') { + if (this._onExitRequest) { + this._onExitRequest(); + return; + } + } + // Задача 04: модал блокирует игровой ввод (WASD/Space/Ctrl/Shift и т.п.), + // но НЕ блокирует Esc (нужен для закрытия модала через Esc-обработчик), + // и НЕ блокирует Tab/Enter (могут понадобиться в GUI-модалах). + if (this._inputBlocked && e.code !== 'Escape' && e.code !== 'Tab' && e.code !== 'Enter') { + // Глотаем preventDefault только для игровых клавиш + if (['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault(); + return; + } this._codes.add(e.code); if (e.shiftKey) this._shift = true; // C — переключение first/third. Отключаем в GD-режиме (автобег > 0) @@ -1593,6 +2178,17 @@ export class PlayerController { || this._shipMode || this._ufoMode || this._waveMode || this._robotMode; if (!inGdMode) this._toggleCameraMode(); } + // L — Shift-Lock (Roblox по умолчанию на Shift, у нас Shift = бег, + // поэтому переназначено на L). Курсор центрируется, корпус всегда + // лицом к камере, камера через плечо. + if (e.code === 'KeyL') { + this.setShiftLock(!this._shiftLock); + } + // B — встроенный магазин скинов (задача 07). Открывается только если + // включён в проекте (scene.skins.shopVisible). Toggle. + if (e.code === 'KeyB' && !this._inputBlocked) { + try { this._scene3d?.toggleSkinShop?.(); } catch (err) {} + } // Tab — переключить «UI-режим курсора» (для кликов по GUI в Play) if (e.code === 'Tab') { e.preventDefault(); @@ -2116,20 +2712,41 @@ export class PlayerController { this._modelYaw += Math.sign(diff) * maxStep; } } else { - const dxReal = this._pos.x - beforeX; - const dzReal = this._pos.z - beforeZ; - const movedHorizontal = Math.abs(dxReal) > 0.001 || Math.abs(dzReal) > 0.001; - if (movedHorizontal) { - const targetYaw = Math.atan2(dxReal, dzReal); + // Roblox-style: в first/lockfirst/shiftLock корпус мгновенно + // следует за yaw камеры (AutoRotate привязан к камере). + // В third — корпус доворачивается под РЕАЛЬНОЕ направление движения. + const followCamera = ( + this._cameraMode === 'first' || + this._cameraMode === 'lockfirst' || + this._shiftLock + ); + if (followCamera) { + const targetYaw = this._yaw; let diff = targetYaw - this._modelYaw; while (diff > Math.PI) diff -= Math.PI * 2; while (diff < -Math.PI) diff += Math.PI * 2; - const maxStep = this.MODEL_TURN_SPEED * dt; + const maxStep = this.MODEL_TURN_SPEED * dt * 3; // быстрее чем при ходьбе if (Math.abs(diff) <= maxStep) { this._modelYaw = targetYaw; } else { this._modelYaw += Math.sign(diff) * maxStep; } + } else { + const dxReal = this._pos.x - beforeX; + const dzReal = this._pos.z - beforeZ; + const movedHorizontal = Math.abs(dxReal) > 0.001 || Math.abs(dzReal) > 0.001; + if (movedHorizontal) { + const targetYaw = Math.atan2(dxReal, dzReal); + let diff = targetYaw - this._modelYaw; + while (diff > Math.PI) diff -= Math.PI * 2; + while (diff < -Math.PI) diff += Math.PI * 2; + const maxStep = this.MODEL_TURN_SPEED * dt; + if (Math.abs(diff) <= maxStep) { + this._modelYaw = targetYaw; + } else { + this._modelYaw += Math.sign(diff) * maxStep; + } + } } } // Применяем yaw + swim-tilt. @@ -2188,6 +2805,17 @@ export class PlayerController { this._tickDebris(dt); // === Анимации === + // Снимок скорости/опоры для процедурной анимации non-humanoid скинов. + this._lastFrameSpeed = (isMoving ? (isSprinting ? this.WALK_SPEED * this.SPRINT_MULT : this.WALK_SPEED) : 0) * (this._speedMul || 1); + this._isGrounded = !!result.onGround; + + // Non-humanoid single-mesh скин: костей нет — анимируем процедурно + // (покачивание/наклон). Делаем ДО R15-ветки, т.к. _isR15=false для них. + if (this._modelKind === 'non-humanoid-mesh' && this._modelRoot) { + this._animateNonHumanoidMesh(dt); + return; + } + // R15-скин: процедурный аниматор (нет glTF AnimationGroups). // Состояния: idle/walk/run/jump/fall. sprint → run. if (this._isR15 && this._r15Animator) { diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js index a03d023..43cdffb 100644 --- a/src/editor/engine/PrimitiveManager.js +++ b/src/editor/engine/PrimitiveManager.js @@ -151,9 +151,10 @@ export class PrimitiveManager { // serialize мог записать их обратно в JSON проекта. const billboardOpts = { template: opts.template || 'shop-item', - face: opts.face || 'camera', + face: opts.face || 'fixed', content: opts.content || null, elements: opts.elements || null, + rotationY: opts.rotationY, }; this.billboardUiManager.applyToMesh(data, billboardOpts); // billboardOpts хранится в data.billboard после applyToMesh. @@ -731,7 +732,11 @@ export class PrimitiveManager { /** Все инстансы как массив (для Hierarchy). */ getAll() { - return Array.from(this.instances.values()).map(d => ({ + return Array.from(this.instances.values()) + // Исключаем скриптовые спавны — они эфемерные и не должны + // попадать в project_data (иначе при каждом Play копятся дубли). + .filter(d => !d._scriptSpawned) + .map(d => ({ id: d.id, type: d.type, x: d.x, y: d.y, z: d.z, sx: d.sx, sy: d.sy, sz: d.sz, diff --git a/src/editor/engine/ScriptSandbox.js b/src/editor/engine/ScriptSandbox.js index 247b5bd..9e56276 100644 --- a/src/editor/engine/ScriptSandbox.js +++ b/src/editor/engine/ScriptSandbox.js @@ -77,7 +77,7 @@ export class ScriptSandbox { _handleMessage(e) { if (this._isStopped) return; const { cmd, payload } = e.data || {}; - if (cmd === 'boot') return; // Worker boot, ничего не делаем + if (cmd === 'boot') return; if (cmd === 'ready') { this._isReady = true; // Доставим pending snapshot'ы (приходили до ready) @@ -97,6 +97,10 @@ export class ScriptSandbox { try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: this._pendingDataSnapshot }); } catch (e) {} this._pendingDataSnapshot = null; } + if (this._pendingSkinsSnapshot) { + try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: this._pendingSkinsSnapshot }); } catch (e) {} + this._pendingSkinsSnapshot = null; + } // Доставим события которые пришли до готовности if (this._pendingEvents.length > 0) { for (const ev of this._pendingEvents) { @@ -175,6 +179,16 @@ export class ScriptSandbox { try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: snapshot }); } catch (e) {} } + /** Задача 07: снапшот скинов — для game.player.getAvailableSkins/getAllSkins. */ + sendSkinsSnapshot(snapshot) { + if (!this.worker) return; + if (!this._isReady) { + this._pendingSkinsSnapshot = snapshot; + return; + } + try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: snapshot }); } catch (e) {} + } + /** * Карта высот гладкого ландшафта — для game.scene.surfaceY(x,z). * Шлётся один раз (террейн не меняется в Play). Формат: diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index 4daf63d..3f6cd1d 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -99,6 +99,14 @@ let _selfUntouchHandlers = []; let _selfInteractHandlers = []; // Зеркало всех GUI-элементов (синхронизируется main через cmd='guiSnapshot') let _guiIndex = []; +// Задача 07: зеркало скинов (синхронизируется через 'skinsSnapshot'). +// _skinsIndex — все встроенные скины [{slug,name,kind,category,price}]. +// _unlockedSkins — slug'и доступные игроку. _currentSkin — активный. +let _skinsIndex = []; +let _unlockedSkins = []; +let _currentSkin = null; +let _skinChangeHandlers = []; +let _skinCoins = 0; // локальная валюта магазина скинов (рублики проекта) // Подписки game.gui.onClick(id, fn) let _guiClickHandlers = {}; // Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter) @@ -112,10 +120,14 @@ let _billboardClickHandlers = {}; // Для GUI-события с реальным id вернуть набор ключей, под которыми // могли быть зарегистрированы handlers: сам id + имя элемента (скрипт // часто подписывается через game.gui.onClick('ИмяКнопки', fn)). -function _guiHandlerKeys(id) { +function _guiHandlerKeys(id, localId) { const keys = [id]; + // localId — ref который вернул gui.create() ('_gui_local_N'); скрипт мог + // подписаться по нему, если не задавал явный id. + if (localId != null && localId !== id) keys.push(localId); + // name элемента — скрипт часто подписывается game.gui.onClick('ИмяКнопки', fn). const el = _guiIndex.find(g => g.id === id); - if (el && el.name && el.name !== id) keys.push(el.name); + if (el && el.name && el.name !== id && !keys.includes(el.name)) keys.push(el.name); return keys; } @@ -918,6 +930,69 @@ const game = { setSkinVisible(visible) { _send('player.setSkinVisible', { visible: !!visible }); }, + /** + * === Задача 07: скины игрока (любая 3D-модель + магазин) === + * Сменить активный скин в Play (без перезагрузки сцены). + * game.player.setSkin('squirrel-donut'); // встроенный + * game.player.setSkin('character-a'); // человек + * Возвращает «локальный Promise» (объект с .then) — реальная смена + * асинхронна (грузится .glb). Для большинства игр можно не ждать. + */ + setSkin(slug) { + if (typeof slug !== 'string' || !slug) return; + _currentSkin = slug; + if (_unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug); + _send('player.setSkin', { slug }); + }, + /** Дать игроку скин (разблокировать — например после покупки). */ + unlockSkin(slug) { + if (typeof slug !== 'string' || !slug) return; + if (_unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug); + _send('player.unlockSkin', { slug }); + }, + /** Список slug'ов скинов, доступных игроку (разблокированных). */ + getAvailableSkins() { + return _unlockedSkins.slice(); + }, + /** Все встроенные скины с метаданными: [{slug,name,kind,category,price}]. */ + getAllSkins() { + return _skinsIndex.map(s => ({ ...s })); + }, + /** Текущий активный скин (slug). */ + getCurrentSkin() { + return _currentSkin; + }, + /** Подписка на смену скина: fn(slug). */ + onSkinChange(fn) { + if (typeof fn === 'function') _skinChangeHandlers.push(fn); + }, + /** Открыть встроенный GUI-магазин скинов (если включён в проекте). */ + openSkinShop() { + _send('player.openSkinShop', {}); + }, + /** Закрыть магазин скинов. */ + closeSkinShop() { + _send('player.closeSkinShop', {}); + }, + /** Игровая валюта магазина скинов (рублики проекта, ЛОКАЛЬНЫЕ — + * не путать с серверной экономикой game.economy). */ + getSkinCoins() { + return _skinCoins; + }, + /** Задать баланс валюты магазина (например стартовые 200). */ + setSkinCoins(amount) { + const n = Number(amount); + if (!Number.isFinite(n)) return; + _skinCoins = Math.max(0, Math.floor(n)); + _send('player.setSkinCoins', { amount: _skinCoins }); + }, + /** Добавить валюту магазина (награда за что-то). */ + addSkinCoins(amount) { + const n = Number(amount); + if (!Number.isFinite(n)) return; + _skinCoins = Math.max(0, _skinCoins + Math.floor(n)); + _send('player.setSkinCoins', { amount: _skinCoins }); + }, /** * Режим камеры: 'first' | 'third' | 'front' | 'sideview'. * 'sideview' нужен для Кубикон Dash — камера фиксируется сбоку, @@ -927,6 +1002,22 @@ const game = { if (typeof mode !== 'string') return; _send('player.setCameraMode', { mode }); }, + /** Задача 02: установить дистанцию камеры (для third-person). */ + setCameraZoom(distance) { + const d = Number(distance); + if (!Number.isFinite(d)) return; + _send('player.setCameraZoom', { distance: d }); + }, + /** Задача 02: границы зума колесом мыши. По умолчанию 0.5..32. */ + setCameraZoomLimits(min, max) { + const mn = Number(min), mx = Number(max); + if (!Number.isFinite(mn) || !Number.isFinite(mx)) return; + _send('player.setCameraZoomLimits', { min: mn, max: mx }); + }, + /** Задача 02: Shift-Lock — курсор в центре, корпус лицом к камере. */ + setShiftLock(on) { + _send('player.setShiftLock', { on: !!on }); + }, /** * Присед: уменьшает высоту хитбокса игрока с 1.8 до 0.9 ед. * Используется чтобы пройти под низким потолком. @@ -1311,6 +1402,11 @@ const game = { if (typeof id !== 'string' || !id) return; _send('ui.set', { id, text: null }); }, + /** Алиас remove. */ + delete(id) { + if (typeof id !== 'string' || !id) return; + _send('ui.set', { id, text: null }); + }, /** Убрать весь HUD. */ clear() { _state.score = null; @@ -2114,6 +2210,32 @@ const game = { if (typeof id !== 'string' || typeof fn !== 'function') return; (_guiSubmitHandlers[id] = _guiSubmitHandlers[id] || []).push(fn); }, + /** Задача 03: tween свойства GUI-элемента. + * props: { x, y, w, h, rotation, scaleX, scaleY, bgOpacity, textSize, + * bgColor, textColor, borderColor } (любое числовое или hex-цвет). + * opts: { duration, easing, delay, repeat, reverses, onDone } */ + tween(id, props, opts) { + if (typeof id !== 'string' || !id) return null; + if (!props || typeof props !== 'object') return null; + opts = opts || {}; + const tid = ++_tweenSeq; + if (typeof opts.onDone === 'function') _tweenCallbacks[tid] = opts.onDone; + _send('gui.tween', { + tweenId: tid, id, props, + duration: Number.isFinite(Number(opts.duration)) ? Number(opts.duration) : 0.5, + easing: typeof opts.easing === 'string' ? opts.easing : 'ease', + delay: Number.isFinite(Number(opts.delay)) ? Number(opts.delay) : 0, + repeat: Number.isFinite(Number(opts.repeat)) ? Number(opts.repeat) : 0, + reverses: !!opts.reverses, + }); + return tid; + }, + /** Отменить tween по id (возвращённому из game.gui.tween). */ + cancelTween(tweenId) { + if (!Number.isFinite(tweenId)) return; + _send('gui.cancelTween', { tweenId }); + delete _tweenCallbacks[tweenId]; + }, }, /** * Камера — тряска, FOV, привязка к объекту, катсцены (Фаза 5.7). @@ -2187,6 +2309,283 @@ const game = { setVisible(visible) { _send('hud.setVisible', { visible: !!visible }); }, + /** Скрыть/показать только хотбар (5 слотов инвентаря снизу). + * В играх где инвентарь не нужен (магазин/головоломка/симулятор кликера). */ + setHotbarVisible(visible) { + _send('hud.setHotbarVisible', { visible: !!visible }); + }, + /** Скрыть/показать только HP-индикатор (полоска жизней слева сверху). */ + setHpVisible(visible) { + _send('hud.setHpVisible', { visible: !!visible }); + }, + }, + /** + * Задача 04: модальные сцены (затемнение + GUI поверх + блок ввода). + * + * Типовой кейс: boss-fight intro, открытие лутбокса, диалог с NPC, получил питомца. + * + * const m = game.modal.open({ + * darken: 0.7, // 0..1 — насколько затемнить (по умолчанию 0.5) + * darkenColor: '#000', // цвет затемнения + * target: 'scene', // 'scene' (HUD виден) | 'screen' (всё затемнено) + * blockInput: true, // блокирует WASD/Space/Ctrl (Esc/Tab/Enter работают) + * freezeCamera: true, // камера замирает + * fadeIn: 0.4, // секунды до полного затемнения + * fadeOut: 0.3, + * spotlights: [boss, h1, h2], // 3D-рефы, которые остаются яркими (вырезка mask) + * spotlightRadius: 120, // пиксели — радиус «прожектора» + * pauseSimulation: false, // ставит весь GameRuntime на паузу (мобы замирают) + * muteWorld: false, // приглушает ambient/sfx + * cameraOverride: { // фокус камеры на цель + * target: boss, distance: 8, height: 3, fov: 60, duration: 0.5, + * }, + * content: { elements: [ // временные GUI поверх модала, удалятся при close + * { kind: 'text', x: 50, y: 25, text: 'Франкенслим', textSize: 48, + * textStroke: { color: '#000', width: 3 }, textColor: '#fff' }, + * { kind: 'button', id: 'fight', x: 50, y: 80, w: 20, h: 6, text: 'В бой!' }, + * ]}, + * }); + * game.gui.onClick('fight', () => game.modal.close(m)); + * + * Готовые пресеты: + * game.modal.bossIntro(name, hp, refs) — intro босса с HP-баром + * game.modal.lootbox(items, onPick) — открытие лутбокса + * game.modal.dialog(npcName, lines, onDone) — диалог с NPC построчно + * game.modal.confirmation(title, body, onYes, onNo) — Да/Нет + * + * Только ОДИН модал одновременно. Повторный open мгновенно закрывает предыдущий. + */ + modal: { + _localSeq: 0, + _localToReal: new Map(), // localId → реальный modalId (приходит в modalOpened) + _onCloseFns: [], + open(opts) { + opts = opts || {}; + const localId = ++this._localSeq; + const replyId = '_mopen_' + localId; + _send('modal.open', { opts, replyId }); + // Возвращаем локальный id — реальный придёт асинхронно через modalOpened-event + return localId; + }, + close(modalId) { + // Резолвим локальный id → реальный. Если modalId — локальное число, но + // реальный ещё не пришёл (гонка modalOpened-event), шлём null: модал + // одиночный, null закрывает активный. Передавать локальный id нельзя — + // ModalManager.close сверяет его со своим _state.id и молча игнорит. + let real = null; + if (typeof modalId === 'number') { + real = this._localToReal.has(modalId) ? this._localToReal.get(modalId) : null; + } else if (modalId != null) { + real = modalId; // уже реальный id (строка/число от runtime) + } + _send('modal.close', { modalId: real }); + }, + update(modalId, patch) { + let real = null; + if (typeof modalId === 'number') { + real = this._localToReal.has(modalId) ? this._localToReal.get(modalId) : null; + } else if (modalId != null) { + real = modalId; + } + _send('modal.update', { modalId: real, patch: patch || {} }); + }, + isOpen() { return !!this._isOpenLocal; }, + onClose(fn) { + if (typeof fn === 'function') this._onCloseFns.push(fn); + }, + + // === Пресеты === + /** Intro босса с именем + HP-баром + кнопкой «В бой!» через delay секунд. */ + bossIntro(name, hp, refs, opts) { + opts = opts || {}; + const startBtnDelay = Number.isFinite(opts.startBtnDelay) ? opts.startBtnDelay : 2; + const buttonText = opts.buttonText || 'В бой!'; + const onStart = opts.onStart; + const elements = [ + { kind: 'text', id: '_boss_name', x: 50, y: 22, w: 60, h: 8, anchor: 'center', + text: String(name || 'Босс'), textSize: 48, fontWeight: 900, textColor: '#ffffff', + textStroke: { color: '#000', width: 3 }, bgColor: 'transparent', bgOpacity: 0, + animationPreset: 'glow' }, + { kind: 'text', id: '_boss_hp', x: 50, y: 30, w: 30, h: 6, anchor: 'center', + text: '❤ ' + hp + ' / ' + hp, textSize: 28, fontWeight: 800, textColor: '#22ff66', + textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0 }, + ]; + const m = this.open({ + darken: 0.7, target: 'scene', + blockInput: true, freezeCamera: true, + spotlights: Array.isArray(refs) ? refs : (refs ? [refs] : []), + cameraOverride: refs ? { target: Array.isArray(refs) ? refs[0] : refs, + distance: 8, height: 3, fov: 60, duration: 0.5 } : null, + content: { elements }, + }); + const _modal = this; + const _afterTid = ++_timerSeq; + _timers.push({ id: _afterTid, fn: () => { + _send('gui.create', { type: 'button', opts: { + id: '_boss_start', x: 50, y: 78, w: 20, h: 7, anchor: 'center', + text: buttonText, + bgGradient: { stops: ['#ff5454','#a00000'], angle: 90 }, + borderColor: '#000', borderWidth: 3, borderRadius: 14, + textColor: '#fff', textSize: 22, fontWeight: 900, + textStroke: { color: '#000', width: 2 }, + hover: { scale: 1.08, brightness: 1.2, duration: 0.15 }, + active: { scale: 0.94, duration: 0.08 }, + animationPreset: 'pulse', + }, localRef: '_boss_start' }); + let _started = false; + _guiClickHandlers['_boss_start'] = [() => { + if (_started) return; + _started = true; + delete _guiClickHandlers['_boss_start']; + _modal.close(m); + if (typeof onStart === 'function') { try { onStart(); } catch (e) {} } + }]; + }, delay: startBtnDelay, elapsed: 0, repeat: false }); + return m; + }, + /** Открытие лутбокса. items: [{name, color, icon, rarity}]. onPick(item). */ + lootbox(items, onPick) { + items = Array.isArray(items) ? items.slice(0, 5) : []; + const elements = [ + { kind: 'frame', id: '_lb_bg', x: 50, y: 50, w: 70, h: 50, anchor: 'center', + bgGradient: { stops: ['#1a1a3a','#0a0a1a'], angle: 135 }, + borderColor: '#ffd700', borderWidth: 3, borderRadius: 18 }, + { kind: 'text', id: '_lb_title', x: 50, y: 28, w: 60, h: 8, anchor: 'center', + text: '✨ Лутбокс ✨', textSize: 32, fontWeight: 900, textColor: '#ffd700', + textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0, + animationPreset: 'glow' }, + ]; + for (let i = 0; i < items.length; i++) { + const it = items[i]; + const x = 50 + (i - (items.length - 1) / 2) * 13; + elements.push({ + kind: 'button', id: '_lb_item_' + i, + x: x, y: 50, w: 11, h: 16, anchor: 'center', + text: (it.icon || '*') + '\\n' + (it.name || 'Приз'), + bgColor: it.color || '#3a3a5a', borderRadius: 12, + borderColor: '#ffd700', borderWidth: 2, + textColor: '#fff', textSize: 14, fontWeight: 700, + hover: { scale: 1.1, brightness: 1.3, duration: 0.15 }, + active: { scale: 0.94, duration: 0.08 }, + animationPreset: 'pulse', + }); + } + const m = this.open({ + darken: 0.6, target: 'screen', blockInput: true, + content: { elements }, + }); + const _modal = this; + // _picked: после первого выбора остальные карточки не должны срабатывать, + // пока модал доигрывает fadeOut (иначе onPick зовётся несколько раз). + let _picked = false; + for (let i = 0; i < items.length; i++) { + const id = '_lb_item_' + i; + const it = items[i]; + _guiClickHandlers[id] = [() => { + if (_picked) return; + _picked = true; + for (let j = 0; j < items.length; j++) delete _guiClickHandlers['_lb_item_' + j]; + _modal.close(m); + if (typeof onPick === 'function') { try { onPick(it); } catch (e) {} } + }]; + } + return m; + }, + /** Диалог с NPC построчно. lines: ['Привет', 'Как дела?']. onDone() в конце. */ + dialog(npcName, lines, onDone) { + lines = Array.isArray(lines) ? lines : [String(lines || '')]; + let idx = 0; + const elements = [ + { kind: 'frame', id: '_dlg_bg', x: 50, y: 80, w: 80, h: 25, anchor: 'center', + bgGradient: { stops: ['#1a1a3a','#0a0a1a'], angle: 90 }, + borderColor: '#fff', borderWidth: 2, borderRadius: 12 }, + { kind: 'text', id: '_dlg_name', x: 14, y: 71, w: 22, h: 6, anchor: 'center', + text: String(npcName || 'NPC'), textSize: 20, fontWeight: 900, + textColor: '#ffd700', textStroke: { color: '#000', width: 2 }, + bgColor: 'transparent', bgOpacity: 0 }, + { kind: 'text', id: '_dlg_line', x: 50, y: 80, w: 76, h: 12, anchor: 'center', + text: lines[0], textSize: 22, fontWeight: 600, textColor: '#fff', + textAlign: 'left', bgColor: 'transparent', bgOpacity: 0 }, + { kind: 'button', id: '_dlg_next', x: 88, y: 88, w: 8, h: 6, anchor: 'center', + // На ПОСЛЕДНЕЙ строке кнопка сразу показывает галочку «завершить», + // на остальных — стрелку «дальше». + text: lines.length > 1 ? '▶' : '✓', textSize: 22, fontWeight: 900, + bgColor: '#ffd700', textColor: '#000', borderRadius: 8, + borderColor: '#000', borderWidth: 2, + hover: { scale: 1.1, brightness: 1.2 }, active: { scale: 0.92 }, + animationPreset: 'pulse' }, + ]; + const m = this.open({ + darken: 0.5, target: 'scene', blockInput: true, freezeCamera: true, + content: { elements }, + }); + const _modal = this; + // _done защищает от повторного срабатывания: game.modal.close() доигрывает + // fadeOut асинхронно, кнопка остаётся кликабельной — без guard'а каждый + // лишний клик снова звал onDone (баг «Диалог завершён ×7»). + let _done = false; + _guiClickHandlers['_dlg_next'] = [() => { + if (_done) return; + idx++; + if (idx < lines.length) { + _send('gui.update', { id: '_dlg_line', patch: { text: lines[idx] } }); + // Последняя строка достигнута — превращаем «дальше» в «завершить». + if (idx === lines.length - 1) { + _send('gui.update', { id: '_dlg_next', patch: { text: '✓' } }); + } + } else { + _done = true; + delete _guiClickHandlers['_dlg_next']; // снимаем handler сразу + _modal.close(m); + if (typeof onDone === 'function') { try { onDone(); } catch (e) {} } + } + }]; + return m; + }, + /** Подтверждение Да/Нет. */ + confirmation(title, body, onYes, onNo) { + const elements = [ + { kind: 'frame', id: '_cf_bg', x: 50, y: 50, w: 50, h: 30, anchor: 'center', + bgGradient: { stops: ['#2a2a4a','#0a0a1a'], angle: 135 }, + borderColor: '#fff', borderWidth: 2, borderRadius: 14 }, + { kind: 'text', id: '_cf_title', x: 50, y: 41, w: 44, h: 6, anchor: 'center', + text: String(title || 'Подтверждение'), textSize: 28, fontWeight: 900, + textColor: '#fff', textStroke: { color: '#000', width: 2 }, + bgColor: 'transparent', bgOpacity: 0 }, + { kind: 'text', id: '_cf_body', x: 50, y: 50, w: 44, h: 8, anchor: 'center', + text: String(body || ''), textSize: 16, fontWeight: 500, + textColor: '#cfd0e8', bgColor: 'transparent', bgOpacity: 0 }, + { kind: 'button', id: '_cf_yes', x: 38, y: 60, w: 14, h: 6, anchor: 'center', + text: 'Да', bgGradient: { stops: ['#22ff66','#0a803a'], angle: 90 }, + borderColor: '#000', borderWidth: 2, borderRadius: 10, + textColor: '#fff', textSize: 18, fontWeight: 900, + hover: { scale: 1.08, brightness: 1.2 }, active: { scale: 0.94 } }, + { kind: 'button', id: '_cf_no', x: 62, y: 60, w: 14, h: 6, anchor: 'center', + text: 'Нет', bgGradient: { stops: ['#ff5454','#a00000'], angle: 90 }, + borderColor: '#000', borderWidth: 2, borderRadius: 10, + textColor: '#fff', textSize: 18, fontWeight: 900, + hover: { scale: 1.08, brightness: 1.2 }, active: { scale: 0.94 } }, + ]; + const m = this.open({ + darken: 0.6, target: 'screen', blockInput: true, + content: { elements }, + }); + const _modal = this; + // _answered: одна кнопка снимает обе (Да/Нет) сразу, чтобы пока модал + // доигрывает fadeOut нельзя было нажать вторую и продублировать ответ. + let _answered = false; + const _finish = (cb) => { + if (_answered) return; + _answered = true; + delete _guiClickHandlers['_cf_yes']; + delete _guiClickHandlers['_cf_no']; + _modal.close(m); + if (typeof cb === 'function') { try { cb(); } catch (e) {} } + }; + _guiClickHandlers['_cf_yes'] = [() => _finish(onYes)]; + _guiClickHandlers['_cf_no'] = [() => _finish(onNo)]; + return m; + }, }, /** * Инвентарь игрока (Фаза 4.2) — 5 слотов hot-bar. @@ -2694,17 +3093,38 @@ const game = { * opts — { template?, face?, content?, elements? } */ set(ref, opts) { - if (!ref || typeof opts !== 'object' || opts == null) return; - _send('billboard.set', { ref, ...opts }); + const refStr = _normRef(ref); + if (!refStr || typeof opts !== 'object' || opts == null) return; + _send('billboard.set', { ref: refStr, ...opts }); }, /** - * Частичное обновление content. Самое частое — после клика поменять - * sub-строку и цену. - * patch — частичный content: { sub, price, title, icon, gradient } + * Частичное обновление таблички. + * Две формы: + * 1) update(ref, patch) + * patch — частичный content: { sub, price, title, icon, gradient } + * Применяется к content пресета (shop-item/banner/sign). + * 2) update(ref, elementId, patch) + * Обновляет конкретный элемент по id (только для template:'card' + * или elements-режима). Например: update(ref, 'buy', { text: '$15,000' }). + * Для пресетов: 'title'|'sub'|'price'|'icon'|'gradient' тоже + * работают как ключи content. */ - update(ref, patch) { - if (!ref || typeof patch !== 'object' || patch == null) return; - _send('billboard.update', { ref, patch }); + update(ref, secondArg, thirdArg) { + const refStr = _normRef(ref); + if (!refStr) return; + // 3-аргументная форма: update(ref, elementId, patch) + if (typeof secondArg === 'string' && typeof thirdArg === 'object' && thirdArg !== null) { + _send('billboard.update', { + ref: refStr, + elementId: secondArg, + patch: thirdArg, + }); + return; + } + // 2-аргументная форма: update(ref, patch) + if (typeof secondArg === 'object' && secondArg !== null) { + _send('billboard.update', { ref: refStr, patch: secondArg }); + } }, /** * Подписаться на клик по кнопке таблички (shop-item: buttonId='buy'; @@ -2715,18 +3135,18 @@ const game = { */ onClick(ref, buttonId, fn) { if (typeof fn !== 'function') { - // Поддержка вызова с 2 аргументами — buttonId по умолчанию 'buy'. fn = buttonId; buttonId = 'buy'; } - if (!ref || typeof fn !== 'function') return; + // Принудительная нормализация ref в plain-string: Instance-Proxy + // не сериализуется через postMessage (DataCloneError). + const refStr = _normRef(ref); + if (!refStr || typeof fn !== 'function') return; const bid = String(buttonId || 'buy'); - const key = ref + ':' + bid; + const key = refStr + ':' + bid; if (!_billboardClickHandlers[key]) _billboardClickHandlers[key] = []; _billboardClickHandlers[key].push(fn); - // Уведомляем main о подписке (чтобы он зарегистрировал hit-listener - // в BillboardUiManager и слал нам billboardClick события). - _send('billboard.onClick', { ref, buttonId: bid }); + _send('billboard.onClick', { ref: refStr, buttonId: bid }); }, }, /** @@ -2743,6 +3163,19 @@ const game = { if (mode !== 'ui' && mode !== 'game') return; _send('input.setCursorMode', { mode }); }, + /** Задача 02: Roblox-style MouseBehavior. + * 'default' — свободный курсор (по умолчанию в third-person). + * 'lockcenter' — pointer-lock (мышь крутит камеру, курсор скрыт). + * 'lockcurrent' — pointer-lock без скрытия курсора (визуально-default). + */ + setMouseBehavior(mode) { + if (mode !== 'default' && mode !== 'lockcenter' && mode !== 'lockcurrent') return; + _send('input.setMouseBehavior', { mode }); + }, + /** Задача 02: показать/скрыть иконку курсора. */ + setMouseIconVisible(visible) { + _send('input.setMouseIconVisible', { visible: !!visible }); + }, /** * Подписаться на движение мыши в UI-режиме. * fn(x, y) — нормализованные координаты [0..1] относительно канваса. @@ -2791,6 +3224,25 @@ const game = { * game.save.merge('progress', { increment: { attempts: 1 } }); * game.save.leaderboard('progress', 'gold', 'desc', fn(entries) {...}); */ + /** Окружение: небо, туман, время суток. */ + environment: { + /** Установить цвет неба (clearColor сцены). hex или 'r,g,b'. */ + setSkyColor(color) { + if (typeof color !== 'string') return; + _send('environment.setSkyColor', { color }); + }, + /** Установить туман: {enabled, color, density}. */ + setFog(opts) { + if (typeof opts !== 'object' || !opts) return; + _send('environment.setFog', opts); + }, + /** Установить время суток (часы, 0..24). */ + setTimeOfDay(hours) { + const h = Number(hours); + if (!Number.isFinite(h)) return; + _send('environment.setTimeOfDay', { hours: h }); + }, + }, save: { /** Прочитать namespace. fn(data) — data это сохранённый объект или null. */ get(namespace, fn) { @@ -3013,14 +3465,10 @@ self.onmessage = (e) => { if (payload.selfPosition) _selfPosition = payload.selfPosition; _selfApi = _buildSelfApi(); } - // modules: { 'имя': 'код' } — все скрипты проекта, доступные через game.require if (payload && payload.modules && typeof payload.modules === 'object') { _moduleCode = payload.modules; } try { - // exports передаём всегда — скрипт может быть и модулем (пишет в - // exports), и обычным скриптом (игнорирует его). Без этого - // скрипт-модуль падает с 'exports is not defined' при прямом запуске. const exportsObj = {}; const userFn = new Function('game', 'exports', '"use strict";\\n' + payload.code); userFn(game, exportsObj); @@ -3259,17 +3707,27 @@ self.onmessage = (e) => { for (const fn of arr) _safeCall(fn, payload.data, 'onMessage:' + name); } else if (t === 'guiClick') { const id = String(payload.id || ''); - // Собираем handlers и по id, и по имени элемента — скрипт - // мог подписаться через game.gui.onClick('ИмяКнопки', fn). - for (const key of _guiHandlerKeys(id)) { - const arr = _guiClickHandlers[key] || []; + const localId = payload.localId != null ? String(payload.localId) : null; + // Собираем handlers по id, по локальному ref и по имени элемента — + // скрипт мог подписаться любым из этих ключей. + // _matched защищает от двойного вызова если несколько ключей ведут + // к одному и тому же массиву handlers. + const _matched = new Set(); + for (const key of _guiHandlerKeys(id, localId)) { + const arr = _guiClickHandlers[key]; + if (!arr || _matched.has(arr)) continue; + _matched.add(arr); for (const fn of arr) _safeCall(fn, { id }, 'gui.onClick:' + key); } } else if (t === 'guiSubmit') { const id = String(payload.id || ''); + const localId = payload.localId != null ? String(payload.localId) : null; const val = payload.value != null ? String(payload.value) : ''; - for (const key of _guiHandlerKeys(id)) { - const arr = _guiSubmitHandlers[key] || []; + const _matched = new Set(); + for (const key of _guiHandlerKeys(id, localId)) { + const arr = _guiSubmitHandlers[key]; + if (!arr || _matched.has(arr)) continue; + _matched.add(arr); for (const fn of arr) _safeCall(fn, val, 'gui.onSubmit:' + key); } } else if (t === 'billboardClick') { @@ -3291,6 +3749,41 @@ self.onmessage = (e) => { for (const fn of arr) _safeCall(fn, { ref: realRef, button }, 'billboard.onClick:' + key); } + } else if (t === 'modalOpened') { + // Задача 04: реальный modalId от runtime. worker сразу вернул скрипту + // локальный id (чтобы он мог его сохранить и звать close/update); здесь + // запоминаем маппинг local→real, иначе close(m) уходит с локальным id + // и ModalManager.close его не узнаёт (баг «закрывается только по Esc»). + try { + const mm = (typeof game !== 'undefined') && game.modal; + if (mm && payload && payload.replyId) { + const localId = Number(String(payload.replyId).replace(/^_mopen_/, '')); + if (Number.isFinite(localId) && payload.modalId != null) { + mm._localToReal.set(localId, payload.modalId); + mm._isOpenLocal = true; + } + } + } catch (e) {} + } else if (t === 'modalClosed') { + // Модал закрыт (fadeOut доиграл) — снимаем флаг и зовём onClose-подписчиков. + try { + const mm = (typeof game !== 'undefined') && game.modal; + if (mm) { + mm._isOpenLocal = false; + const cbs = mm._onCloseFns || []; + for (const fn of cbs) _safeCall(fn, payload && payload.id, 'modal.onClose'); + } + } catch (e) {} + } else if (t === 'skinChanged') { + // Задача 07: скин игрока сменился — обновляем зеркало и зовём подписчиков. + const slug = payload && payload.slug; + if (slug) { + _currentSkin = slug; + for (const fn of _skinChangeHandlers) _safeCall(fn, slug, 'player.onSkinChange'); + } + } else if (t === 'skinUnlocked') { + const slug = payload && payload.slug; + if (slug && _unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug); } } else if (cmd === 'sceneSnapshot') { // payload: { blocks, models, primitives } — массивы { ref, x, y, z, name?, type? } @@ -3306,6 +3799,14 @@ self.onmessage = (e) => { } else if (cmd === 'guiSnapshot') { // payload: массив всех GUI-элементов (для game.gui.find/get/all) _guiIndex = Array.isArray(payload) ? payload : []; + } else if (cmd === 'skinsSnapshot') { + // Задача 07: payload { all:[{slug,name,kind,category,price}], unlocked:[slug], current } + if (payload && typeof payload === 'object') { + _skinsIndex = Array.isArray(payload.all) ? payload.all : []; + _unlockedSkins = Array.isArray(payload.unlocked) ? payload.unlocked.slice() : []; + _currentSkin = payload.current || _currentSkin; + if (Number.isFinite(payload.coins)) _skinCoins = payload.coins; + } } else if (cmd === 'dataSnapshot') { // payload: { ref: { key: value } } — атрибуты всех объектов _dataIndex = payload && typeof payload === 'object' ? payload : {}; @@ -3403,6 +3904,8 @@ _send('boot', null); * Создаёт URL Worker-кода для new Worker(url). */ export function getWorkerSourceUrl() { - const blob = new Blob([SOURCE], { type: 'application/javascript' }); + // type: 'application/javascript; charset=utf-8' — без charset Chrome иногда + // декодирует blob как Latin-1, и surrogate pair (эмодзи) ломаются в SyntaxError. + const blob = new Blob([SOURCE], { type: 'application/javascript; charset=utf-8' }); return URL.createObjectURL(blob); } diff --git a/src/preview-player/KubikonPlayer.jsx b/src/preview-player/KubikonPlayer.jsx index b89497b..58ba7fb 100644 --- a/src/preview-player/KubikonPlayer.jsx +++ b/src/preview-player/KubikonPlayer.jsx @@ -13,6 +13,8 @@ import KubikonLeaderboard, { formatTimeMs } from '../components/KubikonLeaderboa import KubikonPerfOverlay from '../components/KubikonPerfOverlay/KubikonPerfOverlay'; import Hotbar from '../editor/Hotbar'; import PlayerHud from '../editor/PlayerHud'; +import ModalOverlay from '../editor/ModalOverlay'; +import SkinShopOverlay from '../editor/SkinShopOverlay'; import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton'; import KubikonChatPanel from './KubikonChatPanel'; import { useAuth } from '../auth/AuthContext.jsx'; @@ -125,6 +127,9 @@ const KubikonPlayer = () => { const [hp, setHp] = useState({ hp: 100, maxHp: 100 }); // Скрипт через game.hud.setVisible(false) полностью скрывает стандартный HUD. const [stdHudVisible, setStdHudVisible] = useState(true); + // Задача 03: отдельный контроль хотбара/HP — для игр без инвентаря/жизней. + const [hotbarVisible, setHotbarVisible] = useState(true); + const [hpVisible, setHpVisible] = useState(true); const [ammo, setAmmo] = useState(null); const [hurtFlash, setHurtFlash] = useState(0); const [inventoryState, setInventoryState] = useState({ slots: [], activeIndex: 0 }); @@ -320,6 +325,9 @@ const KubikonPlayer = () => { if (projectId) scene.setCurrentProjectId(projectId); // game.hud.setVisible(false) скроет HP-бар/hotbar для своего меню scene.setOnStdHudVisibilityChange?.((v) => setStdHudVisible(v)); + // Задача 03: отдельные подписки + scene.setOnHotbarVisibilityChange?.((v) => setHotbarVisible(v)); + scene.setOnHpVisibilityChange?.((v) => setHpVisible(v)); // Колбэки HUD scene.setOnPlayerHpChange?.((h) => { @@ -909,6 +917,10 @@ const KubikonPlayer = () => { {/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */} {!loading && ( <> + {/* Задача 04: модал-overlay (затемнение + spotlight mask). */} + + {/* Задача 07: встроенный магазин скинов (B / API). */} + {/* HUD: на мобиле уменьшаем и сдвигаем компактно. */} {isTouch ? ( <> @@ -919,7 +931,7 @@ const KubikonPlayer = () => { pointerEvents: 'none', zIndex: 30, }}> { )} {/* Hotbar — только если в инвентаре есть хоть один предмет. Пустой инвентарь не показываем. */} - {stdHudVisible && (inventoryState.slots || []).some(s => s) && ( + {stdHudVisible && hotbarVisible && (inventoryState.slots || []).some(s => s) && ( { ) : ( <> - {stdHudVisible && (inventoryState.slots || []).some(s => s) && ( + {stdHudVisible && hotbarVisible && (inventoryState.slots || []).some(s => s) && ( sceneRef.current?.setActiveInventorySlot?.(i)}