studio/src/editor/engine/ScriptSandbox.js
МИН 42be04def9
Some checks failed
CI / Lint (pull_request) Failing after 1m10s
CI / Build (pull_request) Successful in 2m2s
CI / Secret scan (pull_request) Successful in 2m36s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
feat(week4): модальные сцены, кастомные скины игрока и вики-гайды по 4 играм
Задача 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>
2026-05-30 00:50:56 +03:00

240 lines
9.6 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;
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;
}
}
}