Критфикс:
- LuaSharedSandbox.tick(dt, state) — no-op (GameRuntime.tick крашил)
- LuaSharedSandbox.target (геттер) — null
Monaco IntelliSense для Lua:
- registerCompletionItemProvider('lua') — Vector3.new/Color3.fromRGB/UDim2/CFrame
/Instance.new/game/workspace/script/task.*/print/wait/pcall/etc.
- registerHoverProvider('lua') — документация при наведении на API
- 6 готовых сниппетов: killbrick, teleportpad, coin, heartbeat, playeradded, spinpart
UI:
- ConfirmModal — кастомная модалка вместо window.confirm
- В шапке ScriptEditor при смене языка — наша модалка с правильным стилем
- Esc/Enter, автофокус на confirm-кнопке, blur-фон, поп-ин анимация
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
225 lines
9.3 KiB
JavaScript
225 lines
9.3 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; }
|
||
|
||
/**
|
||
* 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;
|