player/src/engine/ScriptSandbox.js
МИН 87444ee2c8 Initial public release: Rublox Player v1.0
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)
2026-05-27 23:04:04 +03:00

226 lines
9.0 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.

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