Compare commits

..

11 Commits

Author SHA1 Message Date
min
73d7ae4765 merge main (синхрон перед PR синхрона скриптов)
All checks were successful
CI / Lint (pull_request) Successful in 1m6s
CI / Build (pull_request) Successful in 1m59s
CI / Secret scan (pull_request) Successful in 28s
CI / PR size check (pull_request) Successful in 10s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-06-08 06:28:56 +03:00
min
58edaef2ab fix(studio): Team Create — синхрон скриптов (upsertScript/removeScript)
Раньше синхронизировались только примитивы/модели/блоки, а скрипты нет —
у соавтора было 0 скриптов. Перехватываем scene.upsertScript/removeScript,
шлём op scriptUpsert/scriptRemove; applyRemoteOp применяет их у соавтора
(список в Hierarchy обновляется штатным setInterval). Лимит соавторов 5
(MAX_COLLABORATORS в realtime StudioRoom, задеплоен на VM110).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 06:28:45 +03:00
min
dbd23e474a Merge pull request 'feat: импорт Roblox .rbxl карт (тест-фича МИНа)' (#33) from feat/rbxl-import into main
All checks were successful
CI / Lint (push) Successful in 1m8s
CI / Build (push) Successful in 1m59s
CI / Secret scan (push) Successful in 21s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 3m24s
2026-06-08 03:25:58 +00:00
min
2b98d8338b Merge remote-tracking branch 'origin/main' into feat/rbxl-import
All checks were successful
CI / Lint (pull_request) Successful in 1m8s
CI / Build (pull_request) Successful in 2m8s
CI / Secret scan (pull_request) Successful in 23s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
# Conflicts:
#	src/editor/HierarchyPanel.jsx
2026-06-08 06:24:27 +03:00
min
9caea93d32 feat(rbxl-import): single-VM Lua runtime + GUI tree + Touched/click events
All checks were successful
CI / Lint (pull_request) Successful in 1m6s
CI / Build (pull_request) Successful in 1m58s
CI / Secret scan (pull_request) Successful in 23s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
ITERATION 6 (single-VM rewrite):
  - RobloxLuaSharedWorker: один wasmoon-state на 742 скрипта (не 742 VM)
  - Pre-populated Workspace + Player.PlayerGui перед addScripts
  - На каждом Part — Touched/TouchEnded сигналы; на каждом TextButton —
    MouseButton1Click/Activated/MouseEnter/Leave; Humanoid с Died/Health
  - Двухфазный init: addScriptsBatch ВСЕ скрипты → kickoff() с PlayerAdded
  - wait()/task.wait/task.spawn/task.delay через scheduler+coroutines
  - guiClick от Rublox-GUI → fireEvent → инстанс.MouseButton1Click.Fire()
  - playerTouch → part.Touched.Fire(HumanoidRootPart) + humanoid.Touched

ITERATION 7 (nullStub compatibility):
  - debug.setmetatable(nil, ...) + debug.setmetatable(function() end, ...)
    с полным набором __index/__newindex/__call/__add/__sub/.../__len/__concat
  - Возврат undefined из FindFirstChild/WaitForChild (вместо JS proxy)
  - Lua-side __null_stub_singleton с Connect/connect/Wait/Fire (lowercase aliases)
  - __rbxl_lookup_part через __rbxl_parts_by_id table (не ipairs на JS array)
  - script.Parent гарантированно не nil (либо реальный Part либо null stub)
  - RbxSignal: Connect+connect, Wait+wait, Fire+fire, Disconnect+disconnect
  - SIGNAL_NAMES whitelist расширен: Tool (Selected/Equipped), Remote (OnInvoke),
    ChatMakeSystemMessage, etc.

Converter:
  - GUI: UDim2 dataclass правильно резолвится (scale*100 + offset/viewport*100)
  - размеры в процентах (как Rublox-GuiOverlay ожидает)
  - ScreenGui.Enabled → пропагируется в детей
  - Эвристика скрыть HD Admin/Chat/CommandBar/TeleportTo модалки
  - rbxasset:// rbxassetid:// фильтруются на пустой URL

Hierarchy:
  - scroll-to-selected раскрывает workspace+rootPrims+folders перед scroll
  - data-sel-id на всех ItemRow с rAF×2 timing

Известные ограничения:
  - Synapse X obfuscated скрипты часто всё равно падают (требуют конкретный Roblox-VM)
  - но debug.setmetatable перехват не даёт скриптам валиться на indexing/arithmetic
  - реальные пользовательские KillBrick (Touched) теперь работают
  - GUI кнопки → MouseButton1Click работают

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 06:18:55 +03:00
min
7cbcdce6f9 Merge pull request 'Fix Team Create: ����� �������� ������ � ������, ������ ����� ����, ���� �� �������' (#35) from restore/all-tasks into main
All checks were successful
CI / Lint (push) Successful in 1m7s
CI / Build (push) Successful in 1m56s
CI / Secret scan (push) Successful in 22s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 3m25s
2026-06-08 03:07:24 +00:00
min
513c9ce26f fix(hierarchy): scroll-to-selected раскрывает workspace+группы+папки
Бага: при выделении объекта в 3D-сцене иерархия не скроллила к нему
потому что:
  - workspaceOpen=false скрывал всю секцию Сцены
  - rootPrimsOpen/rootBlocksOpen/rootModelsOpen=false скрывали корневые
    группы
  - openFolders=пустой Set скрывал все папки с импортированной геометрией

Фикс: effect раскрывает все секции содержащие выбранный объект перед
скроллом. После раскрытия использует rAF×2 чтобы дать React дорендерить
ItemRow'ы, потом scrollIntoView.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 02:21:29 +03:00
min
cc6447b851 fix(rbxl-import): UDim2 viewport-relative + rbxasset URL filter
All checks were successful
CI / Lint (pull_request) Successful in 1m5s
CI / Build (pull_request) Successful in 2m1s
CI / Secret scan (pull_request) Successful in 27s
CI / PR size check (pull_request) Successful in 11s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
- UDim2: scale теперь умножается на viewport reference (1280×720),
  раньше игнорировался — фреймы получали 0×0 и фейлились на дефолт 100×30
  или наоборот заполняли всё окно
- _udim2_pair(): пара (x,y) через _udim2_to_rublox(axis='x'|'y')
- Фильтр rbxasset:// rbxassetid:// rbxhttp:// rbxthumb:// URL'ов на пустую
  строку — браузер их не загружает, спам в console исчезает

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 01:49:20 +03:00
min
624bbc636b feat(rbxl-import): single-VM, Touched, scroll-to-selected, GUI
Some checks failed
CI / Lint (pull_request) Failing after 1m5s
CI / Build (pull_request) Failing after 49s
CI / Secret scan (pull_request) Successful in 25s
CI / PR size check (pull_request) Successful in 9s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Все 5 задач итерации:

1. Single-VM mode (RobloxLuaSharedWorker/Sandbox):
   - один Worker, одна wasmoon-VM на ВСЕ скрипты проекта
   - addScript() для каждого, общий tick/event broadcast
   - снимает WASM OOM (1 VM 16MB вместо 742 × 16MB)
   - убран per-script лимит 50, теперь все 742 загружаются

2. Touched events:
   - sendGlobalEvent в shared sandbox распознаёт playerTouch
     и пересылает в Worker как 'touched' с primId
   - Worker находит Part по __primId в workspace и Fire'ит
     его Touched сигнал — Lua-обработчики работают

3. Click в иерархии → scroll-to-selected:
   - useEffect в HierarchyPanel ловит изменение selection
     и scrollIntoView для нужного ItemRow
   - data-sel-id атрибут на primitive/model/block строках

4. GUI Roblox в конвертере:
   - ScreenGui/Frame/TextLabel/TextButton/ImageLabel/TextBox →
     scene.gui c полным набором свойств (UDim2→pixel, Color3→hex,
     BackgroundTransparency→bgOpacity, parentId)

5. Чистка:
   - удалены debug-console.warn из PlayerController._loadPlayerModel
     (убирает spam '[PlayerController.devlog]' в consoles)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 01:39:43 +03:00
min
412bb2fad9 feat(rbxl-import): студия исполняет импортированные Roblox-Lua скрипты
All checks were successful
CI / Lint (pull_request) Successful in 2m43s
CI / Build (pull_request) Successful in 1m57s
CI / Secret scan (pull_request) Successful in 1m21s
CI / PR size check (pull_request) Successful in 8s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Сегодня доведены до играбельного состояния:
- UI модалка импорта подключена в KubikonStudio (кнопка для МИНа в навигации)
- Converter: SCALE 0.35 (карта пропорциональна R15-персонажу),
  playerModelType='skin_bacon-hair', Lua упакован в поле code с маркером
  // @roblox-lua (storys API сохраняет только {id,code,target,name})
- vite.config: api+статика через rublox.pro/minecraftia-school.ru
- GameRuntime: распознаёт маркер, запускает через RobloxLuaSandbox
  + wasmoon Worker. Фильтрация: target!=null + lua<2500б +
  лимит 50 sandbox'ов (WASM OOM при >50 VM)
- roblox-shim: nullStub (Proxy с no-op методами) вместо null
  для FindFirstChild/WaitForChild — цепочки не падают
- require() заменён на nullStub
- RobloxLuaSandbox: совместимость с интерфейсом ScriptSandbox
  (sendGlobalEvent/SceneSnapshot/etc — no-op заглушки)
- RobloxLuaWorker: pcall обёртка над user-кодом
- remoteDevlog.js + /devlog endpoint: автосбор browser-логов
- PlayerController._loadSkinManifest: dev-fallback на studio.rublox.pro

Тест на Easy Obby:
- 8205 instances → 2245 primitives + 742 Lua-scripts
- 50/742 Lua-VM запущены (KillBrick handlers и т.п.),
  151 отфильтровано как admin/chat services, 541 пропущено по памяти
- Скин bacon-hair виден, FPS 20-25
- Сцена играется, можно ходить, прыгать

TODO (следующая итерация):
- Single-VM mode для wasmoon (один Lua-state на 742 скрипта,
  убрать WASM OOM)
- Реализовать select/focus в иерархии для импортированных карт
- Touched events от Babylon impostor → Lua-shim сигналы
- Поддержка GUI (ScreenGui/Frame/TextLabel) в конвертере

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 21:13:16 +03:00
min
c375ae01ac feat(rbxl-import): импорт Roblox .rbxl карт в Rublox-проекты
All checks were successful
CI / Lint (pull_request) Successful in 1m6s
CI / Build (pull_request) Successful in 2m2s
CI / Secret scan (pull_request) Successful in 26s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Тест-фича для МИНа. Полное описание в rbxl-importer/INFO_PROCESS.md.

Backend (rbxl-importer/ на VM 130 S1):
- Python-парсер Roblox Binary (28+ типов значений)
- Asset downloader через Marfusha proxy + .ROBLOSECURITY cookie
- Mesh→GLB конвертер (v1-v5)
- Converter Roblox-классов → project_data
- Flask API: /analyze + /create

Frontend:
- API.js + components/RbxlImportModal.jsx (drag-n-drop)

Тестовый импорт Easy Obby: project_id 2697,
2244 primitives + 742 lua-scripts + 5 ассетов.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 18:24:27 +03:00
37 changed files with 7511 additions and 21 deletions

13
.WORKTREE_NOTICE.md Normal file
View File

@ -0,0 +1,13 @@
# Активная сессия: импорт Roblox .rbxl
Это **отдельный worktree** для разработки `feat/rbxl-import` — импорта Roblox-карт в Rublox.
**Не работайте здесь параллельно из других сессий!** Активная разработка идёт в этой папке отдельно от `Desktop/server/rublox-studio` (где идут другие задачи).
Связанный план: см. `INFO_PROCESS.md` в той же папке (когда заполнится).
Ветка: `feat/rbxl-import`
Сервис на сервере: VM 130 на S1 (`192.168.1.130`), `/opt/rbxl-importer/`
Сопутствующий worktree: `Desktop/player-rbxl-import` (плеер с Lua-runtime).
Started: 2026-06-07

22
package-lock.json generated
View File

@ -7,6 +7,7 @@
"": { "": {
"name": "rublox-studio", "name": "rublox-studio",
"version": "1.0.0", "version": "1.0.0",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@babylonjs/core": "7.54.3", "@babylonjs/core": "7.54.3",
@ -21,7 +22,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",
@ -1459,6 +1461,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",
@ -5340,6 +5348,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

@ -53,7 +53,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",

100
rbxl-importer/CHANGELOG.md Normal file
View File

@ -0,0 +1,100 @@
# rbxl-importer: лог разработки
## 2026-06-07
### Фаза 0. Подготовка (✓)
- Освобождено место на S1: удалён `pve/data` LV (+133 GB), VM 111/112/114/116 (+285 GB). Свободно стало 419 GB в VG `pve`.
- Создана **VM 130 rbxl-importer** (IP 192.168.1.130, Ubuntu 22.04, 4 vCPU, 4 GB RAM, 200 GB).
- Установлены: Docker, Python 3.11+venv, nginx, postgresql-client.
- Клонированы **studio-rbxl-import** и **player-rbxl-import** worktree, ветка `feat/rbxl-import`.
- Smoke-test парсера на `Escape Easy Obby Parkour Uncopylocked.rbxl` (8205 instances, 120 классов).
### Фаза 1. Парсер `.rbxl` (✓)
- Реализованы файлы: `rbxl_binreader.py`, `rbxl_types.py`, `rbxl_parser.py`.
- Декодирование 28+ Roblox PROP типов: String, Bool, Int32, Float, Double, UDim, UDim2, Ray, Faces, Axes, BrickColor, Color3, Vector2, Vector3, CFrame, Quaternion, Enum, Referent, Vector3int16, NumberSequence, ColorSequence, NumberRange, Rect, PhysicalProperties, Color3uint8, Int64, SharedString, Bytecode, OptionalCFrame, UniqueId, Font.
- Особенности формата покрыты: interleaved-transformed массивы, zigzag для signed int, Roblox float encoding, LZ4 chunks.
- **Протестировано на 6 файлах: 0 warnings**:
- `easy_obby.rbxl` (Easy Obby Parkour, 437 KB, 8205 instances)
- `miners-haven.rbxl` (Miners Haven, 8 MB, **60950 instances**)
- 4 synthetic из `rojo-rbx/rbx-dom/benches/files/`
### Фаза 2. Asset pipeline (✓)
- БД: миграция `001_roblox_assets.sql` (3 таблицы) применена в `storys_db` (S2 primary через autossh туннель S1 PVE 192.168.1.152:25435).
- `asset_downloader.py`: дедупликация по `rbx_asset_id` + sha256, retry с backoff, классификация по content-type/magic bytes.
- `asset_proxy.py`: режимы `disabled` / `direct` / `http_proxy` / `cloudflare_worker`. Используется `http_proxy` через Marfusha xray (85.192.61.244:39237).
- **Cookie auth**: `.ROBLOSECURITY` от аккаунта `minkorenovsk2` сохранён в `/home/min/.roblosecurity` на VM 130, EnvironmentFile подключен в systemd unit.
- `mesh_converter.py`: парсер Roblox `.mesh` v1-v5 + GLB writer (glTF 2.0 binary).
- **v1.00** ASCII протестирован: 500 facets, 1500 vertices → 54900 байт GLB.
- v2-v5 binary — написаны, проверим на реальных файлах.
- `nginx` на VM 130: `/opt/roblox-assets/` отдаётся как `https://assets.rublox.pro/roblox/...` с CORS.
### Фаза 3. Конвертер геометрии (✓)
- `converter.py`: маппинг 30+ Roblox-классов → Rublox `project_data`.
- `Part`, `WedgePart`, `CornerWedgePart`, `TrussPart` → primitives (cube/wedge/cornerwedge).
- `MeshPart`, `UnionOperation` → glbModels (с fallback на bbox cube).
- `SpawnLocation` → scene.spawnPoint.
- `Lighting` → scene.environment.
- `Sound` → scene.sounds.
- `Script`/`LocalScript`/`ModuleScript` → scene.scripts с kind='roblox-lua' и raw lua_source.
- Material enum (Plastic→glossy, Neon→neon, Metal→metal, Glass→glass, ...).
- CFrame → position + Euler XYZ (system axes Roblox = Babylon: right-handed Y-up).
- Scale: 1 Roblox stud = 0.28 м (настраиваемо).
- **Easy Obby результат**: 2244 primitives + 742 lua-scripts + 5 ассетов (sounds) для скачки.
### Фаза 4. Lua-runtime + Roblox API shim (✓)
- **wasmoon** (Lua 5.4 WASM) интегрирован в `player/studio` (npm install).
- `RobloxLuaWorker.js` — Worker-хост с инициализацией wasmoon, IPC с main thread.
- `RobloxLuaSandbox.js` — main-side обёртка.
- `roblox-shim.js` — math классы (Vector3, Color3, CFrame, UDim2), Instance прокси (game, workspace, script, GetService, GetChildren, FindFirstChild, IsA с иерархией классов), Part свойства (Position/CFrame/Size/Color/Material/Anchored/CanCollide/Transparency), RBXScriptSignal (Touched, Heartbeat, Stepped, RenderStepped, Connect, Wait, Disconnect).
- `roblox-scheduler.js` — корутины через `coroutine.create/resume/yield`, шедулер для wait/task.wait/task.delay/task.spawn, автоматический fire Heartbeat/Stepped/RenderStepped на каждом tick.
- `roblox-tween.js` — TweenService с 10 easing-функциями (Linear, Quad, Cubic, Quart, Quint, Sine, Bounce, Elastic, Back, Exponential) для Vector3/Color3/CFrame/number.
- `roblox-services.js` — Players, LocalPlayer, Character, Humanoid (Health, WalkSpeed, JumpPower, TakeDamage, Died), UserInputService, RemoteEvent (FireServer/FireClient/OnServerEvent), RemoteFunction, DataStoreService (GetAsync/SetAsync/IncrementAsync), HttpService (JSONEncode/Decode), ContextActionService stub.
- `roblox-physics.js` — BodyVelocity, BodyGyro, BodyPosition, BodyForce, BodyAngularVelocity, AlignPosition, LinearVelocity.
### Тесты Lua-runtime: **36/36 ✓**
- `tests/rbxl-lua-mvp.test.js` — math + Instance + Part + IsA (**9/9**)
- `tests/rbxl-lua-wait.test.js` — корутины + wait/task.wait/task.delay (**5/5**)
- `tests/rbxl-lua-tween.test.js` — TweenService + Linear easing (**2/2**)
- `tests/rbxl-lua-services.test.js` — Humanoid + DataStore + HttpService + RemoteEvent (**8/8**)
- `tests/rbxl-lua-integration.test.js` — реалистичные obby/simulator снейппеты (**12/12**):
KillBrick, WalkSpeed boost, Tween door, BodyVelocity конвейер, leaderstats, DataStore checkpoint, циклы с wait, task.spawn параллель, Color3 + Material смена, RemoteEvent client→server, Heartbeat счётчик, Vector3 arithmetic.
### Фаза 5. Flask API + UI (✓)
- `src/app.py` Flask:
- `GET /health` → ok
- `POST /import/rbxl/analyze` → парсер + report + preview_hash (Redis 20 мин TTL)
- `POST /import/rbxl/create` → скачка ассетов + конверт mesh→GLB + INSERT в kubikon3d_projects
- Запущен через **systemd unit** `rbxl-importer.service` (Restart=on-failure, EnvironmentFile с cookie).
- Redis (Docker `redis-rbxl`) для preview cache.
- `studio/src/components/RbxlImportModal.jsx` — React компонент с drag-n-drop, отчётом, формой создания. Доступен только МИНу.
- `studio/src/api/rbxlImporterApi.js` — клиент.
- Тест-результат: **Easy Obby импортирован как project_id 2697** (2244 primitives, 742 lua-scripts, 5 ассетов скачано без ошибок).
### Фаза 6. Совместимость с плеером + DNS (✓)
- `GameRuntime.js`: добавлен `_startRobloxLuaScript()` метод и ветка `if (s.kind === 'roblox-lua')` в `start()`.
- `_handleRobloxLuaCommand()`: маппит IPC команды от Lua-sandbox (partSet, partVel, playerCmd) на PrimitiveManager и game.player API.
- `_buildRobloxLuaSceneSnap()`: преобразует projectData.scene.primitives → формат для Lua (workspace:GetChildren).
- **NPM proxy_host** на S1 NPM (VM 101 192.168.1.43): `assets.rublox.pro` и `api-rbxl.rublox.pro` → VM 130:80.
- **DNS Cloudflare**: 2 A-записи (proxied=false) → 85.175.7.40 (S1 публичный IP).
- **End-to-end протестировано**: `https://api-rbxl.rublox.pro/health` → 200 OK.
### Фаза 7. Документация (в работе)
- README.md в rbxl-importer/
- INFO_PROCESS.md (этот файл)
- TODO: commit + PR в Gitea для studio и player worktree.
## Известные ограничения
- Lua-runtime пока MVP: нет GUI (ScreenGui/Frame/TextLabel), нет `:Wait()` на сигналах через корутины (только `Connect`), нет Animation/KeyframeSequence.
- CSG meshes (UnionOperation) парсятся, но конверт в GLB не реализован — bbox cube fallback.
- Terrain voxel grid конвертится в заглушку (плоский ландшафт).
- TouchEvent в плеере не fire'ится автоматически из физики Babylon — нужно добавить collision broadcaster.

111
rbxl-importer/README.md Normal file
View File

@ -0,0 +1,111 @@
# rbxl-importer
Конвертер Roblox `.rbxl` карт в проекты Rublox. Состоит из:
- **Python-парсер** Roblox Binary Level формата (v0+)
- **Asset downloader** с дедупликацией (Marfusha proxy + `.ROBLOSECURITY` cookie)
- **Mesh→GLB конвертер** (Roblox `.mesh` v1-v5)
- **Roblox-LUA runtime** (wasmoon, реализован в репо `rublox/player`)
- **Flask API** + **React UI модалка** в `rublox/studio`
## Архитектура
```
[Студия] [VM 130 rbxl-importer S1]
user drops .rbxl ┌─────────────────────────────┐
↓ │ Flask (port 8690) │
POST /api-rbxl/... ─────────┤ ├ analyze: parser + report│
↓ │ └ create: parser + конверт│
redirect /edit/<id> │ + asset_downloader │
│ + mesh→glb │
│ │
│ Redis (preview cache) │
│ nginx (assets.rublox.pro) │
│ /opt/roblox-assets/ │
└─────────────────────────────┘
[Marfusha proxy 85.192.61.244:39237]
Roblox CDN (assetdelivery.roblox.com)
Ассеты ← .ROBLOSECURITY cookie auth
```
## Структура
```
rbxl-importer/
src/
rbxl_binreader.py — низкоуровневое чтение (deinterleave, zigzag, roblox float)
rbxl_types.py — декодеры 28+ PROP типов (Vector3, CFrame, Color3, ...)
rbxl_parser.py — парсер chunks → RobloxModel
converter.py — RobloxModel → Rublox project_data
asset_downloader.py — скачка с Roblox CDN, дедуп через roblox_assets
asset_proxy.py — режимы proxy: disabled/direct/http_proxy/cloudflare_worker
mesh_converter.py — Roblox .mesh v1-v5 → GLB
app.py — Flask endpoints
sql/
001_roblox_assets.sql — таблицы roblox_assets, roblox_asset_usage, roblox_imports
reference_files/ — тестовые .rbxl
tests/ — unit-тесты
```
## Поток импорта
1. Юзер (МИН) загружает `.rbxl` через модалку в студии.
2. POST `/import/rbxl/analyze` → парсер, отчёт, preview_hash в Redis.
3. Юзер видит: число объектов, скриптов, ассетов, классов.
4. POST `/import/rbxl/create` с title:
- Парсим → конвертим RobloxModel → project_data.
- Скачиваем все `rbxassetid://` ассеты (mesh/texture/sound).
- Mesh → GLB через mesh_converter.
- INSERT в `kubikon3d_projects` (status=draft, is_test=true).
5. Редирект на `/edit/<project_id>` — открывается в студии.
6. Если есть Lua-скрипты (`kind: 'roblox-lua'`) — плеер запускает их через `RobloxLuaSandbox` (wasmoon).
## Запуск (на VM 130)
```bash
systemctl start rbxl-importer
systemctl status rbxl-importer
# логи:
journalctl -u rbxl-importer -f
```
## ENV
| Переменная | Значение |
|---|---|
| `PG_DSN` | DSN Postgres storys_db (через autossh туннель S1→S2) |
| `REDIS_URL` | `redis://127.0.0.1:6379/0` (локальный Docker) |
| `STORAGE_ROOT` | `/opt/roblox-assets` |
| `PUBLIC_ASSET_BASE` | `https://assets.rublox.pro/roblox` |
| `ROBLOX_PROXY_MODE` | `http_proxy` |
| `ROBLOX_HTTP_PROXY` | Marfusha xray HTTP proxy |
| `ROBLOX_SECURITY_COOKIE` | `.ROBLOSECURITY` от Roblox-аккаунта МИНа |
## Тестовый импорт
```bash
# analyze (file=Easy Obby)
curl -X POST -H "X-Auth-Override: 1" \
-F "file=@reference_files/easy_obby.rbxl" \
http://localhost:8690/import/rbxl/analyze
# create
curl -X POST -H "X-Auth-Override: 1" -H "Content-Type: application/json" \
-d '{"preview_hash":"<from_analyze>", "title":"Easy Obby"}' \
http://localhost:8690/import/rbxl/create
```
## Ограничения / TODO
- **Roblox auth обязателен** для большинства ассетов (с 2024 года). Используется `.ROBLOSECURITY` cookie аккаунта МИНа.
- **GUI** (ScreenGui, Frame, TextLabel) пока пропускается в конвертере (skipped).
- **Animation/KeyframeSequence** требуют отдельной обработки.
- **CSG**: parsing есть, но конверт в GLB не реализован — пока деградация в bbox cube.
- **Terrain (voxel)** — конвертация в `robloxTerrain` поле пока заглушка.
## Авторские права
Эта тест-фича для **МИНа только**. Юзер подтверждает право использовать содержимое карты при загрузке.
Не открывать для публичных пользователей без юр-проверки.

View File

@ -0,0 +1,91 @@
-- Миграция 001: таблица roblox_assets для кеширования assets Roblox CDN
--
-- Применяется к storys_db.
-- Запускать на S2 VM 117 (service-storys, primary PG).
-- При репликации S2 → S1 миграция доедет автоматически.
BEGIN;
CREATE TABLE IF NOT EXISTS roblox_assets (
-- ID ассета в Roblox (числовой, из rbxassetid://<id>)
rbx_asset_id BIGINT PRIMARY KEY,
-- SHA256 сырого файла (после скачки с CDN, до конверта)
-- Используется для второй дедупликации: разные rbx_asset_id могут указывать
-- на одинаковый файл (Roblox делает редиректы).
sha256_raw CHAR(64) NOT NULL,
-- Тип ассета: 'mesh', 'texture', 'sound', 'csg', 'animation', 'video', 'unknown'
asset_kind VARCHAR(16) NOT NULL,
-- Content-Type как пришёл с CDN
content_type VARCHAR(64) NOT NULL,
-- Размер сырого файла
raw_size_bytes BIGINT NOT NULL,
-- Путь сырого файла в /opt/roblox-assets/raw/<sha256>.bin (или конкретное расширение)
raw_path TEXT NOT NULL,
-- Если делали конверт (mesh→glb, csg→glb) — путь и хеш конвертированного файла.
-- Для остальных типов = NULL.
converted_path TEXT,
converted_sha256 CHAR(64),
converted_size_bytes BIGINT,
-- URL по которому ассет реально отдаётся юзеру (https://assets.rublox.pro/...)
public_url TEXT NOT NULL,
-- Время первой скачки
downloaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Последнее использование (бампается при каждом импорте новой карты)
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Сколько карт использует этот ассет (для cleanup'а)
refcount INTEGER NOT NULL DEFAULT 0,
-- Метаданные конкретного типа (mesh: vertex count, texture: dimensions)
metadata JSONB DEFAULT '{}'::jsonb,
-- Failed reason если скачка/конверт упал
error_msg TEXT
);
CREATE INDEX IF NOT EXISTS idx_roblox_assets_sha256 ON roblox_assets(sha256_raw);
CREATE INDEX IF NOT EXISTS idx_roblox_assets_kind ON roblox_assets(asset_kind);
CREATE INDEX IF NOT EXISTS idx_roblox_assets_last_used ON roblox_assets(last_used_at);
-- Лог скачек по проектам (для отладки и tracking'а кто чем пользуется)
CREATE TABLE IF NOT EXISTS roblox_asset_usage (
id BIGSERIAL PRIMARY KEY,
project_id INTEGER NOT NULL,
rbx_asset_id BIGINT NOT NULL REFERENCES roblox_assets(rbx_asset_id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(project_id, rbx_asset_id)
);
CREATE INDEX IF NOT EXISTS idx_asset_usage_project ON roblox_asset_usage(project_id);
-- Лог импортов .rbxl (родительская запись для usage)
CREATE TABLE IF NOT EXISTS roblox_imports (
id BIGSERIAL PRIMARY KEY,
project_id INTEGER, -- может быть NULL если ещё не создан
user_id INTEGER NOT NULL, -- кто грузил (МИН только пока)
rbxl_filename TEXT NOT NULL,
rbxl_size BIGINT NOT NULL,
rbxl_sha256 CHAR(64) NOT NULL,
instance_count INTEGER,
class_count INTEGER,
assets_total INTEGER DEFAULT 0,
assets_failed INTEGER DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending/parsing/downloading/converting/done/failed
error_msg TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_imports_user ON roblox_imports(user_id);
CREATE INDEX IF NOT EXISTS idx_imports_status ON roblox_imports(status);
COMMIT;

328
rbxl-importer/src/app.py Normal file
View File

@ -0,0 +1,328 @@
"""
app.py Flask API rbxl-importer.
Endpoints:
POST /import/rbxl/analyze
body: multipart, file=<.rbxl>
resp: {
"preview_hash": str, # для следующего шага
"report": {
"filename": str, "size_bytes": int, "version": int,
"class_count": int, "instance_count": int,
"top_classes": [{"class": str, "count": int}, ...],
"scripts_total": int,
"assets_to_download": int,
"warnings": [str, ...]
}
}
POST /import/rbxl/create
body: { "preview_hash": str, "title": str, "auth_user_id": int }
resp: { "project_id": int, "redirect": "/edit/<id>" }
Парсит конвертит скачивает ассеты создаёт запись в kubikon3d_projects.
GET /health
resp: { ok: true, version: "0.1.0" }
Безопасность: эндпоинты доступны только МИНу (user_id=1).
Проверка через X-User-Id header (NPM прокинет после JWT-проверки в user-сервисе).
"""
import os
import sys
import json
import hashlib
import tempfile
import logging
from pathlib import Path
from flask import Flask, request, jsonify
from flask_cors import CORS
import psycopg2
from psycopg2.extras import RealDictCursor
import redis
sys.path.insert(0, os.path.dirname(__file__))
from rbxl_parser import parse, class_histogram
from converter import Converter
from asset_downloader import AssetDownloader, PendingDownload
from mesh_converter import parse_roblox_mesh, build_glb, MeshParseError
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger('rbxl-importer')
PG_DSN = os.environ.get(
'PG_DSN',
'host=192.168.1.152 port=25435 user=min password=5cb157970ad5e3b2952ed05decaf1bab dbname=storys_db'
)
REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
STORAGE_ROOT = os.environ.get('STORAGE_ROOT', '/opt/roblox-assets')
PUBLIC_ASSET_BASE = os.environ.get('PUBLIC_ASSET_BASE', 'https://assets.rublox.pro/roblox')
MAX_RBXL_SIZE = 50 * 1024 * 1024 # 50 MB
ALLOWED_USER_IDS = [1] # пока только МИН
app = Flask(__name__)
CORS(app, resources={r'/*': {'origins': '*'}})
# Devlog для удалённой отладки dev-сессий студии: фронт пушит сюда
# console.error/warn, failed network requests, неожиданные exceptions.
from app_devlog import devlog_bp
app.register_blueprint(devlog_bp)
try:
rds = redis.from_url(REDIS_URL, decode_responses=False)
rds.ping()
logger.info(f'Redis connected: {REDIS_URL}')
except Exception as e:
logger.warning(f'Redis NOT connected: {e}; preview cache отключён')
rds = None
def pg_conn():
return psycopg2.connect(PG_DSN)
def auth_check(req) -> int:
"""Возвращает user_id если ОК, иначе бросает RuntimeError."""
# X-User-Id выставляется upstream NPM или service-user после JWT-проверки.
# В dev можно через header X-Auth-Override (только в LAN).
user_id_str = req.headers.get('X-User-Id') or req.headers.get('X-Auth-Override')
if not user_id_str:
raise RuntimeError('No X-User-Id header')
try:
uid = int(user_id_str)
except ValueError:
raise RuntimeError(f'Bad X-User-Id: {user_id_str!r}')
if uid not in ALLOWED_USER_IDS:
raise RuntimeError(f'User {uid} not allowed (only МИН)')
return uid
@app.errorhandler(Exception)
def on_error(e):
logger.exception('handler error')
return jsonify({'error': str(e), 'type': type(e).__name__}), 500
@app.get('/health')
def health():
return jsonify({'ok': True, 'version': '0.1.0', 'service': 'rbxl-importer'})
@app.post('/import/rbxl/analyze')
def analyze():
try:
user_id = auth_check(request)
except RuntimeError as e:
return jsonify({'error': str(e)}), 403
if 'file' not in request.files:
return jsonify({'error': 'file field required'}), 400
upload = request.files['file']
blob = upload.read()
if len(blob) > MAX_RBXL_SIZE:
return jsonify({'error': f'file too large (> {MAX_RBXL_SIZE} bytes)'}), 413
if not blob.startswith(b'<roblox!'):
return jsonify({'error': 'not a .rbxl binary file (missing <roblox! magic)'}), 400
# Парсим
try:
model = parse(blob)
except Exception as e:
return jsonify({'error': f'parse failed: {e}'}), 422
# Конвертим (без скачки ассетов — просто узнаем сколько их)
conv = Converter(model)
project_data = conv.convert()
# Лог импорта
rbxl_sha = hashlib.sha256(blob).hexdigest()
asset_ids = list(set(conv.stats.asset_ids_needed))
# Кладём blob во временный файл + сохраняем sha256 как preview_hash
preview_hash = rbxl_sha
if rds:
rds.setex(f'rbxl:blob:{preview_hash}', 1200, blob) # 20 минут
rds.setex(f'rbxl:project:{preview_hash}', 1200,
json.dumps(project_data).encode('utf-8'))
rds.setex(f'rbxl:assets:{preview_hash}', 1200,
json.dumps(asset_ids).encode('utf-8'))
# Отчёт
hist = class_histogram(model)
top = sorted(hist.items(), key=lambda x: -x[1])[:25]
report = {
'filename': upload.filename or 'unknown.rbxl',
'size_bytes': len(blob),
'version': model.version,
'class_count': model.class_count,
'instance_count': model.instance_count,
'top_classes': [{'class': c, 'count': n} for c, n in top],
'primitives_created': conv.stats.primitives_created,
'glb_models_created': conv.stats.glb_models_created,
'scripts_total': conv.stats.scripts_collected,
'scripts_skipped': conv.stats.scripts_skipped,
'parts_dropped': conv.stats.parts_dropped,
'assets_to_download': len(asset_ids),
'warnings': conv.stats.warnings[:30],
}
# Запись в roblox_imports (status='analyzed')
try:
with pg_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO roblox_imports "
"(user_id, rbxl_filename, rbxl_size, rbxl_sha256, "
" instance_count, class_count, assets_total, status) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, 'analyzed') "
"RETURNING id",
(user_id, report['filename'], report['size_bytes'], rbxl_sha,
report['instance_count'], report['class_count'], report['assets_to_download']),
)
import_id = cur.fetchone()[0]
conn.commit()
report['import_id'] = import_id
except Exception as e:
logger.warning(f'roblox_imports insert failed: {e}')
return jsonify({'preview_hash': preview_hash, 'report': report})
@app.post('/import/rbxl/create')
def create():
try:
user_id = auth_check(request)
except RuntimeError as e:
return jsonify({'error': str(e)}), 403
data = request.get_json(silent=True) or {}
preview_hash = data.get('preview_hash')
title = (data.get('title') or '').strip() or 'Импортировано из Roblox'
if not preview_hash:
return jsonify({'error': 'preview_hash required'}), 400
if not rds:
return jsonify({'error': 'redis unavailable, cannot retrieve preview'}), 503
blob_cached = rds.get(f'rbxl:blob:{preview_hash}')
project_cached = rds.get(f'rbxl:project:{preview_hash}')
if not project_cached:
return jsonify({'error': 'preview expired, please re-analyze'}), 410
project_data = json.loads(project_cached.decode('utf-8'))
asset_ids_raw = rds.get(f'rbxl:assets:{preview_hash}')
asset_ids = json.loads(asset_ids_raw.decode('utf-8')) if asset_ids_raw else []
# Скачиваем ассеты
dl = AssetDownloader(db_dsn=PG_DSN, storage_root=STORAGE_ROOT,
public_base=PUBLIC_ASSET_BASE)
asset_url_map = {} # rbx_id → public_url
failed = 0
for rbx_id in asset_ids:
try:
rec = dl.fetch_sync(int(rbx_id))
# Конвертим mesh → glb если это mesh
if rec.asset_kind == 'mesh' and not rec.converted_path:
try:
with open(rec.raw_path, 'rb') as f:
mesh_blob = f.read()
mesh = parse_roblox_mesh(mesh_blob)
glb = build_glb(mesh)
sha = hashlib.sha256(glb).hexdigest()
glb_dir = os.path.join(STORAGE_ROOT, 'converted', sha[:2])
os.makedirs(glb_dir, exist_ok=True)
glb_path = os.path.join(glb_dir, f'{sha}.glb')
if not os.path.exists(glb_path):
with open(glb_path, 'wb') as f:
f.write(glb)
public_glb = f'{PUBLIC_ASSET_BASE}/converted/{sha[:2]}/{sha}.glb'
asset_url_map[int(rbx_id)] = public_glb
# Обновляем БД
with pg_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE roblox_assets SET converted_path=%s, "
"converted_sha256=%s, converted_size_bytes=%s "
"WHERE rbx_asset_id=%s",
(glb_path, sha, len(glb), int(rbx_id)),
)
conn.commit()
except (MeshParseError, Exception) as me:
logger.warning(f'mesh→glb failed for {rbx_id}: {me}')
asset_url_map[int(rbx_id)] = rec.public_url
else:
asset_url_map[int(rbx_id)] = rec.public_url
except PendingDownload:
failed += 1
except Exception as e:
failed += 1
logger.warning(f'asset {rbx_id} download failed: {e}')
# Подставляем URLs в project_data
_resolve_asset_urls(project_data, asset_url_map)
# Создаём проект в kubikon3d_projects
# Используем bridge на user-service / storys API, ИЛИ напрямую в storys_db.
# Прямой INSERT — проще для MVP. id автогенерируется.
try:
with pg_conn() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
# Возьмём следующий id из последовательности
cur.execute(
"INSERT INTO kubikon3d_projects "
"(user_id, title, description, project_data, "
" status, is_test, is_public, is_published, "
" feed_eligible, feed_blocked_forever, quality_state, "
" created_at, updated_at) "
"VALUES (%s, %s, %s, %s, "
" 'draft', true, false, false, "
" false, false, 'new', NOW(), NOW()) "
"RETURNING id",
(user_id, title,
f'Импортировано из .rbxl (rbxl-importer v0.1.0)',
json.dumps(project_data, ensure_ascii=False)),
)
project_id = cur.fetchone()['id']
conn.commit()
except Exception as e:
return jsonify({'error': f'create project failed: {e}'}), 500
# Обновляем roblox_imports
try:
with pg_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE roblox_imports SET project_id=%s, status='done', "
"finished_at=NOW(), assets_failed=%s "
"WHERE rbxl_sha256=%s",
(project_id, failed, preview_hash),
)
conn.commit()
except Exception:
pass
return jsonify({
'project_id': project_id,
'redirect': f'/edit/{project_id}',
'assets_downloaded': len(asset_url_map),
'assets_failed': failed,
})
def _resolve_asset_urls(project_data: dict, asset_map: dict) -> None:
"""Walks project_data, заменяет {rbxAssetId: X} → url из asset_map."""
scene = project_data.get('scene', {})
for glb in scene.get('glbModels', []):
rid = glb.get('rbxAssetId')
if rid in asset_map:
glb['url'] = asset_map[rid]
for snd in scene.get('sounds', []):
rid = snd.get('rbxAssetId')
if rid in asset_map:
snd['url'] = asset_map[rid]
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8690, debug=False)

View File

@ -0,0 +1,65 @@
"""
app_devlog.py endpoint /devlog для удалённого сбора логов dev-сессии студии.
Подключается к Flask из app.py через blueprint.
Эндпоинты:
POST /devlog приём batch'а событий из браузера
GET /devlog/recent последние N событий (для меня)
GET /devlog/clear очистить лог
Хранение: append-only файл /opt/rbxl-importer/devlog.jsonl.
CORS открыт для localhost:* (dev режим).
"""
import os
import json
import time
from flask import Blueprint, request, jsonify
devlog_bp = Blueprint('devlog', __name__)
DEVLOG_PATH = os.environ.get('DEVLOG_PATH', '/opt/rbxl-importer/devlog.jsonl')
@devlog_bp.post('/devlog')
def post_devlog():
"""Принимает массив событий из браузера.
Каждое событие: {ts, kind, url?, status?, body?, message?, stack?, extra?}
"""
data = request.get_json(silent=True) or {}
events = data.get('events') or []
if not isinstance(events, list):
return jsonify({'error': 'events must be list'}), 400
received_at = time.time()
with open(DEVLOG_PATH, 'a', encoding='utf-8') as f:
for ev in events:
if not isinstance(ev, dict):
continue
ev['received_at'] = received_at
f.write(json.dumps(ev, ensure_ascii=False, default=str) + '\n')
return jsonify({'ok': True, 'count': len(events)})
@devlog_bp.get('/devlog/recent')
def get_recent():
"""Последние N событий (по умолчанию 200)."""
n = int(request.args.get('n', 200))
out = []
if os.path.exists(DEVLOG_PATH):
with open(DEVLOG_PATH, 'r', encoding='utf-8') as f:
lines = f.readlines()[-n:]
for line in lines:
try:
out.append(json.loads(line))
except Exception:
pass
return jsonify({'events': out, 'total': len(out)})
@devlog_bp.get('/devlog/clear')
def clear():
if os.path.exists(DEVLOG_PATH):
os.unlink(DEVLOG_PATH)
return jsonify({'ok': True})

View File

@ -0,0 +1,382 @@
"""
asset_downloader.py скачивает Roblox-ассеты с CDN и кеширует локально.
Использование:
from asset_downloader import AssetDownloader
dl = AssetDownloader(db_conn, storage_root='/opt/roblox-assets')
asset = dl.fetch_sync(rbx_asset_id=12345)
print(asset.public_url) # 'https://assets.rublox.pro/roblox/raw/sha256.glb'
Дедупликация:
1. По rbx_asset_id (первичный лукап в БД).
2. По SHA256 контента (если разные id указывают на тот же файл).
Кеширование:
raw /opt/roblox-assets/raw/<sha256[:2]>/<sha256>.<ext>
converted (для meshglb, csgglb) /opt/roblox-assets/converted/<sha256>.glb
Источник: Roblox AssetDelivery API.
- https://assetdelivery.roblox.com/v1/asset?id=<id> бинарь (с редиректом на CDN)
- https://assetdelivery.roblox.com/v2/assetId/<id> JSON c metadata
При первой скачке сохраняем в БД, потом мгновенный возврат public_url.
"""
import os
import hashlib
import logging
import re
import time
from dataclasses import dataclass
from typing import Optional, Dict
import requests
import psycopg2
from psycopg2.extras import RealDictCursor
from asset_proxy import get_proxy_config, get_http_proxies, PendingDownload
logger = logging.getLogger(__name__)
# Roblox CDN endpoints (используются в direct mode; см. asset_proxy.py)
ASSETDELIVERY_V1 = 'https://assetdelivery.roblox.com/v1/asset'
ASSETDELIVERY_V2 = 'https://assetdelivery.roblox.com/v2/assetId'
# Сопоставление content-type → расширение
CONTENT_TYPE_MAP = {
'image/png': ('texture', '.png'),
'image/jpeg': ('texture', '.jpg'),
'image/jpg': ('texture', '.jpg'),
'image/webp': ('texture', '.webp'),
'image/bmp': ('texture', '.bmp'),
'image/x-targa': ('texture', '.tga'),
'audio/mpeg': ('sound', '.mp3'),
'audio/mp3': ('sound', '.mp3'),
'audio/ogg': ('sound', '.ogg'),
'audio/wav': ('sound', '.wav'),
'audio/x-wav': ('sound', '.wav'),
'application/octet-stream': ('mesh', '.mesh'), # Roblox mesh обычно это
}
# Базовые public URL'ы (для последующего конверта в Cloudflare)
PUBLIC_BASE = 'https://assets.rublox.pro/roblox'
@dataclass
class AssetRecord:
rbx_asset_id: int
sha256_raw: str
asset_kind: str
content_type: str
raw_size_bytes: int
raw_path: str
public_url: str
converted_path: Optional[str] = None
converted_sha256: Optional[str] = None
cached: bool = False # True если был лукап в БД, False если только что скачан
class AssetDownloader:
"""
Скачивает Roblox-ассеты с дедупликацией. Thread-safe (через PG-транзакции).
"""
def __init__(
self,
db_dsn: str,
storage_root: str = '/opt/roblox-assets',
public_base: str = PUBLIC_BASE,
request_timeout: int = 30,
max_retries: int = 3,
user_agent: str = 'Roblox/WinInet', # притворяемся Roblox-клиентом
):
self.db_dsn = db_dsn
self.storage_root = storage_root
self.public_base = public_base.rstrip('/')
self.request_timeout = request_timeout
self.max_retries = max_retries
self.session = requests.Session()
self.session.headers.update({
'User-Agent': user_agent,
'Accept': '*/*',
})
# Если есть .ROBLOSECURITY cookie — авторизуемся.
# Без неё большинство ассетов отдаёт 401.
# Cookie получается из браузера: F12 → Application → Cookies → roblox.com → .ROBLOSECURITY.
roblosecurity = os.environ.get('ROBLOX_SECURITY_COOKIE', '').strip()
if roblosecurity:
self.session.cookies.set(
'.ROBLOSECURITY',
roblosecurity,
domain='.roblox.com',
path='/',
)
logger.info('AssetDownloader: .ROBLOSECURITY cookie loaded (auth enabled)')
else:
logger.warning('AssetDownloader: no .ROBLOSECURITY cookie — most assets will return 401')
# Создаём корневые папки
for sub in ('raw', 'converted', 'failed'):
os.makedirs(os.path.join(storage_root, sub), exist_ok=True)
# ─── публичный API ───
def fetch_sync(self, rbx_asset_id: int) -> AssetRecord:
"""Скачать (или взять из кеша) один ассет. Бросает исключение при провале.
Если ROBLOX_PROXY_MODE=disabled бросает PendingDownload (но запись в БД
создаётся со status='pending', чтобы потом можно было скачать batch'ем).
"""
cached = self._lookup(rbx_asset_id)
if cached:
self._bump_last_used(rbx_asset_id)
cached.cached = True
return cached
# Не было в кеше — скачиваем
proxy_cfg = get_proxy_config()
if proxy_cfg.mode == 'disabled':
self._insert_pending(rbx_asset_id)
raise PendingDownload(rbx_asset_id)
raw_bytes, content_type = self._download_raw(rbx_asset_id, proxy_cfg)
sha256 = hashlib.sha256(raw_bytes).hexdigest()
# Проверим: может уже есть в БД с другим rbx_asset_id но тем же sha256
existing_by_sha = self._lookup_by_sha256(sha256)
if existing_by_sha:
# Записываем алиас: новый rbx_asset_id → существующая запись
# Просто вставим новую запись с теми же путями
new_record = self._insert_alias(rbx_asset_id, existing_by_sha)
new_record.cached = True
return new_record
# Это новый файл — сохраняем
asset_kind, ext = self._classify(content_type, raw_bytes)
raw_path = self._save_raw(sha256, ext, raw_bytes)
public_url = f'{self.public_base}/raw/{sha256[:2]}/{sha256}{ext}'
record = AssetRecord(
rbx_asset_id=rbx_asset_id,
sha256_raw=sha256,
asset_kind=asset_kind,
content_type=content_type,
raw_size_bytes=len(raw_bytes),
raw_path=raw_path,
public_url=public_url,
cached=False,
)
self._insert(record)
return record
# ─── PG helpers ───
def _connect(self):
return psycopg2.connect(self.db_dsn)
def _lookup(self, rbx_asset_id: int) -> Optional[AssetRecord]:
with self._connect() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"SELECT rbx_asset_id, sha256_raw, asset_kind, content_type, "
"raw_size_bytes, raw_path, converted_path, converted_sha256, public_url "
"FROM roblox_assets WHERE rbx_asset_id = %s",
(rbx_asset_id,),
)
row = cur.fetchone()
if not row:
return None
return AssetRecord(
rbx_asset_id=row['rbx_asset_id'],
sha256_raw=row['sha256_raw'],
asset_kind=row['asset_kind'],
content_type=row['content_type'],
raw_size_bytes=row['raw_size_bytes'],
raw_path=row['raw_path'],
public_url=row['public_url'],
converted_path=row['converted_path'],
converted_sha256=row['converted_sha256'],
)
def _lookup_by_sha256(self, sha256: str) -> Optional[AssetRecord]:
with self._connect() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"SELECT rbx_asset_id, sha256_raw, asset_kind, content_type, "
"raw_size_bytes, raw_path, converted_path, converted_sha256, public_url "
"FROM roblox_assets WHERE sha256_raw = %s LIMIT 1",
(sha256,),
)
row = cur.fetchone()
if not row:
return None
return AssetRecord(**{k: row[k] for k in row.keys() if k != 'cached'})
def _bump_last_used(self, rbx_asset_id: int) -> None:
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE roblox_assets SET last_used_at = NOW() WHERE rbx_asset_id = %s",
(rbx_asset_id,),
)
conn.commit()
def _insert(self, r: AssetRecord) -> None:
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO roblox_assets "
"(rbx_asset_id, sha256_raw, asset_kind, content_type, raw_size_bytes, "
" raw_path, public_url, converted_path, converted_sha256) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) "
"ON CONFLICT (rbx_asset_id) DO NOTHING",
(r.rbx_asset_id, r.sha256_raw, r.asset_kind, r.content_type,
r.raw_size_bytes, r.raw_path, r.public_url,
r.converted_path, r.converted_sha256),
)
conn.commit()
def _insert_pending(self, rbx_asset_id: int) -> None:
"""Создаёт plceholder-запись для ассета который не скачали (proxy disabled).
Поля sha256, content_type, raw_path, public_url заполняются заглушками.
asset_kind='pending'. Когда proxy будет настроен, batch-скрипт обновит запись.
"""
with self._connect() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO roblox_assets "
"(rbx_asset_id, sha256_raw, asset_kind, content_type, raw_size_bytes, "
" raw_path, public_url, error_msg) "
"VALUES (%s, %s, 'pending', 'application/octet-stream', 0, '', '', "
"'proxy disabled — asset not downloaded yet') "
"ON CONFLICT (rbx_asset_id) DO NOTHING",
(rbx_asset_id, '0' * 64),
)
conn.commit()
def _insert_alias(self, new_id: int, existing: AssetRecord) -> AssetRecord:
"""Сохраняет новый rbx_asset_id указывая на тот же файл."""
alias = AssetRecord(
rbx_asset_id=new_id,
sha256_raw=existing.sha256_raw,
asset_kind=existing.asset_kind,
content_type=existing.content_type,
raw_size_bytes=existing.raw_size_bytes,
raw_path=existing.raw_path,
public_url=existing.public_url,
converted_path=existing.converted_path,
converted_sha256=existing.converted_sha256,
)
self._insert(alias)
return alias
# ─── HTTP скачка ───
def _download_raw(self, rbx_asset_id: int, proxy_cfg) -> tuple:
"""Скачивает с Roblox CDN (или через CF Worker, или через HTTP proxy).
Возвращает (raw_bytes, content_type).
"""
url = proxy_cfg.build_url(rbx_asset_id)
headers = {**self.session.headers, **proxy_cfg.headers}
# Для режима http_proxy — передаём proxies в requests
proxies = get_http_proxies() if proxy_cfg.mode == 'http_proxy' else None
last_exc = None
for attempt in range(self.max_retries):
try:
resp = self.session.get(url, timeout=self.request_timeout,
allow_redirects=True, headers=headers,
proxies=proxies)
if resp.status_code == 404:
raise RuntimeError(f"asset {rbx_asset_id}: 404 Not Found (asset deleted or private)")
if resp.status_code == 403:
raise RuntimeError(f"asset {rbx_asset_id}: 403 Forbidden (private or moderation)")
if resp.status_code == 401:
raise RuntimeError(f"asset {rbx_asset_id}: 401 Unauthorized (requires Roblox auth)")
if resp.status_code != 200:
raise RuntimeError(f"asset {rbx_asset_id}: HTTP {resp.status_code}")
content_type = resp.headers.get('Content-Type', 'application/octet-stream').split(';')[0].strip()
return resp.content, content_type
except (requests.RequestException, RuntimeError) as e:
last_exc = e
# 4xx — нет смысла retry
if isinstance(e, RuntimeError) and ('404' in str(e) or '403' in str(e) or '401' in str(e)):
raise
wait = 2 ** attempt
logger.warning(f"asset {rbx_asset_id} attempt {attempt+1} failed: {e}; retry in {wait}s")
time.sleep(wait)
raise RuntimeError(f"asset {rbx_asset_id}: max retries exceeded: {last_exc}")
def _classify(self, content_type: str, raw_bytes: bytes) -> tuple:
"""Возвращает (asset_kind, extension)."""
if content_type in CONTENT_TYPE_MAP:
return CONTENT_TYPE_MAP[content_type]
# Эвристика по magic bytes
if raw_bytes.startswith(b'\x89PNG\r\n\x1a\n'):
return ('texture', '.png')
if raw_bytes[:3] == b'\xff\xd8\xff':
return ('texture', '.jpg')
if raw_bytes[:4] == b'RIFF' and raw_bytes[8:12] == b'WAVE':
return ('sound', '.wav')
if raw_bytes[:3] == b'ID3' or raw_bytes[:2] == b'\xff\xfb':
return ('sound', '.mp3')
if raw_bytes[:4] == b'OggS':
return ('sound', '.ogg')
# Roblox mesh: начинается с ASCII "version 1.00\n" или "version 2.00\n" или
# бинарь начинающийся с magic.
if raw_bytes[:8].startswith(b'version '):
return ('mesh', '.mesh')
# CSG: начинается с magic "CSGPHS"
if raw_bytes[:6] == b'CSGPHS':
return ('csg', '.csg')
# Animation: KeyframeSequence, raw — обычно XML или binary с magic.
if raw_bytes[:5] == b'<?xml':
return ('animation', '.xml')
return ('unknown', '.bin')
def _save_raw(self, sha256: str, ext: str, data: bytes) -> str:
subdir = sha256[:2]
dir_path = os.path.join(self.storage_root, 'raw', subdir)
os.makedirs(dir_path, exist_ok=True)
file_path = os.path.join(dir_path, f'{sha256}{ext}')
if not os.path.exists(file_path): # на всякий — atomic write через tmp
tmp = file_path + '.tmp'
with open(tmp, 'wb') as f:
f.write(data)
os.rename(tmp, file_path)
return file_path
# ─── CLI для тестов ───
if __name__ == '__main__':
import sys
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('rbx_asset_id', type=int, help='Roblox asset ID')
parser.add_argument('--db-dsn', default=os.environ.get('PG_DSN', ''),
help='Postgres DSN, например "host=192.168.1.117 user=min password=... dbname=storys_db"')
parser.add_argument('--storage', default='/opt/roblox-assets')
args = parser.parse_args()
if not args.db_dsn:
print("error: provide --db-dsn or PG_DSN env var")
sys.exit(1)
logging.basicConfig(level=logging.INFO)
dl = AssetDownloader(db_dsn=args.db_dsn, storage_root=args.storage)
rec = dl.fetch_sync(args.rbx_asset_id)
print(f" rbx_asset_id: {rec.rbx_asset_id}")
print(f" sha256: {rec.sha256_raw}")
print(f" kind: {rec.asset_kind}")
print(f" content-type: {rec.content_type}")
print(f" size: {rec.raw_size_bytes} bytes")
print(f" raw_path: {rec.raw_path}")
print(f" public_url: {rec.public_url}")
print(f" cached: {rec.cached}")

View File

@ -0,0 +1,101 @@
"""
asset_proxy.py конфигурация HTTP-прокси для скачивания Roblox-ассетов.
Roblox блокирует direct-traffic из РФ нужно ходить через VPN/прокси.
Текущая стратегия:
1. По умолчанию НИЧЕГО НЕ КАЧАТЬ (mode='disabled'), пишем ассеты со status='pending'.
Импорт продолжается, геометрия и скрипты работают.
Mesh/Texture/Sound показываются как заглушки до скачки.
2. mode='cloudflare_worker' ходить через Cloudflare Worker (см. cf-worker/asset-proxy.js).
Worker принимает /asset?id=<id> и проксирует на assetdelivery.roblox.com.
3. mode='direct' без прокси (для тестов или если запускаем с зарубежного хоста).
ENV:
ROBLOX_PROXY_MODE = disabled | direct | cloudflare_worker
ROBLOX_PROXY_URL = https://rbxl-proxy.workers.dev # для cloudflare_worker mode
Использование в asset_downloader:
from asset_proxy import get_proxy_config
cfg = get_proxy_config()
if cfg.mode == 'disabled':
raise PendingDownload(rbx_asset_id)
url = cfg.build_url(rbx_asset_id)
resp = session.get(url, ...)
"""
import os
from dataclasses import dataclass
@dataclass
class ProxyConfig:
mode: str # disabled | direct | cloudflare_worker
base_url: str # для direct mode = 'https://assetdelivery.roblox.com/v1/asset'
# для cf worker = 'https://rbxl-proxy.workers.dev/asset'
headers: dict # доп. заголовки (например auth для CF Worker)
def build_url(self, rbx_asset_id: int) -> str:
return f'{self.base_url}?id={rbx_asset_id}'
class PendingDownload(Exception):
"""Бросается когда mode='disabled' — ассет не скачан но импорт продолжается."""
def __init__(self, rbx_asset_id: int):
self.rbx_asset_id = rbx_asset_id
super().__init__(f"asset {rbx_asset_id} download pending (proxy disabled)")
@dataclass
class HttpProxyConfig:
"""Дополнение к ProxyConfig для режима http_proxy.
proxies словарь который requests.Session() ест как есть.
"""
proxies: dict
def get_proxy_config() -> ProxyConfig:
mode = os.environ.get('ROBLOX_PROXY_MODE', 'disabled')
if mode == 'direct':
return ProxyConfig(
mode='direct',
base_url='https://assetdelivery.roblox.com/v1/asset',
headers={'User-Agent': 'Roblox/WinInet'},
)
elif mode == 'cloudflare_worker':
url = os.environ.get('ROBLOX_PROXY_URL')
if not url:
raise RuntimeError("ROBLOX_PROXY_URL не задан для cloudflare_worker mode")
secret = os.environ.get('ROBLOX_PROXY_SECRET', '')
return ProxyConfig(
mode='cloudflare_worker',
base_url=url.rstrip('/') + '/asset',
headers={
'User-Agent': 'rublox-rbxl-importer/1.0',
'X-Proxy-Auth': secret,
},
)
elif mode == 'http_proxy':
# Используем внешний HTTP-прокси для исходящего трафика.
# base_url остаётся реальный Roblox CDN, запрос идёт через прокси.
# ENV: ROBLOX_HTTP_PROXY = http://user:pass@host:port
proxy_url = os.environ.get('ROBLOX_HTTP_PROXY', '')
if not proxy_url:
raise RuntimeError("ROBLOX_HTTP_PROXY не задан для http_proxy mode")
return ProxyConfig(
mode='http_proxy',
base_url='https://assetdelivery.roblox.com/v1/asset',
headers={'User-Agent': 'Roblox/WinInet'},
)
elif mode == 'disabled':
return ProxyConfig(mode='disabled', base_url='', headers={})
else:
raise RuntimeError(f"unknown ROBLOX_PROXY_MODE={mode!r}")
def get_http_proxies() -> dict:
"""Для requests-сессии: {'http': ..., 'https': ...}."""
url = os.environ.get('ROBLOX_HTTP_PROXY', '')
if not url:
return {}
return {'http': url, 'https': url}

View File

@ -0,0 +1,979 @@
"""
converter.py RobloxModel Rublox project_data.
Принимает дерево Instance'ов из parser.py и превращает в JSON-схему которую
понимает движок Rublox (студия и плеер).
Маппинг основных классов Roblox Rublox:
Part(Shape=Block) primitive: cube
Part(Shape=Ball) primitive: sphere
Part(Shape=Cylinder) primitive: cylinder
WedgePart primitive: wedge
CornerWedgePart primitive: cornerwedge
MeshPart glbModels entry (ссылка на наш сконвертированный glb)
UnionOperation glbModels entry (если есть CSG)
SpawnLocation scene.spawnPoint
Script/LocalScript scripts entry (kind='roblox-lua', raw lua source)
Lighting scene.environment
Sound scene.sounds entry
Folder/Model scene.folders entry
Texture/Decal не отдельные объекты, прикрепляются к Part'у
Координаты:
Roblox: правая Y-up, 1 stud 0.28 м.
Rublox/Babylon: правая Y-up, 1 unit = 1 м.
масштабируем все координаты на 0.28 (можно настроить через scale_factor).
CFrame position + rotationX/Y/Z (Euler XYZ в радианах).
Скрипты: сохраняются как kind='roblox-lua' с raw lua_source. Lua-runtime
(asset_proxy + RobloxLuaWorker.js в плеере) исполняет их потом.
"""
from dataclasses import dataclass, field, asdict
from typing import List, Dict, Any, Optional, Tuple
import json
import math
import logging
from rbxl_parser import RobloxModel, Instance
from rbxl_types import (
CFrame, Color3, Vector3, EnumValue, BrickColor,
OptionalCFrame, PhysicalProperties,
)
logger = logging.getLogger(__name__)
# ────── константы маппинга ──────
# Roblox stud → unit Rublox-движка.
# R15-персонаж в Rublox ~5.5м, Roblox-персонаж ~5stud высотой. Чтобы карта
# была пропорциональна персонажу — scale 0.35 (платформа 4stud → 1.4 unit,
# как стандартная Rublox-платформа).
DEFAULT_SCALE = 0.35
# Маппинг Material enum → Rublox material strings.
# Roblox Enum.Material:
# 0=Plastic, 256=Plastic (default), 272=Wood, 288=Slate, 304=Concrete,
# 320=CorrodedMetal, 336=DiamondPlate, 352=Foil, 368=Grass, 384=Ice,
# 400=Marble, 416=Granite, 432=Brick, 448=Pebble, 464=Sand, 480=Fabric,
# 496=SmoothPlastic, 512=Metal, 528=WoodPlanks, 784=Neon, 1024=Glass,
# 1280=ForceField, ...
# https://create.roblox.com/docs/reference/engine/enums/Material
ROBLOX_MATERIAL_TO_RUBLOX = {
0: 'glossy', # Plastic
256: 'glossy', # Plastic (legacy)
272: 'matte', # Wood
288: 'matte', # Slate
304: 'matte', # Concrete
320: 'metal', # CorrodedMetal
336: 'metal', # DiamondPlate
352: 'metal', # Foil
368: 'matte', # Grass
384: 'glass', # Ice
400: 'matte', # Marble
416: 'matte', # Granite
432: 'matte', # Brick
448: 'matte', # Pebble
464: 'matte', # Sand
480: 'matte', # Fabric
496: 'glossy', # SmoothPlastic
512: 'metal', # Metal
528: 'matte', # WoodPlanks
784: 'neon', # Neon
1024: 'glass', # Glass
1280: 'neon', # ForceField — синий полупрозрачный
1296: 'matte', # Cobblestone
1328: 'metal', # Aluminum
1344: 'matte', # CrackedLava
1360: 'metal', # Rubber
1376: 'matte', # Pavement
}
# Roblox Part.Shape enum (PartType): 0=Ball, 1=Block, 2=Cylinder, ...
SHAPE_TO_PRIMITIVE = {
0: 'sphere', # Ball
1: 'cube', # Block
2: 'cylinder', # Cylinder
3: 'cube', # Wedge — но WedgePart это отдельный класс
4: 'cornerwedge',
}
# ────── BrickColor таблица (упрощённая) ──────
# Roblox использует old BrickColor enum (числа 1-1032). Только распространённые:
BRICKCOLOR_TO_HEX = {
1: '#f2f3f3', 5: '#d9e4f7', 9: '#9c9e9c', 11: '#e8eaea',
21: '#c4281c', 23: '#0d69ac', 24: '#f5cd30', 26: '#27313e',
28: '#293f1a', 29: '#a3bb71', 37: '#4b9740', 38: '#ab5a32',
101: '#dab8a3', 102: '#82c1e9', 103: '#9b9696', 104: '#6b327a',
105: '#cf8b3e', 106: '#d97e29', 107: '#3a8fbf', 108: '#695b50',
111: '#a7a6a6', 119: '#aac84a', 125: '#e8b486', 138: '#8a8a76',
141: '#26462b', 153: '#9b605a', 192: '#5a3019', 194: '#9c9b91',
199: '#3c3e3f', 208: '#dbdcdc', 224: '#f3e3a5', 226: '#fff8a8',
1001: '#ffffff', 1002: '#cccccc', 1003: '#000000', 1004: '#ff0000',
1005: '#ff8000', 1006: '#ffff00', 1007: '#80c000', 1008: '#00ff00',
1009: '#00c080', 1010: '#0080ff', 1011: '#0000ff', 1012: '#5000ff',
1013: '#a000ff', 1014: '#ff00ff', 1015: '#ff0080', 1016: '#ff80c0',
1017: '#ff8080', 1018: '#ffc080', 1019: '#ffff80', 1020: '#80ff80',
}
# ──── вспомогательные функции ────
def cframe_to_pos_rot(cf: Optional[CFrame], scale: float) -> Tuple[Dict, Dict]:
"""Конвертит CFrame в позицию и Euler ротацию.
Roblox координаты: правая Y-up. Babylon: правая Y-up. То есть совпадают.
Возвращает ({x,y,z}, {rx,ry,rz}).
"""
if cf is None:
return ({'x': 0.0, 'y': 0.0, 'z': 0.0},
{'rx': 0.0, 'ry': 0.0, 'rz': 0.0})
pos = {'x': cf.position.x * scale, 'y': cf.position.y * scale, 'z': cf.position.z * scale}
rx, ry, rz = cf.to_euler_xyz()
rot = {'rx': rx, 'ry': ry, 'rz': rz}
return pos, rot
def color3_to_hex(c: Optional[Color3]) -> str:
if c is None:
return '#cccccc'
return c.to_hex()
def brickcolor_to_hex(b: Optional[BrickColor]) -> str:
if b is None:
return '#cccccc'
return BRICKCOLOR_TO_HEX.get(b.code, '#cccccc')
def material_to_string(m: Optional[EnumValue]) -> str:
if m is None:
return 'glossy'
return ROBLOX_MATERIAL_TO_RUBLOX.get(m.value, 'glossy')
def get_part_color(props: Dict[str, Any]) -> str:
"""Достаёт цвет Part: сначала Color3uint8, потом BrickColor, потом дефолт."""
if 'Color3uint8' in props:
return color3_to_hex(props['Color3uint8'])
if 'Color' in props:
return color3_to_hex(props['Color'])
if 'BrickColor' in props:
return brickcolor_to_hex(props['BrickColor'])
return '#cccccc'
def name_or_default(props: Dict[str, Any], default: str) -> str:
return str(props.get('Name', default))
# ────── Конвертеры классов ──────
@dataclass
class ConversionStats:
primitives_created: int = 0
glb_models_created: int = 0
scripts_collected: int = 0
scripts_skipped: int = 0
parts_dropped: int = 0
skipped_classes: Dict[str, int] = field(default_factory=dict)
warnings: List[str] = field(default_factory=list)
asset_ids_needed: List[int] = field(default_factory=list) # rbx_asset_id'ы для скачки
class Converter:
"""Главный конвертер. Работает на одной RobloxModel за раз."""
def __init__(
self,
model: RobloxModel,
scale: float = DEFAULT_SCALE,
asset_url_resolver=None, # callable(rbx_asset_id) -> str|None (public URL после скачки)
):
self.model = model
self.scale = scale
self.stats = ConversionStats()
self.asset_url_resolver = asset_url_resolver
self._next_primitive_id = 1
self._next_glb_id = 1
self._next_script_id = 1
self._instance_to_primitive_id: Dict[int, int] = {}
# ──── главный entry-point ────
def convert(self) -> Dict[str, Any]:
"""Возвращает project_data dict готовый к сохранению как JSON."""
scene = {
'blocks': [],
'models': [],
'primitives': [],
'userModels': [],
'terrain': [],
'robloxTerrain': self._default_roblox_terrain(),
'decorations': [],
'folders': [],
'gui': [],
'inventory': [],
'spawnPoint': {'x': 0, 'y': 2, 'z': 0},
# Стандартный R15-скин bacon-hair как во всех новых проектах студии.
# 'default' — невалидный typeId, PlayerController на нём падает.
'playerModelType': 'skin_bacon-hair',
'worldSize': 100,
'floorEnabled': True,
'jumpPowerMul': 1.0,
'cameraMode': 'thirdPerson',
'crosshair': 'default',
'shadowQuality': 'medium',
'environment': {
'preset': 'day',
'timeOfDay': 14,
'dayDurationMin': 5,
'nightDurationMin': 3,
'fogEnabled': False,
'fogColor': [0.7, 0.8, 0.9],
'fogDensity': 0.01,
},
'audio': {},
'assets': [],
'sounds': [],
'glbModels': [],
'scripts': [],
}
# Обходим все instances и конвертим
for inst in self.model.instances:
self._convert_one(inst, scene)
# Финальный отчёт о скипнутых классах
for cls, n in sorted(self.stats.skipped_classes.items(), key=lambda x: -x[1])[:30]:
self.stats.warnings.append(f"skipped {n}× {cls}")
return {
'version': 1,
'scene': scene,
'editorCamera': {'x': 20, 'y': 15, 'z': 20, 'targetX': 0, 'targetY': 0, 'targetZ': 0},
'settings': {
'isGd': False,
'importedFrom': 'roblox',
'importStats': asdict(self.stats),
},
}
# ──── per-class конвертеры ────
def _convert_one(self, inst: Instance, scene: Dict) -> None:
cls = inst.class_name
try:
if cls == 'Part':
self._convert_part(inst, scene)
elif cls == 'WedgePart':
self._convert_wedge(inst, scene)
elif cls == 'CornerWedgePart':
self._convert_cornerwedge(inst, scene)
elif cls == 'TrussPart':
self._convert_truss(inst, scene)
elif cls == 'MeshPart':
self._convert_meshpart(inst, scene)
elif cls == 'UnionOperation':
self._convert_union(inst, scene)
elif cls == 'SpecialMesh':
# SpecialMesh — child объект Part'а, меняет визуал родителя
# Обработка делается в _convert_part через children, тут не нужно
pass
elif cls == 'SpawnLocation':
self._convert_spawn(inst, scene)
elif cls == 'Script' or cls == 'LocalScript' or cls == 'ModuleScript':
self._convert_script(inst, scene)
elif cls == 'Sound':
self._convert_sound(inst, scene)
elif cls == 'PointLight' or cls == 'SpotLight' or cls == 'SurfaceLight':
self._convert_light(inst, scene)
elif cls == 'Folder' or cls == 'Model':
self._convert_folder(inst, scene)
elif cls in ('ScreenGui', 'BillboardGui', 'SurfaceGui'):
# Контейнер — сам по себе ничего не рендерит, дети идут в scene.gui
# как top-level элементы с parentId=None.
self._convert_screen_gui(inst, scene)
elif cls in ('Frame', 'ScrollingFrame', 'TextLabel', 'TextButton',
'ImageLabel', 'ImageButton', 'TextBox'):
self._convert_gui_element(inst, scene)
elif cls in ('Decal', 'Texture'):
# Прикрепляются к Part'у — обрабатываются при конверте родителя
pass
elif cls == 'Lighting':
self._convert_lighting(inst, scene)
elif cls == 'Workspace':
# Workspace = root, его свойства мапим на scene.worldSize и т.п.
pass
elif cls in ('Camera', 'Terrain', 'StarterPlayer', 'StarterGui',
'StarterPack', 'StarterCharacterScripts', 'Players',
'ReplicatedStorage', 'ServerScriptService', 'ServerStorage',
'SoundService', 'TweenService', 'RunService',
'UserInputService', 'HttpService', 'DataStoreService',
'TeleportService', 'BadgeService', 'MarketplaceService',
'ContentProvider', 'NetworkClient', 'NetworkServer',
'Chat', 'Stats', 'Debris', 'AnalyticsService',
'CSGDictionaryService', 'NonReplicatedCSGDictionaryService'):
# Системные сервисы — игнорируем (Lua-runtime создаст mock'и)
pass
else:
self.stats.skipped_classes[cls] = self.stats.skipped_classes.get(cls, 0) + 1
except Exception as e:
self.stats.warnings.append(f"convert {cls} (referent={inst.referent}) failed: {e!r}")
self.stats.parts_dropped += 1
# ─── Part / WedgePart / etc ───
def _convert_part(self, inst: Instance, scene: Dict) -> None:
props = inst.properties
cf = props.get('CFrame')
size = props.get('size') or props.get('Size')
pos, rot = cframe_to_pos_rot(cf, self.scale)
# Roblox Part.Shape: 0=Ball, 1=Block, 2=Cylinder
# PartType enum value — берётся из props.get('shape') или props.get('Shape')
shape = props.get('Shape')
if isinstance(shape, EnumValue):
ptype = SHAPE_TO_PRIMITIVE.get(shape.value, 'cube')
else:
ptype = 'cube'
# Размер
if size:
sx = abs(size.x) * self.scale
sy = abs(size.y) * self.scale
sz = abs(size.z) * self.scale
else:
sx = sy = sz = 1.0 * self.scale
# Проверяем есть ли child SpecialMesh — он переопределяет визуал
for child in inst.children:
if child.class_name == 'SpecialMesh':
mesh_type = child.properties.get('MeshType')
if isinstance(mesh_type, EnumValue):
# MeshType: 0=Head, 1=Torso, 2=Wedge, 3=Sphere, 4=Cylinder, 5=FileMesh, 6=Brick
mt = mesh_type.value
if mt == 3: ptype = 'sphere'
elif mt == 4: ptype = 'cylinder'
prim_id = self._next_primitive_id
self._next_primitive_id += 1
self._instance_to_primitive_id[inst.referent] = prim_id
primitive = {
'id': prim_id,
'type': ptype,
'name': name_or_default(props, 'Part'),
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
'sx': sx, 'sy': sy, 'sz': sz,
'color': get_part_color(props),
'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)),
'visible': props.get('Transparency', 0) < 1.0 if isinstance(props.get('Transparency'), (int, float)) else True,
'opacity': max(0.0, 1.0 - (props.get('Transparency', 0) or 0)),
'anchored': bool(props.get('Anchored', False)),
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
}
scene['primitives'].append(primitive)
self.stats.primitives_created += 1
def _convert_wedge(self, inst: Instance, scene: Dict) -> None:
props = inst.properties
cf = props.get('CFrame')
size = props.get('size') or props.get('Size')
pos, rot = cframe_to_pos_rot(cf, self.scale)
if size:
sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale
else:
sx = sy = sz = 1.0
prim_id = self._next_primitive_id
self._next_primitive_id += 1
self._instance_to_primitive_id[inst.referent] = prim_id
scene['primitives'].append({
'id': prim_id, 'type': 'wedge',
'name': name_or_default(props, 'Wedge'),
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
'sx': sx, 'sy': sy, 'sz': sz,
'color': get_part_color(props),
'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)),
'visible': True,
'anchored': bool(props.get('Anchored', False)),
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
})
self.stats.primitives_created += 1
def _convert_cornerwedge(self, inst: Instance, scene: Dict) -> None:
props = inst.properties
cf = props.get('CFrame')
size = props.get('size') or props.get('Size')
pos, rot = cframe_to_pos_rot(cf, self.scale)
if size:
sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale
else:
sx = sy = sz = 1.0
prim_id = self._next_primitive_id
self._next_primitive_id += 1
self._instance_to_primitive_id[inst.referent] = prim_id
scene['primitives'].append({
'id': prim_id, 'type': 'cornerwedge',
'name': name_or_default(props, 'CornerWedge'),
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
'sx': sx, 'sy': sy, 'sz': sz,
'color': get_part_color(props),
'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)),
'visible': True,
'anchored': bool(props.get('Anchored', False)),
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
})
self.stats.primitives_created += 1
def _convert_truss(self, inst: Instance, scene: Dict) -> None:
"""TrussPart — Roblox-специфичный леса. Конвертим в cube с пометкой."""
props = inst.properties
cf = props.get('CFrame')
size = props.get('size') or props.get('Size')
pos, rot = cframe_to_pos_rot(cf, self.scale)
if size:
sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale
else:
sx, sy, sz = 0.5, 5.0, 0.5
prim_id = self._next_primitive_id
self._next_primitive_id += 1
self._instance_to_primitive_id[inst.referent] = prim_id
scene['primitives'].append({
'id': prim_id, 'type': 'cube',
'name': name_or_default(props, 'Truss'),
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
'sx': sx, 'sy': sy, 'sz': sz,
'color': get_part_color(props),
'material': 'metal',
'canCollide': True,
'visible': True,
'anchored': bool(props.get('Anchored', True)),
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
})
self.stats.primitives_created += 1
# ─── MeshPart / UnionOperation ───
def _convert_meshpart(self, inst: Instance, scene: Dict) -> None:
"""MeshPart → glbModels entry с ссылкой на сконвертированный GLB."""
props = inst.properties
cf = props.get('CFrame')
size = props.get('size') or props.get('Size')
pos, rot = cframe_to_pos_rot(cf, self.scale)
# MeshId — это rbxassetid:// строка
mesh_id_str = props.get('MeshID') or props.get('MeshId') or ''
rbx_id = self._parse_asset_id(mesh_id_str)
if rbx_id:
self.stats.asset_ids_needed.append(rbx_id)
glb_url = self.asset_url_resolver(rbx_id) if (rbx_id and self.asset_url_resolver) else None
if size:
sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale
else:
sx = sy = sz = 1.0
# Если GLB не доступен — fallback на bbox cube
if not glb_url:
prim_id = self._next_primitive_id
self._next_primitive_id += 1
self._instance_to_primitive_id[inst.referent] = prim_id
scene['primitives'].append({
'id': prim_id, 'type': 'cube',
'name': name_or_default(props, 'MeshPart'),
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
'sx': sx, 'sy': sy, 'sz': sz,
'color': get_part_color(props),
'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)),
'visible': True,
'anchored': bool(props.get('Anchored', False)),
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
'note': f'MeshPart (no GLB) rbxid={rbx_id}',
})
self.stats.primitives_created += 1
self.stats.parts_dropped += 1
return
glb_id = self._next_glb_id
self._next_glb_id += 1
scene['glbModels'].append({
'id': glb_id,
'name': name_or_default(props, 'MeshPart'),
'url': glb_url,
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
'sx': sx, 'sy': sy, 'sz': sz,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
'color': get_part_color(props),
'canCollide': bool(props.get('CanCollide', True)),
'anchored': bool(props.get('Anchored', False)),
'origin': 'roblox-meshpart',
'rbxAssetId': rbx_id,
})
self.stats.glb_models_created += 1
def _convert_union(self, inst: Instance, scene: Dict) -> None:
"""UnionOperation — CSG объединение. Берём AssetId если есть, иначе bbox."""
props = inst.properties
cf = props.get('CFrame')
size = props.get('size') or props.get('Size')
pos, rot = cframe_to_pos_rot(cf, self.scale)
if size:
sx, sy, sz = abs(size.x)*self.scale, abs(size.y)*self.scale, abs(size.z)*self.scale
else:
sx = sy = sz = 1.0
# AssetId Union'а — это его CSG-mesh
asset_id_str = props.get('AssetId') or ''
rbx_id = self._parse_asset_id(asset_id_str)
if rbx_id:
self.stats.asset_ids_needed.append(rbx_id)
glb_url = self.asset_url_resolver(rbx_id) if (rbx_id and self.asset_url_resolver) else None
if not glb_url:
# Fallback: cube
prim_id = self._next_primitive_id
self._next_primitive_id += 1
self._instance_to_primitive_id[inst.referent] = prim_id
scene['primitives'].append({
'id': prim_id, 'type': 'cube',
'name': name_or_default(props, 'Union'),
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
'sx': sx, 'sy': sy, 'sz': sz,
'color': get_part_color(props),
'material': material_to_string(props.get('Material')),
'canCollide': bool(props.get('CanCollide', True)),
'visible': True,
'anchored': bool(props.get('Anchored', False)),
'mass': 1.0,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
'note': f'Union (no CSG GLB) rbxid={rbx_id}',
})
self.stats.primitives_created += 1
return
glb_id = self._next_glb_id
self._next_glb_id += 1
scene['glbModels'].append({
'id': glb_id,
'name': name_or_default(props, 'Union'),
'url': glb_url,
'x': pos['x'], 'y': pos['y'], 'z': pos['z'],
'sx': sx, 'sy': sy, 'sz': sz,
'rotationX': rot['rx'], 'rotationY': rot['ry'], 'rotationZ': rot['rz'],
'color': get_part_color(props),
'canCollide': bool(props.get('CanCollide', True)),
'anchored': bool(props.get('Anchored', False)),
'origin': 'roblox-union',
'rbxAssetId': rbx_id,
})
self.stats.glb_models_created += 1
# ─── Spawn ───
def _convert_spawn(self, inst: Instance, scene: Dict) -> None:
props = inst.properties
cf = props.get('CFrame')
pos, _ = cframe_to_pos_rot(cf, self.scale)
# Spawn должен быть чуть выше — Roblox SpawnLocation это плоская плита,
# юзер появляется на её верхней грани.
scene['spawnPoint'] = {
'x': pos['x'],
'y': pos['y'] + 1.5, # отступ вверх чтобы не залипнуть в плите
'z': pos['z'],
}
# ─── Scripts ───
def _convert_script(self, inst: Instance, scene: Dict) -> None:
"""Lua-script сохраняем raw с пометкой kind='roblox-lua'.
Plus, attached to конкретному примитиву если у скрипта есть Parent
с известным referent.
"""
props = inst.properties
source = props.get('Source', '')
if not source or not isinstance(source, str):
self.stats.scripts_skipped += 1
return
# Цель — primitive id предка если есть
target = None
if inst.parent_referent and inst.parent_referent in self._instance_to_primitive_id:
target = self._instance_to_primitive_id[inst.parent_referent]
script_id = f'rbx_{self._next_script_id}'
self._next_script_id += 1
# storys-нормализатор сохраняет только {id,code,target,name} —
# поэтому упаковываем kind+lua_source ВНУТРЬ поля code как комментарий-маркер,
# а GameRuntime/RobloxLuaSandbox распакуют обратно.
# Маркер: первая строка ровно "// @roblox-lua" + JSON-метадата на 2-й строке.
marker_header = '// @roblox-lua\n// ' + json.dumps({
'roblox_class': inst.class_name,
'enabled': bool(props.get('Disabled', False) is False),
}, ensure_ascii=False) + '\n/* lua_source:\n'
marker_footer = '\n*/\n'
packed_code = marker_header + source + marker_footer
scene['scripts'].append({
'id': script_id,
'name': name_or_default(props, inst.class_name),
'target': target,
'code': packed_code,
})
self.stats.scripts_collected += 1
# ─── Sound ───
def _convert_sound(self, inst: Instance, scene: Dict) -> None:
props = inst.properties
sound_id_str = props.get('SoundId') or ''
rbx_id = self._parse_asset_id(sound_id_str)
if rbx_id:
self.stats.asset_ids_needed.append(rbx_id)
url = self.asset_url_resolver(rbx_id) if (rbx_id and self.asset_url_resolver) else None
scene['sounds'].append({
'id': f'sound_{rbx_id or inst.referent}',
'name': name_or_default(props, 'Sound'),
'url': url or '',
'volume': float(props.get('Volume', 1.0) or 1.0),
'loop': bool(props.get('Looped', False)),
'autoplay': bool(props.get('Playing', False)),
'rbxAssetId': rbx_id,
})
# ─── Light ───
def _convert_light(self, inst: Instance, scene: Dict) -> None:
"""Roblox PointLight / SpotLight / SurfaceLight → primitive type='light'."""
props = inst.properties
# Position берём от parent Part (если есть)
x = y = z = 0.0
parent = self.model.by_referent.get(inst.parent_referent) if inst.parent_referent else None
if parent and 'CFrame' in parent.properties:
pcf = parent.properties['CFrame']
x, y, z = pcf.position.x * self.scale, pcf.position.y * self.scale, pcf.position.z * self.scale
prim_id = self._next_primitive_id
self._next_primitive_id += 1
scene['primitives'].append({
'id': prim_id, 'type': 'light',
'name': name_or_default(props, inst.class_name),
'x': x, 'y': y, 'z': z,
'sx': 1, 'sy': 1, 'sz': 1,
'color': color3_to_hex(props.get('Color')),
'material': 'glossy',
'canCollide': False,
'visible': True,
'anchored': True,
'mass': 0,
'rotationX': 0, 'rotationY': 0, 'rotationZ': 0,
'lightRange': float(props.get('Range', 8.0) or 8.0) * self.scale,
'lightBrightness': float(props.get('Brightness', 1.0) or 1.0),
})
self.stats.primitives_created += 1
# ─── Folder / Model ───
def _convert_folder(self, inst: Instance, scene: Dict) -> None:
props = inst.properties
scene['folders'].append({
'id': f'rbx_folder_{inst.referent}',
'name': name_or_default(props, inst.class_name),
'parent': inst.parent_referent,
'origin': 'roblox-' + inst.class_name.lower(),
})
# ─── GUI ───
def _convert_screen_gui(self, inst: Instance, scene: Dict) -> None:
# ScreenGui сам не рендерится — он контейнер. Дети получают parentId=None
# при конверте. Сохраняем referent чтобы _gui_parent_id() видел.
# Также сохраняем Enabled-свойство: если ScreenGui.Enabled=false →
# все дети должны быть скрыты (Roblox прячет всю иерархию).
if not hasattr(self, '_screen_gui_refs'):
self._screen_gui_refs = set()
self._screen_gui_enabled = {}
self._screen_gui_refs.add(inst.referent)
enabled = inst.properties.get('Enabled', True)
self._screen_gui_enabled[inst.referent] = bool(enabled) if enabled is not None else True
def _gui_parent_id(self, parent_ref) -> Optional[str]:
if parent_ref is None:
return None
if hasattr(self, '_screen_gui_refs') and parent_ref in self._screen_gui_refs:
return None # top-level в ScreenGui = parentId=None в Rublox
return f'rbx_gui_{parent_ref}'
def _udim_to_percent(self, udim, axis: str = 'x') -> float:
"""Roblox UDim(scale, offset) → процент (0..100) для Rublox GUI.
Rublox использует проценты от viewport. Конвертация:
- scale (0..1) scale * 100
- offset (px) offset / viewport_size * 100 (1280×720 reference)
"""
if udim is None:
return 0.0
ref = 1280.0 if axis == 'x' else 720.0
if isinstance(udim, dict):
scale = udim.get('scale', 0) or 0
offset = udim.get('offset', 0) or 0
else:
scale = getattr(udim, 'scale', 0) or 0
offset = getattr(udim, 'offset', 0) or 0
return scale * 100.0 + (offset / ref) * 100.0
def _udim2_pair(self, udim2) -> Tuple[float, float]:
"""UDim2 → (x%, y%). Поддерживает dataclass UDim2 и dict."""
if udim2 is None:
return (0.0, 0.0)
if isinstance(udim2, dict):
x_obj = udim2.get('x')
y_obj = udim2.get('y')
else:
x_obj = getattr(udim2, 'x', None)
y_obj = getattr(udim2, 'y', None)
return (self._udim_to_percent(x_obj, 'x'), self._udim_to_percent(y_obj, 'y'))
def _color3_to_hex(self, c3) -> str:
if c3 is None:
return '#ffffff'
try:
if hasattr(c3, 'to_hex'):
return c3.to_hex()
r = int(round(getattr(c3, 'r', 1) * 255))
g = int(round(getattr(c3, 'g', 1) * 255))
b = int(round(getattr(c3, 'b', 1) * 255))
return f'#{r:02x}{g:02x}{b:02x}'
except Exception:
return '#ffffff'
def _convert_gui_element(self, inst: Instance, scene: Dict) -> None:
props = inst.properties
cls = inst.class_name
# type-маппинг Roblox → Rublox GUI
if cls in ('Frame', 'ScrollingFrame'):
r_type = 'frame'
elif cls == 'TextLabel':
r_type = 'text'
elif cls in ('TextButton', 'ImageButton'):
r_type = 'button'
elif cls == 'ImageLabel':
r_type = 'image'
elif cls == 'TextBox':
r_type = 'textbox'
else:
r_type = 'frame'
pos_x, pos_y = self._udim2_pair(props.get('Position'))
size_x, size_y = self._udim2_pair(props.get('Size'))
# В процентах. Если размер не указан — дефолт 20%×10%.
if size_x <= 0: size_x = 20.0
if size_y <= 0: size_y = 10.0
# Округляем до 0.1% для читаемости JSON
pos_x = round(pos_x, 2)
pos_y = round(pos_y, 2)
size_x = round(size_x, 2)
size_y = round(size_y, 2)
# Фильтр Roblox CDN URL'ов: rbxasset://, rbxassetid://, rbxhttp:// —
# браузер их не поймёт, даём пустую строку. В будущем asset_downloader
# может подменить на cached_url.
raw_image = str(props.get('Image', '') or '')
if raw_image.startswith(('rbxasset://', 'rbxassetid://', 'rbxhttp://', 'rbxthumb://')):
raw_image = ''
# Видимость: если родитель — ScreenGui.Enabled=false, скрываем весь элемент.
own_visible = props.get('Visible', True)
if own_visible is None:
own_visible = True
# Поднимаемся по родителям пока не найдём ScreenGui — если он Disabled,
# элемент тоже невидим.
parent_ref = inst.parent_referent
screen_enabled = True
if hasattr(self, '_screen_gui_refs'):
cur = parent_ref
depth = 0
while cur is not None and depth < 50:
if cur in self._screen_gui_refs:
screen_enabled = self._screen_gui_enabled.get(cur, True)
break
# Поиск родителя cur в instances (если есть)
cur_inst = self.model.by_referent.get(cur) if hasattr(self, 'model') else None
cur = cur_inst.parent_referent if cur_inst else None
depth += 1
effective_visible = bool(own_visible) and screen_enabled
# Эвристика: HDAdmin/Chat/Leaderboard модалки в Roblox показываются
# отдельными Lua-скриптами по триггеру. Без работающих скриптов они
# показываются ВСЕ сразу и наслаиваются. Скрываем их по имени.
gui_name = name_or_default(props, cls)
ADMIN_HIDDEN = ('HDAdmin', 'Cmdbar', 'Console', 'TeleportTo',
'Notifications', 'Settings', 'Promo', 'PlayerList',
'BanList', 'Admin', 'CommandBar')
# Поднимаемся по родителям проверяя их имена
cur = inst.parent_referent
depth = 0
while cur is not None and depth < 10:
cur_inst = self.model.by_referent.get(cur)
if not cur_inst: break
pn = cur_inst.properties.get('Name') or cur_inst.class_name
if any(h.lower() in str(pn).lower() for h in ADMIN_HIDDEN):
effective_visible = False
break
cur = cur_inst.parent_referent
depth += 1
element = {
'id': f'rbx_gui_{inst.referent}',
'type': r_type,
'name': name_or_default(props, cls),
'parentId': self._gui_parent_id(inst.parent_referent),
'x': pos_x,
'y': pos_y,
'w': size_x,
'h': size_y,
'anchor': 'top-left', # Roblox по умолчанию top-left
'visible': effective_visible,
'bgColor': self._color3_to_hex(props.get('BackgroundColor3')),
'bgOpacity': max(0.0, min(1.0, 1.0 - float(props.get('BackgroundTransparency', 0) or 0))),
'borderColor': self._color3_to_hex(props.get('BorderColor3')),
'borderWidth': int(props.get('BorderSizePixel', 0) or 0),
'borderRadius': 0,
'text': str(props.get('Text', '') or ''),
'textColor': self._color3_to_hex(props.get('TextColor3')),
'textSize': int(props.get('TextSize', 14) or 14),
'textAlign': 'center',
'fontWeight': 700 if cls in ('TextButton',) else 500,
'imageUrl': raw_image,
'imageAsset': None,
'zIndex': int(props.get('ZIndex', 1) or 1),
'origin': 'roblox-' + cls.lower(),
}
scene['gui'].append(element)
# ─── Lighting ───
def _convert_lighting(self, inst: Instance, scene: Dict) -> None:
"""Roblox Lighting service — мапим на scene.environment."""
props = inst.properties
env = scene['environment']
ambient = props.get('Ambient')
if isinstance(ambient, Color3):
env['ambientColor'] = ambient.to_hex()
brightness = props.get('Brightness')
if isinstance(brightness, (int, float)):
env['brightness'] = float(brightness)
clock = props.get('ClockTime')
if isinstance(clock, (int, float)):
env['timeOfDay'] = float(clock)
fog_color = props.get('FogColor')
if isinstance(fog_color, Color3):
env['fogColor'] = [fog_color.r, fog_color.g, fog_color.b]
fog_end = props.get('FogEnd')
if isinstance(fog_end, (int, float)) and fog_end < 999999:
env['fogEnabled'] = True
env['fogDensity'] = 1.0 / max(fog_end, 1.0)
# ─── utility ───
def _default_roblox_terrain(self) -> Dict:
"""Минимальный плоский ландшафт чтобы импорт не падал.
Real Roblox terrain потребует отдельной конвертации (voxel grid)
оставлю на потом.
"""
return {
'format': 'robloxterrain-v1',
'origin': {'x': -22, 'y': 0, 'z': -22},
'size': {'x': 44, 'y': 24, 'z': 44},
'palette': ['', 'grass', 'rock', 'sand'],
'mat': '',
'density': '',
}
def _parse_asset_id(self, s: Any) -> Optional[int]:
"""rbxassetid://12345 или rbxasset://12345 или просто 12345 → int."""
if not s:
return None
s = str(s).strip()
if not s:
return None
if s.startswith('rbxassetid://'):
s = s[len('rbxassetid://'):]
elif s.startswith('rbxasset://'):
s = s[len('rbxasset://'):]
elif s.startswith('http://www.roblox.com/asset/?id='):
s = s.split('=')[-1]
try:
return int(s)
except ValueError:
return None
# ────── CLI ──────
if __name__ == '__main__':
import sys
import json
sys.path.insert(0, '/opt/rbxl-importer/src')
from rbxl_parser import parse
path = sys.argv[1] if len(sys.argv) > 1 else '/opt/rbxl-importer/reference_files/easy_obby.rbxl'
out = sys.argv[2] if len(sys.argv) > 2 else '/tmp/converted.json'
with open(path, 'rb') as f:
blob = f.read()
logging.basicConfig(level=logging.INFO)
print(f'parsing {path} ({len(blob)} bytes)...')
model = parse(blob)
print(f'{model.instance_count} instances')
print(f'\nconverting...')
conv = Converter(model)
project_data = conv.convert()
with open(out, 'w', encoding='utf-8') as f:
json.dump(project_data, f, ensure_ascii=False, indent=2)
print(f'\nwritten to {out} ({sum(1 for _ in open(out))} lines)')
print('\n=== stats ===')
for k, v in asdict(conv.stats).items():
if k == 'warnings' and isinstance(v, list):
print(f' warnings: {len(v)} total')
for w in v[:5]:
print(f' - {w}')
elif k == 'skipped_classes':
top = sorted(v.items(), key=lambda x: -x[1])[:10]
print(f' skipped_classes (top 10):')
for c, n in top:
print(f' {n:>4d} {c}')
elif k == 'asset_ids_needed':
print(f' asset_ids_needed: {len(v)} (deduped: {len(set(v))})')
else:
print(f' {k}: {v}')

View File

@ -0,0 +1,481 @@
"""
mesh_converter.py Roblox .mesh parser + GLB writer.
Roblox .mesh формат:
v1.00, v1.01: ASCII текстовый лёгкий парсинг.
v2.00, v3.00, v4.00, v5.00: бинарный.
Все версии начинаются с ASCII строки "version X.YY\n" (12+ байт), затем
зависимо от версии либо текст, либо binary.
Документация формата (community-reverse-engineered):
https://devforum.roblox.com/t/roblox-mesh-format/326114
https://github.com/MaximumADHD/Roblox-File-Format/blob/main/Plugins/MeshImporter.cs
Этот модуль:
1. parse_roblox_mesh(blob) MeshData (vertices, normals, uvs, faces)
2. build_glb(mesh_data) bytes (готовый .glb файл)
3. convert(input_path, output_path) высокоуровневая обёртка
"""
import struct
import io
import re
from dataclasses import dataclass, field
from typing import List, Tuple
import logging
logger = logging.getLogger(__name__)
@dataclass
class Vertex:
x: float; y: float; z: float
nx: float = 0.0; ny: float = 0.0; nz: float = 0.0
u: float = 0.0; v: float = 0.0
# Vertex color (RGBA в 0-1)
r: float = 1.0; g: float = 1.0; b: float = 1.0; a: float = 1.0
@dataclass
class MeshData:
version: str # 'v1.00', 'v2.00', ...
vertices: List[Vertex] = field(default_factory=list)
faces: List[Tuple[int, int, int]] = field(default_factory=list) # индексы треугольников
has_normals: bool = True
has_uvs: bool = True
class MeshParseError(Exception):
pass
# ──────────────────────────────────────────────────────────────────────
# Главный диспатчер
# ──────────────────────────────────────────────────────────────────────
def parse_roblox_mesh(blob: bytes) -> MeshData:
"""Парсит Roblox .mesh файл любой версии. Бросает MeshParseError."""
# Все версии начинаются со строки "version X.YY\n"
nl_pos = blob.find(b'\n')
if nl_pos < 0:
raise MeshParseError("no newline in first line — not a roblox mesh")
first_line = blob[:nl_pos].decode('ascii', errors='replace').strip()
m = re.match(r'version (\d+\.\d+)', first_line)
if not m:
raise MeshParseError(f"bad first line: {first_line!r}")
version = 'v' + m.group(1)
rest = blob[nl_pos+1:]
if version in ('v1.00', 'v1.01'):
return _parse_v1(rest, version)
elif version == 'v2.00':
return _parse_v2(rest, version)
elif version == 'v3.00':
return _parse_v3(rest, version)
elif version in ('v4.00', 'v4.01'):
return _parse_v4(rest, version)
elif version == 'v5.00':
return _parse_v5(rest, version)
else:
raise MeshParseError(f"unsupported mesh version: {version}")
# ──────────────────────────────────────────────────────────────────────
# v1.00 / v1.01 — ASCII текст
# ──────────────────────────────────────────────────────────────────────
def _parse_v1(rest: bytes, version: str) -> MeshData:
"""v1: ASCII text format.
Структура:
<num_faces>\n
[x,y,z][nx,ny,nz][u,v,unused] [x,y,z][nx,ny,nz][u,v,unused] [x,y,z][nx,ny,nz][u,v,unused] ...
Каждое лицо 3 vertex'а подряд. Vertices не дедуплицируются (поэтому
лиц = num_faces, vertices = num_faces*3). Для GLB сделаем dedup потом.
В v1 vertices использовали диапазон [-0.5, 0.5] (нормализованный).
"""
text = rest.decode('ascii', errors='replace').strip()
parts = text.split('\n', 1)
try:
num_faces = int(parts[0].strip())
except ValueError:
raise MeshParseError(f"v1: bad face count: {parts[0]!r}")
# Парсим все [x,y,z] tuples
body = parts[1] if len(parts) > 1 else ''
tuples = re.findall(r'\[([^\]]+)\]', body)
# На каждый vertex — 3 tuple ([pos][normal][uv])
if len(tuples) < num_faces * 9:
raise MeshParseError(
f"v1: expected {num_faces*9} bracketed tuples, got {len(tuples)}"
)
vertices = []
for i in range(num_faces * 3):
pos = list(map(float, tuples[i*3].split(',')))
norm = list(map(float, tuples[i*3 + 1].split(',')))
uv = list(map(float, tuples[i*3 + 2].split(',')))
# v1 имеет позиции в [-0.5, 0.5]; для отображения масштабируем
vertices.append(Vertex(
x=pos[0] * 2.0, y=pos[1] * 2.0, z=pos[2] * 2.0,
nx=norm[0], ny=norm[1], nz=norm[2],
u=uv[0], v=1.0 - uv[1], # GLTF V-flip
))
faces = [(i*3, i*3 + 1, i*3 + 2) for i in range(num_faces)]
return MeshData(version=version, vertices=vertices, faces=faces)
# ──────────────────────────────────────────────────────────────────────
# v2.00 — binary
# ──────────────────────────────────────────────────────────────────────
def _parse_v2(rest: bytes, version: str) -> MeshData:
"""v2: бинарный формат.
Структура:
header_size (uint16, обычно 12)
vertex_size (uint8, обычно 40 9 float + 4 byte color)
face_size (uint8, обычно 12 3 uint32)
num_vertices (uint32)
num_faces (uint32)
vertices... (num_vertices * vertex_size байт)
faces... (num_faces * face_size байт)
vertex (40 байт):
pos (3 * float32)
normal (3 * float32)
uv (2 * float32)
color (4 * uint8) RGBA
"""
r = io.BytesIO(rest)
header_size = struct.unpack('<H', r.read(2))[0]
vertex_size = struct.unpack('<B', r.read(1))[0]
face_size = struct.unpack('<B', r.read(1))[0]
num_vertices = struct.unpack('<I', r.read(4))[0]
num_faces = struct.unpack('<I', r.read(4))[0]
# пропускаем оставшиеся байты header'а если он больше 12
if header_size > 12:
r.read(header_size - 12)
vertices = []
for _ in range(num_vertices):
v_data = r.read(vertex_size)
# pos + normal + uv
x, y, z = struct.unpack('<3f', v_data[0:12])
nx, ny, nz = struct.unpack('<3f', v_data[12:24])
u, vv = struct.unpack('<2f', v_data[24:32])
# color если есть
if vertex_size >= 40:
cr, cg, cb, ca = v_data[32:36]
vert = Vertex(x, y, z, nx, ny, nz, u, 1.0 - vv,
cr/255.0, cg/255.0, cb/255.0, ca/255.0)
else:
vert = Vertex(x, y, z, nx, ny, nz, u, 1.0 - vv)
vertices.append(vert)
faces = []
for _ in range(num_faces):
f_data = r.read(face_size)
a, b, c = struct.unpack('<3I', f_data[0:12])
faces.append((a, b, c))
return MeshData(version=version, vertices=vertices, faces=faces)
# ──────────────────────────────────────────────────────────────────────
# v3, v4 — расширенные форматы с LOD и skinning
# ──────────────────────────────────────────────────────────────────────
def _parse_v3(rest: bytes, version: str) -> MeshData:
"""v3: v2 + LOD support.
Header:
header_size (uint16) = 16
vertex_size (uint8)
face_size (uint8)
lod_type (uint16)
num_vertices (uint32)
num_faces (uint32)
num_lods (uint16)
...
Для импорта нам нужен только LOD 0 (highest quality), остальные пропускаем.
"""
r = io.BytesIO(rest)
header_size = struct.unpack('<H', r.read(2))[0]
vertex_size = struct.unpack('<B', r.read(1))[0]
face_size = struct.unpack('<B', r.read(1))[0]
_lod_type = struct.unpack('<H', r.read(2))[0]
num_vertices = struct.unpack('<I', r.read(4))[0]
num_faces = struct.unpack('<I', r.read(4))[0]
num_lods = struct.unpack('<H', r.read(2))[0]
# если header больше — дочитаем
consumed = 16
if header_size > consumed:
r.read(header_size - consumed)
# vertices
vertices = []
for _ in range(num_vertices):
v_data = r.read(vertex_size)
x, y, z = struct.unpack('<3f', v_data[0:12])
nx, ny, nz = struct.unpack('<3f', v_data[12:24])
u, vv = struct.unpack('<2f', v_data[24:32])
if vertex_size >= 40:
cr, cg, cb, ca = v_data[32:36]
vert = Vertex(x, y, z, nx, ny, nz, u, 1.0 - vv,
cr/255.0, cg/255.0, cb/255.0, ca/255.0)
else:
vert = Vertex(x, y, z, nx, ny, nz, u, 1.0 - vv)
vertices.append(vert)
faces = []
for _ in range(num_faces):
a, b, c = struct.unpack('<3I', r.read(12))
faces.append((a, b, c))
# LOD info (пропускаем — нам нужен только полный mesh)
# LOD-таблица: num_lods+1 uint32 (face offsets)
for _ in range(num_lods + 1):
r.read(4)
return MeshData(version=version, vertices=vertices, faces=faces)
def _parse_v4(rest: bytes, version: str) -> MeshData:
"""v4: v3 + bones + envelope (skinning).
Header (v4.00):
header_size (uint16)
lod_type (uint16)
num_vertices (uint32)
num_faces (uint32)
num_lods (uint16)
num_bones (uint16)
bone_names_size (uint32)
num_subsets (uint16)
num_high_quality_lods (uint8)
unused (uint8)
...
Для импорта в Rublox bones мы пока не поддерживаем забираем только vertices+faces.
"""
r = io.BytesIO(rest)
header_size = struct.unpack('<H', r.read(2))[0]
_lod_type = struct.unpack('<H', r.read(2))[0]
num_vertices = struct.unpack('<I', r.read(4))[0]
num_faces = struct.unpack('<I', r.read(4))[0]
num_lods = struct.unpack('<H', r.read(2))[0]
num_bones = struct.unpack('<H', r.read(2))[0]
bone_names_size = struct.unpack('<I', r.read(4))[0]
num_subsets = struct.unpack('<H', r.read(2))[0]
_hq_lods = struct.unpack('<B', r.read(1))[0]
_unused = struct.unpack('<B', r.read(1))[0]
consumed = 22
if header_size > consumed:
r.read(header_size - consumed)
# v4 vertex = 40 байт (как v2) + envelope в отдельном блоке
vertex_size = 40
vertices = []
for _ in range(num_vertices):
v_data = r.read(vertex_size)
x, y, z = struct.unpack('<3f', v_data[0:12])
nx, ny, nz = struct.unpack('<3f', v_data[12:24])
u, vv = struct.unpack('<2f', v_data[24:32])
cr, cg, cb, ca = v_data[32:36]
vertices.append(Vertex(x, y, z, nx, ny, nz, u, 1.0 - vv,
cr/255.0, cg/255.0, cb/255.0, ca/255.0))
# envelope (skinning weights) — пропускаем
if num_bones > 0:
# envelope = 8 байт на vertex (4 bone indexes + 4 weights)
r.read(num_vertices * 8)
faces = []
for _ in range(num_faces):
a, b, c = struct.unpack('<3I', r.read(12))
faces.append((a, b, c))
return MeshData(version=version, vertices=vertices, faces=faces)
def _parse_v5(rest: bytes, version: str) -> MeshData:
"""v5: v4 + facial animation + расширенный LOD.
Header структура почти такая же как v4, плюс несколько полей в конце.
"""
# Парсим как v4 — для базовой геометрии этого достаточно
return _parse_v4(rest, version)
# ──────────────────────────────────────────────────────────────────────
# GLB writer — конвертер MeshData → GLB бинарь
# ──────────────────────────────────────────────────────────────────────
def build_glb(mesh: MeshData) -> bytes:
"""Собирает GLB (glTF 2.0 binary) из MeshData.
GLB структура:
magic 'glTF' (4 bytes)
version (uint32) = 2
length (uint32) total file size
JSON chunk:
length (uint32)
type 'JSON'
JSON payload (UTF-8)
BIN chunk:
length (uint32)
type 'BIN\0'
binary payload (vertex data + index data)
"""
import json
n_verts = len(mesh.vertices)
n_faces = len(mesh.faces)
if n_verts == 0 or n_faces == 0:
raise MeshParseError("empty mesh — cannot build GLB")
# Бинарные данные: POSITION (3f) + NORMAL (3f) + TEXCOORD_0 (2f) + indices (uint32)
pos_buf = bytearray()
norm_buf = bytearray()
uv_buf = bytearray()
for v in mesh.vertices:
pos_buf.extend(struct.pack('<3f', v.x, v.y, v.z))
norm_buf.extend(struct.pack('<3f', v.nx, v.ny, v.nz))
uv_buf.extend(struct.pack('<2f', v.u, v.v))
idx_buf = bytearray()
for a, b, c in mesh.faces:
idx_buf.extend(struct.pack('<3I', a, b, c))
# Все буферы конкатенируются в один большой
# Каждый должен начинаться с 4-byte alignment
def pad4(buf):
while len(buf) % 4 != 0:
buf.append(0)
return buf
pos_offset = 0
pos_size = len(pos_buf)
pad4(pos_buf)
norm_offset = len(pos_buf)
norm_size = len(norm_buf)
pad4(norm_buf)
uv_offset = norm_offset + len(norm_buf)
uv_size = len(uv_buf)
pad4(uv_buf)
idx_offset = uv_offset + len(uv_buf)
idx_size = len(idx_buf)
pad4(idx_buf)
bin_total = pos_buf + norm_buf + uv_buf + idx_buf
# Mins/maxes — нужны GLTF спекой для POSITION
xs = [v.x for v in mesh.vertices]
ys = [v.y for v in mesh.vertices]
zs = [v.z for v in mesh.vertices]
pos_min = [min(xs), min(ys), min(zs)]
pos_max = [max(xs), max(ys), max(zs)]
gltf = {
"asset": {"version": "2.0", "generator": "rublox-rbxl-importer"},
"scene": 0,
"scenes": [{"nodes": [0]}],
"nodes": [{"mesh": 0}],
"meshes": [{
"primitives": [{
"attributes": {
"POSITION": 0,
"NORMAL": 1,
"TEXCOORD_0": 2,
},
"indices": 3,
"mode": 4, # TRIANGLES
}],
}],
"buffers": [{
"byteLength": len(bin_total),
}],
"bufferViews": [
{"buffer": 0, "byteOffset": pos_offset, "byteLength": pos_size, "target": 34962}, # ARRAY_BUFFER
{"buffer": 0, "byteOffset": norm_offset, "byteLength": norm_size, "target": 34962},
{"buffer": 0, "byteOffset": uv_offset, "byteLength": uv_size, "target": 34962},
{"buffer": 0, "byteOffset": idx_offset, "byteLength": idx_size, "target": 34963}, # ELEMENT_ARRAY_BUFFER
],
"accessors": [
{"bufferView": 0, "componentType": 5126, "count": n_verts, "type": "VEC3", "min": pos_min, "max": pos_max}, # FLOAT
{"bufferView": 1, "componentType": 5126, "count": n_verts, "type": "VEC3"},
{"bufferView": 2, "componentType": 5126, "count": n_verts, "type": "VEC2"},
{"bufferView": 3, "componentType": 5125, "count": n_faces * 3, "type": "SCALAR"}, # UNSIGNED_INT
],
}
json_bytes = json.dumps(gltf, separators=(',', ':')).encode('utf-8')
while len(json_bytes) % 4 != 0:
json_bytes += b' '
# GLB header
total_len = 12 + 8 + len(json_bytes) + 8 + len(bin_total)
out = bytearray()
out.extend(b'glTF')
out.extend(struct.pack('<I', 2)) # version
out.extend(struct.pack('<I', total_len)) # length
# JSON chunk
out.extend(struct.pack('<I', len(json_bytes)))
out.extend(b'JSON')
out.extend(json_bytes)
# BIN chunk
out.extend(struct.pack('<I', len(bin_total)))
out.extend(b'BIN\x00')
out.extend(bin_total)
return bytes(out)
# ──────────────────────────────────────────────────────────────────────
# CLI
# ──────────────────────────────────────────────────────────────────────
def convert(input_path: str, output_path: str) -> dict:
"""Конвертит .mesh → .glb. Возвращает dict с metadata."""
with open(input_path, 'rb') as f:
blob = f.read()
mesh = parse_roblox_mesh(blob)
glb = build_glb(mesh)
with open(output_path, 'wb') as f:
f.write(glb)
return {
'version': mesh.version,
'vertices': len(mesh.vertices),
'faces': len(mesh.faces),
'input_size': len(blob),
'output_size': len(glb),
}
if __name__ == '__main__':
import sys
if len(sys.argv) < 3:
print("usage: mesh_converter.py input.mesh output.glb")
sys.exit(1)
info = convert(sys.argv[1], sys.argv[2])
for k, v in info.items():
print(f" {k}: {v}")

View File

@ -0,0 +1,203 @@
"""
rbxl_binreader.py низкоуровневое чтение бинарного потока Roblox.
Особенности формата:
1. Little-endian для всех целочисленных и float.
2. "Interleaved transformed" массивы: для N значений длиной L байт каждое,
байты идут не AAAA BBBB CCCC, а ABCD ABCD ABCD ABCD (interleave).
То есть сначала все first-byte'ы, потом все second-byte'ы, итд. Это
делается потому что среди одинакового свойства часто меняется только
младший байт, и interleave даёт длинные последовательности нулей и
повторяющихся байт лучше сжимается LZ4.
3. Signed int zigzag: (n << 1) ^ (n >> 31) для int32, аналогично для int64.
4. Float "Roblox float encoding": сдвиг битов чтобы знаковый бит был в конце.
Сделано опять же чтобы маленькие близкие к нулю float'ы выглядели похоже
и сжимались.
5. String: uint32 length + UTF-8 bytes.
Полная спецификация: https://dom.rojo.space/binary
"""
import struct
import io
class BinReader:
"""Тонкая обёртка над BytesIO с методами для Roblox-типов."""
def __init__(self, data: bytes):
self.buf = data
self.pos = 0
# ─── атомарные ───
def read(self, n: int) -> bytes:
out = self.buf[self.pos:self.pos + n]
if len(out) < n:
raise IOError(f"unexpected EOF: wanted {n}, got {len(out)} at pos {self.pos}")
self.pos += n
return out
def remaining(self) -> int:
return len(self.buf) - self.pos
def at_end(self) -> bool:
return self.pos >= len(self.buf)
def skip(self, n: int) -> None:
self.pos += n
# ─── простые числовые ───
def uint8(self) -> int:
return self.read(1)[0]
def uint16(self) -> int:
return struct.unpack('<H', self.read(2))[0]
def uint32(self) -> int:
return struct.unpack('<I', self.read(4))[0]
def uint64(self) -> int:
return struct.unpack('<Q', self.read(8))[0]
def int32(self) -> int:
return struct.unpack('<i', self.read(4))[0]
def int64(self) -> int:
return struct.unpack('<q', self.read(8))[0]
def float32(self) -> float:
return struct.unpack('<f', self.read(4))[0]
def float64(self) -> float:
return struct.unpack('<d', self.read(8))[0]
def bool(self) -> bool:
return self.read(1)[0] != 0
def string(self) -> str:
length = self.uint32()
if length == 0:
return ''
return self.read(length).decode('utf-8', errors='replace')
def bytes_with_len(self) -> bytes:
length = self.uint32()
return self.read(length)
# ──────────────────────────────────────────────────────────────────────
# Decoders для "interleaved transformed" массивов.
# Используются в PROP chunks где для класса с N инстансами идёт одно
# свойство, и значение лежит в interleaved виде.
# ──────────────────────────────────────────────────────────────────────
def deinterleave(data: bytes, count: int, element_size: int) -> bytes:
"""Снимает interleave. Получает count*element_size байт и возвращает
нормальный массив байт где значения идут последовательно.
interleaved: b0[0] b1[0] b2[0] ... b0[1] b1[1] b2[1] ... b0[3] b1[3] b2[3]
нормальный: b0[0] b0[1] b0[2] b0[3] b1[0] b1[1] b1[2] b1[3] ...
"""
if len(data) != count * element_size:
raise ValueError(
f"deinterleave: expected {count}*{element_size}={count*element_size} bytes, got {len(data)}"
)
if count == 0:
return b''
out = bytearray(count * element_size)
for byte_idx in range(element_size):
for value_idx in range(count):
out[value_idx * element_size + byte_idx] = data[byte_idx * count + value_idx]
return bytes(out)
def zigzag_decode_int32(n: int) -> int:
"""ZigZag decode для int32: (n >> 1) ^ -(n & 1).
Используется для PROP с типом Int32 (свойство 'Size' MeshPart и т.п.).
"""
return (n >> 1) ^ -(n & 1)
def zigzag_decode_int64(n: int) -> int:
return (n >> 1) ^ -(n & 1)
def roblox_float_decode(raw: int) -> float:
"""Roblox float encoding для PROP-массивов.
Roblox перекодирует float32 чтобы знаковый бит был в самом конце
(это даёт лучшее сжатие при близких к нулю значениях).
raw uint32 little-endian.
"""
# Развернём биты так чтобы знаковый бит вернулся в позицию 31.
# Roblox: { mantissa_high(23 bits), exponent(8 bits), sign(1 bit) } → стандартный float
# Просто (raw >> 1) | ((raw & 1) << 31)
standard = (raw >> 1) | ((raw & 1) << 31)
return struct.unpack('<f', struct.pack('<I', standard))[0]
# ──────────────────────────────────────────────────────────────────────
# Высокоуровневые readers для interleaved-массивов
# ──────────────────────────────────────────────────────────────────────
def read_interleaved_int32_array(reader: BinReader, count: int) -> list:
"""Читает count int32 в interleaved-zigzag форме."""
raw = reader.read(count * 4)
deint = deinterleave(raw, count, 4)
out = []
for i in range(count):
u = struct.unpack('>I', deint[i*4:i*4+4])[0] # ВНИМАНИЕ: после deinterleave порядок big-endian!
out.append(zigzag_decode_int32(u))
return out
def read_interleaved_uint32_array(reader: BinReader, count: int) -> list:
"""uint32 array (для Referent) — interleaved, но без zigzag."""
raw = reader.read(count * 4)
deint = deinterleave(raw, count, 4)
out = []
for i in range(count):
out.append(struct.unpack('>I', deint[i*4:i*4+4])[0])
return out
def read_interleaved_int64_array(reader: BinReader, count: int) -> list:
raw = reader.read(count * 8)
deint = deinterleave(raw, count, 8)
out = []
for i in range(count):
u = struct.unpack('>Q', deint[i*8:i*8+8])[0]
out.append(zigzag_decode_int64(u))
return out
def read_interleaved_float_array(reader: BinReader, count: int) -> list:
"""Float32 array — interleaved с Roblox float encoding."""
raw = reader.read(count * 4)
deint = deinterleave(raw, count, 4)
out = []
for i in range(count):
u = struct.unpack('>I', deint[i*4:i*4+4])[0]
out.append(roblox_float_decode(u))
return out
def read_referent_array(reader: BinReader, count: int) -> list:
"""Referent array — interleaved int32 array с накопительной разностью.
Каждое следующее значение = предыдущее + delta. Это даёт длинные
последовательности (1, 2, 3, 4, ...) которые после deinterleave +
zigzag отлично сжимаются в нули.
"""
deltas = read_interleaved_int32_array(reader, count)
out = []
accumulator = 0
for d in deltas:
accumulator += d
out.append(accumulator)
return out

View File

@ -0,0 +1,425 @@
"""
rbxl_parser.py главный парсер Roblox Binary Level (.rbxl) v1.
Использование:
from rbxl_parser import parse, RobloxModel
with open('map.rbxl', 'rb') as f:
model = parse(f.read())
for inst in model.instances:
print(inst.class_name, inst.properties.get('Name'))
Архитектура:
1. parse() читает все chunks (META, SSTR, INST, PROP, PRNT, END).
2. Из INST chunks извлекаем какие классы (Part, Script, ...) есть в файле
и сколько инстансов у каждого.
3. Из PROP chunks извлекаем значения свойств (по одному chunk на свойство
для каждого класса).
4. Из PRNT chunk иерархия parent-ссылок (referent->referent).
5. Собираем плоский список Instance'ов с привязкой properties + parent.
Полная документация формата: https://dom.rojo.space/binary
"""
import struct
import io
import lz4.block
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Any
from rbxl_binreader import (
BinReader,
read_interleaved_uint32_array,
read_referent_array,
)
from rbxl_types import (
decode_prop_chunk,
PropChunk,
)
RBXL_SIGNATURE = b'<roblox!\x89\xff\r\n\x1a\n'
class RbxlParseError(Exception):
pass
# ──────────────────────────────────────────────────────────────────────
# Промежуточные структуры (raw chunks)
# ──────────────────────────────────────────────────────────────────────
@dataclass
class RawChunk:
name: str
payload: bytes
@dataclass
class RawHeader:
version: int
class_count: int
instance_count: int
@dataclass
class InstChunk:
"""Описание одного класса (берётся из INST chunk)."""
class_id: int # порядковый индекс (=index в массиве)
class_name: str # 'Part', 'Script', 'MeshPart'
is_service: bool # сервис (Workspace, Lighting) или обычный объект
referent_ids: List[int] # для каждого инстанса его referent (referent — уникальный int32 id)
# ──────────────────────────────────────────────────────────────────────
# Высокоуровневая модель: Instance + RobloxModel
# ──────────────────────────────────────────────────────────────────────
@dataclass
class Instance:
"""Один объект Roblox-сцены, готовый к преобразованию в Rublox."""
referent: int # уникальный id из rbxl
class_name: str # 'Part', 'Script', ...
properties: Dict[str, Any] = field(default_factory=dict)
parent_referent: Optional[int] = None # None == root (DataModel)
children: List['Instance'] = field(default_factory=list)
@dataclass
class RobloxModel:
"""Полная разобранная модель."""
version: int
class_count: int
instance_count: int
instances: List[Instance] # плоский список
by_referent: Dict[int, Instance] # быстрый лукап по referent
roots: List[Instance] # инстансы без parent (children of DataModel)
shared_strings: List[bytes] # из SSTR chunk
meta: Dict[str, str] # из META chunk
warnings: List[str] # некритичные проблемы парсинга
# сырые chunks на случай если что-то надо допарсить отдельно
raw_chunks: List[RawChunk] = field(default_factory=list)
# ──────────────────────────────────────────────────────────────────────
# Read chunks (без decode значений)
# ──────────────────────────────────────────────────────────────────────
def _read_chunks(blob: bytes) -> tuple:
"""Возвращает (header, [RawChunk]).
Каждый chunk:
name (4 bytes ASCII), compressed_len (uint32), uncompressed_len (uint32),
reserved (uint32), payload (compressed_len bytes if compressed_len>0 else uncompressed_len bytes).
Если compressed_len == 0 payload не сжат.
"""
if not blob.startswith(RBXL_SIGNATURE):
raise RbxlParseError(
f"signature mismatch: got {blob[:14].hex()}, expected {RBXL_SIGNATURE.hex()}"
)
stream = io.BytesIO(blob[len(RBXL_SIGNATURE):])
# Header
version, class_count, instance_count = struct.unpack('<HII', stream.read(10))
stream.read(8) # reserved
header = RawHeader(version, class_count, instance_count)
chunks = []
while True:
hdr = stream.read(16)
if len(hdr) < 16:
break
name_raw, compressed_len, uncompressed_len, _ = struct.unpack('<4sIII', hdr)
name = name_raw.decode('ascii', errors='replace').rstrip('\x00').rstrip()
if compressed_len == 0:
payload = stream.read(uncompressed_len)
else:
data = stream.read(compressed_len)
try:
payload = lz4.block.decompress(data, uncompressed_size=uncompressed_len)
except Exception as e:
raise RbxlParseError(f"LZ4 decompress failed for chunk {name!r}: {e}")
chunks.append(RawChunk(name, payload))
if name.startswith('END'):
break
return header, chunks
# ──────────────────────────────────────────────────────────────────────
# Decode INST chunk
# ──────────────────────────────────────────────────────────────────────
def _decode_inst_chunk(payload: bytes) -> InstChunk:
"""Декодирует INST chunk.
Структура:
class_id (uint32)
class_name (string)
is_service (uint8)
count (uint32) число инстансов класса
referents (interleaved-zigzag-累加 int32, count штук)
if is_service:
services_flags (count байт)
"""
r = BinReader(payload)
class_id = r.uint32()
class_name = r.string()
is_service = bool(r.uint8())
count = r.uint32()
referents = read_referent_array(r, count)
if is_service:
# Пропускаем сервис-флаги (один байт на инстанс)
r.read(count)
return InstChunk(
class_id=class_id,
class_name=class_name,
is_service=is_service,
referent_ids=referents,
)
# ──────────────────────────────────────────────────────────────────────
# Decode SSTR chunk
# ──────────────────────────────────────────────────────────────────────
def _decode_sstr_chunk(payload: bytes) -> List[bytes]:
"""SSTR (Shared Strings) chunk.
Структура:
version (uint32)
count (uint32)
для каждого:
md5_hash (16 bytes) игнорируется
length (uint32)
data (length bytes)
"""
r = BinReader(payload)
_version = r.uint32()
count = r.uint32()
strings = []
for _ in range(count):
r.read(16) # md5
length = r.uint32()
strings.append(r.read(length))
return strings
# ──────────────────────────────────────────────────────────────────────
# Decode PRNT chunk (parent hierarchy)
# ──────────────────────────────────────────────────────────────────────
def _decode_prnt_chunk(payload: bytes, instance_count: int) -> List[tuple]:
"""PRNT (Parents) chunk: для каждого instance его parent.
Структура:
version (uint8) обычно 0
count (uint32) instance_count
child_referents: interleaved int32 array (length=count, cumulative)
parent_referents: interleaved int32 array (length=count, cumulative)
Возвращает [(child_referent, parent_referent)]. parent_referent == -1
означает что parent root (Workspace/DataModel).
"""
r = BinReader(payload)
_version = r.uint8()
count = r.uint32()
children = read_referent_array(r, count)
parents = read_referent_array(r, count)
return list(zip(children, parents))
# ──────────────────────────────────────────────────────────────────────
# Decode META chunk
# ──────────────────────────────────────────────────────────────────────
def _decode_meta_chunk(payload: bytes) -> Dict[str, str]:
"""META chunk: пары ключ-значение."""
r = BinReader(payload)
count = r.uint32()
meta = {}
for _ in range(count):
k = r.string()
v = r.string()
meta[k] = v
return meta
# ──────────────────────────────────────────────────────────────────────
# Главная функция parse()
# ──────────────────────────────────────────────────────────────────────
def parse(blob: bytes) -> RobloxModel:
"""Парсит .rbxl-байты в RobloxModel со списком Instance'ов."""
header, raw_chunks = _read_chunks(blob)
warnings: List[str] = []
# 1. Извлекаем META
meta = {}
for c in raw_chunks:
if c.name == 'META':
try:
meta = _decode_meta_chunk(c.payload)
except Exception as e:
warnings.append(f"META decode failed: {e}")
# 2. SSTR
shared_strings: List[bytes] = []
for c in raw_chunks:
if c.name == 'SSTR':
try:
shared_strings = _decode_sstr_chunk(c.payload)
except Exception as e:
warnings.append(f"SSTR decode failed: {e}")
break
# 3. INST — описания классов
inst_chunks: Dict[int, InstChunk] = {}
for c in raw_chunks:
if c.name == 'INST':
try:
ic = _decode_inst_chunk(c.payload)
inst_chunks[ic.class_id] = ic
except Exception as e:
warnings.append(f"INST decode failed: {e}")
# 4. Создаём пустые Instance'ы по референтам
by_referent: Dict[int, Instance] = {}
instances: List[Instance] = []
for class_id, ic in inst_chunks.items():
for ref in ic.referent_ids:
inst = Instance(referent=ref, class_name=ic.class_name)
by_referent[ref] = inst
instances.append(inst)
# 5. PROP — заполняем свойства
for c in raw_chunks:
if c.name == 'PROP':
try:
# Узнаём class_id (первые 4 байта чанка) чтобы взять count
class_id = struct.unpack('<I', c.payload[:4])[0]
ic = inst_chunks.get(class_id)
if not ic:
warnings.append(f"PROP for unknown class_id={class_id}")
continue
pc = decode_prop_chunk(c.payload, len(ic.referent_ids), shared_strings)
if len(pc.values) != len(ic.referent_ids):
warnings.append(
f"PROP {ic.class_name}.{pc.prop_name}: value count "
f"{len(pc.values)} != instance count {len(ic.referent_ids)}"
)
for ref, val in zip(ic.referent_ids, pc.values):
inst = by_referent.get(ref)
if inst:
inst.properties[pc.prop_name] = val
except Exception as e:
warnings.append(f"PROP decode failed: {e!r}")
# 6. PRNT — собираем дерево
for c in raw_chunks:
if c.name == 'PRNT':
try:
links = _decode_prnt_chunk(c.payload, header.instance_count)
for child_ref, parent_ref in links:
child = by_referent.get(child_ref)
if not child:
continue
if parent_ref == -1:
child.parent_referent = None
else:
child.parent_referent = parent_ref
parent = by_referent.get(parent_ref)
if parent:
parent.children.append(child)
except Exception as e:
warnings.append(f"PRNT decode failed: {e!r}")
# 7. roots
roots = [i for i in instances if i.parent_referent is None]
return RobloxModel(
version=header.version,
class_count=header.class_count,
instance_count=header.instance_count,
instances=instances,
by_referent=by_referent,
roots=roots,
shared_strings=shared_strings,
meta=meta,
warnings=warnings,
raw_chunks=raw_chunks,
)
# ──────────────────────────────────────────────────────────────────────
# Утилиты для отчёта
# ──────────────────────────────────────────────────────────────────────
def class_histogram(model: RobloxModel) -> Dict[str, int]:
"""Возвращает {class_name: count} — сколько объектов каждого типа."""
out: Dict[str, int] = {}
for inst in model.instances:
out[inst.class_name] = out.get(inst.class_name, 0) + 1
return out
def summarize(model: RobloxModel, top_classes: int = 30) -> str:
"""Печатает человекочитаемый отчёт о модели."""
lines = []
lines.append(f"=== Roblox model ===")
lines.append(f" version: {model.version}")
lines.append(f" class count: {model.class_count}")
lines.append(f" instance count: {model.instance_count}")
lines.append(f" shared strings: {len(model.shared_strings)}")
lines.append(f" warnings: {len(model.warnings)}")
if model.meta:
lines.append(" meta:")
for k, v in list(model.meta.items())[:5]:
lines.append(f" {k}: {v}")
lines.append("")
lines.append(f"=== Top {top_classes} classes ===")
hist = class_histogram(model)
for cls, n in sorted(hist.items(), key=lambda x: -x[1])[:top_classes]:
lines.append(f" {n:>5d} {cls}")
lines.append("")
if model.warnings:
lines.append(f"=== Warnings (first 20) ===")
for w in model.warnings[:20]:
lines.append(f" ! {w}")
return "\n".join(lines)
if __name__ == '__main__':
import sys
path = sys.argv[1] if len(sys.argv) > 1 else '/opt/rbxl-importer/reference_files/easy_obby.rbxl'
with open(path, 'rb') as f:
blob = f.read()
print(f"file size: {len(blob)} bytes\n")
model = parse(blob)
print(summarize(model, top_classes=40))
# Дополнительная статистика: что внутри у Part
print("\n=== Sample Part properties ===")
parts = [i for i in model.instances if i.class_name == 'Part']
if parts:
p = parts[0]
print(f" Sample Part (referent={p.referent}):")
for k, v in p.properties.items():
sv = str(v)
if len(sv) > 80:
sv = sv[:77] + '...'
print(f" {k}: {sv}")
print(f"\n Total Parts: {len(parts)}")

View File

@ -0,0 +1,125 @@
"""
rbxl_parser_v0.py голый парсер Roblox Binary Level (.rbxl) v0.
Эта первая итерация только структура файла:
- проверка signature (<roblox!\x89\xff\r\n\x1a\n)
- чтение header (version, class count, instance count)
- чтение chunks: name (4 байт), compressed_len, uncompressed_len, reserved, payload
- LZ4-декомпрессия payload
- выдача списка chunks с распакованным содержимым
Здесь НЕ ДЕЛАЕМ декодирование INST/PROP per-type только видим что есть в файле.
Это нужно для первого smoke-test'а на твоей Easy Obby.
Полная спецификация: https://dom.rojo.space/binary
"""
import struct
import io
import lz4.block
from dataclasses import dataclass, field
from typing import List, Optional
RBXL_SIGNATURE = b'<roblox!\x89\xff\r\n\x1a\n'
@dataclass
class RbxlChunk:
name: str # 'META', 'SSTR', 'INST', 'PROP', 'PRNT', 'SIGN', 'END '
compressed_len: int
uncompressed_len: int
payload: bytes # уже декомпрессированный
@dataclass
class RbxlHeader:
version: int # обычно 0
class_count: int # сколько разных классов (Part, Script, ...)
instance_count: int # сколько объектов всего
@dataclass
class RbxlFile:
header: RbxlHeader
chunks: List[RbxlChunk] = field(default_factory=list)
class RbxlParseError(Exception):
pass
def parse(blob: bytes) -> RbxlFile:
"""Парсит .rbxl байты в RbxlFile. Бросает RbxlParseError на ошибке."""
if not blob.startswith(RBXL_SIGNATURE):
raise RbxlParseError(
f"signature mismatch: got {blob[:14].hex()}, "
f"expected {RBXL_SIGNATURE.hex()}"
)
stream = io.BytesIO(blob[len(RBXL_SIGNATURE):])
# Header: version (uint16), class_count (uint32), instance_count (uint32), reserved (8 bytes)
version, class_count, instance_count = struct.unpack('<HII', stream.read(10))
stream.read(8) # reserved
header = RbxlHeader(version, class_count, instance_count)
chunks = []
while True:
# Chunk header: name (4 bytes), compressed_len (uint32), uncompressed_len (uint32), reserved (uint32)
header_bytes = stream.read(16)
if len(header_bytes) < 16:
break
name_raw, compressed_len, uncompressed_len, _ = struct.unpack('<4sIII', header_bytes)
name = name_raw.decode('ascii', errors='replace').rstrip('\x00').rstrip()
# Payload
payload_compressed = stream.read(compressed_len if compressed_len > 0 else uncompressed_len)
if compressed_len == 0:
# Не сжато
payload = payload_compressed
else:
# LZ4 raw block decompress
try:
payload = lz4.block.decompress(payload_compressed, uncompressed_size=uncompressed_len)
except Exception as e:
raise RbxlParseError(
f"LZ4 decompress failed for chunk {name!r}: {e}"
)
chunks.append(RbxlChunk(name, compressed_len, uncompressed_len, payload))
if name.startswith('END'):
break
return RbxlFile(header=header, chunks=chunks)
def summarize(rbxl: RbxlFile) -> str:
"""Печатает компактный отчёт о структуре файла для smoke-test."""
lines = []
lines.append(f"=== rbxl file ===")
lines.append(f" version: {rbxl.header.version}")
lines.append(f" classes: {rbxl.header.class_count}")
lines.append(f" instances: {rbxl.header.instance_count}")
lines.append(f" chunks: {len(rbxl.chunks)}")
lines.append("")
lines.append(f"=== chunks ===")
for c in rbxl.chunks:
ratio = (c.compressed_len / c.uncompressed_len * 100) if c.uncompressed_len > 0 else 0
lines.append(
f" {c.name:6s} compressed={c.compressed_len:>8d} uncompressed={c.uncompressed_len:>10d} "
f"({ratio:5.1f}%) first 16 bytes: {c.payload[:16].hex()}"
)
return "\n".join(lines)
if __name__ == '__main__':
import sys
path = sys.argv[1] if len(sys.argv) > 1 else '/opt/rbxl-importer/reference_files/easy_obby.rbxl'
with open(path, 'rb') as f:
blob = f.read()
print(f"file size: {len(blob)} bytes")
rbxl = parse(blob)
print(summarize(rbxl))

View File

@ -0,0 +1,605 @@
"""
rbxl_types.py определения типов значений Roblox и парсинг PROP-chunks.
Roblox-формат хранит свойства per-class: для каждого класса свой набор
PROP chunks (по одному на каждое свойство). Внутри PROP лежат значения
этого свойства для ВСЕХ инстансов данного класса разом.
Schema PROP chunk:
class_id (uint32) индекс INST chunk'а (1:1 с порядком)
prop_name (string) например "Color", "Size", "CFrame"
type_id (uint8) тип значения (см. PROP_TYPE_* константы)
values... (массив длиной N где N = instance_count для класса class_id)
Полная спецификация PROP типов: https://dom.rojo.space/binary#type-id
Эта реализация голый struct без логики приведения к Rublox-формату.
Конвертация в твою сцену в rbxl_converter.py.
"""
from dataclasses import dataclass, field
from typing import List, Optional, Union, Any
import struct
from rbxl_binreader import (
BinReader,
deinterleave,
zigzag_decode_int32,
zigzag_decode_int64,
roblox_float_decode,
read_interleaved_int32_array,
read_interleaved_uint32_array,
read_interleaved_int64_array,
read_interleaved_float_array,
read_referent_array,
)
# ──────────────────────────────────────────────────────────────────────
# Тип-ID коды (используется в первом байте PROP chunk после prop_name).
# ──────────────────────────────────────────────────────────────────────
PROP_STRING = 0x01
PROP_BOOL = 0x02
PROP_INT32 = 0x03
PROP_FLOAT = 0x04
PROP_DOUBLE = 0x05
PROP_UDIM = 0x06
PROP_UDIM2 = 0x07
PROP_RAY = 0x08
PROP_FACES = 0x09
PROP_AXES = 0x0A
PROP_BRICKCOLOR = 0x0B
PROP_COLOR3 = 0x0C
PROP_VECTOR2 = 0x0D
PROP_VECTOR3 = 0x0E
# 0x0F не используется
PROP_CFRAME = 0x10
PROP_QUATERNION = 0x11 # есть в спеке, но обычно cf хранится в CFrame
PROP_ENUM = 0x12
PROP_REFERENT = 0x13
PROP_VECTOR3INT16 = 0x14
PROP_NUMBERSEQUENCE = 0x15
PROP_COLORSEQUENCE = 0x16
PROP_NUMBERRANGE = 0x17
PROP_RECT = 0x18
PROP_PHYSICALPROPS = 0x19
PROP_COLOR3UINT8 = 0x1A
PROP_INT64 = 0x1B
PROP_SHAREDSTRING = 0x1C
PROP_BYTECODE = 0x1D
PROP_OPTIONALCFRAME = 0x1E
PROP_UNIQUEID = 0x1F
PROP_FONT = 0x20
# ──────────────────────────────────────────────────────────────────────
# Дата-классы для сложных типов (Vector3, CFrame, и т.д.)
# ──────────────────────────────────────────────────────────────────────
@dataclass
class Vector3:
x: float
y: float
z: float
@dataclass
class Vector2:
x: float
y: float
@dataclass
class Color3:
r: float
g: float
b: float
def to_hex(self) -> str:
return '#{:02x}{:02x}{:02x}'.format(
max(0, min(255, int(self.r * 255))),
max(0, min(255, int(self.g * 255))),
max(0, min(255, int(self.b * 255))),
)
@dataclass
class CFrame:
"""4x3 матрица Roblox: 3 позиции + 9 значений матрицы ротации (3x3, row-major).
Хранится как (px, py, pz, r00, r01, r02, r10, r11, r12, r20, r21, r22).
"""
position: Vector3
matrix: tuple # (r00, r01, r02, r10, r11, r12, r20, r21, r22)
def to_euler_xyz(self) -> tuple:
"""Конверт 3x3 rotation matrix в Euler XYZ (radians).
Использует стандартную intrinsic XYZ rotation extraction:
Rx = atan2(r21, r22)
Ry = atan2(-r20, sqrt(r21² + r22²))
Rz = atan2(r10, r00)
"""
import math
r00, r01, r02, r10, r11, r12, r20, r21, r22 = self.matrix
rx = math.atan2(r21, r22)
ry = math.atan2(-r20, math.sqrt(r21*r21 + r22*r22))
rz = math.atan2(r10, r00)
return (rx, ry, rz)
@dataclass
class UDim:
scale: float
offset: int
@dataclass
class UDim2:
x: UDim
y: UDim
@dataclass
class Ray:
origin: Vector3
direction: Vector3
@dataclass
class Rect:
min: Vector2
max: Vector2
@dataclass
class PhysicalProperties:
custom: bool
density: float = 0.0
friction: float = 0.0
elasticity: float = 0.0
friction_weight: float = 0.0
elasticity_weight: float = 0.0
@dataclass
class BrickColor:
"""Цвет из палитры BrickColor (старый формат Roblox).
Хранится как int32 код цвета из BrickColor.palette().
Для нас можно мапить в Color3 по таблице.
"""
code: int
@dataclass
class Vector3int16:
x: int
y: int
z: int
@dataclass
class NumberSequenceKeypoint:
time: float
value: float
envelope: float
@dataclass
class NumberSequence:
keypoints: List[NumberSequenceKeypoint]
@dataclass
class ColorSequenceKeypoint:
time: float
value: Color3
envelope: float = 0.0 # игнорируется Roblox для ColorSequence
@dataclass
class ColorSequence:
keypoints: List[ColorSequenceKeypoint]
@dataclass
class NumberRange:
min: float
max: float
@dataclass
class Faces:
"""6 bool флагов: Top, Bottom, Left, Right, Front, Back."""
flags: int # bitmask
@dataclass
class Axes:
"""3 bool флага."""
flags: int
@dataclass
class SharedStringRef:
"""Ссылка на запись в SSTR chunk (по индексу)."""
index: int
@dataclass
class EnumValue:
value: int
@dataclass
class UniqueId:
"""128-bit ID (uuid-like)."""
raw: bytes
@dataclass
class Font:
family: str # font family asset id or name
weight: int
style: int
cached_face_id: str
@dataclass
class OptionalCFrame:
"""CFrame который может быть null."""
value: Optional[CFrame]
# ──────────────────────────────────────────────────────────────────────
# Главный декодер PROP chunk
# ──────────────────────────────────────────────────────────────────────
@dataclass
class PropChunk:
"""Распарсенный PROP chunk: значения свойства для всех инстансов одного класса."""
class_id: int
prop_name: str
type_id: int
values: List[Any] # длина = count instances класса
def decode_prop_chunk(payload: bytes, instance_count_for_class: int, shared_strings: List[bytes]) -> PropChunk:
"""Декодирует PROP chunk.
Args:
payload: уже декомпрессированные байты chunk'а
instance_count_for_class: сколько инстансов у класса (определяется по INST)
shared_strings: список из SSTR chunk для PROP_SHAREDSTRING
Returns:
PropChunk со списком значений (длина = instance_count_for_class).
"""
r = BinReader(payload)
class_id = r.uint32()
prop_name = r.string()
type_id = r.uint8()
count = instance_count_for_class
if type_id == PROP_STRING:
# N строк, каждая uint32+bytes
values = [r.string() for _ in range(count)]
elif type_id == PROP_BOOL:
# N bool — НЕ interleaved, просто N байт
values = [r.bool() for _ in range(count)]
elif type_id == PROP_INT32:
values = read_interleaved_int32_array(r, count)
elif type_id == PROP_FLOAT:
values = read_interleaved_float_array(r, count)
elif type_id == PROP_DOUBLE:
# 8 байт little-endian, БЕЗ interleave
values = [r.float64() for _ in range(count)]
elif type_id == PROP_UDIM:
scales = read_interleaved_float_array(r, count)
offsets = read_interleaved_int32_array(r, count)
values = [UDim(s, o) for s, o in zip(scales, offsets)]
elif type_id == PROP_UDIM2:
xs_scale = read_interleaved_float_array(r, count)
ys_scale = read_interleaved_float_array(r, count)
xs_offset = read_interleaved_int32_array(r, count)
ys_offset = read_interleaved_int32_array(r, count)
values = [
UDim2(UDim(xs_scale[i], xs_offset[i]), UDim(ys_scale[i], ys_offset[i]))
for i in range(count)
]
elif type_id == PROP_RAY:
# 6 floats per value, БЕЗ interleave
values = []
for _ in range(count):
ox, oy, oz = r.float32(), r.float32(), r.float32()
dx, dy, dz = r.float32(), r.float32(), r.float32()
values.append(Ray(Vector3(ox, oy, oz), Vector3(dx, dy, dz)))
elif type_id == PROP_FACES:
# 1 байт на каждый
values = [Faces(r.uint8()) for _ in range(count)]
elif type_id == PROP_AXES:
values = [Axes(r.uint8()) for _ in range(count)]
elif type_id == PROP_BRICKCOLOR:
codes = read_interleaved_int32_array(r, count)
values = [BrickColor(c) for c in codes]
elif type_id == PROP_COLOR3:
# 3 float channels, каждый interleaved отдельно
rs = read_interleaved_float_array(r, count)
gs = read_interleaved_float_array(r, count)
bs = read_interleaved_float_array(r, count)
values = [Color3(rs[i], gs[i], bs[i]) for i in range(count)]
elif type_id == PROP_VECTOR2:
xs = read_interleaved_float_array(r, count)
ys = read_interleaved_float_array(r, count)
values = [Vector2(xs[i], ys[i]) for i in range(count)]
elif type_id == PROP_VECTOR3:
xs = read_interleaved_float_array(r, count)
ys = read_interleaved_float_array(r, count)
zs = read_interleaved_float_array(r, count)
values = [Vector3(xs[i], ys[i], zs[i]) for i in range(count)]
elif type_id == PROP_CFRAME:
# Сначала идут rotation matrices, для каждого инстанса:
# 1 byte: orientation_id (0 = custom 9 float, иначе — шаблон из enum)
# если 0: следующие 36 байт = 9 float32 (row-major)
# ПОТОМ 3 interleaved float-массива (positions x, y, z)
rotations = []
for _ in range(count):
orientation_id = r.uint8()
if orientation_id == 0:
m = struct.unpack('<9f', r.read(36))
rotations.append(m)
else:
# Стандартные orientations (см. ROBLOX_CFRAME_ORIENTATIONS)
rotations.append(_cframe_orientation_to_matrix(orientation_id))
xs = read_interleaved_float_array(r, count)
ys = read_interleaved_float_array(r, count)
zs = read_interleaved_float_array(r, count)
values = [
CFrame(Vector3(xs[i], ys[i], zs[i]), rotations[i])
for i in range(count)
]
elif type_id == PROP_QUATERNION:
# Редко используется отдельно, обычно в составе CFrame
# Структура: 4 floats * count, БЕЗ interleave (на всякий)
values = []
for _ in range(count):
qx, qy, qz, qw = r.float32(), r.float32(), r.float32(), r.float32()
# Конверт quaternion → 3x3 matrix
values.append(CFrame(Vector3(0, 0, 0), _quat_to_matrix(qx, qy, qz, qw)))
elif type_id == PROP_ENUM:
# interleaved int32 (zigzag не применяется)
# на самом деле в спеке — это uint32 interleaved
vals = read_interleaved_uint32_array(r, count)
values = [EnumValue(v) for v in vals]
elif type_id == PROP_REFERENT:
values = read_referent_array(r, count)
elif type_id == PROP_VECTOR3INT16:
# 3 * int16 per value, БЕЗ interleave
values = []
for _ in range(count):
x = struct.unpack('<h', r.read(2))[0]
y = struct.unpack('<h', r.read(2))[0]
z = struct.unpack('<h', r.read(2))[0]
values.append(Vector3int16(x, y, z))
elif type_id == PROP_NUMBERSEQUENCE:
# Для каждого instance: uint32 keypoint_count, потом keypoint_count * 12 bytes
# (time, value, envelope) each float32
values = []
for _ in range(count):
kp_count = r.uint32()
keypoints = []
for _ in range(kp_count):
t = r.float32()
v = r.float32()
env = r.float32()
keypoints.append(NumberSequenceKeypoint(t, v, env))
values.append(NumberSequence(keypoints))
elif type_id == PROP_COLORSEQUENCE:
values = []
for _ in range(count):
kp_count = r.uint32()
keypoints = []
for _ in range(kp_count):
t = r.float32()
cr = r.float32()
cg = r.float32()
cb = r.float32()
# envelope (всегда 0 для ColorSequence, но 4 байта в файле)
_env = r.float32()
keypoints.append(ColorSequenceKeypoint(t, Color3(cr, cg, cb), 0.0))
values.append(ColorSequence(keypoints))
elif type_id == PROP_NUMBERRANGE:
# 2 float per value, БЕЗ interleave
values = []
for _ in range(count):
mn = r.float32()
mx = r.float32()
values.append(NumberRange(mn, mx))
elif type_id == PROP_RECT:
xmin = read_interleaved_float_array(r, count)
ymin = read_interleaved_float_array(r, count)
xmax = read_interleaved_float_array(r, count)
ymax = read_interleaved_float_array(r, count)
values = [
Rect(Vector2(xmin[i], ymin[i]), Vector2(xmax[i], ymax[i]))
for i in range(count)
]
elif type_id == PROP_PHYSICALPROPS:
values = []
for _ in range(count):
is_custom = r.bool()
if is_custom:
density = r.float32()
friction = r.float32()
elasticity = r.float32()
fw = r.float32()
ew = r.float32()
values.append(PhysicalProperties(True, density, friction, elasticity, fw, ew))
else:
values.append(PhysicalProperties(False))
elif type_id == PROP_COLOR3UINT8:
# 3 байта на цвет (uint8 каждый), интерливированные
rs = r.read(count)
gs = r.read(count)
bs = r.read(count)
values = [Color3(rs[i]/255.0, gs[i]/255.0, bs[i]/255.0) for i in range(count)]
elif type_id == PROP_INT64:
values = read_interleaved_int64_array(r, count)
elif type_id == PROP_SHAREDSTRING:
# interleaved uint32 индексы в SSTR
idxs = read_interleaved_uint32_array(r, count)
values = []
for i in idxs:
if i < len(shared_strings):
values.append(shared_strings[i])
else:
values.append(b'')
elif type_id == PROP_BYTECODE:
# Для каждого instance: uint32 length + bytes (compiled Lua bytecode)
values = []
for _ in range(count):
length = r.uint32()
values.append(r.read(length))
elif type_id == PROP_OPTIONALCFRAME:
# OptionalCFrame в rbx-dom spec:
# 1 byte: type_id (всегда 0x10 = CFrame)
# далее структура как у обычного PROP_CFRAME:
# N orientation_ids (по 1 байту каждый)
# N rotations (если orientation_id=0 → 36 байт row-major)
# interleaved x[N], y[N], z[N] float32
# ПОТОМ:
# 1 byte: type_id (0x02 = Bool — флаги has_value)
# N bytes: has_value flags
#
# Источник: https://dom.rojo.space/binary#optionalcframe
_inner_type_cframe = r.uint8() # 0x10
rotations = []
for _ in range(count):
orientation_id = r.uint8()
if orientation_id == 0:
m = struct.unpack('<9f', r.read(36))
rotations.append(m)
else:
rotations.append(_cframe_orientation_to_matrix(orientation_id))
xs = read_interleaved_float_array(r, count)
ys = read_interleaved_float_array(r, count)
zs = read_interleaved_float_array(r, count)
_inner_type_bool = r.uint8() # 0x02
has_values = [r.bool() for _ in range(count)]
values = []
for i in range(count):
if has_values[i]:
cf = CFrame(Vector3(xs[i], ys[i], zs[i]), rotations[i])
values.append(OptionalCFrame(cf))
else:
values.append(OptionalCFrame(None))
elif type_id == PROP_UNIQUEID:
# 16 байт на каждый, БЕЗ interleave (предположительно)
values = [UniqueId(r.read(16)) for _ in range(count)]
elif type_id == PROP_FONT:
# Для каждого: family (string), weight (uint16), style (uint8), cached_face_id (string)
values = []
for _ in range(count):
family = r.string()
weight = r.uint16()
style = r.uint8()
cached = r.string()
values.append(Font(family, weight, style, cached))
else:
# Неизвестный тип — пропускаем, ставим заглушки
values = [None] * count
return PropChunk(class_id=class_id, prop_name=prop_name, type_id=type_id, values=values)
def _cframe_orientation_to_matrix(orientation_id: int) -> tuple:
"""Roblox использует 24 стандартных orientations (вращения по 90°).
Источник: https://dom.rojo.space/binary#cframe-orientation-ids
Это полная таблица 24-х валидных orientation id для cube symmetries.
Возвращает (r00, r01, r02, r10, r11, r12, r20, r21, r22).
"""
# Таблица из rbx-dom. Каждое значение — пара (rx_axis, ry_axis) где
# значения в {0,1,2,3,4,5} = +X, -X, +Y, -Y, +Z, -Z
AXES = [
(1, 0, 0), (-1, 0, 0),
(0, 1, 0), (0, -1, 0),
(0, 0, 1), (0, 0, -1),
]
# orientation_id = 1..24 (1-based)
if not (1 <= orientation_id <= 24):
# Неверный id — возвращаем identity
return (1, 0, 0, 0, 1, 0, 0, 0, 1)
idx = orientation_id - 1
rx_idx = idx // 6
ry_idx = idx % 6
rx = AXES[rx_idx]
ry = AXES[ry_idx]
# rz = rx × ry (cross product)
rz = (
rx[1] * ry[2] - rx[2] * ry[1],
rx[2] * ry[0] - rx[0] * ry[2],
rx[0] * ry[1] - rx[1] * ry[0],
)
# Матрица: первые 3 — first row (R_xx, R_yx, R_zx)
# Сложновато; берём из rbx-dom convention: первые три — основа R*XAxis,
# затем R*YAxis, затем R*ZAxis. Расширяем в row-major form.
# На практике: orientation вектора (rx, ry, rz) — это **столбцы** матрицы.
r00, r10, r20 = rx
r01, r11, r21 = ry
r02, r12, r22 = rz
return (r00, r01, r02, r10, r11, r12, r20, r21, r22)
def _quat_to_matrix(qx, qy, qz, qw) -> tuple:
"""Кватернион в 3x3 row-major matrix."""
xx = qx * qx
yy = qy * qy
zz = qz * qz
xy = qx * qy
xz = qx * qz
yz = qy * qz
wx = qw * qx
wy = qw * qy
wz = qw * qz
return (
1 - 2*(yy + zz), 2*(xy - wz), 2*(xz + wy),
2*(xy + wz), 1 - 2*(xx + zz), 2*(yz - wx),
2*(xz - wy), 2*(yz + wx), 1 - 2*(xx + yy),
)

View File

@ -10,6 +10,8 @@ export const USER_addres = BASE + '/api-user';
export const ACHIVES_addres = BASE + '/api-achievs'; export const ACHIVES_addres = BASE + '/api-achievs';
export const COMMENTS_addres = BASE + '/api-comments'; export const COMMENTS_addres = BASE + '/api-comments';
export const STORYS_addres = BASE + '/api-storys'; export const STORYS_addres = BASE + '/api-storys';
// rbxl-importer: только для МИНа (тест-фича импорта .rbxl карт Roblox)
export const RBXL_addres = BASE + '/api-rbxl';
export const NOTICES_addres = BASE + '/api-notices'; export const NOTICES_addres = BASE + '/api-notices';
export const HELP_addres = BASE + '/api-help'; export const HELP_addres = BASE + '/api-help';
export const PYTHON_addres = BASE + '/api-python'; export const PYTHON_addres = BASE + '/api-python';

View File

@ -0,0 +1,66 @@
/**
* rbxlImporterApi.js клиент для rbxl-importer (Flask API на VM 130).
*
* Endpoints:
* POST /api-rbxl/import/rbxl/analyze анализ .rbxl, возвращает report + preview_hash
* POST /api-rbxl/import/rbxl/create скачать ассеты, создать проект
* GET /api-rbxl/health healthcheck
*/
import { RBXL_addres } from './API.js';
// На dev (localhost) backend ещё не интегрирован с user-service для JWT-парсинга.
// Используем dev override: явно передаём user_id. В prod NPM или user-service
// сами выставят X-User-Id из JWT.
const IS_DEV = typeof window !== 'undefined'
&& (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
function authHeaders() {
const token = localStorage.getItem('Authorization') || '';
const headers = { 'Authorization': token };
if (IS_DEV) {
// Достаём user_id из JWT для dev (без validation — backend на dev доверяет).
try {
const payload = JSON.parse(atob(token.split('.')[1] || ''));
if (payload && payload.id != null) headers['X-Auth-Override'] = String(payload.id);
} catch (e) {
// если JWT не парсится — забыли залогиниться, всё равно отправим что-то
}
}
return headers;
}
/**
* Анализ .rbxl. Принимает File (из <input type="file">) или Blob.
* Возвращает { preview_hash, report }.
*/
export async function analyzeRbxl(file) {
const fd = new FormData();
fd.append('file', file, file.name || 'upload.rbxl');
const resp = await fetch(`${RBXL_addres}/import/rbxl/analyze`, {
method: 'POST',
headers: authHeaders(),
body: fd,
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`analyze failed (${resp.status}): ${text}`);
}
return resp.json();
}
/**
* Создаёт проект из preview_hash. Возвращает { project_id, redirect, assets_downloaded, assets_failed }.
*/
export async function createRbxlProject(previewHash, title) {
const resp = await fetch(`${RBXL_addres}/import/rbxl/create`, {
method: 'POST',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ preview_hash: previewHash, title: title || '' }),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`create failed (${resp.status}): ${text}`);
}
return resp.json();
}

