diff --git a/.WORKTREE_NOTICE.md b/.WORKTREE_NOTICE.md new file mode 100644 index 0000000..8a20bb2 --- /dev/null +++ b/.WORKTREE_NOTICE.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 712f8f0..fb8ee07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f2b175c..ed46f8a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/rbxl-importer/CHANGELOG.md b/rbxl-importer/CHANGELOG.md new file mode 100644 index 0000000..d6185ab --- /dev/null +++ b/rbxl-importer/CHANGELOG.md @@ -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. diff --git a/rbxl-importer/README.md b/rbxl-importer/README.md new file mode 100644 index 0000000..4a27040 --- /dev/null +++ b/rbxl-importer/README.md @@ -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/ │ + 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/` — открывается в студии. +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":"", "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` поле пока заглушка. + +## Авторские права + +Эта тест-фича для **МИНа только**. Юзер подтверждает право использовать содержимое карты при загрузке. +Не открывать для публичных пользователей без юр-проверки. diff --git a/rbxl-importer/sql/001_roblox_assets.sql b/rbxl-importer/sql/001_roblox_assets.sql new file mode 100644 index 0000000..678c1f3 --- /dev/null +++ b/rbxl-importer/sql/001_roblox_assets.sql @@ -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://) + 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/.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; diff --git a/rbxl-importer/src/app.py b/rbxl-importer/src/app.py new file mode 100644 index 0000000..00a113c --- /dev/null +++ b/rbxl-importer/src/app.py @@ -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/" } + — Парсит → конвертит → скачивает ассеты → создаёт запись в 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' 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) diff --git a/rbxl-importer/src/app_devlog.py b/rbxl-importer/src/app_devlog.py new file mode 100644 index 0000000..702a7dd --- /dev/null +++ b/rbxl-importer/src/app_devlog.py @@ -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}) diff --git a/rbxl-importer/src/asset_downloader.py b/rbxl-importer/src/asset_downloader.py new file mode 100644 index 0000000..3e36afc --- /dev/null +++ b/rbxl-importer/src/asset_downloader.py @@ -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//. + converted (для mesh→glb, csg→glb) → /opt/roblox-assets/converted/.glb + +Источник: Roblox AssetDelivery API. + - https://assetdelivery.roblox.com/v1/asset?id= → бинарь (с редиректом на CDN) + - https://assetdelivery.roblox.com/v2/assetId/ → 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' 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}") diff --git a/rbxl-importer/src/asset_proxy.py b/rbxl-importer/src/asset_proxy.py new file mode 100644 index 0000000..51b43ba --- /dev/null +++ b/rbxl-importer/src/asset_proxy.py @@ -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= и проксирует на 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} diff --git a/rbxl-importer/src/converter.py b/rbxl-importer/src/converter.py new file mode 100644 index 0000000..8785b7f --- /dev/null +++ b/rbxl-importer/src/converter.py @@ -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}') diff --git a/rbxl-importer/src/mesh_converter.py b/rbxl-importer/src/mesh_converter.py new file mode 100644 index 0000000..65238ec --- /dev/null +++ b/rbxl-importer/src/mesh_converter.py @@ -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. + + Структура: + \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(' 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(' 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(' 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(' 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}") diff --git a/rbxl-importer/src/rbxl_binreader.py b/rbxl-importer/src/rbxl_binreader.py new file mode 100644 index 0000000..5e9a677 --- /dev/null +++ b/rbxl-importer/src/rbxl_binreader.py @@ -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(' int: + return struct.unpack(' int: + return struct.unpack(' int: + return struct.unpack(' int: + return struct.unpack(' float: + return struct.unpack(' float: + return struct.unpack(' 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(' 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 diff --git a/rbxl-importer/src/rbxl_parser.py b/rbxl-importer/src/rbxl_parser.py new file mode 100644 index 0000000..a0e8f60 --- /dev/null +++ b/rbxl-importer/src/rbxl_parser.py @@ -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' 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(' 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(' 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)}") diff --git a/rbxl-importer/src/rbxl_parser_v0.py b/rbxl-importer/src/rbxl_parser_v0.py new file mode 100644 index 0000000..af16f5f --- /dev/null +++ b/rbxl-importer/src/rbxl_parser_v0.py @@ -0,0 +1,125 @@ +""" +rbxl_parser_v0.py — голый парсер Roblox Binary Level (.rbxl) v0. + +Эта первая итерация — только структура файла: + - проверка signature ( 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(' 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)) diff --git a/rbxl-importer/src/rbxl_types.py b/rbxl-importer/src/rbxl_types.py new file mode 100644 index 0000000..4113753 --- /dev/null +++ b/rbxl-importer/src/rbxl_types.py @@ -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(' 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), + ) diff --git a/src/api/API.js b/src/api/API.js index 6e7d015..8fa4139 100644 --- a/src/api/API.js +++ b/src/api/API.js @@ -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'; diff --git a/src/api/rbxlImporterApi.js b/src/api/rbxlImporterApi.js new file mode 100644 index 0000000..68675f2 --- /dev/null +++ b/src/api/rbxlImporterApi.js @@ -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 (из ) или 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(); +} diff --git a/src/community/KubikonStudio.jsx b/src/community/KubikonStudio.jsx index 8882cbf..65e4d90 100644 --- a/src/community/KubikonStudio.jsx +++ b/src/community/KubikonStudio.jsx @@ -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 = () => { ВИКИ + {/* Импорт Roblox .rbxl — только для МИНа (user_id=1) */} + {getCurrentUserId() === 1 && ( + + )} + setRbxlImportOpen(false)} + currentUserId={getCurrentUserId()} + onCreated={(result) => { + // eslint-disable-next-line no-console + console.log('[rbxl-import] created:', result); + }} + /> +
+
+ + ); + } + + 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 ( +
+
e.stopPropagation()}> +
+

