/** * LuaSharedSandbox — обёртка над одним wasmoon-Worker для ВСЕХ Lua-скриптов игры. * * Идея: * - Создаётся ОДИН экземпляр на всю игру (а не на скрипт, как ScriptSandbox) * - Все Lua-скрипты добавляются через addScript(id, code, target) * - Worker внутри держит ОДИН wasmoon Lua-state, в котором живут: * * полный Roblox API shim (Vector3, CFrame, Color3, Instance, ...) * * виртуальное DataModel дерево (game.Workspace, Players, ...) * * все скрипты как coroutines (потому что Roblox-Lua так работает) * - При партии команд (partSet/sceneCreate/event/log) — пересылка в main * с тем же интерфейсом что у ScriptSandbox * * Совместимость с GameRuntime: * методы sendEvent / sendGlobalEvent / sendSceneSnapshot / sendGuiSnapshot / * sendDataSnapshot / sendSkinsSnapshot / sendTerrainHeightmap / stop / * setOnCommand — поведение совпадает с ScriptSandbox. * * Отличия: * - addScript(id, code, target) можно вызывать много раз ДО start() — все * скрипты добавляются батчем и потом запускаются вместе. * - После start() можно вызывать addScript() для live-добавления (например, * Instance.new("Script", workspace) с переданным Source). */ import LuaSharedWorker from './LuaSharedWorker.js?worker'; let _ipcId = 0; export class LuaSharedSandbox { constructor() { this.worker = null; this._onCommand = null; this._isReady = false; this._isStopped = false; // Скрипты добавленные до start() — буферизуются, отправляются батчем при start() this._pendingScripts = []; // Снапшоты пришедшие до ready — отправляются после ready this._pendingSceneSnapshot = null; this._pendingGuiSnapshot = null; this._pendingDataSnapshot = null; this._pendingSkinsSnapshot = null; this._pendingTerrainHM = null; } setOnCommand(cb) { this._onCommand = cb; } /** * GameRuntime вызывает sb.tick(dt, state) каждый кадр. * Для JS-sandbox tick шлёт snapshot в worker. Для нас snapshot ходит через * sendSceneSnapshot отдельно — здесь no-op. * NB: target=null, потому что наш sandbox общий, не на конкретный объект. */ get target() { return null; } tick(_dt, _state) { /* no-op: main-loop fires Heartbeat внутри worker */ } /** Добавить Lua-скрипт. Можно вызывать как ДО start() (буфер), так и ПОСЛЕ (live). */ addScript(id, code, target) { const entry = { id: String(id), source: String(code || ''), target: target == null ? null : target, }; if (!this.worker) { this._pendingScripts.push(entry); return; } // Live-добавление после start() try { this.worker.postMessage({ cmd: 'addScript', payload: entry }); } catch (_) {} } /** Удалить Lua-скрипт по id (для случая когда в студии его удалили в Play-mode редко). */ removeScript(id) { if (!this.worker) { this._pendingScripts = this._pendingScripts.filter(s => s.id !== String(id)); return; } try { this.worker.postMessage({ cmd: 'removeScript', payload: { id: String(id) } }); } catch (_) {} } /** * Запустить worker и инициализировать VM. * После start() Lua-runtime готов принимать события и снапшоты. */ start() { if (this.worker) return; // eslint-disable-next-line no-console console.log('[LuaSharedSandbox] starting Lua VM, pending scripts:', this._pendingScripts.length); this.worker = new LuaSharedWorker(); this.worker.onmessage = (e) => this._handleMessage(e); this.worker.onerror = (err) => { // eslint-disable-next-line no-console console.error('[LuaSharedSandbox] Worker error', err); this._emit('log', { level: 'error', text: `Lua-runtime error: ${err.message || err}`, }); }; this.worker.postMessage({ cmd: 'init', payload: { ipcId: ++_ipcId }, }); } _handleMessage(e) { if (this._isStopped) return; const { cmd, payload } = e.data || {}; if (cmd === 'boot') return; if (cmd === 'ready') { this._isReady = true; // Отправляем накопленные скрипты батчем if (this._pendingScripts.length > 0) { try { this.worker.postMessage({ cmd: 'addScriptsBatch', payload: { scripts: this._pendingScripts } }); } catch (_) {} this._pendingScripts = []; } // Отправляем snapshot'ы if (this._pendingSceneSnapshot) { try { this.worker.postMessage({ cmd: 'sceneSnapshot', payload: this._pendingSceneSnapshot }); } catch (_) {} this._pendingSceneSnapshot = null; } if (this._pendingGuiSnapshot) { try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: this._pendingGuiSnapshot }); } catch (_) {} this._pendingGuiSnapshot = null; } if (this._pendingDataSnapshot) { try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: this._pendingDataSnapshot }); } catch (_) {} this._pendingDataSnapshot = null; } if (this._pendingSkinsSnapshot) { try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: this._pendingSkinsSnapshot }); } catch (_) {} this._pendingSkinsSnapshot = null; } if (this._pendingTerrainHM) { try { this.worker.postMessage({ cmd: 'terrainHeightmap', payload: this._pendingTerrainHM }); } catch (_) {} this._pendingTerrainHM = null; } // Запустить главный loop (фаер RunService.Heartbeat/Stepped + резюм coroutines) try { this.worker.postMessage({ cmd: 'kickoff' }); } catch (_) {} return; } // Любая другая команда — прокинуть наружу как partSet/sceneCreate/log/etc // _onCommand обработчик в GameRuntime разруливает их так же как от ScriptSandbox this._emit(cmd, payload); } _emit(cmd, payload) { if (typeof this._onCommand === 'function') { try { this._onCommand({ cmd, payload }); } catch (_) {} } } /** Событие target-attached скрипта (touch/untouch/click/etc). */ sendEvent(payload) { if (!this.worker) return; if (!this._isReady) return; try { this.worker.postMessage({ cmd: 'event', payload }); } catch (_) {} } /** Глобальное событие игры (playerTouch/guiClick/keydown/etc). */ sendGlobalEvent(payload) { if (!this.worker) return; if (!this._isReady) return; try { this.worker.postMessage({ cmd: 'globalEvent', payload }); } catch (_) {} } sendSceneSnapshot(snapshot) { if (!this.worker) { this._pendingSceneSnapshot = snapshot; return; } if (!this._isReady) { this._pendingSceneSnapshot = snapshot; return; } try { this.worker.postMessage({ cmd: 'sceneSnapshot', payload: snapshot }); } catch (_) {} } sendGuiSnapshot(snapshot) { if (!this.worker || !this._isReady) { this._pendingGuiSnapshot = snapshot; return; } try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: snapshot }); } catch (_) {} } sendDataSnapshot(snapshot) { if (!this.worker || !this._isReady) { this._pendingDataSnapshot = snapshot; return; } try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: snapshot }); } catch (_) {} } sendSkinsSnapshot(snapshot) { if (!this.worker || !this._isReady) { this._pendingSkinsSnapshot = snapshot; return; } try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: snapshot }); } catch (_) {} } sendTerrainHeightmap(hm) { if (!this.worker || !this._isReady) { this._pendingTerrainHM = hm; return; } try { this.worker.postMessage({ cmd: 'terrainHeightmap', payload: hm }); } catch (_) {} } stop() { this._isStopped = true; try { this.worker?.terminate(); } catch (_) {} this.worker = null; this._isReady = false; } } export default LuaSharedSandbox;