From 9caea93d325bc46283d81ecfcd574fb1aedd337c Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 06:18:55 +0300 Subject: [PATCH] feat(rbxl-import): single-VM Lua runtime + GUI tree + Touched/click events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ITERATION 6 (single-VM rewrite): - RobloxLuaSharedWorker: один wasmoon-state на 742 скрипта (не 742 VM) - Pre-populated Workspace + Player.PlayerGui перед addScripts - На каждом Part — Touched/TouchEnded сигналы; на каждом TextButton — MouseButton1Click/Activated/MouseEnter/Leave; Humanoid с Died/Health - Двухфазный init: addScriptsBatch ВСЕ скрипты → kickoff() с PlayerAdded - wait()/task.wait/task.spawn/task.delay через scheduler+coroutines - guiClick от Rublox-GUI → fireEvent → инстанс.MouseButton1Click.Fire() - playerTouch → part.Touched.Fire(HumanoidRootPart) + humanoid.Touched ITERATION 7 (nullStub compatibility): - debug.setmetatable(nil, ...) + debug.setmetatable(function() end, ...) с полным набором __index/__newindex/__call/__add/__sub/.../__len/__concat - Возврат undefined из FindFirstChild/WaitForChild (вместо JS proxy) - Lua-side __null_stub_singleton с Connect/connect/Wait/Fire (lowercase aliases) - __rbxl_lookup_part через __rbxl_parts_by_id table (не ipairs на JS array) - script.Parent гарантированно не nil (либо реальный Part либо null stub) - RbxSignal: Connect+connect, Wait+wait, Fire+fire, Disconnect+disconnect - SIGNAL_NAMES whitelist расширен: Tool (Selected/Equipped), Remote (OnInvoke), ChatMakeSystemMessage, etc. Converter: - GUI: UDim2 dataclass правильно резолвится (scale*100 + offset/viewport*100) - размеры в процентах (как Rublox-GuiOverlay ожидает) - ScreenGui.Enabled → пропагируется в детей - Эвристика скрыть HD Admin/Chat/CommandBar/TeleportTo модалки - rbxasset:// rbxassetid:// фильтруются на пустой URL Hierarchy: - scroll-to-selected раскрывает workspace+rootPrims+folders перед scroll - data-sel-id на всех ItemRow с rAF×2 timing Известные ограничения: - Synapse X obfuscated скрипты часто всё равно падают (требуют конкретный Roblox-VM) - но debug.setmetatable перехват не даёт скриптам валиться на indexing/arithmetic - реальные пользовательские KillBrick (Touched) теперь работают - GUI кнопки → MouseButton1Click работают Co-Authored-By: Claude Opus 4.7 --- rbxl-importer/src/converter.py | 113 ++++-- src/editor/engine/GameRuntime.js | 3 + src/editor/engine/RobloxLuaSharedSandbox.js | 144 +++---- src/editor/engine/RobloxLuaSharedWorker.js | 408 +++++++++++++++----- src/editor/engine/rbxl-lua-integration.js | 112 +++--- src/editor/engine/roblox-shim.js | 207 +++++++--- 6 files changed, 695 insertions(+), 292 deletions(-) diff --git a/rbxl-importer/src/converter.py b/rbxl-importer/src/converter.py index 9b4b158..8785b7f 100644 --- a/rbxl-importer/src/converter.py +++ b/rbxl-importer/src/converter.py @@ -714,9 +714,14 @@ class Converter: 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: @@ -725,36 +730,44 @@ class Converter: return None # top-level в ScreenGui = parentId=None в Rublox return f'rbx_gui_{parent_ref}' - def _udim2_to_rublox(self, udim2, axis: str = 'x') -> int: - """Roblox UDim2(scale, offset) → pixel размер для Rublox GUI. - Reference viewport: 1280×720 (стандарт Roblox при импорте). - axis='x' → используется ширина 1280, 'y' → высота 720. + 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 udim2 is None: - return 0 - ref = 1280 if axis == 'x' else 720 - if isinstance(udim2, dict): - scale = udim2.get('scale', 0) or 0 - offset = udim2.get('offset', 0) or 0 - return int(offset + scale * ref) - return 0 + 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[int, int]: - """UDim2 = {x: UDim, y: UDim} → (px, py).""" - if not isinstance(udim2, dict): - return (0, 0) - return ( - self._udim2_to_rublox(udim2.get('x'), 'x'), - self._udim2_to_rublox(udim2.get('y'), 'y'), - ) + 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: - 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 + 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' @@ -778,9 +791,14 @@ class Converter: pos_x, pos_y = self._udim2_pair(props.get('Position')) size_x, size_y = self._udim2_pair(props.get('Size')) - # Size может быть 0×0 (если только scale) — дефолтим в 100×30 - if size_x <= 0: size_x = 100 - if size_y <= 0: size_y = 30 + # В процентах. Если размер не указан — дефолт 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 @@ -789,6 +807,47 @@ class Converter: 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, @@ -799,7 +858,7 @@ class Converter: 'w': size_x, 'h': size_y, 'anchor': 'top-left', # Roblox по умолчанию top-left - 'visible': props.get('Visible', True), + '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')), diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index a57faba..c3f8795 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -138,8 +138,11 @@ export class GameRuntime { // 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) { diff --git a/src/editor/engine/RobloxLuaSharedSandbox.js b/src/editor/engine/RobloxLuaSharedSandbox.js index 1c01b21..84cda46 100644 --- a/src/editor/engine/RobloxLuaSharedSandbox.js +++ b/src/editor/engine/RobloxLuaSharedSandbox.js @@ -1,77 +1,62 @@ /** - * RobloxLuaSharedSandbox — main-side менеджер ОДНОГО shared-worker'а - * со множеством скриптов внутри. + * RobloxLuaSharedSandbox — main-side обёртка над одним shared Lua-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(); + * 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, не за каждый - * скрипт — поэтому в this.sandboxes теперь живёт максимум 1 RobloxLuaSharedSandbox. - * Совместимость с интерфейсом ScriptSandbox: те же sendXxx no-op методы. + * GameRuntime пушит ОДИН экземпляр в this.sandboxes. */ export class RobloxLuaSharedSandbox { constructor() { this.worker = null; this._onCommand = null; this._booted = false; + this._scriptsLoaded = false; this._stopped = false; - this._scriptCount = 0; this._pendingTicks = []; this._pendingEvents = []; - this._pendingAdds = []; + this._pendingScripts = null; + this._pendingKickoff = false; this.scriptId = 'rbxl-shared'; } setOnCommand(cb) { this._onCommand = cb; } - /** @param {Worker} worker — экземпляр (создан через `new RobloxLuaSharedWorker()` в вызывающем коде) */ - start(initialScene, worker) { + 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: initialScene || { primitives: {} } }, - }); + this.worker.postMessage({ cmd: 'init', payload: { sceneSnap, guiTree } }); } - /** Добавить скрипт в 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++; + addScriptsBatch(scripts) { + if (!this._booted) { this._pendingScripts = scripts; return; } + try { this.worker.postMessage({ cmd: 'addScripts', payload: { scripts } }); } catch (e) {} } - 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) {} + kickoff() { + if (!this._scriptsLoaded) { this._pendingKickoff = true; return; } + try { this.worker.postMessage({ cmd: 'start' }); } catch (e) {} } - fireEvent(kind, args, scriptId) { + tick(dt) { 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) {} + 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() { @@ -86,17 +71,28 @@ export class RobloxLuaSharedSandbox { 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++; + // флушим pending scripts + if (this._pendingScripts) { + try { this.worker.postMessage({ cmd: 'addScripts', payload: { scripts: this._pendingScripts } }); } catch (e) {} + this._pendingScripts = null; } - this._pendingAdds = []; - for (const t of this._pendingTicks) { - try { this.worker.postMessage({ cmd: 'tick', payload: t }); } catch (e) {} + // 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 (e) {} + try { this.worker.postMessage({ cmd: 'event', payload: e }); } catch (er) {} } this._pendingEvents = []; return; @@ -105,36 +101,50 @@ export class RobloxLuaSharedSandbox { } _emit(cmd, payload) { - if (this._onCommand) { - try { this._onCommand(cmd, payload); } catch (e) {} - } + if (this._onCommand) { try { this._onCommand(cmd, payload); } catch (e) {} } } // ── Совместимость с ScriptSandbox (GameRuntime ожидает эти методы) ── - sendSceneSnapshot(_snap) { /* tick делает то же */ } + sendSceneSnapshot(_snap) {} sendGuiSnapshot(_snap) {} sendSkinsSnapshot(_snap) {} sendInventorySnapshot(_snap) {} sendTerrainHeightmap(_payload) {} sendGlobalEvent(payload) { - // GameRuntime.routeGlobalEvent шлёт {type, ...extra}. if (!payload || typeof payload !== 'object') return; const type = payload.type; + // playerTouch: BabylonScene уже детектит касания → Touched на Part 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; - } + if (m) { this.fireEvent('touched', { primId: +m[1], isPlayer: true }); return; } } - this.fireEvent(type || 'unknown', [payload]); + // 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', [payload?.primId, payload]); } - sendOnTickEvent(dt) { this.tick(dt, null); } - sendTweenDone(payload) { this.fireEvent('tweenDone', [payload]); } - sendSpawnResolved(payload) { this.fireEvent('spawnResolved', [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 index 938393c..60c16d6 100644 --- a/src/editor/engine/RobloxLuaSharedWorker.js +++ b/src/editor/engine/RobloxLuaSharedWorker.js @@ -1,40 +1,51 @@ /* eslint-disable no-restricted-globals */ /** - * RobloxLuaSharedWorker.js — ОДИН Worker, ОДНА Lua-VM, МНОЖЕСТВО скриптов. + * RobloxLuaSharedWorker.js — single-VM Lua-runtime для импортированных Roblox-скриптов. * - * Отличие от 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) + * Архитектура v2 (после ITERATION 5-step rewrite): * - * Это снимает WASM OOM лимит: 1 wasmoon-VM ~ 16 MB, не 742 × 16. + * ФАЗА 1 (boot): создаём wasmoon-VM, регистрируем Roblox API без скриптов. * - * IPC (с main): - * <- init { sceneSnap } - * <- addScript { id, target, luaSource } - * <- tick { dt, sceneSnap } - * <- event { kind, args, scriptId?: id } + * ФАЗА 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 { scriptId, ok, error? } — после каждого addScript - * -> log, partSet, partVel, playerCmd, broadcast — общие для всех скриптов + * -> ready + * -> log/partSet/partVel/playerCmd/broadcast/guiUpdate */ import { LuaFactory } from 'wasmoon'; -import { registerRobloxApi } from './roblox-shim.js'; +import { registerRobloxApi, RbxSignal } from './roblox-shim.js'; const state = { factory: null, lua: null, sceneSnap: { primitives: {} }, + guiTree: [], isStopped: false, initPromise: null, - scriptIdSeq: 0, - nextSignalId: 1, + 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) { @@ -45,55 +56,199 @@ 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 === 'addScript') { - await handleAddScript(payload); - } else if (cmd === 'tick') { - handleTick(payload); - } else if (cmd === 'event') { - handleEvent(payload); - } else if (cmd === 'stop') { + 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', `SharedWorker error in ${cmd}: ${err && err.stack ? err.stack : err}`); + log('error', `Worker error in ${cmd}: ${err && err.stack ? err.stack : err}`); } }); -async function handleInit({ sceneSnap }) { +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, }); - // Регистрируем Roblox API ОДИН РАЗ для всей VM. - // `script` глобал — здесь не имеет смысла (он per-script), скрипты - // получают свой `script` через локальную таблицу при addScript. - registerRobloxApi(state.lua, { + state.api = registerRobloxApi(state.lua, { getSceneSnap: () => state.sceneSnap, + getGuiTree: () => state.guiTree, targetPrimitiveId: null, send, - registerSignal: () => state.nextSignalId++, + scheduler, }); - // Готовим helper-таблицу для скриптов + // Передаём 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(` - -- Глобальная таблица — все скрипты регистрируют свой контекст здесь. - __rbxl_scripts = __rbxl_scripts or {} - -- helper: безопасный вызов user-функции в pcall, ошибки в warn. + __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) .. " partial fail] " .. tostring(err)) + 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); @@ -101,78 +256,125 @@ async function handleInit({ sceneSnap }) { 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; +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, считаем но не валим всё } - await state.lua.doString(wrapped); - send('ready', { scriptId: id, ok: true }); - } catch (e) { - send('ready', { scriptId: id, ok: false, error: String(e?.message || e) }); } + state.scriptCount = ok; + send('ready', { ok, fail }); } -function handleTick({ dt, sceneSnap }) { - if (state.isStopped || !state.lua) return; - if (sceneSnap) state.sceneSnap = sceneSnap; - // Heartbeat/Stepped/RenderStepped — через global signal'ы из shim - // (см. RunService.Heartbeat). +function handleStart() { + state.eventsStarted = true; + // Эмитим Players.PlayerAdded + CharacterAdded чтобы скрипты которые + // делают game.Players.PlayerAdded:Connect(...) получили событие. 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 */ } + 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 handleEvent({ kind, args, scriptId }) { +function handleTick({ dt }) { if (state.isStopped || !state.lua) return; - // Маршрутизация событий: например 'touched' на конкретном primId. - // В MVP — пробрасываем как глобальный сигнал через RbxSignal.Fire - // на найденном Part'е (если есть в workspace). + 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 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]); - } + 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) { /* swallow */ } + } catch (e) { + log('warn', `event ${kind} err: ${e?.message || e}`); + } } self.__rbxlSharedState = state; diff --git a/src/editor/engine/rbxl-lua-integration.js b/src/editor/engine/rbxl-lua-integration.js index 4c07fba..7b6caa9 100644 --- a/src/editor/engine/rbxl-lua-integration.js +++ b/src/editor/engine/rbxl-lua-integration.js @@ -1,33 +1,26 @@ /** - * rbxl-lua-integration.js — single-VM интеграция Roblox-Lua скриптов. + * rbxl-lua-integration.js — single-VM интеграция (v2). * - * Архитектура (single-VM): - * - Один shared Worker для ВСЕХ Roblox-Lua скриптов проекта - * - Один wasmoon Lua-state - * - Скрипты добавляются через addScript(id, target, luaSource) - * - * Это снимает WASM OOM (1 wasmoon ~ 16 MB, не 742 × 16 MB). + * Двухфазная инициализация: + * 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-исходник из поля code упакованного rbxl-importer'ом. - * Формат: "// @roblox-lua\\n// {meta json}\\n/[*] lua_source:\\n<код>\\n[*]/" - */ +/** Распаковка lua_source из packed-кода. */ export function unpackRobloxLuaCode(code) { - const openTag = '/* lua_source:\n'; + 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*/'); + const closeIdx = code.lastIndexOf('\n*' + '/'); if (closeIdx < start) return null; return code.slice(start, closeIdx); } -/** - * Snap сцены для Lua-shim (workspace:GetChildren). - */ +/** Сцена → snap для shim'а (workspace:GetChildren). */ export function buildLuaSceneSnap(primitives) { const out = { primitives: {} }; if (!Array.isArray(primitives)) return out; @@ -45,52 +38,81 @@ export function buildLuaSceneSnap(primitives) { } /** - * Создаёт shared sandbox менеджер, добавляет все валидные скрипты и - * возвращает его. GameRuntime пушит результат в this.sandboxes ОДИН раз. - * - * @param {Array} scripts — entries из state.scripts (с маркером // @roblox-lua) - * @param {Object} ctx — { primitives, onCommand(cmd, payload) } - * @returns {{ sandbox: RobloxLuaSharedSandbox, count: number, filtered: number } | null} + * 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 = []; - 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 режиме лимита на количество нет — пробуем все. + if (!luaSource) continue; luaScripts.push({ id: s.id, target: s.target, luaSource }); } - if (luaScripts.length === 0) return { sandbox: null, count: 0, filtered }; + 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, worker); - for (const ls of luaScripts) { - mgr.addScript(ls.id, ls.target, ls.luaSource); - } - return { sandbox: mgr, count: luaScripts.length, filtered }; + 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] start failed:', e?.message || e); + console.warn('[rbxl-lua-shared v2] start failed:', e?.message || e); return null; } } /** - * Маппинг IPC команд от shared sandbox на действия в Babylon-сцене. - * - * @param {string} _scriptId — не используется (команды от shared VM не привязаны к одному id) - * @param {string} cmd - * @param {object} payload - * @param {object} runtime — { scene3d, game } + * Обработка IPC команд от worker'а — мапим на действия в Babylon-сцене. */ export function handleLuaCommand(_scriptId, cmd, payload, runtime) { if (cmd === 'log') { @@ -119,12 +141,9 @@ export function handleLuaCommand(_scriptId, cmd, payload, runtime) { else if (prop === 'anchored') patch.anchored = value; else if (prop === 'canCollide') patch.canCollide = value; else if (prop === 'opacity') patch.opacity = value; - else if (prop === 'rotation' && value) { - patch.rotationX = value.rx; patch.rotationY = value.ry; patch.rotationZ = value.rz; - } if (typeof pm.applyPatch === 'function') pm.applyPatch(primId, patch); else if (typeof pm.update === 'function') pm.update(primId, patch); - } catch (e) { /* swallow */ } + } catch (e) {} return; } if (cmd === 'partVel') { @@ -151,5 +170,8 @@ export function handleLuaCommand(_scriptId, cmd, payload, runtime) { } catch (e) {} return; } + if (cmd === 'guiUpdate') { + // TODO: scripts setting Visible/Text/Color on GUI → передать в GuiManager + return; + } } - diff --git a/src/editor/engine/roblox-shim.js b/src/editor/engine/roblox-shim.js index d677682..362b0bb 100644 --- a/src/editor/engine/roblox-shim.js +++ b/src/editor/engine/roblox-shim.js @@ -179,20 +179,21 @@ class RbxSignal { this.connections.push(conn); return { Disconnect: () => { conn.connected = false; }, + disconnect: () => { conn.connected = false; }, Connected: () => conn.connected, }; } - Wait() { - // В рамках MVP — Wait не блокирует (т.к. wasmoon без корутин это сложно). - // Реальный Wait появится в 4.7 через task.wait. - return null; - } + // 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 прокси ──────── */ @@ -204,27 +205,51 @@ let _instanceCounter = 1; // game.Players.LocalPlayer:WaitForChild("PlayerGui"):WaitForChild("X").Touched:Connect(fn) // не падали с "attempt to call js_null", когда промежуточный объект не существует. // Все методы возвращают сам _nullStub чтобы цепочка не разорвалась. -const _nullSignal = { - Connect: () => ({ Disconnect: () => {}, Connected: () => false }), - Wait: () => null, - Fire: () => {}, -}; -const _nullStub = new Proxy({ __isNullStub: true, ClassName: 'Nil', Name: 'Nil', Children: [], Parent: null }, { - get(target, prop) { - if (prop in target) return target[prop]; - if (prop === Symbol.toPrimitive || prop === 'toString' || prop === 'toJSON') return () => 'Nil'; - if (prop === Symbol.iterator) return undefined; - // Любой method/прoperty access на nullStub — функция/property которая возвращает stub - return new Proxy(function () { return _nullStub; }, { - get(_, p2) { - // Сигналы (Touched, Changed, ...) — возвращаем null-сигнал - if (typeof p2 === 'string' && /^[A-Z]/.test(p2)) return _nullSignal; - return undefined; - }, - }); +// 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; }, - has() { return true; }, }); +// Известные имена сигналов (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 = {}) { @@ -266,18 +291,18 @@ class RbxInstance { if (c.Name === name) return c; if (recursive) { const found = c.FindFirstChild(name, true); - if (found && found !== _nullStub) return found; + if (found) return found; } } - // Возвращаем null-stub вместо null чтобы цепочки `:WaitForChild():Connect()` - // молча no-op'ались вместо падения с "attempt to call js_null". - return _nullStub; + // Возвращаем 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 _nullStub; + return undefined; } FindFirstAncestor(name) { let p = this.Parent; @@ -285,7 +310,7 @@ class RbxInstance { if (p.Name === name) return p; p = p.Parent; } - return _nullStub; + return undefined; } WaitForChild(name, _timeout) { // В MVP — синхронный поиск без ожидания. В 4.7 через корутины можно ждать. @@ -423,7 +448,7 @@ class RbxPart extends RbxInstance { /* ──────── Главный entry-point: регистрация в Lua-VM ──────── */ export function registerRobloxApi(lua, ctx) { - const { getSceneSnap, targetPrimitiveId, send } = ctx; + const { getSceneSnap, targetPrimitiveId, send, getGuiTree, scheduler } = ctx; // 1. Math classes — как глобалы с .new factory const wrap = (cls) => ({ @@ -469,20 +494,69 @@ export function registerRobloxApi(lua, ctx) { 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); } } - // 3. script — обёртка над текущим скриптом. - const scriptInst = new RbxInstance('LocalScript', { Name: 'Script' }); + // 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); } - lua.global.set('script', scriptInst); // 4. game / game:GetService const services = new Map(); @@ -510,8 +584,35 @@ export function registerRobloxApi(lua, ctx) { const playersService = new RbxInstance('Players', { Name: 'Players' }); playersService.PlayerAdded = new RbxSignal('PlayerAdded'); playersService.PlayerRemoving = new RbxSignal('PlayerRemoving'); - // LocalPlayer заполнит фаза 4.9 - playersService.LocalPlayer = null; + // 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) { @@ -544,25 +645,31 @@ export function registerRobloxApi(lua, ctx) { }, }); - // 6. wait / task / spawn — в фазе 4.7 заменим на корутинные. - // Сейчас — простой busy-wait через setTimeout не работает в Worker (sync). - // Поэтому MVP: wait это no-op, log warning. + // 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) => { - // TODO 4.7: реализовать через корутины + // В корутине: 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) => { try { fn(...args); } catch (e) {} return null; }, - delay: (sec, fn, ...args) => { try { fn(...args); } catch (e) {} return null; }, - defer: (fn, ...args) => { try { fn(...args); } catch (e) {} return null; }, + 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) => { try { fn(); } catch (e) {} }); - lua.global.set('delay', (sec, fn) => { try { fn(); } catch (e) {} }); - // require(ModuleScript) в Roblox-картах — у нас нет реальных модулей, - // возвращаем nullStub, чтобы доступ к полям не падал. Сами модули - // импортируются как отдельные скрипты на верхнем уровне. - lua.global.set('require', (_arg) => _nullStub); + 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); @@ -593,7 +700,7 @@ export function registerRobloxApi(lua, ctx) { }; lua.global.set('Enum', enumTable); - return { workspace, game, part_by_id, services }; + return { workspace, game, part_by_id, services, gui_by_id, localPlayer, character, humanoid }; } function luaToString(v) {