/** * 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). // initialScene — первичный snapshot сцены, чтобы findOne() работал // в синхронном теле скрипта на старте (до прихода sceneSnapshot в rAF). this.worker.postMessage({ cmd: 'init', payload: { code: this.code, target: this.target, selfPosition: this._initialSelfPosition || null, modules: this._modules || {}, initialScene: this._initialScene || null, }, }); } /** Установить начальную позицию self до start (для первого render Tick). */ setInitialSelfPosition(p) { this._initialSelfPosition = p; } /** Первичный snapshot сцены (до start) — чтобы findOne работал на старте. */ setInitialScene(snap) { this._initialScene = snap; } /** Установить код модулей для game.require — { 'имя': 'код' }. Вызывать до start(). */ setModules(modules) { this._modules = modules || {}; } _handleMessage(e) { if (this._isStopped) return; const { cmd, payload } = e.data || {}; if (cmd === 'boot') return; 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._pendingSkinsSnapshot) { try { this.worker.postMessage({ cmd: 'skinsSnapshot', payload: this._pendingSkinsSnapshot }); } catch (e) {} this._pendingSkinsSnapshot = 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) {} } /** Задача 07: снапшот скинов — для game.player.getAvailableSkins/getAllSkins. */ sendSkinsSnapshot(snapshot) { if (!this.worker) return; if (!this._isReady) { this._pendingSkinsSnapshot = snapshot; return; } try { this.worker.postMessage({ cmd: 'skinsSnapshot', 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; } } }