Compare commits

..

70 Commits

Author SHA1 Message Date
min
70731dae31 Merge pull request 'Team Create (���������� ��������������) + ���������� ���� + ������ 16/17/20/40/44/05' (#34) from restore/all-tasks into main
Some checks failed
CI / Secret scan (push) Successful in 21s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 3m31s
CI / Lint (push) Has been cancelled
CI / Build (push) Has been cancelled
2026-06-08 01:12:57 +00:00
min
6943e93818 ci: перезапуск (build-job упал на docker RWLayer/volume — flaky раннер, не код)
Some checks failed
CI / Build (pull_request) Successful in 2m2s
CI / Secret scan (pull_request) Successful in 22s
CI / PR size check (pull_request) Successful in 8s
CI / Lint (pull_request) Has been cancelled
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 03:38:40 +03:00
min
e48338376a merge main into restore/all-tasks (синхрон перед PR)
Some checks failed
CI / Build (pull_request) Failing after 1m55s
CI / Lint (pull_request) Has been cancelled
CI / PR size check (pull_request) Successful in 8s
CI / Secret scan (pull_request) Failing after 14m39s
CI / Deploy to S1 + S2 (pull_request) Has been cancelled
2026-06-08 03:28:29 +03:00
min
ab11ac0b4e docs(studio): вики — рецепты скриптов, контекст для нейронки, Team Create, актуализация
Новые разделы: «Скрипты: рецепты» (S1-S12: килблок/касание/исчезновение/телепорт/
свойства примитивов/таймеры/враги/сохранение), «Контекст для нейронки» (полный
game-API одним copy-paste блоком для ChatGPT), «Вместе с друзьями» (V1-V3 Team
Create). Раздел «Системы» дополнен G7-G12 (лидерборды/floaters/инвентарь/небо/
меню/машины). Иконка users. 80 секций вики.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 03:27:41 +03:00
min
fbf7ef680b feat(studio): Team Create — совместное редактирование игры в реальном времени
StudioCollab (Colyseus studio-room): синхрон операций примитивов/моделей/блоков,
presence (курсоры/камера/выделение), soft-lock объектов, перехват менеджеров.
CollabOverlay: DOM-курсоры соавторов + онлайн-аватарки + тосты. Кнопки
«Скины»+«Пригласить» в TopRibbon вкладка «Игра». Гость-режим (скрыты
Настройки/Сохранить/Опубликовать). Autosave только host. Вход по ?collab-токену.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 03:27:38 +03:00
min
cb41ea0062 docs(studio): вики статья guide-loadingscreen + карточка #66 (задача 05, восстановление)
Восстановлена полная ветка работ (задачи 16/17/20/40/44 + UX) из c8a9618 +
применена задача 05 (Ken Burns экран загрузки). Карточки g5: skybox/leaderstats/
floaters/inventory/loadingscreen. Ошибки 'items.define/autoMobFloaters/setSkybox
is not a function' были из-за работы на служебной CI-ветке без задач 40/44.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 20:02:57 +03:00
min
c31b1ed3d6 feat(studio): задача 05 — экран загрузки (Ken Burns + название места)
LoadingScreenOverlay: Ken-Burns фон (CSS pan+zoom) + 4 стиля (ken-burns/static/
parallax/particles) + карточка-композиция (cover/название места/автор/verified-SVG).
Стартовый экран при входе в Play (showStartupLoadingScreen из enterPlayMode +
поля проекта loadingScreen.* + serialize/deserialize). API game.loading.
setBackground/isVisible/onHide + расширенный show. UI редактора: секция
«Стартовый экран входа (Ken Burns)». Вики g5 #62 + статья. Тест-игра 2713.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 20:01:00 +03:00
min
c8a961815e chore: проверка CI после фикса runner network (job→gitea:3000)
Some checks failed
CI / Lint (pull_request) Failing after 2m8s
CI / Build (pull_request) Failing after 39s
CI / Secret scan (pull_request) Failing after 37s
CI / PR size check (pull_request) Failing after 31s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-06-07 15:17:31 +03:00
min
9f2cca1a49 docs(studio): вики задача 44 — карточка #65 + статья «Сбор и сортировка (инвентарь drag-drop)»
Карточка g5 #65 guide-inventory (openProjectId 2685) + статья: items.define,
inventory.give/take, окно по I (сетка 8×5 + хотбар 9), drag-drop, стаки,
редкости, ПКМ-меню, tooltip, сортировка. 2 скрина (scene окно / play сбор).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 15:07:42 +03:00
min
f46e6f0102 fix(studio): убрал 2 eslint-ошибки в main (showToast no-undef + text self-assign) — CI был красным для всех PR
Some checks failed
CI / Lint (pull_request) Failing after 28s
CI / Build (pull_request) Failing after 31s
CI / Secret scan (pull_request) Failing after 34s
CI / PR size check (pull_request) Failing after 32s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-06-07 15:06:28 +03:00
min
e15dc56de3 fix(studio): self.delete снимает interact-подсказку (E больше не висит на пустоте)
_applySelfDelete удалял меш, но запись в _interactables оставалась → промпт
«E Собрать» висел на месте собранного предмета. Теперь запись чистится по ref.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:59:25 +03:00
min
c62073f7f8 chore: проверка CI с восстановленным runner (ubuntu-latest label)
Some checks failed
CI / Lint (pull_request) Failing after 37s
CI / Build (pull_request) Failing after 31s
CI / Secret scan (pull_request) Failing after 32s
CI / PR size check (pull_request) Failing after 36s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-06-07 14:54:25 +03:00
min
661ff60bdf fix(studio): инвентарь — собранное идёт сначала в hotbar (виден), hotbar поднят над подсказкой
1) add() заполняет сначала hotbar, потом grid → собранные предметы сразу видны
   в постоянном хотбаре (раньше уходили в скрытую сетку — хотбар казался пустым).
2) Хотбар поднят bottom 14→64px, не перекрывает подсказку внизу экрана.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:51:27 +03:00
min
42f1334908 feat(studio): задача 44 — drag-drop инвентарь (сетка 8×5 + hotbar 9 + стаки + редкости)
InventoryUI.js: DOM-оверлей — окно инвентаря по I (сетка 8×5), постоянный
hotbar 9 (клавиши 1-9), drag-drop между слотами (HTML5), стаки с maxStack,
5 редкостей (цвет рамки), tooltip на hover, ПКМ-меню (использовать/разделить/
выбросить), сортировка по редкости. API: game.items.define([...]),
game.inventory.give/take/open/toggle/sort/setActiveHotbar. onUseEffect heal/speed.
Сериализация scene.inventory2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:45:24 +03:00
min
b1fbc3790e chore: re-trigger CI after runner restart
Some checks failed
CI / Lint (pull_request) Failing after 40s
CI / Build (pull_request) Failing after 29s
CI / Secret scan (pull_request) Failing after 36s
CI / PR size check (pull_request) Failing after 36s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-06-07 14:43:27 +03:00
min
2645337bdd chore: e2e-тест workflow разработчиков после восстановления
Some checks failed
CI / Lint (pull_request) Failing after 3m7s
CI / Build (pull_request) Failing after 40s
CI / Secret scan (pull_request) Failing after 29s
CI / PR size check (pull_request) Failing after 39s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-06-07 14:36:52 +03:00
min
48e2e83ef7 docs(studio): вики задача 40 — превью/скрин = кадр с облачками урона над зомби
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:22:11 +03:00
min
c20ac56895 docs(studio): вики задача 40 — обновлена под зомби-арену (бластер + autoMobFloaters + волны)
Карточка #64 «Зомби-арена — бластер и цифры урона» + статья переписана:
giveTool бластер, autoMobFloaters (авто-облачко над мобами), spawnNpc+follow
волны зомби, прицел в точку клика, ручной damageFloater (типы/стек/комикс).
Новые скрины scene/play (зомби-шутер).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:18:53 +03:00
min
931d53b4d9 fix(studio): бластер от 3-го лица стреляет в точку клика, а не в центр камеры
При свободном курсоре (нет pointer-lock, 3-е лицо) выстрел шёл из getForwardRay
(фокус камеры). Теперь onDown берёт координаты клика → setAimScreenPoint → луч
через точку клика; onMove обновляет _holdAim для авто-огня при удержании. При
pointer-lock (1-е лицо, курсор в центре) — прежнее поведение (центр).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:01:14 +03:00
min
e4fdd91b12 fix(studio): оружие попадает по NPC (pickable+npcId) → авто-floater урона работает
Меши NPC ставились isPickable=false → raycast бластера/меча проходил сквозь
них, урон и авто-floater не срабатывали. Теперь меши NPC pickable + npcId в
metadata; damageByMesh находит NPC по metadata (быстро) или по rootMesh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:49:06 +03:00
min
854074bfa2 feat(studio): авто-floater над мобами + урон NPC от оружия (задача 40 доп)
game.fx.autoMobFloaters(true) — включает облачка урона над NPC при любой потере
HP (NpcManager.damage). NpcManager.damageByMesh — оружие (бластер/меч) наносит
урон скриптовым NPC (weapons.setOnHit → npcManager.damageByMesh). Связка:
выстрел бластера → урон NPC → авто-floater «-N» над целью.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:41:41 +03:00
min
c93070170b docs(studio): вики задача 40 — карточка #64 + статья «Тренировочный полигон (цифры урона)»
Карточка g5 #64 guide-floaters (openProjectId 2676) + статья: game.fx.
damageFloater, типы (damage/crit/heal/mana/miss), стек stackKey, comicStyle,
object pool. 2 скрина (scene/play) в public/wiki.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 11:08:28 +03:00
min
458b6c3b59 feat(studio): задача 40 — damage floaters (game.fx.damageFloater)
FloaterManager.js: object pool (30 billboard-планов с DynamicTexture), tween
подъём+fade+покачивание, crit pop-scale, цвета damage/crit/heal/mana/miss,
стек одинаковых по stackKey (×N), комикс-стиль (BAM!/KAPOW!/POW! на звезде).
API game.fx.damageFloater(position, value, opts) — position {x,y,z} или ref/
'player'. Интеграция: tick в render-loop, resetRuntime при stop. Тест-игра
«Тренировочный полигон» id=2676.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 11:05:25 +03:00
min
f8f0d976ef fix(studio): Ctrl+шорткаты не двигают камеру (Ctrl+D больше не уводит вправо)
onKeyDown клал любую клавишу в _codes (набор для WASD-движения камеры),
включая D при зажатом Ctrl → камера летела вправо при копировании. Теперь
клавиши с ctrl/meta в _codes не попадают.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 10:44:14 +03:00
min
6c0c3dc26e fix(studio): Ctrl+D дублирует объект РОВНО на месте оригинала (не смещает +1 по X)
duplicateSelected для model/userModel/primitive ставил копию на sel.x+1 →
визуальное смещение. Теперь копия появляется в той же точке (как Roblox Studio).
Block остаётся с поиском свободной клетки (воксель нельзя в занятую).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 10:38:34 +03:00
min
e477d652f6 fix(studio): mainMenu.show подхватывает opts.onPlay/onShow/onHide (порт)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:17:43 +03:00
min
ba90bf5c7d feat(studio): кит «Таблица лидеров» в Toolbox → Готовые механики
Новый кит (категория ui): определяет лидерборд (Очки primary + Время),
время идёт само, очки растут от broadcast('score'|'coins'). Сохраняется в БД.
Работает вместе со счётчиком монет/очков. Всего 47 китов.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 10:11:18 +03:00
min
1c976ee870 docs(studio): вики задача 20 — скрины (scene/play), page заменён на текст
Скриншоты сцены (редактор) и геймплея (таблица лидеров) в public/wiki.
Страница достижений описана текстом (отдельного скрина пока нет).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 10:09:43 +03:00
min
7bb789f1af docs(studio): вики задача 20 — карточка #63 + статья «Сбор монет (лидерборды и достижения)»
Карточка g5 #63 guide-leaderstats (openProjectId 2616) + статья в docsLessons:
что получится, API leaderstats/achievements, 2 шага (таблица/достижения),
bindToStat, сохранение в БД. 3 скрина (scene/play/page) — донести вручную
(headless-студия не пускает после взлома, попрошу у пользователя).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 10:04:57 +03:00
min
ce6e69a2e8 fix(studio): saveProgress/loadProgress с JWT-заголовком (был 401 при сохранении прогресса)
loadProgress/saveProgress (лидерстаты/достижения) слали fetch без Authorization
→ 401 UNAUTHORIZED. Используют _economyAuthHeaders() (JWT игрока из localStorage).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 10:01:38 +03:00
min
33cd435d06 feat(studio): прогресс лидербордов и достижений сохраняется в БД (между сессиями)
GameRuntime.saveProgress/loadProgress — helper к storys savegame endpoint
(/kubikon3d/savegame/<pid>/<uid>/<ns>) для движковых менеджеров.

- Достижения: при unlock сохраняются в БД (namespace _achievements) + localStorage
  как быстрый кэш; loadFromDB при Play восстанавливает разблокированные.
- Лидерстаты: статы текущего игрока сохраняются в БД (namespace _leaderstats,
  дебаунс 1с при set); loadFromDB при Play восстанавливает значения.
- Загрузка из БД через 250мс после старта скриптов (даём define зарегистрировать).

Теперь прогресс игрока подгружается при каждой сессии с любого устройства.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 09:59:11 +03:00
min
c9498b086e fix(studio): SkyboxManager.hexToRgb поддерживает короткий хекс #fff (был NaN в облаках)
skybox.clouds.color='#fff' → substring(4,6)='' → parseInt NaN → addColorStop
'rgba(255,15,NaN,0.9)' падал при load → прерывал загрузку проекта. hexToRgb
теперь расширяет #fff→#ffffff и подстраховывает NaN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 09:49:01 +03:00
min
5d49cd9eeb feat(studio): задача 20 — лидерборды (leaderstats) + достижения (achievements)
Leaderstats: HUD-таблица top-right (blur, сортировка по primary, топ-10,
подсветка me, flash-инкремент). API game.leaderstats.define/set/add/get/
onChange + me.* (format number/time/short). LeaderstatsManager.js.

Достижения: toast справа (4 редкости + звук + очередь, slide-in/out), кнопка-
кубок слева-снизу → страница grid (locked grayscale+замок, hidden=?, прогресс-
бар). API game.achievements.define/unlock/has/bindToStat/setButtonVisible/
openPage. bindToStat(id, stat, {gte/lte/eq}) — авто-unlock по лидерстату.
Сохранение unlocked в localStorage по проекту. AchievementsManager.js.

Интеграция: оба менеджера в BabylonScene (tick leaderstats в Play, resetRuntime
при stop, serialize/load в project_data scene.leaderstats/achievements). worker-
API + GameRuntime cmd-обработчики + мост leaderstats.onChange→worker (globalEvent
leaderstatsChange) для bindToStat. Плеер пока НЕ портирован (по плану).

Тест-игра «Сбор монет с достижениями» id=2616 (is_test): поляна + 30 монет +
3 стата + 5 достижений (3 через bindToStat).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 09:32:53 +03:00
min
cf34f9cdb6 fix(studio): враг бьёт с анимацией удара, ключ исчезает при подборе
1+2) Враг с HP / волна врагов: радиус удара 3.5 (NPC останавливался на
     followGap=2.5, урон-чек d<2.5 не срабатывал). Добавлена анимация атаки —
     R15Animator.attack (выпад рукой), npc.setAttacking, NpcManager.setAttacking.
3) Ключ исчезает при подборе: scene.setVisible теперь парсит ref ('primitive:N')
   — obj.visible=false слал {ref} без kind/id, поэтому ключ не пропадал.
4) Машина — остаётся рантайм vehicle:car (особенность транспорта).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:26:11 +03:00
min
c4d184257b fix(studio): враг=NPC+урон, волна бьёт, удалённые скрипты не исполняются, ключ+дверь красивые, тени-acne
1) Враг с HP → R15-NPC (зомби-скин), преследует и бьёт игрока при касании.
2) Волна врагов: враги наносят урон при касании (onTick дистанция → damage).
3) Удалённые скрипты больше не исполняются: _cleanupOrphanScripts при удалении
   объекта (primitive/model/userModel) + перед enterPlayMode чистим скрипты-сироты.
4) Ключ и замок: ключ из примитивов (стержень+кольцо-torus+бородка), дверь
   как дверь-по-E (плавный поворот вокруг петли, только с ключом).
6) Тени-полосы: normalBias 0.005→0.02 (убирает acne-полосы от соседних объектов).

Машина (vehicle:car) — остаётся рантайм-спавном (особенность транспорта).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:12:50 +03:00
min
26e6306f6e fix(studio): дверь-код подсказка E только при открытой, NPC=R15-скин (анимация рук/ног), торговец видимый
1) Дверь по коду: подсказка «E закрыть» теперь только когда дверь открыта
   и игрок рядом (через onKey+onTick, без постоянного interact-промпта).
2) NPC-киты используют R15-скин 'skin_roblox-noob' → процедурная анимация
   бега/покоя с руками и ногами (R15Animator), вместо безжизненного покачивания.
3) Торговец-кит: невидимый триггер (insertGameplayKit теперь уважает
   prim.visible=false, раньше хардкод visible:true) + NPC-персонаж рядом —
   синий куб больше не виден.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:54:38 +03:00
min
9903719f9d fix(studio): дверь-код закрывается по E, NPC ходит с анимацией, торговец=NPC, тени короче
1) Дверь по коду: открытую можно закрыть по E (onInteract) → снова вводить код.
2) NPC: процедурная анимация ходьбы (покачивание по Y + наклон корпуса) для
   Kenney-моделей — раньше скользили без анимации.
3) Торговец переделан в NPC-персонажа (spawnNpc character-a) + невидимый
   триггер с диалогом по E (вместо примитивов).
4) Тени: убрана «полоса через всю карту» — autoCalcDepthBounds off,
   shadowMaxZ 90/60 (было 200/120), lambda 0.6, frustumEdgeFalloff 12.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:47:27 +03:00
min
fe8b6b5b38 fix(studio): NPC-киты (robot→character-a), дверь по коду плавно распахивается
1) NPC-преследователь/торговец/волна: spawnNpc('robot') → 'character-a'
   (модели 'robot' не существует в ModelTypes → spawnNpc возвращал null → ошибка).
2) Дверь по коду: верный код → ПЛАВНОЕ открытие вокруг петли (как дверь по E),
   декор-панели вращаются вместе с полотном. Раньше полотно уезжало вниз,
   декор оставался.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:36:18 +03:00
min
3bf1e77230 fix(studio): self.setLabel, дверь по коду (красивая+радиус), счётчик механик, ссылка на скрипт в консоли
1) Дверь по коду: красивая составная дверь (полотно+рамка+кодовая панель),
   поле ввода появляется ТОЛЬКО когда игрок в радиусе 6м (onTick по дистанции).
2) game.self.setLabel/clearLabel добавлены (кит «Метка с именем» падал
   'setLabel is not a function').
3) Плитка «Готовые механики» в тулбоксе считает киты динамически
   (GAMEPLAY_KITS.length), а не хардкод «12».
4) Консоль: ошибки/логи скриптов привязаны к источнику — справа строки
   кликабельная ссылка «📄 имя скрипта», открывает скрипт в редакторе
   (_log прокидывает scriptId/scriptName).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:27:09 +03:00
min
045f892aaa fix(studio): светофор (obj.color по ref), грядка растёт+зреет (obj.scale), кит скрытия HP
1) scene.setColor теперь принимает {ref} (obj.color=hex), не только {id}.
   Светофор переключал цвета через obj.color, но ref игнорировался → не работал.
2) Грядка: добавлен obj.scale (scene.setScale → mesh.scaling). Урожай после
   сбора исчезает, растёт за 5с (scale 0→1) и зреет цветом красный→зелёный,
   при полном размере снова собирается.
3) Кит «HP-бар» теперь сам прячет стандартный HUD HP (setHpVisible false).
   Новый кит «Скрыть стандартный HUD HP» — отдельно прячет дефолтную полосу.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:13:02 +03:00
min
018fce474b fix(studio): объекты больше не вываливаются из папки после Play/Stop (folderId в serialize)
Корень: serialize примитивов/моделей/userModel НЕ сохранял folderId. При
Play→Stop сцена восстанавливалась из снапшота без группировки → все части
кита (светофор/шипы/дверь) вываливались из папки в общие «Примитивы».
Добавлен folderId в serialize всех 3 менеджеров + восстановление в loadFromArray
(model/userModel явно, primitive через opts.folderId).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:00:36 +03:00
min
6ece149924 fix(studio): камера 3-го лица не цепляется за проходимые зоны (canCollide в metadata)
Зона опасности / триггеры (canCollide:false) ловились camera-clamp, и камера
прыгала к игроку внутри зоны. Причина: metadata примитива НЕ содержал
canCollide, а PlayerController._clampCameraToWorld проверяет md.canCollide.
Добавлен canCollide в metadata меша (+ синк при updateInstance).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:49:17 +03:00
min
2d669a3ff3 fix(studio): выделение папки раскрывает дерево + Delete удаляет всю папку с содержимым
1) При выделении папки (клик по сцене / вставка кита) дерево авто-раскрывается:
   workspace + цепочка родителей + scrollIntoView к строке папки. Раньше папка
   выделялась на сцене, но в свёрнутом дереве её не было видно.
2) Delete на выделенной папке (type='folder') → removeFolder(id, true) удаляет
   всю папку со ВСЕМ содержимым + _cleanupOrphanScripts чистит осиротевшие
   скрипты привязанные к удалённым объектам.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:36:55 +03:00
min
46414d874b fix(studio): кит спавнится на поверхности в фокусе + многочастный в папку + снятие выделения при Play
1) Предметы кита из тулбокса спавнятся на ТВЁРДОЙ поверхности под центром
   экрана (getPlacementPointAtCenter: raycast в пол/объект), а не под камерой.
2) Многочастный кит (дверь) теперь реально попадает в папку: insertGameplayKit
   ставил folderId, но дерево не пересобиралось (markDirty не трогает
   hierarchyDirtyRef) — добавлен hierarchyDirtyRef.current=true.
3) enterPlayMode снимает любое выделение редактора (объект/папка) + убирает
   пивот папки и gizmo — в Play больше нет подсветки выбранного.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:30:41 +03:00
min
bf93219266 fix(studio): клик по части папки выделяет всю папку + free-drag папки + удаление обновляет дерево + затухание длинной тени
1) Длинная тень-полоса: csm.frustumEdgeFalloff=8 — тень персонажа больше не
   тянется на весь пол.
2) Удаление примитива/модели/блока через ПКМ теперь обновляет дерево
   (markDirty + hierarchyDirtyRef) — раньше объект удалялся, но не пропадал.
3) Клик по СЦЕНЕ по объекту в папке → выделяется ВСЯ папка (selectByMesh
   проверяет folderId). Отдельную часть — через раскрытие папки в дереве.
4) Free-drag папки: зажал ЛКМ на группе и тянешь — двигается вся папка
   (moveFolderBy по дельте центра).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:19:02 +03:00
min
ed7310a532 feat(studio): +25 готовых механик из Вики (вся партия 3 — все остальные)
Добавлены все оставшиеся механики из TOOLBOX_KITS_FROM_WIKI.md:
Мир: зона опасности, шипы, светофор, грядка-урожай, падающие предметы.
Интерфейс: счётчик очков, HP-бар, дверь по коду (textbox), метка с именем,
  обратный отсчёт, 3D-стрелка-указатель.
Эффекты: костёр (particles fire), магнит монет.
NPC и бой (новая категория): преследователь, торговец (modal.dialog),
  мишень, враг с HP, волна врагов, диалог/кат-сцена, машина (vehicle:car).
Экономика (новая категория): магазин-кнопка, кликер, ключ+замок.

+2 категории китов (NPC и бой, Экономика). Всего ~37 китов.
Опущены «Главное меню» и «Экран загрузки» — требуют целой сцены, не «1 клик».
Все 45 скриптов прошли синтаксис-проверку, билд зелёный.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:55:32 +03:00
min
f270854795 feat(studio): +5 готовых механик (цветная плитка/лава/лифт/финиш/звук)
Партия 2 из TOOLBOX_KITS_FROM_WIKI.md:
- Цветная плитка — onTouch → смена цвета (self.setColor).
- Лава — onTouch/onUntouch → урон 15 HP/сек пока стоишь (player.damage).
- Лифт — onTick синусоида, ездит вверх-вниз 8 единиц.
- Финиш (победа) — onTouch → экран «ПОБЕДА!» + setInputBlocked.
- Звуковая плитка — onTouch → sound.play('coin') + подсветка.

game.self расширен: setColor(hex). Все 22 кита прошли синтаксис-проверку.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:31:49 +03:00
min
6938f83a3c fix(studio): декор двери поворачивается в ту же сторону (левосторонняя СК Babylon)
Ручка/филёнки уезжали на обратную сторону двери: формула поворота смещения
была правосторонняя, а Babylon mesh.rotation.y — левосторонняя. Единая rotY()
(wx=lx·c+lz·s, wz=-lx·s+lz·c) для полотна И декора → всё открывается синхронно
в одну сторону.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:23:35 +03:00
min
0e4fa89f40 fix(studio): декор двери (филёнки+ручка) вращается вместе с полотном
Декор-части находятся по имени (findOne), их локальное смещение от центра
полотна поворачивается вокруг той же петли в place() — теперь филёнки и ручка
открываются вместе с дверью, а не висят в проёме.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:20:15 +03:00
min
9a58c34303 feat(studio): красивая дверь (рамка+филёнки+ручка) + плавная анимация открытия
- Дверь теперь многочастная: полотно из тёмного дерева + 2 филёнки + золотая
  ручка + косяк-рамка (2 стойки + перемычка). Уходит в общую папку.
- Плавное открытие: постоянный onTick ведёт угол cur→target со скоростью
  ~0.5с на 90° (вместо мгновенного скачка). Поворот вокруг петли сохранён.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:16:46 +03:00
min
32cbb7bbe9 fix(studio): дверь поворачивается вокруг петли (как настоящая), а не отскакивает
Было: дверь сдвигалась вбок. Стало: вращение вокруг левой грани (петли) на
90°. Центр двери пересчитывается по дуге вокруг hinge (p0.z - halfW), плюс
self.rotate(angle) — дверь распахивается, как в реальности.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:51:36 +03:00
min
4906c82792 fix(studio): портал ищет второй портал по имени, дверь уезжает вбок
- Портал: вместо хардкода +8 по X — findOne('Портал B') в момент касания и
  телепорт к реальной позиции второго портала (его можно двигать куда угодно).
  findOne на старте давал null (sceneSnapshot через rAF) → искать в onTouch.
- Дверь по E: сдвиг вбок (+3 по X) вместо ухода вниз (выглядело как исчезновение).
  Текст подсказки «Открыть / закрыть».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:46:34 +03:00
min
cfc79f325f feat(studio): +5 готовых механик из Вики (батут/ускорение/портал/исчезающая платформа/дверь)
Партия 1 из TOOLBOX_KITS_FROM_WIKI.md:
- Батут (пружина) — onTouch → setVy(20) подброс вверх.
- Лента ускорения — onTouch → x2 скорости на 3с.
- Портал-телепорт — пара порталов, onTouch → teleport ко второму.
- Исчезающая платформа — onTouch → через 1с пропадает, через 3с возвращается.
- Дверь по кнопке E — onInteract → дверь уезжает вниз/возвращается.

game.self расширен: setVisible(vis) / setCollide(can) (нужны для исчезающей
платформы). Все скрипты прошли синтаксис-проверку (new Function).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 07:41:25 +03:00
min
6b857636c3 fix(studio): крестик закрытия Toolbox прижат к правому краю шапки
После переработки header убрал .headerInfo (flex:1), который раздвигал
заголовок и крестик → крестик прилип к названию. Добавил margin-left:auto
кнопке закрытия.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 07:28:32 +03:00
min
c7b5f3645d fix(studio): групповые манипуляции папки в реальном времени (не телепорт)
Дельта пивота применялась только в dragEnd → объекты телепортировались в
конце. Теперь _onFolderGizmoDrag применяет инкрементальную дельту на каждом
тике (setOnDrag) — движение/вращение/масштаб группы видно в процессе, как
у одиночных объектов.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 02:53:35 +03:00
min
8b887e866a fix(studio): многочастные киты в папку + стартовая площадка задаёт спавн
- Кит из нескольких частей (сундук = тело+крышка) теперь кладётся в общую
  папку (folderManager.createFolder + assignToFolder), выделяется как группа.
  Раньше части лежали отдельно в корне.
- Кит «Стартовая площадка»: on-target скрипт телепортирует игрока НА площадку
  в начале игры (game.player.teleport через game.after 0.1с). Теперь игрок
  появляется на ней, а не в фолбэк-точке (0,0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 02:46:25 +03:00
min
4c8f8c99cb feat(studio): групповые манипуляции папки (выделение+move/rotate/scale всей группы)
Клик по папке в дереве → выделяется вся группа (подсветка всех объектов
внутри, рекурсивно по подпапкам) + групповой gizmo на пивоте в центре папки.
Манипуляторы двигают/вращают/масштабируют ВСЕ объекты папки сразу. Выбор
отдельной модели внутри — манипулирует только ей (как раньше).

- FolderManager: getFolderObjects (рекурсивный сбор + центр), moveFolderBy,
  scaleFolder (от центра, +размеры примитивов), rotateFolderY расширен на модели.
- SelectionManager.selectFolder → multi-подсветка + type:'folder' + пивот-gizmo.
- BabylonScene._attachFolderGizmo/_applyFolderGizmo: пивот-TransformNode,
  на dragEnd дельта (move/rotate/scale) применяется ко всей папке, пивот
  пересоздаётся в новом центре. Пивот убирается при смене выделения.
- Дерево: клик по строке папки = выделить группу; клик по шеврону = свернуть.

Многокомпонентные модели уже кладутся в авто-папку (ModelManager) — теперь
их можно двигать как единое целое.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 02:40:00 +03:00
min
7fc4ee94f6 fix(studio): удалённая точка спавна не появляется при Play + скрыта из дерева
Баг: после удаления точки спавна она всё равно появлялась при запуске.
Теперь _spawnEnabled синхронизируется в React (spawnEnabledUI) через
onSceneChange/load/setSpawn/deleteSpawn; пункт «Точка спавна» скрыт из дерева
когда удалён; player.start использует фолбэк (0,поверхность+2,0). Стартовая
площадка теперь срабатывает (игрок не телепортируется на старый спавн).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 02:33:15 +03:00
min
471af1cdeb fix(studio): сундук→счётчик монет через broadcast, удаление точки спавна + фолбэк 0,0
- Киты «Сундук» и «Счётчик монет» связаны через game.broadcast('coins',{add})
  + game.onMessage('coins') — раньше каждый кит в своём worker, счётчик не
  обновлялся (был globalThis, не работает между воркерами).
- Точку спавна теперь МОЖНО удалить: Delete (SelectionManager.deleteSelected
  обрабатывает type==='spawn' → scene.deleteSpawn) + ПКМ в дереве → контекст-
  меню «Навести камеру / Удалить точку спавна».
- Если точка спавна удалена (_spawnEnabled=false) — игрок появляется в
  (0, поверхность+2, 0). Постановка новой точки (setSpawnAtCamera) возвращает.
- spawnEnabled сериализуется в project_data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 02:21:38 +03:00
min
7242e80602 fix(studio): кит «Конфетти» вылетает из позиции объекта, а не из центра сцены
Было: spawn кубиков в фикс. координатах (0,0.5,0) → конфетти сыпалось в
центре сцены, далеко от шара-источника (непонятно как связано). Стало:
кубики вылетают из game.self.position (позиции самого объекта-источника).
Описание кита уточнено: «фонтан конфетти из этого объекта».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 02:10:03 +03:00
min
df1647019d fix(studio): game.self.rotate + понятные имена скриптов китов
- game.self.rotate(ry)/rotateY(ry) добавлен в worker (слал scene.rotate с
  ref объекта-носителя). Кит «Вращающийся объект» падал 'game.self.rotate is
  not a function' каждый кадр в onTick — теперь крутится.
- upsertScript принимает name; вставка кита даёт скрипту имя = название кита
  (раньше в дереве был сырой id script_mq03...). Ручное создание скрипта тоже
  даёт «Скрипт N» / «Скрипт объекта N» вместо id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 02:05:17 +03:00
min
781c3cf945 fix(studio): глобальные скрипты (target=game) видны в дереве и удаляемы
Баг: фильтр дерева был scripts.filter(s => !s.target) → скрипты с
target:'game' (главные скрипты игры) НЕ показывались в группе «Скрипты»
(дерево писало «Скрипты (0)»), хотя в Play исполнялись и удалить их было
нельзя. Теперь глобальный = нет target ИЛИ target==='game'.

- ПКМ по «Точка спавна» в дереве → выбирает её (открывает свойства).
- Кит «Точка спавна» → «Стартовая площадка» (точка спавна уже есть по
  умолчанию, дубль путал; её нельзя удалить — это by design).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 01:56:51 +03:00
min
4284fef704 fix(studio): F-фокус на выделенном объекте, автофокус при вставке кита, двойной прыжок
- F в редакторе теперь фокусирует камеру на ВЫДЕЛЕННОМ объекте (раньше всегда
  летел в центр 0,0,0). Если выделения нет — центр сцены. Только в edit-режиме.
- focusOnSelection поддерживает userModel + запасной путь по позиции меша.
- Вставка кита из Тулбокса: объект выделяется И камера наводится на него
  (видно, куда добавилось) + переключение на инструмент «Выделить».
- Кит «Двойной прыжок» чинён: был setJumpPower (высокий прыжок) →
  game.player.setDoubleJump(true) (настоящий второй прыжок в воздухе по Space).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 01:45:40 +03:00
min
b774f92d40 fix(studio): Toolbox UI — читаемый текст, крупнее шрифты, иконка+название в строку
Плитки категорий: убрал зависимость от CSS-переменных --text/--text-dim
(не заданы в модалке → текст был тёмный на тёмном). Явные светлые цвета.
Иконка теперь слева в одну линию с названием (grid 2 колонки), название 18px,
описание 13px. Верхние вкладки 15px. Советы/«скоро» крупнее. Trending-карточки
читаемее.
2026-06-05 01:38:34 +03:00
min
5e1a0edf9b feat(studio): задача 17 — Toolbox переработан под Roblox Creator Store
Единый Toolbox вместо отдельной кнопки «Модель» в панели «Создать»:
- 4 верхние вкладки как в Roblox: Магазин / Инвентарь / Недавние / Советы.
- Магазин: главный экран с 6 плитками-категориями (3D-объекты / Эффекты /
  2D-картинки / Готовые механики / Плагины / Аудио) + ряд «Популярное» (FREE).
- Клик по категории → детальный список с поиском и подкатегориями; «← Категории».
- 3D-объекты = 700+ моделей; Эффекты = эмиттер/луч/указатель/свет/триггер;
  Готовые механики = 12 китов; 2D/Плагины/Аудио = «Скоро будет».
- Инвентарь = мои воксельные модели; Недавние = модели сообщества; Советы = гайд.
- TopRibbon: кнопка «Модель» → «Toolbox» (открывает магазин); вкладка «Модель»
  переименована в «Редактор моделей» (создание своих воксельных ассетов).
- CSS: topTabs/catGrid/catTile/trendRow/breadcrumb/soon/tips/freeBadge.

Вся прежняя логика моделей (lazy-load, лайки, thumbnails) сохранена внутри
новой структуры. Esc в категории → назад к плиткам.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 01:33:39 +03:00
min
d62739d709 feat(studio): задача 17 — Toolbox «Готовые механики» (gameplay-киты)
Фаза T2: вкладка «Готовые механики» в Тулбоксе — 12 готовых китов
(Бег на Shift, Смена дня/ночи, Счётчик монет, Таймер, Приветствие, Сундук,
Чекпоинт, Конфетти, Парящая платформа, Вертушка, Двойной прыжок, Точка спавна).

- GameplayKits.js — каталог китов (scripts global/on-target + prims), getKit.
- ToolboxModal.jsx — section 'gameplay' + категории (Движение/Мир/Интерфейс/
  Эффекты) + карточки китов + поиск; клик → onPick('kit:<id>').
- KubikonEditor.jsx — insertGameplayKit: создаёт примитивы кита перед камерой,
  привязывает on-target скрипт к первому примитиву, global-скрипты добавляет в
  проект (upsertScript). Безопасность: киты наши, существующий sandbox.

Тест-игра «Игра за 5 минут» id=2544 (dev-режим is_test): town + применённые
киты (welcome/timer/coins/day-night/shift-run + сундук/чекпоинт/конфетти/
платформа). Проверено в плеере — все 5 скриптов исполняются без ошибок.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 01:16:35 +03:00
min
8a7ab9aadf docs(studio): вики задача 16 — карточка #62 + статья «Небесная демка»
Карточка g5 #62 guide-skybox (preview guide-skybox-scene.png, openProjectId
2541) + статья в docsLessons (что получится, API setSkybox/setClouds/setFog/
fadeTo, 3 шага, 4 скриншота день/ночь/космос) + иконка cloud в docsIcons.

Скрины в public/wiki (вне git) — на прод донести вручную при возврате CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:56:50 +03:00
min
a4881ee5ce fix(studio): единая система неба — убрать второе (жёлтое) солнце
Environment больше НЕ рисует свою жёлтую сферу-солнце/луну/фон (флаг
_drawSkyBodies=false) — иначе на небе было два солнца. Единое небо рисует
SkyboxManager (купол + солнечный диск + облака + горы). SkyboxManager стал
единым источником освещения: каждый пресет выставляет direction/intensity/
color солнца и ambient (lights переданы в конструктор), fadeTo плавно ведёт
и свет. Environment оставлен только для day/night cycle совместимости.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:44:25 +03:00
min
71536668f2 feat(studio): задача 16 — кастомное небо (SkyboxManager)
Процедурный gradient-skybox без внешних текстур: купол-сфера с ShaderMaterial
(градиент верх→горизонт→низ + солнечный диск + дымка), low-poly горы на
горизонте, billboard-облака с дрейфом, атмосферный туман, звёзды.

Пресеты: clear-summer-day / lowpoly-roblox / cloudy / sunset / starry-night /
space. Плавный fadeTo между пресетами (анимация цветов купола в tick).

game-API (студия): game.scene.setSkybox/setClouds/setFog,
game.scene.skybox.fadeTo/setSunDirection. Сериализация неба в project_data.
Тик облаков/перехода работает и в редакторе (превью).

Плеер пока НЕ портирован (по указанию — сначала проверка в студии).
Тест-игра «Небесная демка» id=2541 (dev-режим is_test=true, не в ленте).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:17:23 +03:00
min
75e83a9f3b feat(studio): UX-правки редактора — выбор, копирование, userModels, free-drag
- Дерево «Объекты сцены»: авто-раскрытие ветки + скролл к объекту при
  выборе на сцене (HierarchyPanel useEffect на selection).
- Копирование/дублирование примитива сохраняет вращение rotationX/Y/Z
  (SelectionManager клал selection без rotation → копия теряла поворот).
- Копирование/дублирование переносит скрипты объекта на копию
  (_copyScriptsToNewObject + clip.scripts для Ctrl+C/V).
- userModels (воксельные модели) теперь видны в дереве в группе «Мои
  модели», можно выбрать/удалить/прикрепить скрипт (target kind userModel
  уже поддержан в GameRuntime).
- Free-drag: перетаскивание объекта ЛКМ как в Roblox Studio — скольжение
  по полу/поверх объектов с AABB-коллизией (скольжение вдоль преграды).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-06-04 23:54:30 +03:00
33 changed files with 7462 additions and 249 deletions

View File

@ -168,3 +168,4 @@ git push origin feature/моя-фича
- Issues и PR: https://git.rublox.pro/rublox/studio - Issues и PR: https://git.rublox.pro/rublox/studio
- Безопасность: [SECURITY.md](./SECURITY.md) - Безопасность: [SECURITY.md](./SECURITY.md)
<!-- e2e-test 2026-06-07: проверка workflow разработчиков после восстановления -->

View File

@ -104,6 +104,123 @@ export const Shot = ({ src, caption, wide }) => (
// DOCS разделы вики A-J // DOCS разделы вики A-J
// //
//
// AI_CONTEXT полный справочник API скриптов Рублокса одним
// текстом для вставки в нейросеть. Держать в синхроне с
// ScriptSandboxWorker.js при добавлении новых game-API.
//
const AI_CONTEXT = `Ты — помощник по написанию скриптов для онлайн-конструктора 3D-игр «Рублокс» (аналог Roblox, движок Babylon.js). Скрипты пишутся на JavaScript. Всё доступно через глобальный объект game. Ниже — полный API. Пиши ТОЛЬКО рабочий код, используя ТОЛЬКО эти методы. Не выдумывай команды. Координаты в метрах, повороты в РАДИАНАХ (90°=Math.PI/2).
=== ДВА ВИДА СКРИПТОВ ===
1) Глобальный в категории «Скрипты», запускается 1 раз при старте. Управляет всей сценой.
2) На объекте висит на примитиве/модели/блоке. В нём доступен game.self сам объект-носитель.
Всегда указывай пользователю, КУДА писать скрипт.
=== ИГРОК game.player ===
.position -> {x,y,z}; .yaw, .pitch (рад); .forward -> {x,y,z} (куда смотрит); .hp, .maxHp, .alive
.teleport(x,y,z); .damage(n); .kill(); .heal(n); .respawn()
.setSpeed(mul); .setJumpPower(mul); .setGravityMul(mul); .setInputBlocked(bool)
.giveTool(toolId, {equip:true}); .removeTool(id); .setSkin(slug); .setTeam(name)
.isKeyDown('w'|'a'|'s'|'d'|'space'|'shift'...); .boostJump(strength); .setDoubleJump(bool)
.setCameraMode('first'|'third'|'front'); .playEmote('wave'|'dance'|'cheer'|'sit')
=== ОБЪЕКТ-НОСИТЕЛЬ game.self (только в скрипте на объекте) ===
.ref ('primitive:N'); .position -> {x,y,z}; .kind
.onTouch(fn) игрок коснулся; .onUntouch(fn) отошёл
.onClick(fn) клик мышью по объекту
.onInteract(fn, {text:'Открыть', key:'e', distance:4, holdDuration:0}) действие по клавише E
.move(x,y,z); .rotateY(rad); .setColor('#hex'); .setVisible(bool); .setCollide(bool)
.setLabel(text, {color,height}); .clearLabel(); .delete()
=== СЦЕНА game.scene ===
spawn(type, opts) -> объект. type: 'cube'|'sphere'|'cylinder'|'cone'|'pyramid'|'torus'|'wedge'|'plane' (примитивы), 'model:ID', 'block:ID', 'vehicle:car', 'light:point', 'billboard', 'trigger'.
opts: {x,y,z, sx,sy,sz, rotationX,rotationY,rotationZ, color:'#hex', material:'matte'|'neon'|'metal'|'glass'|'studs', name, anchored:true(висит)/false(падает), canCollide, visible, mass, lifetime(сек до авто-удаления)}
delete(ref); move(ref,x,y,z); setRotation(ref,rx,ry,rz); setColor(ref,'#hex'); setMaterial(ref,name); setVisible(ref,bool); setCollide(ref,bool); setOpacity(ref,0..1); setScale(ref,sx,sy,sz)
setLabel(ref,text,opts); clearLabel(ref); setData(ref,key,val); getData(ref,key)
find(name)->[...]; findOne(name)->объект|null; all('primitive'|'model'|'block')
tag(ref,tag); untag(ref,tag); hasTag(ref,tag); getTagged(tag)->[refs]
clone(ref,{dx,dy,dz}); spawnParticles('fire'|'smoke'|'sparks'|'magic'|'explosion'|'confetti', {x,y,z}, {duration,count,color})
setSkybox({preset:'clear-summer-day'|'sunset'|'starry-night'|'space'|'cloudy'}); setFog({color,density}); setClouds({enabled,cover,speed})
spawnNpc(modelType, {x,y,z,hp,name,speed}) -> npc. npc: .position, .hp, .follow('player'|ref), .moveTo(x,z), .stop(), .say(text,sec), .damage(n), .setSpeed(s), .onDeath(fn), .remove()
=== ОБЪЕКТ-ПРОКСИ (то, что вернул spawn/findOne) ===
obj.ref; obj.position; obj.color='#hex'; obj.material='neon'; obj.scale=2; obj.opacity=0.5; obj.visible=false; obj.canCollide=false; obj.position={x,y,z}
obj.move(x,y,z); obj.rotateY(rad); obj.onTouch(fn); obj.onClick(fn); obj.onInteract(fn,opts); obj.clone({dx,dy,dz}); obj.destroy(); obj.tween(props,opts); obj.setLabel(t,opts); obj.addTag(t)
=== HUD game.ui ===
.score = N (счётчик в углу); .timer = N (таймер mm:ss); .showText('текст', сек) (крупно по центру)
.set(id, text, {x,y,color,size}) (своя метка, x/y в %); .remove(id); .clear()
=== GUI (кнопки/панели на экране) game.gui ===
create('button'|'text'|'frame'|'image', {name,x,y,w,h,text,bg:'#hex',color,fontSize,borderRadius}) -> id
onClick(id, fn); update(id, {text,...}); show(id); hide(id); remove(id); tween(id, props, {duration})
=== ТАЙМЕРЫ И КАДРЫ ===
game.onTick(fn) каждый кадр, fn(dt) dt=сек с прошлого кадра
game.after(сек, fn) -> id один раз через N сек
game.every(сек, fn) -> id повторять каждые N сек
game.cancel(id) отменить таймер
game.tween(ref, props, {duration, easing:'linear'|'ease'|'bounce'|'elastic', yoyo, repeat:-1, onDone}) анимация свойств (props: x,y,z, rotationX/Y/Z, sx/sy/sz, color, opacity)
=== СОБЫТИЯ ===
game.onKey('w', fn); game.onKeyUp('w', fn); game.onClick(fn) (fn({point,target}))
game.onPlayerDied(fn); game.onHpChange(fn); game.onPlayerJump(fn); game.onMobKilled(fn)
game.broadcast(name, data) послать сообщение всем скриптам
game.onMessage(name, fn) принять сообщение (fn(data))
=== ФИЗИКА game.physics ===
raycast(origin, dir, {maxDistance}) -> {hit, ref, point, distance} (синхронно, для стрельбы)
applyImpulse(ref, {x,y,z}); setVelocity(ref, {x,y,z}); explode({x,y,z}, radius, {damage}); passThrough(refОrTag, bool)
=== ЭФФЕКТЫ game.fx ===
damageFloater(posОrRef, value, {isCrit,isHeal,isMiss,color}) всплывающая цифра урона
autoMobFloaters(true) авто-цифры над всеми мобами
beam({from,to,color,width}) -> луч; pointer({from,to,preset:'quest'}); trail(ref,{color,width})
=== ЗВУК game.sound ===
play('coin'|'jump'|'win'|'lose'|'click'|'hit'|'pickup' | 'sound_N', {volume,pitch,at:{x,y,z},loop}) -> {stop()}
=== КАМЕРА game.camera ===
shake(amp, sec); setFov(deg); focusOn(ref,{distance,height}); cutscene([{x,y,z}...], {lookAt,segDuration}); reset()
=== ОКРУЖЕНИЕ game.environment ===
setSkyColor('#hex'); setFog({enabled,color,density}); setTimeOfDay(0..24)
=== ИНВЕНТАРЬ / ПРЕДМЕТЫ ===
game.items.define([{id,name,emoji,rarity:'common'|'rare'|'epic'|'legendary',maxStack,onUseEffect:'heal:50'}])
game.inventory.give(id,count); .take(id,count); .has(id); .open(); .list()
=== ЛИДЕРБОРД И ДОСТИЖЕНИЯ ===
game.leaderstats.define('Монеты', {initial:0}); game.leaderstats.me.add('Монеты', 1); .me.set(name,val); .me.get(name)
game.achievements.define([{id,name,description,icon,rarity}]); game.achievements.unlock(id)
=== СОХРАНЕНИЕ (между сессиями) game.save ===
.merge(namespace, {patch:{поле:знач}, increment:{счётчик:дельта}, max:{рекорд:знач}})
.get(namespace, fn) (fn(data|null)); .increment(ns,key,delta); .leaderboard(ns,key,'desc',fn)
=== МОДАЛКИ game.modal ===
.dialog(npcName, [строки], onDone); .confirmation(title, body, onYes, onNo); .lootbox([{name,color,icon,rarity}], onPick); .close()
=== УТИЛИТЫ ===
game.log(...) в консоль; game.random(min,max,целое?); game.clamp(v,min,max); game.lerp(a,b,t); game.distance(a,b) (точки или ref); game.format.time(сек,'mm:ss'); game.format.number(n,'short')
=== ВАЖНЫЕ ПРАВИЛА ===
- Повороты в РАДИАНАХ (Math.PI/2 = 90°).
- Счётчики/общую логику держи в ОДНОМ глобальном скрипте; объекты шлют game.broadcast (скрипты объектов не видят переменные друг друга).
- spawn примитива: тип без префикса ('cube'), модели с 'model:', машина 'vehicle:car'.
- material только: matte, neon, metal, glass, studs.
- Для сбора предмета: game.self.onTouch(()=>{ game.broadcast('coin'); game.self.delete(); }).
- Не используй require() и DOM/window только game.* и обычный JS.
ПРИМЕР (килблок-лава, скрипт НА объекте):
game.self.onTouch(() => { game.player.kill(); game.ui.showText('Лава!', 2); });
ПРИМЕР (сбор монет, глобальный скрипт):
let s = 0; game.ui.score = 0;
game.onMessage('coin', () => { s++; game.ui.score = s; game.sound.play('coin'); });
Теперь напиши скрипт под мою задачу (она ниже). Укажи, КУДА его вставить (глобальный или на объект).`;
export const DOCS = [ export const DOCS = [
// //
// РАЗДЕЛ A ОСНОВЫ // РАЗДЕЛ A ОСНОВЫ
@ -2029,6 +2146,167 @@ game.log('Игроков в комнате:', game.players.count());
// когда новый игрок зашёл // когда новый игрок зашёл
game.onPlayerJoin((p) => { game.onPlayerJoin((p) => {
game.ui.showText(p.name + ' присоединился!', 2); game.ui.showText(p.name + ' присоединился!', 2);
});`}</Code>
</>
),
},
{
id: 'leaderstats',
title: 'G7. Лидерборды и достижения',
body: (
<>
<p>
<b>Лидерборд</b> таблица очков игроков справа-сверху (как
в Roblox). Объяви стат и меняй значение:
</p>
<ScriptKind kind="global" />
<Code>{`game.leaderstats.define('Монеты', { initial: 0, icon: 'coin' });
game.leaderstats.define('Уровень', { initial: 1 });
game.leaderstats.me.add('Монеты', 5); // +5 текущему игроку
game.leaderstats.me.set('Уровень', 2); // задать значение
const c = game.leaderstats.me.get('Монеты');`}</Code>
<p>
<b>Достижения</b> всплывающие ачивки с редкостью и звуком:
</p>
<Code>{`game.achievements.define([
{ id: 'first_coin', name: 'Первая монетка', description: 'Собери монету', icon: 'coin', rarity: 'common' },
{ id: 'rich', name: 'Богач', description: '100 монет', icon: 'trophy', rarity: 'legendary' }
]);
game.achievements.unlock('first_coin');
// или авто-разблокировка по статy:
game.achievements.bindToStat('rich', 'Монеты', 100);`}</Code>
<Note>
Лидерборд и достижения сохраняются в БД и подтягиваются при
следующем входе игрока.
</Note>
</>
),
},
{
id: 'damage-floaters',
title: 'G8. Облачка урона (damage floaters)',
body: (
<>
<p>
Всплывающие <b>цифры урона</b> над врагом как в RPG. Самый
простой способ авто-режим (цифры над всеми мобами при уроне):
</p>
<ScriptKind kind="global" />
<Code>{`game.fx.autoMobFloaters(true);`}</Code>
<p>Ручной вызов в нужный момент:</p>
<Code>{`game.fx.damageFloater(enemy.position, 25); // обычный урон
game.fx.damageFloater(enemy.position, 100, { isCrit: true }); // крит крупно, жёлтый
game.fx.damageFloater('player', 30, { isHeal: true }); // лечение, зелёный
game.fx.damageFloater(pos, 0, { isMiss: true }); // промах MISS`}</Code>
</>
),
},
{
id: 'items-inventory',
title: 'G9. Предметы и инвентарь с редкостями',
body: (
<>
<p>
Полноценный инвентарь (сетка + хотбар, стаки, редкости).
Сначала опиши предметы, потом выдавай:
</p>
<ScriptKind kind="global" />
<Code>{`game.items.define([
{ id: 'berry', name: 'Ягоды', emoji: '🍓', rarity: 'common', maxStack: 16 },
{ id: 'potion', name: 'Зелье', emoji: '🧪', rarity: 'rare', maxStack: 8, onUseEffect: 'heal:50' },
{ id: 'sword', name: 'Меч', emoji: '⚔️', rarity: 'legendary', maxStack: 1 },
]);
game.inventory.give('sword', 1);
game.inventory.give('berry', 5); // стак`}</Code>
<p>Сбор предмета с земли (скрипт на предмете):</p>
<ScriptKind kind="object" on="собираемый предмет" />
<Code>{`game.self.onInteract(() => {
game.inventory.give('berry', 2);
game.self.delete();
}, { text: 'Собрать', key: 'e', distance: 3 });`}</Code>
<Note>
Редкости: common (серый), uncommon (зелёный), rare (голубой),
epic (фиолетовый), legendary (золотой). Окно инвентаря
клавиша <kbd className="kbd">I</kbd>, drag-drop, ПКМ-меню.
</Note>
</>
),
},
{
id: 'sky-environment',
title: 'G10. Небо, облака, туман, время суток',
body: (
<>
<p>Кастомное небо одной строкой пресеты:</p>
<ScriptKind kind="global" />
<Code>{`game.scene.setSkybox({ preset: 'sunset' });
// пресеты: clear-summer-day | lowpoly-roblox | cloudy | sunset | starry-night | space`}</Code>
<p>Облака, туман и плавный переход:</p>
<Code>{`game.scene.setClouds({ enabled: true, cover: 0.5, speed: 0.02 });
game.scene.setFog({ color: '#dddddd', density: 0.006 });
game.scene.skybox.fadeTo({ preset: 'starry-night' }, 3); // плавно за 3 сек`}</Code>
<p>Простое управление цветом неба и временем суток:</p>
<Code>{`game.environment.setSkyColor('#0a1024'); // тёмное небо
game.environment.setTimeOfDay(0); // ночь (0..24)
game.environment.setTimeOfDay(12); // полдень`}</Code>
</>
),
},
{
id: 'modal-menu-loading',
title: 'G11. Диалоги, меню, экран загрузки',
body: (
<>
<p><b>Диалог NPC</b> построчно:</p>
<ScriptKind kind="global" />
<Code>{`game.modal.dialog('Староста', [
'Привет, путник!',
'Собери 10 монет и возвращайся.',
], () => game.ui.showText('Квест начат!', 2));`}</Code>
<p><b>Окно Да/Нет</b> и <b>лутбокс</b>:</p>
<Code>{`game.modal.confirmation('Выход', 'Точно выйти?', () => game.player.respawn(), null);
game.modal.lootbox([
{ name: 'Меч', color: '#f0ad4e', rarity: 'legendary' },
{ name: 'Щит', color: '#5bc0de', rarity: 'rare' },
], (item) => game.ui.showText('Выпал: ' + item.name, 3));`}</Code>
<p><b>Экран загрузки</b> при переходе между уровнями:</p>
<Code>{`game.loading.show({
style: 'ken-burns',
placeName: 'Глава 2 — Шахта',
studioName: 'Моя студия',
duration: 2
});
game.loading.onHide(() => game.ui.showText('Добро пожаловать!', 2));`}</Code>
<Note>
Стартовый экран загрузки игры настраивается без кода
см. раздел вики «Экран загрузки» (карточка в разборе игр) и
вкладку «Стартовый экран» в настройках проекта.
</Note>
</>
),
},
{
id: 'vehicles-menu',
title: 'G12. Машины и главное меню',
body: (
<>
<p>
<b>Машина</b>, на которой можно ездить (вход hold-F, WASD руль):
</p>
<ScriptKind kind="global" />
<Code>{`game.scene.spawn('vehicle:car', { x: 0, y: 1, z: 0, name: 'Тачка' });
game.onVehicleEnter(() => game.ui.showText('За рулём! WASD — ехать', 2));
game.onVehicleExit(() => game.ui.showText('Вышел', 1));`}</Code>
<p><b>Главное меню</b> игры с живой камерой и кнопкой ИГРАТЬ:</p>
<Code>{`game.mainMenu.show({
title: 'МОЯ ИГРА',
camera: 'orbit',
playButtonText: 'ИГРАТЬ',
patchNotes: { title: 'Что нового', items: ['Добавлены машины', 'Новая карта'] },
onPlay: () => game.ui.showText('Поехали!', 2)
});`}</Code> });`}</Code>
</> </>
), ),
@ -2398,4 +2676,632 @@ game.onTick(() => {
}, },
], ],
}, },
//
// РАЗДЕЛ СКРИПТЫ: ГОТОВЫЕ РЕЦЕПТЫ
//
{
id: 'recipes',
icon: 'code',
title: 'Скрипты: рецепты',
summary: 'Готовые мини-скрипты, которые можно скопировать и вставить: килблок, сбор предметов, исчезновение и телепорт при касании, кнопки, таймеры, все свойства примитивов.',
sections: [
{
id: 'recipes-howto',
title: 'S1. Куда писать скрипты',
body: (
<>
<p>В Рублоксе есть <b>два вида скриптов</b>:</p>
<ul>
<li><b>Глобальный</b> создаётся в иерархии в категории
<b> «Скрипты»</b> (кнопка «+»). Запускается один раз при
старте игры. В нём управляешь всей сценой через
<code> game.scene</code>, <code>game.player</code>, <code>game.ui</code>.</li>
<li><b>На объекте</b> вешается прямо на примитив/модель/блок.
В нём работает <code>game.self</code> это сам объект-носитель.
Удобно для «этот блок убивает», «этот сундук открывается».</li>
</ul>
<Note>
В рецептах ниже над каждым примером написано, <b>куда его
писать</b>. Просто скопируй код и вставь в нужный скрипт.
</Note>
<p>
Полезное: <code>game.log(...)</code> печатает в консоль (значок
«Консоль» внизу) для отладки. <code>game.player.position</code>
где сейчас игрок (<code>{'{x, y, z}'}</code>).
</p>
</>
),
},
{
id: 'recipes-touch',
title: 'S2. Касание объекта (onTouch)',
body: (
<>
<p>
Самое частое событие <b>игрок коснулся объекта</b>. Вешаем
скрипт на объект и подписываемся через <code>game.self.onTouch</code>.
</p>
<ScriptKind kind="object" on="любой объект (плита, зона, предмет)" />
<Code>{`// Игрок наступил на объект — показать надпись и звук
game.self.onTouch(() => {
game.ui.showText('Ты коснулся плиты!', 2);
game.sound.play('click');
});
// Когда игрок ушёл с объекта
game.self.onUntouch(() => {
game.ui.showText('Отошёл', 1);
});`}</Code>
<p>
Можно подписаться и на <b>чужой</b> объект из глобального
скрипта найди его по имени:
</p>
<ScriptKind kind="global" />
<Code>{`const trap = game.scene.findOne('Ловушка');
trap.onTouch(() => game.player.damage(20));`}</Code>
</>
),
},
{
id: 'recipes-killblock',
title: 'S3. Килблок — урон и смерть при касании',
body: (
<>
<p>
<b>Килблок</b> объект, который наносит урон или мгновенно
убивает, когда игрок его коснулся (лава, шипы, кислота).
</p>
<ScriptKind kind="object" on="блок-ловушку (лава, шипы)" />
<Code>{`// Мгновенная смерть при касании
game.self.onTouch(() => {
game.player.kill();
game.ui.showText('💀 Ты сгорел в лаве!', 2);
});`}</Code>
<p>Если хочешь не убивать сразу, а наносить урон:</p>
<Code>{`// Урон 25 при касании (учитывает кадры неуязвимости)
game.self.onTouch(() => {
game.player.damage(25);
game.camera.shake(0.2, 0.3); // лёгкая тряска
});`}</Code>
<p>
<b>Постоянный урон</b>, пока игрок стоит в зоне (например,
ядовитое облако) урон каждые 0.5 сек, пока касается:
</p>
<Code>{`let inside = false;
game.self.onTouch(() => { inside = true; });
game.self.onUntouch(() => { inside = false; });
game.every(0.5, () => {
if (inside) game.player.damage(5);
});`}</Code>
<Try>
Сделай красный неоновый куб, повесь на него скрипт смерти
получится лава. Поставь его в проёме как преграду.
</Try>
</>
),
},
{
id: 'recipes-disappear',
title: 'S4. Исчезновение при касании (сбор монет)',
body: (
<>
<p>
Предмет <b>исчезает</b>, когда игрок его коснулся основа
сбора монеток, ключей, бонусов.
</p>
<ScriptKind kind="object" on="монетку / собираемый предмет" />
<Code>{`// Простое исчезновение + звук
game.self.onTouch(() => {
game.sound.play('coin');
game.self.delete();
});`}</Code>
<p>
Со <b>счётчиком</b>: предмет сообщает глобальному скрипту,
тот считает. На монетке:
</p>
<Code>{`game.self.onTouch(() => {
game.broadcast('coin'); // сообщить всем скриптам
game.self.delete();
});`}</Code>
<p>В глобальном скрипте приём и счёт:</p>
<ScriptKind kind="global" />
<Code>{`let score = 0;
game.ui.score = 0;
game.onMessage('coin', () => {
score = score + 1;
game.ui.score = score; // обновить счётчик в углу
if (score >= 10) game.ui.showText('🏆 Собрал все!', 3);
});`}</Code>
<Note>
<b>Не</b> ставь счётчик на саму монетку каждая монетка это
свой скрипт, они не видят переменные друг друга. Считай в
одном глобальном скрипте, монетки только шлют
<code> game.broadcast</code>.
</Note>
</>
),
},
{
id: 'recipes-teleport',
title: 'S5. Телепорт и смена позиции при касании',
body: (
<>
<p>
При касании <b>переместить игрока</b> (портал) или
<b> сдвинуть сам объект</b> (движущаяся платформа).
</p>
<p><b>Портал</b> телепорт игрока в точку:</p>
<ScriptKind kind="object" on="портал" />
<Code>{`game.self.onTouch(() => {
game.player.teleport(0, 20, 50); // x, y, z назначения
game.sound.play('win');
game.camera.shake(0.15, 0.2);
});`}</Code>
<p>
<b>Сдвинуть сам объект</b> при касании (например, опустить
мост). <code>game.self.move</code> ставит новую позицию:
</p>
<Code>{`let opened = false;
game.self.onTouch(() => {
if (opened) return;
opened = true;
const p = game.self.position;
game.self.move(p.x, p.y - 3, p.z); // уехал вниз на 3 м
});`}</Code>
<p>
<b>Плавно</b> сдвинуть через <code>game.tween</code> (анимация):
</p>
<Code>{`// дверь уезжает вбок за 1 секунду
const p = game.self.position;
game.self.onTouch(() => {
game.tween(game.self.ref, { x: p.x + 4 }, { duration: 1, easing: 'ease' });
});`}</Code>
</>
),
},
{
id: 'recipes-primitive-props',
title: 'S6. Все свойства примитивов из скрипта',
body: (
<>
<p>
Любой примитив можно <b>создать</b> и <b>менять</b> из
скрипта. Вот все свойства и как их задать.
</p>
<ScriptKind kind="global" />
<p><b>Создать примитив</b> со всеми свойствами:</p>
<Code>{`const box = game.scene.spawn('cube', {
x: 0, y: 2, z: 0, // позиция
sx: 2, sy: 1, sz: 3, // размер по осям (ширина/высота/глубина)
rotationX: 0, rotationY: 0.8, rotationZ: 0, // поворот в радианах
color: '#ff5533', // цвет (hex)
material: 'neon', // matte | neon | metal | glass | studs
name: 'МойКуб',
anchored: true, // true = висит на месте; false = падает (физика)
canCollide: true, // false = игрок проходит насквозь
visible: true,
mass: 5, // масса (если anchored:false)
});`}</Code>
<p>Типы примитивов для <code>spawn</code>:</p>
<Code>{`'cube' 'sphere' 'cylinder' 'cone' 'pyramid' 'torus' 'wedge' 'cornerwedge' 'plane'`}</Code>
<p><b>Менять свойства</b> уже существующего объекта:</p>
<Code>{`game.scene.setColor(box, '#00ff88'); // цвет
game.scene.setMaterial(box, 'glass'); // материал
game.scene.setVisible(box, false); // спрятать
game.scene.setCollide(box, false); // сделать проходимым
game.scene.setOpacity(box, 0.4); // полупрозрачность (1=видно, 0=невидимо)
game.scene.setScale(box, 3, 1, 1); // новый размер
game.scene.move(box, 5, 2, 0); // переместить
game.scene.setRotation(box, 0, 1.57, 0); // повернуть (радианы)
game.scene.setLabel(box, 'Привет!', { color:'#fff', height: 2.5 });`}</Code>
<p>
Удобнее через <b>объект-прокси</b> (присваивание свойств):
</p>
<Code>{`const obj = game.scene.findOne('МойКуб');
obj.color = '#ffd700';
obj.material = 'metal';
obj.scale = 2; // равномерный масштаб
obj.opacity = 0.5;
obj.visible = false;
obj.canCollide = false;
obj.position = { x: 0, y: 10, z: 0 };
obj.rotateY(1.57);
obj.destroy(); // удалить`}</Code>
<Note>
<b>Радианы:</b> поворот задаётся в радианах, не градусах.
90° = <code>Math.PI/2</code> 1.57, 180° = <code>Math.PI</code> 3.14.
</Note>
</>
),
},
{
id: 'recipes-anim',
title: 'S7. Движение, вращение, мигание (onTick и tween)',
body: (
<>
<p>
<b>Вращающийся объект</b> (монета, портал) крутим каждый
кадр через <code>game.onTick</code> (dt = время кадра):
</p>
<ScriptKind kind="object" on="вращающийся объект" />
<Code>{`let angle = 0;
game.onTick((dt) => {
angle = angle + dt * 2; // скорость вращения
game.self.rotateY(angle);
});`}</Code>
<p><b>Парение вверх-вниз</b> (плавно качается):</p>
<Code>{`const start = game.self.position;
let t = 0;
game.onTick((dt) => {
t = t + dt;
const dy = Math.sin(t * 2) * 0.4; // амплитуда 0.4 м
game.self.move(start.x, start.y + dy, start.z);
});`}</Code>
<p><b>Пульсация размера</b> через tween (бесконечно туда-обратно):</p>
<Code>{`game.tween(game.self.ref, { sy: 1.4 }, {
duration: 0.6, easing: 'ease', yoyo: true, repeat: -1
});`}</Code>
<p><b>Мигание цветом</b> каждые полсекунды:</p>
<Code>{`let on = false;
game.every(0.5, () => {
on = !on;
game.self.setColor(on ? '#ff0000' : '#330000');
});`}</Code>
</>
),
},
{
id: 'recipes-button-door',
title: 'S8. Кнопка по E и дверь',
body: (
<>
<p>
<b>Взаимодействие по клавише E</b> (как в Roblox ProximityPrompt)
через <code>game.self.onInteract</code>. Появляется подсказка
«[E] » когда игрок рядом.
</p>
<ScriptKind kind="object" on="кнопку / рычаг / сундук" />
<Code>{`game.self.onInteract(() => {
game.ui.showText('Открыто!', 2);
game.broadcast('open-door');
}, { text: 'Открыть', key: 'e', distance: 4 });`}</Code>
<p>На двери глобальный/объектный скрипт, который её открывает:</p>
<ScriptKind kind="object" on="дверь" />
<Code>{`const closed = game.self.position;
game.onMessage('open-door', () => {
// плавно уехать вверх (открыться)
game.tween(game.self.ref, { y: closed.y + 4 }, { duration: 1, easing: 'ease' });
game.self.setCollide(false); // через неё можно пройти
});`}</Code>
<Note>
<code>holdDuration: 1</code> в опциях onInteract держать E
1 секунду (для важных действий). <code>distance</code>
с какого расстояния появляется подсказка.
</Note>
</>
),
},
{
id: 'recipes-gui-timer',
title: 'S9. Надписи на экране, таймер, кнопки GUI',
body: (
<>
<p><b>HUD-надписи</b> в углу и по центру:</p>
<ScriptKind kind="global" />
<Code>{`game.ui.score = 0; // счётчик «Очки: 0» в углу
game.ui.score = 50; // обновить
game.ui.timer = 60; // таймер mm:ss в углу
game.ui.showText('Старт!', 2); // крупно по центру на 2 сек
game.ui.set('hp', 'Жизни: 3', { x: 50, y: 90, color: '#fff' }); // своя метка
game.ui.remove('hp'); // убрать метку`}</Code>
<p><b>Обратный отсчёт</b> и проигрыш по времени:</p>
<Code>{`let time = 30;
game.ui.timer = time;
const id = game.every(1, () => {
time = time - 1;
game.ui.timer = time;
if (time <= 0) {
game.cancel(id);
game.ui.showText('Время вышло!', 3);
game.player.kill();
}
});`}</Code>
<p><b>Кнопка на экране</b> (GUI) и обработка клика:</p>
<Code>{`const btn = game.gui.create('button', {
name: 'start', text: 'НАЧАТЬ', x: 50, y: 80,
w: 20, h: 8, bg: '#3a6ee0', color: '#fff', fontSize: 18, borderRadius: 12
});
game.gui.onClick(btn, () => {
game.ui.showText('Поехали!', 2);
game.gui.hide(btn);
});`}</Code>
</>
),
},
{
id: 'recipes-spawn-fall',
title: 'S10. Спавн, падение, проверка падения вниз',
body: (
<>
<p><b>Спавнить объекты с неба</b> каждую секунду (ловилка):</p>
<ScriptKind kind="global" />
<Code>{`game.every(1, () => {
const x = game.random(-10, 10);
game.scene.spawn('sphere', {
x: x, y: 20, z: 0,
color: '#ffd700', material: 'neon',
anchored: false, // будет падать (физика)
lifetime: 8 // само исчезнет через 8 сек
});
});`}</Code>
<p>
<b>Игрок упал вниз</b> (за карту) вернуть на спавн. Проверяем
высоту каждый кадр:
</p>
<Code>{`game.onTick(() => {
if (game.player.position.y < -10) {
game.player.respawn();
game.ui.showText('Упал! Назад на старт.', 2);
}
});`}</Code>
<p><b>Финиш</b> дошёл до зоны, победа:</p>
<ScriptKind kind="object" on="финишную плиту" />
<Code>{`game.self.onTouch(() => {
game.ui.showText('🏁 ПОБЕДА!', 4);
game.sound.play('win');
game.player.setInputBlocked(true); // заморозить управление
});`}</Code>
</>
),
},
{
id: 'recipes-npc-enemy',
title: 'S11. Враг, который идёт за игроком',
body: (
<>
<p>
<b>NPC/враг</b>, который преследует игрока и наносит урон.
</p>
<ScriptKind kind="global" />
<Code>{`const enemy = game.scene.spawnNpc('zombie', {
x: 10, y: 0, z: 10,
hp: 100, name: 'Зомби', speed: 3
});
enemy.follow('player'); // идти за игроком
enemy.say('Хочу тебя поймать!', 3);
enemy.onDeath(() => {
game.ui.showText('Враг повержен!', 2);
game.fx.damageFloater(enemy.position, 0, { isHeal: true });
});`}</Code>
<p>Урон игроку, когда враг близко:</p>
<Code>{`game.every(0.5, () => {
const d = game.distance(enemy.position, game.player.position);
if (d < 2) game.player.damage(10);
});`}</Code>
<Note>
Облачка урона над всеми мобами одной строкой:
<code> game.fx.autoMobFloaters(true)</code>.
</Note>
</>
),
},
{
id: 'recipes-save',
title: 'S12. Сохранение прогресса и лидерборд',
body: (
<>
<p>
<b>Лидерборд</b> (таблица очков справа) объяви стат и
прибавляй:
</p>
<ScriptKind kind="global" />
<Code>{`game.leaderstats.define('Монеты', { initial: 0 });
// прибавить текущему игроку:
game.leaderstats.me.add('Монеты', 1);`}</Code>
<p>
<b>Сохранение между сессиями</b> (прогресс не теряется после
выхода):
</p>
<Code>{`// записать
game.save.merge('progress', {
patch: { level: 3 }, // обычные поля
increment: { coins: 10 }, // атомарно прибавить
max: { bestScore: 5000 } // запишется только если больше старого
});
// прочитать при старте
game.save.get('progress', (data) => {
if (data) {
game.ui.showText('С возвращением! Уровень ' + data.level, 3);
}
});`}</Code>
<Try>
Собери всё вместе: монетки шлют broadcast глобальный скрипт
считает в leaderstats раз в N монет сохраняет через
game.save. Получится игра с прогрессом как в настоящем Roblox.
</Try>
</>
),
},
],
},
//
// РАЗДЕЛ СОВМЕСТНОЕ РЕДАКТИРОВАНИЕ (Team Create)
//
{
id: 'collab',
icon: 'users',
title: 'Вместе с друзьями',
summary: 'Совместное редактирование игры в реальном времени: приглашай друзей, стройте сцену вдвоём, видьте курсоры и правки друг друга.',
sections: [
{
id: 'collab-what',
title: 'V1. Что такое совместное редактирование',
body: (
<>
<p>
<b>Совместное редактирование (Team Create)</b> это когда
одну игру в студии редактируют <b>несколько человек
одновременно</b>. Ты строишь дом, друг в это же время
ставит деревья и каждый видит правки другого
<b> вживую</b>, без перезагрузки страницы.
</p>
<p>Что синхронизируется в реальном времени:</p>
<ul>
<li><b>Блоки</b> поставил/удалил блок, друг сразу видит;</li>
<li><b>Примитивы</b> добавил, подвинул, повернул, изменил
цвет или размер;</li>
<li><b>Модели</b> добавил из Тулбокса или удалил;</li>
<li><b>Курсоры друзей</b> где сейчас «мышка» каждого
соавтора (цветная точка с ником);</li>
<li><b>Кто онлайн</b> список соавторов с цветными
аватарками вверху сцены.</li>
</ul>
<Shot src="collab-scene.png" wide
caption="Сцена при совместном редактировании: вверху — кто онлайн (аватарки «У» и «М», всего 2), на сцене — курсор соавтора «УтреннийРозмарин4633» (оранжевая точка с ником) и блок, который он только что поставил." />
<Note>
Это как Google Документы, только для 3D-игр. Удобно делать
игру в команде, помогать ученику или показывать, как что
устроено прямо в его проекте.
</Note>
</>
),
},
{
id: 'collab-invite',
title: 'V2. Как пригласить друга',
body: (
<>
<p>
Открой свою игру в студии и перейди на вкладку
<kbd className="kbd">Игра</kbd> в верхней панели. Там, в
группе <b>«Вместе»</b>, есть кнопка
<kbd className="kbd">Пригласить</kbd>.
</p>
<Shot src="collab-button.png" wide
caption="Вкладка «Игра» → группа «Вместе» → кнопка «Пригласить». Когда соавторы подключатся, на ней появится счётчик «Вместе (2)»." />
<Step n={1}>
Нажми <kbd className="kbd">Пригласить</kbd> ссылка-приглашение
автоматически скопируется в буфер обмена (появится
уведомление).
</Step>
<Step n={2}>
Отправь эту ссылку другу (в чат, мессенджер как угодно).
</Step>
<Step n={3}>
Друг открывает ссылку и попадает в <b>ту же сцену</b>.
Теперь вы редактируете вместе. На кнопке появится счётчик:
<b> «Вместе (2)»</b>.
</Step>
<Note>
Приглашать может только <b>автор игры</b>. Ссылка действует
24 часа. Друг должен быть зарегистрирован на Рублоксе и
войти в свой аккаунт.
</Note>
</>
),
},
{
id: 'collab-how',
title: 'V3. Как это работает и правила',
body: (
<>
<p>
<b>Блокировка объекта.</b> Пока один соавтор выделил объект
и двигает его этот объект <b>заблокирован</b> для других
(никто другой не сможет его двигать одновременно). Так
правки не конфликтуют. Как только выделение снято объект
снова свободен.
</p>
<p>
<b>Кто сохраняет.</b> Игру в базу сохраняет <b>автор</b>
(владелец проекта). Приглашённые друзья видят пометку
«Совместное редактирование» вместо кнопок Сохранить и
Опубликовать это правильно: они помогают строить, а
управляет игрой автор.
</p>
<p>
<b>Цвета.</b> У каждого соавтора свой цвет им подсвечены
его курсор и аватарка, чтобы было понятно, кто что делает.
</p>
<Note>
Если друг ненадолго потерял связь (вылетел интернет) у
него есть несколько секунд, чтобы переподключиться и
продолжить с того же места.
</Note>
<p>
<b>Совет:</b> договоритесь заранее, кто какую часть карты
делает (например, один здания, другой ландшафт и
декор) так работать вместе быстрее и без накладок.
</p>
</>
),
},
],
},
//
// РАЗДЕЛ КОНТЕКСТ ДЛЯ НЕЙРОНКИ (AI)
//
{
id: 'ai-context',
icon: 'lightbulb',
title: 'Контекст для нейронки',
summary: 'Готовый текст со всем API скриптов Рублокса. Скопируй его целиком, вставь в ChatGPT/нейросеть — и она будет писать тебе рабочие скрипты под твою задачу.',
sections: [
{
id: 'ai-howto',
title: 'AI1. Как писать скрипты с нейросетью',
body: (
<>
<p>
Хочешь, чтобы скрипт написала <b>нейросеть</b> (ChatGPT,
DeepSeek, Claude и т.п.)? Проблема в том, что нейросеть <b>не
знает</b> устройство Рублокса придумает несуществующие
команды. Решение простое:
</p>
<Step n={1}>
Открой статью <b>«AI2. Контекст скопируй в нейросеть»</b> ниже.
</Step>
<Step n={2}>
<b>Выдели и скопируй весь текст</b> из серого блока (он
описывает все команды Рублокса).
</Step>
<Step n={3}>
Вставь его в нейросеть <b>первым сообщением</b>. Затем добавь
свою задачу, например: «Напиши скрипт: при касании синего куба
игрок прыгает высоко и играет звук».
</Step>
<Step n={4}>
Нейросеть выдаст готовый код. Скопируй его в скрипт в студии
(глобальный или на объекте она подскажет куда).
</Step>
<Note>
Если нейросеть всё равно ошиблась в команде скинь ей текст
ошибки из «Консоли» студии, она исправит. И всегда проверяй
результат запуском игры.
</Note>
</>
),
},
{
id: 'ai-context-text',
title: 'AI2. Контекст — скопируй в нейросеть',
body: (
<>
<p>
<b>Выдели весь текст ниже и скопируй</b> (Ctrl+A внутри блока
или мышью), затем вставь в нейросеть перед своим вопросом:
</p>
<Code>{AI_CONTEXT}</Code>
</>
),
},
],
},
]; ];

View File

@ -353,4 +353,29 @@ export const GAMES = [
desc: 'Полноценные машины: подходишь, держишь F — садишься за руль, WASD рулят, камера следует за авто, спидометр снизу. E — выйти. Готовые 3D-модели машин.', desc: 'Полноценные машины: подходишь, держишь F — садишься за руль, WASD рулят, камера следует за авто, спидометр снизу. E — выйти. Готовые 3D-модели машин.',
mechanics: ['game.scene.spawn(\'vehicle:car\')', 'аркадная физика (газ/руль/тормоз)', 'hold-F вход / E выход', 'камера за машиной (V меняет)', 'HUD водителя (спидометр+передача)', 'onVehicleEnter/onVehicleExit'], mechanics: ['game.scene.spawn(\'vehicle:car\')', 'аркадная физика (газ/руль/тормоз)', 'hold-F вход / E выход', 'камера за машиной (V меняет)', 'HUD водителя (спидометр+передача)', 'onVehicleEnter/onVehicleExit'],
previewShot: 'guide-taxisim-scene.png', openProjectId: 2436, ready: true }, previewShot: 'guide-taxisim-scene.png', openProjectId: 2436, ready: true },
{ id: 'guide-skybox', num: 62, group: 'g5', stars: 2, icon: 'cloud',
title: 'Небесная демка — кастомное небо',
desc: 'Одной строкой меняешь небо: голубой день, закат, звёздная ночь, космос. Облака, туман, далёкие горы и плавные переходы между пресетами.',
mechanics: ['game.scene.setSkybox({ preset })', 'game.scene.setClouds / setFog', 'skybox.fadeTo(opts, сек) — плавный переход', '6 пресетов: день/lowpoly/закат/ночь/космос', 'небо = единый источник света сцены', 'облака-дрейф + дымка горизонта'],
previewShot: 'guide-skybox-scene.png', openProjectId: 2541, ready: true },
{ id: 'guide-leaderstats', num: 63, group: 'g5', stars: 2, icon: 'trophy',
title: 'Сбор монет — лидерборды и достижения',
desc: 'Таблица лидеров справа-сверху (монеты/время/уровень) + всплывающие достижения с редкостью и звуком. Прогресс сохраняется в БД между сессиями.',
mechanics: ['game.leaderstats.define / me.add', 'HUD-таблица топ-10 (сортировка по primary)', 'game.achievements.define / unlock', 'bindToStat — авто-награда по статy', 'toast 4 редкости + очередь', 'кубок → страница достижений', 'сохранение в БД (savegame)'],
previewShot: 'guide-leaderstats-scene.png', openProjectId: 2616, ready: true },
{ id: 'guide-floaters', num: 64, group: 'g5', stars: 2, icon: 'sparkles',
title: 'Зомби-арена — бластер и цифры урона',
desc: 'Шутер: волны зомби бегут к игроку, бластер их отстреливает, над целью всплывают облачка урона. Авто-floater над любым мобом одной строкой + ручной game.fx.damageFloater (крит/хил/мана/промах/стек/комикс).',
mechanics: ['game.fx.damageFloater(pos, value, opts)', 'game.fx.autoMobFloaters(true) — облачко над NPC при уроне', 'game.player.giveTool(\'blaster-...\') — бластер', 'бластер от 3-го лица — в точку клика', 'spawnNpc + follow(\'player\') — зомби-волны', 'isCrit/isHeal/isMana/isMiss, стек ×N, комикс', 'object pool 30 планов (без лагов)'],
previewShot: 'guide-floaters-scene.png', openProjectId: 2676, ready: true },
{ id: 'guide-inventory', num: 65, group: 'g5', stars: 2, icon: 'box',
title: 'Сбор и сортировка — инвентарь с drag-drop',
desc: 'Полный инвентарь как в Minecraft/RPG: сетка 8×5 + хотбар 9, стаки, 5 редкостей (цвет рамки), перетаскивание мышью, ПКМ-меню, tooltip, сортировка. Собираешь предметы — стаки растут.',
mechanics: ['game.items.define([...]) — предметы (редкость/стак/иконка)', 'game.inventory.give / take', 'окно по I — сетка 8×5 + хотбар 9 (1-9)', 'drag-drop между слотами (swap + merge)', 'стаки с maxStack, 5 редкостей', 'ПКМ-меню: использовать / разделить / выбросить', 'tooltip + сортировка по редкости'],
previewShot: 'guide-inventory-scene.png', openProjectId: 2685, ready: true },
{ id: 'guide-loadingscreen', num: 66, group: 'g5', stars: 2, icon: 'loader',
title: 'Экран загрузки — Ken Burns и название места',
desc: 'Что видит игрок при входе в игру: размытый фон с медленным движением (Ken Burns), карточка-витрина по центру, название места и автор с verified-галочкой — как в Roblox. Автор настраивает экран во вкладке «Стартовый экран».',
mechanics: ['красивый экран загрузки игры в плеере (GameLoadingScreen)', 'Ken Burns / static / parallax / particles', 'карточка-витрина + название места + автор + verified', 'настройка во вкладке «Стартовый экран» (свойства проекта)', 'game.loading.show({ style, placeName, studioName, duration }) — переходы', 'game.loading.onHide() — продолжить после загрузки', 'game.loading.setBackground / setText / setProgress'],
previewShot: 'guide-loadingscreen-scene.png', openProjectId: 2713, ready: true },
]; ];

View File

@ -21,6 +21,14 @@ const F = { fill: 'currentColor', stroke: 'none' };
const ICONS = { const ICONS = {
// разделы вики // разделы вики
users: () => (
<>
<circle cx="9" cy="8" r="3.2" {...S} />
<path d="M3.5 19c0-3 2.5-5 5.5-5s5.5 2 5.5 5" {...S} />
<circle cx="16.5" cy="9" r="2.4" {...S} />
<path d="M15 14.2c2.6.2 4.7 2 4.7 4.8" {...S} />
</>
),
rocket: () => ( rocket: () => (
<> <>
<path d="M12 3c3.4 1.7 5.4 5 5.4 9l-2.2 2.3H8.8L6.6 12C6.6 8 8.6 4.7 12 3Z" {...S} /> <path d="M12 3c3.4 1.7 5.4 5 5.4 9l-2.2 2.3H8.8L6.6 12C6.6 8 8.6 4.7 12 3Z" {...S} />
@ -319,6 +327,11 @@ const ICONS = {
<path d="M9 12l2 2 4-4" {...S} /> <path d="M9 12l2 2 4-4" {...S} />
</> </>
), ),
cloud: () => (
<>
<path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.6-1.3A3.5 3.5 0 0 1 17.5 18H7z" {...S} />
</>
),
car: () => ( car: () => (
<> <>
<path d="M3 14l1.8-5a2 2 0 0 1 1.9-1.4h8.6A2 2 0 0 1 19 9l1.8 5M3 14h18M3 14v3.5h2.5V14M21 14v3.5h-2.5V14" {...S} /> <path d="M3 14l1.8-5a2 2 0 0 1 1.9-1.4h8.6A2 2 0 0 1 19 9l1.8 5M3 14h18M3 14v3.5h2.5V14M21 14v3.5h-2.5V14" {...S} />

View File

@ -8596,6 +8596,429 @@ game.onVehicleExit((vehicleRef) => {
), ),
}, },
'guide-skybox': {
body: (
<>
<h3 className="lessonH">Что получится</h3>
<p>
<b>Красивое небо в одну строку.</b> Вместо плоского цветного фона
градиентный купол с солнцем, плывущими облаками, дымкой у горизонта
и далёкими горами (low-poly стиль, как в топовых Roblox-играх). Небо
меняется кнопками: <b>день</b>, <b>закат</b>, <b>звёздная ночь</b>,
<b> космос</b> с плавным переходом за пару секунд. И главное: небо
это <b>единый источник света</b>: меняешь пресет вместе с небом
меняется и освещение всей сцены (на закате теплеет, ночью темнеет).
</p>
<Shot src="guide-skybox-scene.png" wide
caption="Дневное небо (пресет «Lowpoly»): голубой градиент, облака, дымка у горизонта, далёкие горы — town выглядит живым." />
<h3 className="lessonH">Чему научишься</h3>
<ul>
<li><b>game.scene.setSkybox({'{'} preset {'}'})</b> поставить готовое небо
одной строкой (6 пресетов);</li>
<li><b>game.scene.setClouds(...)</b> облака: плотность, скорость дрейфа, цвет;</li>
<li><b>game.scene.setFog(...)</b> атмосферный туман: дальние объекты выцветают в небо;</li>
<li><b>game.scene.skybox.fadeTo(opts, сек)</b> плавный переход между небесами;</li>
<li><b>game.scene.skybox.setSunDirection(...)</b> двигать солнце (анимация дуги).</li>
</ul>
<h3 className="lessonH">Шаг 1. Поставить небо</h3>
<p>
Самое простое выбрать пресет. Доступны:
<code>clear-summer-day</code>, <code>lowpoly-roblox</code>,
<code>cloudy</code>, <code>sunset</code>, <code>starry-night</code>,
<code>space</code>.
</p>
<ScriptKind kind="global" />
<Code>{`// Голубое low-poly небо с облаками, дымкой и горами (как на скрине):
game.scene.setSkybox({ preset: 'lowpoly-roblox' });
game.scene.setClouds({ enabled: true, cover: 0.45, speed: 0.014 });
game.scene.setFog({ color: '#e2eef7', density: 0.005 });`}</Code>
<Note>
Небо само выставляет освещение сцены под выбранный пресет отдельно
свет настраивать не нужно. Купол бесконечно далёкий, поэтому ходить
«до края неба» нельзя оно всегда вокруг игрока.
</Note>
<h3 className="lessonH">Шаг 2. Плавная смена неба (день закат ночь)</h3>
<p>
<code>skybox.fadeTo</code> переводит небо к новому пресету за указанное
число секунд цвета купола, солнце, облака, туман и свет сцены
меняются плавно. Удобно вешать на кнопки или события.
</p>
<ScriptKind kind="global" />
<Code>{`game.gui.onClick('btn-sunset', () => game.scene.skybox.fadeTo({ preset: 'sunset' }, 2));
game.gui.onClick('btn-night', () => game.scene.skybox.fadeTo({ preset: 'starry-night' }, 2));
game.gui.onClick('btn-space', () => game.scene.skybox.fadeTo({ preset: 'space' }, 2));`}</Code>
<Shot src="guide-skybox-night.png" wide
caption="Пресет «Звёздная ночь»: тёмно-синий купол со звёздами, свет сцены приглушён — town погружается в ночь. Переход плавный (fadeTo)." />
<Shot src="guide-skybox-space.png" wide
caption="Пресет «Космос»: почти чёрное звёздное небо, туман отключён — дальние объекты видны чётко." />
<h3 className="lessonH">Шаг 3. Своё небо (gradient)</h3>
<p>
Можно не брать пресет, а задать цвета купола вручную верх, низ,
горизонт и солнце.
</p>
<ScriptKind kind="global" />
<Code>{`game.scene.setSkybox({
mode: 'gradient',
topColor: '#3d7fe0', // зенит
bottomColor: '#dcebf7', // у земли
horizonColor: '#bcd9f2', // линия горизонта
sunDirection: { x: 0.3, y: 0.85, z: 0.4 },
sunColor: '#fff6d8',
sunSize: 0.035,
});`}</Code>
<h3 className="lessonH">Почему это важно</h3>
<p>
Небо половина визуального впечатления от мира. С плоским фоном все
игры выглядят одинаково и дёшево; с кастомным небом атмосферно и
«дорого». А связка неба с освещением даёт бесплатный приём: смена
времени суток одной строкой мгновенно меняет настроение всей сцены.
</p>
<Try>
Сделай день/ночь цикл: по таймеру каждые 10 секунд переключай
<code>fadeTo</code> между <code>'clear-summer-day'</code> и
<code>'starry-night'</code>. Добавь облака погуще на день и убери на ночь.
</Try>
</>
),
},
'guide-leaderstats': {
body: (
<>
<h3 className="lessonH">Что получится</h3>
<p>
Игра «собери монеты» с двумя системами удержания, как в Roblox:
<b> таблица лидеров</b> справа-сверху (монеты, время, уровень) и
<b> достижения</b> всплывающие награды с редкостью, звуком и
страницей-витриной. Прогресс <b>сохраняется в базе</b> закрыл
игру, вернулся завтра, а монеты и открытые ачивки на месте.
</p>
<Shot src="guide-leaderstats-play.png" wide
caption="Таблица лидеров справа-сверху + всплывающая плашка достижения «Терпеливый» при игре. Слева-снизу — кубок, открывающий страницу всех достижений." />
<h3 className="lessonH">Чему научишься</h3>
<ul>
<li><b>game.leaderstats.define(name, opts)</b> столбец таблицы:
иконка, цвет, формат (число / время / 1.2K), primary (по нему сортировка);</li>
<li><b>game.leaderstats.me.add('Монеты', 1)</b> изменить стат игрока
(ячейка жёлто мигает);</li>
<li><b>game.achievements.define([...])</b> объявить достижения
(id, название, описание, редкость, очки, скрытое);</li>
<li><b>game.achievements.unlock(id)</b> выдать достижение (плашка + звук);</li>
<li><b>game.achievements.bindToStat(id, 'Монеты', {'{'} gte: 10 {'}'})</b>
авто-награда при достижении значения стата;</li>
<li>прогресс сохраняется в БД и подгружается в новой сессии.</li>
</ul>
<h3 className="lessonH">Шаг 1. Таблица лидеров</h3>
<p>
Объяви столбцы. Первый <code>primary: true</code> по нему сортируются
игроки в топе. Дальше меняй значения через <code>me.add / me.set</code>.
</p>
<ScriptKind kind="global" />
<Code>{`game.leaderstats.define('Монеты', { initial: 0, format: 'number', icon: '🪙', color: '#ffd23a', primary: true });
game.leaderstats.define('Время', { initial: 0, format: 'time', icon: '⏱', color: '#7fd0ff' });
game.leaderstats.define('Уровень', { initial: 1, format: 'number', icon: '⭐', color: '#b48bff' });
// Время идёт само, монеты за подбор
game.every(1, () => game.leaderstats.me.add('Время', 1));
game.leaderstats.me.add('Монеты', 1);`}</Code>
<h3 className="lessonH">Шаг 2. Достижения</h3>
<p>
Объяви список достижений с редкостью (<code>common / rare / epic /
legendary</code> разный цвет плашки и звук). Выдавай явно через
<code>unlock</code> или автоматически через <code>bindToStat</code>.
</p>
<ScriptKind kind="global" />
<Code>{`game.achievements.define([
{ id:'first_coin', name:'Первая монета', description:'Подобрать монету', icon:'🪙', rarity:'common', points:5 },
{ id:'fifty_coins', name:'Полная сумка', description:'Собрать 50 монет', icon:'💰', rarity:'rare', points:25 },
]);
// Авто-награда: как только Монеты >= 50 плашка «Полная сумка» сама выедет
game.achievements.bindToStat('fifty_coins', 'Монеты', { gte: 50 });
// Явная выдача (на первой монете)
game.achievements.unlock('first_coin');`}</Code>
<p>
Кубок слева-снизу открывает страницу <b>«Мои достижения»</b>:
открытые цветные с рамкой по редкости, закрытые серые с
замком, скрытые «?», сверху прогресс-бар «N из M (очки)».
</p>
<Note>
Прогресс игрока (значения статов + открытые достижения) автоматически
сохраняется в базу и подгружается при следующем входе ничего
дописывать не нужно. Уже открытое достижение второй раз плашкой не
показывается (оно «навсегда»).
</Note>
<h3 className="lessonH">Почему это важно</h3>
<p>
Лидерборды и достижения главный механизм удержания: ребёнок
возвращается в игру за новым рекордом и новой ачивкой. Это основа
симуляторов, ферм и PvP в Roblox столбец «Coins / Wins / Level»
есть почти в каждой игре.
</p>
<Try>
Добавь стат «Рекорд» и достижение <code>'speedrun'</code>, которое
выдаётся через <code>bindToStat('Время', {'{'} lte: 30 {'}'})</code>,
если собрать все монеты быстрее 30 секунд.
</Try>
</>
),
},
'guide-floaters': {
body: (
<>
<h3 className="lessonH">Что получится</h3>
<p>
Мини-шутер: волны <b>зомби бегут к игроку</b>, ты отстреливаешь
их из <b>бластера</b>, а над каждой целью всплывает <b>облачко
урона</b> как в Roblox-RPG (Pet Sim, Anime Adventures). Зомби
гибнут, счётчик растёт, волны усиливаются.
</p>
<Shot src="guide-floaters-scene.png" wide
caption="Зомби сбегаются к игроку, бластер стреляет — над целью всплывают красные облачка урона «-25»." />
<h3 className="lessonH">Шаг 1. Бластер + авто-облачка над мобами</h3>
<p>
Две строки превращают игру в шутер с фидбеком урона: выдаём
бластер и включаем <b>авто-floater</b> теперь <i>любой</i> урон
по NPC сам рисует «-N» над целью, вручную вызывать ничего не надо.
</p>
<ScriptKind kind="global" />
<Code>{`game.player.giveTool('blaster-blaster-a', { equip: true }); // бластер в руки
game.fx.autoMobFloaters(true); // облачко урона над любым мобом при попадании`}</Code>
<h3 className="lessonH">Шаг 2. Волны зомби, идущих к игроку</h3>
<Code>{`function spawnWave(n){
const pl = game.player.position;
for (let i = 0; i < n; i++){
const a = (i / n) * Math.PI * 2;
const e = game.scene.spawnNpc('skin_retro-zombie', {
x: pl.x + Math.cos(a)*18, z: pl.z + Math.sin(a)*18,
name: 'Зомби', hp: 100, speed: 2.6,
});
if (e && e.follow) e.follow('player'); // зомби преследует игрока
}
}
game.after(1.5, () => spawnWave(5));
game.every(14, () => spawnWave(8));`}</Code>
<p>
Стрелять из бластера ЛКМ. В режиме от 3-го лица пуля летит
<b> туда, куда кликнул</b> курсором. Попал по зомби облачко
урона (благодаря <code>autoMobFloaters</code>), убил засчитан.
</p>
<h3 className="lessonH">Ручной floater все типы</h3>
<p>Когда нужен полный контроль рисуй цифру сам:</p>
<Code>{`game.fx.damageFloater(pos, 25); // красный — обычный урон
game.fx.damageFloater(pos, 80, { isCrit: true }); // жёлтый, больше + подскок
game.fx.damageFloater(pos, 30, { isHeal: true }); // зелёный лечение (+30)
game.fx.damageFloater(pos, 50, { isMana: true }); // синий мана
game.fx.damageFloater(pos, 'Промах', { isMiss: true }); // серый текст`}</Code>
<p>
<b>position</b> <code>{'{x,y,z}'}</code>, ссылка на объект или
<code>'player'</code>; <b>value</b> число или строка.
</p>
<h3 className="lessonH">Стек и комикс-стиль</h3>
<Code>{`// общий stackKey → удары сливаются в «-25 ×N» вместо кучи цифр
game.fx.damageFloater(enemy.position, 25, { stackKey: 'aoe_' + enemy.id });
// comicStyle BAM! (>50), KAPOW! (>100), POW! (крит) на жёлтой звезде
game.fx.damageFloater(pos, 120, { comicStyle: true });`}</Code>
<Note>
Под капотом пул из 30 переиспользуемых билборд-планов
(object pool), поэтому даже при толпе зомби и спаме цифр FPS не
проседает. Цифры всегда поверх геометрии и повёрнуты к камере.
</Note>
<h3 className="lessonH">Почему это важно</h3>
<p>
Без облачек урона стрельба ощущается «впустую». Это базовый
боевой фидбек: игрок видит, сколько нанёс, был ли крит, попал ли.
Связка <code>бластер + autoMobFloaters + волны NPC</code> готовый
каркас любого шутера/выживания.
</p>
<Try>
Сделай «огненный» урон: <code>damageFloater(pos, 15, {'{'} color:
'#ff7a2a' {'}'})</code> каждые 0.5 сек 3 раза эффект горения.
Или увеличь HP зомби и добавь крит каждый 5-й выстрел.
</Try>
</>
),
},
'guide-inventory': {
body: (
<>
<h3 className="lessonH">Что получится</h3>
<p>
Полноценный инвентарь как в Minecraft и RPG: <b>сетка 8×5</b> +
<b> хотбар на 9 слотов</b>, предметы со <b>стаками</b> и
<b> редкостями</b>, перетаскивание мышью, ПКМ-меню, всплывающие
подсказки. Собираешь предметы на поляне стаки растут, открываешь
инвентарь клавишей <b>I</b> и раскладываешь добычу.
</p>
<Shot src="guide-inventory-scene.png" wide
caption="Окно инвентаря (I): сетка 8×5 + хотбар 9. Стаки (×12 ягод), цвет рамки = редкость (голубая rare, зелёная uncommon)." />
<h3 className="lessonH">Шаг 1. Определи предметы</h3>
<p>
Каждый предмет описывается один раз: имя, иконка-эмодзи, редкость,
размер стака, эффект использования.
</p>
<ScriptKind kind="global" />
<Code>{`game.items.define([
{ id:'berry', name:'Ягоды', emoji:'🍓', rarity:'common', maxStack:16, value:2 },
{ id:'iron', name:'Руда', emoji:'⛏️', rarity:'uncommon', maxStack:16, value:8 },
{ id:'potion', name:'Зелье', emoji:'🧪', rarity:'rare', maxStack:8, onUseEffect:'heal:50' },
{ id:'sword', name:'Меч', emoji:'⚔️', rarity:'legendary', maxStack:1, value:500 },
]);`}</Code>
<p>
Редкости: <code>common</code> (серый), <code>uncommon</code>
(зелёный), <code>rare</code> (голубой), <code>epic</code>
(фиолетовый), <code>legendary</code> (золотой) это цвет рамки слота.
</p>
<h3 className="lessonH">Шаг 2. Выдавай и собирай предметы</h3>
<Code>{`game.inventory.give('sword', 1); // в стартовый набор
game.inventory.give('berry', 5); // стак до maxStack, дальше новый слот
// сбор предмета с земли (на объекте-ягоде):
game.self.onInteract(() => {
game.inventory.give('berry', 2);
game.self.delete(); // убрать собранный предмет
}, { text:'Собрать', key:'e', distance:3 });`}</Code>
<p>
Собранное попадает <b>сначала в хотбар</b> (виден внизу экрана),
одинаковые предметы складываются в стак с учётом <code>maxStack</code>.
</p>
<Shot src="guide-inventory-play.png" wide
caption="Хотбар внизу наполняется собранным (меч, яблоки ×3, зелье). Подсказка «E Собрать» у ближайшего предмета." />
<h3 className="lessonH">Шаг 3. Окно инвентаря</h3>
<ul>
<li><b>I</b> открыть/закрыть окно (Esc тоже закрывает);</li>
<li><b>Перетаскивание</b> мышью поменять слоты местами или
слить стаки;</li>
<li><b>ПКМ</b> по слоту меню: использовать / разделить / выбросить;</li>
<li><b>Наведение</b> tooltip (название цветом редкости, описание, цена);</li>
<li><b>Сорт.</b> расставить по редкости;</li>
<li><b>1-9</b> выбрать активный слот хотбара.</li>
</ul>
<Note>
Всё хранится в движке и сериализуется в проект автоматически
дописывать сохранение не нужно. Предметы с тегом
<code>'quest'</code> нельзя выбросить.
</Note>
<h3 className="lessonH">Почему это важно</h3>
<p>
Инвентарь несущая конструкция RPG, выживания и симуляторов.
Сетка + хотбар + стаки + редкости стандарт, который игроки
узнают мгновенно. Сочетается с крафтом, квестами и магазином.
</p>
<Try>
Добавь предмет <code>'apple'</code> с
<code> onUseEffect:'heal:15'</code>, положи в хотбар и нажми ПКМ
«Использовать» HP восстановится, яблоко убавится на 1.
</Try>
</>
),
},
'guide-loadingscreen': {
body: (
<>
<h3 className="lessonH">Что получится</h3>
<p>
<b>Красивый экран загрузки игры</b> то, что видит игрок при входе
в игру (после клика «Играть»), пока грузится сцена. Композиция как в
Roblox: размытый фон с медленным движением (<b>Ken Burns</b>),
<b> карточка-витрина</b> по центру, крупное <b>название места</b> и
<b> автор с verified-галочкой</b>, прогресс-бар и спиннер. Когда сцена
загрузилась экран плавно исчезает.
</p>
<Shot src="guide-loadingscreen-scene.png" wide
caption="Экран загрузки игры: размытый Ken-Burns фон, карточка-витрина, «Открыть фабрику», автор с verified-галочкой, прогресс и спиннер." />
<h3 className="lessonH">Шаг 1. Настроить в свойствах проекта</h3>
<p>
Без кода: <b>Настройки игры вкладка «Стартовый экран входа (Ken Burns)»</b>.
Задай фон (размытое изображение игры), карточку, название места, имя
автора, галочку verified, стиль анимации и длительность. Этот экран
автоматически покажется игроку при заходе.
</p>
<ul>
<li><b>Фон</b> размытое изображение игры (или её обложка);</li>
<li><b>Карточка</b> витрина по центру (необязательно);</li>
<li><b>Название места</b> + <b>автор</b> + <b>verified</b>;</li>
<li><b>Стиль:</b> Ken Burns / статичный / параллакс / частицы;</li>
<li><b>Длительность</b> и <b>прогресс-бар</b>.</li>
</ul>
<p>
Если ничего не задано экран всё равно красивый: берёт обложку,
название и автора игры автоматически.
</p>
<h3 className="lessonH">Шаг 2. Переходы между мирами из скрипта</h3>
<p>Для смены главы/мира вызывай экран вручную:</p>
<ScriptKind kind="global" />
<Code>{`game.loading.show({
style: 'particles',
placeName: 'Алмазная глава',
studioName: 'Виктория — Майнкрафтия',
verified: true,
duration: 2,
});
game.after(0.6, () => {
game.environment.setTimeOfDay(0); // меняем мир «за кулисами»
game.environment.setSkyColor('#0a1024');
});
game.loading.onHide(() => {
game.ui.set('hi', 'Добро пожаловать!', { x:50, y:6, anchor:'top' });
});`}</Code>
<Note>
Стили: <b>Ken Burns</b> медленный pan+zoom фона (классика Roblox);
<b> параллакс</b> фон смещается за мышью; <b>частицы</b> летящие
искры; <b>статичный</b> без анимации. Verified-галочка синий кружок
с белым чеком рядом с автором.
</Note>
<Try>
Открой настройки игры «Стартовый экран», впиши название места и автора,
выбери стиль «Частицы» запусти игру и посмотри, как экран загрузки
встречает игрока.
</Try>
</>
),
},
}; };
/** Есть ли готовый текст урока для игры с таким id. */ /** Есть ли готовый текст урока для игры с таким id. */

View File

@ -50,9 +50,21 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
const [loadingAccent, setLoadingAccent] = useState('#ffc020'); const [loadingAccent, setLoadingAccent] = useState('#ffc020');
const [loadingSpinner, setLoadingSpinner] = useState(true); const [loadingSpinner, setLoadingSpinner] = useState(true);
const [loadingSkip, setLoadingSkip] = useState(false); const [loadingSkip, setLoadingSkip] = useState(false);
// Задача 05: стартовый Ken-Burns экран
const [lsEnabled, setLsEnabled] = useState(true);
const [lsBackground, setLsBackground] = useState('');
const [lsCover, setLsCover] = useState('');
const [lsStyle, setLsStyle] = useState('ken-burns');
const [lsPlaceName, setLsPlaceName] = useState('');
const [lsStudioName, setLsStudioName] = useState('');
const [lsVerified, setLsVerified] = useState(false);
const [lsDuration, setLsDuration] = useState(2.5);
const [lsProgressBar, setLsProgressBar] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
const logoInputRef = useRef(null); const logoInputRef = useRef(null);
const lsBgInputRef = useRef(null);
const lsCoverInputRef = useRef(null);
// Заполняем поля ОДИН РАЗ при открытии модала. // Заполняем поля ОДИН РАЗ при открытии модала.
// Не зависим от `initial` родитель часто передаёт литерал-объект, // Не зависим от `initial` родитель часто передаёт литерал-объект,
@ -71,6 +83,16 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
setLoadingAccent(ls.accentColor || '#ffc020'); setLoadingAccent(ls.accentColor || '#ffc020');
setLoadingSpinner(ls.defaultSpinner !== false); setLoadingSpinner(ls.defaultSpinner !== false);
setLoadingSkip(!!ls.defaultSkipButton); setLoadingSkip(!!ls.defaultSkipButton);
// Задача 05:
setLsEnabled(ls.enabled !== false);
setLsBackground(ls.background || '');
setLsCover(ls.cover || '');
setLsStyle(ls.style || 'ken-burns');
setLsPlaceName(ls.placeName || '');
setLsStudioName(ls.studioName || '');
setLsVerified(!!ls.verified);
setLsDuration(Number.isFinite(ls.duration) && ls.duration > 0 ? ls.duration : 2.5);
setLsProgressBar(ls.progressBar !== false);
setMaxPlayers( setMaxPlayers(
typeof initial?.max_players === 'number' typeof initial?.max_players === 'number'
? Math.max(2, Math.min(50, initial.max_players)) ? Math.max(2, Math.min(50, initial.max_players))
@ -117,6 +139,17 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
reader.readAsDataURL(file); reader.readAsDataURL(file);
}; };
// Задача 05: универсальный загрузчик изображения (фон / cover-карточка).
const handleLsImage = (e, setter) => {
const file = e.target.files?.[0];
if (!file) return;
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) { setError('Только PNG, JPG или WEBP'); return; }
if (file.size > MAX_THUMBNAIL_BYTES) { setError('Изображение слишком большое (макс. 500 КБ)'); return; }
const reader = new FileReader();
reader.onload = (ev) => { setter(ev.target.result); setError(''); };
reader.readAsDataURL(file);
};
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
const trimmedTitle = title.trim(); const trimmedTitle = title.trim();
@ -146,6 +179,16 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
accentColor: loadingAccent || '#ffc020', accentColor: loadingAccent || '#ffc020',
defaultSpinner: loadingSpinner, defaultSpinner: loadingSpinner,
defaultSkipButton: loadingSkip, defaultSkipButton: loadingSkip,
// Задача 05:
enabled: lsEnabled,
background: lsBackground || null,
cover: lsCover || null,
style: lsStyle || 'ken-burns',
placeName: lsPlaceName.trim(),
studioName: lsStudioName.trim(),
verified: lsVerified,
duration: Math.max(1, Math.min(10, Number(lsDuration) || 2.5)),
progressBar: lsProgressBar,
}, },
}); });
}; };
@ -384,6 +427,115 @@ const GameSettingsModal = ({ open, initial, onClose, onSave, onCaptureScreenshot
</div> </div>
</div> </div>
{/* Стартовый экран — Ken Burns + название места (задача 05) */}
<div className={cl.field} style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 14, marginTop: 4 }}>
<div className={cl.fieldLabel} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Icon name="loader" size={13} /> Стартовый экран входа (Ken Burns)
</div>
<div className={cl.fieldHint} style={{ marginBottom: 10 }}>
Что видит игрок при входе в игру: размытый фон с медленным движением (Ken Burns), карточка-витрина по центру, название места и автор.
</div>
<label className={cl.toggleRow} style={{ marginBottom: 10 }}>
<input type="checkbox" className={cl.toggle}
checked={lsEnabled} onChange={(e) => setLsEnabled(e.target.checked)} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Показывать стартовый экран</div>
<div className={cl.toggleHint}><span>Если выключено игрок сразу попадает в 3D-сцену</span></div>
</div>
</label>
{lsEnabled && (
<>
{/* Фон + карточка */}
<div style={{ display: 'flex', gap: 14, marginBottom: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{
width: 130, height: 74, borderRadius: 8, background: '#15192a',
border: '1px solid rgba(255,255,255,0.12)', overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
backgroundImage: lsBackground ? `url(${lsBackground})` : 'none',
backgroundSize: 'cover', backgroundPosition: 'center',
}}>
{!lsBackground && <span style={{ color: '#5a6178', fontSize: 11 }}>фон (размытый)</span>}
</div>
<button type="button" className={cl.actionBtn} onClick={() => lsBgInputRef.current?.click()}>
<Icon name="folder" size={14} /> Фон
</button>
{lsBackground && (
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLsBackground('')}>
<Icon name="close" size={13} /> Убрать
</button>
)}
<input ref={lsBgInputRef} type="file" accept="image/png,image/jpeg,image/webp"
style={{ display: 'none' }} onChange={(e) => handleLsImage(e, setLsBackground)} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{
width: 74, height: 74, borderRadius: 12, background: '#15192a',
border: '1px solid rgba(255,255,255,0.12)', overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
backgroundImage: lsCover ? `url(${lsCover})` : 'none',
backgroundSize: 'cover', backgroundPosition: 'center',
}}>
{!lsCover && <span style={{ color: '#5a6178', fontSize: 10, textAlign: 'center' }}>карточка</span>}
</div>
<button type="button" className={cl.actionBtn} onClick={() => lsCoverInputRef.current?.click()}>
<Icon name="folder" size={14} /> Карточка
</button>
{lsCover && (
<button type="button" className={cl.removeBtn} style={{ alignSelf: 'flex-start' }} onClick={() => setLsCover('')}>
<Icon name="close" size={13} /> Убрать
</button>
)}
<input ref={lsCoverInputRef} type="file" accept="image/png,image/jpeg,image/webp"
style={{ display: 'none' }} onChange={(e) => handleLsImage(e, setLsCover)} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1, minWidth: 180 }}>
<input type="text" className={cl.input} placeholder="Название места (по умолчанию = название игры)"
value={lsPlaceName} maxLength={40}
onChange={(e) => setLsPlaceName(e.target.value)} />
<input type="text" className={cl.input} placeholder="Имя автора"
value={lsStudioName} maxLength={40}
onChange={(e) => setLsStudioName(e.target.value)} />
<label className={cl.toggleRow}>
<input type="checkbox" className={cl.toggle}
checked={lsVerified} onChange={(e) => setLsVerified(e.target.checked)} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Галочка verified</div>
</div>
</label>
</div>
</div>
{/* Стиль + длительность + прогресс */}
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 12, color: '#aab' }}>Стиль анимации</span>
<select className={cl.input} value={lsStyle} onChange={(e) => setLsStyle(e.target.value)}>
<option value="ken-burns">Ken Burns (плавный pan+zoom)</option>
<option value="static">Статичный фон</option>
<option value="parallax">Параллакс (по мыши)</option>
<option value="particles">Частицы (искры)</option>
</select>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 12, color: '#aab' }}>Длительность: {Number(lsDuration).toFixed(1)} с</span>
<input type="range" min="1" max="10" step="0.5" value={lsDuration}
onChange={(e) => setLsDuration(Number(e.target.value))}
style={{ width: 160 }} />
</label>
<label className={cl.toggleRow}>
<input type="checkbox" className={cl.toggle}
checked={lsProgressBar} onChange={(e) => setLsProgressBar(e.target.checked)} />
<div className={cl.toggleText}>
<div className={cl.toggleTitle}>Прогресс-бар</div>
</div>
</label>
</div>
</>
)}
</div>
{error && <div className={cl.error}><Icon name="warning" size={14} /> {error}</div>} {error && <div className={cl.error}><Icon name="warning" size={14} /> {error}</div>}
</div> </div>

View File

@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo, useEffect } from 'react';
import { getBlockType } from './engine/BlockTypes'; import { getBlockType } from './engine/BlockTypes';
import { getModelType } from './engine/ModelTypes'; import { getModelType } from './engine/ModelTypes';
import { getPrimitiveType } from './engine/PrimitiveTypes'; import { getPrimitiveType } from './engine/PrimitiveTypes';
@ -40,8 +40,17 @@ const ItemRow = ({
extraStyle, extraStyle,
}) => { }) => {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const rowRef = React.useRef(null);
// Когда строка стала выделенной подскроллить её в видимую зону дерева
// (после авто-раскрытия веток объект может оказаться вне видимой области).
useEffect(() => {
if (selected && rowRef.current) {
rowRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}, [selected]);
return ( return (
<div <div
ref={rowRef}
className={`item ${selected ? 'item-selected' : ''}`} className={`item ${selected ? 'item-selected' : ''}`}
style={{ style={{
display: 'flex', display: 'flex',
@ -226,13 +235,15 @@ function targetMatches(target, kind, ref) {
* onAssignToFolder(kind, ref, folderId) переместить объект/папку в папку * onAssignToFolder(kind, ref, folderId) переместить объект/папку в папку
*/ */
const HierarchyPanel = ({ const HierarchyPanel = ({
blocks, models, primitives = [], folders = [], blocks, models, primitives = [], userModels = [], folders = [],
selection, selection,
onSelectUserModel, onDeleteUserModel, onRenameUserModel,
onSelectBlock, onSelectModel, onSelectPrimitive, onSelectSpawn, onSelectLighting, onSelectBlock, onSelectModel, onSelectPrimitive, onSelectSpawn, onSelectLighting,
onSelectSound, onSelectPlayer, onSelectPlayerProps, onSelectFloor, onSelectSound, onSelectPlayer, onSelectPlayerProps, onSelectFloor,
guiElements = [], onSelectGui, onCreateGui, onDeleteGui, onRenameGui, onMoveGuiZ, onSetGuiParent, guiElements = [], onSelectGui, onCreateGui, onDeleteGui, onRenameGui, onMoveGuiZ, onSetGuiParent,
guiOverlayHidden = false, onToggleGuiOverlay, guiOverlayHidden = false, onToggleGuiOverlay,
floorEnabled = true, onCreateFloor, onDeleteFloor, floorEnabled = true, onCreateFloor, onDeleteFloor, onDeleteSpawn, spawnEnabled = true,
onSelectFolder,
scripts = [], onSelectScript, onCreateScript, onDeleteScript, scripts = [], onSelectScript, onCreateScript, onDeleteScript,
onRenameModel, onRenamePrimitive, onRenameScript, onRenameModel, onRenamePrimitive, onRenameScript,
/** /**
@ -253,6 +264,7 @@ const HierarchyPanel = ({
const [rootBlocksOpen, setRootBlocksOpen] = useState(false); const [rootBlocksOpen, setRootBlocksOpen] = useState(false);
const [rootPrimsOpen, setRootPrimsOpen] = useState(false); const [rootPrimsOpen, setRootPrimsOpen] = useState(false);
const [rootModelsOpen, setRootModelsOpen] = useState(false); const [rootModelsOpen, setRootModelsOpen] = useState(false);
const [rootUserModelsOpen, setRootUserModelsOpen] = useState(false);
// Главные «service»-категории (Roblox-style). По умолчанию свёрнуты. // Главные «service»-категории (Roblox-style). По умолчанию свёрнуты.
const [workspaceOpen, setWorkspaceOpen] = useState(false); const [workspaceOpen, setWorkspaceOpen] = useState(false);
const [lightingOpen, setLightingOpen] = useState(false); const [lightingOpen, setLightingOpen] = useState(false);
@ -328,11 +340,23 @@ const HierarchyPanel = ({
return map; return map;
}, [primitives]); }, [primitives]);
const userModelsByFolder = useMemo(() => {
const map = new Map();
for (const um of userModels) {
const k = um.folderId ?? null;
if (!map.has(k)) map.set(k, []);
map.get(k).push(um);
}
return map;
}, [userModels]);
const isBlockSelected = (b) => const isBlockSelected = (b) =>
selection?.type === 'block' && selection?.type === 'block' &&
selection.gridX === b.gridX && selection.gridY === b.gridY && selection.gridZ === b.gridZ; selection.gridX === b.gridX && selection.gridY === b.gridY && selection.gridZ === b.gridZ;
const isModelSelected = (m) => const isModelSelected = (m) =>
selection?.type === 'model' && selection.instanceId === m.instanceId; selection?.type === 'model' && selection.instanceId === m.instanceId;
const isUserModelSelected = (um) =>
selection?.type === 'userModel' && selection.instanceId === um.instanceId;
const isPrimitiveSelected = (p) => const isPrimitiveSelected = (p) =>
selection?.type === 'primitive' && selection.id === p.id; selection?.type === 'primitive' && selection.id === p.id;
@ -344,6 +368,82 @@ const HierarchyPanel = ({
}); });
}; };
// === Авто-раскрытие пути до выделенного объекта ===
// Когда объект выбирают мышкой на сцене (или из скрипта), он должен стать
// ВИДИМЫМ в дереве: раскрываем «Сцену», нужную под-группу (Блоки/Примитивы/
// Модели) и всю цепочку папок-родителей. Подсветка строки уже работает через
// проп `selection`; здесь только разворачиваем свёрнутые ветки.
useEffect(() => {
if (!selection) return;
const t = selection.type;
// Выделена ПАПКА (клик по сцене / вставка кита из тулбокса) раскрыть
// «Сцену» и цепочку папок до неё, чтобы папка стала видна в дереве.
if (t === 'folder') {
setWorkspaceOpen(true);
setOpenFolders(prev => {
const n = new Set(prev);
let cur = selection.folderId;
const guard = new Set();
while (cur != null && !guard.has(cur)) {
guard.add(cur);
const f = folders.find(ff => ff.id === cur);
if (f && f.parentId != null) { n.add(f.parentId); cur = f.parentId; } else cur = null;
}
return n;
});
return;
}
// Находим объект и его folderId по выделению.
let obj = null, kind = null;
if (t === 'block') {
obj = blocks.find(b => b.gridX === selection.gridX && b.gridY === selection.gridY && b.gridZ === selection.gridZ);
kind = 'block';
} else if (t === 'primitive') {
obj = primitives.find(p => p.id === selection.id);
kind = 'primitive';
} else if (t === 'model') {
obj = models.find(m => m.instanceId === selection.instanceId);
kind = 'model';
} else if (t === 'userModel') {
obj = userModels.find(um => um.instanceId === selection.instanceId);
kind = 'userModel';
} else if (t === 'spawn' || t === 'floor') {
kind = 'workspace-only';
}
if (!kind) return; // lighting/sound/player/gui/script свои группы, не трогаем
// 1) Раскрыть корневую категорию «Сцена».
setWorkspaceOpen(true);
if (kind === 'workspace-only') return;
const folderId = obj?.folderId ?? null;
if (folderId == null) {
// 2a) Объект в корне раскрыть его под-группу (Блоки/Примитивы/Модели).
if (kind === 'block') setRootBlocksOpen(true);
else if (kind === 'primitive') setRootPrimsOpen(true);
else if (kind === 'model') setRootModelsOpen(true);
else if (kind === 'userModel') setRootUserModelsOpen(true);
} else {
// 2b) Объект в папке раскрыть всю цепочку папок-родителей.
setOpenFolders(prev => {
const n = new Set(prev);
let cur = folderId;
const guard = new Set(); // защита от циклов
while (cur != null && !guard.has(cur)) {
guard.add(cur);
n.add(cur);
const f = folders.find(ff => ff.id === cur);
cur = f ? (f.parentId ?? null) : null;
}
return n;
});
}
// Триггер только смена выделения (не трогаем при добавлении объектов,
// чтобы не переоткрывать ветки, которые пользователь свернул вручную).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selection]);
const handleContextMenu = (e, item) => { const handleContextMenu = (e, item) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -400,19 +500,24 @@ const HierarchyPanel = ({
const subBlocks = blocksByFolder.get(folder.id) || []; const subBlocks = blocksByFolder.get(folder.id) || [];
const subModels = modelsByFolder.get(folder.id) || []; const subModels = modelsByFolder.get(folder.id) || [];
const subPrims = primitivesByFolder.get(folder.id) || []; const subPrims = primitivesByFolder.get(folder.id) || [];
const totalCount = subBlocks.length + subModels.length + subPrims.length + subFolders.length; const subUserModels = userModelsByFolder.get(folder.id) || [];
const totalCount = subBlocks.length + subModels.length + subPrims.length + subUserModels.length + subFolders.length;
const folderSelected = selection?.type === 'folder' && selection?.folderId === folder.id;
return ( return (
<div key={`folder-${folder.id}`} className={cl.folderWrap}> <div key={`folder-${folder.id}`} className={cl.folderWrap}>
<div <div
className={cl.folderHeader} ref={folderSelected ? (el) => { if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } : null}
className={`${cl.folderHeader} ${folderSelected ? cl.itemSelected : ''}`}
style={{ paddingLeft: depth * 12 + 8 }} style={{ paddingLeft: depth * 12 + 8 }}
onClick={() => toggleFolder(folder.id)} onClick={() => onSelectFolder?.(folder.id)}
onContextMenu={(e) => handleContextMenu(e, { type: 'folder', ...folder })} onContextMenu={(e) => handleContextMenu(e, { type: 'folder', ...folder })}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={(e) => handleDropOnFolder(e, folder.id)} onDrop={(e) => handleDropOnFolder(e, folder.id)}
> >
<span className={cl.chevron} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <span className={cl.chevron} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={(e) => { e.stopPropagation(); toggleFolder(folder.id); }}
>
<Icon name={isOpen ? 'chevronDown' : 'chevronRight'} size={10} strokeWidth={2.5} /> <Icon name={isOpen ? 'chevronDown' : 'chevronRight'} size={10} strokeWidth={2.5} />
</span> </span>
<span className={cl.folderIcon} style={{ display: 'flex', alignItems: 'center' }}> <span className={cl.folderIcon} style={{ display: 'flex', alignItems: 'center' }}>
@ -452,6 +557,7 @@ const HierarchyPanel = ({
{subBlocks.map(b => renderBlockItem(b, depth + 1))} {subBlocks.map(b => renderBlockItem(b, depth + 1))}
{subPrims.map(p => renderPrimitiveItem(p, depth + 1))} {subPrims.map(p => renderPrimitiveItem(p, depth + 1))}
{subModels.map(m => renderModelItem(m, depth + 1))} {subModels.map(m => renderModelItem(m, depth + 1))}
{subUserModels.map(um => renderUserModelItem(um, depth + 1))}
{totalCount === 0 && ( {totalCount === 0 && (
<div className={cl.empty} style={{ paddingLeft: (depth + 1) * 12 + 8 }}>пусто</div> <div className={cl.empty} style={{ paddingLeft: (depth + 1) * 12 + 8 }}>пусто</div>
)} )}
@ -580,6 +686,38 @@ const HierarchyPanel = ({
); );
}; };
const renderUserModelItem = (um, depth) => {
const displayName = um.name || 'Моя модель';
return (
<React.Fragment key={`um-${um.instanceId}`}>
<ItemRow
icon="🧊"
label={`${displayName} (${um.x.toFixed(1)}, ${um.y.toFixed(1)}, ${um.z.toFixed(1)})`}
title={`${displayName} — пользовательская модель (id: ${um.instanceId})`}
depth={depth}
selected={isUserModelSelected(um)}
draggable
onDragStart={(e) => handleDragStart(e, { kind: 'userModel', id: um.instanceId })}
onClick={() => onSelectUserModel?.(um.instanceId)}
onDoubleClick={() => { onSelectUserModel?.(um.instanceId); onFocusSelection?.(); }}
onContextMenu={(e) => handleContextMenu(e, { type: 'userModel', ...um })}
plusItems={[
{
id: 'add-script', label: 'Скрипт', icon: '📜',
onClick: () => onCreateScript?.({ kind: 'userModel', id: um.instanceId }),
},
{ divider: true },
{
id: 'delete', label: 'Удалить', icon: '🗑', danger: true,
onClick: () => onDeleteUserModel?.(um.instanceId),
},
]}
/>
{renderNestedScriptsFor('userModel', um.instanceId, depth)}
</React.Fragment>
);
};
const renderPrimitiveItem = (p, depth) => { const renderPrimitiveItem = (p, depth) => {
const def = getPrimitiveType(p.type); const def = getPrimitiveType(p.type);
const displayName = p.name || def?.name || p.type; const displayName = p.name || def?.name || p.type;
@ -633,6 +771,7 @@ const HierarchyPanel = ({
const rootFolders = foldersByParent.get(null) || []; const rootFolders = foldersByParent.get(null) || [];
const rootBlocks = blocksByFolder.get(null) || []; const rootBlocks = blocksByFolder.get(null) || [];
const rootModels = modelsByFolder.get(null) || []; const rootModels = modelsByFolder.get(null) || [];
const rootUserModels = userModelsByFolder.get(null) || [];
const rootPrims = primitivesByFolder.get(null) || []; const rootPrims = primitivesByFolder.get(null) || [];
return ( return (
@ -664,16 +803,19 @@ const HierarchyPanel = ({
/> />
{workspaceOpen && ( {workspaceOpen && (
<div style={{ paddingLeft: 8 }}> <div style={{ paddingLeft: 8 }}>
{/* Точка спавна — кликабельная */} {/* Точка спавна — кликабельная. Скрыта если удалена. */}
{spawnEnabled !== false && (
<div <div
className={`${cl.item} ${selection?.type === 'spawn' ? cl.itemSelected : ''}`} className={`${cl.item} ${selection?.type === 'spawn' ? cl.itemSelected : ''}`}
onClick={() => onSelectSpawn?.()} onClick={() => onSelectSpawn?.()}
onDoubleClick={() => { onSelectSpawn?.(); onFocusSelection?.(); }} onDoubleClick={() => { onSelectSpawn?.(); onFocusSelection?.(); }}
title="Точка спавна игрока" onContextMenu={(e) => { onSelectSpawn?.(); handleContextMenu(e, { type: 'spawn' }); }}
title="Точка спавна игрока (ПКМ — меню, Delete — удалить)"
> >
<span className={cl.itemIcon} style={{ display: 'inline-flex' }}><Icon name="flag" size={14} /></span> <span className={cl.itemIcon} style={{ display: 'inline-flex' }}><Icon name="flag" size={14} /></span>
<span className={cl.itemLabel}>Точка спавна</span> <span className={cl.itemLabel}>Точка спавна</span>
</div> </div>
)}
{/* Пол — псевдо-объект, если включён */} {/* Пол — псевдо-объект, если включён */}
{floorEnabled && ( {floorEnabled && (
@ -757,6 +899,24 @@ const HierarchyPanel = ({
{rootModelsOpen && rootModels.map(m => renderModelItem(m, 0))} {rootModelsOpen && rootModels.map(m => renderModelItem(m, 0))}
</> </>
)} )}
{/* Мои модели (воксельный редактор) в корне */}
{rootUserModels.length > 0 && (
<>
<div
className={cl.groupHeader}
onClick={() => setRootUserModelsOpen(!rootUserModelsOpen)}
>
<span className={cl.chevron} style={{ display: 'inline-flex', alignItems: 'center' }}>
<Icon name={rootUserModelsOpen ? 'chevronDown' : 'chevronRight'} size={10} strokeWidth={2.5} />
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<Icon name="cube" size={13} /> Мои модели ({rootUserModels.length})
</span>
</div>
{rootUserModelsOpen && rootUserModels.map(um => renderUserModelItem(um, 0))}
</>
)}
</div> </div>
)} )}
@ -1014,7 +1174,12 @@ const HierarchyPanel = ({
{/* === 📜 СКРИПТЫ === только глобальные (без target). {/* === 📜 СКРИПТЫ === только глобальные (без target).
Скрипты с target отображаются под объектом-носителем. */} Скрипты с target отображаются под объектом-носителем. */}
{(() => { {(() => {
const globalScripts = scripts.filter(s => !s.target); // Глобальные скрипты: без target ИЛИ target==='game' (строка).
// Раньше фильтр был `!s.target` скрипты с target:'game'
// (главный скрипт игры) НЕ показывались в дереве и их нельзя
// было удалить, хотя в Play они исполнялись.
const isGlobalTarget = (t) => !t || t === 'game';
const globalScripts = scripts.filter(s => isGlobalTarget(s.target));
return ( return (
<> <>
<GroupRow <GroupRow
@ -1173,6 +1338,21 @@ const HierarchyPanel = ({
<Icon name="delete" size={13} /> Удалить пол <Icon name="delete" size={13} /> Удалить пол
</div> </div>
</> </>
) : contextMenu.item.type === 'spawn' ? (
<>
<div
className={cl.contextItem}
onClick={() => { onSelectSpawn?.(); onFocusSelection?.(); closeContext(); }}
>
<Icon name="target" size={13} /> Навести камеру
</div>
<div
className={`${cl.contextItem} ${cl.contextDanger}`}
onClick={() => { onDeleteSpawn?.(); closeContext(); }}
>
<Icon name="delete" size={13} /> Удалить точку спавна
</div>
</>
) : contextMenu.item.type === 'script' ? ( ) : contextMenu.item.type === 'script' ? (
<> <>
<div <div

View File

@ -4,11 +4,15 @@ import { jwtDecode } from 'jwt-decode';
import { useAuth } from '../auth/AuthContext.jsx'; import { useAuth } from '../auth/AuthContext.jsx';
import { useSanctions } from '../auth/SanctionsContext.jsx'; import { useSanctions } from '../auth/SanctionsContext.jsx';
import { BabylonScene } from './engine/BabylonScene'; import { BabylonScene } from './engine/BabylonScene';
import { StudioCollab } from './engine/StudioCollab';
import { CollabOverlay } from './engine/CollabOverlay';
import { BLOCK_TYPES, BLOCK_CATEGORIES, blockPreview, registerCustomBlockType } from './engine/BlockTypes'; import { BLOCK_TYPES, BLOCK_CATEGORIES, blockPreview, registerCustomBlockType } from './engine/BlockTypes';
import { MODEL_TYPES, MODEL_CATEGORIES, getModelType } from './engine/ModelTypes'; import { MODEL_TYPES, MODEL_CATEGORIES, getModelType } from './engine/ModelTypes';
import { getKit } from './engine/GameplayKits';
import { PRIMITIVE_TYPES, PALETTE_PRIMITIVE_TYPES } from './engine/PrimitiveTypes'; import { PRIMITIVE_TYPES, PALETTE_PRIMITIVE_TYPES } from './engine/PrimitiveTypes';
import { getModelThumbnail } from './engine/ModelThumbnails'; import { getModelThumbnail } from './engine/ModelThumbnails';
import * as Kubikon3DApi from '../api/Kubikon3DService'; import * as Kubikon3DApi from '../api/Kubikon3DService';
import { REALTIME_HTTP } from '../api/API';
import GameSettingsModal from './GameSettingsModal'; import GameSettingsModal from './GameSettingsModal';
import SkinManagerModal from './SkinManagerModal'; import SkinManagerModal from './SkinManagerModal';
import PublishModal from './PublishModal'; import PublishModal from './PublishModal';
@ -463,6 +467,15 @@ const KubikonEditor = () => {
const canvasRef = useRef(null); const canvasRef = useRef(null);
const sceneRef = useRef(null); const sceneRef = useRef(null);
// Team Create клиент совместного редактирования + presence-overlay.
const collabRef = useRef(null);
const collabOverlayRef = useRef(null);
const [collabActive, setCollabActive] = useState(false); // подключены к комнате
const [collabPeers, setCollabPeers] = useState(0); // сколько ДРУГИХ соавторов
// Роль в коллабе: 'owner' (владелец) | 'collab' (приглашённый по ссылке).
// Приглашённый гость: не может менять настройки/сохранять/публиковать.
const [collabRole, setCollabRole] = useState('owner');
const isInvitedGuest = collabActive && collabRole !== 'owner';
// Флаш pending-debounce ScriptEditor. Зовём перед каждым doSave/перед уходом // Флаш pending-debounce ScriptEditor. Зовём перед каждым doSave/перед уходом
// со страницы иначе последние 600мс правок скрипта потеряются. // со страницы иначе последние 600мс правок скрипта потеряются.
const scriptEditorFlushRef = useRef(null); const scriptEditorFlushRef = useRef(null);
@ -605,6 +618,7 @@ const KubikonEditor = () => {
const [crosshair, setCrosshairUI] = useState('none'); const [crosshair, setCrosshairUI] = useState('none');
// Видимость пола в иерархии // Видимость пола в иерархии
const [floorEnabled, setFloorEnabledUI] = useState(true); const [floorEnabled, setFloorEnabledUI] = useState(true);
const [spawnEnabledUI, setSpawnEnabledUI] = useState(true);
// Табы над viewport (Roblox-style): «🎬 Сцена» + открытые скрипты // Табы над viewport (Roblox-style): «🎬 Сцена» + открытые скрипты
const [openTabs, setOpenTabs] = useState([{ id: 'scene', kind: 'scene', title: 'Сцена' }]); const [openTabs, setOpenTabs] = useState([{ id: 'scene', kind: 'scene', title: 'Сцена' }]);
const [activeTabId, setActiveTabId] = useState('scene'); const [activeTabId, setActiveTabId] = useState('scene');
@ -768,6 +782,93 @@ const KubikonEditor = () => {
}); });
} }
}, []); }, []);
// Задача 17: вставить готовую механику (kit) из Тулбокса в проект.
// prims[] создаём примитивы перед камерой; on-target скрипт привязываем
// к первому созданному примитиву; global скрипт добавляем как скрипт игры.
const insertGameplayKit = useCallback((kitId) => {
const kit = getKit(kitId);
const s = sceneRef.current;
if (!kit || !s) return;
// Точка вставки на ТВЁРДОЙ поверхности под центром экрана (пол/объект),
// чтобы предмет встал на землю в фокусе камеры, а не висел под камерой.
let px = 0, pz = 0, py = 0;
try {
const gp = s.getPlacementPointAtCenter?.();
if (gp) { px = gp.x; pz = gp.z; py = gp.y; }
else {
const cam = s.camera;
const fwd = cam?.getForwardRay ? cam.getForwardRay().direction : null;
if (cam && fwd) { px = cam.position.x + fwd.x * 6; pz = cam.position.z + fwd.z * 6; }
}
} catch (e) { /* ignore */ }
// 1) Создаём примитивы кита. Запоминаем все id (первый для on-target скрипта).
let firstPrimId = null;
const createdIds = [];
if (Array.isArray(kit.prims)) {
for (const p of kit.prims) {
const newId = s.primitiveManager?.addInstance(p.type || 'cube', {
x: px + (p.x || 0), y: py + (p.y != null ? p.y : 1), z: pz + (p.z || 0),
sx: p.sx, sy: p.sy, sz: p.sz,
color: p.color, material: p.material,
canCollide: p.canCollide !== false, visible: p.visible !== false, anchored: true,
name: p.name,
});
if (newId != null) {
createdIds.push(newId);
if (firstPrimId == null) firstPrimId = newId;
}
}
}
// Если кит состоит из НЕСКОЛЬКИХ частей кладём их в общую папку
// (объекты из нескольких частей всегда сгруппированы).
let kitFolderId = null;
if (createdIds.length > 1 && s.folderManager) {
kitFolderId = s.folderManager.createFolder(kit.name);
for (const pid of createdIds) {
s.folderManager.assignToFolder('primitive', pid, kitFolderId);
}
}
// 2) Добавляем скрипты кита с понятным именем (название кита).
if (Array.isArray(kit.scripts)) {
kit.scripts.forEach((sc, idx) => {
const sid = `script_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
// Имя = название кита (+ номер, если скриптов несколько).
const nm = kit.scripts.length > 1 ? `${kit.name} (${idx + 1})` : kit.name;
if (sc.attachTo === 'on-target' && firstPrimId != null) {
s.upsertScript(sid, sc.code, { kind: 'primitive', id: firstPrimId }, nm);
} else {
s.upsertScript(sid, sc.code, null, nm); // глобальный
}
});
}
markDirty();
hierarchyDirtyRef.current = true; // пересобрать дерево (примитивы с folderId)
setScriptsList(s.getScripts?.() || []);
if (s.folderManager) setFoldersList(s.folderManager.getAll());
// Выделим созданное и наведём камеру (видно, куда добавилось).
try {
setActiveTool('select');
if (kitFolderId != null) {
s.selection?.selectFolder?.(kitFolderId); // группа из нескольких частей
setGizmoMode('move');
} else if (firstPrimId != null) {
s.selection?.selectPrimitiveById(firstPrimId);
}
s.focusOnSelection?.();
} catch (e) {}
// Тост-уведомление (showToast будет подключён позже заглушка,
// чтобы не падал eslint no-undef и CI оставался зелёным).
try {
if (typeof window !== 'undefined' && typeof window.showToast === 'function') {
window.showToast(`Механика «${kit.name}» добавлена`);
}
} catch (e) {}
}, []);
const [paletteTab, setPaletteTab] = useState('blocks'); // 'blocks' | 'models' | 'primitives' const [paletteTab, setPaletteTab] = useState('blocks'); // 'blocks' | 'models' | 'primitives'
const [blockCount, setBlockCount] = useState(0); const [blockCount, setBlockCount] = useState(0);
const [modelCount, setModelCount] = useState(0); const [modelCount, setModelCount] = useState(0);
@ -776,6 +877,7 @@ const KubikonEditor = () => {
const [selection, setSelection] = useState(null); const [selection, setSelection] = useState(null);
const [blocksList, setBlocksList] = useState([]); const [blocksList, setBlocksList] = useState([]);
const [modelsList, setModelsList] = useState([]); const [modelsList, setModelsList] = useState([]);
const [userModelsList, setUserModelsList] = useState([]);
const [primitivesList, setPrimitivesList] = useState([]); const [primitivesList, setPrimitivesList] = useState([]);
const [foldersList, setFoldersList] = useState([]); const [foldersList, setFoldersList] = useState([]);
@ -920,6 +1022,14 @@ const KubikonEditor = () => {
console.warn('[KubikonEditor] save: skip (load failed)'); console.warn('[KubikonEditor] save: skip (load failed)');
return; return;
} }
// Team Create: в комнате совместного редактирования сохраняет ТОЛЬКО host
// (authoritative). Иначе два соавтора autosave'ят наперегонки last-write-wins
// затирает чужие правки. Не-host просто не пишет в БД, его изменения уже
// у host через операции.
if (collabRef.current?.connected && !collabRef.current?.isHost) {
console.log('[KubikonEditor] save: skip (collab non-host, host saves)');
return;
}
const userId = getCurrentUserId(); const userId = getCurrentUserId();
if (!userId) { if (!userId) {
console.warn('[KubikonEditor] save: no userId'); console.warn('[KubikonEditor] save: no userId');
@ -1139,6 +1249,108 @@ const KubikonEditor = () => {
}, AUTOSAVE_DEBOUNCE_MS); }, AUTOSAVE_DEBOUNCE_MS);
}, [doSave]); }, [doSave]);
/**
* Team Create: подключиться к комнате совместного редактирования.
* Зовётся ПОСЛЕ загрузки сцены (scene готова). projectIdNum числовой id.
* Подключаемся если: владелец проекта (всегда) ИЛИ есть ?collab=<token> в URL.
*/
const initCollab = useCallback(async (projectIdNum) => {
try {
if (!sceneRef.current || !projectIdNum) return;
if (collabRef.current) return; // уже подключены
const collabToken = new URLSearchParams(window.location.search).get('collab') || null;
const tokenRaw = localStorage.getItem('Authorization')
|| localStorage.getItem('jwt') || '';
if (!tokenRaw) return;
// Подключаемся только если есть инвайт ИЛИ владелец (бэкенд решит в onAuth).
// Если не владелец и нет инвайта onAuth вернёт 403, ловим тихо.
const collab = new StudioCollab(sceneRef.current, {
projectId: projectIdNum,
token: tokenRaw,
collabToken,
callbacks: {
onConnected: ({ isHost, role }) => {
setCollabActive(true);
setCollabRole(role || (isHost ? 'owner' : 'collab'));
collabOverlayRef.current?.toast(isHost
? 'Совместное редактирование включено. Пригласи друга кнопкой 👥'
: 'Ты подключился к совместному редактированию!');
},
onError: (msg) => {
// 403 (нет доступа) норм для не-приглашённого; просто не коллабим.
console.warn('[collab] error:', msg);
},
onLeft: () => { setCollabActive(false); },
onPresenceChange: (list) => {
collabOverlayRef.current?.updatePresence(list);
setCollabPeers(Math.max(0, list.filter(c => !c.me).length));
},
onOpRejected: (m) => {
collabOverlayRef.current?.toast('Этот объект сейчас редактирует другой соавтор');
},
onChat: (m) => { /* чат соавторов — на этап 2 */ },
// host отдаёт текущую сцену новому соавтору
onSnapshotRequest: (replyFn) => {
try { replyFn(sceneRef.current.serialize()); } catch (e) { /* ignore */ }
},
// новый соавтор получил сцену от host грузим
onRemoteSnapshot: async (state) => {
try {
if (state) {
await sceneRef.current.loadFromState(state);
dirtyRef.current = false;
}
} catch (e) { console.warn('[collab] snapshot load failed', e); }
},
},
});
await collab.connect();
collab.installInterceptors();
collabRef.current = collab;
// presence-overlay
const ov = new CollabOverlay(sceneRef.current);
ov.mount();
collabOverlayRef.current = ov;
// курсор-трекинг: шлём точку под мышью на сцене (raycast по pointermove)
_wireCursorTracking(sceneRef.current, collab);
} catch (e) {
// 403/нет доступа/realtime недоступен работаем соло, не падаем.
console.warn('[collab] init skipped:', e?.message || e);
}
}, []);
/**
* Team Create: «Пригласить» запросить collab-токен у realtime, собрать
* ссылку studio.rublox.pro/edit/<id>?collab=<token> и скопировать в буфер.
*/
const handleInvite = useCallback(async () => {
try {
if (!/^\d+$/.test(id)) { alert('Сначала сохрани проект.'); return; }
const tokenRaw = localStorage.getItem('Authorization') || localStorage.getItem('jwt') || '';
const base = (REALTIME_HTTP || '').replace(/\/$/, '');
const res = await fetch(`${base}/studio-invite/${id}`, {
method: 'POST',
headers: { Authorization: tokenRaw, 'Content-Type': 'application/json' },
});
if (!res.ok) {
if (res.status === 403) { alert('Только автор проекта может приглашать соавторов.'); return; }
alert('Не удалось создать приглашение (' + res.status + ').');
return;
}
const { token } = await res.json();
const link = `${window.location.origin}/edit/${id}?collab=${token}`;
try {
await navigator.clipboard.writeText(link);
collabOverlayRef.current?.toast('Ссылка-приглашение скопирована! Отправь другу.');
} catch (e) {
window.prompt('Скопируй ссылку-приглашение для друга:', link);
}
} catch (e) {
console.warn('[collab] invite failed', e);
alert('Не удалось создать приглашение. Realtime недоступен?');
}
}, [id]);
// Инициализация Babylon + загрузка проекта (если редактируем существующий) // Инициализация Babylon + загрузка проекта (если редактируем существующий)
useEffect(() => { useEffect(() => {
// RACE FIX: пока isLoading=true (auth ещё грузится), компонент // RACE FIX: пока isLoading=true (auth ещё грузится), компонент
@ -1293,6 +1505,8 @@ const KubikonEditor = () => {
markDirty(); markDirty();
// Иерархия изменилась interval пересоберёт списки на след. тике. // Иерархия изменилась interval пересоберёт списки на след. тике.
hierarchyDirtyRef.current = true; hierarchyDirtyRef.current = true;
// Синк флага точки спавна (например после Delete-клавиши).
try { setSpawnEnabledUI(scene.hasSpawn?.() !== false); } catch (e) {}
}); });
// Этап 5: подключаем API пользовательских моделей в BabylonScene, // Этап 5: подключаем API пользовательских моделей в BabylonScene,
@ -1497,6 +1711,7 @@ const KubikonEditor = () => {
const ch = sceneRef.current.getCrosshair?.(); const ch = sceneRef.current.getCrosshair?.();
if (ch) setCrosshairUI(ch); if (ch) setCrosshairUI(ch);
setFloorEnabledUI(sceneRef.current.isFloorEnabled?.() !== false); setFloorEnabledUI(sceneRef.current.isFloorEnabled?.() !== false);
setSpawnEnabledUI(sceneRef.current.hasSpawn?.() !== false);
const a = sceneRef.current.getAudioState?.(); const a = sceneRef.current.getAudioState?.();
if (a?.ambientId) setAmbientIdUI(a.ambientId); if (a?.ambientId) setAmbientIdUI(a.ambientId);
if (a?.musicId) setMusicIdUI(a.musicId); if (a?.musicId) setMusicIdUI(a.musicId);
@ -1515,6 +1730,9 @@ const KubikonEditor = () => {
} finally { } finally {
console.log(`[KubikonEditor] setSceneLoading(false) — load finally (id=${id})`); console.log(`[KubikonEditor] setSceneLoading(false) — load finally (id=${id})`);
setSceneLoading(false); setSceneLoading(false);
// Team Create: после загрузки сцены подключиться к комнате
// совместного редактирования (владелец или по ?collab-инвайту).
if (/^\d+$/.test(id)) initCollab(Number(id));
} }
})(); })();
} else { } else {
@ -1565,6 +1783,21 @@ const KubikonEditor = () => {
} }
setModelsList(arr); setModelsList(arr);
} }
if (s.userModelManager) {
const arr = [];
for (const data of s.userModelManager.instances.values()) {
arr.push({
instanceId: data.instanceId,
userModelTypeId: data.userModelTypeId,
userModelId: data.userModelId,
x: data.x, y: data.y, z: data.z,
rotationY: data.rotationY,
folderId: data.folderId ?? null,
name: data.name || null,
});
}
setUserModelsList(arr);
}
if (s.primitiveManager) { if (s.primitiveManager) {
// getAll() не включает folderId добавляем вручную // getAll() не включает folderId добавляем вручную
const arr = s.primitiveManager.getAll(); const arr = s.primitiveManager.getAll();
@ -1616,13 +1849,23 @@ const KubikonEditor = () => {
clearTimeout(autoSaveTimerRef.current); clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null; autoSaveTimerRef.current = null;
} }
// Team Create: отключиться от комнаты + снять overlay.
try {
if (collabRef.current?.__cursorHandler && collabRef.current?.__cursorCanvas) {
collabRef.current.__cursorCanvas.removeEventListener('pointermove', collabRef.current.__cursorHandler);
}
collabRef.current?.dispose();
collabOverlayRef.current?.dispose();
} catch (e) { /* ignore */ }
collabRef.current = null;
collabOverlayRef.current = null;
scene.dispose(); scene.dispose();
sceneRef.current = null; sceneRef.current = null;
}; };
// isLoading в deps без него эффект мог стрельнуть пока canvas // isLoading в deps без него эффект мог стрельнуть пока canvas
// ещё не в DOM (isLoading=true компонент рендерит null) и больше // ещё не в DOM (isLoading=true компонент рендерит null) и больше
// не перезапускался вечная "Загрузка проекта 0%". // не перезапускался вечная "Загрузка проекта 0%".
}, [isAuthenticated, isLoading, id, markDirty]); }, [isAuthenticated, isLoading, id, markDirty, initCollab]);
// beforeunload браузерный диалог нельзя кастомизировать (API запрещает). // beforeunload браузерный диалог нельзя кастомизировать (API запрещает).
// Auto-save срабатывает на каждое изменение через ~1.5 сек, поэтому риск // Auto-save срабатывает на каждое изменение через ~1.5 сек, поэтому риск
@ -1840,6 +2083,19 @@ const KubikonEditor = () => {
{saveStatus === 'error' && <><Icon name="warning" size={12} /> Ошибка</>} {saveStatus === 'error' && <><Icon name="warning" size={12} /> Ошибка</>}
{saveStatus === 'idle' && '—'} {saveStatus === 'idle' && '—'}
</span> </span>
{/* Гость-соавтор (приглашённый по ссылке) НЕ может менять
настройки/сохранять/публиковать это делает только владелец. */}
{isInvitedGuest ? (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 8,
background: 'rgba(92,214,138,0.15)', color: '#5cd68a',
font: '600 12px system-ui',
}} title="Ты редактируешь вместе с автором. Сохраняет и публикует автор.">
<Icon name="users" size={13} /> Совместное редактирование
</span>
) : (
<>
<button <button
className={cl.toolbarBtn} className={cl.toolbarBtn}
onClick={() => setSettingsModalOpen(true)} onClick={() => setSettingsModalOpen(true)}
@ -1848,14 +2104,7 @@ const KubikonEditor = () => {
> >
<Icon name="settings" size={13} /> Настройки <Icon name="settings" size={13} /> Настройки
</button> </button>
<button {/* Кнопка «Скины» переехала в TopRibbon → вкладка «Игра». */}
className={cl.toolbarBtn}
onClick={() => setSkinManagerOpen(true)}
title="Скины игрока: стартовый скин, магазин, рублики, свои .glb"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
>
<Icon name="user-square" size={13} /> Скины
</button>
<button className={cl.toolbarBtn} onClick={handleSave}><Icon name="save" size={13} /> Сохранить</button> <button className={cl.toolbarBtn} onClick={handleSave}><Icon name="save" size={13} /> Сохранить</button>
<button <button
className={cl.toolbarBtn} className={cl.toolbarBtn}
@ -1895,6 +2144,8 @@ const KubikonEditor = () => {
> >
{(publishBan || isCantPublish) ? <><Icon name="hidden" size={13} /> Запрещено</> : <><Icon name="upload" size={13} /> Опубликовать</>} {(publishBan || isCantPublish) ? <><Icon name="hidden" size={13} /> Запрещено</> : <><Icon name="upload" size={13} /> Опубликовать</>}
</button> </button>
</>
)}
{/* Кнопка баг-репорта в шапке — кликает по скрытой плавающей. */} {/* Кнопка баг-репорта в шапке — кликает по скрытой плавающей. */}
<button <button
onClick={() => document.querySelector('[data-kubikon-bug-btn]')?.click()} onClick={() => document.querySelector('[data-kubikon-bug-btn]')?.click()}
@ -2036,7 +2287,12 @@ const KubikonEditor = () => {
onPlayToggle={handlePlay} onPlayToggle={handlePlay}
onSetSpawn={() => { onSetSpawn={() => {
sceneRef.current?.setSpawnAtCamera(); sceneRef.current?.setSpawnAtCamera();
setSpawnEnabledUI(true);
}} }}
onSkins={() => setSkinManagerOpen(true)}
onInvite={handleInvite}
collabActive={collabActive}
collabPeers={collabPeers}
hasSelection={!!selection} hasSelection={!!selection}
onDuplicate={() => sceneRef.current?.duplicateSelected()} onDuplicate={() => sceneRef.current?.duplicateSelected()}
onAlignToFloor={() => sceneRef.current?.alignSelectedToFloor()} onAlignToFloor={() => sceneRef.current?.alignSelectedToFloor()}
@ -2674,6 +2930,7 @@ const KubikonEditor = () => {
className={cl.canvas} className={cl.canvas}
style={{ visibility: activeTabId === 'scene' ? 'visible' : 'hidden' }} style={{ visibility: activeTabId === 'scene' ? 'visible' : 'hidden' }}
/> />
{/* Кнопка «Пригласить» переехала в TopRibbon → вкладка «Игра» → группа «Вместе». */}
{isMaterialPreview && ( {isMaterialPreview && (
<div style={{ <div style={{
position: 'absolute', top: 10, position: 'absolute', top: 10,
@ -3020,6 +3277,11 @@ const KubikonEditor = () => {
logs={scriptLogs} logs={scriptLogs}
onClear={() => setScriptLogs([])} onClear={() => setScriptLogs([])}
onClose={() => setConsoleOpen(false)} onClose={() => setConsoleOpen(false)}
onOpenScript={(scriptId) => {
// Открыть скрипт-источник ошибки в редакторе.
try { sceneRef.current?.selection?.selectScript?.(scriptId); } catch (e) {}
openScriptTab(scriptId);
}}
/> />
{/* Monaco-редактор скрипта (этап 2.2). Активен когда выбран таб со скриптом. */} {/* Monaco-редактор скрипта (этап 2.2). Активен когда выбран таб со скриптом. */}
@ -3064,9 +3326,21 @@ const KubikonEditor = () => {
<HierarchyPanel <HierarchyPanel
blocks={blocksList} blocks={blocksList}
models={modelsList} models={modelsList}
userModels={userModelsList}
primitives={primitivesList} primitives={primitivesList}
folders={foldersList} folders={foldersList}
scripts={scriptsList} scripts={scriptsList}
onSelectUserModel={(id) => {
sceneRef.current?.selection?.selectUserModelByInstanceId(id);
setActiveTool('select');
}}
onDeleteUserModel={(id) => {
sceneRef.current?.userModelManager?.removeInstance(id);
sceneRef.current?.clearSelection();
}}
onRenameUserModel={(id, name) => {
if (sceneRef.current?.renameUserModel?.(id, name)) markDirty();
}}
onSelectScript={(scriptId) => { onSelectScript={(scriptId) => {
sceneRef.current?.selection?.selectScript?.(scriptId); sceneRef.current?.selection?.selectScript?.(scriptId);
setActiveTool('select'); setActiveTool('select');
@ -3080,12 +3354,17 @@ const KubikonEditor = () => {
if (target) { if (target) {
if (target.kind === 'block') { if (target.kind === 'block') {
normalized = { kind: 'block', ref: { x: target.x, y: target.y, z: target.z } }; normalized = { kind: 'block', ref: { x: target.x, y: target.y, z: target.z } };
} else if (target.kind === 'model' || target.kind === 'primitive') { } else if (target.kind === 'model' || target.kind === 'primitive' || target.kind === 'userModel') {
normalized = { kind: target.kind, id: target.id }; normalized = { kind: target.kind, id: target.id };
} }
} }
const tpl = normalized ? NEW_OBJECT_SCRIPT_TEMPLATE : NEW_SCRIPT_TEMPLATE; const tpl = normalized ? NEW_OBJECT_SCRIPT_TEMPLATE : NEW_SCRIPT_TEMPLATE;
sceneRef.current?.upsertScript(id, tpl, normalized); // Понятное имя по умолчанию (а не сырой id).
const existing = sceneRef.current?.getScripts?.() || [];
const nm = normalized
? `Скрипт объекта ${existing.filter(s => s.target && s.target !== 'game').length + 1}`
: `Скрипт ${existing.filter(s => !s.target || s.target === 'game').length + 1}`;
sceneRef.current?.upsertScript(id, tpl, normalized, nm);
markDirty(); markDirty();
setScriptsList(sceneRef.current?.getScripts?.() || []); setScriptsList(sceneRef.current?.getScripts?.() || []);
sceneRef.current?.selection?.selectScript?.(id); sceneRef.current?.selection?.selectScript?.(id);
@ -3152,6 +3431,7 @@ const KubikonEditor = () => {
setActiveTool('select'); setActiveTool('select');
}} }}
floorEnabled={floorEnabled} floorEnabled={floorEnabled}
spawnEnabled={spawnEnabledUI}
onSelectFloor={() => { onSelectFloor={() => {
sceneRef.current?.selection?.selectFloor?.(); sceneRef.current?.selection?.selectFloor?.();
setActiveTool('select'); setActiveTool('select');
@ -3216,6 +3496,18 @@ const KubikonEditor = () => {
// Активируем гизмо «Двигать» чтобы можно было сразу таскать // Активируем гизмо «Двигать» чтобы можно было сразу таскать
setGizmoMode('move'); setGizmoMode('move');
}} }}
onDeleteSpawn={() => {
sceneRef.current?.deleteSpawn?.();
sceneRef.current?.clearSelection?.();
setSpawnEnabledUI(false);
markDirty();
}}
onSelectFolder={(folderId) => {
sceneRef.current?.selection?.selectFolder?.(folderId);
setActiveTool('select');
// Активируем gizmo «Двигать» чтобы сразу таскать всю группу.
setGizmoMode('move');
}}
onSelectLighting={() => { onSelectLighting={() => {
sceneRef.current?.selection?.selectLighting(); sceneRef.current?.selection?.selectLighting();
setActiveTool('select'); setActiveTool('select');
@ -3223,14 +3515,24 @@ const KubikonEditor = () => {
onDeleteBlock={(x, y, z) => { onDeleteBlock={(x, y, z) => {
sceneRef.current?.blockManager?.removeBlock(x, y, z); sceneRef.current?.blockManager?.removeBlock(x, y, z);
sceneRef.current?.clearSelection(); sceneRef.current?.clearSelection();
markDirty();
hierarchyDirtyRef.current = true;
}} }}
onDeleteModel={(id) => { onDeleteModel={(id) => {
sceneRef.current?.modelManager?.removeInstance(id); sceneRef.current?.modelManager?.removeInstance(id);
sceneRef.current?._cleanupOrphanScripts?.();
sceneRef.current?.clearSelection(); sceneRef.current?.clearSelection();
setScriptsList(sceneRef.current?.getScripts?.() || []);
markDirty();
hierarchyDirtyRef.current = true;
}} }}
onDeletePrimitive={(id) => { onDeletePrimitive={(id) => {
sceneRef.current?.primitiveManager?.removeInstance(id); sceneRef.current?.primitiveManager?.removeInstance(id);
sceneRef.current?._cleanupOrphanScripts?.();
sceneRef.current?.clearSelection(); sceneRef.current?.clearSelection();
setScriptsList(sceneRef.current?.getScripts?.() || []);
markDirty();
hierarchyDirtyRef.current = true;
}} }}
onFocusSelection={() => sceneRef.current?.focusOnSelection()} onFocusSelection={() => sceneRef.current?.focusOnSelection()}
onCreateFolder={(name, parentId) => onCreateFolder={(name, parentId) =>
@ -3814,6 +4116,12 @@ const KubikonEditor = () => {
}} }}
onClose={() => setToolboxOpen(false)} onClose={() => setToolboxOpen(false)}
onPick={(id, userModelObj = null) => { onPick={(id, userModelObj = null) => {
// Задача 17: готовая механика из Тулбокса (kit:<id>).
// Вставляем её скрипты/примитивы в проект одним кликом.
if (typeof id === 'string' && id.startsWith('kit:')) {
insertGameplayKit(id.slice(4));
return;
}
// Пользовательские модели имеют префикс 'user:<id>' и // Пользовательские модели имеют префикс 'user:<id>' и
// обрабатываются в BabylonScene через UserModelManager // обрабатываются в BabylonScene через UserModelManager
// (Этап 5). Активный тип модели работает одинаково. // (Этап 5). Активный тип модели работает одинаково.
@ -3852,4 +4160,34 @@ const KubikonEditor = () => {
); );
}; };
/**
* Team Create: слать соавторам точку под мышью на сцене (raycast по pointermove).
* Throttle уже внутри collab.sendCursor. Также шлём позицию камеры.
*/
function _wireCursorTracking(scene, collab) {
try {
const canvas = scene.canvas;
if (!canvas) return;
const onMove = () => {
const bScene = scene.scene;
if (!bScene) return;
try {
const pick = bScene.pick(bScene.pointerX, bScene.pointerY);
if (pick && pick.hit && pick.pickedPoint) {
collab.sendCursor(pick.pickedPoint.x, pick.pickedPoint.y, pick.pickedPoint.z);
}
} catch (e) { /* ignore */ }
// камера (throttle внутри)
try {
const c = scene.camera;
if (c && c.position) collab.sendCamera(c.position.x, c.position.y, c.position.z);
} catch (e) { /* ignore */ }
};
canvas.addEventListener('pointermove', onMove);
// сохраним для снятия (необязательно canvas живёт с редактором)
collab.__cursorHandler = onMove;
collab.__cursorCanvas = canvas;
} catch (e) { /* ignore */ }
}
export default KubikonEditor; export default KubikonEditor;

View File

@ -25,7 +25,7 @@ const LEVEL_BG = {
warn: 'rgba(245, 158, 11, 0.12)', warn: 'rgba(245, 158, 11, 0.12)',
}; };
const ScriptConsole = ({ logs = [], onClear, onClose, visible }) => { const ScriptConsole = ({ logs = [], onClear, onClose, visible, onOpenScript }) => {
const listRef = useRef(null); const listRef = useRef(null);
const [copyState, setCopyState] = useState('idle'); const [copyState, setCopyState] = useState('idle');
@ -260,7 +260,9 @@ const ScriptConsole = ({ logs = [], onClear, onClose, visible }) => {
? `3px solid ${LEVEL_COLORS[l.level]}` ? `3px solid ${LEVEL_COLORS[l.level]}`
: '3px solid transparent', : '3px solid transparent',
paddingLeft: 8, paddingLeft: 8,
display: 'flex', alignItems: 'flex-start', gap: 8,
}}> }}>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ <span style={{
color: '#94a3b8', marginRight: 10, fontWeight: 700, color: '#94a3b8', marginRight: 10, fontWeight: 700,
}}> }}>
@ -270,6 +272,26 @@ const ScriptConsole = ({ logs = [], onClear, onClose, visible }) => {
{l.level === 'warn' && <span><Icon name="warning" size={14} /></span>} {l.level === 'warn' && <span><Icon name="warning" size={14} /></span>}
{l.level === 'info' && <span style={{ opacity: 0.7 }}> </span>} {l.level === 'info' && <span style={{ opacity: 0.7 }}> </span>}
{l.text} {l.text}
</span>
{/* Ссылка на скрипт-источник (клик открывает его). */}
{l.scriptId && (
<button
type="button"
onClick={() => onOpenScript?.(l.scriptId)}
title={'Открыть скрипт: ' + (l.scriptName || l.scriptId)}
style={{
flex: '0 0 auto', maxWidth: 160,
background: 'rgba(79,116,255,0.16)',
border: '1px solid rgba(79,116,255,0.3)',
color: '#8aa0ff', borderRadius: 6,
padding: '1px 8px', fontSize: 11, fontWeight: 700,
cursor: 'pointer', whiteSpace: 'nowrap',
overflow: 'hidden', textOverflow: 'ellipsis',
}}
>
📄 {l.scriptName || l.scriptId}
</button>
)}
</div> </div>
)) ))
)} )}

View File

@ -1,5 +1,6 @@
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import { MODEL_TYPES, MODEL_CATEGORIES } from './engine/ModelTypes'; import { MODEL_TYPES, MODEL_CATEGORIES } from './engine/ModelTypes';
import { GAMEPLAY_KITS, KIT_CATEGORIES } from './engine/GameplayKits';
import { getModelThumbnail, cancelThumbnailRequest } from './engine/ModelThumbnails'; import { getModelThumbnail, cancelThumbnailRequest } from './engine/ModelThumbnails';
import { import {
getMyUserModels, getPublicUserModels, likeUserModel, getMyUserModels, getPublicUserModels, likeUserModel,
@ -281,10 +282,18 @@ const ToolboxModal = ({
initialSection = 'standard', initialSection = 'standard',
}) => { }) => {
// Корневой раздел: 'standard' | 'mine' | 'community' // Корневой раздел: 'standard' | 'mine' | 'community'
// === Roblox-style Toolbox (задача 17) ===
// Верхняя вкладка: 'store' | 'inventory' | 'recent' | 'tips'.
const [view, setView] = useState('store');
// Выбранная категория магазина (null = главный экран с 6 плитками):
// '3d' | 'fx' | '2d' | 'gameplay' | 'plugins' | 'audio'.
const [storeCat, setStoreCat] = useState(null);
const [section, setSection] = useState('standard'); const [section, setSection] = useState('standard');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [category, setCategory] = useState('all'); // для 'standard' const [category, setCategory] = useState('all'); // для 'standard'
const [userKind, setUserKind] = useState('all'); // для 'mine'/'community': all|voxel|smooth const [userKind, setUserKind] = useState('all'); // для 'mine'/'community': all|voxel|smooth
const [kitCat, setKitCat] = useState('all'); // для 'gameplay': категория кита
// Загруженные модели для 'mine' и 'community' // Загруженные модели для 'mine' и 'community'
const [myModels, setMyModels] = useState(null); // null = ещё не загружено const [myModels, setMyModels] = useState(null); // null = ещё не загружено
@ -295,22 +304,43 @@ const ToolboxModal = ({
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setSearch(''); setSearch('');
// initialSection маппится в новую структуру: mine inventory.
if (initialSection === 'mine') { setView('inventory'); setStoreCat(null); }
else { setView('store'); setStoreCat(null); }
setSection(initialSection || 'standard'); setSection(initialSection || 'standard');
setCategory('all'); setCategory('all');
setUserKind('all'); setUserKind('all');
setKitCat('all');
setMyModels(null); setMyModels(null);
setCommunityModels(null); setCommunityModels(null);
setLoadError(''); setLoadError('');
} }
}, [open, initialSection]); }, [open, initialSection]);
// Esc закрыть // Маппинг категории магазина внутренний section (для lazy-load моделей).
const STORE_CAT_TO_SECTION = { '3d': 'standard', gameplay: 'gameplay', '2d': 'standard' };
const openStoreCategory = useCallback((catId) => {
setStoreCat(catId);
setSearch('');
setCategory('all');
setKitCat('all');
const sec = STORE_CAT_TO_SECTION[catId];
if (sec) setSection(sec);
}, []);
// Синхронизация верхней вкладки внутренний section (для lazy-load).
useEffect(() => {
if (view === 'inventory') setSection('mine');
else if (view === 'recent') setSection('community');
}, [view]);
// Esc закрыть (если открыта категория магазина сначала назад к плиткам)
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') onClose(); }; const onKey = (e) => { if (e.key === 'Escape') { if (view === 'store' && storeCat) setStoreCat(null); else onClose(); } };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]); }, [open, onClose, view, storeCat]);
// Lazy-load моих моделей при переключении на 'mine' // Lazy-load моих моделей при переключении на 'mine'
useEffect(() => { useEffect(() => {
@ -413,6 +443,16 @@ const ToolboxModal = ({
[communityModels, filterUserModels] [communityModels, filterUserModels]
); );
// === Готовые механики (gameplay-киты) фильтр по категории + search ===
const kitsFiltered = useMemo(() => {
const q = search.trim().toLowerCase();
let arr = GAMEPLAY_KITS;
if (kitCat !== 'all') arr = arr.filter(k => k.category === kitCat);
if (q) arr = arr.filter(k =>
k.name.toLowerCase().includes(q) || k.desc.toLowerCase().includes(q));
return arr;
}, [search, kitCat]);
// Активный счётчик для шапки // Активный счётчик для шапки
const visibleCount = section === 'standard' const visibleCount = section === 'standard'
? standardFiltered.length ? standardFiltered.length
@ -425,6 +465,27 @@ const ToolboxModal = ({
? (myModels?.length || 0) ? (myModels?.length || 0)
: (communityModels?.length || 0); : (communityModels?.length || 0);
// 6 категорий магазина (как в Roblox Creator Store).
const STORE_CATEGORIES = [
{ id: '3d', label: '3D-объекты', icon: 'cube', desc: '700+ моделей: природа, дома, мебель, NPC' },
{ id: 'fx', label: 'Эффекты', icon: 'sparkles', desc: 'Частицы, лучи, маркеры' },
{ id: '2d', label: '2D-картинки', icon: 'image', desc: 'Иконки и текстуры для интерфейса' },
{ id: 'gameplay', label: 'Готовые механики', icon: 'zap', desc: `${GAMEPLAY_KITS.length} механик: вставил — работает` },
{ id: 'plugins', label: 'Плагины', icon: 'puzzle', desc: 'Расширения студии' },
{ id: 'audio', label: 'Аудио', icon: 'sound', desc: 'Звуки и музыка' },
];
// Trending что популярно (для главного экрана магазина). Берём яркие киты.
const TRENDING = GAMEPLAY_KITS.filter(k =>
['shift-to-run', 'day-night-cycle', 'loot-crate', 'confetti'].includes(k.id));
// Эффекты-примитивы для категории «Эффекты».
const FX_ITEMS = [
{ id: 'emitter', name: 'Эмиттер частиц', icon: 'sparkles', desc: 'Источник частиц (огонь/искры/дым)' },
{ id: 'beam', name: 'Луч (beam)', icon: 'zap', desc: 'Бегущий луч между точками' },
{ id: 'pointer', name: 'Указатель-стрелка', icon: 'arrow-up', desc: 'Парящая стрелка-подсказка' },
{ id: 'light', name: 'Источник света', icon: 'sun', desc: 'Точечная лампа' },
{ id: 'checkpoint', name: 'Триггер-зона', icon: 'flag', desc: 'Невидимая зона-триггер' },
];
if (!open) return null; if (!open) return null;
// Обработчик выбора пользовательской модели пока stub. // Обработчик выбора пользовательской модели пока stub.
@ -490,59 +551,63 @@ const ToolboxModal = ({
return ( return (
<div className={cl.overlay} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}> <div className={cl.overlay} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className={cl.modal}> <div className={cl.modal}>
{/* === Шапка с 4 верхними вкладками (как Roblox Creator Store) === */}
<header className={cl.header}> <header className={cl.header}>
<h2 className={cl.title} style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <h2 className={cl.title} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Icon name="box" size={20} /> Тулбокс библиотека объектов <Icon name="box" size={20} /> Toolbox
</h2> </h2>
<div className={cl.headerInfo}>
{section === 'standard'
? `Показано ${visibleCount} из ${totalForSection}`
: (myModels === null && section === 'mine') || (communityModels === null && section === 'community')
? '...'
: `${visibleCount} из ${totalForSection}`}
</div>
<button className={cl.closeBtn} onClick={onClose} title="Закрыть (Esc)"> <button className={cl.closeBtn} onClick={onClose} title="Закрыть (Esc)">
<Icon name="close" size={14} /> <Icon name="close" size={14} />
</button> </button>
</header> </header>
{/* Раздел: Стандартные / Мои / Сообщество */} <div className={cl.topTabs}>
<div className={cl.sectionTabs}> {[
{ id: 'store', label: 'Магазин', icon: 'box' },
{ id: 'inventory', label: 'Инвентарь', icon: 'grid' },
{ id: 'recent', label: 'Недавние', icon: 'clock' },
{ id: 'tips', label: 'Советы', icon: 'bulb' },
].map(t => (
<button <button
className={`${cl.sectionTab} ${section === 'standard' ? cl.sectionTabActive : ''}`} key={t.id}
onClick={() => setSection('standard')} className={`${cl.topTab} ${view === t.id ? cl.topTabActive : ''}`}
onClick={() => { setView(t.id); setStoreCat(null); setSearch(''); }}
title={t.label}
> >
<Icon name="library" size={15} /> Стандартные <Icon name={t.icon} size={18} />
</button> <span>{t.label}</span>
<button
className={`${cl.sectionTab} ${section === 'mine' ? cl.sectionTabActive : ''}`}
onClick={() => setSection('mine')}
>
<Icon name="user" size={15} /> Мои модели
</button>
<button
className={`${cl.sectionTab} ${section === 'community' ? cl.sectionTabActive : ''}`}
onClick={() => setSection('community')}
>
<Icon name="globe" size={15} /> Сообщество
</button> </button>
))}
</div> </div>
{/* Поиск — скрыт только на главном экране магазина и в советах */}
{!(view === 'store' && !storeCat) && view !== 'tips' && (
<div className={cl.searchBar}> <div className={cl.searchBar}>
<input <input
type="text" type="text"
className={cl.searchInput} className={cl.searchInput}
placeholder={section === 'standard' placeholder="Поиск по названию..."
? 'Поиск по названию, категории...'
: 'Поиск по названию модели или автору...'}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
autoFocus autoFocus
/> />
</div> </div>
)}
{/* Подкатегории зависят от раздела */} {/* Хлебные крошки/назад при открытой категории магазина */}
{section === 'standard' && ( {view === 'store' && storeCat && (
<div className={cl.breadcrumb}>
<button className={cl.backBtn} onClick={() => setStoreCat(null)}>
<Icon name="arrow-left" size={14} /> Категории
</button>
<span className={cl.crumbCurrent}>
{(STORE_CATEGORIES.find(c => c.id === storeCat) || {}).label}
</span>
</div>
)}
{/* Подкатегории standard (3D) */}
{view === 'store' && storeCat === '3d' && (
<div className={cl.categoryTabs}> <div className={cl.categoryTabs}>
{standardCategoriesWithCount.map(c => ( {standardCategoriesWithCount.map(c => (
<button <button
@ -557,107 +622,180 @@ const ToolboxModal = ({
))} ))}
</div> </div>
)} )}
{/* Подкатегории механик */}
{(section === 'mine' || section === 'community') && ( {view === 'store' && storeCat === 'gameplay' && (
<div className={cl.categoryTabs}> <div className={cl.categoryTabs}>
{KIT_CATEGORIES.map(c => (
<button <button
className={`${cl.categoryTab} ${userKind === 'all' ? cl.categoryTabActive : ''}`} key={c.id}
onClick={() => setUserKind('all')} className={`${cl.categoryTab} ${kitCat === c.id ? cl.categoryTabActive : ''}`}
> onClick={() => setKitCat(c.id)}
<Icon name="library" size={14} /> Все >{c.label}</button>
</button> ))}
<button </div>
className={`${cl.categoryTab} ${userKind === 'voxel' ? cl.categoryTabActive : ''}`} )}
onClick={() => setUserKind('voxel')} {/* Фильтр kind для инвентаря */}
> {view === 'inventory' && (
<Icon name="square" size={14} /> Воксельные <div className={cl.categoryTabs}>
</button> <button className={`${cl.categoryTab} ${userKind === 'all' ? cl.categoryTabActive : ''}`} onClick={() => setUserKind('all')}><Icon name="library" size={14} /> Все</button>
<button <button className={`${cl.categoryTab} ${userKind === 'voxel' ? cl.categoryTabActive : ''}`} onClick={() => setUserKind('voxel')}><Icon name="square" size={14} /> Воксельные</button>
className={`${cl.categoryTab} ${userKind === 'smooth' ? cl.categoryTabActive : ''}`} <button className={`${cl.categoryTab} ${userKind === 'smooth' ? cl.categoryTabActive : ''}`} onClick={() => setUserKind('smooth')}><Icon name="sphere" size={14} /> Гладкие</button>
onClick={() => setUserKind('smooth')}
>
<Icon name="sphere" size={14} /> Гладкие
</button>
</div> </div>
)} )}
{/* === Контент === */} {/* ====================== КОНТЕНТ ====================== */}
<div className={cl.grid}>
{section === 'standard' && ( {/* --- МАГАЗИН: главный экран (6 плиток + Trending) --- */}
standardFiltered.length === 0 ? ( {view === 'store' && !storeCat && (
<div className={cl.empty}>Ничего не найдено</div> <div className={cl.storeHome}>
) : ( <div className={cl.sectionLabel}>Категории</div>
standardFiltered.map(m => ( <div className={cl.catGrid}>
<ThumbCard {STORE_CATEGORIES.map(c => (
key={m.id} <button key={c.id} className={cl.catTile} onClick={() => openStoreCategory(c.id)}>
model={m} <div className={cl.catTileIcon}><Icon name={c.icon} size={26} /></div>
active={activeId === m.id} <div className={cl.catTileLabel}>{c.label}</div>
onPick={() => { onPick(m.id); onClose(); }} <div className={cl.catTileDesc}>{c.desc}</div>
/> </button>
)) ))}
) </div>
<div className={cl.sectionLabel} style={{ marginTop: 18 }}>
<Icon name="trending" size={15} /> Популярное
</div>
<div className={cl.trendRow}>
{TRENDING.map(kit => (
<button key={kit.id} className={cl.trendCard}
onClick={() => { onPick('kit:' + kit.id); onClose(); }} title={kit.desc}>
<div className={cl.trendIcon}><Icon name={kit.icon || 'zap'} size={28} /></div>
<div className={cl.trendName}>{kit.name}</div>
<div className={cl.freeBadge}>FREE</div>
</button>
))}
</div>
</div>
)} )}
{section === 'mine' && ( {/* --- МАГАЗИН: категория 3D-объекты --- */}
loading || myModels === null ? ( {view === 'store' && storeCat === '3d' && (
<div className={cl.grid}>
{standardFiltered.length === 0
? <div className={cl.empty}>Ничего не найдено</div>
: standardFiltered.map(m => (
<ThumbCard key={m.id} model={m} active={activeId === m.id}
onPick={() => { onPick(m.id); onClose(); }} />
))}
</div>
)}
{/* --- МАГАЗИН: Эффекты --- */}
{view === 'store' && storeCat === 'fx' && (
<div className={cl.grid}>
{FX_ITEMS.filter(f => !search.trim() || f.name.toLowerCase().includes(search.trim().toLowerCase())).map(f => (
<button key={f.id} type="button" className={cl.card} style={{ textAlign: 'left' }}
onClick={() => { onPick('primitive:' + f.id); onClose(); }} title={f.desc}>
<div className={cl.cardIconWrap}>
<div className={cl.cardIconPlaceholder} style={{ background: 'linear-gradient(135deg, rgba(255,90,176,0.22), rgba(255,210,58,0.18))' }}>
<Icon name={f.icon} size={30} />
</div>
</div>
<div className={cl.cardName}>{f.name}</div>
<div className={cl.cardCat} style={{ fontSize: 11, opacity: 0.8 }}>{f.desc}</div>
</button>
))}
</div>
)}
{/* --- МАГАЗИН: Готовые механики --- */}
{view === 'store' && storeCat === 'gameplay' && (
<div className={cl.grid}>
{kitsFiltered.length === 0
? <div className={cl.empty}>Ничего не найдено</div>
: kitsFiltered.map(kit => (
<button key={kit.id} type="button" className={cl.card} style={{ textAlign: 'left' }}
onClick={() => { onPick('kit:' + kit.id); onClose(); }} title={kit.desc}>
<div className={cl.cardIconWrap}>
<div className={cl.cardIconPlaceholder} style={{ background: 'linear-gradient(135deg, rgba(77,107,255,0.25), rgba(54,213,122,0.18))' }}>
<Icon name={kit.icon || 'zap'} size={30} />
</div>
</div>
<div className={cl.cardName}>{kit.name}</div>
<div className={cl.cardCat} style={{ fontSize: 11, opacity: 0.8, lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{kit.desc}</div>
<div className={cl.freeBadge}>FREE</div>
</button>
))}
</div>
)}
{/* --- МАГАЗИН: 2D-картинки / Плагины / Аудио — пока «скоро» --- */}
{view === 'store' && (storeCat === '2d' || storeCat === 'plugins' || storeCat === 'audio') && (
<div className={cl.soon}>
<Icon name={storeCat === 'audio' ? 'sound' : storeCat === '2d' ? 'image' : 'puzzle'} size={42} />
<div className={cl.soonTitle}>Скоро будет</div>
<div className={cl.soonText}>
{storeCat === '2d' && 'Иконки и текстуры для интерфейса появятся в следующем обновлении.'}
{storeCat === 'plugins' && 'Плагины-расширения студии — в разработке (фаза T4).'}
{storeCat === 'audio' && 'Библиотека звуков и музыки — в разработке.'}
</div>
</div>
)}
{/* --- ИНВЕНТАРЬ: мои модели --- */}
{view === 'inventory' && (
<div className={cl.grid}>
{loading || myModels === null ? (
<div className={cl.empty}> Загрузка...</div> <div className={cl.empty}> Загрузка...</div>
) : !userId ? ( ) : !userId ? (
<div className={cl.empty}> <div className={cl.empty}>Войдите в аккаунт, чтобы видеть свои модели</div>
Войдите в аккаунт, чтобы видеть свои модели
</div>
) : loadError ? ( ) : loadError ? (
<div className={cl.empty}>{loadError}</div> <div className={cl.empty}>{loadError}</div>
) : mineFiltered.length === 0 ? ( ) : mineFiltered.length === 0 ? (
<div className={cl.empty}> <div className={cl.empty}>
{myModels.length === 0 {myModels.length === 0
? 'У вас пока нет своих моделей. Создайте их во вкладке «Модель» → «Воксельная» или «Гладкая».' ? 'У вас пока нет своих моделей. Создайте их в воксельном редакторе.'
: 'Ничего не найдено по фильтру'} : 'Ничего не найдено по фильтру'}
</div> </div>
) : ( ) : (
mineFiltered.map(m => ( mineFiltered.map(m => (
<UserModelCard <UserModelCard key={m.id} model={m} active={activeId === userModelKey(m)}
key={m.id} onPick={() => handlePickUserModel(m)} isMine
model={m} onEdit={onEditUserModel} onSettings={onUserModelSettings} onDelete={onDeleteUserModel} />
active={activeId === userModelKey(m)}
onPick={() => handlePickUserModel(m)}
isMine
onEdit={onEditUserModel}
onSettings={onUserModelSettings}
onDelete={onDeleteUserModel}
/>
)) ))
) )}
</div>
)} )}
{section === 'community' && ( {/* --- НЕДАВНИЕ: сообщество (популярные модели сообщества) --- */}
loading || communityModels === null ? ( {view === 'recent' && (
<div className={cl.grid}>
{communityModels === null ? (
<div className={cl.empty}> Загрузка...</div> <div className={cl.empty}> Загрузка...</div>
) : loadError ? (
<div className={cl.empty}>{loadError}</div>
) : communityFiltered.length === 0 ? ( ) : communityFiltered.length === 0 ? (
<div className={cl.empty}> <div className={cl.empty}>Пока пусто. Используй ассеты они появятся здесь.</div>
{communityModels.length === 0
? 'Пока нет опубликованных моделей сообщества. Будь первым!'
: 'Ничего не найдено по фильтру'}
</div>
) : ( ) : (
communityFiltered.map(m => ( communityFiltered.map(m => (
<UserModelCard <UserModelCard key={m.id} model={m} active={activeId === userModelKey(m)}
key={m.id}
model={m}
active={activeId === userModelKey(m)}
onPick={() => handlePickUserModel(m)} onPick={() => handlePickUserModel(m)}
isMine={userId != null && m.user_id === userId} isMine={userId != null && m.user_id === userId}
onEdit={onEditUserModel} onEdit={onEditUserModel} onSettings={onUserModelSettings} onDelete={onDeleteUserModel}
onSettings={onUserModelSettings} showSocial onLike={handleLikeModel} />
onDelete={onDeleteUserModel}
showSocial
onLike={handleLikeModel}
/>
)) ))
)
)} )}
</div> </div>
)}
{/* --- СОВЕТЫ --- */}
{view === 'tips' && (
<div className={cl.tips}>
<h3>Как пользоваться Toolbox</h3>
<ul>
<li><b>3D-объекты</b> 700+ готовых моделей: деревья, дома, мебель, персонажи. Клик объект появляется на сцене.</li>
<li><b>Готовые механики</b> вставь поведение одним кликом: бег на Shift, смена дня/ночи, сундук с лутом, счётчик монет. Скрипт прикрепляется сам.</li>
<li><b>Эффекты</b> частицы, лучи, источники света, триггер-зоны.</li>
<li><b>Инвентарь</b> твои воксельные модели, созданные в редакторе.</li>
<li>Жми на категорию, ищи через поиск, кликни ассет он добавится в проект.</li>
</ul>
<p style={{ opacity: 0.7 }}>Собери целую игру, не написав ни строчки кода просто перетаскивая готовые механики.</p>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@ -79,6 +79,7 @@
} }
.closeBtn { .closeBtn {
margin-left: auto; /* прижать крестик к правому краю шапки */
background: rgba(255, 255, 255, 0.16); background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.25); border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 10px; border-radius: 10px;
@ -447,3 +448,156 @@
font-size: 36px; font-size: 36px;
color: var(--text-dim); color: var(--text-dim);
} }
/* ====================== Roblox-style Toolbox (задача 17) ======================
Явные светлые цвета (не --text-переменные они в этой модалке не заданы и
давали тёмный текст на тёмном фоне). Крупнее шрифты. */
.topTabs {
display: flex;
gap: 4px;
padding: 0 16px;
border-bottom: 1px solid rgba(255,255,255,0.10);
flex: 0 0 auto;
}
.topTab {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 4px 12px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: #aab2c0;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: color .12s, border-color .12s;
}
.topTab:hover { color: #ffffff; }
.topTabActive {
color: #6f8bff;
border-bottom-color: #6f8bff;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 18px 6px;
flex: 0 0 auto;
}
.backBtn {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.14);
color: #e8ecf2;
padding: 7px 14px;
border-radius: 9px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.backBtn:hover { background: rgba(255,255,255,0.15); }
.crumbCurrent { font-weight: 700; font-size: 15px; color: #ffffff; }
.storeHome { overflow-y: auto; padding: 16px 20px 22px; flex: 1; }
.sectionLabel {
display: flex; align-items: center; gap: 8px;
font-weight: 700; font-size: 17px; color: #ffffff;
margin-bottom: 14px;
}
.sectionLabel svg { color: #6f8bff; }
.catGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
}
.catTile {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
column-gap: 14px;
row-gap: 4px;
align-items: center;
padding: 18px 20px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.10);
border-radius: 14px;
cursor: pointer;
text-align: left;
transition: transform .1s, background .12s, border-color .12s;
}
.catTile:hover {
background: rgba(111,139,255,0.16);
border-color: #6f8bff;
transform: translateY(-2px);
}
/* Иконка — слева, занимает обе строки (в одну линию с названием). */
.catTileIcon {
grid-row: 1 / 3;
display: flex; align-items: center; justify-content: center;
width: 52px; height: 52px;
background: rgba(111,139,255,0.16);
border-radius: 12px;
color: #8aa0ff;
}
.catTileLabel { font-weight: 800; font-size: 18px; color: #ffffff; align-self: end; }
.catTileDesc { font-size: 13px; color: #aab2c0; line-height: 1.35; align-self: start; }
.trendRow {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
.trendCard {
position: relative;
display: flex; flex-direction: column; align-items: center; gap: 10px;
padding: 14px 10px 16px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.10);
border-radius: 14px;
cursor: pointer;
transition: transform .1s, border-color .12s;
}
.trendCard:hover { transform: translateY(-2px); border-color: #6f8bff; }
.trendIcon {
width: 100%; height: 78px;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, rgba(111,139,255,0.28), rgba(54,213,122,0.18));
border-radius: 10px;
color: #ffffff;
}
.trendName { font-size: 14px; font-weight: 700; text-align: center; color: #ffffff; }
.freeBadge {
position: absolute; top: 10px; right: 10px;
font-size: 10px; font-weight: 800; letter-spacing: 0.5px;
color: #3ce087;
background: rgba(54,213,122,0.18);
padding: 3px 7px; border-radius: 6px;
}
.soon {
flex: 1;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 12px; padding: 50px;
color: #aab2c0; text-align: center;
}
.soon svg { color: #6f8bff; }
.soonTitle { font-size: 22px; font-weight: 800; color: #ffffff; }
.soonText { font-size: 15px; max-width: 400px; color: #aab2c0; line-height: 1.5; }
.tips {
overflow-y: auto; padding: 22px 28px; flex: 1;
color: #e8ecf2; line-height: 1.6;
}
.tips h3 { margin: 4px 0 16px; font-size: 21px; color: #ffffff; }
.tips ul { margin: 0 0 18px; padding-left: 22px; }
.tips li { margin-bottom: 12px; font-size: 15px; color: #d4dae4; }
.tips b { color: #8aa0ff; }
.tips p { font-size: 14px; }

View File

@ -141,7 +141,9 @@ const Dropdown = ({ trigger, children }) => {
const TABS = [ const TABS = [
{ id: 'home', label: 'Главная', iconName: 'home' }, { id: 'home', label: 'Главная', iconName: 'home' },
{ id: 'model', label: 'Модель', iconName: 'wrench' }, // Вкладка-редактор СВОИХ воксельных моделей (создание ассета).
// Каталог готовых моделей/механик теперь в Toolbox (кнопка на «Главной»).
{ id: 'model', label: 'Редактор моделей', iconName: 'wrench' },
{ id: 'test', label: 'Игра', iconName: 'gamepad' }, { id: 'test', label: 'Игра', iconName: 'gamepad' },
{ id: 'view', label: 'Вид', iconName: 'eye' }, { id: 'view', label: 'Вид', iconName: 'eye' },
]; ];
@ -231,6 +233,7 @@ const TopRibbon = (props) => {
snap, onSnapChange, snap, onSnapChange,
activeTool, onToolChange, activeTool, onToolChange,
isPlaying, onPlayToggle, onSetSpawn, isPlaying, onPlayToggle, onSetSpawn,
onSkins, onInvite, collabActive, collabPeers,
hasSelection, hasSelection,
onDuplicate, onAlignToFloor, onDelete, onDuplicate, onAlignToFloor, onDelete,
onClearScene, onClearScene,
@ -329,9 +332,9 @@ const TopRibbon = (props) => {
title="Параметрическая фигура (куб/сфера/...)" title="Параметрическая фигура (куб/сфера/...)"
/> />
<RibbonBtn <RibbonBtn
iconName="trees" label="Модель" iconName="box" label="Toolbox"
active={activeTool === 'model'} onClick={onOpenStandardModels}
onClick={() => onToolChange('model')} title="Библиотека: 3D-объекты, готовые механики, эффекты (как Creator Store)"
/> />
<RibbonBtn <RibbonBtn
iconName="image" label="Интерфейс" iconName="image" label="Интерфейс"
@ -429,6 +432,22 @@ const TopRibbon = (props) => {
onClick={onSetSpawn} onClick={onSetSpawn}
title="Поставить точку спавна там где смотрит камера" title="Поставить точку спавна там где смотрит камера"
/> />
<RibbonBtn
iconName="user-square" label="Скины"
onClick={onSkins}
title="Скины игрока: стартовый скин, магазин, рублики, свои .glb"
/>
</Group>
{/* Team Create — совместное редактирование. */}
<Group title="Вместе">
<RibbonBtn
iconName="users"
label={collabActive && collabPeers > 0 ? `Вместе (${collabPeers + 1})` : 'Пригласить'}
active={collabActive && collabPeers > 0}
onClick={onInvite}
title="Пригласить друга редактировать игру вместе (Team Create)"
/>
</Group> </Group>
{/* «Окружение» (время суток / амбиент / музыка) и {/* «Окружение» (время суток / амбиент / музыка) и

View File

@ -0,0 +1,249 @@
/**
* AchievementsManager достижения (badges) как в Roblox (задача 20).
*
* - define([...]) регистрирует достижения проекта.
* - unlock(id) разблокирует toast справа-сверху (4 редкости, очередь, звук).
* - bindToStat(id, statName, {gte/lte/eq}) авто-unlock по leaderstat.
* - кнопка-кубок слева-снизу страница «Мои достижения» (grid + прогресс).
* - сохранение разблокированных в localStorage по projectId (закрыл-открыл остались).
*
* API (через game.achievements.*): define/unlock/has/list/progress/bindToStat/
* setButtonVisible/openPage.
*
* Фича-парность: тот же модуль в rublox-player/src/engine/.
*/
const RARITY = {
common: { label: 'Обычное', border: '#9aa3b2', bg: 'linear-gradient(135deg,rgba(120,130,150,0.9),rgba(80,88,104,0.9))', glow: 'rgba(154,163,178,0.5)' },
rare: { label: 'Редкое', border: '#4d8bff', bg: 'linear-gradient(135deg,rgba(60,110,220,0.92),rgba(30,60,150,0.92))', glow: 'rgba(77,139,255,0.6)' },
epic: { label: 'Эпическое', border: '#a05aff', bg: 'linear-gradient(135deg,rgba(150,80,230,0.92),rgba(90,40,160,0.92))', glow: 'rgba(160,90,255,0.65)' },
legendary: { label: 'Легендарное', border: '#ffd23a', bg: 'linear-gradient(135deg,rgba(255,200,60,0.95),rgba(220,140,20,0.95))', glow: 'rgba(255,210,58,0.75)' },
};
export class AchievementsManager {
constructor(scene3d) {
this.s = scene3d;
this._defs = []; // [{id,name,description,icon,rarity,points,hidden}]
this._unlocked = new Set(); // id разблокированных
this._binds = []; // [{id, stat, op, value}]
this._toastQueue = [];
this._toastActive = false;
this._btnVisible = true;
this.btn = null; this.toastRoot = null; this.page = null;
this._projectKey = 'rublox_ach_' + (this.s?._projectId ?? 'proj');
}
define(list) {
const arr = Array.isArray(list) ? list : [list];
for (const a of arr) {
if (!a || typeof a.id !== 'string') continue;
if (this._defs.some(d => d.id === a.id)) continue;
this._defs.push({
id: a.id, name: a.name || a.id, description: a.description || '',
icon: a.icon || '🏆', rarity: RARITY[a.rarity] ? a.rarity : 'common',
points: Number(a.points) || 5, hidden: !!a.hidden,
});
}
this._loadSaved();
this._mountButton();
}
_loadSaved() {
// Резервная локальная копия (мгновенно, до ответа БД).
try {
const raw = localStorage.getItem(this._projectKey);
if (raw) for (const id of JSON.parse(raw)) this._unlocked.add(id);
} catch (e) { /* ignore */ }
}
/** Загрузить разблокированные достижения из БД (по игроку). Вызывать при Play. */
loadFromDB() {
const rt = this.s?.gameRuntime;
if (!rt || !rt.loadProgress) return;
rt.loadProgress('_achievements', (data) => {
if (Array.isArray(data)) {
for (const id of data) this._unlocked.add(id);
}
});
}
_persist() {
// 1) локально (быстрый кэш), 2) в БД (между сессиями, любое устройство).
try { localStorage.setItem(this._projectKey, JSON.stringify([...this._unlocked])); } catch (e) {}
try { this.s?.gameRuntime?.saveProgress?.('_achievements', [...this._unlocked]); } catch (e) {}
}
unlock(id, _playerId) {
const def = this._defs.find(d => d.id === id);
if (!def || this._unlocked.has(id)) return false;
this._unlocked.add(id);
this._persist();
this._queueToast(def);
this._playSound(def.rarity);
return true;
}
has(id) { return this._unlocked.has(id); }
list() {
return this._defs.map(d => ({ id: d.id, name: d.name, unlocked: this._unlocked.has(d.id) }));
}
progress() {
const total = this._defs.length;
const unlocked = this._defs.filter(d => this._unlocked.has(d.id)).length;
const pts = this._defs.filter(d => this._unlocked.has(d.id)).reduce((s, d) => s + d.points, 0);
const maxPts = this._defs.reduce((s, d) => s + d.points, 0);
return { total, unlocked, points: pts, maxPoints: maxPts };
}
/** Авто-unlock при достижении leaderstat значения. */
bindToStat(id, statName, cond) {
const op = cond && (cond.gte != null ? 'gte' : cond.lte != null ? 'lte' : cond.eq != null ? 'eq' : null);
if (!op) return;
this._binds.push({ id, stat: statName, op, value: cond[op] });
// Подпишемся на leaderstats при первом bind.
if (!this._boundLs && this.s?.leaderstats) {
this._boundLs = true;
this.s.leaderstats.onChange((pid, name, nv) => this._checkBinds(name, nv));
}
}
_checkBinds(statName, value) {
for (const b of this._binds) {
if (b.stat !== statName || this._unlocked.has(b.id)) continue;
const ok = b.op === 'gte' ? value >= b.value : b.op === 'lte' ? value <= b.value : value === b.value;
if (ok) this.unlock(b.id);
}
}
setButtonVisible(v) { this._btnVisible = !!v; if (this.btn) this.btn.style.display = v ? 'flex' : 'none'; }
get active() { return this._defs.length > 0; }
// ── Кнопка-кубок ───────────────────────────────────────────────────────
_mountButton() {
if (this.btn || !this.active) return;
if (!this.s?._isPlaying) return; // кнопка-кубок только в Play
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
const b = document.createElement('button');
b.title = 'Мои достижения';
b.textContent = '🏆';
b.style.cssText = [
'position:absolute', 'left:14px', 'bottom:64px', 'z-index:50',
'width:46px', 'height:46px', 'border-radius:12px', 'font-size:24px',
'background:rgba(18,22,33,0.6)', 'backdrop-filter:blur(8px)',
'border:1px solid rgba(255,255,255,0.15)', 'cursor:pointer',
'display:flex', 'align-items:center', 'justify-content:center',
'box-shadow:0 4px 16px rgba(0,0,0,0.35)', 'pointer-events:auto',
].join(';');
if (!this._btnVisible) b.style.display = 'none';
b.onclick = () => this.openPage();
parent.appendChild(b);
this.btn = b;
}
// ── Toast ────────────────────────────────────────────────────────────
_queueToast(def) { this._toastQueue.push(def); if (!this._toastActive) this._nextToast(); }
_nextToast() {
if (!this._toastQueue.length) { this._toastActive = false; return; }
this._toastActive = true;
const def = this._toastQueue.shift();
const r = RARITY[def.rarity];
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
const t = document.createElement('div');
t.style.cssText = [
'position:absolute', 'top:200px', 'right:14px', 'z-index:60',
'width:340px', 'display:flex', 'align-items:center', 'gap:12px',
'padding:12px 14px', 'border-radius:14px', 'background:' + r.bg,
'border:2px solid ' + r.border, 'box-shadow:0 0 24px ' + r.glow + ',0 8px 24px rgba(0,0,0,0.4)',
'font-family:Inter,system-ui,sans-serif', 'color:#fff',
'transform:translateX(380px)', 'transition:transform .32s cubic-bezier(.2,.8,.3,1)',
'pointer-events:auto', 'cursor:pointer',
].join(';');
t.innerHTML =
'<div style="font-size:42px;flex:0 0 auto;filter:drop-shadow(0 2px 4px rgba(0,0,0,0.4))">' + def.icon + '</div>' +
'<div style="flex:1;min-width:0">' +
'<div style="font-size:11px;opacity:0.85;font-weight:700;text-transform:uppercase;letter-spacing:0.5px">Достижение разблокировано · ' + r.label + '</div>' +
'<div style="font-size:17px;font-weight:800;margin:1px 0">' + this._esc(def.name) + '</div>' +
'<div style="font-size:12px;opacity:0.9">' + this._esc(def.description) + ' · +' + def.points + ' очк.</div>' +
'</div>';
t.onclick = () => this.openPage();
parent.appendChild(t);
// slide-in
requestAnimationFrame(() => { t.style.transform = 'translateX(0)'; });
// через 3с slide-out + следующий
setTimeout(() => {
t.style.transform = 'translateX(380px)';
setTimeout(() => { try { t.remove(); } catch (e) {} this._nextToast(); }, 350);
}, 3000);
}
_playSound(rarity) {
// Используем встроенные звуки движка через gameRuntime/audio.
try {
const map = { common: 'coin', rare: 'win', epic: 'win', legendary: 'win' };
const pitch = { common: 1, rare: 1.1, epic: 0.9, legendary: 0.8 }[rarity] || 1;
this.s?.gameRuntime?._playSound?.({ name: map[rarity] || 'coin', pitch });
} catch (e) { /* ignore */ }
}
// ── Страница «Мои достижения» ───────────────────────────────────────────
openPage() {
if (this.page) { this._closePage(); return; }
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
const overlay = document.createElement('div');
overlay.style.cssText = [
'position:absolute', 'inset:0', 'z-index:80',
'background:rgba(8,10,16,0.78)', 'backdrop-filter:blur(6px)',
'display:flex', 'align-items:center', 'justify-content:center',
'font-family:Inter,system-ui,sans-serif', 'pointer-events:auto',
].join(';');
overlay.onclick = (e) => { if (e.target === overlay) this._closePage(); };
const pr = this.progress();
const pct = pr.total ? Math.round(pr.unlocked / pr.total * 100) : 0;
const panel = document.createElement('div');
panel.style.cssText = 'width:min(720px,92%);max-height:84%;overflow-y:auto;background:#141826;border:1px solid rgba(255,255,255,0.12);border-radius:18px;padding:22px;color:#e8ecf2;box-shadow:0 20px 60px rgba(0,0,0,0.5)';
let html = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">' +
'<div style="font-size:22px;font-weight:800">🏆 Мои достижения</div>' +
'<button id="_achClose" style="width:34px;height:34px;border-radius:9px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.15);color:#fff;font-size:18px;cursor:pointer">✕</button></div>';
html += '<div style="font-size:14px;color:#9aa3b2;margin-bottom:6px">' + pr.unlocked + ' из ' + pr.total + ' (' + pr.points + ' / ' + pr.maxPoints + ' очков)</div>';
html += '<div style="height:8px;background:rgba(255,255,255,0.1);border-radius:6px;margin-bottom:18px;overflow:hidden"><div style="height:100%;width:' + pct + '%;background:linear-gradient(90deg,#ffd23a,#ff9a3a);border-radius:6px"></div></div>';
html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px">';
for (const d of this._defs) {
const un = this._unlocked.has(d.id);
const r = RARITY[d.rarity];
const hiddenLocked = d.hidden && !un;
const icon = hiddenLocked ? '❔' : d.icon;
const name = hiddenLocked ? 'Скрытое достижение' : d.name;
const desc = hiddenLocked ? 'Найди, чтобы открыть' : d.description;
html += '<div style="background:rgba(255,255,255,0.04);border:2px solid ' + (un ? r.border : 'rgba(255,255,255,0.08)') + ';border-radius:14px;padding:14px 10px;text-align:center;' + (un ? '' : 'opacity:0.55;') + '">' +
'<div style="font-size:44px;margin-bottom:6px;' + (un ? '' : 'filter:grayscale(1);') + '">' + icon + (un ? '' : ' 🔒') + '</div>' +
'<div style="font-size:14px;font-weight:800">' + this._esc(name) + '</div>' +
'<div style="font-size:11px;color:#9aa3b2;margin-top:3px;line-height:1.3">' + this._esc(desc) + '</div>' +
'<div style="font-size:10px;font-weight:700;margin-top:6px;color:' + r.border + '">' + r.label + ' · ' + d.points + ' очк.</div>' +
'</div>';
}
html += '</div>';
panel.innerHTML = html;
overlay.appendChild(panel);
parent.appendChild(overlay);
panel.querySelector('#_achClose').onclick = () => this._closePage();
this.page = overlay;
}
_closePage() { if (this.page) { try { this.page.remove(); } catch (e) {} this.page = null; } }
_esc(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
serialize() { return this._defs.map(d => ({ ...d })); }
load(arr) { if (Array.isArray(arr) && arr.length) this.define(arr); }
dispose() {
for (const el of [this.btn, this.toastRoot, this.page]) { if (el) try { el.remove(); } catch (e) {} }
this.btn = null; this.page = null; this._toastQueue = []; this._toastActive = false;
}
resetRuntime() {
// Определения и unlocked сохраняются (достижения «навсегда»). Чистим UI.
this._closePage();
this._toastQueue = []; this._toastActive = false;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,180 @@
/**
* CollabOverlay DOM-слой совместного редактирования (Team Create).
*
* Рисует поверх canvas студии:
* - панель «онлайн» (аватарки-кружки соавторов с цветом и ником);
* - 3D-курсоры соавторов (точка с ником, спроецированная из мировых
* координат в экранные);
* - подсветку чужого выделения (по selectedKey обводим объект);
* - тосты («объект занят другим», «N присоединился»).
*
* Самодостаточный (как LoadingScreenOverlay): монтируется на parent canvas,
* UI обновляется методами update(...). Кнопка «Пригласить» снаружи (TopRibbon),
* здесь только presence + курсоры.
*/
import { Vector3, Matrix } from '@babylonjs/core';
let _cssInjected = false;
function injectCss() {
if (_cssInjected || typeof document === 'undefined') return;
_cssInjected = true;
const s = document.createElement('style');
s.id = 'kbn-collab-css';
s.textContent =
'.kbnCollabCursor{position:absolute;transform:translate(-50%,-50%);pointer-events:none;z-index:62;transition:left 0.08s linear,top 0.08s linear;will-change:left,top}' +
'.kbnCollabDot{width:14px;height:14px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.5)}' +
'.kbnCollabName{position:absolute;left:16px;top:-2px;white-space:nowrap;font:600 11px system-ui;color:#fff;padding:1px 6px;border-radius:6px;text-shadow:0 1px 2px rgba(0,0,0,.6)}' +
'.kbnCollabBar{position:absolute;top:10px;left:50%;transform:translateX(-50%);z-index:63;display:flex;gap:6px;align-items:center;background:rgba(20,24,38,.78);padding:5px 10px;border-radius:20px;backdrop-filter:blur(6px);font:600 12px system-ui;color:#cdd6e6;pointer-events:auto}' +
'.kbnCollabAva{width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font:700 12px system-ui;color:#10131c;border:2px solid rgba(255,255,255,.25)}' +
'.kbnCollabToast{position:absolute;bottom:80px;left:50%;transform:translateX(-50%);z-index:64;background:rgba(20,24,38,.92);color:#fff;padding:10px 18px;border-radius:10px;font:600 14px system-ui;box-shadow:0 8px 24px rgba(0,0,0,.5);opacity:0;transition:opacity .25s}';
document.head.appendChild(s);
}
export class CollabOverlay {
constructor(scene) {
this.scene = scene;
this.root = null;
this.barEl = null;
this.cursorsEl = null;
this.toastEl = null;
this._cursors = new Map(); // sessionId → {wrap, dot, name}
this._presence = [];
this._raf = null;
}
mount() {
injectCss();
const parent = (this.scene.canvas && this.scene.canvas.parentElement) || document.body;
try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch (e) { /* ignore */ }
const root = document.createElement('div');
root.className = 'kbn-collab-root';
root.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:60;overflow:hidden';
const bar = document.createElement('div');
bar.className = 'kbnCollabBar';
bar.innerHTML = '<span style="opacity:.8">👥 Соавторы:</span>';
const cursors = document.createElement('div');
cursors.style.cssText = 'position:absolute;inset:0';
const toast = document.createElement('div');
toast.className = 'kbnCollabToast';
root.appendChild(bar);
root.appendChild(cursors);
root.appendChild(toast);
parent.appendChild(root);
this.root = root; this.barEl = bar; this.cursorsEl = cursors; this.toastEl = toast;
this._startLoop();
}
/** Обновить список соавторов (из StudioCollab.onPresenceChange). */
updatePresence(list) {
this._presence = list || [];
if (!this.barEl) return;
// Перерисовать аватарки.
const others = this._presence;
let html = '<span style="opacity:.8">👥</span>';
for (const c of others) {
const initial = (c.username || '?').trim().charAt(0).toUpperCase() || '?';
const ring = c.me ? 'box-shadow:0 0 0 2px #fff' : '';
html += `<div class="kbnCollabAva" title="${escapeHtml(c.username)}${c.isHost ? ' (хост)' : ''}${c.me ? ' — вы' : ''}" style="background:${c.color};${ring}">${escapeHtml(initial)}</div>`;
}
html += `<span style="opacity:.7;margin-left:4px">${others.length}</span>`;
this.barEl.innerHTML = html;
// Курсоры пересоберём в loop.
this._syncCursorEls();
}
_syncCursorEls() {
const present = new Set(this._presence.filter(c => !c.me).map(c => c.sessionId));
// удалить ушедших
for (const [sid, el] of this._cursors) {
if (!present.has(sid)) { try { el.wrap.remove(); } catch (e) { /* ignore */ } this._cursors.delete(sid); }
}
// добавить новых
for (const c of this._presence) {
if (c.me) continue;
if (!this._cursors.has(c.sessionId)) {
const wrap = document.createElement('div');
wrap.className = 'kbnCollabCursor';
const dot = document.createElement('div');
dot.className = 'kbnCollabDot';
const name = document.createElement('div');
name.className = 'kbnCollabName';
wrap.appendChild(dot); wrap.appendChild(name);
this.cursorsEl.appendChild(wrap);
this._cursors.set(c.sessionId, { wrap, dot, name });
}
const el = this._cursors.get(c.sessionId);
el.dot.style.background = c.color;
el.name.style.background = c.color;
el.name.textContent = c.username;
}
}
/** Каждый кадр проецируем 3D-курсоры соавторов в экранные координаты. */
_startLoop() {
const tick = () => {
this._raf = requestAnimationFrame(tick);
if (!this.cursorsEl || !this._presence.length) return;
const scene = this.scene.scene;
const cam = this.scene.camera;
const eng = this.scene.engine;
if (!scene || !cam || !eng) return;
for (const c of this._presence) {
if (c.me) continue;
const el = this._cursors.get(c.sessionId);
if (!el) continue;
const cur = c.cursor;
if (!cur || (cur.x === 0 && cur.y === 0 && cur.z === 0)) { el.wrap.style.display = 'none'; continue; }
const sp = this._project(cur.x, cur.y, cur.z, scene, cam, eng);
if (!sp) { el.wrap.style.display = 'none'; continue; }
el.wrap.style.display = 'block';
el.wrap.style.left = sp.x + 'px';
el.wrap.style.top = sp.y + 'px';
}
};
this._raf = requestAnimationFrame(tick);
}
/** Спроецировать мировую точку в экранные координаты canvas. */
_project(x, y, z, scene, cam, eng) {
try {
const w = eng.getRenderWidth();
const h = eng.getRenderHeight();
const p = Vector3.Project(
new Vector3(x, y, z),
Matrix.Identity(),
scene.getTransformMatrix(),
cam.viewport.toGlobal(w, h)
);
if (p.z < 0 || p.z > 1) return null; // за камерой
// переводим из render-пикселей в css-пиксели canvas
const canvas = eng.getRenderingCanvas();
const rect = canvas.getBoundingClientRect();
const sx = rect.width / w, sy = rect.height / h;
return { x: p.x * sx, y: p.y * sy };
} catch (e) { return null; }
}
toast(text) {
if (!this.toastEl) return;
this.toastEl.textContent = text;
this.toastEl.style.opacity = '1';
clearTimeout(this._toastT);
this._toastT = setTimeout(() => { if (this.toastEl) this.toastEl.style.opacity = '0'; }, 2600);
}
dispose() {
if (this._raf) cancelAnimationFrame(this._raf);
try { this.root?.remove(); } catch (e) { /* ignore */ }
this.root = null; this._cursors.clear();
}
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m]));
}

View File

@ -91,10 +91,15 @@ export class Environment {
this.fogEnabled = false; this.fogEnabled = false;
this.fogColor = [0.7, 0.8, 0.9]; this.fogColor = [0.7, 0.8, 0.9];
this.fogDensity = 0.01; this.fogDensity = 0.01;
// Видимые тела на небе (солнце и луна) — создаём по запросу // Видимые тела на небе (солнце и луна).
// ВАЖНО (задача 16): единое небо рисует SkyboxManager (купол + солнечный
// диск + облака). Environment больше НЕ рисует свою жёлтую сферу/луну/фон —
// иначе на небе два солнца. Environment теперь отвечает ТОЛЬКО за свет
// (направление/яркость солнца, ambient). Флаг ниже отключает небесные тела.
this._drawSkyBodies = false;
this._sunMesh = null; this._sunMesh = null;
this._moonMesh = null; this._moonMesh = null;
this._createSkyBodies(); if (this._drawSkyBodies) this._createSkyBodies();
this._applyTime(); this._applyTime();
} }

View File

@ -0,0 +1,236 @@
/**
* FloaterManager всплывающие цифры урона (Damage Floaters), задача 40.
*
* game.fx.damageFloater(position, value, opts) над точкой всплывает число,
* поднимается вверх, покачивается, плавно гаснет. Цвета: damage/crit/heal/
* mana/miss. Object pool из переиспользуемых billboard-планов (без create/
* destroy на каждый удар). Стек одинаковых по stackKey («×N»). Комикс-стиль
* (BAM!/KAPOW!/POW!).
*
* Билборд = плоскость с DynamicTexture (как LabelManager), billboardMode=7,
* renderingGroupId=1 (всегда поверх геометрии), disableDepthWrite.
*
* Фича-парность: тот же модуль в rublox-player/src/engine/.
*/
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
import { Color3 } from '@babylonjs/core/Maths/math.color';
import { Mesh } from '@babylonjs/core/Meshes/mesh';
const POOL_SIZE = 30;
const TEX_W = 512, TEX_H = 256;
// Пресеты типов урона: цвет текста + множители.
const PRESETS = {
damage: { color: '#ff5a4a', stroke: '#3a0000' },
crit: { color: '#ffd23a', stroke: '#5a3a00' },
heal: { color: '#46e06a', stroke: '#063a14' },
mana: { color: '#4aa8ff', stroke: '#001a3a' },
miss: { color: '#b8b8b8', stroke: '#222222' },
};
function easeOutQuad(t) { return 1 - (1 - t) * (1 - t); }
export class FloaterManager {
constructor(scene3d) {
this.s = scene3d;
this.scene = scene3d.scene;
this.pool = [];
this._initialized = false;
this._stacks = new Map(); // stackKey → slot (для накопления ×N)
}
_init() {
if (this._initialized) return;
this._initialized = true;
for (let i = 0; i < POOL_SIZE; i++) {
const tex = new DynamicTexture(`floaterTex_${i}`, { width: TEX_W, height: TEX_H }, this.scene, true);
tex.hasAlpha = true;
const plane = MeshBuilder.CreatePlane(`floater_${i}`, { width: 2.4, height: 1.2, sideOrientation: Mesh.DOUBLESIDE }, this.scene);
const mat = new StandardMaterial(`floaterMat_${i}`, this.scene);
mat.diffuseTexture = tex;
mat.diffuseTexture.hasAlpha = true;
mat.emissiveColor = new Color3(1, 1, 1);
mat.diffuseColor = new Color3(0, 0, 0);
mat.disableLighting = true;
mat.backFaceCulling = false;
mat.disableDepthWrite = true;
mat.useAlphaFromDiffuseTexture = true;
plane.material = mat;
plane.billboardMode = 7;
plane.renderingGroupId = 1;
plane.isPickable = false;
plane.setEnabled(false);
this.pool.push({ plane, tex, mat, active: false, age: 0, lifetime: 0.8 });
}
}
_acquire() {
for (const slot of this.pool) if (!slot.active) return slot;
return null; // все заняты — пропускаем новый floater (норма)
}
/**
* Главный API. position: {x,y,z}; value: число|строка; opts см. задачу 40.
*/
spawn(position, value, opts = {}) {
this._init();
if (!position) return;
opts = opts || {};
// Стек: одинаковый stackKey за время жизни накапливает счётчик.
if (opts.stackKey && this._stacks.has(opts.stackKey)) {
const slot = this._stacks.get(opts.stackKey);
if (slot.active) {
slot.stackCount = (slot.stackCount || 1) + 1;
slot.age = Math.min(slot.age, slot.lifetime * 0.3); // продлеваем
this._draw(slot, slot.baseText, slot.preset, slot.fontSize, slot.comic, slot.stackCount);
return;
}
}
const slot = this._acquire();
if (!slot) return;
// Тип floater'а.
let kind = 'damage';
if (opts.isCrit) kind = 'crit';
else if (opts.isHeal) kind = 'heal';
else if (opts.isMana) kind = 'mana';
else if (opts.isMiss) kind = 'miss';
const preset = PRESETS[kind];
const color = opts.color || preset.color;
const stroke = opts.strokeColor || preset.stroke;
let fontSize = Number.isFinite(opts.fontSize) ? opts.fontSize : 60;
let floatHeight = Number.isFinite(opts.floatHeight) ? opts.floatHeight : 2;
let lifetime = Number.isFinite(opts.lifetime) ? opts.lifetime : 0.9;
const randomOffset = Number.isFinite(opts.randomOffset) ? opts.randomOffset : (opts.isCrit ? 0.5 : 0.25);
// Текст: число с минусом (урон) или как есть (строка / heal с плюсом).
let baseText;
if (typeof value === 'string') baseText = value;
else if (opts.isHeal) baseText = '+' + value;
else if (opts.isMiss) baseText = String(value);
else baseText = '-' + Math.abs(value);
if (opts.isCrit) { fontSize = Math.round(fontSize * 1.4); floatHeight *= 1.2; }
slot.active = true;
slot.age = 0;
slot.lifetime = lifetime;
slot.floatHeight = floatHeight;
slot.isCrit = !!opts.isCrit;
slot.color = color; slot.stroke = stroke;
slot.preset = { color, stroke };
slot.fontSize = fontSize;
slot.comic = !!opts.comicStyle;
slot.baseText = baseText;
slot.stackCount = 1;
slot.stackKey = opts.stackKey || null;
const rx = (Math.random() - 0.5) * 2 * randomOffset;
const rz = (Math.random() - 0.5) * 2 * randomOffset;
slot.startX = position.x + rx;
slot.startY = position.y + (Number.isFinite(opts.yOffset) ? opts.yOffset : 1.5);
slot.startZ = position.z + rz;
slot.plane.position.set(slot.startX, slot.startY, slot.startZ);
slot.plane.scaling.set(1, 1, 1);
slot.plane.setEnabled(true);
this._draw(slot, baseText, slot.preset, fontSize, slot.comic, 1);
if (opts.stackKey) this._stacks.set(opts.stackKey, slot);
}
_draw(slot, baseText, preset, fontSize, comic, stackCount) {
const ctx = slot.tex.getContext();
ctx.clearRect(0, 0, TEX_W, TEX_H);
let text = baseText;
if (comic) {
const num = parseInt(String(baseText).replace(/[^0-9]/g, ''), 10) || 0;
if (slot.isCrit) text = 'POW!';
else if (num > 100) text = 'KAPOW!';
else if (num > 50) text = 'BAM!';
}
if (stackCount > 1) text = baseText + ' ×' + stackCount;
const fs = comic ? Math.round(fontSize * 1.1) : fontSize;
ctx.font = `900 ${fs}px ${comic ? 'Bangers, Impact, sans-serif' : 'Inter, Arial, sans-serif'}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.lineJoin = 'round';
// Комикс-фон: жёлтая звезда-вспышка.
if (comic) {
ctx.save();
ctx.translate(TEX_W / 2, TEX_H / 2);
ctx.fillStyle = 'rgba(255,210,60,0.9)';
ctx.beginPath();
const spikes = 10, outer = 130, inner = 70;
for (let i = 0; i < spikes * 2; i++) {
const r = i % 2 === 0 ? outer : inner;
const a = (i / (spikes * 2)) * Math.PI * 2 - Math.PI / 2;
const px = Math.cos(a) * r, py = Math.sin(a) * r * 0.55;
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
}
ctx.closePath(); ctx.fill();
ctx.restore();
}
// Обводка + текст.
ctx.strokeStyle = comic ? '#000' : preset.stroke;
ctx.lineWidth = Math.max(6, fs * 0.16);
ctx.strokeText(text, TEX_W / 2, TEX_H / 2);
ctx.fillStyle = comic ? '#d22' : preset.color;
ctx.fillText(text, TEX_W / 2, TEX_H / 2);
slot.tex.update(true);
}
/** Вызывать каждый кадр (анимация подъёма + fade + покачивание + crit-pop). */
tick(dt) {
if (!this._initialized) return;
for (const slot of this.pool) {
if (!slot.active) continue;
slot.age += dt;
const t = slot.age / slot.lifetime;
if (t >= 1) {
slot.active = false;
slot.plane.setEnabled(false);
if (slot.stackKey && this._stacks.get(slot.stackKey) === slot) this._stacks.delete(slot.stackKey);
continue;
}
const ease = easeOutQuad(t);
slot.plane.position.y = slot.startY + slot.floatHeight * ease;
slot.plane.position.x = slot.startX + Math.sin(slot.age * 5) * 0.12;
// fade-in 0.12 / hold / fade-out 0.25
let alpha = 1;
if (t < 0.12) alpha = t / 0.12;
else if (t > 0.75) alpha = 1 - (t - 0.75) / 0.25;
slot.mat.alpha = Math.max(0, Math.min(1, alpha));
// crit pop: scale 1 → 1.3 → 1 в первые 0.4 жизни
if (slot.isCrit) {
let s = 1;
if (t < 0.2) s = 1 + (t / 0.2) * 0.3;
else if (t < 0.4) s = 1.3 - ((t - 0.2) / 0.2) * 0.3;
slot.plane.scaling.set(s, s, s);
}
}
}
dispose() {
for (const slot of this.pool) {
try { slot.plane.dispose(); slot.tex.dispose(); slot.mat.dispose(); } catch (e) {}
}
this.pool = []; this._stacks.clear(); this._initialized = false;
}
resetRuntime() {
for (const slot of this.pool) { slot.active = false; slot.plane?.setEnabled(false); }
this._stacks.clear();
}
}

View File

@ -215,22 +215,21 @@ export class FolderManager {
* Возвращает количество повёрнутых примитивов. * Возвращает количество повёрнутых примитивов.
*/ */
rotateFolderY(folderId, angle, pivot) { rotateFolderY(folderId, angle, pivot) {
if (!this.primitiveManager || !pivot) return 0; if (!pivot) return 0;
const cosA = Math.cos(angle); const cosA = Math.cos(angle);
const sinA = Math.sin(angle); const sinA = Math.sin(angle);
let count = 0; let count = 0;
// Примитивы папки.
if (this.primitiveManager) {
for (const data of this.primitiveManager.instances.values()) { for (const data of this.primitiveManager.instances.values()) {
if (data.folderId !== folderId) continue; if (data.folderId !== folderId) continue;
// Поворачиваем позицию вокруг pivot.y axis (XZ-плоскость)
const dx = data.x - pivot.x; const dx = data.x - pivot.x;
const dz = data.z - pivot.z; const dz = data.z - pivot.z;
const newX = pivot.x + dx * cosA - dz * sinA; data.x = pivot.x + dx * cosA - dz * sinA;
const newZ = pivot.z + dx * sinA + dz * cosA; data.z = pivot.z + dx * sinA + dz * cosA;
data.x = newX;
data.z = newZ;
data.rotationY = (data.rotationY || 0) + angle; data.rotationY = (data.rotationY || 0) + angle;
if (data.mesh) { if (data.mesh) {
data.mesh.position.set(newX, data.y, newZ); data.mesh.position.set(data.x, data.y, data.z);
data.mesh.rotation.y = data.rotationY; data.mesh.rotation.y = data.rotationY;
if (data._worldMatrixFrozen) { if (data._worldMatrixFrozen) {
try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {}
@ -239,6 +238,26 @@ export class FolderManager {
} }
count++; count++;
} }
}
// Модели папки (позиция вокруг pivot + собственный поворот).
if (this.modelManager) {
const Vec = this.modelManager._Vector3 || null;
for (const data of this.modelManager.instances.values()) {
if (data.folderId !== folderId) continue;
const dx = data.x - pivot.x;
const dz = data.z - pivot.z;
data.x = pivot.x + dx * cosA - dz * sinA;
data.z = pivot.z + dx * sinA + dz * cosA;
data.rotationY = (data.rotationY || 0) + angle;
const root = data.rootMesh || data.rootNode;
if (root) {
if (data._worldMatrixFrozen) { try { root.unfreezeWorldMatrix?.(); } catch (e) {} data._worldMatrixFrozen = false; }
root.position.set(data.x, data.y, data.z);
if (root.rotation) root.rotation.y = data.rotationY;
}
count++;
}
}
this._notifyChange(); this._notifyChange();
return count; return count;
} }
@ -259,6 +278,99 @@ export class FolderManager {
return count; return count;
} }
/**
* Собрать все объекты папки (рекурсивно по подпапкам) с их мешами.
* Возвращает { models:[{data}], primitives:[{data}], blocks:[mesh],
* meshes:[meshes для подсветки], center:{x,y,z}, count }.
*/
getFolderObjects(folderId) {
const out = { models: [], primitives: [], blocks: [], meshes: [] };
const ids = new Set([folderId]);
// Собираем id всех вложенных подпапок.
let added = true;
while (added) {
added = false;
for (const f of this.getAll()) {
if (f.parentId != null && ids.has(f.parentId) && !ids.has(f.id)) {
ids.add(f.id); added = true;
}
}
}
if (this.modelManager) {
for (const d of this.modelManager.instances.values()) {
if (ids.has(d.folderId)) {
out.models.push(d);
const root = d.rootMesh || d.rootNode;
if (root) out.meshes.push(root);
}
}
}
if (this.primitiveManager) {
for (const d of this.primitiveManager.instances.values()) {
if (ids.has(d.folderId)) {
out.primitives.push(d);
if (d.mesh) out.meshes.push(d.mesh);
}
}
}
if (this.blockManager) {
for (const mesh of this.blockManager.blocks.values()) {
if (ids.has(mesh.metadata?.folderId)) out.blocks.push(mesh);
}
}
// Центр группы (по позициям моделей/примитивов).
let sx = 0, sy = 0, sz = 0, n = 0;
for (const d of out.models) { sx += d.x || 0; sy += d.y || 0; sz += d.z || 0; n++; }
for (const d of out.primitives) { sx += d.x || 0; sy += d.y || 0; sz += d.z || 0; n++; }
out.center = n > 0 ? { x: sx / n, y: sy / n, z: sz / n } : { x: 0, y: 0, z: 0 };
out.count = out.models.length + out.primitives.length + out.blocks.length;
return out;
}
/** Сдвинуть все объекты папки на (dx,dy,dz). */
moveFolderBy(folderId, dx, dy, dz) {
const g = this.getFolderObjects(folderId);
const apply = (d, mesh) => {
d.x = (d.x || 0) + dx; d.y = (d.y || 0) + dy; d.z = (d.z || 0) + dz;
if (mesh) {
if (d._worldMatrixFrozen) { try { mesh.unfreezeWorldMatrix?.(); } catch (e) {} d._worldMatrixFrozen = false; }
mesh.position.set(d.x, d.y, d.z);
}
};
for (const d of g.models) apply(d, d.rootMesh || d.rootNode);
for (const d of g.primitives) apply(d, d.mesh);
this._notifyChange();
}
/**
* Масштабировать папку относительно центра pivot на коэффициент factor.
* Позиции расходятся/сходятся от центра + размеры примитивов меняются.
*/
scaleFolder(folderId, factor, pivot) {
if (!Number.isFinite(factor) || factor <= 0) return;
const g = this.getFolderObjects(folderId);
const p = pivot || g.center;
const sc = (d, mesh, isPrim) => {
d.x = p.x + ((d.x || 0) - p.x) * factor;
d.y = p.y + ((d.y || 0) - p.y) * factor;
d.z = p.z + ((d.z || 0) - p.z) * factor;
if (isPrim) {
d.sx = (d.sx || 1) * factor; d.sy = (d.sy || 1) * factor; d.sz = (d.sz || 1) * factor;
} else {
d.scale = (d.scale || 1) * factor;
}
if (mesh) {
if (d._worldMatrixFrozen) { try { mesh.unfreezeWorldMatrix?.(); } catch (e) {} d._worldMatrixFrozen = false; }
mesh.position.set(d.x, d.y, d.z);
if (isPrim && mesh.scaling) mesh.scaling.set(d.sx, d.sy, d.sz);
else if (mesh.scaling) mesh.scaling.scaleInPlace(factor);
}
};
for (const d of g.models) sc(d, d.rootMesh || d.rootNode, false);
for (const d of g.primitives) sc(d, d.mesh, true);
this._notifyChange();
}
/** Найти папку по имени (regex/exact). */ /** Найти папку по имени (regex/exact). */
findByName(name) { findByName(name) {
const n = String(name || '').toLowerCase(); const n = String(name || '').toLowerCase();

View File

@ -68,6 +68,22 @@ export class GameRuntime {
start(scripts) { start(scripts) {
this.stop(); this.stop();
this._isRunning = true; this._isRunning = true;
this.scripts = scripts || []; // для привязки логов/ошибок к скрипту
// Задача 20: мост leaderstats.onChange (main) → globalEvent в worker'ы,
// чтобы скриптовые game.leaderstats.onChange и bindToStat срабатывали.
try {
const ls = this.scene3d?.leaderstats;
if (ls && !ls._bridgeBound) {
ls._bridgeBound = true;
const meId = ls._resolveMe?.();
ls.onChange((pid, name, nv, ov) => {
for (const sb of this.sandboxes) sb.sendGlobalEvent({
type: 'leaderstatsChange', playerId: pid, name, newValue: nv, oldValue: ov,
isMe: String(pid) === String(meId),
});
});
}
} catch (e) { /* ignore */ }
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('[GameRuntime] start called with scripts:', scripts); console.log('[GameRuntime] start called with scripts:', scripts);
// Phase 6.5: lazy-init физ-мира. Wasm-инициализация асинхронная (~50мс), // Phase 6.5: lazy-init физ-мира. Wasm-инициализация асинхронная (~50мс),
@ -1310,6 +1326,8 @@ export class GameRuntime {
ls.setBridge( ls.setBridge(
(id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingSkip', loadingId: id }); }, (id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingSkip', loadingId: id }); },
(id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingComplete', loadingId: id }); }, (id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingComplete', loadingId: id }); },
// Задача 05: onHide — экран скрылся (любой).
() => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingHidden' }); },
); );
this.scene3d.loadingScreen = ls; this.scene3d.loadingScreen = ls;
} }
@ -1548,7 +1566,13 @@ export class GameRuntime {
/** Команда от Worker'а пришла — применяем на сцене. */ /** Команда от Worker'а пришла — применяем на сцене. */
_handleCommand(scriptId, cmd, payload) { _handleCommand(scriptId, cmd, payload) {
if (cmd === 'log') { if (cmd === 'log') {
this._log(payload?.level || 'info', payload?.text || ''); // Привязываем запись к скрипту-источнику (для ссылки в консоли).
let scriptName = null;
try {
const meta = (this.scripts || []).find(s => s.id === scriptId);
scriptName = meta?.name || scriptId;
} catch (e) { scriptName = scriptId; }
this._log(payload?.level || 'info', payload?.text || '', scriptId, scriptName);
return; return;
} }
if (cmd === 'player.teleport') { if (cmd === 'player.teleport') {
@ -1724,6 +1748,11 @@ export class GameRuntime {
}); });
return; return;
} }
if (cmd === 'npc.setAttacking') {
this._npcCmd(payload?.ref, (nid) =>
this.scene3d?.npcManager?.setAttacking?.(nid, !!payload?.on));
return;
}
if (cmd === 'npc.stop') { if (cmd === 'npc.stop') {
this._npcCmd(payload?.ref, (nid) => this._npcCmd(payload?.ref, (nid) =>
this.scene3d?.npcManager?.stopNpc(nid)); this.scene3d?.npcManager?.stopNpc(nid));
@ -1852,9 +1881,9 @@ export class GameRuntime {
const id = ls.show(payload.opts || {}); const id = ls.show(payload.opts || {});
// Вернуть worker'у real loadingId, чтобы хэндл (setProgress/close/колбэки) // Вернуть worker'у real loadingId, чтобы хэндл (setProgress/close/колбэки)
// нашёл нужный экран по replyId → local→real маппингу. // нашёл нужный экран по replyId → local→real маппингу.
if (payload.replyId != null) { // replyId может отсутствовать (стартовый экран) — всё равно шлём
// loadingShown для game.loading.isVisible() (задача 05).
for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingShown', replyId: payload.replyId, loadingId: id }); for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingShown', replyId: payload.replyId, loadingId: id });
}
} catch (e) { this._log('error', 'loading.show: ' + (e?.message || e)); } } catch (e) { this._log('error', 'loading.show: ' + (e?.message || e)); }
} }
return; return;
@ -1862,8 +1891,38 @@ export class GameRuntime {
if (cmd === 'loading.setProgress') { this.scene3d?.loadingScreen?.setProgress(payload?.value); return; } if (cmd === 'loading.setProgress') { this.scene3d?.loadingScreen?.setProgress(payload?.value); return; }
if (cmd === 'loading.setText') { this.scene3d?.loadingScreen?.setText(payload?.text); return; } if (cmd === 'loading.setText') { this.scene3d?.loadingScreen?.setText(payload?.text); return; }
if (cmd === 'loading.setCover') { this.scene3d?.loadingScreen?.setCover(payload?.cover); return; } if (cmd === 'loading.setCover') { this.scene3d?.loadingScreen?.setCover(payload?.cover); return; }
if (cmd === 'loading.setBackground') { this.scene3d?.loadingScreen?.setBackground?.(payload?.background); return; }
if (cmd === 'loading.close') { this.scene3d?.loadingScreen?.close(); return; } if (cmd === 'loading.close') { this.scene3d?.loadingScreen?.close(); return; }
// === Damage Floaters (задача 40) — всплывающие цифры урона ===
if (cmd === 'fx.damageFloater') {
try {
let pos = payload?.position;
// ref-строка ('player'|'primitive:N'|'model:N') → координаты объекта.
if (typeof pos === 'string') {
if (pos === 'player') {
const pl = this.scene3d?.player;
const p = pl ? (pl._pos || pl.position || pl.mesh?.position) : null;
pos = p ? { x: p.x, y: p.y, z: p.z } : null;
} else {
const tgt = this._resolveTweenTarget(pos);
pos = tgt ? { x: tgt.data.x || 0, y: tgt.data.y || 0, z: tgt.data.z || 0 } : null;
}
}
if (pos) this.scene3d?.floaters?.spawn(pos, payload?.value, payload?.opts || {});
} catch (e) { /* ignore */ }
return;
}
if (cmd === 'fx.autoMobFloaters') {
try {
if (this.scene3d?.npcManager) {
this.scene3d.npcManager._autoFloater = payload?.enabled
? { opts: payload?.opts || {} } : null;
}
} catch (e) { /* ignore */ }
return;
}
// === Beam / Trail — лучи и следы (Фаза 5.2) === // === Beam / Trail — лучи и следы (Фаза 5.2) ===
if (cmd === 'fx.create') { if (cmd === 'fx.create') {
// payload: { kind: 'beam'|'trail', localRef, ... } // payload: { kind: 'beam'|'trail', localRef, ... }
@ -2031,6 +2090,19 @@ export class GameRuntime {
} }
return; return;
} }
// === Задача 44: drag-drop инвентарь (invUI) ===
if (cmd === 'items.define') { try { this.scene3d?.invUI?.defineItem(payload.def); } catch (e) {} return; }
if (cmd === 'inv2.add') {
try { this.scene3d?.invUI?.add(payload.itemId, payload.count); this.scene3d?.invUI?.mountHotbar(); } catch (e) {}
return;
}
if (cmd === 'inv2.remove') { try { this.scene3d?.invUI?.remove(payload.itemId, payload.count); } catch (e) {} return; }
if (cmd === 'inv2.open') { try { this.scene3d?.invUI?.open(); } catch (e) {} return; }
if (cmd === 'inv2.close') { try { this.scene3d?.invUI?.close(); } catch (e) {} return; }
if (cmd === 'inv2.toggle') { try { this.scene3d?.invUI?.toggle(); } catch (e) {} return; }
if (cmd === 'inv2.sort') { try { this.scene3d?.invUI?.sort(payload.by); } catch (e) {} return; }
if (cmd === 'inv2.setActive') { try { this.scene3d?.invUI?.setActiveHotbar(payload.i); } catch (e) {} return; }
if (cmd === 'inventory.remove') { if (cmd === 'inventory.remove') {
// payload: { modelTypeId? , name? } — убрать первый совпавший слот. // payload: { modelTypeId? , name? } — убрать первый совпавший слот.
const inv = this.scene3d?.inventory; const inv = this.scene3d?.inventory;
@ -2882,13 +2954,85 @@ export class GameRuntime {
} }
return; return;
} }
// === Небо и атмосфера (задача 16) ===
// === Лидерборды и достижения (задача 20) ===
if (cmd === 'leaderstats.define') {
try { this.scene3d?.leaderstats?.define(payload.name, payload.opts || {}); } catch (e) {}
return;
}
if (cmd === 'leaderstats.set') {
try { this.scene3d?.leaderstats?.set(payload.playerId, payload.name, payload.value); } catch (e) {}
return;
}
if (cmd === 'leaderstats.add') {
try { this.scene3d?.leaderstats?.add(payload.playerId, payload.name, payload.delta); } catch (e) {}
return;
}
if (cmd === 'achievements.define') {
try { this.scene3d?.achievements?.define(payload.list); } catch (e) {}
return;
}
if (cmd === 'achievements.unlock') {
try { this.scene3d?.achievements?.unlock(payload.id, payload.playerId); } catch (e) {}
return;
}
if (cmd === 'achievements.bindToStat') {
try { this.scene3d?.achievements?.bindToStat(payload.id, payload.statName, payload.cond || {}); } catch (e) {}
return;
}
if (cmd === 'achievements.setButtonVisible') {
try { this.scene3d?.achievements?.setButtonVisible(!!payload.visible); } catch (e) {}
return;
}
if (cmd === 'achievements.openPage') {
try { this.scene3d?.achievements?.openPage(); } catch (e) {}
return;
}
if (cmd === 'scene.setSkybox') {
try { this.scene3d?.skybox?.setSkybox(payload?.opts || {}); } catch (e) {}
return;
}
if (cmd === 'scene.setClouds') {
try { this.scene3d?.skybox?.setClouds(payload?.opts || {}); } catch (e) {}
return;
}
if (cmd === 'scene.setFog') {
try { this.scene3d?.skybox?.setFog(payload?.opts || {}); } catch (e) {}
return;
}
if (cmd === 'scene.skyboxFadeTo') {
try { this.scene3d?.skybox?.fadeTo(payload?.opts || {}, payload?.duration || 2); } catch (e) {}
return;
}
if (cmd === 'scene.skyboxSunDir') {
try { this.scene3d?.skybox?.setSunDirection(payload?.dir || {}); } catch (e) {}
return;
}
if (cmd === 'scene.setScale') {
try {
const k = Number(payload?.scale);
if (!Number.isFinite(k) || k < 0) return;
const pm = this.scene3d?.primitiveManager;
const rid = this._resolvePrimitiveId(payload?.id ?? payload?.ref);
const data = (pm && rid != null) ? pm.instances.get(rid) : null;
if (data?.mesh) {
if (data._worldMatrixFrozen) { try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} data._worldMatrixFrozen = false; }
data.mesh.scaling.set(k, k, k); // визуальный масштаб от исходного размера
}
} catch (e) {}
return;
}
if (cmd === 'scene.setColor') { if (cmd === 'scene.setColor') {
try { try {
const color = payload?.color; const color = payload?.color;
if (typeof color !== 'string') return; if (typeof color !== 'string') return;
// Окрашиваемый блок (studs-block): ref вида 'block:x,y,z' → // Окрашиваемый блок (studs-block): ref вида 'block:x,y,z' →
// меняем per-instance цвет через BlockManager.setBlockColor. // меняем per-instance цвет через BlockManager.setBlockColor.
const ref = payload?.id; // ВАЖНО: obj.color=hex шлёт {ref}, а self.setColor — {id}. Берём оба.
const ref = payload?.id ?? payload?.ref;
if (typeof ref === 'string' && ref.startsWith('block:')) { if (typeof ref === 'string' && ref.startsWith('block:')) {
const parts = ref.slice(6).split(',').map(Number); const parts = ref.slice(6).split(',').map(Number);
if (parts.length === 3 && parts.every(Number.isFinite)) { if (parts.length === 3 && parts.every(Number.isFinite)) {
@ -2898,7 +3042,7 @@ export class GameRuntime {
} }
const pm = this.scene3d?.primitiveManager; const pm = this.scene3d?.primitiveManager;
if (!pm) return; if (!pm) return;
const rid = this._resolvePrimitiveId(payload?.id); const rid = this._resolvePrimitiveId(payload?.id ?? payload?.ref);
const data = rid != null ? pm.instances.get(rid) : null; const data = rid != null ? pm.instances.get(rid) : null;
if (data) { if (data) {
data.color = color; data.color = color;
@ -3532,8 +3676,13 @@ export class GameRuntime {
} }
if (cmd === 'scene.setVisible') { if (cmd === 'scene.setVisible') {
try { try {
const kind = payload?.kind; let kind = payload?.kind;
const id = payload?.id; let id = payload?.id;
// obj.visible=false шлёт {ref:'primitive:N'} без kind/id — парсим ref.
if ((kind == null || id == null) && typeof payload?.ref === 'string') {
const colon = payload.ref.indexOf(':');
if (colon > 0) { kind = payload.ref.slice(0, colon); id = payload.ref.slice(colon + 1); }
}
const visible = !!payload?.visible; const visible = !!payload?.visible;
if (id == null) return; if (id == null) return;
if (kind === 'primitive') { if (kind === 'primitive') {
@ -4168,6 +4317,11 @@ export class GameRuntime {
const id = t.id ?? t.ref; const id = t.id ?? t.ref;
this.scene3d?.primitiveManager?.removeInstance(id); this.scene3d?.primitiveManager?.removeInstance(id);
} }
// Снять interact-подсказку удалённого объекта (иначе «E» висит на пустоте).
if (t.kind && (t.ref ?? t.id) != null && Array.isArray(this._interactables)) {
const ref = t.kind + ':' + (t.ref ?? t.id);
this._interactables = this._interactables.filter(it => it.ref !== ref);
}
this.scheduleSceneSnapshot(); this.scheduleSceneSnapshot();
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@ -4175,9 +4329,9 @@ export class GameRuntime {
} }
} }
_log(level, text) { _log(level, text, scriptId = null, scriptName = null) {
if (this._onLog) { if (this._onLog) {
try { this._onLog({ level, text, ts: Date.now() }); } catch (e) { /* ignore */ } try { this._onLog({ level, text, ts: Date.now(), scriptId, scriptName }); } catch (e) { /* ignore */ }
} }
} }
@ -4331,6 +4485,29 @@ export class GameRuntime {
.then(j => this._saveReply(scriptId, reqId, j.namespaces || {})) .then(j => this._saveReply(scriptId, reqId, j.namespaces || {}))
.catch(() => this._saveReply(scriptId, reqId, {})); .catch(() => this._saveReply(scriptId, reqId, {}));
} }
/** Публичный helper для движковых менеджеров (leaderstats/achievements):
* сохранить прогресс текущего игрока в БД (storys savegame). */
saveProgress(namespace, data) {
const url = this._saveBaseUrl(namespace);
if (!url) return;
try {
fetch(url, {
method: 'POST',
headers: this._economyAuthHeaders(), // JWT игрока (иначе 401)
body: JSON.stringify({ data }),
}).catch(() => {});
} catch (e) { /* ignore */ }
}
/** Загрузить прогресс из БД (cb(data|null)). */
loadProgress(namespace, cb) {
const url = this._saveBaseUrl(namespace);
if (!url) { cb && cb(null); return; }
fetch(url, { headers: this._economyAuthHeaders() })
.then(r => r.json())
.then(j => cb && cb(j.data ?? null))
.catch(() => cb && cb(null));
}
_saveSet(payload) { _saveSet(payload) {
const url = this._saveBaseUrl(payload?.namespace); const url = this._saveBaseUrl(payload?.namespace);
if (!url) return; if (!url) return;

View File

@ -0,0 +1,953 @@
/**
* GameplayKits каталог готовых механик для Toolbox (задача 17, фаза T2).
*
* Каждый kit это готовый кусок поведения, который автор вставляет одним кликом
* из Тулбокса (вкладка «Готовые механики»). При вставке:
* - scripts с attachTo:'global' добавляются как глобальный скрипт игры;
* - scripts с attachTo:'on-target' создаётся примитив-маркер + скрипт на нём;
* - prims[] создаются примитивы на сцене (визуал кита).
*
* Все киты написаны НАМИ на белом-листе game-API (ScriptSandboxWorker)
* заведомо безопасны, исполняются в существующем sandbox (нет доступа к DOM/fetch).
*
* Фича-парность: тот же файл копируется в rublox-player/src/engine/ (киты это
* данные-скрипты, исполняются движком плеера так же).
*/
export const KIT_CATEGORIES = [
{ id: 'all', label: 'Все' },
{ id: 'movement', label: 'Движение' },
{ id: 'world', label: 'Мир' },
{ id: 'ui', label: 'Интерфейс' },
{ id: 'fx', label: 'Эффекты' },
{ id: 'npc', label: 'NPC и бой' },
{ id: 'economy', label: 'Экономика' },
];
export const GAMEPLAY_KITS = [
{
id: 'shift-to-run',
name: 'Бег на Shift',
desc: 'Игрок ускоряется в 1.8× при удержании Shift и возвращается к обычной скорости при отпускании.',
icon: 'zap', category: 'movement',
scripts: [{ attachTo: 'global', code:
`// Бег на Shift
game.onKey('shift', () => game.player.setSpeed(1.8));
game.onKeyUp('shift', () => game.player.setSpeed(1.0));` }],
},
{
id: 'double-jump',
name: 'Двойной прыжок',
desc: 'Разрешает второй прыжок прямо в воздухе. Нажми Space ещё раз во время прыжка.',
icon: 'arrow-up', category: 'movement',
scripts: [{ attachTo: 'global', code:
`// Двойной прыжок: второй прыжок в воздухе по Space
game.player.setDoubleJump(true);
game.ui.set('dj', 'Двойной прыжок включён! Жми Space в воздухе.', { x: 50, y: 90, anchor: 'bottom', color: '#fff', size: 16 });
game.after(5, () => game.ui.set('dj', ''));` }],
},
{
id: 'day-night-cycle',
name: 'Смена дня и ночи',
desc: 'Небо плавно переключается день → закат → ночь → день по кругу (использует Skybox задачи 16).',
icon: 'cloud', category: 'world',
scripts: [{ attachTo: 'global', code:
`// Авто-цикл дня и ночи
const phases = ['clear-summer-day', 'sunset', 'starry-night', 'clear-summer-day'];
let i = 0;
game.scene.setSkybox({ preset: phases[0] });
game.every(8, () => {
i = (i + 1) % phases.length;
game.scene.skybox.fadeTo({ preset: phases[i] }, 3);
});` }],
},
{
id: 'currency-counter',
name: 'Счётчик монет',
desc: 'Счётчик монет в углу HUD. Другие механики шлют game.broadcast("coins", {add: N}) — счётчик обновляется.',
icon: 'circle', category: 'ui',
scripts: [{ attachTo: 'global', code:
`// Счётчик монет в HUD. Прибавить монеты из любого скрипта:
// game.broadcast('coins', { add: 100 });
let coins = 0;
function show() { game.ui.set('coins', '🪙 ' + coins, { x: 92, y: 6, anchor: 'top', color: '#ffd23a', size: 22 }); }
show();
game.onMessage('coins', (m) => { coins += (m && m.add) ? m.add : 1; show(); });` }],
},
{
id: 'start-pad',
name: 'Стартовая площадка',
desc: 'Светящаяся платформа — игрок появляется НА ней в начале игры (задаёт точку старта).',
icon: 'flag', category: 'world',
prims: [{ type: 'cylinder', x: 0, y: 0.15, z: 0, sx: 3, sy: 0.3, sz: 3, color: '#36d57a', material: 'neon', name: 'Стартовая площадка' }],
scripts: [{ attachTo: 'on-target', code:
`// Игрок появляется на этой площадке в начале игры.
// Небольшая задержка — чтобы позиция объекта и игрок успели проинициализироваться.
game.after(0.1, () => {
const p = game.self.position;
game.player.teleport(p.x, p.y + 1.5, p.z);
});` }],
},
{
id: 'checkpoint',
name: 'Чекпоинт',
desc: 'Светящийся столб-чекпоинт. При касании сохраняет прогресс и показывает уведомление.',
icon: 'flag', category: 'world',
prims: [{ type: 'cylinder', x: 0, y: 1.5, z: 0, sx: 0.6, sy: 3, sz: 0.6, color: '#4d6bff', material: 'neon', name: 'Чекпоинт' }],
scripts: [{ attachTo: 'on-target', code:
`// Чекпоинт: касание → сообщение
game.self.onInteract(() => {
game.ui.set('cp', '✓ Чекпоинт сохранён!', { x: 50, y: 85, anchor: 'bottom', color: '#36d57a', size: 18 });
game.after(2, () => game.ui.set('cp', ''));
}, { text: 'Активировать', key: 'f', distance: 4 });` }],
},
{
id: 'confetti',
name: 'Конфетти',
desc: 'Праздничный фонтан конфетти из этого объекта. Кубики разлетаются и падают. Запускается периодически.',
icon: 'sparkles', category: 'fx',
prims: [{ type: 'sphere', x: 0, y: 1, z: 0, sx: 0.5, sy: 0.5, sz: 0.5, color: '#ff5ab0', material: 'neon', name: 'Конфетти-источник', canCollide: false }],
scripts: [{ attachTo: 'on-target', code:
`// Конфетти вылетает из ПОЗИЦИИ этого объекта (не из центра сцены).
function burst() {
const p = game.self.position; // где стоит конфетти-источник
for (let k = 0; k < 16; k++) {
const col = ['#ff5ab0','#ffd23a','#4d6bff','#36d57a','#ff7a3a'][k % 5];
game.scene.spawn('primitive:cube', {
x: p.x + (Math.random()-0.5)*0.6,
y: p.y + 0.5,
z: p.z + (Math.random()-0.5)*0.6,
sx: 0.22, sy: 0.22, sz: 0.22, color: col,
anchored: false, canCollide: false, lifetime: 2.5,
});
}
}
burst();
game.every(3, burst);` }],
},
{
id: 'floating-platform',
name: 'Парящая платформа',
desc: 'Платформа, которая плавно качается вверх-вниз — для паркура.',
icon: 'square', category: 'world',
prims: [{ type: 'cube', x: 0, y: 2, z: 0, sx: 4, sy: 0.5, sz: 4, color: '#c8a86a', material: 'matte', name: 'Парящая платформа' }],
scripts: [{ attachTo: 'on-target', code:
`// Качание платформы вверх-вниз
let t = 0; const baseY = 2;
game.onTick((dt) => {
t += dt;
game.self.move(game.self.position.x, baseY + Math.sin(t * 1.5) * 1.2, game.self.position.z);
});` }],
},
{
id: 'rotating-trap',
name: 'Вращающийся объект',
desc: 'Объект, который постоянно вращается — препятствие или декор.',
icon: 'refresh', category: 'world',
prims: [{ type: 'cube', x: 0, y: 1.5, z: 0, sx: 5, sy: 0.4, sz: 0.6, color: '#e0483c', material: 'matte', name: 'Вертушка' }],
scripts: [{ attachTo: 'on-target', code:
`// Постоянное вращение
let a = 0;
game.onTick((dt) => { a += dt * 1.5; game.self.rotate(a); });` }],
},
{
id: 'timer-hud',
name: 'Таймер забега',
desc: 'Секундомер в HUD — считает время с начала игры. Основа для гонок на время.',
icon: 'clock', category: 'ui',
scripts: [{ attachTo: 'global', code:
`// Таймер забега
let t = 0;
game.every(0.1, () => {
t += 0.1;
game.ui.set('timer', '⏱ ' + t.toFixed(1) + ' c', { x: 50, y: 6, anchor: 'top', color: '#ffffff', size: 22 });
});` }],
},
{
id: 'welcome-message',
name: 'Приветствие',
desc: 'Показывает приветственное сообщение при входе в игру и убирает через 5 секунд.',
icon: 'message', category: 'ui',
scripts: [{ attachTo: 'global', code:
`// Приветствие
game.ui.set('welcome', '👋 Добро пожаловать в игру!', { x: 50, y: 40, anchor: 'center', color: '#ffffff', size: 30 });
game.after(5, () => game.ui.set('welcome', ''));` }],
},
{
id: 'loot-crate',
name: 'Сундук с лутом',
desc: 'Золотой сундук. При взаимодействии «открывается» — даёт награду и сообщение.',
icon: 'box', category: 'world',
prims: [
{ type: 'cube', x: 0, y: 0.6, z: 0, sx: 1.6, sy: 1.2, sz: 1.2, color: '#b5862e', material: 'metal', name: 'Сундук' },
{ type: 'cube', x: 0, y: 1.35, z: 0, sx: 1.7, sy: 0.4, sz: 1.3, color: '#d4a843', material: 'metal', name: 'Крышка сундука', canCollide: false },
],
scripts: [{ attachTo: 'on-target', code:
`// Сундук с лутом — даёт 100 монет (через счётчик монет, если он добавлен).
let opened = false;
game.self.onInteract(() => {
if (opened) { game.ui.set('loot', 'Сундук уже открыт.', { x: 50, y: 85, anchor: 'bottom', color: '#bbb', size: 16 }); return; }
opened = true;
game.broadcast('coins', { add: 100 }); // обновит «Счётчик монет», если он есть
game.ui.set('loot', '🎁 Ты получил награду: +100 монет!', { x: 50, y: 85, anchor: 'bottom', color: '#ffd23a', size: 18 });
game.after(3, () => game.ui.set('loot', ''));
}, { text: 'Открыть сундук', key: 'f', distance: 4 });` }],
},
// ===== Партия 1 из Вики (киты 13-17) =====
{
id: 'trampoline',
name: 'Батут (пружина)',
desc: 'Яркая платформа-батут — наступи на неё, и игрока подбросит высоко вверх. (Вики: «Прыжок-пружина»)',
icon: 'arrow-up', category: 'movement',
prims: [{ type: 'cylinder', x: 0, y: 0.3, z: 0, sx: 3, sy: 0.6, sz: 3, color: '#ff3c8e', material: 'neon', name: 'Батут' }],
scripts: [{ attachTo: 'on-target', code:
`// Батут: касание → подброс игрока вверх.
game.self.onTouch(() => {
game.player.setVy(20); // вертикальный импульс (как трамплин)
});` }],
},
{
id: 'speed-pad',
name: 'Лента ускорения',
desc: 'Жёлтая плита-ускоритель — наступи, и игрок бежит быстрее несколько секунд. (Вики: «бусты скорости»)',
icon: 'zap', category: 'movement',
prims: [{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 3, sy: 0.2, sz: 5, color: '#ffd23a', material: 'neon', name: 'Лента ускорения', canCollide: false }],
scripts: [{ attachTo: 'on-target', code:
`// Лента ускорения: касание → x2 скорости на 3 секунды.
let boosting = false;
game.self.onTouch(() => {
if (boosting) return;
boosting = true;
game.player.setSpeed(2.0);
game.ui.set('boost', '⚡ Ускорение!', { x: 50, y: 80, anchor: 'bottom', color: '#ffd23a', size: 18 });
game.after(3, () => { game.player.setSpeed(1.0); game.ui.set('boost', ''); boosting = false; });
});` }],
},
{
id: 'teleport-portal',
name: 'Портал-телепорт',
desc: 'Два синих портала. Вошёл в портал — мгновенно переносишься ко второму. Поставь второй портал где нужно. (Вики: «секретный путь»)',
icon: 'sparkles', category: 'movement',
prims: [
{ type: 'cylinder', x: 0, y: 1.5, z: 0, sx: 0.4, sy: 3, sz: 3, color: '#4d6bff', material: 'neon', name: 'Портал A' },
{ type: 'cylinder', x: 8, y: 1.5, z: 0, sx: 0.4, sy: 3, sz: 3, color: '#4dffd6', material: 'neon', name: 'Портал B', canCollide: false },
],
scripts: [{ attachTo: 'on-target', code:
`// Портал A: касание → телепорт к «Портал B» (ищем его по имени в момент входа).
// Передвигай «Портал B» куда угодно — телепорт всегда попадёт к нему.
let cd = false;
game.self.onTouch(() => {
if (cd) return;
const b = game.scene.findOne('Портал B'); // ищем второй портал
if (!b || !b.position) return;
cd = true;
game.player.teleport(b.position.x, b.position.y + 1, b.position.z);
game.after(1.2, () => { cd = false; }); // защита от повторного входа
});` }],
},
{
id: 'disappearing-platform',
name: 'Исчезающая платформа',
desc: 'Платформа пропадает под ногами через секунду после касания и возвращается через 3с. (Вики: «Не упади», «Падающий мост»)',
icon: 'square', category: 'world',
prims: [{ type: 'cube', x: 0, y: 0.25, z: 0, sx: 4, sy: 0.5, sz: 4, color: '#e06a3c', material: 'matte', name: 'Исчезающая платформа' }],
scripts: [{ attachTo: 'on-target', code:
`// Исчезающая платформа: наступил → через 1с пропадает, через 3с возвращается.
let busy = false;
game.self.onTouch(() => {
if (busy) return; busy = true;
game.after(1, () => { game.self.setVisible(false); game.self.setCollide(false); });
game.after(3, () => { game.self.setVisible(true); game.self.setCollide(true); busy = false; });
});` }],
},
{
id: 'door-button',
name: 'Дверь по кнопке E',
desc: 'Красивая дверь с рамкой, филёнками и ручкой. Нажми E — плавно распахивается вокруг петли. (Вики: «Кнопка-открывашка»)',
icon: 'door', category: 'world',
// Полотно двери — ПЕРВЫЙ prim (на нём скрипт). Остальные части — рамка
// (неподвижный косяк) + декор полотна. Всё уходит в одну папку.
prims: [
// 0) Полотно двери (тёмное дерево).
{ type: 'cube', x: 0, y: 2, z: 0, sx: 0.25, sy: 3.8, sz: 2.6, color: '#6b4423', material: 'matte', name: 'Полотно двери' },
// Филёнки (светлее, чуть выступают) — верхняя и нижняя.
{ type: 'cube', x: 0.16, y: 2.9, z: 0, sx: 0.08, sy: 1.2, sz: 1.9, color: '#8a5a2e', material: 'matte', canCollide: false, name: 'Филёнка верх' },
{ type: 'cube', x: 0.16, y: 1.2, z: 0, sx: 0.08, sy: 1.4, sz: 1.9, color: '#8a5a2e', material: 'matte', canCollide: false, name: 'Филёнка низ' },
// Ручка (золотая).
{ type: 'sphere', x: 0.28, y: 2, z: 0.95, sx: 0.3, sy: 0.3, sz: 0.3, color: '#e0b030', material: 'metal', canCollide: false, name: 'Ручка' },
// Косяк-рамка (неподвижная) — две стойки + перемычка.
{ type: 'cube', x: 0, y: 2, z: -1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк левый' },
{ type: 'cube', x: 0, y: 2, z: 1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк правый' },
{ type: 'cube', x: 0, y: 4.1, z: 0, sx: 0.4, sy: 0.4, sz: 3.5, color: '#4a3018', material: 'matte', name: 'Перемычка' },
],
scripts: [{ attachTo: 'on-target', code:
`// Дверь по E: ПЛАВНО поворачивается вокруг петли (левой грани).
// Скрипт на полотне двери. Филёнки/ручка двигаются вместе как часть полотна?
// Нет — они отдельные примитивы, поэтому анимируем только полотно (game.self),
// а декор оставляем — на низкой толщине двери смотрится цельно.
const p0 = game.self.position; // центр полотна (закрытое положение)
const halfW = 1.3; // половина ширины полотна по Z (sz=2.6)
const hingeX = p0.x;
const hingeZ = p0.z - halfW; // петля у левого края
let open = false;
let cur = 0, target = 0; // текущий и целевой угол (радианы)
const SPEED = Math.PI; // рад/сек → ~0.5с на 90°
// Декор полотна — двигаем вместе с дверью. Запоминаем их СМЕЩЕНИЕ относительно
// центра полотна (в закрытом виде), чтобы вращать вокруг той же петли.
const decorNames = ['Филёнка верх', 'Филёнка низ', 'Ручка'];
const decor = [];
for (const nm of decorNames) {
const o = game.scene.findOne(nm);
if (o && o.position) {
decor.push({ obj: o, dx: o.position.x - p0.x, dy: o.position.y - p0.y, dz: o.position.z - p0.z });
}
}
// Поворот локального вектора (lx,lz) вокруг оси Y на angle — согласованно с
// тем, как Babylon поворачивает меш при rotation.y=angle (левосторонняя СК).
function rotY(lx, lz, a) {
const s = Math.sin(a), c = Math.cos(a);
return { x: lx * c + lz * s, z: -lx * s + lz * c };
}
function place(angle) {
// Полотно: центр = петля + повёрнутый локальный вектор (0, +halfW).
const pc = rotY(0, halfW, angle);
const cx = hingeX + pc.x;
const cz = hingeZ + pc.z;
game.self.move(cx, p0.y, cz);
game.self.rotate(angle);
// Декор: центр полотна + повёрнутое локальное смещение (той же формулой).
for (const d of decor) {
const r = rotY(d.dx, d.dz, angle);
d.obj.move(cx + r.x, p0.y + d.dy, cz + r.z);
if (d.obj.rotate) d.obj.rotate(angle);
}
}
// Один постоянный тик плавно ведёт cur → target.
game.onTick((dt) => {
if (cur === target) return;
const step = SPEED * dt;
if (Math.abs(target - cur) <= step) cur = target;
else cur += Math.sign(target - cur) * step;
place(cur);
});
game.self.onInteract(() => {
open = !open;
target = open ? Math.PI / 2 : 0; // 90° открыта / 0° закрыта
}, { text: 'Открыть / закрыть', key: 'e', distance: 5 });` }],
},
// ===== Партия 2 из Вики (киты 18-22) =====
{
id: 'color-tiles',
name: 'Цветная плитка',
desc: 'Наступи на плитку — она меняет цвет на случайный. (Вики: «Цветные плитки»)',
icon: 'palette', category: 'world',
prims: [{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 2.5, sy: 0.2, sz: 2.5, color: '#cfd8dc', material: 'matte', name: 'Цветная плитка' }],
scripts: [{ attachTo: 'on-target', code:
`// Плитка меняет цвет при касании.
const colors = ['#ff5ab0','#ffd23a','#4d6bff','#36d57a','#ff7a3a','#a05aff'];
let i = 0;
game.self.onTouch(() => {
i = (i + 1) % colors.length;
game.self.setColor(colors[i]);
});` }],
},
{
id: 'lava-floor',
name: 'Лава (урон по касанию)',
desc: 'Раскалённая плита: наступишь — теряешь здоровье каждую секунду, пока стоишь. (Вики: «Лава-пол»)',
icon: 'lava', category: 'world',
prims: [{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 5, sy: 0.2, sz: 5, color: '#ff4422', material: 'neon', name: 'Лава' }],
scripts: [{ attachTo: 'on-target', code:
`// Лава: пока игрок на плите — урон каждую секунду.
let onLava = false, timer = null;
game.self.onTouch(() => {
if (onLava) return; onLava = true;
const tick = () => { if (!onLava) return; game.player.damage(15);
game.ui.set('lava', '🔥 Горячо! -15 HP', { x: 50, y: 80, anchor: 'bottom', color: '#ff6644', size: 18 });
timer = game.after(1, tick); };
tick();
});
game.self.onUntouch(() => { onLava = false; if (timer) game.cancel(timer); game.ui.set('lava', ''); });` }],
},
{
id: 'elevator',
name: 'Лифт',
desc: 'Платформа-лифт сама ездит вверх-вниз между двумя этажами. Встань на неё и катайся. (Вики: «Лифт»)',
icon: 'elevator', category: 'world',
prims: [{ type: 'cube', x: 0, y: 0.5, z: 0, sx: 4, sy: 0.5, sz: 4, color: '#7a8a9a', material: 'metal', name: 'Лифт' }],
scripts: [{ attachTo: 'on-target', code:
`// Лифт: плавно ездит между нижней и верхней высотой.
const p0 = game.self.position;
const lowY = p0.y, highY = p0.y + 8;
let t = 0;
game.onTick((dt) => {
t += dt;
// Синусоида 0..1 с паузами на концах (период ~8с).
const k = (Math.sin(t * 0.5 - Math.PI/2) + 1) / 2;
game.self.move(p0.x, lowY + (highY - lowY) * k, p0.z);
});` }],
},
{
id: 'finish-line',
name: 'Финиш (победа)',
desc: 'Финишная плита: дойди до неё — на экране «ПОБЕДА!» и управление блокируется. (Вики: «Беги к финишу»)',
icon: 'flag', category: 'ui',
prims: [{ type: 'cube', x: 0, y: 0.15, z: 0, sx: 4, sy: 0.3, sz: 2, color: '#ffd23a', material: 'neon', name: 'Финиш', canCollide: false }],
scripts: [{ attachTo: 'on-target', code:
`// Финиш: касание → экран победы.
let done = false;
game.self.onTouch(() => {
if (done) return; done = true;
game.ui.set('win', '🏆 ПОБЕДА!', { x: 50, y: 42, anchor: 'center', color: '#ffd23a', size: 48 });
game.ui.set('winsub', 'Ты дошёл до финиша!', { x: 50, y: 54, anchor: 'center', color: '#fff', size: 22 });
game.player.setInputBlocked(true);
});` }],
},
{
id: 'sound-tile',
name: 'Звуковая плитка',
desc: 'Наступи на плитку — играет звук. Из таких можно собрать мелодию. (Вики: «Эхо-комната»)',
icon: 'sound', category: 'fx',
prims: [{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 2, sy: 0.2, sz: 2, color: '#6f8bff', material: 'neon', name: 'Звуковая плитка' }],
scripts: [{ attachTo: 'on-target', code:
`// Плитка играет звук при касании + подсвечивается.
let cd = false;
game.self.onTouch(() => {
if (cd) return; cd = true;
game.sound.play('coin');
game.self.setColor('#ffffff');
game.after(0.25, () => { game.self.setColor('#6f8bff'); cd = false; });
});` }],
},
// ===== Партия 3 из Вики (остальные механики) =====
// --- Мир ---
{
id: 'damage-zone',
name: 'Зона опасности',
desc: 'Невидимая зона: пока игрок внутри — теряет здоровье. (Вики: «Зона опасности»)',
icon: 'warning', category: 'world',
prims: [{ type: 'cube', x: 0, y: 2, z: 0, sx: 6, sy: 4, sz: 6, color: '#ff3344', material: 'glass', canCollide: false, name: 'Зона опасности' }],
scripts: [{ attachTo: 'on-target', code:
`// Зона урона: внутри — 10 HP/сек.
let inside = false, t = null;
game.self.onTouch(() => { if (inside) return; inside = true;
const tick = () => { if (!inside) return; game.player.damage(10);
game.ui.set('dz', '☠ Опасно! -10 HP', { x:50, y:78, anchor:'bottom', color:'#ff5555', size:18 });
t = game.after(1, tick); }; tick(); });
game.self.onUntouch(() => { inside = false; if (t) game.cancel(t); game.ui.set('dz',''); });` }],
},
{
id: 'spikes-trap',
name: 'Шипы-ловушка',
desc: 'Ряд острых шипов: наступишь — мгновенный урон. (Вики: «Полоса препятствий»)',
icon: 'warning', category: 'world',
prims: [
{ type: 'cube', x: 0, y: 0.1, z: 0, sx: 4, sy: 0.2, sz: 1.5, color: '#555', material: 'metal', name: 'Основание шипов' },
{ type: 'cone', x: -1.2, y: 0.7, z: 0, sx: 0.6, sy: 1.2, sz: 0.6, color: '#cccccc', material: 'metal', canCollide: false, name: 'Шип 1' },
{ type: 'cone', x: 0, y: 0.7, z: 0, sx: 0.6, sy: 1.2, sz: 0.6, color: '#cccccc', material: 'metal', canCollide: false, name: 'Шип 2' },
{ type: 'cone', x: 1.2, y: 0.7, z: 0, sx: 0.6, sy: 1.2, sz: 0.6, color: '#cccccc', material: 'metal', canCollide: false, name: 'Шип 3' },
],
scripts: [{ attachTo: 'on-target', code:
`// Шипы: касание → урон + отброс.
let cd = false;
game.self.onTouch(() => { if (cd) return; cd = true;
game.player.damage(34);
game.ui.set('sp', '🗡 Ой! -34 HP', { x:50, y:78, anchor:'bottom', color:'#ff5555', size:18 });
game.after(1.2, () => { game.ui.set('sp',''); cd = false; }); });` }],
},
{
id: 'traffic-light',
name: 'Светофор',
desc: 'Светофор переключает красный/жёлтый/зелёный по таймеру. (Вики: «Светофор»)',
icon: 'light', category: 'world',
prims: [
{ type: 'cube', x: 0, y: 3, z: 0, sx: 1.2, sy: 4, sz: 1.2, color: '#2a2a2a', material: 'matte', name: 'Корпус светофора' },
{ type: 'sphere', x: 0, y: 4.2, z: 0.6, sx: 0.8, sy: 0.8, sz: 0.4, color: '#5a0000', material: 'neon', canCollide: false, name: 'Красный' },
{ type: 'sphere', x: 0, y: 3.3, z: 0.6, sx: 0.8, sy: 0.8, sz: 0.4, color: '#5a5a00', material: 'neon', canCollide: false, name: 'Жёлтый' },
{ type: 'sphere', x: 0, y: 2.4, z: 0.6, sx: 0.8, sy: 0.8, sz: 0.4, color: '#005a00', material: 'neon', canCollide: false, name: 'Зелёный' },
],
scripts: [{ attachTo: 'on-target', code:
`// Светофор: переключение фаз каждые 2 сек.
const R = game.scene.findOne('Красный'), Y = game.scene.findOne('Жёлтый'), G = game.scene.findOne('Зелёный');
const phases = [['#ff0000','#5a5a00','#005a00'], ['#5a0000','#ffcc00','#005a00'], ['#5a0000','#5a5a00','#00ff00']];
let p = 0;
function show(){ const c = phases[p]; if(R) R.color = c[0]; if(Y) Y.color = c[1]; if(G) G.color = c[2]; }
show();
game.every(2, () => { p = (p+1) % phases.length; show(); });` }],
},
{
id: 'harvest-plant',
name: 'Грядка с урожаем',
desc: 'Растение растёт, по клику собираешь урожай (+10 монет) и оно вырастает заново. (Вики: «Сбор урожая»)',
icon: 'plant', category: 'world',
prims: [
{ type: 'cube', x: 0, y: 0.2, z: 0, sx: 2, sy: 0.4, sz: 2, color: '#6b4a2e', material: 'matte', name: 'Грядка' },
{ type: 'sphere', x: 0, y: 0.8, z: 0, sx: 1, sy: 1, sz: 1, color: '#3f9a48', material: 'matte', name: 'Урожай' },
],
scripts: [{ attachTo: 'on-target', code:
`// Грядка: собрал урожай → он исчезает, растёт заново (цвет красный→зелёный),
// при полном размере снова созрел и его можно собрать.
const plant = game.scene.findOne('Урожай');
let ripe = true; // созрел ли (можно собирать)
let growth = 1; // 0..1 — степень роста
// Плавная интерполяция цвета красный(незрелый)→зелёный(спелый).
function colorAt(g) {
const r = Math.round(0xb0 + (0x3f - 0xb0) * g);
const gr = Math.round(0x40 + (0x9a - 0x40) * g);
const b = Math.round(0x2e + (0x48 - 0x2e) * g);
return '#' + [r,gr,b].map(v => v.toString(16).padStart(2,'0')).join('');
}
game.self.onInteract(() => {
if (!ripe) { game.ui.set('h','Ещё не созрело...', {x:50,y:80,anchor:'bottom',color:'#bbb',size:16}); return; }
ripe = false;
growth = 0;
game.broadcast('coins', { add: 10 });
if (plant) { plant.scale = 0.01; plant.color = colorAt(0); }
game.ui.set('h','🌾 Собрано! +10 монет', {x:50,y:80,anchor:'bottom',color:'#ffd23a',size:18});
game.after(2, () => game.ui.set('h',''));
}, { text: 'Собрать урожай', key: 'e', distance: 4 });
// Рост: за ~5 секунд от 0 до 1.
game.onTick((dt) => {
if (ripe || !plant) return;
growth = Math.min(1, growth + dt / 5);
plant.scale = Math.max(0.05, growth);
plant.color = colorAt(growth);
if (growth >= 1) ripe = true;
});` }],
},
{
id: 'falling-objects',
name: 'Падающие предметы',
desc: 'Из этой точки с неба сыплются кубики — лови их или уворачивайся. (Вики: «Поймай падающее»)',
icon: 'box', category: 'world',
prims: [{ type: 'sphere', x: 0, y: 8, z: 0, sx: 0.6, sy: 0.6, sz: 0.6, color: '#4d6bff', material: 'neon', canCollide: false, name: 'Тучка-источник' }],
scripts: [{ attachTo: 'on-target', code:
`// Каждые 1.5с роняет куб из позиции источника.
const p = game.self.position;
game.every(1.5, () => {
game.scene.spawn('primitive:cube', {
x: p.x + (Math.random()-0.5)*6, y: p.y, z: p.z + (Math.random()-0.5)*6,
sx: 0.6, sy: 0.6, sz: 0.6, color: '#ffaa33', anchored: false, canCollide: true, lifetime: 6 });
});` }],
},
// --- Интерфейс ---
{
id: 'score-counter',
name: 'Счётчик очков',
desc: 'Счёт очков в HUD. Другие механики шлют game.broadcast("score",{add:N}). (Вики: «Собери монетки»)',
icon: 'star', category: 'ui',
scripts: [{ attachTo: 'global', code:
`// Счётчик очков. Прибавить: game.broadcast('score', { add: 1 });
let score = 0;
function show(){ game.ui.set('score', '⭐ ' + score, { x:8, y:6, anchor:'top', color:'#ffd23a', size:22 }); }
show();
game.onMessage('score', (m) => { score += (m && m.add) ? m.add : 1; show(); });` }],
},
{
id: 'leaderboard',
name: 'Таблица лидеров',
desc: 'Лидерборд справа-сверху (Очки/Время). Растёт от монет и очков других механик. Сохраняется в БД между сессиями. (Задача 20)',
icon: 'trophy', category: 'ui',
scripts: [{ attachTo: 'global', code:
`// Таблица лидеров: столбцы «Очки» (primary) и «Время».
game.leaderstats.define('Очки', { initial: 0, format: 'number', icon: '⭐', color: '#ffd23a', primary: true });
game.leaderstats.define('Время', { initial: 0, format: 'time', icon: '⏱', color: '#7fd0ff' });
// Время идёт само.
game.every(1, () => game.leaderstats.me.add('Время', 1));
// Любая механика, шлющая broadcast('score',{add}) или ('coins',{add}),
// автоматически добавляет очки в таблицу.
game.onMessage('score', (m) => game.leaderstats.me.add('Очки', (m && m.add) ? m.add : 1));
game.onMessage('coins', (m) => game.leaderstats.me.add('Очки', (m && m.add) ? m.add : 1));` }],
},
{
id: 'hp-bar',
name: 'Полоска здоровья',
desc: 'Показывает HP игрока в углу экрана, обновляется при уроне/лечении. (Вики: «Лава-пол»)',
icon: 'warning', category: 'ui',
scripts: [{ attachTo: 'global', code:
`// Своя полоска HP. Сначала прячем стандартную, чтобы не дублировалась.
game.hud.setHpVisible(false);
function show(){ const hp = Math.max(0, Math.round(game.player.hp));
game.ui.set('hp', '❤ ' + hp + ' / 100', { x:50, y:94, anchor:'bottom', color: hp>30?'#36d57a':'#ff4444', size:22 }); }
show();
game.every(0.2, show);` }],
},
{
id: 'hide-default-hp',
name: 'Скрыть стандартный HUD HP',
desc: 'Прячет стандартную полосу здоровья сверху — чтобы показать свою. (Свойство игрока game.hud.setHpVisible)',
icon: 'warning', category: 'ui',
scripts: [{ attachTo: 'global', code:
`// Скрыть стандартную полосу здоровья игрока.
game.hud.setHpVisible(false);` }],
},
{
id: 'code-door',
name: 'Дверь по коду',
desc: 'Красивая дверь с кодовой панелью: подойди — появится поле ввода, введи код (1234) — откроется. (Вики: «Дверь по коду»)',
icon: 'keypad', category: 'world',
// Красивая дверь (полотно + рамка) + кодовая панель на стене.
prims: [
{ type: 'cube', x: 0, y: 2, z: 0, sx: 0.25, sy: 3.8, sz: 2.6, color: '#5a6478', material: 'metal', name: 'Полотно двери-код' },
{ type: 'cube', x: 0.16, y: 2.6, z: 0, sx: 0.06, sy: 0.9, sz: 1.8, color: '#79879a', material: 'metal', canCollide: false, name: 'Панель двери' },
{ type: 'cube', x: 0.16, y: 1.3, z: 0, sx: 0.06, sy: 0.9, sz: 1.8, color: '#79879a', material: 'metal', canCollide: false, name: 'Панель двери низ' },
{ type: 'cube', x: 0.3, y: 2, z: 0.95, sx: 0.15, sy: 0.6, sz: 0.5, color: '#ffd23a', material: 'neon', canCollide: false, name: 'Кодовая панель' },
{ type: 'cube', x: 0, y: 2, z: -1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#3a4250', material: 'metal', name: 'Косяк левый' },
{ type: 'cube', x: 0, y: 2, z: 1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#3a4250', material: 'metal', name: 'Косяк правый' },
{ type: 'cube', x: 0, y: 4.1, z: 0, sx: 0.4, sy: 0.4, sz: 3.5, color: '#3a4250', material: 'metal', name: 'Перемычка' },
],
scripts: [{ attachTo: 'on-target', code:
`// Дверь по коду 1234. Поле ввода появляется ТОЛЬКО рядом. Верный код →
// дверь ПЛАВНО распахивается вокруг петли (вместе с панелями).
const CODE = '1234';
const p0 = game.self.position;
const halfW = 1.3; // полуширина полотна (sz=2.6)
const hingeX = p0.x, hingeZ = p0.z - halfW;
const RADIUS = 6;
let opened = false, near = false, cur = 0, target = 0;
const SPEED = Math.PI; // ~0.5с на 90°
// Декор полотна — двигается вместе с дверью.
const decorNames = ['Панель двери', 'Панель двери низ', 'Кодовая панель'];
const decor = [];
for (const nm of decorNames) {
const o = game.scene.findOne(nm);
if (o && o.position) decor.push({ obj:o, dx:o.position.x-p0.x, dy:o.position.y-p0.y, dz:o.position.z-p0.z });
}
function rotY(lx, lz, a){ const s=Math.sin(a),c=Math.cos(a); return { x: lx*c+lz*s, z: -lx*s+lz*c }; }
function place(a){
const pc = rotY(0, halfW, a);
const cx = hingeX + pc.x, cz = hingeZ + pc.z;
game.self.move(cx, p0.y, cz); game.self.rotate(a);
for (const d of decor){ const r = rotY(d.dx, d.dz, a); d.obj.move(cx+r.x, p0.y+d.dy, cz+r.z); if (d.obj.rotate) d.obj.rotate(a); }
}
game.onTick((dt) => {
if (cur !== target){ const st=SPEED*dt; cur = Math.abs(target-cur)<=st ? target : cur+Math.sign(target-cur)*st; place(cur); }
const pl = game.player.position;
const d = Math.sqrt((pl.x-p0.x)**2 + (pl.z-p0.z)**2);
if (opened){
// Дверь открыта: подсказка «E закрыть» только когда игрок рядом.
if (d < RADIUS && !near){ near = true; game.ui.set('codehint','Нажми E чтобы закрыть дверь', {x:50,y:80,anchor:'bottom',color:'#fff',size:16}); }
else if (d >= RADIUS && near){ near = false; game.ui.set('codehint','',{}); }
return;
}
// Дверь закрыта: поле ввода кода по дистанции.
if (d < RADIUS && !near){ near = true;
game.gui.create('textbox', { id:'codein', x:50, y:86, w:24, h:8, anchor:'center', placeholder:'Код...', textSize:20 });
game.ui.set('codehint', '🔢 Введи код двери (1234) и нажми Enter', {x:50,y:78,anchor:'bottom',color:'#fff',size:16}); }
else if (d >= RADIUS && near){ near = false; game.gui.remove('codein'); game.ui.set('codehint','',{}); }
});
game.gui.onSubmit('codein', (text) => {
if (opened) return;
if (String(text).trim() === CODE){
opened = true; near = false; target = Math.PI/2; // плавно распахнуть
game.gui.remove('codein');
game.ui.set('codehint','✓ Открыто!', {x:50,y:78,anchor:'bottom',color:'#36d57a',size:18});
game.after(2, () => game.ui.set('codehint','',{}));
} else game.ui.set('codehint','✗ Неверный код', {x:50,y:78,anchor:'bottom',color:'#ff5555',size:18});
});
// Закрыть дверь по E (только если открыта и игрок рядом).
game.onKey('e', () => {
if (!opened) return;
const pl = game.player.position;
if (Math.sqrt((pl.x-p0.x)**2 + (pl.z-p0.z)**2) >= RADIUS) return;
opened = false; near = false; target = 0;
game.ui.set('codehint','🔒 Дверь закрыта.', {x:50,y:80,anchor:'bottom',color:'#fff',size:16});
game.after(1.5, () => game.ui.set('codehint','',{}));
});` }],
},
{
id: 'name-label',
name: 'Метка с именем',
desc: 'Над объектом висит табличка с текстом (имя/HP). (Вики: «Имена над врагами»)',
icon: 'tag', category: 'ui',
prims: [{ type: 'cube', x: 0, y: 1, z: 0, sx: 1.5, sy: 2, sz: 1.5, color: '#c83030', material: 'matte', name: 'Объект с меткой' }],
scripts: [{ attachTo: 'on-target', code:
`// Метка-табличка над объектом.
game.self.setLabel('Враг ❤ 100', { color: '#ffffff', bg: '#c83030' });` }],
},
{
id: 'countdown',
name: 'Обратный отсчёт',
desc: 'Таймер обратного отсчёта в HUD. По нулю — событие (тут просто сообщение). (Вики: «продержись N секунд»)',
icon: 'clock', category: 'ui',
scripts: [{ attachTo: 'global', code:
`// Обратный отсчёт 30 секунд.
let left = 30;
game.ui.set('cd', '⏳ ' + left, { x:50, y:6, anchor:'top', color:'#fff', size:26 });
const id = game.every(1, () => {
left--; game.ui.set('cd', '⏳ ' + left, { x:50, y:6, anchor:'top', color: left<=5?'#ff4444':'#fff', size:26 });
if (left <= 0) { game.cancel(id); game.ui.set('cd', '⏰ Время вышло!', { x:50, y:42, anchor:'center', color:'#ff4444', size:40 }); }
});` }],
},
// --- Эффекты ---
{
id: 'fire-emitter',
name: 'Костёр (огонь)',
desc: 'Источник частиц огня — горит постоянно. (Палитра эффектов)',
icon: 'sparkles', category: 'fx',
prims: [{ type: 'cylinder', x: 0, y: 0.2, z: 0, sx: 1.2, sy: 0.4, sz: 1.2, color: '#3a2a1a', material: 'matte', name: 'Костёр' }],
scripts: [{ attachTo: 'on-target', code:
`// Постоянный огонь из точки костра.
const p = game.self.position;
function fire(){ game.scene.spawnParticles('fire', { x:p.x, y:p.y+0.5, z:p.z }, { duration: 1.5, count: 40 }); }
fire(); game.every(1.2, fire);` }],
},
{
id: 'magnet-coins',
name: 'Магнит монет',
desc: 'Монета сама летит к игроку, когда он подходит близко. (Вики: «Магнит монет»)',
icon: 'circle', category: 'fx',
prims: [{ type: 'cylinder', x: 0, y: 1, z: 0, sx: 0.8, sy: 0.2, sz: 0.8, color: '#ffd23a', material: 'metal', name: 'Монета' }],
scripts: [{ attachTo: 'on-target', code:
`// Монета летит к игроку, если он ближе 6 м; коснулся — +1 монета.
let taken = false;
game.onTick(() => {
if (taken) return;
const me = game.self.position, pl = game.player.position;
const dx = pl.x-me.x, dy = pl.y-me.y, dz = pl.z-me.z;
const d = Math.sqrt(dx*dx+dy*dy+dz*dz);
if (d < 1.2) { taken = true; game.broadcast('coins', { add: 1 }); game.self.setVisible(false); return; }
if (d < 6) { game.self.move(me.x+dx*0.12, me.y+dy*0.12, me.z+dz*0.12); }
});` }],
},
// --- NPC и бой ---
{
id: 'npc-chaser',
name: 'NPC-преследователь',
desc: 'Враг бежит за игроком по всему уровню. (Вики: «Преследователь»)',
icon: 'chase', category: 'npc',
scripts: [{ attachTo: 'global', code:
`// Спавним NPC, который преследует игрока.
const enemy = game.scene.spawnNpc('skin_roblox-noob', { x: 8, z: 8, name: 'Охотник', speed: 4 });
if (enemy && enemy.follow) enemy.follow('player');` }],
},
{
id: 'npc-trader',
name: 'Торговец (NPC)',
desc: 'NPC-персонаж торговец: подойди, нажми E — открывается диалог. (Вики: «Торговец»)',
icon: 'trader', category: 'npc',
// Невидимый prim-триггер держит onInteract; рядом спавнится NPC-персонаж.
prims: [{ type: 'cube', x: 0, y: 1.5, z: 0, sx: 2, sy: 3, sz: 2, color: '#3a6ea5', material: 'matte', visible: false, canCollide: false, name: 'Зона торговца' }],
scripts: [{ attachTo: 'on-target', code:
`// Торговец — настоящий NPC-персонаж. Триггер (этот объект) держит диалог по E.
const p = game.self.position;
const npc = game.scene.spawnNpc('skin_roblox-noob', { x: p.x, z: p.z, name: 'Торговец Боб' });
game.self.onInteract(() => {
game.modal.dialog('Торговец Боб', [
'Привет, путник! Заходи за товарами.',
'У меня лучшие мечи во всём королевстве!',
'Возвращайся, когда накопишь монет.',
]);
}, { text: 'Поговорить', key: 'e', distance: 4 });` }],
},
{
id: 'shooting-target',
name: 'Мишень для стрельбы',
desc: 'Кликни по мишени — +10 очков, мишень исчезает и появляется снова. (Вики: «Тир»)',
icon: 'crosshair', category: 'npc',
prims: [
{ type: 'cylinder', x: 0, y: 2, z: 0, sx: 0.3, sy: 2, sz: 2, color: '#ffffff', material: 'matte', name: 'Мишень' },
{ type: 'cylinder', x: 0.2, y: 2, z: 0, sx: 0.1, sy: 1.2, sz: 1.2, color: '#ff3333', material: 'matte', canCollide: false, name: 'Кольцо мишени' },
],
scripts: [{ attachTo: 'on-target', code:
`// Мишень: клик → +10 очков, прячется на 1.5с.
let active = true;
game.self.onClick(() => {
if (!active) return; active = false;
game.broadcast('score', { add: 10 });
game.self.setVisible(false);
game.ui.set('hit', '🎯 +10!', { x:50, y:75, anchor:'bottom', color:'#36d57a', size:20 });
game.after(1.5, () => { game.self.setVisible(true); active = true; game.ui.set('hit',''); });
});` }],
},
{
id: 'enemy-hp',
name: 'Враг с HP',
desc: 'Враг-персонаж: преследует игрока, бьёт при касании. Над головой — полоска здоровья. (Вики: «босс», «имена над врагами»)',
icon: 'boss', category: 'npc',
// Невидимый триггер-якорь; рядом спавнится NPC-враг.
prims: [{ type: 'cube', x: 0, y: 1, z: 0, sx: 1, sy: 2, sz: 1, color: '#7a2030', material: 'matte', visible: false, canCollide: false, name: 'Якорь врага' }],
scripts: [{ attachTo: 'on-target', code:
`// Враг-персонаж: преследует игрока, бьёт с анимацией удара при сближении.
const p = game.self.position;
const enemy = game.scene.spawnNpc('skin_retro-zombie', { x: p.x, z: p.z, name: 'Враг', hp: 100, speed: 3.5 });
if (enemy && enemy.follow) enemy.follow('player');
let cd = 0, atk = false;
game.onTick((dt) => {
if (!enemy || !enemy.position) return;
cd -= dt;
const pl = game.player.position, e = enemy.position;
const d = Math.sqrt((pl.x-e.x)**2 + (pl.z-e.z)**2);
const inRange = d < 3.5;
if (inRange !== atk) { atk = inRange; enemy.setAttacking && enemy.setAttacking(inRange); }
if (inRange && cd <= 0) { game.player.damage(10); cd = 1; } // удар раз в секунду
});` }],
},
{
id: 'enemy-wave',
name: 'Волна врагов',
desc: 'Спавнер: выпускает врагов волнами по таймеру. (Вики: «Выживание от волн», tower defense)',
icon: 'zombie', category: 'npc',
prims: [{ type: 'cylinder', x: 0, y: 0.15, z: 0, sx: 2, sy: 0.3, sz: 2, color: '#7a2030', material: 'neon', name: 'Портал врагов' }],
scripts: [{ attachTo: 'on-target', code:
`// Каждые 5с спавнит 2 врагов из портала, они идут к игроку И бьют при касании.
const p = game.self.position;
const enemies = [];
function wave(){
for (let i=0;i<2;i++){ const e = game.scene.spawnNpc('skin_retro-zombie', { x:p.x+(Math.random()-0.5)*2, z:p.z+(Math.random()-0.5)*2, name:'Враг', hp:60, speed:3 });
if (e){ if (e.follow) e.follow('player'); enemies.push({ npc:e, cd:0 }); } }
}
game.after(2, wave); game.every(5, wave);
// Урон + анимация удара при сближении (у каждого врага свой кулдаун).
game.onTick((dt) => {
const pl = game.player.position;
for (const en of enemies){
if (!en.npc || !en.npc.position) continue;
en.cd -= dt;
const e = en.npc.position;
const d = Math.sqrt((pl.x-e.x)**2 + (pl.z-e.z)**2);
const inRange = d < 3.5;
if (inRange !== en.atk){ en.atk = inRange; en.npc.setAttacking && en.npc.setAttacking(inRange); }
if (inRange && en.cd <= 0){ game.player.damage(8); en.cd = 1; }
}
});` }],
},
// --- Экономика ---
{
id: 'shop-button',
name: 'Магазин (кнопка покупки)',
desc: 'GUI-кнопка магазина: покупка предмета за 50 монет (если хватает). (Вики: «Магазин»)',
icon: 'cart', category: 'economy',
scripts: [{ attachTo: 'global', code:
`// Кнопка магазина: купить за 50 монет.
let coins = 100; // локальный баланс кита (для демо)
game.ui.set('bal', '🪙 ' + coins, { x:92, y:6, anchor:'top', color:'#ffd23a', size:22 });
game.onMessage('coins', (m) => { coins += (m&&m.add)?m.add:0; game.ui.set('bal','🪙 '+coins,{x:92,y:6,anchor:'top',color:'#ffd23a',size:22}); });
game.gui.create('button', { id:'buybtn', x:50, y:90, w:26, h:9, anchor:'center', text:'Купить меч — 50 🪙',
bgGradient:{ stops:['#ffe066','#e0a000'], angle:90 }, textColor:'#3a2a00', textSize:18, fontWeight:800, borderRadius:12 });
game.gui.onClick('buybtn', () => {
if (coins >= 50) { game.broadcast('coins', { add: -50 }); game.ui.set('shopmsg','✓ Куплено!',{x:50,y:80,anchor:'bottom',color:'#36d57a',size:18}); }
else game.ui.set('shopmsg','✗ Мало монет',{x:50,y:80,anchor:'bottom',color:'#ff5555',size:18});
game.after(2, () => game.ui.set('shopmsg',''));
});` }],
},
{
id: 'clicker-button',
name: 'Кликер',
desc: 'GUI-кнопка: кликай и копи очки. (Вики: «Кликер»)',
icon: 'click', category: 'economy',
scripts: [{ attachTo: 'global', code:
`// Кликер: кнопка по центру, клик → +1 очко.
let n = 0;
function show(){ game.ui.set('clk', '👆 ' + n, { x:50, y:20, anchor:'center', color:'#fff', size:36 }); }
show();
game.gui.create('button', { id:'clickbtn', x:50, y:55, w:30, h:14, anchor:'center', text:'КЛИК!',
bgGradient:{ stops:['#6f8bff','#3a4ed0'], angle:90 }, textColor:'#fff', textSize:32, fontWeight:900, borderRadius:18,
hover:{ scale:1.05 }, active:{ scale:0.94 } });
game.gui.onClick('clickbtn', () => { n++; show(); });` }],
},
{
id: 'key-lock',
name: 'Ключ и замок',
desc: 'Найди золотой ключ, подбери — и дверь рядом плавно откроется по E. Без ключа заперта. (Вики: «Ключ и сундук»)',
icon: 'key', category: 'economy',
prims: [
// Ключ из примитивов: стержень + бородка + кольцо (torus). ПЕРВЫЙ — скрипт на нём.
{ type: 'cylinder', x: 0, y: 1, z: 0, sx: 0.12, sy: 1.0, sz: 0.12, color: '#ffd23a', material: 'metal', name: 'Ключ' },
{ type: 'torus', x: 0, y: 1.6, z: 0, sx: 0.5, sy: 0.5, sz: 0.5, color: '#ffd23a', material: 'metal', canCollide: false, name: 'Кольцо ключа' },
{ type: 'cube', x: 0.18, y: 0.6, z: 0, sx: 0.3, sy: 0.12, sz: 0.12, color: '#ffd23a', material: 'metal', canCollide: false, name: 'Бородка ключа' },
{ type: 'cube', x: 0.18, y: 0.4, z: 0, sx: 0.2, sy: 0.12, sz: 0.12, color: '#ffd23a', material: 'metal', canCollide: false, name: 'Бородка ключа 2' },
// Красивая дверь (полотно + рамка) на расстоянии.
{ type: 'cube', x: 6, y: 2, z: 0, sx: 0.25, sy: 3.8, sz: 2.6, color: '#6b4423', material: 'matte', name: 'Запертая дверь' },
{ type: 'cube', x: 6, y: 2, z: -1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк левый замка' },
{ type: 'cube', x: 6, y: 2, z: 1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк правый замка' },
{ type: 'cube', x: 6, y: 4.1, z: 0, sx: 0.4, sy: 0.4, sz: 3.5, color: '#4a3018', material: 'matte', name: 'Перемычка замка' },
],
scripts: [{ attachTo: 'on-target', code:
`// Ключ подбирается касанием. Дверь рядом открывается по E ТОЛЬКО с ключом —
// плавный поворот вокруг петли (как дверь по кнопке E).
let hasKey = false;
const keyParts = ['Ключ','Кольцо ключа','Бородка ключа','Бородка ключа 2'];
game.self.onTouch(() => {
if (hasKey) return; hasKey = true;
for (const nm of keyParts){ const o = game.scene.findOne(nm); if (o) o.visible = false; }
game.ui.set('key', '🔑 Ключ найден! Иди к двери (E).', {x:50,y:80,anchor:'bottom',color:'#ffd23a',size:18});
});
const door = game.scene.findOne('Запертая дверь');
if (door && door.onInteract){
const dp0 = door.position;
const halfW = 1.3, hingeZ = dp0.z - halfW;
let open = false, cur = 0, target = 0;
function rotY(lx,lz,a){ const s=Math.sin(a),c=Math.cos(a); return {x:lx*c+lz*s, z:-lx*s+lz*c}; }
game.onTick((dt) => {
if (cur===target) return;
const st = Math.PI*dt; cur = Math.abs(target-cur)<=st ? target : cur+Math.sign(target-cur)*st;
const pc = rotY(0, halfW, cur);
door.move(dp0.x+pc.x, dp0.y, hingeZ+pc.z); if (door.rotate) door.rotate(cur);
});
door.onInteract(() => {
if (!hasKey){ game.ui.set('key','🔒 Заперто. Нужен ключ.',{x:50,y:80,anchor:'bottom',color:'#ff5555',size:18}); return; }
open = !open; target = open ? Math.PI/2 : 0;
game.ui.set('key', open ? '✓ Дверь открыта!' : '🔒 Дверь закрыта.', {x:50,y:80,anchor:'bottom',color:'#36d57a',size:18});
game.after(2, () => game.ui.set('key','',{}));
}, { text:'Открыть / закрыть', key:'e', distance:4 });
}` }],
},
// --- Из готовых игр (g5) ---
{
id: 'spawn-car',
name: 'Машина (сядь за руль)',
desc: 'Готовый автомобиль: подойди, держи F — садись за руль, WASD — едь. (Вики: «Такси-симулятор»)',
icon: 'car', category: 'npc',
scripts: [{ attachTo: 'global', code:
`// Спавн машины, на которой можно ездить.
game.scene.spawn('vehicle:car', { x: 0, y: 0.5, z: 5, model: 'car-sedan', color: '#c83030',
name: 'Авто', params: { maxSpeed: 18, turnSpeed: 1.7, enginePower: 20 } });
game.ui.set('carhint', 'Подойди к машине и держи F — за руль!', {x:50,y:90,anchor:'bottom',color:'#fff',size:16});` }],
},
{
id: 'cutscene-dialog',
name: 'Диалог (кат-сцена)',
desc: 'Объект, по взаимодействию показывает диалог по строкам. (Вики: «Тайна старого сундука»)',
icon: 'scroll', category: 'npc',
prims: [{ type: 'cube', x: 0, y: 0.6, z: 0, sx: 1.4, sy: 1.2, sz: 1, color: '#8a6a3a', material: 'matte', name: 'Рассказчик' }],
scripts: [{ attachTo: 'on-target', code:
`// Диалог по строкам через готовый game.modal.dialog.
const lines = ['Давным-давно здесь стоял замок...', 'Его охранял древний страж.', 'Найди три ключа, чтобы войти!'];
game.self.onInteract(() => {
game.modal.dialog('Рассказчик', lines);
}, { text:'Поговорить', key:'e', distance:4 });` }],
},
{
id: 'guide-arrow',
name: '3D-стрелка-указатель',
desc: 'Стрелка-подсказка «иди сюда» ведёт игрока к цели. (Вики: «Туториал — собери монетки»)',
icon: 'flag', category: 'ui',
prims: [{ type: 'cylinder', x: 0, y: 1, z: 0, sx: 1, sy: 2, sz: 1, color: '#ffd23a', material: 'neon', name: 'Цель-указатель' }],
scripts: [{ attachTo: 'on-target', code:
`// Стрелка от игрока к этому объекту-цели.
const arrow = game.fx.pointer({ from: 'player', to: game.self, preset: 'guide' });
game.self.onTouch(() => { if (arrow && arrow.remove) arrow.remove();
game.ui.set('arr','✓ Дошёл!', {x:50,y:80,anchor:'bottom',color:'#36d57a',size:18}); });` }],
},
];
/** Найти кит по id. */
export function getKit(id) {
return GAMEPLAY_KITS.find(k => k.id === id) || null;
}

View File

@ -0,0 +1,370 @@
/**
* InventoryUI drag-drop инвентарь (задача 44): сетка 8×5 + hotbar 9 + стаки +
* редкости + ПКМ-меню + tooltip. Самодостаточный DOM-оверлей (как
* LoadingScreenOverlay) крепится к canvas.parentElement, работает в студии и
* плеере одинаково.
*
* Хранит: item-defs (game.items.define), слоты основного инвентаря (GRID),
* слоты hotbar (HOTBAR), активный hotbar-слот. Постоянный hotbar внизу HUD;
* окно инвентаря по клавише I (toggle).
*
* API (через game.inventory.* / game.items.*):
* game.items.define({id,name,icon,rarity,maxStack,description,value,onUse,tags})
* game.inventory.add(itemId, count) / remove / has / count
* game.inventory.open() / close() / toggle() / isOpen()
* game.inventory.move(from, to) / split(slot, n) / sort(by) / use(slot)
* game.inventory.setActiveHotbar(i) / getActiveItem()
*
* Фича-парность: тот же модуль в rublox-player/src/engine/.
*/
const GRID = 40; // 8×5 основной инвентарь
const COLS = 8;
const HOTBAR = 9;
const RARITY = {
common: { color: '#bbbbbb', label: 'Обычное' },
uncommon: { color: '#5cb85c', label: 'Необычное' },
rare: { color: '#5bc0de', label: 'Редкое' },
epic: { color: '#9b59b6', label: 'Эпическое' },
legendary: { color: '#f0ad4e', label: 'Легендарное' },
};
export class InventoryUI {
constructor(scene3d) {
this.s = scene3d;
this.defs = new Map(); // itemId → def
this.grid = new Array(GRID).fill(null); // {itemId,count}|null
this.hotbar = new Array(HOTBAR).fill(null);
this.active = 0;
this._open = false;
this.root = null; this.hotbarRoot = null; this.tooltip = null; this.ctxMenu = null;
this._drag = null; // {from:'grid'|'hotbar', idx}
this._onChange = [];
this._events = { added: [], removed: [], used: [], slot: [] };
this._opts = { allowDrop: true, allowSplit: true, showRarity: true };
}
// ── Определения предметов ───────────────────────────────────────────────
defineItem(def) {
if (!def || typeof def.id !== 'string') return;
this.defs.set(def.id, {
id: def.id, name: def.name || def.id,
icon: def.icon || null, emoji: def.emoji || null,
rarity: RARITY[def.rarity] ? def.rarity : 'common',
maxStack: Number(def.maxStack) > 0 ? Number(def.maxStack) : 1,
description: def.description || '', value: Number(def.value) || 0,
tags: Array.isArray(def.tags) ? def.tags : [],
onUseEffect: def.onUseEffect || null, // 'heal:50' | 'speed:1.5:5' | null
});
}
_def(id) { return this.defs.get(id) || { id, name: id, rarity: 'common', maxStack: 99, emoji: '📦', icon: null, description: '', value: 0, tags: [] }; }
// ── Операции ────────────────────────────────────────────────────────────
add(itemId, count = 1) {
const def = this._def(itemId);
let left = count;
// 1) долить в существующие стаки (сначала hotbar — он на виду, потом grid)
const fill = (arr) => {
for (let i = 0; i < arr.length && left > 0; i++) {
const s = arr[i];
if (s && s.itemId === itemId && s.count < def.maxStack) {
const room = def.maxStack - s.count;
const take = Math.min(room, left);
s.count += take; left -= take;
}
}
};
fill(this.hotbar); fill(this.grid);
// 2) в пустые слоты (сначала hotbar — собранное видно сразу, потом grid)
const place = (arr) => {
for (let i = 0; i < arr.length && left > 0; i++) {
if (!arr[i]) { const take = Math.min(def.maxStack, left); arr[i] = { itemId, count: take }; left -= take; }
}
};
place(this.hotbar); place(this.grid);
const added = count - left;
if (added > 0) { this._emit('added', { itemId, count: added }); this._changed(); }
return { added, overflow: left };
}
remove(itemId, count = 1) {
let left = count;
const drain = (arr) => {
for (let i = arr.length - 1; i >= 0 && left > 0; i--) {
const s = arr[i];
if (s && s.itemId === itemId) {
const take = Math.min(s.count, left);
s.count -= take; left -= take;
if (s.count <= 0) arr[i] = null;
}
}
};
drain(this.hotbar); drain(this.grid);
const removed = count - left;
if (removed > 0) { this._emit('removed', { itemId, count: removed }); this._changed(); }
return removed;
}
count(itemId) {
let n = 0;
for (const s of this.grid) if (s && s.itemId === itemId) n += s.count;
for (const s of this.hotbar) if (s && s.itemId === itemId) n += s.count;
return n;
}
has(itemId, n = 1) { return this.count(itemId) >= n; }
/** slot-ref: число 0..39 = grid; строка 'h0'..'h8' = hotbar. */
_arrIdx(ref) {
if (typeof ref === 'string' && ref[0] === 'h') return { arr: this.hotbar, idx: parseInt(ref.slice(1), 10) };
return { arr: this.grid, idx: Number(ref) };
}
move(from, to) {
const a = this._arrIdx(from), b = this._arrIdx(to);
if (!a.arr || !b.arr || a.idx == null || b.idx == null) return;
if (a.arr === b.arr && a.idx === b.idx) return;
const src = a.arr[a.idx], dst = b.arr[b.idx];
// merge одинаковых стаков
if (src && dst && src.itemId === dst.itemId) {
const def = this._def(src.itemId);
const room = def.maxStack - dst.count;
if (room > 0) {
const take = Math.min(room, src.count);
dst.count += take; src.count -= take;
if (src.count <= 0) a.arr[a.idx] = null;
this._changed(); return;
}
}
// swap
a.arr[a.idx] = dst; b.arr[b.idx] = src;
this._changed();
}
split(ref, n) {
if (!this._opts.allowSplit) return;
const { arr, idx } = this._arrIdx(ref);
const s = arr[idx]; if (!s || s.count <= 1) return;
const take = Math.max(1, Math.min(s.count - 1, n || Math.floor(s.count / 2)));
const empty = this.grid.indexOf(null);
if (empty < 0) return;
s.count -= take; this.grid[empty] = { itemId: s.itemId, count: take };
this._changed();
}
sort(by = 'rarity') {
const order = { legendary: 0, epic: 1, rare: 2, uncommon: 3, common: 4 };
const all = this.grid.filter(Boolean);
all.sort((x, y) => {
const dx = this._def(x.itemId), dy = this._def(y.itemId);
if (by === 'rarity') return (order[dx.rarity] - order[dy.rarity]) || dx.name.localeCompare(dy.name);
if (by === 'name') return dx.name.localeCompare(dy.name);
return dx.id.localeCompare(dy.id);
});
this.grid = all.concat(new Array(GRID - all.length).fill(null));
this._changed();
}
use(ref) {
const { arr, idx } = this._arrIdx(ref);
const s = arr[idx]; if (!s) return;
const def = this._def(s.itemId);
let consume = false;
if (def.onUseEffect) {
const [eff, a, b] = String(def.onUseEffect).split(':');
try {
if (eff === 'heal') { this.s?.player?.heal?.(Number(a) || 25); consume = true; }
else if (eff === 'speed') { this.s?.player?.setSpeed?.(Number(a) || 1.5); consume = true; }
} catch (e) { /* ignore */ }
}
this._emit('used', { itemId: s.itemId });
if (consume) { s.count -= 1; if (s.count <= 0) arr[idx] = null; this._changed(); }
}
setActiveHotbar(i) { this.active = Math.max(0, Math.min(HOTBAR - 1, i | 0)); this._renderHotbar(); }
getActiveItem() { const s = this.hotbar[this.active]; return s ? { ...s, def: this._def(s.itemId) } : null; }
onChange(fn) { if (typeof fn === 'function') this._onChange.push(fn); }
on(evt, fn) { if (this._events[evt] && typeof fn === 'function') this._events[evt].push(fn); }
_emit(evt, data) { for (const fn of (this._events[evt] || [])) { try { fn(data); } catch (e) {} } }
_changed() {
for (const fn of this._onChange) { try { fn(); } catch (e) {} }
this._emit('slot', {});
if (this._open) this._renderGrid();
this._renderHotbar();
}
// ── DOM: hotbar (постоянный) ───────────────────────────────────────────
_parent() { return (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body; }
mountHotbar() {
if (this.hotbarRoot) return;
const r = document.createElement('div');
r.style.cssText = 'position:absolute;left:50%;bottom:64px;transform:translateX(-50%);z-index:48;display:flex;gap:6px;pointer-events:auto;font-family:Inter,system-ui,sans-serif';
this._parent().appendChild(r); this.hotbarRoot = r;
this._renderHotbar();
}
_slotInner(s) {
if (!s) return '';
const def = this._def(s.itemId);
const icon = def.icon ? `<img src="${def.icon}" style="width:80%;height:80%;object-fit:contain;pointer-events:none">`
: `<span style="font-size:26px;pointer-events:none">${def.emoji || '📦'}</span>`;
const cnt = s.count > 1 ? `<span style="position:absolute;right:3px;bottom:1px;font-size:13px;font-weight:900;color:#fff;text-shadow:0 1px 2px #000">${s.count}</span>` : '';
return icon + cnt;
}
_slotStyle(s, activeBorder) {
const rc = (s && this._opts.showRarity) ? RARITY[this._def(s.itemId).rarity].color : 'rgba(255,255,255,0.15)';
const border = activeBorder ? '#ffd23a' : rc;
return `position:relative;width:52px;height:52px;border-radius:10px;border:2px solid ${border};background:rgba(20,26,40,0.7);display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,0.3)` + (activeBorder ? ';box-shadow:0 0 12px #ffd23a' : '');
}
_renderHotbar() {
if (!this.hotbarRoot) return;
this.hotbarRoot.innerHTML = '';
for (let i = 0; i < HOTBAR; i++) {
const s = this.hotbar[i];
const cell = document.createElement('div');
cell.style.cssText = this._slotStyle(s, i === this.active);
cell.innerHTML = `<span style="position:absolute;left:3px;top:1px;font-size:11px;color:#9aa3b2;font-weight:700">${i + 1}</span>` + this._slotInner(s);
cell.onmouseenter = (e) => this._showTooltip(s, e);
cell.onmouseleave = () => this._hideTooltip();
cell.onclick = () => { this.setActiveHotbar(i); };
cell.oncontextmenu = (e) => { e.preventDefault(); this._openCtx('h' + i, e); };
this._wireDrag(cell, 'h' + i);
this.hotbarRoot.appendChild(cell);
}
}
// ── DOM: окно инвентаря ─────────────────────────────────────────────────
open() { if (this._open) return; this._open = true; this._mountWindow(); }
close() { this._open = false; if (this.root) { try { this.root.remove(); } catch (e) {} this.root = null; } this._hideTooltip(); this._closeCtx(); }
toggle() { this._open ? this.close() : this.open(); }
isOpen() { return this._open; }
_mountWindow() {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:absolute;inset:0;z-index:70;background:rgba(8,10,16,0.6);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;font-family:Inter,system-ui,sans-serif;pointer-events:auto';
overlay.onclick = (e) => { if (e.target === overlay) this.close(); };
const panel = document.createElement('div');
panel.style.cssText = 'width:min(640px,94%);background:#141826;border:1px solid rgba(255,255,255,0.12);border-radius:18px;padding:20px;color:#e8ecf2;box-shadow:0 20px 60px rgba(0,0,0,0.5)';
panel.onclick = (e) => e.stopPropagation();
panel.innerHTML =
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px">' +
'<div style="font-size:22px;font-weight:800">🎒 Инвентарь</div>' +
'<div style="display:flex;gap:8px">' +
'<button id="_inv_sort" style="height:34px;padding:0 14px;border-radius:9px;background:#2a3550;border:1px solid rgba(255,255,255,0.15);color:#fff;cursor:pointer;font-weight:700">Сорт.</button>' +
'<button id="_inv_close" style="width:34px;height:34px;border-radius:9px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.15);color:#fff;font-size:18px;cursor:pointer">✕</button>' +
'</div></div>' +
'<div id="_inv_grid" style="display:grid;grid-template-columns:repeat(' + COLS + ',1fr);gap:6px"></div>' +
'<div style="margin:16px 0 6px;font-size:13px;color:#9aa3b2;font-weight:700">Панель быстрого доступа (1-9)</div>' +
'<div id="_inv_hb" style="display:grid;grid-template-columns:repeat(' + HOTBAR + ',1fr);gap:6px"></div>';
overlay.appendChild(panel); this._parent().appendChild(overlay); this.root = overlay;
panel.querySelector('#_inv_close').onclick = () => this.close();
panel.querySelector('#_inv_sort').onclick = () => this.sort('rarity');
this._gridEl = panel.querySelector('#_inv_grid');
this._hbEl = panel.querySelector('#_inv_hb');
this._renderGrid();
}
_renderGrid() {
if (!this._gridEl) return;
const build = (el, arr, prefix) => {
el.innerHTML = '';
for (let i = 0; i < arr.length; i++) {
const ref = prefix === 'h' ? 'h' + i : i;
const s = arr[i];
const cell = document.createElement('div');
cell.style.cssText = this._slotStyle(s, prefix === 'h' && i === this.active).replace('52px', '56px');
cell.innerHTML = (prefix === 'h' ? `<span style="position:absolute;left:3px;top:1px;font-size:11px;color:#9aa3b2;font-weight:700">${i + 1}</span>` : '') + this._slotInner(s);
cell.onmouseenter = (e) => this._showTooltip(s, e);
cell.onmouseleave = () => this._hideTooltip();
cell.oncontextmenu = (e) => { e.preventDefault(); this._openCtx(ref, e); };
if (prefix === 'h') cell.onclick = () => this.setActiveHotbar(i);
this._wireDrag(cell, ref);
el.appendChild(cell);
}
};
build(this._gridEl, this.grid, 'g');
if (this._hbEl) build(this._hbEl, this.hotbar, 'h');
}
// ── Drag-drop (HTML5 native) ────────────────────────────────────────────
_wireDrag(cell, ref) {
cell.draggable = true;
cell.addEventListener('dragstart', (e) => {
this._drag = ref; cell.style.opacity = '0.4';
try { e.dataTransfer.setData('text/plain', String(ref)); e.dataTransfer.effectAllowed = 'move'; } catch (er) {}
});
cell.addEventListener('dragend', () => { cell.style.opacity = '1'; this._drag = null; });
cell.addEventListener('dragover', (e) => { e.preventDefault(); });
cell.addEventListener('drop', (e) => {
e.preventDefault();
const from = this._drag;
if (from != null && String(from) !== String(ref)) this.move(from, ref);
});
}
// ── Tooltip ──────────────────────────────────────────────────────────────
_showTooltip(s, e) {
if (!s) return;
this._hideTooltip();
const def = this._def(s.itemId), rc = RARITY[def.rarity];
const t = document.createElement('div');
t.style.cssText = 'position:absolute;z-index:90;max-width:240px;padding:10px 12px;background:rgba(12,16,26,0.96);border:1px solid ' + rc.color + ';border-radius:10px;color:#e8ecf2;font-family:Inter,system-ui,sans-serif;font-size:13px;pointer-events:none;box-shadow:0 6px 20px rgba(0,0,0,0.5)';
t.innerHTML =
'<div style="font-weight:800;color:' + rc.color + '">' + this._esc(def.name) + '</div>' +
'<div style="font-size:11px;color:#9aa3b2;margin:2px 0">' + rc.label + (def.tags.length ? ' · ' + def.tags.join(', ') : '') + '</div>' +
(def.description ? '<div style="margin-top:4px">' + this._esc(def.description) + '</div>' : '') +
(def.value ? '<div style="margin-top:4px;color:#ffd23a">💰 ' + def.value + '</div>' : '');
document.body.appendChild(t);
const x = (e && e.clientX) || 0, y = (e && e.clientY) || 0;
t.style.left = Math.min(x + 14, window.innerWidth - 250) + 'px';
t.style.top = (y + 14) + 'px';
this.tooltip = t;
}
_hideTooltip() { if (this.tooltip) { try { this.tooltip.remove(); } catch (e) {} this.tooltip = null; } }
// ── ПКМ-меню (Use/Split/Drop) ─────────────────────────────────────────────
_openCtx(ref, e) {
this._closeCtx();
const { arr, idx } = this._arrIdx(ref);
const s = arr[idx]; if (!s) return;
const m = document.createElement('div');
m.style.cssText = 'position:absolute;z-index:95;background:#1a2030;border:1px solid rgba(255,255,255,0.15);border-radius:10px;padding:5px;min-width:140px;font-family:Inter,system-ui,sans-serif;box-shadow:0 8px 24px rgba(0,0,0,0.5)';
const item = (label, fn) => {
const b = document.createElement('div');
b.textContent = label;
b.style.cssText = 'padding:8px 12px;border-radius:7px;cursor:pointer;color:#e8ecf2;font-size:14px';
b.onmouseenter = () => b.style.background = 'rgba(255,255,255,0.08)';
b.onmouseleave = () => b.style.background = 'transparent';
b.onclick = () => { fn(); this._closeCtx(); };
m.appendChild(b);
};
item('Использовать', () => this.use(ref));
if (this._opts.allowSplit && s.count > 1) item('Разделить', () => this.split(ref, Math.floor(s.count / 2)));
if (this._opts.allowDrop && !this._def(s.itemId).tags.includes('quest')) item('Выбросить', () => { arr[idx] = null; this._changed(); });
item('Отмена', () => {});
document.body.appendChild(m);
m.style.left = Math.min((e.clientX || 0), window.innerWidth - 150) + 'px';
m.style.top = (e.clientY || 0) + 'px';
this.ctxMenu = m;
setTimeout(() => { this._ctxCloser = () => this._closeCtx(); window.addEventListener('click', this._ctxCloser, { once: true }); }, 0);
}
_closeCtx() { if (this.ctxMenu) { try { this.ctxMenu.remove(); } catch (e) {} this.ctxMenu = null; } }
_esc(str) { return String(str).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
// ── Сериализация ──────────────────────────────────────────────────────────
serialize() {
return { defs: [...this.defs.values()], grid: this.grid, hotbar: this.hotbar, active: this.active, opts: this._opts };
}
load(data) {
if (!data) return;
if (Array.isArray(data.defs)) for (const d of data.defs) this.defineItem(d);
if (Array.isArray(data.grid)) this.grid = data.grid.slice(0, GRID).concat(new Array(Math.max(0, GRID - data.grid.length)).fill(null));
if (Array.isArray(data.hotbar)) this.hotbar = data.hotbar.slice(0, HOTBAR).concat(new Array(Math.max(0, HOTBAR - data.hotbar.length)).fill(null));
if (typeof data.active === 'number') this.active = data.active;
if (data.opts) this._opts = { ...this._opts, ...data.opts };
}
dispose() {
this.close();
if (this.hotbarRoot) { try { this.hotbarRoot.remove(); } catch (e) {} this.hotbarRoot = null; }
}
resetRuntime() {
this.close();
if (this.hotbarRoot) { try { this.hotbarRoot.remove(); } catch (e) {} this.hotbarRoot = null; }
}
}

View File

@ -0,0 +1,255 @@
/**
* LeaderstatsManager лидерборды (leaderstats) как в Roblox (задача 20).
*
* Хранит статы игроков и рендерит HUD-таблицу в правом-верхнем углу.
* В одиночной игре один игрок ('me'). Поля сортируются по primary-стату.
*
* API (через game.leaderstats.*):
* define(name, opts) зарегистрировать стат (initial/format/icon/color/primary)
* set(playerId, name, value) / add изменить стат игрока
* get(playerId, name) прочитать
* me.set/add(name, value) для текущего игрока
* onChange(fn) подписка (для bindToStat достижений)
*
* format: 'number' (42) | 'time' (mm:ss) | 'short' (1.2K).
* DOM-оверлей крепится к canvas.parentElement (как LoadingScreenOverlay).
*
* Фича-парность: тот же модуль в rublox-player/src/engine/.
*/
function fmt(value, format) {
const v = Number(value) || 0;
if (format === 'time') {
const m = Math.floor(v / 60), s = Math.floor(v % 60);
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
}
if (format === 'short') {
if (v >= 1e9) return (v / 1e9).toFixed(1).replace(/\.0$/, '') + 'B';
if (v >= 1e6) return (v / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
if (v >= 1e3) return (v / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
return String(Math.round(v));
}
return String(Math.round(v));
}
export class LeaderstatsManager {
constructor(scene3d) {
this.s = scene3d;
this._defs = []; // [{name, initial, format, icon, color, primary}]
this._stats = new Map(); // playerId → Map(name → value)
this._players = new Map(); // playerId → displayName
this._onChange = [];
this.root = null;
this._dirty = false;
this._meId = 'me';
}
/** id текущего игрока (одиночка = 'me'). */
_resolveMe() {
try {
const p = this.s?.gameRuntime?._players?.me;
if (p && p.id != null) return String(p.id);
} catch (e) { /* ignore */ }
return 'me';
}
define(name, opts = {}) {
if (typeof name !== 'string' || !name) return;
if (this._defs.some(d => d.name === name)) return; // уже есть
this._defs.push({
name,
initial: Number(opts.initial) || 0,
format: opts.format || 'number',
icon: opts.icon || '',
color: opts.color || '#e8ecf2',
primary: !!opts.primary,
});
// Если ни один не primary — первый становится primary.
if (!this._defs.some(d => d.primary)) this._defs[0].primary = true;
// Инициализируем стат у уже известных игроков.
for (const [pid] of this._players) this._ensure(pid, name);
this._ensureMe();
if (this.s?._isPlaying) this._mount(); // HUD только в Play
this._dirty = true;
}
_ensureMe() {
const me = this._resolveMe();
this._meId = me;
if (!this._players.has(me)) {
let nm = 'Ты';
try { nm = this.s?.gameRuntime?._players?.me?.name || 'Ты'; } catch (e) {}
this._players.set(me, nm);
}
for (const d of this._defs) this._ensure(me, d.name);
}
_ensure(pid, name) {
if (!this._stats.has(pid)) this._stats.set(pid, new Map());
const m = this._stats.get(pid);
if (!m.has(name)) {
const def = this._defs.find(d => d.name === name);
m.set(name, def ? def.initial : 0);
}
}
set(playerId, name, value) {
const pid = playerId == null ? this._resolveMe() : String(playerId);
if (!this._players.has(pid)) this._players.set(pid, pid === this._resolveMe() ? 'Ты' : ('Игрок ' + pid));
this._ensure(pid, name);
const m = this._stats.get(pid);
const old = m.get(name);
const nv = Number(value) || 0;
if (old === nv) return;
m.set(name, nv);
this._dirty = true;
this._flash = this._flash || {};
this._flash[pid + '|' + name] = performance.now ? performance.now() : Date.now();
for (const fn of this._onChange) {
try { fn(pid, name, nv, old); } catch (e) { /* ignore */ }
}
// Сохраняем статы текущего игрока в БД (дебаунс 1с) — между сессиями.
if (pid === this._resolveMe()) this._scheduleSave();
}
_scheduleSave() {
if (this._saveTimer) clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(() => {
this._saveTimer = null;
try {
const me = this._resolveMe();
const m = this._stats.get(me);
if (!m) return;
const obj = {}; for (const [k, v] of m) obj[k] = v;
this.s?.gameRuntime?.saveProgress?.('_leaderstats', obj);
} catch (e) { /* ignore */ }
}, 1000);
}
/** Загрузить статы текущего игрока из БД (вызывать при Play, после define). */
loadFromDB() {
const rt = this.s?.gameRuntime;
if (!rt || !rt.loadProgress) return;
rt.loadProgress('_leaderstats', (data) => {
if (data && typeof data === 'object') {
const me = this._resolveMe();
for (const name of Object.keys(data)) {
// Применяем только к зарегистрированным статам, без повторного сейва.
if (this._defs.some(d => d.name === name)) {
this._ensure(me, name);
this._stats.get(me).set(name, Number(data[name]) || 0);
}
}
this._dirty = true;
}
});
}
add(playerId, name, delta) {
const pid = playerId == null ? this._resolveMe() : String(playerId);
this._ensure(pid, name);
const cur = this._stats.get(pid).get(name) || 0;
this.set(pid, name, cur + (Number(delta) || 0));
}
get(playerId, name) {
const pid = playerId == null ? this._resolveMe() : String(playerId);
const m = this._stats.get(pid);
return m ? (m.get(name) || 0) : 0;
}
onChange(fn) { if (typeof fn === 'function') this._onChange.push(fn); }
/** Активны ли leaderstats (хотя бы один define). */
get active() { return this._defs.length > 0; }
// ── HUD ──────────────────────────────────────────────────────────────
_mount() {
if (this.root) return;
const parent = (this.s && this.s.canvas && this.s.canvas.parentElement) || document.body;
const root = document.createElement('div');
root.style.cssText = [
'position:absolute', 'top:14px', 'right:14px', 'z-index:50',
'min-width:230px', 'max-width:300px',
'background:rgba(18,22,33,0.55)', 'backdrop-filter:blur(8px)',
'-webkit-backdrop-filter:blur(8px)',
'border:1px solid rgba(255,255,255,0.12)', 'border-radius:12px',
'padding:10px 12px', 'font-family:Inter,system-ui,sans-serif',
'color:#e8ecf2', 'pointer-events:none', 'user-select:none',
'box-shadow:0 6px 24px rgba(0,0,0,0.35)',
].join(';');
parent.appendChild(root);
this.root = root;
this._sortBy = null; // имя стата для сортировки (null = primary)
}
/** Вызывать каждый кадр (рендер при изменениях + затухание flash). */
tick() {
if (!this.active) return;
if (!this.root) { this._mount(); this._dirty = true; }
if (this._dirty) { this._render(); this._dirty = false; }
// flash затухает ~600мс — перерисуем пока активен.
if (this._flash && Object.keys(this._flash).length) {
const now = performance.now ? performance.now() : Date.now();
let any = false;
for (const k of Object.keys(this._flash)) {
if (now - this._flash[k] < 600) any = true; else delete this._flash[k];
}
if (any) this._render();
}
}
_render() {
const defs = this._defs;
if (!defs.length) { this.root.innerHTML = ''; return; }
const sortStat = this._sortBy || (defs.find(d => d.primary) || defs[0]).name;
const me = this._resolveMe();
// Строки игроков, сортировка по убыванию sortStat, топ-10.
const rows = [...this._players.keys()]
.map(pid => ({ pid, name: this._players.get(pid) }))
.sort((a, b) => (this.get(b.pid, sortStat) - this.get(a.pid, sortStat)))
.slice(0, 10);
const now = performance.now ? performance.now() : Date.now();
let html = '<div style="display:flex;align-items:center;gap:6px;font-weight:800;font-size:13px;margin-bottom:8px;color:#ffd23a">🏆 Таблица лидеров</div>';
// Шапка столбцов.
html += '<div style="display:grid;grid-template-columns:1fr ' + defs.map(() => 'auto').join(' ') + ';gap:8px;font-size:11px;color:#9aa3b2;font-weight:700;padding-bottom:4px;border-bottom:1px solid rgba(255,255,255,0.1)">';
html += '<span>Игрок</span>';
for (const d of defs) html += '<span style="text-align:right;color:' + d.color + '">' + (d.icon ? d.icon + ' ' : '') + d.name + '</span>';
html += '</div>';
// Строки.
for (const r of rows) {
const mine = r.pid === me;
html += '<div style="display:grid;grid-template-columns:1fr ' + defs.map(() => 'auto').join(' ') + ';gap:8px;font-size:13px;padding:4px 2px;border-radius:6px;' + (mine ? 'background:rgba(51,87,255,0.22);' : '') + '">';
html += '<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:' + (mine ? '800' : '600') + '">' + this._esc(r.name) + '</span>';
for (const d of defs) {
const flashed = this._flash && (now - (this._flash[r.pid + '|' + d.name] || 0) < 600);
const col = flashed ? '#ffe066' : d.color;
html += '<span style="text-align:right;font-weight:700;color:' + col + ';transition:color .2s">' + fmt(this.get(r.pid, d.name), d.format) + '</span>';
}
html += '</div>';
}
this.root.innerHTML = html;
}
_esc(s) { return String(s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
/** Сериализация определений в project_data. */
serialize() {
return this._defs.map(d => ({ ...d }));
}
load(arr) {
if (!Array.isArray(arr)) return;
for (const d of arr) this.define(d.name, d);
}
dispose() {
if (this.root) { try { this.root.remove(); } catch (e) {} this.root = null; }
this._stats.clear(); this._players.clear(); this._onChange = [];
}
/** Сброс рантайм-значений при exitPlayMode (определения остаются). */
resetRuntime() {
this._stats.clear(); this._players.clear(); this._flash = {};
if (this.root) this.root.innerHTML = '';
}
}

View File

@ -35,7 +35,25 @@ function injectSpinnerCss() {
style.textContent = style.textContent =
'@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' + '@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
'.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' + '.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' +
'@media (prefers-reduced-motion:reduce){.kbn-ls-spinner{animation:none}}'; // Ken Burns — медленный pan+zoom фона (задача 05).
'@keyframes kbn-ls-kenburns{' +
'0%{transform:scale(1.0) translate3d(0,0,0)}' +
'50%{transform:scale(1.10) translate3d(-3%,-2%,0)}' +
'100%{transform:scale(1.0) translate3d(-6%,0,0)}}' +
'.kbn-ls-kenburns{animation:kbn-ls-kenburns 24s ease-in-out infinite}' +
// particles — медленно всплывающие искры.
'@keyframes kbn-ls-rise{' +
'0%{transform:translateY(0) scale(1);opacity:0}' +
'10%{opacity:0.9}' +
'90%{opacity:0.7}' +
'100%{transform:translateY(-110vh) scale(1.4);opacity:0}}' +
'.kbn-ls-particle{animation:kbn-ls-rise linear infinite}' +
// лёгкий «дыхательный» glow карточки-превью.
'@keyframes kbn-ls-cardglow{' +
'0%,100%{box-shadow:0 18px 60px rgba(0,0,0,0.6),0 0 0 rgba(120,160,255,0)}' +
'50%{box-shadow:0 18px 60px rgba(0,0,0,0.6),0 0 40px rgba(120,160,255,0.35)}}' +
'.kbn-ls-cardglow{animation:kbn-ls-cardglow 4s ease-in-out infinite}' +
'@media (prefers-reduced-motion:reduce){.kbn-ls-spinner,.kbn-ls-kenburns,.kbn-ls-particle,.kbn-ls-cardglow{animation:none}}';
document.head.appendChild(style); document.head.appendChild(style);
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@ -49,14 +67,17 @@ export class LoadingScreenOverlay {
// Мост наружу (GameRuntime подписывает) — id-based колбэки. // Мост наружу (GameRuntime подписывает) — id-based колбэки.
this._onSkipCb = null; // (id) => void this._onSkipCb = null; // (id) => void
this._onCompleteCb = null; // (id) => void this._onCompleteCb = null; // (id) => void
this._onHideCb = null; // () => void — задача 05 (game.loading.onHide)
this._parallaxHandler = null;
// DOM-ссылки активного экрана: // DOM-ссылки активного экрана:
this._els = null; this._els = null;
} }
/** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */ /** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */
setBridge(onSkip, onComplete) { setBridge(onSkip, onComplete, onHide) {
this._onSkipCb = onSkip; this._onSkipCb = onSkip;
this._onCompleteCb = onComplete; this._onCompleteCb = onComplete;
if (onHide) this._onHideCb = onHide;
} }
/** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */ /** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */
@ -104,6 +125,15 @@ export class LoadingScreenOverlay {
logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12, logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12,
// Текст под картинкой // Текст под картинкой
text: opts.text != null ? String(opts.text) : '', text: opts.text != null ? String(opts.text) : '',
// --- Задача 05: Ken-Burns фон + карточка места ---
// style: 'ken-burns' | 'static' | 'parallax' | 'particles'
style: opts.style || cfg.style || 'ken-burns',
// фоновое размытое изображение (на весь экран); резолвится в _resolveCover.
background: opts.background != null ? opts.background : (cfg.background || null),
// карточка-витрина по центру (название места + автор), как в Roblox.
placeName: opts.placeName != null ? String(opts.placeName) : (cfg.placeName || ''),
studioName: opts.studioName != null ? String(opts.studioName) : (cfg.studioName || ''),
verified: opts.verified != null ? !!opts.verified : !!cfg.verified,
// Поведение // Поведение
blockInput: opts.blockInput !== false, blockInput: opts.blockInput !== false,
pauseSimulation: opts.pauseSimulation !== false, pauseSimulation: opts.pauseSimulation !== false,
@ -163,20 +193,107 @@ export class LoadingScreenOverlay {
// (используем opacity всего root для fade, а bgOpacity — через rgba фон): // (используем opacity всего root для fade, а bgOpacity — через rgba фон):
root.style.background = this._bgRgba(st.bgColor, st.bgOpacity); root.style.background = this._bgRgba(st.bgColor, st.bgOpacity);
// --- Cover (картинка по центру) --- // --- Фоновый слой (Ken Burns / parallax / static) ---
// Размытое изображение игры на весь экран. Отдельный div под контентом,
// чтобы blur/анимация не трогали карточку и текст.
const bgUrl = this._resolveCover(st.background);
const bgLayer = document.createElement('div');
let bgClass = '';
if (bgUrl) {
if (st.style === 'ken-burns') bgClass = 'kbn-ls-kenburns';
bgLayer.className = bgClass;
bgLayer.style.cssText =
'position:absolute;inset:-8%;z-index:0;background-size:cover;background-position:center;' +
'filter:blur(8px) brightness(0.55);will-change:transform;' +
`background-image:url("${bgUrl}");`;
// parallax — лёгкий сдвиг по мыши (2 слоя имитируем одним + transform).
if (st.style === 'parallax') {
bgLayer.style.transition = 'transform 0.25s ease-out';
this._parallaxHandler = (e) => {
const cx = (e.clientX / window.innerWidth - 0.5) * 28;
const cy = (e.clientY / window.innerHeight - 0.5) * 18;
bgLayer.style.transform = `translate3d(${-cx}px,${-cy}px,0) scale(1.06)`;
};
window.addEventListener('mousemove', this._parallaxHandler);
}
root.appendChild(bgLayer);
}
// --- particles слой (медленные искры) ---
if (st.style === 'particles') {
const pLayer = document.createElement('div');
pLayer.style.cssText = 'position:absolute;inset:0;z-index:1;overflow:hidden;pointer-events:none;';
for (let i = 0; i < 26; i++) {
const sp = document.createElement('span');
sp.className = 'kbn-ls-particle';
const size = 2 + Math.round(Math.random() * 4);
const dur = 7 + Math.random() * 10;
sp.style.cssText =
`position:absolute;bottom:-10px;left:${(Math.random() * 100).toFixed(1)}%;` +
`width:${size}px;height:${size}px;border-radius:50%;` +
`background:rgba(${180 + Math.round(Math.random() * 70)},${190 + Math.round(Math.random() * 60)},255,0.85);` +
`box-shadow:0 0 ${size * 2}px rgba(140,170,255,0.7);` +
`animation-duration:${dur.toFixed(1)}s;animation-delay:${(-Math.random() * dur).toFixed(1)}s;`;
pLayer.appendChild(sp);
}
root.appendChild(pLayer);
}
// Обёртка контента (над фоном).
const content = document.createElement('div');
content.style.cssText =
'position:relative;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;height:100%;';
// --- Cover (картинка-карточка по центру) ---
const coverUrl = this._resolveCover(cover); const coverUrl = this._resolveCover(cover);
// Режим карточки места (задача 05): квадрат + название + автор под ней.
const hasPlaceCard = !!(st.placeName || st.studioName);
const coverImg = document.createElement('div'); const coverImg = document.createElement('div');
if (hasPlaceCard) {
coverImg.className = 'kbn-ls-cardglow';
coverImg.style.cssText =
'width:min(42vw,400px);aspect-ratio:1/1;border-radius:18px;' +
'background-size:cover;background-position:center;background-color:#1a1f2b;' +
'border:2px solid rgba(255,255,255,0.12);';
} else {
coverImg.style.cssText = coverImg.style.cssText =
'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' + 'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' +
'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' + 'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' +
'background-color:#1a1f2b;margin-bottom:140px;'; 'background-color:#1a1f2b;margin-bottom:140px;';
}
if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`; if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`;
else if (bgUrl && hasPlaceCard) coverImg.style.backgroundImage = `url("${bgUrl}")`;
// --- Текст под картинкой --- // --- Название места (крупный белый, под карточкой) ---
const placeEl = document.createElement('div');
placeEl.style.cssText =
'margin-top:22px;color:#fff;font-size:38px;font-weight:800;letter-spacing:0.5px;' +
'text-align:center;text-shadow:0 3px 14px rgba(0,0,0,0.7);' +
(st.placeName ? '' : 'display:none;');
placeEl.textContent = st.placeName || '';
// --- Автор + verified-галочка ---
const studioRow = document.createElement('div');
studioRow.style.cssText =
'margin-top:8px;display:flex;align-items:center;gap:7px;' +
'color:#cdd6e6;font-size:16px;font-weight:600;text-shadow:0 1px 4px rgba(0,0,0,0.6);' +
(st.studioName ? '' : 'display:none;');
const studioTxt = document.createElement('span');
studioTxt.textContent = st.studioName || '';
studioRow.appendChild(studioTxt);
if (st.verified) studioRow.appendChild(this._buildVerifiedBadge());
// --- Текст под картинкой (для не-карточного режима / mid-game) ---
const textEl = document.createElement('div'); const textEl = document.createElement('div');
if (hasPlaceCard) {
textEl.style.cssText =
'margin-top:14px;color:#e8edf5;font-size:16px;font-weight:500;text-align:center;' +
'text-shadow:0 1px 3px rgba(0,0,0,0.6);' + (st.text ? '' : 'display:none;');
} else {
textEl.style.cssText = textEl.style.cssText =
'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' + 'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' +
'color:#e8edf5;font-size:18px;font-weight:500;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,0.6);'; 'color:#e8edf5;font-size:18px;font-weight:500;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,0.6);';
}
textEl.textContent = st.text || ''; textEl.textContent = st.text || '';
// --- Прогресс-бар --- // --- Прогресс-бар ---
@ -245,8 +362,13 @@ export class LoadingScreenOverlay {
spinWrap.appendChild(spinTxt); spinWrap.appendChild(spinTxt);
spinWrap.appendChild(spinCircle); spinWrap.appendChild(spinCircle);
root.appendChild(coverImg); // Центральная композиция (карточка + название + автор + текст) — в content.
root.appendChild(textEl); content.appendChild(coverImg);
content.appendChild(placeEl);
content.appendChild(studioRow);
content.appendChild(textEl);
root.appendChild(content);
// Нижние/угловые элементы — абсолютно на root (поверх фона, рядом с content).
root.appendChild(barWrap); root.appendChild(barWrap);
root.appendChild(percent); root.appendChild(percent);
root.appendChild(skipBtn); root.appendChild(skipBtn);
@ -255,7 +377,19 @@ export class LoadingScreenOverlay {
parent.appendChild(root); parent.appendChild(root);
this.root = root; this.root = root;
this._els = { root, coverImg, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap }; this._els = { root, content, coverImg, placeEl, studioRow, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap };
}
/** Verified-галочка (синий круг + белый чек) — inline SVG, без emoji-шрифта. */
_buildVerifiedBadge() {
const wrap = document.createElement('span');
wrap.style.cssText = 'display:inline-flex;align-items:center;';
wrap.innerHTML =
'<svg width="18" height="18" viewBox="0 0 24 24" aria-label="verified">' +
'<circle cx="12" cy="12" r="11" fill="#3897f0"/>' +
'<path d="M7 12.5l3 3 7-7" fill="none" stroke="#fff" stroke-width="2.4" ' +
'stroke-linecap="round" stroke-linejoin="round"/></svg>';
return wrap;
} }
/** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */ /** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */
@ -329,6 +463,23 @@ export class LoadingScreenOverlay {
if (url) this._els.coverImg.style.backgroundImage = `url("${url}")`; if (url) this._els.coverImg.style.backgroundImage = `url("${url}")`;
} }
/** Задача 05: сменить фоновое (Ken-Burns) изображение на лету. */
setBackground(bg) {
if (!this._st || !this._els) return;
const url = this._resolveCover(bg);
if (!url) return;
this._st.background = bg;
// фоновый слой — первый ребёнок root с background-image; найдём его.
const layer = this._els.root.querySelector('.kbn-ls-kenburns')
|| this._els.root.firstElementChild;
if (layer && layer !== this._els.content) layer.style.backgroundImage = `url("${url}")`;
}
/** Задача 05: виден ли экран сейчас. */
isVisible() {
return !!(this._st && this._st.phase !== 'out');
}
/** Закрыть программно (с fadeOut). */ /** Закрыть программно (с fadeOut). */
close() { close() {
const st = this._st; const st = this._st;
@ -361,6 +512,13 @@ export class LoadingScreenOverlay {
if (st.blockInput) { try { this.s.player?.setInputBlocked?.(false); } catch { /* ignore */ } } if (st.blockInput) { try { this.s.player?.setInputBlocked?.(false); } catch { /* ignore */ } }
if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = false; } catch { /* ignore */ } } if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = false; } catch { /* ignore */ } }
} }
// Снять parallax-listener (задача 05).
if (this._parallaxHandler) {
try { window.removeEventListener('mousemove', this._parallaxHandler); } catch { /* ignore */ }
this._parallaxHandler = null;
}
// onHide-мост (задача 05) — сообщаем скриптам что экран скрылся.
if (this._onHideCb) { try { this._onHideCb(); } catch { /* ignore */ } }
if (this.root) { try { this.root.remove(); } catch { /* ignore */ } } if (this.root) { try { this.root.remove(); } catch { /* ignore */ } }
this.root = null; this.root = null;
this._els = null; this._els = null;

View File

@ -526,6 +526,9 @@ export class ModelManager {
opacity: typeof data.opacity === 'number' ? data.opacity : 1, opacity: typeof data.opacity === 'number' ? data.opacity : 1,
tint: data.tint || null, tint: data.tint || null,
name: data.name || null, name: data.name || null,
// folderId — принадлежность к папке (иначе модели вываливаются
// из папки после Play/Stop). Баг 2026-06-05.
...(data.folderId != null ? { folderId: data.folderId } : {}),
// Параметры геймплея (HP, скорость врага, лимит спавнера и т.п.) // Параметры геймплея (HP, скорость врага, лимит спавнера и т.п.)
gameplayParams: data.gameplayParams || null, gameplayParams: data.gameplayParams || null,
}); });
@ -768,6 +771,7 @@ export class ModelManager {
if (m.tint) data.tint = m.tint; if (m.tint) data.tint = m.tint;
if (m.name) data.name = m.name; if (m.name) data.name = m.name;
if (m.gameplayParams) data.gameplayParams = m.gameplayParams; if (m.gameplayParams) data.gameplayParams = m.gameplayParams;
if (m.folderId != null) data.folderId = m.folderId; // восстановить папку
if (data.opacity != null || data.tint) this._applyMaterialOverrides(data); if (data.opacity != null || data.tint) this._applyMaterialOverrides(data);
} }
// Гарантируем что _nextInstanceId стоит ПОСЛЕ максимального восстановленного id — // Гарантируем что _nextInstanceId стоит ПОСЛЕ максимального восстановленного id —

View File

@ -161,6 +161,20 @@ export class NpcManager {
r15Animator, r15Animator,
}; };
this.npcs.set(id, npc); this.npcs.set(id, npc);
// Пометить меши NPC для попаданий оружия (бластер/меч): pickable + npcId
// в metadata. Без pickable raycast оружия проходит сквозь NPC и урон/
// floater'ы не срабатывают (задача 40).
try {
const root = npc.data && npc.data.rootMesh;
if (root) {
root.isPickable = true;
root.metadata = Object.assign({}, root.metadata, { npcId: id });
for (const m of root.getChildMeshes(false)) {
m.isPickable = true;
m.metadata = Object.assign({}, m.metadata, { npcId: id });
}
}
} catch (e) { /* ignore */ }
return id; return id;
} }
@ -274,6 +288,12 @@ export class NpcManager {
npc.isMoving = false; npc.isMoving = false;
} }
/** Включить/выключить анимацию атаки (R15-NPC машет руками). */
setAttacking(id, on) {
const npc = this.npcs.get(Number(id));
if (npc) npc.attacking = !!on;
}
/** Реплика над головой NPC на duration секунд. */ /** Реплика над головой NPC на duration секунд. */
say(id, text, duration = 3) { say(id, text, duration = 3) {
const npc = this.npcs.get(Number(id)); const npc = this.npcs.get(Number(id));
@ -286,10 +306,43 @@ export class NpcManager {
damage(id, amount) { damage(id, amount) {
const npc = this.npcs.get(Number(id)); const npc = this.npcs.get(Number(id));
if (!npc || npc.dead) return; if (!npc || npc.dead) return;
npc.hp = Math.max(0, npc.hp - (Number(amount) || 0)); const amt = Number(amount) || 0;
npc.hp = Math.max(0, npc.hp - amt);
// Авто-floater над мобом (задача 40 доп): game.fx.autoMobFloaters(true).
if (this._autoFloater && amt > 0 && this.scene3d?.floaters) {
try {
this.scene3d.floaters.spawn(
{ x: npc.x, y: (npc.y || 0) + 2.2, z: npc.z }, amt, this._autoFloater.opts || {});
} catch (e) { /* ignore */ }
}
if (npc.hp <= 0) this._killNpc(npc); if (npc.hp <= 0) this._killNpc(npc);
} }
/** Нанести урон NPC по мешу-попаданию (бластер/оружие). Ищет NPC, чьи меши
* содержат hit-меш (или предка). Вызывает damage() авто-floater. */
damageByMesh(mesh, amount) {
if (!mesh) return false;
// 1) Быстрый путь: npcId в metadata меша (или предка).
let m = mesh;
for (let i = 0; i < 8 && m; i++) {
const nid = m.metadata && m.metadata.npcId;
if (nid != null && this.npcs.has(nid)) { this.damage(nid, amount); return true; }
m = m.parent;
}
// 2) Fallback: сравнение с rootMesh по иерархии.
for (const npc of this.npcs.values()) {
if (npc.dead) continue;
const root = npc.data && npc.data.rootMesh;
if (!root) continue;
let mm = mesh;
for (let i = 0; i < 8 && mm; i++) {
if (mm === root) { this.damage(npc.id, amount); return true; }
mm = mm.parent;
}
}
return false;
}
/** Удалить NPC по id (без эффекта смерти — просто убрать). */ /** Удалить NPC по id (без эффекта смерти — просто убрать). */
removeNpc(id) { removeNpc(id) {
const npc = this.npcs.get(Number(id)); const npc = this.npcs.get(Number(id));
@ -390,17 +443,23 @@ export class NpcManager {
if (root._isWorldMatrixFrozen) { if (root._isWorldMatrixFrozen) {
try { root.unfreezeWorldMatrix(); } catch (e) {} try { root.unfreezeWorldMatrix(); } catch (e) {}
} }
root.position.set(npc.x, npc.y, npc.z); // Анимация ходьбы — процедурное покачивание (у Kenney-моделей нет
// скелета). Подпрыгивание по Y + лёгкое раскачивание корпуса.
if (moving) npc.walkPhase += dt * 10;
let bobY = 0, lean = 0;
if (moving && !npc.r15Animator) {
bobY = Math.abs(Math.sin(npc.walkPhase)) * 0.12; // шаги вверх-вниз
lean = Math.sin(npc.walkPhase) * 0.08; // покачивание
}
root.position.set(npc.x, npc.y + bobY, npc.z);
root.rotation.y = npc.yaw; root.rotation.y = npc.yaw;
root.rotation.z = lean;
// data.x/y/z — чтобы scene.find/getPosition видели NPC. // data.x/y/z — чтобы scene.find/getPosition видели NPC.
data.x = npc.x; data.y = npc.y; data.z = npc.z; data.x = npc.x; data.y = npc.y; data.z = npc.z;
// R15-NPC (skin_*): процедурная анимация бега/покоя/атаки через R15Animator.
// Анимация ходьбы — простое покачивание (без R15-скелета у Kenney).
if (moving) npc.walkPhase += dt * 6;
// R15-NPC (skin_*): процедурная анимация бега/покоя через R15Animator.
if (npc.r15Animator) { if (npc.r15Animator) {
try { try {
npc.r15Animator.setState(moving ? 'run' : 'idle'); npc.r15Animator.setState(npc.attacking ? 'attack' : (moving ? 'run' : 'idle'));
npc.r15Animator.update(dt); npc.r15Animator.update(dt);
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
} }

View File

@ -186,6 +186,10 @@ export class PrimitiveManager {
primitiveId: id, primitiveId: id,
primitiveType: type, primitiveType: type,
primitiveKind: typeDef.kind, primitiveKind: typeDef.kind,
// canCollide в metadata нужен camera-clamp (PlayerController):
// без него камера 3-го лица цепляется за проходимые зоны/триггеры
// (canCollide:false) и прыгает к игроку внутри зоны. Баг 2026-06-05.
canCollide,
}; };
// textureAsset — id картинки из AssetManager (пользовательская // textureAsset — id картинки из AssetManager (пользовательская
@ -754,7 +758,10 @@ export class PrimitiveManager {
this._applyMaterial(data.mesh, typeDef, data.color, data.material); this._applyMaterial(data.mesh, typeDef, data.color, data.material);
} }
if (patch.canCollide !== undefined) data.canCollide = patch.canCollide; if (patch.canCollide !== undefined) {
data.canCollide = patch.canCollide;
if (data.mesh?.metadata) data.mesh.metadata.canCollide = patch.canCollide;
}
if (patch.locked !== undefined) data.locked = !!patch.locked; if (patch.locked !== undefined) data.locked = !!patch.locked;
if (patch.visible !== undefined) { if (patch.visible !== undefined) {
data.visible = patch.visible; data.visible = patch.visible;
@ -938,6 +945,9 @@ export class PrimitiveManager {
anchored: d.anchored, anchored: d.anchored,
mass: d.mass, mass: d.mass,
name: d.name || null, name: d.name || null,
// folderId — принадлежность к папке. БЕЗ него примитивы вываливались
// из папки после Play/Stop (снапшот терял группировку). Баг 2026-06-05.
...(d.folderId != null ? { folderId: d.folderId } : {}),
// locked — защита от выделения/перемещения (Фаза 5.11). // locked — защита от выделения/перемещения (Фаза 5.11).
...(d.locked ? { locked: true } : {}), ...(d.locked ? { locked: true } : {}),
// id пользовательской текстуры (картинка из AssetManager). // id пользовательской текстуры (картинка из AssetManager).

View File

@ -131,6 +131,23 @@ const ANIMS_STD = {
{ bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: 10, { bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: 10,
times: [0.0, 0.4, 0.8], values: [0.0, 1.0, 0.0] }, times: [0.0, 0.4, 0.8], values: [0.0, 1.0, 0.0] },
]), ]),
// Удар правой рукой вперёд (для враждебных NPC). loop=true — постоянно
// машет, пока NPC в режиме атаки.
attack: makeAnim(0.5, true, [
// Правая рука выбрасывается вперёд (замах назад → удар).
{ bone: B.RIGHT_ARM, axis: AXIS_RIGHT, angleDeg: -95,
times: [0.0, 0.15, 0.3, 0.5], values: [0.3, 1.0, 0.2, 0.3] },
{ bone: B.RIGHT_FOREARM, axis: AXIS_RIGHT, angleDeg: -50,
times: [0.0, 0.15, 0.3, 0.5], values: [1.0, 0.2, 1.0, 1.0] },
// Левая рука тоже в боевой стойке.
{ bone: B.LEFT_ARM, axis: AXIS_RIGHT, angleDeg: -45,
times: [0.0, 0.5], values: [1.0, 1.0] },
{ bone: B.LEFT_FOREARM, axis: AXIS_RIGHT, angleDeg: -70,
times: [0.0, 0.5], values: [1.0, 1.0] },
// Корпус подаётся в удар.
{ bone: B.SPINE, axis: AXIS_RIGHT, angleDeg: -12,
times: [0.0, 0.15, 0.3, 0.5], values: [0.0, 1.0, 0.3, 0.0] },
]),
// === ЭМОЦИИ (game.player.playAnimation) === // === ЭМОЦИИ (game.player.playAnimation) ===
// Разовые анимации поверх авто-состояния. loop=false — играют один раз, // Разовые анимации поверх авто-состояния. loop=false — играют один раз,

View File

@ -70,10 +70,16 @@ let _toolUseHandlers = [];
// При toolUse-событии воркер сначала вызывает per-tool колбэк, потом глобальные. // При toolUse-событии воркер сначала вызывает per-tool колбэк, потом глобальные.
let _toolCallbacks = {}; // { 'custom:1': { activated: fn, equipped: fn, unequipped: fn } } let _toolCallbacks = {}; // { 'custom:1': { activated: fn, equipped: fn, unequipped: fn } }
let _toolSeq = 0; let _toolSeq = 0;
// Задача 05: зеркало видимости экрана загрузки (для game.loading.isVisible()).
let _loadingVisible = false;
// Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }. // Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }.
let _players = { me: null, list: [] }; let _players = { me: null, list: [] };
// Общее состояние комнаты game.room.get/set — зеркало из main thread. // Общее состояние комнаты game.room.get/set — зеркало из main thread.
let _roomState = {}; let _roomState = {};
// Задача 20: зеркала лидербордов/достижений для синхронного get/has в скриптах.
let _lsMirror = {}; // { playerId: { statName: value } }
let _achUnlocked = {}; // { id: true }
let _lsChangeHandlers = []; // game.leaderstats.onChange подписки
// Подписки game.onPlayerJoin / onPlayerLeave / game.onMessage(name). // Подписки game.onPlayerJoin / onPlayerLeave / game.onMessage(name).
let _playerJoinHandlers = []; let _playerJoinHandlers = [];
let _playerLeaveHandlers = []; let _playerLeaveHandlers = [];
@ -229,6 +235,10 @@ function _makeNpcProxy(ref) {
damage(amount) { damage(amount) {
_send('npc.damage', { ref, amount: Number(amount) || 0 }); _send('npc.damage', { ref, amount: Number(amount) || 0 });
}, },
/** Включить/выключить анимацию атаки (удары руками). */
setAttacking(on) {
_send('npc.setAttacking', { ref, on: !!on });
},
/** Убрать NPC со сцены. */ /** Убрать NPC со сцены. */
remove() { remove() {
_send('npc.remove', { ref }); _send('npc.remove', { ref });
@ -554,6 +564,12 @@ function _getOrCreateInstance(ref, kindHint) {
_send('scene.setColor', { ref, color: String(value) }); _send('scene.setColor', { ref, color: String(value) });
return true; return true;
} }
if (prop === 'scale') {
// Равномерный визуальный масштаб объекта (1 = исходный размер).
const k = Number(value);
if (Number.isFinite(k) && k >= 0) _send('scene.setScale', { ref, scale: k });
return true;
}
if (prop === 'transparency' || prop === 'opacity') { if (prop === 'transparency' || prop === 'opacity') {
const v = Number(value); const v = Number(value);
if (Number.isFinite(v)) { if (Number.isFinite(v)) {
@ -742,6 +758,51 @@ function _buildSelfApi() {
_send('self.move', { target: _target, x: nx, y: ny, z: nz }); _send('self.move', { target: _target, x: nx, y: ny, z: nz });
} }
}, },
/**
* Повернуть объект-носитель вокруг вертикальной оси Y на угол ry (радианы).
* game.onTick((dt) => { a += dt; game.self.rotate(a); });
*/
rotate(ry) {
const r = Number(ry);
if (!Number.isFinite(r)) return;
const k = _target.kind;
const id = _target.id ?? _target.ref;
_send('scene.rotate', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, rotationY: r });
},
rotateY(ry) { this.rotate(ry); },
/** Показать/скрыть объект-носитель. */
setVisible(vis) {
const k = _target.kind;
const id = _target.id ?? _target.ref;
_send('scene.setVisible', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, visible: !!vis });
},
/** Включить/выключить столкновения объекта-носителя (проходимость). */
setCollide(can) {
const k = _target.kind;
const id = _target.id ?? _target.ref;
_send('scene.setCollide', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, canCollide: !!can });
},
/** Перекрасить объект-носитель (только примитив). */
setColor(hex) {
if (typeof hex !== 'string') return;
const k = _target.kind;
const id = _target.id ?? _target.ref;
_send('scene.setColor', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, color: hex });
},
/** Повесить текст-метку над объектом-носителем (имя/HP). */
setLabel(text, opts) {
const k = _target.kind;
const id = _target.id ?? _target.ref;
const ref = (k && id != null) ? (k + ':' + id) : undefined;
_send('scene.setLabel', { ref, text: String(text == null ? '' : text), opts: opts || {} });
},
/** Убрать метку с объекта-носителя. */
clearLabel() {
const k = _target.kind;
const id = _target.id ?? _target.ref;
const ref = (k && id != null) ? (k + ':' + id) : undefined;
_send('scene.clearLabel', { ref });
},
delete() { delete() {
_send('self.delete', { target: _target }); _send('self.delete', { target: _target });
}, },
@ -1966,6 +2027,32 @@ const game = {
const bag = _dataIndex[r]; const bag = _dataIndex[r];
return bag ? bag[key] : undefined; return bag ? bag[key] : undefined;
}, },
// === Небо и атмосфера (задача 16) ===
/**
* Установить небо. Либо пресет, либо ручной gradient:
* game.scene.setSkybox({ preset: 'lowpoly-roblox' });
* game.scene.setSkybox({ mode:'gradient', topColor:'#4a90e2', bottomColor:'#cfd8dc' });
* Пресеты: clear-summer-day / lowpoly-roblox / cloudy / sunset / starry-night / space.
*/
setSkybox(opts) { _send('scene.setSkybox', { opts: opts || {} }); },
/**
* Облака поверх неба:
* game.scene.setClouds({ enabled:true, cover:0.5, speed:0.02, color:'#ffffff' });
*/
setClouds(opts) { _send('scene.setClouds', { opts: opts || {} }); },
/**
* Атмосферный туман:
* game.scene.setFog({ color:'#dddddd', density:0.005 });
* game.scene.setFog({ enabled:false });
*/
setFog(opts) { _send('scene.setFog', { opts: opts || {} }); },
/** Объект управления небом: плавный переход + солнце. */
skybox: {
/** Плавный переход к пресету за N секунд: skybox.fadeTo({preset:'sunset'}, 2). */
fadeTo(opts, durationSec) { _send('scene.skyboxFadeTo', { opts: opts || {}, duration: Number(durationSec) || 2 }); },
/** Направление солнца (для анимации дуги): setSunDirection({x,y,z}). */
setSunDirection(dir) { _send('scene.skyboxSunDir', { dir: dir || {} }); },
},
/** /**
* Теги объектов (Фаза 5.6) как CollectionService в Roblox. * Теги объектов (Фаза 5.6) как CollectionService в Roblox.
* Помечаешь объекты тегом, потом находишь все объекты с тегом. * Помечаешь объекты тегом, потом находишь все объекты с тегом.
@ -2508,6 +2595,10 @@ const game = {
opts = opts && typeof opts === 'object' ? opts : {}; opts = opts && typeof opts === 'object' ? opts : {};
this._opts = opts; this._opts = opts;
this._active = true; this._active = true;
// Колбэки можно передавать прямо в опциях show({ onPlay, onShow, onHide }).
if (typeof opts.onPlay === 'function') this._onPlay.push(opts.onPlay);
if (typeof opts.onShow === 'function') this._onShow.push(opts.onShow);
if (typeof opts.onHide === 'function') this._onHide.push(opts.onHide);
// 1) Заблокировать управление игроком (наблюдатель). // 1) Заблокировать управление игроком (наблюдатель).
_send('player.setInputBlocked', { blocked: true }); _send('player.setInputBlocked', { blocked: true });
game.hud.setVisible(false); game.hud.setVisible(false);
@ -2943,6 +3034,7 @@ const game = {
_localSeq: 0, _localSeq: 0,
_localToReal: new Map(), // localId → реальный loadingId (приходит в loadingShown) _localToReal: new Map(), // localId → реальный loadingId (приходит в loadingShown)
_handlers: new Map(), // localId → { onSkip:[], onComplete:[] } _handlers: new Map(), // localId → { onSkip:[], onComplete:[] }
_onHide: [], // задача 05 — глобальные подписки на скрытие
show(opts) { show(opts) {
opts = opts && typeof opts === 'object' ? opts : {}; opts = opts && typeof opts === 'object' ? opts : {};
const localId = ++this._localSeq; const localId = ++this._localSeq;
@ -2961,11 +3053,27 @@ const game = {
setProgress(v) { _send('loading.setProgress', { localId, value: Number(v) || 0 }); }, setProgress(v) { _send('loading.setProgress', { localId, value: Number(v) || 0 }); },
setText(t) { _send('loading.setText', { localId, text: String(t == null ? '' : t) }); }, setText(t) { _send('loading.setText', { localId, text: String(t == null ? '' : t) }); },
setCover(c) { _send('loading.setCover', { localId, cover: c }); }, setCover(c) { _send('loading.setCover', { localId, cover: c }); },
setBackground(b) { _send('loading.setBackground', { localId, background: b }); },
close() { _send('loading.close', { localId }); }, close() { _send('loading.close', { localId }); },
onSkip(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onSkip.push(fn); }, onSkip(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onSkip.push(fn); },
onComplete(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onComplete.push(fn); }, onComplete(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onComplete.push(fn); },
}; };
}, },
// --- Задача 05: управление активным экраном без хэндла (стартовый/любой текущий) ---
/** Подписаться на скрытие экрана загрузки. */
onHide(fn) { if (typeof fn === 'function') this._onHide.push(fn); },
/** Сменить фоновое изображение текущего экрана. */
setBackground(b) { _send('loading.setBackground', { background: b }); },
/** Сменить текст текущего экрана. */
setText(t) { _send('loading.setText', { text: String(t == null ? '' : t) }); },
/** Сменить cover текущего экрана. */
setCover(c) { _send('loading.setCover', { cover: c }); },
/** Ручной прогресс текущего экрана. */
setProgress(v) { _send('loading.setProgress', { value: Number(v) || 0 }); },
/** Скрыть текущий экран. */
hide() { _send('loading.close', {}); },
/** Виден ли экран сейчас (синхронно из локального зеркала). */
isVisible() { return !!_loadingVisible; },
/** Типовой переход с фейковым прогрессом. Возвращает Promise (резолв по complete/skip). */ /** Типовой переход с фейковым прогрессом. Возвращает Promise (резолв по complete/skip). */
transition(opts) { transition(opts) {
opts = opts && typeof opts === 'object' ? { ...opts } : {}; opts = opts && typeof opts === 'object' ? { ...opts } : {};
@ -3083,6 +3191,28 @@ const game = {
clear() { clear() {
_send('inventory.clear', {}); _send('inventory.clear', {});
}, },
// === Задача 44: drag-drop инвентарь (сетка 8×5 + hotbar 9 + стаки) ===
/** Добавить предмет по itemId со стаком. game.inventory.give('berry', 5). */
give(itemId, count) { _send('inv2.add', { itemId, count: Number(count) || 1 }); },
/** Убрать N предметов по itemId. */
take(itemId, count) { _send('inv2.remove', { itemId, count: Number(count) || 1 }); },
/** Открыть/закрыть/тоггл окна инвентаря. */
open() { _send('inv2.open', {}); },
closeUi() { _send('inv2.close', {}); },
toggle() { _send('inv2.toggle', {}); },
/** Сортировать (by: 'rarity'|'name'). */
sort(by) { _send('inv2.sort', { by: by || 'rarity' }); },
/** Активный слот хотбара (0..8). */
setActiveHotbar(i) { _send('inv2.setActive', { i: Number(i) || 0 }); },
},
// === Определения предметов (задача 44) ===
items: {
/** Зарегистрировать предмет: {id,name,emoji|icon,rarity,maxStack,description,value,tags,onUseEffect}. */
define(def) {
if (Array.isArray(def)) { for (const d of def) _send('items.define', { def: d }); return; }
_send('items.define', { def: def || {} });
},
}, },
/** /**
* Игроки комнаты (Фаза 4.3 мультиплеер). * Игроки комнаты (Фаза 4.3 мультиплеер).
@ -3110,6 +3240,62 @@ const game = {
return p ? { ...p } : null; return p ? { ...p } : null;
}, },
}, },
// === Лидерборды (leaderstats) — задача 20 ===
leaderstats: {
/** Зарегистрировать стат: define('Монеты', {initial,format,icon,color,primary}). */
define(name, opts) {
if (typeof name !== 'string' || !name) return;
_send('leaderstats.define', { name, opts: opts || {} });
},
/** Установить стат игрока (playerId=null → текущий). */
set(playerId, name, value) {
_send('leaderstats.set', { playerId: playerId == null ? null : String(playerId), name, value: Number(value) || 0 });
const pid = playerId == null ? '@me' : String(playerId);
if (!_lsMirror[pid]) _lsMirror[pid] = {};
_lsMirror[pid][name] = Number(value) || 0;
},
/** Прибавить к стату. */
add(playerId, name, delta) {
_send('leaderstats.add', { playerId: playerId == null ? null : String(playerId), name, delta: Number(delta) || 0 });
const pid = playerId == null ? '@me' : String(playerId);
if (!_lsMirror[pid]) _lsMirror[pid] = {};
_lsMirror[pid][name] = (_lsMirror[pid][name] || 0) + (Number(delta) || 0);
},
/** Прочитать стат (из локального зеркала). */
get(playerId, name) {
const pid = playerId == null ? '@me' : String(playerId);
return (_lsMirror[pid] && _lsMirror[pid][name]) || 0;
},
/** Подписка на изменение: onChange((playerId, name, newVal, oldVal) => {}). */
onChange(fn) { if (typeof fn === 'function') _lsChangeHandlers.push(fn); },
/** Шорткат для текущего игрока. */
me: {
set(name, value) { game.leaderstats.set(null, name, value); },
add(name, delta) { game.leaderstats.add(null, name, delta); },
get(name) { return game.leaderstats.get(null, name); },
},
},
// === Достижения — задача 20 ===
achievements: {
/** Объявить достижения: define([{id,name,description,icon,rarity,points,hidden}]). */
define(list) { _send('achievements.define', { list: Array.isArray(list) ? list : [list] }); },
/** Разблокировать достижение. */
unlock(id, playerId) {
if (typeof id !== 'string') return;
_achUnlocked[id] = true;
_send('achievements.unlock', { id, playerId: playerId == null ? null : String(playerId) });
},
/** Разблокировано ли (из зеркала). */
has(id) { return !!_achUnlocked[id]; },
/** Авто-unlock по достижению значения leaderstat. */
bindToStat(id, statName, cond) { _send('achievements.bindToStat', { id, statName, cond: cond || {} }); },
/** Показать/скрыть кнопку-кубок. */
setButtonVisible(v) { _send('achievements.setButtonVisible', { visible: !!v }); },
/** Открыть страницу достижений. */
openPage() { _send('achievements.openPage', {}); },
},
/** /**
* Общее состояние комнаты (Фаза 4.3) данные, видимые всем игрокам. * Общее состояние комнаты (Фаза 4.3) данные, видимые всем игрокам.
* В одиночной игре работает как локальное хранилище. * В одиночной игре работает как локальное хранилище.
@ -3298,6 +3484,25 @@ const game = {
* trail шлейф за движущимся объектом. * trail шлейф за движущимся объектом.
*/ */
fx: { fx: {
/**
* Всплывающая цифра урона (задача 40). position {x,y,z} или ref
* объекта; value число или строка; opts color/isCrit/isHeal/isMana/
* isMiss/fontSize/floatHeight/lifetime/randomOffset/stackKey/comicStyle.
* game.fx.damageFloater(enemy.position, 25);
* game.fx.damageFloater(pos, 100, { isCrit: true });
* game.fx.damageFloater(pos, 30, { isHeal: true });
*/
damageFloater(position, value, opts) {
const pos = _normFxPoint(position);
_send('fx.damageFloater', { position: pos, value, opts: opts || {} });
},
/**
* Авто-floater'ы над мобами (NPC) при потере HP. Включил один раз любой
* урон по NPC сам показывает облачко «-N». game.fx.autoMobFloaters(true).
*/
autoMobFloaters(enabled, opts) {
_send('fx.autoMobFloaters', { enabled: enabled !== false, opts: opts || {} });
},
/** /**
* Луч между двумя точками. opts: { from, to {x,y,z} или ref * Луч между двумя точками. opts: { from, to {x,y,z} или ref
* объекта (тогда луч следит за ним); color: '#hex', width }. * объекта (тогда луч следит за ним); color: '#hex', width }.
@ -4197,6 +4402,15 @@ self.onmessage = (e) => {
const t = payload?.type; const t = payload?.type;
if (t === 'click') { if (t === 'click') {
for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick'); for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick');
} else if (t === 'leaderstatsChange') {
// Задача 20: стат изменился на сервере/main — обновляем зеркало + onChange.
const pid = payload.playerId == null ? '@me' : String(payload.playerId);
if (!_lsMirror[pid]) _lsMirror[pid] = {};
_lsMirror[pid][payload.name] = payload.newValue;
if (payload.isMe) { if (!_lsMirror['@me']) _lsMirror['@me'] = {}; _lsMirror['@me'][payload.name] = payload.newValue; }
for (const fn of _lsChangeHandlers) { try { fn(payload.playerId, payload.name, payload.newValue, payload.oldValue); } catch (err) { _send('log', { level: 'error', text: 'leaderstats.onChange: ' + (err && err.message ? err.message : err) }); } }
} else if (t === 'achievementUnlocked') {
_achUnlocked[payload.id] = true;
} else if (t === 'mouseMove') { } else if (t === 'mouseMove') {
for (const fn of _mouseMoveHandlers) { for (const fn of _mouseMoveHandlers) {
try { fn(payload.x, payload.y); } try { fn(payload.x, payload.y); }
@ -4402,6 +4616,7 @@ self.onmessage = (e) => {
} else if (t === 'loadingShown') { } else if (t === 'loadingShown') {
// Задача 12: реальный loadingId от runtime — маппим local→real, чтобы // Задача 12: реальный loadingId от runtime — маппим local→real, чтобы
// setProgress/close/колбэки нашли нужный экран. // setProgress/close/колбэки нашли нужный экран.
_loadingVisible = true;
try { try {
const lo = (typeof game !== 'undefined') && game.loading; const lo = (typeof game !== 'undefined') && game.loading;
if (lo && payload && payload.replyId) { if (lo && payload && payload.replyId) {
@ -4411,6 +4626,13 @@ self.onmessage = (e) => {
} }
} }
} catch (e) {} } catch (e) {}
} else if (t === 'loadingHidden') {
// Задача 05: экран загрузки скрылся — обновляем зеркало + onHide-подписки.
_loadingVisible = false;
try {
const lo = (typeof game !== 'undefined') && game.loading;
if (lo) for (const fn of (lo._onHide || [])) _safeCall(fn, undefined, 'loading.onHide');
} catch (e) {}
} else if (t === 'loadingSkip' || t === 'loadingComplete') { } else if (t === 'loadingSkip' || t === 'loadingComplete') {
// Игрок нажал «Пропустить» (skip) или прогресс достиг 100% (complete). // Игрок нажал «Пропустить» (skip) или прогресс достиг 100% (complete).
// Находим local по real loadingId и зовём соответствующие подписчики. // Находим local по real loadingId и зовём соответствующие подписчики.

View File

@ -76,24 +76,37 @@ export class SelectionManager {
selectByMesh(mesh) { selectByMesh(mesh) {
if (!mesh) return this.clear(); if (!mesh) return this.clear();
const m = mesh.metadata; const m = mesh.metadata;
// Если объект лежит в папке — клик по СЦЕНЕ выделяет ВСЮ папку целиком
// (отдельную часть можно выбрать через дерево). folderId берём из data.
const folderIdOf = (kind, id) => {
let d = null;
if (kind === 'model') d = this.modelManager?.instances?.get(id);
else if (kind === 'userModel') d = this.userModelManager?.instances?.get(id);
else if (kind === 'primitive') d = this.primitiveManager?.instances?.get(id);
return d ? (d.folderId ?? null) : null;
};
if (m?.isBlock) { if (m?.isBlock) {
return this.selectBlockAt(m.gridX, m.gridY, m.gridZ); return this.selectBlockAt(m.gridX, m.gridY, m.gridZ);
} }
if (m?.isModel) { if (m?.isModel) {
// Заблокированный объект (Фаза 5.11) не выделяется кликом по
// сцене — только через иерархию (чтобы можно было снять lock).
const md = this.modelManager?.instances?.get(m.instanceId); const md = this.modelManager?.instances?.get(m.instanceId);
if (md && md.locked) return this.clear(); if (md && md.locked) return this.clear();
const fid = folderIdOf('model', m.instanceId);
if (fid != null) return this.selectFolder(fid);
return this.selectModelByInstanceId(m.instanceId); return this.selectModelByInstanceId(m.instanceId);
} }
if (m?.isUserModel) { if (m?.isUserModel) {
const ud = this.userModelManager?.instances?.get(m.instanceId); const ud = this.userModelManager?.instances?.get(m.instanceId);
if (ud && ud.locked) return this.clear(); if (ud && ud.locked) return this.clear();
const fid = folderIdOf('userModel', m.instanceId);
if (fid != null) return this.selectFolder(fid);
return this.selectUserModelByInstanceId(m.instanceId); return this.selectUserModelByInstanceId(m.instanceId);
} }
if (m?.isPrimitive) { if (m?.isPrimitive) {
const pd = this.primitiveManager?.instances?.get(m.primitiveId); const pd = this.primitiveManager?.instances?.get(m.primitiveId);
if (pd && pd.locked) return this.clear(); if (pd && pd.locked) return this.clear();
const fid = folderIdOf('primitive', m.primitiveId);
if (fid != null) return this.selectFolder(fid);
return this.selectPrimitiveById(m.primitiveId); return this.selectPrimitiveById(m.primitiveId);
} }
if (m?.isSpawn) { if (m?.isSpawn) {
@ -116,6 +129,11 @@ export class SelectionManager {
primitiveType: data.type, primitiveType: data.type,
x: data.x, y: data.y, z: data.z, x: data.x, y: data.y, z: data.z,
sx: data.sx, sy: data.sy, sz: data.sz, sx: data.sx, sy: data.sy, sz: data.sz,
// Вращение — нужно для корректного копирования/дублирования
// (без него копия теряла поворот, баг 2026-06-04).
rotationX: data.rotationX || 0,
rotationY: data.rotationY || 0,
rotationZ: data.rotationZ || 0,
color: data.color, color: data.color,
material: data.material, material: data.material,
canCollide: data.canCollide, canCollide: data.canCollide,
@ -194,6 +212,29 @@ export class SelectionManager {
this._notifyChange(); this._notifyChange();
} }
/**
* Выделить ПАПКУ целиком: подсветить все объекты внутри + поставить
* selection.type='folder'. Групповой gizmo привязывается в BabylonScene.
*/
selectFolder(folderId) {
const fm = this._scene3d?.folderManager;
if (!fm) return;
const g = fm.getFolderObjects(folderId);
this._removeHighlight();
this._multi = [];
for (const m of g.meshes) this._highlightMesh(m);
this._selection = {
type: 'folder',
folderId,
center: g.center,
count: g.count,
meshes: g.meshes,
};
this._notifyChange();
// Поставить групповой gizmo на пивот папки.
this._scene3d?._attachFolderGizmo?.(folderId, g.center);
}
/** Выделить псевдо-объект «Пол» — настройки grid'а пола. */ /** Выделить псевдо-объект «Пол» — настройки grid'а пола. */
selectFloor() { selectFloor() {
if (!this._scene3d) return; if (!this._scene3d) return;
@ -676,10 +717,27 @@ export class SelectionManager {
this.blockManager.removeBlock(this._selection.gridX, this._selection.gridY, this._selection.gridZ); this.blockManager.removeBlock(this._selection.gridX, this._selection.gridY, this._selection.gridZ);
} else if (this._selection.type === 'model') { } else if (this._selection.type === 'model') {
this.modelManager.removeInstance(this._selection.instanceId); this.modelManager.removeInstance(this._selection.instanceId);
this.clear(); this._scene3d?._cleanupOrphanScripts?.(); return;
} else if (this._selection.type === 'userModel') { } else if (this._selection.type === 'userModel') {
this.userModelManager.removeInstance(this._selection.instanceId); this.userModelManager.removeInstance(this._selection.instanceId);
this.clear(); this._scene3d?._cleanupOrphanScripts?.(); return;
} else if (this._selection.type === 'primitive') { } else if (this._selection.type === 'primitive') {
this.primitiveManager.removeInstance(this._selection.id); this.primitiveManager.removeInstance(this._selection.id);
this.clear(); this._scene3d?._cleanupOrphanScripts?.(); return;
} else if (this._selection.type === 'spawn') {
// Удаление точки спавна → игрок будет появляться в (0, высота, 0).
this._scene3d?.deleteSpawn?.();
} else if (this._selection.type === 'folder') {
// Папка целиком — удаляем со ВСЕМ содержимым (рекурсивно).
const fid = this._selection.folderId;
this.clear();
this._scene3d?.folderManager?.removeFolder?.(fid, true);
// Удаляем скрипты, привязанные к объектам этой папки? Они привязаны
// к примитивам, которые removeFolder удалит; скрипты на них чистятся
// через _onSceneChange / при сохранении. Дополнительно пусть движок
// подчистит «осиротевшие» скрипты.
this._scene3d?._cleanupOrphanScripts?.();
return;
} }
this.clear(); this.clear();
} }

View File

@ -0,0 +1,571 @@
/**
* SkyboxManager кастомное небо для сцены (задача 16).
*
* Реализует процедурный gradient-skybox без внешних текстур (работает offline):
* - купол-сфера (BACKSIDE) с ShaderMaterial: плавный градиент верхниз,
* солнечный диск, лёгкая дымка у горизонта;
* - low-poly горы на горизонте (как в Roblox-эталоне);
* - billboard-облака (плоскости, медленный дрейф);
* - атмосферный туман (scene.fog).
*
* Пресеты: clear-summer-day / cloudy / sunset / starry-night / space /
* lowpoly-roblox (+ gradient вручную). Плавный fadeTo между ними.
*
* API (через game.scene.*):
* setSkybox({ preset } | { mode:'gradient', topColor, bottomColor, ... })
* setClouds({ enabled, cover, density, speed, color })
* setFog({ color, density, near, far } | enabled:false)
* skybox.fadeTo(opts, durationSec)
* skybox.setSunDirection({x,y,z})
*
* Фича-парность: при портировании в плеер тот же модуль в rublox-player/src/engine/.
*/
import {
Color3, Color4, Vector3,
MeshBuilder, ShaderMaterial, Effect, StandardMaterial, Texture,
DynamicTexture, VertexData, Mesh,
} from '@babylonjs/core';
// ── Шейдер градиентного неба ──────────────────────────────────────────────
// Купол изнутри: цвет интерполируется по высоте (y от -1 низ до +1 верх),
// плюс солнечный диск и осветление у горизонта (дымка).
const SKY_VERT = `
precision highp float;
attribute vec3 position;
uniform mat4 worldViewProjection;
varying vec3 vDir;
void main(void){
vDir = normalize(position);
gl_Position = worldViewProjection * vec4(position, 1.0);
}`;
const SKY_FRAG = `
precision highp float;
varying vec3 vDir;
uniform vec3 topColor;
uniform vec3 bottomColor;
uniform vec3 horizonColor;
uniform vec3 sunDir;
uniform vec3 sunColor;
uniform float sunSize; // 0..1 угловой радиус
uniform float horizonHaze; // 0..1 сила дымки у горизонта
void main(void){
float h = clamp(vDir.y * 0.5 + 0.5, 0.0, 1.0); // 0 низ .. 1 верх
// Градиент: низ→горизонт→верх (двойная интерполяция через горизонт у h~0.5)
vec3 col;
if (h < 0.5) {
col = mix(bottomColor, horizonColor, h * 2.0);
} else {
col = mix(horizonColor, topColor, (h - 0.5) * 2.0);
}
// Дымка у горизонта — осветление узкой полосы около h=0.5
float haze = smoothstep(0.5, 0.0, abs(h - 0.5)) * horizonHaze;
col = mix(col, horizonColor + vec3(0.08), haze * 0.5);
// Солнечный диск + гало
float d = distance(normalize(vDir), normalize(sunDir));
float disk = smoothstep(sunSize, sunSize * 0.4, d);
float glow = smoothstep(sunSize * 6.0, 0.0, d) * 0.35;
col += sunColor * disk;
col += sunColor * glow;
gl_FragColor = vec4(col, 1.0);
}`;
let _shaderRegistered = false;
function registerSkyShader() {
if (_shaderRegistered) return;
Effect.ShadersStore['kubikonSkyVertexShader'] = SKY_VERT;
Effect.ShadersStore['kubikonSkyFragmentShader'] = SKY_FRAG;
_shaderRegistered = true;
}
const hexToRgb = (hex) => {
if (Array.isArray(hex)) return hex;
let h = String(hex || '#ffffff').replace('#', '').trim();
// Короткая форма #fff → #ffffff.
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
if (h.length < 6) h = (h + 'ffffff').slice(0, 6);
const r = parseInt(h.substring(0, 2), 16);
const g = parseInt(h.substring(2, 4), 16);
const b = parseInt(h.substring(4, 6), 16);
return [
(Number.isFinite(r) ? r : 255) / 255,
(Number.isFinite(g) ? g : 255) / 255,
(Number.isFinite(b) ? b : 255) / 255,
];
};
// ── Пресеты неба ──────────────────────────────────────────────────────────
// top/bottom/horizon — цвета купола; sun — направление/цвет/размер солнца;
// mountains — рисовать ли горы; clouds — параметры облаков; fog — туман;
// stars — звёздное небо (для ночи/космоса).
const PRESETS = {
'clear-summer-day': {
top: '#3d7fe0', horizon: '#bcd9f2', bottom: '#dcebf7',
sunDir: [0.3, 0.85, 0.4], sunColor: '#fff6d8', sunSize: 0.035, haze: 0.6,
mountains: false,
clouds: { enabled: true, cover: 0.25, color: '#ffffff', speed: 0.015 },
fog: { color: '#cfe2f2', density: 0.0035 },
light: { sunIntensity: 1.0, sunColor: '#fff6e0', hemiIntensity: 0.7, ambient: '#b9d4ee' },
},
'lowpoly-roblox': {
top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa',
sunDir: [0.4, 0.8, 0.45], sunColor: '#fffbe6', sunSize: 0.03, haze: 0.85,
mountains: true,
clouds: { enabled: true, cover: 0.45, color: '#ffffff', speed: 0.012 },
fog: { color: '#e2eef7', density: 0.005 },
light: { sunIntensity: 0.95, sunColor: '#fff7e0', hemiIntensity: 0.75, ambient: '#cfe6f5' },
},
'cloudy': {
top: '#8fa6bd', horizon: '#c2ccd6', bottom: '#d8dde2',
sunDir: [0.2, 0.7, 0.3], sunColor: '#e8e8e8', sunSize: 0.0, haze: 0.4,
mountains: false,
clouds: { enabled: true, cover: 0.8, color: '#e8ebef', speed: 0.02 },
fog: { color: '#cfd6dd', density: 0.008 },
light: { sunIntensity: 0.5, sunColor: '#dfe4ea', hemiIntensity: 0.8, ambient: '#c2ccd6' },
},
'sunset': {
top: '#2a3a6b', horizon: '#f5915a', bottom: '#f7c98a',
sunDir: [0.7, 0.12, -0.2], sunColor: '#ffd28a', sunSize: 0.05, haze: 1.0,
mountains: true,
clouds: { enabled: true, cover: 0.35, color: '#ffd9b3', speed: 0.01 },
fog: { color: '#f0b483', density: 0.006 },
light: { sunIntensity: 0.7, sunColor: '#ff9a52', hemiIntensity: 0.5, ambient: '#9a6a78' },
},
'starry-night': {
top: '#070b1f', horizon: '#1b2547', bottom: '#243056',
sunDir: [-0.4, 0.75, 0.3], sunColor: '#cdd6ff', sunSize: 0.02, haze: 0.3,
mountains: true, stars: true,
clouds: { enabled: false },
fog: { color: '#141c38', density: 0.004 },
light: { sunIntensity: 0.18, sunColor: '#9aa8d8', hemiIntensity: 0.25, ambient: '#1b2547' },
},
'space': {
top: '#02030a', horizon: '#06070f', bottom: '#0a0c18',
sunDir: [0.5, 0.6, 0.4], sunColor: '#ffffff', sunSize: 0.015, haze: 0.0,
mountains: false, stars: true,
clouds: { enabled: false },
fog: { enabled: false },
light: { sunIntensity: 0.6, sunColor: '#ffffff', hemiIntensity: 0.2, ambient: '#10121c' },
},
};
export class SkyboxManager {
constructor(scene, hemiLight, sunLight) {
this.scene = scene;
this.hemiLight = hemiLight || null; // ambient
this.sunLight = sunLight || null; // directional (тени)
this._dome = null;
this._mat = null;
this._mountains = null;
this._clouds = []; // [{mesh, baseX, speed}]
this._cloudRoot = null;
this._stars = null;
this._fade = null; // активный fadeTo {from,to,t,dur}
this._state = this._defaultState();
registerSkyShader();
this._buildDome();
}
_defaultState() {
return {
mode: 'gradient',
top: '#4a93e6', horizon: '#cfe6f5', bottom: '#e6f1fa',
sunDir: [0.4, 0.8, 0.45], sunColor: '#fffbe6', sunSize: 0.03, haze: 0.8,
mountains: false, stars: false,
clouds: { enabled: false, cover: 0.4, color: '#ffffff', speed: 0.012 },
fog: { enabled: false, color: '#dde8f2', density: 0.005 },
light: { sunIntensity: 0.95, sunColor: '#fff7e0', hemiIntensity: 0.75, ambient: '#cfe6f5' },
};
}
// ── Купол ──────────────────────────────────────────────────────────────
_buildDome() {
const dome = MeshBuilder.CreateSphere('kubikonSkyDome', {
diameter: 1000, segments: 24, sideOrientation: Mesh.BACKSIDE,
}, this.scene);
dome.isPickable = false;
dome.infiniteDistance = true; // не двигается с камерой
dome.renderingGroupId = 0;
dome.applyFog = false;
const mat = new ShaderMaterial('kubikonSkyMat', this.scene, {
vertex: 'kubikonSky', fragment: 'kubikonSky',
}, {
attributes: ['position'],
uniforms: ['worldViewProjection', 'topColor', 'bottomColor',
'horizonColor', 'sunDir', 'sunColor', 'sunSize', 'horizonHaze'],
});
mat.backFaceCulling = false;
mat.disableDepthWrite = true; // небо всегда позади
dome.material = mat;
this._dome = dome;
this._mat = mat;
this._applyShaderUniforms();
}
_applyShaderUniforms() {
const s = this._state;
const m = this._mat;
if (!m) return;
m.setColor3('topColor', Color3.FromArray(hexToRgb(s.top)));
m.setColor3('bottomColor', Color3.FromArray(hexToRgb(s.bottom)));
m.setColor3('horizonColor', Color3.FromArray(hexToRgb(s.horizon)));
const sd = Array.isArray(s.sunDir) ? s.sunDir : [0.4, 0.8, 0.45];
m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize());
m.setColor3('sunColor', Color3.FromArray(hexToRgb(s.sunColor)));
m.setFloat('sunSize', s.sunSize || 0.03);
m.setFloat('horizonHaze', s.haze != null ? s.haze : 0.7);
}
// ── Горы (low-poly на горизонте) ────────────────────────────────────────
_buildMountains(colorHex) {
this._disposeMountains();
const positions = [], indices = [];
const ringR = 420, baseY = -10, segs = 64;
// Кольцо из треугольных пиков переменной высоты — стилизованный силуэт.
let vi = 0;
for (let i = 0; i < segs; i++) {
const a0 = (i / segs) * Math.PI * 2;
const a1 = ((i + 1) / segs) * Math.PI * 2;
const am = (a0 + a1) / 2;
// Псевдослучайная высота пика (детерминированно от индекса).
const hh = 40 + Math.abs(Math.sin(i * 1.7) * Math.cos(i * 0.6)) * 130;
const x0 = Math.cos(a0) * ringR, z0 = Math.sin(a0) * ringR;
const x1 = Math.cos(a1) * ringR, z1 = Math.sin(a1) * ringR;
const xm = Math.cos(am) * ringR, zm = Math.sin(am) * ringR;
positions.push(x0, baseY, z0, x1, baseY, z1, xm, baseY + hh, zm);
indices.push(vi, vi + 1, vi + 2);
vi += 3;
}
const vd = new VertexData();
vd.positions = positions; vd.indices = indices;
const normals = [];
VertexData.ComputeNormals(positions, indices, normals);
vd.normals = normals;
const mesh = new Mesh('kubikonSkyMountains', this.scene);
vd.applyToMesh(mesh);
mesh.isPickable = false;
mesh.applyFog = true; // горы выцветают в туман (атмосфера)
mesh.renderingGroupId = 0;
const mat = new StandardMaterial('kubikonSkyMountainsMat', this.scene);
const c = hexToRgb(colorHex || '#8fa98a');
mat.diffuseColor = new Color3(c[0], c[1], c[2]);
mat.specularColor = new Color3(0, 0, 0);
mat.emissiveColor = new Color3(c[0] * 0.25, c[1] * 0.25, c[2] * 0.25);
mesh.material = mat;
this._mountains = mesh;
}
_disposeMountains() {
if (this._mountains) { this._mountains.material?.dispose(); this._mountains.dispose(); this._mountains = null; }
}
// ── Облака (billboard-плоскости) ────────────────────────────────────────
_buildClouds(opts) {
this._disposeClouds();
const o = opts || {};
if (!o.enabled) return;
const cover = o.cover != null ? o.cover : 0.4;
const count = Math.round(4 + cover * 16); // 4..20 облаков
const tex = this._makeCloudTexture(o.color || '#ffffff');
for (let i = 0; i < count; i++) {
const w = 60 + Math.random() * 90;
const plane = MeshBuilder.CreatePlane(`kubikonCloud_${i}`, { width: w, height: w * 0.55 }, this.scene);
plane.billboardMode = Mesh.BILLBOARDMODE_ALL;
plane.isPickable = false;
plane.applyFog = false;
plane.renderingGroupId = 0;
const mat = new StandardMaterial(`kubikonCloudMat_${i}`, this.scene);
mat.diffuseTexture = tex;
mat.opacityTexture = tex;
mat.emissiveColor = new Color3(1, 1, 1);
mat.disableLighting = true;
mat.backFaceCulling = false;
plane.material = mat;
const ang = Math.random() * Math.PI * 2;
const rad = 150 + Math.random() * 200;
const x = Math.cos(ang) * rad;
const z = Math.sin(ang) * rad;
const y = 90 + Math.random() * 70;
plane.position.set(x, y, z);
this._clouds.push({ mesh: plane, speed: (o.speed || 0.012) * (0.6 + Math.random() * 0.8) });
}
}
/** Процедурная мягкая текстура-облако (radial-gradient на DynamicTexture). */
_makeCloudTexture(colorHex) {
const size = 256;
const dt = new DynamicTexture('kubikonCloudTex', { width: size, height: size }, this.scene, false);
const ctx = dt.getContext();
ctx.clearRect(0, 0, size, size);
const c = hexToRgb(colorHex);
const rgb = `${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)}`;
// Несколько перекрывающихся мягких кругов → пухлое облако.
const blobs = [[128, 140, 70], [90, 150, 50], [170, 150, 55], [128, 110, 55], [110, 130, 45], [150, 130, 45]];
for (const [bx, by, br] of blobs) {
const g = ctx.createRadialGradient(bx, by, 0, bx, by, br);
g.addColorStop(0, `rgba(${rgb},0.9)`);
g.addColorStop(0.6, `rgba(${rgb},0.5)`);
g.addColorStop(1, `rgba(${rgb},0)`);
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.fill();
}
dt.hasAlpha = true;
dt.update();
return dt;
}
_disposeClouds() {
for (const c of this._clouds) { c.mesh.material?.diffuseTexture?.dispose?.(); c.mesh.material?.dispose(); c.mesh.dispose(); }
this._clouds = [];
}
// ── Звёзды (точки на куполе) ─────────────────────────────────────────────
_buildStars(enabled) {
this._disposeStars();
if (!enabled) return;
const size = 1024;
const dt = new DynamicTexture('kubikonStarsTex', { width: size, height: size }, this.scene, false);
const ctx = dt.getContext();
ctx.fillStyle = 'rgba(0,0,0,0)'; ctx.clearRect(0, 0, size, size);
for (let i = 0; i < 600; i++) {
const x = Math.random() * size, y = Math.random() * size;
const r = Math.random() * 1.4 + 0.3;
const a = 0.4 + Math.random() * 0.6;
ctx.fillStyle = `rgba(255,255,255,${a})`;
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
}
dt.hasAlpha = true; dt.update();
const dome = MeshBuilder.CreateSphere('kubikonStarsDome', {
diameter: 980, segments: 16, sideOrientation: Mesh.BACKSIDE,
}, this.scene);
dome.isPickable = false; dome.infiniteDistance = true;
dome.applyFog = false; dome.renderingGroupId = 0;
const mat = new StandardMaterial('kubikonStarsMat', this.scene);
mat.diffuseTexture = dt; mat.opacityTexture = dt;
mat.emissiveColor = new Color3(1, 1, 1);
mat.disableLighting = true; mat.backFaceCulling = false;
mat.disableDepthWrite = true;
dome.material = mat;
this._stars = dome;
}
_disposeStars() {
if (this._stars) { this._stars.material?.diffuseTexture?.dispose?.(); this._stars.material?.dispose(); this._stars.dispose(); this._stars = null; }
}
// ── Туман ────────────────────────────────────────────────────────────────
_applyFog(fog) {
if (!this.scene) return;
if (fog && fog.enabled !== false && (fog.density != null || fog.color)) {
this.scene.fogMode = 2; // EXP
const c = hexToRgb(fog.color || '#dde8f2');
this.scene.fogColor = new Color3(c[0], c[1], c[2]);
this.scene.fogDensity = fog.density != null ? fog.density : 0.005;
} else if (fog && fog.enabled === false) {
this.scene.fogMode = 0;
}
}
// ── Освещение (единый источник: небо управляет светом сцены) ─────────────
/** Выставить направление/яркость солнца и ambient под текущее небо. */
_applyLighting(light, sunDir) {
if (this.sunLight && sunDir) {
// DirectionalLight.direction указывает КУДА падает свет → от солнца вниз.
const d = new Vector3(-sunDir[0], -sunDir[1], -sunDir[2]);
if (d.lengthSquared() > 0) { d.normalize(); this.sunLight.direction = d; }
}
if (!light) return;
if (this.sunLight) {
if (typeof light.sunIntensity === 'number') this.sunLight.intensity = light.sunIntensity;
if (light.sunColor) this.sunLight.diffuse = Color3.FromArray(hexToRgb(light.sunColor));
}
if (this.hemiLight) {
if (typeof light.hemiIntensity === 'number') this.hemiLight.intensity = light.hemiIntensity;
if (light.ambient) this.hemiLight.groundColor = Color3.FromArray(hexToRgb(light.ambient));
}
}
// ── Public API ───────────────────────────────────────────────────────────
/** Применить пресет или ручные опции gradient. */
setSkybox(opts) {
if (!opts) return;
const preset = opts.preset && PRESETS[opts.preset] ? { ...PRESETS[opts.preset] } : null;
const s = this._state;
if (preset) {
s.top = preset.top; s.horizon = preset.horizon; s.bottom = preset.bottom;
s.sunDir = preset.sunDir; s.sunColor = preset.sunColor; s.sunSize = preset.sunSize;
s.haze = preset.haze; s.mountains = !!preset.mountains; s.stars = !!preset.stars;
s.clouds = { ...(preset.clouds || { enabled: false }) };
s.fog = { enabled: preset.fog?.enabled !== false, ...(preset.fog || {}) };
s.light = preset.light || null;
this._applyLighting(preset.light, preset.sunDir);
} else {
// Ручной gradient: { topColor, bottomColor, horizonColor, sunDirection, sunColor, sunSize }
if (opts.topColor) s.top = opts.topColor;
if (opts.bottomColor) s.bottom = opts.bottomColor;
if (opts.horizonColor) s.horizon = opts.horizonColor;
if (opts.sunDirection) s.sunDir = [opts.sunDirection.x, opts.sunDirection.y, opts.sunDirection.z];
if (opts.sunColor) s.sunColor = opts.sunColor;
if (typeof opts.sunSize === 'number') s.sunSize = opts.sunSize;
if (typeof opts.haze === 'number') s.haze = opts.haze;
if (typeof opts.mountains === 'boolean') s.mountains = opts.mountains;
if (typeof opts.stars === 'boolean') s.stars = opts.stars;
}
this._rebuildAll();
}
/** Облака поверх любого режима. */
setClouds(opts) {
if (!opts) return;
this._state.clouds = { ...this._state.clouds, ...opts };
if (this._state.clouds.enabled == null) this._state.clouds.enabled = true;
this._buildClouds(this._state.clouds);
}
/** Атмосферный туман. */
setFog(opts) {
if (!opts) { return; }
this._state.fog = { ...this._state.fog, ...opts };
if (opts.enabled == null) this._state.fog.enabled = true;
this._applyFog(this._state.fog);
}
/** Установить направление солнца (для программной анимации). */
setSunDirection(dir) {
if (!dir) return;
this._state.sunDir = [dir.x, dir.y, dir.z];
this._applyShaderUniforms();
}
/** Плавный переход к пресету/опциям за durationSec секунд (цвета купола + туман). */
fadeTo(opts, durationSec = 2) {
const target = opts?.preset && PRESETS[opts.preset] ? { ...PRESETS[opts.preset] } : null;
if (!target) { this.setSkybox(opts); return; }
// Запоминаем стартовые цвета и целевые — анимируем в tick().
this._fade = {
t: 0, dur: Math.max(0.1, durationSec),
from: {
top: hexToRgb(this._state.top), horizon: hexToRgb(this._state.horizon),
bottom: hexToRgb(this._state.bottom), sunColor: hexToRgb(this._state.sunColor),
sunDir: this._state.sunDir.slice(), sunSize: this._state.sunSize, haze: this._state.haze,
},
to: {
top: hexToRgb(target.top), horizon: hexToRgb(target.horizon),
bottom: hexToRgb(target.bottom), sunColor: hexToRgb(target.sunColor),
sunDir: target.sunDir.slice(), sunSize: target.sunSize, haze: target.haze,
},
target,
};
// Немедленно перестраиваем «дискретные» части (горы/звёзды/облака/туман
// целевого пресета появляются сразу, цвета купола — плавно).
const s = this._state;
s.mountains = !!target.mountains; s.stars = !!target.stars;
s.clouds = { ...(target.clouds || { enabled: false }) };
s.fog = { enabled: target.fog?.enabled !== false, ...(target.fog || {}) };
s.light = target.light || null;
this._rebuildExtras();
// Запоминаем стартовые/целевые значения света для плавной анимации.
if (target.light) {
this._fade.lightFrom = {
sunInt: this.sunLight?.intensity ?? 1,
hemiInt: this.hemiLight?.intensity ?? 0.7,
};
this._fade.lightTo = {
sunInt: target.light.sunIntensity ?? 1,
hemiInt: target.light.hemiIntensity ?? 0.7,
sunColor: target.light.sunColor, ambient: target.light.ambient,
};
}
}
/** Пересобрать всё (купол-uniforms + горы + звёзды + облака + туман + свет). */
_rebuildAll() {
this._applyShaderUniforms();
this._rebuildExtras();
this._applyLighting(this._state.light, this._state.sunDir);
}
_rebuildExtras() {
const s = this._state;
if (s.mountains) {
// Цвет гор подбираем под пресет (днём зеленовато-серый, ночью тёмный).
const mc = s.stars ? '#2a3550' : '#8fa98a';
this._buildMountains(mc);
} else this._disposeMountains();
this._buildStars(!!s.stars);
this._buildClouds(s.clouds);
this._applyFog(s.fog);
}
/** Вызывать каждый кадр: дрейф облаков + анимация fadeTo. */
tick(dt) {
// Дрейф облаков по кругу.
for (const c of this._clouds) {
c.mesh.position.x += c.speed * dt * 60;
if (c.mesh.position.x > 380) c.mesh.position.x = -380;
}
// Анимация перехода неба.
if (this._fade) {
this._fade.t += dt;
const k = Math.min(1, this._fade.t / this._fade.dur);
const f = this._fade.from, t = this._fade.to, m = this._mat;
const mix = (a, b) => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k];
if (m) {
m.setColor3('topColor', Color3.FromArray(mix(f.top, t.top)));
m.setColor3('bottomColor', Color3.FromArray(mix(f.bottom, t.bottom)));
m.setColor3('horizonColor', Color3.FromArray(mix(f.horizon, t.horizon)));
m.setColor3('sunColor', Color3.FromArray(mix(f.sunColor, t.sunColor)));
const sd = mix(f.sunDir, t.sunDir);
m.setVector3('sunDir', new Vector3(sd[0], sd[1], sd[2]).normalize());
m.setFloat('sunSize', f.sunSize + (t.sunSize - f.sunSize) * k);
m.setFloat('horizonHaze', f.haze + (t.haze - f.haze) * k);
// Плавно ведём направление солнца (свет) к целевому (используем sd выше).
if (this.sunLight) {
const d = new Vector3(-sd[0], -sd[1], -sd[2]);
if (d.lengthSquared() > 0) { d.normalize(); this.sunLight.direction = d; }
}
}
// Плавно ведём яркость/ambient света.
if (this._fade.lightFrom && this._fade.lightTo) {
const lf = this._fade.lightFrom, lt = this._fade.lightTo;
if (this.sunLight) {
this.sunLight.intensity = lf.sunInt + (lt.sunInt - lf.sunInt) * k;
if (lt.sunColor) this.sunLight.diffuse = Color3.FromArray(hexToRgb(lt.sunColor));
}
if (this.hemiLight) {
this.hemiLight.intensity = lf.hemiInt + (lt.hemiInt - lf.hemiInt) * k;
if (lt.ambient) this.hemiLight.groundColor = Color3.FromArray(hexToRgb(lt.ambient));
}
}
if (k >= 1) {
// Зафиксировать целевое состояние в _state (как hex).
const tp = this._fade.target;
Object.assign(this._state, {
top: tp.top, horizon: tp.horizon, bottom: tp.bottom,
sunColor: tp.sunColor, sunDir: tp.sunDir.slice(),
sunSize: tp.sunSize, haze: tp.haze,
});
this._fade = null;
}
}
}
serialize() {
return { ...this._state, _active: true };
}
load(data) {
if (!data) return;
this._state = { ...this._defaultState(), ...data };
this._rebuildAll();
}
dispose() {
this._disposeMountains();
this._disposeClouds();
this._disposeStars();
if (this._dome) { this._mat?.dispose(); this._dome.dispose(); this._dome = null; }
}
}

View File

@ -0,0 +1,502 @@
/**
* StudioCollab клиент совместного редактирования (Team Create) для студии.
*
* Подключается к Colyseus-комнате `studio` (одна на projectId) и:
* 1. ОТПРАВЛЯЕТ операции локального юзера (add/move/delete/setColor/...)
* вызывается из обёрток в KubikonEditor/менеджерах через collab.sendOp(...).
* 2. ПРИНИМАЕТ операции других соавторов и применяет их к сцене
* (через applyRemoteOp менеджеры BabylonScene), с флагом _fromRemote,
* чтобы не отправить их обратно (анти-эхо).
* 3. PRESENCE: рисует курсоры/выделение/онлайн-список соавторов
* (через колбэки onPresence UI-слой подписывается).
* 4. SOFT-LOCK: select/deselect объекта; залоченный другим объект не дать
* двигать (onLockRejected колбэк для UI-уведомления).
*
* Транспорт переиспользует существующий kubikon-realtime (Colyseus 0.16).
* В Colyseus 0.16 state-колбэки идут через getStateCallbacks(room) (см.
* feedback_colyseus_016_api).
*/
import { Client, getStateCallbacks } from 'colyseus.js';
import { REALTIME_WS } from '../../api/API';
export class StudioCollab {
/**
* @param {object} scene BabylonScene (со всеми менеджерами)
* @param {object} opts { projectId, token, collabToken?, callbacks }
* callbacks: {
* onConnected({sessionId,color,isHost}), onError(msg), onLeft(),
* onPresenceChange(list), onOpRejected({key,by}),
* onSnapshotRequest(replyFn), onRemoteSnapshot(state), onChat(msg),
* }
*/
constructor(scene, opts = {}) {
this.scene = scene;
this.projectId = opts.projectId;
this.token = opts.token;
this.collabToken = opts.collabToken || null;
this.cb = opts.callbacks || {};
this.room = null;
this.client = null;
this.sessionId = null;
this.color = '#5fd0ff';
this.isHost = false;
this.connected = false;
this._applyingRemote = false; // флаг анти-эхо
this._cursorThrottle = 0;
this._camThrottle = 0;
this._destroyed = false;
}
/** true если сейчас применяем удалённую операцию (не слать обратно). */
get applyingRemote() { return this._applyingRemote; }
/**
* Обернуть методы менеджеров, чтобы ЛЮБОЕ изменение сцены (из любого
* UI-пути) транслировалось соавторам. Анти-эхо: при _applyingRemote
* (мы применяем чужую операцию) обёртки молчат.
*
* Перехватываем минимальный набор для MVP:
* PrimitiveManager: addInstance, updateInstance, removeInstance
* ModelManager: addInstance, removeInstance
*/
installInterceptors() {
const self = this;
const pm = this.scene.primitiveManager;
const mm = this.scene.modelManager;
if (pm && !pm.__collabPatched) {
pm.__collabPatched = true;
const origAdd = pm.addInstance.bind(pm);
pm.addInstance = function (type, opts = {}) {
const id = origAdd(type, opts);
if (id != null && !self._applyingRemote) {
// Шлём полный снимок объекта (с id), чтобы у соавтора создался
// ТОТ ЖЕ instanceId — иначе move-операции не совпадут.
const data = pm.instances?.get?.(id);
const def = serializePrimitive(data, id, type);
self.sendOp({ op: 'add', key: 'primitive:' + id, def });
}
return id;
};
const origUpd = pm.updateInstance.bind(pm);
pm.updateInstance = function (id, patch) {
const r = origUpd(id, patch);
if (!self._applyingRemote && patch && typeof patch === 'object') {
self.sendOp(patchToOp('primitive:' + id, patch));
}
return r;
};
const origRem = pm.removeInstance.bind(pm);
pm.removeInstance = function (id) {
const r = origRem(id);
if (!self._applyingRemote) self.sendOp({ op: 'delete', key: 'primitive:' + id });
return r;
};
}
// BlockManager — синхрон блоков (addBlock/removeBlock).
const bm = this.scene.blockManager;
if (bm && !bm.__collabPatched) {
bm.__collabPatched = true;
if (typeof bm.addBlock === 'function') {
const origAddB = bm.addBlock.bind(bm);
bm.addBlock = function (x, y, z, blockTypeId, color) {
const r = origAddB(x, y, z, blockTypeId, color);
if (!self._applyingRemote) {
self.sendOp({ op: 'blockAdd', x, y, z, blockTypeId, color });
}
return r;
};
}
if (typeof bm.removeBlock === 'function') {
const origRemB = bm.removeBlock.bind(bm);
bm.removeBlock = function (x, y, z) {
const r = origRemB(x, y, z);
if (!self._applyingRemote) self.sendOp({ op: 'blockRemove', x, y, z });
return r;
};
}
}
// SelectionManager — берём/снимаем soft-lock при выделении.
const sm = this.scene.selectionManager || this.scene.selection;
if (sm && !sm.__collabPatched) {
sm.__collabPatched = true;
const sendSel = () => {
if (self._applyingRemote) return;
const s = sm._selection;
if (s && s.type && s.id != null) {
self.selectObject(s.type + ':' + s.id);
} else {
self.deselectObject();
}
};
['selectByMesh', 'selectPrimitiveById'].forEach((fn) => {
if (typeof sm[fn] === 'function') {
const orig = sm[fn].bind(sm);
sm[fn] = function (...a) { const r = orig(...a); sendSel(); return r; };
}
});
if (typeof sm.clear === 'function') {
const origClear = sm.clear.bind(sm);
sm.clear = function (...a) { const r = origClear(...a); if (!self._applyingRemote) self.deselectObject(); return r; };
}
}
if (mm && !mm.__collabPatched) {
mm.__collabPatched = true;
const origAddM = mm.addInstance.bind(mm);
mm.addInstance = function (modelTypeId, x, y, z, rotationY = 0) {
const ret = origAddM(modelTypeId, x, y, z, rotationY);
// addInstance модели async → ret это Promise<id>.
Promise.resolve(ret).then((id) => {
if (id != null && !self._applyingRemote) {
self.sendOp({ op: 'add', key: 'model:' + id,
def: { __kind: 'model', modelTypeId, x, y, z, rotationY } });
}
});
return ret;
};
const origRemM = mm.removeInstance.bind(mm);
mm.removeInstance = function (id) {
const r = origRemM(id);
if (!self._applyingRemote) self.sendOp({ op: 'delete', key: 'model:' + id });
return r;
};
}
}
/** Снять обёртки (при выходе из коллаба восстановить оригиналы — простой флаг). */
uninstallInterceptors() {
// Оставляем обёртки, но они станут no-op после dispose (connected=false).
// Полный un-patch не нужен: sendOp молчит при !connected.
}
async connect() {
this.client = new Client(REALTIME_WS);
const joinOpts = {
projectId: this.projectId,
token: this.token,
};
if (this.collabToken) joinOpts.collabToken = this.collabToken;
this.room = await this.client.joinOrCreate('studio', joinOpts);
this.connected = true;
this.sessionId = this.room.sessionId;
const room = this.room;
// --- сервер сообщил наши параметры ---
room.onMessage('joined', (m) => {
this.sessionId = m.sessionId;
this.color = m.color;
this.isHost = !!m.isHost;
this.cb.onConnected?.({ sessionId: m.sessionId, color: m.color, isHost: !!m.isHost, role: m.role });
// Новый соавтор грузит сцену из БД сам (как при обычном открытии
// проекта), дальше догоняет live-операции. Снапшот через комнату НЕ
// шлём — большая сцена превышает лимит payload Colyseus (RangeError).
});
// --- входящая операция от другого соавтора ---
room.onMessage('op', (m) => {
if (!m || m.from === this.sessionId) return;
this._applyRemoteOp(m.op);
});
// --- наша операция отклонена (объект залочен) ---
room.onMessage('op-rejected', (m) => { this.cb.onOpRejected?.(m); });
room.onMessage('select-denied', (m) => { this.cb.onOpRejected?.(m); });
// --- чат соавторов ---
room.onMessage('chat', (m) => { this.cb.onChat?.(m); });
// --- presence через state ---
this._wirePresence();
room.onLeave(() => { this.connected = false; this.cb.onLeft?.(); });
room.onError((code, msg) => { this.cb.onError?.(msg || ('Ошибка ' + code)); });
return this.room;
}
_wirePresence() {
const $ = getStateCallbacks(this.room);
const state = this.room.state;
const emit = () => {
if (this._destroyed) return;
const list = [];
const col = state && state.collaborators;
if (col && typeof col.forEach === 'function') {
col.forEach((c, sid) => {
list.push({
sessionId: sid,
me: sid === this.sessionId,
username: c.username,
color: c.color,
cursor: { x: c.cursorX, y: c.cursorY, z: c.cursorZ },
cam: { x: c.camX, y: c.camY, z: c.camZ },
selectedKey: c.selectedKey,
isHost: sid === state.hostSessionId,
});
});
}
this.cb.onPresenceChange?.(list);
};
// Подписки оборачиваем в try — в Colyseus 0.16 API колбэков для
// MapSchema(string) может отличаться от MapSchema(Schema); не валим коннект.
try {
$(state).collaborators.onAdd((c, _sid) => {
try { $(c).onChange(() => emit()); } catch (e) { /* ignore */ }
emit();
});
$(state).collaborators.onRemove(() => emit());
} catch (e) { /* ignore */ }
try {
$(state).locks.onAdd(() => emit());
$(state).locks.onRemove(() => emit());
} catch (e) { /* ignore */ }
emit();
}
// ============ ИСХОДЯЩИЕ ============
/** Отправить операцию (если подключены и это не эхо удалённой).
* Для непрерывных op (move/rotate/scale) throttle ~30/сек по ключу,
* чтобы драг гизмо (60fps) не залил комнату. add/delete/setColor — сразу. */
sendOp(op) {
if (!this.connected || this._applyingRemote) return;
const cont = op && (op.op === 'move' || op.op === 'rotate' || op.op === 'scale');
if (cont) {
const now = Date.now();
if (!this._opThrottle) this._opThrottle = {};
const tk = op.op + ':' + op.key;
if (now - (this._opThrottle[tk] || 0) < 33) {
// запомним последнее значение, дошлём в _flushPending через rAF
this._pendingOps = this._pendingOps || {};
this._pendingOps[tk] = op;
this._scheduleFlush();
return;
}
this._opThrottle[tk] = now;
}
try { this.room.send('op', op); } catch (e) { /* ignore */ }
}
/** Догнать «зажатые» throttle-операции (последнее значение драга важно
* чтобы финальная позиция точно дошла, а не оборвалась на середине). */
_scheduleFlush() {
if (this._flushScheduled) return;
this._flushScheduled = true;
const run = () => {
this._flushScheduled = false;
const pend = this._pendingOps; this._pendingOps = null;
if (!pend || !this.connected) return;
const now = Date.now();
for (const tk in pend) {
const op = pend[tk];
this._opThrottle[tk] = now;
try { this.room.send('op', op); } catch (e) { /* ignore */ }
}
};
if (typeof requestAnimationFrame === 'function') setTimeout(run, 40);
else setTimeout(run, 40);
}
/** Курсор-точка под мышью (throttle ~20/сек). */
sendCursor(x, y, z) {
if (!this.connected) return;
const now = Date.now();
if (now - this._cursorThrottle < 50) return;
this._cursorThrottle = now;
try { this.room.send('cursor', { x, y, z }); } catch (e) { /* ignore */ }
}
sendCamera(x, y, z) {
if (!this.connected) return;
const now = Date.now();
if (now - this._camThrottle < 200) return;
this._camThrottle = now;
try { this.room.send('camera', { x, y, z }); } catch (e) { /* ignore */ }
}
/** Выделить объект (берёт soft-lock). key = 'primitive:42' / 'model:m3'. */
selectObject(key) {
if (!this.connected) return;
try { this.room.send('select', { key: key || '' }); } catch (e) { /* ignore */ }
}
deselectObject() {
if (!this.connected) return;
try { this.room.send('deselect', {}); } catch (e) { /* ignore */ }
}
sendChat(text) {
if (!this.connected) return;
try { this.room.send('chat', { text }); } catch (e) { /* ignore */ }
}
// ============ ВХОДЯЩИЕ → СЦЕНА ============
/**
* Применить операцию другого соавтора к локальной сцене.
* Оборачиваем во флаг _applyingRemote, чтобы обёртки менеджеров НЕ
* отправили её обратно в комнату (анти-эхо).
*/
_applyRemoteOp(op) {
if (!op || typeof op !== 'object') return;
this._applyingRemote = true;
try {
applyRemoteOp(this.scene, op);
} catch (e) {
// не валим соединение из-за одной кривой операции
// eslint-disable-next-line no-console
console.warn('[StudioCollab] applyRemoteOp failed:', op?.op, e);
} finally {
this._applyingRemote = false;
}
}
dispose() {
this._destroyed = true;
this.connected = false;
try { this.room?.leave(true); } catch (e) { /* ignore */ }
this.room = null;
this.client = null;
}
}
/**
* Применить удалённую операцию к сцене через менеджеры BabylonScene.
* Каждый тип op маппится на метод менеджера. Если метода нет тихо
* пропускаем (фича-парность движков: на этапе 1 поддерживаем базовый набор).
*
* Поддерживаемые операции (этап 1):
* add { kind:'primitive'|'model', def } создать объект
* move { key, x, y, z } переместить
* rotate { key, rx, ry, rz } повернуть
* scale { key, sx, sy, sz } масштаб
* setColor { key, color } цвет
* setProp { key, prop, value } произвольное свойство
* delete { key } удалить
*/
export function applyRemoteOp(scene, op) {
const t = op.op;
const pm = scene.primitiveManager;
const mm = scene.modelManager;
const bm = scene.blockManager;
const { kind, id } = parseKey(op.key);
switch (t) {
case 'blockAdd':
bm?.addBlock?.(op.x, op.y, op.z, op.blockTypeId, op.color);
return;
case 'blockRemove':
bm?.removeBlock?.(op.x, op.y, op.z);
return;
}
switch (t) {
case 'add': {
if (op.def?.__kind === 'primitive' && pm?.addInstance) {
// addInstance(type, opts) — opts со всеми полями (id восстановим по instanceId).
pm.addInstance(op.def.type, op.def);
} else if (op.def?.__kind === 'model' && mm?.addInstance) {
// ModelManager.addInstance(modelTypeId, x, y, z, rotationY) — позиционно.
const d = op.def;
mm.addInstance(d.modelTypeId || d.type, d.x, d.y, d.z, d.rotationY || 0);
}
break;
}
case 'move': {
if (kind === 'primitive') pm?.updateInstance?.(id, { x: op.x, y: op.y, z: op.z });
else if (kind === 'model') _moveModel(mm, id, op.x, op.y, op.z);
break;
}
case 'rotate': {
if (kind === 'primitive') {
pm?.updateInstance?.(id, { rotationX: op.rx, rotationY: op.ry, rotationZ: op.rz });
} else if (kind === 'model') {
_rotateModel(mm, id, op.ry);
}
break;
}
case 'scale': {
if (kind === 'primitive') pm?.updateInstance?.(id, { sx: op.sx, sy: op.sy, sz: op.sz });
break;
}
case 'setColor': {
if (kind === 'primitive') pm?.updateInstance?.(id, { color: op.color });
break;
}
case 'setProp': {
if (kind === 'primitive') { const patch = {}; patch[op.prop] = op.value; pm?.updateInstance?.(id, patch); }
break;
}
case 'delete': {
if (kind === 'primitive') pm?.removeInstance?.(id);
else if (kind === 'model') mm?.removeInstance?.(id);
break;
}
default:
// неизвестная операция — пропускаем (вперёд-совместимость)
break;
}
}
/** Переместить модель (нет updateInstance — двигаем rootMesh напрямую). */
function _moveModel(mm, id, x, y, z) {
const inst = mm?.instances?.get?.(id);
if (inst?.rootMesh?.position) {
inst.rootMesh.position.set(x, y, z);
if (inst.data) { inst.data.x = x; inst.data.y = y; inst.data.z = z; }
inst.x = x; inst.y = y; inst.z = z;
}
}
/** Повернуть модель вокруг Y. */
function _rotateModel(mm, id, ry) {
const inst = mm?.instances?.get?.(id);
if (inst?.rootMesh && typeof ry === 'number') {
inst.rootMesh.rotation = inst.rootMesh.rotation || { x: 0, y: 0, z: 0 };
inst.rootMesh.rotation.y = ry;
if (inst.data) inst.data.rotationY = ry;
inst.rotationY = ry;
}
}
/** Снимок примитива для операции 'add' (с instanceId, чтобы id совпал у всех). */
function serializePrimitive(data, id, type) {
if (!data) return { __kind: 'primitive', type, instanceId: id, id };
return {
__kind: 'primitive',
type: data.type || type,
id, instanceId: id,
x: data.x, y: data.y, z: data.z,
sx: data.sx, sy: data.sy, sz: data.sz,
rotationX: data.rotationX, rotationY: data.rotationY, rotationZ: data.rotationZ,
color: data.color, material: data.material,
name: data.name, anchored: data.anchored, canCollide: data.canCollide,
visible: data.visible, mass: data.mass, studDensity: data.studDensity,
};
}
/** Превратить patch updateInstance в конкретную операцию (move/rotate/scale/setColor/setProp). */
function patchToOp(key, patch) {
if ('x' in patch || 'y' in patch || 'z' in patch) {
return { op: 'move', key, x: patch.x, y: patch.y, z: patch.z };
}
if ('rotationX' in patch || 'rotationY' in patch || 'rotationZ' in patch) {
return { op: 'rotate', key, rx: patch.rotationX, ry: patch.rotationY, rz: patch.rotationZ };
}
if ('sx' in patch || 'sy' in patch || 'sz' in patch) {
return { op: 'scale', key, sx: patch.sx, sy: patch.sy, sz: patch.sz };
}
if ('color' in patch) {
return { op: 'setColor', key, color: patch.color };
}
// прочее — generic setProp (берём первый ключ)
const k = Object.keys(patch)[0];
return { op: 'setProp', key, prop: k, value: patch[k] };
}
/** 'primitive:42' → {kind:'primitive', id:42}; 'model:m3' → {kind:'model', id:'m3'}. */
function parseKey(key) {
if (typeof key !== 'string' || !key.includes(':')) return { kind: '', id: null };
const i = key.indexOf(':');
const kind = key.slice(0, i);
let id = key.slice(i + 1);
if (kind === 'primitive') { const n = Number(id); if (Number.isFinite(n)) id = n; }
return { kind, id };
}

View File

@ -599,6 +599,8 @@ export class UserModelManager {
// instanceId — чтобы target-скрипты могли стабильно ссылаться // instanceId — чтобы target-скрипты могли стабильно ссылаться
// на конкретный инстанс после перезагрузки. // на конкретный инстанс после перезагрузки.
instanceId: inst.instanceId, instanceId: inst.instanceId,
// folderId — принадлежность к папке (иначе вываливается после Play/Stop).
...(inst.folderId != null ? { folderId: inst.folderId } : {}),
}); });
} }
return arr; return arr;
@ -663,7 +665,13 @@ export class UserModelManager {
forceInstanceId: item.instanceId, forceInstanceId: item.instanceId,
}, },
); );
if (id != null) loaded++; if (id != null) {
loaded++;
if (item.folderId != null) { // восстановить папку
const inst = this.instances.get(id);
if (inst) inst.folderId = item.folderId;
}
}
} catch (e) { } catch (e) {
console.warn('[UserModelManager] failed to load instance', item, e); console.warn('[UserModelManager] failed to load instance', item, e);
} }

View File

@ -90,6 +90,18 @@ export class WeaponSystem {
if (e.button !== 0) return; if (e.button !== 0) return;
// Если UI-режим курсора — не стреляем (мышь работает по GUI) // Если UI-режим курсора — не стреляем (мышь работает по GUI)
if (this.scene3d?.player?.isUiCursorMode?.()) return; if (this.scene3d?.player?.isUiCursorMode?.()) return;
// Если курсор СВОБОДЕН (нет pointer-lock — обычно 3-е лицо) — стреляем
// ТУДА, КУДА КЛИКНУЛИ, а не в центр камеры. При pointer-lock курсор в
// центре экрана → используем прицел камеры (aim не задаём).
if (document.pointerLockElement !== canvas) {
const rect = canvas.getBoundingClientRect();
const cx = (e.clientX != null ? e.clientX : 0) - rect.left;
const cy = (e.clientY != null ? e.clientY : 0) - rect.top;
if (cx >= 0 && cy >= 0 && cx <= rect.width && cy <= rect.height) {
this.setAimScreenPoint(cx * (canvas.width / rect.width),
cy * (canvas.height / rect.height));
}
}
this._mouseDown = true; this._mouseDown = true;
this._tryFire(); this._tryFire();
}; };
@ -97,14 +109,28 @@ export class WeaponSystem {
if (e.button !== 0) return; if (e.button !== 0) return;
this._mouseDown = false; this._mouseDown = false;
}; };
// При свободном курсоре (3-е лицо) запоминаем позицию мыши — чтобы
// авто-огонь при удержании ЛКМ продолжал стрелять в точку курсора.
const onMove = (e) => {
if (!this._mouseDown) return;
if (document.pointerLockElement === canvas) return;
const rect = canvas.getBoundingClientRect();
const cx = (e.clientX != null ? e.clientX : 0) - rect.left;
const cy = (e.clientY != null ? e.clientY : 0) - rect.top;
if (cx >= 0 && cy >= 0 && cx <= rect.width && cy <= rect.height) {
this._holdAim = { x: cx * (canvas.width / rect.width), y: cy * (canvas.height / rect.height) };
}
};
const onKey = (e) => { const onKey = (e) => {
if (e.code === 'KeyR') this.reload(); if (e.code === 'KeyR') this.reload();
}; };
canvas.addEventListener('mousedown', onDown); canvas.addEventListener('mousedown', onDown);
window.addEventListener('mouseup', onUp); window.addEventListener('mouseup', onUp);
window.addEventListener('mousemove', onMove);
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
this._listeners.push({ target: canvas, type: 'mousedown', fn: onDown }); this._listeners.push({ target: canvas, type: 'mousedown', fn: onDown });
this._listeners.push({ target: window, type: 'mouseup', fn: onUp }); this._listeners.push({ target: window, type: 'mouseup', fn: onUp });
this._listeners.push({ target: window, type: 'mousemove', fn: onMove });
this._listeners.push({ target: window, type: 'keydown', fn: onKey }); this._listeners.push({ target: window, type: 'keydown', fn: onKey });
// Регистрируем перед-кадровый хук для авто-стрельбы (если auto=true) // Регистрируем перед-кадровый хук для авто-стрельбы (если auto=true)
@ -583,7 +609,12 @@ export class WeaponSystem {
// (для tap-to-shoot на мобиле). Точка применяется один раз. // (для tap-to-shoot на мобиле). Точка применяется один раз.
let hit = null; let hit = null;
let ray; let ray;
const aim = this._aimScreenPoint; // aim: разовый клик (_aimScreenPoint) или удержание по курсору (_holdAim,
// только когда курсор свободен — нет pointer-lock).
let aim = this._aimScreenPoint;
if (!aim && this._holdAim && document.pointerLockElement !== this.scene3d?.canvas) {
aim = this._holdAim;
}
try { try {
if (aim) { if (aim) {
ray = this.scene.createPickingRay(aim.x, aim.y, null, camera); ray = this.scene.createPickingRay(aim.x, aim.y, null, camera);