All checks were successful
Баг: стрелка-указатель 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>
248 lines
10 KiB
JavaScript
248 lines
10 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).
|
||
// 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;
|
||
}
|
||
}
|
||
}
|