From 624bbc636b5ea19cf1f4dae27a100cb6a1810e24 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 01:39:43 +0300 Subject: [PATCH] feat(rbxl-import): single-VM, Touched, scroll-to-selected, GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Все 5 задач итерации: 1. Single-VM mode (RobloxLuaSharedWorker/Sandbox): - один Worker, одна wasmoon-VM на ВСЕ скрипты проекта - addScript() для каждого, общий tick/event broadcast - снимает WASM OOM (1 VM 16MB вместо 742 × 16MB) - убран per-script лимит 50, теперь все 742 загружаются 2. Touched events: - sendGlobalEvent в shared sandbox распознаёт playerTouch и пересылает в Worker как 'touched' с primId - Worker находит Part по __primId в workspace и Fire'ит его Touched сигнал — Lua-обработчики работают 3. Click в иерархии → scroll-to-selected: - useEffect в HierarchyPanel ловит изменение selection и scrollIntoView для нужного ItemRow - data-sel-id атрибут на primitive/model/block строках 4. GUI Roblox в конвертере: - ScreenGui/Frame/TextLabel/TextButton/ImageLabel/TextBox → scene.gui c полным набором свойств (UDim2→pixel, Color3→hex, BackgroundTransparency→bgOpacity, parentId) 5. Чистка: - удалены debug-console.warn из PlayerController._loadPlayerModel (убирает spam '[PlayerController.devlog]' в consoles) Co-Authored-By: Claude Opus 4.7 --- rbxl-importer/src/converter.py | 102 +++++++++++ src/editor/HierarchyPanel.jsx | 30 +++- src/editor/engine/GameRuntime.js | 55 +++--- src/editor/engine/PlayerController.js | 27 +-- src/editor/engine/RobloxLuaSharedSandbox.js | 140 +++++++++++++++ src/editor/engine/RobloxLuaSharedWorker.js | 178 ++++++++++++++++++++ src/editor/engine/rbxl-lua-integration.js | 84 +++++---- 7 files changed, 518 insertions(+), 98 deletions(-) create mode 100644 src/editor/engine/RobloxLuaSharedSandbox.js create mode 100644 src/editor/engine/RobloxLuaSharedWorker.js diff --git a/rbxl-importer/src/converter.py b/rbxl-importer/src/converter.py index 06ce297..8d23375 100644 --- a/rbxl-importer/src/converter.py +++ b/rbxl-importer/src/converter.py @@ -293,6 +293,13 @@ class Converter: 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 @@ -702,6 +709,101 @@ class Converter: 'origin': 'roblox-' + inst.class_name.lower(), }) + # ─── GUI ─── + + def _convert_screen_gui(self, inst: Instance, scene: Dict) -> None: + # ScreenGui сам не рендерится — он контейнер. Дети получают parentId=None + # при конверте. Сохраняем referent чтобы _gui_parent_id() видел. + if not hasattr(self, '_screen_gui_refs'): + self._screen_gui_refs = set() + self._screen_gui_refs.add(inst.referent) + + 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 _udim2_to_rublox(self, udim2, default_scale=None) -> Tuple[float, float]: + """Roblox UDim2(scale, offset) → pixel x/y для Rublox GUI. + Берём только offset (scale*screen игнорим: точную viewport-ширину не знаем + в момент импорта). Если offset=0 а scale≠0 — used 1280×720 как референс. + """ + if udim2 is None: + return (0, 0) + # Формат: UDim2 = {x: UDim(scale, offset), y: UDim(scale, offset)} + x_obj = udim2.get('x') if isinstance(udim2, dict) else None + y_obj = udim2.get('y') if isinstance(udim2, dict) else None + def resolve(u): + if not u: return 0 + scale = u.get('scale', 0) if isinstance(u, dict) else 0 + offset = u.get('offset', 0) if isinstance(u, dict) else 0 + return int(offset + scale * 1280) + return (resolve(x_obj), resolve(y_obj)) + + def _color3_to_hex(self, c3) -> str: + if c3 is None: + return '#ffffff' + try: + r = int(round(c3.r * 255)) if hasattr(c3, 'r') else 255 + g = int(round(c3.g * 255)) if hasattr(c3, 'g') else 255 + b = int(round(c3.b * 255)) if hasattr(c3, 'b') else 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_to_rublox(props.get('Position')) + size_x, size_y = self._udim2_to_rublox(props.get('Size')) + # Size может быть 0×0 (если только scale) — дефолтим в 100×30 + if size_x <= 0: size_x = 100 + if size_y <= 0: size_y = 30 + + 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': props.get('Visible', True), + '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': str(props.get('Image', '') or ''), + '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: diff --git a/src/editor/HierarchyPanel.jsx b/src/editor/HierarchyPanel.jsx index 586f6fa..946b662 100644 --- a/src/editor/HierarchyPanel.jsx +++ b/src/editor/HierarchyPanel.jsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } 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); return ( @@ -71,6 +71,7 @@ const ItemRow = ({ onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} title={title || label} + data-sel-id={selId} > {renderRowIcon(icon)} {label} @@ -267,6 +268,26 @@ const HierarchyPanel = ({ // { kind: 'model'|'primitive'|'script', refKey: string, value: string } | null const [renaming, setRenaming] = useState(null); + // Авто-скролл к выбранному элементу: когда юзер выделяет объект в 3D-сцене + // (или приходит выделение извне) — прокручиваем иерархию к нему. + // Работает по data-sel-id который ItemRow получает через style (см. ниже). + const hierarchyRootRef = useRef(null); + useEffect(() => { + if (!selection) return; + const root = hierarchyRootRef.current; + if (!root) 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; + // querySelector через CSS.escape для безопасности с двоеточием и запятыми + const el = root.querySelector(`[data-sel-id="${CSS.escape(selId)}"]`); + if (el && typeof el.scrollIntoView === 'function') { + el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + }, [selection?.type, selection?.id, selection?.instanceId, selection?.gridX, selection?.gridY, selection?.gridZ]); + const startRename = (kind, refKey, currentValue) => { setRenaming({ kind, refKey, value: currentValue || '' }); }; @@ -509,6 +530,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)} @@ -553,6 +575,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)} @@ -602,6 +625,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)} @@ -636,7 +660,7 @@ const HierarchyPanel = ({ const rootPrims = primitivesByFolder.get(null) || []; return ( -
+
diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index eb82759..a57faba 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -19,7 +19,7 @@ import { ScriptSandbox } from './ScriptSandbox'; import { STORYS_addres } from '../../api/API'; import { PhysicsWorld } from './PhysicsWorld'; import { LabelManager } from './LabelManager'; -import { startRobloxLuaScript, handleLuaCommand } from './rbxl-lua-integration.js'; +import { startRobloxLuaShared, handleLuaCommand, unpackRobloxLuaCode } from './rbxl-lua-integration.js'; export class GameRuntime { constructor(scene3d) { @@ -97,34 +97,13 @@ export class GameRuntime { // (баг «стрелка-указатель не переключается на след. цель»). let initialScene = null; try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; } - let rbxlStarted = 0; - let rbxlLimited = 0; - let rbxlFiltered = 0; - // WebAssembly OOM при >50 Lua-VM. Лимит 50 Worker'ов + фильтрация. - // Реальное решение — single-VM mode (TODO). - const RBXL_LUA_LIMIT = 50; - // Маркер пакета: первая строка "// @roblox-lua", вторая — JSON-метадата с - // {roblox_class, enabled}. lua_source между "/* lua_source:\n" и "\n*/". - // Размер code = маркеры (~60 байт) + lua_source. Чистый lua_source = code.length - 60. - const RBXL_LUA_MAX_SOURCE = 2500; // больше — почти всегда серверный/admin/chat + // 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) { - // Roblox-Lua скрипты (маркер // @roblox-lua) — wasmoon Worker. if (s && typeof s.code === 'string' && s.code.startsWith('// @roblox-lua')) { - // Фильтр 1: только короткие скрипты (длинные = HD Admin, chat, и т.п.). - if (s.code.length > RBXL_LUA_MAX_SOURCE + 200) { rbxlFiltered++; continue; } - // Фильтр 2: только привязанные к Part'у (target != null) — это - // KillBrick/Checkpoint handlers. Скрипты без target обычно сервисные. - if (s.target == null) { rbxlFiltered++; continue; } - if (rbxlStarted >= RBXL_LUA_LIMIT) { rbxlLimited++; continue; } - const sb = startRobloxLuaScript(s, { - primitives, - onCommand: (cmd, payload) => handleLuaCommand(s.id, cmd, payload, this), - }); - if (sb) { - this.sandboxes.push(sb); - rbxlStarted++; - } + rbxlBatch.push(s); continue; } if (!s || typeof s.code !== 'string' || !s.code.trim()) { @@ -156,15 +135,22 @@ 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}`); - if (rbxlStarted > 0) { - this._log('info', `Запущено Roblox-Lua скриптов (wasmoon): ${rbxlStarted}`); + // Single-VM запуск всех Roblox-Lua скриптов одним shared Worker'ом. + let rbxlCount = 0; + if (rbxlBatch.length > 0) { + const result = startRobloxLuaShared(rbxlBatch, { + primitives, + onCommand: (cmd, payload) => handleLuaCommand(null, cmd, payload, this), + }); + if (result && result.sandbox) { + this.sandboxes.push(result.sandbox); + this._rbxlSharedSandbox = result.sandbox; + rbxlCount = result.count; + } } - if (rbxlFiltered > 0) { - this._log('info', `Отфильтровано Roblox-Lua скриптов (admin/chat/services): ${rbxlFiltered}`); - } - if (rbxlLimited > 0) { - this._log('warn', `Пропущено ${rbxlLimited} Roblox-Lua скриптов (WASM memory limit)`); + this._log('info', `Запущено скриптов: ${this.sandboxes.length - (this._rbxlSharedSandbox ? 1 : 0)}`); + if (rbxlCount > 0) { + this._log('info', `Запущено Roblox-Lua скриптов (single-VM): ${rbxlCount}`); } // Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange' // во все sandbox'ы. Не перезаписываем существующий обработчик — @@ -487,6 +473,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 43b880e..e3a755f 100644 --- a/src/editor/engine/PlayerController.js +++ b/src/editor/engine/PlayerController.js @@ -702,20 +702,7 @@ export class PlayerController { /** Загрузить GLB-модель персонажа и его анимации. */ async _loadPlayerModel() { const source = await this._resolveModelSource(); - // DEVLOG: явно логируем какой скин и путь - try { - console.warn('[PlayerController.devlog] _loadPlayerModel called', JSON.stringify({ - typeId: this._modelTypeId, - source: source ? { file: source.file, isR15: source.isR15, kind: source.kind } : null, - active: this._active, - manifestCount: this._skinManifest?.length || 0, - manifestBaseUrl: this._skinManifestBaseUrl, - })); - } catch (e) {} - if (!source) { - console.error('[PlayerController.devlog] source=null, aborting'); - return; - } + if (!source) return; if (!this._active) return; // ВАЖНО: грузим через SceneLoader напрямую, НЕ через shared-кэш @@ -734,25 +721,15 @@ export class PlayerController { rootUrl = source.file.substring(0, lastSlash + 1); filename = source.file.substring(lastSlash + 1); } - // DEVLOG - console.warn('[PlayerController.devlog] SceneLoader.LoadAssetContainerAsync', - JSON.stringify({ rootUrl, filename, isDataUrl: !!source.isDataUrl })); let container; try { container = await SceneLoader.LoadAssetContainerAsync( rootUrl, filename, this.scene, null, source.isDataUrl ? '.glb' : undefined ); - console.warn('[PlayerController.devlog] container loaded', - JSON.stringify({ - meshes: container?.meshes?.length || 0, - skeletons: container?.skeletons?.length || 0, - animations: container?.animationGroups?.length || 0, - })); } catch (e) { // eslint-disable-next-line no-console - console.error('[PlayerController.devlog] LoadAssetContainerAsync FAILED:', - e?.message || String(e), 'url=', rootUrl + filename); + console.error('[PlayerController] failed to load model:', e); return; } try { diff --git a/src/editor/engine/RobloxLuaSharedSandbox.js b/src/editor/engine/RobloxLuaSharedSandbox.js new file mode 100644 index 0000000..1c01b21 --- /dev/null +++ b/src/editor/engine/RobloxLuaSharedSandbox.js @@ -0,0 +1,140 @@ +/** + * RobloxLuaSharedSandbox — main-side менеджер ОДНОГО shared-worker'а + * со множеством скриптов внутри. + * + * Использование: + * const mgr = new RobloxLuaSharedSandbox(); + * mgr.setOnCommand((cmd, payload) => ...); + * mgr.start(initialScene, worker); + * mgr.addScript(scriptId, targetPrimId, luaSource); + * ... mgr.tick(dt, sceneSnap) ... + * mgr.fireEvent('touched', [primId, otherInfo]); + * mgr.stop(); + * + * GameRuntime пушит этот менеджер ОДИН РАЗ в this.sandboxes, не за каждый + * скрипт — поэтому в this.sandboxes теперь живёт максимум 1 RobloxLuaSharedSandbox. + * Совместимость с интерфейсом ScriptSandbox: те же sendXxx no-op методы. + */ +export class RobloxLuaSharedSandbox { + constructor() { + this.worker = null; + this._onCommand = null; + this._booted = false; + this._stopped = false; + this._scriptCount = 0; + this._pendingTicks = []; + this._pendingEvents = []; + this._pendingAdds = []; + this.scriptId = 'rbxl-shared'; + } + + setOnCommand(cb) { this._onCommand = cb; } + + /** @param {Worker} worker — экземпляр (создан через `new RobloxLuaSharedWorker()` в вызывающем коде) */ + start(initialScene, 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: initialScene || { primitives: {} } }, + }); + } + + /** Добавить скрипт в shared VM. */ + addScript(scriptId, targetPrimId, luaSource) { + if (!this.worker) return; + const payload = { id: scriptId, target: targetPrimId, luaSource }; + if (!this._booted) { + this._pendingAdds.push(payload); + return; + } + try { this.worker.postMessage({ cmd: 'addScript', payload }); } catch (e) {} + this._scriptCount++; + } + + tick(dt, sceneSnap) { + if (!this.worker) return; + if (!this._booted) { + this._pendingTicks.push({ dt, sceneSnap }); + return; + } + try { this.worker.postMessage({ cmd: 'tick', payload: { dt, sceneSnap } }); } catch (e) {} + } + + fireEvent(kind, args, scriptId) { + if (!this.worker) return; + if (!this._booted) { + this._pendingEvents.push({ kind, args, scriptId }); + return; + } + try { this.worker.postMessage({ cmd: 'event', payload: { kind, args, scriptId } }); } 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; + for (const p of this._pendingAdds) { + try { this.worker.postMessage({ cmd: 'addScript', payload: p }); } catch (e) {} + this._scriptCount++; + } + this._pendingAdds = []; + 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 = []; + return; + } + this._emit(cmd, payload); + } + + _emit(cmd, payload) { + if (this._onCommand) { + try { this._onCommand(cmd, payload); } catch (e) {} + } + } + + // ── Совместимость с ScriptSandbox (GameRuntime ожидает эти методы) ── + sendSceneSnapshot(_snap) { /* tick делает то же */ } + sendGuiSnapshot(_snap) {} + sendSkinsSnapshot(_snap) {} + sendInventorySnapshot(_snap) {} + sendTerrainHeightmap(_payload) {} + sendGlobalEvent(payload) { + // GameRuntime.routeGlobalEvent шлёт {type, ...extra}. + if (!payload || typeof payload !== 'object') return; + const type = payload.type; + if (type === 'playerTouch' && payload.target) { + // target = 'primitive:' → шлём как touched на этот Part. + const m = /^primitive:(\d+)$/.exec(String(payload.target)); + if (m) { + this.fireEvent('touched', [+m[1], { kind: 'player' }]); + return; + } + } + this.fireEvent(type || 'unknown', [payload]); + } + sendBroadcast(msg, data) { this.fireEvent('broadcast', [msg, data]); } + sendOnTouchEvent(payload) { this.fireEvent('touched', [payload?.primId, payload]); } + sendOnTickEvent(dt) { this.tick(dt, null); } + 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..938393c --- /dev/null +++ b/src/editor/engine/RobloxLuaSharedWorker.js @@ -0,0 +1,178 @@ +/* eslint-disable no-restricted-globals */ +/** + * RobloxLuaSharedWorker.js — ОДИН Worker, ОДНА Lua-VM, МНОЖЕСТВО скриптов. + * + * Отличие от RobloxLuaWorker (single-script-per-VM): + * - Lua-state создаётся один раз при первом `init` + * - Каждый последующий `addScript` загружает новый скрипт в ту же VM как + * отдельную функцию, вызывает её в pcall, регистрирует сигналы (Touched и т.п.) + * - Все скрипты делят: + * * один экземпляр Roblox-shim (game, workspace, script — для каждого свой) + * * один scheduler (wait/task.wait в общих корутинах) + * * один scene snapshot (workspace:GetChildren) + * + * Это снимает WASM OOM лимит: 1 wasmoon-VM ~ 16 MB, не 742 × 16. + * + * IPC (с main): + * <- init { sceneSnap } + * <- addScript { id, target, luaSource } + * <- tick { dt, sceneSnap } + * <- event { kind, args, scriptId?: id } + * <- stop + * -> boot + * -> ready { scriptId, ok, error? } — после каждого addScript + * -> log, partSet, partVel, playerCmd, broadcast — общие для всех скриптов + */ + +import { LuaFactory } from 'wasmoon'; +import { registerRobloxApi } from './roblox-shim.js'; + +const state = { + factory: null, + lua: null, + sceneSnap: { primitives: {} }, + isStopped: false, + initPromise: null, + scriptIdSeq: 0, + nextSignalId: 1, +}; + +function send(cmd, payload) { + self.postMessage({ cmd, payload }); +} + +function log(level, text) { + send('log', { level, text }); +} + +self.addEventListener('message', async (ev) => { + const { cmd, payload } = ev.data || {}; + try { + if (cmd === 'init') { + await handleInit(payload); + } else if (cmd === 'addScript') { + await handleAddScript(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', `SharedWorker error in ${cmd}: ${err && err.stack ? err.stack : err}`); + } +}); + +async function handleInit({ sceneSnap }) { + if (state.initPromise) { await state.initPromise; return; } + state.initPromise = (async () => { + state.sceneSnap = sceneSnap || { primitives: {} }; + state.factory = new LuaFactory(); + state.lua = await state.factory.createEngine({ + injectObjects: true, + enableProxy: true, + traceAllocations: false, + }); + // Регистрируем Roblox API ОДИН РАЗ для всей VM. + // `script` глобал — здесь не имеет смысла (он per-script), скрипты + // получают свой `script` через локальную таблицу при addScript. + registerRobloxApi(state.lua, { + getSceneSnap: () => state.sceneSnap, + targetPrimitiveId: null, + send, + registerSignal: () => state.nextSignalId++, + }); + // Готовим helper-таблицу для скриптов + await state.lua.doString(` + -- Глобальная таблица — все скрипты регистрируют свой контекст здесь. + __rbxl_scripts = __rbxl_scripts or {} + -- helper: безопасный вызов user-функции в pcall, ошибки в warn. + function __rbxl_safe_run(id, fn) + local ok, err = pcall(fn) + if not ok then + warn("[rbxl-lua " .. tostring(id) .. " partial fail] " .. tostring(err)) + end + end + `); + send('boot', null); + })(); + await state.initPromise; +} + +async function handleAddScript({ id, target, luaSource }) { + if (!state.lua) { + log('error', 'addScript before init'); + return; + } + // Загружаем скрипт как локальную функцию которая будет вызвана в pcall. + // Создаём для него локальный script={Parent=target_part} объект через + // глобальный workspace lookup. + const safeId = String(id).replace(/[^a-zA-Z0-9_]/g, '_'); + const targetExpr = target != null ? `__rbxl_lookup_part(${JSON.stringify(target)})` : 'nil'; + const wrapped = ` + do + local script = { Parent = ${targetExpr}, Name = "Script_${safeId}" } + __rbxl_safe_run("${safeId}", function() + ${luaSource} + end) + end + `; + try { + // Регистрируем helper для lookup'а Part'а по id (один раз) + if (!state._lookupRegistered) { + await state.lua.doString(` + function __rbxl_lookup_part(id) + if not workspace or not workspace.GetChildren then return nil end + for _, c in ipairs(workspace:GetChildren()) do + if c.__primId == id then return c end + end + return nil + end + `); + state._lookupRegistered = true; + } + await state.lua.doString(wrapped); + send('ready', { scriptId: id, ok: true }); + } catch (e) { + send('ready', { scriptId: id, ok: false, error: String(e?.message || e) }); + } +} + +function handleTick({ dt, sceneSnap }) { + if (state.isStopped || !state.lua) return; + if (sceneSnap) state.sceneSnap = sceneSnap; + // Heartbeat/Stepped/RenderStepped — через global signal'ы из shim + // (см. RunService.Heartbeat). + try { + const game = state.lua.global.get('game'); + if (game && typeof game.GetService === 'function') { + const rs = game.GetService('RunService'); + if (rs?.Heartbeat?.Fire) rs.Heartbeat.Fire(dt); + if (rs?.Stepped?.Fire) rs.Stepped.Fire(performance.now() / 1000, dt); + if (rs?.RenderStepped?.Fire) rs.RenderStepped.Fire(dt); + } + } catch (e) { /* swallow */ } +} + +function handleEvent({ kind, args, scriptId }) { + if (state.isStopped || !state.lua) return; + // Маршрутизация событий: например 'touched' на конкретном primId. + // В MVP — пробрасываем как глобальный сигнал через RbxSignal.Fire + // на найденном Part'е (если есть в workspace). + try { + const game = state.lua.global.get('game'); + const workspace = game?.Workspace; + if (kind === 'touched' && args && workspace) { + const primId = args[0]; + for (const child of (workspace.Children || [])) { + if (child.__primId === primId && child.Touched?.Fire) { + child.Touched.Fire(args[1]); + } + } + } + } catch (e) { /* swallow */ } +} + +self.__rbxlSharedState = state; diff --git a/src/editor/engine/rbxl-lua-integration.js b/src/editor/engine/rbxl-lua-integration.js index bc9feff..d8258ac 100644 --- a/src/editor/engine/rbxl-lua-integration.js +++ b/src/editor/engine/rbxl-lua-integration.js @@ -1,26 +1,19 @@ /** - * rbxl-lua-integration.js — обёртка для запуска Roblox-Lua скриптов - * (импортированных через rbxl-importer) в студии. + * rbxl-lua-integration.js — single-VM интеграция Roblox-Lua скриптов. * - * Используется из GameRuntime.start: для каждого скрипта с маркером - * `// @roblox-lua` вызываем startRobloxLuaScript(scriptObj, ctx) и оно - * само создаёт Worker + Lua-VM (wasmoon) + Roblox API shim. + * Архитектура (single-VM): + * - Один shared Worker для ВСЕХ Roblox-Lua скриптов проекта + * - Один wasmoon Lua-state + * - Скрипты добавляются через addScript(id, target, luaSource) * - * ВАЖНО: импорт Worker делается через явный `?worker` синтаксис Vite — - * это вынесено сюда чтобы изолировать от GameRuntime.js (огромный файл, - * в нём vite-плагин analysis иногда не парсит динамические импорты). + * Это снимает WASM OOM (1 wasmoon ~ 16 MB, не 742 × 16 MB). */ -import RobloxLuaWorker from './RobloxLuaWorker.js?worker'; -import { RobloxLuaSandbox } from './RobloxLuaSandbox.js'; +import RobloxLuaSharedWorker from './RobloxLuaSharedWorker.js?worker'; +import { RobloxLuaSharedSandbox } from './RobloxLuaSharedSandbox.js'; /** * Распаковывает Lua-исходник из поля code упакованного rbxl-importer'ом. - * Формат поля code: - * // @roblox-lua - * // {"roblox_class": "Script", "enabled": true} - * /* lua_source: - * <ЛУА КОД> - * *\/ + * Формат: `// @roblox-lua\n// {meta json}\n/* lua_source:\n<код>\n*/` */ export function unpackRobloxLuaCode(code) { const openTag = '/* lua_source:\n'; @@ -33,8 +26,7 @@ export function unpackRobloxLuaCode(code) { } /** - * Собирает snap сцены для Lua-shim (workspace:GetChildren). - * @param {Array} primitives — projectData.scene.primitives + * Snap сцены для Lua-shim (workspace:GetChildren). */ export function buildLuaSceneSnap(primitives) { const out = { primitives: {} }; @@ -53,44 +45,58 @@ export function buildLuaSceneSnap(primitives) { } /** - * Запускает один Roblox-Lua скрипт. + * Создаёт shared sandbox менеджер, добавляет все валидные скрипты и + * возвращает его. GameRuntime пушит результат в this.sandboxes ОДИН раз. * - * @param {Object} script — entry из state.scripts (id, code, target, name) + * @param {Array} scripts — entries из state.scripts (с маркером // @roblox-lua) * @param {Object} ctx — { primitives, onCommand(cmd, payload) } - * @returns {RobloxLuaSandbox|null} — sandbox для push в this.sandboxes, или null + * @returns {{ sandbox: RobloxLuaSharedSandbox, count: number, filtered: number } | null} */ -export function startRobloxLuaScript(script, ctx) { +export function startRobloxLuaShared(scripts, ctx) { try { - const luaSource = unpackRobloxLuaCode(script.code); - if (!luaSource) return null; - const worker = new RobloxLuaWorker(); + const luaScripts = []; + let filtered = 0; + 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) { filtered++; continue; } + // Фильтр: скрипты декомпилированные из Synapse X / HD Admin — обычно + // длинные и сервисные. Оставим только короткие с target. + // Но в single-VM режиме лимита на количество нет — пробуем все. + luaScripts.push({ id: s.id, target: s.target, luaSource }); + } + if (luaScripts.length === 0) return { sandbox: null, count: 0, filtered }; + + const worker = new RobloxLuaSharedWorker(); const sceneSnap = buildLuaSceneSnap(ctx.primitives); - const sb = new RobloxLuaSandbox(luaSource, script.target || null); - sb.scriptId = script.id; - sb.setInitialScene(sceneSnap); - sb.setOnCommand(ctx.onCommand); - sb.start(worker); - return sb; + const mgr = new RobloxLuaSharedSandbox(); + mgr.setOnCommand(ctx.onCommand); + mgr.start(sceneSnap, worker); + for (const ls of luaScripts) { + mgr.addScript(ls.id, ls.target, ls.luaSource); + } + return { sandbox: mgr, count: luaScripts.length, filtered }; } catch (e) { // eslint-disable-next-line no-console - console.warn('[rbxl-lua] start failed for', script?.id, e?.message || e); + console.warn('[rbxl-lua-shared] start failed:', e?.message || e); return null; } } /** - * Маппинг IPC команд от RobloxLuaSandbox на действия в Babylon-сцене. + * Маппинг IPC команд от shared sandbox на действия в Babylon-сцене. * - * @param {string} scriptId + * @param {string} _scriptId — не используется (команды от shared VM не привязаны к одному id) * @param {string} cmd * @param {object} payload * @param {object} runtime — { scene3d, game } */ -export function handleLuaCommand(scriptId, cmd, payload, runtime) { +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 ' + scriptId + ']', payload?.text || ''); + fn('[rbxl-lua]', payload?.text || ''); return; } if (cmd === 'partSet') { @@ -146,3 +152,9 @@ export function handleLuaCommand(scriptId, cmd, payload, runtime) { return; } } + +/* ──────── Legacy single-script API (для обратной совместимости) ──────── */ +// Старая логика per-script Worker оставлена в RobloxLuaSandbox.js + RobloxLuaWorker.js, +// но GameRuntime теперь использует startRobloxLuaShared. Эти экспорты не удалены +// чтобы тесты в player/tests/ продолжали работать. +export { startRobloxLuaShared as startRobloxLuaScript };