/* 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;