Compare commits

...

88 Commits

Author SHA1 Message Date
min
c05ab68e6b Merge pull request '�������/������� (������� �� �������) + realtime �� game.rublox.pro' (#24) from fix/env-production-ci into main
All checks were successful
CI / Lint (push) Successful in 57s
CI / Build (push) Successful in 1m30s
CI / Secret scan (push) Successful in 19s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 2m55s
2026-06-09 22:41:21 +00:00
min
39eae607e1 merge main (синхрон перед PR графики плеера)
All checks were successful
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
CI / Lint (pull_request) Successful in 57s
CI / Build (pull_request) Successful in 1m31s
CI / Secret scan (pull_request) Successful in 19s
2026-06-10 01:29:15 +03:00
min
ccf76d539b feat(player): графика/эффекты (фича-парность со студией) + realtime на game.rublox.pro
GraphicsManager (постобработка/материалы/API game.graphics) — паритет со студией,
применяется при загрузке игры если автор настроил. Новые материалы chrome/water/
iridescent. Realtime-эндпоинт переведён на game.rublox.pro (S1 NPM прямо, без
hop через S2 — чинит разрывы WebSocket). MultiplayerSync улучшен.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 01:29:00 +03:00
min
a5e1558c2d feat(player): ������������� �� ������� (Lua + JS-API + Roblox-������ + LoadingOverlay)
All checks were successful
CI / Lint (push) Successful in 54s
CI / Build (push) Successful in 1m30s
CI / Secret scan (push) Successful in 20s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 2m56s
2026-06-09 22:01:51 +00:00
min
f5a96fbec0 fix(player): задача 05 — красивый экран загрузки ИГРЫ при входе (а не в студии)
Главное по задаче 05: переделан React loading-оверлей в KubikonPlayer (тот,
что игрок видит после клика «Играть» пока грузится игра). Новый компонент
GameLoadingScreen: Ken Burns фон + карточка-витрина + название места + автор
+ verified-галочка + прогресс-бар (реальный 0→100%) + спиннер. Данные:
project_data.scene.loadingScreen (настройки автора из студии) → мета игры
(title/thumbnail/автор) → дефолт. 0 ошибок, проверено headless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:46:20 +03:00
min
247a5703c9 feat(player): задача 05 — экран загрузки (Ken Burns) — фича-парность со студией
Порт LoadingScreenOverlay (Ken-Burns/4 стиля/карточка/verified) + старт-экран
при входе в Play + API game.loading.setBackground/isVisible/onHide. Идентично
студии. worker SOURCE синтаксис проверен. Проверено headless в плеере (0 ошибок).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:34:55 +03:00
min
3330715781 Merge pull request 'fix(player): .env.production � git � CI ������� ��� VITE_API_BASE (����� ������� �� /app)' (#23) from fix/env-production-ci into main
All checks were successful
CI / Lint (push) Successful in 56s
CI / Build (push) Successful in 1m32s
CI / Secret scan (push) Successful in 24s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 3m0s
2026-06-07 15:49:11 +00:00
min
f4a1feb41d merge main into fix/env-production-ci
All checks were successful
CI / Lint (pull_request) Successful in 1m4s
CI / Build (pull_request) Successful in 1m33s
CI / Secret scan (pull_request) Successful in 20s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-06-07 18:45:39 +03:00
min
71f9d4dd11 fix(player): закоммитить .env.production — CI собирал плеер без VITE_API_BASE
All checks were successful
CI / Lint (pull_request) Successful in 56s
CI / Build (pull_request) Successful in 1m31s
CI / Secret scan (pull_request) Successful in 20s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Корень инцидента 2026-06-07: .env.production был в .gitignore → CI-сборка
без VITE_API_BASE → API base падал на window.location.origin (rublox.pro)
вместо minecraftia-school.ru. redeem-ticket уходил на rublox.pro/api-user
(нет такого) → плеер не получал JWT → выбивал на /app через секунду.
Файл содержит только публичные URL, секретов нет.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:41:27 +03:00
min
84fd2d996e Merge pull request 'ci: redeploy player (deploy S2)' (#21) from ci/redeploy-player into main
All checks were successful
CI / Lint (push) Successful in 54s
CI / Build (push) Successful in 1m31s
CI / Secret scan (push) Successful in 20s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 2m57s
2026-06-07 15:11:36 +00:00
min
5a6a222c78 ci: redeploy player (deploy-key + build perms S2)
All checks were successful
CI / Lint (pull_request) Successful in 56s
CI / Build (pull_request) Successful in 1m31s
CI / Secret scan (pull_request) Successful in 20s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:06:49 +03:00
min
66d74b823f ������ 14/16/20/40/44: vehicle, ����, ����������, floaters, ��������� (#20)
Some checks failed
CI / Lint (push) Successful in 56s
CI / Build (push) Successful in 1m30s
CI / Secret scan (push) Successful in 51s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Failing after 2m33s
2026-06-07 14:09:57 +00:00
min
2847136819 ci: re-run (secret-scan упал на flaky GitHub 504 при install trufflehog)
All checks were successful
CI / Lint (pull_request) Successful in 54s
CI / Build (pull_request) Successful in 1m36s
CI / Secret scan (pull_request) Successful in 23s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 17:01:37 +03:00
min
270478b133 merge main into feat/vehicle-task14 (sync для мержа PR)
Some checks failed
CI / Lint (pull_request) Successful in 55s
CI / Build (pull_request) Successful in 1m33s
CI / Secret scan (pull_request) Failing after 18s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-06-07 16:55:33 +03:00
min
fed48dd701 fix(player): убрать self-assign text=text в FloaterManager (lint error, блокировал CI)
All checks were successful
CI / Lint (pull_request) Successful in 56s
CI / Build (pull_request) Successful in 1m33s
CI / Secret scan (pull_request) Successful in 27s
CI / PR size check (pull_request) Successful in 8s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 16:48:19 +03:00
min
4364af6e4e ci: проверка после фикса раннера
Some checks failed
CI / Lint (pull_request) Failing after 59s
CI / Build (pull_request) Successful in 1m30s
CI / Secret scan (pull_request) Successful in 20s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 16:42:18 +03:00
min
f452e3794e ci: перезапуск после фикса раннера
Some checks failed
CI / Lint (pull_request) Failing after 38s
CI / Build (pull_request) Failing after 42s
CI / Secret scan (pull_request) Failing after 39s
CI / PR size check (pull_request) Failing after 29s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 15:28:07 +03:00
min
37d1acbba1 fix(player): порт — self.delete снимает interact-подсказку
Some checks failed
CI / Lint (pull_request) Failing after 35s
CI / Build (pull_request) Failing after 29s
CI / Secret scan (pull_request) Failing after 32s
CI / PR size check (pull_request) Failing after 33s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:59:26 +03:00
min
5f789764a6 fix(player): порт — инвентарь hotbar-first + поднят над подсказкой
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:51:28 +03:00
min
4cc33daa1a feat(player): порт задачи 44 — drag-drop инвентарь
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:45:26 +03:00
min
d36059e5ce fix(player): порт — бластер от 3-го лица стреляет в точку клика
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:01:15 +03:00
min
909af7a5d8 fix(player): порт — NPC pickable+npcId для попаданий оружия (авто-floater)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:49:07 +03:00
min
08143b837c feat(player): порт — авто-floater над мобами + урон NPC от оружия (задача 40 доп)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:41:45 +03:00
min
8f229e2cfb feat(player): порт задачи 40 — damage floaters (game.fx.damageFloater)
FloaterManager + fx.damageFloater API/обработчик. Прогон 2676: стек -25×10,
мана -50, 0 ошибок.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 11:05:28 +03:00
min
3eee24ff48 fix(player): mainMenu.show подхватывает opts.onPlay/onShow/onHide (кнопка ИГРАТЬ не запускала игру)
show() игнорировал колбэки из опций — onPlay из mainMenu.show({onPlay}) не
регистрировался, кнопка ИГРАТЬ ничего не делала. Теперь опции-колбэки пушатся
в _onPlay/_onShow/_onHide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:17:42 +03:00
min
d08497ef3b fix(player): scene.setLabel — import LabelManager вместо require (require крашит в браузере)
ReferenceError: require is not defined при game.self.setLabel/scene.setLabel
в плеере. Заменён require('./LabelManager') на статический import (как в студии).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 11:56:34 +03:00
min
b87d1e2525 feat(player): порт задачи 20 — лидерборды + достижения
LeaderstatsManager + AchievementsManager скопированы в плеер, интегрированы
в BabylonScene/worker/GameRuntime (те же точки, что в студии): HUD-таблица,
toast, кубок+страница, bindToStat, сохранение прогресса в БД (savegame, JWT),
мост onChange→worker. Прогон 2616 в плеере: таблица в DOM, 0 ошибок. Полная
фича-парность задачи 20.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 10:48:05 +03:00
min
192e721ba2 fix(player): SkyboxManager.hexToRgb короткий хекс #fff (порт)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 09:49:02 +03:00
min
88f4307308 fix(player): порт — анимация атаки NPC (setAttacking, R15 attack), scene.setVisible по ref
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:26:26 +03:00
min
53f9f3be00 fix(player): тени normalBias 0.02 (порт)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:12:54 +03:00
min
0417d60bdd fix(player): NPC анимация ходьбы + короткие тени (порт)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:47:29 +03:00
min
624bb2a05f fix(player): game.self.setLabel/clearLabel (порт)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:27:13 +03:00
min
b6397a3ad0 fix(player): scene.setColor по ref + scene.setScale (порт)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:13:05 +03:00
min
e4125e6488 fix(player): folderId в serialize/load (парность со студией)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:00:38 +03:00
min
fe7e402ebc fix(player): canCollide в metadata примитива (камера не цепляется за зоны, порт)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:49:18 +03:00
min
ffc5341922 fix(player): csm.frustumEdgeFalloff=8 (затухание длинной тени, порт)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:19:05 +03:00
min
fd1d6c7fdb fix(player): game.self.setColor (порт)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:31:49 +03:00
min
8cc608ca2a fix(player): game.self.setVisible/setCollide (порт для китов Вики)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 07:41:25 +03:00
min
c3260e0395 fix(player): спавн в 0,0 если точка спавна удалена (порт)
spawnEnabled из project_data; при false игрок появляется в (0, поверхность+2, 0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 02:21:38 +03:00
min
6c05c5631d fix(player): game.self.rotate (порт) — фича-парность с студией
self.rotate(ry)/rotateY шлёт scene.rotate с ref носителя (обработчик
scene.rotate в плеере уже был). Иначе вращающиеся объекты падали бы в плеере.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 02:05:17 +03:00
min
a22832628f fix(player): единая система неба — убрать второе солнце (порт)
Фича-парность: Environment не рисует жёлтую сферу/луну (флаг _drawSkyBodies),
SkyboxManager — единый источник неба и света (lights в конструкторе). Порт
правок студии 1:1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:44:25 +03:00
min
151b95f395 feat(player): задача 16 — порт кастомного неба (SkyboxManager)
Фича-парность со студией: SkyboxManager (gradient-купол + горы + облака +
туман + звёзды + пресеты + fadeTo), game-API scene.setSkybox/setClouds/setFog
+ skybox.fadeTo/setSunDirection, сериализация неба, tick облаков/перехода.

Проверено на тест-игре 2541 «Небесная демка» в локальном плеере — небо,
кнопки пресетов и облачность работают.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:30:25 +03:00
min
24b6360266 feat(14): Vehicle System V1+V2 � ���� � ����� (#19)
All checks were successful
CI / Lint (push) Successful in 59s
CI / Build (push) Successful in 1m36s
CI / Secret scan (push) Successful in 2m31s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 2m25s
2026-06-02 23:37:41 +00:00
min
eb6430182b feat(14): Vehicle System V1+V2 — порт в плеер
All checks were successful
CI / Lint (pull_request) Successful in 57s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
CI / Build (pull_request) Successful in 1m34s
CI / Secret scan (pull_request) Successful in 2m30s
CI / PR size check (pull_request) Successful in 7s
Фича-парность со студией (задача 14):
- VehicleManager + VehicleHud (спидометр-стрелка) идентичны студийным.
- game.scene.spawn('vehicle:car'), onVehicleEnter/Exit, hold-F/E, камера follow/V.
- Звук мотора (рокот+LFO), оседание машины на землю (_settle+повторы),
  скрытие водителя, респавн при падении, shadow-caster фильтр (фикс FPS).
- incrementPlay(id, userId) — передаём user_id для cooldown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 02:25:15 +03:00
min
4ca8cdd9bd Merge pull request 'feat(13): ������� ���� ���� (game.mainMenu)' (#18) from feat/main-menu-task13 into main
All checks were successful
CI / Lint (push) Successful in 59s
CI / Build (push) Successful in 1m35s
CI / Secret scan (push) Successful in 2m31s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 2m9s
2026-06-02 21:21:53 +00:00
min
b2cff903ba feat(13): главное меню игры (game.mainMenu) — порт в плеер
All checks were successful
CI / Lint (pull_request) Successful in 59s
CI / Build (pull_request) Successful in 1m37s
CI / Secret scan (pull_request) Successful in 2m30s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Фича-парность со студией: namespace game.mainMenu (show/hide/setCamera/
setPatchNotes/колбэки) + зацикливание облёта через onCutsceneDone +
game.player.setInputBlocked в worker + handler в runtime + passthrough
scene.mainMenu в load. Проверено: меню работает в плеере на игре 2434.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 00:07:47 +03:00
min
dd7688c4d7 Merge pull request 'feat(12): ������������� Loading Screen (game.loading)' (#17) from feat/loading-screen-task12 into main
All checks were successful
CI / Lint (push) Successful in 58s
CI / Build (push) Successful in 1m37s
CI / Secret scan (push) Successful in 2m29s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 2m9s
2026-06-02 20:10:51 +00:00
min
302db5e1f4 feat(12): внутриигровой Loading Screen (game.loading) — порт в плеер
All checks were successful
CI / Lint (pull_request) Successful in 58s
CI / Build (pull_request) Successful in 1m36s
CI / Secret scan (pull_request) Successful in 2m40s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Фича-парность со студией: LoadingScreenOverlay.js (DOM-оверлей),
namespace game.loading в worker (хэндл local→real + колбэки через
globalEvent), cmd loading.* + _ensureLoadingScreen в GameRuntime,
class-ref + tick + load конфига в BabylonScene. Проверено: экран
загрузки работает в плеере на тест-игре «Такси-босс» 2427.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:00:42 +03:00
min
f420501481 ci: redeploy player на S1 (фикс прав www-data→min на build/)
All checks were successful
CI / Lint (push) Successful in 55s
CI / Build (push) Successful in 1m32s
CI / Secret scan (push) Successful in 2m52s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 1m53s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:03:47 +03:00
min
9e3bc60a76 ci: verify-шаг не валится на недоступном root-мусоре в build/wiki
All checks were successful
CI / Lint (push) Successful in 55s
CI / Build (push) Successful in 1m33s
CI / Secret scan (push) Successful in 2m29s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 1m52s
du -sh натыкался на systemd-private-* в build/wiki/tmp (Permission
denied) → exit 1 → deploy failure, хотя rsync долетел. Теперь verify
проверяет наличие index.html, а du неблокирующий (2>/dev/null||true).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:43:12 +03:00
min
61ac40ab61 Merge pull request 'feat(11): placement mode � ����������� ��������� (tycoon)' (#15) from feat/placement-task11 into main
All checks were successful
CI / Lint (push) Successful in 59s
CI / Build (push) Successful in 1m33s
CI / Secret scan (push) Successful in 2m27s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 1m55s
2026-06-02 17:24:22 +00:00
min
91af8514c5 fix(11): порт game.format в worker плеера (money/number/time)
All checks were successful
CI / Lint (pull_request) Successful in 55s
CI / Build (pull_request) Successful in 1m29s
CI / Secret scan (pull_request) Successful in 2m30s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Скрипт «Мой завод» (id 2345) падал в плеере на game.format.money —
неймспейс был только в worker студии. Из-за краха в синхронной части
не доходило до inventoryUi.create/placement → инвентарь не показывался.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:09:27 +03:00
min
517545b0cf feat(11): порт placement mode в плеер (фича-парность со студией)
Some checks failed
CI / Lint (pull_request) Successful in 54s
CI / Build (pull_request) Successful in 1m30s
CI / Secret scan (pull_request) Failing after 12s
CI / PR size check (pull_request) Successful in 8s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
PlacementManager + ShopInventoryUi + проводка game.placement.*/inventoryUi.*
в worker/GameRuntime/BabylonScene — опубликованные tycoon-игры с расстановкой
теперь работают в плеере. + TerrainManager backFaceCulling=false (воксели не
просвечивают), cleanup usermodel при Stop, Hotbar скрыт при пустом инвентаре.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:06:51 +03:00
af3dd97f97 ci: retry — actions cache полностью очищен
All checks were successful
CI / PR size check (push) Has been skipped
CI / Lint (push) Successful in 2m40s
CI / Build (push) Successful in 3m16s
CI / Secret scan (push) Successful in 3m24s
CI / Deploy to S1 + S2 (push) Successful in 1m50s
2026-06-01 21:18:03 +03:00
37e9f9b2c4 ci: retry after runner restart
Some checks failed
CI / PR size check (push) Has been skipped
CI / Build (push) Failing after 58s
CI / Lint (push) Successful in 1m42s
CI / Secret scan (push) Successful in 2m47s
CI / Deploy to S1 + S2 (push) Has been skipped
2026-06-01 21:10:01 +03:00
32a2fa6137 ci: retry — actions/checkout cache warm-up
Some checks failed
CI / PR size check (push) Has been skipped
CI / Build (push) Failing after 9s
CI / Lint (push) Successful in 58s
CI / Secret scan (push) Successful in 2m34s
CI / Deploy to S1 + S2 (push) Has been skipped
2026-06-01 20:56:52 +03:00
93739e13af ci: redeploy main через CI (Deploy_v2 затёр свежий CI build)
Some checks failed
CI / PR size check (push) Has been skipped
CI / Lint (push) Failing after 18s
CI / Build (push) Failing after 21s
CI / Secret scan (push) Successful in 2m49s
CI / Deploy to S1 + S2 (push) Has been skipped
2026-06-01 20:44:14 +03:00
min
cec58412dc Merge pull request 'feat(09): Studs материал + окрашиваемые блоки + лего-сет' (#13) from feat/studs-material-09 into main
All checks were successful
CI / Deploy to S1 + S2 (push) Successful in 2m34s
CI / Lint (push) Successful in 59s
CI / Build (push) Successful in 1m30s
CI / Secret scan (push) Successful in 2m28s
CI / PR size check (push) Has been skipped
2026-05-31 11:17:41 +00:00
322dd089d9 merge main into feat/studs-material-09
All checks were successful
CI / Lint (pull_request) Successful in 1m1s
CI / Build (pull_request) Successful in 1m34s
CI / Secret scan (pull_request) Successful in 2m31s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-05-31 14:09:39 +03:00
8504549928 fix(09): _recreateMesh studs пересоздаёт материал (паритет со студией)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 13:37:44 +03:00
ae83926a5a feat(09): per-face UV studs + studDensity (паритет со студией)
All checks were successful
CI / Lint (pull_request) Successful in 56s
CI / Build (pull_request) Successful in 1m31s
CI / Secret scan (pull_request) Successful in 2m30s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
faceUV для куба (кружки одного размера на всех гранях) + studDensity
(плотность кружков) — портировано из студии.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 13:26:52 +03:00
d5968f7cb8 feat(09): сочные круглые studs (v4) — паритет со студией
- Текстура studs v4 (круглые, объём+тени, сочный цвет), URL studs_v4_*.
- PrimitiveManager: emissive 45% цвета + новые константы (GRID 4, UNIT 1).
- BlockManager/BlockTypes: studs-block на v4-текстуре, specular убран.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:22:03 +03:00
80e546eb08 feat(09): материал studs + studs-block + лего-сет (паритет со студией)
Портирование задачи 09 в плеер:
- PrimitiveManager: material 'studs' (diffuse×color + normal, тайлинг по размеру).
- BlockTypes: studs-block ('Окрашиваемые', colorable).
- BlockManager: per-instance color через ThinInstance color buffer
  (useVertexColors + thinInstanceSetBuffer('color')), addBlock с color,
  _setBlockColorAt/setBlockColor, serialize/load с color.
- GameRuntime: scene.setColor блока + spawn block с color.
- ScriptSandboxWorker: spawn блока прокидывает color.
- ModelTypes: лего-сет 19 compound-моделей (паритет).

Текстуры: public/kubikon-assets/materials/studs_{diffuse,normal}.png.
Проверено: рендер studs-блоков/примитивов/лего-моделей.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 11:11:09 +03:00
min
64de6c3683 Merge pull request 'fix(player): ���� ESC toggle + ����� orbit-������ �� ���' (#12) from fix/player-menu-toggle-camera into main
All checks were successful
CI / Lint (push) Successful in 57s
CI / Build (push) Successful in 1m37s
CI / Secret scan (push) Successful in 2m28s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 2m33s
2026-05-31 07:15:26 +00:00
acb5b0b133 fix(player): меню ESC — toggle вместо открытия поверх + чинит orbit-камеру по ПКМ
All checks were successful
CI / Build (pull_request) Successful in 1m35s
CI / Secret scan (pull_request) Successful in 2m29s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
CI / Lint (pull_request) Successful in 58s
Два бага меню в плеере:
1. Повторный ESC открывал меню ПОВЕРХ первого (не закрывал).
2. После открытия/закрытия меню переставала работать orbit-камера по
   зажатой ПКМ (игры задачи 2 camera_mouse_controls).

Первопричина: ESC слушали ДВА обработчика — движок (setOnExitRequest →
_onEscMenu) и React (отдельный keydown при topMenuOpen). На одно нажатие
срабатывали оба → гонка: меню дублировалось, а _uiCursorMode застревал в
true, из-за чего onCanvasMouseDownGlobal (if _uiCursorMode return) игнорировал
ПКМ → orbit-камера не включалась.

Фикс — единый источник истины в движке:
- BabylonScene: флаг _playerMenuOpen + toggle в setOnExitRequest (открыто→
  закрыть+setUiCursorMode(false), закрыто→открыть). _onEscMenu(open) передаёт
  состояние в UI. setPlayerMenuOpen(open) — синхронизация при закрытии из UI
  (кнопка «Продолжить»). Сброс флага в enterPlayMode.
- KubikonPlayer: setOnEscMenu((open)=>setTopMenuOpen(open)); УБРАН дублирующий
  React ESC-обработчик; onClose меню → setPlayerMenuOpen(false); синхронизация
  _playerMenuOpen=true в onLockChange (perma) и setOnPlayChange.
- PlayerController.setUiCursorMode(true): сброс _rmbHeld=false (иначе если меню
  открыли при зажатой ПКМ, флаг застревал → orbit «думал» что ПКМ активна).

Проверено: ESC открыл→ESC закрыл (1 меню в DOM), ПКМ-orbit работает после меню.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 09:55:58 +03:00
min
cd31078e6d Merge pull request 'fix(engine): findOne(x).onTouch + findOne �� ������ + ������� Instance-proxy' (#11) from fix/pointer-ontouch-findone into main
All checks were successful
CI / Build (push) Successful in 1m31s
CI / Deploy to S1 + S2 (push) Successful in 2m28s
CI / Lint (push) Successful in 57s
CI / Secret scan (push) Successful in 2m28s
CI / PR size check (push) Has been skipped
2026-05-31 06:53:48 +00:00
256f147568 fix(engine): findOne(x).onTouch + findOne на старте + паритет Instance-proxy
All checks were successful
CI / Lint (pull_request) Successful in 55s
CI / Build (pull_request) Successful in 1m42s
CI / Secret scan (pull_request) Successful in 2m30s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Портирование фикса из studio (фича-парность движков). Баг: стрелка-указатель
game.fx.pointer не переключалась на следующую цель.

- find/findOne/all раньше возвращали голую строку-ref → .onTouch невозможен.
  Приведены к студийному Instance-proxy (_getOrCreateInstance, coerces в строку
  через Symbol.toPrimitive → обратно совместимо со старым кодом).
- Instance-proxy: + onTouch/onUntouch/onClick → inst.watchTouch{ref}.
  Worker: _instTouchHandlers + маршрут instTouch/instUntouch/instClick;
  _detectSnapshotDeltas для changed/destroying-событий.
- GameRuntime: inst.watchTouch/watchClick → _watchedTouchRefs; routeInstEvent.
- BabylonScene._detectTouchEvents: блок watched-объектов + _refToTarget;
  _touchState.clear() в enterPlayMode.
- Первичный snapshot сцены в init (setInitialScene) → findOne на старте.

Проверено на проде player.rublox.pro/333: стрелка переключается
red-cube→blue-sphere→gold-chest, на финале удаляется.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 08:28:55 +03:00
min
3edc462741 Merge pull request 'chore: redeploy �� S2 (���� ���� build)' (#10) from chore/redeploy-after-perm-fix into main
All checks were successful
CI / Lint (push) Successful in 57s
CI / Build (push) Successful in 1m32s
CI / Secret scan (push) Successful in 2m32s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 2m32s
2026-05-30 20:08:35 +00:00
60f214ee84 chore: redeploy на S2 после фикса прав /var/www/rublox-player/build (chown min)
All checks were successful
CI / Lint (pull_request) Successful in 58s
CI / Build (pull_request) Successful in 1m33s
CI / Secret scan (pull_request) Successful in 2m28s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 23:01:32 +03:00
min
1534cdfecc Merge pull request 'feat: ���� 3D-�������-��������� � ����� + dev JWT-������' (#9) from feat/arrow-pointer into main
Some checks failed
CI / Lint (push) Successful in 59s
CI / Build (push) Successful in 1m32s
CI / Secret scan (push) Successful in 2m31s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Failing after 2m10s
2026-05-30 19:41:39 +00:00
9c79da4ce5 fix(lint): устранить 8 eslint-ошибок (предсущ., всплыли после починки конфига)
All checks were successful
CI / Lint (pull_request) Successful in 58s
CI / Build (pull_request) Successful in 1m34s
CI / Secret scan (pull_request) Successful in 2m28s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
- no-dupe-keys: дубль ключа эмодзи '🟧' в Icon.jsx
- no-useless-escape: лишний \- в regex (ticketExchange, EmoteGlbParser)
- no-extra-semi: висячие ; в PreviewSkin-route (auto-fix)
Лок. eslint: 0 errors, 118 warnings (< max-warnings 200).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:30:50 +03:00
fe68248b57 ci: S1-деплой не блокирующий (continue-on-error + ConnectTimeout 20с)
Some checks failed
CI / Lint (pull_request) Failing after 56s
CI / Build (pull_request) Successful in 1m36s
CI / Secret scan (pull_request) Successful in 2m28s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
S1 в downtime — деплой не должен валиться, главное доставить на S2.
S1 rsync/verify помечены continue-on-error; S2 остаётся строгим.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:20:03 +03:00
1c568728b5 fix(ci): убрать flat eslint.config.js (несовместим с eslint 8.57) — вернуть .eslintrc.json
Some checks failed
CI / Lint (pull_request) Failing after 56s
CI / Build (pull_request) Successful in 1m41s
CI / Secret scan (pull_request) Successful in 2m32s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
CI Lint падал: ERR_MODULE_NOT_FOUND eslint-plugin-react-refresh + flat-config
(defineConfig/configs.flat) — это API eslint 9, а в lock eslint 8.57.1.
У студии (lint=success) рабочая схема — legacy .eslintrc.json. Привёл плеер
к ней: удалён eslint.config.js, .eslintrc.json дополнен правилами движка,
lint-скрипт с --ext .js,.jsx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:13:55 +03:00
70bbd2f7d4 chore: не коммитить .env.production (только .env.example)
Some checks failed
CI / Lint (pull_request) Failing after 41s
CI / Build (pull_request) Successful in 1m32s
CI / Secret scan (pull_request) Successful in 2m31s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:49:59 +03:00
8f0524cbb3 feat: порт 3D-стрелки-указателя в плеер (фича-парность) + dev JWT-панель
- game.fx.pointer + расширенный game.fx.beam: BeamManager (текстуры/curved/
  градиент/quest-marker), ScriptSandboxWorker (_normFxPoint от DataCloneError),
  GameRuntime (fx.createPointer/pointerTarget/pointerUpdate/beamUpdate/
  beamVisible), BabylonScene._activatePointers. 1-в-1 со студией.
- Dev JWT-панель на экране «Нужен JWT» (только localhost): кнопка → инпут →
  localStorage.player_jwt + reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:46:24 +03:00
bec3ee830c fix(player): смена скина - манифест объединяет статичный JSON + rublox/avatars (фикс исчезновения скина при setSkin non-humanoid) 2026-05-30 14:06:56 +03:00
f794fbe2d4 fix(player): чат — русская модалка вместо англ. кода при неподтверждённом email
REST-fallback чата при ошибке email_not_confirmed попадал в else-ветку и
показывал сырой код email_not_confirmed (англ). WS-путь уже показывал
русскую модалку EmailConfirmNotice. Добавил ту же ветку в REST-catch:
email_not_confirmed → setEmailNotice(true). Игра 2046 не-мультиплеерная,
чат часто идёт REST-фоллбэком → баг был виден именно там.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:30:37 +03:00
e26f854578 fix(player): меню больше не открывается на отпускание ПКМ (orbit)
React onLockChange в KubikonPlayer считал ЛЮБУЮ потерю pointer-lock за
нажатие ESC и открывал меню. В third отпускание ПКМ (orbit-камера) тоже
снимает lock → меню выскакивало на каждый поворот камеры.

Теперь меню открывается только если lock потерян в perma-режиме
(first/lockfirst/sideview/shiftLock) — там потеря lock = реальный ESC.
В third отпускание ПКМ игнорируется.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:15:09 +03:00
66375e26c8 fix(player): управление камерой 02 как в студии + фикс рандомного меню
Привёл _setupInput/onPointerLockChange к рабочей студийной реализации:
- onCanvasMouseDownGlobal/onWindowMouseUpGlobal — ПКМ-orbit с проверкой
  needPermLock() (как в студии), вместо самодельных onRmbDown/onRmbUp.
- onPointerLockChange: при потере lock выход из Play (меню) ТОЛЬКО если
  needPermLock (first/lockfirst/sideview/shiftLock). В third потеря lock =
  отпустили ПКМ → остаёмся в Play. Это убирает рандомное открытие меню.
- onCanvasClick лочит только в perma-режимах.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:45:06 +03:00
7b869c83bd fix(player): клик по 3D-табличкам в third-person (свободный курсор)
_handlePlayClick пикал билборд из ЦЕНТРА экрана (w/2,h/2) — верно только при
pointer-lock. В third курсор свободен, юзер кликает мышью НЕ в центре →
pick промахивался, кнопки табличек не нажимались (Ферма 1981 и др).

Фикс: onMouseDown передаёт реальные canvas-координаты клика в
_handlePlayClick(clickX,clickY); при locked — центр, иначе — точка клика.
Добавлен console.log [billboard] для диагностики попадания.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:32:34 +03:00
fe23d099cd feat(player): hud.setHotbarVisible / hud.setHpVisible (паритет со студией)
Игры со студии (1995/2037/2046) звали game.hud.setHotbarVisible/setHpVisible —
в движке плеера были только hud.setVisible (весь HUD). Без них скрипт падал
на первой строке и игра не работала (нет монет, кнопки не жмутся).

Добавлено во все 3 слоя:
- ScriptSandboxWorker: методы hud.setHotbarVisible/setHpVisible → _send
- GameRuntime: обработчики cmd hud.setHotbarVisible/setHpVisible
- BabylonScene: _setHotbarVisible/_setHpVisible + колбэки видимости

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:16:37 +03:00
85f8198c7c fix(player): порядок чтения скина + управление камерой 02 + авто-меню
1. Стартовый скин не ставился: блок чтения scene.skins/playerModelType стоял
   НИЖЕ предзагрузки модели и enterPlayMode. Перенёс ВЫШЕ — теперь
   PlayerController при старте видит корректный _playerModelType.
2. Меню открывалось каждые ~4с: onPointerLockChange звал _onExitRequest при
   любой потере lock. В third/front потеря lock = отпустили ПКМ (orbit), это
   НЕ выход. Меню (Esc) только из perma-режимов (first/lockfirst/sideview/
   shift-lock).
3. Управление 02: start() лочит только в perma-режимах; onCanvasClick не лочит
   в third (курсор свободен для GUI/3D-табличек); ПКМ-orbit (onRmbDown/Up);
   onWheel авто-переход third<->first; _isPermaLockMode/_applyCursorVisibility.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 09:17:19 +03:00
6a2aeefee5 Merge remote-tracking branch 'origin/main' into feat/sync-engine-week4 2026-05-30 08:28:08 +03:00
2afd6a287a fix(player): кнопки 3D-табличек + управление камерой как в Roblox (задачи 01, 02)
Задача 01 — billboard-клик не работал: _handlePlayClick не проверял кнопки
табличек. Добавил pick по billboard-мешу из центра экрана → pickButtonAt →
fireClick (BabylonScene._handlePlayClick).

Задача 02 — управление было старым (всегда pointer-lock, ПКМ не работал):
- start(): lock только в perma-режимах (first/lockfirst/sideview/shift-lock),
  в third курсор виден свободно
- onCanvasClick: не лочит в third (курсор для GUI/3D-табличек)
- ПКМ-orbit: зажал ПКМ в third → lock+вращение, отпустил → курсор вернулся
- onWheel: авто-переход third↔first при зуме (порог 0.7), экспоненциальный шаг
- onPointerLockChange: отпускание ПКМ в third НЕ выходит из Play (раньше выходило)
- _applyCursorVisibility / _isPermaLockMode хелперы

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 08:27:59 +03:00
min
64a9d3064d Merge pull request 'feat: синхронизация движка плеера со студией (задачи 01-07)' (#8) from feat/sync-engine-week4 into main
Some checks failed
CI / Lint (push) Failing after 41s
CI / Build (push) Successful in 1m37s
CI / Secret scan (push) Successful in 2m29s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Failing after 4m1s
2026-05-30 00:28:12 +00:00
e61c398eeb Merge remote-tracking branch 'origin/main' into feat/sync-engine-week4
Some checks failed
CI / Lint (pull_request) Failing after 40s
CI / Build (pull_request) Successful in 1m32s
CI / Secret scan (pull_request) Successful in 2m28s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-05-30 03:22:28 +03:00
a46829c5f7 feat: синхронизация движка плеера со студией (задачи 01-07)
Some checks failed
CI / Lint (pull_request) Failing after 42s
CI / Build (pull_request) Successful in 1m30s
CI / Secret scan (pull_request) Successful in 2m28s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Плеер отстал на несколько задач — игры из студии не открывались с механиками.
Перенёс из rublox-studio в движок плеера:

Новые файлы движка:
- engine/ModalManager.js (задача 04 — модальные сцены)
- engine/BillboardUiManager.js (задача 01 — 3D-таблички)

Точечный перенос в существующие файлы:
- ScriptSandboxWorker.js: namespace game.modal/billboard/environment, скины в
  game.player, game.gui.tween, _guiHandlerKeys(localId), события
  modalOpened/modalClosed/skinChanged/billboardClick
- GameRuntime.js: команды modal.*/billboard.*/player.setSkin.*/gui.tween +
  _broadcastSkinsSnapshot/_ensureSkinState + routeGlobalEvent с localId
- PlayerController.js: non-humanoid скины (loadNonHumanoid+reloadSkin+
  процедурная анимация+pivot-центрирование), setInputBlocked/focusOnTarget,
  камера задачи 02 (zoom/shift-lock), клавиша B (магазин)
- BabylonScene.js: init modalManager/billboardUiManager, методы магазина скинов,
  чтение scene.skins, modalManager.tick, Esc-приоритет
- ScriptSandbox.js: sendSkinsSnapshot
- GuiManager.js: поля анимаций задачи 03 (синхронизирован со студией)
- PrimitiveTypes.js / PrimitiveManager.js: тип billboard + рендер

React-слой (editor-shared):
- ModalOverlay.jsx, SkinShopOverlay.jsx (новые) + подключены в KubikonPlayer
- GuiOverlay.jsx, GameHud.jsx синхронизированы со студией

eslint.config: послабления стилевых правил (no-empty off и т.п.).

Локальный build зелёный.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 03:15:43 +03:00
a3455710c0 ci: исключить wiki/ и kubikon-assets/ из rsync deploy (#7)
Some checks failed
CI / PR size check (push) Has been skipped
CI / Lint (push) Failing after 6s
CI / Build (push) Successful in 2m1s
CI / Secret scan (push) Successful in 2m32s
CI / Deploy to S1 + S2 (push) Failing after 1m45s
Co-authored-by: МИН <maksimivankov26@yandex.ru>
Co-committed-by: МИН <maksimivankov26@yandex.ru>
2026-05-29 05:45:01 +00:00
72 changed files with 33353 additions and 17725 deletions

11
.WORKTREE_NOTICE.md Normal file
View File

@ -0,0 +1,11 @@
# Активная сессия: импорт Roblox .rbxl
Это **отдельный worktree** для разработки `feat/rbxl-import` — импорта Roblox-карт в Rublox.
**Не работайте здесь параллельно из других сессий!**
Ветка: `feat/rbxl-import`
Сервис на сервере: VM 130 на S1
Сопутствующий worktree: `Desktop/studio-rbxl-import`
Started: 2026-06-07

5
.env.production Normal file
View File

@ -0,0 +1,5 @@
VITE_API_BASE=https://minecraftia-school.ru
VITE_REALTIME_HTTP=https://minecraftia-school.ru/api-game
VITE_REALTIME_WS=wss://minecraftia-school.ru/api-game
VITE_RUBLOX_HOME=https://rublox.pro/app
VITE_STANDALONE=false

View File

@ -33,7 +33,12 @@
"react-hooks/exhaustive-deps": "warn", "react-hooks/exhaustive-deps": "warn",
"no-eval": "error", "no-eval": "error",
"no-new-func": "error", "no-new-func": "error",
"no-implied-eval": "error" "no-implied-eval": "error",
"no-empty": "off",
"react/no-unescaped-entities": "off",
"no-useless-catch": "warn",
"no-constant-condition": ["warn", { "checkLoops": false }],
"no-fallthrough": "warn"
}, },
"ignorePatterns": [ "ignorePatterns": [
"build/", "build/",

View File

@ -132,23 +132,27 @@ jobs:
chmod 600 ~/.ssh/known_hosts chmod 600 ~/.ssh/known_hosts
- name: Install rsync - name: Install rsync
run: apt-get update -qq && apt-get install -y rsync openssh-client run: apt-get update -qq && apt-get install -y rsync openssh-client
# S1 — НЕ блокирующий: при недоступности S1 (downtime) деплой не должен
# валиться, главное доставить на S2. ConnectTimeout 20с чтобы не висеть.
- name: Deploy to S1 (85.175.7.40:1998) - name: Deploy to S1 (85.175.7.40:1998)
continue-on-error: true
run: | run: |
rsync -az --delete-after --human-readable \ rsync -az --delete-after --human-readable --exclude=wiki --exclude=kubikon-assets \
-e "ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 1998" \ -e "ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -o ConnectTimeout=20 -p 1998" \
build/ min@85.175.7.40:/var/www/rublox-player/build/ build/ min@85.175.7.40:/var/www/rublox-player/build/
- name: Deploy to S2 (192.168.0.124:22, runner в той же сети) - name: Deploy to S2 (192.168.0.124:22, runner в той же сети)
run: | run: |
rsync -az --delete-after --human-readable \ rsync -az --delete-after --human-readable --exclude=wiki --exclude=kubikon-assets \
-e "ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 22" \ -e "ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 22" \
build/ min@192.168.0.124:/var/www/rublox-player/build/ build/ min@192.168.0.124:/var/www/rublox-player/build/
- name: Verify deploy - name: Verify S1 (не блокирующий)
continue-on-error: true
run: | run: |
echo "=== S1 ===" ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -o ConnectTimeout=20 -p 1998 \
ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 1998 \
min@85.175.7.40 \ min@85.175.7.40 \
"ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/" "ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/ 2>/dev/null || true"
echo "=== S2 ===" - name: Verify S2 (обязательный)
run: |
ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 22 \ ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 22 \
min@192.168.0.124 \ min@192.168.0.124 \
"ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/" "ls /var/www/rublox-player/build/index.html && (du -sh /var/www/rublox-player/build/ 2>/dev/null || true)"

40
.gitignore vendored
View File

@ -41,3 +41,43 @@ public/kubikon-assets/
# OS # OS
Thumbs.db Thumbs.db
# ============================================================
# SECURITY — добавлено после взлома 2026-06-04
# НИКОГДА не коммитить эти файлы — они могут содержать секреты!
# ============================================================
CLAUDE.md
INFO_PROCESS.md
PASSWORD_*.md
SECRETS*
*_SECRETS*
*.kdbx
*.kdbx.bak
.env
.env.*
!.env.example
!.env.sample
# .env.production содержит ТОЛЬКО публичные URL (api-base, realtime, rublox.pro)
# — без секретов. Нужен в git, чтобы CI собирал прод-бандл с правильным
# VITE_API_BASE (иначе API уходит на origin вместо minecraftia-school.ru,
# redeem-ticket падает → плеер выбивает на /app). Инцидент 2026-06-07.
!.env.production
secrets/
*.pem
*.key
id_rsa
id_ed25519
known_hosts
authorized_keys
# Текстовые заметки разработчика (могут содержать всё что угодно)
NOTES*.md
TODO*.md
PRIVATE*.md
INTERNAL_*.md
# Бэкапы кода с предыдущих версий
*.bak
*.bak_*
BackUp/
backup/

104
API_USAGE.md Normal file
View File

@ -0,0 +1,104 @@
# API, которые использует плеер
Плеер — это **клиент**. Все данные он берёт с серверных API. Этот документ — полный список эндпоинтов которые он дёргает, зачем и от чего ломается если API изменится.
## Базовый URL
В prod: `https://api.rublox.pro` (alias на `minecraftia-school.ru/api-storys`)
В dev: `https://dev-api.rublox.pro` (staging, см. [reference-staging-env])
Локально: `http://localhost:8674` (storys-микросервис)
## Эндпоинты — список
### Игры и лента
| Method | Path | Когда зовётся | Сломается если... |
|---|---|---|---|
| GET | `/kubikon3d/feed?tab=hot\|new\|popular\|topweek` | при заходе на главную | поле `games[]` отсутствует |
| GET | `/kubikon3d/trending` | главная, секция трендов | возвращает не-массив |
| GET | `/kubikon3d/search?q=<query>` | поиск игр | поле `results[]` отсутствует |
| GET | `/kubikon3d/collections` | главная, секции «Хиты», «Новинки» | возвращает не-массив |
| GET | `/kubikon3d/projects/<id>` | при открытии конкретной игры | поле `project_data` отсутствует или поломан JSON-формат |
| GET | `/kubikon3d/my-projects` | страница «Мои игры» | в ответе нет `projects[]` |
| GET | `/kubikon3d/top-authors` | страница «Топ авторов» | возвращает не-массив |
| GET | `/kubikon3d/events` | главная, баннер мероприятий | возвращает не-массив |
### Игровой процесс (telemetry)
| Method | Path | Когда | Что шлёт |
|---|---|---|---|
| POST | `/kubikon3d/play/heartbeat` | каждые 30 сек пока игрок в игре | `{ game_id, play_time_ms }` |
| POST | `/kubikon3d/activity` | при входе/выходе из игры | `{ game_id, action: 'enter'\|'leave' }` |
| POST | `/kubikon3d/perf-log` | при детектировании просадки FPS <20 на 5+ сек | `{ game_id, fps, draw_calls, mem }` |
| POST | `/kubikon3d/bug-reports` | юзер жмёт «Сообщить о баге» | `{ game_id, description, screenshot_b64 }` |
| POST | `/kubikon3d/reports` | юзер репортит игру | `{ game_id, reason, comment }` |
### Скины и аватары (Рублокс-персонажи)
| Method | Path | Когда |
|---|---|---|
| GET | `/kubikon3d/rublox/equipped-skin/<user_id>` | при спавне аватара любого игрока |
| GET | `/kubikon3d/rublox/owned-skins` | страница «Мои скины» |
| POST | `/kubikon3d/rublox/equipped-skin` | юзер сменил скин |
| GET | `/api-storys/rublox/avatars/<user_id>` | список аватаров юзера |
| GET | `/api-storys/rublox/outfit/<user_id>` | детали одежды |
| GET | `/kubikon-assets/characters/skins_manifest.json` | при загрузке плеера, список всех доступных скинов |
### Эмоушены (R15 анимации)
| Method | Path | Когда |
|---|---|---|
| GET | `/api-storys/rublox/emotes/list` | при заходе в игру (для меню эмоушенов) |
| POST | `/api-storys/rublox/emotes/play/<emote_id>` | юзер выбрал эмоушен |
### Модели и ассеты
| Method | Path | Когда |
|---|---|---|
| GET | `/kubikon3d/models/public` | при загрузке игры — список public GLB-моделей |
| GET | `/kubikon3d/models/mine` | в редакторе (плеер запускает превью своих моделей) |
| GET | `/kubikon3d/models/<id>` | при первом упоминании модели в проекте |
### Админка (только видна юзерам с ролью admin)
Все `/kubikon3d/admin/*` доступны только если в JWT юзер имеет роль `admin`. Иначе бэк возвращает 403.
| Method | Path | Что показывает |
|---|---|---|
| GET | `/kubikon3d/admin/dashboard` | сводка: онлайн, активные игры |
| GET | `/kubikon3d/admin/online` | список онлайн-юзеров |
| GET | `/kubikon3d/admin/all-games` | все игры с фильтрами |
| GET | `/kubikon3d/admin/moderation-queue` | очередь премодерации (для review-игр) |
| GET | `/kubikon3d/admin/reports` | репорты на игры |
| GET | `/kubikon3d/admin/bug-reports` | баг-репорты |
| GET | `/kubikon3d/admin/comments` | модерация комментариев |
| GET | `/kubikon3d/admin/chat/messages` | чат-сообщения |
| GET | `/kubikon3d/admin/chat/bans` | список банов в чате |
| GET | `/kubikon3d/admin/authors` | топ авторов с детальной статистикой |
### Multiplayer (Colyseus)
WebSocket-соединение, не HTTP. Адрес: `wss://multiplayer.rublox.pro/<room_id>`. Сейчас работает через `kubikon-realtime` микросервис (VM 110, port 8685, Node.js+Colyseus+Redis).
## Аутентификация
Все приватные эндпоинты ожидают **JWT в заголовке** `Authorization: Bearer <token>`. JWT выдаёт `/api-user/auth/login`. Срок жизни access-токена — 1 час, refresh-токена — 30 дней.
В плеере токен хранится в `localStorage.jwt`, рефрешится автоматически при 401 через `localStorage.refresh_token``/api-user/auth/refresh`.
## Изменения API
**До publish:** если меняешь сигнатуру эндпоинта (убираешь поле, переименовываешь, меняешь тип) — это **breaking change**. Объявление за 48 часов в канале `#разработка` на https://team.rublox.pro.
Записывай в `API_CHANGELOG.md` админ-репо (приватный, `minecraftia-school.ru/...`) — это позволяет отследить какие изменения когда были.
## Что делать если эндпоинт пропал
1. Открой issue в репо плеера: «API endpoint /xxx not found».
2. Прикрепи console-лог с ошибкой.
3. Кто-то из core-команды посмотрит в `API_CHANGELOG.md` админ-репо и ответит — это было умышленное изменение или баг.
## Контакты
- Issue tracker: https://git.rublox.pro/rublox/player/issues
- Чат: `#разработка` на https://team.rublox.pro

View File

@ -1,21 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
])

21
package-lock.json generated
View File

@ -18,7 +18,8 @@
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-router-dom": "7.4.0", "react-router-dom": "7.4.0",
"socket.io-client": "^4.8.3" "socket.io-client": "^4.8.3",
"wasmoon": "^1.16.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "18.3.12", "@types/react": "18.3.12",
@ -1427,6 +1428,12 @@
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/emscripten": {
"version": "1.39.10",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.39.10.tgz",
"integrity": "sha512-TB/6hBkYQJxsZHSqyeuO1Jt0AB/bW6G7rHt9g7lML7SOF6lbgcHvw/Lr+69iqN0qxgXLhWKScAon73JNnptuDw==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -5206,6 +5213,18 @@
} }
} }
}, },
"node_modules/wasmoon": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/wasmoon/-/wasmoon-1.16.0.tgz",
"integrity": "sha512-FlRLb15WwAOz1A9OQDbf6oOKKSiefi5VK0ZRF2wgH9xk3o5SnU11tNPaOnQuAh1Ucr66cwwvVXaeVRaFdRBt5g==",
"license": "MIT",
"dependencies": {
"@types/emscripten": "1.39.10"
},
"bin": {
"wasmoon": "bin/wasmoon"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -34,7 +34,7 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --max-warnings 200", "lint": "eslint . --ext .js,.jsx --max-warnings 200",
"format": "prettier --write \"src/**/*.{js,jsx,json,md,css}\"", "format": "prettier --write \"src/**/*.{js,jsx,json,md,css}\"",
"format:check": "prettier --check \"src/**/*.{js,jsx,json,md,css}\"", "format:check": "prettier --check \"src/**/*.{js,jsx,json,md,css}\"",
"fetch-assets": "node scripts/fetch-assets.js", "fetch-assets": "node scripts/fetch-assets.js",
@ -49,7 +49,8 @@
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-router-dom": "7.4.0", "react-router-dom": "7.4.0",
"socket.io-client": "^4.8.3" "socket.io-client": "^4.8.3",
"wasmoon": "^1.16.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "18.3.12", "@types/react": "18.3.12",

View File

@ -38,7 +38,8 @@ function PlayerRoute() {
return ( return (
<LoadingScreen <LoadingScreen
text="Нужен JWT" text="Нужен JWT"
subText={`Положи токен в localStorage["player_jwt"] и перезагрузи страницу. Это dev-fallback, на проде такого экрана нет — там сразу редирект на rublox.pro. (gameId=${id})`} subText={`Это dev-fallback (только localhost). На проде сразу редирект на rublox.pro. (gameId=${id})`}
devJwt
/> />
); );
} }

View File

@ -0,0 +1,198 @@
/**
* GameLoadingScreen красивый экран загрузки игры в плеере (задача 05).
*
* Показывается пока грузится игра (после клика «Играть» на странице игры
* открытие плеера). Композиция как в Roblox:
* - размытый фон-обложка игры с медленным Ken Burns (pan + zoom);
* - карточка-витрина по центру (обложка игры);
* - крупное название места;
* - автор + verified-галочка;
* - прогресс-бар + спиннер «ЗАГРУЗКА».
*
* Данные берёт из меты игры (title/thumbnail/автор) и, если автор настроил в
* студии вкладку «Стартовый экран» из project_data.scene.loadingScreen
* (placeName / studioName / style / verified / background / cover).
*/
import React, { useEffect, useRef, useState } from 'react';
// Один раз вставляем CSS-keyframes (нельзя инлайнить в style).
let _cssInjected = false;
function injectCss() {
if (_cssInjected || typeof document === 'undefined') return;
_cssInjected = true;
const s = document.createElement('style');
s.id = 'kbn-game-loading-css';
s.textContent =
'@keyframes kbnGlsKen{0%{transform:scale(1.05) translate3d(0,0,0)}50%{transform:scale(1.15) translate3d(-3%,-2%,0)}100%{transform:scale(1.05) translate3d(-6%,0,0)}}' +
'.kbnGlsKen{animation:kbnGlsKen 22s ease-in-out infinite}' +
'@keyframes kbnGlsSpin{to{transform:rotate(360deg)}}' +
'.kbnGlsSpin{animation:kbnGlsSpin 0.85s linear infinite}' +
'@keyframes kbnGlsRise{0%{transform:translateY(0) scale(1);opacity:0}12%{opacity:.9}88%{opacity:.6}100%{transform:translateY(-110vh) scale(1.4);opacity:0}}' +
'.kbnGlsP{animation:kbnGlsRise linear infinite}' +
'@keyframes kbnGlsGlow{0%,100%{box-shadow:0 18px 60px rgba(0,0,0,.6),0 0 0 rgba(120,160,255,0)}50%{box-shadow:0 18px 60px rgba(0,0,0,.6),0 0 44px rgba(120,160,255,.4)}}' +
'.kbnGlsCard{animation:kbnGlsGlow 4s ease-in-out infinite}' +
'@keyframes kbnGlsBar{0%{transform:translateX(-100%)}100%{transform:translateX(250%)}}' +
'.kbnGlsBarRun{animation:kbnGlsBar 1.2s ease-in-out infinite}' +
'@media (prefers-reduced-motion:reduce){.kbnGlsKen,.kbnGlsP,.kbnGlsCard,.kbnGlsBarRun{animation:none}}';
document.head.appendChild(s);
}
function VerifiedBadge() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" style={{ flex: '0 0 auto' }} aria-label="verified">
<circle cx="12" cy="12" r="11" fill="#3897f0" />
<path d="M7 12.5l3 3 7-7" fill="none" stroke="#fff" strokeWidth="2.4"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/**
* props:
* meta ответ getProjectForPlay (title, thumbnail, author_username/username, ...)
* loadingScreen project_data.scene.loadingScreen (опц., настройки автора)
* progress 0..1 (если null «бегущая» полоса без процента)
*/
export default function GameLoadingScreen({ meta, loadingScreen, progress }) {
injectCss();
const ls = loadingScreen || {};
const [fade, setFade] = useState(0);
const rootRef = useRef(null);
useEffect(() => { const t = setTimeout(() => setFade(1), 20); return () => clearTimeout(t); }, []);
// Источники данных: настройки автора мета игры дефолт.
const bg = ls.background || meta?.thumbnail || null;
const cover = ls.cover || meta?.thumbnail || null;
const placeName = ls.placeName || meta?.title || 'Загрузка игры';
const studioName = ls.studioName
|| meta?.author_username || meta?.username || meta?.author || '';
const verified = ls.verified != null ? !!ls.verified
: !!(meta?.author_verified || meta?.is_verified);
const style = ls.style || 'ken-burns';
const accent = ls.accentColor || '#5fd0ff';
const hasProgress = typeof progress === 'number' && progress >= 0;
const pct = hasProgress ? Math.round(Math.max(0, Math.min(1, progress)) * 100) : null;
// parallax по мыши
const bgRef = useRef(null);
useEffect(() => {
if (style !== 'parallax' || !bgRef.current) return;
const h = (e) => {
const cx = (e.clientX / window.innerWidth - 0.5) * 26;
const cy = (e.clientY / window.innerHeight - 0.5) * 16;
if (bgRef.current) bgRef.current.style.transform = `translate3d(${-cx}px,${-cy}px,0) scale(1.1)`;
};
window.addEventListener('mousemove', h);
return () => window.removeEventListener('mousemove', h);
}, [style]);
const particles = style === 'particles'
? Array.from({ length: 24 }, (_, i) => {
const size = 2 + (i % 4);
const dur = 7 + (i % 7);
return (
<span key={i} className="kbnGlsP" style={{
position: 'absolute', bottom: -10, left: `${(i * 37) % 100}%`,
width: size, height: size, borderRadius: '50%',
background: `rgba(${180 + (i * 7) % 70},${190 + (i * 5) % 60},255,0.85)`,
boxShadow: `0 0 ${size * 2}px rgba(140,170,255,0.7)`,
animationDuration: `${dur}s`, animationDelay: `${-(i % 7)}s`,
}} />
);
}) : null;
return (
<div ref={rootRef} style={{
position: 'absolute', inset: 0, zIndex: 60, overflow: 'hidden',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
background: 'radial-gradient(ellipse at center, #0e1430 0%, #070a14 70%)',
opacity: fade, transition: 'opacity 0.4s ease',
fontFamily: 'system-ui,"Segoe UI",sans-serif',
}}>
{/* Фоновый слой (Ken Burns / parallax / static) */}
{bg && (
<div ref={bgRef}
className={style === 'ken-burns' ? 'kbnGlsKen' : undefined}
style={{
position: 'absolute', inset: '-8%', zIndex: 0,
backgroundImage: `url("${bg}")`, backgroundSize: 'cover', backgroundPosition: 'center',
filter: 'blur(9px) brightness(0.5)', willChange: 'transform',
transition: style === 'parallax' ? 'transform 0.25s ease-out' : 'none',
}} />
)}
{/* particles */}
{particles && <div style={{ position: 'absolute', inset: 0, zIndex: 1, pointerEvents: 'none' }}>{particles}</div>}
{/* Контент */}
<div style={{
position: 'relative', zIndex: 2, display: 'flex',
flexDirection: 'column', alignItems: 'center',
}}>
{/* Карточка-витрина */}
<div className="kbnGlsCard" style={{
width: 'min(40vw,300px)', aspectRatio: '1/1', borderRadius: 18,
backgroundImage: cover ? `url("${cover}")` : 'none',
backgroundColor: '#1a1f2b', backgroundSize: 'cover', backgroundPosition: 'center',
border: '2px solid rgba(255,255,255,0.14)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{!cover && (
<span style={{ color: '#5a6178', fontSize: 14, fontWeight: 700 }}>РУБЛОКС 3D</span>
)}
</div>
{/* Название места */}
<div style={{
marginTop: 22, color: '#fff', fontSize: 34, fontWeight: 800,
letterSpacing: 0.4, textAlign: 'center', maxWidth: '80vw',
textShadow: '0 3px 14px rgba(0,0,0,0.7)',
}}>{placeName}</div>
{/* Автор + verified */}
{studioName && (
<div style={{
marginTop: 8, display: 'flex', alignItems: 'center', gap: 7,
color: '#cdd6e6', fontSize: 16, fontWeight: 600,
textShadow: '0 1px 4px rgba(0,0,0,0.6)',
}}>
<span>{studioName}</span>
{verified && <VerifiedBadge />}
</div>
)}
{/* Прогресс-бар */}
<div style={{
marginTop: 26, width: 'min(64vw,420px)', height: 10, borderRadius: 6,
background: 'rgba(255,255,255,0.12)', overflow: 'hidden',
boxShadow: 'inset 0 1px 3px rgba(0,0,0,0.5)', position: 'relative',
}}>
{hasProgress ? (
<div style={{
height: '100%', width: `${pct}%`, borderRadius: 6,
background: `linear-gradient(90deg, ${accent}, #ffffff)`,
transition: 'width 0.2s linear', boxShadow: `0 0 10px ${accent}`,
}} />
) : (
<div className="kbnGlsBarRun" style={{
height: '100%', width: '40%', borderRadius: 6,
background: `linear-gradient(90deg, transparent, ${accent}, transparent)`,
}} />
)}
</div>
{/* Спиннер + статус */}
<div style={{
marginTop: 16, display: 'flex', alignItems: 'center', gap: 12,
color: '#fff', fontSize: 15, fontWeight: 700, letterSpacing: 0.5,
}}>
<span className="kbnGlsSpin" style={{
display: 'inline-block', width: 20, height: 20,
border: '3px solid rgba(255,255,255,0.25)', borderTopColor: accent, borderRadius: '50%',
}} />
{pct != null ? `${pct}%` : 'ЗАГРУЗКА'}
</div>
</div>
</div>
);
}

View File

@ -1217,6 +1217,17 @@ function TabReport({ gameId, gameTitle }) {
setStatus({ text: 'Отправка...', error: false }); setStatus({ text: 'Отправка...', error: false });
try { try {
const token = getToken(); const token = getToken();
// Бэкенд /kubikon3d/reports требует reporter_user_id и target_id и
// принимает поле text. Раньше TabReport слал {title, message,
// game_id, game_title} БЕЗ reporter_user_id бэк отвечал
// 400 'reporter_user_id required' жалоба падала. Приводим к
// формату бэкенда (как нижняя кнопка «Жалоба»).
const me = getMyProfile();
if (!me || !me.id) {
setStatus({ text: 'Войдите, чтобы отправить жалобу.', error: true });
setSending(false);
return;
}
const res = await fetch(`${STORYS_addres}/kubikon3d/reports`, { const res = await fetch(`${STORYS_addres}/kubikon3d/reports`, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -1224,11 +1235,12 @@ function TabReport({ gameId, gameTitle }) {
...(token ? { Authorization: token } : {}), ...(token ? { Authorization: token } : {}),
}, },
body: JSON.stringify({ body: JSON.stringify({
reporter_user_id: me.id,
target_type: 'project',
target_id: gameId || null,
category, category,
title: title.trim(), text: title.trim() + '\n\n' + message.trim()
message: message.trim(), + (gameTitle ? `\n\n(игра: ${gameTitle})` : ''),
game_id: gameId || null,
game_title: gameTitle || null,
}), }),
}); });
if (res.ok) { if (res.ok) {
@ -1236,7 +1248,9 @@ function TabReport({ gameId, gameTitle }) {
setTitle(''); setTitle('');
setMessage(''); setMessage('');
} else { } else {
setStatus({ text: 'Не удалось отправить. Попробуйте позже.', error: true }); let detail = '';
try { const j = await res.json(); if (j && j.error) detail = ': ' + j.error; } catch (e) {}
setStatus({ text: 'Не удалось отправить' + detail + '.', error: true });
} }
} catch (e) { } catch (e) {
setStatus({ text: 'Сеть недоступна.', error: true }); setStatus({ text: 'Сеть недоступна.', error: true });

View File

@ -363,6 +363,10 @@ const KubikonChatPanel = ({ projectId, onClose, onRequestAuth, compact = false,
is_manual: data.is_manual, is_manual: data.is_manual,
}); });
setError(data.message || formatMuteMessage(data)); setError(data.message || formatMuteMessage(data));
} else if (code === 'email_not_confirmed') {
// То же поведение что и в WS-пути: русская модалка «подтвердите
// email», а не сырой английский код ошибки.
setEmailNotice(true);
} else if (code === 'too_frequent') { } else if (code === 'too_frequent') {
setError(data.message || 'Слишком быстро.'); setError(data.message || 'Слишком быстро.');
} else if (code === 'login_required') { } else if (code === 'login_required') {

View File

@ -9,6 +9,8 @@ import { MultiplayerSync } from '../engine/MultiplayerSync';
import { REALTIME_WS } from '../api/API'; import { REALTIME_WS } from '../api/API';
import GameHud from '../editor-shared/GameHud'; import GameHud from '../editor-shared/GameHud';
import GuiOverlay from '../editor-shared/GuiOverlay'; import GuiOverlay from '../editor-shared/GuiOverlay';
import ModalOverlay from '../editor-shared/ModalOverlay';
import SkinShopOverlay from '../editor-shared/SkinShopOverlay';
import KubikonLeaderboard, { formatTimeMs } from '../components/KubikonLeaderboard/KubikonLeaderboard'; import KubikonLeaderboard, { formatTimeMs } from '../components/KubikonLeaderboard/KubikonLeaderboard';
import KubikonPerfOverlay from '../components/KubikonPerfOverlay/KubikonPerfOverlay'; import KubikonPerfOverlay from '../components/KubikonPerfOverlay/KubikonPerfOverlay';
import Hotbar from '../editor-shared/Hotbar'; import Hotbar from '../editor-shared/Hotbar';
@ -20,6 +22,7 @@ import { useAuth } from '../auth/PlayerAuth';
import RublocsLogo from '../components/RublocsLogo/RublocsLogo'; import RublocsLogo from '../components/RublocsLogo/RublocsLogo';
import useDeviceType from '../hooks/useDeviceType'; import useDeviceType from '../hooks/useDeviceType';
import KubikonMobileControls from './KubikonMobileControls'; import KubikonMobileControls from './KubikonMobileControls';
import GameLoadingScreen from './GameLoadingScreen';
// Плеер живёт на player.rublox.pro он не знает SPA-роутов Майнкрафтии // Плеер живёт на player.rublox.pro он не знает SPA-роутов Майнкрафтии
// (/kubikon, /login, /auth). Поэтому вместо navigate(...) делаем // (/kubikon, /login, /auth). Поэтому вместо navigate(...) делаем
@ -36,12 +39,12 @@ function exitPlayer(gameId) {
// (флаг читает onBeforeUnload listener ниже). // (флаг читает onBeforeUnload listener ниже).
try { window.__rubloxExplicitExit = true; } catch {} try { window.__rubloxExplicitExit = true; } catch {}
const env = (typeof import.meta !== 'undefined' && import.meta.env) || {}; const env = (typeof import.meta !== 'undefined' && import.meta.env) || {};
const RUBLOX_HOME = env.VITE_RUBLOX_HOME || 'https://rublox.pro/app'; const RUBLOX_HOME = (env.VITE_RUBLOX_HOME || 'https://rublox.pro/app').replace(/\/+$/, '');
if (gameId) { if (gameId) {
// Передаём gameId через ?game=<id> главный сайт прочитает и снова // У страницы игры теперь свой URL: /app/game/<id> (им можно делиться).
// откроет карточку игры (юзер возвращается на ту же страницу). // Возвращаемся прямо на него. base RUBLOX_HOME оканчивается на /app.
const sep = RUBLOX_HOME.includes('?') ? '&' : '?'; const base = RUBLOX_HOME.endsWith('/app') ? RUBLOX_HOME : `${RUBLOX_HOME}/app`;
window.location.assign(`${RUBLOX_HOME}${sep}game=${gameId}`); window.location.assign(`${base}/game/${gameId}`);
} else { } else {
window.location.assign(RUBLOX_HOME); window.location.assign(RUBLOX_HOME);
} }
@ -214,6 +217,9 @@ const KubikonPlayer = () => {
const [forbidden, setForbidden] = useState(false); const [forbidden, setForbidden] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Задача 05: конфиг красивого экрана загрузки (из project_data.scene.loadingScreen).
const [loadingScreenCfg, setLoadingScreenCfg] = useState(null);
const [loadProgress, setLoadProgress] = useState(0);
// Раньше была стартовая заглушка «тапни чтобы начать» убрали по // Раньше была стартовая заглушка «тапни чтобы начать» убрали по
// фидбэку, она бесила. Теперь fullscreen опционально через кнопку // фидбэку, она бесила. Теперь fullscreen опционально через кнопку
// в углу. Этот state остался для совместимости с handleMobileStart. // в углу. Этот state остался для совместимости с handleMobileStart.
@ -503,20 +509,31 @@ const KubikonPlayer = () => {
}); });
scene.setOnPlayChange?.((playing) => { scene.setOnPlayChange?.((playing) => {
setIsPlaying(playing); setIsPlaying(playing);
// ESC обрабатывается через pointerlockchange-перехват в плеере // ВНИМАНИЕ: при обычном ESC сюда мы больше НЕ попадаем ESC теперь
// (см. отдельный useEffect ниже). Сюда мы попадаем только если // открывает меню через setOnEscMenu (ниже), не выходя из Play.
// exitPlayMode вызвался по другой причине тогда просто открываем // exitPlayMode(false) случается только по-настоящему (напр. движок
// меню, чтобы пользователь мог выйти/вернуться, и пересоздаём Play // сам остановил Play). В этом случае просто открываем меню, чтобы
// в UI-cursor режиме. // юзер мог выйти/перезапустить. НЕ пересоздаём Play автоматически
// повторный enterPlayMode респавнил игрока и перезапускал скрипты
// («перезапуск плейса» при ESC). Перезапуск делается явной кнопкой.
if (!playing) { if (!playing) {
setTimeout(() => { const s = sceneRef.current;
const s = sceneRef.current; s?.player?.setUiCursorMode?.(true);
if (!s) return;
s.enterPlayMode?.();
s.player?.setUiCursorMode?.(true);
}, 30);
setChatOpen(false); setChatOpen(false);
setTopMenuOpen(true); setTopMenuOpen(true);
try { if (s) s._playerMenuOpen = true; } catch (e) { /* ignore */ }
}
});
// ESC в Play TOGGLE меню-оверлея поверх ЖИВОЙ игры (Roblox-style).
// Движок сам решает open/close (единый источник истины _playerMenuOpen)
// и передаёт сюда. Это убирает гонку двух ESC-обработчиков, из-за которой
// меню открывалось поверх меню, а orbit-камера по ПКМ зависала.
scene.setOnEscMenu?.((open) => {
if (open) {
setChatOpen(false);
setTopMenuOpen(true);
} else {
setTopMenuOpen(false);
} }
}); });
@ -538,11 +555,18 @@ const KubikonPlayer = () => {
setMeta(data); setMeta(data);
setLikesCount(data.likes_count || 0); setLikesCount(data.likes_count || 0);
setDislikesCount(data.dislikes_count || 0); setDislikesCount(data.dislikes_count || 0);
setLoadProgress(0.3);
if (data.project_data) { if (data.project_data) {
const parsed = JSON.parse(data.project_data); const parsed = JSON.parse(data.project_data);
initialStateRef.current = parsed; initialStateRef.current = parsed;
// Задача 05: красивый экран загрузки конфиг автора (если задан в студии).
try {
const lsc = parsed?.scene?.loadingScreen;
if (lsc && typeof lsc === 'object' && lsc.enabled !== false) setLoadingScreenCfg(lsc);
} catch (e) { /* ignore */ }
await scene.loadFromState(parsed); await scene.loadFromState(parsed);
setLoadProgress(0.7);
} }
// Ждём пока Babylon реально загрузит и скомпилит все // Ждём пока Babylon реально загрузит и скомпилит все
@ -579,9 +603,12 @@ const KubikonPlayer = () => {
skinFolderRef.current = mySkin; skinFolderRef.current = mySkin;
try { scene.setPlayerModelType?.(mySkin); } catch (e) {} try { scene.setPlayerModelType?.(mySkin); } catch (e) {}
setLoadProgress(1);
setLoading(false); setLoading(false);
// Засчитываем плей // Засчитываем плей. Передаём user_id (если залогинен)
Kubikon3DApi.incrementPlay(projectId).catch(() => {}); // это активирует self-cooldown (автор не накручивает себе)
// и user-cooldown на бэке (см. /play в Kubikon3D.py).
Kubikon3DApi.incrementPlay(projectId, userId).catch(() => {});
// Запускаем игру сразу // Запускаем игру сразу
setTimeout(() => { setTimeout(() => {
scene.enterPlayMode?.(); scene.enterPlayMode?.();
@ -709,35 +736,39 @@ const KubikonPlayer = () => {
const s = sceneRef.current; const s = sceneRef.current;
if (!s || !s._isPlaying) return; if (!s || !s._isPlaying) return;
const locked = !!document.pointerLockElement; const locked = !!document.pointerLockElement;
// Lock потерян, мы НЕ в UI-cursor mode пользователь нажал ESC if (locked || !s.player || s.player._uiCursorMode) return;
if (!locked && s.player && !s.player._uiCursorMode) { // Lock потерян. НЕ всякая потеря = ESC! В third-person отпускание
// Синхронно ставим флаг listener PlayerController сработает // ПКМ (orbit-камера) тоже снимает lock это НЕ выход в меню.
// следующим и увидит true, не вызовет _onExitRequest. // Меню открываем ТОЛЬКО если lock был «постоянным» (perma-режим:
s.player._uiCursorMode = true; // first/lockfirst/sideview/shiftLock) там потеря lock = реальный ESC.
// Открываем меню в следующий тик (state-update React) const p = s.player;
setChatOpen(false); const permaLock = (
setTopMenuOpen(true); p._cameraMode === 'first' ||
} p._cameraMode === 'lockfirst' ||
p._cameraMode === 'sideview' ||
p._shiftLock
);
// _rmbHeld был выставлен при входе в lock; если ПКМ отпущена в third
// это orbit-завершение, не меню.
if (!permaLock) return;
// Реальный ESC в perma-режиме открываем меню.
p._uiCursorMode = true;
setChatOpen(false);
setTopMenuOpen(true);
// Синхронизируем единый флаг меню в движке, чтобы следующий ESC
// сработал как toggle-закрытие (а не открыл второе меню).
try { s._playerMenuOpen = true; } catch (e) { /* ignore */ }
}; };
// capture-фаза, чтобы успеть раньше PlayerController // capture-фаза, чтобы успеть раньше PlayerController
document.addEventListener('pointerlockchange', onLockChange, true); document.addEventListener('pointerlockchange', onLockChange, true);
return () => document.removeEventListener('pointerlockchange', onLockChange, true); return () => document.removeEventListener('pointerlockchange', onLockChange, true);
}, []); }, []);
// Повторный ESC (когда меню уже открыто) закрыть меню и вернуть // Повторный ESC (toggle закрытие) теперь обрабатывает движок через
// мышь в игру. // setOnExitRequest _onEscMenu(false). Отдельный React-обработчик ESC
useEffect(() => { // УБРАН он слушал тот же ESC, что и движок, и создавал гонку:
if (!topMenuOpen) return; // меню открывалось поверх себя, а _uiCursorMode застревал в true
const onEsc = (e) => { // (orbit-камера по ПКМ переставала работать после закрытия меню).
if (e.key !== 'Escape') return;
const s = sceneRef.current;
if (!s || !s._isPlaying) return;
setTopMenuOpen(false);
s.player?.setUiCursorMode?.(false);
};
window.addEventListener('keydown', onEsc, true);
return () => window.removeEventListener('keydown', onEsc, true);
}, [topMenuOpen]);
// Горячая клавиша T открыть/закрыть чат. Игнорируем когда: // Горячая клавиша T открыть/закрыть чат. Игнорируем когда:
// уже введён текст в <input>/<textarea>/contenteditable (юзер печатает) // уже введён текст в <input>/<textarea>/contenteditable (юзер печатает)
@ -951,6 +982,11 @@ const KubikonPlayer = () => {
// Очищаем ref'ы иначе следующий connectMultiplayer выйдет // Очищаем ref'ы иначе следующий connectMultiplayer выйдет
// на if (mpSyncRef.current || roomRef.current) return. // на if (mpSyncRef.current || roomRef.current) return.
try { sync.stop?.(); } catch (e) {} try { sync.stop?.(); } catch (e) {}
// ВАЖНО: dispose() сносит ВСЕ старые меши remote-игроков со
// сцены. Без этого при auto-reconnect (Colyseus rejoin) новый
// MultiplayerSync видит пустую Map и при +remote создаёт
// дубль-меш на каждый кадр (см. фикс 2026-06-05).
try { sync.dispose?.(); } catch (e) {}
mpSyncRef.current = null; mpSyncRef.current = null;
roomRef.current = null; roomRef.current = null;
// Code 1000 / 1001 нормальное закрытие. Code >= 4000 наш // Code 1000 / 1001 нормальное закрытие. Code >= 4000 наш
@ -1114,46 +1150,13 @@ const KubikonPlayer = () => {
outline: 'none', outline: 'none',
}} }}
/> />
{/* Loading-оверлей */} {/* Задача 05: красивый экран загрузки игры (Ken Burns + название места + автор). */}
{loading && ( {loading && (
<div style={{ <GameLoadingScreen
position: 'absolute', inset: 0, meta={meta}
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', loadingScreen={loadingScreenCfg}
background: progress={loadProgress}
'radial-gradient(ellipse at center, rgba(51,87,255,0.22) 0%, rgba(7,10,20,0.96) 60%)', />
gap: 18, color: HUD.text,
}}>
<div style={{
position: 'relative',
animation: 'hudFloat 3s ease-in-out infinite',
}}>
<div style={{
position: 'absolute', inset: -10,
borderRadius: 20,
animation: 'hudPulseRing 1.6s ease-out infinite',
}} />
<RublocsLogo size={72} />
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
fontSize: 15, fontWeight: 700, letterSpacing: 0.3,
}}>
<div style={{
width: 14, height: 14,
border: `2.5px solid ${HUD.accentBg}`,
borderTopColor: HUD.accent,
borderRadius: '50%',
animation: 'hudSpin 0.8s linear infinite',
}} />
Загрузка игры
</div>
<div style={{
fontSize: 11, color: HUD.textDim,
textTransform: 'uppercase', letterSpacing: 1.4, fontWeight: 700,
}}>
Рублокс 3D
</div>
</div>
)} )}
{/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */} {/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */}
@ -1382,6 +1385,10 @@ const KubikonPlayer = () => {
rt.routeGlobalEvent('guiClick', { id: gid }); rt.routeGlobalEvent('guiClick', { id: gid });
}} }}
/> />
{/* Задача 04: модал-overlay (затемнение + spotlight mask) */}
<ModalOverlay scene={sceneRef.current} />
{/* Задача 07: встроенный магазин скинов (клавиша B / API) */}
<SkinShopOverlay scene={sceneRef.current} />
{/* Мобильное управление на любых тач-устройствах, {/* Мобильное управление на любых тач-устройствах,
и в портрете и в ландшафте (ранее был блок portrait, и в портрете и в ландшафте (ранее был блок portrait,
убрали по фидбэку играть можно как угодно). */} убрали по фидбэку играть можно как угодно). */}
@ -1599,9 +1606,10 @@ const KubikonPlayer = () => {
visible={topMenuOpen} visible={topMenuOpen}
onClose={() => { onClose={() => {
setTopMenuOpen(false); setTopMenuOpen(false);
// Возвращаем мышь в pointer-lock игры (как делал // Синхронизируем движок (_playerMenuOpen) И возвращаем мышь
// старый ESC-handler выше). // в игру одним вызовом. Без этого следующий ESC решит, что
try { sceneRef.current?.player?.setUiCursorMode?.(false); } catch {} // меню «ещё открыто», и не откроет его.
try { sceneRef.current?.setPlayerMenuOpen?.(false); } catch {}
}} }}
onExit={() => exitPlayer(id)} onExit={() => exitPlayer(id)}
onRespawn={() => respawnPlayer()} onRespawn={() => respawnPlayer()}

View File

@ -9,11 +9,62 @@
// //
// CSS-анимации, без JS-фрейма каждый кадр. // CSS-анимации, без JS-фрейма каждый кадр.
import React from 'react'; import React, { useState } from 'react';
const TITLE = 'Рублокс'; const TITLE = 'Рублокс';
export default function LoadingScreen({ text = 'Подключение', subText = null }) { /**
* Dev-only панель вставки JWT. Показывается на экране «Нужен JWT» (только
* localhost). Кнопка инпут сохраняет в localStorage['player_jwt'] и
* перезагружает страницу. На проде этот экран не наступает (там redirect).
*/
function DevJwtPanel() {
const [open, setOpen] = useState(false);
const [val, setVal] = useState('');
const apply = () => {
const t = (val || '').trim();
if (!t) return;
try {
localStorage.setItem('player_jwt', t);
// совместимость с другими местами чтения токена
localStorage.setItem('Authorization', t);
} catch (e) { /* ignore */ }
window.location.reload();
};
if (!open) {
return (
<button className="rb-devbtn" onClick={() => setOpen(true)} title="Вставить JWT (dev)">
🔑 Вставить JWT
</button>
);
}
return (
<div className="rb-devpanel">
<textarea
className="rb-devinput"
placeholder="Вставь сюда player_jwt…"
value={val}
onChange={(e) => setVal(e.target.value)}
autoFocus
spellCheck={false}
onKeyDown={(e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) apply(); }}
/>
<div className="rb-devrow">
<button className="rb-devapply" onClick={apply} disabled={!val.trim()}>
Войти
</button>
<button className="rb-devcancel" onClick={() => setOpen(false)}>
Отмена
</button>
</div>
<div className="rb-devhint">Ctrl+Enter войти. Токен сохранится в localStorage.</div>
</div>
);
}
export default function LoadingScreen({ text = 'Подключение', subText = null, devJwt = false }) {
return ( return (
<div className="rb-splash"> <div className="rb-splash">
<style>{splashCss}</style> <style>{splashCss}</style>
@ -47,6 +98,9 @@ export default function LoadingScreen({ text = 'Подключение', subText
{text}<span className="rb-dots" aria-hidden="true"></span> {text}<span className="rb-dots" aria-hidden="true"></span>
</div> </div>
{subText && <div className="rb-substatus">{subText}</div>} {subText && <div className="rb-substatus">{subText}</div>}
{/* Dev-only: вставка JWT прямо с экрана (вместо ручного localStorage). */}
{devJwt && <DevJwtPanel />}
</div> </div>
); );
} }
@ -127,6 +181,51 @@ const splashCss = `
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
pointer-events: none; pointer-events: none;
} }
/* === Dev JWT panel === */
.rb-devbtn {
margin-top: 22px;
background: rgba(255,255,255,0.14);
border: 1px solid rgba(255,255,255,0.3);
color: #fff;
font-size: 13px; font-weight: 600;
padding: 8px 16px; border-radius: 10px;
cursor: pointer;
transition: background 0.15s;
}
.rb-devbtn:hover { background: rgba(255,255,255,0.24); }
.rb-devpanel {
margin-top: 20px;
width: 380px; max-width: calc(100vw - 40px);
display: flex; flex-direction: column; gap: 8px;
}
.rb-devinput {
width: 100%; height: 70px; resize: vertical;
background: rgba(0,0,0,0.28);
border: 1px solid rgba(255,255,255,0.3);
border-radius: 10px;
color: #fff; font-size: 12px;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
padding: 10px; box-sizing: border-box;
outline: none;
}
.rb-devinput:focus { border-color: rgba(255,255,255,0.6); }
.rb-devrow { display: flex; gap: 8px; }
.rb-devapply {
flex: 1;
background: #ffffff; color: #2841C8;
border: none; border-radius: 10px;
font-size: 14px; font-weight: 700;
padding: 9px 0; cursor: pointer;
}
.rb-devapply:disabled { opacity: 0.4; cursor: default; }
.rb-devcancel {
background: transparent; color: rgba(255,255,255,0.8);
border: 1px solid rgba(255,255,255,0.3); border-radius: 10px;
font-size: 13px; padding: 9px 16px; cursor: pointer;
}
.rb-devhint {
font-size: 11px; opacity: 0.6; text-align: center;
}
@keyframes rbBubble { @keyframes rbBubble {
0%, 100% { opacity: 0.35; } 0%, 100% { opacity: 0.35; }
50% { opacity: 0.70; } 50% { opacity: 0.70; }

View File

@ -132,7 +132,7 @@ export default function PreviewAvatarRoute() {
</span> </span>
)} )}
</div> </div>
<button onClick={() => { try { window.close(); } catch (e) {}; navigate('/'); }} <button onClick={() => { try { window.close(); } catch (e) {} navigate('/'); }}
style={closeBtnStyle}>Закрыть</button> style={closeBtnStyle}>Закрыть</button>
</div> </div>

View File

@ -214,7 +214,7 @@ export default function PreviewEmoteRoute() {
</span> </span>
)} )}
</div> </div>
<button onClick={() => { try { window.close(); } catch (e) {}; navigate('/'); }} <button onClick={() => { try { window.close(); } catch (e) {} navigate('/'); }}
style={closeBtnStyle}>Закрыть</button> style={closeBtnStyle}>Закрыть</button>
</div> </div>

View File

@ -208,7 +208,7 @@ export default function PreviewModelRoute() {
</span> </span>
)} )}
</div> </div>
<button onClick={() => { try { window.close(); } catch (e) {}; navigate('/'); }} <button onClick={() => { try { window.close(); } catch (e) {} navigate('/'); }}
style={closeBtnStyle}>Закрыть</button> style={closeBtnStyle}>Закрыть</button>
</div> </div>

View File

@ -30,10 +30,13 @@ export const STORYS_addres = BASE + '/api-storys';
// env-настроенные прямые URL. // env-настроенные прямые URL.
const IS_HTTPS = typeof window !== 'undefined' && window.location.protocol === 'https:'; const IS_HTTPS = typeof window !== 'undefined' && window.location.protocol === 'https:';
// 2026-06-05: realtime теперь прямо на game.rublox.pro (S1 NPM → S1 VM 110),
// не через minecraftia-school.ru/api-game (лишний hop S2 NPM → S1 NAT
// давал разрывы WebSocket каждую секунду).
export const REALTIME_HTTP = ENV.VITE_REALTIME_HTTP export const REALTIME_HTTP = ENV.VITE_REALTIME_HTTP
?? (IS_HTTPS ? `${window.location.origin}/api-game` : 'http://localhost:8685'); ?? (IS_HTTPS ? 'https://game.rublox.pro' : 'http://localhost:8685');
export const REALTIME_WS = ENV.VITE_REALTIME_WS export const REALTIME_WS = ENV.VITE_REALTIME_WS
?? (IS_HTTPS ? `wss://${window.location.host}/api-game` : 'ws://localhost:8685'); ?? (IS_HTTPS ? 'wss://game.rublox.pro' : 'ws://localhost:8685');
// Главный сайт Рублокса — куда редиректить юзеров без ticket/JWT. // Главный сайт Рублокса — куда редиректить юзеров без ticket/JWT.
export const RUBLOX_HOME = ENV.VITE_RUBLOX_HOME ?? 'https://rublox.pro/app'; export const RUBLOX_HOME = ENV.VITE_RUBLOX_HOME ?? 'https://rublox.pro/app';

View File

@ -198,8 +198,9 @@ export const getProjectForPlay = (id, userId = null, isAdmin = false) =>
}, },
}); });
export const incrementPlay = (id) => export const incrementPlay = (id, userId) =>
api.post(`/kubikon3d/projects/${id}/play`); api.post(`/kubikon3d/projects/${id}/play`,
userId ? { user_id: userId } : {});
/** kind: 'like' | 'dislike'. Toggle: повторный голос того же типа снимает, /** kind: 'like' | 'dislike'. Toggle: повторный голос того же типа снимает,
* голос другого типа переключает. */ * голос другого типа переключает. */

View File

@ -91,7 +91,7 @@ export function readTicketFromHash() {
export function readTeamJwtFromHash() { export function readTeamJwtFromHash() {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
// JWT-формат: header.payload.signature — три blob'а из base64url, точки. // JWT-формат: header.payload.signature — три blob'а из base64url, точки.
const m = /(?:^|[#&])team_jwt=([A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+)/ const m = /(?:^|[#&])team_jwt=([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/
.exec(window.location.hash || ''); .exec(window.location.hash || '');
return m ? m[1] : null; return m ? m[1] : null;
} }

View File

@ -21,11 +21,16 @@ import Icon from './Icon';
*/ */
function _optsEqual(a, b) { function _optsEqual(a, b) {
// Расширенный compare учитываем все поля стилизации.
if (a === b) return true; if (a === b) return true;
if (!a || !b) return false; if (!a || !b) return false;
return a.x === b.x && a.y === b.y && a.color === b.color && a.size === b.size; const keys = ['x','y','color','size','textSize','bold','bg','border',
'borderRadius','padding','w','h','textAlign','anchor'];
for (const k of keys) {
if (a[k] !== b[k]) return false;
}
return true;
} }
const DEFAULT_LABEL_STYLE = { const DEFAULT_LABEL_STYLE = {
fontSize: 18, fontSize: 18,
fontWeight: 700, fontWeight: 700,
@ -137,32 +142,59 @@ function GameHud({ visible, hudRef }) {
{otherIds.map((id, i) => { {otherIds.map((id, i) => {
const lbl = labels[id]; const lbl = labels[id];
const o = lbl.opts || {}; const o = lbl.opts || {};
const hasPos = typeof o.x === 'number' || typeof o.y === 'number'; // Поддерживаем как старый формат opts (x/y в %, color, size),
// так и расширенный (bg, border, borderRadius, padding,
// w/h/textSize/bold/textAlign, x/y в пикселях или с '%').
const hasPercentXY = (typeof o.x === 'number' && o.x <= 100 && typeof o.y === 'number' && o.y <= 100)
&& (o.bg === undefined && o.w === undefined && o.h === undefined);
const usePixelPos = (typeof o.x === 'number' && !hasPercentXY)
|| typeof o.x === 'string';
const style = { const style = {
...DEFAULT_LABEL_STYLE, fontFamily: '"Roboto Condensed", system-ui, sans-serif',
fontSize: o.size || DEFAULT_LABEL_STYLE.fontSize, fontWeight: o.bold ? 800 : DEFAULT_LABEL_STYLE.fontWeight,
fontSize: o.textSize || o.size || DEFAULT_LABEL_STYLE.fontSize,
color: o.color || DEFAULT_LABEL_STYLE.color, color: o.color || DEFAULT_LABEL_STYLE.color,
background: 'rgba(15,12,8,0.55)', background: o.bg || 'rgba(15,12,8,0.55)',
padding: '4px 10px', padding: o.padding != null ? o.padding : '4px 10px',
borderRadius: 5, borderRadius: o.borderRadius != null ? o.borderRadius : 5,
// длинные подписи переносятся и остаются по центру, border: o.border || undefined,
// не вылезая за края экрана textAlign: o.textAlign || 'center',
textAlign: 'center',
maxWidth: '70vw', maxWidth: '70vw',
whiteSpace: 'normal', whiteSpace: 'pre-line',
wordBreak: 'break-word', wordBreak: 'break-word',
width: o.w != null ? o.w : undefined,
height: o.h != null ? o.h : undefined,
display: 'flex',
alignItems: 'center',
justifyContent: o.textAlign === 'left' ? 'flex-start' : 'center',
boxSizing: 'border-box',
}; };
if (hasPos) { if (hasPercentXY) {
return ( return (
<div key={id} style={{ <div key={id} style={{
...style, ...style,
position: 'absolute', position: 'absolute',
left: typeof o.x === 'number' ? `${o.x}%` : undefined, left: `${o.x}%`,
top: typeof o.y === 'number' ? `${o.y}%` : undefined, top: `${o.y}%`,
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
}}>{lbl.text}</div> }}>{lbl.text}</div>
); );
} }
if (usePixelPos) {
// Якорь: 'center' translate(-50%,-50%); по умолчанию top-left
const isCenter = o.anchor === 'center';
const leftVal = typeof o.x === 'string' ? o.x : `${o.x}px`;
const topVal = typeof o.y === 'string' ? o.y : `${o.y}px`;
return (
<div key={id} style={{
...style,
position: 'absolute',
left: leftVal,
top: topVal,
transform: isCenter ? 'translate(-50%, -50%)' : undefined,
}}>{lbl.text}</div>
);
}
// Без позиции стек в левом верхнем углу // Без позиции стек в левом верхнем углу
return ( return (
<div key={id} style={{ <div key={id} style={{

View File

@ -264,10 +264,25 @@ function GuiElement({ el, allElements, childrenMap, isPlaying, selectedId, onSel
// textbox в Play кликабелен (для фокуса и ввода), как и кнопка // textbox в Play кликабелен (для фокуса и ввода), как и кнопка
const pointerEvents = isPlaying ? ((isButton || isTextbox) ? 'auto' : 'none') : 'auto'; const pointerEvents = isPlaying ? ((isButton || isTextbox) ? 'auto' : 'none') : 'auto';
// В Play на кнопке лёгкий hover/pressed эффект // В Play на кнопке hover/pressed эффект (Задача 03).
// Если у элемента задан el.hover/el.active используем их параметры,
// иначе дефолтные значения.
const playInteractive = isPlaying && isButton; const playInteractive = isPlaying && isButton;
const playFilter = pressed ? 'brightness(0.85)' : (hover ? 'brightness(1.15)' : 'none'); const hoverCfg = el.hover || { scale: 1.08, brightness: 1.15, rotation: 0 };
const playTransform = pressed ? `${style.transform || ''} scale(0.97)` : style.transform; const activeCfg = el.active || { scale: 0.94 };
const hoverBrightness = (typeof hoverCfg.brightness === 'number') ? hoverCfg.brightness : 1.15;
const hoverScale = (typeof hoverCfg.scale === 'number') ? hoverCfg.scale : 1.08;
const hoverRotation = (typeof hoverCfg.rotation === 'number') ? hoverCfg.rotation : 0;
const activeScale = (typeof activeCfg.scale === 'number') ? activeCfg.scale : 0.94;
const playFilter = pressed
? 'brightness(0.85)'
: (hover ? `brightness(${hoverBrightness})` : 'none');
const dynScale = pressed ? activeScale : (hover ? hoverScale : 1);
const dynRot = hover ? hoverRotation : 0;
let extraTr = '';
if (dynScale !== 1) extraTr += ` scale(${dynScale})`;
if (dynRot) extraTr += ` rotate(${dynRot}deg)`;
const playTransform = (style.transform || '') + extraTr;
return ( return (
<div <div
@ -340,28 +355,82 @@ function GuiElement({ el, allElements, childrenMap, isPlaying, selectedId, onSel
/> />
); );
})()} })()}
{isText && (el.text != null) && ( {isText && (el.text != null) && (() => {
<div style={{ // Задача 03: text-stroke (обводка текста). Через -webkit-text-stroke
width: '100%', height: '100%', // (хорошая поддержка, чётко на крупном шрифте) + paint-order
display: 'flex', // (stroke под fill чтобы текст не «сжимался»).
alignItems: 'center', const ts = el.textStroke;
justifyContent: el.textAlign === 'left' ? 'flex-start' const strokeStyle = (ts && ts.color && Number.isFinite(ts.width))
: el.textAlign === 'right' ? 'flex-end' : 'center', ? {
color: el.textColor || '#f0e6d8', WebkitTextStroke: `${ts.width}px ${ts.color}`,
fontSize: el.textSize || 16, paintOrder: 'stroke fill',
fontWeight: el.fontWeight || 500, }
fontFamily: '"Roboto Condensed", system-ui, sans-serif', : null;
padding: '4px 8px', return (
boxSizing: 'border-box', <div style={{
textAlign: el.textAlign || 'center', width: '100%', height: '100%',
lineHeight: 1.2, display: 'flex',
whiteSpace: 'pre-wrap', alignItems: 'center',
wordBreak: 'break-word', justifyContent: el.textAlign === 'left' ? 'flex-start'
pointerEvents: 'none', : el.textAlign === 'right' ? 'flex-end' : 'center',
}}> color: el.textColor || '#f0e6d8',
{el.text} fontSize: el.textSize || 16,
</div> fontWeight: el.fontWeight || 500,
)} fontFamily: '"Roboto Condensed", system-ui, sans-serif',
padding: '4px 8px',
boxSizing: 'border-box',
textAlign: el.textAlign || 'center',
lineHeight: 1.2,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
pointerEvents: 'none',
...(strokeStyle || {}),
}}>
{el.text}
</div>
);
})()}
{/* Задача 03: Бейдж в углу — отдельный absolute-элемент. */}
{el.badge && (() => {
const b = el.badge;
const corner = b.corner || 'top-right';
const cornerStyle = {
'top-right': { top: -6, right: -6 },
'top-left': { top: -6, left: -6 },
'bottom-right': { bottom: -6, right: -6 },
'bottom-left': { bottom: -6, left: -6 },
}[corner] || { top: -6, right: -6 };
const icons = {
exclamation: '!',
star: '★',
plus: '+',
new: 'NEW',
sale: '%',
};
const text = b.text != null ? b.text : (icons[b.icon] || '!');
const big = b.icon === 'new';
return (
<div style={{
position: 'absolute',
...cornerStyle,
minWidth: big ? 32 : 22,
height: 22,
padding: '0 6px',
background: b.color || '#fbbf24',
color: '#3a1a00',
borderRadius: big ? 6 : 11,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: big ? 11 : 14,
fontWeight: 800,
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
boxShadow: '0 2px 4px rgba(0,0,0,0.4)',
border: '2px solid #fff',
pointerEvents: 'none',
zIndex: 5,
transform: 'rotate(8deg)',
}}>{text}</div>
);
})()}
{/* TextBox настоящий <input> в Play (принимает ввод), {/* TextBox настоящий <input> в Play (принимает ввод),
в редакторе статичный вид с placeholder. */} в редакторе статичный вид с placeholder. */}
@ -483,11 +552,30 @@ function GuiElement({ el, allElements, childrenMap, isPlaying, selectedId, onSel
*/ */
function layoutChildren(container, children) { function layoutChildren(container, children) {
const layout = container && container.layout; const layout = container && container.layout;
if (layout !== 'vertical' && layout !== 'horizontal') return children; if (layout !== 'vertical' && layout !== 'horizontal' && layout !== 'grid') return children;
const gap = Number.isFinite(container.layoutGap) ? container.layoutGap : 2; const gap = Number.isFinite(container.layoutGap) ? container.layoutGap : 2;
const pad = Number.isFinite(container.layoutPad) ? container.layoutPad : 3; const pad = Number.isFinite(container.layoutPad) ? container.layoutPad : 3;
// scrollY сдвиг прокрутки (для type='scroll'). // scrollY -- сдвиг прокрутки (для type='scroll').
const scrollY = Number.isFinite(container.scrollY) ? container.scrollY : 0; const scrollY = Number.isFinite(container.scrollY) ? container.scrollY : 0;
// Phase 6.3.2: Grid layout -- авто-сетка с заданной шириной ячейки.
// layoutCellW/H -- размер ячейки в %, layoutCols -- сколько колонок (если 0 -- авто).
if (layout === 'grid') {
const cellW = Number.isFinite(container.layoutCellW) ? container.layoutCellW : 18;
const cellH = Number.isFinite(container.layoutCellH) ? container.layoutCellH : 18;
const availW = 100 - pad * 2;
// Авто-вычисление кол-ва колонок если не задано.
let cols = Number(container.layoutCols) || 0;
if (cols < 1) cols = Math.max(1, Math.floor((availW + gap) / (cellW + gap)));
return children.map((ch, i) => {
const row = Math.floor(i / cols);
const col = i % cols;
const nx = pad + col * (cellW + gap);
const ny = pad + row * (cellH + gap) - scrollY;
return { ...ch, x: nx, y: ny, w: cellW, h: cellH, anchor: 'top-left' };
});
}
let cursor = pad; let cursor = pad;
return children.map((ch) => { return children.map((ch) => {
const w = ch.w ?? 20, h = ch.h ?? 10; const w = ch.w ?? 20, h = ch.h ?? 10;
@ -501,7 +589,7 @@ function layoutChildren(container, children) {
ny = pad - scrollY; ny = pad - scrollY;
cursor += w + gap; cursor += w + gap;
} }
// Якорь top-left координаты считаются от левого-верхнего угла. // Якорь top-left -- координаты считаются от левого-верхнего угла.
return { ...ch, x: nx, y: ny, anchor: 'top-left' }; return { ...ch, x: nx, y: ny, anchor: 'top-left' };
}); });
} }
@ -609,22 +697,77 @@ function elementToStyle(el) {
const w = `${el.w ?? 20}%`; const w = `${el.w ?? 20}%`;
const h = `${el.h ?? 10}%`; const h = `${el.h ?? 10}%`;
const anchor = el.anchor || 'center'; const anchor = el.anchor || 'center';
// Phase 6.3.1: AnchorPoint -- точка ВНУТРИ элемента (0..1 по обеим осям),
// относительно которой считается позиция. Если el.anchorPoint не задан,
// вычисляем по anchor: center {0.5, 0.5}, top-left {0, 0}, и т.д.
// (это сохраняет старое поведение). Юзер может override через anchorPoint.
const apDefault = {
x: anchor === 'right' || anchor.endsWith('-right') ? 1
: (anchor === 'left' || anchor.endsWith('-left') ? 0 : 0.5),
y: anchor === 'bottom' || anchor.startsWith('bottom-') ? 1
: (anchor === 'top' || anchor.startsWith('top-') ? 0 : 0.5),
};
const ap = el.anchorPoint && typeof el.anchorPoint === 'object'
? {
x: typeof el.anchorPoint.x === 'number' ? el.anchorPoint.x : apDefault.x,
y: typeof el.anchorPoint.y === 'number' ? el.anchorPoint.y : apDefault.y,
}
: apDefault;
let left, top, transform; let left, top, transform;
// Левый/верх вычисляется по anchor (ссылочная точка на экране).
// translate(-anchorPoint*100% по каждой оси) -- сдвиг сам элемент так,
// чтобы anchorPoint оказался в (left, top).
const tx = -ap.x * 100;
const ty = -ap.y * 100;
switch (anchor) { switch (anchor) {
case 'top-left': left = `${el.x ?? 0}%`; top = `${el.y ?? 0}%`; transform = 'translate(0, 0)'; break; case 'top-left': left = `${el.x ?? 0}%`; top = `${el.y ?? 0}%`; break;
case 'top-right': left = `${100 - (el.x ?? 0)}%`; top = `${el.y ?? 0}%`; transform = 'translate(-100%, 0)'; break; case 'top-right': left = `${100 - (el.x ?? 0)}%`; top = `${el.y ?? 0}%`; break;
case 'bottom-left': left = `${el.x ?? 0}%`; top = `${100 - (el.y ?? 0)}%`; transform = 'translate(0, -100%)'; break; case 'bottom-left': left = `${el.x ?? 0}%`; top = `${100 - (el.y ?? 0)}%`; break;
case 'bottom-right': left = `${100 - (el.x ?? 0)}%`; top = `${100 - (el.y ?? 0)}%`; transform = 'translate(-100%, -100%)'; break; case 'bottom-right': left = `${100 - (el.x ?? 0)}%`; top = `${100 - (el.y ?? 0)}%`; break;
// Phase 6.3.1: одиночные стороны -- ссылочная точка на середине стороны.
case 'top': left = `${el.x ?? 50}%`; top = `${el.y ?? 0}%`; break;
case 'bottom': left = `${el.x ?? 50}%`; top = `${100 - (el.y ?? 0)}%`; break;
case 'left': left = `${el.x ?? 0}%`; top = `${el.y ?? 50}%`; break;
case 'right': left = `${100 - (el.x ?? 0)}%`; top = `${el.y ?? 50}%`; break;
case 'center': case 'center':
default: left = `${el.x ?? 50}%`; top = `${el.y ?? 50}%`; transform = 'translate(-50%, -50%)'; break; default: left = `${el.x ?? 50}%`; top = `${el.y ?? 50}%`; break;
} }
// Задача 03: rotation + scale через transform. Добавляются ПОСЛЕ translate.
// hoverScale/activeScale хранятся в el._dynScale (выставляется hover-handler'ом
// в GuiElement через mutate-ref). При штатном рендере читаем el.scaleX/scaleY.
const sx = (typeof el._dynScaleX === 'number' ? el._dynScaleX : 1)
* (typeof el.scaleX === 'number' ? el.scaleX : 1);
const sy = (typeof el._dynScaleY === 'number' ? el._dynScaleY : 1)
* (typeof el.scaleY === 'number' ? el.scaleY : 1);
const rot = (typeof el._dynRotation === 'number' ? el._dynRotation : 0)
+ (typeof el.rotation === 'number' ? el.rotation : 0);
const brightness = (typeof el._dynBrightness === 'number' ? el._dynBrightness : 1);
transform = `translate(${tx}%, ${ty}%)`;
if (sx !== 1 || sy !== 1) transform += ` scale(${sx}, ${sy})`;
if (rot) transform += ` rotate(${rot}deg)`;
let bg = el.bgColor || '#1f1810'; let bg = el.bgColor || '#1f1810';
const opacity = el.bgOpacity == null ? 1 : Math.max(0, Math.min(1, el.bgOpacity)); const opacity = el.bgOpacity == null ? 1 : Math.max(0, Math.min(1, el.bgOpacity));
if (bg === 'transparent' || opacity === 0) bg = 'transparent'; if (bg === 'transparent' || opacity === 0) bg = 'transparent';
else bg = hexToRgba(bg, opacity); else bg = hexToRgba(bg, opacity);
// Задача 03: bgGradient { stops: ['#a','#b'] | [{c,p}], angle: 0..360 }.
// Если задан перебиваем background.
if (el.bgGradient && Array.isArray(el.bgGradient.stops) && el.bgGradient.stops.length >= 2) {
const angle = Number.isFinite(el.bgGradient.angle) ? el.bgGradient.angle : 90;
const parts = el.bgGradient.stops.map((s, i, arr) => {
if (typeof s === 'string') {
const p = (i / (arr.length - 1)) * 100;
return `${s} ${p.toFixed(1)}%`;
}
const c = s.c || '#000';
const p = typeof s.p === 'number' ? s.p * 100 : (i / (arr.length - 1)) * 100;
return `${c} ${p.toFixed(1)}%`;
});
bg = `linear-gradient(${angle}deg, ${parts.join(', ')})`;
}
return { return {
position: 'absolute', position: 'absolute',
left, top, transform, left, top, transform,
transformOrigin: 'center center',
width: w, height: h, width: w, height: h,
background: bg, background: bg,
border: el.borderWidth > 0 border: el.borderWidth > 0
@ -632,14 +775,11 @@ function elementToStyle(el) {
: 'none', : 'none',
borderRadius: (el.borderRadius || 0) + 'px', borderRadius: (el.borderRadius || 0) + 'px',
boxSizing: 'border-box', boxSizing: 'border-box',
// Тень: явный флаг shadow мягкая drop-shadow; у кнопок
// лёгкая тень по умолчанию (как было). shadow=true усиливает.
boxShadow: el.shadow boxShadow: el.shadow
? '0 6px 16px rgba(0,0,0,0.45)' ? '0 6px 16px rgba(0,0,0,0.45)'
: (el.type === 'button' ? '0 2px 6px rgba(0,0,0,0.3)' : 'none'), : (el.type === 'button' ? '0 2px 6px rgba(0,0,0,0.3)' : 'none'),
// Frame обрезает детей по своей границе (как ScreenGui в Roblox).
// Для не-frame оставляем visible чтобы текст не клипался.
overflow: el.type === 'frame' ? 'hidden' : 'visible', overflow: el.type === 'frame' ? 'hidden' : 'visible',
filter: brightness !== 1 ? `brightness(${brightness})` : undefined,
}; };
} }

View File

@ -16,6 +16,13 @@ import Icon from './Icon';
function Hotbar({ visible, slots = [], activeIndex = 0, onSelect, mobileMode = false }) { function Hotbar({ visible, slots = [], activeIndex = 0, onSelect, mobileMode = false }) {
if (!visible) return null; if (!visible) return null;
// ГЛОБАЛЬНОЕ ПРАВИЛО (для всех игр тулбокса): если в инвентаре нет ни
// одного предмета панель инвентаря НЕ показываем вовсе. Пустой hotbar
// из 5 серых ячеек загромождает экран в играх, где инвентарь не нужен.
// Панель появится автоматически, как только в слот попадёт предмет.
const hasAnyItem = Array.isArray(slots) && slots.some((s) => s != null);
if (!hasAnyItem) return null;
const SLOT_COUNT = 5; const SLOT_COUNT = 5;
const cells = []; const cells = [];
for (let i = 0; i < SLOT_COUNT; i++) { for (let i = 0; i < SLOT_COUNT; i++) {

View File

@ -313,7 +313,7 @@ const EMOJI_TO_NAME = {
'◣': 'prim-wedge', '◢': 'prim-cornerwedge', '〰': 'waves', '◣': 'prim-wedge', '◢': 'prim-cornerwedge', '〰': 'waves',
// UI / художественные // UI / художественные
'🎨': 'palette', '📺': 'monitor', '🖼': 'image', '🖼️': 'image', '🎨': 'palette', '📺': 'monitor', '🖼': 'image', '🖼️': 'image',
'🔤': 'type', '🟧': 'square', '🔤': 'type',
// звук // звук
'🎵': 'music', '🎼': 'music2', '🔊': 'sound', '🎵': 'music', '🎼': 'music2', '🔊': 'sound',
// навигация // навигация

View File

@ -0,0 +1,101 @@
/**
* ModalOverlay рендерит затемнение модальной сцены.
* Задача 04. Подписан на ModalManager.setOnChange получает state.
*
* Архитектура:
* - Слой ПОД GUI-overlay (z-index ниже GuiOverlay) но НАД Babylon-канвасом.
* - Если target='screen' слой поверх ВСЕГО (включая GUI). z-index выше.
* - Spotlights через CSS mask-image: radial-gradient(...) вырезает «дырки».
* - pointer-events: auto когда модал открыт (перехватывает клики кроме GUI).
*/
import React, { useEffect, useState } from 'react';
export default function ModalOverlay({ scene }) {
const [state, setState] = useState(null);
// Поллинг надёжнее чем setOnChange callback, который может перетереться
// или не вызваться если scene изменился на следующем кадре.
useEffect(() => {
if (!scene?.modalManager) return;
let cancelled = false;
const tick = () => {
if (cancelled) return;
const s = scene.modalManager.getState?.();
// Снимок shallow-clone иначе React не увидит изменение
setState(s ? {
id: s.id,
fadePhase: s.fadePhase,
currentAlpha: s.currentAlpha,
opts: s.opts,
spotlightScreens: s.spotlightScreens,
} : null);
requestAnimationFrame(tick);
};
tick();
return () => { cancelled = true; };
}, [scene]);
if (!state || state.fadePhase === 'closed') return null;
if (state.currentAlpha <= 0.001) return null;
console.log('[ModalOverlay] RENDERING alpha=', state.currentAlpha.toFixed(2), 'phase=', state.fadePhase, 'target=', state.opts?.target);
const opts = state.opts;
const isScreen = opts.target === 'screen';
const color = opts.darkenColor || '#000000';
const alpha = Math.max(0, Math.min(1, state.currentAlpha));
// RGBA bg
const bg = _hexToRgba(color, alpha);
// mask-image для spotlights (только для target='scene' на 'screen' нет смысла)
let maskStyle = {};
if (!isScreen && Array.isArray(state.spotlightScreens) && state.spotlightScreens.length) {
const softEdge = opts.spotlightSoftEdge ?? 40;
const gradients = state.spotlightScreens.map(s => {
const inner = Math.max(0, s.r - softEdge);
const outer = s.r;
// mask-image: внутри круга transparent (вырезаем), снаружи black (показываем затемнение)
return `radial-gradient(circle at ${s.x.toFixed(0)}px ${s.y.toFixed(0)}px, transparent ${inner}px, black ${outer}px)`;
});
maskStyle = {
WebkitMaskImage: gradients.join(', '),
maskImage: gradients.join(', '),
WebkitMaskComposite: 'source-in',
maskComposite: 'intersect',
};
}
// ВАЖНО pointer-events: none иначе overlay перехватывает клики и кнопки модала не работают.
// Затемнение это просто визуальный фильтр, blockInput реализован в PlayerController.
// zIndex:
// target='scene' 24 (под GuiOverlay zIndex=25 чтобы GUI был ВИДЕН поверх затемнения)
// target='screen' 60 (поверх GUI закрывает ВСЁ)
// Для 'screen' GUI модала всё равно поверх (GuiOverlay zIndex=25, наш ScreenOverlay 60,
// GUI элементы модала рендерятся в GuiOverlay поэтому надо ставить их в отдельный
// слой ВЫШЕ overlay). Простой фикс: для screen ставим overlay на 24 тоже.
const zIdx = 24;
return (
<div
style={{
position: 'absolute', inset: 0,
background: bg,
zIndex: zIdx,
pointerEvents: 'none', // НЕ перехватываем клики иначе кнопки не работают
transition: 'background-color 0.05s linear',
...maskStyle,
}}
data-modal-overlay={state.id}
/>
);
}
function _hexToRgba(hex, a) {
if (typeof hex !== 'string' || !hex.startsWith('#')) return `rgba(0,0,0,${a})`;
let h = hex.slice(1);
if (h.length === 3) h = h.split('').map(c => c + c).join('');
if (h.length !== 6) return `rgba(0,0,0,${a})`;
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
return `rgba(${r},${g},${b},${a})`;
}

View File

@ -0,0 +1,294 @@
/**
* SkinShopOverlay встроенный магазин скинов игрока (задача 07).
*
* Готовый GUI-кит: полноэкранная витрина карточек скинов. Открывается
* клавишей B в Play или через game.player.openSkinShop(). Логика покупки
* (списание локальных рубликов проекта, unlock, setSkin) живёт в GameRuntime;
* этот компонент только рендерит состояние и шлёт намерение «купить/надеть».
*
* Подписка на состояние rAF-поллинг scene.getSkinShopState() (как ModalOverlay):
* { open, rev, data: { all:[{slug,name,kind,category,price}], unlocked:[slug],
* current, coins, shopVisible } }
*
* Превью скина цветная плашка по категории + крупная самописная SVG-иконка
* (правило проекта: без эмодзи в UI). Категории: human/animal/food/vehicle/robot.
*/
import React, { useEffect, useState, useMemo } from 'react';
// Палитра градиентов по категории чтобы витрина была живой и читаемой.
const CAT_THEME = {
human: { from: '#3b6cff', to: '#1e2da5', label: 'Люди' },
animal: { from: '#46b34a', to: '#1f6b2a', label: 'Животные' },
food: { from: '#ff8a3d', to: '#c2410c', label: 'Еда' },
vehicle: { from: '#9b59ff', to: '#5b21b6', label: 'Транспорт' },
robot: { from: '#22c6d6', to: '#0e7490', label: 'Роботы' },
custom: { from: '#fbbf24', to: '#b45309', label: 'Свои' },
};
const CAT_ORDER = ['all', 'human', 'animal', 'food', 'vehicle', 'robot', 'custom'];
// Самописные SVG-иконки категорий (viewBox 24×24, обводка currentColor).
function CatGlyph({ cat, size = 46 }) {
const st = { fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' };
let body;
switch (cat) {
case 'human':
body = (<><circle cx="12" cy="7" r="3.2" {...st} /><path d="M5 21c0-4 3.2-7 7-7s7 3 7 7" {...st} /></>);
break;
case 'animal': // мордочка зверя с ушами
body = (<><path d="M5 6l2.5 3M19 6l-2.5 3" {...st} /><circle cx="12" cy="13" r="7" {...st} /><circle cx="9.5" cy="12" r="0.9" fill="currentColor" stroke="none" /><circle cx="14.5" cy="12" r="0.9" fill="currentColor" stroke="none" /><path d="M10.5 16c1 0.8 2 0.8 3 0" {...st} /></>);
break;
case 'food': // пончик
body = (<><circle cx="12" cy="12" r="8" {...st} /><circle cx="12" cy="12" r="2.6" {...st} /><path d="M7 8.5l0.5 1M16.5 9l-0.7 0.9M9 16l0.6-1M15.5 15.5l-0.7-0.9" {...st} /></>);
break;
case 'vehicle': // машинка
body = (<><path d="M3 14l1.5-4.5A2 2 0 0 1 6.4 8h11.2a2 2 0 0 1 1.9 1.5L21 14v3h-2" {...st} /><path d="M3 14v3h2" {...st} /><circle cx="7.5" cy="17" r="1.8" {...st} /><circle cx="16.5" cy="17" r="1.8" {...st} /></>);
break;
case 'robot': // голова робота
body = (<><rect x="6" y="8" width="12" height="10" rx="2" {...st} /><path d="M12 8V5M9 5h6" {...st} /><circle cx="9.5" cy="13" r="1" fill="currentColor" stroke="none" /><circle cx="14.5" cy="13" r="1" fill="currentColor" stroke="none" /></>);
break;
default: // custom звезда
body = (<path d="M12 4l2.2 4.8L19 9.4l-3.6 3.3 1 5-4.4-2.5L7.6 17.7l1-5L5 9.4l4.8-0.6z" {...st} />);
}
return (<svg width={size} height={size} viewBox="0 0 24 24" style={{ display: 'block' }}>{body}</svg>);
}
// Монета-рублик (для баланса/цены).
function CoinIcon({ size = 16 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" style={{ display: 'inline-block', verticalAlign: 'middle' }}>
<circle cx="12" cy="12" r="9" fill="#ffd24a" stroke="#a86b00" strokeWidth="1.6" />
<text x="12" y="16" textAnchor="middle" fontSize="11" fontWeight="900" fill="#7a4d00"></text>
</svg>
);
}
export default function SkinShopOverlay({ scene }) {
const [snap, setSnap] = useState(null);
const [cat, setCat] = useState('all');
// rAF-поллинг состояния магазина из сцены.
useEffect(() => {
if (!scene?.getSkinShopState) return;
let cancelled = false;
let lastRev = -1;
const tick = () => {
if (cancelled) return;
const s = scene.getSkinShopState?.();
if (s && s.rev !== lastRev) {
lastRev = s.rev;
setSnap({
open: s.open,
data: s.data,
buyResult: s.buyResult,
});
} else if (!s && lastRev !== -1) {
lastRev = -1;
setSnap(null);
}
requestAnimationFrame(tick);
};
tick();
return () => { cancelled = true; };
}, [scene]);
const data = snap?.data || null;
// Список скинов с категориями (фильтрованный).
const skins = useMemo(() => {
const all = (data?.all) || [];
if (cat === 'all') return all;
return all.filter(s => (s.category || 'human') === cat);
}, [data, cat]);
// Какие категории реально есть для табов.
const cats = useMemo(() => {
const present = new Set((data?.all || []).map(s => s.category || 'human'));
return CAT_ORDER.filter(c => c === 'all' || present.has(c));
}, [data]);
if (!snap || !snap.open || !data) return null;
const unlocked = new Set(data.unlocked || []);
const current = data.current;
const coins = data.coins || 0;
const close = () => { try { scene._closeSkinShop?.(); } catch (e) {} };
const onCardClick = (s) => {
const owned = unlocked.has(s.slug);
const price = s.price || 0;
if (!owned && coins < price) return; // не хватает карточка покажет это
try { scene.requestBuySkin?.(s.slug, price); } catch (e) {}
};
return (
<div
style={{
position: 'absolute', inset: 0, zIndex: 55,
background: 'rgba(6, 9, 20, 0.72)',
backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
}}
onClick={close}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: 'min(880px, 92vw)', maxHeight: '86vh',
background: 'linear-gradient(160deg, #161b2e 0%, #0c1020 100%)',
border: '2px solid #2b3a66', borderRadius: 20,
boxShadow: '0 24px 60px rgba(0,0,0,0.55)',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}
>
{/* Шапка */}
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '16px 20px',
borderBottom: '1px solid rgba(255,255,255,0.08)',
background: 'linear-gradient(90deg, rgba(59,108,255,0.18), transparent)',
}}>
<div style={{ fontSize: 22, fontWeight: 900, color: '#fff', letterSpacing: 0.3 }}>
Магазин скинов
</div>
<div style={{ flex: 1 }} />
{/* Баланс */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'rgba(255, 210, 74, 0.14)',
border: '1px solid rgba(255, 210, 74, 0.4)',
borderRadius: 999, padding: '6px 14px',
color: '#ffd24a', fontWeight: 900, fontSize: 16,
}}>
<CoinIcon size={18} /> {coins}
</div>
{/* Закрыть */}
<button
onClick={close}
style={{
width: 34, height: 34, borderRadius: 10, cursor: 'pointer',
background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.16)',
color: '#fff', fontSize: 18, fontWeight: 700, lineHeight: 1,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
title="Закрыть (B / Esc)"
>×</button>
</div>
{/* Табы категорий */}
<div style={{ display: 'flex', gap: 8, padding: '12px 20px 4px', flexWrap: 'wrap' }}>
{cats.map(c => {
const active = c === cat;
const label = c === 'all' ? 'Все' : (CAT_THEME[c]?.label || c);
return (
<button
key={c}
onClick={() => setCat(c)}
style={{
padding: '6px 14px', borderRadius: 999, cursor: 'pointer',
fontSize: 13, fontWeight: 800,
background: active ? 'linear-gradient(135deg, #3b6cff, #1e2da5)' : 'rgba(255,255,255,0.06)',
border: active ? '1px solid #6b8cff' : '1px solid rgba(255,255,255,0.12)',
color: active ? '#fff' : '#aab4d4',
}}
>{label}</button>
);
})}
</div>
{/* Сетка карточек */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
gap: 14, padding: 20, overflowY: 'auto',
}}>
{skins.map(s => {
const theme = CAT_THEME[s.category] || CAT_THEME.human;
const owned = unlocked.has(s.slug);
const isActive = current === s.slug;
const price = s.price || 0;
const canAfford = owned || coins >= price;
return (
<div
key={s.slug}
onClick={() => onCardClick(s)}
style={{
borderRadius: 16, overflow: 'hidden', cursor: canAfford ? 'pointer' : 'not-allowed',
border: isActive ? '3px solid #22ff88' : '2px solid rgba(255,255,255,0.10)',
background: 'rgba(255,255,255,0.04)',
opacity: canAfford ? 1 : 0.55,
transition: 'transform 0.1s, border-color 0.15s',
position: 'relative',
}}
onMouseEnter={(e) => { if (canAfford) e.currentTarget.style.transform = 'translateY(-3px)'; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'none'; }}
>
{/* Превью-плашка с иконкой категории */}
<div style={{
height: 96, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: `linear-gradient(150deg, ${theme.from}, ${theme.to})`,
color: 'rgba(255,255,255,0.92)',
}}>
<CatGlyph cat={s.category || 'human'} size={50} />
</div>
{/* Бейдж активного/купленного */}
{isActive && (
<div style={badgeStyle('#22ff88', '#04361b')}>Надет</div>
)}
{!isActive && owned && (
<div style={badgeStyle('#ffd24a', '#5a3a00')}>Куплено</div>
)}
{/* Низ карточки: имя + цена/статус */}
<div style={{ padding: '10px 12px' }}>
<div style={{
color: '#fff', fontWeight: 800, fontSize: 14,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{s.name || s.slug}</div>
<div style={{ marginTop: 6, minHeight: 22 }}>
{isActive ? (
<span style={{ color: '#22ff88', fontWeight: 800, fontSize: 13 }}>Активен</span>
) : owned ? (
<span style={{ color: '#9fb0d8', fontWeight: 700, fontSize: 13 }}>Нажми, чтобы надеть</span>
) : price === 0 ? (
<span style={{ color: '#7fe0a0', fontWeight: 800, fontSize: 13 }}>Бесплатно</span>
) : (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
color: canAfford ? '#ffd24a' : '#ff7a7a', fontWeight: 900, fontSize: 14,
}}>
<CoinIcon size={15} /> {price}
</span>
)}
</div>
</div>
</div>
);
})}
{skins.length === 0 && (
<div style={{ color: '#8a93b4', gridColumn: '1 / -1', textAlign: 'center', padding: 30 }}>
В этой категории пока нет скинов
</div>
)}
</div>
{/* Подвал-подсказка */}
<div style={{
padding: '10px 20px', borderTop: '1px solid rgba(255,255,255,0.08)',
color: '#6b76a0', fontSize: 12, textAlign: 'center',
}}>
Нажми <b style={{ color: '#aab4d4' }}>B</b> или <b style={{ color: '#aab4d4' }}>Esc</b>, чтобы закрыть
</div>
</div>
</div>
);
}
function badgeStyle(bg, fg) {
return {
position: 'absolute', top: 8, right: 8,
background: bg, color: fg,
fontSize: 11, fontWeight: 900, padding: '3px 8px', borderRadius: 999,
boxShadow: '0 2px 6px rgba(0,0,0,0.4)',
};
}

View File

@ -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;
}
}

View File

@ -53,18 +53,30 @@ import { placeVoxelTree, TREE_TYPES } from './VoxelTreeBuilder';
import { UserModelManager, parseUserModelId, USER_MODEL_PREFIX } from './UserModelManager'; import { UserModelManager, parseUserModelId, USER_MODEL_PREFIX } from './UserModelManager';
import { ModelManager } from './ModelManager'; import { ModelManager } from './ModelManager';
import { PrimitiveManager } from './PrimitiveManager'; import { PrimitiveManager } from './PrimitiveManager';
import { BillboardUiManager } from './BillboardUiManager';
import { getPrimitiveType } from './PrimitiveTypes'; import { getPrimitiveType } from './PrimitiveTypes';
import { FolderManager } from './FolderManager'; import { FolderManager } from './FolderManager';
import { GuiManager } from './GuiManager'; import { GuiManager } from './GuiManager';
import { ModalManager } from './ModalManager';
import { InventoryManager } from './InventoryManager'; import { InventoryManager } from './InventoryManager';
import { WeaponSystem } from './WeaponSystem'; import { WeaponSystem } from './WeaponSystem';
import { ZombieManager } from './ZombieManager'; import { ZombieManager } from './ZombieManager';
import { NpcManager } from './NpcManager'; import { NpcManager } from './NpcManager';
import { ConstraintManager } from './ConstraintManager'; import { ConstraintManager } from './ConstraintManager';
import { BeamManager } from './BeamManager'; import { BeamManager } from './BeamManager';
import { PlacementManager } from './PlacementManager';
import { ShopInventoryUi } from './ShopInventoryUi';
import { LoadingScreenOverlay } from './LoadingScreenOverlay';
import { VehicleManager } from './VehicleManager';
import { VehicleHud } from './VehicleHud';
import { ZombieSpawnerManager } from './ZombieSpawnerManager'; import { ZombieSpawnerManager } from './ZombieSpawnerManager';
import { DynamicsManager } from './DynamicsManager'; import { DynamicsManager } from './DynamicsManager';
import { Environment } from './Environment'; import { Environment } from './Environment';
import { SkyboxManager } from './SkyboxManager';
import { LeaderstatsManager } from './LeaderstatsManager';
import { AchievementsManager } from './AchievementsManager';
import { FloaterManager } from './FloaterManager'; // задача 40 — damage floaters
import { InventoryUI } from './InventoryUI'; // задача 44 — drag-drop инвентарь
import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager'; import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager';
import { GameAudioManager } from './GameAudioManager'; import { GameAudioManager } from './GameAudioManager';
import { AssetManager } from './AssetManager'; import { AssetManager } from './AssetManager';
@ -84,6 +96,7 @@ import { GdForest } from './GdForest';
import { GdPlayerCube } from './GdPlayerCube'; import { GdPlayerCube } from './GdPlayerCube';
import { GdPlayerTrail } from './GdPlayerTrail'; import { GdPlayerTrail } from './GdPlayerTrail';
import { GdPostFx } from './GdPostFx'; import { GdPostFx } from './GdPostFx';
import { GraphicsManager } from './GraphicsManager';
import { PhysicsAABB } from './PhysicsAABB'; import { PhysicsAABB } from './PhysicsAABB';
import { PlayerController } from './PlayerController'; import { PlayerController } from './PlayerController';
import { SelectionManager } from './SelectionManager'; import { SelectionManager } from './SelectionManager';
@ -142,6 +155,20 @@ export class BabylonScene {
this.npcManager = null; // управляемые скриптом NPC (Фаза 4.1) this.npcManager = null; // управляемые скриптом NPC (Фаза 4.1)
this.constraintManager = null; // связи объектов (Фаза 5, Constraints) this.constraintManager = null; // связи объектов (Фаза 5, Constraints)
this.beamManager = null; // лучи и следы (Фаза 5.2) this.beamManager = null; // лучи и следы (Фаза 5.2)
// Placement mode (задача 11) — фича-парность со студией.
this.placementManager = null;
this.shopInventoryUi = null;
this.vehicleManager = null; // задача 14
this.vehicleHud = null;
this._VehicleHudClass = VehicleHud;
this._PlacementManagerClass = PlacementManager;
this._ShopInventoryUiClass = ShopInventoryUi;
// Экран загрузки (задача 12).
this.loadingScreen = null;
this._LoadingScreenOverlayClass = LoadingScreenOverlay;
this._loadingConfig = null;
this._mainMenuConfig = null; // задача 13
this._projectThumbnail = null;
this.spawnerManager = null; // спавнеры зомби this.spawnerManager = null; // спавнеры зомби
this.environment = null; this.environment = null;
this.audioManager = null; this.audioManager = null;
@ -1266,8 +1293,16 @@ export class BabylonScene {
// Ссылка на обёртку BabylonScene — нужна для эмиттеров частиц // Ссылка на обёртку BabylonScene — нужна для эмиттеров частиц
// (createEmitterParticles живёт на обёртке). // (createEmitterParticles живёт на обёртке).
this.primitiveManager.scene3d = this; this.primitiveManager.scene3d = this;
// BillboardUiManager — отдельный модуль, рисует GUI на DynamicTexture
// для билбордов. Нужен PrimitiveManager-у чтобы при создании billboard
// (type='billboard') сразу применить текстуру с дефолтным пресетом.
this.billboardUiManager = new BillboardUiManager(this.scene);
this.primitiveManager.billboardUiManager = this.billboardUiManager;
this.folderManager = new FolderManager(this.blockManager, this.modelManager, this.primitiveManager); this.folderManager = new FolderManager(this.blockManager, this.modelManager, this.primitiveManager);
this.guiManager = new GuiManager(); this.guiManager = new GuiManager();
this.modalManager = new ModalManager();
this.modalManager.attachScene(this);
this.modalManager.attachGui(this.guiManager);
this.inventory = new InventoryManager(); this.inventory = new InventoryManager();
this.physics = new PhysicsAABB(this.blockManager); this.physics = new PhysicsAABB(this.blockManager);
// Сразу синхронизируем границу пола с текущим размером мира, // Сразу синхронизируем границу пола с текущим размером мира,
@ -1280,6 +1315,7 @@ export class BabylonScene {
// Voxel-террейн тоже участвует в физике. У террейна свой размер // Voxel-террейн тоже участвует в физике. У террейна свой размер
// ячейки (TERRAIN_VOXEL_SIZE = 0.5), поэтому передаём отдельно. // ячейки (TERRAIN_VOXEL_SIZE = 0.5), поэтому передаём отдельно.
this.physics.setTerrainManager(this.terrainManager, TERRAIN_VOXEL_SIZE); this.physics.setTerrainManager(this.terrainManager, TERRAIN_VOXEL_SIZE);
this.vehicleManager = new VehicleManager(this); // задача 14
// Этап 4 voxel-движка: подключаем новый chunk-based VoxelWorld к физике. // Этап 4 voxel-движка: подключаем новый chunk-based VoxelWorld к физике.
// Физика проверяет коллизии в обоих источниках (legacy terrainManager + // Физика проверяет коллизии в обоих источниках (legacy terrainManager +
// voxelWorld), что позволяет постепенно мигрировать без поломки. // voxelWorld), что позволяет постепенно мигрировать без поломки.
@ -1288,6 +1324,11 @@ export class BabylonScene {
} }
this.dynamics = new DynamicsManager(this); this.dynamics = new DynamicsManager(this);
this.environment = new Environment(this.scene, this._hemiLight, this._sunLight); this.environment = new Environment(this.scene, this._hemiLight, this._sunLight);
this.skybox = new SkyboxManager(this.scene, this._hemiLight, this._sunLight); // задача 16 — кастомное небо (единый источник света)
this.floaters = new FloaterManager(this); // задача 40 — damage floaters
this.invUI = new InventoryUI(this); // задача 44 — drag-drop инвентарь
this.leaderstats = new LeaderstatsManager(this); // задача 20 — лидерборды
this.achievements = new AchievementsManager(this); // задача 20 — достижения
this.audioManager = new AudioManager(); this.audioManager = new AudioManager();
this.assetManager = new AssetManager(); this.assetManager = new AssetManager();
// PrimitiveManager должен уметь брать dataURL картинки по id ассета, // PrimitiveManager должен уметь брать dataURL картинки по id ассета,
@ -1391,6 +1432,18 @@ export class BabylonScene {
if (this._isPlaying && this.environment) { if (this._isPlaying && this.environment) {
this.environment.tick(dt); this.environment.tick(dt);
} }
// Небо: дрейф облаков + fadeTo
if (this.skybox) {
this.skybox.tick(dt);
}
// Лидерборды (задача 20) — рендер HUD-таблицы при изменениях.
if (this._isPlaying && this.leaderstats) {
this.leaderstats.tick();
}
// Damage floaters (задача 40) — анимация всплывающих цифр.
if (this.floaters) {
this.floaters.tick(dt);
}
// Анимация жидкостей — работает всегда (и в редакторе) // Анимация жидкостей — работает всегда (и в редакторе)
if (this.blockManager) { if (this.blockManager) {
this.blockManager.tick(dt); this.blockManager.tick(dt);
@ -1474,6 +1527,14 @@ export class BabylonScene {
} }
} }
} }
// Задача 04: modalManager.tick — независимо от runtime'а
if (this._isPlaying && this.modalManager?.tick) {
try { this.modalManager.tick(dt); } catch (e) {}
}
// Задача 12: loadingScreen.tick — fade/auto-duration независимо от paused.
if (this._isPlaying && this.loadingScreen?.tick) {
try { this.loadingScreen.tick(dt); } catch (e) {}
}
// Tick пользовательских скриптов: в Play-режиме или в solo-debug // Tick пользовательских скриптов: в Play-режиме или в solo-debug
if (this.gameRuntime && (this._isPlaying || this.gameRuntime.isSolo?.())) { if (this.gameRuntime && (this._isPlaying || this.gameRuntime.isSolo?.())) {
this.gameRuntime.tick(dt); this.gameRuntime.tick(dt);
@ -1589,6 +1650,42 @@ export class BabylonScene {
this._ssaoEnabled = false; this._ssaoEnabled = false;
} }
/**
* Система графики/эффектов («шейдеры»). Лениво создаём GraphicsManager.
* Идентична студийной (фича-парность). Применяется при загрузке игры,
* если автор настроил graphics в проекте (и не 'off').
*/
_ensureGraphics() {
if (this._graphics) {
const cam = this.scene?.activeCamera || this.camera;
if (cam) this._graphics.setCamera(cam);
return this._graphics;
}
const cam = this.scene?.activeCamera || this.camera;
if (!this.scene || !cam) return null;
this._graphics = new GraphicsManager(this.scene, cam, this, {
mobile: !!this._isMobileMode,
});
return this._graphics;
}
setGraphics(settings) {
const g = this._ensureGraphics();
if (!g) return null;
const cfg = g.apply(settings || {});
this._graphicsConfig = cfg;
return cfg;
}
getGraphicsState() {
return this._graphics ? this._graphics.serialize() : (this._graphicsConfig || null);
}
disableGraphics() {
if (this._graphics) this._graphics.disableAll();
this._graphicsConfig = null;
}
/** /**
* Включить/выключить SSAO пост-эффект (контактные тени). * Включить/выключить SSAO пост-эффект (контактные тени).
* Используем SSAORenderingPipeline v1 v2 ломал thin-instance рендер * Используем SSAORenderingPipeline v1 v2 ломал thin-instance рендер
@ -1680,8 +1777,8 @@ export class BabylonScene {
// peter-panning — тень "уезжала" далеко в сторону от блока (баг // peter-panning — тень "уезжала" далеко в сторону от блока (баг
// 2026-05-27). 0.005 — баланс между acne и peter-panning для // 2026-05-27). 0.005 — баланс между acne и peter-panning для
// воксельных кубов 1м. // воксельных кубов 1м.
const PCF_BIAS = 0.0005; const PCF_BIAS = 0.0008;
const PCF_NORMAL_BIAS = 0.005; const PCF_NORMAL_BIAS = 0.02; // убирает «полосы»-acne на полу от соседних теней
if (!this._shadowGenerator) { if (!this._shadowGenerator) {
if (wantCsm) { if (wantCsm) {
@ -1691,9 +1788,9 @@ export class BabylonScene {
const csm = new CascadedShadowGenerator(size, this._sunLight); const csm = new CascadedShadowGenerator(size, this._sunLight);
csm.numCascades = numCascades; csm.numCascades = numCascades;
csm.stabilizeCascades = true; csm.stabilizeCascades = true;
csm.lambda = 0.8; csm.lambda = 0.6;
csm.cascadeBlendPercentage = 0.07; csm.cascadeBlendPercentage = 0.1;
csm.shadowMaxZ = (q === 'high') ? 200 : 120; csm.shadowMaxZ = (q === 'high') ? 90 : 60;
csm.bias = PCF_BIAS; csm.bias = PCF_BIAS;
csm.normalBias = PCF_NORMAL_BIAS; csm.normalBias = PCF_NORMAL_BIAS;
csm.usePercentageCloserFiltering = true; csm.usePercentageCloserFiltering = true;
@ -1701,7 +1798,8 @@ export class BabylonScene {
? ShadowGenerator.QUALITY_HIGH ? ShadowGenerator.QUALITY_HIGH
: ShadowGenerator.QUALITY_MEDIUM; : ShadowGenerator.QUALITY_MEDIUM;
csm.darkness = 0.4; csm.darkness = 0.4;
csm.autoCalcDepthBounds = true; csm.autoCalcDepthBounds = false;
csm.frustumEdgeFalloff = 12; // убирает «полосу-хвост» тени игрока
this._shadowGenerator = csm; this._shadowGenerator = csm;
} else { } else {
// Обычный ShadowGenerator. Soft теперь 2048 (было 1024). // Обычный ShadowGenerator. Soft теперь 2048 (было 1024).
@ -1861,6 +1959,20 @@ export class BabylonScene {
if (typeof mesh.getBoundingInfo !== 'function') return; if (typeof mesh.getBoundingInfo !== 'function') return;
if (typeof mesh.getTotalVertices !== 'function') return; if (typeof mesh.getTotalVertices !== 'function') return;
if (mesh.getTotalVertices() <= 0) return; if (mesh.getTotalVertices() <= 0) return;
// ОПТИМИЗАЦИЯ ТЕНЕЙ (задача 14): мелкие/тонкие меши и огромный плоский
// пол НЕ кастят тень — каждый caster дорого стоит в shadow-map
// (на сцене из сотен примитивов давало 5-15 FPS вместо 45-60).
try {
const bb = mesh.getBoundingInfo().boundingBox;
const ext = bb.extendSizeWorld || bb.extendSize;
if (ext) {
const w = ext.x * 2, h = ext.y * 2, d = ext.z * 2;
const maxDim = Math.max(w, h, d);
const minDim = Math.min(w, h, d);
if (maxDim < 1.6 || minDim < 0.35) return;
if (maxDim > 30 && Math.min(w, d) > 30 && h < 3) return;
}
} catch (e) { /* ignore */ }
try { this._shadowGenerator.addShadowCaster(mesh, false); } catch (e) { /* ignore */ } try { this._shadowGenerator.addShadowCaster(mesh, false); } catch (e) { /* ignore */ }
} }
@ -2169,8 +2281,16 @@ export class BabylonScene {
const onMouseDown = (e) => { const onMouseDown = (e) => {
if (this._isPlaying) { if (this._isPlaying) {
// В Play-режиме ЛКМ — клик игрока в forward-направлении. // В Play-режиме ЛКМ — клик игрока в forward-направлении.
// Pointer Lock — курсор всё равно в центре экрана. // При pointer-lock курсор в центре; в third (свободный курсор)
if (e.button === 0) this._handlePlayClick(); // передаём реальные координаты клика для pick по табличкам.
if (this.placementManager && this.placementManager.isActive()) {
if (e.button === 0) { e.preventDefault(); this.placementManager.confirm(); return; }
if (e.button === 2) { e.preventDefault(); this.placementManager.cancel(); return; }
}
if (e.button === 0) {
const r = canvas.getBoundingClientRect();
this._handlePlayClick(e.clientX - r.left, e.clientY - r.top);
}
return; return;
} }
// Обновляем pointer координаты для raycast и Gizmo // Обновляем pointer координаты для raycast и Gizmo
@ -2365,6 +2485,10 @@ export class BabylonScene {
const onWheel = (e) => { const onWheel = (e) => {
e.preventDefault(); e.preventDefault();
if (this._isPlaying && this.placementManager && this.placementManager.isActive()) {
this.placementManager.rotate();
return;
}
const forward = this._getCameraForward(); const forward = this._getCameraForward();
const delta = -Math.sign(e.deltaY) * this.ZOOM_SPEED; const delta = -Math.sign(e.deltaY) * this.ZOOM_SPEED;
this.camera.position.addInPlace(forward.scale(delta)); this.camera.position.addInPlace(forward.scale(delta));
@ -2401,6 +2525,22 @@ export class BabylonScene {
const key = this._normalizeKey(e); const key = this._normalizeKey(e);
this.gameRuntime.routeGlobalEvent('keydown', { key, code: e.code }); this.gameRuntime.routeGlobalEvent('keydown', { key, code: e.code });
} }
// Задача 44: I — открыть/закрыть инвентарь, Esc — закрыть, 1-9 — хотбар.
if (this._isPlaying && e.code === 'KeyI' && this.invUI &&
(this.invUI.defs.size > 0 || this.invUI.grid.some(Boolean) || this.invUI.hotbar.some(Boolean))) {
e.preventDefault(); this.invUI.toggle(); return;
}
if (this._isPlaying && e.code === 'Escape' && this.invUI?.isOpen()) {
e.preventDefault(); this.invUI.close(); return;
}
if (this._isPlaying && this.invUI && /^Digit[1-9]$/.test(e.code) &&
(this.invUI.hotbar.some(Boolean) || this.invUI.defs.size > 0)) {
this.invUI.setActiveHotbar(parseInt(e.code.slice(5), 10) - 1);
}
if (this._isPlaying && this.placementManager && this.placementManager.isActive()) {
if (e.code === 'KeyR') { e.preventDefault(); this.placementManager.rotate(); return; }
if (e.code === 'Escape') { e.preventDefault(); this.placementManager.cancel(); return; }
}
if (e.code === 'KeyF') { if (e.code === 'KeyF') {
this._focusOnTarget(new Vector3(0, 0, 0)); this._focusOnTarget(new Vector3(0, 0, 0));
} }
@ -2760,6 +2900,7 @@ export class BabylonScene {
if (md.isBlock) { if (md.isBlock) {
return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } }; return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } };
} }
if (md.npcId != null) return { kind: 'npc', id: md.npcId };
if (md.isModel) return { kind: 'model', id: md.instanceId }; if (md.isModel) return { kind: 'model', id: md.instanceId };
if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId }; if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId };
return null; return null;
@ -2856,12 +2997,59 @@ export class BabylonScene {
} }
} }
// 3) Касания объектов, на которые подписан ГЛОБАЛЬНЫЙ скрипт через
// findOne(x).onTouch(...) (rt._watchedTouchRefs). Объекты без скрипта
// и не триггеры — например цели туториала. Событие адресное (по ref).
const watched = rt._watchedTouchRefs;
if (watched && watched.size > 0) {
for (const ref of watched) {
const target = this._refToTarget(ref);
if (!target) continue;
const aabb = this._targetAABB(target);
if (!aabb) continue;
const key = 'w:' + ref;
seen.add(key);
const overlap =
px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS &&
py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS &&
pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS;
const wasTouching = this._touchState.get(key);
if (overlap && !wasTouching) {
this._touchState.set(key, true);
rt.routeInstEvent(ref, 'instTouch', {});
} else if (!overlap && wasTouching) {
this._touchState.set(key, false);
rt.routeInstEvent(ref, 'instUntouch', {});
}
}
}
// Чистим устаревшие записи (удалённые скрипты/триггеры) // Чистим устаревшие записи (удалённые скрипты/триггеры)
for (const id of this._touchState.keys()) { for (const id of this._touchState.keys()) {
if (!seen.has(id)) this._touchState.delete(id); if (!seen.has(id)) this._touchState.delete(id);
} }
} }
/** Ref-строка 'primitive:NN' | 'model:NN' → {kind,id} для _targetAABB. */
_refToTarget(ref) {
if (typeof ref !== 'string') return null;
const colon = ref.indexOf(':');
if (colon < 0) return null;
const kind = ref.slice(0, colon);
const rest = ref.slice(colon + 1);
if (kind === 'primitive') {
const id = this.gameRuntime?._resolvePrimitiveId
? this.gameRuntime._resolvePrimitiveId(rest)
: (Number.isFinite(Number(rest)) ? Number(rest) : rest);
return { kind: 'primitive', id };
}
if (kind === 'model') {
const n = Number(rest);
return { kind: 'model', id: Number.isFinite(n) ? n : rest };
}
return null;
}
/** Получить мировой AABB target-объекта (для touch-детекции). */ /** Получить мировой AABB target-объекта (для touch-детекции). */
_targetAABB(target) { _targetAABB(target) {
if (!target) return null; if (!target) return null;
@ -2899,7 +3087,7 @@ export class BabylonScene {
* - в self-обработчики скриптов (routeEvent с target) * - в self-обработчики скриптов (routeEvent с target)
* - в глобальные обработчики (game.onClick) с event.target * - в глобальные обработчики (game.onClick) с event.target
*/ */
_handlePlayClick() { _handlePlayClick(clickX, clickY) {
if (!this._isPlaying) return; if (!this._isPlaying) return;
// Мультиплеер-выстрел: если у сцены есть mpSync, шлём 'shoot' серверу. // Мультиплеер-выстрел: если у сцены есть mpSync, шлём 'shoot' серверу.
@ -2920,7 +3108,63 @@ export class BabylonScene {
} }
if (!this.gameRuntime) return; if (!this.gameRuntime) return;
const pick = this._pickFromCenter();
// === Задача 01: клик по КНОПКЕ 3D-таблички (billboard) ===
// При pointer-lock (first/shift-lock) курсор в центре экрана → пикаем
// из центра. В third курсор СВОБОДНЫЙ → пикаем по реальным координатам
// клика (clickX/clickY переданы из onMouseDown). Без этого клик по
// табличке мышью в third промахивался — кнопки не нажимались.
if (this.billboardUiManager && this.primitiveManager) {
const locked = (document.pointerLockElement === this.canvas);
const w = this.engine?.getRenderWidth?.() || this.canvas.width;
const h = this.engine?.getRenderHeight?.() || this.canvas.height;
const px = locked ? w / 2 : (Number.isFinite(clickX) ? clickX : w / 2);
const py = locked ? h / 2 : (Number.isFinite(clickY) ? clickY : h / 2);
const bpick = this.scene.pick(px, py, (m) =>
m && m.metadata && m.metadata.primitiveId != null
&& this.primitiveManager.instances.get(m.metadata.primitiveId)?.type === 'billboard');
if (bpick && bpick.hit && bpick.pickedMesh) {
const bdata = this.primitiveManager.instances.get(bpick.pickedMesh.metadata.primitiveId);
const uv = bpick.getTextureCoordinates ? bpick.getTextureCoordinates() : null;
if (bdata && uv) {
const buttonId = this.billboardUiManager.pickButtonAt(bdata, uv.x, uv.y);
console.log('[billboard] клик id=' + bpick.pickedMesh.metadata.primitiveId
+ ' uv=(' + uv.x.toFixed(2) + ',' + uv.y.toFixed(2) + ') buttonId=' + buttonId
+ ' locked=' + locked);
if (buttonId) {
this.billboardUiManager.fireClick(bdata, buttonId);
return; // клик по табличке обработан
}
} else {
console.log('[billboard] попал в табличку id='
+ bpick.pickedMesh.metadata.primitiveId + ' но нет UV');
}
}
}
// В pointer-lock (1-е лицо) курсор скрыт в центре — пикаем центром.
// В 3-м лице (свободный курсор) — пикаем по реальным координатам клика.
const locked = (document.pointerLockElement === this.canvas);
let pick;
if (!locked && Number.isFinite(clickX) && Number.isFinite(clickY)) {
const pi = this.scene.pick(clickX, clickY, (mesh) => {
if (!mesh.isPickable) return false;
if (mesh.name && mesh.name.startsWith('gridLine')) return false;
return true;
});
if (pi?.hit) {
let m = pi.pickedMesh;
if (m?.metadata?._isBlockProto && this.blockManager) {
const proxy = this.blockManager.findProxyByPickInfo?.(pi);
if (proxy) m = proxy;
}
pick = { mesh: m, point: pi.pickedPoint, pickInfo: pi };
} else {
pick = null;
}
} else {
pick = this._pickFromCenter();
}
const target = pick?.mesh ? this._meshToTarget(pick.mesh) : null; const target = pick?.mesh ? this._meshToTarget(pick.mesh) : null;
const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null; const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null;
// 1) Self-onClick — только если target есть // 1) Self-onClick — только если target есть
@ -5120,6 +5364,11 @@ export class BabylonScene {
} }
/** Изменить позицию выделенного (используется Inspector). */ /** Изменить позицию выделенного (используется Inspector). */
// ── Небо (задача 16) — обёртки для game-API ──────────────────────────
setSkybox(opts) { this.skybox?.setSkybox(opts); }
setClouds(opts) { this.skybox?.setClouds(opts); }
setSkyFog(opts) { this.skybox?.setFog(opts); }
moveSelectedTo(x, y, z) { moveSelectedTo(x, y, z) {
if (!this.selection) return; if (!this.selection) return;
const sel = this.selection.getSelection(); const sel = this.selection.getSelection();
@ -5224,6 +5473,56 @@ export class BabylonScene {
return this._isPlaying; return this._isPlaying;
} }
/** Задача 12+05: конфиг экрана загрузки из настроек проекта. */
setLoadingConfig(cfg, thumbnail) {
if (cfg && typeof cfg === 'object') {
this._loadingConfig = {
logo: cfg.logo || null,
accentColor: cfg.accentColor || '#ffc020',
defaultSpinner: cfg.defaultSpinner !== false,
defaultSkipButton: !!cfg.defaultSkipButton,
// Задача 05:
enabled: cfg.enabled !== false,
background: cfg.background || cfg.backgroundUrl || null,
cover: cfg.cover || cfg.coverUrl || null,
style: cfg.style || 'ken-burns',
placeName: cfg.placeName || '',
studioName: cfg.studioName || '',
verified: !!cfg.verified,
duration: Number.isFinite(cfg.duration) && cfg.duration > 0 ? Number(cfg.duration) : 2.5,
progressBar: cfg.progressBar !== false,
};
} else {
this._loadingConfig = null;
}
if (thumbnail !== undefined) this._projectThumbnail = thumbnail || null;
}
/** Задача 05: стартовый экран загрузки при входе в Play (Ken-Burns + название места). */
showStartupLoadingScreen() {
const cfg = this._loadingConfig;
if (!cfg || cfg.enabled === false) return;
if (!this.gameRuntime) return;
try {
const ls = this.gameRuntime._ensureLoadingScreen?.();
if (!ls) return;
ls.show({
style: cfg.style,
background: cfg.background || cfg.cover || this._projectThumbnail,
cover: cfg.cover || this._projectThumbnail,
placeName: cfg.placeName || this._projectName || '',
studioName: cfg.studioName || '',
verified: cfg.verified,
duration: cfg.duration,
progressBar: cfg.progressBar,
spinner: true,
bgColor: '#070a14',
pauseSimulation: false,
blockInput: true,
});
} catch (e) { /* ignore */ }
}
/** /**
* Переключить в режим игры. Создаём PlayerController, прячем ghost-блок, * Переключить в режим игры. Создаём PlayerController, прячем ghost-блок,
* запоминаем позицию редактор-камеры чтобы вернуть при exit. * запоминаем позицию редактор-камеры чтобы вернуть при exit.
@ -5231,6 +5530,9 @@ export class BabylonScene {
enterPlayMode() { enterPlayMode() {
if (this._isPlaying) return; if (this._isPlaying) return;
this._isPlaying = true; this._isPlaying = true;
// Сброс состояния касаний — каждый прогон начинается «не касаясь».
if (this._touchState) this._touchState.clear();
this._playerMenuOpen = false; // меню-оверлей закрыт на старте Play
// По умолчанию стандартный HUD видим в Play. // По умолчанию стандартный HUD видим в Play.
// Скрипт может скрыть через game.hud.setVisible(false). // Скрипт может скрыть через game.hud.setVisible(false).
this._setStdHudVisible(true); this._setStdHudVisible(true);
@ -5266,6 +5568,11 @@ export class BabylonScene {
// Создаём PlayerController и стартуем // Создаём PlayerController и стартуем
this.player = new PlayerController(this.scene, this.canvas, this.physics, this); this.player = new PlayerController(this.scene, this.canvas, this.physics, this);
this.player.setModelType(this._playerModelType); this.player.setModelType(this._playerModelType);
// Задача 04: модал нуждается в player и audio для блока ввода/freeze камеры/duck
try {
this.modalManager?.attachPlayer?.(this.player);
this.modalManager?.attachAudio?.(this.audioManager);
} catch (e) {}
this.player._jumpPowerMul = this._jumpPowerMul ?? 1; this.player._jumpPowerMul = this._jumpPowerMul ?? 1;
// Применяем дефолтную камеру если задана в сцене // Применяем дефолтную камеру если задана в сцене
if (this._defaultCameraMode && this._defaultCameraMode !== this.player._cameraMode) { if (this._defaultCameraMode && this._defaultCameraMode !== this.player._cameraMode) {
@ -5274,17 +5581,63 @@ export class BabylonScene {
// На тач-устройствах отключаем pointer-lock и mouse-камеру // На тач-устройствах отключаем pointer-lock и mouse-камеру
if (this._touchMode) this.player.setTouchMode(true); if (this._touchMode) this.player.setTouchMode(true);
this.player.setOnExitRequest(() => { this.player.setOnExitRequest(() => {
// Задача 07: магазин скинов открыт → Esc закрывает его (приоритет выше модала).
if (this._skinShop?.open) {
this._closeSkinShop();
return;
}
// Задача 04: если открыт модал — первый Esc закрывает его,
// второй Esc уже выходит из Play. Так юзер не теряет состояние игры
// случайно при попытке скрыть модал.
if (this.modalManager?.isOpen?.()) {
this.modalManager.close();
return;
}
// ESC в плеере = TOGGLE меню-оверлея поверх ЖИВОЙ игры (как в Roblox).
// Единый источник истины — _playerMenuOpen в движке. Раньше состояние
// меню держал React, а ESC слушали ДВА обработчика (движок + React) →
// гонка: меню открывалось поверх меню, а _uiCursorMode застревал в true
// → orbit-камера по ПКМ переставала работать после закрытия меню.
// Теперь движок сам решает open/close и шлёт это в _onEscMenu(open).
if (typeof this._onEscMenu === 'function') {
if (this._playerMenuOpen) {
// Меню открыто → ESC закрывает: вернуть мышь в игру.
this._playerMenuOpen = false;
this.player?.setUiCursorMode?.(false);
this._onEscMenu(false);
} else {
// Меню закрыто → ESC открывает: освободить курсор.
this._playerMenuOpen = true;
this.player?.setUiCursorMode?.(true);
this._onEscMenu(true);
}
return;
}
// Фолбэк (если меню не подписано, напр. в студии) — старое поведение.
this.exitPlayMode(); this.exitPlayMode();
if (this._onPlayChange) this._onPlayChange(false); if (this._onPlayChange) this._onPlayChange(false);
}); });
if (this._onPlayerHpChange) this.player.setOnHpChange(this._onPlayerHpChange); if (this._onPlayerHpChange) this.player.setOnHpChange(this._onPlayerHpChange);
if (this._onPlayerDeath) this.player.setOnDeath(this._onPlayerDeath); if (this._onPlayerDeath) this.player.setOnDeath(this._onPlayerDeath);
this.player.start(this._spawnPoint); // Точка спавна удалена → игрок появляется в (0, безопасная высота, 0).
let startPoint = this._spawnPoint;
if (this._spawnEnabled === false) {
let sy = 3;
try {
const surf = this.physics?._sampleRobloxSurface?.(0, 0);
if (surf !== null && surf !== undefined) sy = surf + 2;
} catch (e) { /* ignore */ }
startPoint = { x: 0, y: sy, z: 0 };
}
this.player.start(startPoint);
// Запускаем пользовательские скрипты (этап 2.1). // Запускаем пользовательские скрипты (этап 2.1).
// Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену, // Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену,
// поэтому скрипты стартуем в следующем кадре. // поэтому скрипты стартуем в следующем кадре.
this.gameRuntime = new GameRuntime(this); this.gameRuntime = new GameRuntime(this);
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
// Задача 05: стартовый экран загрузки (Ken-Burns + название места).
try { this.showStartupLoadingScreen(); } catch (e) {}
// Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime. // Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime.
// ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным // ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным
// this.audioManager (AudioManager — ambient/music для всех проектов). // this.audioManager (AudioManager — ambient/music для всех проектов).
@ -5428,9 +5781,24 @@ export class BabylonScene {
if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair); if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('[BabylonScene] enterPlayMode — scripts to run:', this._scripts?.length || 0, this._scripts); console.log('[BabylonScene] enterPlayMode — scripts to run:', this._scripts?.length || 0, this._scripts);
// Задача 20: смонтировать HUD лидербордов/достижений если определения уже
// загружены из проекта (define из project_data при load).
try { if (this.leaderstats?.active) this.leaderstats._mount(); } catch (e) {}
try { if (this.achievements?.active) this.achievements._mountButton(); } catch (e) {}
try {
if (this.invUI && (this.invUI.defs.size > 0 || this.invUI.hotbar.some(Boolean) || this.invUI.grid.some(Boolean))) {
this.invUI.mountHotbar();
}
} catch (e) {}
// Старт через requestAnimationFrame — даём Babylon собрать сцену // Старт через requestAnimationFrame — даём Babylon собрать сцену
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (this._isPlaying) this.gameRuntime?.start(this._scripts || []); if (this._isPlaying) this.gameRuntime?.start(this._scripts || []);
// Задача 20: подгрузить сохранённый прогресс игрока из БД ПОСЛЕ define().
setTimeout(() => {
if (!this._isPlaying) return;
try { this.achievements?.loadFromDB?.(); } catch (e) {}
try { this.leaderstats?.loadFromDB?.(); } catch (e) {}
}, 250);
}); });
// === Оружие === // === Оружие ===
@ -5441,6 +5809,10 @@ export class BabylonScene {
if (hit?.mesh && this.zombieManager) { if (hit?.mesh && this.zombieManager) {
this.zombieManager.damageByMesh(hit.mesh, hit.damage || 25); this.zombieManager.damageByMesh(hit.mesh, hit.damage || 25);
} }
// Урон скриптовым NPC (киты-враги) → авто-floater над мобом (задача 40).
if (hit?.mesh && this.npcManager) {
try { this.npcManager.damageByMesh(hit.mesh, hit.damage || 25); } catch (e) {}
}
if (this._onWeaponHit) { if (this._onWeaponHit) {
try { this._onWeaponHit(hit); } catch (e) {} try { this._onWeaponHit(hit); } catch (e) {}
} }
@ -5464,6 +5836,8 @@ export class BabylonScene {
// === Лучи и следы (Фаза 5.2 — Beam/Trail) === // === Лучи и следы (Фаза 5.2 — Beam/Trail) ===
if (!this.beamManager) this.beamManager = new BeamManager(this); if (!this.beamManager) this.beamManager = new BeamManager(this);
this.beamManager.start(); this.beamManager.start();
// Задача 08: активируем pointer-примитивы из палитры в реальные стрелки.
this._activatePointers();
// === 3D-звук (Фаза 5.5 — позиционный звук) === // === 3D-звук (Фаза 5.5 — позиционный звук) ===
if (!this.soundManager) this.soundManager = new SoundManager(this); if (!this.soundManager) this.soundManager = new SoundManager(this);
@ -5778,6 +6152,7 @@ export class BabylonScene {
if (!sc) return false; if (!sc) return false;
if (!this.gameRuntime) { if (!this.gameRuntime) {
this.gameRuntime = new GameRuntime(this); this.gameRuntime = new GameRuntime(this);
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
if (!this.gameAudioManager) { if (!this.gameAudioManager) {
this.gameAudioManager = new GameAudioManager(); this.gameAudioManager = new GameAudioManager();
} }
@ -5853,6 +6228,32 @@ export class BabylonScene {
this._onPlayChange = cb; this._onPlayChange = cb;
} }
/**
* Колбэк «ESC в Play» для плеера: открыть меню-оверлей поверх живой игры
* БЕЗ выхода из Play. Если подписан ESC не делает exitPlayMode (см.
* setOnExitRequest в enterPlayMode). В студии не подписывается там ESC
* по-прежнему выходит из Play.
*/
setOnEscMenu(cb) {
this._onEscMenu = cb;
}
/**
* Синхронизация состояния меню-оверлея из UI (React). Когда меню закрывают
* НЕ через ESC (кнопка «Продолжить»/крестик/клик по фону), UI обязан сообщить
* движку иначе _playerMenuOpen рассинхронизируется и следующий ESC решит,
* что меню «открыто», и не откроет его. open=false также возвращает мышь в игру.
*/
setPlayerMenuOpen(open) {
const v = !!open;
if (this._playerMenuOpen === v) return;
this._playerMenuOpen = v;
if (!v) {
// меню закрыли из UI → вернуть управление камерой/мышью
try { this.player?.setUiCursorMode?.(false); } catch (e) { /* ignore */ }
}
}
/** /**
* Колбэк изменения сцены (любая модификация блоков/моделей). * Колбэк изменения сцены (любая модификация блоков/моделей).
* Используется KubikonEditor для dirty-tracking auto-save. * Используется KubikonEditor для dirty-tracking auto-save.
@ -5900,6 +6301,18 @@ export class BabylonScene {
try { this._onStdHudVisibilityChange?.(this._stdHudVisible); } catch (e) {} try { this._onStdHudVisibilityChange?.(this._stdHudVisible); } catch (e) {}
} }
/** Скрыть/показать только хотбар (5 слотов инвентаря снизу). */
_setHotbarVisible(visible) {
this._hotbarVisible = !!visible;
try { this._onHotbarVisibilityChange?.(this._hotbarVisible); } catch (e) {}
}
/** Скрыть/показать только HP-индикатор (полоска жизней). */
_setHpVisible(visible) {
this._hpVisible = !!visible;
try { this._onHpVisibilityChange?.(this._hpVisible); } catch (e) {}
}
/** Колбэк смены cursor-режима (ui/game) скриптом через game.input.setCursorMode. /** Колбэк смены cursor-режима (ui/game) скриптом через game.input.setCursorMode.
* Редактор подписан чтобы синхронизировать React-state uiCursorMode (для бейджа). */ * Редактор подписан чтобы синхронизировать React-state uiCursorMode (для бейджа). */
setOnCursorModeChange(cb) { setOnCursorModeChange(cb) {
@ -6024,6 +6437,71 @@ export class BabylonScene {
return this.guiManager ? this.guiManager.getAll() : []; return this.guiManager ? this.guiManager.getAll() : [];
} }
// ===== Задача 07: встроенный магазин скинов (React-оверлей) =====
// Состояние держим тут; React-компонент SkinShopOverlay поллит getSkinShopState().
_ensureSkinShopState() {
if (!this._skinShop) {
this._skinShop = {
open: false,
rev: 0, // ревизия — React видит изменение
data: { all: [], unlocked: [], current: null, coins: 0, manifestFull: [] },
buyResult: null, // последний результат покупки {slug, ok, reason}
};
}
return this._skinShop;
}
/** Снимок состояния магазина для React (поллинг через rAF). */
getSkinShopState() { return this._skinShop || null; }
/** Открыть/закрыть магазин (из скрипта или клавиши B). */
_openSkinShop() {
const s = this._ensureSkinShopState();
// Отключён в проекте? (скрипт всё равно может открыть через API —
// shopVisible:false запрещает только клавишу B, см. toggleSkinShop).
s.open = true; s.rev++;
}
_closeSkinShop() {
const s = this._ensureSkinShopState();
s.open = false; s.rev++;
}
toggleSkinShop() {
const s = this._ensureSkinShopState();
if (s.open) { this._closeSkinShop(); return; }
// Клавиша B открывает магазин только если он включён в проекте.
if (this._skinsConfig && this._skinsConfig.shopVisible === false) return;
this._openSkinShop();
}
/** Данные скинов от GameRuntime (манифест + unlocked + coins). */
_setSkinShopData(data) {
const s = this._ensureSkinShopState();
s.data = { ...s.data, ...data };
s.rev++;
}
_onSkinBuyResult(res) {
const s = this._ensureSkinShopState();
s.buyResult = { ...res, t: (this.scene?.getEngine?.()?.getFps?.() || 0) };
s.rev++;
}
/** Намерение купить/надеть скин — шлём в runtime (он списывает рублики). */
requestBuySkin(slug, price) {
const rt = this.gameRuntime;
if (!rt) return;
try { rt._handleCommand?.(null, 'player.buySkin', { slug, price: price || 0 }); } catch (e) {}
}
/** Данные из загруженных проектом кастомных .glb-скинов (data-URL по slug). */
getAssetDataUrl(slug) {
try {
// Кастомные скины хранят dataUrl прямо в _skinsConfig.customGlbs.
const list = this._skinsConfig?.customGlbs || [];
const rec = list.find(g => g && g.slug === slug);
if (rec && rec.dataUrl) return rec.dataUrl;
} catch (e) {}
return null;
}
_onPlayerSkinChanged(slug) {
const s = this._ensureSkinShopState();
if (s.data) { s.data.current = slug; s.rev++; }
}
// ===== Библиотека пользовательских картинок (этап 3.6) ===== // ===== Библиотека пользовательских картинок (этап 3.6) =====
/** Список картинок проекта [{id, name, dataUrl}]. */ /** Список картинок проекта [{id, name, dataUrl}]. */
@ -6695,8 +7173,16 @@ export class BabylonScene {
folders: this.folderManager ? this.folderManager.serialize() : [], folders: this.folderManager ? this.folderManager.serialize() : [],
gui: this.guiManager ? this.guiManager.serialize() : [], gui: this.guiManager ? this.guiManager.serialize() : [],
inventory: this.inventory ? this.inventory.serialize() : null, inventory: this.inventory ? this.inventory.serialize() : null,
inventory2: this.invUI ? this.invUI.serialize() : null, // задача 44
spawnPoint: { ...this._spawnPoint }, spawnPoint: { ...this._spawnPoint },
playerModelType: this._playerModelType, playerModelType: this._playerModelType,
skins: this._skinsConfig ? {
default: this._skinsConfig.default || null,
unlocked: this._skinsConfig.unlocked || [],
shopVisible: this._skinsConfig.shopVisible !== false,
coins: this._skinsConfig.coins || 0,
customGlbs: this._skinsConfig.customGlbs || [],
} : undefined,
worldSize: this._worldHalf * 2, worldSize: this._worldHalf * 2,
floorEnabled: this._floorEnabled !== false, floorEnabled: this._floorEnabled !== false,
jumpPowerMul: this._jumpPowerMul ?? 1, jumpPowerMul: this._jumpPowerMul ?? 1,
@ -6704,6 +7190,9 @@ export class BabylonScene {
crosshair: this._crosshair || 'dot', crosshair: this._crosshair || 'dot',
shadowQuality: this._shadowQuality || 'soft', shadowQuality: this._shadowQuality || 'soft',
environment: this.environment ? this.environment.serialize() : null, environment: this.environment ? this.environment.serialize() : null,
skybox: this.skybox ? this.skybox.serialize() : null,
leaderstats: this.leaderstats ? this.leaderstats.serialize() : null,
achievements: this.achievements ? this.achievements.serialize() : null,
audio: this.audioManager ? this.audioManager.serialize() : null, audio: this.audioManager ? this.audioManager.serialize() : null,
// Библиотека пользовательских картинок (текстуры/GUI-image). // Библиотека пользовательских картинок (текстуры/GUI-image).
assets: this.assetManager ? this.assetManager.serialize() : [], assets: this.assetManager ? this.assetManager.serialize() : [],
@ -7026,6 +7515,44 @@ export class BabylonScene {
this._syncUserModelColliders(); this._syncUserModelColliders();
} }
// === Тип модели персонажа — РЕШАЕМ ДО предзагрузки/плеера ===
// ВАЖНО: должно стоять ВЫШЕ _loadPrototype и до enterPlayMode, иначе
// PlayerController прочитает старый _playerModelType (баг: пончик 2046
// не ставился — skins.default применялся ниже, после предзагрузки).
// Миграция: старые проекты сохраняли Kenney-модель ('character-a..g');
// форсим R15 bacon-hair. Явно выбранные 'skin_*' не трогаем.
if (state.scene.playerModelType) {
const pmt = state.scene.playerModelType;
this._playerModelType = pmt.startsWith('character-') ? 'skin_bacon-hair' : pmt;
}
// Задача 07: конфиг скинов { default, unlocked, shopVisible, coins, customGlbs }.
if (state.scene.skins && typeof state.scene.skins === 'object') {
this._skinsConfig = {
default: state.scene.skins.default || null,
unlocked: Array.isArray(state.scene.skins.unlocked) ? state.scene.skins.unlocked.slice() : [],
shopVisible: state.scene.skins.shopVisible !== false,
coins: Number.isFinite(state.scene.skins.coins) ? state.scene.skins.coins : 0,
customGlbs: Array.isArray(state.scene.skins.customGlbs) ? state.scene.skins.customGlbs.slice() : [],
};
// Стартовый скин из skins.default имеет приоритет над playerModelType.
if (this._skinsConfig.default) {
const d = this._skinsConfig.default;
this._playerModelType = (d.startsWith('character-') || d.startsWith('skin_') || d.startsWith('customskin:'))
? d : ('skin_' + d);
}
} else {
this._skinsConfig = null;
}
// Задача 12+05: конфиг экрана загрузки (через setLoadingConfig — единый маппинг).
if (state.scene.loadingScreen && typeof state.scene.loadingScreen === 'object') {
this.setLoadingConfig(state.scene.loadingScreen);
} else {
this._loadingConfig = null;
}
// Задача 13: конфиг главного меню (passthrough).
this._mainMenuConfig = (state.scene.mainMenu && typeof state.scene.mainMenu === 'object')
? state.scene.mainMenu : null;
// ОПТИМИЗАЦИЯ: предзагружаем модель ИГРОКА (character) тоже — // ОПТИМИЗАЦИЯ: предзагружаем модель ИГРОКА (character) тоже —
// PlayerController.start() её ждёт, но если предзагрузить сейчас, // PlayerController.start() её ждёт, но если предзагрузить сейчас,
// на enterPlayMode она будет в кэше Babylon и стартует мгновенно. // на enterPlayMode она будет в кэше Babylon и стартует мгновенно.
@ -7057,6 +7584,9 @@ export class BabylonScene {
if (this.inventory) { if (this.inventory) {
this.inventory.loadFromArray(state.scene.inventory || null); this.inventory.loadFromArray(state.scene.inventory || null);
} }
if (this.invUI && state.scene.inventory2) { // задача 44
this.invUI.load(state.scene.inventory2);
}
// Простановка folderId на блоках (loadFromArray BlockManager не знает про это поле) // Простановка folderId на блоках (loadFromArray BlockManager не знает про это поле)
if (this.blockManager && Array.isArray(state.scene.blocks)) { if (this.blockManager && Array.isArray(state.scene.blocks)) {
for (const b of state.scene.blocks) { for (const b of state.scene.blocks) {
@ -7100,8 +7630,10 @@ export class BabylonScene {
// Точка спавна // Точка спавна
if (state.scene.spawnPoint) { if (state.scene.spawnPoint) {
this._spawnPoint = { ...state.scene.spawnPoint }; this._spawnPoint = { ...state.scene.spawnPoint };
this._updateSpawnMarker(); this._updateSpawnMarker?.();
} }
// Удалена ли точка спавна (плеер: спавн в 0,0 при отсутствии).
this._spawnEnabled = state.scene.spawnEnabled !== false;
// === Авто-fix спавна для smooth terrain === // === Авто-fix спавна для smooth terrain ===
// Если есть RobloxTerrain и spawnPoint оказался НИЖЕ поверхности — // Если есть RobloxTerrain и spawnPoint оказался НИЖЕ поверхности —
// поднимаем его, чтобы игрок не провалился в момент нажатия "Запустить". // поднимаем его, чтобы игрок не провалился в момент нажатия "Запустить".
@ -7123,18 +7655,7 @@ export class BabylonScene {
} }
} }
} catch (e) { console.warn('[BabylonScene] spawn auto-lift failed:', e); } } catch (e) { console.warn('[BabylonScene] spawn auto-lift failed:', e); }
// Тип модели персонажа. // (Тип модели персонажа и skins решены выше — до предзагрузки модели.)
// Миграция: старые проекты сохраняли Kenney-модель ('character-a..g').
// Теперь стандарт — R15-скин bacon-hair. Если в проекте старая
// Kenney-модель — форсим bacon-hair. Явно выбранные 'skin_*' не трогаем.
if (state.scene.playerModelType) {
const pmt = state.scene.playerModelType;
if (pmt.startsWith('character-')) {
this._playerModelType = 'skin_bacon-hair';
} else {
this._playerModelType = pmt;
}
}
// Пользовательские скрипты // Пользовательские скрипты
if (Array.isArray(state.scene.scripts)) { if (Array.isArray(state.scene.scripts)) {
this._scripts = state.scene.scripts this._scripts = state.scene.scripts
@ -7150,6 +7671,22 @@ export class BabylonScene {
if (state.scene.environment && this.environment) { if (state.scene.environment && this.environment) {
this.environment.load(state.scene.environment); this.environment.load(state.scene.environment);
} }
// Графика/эффекты (шейдеры) — применяем если автор настроил и не 'off'.
if (state.scene.graphics && state.scene.graphics.preset
&& state.scene.graphics.preset !== 'off') {
try { this.setGraphics(state.scene.graphics); } catch (e) { /* ignore */ }
}
// Кастомное небо (задача 16)
if (state.scene.skybox && this.skybox) {
this.skybox.load(state.scene.skybox);
}
// Лидерборды и достижения (задача 20) — определения из проекта.
if (state.scene.leaderstats && this.leaderstats) {
this.leaderstats.load(state.scene.leaderstats);
}
if (state.scene.achievements && this.achievements) {
this.achievements.load(state.scene.achievements);
}
// Аудио (фоновая музыка/амбиент) // Аудио (фоновая музыка/амбиент)
if (state.scene.audio && this.audioManager) { if (state.scene.audio && this.audioManager) {
this.audioManager.load(state.scene.audio); this.audioManager.load(state.scene.audio);
@ -7167,10 +7704,60 @@ export class BabylonScene {
} }
} }
/**
* Задача 08: активировать pointer-примитивы из палитры в реальные стрелки.
* Маркер-сфера прячется, через BeamManager создаётся анимированная стрелка
* (лента + парящий quest-marker) от источника к цели. from/to из инспектора.
*/
_activatePointers() {
const pm = this.primitiveManager;
const bm = this.beamManager;
if (!pm || !bm) return;
for (const inst of pm.instances.values()) {
if (inst.type !== 'pointer') continue;
try { if (inst.mesh) inst.mesh.setEnabled(false); } catch (e) {}
const at = { x: inst.x, y: inst.y, z: inst.z };
const from = this._pointerRefOrPoint(inst.pointerFrom, at);
const to = this._pointerRefOrPoint(inst.pointerTo, { x: at.x, y: at.y, z: at.z + 4 });
try {
bm.addPointer({
from, to,
preset: inst.pointerPreset || 'guide',
color: inst.color, textureSpeed: inst.textureSpeed,
curved: inst.curved, curveHeight: inst.curveHeight,
});
} catch (e) {
console.warn('[BabylonScene] addPointer failed:', e);
}
}
}
/** from/to стрелки: 'player' | id примитива/модели → ref | точка-fallback. */
_pointerRefOrPoint(val, fallbackPoint) {
if (val === 'player') return 'player';
if (val != null && val !== '') {
const n = Number(val);
if (Number.isFinite(n)) {
if (this.primitiveManager?.instances?.has(n)) return 'primitive:' + n;
if (this.modelManager?.instances?.has(n)) return 'model:' + n;
}
if (typeof val === 'string'
&& (val.startsWith('primitive:') || val.startsWith('model:'))) return val;
}
return fallbackPoint;
}
/** Выйти из режима игры — восстановить редактор-камеру. */ /** Выйти из режима игры — восстановить редактор-камеру. */
exitPlayMode() { exitPlayMode() {
if (!this._isPlaying) return; if (!this._isPlaying) return;
this._isPlaying = false; this._isPlaying = false;
// Задача 04: закрываем любой активный модал чтобы не «висел» при стопе
try { this.modalManager?._instantClose?.(); } catch (e) {}
// Задача 20: чистим рантайм лидербордов/достижений (определения остаются).
try { this.leaderstats?.resetRuntime?.(); } catch (e) {}
try { this.achievements?.resetRuntime?.(); } catch (e) {}
try { this.floaters?.resetRuntime?.(); } catch (e) {} // задача 40
try { this.invUI?.resetRuntime?.(); } catch (e) {} // задача 44
// Сбрасываем таймер прохождения // Сбрасываем таймер прохождения
this._timerRunning = false; this._timerRunning = false;
this._timerStartedAt = null; this._timerStartedAt = null;
@ -7198,6 +7785,13 @@ export class BabylonScene {
this.gameRuntime = null; this.gameRuntime = null;
} }
// Placement mode (задача 11): сброс активной сессии + виджета магазина.
if (this.vehicleManager) { try { this.vehicleManager.dispose(); } catch (e) {} }
if (this.vehicleHud) { try { this.vehicleHud.dispose(); } catch (e) {} this.vehicleHud = null; }
if (this.placementManager) { try { this.placementManager.dispose(); } catch (e) {} this.placementManager = null; }
if (this.shopInventoryUi) { try { this.shopInventoryUi.dispose(); } catch (e) {} this.shopInventoryUi = null; }
if (this.loadingScreen) { try { this.loadingScreen.dispose(); } catch (e) {} this.loadingScreen = null; }
// Останавливаем GD-level manager (сбрасывает _shipMode/_ufoMode/... на player перед его удалением) // Останавливаем GD-level manager (сбрасывает _shipMode/_ufoMode/... на player перед его удалением)
if (this.gdLevelManager) { if (this.gdLevelManager) {
this.gdLevelManager.stop(); this.gdLevelManager.stop();

View File

@ -1,30 +1,50 @@
/** /**
* BeamManager лучи (Beam) и следы (Trail) как объекты сцены (Фаза 5.2). * BeamManager лучи (Beam), следы (Trail) и стрелки-указатели (Pointer)
* как объекты сцены.
* *
* Beam светящаяся линия между двумя точками. Точки могут быть * Beam светящаяся линия/лента между двумя точками. Точки могут быть
* фиксированными координатами или ref объектов тогда луч * фиксированными координатами, ref объектов ИЛИ 'player' тогда
* следует за объектами каждый кадр (лазеры, мосты света, * конец следует за объектом/игроком каждый кадр.
* соединения, цепи). * Trail шлейф за движущимся объектом (Babylon TrailMesh).
* Trail шлейф, тянущийся за движущимся объектом (Babylon TrailMesh). * Pointer высокоуровневая «стрелка иди сюда»: текстурированная лента
* (бегущие шевроны/стрелки), с пресетами, curved-дугой, градиентом.
* *
* Живут только в Play-режиме. Управляются скриптом через game.fx.* * Задача 08: расширены опции addBeam (texture/textureSpeed/curved/colorSequence/
* каждый вызов возвращает прокси-объект. * faceMode/strokeColor/...) + game.fx.pointer. Живут только в Play-режиме
* (и в превью редактора со скоростью анимации 0).
*/ */
import { import {
MeshBuilder, StandardMaterial, Color3, Vector3, MeshBuilder, StandardMaterial, Color3, Color4, Vector3,
DynamicTexture, Texture, VertexBuffer, TransformNode,
} from '@babylonjs/core'; } from '@babylonjs/core';
import { TrailMesh } from '@babylonjs/core/Meshes/trailMesh'; import { TrailMesh } from '@babylonjs/core/Meshes/trailMesh';
let _fxIdSeq = 1; let _fxIdSeq = 1;
// Кэш сгенерированных текстур по ключу (форма+обводка) — одна на сцену.
// Текстуры белые (рисуются белым), реальный цвет даёт mat.emissiveColor/diffuse.
const _texCache = new Map();
// Встроенные пресеты для game.fx.pointer — разворачиваются в опции beam.
const POINTER_PRESETS = {
guide: { texture: 'chevron', color: '#ff3a3a', strokeColor: '#000000', strokeWidth: 4, textureSpeed: 3, width: 0.9, textureScale: 1 },
quest: { texture: 'chevron', color: '#ffd23a', strokeColor: '#5a3a00', strokeWidth: 3, textureSpeed: 3.5, width: 0.9, textureScale: 1 },
danger: { texture: 'lightning', color: '#ff2a2a', strokeColor: '#3a0000', strokeWidth: 2, textureSpeed: 5, width: 1.0, textureScale: 1.2 },
gift: { texture: 'sparkle', color: '#ffffff', strokeColor: null, strokeWidth: 0, textureSpeed: 2, width: 1.0, textureScale: 1.4,
colorSequence: [{ p: 0, c: '#ff5a5a' }, { p: 0.25, c: '#ffd23a' }, { p: 0.5, c: '#5aff7a' }, { p: 0.75, c: '#3a9aff' }, { p: 1, c: '#c45aff' }] },
};
export class BeamManager { export class BeamManager {
constructor(scene3d) { constructor(scene3d) {
this.scene3d = scene3d; this.scene3d = scene3d;
this.scene = scene3d.scene; this.scene = scene3d.scene;
/** @type {Map<number, object>} id → fx state (beam | trail) */ /** @type {Map<number, object>} id → fx state (beam | trail | pointer) */
this.items = new Map(); this.items = new Map();
this._renderHook = null; this._renderHook = null;
this._lastTime = 0;
// В превью редактора (не Play) анимацию текстур замораживаем.
this.animationEnabled = true;
} }
start() { start() {
@ -46,59 +66,304 @@ export class BeamManager {
try { try {
if (it.mesh) it.mesh.dispose(); if (it.mesh) it.mesh.dispose();
if (it.mat) it.mat.dispose(); if (it.mat) it.mat.dispose();
// Парящий quest-marker: root (TransformNode) с дочерним конусом —
// dispose с потомками убирает и конус, и его outline.
if (it._markerMesh) it._markerMesh.dispose();
if (it._marker) it._marker.dispose();
if (it._markerMat) it._markerMat.dispose();
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
} }
// ===================================================================
// ТЕКСТУРЫ ЛУЧА — генерируются на лету через Canvas2D (без PNG-файлов).
// Все белые с альфа-каналом; цвет даёт материал. strokeColor рисуется
// под основной формой.
// ===================================================================
/** Получить (с кэшем) DynamicTexture для формы. */
_getBeamTexture(shape, strokeColor, strokeWidth) {
const key = shape + '|' + (strokeColor || '') + '|' + (strokeWidth || 0);
if (_texCache.has(key)) return _texCache.get(key);
const S = 128;
const dyn = new DynamicTexture('beamTex_' + key, { width: S, height: S }, this.scene, true);
dyn.hasAlpha = true;
dyn.wrapU = Texture.WRAP_ADDRESSMODE;
dyn.wrapV = Texture.CLAMP_ADDRESSMODE;
const ctx = dyn.getContext();
ctx.clearRect(0, 0, S, S);
this._drawShape(ctx, shape, S, strokeColor, strokeWidth);
dyn.update();
_texCache.set(key, dyn);
return dyn;
}
/** /**
* Создать луч между двумя точками. * Рисует форму на canvas. Ориентация: текстура натягивается вдоль ленты
* opts: { from, to {x,y,z} или ref-строка объекта; * так, что U идёт ПО длине (fromto). Шеврон рисуем «>» указывающим в
* color: '#hex', width: толщина (м) }. * сторону +U (к цели).
*/
_drawShape(ctx, shape, S, strokeColor, strokeWidth) {
const sw = Number(strokeWidth) || 0;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
const drawPath = (pathFn, fill) => {
// Обводка (под формой) — рисуем толще тем же путём.
if (strokeColor && sw > 0) {
ctx.beginPath(); pathFn();
ctx.strokeStyle = strokeColor;
ctx.lineWidth = sw * 2 + 14;
ctx.stroke();
}
ctx.beginPath(); pathFn();
if (fill) {
ctx.fillStyle = '#ffffff';
ctx.fill();
} else {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 16;
ctx.stroke();
}
};
const m = S * 0.18; // отступ
const cx = S / 2, cy = S / 2;
switch (shape) {
case 'chevron': {
// «>» указывающий вправо (к цели вдоль +U)
drawPath(() => {
ctx.moveTo(m, m);
ctx.lineTo(S - m, cy);
ctx.lineTo(m, S - m);
}, false);
break;
}
case 'arrow': {
// наконечник-треугольник вправо
drawPath(() => {
ctx.moveTo(m, m + 6);
ctx.lineTo(S - m, cy);
ctx.lineTo(m, S - m - 6);
ctx.closePath();
}, true);
break;
}
case 'dot': {
drawPath(() => { ctx.arc(cx, cy, S * 0.22, 0, Math.PI * 2); }, true);
break;
}
case 'line': {
ctx.fillStyle = '#ffffff';
if (strokeColor && sw > 0) {
ctx.fillStyle = strokeColor;
ctx.fillRect(0, cy - (S * 0.28) / 2 - sw, S, S * 0.28 + sw * 2);
ctx.fillStyle = '#ffffff';
}
ctx.fillRect(0, cy - (S * 0.28) / 2, S, S * 0.28);
break;
}
case 'dash': {
ctx.fillStyle = '#ffffff';
ctx.fillRect(S * 0.12, cy - (S * 0.22) / 2, S * 0.76, S * 0.22);
break;
}
case 'wave': {
drawPath(() => {
ctx.moveTo(0, cy);
for (let x = 0; x <= S; x += 4) {
ctx.lineTo(x, cy + Math.sin((x / S) * Math.PI * 2) * (S * 0.28));
}
}, false);
break;
}
case 'lightning': {
drawPath(() => {
ctx.moveTo(m, m);
ctx.lineTo(cx + 6, cy - 6);
ctx.lineTo(cx - 6, cy + 6);
ctx.lineTo(S - m, S - m);
}, false);
break;
}
case 'sparkle': {
// 4-лучевая звёздочка
drawPath(() => {
const r = S * 0.30, ri = S * 0.10;
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2 - Math.PI / 2;
const rad = (i % 2 === 0) ? r : ri;
const x = cx + Math.cos(a) * rad, y = cy + Math.sin(a) * rad;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.closePath();
}, true);
break;
}
default: { // fallback — сплошная линия
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, cy - (S * 0.28) / 2, S, S * 0.28);
}
}
}
// ===================================================================
// BEAM (расширенный — задача 08)
// ===================================================================
/**
* Создать луч/ленту между двумя точками.
* opts: {
* from, to {x,y,z} | ref-строка | 'player';
* color, width;
* texture: 'chevron'|'arrow'|'dot'|'line'|'wave'|'lightning'|'dash'|'sparkle'|'custom'|null;
* customTextureUrl, textureMode, textureSpeed, textureScale,
* strokeColor, strokeWidth,
* colorSequence:[{p,c}], transparencySequence:[{p,t}], widthSequence:[{p,w}],
* faceMode: 'billboard'|'flat-x'|'flat-y'|'flat-z',
* segments, curved, curveHeight, attachOffset:{fromY,toY},
* ignoreDepth,
* }
* Возвращает id. * Возвращает id.
*/ */
addBeam(opts = {}) { addBeam(opts = {}) {
const id = _fxIdSeq++; const id = _fxIdSeq++;
const width = Number.isFinite(opts.width) ? opts.width : 0.15; const hasTexture = !!opts.texture && opts.texture !== 'none';
const mat = new StandardMaterial('beamMat_' + id, this.scene); const it = {
const col = Color3.FromHexString(opts.color || '#66ccff'); id, type: 'beam',
from: opts.from, to: opts.to,
width: Number.isFinite(opts.width) ? opts.width : (hasTexture ? 0.8 : 0.15),
color: opts.color || '#66ccff',
texture: hasTexture ? opts.texture : null,
customTextureUrl: opts.customTextureUrl || null,
textureMode: opts.textureMode || 'wrap',
textureSpeed: Number.isFinite(opts.textureSpeed) ? opts.textureSpeed : 0,
textureScale: Number.isFinite(opts.textureScale) ? opts.textureScale : 1,
strokeColor: opts.strokeColor || null,
strokeWidth: Number.isFinite(opts.strokeWidth) ? opts.strokeWidth : 3,
colorSequence: Array.isArray(opts.colorSequence) ? opts.colorSequence : null,
transparencySequence: Array.isArray(opts.transparencySequence) ? opts.transparencySequence : null,
widthSequence: Array.isArray(opts.widthSequence) ? opts.widthSequence : null,
faceMode: opts.faceMode || 'billboard',
segments: Math.max(2, Number.isFinite(opts.segments) ? opts.segments : 24),
curved: !!opts.curved,
curveHeight: Number.isFinite(opts.curveHeight) ? opts.curveHeight : 2,
attachOffset: opts.attachOffset || { fromY: 0, toY: 0 },
ignoreDepth: opts.ignoreDepth !== false, // по умолчанию рисуем поверх
uOffset: 0,
mesh: null, mat: null,
};
this._buildBeamMaterial(it);
this.items.set(id, it);
this._updateBeam(it);
return id;
}
_buildBeamMaterial(it) {
if (it.mat) { try { it.mat.dispose(); } catch (e) {} }
const mat = new StandardMaterial('beamMat_' + it.id, this.scene);
const col = Color3.FromHexString(it.color);
mat.diffuseColor = col; mat.diffuseColor = col;
mat.emissiveColor = col; mat.emissiveColor = col;
mat.disableLighting = true; mat.disableLighting = true;
// Цилиндр-заготовка единичной высоты — масштабируем под длину луча. mat.backFaceCulling = false;
const mesh = MeshBuilder.CreateCylinder('beam_' + id, if (it.texture) {
{ height: 1, diameter: width, tessellation: 8 }, this.scene); let tex;
mesh.material = mat; if (it.texture === 'custom' && it.customTextureUrl) {
mesh.isPickable = false; tex = new Texture(it.customTextureUrl, this.scene);
mesh.renderingGroupId = 1; tex.hasAlpha = true;
const it = { tex.wrapU = Texture.WRAP_ADDRESSMODE;
id, type: 'beam', mesh, mat, tex.wrapV = Texture.CLAMP_ADDRESSMODE;
from: opts.from, to: opts.to, } else {
}; tex = this._getBeamTexture(it.texture, it.strokeColor, it.strokeWidth);
this.items.set(id, it); }
this._updateBeam(it); // сразу позиционируем mat.diffuseTexture = tex;
return id; mat.emissiveTexture = tex;
mat.opacityTexture = tex; // альфа формы
mat.useAlphaFromDiffuseTexture = true;
}
mat.alpha = it.texture ? 1 : 0.9;
if (it.ignoreDepth) {
mat.disableDepthWrite = true;
// рисуем поверх геометрии
}
it.mat = mat;
} }
/** Сменить цвет луча. */ /** Сменить цвет луча. */
setBeamColor(id, color) { setBeamColor(id, color) {
const it = this.items.get(Number(id)); const it = this.items.get(Number(id));
if (!it || it.type !== 'beam' || !it.mat) return; if (!it || !it.mat || !color) return;
const col = Color3.FromHexString(color || '#66ccff'); it.color = color;
const col = Color3.FromHexString(color);
it.mat.diffuseColor = col; it.mat.diffuseColor = col;
it.mat.emissiveColor = col; it.mat.emissiveColor = col;
this._recolorMarker(it, col);
// если есть градиент — он перекрывает; иначе vertexColors обновим в _updateBeam
it.colorSequence = null;
} }
/** Сменить концы луча (координаты или ref). */ /** Перекрасить quest-marker в цвет луча (при смене пресета). */
_recolorMarker(it, col) {
if (it && it._markerMat && col) {
it._markerMat.diffuseColor = col;
it._markerMat.emissiveColor = col;
}
}
/** Сменить концы луча (координаты | ref | 'player'). */
setBeamEndpoints(id, from, to) { setBeamEndpoints(id, from, to) {
const it = this.items.get(Number(id)); const it = this.items.get(Number(id));
if (!it || it.type !== 'beam') return; if (!it) return;
if (from !== undefined) it.from = from; if (from !== undefined) it.from = from;
if (to !== undefined) it.to = to; if (to !== undefined) it.to = to;
} }
/** Обновить произвольные опции луча на лету (для pointer.update). */
updateBeam(id, opts = {}) {
const it = this.items.get(Number(id));
if (!it) return;
let rebuild = false;
for (const k of ['texture', 'strokeColor', 'strokeWidth', 'customTextureUrl', 'ignoreDepth']) {
if (opts[k] !== undefined && opts[k] !== it[k]) { it[k] = opts[k]; rebuild = true; }
}
for (const k of ['color', 'width', 'textureMode', 'textureSpeed', 'textureScale',
'faceMode', 'segments', 'curved', 'curveHeight', 'attachOffset',
'colorSequence', 'transparencySequence', 'widthSequence']) {
if (opts[k] !== undefined) it[k] = opts[k];
}
if (opts.from !== undefined) it.from = opts.from;
if (opts.to !== undefined) it.to = opts.to;
if (rebuild) {
this._buildBeamMaterial(it);
} else if (opts.color !== undefined && !it.colorSequence) {
const col = Color3.FromHexString(it.color);
it.mat.diffuseColor = col; it.mat.emissiveColor = col;
}
// Перекрасить quest-marker под новый пресет. Для градиента (gift) —
// средний цвет последовательности.
if (it._markerMat) {
let mc = it.color;
if (it.colorSequence && it.colorSequence.length) {
mc = this._sampleSeqColor(it.colorSequence, 0.5) || it.color;
}
if (mc) this._recolorMarker(it, Color3.FromHexString(mc));
}
// геометрия (curved/segments/width) пересоберётся в _updateBeam — сбросим mesh
if (opts.curved !== undefined || opts.segments !== undefined
|| opts.width !== undefined || opts.widthSequence !== undefined
|| opts.colorSequence !== undefined) {
if (it.mesh) { try { it.mesh.dispose(); } catch (e) {} it.mesh = null; }
}
this._updateBeam(it);
}
setVisible(id, vis) {
const it = this.items.get(Number(id));
if (it && it.mesh) it.mesh.setEnabled(!!vis);
if (it && it._marker) it._marker.setEnabled(!!vis); // и маркер
if (it) it._hidden = !vis;
}
/** /**
* Создать шлейф за объектом. * Создать шлейф за объектом. (без изменений Фаза 5.2)
* ref ref-строка объекта. opts: { color, width, lifetime (сек) }.
* Возвращает id.
*/ */
addTrail(ref, opts = {}) { addTrail(ref, opts = {}) {
const data = this._resolve(ref); const data = this._resolve(ref);
@ -107,11 +372,8 @@ export class BeamManager {
const id = _fxIdSeq++; const id = _fxIdSeq++;
const width = Number.isFinite(opts.width) ? opts.width : 0.4; const width = Number.isFinite(opts.width) ? opts.width : 0.4;
const lifetime = Number.isFinite(opts.lifetime) ? opts.lifetime : 1.5; const lifetime = Number.isFinite(opts.lifetime) ? opts.lifetime : 1.5;
// TrailMesh(name, generator, scene, diameter, length, autoStart).
// length — сколько сегментов хранить; считаем из lifetime (≈60 fps).
const segments = Math.max(10, Math.round(lifetime * 60)); const segments = Math.max(10, Math.round(lifetime * 60));
const trail = new TrailMesh('trail_' + id, mesh, this.scene, const trail = new TrailMesh('trail_' + id, mesh, this.scene, width, segments, true);
width, segments, true);
const mat = new StandardMaterial('trailMat_' + id, this.scene); const mat = new StandardMaterial('trailMat_' + id, this.scene);
const col = Color3.FromHexString(opts.color || '#ffcc44'); const col = Color3.FromHexString(opts.color || '#ffcc44');
mat.diffuseColor = col; mat.diffuseColor = col;
@ -126,7 +388,7 @@ export class BeamManager {
return id; return id;
} }
/** Убрать луч/след по id. */ /** Убрать луч/след/указатель по id. */
remove(id) { remove(id) {
const it = this.items.get(Number(id)); const it = this.items.get(Number(id));
if (!it) return; if (!it) return;
@ -138,41 +400,380 @@ export class BeamManager {
_tick() { _tick() {
if (this.items.size === 0) return; if (this.items.size === 0) return;
const now = performance.now() / 1000;
let dt = this._lastTime ? (now - this._lastTime) : 0.016;
this._lastTime = now;
if (dt > 0.1) dt = 0.016; // защита от больших скачков (вкладка спала)
for (const it of this.items.values()) { for (const it of this.items.values()) {
// Trail обновляется самим Babylon (autoStart). Beam — мы. if (it.type === 'trail') continue; // Babylon сам
if (it.type === 'beam') this._updateBeam(it); if (it._hidden) continue;
this._updateBeam(it);
// Анимация бегущей текстуры.
if (this.animationEnabled && it.texture && it.textureSpeed
&& it.mat && it.mat.diffuseTexture && it.mat.diffuseTexture.uOffset !== undefined) {
it.uOffset -= it.textureSpeed * dt * 0.4;
it.mat.diffuseTexture.uOffset = it.uOffset;
}
// Парящий 3D-маркер над целью: позиция + подпрыгивание + вращение.
if (it._marker) this._tickMarker(it, now);
} }
} }
/** Анимировать quest-marker над целью (bob + spin), держать над to. */
_tickMarker(it, now) {
const root = it._marker;
if (!root) return;
// Цель: точка `to` (без attachOffset — нам нужна верхушка объекта).
const tgt = this._point(it.to, 0);
if (!tgt) { root.setEnabled(false); return; }
root.setEnabled(true);
const ph = it._markerPhase || 0;
// bob: плавный синус ±0.22м; высота над целью ~2.2м.
const bob = Math.sin(now * 3 + ph) * 0.22;
root.position.set(tgt.x, tgt.y + 2.2 + bob, tgt.z);
// Вращение всего маркера вокруг Y (дочерний конус остаётся остриём вниз).
root.rotation.y = now * 1.6 + ph;
}
/**
* Построить/обновить геометрию луча. Для текстурированных/curved
* ribbon из сегментов с UV вдоль длины. Для простого цилиндр (легаси).
*/
_updateBeam(it) { _updateBeam(it) {
const a = this._point(it.from); const a = this._point(it.from, it.attachOffset && it.attachOffset.fromY);
const b = this._point(it.to); const b = this._point(it.to, it.attachOffset && it.attachOffset.toY);
if (!a || !b || !it.mesh) return; if (!a || !b) {
if (it.mesh) it.mesh.setEnabled(false);
return;
}
const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z; const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
const len = Math.hypot(dx, dy, dz); const len = Math.hypot(dx, dy, dz);
if (len < 0.001) { it.mesh.setEnabled(false); return; } if (len < 0.01) { if (it.mesh) it.mesh.setEnabled(false); return; }
// Текстурированный/curved/градиент — лента (ribbon).
if (it.texture || it.curved || it.colorSequence || it.widthSequence) {
this._updateRibbon(it, a, b, len);
return;
}
// Простой луч — легаси цилиндр.
if (!it.mesh || it._meshKind !== 'cyl') {
if (it.mesh) { try { it.mesh.dispose(); } catch (e) {} }
it.mesh = MeshBuilder.CreateCylinder('beam_' + it.id,
{ height: 1, diameter: it.width, tessellation: 8 }, this.scene);
it.mesh.material = it.mat;
it.mesh.isPickable = false;
it.mesh.renderingGroupId = 1;
it._meshKind = 'cyl';
}
it.mesh.setEnabled(true); it.mesh.setEnabled(true);
// Цилиндр единичной высоты вдоль локальной оси Y. Растягиваем по длине.
it.mesh.scaling.y = len; it.mesh.scaling.y = len;
// Центр луча.
it.mesh.position.set((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2); it.mesh.position.set((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2);
// Ориентируем ось Y цилиндра вдоль вектора a→b.
const dir = new Vector3(dx, dy, dz).normalize(); const dir = new Vector3(dx, dy, dz).normalize();
// yaw + pitch так, чтобы +Y смотрел вдоль dir.
const yaw = Math.atan2(dir.x, dir.z); const yaw = Math.atan2(dir.x, dir.z);
const pitch = Math.acos(Math.max(-1, Math.min(1, dir.y))); const pitch = Math.acos(Math.max(-1, Math.min(1, dir.y)));
it.mesh.rotation.set(pitch, yaw, 0); it.mesh.rotation.set(pitch, yaw, 0);
} }
/** Точка из {x,y,z} или ref-строки объекта. */ /**
_point(p) { * Построить ленту (ribbon): две линии вершин вдоль направления fromto,
* смещённые перпендикулярно (billboard к камере). UV: u вдоль длины
* (количество тайлов = длина/ширина × textureScale), v поперёк.
*/
_updateRibbon(it, a, b, len) {
const seg = it.curved ? it.segments : 1;
const A = new Vector3(a.x, a.y, a.z);
const B = new Vector3(b.x, b.y, b.z);
// центральная линия (curved = квадратичная Безье через приподнятый midpoint)
const center = [];
if (it.curved) {
const mid = A.add(B).scale(0.5);
mid.y += it.curveHeight;
for (let i = 0; i <= seg; i++) {
const t = i / seg;
const omt = 1 - t;
// B(t) = (1-t)^2 A + 2(1-t)t M + t^2 B
const x = omt * omt * A.x + 2 * omt * t * mid.x + t * t * B.x;
const y = omt * omt * A.y + 2 * omt * t * mid.y + t * t * B.y;
const z = omt * omt * A.z + 2 * omt * t * mid.z + t * t * B.z;
center.push(new Vector3(x, y, z));
}
} else {
center.push(A, B);
}
// направление «вбок» для ширины: billboard → перпендикуляр к лучу и к камере.
const cam = this.scene.activeCamera;
const camPos = cam ? cam.position : new Vector3(0, 50, 0);
const widthAt = (t) => {
if (it.widthSequence && it.widthSequence.length) {
return this._sampleSeq(it.widthSequence, t, 'w', it.width);
}
return it.width;
};
const left = [], right = [];
for (let i = 0; i < center.length; i++) {
const p = center[i];
const t = center.length > 1 ? i / (center.length - 1) : 0;
// касательная к линии
const prev = center[Math.max(0, i - 1)];
const next = center[Math.min(center.length - 1, i + 1)];
const tangent = next.subtract(prev);
if (tangent.lengthSquared() < 1e-6) tangent.copyFrom(B.subtract(A));
tangent.normalize();
let side;
if (it.faceMode === 'flat-y') {
side = new Vector3(0, 1, 0).cross(tangent); // лежит горизонтально
} else if (it.faceMode === 'flat-x') {
side = new Vector3(1, 0, 0);
} else if (it.faceMode === 'flat-z') {
side = new Vector3(0, 0, 1);
} else { // billboard — перпендикуляр к лучу, в плоскости к камере
const toCam = camPos.subtract(p);
side = tangent.cross(toCam);
}
if (side.lengthSquared() < 1e-6) side = new Vector3(1, 0, 0);
side.normalize().scaleInPlace(widthAt(t) / 2);
left.push(p.add(side));
right.push(p.subtract(side));
}
const pathArray = [left, right];
// UV вдоль длины: тайлов = длина / ширина × scale
const tiles = Math.max(1, Math.round((len / Math.max(0.1, it.width)) * 0.6 * it.textureScale));
// (пере)создание ribbon. updatable, чтобы менять каждый кадр дёшево.
const needNew = !it.mesh || it._meshKind !== 'ribbon' || it._segCount !== center.length;
if (needNew) {
if (it.mesh) { try { it.mesh.dispose(); } catch (e) {} }
// sideOrientation НЕ DOUBLESIDE: при DOUBLESIDE Babylon удваивает
// вершины (фронт+бэк), а наши UV/vertexColors считаются на 2n вершин
// → setVerticesData выходит за vertex buffer → GL_INVALID_OPERATION
// «Vertex buffer is not big enough» (стрелка не рисовалась, WebGL
// отрубал контекст). Двусторонность даёт материал (backFaceCulling
// = false), DOUBLESIDE-геометрия не нужна.
it.mesh = MeshBuilder.CreateRibbon('beam_' + it.id, {
pathArray, updatable: true,
}, this.scene);
it.mesh.material = it.mat;
it.mesh.isPickable = false;
it.mesh.renderingGroupId = it.ignoreDepth ? 3 : 1;
it._meshKind = 'ribbon';
it._segCount = center.length;
this._applyRibbonUV(it, center.length, tiles);
this._applyRibbonColors(it, center.length);
} else {
it.mesh = MeshBuilder.CreateRibbon('beam_' + it.id, {
pathArray, instance: it.mesh,
});
// UV/colors при изменении длины тайлов
if (it._tiles !== tiles) { this._applyRibbonUV(it, center.length, tiles); }
}
it._tiles = tiles;
it.mesh.setEnabled(true);
}
/** UV: u = тайлы вдоль длины (для wrap-повтора), v = 0..1 поперёк. */
_applyRibbonUV(it, n, tiles) {
if (!it.mesh) return;
const uv = [];
// pathArray=[left,right] → вершины идут: left[0..n-1], right[0..n-1]
for (let i = 0; i < n; i++) {
const u = (i / (n - 1 || 1)) * tiles;
uv.push(u, 0);
}
for (let i = 0; i < n; i++) {
const u = (i / (n - 1 || 1)) * tiles;
uv.push(u, 1);
}
try { it.mesh.setVerticesData(VertexBuffer.UVKind, uv, true); } catch (e) {}
}
/** Градиент цвета/прозрачности вдоль длины через vertexColors. */
_applyRibbonColors(it, n) {
if (!it.mesh) return;
if (!it.colorSequence && !it.transparencySequence) {
// равномерный цвет — vertexColors не нужны (материал даёт цвет)
return;
}
const colors = [];
const base = Color3.FromHexString(it.color);
const sample = (t) => {
let c = base;
if (it.colorSequence) {
const hex = this._sampleSeqColor(it.colorSequence, t);
if (hex) c = Color3.FromHexString(hex);
}
const alpha = it.transparencySequence
? 1 - this._sampleSeq(it.transparencySequence, t, 't', 0)
: 1;
return new Color4(c.r, c.g, c.b, alpha);
};
for (let i = 0; i < n; i++) {
const t = i / (n - 1 || 1);
const c = sample(t);
colors.push(c.r, c.g, c.b, c.a);
}
for (let i = 0; i < n; i++) {
const t = i / (n - 1 || 1);
const c = sample(t);
colors.push(c.r, c.g, c.b, c.a);
}
try {
it.mesh.setVerticesData(VertexBuffer.ColorKind, colors, true);
it.mat.useVertexColor = true;
if (it.transparencySequence) it.mat.alpha = 1; // альфа из vertexColor
} catch (e) {}
}
_sampleSeq(seq, t, key, def) {
if (!seq || !seq.length) return def;
let prev = seq[0], next = seq[seq.length - 1];
for (let i = 0; i < seq.length; i++) {
if (seq[i].p <= t) prev = seq[i];
if (seq[i].p >= t) { next = seq[i]; break; }
}
if (prev === next || next.p === prev.p) return prev[key];
const f = (t - prev.p) / (next.p - prev.p);
return prev[key] + (next[key] - prev[key]) * f;
}
_sampleSeqColor(seq, t) {
if (!seq || !seq.length) return null;
let prev = seq[0], next = seq[seq.length - 1];
for (let i = 0; i < seq.length; i++) {
if (seq[i].p <= t) prev = seq[i];
if (seq[i].p >= t) { next = seq[i]; break; }
}
const ca = Color3.FromHexString(prev.c), cb = Color3.FromHexString(next.c);
if (prev === next || next.p === prev.p) return prev.c;
const f = (t - prev.p) / (next.p - prev.p);
const r = Math.round((ca.r + (cb.r - ca.r) * f) * 255);
const g = Math.round((ca.g + (cb.g - ca.g) * f) * 255);
const b = Math.round((ca.b + (cb.b - ca.b) * f) * 255);
return '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('');
}
// ===================================================================
// POINTER (game.fx.pointer) — высокоуровневая стрелка-указатель
// ===================================================================
/**
* Создать стрелку-указатель. opts: { from, to, preset, ...override }.
* from/to: {x,y,z} | ref | 'player'. preset: guide|quest|danger|gift|custom.
* Возвращает id (это beam с pointer-флагом).
*/
addPointer(opts = {}) {
const preset = POINTER_PRESETS[opts.preset] || (opts.preset === 'custom' ? {} : POINTER_PRESETS.guide);
const merged = {
from: opts.from,
to: opts.to,
...preset,
// явные override из opts перебивают пресет:
...(opts.color !== undefined ? { color: opts.color } : {}),
...(opts.texture !== undefined ? { texture: opts.texture } : {}),
...(opts.textureSpeed !== undefined ? { textureSpeed: opts.textureSpeed } : {}),
...(opts.strokeColor !== undefined ? { strokeColor: opts.strokeColor } : {}),
...(opts.colorSequence !== undefined ? { colorSequence: opts.colorSequence } : {}),
...(opts.curved !== undefined ? { curved: opts.curved } : {}),
...(opts.curveHeight !== undefined ? { curveHeight: opts.curveHeight } : {}),
...(opts.faceMode !== undefined ? { faceMode: opts.faceMode } : {}),
...(opts.customTextureUrl !== undefined ? { texture: 'custom', customTextureUrl: opts.customTextureUrl } : {}),
// Указатель — горизонтальная лента (flat-y), лежит над землёй и
// всегда видна сверху. billboard на curved-ленте вырождался (side-
// вектор tangent×toCam схлопывался на сегментах смотрящих на камеру)
// → стрелка пропадала. flat-y держит ширину стабильно.
faceMode: opts.faceMode || 'flat-y',
curved: opts.curved !== undefined ? opts.curved : true,
curveHeight: opts.curveHeight !== undefined ? opts.curveHeight : 0.8,
// Лента над землёй, цель — у её центра. Хорошо видно, не в земле.
width: opts.width !== undefined ? opts.width : 1.4,
attachOffset: opts.attachOffset || { fromY: 1.6, toY: 1.2 },
};
const id = this.addBeam(merged);
const it = this.items.get(id);
if (it) {
it._isPointer = true;
// Парящая 3D-стрелка над целью (как quest-marker в Roblox):
// объёмный конус вершиной ВНИЗ, подпрыгивает + вращается + светится.
this._makePointerMarker(it);
}
return id;
}
/**
* 3D-маркер над целью (Roblox quest-pin): гладкий конус остриём ВНИЗ,
* светится, с чёрной обводкой. Анимация (bob + spin) на РОДИТЕЛЕ
* (TransformNode), сам конус ориентирован вниз статично, поэтому spin
* не ломает «вниз» направление и обводка не съезжает.
*/
_makePointerMarker(it) {
try {
const col = Color3.FromHexString(it.color || '#ff3a3a');
// Родитель: только позиция/bob/spin. Дочерний конус — геометрия.
const root = new TransformNode('ptrMarkerRoot_' + it.id, this.scene);
// Гладкий конус (tessellation 18 — не пирамида). Остриё вниз:
// CreateCylinder остриём ВВЕРХ (diameterTop=0), поворот X=PI → вниз.
const m = MeshBuilder.CreateCylinder('ptrMarker_' + it.id, {
height: 1.0, diameterTop: 0, diameterBottom: 0.55, tessellation: 18,
}, this.scene);
m.parent = root;
m.rotation.x = Math.PI; // остриём вниз (на цель)
m.isPickable = false;
m.renderingGroupId = 3; // поверх геометрии (как beam)
const mat = new StandardMaterial('ptrMarkerMat_' + it.id, this.scene);
mat.diffuseColor = col;
mat.emissiveColor = col; // светится
mat.disableLighting = true;
mat.disableDepthWrite = true; // рисуем поверх
m.material = mat;
// Мультяшная обводка — встроенный Babylon outline (не второй меш,
// потому не съезжает). Толщину даём заметную.
m.renderOutline = true;
m.outlineColor = new Color3(0, 0, 0);
m.outlineWidth = 0.06;
it._marker = root;
it._markerMesh = m;
it._markerMat = mat;
it._markerPhase = (it.id % 7) * 0.5; // разный фазовый сдвиг bob
} catch (e) {
console.warn('[BeamManager] marker create failed:', e);
}
}
/** Сменить цель указателя. */
setPointerTarget(id, to) {
const it = this.items.get(Number(id));
if (it) it.to = to;
}
/** Применить пресет к существующему указателю (для pointer.update({preset})). */
applyPointerPreset(id, preset) {
const p = POINTER_PRESETS[preset];
if (!p) return;
this.updateBeam(id, p);
}
// ===================================================================
/** Точка из {x,y,z} | ref | 'player'. yOff — доп. смещение по Y. */
_point(p, yOff) {
const off = Number.isFinite(yOff) ? yOff : 0;
if (!p) return null; if (!p) return null;
if (p === 'player') {
const pl = this.scene3d && this.scene3d.player;
if (pl && pl._pos) return { x: pl._pos.x, y: pl._pos.y + off, z: pl._pos.z };
return null;
}
if (typeof p === 'object' && Number.isFinite(p.x)) { if (typeof p === 'object' && Number.isFinite(p.x)) {
return { x: p.x, y: p.y, z: p.z }; return { x: p.x, y: p.y + off, z: p.z };
} }
if (typeof p === 'string') { if (typeof p === 'string') {
const d = this._resolve(p); const d = this._resolve(p);
if (d) return { x: d.x, y: d.y, z: d.z }; if (d) return { x: d.x, y: d.y + off, z: d.z };
} }
return null; return null;
} }

View File

@ -0,0 +1,698 @@
/**
* BillboardUiManager управление 3D-табличками с GUI (BillboardGui в Roblox).
*
* Каждая табличка это plane-mesh с натянутой DynamicTexture, на которой
* с помощью обычного Canvas 2D API рисуется содержимое: градиентный фон,
* иконка, заголовок, подзаголовок, кнопка цены.
*
* Поддерживает 4 пресета (template):
* - 'shop-item' иконка слева, заголовок, "1 > 2", кнопка цены справа
* - 'shop-purchase' иконка + название + цена в рубликах
* - 'banner' крупная плашка с одним текстом
* - 'sign' простой указатель с текстом
*
* Режимы ориентации:
* - 'camera' всегда смотрит на камеру (BillboardMode.BILLBOARDMODE_ALL)
* - 'fixed' фиксированная ориентация (используется rotationY mesh-а)
*
* Клики:
* - на mesh-е ставим pickable=true
* - в _handlePick ловим точку пересечения, переводим в UV,
* ищем под этой точкой кнопку и эмитим событие
*
* State хранится в PrimitiveManager.instances[id].billboard:
* {
* template: 'shop-item',
* face: 'camera',
* content: { icon, title, sub, price, gradient: [from, to] }
* // или для custom-режима — elements: [...]
* onClickHandlers: { 'buy': fn } // только в Play-режиме
* }
*/
import {
DynamicTexture, StandardMaterial, Color3, Mesh, Texture,
} from '@babylonjs/core';
// Размер текстуры таблички (UV pixels). Чем больше — тем чётче, но больше VRAM.
// 512×320 даёт нормальное качество на расстоянии 2-10 метров.
const TEXTURE_W = 512;
const TEXTURE_H = 320;
// Координаты кнопки в shop-item пресете (для hit-теста кликов).
// Совпадают с тем что рисуется в _renderShopItem (cx, cy, cw, ch).
const SHOP_ITEM_BUTTON = { x: 332, y: 200, w: 160, h: 90 };
// Описания доступных иконок (key → emoji-аналог для Canvas).
// На фронте мы не имеем доступа к специализированным icon-fonts,
// поэтому используем простые символы рисуемые крупно. Можно расширить
// до полноценной библиотеки PNG-иконок позже.
const ICONS = {
hammer: '🔨',
saw: '🪚',
drop: '💧',
seed: '🌱',
cube: '🧊',
coin: '💰',
home: '🏠',
rocket: '🚀',
sprinkler: '⛲',
sparkle: '✨',
star: '⭐',
bag: '🎒',
diamond: '💎',
fire: '🔥',
lightning: '⚡',
heart: '❤',
key: '🔑',
shield: '🛡',
};
export class BillboardUiManager {
constructor(scene) {
this.scene = scene;
// Колбэки от пользовательских скриптов: key=`${id}:${buttonId}` → fn
this._clickHandlers = new Map();
}
/**
* Применить к существующему mesh-у настройки билборда:
* натянуть DynamicTexture, нарисовать контент, выставить billboardMode.
*
* data объект из PrimitiveManager.instances[id], содержит уже mesh, sx, sy, sz.
* billboardOpts { template, face, content, elements }
*/
applyToMesh(data, billboardOpts = {}) {
const mesh = data.mesh;
if (!mesh) return;
const template = billboardOpts.template || 'shop-item';
const face = billboardOpts.face || 'camera';
const content = billboardOpts.content || this._defaultContent(template);
// Создаём DynamicTexture один раз и кешируем на mesh.
let dyn = mesh.metadata?._billboardTexture;
if (!dyn) {
dyn = new DynamicTexture(
`bb_tex_${data.id}`,
{ width: TEXTURE_W, height: TEXTURE_H },
this.scene,
false /* generateMipMaps */
);
dyn.hasAlpha = true;
// Новый StandardMaterial с этой текстурой как diffuseTexture+emissiveTexture
// (emissive чтобы светилось и без освещения, как UI).
const mat = new StandardMaterial(`bb_mat_${data.id}`, this.scene);
mat.diffuseTexture = dyn;
mat.emissiveTexture = dyn;
mat.emissiveColor = new Color3(1, 1, 1);
mat.specularColor = new Color3(0, 0, 0);
mat.useAlphaFromDiffuseTexture = true;
mat.backFaceCulling = false;
mesh.material = mat;
if (!mesh.metadata) mesh.metadata = {};
mesh.metadata._billboardTexture = dyn;
mesh.metadata._billboardMaterial = mat;
}
// Ориентация. Babylon-quirk: CreatePlane FRONT в -Z (left-handed),
// юзер ставит билборд так чтобы лицо смотрело в +Z мира → +π.
mesh.billboardMode = Mesh.BILLBOARDMODE_NONE;
if (mesh.metadata._billboardLookObs) {
this.scene.onBeforeRenderObservable.remove(mesh.metadata._billboardLookObs);
mesh.metadata._billboardLookObs = null;
}
if (face === 'camera') {
// Ручной look-at — каждый кадр поворачиваем front к камере.
const obs = this.scene.onBeforeRenderObservable.add(() => {
if (mesh.isDisposed()) return;
const cam = this.scene.activeCamera;
if (!cam) return;
const dx = cam.position.x - mesh.position.x;
const dz = cam.position.z - mesh.position.z;
mesh.rotation.y = Math.atan2(dx, dz) + Math.PI;
});
mesh.metadata._billboardLookObs = obs;
} else {
// Фиксированная ориентация: front в +Z + пользовательский rotationY.
const userY = Number.isFinite(billboardOpts.rotationY) ? billboardOpts.rotationY : 0;
mesh.rotation.y = Math.PI + userY;
// Двусторонняя табличка: рамка стоит, но при взгляде сзади
// флипаем UV таблички чтобы текст не был зеркальным.
const mat = mesh.material;
if (mat) {
// Включаем рендер обеих сторон (back-face визуализируется).
mat.backFaceCulling = false;
}
const obs = this.scene.onBeforeRenderObservable.add(() => {
if (mesh.isDisposed()) return;
const cam = this.scene.activeCamera;
if (!cam) return;
// Локальная нормаль FRONT plane = +Z. Поворот mesh.rotation.y
// переводит её в world: normalWorld = (sin(ry), 0, cos(ry)).
const ry = mesh.rotation.y;
const nWx = Math.sin(ry);
const nWz = Math.cos(ry);
// Вектор от mesh к камере
const vx = cam.position.x - mesh.position.x;
const vz = cam.position.z - mesh.position.z;
// Скалярное произведение: >0 — камера смотрит на FRONT,
// <0 — на BACK (зеркальная UV). Для BACK инвертируем uScale.
const dot = nWx * vx + nWz * vz;
const dyn = mesh.metadata?._billboardTexture;
if (dyn) {
// dot > 0 — камера со стороны FRONT-нормали → flip
// dot < 0 — камера сзади → нормально
if (dot > 0) {
if (dyn.uScale !== -1) { dyn.uScale = -1; dyn.uOffset = 1; }
} else {
if (dyn.uScale !== 1) { dyn.uScale = 1; dyn.uOffset = 0; }
}
}
});
mesh.metadata._billboardLookObs = obs;
}
mesh.scaling.x = Math.abs(mesh.scaling.x || 1);
mesh.metadata._billboardMirrorX = false;
// Сохраняем state в data для сериализации и для hit-теста кликов.
data.billboard = {
template,
face,
content: { ...content },
elements: billboardOpts.elements || null,
};
dyn._kubikonMirrorX = mesh.metadata._billboardMirrorX === true;
dyn._kubikonOwnerMesh = mesh;
this._render(dyn, template, content, billboardOpts.elements);
}
/**
* Обновить контент билборда (без пересоздания текстуры).
* Две формы:
* 1) update(data, { sub: '2 > 3', price: '$20,000' }) patch content
* 2) update(data, 'buy', { text: '$15,000' }) patch конкретного элемента
* по id (для elements-режима ИЛИ для known-id пресета: 'buy', 'title',
* 'sub', 'price', 'icon', 'gradient' маппятся на поля content).
*/
update(data, elementIdOrPatch, patchMaybe) {
if (!data.billboard) return;
// Форма 2: 3 аргумента (data, elementId, patch)
if (typeof elementIdOrPatch === 'string' && typeof patchMaybe === 'object' && patchMaybe !== null) {
const elId = elementIdOrPatch;
const patch = patchMaybe;
// Кастомные elements: ищем элемент по id и обновляем его поля.
if (Array.isArray(data.billboard.elements)) {
data.billboard.elements = data.billboard.elements.map(el =>
el && el.id === elId ? { ...el, ...patch } : el);
} else {
// Пресет: мапим известные elementId → ключ content.
// 'buy' → content.price; 'title'/'sub'/'icon'/'gradient' → одноимённый ключ.
const c = { ...(data.billboard.content || {}) };
if (elId === 'buy' && 'text' in patch) {
c.price = patch.text;
} else if (elId in c) {
// Если patch имеет text — кладём в content[elId], иначе мерджим поля.
if ('text' in patch) c[elId] = patch.text;
else Object.assign(c, patch);
} else {
Object.assign(c, patch);
}
data.billboard.content = c;
}
} else if (typeof elementIdOrPatch === 'object' && elementIdOrPatch !== null) {
// Форма 1: 2 аргумента (data, patchContent)
data.billboard.content = { ...data.billboard.content, ...elementIdOrPatch };
} else {
return;
}
const dyn = data.mesh?.metadata?._billboardTexture;
if (dyn) {
this._render(dyn, data.billboard.template, data.billboard.content, data.billboard.elements);
}
}
/** Подписаться на клик по кнопке билборда (только в Play-режиме). */
onClick(data, buttonId, fn) {
const key = `${data.id}:${buttonId}`;
this._clickHandlers.set(key, fn);
}
/** Снять все подписки (вызывается при остановке Play). */
clearHandlers() {
this._clickHandlers.clear();
}
/**
* Проверить клик по точке UV: вернуть buttonId или null.
* UV точка нормализованная (0..1).
*/
pickButtonAt(data, uvX, uvY) {
if (!data.billboard) return null;
// Если текстура в данный момент отзеркалена (face=fixed, смотрим
// на back-side), uScale=-1 — инвертим UV.x чтобы попасть в правильный
// canvas-пиксель.
const dyn = data.mesh?.metadata?._billboardTexture;
const flipped = dyn && dyn.uScale === -1;
const uX = flipped ? (1 - uvX) : uvX;
const px = uX * TEXTURE_W;
const py = (1 - uvY) * TEXTURE_H;
// Кастомные elements имеют приоритет (если заданы)
if (data.billboard.elements) {
return this._hitTestElements(data.billboard.elements, px, py);
}
const tmpl = data.billboard.template;
if (tmpl === 'shop-item' || tmpl === 'shop-purchase') {
// Кнопка адаптивной ширины — пересчитываем её rect по тексту
// именно ЭТОЙ таблички (тем же _computeBuyRect, что и при рисовании).
const label = (data.billboard.content && data.billboard.content.price) || '$0';
let b = SHOP_ITEM_BUTTON;
try {
const measCtx = (dyn && dyn.getContext && dyn.getContext()) || null;
if (measCtx) b = this._computeBuyRect(measCtx, label, SHOP_ITEM_BUTTON);
} catch (e) { /* fallback на базовый rect */ }
if (px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h) {
return 'buy';
}
}
// banner и sign — кнопок нет, только текст.
return null;
}
/** Вызвать обработчик клика, если он подписан. */
fireClick(data, buttonId) {
const key = `${data.id}:${buttonId}`;
const fn = this._clickHandlers.get(key);
if (fn) {
try { fn(); } catch (e) { console.error('[Billboard onClick]', e); }
}
// Также пишем кнопку в "нажатом" виде на 100мс для UX-фидбека.
this._flashButton(data, buttonId);
}
_flashButton(data, buttonId) {
if (!data.billboard) return;
const dyn = data.mesh?.metadata?._billboardTexture;
if (!dyn) return;
// Перерисовываем pressed=true. ВАЖНО: используем СВЕЖИЙ content в callback'е
// (на момент 120мс content уже может быть обновлён через update — берём
// актуальный, иначе откатим к старому).
// Также гарантируем 1 flash на табличку — если предыдущий ещё крутится,
// отменяем его таймер.
if (data._flashTimer) {
clearTimeout(data._flashTimer);
data._flashTimer = null;
}
this._render(dyn, data.billboard.template, data.billboard.content,
data.billboard.elements, /* pressed */ buttonId);
data._flashTimer = setTimeout(() => {
data._flashTimer = null;
// Берём АКТУАЛЬНЫЕ data.billboard.content/elements — могли обновиться
// через game.billboard.update() ВО ВРЕМЯ flash'а.
if (data.mesh?.metadata?._billboardTexture === dyn && data.billboard) {
this._render(dyn, data.billboard.template, data.billboard.content,
data.billboard.elements, null);
}
}, 120);
}
_defaultContent(template) {
switch (template) {
case 'shop-item':
return { icon: 'hammer', title: 'Апгрейд', sub: '1 > 2',
price: '$100', gradient: ['#ff5a5a', '#ff8a3d'] };
case 'shop-purchase':
return { icon: 'seed', title: 'Набор семян', sub: 'x3',
price: '199 R', gradient: ['#3b82f6', '#0ea5e9'] };
case 'banner':
return { title: 'Удвоенный урожай в 17:00',
gradient: ['#7c3aed', '#a855f7'] };
case 'sign':
return { title: 'Сюда', gradient: ['#1f2937', '#374151'] };
default:
return { title: 'Табличка', gradient: ['#1f2937', '#374151'] };
}
}
/** Главная функция рендера — рисует контент на canvas DynamicTexture. */
_render(dyn, template, content, elements, pressedButtonId) {
const ctx = dyn.getContext();
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, TEXTURE_W, TEXTURE_H);
if (elements && Array.isArray(elements)) {
this._renderElements(ctx, elements, pressedButtonId);
} else {
switch (template) {
case 'shop-item':
this._renderShopItem(ctx, content, pressedButtonId);
break;
case 'shop-purchase':
this._renderShopPurchase(ctx, content, pressedButtonId);
break;
case 'banner':
this._renderBanner(ctx, content);
break;
case 'sign':
this._renderSign(ctx, content);
break;
default:
this._renderBanner(ctx, content);
}
}
ctx.restore();
dyn.update(true);
}
/** Скруглённый прямоугольник + заливка градиентом + обводка. */
_roundedGradientRect(ctx, x, y, w, h, opts) {
const r = opts.radius ?? 24;
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
const grad = ctx.createLinearGradient(x, y, x, y + h);
const [from, to] = opts.gradient || ['#333', '#111'];
grad.addColorStop(0, from);
grad.addColorStop(1, to);
ctx.fillStyle = grad;
ctx.fill();
if (opts.stroke) {
ctx.lineWidth = opts.stroke.width ?? 3;
ctx.strokeStyle = opts.stroke.color || '#000';
ctx.stroke();
}
}
/** Рендер пресета shop-item: иконка слева | title + sub | кнопка цены. */
_renderShopItem(ctx, content, pressedButtonId) {
// Главная плашка
this._roundedGradientRect(ctx, 8, 8, TEXTURE_W - 16, TEXTURE_H - 16, {
gradient: content.gradient || ['#ff5a5a', '#ff8a3d'],
radius: 28,
stroke: { color: '#0008', width: 4 },
});
// Иконка-слот: круг чуть темнее в левой части
ctx.beginPath();
ctx.arc(110, 130, 70, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.18)';
ctx.fill();
// Сама иконка (emoji крупно)
const iconChar = ICONS[content.icon] || ICONS.cube;
ctx.font = 'bold 96px "Segoe UI Emoji", Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#fff';
ctx.fillText(iconChar, 110, 132);
// Заголовок
ctx.font = 'bold 36px Arial, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillStyle = '#fff';
// Лёгкая тень
ctx.shadowColor = 'rgba(0,0,0,0.45)';
ctx.shadowBlur = 4;
ctx.shadowOffsetY = 2;
ctx.fillText(this._truncate(content.title || '', 18), 200, 50);
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
ctx.shadowOffsetY = 0;
// Подзаголовок "1 > 2" — зелёный, поменьше
if (content.sub) {
ctx.font = 'bold 28px Arial, sans-serif';
ctx.fillStyle = '#a7f3d0';
ctx.fillText(content.sub, 200, 105);
}
// Кнопка цены — жёлтый прямоугольник внизу справа.
// Ширина адаптивная: длинный текст («ПРИМЕРИТЬ»/«НАДЕТО») расширяет
// кнопку ВЛЕВО (правый край прижат к краю таблички), шрифт ужимается
// если даже расширенная кнопка узка. Фактический rect → _buyRect для hit-теста.
const pressed = pressedButtonId === 'buy';
const label = content.price || '$0';
const rect = this._computeBuyRect(ctx, label, SHOP_ITEM_BUTTON);
this._roundedGradientRect(ctx, rect.x, rect.y, rect.w, rect.h, {
gradient: pressed
? ['#d97706', '#92400e']
: ['#fbbf24', '#f59e0b'],
radius: 16,
stroke: { color: '#000', width: 3 },
});
ctx.font = `bold ${rect.fontSize}px Arial, sans-serif`;
ctx.fillStyle = pressed ? '#fef3c7' : '#1c1917';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
}
/**
* Подобрать прямоугольник кнопки «buy» под текст: правый край прижат к
* правому краю таблички (как в базовом SHOP_ITEM_BUTTON), ширина растёт
* влево под длину текста, шрифт ужимается если упёрлись в макс-ширину.
* Возвращает { x, y, w, h, fontSize }.
*/
_computeBuyRect(ctx, label, base) {
const PAD = 36; // отступы текста по бокам
const MAX_W = 300; // макс ширина кнопки (не залезать на title)
const rightEdge = base.x + base.w; // правый край держим на месте
let fontSize = 36;
ctx.font = `bold ${fontSize}px Arial, sans-serif`;
let textW = ctx.measureText(label).width;
let w = Math.max(base.w, textW + PAD * 2);
if (w > MAX_W) {
// Ужимаем шрифт чтобы текст влез в MAX_W.
w = MAX_W;
const inner = MAX_W - PAD * 2;
while (fontSize > 20 && textW > inner) {
fontSize -= 2;
ctx.font = `bold ${fontSize}px Arial, sans-serif`;
textW = ctx.measureText(label).width;
}
}
return { x: rightEdge - w, y: base.y, w, h: base.h, fontSize };
}
/** Рендер пресета shop-purchase: похож на shop-item, но иконка крупнее и центрирована. */
_renderShopPurchase(ctx, content, pressedButtonId) {
this._roundedGradientRect(ctx, 8, 8, TEXTURE_W - 16, TEXTURE_H - 16, {
gradient: content.gradient || ['#3b82f6', '#0ea5e9'],
radius: 28,
stroke: { color: '#0008', width: 4 },
});
const iconChar = ICONS[content.icon] || ICONS.bag;
ctx.font = 'bold 110px "Segoe UI Emoji", Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#fff';
ctx.fillText(iconChar, 110, 140);
ctx.font = 'bold 34px Arial, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillStyle = '#fff';
ctx.fillText(this._truncate(content.title || '', 16), 200, 50);
if (content.sub) {
ctx.font = 'bold 26px Arial, sans-serif';
ctx.fillStyle = '#dbeafe';
ctx.fillText(content.sub, 200, 100);
}
// Кнопка-цена (адаптивная ширина под длинный текст, см. _computeBuyRect).
const pressed = pressedButtonId === 'buy';
const label = content.price || '0 R';
const rect = this._computeBuyRect(ctx, label, SHOP_ITEM_BUTTON);
this._roundedGradientRect(ctx, rect.x, rect.y, rect.w, rect.h, {
gradient: pressed
? ['#9333ea', '#6b21a8']
: ['#a855f7', '#7c3aed'],
radius: 16,
stroke: { color: '#000', width: 3 },
});
ctx.font = `bold ${rect.fontSize}px Arial, sans-serif`;
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
}
/** Рендер пресета banner: одна крупная фраза по центру. */
_renderBanner(ctx, content) {
this._roundedGradientRect(ctx, 8, 8, TEXTURE_W - 16, TEXTURE_H - 16, {
gradient: content.gradient || ['#7c3aed', '#a855f7'],
radius: 28,
stroke: { color: '#0008', width: 4 },
});
ctx.font = 'bold 46px Arial, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#fff';
ctx.shadowColor = 'rgba(0,0,0,0.45)';
ctx.shadowBlur = 8;
ctx.shadowOffsetY = 3;
// Перенос строк, чтобы длинная фраза влезла
const lines = this._wrapText(ctx, content.title || '', TEXTURE_W - 80);
const lh = 56;
const startY = TEXTURE_H / 2 - (lines.length - 1) * lh / 2;
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], TEXTURE_W / 2, startY + i * lh);
}
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
ctx.shadowOffsetY = 0;
}
/** Рендер пресета sign: компактный указатель. */
_renderSign(ctx, content) {
this._roundedGradientRect(ctx, 8, 8, TEXTURE_W - 16, TEXTURE_H - 16, {
gradient: content.gradient || ['#1f2937', '#374151'],
radius: 20,
stroke: { color: '#fff', width: 4 },
});
// Заголовок крупно сверху
ctx.font = 'bold 44px Arial, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#ffd166';
const title = content.title || '';
const subText = content.sub || '';
if (subText) {
// Заголовок сверху, sub-строки списком ниже
ctx.fillText(this._truncate(title, 18), TEXTURE_W / 2, 50);
// Sub — многострочный, выравнивание по левому краю
ctx.font = '20px Arial, sans-serif';
ctx.fillStyle = '#fff';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const lines = String(subText).split('\n');
const startY = 95;
const lineH = 30;
const leftX = 38;
for (let i = 0; i < lines.length && i < 8; i++) {
ctx.fillText(this._truncate(lines[i], 36), leftX, startY + i * lineH);
}
} else {
ctx.font = 'bold 64px Arial, sans-serif';
ctx.fillText(this._truncate(title, 14), TEXTURE_W / 2, TEXTURE_H / 2);
}
}
/** Рендер кастомного списка элементов: фон + список text/image/button.
* Каждый элемент: { kind, x, y, w, h, ... }
* text: { text, size, color, bold, align }
* image: { src (icon-key), w, h }
* button: { id, text, background: {color|gradient, cornerRadius, stroke} }
*/
_renderElements(ctx, elements, pressedButtonId) {
// Фоновая плашка — первый элемент типа 'background' (опционально)
const bg = elements.find(e => e.kind === 'background');
if (bg) {
this._roundedGradientRect(ctx, 8, 8, TEXTURE_W - 16, TEXTURE_H - 16, {
gradient: bg.gradient || ['#1f2937', '#374151'],
radius: bg.cornerRadius ?? 24,
stroke: bg.stroke || { color: '#0008', width: 4 },
});
}
// Остальные элементы — поверх фона
for (const el of elements) {
if (el.kind === 'background') continue;
if (el.kind === 'text') {
ctx.font = `${el.bold ? 'bold ' : ''}${el.size || 24}px Arial, sans-serif`;
ctx.fillStyle = el.color || '#fff';
ctx.textAlign = el.align || 'left';
ctx.textBaseline = 'top';
ctx.fillText(el.text || '', el.x || 0, el.y || 0);
} else if (el.kind === 'image') {
const iconChar = ICONS[el.src] || ICONS.cube;
const size = Math.min(el.w || 64, el.h || 64);
ctx.font = `${Math.round(size * 1.1)}px "Segoe UI Emoji", Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = el.color || '#fff';
ctx.fillText(iconChar, (el.x || 0) + (el.w || 64) / 2,
(el.y || 0) + (el.h || 64) / 2);
} else if (el.kind === 'button') {
const isPressed = pressedButtonId === el.id;
const bgSpec = el.background || {};
this._roundedGradientRect(ctx, el.x || 0, el.y || 0,
el.w || 100, el.h || 36, {
gradient: bgSpec.gradient ||
(bgSpec.color ? [bgSpec.color, bgSpec.color] : ['#fbbf24', '#f59e0b']),
radius: bgSpec.cornerRadius ?? 12,
stroke: bgSpec.stroke || { color: '#000', width: 2 },
});
ctx.font = `bold ${el.textSize || 28}px Arial, sans-serif`;
ctx.fillStyle = isPressed ? '#fef3c7' : (el.textColor || '#1c1917');
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(el.text || '', (el.x || 0) + (el.w || 100) / 2,
(el.y || 0) + (el.h || 36) / 2);
}
}
}
/** Hit-тест для кастомных elements (используется в pickButtonAt). */
_hitTestElements(elements, px, py) {
for (const el of elements) {
if (el.kind !== 'button') continue;
const x = el.x || 0, y = el.y || 0;
const w = el.w || 100, h = el.h || 36;
if (px >= x && px <= x + w && py >= y && py <= y + h) {
return el.id || null;
}
}
return null;
}
_truncate(s, max) {
if (!s) return '';
return s.length > max ? s.slice(0, max - 1) + '…' : s;
}
_wrapText(ctx, text, maxWidth) {
const words = (text || '').split(' ');
const lines = [];
let cur = '';
for (const w of words) {
const test = cur ? cur + ' ' + w : w;
if (ctx.measureText(test).width <= maxWidth) {
cur = test;
} else {
if (cur) lines.push(cur);
cur = w;
}
}
if (cur) lines.push(cur);
return lines.slice(0, 3); // максимум 3 строки
}
/** Список доступных иконок (для UI редактора). */
static getAvailableIcons() {
return Object.keys(ICONS);
}
/** Список доступных пресетов (для UI редактора). */
static getAvailableTemplates() {
return [
{ id: 'shop-item', name: 'Магазин: апгрейд', hasButton: true,
fields: ['icon', 'title', 'sub', 'price', 'gradient'] },
{ id: 'shop-purchase', name: 'Магазин: покупка', hasButton: true,
fields: ['icon', 'title', 'sub', 'price', 'gradient'] },
{ id: 'banner', name: 'Баннер', hasButton: false,
fields: ['title', 'gradient'] },
{ id: 'sign', name: 'Указатель', hasButton: false,
fields: ['title', 'gradient'] },
];
}
}

View File

@ -94,6 +94,10 @@ export class BlockManager {
this._lavaSurfaceBaseY = null; this._lavaSurfaceBaseY = null;
this._lavaDirty = false; this._lavaDirty = false;
this._animTime = 0; this._animTime = 0;
// Окрашиваемые блоки (studs-block, задача 09): per-instance color через
// ThinInstance color buffer. blockTypeId → Float32Array(maxBlocks*4 RGBA).
this._colorsByProto = new Map();
this._STUDS_MAX = 20000; // макс блоков одного окрашиваемого типа
} }
/** Вызывать каждый кадр для анимации воды/лавы. */ /** Вызывать каждый кадр для анимации воды/лавы. */
@ -359,6 +363,23 @@ export class BlockManager {
const mat = new StandardMaterial(name, this.scene); const mat = new StandardMaterial(name, this.scene);
mat.specularColor = new Color3(0, 0, 0); mat.specularColor = new Color3(0, 0, 0);
// Окрашиваемый блок (studs-block): цвет берётся per-instance из vertex
// color буфера ThinInstance и умножается на серую текстуру. Включаем
// useVertexColors, normal map (выпуклость кружков), мягкий спекуляр.
if (blockType.colorable) {
const tex = new Texture(texturePath, this.scene);
mat.diffuseTexture = tex;
mat.diffuseColor = new Color3(1, 1, 1); // нейтраль — цвет идёт из vertex color
if (blockType.normal) {
try { mat.bumpTexture = new Texture(blockType.normal, this.scene); } catch (e) {}
}
// Сочность (Roblox-look): почти-белая текстура × яркий vertex color,
// specular убран (он белит/тускнит цвет).
mat.specularColor = new Color3(0, 0, 0);
mat.useVertexColors = true;
return mat;
}
if (texturePath) { if (texturePath) {
const tex = new Texture(texturePath, this.scene); const tex = new Texture(texturePath, this.scene);
tex.updateSamplingMode(Texture.NEAREST_SAMPLINGMODE); tex.updateSamplingMode(Texture.NEAREST_SAMPLINGMODE);
@ -439,7 +460,7 @@ export class BlockManager {
* один meshes-prototype на тип блока, тысячи позиций в одном draw call. * один meshes-prototype на тип блока, тысячи позиций в одном draw call.
* Жидкости (water/lava) идут по старому пути у них свой single-surface. * Жидкости (water/lava) идут по старому пути у них свой single-surface.
*/ */
addBlock(x, y, z, blockTypeId) { addBlock(x, y, z, blockTypeId, color) {
const ix = Math.round(x); const ix = Math.round(x);
const iy = Math.round(y); const iy = Math.round(y);
const iz = Math.round(z); const iz = Math.round(z);
@ -449,6 +470,9 @@ export class BlockManager {
const typeDef = getBlockType(blockTypeId); const typeDef = getBlockType(blockTypeId);
const isWater = !!typeDef?.isWater; const isWater = !!typeDef?.isWater;
const isLava = !!typeDef?.isLava; const isLava = !!typeDef?.isLava;
// Окрашиваемый блок: цвет инстанса (из аргумента или дефолт типа).
const colorable = !!typeDef?.colorable;
const instColor = colorable ? (color || typeDef.defaultColor || '#cccccc') : null;
// Для жидкостей оставляем старую логику: невидимый куб + единый surface // Для жидкостей оставляем старую логику: невидимый куб + единый surface
if (isWater || isLava) { if (isWater || isLava) {
@ -496,6 +520,9 @@ export class BlockManager {
keysArr[idx] = key; keysArr[idx] = key;
this._cellToInst.set(key, { typeId: blockTypeId, idx }); this._cellToInst.set(key, { typeId: blockTypeId, idx });
// Окрашиваемый блок — пишем цвет инстанса в color buffer.
if (colorable) this._setBlockColorAt(blockTypeId, idx, instColor);
// Логический «meshProxy» — объект, имитирующий API mesh для совместимости. // Логический «meshProxy» — объект, имитирующий API mesh для совместимости.
// НЕ создаёт реального меша. Используется selection / removeBlockByMesh. // НЕ создаёт реального меша. Используется selection / removeBlockByMesh.
const meshProxy = { const meshProxy = {
@ -511,6 +538,7 @@ export class BlockManager {
mass: 1, mass: 1,
folderId: null, folderId: null,
_thinIdx: idx, _thinIdx: idx,
color: instColor, // per-instance цвет окрашиваемого блока
}, },
// Минимальные методы, которые ожидает остальной код // Минимальные методы, которые ожидает остальной код
position: new Vector3(ix, iy + 0.5, iz), position: new Vector3(ix, iy + 0.5, iz),
@ -538,6 +566,18 @@ export class BlockManager {
proto.material = material; proto.material = material;
if (isMulti) this._setupSubmeshes(proto); if (isMulti) this._setupSubmeshes(proto);
// Окрашиваемый блок — включаем per-instance color buffer (vertex colors).
const _bt = getBlockType(blockTypeId);
if (_bt && _bt.colorable) {
proto.useVertexColors = true;
proto.hasVertexAlpha = false;
const buf = new Float32Array(this._STUDS_MAX * 4);
// Дефолт-цвет (белый) для всех слотов — иначе невыставленные = чёрные.
buf.fill(1);
proto.thinInstanceSetBuffer('color', buf, 4, false);
this._colorsByProto.set(blockTypeId, buf);
}
proto.checkCollisions = false; // коллизии через thin-instances не работают штатно; proto.checkCollisions = false; // коллизии через thin-instances не работают штатно;
// PlayerController в Play-режиме читает blocks Map напрямую (см. PhysicsAABB). // PlayerController в Play-режиме читает blocks Map напрямую (см. PhysicsAABB).
proto.isPickable = true; proto.isPickable = true;
@ -571,6 +611,44 @@ export class BlockManager {
/** Подписка для уведомлений о создании prototype-меша (для shadow setup). */ /** Подписка для уведомлений о создании prototype-меша (для shadow setup). */
setOnProtoCreated(cb) { this._onProtoCreated = cb; } setOnProtoCreated(cb) { this._onProtoCreated = cb; }
/**
* Записать цвет инстанса окрашиваемого блока в color buffer (RGBA float).
* idx индекс thin-instance. hex '#rrggbb'. В batch-режиме обновление
* GPU откладывается (флаг dirty), иначе сразу thinInstanceBufferUpdated.
*/
_setBlockColorAt(blockTypeId, idx, hex) {
const buf = this._colorsByProto.get(blockTypeId);
if (!buf) return;
const c = Color3.FromHexString(hex || '#cccccc');
const o = idx * 4;
buf[o] = c.r; buf[o + 1] = c.g; buf[o + 2] = c.b; buf[o + 3] = 1;
const proto = this._protoMeshes.get(blockTypeId);
if (!proto) return;
if (this._batchMode) {
if (!this._colorDirtyProtos) this._colorDirtyProtos = new Set();
this._colorDirtyProtos.add(blockTypeId);
} else {
try { proto.thinInstanceBufferUpdated('color'); } catch (e) { /* ignore */ }
}
}
/**
* Сменить цвет окрашиваемого блока в (x,y,z) на лету (для scene.setColor /
* color-пикера). Возвращает true если блок окрашиваемый и цвет применён.
*/
setBlockColor(x, y, z, hex) {
const key = this._key(Math.round(x), Math.round(y), Math.round(z));
const inst = this._cellToInst.get(key);
if (!inst) return false;
const bt = getBlockType(inst.typeId);
if (!bt || !bt.colorable) return false;
this._setBlockColorAt(inst.typeId, inst.idx, hex);
const mp = this.blocks.get(key);
if (mp && mp.metadata) mp.metadata.color = hex;
this._notifyChange();
return true;
}
/** Установить флаг anchored у блока. */ /** Установить флаг anchored у блока. */
setBlockAnchored(x, y, z, anchored) { setBlockAnchored(x, y, z, anchored) {
const mesh = this.blocks.get(this._key(x, y, z)); const mesh = this.blocks.get(this._key(x, y, z));
@ -767,6 +845,8 @@ export class BlockManager {
canCollide: m.canCollide !== false, canCollide: m.canCollide !== false,
visible: m.visible !== false, visible: m.visible !== false,
mass: m.mass ?? 1, mass: m.mass ?? 1,
// per-instance цвет окрашиваемого блока (studs-block); иначе пропускаем
...(m.color ? { color: m.color } : {}),
}); });
} }
return out; return out;
@ -778,7 +858,7 @@ export class BlockManager {
this._batchMode = true; this._batchMode = true;
try { try {
for (const b of arr) { for (const b of arr) {
const mesh = this.addBlock(b.x, b.y, b.z, b.type); const mesh = this.addBlock(b.x, b.y, b.z, b.type, b.color);
if (!mesh) continue; if (!mesh) continue;
if (b.anchored === false) { if (b.anchored === false) {
mesh.metadata.anchored = false; mesh.metadata.anchored = false;
@ -802,6 +882,14 @@ export class BlockManager {
proto.thinInstanceRefreshBoundingInfo(true); proto.thinInstanceRefreshBoundingInfo(true);
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
} }
// Финальный refresh color-буферов окрашиваемых блоков (batch).
if (this._colorDirtyProtos) {
for (const typeId of this._colorDirtyProtos) {
const proto = this._protoMeshes.get(typeId);
try { proto?.thinInstanceBufferUpdated('color'); } catch (e) { /* ignore */ }
}
this._colorDirtyProtos.clear();
}
} }
clear() { clear() {

View File

@ -105,6 +105,11 @@ export const BLOCK_TYPES = [
// top = stone, side = oven (4 стороны с дверцей — лучше чем раньше), // top = stone, side = oven (4 стороны с дверцей — лучше чем раньше),
// bottom = stone. В будущем для одной двери понадобится 6-face формат. // bottom = stone. В будущем для одной двери понадобится 6-face формат.
multiCube('oven', 'Печь', 'Особые', `${TEX}/stone.png`, `${TEX}/oven.png`, `${TEX}/stone.png`), multiCube('oven', 'Печь', 'Особые', `${TEX}/stone.png`, `${TEX}/oven.png`, `${TEX}/stone.png`),
// === ОКРАШИВАЕМЫЕ (задача 09) — паритет со студией ===
cube('studs-block', 'Лего-кирпич', 'Окрашиваемые',
'/kubikon-assets/materials/studs_v4_diffuse.png',
{ colorable: true, normal: '/kubikon-assets/materials/studs_v4_normal.png', defaultColor: '#3a7aff' }),
]; ];
/** Все доступные категории в порядке появления. */ /** Все доступные категории в порядке появления. */
@ -120,6 +125,7 @@ export const CATEGORY_COLORS = {
'Кирпич': '#9d4a3a', 'Кирпич': '#9d4a3a',
'Особые': '#9966ff', 'Особые': '#9966ff',
'Природа': '#5a8c3e', 'Природа': '#5a8c3e',
'Окрашиваемые': '#3a7aff',
}; };
/** Найти описание блока по id. */ /** Найти описание блока по id. */

View File

@ -43,7 +43,7 @@ function normName(raw) {
return String(raw || '') return String(raw || '')
.toLowerCase() .toLowerCase()
.replace(/mixamorig/g, '') .replace(/mixamorig/g, '')
.replace(/[:_\s.\-]/g, ''); .replace(/[:_\s.-]/g, '');
} }
function resolveLogicalR15(boneName) { function resolveLogicalR15(boneName) {

View File

@ -91,10 +91,14 @@ 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 больше
// НЕ рисует свою жёлтую сферу/луну — иначе на небе два солнца. Здесь
// остаётся только управление светом (направление/яркость/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();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,328 @@
/**
* GraphicsManager система визуальных эффектов («шейдеры») для игр Рублокса.
*
* Управляет:
* - постобработкой экрана через Babylon DefaultRenderingPipeline:
* bloom (свечение), FXAA (сглаживание), виньетка, цветокоррекция
* (контраст/насыщенность/экспозиция), тонмаппинг, глубина резкости (DoF);
* - качеством теней (через scene3d.setShadowQuality);
* - контактными тенями SSAO (через scene3d.setSsaoEnabled).
*
* Управляется И из настроек игры (вкладка «Графика»), И из скриптов
* (game.graphics.*). По умолчанию ВСЁ ВЫКЛЮЧЕНО (preset 'off') старые игры
* не меняются, FPS не страдает. Автор включает осознанно.
*
* Mobile-safe: на слабых устройствах тяжёлые эффекты (DoF, SSAO, ultra-тени,
* HDR-bloom) автоматически урезаются, даже если в пресете включены.
*
* Один и тот же класс используется в студии и плеере (фича-парность).
*
* Использование:
* const gfx = new GraphicsManager(scene, camera, scene3d, { mobile });
* gfx.apply({ preset: 'cinematic' });
* gfx.apply({ bloom: { enabled: true, intensity: 0.7 } });
* gfx.dispose();
*/
import {
DefaultRenderingPipeline, Color4, ImageProcessingConfiguration,
} from '@babylonjs/core';
/**
* Именованные пресеты. Каждый полный набор настроек. 'off' = чистая картинка
* (pipeline не создаётся вовсе). Значения подобраны так, чтобы быть заметными,
* но не «кислотными».
*/
export const GRAPHICS_PRESETS = {
off: {
bloom: { enabled: false },
fxaa: false,
vignette: { enabled: false },
grading: { enabled: false },
dof: { enabled: false },
ssao: false,
shadows: null, // null = не трогаем текущее качество теней
},
// Лёгкий: только мягкое свечение + сглаживание. Дёшево, годится почти везде.
low: {
bloom: { enabled: true, intensity: 0.3, threshold: 0.9 },
fxaa: true,
vignette: { enabled: false },
grading: { enabled: false },
dof: { enabled: false },
ssao: false,
shadows: 'hard',
},
// Средний: свечение + лёгкая виньетка + чуть насыщенности.
medium: {
bloom: { enabled: true, intensity: 0.45, threshold: 0.85 },
fxaa: true,
vignette: { enabled: true, weight: 0.5 },
grading: { enabled: true, contrast: 1.05, saturation: 1.1, exposure: 1.0 },
dof: { enabled: false },
ssao: false,
shadows: 'soft',
},
// Высокий: всё кроме DoF, SSAO включён.
high: {
bloom: { enabled: true, intensity: 0.6, threshold: 0.82 },
fxaa: true,
vignette: { enabled: true, weight: 0.6 },
grading: { enabled: true, contrast: 1.1, saturation: 1.2, exposure: 1.05 },
dof: { enabled: false },
ssao: true,
shadows: 'soft',
},
// Ультра: + глубина резкости + мягкие каскадные тени.
ultra: {
bloom: { enabled: true, intensity: 0.7, threshold: 0.8 },
fxaa: true,
vignette: { enabled: true, weight: 0.65 },
grading: { enabled: true, contrast: 1.12, saturation: 1.25, exposure: 1.05 },
dof: { enabled: true, focusDistance: 18, focalLength: 50, aperture: 1.2 },
ssao: true,
shadows: 'high',
},
// === Стилевые пресеты (художественные) ===
cinematic: {
bloom: { enabled: true, intensity: 0.55, threshold: 0.8 },
fxaa: true,
vignette: { enabled: true, weight: 0.85 },
grading: { enabled: true, contrast: 1.18, saturation: 1.05, exposure: 1.0 },
dof: { enabled: true, focusDistance: 22, focalLength: 60, aperture: 1.0 },
ssao: true,
shadows: 'soft',
},
vivid: {
bloom: { enabled: true, intensity: 0.65, threshold: 0.78 },
fxaa: true,
vignette: { enabled: false },
grading: { enabled: true, contrast: 1.1, saturation: 1.5, exposure: 1.1 },
dof: { enabled: false },
ssao: false,
shadows: 'soft',
},
night: {
bloom: { enabled: true, intensity: 0.8, threshold: 0.7 },
fxaa: true,
vignette: { enabled: true, weight: 1.0 },
grading: { enabled: true, contrast: 1.2, saturation: 0.85, exposure: 0.8 },
dof: { enabled: false },
ssao: true,
shadows: 'soft',
},
retro: {
bloom: { enabled: false },
fxaa: false, // намеренно «пиксельно»
vignette: { enabled: true, weight: 1.2 },
grading: { enabled: true, contrast: 1.3, saturation: 0.7, exposure: 0.95 },
dof: { enabled: false },
ssao: false,
shadows: 'hard',
},
soft: {
bloom: { enabled: true, intensity: 0.4, threshold: 0.88 },
fxaa: true,
vignette: { enabled: true, weight: 0.4 },
grading: { enabled: true, contrast: 0.95, saturation: 1.05, exposure: 1.05 },
dof: { enabled: false },
ssao: false,
shadows: 'soft',
},
};
// Глубокое слияние пресета и пользовательских оверрайдов.
function _mergeConfig(base, over) {
const out = JSON.parse(JSON.stringify(base || {}));
if (!over) return out;
for (const k of Object.keys(over)) {
const v = over[k];
if (v && typeof v === 'object' && !Array.isArray(v)) {
out[k] = { ...(out[k] || {}), ...v };
} else {
out[k] = v;
}
}
return out;
}
export class GraphicsManager {
/**
* @param scene Babylon Scene
* @param camera активная камера (для pipeline)
* @param scene3d ссылка на BabylonScene (для setShadowQuality / setSsaoEnabled / света)
* @param opts { mobile:boolean }
*/
constructor(scene, camera, scene3d, opts = {}) {
this.scene = scene;
this.camera = camera;
this.scene3d = scene3d;
this.mobile = !!opts.mobile;
this._pipeline = null;
// Текущая активная конфигурация (после merge + mobile-clamp).
this.config = _mergeConfig(GRAPHICS_PRESETS.off, null);
this.config.preset = 'off';
this.enabled = false;
}
/** Сменить камеру (например после смены режима камеры) — пересобрать pipeline. */
setCamera(camera) {
if (camera === this.camera) return;
this.camera = camera;
if (this.enabled) this._rebuildPipeline();
}
/**
* Применить настройки графики. Принимает либо {preset}, либо отдельные
* секции (bloom/vignette/grading/dof/ssao/fxaa/shadows), либо и то и другое
* (оверрайды поверх пресета). Сохраняет состояние в this.config.
*/
apply(settings = {}) {
let cfg;
if (settings.preset && GRAPHICS_PRESETS[settings.preset]) {
cfg = _mergeConfig(GRAPHICS_PRESETS[settings.preset], settings);
cfg.preset = settings.preset;
} else {
// частичный апдейт поверх текущего
cfg = _mergeConfig(this.config, settings);
cfg.preset = settings.preset || this.config.preset || 'custom';
}
this.config = this._clampForMobile(cfg);
this._applyConfig();
return this.config;
}
/** Полностью выключить эффекты (как preset 'off'). */
disableAll() {
return this.apply({ preset: 'off' });
}
/** Текущая конфигурация (для serialize). */
serialize() {
// Храним «как просили» (preset + явные оверрайды). Для простоты — весь cfg.
return JSON.parse(JSON.stringify(this.config));
}
// --- внутреннее ---
/** На слабых устройствах гасим самое дорогое, что бы ни просили. */
_clampForMobile(cfg) {
if (!this.mobile) return cfg;
const c = JSON.parse(JSON.stringify(cfg));
if (c.dof) c.dof.enabled = false; // DoF дорогой
c.ssao = false; // SSAO дорогой
if (c.shadows === 'high' || c.shadows === 'medium') c.shadows = 'hard';
// bloom оставляем, но без HDR (решается в _rebuildPipeline)
c._mobileClamped = true;
return c;
}
_applyConfig() {
const c = this.config;
const anyPipelineFx = (c.bloom && c.bloom.enabled) || c.fxaa
|| (c.vignette && c.vignette.enabled) || (c.grading && c.grading.enabled)
|| (c.dof && c.dof.enabled);
// Тени и SSAO — через scene3d (они вне pipeline).
try {
if (c.shadows && this.scene3d?.setShadowQuality) {
this.scene3d.setShadowQuality(c.shadows);
}
} catch (e) { /* ignore */ }
try {
if (this.scene3d?.setSsaoEnabled) this.scene3d.setSsaoEnabled(!!c.ssao);
} catch (e) { /* ignore */ }
if (!anyPipelineFx) {
this.enabled = false;
this._disposePipeline();
return;
}
this.enabled = true;
this._rebuildPipeline();
}
_rebuildPipeline() {
this._disposePipeline();
if (!this.scene || !this.camera) return;
const c = this.config;
const useHdr = (c.bloom && c.bloom.enabled) && !this.mobile;
const p = new DefaultRenderingPipeline('rbx_graphics', useHdr, this.scene, [this.camera]);
// Bloom
p.bloomEnabled = !!(c.bloom && c.bloom.enabled);
if (p.bloomEnabled) {
p.bloomThreshold = c.bloom.threshold ?? 0.85;
p.bloomWeight = c.bloom.intensity ?? 0.5;
p.bloomKernel = this.mobile ? 32 : 64;
p.bloomScale = 0.5;
}
// FXAA
p.fxaaEnabled = !!c.fxaa;
p.samples = this.mobile ? 1 : 4;
// Image processing: виньетка + цветокоррекция + (опц.) тонмаппинг
const ip = p.imageProcessing;
if (ip) {
p.imageProcessingEnabled = true;
ip.toneMappingEnabled = false; // как в GdPostFx — иначе картинка темнеет
// экспозиция/контраст из grading
if (c.grading && c.grading.enabled) {
ip.exposure = c.grading.exposure ?? 1.0;
ip.contrast = c.grading.contrast ?? 1.0;
ip.colorCurvesEnabled = true;
try {
const curves = ip.colorCurves;
if (curves) {
// saturation: 1.0 = норма → curves в диапазоне примерно -100..100
const sat = c.grading.saturation ?? 1.0;
curves.globalSaturation = Math.round((sat - 1.0) * 60);
}
} catch (e) { /* ignore */ }
} else {
ip.exposure = 1.0; ip.contrast = 1.0;
}
// виньетка
if (c.vignette && c.vignette.enabled) {
ip.vignetteEnabled = true;
ip.vignetteWeight = c.vignette.weight ?? 0.6;
ip.vignetteColor = new Color4(0, 0, 0, 0);
ip.vignetteStretch = 0.3;
ip.vignetteCameraFov = 0.5;
ip.vignetteBlendMode = ImageProcessingConfiguration.VIGNETTEMODE_MULTIPLY;
} else {
ip.vignetteEnabled = false;
}
}
// Depth of Field (глубина резкости) — только desktop
if (c.dof && c.dof.enabled && !this.mobile) {
p.depthOfFieldEnabled = true;
try {
p.depthOfFieldBlurLevel = 1; // 0..2
p.depthOfField.focusDistance = (c.dof.focusDistance ?? 18) * 1000; // мм
p.depthOfField.focalLength = c.dof.focalLength ?? 50;
p.depthOfField.fStop = c.dof.aperture ?? 1.2;
} catch (e) { /* ignore */ }
} else {
p.depthOfFieldEnabled = false;
}
this._pipeline = p;
}
_disposePipeline() {
if (this._pipeline) {
try { this._pipeline.dispose(); } catch (e) { /* ignore */ }
this._pipeline = null;
}
}
dispose() {
this._disposePipeline();
this.scene = null;
this.camera = null;
this.scene3d = null;
}
}

View File

@ -99,6 +99,11 @@ export class GuiManager {
w: opts.w ?? _defaultSize(type).w, w: opts.w ?? _defaultSize(type).w,
h: opts.h ?? _defaultSize(type).h, h: opts.h ?? _defaultSize(type).h,
anchor: opts.anchor || 'center', anchor: opts.anchor || 'center',
// Phase 6.3.1: AnchorPoint -- точка ВНУТРИ элемента, относительно
// которой считается позиция x/y. По умолчанию НЕ задан -- рендерер
// вычислит дефолт из anchor (center → {0.5,0.5}, top-left → {0,0}, ...).
// Юзер может override через opts.anchorPoint = {x:0..1, y:0..1}.
anchorPoint: opts.anchorPoint || null,
visible: opts.visible !== false, visible: opts.visible !== false,
bgColor: opts.bgColor ?? _defaultBgColor(type), bgColor: opts.bgColor ?? _defaultBgColor(type),
bgOpacity: opts.bgOpacity ?? _defaultBgOpacity(type), bgOpacity: opts.bgOpacity ?? _defaultBgOpacity(type),
@ -118,17 +123,42 @@ export class GuiManager {
placeholder: opts.placeholder ?? (type === 'textbox' ? 'Введите текст…' : ''), placeholder: opts.placeholder ?? (type === 'textbox' ? 'Введите текст…' : ''),
onClickScriptId: opts.onClickScriptId ?? null, onClickScriptId: opts.onClickScriptId ?? null,
zIndex: opts.zIndex ?? this.elements.length + 1, zIndex: opts.zIndex ?? this.elements.length + 1,
// Авто-раскладка детей (Фаза 5.3, для frame/scroll): // Авто-раскладка детей (Фаза 5.3 + 6.3.2):
// 'none' — дети как есть (по своим x/y); // 'none' -- дети как есть (по своим x/y);
// 'vertical' — дети в столбик; 'horizontal' — в строку. // 'vertical' -- дети в столбик; 'horizontal' -- в строку;
// 'grid' -- сетка (требует layoutCellW/H/Cols).
layout: opts.layout ?? 'none', layout: opts.layout ?? 'none',
// Отступ между детьми и внутреннее поле контейнера (в % размера контейнера). // Отступ между детьми и внутреннее поле контейнера (в % размера контейнера).
layoutGap: opts.layoutGap ?? 2, layoutGap: opts.layoutGap ?? 2,
layoutPad: opts.layoutPad ?? 3, layoutPad: opts.layoutPad ?? 3,
// Phase 6.3.2: параметры grid-layout.
// cellW/H -- размер каждой ячейки в %; cols -- количество колонок (0 = авто).
layoutCellW: opts.layoutCellW ?? 18,
layoutCellH: opts.layoutCellH ?? 18,
layoutCols: opts.layoutCols ?? 0,
// Текущая прокрутка scroll-контейнера (в %, только для type='scroll'). // Текущая прокрутка scroll-контейнера (в %, только для type='scroll').
scrollY: opts.scrollY ?? 0, scrollY: opts.scrollY ?? 0,
// Тень под элементом (Фаза 5.4) — мягкая drop-shadow. // Тень под элементом (Фаза 5.4) — мягкая drop-shadow.
shadow: opts.shadow ?? false, shadow: opts.shadow ?? false,
// === Задача 03: расширения для красивого UI + анимаций ===
// Линейный градиент фона. Формат: { stops: ['#a','#b'] | [{c,p}], angle: 0..360 }.
bgGradient: opts.bgGradient ?? null,
// Обводка текста (для крупных подписей "X2 ДЕНЕГ"). { color, width }.
textStroke: opts.textStroke ?? null,
// Поворот элемента в градусах (transform: rotate).
rotation: opts.rotation ?? 0,
// Scale-множитель (transform: scale). 1 = нормальный размер.
scaleX: opts.scaleX ?? 1,
scaleY: opts.scaleY ?? 1,
// Бейдж-маркер в углу: { corner, icon, color, text }.
badge: opts.badge ?? null,
// Hover-реакция (только для button/image-button): { scale, rotation, brightness, duration, easing }.
hover: opts.hover ?? null,
// Active-реакция (зажатие ЛКМ): { scale, duration }.
active: opts.active ?? null,
// Анимация-пресет: 'none'|'pulse'|'rotate'|'sway'|'glow'|'bounce'|'custom'.
// Раскрывается в реальный tween при applyAnimationPreset(id) в Play.
animationPreset: opts.animationPreset ?? 'none',
// Создан скриптом в Play (game.gui.create) — НЕ сериализуется // Создан скриптом в Play (game.gui.create) — НЕ сериализуется
// в проект, удаляется при Stop. // в проект, удаляется при Stop.
_scriptCreated: opts._scriptCreated === true, _scriptCreated: opts._scriptCreated === true,

370
src/engine/InventoryUI.js Normal file
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

@ -1,80 +1,385 @@
/** /**
* LabelManager billboard-метки (текст-плашки) над 3D-объектами. * LabelManager billboard-плашки (текст-надписи) над 3D-объектами.
* *
* Используется для game.scene.setLabel(ref, text) имена/HP над * game.scene.setLabel(ref, text, opts) имена/HP/таймеры/счётчики над
* персонажами, врагами, предметами. Метка всегда повёрнута лицом к камере * персонажами, врагами, предметами. По умолчанию плашка повёрнута лицом к
* (billboardMode=7), всегда поверх геометрии (renderingGroupId=1). * камере (billboardMode=7), всегда поверх геометрии (renderingGroupId=1).
* *
* Метка привязывается к мешу объекта (parent) и висит над ним. * Задача 10 расширенные стили: фон/обводка/скругление (пресеты gameui/
* warning/reward/boss-hp/plain), обводка текста, richText (<color>/<b>/<size>),
* faceMode billboard|fixed, attachPoint, maxDistance.
*
* Плашка привязывается к мешу объекта (parent) и висит над ним.
*/ */
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture'; import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial'; import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder'; import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
import { Color3 } from '@babylonjs/core/Maths/math.color'; import { Color3 } from '@babylonjs/core/Maths/math.color';
import { Mesh } from '@babylonjs/core/Meshes/mesh';
// === Пресеты стилей плашки (фон/обводка/текст) ===
// gameui — жёлтая обводка + тёмно-синий фон + белый текст (как в Roblox-UI).
export const LABEL_PRESETS = {
plain: {
background: null, borderColor: null, borderWidth: 0, cornerRadius: 0,
color: '#ffffff', textStroke: { color: '#000', width: 8 },
},
gameui: {
background: '#1a3a8a', borderColor: '#f5c020', borderWidth: 10, cornerRadius: 28,
color: '#ffffff', textStroke: { color: '#0a1430', width: 6 },
},
warning: {
background: '#b01e1e', borderColor: '#ffce3a', borderWidth: 10, cornerRadius: 28,
color: '#ffffff', textStroke: { color: '#000', width: 6 },
},
reward: {
background: '#caa018', borderColor: '#fff0a0', borderWidth: 10, cornerRadius: 28,
color: '#fff8e0', textStroke: { color: '#6b4e00', width: 6 },
gradient: ['#f7d34a', '#c98a18'], // золотой градиент фона
},
'boss-hp': {
background: '#3a0a0a', borderColor: '#ff4040', borderWidth: 8, cornerRadius: 20,
color: '#ffd0d0', textStroke: { color: '#000', width: 6 },
gradient: ['#8a1414', '#3a0a0a'],
},
};
export class LabelManager { export class LabelManager {
constructor(scene) { constructor(scene) {
this.scene = scene; this.scene = scene;
// ref-строка объекта → { plane, tex, mat } // ref-строка объекта → { plane, tex, mat, lastKey, opts }
this.labels = new Map(); this.labels = new Map();
this._playerMesh = null; // для maxDistance — задаётся из BabylonScene
} }
/** Дать ссылку на меш игрока (для maxDistance-скрытия). */
setPlayerMesh(mesh) { this._playerMesh = mesh; }
/** /**
* Установить/обновить метку над объектом. * Установить/обновить плашку над объектом.
* ref ref-строка объекта (от scene.spawn / scene.find). * ref ref-строка объекта.
* anchorMesh Babylon-меш объекта (метка крепится к нему). * anchorMesh Babylon-меш объекта (плашка крепится к нему).
* text текст метки. * text текст (может содержать richText-теги если opts.richText).
* opts { color: '#fff', height: 2.5 (м над объектом), size: 1 } * opts см. LABEL_PRESETS + { color, height, size, background,
* borderColor, borderWidth, cornerRadius, padding, textStroke,
* fontWeight, faceMode, rotationY, attachPoint, preset,
* richText, maxDistance }
*/ */
setLabel(ref, anchorMesh, text, opts = {}) { setLabel(ref, anchorMesh, text, opts = {}) {
if (!anchorMesh) return; if (!anchorMesh) return;
const color = opts.color || '#ffffff'; text = String(text == null ? '' : text);
// Пресет → база, поверх — явные opts.
const preset = opts.preset && LABEL_PRESETS[opts.preset] ? LABEL_PRESETS[opts.preset] : null;
const st = { ...(preset || {}), ...opts };
const color = st.color || '#ffffff';
const heightAbove = Number.isFinite(opts.height) ? opts.height : 2.5; const heightAbove = Number.isFinite(opts.height) ? opts.height : 2.5;
const sizeMul = Number.isFinite(opts.size) ? opts.size : 1; const sizeMul = Number.isFinite(opts.size) ? opts.size : 1;
const richText = !!opts.richText;
// Диф-чек: если ничего не поменялось — не пересоздаём (важно для bindLabel).
const styleKey = JSON.stringify({ text, color, preset: opts.preset, bg: st.background,
bc: st.borderColor, bw: st.borderWidth, cr: st.cornerRadius, rich: richText,
fw: st.fontWeight, h: heightAbove, sz: sizeMul, fm: st.faceMode, ry: st.rotationY,
af: st.attachFace, tl: st.tilt, ap: typeof st.attachPoint === 'string' ? st.attachPoint : null });
const existing = this.labels.get(ref);
if (existing && existing.lastKey === styleKey && existing.plane.parent === anchorMesh) {
return; // ничего не изменилось
}
// Меняется только текст (тот же стиль/размер) → перерисуем canvas без
// пересоздания меша (дешевле). Иначе — полное пересоздание.
const sameStruct = existing && existing.styleStruct === this._structKey(st, richText, heightAbove, sizeMul);
if (sameStruct) {
this._drawCanvas(existing.tex, text, color, st, richText);
existing.tex.update(true);
existing.lastKey = styleKey;
existing.lastText = text;
return;
}
// Если метка уже есть — пересоздаём (текст/цвет могли измениться).
this.clearLabel(ref); this.clearLabel(ref);
// Размер текстуры: чем больше текста — тем шире, чтобы не растягивать.
const fontPx = 120;
const W = 1024, H = 256; const W = 1024, H = 256;
const tex = new DynamicTexture(`lblTex_${ref}_${Date.now()}`, const tex = new DynamicTexture(`lblTex_${ref}_${this._uid()}`,
{ width: W, height: H }, this.scene, true); { width: W, height: H }, this.scene, true);
tex.updateSamplingMode?.(3); // TRILINEAR tex.updateSamplingMode?.(3); // TRILINEAR
tex.anisotropicFilteringLevel = 8; tex.anisotropicFilteringLevel = 8;
const ctx = tex.getContext();
ctx.clearRect(0, 0, W, H);
ctx.font = 'bold 120px "Roboto Condensed", "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.lineWidth = 16;
ctx.lineJoin = 'round';
ctx.strokeStyle = '#000';
ctx.strokeText(String(text), W / 2, H / 2);
ctx.fillStyle = color;
ctx.fillText(String(text), W / 2, H / 2);
tex.update(true);
tex.hasAlpha = true; tex.hasAlpha = true;
this._drawCanvas(tex, text, color, st, richText);
tex.update(true);
// Соотношение плашки — 4:1 (1024×256). Крупная и читаемая (как в Roblox).
// ОДНОСТОРОННЯЯ плоскость (FRONTSIDE): лицо = нормаль +Z. Мы всегда
// разворачиваем плашку лицом к наблюдателю снаружи грани, поэтому текст
// читается прямо. DOUBLESIDE НЕ используем — задняя грань зеркалит UV
// (баг: «Ключ от тайника» наоборот). backFaceCulling выключен только
// чтобы плашка не пропадала при взгляде сзади (без отражённого текста).
const plane = MeshBuilder.CreatePlane(`lbl_${ref}`, const plane = MeshBuilder.CreatePlane(`lbl_${ref}`,
{ width: 2.2 * sizeMul, height: 0.55 * sizeMul }, this.scene); { width: 3.4 * sizeMul, height: 0.85 * sizeMul,
sideOrientation: Mesh.FRONTSIDE }, this.scene);
const mat = new StandardMaterial(`lblMat_${ref}`, this.scene); const mat = new StandardMaterial(`lblMat_${ref}`, this.scene);
mat.diffuseTexture = tex; mat.diffuseTexture = tex;
mat.diffuseTexture.hasAlpha = true; mat.diffuseTexture.hasAlpha = true;
mat.emissiveColor = new Color3(1, 1, 1); mat.emissiveColor = new Color3(1, 1, 1);
mat.diffuseColor = new Color3(0, 0, 0);
mat.disableLighting = true; mat.disableLighting = true;
// Геометрия двусторонняя (DOUBLESIDE) с прямыми backUVs — culling можно
// включить, дублей нет; текст читается с обеих сторон без зеркала.
mat.backFaceCulling = false; mat.backFaceCulling = false;
mat.disableDepthWrite = true; mat.disableDepthWrite = true;
mat.useAlphaFromDiffuseTexture = true;
plane.material = mat; plane.material = mat;
plane.billboardMode = 7; // всегда лицом к камере plane.renderingGroupId = 1;
plane.renderingGroupId = 1; // поверх геометрии
plane.isPickable = false; plane.isPickable = false;
// Крепим к объекту: метка висит над ним и двигается вместе с ним.
plane.parent = anchorMesh; plane.parent = anchorMesh;
plane.position.set(0, heightAbove, 0);
this.labels.set(ref, { plane, tex, mat }); // Полуразмеры объекта по каждой оси (для крепления над верхом ИЛИ на
// грань). Берём bounding box в ЛОКАЛЬНЫХ координатах меша (min/max), чтобы
// позиция плашки-ребёнка была верной при любом масштабе/вращении родителя.
let halfX = 0.5, halfY = 0.5, halfZ = 0.5;
try {
const bb = anchorMesh.getBoundingInfo?.().boundingBox;
if (bb && bb.minimum && bb.maximum) {
halfX = (bb.maximum.x - bb.minimum.x) / 2;
halfY = (bb.maximum.y - bb.minimum.y) / 2;
halfZ = (bb.maximum.z - bb.minimum.z) / 2;
} else if (anchorMesh.scaling) {
halfX = Math.abs(anchorMesh.scaling.x) / 2;
halfY = Math.abs(anchorMesh.scaling.y) / 2;
halfZ = Math.abs(anchorMesh.scaling.z) / 2;
}
} catch (e) { /* ignore */ }
const halfH = halfY;
const halfPlane = 0.45 * sizeMul; // полувысота самой плашки (3.4×0.85)
// attachFace — ПРИКРЕПИТЬ плашку НА ГРАНЬ объекта (как табличка на
// стене/полу): плашка лежит В ПЛОСКОСТИ грани, фиксированной ориентации,
// и перемещается/вращается ВМЕСТЕ с объектом (она его ребёнок). Это
// Roblox-style «надпись = часть постройки» (в отличие от billboard над
// верхом). Значения: 'top'|'bottom'|'front'|'back'|'left'|'right'
// (или сырые '+y'/'-y'/'+z'/'-z'/'+x'/'-x').
const FACE = { top: '+y', bottom: '-y', front: '+z', back: '-z',
right: '+x', left: '-x' };
let face = st.attachFace;
if (face && FACE[face]) face = FACE[face];
if (face) {
// На грань — всегда фиксированная ориентация (не billboard), иначе
// «связки с примитивом» не будет (плашка крутилась бы к камере).
plane.billboardMode = 0;
const gap = Number.isFinite(opts.height) ? opts.height : 0.05;
// ВАЖНО: Babylon CreatePlane (FRONTSIDE) лицевой стороной (где UV/текст
// не зеркалятся) смотрит в Z. Поэтому чтобы ЛИЦО таблички смотрело
// НАРУЖУ выбранной грани, поворачиваем плоскость так, чтобы её Z
// совпал с внешней нормалью грани. tiltSign — знак наклона tilt с
// учётом того, что для грани +z плоскость развёрнута на π.
let tiltSign = 1;
if (face === '+z') { plane.position.set(0, 0, halfZ + gap); plane.rotation.set(0, Math.PI, 0); tiltSign = -1; }
else if (face === '-z') { plane.position.set(0, 0, -(halfZ + gap)); plane.rotation.set(0, 0, 0); }
else if (face === '+x') { plane.position.set( halfX + gap, 0, 0); plane.rotation.set(0, -Math.PI / 2, 0); }
else if (face === '-x') { plane.position.set(-(halfX + gap), 0, 0); plane.rotation.set(0, Math.PI / 2, 0); }
else if (face === '+y') { plane.position.set(0, halfY + gap, 0); plane.rotation.set( Math.PI / 2, 0, 0); }
else if (face === '-y') { plane.position.set(0, -(halfY + gap), 0); plane.rotation.set(-Math.PI / 2, 0, 0); }
if (Number.isFinite(st.rotationY)) plane.rotation.y += st.rotationY;
// tilt — наклон таблички вокруг локальной X (ценник под ~45°, как на
// витрине Roblox). Знак нормализуем (tiltSign), чтобы «верх назад» был
// одинаковым для всех граней. Отрицательный tilt = верх отклоняется
// назад (от наблюдателя), как пюпитр.
if (Number.isFinite(st.tilt)) plane.rotation.x += st.tilt * tiltSign;
} else {
// faceMode: 'fixed' — фиксированная ориентация (вращается с объектом),
// но позиционируется как обычная плашка (над верхом/центром/низом).
if (st.faceMode === 'fixed') {
plane.billboardMode = 0;
if (Number.isFinite(st.rotationY)) plane.rotation.y = st.rotationY;
} else {
plane.billboardMode = 7; // всегда лицом к камере
}
// attachPoint: 'top'(default) — над верхом + небольшой зазор (height);
// 'center' — по центру; 'bottom' — у низа; {x,y,z} — явно.
const gap = Number.isFinite(opts.height) ? opts.height : 0.6;
let py = halfH + gap + halfPlane; // верх объекта + зазор + полувысота плашки
if (st.attachPoint === 'center') py = 0;
else if (st.attachPoint === 'bottom') py = -(halfH + gap);
else if (st.attachPoint && typeof st.attachPoint === 'object') {
plane.position.set(st.attachPoint.x || 0, st.attachPoint.y || 0, st.attachPoint.z || 0);
py = null;
}
if (py !== null) plane.position.set(0, py, 0);
}
this.labels.set(ref, {
plane, tex, mat,
lastKey: styleKey,
lastText: text,
styleStruct: this._structKey(st, richText, heightAbove, sizeMul),
maxDistance: Number.isFinite(opts.maxDistance) ? opts.maxDistance : null,
});
} }
/** Убрать метку с объекта. */ /** Ключ «структуры» (всё кроме текста) — для решения перерисовать ли canvas. */
_structKey(st, richText, h, sz) {
return JSON.stringify({ p: st.preset, bg: st.background, bc: st.borderColor,
bw: st.borderWidth, cr: st.cornerRadius, rich: richText, fw: st.fontWeight,
grad: st.gradient, ts: st.textStroke, h, sz, fm: st.faceMode,
af: st.attachFace, tl: st.tilt, ap: typeof st.attachPoint === 'string' ? st.attachPoint : null });
}
_uid() { this._seq = (this._seq || 0) + 1; return this._seq; }
/**
* Нарисовать плашку на canvas DynamicTexture.
* Фон (roundRect + gradient/fill) обводка border текст (с обводкой).
*/
_drawCanvas(tex, text, color, st, richText) {
const W = 1024, H = 256;
const ctx = tex.getContext();
ctx.clearRect(0, 0, W, H);
const hasBg = !!st.background || (Array.isArray(st.gradient) && st.gradient.length === 2);
const pad = Number.isFinite(st.padding) ? st.padding : 28;
const cr = Number.isFinite(st.cornerRadius) ? st.cornerRadius : 0;
const bw = Number.isFinite(st.borderWidth) ? st.borderWidth : 0;
const weight = st.fontWeight || 700;
const innerPad = (hasBg ? (bw + pad) : 24); // отступ от краёв (под рамку)
const maxTextW = W - innerPad * 2;
// Auto-fit: подбираем размер шрифта, чтобы текст влез по ширине (не обрезался).
let fontPx = 120;
if (!richText) {
ctx.font = `${weight} ${fontPx}px "Roboto Condensed", "Segoe UI", sans-serif`;
const tw = ctx.measureText(text).width;
if (tw > maxTextW) fontPx = Math.max(40, Math.floor(fontPx * maxTextW / tw));
}
ctx.font = `${weight} ${fontPx}px "Roboto Condensed", "Segoe UI", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// === Фон-плашка ===
if (hasBg) {
const m = bw / 2 + 4; // отступ рамки от края текстуры
const x = m, y = m, w = W - m * 2, h = H - m * 2;
this._roundRectPath(ctx, x, y, w, h, cr);
if (Array.isArray(st.gradient) && st.gradient.length === 2) {
const g = ctx.createLinearGradient(0, y, 0, y + h);
g.addColorStop(0, st.gradient[0]);
g.addColorStop(1, st.gradient[1]);
ctx.fillStyle = g;
} else {
ctx.fillStyle = st.background;
}
ctx.globalAlpha = Number.isFinite(st.backgroundOpacity) ? st.backgroundOpacity : 0.92;
ctx.fill();
ctx.globalAlpha = 1;
if (bw > 0 && st.borderColor) {
ctx.lineWidth = bw;
ctx.strokeStyle = st.borderColor;
ctx.stroke();
}
}
// === Текст ===
const ts = st.textStroke || { color: '#000', width: hasBg ? 6 : 10 };
if (richText) {
this._drawRichText(ctx, text, color, ts, W, H);
} else {
if (ts && ts.width > 0) {
ctx.lineWidth = ts.width;
ctx.lineJoin = 'round';
ctx.strokeStyle = ts.color || '#000';
ctx.strokeText(text, W / 2, H / 2 + 4);
}
ctx.fillStyle = color;
ctx.fillText(text, W / 2, H / 2 + 4);
}
}
/** Путь скруглённого прямоугольника (roundRect не везде есть). */
_roundRectPath(ctx, x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
/**
* RichText: парсим теги <color=#hex>...</color>, <b>...</b>, <size=N>...</size>.
* Рисуем сегментами по горизонтали, центрируя всю строку. Вложенность не
* поддерживается (на MVP) берём последний открытый тег каждого типа.
*/
_drawRichText(ctx, text, baseColor, ts, W, H) {
const segs = this._parseRich(text, baseColor);
const fontPx = 120;
// Замер ширины каждого сегмента в его размере.
let total = 0;
for (const s of segs) {
ctx.font = `${s.bold ? 800 : 700} ${Math.round(fontPx * s.sizeMul)}px "Roboto Condensed","Segoe UI",sans-serif`;
s.w = ctx.measureText(s.text).width;
total += s.w;
}
let x = (W - total) / 2;
for (const s of segs) {
ctx.font = `${s.bold ? 800 : 700} ${Math.round(fontPx * s.sizeMul)}px "Roboto Condensed","Segoe UI",sans-serif`;
ctx.textAlign = 'left';
if (ts && ts.width > 0) {
ctx.lineWidth = ts.width;
ctx.lineJoin = 'round';
ctx.strokeStyle = ts.color || '#000';
ctx.strokeText(s.text, x, H / 2 + 4);
}
ctx.fillStyle = s.color;
ctx.fillText(s.text, x, H / 2 + 4);
x += s.w;
}
ctx.textAlign = 'center';
}
/** Простой парсер richText → [{text, color, bold, sizeMul}]. */
_parseRich(text, baseColor) {
const segs = [];
let color = baseColor, bold = false, sizeMul = 1;
// Разбиваем по тегам (открывающим/закрывающим).
const re = /<(\/?)(?:(color)=([#0-9a-fA-F]+)|(b)|(i)|(size)=(\d+))>|([^<]+)/g;
let m;
while ((m = re.exec(text)) !== null) {
const closing = m[1] === '/';
if (m[8] != null) {
// текстовый кусок
if (m[8]) segs.push({ text: m[8], color, bold, sizeMul });
} else if (m[2]) { // <color=...>
color = closing ? baseColor : m[3];
} else if (m[4]) { // <b>
bold = !closing;
} else if (m[6]) { // <size=N>
sizeMul = closing ? 1 : Math.max(0.4, Math.min(2, Number(m[7]) / 100));
}
// <i> игнорим визуально (italic в canvas через font-style — опускаем на MVP)
}
if (segs.length === 0) segs.push({ text: '', color: baseColor, bold: false, sizeMul: 1 });
return segs;
}
/** Каждый кадр: maxDistance-скрытие плашек дальше порога от игрока. */
update() {
if (!this._playerMesh) return;
const pp = this._playerMesh.position;
for (const rec of this.labels.values()) {
if (rec.maxDistance == null) continue;
const ap = rec.plane.getAbsolutePosition();
const dx = ap.x - pp.x, dy = ap.y - pp.y, dz = ap.z - pp.z;
const far = (dx * dx + dy * dy + dz * dz) > rec.maxDistance * rec.maxDistance;
rec.plane.setEnabled(!far);
}
}
/** Убрать плашку с объекта. */
clearLabel(ref) { clearLabel(ref) {
const rec = this.labels.get(ref); const rec = this.labels.get(ref);
if (!rec) return; if (!rec) return;
@ -84,7 +389,7 @@ export class LabelManager {
this.labels.delete(ref); this.labels.delete(ref);
} }
/** Удалить все метки (при выходе из Play). */ /** Удалить все плашки (при выходе из Play). */
clearAll() { clearAll() {
for (const ref of [...this.labels.keys()]) this.clearLabel(ref); for (const ref of [...this.labels.keys()]) this.clearLabel(ref);
} }

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

@ -0,0 +1,557 @@
/**
* LoadingScreenOverlay внутриигровой экран загрузки (задача 12).
*
* Программный mid-game transition: чёрный фон (fadeIn/Out), картинка-превью
* (cover) по центру, прогресс-бар (жёлтый по серому) + процент, спиннер
* «ЗАГРУЗКА» справа-снизу (CSS keyframes), кнопка «ПРОПУСТИТЬ» по центру-снизу
* (появляется через 0.5с анти-accidental), логотип игры слева-снизу.
*
* Вызывается из скрипта через game.loading.show(opts) / game.loading.transition(opts).
* Покрывает и кейс задачи 05 (начальный экран при входе).
*
* Реализация лёгкий DOM-оверлей поверх canvas (как ShopInventoryUi), а не
* Babylon-GUI: фиксированный layout с прогресс-баром/спиннером/кнопкой на HTML
* делается быстрее и доступнее. Класс самодостаточен: хранит state, рисует DOM,
* имеет tick(dt) для fade-фаз и авто-duration (в отличие от ShopInventoryUi,
* которому tick не нужен).
*
* Один активный экран одновременно: повторный show() мгновенно закрывает
* предыдущий (как ModalManager) нет утечки overlay'ев при нескольких
* transition подряд.
*
* Фича-парность: идентичный модуль в rublox-player/src/engine/.
*/
const EASE_OUT = (t) => 1 - Math.pow(1 - t, 3);
// CSS спиннера вставляем один раз в <head> (keyframes нельзя инлайнить в style).
let _spinCssInjected = false;
function injectSpinnerCss() {
if (_spinCssInjected) return;
_spinCssInjected = true;
try {
const style = document.createElement('style');
style.id = 'kbn-loading-spin-css';
style.textContent =
'@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
'.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' +
// 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);
} catch { /* ignore */ }
}
export class LoadingScreenOverlay {
constructor(scene3d) {
this.s = scene3d;
this.root = null;
this._st = null; // state активного экрана или null
this._idSeq = 0;
// Мост наружу (GameRuntime подписывает) — id-based колбэки.
this._onSkipCb = null; // (id) => void
this._onCompleteCb = null; // (id) => void
this._onHideCb = null; // () => void — задача 05 (game.loading.onHide)
this._parallaxHandler = null;
// DOM-ссылки активного экрана:
this._els = null;
}
/** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */
setBridge(onSkip, onComplete, onHide) {
this._onSkipCb = onSkip;
this._onCompleteCb = onComplete;
if (onHide) this._onHideCb = onHide;
}
/** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */
_cfg() {
return (this.s && this.s._loadingConfig) || {};
}
/**
* Показать экран загрузки. Возвращает числовой id (для матчинга команд).
* opts см. 12_ingame_loading.md §2.2.
*/
show(opts) {
injectSpinnerCss();
opts = opts && typeof opts === 'object' ? opts : {};
// Один активный — мгновенно убрать предыдущий.
if (this._st) this._instantClose();
const cfg = this._cfg();
const accent = opts.progressColor || cfg.accentColor || '#ffc020';
const st = {
id: ++this._idSeq,
// Фон
bgColor: opts.bgColor || '#000',
bgOpacity: opts.bgOpacity != null ? Number(opts.bgOpacity) : 1,
fadeIn: opts.fadeIn != null ? Number(opts.fadeIn) : 0.3,
fadeOut: opts.fadeOut != null ? Number(opts.fadeOut) : 0.3,
// Прогресс
progressBar: opts.progressBar !== false,
progressColor: accent,
progressBgColor: opts.progressBgColor || '#444',
percentText: opts.percentText !== false,
progress: Math.max(0, Math.min(1, Number(opts.initialProgress) || 0)),
duration: Number.isFinite(opts.duration) && opts.duration > 0 ? Number(opts.duration) : null,
manualProgress: false,
// Спиннер
spinner: opts.spinner != null ? !!opts.spinner : (cfg.defaultSpinner !== false),
spinnerText: opts.spinnerText != null ? String(opts.spinnerText) : 'ЗАГРУЗКА',
// Кнопка Пропустить
skipButton: opts.skipButton != null ? !!opts.skipButton : !!cfg.defaultSkipButton,
skipButtonText: opts.skipButtonText != null ? String(opts.skipButtonText) : 'ПРОПУСТИТЬ',
skipButtonColor: opts.skipButtonColor || accent,
skipShown: false,
// Логотип
logo: opts.logo || cfg.logo || (this.s && this.s._projectThumbnail) || null,
logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12,
// Текст под картинкой
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,
pauseSimulation: opts.pauseSimulation !== false,
// Жизненный цикл
phase: 'in', // 'in' | 'visible' | 'out'
alpha: 0,
elapsed: 0, // время с момента полного появления (для duration/skip)
fadeT: 0,
completed: false, // onComplete уже вызывался
};
this._st = st;
this._build(st, opts.cover);
// Блок ввода + пауза симуляции.
if (st.blockInput) { try { this.s.player?.setInputBlocked?.(true); } catch { /* ignore */ } }
if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = true; } catch { /* ignore */ } }
return st.id;
}
/** Резолв cover в URL/dataURL. */
_resolveCover(cover) {
if (!cover) return null;
if (typeof cover === 'string') {
// asset:xxx → пробуем через AssetManager, иначе как прямой URL.
try {
const r = this.s.assetManager?.resolveUrl?.(cover);
if (r) return r;
} catch { /* ignore */ }
return cover;
}
if (typeof cover === 'object') {
if (cover.sceneSnapshot) {
try {
const canvas = this.s.engine?.getRenderingCanvas?.();
if (canvas) return canvas.toDataURL('image/jpeg', 0.72);
} catch { /* ignore */ }
return null;
}
if (cover.url) return cover.url;
}
return null;
}
_build(st, cover) {
const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body;
try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ }
const root = document.createElement('div');
root.className = 'kbn-loading';
root.style.cssText =
'position:absolute;inset:0;z-index:60;overflow:hidden;' +
'display:flex;align-items:center;justify-content:center;' +
'opacity:0;transition:none;font-family:system-ui,"Segoe UI",sans-serif;' +
`background:${st.bgColor};`;
// фон с настраиваемой непрозрачностью — отдельный слой, чтобы контент был непрозрачным
// (используем opacity всего root для fade, а bgOpacity — через rgba фон):
root.style.background = this._bgRgba(st.bgColor, st.bgOpacity);
// --- Фоновый слой (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);
// Режим карточки места (задача 05): квадрат + название + автор под ней.
const hasPlaceCard = !!(st.placeName || st.studioName);
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 =
'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;' +
'background-color:#1a1f2b;margin-bottom:140px;';
}
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');
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 =
'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);';
}
textEl.textContent = st.text || '';
// --- Прогресс-бар ---
const barWrap = document.createElement('div');
barWrap.style.cssText =
'position:absolute;left:50%;transform:translateX(-50%);bottom:120px;' +
`width:min(74vw,1180px);height:14px;border-radius:8px;background:${st.progressBgColor};` +
'overflow:hidden;box-shadow:inset 0 1px 3px rgba(0,0,0,0.5);' +
(st.progressBar ? '' : 'display:none;');
const bar = document.createElement('div');
bar.style.cssText =
`height:100%;width:${(st.progress * 100).toFixed(1)}%;border-radius:8px;` +
`background:linear-gradient(90deg,${st.progressColor},${this._lighten(st.progressColor)});` +
'transition:width 0.12s linear;box-shadow:0 0 8px rgba(255,200,40,0.4);';
barWrap.appendChild(bar);
// --- Процент ---
const percent = document.createElement('div');
percent.style.cssText =
'position:absolute;left:50%;transform:translateX(-50%);bottom:74px;' +
`color:${st.progressColor};font-size:30px;font-weight:800;text-shadow:0 2px 4px rgba(0,0,0,0.5);` +
(st.percentText ? '' : 'display:none;');
percent.textContent = `${Math.round(st.progress * 100)}%`;
// --- Кнопка Пропустить ---
const skipBtn = document.createElement('button');
skipBtn.type = 'button';
skipBtn.textContent = st.skipButtonText;
skipBtn.style.cssText =
'position:absolute;left:50%;transform:translateX(-50%);bottom:18px;' +
'min-width:260px;padding:14px 36px;border:none;border-radius:12px;cursor:pointer;' +
`background:linear-gradient(180deg,${this._lighten(st.skipButtonColor)},${st.skipButtonColor});` +
'color:#3a2a00;font-size:18px;font-weight:800;letter-spacing:0.5px;' +
'box-shadow:0 6px 16px rgba(0,0,0,0.4),inset 0 1px 0 rgba(255,255,255,0.4);' +
'opacity:0;transition:opacity 0.25s,transform 0.1s;pointer-events:none;' +
(st.skipButton ? '' : 'display:none;');
skipBtn.onmouseenter = () => { skipBtn.style.transform = 'translateX(-50%) translateY(-2px)'; };
skipBtn.onmouseleave = () => { skipBtn.style.transform = 'translateX(-50%)'; };
skipBtn.onclick = () => {
if (skipBtn.style.pointerEvents === 'none') return;
this._fireSkip();
};
// --- Логотип (слева снизу) ---
const logo = document.createElement('div');
logo.style.cssText =
'position:absolute;left:28px;bottom:24px;max-width:200px;max-height:110px;' +
`border-radius:${st.logoCornerRadius}px;background-size:contain;background-repeat:no-repeat;` +
'background-position:left bottom;width:200px;height:90px;';
if (st.logo) logo.style.backgroundImage = `url("${st.logo}")`;
else logo.style.display = 'none';
// --- Спиннер + «ЗАГРУЗКА» (справа снизу) ---
const spinWrap = document.createElement('div');
spinWrap.style.cssText =
'position:absolute;right:32px;bottom:32px;display:flex;align-items:center;gap:14px;' +
'color:#fff;font-size:20px;font-weight:700;letter-spacing:1px;' +
(st.spinner ? '' : 'display:none;');
const spinTxt = document.createElement('span');
spinTxt.textContent = st.spinnerText;
const spinCircle = document.createElement('span');
spinCircle.className = 'kbn-ls-spinner';
spinCircle.style.cssText =
`display:inline-block;width:28px;height:28px;border:3px solid rgba(255,255,255,0.25);` +
`border-top-color:${st.progressColor};border-radius:50%;`;
spinWrap.appendChild(spinTxt);
spinWrap.appendChild(spinCircle);
// Центральная композиция (карточка + название + автор + текст) — в content.
content.appendChild(coverImg);
content.appendChild(placeEl);
content.appendChild(studioRow);
content.appendChild(textEl);
root.appendChild(content);
// Нижние/угловые элементы — абсолютно на root (поверх фона, рядом с content).
root.appendChild(barWrap);
root.appendChild(percent);
root.appendChild(skipBtn);
root.appendChild(logo);
root.appendChild(spinWrap);
parent.appendChild(root);
this.root = root;
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(dt) {
const st = this._st;
if (!st || !this._els) return;
dt = Number(dt) || 0;
if (st.phase === 'in') {
st.fadeT += dt;
const d = st.fadeIn > 0 ? st.fadeIn : 0.0001;
st.alpha = Math.min(1, EASE_OUT(st.fadeT / d));
this._els.root.style.opacity = String(st.alpha);
if (st.fadeT >= d) { st.phase = 'visible'; st.alpha = 1; st.fadeT = 0; }
} else if (st.phase === 'visible') {
st.elapsed += dt;
// Кнопка Пропустить — появляется через 0.5с.
if (!st.skipShown && st.skipButton && st.elapsed >= 0.5) {
st.skipShown = true;
this._els.skipBtn.style.opacity = '1';
this._els.skipBtn.style.pointerEvents = 'auto';
}
// Авто-duration (если не было ручного setProgress).
if (st.duration && !st.manualProgress) {
st.progress = Math.min(1, st.elapsed / st.duration);
this._applyProgress(st);
if (st.progress >= 1 && !st.completed) {
st.completed = true;
this._fireComplete();
this.close();
}
}
} else if (st.phase === 'out') {
st.fadeT += dt;
const d = st.fadeOut > 0 ? st.fadeOut : 0.0001;
st.alpha = Math.max(0, 1 - EASE_OUT(st.fadeT / d));
this._els.root.style.opacity = String(st.alpha);
if (st.fadeT >= d) this._teardown();
}
}
_applyProgress(st) {
if (!this._els) return;
this._els.bar.style.width = `${(st.progress * 100).toFixed(1)}%`;
this._els.percent.textContent = `${Math.round(st.progress * 100)}%`;
}
setProgress(value) {
const st = this._st;
if (!st) return;
st.manualProgress = true;
st.progress = Math.max(0, Math.min(1, Number(value) || 0));
this._applyProgress(st);
if (st.progress >= 1 && !st.completed) {
st.completed = true;
this._fireComplete();
this.close();
}
}
setText(text) {
const st = this._st;
if (!st || !this._els) return;
st.text = String(text == null ? '' : text);
this._els.textEl.textContent = st.text;
}
setCover(cover) {
if (!this._st || !this._els) return;
const url = this._resolveCover(cover);
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). */
close() {
const st = this._st;
if (!st) return;
if (st.phase !== 'out') { st.phase = 'out'; st.fadeT = 0; }
}
_fireSkip() {
const st = this._st;
if (!st) return;
if (this._onSkipCb) { try { this._onSkipCb(st.id); } catch { /* ignore */ } }
this.close();
}
_fireComplete() {
const st = this._st;
if (!st) return;
if (this._onCompleteCb) { try { this._onCompleteCb(st.id); } catch { /* ignore */ } }
}
/** Мгновенно убрать без fade (повторный show / выход из Play). */
_instantClose() {
this._teardown();
}
_teardown() {
// Снять блок ввода / паузу.
const st = this._st;
if (st) {
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 */ } }
}
// Снять 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 */ } }
this.root = null;
this._els = null;
this._st = null;
}
dispose() {
this._instantClose();
this._onSkipCb = null;
this._onCompleteCb = null;
}
// --- утилиты цвета ---
_lighten(hex) {
try {
const h = String(hex).replace('#', '');
if (h.length !== 6) return hex;
const r = Math.min(255, parseInt(h.slice(0, 2), 16) + 40);
const g = Math.min(255, parseInt(h.slice(2, 4), 16) + 40);
const b = Math.min(255, parseInt(h.slice(4, 6), 16) + 40);
return `rgb(${r},${g},${b})`;
} catch { return hex; }
}
_bgRgba(hex, opacity) {
try {
const h = String(hex).replace('#', '');
if (h.length !== 6) return hex;
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
const a = opacity != null ? Math.max(0, Math.min(1, opacity)) : 1;
return `rgba(${r},${g},${b},${a})`;
} catch { return hex; }
}
}

398
src/engine/ModalManager.js Normal file
View File

@ -0,0 +1,398 @@
/**
* ModalManager модальные сцены (затемнение + GUI поверх + блок ввода).
*
* Задача 04 из «1 - Неделя 4/ЗАДАЧИ РУБЛОКС/04_modal_cutscene.md».
*
* Типовой кейс: boss-fight intro / открытие лутбокса / диалог с NPC / получил
* питомца. Скрипт зовёт `game.modal.open(opts)` весь 3D-мир затемняется
* (но HUD остаётся), управление блокируется, поверх показывается контент.
*
* Координирует:
* - DOM overlay (рендерится в KubikonEditor/KubikonPlayer)
* - PlayerController.setInputBlocked / setCameraFrozen
* - HighlightLayer Babylon (spotlight-объекты светятся)
* - GameRuntime.paused (опционально, через pauseSimulation)
* - AudioManager.duck (опционально, через muteWorld)
* - GuiManager (временные элементы создаются/удаляются с модалом)
*
* Не зависит от React просто состояние и колбэки.
*
* Архитектура:
* _state = {
* id, opts,
* fadePhase: 'in'|'visible'|'out'|'closed',
* fadeStart: ms, fadeFrom: 0..1, fadeTo: 0..1,
* currentAlpha: 0..1,
* tempGuiIds: [...], id-шники созданных временных GUI-элементов
* spotlightScreens: [{x,y,r}], позиции spotlight'ов в экранных координатах
* }
*
* Активен только ОДИН модал одновременно (Roblox-style). Повторный open
* автоматически закрывает предыдущий (через close+open).
*/
let _seq = 1;
export class ModalManager {
constructor() {
/** @type {object|null} текущий модал, null если закрыт */
this._state = null;
/** @type {Function|null} вызывается когда меняется state — UI пере-рендерится */
this._onChange = null;
/** Babylon scene нужна для HighlightLayer и Vector3.Project */
this._scene = null;
/** PlayerController для блока ввода/freeze камеры */
this._player = null;
/** GuiManager для temp-элементов */
this._gui = null;
/** GameRuntime для pauseSimulation */
this._runtime = null;
/** AudioManager для muteWorld */
this._audio = null;
/** HighlightLayer Babylon — создаётся лениво при первом spotlight */
this._highlight = null;
/** Колбэки onClose — массив функций (modalId) => void */
this._closeCallbacks = [];
/** Прежний WASD-state и FOV — для восстановления */
this._savedCameraState = null;
}
setOnChange(cb) { this._onChange = cb; }
_notify() { if (this._onChange) try { this._onChange(this._state); } catch (e) {} }
attachScene(scene) { this._scene = scene; }
attachPlayer(player) { this._player = player; }
attachGui(gui) { this._gui = gui; }
attachRuntime(runtime) { this._runtime = runtime; }
attachAudio(audio) { this._audio = audio; }
/** Открыт ли сейчас модал. */
isOpen() {
return !!this._state && this._state.fadePhase !== 'closed';
}
/** Получить текущий state (для UI-overlay). */
getState() { return this._state; }
/**
* Открыть модал. opts см. doc по задаче 04.
* Возвращает modalId (число).
*/
open(opts) {
opts = opts || {};
console.log('[ModalManager] open called, opts:', opts);
// Если уже открыт — мгновенно закрываем (без fadeOut, чтобы не плодить
// одновременных модалов).
if (this.isOpen()) this._instantClose();
const id = ++_seq;
const norm = {
darken: Number.isFinite(opts.darken) ? Math.max(0, Math.min(1, opts.darken)) : 0.5,
darkenColor: typeof opts.darkenColor === 'string' ? opts.darkenColor : '#000000',
target: opts.target === 'screen' ? 'screen' : 'scene',
blockInput: opts.blockInput !== false, // по умолчанию true
freezeCamera: !!opts.freezeCamera,
cameraOverride: opts.cameraOverride || null,
fadeIn: Number.isFinite(opts.fadeIn) ? Math.max(0, opts.fadeIn) : 0.3,
fadeOut: Number.isFinite(opts.fadeOut) ? Math.max(0, opts.fadeOut) : 0.3,
spotlights: Array.isArray(opts.spotlights) ? opts.spotlights.slice() : [],
spotlightRadius: Number.isFinite(opts.spotlightRadius) ? opts.spotlightRadius : 120,
spotlightSoftEdge: Number.isFinite(opts.spotlightSoftEdge) ? opts.spotlightSoftEdge : 40,
pauseSimulation: !!opts.pauseSimulation,
muteWorld: !!opts.muteWorld,
content: opts.content || null,
};
this._state = {
id,
opts: norm,
fadePhase: 'in',
fadeStart: this._now(),
fadeFrom: 0,
fadeTo: norm.darken,
currentAlpha: 0,
tempGuiIds: [],
spotlightScreens: [],
};
// 1) Block input
if (norm.blockInput) {
try { this._player?.setInputBlocked?.(true); } catch (e) {}
}
// 2) Freeze camera (сохраняем текущее состояние для восстановления)
if (norm.freezeCamera) {
try {
this._savedCameraState = this._player?.captureCameraState?.() || null;
this._player?.setCameraFrozen?.(true);
} catch (e) {}
}
// 3) Camera override — переключение на focusOn
if (norm.cameraOverride && this._scene) {
this._applyCameraOverride(norm.cameraOverride);
}
// 4) Pause simulation
if (norm.pauseSimulation && this._runtime) {
try { this._runtime.paused = true; } catch (e) {}
}
// 5) Mute world audio
if (norm.muteWorld && this._audio) {
try { this._audio.duck?.(0.3); } catch (e) {}
}
// 6) Highlight spotlight-объектов в Babylon
if (norm.spotlights.length && norm.target === 'scene' && this._scene) {
this._applyHighlight(norm.spotlights);
}
// 7) content.elements — создать временные GUI-элементы
if (norm.content?.elements && this._gui) {
this._createTempGui(norm.content.elements);
}
this._notify();
return id;
}
/** Закрыть модал. Если modalId передан и не совпадает — игнор. */
close(modalId) {
if (!this._state) return;
if (modalId != null && this._state.id !== modalId) return;
if (this._state.fadePhase === 'out' || this._state.fadePhase === 'closed') return;
this._state.fadePhase = 'out';
this._state.fadeStart = this._now();
this._state.fadeFrom = this._state.currentAlpha;
this._state.fadeTo = 0;
this._notify();
}
/** Поменять параметры на лету. */
update(modalId, patch) {
if (!this._state) return;
if (modalId != null && this._state.id !== modalId) return;
if (!patch || typeof patch !== 'object') return;
Object.assign(this._state.opts, patch);
// Если поменяли darken — плавно tween-им currentAlpha к новому значению
if (Number.isFinite(patch.darken) && this._state.fadePhase !== 'out') {
this._state.fadeFrom = this._state.currentAlpha;
this._state.fadeTo = patch.darken;
this._state.fadeStart = this._now();
this._state.fadePhase = 'in';
}
this._notify();
}
/** Подписаться на закрытие. fn получает modalId. */
onClose(fn) {
if (typeof fn === 'function') this._closeCallbacks.push(fn);
}
/** Обновление за кадр — двигает fade-phase и spotlight-screens. dt в секундах. */
tick(dt) {
if (!this._state) return;
const st = this._state;
if (!this._tickLogged) {
this._tickLogged = true;
console.log('[ModalManager] first tick, phase:', st.fadePhase, 'alpha:', st.currentAlpha);
}
// 1) Fade-tween
if (st.fadePhase === 'in' || st.fadePhase === 'out') {
const dur = st.fadePhase === 'in' ? st.opts.fadeIn : st.opts.fadeOut;
const elapsed = (this._now() - st.fadeStart) / 1000;
const t = dur > 0 ? Math.min(1, elapsed / dur) : 1;
// ease-out cubic
const k = 1 - Math.pow(1 - t, 3);
st.currentAlpha = st.fadeFrom + (st.fadeTo - st.fadeFrom) * k;
if (t >= 1) {
if (st.fadePhase === 'in') {
st.fadePhase = 'visible';
} else {
// close завершился — финальная уборка
st.fadePhase = 'closed';
this._teardown();
}
}
}
// 2) Обновить экранные координаты spotlight'ов (объекты могут двигаться)
if (st.fadePhase !== 'closed' && st.opts.spotlights.length && st.opts.target === 'scene') {
st.spotlightScreens = this._computeSpotlightScreens(st.opts.spotlights);
}
this._notify();
}
// ===== private =====
_now() {
return (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
}
_instantClose() {
if (!this._state) return;
this._teardown();
this._state = null;
}
_teardown() {
const st = this._state;
if (!st) return;
// 1) Unblock input
if (st.opts.blockInput) {
try { this._player?.setInputBlocked?.(false); } catch (e) {}
}
// 2) Unfreeze camera
if (st.opts.freezeCamera) {
try { this._player?.setCameraFrozen?.(false); } catch (e) {}
}
// 3) Camera reset — только если был cameraOverride
if (st.opts.cameraOverride && this._savedCameraState) {
try { this._player?.restoreCameraState?.(this._savedCameraState); } catch (e) {}
this._savedCameraState = null;
}
// 4) Unpause
if (st.opts.pauseSimulation && this._runtime) {
try { this._runtime.paused = false; } catch (e) {}
}
// 5) Unmute
if (st.opts.muteWorld && this._audio) {
try { this._audio.unduck?.(); } catch (e) {}
}
// 6) Снять highlight
if (this._highlight) {
try { this._highlight.removeAllMeshes(); } catch (e) {}
}
// 7) Удалить temp GUI
if (st.tempGuiIds.length && this._gui) {
for (const id of st.tempGuiIds) {
try { this._gui.remove(id); } catch (e) {}
}
}
// 8) Колбэки onClose
for (const cb of this._closeCallbacks) {
try { cb(st.id); } catch (e) {}
}
}
_applyCameraOverride(co) {
// Используем существующий camera.focusOn механизм из BabylonScene/PlayerController
try {
const ref = co.target;
const distance = Number.isFinite(co.distance) ? co.distance : 8;
const height = Number.isFinite(co.height) ? co.height : 3;
const fov = Number.isFinite(co.fov) ? co.fov : null;
const duration = Number.isFinite(co.duration) ? co.duration : 0.5;
if (this._player?.focusOnTarget) {
this._player.focusOnTarget(ref, { distance, height, fov, duration });
} else if (this._scene?._gameRuntime?._handleCommand) {
// fallback через runtime — отправляем camera.focus
this._scene._gameRuntime._handleCommand(null, 'camera.focus', {
ref, distance, height, fov, duration,
});
}
} catch (e) {}
}
_applyHighlight(refs) {
if (!this._scene) return;
// Лениво создаём HighlightLayer
if (!this._highlight) {
try {
const BABYLON = window.BABYLON || (typeof globalThis !== 'undefined' ? globalThis.BABYLON : null);
if (BABYLON?.HighlightLayer && this._scene.scene) {
this._highlight = new BABYLON.HighlightLayer('modal-spotlight', this._scene.scene);
this._highlight.innerGlow = false;
this._highlight.outerGlow = true;
}
} catch (e) {}
}
if (!this._highlight) return;
try { this._highlight.removeAllMeshes(); } catch (e) {}
const BABYLON = window.BABYLON;
const glowColor = (BABYLON && BABYLON.Color3)
? new BABYLON.Color3(1, 1, 0.6)
: null;
for (const ref of refs) {
const meshes = this._resolveMeshes(ref);
for (const m of meshes) {
try {
if (glowColor) this._highlight.addMesh(m, glowColor);
} catch (e) {}
}
}
}
/** Резолв ref массив Babylon-мешей.
* ref может быть: строка-id, объект ref-обёртка ({kind, id}), либо сам Mesh. */
_resolveMeshes(ref) {
if (!ref || !this._scene) return [];
// Уже Mesh-инстанс
if (ref.getScene && typeof ref.getScene === 'function') return [ref];
const sc = this._scene;
const idStr = (typeof ref === 'string') ? ref : (ref.id || ref.refId || null);
if (!idStr) return [];
// Пробуем разные менеджеры
const tryGetters = [
() => sc.primitiveManager?.getMesh?.(idStr),
() => sc.modelManager?.getInstanceMeshes?.(idStr),
() => sc.scene?.getMeshByName?.(idStr),
() => sc.npcManager?.getMeshes?.(idStr),
() => sc.zombieManager?.getMeshes?.(idStr),
];
for (const g of tryGetters) {
try {
const r = g();
if (!r) continue;
if (Array.isArray(r)) return r;
return [r];
} catch (e) {}
}
return [];
}
/** Проектируем 3D-позиции spotlight-refs в экранные координаты для CSS-mask. */
_computeSpotlightScreens(refs) {
if (!this._scene?.scene) return [];
const out = [];
const BABYLON = window.BABYLON;
if (!BABYLON) return [];
const engine = this._scene.scene.getEngine();
const camera = this._scene.scene.activeCamera;
if (!camera || !engine) return [];
const w = engine.getRenderWidth();
const h = engine.getRenderHeight();
const matrix = camera.getTransformationMatrix();
const viewport = camera.viewport.toGlobal(w, h);
for (const ref of refs) {
const meshes = this._resolveMeshes(ref);
if (!meshes.length) continue;
const m = meshes[0];
try {
const pos = m.getAbsolutePosition?.() || m.position;
if (!pos) continue;
// Center проектируем
const proj = BABYLON.Vector3.Project(pos, BABYLON.Matrix.Identity(), matrix, viewport);
// Если за камерой — скип (z вне 0..1)
if (proj.z < 0 || proj.z > 1) continue;
// Радиус — фиксированный из opts (можно потом масштабировать по distance/size)
out.push({ x: proj.x, y: proj.y, r: this._state.opts.spotlightRadius });
} catch (e) {}
}
return out;
}
_createTempGui(elements) {
if (!Array.isArray(elements) || !this._gui) return;
for (const el of elements) {
if (!el || typeof el !== 'object') continue;
const kind = el.kind || el.type || 'frame';
const opts = { ...el };
delete opts.kind;
delete opts.type;
try {
const id = this._gui.create(kind, opts);
if (id) this._state.tempGuiIds.push(id);
} catch (e) {}
}
}
}

View File

@ -314,9 +314,10 @@ export class ModelManager {
r.getChildMeshes(false).forEach(m => { r.getChildMeshes(false).forEach(m => {
m.isPickable = true; m.isPickable = true;
m.metadata = { isModel: true, instanceId: this._nextInstanceId }; m.metadata = { isModel: true, instanceId: this._nextInstanceId };
// Тени: GLB-модель и принимает тени, и отбрасывает их // Тени: на InstancedMesh receiveShadows не действует (warning+нагрузка).
// (через addShadowCaster в refreshAllShadows). if (m.getClassName && m.getClassName() !== 'InstancedMesh') {
m.receiveShadows = true; m.receiveShadows = true;
}
clonedMeshes.push(m); clonedMeshes.push(m);
}); });
// И сам root тоже на всякий // И сам root тоже на всякий
@ -541,6 +542,7 @@ 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,
...(data.folderId != null ? { folderId: data.folderId } : {}), // папка
// Параметры геймплея (HP, скорость врага, лимит спавнера и т.п.) // Параметры геймплея (HP, скорость врага, лимит спавнера и т.п.)
gameplayParams: data.gameplayParams || null, gameplayParams: data.gameplayParams || null,
}); });
@ -774,6 +776,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);
} }
} }

View File

@ -879,6 +879,85 @@ export const MODEL_TYPES = [
m('pg-fence-planks', 'Забор', 'Площадка', 'nature-kit', 'fence_planks', m('pg-fence-planks', 'Забор', 'Площадка', 'nature-kit', 'fence_planks',
{ targetHeight: 1.5 }), { targetHeight: 1.5 }),
// === ЛЕГО-СЕТ (задача 09) — паритет со студией ===
mc('lego-brick-1x1', 'Лего 1×1', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 1, sy: 1, sz: 1, color: '#e02a2a', material: 'studs', dy: 0.5 },
]),
mc('lego-brick-1x2', 'Лего 1×2', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 2, sy: 1, sz: 1, color: '#2a6fe0', material: 'studs', dy: 0.5 },
]),
mc('lego-brick-1x4', 'Лего 1×4', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 4, sy: 1, sz: 1, color: '#f0c020', material: 'studs', dy: 0.5 },
]),
mc('lego-brick-2x2', 'Лего 2×2', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 2, sy: 1, sz: 2, color: '#35ba5c', material: 'studs', dy: 0.5 },
]),
mc('lego-brick-2x4', 'Лего 2×4', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 4, sy: 1, sz: 2, color: '#e07a30', material: 'studs', dy: 0.5 },
]),
mc('lego-brick-2x8', 'Лего 2×8', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 8, sy: 1, sz: 2, color: '#9b5cf0', material: 'studs', dy: 0.5 },
]),
mc('lego-plate-1x1', 'Плита 1×1', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 1, sy: 0.35, sz: 1, color: '#cfd2d6', material: 'studs', dy: 0.175 },
]),
mc('lego-plate-1x2', 'Плита 1×2', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 2, sy: 0.35, sz: 1, color: '#cfd2d6', material: 'studs', dy: 0.175 },
]),
mc('lego-plate-2x2', 'Плита 2×2', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 2, sy: 0.35, sz: 2, color: '#cfd2d6', material: 'studs', dy: 0.175 },
]),
mc('lego-plate-4x4', 'Плита 4×4', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 4, sy: 0.35, sz: 4, color: '#9aa0a6', material: 'studs', dy: 0.175 },
]),
mc('lego-slope-30', 'Скат 30°', 'Лего-сет', [
{ kind: 'primitive', type: 'wedge', sx: 2, sy: 1, sz: 2, color: '#e02a2a', material: 'studs', dy: 0.5 },
]),
mc('lego-slope-45', 'Скат 45°', 'Лего-сет', [
{ kind: 'primitive', type: 'wedge', sx: 2, sy: 2, sz: 2, color: '#2a6fe0', material: 'studs', dy: 1 },
]),
mc('lego-slope-60', 'Скат 60°', 'Лего-сет', [
{ kind: 'primitive', type: 'wedge', sx: 2, sy: 3, sz: 2, color: '#f0c020', material: 'studs', dy: 1.5 },
]),
mc('lego-tree', 'Лего-дерево', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 1, sy: 3, sz: 1, color: '#8a5a2b', material: 'studs', dy: 1.5 },
{ kind: 'primitive', type: 'cube', sx: 3, sy: 2, sz: 3, color: '#35ba5c', material: 'studs', dy: 4 },
{ kind: 'primitive', type: 'cube', sx: 2, sy: 1.5, sz: 2, color: '#2e9e4c', material: 'studs', dy: 5.5 },
], { targetHeight: 6 }),
mc('lego-bush', 'Лего-куст', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 2, sy: 1, sz: 2, color: '#2e9e4c', material: 'studs', dy: 0.5 },
{ kind: 'primitive', type: 'cube', sx: 1, sy: 1, sz: 1, color: '#35ba5c', material: 'studs', dy: 1.3, dx: 0.4 },
{ kind: 'primitive', type: 'cube', sx: 1, sy: 1, sz: 1, color: '#35ba5c', material: 'studs', dy: 1.3, dx: -0.5, dz: 0.3 },
], { targetHeight: 1.8 }),
mc('lego-house-small', 'Лего-дом', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 6, sy: 4, sz: 6, color: '#e02a2a', material: 'studs', dy: 2 },
{ kind: 'primitive', type: 'wedge', sx: 7, sy: 2.5, sz: 7, color: '#2a6fe0', material: 'studs', dy: 5.25 },
{ kind: 'primitive', type: 'cube', sx: 1.4, sy: 2.4, sz: 0.3, color: '#f0c020', material: 'studs', dy: 1.2, dz: -3.05 },
{ kind: 'primitive', type: 'cube', sx: 1.2, sy: 1.2, sz: 0.3, color: '#9ad0ff', material: 'studs', dy: 2.6, dz: -3.05, dx: 1.8 },
], { targetHeight: 6.5 }),
mc('lego-car-racer', 'Лего-машина', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 6, sy: 1, sz: 3, color: '#e02a2a', material: 'studs', dy: 0.9 },
{ kind: 'primitive', type: 'cube', sx: 2.5, sy: 1.2, sz: 2.6, color: '#2a6fe0', material: 'studs', dy: 1.9, dx: 0.6 },
{ kind: 'primitive', type: 'cylinder', sx: 1, sy: 0.6, sz: 1, color: '#222428', material: 'studs', dy: 0.5, dx: 1.8, dz: 1.6, rz: Math.PI / 2 },
{ kind: 'primitive', type: 'cylinder', sx: 1, sy: 0.6, sz: 1, color: '#222428', material: 'studs', dy: 0.5, dx: 1.8, dz: -1.6, rz: Math.PI / 2 },
{ kind: 'primitive', type: 'cylinder', sx: 1, sy: 0.6, sz: 1, color: '#222428', material: 'studs', dy: 0.5, dx: -1.8, dz: 1.6, rz: Math.PI / 2 },
{ kind: 'primitive', type: 'cylinder', sx: 1, sy: 0.6, sz: 1, color: '#222428', material: 'studs', dy: 0.5, dx: -1.8, dz: -1.6, rz: Math.PI / 2 },
], { targetHeight: 2.5 }),
mc('lego-stairs', 'Лего-ступеньки', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 3, sy: 1, sz: 1.2, color: '#cfd2d6', material: 'studs', dy: 0.5, dz: 1.8 },
{ kind: 'primitive', type: 'cube', sx: 3, sy: 2, sz: 1.2, color: '#cfd2d6', material: 'studs', dy: 1.0, dz: 0.6 },
{ kind: 'primitive', type: 'cube', sx: 3, sy: 3, sz: 1.2, color: '#cfd2d6', material: 'studs', dy: 1.5, dz: -0.6 },
{ kind: 'primitive', type: 'cube', sx: 3, sy: 4, sz: 1.2, color: '#cfd2d6', material: 'studs', dy: 2.0, dz: -1.8 },
], { targetHeight: 4 }),
mc('lego-minifig', 'Лего-человечек', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 1.4, sy: 0.6, sz: 0.9, color: '#f0c020', material: 'studs', dy: 0.3 },
{ kind: 'primitive', type: 'cube', sx: 1.2, sy: 1.6, sz: 0.8, color: '#2a6fe0', material: 'studs', dy: 1.4 },
{ kind: 'primitive', type: 'cube', sx: 0.4, sy: 1.4, sz: 0.4, color: '#f0c020', material: 'studs', dy: 1.4, dx: 0.85 },
{ kind: 'primitive', type: 'cube', sx: 0.4, sy: 1.4, sz: 0.4, color: '#f0c020', material: 'studs', dy: 1.4, dx: -0.85 },
{ kind: 'primitive', type: 'cube', sx: 1.1, sy: 1.0, sz: 0.85, color: '#f5c84a', material: 'studs', dy: 2.7 },
{ kind: 'primitive', type: 'cylinder', sx: 0.9, sy: 0.5, sz: 0.9, color: '#e02a2a', material: 'studs', dy: 3.4 },
], { targetHeight: 3.8 }),
// TOTAL: 644 // TOTAL: 644
]; ];

View File

@ -137,9 +137,16 @@ export class MultiplayerSync {
// 1. Подписки на state // 1. Подписки на state
const $ = getStateCallbacks(this.room); const $ = getStateCallbacks(this.room);
// Защита от повторного срабатывания onAdd (Colyseus 0.16 + immediate:true
// может триггерить .onAdd на каждый schema patch). Локальный set хранит
// sessionId которые уже обработаны в ТЕКУЩЕМ sync объекте.
const _addedSessionIds = new Set();
const handleAdd = (player, sessionId) => { const handleAdd = (player, sessionId) => {
// Не рисуем СЕБЯ как remote-меш — у нас уже есть PlayerController // Не рисуем СЕБЯ как remote-меш — у нас уже есть PlayerController
if (sessionId === this.room.sessionId) return; if (sessionId === this.room.sessionId) return;
// Защита от дублирующих onAdd событий для уже добавленного игрока
if (_addedSessionIds.has(sessionId)) return;
_addedSessionIds.add(sessionId);
this._addRemotePlayer(sessionId, player); this._addRemotePlayer(sessionId, player);
// Подписываемся на изменения этого Player'а // Подписываемся на изменения этого Player'а
$(player).onChange(() => this._updateRemoteTarget(sessionId, player)); $(player).onChange(() => this._updateRemoteTarget(sessionId, player));
@ -149,7 +156,11 @@ export class MultiplayerSync {
this._attachRemoteWeapon(sessionId, val || ''); this._attachRemoteWeapon(sessionId, val || '');
}); });
}; };
// Используем тот же set в handleRemove чтобы при настоящем уходе игрока
// потом можно было его снова добавить.
this._addedSessionIds = _addedSessionIds;
const handleRemove = (player, sessionId) => { const handleRemove = (player, sessionId) => {
if (this._addedSessionIds) this._addedSessionIds.delete(sessionId);
this._removeRemotePlayer(sessionId); this._removeRemotePlayer(sessionId);
}; };
@ -289,8 +300,20 @@ export class MultiplayerSync {
// Интерполяция remote-игроков (позиция + yaw ставится на root, // Интерполяция remote-игроков (позиция + yaw ставится на root,
// модель — child root'а — следует за ним). // модель — child root'а — следует за ним).
// 2026-06-05: читаем target напрямую из room.state.players —
// в Colyseus 0.16 onChange может не срабатывать для всех полей
// (особенно yaw/animState), а target.x/y/z/yaw обновляется
// через _updateRemoteTarget только из onChange. Подстраховка.
for (const rp of this.remotePlayers.values()) { for (const rp of this.remotePlayers.values()) {
if (!rp.root || !rp.target) continue; if (!rp.root || !rp.target) continue;
const livePlayer = this.room?.state?.players?.get?.(rp.sessionId);
if (livePlayer) {
rp.target.x = livePlayer.x;
rp.target.y = livePlayer.y;
rp.target.z = livePlayer.z;
rp.target.yaw = livePlayer.yaw || 0;
if (livePlayer.animState) rp.animState = livePlayer.animState;
}
const cur = rp.current; const cur = rp.current;
cur.x += (rp.target.x - cur.x) * LERP_FACTOR; cur.x += (rp.target.x - cur.x) * LERP_FACTOR;
cur.y += (rp.target.y - cur.y) * LERP_FACTOR; cur.y += (rp.target.y - cur.y) * LERP_FACTOR;
@ -332,13 +355,25 @@ export class MultiplayerSync {
// Развилка: R15-скины анимируются процедурно через R15Animator // Развилка: R15-скины анимируются процедурно через R15Animator
// (как локальный игрок), Kenney-модели — через glTF AnimationGroups. // (как локальный игрок), Kenney-модели — через glTF AnimationGroups.
if (rp.isR15 && rp.r15Animator && rp.modelLoaded) { if (rp.isR15 && rp.r15Animator && rp.modelLoaded) {
// Серверный animState: 'idle' | 'run' | 'attack'. R15Animator // Серверный animState: 'idle' | 'run' | 'jump' | 'fall' | 'attack'.
// понимает idle/walk/run/jump/fall. Сервер не различает // R15Animator понимает idle/walk/run/jump/fall.
// walk/run и не шлёт прыжки → маппим run→run, attack→idle // 2026-06-05: раньше run/jump/fall маппились в idle (баг
// (атака показывается отдельным swing-ом руки ниже). // в маппинге), из-за чего у remote-игроков не было
const r15State = rp.isDead // анимации ни ходьбы, ни прыжка. Теперь пробрасываем
? 'idle' // напрямую. attack показывается отдельным swing руки.
: (rp.animState === 'run' ? 'run' : 'idle'); let r15State;
if (rp.isDead) {
r15State = 'idle';
} else if (rp.animState === 'jump') {
r15State = 'jump';
} else if (rp.animState === 'fall') {
r15State = 'fall';
} else if (rp.animState === 'run') {
r15State = 'run';
} else {
// 'attack' или 'idle' или неизвестное — стоим
r15State = 'idle';
}
rp.r15Animator.setState(r15State); rp.r15Animator.setState(r15State);
rp.r15Animator.update(dt); rp.r15Animator.update(dt);
} else if (!rp.isR15) { } else if (!rp.isR15) {
@ -632,6 +667,23 @@ export class MultiplayerSync {
// === Внутреннее: меши remote-игроков === // === Внутреннее: меши remote-игроков ===
// ================================================================= // =================================================================
_addRemotePlayer(sessionId, player) { _addRemotePlayer(sessionId, player) {
// Защита от дублей при Colyseus reconnect: state получается заново
// и onAdd срабатывает для тех же sessionId. Без этой проверки в
// сцене появляются клоны игроков (см. issue после 2026-06-05).
if (this.remotePlayers && this.remotePlayers.has(sessionId)) {
const existing = this.remotePlayers.get(sessionId);
// Обновим target позицию и пометим что игрок жив
const sx2 = player.x || 0, sy2 = player.y || 0, sz2 = player.z || 0, yaw2 = player.yaw || 0;
existing.target = { x: sx2, y: sy2, z: sz2, yaw: yaw2 };
existing.username = player.username || sessionId;
existing.modelType = player.modelType || existing.modelType;
existing.hp = player.hp ?? existing.hp;
existing.maxHp = player.maxHp ?? existing.maxHp;
existing.isDead = !!player.isDead;
existing.animState = player.animState || existing.animState;
console.log(`[MultiplayerSync] re-add (reconnect): ${sessionId} (${player.username}) — обновили без пересоздания меша`);
return;
}
const sx = player.x || 0; const sx = player.x || 0;
const sy = player.y || 0; const sy = player.y || 0;
const sz = player.z || 0; const sz = player.z || 0;

View File

@ -161,6 +161,19 @@ export class NpcManager {
r15Animator, r15Animator,
}; };
this.npcs.set(id, npc); this.npcs.set(id, npc);
// Пометить меши NPC для попаданий оружия (бластер/меч): pickable + npcId
// в metadata. Без pickable raycast оружия проходит сквозь NPC (задача 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;
} }
@ -275,6 +288,12 @@ export class NpcManager {
npc.isMoving = false; npc.isMoving = false;
} }
/** Включить/выключить анимацию атаки. */
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));
@ -287,10 +306,41 @@ 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;
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;
}
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));
@ -391,17 +441,22 @@ 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-моделей нет скелета).
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-скелета у Kenney).
if (moving) npc.walkPhase += dt * 6;
// R15-NPC (skin_*): процедурная анимация бега/покоя через R15Animator. // 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

@ -0,0 +1,586 @@
/**
* PlacementManager drag-and-drop размещение объектов в 3D-мире (задача 11).
*
* Фундамент жанра tycoon/simulator/farm: «кликнул предмет в инвентаре
* полупрозрачный preview летает за курсором ЛКМ ставит, ПКМ/Esc отменяет».
* Аналог placement-системы из Roblox tycoon-игр (Pet Sim, Lumber Tycoon).
*
* Подключается из BabylonScene: `this.placementManager = new PlacementManager(this)`.
* Доступ к движку через scene3d: camera, scene, player, pick, spawn, fx.
*
* Скриптовый API игры (через GameRuntime game.placement.*):
* start(itemKey, opts) войти в режим расстановки
* cancel() выйти (как ПКМ/Esc)
* confirm() поставить на текущей позиции (как ЛКМ)
* rotate(deg) повернуть preview (как R / колесо)
* onPlace / onCancel / onMove колбэки (роутятся в worker как события)
*
* Фича-парность: идентичный модуль есть в rublox-player/src/engine/.
*/
import { MeshBuilder, StandardMaterial, Color3, Vector3 } from '@babylonjs/core';
const VALID_TINT = new Color3(0.30, 0.95, 0.40); // зелёный — можно ставить
const INVALID_TINT = new Color3(0.95, 0.25, 0.25); // красный — нельзя
export class PlacementManager {
constructor(scene3d) {
this.s = scene3d; // BabylonScene
this.scene = scene3d.scene;
this._active = null; // активная сессия placement или null
this._tickObs = null; // observer renderLoop
this._placementSeq = 0;
// Колбэки (вызываются движком, GameRuntime роутит их в worker как события)
this._onPlace = null;
this._onCancel = null;
this._onMove = null;
}
setCallbacks({ onPlace, onCancel, onMove } = {}) {
if (onPlace !== undefined) this._onPlace = onPlace;
if (onCancel !== undefined) this._onCancel = onCancel;
if (onMove !== undefined) this._onMove = onMove;
}
isActive() { return !!this._active; }
/**
* Войти в placement-режим.
* @param {string} itemKey ключ предмета (передаётся обратно в onPlace)
* @param {object} opts см. 11_placement_mode.md §2.1
* @returns {string} placementId
*/
start(itemKey, opts = {}) {
// Уже активна сессия — отменим прежнюю (без onCancel-шума автора).
if (this._active) this._teardown(false);
const o = {
previewType: opts.previewType || 'primitive:cube',
previewColor: opts.previewColor || '#a0522d',
previewScale: Number(opts.previewScale) || 1,
// modelScale — реальный scale воксельной модели для превью (чтобы
// полупрозрачная копия была того же размера, что и ставимый объект).
modelScale: Number(opts.modelScale) || Number(opts.previewScale) || 1,
ghostOpacity: opts.ghostOpacity != null ? Number(opts.ghostOpacity) : 0.5,
surfaceMode: opts.surfaceMode || 'ground', // 'ground'|'any'|'tag'
allowSurfaces: Array.isArray(opts.allowSurfaces) ? opts.allowSurfaces : null,
forbidOverlap: opts.forbidOverlap !== false,
grid: opts.grid != null ? Number(opts.grid) : 1,
rotationStep: opts.rotationStep != null ? Number(opts.rotationStep) : 90,
targetZone: opts.targetZone || null, // ref-строка примитива-зоны
showZoneOutline: opts.showZoneOutline !== false,
showArrowFrom: opts.showArrowFrom || null, // 'player' | ref
cost: Number(opts.cost) || 0,
currency: opts.currency || 'rubles',
hint: opts.hint || '',
hintError: opts.hintError || 'Разместите в отмеченном месте!',
placedType: opts.placedType || null,
chainPlace: !!opts.chainPlace,
maxDistance: opts.maxDistance != null ? Number(opts.maxDistance) : 0,
maxItems: opts.maxItems != null ? Number(opts.maxItems) : 0,
forceCameraMode: opts.forceCameraMode !== false,
freezePlayer: !!opts.freezePlayer,
previewPulse: opts.previewPulse !== false,
};
const id = 'placement_' + (++this._placementSeq);
const preview = this._createPreview(o);
this._active = {
id, itemKey, opts: o, preview,
rotationY: 0,
valid: false,
pos: new Vector3(0, 0, 0),
zoneOutline: null,
arrowFxRef: null,
placedCount: 0,
pulseT: 0,
prevCameraMode: null,
prevFrozen: null,
};
// Зона размещения — красный контур по AABB.
if (o.targetZone && o.showZoneOutline) this._createZoneOutline();
// Стрелка от игрока/объекта к зоне (переиспользует fx.pointer задачи 08).
if (o.showArrowFrom && o.targetZone) this._createArrow();
// Камера: placement требует видимый курсор — в first переводим в third.
if (o.forceCameraMode) this._forceThirdCamera();
// Заморозка игрока (опция).
if (o.freezePlayer) this._setPlayerFrozen(true);
// HUD: подсказки снизу-справа + верхний hint. Сообщаем движку.
this._emitHud(true);
this._startTick();
return id;
}
cancel() {
if (!this._active) return;
const cb = this._onCancel;
this._teardown(true);
if (typeof cb === 'function') cb();
}
/** Поставить на текущей позиции (как ЛКМ). */
confirm() {
const a = this._active;
if (!a) return false;
if (!a.valid) {
// Невалидно — звук «не получилось» + мигание preview в красный.
this._playFail();
this._flashInvalid();
return false;
}
// Позиция для spawn = точка курсора МИНУС offset центра модели (с учётом
// поворота), чтобы реальный объект встал ОСНОВАНИЕМ под курсором —
// ровно туда, где показывалось превью. Для куба-превью offset = 0.
let ox = a._modelOffsetX || 0, oz = a._modelOffsetZ || 0;
if (ox || oz) {
const c = Math.cos(a.rotationY), s = Math.sin(a.rotationY);
const rx = ox * c - oz * s;
const rz = ox * s + oz * c;
ox = rx; oz = rz;
}
const result = {
itemKey: a.itemKey,
position: { x: a.pos.x - ox, y: a.pos.y, z: a.pos.z - oz },
rotationY: a.rotationY,
};
// Списание стоимости (если задана и есть валюта-хелпер в движке).
if (a.opts.cost > 0) this._spendCurrency(a.opts.currency, a.opts.cost);
a.placedCount++;
this._playPlace();
if (typeof this._onPlace === 'function') this._onPlace(result);
if (a.opts.chainPlace) {
// Остаёмся в режиме для следующего — preview не удаляем, сбрасываем поворот? нет, сохраняем.
// Просто продолжаем тик; valid пересчитается в следующем кадре.
return true;
}
this._teardown(false);
return true;
}
/** Повернуть preview на N градусов вокруг Y. */
rotate(deg) {
const a = this._active;
if (!a) return;
const step = (deg != null ? Number(deg) : a.opts.rotationStep) || 90;
a.rotationY = (a.rotationY + step * Math.PI / 180) % (Math.PI * 2);
if (a.preview) a.preview.rotation.y = a.rotationY;
}
// ── Внутреннее ──────────────────────────────────────────────────────
_createPreview(o) {
const base = Color3.FromHexString(o.previewColor || '#a0522d');
// Для воксельной модели (user:<id>) строим ПРЕВЬЮ ИЗ РЕАЛЬНОЙ ГЕОМЕТРИИ
// модели — полупрозрачную копию. Так тень точно повторяет форму предмета
// И совпадает по позиционированию с реальным spawn (модель растёт от угла
// root, а не центрируется — куб-превью раньше центрировался → предмет
// вставал в угол превью). Здесь превью = тот же addInstance, поэтому
// угол-в-угол. Делается асинхронно (см. _buildUserModelPreview).
const pt = o.previewType || '';
if (pt.indexOf('user:') === 0 && this.s.userModelManager) {
// Временный куб-заглушка пока модель грузится (1-2 кадра), заменим.
const stub = MeshBuilder.CreateBox('placementGhostStub', { size: 0.01 }, this.scene);
stub.isPickable = false;
stub._baseColor = base;
this._buildUserModelPreview(pt, o, base);
return stub;
}
// Примитивы / прочее — полупрозрачный куб размером previewScale (юниты).
const edge = Number(o.previewScale) || 1;
const ghost = MeshBuilder.CreateBox('placementGhost', { size: edge }, this.scene);
const mat = new StandardMaterial('placementGhostMat', this.scene);
mat.diffuseColor = base;
mat.emissiveColor = base.scale(0.25);
mat.specularColor = new Color3(0, 0, 0);
mat.alpha = o.ghostOpacity;
mat.disableLighting = true;
ghost.material = mat;
ghost.isPickable = false;
ghost._baseColor = base;
return ghost;
}
/** Построить полупрозрачное превью из реальной воксельной модели (async). */
async _buildUserModelPreview(previewType, o, base) {
try {
const um = this.s.userModelManager;
// Спавним реальный инстанс модели в (0,0,0) — будем двигать как превью.
const instId = await um.addInstance(previewType, 0, 0, 0, 0, {
scale: o.modelScale || o.previewScale || 1,
canCollide: false, visible: true, anchored: true,
currentUserId: this.s._currentUserId || null,
});
if (instId == null) return;
// Сессия уже могла завершиться/смениться, пока грузилось.
const a = this._active;
if (!a) { try { um.removeInstance(instId); } catch (e) {} return; }
const inst = um.instances.get(instId);
if (!inst || !inst.rootNode) return;
// Применяем ghost-вид ко всем мешам модели: полупрозрачно, не pickable.
const ghostMat = new StandardMaterial('placementGhostMatUM', this.scene);
ghostMat.diffuseColor = base;
ghostMat.emissiveColor = base.scale(0.25);
ghostMat.specularColor = new Color3(0, 0, 0);
ghostMat.alpha = o.ghostOpacity;
ghostMat.disableLighting = true;
ghostMat.backFaceCulling = false;
for (const m of (inst.meshes || [])) {
m.isPickable = false;
m.material = ghostMat;
}
// Центр модели по X/Z (воксели растут углом от root → центр смещён).
// Вычисляем из bbox мешей в локальных координатах root (root в 0,0,0).
// Этот offset вычитаем из позиции, чтобы ОСНОВАНИЕ модели (её центр
// по X/Z) было ровно под курсором, а не угол. Применяется и к превью,
// и к реальному spawn (onPlace) — иначе предмет «уезжает» по диагонали.
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
for (const m of (inst.meshes || [])) {
m.computeWorldMatrix(true);
const bb = m.getBoundingInfo().boundingBox;
minX = Math.min(minX, bb.minimumWorld.x); maxX = Math.max(maxX, bb.maximumWorld.x);
minZ = Math.min(minZ, bb.minimumWorld.z); maxZ = Math.max(maxZ, bb.maximumWorld.z);
}
const offX = Number.isFinite(minX) ? (minX + maxX) / 2 : 0;
const offZ = Number.isFinite(minZ) ? (minZ + maxZ) / 2 : 0;
a._modelOffsetX = offX;
a._modelOffsetZ = offZ;
// Удаляем временный stub, новый root становится превью.
const old = a.preview;
a.preview = inst.rootNode;
a.preview._baseColor = base;
a.preview._userModelInstId = instId; // для teardown
a.preview._ghostMat = ghostMat;
if (old) { try { old.dispose(); } catch (e) {} }
} catch (e) {
// тихо — превью некритично, останется stub
}
}
_startTick() {
this._tickObs = this.scene.onBeforeRenderObservable.add(() => this._tick());
}
_tick() {
const a = this._active;
if (!a) return;
const scn = this.scene;
// Raycast от камеры через текущую позицию курсора.
const pick = scn.pick(scn.pointerX, scn.pointerY, (m) =>
m && m.isPickable && m !== a.preview && this._isSurface(m, a.opts));
if (pick && pick.hit && pick.pickedPoint) {
let p = pick.pickedPoint.clone();
// surfaceMode 'ground' — нормаль должна смотреть вверх.
// Поверхность валидна, если смотрит вверх (горизонтальная грань).
// Это и пол, и ВЕРХ другого объекта → можно строить стопкой.
let surfOk = true;
if (a.opts.surfaceMode === 'ground') {
const n = pick.getNormal(true);
surfOk = n && n.y > 0.6; // только грань, обращённая вверх
}
// Снэппинг к сетке (X/Z). Y берём с поверхности — чтобы объект
// лёг ровно сверху на пол ИЛИ на другой объект (стопка).
if (a.opts.grid > 0) {
p.x = Math.round(p.x / a.opts.grid) * a.opts.grid;
p.z = Math.round(p.z / a.opts.grid) * a.opts.grid;
}
a.pos.copyFrom(p);
if (a.preview) {
if (a.preview._userModelInstId != null) {
// userModel-превью: root = угол модели. Вычитаем offset центра
// по X/Z → ОСНОВАНИЕ модели (ствол/центр) точно под курсором.
// Высота p.y без сдвига (низ модели на поверхность).
a.preview.position.set(
p.x - (a._modelOffsetX || 0),
p.y,
p.z - (a._modelOffsetZ || 0),
);
} else {
// Куб-превью центрирован → поднимаем на полвысоты.
a.preview.position.set(p.x, p.y + 0.5 * a.opts.previewScale, p.z);
}
}
// Валидность. forbidOverlap теперь означает «не врезаться вбок в
// объект на ТОЙ ЖЕ высоте» — вертикальная стопка разрешена.
a.valid = surfOk
&& this._inZone(p, a.opts)
&& this._distanceOk(p, a.opts)
&& this._limitOk(a.opts)
&& this._affordable(a)
&& (!a.opts.forbidOverlap || !this._overlapsSide(p, a));
} else {
a.valid = false;
}
// Цвет preview: зелёный/красный.
this._applyTint(a, a.valid);
// Пульсация прозрачности (привлекает внимание). Материал — у куба-превью
// напрямую, у userModel-превью в _ghostMat (root — TransformNode без mat).
const pmat = a.preview && (a.preview.material || a.preview._ghostMat);
if (a.opts.previewPulse && pmat) {
a.pulseT += this.scene.getEngine().getDeltaTime() / 1000;
const k = 0.5 + 0.5 * Math.sin(a.pulseT * Math.PI); // 0..1
pmat.alpha = a.opts.ghostOpacity * (0.7 + 0.3 * k);
}
// HUD-индикатор ошибки (красный текст когда невалидно).
this._emitHudError(!a.valid);
// Стрелка к зоне — обновим конечную точку (если игрок движется).
if (a.arrowFxRef) this._updateArrow();
// onMove колбэк автору (каждый кадр).
if (typeof this._onMove === 'function') {
this._onMove({ position: { x: a.pos.x, y: a.pos.y, z: a.pos.z }, valid: a.valid });
}
}
_applyTint(a, valid) {
// Материал куба-превью напрямую, userModel-превью — в _ghostMat.
const pmat = a.preview && (a.preview.material || a.preview._ghostMat);
if (!pmat) return;
if (a._flashUntil && performance && performance.now && performance.now() < a._flashUntil) {
return; // во время flash держим красный
}
const tint = valid ? VALID_TINT : INVALID_TINT;
// Смешиваем базовый цвет с tint-ом (multiply-эффект).
const b = a.preview._baseColor || new Color3(0.6, 0.4, 0.25);
pmat.diffuseColor = new Color3(
b.r * tint.r + tint.r * 0.4,
b.g * tint.g + tint.g * 0.4,
b.b * tint.b + tint.b * 0.4,
);
pmat.emissiveColor = tint.scale(0.35);
}
_flashInvalid() {
const a = this._active;
if (!a || !a.preview || !a.preview.material) return;
try { a._flashUntil = (performance.now ? performance.now() : Date.now()) + 300; } catch { a._flashUntil = Date.now() + 300; }
a.preview.material.diffuseColor = INVALID_TINT;
a.preview.material.emissiveColor = INVALID_TINT.scale(0.6);
}
_isSurface(mesh, o) {
if (!o.allowSurfaces) return true; // любая поверхность
// Совпадение по имени или тегу.
const name = mesh.name || '';
if (o.allowSurfaces.some(s => name.includes(s))) return true;
const tags = mesh.metadata && mesh.metadata.tags;
if (Array.isArray(tags) && tags.some(t => o.allowSurfaces.includes(t))) return true;
return false;
}
_inZone(p, o) {
if (!o.targetZone) return true;
const z = this._resolveZoneMesh(o.targetZone);
if (!z) return true;
const bb = z.getBoundingInfo().boundingBox;
const min = bb.minimumWorld, max = bb.maximumWorld;
return p.x >= min.x && p.x <= max.x && p.z >= min.z && p.z <= max.z;
}
_distanceOk(p, o) {
if (!o.maxDistance || o.maxDistance <= 0) return true;
const pl = this.s.player && this.s.player._pos;
if (!pl) return true;
const dx = p.x - pl.x, dz = p.z - pl.z;
return (dx * dx + dz * dz) <= o.maxDistance * o.maxDistance;
}
_limitOk(o) {
if (!o.maxItems || o.maxItems <= 0) return true;
return (this._active.placedCount || 0) < o.maxItems;
}
_overlapsSide(p, a) {
// Боковое пересечение: объект мешает ТОЛЬКО если он на той же высоте
// (его тело пересекает уровень, куда ляжет новый объект). Объект строго
// НИЖЕ (фундамент под стопку) или строго ВЫШЕ — не мешает. Это позволяет
// строить башню из кубов, но не даёт двум кубам слипнуться вбок.
const r = Math.max(0.45, (a.opts.grid || 1) * 0.5);
const newY = p.y; // высота поверхности (низ нового объекта)
const newTop = newY + (a.opts.previewScale || 1);
for (const m of this.scene.meshes) {
if (!m.isPickable || m === a.preview) continue;
if (!m.getBoundingInfo) continue;
const bb = m.getBoundingInfo().boundingBox;
const sizeX = bb.maximumWorld.x - bb.minimumWorld.x;
if (sizeX > 8) continue; // пол/большая поверхность — не препятствие
const c = bb.centerWorld;
const dx = c.x - p.x, dz = c.z - p.z;
if (Math.abs(dx) >= r || Math.abs(dz) >= r) continue; // не под курсором
const mTop = bb.maximumWorld.y, mBot = bb.minimumWorld.y;
// Пересечение по вертикали: тела перекрываются по Y → бок в бок.
const overlapY = newY < (mTop - 0.05) && newTop > (mBot + 0.05);
if (overlapY) return true;
}
return false;
}
/** Хватает ли валюты на текущий предмет (если задан баланс). */
_affordable(a) {
const cur = a.opts.currency;
const cost = a.opts.cost || 0;
if (!cost) return true;
const bal = (this._balances && this._balances[cur] != null) ? this._balances[cur] : Infinity;
return cost <= bal;
}
/** Установить баланс валюты (для проверки «нельзя уйти в минус»). */
setBalance(currency, amount) {
if (!this._balances) this._balances = {};
if (currency) this._balances[currency] = Number(amount) || 0;
}
_resolveZoneMesh(ref) {
// ref может быть строкой ('primitive:N' / имя) или уже мешем.
if (ref && ref.getBoundingInfo) return ref;
if (typeof ref === 'string') {
// через scene3d — найти примитив/модель по ref
try {
const mesh = this.s._refStrToMesh ? this.s._refStrToMesh(ref) : null;
if (mesh) return mesh;
} catch { /* ignore */ }
// fallback — по имени
return this.scene.getMeshByName(ref) || null;
}
return null;
}
_createZoneOutline() {
const a = this._active;
const z = this._resolveZoneMesh(a.opts.targetZone);
if (!z) return;
const bb = z.getBoundingInfo().boundingBox;
const min = bb.minimumWorld, max = bb.maximumWorld;
const y = min.y + 0.06;
const pts = [
new Vector3(min.x, y, min.z), new Vector3(max.x, y, min.z),
new Vector3(max.x, y, max.z), new Vector3(min.x, y, max.z),
new Vector3(min.x, y, min.z),
];
const line = MeshBuilder.CreateLines('placementZoneOutline', { points: pts }, this.scene);
line.color = new Color3(1, 0.19, 0.19);
line.isPickable = false;
// glow-имитация: чуть приподнятая полупрозрачная плоскость
a.zoneOutline = line;
}
_createArrow() {
const a = this._active;
// Стрелка-указатель через BeamManager (задача 08: addPointer/setPointerTarget).
try {
const bm = this.s.beamManager;
if (!bm || !bm.addPointer) return;
const z = this._resolveZoneMesh(a.opts.targetZone);
if (!z) return;
const c = z.getBoundingInfo().boundingBox.centerWorld;
const from = (a.opts.showArrowFrom === 'player' && this.s.player && this.s.player._pos)
? this.s.player._pos
: this._resolveZoneMesh(a.opts.showArrowFrom);
const fromV = from && from.x != null ? new Vector3(from.x, (from.y || 0) + 1, from.z) : null;
if (!fromV) return;
a.arrowFxRef = bm.addPointer({
from: { x: fromV.x, y: fromV.y, z: fromV.z },
to: { x: c.x, y: c.y + 0.6, z: c.z },
preset: 'guide',
});
} catch { /* стрелка не критична */ }
}
_updateArrow() {
// Стрелка статична от точки старта к зоне (как в Roblox tycoon —
// указатель «куда ставить»). BeamManager не имеет setPointerOrigin,
// а пересоздавать каждый кадр дорого. Конец уже привязан к зоне.
}
_forceThirdCamera() {
const a = this._active;
try {
if (this.s.player && this.s.player.getCameraMode && this.s.player.setCameraMode) {
a.prevCameraMode = this.s.player.getCameraMode();
if (a.prevCameraMode === 'first') this.s.player.setCameraMode('third');
}
} catch { /* ignore */ }
}
_setPlayerFrozen(frozen) {
try {
if (this.s.player && this.s.player.setFrozen) {
if (this._active) this._active.prevFrozen = true;
this.s.player.setFrozen(frozen);
}
} catch { /* ignore */ }
}
_spendCurrency(currency, amount) {
// Движок не держит «кошелёк» — это делает игра через onPlace + save.
// Но если есть хелпер валюты, вызовем. Иначе — no-op (автор сам спишет).
try {
if (this.s.spendCurrency) this.s.spendCurrency(currency, amount);
} catch { /* ignore */ }
}
_playPlace() { try { this.s.gameAudioManager && this.s.gameAudioManager.playUi && this.s.gameAudioManager.playUi('place'); } catch { /* ignore */ } }
_playFail() { try { this.s.gameAudioManager && this.s.gameAudioManager.playUi && this.s.gameAudioManager.playUi('lose'); } catch { /* ignore */ } }
_emitHud(show) {
// Сообщаем движку показать/скрыть placement-HUD (подсказки).
try {
if (this.s.onPlacementHud) this.s.onPlacementHud({ show, hint: this._active ? this._active.opts.hint : '' });
} catch { /* ignore */ }
}
_emitHudError(isError) {
try {
if (this.s.onPlacementHudError) this.s.onPlacementHudError(isError);
} catch { /* ignore */ }
}
_teardown(emitHudOff) {
const a = this._active;
if (!a) return;
if (this._tickObs) { this.scene.onBeforeRenderObservable.remove(this._tickObs); this._tickObs = null; }
if (a.preview) {
try {
if (a.preview._userModelInstId != null && this.s.userModelManager) {
// userModel-превью — это реальный инстанс; удаляем через менеджер
// (снимет из Map + dispose мешей). + чистим ghost-материал.
try { a.preview._ghostMat && a.preview._ghostMat.dispose(); } catch (e) {}
this.s.userModelManager.removeInstance(a.preview._userModelInstId);
} else {
a.preview.material && a.preview.material.dispose();
a.preview.dispose();
}
} catch { /* ignore */ }
}
if (a.zoneOutline) { try { a.zoneOutline.dispose(); } catch { /* ignore */ } }
if (a.arrowFxRef != null && this.s.beamManager && this.s.beamManager.remove) {
try { this.s.beamManager.remove(a.arrowFxRef); } catch { /* ignore */ }
}
if (a.prevCameraMode === 'first' && this.s.player && this.s.player.setCameraMode) {
try { this.s.player.setCameraMode('first'); } catch { /* ignore */ }
}
if (a.prevFrozen && this.s.player && this.s.player.setFrozen) {
try { this.s.player.setFrozen(false); } catch { /* ignore */ }
}
this._active = null;
if (emitHudOff !== false) this._emitHud(false);
}
/** Полный сброс при Stop игры. */
dispose() {
this._teardown(true);
this._onPlace = this._onCancel = this._onMove = null;
}
}

View File

@ -144,6 +144,15 @@ export class PlayerController {
// Камера. Дефолт — первое лицо (как в большинстве игр). // Камера. Дефолт — первое лицо (как в большинстве игр).
this._cameraMode = 'third'; this._cameraMode = 'third';
this._thirdDistance = this.THIRD_DISTANCE_DEFAULT; this._thirdDistance = this.THIRD_DISTANCE_DEFAULT;
// Порог авто-перехода third→first при зуме колесом (Roblox-style).
this.FIRST_PERSON_ZOOM_THRESHOLD = 0.7;
// Если true — нельзя выйти из first-person зумом (lockfirst-режим).
this._lockFirstPerson = false;
// Shift-Lock (Roblox-style): курсор зафиксирован, корпус лицом к камере.
this._shiftLock = false;
// Задача 02: ПКМ зажата сейчас (orbit в third) + видимость курсора.
this._rmbHeld = false;
this._mouseIconVisible = true;
// Ввод // Ввод
this._codes = new Set(); this._codes = new Set();
@ -185,6 +194,21 @@ export class PlayerController {
this._skinManifest = null; // кеш skins_manifest.json this._skinManifest = null; // кеш skins_manifest.json
this._skinOverrides = {}; // overrides текущего скина this._skinOverrides = {}; // overrides текущего скина
// === non-humanoid скины (задача 07) ===
// Скин без R15-скелета (животное, машина, абстрактная модель).
// Для них центрируем pivot, считаем собственный AABB и анимируем
// процедурно через _animateNonHumanoidMesh (см. _loadPlayerModel/_tick).
this._modelKind = 'r15'; // 'r15' | 'non-humanoid-mesh'
this._modelHipHeight = null; // локальная база модели (опущена на ноги)
this._nonHumanoidBox = null; // {hw,hh,hd} собственный AABB модели
this._lastFrameSpeed = 0; // горизонтальная скорость кадра (для анимаций)
this._isGrounded = true; // флаг «на земле» (для анимаций)
// === Блокировка ввода/камеры для модалов (задача 04) ===
this._inputBlocked = false; // глотает игровой ввод (кроме Esc/Tab/Enter)
this._cameraFrozen = false; // замораживает вращение/зум камеры
this._savedCameraState = null;// сохранённое состояние камеры (focusOnTarget)
// === Жизни игрока === // === Жизни игрока ===
this.maxHp = 100; this.maxHp = 100;
this.hp = 100; this.hp = 100;
@ -296,6 +320,44 @@ export class PlayerController {
this._modelTypeId = typeId || 'character-a'; this._modelTypeId = typeId || 'character-a';
} }
/**
* Сменить скин ИГРОКА в Play-режиме без перезапуска сцены (задача 07).
* Выгружает текущую модель/скелет/аниматор, сбрасывает AABB к дефолту,
* грузит новую модель (R15 или non-humanoid). Возвращает Promise.
*
* Используется из game.player.setSkin(slug).
*/
async reloadSkin(typeId) {
if (!this._active) return false;
const newType = typeId || 'character-a';
if (newType === this._modelTypeId && this._modelRoot) return true; // уже этот скин
// 1) Выгрузить текущую модель и связанные аниматоры.
try {
if (this._modelRoot) { this._modelRoot.dispose(false, true); }
} catch (e) { /* ignore */ }
this._modelRoot = null;
this._modelMeshes = [];
this._rightArmMeshes = [];
this._r15Skeleton = null;
this._r15Animator = null;
this._isR15 = false;
this._modelKind = 'r15';
this._modelHipHeight = null;
this._nonHumanoidBox = null;
// 2) Сбросить AABB к дефолтному «человеку» (non-humanoid его переопределит).
this.HALF_W = 0.3;
this.HALF_H = 0.9;
this.HALF_D = 0.3;
this.HALF_H_NORMAL = 0.9;
this.EYE_HEIGHT = 0.7;
// 3) Поднять игрока на запас, чтобы новый AABB не оказался в полу.
this._pos.y += 0.5;
// 4) Загрузить новую модель.
this._modelTypeId = newType;
await this._loadPlayerModel();
return !!this._modelRoot;
}
/** /**
* Запустить режим игры. * Запустить режим игры.
* spawnPos точка спавна. Если не указано (0, 5, 0). * spawnPos точка спавна. Если не указано (0, 5, 0).
@ -336,10 +398,28 @@ export class PlayerController {
this._beforeRender = () => this._tick(); this._beforeRender = () => this._tick();
this.scene.registerBeforeRender(this._beforeRender); this.scene.registerBeforeRender(this._beforeRender);
// Сразу запросить Pointer Lock. Promise-форма (новые Chrome) может // === Задача 02: pointer-lock берём ТОЛЬКО в режимах с постоянным lock
// отклониться с SecurityError если предыдущий lock ещё не отпущен — // (first/lockfirst/sideview/shift-lock). В third курсор виден свободно —
// в этом случае ждём отпускания и пробуем снова. // кликает GUI и 3D-таблички, камера крутится только при зажатой ПКМ.
this._requestPointerLockSafe(); if (this._isPermaLockMode()) {
this._requestPointerLockSafe();
}
this._applyCursorVisibility();
}
/** Задача 02: режим с ПОСТОЯННЫМ pointer-lock (мышь всегда крутит камеру). */
_isPermaLockMode() {
return this._cameraMode === 'first' || this._cameraMode === 'lockfirst'
|| this._cameraMode === 'sideview' || this._shiftLock;
}
/** Задача 02: показать/скрыть курсор. В third виден (если нет lock), в
* first/lock скрыт. Учитывает game.input.setMouseIconVisible. */
_applyCursorVisibility() {
if (!this.canvas) return;
const locked = (document.pointerLockElement === this.canvas);
const show = (this._mouseIconVisible !== false) && !locked;
try { this.canvas.style.cursor = show ? '' : 'none'; } catch (e) { /* ignore */ }
} }
/** /**
@ -500,6 +580,10 @@ export class PlayerController {
setUiCursorMode(enabled) { setUiCursorMode(enabled) {
this._uiCursorMode = !!enabled; this._uiCursorMode = !!enabled;
if (enabled) { if (enabled) {
// Открываем UI (меню/курсор) → сбрасываем удержание ПКМ. Иначе если
// меню открыли при зажатой ПКМ, _rmbHeld застревает в true и orbit-
// камера после закрытия меню «думает», что ПКМ всё ещё активна.
this._rmbHeld = false;
// Освобождаем мышь // Освобождаем мышь
if (document.pointerLockElement === this.canvas) { if (document.pointerLockElement === this.canvas) {
try { document.exitPointerLock(); } catch (e) { /* ignore */ } try { document.exitPointerLock(); } catch (e) { /* ignore */ }
@ -573,45 +657,51 @@ export class PlayerController {
*/ */
async _loadSkinManifest() { async _loadSkinManifest() {
if (this._skinManifest) return this._skinManifest; if (this._skinManifest) return this._skinManifest;
// 2026-05-27: сначала пробуем БД (rublox_avatars), там и легаси и // ВАЖНО: объединяем ОБА источника, а не «или-или».
// дизайнерские аватары после approve. Только при сетевой ошибке — // Баг (2026-05-30): раньше при непустом /rublox/avatars возвращался
// fallback на статичный manifest.json. // ТОЛЬКО он, а статичный skins_manifest.json (где встроенные
// non-humanoid скины — еда/машины/животные: skin_burger, squirrel-donut
// и т.д.) НЕ подгружался. setSkin('burger') не находил entry → fallback
// на несуществующий characters/skin_burger/body.glb → 404 → краш GLTF
// (Unexpected magic) → старая модель уже выгружена, новая не создаётся →
// скин исчезал. Теперь грузим статичный манифест ВСЕГДА, плюс аватары.
let combined = [];
// 1) Статичный JSON (встроенные скины, включая non-humanoid).
try {
const resp = await fetch('/kubikon-assets/characters/skins_manifest.json');
if (resp.ok) {
const json = await resp.json();
if (Array.isArray(json.skins)) combined = combined.concat(json.skins);
}
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[PlayerController] skins_manifest load failed:', e);
}
// 2) БД rublox_avatars (легаси + дизайнерские аватары после approve).
try { try {
const resp = await fetch(_storysApiUrl('/rublox/avatars')); const resp = await fetch(_storysApiUrl('/rublox/avatars'));
if (resp.ok) { if (resp.ok) {
const json = await resp.json(); const json = await resp.json();
const items = json.items || []; const items = json.items || [];
// Нормализуем под формат старого manifest: // Нормализуем: file уже полный путь (absolute_file=true), т.к.
// {id, file (без /kubikon-assets/ префикса), overrides} // _resolveModelSource иначе добавляет '/kubikon-assets/' префикс.
// — потому что _resolveModelSource дальше добавляет const avatars = items.map((a) => ({
// '/kubikon-assets/' + entry.file.
// Дизайнерский file_path может быть /api-storys/... — оставляем
// как есть и добавляем спец-флаг entry.absolute_file=true,
// _resolveModelSource учтёт.
this._skinManifest = items.map((a) => ({
id: a.code, id: a.code,
name: a.name, name: a.name,
file: a.file_path, file: a.file_path,
overrides: a.overrides || {}, overrides: a.overrides || {},
absolute_file: true, // file уже полный путь, не resolve через /kubikon-assets/ absolute_file: true,
})); }));
if (this._skinManifest.length > 0) return this._skinManifest; // Аватары имеют приоритет при совпадении id — кладём в начало.
const avatarIds = new Set(avatars.map((a) => a.id));
combined = avatars.concat(combined.filter((s) => !avatarIds.has(s.id)));
} }
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn('[PlayerController] /rublox/avatars failed, fallback to manifest.json:', e); console.warn('[PlayerController] /rublox/avatars failed:', e);
} }
// Fallback на статичный JSON this._skinManifest = combined;
try { return combined;
const resp = await fetch('/kubikon-assets/characters/skins_manifest.json');
const json = await resp.json();
this._skinManifest = json.skins || [];
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[PlayerController] skins_manifest load failed:', e);
this._skinManifest = [];
}
return this._skinManifest;
} }
/** /**
@ -677,10 +767,34 @@ export class PlayerController {
// Прямой URL (для preview-режима или тестов). // Прямой URL (для preview-режима или тестов).
return { file: typeId, isR15: true, overrides: {} }; return { file: typeId, isR15: true, overrides: {} };
} }
// Кастомный .glb пользователя: 'customskin:<slug>'. dataUrl + метаданные
// (scale/hipHeight) лежат в scene._skinsConfig.customGlbs.
if (typeId.startsWith('customskin:')) {
const slug = typeId.slice('customskin:'.length);
const list = this._scene3d?._skinsConfig?.customGlbs || [];
const meta = list.find(g => g && g.slug === slug) || null;
const url = this._scene3d?.getAssetDataUrl?.(slug) || (meta && meta.dataUrl) || null;
if (url) {
return {
file: url, isR15: false, kind: 'non-humanoid-mesh', overrides: {},
scaleManifest: meta?.scale ?? 1.5,
hipHeight: meta?.hipHeight ?? 0.4,
rotationYOffset: meta?.rotationYOffset ?? 0,
isDataUrl: true,
};
}
return null;
}
if (typeId.startsWith('skin_')) { if (typeId.startsWith('skin_')) {
const manifest = await this._loadSkinManifest(); const manifest = await this._loadSkinManifest();
const entry = manifest.find((s) => s.id === typeId); const entry = manifest.find((s) => s.id === typeId);
if (entry) { if (entry) {
// kind определяет систему анимации:
// 'r15' → R15-скелет (как раньше)
// 'non-humanoid-mesh' → single-mesh, процедурное покачивание
// 'non-humanoid-rigged' → свой скелет + встроенные AnimationGroup
// Отсутствие kind = 'r15' (обратная совместимость со старым манифестом).
const kind = entry.kind || 'r15';
// absolute_file=true (источник /rublox/avatars) — file уже // absolute_file=true (источник /rublox/avatars) — file уже
// полный URL (legacy /kubikon-assets/... или дизайнерский // полный URL (legacy /kubikon-assets/... или дизайнерский
// /api-storys/...). Без флага — это легаси-формат // /api-storys/...). Без флага — это легаси-формат
@ -690,20 +804,25 @@ export class PlayerController {
: '/kubikon-assets/' + entry.file; : '/kubikon-assets/' + entry.file;
return { return {
file, file,
isR15: true, isR15: kind === 'r15',
kind,
overrides: entry.overrides || {}, overrides: entry.overrides || {},
scaleManifest: Number.isFinite(entry.scale) ? entry.scale : null,
hipHeight: Number.isFinite(entry.hipHeight) ? entry.hipHeight : null,
rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0,
}; };
} }
// нет в манифесте — пробуем прямой путь // нет в манифесте — пробуем прямой путь
return { return {
file: `/kubikon-assets/characters/${typeId}/body.glb`, file: `/kubikon-assets/characters/${typeId}/body.glb`,
isR15: true, isR15: true,
kind: 'r15',
overrides: {}, overrides: {},
}; };
} }
const modelType = getModelType(typeId); const modelType = getModelType(typeId);
if (!modelType) return null; if (!modelType) return null;
return { file: modelType.file, isR15: false, overrides: {} }; return { file: modelType.file, isR15: false, kind: 'r15', overrides: {} };
} }
/** Подгрузить metadata designer-аватара по id через api-storys. */ /** Подгрузить metadata designer-аватара по id через api-storys. */
@ -830,9 +949,17 @@ export class PlayerController {
absFile = 'https://minecraftia-school.ru' + absFile; absFile = 'https://minecraftia-school.ru' + absFile;
} }
} }
const lastSlash = absFile.lastIndexOf('/'); let rootUrl, filename;
const rootUrl = absFile.substring(0, lastSlash + 1); if (source.isDataUrl) {
const filename = absFile.substring(lastSlash + 1); // Кастомный скин — data:URL. SceneLoader принимает его как rootUrl=''
// и filename=data:... с подсказкой расширения через 5-й аргумент.
rootUrl = '';
filename = absFile;
} else {
const lastSlash = absFile.lastIndexOf('/');
rootUrl = absFile.substring(0, lastSlash + 1);
filename = absFile.substring(lastSlash + 1);
}
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`[PlayerController] SceneLoader.Load: ${rootUrl}${filename}`); console.log(`[PlayerController] SceneLoader.Load: ${rootUrl}${filename}`);
// Прогресс-индикатор для больших GLB (некоторые дизайнерские // Прогресс-индикатор для больших GLB (некоторые дизайнерские
@ -858,6 +985,7 @@ export class PlayerController {
try { try {
container = await SceneLoader.LoadAssetContainerAsync( container = await SceneLoader.LoadAssetContainerAsync(
rootUrl, filename, this.scene, onProgress, rootUrl, filename, this.scene, onProgress,
source.isDataUrl ? '.glb' : undefined,
); );
try { window.__playerLoadProgress = null; } catch (e) {} try { window.__playerLoadProgress = null; } catch (e) {}
} catch (e) { } catch (e) {
@ -880,10 +1008,20 @@ export class PlayerController {
// с торчащими волосами/плащами (как у bacon-hair). // с торчащими волосами/плащами (как у bacon-hair).
// - Kenney-модели: старый 0.72. // - Kenney-модели: старый 0.72.
// - overrides.scale_mult — per-skin множитель из манифеста. // - overrides.scale_mult — per-skin множитель из манифеста.
let modelScale = source.isR15 ? 0.301 : this._modelScale; // Non-humanoid скины (животное/машина/еда) масштабируются иначе:
const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0; // базовый размер из манифеста (scale), без фикс-0.301.
modelScale *= scaleMult; const isNonHumanoid = source.kind === 'non-humanoid-mesh'
|| source.kind === 'non-humanoid-rigged';
let modelScale;
if (isNonHumanoid) {
modelScale = Number.isFinite(source.scaleManifest) ? source.scaleManifest : 1.0;
} else {
modelScale = source.isR15 ? 0.301 : this._modelScale;
const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0;
modelScale *= scaleMult;
}
root.scaling = new Vector3(modelScale, modelScale, modelScale); root.scaling = new Vector3(modelScale, modelScale, modelScale);
if (source.rotationYOffset) root.rotation.y = source.rotationYOffset;
const inst = container.instantiateModelsToScene( const inst = container.instantiateModelsToScene(
(name) => `player_${name}`, (name) => `player_${name}`,
/*cloneAnimations*/ true, /*cloneAnimations*/ true,
@ -900,6 +1038,14 @@ export class PlayerController {
} }
} }
this._modelRoot = root; this._modelRoot = root;
this._modelKind = source.kind || 'r15';
// hipHeight: на сколько центр модели поднят от «низа ног».
this._modelHipHeight = Number.isFinite(source.hipHeight) ? source.hipHeight : null;
// Non-humanoid: нормализуем размер и опускаем модель на «ноги».
if (isNonHumanoid) {
this._setupNonHumanoidModel(root, modelScale, source);
}
// === R15-скин: детекция скелета === // === R15-скин: детекция скелета ===
// R15-скины приходят с встроенным скелетом Mixamo. Babylon // R15-скины приходят с встроенным скелетом Mixamo. Babylon
@ -1050,6 +1196,121 @@ export class PlayerController {
} }
} }
/**
* Настройка non-humanoid модели (животное/машина/еда): нормализация
* размера и опускание на «низ ног». В отличие от R15 (нормализованы
* пайплайном), эти модели произвольного размера, поэтому считаем bbox.
*
* Локальные координаты root: модель должна стоять так, чтобы её низ был
* на y=0 (там «ноги»). PlayerController позиционирует root в точке
* `_pos.y - HALF_H` (низ AABB) модель «садится» на землю.
*/
_setupNonHumanoidModel(root, scaleApplied, source) {
try {
// Считаем bounding box всех дочерних мешей в world-координатах ПОСЛЕ
// применения scaling root'а. Babylon refreshBoundingInfo нужен после
// инстансинга.
const meshes = root.getChildMeshes(false).filter(m => m.getBoundingInfo && m.getTotalVertices && m.getTotalVertices() > 0);
if (!meshes.length) return;
root.computeWorldMatrix(true);
let minY = Infinity, maxY = -Infinity, maxDim = 0;
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
for (const m of meshes) {
m.computeWorldMatrix(true);
// refreshBoundingInfo(true) — пересчитать bbox с учётом возможного
// скелета/морфов; без него minimumWorld у инстансов часто нулевой
// или из исходной позы → центр считался неверно (баг пришельца/робота).
try { m.refreshBoundingInfo(true); } catch (e) { try { m.refreshBoundingInfo(); } catch (e2) {} }
const bi = m.getBoundingInfo();
const bb = bi.boundingBox;
const lo = bb.minimumWorld, hi = bb.maximumWorld;
if (!lo || !hi) continue;
minY = Math.min(minY, lo.y); maxY = Math.max(maxY, hi.y);
minX = Math.min(minX, lo.x); maxX = Math.max(maxX, hi.x);
minZ = Math.min(minZ, lo.z); maxZ = Math.max(maxZ, hi.z);
}
if (!Number.isFinite(minX) || !Number.isFinite(minY)) return;
const h = maxY - minY;
const w = maxX - minX;
const d = maxZ - minZ;
maxDim = Math.max(h, w, d);
// === Центрирование модели через pivot-node ===
// Многие Kenney-модели имеют origin НЕ в геометрическом центре
// (в углу/ноге) → при повороте модель «облетает» вокруг смещённого
// origin (баг пришельца/робота). Ручной сдвиг детей с делением на
// scaleApplied неверен если у детей свой scale/rotation. Надёжно:
// вставляем промежуточный pivot между root и моделью и смещаем pivot
// на -localCenter (через инверсию world-матрицы root — точно при
// любом scale/rotation).
const worldCenter = new Vector3(
(minX + maxX) / 2, // центр X
minY, // низ Y (модель «садится» на ноги)
(minZ + maxZ) / 2 // центр Z
);
// world-центр → локальные координаты root
const invRoot = root.getWorldMatrix().clone().invert();
const localCenter = Vector3.TransformCoordinates(worldCenter, invRoot);
const pivot = new TransformNode('playerModelPivot', this.scene);
pivot.parent = root;
pivot.position.set(-localCenter.x, -localCenter.y, -localCenter.z);
// Перевешиваем все прямые дочерние ноды root (кроме самого pivot) на pivot.
for (const ch of root.getChildren().slice()) {
if (ch === pivot) continue;
ch.parent = pivot;
}
// Сохраняем размеры для настраиваемого AABB и камеры.
// hipHeight из манифеста — приоритетно; иначе берём низ модели.
this._nonHumanoidBox = { w, h, d };
this._modelBaseHeight = h;
// AABB подгоняем под модель (плоская/широкая для машин, узкая для еды).
// Ограничиваем разумными пределами чтобы не проваливаться/застревать.
this.HALF_W = Math.max(0.25, Math.min(0.9, w / 2));
this.HALF_D = Math.max(0.25, Math.min(1.1, d / 2));
const halfH = Math.max(0.3, Math.min(1.0, h / 2));
this.HALF_H = halfH;
this.HALF_H_NORMAL = halfH;
this.EYE_HEIGHT = halfH * 0.7;
// eslint-disable-next-line no-console
console.log('[PlayerController] non-humanoid setup:', this._modelTypeId,
'kind=' + source.kind, 'bbox(w/h/d)=' + w.toFixed(2) + '/' + h.toFixed(2) + '/' + d.toFixed(2),
'AABB(W/H/D)=' + this.HALF_W.toFixed(2) + '/' + this.HALF_H.toFixed(2) + '/' + this.HALF_D.toFixed(2));
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[PlayerController] _setupNonHumanoidModel failed:', e);
}
}
/**
* Процедурная анимация single-mesh скина (нет скелета нечего анимировать
* костями). Лёгкое покачивание вверх-вниз при движении + наклон вперёд при
* беге + наклон в воздухе. Вызывается каждый кадр из _tick.
* baseY локальная база модели (опущена на ноги в _setupNonHumanoidModel).
*/
_animateNonHumanoidMesh(dt) {
const root = this._modelRoot;
if (!root) return;
const t = (typeof performance !== 'undefined' && performance.now)
? performance.now() / 1000 : Date.now() / 1000;
const speed = this._lastFrameSpeed || 0;
// Базовое вращение по yaw уже выставляет _tick (он крутит модель под
// направление движения/камеры). Мы добавляем ТОЛЬКО процедурный bob/tilt
// поверх — храним их в отдельных полях, чтобы _tick их не перетёр.
let bobY = 0, tiltX = 0;
if (!this._isGrounded) {
tiltX = 0.2; // в воздухе — нос вверх
} else if (speed > 0.1) {
const bobFreq = 8 * Math.min(2, speed / 4);
bobY = Math.sin(t * bobFreq) * 0.06;
tiltX = Math.min(speed * 0.04, 0.13);
} else {
bobY = Math.sin(t * 1.5) * 0.015; // лёгкое «дыхание» в покое
}
// Применяем поверх позиции, которую _tick уже выставил в root.position.y.
root.position.y += bobY;
// tilt по локальной оси X модели. rotation.y уже выставлен _tick'ом.
root.rotation.x = tiltX;
}
// ─── Аксессуары (Подфаза 3.3 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md) ── // ─── Аксессуары (Подфаза 3.3 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md) ──
/** /**
@ -1562,6 +1823,143 @@ export class PlayerController {
this._cameraOverride = null; this._cameraOverride = null;
} }
// ===== Задача 14: вождение машины =====
enterVehicle(veh) {
if (!veh) return;
this._inVehicle = veh;
this._vehicleCamMode = 'follow';
veh.driver = 'player';
if (this._codes) this._codes.clear();
this._skinVisibleScripted = false;
this._startEngineSound();
}
// Звук мотора: низкочастотный РОКОТ (бас-пила + отфильтрованный шум +
// LFO-пульсация тактов), а не воющий тон. Парность со студией.
_startEngineSound() {
try {
if (!this._audioCtx) {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return;
this._audioCtx = new Ctx();
}
const ctx = this._audioCtx;
if (ctx.state === 'suspended') ctx.resume();
if (this._engineNodes) return;
const osc = ctx.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = 45;
const bufLen = ctx.sampleRate * 1.0;
const buf = ctx.createBuffer(1, bufLen, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < bufLen; i++) data[i] = (Math.random() * 2 - 1) * 0.6;
const noise = ctx.createBufferSource(); noise.buffer = buf; noise.loop = true;
const noiseLp = ctx.createBiquadFilter(); noiseLp.type = 'lowpass'; noiseLp.frequency.value = 180; noiseLp.Q.value = 0.7;
const noiseGain = ctx.createGain(); noiseGain.gain.value = 0.35;
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 350; lp.Q.value = 0.5;
const lfo = ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 12;
const lfoGain = ctx.createGain(); lfoGain.gain.value = 0.18;
const gain = ctx.createGain(); gain.gain.value = 0.05;
osc.connect(lp);
noise.connect(noiseLp); noiseLp.connect(noiseGain); noiseGain.connect(lp);
lp.connect(gain); gain.connect(ctx.destination);
lfo.connect(lfoGain); lfoGain.connect(gain.gain);
osc.start(); noise.start(); lfo.start();
this._engineNodes = { osc, noise, noiseLp, noiseGain, lp, lfo, lfoGain, gain };
} catch (e) {}
}
_updateEngineSound(speedMs, maxSpeed) {
const n = this._engineNodes; if (!n) return;
try {
const frac = Math.min(1, Math.abs(speedMs) / (maxSpeed || 14));
const ctx = this._audioCtx; const t = ctx.currentTime;
n.osc.frequency.setTargetAtTime(45 + frac * 50, t, 0.12);
n.lfo.frequency.setTargetAtTime(12 + frac * 33, t, 0.12);
n.lp.frequency.setTargetAtTime(300 + frac * 500, t, 0.12);
n.noiseLp.frequency.setTargetAtTime(150 + frac * 350, t, 0.12);
n.noiseGain.gain.setTargetAtTime(0.25 + frac * 0.35, t, 0.12);
n.gain.gain.setTargetAtTime(0.05 + frac * 0.08, t, 0.12);
} catch (e) {}
}
_stopEngineSound() {
const n = this._engineNodes; if (!n) return;
try {
const t = this._audioCtx.currentTime;
n.gain.gain.setTargetAtTime(0, t, 0.05);
n.osc.stop(t + 0.2); n.noise.stop(t + 0.2); n.lfo.stop(t + 0.2);
} catch (e) {}
this._engineNodes = null;
}
exitVehicle() {
const veh = this._inVehicle;
this._inVehicle = null;
if (veh) {
veh.driver = null;
try {
const side = new Vector3(Math.cos(veh.yaw), 0, -Math.sin(veh.yaw));
this._pos.set(veh.pos.x + side.x * (veh.half.w + 1.0), veh.pos.y + 1.0, veh.pos.z + side.z * (veh.half.w + 1.0));
this._vy = 0;
} catch (e) {}
}
this._stopEngineSound();
this._skinVisibleScripted = true;
if (this._modelMeshes) {
for (const m of this._modelMeshes) {
if (m && m.setEnabled) { try { m.setEnabled(true); } catch (e) {} }
}
}
}
cycleVehicleCamera() {
const modes = ['follow', 'hood', 'cinematic'];
const i = modes.indexOf(this._vehicleCamMode || 'follow');
this._vehicleCamMode = modes[(i + 1) % modes.length];
}
_tickVehicle(dt) {
const veh = this._inVehicle;
if (!veh || !this._scene3d?.vehicleManager) return;
if (this._modelMeshes) {
for (const m of this._modelMeshes) {
if (m && m.isEnabled && m.isEnabled() && m.setEnabled) { try { m.setEnabled(false); } catch (e) {} }
}
}
const c = this._codes;
const throttle = (c.has('KeyW') || c.has('ArrowUp') ? 1 : 0) - (c.has('KeyS') || c.has('ArrowDown') ? 1 : 0);
const steer = (c.has('KeyD') || c.has('ArrowRight') ? 1 : 0) - (c.has('KeyA') || c.has('ArrowLeft') ? 1 : 0);
const handbrake = c.has('Space');
this._scene3d.vehicleManager.setInput(veh, throttle, steer, handbrake);
const _vres = this._scene3d.vehicleManager.tickVehicle(veh, dt);
this._updateEngineSound(veh.speed, veh.params?.maxSpeed);
if (_vres && _vres.fellOut) {
this.exitVehicle();
if (this._onVehicleExit) { try { this._onVehicleExit(veh); } catch (e) {} }
const sp = this._scene3d?._spawnPoint || { x: 0, y: 5, z: 0 };
try { this._pos.set(sp.x, sp.y + 1.0, sp.z); this._vy = 0; } catch (e) {}
return;
}
try { this._pos.set(veh.pos.x, veh.pos.y + 1.0, veh.pos.z); } catch (e) {}
if (!this.camera) return;
const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw));
const cp = veh.pos;
const mode = this._vehicleCamMode || 'follow';
let camPos, camTarget;
if (mode === 'hood') {
camPos = new Vector3(cp.x + dir.x * (veh.half.d + 0.3), cp.y + veh.half.h + 0.6, cp.z + dir.z * (veh.half.d + 0.3));
camTarget = new Vector3(cp.x + dir.x * 8, cp.y + 0.5, cp.z + dir.z * 8);
} else if (mode === 'cinematic') {
const side = new Vector3(Math.cos(veh.yaw), 0, -Math.sin(veh.yaw));
camPos = new Vector3(cp.x + side.x * 7 + dir.x * 2, cp.y + 2.5, cp.z + side.z * 7 + dir.z * 2);
camTarget = new Vector3(cp.x, cp.y + 0.8, cp.z);
} else {
camPos = new Vector3(cp.x - dir.x * 8, cp.y + 3.2, cp.z - dir.z * 8);
camTarget = new Vector3(cp.x + dir.x * 2, cp.y + 1.0, cp.z + dir.z * 2);
}
const k = Math.min(1, dt * 6);
this.camera.position.set(
this.camera.position.x + (camPos.x - this.camera.position.x) * k,
this.camera.position.y + (camPos.y - this.camera.position.y) * k,
this.camera.position.z + (camPos.z - this.camera.position.z) * k,
);
try { this.camera.setTarget(camTarget); } catch (e) {}
}
/** Применить активный режим камеры скрипта (вызывается в _tick). */ /** Применить активный режим камеры скрипта (вызывается в _tick). */
_applyCameraOverride(dt) { _applyCameraOverride(dt) {
const o = this._cameraOverride; const o = this._cameraOverride;
@ -1795,13 +2193,189 @@ export class PlayerController {
this._applyCameraMode(); this._applyCameraMode();
} }
/** Включить/выключить Shift-Lock (Roblox-style: курсор зафиксирован, корпус
* всегда лицом к камере, камера через плечо).
*/
setShiftLock(on) {
this._shiftLock = !!on;
if (this._shiftLock) {
// Запросить pointer-lock — курсор в центре
this._requestPointerLockSafe();
} else {
// Снять lock если он есть и нет других причин держать (first/sideview)
const needPermLock = (
this._cameraMode === 'first' ||
this._cameraMode === 'lockfirst' ||
this._cameraMode === 'sideview'
);
if (!needPermLock && document.pointerLockElement === this.canvas) {
try { document.exitPointerLock(); } catch (e) {}
}
}
this._applyCursorVisibility?.();
}
isShiftLock() { return !!this._shiftLock; }
/** Задача 04: блокирует игровой ввод (WASD/Space/Ctrl/Shift/etc).
* Не блокирует Esc/Tab/Enter (нужны для GUI).
* Также сбрасывает накопленные клавиши чтобы движение остановилось. */
setInputBlocked(blocked) {
this._inputBlocked = !!blocked;
if (this._inputBlocked) {
try { this._codes?.clear(); } catch (e) {}
this._shift = false;
// Снимаем pointer-lock — иначе мышь застрянет «в режиме игры»
try {
if (document.pointerLockElement === this.canvas) document.exitPointerLock();
} catch (e) {}
}
}
isInputBlocked() { return !!this._inputBlocked; }
/** Задача 04: замораживает камеру (не вращается мышью, не зумится колесом). */
setCameraFrozen(frozen) {
this._cameraFrozen = !!frozen;
}
isCameraFrozen() { return !!this._cameraFrozen; }
/** Задача 04: снимок состояния камеры — для восстановления после модала. */
captureCameraState() {
return {
yaw: this._yaw,
pitch: this._pitch,
cameraMode: this._cameraMode,
thirdDistance: this._thirdDistance,
fov: this.scene?.activeCamera?.fov,
playerPos: this._pos ? {
x: this._pos.x, y: this._pos.y, z: this._pos.z
} : null,
};
}
/** Задача 04: восстановить состояние камеры из снимка. */
restoreCameraState(s) {
if (!s) return;
if (Number.isFinite(s.yaw)) this._yaw = s.yaw;
if (Number.isFinite(s.pitch)) this._pitch = s.pitch;
if (s.cameraMode) {
this._cameraMode = s.cameraMode;
try { this._applyCameraMode?.(); } catch (e) {}
}
if (Number.isFinite(s.thirdDistance)) this._thirdDistance = s.thirdDistance;
if (Number.isFinite(s.fov) && this.scene?.activeCamera) {
this.scene.activeCamera.fov = s.fov;
}
}
/** Задача 04: камера-фокус на reference (cube/npc/cam-target).
* ref может быть: строка-id, ref-объект ({kind, id}), Mesh, или {x,y,z}.
* Использует уже существующий механизм camera.focus в GameRuntime, но
* здесь упрощённая обёртка: ставим yaw/pitch так, чтобы смотреть на цель,
* и зум на distance. */
focusOnTarget(ref, opts) {
opts = opts || {};
const distance = Number.isFinite(opts.distance) ? opts.distance : 8;
const height = Number.isFinite(opts.height) ? opts.height : 3;
const fov = Number.isFinite(opts.fov) ? opts.fov : null;
let target = null;
if (ref && typeof ref === 'object' && 'x' in ref && 'y' in ref && 'z' in ref) {
target = ref;
} else {
const m = this._resolveTargetMesh(ref);
if (m) {
const p = m.getAbsolutePosition?.() || m.position;
target = { x: p.x, y: p.y, z: p.z };
}
}
if (!target) return;
// Прицельный взгляд: позиция камеры за игроком на distance, направление — на target
// Но мы во freezeCamera режиме — лучше через прямой override yaw/pitch.
if (!this._pos) return;
const dx = target.x - this._pos.x;
const dz = target.z - this._pos.z;
const dy = target.y - this._pos.y;
const horiz = Math.hypot(dx, dz);
this._yaw = Math.atan2(dx, dz);
this._pitch = Math.max(-1.4, Math.min(1.4, -Math.atan2(dy, horiz)));
this._thirdDistance = distance;
if (this._cameraMode !== 'third') {
this._cameraMode = 'third';
try { this._applyCameraMode?.(); } catch (e) {}
}
if (fov && this.scene?.activeCamera) {
this.scene.activeCamera.fov = fov * Math.PI / 180;
}
}
_resolveTargetMesh(ref) {
if (!ref) return null;
if (ref.getScene && typeof ref.getScene === 'function') return ref;
const sc = this._scene3d || this.scene3d;
const idStr = (typeof ref === 'string') ? ref : (ref.id || ref.refId || null);
if (!idStr || !sc) return null;
const tries = [
() => sc.primitiveManager?.getMesh?.(idStr),
() => sc.modelManager?.getInstanceMeshes?.(idStr)?.[0],
() => sc.scene?.getMeshByName?.(idStr),
() => sc.npcManager?.getMeshes?.(idStr)?.[0],
];
for (const fn of tries) {
try { const r = fn(); if (r) return r; } catch (e) {}
}
return null;
}
/** Прямо установить дистанцию камеры (для third). Кламп в min/max. */
setCameraZoom(distance) {
const d = Number(distance);
if (!Number.isFinite(d)) return;
this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN,
Math.min(this.THIRD_DISTANCE_MAX, d));
// Авто-переход third↔first если пересекли порог
if (this._thirdDistance <= this.FIRST_PERSON_ZOOM_THRESHOLD
&& this._cameraMode === 'third') {
this._cameraMode = 'first';
this._applyCameraMode?.();
this._requestPointerLockSafe();
} else if (this._thirdDistance > this.FIRST_PERSON_ZOOM_THRESHOLD
&& this._cameraMode === 'first' && !this._lockFirstPerson) {
this._cameraMode = 'third';
this._applyCameraMode?.();
if (!this._shiftLock && document.pointerLockElement === this.canvas) {
try { document.exitPointerLock(); } catch (e) {}
}
}
}
/** Установить границы зума колеса. */
setCameraZoomLimits(min, max) {
const mn = Number(min), mx = Number(max);
if (Number.isFinite(mn) && mn >= 0) this.THIRD_DISTANCE_MIN = mn;
if (Number.isFinite(mx) && mx > 0) this.THIRD_DISTANCE_MAX = mx;
// Перекламп текущей дистанции
this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN,
Math.min(this.THIRD_DISTANCE_MAX, this._thirdDistance));
}
_setupInput() { _setupInput() {
const canvas = this.canvas; const canvas = this.canvas;
// Задача 02 (как в студии): хелпер — режим с ПОСТОЯННЫМ pointer-lock.
const needPermLock = () => (
this._cameraMode === 'first' ||
this._cameraMode === 'lockfirst' ||
this._cameraMode === 'sideview' ||
this._shiftLock
);
const onCanvasClick = () => { const onCanvasClick = () => {
// В UI-режиме клик по канвасу НЕ перехватывает мышь // В UI-режиме клик не перехватывает мышь.
if (this._uiCursorMode) return; if (this._uiCursorMode) return;
if (this._active && document.pointerLockElement !== canvas) { if (!this._active) return;
// Roblox-style: в third-person ЛКМ-клик НЕ лочит курсор (он остаётся
// свободным для GUI/3D-onClick). Lock запрашиваем ТОЛЬКО для режимов
// где курсор постоянно скрыт, и только если lock был снят.
if (!needPermLock()) return;
if (document.pointerLockElement !== canvas) {
try { try {
const p = canvas.requestPointerLock?.(); const p = canvas.requestPointerLock?.();
if (p && typeof p.catch === 'function') p.catch(() => {}); if (p && typeof p.catch === 'function') p.catch(() => {});
@ -1810,6 +2384,34 @@ export class PlayerController {
}; };
canvas.addEventListener('click', onCanvasClick); canvas.addEventListener('click', onCanvasClick);
// === ПКМ: в third-person удержание ПКМ запускает orbit-камеру ===
// Зажал ПКМ → курсор скрыт, мышь крутит камеру. Отпустил → курсор вернулся.
const onCanvasMouseDownGlobal = (e) => {
if (!this._active || this._uiCursorMode) return;
if (e.button !== 2) return; // только ПКМ
if (needPermLock()) return; // в perma-режимах ПКМ ничего не делает
this._rmbHeld = true;
if (document.pointerLockElement !== canvas) {
try {
const p = canvas.requestPointerLock?.();
if (p && typeof p.catch === 'function') p.catch(() => {});
} catch (err) { /* ignore */ }
}
e.preventDefault();
};
const onWindowMouseUpGlobal = (e) => {
if (e.button !== 2) return;
if (!this._rmbHeld) return;
this._rmbHeld = false;
if (needPermLock()) return;
if (document.pointerLockElement === canvas) {
try { document.exitPointerLock(); } catch (err) { /* ignore */ }
}
};
canvas.addEventListener('mousedown', onCanvasMouseDownGlobal);
window.addEventListener('mouseup', onWindowMouseUpGlobal);
canvas.addEventListener('contextmenu', (e) => { if (this._active) e.preventDefault(); });
// === UI-режим: mousedown / mouseup → callback (для drag-игр) === // === UI-режим: mousedown / mouseup → callback (для drag-игр) ===
const onCanvasMouseDown = (e) => { const onCanvasMouseDown = (e) => {
if (!this._uiCursorMode) return; if (!this._uiCursorMode) return;
@ -1849,6 +2451,8 @@ export class PlayerController {
if (document.pointerLockElement !== canvas) return; if (document.pointerLockElement !== canvas) return;
// Кубикон Dash: в sideview мышь не вращает камеру. // Кубикон Dash: в sideview мышь не вращает камеру.
if (this._cameraMode === 'sideview') return; if (this._cameraMode === 'sideview') return;
// Задача 04: модал с freezeCamera — мышь не вращает.
if (this._cameraFrozen) return;
this._yaw += e.movementX * this.MOUSE_SENSITIVITY; this._yaw += e.movementX * this.MOUSE_SENSITIVITY;
// _invertCamera (GameMenu Настройки → Инвертировать) — флипает Y. // _invertCamera (GameMenu Настройки → Инвертировать) — флипает Y.
const pitchSign = this._invertCamera ? -1 : 1; const pitchSign = this._invertCamera ? -1 : 1;
@ -1859,13 +2463,38 @@ export class PlayerController {
}; };
document.addEventListener('mousemove', onMouseMove); document.addEventListener('mousemove', onMouseMove);
// Колесо в 3rd-person — меняет дистанцию // Задача 02: колесо = зум third-камеры с авто-переходом third↔first.
const onWheel = (e) => { const onWheel = (e) => {
if (!this._active) return; if (!this._active) return;
if (this._cameraMode !== 'third') return; if (this._cameraFrozen) { e.preventDefault(); return; } // модал
this._thirdDistance += Math.sign(e.deltaY) * 0.5; if (this._cameraMode === 'sideview') { e.preventDefault(); return; } // GD
// В first зум наружу возвращает в third (если не lockfirst).
if (this._cameraMode === 'first') {
if (e.deltaY > 0 && !this._lockFirstPerson) {
this._cameraMode = 'third';
this._thirdDistance = (this.FIRST_PERSON_ZOOM_THRESHOLD || 0.7) + 0.5;
if (!this._isPermaLockMode() && document.pointerLockElement === canvas) {
try { document.exitPointerLock(); } catch (err) { /* ignore */ }
}
this._applyCursorVisibility();
this._applyCameraMode?.();
}
e.preventDefault();
return;
}
if (this._cameraMode !== 'third') { e.preventDefault(); return; }
// Экспоненциальный шаг (плавнее вблизи).
this._thirdDistance += Math.sign(e.deltaY) * Math.max(0.3, this._thirdDistance * 0.15);
if (this._thirdDistance < this.THIRD_DISTANCE_MIN) this._thirdDistance = this.THIRD_DISTANCE_MIN; if (this._thirdDistance < this.THIRD_DISTANCE_MIN) this._thirdDistance = this.THIRD_DISTANCE_MIN;
if (this._thirdDistance > this.THIRD_DISTANCE_MAX) this._thirdDistance = this.THIRD_DISTANCE_MAX; if (this._thirdDistance > this.THIRD_DISTANCE_MAX) this._thirdDistance = this.THIRD_DISTANCE_MAX;
// Зум внутрь до порога → авто-переход в first (Roblox-style).
const THRESH = this.FIRST_PERSON_ZOOM_THRESHOLD || 0.7;
if (this._thirdDistance <= THRESH) {
this._cameraMode = 'first';
this._requestPointerLockSafe();
this._applyCursorVisibility();
this._applyCameraMode?.();
}
e.preventDefault(); e.preventDefault();
}; };
canvas.addEventListener('wheel', onWheel, { passive: false }); canvas.addEventListener('wheel', onWheel, { passive: false });
@ -1873,12 +2502,26 @@ export class PlayerController {
let wasLocked = false; let wasLocked = false;
const onPointerLockChange = () => { const onPointerLockChange = () => {
const locked = document.pointerLockElement === canvas; const locked = document.pointerLockElement === canvas;
this._applyCursorVisibility?.(); // задача 02: вернуть/скрыть курсор
if (locked) { if (locked) {
wasLocked = true; wasLocked = true;
this._rmbHeld = true; // если попали в lock — ПКМ удерживается
} else if (wasLocked && this._active) { } else if (wasLocked && this._active) {
// Если мы САМИ переключились в UI-cursor mode — не выходим из Play // pointer-lock снят. Причин три:
if (this._uiCursorMode) return; // 1) пользователь в UI-режиме (game.input.setCursorMode('ui'))
if (this._onExitRequest) this._onExitRequest(); // 2) ПКМ отпущена в third-person (orbit-камера завершена)
// 3) Esc → выход из Play (если был в first/lockfirst/sideview/shift)
wasLocked = false;
this._rmbHeld = false;
if (this._uiCursorMode) { this._applyCursorVisibility?.(); return; }
if (needPermLock()) {
// Был режим с постоянным lock'ом и его сняли (Esc) → выход.
if (this._onExitRequest) this._onExitRequest();
} else {
// Third-person: просто отпустили ПКМ. Остаёмся в Play,
// курсор вернулся — это НЕ повод открывать меню.
this._applyCursorVisibility?.();
}
} }
}; };
document.addEventListener('pointerlockchange', onPointerLockChange); document.addEventListener('pointerlockchange', onPointerLockChange);
@ -1892,7 +2535,33 @@ export class PlayerController {
const onKeyDown = (e) => { const onKeyDown = (e) => {
if (!this._active) return; if (!this._active) return;
if (isTypingTarget(e.target)) return; if (isTypingTarget(e.target)) return;
// Задача 04: Esc в Play — приоритет закрытие модала через _onExitRequest
// (там стоит проверка modalManager.isOpen). Без явного перехвата Esc
// в third (без pointer-lock) сразу выходил из Play.
if (e.code === 'Escape') {
if (this._onExitRequest) {
this._onExitRequest();
return;
}
}
// Задача 04: модал блокирует игровой ввод (WASD/Space/Ctrl/Shift и т.п.),
// но НЕ блокирует Esc (нужен для закрытия модала через Esc-обработчик),
// и НЕ блокирует Tab/Enter (могут понадобиться в GUI-модалах).
if (this._inputBlocked && e.code !== 'Escape' && e.code !== 'Tab' && e.code !== 'Enter') {
// Глотаем preventDefault только для игровых клавиш
if (['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
return;
}
this._codes.add(e.code); this._codes.add(e.code);
// Задача 14: в машине — V камера, E выход.
if (this._inVehicle) {
if (e.code === 'KeyV') { this.cycleVehicleCamera(); }
else if (e.code === 'KeyE') {
const veh = this._inVehicle;
this.exitVehicle();
if (this._onVehicleExit) { try { this._onVehicleExit(veh); } catch (err) {} }
}
}
if (e.shiftKey) this._shift = true; if (e.shiftKey) this._shift = true;
// C — переключение first/third. Отключаем в GD-режиме (автобег > 0) // C — переключение first/third. Отключаем в GD-режиме (автобег > 0)
// и при любом активном GD-гейммоде, потому что ломает физику auto-run + sideview. // и при любом активном GD-гейммоде, потому что ломает физику auto-run + sideview.
@ -1901,6 +2570,17 @@ export class PlayerController {
|| this._shipMode || this._ufoMode || this._waveMode || this._robotMode; || this._shipMode || this._ufoMode || this._waveMode || this._robotMode;
if (!inGdMode) this._toggleCameraMode(); if (!inGdMode) this._toggleCameraMode();
} }
// L — Shift-Lock (Roblox по умолчанию на Shift, у нас Shift = бег,
// поэтому переназначено на L). Курсор центрируется, корпус всегда
// лицом к камере, камера через плечо.
if (e.code === 'KeyL') {
this.setShiftLock(!this._shiftLock);
}
// B — встроенный магазин скинов (задача 07). Открывается только если
// включён в проекте (scene.skins.shopVisible). Toggle.
if (e.code === 'KeyB' && !this._inputBlocked) {
try { this._scene3d?.toggleSkinShop?.(); } catch (err) {}
}
// Tab — переключить «UI-режим курсора» (для кликов по GUI в Play) // Tab — переключить «UI-режим курсора» (для кликов по GUI в Play)
if (e.code === 'Tab') { if (e.code === 'Tab') {
e.preventDefault(); e.preventDefault();
@ -1953,6 +2633,12 @@ export class PlayerController {
if (dt <= 0) return; if (dt <= 0) return;
if (dt > 0.1) dt = 0.1; if (dt > 0.1) dt = 0.1;
// === Задача 14: режим вождения — инпут идёт в машину, не в ходьбу ===
if (this._inVehicle) {
try { this._tickVehicle(dt); } catch (e) { /* ignore */ }
return;
}
// === Присед: по Ctrl на десктопе, или через мобильную кнопку // === Присед: по Ctrl на десктопе, или через мобильную кнопку
// (которая шлёт keydown 'ControlLeft'). C — НЕ используется // (которая шлёт keydown 'ControlLeft'). C — НЕ используется
// (это смена вида в Babylon). // (это смена вида в Babylon).
@ -2496,6 +3182,17 @@ export class PlayerController {
this._tickDebris(dt); this._tickDebris(dt);
// === Анимации === // === Анимации ===
// Снимок скорости/опоры для процедурной анимации non-humanoid скинов.
this._lastFrameSpeed = (isMoving ? (isSprinting ? this.WALK_SPEED * this.SPRINT_MULT : this.WALK_SPEED) : 0) * (this._speedMul || 1);
this._isGrounded = !!result.onGround;
// Non-humanoid single-mesh скин: костей нет — анимируем процедурно
// (покачивание/наклон). Делаем ДО R15-ветки, т.к. _isR15=false для них.
if (this._modelKind === 'non-humanoid-mesh' && this._modelRoot) {
this._animateNonHumanoidMesh(dt);
return;
}
// R15-скин: процедурный аниматор (нет glTF AnimationGroups). // R15-скин: процедурный аниматор (нет glTF AnimationGroups).
// Состояния: idle/walk/run/jump/fall. sprint → run. // Состояния: idle/walk/run/jump/fall. sprint → run.
if (this._isR15 && this._r15Animator) { if (this._isR15 && this._r15Animator) {

View File

@ -19,7 +19,7 @@
* При касании игроком обновляет spawnPoint сцены. * При касании игроком обновляет spawnPoint сцены.
*/ */
import { import {
MeshBuilder, StandardMaterial, Color3, Vector3, PointLight, MeshBuilder, StandardMaterial, Color3, Vector3, Vector4, PointLight,
Mesh, VertexData, Mesh, VertexData,
} from '@babylonjs/core'; } from '@babylonjs/core';
// CRA→Vite адаптация: оригинальный код в setTexture()/_applyMaterial()/ // CRA→Vite адаптация: оригинальный код в setTexture()/_applyMaterial()/
@ -33,6 +33,57 @@ import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTextur
import { Texture } from '@babylonjs/core/Materials/Textures/texture'; import { Texture } from '@babylonjs/core/Materials/Textures/texture';
import { getPrimitiveType } from './PrimitiveTypes'; import { getPrimitiveType } from './PrimitiveTypes';
// === Материал «studs» (лего-кружки, задача 09) — паритет со студией ===
const STUDS_DIFFUSE_URL = '/kubikon-assets/materials/studs_v4_diffuse.png';
const STUDS_NORMAL_URL = '/kubikon-assets/materials/studs_v4_normal.png';
const STUD_UNIT = 1;
const STUDS_GRID = 4;
const _studsTexCache = new WeakMap();
function _getStudsTextures(scene) {
let c = _studsTexCache.get(scene);
if (!c) {
c = { diffuse: new Texture(STUDS_DIFFUSE_URL, scene), normal: new Texture(STUDS_NORMAL_URL, scene) };
_studsTexCache.set(scene, c);
}
return c;
}
function _studsTiling(type, sx, sy, sz, density) {
// density — множитель плотности кружков (1=стандарт, 2=вдвое мельче/чаще).
const d = density && density > 0 ? density : 1;
const f = (STUD_UNIT * STUDS_GRID) / d;
let u = Math.max(sx, sz) / f;
let v = sy / f;
if (type === 'cylinder') { u = (Math.PI * sx) / f; v = sy / f; }
else if (type === 'sphere') { u = (Math.PI * sx) / f; v = (Math.PI * sy) / f; }
else if (type === 'plane') { u = sx / f; v = sz / f; }
return { u: Math.max(0.25, u), v: Math.max(0.25, v) };
}
/**
* faceUV для куба со studs КАЖДАЯ грань тайлится по СВОИМ реальным размерам,
* чтобы кружки были одного размера на всех гранях (не растягивались на длинных).
* Грани CreateBox: 0=front(z-) 1=back(z+) 2=right(x+) 3=left(x-) 4=top(y+) 5=bottom(y-).
* front/back ширина=sx, высота=sy
* left/right ширина=sz, высота=sy
* top/bottom ширина=sx, высота=sz
* UV-диапазон грани = (0,0)..(кол-во_studs_по_ширине, кол-во_по_высоте).
*/
function _studsCubeFaceUV(sx, sy, sz, density) {
const d = density && density > 0 ? density : 1;
const f = (STUD_UNIT * STUDS_GRID) / d;
const nx = Math.max(0.25, sx / f); // studs вдоль X
const ny = Math.max(0.25, sy / f); // studs вдоль Y
const nz = Math.max(0.25, sz / f); // studs вдоль Z
// Vector4(u0, v0, u1, v1)
return [
new Vector4(0, 0, nx, ny), // front (z-): X×Y
new Vector4(0, 0, nx, ny), // back (z+): X×Y
new Vector4(0, 0, nz, ny), // right (x+): Z×Y
new Vector4(0, 0, nz, ny), // left (x-): Z×Y
new Vector4(0, 0, nx, nz), // top (y+): X×Z
new Vector4(0, 0, nx, nz), // bottom (y-): X×Z
];
}
export class PrimitiveManager { export class PrimitiveManager {
constructor(scene) { constructor(scene) {
this.scene = scene; this.scene = scene;
@ -73,6 +124,8 @@ export class PrimitiveManager {
const isGlowingGd = isGdKind; const isGlowingGd = isGdKind;
const isGdSpike = typeDef.kind === 'gd_spike'; const isGdSpike = typeDef.kind === 'gd_spike';
const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte'); const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte');
// studDensity — плотность кружков studs (1=стандарт, >1 мельче/чаще).
const studDensity = Number.isFinite(opts.studDensity) ? opts.studDensity : 1;
// canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции) // canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции)
const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike; const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike;
const visible = opts.visible !== false; const visible = opts.visible !== false;
@ -90,7 +143,7 @@ export class PrimitiveManager {
const rotationY = opts.rotationY ?? 0; const rotationY = opts.rotationY ?? 0;
const rotationZ = opts.rotationZ ?? 0; const rotationZ = opts.rotationZ ?? 0;
const mesh = this._createMeshForType(typeDef, id, sx, sy, sz); const mesh = this._createMeshForType(typeDef, id, sx, sy, sz, material, studDensity);
mesh.position = new Vector3(x, y, z); mesh.position = new Vector3(x, y, z);
mesh.rotation = new Vector3(rotationX, rotationY, rotationZ); mesh.rotation = new Vector3(rotationX, rotationY, rotationZ);
mesh.isPickable = true; mesh.isPickable = true;
@ -103,6 +156,7 @@ export class PrimitiveManager {
primitiveId: id, primitiveId: id,
primitiveType: type, primitiveType: type,
primitiveKind: typeDef.kind, primitiveKind: typeDef.kind,
canCollide, // нужен camera-clamp: камера не цепляется за зоны canCollide:false
}; };
// textureAsset — id картинки из AssetManager (пользовательская // textureAsset — id картинки из AssetManager (пользовательская
@ -114,13 +168,15 @@ export class PrimitiveManager {
id, mesh, type, x, y, z, sx, sy, sz, id, mesh, type, x, y, z, sx, sy, sz,
rotationX, rotationY, rotationZ, rotationX, rotationY, rotationZ,
color, material, canCollide, visible, anchored, mass, color, material, canCollide, visible, anchored, mass,
textureAsset, textureAsset, studDensity,
// locked — объект защищён от выделения/перемещения в редакторе // locked — объект защищён от выделения/перемещения в редакторе
// (Фаза 5.11). На геймплей не влияет. // (Фаза 5.11). На геймплей не влияет.
locked: opts.locked === true, locked: opts.locked === true,
name: opts.name || null, name: opts.name || null,
folderId: opts.folderId ?? null, folderId: opts.folderId ?? null,
}; };
// Размеры для тайлинга studs-материала (читается в _applyMaterial).
mesh._studsDims = { type, sx, sy, sz, density: studDensity };
this._applyMaterial(mesh, typeDef, color, material); this._applyMaterial(mesh, typeDef, color, material);
this._applyVisible(mesh, visible, typeDef); this._applyVisible(mesh, visible, typeDef);
// Пользовательская текстура — поверх базового материала. // Пользовательская текстура — поверх базового материала.
@ -156,6 +212,21 @@ export class PrimitiveManager {
} }
} }
// === 3D-табличка (billboard): натягиваем DynamicTexture с GUI ===
if (typeDef.kind === 'billboard' && this.billboardUiManager) {
// Сохраняем настройки билборда в data.billboardOpts чтобы
// serialize мог записать их обратно в JSON проекта.
const billboardOpts = {
template: opts.template || 'shop-item',
face: opts.face || 'fixed',
content: opts.content || null,
elements: opts.elements || null,
rotationY: opts.rotationY,
};
this.billboardUiManager.applyToMesh(data, billboardOpts);
// billboardOpts хранится в data.billboard после applyToMesh.
}
this.instances.set(id, data); this.instances.set(id, data);
// Авто-регистрация в shadow casters (Этап 4 теней). // Авто-регистрация в shadow casters (Этап 4 теней).
// Когда скрипт спавнит новый объект через scene.spawn(...) — раньше он // Когда скрипт спавнит новый объект через scene.spawn(...) — раньше он
@ -170,13 +241,17 @@ export class PrimitiveManager {
} }
/** Создать базовый mesh нужной формы (без материала). */ /** Создать базовый mesh нужной формы (без материала). */
_createMeshForType(typeDef, id, sx, sy, sz) { _createMeshForType(typeDef, id, sx, sy, sz, material, studDensity) {
const name = `prim_${typeDef.id}_${id}`; const name = `prim_${typeDef.id}_${id}`;
switch (typeDef.id) { switch (typeDef.id) {
case 'cube': case 'cube':
case 'trigger': case 'trigger': {
return MeshBuilder.CreateBox(name, const boxOpts = { width: sx, height: sy, depth: sz };
{ width: sx, height: sy, depth: sz }, this.scene); // studs — per-face UV, чтобы кружки были одного размера на всех
// гранях (не растягивались на длинной стороне).
if (material === 'studs') boxOpts.faceUV = _studsCubeFaceUV(sx, sy, sz, studDensity);
return MeshBuilder.CreateBox(name, boxOpts, this.scene);
}
case 'sphere': case 'sphere':
return MeshBuilder.CreateSphere(name, return MeshBuilder.CreateSphere(name,
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene); { diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene);
@ -210,6 +285,16 @@ export class PrimitiveManager {
// создаются отдельно в addInstance. // создаются отдельно в addInstance.
return MeshBuilder.CreateSphere(name, return MeshBuilder.CreateSphere(name,
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 12 }, this.scene); { diameterX: sx, diameterY: sy, diameterZ: sz, segments: 12 }, this.scene);
case 'billboard': {
// 3D-табличка — плоскость с пропорциями таблички (sx × sy),
// sz — толщина рамки (визуально-незаметная).
// ВАЖНО: FRONTSIDE, не DOUBLESIDE — иначе сквозь back-side
// видно зеркальную сторону UV (текст справа-налево).
// BillboardMode разворачивает FRONT к камере.
const m = MeshBuilder.CreatePlane(name,
{ width: sx, height: sy, sideOrientation: Mesh.FRONTSIDE }, this.scene);
return m;
}
case 'plane': case 'plane':
return MeshBuilder.CreateBox(name, return MeshBuilder.CreateBox(name,
{ width: sx, height: sy, depth: sz }, this.scene); { width: sx, height: sy, depth: sz }, this.scene);
@ -400,12 +485,65 @@ export class PrimitiveManager {
break; break;
case 'glass': case 'glass':
mat.alpha = 0.4; mat.alpha = 0.4;
mat.specularColor = new Color3(0.5, 0.5, 0.5); mat.specularColor = new Color3(0.8, 0.85, 0.9);
mat.specularPower = 96;
mat.backFaceCulling = false;
break; break;
case 'neon': case 'neon':
mat.emissiveColor = Color3.FromHexString(color || '#888888'); mat.emissiveColor = Color3.FromHexString(color || '#888888');
mat.specularColor = new Color3(0, 0, 0); mat.specularColor = new Color3(0, 0, 0);
break; break;
case 'chrome': {
const cc = Color3.FromHexString(color || '#cfd6e0');
mat.diffuseColor = new Color3(cc.r * 0.6, cc.g * 0.6, cc.b * 0.6);
mat.specularColor = new Color3(1, 1, 1);
mat.specularPower = 128;
mat.emissiveColor = new Color3(cc.r * 0.12, cc.g * 0.12, cc.b * 0.14);
break;
}
case 'water': {
const wc = Color3.FromHexString(color || '#3aa0ff');
mat.diffuseColor = wc;
mat.alpha = 0.55;
mat.specularColor = new Color3(0.9, 0.95, 1.0);
mat.specularPower = 64;
mat.emissiveColor = new Color3(wc.r * 0.1, wc.g * 0.14, wc.b * 0.2);
mesh._isWater = true;
break;
}
case 'iridescent': {
const ic = Color3.FromHexString(color || '#a06bff');
mat.diffuseColor = ic;
mat.emissiveColor = new Color3(ic.r * 0.5, ic.g * 0.35, ic.b * 0.6);
mat.specularColor = new Color3(1, 1, 1);
mat.specularPower = 96;
break;
}
case 'studs': {
// Лего-материал (паритет со студией): почти-белая diffuse × цвет
// меша + normal map. emissive = доля цвета → сочность (Roblox-look).
const tex = _getStudsTextures(this.scene);
const dt = tex.diffuse.clone();
const nt = tex.normal.clone();
const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 };
// Куб/триггер тайлятся через faceUV геометрии (uScale=1) — кружки
// одного размера на всех гранях. Остальные формы — через uScale.
if (dims.type === 'cube' || dims.type === 'trigger') {
dt.uScale = nt.uScale = 1;
dt.vScale = nt.vScale = 1;
} else {
const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz, dims.density);
dt.uScale = nt.uScale = tile.u;
dt.vScale = nt.vScale = tile.v;
}
mat.diffuseTexture = dt;
mat.bumpTexture = nt;
const sc = Color3.FromHexString(color || '#cccccc');
mat.diffuseColor = sc;
mat.emissiveColor = new Color3(sc.r * 0.45, sc.g * 0.45, sc.b * 0.45);
mat.specularColor = new Color3(0, 0, 0);
break;
}
case 'matte': case 'matte':
default: default:
mat.specularColor = new Color3(0, 0, 0); mat.specularColor = new Color3(0, 0, 0);
@ -551,6 +689,12 @@ export class PrimitiveManager {
if (patch.sx !== undefined) { data.sx = patch.sx; scaleChanged = true; } if (patch.sx !== undefined) { data.sx = patch.sx; scaleChanged = true; }
if (patch.sy !== undefined) { data.sy = patch.sy; scaleChanged = true; } if (patch.sy !== undefined) { data.sy = patch.sy; scaleChanged = true; }
if (patch.sz !== undefined) { data.sz = patch.sz; scaleChanged = true; } if (patch.sz !== undefined) { data.sz = patch.sz; scaleChanged = true; }
// Плотность studs (мелкие/крупные кружки) — требует пересоздания меша
// (faceUV для куба зашит в геометрию).
if (patch.studDensity !== undefined) {
data.studDensity = Number.isFinite(patch.studDensity) ? patch.studDensity : 1;
scaleChanged = true;
}
if (scaleChanged) { if (scaleChanged) {
// Поскольку MeshBuilder уже создал mesh с базовыми размерами, // Поскольку MeshBuilder уже создал mesh с базовыми размерами,
// изменения через scaling кажутся правильными. Простой способ — // изменения через scaling кажутся правильными. Простой способ —
@ -576,6 +720,7 @@ export class PrimitiveManager {
if (data.mesh.material) { if (data.mesh.material) {
try { data.mesh.material.dispose(); } catch (e) { /* ignore */ } try { data.mesh.material.dispose(); } catch (e) { /* ignore */ }
} }
data.mesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz };
this._applyMaterial(data.mesh, typeDef, data.color, data.material); this._applyMaterial(data.mesh, typeDef, data.color, data.material);
} }
// Текстуру переприменяем если: сменили саму текстуру, или // Текстуру переприменяем если: сменили саму текстуру, или
@ -589,10 +734,14 @@ export class PrimitiveManager {
if (data.mesh.material) { if (data.mesh.material) {
try { data.mesh.material.dispose(); } catch (e) { /* ignore */ } try { data.mesh.material.dispose(); } catch (e) { /* ignore */ }
} }
data.mesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz };
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;
@ -607,6 +756,11 @@ export class PrimitiveManager {
} }
} }
// Billboard: пересоздать GUI-текстуру при изменении template/content/face/elements
if (patch.billboardOpts && this.billboardUiManager && data.type === 'billboard') {
this.billboardUiManager.applyToMesh(data, patch.billboardOpts);
}
// === Лампа: синхронизируем привязанный PointLight === // === Лампа: синхронизируем привязанный PointLight ===
if (data.light) { if (data.light) {
// позиция света — за маркером // позиция света — за маркером
@ -674,10 +828,17 @@ export class PrimitiveManager {
const oldMat = oldMesh.material; const oldMat = oldMesh.material;
const typeDef = getPrimitiveType(data.type); const typeDef = getPrimitiveType(data.type);
const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz); const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz, data.material, data.studDensity);
newMesh.position = oldPos; newMesh.position = oldPos;
if (oldRot) newMesh.rotation = oldRot; if (oldRot) newMesh.rotation = oldRot;
newMesh.material = oldMat; // studs — материал пересоздаём заново (свежий faceUV/тайлинг). Иначе перенос.
if (data.material === 'studs') {
newMesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz, density: data.studDensity };
this._applyMaterial(newMesh, typeDef, data.color, data.material);
try { oldMat?.dispose(); } catch (e) { /* ignore */ }
} else {
newMesh.material = oldMat;
}
newMesh.isPickable = true; newMesh.isPickable = true;
newMesh.metadata = { ...oldMesh.metadata }; newMesh.metadata = { ...oldMesh.metadata };
newMesh.setEnabled(data.visible); newMesh.setEnabled(data.visible);
@ -687,6 +848,7 @@ export class PrimitiveManager {
catch (e) { /* ignore */ } catch (e) { /* ignore */ }
data.mesh = newMesh; data.mesh = newMesh;
// _studsDims и материал studs уже выставлены выше.
} }
/** Удалить инстанс. */ /** Удалить инстанс. */
@ -731,14 +893,24 @@ export class PrimitiveManager {
anchored: d.anchored, anchored: d.anchored,
mass: d.mass, mass: d.mass,
name: d.name || null, name: d.name || null,
...(d.folderId != null ? { folderId: d.folderId } : {}), // папка (парность со студией)
// locked — защита от выделения/перемещения (Фаза 5.11). // locked — защита от выделения/перемещения (Фаза 5.11).
...(d.locked ? { locked: true } : {}), ...(d.locked ? { locked: true } : {}),
// id пользовательской текстуры (картинка из AssetManager). // id пользовательской текстуры (картинка из AssetManager).
...(d.textureAsset ? { textureAsset: d.textureAsset } : {}), ...(d.textureAsset ? { textureAsset: d.textureAsset } : {}),
// Плотность studs (если не 1) — мелкие/крупные кружки.
...(d.studDensity && d.studDensity !== 1 ? { studDensity: d.studDensity } : {}),
// Параметры лампы (только для type='light', иначе undefined) // Параметры лампы (только для type='light', иначе undefined)
...(d.light ? { brightness: d.brightness, range: d.range } : {}), ...(d.light ? { brightness: d.brightness, range: d.range } : {}),
// Параметр эмиттера (только для type='emitter') // Параметр эмиттера (только для type='emitter')
...(d.effect !== undefined ? { effect: d.effect } : {}), ...(d.effect !== undefined ? { effect: d.effect } : {}),
// Параметры билборда (только для type='billboard')
...(d.billboard ? {
template: d.billboard.template,
face: d.billboard.face,
content: d.billboard.content,
...(d.billboard.elements ? { elements: d.billboard.elements } : {}),
} : {}),
})); }));
} }

View File

@ -57,6 +57,15 @@ export const PRIMITIVE_TYPES = [
{ id: 'emitter', name: 'Эмиттер частиц', icon: 'prim-emitter', kind: 'emitter', { id: 'emitter', name: 'Эмиттер частиц', icon: 'prim-emitter', kind: 'emitter',
defaultScale: { x: 0.4, y: 0.4, z: 0.4 }, defaultColor: '#ff8833' }, defaultScale: { x: 0.4, y: 0.4, z: 0.4 }, defaultColor: '#ff8833' },
// === Табличка — 3D-карточка с GUI (как BillboardGui в Roblox) ===
// Плоскость с натянутой DynamicTexture, на которой рендерится контент
// (заголовок, иконка, кнопка). Поддерживает 4 пресета (см. BillboardUiManager):
// shop-item, shop-purchase, banner, sign. Может смотреть на камеру
// (face=camera) или быть фиксированной (face=fixed). Клик по кнопке
// эмитит событие через game.billboard.onClick.
{ id: 'billboard', name: '3D-табличка', icon: 'prim-billboard', kind: 'billboard',
defaultScale: { x: 2, y: 1.2, z: 0.05 }, defaultColor: '#1a1a2e' },
// === GD-порталы (Geometry Dash 2.0) — переключают гейммод игрока === // === GD-порталы (Geometry Dash 2.0) — переключают гейммод игрока ===
// Все цилиндры-«столбы» с neon-материалом. Цвета и gdMode задают режим. // Все цилиндры-«столбы» с neon-материалом. Цвета и gdMode задают режим.
{ id: 'gd_cube', name: 'Портал: Куб', icon: 'prim-portal', kind: 'gd_portal', gdMode: 'cube', { id: 'gd_cube', name: 'Портал: Куб', icon: 'prim-portal', kind: 'gd_portal', gdMode: 'cube',
@ -87,7 +96,7 @@ export const PRIMITIVE_TYPES = [
/** Категории для группировки в палитре. */ /** Категории для группировки в палитре. */
export const PRIMITIVE_CATEGORIES = [ export const PRIMITIVE_CATEGORIES = [
{ id: 'geometry', name: 'Фигуры', ids: ['cube','sphere','cylinder','cone','plane','torus','wedge','cornerwedge'] }, { id: 'geometry', name: 'Фигуры', ids: ['cube','sphere','cylinder','cone','plane','torus','wedge','cornerwedge'] },
{ id: 'gameplay', name: 'Геймплей', ids: ['trigger', 'checkpoint', 'light', 'emitter'] }, { id: 'gameplay', name: 'Геймплей', ids: ['trigger', 'checkpoint', 'light', 'emitter', 'billboard'] },
{ id: 'gd-portals', name: 'GD-порталы', ids: ['gd_cube','gd_ship','gd_ball','gd_ufo','gd_wave','gd_robot'] }, { id: 'gd-portals', name: 'GD-порталы', ids: ['gd_cube','gd_ship','gd_ball','gd_ufo','gd_wave','gd_robot'] },
{ id: 'gd-objects', name: 'GD-объекты', ids: ['gd_spike','gd_finish','gd_coin'] }, { id: 'gd-objects', name: 'GD-объекты', ids: ['gd_spike','gd_finish','gd_coin'] },
]; ];

View File

@ -131,6 +131,18 @@ 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] },
]), ]),
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 — играют один раз,

109
src/engine/README.md Normal file
View File

@ -0,0 +1,109 @@
# Движок плеера Рублокса
Это движок, который **запускает** игры созданные в [студии](https://studio.rublox.pro). В отличие от студии, плеер **только проигрывает** — не редактирует.
## Чем плеер отличается от студии
Обе используют **общий код движка** (BlockManager, PhysicsWorld, ScriptSandbox и т.д.) — это исторически наследие, движок был выделен в студию когда плеер уже существовал. Сейчас репозитории отдельные, но движок один и тот же на 90%.
**Что есть в плеере и нет в студии:**
- `PlayerAuth` через ticket-flow (см. `src/auth/`)
- Polling статуса игры (опубликована / в премодерации / заблокирована)
- Heartbeat для метрик `/kubikon3d/play/heartbeat`
- Чат внутри игры
- Лидерборд по очкам
- Кнопка «Сообщить о баге» (`/kubikon3d/bug-reports`)
- Кнопка «Пожаловаться» (`/kubikon3d/reports`)
**Что есть в студии и нет в плеере:**
- Редактирование сцены (gizmo, кисти, инспектор)
- Сохранение `PUT /projects/<id>`
- Загрузка моделей юзера
- Скрипт-редактор Monaco
- CAD-редактор моделей
- Геймдиз-инструменты (Geometry Dash sub-app)
## Файлы
| Файл | Что |
|---|---|
| `BabylonScene.js` | Главный класс — Engine + Scene + Camera. Тот же что в студии но без gizmo и SelectionManager. |
| `BlockManager.js` | Блоки 1×1×1 на InstancedMesh. **Read-only режим** — не позволяет ставить новые блоки в runtime (только скрипты через `game.scene.spawn`). |
| `PrimitiveManager.js` | Сферы/кубы/цилиндры. Так же read-only. |
| `ModelManager.js` | GLB-модели (мечи, машины, NPC-скины). |
| `PhysicsWorld.js` | AABB-физика. **Всегда включена** в плеере (в студии — только в Play-режиме). |
| `PlayerController.js` | Управление игроком, WASD + Space + мышь. |
| `ScriptSandbox.js` + `ScriptSandboxWorker.js` | Песочница скриптов юзера. |
| `GameRuntime.js` | Оркестратор игрового режима (см. студию). |
| `MultiplayerSync.js` | Colyseus state-sync для онлайн-игр. |
| `AccessoryManager.js` | Шляпы, очки, аксессуары на R15-скелете. |
| `EmoteGlbParser.js` | Парсер GLB-анимаций (танцы, эмоушены). |
## Поток загрузки игры
```
1. Юзер открывает https://player.rublox.pro/<game_id>
2. PlayerAuth проверяет JWT (или redeem ticket из #ticket=)
3. fetch GET /kubikon3d/projects/<game_id>
→ возвращает project_data JSON ~100КБ-5МБ
4. BabylonScene создаёт сцену
BlockManager.loadFromProject(data.blocks)
PrimitiveManager.loadFromProject(data.primitives)
ModelManager.loadFromProject(data.models)
...
5. ScriptSandbox.startAll() — запускает все скрипты юзера
6. GameRuntime.start() — включает физику, ввод
7. Игрок играет
8. Каждые 30с: POST /kubikon3d/play/heartbeat { game_id, play_time_ms }
```
## Что НЕ трогать (опасные оптимизации)
Те же грабли что в студии:
- `scene.blockMaterialDirtyMechanism = true` — ломает новые меши (трейсеры, debris)
- `scene.createOrUpdateSelectionOctree()` в hot path — O(N²) лагает
- `game.ui.set()` в `onTick` без throttle — React setState 60Hz убивает FPS
- `findOne` на старте скрипта — sceneSnapshot приходит через rAF, ref будет null
Подробнее: [docs/TUTORIAL_DEBUG_BABYLON.md](../../docs/TUTORIAL_DEBUG_BABYLON.md)
## AdminPreview/
Папка `src/AdminPreview/` — каталоги ассетов (gdSkins, gdPortals, gdSfx, gdMusic и т.д.). Используются движком при загрузке игр GD-формата (`GdLevelManager`). Контрибьюторам обычно трогать не нужно.
## Производительность — ориентиры
Те же что в студии:
| Объект | Норм | Лагать начинает |
|---|---|---|
| Блоки | 50К | 200К+ |
| Примитивы | 500 | 2000+ |
| GLB-модели | 200 | 500+ (зависит от вершин) |
| NPC | 50 | 100+ |
| Активные скрипты | 30 | 100+ |
| Частицы | 5К | 20К+ |
**FPS-цель плеера выше чем у студии** — игроки чувствительнее к лагам чем создатели:
- 60 FPS на средних ноутбуках 2020+
- 30 FPS на школьных машинках 2015+
- 60 FPS на мобиле (отдельная задача — мобильная оптимизация)
## Связанные доки
- [../../docs/TUTORIAL_FIRST_PR.md](../../docs/TUTORIAL_FIRST_PR.md) — первый PR
- [../../docs/TUTORIAL_DEBUG_BABYLON.md](../../docs/TUTORIAL_DEBUG_BABYLON.md) — отладка
- [../../API_USAGE.md](../../API_USAGE.md) — какие эндпоинты плеер дёргает
- В студии: [studio/src/editor/engine/README.md](https://git.rublox.pro/rublox/studio/src/branch/main/src/editor/engine/README.md) — полное описание движка
## Вопросы
Канал `#разработка` на https://team.rublox.pro

View File

@ -0,0 +1,177 @@
/**
* RbxlHudOverlay DOM-оверлей с HUD-элементами для импортированных
* Roblox-карт: KillFeed (правый верх), Message/Hint (центр), WinGui.
*
* Один контейнер `.rbxl-hud-overlay` на canvas.parentElement, в нём дочерние
* блоки по типу. Стили inline, ничего не зависит от CSS приложения.
*
* API:
* const hud = new RbxlHudOverlay(canvasParent);
* hud.addKillFeed(killer, victim, weapon)
* hud.showMessage(text, opts)
* hud.hideMessage()
* hud.showWin(text)
* hud.dispose()
*/
export class RbxlHudOverlay {
constructor(parent) {
this._parent = parent || document.body;
this._root = null;
this._killFeed = null;
this._message = null;
this._winBox = null;
this._killEntries = []; // [{el, expireAt}]
this._mount();
}
_mount() {
if (this._root) return;
const root = document.createElement('div');
root.className = 'rbxl-hud-overlay';
Object.assign(root.style, {
position: 'absolute',
inset: '0',
pointerEvents: 'none',
zIndex: '999',
fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif',
});
this._parent.appendChild(root);
this._root = root;
// KillFeed — правый верхний угол
const kf = document.createElement('div');
Object.assign(kf.style, {
position: 'absolute',
top: '60px',
right: '12px',
display: 'flex',
flexDirection: 'column',
gap: '6px',
maxWidth: '320px',
pointerEvents: 'none',
});
root.appendChild(kf);
this._killFeed = kf;
// Message — центр сверху (Roblox Message по центру экрана,
// но в верхней трети чтобы не мешать игре)
const msg = document.createElement('div');
Object.assign(msg.style, {
position: 'absolute',
top: '15%',
left: '50%',
transform: 'translateX(-50%)',
padding: '10px 24px',
background: 'rgba(0,0,0,0.6)',
color: '#fff',
fontSize: '22px',
fontWeight: '600',
borderRadius: '6px',
textShadow: '0 2px 4px rgba(0,0,0,0.8)',
display: 'none',
pointerEvents: 'none',
});
root.appendChild(msg);
this._message = msg;
// WinGui — большая надпись по центру
const win = document.createElement('div');
Object.assign(win.style, {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
padding: '24px 48px',
background: 'rgba(0,0,0,0.75)',
color: '#ffd86b',
fontSize: '48px',
fontWeight: '800',
borderRadius: '12px',
textShadow: '0 4px 8px rgba(0,0,0,0.8)',
display: 'none',
pointerEvents: 'none',
});
root.appendChild(win);
this._winBox = win;
// Тик для авто-исчезновения KillFeed entries (через 5с)
this._tickInterval = setInterval(() => this._cleanupKills(), 500);
}
addKillFeed(killer, victim, weapon) {
if (!this._killFeed) return;
const entry = document.createElement('div');
Object.assign(entry.style, {
background: 'rgba(0,0,0,0.55)',
color: '#fff',
padding: '6px 10px',
borderRadius: '4px',
fontSize: '13px',
display: 'flex',
gap: '6px',
alignItems: 'center',
animation: 'rbxlHudFadeIn 0.3s',
});
const killerEl = document.createElement('span');
killerEl.textContent = String(killer || '?');
killerEl.style.color = '#5bd1e8';
const arrow = document.createElement('span');
arrow.textContent = weapon ? `→ [${weapon}] →` : '→';
arrow.style.color = '#ff9a52';
const victimEl = document.createElement('span');
victimEl.textContent = String(victim || '?');
victimEl.style.color = '#f87a7a';
entry.appendChild(killerEl);
entry.appendChild(arrow);
entry.appendChild(victimEl);
this._killFeed.appendChild(entry);
this._killEntries.push({ el: entry, expireAt: performance.now() + 5000 });
// Keep only last 8
while (this._killEntries.length > 8) {
const old = this._killEntries.shift();
try { old.el.remove(); } catch (_) {}
}
}
_cleanupKills() {
const now = performance.now();
const keep = [];
for (const e of this._killEntries) {
if (e.expireAt < now) { try { e.el.remove(); } catch (_) {} }
else keep.push(e);
}
this._killEntries = keep;
}
showMessage(text, opts = {}) {
if (!this._message) return;
this._message.textContent = String(text || '');
this._message.style.display = text ? 'block' : 'none';
if (opts.duration) {
clearTimeout(this._msgTimer);
this._msgTimer = setTimeout(() => this.hideMessage(), opts.duration);
}
}
hideMessage() {
if (this._message) this._message.style.display = 'none';
}
showWin(text) {
if (!this._winBox) return;
this._winBox.textContent = String(text || '');
this._winBox.style.display = 'block';
// Auto-hide через 6с
clearTimeout(this._winTimer);
this._winTimer = setTimeout(() => { if (this._winBox) this._winBox.style.display = 'none'; }, 6000);
}
dispose() {
try { this._root?.remove(); } catch (_) {}
clearInterval(this._tickInterval);
clearTimeout(this._msgTimer);
clearTimeout(this._winTimer);
this._root = null;
}
}

View File

@ -60,6 +60,7 @@ export class ScriptSandbox {
target: this.target, target: this.target,
selfPosition: this._initialSelfPosition || null, selfPosition: this._initialSelfPosition || null,
modules: this._modules || {}, modules: this._modules || {},
initialScene: this._initialScene || null,
}, },
}); });
} }
@ -69,6 +70,11 @@ export class ScriptSandbox {
this._initialSelfPosition = p; this._initialSelfPosition = p;
} }
/** Первичный snapshot сцены (до start) — чтобы findOne работал на старте. */
setInitialScene(snap) {
this._initialScene = snap;
}
/** Установить код модулей для game.require — { 'имя': 'код' }. Вызывать до start(). */ /** Установить код модулей для game.require — { 'имя': 'код' }. Вызывать до start(). */
setModules(modules) { setModules(modules) {
this._modules = modules || {}; this._modules = modules || {};
@ -89,6 +95,10 @@ export class ScriptSandbox {
try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: this._pendingGuiSnapshot }); } catch (e) {} try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: this._pendingGuiSnapshot }); } catch (e) {}
this._pendingGuiSnapshot = null; this._pendingGuiSnapshot = null;
} }
if (this._pendingSkinsSnapshot) {
try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: this._pendingSkinsSnapshot }); } catch (e) {}
this._pendingSkinsSnapshot = null;
}
if (this._pendingTerrainHM) { if (this._pendingTerrainHM) {
try { this.worker.postMessage({ cmd: 'terrainHeightmap', payload: this._pendingTerrainHM }); } catch (e) {} try { this.worker.postMessage({ cmd: 'terrainHeightmap', payload: this._pendingTerrainHM }); } catch (e) {}
this._pendingTerrainHM = null; this._pendingTerrainHM = null;
@ -165,6 +175,16 @@ export class ScriptSandbox {
try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: snapshot }); } catch (e) {} try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: snapshot }); } catch (e) {}
} }
/** Задача 07: снапшот скинов — для game.player.getAvailableSkins/getAllSkins. */
sendSkinsSnapshot(snapshot) {
if (!this.worker) return;
if (!this._isReady) {
this._pendingSkinsSnapshot = snapshot;
return;
}
try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: snapshot }); } catch (e) {}
}
/** Snapshot атрибутов объектов — для синхронного game.scene.getData. */ /** Snapshot атрибутов объектов — для синхронного game.scene.getData. */
sendDataSnapshot(snapshot) { sendDataSnapshot(snapshot) {
if (!this.worker) return; if (!this.worker) return;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,132 @@
/**
* ShopInventoryUi готовый GUI-кит «слот-инвентарь магазина» (задача 11).
*
* Нижняя (или боковая) панель кнопок-слотов с иконкой/названием/ценой и hover.
* Клик по слоту колбэк onSlotClick(item) обычно автор вызывает внутри
* game.placement.start(...). Слот серый и некликабельный, если валюты мало
* (показывается, когда заданы showCurrency + текущий баланс через setBalance).
*
* Реализация лёгкий DOM-оверлей поверх canvas (а не Babylon-GUI): кнопки с
* иконками/hover/disabled делаются на HTML быстрее и доступнее. Крепится к
* родителю canvas, абсолютным позиционированием.
*
* Фича-парность: идентичный модуль в rublox-player/src/engine/.
*/
// Встроенные inline-SVG иконки слотов (без эмодзи — самописные, см. правила UI).
const SLOT_ICONS = {
crate: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="#7a4a1e" stroke-width="1.6"><rect x="3" y="6" width="18" height="13" rx="1" fill="#c2884a"/><path d="M3 10h18M9 6v13M15 6v13" stroke="#7a4a1e"/></svg>',
plant: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none"><path d="M12 21V11" stroke="#4a7a2e" stroke-width="2"/><path d="M12 12c-3-1-5-4-5-7 3 0 6 2 5 7zM12 11c3-1 5-3 5-6-3 0-6 1-5 6z" fill="#5aa83a"/></svg>',
oven: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="#444" stroke-width="1.4"><rect x="4" y="3" width="16" height="18" rx="1.5" fill="#9aa0a6"/><rect x="7" y="9" width="10" height="9" rx="1" fill="#3a3f44"/><circle cx="9" cy="6" r="1" fill="#444"/><circle cx="13" cy="6" r="1" fill="#444"/></svg>',
coin: '<svg viewBox="0 0 24 24" width="34" height="34"><circle cx="12" cy="12" r="9" fill="#f5c542" stroke="#b8860b" stroke-width="1.4"/><text x="12" y="16" font-size="10" text-anchor="middle" fill="#7a5a00" font-weight="700">$</text></svg>',
box: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="#666" stroke-width="1.6"><path d="M12 2l9 5v10l-9 5-9-5V7z" fill="#b0b6bc"/><path d="M12 2v20M3 7l9 5 9-5" /></svg>',
};
function iconSvg(name) {
return SLOT_ICONS[name] || SLOT_ICONS.box;
}
export class ShopInventoryUi {
constructor(scene3d) {
this.s = scene3d;
this.root = null;
this.items = [];
this.balance = {}; // currency → amount
this.currency = '';
this.showCost = true;
this._onSlotClick = null;
this._slotEls = [];
}
create(opts, onSlotClick) {
this.remove();
this.items = Array.isArray(opts.items) ? opts.items : [];
this.currency = opts.showCurrency || '';
this.showCost = opts.showCost !== false;
this._onSlotClick = typeof onSlotClick === 'function' ? onSlotClick : null;
const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body;
// Контейнер должен быть position:relative чтобы absolute-панель легла поверх.
try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ }
const pos = opts.position || 'bottom';
const slotSize = Number(opts.slotSize) || 80;
const spacing = Number(opts.spacing) || 4;
const root = document.createElement('div');
root.className = 'kbn-shop-inv';
const sideStyle = {
bottom: `left:50%;bottom:18px;transform:translateX(-50%);flex-direction:row;`,
top: `left:50%;top:18px;transform:translateX(-50%);flex-direction:row;`,
left: `left:18px;top:50%;transform:translateY(-50%);flex-direction:column;`,
right: `right:18px;top:50%;transform:translateY(-50%);flex-direction:column;`,
}[pos] || '';
root.style.cssText =
`position:absolute;display:flex;gap:${spacing}px;z-index:40;` +
`padding:8px;border-radius:14px;` +
`background:rgba(20,26,38,0.82);backdrop-filter:blur(4px);` +
`box-shadow:0 6px 24px rgba(0,0,0,0.4);` + sideStyle;
this.items.forEach((it, idx) => {
const slot = document.createElement('button');
slot.type = 'button';
slot.dataset.key = it.key;
slot.style.cssText =
`width:${slotSize}px;height:${slotSize}px;border:none;border-radius:11px;` +
`display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;` +
`cursor:pointer;color:#fff;font:600 12px/1.1 system-ui,sans-serif;` +
`background:linear-gradient(180deg,#3a4a66,#26324a);` +
`transition:transform .1s,box-shadow .1s,filter .1s;position:relative;`;
slot.innerHTML =
`<span style="pointer-events:none">${iconSvg(it.icon)}</span>` +
`<span style="pointer-events:none;max-width:${slotSize - 8}px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${it.name || ''}</span>` +
(this.showCost && it.cost
? `<span class="kbn-cost" style="pointer-events:none;color:#ffd23a;font-size:11px">${it.cost}${this.currency ? ' ' + this._curShort() : ''}</span>`
: '');
slot.onmouseenter = () => { if (!slot.disabled) { slot.style.transform = 'translateY(-3px)'; slot.style.boxShadow = '0 6px 14px rgba(0,0,0,0.45)'; } };
slot.onmouseleave = () => { slot.style.transform = ''; slot.style.boxShadow = ''; };
slot.onclick = () => {
if (slot.disabled) return;
if (this._onSlotClick) this._onSlotClick(it);
};
this._slotEls[idx] = slot;
root.appendChild(slot);
});
parent.appendChild(root);
this.root = root;
this._refreshAffordability();
}
_curShort() {
const map = { rubles: 'руб', coins: 'мон', diamonds: 'алм' };
return map[this.currency] || this.currency;
}
/** Обновить баланс валюты — слоты дороже баланса станут серыми. */
setBalance(currency, amount) {
if (currency) this.balance[currency] = Number(amount) || 0;
this._refreshAffordability();
}
_refreshAffordability() {
if (!this.currency) return; // без валюты все слоты активны
const bal = this.balance[this.currency] != null ? this.balance[this.currency] : Infinity;
this.items.forEach((it, idx) => {
const slot = this._slotEls[idx];
if (!slot) return;
const afford = (Number(it.cost) || 0) <= bal;
slot.disabled = !afford;
slot.style.filter = afford ? '' : 'grayscale(1) brightness(0.6)';
slot.style.cursor = afford ? 'pointer' : 'not-allowed';
slot.style.opacity = afford ? '1' : '0.7';
});
}
remove() {
if (this.root) { try { this.root.remove(); } catch { /* ignore */ } this.root = null; }
this._slotEls = [];
}
dispose() { this.remove(); this._onSlotClick = null; }
}

570
src/engine/SkyboxManager.js Normal file
View File

@ -0,0 +1,570 @@
/**
* 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();
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

@ -514,6 +514,10 @@ export class TerrainManager {
const mat = new StandardMaterial(name, this.scene); const mat = new StandardMaterial(name, this.scene);
// Specular почти выключен — на pixel-art-текстуре любой блик уничтожает стиль. // Specular почти выключен — на pixel-art-текстуре любой блик уничтожает стиль.
mat.specularColor = new Color3(0, 0, 0); mat.specularColor = new Color3(0, 0, 0);
// 2026-06-02: воксели «просвечивали» (видна задняя грань сквозь переднюю).
// backFaceCulling=false рисует обе стороны, ближняя перекрывает дальнюю
// по depth. Прозрачным (water/glacier) culling оставляем. См. studio.
mat.backFaceCulling = !(def.alpha != null && def.alpha < 1) ? false : true;
// Ambient ставим в белый, чтобы hemisphere-light освещал материал // Ambient ставим в белый, чтобы hemisphere-light освещал материал
// с любой стороны (иначе нижние/тыловые грани выходят серыми, что // с любой стороны (иначе нижние/тыловые грани выходят серыми, что
// особенно заметно на светло-бежевом песке — он становится серым). // особенно заметно на светло-бежевом песке — он становится серым).
@ -543,6 +547,12 @@ export class TerrainManager {
mat.diffuseTexture.hasAlpha = true; mat.diffuseTexture.hasAlpha = true;
mat.useAlphaFromDiffuseTexture = true; mat.useAlphaFromDiffuseTexture = true;
mat.alpha = def.alpha; mat.alpha = def.alpha;
} else {
// RGBA-текстуры (alpha=255) Babylon мог рендерить с alpha-blend →
// воксели просвечивали. Явно OPAQUE для непрозрачных. См. studio.
mat.diffuseTexture.hasAlpha = false;
mat.useAlphaFromDiffuseTexture = false;
mat.transparencyMode = 0;
} }
if (Array.isArray(def.emissive)) { if (Array.isArray(def.emissive)) {
mat.emissiveColor = new Color3(def.emissive[0], def.emissive[1], def.emissive[2]); mat.emissiveColor = new Color3(def.emissive[0], def.emissive[1], def.emissive[2]);

View File

@ -599,6 +599,7 @@ export class UserModelManager {
// instanceId — чтобы target-скрипты могли стабильно ссылаться // instanceId — чтобы target-скрипты могли стабильно ссылаться
// на конкретный инстанс после перезагрузки. // на конкретный инстанс после перезагрузки.
instanceId: inst.instanceId, instanceId: inst.instanceId,
...(inst.folderId != null ? { folderId: inst.folderId } : {}),
}); });
} }
return arr; return arr;
@ -663,7 +664,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);
} }

95
src/engine/VehicleHud.js Normal file
View File

@ -0,0 +1,95 @@
/**
* VehicleHud HUD водителя (задача 14): круглый спидометр со стрелкой,
* передача (D/R/N), подсказки клавиш. DOM-оверлей поверх canvas (как
* ShopInventoryUi). Показывается пока игрок за рулём.
*
* Фича-парность: идентичный модуль в rublox-player/src/engine/.
*/
export class VehicleHud {
constructor(scene3d) {
this.s = scene3d;
this.root = null;
this.needle = null;
this.speedText = null;
this.gearText = null;
this._maxKmh = 80;
}
show(maxKmh) {
this.remove();
this._maxKmh = Math.max(20, Math.round((maxKmh || 14) * 3.6 / 10) * 10 + 10);
const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body;
try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ }
const root = document.createElement('div');
root.className = 'kbn-veh-hud';
root.style.cssText =
'position:absolute;left:24px;bottom:22px;z-index:45;width:160px;height:160px;' +
'pointer-events:none;font-family:system-ui,"Segoe UI",sans-serif;user-select:none;';
// SVG-циферблат.
const R = 70, CX = 80, CY = 80;
const startA = 135, endA = 405; // дуга 270°
const ticks = [];
const N = 8;
for (let i = 0; i <= N; i++) {
const a = (startA + (endA - startA) * i / N) * Math.PI / 180;
const x1 = CX + Math.cos(a) * (R - 4), y1 = CY + Math.sin(a) * (R - 4);
const x2 = CX + Math.cos(a) * (R - 14), y2 = CY + Math.sin(a) * (R - 14);
ticks.push(`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="#c8d0dc" stroke-width="2"/>`);
const lx = CX + Math.cos(a) * (R - 26), ly = CY + Math.sin(a) * (R - 26) + 4;
const val = Math.round(this._maxKmh * i / N);
ticks.push(`<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}" fill="#9aa6b8" font-size="9" text-anchor="middle">${val}</text>`);
}
root.innerHTML =
`<svg viewBox="0 0 160 160" width="160" height="160">` +
`<circle cx="${CX}" cy="${CY}" r="${R}" fill="rgba(16,20,32,0.82)" stroke="#3a4760" stroke-width="3"/>` +
ticks.join('') +
`<line id="kbn-veh-needle" x1="${CX}" y1="${CY}" x2="${CX}" y2="${CY - R + 18}" stroke="#ff5a3c" stroke-width="3.5" stroke-linecap="round" transform="rotate(-135 ${CX} ${CY})"/>` +
`<circle cx="${CX}" cy="${CY}" r="6" fill="#ff5a3c"/>` +
`<text id="kbn-veh-speed" x="${CX}" y="${CY + 30}" fill="#ffe44a" font-size="22" font-weight="800" text-anchor="middle">0</text>` +
`<text x="${CX}" y="${CY + 44}" fill="#9aa6b8" font-size="9" text-anchor="middle">км/ч</text>` +
`<text id="kbn-veh-gear" x="${CX}" y="${CY - 16}" fill="#7fe0a0" font-size="18" font-weight="900" text-anchor="middle">N</text>` +
`</svg>`;
parent.appendChild(root);
this.root = root;
this.needle = root.querySelector('#kbn-veh-needle');
this.speedText = root.querySelector('#kbn-veh-speed');
this.gearText = root.querySelector('#kbn-veh-gear');
this._CX = CX; this._CY = CY;
// Подсказки клавиш справа снизу.
const keys = document.createElement('div');
keys.className = 'kbn-veh-keys';
keys.style.cssText =
'position:absolute;right:24px;bottom:28px;z-index:45;pointer-events:none;' +
'color:#cfd6e0;font:600 14px/1.6 system-ui,sans-serif;text-align:right;' +
'text-shadow:0 1px 3px rgba(0,0,0,0.7);';
keys.innerHTML = '<div><b>WASD</b> — руль</div><div><b>V</b> — камера</div><div><b>E</b> — выйти</div>';
parent.appendChild(keys);
this._keys = keys;
}
/** Обновить стрелку/число/передачу. speed — м/с (signed). */
update(speedMs) {
if (!this.needle) return;
const kmh = Math.abs(speedMs) * 3.6;
const frac = Math.max(0, Math.min(1, kmh / this._maxKmh));
const ang = -135 + 270 * frac; // -135°..+135°
this.needle.setAttribute('transform', `rotate(${ang.toFixed(1)} ${this._CX} ${this._CY})`);
if (this.speedText) this.speedText.textContent = String(Math.round(kmh));
if (this.gearText) {
const g = speedMs < -0.3 ? 'R' : (Math.abs(speedMs) < 0.3 ? 'N' : 'D');
this.gearText.textContent = g;
this.gearText.setAttribute('fill', g === 'R' ? '#ff7a5a' : g === 'N' ? '#9aa6b8' : '#7fe0a0');
}
}
remove() {
if (this.root) { try { this.root.remove(); } catch { /* ignore */ } this.root = null; }
if (this._keys) { try { this._keys.remove(); } catch { /* ignore */ } this._keys = null; }
this.needle = this.speedText = this.gearText = null;
}
dispose() { this.remove(); }
}

View File

@ -0,0 +1,249 @@
import { Vector3, TransformNode } from '@babylonjs/core';
/**
* VehicleManager система транспорта (задача 14, фаза V1 аркадная + V2 параметры).
*
* Каждая машина = chassisNode (TransformNode) + GLB-кузов (modelManager-инстанс) +
* 4 колеса-визуала (передние доворачивают при руле). Физика АРКАДНАЯ:
* speed (скаляр вдоль yaw) += throttle*power*dt; трение; поворот по steer
* (масштаб от скорости нет вращения на месте); коллизия с миром через
* physics.moveAABB (тот же солвер что у игрока). Колёса друг с другом и с
* другими машинами НЕ сталкиваются (V1) только chassis с миром.
*
* Фича-парность: идентичный модуль в rublox-player/src/engine/.
*/
const DEFAULT_PARAMS = {
mass: 1200,
enginePower: 14, // ускорение (м/с²) — аркадно, не реальные л.с.
maxSpeed: 14, // м/с (~50 км/ч) — для маленьких миров
turnSpeed: 1.8, // рад/с при полной скорости
brake: 26, // замедление при тормозе/реверсе
drive: 'rwd',
};
export class VehicleManager {
constructor(scene3d) {
this.s = scene3d;
this.scene = scene3d.scene;
this.vehicles = new Map(); // id → veh
this._seq = 0;
}
get _physics() { return this.s.physics; }
get _models() { return this.s.modelManager; }
/**
* Создать машину. opts: { model:'car-taxi', color, name, params, x,y,z, rotationY }.
* Возвращает Promise<id>.
*/
async spawn(opts) {
opts = opts || {};
const x0 = Number(opts.x) || 0, z0 = Number(opts.z) || 0;
// Идемпотентность: если машина с такой позицией уже есть — не плодим
// (защита от двойного выполнения скрипта спавна → дубли машин).
for (const v of this.vehicles.values()) {
if (Math.abs(v.spawnX - x0) < 0.5 && Math.abs(v.spawnZ - z0) < 0.5) return v.id;
}
const id = ++this._seq;
const x = Number(opts.x) || 0, y = Number(opts.y) || 0.4, z = Number(opts.z) || 0;
const yaw = Number(opts.rotationY) || 0;
const params = { ...DEFAULT_PARAMS, ...(opts.params || {}) };
const modelType = opts.model || 'car-sedan';
// chassis-узел — родитель кузова и колёс.
const chassisNode = new TransformNode(`vehicle_${id}`, this.scene);
chassisNode.position = new Vector3(x, y, z);
chassisNode.rotation = new Vector3(0, yaw, 0);
const veh = {
id, name: opts.name || 'Машина', params,
spawnX: x, spawnZ: z, // для дедупа повторного спавна
chassisNode, bodyInstanceId: null, wheels: [],
pos: new Vector3(x, y, z), yaw, vy: 0,
speed: 0, steerAngle: 0,
half: { w: 1.0, h: 0.6, d: 2.0 }, // уточним по bbox кузова
throttle: 0, steer: 0, handbrake: false,
driver: null,
handlers: { onEnter: [], onExit: [], onCollide: [], onSpeedChange: [] },
ref: opts.ref || null,
};
this.vehicles.set(id, veh);
// Кузов (GLB Kenney car-kit).
try {
const bodyId = await this._models.addInstance(modelType, x, y, z, yaw);
veh.bodyInstanceId = bodyId;
const inst = this._models.instances.get(bodyId);
if (inst && inst.rootMesh) {
// Габариты AABB + вертикальный offset кузова СЧИТАЕМ ДО парентинга
// (в мировых координатах, кузов ещё в (x,y,z)).
try {
const bb = inst.rootMesh.getHierarchyBoundingVectors(true);
veh.half = {
w: Math.max(0.6, (bb.max.x - bb.min.x) / 2),
h: Math.max(0.4, (bb.max.y - bb.min.y) / 2),
d: Math.max(1.0, (bb.max.z - bb.min.z) / 2),
};
// Насколько низ кузова ниже точки спавна y — чтобы посадить
// кузов так, чтобы его НИЗ совпал с низом AABB (машина на земле,
// не парит). bodyYOffset применяется к локальной Y кузова.
veh.bodyYOffset = -(bb.min.y - y) - veh.half.h;
} catch (e) { veh.bodyYOffset = -veh.half.h; }
inst.rootMesh.setParent(chassisNode);
inst.rootMesh.position = new Vector3(0, veh.bodyYOffset || 0, 0);
inst.rootMesh.rotation = Vector3.Zero();
// Цвет кузова (tint поверх GLB-текстуры).
if (opts.color) { try { this._models.setInstanceProps?.(bodyId, { tint: opts.color }); } catch (e) {} }
}
} catch (e) { console.warn('[VehicleManager] body load failed', e); }
// Колёса НЕ спавним отдельно — GLB-модели Kenney car-kit уже содержат
// колёса в кузове. Отдельные колёса дублировали/отрывались (баг V1).
// Визуальный доворот передних колёс — фаза V3 (там кузов+колёса раздельно).
// «Оседание»: уроним машину на землю СРАЗУ (до посадки игрока), иначе она
// висит/утоплена на стартовой y, пока никто не за рулём (нет tick).
this._settle(veh);
// Повторное оседание на следующих кадрах: физический грид статики может
// ещё не проиндексироваться к моменту спавна (await addInstance), тогда
// первый _settle не находит пол и машина зависает в воздухе (баг седана).
for (const d of [120, 350, 800]) {
setTimeout(() => { try { if (!veh.driver) this._settle(veh); } catch (e) {} }, d);
}
return id;
}
/**
* Опустить машину на поверхность гравитацией. Стартуем ВЫШЕ текущей точки и
* роняем большим запасом (много шагов), чтобы гарантированно найти пол даже
* если стартовая y оказалась чуть ниже/выше или физика поздно готова.
*/
_settle(veh) {
try {
veh.pos.y += 0.5;
let landed = false;
for (let i = 0; i < 80; i++) {
const r = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, 0, -0.25, 0);
veh.pos.set(r.x, r.y, r.z);
if (r.hitY) { landed = true; break; }
}
if (landed) {
for (let i = 0; i < 4; i++) {
const r = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, 0, -0.04, 0);
veh.pos.set(r.x, r.y, r.z);
if (r.hitY) break;
}
}
veh.vy = 0;
veh.chassisNode.position.copyFrom(veh.pos);
veh.chassisNode.rotation.y = veh.yaw;
} catch (e) { /* ignore */ }
}
getById(id) { return this.vehicles.get(id) || null; }
/** Установить ввод водителя (из PlayerController). */
setInput(veh, throttle, steer, handbrake) {
if (!veh) return;
veh.throttle = Math.max(-1, Math.min(1, throttle || 0));
veh.steer = Math.max(-1, Math.min(1, steer || 0));
veh.handbrake = !!handbrake;
}
/** Физический шаг машины (вызывается каждый кадр пока есть водитель). */
tickVehicle(veh, dt) {
if (!veh) return;
dt = Math.min(dt, 1 / 30);
const p = veh.params;
const prevSpeed = veh.speed;
// Ускорение / торможение / реверс.
if (veh.throttle > 0) {
veh.speed += veh.throttle * p.enginePower * dt;
} else if (veh.throttle < 0) {
// S: сначала тормоз, потом задний ход (ограничен).
if (veh.speed > 0.2) veh.speed -= p.brake * dt;
else veh.speed += veh.throttle * p.enginePower * 0.5 * dt;
}
// Накат-трение.
veh.speed *= (1 - 1.2 * dt);
if (veh.handbrake) veh.speed *= (1 - 6 * dt);
// Клампы.
const maxFwd = p.maxSpeed, maxRev = p.maxSpeed * 0.4;
if (veh.speed > maxFwd) veh.speed = maxFwd;
if (veh.speed < -maxRev) veh.speed = -maxRev;
if (Math.abs(veh.speed) < 0.05) veh.speed = 0;
// Поворот (зависит от скорости — нельзя крутиться на месте).
const speedFrac = veh.speed / maxFwd;
veh.yaw += veh.steer * p.turnSpeed * speedFrac * dt;
// Угол доворота передних колёс (визуал) — плавный lerp.
const targetSteer = veh.steer * 0.5;
veh.steerAngle += (targetSteer - veh.steerAngle) * Math.min(1, dt * 8);
// Направление и перемещение.
const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw));
const moveX = dir.x * veh.speed * dt;
const moveZ = dir.z * veh.speed * dt;
// Гравитация (машина сидит на полу/дороге).
veh.vy += -22 * dt;
// Коллизия с миром через тот же солвер что у игрока.
let res;
try {
res = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, moveX, veh.vy * dt, moveZ);
} catch (e) {
res = { x: veh.pos.x + moveX, y: veh.pos.y, z: veh.pos.z + moveZ, hitX: false, hitY: false, hitZ: false };
}
veh.pos.set(res.x, res.y, res.z);
if (res.hitY) veh.vy = 0;
// Удар об стену — гасим ход.
if (res.hitX || res.hitZ) {
const force = Math.abs(veh.speed);
veh.speed *= 0.3;
for (const fn of veh.handlers.onCollide) { try { fn(force); } catch (e) {} }
}
// Применить к узлам.
veh.chassisNode.position.copyFrom(veh.pos);
veh.chassisNode.rotation.y = veh.yaw;
// Колёса: передние доворачивают, все катятся.
const roll = (veh.speed * dt) / 0.4;
for (const w of veh.wheels) {
if (w.isFront) w.node.rotation.y = veh.steerAngle;
w.node.rotation.x = (w.node.rotation.x + roll) % (Math.PI * 2);
}
if (Math.abs(veh.speed - prevSpeed) > 0.01) {
for (const fn of veh.handlers.onSpeedChange) { try { fn(Math.abs(veh.speed)); } catch (e) {} }
}
// Падение в бездну — сигнал PlayerController высадить + респавн.
if (veh.pos.y < -25) return { fellOut: true };
return null;
}
/** Текущая скорость машины в м/с (для спидометра). */
speedOf(veh) { return veh ? Math.abs(veh.speed) : 0; }
applyImpulse(veh, v) {
if (!veh || !v) return;
// Простой импульс: вертикальная составляющая в vy, горизонтальная в speed по направлению.
if (Number.isFinite(v.y)) veh.vy += Number(v.y);
const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw));
const horiz = (Number(v.x) || 0) * dir.x + (Number(v.z) || 0) * dir.z;
veh.speed += horiz;
}
dispose() {
for (const veh of this.vehicles.values()) {
try {
if (veh.bodyInstanceId != null) this._models.removeInstance?.(veh.bodyInstanceId);
for (const w of veh.wheels) this._models.removeInstance?.(w.instanceId);
veh.chassisNode?.dispose?.();
} catch (e) { /* ignore */ }
}
this.vehicles.clear();
}
}

View File

@ -90,6 +90,17 @@ 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-е лицо) → стрелять туда,
// куда кликнули, а не в центр камеры.
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 +108,26 @@ export class WeaponSystem {
if (e.button !== 0) return; if (e.button !== 0) return;
this._mouseDown = false; this._mouseDown = false;
}; };
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 +606,10 @@ export class WeaponSystem {
// (для tap-to-shoot на мобиле). Точка применяется один раз. // (для tap-to-shoot на мобиле). Точка применяется один раз.
let hit = null; let hit = null;
let ray; let ray;
const aim = this._aimScreenPoint; 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);

View File

@ -0,0 +1,337 @@
/**
* LuaSharedSandbox (v3, main-thread) wasmoon-VM работает в MAIN потоке,
* без Web Worker. Это позволяет:
* - Видеть точные Lua-ошибки в DevTools (через console.error)
* - Использовать debugger / breakpoints прямо в RobloxShim.js
* - Не возиться с молчаливыми Worker-падениями
*
* Цена: тяжёлые Lua-скрипты могут блокировать UI. Для KillBrick-style
* скриптов это нестрашно они быстрые.
*
* API совместим с ScriptSandbox: setOnCommand / sendEvent / sendGlobalEvent /
* sendSceneSnapshot / sendGuiSnapshot / sendDataSnapshot / sendSkinsSnapshot /
* sendTerrainHeightmap / stop / tick / target.
*
* Что добавлено сверх ScriptSandbox:
* - addScript(id, code, target) добавить скрипт в общий VM. Можно
* до или после start().
* - start() асинхронен (createEngine), но возвращает сразу. После init
* стартует main loop (Heartbeat + scheduler).
*/
import { LuaFactory } from 'wasmoon';
import { registerRobloxShim } from './RobloxShim.js';
export class LuaSharedSandbox {
constructor() {
this.vm = null;
this.api = null;
this._onCommand = null;
this._isReady = false;
this._isStopped = false;
this._isKickedOff = false;
this._pendingScripts = []; // [{id, code, target, name}]
this._scriptsById = new Map();
this._scenes = null;
this._guiTree = null;
this._loopHandle = null;
this._lastTickAt = 0;
// Маркер для GameRuntime.routeEvent — этот sandbox принимает все
// события и сам маршрутизирует через shim.fireTargetEvent.
this._luaShared = true;
}
setOnCommand(cb) { this._onCommand = cb; }
get target() { return null; }
tick(_dt, _state) { /* no-op: main-loop запускается изнутри (setInterval) */ }
addScript(id, code, target, name, extra) {
const entry = {
id: String(id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`),
code: String(code || ''),
target: target == null ? null : target,
name: name || null,
toolName: extra?.toolName || null,
};
this._scriptsById.set(entry.id, entry);
if (!this._isKickedOff) {
this._pendingScripts.push(entry);
} else {
this._startSingleScript(entry);
}
}
removeScript(id) {
this._scriptsById.delete(String(id));
}
/** Стартует VM, регистрирует shim, запускает main-loop. */
start() {
if (this.vm || this._isStopped) return;
// eslint-disable-next-line no-console
console.log('[LuaSharedSandbox v3] starting Lua VM in main thread...');
this._initAsync().catch((err) => {
// eslint-disable-next-line no-console
console.error('[LuaSharedSandbox] FATAL init error:', err);
this._emit('log', { level: 'error', text: `Lua-runtime init: ${err?.message || err}` });
});
}
async _initAsync() {
const factory = new LuaFactory();
this.vm = await factory.createEngine({ openStandardLibs: true });
// eslint-disable-next-line no-console
console.log('[LuaSharedSandbox] VM created. Registering Roblox shim...');
// Адаптер для shim'а: он ожидает send(cmd, payload), getSceneSnapshot, getGuiTree, scheduleWait.
const send = (cmd, payload) => this._emit(cmd, payload);
this.api = registerRobloxShim(this.vm, {
send,
getSceneSnapshot: () => this._scenes,
getGuiTree: () => this._guiTree,
scheduleWait: () => null,
});
// eslint-disable-next-line no-console
console.log('[LuaSharedSandbox] Shim registered. api keys:', Object.keys(this.api || {}));
// Применим snapshot если он есть
if (this._scenes && this.api?.onSceneSnapshot) {
try { this.api.onSceneSnapshot(this._scenes); } catch (e) {
console.error('[LuaSharedSandbox] onSceneSnapshot:', e);
}
}
this._isReady = true;
this._kickoff();
}
_kickoff() {
if (this._isKickedOff || this._isStopped) return;
this._isKickedOff = true;
// eslint-disable-next-line no-console
console.log(`[LuaSharedSandbox] kickoff: starting ${this._pendingScripts.length} scripts`);
const pending = this._pendingScripts;
this._pendingScripts = [];
// Запускаем main-loop сразу — он начнёт tick'ать как только будут coroutines.
this._lastTickAt = performance.now();
this._startMainLoop();
// Init батчами по 5 с задержкой 20мс между ними, чтобы UI отзывался.
const BATCH_SIZE = 5;
let idx = 0;
const initBatch = () => {
if (this._isStopped) return;
const end = Math.min(idx + BATCH_SIZE, pending.length);
for (let i = idx; i < end; i++) {
try { this._startSingleScript(pending[i]); }
catch (e) {
// eslint-disable-next-line no-console
console.error('[LuaSharedSandbox] init batch err:', e);
}
}
idx = end;
if (idx < pending.length) {
setTimeout(initBatch, 20);
} else {
// eslint-disable-next-line no-console
console.log(`[LuaSharedSandbox] all ${pending.length} scripts kicked off`);
// После того как все скрипты подключили хендлеры — фейрим
// events для уже существующих сущностей. Roblox-конвенция:
// если игрок уже на сервере когда скрипт подключается,
// Players.PlayerAdded не сработает повторно. Юзеру нужно
// делать ручной обход GetPlayers() — но это редко кто помнит.
// Мы дублируем событие через короткую задержку.
setTimeout(() => {
try {
if (this.api?.fireExistingPlayers) {
this.api.fireExistingPlayers();
}
} catch (e) {
console.warn('[LuaSharedSandbox] fireExistingPlayers failed:', e);
}
}, 100);
}
};
setTimeout(initBatch, 0);
}
_startSingleScript(entry) {
if (!this.vm || !entry || typeof entry.code !== 'string') return;
let primId = null;
if (typeof entry.target === 'number') primId = entry.target;
else if (entry.target && typeof entry.target === 'object') {
if (entry.target.kind === 'primitive') primId = entry.target.id ?? entry.target.ref;
}
const safeId = entry.id.replace(/[^a-zA-Z0-9_]/g, '_');
const scriptName = entry.name || `Script_${safeId}`;
// Скрипт оборачиваем в coroutine — это позволяет task.wait через yield.
// Резюмим coroutine из main-loop когда наступило время.
// Регистрируем coroutine в __rbxl_coroutines с id для возобновления.
// Скрипт оборачиваем в coroutine. task.wait()→coroutine.yield(sec) возвращает
// delay из resume → планируем следующий resume через scheduleResume.
// Fallback Parent: если скрипт связан с Tool (по имени Tool из metadata) —
// подсовываем виртуальный Tool как script.Parent. Иначе primitive по id,
// иначе workspace.
let parentExpr;
if (entry.toolName) {
// Tool создаётся в shim как Instance.new('Tool'). По имени достаём.
// Если не нашли — fallback на новый Tool того же имени.
const safeName = JSON.stringify(entry.toolName);
parentExpr = `(function()
local existing = __rbxl_get_tool_by_name(${safeName})
if existing then return existing end
local t = Instance.new("Tool")
t.Name = ${safeName}
return t
end)()`;
} else if (primId != null) {
parentExpr = `(__rbxl_get_part_by_id(${Number(primId)}) or workspace)`;
} else {
parentExpr = 'workspace';
}
const wrapped = `
do
-- Если parentExpr вернул primitive у него уже есть :FindFirstChild и пр.
-- Если ничего не вернёт workspace (всегда валидный).
-- script.Parent.Parent (Tool.Parent = StarterPack / Backpack / workspace).
local _scriptParent = ${parentExpr}
if _scriptParent == nil then _scriptParent = workspace end
if _scriptParent.Parent == nil then _scriptParent.Parent = workspace end
local script = setmetatable({
Name = ${JSON.stringify(scriptName)},
Parent = _scriptParent,
ClassName = "Script",
Disabled = false,
Source = nil,
}, {
-- Любой доступ к несуществующему полю workspace
-- (на случай script.Foo:Bar() в старом коде)
__index = function(t, k)
if k == "FindFirstChild" or k == "WaitForChild" or k == "GetChildren" then
return function() return nil end
end
return workspace[k]
end,
})
local co = coroutine.create(function()
-- WATCHDOG: каждые 100000 инструкций yield 1 кадр.
-- НЕ оборачиваем в pcall внутри C-call boundary yield
-- упадёт ошибкой, что прервёт скрипт. Это лучше чем виснуть.
debug.sethook(function()
coroutine.yield(0.016)
end, "", 20000)
-- pcall защищает от runtime-ошибок которые иначе крашат
-- coroutine и могут повредить WASM-стейт. Возвраты
-- handler'а намеренно поглощаются.
local ok_, err_ = pcall(function()
${entry.code}
end)
if not ok_ then
__rbxl_send_error(${JSON.stringify(entry.id)}, tostring(err_))
end
end)
__rbxl_register_coroutine(${JSON.stringify(entry.id)}, co)
local ok, ret = coroutine.resume(co)
if not ok then
__rbxl_send_error(${JSON.stringify(entry.id)}, tostring(ret))
__rbxl_unregister_coroutine(${JSON.stringify(entry.id)})
elseif type(ret) == 'number' then
-- скрипт yield'нул с delay (через task.wait) планируем resume
__rbxl_schedule_resume(${JSON.stringify(entry.id)}, ret)
elseif coroutine.status(co) == 'dead' then
__rbxl_unregister_coroutine(${JSON.stringify(entry.id)})
end
end
`;
try {
this.vm.doStringSync(wrapped);
// eslint-disable-next-line no-console
console.log(`[LuaSharedSandbox] script ${entry.id} initialized OK`);
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[LuaSharedSandbox] script ${entry.id} init FAILED:`, err);
this._emit('log', { level: 'error', text: `[Lua ${entry.id}] ${err?.message || err}` });
}
}
_startMainLoop() {
const tick = () => {
if (this._isStopped) return;
try {
const now = performance.now();
const dt = Math.min(0.1, (now - this._lastTickAt) / 1000);
this._lastTickAt = now;
if (this.api?.tickScheduler) this.api.tickScheduler(dt);
if (this.api?.fireHeartbeat) this.api.fireHeartbeat(dt);
} catch (e) {
// eslint-disable-next-line no-console
console.error('[LuaSharedSandbox tick]', e);
}
this._loopHandle = setTimeout(tick, 16);
};
this._loopHandle = setTimeout(tick, 16);
}
_emit(cmd, payload) {
if (typeof this._onCommand === 'function') {
try { this._onCommand({ cmd, payload }); } catch (_) {}
}
}
// ----- API совместимый с ScriptSandbox -----
sendEvent(payload) {
if (!this.api?.fireTargetEvent || !this._isReady) return;
try { this.api.fireTargetEvent(payload); } catch (e) {
console.error('[LuaSharedSandbox] sendEvent:', e);
}
}
sendGlobalEvent(payload) {
if (!this.api?.fireGlobalEvent || !this._isReady) return;
try { this.api.fireGlobalEvent(payload); } catch (e) {
console.error('[LuaSharedSandbox] sendGlobalEvent:', e);
}
}
sendSceneSnapshot(snapshot) {
this._scenes = snapshot;
if (this.api?.onSceneSnapshot && this._isReady) {
try { this.api.onSceneSnapshot(snapshot); } catch (e) {
console.error('[LuaSharedSandbox] onSceneSnapshot:', e);
}
}
}
sendGuiSnapshot(snapshot) {
this._guiTree = snapshot;
if (this.api?.onGuiSnapshot && this._isReady) {
try { this.api.onGuiSnapshot(snapshot); } catch (_) {}
}
}
sendDataSnapshot(snapshot) {
if (this.api?.onDataSnapshot && this._isReady) {
try { this.api.onDataSnapshot(snapshot); } catch (_) {}
}
}
sendSkinsSnapshot(_) { /* no-op для Этапа 3 */ }
sendTerrainHeightmap(_) { /* no-op */ }
stop() {
this._isStopped = true;
if (this._loopHandle) {
clearTimeout(this._loopHandle);
this._loopHandle = null;
}
if (this.vm) {
try { this.vm.global.close(); } catch (_) {}
this.vm = null;
}
this.api = null;
}
}
export default LuaSharedSandbox;

2500
src/engine/lua/RobloxShim.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,210 @@
/**
* rbxl-lua-integration.js вспомогательные функции для импорта .rbxl-карт.
*
* Старый отдельный Worker-based runtime удалён (2026-06-08): импортированные
* Lua-скрипты теперь идут через тот же LuaSharedSandbox что и user-Lua
* (см. GameRuntime.start()). Этот файл оставлен только для:
* - unpackRobloxLuaCode() распаковка Lua из JS-комментария-обёртки;
* - handleLuaCommand() обработка partSet/sceneCreate/sceneDelete/playerCmd
* команд от Lua-VM в BabylonScene.
*/
/** Распаковка lua_source из packed-кода. */
export function unpackRobloxLuaCode(code) {
const openTag = '/[*] lua_source:\n'.replace('[*]', '*');
const i = code.indexOf(openTag);
if (i < 0) return null;
const start = i + openTag.length;
const closeIdx = code.lastIndexOf('\n*' + '/');
if (closeIdx < start) return null;
return code.slice(start, closeIdx);
}
/** Парсит JSON-метадату из 2-й строки packed-кода (`// {"roblox_class":..., "enabled": true}`). */
export function parseRobloxLuaMeta(code) {
if (typeof code !== 'string') return null;
const lines = code.split('\n');
if (lines.length < 2) return null;
const metaLine = lines[1];
if (!metaLine.startsWith('// ')) return null;
try {
return JSON.parse(metaLine.slice(3));
} catch (_) {
return null;
}
}
/** Сцена → snap для shim'а (workspace:GetChildren). */
export function buildLuaSceneSnap(primitives) {
const out = { primitives: {} };
if (!Array.isArray(primitives)) return out;
for (const p of primitives) {
out.primitives[p.id] = {
id: p.id, type: p.type, name: p.name,
x: p.x, y: p.y, z: p.z,
sx: p.sx, sy: p.sy, sz: p.sz,
color: p.color, material: p.material,
anchored: !!p.anchored, canCollide: p.canCollide !== false,
opacity: typeof p.opacity === 'number' ? p.opacity : 1,
};
}
return out;
}
/**
* GUI-tree для shim'а. Mapping origin __roblox_class.
* scene.gui массив элементов с {id, type, name, parentId, ...origin}.
* Возвращаем массив сохраняя порядок parent child (важно для tree-сборки).
*/
export function buildLuaGuiTree(guiElements) {
if (!Array.isArray(guiElements)) return [];
const out = [];
for (const el of guiElements) {
// origin = 'roblox-textbutton' → 'TextButton'
let rblClass = 'Frame';
const origin = el.origin || '';
if (origin.startsWith('roblox-')) {
const tail = origin.slice(7);
rblClass = tail.charAt(0).toUpperCase() + tail.slice(1);
// Camel-case "textbutton" → "TextButton"
if (rblClass.toLowerCase() === 'textbutton') rblClass = 'TextButton';
else if (rblClass.toLowerCase() === 'textlabel') rblClass = 'TextLabel';
else if (rblClass.toLowerCase() === 'imagebutton') rblClass = 'ImageButton';
else if (rblClass.toLowerCase() === 'imagelabel') rblClass = 'ImageLabel';
else if (rblClass.toLowerCase() === 'textbox') rblClass = 'TextBox';
else if (rblClass.toLowerCase() === 'scrollingframe') rblClass = 'ScrollingFrame';
else if (rblClass.toLowerCase() === 'frame') rblClass = 'Frame';
} else {
// Если origin не задан — гадаем по type
const t = el.type;
if (t === 'button') rblClass = 'TextButton';
else if (t === 'text') rblClass = 'TextLabel';
else if (t === 'image') rblClass = 'ImageLabel';
else if (t === 'textbox') rblClass = 'TextBox';
}
out.push({
id: el.id,
name: el.name || rblClass,
parentId: el.parentId || null,
visible: el.visible !== false,
text: el.text || '',
__roblox_class: rblClass,
});
}
return out;
}
/**
* Обработка IPC команд от worker'а мапим на действия в Babylon-сцене.
*/
export function handleLuaCommand(_scriptId, cmd, payload, runtime) {
if (cmd === 'log') {
const fn = payload?.level === 'error' ? console.error
: payload?.level === 'warn' ? console.warn : console.log;
fn('[rbxl-lua]', payload?.text || '');
return;
}
if (cmd === 'partSet') {
const pm = runtime.scene3d?.primitiveManager;
if (!pm) {
console.warn('[partSet] no primitiveManager. scene3d=', !!runtime.scene3d);
return;
}
const primId = payload?.primId;
const prop = payload?.prop;
const value = payload?.value;
const patch = {};
if (prop === 'position' && value) {
patch.x = value.x; patch.y = value.y; patch.z = value.z;
} else if (prop === 'cframe' && value) {
patch.x = value.x; patch.y = value.y; patch.z = value.z;
patch.rotationX = value.rx; patch.rotationY = value.ry; patch.rotationZ = value.rz;
} else if (prop === 'size' && value) {
patch.sx = value.sx; patch.sy = value.sy; patch.sz = value.sz;
} else if (prop === 'color') patch.color = value;
else if (prop === 'material') patch.material = value;
else if (prop === 'anchored') patch.anchored = value;
else if (prop === 'canCollide') patch.canCollide = value;
else if (prop === 'opacity') patch.opacity = value;
try {
if (typeof pm.updateInstance === 'function') pm.updateInstance(primId, patch);
else if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch);
else if (typeof pm.update === 'function') pm.update(primId, patch);
} catch (e) {
console.error('[partSet] updateInstance failed:', e);
}
return;
}
if (cmd === 'sceneCreate') {
// Lua: Instance.new("Part") + part.Parent = workspace → создание примитива.
// payload: { primId (предложенный id), type, x, y, z, sx, sy, sz, color, anchored }
try {
const pm = runtime.scene3d?.primitiveManager;
if (!pm || typeof pm.addInstance !== 'function') return;
const opts = {
id: payload?.primId,
x: payload?.x || 0, y: payload?.y || 0, z: payload?.z || 0,
sx: payload?.sx || 1, sy: payload?.sy || 1, sz: payload?.sz || 1,
color: payload?.color,
anchored: payload?.anchored !== false,
canCollide: payload?.canCollide !== false,
};
pm.addInstance(payload?.type || 'cube', opts);
// Если unanchored — регистрируем в физике на лету, иначе он не падает.
if (opts.anchored === false) {
try {
const dm = runtime.scene3d?.dynamics;
const data = pm.instances?.get?.(opts.id);
if (dm && data && typeof dm.registerPrimitive === 'function') {
dm.registerPrimitive(data);
}
} catch (e) {
console.warn('[sceneCreate] registerPrimitive failed', e);
}
}
} catch (e) {
console.error('[sceneCreate]', e);
}
return;
}
if (cmd === 'sceneDelete') {
// Lua: part:Destroy() → удаление примитива.
try {
const pm = runtime.scene3d?.primitiveManager;
if (!pm || typeof pm.removeInstance !== 'function') return;
const id = payload?.primId;
if (id != null) pm.removeInstance(Number(id));
} catch (e) {
console.error('[sceneDelete]', e);
}
return;
}
if (cmd === 'partVel') {
try {
const pm = runtime.scene3d?.primitiveManager;
if (pm && typeof pm.setVelocity === 'function') {
pm.setVelocity(payload.primId, payload.vx, payload.vy, payload.vz);
}
} catch (e) {}
return;
}
if (cmd === 'playerCmd') {
try {
const p = runtime.game?.player;
if (!p) return;
const method = payload?.method;
const args = payload?.args || [];
if (method === 'teleport') p.teleport && p.teleport(args[0], args[1], args[2]);
else if (method === 'setWalkSpeed') p.setWalkSpeed && p.setWalkSpeed(args[0]);
else if (method === 'setJumpPower') p.setJumpPower && p.setJumpPower(args[0]);
else if (method === 'setHealth') p.setHealth && p.setHealth(args[0]);
else if (method === 'die') p.die && p.die();
else if (method === 'damage' || method === 'takeDamage') p.damage && p.damage(args[0]);
} catch (e) {}
return;
}
if (cmd === 'guiUpdate') {
// TODO: scripts setting Visible/Text/Color on GUI → передать в GuiManager
return;
}
}

View File

@ -0,0 +1,243 @@
/**
* rbxl-lua-integration.test.js реалистичные Roblox-сниппеты из obby/simulator карт.
*/
import { LuaFactory } from 'wasmoon';
import { registerRobloxApi } from '../src/engine/roblox-shim.js';
import { RobloxScheduler } from '../src/engine/roblox-scheduler.js';
import { installRobloxServices } from '../src/engine/roblox-services.js';
import { RobloxTweenManager } from '../src/engine/roblox-tween.js';
import { RobloxPhysicsManager } from '../src/engine/roblox-physics.js';
function makeScene() {
return {
primitives: {
10: { id: 10, type: 'cube', name: 'KillPart', x: 5, y: 1, z: 0, sx: 4, sy: 1, sz: 4,
color: '#ff0000', material: 'neon', anchored: true, canCollide: true, opacity: 1 },
11: { id: 11, type: 'cube', name: 'WinPart', x: 30, y: 1, z: 0, sx: 4, sy: 1, sz: 4,
color: '#00ff00', material: 'neon', anchored: true, canCollide: true, opacity: 1 },
12: { id: 12, type: 'cube', name: 'Conveyor', x: 15, y: 1, z: 0, sx: 8, sy: 0.5, sz: 4,
color: '#888888', material: 'metal', anchored: true, canCollide: true, opacity: 1 },
13: { id: 13, type: 'cube', name: 'Door', x: 20, y: 3, z: 0, sx: 2, sy: 6, sz: 4,
color: '#a0522d', material: 'matte', anchored: true, canCollide: true, opacity: 1 },
},
};
}
const STORE = new Map();
async function run(luaSource, targetPrimId = 10, ticks = []) {
const factory = new LuaFactory();
const lua = await factory.createEngine();
const sent = [];
const send = (cmd, payload) => sent.push({ cmd, payload });
let playerState = { x: 0, y: 5, z: 0, hp: 100 };
registerRobloxApi(lua, { getSceneSnap: makeScene, targetPrimitiveId: targetPrimId, send });
const sched = new RobloxScheduler(lua);
sched.install();
installRobloxServices(lua, {
send,
getPlayerState: () => playerState,
loadSave: (k) => STORE.get(k),
saveSave: (k, v) => STORE.set(k, v),
removeSave: (k) => STORE.delete(k),
});
const tween = new RobloxTweenManager();
tween.install(lua);
const phys = new RobloxPhysicsManager(send);
phys.install(lua);
await sched.spawnMain(luaSource);
for (const dt of ticks) {
await sched.tick(dt);
tween.tick(dt);
phys.tick(dt);
}
lua.global.close();
return {
logs: sent.filter(s => s.cmd === 'log').map(s => s.payload),
partSets: sent.filter(s => s.cmd === 'partSet').map(s => s.payload),
partVels: sent.filter(s => s.cmd === 'partVel').map(s => s.payload),
playerCmds: sent.filter(s => s.cmd === 'playerCmd').map(s => s.payload),
};
}
const TESTS = [
{
name: 'KillBrick (Touched → Humanoid.Health = 0)',
lua: `
local part = script.Parent
part.Touched:Connect(function(hit)
local hum = hit.Parent and hit.Parent:FindFirstChild("Humanoid")
if hum then hum.Health = 0 end
end)
print("kill brick armed")
`,
ticks: [],
check: (r) => r.logs.some(l => l.text === 'kill brick armed'),
},
{
name: 'WalkSpeed boost через trigger',
lua: `
local h = game:GetService("Players").LocalPlayer.Character.Humanoid
h.WalkSpeed = 32
print("speed boosted to", h.WalkSpeed)
`,
check: (r) => r.playerCmds.some(c => c.method === 'setWalkSpeed' && c.args[0] === 32)
&& r.logs.some(l => l.text.includes('speed boosted')),
},
{
name: 'Door open: TweenService двигает дверь вверх',
lua: `
local door = workspace:FindFirstChild("Door")
local TS = game:GetService("TweenService")
local goal = { Position = Vector3.new(door.Position.X, door.Position.Y + 10, door.Position.Z) }
local info = TweenInfo.new(1, Enum.EasingStyle.Linear, Enum.EasingDirection.Out)
local tw = TS:Create(door, info, goal)
tw:Play()
print("door opening")
`,
ticks: [0.5, 0.5, 0.1],
check: (r) => r.partSets.some(p => p.primId === 13 && p.prop === 'position'),
},
{
name: 'Конвейер: BodyVelocity толкает игрока',
lua: `
local conv = workspace:FindFirstChild("Conveyor")
local bv = Instance.new("BodyVelocity", conv)
bv.Velocity = Vector3.new(20, 0, 0)
bv.MaxForce = Vector3.new(4000, 0, 4000)
print("conveyor started")
`,
ticks: [0.1],
check: (r) => r.partVels.some(v => v.primId === 12 && v.vx === 20),
},
{
name: 'leaderstats (как в tycoon)',
lua: `
local Players = game:GetService("Players")
local plr = Players.LocalPlayer
local money = Instance.new("IntValue", plr.leaderstats)
money.Name = "Money"
money.Value = 100
print("money:", money.Value)
`,
check: (r) => r.logs.some(l => l.text === 'money:\t100'),
},
{
name: 'Checkpoint сохраняется в DataStore',
lua: `
local DSS = game:GetService("DataStoreService")
local store = DSS:GetDataStore("checkpoints")
store:SetAsync("player1", 5)
local cp = store:GetAsync("player1")
print("checkpoint:", cp)
`,
check: (r) => r.logs.some(l => l.text === 'checkpoint:\t5'),
},
{
name: 'Цикл с wait — подсчёт',
lua: `
for i = 1, 3 do
print("count:", i)
wait(0.3)
end
print("done")
`,
ticks: [0.3, 0.3, 0.3, 0.3],
check: (r) => {
const texts = r.logs.map(l => l.text);
return texts.includes('count:\t1') && texts.includes('count:\t2')
&& texts.includes('count:\t3') && texts.includes('done');
},
},
{
name: 'task.spawn — параллельные функции',
lua: `
task.spawn(function() print("parallel A") end)
task.spawn(function() print("parallel B") end)
print("main")
`,
check: (r) => {
const texts = r.logs.map(l => l.text);
return texts.includes('parallel A') && texts.includes('parallel B') && texts.includes('main');
},
},
{
name: 'Color3 + Material смена при Touched',
lua: `
local part = workspace:FindFirstChild("KillPart")
part.Touched:Connect(function()
part.Color = Color3.fromRGB(0, 0, 255)
part.Material = "Neon"
end)
-- симулируем touch
part.Touched:Fire(workspace)
`,
check: (r) => r.partSets.some(p => p.primId === 10 && p.prop === 'color')
&& r.partSets.some(p => p.primId === 10 && p.prop === 'material'),
},
{
name: 'RemoteEvent: client→server message',
lua: `
local re = Instance.new("RemoteEvent", workspace)
re.Name = "Coins"
re.OnServerEvent:Connect(function(player, amount)
print("server received:", amount)
end)
re:FireServer(50)
`,
check: (r) => r.logs.some(l => l.text === 'server received:\t50'),
},
{
name: 'Heartbeat: счётчик через RunService',
lua: `
local RS = game:GetService("RunService")
local count = 0
RS.Heartbeat:Connect(function(dt)
count = count + 1
if count == 3 then print("tick3") end
end)
`,
ticks: [0.1, 0.1, 0.1],
check: (r) => r.logs.some(l => l.text === 'tick3'),
},
{
name: 'Math: Vector3 arithmetic',
lua: `
local a = Vector3.new(1, 2, 3)
local b = Vector3.new(4, 5, 6)
local sum = a:add(b)
print("sum:", sum.X, sum.Y, sum.Z)
local d = a:Dot(b)
print("dot:", d)
`,
check: (r) => {
const texts = r.logs.map(l => l.text);
return texts.some(t => t === 'sum:\t5\t7\t9') && texts.some(t => t === 'dot:\t32');
},
},
];
(async () => {
let passed = 0, failed = 0;
for (const t of TESTS) {
try {
const r = await run(t.lua, t.targetPrimId, t.ticks || []);
const ok = t.check(r);
if (ok) { console.log(`${t.name}`); passed++; }
else {
console.log(`${t.name}`);
console.log(` logs: ${JSON.stringify(r.logs.map(l => l.text))}`);
if (r.partSets.length) console.log(` partSets: ${JSON.stringify(r.partSets)}`);
if (r.partVels.length) console.log(` partVels: ${JSON.stringify(r.partVels)}`);
if (r.playerCmds.length) console.log(` playerCmds: ${JSON.stringify(r.playerCmds)}`);
failed++;
}
} catch (e) {
console.log(`${t.name} — exception: ${e.message || e}`);
failed++;
}
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
})();

187
tests/rbxl-lua-mvp.test.js Normal file
View File

@ -0,0 +1,187 @@
/**
* rbxl-lua-mvp.test.js headless smoke-тест Roblox Lua API shim.
*
* НЕ запускает Worker (это требует браузерного Worker API). Вместо этого
* напрямую импортирует roblox-shim.js и инициализирует Lua в текущем потоке.
*
* Запуск: node --experimental-vm-modules tests/rbxl-lua-mvp.test.js
*/
import { LuaFactory } from 'wasmoon';
import { registerRobloxApi } from '../src/engine/roblox-shim.js';
const FAKE_SCENE_SNAP = {
primitives: {
1: { id: 1, type: 'cube', name: 'Floor', x: 0, y: 0, z: 0, sx: 10, sy: 1, sz: 10,
color: '#888888', material: 'glossy', anchored: true, canCollide: true, opacity: 1 },
2: { id: 2, type: 'cube', name: 'KillBrick', x: 5, y: 1, z: 0, sx: 2, sy: 1, sz: 2,
color: '#ff0000', material: 'neon', anchored: true, canCollide: true, opacity: 1 },
},
};
const SNIPPETS = [
{
name: 'print hello',
lua: `print("Hello from Lua!")`,
expectLogs: [{ level: 'info', text: 'Hello from Lua!' }],
},
{
name: 'Vector3 math',
lua: `
local v = Vector3.new(3, 4, 0)
print("magnitude:", v.Magnitude)
local u = v.Unit
print("unit:", u.X, u.Y, u.Z)
`,
expectLogs: [
{ level: 'info', text: 'magnitude:\t5' },
],
},
{
name: 'workspace iteration',
lua: `
local children = workspace:GetChildren()
print("count:", #children)
for i, c in ipairs(children) do
print("child:", c.Name, "class:", c.ClassName)
end
`,
expectLogs: [
{ level: 'info', text: 'count:\t2' },
],
},
{
name: 'FindFirstChild',
lua: `
local kb = workspace:FindFirstChild("KillBrick")
if kb then print("found:", kb.Name)
else print("not found") end
`,
expectLogs: [{ level: 'info', text: 'found:\tKillBrick' }],
},
{
name: 'Part.Position get',
lua: `
local kb = workspace:FindFirstChild("KillBrick")
print("position:", kb.Position.X, kb.Position.Y, kb.Position.Z)
`,
expectLogs: [{ level: 'info', text: 'position:\t5\t1\t0' }],
},
{
name: 'Part.Color set',
lua: `
local kb = workspace:FindFirstChild("KillBrick")
kb.Color = Color3.new(0, 1, 0)
print("new color hex (via Position):", kb.Color.R, kb.Color.G, kb.Color.B)
`,
expectPartSet: { primId: 2, prop: 'color' },
},
{
name: 'CFrame.Angles',
lua: `
local cf = CFrame.Angles(0, math.pi/2, 0)
print("lookvector:", cf.LookVector.X, cf.LookVector.Y, cf.LookVector.Z)
`,
expectLogs: [],
},
{
name: 'Instance.new + Parent',
lua: `
local f = Instance.new("Folder", workspace)
f.Name = "MyFolder"
print("folder name:", f.Name, "parent:", f.Parent.Name)
`,
expectLogs: [{ level: 'info', text: 'folder name:\tMyFolder\tparent:\tWorkspace' }],
},
{
name: 'IsA hierarchy',
lua: `
local kb = workspace:FindFirstChild("KillBrick")
print("isa Part:", kb:IsA("Part"))
print("isa BasePart:", kb:IsA("BasePart"))
print("isa Instance:", kb:IsA("Instance"))
print("isa Sound:", kb:IsA("Sound"))
`,
expectLogs: [
{ level: 'info', text: 'isa Part:\ttrue' },
{ level: 'info', text: 'isa BasePart:\ttrue' },
{ level: 'info', text: 'isa Instance:\ttrue' },
{ level: 'info', text: 'isa Sound:\tfalse' },
],
},
];
async function runSnippet(snippet) {
const factory = new LuaFactory();
const lua = await factory.createEngine();
const logs = [];
const sent = [];
const send = (cmd, payload) => sent.push({ cmd, payload });
registerRobloxApi(lua, {
getSceneSnap: () => FAKE_SCENE_SNAP,
targetPrimitiveId: 2, // как будто скрипт прикреплён к KillBrick
send,
});
// Перехват print через send('log', ...)
let errMsg = null;
try {
await lua.doString(snippet.lua);
} catch (e) {
errMsg = e && e.message ? e.message : String(e);
}
lua.global.close();
const captured = sent.filter(s => s.cmd === 'log');
return { logs: captured.map(s => s.payload), partSets: sent.filter(s => s.cmd === 'partSet'), error: errMsg };
}
(async () => {
let passed = 0;
let failed = 0;
for (const s of SNIPPETS) {
const result = await runSnippet(s);
const ok = checkExpectations(s, result);
if (ok.success) {
console.log(`${s.name}`);
passed++;
} else {
console.log(`${s.name}`);
console.log(` error: ${result.error || 'none'}`);
console.log(` logs received:`);
for (const l of result.logs) console.log(` [${l.level}] ${JSON.stringify(l.text)}`);
if (result.partSets.length) {
console.log(` partSets:`, JSON.stringify(result.partSets));
}
console.log(` reason: ${ok.reason}`);
failed++;
}
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
})();
function checkExpectations(snippet, result) {
if (result.error) {
return { success: false, reason: `lua error: ${result.error}` };
}
if (snippet.expectLogs) {
for (const exp of snippet.expectLogs) {
const found = result.logs.find(l => l.level === exp.level && l.text === exp.text);
if (!found) {
return { success: false, reason: `missing log: [${exp.level}] ${JSON.stringify(exp.text)}` };
}
}
}
if (snippet.expectPartSet) {
const found = result.partSets.find(s =>
s.payload.primId === snippet.expectPartSet.primId &&
s.payload.prop === snippet.expectPartSet.prop
);
if (!found) {
return { success: false, reason: `missing partSet ${JSON.stringify(snippet.expectPartSet)}` };
}
}
return { success: true };
}

View File

@ -0,0 +1,144 @@
/**
* rbxl-lua-services.test.js тесты Humanoid, RemoteEvent, DataStore, HttpService.
*/
import { LuaFactory } from 'wasmoon';
import { registerRobloxApi } from '../src/engine/roblox-shim.js';
import { RobloxScheduler } from '../src/engine/roblox-scheduler.js';
import { installRobloxServices } from '../src/engine/roblox-services.js';
const SCENE = { primitives: {} };
const STORE = new Map();
async function run(luaSource, ticks = []) {
const factory = new LuaFactory();
const lua = await factory.createEngine();
const sent = [];
const send = (cmd, payload) => sent.push({ cmd, payload });
let playerState = { x: 0, y: 5, z: 0 };
registerRobloxApi(lua, { getSceneSnap: () => SCENE, targetPrimitiveId: null, send });
const sched = new RobloxScheduler(lua);
sched.install();
installRobloxServices(lua, {
send,
getPlayerState: () => playerState,
loadSave: (k) => STORE.get(k),
saveSave: (k, v) => STORE.set(k, v),
removeSave: (k) => STORE.delete(k),
});
await sched.spawnMain(luaSource);
for (const dt of ticks) await sched.tick(dt);
lua.global.close();
return { logs: sent.filter(s => s.cmd === 'log').map(s => s.payload),
playerCmds: sent.filter(s => s.cmd === 'playerCmd').map(s => s.payload),
broadcasts: sent.filter(s => s.cmd === 'broadcast').map(s => s.payload) };
}
const TESTS = [
{
name: 'Players.LocalPlayer.Character.Humanoid существует',
lua: `
local p = game:GetService("Players").LocalPlayer
local h = p.Character:WaitForChild("Humanoid")
print("hp:", h.Health, "ws:", h.WalkSpeed)
`,
expect: [{ level: 'info', text: 'hp:\t100\tws:\t16' }],
},
{
name: 'Humanoid.WalkSpeed = 50 → playerCmd setWalkSpeed',
lua: `
local h = game:GetService("Players").LocalPlayer.Character.Humanoid
h.WalkSpeed = 50
`,
expectPlayerCmd: { method: 'setWalkSpeed', argsCheck: (a) => a[0] === 50 },
},
{
name: 'Humanoid:TakeDamage уменьшает HP',
lua: `
local h = game:GetService("Players").LocalPlayer.Character.Humanoid
h:TakeDamage(30)
print("after damage:", h.Health)
`,
expect: [{ level: 'info', text: 'after damage:\t70' }],
},
{
name: 'Humanoid.Health = 0 → Died fires',
lua: `
local h = game:GetService("Players").LocalPlayer.Character.Humanoid
h.Died:Connect(function() print("DIED") end)
h.Health = 0
`,
expect: [{ level: 'info', text: 'DIED' }],
},
{
name: 'DataStoreService GetAsync/SetAsync',
lua: `
local DSS = game:GetService("DataStoreService")
local store = DSS:GetDataStore("coins")
store:SetAsync("player1", 100)
print("got:", store:GetAsync("player1"))
`,
expect: [{ level: 'info', text: 'got:\t100' }],
},
{
name: 'DataStoreService IncrementAsync',
lua: `
local store = game:GetService("DataStoreService"):GetDataStore("score")
store:SetAsync("p1", 50)
store:IncrementAsync("p1", 25)
print("final:", store:GetAsync("p1"))
`,
expect: [{ level: 'info', text: 'final:\t75' }],
},
{
name: 'HttpService:JSONEncode/Decode',
lua: `
local HS = game:GetService("HttpService")
local s = HS:JSONEncode({a=1, b="two"})
print("encoded len:", #s)
local d = HS:JSONDecode('{"x":42}')
print("decoded x:", d.x)
`,
expect: [{ level: 'info', text: 'decoded x:\t42' }],
},
{
name: 'RemoteEvent FireServer + OnServerEvent',
lua: `
local re = Instance.new("RemoteEvent", workspace)
re.Name = "MyEvent"
re.OnServerEvent:Connect(function(player, msg)
print("server got:", msg)
end)
re:FireServer("hello")
`,
expect: [{ level: 'info', text: 'server got:\thello' }],
},
];
(async () => {
let passed = 0, failed = 0;
for (const t of TESTS) {
try {
const r = await run(t.lua, t.ticks);
let ok = true; let reason = '';
for (const exp of (t.expect || [])) {
const found = r.logs.find(l => l.level === exp.level && l.text === exp.text);
if (!found) { ok = false; reason = `missing log: ${exp.text}; got: ${JSON.stringify(r.logs)}`; break; }
}
if (t.expectPlayerCmd) {
const found = r.playerCmds.find(c => c.method === t.expectPlayerCmd.method
&& (!t.expectPlayerCmd.argsCheck || t.expectPlayerCmd.argsCheck(c.args)));
if (!found) { ok = false; reason = `missing playerCmd ${t.expectPlayerCmd.method}; got: ${JSON.stringify(r.playerCmds)}`; }
}
if (ok) { console.log(`${t.name}`); passed++; }
else { console.log(`${t.name}${reason}`); failed++; }
} catch (e) {
console.log(`${t.name} — exception: ${e.message || e}`);
failed++;
}
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
})();

View File

@ -0,0 +1,89 @@
/**
* rbxl-lua-tween.test.js тесты TweenService.
*/
import { LuaFactory } from 'wasmoon';
import { registerRobloxApi } from '../src/engine/roblox-shim.js';
import { RobloxScheduler } from '../src/engine/roblox-scheduler.js';
import { RobloxTweenManager } from '../src/engine/roblox-tween.js';
const SCENE = {
primitives: {
1: { id: 1, type: 'cube', name: 'Movable', x: 0, y: 5, z: 0, sx: 1, sy: 1, sz: 1,
color: '#ffffff', material: 'glossy', anchored: false, canCollide: true, opacity: 1 },
},
};
async function run(luaSource, ticks = []) {
const factory = new LuaFactory();
const lua = await factory.createEngine();
const sent = [];
const send = (cmd, payload) => sent.push({ cmd, payload });
registerRobloxApi(lua, { getSceneSnap: () => SCENE, targetPrimitiveId: 1, send });
const sched = new RobloxScheduler(lua);
sched.install();
const tweenMgr = new RobloxTweenManager();
tweenMgr.install(lua);
await sched.spawnMain(luaSource);
for (const dt of ticks) {
await sched.tick(dt);
tweenMgr.tick(dt);
}
lua.global.close();
return { logs: sent.filter(s => s.cmd === 'log').map(s => s.payload),
partSets: sent.filter(s => s.cmd === 'partSet').map(s => s.payload) };
}
const TESTS = [
{
name: 'TweenInfo создаётся',
lua: `
local info = TweenInfo.new(2, Enum.EasingStyle.Linear, Enum.EasingDirection.Out)
print("time:", info.Time, "style:", info.EasingStyle)
`,
ticks: [],
expectLogs: [{ level: 'info', text: 'time:\t2\tstyle:\tLinear' }],
},
{
name: 'TweenService:Create + Play (Linear)',
lua: `
local TS = game:GetService("TweenService")
local p = workspace:FindFirstChild("Movable")
local info = TweenInfo.new(1, Enum.EasingStyle.Linear, Enum.EasingDirection.Out)
local tw = TS:Create(p, info, { Position = Vector3.new(10, 5, 0) })
tw:Play()
print("started")
`,
ticks: [0.5, 0.5, 0.1], // больше 1 сек — должен завершиться
// Ожидаем что хотя бы один partSet с prop=position
expectPartSet: { primId: 1, prop: 'position' },
},
];
(async () => {
let passed = 0, failed = 0;
for (const t of TESTS) {
try {
const r = await run(t.lua, t.ticks);
let ok = true;
let reason = '';
for (const exp of (t.expectLogs || [])) {
const found = r.logs.find(l => l.level === exp.level && l.text === exp.text);
if (!found) { ok = false; reason = `missing log: ${exp.text}`; break; }
}
if (t.expectPartSet) {
const found = r.partSets.find(p => p.primId === t.expectPartSet.primId && p.prop === t.expectPartSet.prop);
if (!found) {
ok = false; reason = `missing partSet: ${JSON.stringify(t.expectPartSet)}; got: ${JSON.stringify(r.partSets.slice(0,3))}`;
}
}
if (ok) { console.log(`${t.name}`); passed++; }
else { console.log(`${t.name}${reason}`); failed++; }
} catch (e) {
console.log(`${t.name} — exception: ${e}`);
failed++;
}
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
})();

104
tests/rbxl-lua-wait.test.js Normal file
View File

@ -0,0 +1,104 @@
/**
* rbxl-lua-wait.test.js тесты wait/task.wait через шедулер.
*/
import { LuaFactory } from 'wasmoon';
import { registerRobloxApi } from '../src/engine/roblox-shim.js';
import { RobloxScheduler } from '../src/engine/roblox-scheduler.js';
const SCENE = { primitives: {} };
async function run(luaSource, ticks = [0.5, 0.5, 0.5, 0.5, 0.5]) {
const factory = new LuaFactory();
const lua = await factory.createEngine();
const sent = [];
const send = (cmd, payload) => sent.push({ cmd, payload });
registerRobloxApi(lua, { getSceneSnap: () => SCENE, targetPrimitiveId: null, send });
const sched = new RobloxScheduler(lua);
sched.install();
await sched.spawnMain(luaSource);
for (const dt of ticks) {
await sched.tick(dt);
}
lua.global.close();
return sent.filter(s => s.cmd === 'log').map(s => s.payload);
}
const TESTS = [
{
name: 'wait(0) — мгновенный',
lua: `
print("before")
wait(0)
print("after")
`,
expect: ['before', 'after'],
},
{
name: 'wait(1) — резюм после tick',
lua: `
print("step1")
wait(1)
print("step2")
`,
ticks: [0.5, 0.5, 0.5], // 1.5 сек суммарно
expect: ['step1', 'step2'],
},
{
name: 'task.wait(0.5)',
lua: `
print("a")
task.wait(0.5)
print("b")
`,
ticks: [0.5, 0.5],
expect: ['a', 'b'],
},
{
name: 'несколько wait подряд',
lua: `
print("p1")
wait(0.5)
print("p2")
wait(0.5)
print("p3")
`,
ticks: [0.5, 0.5, 0.5, 0.5], // 2 сек
expect: ['p1', 'p2', 'p3'],
},
{
name: 'task.delay (не блокирует)',
lua: `
print("immediate")
task.delay(0.3, function() print("delayed") end)
print("after delay-call")
`,
ticks: [0.5],
expect: ['immediate', 'after delay-call', 'delayed'],
},
];
(async () => {
let passed = 0, failed = 0;
for (const t of TESTS) {
try {
const logs = await run(t.lua, t.ticks);
const texts = logs.map(l => l.text);
const ok = JSON.stringify(texts) === JSON.stringify(t.expect);
if (ok) {
console.log(`${t.name}`);
passed++;
} else {
console.log(`${t.name}`);
console.log(` expected: ${JSON.stringify(t.expect)}`);
console.log(` got: ${JSON.stringify(texts)}`);
failed++;
}
} catch (e) {
console.log(`${t.name} — exception: ${e}`);
failed++;
}
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
})();