All checks were successful
Сегодня доведены до играбельного состояния:
- UI модалка импорта подключена в KubikonStudio (кнопка для МИНа в навигации)
- Converter: SCALE 0.35 (карта пропорциональна R15-персонажу),
playerModelType='skin_bacon-hair', Lua упакован в поле code с маркером
// @roblox-lua (storys API сохраняет только {id,code,target,name})
- vite.config: api+статика через rublox.pro/minecraftia-school.ru
- GameRuntime: распознаёт маркер, запускает через RobloxLuaSandbox
+ wasmoon Worker. Фильтрация: target!=null + lua<2500б +
лимит 50 sandbox'ов (WASM OOM при >50 VM)
- roblox-shim: nullStub (Proxy с no-op методами) вместо null
для FindFirstChild/WaitForChild — цепочки не падают
- require() заменён на nullStub
- RobloxLuaSandbox: совместимость с интерфейсом ScriptSandbox
(sendGlobalEvent/SceneSnapshot/etc — no-op заглушки)
- RobloxLuaWorker: pcall обёртка над user-кодом
- remoteDevlog.js + /devlog endpoint: автосбор browser-логов
- PlayerController._loadSkinManifest: dev-fallback на studio.rublox.pro
Тест на Easy Obby:
- 8205 instances → 2245 primitives + 742 Lua-scripts
- 50/742 Lua-VM запущены (KillBrick handlers и т.п.),
151 отфильтровано как admin/chat services, 541 пропущено по памяти
- Скин bacon-hair виден, FPS 20-25
- Сцена играется, можно ходить, прыгать
TODO (следующая итерация):
- Single-VM mode для wasmoon (один Lua-state на 742 скрипта,
убрать WASM OOM)
- Реализовать select/focus в иерархии для импортированных карт
- Touched events от Babylon impostor → Lua-shim сигналы
- Поддержка GUI (ScreenGui/Frame/TextLabel) в конвертере
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
165 lines
6.2 KiB
JavaScript
165 lines
6.2 KiB
JavaScript
/**
|
||
* RobloxLuaSandbox — main-side обёртка над одним RobloxLuaWorker.
|
||
*
|
||
* Использование (по аналогии с ScriptSandbox):
|
||
* const sb = new RobloxLuaSandbox(luaSource, targetPrimitiveId);
|
||
* sb.setOnCommand((cmd, payload) => ...);
|
||
* sb.setInitialScene({primitives: {...}});
|
||
* sb.start();
|
||
* sb.tick(dt, sceneSnap);
|
||
* sb.fireEvent('touched', {primId, otherPrimId});
|
||
* sb.stop();
|
||
*
|
||
* Команды от Worker:
|
||
* { cmd: 'boot' } — Lua-VM запущена
|
||
* { cmd: 'ready' } — top-level код выполнен
|
||
* { cmd: 'log', payload: { level, text } }
|
||
* { cmd: 'partSet', payload: { primId, prop, value } }
|
||
* { cmd: 'partVel', payload: { primId, vx, vy, vz } }
|
||
* { cmd: 'playerCmd', payload: { method, args } }
|
||
* { cmd: 'tweenStart', payload: { ... } }
|
||
* { cmd: 'broadcast', payload: { msg, data } }
|
||
* { cmd: 'spawn', payload: { template, props, parentId } }
|
||
*/
|
||
|
||
let _workerUrl = null;
|
||
|
||
function getWorkerUrl() {
|
||
if (_workerUrl) return _workerUrl;
|
||
// Vite worker syntax — лучше через ?worker импорт; но мы можем
|
||
// динамически генерировать URL для ScriptSandboxWorker-style.
|
||
// Здесь упрощённо: загружаем worker как module через Vite ?worker&inline.
|
||
// Это будет настроено при интеграции в GameRuntime.
|
||
return null;
|
||
}
|
||
|
||
export class RobloxLuaSandbox {
|
||
constructor(luaSource, targetPrimitiveId = null) {
|
||
this.luaSource = luaSource || '';
|
||
this.targetPrimitiveId = targetPrimitiveId;
|
||
this.worker = null;
|
||
this._onCommand = null;
|
||
this._booted = false;
|
||
this._ready = false;
|
||
this._stopped = false;
|
||
this._pendingTicks = [];
|
||
this._pendingEvents = [];
|
||
this._initialScene = null;
|
||
}
|
||
|
||
setOnCommand(cb) { this._onCommand = cb; }
|
||
setInitialScene(snap) { this._initialScene = snap; }
|
||
|
||
/**
|
||
* @param {Worker} worker — экземпляр Worker'а (предоставляется снаружи,
|
||
* так как Vite требует new Worker(new URL(...)) syntax который надо
|
||
* прописать в месте импорта)
|
||
*/
|
||
start(worker) {
|
||
if (this.worker) return;
|
||
if (!worker) throw new Error('RobloxLuaSandbox.start(worker): worker is required');
|
||
|
||
this.worker = worker;
|
||
this.worker.onmessage = (e) => this._handle(e);
|
||
this.worker.onerror = (err) => {
|
||
this._emit('log', { level: 'error', text: `Worker error: ${err.message || err}` });
|
||
};
|
||
this.worker.postMessage({
|
||
cmd: 'init',
|
||
payload: {
|
||
code: this.luaSource,
|
||
target: this.targetPrimitiveId,
|
||
sceneSnap: this._initialScene || { primitives: {} },
|
||
},
|
||
});
|
||
}
|
||
|
||
/** Передать кадр (snap сцены + dt). */
|
||
tick(dt, sceneSnap) {
|
||
if (!this.worker) return;
|
||
if (!this._ready) {
|
||
this._pendingTicks.push({ dt, sceneSnap });
|
||
return;
|
||
}
|
||
try { this.worker.postMessage({ cmd: 'tick', payload: { dt, sceneSnap } }); } catch (e) {}
|
||
}
|
||
|
||
/** Передать событие. */
|
||
fireEvent(kind, args, signalId) {
|
||
if (!this.worker) return;
|
||
if (!this._ready) {
|
||
this._pendingEvents.push({ kind, args, signalId });
|
||
return;
|
||
}
|
||
try { this.worker.postMessage({ cmd: 'event', payload: { kind, args, signalId } }); } catch (e) {}
|
||
}
|
||
|
||
stop() {
|
||
this._stopped = true;
|
||
try { this.worker?.postMessage({ cmd: 'stop' }); } catch (e) {}
|
||
try { this.worker?.terminate(); } catch (e) {}
|
||
this.worker = null;
|
||
}
|
||
|
||
// ── Совместимость с интерфейсом ScriptSandbox (GameRuntime ожидает эти методы) ──
|
||
// Все no-op либо мапятся на fireEvent — наш Worker сам держит state сцены.
|
||
sendSceneSnapshot(_snap) { /* no-op: ничего не делает, наш Worker не использует snapshot напрямую */ }
|
||
sendGuiSnapshot(_snap) { /* no-op */ }
|
||
sendSkinsSnapshot(_snap) { /* no-op */ }
|
||
sendInventorySnapshot(_snap) { /* no-op */ }
|
||
sendTerrainHeightmap(_payload) { /* no-op */ }
|
||
sendGlobalEvent(kind, payload) {
|
||
// Глобальные события (input, hpChange, broadcast) маршрутизируем в наш fireEvent.
|
||
try { this.fireEvent(kind, [payload]); } catch (e) {}
|
||
}
|
||
sendBroadcast(msg, data) {
|
||
try { this.fireEvent('broadcast', [msg, data]); } catch (e) {}
|
||
}
|
||
sendOnTouchEvent(payload) {
|
||
try { this.fireEvent('touched', [payload]); } catch (e) {}
|
||
}
|
||
sendOnTickEvent(dt) {
|
||
try { this.tick(dt, null); } catch (e) {}
|
||
}
|
||
sendTweenDone(payload) {
|
||
try { this.fireEvent('tweenDone', [payload]); } catch (e) {}
|
||
}
|
||
sendSpawnResolved(payload) {
|
||
try { this.fireEvent('spawnResolved', [payload]); } catch (e) {}
|
||
}
|
||
setInitialSelfPosition(_p) { /* no-op */ }
|
||
setModules(_modules) { /* no-op: rbxl Lua не использует наш game.require */ }
|
||
get scriptId() { return this._scriptId; }
|
||
set scriptId(v) { this._scriptId = v; }
|
||
|
||
_handle(ev) {
|
||
if (this._stopped) return;
|
||
const { cmd, payload } = ev.data || {};
|
||
if (cmd === 'boot') {
|
||
this._booted = true;
|
||
return;
|
||
}
|
||
if (cmd === 'ready') {
|
||
this._ready = true;
|
||
// флушим накопленное
|
||
for (const t of this._pendingTicks) {
|
||
try { this.worker.postMessage({ cmd: 'tick', payload: t }); } catch (e) {}
|
||
}
|
||
this._pendingTicks = [];
|
||
for (const e of this._pendingEvents) {
|
||
try { this.worker.postMessage({ cmd: 'event', payload: e }); } catch (e) {}
|
||
}
|
||
this._pendingEvents = [];
|
||
this._emit('ready', null);
|
||
return;
|
||
}
|
||
this._emit(cmd, payload);
|
||
}
|
||
|
||
_emit(cmd, payload) {
|
||
if (this._onCommand) {
|
||
try { this._onCommand(cmd, payload); } catch (e) {}
|
||
}
|
||
}
|
||
}
|