Импорт игры из Roblox

+ +
+

+ Загрузи Roblox-карту в формате .rbxl. Геометрия и Lua-скрипты будут + сконвертированы в проект Rublox. +

+ + {!file && ( +
{ 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', + }} + > +
📦
+
+ Перетащи .rbxl сюда +
+ или кликни чтобы выбрать файл (макс {MAX_SIZE / 1024 / 1024} MB) +
+
+ handleFile(e.target.files?.[0])} + /> +
+ )} + + {file && !report && ( +
+
{file.name} ({(file.size / 1024).toFixed(1)} KB)
+
+ + +
+
+ )} + + {report && ( +
+

Отчёт

+ + + + + + + + + + + +
Файл:{report.filename}
Размер:{(report.size_bytes / 1024).toFixed(1)} KB
Объектов:{report.instance_count}
Классов:{report.class_count}
Создано Part'ов:{report.primitives_created}
GLB-моделей:{report.glb_models_created}
Lua-скриптов:{report.scripts_total}
Ассетов для скачки:{report.assets_to_download}
+ + {report.top_classes?.length > 0 && ( +
+ Что внутри (топ-25 классов) + + + {report.top_classes.slice(0, 25).map((c, i) => ( + + ))} + +
{c.class}{c.count}
+
+ )} + + {report.warnings?.length > 0 && ( +
+ + Предупреждения ({report.warnings.length}) + +
    + {report.warnings.slice(0, 30).map((w, i) =>
  • {w}
  • )} +
+
+ )} + +
+
+ ⚠️ Загружая, ты подтверждаешь право использовать содержимое этой карты. + Не загружай чужие карты без разрешения автора. +
+
+ +
+ + setTitle(e.target.value)} + style={inputStyle} + placeholder="Например: Моя обби-карта" + /> +
+ +
+ + +
+
+ )} + + {error && ( +
+ {error} +
+ )} +
+
+ ); +} + +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, +}; diff --git a/src/editor/HierarchyPanel.jsx b/src/editor/HierarchyPanel.jsx index a39613c..531aaed 100644 --- a/src/editor/HierarchyPanel.jsx +++ b/src/editor/HierarchyPanel.jsx @@ -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} > {renderRowIcon(icon)} {label} @@ -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 ( -
+
diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 5170722..16dfde9 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -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 = []; diff --git a/src/editor/engine/PlayerController.js b/src/editor/engine/PlayerController.js index d94238a..e3a755f 100644 --- a/src/editor/engine/PlayerController.js +++ b/src/editor/engine/PlayerController.js @@ -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: {}, diff --git a/src/editor/engine/RobloxLuaSandbox.js b/src/editor/engine/RobloxLuaSandbox.js new file mode 100644 index 0000000..6a87e77 --- /dev/null +++ b/src/editor/engine/RobloxLuaSandbox.js @@ -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) {} + } + } +} diff --git a/src/editor/engine/RobloxLuaSharedSandbox.js b/src/editor/engine/RobloxLuaSharedSandbox.js new file mode 100644 index 0000000..84cda46 --- /dev/null +++ b/src/editor/engine/RobloxLuaSharedSandbox.js @@ -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) {} +} diff --git a/src/editor/engine/RobloxLuaSharedWorker.js b/src/editor/engine/RobloxLuaSharedWorker.js new file mode 100644 index 0000000..60c16d6 --- /dev/null +++ b/src/editor/engine/RobloxLuaSharedWorker.js @@ -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; diff --git a/src/editor/engine/RobloxLuaWorker.js b/src/editor/engine/RobloxLuaWorker.js new file mode 100644 index 0000000..c58b6f7 --- /dev/null +++ b/src/editor/engine/RobloxLuaWorker.js @@ -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; diff --git a/src/editor/engine/rbxl-lua-integration.js b/src/editor/engine/rbxl-lua-integration.js new file mode 100644 index 0000000..7b6caa9 --- /dev/null +++ b/src/editor/engine/rbxl-lua-integration.js @@ -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; + } +} diff --git a/src/editor/engine/roblox-physics.js b/src/editor/engine/roblox-physics.js new file mode 100644 index 0000000..0962898 --- /dev/null +++ b/src/editor/engine/roblox-physics.js @@ -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) {} + } + } +} diff --git a/src/editor/engine/roblox-scheduler.js b/src/editor/engine/roblox-scheduler.js new file mode 100644 index 0000000..936c181 --- /dev/null +++ b/src/editor/engine/roblox-scheduler.js @@ -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) { + // Корутина завершилась с ошибкой — просто дропаем + } + } + } +} diff --git a/src/editor/engine/roblox-services.js b/src/editor/engine/roblox-services.js new file mode 100644 index 0000000..8ffbfba --- /dev/null +++ b/src/editor/engine/roblox-services.js @@ -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 }; diff --git a/src/editor/engine/roblox-shim.js b/src/editor/engine/roblox-shim.js new file mode 100644 index 0000000..362b0bb --- /dev/null +++ b/src/editor/engine/roblox-shim.js @@ -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 ''; +} + +export { RbxVector3, RbxColor3, RbxCFrame, RbxUDim, RbxUDim2, RbxInstance, RbxPart, RbxSignal }; diff --git a/src/editor/engine/roblox-tween.js b/src/editor/engine/roblox-tween.js new file mode 100644 index 0000000..4c55fd6 --- /dev/null +++ b/src/editor/engine/roblox-tween.js @@ -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 }; diff --git a/src/main.jsx b/src/main.jsx index 3d44b86..d9325f6 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -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(); diff --git a/src/utils/remoteDevlog.js b/src/utils/remoteDevlog.js new file mode 100644 index 0000000..5f9e7d1 --- /dev/null +++ b/src/utils/remoteDevlog.js @@ -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); } +} diff --git a/vite.config.js b/vite.config.js index 2bffded..ef0ac3f 100644 --- a/vite.config.js +++ b/vite.config.js @@ -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()],