diff --git a/.eslintrc.json b/.eslintrc.json index c510105..571899b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -33,7 +33,12 @@ "react-hooks/exhaustive-deps": "warn", "no-eval": "error", "no-new-func": "error", - "no-implied-eval": "error" + "no-implied-eval": "error", + "no-empty": "off", + "react/no-unescaped-entities": "off", + "no-useless-catch": "warn", + "no-constant-condition": ["warn", { "checkLoops": false }], + "no-fallthrough": "warn" }, "ignorePatterns": [ "build/", 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 }) => {
— код-блок (тёмный, моноширинный)
- * - — плашка «куда писать скрипт»
- * - — шаг инструкции
- * - — жёлтая плашка-подсказка
- * - — зелёная плашка «попробуй сам»
- *
- * Контент написан для детей 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} }
-
-);
-
-// ══════════════════════════════════════════════════════════════════
-// 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 Взаимодействовать
- 1…5 Слот инвентаря
- C 1-е / 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} }
+
+);
+
+// ══════════════════════════════════════════════════════════════════
+// 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 Взаимодействовать
+ 1…5 Слот инвентаря
+ C 1-е / 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();
}}
@@ -2326,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();
}}
@@ -2541,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)}
@@ -2926,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,
@@ -3080,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);
+ }}
+ />
{
setEmailNotice(true);
return;
}
+ // Обложки нет — бэк-страховка (фронт уже не должен
+ // дойти сюда, но если кто-то старый клиент шлёт — словим).
+ if (e?.response?.status === 400
+ && e.response.data?.error === 'thumbnail_required') {
+ setPublishModalOpen(false);
+ alert(e.response.data.message || 'Добавь обложку игры в настройках.');
+ setSettingsModalOpen(true);
+ return;
+ }
throw e;
}
}}
diff --git a/src/editor/ModalOverlay.jsx b/src/editor/ModalOverlay.jsx
new file mode 100644
index 0000000..8141cd2
--- /dev/null
+++ b/src/editor/ModalOverlay.jsx
@@ -0,0 +1,101 @@
+/**
+ * ModalOverlay — рендерит затемнение модальной сцены.
+ * Задача 04. Подписан на ModalManager.setOnChange — получает state.
+ *
+ * Архитектура:
+ * - Слой ПОД GUI-overlay (z-index ниже GuiOverlay) но НАД Babylon-канвасом.
+ * - Если target='screen' — слой поверх ВСЕГО (включая GUI). z-index выше.
+ * - Spotlights через CSS mask-image: radial-gradient(...) — вырезает «дырки».
+ * - pointer-events: auto когда модал открыт (перехватывает клики кроме GUI).
+ */
+
+import React, { useEffect, useState } from 'react';
+
+export default function ModalOverlay({ scene }) {
+ const [state, setState] = useState(null);
+
+ // Поллинг — надёжнее чем setOnChange callback, который может перетереться
+ // или не вызваться если scene изменился на следующем кадре.
+ useEffect(() => {
+ if (!scene?.modalManager) return;
+ let cancelled = false;
+ const tick = () => {
+ if (cancelled) return;
+ const s = scene.modalManager.getState?.();
+ // Снимок shallow-clone — иначе React не увидит изменение
+ setState(s ? {
+ id: s.id,
+ fadePhase: s.fadePhase,
+ currentAlpha: s.currentAlpha,
+ opts: s.opts,
+ spotlightScreens: s.spotlightScreens,
+ } : null);
+ requestAnimationFrame(tick);
+ };
+ tick();
+ return () => { cancelled = true; };
+ }, [scene]);
+
+ if (!state || state.fadePhase === 'closed') return null;
+ if (state.currentAlpha <= 0.001) return null;
+ console.log('[ModalOverlay] RENDERING alpha=', state.currentAlpha.toFixed(2), 'phase=', state.fadePhase, 'target=', state.opts?.target);
+
+ const opts = state.opts;
+ const isScreen = opts.target === 'screen';
+ const color = opts.darkenColor || '#000000';
+ const alpha = Math.max(0, Math.min(1, state.currentAlpha));
+ // RGBA bg
+ const bg = _hexToRgba(color, alpha);
+
+ // mask-image для spotlights (только для target='scene' — на 'screen' нет смысла)
+ let maskStyle = {};
+ if (!isScreen && Array.isArray(state.spotlightScreens) && state.spotlightScreens.length) {
+ const softEdge = opts.spotlightSoftEdge ?? 40;
+ const gradients = state.spotlightScreens.map(s => {
+ const inner = Math.max(0, s.r - softEdge);
+ const outer = s.r;
+ // mask-image: внутри круга — transparent (вырезаем), снаружи — black (показываем затемнение)
+ return `radial-gradient(circle at ${s.x.toFixed(0)}px ${s.y.toFixed(0)}px, transparent ${inner}px, black ${outer}px)`;
+ });
+ maskStyle = {
+ WebkitMaskImage: gradients.join(', '),
+ maskImage: gradients.join(', '),
+ WebkitMaskComposite: 'source-in',
+ maskComposite: 'intersect',
+ };
+ }
+
+ // ВАЖНО pointer-events: none — иначе overlay перехватывает клики и кнопки модала не работают.
+ // Затемнение — это просто визуальный фильтр, blockInput реализован в PlayerController.
+ // zIndex:
+ // target='scene' → 24 (под GuiOverlay zIndex=25 чтобы GUI был ВИДЕН поверх затемнения)
+ // target='screen' → 60 (поверх GUI — закрывает ВСЁ)
+ // Для 'screen' GUI модала всё равно поверх (GuiOverlay zIndex=25, наш ScreenOverlay 60,
+ // GUI элементы модала рендерятся в GuiOverlay — поэтому надо ставить их в отдельный
+ // слой ВЫШЕ overlay). Простой фикс: для screen ставим overlay на 24 тоже.
+ const zIdx = 24;
+ return (
+
+ );
+}
+
+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 ();
+}
+
+// ---- Монета-рублик (дубль из 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 ();
+}
+
+// Монета-рублик (для баланса/цены).
+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 d735042..0eb5e17 100644
--- a/src/editor/engine/BillboardUiManager.js
+++ b/src/editor/engine/BillboardUiManager.js
@@ -114,12 +114,65 @@ export class BillboardUiManager {
mesh.metadata._billboardMaterial = mat;
}
- // Ориентация на камеру (BillboardMode_ALL = и X, и Y, и Z).
- if (face === 'camera') {
- mesh.billboardMode = Mesh.BILLBOARDMODE_ALL;
- } else {
- mesh.billboardMode = Mesh.BILLBOARDMODE_NONE;
+ // Ориентация. Babylon-quirk: CreatePlane FRONT в -Z (left-handed),
+ // юзер ставит билборд так чтобы лицо смотрело в +Z мира → +π.
+ mesh.billboardMode = Mesh.BILLBOARDMODE_NONE;
+ if (mesh.metadata._billboardLookObs) {
+ this.scene.onBeforeRenderObservable.remove(mesh.metadata._billboardLookObs);
+ mesh.metadata._billboardLookObs = null;
}
+ if (face === 'camera') {
+ // Ручной look-at — каждый кадр поворачиваем front к камере.
+ const obs = this.scene.onBeforeRenderObservable.add(() => {
+ if (mesh.isDisposed()) return;
+ const cam = this.scene.activeCamera;
+ if (!cam) return;
+ const dx = cam.position.x - mesh.position.x;
+ const dz = cam.position.z - mesh.position.z;
+ 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;
// Сохраняем state в data для сериализации и для hit-теста кликов.
data.billboard = {
@@ -129,16 +182,50 @@ export class BillboardUiManager {
elements: billboardOpts.elements || null,
};
+ dyn._kubikonMirrorX = mesh.metadata._billboardMirrorX === true;
+ dyn._kubikonOwnerMesh = mesh;
this._render(dyn, template, content, billboardOpts.elements);
}
/**
* Обновить контент билборда (без пересоздания текстуры).
- * 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);
@@ -162,15 +249,28 @@ export class BillboardUiManager {
*/
pickButtonAt(data, uvX, uvY) {
if (!data.billboard) return null;
- 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';
}
@@ -192,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);
}
@@ -226,10 +337,10 @@ export class BillboardUiManager {
/** Главная функция рендера — рисует контент на canvas DynamicTexture. */
_render(dyn, template, content, elements, pressedButtonId) {
const ctx = dyn.getContext();
+ ctx.save();
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, TEXTURE_W, TEXTURE_H);
-
if (elements && Array.isArray(elements)) {
- // Кастомный режим — рисуем массив элементов
this._renderElements(ctx, elements, pressedButtonId);
} else {
switch (template) {
@@ -249,7 +360,8 @@ export class BillboardUiManager {
this._renderBanner(ctx, content);
}
}
- dyn.update(false /* invertY */);
+ ctx.restore();
+ dyn.update(true);
}
/** Скруглённый прямоугольник + заливка градиентом + обводка. */
@@ -319,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, но иконка крупнее и центрирована. */
@@ -363,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: одна крупная фраза по центру. */
@@ -416,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 e8aac7c..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.
@@ -211,13 +212,16 @@ export class PrimitiveManager {
// создаются отдельно в addInstance.
return MeshBuilder.CreateSphere(name,
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 12 }, this.scene);
- case 'billboard':
+ case 'billboard': {
// 3D-табличка — плоскость с пропорциями таблички (sx × sy),
- // sz используется как «толщина рамки» (визуально-незаметная).
- // Использует CreatePlane для одностороннего рендера, но в
- // BillboardUiManager backFaceCulling=false → видно с обеих сторон.
- return MeshBuilder.CreatePlane(name,
- { width: sx, height: sy, sideOrientation: Mesh.DOUBLESIDE }, this.scene);
+ // sz — толщина рамки (визуально-незаметная).
+ // ВАЖНО: FRONTSIDE, не DOUBLESIDE — иначе сквозь back-side
+ // видно зеркальную сторону UV (текст справа-налево).
+ // BillboardMode разворачивает FRONT к камере.
+ const m = MeshBuilder.CreatePlane(name,
+ { width: sx, height: sy, sideOrientation: Mesh.FRONTSIDE }, this.scene);
+ return m;
+ }
case 'plane':
return MeshBuilder.CreateBox(name,
{ width: sx, height: sy, depth: sz }, this.scene);
@@ -728,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 04542db..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;
@@ -1335,6 +1431,10 @@ const game = {
opts = opts || {};
// Алиас: 'light:point' — это примитив-лампа.
if (type === 'light:point' || type === 'light') type = 'primitive:light';
+ // Шорткаты — позволяем писать просто 'billboard' / 'cube' / 'trigger' и т.п.
+ // вместо 'primitive:billboard'. Если нет двоеточия — это шорткат
+ // на primitive:.
+ if (type.indexOf(':') < 0) type = 'primitive:' + type;
const x = Number(opts.x) || 0;
const y = Number(opts.y) || 0;
const z = Number(opts.z) || 0;
@@ -1357,7 +1457,9 @@ const game = {
kind, subType, x, y, z,
sx: opts.sx, sy: opts.sy, sz: opts.sz,
color: opts.color, material: opts.material,
+ rotationX: opts.rotationX,
rotationY: opts.rotationY,
+ rotationZ: opts.rotationZ,
name: opts.name,
brightness: opts.brightness, range: opts.range,
effect: opts.effect,
@@ -1370,6 +1472,11 @@ const game = {
visible: opts.visible,
// textureAsset — id картинки из ассетов проекта на грани.
textureAsset: opts.textureAsset,
+ // Billboard-специфичные (template/content/face/elements)
+ template: opts.template,
+ content: opts.content,
+ face: opts.face,
+ elements: opts.elements,
ref,
});
if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime);
@@ -2103,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).
@@ -2176,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.
@@ -2683,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';
@@ -2704,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 });
},
},
/**
@@ -2732,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] относительно канваса.
@@ -2780,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) {
@@ -3002,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);
@@ -3248,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') {
@@ -3280,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? }
@@ -3295,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 : {};
@@ -3392,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)}