/** * RobloxLuaSandbox — main-side обёртка над одним RobloxLuaWorker. * * Использование (по аналогии с ScriptSandbox): * const sb = new RobloxLuaSandbox(luaSource, targetPrimitiveId); * sb.setOnCommand((cmd, payload) => ...); * sb.setInitialScene({primitives: {...}}); * sb.start(); * sb.tick(dt, sceneSnap); * sb.fireEvent('touched', {primId, otherPrimId}); * sb.stop(); * * Команды от Worker: * { cmd: 'boot' } — Lua-VM запущена * { cmd: 'ready' } — top-level код выполнен * { cmd: 'log', payload: { level, text } } * { cmd: 'partSet', payload: { primId, prop, value } } * { cmd: 'partVel', payload: { primId, vx, vy, vz } } * { cmd: 'playerCmd', payload: { method, args } } * { cmd: 'tweenStart', payload: { ... } } * { cmd: 'broadcast', payload: { msg, data } } * { cmd: 'spawn', payload: { template, props, parentId } } */ let _workerUrl = null; function getWorkerUrl() { if (_workerUrl) return _workerUrl; // Vite worker syntax — лучше через ?worker импорт; но мы можем // динамически генерировать URL для ScriptSandboxWorker-style. // Здесь упрощённо: загружаем worker как module через Vite ?worker&inline. // Это будет настроено при интеграции в GameRuntime. return null; } export class RobloxLuaSandbox { constructor(luaSource, targetPrimitiveId = null) { this.luaSource = luaSource || ''; this.targetPrimitiveId = targetPrimitiveId; this.worker = null; this._onCommand = null; this._booted = false; this._ready = false; this._stopped = false; this._pendingTicks = []; this._pendingEvents = []; this._initialScene = null; } setOnCommand(cb) { this._onCommand = cb; } setInitialScene(snap) { this._initialScene = snap; } /** * @param {Worker} worker — экземпляр Worker'а (предоставляется снаружи, * так как Vite требует new Worker(new URL(...)) syntax который надо * прописать в месте импорта) */ start(worker) { if (this.worker) return; if (!worker) throw new Error('RobloxLuaSandbox.start(worker): worker is required'); this.worker = worker; this.worker.onmessage = (e) => this._handle(e); this.worker.onerror = (err) => { this._emit('log', { level: 'error', text: `Worker error: ${err.message || err}` }); }; this.worker.postMessage({ cmd: 'init', payload: { code: this.luaSource, target: this.targetPrimitiveId, sceneSnap: this._initialScene || { primitives: {} }, }, }); } /** Передать кадр (snap сцены + dt). */ tick(dt, sceneSnap) { if (!this.worker) return; if (!this._ready) { this._pendingTicks.push({ dt, sceneSnap }); return; } try { this.worker.postMessage({ cmd: 'tick', payload: { dt, sceneSnap } }); } catch (e) {} } /** Передать событие. */ fireEvent(kind, args, signalId) { if (!this.worker) return; if (!this._ready) { this._pendingEvents.push({ kind, args, signalId }); return; } try { this.worker.postMessage({ cmd: 'event', payload: { kind, args, signalId } }); } catch (e) {} } stop() { this._stopped = true; try { this.worker?.postMessage({ cmd: 'stop' }); } catch (e) {} try { this.worker?.terminate(); } catch (e) {} this.worker = null; } // ── Совместимость с интерфейсом ScriptSandbox (GameRuntime ожидает эти методы) ── // Все no-op либо мапятся на fireEvent — наш Worker сам держит state сцены. sendSceneSnapshot(_snap) { /* no-op: ничего не делает, наш Worker не использует snapshot напрямую */ } sendGuiSnapshot(_snap) { /* no-op */ } sendSkinsSnapshot(_snap) { /* no-op */ } sendInventorySnapshot(_snap) { /* no-op */ } sendTerrainHeightmap(_payload) { /* no-op */ } sendGlobalEvent(kind, payload) { // Глобальные события (input, hpChange, broadcast) маршрутизируем в наш fireEvent. try { this.fireEvent(kind, [payload]); } catch (e) {} } sendBroadcast(msg, data) { try { this.fireEvent('broadcast', [msg, data]); } catch (e) {} } sendOnTouchEvent(payload) { try { this.fireEvent('touched', [payload]); } catch (e) {} } sendOnTickEvent(dt) { try { this.tick(dt, null); } catch (e) {} } sendTweenDone(payload) { try { this.fireEvent('tweenDone', [payload]); } catch (e) {} } sendSpawnResolved(payload) { try { this.fireEvent('spawnResolved', [payload]); } catch (e) {} } setInitialSelfPosition(_p) { /* no-op */ } setModules(_modules) { /* no-op: rbxl Lua не использует наш game.require */ } get scriptId() { return this._scriptId; } set scriptId(v) { this._scriptId = v; } _handle(ev) { if (this._stopped) return; const { cmd, payload } = ev.data || {}; if (cmd === 'boot') { this._booted = true; return; } if (cmd === 'ready') { this._ready = true; // флушим накопленное for (const t of this._pendingTicks) { try { this.worker.postMessage({ cmd: 'tick', payload: t }); } catch (e) {} } this._pendingTicks = []; for (const e of this._pendingEvents) { try { this.worker.postMessage({ cmd: 'event', payload: e }); } catch (e) {} } this._pendingEvents = []; this._emit('ready', null); return; } this._emit(cmd, payload); } _emit(cmd, payload) { if (this._onCommand) { try { this._onCommand(cmd, payload); } catch (e) {} } } }