studio/src/editor/engine/lua/LuaSharedSandbox.js
min 06df77cc97 feat(lua): этапы 1+2 — Lua-скрипты в Рублоксе
Этап 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>
2026-06-08 09:57:12 +03:00

216 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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