Some checks failed
Задача 04 — модальные сцены (затемнение + GUI/3D-анимация + блок ввода): - engine/ModalManager.js (state-машина, fade, spotlight-проекция, HighlightLayer) - editor/ModalOverlay.jsx (CSS-overlay + mask-image для spotlight) - PlayerController.setInputBlocked/setCameraFrozen/captureCameraState/focusOnTarget - game.modal.open/close/update/isOpen/onClose + пресеты bossIntro/lootbox/dialog/confirmation - Фиксы ядра: клик GUI-кнопок (realId↔localId), modalOpened через globalEvent, guard от двойного срабатывания колбэков, кнопка ✓ на последней строке диалога Задача 07 — кастомные скины игрока + магазин: - non-humanoid скины (любая 3D-модель): загрузчик, процедурная анимация, настраиваемый AABB, центрирование через pivot-node - PlayerController.reloadSkin (смена скина в Play) - game.player.setSkin/unlockSkin/getAvailableSkins/onSkinChange/openSkinShop + локальная валюта - editor/SkinShopOverlay.jsx (встроенный магазин, клавиша B) + SkinManagerModal.jsx (вкладка «Скины») - сериализация scene.skins, кнопка «Скины» в тулбаре Вики — категория «Разбор готовых игр»: - docsGames.js: группа g5 + 4 карточки (Двор/Витрина GUI/Сундук/Парк) - docsLessons.jsx: 4 подробные статьи-урока для детей - «Открыть мою копию» создаёт копию проекта (оригинал не трогается) Адаптивная ширина кнопки billboard под длинный текст. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
240 lines
9.6 KiB
JavaScript
240 lines
9.6 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;
|
||
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;
|
||
}
|
||
}
|
||
}
|