studio/src/editor/engine/ScriptSandbox.js
МИН 7c928462fc
All checks were successful
CI / Lint (pull_request) Successful in 1m8s
CI / Build (pull_request) Successful in 1m56s
CI / Secret scan (pull_request) Successful in 2m31s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
fix(engine): findOne(x).onTouch работает + findOne на старте скрипта
Баг: стрелка-указатель game.fx.pointer не переключалась на следующую цель —
при касании цель не менялась, стрелка не выключалась.

Первопричина (две движковые проблемы):
1. findOne(x).onTouch(...) не существовал: Instance-proxy не имел методов
   касания, движок ловил touch только объектов со скриптом-target/триггеров.
2. Race: скрипт исполняется синхронно в init, а sceneSnapshot приходил позже
   (rAF) → findOne() на старте = null → подписки onTouch молча не вешались.

Фикс:
- Instance-proxy: + onTouch/onUntouch/onClick → шлёт inst.watchTouch{ref}.
  Worker: _instTouchHandlers + маршрут instTouch/instUntouch/instClick по ref.
- GameRuntime: handler inst.watchTouch/watchClick → _watchedTouchRefs;
  routeInstEvent(ref,type); сброс в teardown.
- BabylonScene._detectTouchEvents: блок watched-объектов (AABB по ref, rising/
  falling edge → routeInstEvent), _refToTarget(ref)→{kind,id},
  _touchState.clear() в enterPlayMode.
- Первичный snapshot сцены передаётся прямо в init
  (ScriptSandbox.setInitialScene → worker заполняет _sceneIndex до userFn) →
  findOne работает в синхронном теле скрипта на старте.

Проверено: телепорт игрока по 3 целям игры 333 — стрелка переключается
red-cube→blue-sphere→gold-chest, на финале удаляется.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 08:28:02 +03:00

248 lines
10 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).
// 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;
}
}
}