merge main (синхрон перед PR синхрона скриптов)
All checks were successful
All checks were successful
This commit is contained in:
commit
73d7ae4765
13
.WORKTREE_NOTICE.md
Normal file
13
.WORKTREE_NOTICE.md
Normal 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
22
package-lock.json
generated
@ -7,6 +7,7 @@
|
||||
"": {
|
||||
"name": "rublox-studio",
|
||||
"version": "1.0.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@babylonjs/core": "7.54.3",
|
||||
@ -21,7 +22,8 @@
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router-dom": "7.4.0",
|
||||
"socket.io-client": "^4.8.3"
|
||||
"socket.io-client": "^4.8.3",
|
||||
"wasmoon": "^1.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.3.12",
|
||||
@ -1459,6 +1461,12 @@
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@ -53,7 +53,8 @@
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router-dom": "7.4.0",
|
||||
"socket.io-client": "^4.8.3"
|
||||
"socket.io-client": "^4.8.3",
|
||||
"wasmoon": "^1.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.3.12",
|
||||
|
||||
100
rbxl-importer/CHANGELOG.md
Normal file
100
rbxl-importer/CHANGELOG.md
Normal 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
111
rbxl-importer/README.md
Normal 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` поле пока заглушка.
|
||||
|
||||
## Авторские права
|
||||
|
||||
Эта тест-фича для **МИНа только**. Юзер подтверждает право использовать содержимое карты при загрузке.
|
||||
Не открывать для публичных пользователей без юр-проверки.
|
||||
91
rbxl-importer/sql/001_roblox_assets.sql
Normal file
91
rbxl-importer/sql/001_roblox_assets.sql
Normal 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
328
rbxl-importer/src/app.py
Normal 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)
|
||||
65
rbxl-importer/src/app_devlog.py
Normal file
65
rbxl-importer/src/app_devlog.py
Normal 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})
|
||||
382
rbxl-importer/src/asset_downloader.py
Normal file
382
rbxl-importer/src/asset_downloader.py
Normal 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 (для mesh→glb, csg→glb) → /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}")
|
||||
101
rbxl-importer/src/asset_proxy.py
Normal file
101
rbxl-importer/src/asset_proxy.py
Normal 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}
|
||||
979
rbxl-importer/src/converter.py
Normal file
979
rbxl-importer/src/converter.py
Normal 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}')
|
||||
481
rbxl-importer/src/mesh_converter.py
Normal file
481
rbxl-importer/src/mesh_converter.py
Normal 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}")
|
||||
203
rbxl-importer/src/rbxl_binreader.py
Normal file
203
rbxl-importer/src/rbxl_binreader.py
Normal 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
|
||||
425
rbxl-importer/src/rbxl_parser.py
Normal file
425
rbxl-importer/src/rbxl_parser.py
Normal 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)}")
|
||||
125
rbxl-importer/src/rbxl_parser_v0.py
Normal file
125
rbxl-importer/src/rbxl_parser_v0.py
Normal 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))
|
||||
605
rbxl-importer/src/rbxl_types.py
Normal file
605
rbxl-importer/src/rbxl_types.py
Normal 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),
|
||||
)
|
||||
@ -10,6 +10,8 @@ export const USER_addres = BASE + '/api-user';
|
||||
export const ACHIVES_addres = BASE + '/api-achievs';
|
||||
export const COMMENTS_addres = BASE + '/api-comments';
|
||||
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 HELP_addres = BASE + '/api-help';
|
||||
export const PYTHON_addres = BASE + '/api-python';
|
||||
|
||||
66
src/api/rbxlImporterApi.js
Normal file
66
src/api/rbxlImporterApi.js
Normal 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();
|
||||
}
|
||||
@ -14,6 +14,7 @@ import useDeviceType from '../hooks/useDeviceType';
|
||||
import KubikonDesktopOnlyStub from './KubikonDesktopOnlyStub';
|
||||
import PleeseReg from '../components/PleeseReg/PleeseReg';
|
||||
import Icon from '../editor/Icon';
|
||||
import RbxlImportModal from '../components/RbxlImportModal';
|
||||
|
||||
function getCurrentUserId() {
|
||||
try {
|
||||
@ -131,6 +132,7 @@ const KubikonStudio = () => {
|
||||
const [greetName, setGreetName] = useState('');
|
||||
// Поиск по своим играм. searchOpen — раскрыт ли инпут в шапке.
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [rbxlImportOpen, setRbxlImportOpen] = 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>ВИКИ</span>
|
||||
</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>
|
||||
|
||||
<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}>
|
||||
<button
|
||||
className={cl.docsBtn}
|
||||
|
||||
275
src/components/RbxlImportModal.jsx
Normal file
275
src/components/RbxlImportModal.jsx
Normal 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,
|
||||
};
|
||||
@ -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 { getModelType } from './engine/ModelTypes';
|
||||
import { getPrimitiveType } from './engine/PrimitiveTypes';
|
||||
@ -37,7 +37,7 @@ const renderRowIcon = (val) => {
|
||||
const ItemRow = ({
|
||||
icon, label, title, depth = 0, selected, plusItems,
|
||||
onClick, onDoubleClick, onContextMenu, onDragStart, draggable,
|
||||
extraStyle,
|
||||
extraStyle, selId,
|
||||
}) => {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const rowRef = React.useRef(null);
|
||||
@ -80,6 +80,7 @@ const ItemRow = ({
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
title={title || label}
|
||||
data-sel-id={selId}
|
||||
>
|
||||
<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>
|
||||
@ -279,6 +280,53 @@ const HierarchyPanel = ({
|
||||
// { kind: 'model'|'primitive'|'script', refKey: string, value: string } | 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) => {
|
||||
setRenaming({ kind, refKey, value: currentValue || '' });
|
||||
};
|
||||
@ -615,6 +663,7 @@ const HierarchyPanel = ({
|
||||
title={`${type?.name} (${b.gridX}, ${b.gridY}, ${b.gridZ})`}
|
||||
depth={depth}
|
||||
selected={isBlockSelected(b)}
|
||||
selId={`block:${b.gridX},${b.gridY},${b.gridZ}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, { kind: 'block', x: b.gridX, y: b.gridY, z: 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)})`}
|
||||
depth={depth}
|
||||
selected={isModelSelected(m)}
|
||||
selId={`model:${m.instanceId}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, { kind: 'model', id: 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)})`}
|
||||
depth={depth}
|
||||
selected={isPrimitiveSelected(p)}
|
||||
selId={`primitive:${p.id}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, { kind: 'primitive', id: p.id })}
|
||||
onClick={() => onSelectPrimitive?.(p.id)}
|
||||
@ -775,7 +826,7 @@ const HierarchyPanel = ({
|
||||
const rootPrims = primitivesByFolder.get(null) || [];
|
||||
|
||||
return (
|
||||
<div className={cl.hierarchy} onClick={closeContext}>
|
||||
<div className={cl.hierarchy} onClick={closeContext} ref={hierarchyRootRef}>
|
||||
<div className={cl.root}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDropOnRoot}>
|
||||
|
||||
@ -19,6 +19,7 @@ import { ScriptSandbox } from './ScriptSandbox';
|
||||
import { STORYS_addres } from '../../api/API';
|
||||
import { PhysicsWorld } from './PhysicsWorld';
|
||||
import { LabelManager } from './LabelManager';
|
||||
import { startRobloxLuaShared, handleLuaCommand, unpackRobloxLuaCode } from './rbxl-lua-integration.js';
|
||||
|
||||
export class GameRuntime {
|
||||
constructor(scene3d) {
|
||||
@ -112,7 +113,15 @@ export class GameRuntime {
|
||||
// (баг «стрелка-указатель не переключается на след. цель»).
|
||||
let 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) {
|
||||
if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) {
|
||||
rbxlBatch.push(s);
|
||||
continue;
|
||||
}
|
||||
if (!s || typeof s.code !== 'string' || !s.code.trim()) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[GameRuntime] skipping invalid script entry', s);
|
||||
@ -142,7 +151,26 @@ export class GameRuntime {
|
||||
// eslint-disable-next-line no-console
|
||||
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'
|
||||
// во все sandbox'ы. Не перезаписываем существующий обработчик —
|
||||
// оборачиваем его (старый колбэк UI должен продолжать работать).
|
||||
@ -464,6 +492,7 @@ export class GameRuntime {
|
||||
this._physicsWorld = null;
|
||||
}
|
||||
this.sandboxes = [];
|
||||
this._rbxlSharedSandbox = null;
|
||||
this._isRunning = false;
|
||||
this._soloScriptId = null;
|
||||
this._tweens = [];
|
||||
|
||||
@ -638,10 +638,10 @@ export class PlayerController {
|
||||
const json = await resp.json();
|
||||
this._skinManifest = json.skins || [];
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[PlayerController] skins_manifest load failed:', e);
|
||||
this._skinManifest = [];
|
||||
}
|
||||
this._skinManifestBaseUrl = '/kubikon-assets';
|
||||
return this._skinManifest;
|
||||
}
|
||||
|
||||
@ -656,15 +656,11 @@ export class PlayerController {
|
||||
if (typeId.startsWith('skin_')) {
|
||||
const manifest = await this._loadSkinManifest();
|
||||
const entry = manifest.find((s) => s.id === typeId);
|
||||
const baseUrl = this._skinManifestBaseUrl || '/kubikon-assets';
|
||||
if (entry) {
|
||||
// kind определяет систему анимации:
|
||||
// 'r15' → R15-скелет (как раньше)
|
||||
// 'non-humanoid-mesh' → single-mesh, процедурное покачивание
|
||||
// 'non-humanoid-rigged' → свой скелет + встроенные AnimationGroup
|
||||
// Отсутствие kind = 'r15' (обратная совместимость со старым манифестом).
|
||||
const kind = entry.kind || 'r15';
|
||||
return {
|
||||
file: '/kubikon-assets/' + entry.file,
|
||||
file: baseUrl + '/' + entry.file,
|
||||
isR15: kind === 'r15',
|
||||
kind,
|
||||
overrides: entry.overrides || {},
|
||||
@ -673,9 +669,8 @@ export class PlayerController {
|
||||
rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0,
|
||||
};
|
||||
}
|
||||
// нет в манифесте — пробуем прямой путь (старые R15-скины)
|
||||
return {
|
||||
file: `/kubikon-assets/characters/${typeId}/body.glb`,
|
||||
file: `${baseUrl}/characters/${typeId}/body.glb`,
|
||||
isR15: true,
|
||||
kind: 'r15',
|
||||
overrides: {},
|
||||
|
||||
164
src/editor/engine/RobloxLuaSandbox.js
Normal file
164
src/editor/engine/RobloxLuaSandbox.js
Normal 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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
150
src/editor/engine/RobloxLuaSharedSandbox.js
Normal file
150
src/editor/engine/RobloxLuaSharedSandbox.js
Normal 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) {}
|
||||
}
|
||||
380
src/editor/engine/RobloxLuaSharedWorker.js
Normal file
380
src/editor/engine/RobloxLuaSharedWorker.js
Normal 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;
|
||||
180
src/editor/engine/RobloxLuaWorker.js
Normal file
180
src/editor/engine/RobloxLuaWorker.js
Normal 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;
|
||||
177
src/editor/engine/rbxl-lua-integration.js
Normal file
177
src/editor/engine/rbxl-lua-integration.js
Normal 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;
|
||||
}
|
||||
}
|
||||
216
src/editor/engine/roblox-physics.js
Normal file
216
src/editor/engine/roblox-physics.js
Normal 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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
209
src/editor/engine/roblox-scheduler.js
Normal file
209
src/editor/engine/roblox-scheduler.js
Normal 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) {
|
||||
// Корутина завершилась с ошибкой — просто дропаем
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
384
src/editor/engine/roblox-services.js
Normal file
384
src/editor/engine/roblox-services.js
Normal 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 };
|
||||
715
src/editor/engine/roblox-shim.js
Normal file
715
src/editor/engine/roblox-shim.js
Normal 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 };
|
||||
204
src/editor/engine/roblox-tween.js
Normal file
204
src/editor/engine/roblox-tween.js
Normal 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 };
|
||||
@ -2,5 +2,8 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './index.css';
|
||||
import { installRemoteDevlog } from './utils/remoteDevlog.js';
|
||||
|
||||
installRemoteDevlog();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
|
||||
168
src/utils/remoteDevlog.js
Normal file
168
src/utils/remoteDevlog.js
Normal 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); }
|
||||
}
|
||||
@ -16,13 +16,28 @@ export default defineConfig(({ mode }) => {
|
||||
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 proxy = Object.fromEntries(
|
||||
proxyPrefixes.map((p) => [
|
||||
p,
|
||||
{ target: PROXY_TARGET, changeOrigin: true, secure: true, ws: true },
|
||||
])
|
||||
);
|
||||
const isProdTarget = PROXY_TARGET.includes('minecraftia-school.ru');
|
||||
const proxyOpts = {
|
||||
target: PROXY_TARGET,
|
||||
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 {
|
||||
plugins: [react()],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user