Этап 1 (UI): - Скрипт имеет поле language: 'js' | 'lua' (дефолт 'js') - Переключатель JS / Lua в шапке ScriptEditor (жёлтый / синий) - При смене с пустого/template — подставляется шаблон нового языка - При смене с реальным кодом — confirm - Monaco автоматически переключает подсветку - Badge JS/LUA в HierarchyPanel рядом с именем скрипта Этап 2 (базовый runtime): - LuaSharedSandbox — обёртка с API совместимым с ScriptSandbox - LuaSharedWorker — Web Worker с одним wasmoon-VM на всю игру - RobloxShim — Vector3/Color3/UDim2/Vector2/CFrame, Enum.*, print/warn, wait/task.*, RbxSignal, Instance.new (база), game.GetService (стабы), RunService.Heartbeat - Scheduler для task.delay/defer через main loop tick - GameRuntime разделяет скрипты: JS / Roblox-Lua (импорт) / user-Lua На Этапе 3 — DataModel (game.Workspace + Instance.Parent + Touched). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
216 lines
8.8 KiB
JavaScript
216 lines
8.8 KiB
JavaScript
/**
|
||
* 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; }
|
||
|
||
/** Добавить 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;
|