studio/src/editor/engine/lua/LuaSharedSandbox.js
min 71d2f2db83 fix(lua): tick() в LuaSharedSandbox + autocomplete/hover Lua + ConfirmModal
Критфикс:
- 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>
2026-06-08 10:07:51 +03:00

225 lines
9.3 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; }
/**
* 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;