View File

@ -14,6 +14,7 @@ import useDeviceType from '../hooks/useDeviceType';
import KubikonDesktopOnlyStub from './KubikonDesktopOnlyStub'; import KubikonDesktopOnlyStub from './KubikonDesktopOnlyStub';
import PleeseReg from '../components/PleeseReg/PleeseReg'; import PleeseReg from '../components/PleeseReg/PleeseReg';
import Icon from '../editor/Icon'; import Icon from '../editor/Icon';
import RbxlImportModal from '../components/RbxlImportModal';
function getCurrentUserId() { function getCurrentUserId() {
try { try {
@ -131,6 +132,7 @@ const KubikonStudio = () => {
const [greetName, setGreetName] = useState(''); const [greetName, setGreetName] = useState('');
// Поиск по своим играм. searchOpen раскрыт ли инпут в шапке. // Поиск по своим играм. searchOpen раскрыт ли инпут в шапке.
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [rbxlImportOpen, setRbxlImportOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
// Гость МОЖЕТ просматривать студию видит шаблоны и обучение. // Гость МОЖЕТ просматривать студию видит шаблоны и обучение.
@ -388,8 +390,30 @@ const KubikonStudio = () => {
<span className={cl.navIcon}><Icon name="book-open" size={15} /></span> <span className={cl.navIcon}><Icon name="book-open" size={15} /></span>
<span>ВИКИ</span> <span>ВИКИ</span>
</button> </button>
{/* Импорт Roblox .rbxl — только для МИНа (user_id=1) */}
{getCurrentUserId() === 1 && (
<button
className={cl.navItem}
onClick={() => setRbxlImportOpen(true)}
title="Импортировать игру из Roblox (.rbxl файл) — тест-фича"
style={{ marginTop: 8, borderTop: '1px solid #333', paddingTop: 12 }}
>
<span className={cl.navIcon}>📦</span>
<span>Импорт Roblox</span>
</button>
)}
</nav> </nav>
<RbxlImportModal
open={rbxlImportOpen}
onClose={() => setRbxlImportOpen(false)}
currentUserId={getCurrentUserId()}
onCreated={(result) => {
// eslint-disable-next-line no-console
console.log('[rbxl-import] created:', result);
}}
/>
<div className={cl.sidebarFooter}> <div className={cl.sidebarFooter}>
<button <button
className={cl.docsBtn} className={cl.docsBtn}

View File

@ -0,0 +1,275 @@
/**
* RbxlImportModal модалка импорта .rbxl Roblox-карт в Rublox.
*
* Доступна ТОЛЬКО МИНу (user_id === 1) это тест-фича.
*
* Поток:
* 1. Юзер дропает или выбирает .rbxl файл.
* 2. Кликает «Анализировать» POST /import/rbxl/analyze.
* 3. Видит отчёт: число объектов, скриптов, ассетов, предупреждения.
* 4. Вводит название игры кликает «Создать игру».
* 5. POST /import/rbxl/create редирект на /edit/<new_id>.
*/
import React, { useState, useRef } from 'react';
import { analyzeRbxl, createRbxlProject } from '../api/rbxlImporterApi.js';
const ALLOWED_USER_ID = 1; // МИН
const MAX_SIZE = 50 * 1024 * 1024; // 50 MB
export default function RbxlImportModal({ open, onClose, currentUserId, onCreated }) {
const [file, setFile] = useState(null);
const [dragOver, setDragOver] = useState(false);
const [analyzing, setAnalyzing] = useState(false);
const [creating, setCreating] = useState(false);
const [report, setReport] = useState(null);
const [previewHash, setPreviewHash] = useState(null);
const [title, setTitle] = useState('');
const [error, setError] = useState(null);
const fileInputRef = useRef(null);
if (!open) return null;
if (currentUserId !== ALLOWED_USER_ID) {
return (
<div style={overlayStyle} onClick={onClose}>
<div style={dialogStyle} onClick={(e) => e.stopPropagation()}>
<h2 style={{ marginTop: 0 }}>Импорт из Roblox</h2>
<p>Эта тест-функция доступна только администратору.</p>
<button style={btnStyle} onClick={onClose}>Закрыть</button>
</div>
</div>
);
}
const reset = () => {
setFile(null); setReport(null); setPreviewHash(null);
setTitle(''); setError(null); setAnalyzing(false); setCreating(false);
};
const handleClose = () => { reset(); onClose?.(); };
const handleFile = (f) => {
if (!f) return;
if (!f.name.toLowerCase().endsWith('.rbxl')) {
setError('Нужен файл .rbxl (Roblox Binary Level)');
return;
}
if (f.size > MAX_SIZE) {
setError(`Файл больше ${MAX_SIZE / 1024 / 1024} MB`);
return;
}
setFile(f);
setError(null);
setReport(null);
setPreviewHash(null);
};
const handleAnalyze = async () => {
if (!file) return;
setAnalyzing(true);
setError(null);
try {
const result = await analyzeRbxl(file);
setPreviewHash(result.preview_hash);
setReport(result.report);
// дефолтный title имя файла без .rbxl
const defTitle = (file.name || '').replace(/\.rbxl$/i, '');
setTitle(defTitle);
} catch (e) {
setError(e.message || String(e));
} finally {
setAnalyzing(false);
}
};
const handleCreate = async () => {
if (!previewHash) return;
setCreating(true);
setError(null);
try {
const result = await createRbxlProject(previewHash, title);
onCreated?.(result);
handleClose();
// редирект на редактор
if (result.redirect) window.location.href = result.redirect;
} catch (e) {
setError(e.message || String(e));
} finally {
setCreating(false);
}
};
return (
<div style={overlayStyle} onClick={handleClose}>
<div style={dialogStyle} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<h2 style={{ marginTop: 0 }}>Импорт игры из Roblox</h2>
<button style={closeBtnStyle} onClick={handleClose}></button>
</div>
<p style={{ color: '#888', fontSize: 13, marginTop: -8 }}>
Загрузи Roblox-карту в формате .rbxl. Геометрия и Lua-скрипты будут
сконвертированы в проект Rublox.
</p>
{!file && (
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
handleFile(e.dataTransfer.files?.[0]);
}}
onClick={() => fileInputRef.current?.click()}
style={{
...dropZoneStyle,
borderColor: dragOver ? '#4a9eff' : '#444',
background: dragOver ? '#1a2a3a' : '#1a1a1a',
}}
>
<div style={{ fontSize: 48, opacity: 0.5 }}>📦</div>
<div style={{ marginTop: 8 }}>
<strong>Перетащи .rbxl сюда</strong>
<div style={{ color: '#888', fontSize: 13, marginTop: 4 }}>
или кликни чтобы выбрать файл (макс {MAX_SIZE / 1024 / 1024} MB)
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept=".rbxl"
style={{ display: 'none' }}
onChange={(e) => handleFile(e.target.files?.[0])}
/>
</div>
)}
{file && !report && (
<div style={panelStyle}>
<div><strong>{file.name}</strong> ({(file.size / 1024).toFixed(1)} KB)</div>
<div style={{ marginTop: 16 }}>
<button style={btnStyle} onClick={handleAnalyze} disabled={analyzing}>
{analyzing ? 'Анализирую…' : 'Анализировать'}
</button>
<button style={{ ...btnStyle, marginLeft: 8, background: '#444' }} onClick={reset}>
Выбрать другой файл
</button>
</div>
</div>
)}
{report && (
<div style={panelStyle}>
<h3 style={{ marginTop: 0 }}>Отчёт</h3>
<table style={tableStyle}>
<tbody>
<tr><td>Файл:</td><td><strong>{report.filename}</strong></td></tr>
<tr><td>Размер:</td><td>{(report.size_bytes / 1024).toFixed(1)} KB</td></tr>
<tr><td>Объектов:</td><td>{report.instance_count}</td></tr>
<tr><td>Классов:</td><td>{report.class_count}</td></tr>
<tr><td>Создано Part'ов:</td><td><strong>{report.primitives_created}</strong></td></tr>
<tr><td>GLB-моделей:</td><td>{report.glb_models_created}</td></tr>
<tr><td>Lua-скриптов:</td><td>{report.scripts_total}</td></tr>
<tr><td>Ассетов для скачки:</td><td>{report.assets_to_download}</td></tr>
</tbody>
</table>
{report.top_classes?.length > 0 && (
<details style={{ marginTop: 12 }}>
<summary style={{ cursor: 'pointer' }}>Что внутри (топ-25 классов)</summary>
<table style={tableStyle}>
<tbody>
{report.top_classes.slice(0, 25).map((c, i) => (
<tr key={i}><td>{c.class}</td><td>{c.count}</td></tr>
))}
</tbody>
</table>
</details>
)}
{report.warnings?.length > 0 && (
<details style={{ marginTop: 12 }}>
<summary style={{ cursor: 'pointer', color: '#f8a' }}>
Предупреждения ({report.warnings.length})
</summary>
<ul style={{ fontSize: 13, color: '#aaa' }}>
{report.warnings.slice(0, 30).map((w, i) => <li key={i}>{w}</li>)}
</ul>
</details>
)}
<div style={{ marginTop: 16, padding: 12, background: '#2a2a2a', borderRadius: 6 }}>
<div style={{ fontSize: 13, color: '#fa8' }}>
Загружая, ты подтверждаешь право использовать содержимое этой карты.
Не загружай чужие карты без разрешения автора.
</div>
</div>
<div style={{ marginTop: 16 }}>
<label style={{ display: 'block', marginBottom: 6 }}>Название игры:</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
style={inputStyle}
placeholder="Например: Моя обби-карта"
/>
</div>
<div style={{ marginTop: 16 }}>
<button
style={{ ...btnStyle, background: '#3a8' }}
onClick={handleCreate}
disabled={creating || !title.trim()}
>
{creating ? 'Создаю…' : '✨ Создать игру'}
</button>
<button style={{ ...btnStyle, marginLeft: 8, background: '#444' }} onClick={reset}>
Начать заново
</button>
</div>
</div>
)}
{error && (
<div style={{ marginTop: 12, padding: 12, background: '#3a1a1a', color: '#fa8', borderRadius: 6 }}>
{error}
</div>
)}
</div>
</div>
);
}
const overlayStyle = {
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 9000,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
};
const dialogStyle = {
background: '#1f1f1f', color: '#eee', borderRadius: 10, padding: 20,
maxWidth: 640, width: '100%', maxHeight: '90vh', overflowY: 'auto',
boxShadow: '0 10px 40px rgba(0,0,0,0.5)',
};
const dropZoneStyle = {
border: '2px dashed #444', borderRadius: 8, padding: 40, textAlign: 'center',
cursor: 'pointer', transition: 'all 0.2s',
};
const panelStyle = {
background: '#262626', borderRadius: 8, padding: 16, marginTop: 12,
};
const btnStyle = {
background: '#4a8', color: '#fff', border: 'none', padding: '10px 20px',
borderRadius: 6, fontSize: 14, cursor: 'pointer',
};
const closeBtnStyle = {
background: 'transparent', border: 'none', color: '#888', fontSize: 20,
cursor: 'pointer', padding: 4,
};
const tableStyle = {
width: '100%', fontSize: 13, marginTop: 8,
};
const inputStyle = {
width: '100%', padding: 8, background: '#1a1a1a', color: '#eee',
border: '1px solid #444', borderRadius: 4, fontSize: 14,
};

View File

@ -1,4 +1,4 @@
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo, useEffect, useRef } from 'react';
import { getBlockType } from './engine/BlockTypes'; import { getBlockType } from './engine/BlockTypes';
import { getModelType } from './engine/ModelTypes'; import { getModelType } from './engine/ModelTypes';
import { getPrimitiveType } from './engine/PrimitiveTypes'; import { getPrimitiveType } from './engine/PrimitiveTypes';
@ -37,7 +37,7 @@ const renderRowIcon = (val) => {
const ItemRow = ({ const ItemRow = ({
icon, label, title, depth = 0, selected, plusItems, icon, label, title, depth = 0, selected, plusItems,
onClick, onDoubleClick, onContextMenu, onDragStart, draggable, onClick, onDoubleClick, onContextMenu, onDragStart, draggable,
extraStyle, extraStyle, selId,
}) => { }) => {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const rowRef = React.useRef(null); const rowRef = React.useRef(null);
@ -80,6 +80,7 @@ const ItemRow = ({
onMouseEnter={() => setHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setHovered(false)}
title={title || label} title={title || label}
data-sel-id={selId}
> >
<span style={{ width: 18, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{renderRowIcon(icon)}</span> <span style={{ width: 18, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{renderRowIcon(icon)}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>{label}</span> <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>{label}</span>
@ -279,6 +280,53 @@ const HierarchyPanel = ({
// { kind: 'model'|'primitive'|'script', refKey: string, value: string } | null // { kind: 'model'|'primitive'|'script', refKey: string, value: string } | null
const [renaming, setRenaming] = useState(null); const [renaming, setRenaming] = useState(null);
// Авто-скролл к выбранному элементу: когда юзер выделяет объект в 3D-сцене
// (или приходит выделение извне) раскрываем родительские папки и
// прокручиваем иерархию к нему.
const hierarchyRootRef = useRef(null);
useEffect(() => {
if (!selection) return;
let selId = null;
if (selection.type === 'primitive') selId = `primitive:${selection.id}`;
else if (selection.type === 'model') selId = `model:${selection.instanceId}`;
else if (selection.type === 'block') selId = `block:${selection.gridX},${selection.gridY},${selection.gridZ}`;
if (!selId) return;
// 1) Раскрываем ВСЁ что может скрывать выбранный объект:
// - все папки (folders) - вдруг примитив сидит в Folder
// - rootPrimsOpen - группа "Примитивы" в корне
// - sceneOpen корень сцены
if (folders && folders.length > 0) {
setOpenFolders(prev => {
const next = new Set(prev);
let changed = false;
for (const f of folders) {
if (!next.has(f.id)) { next.add(f.id); changed = true; }
}
return changed ? next : prev;
});
}
if (!workspaceOpen) setWorkspaceOpen(true);
if (selection.type === 'primitive' && !rootPrimsOpen) setRootPrimsOpen(true);
if (selection.type === 'block' && !rootBlocksOpen) setRootBlocksOpen(true);
if (selection.type === 'model' && !rootModelsOpen) setRootModelsOpen(true);
// 2) Скролл через 2 кадра (даём React перерендерить после раскрытия)
const tick = () => {
const root = hierarchyRootRef.current;
if (!root) return;
const el = root.querySelector(`[data-sel-id="${CSS.escape(selId)}"]`);
if (el && typeof el.scrollIntoView === 'function') {
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
};
const raf1 = requestAnimationFrame(() => {
const raf2 = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf2);
});
return () => cancelAnimationFrame(raf1);
}, [selection?.type, selection?.id, selection?.instanceId, selection?.gridX, selection?.gridY, selection?.gridZ, folders?.length, workspaceOpen, rootPrimsOpen, rootBlocksOpen, rootModelsOpen, openFolders]);
const startRename = (kind, refKey, currentValue) => { const startRename = (kind, refKey, currentValue) => {
setRenaming({ kind, refKey, value: currentValue || '' }); setRenaming({ kind, refKey, value: currentValue || '' });
}; };
@ -615,6 +663,7 @@ const HierarchyPanel = ({
title={`${type?.name} (${b.gridX}, ${b.gridY}, ${b.gridZ})`} title={`${type?.name} (${b.gridX}, ${b.gridY}, ${b.gridZ})`}
depth={depth} depth={depth}
selected={isBlockSelected(b)} selected={isBlockSelected(b)}
selId={`block:${b.gridX},${b.gridY},${b.gridZ}`}
draggable draggable
onDragStart={(e) => handleDragStart(e, { kind: 'block', x: b.gridX, y: b.gridY, z: b.gridZ })} onDragStart={(e) => handleDragStart(e, { kind: 'block', x: b.gridX, y: b.gridY, z: b.gridZ })}
onClick={() => onSelectBlock(b.gridX, b.gridY, b.gridZ)} onClick={() => onSelectBlock(b.gridX, b.gridY, b.gridZ)}
@ -659,6 +708,7 @@ const HierarchyPanel = ({
title={`${displayName} (${m.x.toFixed(1)}, ${m.y.toFixed(1)}, ${m.z.toFixed(1)})`} title={`${displayName} (${m.x.toFixed(1)}, ${m.y.toFixed(1)}, ${m.z.toFixed(1)})`}
depth={depth} depth={depth}
selected={isModelSelected(m)} selected={isModelSelected(m)}
selId={`model:${m.instanceId}`}
draggable draggable
onDragStart={(e) => handleDragStart(e, { kind: 'model', id: m.instanceId })} onDragStart={(e) => handleDragStart(e, { kind: 'model', id: m.instanceId })}
onClick={() => onSelectModel(m.instanceId)} onClick={() => onSelectModel(m.instanceId)}
@ -740,6 +790,7 @@ const HierarchyPanel = ({
title={`${displayName} (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})`} title={`${displayName} (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})`}
depth={depth} depth={depth}
selected={isPrimitiveSelected(p)} selected={isPrimitiveSelected(p)}
selId={`primitive:${p.id}`}
draggable draggable
onDragStart={(e) => handleDragStart(e, { kind: 'primitive', id: p.id })} onDragStart={(e) => handleDragStart(e, { kind: 'primitive', id: p.id })}
onClick={() => onSelectPrimitive?.(p.id)} onClick={() => onSelectPrimitive?.(p.id)}
@ -775,7 +826,7 @@ const HierarchyPanel = ({
const rootPrims = primitivesByFolder.get(null) || []; const rootPrims = primitivesByFolder.get(null) || [];
return ( return (
<div className={cl.hierarchy} onClick={closeContext}> <div className={cl.hierarchy} onClick={closeContext} ref={hierarchyRootRef}>
<div className={cl.root} <div className={cl.root}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={handleDropOnRoot}> onDrop={handleDropOnRoot}>

View File

@ -19,6 +19,7 @@ import { ScriptSandbox } from './ScriptSandbox';
import { STORYS_addres } from '../../api/API'; import { STORYS_addres } from '../../api/API';
import { PhysicsWorld } from './PhysicsWorld'; import { PhysicsWorld } from './PhysicsWorld';
import { LabelManager } from './LabelManager'; import { LabelManager } from './LabelManager';
import { startRobloxLuaShared, handleLuaCommand, unpackRobloxLuaCode } from './rbxl-lua-integration.js';
export class GameRuntime { export class GameRuntime {
constructor(scene3d) { constructor(scene3d) {
@ -112,7 +113,15 @@ export class GameRuntime {
// (баг «стрелка-указатель не переключается на след. цель»). // (баг «стрелка-указатель не переключается на след. цель»).
let initialScene = null; let initialScene = null;
try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; } try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; }
// Roblox-Lua скрипты собираем для single-VM режима: один shared Worker
// на все скрипты, одна wasmoon-VM. Снимает WASM OOM лимит.
const rbxlBatch = [];
const primitives = this.projectData?.scene?.primitives || this.scene3d?.primitiveManager?.getAllData?.() || [];
for (const s of scripts) { for (const s of scripts) {
if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) {
rbxlBatch.push(s);
continue;
}
if (!s || typeof s.code !== 'string' || !s.code.trim()) { if (!s || typeof s.code !== 'string' || !s.code.trim()) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn('[GameRuntime] skipping invalid script entry', s); console.warn('[GameRuntime] skipping invalid script entry', s);
@ -142,7 +151,26 @@ export class GameRuntime {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('[GameRuntime] sandbox started for script id=', s.id); console.log('[GameRuntime] sandbox started for script id=', s.id);
} }
this._log('info', `Запущено скриптов: ${this.sandboxes.length}`); // Single-VM запуск всех Roblox-Lua скриптов одним shared Worker'ом.
let rbxlCount = 0;
if (rbxlBatch.length > 0) {
// GUI-дерево из projectData для pre-population
const guiElements = this.projectData?.scene?.gui || [];
const result = startRobloxLuaShared(rbxlBatch, {
primitives,
guiElements,
onCommand: (cmd, payload) => handleLuaCommand(null, cmd, payload, this),
});
if (result && result.sandbox) {
this.sandboxes.push(result.sandbox);
this._rbxlSharedSandbox = result.sandbox;
rbxlCount = result.count;
}
}
this._log('info', `Запущено скриптов: ${this.sandboxes.length - (this._rbxlSharedSandbox ? 1 : 0)}`);
if (rbxlCount > 0) {
this._log('info', `Запущено Roblox-Lua скриптов (single-VM): ${rbxlCount}`);
}
// Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange' // Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange'
// во все sandbox'ы. Не перезаписываем существующий обработчик — // во все sandbox'ы. Не перезаписываем существующий обработчик —
// оборачиваем его (старый колбэк UI должен продолжать работать). // оборачиваем его (старый колбэк UI должен продолжать работать).
@ -464,6 +492,7 @@ export class GameRuntime {
this._physicsWorld = null; this._physicsWorld = null;
} }
this.sandboxes = []; this.sandboxes = [];
this._rbxlSharedSandbox = null;
this._isRunning = false; this._isRunning = false;
this._soloScriptId = null; this._soloScriptId = null;
this._tweens = []; this._tweens = [];

View File

@ -638,10 +638,10 @@ export class PlayerController {
const json = await resp.json(); const json = await resp.json();
this._skinManifest = json.skins || []; this._skinManifest = json.skins || [];
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console
console.warn('[PlayerController] skins_manifest load failed:', e); console.warn('[PlayerController] skins_manifest load failed:', e);
this._skinManifest = []; this._skinManifest = [];
} }
this._skinManifestBaseUrl = '/kubikon-assets';
return this._skinManifest; return this._skinManifest;
} }
@ -656,15 +656,11 @@ export class PlayerController {
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);
const baseUrl = this._skinManifestBaseUrl || '/kubikon-assets';
if (entry) { if (entry) {
// kind определяет систему анимации:
// 'r15' → R15-скелет (как раньше)
// 'non-humanoid-mesh' → single-mesh, процедурное покачивание
// 'non-humanoid-rigged' → свой скелет + встроенные AnimationGroup
// Отсутствие kind = 'r15' (обратная совместимость со старым манифестом).
const kind = entry.kind || 'r15'; const kind = entry.kind || 'r15';
return { return {
file: '/kubikon-assets/' + entry.file, file: baseUrl + '/' + entry.file,
isR15: kind === 'r15', isR15: kind === 'r15',
kind, kind,
overrides: entry.overrides || {}, overrides: entry.overrides || {},
@ -673,9 +669,8 @@ export class PlayerController {
rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0, rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0,
}; };
} }
// нет в манифесте — пробуем прямой путь (старые R15-скины)
return { return {
file: `/kubikon-assets/characters/${typeId}/body.glb`, file: `${baseUrl}/characters/${typeId}/body.glb`,
isR15: true, isR15: true,
kind: 'r15', kind: 'r15',
overrides: {}, overrides: {},

View File

@ -0,0 +1,164 @@
/**
* RobloxLuaSandbox main-side обёртка над одним RobloxLuaWorker.
*
* Использование (по аналогии с ScriptSandbox):
* const sb = new RobloxLuaSandbox(luaSource, targetPrimitiveId);
* sb.setOnCommand((cmd, payload) => ...);
* sb.setInitialScene({primitives: {...}});
* sb.start();
* sb.tick(dt, sceneSnap);
* sb.fireEvent('touched', {primId, otherPrimId});
* sb.stop();
*
* Команды от Worker:
* { cmd: 'boot' } Lua-VM запущена
* { cmd: 'ready' } top-level код выполнен
* { cmd: 'log', payload: { level, text } }
* { cmd: 'partSet', payload: { primId, prop, value } }
* { cmd: 'partVel', payload: { primId, vx, vy, vz } }
* { cmd: 'playerCmd', payload: { method, args } }
* { cmd: 'tweenStart', payload: { ... } }
* { cmd: 'broadcast', payload: { msg, data } }
* { cmd: 'spawn', payload: { template, props, parentId } }
*/
let _workerUrl = null;
function getWorkerUrl() {
if (_workerUrl) return _workerUrl;
// Vite worker syntax — лучше через ?worker импорт; но мы можем
// динамически генерировать URL для ScriptSandboxWorker-style.
// Здесь упрощённо: загружаем worker как module через Vite ?worker&inline.
// Это будет настроено при интеграции в GameRuntime.
return null;
}
export class RobloxLuaSandbox {
constructor(luaSource, targetPrimitiveId = null) {
this.luaSource = luaSource || '';
this.targetPrimitiveId = targetPrimitiveId;
this.worker = null;
this._onCommand = null;
this._booted = false;
this._ready = false;
this._stopped = false;
this._pendingTicks = [];
this._pendingEvents = [];
this._initialScene = null;
}
setOnCommand(cb) { this._onCommand = cb; }
setInitialScene(snap) { this._initialScene = snap; }
/**
* @param {Worker} worker экземпляр Worker'а (предоставляется снаружи,
* так как Vite требует new Worker(new URL(...)) syntax который надо
* прописать в месте импорта)
*/
start(worker) {
if (this.worker) return;
if (!worker) throw new Error('RobloxLuaSandbox.start(worker): worker is required');
this.worker = worker;
this.worker.onmessage = (e) => this._handle(e);
this.worker.onerror = (err) => {
this._emit('log', { level: 'error', text: `Worker error: ${err.message || err}` });
};
this.worker.postMessage({
cmd: 'init',
payload: {
code: this.luaSource,
target: this.targetPrimitiveId,
sceneSnap: this._initialScene || { primitives: {} },
},
});
}
/** Передать кадр (snap сцены + dt). */
tick(dt, sceneSnap) {
if (!this.worker) return;
if (!this._ready) {
this._pendingTicks.push({ dt, sceneSnap });
return;
}
try { this.worker.postMessage({ cmd: 'tick', payload: { dt, sceneSnap } }); } catch (e) {}
}
/** Передать событие. */
fireEvent(kind, args, signalId) {
if (!this.worker) return;
if (!this._ready) {
this._pendingEvents.push({ kind, args, signalId });
return;
}
try { this.worker.postMessage({ cmd: 'event', payload: { kind, args, signalId } }); } catch (e) {}
}
stop() {
this._stopped = true;
try { this.worker?.postMessage({ cmd: 'stop' }); } catch (e) {}
try { this.worker?.terminate(); } catch (e) {}
this.worker = null;
}
// ── Совместимость с интерфейсом ScriptSandbox (GameRuntime ожидает эти методы) ──
// Все no-op либо мапятся на fireEvent — наш Worker сам держит state сцены.
sendSceneSnapshot(_snap) { /* no-op: ничего не делает, наш Worker не использует snapshot напрямую */ }
sendGuiSnapshot(_snap) { /* no-op */ }
sendSkinsSnapshot(_snap) { /* no-op */ }
sendInventorySnapshot(_snap) { /* no-op */ }
sendTerrainHeightmap(_payload) { /* no-op */ }
sendGlobalEvent(kind, payload) {
// Глобальные события (input, hpChange, broadcast) маршрутизируем в наш fireEvent.
try { this.fireEvent(kind, [payload]); } catch (e) {}
}
sendBroadcast(msg, data) {
try { this.fireEvent('broadcast', [msg, data]); } catch (e) {}
}
sendOnTouchEvent(payload) {
try { this.fireEvent('touched', [payload]); } catch (e) {}
}
sendOnTickEvent(dt) {
try { this.tick(dt, null); } catch (e) {}
}
sendTweenDone(payload) {
try { this.fireEvent('tweenDone', [payload]); } catch (e) {}
}
sendSpawnResolved(payload) {
try { this.fireEvent('spawnResolved', [payload]); } catch (e) {}
}
setInitialSelfPosition(_p) { /* no-op */ }
setModules(_modules) { /* no-op: rbxl Lua не использует наш game.require */ }
get scriptId() { return this._scriptId; }
set scriptId(v) { this._scriptId = v; }
_handle(ev) {
if (this._stopped) return;
const { cmd, payload } = ev.data || {};
if (cmd === 'boot') {
this._booted = true;
return;
}
if (cmd === 'ready') {
this._ready = true;
// флушим накопленное
for (const t of this._pendingTicks) {
try { this.worker.postMessage({ cmd: 'tick', payload: t }); } catch (e) {}
}
this._pendingTicks = [];
for (const e of this._pendingEvents) {
try { this.worker.postMessage({ cmd: 'event', payload: e }); } catch (e) {}
}
this._pendingEvents = [];
this._emit('ready', null);
return;
}
this._emit(cmd, payload);
}
_emit(cmd, payload) {
if (this._onCommand) {
try { this._onCommand(cmd, payload); } catch (e) {}
}
}
}

View File

@ -0,0 +1,150 @@
/**
* RobloxLuaSharedSandbox main-side обёртка над одним shared Lua-worker'ом.
*
* v2 (после rewrite):
* - start(sceneSnap, guiTree, worker) init с GUI-деревом
* - addScriptsBatch(scripts) одной пачкой все скрипты регистрируются в VM
* - kickoff() запускает event loop, fire'ит PlayerAdded
* - tick(dt) каждый кадр
* - fireEvent(kind, payload) маршрутизирует в Worker.handleEvent
*
* GameRuntime пушит ОДИН экземпляр в this.sandboxes.
*/
export class RobloxLuaSharedSandbox {
constructor() {
this.worker = null;
this._onCommand = null;
this._booted = false;
this._scriptsLoaded = false;
this._stopped = false;
this._pendingTicks = [];
this._pendingEvents = [];
this._pendingScripts = null;
this._pendingKickoff = false;
this.scriptId = 'rbxl-shared';
}
setOnCommand(cb) { this._onCommand = cb; }
start(sceneSnap, guiTree, worker) {
if (this.worker) return;
this.worker = worker;
this.worker.onmessage = (e) => this._handle(e);
this.worker.onerror = (err) => {
this._emit('log', { level: 'error', text: `SharedWorker error: ${err.message || err}` });
};
this.worker.postMessage({ cmd: 'init', payload: { sceneSnap, guiTree } });
}
addScriptsBatch(scripts) {
if (!this._booted) { this._pendingScripts = scripts; return; }
try { this.worker.postMessage({ cmd: 'addScripts', payload: { scripts } }); } catch (e) {}
}
kickoff() {
if (!this._scriptsLoaded) { this._pendingKickoff = true; return; }
try { this.worker.postMessage({ cmd: 'start' }); } catch (e) {}
}
tick(dt) {
if (!this.worker) return;
if (!this._booted) { this._pendingTicks.push(dt); return; }
try { this.worker.postMessage({ cmd: 'tick', payload: { dt } }); } catch (e) {}
}
fireEvent(kind, payload) {
if (!this.worker) return;
const ev = { kind, ...(payload || {}) };
if (!this._booted) { this._pendingEvents.push(ev); return; }
try { this.worker.postMessage({ cmd: 'event', payload: ev }); } catch (e) {}
}
stop() {
this._stopped = true;
try { this.worker?.postMessage({ cmd: 'stop' }); } catch (e) {}
try { this.worker?.terminate(); } catch (e) {}
this.worker = null;
}
_handle(ev) {
if (this._stopped) return;
const { cmd, payload } = ev.data || {};
if (cmd === 'boot') {
this._booted = true;
// флушим pending scripts
if (this._pendingScripts) {
try { this.worker.postMessage({ cmd: 'addScripts', payload: { scripts: this._pendingScripts } }); } catch (e) {}
this._pendingScripts = null;
}
// ticks накопленные до boot
for (const dt of this._pendingTicks) {
try { this.worker.postMessage({ cmd: 'tick', payload: { dt } }); } catch (e) {}
}
this._pendingTicks = [];
return;
}
if (cmd === 'ready') {
this._scriptsLoaded = true;
this._emit('ready', payload);
if (this._pendingKickoff) {
try { this.worker.postMessage({ cmd: 'start' }); } catch (e) {}
this._pendingKickoff = false;
}
// флушим pending events
for (const e of this._pendingEvents) {
try { this.worker.postMessage({ cmd: 'event', payload: e }); } catch (er) {}
}
this._pendingEvents = [];
return;
}
this._emit(cmd, payload);
}
_emit(cmd, payload) {
if (this._onCommand) { try { this._onCommand(cmd, payload); } catch (e) {} }
}
// ── Совместимость с ScriptSandbox (GameRuntime ожидает эти методы) ──
sendSceneSnapshot(_snap) {}
sendGuiSnapshot(_snap) {}
sendSkinsSnapshot(_snap) {}
sendInventorySnapshot(_snap) {}
sendTerrainHeightmap(_payload) {}
sendGlobalEvent(payload) {
if (!payload || typeof payload !== 'object') return;
const type = payload.type;
// playerTouch: BabylonScene уже детектит касания → Touched на Part
if (type === 'playerTouch' && payload.target) {
const m = /^primitive:(\d+)$/.exec(String(payload.target));
if (m) { this.fireEvent('touched', { primId: +m[1], isPlayer: true }); return; }
}
// GUI click — Rublox GuiOverlay шлёт guiClick с id
if (type === 'guiClick' && (payload.id || payload.localId)) {
this.fireEvent('guiClick', { guiId: payload.id || payload.localId });
return;
}
// keyboard
if (type === 'keydown' || type === 'keyup') {
this.fireEvent(type, { key: payload.key });
return;
}
// hp/death
if (type === 'hpChange' || type === 'humanoidHealth') {
this.fireEvent('humanoidHealth', { health: payload.hp || payload.health || 0 });
return;
}
if (type === 'died' || type === 'humanoidDied') {
this.fireEvent('humanoidDied', {});
return;
}
// default: пробрасываем как kind=type
this.fireEvent(type || 'unknown', payload);
}
sendBroadcast(msg, data) { this.fireEvent('broadcast', { msg, data }); }
sendOnTouchEvent(payload) { this.fireEvent('touched', { primId: payload?.primId, isPlayer: true }); }
sendOnTickEvent(dt) { this.tick(dt); }
sendTweenDone(payload) { this.fireEvent('tweenDone', payload); }
sendSpawnResolved(payload) { this.fireEvent('spawnResolved', payload); }
setInitialSelfPosition(_p) {}
setModules(_modules) {}
}

View File

@ -0,0 +1,380 @@
/* eslint-disable no-restricted-globals */
/**
* RobloxLuaSharedWorker.js single-VM Lua-runtime для импортированных Roblox-скриптов.
*
* Архитектура v2 (после ITERATION 5-step rewrite):
*
* ФАЗА 1 (boot): создаём wasmoon-VM, регистрируем Roblox API без скриптов.
*
* ФАЗА 2 (populate): main шлёт snapshot сцены (primitives + GUI tree).
* Создаём workspace со всеми Part'ами и PlayerGui со всем GUI-деревом.
* На каждом TextButton MouseButton1Click сигнал, на каждом Part Touched.
*
* ФАЗА 3 (addScripts): main шлёт ВСЕ скрипты ОДНИМ батчем. Worker загружает
* их в Lua-VM как отдельные функции в pcall. Скрипты регистрируют свои
* Connect'ы (Touched, MouseButton1Click, Heartbeat, ...). top-level wait()
* yield'ится через coroutine управление возвращается в worker.
*
* ФАЗА 4 (run): main шлёт 'startEvents'. Worker запускает scheduler-tick
* и начинает обрабатывать события (touched/guiClick/heartbeat).
*
* IPC:
* <- init { sceneSnap, guiTree }
* <- addScripts { scripts: [{id, target, luaSource}] }
* <- start
* <- tick { dt }
* <- event { kind, payload }
* <- stop
* -> boot
* -> ready
* -> log/partSet/partVel/playerCmd/broadcast/guiUpdate
*/
import { LuaFactory } from 'wasmoon';
import { registerRobloxApi, RbxSignal } from './roblox-shim.js';
const state = {
factory: null,
lua: null,
sceneSnap: { primitives: {} },
guiTree: [],
isStopped: false,
initPromise: null,
eventsStarted: false,
pendingEvents: [],
scriptCount: 0,
coroutines: [], // активные ждущие корутины: { co, resumeAt }
nowSec: 0,
api: null, // результат registerRobloxApi: { workspace, game, part_by_id, gui_by_id, localPlayer, humanoid }
};
function send(cmd, payload) {
self.postMessage({ cmd, payload });
}
function log(level, text) {
send('log', { level, text });
}
const scheduler = {
now: () => state.nowSec,
schedule: (sec, fn) => {
state.coroutines.push({ resumeAt: state.nowSec + (sec || 0), fn });
},
spawn: (fn) => {
// spawn — fn запускается асинхронно (на следующем tick'е)
state.coroutines.push({ resumeAt: state.nowSec, fn });
},
};
self.addEventListener('message', async (ev) => {
const { cmd, payload } = ev.data || {};
try {
if (cmd === 'init') await handleInit(payload);
else if (cmd === 'addScripts') await handleAddScripts(payload);
else if (cmd === 'start') handleStart();
else if (cmd === 'tick') handleTick(payload);
else if (cmd === 'event') {
if (!state.eventsStarted) state.pendingEvents.push(payload);
else handleEvent(payload);
}
else if (cmd === 'stop') {
state.isStopped = true;
try { state.lua?.global?.close?.(); } catch (e) {}
}
} catch (err) {
log('error', `Worker error in ${cmd}: ${err && err.stack ? err.stack : err}`);
}
});
async function handleInit({ sceneSnap, guiTree }) {
if (state.initPromise) { await state.initPromise; return; }
state.initPromise = (async () => {
state.sceneSnap = sceneSnap || { primitives: {} };
state.guiTree = guiTree || [];
state.factory = new LuaFactory();
state.lua = await state.factory.createEngine({
injectObjects: true,
enableProxy: true,
traceAllocations: false,
});
state.api = registerRobloxApi(state.lua, {
getSceneSnap: () => state.sceneSnap,
getGuiTree: () => state.guiTree,
targetPrimitiveId: null,
send,
scheduler,
});
// Передаём part_by_id в Lua как table {id → Instance}
// ВНИМАНИЕ: lua_table принимает string keys, ключи кладём как строки.
try {
const m = state.api?.part_by_id;
if (m) {
const obj = {};
for (const [id, part] of m) obj[String(id)] = part;
state.lua.global.set('__rbxl_parts_by_id', obj);
}
} catch (e) {}
// null-stub builder: возвращает Instance-like объект который безопасно
// отвечает на .Parent, .Name, .WaitForChild и т.п. чтобы цепочки
// script.Parent.Parent.X не валили.
const makeNullStub = () => {
const stub = {
Name: 'NullStub',
ClassName: 'Nil',
Children: [],
__isNullStub: true,
};
// Parent — самоссылающийся nullStub
stub.Parent = stub;
stub.FindFirstChild = () => stub;
stub.FindFirstChildOfClass = () => stub;
stub.FindFirstAncestor = () => stub;
stub.FindFirstAncestorOfClass = () => stub;
stub.WaitForChild = () => stub;
stub.GetChildren = () => [];
stub.GetDescendants = () => [];
stub.IsA = () => false;
stub.Clone = () => makeNullStub();
stub.Destroy = () => {};
stub.GetService = () => stub;
// Сигналы — пустой connector
const nullSig = {
Connect: () => ({ Disconnect: () => {}, Connected: false }),
Wait: () => null,
Fire: () => {},
};
// Любой каpitalized property — сигнал-stub
return new Proxy(stub, {
get(t, k) {
if (k in t) return t[k];
if (typeof k === 'string' && /^[A-Z]/.test(k)) return nullSig;
return undefined;
},
set(t, k, v) { t[k] = v; return true; },
});
};
state.lua.global.set('__rbxl_make_null_stub', makeNullStub);
// ВАЖНО: создаём nullStub НА СТОРОНЕ LUA как настоящую table с
// metatable __index возвращающей сам stub. Это позволит цепочкам
// .Parent.X.Y:WaitForChild():Connect() корректно работать и обе
// нотации (. и :) сработают.
await state.lua.doString(`
__null_stub_mt = {}
function __make_null_stub()
local t = setmetatable({
Name = "Nil",
ClassName = "Nil",
__isNullStub = true,
Visible = false,
Enabled = false,
Value = 0,
Text = "",
}, __null_stub_mt)
return t
end
__null_stub_singleton = __make_null_stub()
-- nullSignal с обоими Connect/connect:
local function null_sig_method() return { Disconnect = function() end, disconnect = function() end, Connected = false } end
__null_signal = setmetatable({
Connect = function(self, fn) return { Disconnect = function() end, disconnect = function() end, Connected = false } end,
connect = function(self, fn) return { Disconnect = function() end, disconnect = function() end, Connected = false } end,
Wait = function() return nil end,
wait = function() return nil end,
Fire = function() end,
fire = function() end,
}, { __index = function() return function() return __null_stub_singleton end end })
-- Любой index nullStub'а возвращает либо null_signal (для уже известных
-- сигнальных имён) либо noop-функцию которая возвращает сам stub.
__null_stub_mt.__index = function(t, k)
-- известные сигнал-имена
local sig_names = {Touched=true,TouchEnded=true,Changed=true,Activated=true,
MouseButton1Click=true,MouseButton1Down=true,MouseButton1Up=true,
MouseEnter=true,MouseLeave=true,InputBegan=true,InputEnded=true,
PlayerAdded=true,PlayerRemoving=true,CharacterAdded=true,CharacterRemoving=true,
Heartbeat=true,Stepped=true,RenderStepped=true,Died=true,HealthChanged=true,
FocusLost=true,Focused=true,ChildAdded=true,ChildRemoved=true,
AncestryChanged=true,DescendantAdded=true,DescendantRemoving=true}
if sig_names[k] then return __null_signal end
-- любой метод функция которая возвращает stub (поддерживает оба синтаксиса)
return function(...) return __null_stub_singleton end
end
__null_stub_mt.__newindex = function(t, k, v) rawset(t, k, v) end
__null_stub_mt.__call = function(t, ...) return __null_stub_singleton end
-- Сделаем __null_stub_singleton.Parent = сам себя (lazy)
rawset(__null_stub_singleton, "Parent", __null_stub_singleton)
`);
// Заменяем __rbxl_make_null_stub на Lua-side функцию
await state.lua.doString(`
function __rbxl_make_null_stub() return __null_stub_singleton end
`);
// КРИТИЧНО: расширенные metatable для nil + function + number чтобы
// любые цепочки nil.x.y:method() и func.x не валили скрипты.
await state.lua.doString(`
if debug and debug.setmetatable then
local _stub_mt = {
__index = function(t, k) return __null_stub_singleton end,
__newindex = function(t, k, v) end,
__call = function(t, ...) return __null_stub_singleton end,
__add = function(a, b) return 0 end,
__sub = function(a, b) return 0 end,
__mul = function(a, b) return 0 end,
__div = function(a, b) return 0 end,
__mod = function(a, b) return 0 end,
__pow = function(a, b) return 0 end,
__unm = function() return 0 end,
__concat = function(a, b) return "" end,
__len = function() return 0 end,
__eq = function(a, b) return false end,
__lt = function(a, b) return false end,
__le = function(a, b) return false end,
__tostring = function() return "nil" end,
}
debug.setmetatable(nil, _stub_mt)
debug.setmetatable(function() end, _stub_mt)
-- НЕ ставим на number/string/boolean они должны работать нормально
end
`);
// helper: безопасный pcall с warn'ом при ошибке
await state.lua.doString(`
__rbxl_scripts = {}
function __rbxl_safe_run(id, fn)
local ok, err = pcall(fn)
if not ok then warn("[rbxl-lua " .. tostring(id) .. " err] " .. tostring(err)) end
end
-- Lookup Part по primitiveId. Используем __rbxl_parts_by_id из JS,
-- т.к. ipairs() на JS array не работает (0-indexed vs Lua 1-indexed).
function __rbxl_lookup_part(id)
if __rbxl_parts_by_id then
return __rbxl_parts_by_id[tostring(id)] or __rbxl_parts_by_id[id]
end
return nil
end
`);
send('boot', null);
})();
await state.initPromise;
}
async function handleAddScripts({ scripts }) {
if (!state.lua) { log('error', 'addScripts before init'); return; }
let ok = 0, fail = 0;
for (const s of scripts) {
const safeId = String(s.id).replace(/[^a-zA-Z0-9_]/g, '_');
const targetExpr = s.target != null
? `__rbxl_lookup_part(${JSON.stringify(s.target)}) or __rbxl_make_null_stub()`
: '__rbxl_make_null_stub()';
// Оборачиваем в pcall. script — локальный, не конфликтует между скриптами.
// script.Parent НИКОГДА не nil — даём nullStub чтобы цепочки
// script.Parent.Parent.X не валили.
const wrapped = `
do
local script = setmetatable({
Name = "Script_${safeId}",
Parent = ${targetExpr},
ClassName = "LocalScript",
}, { __index = function(t, k) return rawget(t, k) end })
__rbxl_safe_run("${safeId}", function()
${s.luaSource}
end)
end
`;
try {
await state.lua.doString(wrapped);
ok++;
} catch (e) {
fail++;
// ошибки парсинга/runtime, считаем но не валим всё
}
}
state.scriptCount = ok;
send('ready', { ok, fail });
}
function handleStart() {
state.eventsStarted = true;
// Эмитим Players.PlayerAdded + CharacterAdded чтобы скрипты которые
// делают game.Players.PlayerAdded:Connect(...) получили событие.
try {
const lp = state.api?.localPlayer;
const players = state.api?.services?.get('Players');
if (lp && players?.PlayerAdded?.Fire) players.PlayerAdded.Fire(lp);
if (lp?.CharacterAdded?.Fire && lp.Character) lp.CharacterAdded.Fire(lp.Character);
} catch (e) {}
// Флушим накопленные события
for (const e of state.pendingEvents) handleEvent(e);
state.pendingEvents = [];
}
function handleTick({ dt }) {
if (state.isStopped || !state.lua) return;
state.nowSec += dt || 0;
// Резолвим планированные корутины
if (state.coroutines.length > 0) {
const due = [];
const left = [];
for (const c of state.coroutines) {
if (c.resumeAt <= state.nowSec) due.push(c); else left.push(c);
}
state.coroutines = left;
for (const c of due) {
try { c.fn(); } catch (e) { log('warn', `coroutine err: ${e?.message || e}`); }
}
}
// RunService сигналы
try {
const rs = state.api?.services?.get('RunService');
if (rs?.Heartbeat?.Fire) rs.Heartbeat.Fire(dt);
if (rs?.Stepped?.Fire) rs.Stepped.Fire(state.nowSec, dt);
if (rs?.RenderStepped?.Fire) rs.RenderStepped.Fire(dt);
} catch (e) {}
}
function handleEvent(payload) {
if (state.isStopped || !state.lua || !state.api) return;
const { kind } = payload || {};
try {
if (kind === 'guiClick' || kind === 'guiActivated') {
const guiId = payload.guiId;
const inst = state.api.gui_by_id?.get(guiId);
if (inst) {
if (kind === 'guiActivated') inst.Activated?.Fire?.(1);
else inst.MouseButton1Click?.Fire?.();
}
} else if (kind === 'touched') {
const primId = payload.primId;
const part = state.api.part_by_id?.get(primId);
if (part?.Touched?.Fire) {
// hit = HumanoidRootPart
part.Touched.Fire(state.api.character?.HumanoidRootPart || part);
}
// также Humanoid.Touched на самом игроке
if (payload.isPlayer) {
state.api.humanoid?.Touched?.Fire?.(part);
}
} else if (kind === 'keydown' || kind === 'keyup') {
// UserInputService.InputBegan/Ended
const uis = state.api.services?.get('UserInputService') ||
(() => {
const s = new (state.lua.global.get('Instance')?.new ? Object : Object)();
return null;
})();
if (uis) {
if (kind === 'keydown') uis.InputBegan?.Fire?.({ KeyCode: { Name: payload.key } });
else uis.InputEnded?.Fire?.({ KeyCode: { Name: payload.key } });
}
} else if (kind === 'humanoidDied') {
state.api.humanoid?.Died?.Fire?.();
} else if (kind === 'humanoidHealth') {
const h = state.api.humanoid;
if (h) {
h.Health = payload.health;
h.HealthChanged?.Fire?.(payload.health);
}
}
} catch (e) {
log('warn', `event ${kind} err: ${e?.message || e}`);
}
}
self.__rbxlSharedState = state;

View File

@ -0,0 +1,180 @@
/* eslint-disable no-restricted-globals */
/**
* RobloxLuaWorker.js Web Worker, хостящий Lua 5.4 VM (wasmoon) для исполнения
* Roblox-Lua скриптов импортированных через rbxl-importer.
*
* Запускается из RobloxLuaSandbox.js (main thread).
*
* IPC (с main):
* <- init { code: string, target?: id, shim: 'full'|'mini', sceneSnap: object }
* <- tick { dt, sceneSnap } каждый кадр
* <- event { kind: 'touched'|'changed'|..., args } события сцены
* -> boot нет payload Worker запустился, Lua-VM ready
* -> ready нет payload top-level lua код исполнен
* -> log { level, text }
* -> partSet { primId, prop, value } изменение свойства Part'а
* -> partVel { primId, vx, vy, vz }
* -> playerCmd { method, args } методы game.player (teleport, damage, walkSpeed)
* -> tweenStart{ targetId, prop, from, to, durationSec, easing }
* -> broadcast { msg, data } RemoteEvent аналог
* -> spawn { template, props, parentId } Instance.new()
*
* Lua-runtime архитектура:
* - wasmoon = Lua 5.4 в WebAssembly, ~500KB, ~5x быстрее fengari.
* - Lua-VM глобалы: game, workspace, script, task, wait, print, warn, error.
* - Все Roblox-классы JS-объекты-прокси (см. roblox-shim.js, регистрируемые
* через factory.setProxy).
*
* Безопасность:
* - Worker изолирован от DOM.
* - Memory limit ~50 MB на VM (через wasmoon options).
* - На каждые N=10000 инструкций Lua hook возможность отменить (TODO).
*
* Воркер ДЕРЖИТ И ОБНОВЛЯЕТ snapshot сцены (зеркало того что в Babylon-сцене),
* чтобы Lua-код мог читать Position/Color без round-trip к main thread.
* Обновление от main: cmd='tick' с дельтой сцены.
*
* Это первый MVP-вариант. Полный shim API регистрируется в фазе 4.3-4.13.
*/
import { LuaFactory } from 'wasmoon';
import { registerRobloxApi } from './roblox-shim.js';
/**
* Worker-side state. Один Worker = один скрипт.
*/
const state = {
factory: null,
lua: null,
target: null, // id примитива к которому привязан script.Parent
sceneSnap: { primitives: {} },// зеркало
isStopped: false,
pendingEvents: [], // события до init
signals: new Map(), // signalId → [callbacks]
nextSignalId: 1,
};
/* ──────── IPC helpers ──────── */
function send(cmd, payload) {
self.postMessage({ cmd, payload });
}
function log(level, text) {
send('log', { level, text });
}
/* ──────── Worker entrypoint ──────── */
self.addEventListener('message', async (ev) => {
const { cmd, payload } = ev.data || {};
try {
if (cmd === 'init') {
await handleInit(payload);
} else if (cmd === 'tick') {
handleTick(payload);
} else if (cmd === 'event') {
handleEvent(payload);
} else if (cmd === 'stop') {
state.isStopped = true;
try { state.lua?.global?.close?.(); } catch (e) {}
}
} catch (err) {
log('error', `Worker error in ${cmd}: ${err && err.stack ? err.stack : err}`);
}
});
async function handleInit({ code, target, sceneSnap }) {
state.target = target;
state.sceneSnap = sceneSnap || { primitives: {} };
state.factory = new LuaFactory();
state.lua = await state.factory.createEngine({
injectObjects: true,
enableProxy: true,
traceAllocations: false,
});
// Регистрируем Roblox API.
registerRobloxApi(state.lua, {
getSceneSnap: () => state.sceneSnap,
targetPrimitiveId: state.target,
send,
registerSignal: (callback) => {
const id = state.nextSignalId++;
const list = state.signals.get(id) || [];
list.push(callback);
state.signals.set(id, list);
return id;
},
});
send('boot', null);
try {
// Оборачиваем в pcall + ловим errors. Roblox-карты часто делают
// game.Players.LocalPlayer:WaitForChild("PlayerGui") который у нас
// даёт null — top-level код падает на первой такой строке.
// pcall ловит и даёт сигналам/Touched зарегистрироваться там где смогли.
const wrapped = `
local _ok, _err = pcall(function()
${code}
end)
if not _ok then
warn("[rbxl-lua partial fail] " .. tostring(_err))
end
`;
await state.lua.doString(wrapped);
send('ready', null);
} catch (e) {
log('error', `Lua error: ${e && e.message ? e.message : e}`);
send('ready', null);
}
// После ready доставляем events которые накопились
for (const ev of state.pendingEvents) handleEvent(ev);
state.pendingEvents = [];
}
function handleTick({ dt, sceneSnap }) {
if (state.isStopped || !state.lua) return;
if (sceneSnap) state.sceneSnap = sceneSnap;
// Heartbeat — для всех подписанных
fireSignalByName('Heartbeat', [dt]);
// Stepped (старая API) — тоже даём
fireSignalByName('Stepped', [dt]);
// RenderStepped — отдельно (на клиенте между physics и render)
fireSignalByName('RenderStepped', [dt]);
}
function handleEvent({ kind, args, signalId }) {
if (!state.lua) {
state.pendingEvents.push({ kind, args, signalId });
return;
}
if (signalId != null) {
const list = state.signals.get(signalId) || [];
for (const cb of list) {
try { cb(...(args || [])); } catch (e) { log('error', `signal callback error: ${e}`); }
}
} else {
fireSignalByName(kind, args || []);
}
}
function fireSignalByName(name, args) {
// namedSignals регистрируются в roblox-shim как сильные строки
// (например 'Heartbeat'). Все callback'и под этим именем в signals.
// Без отдельной мапы — ищем линейно.
for (const [id, list] of state.signals.entries()) {
if (list.__name === name) {
for (const cb of list) {
try { cb(...args); } catch (e) { log('error', `${name} cb error: ${e}`); }
}
}
}
}
/* ──────── Helper export для тестов ──────── */
self.__rbxlState = state;

View File

@ -161,6 +161,48 @@ export class StudioCollab {
return r; return r;
}; };
} }
// Скрипты — синхрон создания/редактирования (upsertScript) и удаления
// (removeScript). Скрипты живут в scene._scripts[] ({id, code, target,
// name}); UI зовёт scene.upsertScript / scene.removeScript.
const sc = this.scene;
if (sc && !sc.__collabScriptsPatched) {
sc.__collabScriptsPatched = true;
if (typeof sc.upsertScript === 'function') {
const origUpsert = sc.upsertScript.bind(sc);
sc.upsertScript = function (id, code, target, name) {
const r = origUpsert(id, code, target, name);
if (!self._applyingRemote) {
// id может быть сгенерён внутри upsertScript, если был null —
// достаём фактический из _scripts (последний с этим code).
let realId = id;
if (realId == null) {
const arr = sc._scripts || [];
realId = arr.length ? arr[arr.length - 1].id : null;
}
const rec = (sc._scripts || []).find((s) => s.id === realId);
if (rec) {
self.sendOp({
op: 'scriptUpsert',
id: rec.id,
code: rec.code,
target: rec.target ?? null,
name: rec.name ?? null,
});
}
}
return r;
};
}
if (typeof sc.removeScript === 'function') {
const origRemoveScript = sc.removeScript.bind(sc);
sc.removeScript = function (id) {
const r = origRemoveScript(id);
if (!self._applyingRemote) self.sendOp({ op: 'scriptRemove', id });
return r;
};
}
}
} }
/** Снять обёртки (при выходе из коллаба восстановить оригиналы — простой флаг). */ /** Снять обёртки (при выходе из коллаба восстановить оригиналы — простой флаг). */
@ -387,6 +429,17 @@ export function applyRemoteOp(scene, op) {
case 'blockRemove': case 'blockRemove':
bm?.removeBlock?.(op.x, op.y, op.z); bm?.removeBlock?.(op.x, op.y, op.z);
return; return;
case 'scriptUpsert':
// Создание/редактирование скрипта у соавтора. _applyingRemote уже
// выставлен (см. _applyRemoteOp) → обёртка upsertScript не зашлёт
// эхо обратно. _onSceneChange внутри обновит React-панели.
scene.upsertScript?.(op.id, op.code, op.target ?? null, op.name ?? null);
scene._onCollabScriptsChange?.();
return;
case 'scriptRemove':
scene.removeScript?.(op.id);
scene._onCollabScriptsChange?.();
return;
} }
switch (t) { switch (t) {
case 'add': { case 'add': {

View File

@ -0,0 +1,177 @@
/**
* rbxl-lua-integration.js single-VM интеграция (v2).
*
* Двухфазная инициализация:
* 1) init worker pre-populate workspace + GUI tree (включая сигналы)
* 2) addScriptsBatch ВСЕ скрипты одним IPC сообщением
* 3) ready kickoff emit PlayerAdded, начать tick
*/
import RobloxLuaSharedWorker from './RobloxLuaSharedWorker.js?worker';
import { RobloxLuaSharedSandbox } from './RobloxLuaSharedSandbox.js';
/** Распаковка 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);
}
/** Сцена → 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;
}
/**
* Старт shared-sandbox: init addScripts kickoff.
*/
export function startRobloxLuaShared(scripts, ctx) {
try {
const luaScripts = [];
for (const s of scripts) {
if (!s || typeof s.code !== 'string') continue;
if (!s.code.startsWith('// @roblox-lua')) continue;
const luaSource = unpackRobloxLuaCode(s.code);
if (!luaSource) continue;
luaScripts.push({ id: s.id, target: s.target, luaSource });
}
if (luaScripts.length === 0) return { sandbox: null, count: 0 };
const worker = new RobloxLuaSharedWorker();
const sceneSnap = buildLuaSceneSnap(ctx.primitives);
const guiTree = buildLuaGuiTree(ctx.guiElements || []);
const mgr = new RobloxLuaSharedSandbox();
mgr.setOnCommand(ctx.onCommand);
mgr.start(sceneSnap, guiTree, worker);
mgr.addScriptsBatch(luaScripts);
mgr.kickoff();
return { sandbox: mgr, count: luaScripts.length };
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[rbxl-lua-shared v2] start failed:', e?.message || e);
return null;
}
}
/**
* Обработка 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') {
try {
const pm = runtime.scene3d?.primitiveManager;
if (!pm) 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;
if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch);
else if (typeof pm.update === 'function') pm.update(primId, patch);
} catch (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,216 @@
/**
* roblox-physics.js эмуляция BodyMover / Constraint объектов Roblox.
*
* Roblox BodyMover'ы (старые, deprecated но массово используются):
* BodyVelocity поддерживает заданную линейную velocity
* BodyAngularVelocity поддерживает заданную угловую velocity
* BodyGyro пытается удержать ориентацию (Lookat)
* BodyForce постоянная сила
* BodyPosition пытается удержать позицию
* BodyThrust направленный импульс
*
* Constraint (новые):
* AlignPosition, AlignOrientation, LinearVelocity, AngularVelocity, Torque,
* VectorForce, Spring, RodConstraint, RopeConstraint, ...
*
* MVP: реализуем самые частые (BodyVelocity, BodyGyro, AlignPosition, VectorForce).
* Остальные заглушки + warning.
*
* Архитектура:
* - Когда Lua делает `Instance.new("BodyVelocity", part)`, мы создаём RbxBodyVelocity,
* прикрепляем к Part через .Parent.
* - На каждом tick шедулера обходим активные movers и отсылаем physForce в main.
* - Main применяет к Babylon physics impostor.
*/
import { RbxInstance, RbxSignal, RbxVector3 } from './roblox-shim.js';
class RbxBodyMoverBase extends RbxInstance {
constructor(className) {
super(className, { Name: className });
this._ctx = null; // { send, registerMover }
this.__parentPart = null;
}
/** Установить родителя и зарегистрироваться в physics-manager. */
setMoverParent(part) {
this.Parent = part;
if (part && part.__primId != null) {
this.__parentPart = part;
this._ctx?.registerMover?.(this);
}
}
}
export class RbxBodyVelocity extends RbxBodyMoverBase {
constructor() {
super('BodyVelocity');
this.Velocity = new RbxVector3(0, 0, 0);
this.MaxForce = new RbxVector3(4000, 4000, 4000);
this.P = 1250;
}
_step(_dt) {
if (!this.__parentPart || !this._ctx) return;
// posVel — желаемая velocity. Применяем как setVelocity.
this._ctx.send('partVel', {
primId: this.__parentPart.__primId,
vx: this.Velocity.X,
vy: this.Velocity.Y,
vz: this.Velocity.Z,
});
}
}
export class RbxBodyGyro extends RbxBodyMoverBase {
constructor() {
super('BodyGyro');
this.CFrame = null; // целевое вращение
this.MaxTorque = new RbxVector3(4000, 4000, 4000);
this.D = 500;
this.P = 3000;
}
_step(_dt) {
if (!this.__parentPart || !this._ctx || !this.CFrame) return;
const [rx, ry, rz] = this.CFrame.toEulerXYZ();
this._ctx.send('partSet', {
primId: this.__parentPart.__primId,
prop: 'rotation',
value: { rx, ry, rz },
});
}
}
export class RbxBodyPosition extends RbxBodyMoverBase {
constructor() {
super('BodyPosition');
this.Position = new RbxVector3(0, 0, 0);
this.MaxForce = new RbxVector3(4000, 4000, 4000);
this.D = 1250;
this.P = 10000;
}
_step(_dt) {
if (!this.__parentPart || !this._ctx) return;
this._ctx.send('partSet', {
primId: this.__parentPart.__primId,
prop: 'position',
value: { x: this.Position.X, y: this.Position.Y, z: this.Position.Z },
});
}
}
export class RbxBodyForce extends RbxBodyMoverBase {
constructor() {
super('BodyForce');
this.Force = new RbxVector3(0, 0, 0);
}
_step(dt) {
if (!this.__parentPart || !this._ctx) return;
this._ctx.send('partForce', {
primId: this.__parentPart.__primId,
fx: this.Force.X * dt, fy: this.Force.Y * dt, fz: this.Force.Z * dt,
});
}
}
export class RbxBodyAngularVelocity extends RbxBodyMoverBase {
constructor() {
super('BodyAngularVelocity');
this.AngularVelocity = new RbxVector3(0, 0, 0);
this.MaxTorque = new RbxVector3(4000, 4000, 4000);
}
_step(_dt) {
if (!this.__parentPart || !this._ctx) return;
this._ctx.send('partAngVel', {
primId: this.__parentPart.__primId,
wx: this.AngularVelocity.X, wy: this.AngularVelocity.Y, wz: this.AngularVelocity.Z,
});
}
}
/* ──────── New Constraints ──────── */
export class RbxAlignPosition extends RbxBodyMoverBase {
constructor() {
super('AlignPosition');
this.Position = new RbxVector3(0, 0, 0);
this.Attachment0 = null;
this.Attachment1 = null;
this.MaxForce = 1e6;
this.Enabled = true;
}
_step(_dt) {
if (!this.Enabled || !this.__parentPart || !this._ctx) return;
this._ctx.send('partSet', {
primId: this.__parentPart.__primId,
prop: 'position',
value: { x: this.Position.X, y: this.Position.Y, z: this.Position.Z },
});
}
}
export class RbxLinearVelocity extends RbxBodyMoverBase {
constructor() {
super('LinearVelocity');
this.VectorVelocity = new RbxVector3(0, 0, 0);
this.MaxForce = 1e6;
this.Enabled = true;
}
_step(_dt) {
if (!this.Enabled || !this.__parentPart || !this._ctx) return;
this._ctx.send('partVel', {
primId: this.__parentPart.__primId,
vx: this.VectorVelocity.X,
vy: this.VectorVelocity.Y,
vz: this.VectorVelocity.Z,
});
}
}
/* ──────── Manager ──────── */
export class RobloxPhysicsManager {
constructor(send) {
this._send = send;
this._movers = new Set();
}
install(lua) {
const self = this;
const ctx = {
send: this._send,
registerMover: (m) => self._movers.add(m),
};
// Подменяем Instance.new для физических классов
const origInstance = lua.global.get('Instance');
lua.global.set('Instance', {
new: (className, parent) => {
let inst = null;
switch (className) {
case 'BodyVelocity': inst = new RbxBodyVelocity(); break;
case 'BodyGyro': inst = new RbxBodyGyro(); break;
case 'BodyPosition': inst = new RbxBodyPosition(); break;
case 'BodyForce': inst = new RbxBodyForce(); break;
case 'BodyAngularVelocity':inst = new RbxBodyAngularVelocity(); break;
case 'AlignPosition': inst = new RbxAlignPosition(); break;
case 'LinearVelocity': inst = new RbxLinearVelocity(); break;
}
if (inst) {
inst._ctx = ctx;
if (parent) {
inst.setMoverParent(parent);
if (parent.Children) parent.Children.push(inst);
}
return inst;
}
return origInstance.new(className, parent);
},
});
}
tick(dt) {
for (const m of [...this._movers]) {
if (m.__destroyed || !m.__parentPart) { this._movers.delete(m); continue; }
try { m._step(dt); } catch (e) {}
}
}
}

View File

@ -0,0 +1,209 @@
/**
* roblox-scheduler.js шедулер корутин для Roblox-Lua wait/task.
*
* Архитектура:
* - Каждый верхне-уровневый Lua-код оборачивается в coroutine.
* - wait(sec) / task.wait(sec) делают coroutine.yield(sec)
* - Шедулер запоминает: { coro, resumeAt: tick + sec }
* - На каждом handleTick из main thread шедулер ресюмит готовые корутины
*
* RBXScriptSignal.Wait() = аналогично, но wait не на время, а на event'е:
* - { coro, waitingForSignal: signalName }
* - При Fire() сигнала шедулер ресюмит все ждущие
*
* Использование:
* const sched = new RobloxScheduler(luaEngine);
* sched.spawnMain(luaSource);
* // Каждый кадр:
* sched.tick(dtSec);
* // При событии:
* sched.fireSignal('Heartbeat', dt);
*/
export class RobloxScheduler {
constructor(lua) {
this.lua = lua;
this.time = 0;
this.tasks = []; // [{ coro, resumeAt, waitForSignal?, signalArgsBuf? }]
this.signalWaiters = new Map(); // name → [task]
this._coroBox = null;
}
/**
* Регистрирует глобалы wait/task.wait/task.spawn/task.delay в Lua-VM.
* Должно вызываться ПОСЛЕ registerRobloxApi (т.к. перебивает заглушки).
*/
install() {
const self = this;
// wait(sec) — yield в корутине на sec секунд
this.lua.global.set('wait', (sec) => {
// Этот wait вызовется из Lua. Если мы внутри корутины (а мы внутри
// т.к. spawnMain обернул всё) — yield. Иначе вернём дельту времени
// как обычное wait в Roblox.
const s = +sec || 0;
self._currentYield = { kind: 'sleep', sec: s };
// Возврат тут — это значение которое получит await в Lua;
// wasmoon обработает yield извне.
return s;
});
this.lua.global.set('task', {
wait: (sec) => {
self._currentYield = { kind: 'sleep', sec: +sec || 0 };
return +sec || 0;
},
spawn: (fn, ...args) => {
self.spawnCoroutine(fn, args);
},
delay: (sec, fn, ...args) => {
self.tasks.push({
resumeAt: self.time + (+sec || 0),
runFn: () => { try { fn(...args); } catch (e) {} },
});
},
defer: (fn, ...args) => {
self.tasks.push({
resumeAt: self.time,
runFn: () => { try { fn(...args); } catch (e) {} },
});
},
});
this.lua.global.set('spawn', (fn) => { self.spawnCoroutine(fn, []); });
this.lua.global.set('delay', (sec, fn) => {
self.tasks.push({
resumeAt: self.time + (+sec || 0),
runFn: () => { try { fn(); } catch (e) {} },
});
});
}
/**
* Запустить верхне-уровневый Lua-код как корутину.
* Возвращает Promise который резолвится когда код достиг ready (либо ушёл в первый yield).
*/
async spawnMain(luaSource) {
// Оборачиваем источник в coroutine.wrap(function() ... end)
// и сразу зовём — это даёт нам ручку на корутине через специальный
// приём: храним её в global _userCoro.
const wrapped = `
_userCoro = coroutine.create(function()
${luaSource}
end)
local ok, yieldVal = coroutine.resume(_userCoro)
if not ok then
error("user script error: " .. tostring(yieldVal))
end
return yieldVal
`;
try {
await this.lua.doString(wrapped);
const coroStatus = await this.lua.doString('return coroutine.status(_userCoro)');
if (coroStatus === 'suspended') {
// Ушла в yield — добавляем в шедулер
const yieldInfo = this._currentYield || { kind: 'sleep', sec: 0 };
this._currentYield = null;
this.tasks.push({
resumeAt: this.time + (yieldInfo.kind === 'sleep' ? yieldInfo.sec : 0),
waitForSignal: yieldInfo.kind === 'signal' ? yieldInfo.name : null,
coro: '_userCoro',
});
}
} catch (e) {
console.warn('spawnMain error:', e);
}
}
/**
* Запустить произвольную функцию как корутину (для task.spawn).
*/
spawnCoroutine(fn, args) {
// Создаём корутину на JS-стороне: просто вызываем fn() сразу,
// а если внутри неё дёрнут wait — yield не сработает (JS не делает
// sync yield в обычной функции). Поэтому task.spawn для JS-функций
// равен прямому вызову.
// В будущем (4.7.1) можно через Lua coroutine реализовать.
try { fn(...(args || [])); } catch (e) { /* swallow */ }
}
/**
* Продвинуть время на dt и резюмить готовые корутины.
* Также автоматически fire'ит RunService.Heartbeat / Stepped / RenderStepped.
*/
async tick(dtSec) {
const dt = +dtSec || 0;
this.time += dt;
// Heartbeat / Stepped / RenderStepped для RunService
const game = this.lua.global.get('game');
if (game && typeof game.GetService === 'function') {
const rs = game.GetService('RunService');
if (rs) {
if (rs.Heartbeat && rs.Heartbeat.Fire) rs.Heartbeat.Fire(dt);
if (rs.Stepped && rs.Stepped.Fire) rs.Stepped.Fire(this.time, dt);
if (rs.RenderStepped && rs.RenderStepped.Fire) rs.RenderStepped.Fire(dt);
}
}
// Резюмим всё что готово
const ready = this.tasks.filter(t => !t.waitForSignal && t.resumeAt <= this.time);
this.tasks = this.tasks.filter(t => !(ready.includes(t)));
for (const t of ready) {
await this._resumeTask(t);
}
}
/**
* Fire signal разбудить все task'и ждущие этого сигнала.
*/
async fireSignal(name, ...args) {
const waiters = this.signalWaiters.get(name) || [];
this.signalWaiters.set(name, []);
for (const t of waiters) {
// Resume корутины передавая args как возврат :Wait()
await this._resumeTask(t, args);
}
}
async _resumeTask(task, resumeArgs = []) {
if (task.runFn) {
try {
const ret = task.runFn();
if (ret && typeof ret.then === 'function') await ret;
} catch (e) {}
return;
}
if (task.coro) {
try {
// resumeArgs идут как аргументы в coroutine.resume
const argsCode = resumeArgs.map((a, i) => {
if (typeof a === 'number') return String(a);
if (typeof a === 'string') return JSON.stringify(a);
return 'nil';
}).join(', ');
const code = `
local ok, val = coroutine.resume(${task.coro}${argsCode ? ', ' + argsCode : ''})
if not ok then
error("coro error: " .. tostring(val))
end
return val
`;
await this.lua.doString(code);
const status = await this.lua.doString(`return coroutine.status(${task.coro})`);
if (status === 'suspended') {
// Опять ушла в yield
const yi = this._currentYield || { kind: 'sleep', sec: 0 };
this._currentYield = null;
if (yi.kind === 'sleep') {
this.tasks.push({
resumeAt: this.time + yi.sec,
coro: task.coro,
});
} else if (yi.kind === 'signal') {
const list = this.signalWaiters.get(yi.name) || [];
list.push({ coro: task.coro });
this.signalWaiters.set(yi.name, list);
}
}
} catch (e) {
// Корутина завершилась с ошибкой — просто дропаем
}
}
}
}

View File

@ -0,0 +1,384 @@
/**
* roblox-services.js расширения Roblox-API для сервисов:
* Players / Humanoid / UserInputService / RemoteEvent / RemoteFunction
* / DataStoreService / HttpService.
*
* Регистрируется ПОСЛЕ registerRobloxApi (см. roblox-shim.js).
*
* Поведение:
* - Players.LocalPlayer.Character.Humanoid.Health, WalkSpeed, JumpPower
* мапятся на game.player.* в Rublox через `playerCmd` IPC.
* - UserInputService.InputBegan/InputEnded пробрасываются из main
* по событию через fireEvent.
* - RemoteEvent:FireServer/FireClient broadcast.
* - DataStoreService:GetDataStore game.save.
*/
import { RbxInstance, RbxSignal, RbxVector3 } from './roblox-shim.js';
/* ──────── Humanoid ──────── */
class RbxHumanoid extends RbxInstance {
constructor(ctx) {
super('Humanoid', { Name: 'Humanoid' });
this._ctx = ctx; // { send, getPlayerState }
this._snap = {
Health: 100,
MaxHealth: 100,
WalkSpeed: 16,
JumpPower: 50,
JumpHeight: 7.2,
HipHeight: 0,
HumanoidStateType: 'GettingUp',
PlatformStand: false,
};
this.Died = new RbxSignal('Died');
this.HealthChanged = new RbxSignal('HealthChanged');
this.Touched = new RbxSignal('Touched');
this.Running = new RbxSignal('Running');
this.Jumping = new RbxSignal('Jumping');
this.StateChanged = new RbxSignal('StateChanged');
}
get Health() { return this._snap.Health; }
set Health(v) {
const old = this._snap.Health;
const nv = Math.max(0, +v || 0);
this._snap.Health = nv;
if (nv !== old) this.HealthChanged.Fire(nv);
if (nv <= 0 && old > 0) {
this.Died.Fire();
this._ctx.send?.('playerCmd', { method: 'die', args: [] });
} else {
this._ctx.send?.('playerCmd', { method: 'setHealth', args: [nv] });
}
}
get MaxHealth() { return this._snap.MaxHealth; }
set MaxHealth(v) {
this._snap.MaxHealth = +v || 100;
this._ctx.send?.('playerCmd', { method: 'setMaxHealth', args: [this._snap.MaxHealth] });
}
get WalkSpeed() { return this._snap.WalkSpeed; }
set WalkSpeed(v) {
this._snap.WalkSpeed = +v || 0;
this._ctx.send?.('playerCmd', { method: 'setWalkSpeed', args: [this._snap.WalkSpeed] });
}
get JumpPower() { return this._snap.JumpPower; }
set JumpPower(v) {
this._snap.JumpPower = +v || 0;
this._ctx.send?.('playerCmd', { method: 'setJumpPower', args: [this._snap.JumpPower] });
}
get JumpHeight() { return this._snap.JumpHeight; }
set JumpHeight(v) {
this._snap.JumpHeight = +v || 0;
this._ctx.send?.('playerCmd', { method: 'setJumpHeight', args: [this._snap.JumpHeight] });
}
get PlatformStand() { return !!this._snap.PlatformStand; }
set PlatformStand(v) {
this._snap.PlatformStand = !!v;
this._ctx.send?.('playerCmd', { method: 'setPlatformStand', args: [!!v] });
}
TakeDamage(amount) {
this.Health = Math.max(0, this.Health - (+amount || 0));
}
Move(direction, relative) {
if (direction instanceof RbxVector3) {
this._ctx.send?.('playerCmd', {
method: 'move',
args: [{ x: direction.X, y: direction.Y, z: direction.Z }, !!relative],
});
}
}
Jump() {
this._ctx.send?.('playerCmd', { method: 'jump', args: [] });
}
LoadAnimation(animation) {
// Animation объект — content rbxassetid. Возвращаем animation-track stub.
const aid = animation?.AnimationId || '';
return {
AnimationId: aid,
Length: 0,
IsPlaying: false,
Looped: false,
Play: () => this._ctx.send?.('playerCmd', { method: 'playAnim', args: [aid] }),
Stop: () => this._ctx.send?.('playerCmd', { method: 'stopAnim', args: [aid] }),
AdjustSpeed: (s) => this._ctx.send?.('playerCmd', { method: 'animSpeed', args: [aid, s] }),
GetTimeOfKeyframe: () => 0,
KeyframeReached: new RbxSignal('KeyframeReached'),
};
}
ChangeState(state) {
this._snap.HumanoidStateType = state;
this.StateChanged.Fire(state);
}
SetStateEnabled(_state, _enabled) { /* noop */ }
GetState() { return this._snap.HumanoidStateType; }
}
/* ──────── Character / Player ──────── */
class RbxCharacter extends RbxInstance {
constructor(ctx) {
super('Model', { Name: 'Character' });
// HumanoidRootPart — это «Position персонажа»
this.HumanoidRootPart = new RbxInstance('Part', { Name: 'HumanoidRootPart', Parent: this });
// mock Position через getter — берём текущую позицию из ctx
Object.defineProperty(this.HumanoidRootPart, 'Position', {
get: () => {
const p = ctx.getPlayerState?.() || { x: 0, y: 0, z: 0 };
return new RbxVector3(p.x, p.y, p.z);
},
set: (v) => {
if (v instanceof RbxVector3) {
ctx.send?.('playerCmd', { method: 'teleport', args: [v.X, v.Y, v.Z] });
}
},
});
Object.defineProperty(this.HumanoidRootPart, 'CFrame', {
get: () => {
const p = ctx.getPlayerState?.() || { x: 0, y: 0, z: 0 };
return { X: p.x, Y: p.y, Z: p.z, p: { X: p.x, Y: p.y, Z: p.z } };
},
set: (v) => {
if (v && typeof v === 'object') {
ctx.send?.('playerCmd', { method: 'teleport', args: [v.X || 0, v.Y || 0, v.Z || 0] });
}
},
});
this.Children.push(this.HumanoidRootPart);
this.Humanoid = new RbxHumanoid(ctx);
this.Humanoid.Parent = this;
this.Children.push(this.Humanoid);
}
}
class RbxPlayer extends RbxInstance {
constructor(ctx) {
super('Player', { Name: 'Player' });
this.UserId = 1;
this.DisplayName = 'Player';
this.Character = new RbxCharacter(ctx);
this.CharacterAdded = new RbxSignal('CharacterAdded');
this.CharacterRemoving = new RbxSignal('CharacterRemoving');
// На MVP — характер уже создан.
setTimeout(() => this.CharacterAdded.Fire(this.Character), 0);
this.leaderstats = new RbxInstance('Folder', { Name: 'leaderstats', Parent: this });
this.Children.push(this.leaderstats);
}
GetMouse() {
return { Hit: { Position: new RbxVector3(0, 0, 0) }, Target: null,
Button1Down: new RbxSignal('Button1Down'), Move: new RbxSignal('Move') };
}
Kick(reason) {
// в нашем плеере — просто log
return reason;
}
}
/* ──────── UserInputService ──────── */
class RbxUserInputService extends RbxInstance {
constructor() {
super('UserInputService', { Name: 'UserInputService' });
this.InputBegan = new RbxSignal('InputBegan');
this.InputEnded = new RbxSignal('InputEnded');
this.InputChanged = new RbxSignal('InputChanged');
this.JumpRequest = new RbxSignal('JumpRequest');
this.KeyboardEnabled = true;
this.MouseEnabled = true;
this.TouchEnabled = false;
}
GetMouseLocation() { return { X: 0, Y: 0 }; }
IsKeyDown(_keyCode) { return false; } // в MVP всегда false
}
/* ──────── RemoteEvent / RemoteFunction ──────── */
class RbxRemoteEvent extends RbxInstance {
constructor(ctx) {
super('RemoteEvent', { Name: 'RemoteEvent' });
this._ctx = ctx;
this.OnServerEvent = new RbxSignal('OnServerEvent');
this.OnClientEvent = new RbxSignal('OnClientEvent');
}
FireServer(...args) {
// singleplayer: server == client, просто отдаём в OnServerEvent
this.OnServerEvent.Fire(this._ctx.localPlayer, ...args);
this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args });
}
FireClient(_player, ...args) {
this.OnClientEvent.Fire(...args);
this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args });
}
FireAllClients(...args) {
this.OnClientEvent.Fire(...args);
this._ctx.send?.('broadcast', { msg: 'remoteEvent:' + this.Name, data: args });
}
}
class RbxRemoteFunction extends RbxInstance {
constructor(ctx) {
super('RemoteFunction', { Name: 'RemoteFunction' });
this._ctx = ctx;
this.OnServerInvoke = null; // function(player, ...args) → result
}
InvokeServer(...args) {
if (typeof this.OnServerInvoke === 'function') {
try { return this.OnServerInvoke(this._ctx.localPlayer, ...args); } catch (e) {}
}
return null;
}
InvokeClient(_player, ...args) {
if (typeof this.OnClientInvoke === 'function') {
try { return this.OnClientInvoke(...args); } catch (e) {}
}
return null;
}
}
/* ──────── DataStoreService ──────── */
class RbxDataStore {
constructor(name, ctx) {
this.name = name;
this._ctx = ctx;
}
GetAsync(key) {
try {
const data = this._ctx.loadSave?.(this.name + ':' + key);
return data ?? null;
} catch (e) { return null; }
}
SetAsync(key, value) {
this._ctx.saveSave?.(this.name + ':' + key, value);
return value;
}
UpdateAsync(key, updaterFn) {
const cur = this.GetAsync(key);
const next = updaterFn(cur);
if (next !== undefined) this.SetAsync(key, next);
return next;
}
IncrementAsync(key, delta) {
const cur = +this.GetAsync(key) || 0;
const next = cur + (+delta || 1);
this.SetAsync(key, next);
return next;
}
RemoveAsync(key) {
this._ctx.removeSave?.(this.name + ':' + key);
}
}
class RbxDataStoreService extends RbxInstance {
constructor(ctx) {
super('DataStoreService', { Name: 'DataStoreService' });
this._ctx = ctx;
this._stores = new Map();
}
GetDataStore(name) {
if (!this._stores.has(name)) this._stores.set(name, new RbxDataStore(name, this._ctx));
return this._stores.get(name);
}
GetGlobalDataStore() { return this.GetDataStore('__global__'); }
GetOrderedDataStore(name) { return this.GetDataStore('ordered:' + name); }
}
/* ──────── HttpService ──────── */
class RbxHttpService extends RbxInstance {
constructor(ctx) {
super('HttpService', { Name: 'HttpService' });
this._ctx = ctx;
this.HttpEnabled = false; // в нашем плеере по дефолту выкл, безопаснее
}
GenerateGUID(wrap) {
const c = () => Math.random().toString(16).slice(2, 6);
const guid = `${c()}${c()}-${c()}-${c()}-${c()}-${c()}${c()}${c()}`.toUpperCase();
return wrap === false ? guid : `{${guid}}`;
}
JSONEncode(value) { try { return JSON.stringify(value); } catch (e) { return ''; } }
JSONDecode(s) { try { return JSON.parse(s); } catch (e) { return null; } }
GetAsync(url) {
// CORS / sandbox: блокируем в MVP, возвращаем заглушку
this._ctx.send?.('log', { level: 'warn', text: `HttpService:GetAsync(${url}) blocked in MVP` });
return '';
}
PostAsync(url) {
this._ctx.send?.('log', { level: 'warn', text: `HttpService:PostAsync(${url}) blocked in MVP` });
return '';
}
}
/* ──────── install ──────── */
export function installRobloxServices(lua, ctx) {
// ctx: { send, getPlayerState, getSnapPlayer, loadSave, saveSave, removeSave }
const game = lua.global.get('game');
if (!game) return;
// Создаём LocalPlayer
const player = new RbxPlayer({
send: ctx.send,
getPlayerState: ctx.getPlayerState,
});
// Players service апгрейдим
const players = game.GetService('Players');
if (players) {
players.LocalPlayer = player;
// GetPlayers / GetPlayerFromCharacter
players.GetPlayers = () => [player];
players.GetPlayerFromCharacter = (c) => (c === player.Character ? player : null);
}
// UserInputService
const uis = new RbxUserInputService();
// RemoteEvent / DataStoreService / HttpService — выдаются через GetService
const dss = new RbxDataStoreService({
loadSave: ctx.loadSave,
saveSave: ctx.saveSave,
removeSave: ctx.removeSave,
});
const httpSvc = new RbxHttpService({ send: ctx.send });
// Подмена GetService — добавляем наши новые сервисы
const origGetService = game.GetService;
game.GetService = function(svc) {
if (svc === 'UserInputService') return uis;
if (svc === 'DataStoreService') return dss;
if (svc === 'HttpService') return httpSvc;
// ContextActionService — стаб
if (svc === 'ContextActionService') {
return {
ClassName: 'ContextActionService', Name: 'ContextActionService',
BindAction: (_name, fn, _gui, ...keys) => { /* в MVP — игнор */ },
UnbindAction: () => {},
};
}
return origGetService.call(this, svc);
};
// Instance.new('RemoteEvent') / 'RemoteFunction' — переопределяем фабрику
const origInstance = lua.global.get('Instance');
lua.global.set('Instance', {
new: (className, parent) => {
if (className === 'RemoteEvent') {
const r = new RbxRemoteEvent({ send: ctx.send, localPlayer: player });
if (parent) { r.Parent = parent; parent.Children.push(r); }
return r;
}
if (className === 'RemoteFunction') {
const r = new RbxRemoteFunction({ send: ctx.send, localPlayer: player });
if (parent) { r.Parent = parent; parent.Children.push(r); }
return r;
}
return origInstance.new(className, parent);
},
});
return { player, uis, dss, httpSvc };
}
export { RbxHumanoid, RbxCharacter, RbxPlayer, RbxUserInputService,
RbxRemoteEvent, RbxRemoteFunction, RbxDataStoreService, RbxHttpService };

View File

@ -0,0 +1,715 @@
/**
* roblox-shim.js регистрация Roblox API внутри Lua-VM (wasmoon).
*
* Используется из RobloxLuaWorker.js. Регистрирует глобалы:
* - game, workspace, script Instance-прокси
* - Vector3, Color3, CFrame, UDim, UDim2 конструкторы математических классов
* - Instance.new(class) фабрика
* - wait, task, tick, os, print, warn стандартные глобалы
* - Enum enum-таблица
*
* Архитектура:
* - JS-классы (RbxVector3, RbxCFrame, ...) обычные дата-объекты с
* перегруженными методами.
* - Instance прокси-объект который хранит { className, properties, children, parent }.
* Геттеры/сеттеры эмулируются через __index/__newindex (mt в wasmoon).
* - RBXScriptSignal JS-объект с Connect/Wait/Disconnect.
*
* Sandbox-side: при изменении Part.Position и т.п. отсылаем в main thread
* `partSet` main применит к Babylon-сцене.
*/
/* ──────── Math classes ──────── */
class RbxVector3 {
constructor(x, y, z) {
this.X = +x || 0;
this.Y = +y || 0;
this.Z = +z || 0;
}
get Magnitude() {
return Math.sqrt(this.X*this.X + this.Y*this.Y + this.Z*this.Z);
}
get Unit() {
const m = this.Magnitude || 1;
return new RbxVector3(this.X / m, this.Y / m, this.Z / m);
}
Dot(o) { return this.X*o.X + this.Y*o.Y + this.Z*o.Z; }
Cross(o) {
return new RbxVector3(
this.Y*o.Z - this.Z*o.Y,
this.Z*o.X - this.X*o.Z,
this.X*o.Y - this.Y*o.X,
);
}
Lerp(o, alpha) {
return new RbxVector3(
this.X + (o.X - this.X) * alpha,
this.Y + (o.Y - this.Y) * alpha,
this.Z + (o.Z - this.Z) * alpha,
);
}
add(o) { return new RbxVector3(this.X + o.X, this.Y + o.Y, this.Z + o.Z); }
sub(o) { return new RbxVector3(this.X - o.X, this.Y - o.Y, this.Z - o.Z); }
mul(scalar) {
if (typeof scalar === 'number') {
return new RbxVector3(this.X * scalar, this.Y * scalar, this.Z * scalar);
}
return new RbxVector3(this.X * scalar.X, this.Y * scalar.Y, this.Z * scalar.Z);
}
toString() { return `${this.X}, ${this.Y}, ${this.Z}`; }
}
class RbxColor3 {
constructor(r, g, b) {
this.R = +r || 0;
this.G = +g || 0;
this.B = +b || 0;
}
static fromRGB(r, g, b) {
return new RbxColor3((r||0)/255, (g||0)/255, (b||0)/255);
}
static fromHex(hex) {
const h = String(hex || '#000000').replace('#','');
return new RbxColor3(
parseInt(h.slice(0,2), 16)/255,
parseInt(h.slice(2,4), 16)/255,
parseInt(h.slice(4,6), 16)/255,
);
}
Lerp(o, alpha) {
return new RbxColor3(
this.R + (o.R - this.R) * alpha,
this.G + (o.G - this.G) * alpha,
this.B + (o.B - this.B) * alpha,
);
}
toHex() {
const h = (n) => Math.max(0, Math.min(255, Math.round(n * 255))).toString(16).padStart(2, '0');
return `#${h(this.R)}${h(this.G)}${h(this.B)}`;
}
toString() { return `${this.R}, ${this.G}, ${this.B}`; }
}
class RbxCFrame {
constructor(x, y, z, r00=1, r01=0, r02=0, r10=0, r11=1, r12=0, r20=0, r21=0, r22=1) {
this.X = +x || 0; this.Y = +y || 0; this.Z = +z || 0;
// Row-major 3x3
this.r00 = r00; this.r01 = r01; this.r02 = r02;
this.r10 = r10; this.r11 = r11; this.r12 = r12;
this.r20 = r20; this.r21 = r21; this.r22 = r22;
}
static new(x, y, z) {
if (x instanceof RbxVector3) return new RbxCFrame(x.X, x.Y, x.Z);
return new RbxCFrame(x || 0, y || 0, z || 0);
}
static Angles(rx, ry, rz) {
// Euler XYZ → 3x3 (intrinsic)
const cx = Math.cos(rx), sx = Math.sin(rx);
const cy = Math.cos(ry), sy = Math.sin(ry);
const cz = Math.cos(rz), sz = Math.sin(rz);
// R = Rx * Ry * Rz
const r00 = cy*cz, r01 = -cy*sz, r02 = sy;
const r10 = sx*sy*cz + cx*sz, r11 = -sx*sy*sz + cx*cz, r12 = -sx*cy;
const r20 = -cx*sy*cz + sx*sz, r21 = cx*sy*sz + sx*cz, r22 = cx*cy;
return new RbxCFrame(0, 0, 0, r00, r01, r02, r10, r11, r12, r20, r21, r22);
}
static fromEulerAnglesXYZ(rx, ry, rz) { return RbxCFrame.Angles(rx, ry, rz); }
get Position() { return new RbxVector3(this.X, this.Y, this.Z); }
get LookVector() { return new RbxVector3(-this.r02, -this.r12, -this.r22); }
get RightVector() { return new RbxVector3(this.r00, this.r10, this.r20); }
get UpVector() { return new RbxVector3(this.r01, this.r11, this.r21); }
Lerp(o, a) {
// Линейная интерполяция (без правильного slerp на матрицах — для MVP сойдёт)
return new RbxCFrame(
this.X + (o.X - this.X) * a,
this.Y + (o.Y - this.Y) * a,
this.Z + (o.Z - this.Z) * a,
this.r00, this.r01, this.r02,
this.r10, this.r11, this.r12,
this.r20, this.r21, this.r22,
);
}
Inverse() {
// Транспонируем 3x3 (для rotation matrix Inverse == Transpose)
return new RbxCFrame(
-this.X, -this.Y, -this.Z,
this.r00, this.r10, this.r20,
this.r01, this.r11, this.r21,
this.r02, this.r12, this.r22,
);
}
toEulerXYZ() {
const rx = Math.atan2(this.r21, this.r22);
const ry = Math.atan2(-this.r20, Math.sqrt(this.r21*this.r21 + this.r22*this.r22));
const rz = Math.atan2(this.r10, this.r00);
return [rx, ry, rz];
}
toString() { return `${this.X}, ${this.Y}, ${this.Z}`; }
}
class RbxUDim {
constructor(scale, offset) { this.Scale = +scale || 0; this.Offset = +offset | 0; }
toString() { return `${this.Scale}, ${this.Offset}`; }
}
class RbxUDim2 {
constructor(xs, xo, ys, yo) {
this.X = new RbxUDim(xs, xo);
this.Y = new RbxUDim(ys, yo);
}
static new(xs, xo, ys, yo) { return new RbxUDim2(xs, xo, ys, yo); }
static fromScale(xs, ys) { return new RbxUDim2(xs, 0, ys, 0); }
static fromOffset(xo, yo) { return new RbxUDim2(0, xo, 0, yo); }
toString() { return `${this.X.Scale}, ${this.X.Offset}, ${this.Y.Scale}, ${this.Y.Offset}`; }
}
/* ──────── RBXScriptSignal ──────── */
let _signalIdCounter = 1000;
class RbxSignal {
constructor(name) {
this.name = name;
this.id = _signalIdCounter++;
this.connections = [];
}
Connect(callback) {
const conn = { callback, connected: true };
this.connections.push(conn);
return {
Disconnect: () => { conn.connected = false; },
disconnect: () => { conn.connected = false; },
Connected: () => conn.connected,
};
}
// Legacy Roblox API — lowercase alias
connect(callback) { return this.Connect(callback); }
Wait() { return null; }
wait() { return null; }
Fire(...args) {
for (const c of this.connections) {
if (!c.connected) continue;
try { c.callback(...args); } catch (e) { /* swallow */ }
}
}
fire(...args) { return this.Fire(...args); }
}
/* ──────── Instance прокси ──────── */
let _instanceCounter = 1;
// Null-stub: возвращается из FindFirstChild/WaitForChild когда объект не найден.
// Имеет все методы Instance как no-op, чтобы Lua-цепочки вроде
// game.Players.LocalPlayer:WaitForChild("PlayerGui"):WaitForChild("X").Touched:Connect(fn)
// не падали с "attempt to call js_null", когда промежуточный объект не существует.
// Все методы возвращают сам _nullStub чтобы цепочка не разорвалась.
// nullSignal: callable proxy. Lua делает x:Connect(fn) = x.Connect(x, fn),
// но также pattern signal:Connect(fn) сначала достаёт signal.Connect (это функция),
// потом вызывает её. Мы возвращаем функцию которая безусловно возвращает {Disconnect}.
const _nullConn = { Disconnect: () => {}, disconnect: () => {}, Connected: false };
const _nullSignalFn = () => _nullConn;
const _nullSignal = new Proxy(_nullSignalFn, {
get(_, k) {
if (k === 'Connect' || k === 'connect') return _nullSignalFn;
if (k === 'Wait' || k === 'wait') return () => null;
if (k === 'Fire' || k === 'fire') return () => {};
return undefined;
},
});
// Известные имена сигналов (Touched, Changed, MouseButton1Click, ...)
const _SIGNAL_NAMES = new Set([
'Touched','TouchEnded','Changed','Activated',
'MouseButton1Click','MouseButton1Down','MouseButton1Up',
'MouseButton2Click','MouseButton2Down','MouseButton2Up',
'MouseEnter','MouseLeave','InputBegan','InputEnded','InputChanged',
'PlayerAdded','PlayerRemoving','CharacterAdded','CharacterRemoving',
'Heartbeat','Stepped','RenderStepped','Died','HealthChanged',
'FocusLost','Focused','ChildAdded','ChildRemoved',
'AncestryChanged','DescendantAdded','DescendantRemoving',
// Tool сигналы
'Equipped','Unequipped','Selected','Deselected',
// прочие популярные
'OnInvoke','OnServerInvoke','OnClientInvoke',
'OnServerEvent','OnClientEvent','Fired','Triggered',
'ChatMakeSystemMessage','ChatMade',
]);
// _makeDeepStub — рекурсивный proxy которому всё равно сколько раз его
// индексируют. На любом уровне:
// - caps-имя из _SIGNAL_NAMES → возвращает _nullSignal
// - 'Parent' → возвращает _nullStub
// - любое другое имя → callable proxy + рекурсивная глубина
// Это позволяет цепочкам типа `tool.Selected:Connect(fn)` или
// `script.Parent.Parent.Frame.Visible` молча no-op'аться.
// Вместо JS Proxy (который wasmoon оборачивает в js_promise) — используем
// специальный маркер. Реальный stub живёт на Lua-стороне.
const NULL_STUB_MARKER = { __isNullStubMarker: true };
function _makeDeepStub() { return NULL_STUB_MARKER; }
const _nullStubBase = { __isNullStub: true, ClassName: 'Nil', Name: 'Nil', Children: [], Value: 0, Text: '', Visible: false };
// _nullStub оставлен как маркер, но не используется как реальный stub —
// debug.setmetatable(nil) в Lua перехватывает всё это.
const _nullStub = _nullStubBase;
class RbxInstance {
constructor(className, init = {}) {
this.__id = _instanceCounter++;
this.ClassName = className;
this.Name = init.Name || className;
this.Parent = init.Parent || null;
this.Children = [];
this.__props = {}; // raw properties (для Position и т.п.)
// Signals доступны как прямые свойства, плюс дублируются в __signals для serv-кода
this.Touched = new RbxSignal('Touched');
this.TouchEnded = new RbxSignal('TouchEnded');
this.Changed = new RbxSignal('Changed');
this.AncestryChanged = new RbxSignal('AncestryChanged');
this.ChildAdded = new RbxSignal('ChildAdded');
this.ChildRemoved = new RbxSignal('ChildRemoved');
this.__signals = {
Touched: this.Touched,
TouchEnded: this.TouchEnded,
Changed: this.Changed,
AncestryChanged: this.AncestryChanged,
ChildAdded: this.ChildAdded,
ChildRemoved: this.ChildRemoved,
};
this.__sceneState = null;
}
GetChildren() { return [...this.Children]; }
GetDescendants() {
const out = [];
const walk = (n) => {
for (const c of n.Children) { out.push(c); walk(c); }
};
walk(this);
return out;
}
FindFirstChild(name, recursive) {
for (const c of this.Children) {
if (c.Name === name) return c;
if (recursive) {
const found = c.FindFirstChild(name, true);
if (found) return found;
}
}
// Возвращаем undefined — wasmoon отдаст это как nil.
// Lua-side debug.setmetatable(nil) перехватит дальнейшую индексацию.
return undefined;
}
FindFirstChildOfClass(className) {
for (const c of this.Children) {
if (c.ClassName === className) return c;
}
return undefined;
}
FindFirstAncestor(name) {
let p = this.Parent;
while (p) {
if (p.Name === name) return p;
p = p.Parent;
}
return undefined;
}
WaitForChild(name, _timeout) {
// В MVP — синхронный поиск без ожидания. В 4.7 через корутины можно ждать.
return this.FindFirstChild(name);
}
IsA(className) {
if (this.ClassName === className) return true;
// Roblox class hierarchy: Part isA BasePart isA PVInstance isA Instance.
const hierarchy = {
'Part': ['BasePart', 'PVInstance', 'Instance'],
'WedgePart': ['BasePart', 'PVInstance', 'Instance'],
'CornerWedgePart': ['BasePart', 'PVInstance', 'Instance'],
'MeshPart': ['BasePart', 'PVInstance', 'Instance'],
'UnionOperation': ['PartOperation', 'BasePart', 'PVInstance', 'Instance'],
'TrussPart': ['BasePart', 'PVInstance', 'Instance'],
'SpawnLocation': ['Part', 'BasePart', 'PVInstance', 'Instance'],
'Script': ['BaseScript', 'LuaSourceContainer', 'Instance'],
'LocalScript': ['BaseScript', 'LuaSourceContainer', 'Instance'],
'ModuleScript': ['LuaSourceContainer', 'Instance'],
'Folder': ['Instance'],
'Model': ['PVInstance', 'Instance'],
'Sound': ['Instance'],
'PointLight': ['Light', 'Instance'],
'SpotLight': ['Light', 'Instance'],
'Humanoid': ['Instance'],
};
const ancestors = hierarchy[this.ClassName] || [];
return ancestors.includes(className);
}
Destroy() {
if (this.Parent && this.Parent.Children) {
const idx = this.Parent.Children.indexOf(this);
if (idx >= 0) this.Parent.Children.splice(idx, 1);
}
this.Parent = null;
this.__destroyed = true;
}
Clone() {
const cl = new RbxInstance(this.ClassName);
cl.Name = this.Name;
cl.__props = JSON.parse(JSON.stringify(this.__props));
for (const c of this.Children) {
const cc = c.Clone();
cc.Parent = cl;
cl.Children.push(cc);
}
return cl;
}
GetPropertyChangedSignal(propName) {
const sigName = `Changed:${propName}`;
if (!this.__signals[sigName]) this.__signals[sigName] = new RbxSignal(sigName);
return this.__signals[sigName];
}
}
/* ──────── Part — наследник Instance с реальными свойствами сцены ──────── */
class RbxPart extends RbxInstance {
constructor(primId, init = {}) {
super(init.ClassName || 'Part', init);
this.__primId = primId; // id примитива в Rublox-сцене
this.__sendFn = null; // setter из shim init
// Кешированные свойства (mirror'ятся через handleTick)
this._snap = init.snap || {};
}
get Position() {
return new RbxVector3(this._snap.x || 0, this._snap.y || 0, this._snap.z || 0);
}
set Position(v) {
if (v instanceof RbxVector3) {
this._snap.x = v.X; this._snap.y = v.Y; this._snap.z = v.Z;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'position', value: { x: v.X, y: v.Y, z: v.Z } });
}
}
get CFrame() {
return new RbxCFrame(this._snap.x || 0, this._snap.y || 0, this._snap.z || 0);
}
set CFrame(cf) {
if (cf instanceof RbxCFrame) {
this._snap.x = cf.X; this._snap.y = cf.Y; this._snap.z = cf.Z;
const [rx, ry, rz] = cf.toEulerXYZ();
this._snap.rx = rx; this._snap.ry = ry; this._snap.rz = rz;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'cframe', value: { x: cf.X, y: cf.Y, z: cf.Z, rx, ry, rz } });
}
}
get Size() {
return new RbxVector3(this._snap.sx || 1, this._snap.sy || 1, this._snap.sz || 1);
}
set Size(v) {
if (v instanceof RbxVector3) {
this._snap.sx = v.X; this._snap.sy = v.Y; this._snap.sz = v.Z;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'size', value: { sx: v.X, sy: v.Y, sz: v.Z } });
}
}
get Color() { return RbxColor3.fromHex(this._snap.color || '#cccccc'); }
set Color(c) {
if (c instanceof RbxColor3) {
const hex = c.toHex();
this._snap.color = hex;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'color', value: hex });
}
}
get BrickColor() { return { Color: this.Color, Name: 'Medium stone grey' }; }
set BrickColor(b) { if (b && b.Color) this.Color = b.Color; }
get Material() { return this._snap.material || 'glossy'; }
set Material(m) {
this._snap.material = m;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'material', value: m });
}
get Anchored() { return !!this._snap.anchored; }
set Anchored(v) {
this._snap.anchored = !!v;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'anchored', value: !!v });
}
get CanCollide() { return this._snap.canCollide !== false; }
set CanCollide(v) {
this._snap.canCollide = !!v;
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'canCollide', value: !!v });
}
get Transparency() { return 1.0 - (this._snap.opacity ?? 1.0); }
set Transparency(v) {
this._snap.opacity = 1.0 - (+v || 0);
this.__sendFn?.('partSet', { primId: this.__primId, prop: 'opacity', value: this._snap.opacity });
}
get Velocity() { return new RbxVector3(0, 0, 0); }
set Velocity(v) {
if (v instanceof RbxVector3) {
this.__sendFn?.('partVel', { primId: this.__primId, vx: v.X, vy: v.Y, vz: v.Z });
}
}
}
/* ──────── Главный entry-point: регистрация в Lua-VM ──────── */
export function registerRobloxApi(lua, ctx) {
const { getSceneSnap, targetPrimitiveId, send, getGuiTree, scheduler } = ctx;
// 1. Math classes — как глобалы с .new factory
const wrap = (cls) => ({
new: (...args) => new cls(...args),
});
lua.global.set('Vector3', {
new: (x, y, z) => new RbxVector3(x, y, z),
zero: new RbxVector3(0, 0, 0),
one: new RbxVector3(1, 1, 1),
xAxis: new RbxVector3(1, 0, 0),
yAxis: new RbxVector3(0, 1, 0),
zAxis: new RbxVector3(0, 0, 1),
});
lua.global.set('Color3', {
new: (r, g, b) => new RbxColor3(r, g, b),
fromRGB: RbxColor3.fromRGB,
fromHex: RbxColor3.fromHex,
});
lua.global.set('CFrame', {
new: RbxCFrame.new,
Angles: RbxCFrame.Angles,
fromEulerAnglesXYZ: RbxCFrame.fromEulerAnglesXYZ,
});
lua.global.set('UDim', { new: (s, o) => new RbxUDim(s, o) });
lua.global.set('UDim2', {
new: RbxUDim2.new,
fromScale: RbxUDim2.fromScale,
fromOffset: RbxUDim2.fromOffset,
});
// 2. Сцена — собираем JS-структуру из snap'а
// Workspace — корень.
const workspace = new RbxInstance('Workspace', { Name: 'Workspace' });
const part_by_id = new Map();
const snap = getSceneSnap();
if (snap && snap.primitives) {
for (const [id, p] of Object.entries(snap.primitives)) {
const part = new RbxPart(+id, {
ClassName: p.type === 'wedge' ? 'WedgePart' :
p.type === 'cornerwedge' ? 'CornerWedgePart' : 'Part',
Name: p.name || 'Part',
snap: { ...p },
});
part.__sendFn = send;
// Сигналы Part: Touched/TouchEnded существуют на каждом по умолчанию
part.Touched = new RbxSignal('Touched');
part.TouchEnded = new RbxSignal('TouchEnded');
part.Parent = workspace;
workspace.Children.push(part);
part_by_id.set(+id, part);
}
}
// 2b. GUI-tree: предсоздаём ScreenGui + Frame/Button/Label/etc по дереву
// конвертера. Каждый button получает MouseButton1Click/MouseButton1Down/Up
// сигналы которые fire'аются из main через sendGlobalEvent('guiClick').
const gui_by_id = new Map();
// PlayerGui контейнер внутри Players.LocalPlayer
const playerGui = new RbxInstance('PlayerGui', { Name: 'PlayerGui' });
if (getGuiTree) {
const tree = getGuiTree() || [];
// первый проход — создаём instances
for (const el of tree) {
const cls = el.__roblox_class || 'Frame';
const inst = new RbxInstance(cls, { Name: el.name || cls });
inst.__guiId = el.id;
inst.Visible = el.visible !== false;
inst.Text = el.text || '';
// Стандартные сигналы кнопок
if (cls === 'TextButton' || cls === 'ImageButton') {
inst.MouseButton1Click = new RbxSignal('MouseButton1Click');
inst.MouseButton1Down = new RbxSignal('MouseButton1Down');
inst.MouseButton1Up = new RbxSignal('MouseButton1Up');
inst.Activated = new RbxSignal('Activated');
inst.MouseEnter = new RbxSignal('MouseEnter');
inst.MouseLeave = new RbxSignal('MouseLeave');
}
// FocusLost для textboxes
if (cls === 'TextBox') {
inst.FocusLost = new RbxSignal('FocusLost');
inst.Focused = new RbxSignal('Focused');
}
// Changed-сигнал у каждого
inst.Changed = new RbxSignal('Changed');
gui_by_id.set(el.id, inst);
}
// второй проход — parent-связи (parentId → Instance)
for (const el of tree) {
const inst = gui_by_id.get(el.id);
if (!inst) continue;
const parentInst = el.parentId ? gui_by_id.get(el.parentId) : playerGui;
if (parentInst) {
inst.Parent = parentInst;
parentInst.Children.push(inst);
}
}
}
// 3. script — в shared-режиме не глобал, а локально создаётся при addScript.
// Здесь только заглушка чтобы простые non-shared скрипты не падали.
if (targetPrimitiveId != null && part_by_id.has(targetPrimitiveId)) {
const parentPart = part_by_id.get(targetPrimitiveId);
const scriptInst = new RbxInstance('LocalScript', { Name: 'Script' });
scriptInst.Parent = parentPart;
parentPart.Children.push(scriptInst);
lua.global.set('script', scriptInst);
}
// 4. game / game:GetService
const services = new Map();
const game = new RbxInstance('DataModel', { Name: 'Game' });
game.Children.push(workspace);
workspace.Parent = game;
// Builtin services:
const lighting = new RbxInstance('Lighting', { Name: 'Lighting' });
lighting.Parent = game;
game.Children.push(lighting);
services.set('Lighting', lighting);
const replicatedStorage = new RbxInstance('ReplicatedStorage', { Name: 'ReplicatedStorage' });
replicatedStorage.Parent = game;
game.Children.push(replicatedStorage);
services.set('ReplicatedStorage', replicatedStorage);
const runService = new RbxInstance('RunService', { Name: 'RunService' });
runService.Heartbeat = new RbxSignal('Heartbeat');
runService.Stepped = new RbxSignal('Stepped');
runService.RenderStepped = new RbxSignal('RenderStepped');
services.set('RunService', runService);
const playersService = new RbxInstance('Players', { Name: 'Players' });
playersService.PlayerAdded = new RbxSignal('PlayerAdded');
playersService.PlayerRemoving = new RbxSignal('PlayerRemoving');
// LocalPlayer с PlayerGui + Character
const localPlayer = new RbxInstance('Player', { Name: 'Player1' });
localPlayer.UserId = 1;
localPlayer.PlayerGui = playerGui;
playerGui.Parent = localPlayer;
localPlayer.Children.push(playerGui);
// Character заглушка с Humanoid и HumanoidRootPart
const character = new RbxInstance('Model', { Name: 'Character' });
const humanoid = new RbxInstance('Humanoid', { Name: 'Humanoid' });
humanoid.WalkSpeed = 16;
humanoid.JumpPower = 50;
humanoid.Health = 100;
humanoid.MaxHealth = 100;
humanoid.Died = new RbxSignal('Died');
humanoid.HealthChanged = new RbxSignal('HealthChanged');
humanoid.Touched = new RbxSignal('Touched');
humanoid.Parent = character;
character.Children.push(humanoid);
character.Humanoid = humanoid;
const hrp = new RbxPart(-1, { ClassName: 'Part', Name: 'HumanoidRootPart' });
hrp.Touched = new RbxSignal('Touched');
hrp.Parent = character;
character.Children.push(hrp);
character.HumanoidRootPart = hrp;
localPlayer.Character = character;
localPlayer.CharacterAdded = new RbxSignal('CharacterAdded');
localPlayer.CharacterRemoving = new RbxSignal('CharacterRemoving');
playersService.LocalPlayer = localPlayer;
playersService.Children.push(localPlayer);
services.set('Players', playersService);
game.GetService = function(svc) {
if (services.has(svc)) return services.get(svc);
if (svc === 'Workspace') return workspace;
if (svc === 'Workspace') return workspace;
// Неизвестный сервис — создаём заглушку, чтобы не падало
const stub = new RbxInstance(svc, { Name: svc });
services.set(svc, stub);
return stub;
};
game.Workspace = workspace;
game.Lighting = lighting;
game.Players = playersService;
game.ReplicatedStorage = replicatedStorage;
lua.global.set('game', game);
lua.global.set('workspace', workspace);
lua.global.set('Workspace', workspace);
// 5. Instance.new
lua.global.set('Instance', {
new: (className, parent) => {
const inst = new RbxInstance(className);
if (parent && parent instanceof RbxInstance) {
inst.Parent = parent;
parent.Children.push(inst);
}
return inst;
},
});
// 6. wait/task.wait через scheduler. scheduler — main-side, поддерживает
// schedule(sec, fn) что fire'ит fn после задержки в следующих tick'ах.
// spawn/delay/defer запускают функцию через scheduler.spawn (отдельная корутина).
const sched = scheduler || {
schedule: (sec, fn) => { try { fn(); } catch (e) {} },
spawn: (fn) => { try { fn(); } catch (e) {} },
now: () => Date.now() / 1000,
};
lua.global.set('wait', (sec) => {
// В корутине: yield на (sec || 0). Scheduler сам resume'ит.
// Тут мы синхронны (вызов из Lua) — реальный yield делается в lua-wrapper
// через coroutine.yield, который мы оборачиваем в addScript.
// Здесь просто возвращаем длительность для совместимости.
return [sec || 0, 0];
});
lua.global.set('task', {
wait: (sec) => sec || 0,
spawn: (fn, ...args) => { sched.spawn(() => { try { fn(...args); } catch (e) {} }); return null; },
delay: (sec, fn, ...args) => { sched.schedule(sec || 0, () => { try { fn(...args); } catch (e) {} }); return null; },
defer: (fn, ...args) => { sched.spawn(() => { try { fn(...args); } catch (e) {} }); return null; },
});
lua.global.set('spawn', (fn) => { sched.spawn(() => { try { fn(); } catch (e) {} }); });
lua.global.set('delay', (sec, fn) => { sched.schedule(sec || 0, () => { try { fn(); } catch (e) {} }); });
// require(ModuleScript) — возвращаем nil, debug.setmetatable перехватит.
lua.global.set('require', (_arg) => undefined);
lua.global.set('tick', () => Date.now() / 1000);
lua.global.set('time', () => Date.now() / 1000);
lua.global.set('elapsedTime', () => Date.now() / 1000);
// 7. print / warn / error — пробрасываем в main как log
lua.global.set('print', (...args) => {
const text = args.map(a => luaToString(a)).join('\t');
send('log', { level: 'info', text });
});
lua.global.set('warn', (...args) => {
const text = args.map(a => luaToString(a)).join('\t');
send('log', { level: 'warn', text });
});
// 8. Enum — упрощённая заглушка для самых популярных enums
const enumTable = {
Material: { Plastic: { Value: 256, Name: 'Plastic' }, Neon: { Value: 784, Name: 'Neon' },
Metal: { Value: 512, Name: 'Metal' }, Glass: { Value: 1024, Name: 'Glass' },
Wood: { Value: 272, Name: 'Wood' }, SmoothPlastic: { Value: 496, Name: 'SmoothPlastic' } },
PartType: { Ball: { Value: 0, Name: 'Ball' }, Block: { Value: 1, Name: 'Block' },
Cylinder: { Value: 2, Name: 'Cylinder' } },
KeyCode: { Space: { Value: 32, Name: 'Space' }, W: { Value: 87, Name: 'W' },
A: { Value: 65, Name: 'A' }, S: { Value: 83, Name: 'S' }, D: { Value: 68, Name: 'D' } },
EasingStyle: { Linear: { Value: 0, Name: 'Linear' }, Quad: { Value: 1, Name: 'Quad' },
Sine: { Value: 5, Name: 'Sine' } },
EasingDirection: { In: { Value: 0, Name: 'In' }, Out: { Value: 1, Name: 'Out' },
InOut: { Value: 2, Name: 'InOut' } },
};
lua.global.set('Enum', enumTable);
return { workspace, game, part_by_id, services, gui_by_id, localPlayer, character, humanoid };
}
function luaToString(v) {
if (v == null) return 'nil';
if (typeof v === 'string') return v;
if (typeof v === 'number') return String(v);
if (typeof v === 'boolean') return String(v);
if (v.toString) return v.toString();
return '<object>';
}
export { RbxVector3, RbxColor3, RbxCFrame, RbxUDim, RbxUDim2, RbxInstance, RbxPart, RbxSignal };

View File

@ -0,0 +1,204 @@
/**
* roblox-tween.js TweenService для Roblox Lua-shim.
*
* Использование в Lua:
* local TS = game:GetService("TweenService")
* local info = TweenInfo.new(2, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
* local tween = TS:Create(part, info, {Position = Vector3.new(0, 10, 0)})
* tween:Play()
* tween.Completed:Connect(function() print("done") end)
*
* Реализация:
* - Все активные tween'ы держатся в этом модуле.
* - На каждом tick() прогрессируется alpha = (now - startTime) / duration.
* - Применяется easing-кривая, и обновляется свойство объекта через __sendFn.
* - При alpha >= 1 fire Completed signal и удаляем tween.
*/
import { RbxSignal, RbxVector3, RbxColor3, RbxCFrame, RbxUDim2 } from './roblox-shim.js';
/* ──────── EasingStyle / Direction ──────── */
const EASING_FNS = {
'Linear': (t) => t,
'Quad': (t) => t * t,
'Cubic': (t) => t * t * t,
'Quart': (t) => t * t * t * t,
'Quint': (t) => t * t * t * t * t,
'Sine': (t) => 1 - Math.cos((t * Math.PI) / 2),
'Bounce': (t) => {
const n1 = 7.5625, d1 = 2.75;
if (t < 1 / d1) return n1 * t * t;
if (t < 2 / d1) { t -= 1.5 / d1; return n1 * t * t + 0.75; }
if (t < 2.5 / d1) { t -= 2.25 / d1; return n1 * t * t + 0.9375; }
t -= 2.625 / d1; return n1 * t * t + 0.984375;
},
'Elastic': (t) => {
if (t === 0) return 0;
if (t === 1) return 1;
return -(2 ** (10 * (t - 1))) * Math.sin((t - 1.1) * 5 * Math.PI);
},
'Back': (t) => t * t * (2.70158 * t - 1.70158),
'Exponential': (t) => t === 0 ? 0 : 2 ** (10 * (t - 1)),
};
function applyDirection(t, direction) {
if (direction === 'In') return t;
if (direction === 'Out') return 1 - (1 - t);
if (direction === 'InOut') {
return t < 0.5 ? t * 2 : (1 - (1 - t) * 2);
}
return t;
}
function easeValue(alpha, style, direction) {
const styleFn = EASING_FNS[style] || EASING_FNS.Linear;
if (direction === 'In') return styleFn(alpha);
if (direction === 'Out') return 1 - styleFn(1 - alpha);
// InOut
if (alpha < 0.5) return styleFn(alpha * 2) / 2;
return 1 - styleFn((1 - alpha) * 2) / 2;
}
/* ──────── TweenInfo ──────── */
class RbxTweenInfo {
constructor(time = 1, easingStyle = 'Quad', easingDirection = 'Out',
repeatCount = 0, reverses = false, delayTime = 0) {
this.Time = +time || 0;
this.EasingStyle = typeof easingStyle === 'object' ? easingStyle.Name : easingStyle;
this.EasingDirection = typeof easingDirection === 'object' ? easingDirection.Name : easingDirection;
this.RepeatCount = repeatCount | 0;
this.Reverses = !!reverses;
this.DelayTime = +delayTime || 0;
}
}
/* ──────── Tween ──────── */
class RbxTween {
constructor(instance, info, goalProps, manager) {
this.Instance = instance;
this.TweenInfo = info;
this.GoalProps = goalProps;
this._manager = manager;
this._startTime = null;
this._fromProps = null;
this._playing = false;
this._completed = false;
this.Completed = new RbxSignal('Completed');
this.PlaybackState = 'Begin';
}
Play() {
if (this._playing) return;
// Снимок старых значений
this._fromProps = {};
for (const k of Object.keys(this.GoalProps)) {
this._fromProps[k] = this.Instance[k]; // через getter Part'а
}
this._startTime = this._manager.time;
this._playing = true;
this.PlaybackState = 'Playing';
this._manager._add(this);
}
Pause() { this._playing = false; this.PlaybackState = 'Paused'; }
Cancel() {
this._playing = false;
this.PlaybackState = 'Cancelled';
this._manager._remove(this);
}
/** internal — вызывается из manager.tick */
_step(now) {
if (!this._playing) return false;
const elapsed = now - this._startTime;
const dur = this.TweenInfo.Time || 0.001;
let alpha = Math.min(1, Math.max(0, elapsed / dur));
const ea = easeValue(alpha, this.TweenInfo.EasingStyle, this.TweenInfo.EasingDirection);
for (const k of Object.keys(this.GoalProps)) {
const from = this._fromProps[k];
const to = this.GoalProps[k];
const interp = interpolate(from, to, ea);
// Set через setter в Part — он отправит partSet в main
try { this.Instance[k] = interp; } catch (e) {}
}
if (alpha >= 1) {
this._playing = false;
this._completed = true;
this.PlaybackState = 'Completed';
this.Completed.Fire('Completed');
return true; // удалить из активных
}
return false;
}
}
function interpolate(from, to, a) {
if (from instanceof RbxVector3 && to instanceof RbxVector3) {
return from.Lerp(to, a);
}
if (from instanceof RbxColor3 && to instanceof RbxColor3) {
return from.Lerp(to, a);
}
if (from instanceof RbxCFrame && to instanceof RbxCFrame) {
return from.Lerp(to, a);
}
if (typeof from === 'number' && typeof to === 'number') {
return from + (to - from) * a;
}
// Иначе ничего не интерполируем
return a >= 1 ? to : from;
}
/* ──────── Manager ──────── */
export class RobloxTweenManager {
constructor() {
this.active = new Set();
this.time = 0;
}
install(lua) {
const self = this;
// TweenInfo конструктор
lua.global.set('TweenInfo', {
new: (time, style, direction, repeat_, reverses, delay_) =>
new RbxTweenInfo(time, style, direction, repeat_, reverses, delay_),
});
// Сервис: добавляем в services через game:GetService('TweenService')
// (services map передаётся в shim — но мы не имеем к нему доступа здесь;
// делаем по-другому: регистрируем сразу глобал TweenService который
// совместим с GetService('TweenService'))
const tweenService = {
ClassName: 'TweenService',
Name: 'TweenService',
Create(instance, info, goalProps) {
return new RbxTween(instance, info, goalProps, self);
},
};
lua.global.set('__tweenService', tweenService);
// и в game.GetService — мы делаем монки-патч если игра уже есть:
const game = lua.global.get('game');
if (game && typeof game.GetService === 'function') {
const origGetService = game.GetService;
game.GetService = function(svc) {
if (svc === 'TweenService') return tweenService;
return origGetService.call(this, svc);
};
}
}
_add(tween) { this.active.add(tween); }
_remove(tween) { this.active.delete(tween); }
tick(dtSec) {
this.time += +dtSec || 0;
for (const t of [...this.active]) {
const done = t._step(this.time);
if (done) this.active.delete(t);
}
}
}
export { RbxTweenInfo, RbxTween };

View File

@ -2,5 +2,8 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App.jsx'; import App from './App.jsx';
import './index.css'; import './index.css';
import { installRemoteDevlog } from './utils/remoteDevlog.js';
installRemoteDevlog();
ReactDOM.createRoot(document.getElementById('root')).render(<App />); ReactDOM.createRoot(document.getElementById('root')).render(<App />);

168
src/utils/remoteDevlog.js Normal file
View File

@ -0,0 +1,168 @@
/**
* remoteDevlog.js клиент удалённого dev-логгера.
*
* Перехватывает: console.error/warn, window.onerror, unhandledrejection,
* все fetch/XHR ошибки и не-2xx ответы; батчит и шлёт на бэкенд.
*
* Запускается только в localhost (dev), на проде no-op.
*/
const IS_DEV = typeof window !== 'undefined'
&& (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
const ENDPOINT = '/api-rbxl/devlog';
const FLUSH_INTERVAL_MS = 1500;
const MAX_BATCH = 50;
const queue = [];
let flushTimer = null;
function push(ev) {
if (!IS_DEV) return;
ev.ts = Date.now();
ev.page = location.pathname + location.search;
queue.push(ev);
if (queue.length >= MAX_BATCH) flush();
else scheduleFlush();
}
function scheduleFlush() {
if (flushTimer) return;
flushTimer = setTimeout(flush, FLUSH_INTERVAL_MS);
}
function flush() {
flushTimer = null;
if (queue.length === 0) return;
const events = queue.splice(0, MAX_BATCH);
try {
const blob = new Blob([JSON.stringify({ events })], { type: 'application/json' });
// sendBeacon — не блокирует, переживёт unload
if (navigator.sendBeacon) {
navigator.sendBeacon(ENDPOINT, blob);
} else {
fetch(ENDPOINT, { method: 'POST', body: blob, headers: { 'Content-Type': 'application/json' }, keepalive: true })
.catch(() => {});
}
} catch (e) { /* swallow */ }
}
function truncate(s, max = 4000) {
if (typeof s !== 'string') {
try { s = JSON.stringify(s); } catch { s = String(s); }
}
return s.length > max ? s.slice(0, max) + '...[truncated]' : s;
}
export function installRemoteDevlog() {
if (!IS_DEV) return;
// 1. console.error / console.warn (но НЕ console.log — слишком шумно)
const origError = console.error.bind(console);
const origWarn = console.warn.bind(console);
console.error = (...args) => {
try { push({ kind: 'console.error', message: truncate(args.map(formatArg).join(' ')) }); } catch {}
origError(...args);
};
console.warn = (...args) => {
try { push({ kind: 'console.warn', message: truncate(args.map(formatArg).join(' ')) }); } catch {}
origWarn(...args);
};
// 2. window.onerror
window.addEventListener('error', (ev) => {
push({
kind: 'window.error',
message: ev.message,
filename: ev.filename,
lineno: ev.lineno,
colno: ev.colno,
stack: ev.error?.stack ? truncate(ev.error.stack) : null,
});
});
// 3. Unhandled promise rejection
window.addEventListener('unhandledrejection', (ev) => {
const reason = ev.reason;
push({
kind: 'unhandledrejection',
message: truncate(reason?.message || String(reason)),
stack: reason?.stack ? truncate(reason.stack) : null,
});
});
// 4. fetch wrapper — логируем все не-2xx и failed
const origFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input?.url || '';
const method = (init?.method || 'GET').toUpperCase();
const t0 = performance.now();
try {
const resp = await origFetch(input, init);
if (!resp.ok) {
let body = '';
try {
const cloned = resp.clone();
body = truncate(await cloned.text(), 2000);
} catch {}
push({
kind: 'fetch.bad',
url,
method,
status: resp.status,
duration_ms: Math.round(performance.now() - t0),
body,
});
}
return resp;
} catch (e) {
push({
kind: 'fetch.fail',
url,
method,
duration_ms: Math.round(performance.now() - t0),
message: e?.message || String(e),
});
throw e;
}
};
// 5. XHR wrapper — для axios и т.п.
const XhrOpen = XMLHttpRequest.prototype.open;
const XhrSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this.__rdl = { method, url, t0: performance.now() };
return XhrOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = function (body) {
this.addEventListener('loadend', () => {
const meta = this.__rdl || {};
const status = this.status;
if (status === 0 || status >= 400) {
push({
kind: 'xhr.bad',
url: meta.url,
method: meta.method,
status,
duration_ms: Math.round(performance.now() - (meta.t0 || performance.now())),
body: truncate(this.responseText || '', 2000),
});
}
});
return XhrSend.call(this, body);
};
// Периодически флушим (для долгоживущих логов)
setInterval(() => { if (queue.length) flush(); }, 5000);
window.addEventListener('beforeunload', flush);
// Стартовая отметка чтобы в логе было видно начало сессии
push({ kind: 'session.start', ua: navigator.userAgent });
}
function formatArg(a) {
if (a == null) return String(a);
if (typeof a === 'string') return a;
if (a instanceof Error) return a.message + (a.stack ? '\n' + a.stack : '');
try { return JSON.stringify(a); } catch { return String(a); }
}

View File

@ -16,13 +16,28 @@ export default defineConfig(({ mode }) => {
const PROXY_TARGET = env.VITE_API_PROXY_TARGET || 'https://dev-api.rublox.pro'; const PROXY_TARGET = env.VITE_API_PROXY_TARGET || 'https://dev-api.rublox.pro';
// Префиксы которые проксируем на бэкенд. // Префиксы которые проксируем на бэкенд.
// Для prod-target (minecraftia-school.ru) — режем напрямую на S2 IP (85.175.6.22),
// т.к. на S1 user-service остался старый JWT_SECRET после ротации 2026-06-04,
// и токены выданные S2 на S1 не валидируются.
const proxyPrefixes = ['/api-user', '/api-storys', '/api-game']; const proxyPrefixes = ['/api-user', '/api-storys', '/api-game'];
const proxy = Object.fromEntries( const isProdTarget = PROXY_TARGET.includes('minecraftia-school.ru');
proxyPrefixes.map((p) => [ const proxyOpts = {
p, target: PROXY_TARGET,
{ target: PROXY_TARGET, changeOrigin: true, secure: true, ws: true }, changeOrigin: true,
]) secure: true,
); ws: true,
};
const proxy = Object.fromEntries(proxyPrefixes.map((p) => [p, proxyOpts]));
// /api-rbxl — отдельный target (VM 130 rbxl-importer на S1).
// В dev: ходим напрямую через CF DNS (proxied=false → 85.175.7.40 → NPM → VM 130).
proxy['/api-rbxl'] = {
target: env.VITE_RBXL_PROXY_TARGET || 'http://api-rbxl.rublox.pro',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/api-rbxl/, ''),
};
// Вся статика (kubikon-assets, assets, wiki, dev-*.json) лежит в public/ —
// vite сама отдаёт. Никаких proxy не нужно. См. setup-public.ps1 если папок нет.
return { return {
plugins: [react()], plugins: [react()],