Open-source web player for Rublox games, dual-licensed under AGPL-3.0 + Commercial. Highlights: - Babylon.js 7 + React 18 + Vite 5 stack - Self-contained engine (~46k lines): BlockManager, ModelManager, PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD gamemodes - Configurable backend via VITE_API_BASE and friends — works against staging (dev-api.rublox.pro) out of the box - Standalone mode (VITE_STANDALONE=true) loads a bundled sample game for first-run without any backend - Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG - Lint + format scaffolding (ESLint + Prettier + EditorConfig) - Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md - Issue templates: bug_report, feature_request, security_disclosure Removed before public release: - frontend_deploy.py (contained production SSH credentials) - ~27 admin endpoints (kept in private repo) - Hard-coded internal URLs and IPs - All previous git history (clean repo init)
226 lines
9.0 KiB
JavaScript
226 lines
9.0 KiB
JavaScript
/**
|
||
* ScriptSandbox — main-side обёртка над одним Web Worker'ом, исполняющим
|
||
* пользовательский скрипт.
|
||
*
|
||
* Использование:
|
||
* const sandbox = new ScriptSandbox(code);
|
||
* sandbox.setOnCommand((cmd, payload) => { ... }); // команды от Worker'а
|
||
* sandbox.start(); // запустить
|
||
* sandbox.tick(dt, sceneState); // вызывать каждый кадр
|
||
* sandbox.stop(); // остановить и освободить Worker
|
||
*
|
||
* Команды от Worker'а:
|
||
* { cmd: 'log', payload: { level, text } }
|
||
* { cmd: 'player.teleport', payload: { x, y, z } }
|
||
* { cmd: 'boot', payload: null } — Worker запустился
|
||
* { cmd: 'ready', payload: null } — user-код выполнен (top-level)
|
||
*
|
||
* Защита:
|
||
* - watchdog 1 секунда: если Worker не отвечает на ping → terminate
|
||
* - terminate при остановке гарантированно убивает таймеры/циклы
|
||
*/
|
||
|
||
import { getWorkerSourceUrl } from './ScriptSandboxWorker';
|
||
|
||
let _workerUrl = null;
|
||
|
||
export class ScriptSandbox {
|
||
constructor(code, target = null) {
|
||
this.code = code || '';
|
||
this.target = target; // {kind, ref} — объект-носитель скрипта
|
||
this.worker = null;
|
||
this._onCommand = null;
|
||
this._isReady = false;
|
||
this._isStopped = false;
|
||
this._pendingEvents = []; // события до ready
|
||
}
|
||
|
||
setOnCommand(cb) { this._onCommand = cb; }
|
||
|
||
start() {
|
||
if (this.worker) return;
|
||
if (!_workerUrl) _workerUrl = getWorkerSourceUrl();
|
||
// eslint-disable-next-line no-console
|
||
console.log('[ScriptSandbox] creating Worker, code length:', this.code.length);
|
||
this.worker = new Worker(_workerUrl);
|
||
this.worker.onmessage = (e) => this._handleMessage(e);
|
||
this.worker.onerror = (err) => {
|
||
// eslint-disable-next-line no-console
|
||
console.error('[ScriptSandbox] Worker error', err);
|
||
this._emit('log', {
|
||
level: 'error',
|
||
text: `Ошибка Worker'а: ${err.message || err}`,
|
||
});
|
||
};
|
||
// Передаём код + target (если есть). Worker внутри сам выполнит его в new Function(game, code).
|
||
this.worker.postMessage({
|
||
cmd: 'init',
|
||
payload: {
|
||
code: this.code,
|
||
target: this.target,
|
||
selfPosition: this._initialSelfPosition || null,
|
||
modules: this._modules || {},
|
||
},
|
||
});
|
||
}
|
||
|
||
/** Установить начальную позицию self до start (для первого render Tick). */
|
||
setInitialSelfPosition(p) {
|
||
this._initialSelfPosition = p;
|
||
}
|
||
|
||
/** Установить код модулей для game.require — { 'имя': 'код' }. Вызывать до start(). */
|
||
setModules(modules) {
|
||
this._modules = modules || {};
|
||
}
|
||
|
||
_handleMessage(e) {
|
||
if (this._isStopped) return;
|
||
const { cmd, payload } = e.data || {};
|
||
if (cmd === 'boot') return; // Worker boot, ничего не делаем
|
||
if (cmd === 'ready') {
|
||
this._isReady = true;
|
||
// Доставим pending snapshot'ы (приходили до ready)
|
||
if (this._pendingSceneSnapshot) {
|
||
try { this.worker.postMessage({ cmd: 'sceneSnapshot', payload: this._pendingSceneSnapshot }); } catch (e) {}
|
||
this._pendingSceneSnapshot = null;
|
||
}
|
||
if (this._pendingGuiSnapshot) {
|
||
try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: this._pendingGuiSnapshot }); } catch (e) {}
|
||
this._pendingGuiSnapshot = null;
|
||
}
|
||
if (this._pendingTerrainHM) {
|
||
try { this.worker.postMessage({ cmd: 'terrainHeightmap', payload: this._pendingTerrainHM }); } catch (e) {}
|
||
this._pendingTerrainHM = null;
|
||
}
|
||
if (this._pendingDataSnapshot) {
|
||
try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: this._pendingDataSnapshot }); } catch (e) {}
|
||
this._pendingDataSnapshot = null;
|
||
}
|
||
// Доставим события которые пришли до готовности
|
||
if (this._pendingEvents.length > 0) {
|
||
for (const ev of this._pendingEvents) {
|
||
const isGlobal = ev.__global;
|
||
if (isGlobal) {
|
||
const { __global, ...payload } = ev;
|
||
try { this.worker.postMessage({ cmd: 'globalEvent', payload }); } catch (e) {}
|
||
} else {
|
||
try { this.worker.postMessage({ cmd: 'event', payload: ev }); } catch (e) {}
|
||
}
|
||
}
|
||
this._pendingEvents = [];
|
||
}
|
||
return;
|
||
}
|
||
this._emit(cmd, payload);
|
||
}
|
||
|
||
/**
|
||
* Доставить событие в Worker (например click/touch объекта).
|
||
* Если Worker ещё не ready — буферим.
|
||
*/
|
||
sendEvent(payload) {
|
||
if (!this.worker) return;
|
||
if (!this._isReady) {
|
||
this._pendingEvents.push(payload);
|
||
return;
|
||
}
|
||
try { this.worker.postMessage({ cmd: 'event', payload }); } catch (e) {}
|
||
}
|
||
|
||
/**
|
||
* Глобальное событие (key/click/playerTouch) — доставляется всем sandbox'ам.
|
||
* Отличается от sendEvent тем что Worker роутит его в _global*Handlers.
|
||
*/
|
||
sendGlobalEvent(payload) {
|
||
if (!this.worker) return;
|
||
if (!this._isReady) {
|
||
this._pendingEvents.push({ __global: true, ...payload });
|
||
return;
|
||
}
|
||
try { this.worker.postMessage({ cmd: 'globalEvent', payload }); } catch (e) {}
|
||
}
|
||
|
||
/**
|
||
* Snapshot сцены — отправляется всем sandbox'ам, чтобы game.scene.find/all/getPosition
|
||
* могли работать синхронно.
|
||
*/
|
||
sendSceneSnapshot(snapshot) {
|
||
if (!this.worker) return;
|
||
if (!this._isReady) {
|
||
// Запоминаем последний snapshot — отдадим в _handleMessage когда придёт ready
|
||
this._pendingSceneSnapshot = snapshot;
|
||
return;
|
||
}
|
||
try { this.worker.postMessage({ cmd: 'sceneSnapshot', payload: snapshot }); } catch (e) {}
|
||
}
|
||
|
||
/** Snapshot GUI-элементов — для game.gui.find/get/all и game.self.props (gui). */
|
||
sendGuiSnapshot(snapshot) {
|
||
if (!this.worker) return;
|
||
if (!this._isReady) {
|
||
this._pendingGuiSnapshot = snapshot;
|
||
return;
|
||
}
|
||
try { this.worker.postMessage({ cmd: 'guiSnapshot', payload: snapshot }); } catch (e) {}
|
||
}
|
||
|
||
/** Snapshot атрибутов объектов — для синхронного game.scene.getData. */
|
||
sendDataSnapshot(snapshot) {
|
||
if (!this.worker) return;
|
||
if (!this._isReady) {
|
||
this._pendingDataSnapshot = snapshot;
|
||
return;
|
||
}
|
||
try { this.worker.postMessage({ cmd: 'dataSnapshot', payload: snapshot }); } catch (e) {}
|
||
}
|
||
|
||
/**
|
||
* Карта высот гладкого ландшафта — для game.scene.surfaceY(x,z).
|
||
* Шлётся один раз (террейн не меняется в Play). Формат:
|
||
* { origin:{x,z}, step, cols, rows, heights:Float32Array|number[] }
|
||
*/
|
||
sendTerrainHeightmap(hm) {
|
||
if (!this.worker) return;
|
||
if (!this._isReady) {
|
||
this._pendingTerrainHM = hm;
|
||
return;
|
||
}
|
||
try { this.worker.postMessage({ cmd: 'terrainHeightmap', payload: hm }); } catch (e) {}
|
||
}
|
||
|
||
_emit(cmd, payload) {
|
||
if (this._onCommand) {
|
||
try { this._onCommand(cmd, payload); } catch (e) { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Вызвать onTick-хэндлеры пользователя.
|
||
* sceneState — { player: { position: {x,y,z} } } для зеркалирования в worker.
|
||
*/
|
||
tick(dt, sceneState) {
|
||
if (!this.worker || !this._isReady) return;
|
||
this.worker.postMessage({
|
||
cmd: 'tick',
|
||
payload: { dt, ...sceneState },
|
||
});
|
||
}
|
||
|
||
/** Обновить state в worker без tick (на паузе). */
|
||
syncState(sceneState) {
|
||
if (!this.worker || !this._isReady) return;
|
||
this.worker.postMessage({ cmd: 'state', payload: sceneState });
|
||
}
|
||
|
||
stop() {
|
||
this._isStopped = true;
|
||
this._isReady = false;
|
||
if (this.worker) {
|
||
try { this.worker.postMessage({ cmd: 'stop' }); } catch (e) { /* ignore */ }
|
||
try { this.worker.terminate(); } catch (e) { /* ignore */ }
|
||
this.worker = null;
|
||
}
|
||
}
|
||
}
|