studio/src/editor/engine/GameRuntime.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

4098 lines
190 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.

/**
* GameRuntime — управляет всеми пользовательскими скриптами в режиме Play.
*
* Жизненный цикл:
* const rt = new GameRuntime(scene3d);
* rt.setOnLog(({level,text}) => console.log(text));
* rt.start(scripts); // scripts — массив { id, code }
* ... каждый кадр rt.tick(dt) ...
* rt.stop(); // выгрузить всех Worker'ов
*
* Каждый скрипт = отдельный Worker. Команды от Worker'ов обрабатываются здесь
* и применяются к BabylonScene (через player.teleport и т.п.).
*
* Этап 2.1: минимальный API — player.teleport, onTick, log.
*/
import { Color3 } from '@babylonjs/core';
import { ScriptSandbox } from './ScriptSandbox';
import { STORYS_addres } from '../../api/API';
import { PhysicsWorld } from './PhysicsWorld';
import { LabelManager } from './LabelManager';
export class GameRuntime {
constructor(scene3d) {
this.scene3d = scene3d;
/** @type {ScriptSandbox[]} */
this.sandboxes = [];
this._onLog = null;
this._isRunning = false;
// Активные твины (game.tween). Крутятся в tick(dt).
// Каждый: { tweenId, scriptId, ref, props, from, duration, easing,
// delay, repeat, yoyo, elapsed, delayLeft, dir, loopsLeft }
this._tweens = [];
// Атрибуты объектов (game.scene.setData/getData). { ref: { key: value } }.
// Общие для всех скриптов, рассылаются воркерам через dataSnapshot.
this._objectData = {};
// Интерактивные объекты (game.self.onInteract / ProximityPrompt).
// Каждый: { target, text, distance, key }. Заполняется при
// self.registerInteract, проверяется по дистанции в tick.
this._interactables = [];
// ref ближайшего интерактивного объекта в зоне (для подсветки [E]).
this._activeInteractRef = null;
// Общее состояние комнаты для game.room.set/get (Фаза 4.3).
// В редакторе (single-player) — локальное хранилище. С Colyseus-
// комнатой будет синхронизироваться (требует серверной схемы).
this._roomState = {};
// Сессии игроков, которых видели в прошлом tick — для детекта
// join/leave (game.onPlayerJoin / onPlayerLeave).
this._seenSessions = null;
// Команды (Фаза 4.4): name → { name, color }.
this._teams = new Map();
// Команда локального игрока (имя) или null.
this._localPlayerTeam = null;
}
setOnLog(cb) { this._onLog = cb; }
/** Колбэк HUD-команд от скриптов: { cmd, payload }. */
setOnHud(cb) { this._onHud = cb; }
/** Колбэк смены прицела через скрипт: (type) — UI обновляет overlay. */
setOnCrosshairChange(cb) { this._onCrosshair = cb; }
/**
* Запустить все скрипты.
* @param {Array<{id:any, code:string}>} scripts
*/
start(scripts) {
this.stop();
this._isRunning = true;
// eslint-disable-next-line no-console
console.log('[GameRuntime] start called with scripts:', scripts);
// Phase 6.5: lazy-init физ-мира. Wasm-инициализация асинхронная (~50мс),
// тела регистрируются позже при первом game.physics.setBodyType().
// PhysicsWorld остаётся null если ни один скрипт не запросил физику.
this._physicsWorld = null;
if (!Array.isArray(scripts) || scripts.length === 0) {
// eslint-disable-next-line no-console
console.warn('[GameRuntime] start: no scripts to run');
return;
}
// Карта модулей для game.require — { имя_скрипта: код }.
// Любой скрипт проекта можно подключить как модуль по его имени.
const modules = {};
for (const s of scripts) {
if (s && typeof s.name === 'string' && s.name && typeof s.code === 'string') {
modules[s.name] = s.code;
}
}
for (const s of scripts) {
if (!s || typeof s.code !== 'string' || !s.code.trim()) {
// eslint-disable-next-line no-console
console.warn('[GameRuntime] skipping invalid script entry', s);
continue;
}
const sb = new ScriptSandbox(s.code, s.target || null);
sb.scriptId = s.id;
sb.setModules(modules);
// Если target есть — передаём начальную позицию self до старта
if (s.target) {
const pos = this._collectSelfPosition(s.target);
if (pos) sb.setInitialSelfPosition(pos);
}
sb.setOnCommand((cmd, payload) => {
// PERF-METRICS: замер скриптов (postMessage→handle)
const _t0 = performance.now();
this._handleCommand(s.id, cmd, payload);
const m = this.scene3d?._perfMetrics;
if (m) {
m.script_ms_sum += performance.now() - _t0;
m.script_count++;
}
});
sb.start();
this.sandboxes.push(sb);
// eslint-disable-next-line no-console
console.log('[GameRuntime] sandbox started for script id=', s.id);
}
this._log('info', `Запущено скриптов: ${this.sandboxes.length}`);
// Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange'
// во все sandbox'ы. Не перезаписываем существующий обработчик —
// оборачиваем его (старый колбэк UI должен продолжать работать).
try {
const player = this.scene3d?.player;
if (player && !player._gameRuntimeHpHook) {
const prevCb = player._onHpChange;
this._lastSeenHp = player.hp ?? 100;
player._onHpChange = (ev) => {
if (typeof prevCb === 'function') {
try { prevCb(ev); } catch (e) {}
}
const delta = (ev?.hp ?? 0) - (this._lastSeenHp ?? 0);
this._lastSeenHp = ev?.hp ?? 0;
this.routeGlobalEvent('hpChange', {
hp: ev?.hp,
maxHp: ev?.maxHp,
source: ev?.source || null,
damaged: !!ev?.damaged,
delta,
});
};
player._gameRuntimeHpHook = true;
}
// Хуки прыжка/приземления для game.onPlayerJump / game.onPlayerLand
if (player && !player._gameRuntimeMoveHook) {
player._onJump = () => this.routeGlobalEvent('playerJump', {});
player._onLand = () => this.routeGlobalEvent('playerLand', {});
player._gameRuntimeMoveHook = true;
}
// Флаг для детекта смерти (game.onPlayerDied) — проверяется в tick
this._playerWasAlive = (this.scene3d?.player?.hp ?? 100) > 0;
// Хук смерти NPC (game.scene.onNpcDeath / npc.onDeath) — событие
// npcDeath с id и позицией погибшего NPC.
const nm = this.scene3d?.npcManager;
if (nm && typeof nm.setOnDeath === 'function') {
nm.setOnDeath((npcId, position) => {
this.routeGlobalEvent('npcDeath', { npcId, position });
});
}
} catch (e) { /* ignore */ }
// Первичный snapshot — нужен чтобы game.scene.find/all и game.gui.find работали с самого начала.
const sendInitial = () => {
this._broadcastSceneSnapshot();
this._broadcastGuiSnapshot();
this._broadcastTerrainHeightmap();
this._broadcastSkinsSnapshot(); // задача 07
// Задача 03: запустить animationPreset для GUI-элементов с пресетом ≠ 'none'.
this._startGuiAnimationPresets();
};
if (typeof requestAnimationFrame !== 'undefined') {
requestAnimationFrame(sendInitial);
} else {
setTimeout(sendInitial, 16);
}
}
/** Задача 03: запустить пресеты анимаций для GUI-элементов в Play. */
_startGuiAnimationPresets() {
const gm = this.scene3d?.guiManager;
if (!gm) return;
if (!this._guiTweens) this._guiTweens = [];
for (const el of (gm.elements || [])) {
const preset = el.animationPreset;
if (!preset || preset === 'none') continue;
const id = el.id;
// Каждый пресет = одна tween-запись с reverses+repeat=-1
switch (preset) {
case 'pulse':
this._guiTweens.push(this._mkGuiPreset(id, el,
{ scaleX: 1.1, scaleY: 1.1 }, 0.7, 'ease', true, -1));
break;
case 'rotate':
this._guiTweens.push(this._mkGuiPreset(id, el,
{ rotation: (el.rotation || 0) + 360 }, 3, 'linear', false, -1));
break;
case 'sway':
this._guiTweens.push(this._mkGuiPreset(id, el,
{ rotation: (el.rotation || 0) + 8 }, 0.6, 'ease', true, -1));
this._guiTweens[this._guiTweens.length - 1].start.rotation = (el.rotation || 0) - 8;
break;
case 'glow':
this._guiTweens.push(this._mkGuiPreset(id, el,
{ bgOpacity: 0.6 }, 0.8, 'ease', true, -1));
break;
case 'bounce':
this._guiTweens.push(this._mkGuiPreset(id, el,
{ y: (el.y || 50) - 1 }, 0.5, 'ease', true, -1));
break;
}
}
}
_mkGuiPreset(id, el, targetProps, duration, easing, reverses, repeat) {
const start = {};
for (const k of Object.keys(targetProps)) {
if (k === 'scaleX' || k === 'scaleY') start[k] = el[k] || 1;
else if (k === 'rotation') start[k] = el.rotation || 0;
else if (k === 'bgOpacity') start[k] = el.bgOpacity == null ? 1 : el.bgOpacity;
else start[k] = el[k] || 0;
}
return {
tweenId: ++this._tweenSeq || (this._tweenSeq = 1),
scriptId: '__preset__',
realId: id,
start, target: targetProps,
elapsed: 0, delay: 0,
duration, easing,
repeat, reverses, iter: 0, dir: 1,
};
}
/**
* Разослать карту высот гладкого ландшафта всем sandbox'ам.
* Нужно для game.scene.surfaceY(x,z). Снимается raycast'ом по
* реальному мешу один раз — террейн в Play не меняется.
*/
_broadcastTerrainHeightmap() {
const s = this.scene3d;
if (!s || typeof s.exportRobloxHeightmap !== 'function') return;
// Шаг 3м — компромисс: меньше точек (~14K при 360м) чем у зомби
// (там шаг 2), для плавности движения животных достаточно.
let hm;
try {
hm = s.exportRobloxHeightmap(3);
} catch (e) {
return;
}
if (!hm || !hm.heights) return;
const payload = {
origin: hm.origin, step: hm.step,
cols: hm.cols, rows: hm.rows, heights: hm.heights,
};
for (const sb of this.sandboxes) {
sb.sendTerrainHeightmap(payload);
}
}
/**
* Задача 07: разослать снапшот скинов всем sandbox'ам — чтобы
* game.player.getAvailableSkins/getAllSkins работали синхронно.
* Манифест грузится через fetch (кешируется браузером), затем
* объединяется с разблокированными скинами из scene.skins.
*/
async _broadcastSkinsSnapshot() {
try {
this._ensureSkinState();
let manifest = this._skinManifestCache;
if (!manifest) {
const resp = await fetch('/kubikon-assets/characters/skins_manifest.json');
const json = await resp.json();
manifest = (json.skins || []).map(s => ({
slug: s.slug || (s.id || '').replace(/^skin_/, ''),
name: s.name || s.slug,
kind: s.kind || 'r15',
category: s.category || 'human',
price: Number.isFinite(s.price) ? s.price : 0,
}));
// Встроенные «человеки» character-a..g тоже добавим как базовый выбор.
this._skinManifestCache = manifest;
}
const payload = {
all: manifest,
unlocked: Array.from(this._skinState.unlocked),
current: this._skinState.current,
coins: this._skinState.coins,
};
for (const sb of this.sandboxes) sb.sendSkinsSnapshot(payload);
// Также отдать снапшот в scene для React-магазина.
try { this.scene3d?._setSkinShopData?.({ ...payload, manifestFull: this._skinManifestCache }); } catch (e) {}
} catch (e) {
// манифест недоступен — не критично, скрипт получит пустой список
}
}
/**
* Получить позицию объекта по его target (для зеркалирования в worker).
*/
_collectSelfPosition(target) {
if (!target || !this.scene3d) return null;
try {
if (target.kind === 'block') {
const r = target.ref || target;
return { x: r.x, y: r.y + 0.5, z: r.z };
}
if (target.kind === 'model') {
const data = this.scene3d.modelManager?.instances?.get(target.id ?? target.ref);
if (data) return { x: data.x, y: data.y, z: data.z };
}
if (target.kind === 'primitive') {
const data = this.scene3d.primitiveManager?.instances?.get(target.id ?? target.ref);
if (data) return { x: data.x, y: data.y, z: data.z };
}
if (target.kind === 'userModel') {
const data = this.scene3d.userModelManager?.instances?.get(target.id ?? target.ref);
if (data) return { x: data.x, y: data.y, z: data.z };
}
} catch (e) { /* ignore */ }
return null;
}
stop() {
if (this.sandboxes.length > 0) {
this._log('info', 'Остановка скриптов');
// eslint-disable-next-line no-console
console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes');
for (const sb of this.sandboxes) sb.stop();
}
// Удаляем все объекты, которые скрипты наспавнили через
// game.scene.spawn/clone — иначе после Stop они остаются на сцене
// и накапливаются при повторных запусках.
this._cleanupSpawnedObjects();
// Удаляем GUI-элементы, созданные скриптом через game.gui.create —
// иначе после Stop они остаются в интерфейсе сцены.
this._cleanupSpawnedGui();
// Убираем billboard-метки над объектами (game.scene.setLabel).
try {
if (this.scene3d?._labelManager) this.scene3d._labelManager.clearAll();
} catch (e) { /* ignore */ }
// Phase 6.5: освобождаем физ-мир и его wasm-память.
if (this._physicsWorld) {
try { this._physicsWorld.dispose(); } catch (_) {}
this._physicsWorld = null;
}
this.sandboxes = [];
this._isRunning = false;
this._soloScriptId = null;
this._tweens = [];
this._objectData = {};
this._interactables = [];
this._activeInteractRef = null;
this._roomState = {};
this._seenSessions = null;
this._teams = new Map();
this._localPlayerTeam = null;
this._constraintLocalToReal = new Map();
this._fxLocalToReal = new Map();
this._soundLocalToReal = new Map();
this._guiLocalToReal = new Map();
this._guiRealToLocal = new Map();
}
/**
* Удалить GUI-элементы, созданные скриптом через game.gui.create.
* Вызывается в stop() — иначе скриптовый интерфейс остаётся в сцене
* после остановки игры и копится при повторных запусках.
*/
_cleanupSpawnedGui() {
if (!this._guiLocalToReal || this._guiLocalToReal.size === 0) return;
const s = this.scene3d;
if (!s || typeof s.removeGuiElement !== 'function') return;
for (const realId of this._guiLocalToReal.values()) {
try {
// removeGuiElement каскадно удаляет детей — повторный вызов
// для уже удалённого элемента безопасен (no-op).
s.removeGuiElement(realId);
} catch (e) { /* ignore */ }
}
// removeGuiElement дёргает _notify GuiManager → KubikonEditor
// синхронит guiList. Снапшот воркерам не нужен (они остановлены).
}
/** Удалить со сцены все объекты, созданные скриптами в Play-режиме. */
_cleanupSpawnedObjects() {
if (!this._localToReal || this._localToReal.size === 0) return;
const s = this.scene3d;
for (const realRef of this._localToReal.values()) {
try {
if (typeof realRef !== 'string') continue;
const colon = realRef.indexOf(':');
if (colon < 0) continue;
const kind = realRef.slice(0, colon);
const rest = realRef.slice(colon + 1);
if (kind === 'block') {
const [xs, ys, zs] = rest.split(',');
s?.blockManager?.removeBlock(Number(xs), Number(ys), Number(zs));
} else if (kind === 'model') {
s?.modelManager?.removeInstance(Number(rest));
} else if (kind === 'primitive') {
s?.primitiveManager?.removeInstance(Number(rest));
}
} catch (e) { /* ignore — объект мог быть уже удалён скриптом */ }
}
this._localToReal = new Map();
}
/**
* Запустить ОДИН скрипт без перезагрузки сцены — режим отладки.
* Останавливает другие скрипты, оставляет только заданный.
* Это альтернатива Play-режиму: без полноценного игрока, без физики, но
* скрипты получают зеркало state и могут вызывать game.log/teleport.
*
* Используется из ScriptEditor → кнопка «Запустить только этот».
*/
startSolo(script) {
this.stop();
this._isRunning = true;
this._soloScriptId = script?.id || null;
if (!script || typeof script.code !== 'string' || !script.code.trim()) {
this._log('warn', 'Solo-запуск: пустой код');
return;
}
const sb = new ScriptSandbox(script.code, script.target || null);
sb.scriptId = script.id;
if (script.target) {
const pos = this._collectSelfPosition(script.target);
if (pos) sb.setInitialSelfPosition(pos);
}
sb.setOnCommand((cmd, payload) => {
const _t0 = performance.now();
this._handleCommand(script.id, cmd, payload);
const m = this.scene3d?._perfMetrics;
if (m) {
m.script_ms_sum += performance.now() - _t0;
m.script_count++;
}
});
sb.start();
this.sandboxes.push(sb);
this._log('info', `Отладочный запуск: ${script.id}`);
}
/** True если runtime работает в solo-режиме (один скрипт). */
isSolo() { return !!this._soloScriptId; }
getSoloScriptId() { return this._soloScriptId; }
/**
* Вызывать каждый кадр в Play-режиме.
* dt в секундах.
*/
tick(dt) {
if (!this._isRunning || this.sandboxes.length === 0) return;
// Phase 6.5: шаг физ-движка ДО collectState (чтобы скрипты видели
// свежие позиции). Sync rigid body transforms с Babylon-mesh.
if (this._physicsWorld && this._physicsWorld.isReady()) {
this._physicsWorld.step(dt);
this._syncPhysicsToScene();
}
const state = this._collectState();
for (const sb of this.sandboxes) {
// Для скриптов с target — добавляем актуальную позицию self
const stateForSb = sb.target
? { ...state, selfPosition: this._collectSelfPosition(sb.target) }
: state;
sb.tick(dt, stateForSb);
}
// Анимации game.tween
if (this._tweens.length > 0) this._updateTweens(dt);
// Задача 03: GUI tweens
if (this._guiTweens && this._guiTweens.length > 0) this._updateGuiTweens(dt);
// Задача 04: модал-сцены — tick вынесен в BabylonScene.onBeforeRender
// (не зависит от наличия скриптов).
// ProximityPrompt — подсказка [E] над ближайшим интерактивным объектом
if (this._interactables.length > 0) this._updateInteractables();
// Детект смерти игрока — событие game.onPlayerDied (один раз на смерть)
const hp = this.scene3d?.player?.hp ?? 100;
const aliveNow = hp > 0;
if (this._playerWasAlive && !aliveNow) {
this.routeGlobalEvent('playerDied', {});
}
this._playerWasAlive = aliveNow;
// Детект join/leave игроков комнаты (Фаза 4.3).
this._detectPlayerJoinLeave(state.players);
}
/**
* Сравнить текущий список игроков с прошлым tick — событие
* playerJoin для новых, playerLeave для исчезнувших.
* Локального игрока не учитываем (он не «присоединяется»).
*/
_detectPlayerJoinLeave(players) {
if (!players || !players.list) return;
const now = new Map();
for (const p of players.list) {
if (!p.isLocal) now.set(p.sessionId, p);
}
if (this._seenSessions == null) {
// Первый tick — фиксируем без событий (это «уже были»).
this._seenSessions = now;
return;
}
for (const [sid, p] of now) {
if (!this._seenSessions.has(sid)) {
this.routeGlobalEvent('playerJoin', {
sessionId: sid, name: p.name,
});
}
}
for (const [sid, p] of this._seenSessions) {
if (!now.has(sid)) {
this.routeGlobalEvent('playerLeave', {
sessionId: sid, name: p.name,
});
}
}
this._seenSessions = now;
}
/**
* Запустить твин: зарезолвить ref, снять стартовые значения, добавить в _tweens.
* payload: { tweenId, ref, props, duration, easing, delay, repeat, yoyo }
*/
_startTween(scriptId, payload) {
try {
const { tweenId, ref, props } = payload || {};
if (tweenId == null || typeof ref !== 'string' || !props) return;
const from = {};
let guiId = null;
// --- цель: GUI или 3D-объект ---
// GUI-id: либо локальный ref (gui.create), либо реальный id
let resolvedGuiId = ref;
if (this._guiLocalToReal?.has(ref)) resolvedGuiId = this._guiLocalToReal.get(ref);
const guiList = this.scene3d?.getGuiElements?.() || [];
const guiEl = guiList.find(g => g.id === resolvedGuiId);
if (guiEl) {
guiId = resolvedGuiId;
// числовые свойства GUI
for (const key of ['x', 'y', 'w', 'h', 'bgOpacity', 'textSize']) {
if (props[key] != null && guiEl[key] != null) from[key] = Number(guiEl[key]);
}
// цвет
if (props.color != null && guiEl.bgColor) {
from._color = Color3.FromHexString(guiEl.bgColor);
from._colorTo = Color3.FromHexString(String(props.color));
}
if (props.textColor != null && guiEl.textColor) {
from._color = Color3.FromHexString(guiEl.textColor);
from._colorTo = Color3.FromHexString(String(props.textColor));
}
} else {
// 3D-объект
const tgt = this._resolveTweenTarget(ref);
if (!tgt) {
this._log('error', 'tween: объект не найден — ' + ref);
return;
}
const d = tgt.data;
from.x = d.x || 0; from.y = d.y || 0; from.z = d.z || 0;
from.rotationX = d.rotationX || 0;
from.rotationY = d.rotationY || 0;
from.rotationZ = d.rotationZ || 0;
from.sx = d.sx != null ? d.sx : 1;
from.sy = d.sy != null ? d.sy : 1;
from.sz = d.sz != null ? d.sz : 1;
from.opacity = d.opacity != null ? d.opacity
: (d.mesh?.material?.alpha != null ? d.mesh.material.alpha : 1);
if (props.color != null) {
const cur = d.color || '#ffffff';
from._color = Color3.FromHexString(cur);
from._colorTo = Color3.FromHexString(String(props.color));
}
}
this._tweens.push({
tweenId, scriptId, ref, guiId,
props, from,
duration: Math.max(0, Number(payload.duration) || 0),
easing: payload.easing || 'ease',
delayLeft: Math.max(0, Number(payload.delay) || 0),
loopsLeft: Number(payload.repeat) || 0, // 0=без повтора, -1=бесконечно
yoyo: !!payload.yoyo,
elapsed: 0,
dir: 1,
});
} catch (e) {
this._log('error', 'tween.start failed: ' + (e?.message || e));
}
}
/**
* ProximityPrompt: каждый кадр ищем ближайший интерактивный объект
* в радиусе и показываем подсказку «[E] ...» над ним (HUD-метка).
*/
_updateInteractables() {
const player = this.scene3d?.player;
const pp = player?._pos;
if (!pp) return;
const halfH = player?.HALF_H ?? 0.9;
const px = pp.x, py = pp.y - halfH, pz = pp.z;
let nearest = null;
let nearestD2 = Infinity;
for (const it of this._interactables) {
const objPos = this._resolveInteractPos(it);
if (!objPos) continue;
const dx = objPos.x - px, dy = objPos.y - py, dz = objPos.z - pz;
const d2 = dx*dx + dy*dy + dz*dz;
const r = it.distance;
if (d2 <= r*r && d2 < nearestD2) {
nearestD2 = d2;
nearest = it;
}
}
const nearestRef = nearest ? nearest.ref : null;
if (nearestRef !== this._activeInteractRef) {
this._activeInteractRef = nearestRef;
if (nearest) {
// показываем подсказку через HUD (как game.ui.set)
if (this._onHud) {
try {
this._onHud({ cmd: 'ui.set', payload: {
id: '__interact',
text: '[' + nearest.key.toUpperCase() + '] ' + nearest.text,
opts: { x: 50, y: 75, color: '#ffe44a', size: 20 },
} });
} catch (e) { /* ignore */ }
}
} else {
// вышли из зоны — убираем подсказку
if (this._onHud) {
try {
this._onHud({ cmd: 'ui.set', payload: { id: '__interact', text: null } });
} catch (e) { /* ignore */ }
}
}
}
}
/** Резолв позиции интерактивного объекта (по ref). */
_resolveInteractPos(it) {
const tgt = this._resolveTweenTarget(it.ref);
if (tgt) {
const d = tgt.data;
return { x: d.x || 0, y: d.y || 0, z: d.z || 0 };
}
return null;
}
/**
* Нажата клавиша взаимодействия (E) — отправить событие 'interact'
* скрипту ближайшего интерактивного объекта. Вызывается из routeGlobalEvent
* при keydown.
*/
_tryInteract(key) {
if (!this._activeInteractRef) return;
const it = this._interactables.find(x => x.ref === this._activeInteractRef);
if (!it || it.key !== String(key).toLowerCase()) return;
// событие 'interact' скрипту с target = этим объектом
this.routeEvent(it.target, 'interact', {});
}
/** Прокрутка всех активных твинов на dt секунд. */
/** Задача 03: обновление GUI-tweens. Простая реализация без _applyTweenFrame
* (там 3D-логика с rotationY/sx/cy/color через babylon-объекты). */
_updateGuiTweens(dt) {
const gm = this.scene3d?.guiManager;
if (!gm) return;
for (let i = this._guiTweens.length - 1; i >= 0; i--) {
const tw = this._guiTweens[i];
if (tw.delay > 0) { tw.delay -= dt; if (tw.delay > 0) continue; }
tw.elapsed += dt;
let t = tw.elapsed / tw.duration;
let done = false;
if (t >= 1) { t = 1; done = true; }
const raw = tw.dir === -1 ? 1 - t : t;
const k = GameRuntime._ease(tw.easing, raw);
// Применяем
const el = gm.elements.find(e => e.id === tw.realId);
if (!el) { this._guiTweens.splice(i, 1); continue; }
const patch = {};
for (const key of Object.keys(tw.target)) {
const from = tw.start[key];
const to = tw.target[key];
if (typeof from === 'number' && typeof to === 'number') {
patch[key] = from + (to - from) * k;
} else if (typeof from === 'string' && typeof to === 'string'
&& from.startsWith('#') && to.startsWith('#')) {
patch[key] = GameRuntime._lerpColor(from, to, k);
} else {
// Прочее — на конце ставим целевое
if (done) patch[key] = to;
}
}
// Throttle: обновляем не чаще чем раз в 32мс (~30 FPS).
tw._lastApply = tw._lastApply || 0;
tw._lastApply += dt;
if (tw._lastApply >= 0.032 || done) {
tw._lastApply = 0;
try { gm.update(tw.realId, patch); } catch (e) {}
}
if (done) {
if (tw.reverses && tw.dir === 1) {
tw.dir = -1;
tw.elapsed = 0;
continue;
}
tw.iter++;
if (tw.repeat === -1 || tw.iter < tw.repeat) {
// повтор
tw.elapsed = 0;
tw.dir = 1;
continue;
}
// готово
this._guiTweens.splice(i, 1);
// onDone callback в worker
const sb = this.sandboxes.find(s => s.scriptId === tw.scriptId);
if (sb) sb.sendGlobalEvent({ type: 'tweenDone', tweenId: tw.tweenId });
}
}
}
_updateTweens(dt) {
for (let i = this._tweens.length - 1; i >= 0; i--) {
const tw = this._tweens[i];
// задержка перед стартом
if (tw.delayLeft > 0) {
tw.delayLeft -= dt;
if (tw.delayLeft > 0) continue;
dt = -tw.delayLeft; // остаток времени уходит в анимацию
}
tw.elapsed += dt;
let t = tw.duration > 0 ? tw.elapsed / tw.duration : 1;
let done = false;
if (t >= 1) {
t = 1;
done = true;
}
// прогресс с учётом направления (yoyo) + easing
const raw = tw.dir === -1 ? 1 - t : t;
const k = GameRuntime._ease(tw.easing, raw);
this._applyTweenFrame(tw, k);
if (done) {
if (tw.yoyo && tw.dir === 1) {
// первый проход «туда» завершён — разворачиваем «обратно»
tw.dir = -1;
tw.elapsed = 0;
continue;
}
// цикл завершён полностью (или прямой, или yoyo туда-обратно)
if (tw.loopsLeft !== 0) {
if (tw.loopsLeft > 0) tw.loopsLeft--;
tw.dir = 1;
tw.elapsed = 0;
continue;
}
// твин закончен — снять и уведомить скрипт
this._tweens.splice(i, 1);
this._notifyTweenDone(tw.scriptId, tw.tweenId);
}
}
}
/** Easing-функции. Принимают t∈[0,1], возвращают сглаженное значение. */
static _ease(name, t) {
switch (name) {
case 'linear':
return t;
case 'bounce': {
const n1 = 7.5625, d1 = 2.75;
if (t < 1 / d1) return n1 * t * t;
if (t < 2 / d1) { t -= 1.5 / d1; return n1 * t * t + 0.75; }
if (t < 2.5 / d1) { t -= 2.25 / d1; return n1 * t * t + 0.9375; }
t -= 2.625 / d1; return n1 * t * t + 0.984375;
}
case 'elastic': {
if (t === 0 || t === 1) return t;
const c4 = (2 * Math.PI) / 3;
return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
}
case 'back': {
const c1 = 1.70158, c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
}
case 'ease':
default:
// ease-in-out (плавный старт и финиш)
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}
}
/** Уведомить воркер скрипта что твин доиграл (resolve onDone). */
_notifyTweenDone(scriptId, tweenId) {
const sb = this.sandboxes.find(s => s.scriptId === scriptId);
if (sb && sb.worker) {
try { sb.worker.postMessage({ cmd: 'tweenDone', payload: { tweenId } }); } catch (e) {}
}
}
/**
* Сообщить ВСЕМ sandbox'ам маппинг локальный ref → реальный после
* scene.spawn. Нужно чтобы синхронные read-методы воркера
* (getPosition и т.п.) резолвили локальный ref в реальный — иначе
* заспавненный объект не находится в _sceneIndex (там реальные ref).
*/
_notifySpawnResolved(localRef, realRef) {
if (!localRef || !realRef) return;
// Объект мог быть удалён скриптом ДО того как зарезолвился
// (асинхронный спавн GLB-модели). Если он в очереди отложенных
// удалений — удаляем сейчас, когда реальный id известен.
if (this._pendingDeletes && this._pendingDeletes.has(localRef)) {
this._pendingDeletes.delete(localRef);
try {
this._applySceneDelete({ ref: realRef });
} catch (e) { /* ignore */ }
return;
}
for (const sb of this.sandboxes) {
if (sb && sb.worker) {
try {
sb.worker.postMessage({
cmd: 'spawnResolved',
payload: { localRef, realRef },
});
} catch (e) { /* ignore */ }
}
}
}
/**
* Резолв ref в инстанс-данные объекта сцены.
* Возвращает { kind, data } или null. kind: 'primitive'|'model'|'userModel'.
* data — объект из *Manager.instances (имеет mesh/rootMesh/rootNode + x/y/z).
*/
/**
* Резолв id примитива из любого вида ссылки в реальный id для
* primitiveManager.instances. Принимает:
* - реальный числовой id (или строку-число)
* - локальный ref от spawn/clone ('primitive:_local_N')
* - ref 'primitive:realId'
* Возвращает id (число) или null.
*/
_resolvePrimitiveId(idOrRef) {
if (idOrRef == null) return null;
const pm = this.scene3d?.primitiveManager;
if (!pm) return null;
let v = idOrRef;
if (typeof v === 'string') {
// полный ref 'primitive:_local_N' / 'primitive:123' → резолвим через карту
if (this._localToReal?.has(v)) v = this._localToReal.get(v);
const colon = v.indexOf(':');
if (colon >= 0) v = v.slice(colon + 1);
// голый '_local_N' (воркер мог отрезать 'primitive:') — ищем по карте:
// ключ 'primitive:_local_N' → значение 'primitive:realId'.
if (typeof v === 'string' && v.indexOf('_local_') === 0 && this._localToReal) {
const full = 'primitive:' + v;
if (this._localToReal.has(full)) {
const real = this._localToReal.get(full);
const c2 = real.indexOf(':');
v = c2 >= 0 ? real.slice(c2 + 1) : real;
}
}
}
// прямой id
if (pm.instances.has(v)) return v;
const n = Number(v);
if (Number.isFinite(n) && pm.instances.has(n)) return n;
return null;
}
/**
* ref NPC ('npc:_local_N' от воркера или 'npc:<id>') → числовой npcId.
* Возвращает number или null.
*/
_resolveNpcId(ref) {
if (typeof ref !== 'string') return null;
let v = ref;
// Локальный ref воркера → реальный 'npc:<id>'.
if (this._localToReal?.has(v)) v = this._localToReal.get(v);
const colon = v.indexOf(':');
if (colon < 0) return null;
const id = Number(v.slice(colon + 1));
return Number.isFinite(id) ? id : null;
}
/**
* Выполнить NPC-команду. Если NPC ещё не создан (spawnNpc async, а
* скрипт сразу зовёт follow/moveTo/say) — откладываем команду в
* очередь по локальному ref и проигрываем после npcSpawned-резолва.
* Без этого команды сразу после spawnNpc молча терялись.
*/
_npcCmd(ref, fn) {
const nid = this._resolveNpcId(ref);
if (nid != null) { fn(nid); return; }
// ещё не резолвится — откладываем (только для локальных ref NPC)
if (typeof ref === 'string' && ref.indexOf('npc:_local_') === 0) {
if (!this._pendingNpcCmds) this._pendingNpcCmds = new Map();
if (!this._pendingNpcCmds.has(ref)) this._pendingNpcCmds.set(ref, []);
this._pendingNpcCmds.get(ref).push(fn);
}
}
/** Проиграть отложенные команды для NPC после его резолва. */
_flushPendingNpcCmds(localRef, npcId) {
if (!this._pendingNpcCmds) return;
const queue = this._pendingNpcCmds.get(localRef);
if (!queue) return;
this._pendingNpcCmds.delete(localRef);
for (const fn of queue) {
try { fn(npcId); } catch (e) { /* ignore */ }
}
}
/** Локальный ref связи ('constraint:_local_N') → числовой id или null. */
_resolveConstraintId(ref) {
if (typeof ref !== 'string') return null;
if (this._constraintLocalToReal?.has(ref)) {
return this._constraintLocalToReal.get(ref);
}
// Запасной путь: прямой числовой id в строке.
const colon = ref.indexOf(':');
const id = Number(colon >= 0 ? ref.slice(colon + 1) : ref);
return Number.isFinite(id) ? id : null;
}
/** Локальный ref луча/следа ('fx:_local_N') → числовой id или null. */
_resolveFxId(ref) {
if (typeof ref !== 'string') return null;
if (this._fxLocalToReal?.has(ref)) {
return this._fxLocalToReal.get(ref);
}
const colon = ref.indexOf(':');
const id = Number(colon >= 0 ? ref.slice(colon + 1) : ref);
return Number.isFinite(id) ? id : null;
}
_resolveTweenTarget(ref) {
if (typeof ref !== 'string') return null;
// Локальный ref из scene.spawn ('primitive:_local_N') → реальный id
if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref);
const colon = ref.indexOf(':');
const kind = colon >= 0 ? ref.slice(0, colon) : null;
const rawId = colon >= 0 ? ref.slice(colon + 1) : ref;
const tryGet = (mgr) => {
if (!mgr || !mgr.instances) return null;
let d = mgr.instances.get(rawId);
if (!d) {
const n = Number(rawId);
if (Number.isFinite(n)) d = mgr.instances.get(n);
}
return d || null;
};
if (kind === 'primitive' || kind == null) {
const d = tryGet(this.scene3d?.primitiveManager);
if (d) return { kind: 'primitive', data: d };
}
if (kind === 'model' || kind == null) {
const d = tryGet(this.scene3d?.modelManager);
if (d) return { kind: 'model', data: d };
}
const um = tryGet(this.scene3d?.userModelManager);
if (um) return { kind: 'userModel', data: um };
return null;
}
/**
* Применить промежуточное состояние твина к объекту.
* k — сглаженный прогресс [0,1]. Интерполяция from→props по каждому ключу.
*/
_applyTweenFrame(tw, k) {
const lerp = (a, b) => a + (b - a) * k;
// --- GUI-элемент ---
if (tw.guiId != null) {
const patch = {};
for (const key of Object.keys(tw.props)) {
if (key === 'color' || key === 'textColor') continue;
if (tw.from[key] == null) continue;
patch[key] = lerp(tw.from[key], Number(tw.props[key]));
}
if (tw.props.color != null || tw.props.textColor != null) {
const ck = tw.props.color != null ? 'color' : 'textColor';
patch[ck] = GameRuntime._lerpColor(tw.from._color, tw.from._colorTo, k);
}
// обновляем напрямую — без scheduleGuiSnapshot (дорого каждый кадр)
try { this.scene3d?.updateGuiElement?.(tw.guiId, patch); } catch (e) {}
return;
}
// --- 3D-объект ---
const tgt = this._resolveTweenTarget(tw.ref);
if (!tgt) return;
const d = tgt.data;
const p = tw.props, f = tw.from;
// позиция
let posChanged = false;
if (p.x != null) { d.x = lerp(f.x, Number(p.x)); posChanged = true; }
if (p.y != null) { d.y = lerp(f.y, Number(p.y)); posChanged = true; }
if (p.z != null) { d.z = lerp(f.z, Number(p.z)); posChanged = true; }
// поворот
let rotChanged = false;
if (p.rotationX != null) { d.rotationX = lerp(f.rotationX || 0, Number(p.rotationX)); rotChanged = true; }
if (p.rotationY != null) { d.rotationY = lerp(f.rotationY || 0, Number(p.rotationY)); rotChanged = true; }
if (p.rotationZ != null) { d.rotationZ = lerp(f.rotationZ || 0, Number(p.rotationZ)); rotChanged = true; }
// масштаб
let scaleChanged = false;
if (p.sx != null) { d.sx = lerp(f.sx || 1, Number(p.sx)); scaleChanged = true; }
if (p.sy != null) { d.sy = lerp(f.sy || 1, Number(p.sy)); scaleChanged = true; }
if (p.sz != null) { d.sz = lerp(f.sz || 1, Number(p.sz)); scaleChanged = true; }
// меш (primitive → .mesh, model/userModel → .rootMesh/.rootNode)
const mesh = d.mesh || d.rootMesh || d.rootNode;
if (mesh) {
if (posChanged && mesh.position) mesh.position.set(d.x, d.y, d.z);
if (rotChanged && mesh.rotation) {
mesh.rotation.x = d.rotationX || 0;
mesh.rotation.y = d.rotationY || 0;
mesh.rotation.z = d.rotationZ || 0;
}
if (scaleChanged && mesh.scaling) {
mesh.scaling.set(d.sx || 1, d.sy || 1, d.sz || 1);
}
// размороз world-matrix если был заморожен
if ((posChanged || rotChanged || scaleChanged) && d._worldMatrixFrozen) {
try { mesh.unfreezeWorldMatrix?.(); } catch (e) {}
d._worldMatrixFrozen = false;
}
}
// цвет
if (p.color != null && f._color != null && mesh?.material) {
const c = GameRuntime._lerpColor3(f._color, f._colorTo, k);
mesh.material.diffuseColor = c;
if (d.material === 'neon') mesh.material.emissiveColor = c;
d.color = '#' + c.toHexString().slice(1);
}
// прозрачность
if (p.opacity != null && mesh?.material) {
const op = lerp(f.opacity != null ? f.opacity : 1, Number(p.opacity));
mesh.material.alpha = op;
d.opacity = op;
}
}
/** Интерполяция цвета (Babylon Color3) между двумя hex. */
static _lerpColor3(from, to, k) {
return new Color3(
from.r + (to.r - from.r) * k,
from.g + (to.g - from.g) * k,
from.b + (to.b - from.b) * k,
);
}
/** Интерполяция цвета → hex-строка (для GUI). */
static _lerpColor(from, to, k) {
return '#' + GameRuntime._lerpColor3(from, to, k).toHexString().slice(1);
}
/**
* Маршрутизация событий объектов к скриптам с соответствующим target.
* Вызывается из BabylonScene при клике/touch.
*
* @param {object} target — {kind, ref|x|y|z|id}
* @param {string} eventType — 'click' | 'touch'
* @param {object} extra — дополнительные данные события
*/
routeEvent(target, eventType, extra = {}) {
if (!target || !eventType) return;
for (const sb of this.sandboxes) {
if (!sb.target) continue;
if (!this._targetMatches(sb.target, target)) continue;
sb.sendEvent({ type: eventType, ...extra });
}
}
/**
* Глобальное событие — доставляется ВСЕМ sandbox'ам (не зависит от target).
* Используется для onKey, onClick (глобальный), onPlayerTouch.
*/
/**
* Задача 07: состояние скинов на стороне runtime.
* Инициализируется из scene.skins (default/unlocked/shopVisible) при первом
* обращении. Держит множество разблокированных скинов и текущий.
*/
_ensureSkinState() {
if (this._skinState) return this._skinState;
const sk = this.scene3d?._skinsConfig || {};
const def = sk.default || this.scene3d?._playerModelType || 'character-a';
const defSlug = this._slugFromTypeId(def);
const unlocked = new Set(Array.isArray(sk.unlocked) ? sk.unlocked : []);
unlocked.add(defSlug);
this._skinState = {
unlocked,
current: defSlug,
shopVisible: sk.shopVisible !== false,
coins: Number.isFinite(sk.coins) ? sk.coins : 0,
};
return this._skinState;
}
/** slug → _modelTypeId движка. Встроенные → 'skin_<slug>', character-* как есть. */
_resolveSkinTypeId(slug) {
if (!slug) return 'character-a';
if (slug.startsWith('character-')) return slug;
if (slug.startsWith('skin_') || slug.startsWith('customskin:')) return slug;
return 'skin_' + slug;
}
/** _modelTypeId → slug (обратно). */
_slugFromTypeId(typeId) {
if (!typeId) return 'character-a';
if (typeId.startsWith('skin_')) return typeId.slice('skin_'.length);
return typeId;
}
routeGlobalEvent(eventType, extra = {}) {
if (!eventType) return;
// Спецслучай: guiClick приходит с realId. Worker мог подписаться двумя
// способами:
// 1) по локальному ref, который вернул gui.create() — '_gui_local_N'
// 2) по ЯВНОМУ id, который сам передал в gui.create({ id: 'fight' }),
// или по name элемента.
// Раньше мы ПЕРЕЗАПИСЫВАЛИ extra.id на localRef — это ломало вариант (2),
// потому что worker искал handler по localRef, а юзер подписался по
// явному id. Теперь шлём ОБА id (`id` = реальный + `localId` = ref),
// worker матчит по обоим (см. _guiHandlerKeys в ScriptSandboxWorker).
if ((eventType === 'guiClick' || eventType === 'guiSubmit'
|| eventType === 'guiTextChange')
&& extra && extra.id != null && this._guiRealToLocal) {
const local = this._guiRealToLocal.get(extra.id);
if (local && local !== extra.id) extra = { ...extra, localId: local };
}
// ProximityPrompt: keydown клавиши взаимодействия → событие interact
if (eventType === 'keydown' && extra && extra.key
&& this._interactables.length > 0) {
this._tryInteract(extra.key);
}
for (const sb of this.sandboxes) {
sb.sendGlobalEvent({ type: eventType, ...extra });
}
}
/**
* Уведомление: моб убит. Рассылается во все скрипты как 'mobKilled'.
* Скрипт может подписаться через `game.onMobKilled(fn)`.
* payload: { type: 'zombie' | ..., x, y, z }
*/
notifyMobKilled(mobType, position) {
this.routeGlobalEvent('mobKilled', { mobType, position });
}
/** Совпадает ли target скрипта с обращённым target события. */
_targetMatches(a, b) {
if (!a || !b) return false;
if (a.kind !== b.kind) return false;
if (a.kind === 'block') {
const ar = a.ref || a;
const br = b.ref || b;
return ar.x === br.x && ar.y === br.y && ar.z === br.z;
}
const aId = a.id ?? a.ref;
const bId = b.id ?? b.ref;
return aId === bId;
}
/** Собрать снимок state для отправки в Worker'ы. */
_collectState() {
const player = this.scene3d?.player;
// PlayerController хранит позицию в this._pos (Vector3).
// Внутри _pos.y — это центр капсулы (учтена HALF_H ~= 0.9), для авторов
// удобнее давать «низ ног» = _pos.y - HALF_H.
const p = player?._pos;
const halfH = player?.HALF_H ?? 0.9;
const position = p
? { x: p.x, y: p.y - halfH, z: p.z }
: { x: 0, y: 0, z: 0 };
// Yaw/pitch (для player.forward)
const yaw = player?._yaw || 0;
const pitch = player?._pitch || 0;
// Forward-вектор. PlayerController использует:
// fx = sin(yaw)*cos(pitch), fy = -sin(pitch), fz = cos(yaw)*cos(pitch)
const cosP = Math.cos(pitch);
const forward = {
x: Math.sin(yaw) * cosP,
y: -Math.sin(pitch),
z: Math.cos(yaw) * cosP,
};
const crosshair = this.scene3d?.getCrosshair ? this.scene3d.getCrosshair() : 'none';
const hp = player?.hp ?? 100;
const maxHp = player?.maxHp ?? 100;
// Снимок мобов (зомби) — для game.scene.mobs() из скриптов
let mobs = [];
try {
const zm = this.scene3d?.zombieManager;
if (zm && typeof zm.getMobsSnapshot === 'function') {
mobs = zm.getMobsSnapshot();
}
} catch (e) {}
// Снимок NPC — для game.scene.npcs() и npc.position из скриптов.
let npcs = [];
try {
const nm = this.scene3d?.npcManager;
if (nm && typeof nm.getSnapshot === 'function') {
npcs = nm.getSnapshot();
}
} catch (e) {}
// Снимок инвентаря — для game.inventory.has/list/active.
let inventory = null;
try {
const inv = this.scene3d?.inventory;
if (inv) {
inventory = {
slots: inv.slots.map(s => s ? {
kind: s.kind, modelTypeId: s.modelTypeId, name: s.name,
} : null),
activeIndex: inv.activeIndex,
};
}
} catch (e) {}
// Снимок игроков комнаты — для game.players.* (Фаза 4.3).
// В редакторе (single-player) — только локальный игрок.
// С мультиплеером — локальный + все remote из _mpSync.
const players = this._collectPlayers(position, hp, maxHp);
// Кубикон Dash: текущее направление гравитации (+1 / -1).
// Нужно скрипту для рендера куба в правильной ориентации.
const gravityDir = player?._gravityDir ?? 1;
// Состояние игрока ('ground'|'air'|'water') для game.player.state.
const state = player?._playerState || 'ground';
// Зажатые клавиши — для game.player.isKeyDown(key).
// _codes хранит коды ('KeyW','Space','ArrowUp'), нормализуем в имена скрипта.
const keys = {};
if (player?._codes) {
for (const code of player._codes) {
const k = GameRuntime._normalizeKeyCode(code);
if (k) keys[k] = true;
}
}
return {
player: { position, yaw, pitch, forward, crosshair, hp, maxHp, gravityDir, state, keys },
mobs,
npcs,
inventory,
players,
roomState: this._roomState || {},
teams: this._teams ? Array.from(this._teams.values()) : [],
};
}
/**
* Снимок всех игроков комнаты для game.players.* (Фаза 4.3).
* Локальный игрок всегда первый, sessionId='local' в одиночной игре
* или реальный sessionId если есть Colyseus-комната.
* Возвращает { me, list } — list включает me.
*/
_collectPlayers(myPos, myHp, myMaxHp) {
const mp = this.scene3d?._mpSync;
const mySessionId = mp?.room?.sessionId || 'local';
const myName = mp?.room?.state?.players?.get?.(mySessionId)?.username
|| this._localPlayerName || 'Игрок';
const me = {
sessionId: mySessionId,
name: myName,
isLocal: true,
position: myPos,
hp: myHp, maxHp: myMaxHp,
team: this._localPlayerTeam || null,
};
const list = [me];
// Remote-игроки из MultiplayerSync (если есть комната).
if (mp && mp.remotePlayers) {
const roomPlayers = mp.room?.state?.players;
for (const rp of mp.remotePlayers.values()) {
// team берётся из Colyseus-state (его синхронизирует сервер).
const colyP = roomPlayers?.get?.(rp.sessionId);
list.push({
sessionId: rp.sessionId,
name: rp.username || rp.sessionId,
isLocal: false,
position: rp.current
? { x: rp.current.x, y: rp.current.y, z: rp.current.z }
: { x: 0, y: 0, z: 0 },
hp: rp.hp ?? 100, maxHp: rp.maxHp ?? 100,
team: (colyP && colyP.team) || null,
});
}
}
return { me, list };
}
/** Код клавиши Babylon ('KeyW','Space','ArrowUp') → имя для скрипта ('w','space','arrowup'). */
static _normalizeKeyCode(code) {
if (!code) return null;
if (code.startsWith('Key')) return code.slice(3).toLowerCase(); // KeyW → w
if (code.startsWith('Digit')) return code.slice(5); // Digit1 → 1
if (code.startsWith('Arrow')) return code.toLowerCase(); // ArrowUp → arrowup
const map = {
Space: 'space', ShiftLeft: 'shift', ShiftRight: 'shift',
Enter: 'enter', Escape: 'escape',
ControlLeft: 'ctrl', ControlRight: 'ctrl',
};
return map[code] || code.toLowerCase();
}
/** Слить отложенные команды для конкретного только что зарезолвленного ref. */
_drainPendingResolveQueue(resolvedLocalRef) {
if (!this._pendingResolveQueue || !this._pendingResolveQueue.length) return;
const stay = [];
for (const item of this._pendingResolveQueue) {
if (item.payload?.ref === resolvedLocalRef) {
this._handleCommand(item.scriptId, item.cmd, item.payload);
} else {
stay.push(item);
}
}
this._pendingResolveQueue = stay;
}
/** Команда от Worker'а пришла — применяем на сцене. */
_handleCommand(scriptId, cmd, payload) {
if (cmd === 'log') {
this._log(payload?.level || 'info', payload?.text || '');
return;
}
if (cmd === 'player.teleport') {
const player = this.scene3d?.player;
if (player && player._pos && payload) {
const { x, y, z } = payload;
if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {
try {
const halfH = player.HALF_H ?? 0.9;
// Конвертируем «низ ног» обратно в центр капсулы
player._pos.set(x, y + halfH, z);
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[GameRuntime] teleport failed', e);
}
}
} else {
// eslint-disable-next-line no-console
console.warn('[GameRuntime] teleport ignored — no player or _pos', { hasPlayer: !!player, hasPos: !!(player && player._pos) });
}
return;
}
if (cmd === 'player.setLaneX') {
// Сдвиг игрока ТОЛЬКО по X — не трогает Z и Y. Нужно для
// раннеров (смена полосы): teleport(x,y,z) затирал бы Z,
// отменяя продвижение autorun каждый кадр.
const player = this.scene3d?.player;
if (player && player._pos && payload) {
const x = Number(payload.x);
if (Number.isFinite(x)) {
try { player._pos.x = x; } catch (e) { /* ignore */ }
}
}
return;
}
if (cmd === 'player.damage') {
const player = this.scene3d?.player;
if (player && typeof player.takeDamage === 'function') {
const amt = Math.max(0, Number(payload?.amount) || 0);
if (amt > 0) {
// Если урон больше maxHp — обходим i-frames для kill().
if (amt >= (player.maxHp ?? 100)) {
player._lastDamageTime = 0; // сбрасываем cooldown
}
try { player.takeDamage(amt, 'script'); } catch (e) {}
}
}
return;
}
if (cmd === 'player.heal') {
const player = this.scene3d?.player;
if (player && typeof player.hp === 'number') {
const amt = Math.max(0, Number(payload?.amount) || 0);
player.hp = Math.min(player.maxHp ?? 100, player.hp + amt);
if (player._onHpChange) {
try { player._onHpChange({ hp: player.hp, maxHp: player.maxHp, source: 'heal', damaged: false }); } catch (e) {}
}
}
return;
}
if (cmd === 'player.respawn') {
const player = this.scene3d?.player;
if (player && player._pos) {
// Восстанавливаем HP
player.hp = player.maxHp ?? 100;
if (player._onHpChange) {
try { player._onHpChange({ hp: player.hp, maxHp: player.maxHp, source: 'respawn', damaged: false }); } catch (e) {}
}
// Возвращаем модель если была спрятана при смерти
if (player._modelRoot) player._modelRoot.setEnabled(true);
// Телепорт на spawnPoint сцены
const sp = this.scene3d?._spawnPoint
|| this.scene3d?.scene?.metadata?.spawnPoint
|| { x: 0, y: 1, z: 0 };
const halfH = player.HALF_H ?? 0.9;
try { player._pos.set(sp.x, sp.y + halfH, sp.z); } catch (e) {}
// Сбросим скорость падения
if (player._velocity) {
try { player._velocity.set(0, 0, 0); } catch (e) {}
}
}
return;
}
if (cmd === 'player.setSpawn') {
// Назначить активную точку возрождения. Меняем scene3d._spawnPoint —
// им пользуется player.respawn и логика смерти.
const s = this.scene3d;
if (s && payload) {
let sp = null;
if (typeof payload.ref === 'string') {
// ref объекта: встаём НАД ним (центр + полувысота + зазор).
const ref = payload.ref;
if (ref.indexOf('block:') === 0) {
const [bx, by, bz] = ref.slice(6).split(',').map(Number);
if ([bx, by, bz].every(Number.isFinite)) {
sp = { x: bx, y: by + 1.1, z: bz };
}
} else {
const tgt = this._resolveTweenTarget(ref);
if (tgt && tgt.data) {
const d = tgt.data;
const topOff = (d.sy != null ? d.sy * 0.5 : 0.5) + 0.1;
sp = { x: d.x, y: (d.y || 0) + topOff, z: d.z };
}
}
} else if (Number.isFinite(payload.x)) {
sp = { x: payload.x, y: payload.y, z: payload.z };
}
if (sp && typeof s.setSpawnPoint === 'function') {
s.setSpawnPoint(sp.x, sp.y, sp.z);
}
}
return;
}
// === NPC API (Фаза 4.1) ===
if (cmd === 'npc.spawn') {
// payload: { modelType, ref, x, y, z, rotationY, hp, name, speed }
const nm = this.scene3d?.npcManager;
if (nm && payload) {
if (!this._localToReal) this._localToReal = new Map();
const p = nm.spawnNpc(payload.modelType, {
x: payload.x, y: payload.y, z: payload.z,
rotationY: payload.rotationY,
hp: payload.hp, name: payload.name, speed: payload.speed,
});
Promise.resolve(p).then((npcId) => {
if (npcId == null) {
this._log('error', 'spawnNpc не удался: ' + payload.modelType);
return;
}
// Локальный ref воркера → реальный 'npc:<id>'.
if (payload.ref) {
this._localToReal.set(payload.ref, 'npc:' + npcId);
// Проигрываем команды, отправленные скриптом сразу
// после spawnNpc (follow/moveTo/say) — они ждали
// резолва ref в очереди.
this._flushPendingNpcCmds(payload.ref, npcId);
}
// Сообщаем воркеру маппинг localRef → npcId, чтобы
// npc.onDeath по локальному ref находил правильного NPC.
if (payload.ref) {
const sb = this.sandboxes.find(s => s.scriptId === scriptId);
if (sb && sb.worker) {
try {
sb.worker.postMessage({
cmd: 'npcSpawned',
payload: { localRef: payload.ref, npcId },
});
} catch (e) { /* ignore */ }
}
}
}).catch((err) => {
this._log('error', 'spawnNpc failed: ' + (err?.message || err));
});
}
return;
}
if (cmd === 'npc.moveTo') {
// _npcCmd откладывает команду, если NPC ещё не создан (async).
this._npcCmd(payload?.ref, (nid) =>
this.scene3d?.npcManager?.moveTo(nid, payload.x, payload.z));
return;
}
if (cmd === 'npc.follow') {
this._npcCmd(payload?.ref, (nid) => {
// target — ref объекта или 'player'. Резолвим локальный ref
// в реальный (объект мог быть заспавнен скриптом).
let target = payload?.target;
if (typeof target === 'string' && this._localToReal?.has(target)) {
target = this._localToReal.get(target);
}
this.scene3d?.npcManager?.follow(nid, target);
});
return;
}
if (cmd === 'npc.stop') {
this._npcCmd(payload?.ref, (nid) =>
this.scene3d?.npcManager?.stopNpc(nid));
return;
}
if (cmd === 'npc.setSpeed') {
this._npcCmd(payload?.ref, (nid) =>
this.scene3d?.npcManager?.setSpeed(nid, payload?.speed));
return;
}
if (cmd === 'npc.say') {
this._npcCmd(payload?.ref, (nid) =>
this.scene3d?.npcManager?.say(nid, payload?.text, payload?.duration));
return;
}
if (cmd === 'npc.damage') {
this._npcCmd(payload?.ref, (nid) =>
this.scene3d?.npcManager?.damage(nid, payload?.amount));
return;
}
if (cmd === 'npc.remove') {
this._npcCmd(payload?.ref, (nid) =>
this.scene3d?.npcManager?.removeNpc(nid));
return;
}
// === Constraints / связи объектов (Фаза 5) ===
if (cmd === 'constraint.create') {
// payload: { kind: 'weld'|'hinge'|'spring', localRef, ... }
const cm = this.scene3d?.constraintManager;
if (cm && payload) {
let id = null;
if (payload.kind === 'weld') {
id = cm.addWeld(payload.refA, payload.refB);
} else if (payload.kind === 'hinge') {
id = cm.addHinge(payload.ref, {
pivotX: payload.pivotX, pivotZ: payload.pivotZ,
angle: payload.angle,
});
} else if (payload.kind === 'spring') {
id = cm.addSpring(payload.ref, {
stiffness: payload.stiffness, damping: payload.damping,
});
}
if (id == null) {
this._log('error', 'не удалось создать связь ' + payload.kind);
} else if (payload.localRef) {
// Маппинг localRef → реальный id (как у NPC).
if (!this._constraintLocalToReal) this._constraintLocalToReal = new Map();
this._constraintLocalToReal.set(payload.localRef, id);
}
}
return;
}
if (cmd === 'constraint.hingeAngle') {
const cid = this._resolveConstraintId(payload?.ref);
if (cid != null) this.scene3d?.constraintManager?.setHingeAngle(cid, payload?.deg);
return;
}
if (cmd === 'constraint.springPush') {
const cid = this._resolveConstraintId(payload?.ref);
if (cid != null) {
this.scene3d?.constraintManager?.pushSpring(
cid, payload?.vx, payload?.vy, payload?.vz);
}
return;
}
if (cmd === 'constraint.remove') {
const cid = this._resolveConstraintId(payload?.ref);
if (cid != null) this.scene3d?.constraintManager?.remove(cid);
return;
}
// === Beam / Trail — лучи и следы (Фаза 5.2) ===
if (cmd === 'fx.create') {
// payload: { kind: 'beam'|'trail', localRef, ... }
const bm = this.scene3d?.beamManager;
if (bm && payload) {
let id = null;
if (payload.kind === 'beam') {
id = bm.addBeam({
from: payload.from, to: payload.to,
color: payload.color, width: payload.width,
});
} else if (payload.kind === 'trail') {
id = bm.addTrail(payload.ref, {
color: payload.color, width: payload.width,
lifetime: payload.lifetime,
});
}
if (id == null) {
this._log('error', 'не удалось создать ' + payload.kind);
} else if (payload.localRef) {
if (!this._fxLocalToReal) this._fxLocalToReal = new Map();
this._fxLocalToReal.set(payload.localRef, id);
}
}
return;
}
if (cmd === 'fx.beamColor') {
const fid = this._resolveFxId(payload?.ref);
if (fid != null) this.scene3d?.beamManager?.setBeamColor(fid, payload?.color);
return;
}
if (cmd === 'fx.beamEndpoints') {
const fid = this._resolveFxId(payload?.ref);
if (fid != null) {
this.scene3d?.beamManager?.setBeamEndpoints(
fid, payload?.from, payload?.to);
}
return;
}
if (cmd === 'fx.remove') {
const fid = this._resolveFxId(payload?.ref);
if (fid != null) this.scene3d?.beamManager?.remove(fid);
return;
}
// === Звук — game.sound.* (Фаза 5.5) ===
// Пользовательский звук из библиотеки проекта (Фаза 5.5).
// Встроенные пресеты ({name} без soundId) обрабатывает старый
// обработчик ниже — здесь только {soundId}.
if (cmd === 'sound.play' && payload && typeof payload.soundId === 'string') {
const sm = this.scene3d?.soundManager;
if (sm && this.scene3d?.soundLibrary?.count() > 0) {
// attachRef может быть локальным ref от scene.spawn — резолвим.
let attachRef = payload.attachRef;
if (typeof attachRef === 'string' && attachRef !== 'player'
&& this._localToReal?.has(attachRef)) {
attachRef = this._localToReal.get(attachRef);
}
const instId = sm.play(payload.soundId, {
volume: payload.volume,
loop: payload.loop,
at: payload.at,
attachRef,
});
if (instId != null && payload.localRef) {
if (!this._soundLocalToReal) this._soundLocalToReal = new Map();
this._soundLocalToReal.set(payload.localRef, instId);
}
}
return;
}
if (cmd === 'sound.stop') {
const ref = payload?.ref;
if (ref != null && this.scene3d?.soundManager) {
const instId = this._soundLocalToReal?.has(ref)
? this._soundLocalToReal.get(ref) : Number(ref);
if (Number.isFinite(instId)) {
this.scene3d.soundManager.stopSound(instId);
}
}
return;
}
// === Tool / инвентарь API (Фаза 4.2) ===
if (cmd === 'inventory.give') {
// payload: { kind, modelTypeId, name, params, customToolId? }
const inv = this.scene3d?.inventory;
if (inv && payload) {
// Phase 6.4: customToolId сохраняется в params._customToolId,
// чтобы при toolUse main мог прокинуть его обратно в воркер.
const params = { ...(payload.params || {}) };
if (payload.customToolId) params._customToolId = payload.customToolId;
const idx = inv.add({
kind: payload.kind || 'item',
modelTypeId: payload.modelTypeId || null,
name: payload.name || 'Предмет',
params,
});
if (idx < 0) {
this._log('error', 'инвентарь полон — предмет не добавлен');
} else if (payload.equip) {
// Сразу сделать активным и снарядить (для giveTool).
inv.setActive(idx);
const item = inv.slots[idx];
if (item && item.kind === 'weapon' && this.scene3d?.weapons) {
try { this.scene3d.weapons.equip(item); } catch (e) {}
}
}
}
return;
}
if (cmd === 'inventory.remove') {
// payload: { modelTypeId? , name? } — убрать первый совпавший слот.
const inv = this.scene3d?.inventory;
if (inv && payload) {
const slots = inv.slots;
for (let i = 0; i < slots.length; i++) {
const s = slots[i];
if (!s) continue;
const matchModel = payload.modelTypeId && s.modelTypeId === payload.modelTypeId;
const matchName = payload.name && s.name === payload.name;
if (matchModel || matchName) {
// Если убираем активное оружие — снять модель из руки.
if (i === inv.activeIndex && this.scene3d?.weapons) {
try { this.scene3d.weapons.unequip(); } catch (e) {}
}
inv.removeSlot(i);
break;
}
}
}
return;
}
// Phase 6.4: tools.drop -- создать pickup-примитив на земле.
// На примитив вешается тег 'pickup' + атрибут __pickupTool с данными tool'а.
if (cmd === 'tools.drop') {
try {
const { toolId, name, model, params, x, y, z } = payload || {};
if (!toolId) return;
// Спавним простой куб как маркер pickup'а.
this.scene3d?.primitiveManager?.addInstance?.('cube', {
x: Number(x) || 0,
y: Number(y) || 0.5,
z: Number(z) || 0,
sx: 0.5, sy: 0.5, sz: 0.5,
color: '#ffd166',
material: 'neon',
name: `Pickup_${name || toolId}`,
anchored: true,
canCollide: true,
});
this.scheduleSceneSnapshot();
} catch (e) {
this._log('error', 'tools.drop failed: ' + (e?.message || e));
}
return;
}
if (cmd === 'inventory.clear') {
const inv = this.scene3d?.inventory;
if (inv) {
try { this.scene3d?.weapons?.unequip(); } catch (e) {}
inv.clear();
}
return;
}
// === Мультиплеер-API: общее состояние комнаты (Фаза 4.3) ===
if (cmd === 'room.set') {
// payload: { key, value }
if (payload && typeof payload.key === 'string') {
if (!this._roomState) this._roomState = {};
const changed = this._roomState[payload.key] !== payload.value;
this._roomState[payload.key] = payload.value;
// Если есть Colyseus-комната — отправляем серверу (он
// обновит общее state; серверная схема — отдельная задача).
try {
this.scene3d?._mpSync?.room?.send?.('scriptRoomSet', {
key: payload.key, value: payload.value,
});
} catch (e) { /* ignore */ }
// Локально сразу рассылаем событие изменения всем скриптам.
if (changed) {
this.routeGlobalEvent('roomChange', {
key: payload.key, value: payload.value,
});
}
}
return;
}
if (cmd === 'mp.sendTo') {
// payload: { sessionId, name, data } — адресное сообщение игроку.
if (payload) {
const mp = this.scene3d?._mpSync;
if (mp && mp.room && typeof mp.room.send === 'function') {
// С комнатой — через сервер (релей по sessionId).
try {
mp.room.send('scriptMessage', {
to: payload.sessionId,
name: payload.name,
data: payload.data,
});
} catch (e) { /* ignore */ }
} else if (payload.sessionId === 'local') {
// Single-player: сообщение «себе» — доставляем сразу.
this.routeGlobalEvent('mpMessage', {
from: 'local', name: payload.name, data: payload.data,
});
}
}
return;
}
// Phase 6.6: RemoteEvent от скрипта → сервер → все целевые клиенты.
if (cmd === 'mp.remoteFire') {
if (payload) {
const mp = this.scene3d?._mpSync;
if (mp && mp.room && typeof mp.room.send === 'function') {
try {
mp.room.send('scriptRemote', {
name: payload.name,
target: payload.target || 'all',
data: payload.data,
});
} catch (e) { /* ignore */ }
} else {
// Single-player: симулируем эхо самому себе через 1 кадр.
setTimeout(() => {
this.routeGlobalEvent('remoteEvent', {
from: 'local', name: payload.name, data: payload.data,
});
}, 0);
}
}
return;
}
// === Команды / Teams (Фаза 4.4) ===
if (cmd === 'teams.create') {
// payload: { name, color }
if (payload && typeof payload.name === 'string' && payload.name) {
if (!this._teams) this._teams = new Map();
this._teams.set(payload.name, {
name: payload.name,
color: typeof payload.color === 'string' ? payload.color : '#888888',
});
}
return;
}
if (cmd === 'teams.remove') {
if (payload && this._teams) {
this._teams.delete(payload.name);
// Если игрок был в этой команде — сбрасываем.
if (this._localPlayerTeam === payload.name) {
this._localPlayerTeam = null;
}
}
return;
}
if (cmd === 'player.setTeam') {
// payload: { team } — null/'' убирает команду.
const t = payload?.team;
let applied = null;
if (t == null || t === '') {
this._localPlayerTeam = null;
applied = '';
} else if (typeof t === 'string') {
// Назначаем только если команда существует.
if (this._teams?.has(t)) {
this._localPlayerTeam = t;
applied = t;
} else {
this._log('error', 'команда не создана: ' + t);
}
}
// С Colyseus-комнатой — синхронизируем команду на сервер,
// чтобы остальные игроки видели её в Player.team.
if (applied != null) {
try {
this.scene3d?._mpSync?.room?.send?.('setTeam', { team: applied });
} catch (e) { /* ignore */ }
}
return;
}
if (cmd === 'player.setSpeed') {
const player = this.scene3d?.player;
if (player) {
const m = Number(payload?.mul);
if (Number.isFinite(m) && m > 0) player._speedMul = m;
}
return;
}
if (cmd === 'player.setJumpPower') {
const player = this.scene3d?.player;
if (player) {
const m = Number(payload?.mul);
if (Number.isFinite(m) && m > 0) player._jumpPowerMul = m;
}
return;
}
if (cmd === 'player.setGravityMul') {
// Множитель гравитации (для GD-стиля нужно ~1.23 — поднимает 22 до 27).
// Не зависит от gravityDir — работает в обоих направлениях.
const player = this.scene3d?.player;
if (player) {
const m = Number(payload?.mul);
if (Number.isFinite(m) && m > 0) player._gravityMul = m;
}
return;
}
if (cmd === 'player.setShipMode') {
// GD-гейммод Ship: тап-удержание = подъём (вертолёт-стиль).
const player = this.scene3d?.player;
if (player) player._shipMode = !!payload?.enabled;
return;
}
if (cmd === 'player.setUfoMode') {
// GD-гейммод UFO: каждый отдельный тап Space = микропрыжок в воздухе.
const player = this.scene3d?.player;
if (player) player._ufoMode = !!payload?.enabled;
return;
}
if (cmd === 'player.setWaveMode') {
// GD-гейммод Wave: движение под ±45° (Space зажат — вверх, отпущен — вниз).
const player = this.scene3d?.player;
if (player) player._waveMode = !!payload?.enabled;
return;
}
if (cmd === 'player.setVy') {
// Прямое задание vy (для трамплинов, jump orb, boost-зон).
const player = this.scene3d?.player;
if (player) {
const v = Number(payload?.vy);
if (Number.isFinite(v)) player._vy = v;
}
return;
}
if (cmd === 'player.setRobotMode') {
// GD-гейммод Robot: variable-jump (высота = длительности удержания Space).
const player = this.scene3d?.player;
if (player) {
player._robotMode = !!payload?.enabled;
if (!player._robotMode) player._robotBoostLeft = 0;
}
return;
}
if (cmd === 'player.setDoubleJump') {
const player = this.scene3d?.player;
if (player) player._doubleJumpEnabled = !!payload?.enabled;
return;
}
if (cmd === 'player.playAnimation') {
const player = this.scene3d?.player;
if (player && typeof player.playEmote === 'function') {
const ok = player.playEmote(payload?.name);
if (!ok) {
this._log('error', 'playAnimation: эмоция не найдена — '
+ payload?.name + ' (доступно: wave, dance, cheer, sit)');
}
}
return;
}
if (cmd === 'player.stopAnimation') {
const player = this.scene3d?.player;
if (player && typeof player.stopEmote === 'function') player.stopEmote();
return;
}
if (cmd === 'player.setIceFriction') {
const player = this.scene3d?.player;
if (player) {
const v = Number(payload?.value);
if (Number.isFinite(v)) {
player._iceFriction = Math.max(0, Math.min(1, v));
}
}
return;
}
if (cmd === 'player.setAutoRun') {
const player = this.scene3d?.player;
if (player) {
const s = Number(payload?.speed);
if (Number.isFinite(s)) player._autoRunSpeed = Math.max(0, s);
}
return;
}
if (cmd === 'player.boostJump') {
const player = this.scene3d?.player;
if (player) {
const s = Number(payload?.strength);
if (Number.isFinite(s) && s > 0) {
// boostJump учитывает текущую гравитацию: при flipped — толкает к потолку (vy<0)
const gDir = player._gravityDir || 1;
const base = player.JUMP_VELOCITY * (player._jumpPowerMul || 1);
player._vy = base * s * gDir;
}
}
return;
}
if (cmd === 'player.flipGravity') {
// Меняет направление гравитации (как blue orb в GD): +1 ↔ -1
const player = this.scene3d?.player;
if (player) {
player._gravityDir = (player._gravityDir || 1) > 0 ? -1 : 1;
// Сбрасываем "second jump used" чтобы после флипа доступен прыжок
player._doubleJumpUsed = false;
}
return;
}
if (cmd === 'player.setGravityDir') {
// Явно задать направление: dir=1 (вниз) или -1 (вверх).
const player = this.scene3d?.player;
if (player) {
const d = Number(payload?.dir);
if (d === 1 || d === -1) {
player._gravityDir = d;
player._doubleJumpUsed = false;
}
}
return;
}
if (cmd === 'player.getGravityDir') {
// Возвращает текущее значение через broadcast-style "reply"
// Скрипту это нужно через геттер game.player.gravityDir — см. shim в Worker
return;
}
// === HUD / Input / App ===
if (cmd === 'hud.setVisible') {
try {
const v = !!payload?.visible;
this.scene3d?._setStdHudVisible?.(v);
} catch (e) {}
return;
}
if (cmd === 'hud.setHotbarVisible') {
try {
const v = !!payload?.visible;
this.scene3d?._setHotbarVisible?.(v);
} catch (e) {}
return;
}
if (cmd === 'hud.setHpVisible') {
try {
const v = !!payload?.visible;
this.scene3d?._setHpVisible?.(v);
} catch (e) {}
return;
}
if (cmd === 'input.setCursorMode') {
try {
const mode = payload?.mode === 'ui' ? 'ui' : 'game';
const player = this.scene3d?.player;
if (player?.setUiCursorMode) {
player.setUiCursorMode(mode === 'ui');
if (mode === 'ui') {
try { document.exitPointerLock?.(); } catch (e) {}
// Подписываемся на mouse-события и транслируем в Worker.
if (player.setUiMouseMoveCallback) {
let lastMM = 0;
player.setUiMouseMoveCallback((x, y) => {
const now = performance.now();
if (now - lastMM < 20) return;
lastMM = now;
this.routeGlobalEvent('mouseMove', { x, y });
});
}
if (player.setUiMouseDownCallback) {
player.setUiMouseDownCallback((x, y) => {
this.routeGlobalEvent('mouseDown', { x, y });
});
}
if (player.setUiMouseUpCallback) {
player.setUiMouseUpCallback((x, y) => {
this.routeGlobalEvent('mouseUp', { x, y });
});
}
} else if (player._requestPointerLockSafe) {
// Отписываемся при возврате в game-режим
if (player.setUiMouseMoveCallback) {
player.setUiMouseMoveCallback(null);
}
if (player.setUiMouseDownCallback) {
player.setUiMouseDownCallback(null);
}
if (player.setUiMouseUpCallback) {
player.setUiMouseUpCallback(null);
}
try { player._requestPointerLockSafe(); } catch (e) {}
}
// Сообщить редактору/плееру чтобы синхронизировать UI-state
try { this.scene3d?._onCursorModeChange?.(mode); } catch (e) {}
}
} catch (e) {}
return;
}
if (cmd === 'app.exit') {
try {
// В Kubikon-проекте 265 (Geometry Dash) и любых других —
// выход в ленту Kubikon-игр.
window.location.href = '/kubikon3d';
} catch (e) {}
return;
}
if (cmd === 'app.navigate') {
try {
const url = String(payload?.url || '');
if (url) window.location.href = url;
} catch (e) {}
return;
}
// === Универсальное хранилище сейвов (game.save.*) ===
if (cmd === 'save.get') {
this._saveGet(scriptId, payload);
return;
}
if (cmd === 'save.getAll') {
this._saveGetAll(scriptId, payload);
return;
}
if (cmd === 'save.set') {
this._saveSet(payload);
return;
}
if (cmd === 'save.merge') {
this._saveMerge(payload);
return;
}
if (cmd === 'save.leaderboard') {
this._saveLeaderboard(scriptId, payload);
return;
}
if (cmd === 'economy.reward') {
this._economyReward(scriptId, payload);
return;
}
if (cmd === 'economy.dailyCheck') {
this._economyDailyCheck(scriptId, payload);
return;
}
if (cmd === 'economy.getBalance') {
this._economyGetBalance(scriptId, payload);
return;
}
if (cmd === 'economy.spend') {
this._economySpend(scriptId, payload);
return;
}
if (cmd === 'camera.shake') {
const player = this.scene3d?.player;
if (player) {
const amp = Number(payload?.amp);
const dur = Number(payload?.dur);
if (Number.isFinite(amp) && Number.isFinite(dur) && amp > 0 && dur > 0) {
player._cameraShakeAmp = amp;
player._cameraShakeLeft = dur;
}
}
return;
}
// === Камера: FOV, привязка, катсцены (Фаза 5.7) ===
if (cmd === 'camera.fov') {
this.scene3d?.player?.setCameraFov?.(payload?.degrees);
return;
}
if (cmd === 'camera.focus') {
// payload: { ref, distance, height } — следить за объектом.
const player = this.scene3d?.player;
if (player && payload && typeof payload.ref === 'string') {
const ref = payload.ref;
// getTarget резолвит позицию объекта каждый кадр.
const getTarget = () => {
const tgt = this._resolveTweenTarget(ref);
if (tgt && tgt.data) {
return { x: tgt.data.x, y: tgt.data.y, z: tgt.data.z };
}
return null;
};
player.cameraFocusOn(getTarget, {
distance: payload.distance, height: payload.height,
});
}
return;
}
if (cmd === 'camera.cutscene') {
// payload: { points: [{x,y,z}], lookAt: [{x,y,z}], segDuration }
const player = this.scene3d?.player;
if (player && payload && Array.isArray(payload.points)) {
player.cameraCutscene(
payload.points, payload.lookAt, payload.segDuration,
// onDone — событие скрипту.
() => this.routeGlobalEvent('cutsceneDone', {}),
);
}
return;
}
if (cmd === 'camera.reset') {
this.scene3d?.player?.cameraReset?.();
return;
}
if (cmd === 'player.setSkinVisible') {
const player = this.scene3d?.player;
if (player) {
const v = !!payload?.visible;
player._skinVisibleScripted = v;
// Применяем сразу — но также флаг будет применяться каждый
// кадр в _tick (на случай если меши ещё не загружены сейчас).
if (Array.isArray(player._modelMeshes)) {
for (const m of player._modelMeshes) {
try { m.setEnabled(v); } catch (e) {}
}
}
}
return;
}
// === Задача 07: скины игрока ===
if (cmd === 'player.setSkin') {
const player = this.scene3d?.player;
const slug = payload?.slug;
if (player && typeof slug === 'string' && slug) {
const typeId = this._resolveSkinTypeId(slug);
// Помечаем доступным (setSkin неявно разблокирует).
this._ensureSkinState();
this._skinState.unlocked.add(slug);
this._skinState.current = slug;
// Асинхронная перезагрузка модели; по завершении шлём skinChanged.
Promise.resolve(player.reloadSkin?.(typeId)).then(() => {
this.routeGlobalEvent?.('skinChanged', { slug });
try { this.scene3d?._onPlayerSkinChanged?.(slug); } catch (e) {}
}).catch((e) => {
this._log('error', 'setSkin failed: ' + (e?.message || e));
});
}
return;
}
if (cmd === 'player.unlockSkin') {
const slug = payload?.slug;
if (typeof slug === 'string' && slug) {
this._ensureSkinState();
this._skinState.unlocked.add(slug);
this.routeGlobalEvent?.('skinUnlocked', { slug });
}
return;
}
if (cmd === 'player.openSkinShop') {
this._ensureSkinState();
try { this.scene3d?._openSkinShop?.(); } catch (e) {}
return;
}
if (cmd === 'player.closeSkinShop') {
try { this.scene3d?._closeSkinShop?.(); } catch (e) {}
return;
}
if (cmd === 'player.setSkinCoins') {
this._ensureSkinState();
const n = Number(payload?.amount);
if (Number.isFinite(n)) {
this._skinState.coins = Math.max(0, Math.floor(n));
this._broadcastSkinsSnapshot();
}
return;
}
// Покупка скина из встроенного магазина (намерение от React-оверлея
// или из скрипта). Списывает локальные рублики, разблокирует, надевает.
if (cmd === 'player.buySkin') {
this._ensureSkinState();
const slug = payload?.slug;
const price = Number(payload?.price) || 0;
if (typeof slug !== 'string' || !slug) return;
const st = this._skinState;
const owned = st.unlocked.has(slug);
if (owned) {
// Уже куплен — просто надеть.
this._handleCommand(scriptId, 'player.setSkin', { slug });
return;
}
if (st.coins < price) {
// Не хватает — сообщаем оверлею.
try { this.scene3d?._onSkinBuyResult?.({ slug, ok: false, reason: 'no_coins' }); } catch (e) {}
return;
}
st.coins -= price;
st.unlocked.add(slug);
this._handleCommand(scriptId, 'player.setSkin', { slug });
this._broadcastSkinsSnapshot();
try { this.scene3d?._onSkinBuyResult?.({ slug, ok: true }); } catch (e) {}
return;
}
if (cmd === 'player.setCameraMode') {
const player = this.scene3d?.player;
if (player && typeof payload?.mode === 'string') {
const valid = ['first', 'third', 'front', 'sideview', 'lockfirst'];
if (valid.includes(payload.mode)) {
const wasFirst = (player._cameraMode === 'first' || player._cameraMode === 'lockfirst');
player._cameraMode = (payload.mode === 'lockfirst') ? 'first' : payload.mode;
player._lockFirstPerson = (payload.mode === 'lockfirst');
try { player._applyCameraMode?.(); } catch (e) {}
// Запросить/снять lock в зависимости от нового режима
const isFirst = (player._cameraMode === 'first');
if (isFirst && !wasFirst) player._requestPointerLockSafe?.();
else if (!isFirst && wasFirst && !player._shiftLock) {
if (document.pointerLockElement === player.canvas) {
try { document.exitPointerLock(); } catch (e) {}
}
}
try { player._applyCursorVisibility?.(); } catch (e) {}
}
}
return;
}
// Задача 02: setCameraZoom / setCameraZoomLimits / setShiftLock
if (cmd === 'player.setCameraZoom') {
const player = this.scene3d?.player;
if (player && typeof player.setCameraZoom === 'function') {
try { player.setCameraZoom(payload?.distance); } catch (e) {}
}
return;
}
if (cmd === 'player.setCameraZoomLimits') {
const player = this.scene3d?.player;
if (player && typeof player.setCameraZoomLimits === 'function') {
try { player.setCameraZoomLimits(payload?.min, payload?.max); } catch (e) {}
}
return;
}
if (cmd === 'player.setShiftLock') {
const player = this.scene3d?.player;
if (player && typeof player.setShiftLock === 'function') {
try { player.setShiftLock(payload?.on); } catch (e) {}
}
return;
}
// Задача 02: input.setMouseBehavior / setMouseIconVisible
if (cmd === 'input.setMouseBehavior') {
const player = this.scene3d?.player;
if (player && typeof player.setMouseBehavior === 'function') {
try { player.setMouseBehavior(payload?.mode); } catch (e) {}
}
return;
}
if (cmd === 'input.setMouseIconVisible') {
const player = this.scene3d?.player;
if (player && typeof player.setMouseIconVisible === 'function') {
try { player.setMouseIconVisible(payload?.visible); } catch (e) {}
}
return;
}
// Задача 02: environment API
if (cmd === 'environment.setSkyColor') {
try {
const hex = String(payload?.color || '');
const scene = this.scene3d?.scene;
if (scene && hex) {
// Парсим #rrggbb → Color4
const m = hex.match(/^#?([0-9a-f]{6})$/i);
if (m) {
const n = parseInt(m[1], 16);
const r = ((n >> 16) & 0xff) / 255;
const g = ((n >> 8) & 0xff) / 255;
const b = (n & 0xff) / 255;
// Color4 импортирован в начале файла
if (scene.clearColor) {
scene.clearColor.r = r;
scene.clearColor.g = g;
scene.clearColor.b = b;
scene.clearColor.a = 1;
}
}
}
} catch (e) {
this._log('error', 'environment.setSkyColor failed: ' + (e?.message || e));
}
return;
}
if (cmd === 'environment.setFog') {
try {
const env = this.scene3d?.environment;
if (env && typeof env.setFog === 'function') {
env.setFog(payload?.enabled, payload?.color, payload?.density);
}
} catch (e) {}
return;
}
if (cmd === 'environment.setTimeOfDay') {
try {
const env = this.scene3d?.environment;
if (env && typeof env.setTimeOfDay === 'function') {
env.setTimeOfDay(payload?.hours);
}
} catch (e) {}
return;
}
if (cmd === 'player.setCrouch') {
const player = this.scene3d?.player;
if (player) {
const want = !!payload?.enabled;
player._scriptForcedCrouch = want;
if (want !== player._crouching) {
player._crouching = want;
const newHalfH = want ? player.HALF_H_CROUCH : player.HALF_H_NORMAL;
// КРИТИЧНО: _pos — центр капсулы. При смене HALF_H
// центр надо сдвинуть на ту же дельту, иначе «низ ног»
// (_pos.y - HALF_H) меняется и персонажа подкидывает
// вверх при приседе. Сдвигаем — низ ног остаётся на месте.
const dH = newHalfH - player.HALF_H;
player.HALF_H = newHalfH;
if (player._pos) player._pos.y += dH;
}
}
return;
}
if (cmd === 'player.setFacing') {
// Развернуть модель игрока на угол yaw (радианы). Полезно
// в кат-сценах, когда игрок стоит лицом куда нужно.
const player = this.scene3d?.player;
if (player) {
const yaw = Number(payload?.yaw);
if (Number.isFinite(yaw)) {
player._modelYaw = yaw;
if (player._modelRoot) player._modelRoot.rotation.y = yaw;
}
}
return;
}
if (cmd === 'player.emote') {
// Проиграть эмоцию персонажа (wave/dance/cheer/sit/paint).
// Работает только для R15-скинов.
const player = this.scene3d?.player;
if (player && typeof player.playEmote === 'function') {
const name = payload?.name;
if (typeof name === 'string') {
try { player.playEmote(name); } catch (e) { /* ignore */ }
}
}
return;
}
if (cmd === 'player.stopEmote') {
const player = this.scene3d?.player;
if (player && typeof player.stopEmote === 'function') {
try { player.stopEmote(); } catch (e) { /* ignore */ }
}
return;
}
if (cmd === 'timer.start' || cmd === 'timer.stop' || cmd === 'timer.submit') {
// Делегируем в scene3d — у него есть колбэки для UI/API
const fn = this.scene3d?.[cmd === 'timer.start' ? '_timerStart'
: cmd === 'timer.stop' ? '_timerStop' : '_timerSubmit'];
if (typeof fn === 'function') {
try { fn.call(this.scene3d); } catch (e) { /* ignore */ }
}
return;
}
if (cmd === 'self.move') {
this._applySelfMove(payload);
return;
}
if (cmd === 'scene.rotate') {
try {
const ry = Number(payload?.rotationY);
if (!Number.isFinite(ry)) return;
const pm = this.scene3d?.primitiveManager;
if (!pm) return;
const rid = this._resolvePrimitiveId(payload?.id);
const data = rid != null ? pm.instances.get(rid) : null;
if (data) {
data.rotationY = ry;
if (data.mesh?.rotation) {
data.mesh.rotation.y = ry;
if (data._worldMatrixFrozen) {
try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {}
data._worldMatrixFrozen = false;
}
}
}
// snapshot не шлём — объект всё ещё findOne'ится по тому же ref'у,
// только rotationY обновился, для скрипта это прозрачно.
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[GameRuntime] scene.rotate failed', e);
}
return;
}
if (cmd === 'scene.setRotation') {
try {
const rx = Number(payload?.rx);
const ry = Number(payload?.ry);
const rz = Number(payload?.rz);
if (!Number.isFinite(rx) || !Number.isFinite(ry) || !Number.isFinite(rz)) return;
const pm = this.scene3d?.primitiveManager;
if (!pm) return;
const rid = this._resolvePrimitiveId(payload?.id);
const data = rid != null ? pm.instances.get(rid) : null;
if (data) {
data.rotationX = rx;
data.rotationY = ry;
data.rotationZ = rz;
if (data.mesh?.rotation) {
data.mesh.rotation.x = rx;
data.mesh.rotation.y = ry;
data.mesh.rotation.z = rz;
if (data._worldMatrixFrozen) {
try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {}
data._worldMatrixFrozen = false;
}
}
}
} catch (e) {
console.warn('[GameRuntime] scene.setRotation failed', e);
}
return;
}
if (cmd === 'scene.setCollide') {
try {
const canCollide = !!payload?.canCollide;
const pm = this.scene3d?.primitiveManager;
if (!pm) return;
const rid = this._resolvePrimitiveId(payload?.id);
const data = rid != null ? pm.instances.get(rid) : null;
if (data) {
data.canCollide = canCollide;
if (data.mesh?.metadata) data.mesh.metadata.canCollide = canCollide;
this.scene3d?.physics?.setSpatialDirty?.();
}
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[GameRuntime] scene.setCollide failed', e);
}
return;
}
if (cmd === 'scene.setColor') {
try {
const color = payload?.color;
if (typeof color !== 'string') return;
const pm = this.scene3d?.primitiveManager;
if (!pm) return;
const rid = this._resolvePrimitiveId(payload?.id);
const data = rid != null ? pm.instances.get(rid) : null;
if (data) {
data.color = color;
if (data.mesh?.material) {
const c = Color3.FromHexString(color);
data.mesh.material.diffuseColor = c;
// Если материал neon — обновляем emissive тоже
if (data.material === 'neon') {
data.mesh.material.emissiveColor = c;
}
}
}
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[GameRuntime] scene.setColor failed', e);
}
return;
}
if (cmd === 'scene.setOpacity') {
try {
const id = this._resolvePrimitiveId(payload?.ref);
const pm = this.scene3d?.primitiveManager;
if (id != null && pm) pm.updateInstance(id, { opacity: payload.opacity });
} catch (e) {
console.warn('[GameRuntime] scene.setOpacity failed', e);
}
return;
}
if (cmd === 'scene.setScale') {
try {
const id = this._resolvePrimitiveId(payload?.ref);
const pm = this.scene3d?.primitiveManager;
if (id != null && pm) {
pm.updateInstance(id, { sx: payload.sx, sy: payload.sy, sz: payload.sz });
}
} catch (e) {
console.warn('[GameRuntime] scene.setScale failed', e);
}
return;
}
if (cmd === 'scene.setMaterial') {
try {
const id = this._resolvePrimitiveId(payload?.ref);
const pm = this.scene3d?.primitiveManager;
if (id != null && pm) pm.updateInstance(id, { material: payload.material });
} catch (e) {
console.warn('[GameRuntime] scene.setMaterial failed', e);
}
return;
}
if (cmd === 'scene.clone') {
try {
const id = this._resolvePrimitiveId(payload?.ref);
const pm = this.scene3d?.primitiveManager;
if (id == null || !pm) return;
const src = pm.instances.get(id);
if (!src) return;
const newId = pm.addInstance(src.type, {
x: (src.x || 0) + (Number(payload.dx) || 0),
y: (src.y || 0) + (Number(payload.dy) || 0),
z: (src.z || 0) + (Number(payload.dz) || 0),
sx: src.sx, sy: src.sy, sz: src.sz,
color: src.color, material: src.material,
rotationY: src.rotationY,
});
if (newId != null) {
if (!this._localToReal) this._localToReal = new Map();
this._localToReal.set(payload.newRef, 'primitive:' + newId);
this.scheduleSceneSnapshot();
}
} catch (e) {
console.warn('[GameRuntime] scene.clone failed', e);
}
return;
}
if (cmd === 'self.registerInteract') {
try {
const t = payload?.target;
if (!t) return;
// ref объекта-носителя скрипта
const ref = (t.kind && (t.ref ?? t.id) != null)
? (t.kind + ':' + (t.ref ?? t.id)) : null;
if (!ref) return;
// не дублируем — один объект = одна запись
if (!this._interactables.some(it => it.ref === ref)) {
this._interactables.push({
ref,
target: t,
text: payload.text || 'Взаимодействовать',
distance: Number(payload.distance) || 4,
key: payload.key || 'e',
});
}
} catch (e) {
console.warn('[GameRuntime] self.registerInteract failed', e);
}
return;
}
if (cmd === 'scene.setLabel') {
try {
const ref = payload?.ref;
const text = payload?.text;
if (typeof ref !== 'string') return;
// ленивое создание менеджера меток
if (!this.scene3d._labelManager) {
this.scene3d._labelManager = new LabelManager(this.scene3d.scene);
}
const lm = this.scene3d._labelManager;
// резолвим меш объекта (примитив или модель)
const tgt = this._resolveTweenTarget(ref);
const mesh = tgt && (tgt.data.mesh || tgt.data.rootMesh || tgt.data.rootNode);
if (mesh) {
lm.setLabel(ref, mesh, text, payload?.opts || {});
}
} catch (e) {
console.warn('[GameRuntime] scene.setLabel failed', e);
}
return;
}
if (cmd === 'scene.clearLabel') {
try {
const lm = this.scene3d?._labelManager;
if (lm && typeof payload?.ref === 'string') lm.clearLabel(payload.ref);
} catch (e) {
console.warn('[GameRuntime] scene.clearLabel failed', e);
}
return;
}
if (cmd === 'scene.setData') {
try {
const { ref, key, value } = payload || {};
if (typeof ref !== 'string' || typeof key !== 'string') return;
if (!this._objectData[ref]) this._objectData[ref] = {};
this._objectData[ref][key] = value;
this.scheduleDataSnapshot();
} catch (e) {
console.warn('[GameRuntime] scene.setData failed', e);
}
return;
}
// === Phase 6.2: Instance-модель ===
// inst.set — изменить простое свойство Instance (name).
// Сложные свойства (color/visible/...) идут через scene.set* и реализованы выше.
if (cmd === 'inst.set') {
try {
const { ref, prop, value } = payload || {};
if (typeof ref !== 'string' || typeof prop !== 'string') return;
if (prop === 'name') {
// Меняем name в реальном объекте сцены.
// Парсим ref: 'primitive:_local_3' / 'primitive:123' / 'model:5' / 'block:x,y,z'
const colon = ref.indexOf(':');
if (colon < 0) return;
const kind = ref.slice(0, colon);
if (kind === 'primitive') {
const pid = this._resolvePrimitiveId(ref.slice(colon + 1));
const data = this.scene3d?.primitiveManager?.instances?.get(pid);
if (data) {
data.name = String(value || '');
this.scheduleSceneSnapshot();
this.scene3d?._onSceneChange?.();
}
} else if (kind === 'model') {
const id = Number(ref.slice(colon + 1));
const data = this.scene3d?.modelManager?.instances?.get(id);
if (data) {
data.name = String(value || '');
this.scheduleSceneSnapshot();
this.scene3d?._onSceneChange?.();
}
}
}
} catch (e) {
console.warn('[GameRuntime] inst.set failed', e);
}
return;
}
// inst.setParent — переустановить родителя в иерархии.
// Хранится в _objectData[ref].__parent (string ref) и зеркально в
// _objectData[parentRef].__children (массив ref'ов).
if (cmd === 'inst.setParent') {
try {
const { ref, parentRef } = payload || {};
if (typeof ref !== 'string') return;
// Старый родитель — убираем из его __children.
const oldData = this._objectData[ref];
const oldParent = oldData && oldData.__parent;
if (oldParent && this._objectData[oldParent]) {
const arr = this._objectData[oldParent].__children;
if (Array.isArray(arr)) {
this._objectData[oldParent].__children = arr.filter(r => r !== ref);
}
}
// Новый родитель.
if (!this._objectData[ref]) this._objectData[ref] = {};
this._objectData[ref].__parent = parentRef || null;
if (parentRef) {
if (!this._objectData[parentRef]) this._objectData[parentRef] = {};
const kids = Array.isArray(this._objectData[parentRef].__children)
? this._objectData[parentRef].__children : [];
if (!kids.includes(ref)) kids.push(ref);
this._objectData[parentRef].__children = kids;
}
this.scheduleDataSnapshot();
} catch (e) {
console.warn('[GameRuntime] inst.setParent failed', e);
}
return;
}
// === Теги объектов (Фаза 5.6) — game.scene.tag/untag/getTagged ===
// Теги хранятся как массив в _objectData[ref].__tags — переиспользуем
// готовый канал dataSnapshot, отдельная синхронизация не нужна.
if (cmd === 'scene.tag' || cmd === 'scene.untag') {
try {
const { ref, tag } = payload || {};
if (typeof ref !== 'string' || typeof tag !== 'string' || !tag) return;
if (!this._objectData[ref]) this._objectData[ref] = {};
const cur = Array.isArray(this._objectData[ref].__tags)
? this._objectData[ref].__tags : [];
this._objectData[ref].__tags = cmd === 'scene.tag'
? (cur.includes(tag) ? cur : [...cur, tag])
: cur.filter(t => t !== tag);
this.scheduleDataSnapshot();
} catch (e) {
console.warn('[GameRuntime] scene.tag failed', e);
}
return;
}
// === Phase 6.5: Rapier3D физический движок ===
if (cmd === 'physics.setBodyType') {
// payload: { ref, bodyType, mass?, friction?, restitution? }
const ref = payload?.ref;
const bodyType = payload?.bodyType || 'dynamic';
if (typeof ref !== 'string') return;
this._physicsDo((pw) => {
if (bodyType === 'none') {
pw.removeBody(ref);
return;
}
const desc = this._resolvePrimitiveForPhysics(ref);
if (!desc) {
console.warn('[GameRuntime] physics.setBodyType: не нашёл primitive', ref);
return;
}
if (pw.bodies.has(ref)) pw.removeBody(ref);
const ok = pw.addBody(ref, {
...desc,
bodyType,
mass: payload.mass != null ? payload.mass : desc.mass,
friction: payload.friction,
restitution: payload.restitution,
});
// eslint-disable-next-line no-console
console.log('[GameRuntime] physics.setBodyType:', ref, '→', bodyType, ok ? 'OK' : 'FAIL',
'pos=', desc.position, 'size=', desc.size);
});
return;
}
if (cmd === 'physics.applyImpulseV2') {
const { ref, ix, iy, iz } = payload || {};
if (typeof ref !== 'string') return;
this._physicsDo((pw) => pw.applyImpulse(ref, ix, iy, iz));
return;
}
if (cmd === 'physics.setVelocityV2') {
const { ref, vx, vy, vz } = payload || {};
if (typeof ref !== 'string') return;
this._physicsDo((pw) => pw.setVelocity(ref, vx, vy, vz));
return;
}
if (cmd === 'physics.raycastV2') {
// Sync raycast в воркер не вернуть (асинхронный канал). Игнорим:
// используется через game.physics.raycast (legacy, синхронный по AABB).
// V2-вариант -- через reqId, см. ниже physics.raycastReq.
return;
}
if (cmd === 'physics.raycastReq') {
// payload: { reqId, scriptId, origin, dir, maxDist }
const { reqId, origin, dir, maxDist } = payload || {};
if (!this._physicsWorld?.isReady()) {
// Если физика не готова -- отвечаем пустым результатом.
const sb = this.sandboxes.find(s => s.scriptId === scriptId);
sb?.sendCommand?.('physicsResponse', { reqId, result: null });
return;
}
const r = this._physicsWorld.raycast(origin, dir, maxDist || 100);
const sb = this.sandboxes.find(s => s.scriptId === scriptId);
sb?.sendCommand?.('physicsResponse', { reqId, result: r });
return;
}
if (cmd === 'physics.addJoint') {
// payload: { type: 'hinge'|'distance', refA, refB, anchorA, anchorB, axis }
const { type, refA, refB, anchorA, anchorB, axis, localRef } = payload || {};
this._physicsDo((pw) => {
let jointId = null;
if (type === 'hinge') {
jointId = pw.addHinge(refA, refB, { anchorA, anchorB, axis });
} else {
jointId = pw.addDistance(refA, refB, { anchorA, anchorB });
}
if (jointId != null && localRef) {
if (!this._physicsJointLocalToReal) this._physicsJointLocalToReal = new Map();
this._physicsJointLocalToReal.set(localRef, jointId);
}
});
return;
}
if (cmd === 'physics.removeJoint') {
const { localRef } = payload || {};
if (!localRef) return;
this._physicsDo((pw) => {
const jointId = this._physicsJointLocalToReal?.get(localRef);
if (jointId != null) {
pw.removeJoint(jointId);
this._physicsJointLocalToReal.delete(localRef);
}
});
return;
}
// === Collision groups (Фаза 5.9) — проходимость объекта/группы ===
// physics.passThrough — игрок проходит сквозь объект (объект виден).
// target: ref одного объекта ИЛИ тег (тогда применяется ко всей
// группе объектов с этим тегом — теги = collision groups).
if (cmd === 'physics.passThrough') {
try {
const { target, on } = payload || {};
if (typeof target !== 'string' || !target) return;
const pm = this.scene3d?.primitiveManager;
if (!pm) return;
// canCollide = !on (passThrough=true → коллизия выключена).
const canCollide = !on;
// Собираем список ref: либо один объект, либо все с тегом.
let refs;
if (target.indexOf(':') >= 0) {
refs = [target]; // похоже на ref объекта
} else {
// Тег — все объекты с ним.
refs = [];
for (const r of Object.keys(this._objectData)) {
const bag = this._objectData[r];
if (bag && Array.isArray(bag.__tags) && bag.__tags.includes(target)) {
refs.push(r);
}
}
}
for (const r of refs) {
const rid = this._resolvePrimitiveId(r);
if (rid != null) pm.updateInstance(rid, { canCollide });
}
// Сбрасываем кэш spatial-grid физики — иначе grid до 50мс
// держит старое состояние, и при возврате твёрдости (on=false)
// UNSTUCK не видит стену, игрок застревает в ней.
this.scene3d?.physics?.invalidateSpatialGrid?.();
} catch (e) {
console.warn('[GameRuntime] physics.passThrough failed', e);
}
return;
}
if (cmd === 'physics.setVelocity' || cmd === 'physics.applyImpulse') {
try {
const id = this._resolvePrimitiveId(payload?.ref);
const pm = this.scene3d?.primitiveManager;
const dm = this.scene3d?.dynamics;
if (id == null || !pm || !dm) return;
const data = pm.instances.get(id);
if (!data) return;
const isImpulse = cmd === 'physics.applyImpulse';
const vx = isImpulse ? payload.ix : payload.vx;
const vy = isImpulse ? payload.iy : payload.vy;
const vz = isImpulse ? payload.iz : payload.vz;
const ok = dm.applyToInstance(data, vx, vy, vz, isImpulse ? 'impulse' : 'set');
if (!ok) {
this._log('error', cmd + ': объект закреплён (anchored) — '
+ 'физика работает только для незакреплённых объектов');
}
} catch (e) {
console.warn('[GameRuntime] ' + cmd + ' failed', e);
}
return;
}
if (cmd === 'physics.explode') {
try {
const { x, y, z, radius, damage, force } = payload || {};
const r = Number(radius) || 3;
// визуальный эффект взрыва
this._handleCommand(scriptId, 'scene.particles', {
type: 'explosion', position: { x, y, z },
duration: 1.2, count: 2, color: null,
});
// урон игроку если в радиусе
const player = this.scene3d?.player;
if (player && Number(damage) > 0) {
const pp = player._pos || player.position;
if (pp) {
const dx = pp.x - x, dy = (pp.y || 0) - y, dz = pp.z - z;
if (dx*dx + dy*dy + dz*dz <= r*r) {
try { player.takeDamage(Number(damage), 'explosion'); } catch (e) {}
}
}
}
// убиваем мобов в радиусе
const zm = this.scene3d?.zombieManager;
if (zm && typeof zm.getMobsSnapshot === 'function') {
const mobs = zm.getMobsSnapshot();
for (const m of mobs) {
const dx = m.x - x, dy = (m.y || 0) - y, dz = m.z - z;
if (dx*dx + dy*dy + dz*dz <= r*r) {
try { zm.killById(m.id); } catch (e) {}
}
}
}
} catch (e) {
console.warn('[GameRuntime] physics.explode failed', e);
}
return;
}
if (cmd === 'tween.start') {
this._startTween(scriptId, payload);
return;
}
if (cmd === 'tween.cancel') {
const tid = payload?.tweenId;
if (tid != null) {
const i = this._tweens.findIndex(t => t.tweenId === tid && t.scriptId === scriptId);
if (i >= 0) this._tweens.splice(i, 1);
}
return;
}
// === Задача 03: GUI tween ===
if (cmd === 'gui.tween') {
try {
const guiId = payload?.id;
if (typeof guiId !== 'string' || !guiId) return;
const gm = this.scene3d?.guiManager;
if (!gm) return;
// Резолв localRef → realId если есть
let realId = guiId;
if (this._guiLocalToReal?.has(guiId)) realId = this._guiLocalToReal.get(guiId);
const el = gm.elements?.find(e => e.id === realId);
if (!el) return;
if (!this._guiTweens) this._guiTweens = [];
// Снимок начальных значений по тем ключам что есть в props
const props = payload.props || {};
const propKeys = Object.keys(props);
// Перебивка: убрать существующие tween'ы на ТОТ ЖЕ id,
// которые анимируют ХОТЯ БЫ ОДИН из этих же ключей.
// Это поведение Roblox TweenService: новый tween на тот же ключ перебивает старый.
for (let j = this._guiTweens.length - 1; j >= 0; j--) {
const old = this._guiTweens[j];
if (old.realId !== realId) continue;
const oldKeys = Object.keys(old.target);
const overlap = oldKeys.some(k => propKeys.includes(k));
if (overlap) this._guiTweens.splice(j, 1);
}
const start = {};
for (const k of propKeys) {
if (k in el) start[k] = el[k];
else if (k === 'scaleX' || k === 'scaleY' || k === 'rotation') start[k] = el[k] || (k === 'rotation' ? 0 : 1);
}
this._guiTweens.push({
tweenId: payload.tweenId,
scriptId,
realId,
start, target: { ...props },
elapsed: 0,
duration: Math.max(0.001, Number(payload.duration) || 0.5),
delay: Math.max(0, Number(payload.delay) || 0),
easing: payload.easing || 'ease',
repeat: Number.isFinite(payload.repeat) ? payload.repeat : 0,
reverses: !!payload.reverses,
iter: 0,
dir: 1, // 1 = вперёд, -1 = обратно (для reverses)
});
} catch (e) {
this._log('error', 'gui.tween failed: ' + (e?.message || e));
}
return;
}
if (cmd === 'gui.cancelTween') {
const tid = payload?.tweenId;
if (tid != null && this._guiTweens) {
const i = this._guiTweens.findIndex(t => t.tweenId === tid);
if (i >= 0) this._guiTweens.splice(i, 1);
}
return;
}
// === Задача 04: модал-сцены ===
if (cmd === 'modal.open') {
try {
const mm = this.scene3d?.modalManager;
if (!mm) return;
// Резолв spotlights: строки-id → ref-объекты {kind:'primitive', id} как обычно
const opts = { ...(payload?.opts || {}) };
if (Array.isArray(opts.spotlights)) {
opts.spotlights = opts.spotlights.map(r => this._normRef ? this._normRef(r) : r);
}
if (opts.cameraOverride && opts.cameraOverride.target) {
opts.cameraOverride = {
...opts.cameraOverride,
target: this._normRef ? this._normRef(opts.cameraOverride.target) : opts.cameraOverride.target,
};
}
const modalId = mm.open(opts);
// Подписка чтобы автоматически слать tweenDone-стиль событий
// на конкретный скрипт (тот кто открыл) — для onClose.
if (!mm._runtimeBoundOnClose) {
mm._runtimeBoundOnClose = true;
mm.onClose((closedId) => {
// Шлём событие всем sandbox'ам — они роутят к зарегистрированным fn
this.routeGlobalEvent?.('modalClosed', { id: closedId });
});
}
// Ответ обратно в worker: фактический modalId (юзер мог вернуть из open)
const sb = this.sandboxes.find(s => s.scriptId === scriptId);
if (sb && payload?.replyId != null) {
sb.sendGlobalEvent({ type: 'modalOpened', replyId: payload.replyId, modalId });
}
} catch (e) {
this._log('error', 'modal.open failed: ' + (e?.message || e));
}
return;
}
if (cmd === 'modal.close') {
try {
const mm = this.scene3d?.modalManager;
mm?.close?.(payload?.modalId);
} catch (e) {}
return;
}
if (cmd === 'modal.update') {
try {
const mm = this.scene3d?.modalManager;
mm?.update?.(payload?.modalId, payload?.patch);
} catch (e) {}
return;
}
if (cmd === 'scene.setTexture') {
// Установить динамическую текстуру примитива из dataURL.
// Используется GD-скинами куба (canvas-фабрика в скрипте → dataURL → текстура).
try {
const dataUrl = payload?.dataUrl;
if (typeof dataUrl !== 'string') return;
const pm = this.scene3d?.primitiveManager;
if (!pm) return;
const rid = this._resolvePrimitiveId(payload?.id);
if (rid != null) pm.setTexture(rid, dataUrl);
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[GameRuntime] scene.setTexture failed', e);
}
return;
}
// === AUDIO: GD-музыка и SFX ===
if (cmd === 'audio.playSfx') {
try {
const am = this.scene3d?.gameAudioManager;
if (am && payload?.name) am.playSfx(payload.name);
} catch (e) {
console.warn('[GameRuntime] audio.playSfx failed', e);
}
return;
}
if (cmd === 'audio.playMusic') {
try {
const am = this.scene3d?.gameAudioManager;
if (am && payload?.trackId) am.playMusic(payload.trackId);
} catch (e) {
console.warn('[GameRuntime] audio.playMusic failed', e);
}
return;
}
if (cmd === 'audio.stopMusic') {
try {
const am = this.scene3d?.gameAudioManager;
if (am) am.stopMusic();
} catch (e) {
console.warn('[GameRuntime] audio.stopMusic failed', e);
}
return;
}
if (cmd === 'audio.setMuted') {
try {
const am = this.scene3d?.gameAudioManager;
if (am) am.setMuted(!!payload?.muted);
} catch (e) {
console.warn('[GameRuntime] audio.setMuted failed', e);
}
return;
}
if (cmd === 'scene.setVisible') {
try {
const kind = payload?.kind;
const id = payload?.id;
const visible = !!payload?.visible;
if (id == null) return;
if (kind === 'primitive') {
const pm = this.scene3d?.primitiveManager;
if (!pm) return;
const rid = this._resolvePrimitiveId(id);
const data = rid != null ? pm.instances.get(rid) : null;
if (data) {
data.visible = visible;
if (data.mesh) data.mesh.setEnabled(visible);
}
} else if (kind === 'model') {
const mm = this.scene3d?.modelManager;
if (!mm) return;
let data = mm.instances.get(id);
if (!data && typeof id === 'string') {
const n = Number(id);
if (Number.isFinite(n)) data = mm.instances.get(n);
}
if (data) {
data.visible = visible;
if (data.rootMesh) data.rootMesh.setEnabled(visible);
}
}
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[GameRuntime] scene.setVisible failed', e);
}
return;
}
if (cmd === 'scene.setFolderYaw') {
try {
const fm = this.scene3d?.folderManager;
if (!fm) return;
const name = payload?.folderName;
const angle = Number(payload?.angle);
const pivot = payload?.pivot;
if (typeof name !== 'string' || !Number.isFinite(angle) || !pivot) return;
const folder = fm.findByName(name);
if (!folder) return;
fm.setFolderYawY(folder.id, angle, pivot);
this.scheduleSceneSnapshot();
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[GameRuntime] scene.setFolderYaw failed', e);
}
return;
}
if (cmd === 'self.delete') {
this._applySelfDelete(payload);
return;
}
if (cmd === 'scene.spawn') {
this._applySceneSpawn(scriptId, payload);
return;
}
if (cmd === 'scene.delete') {
this._applySceneDelete(payload);
return;
}
if (cmd === 'ui.set' || cmd === 'ui.flash' || cmd === 'ui.clear') {
// Просто пробрасываем в onHud колбэк — UI на стороне React сам отрисует
if (this._onHud) {
try { this._onHud({ cmd, payload }); } catch (e) { /* ignore */ }
}
return;
}
if (cmd === 'sound.play') {
this._playSound(payload);
return;
}
if (cmd === 'scene.particles') {
this._spawnParticles(payload);
return;
}
if (cmd === 'mob.kill') {
try {
const id = Number(payload?.id);
if (Number.isFinite(id) && this.scene3d?.zombieManager) {
this.scene3d.zombieManager.killById(id);
}
} catch (e) {
this._log('error', 'mob.kill failed: ' + (e?.message || e));
}
return;
}
// === Billboard 3D-таблички (см. BillboardUiManager) ===
if (cmd === 'billboard.set' || cmd === 'billboard.update' || cmd === 'billboard.onClick') {
// Резолв ref → primitiveId.
// Worker может прислать ref сразу после game.scene.spawn — до
// того как main spawn'нул примитив и обновил _localToReal.
// Откладываем команду до резолва.
let ref = payload?.ref;
if (typeof ref === 'string' && ref.includes('_local_')
&& !this._localToReal?.has(ref)) {
this._pendingResolveQueue = this._pendingResolveQueue || [];
this._pendingResolveQueue.push({ cmd, payload, scriptId });
return;
}
try {
if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref);
let id = null;
if (typeof ref === 'string' && ref.startsWith('primitive:')) {
id = Number(ref.slice('primitive:'.length));
} else if (Number.isFinite(ref)) {
id = Number(ref);
}
if (!Number.isFinite(id) || id == null) return;
const data = this.scene3d?.primitiveManager?.instances?.get(id);
if (!data || data.type !== 'billboard') return;
const mgr = this.scene3d?.billboardUiManager;
if (!mgr) return;
if (cmd === 'billboard.set') {
mgr.applyToMesh(data, {
template: payload.template || data.billboard?.template || 'shop-item',
face: payload.face || data.billboard?.face || 'camera',
content: payload.content || data.billboard?.content,
elements: payload.elements || data.billboard?.elements,
});
this.scheduleSceneSnapshot?.();
} else if (cmd === 'billboard.update') {
// 2 формы: с elementId (точечно) или без (patch content)
if (typeof payload.elementId === 'string') {
mgr.update(data, payload.elementId, payload.patch || {});
} else {
mgr.update(data, payload.patch || {});
}
this.scheduleSceneSnapshot?.();
} else if (cmd === 'billboard.onClick') {
const buttonId = String(payload.buttonId || 'buy');
const realRef = 'primitive:' + id;
mgr.onClick(data, buttonId, () => {
const sb = this.sandboxes.find(s => s.scriptId === scriptId);
if (sb && typeof sb.sendGlobalEvent === 'function') {
// billboardClick роутится в worker'е через globalEvent-ветку
// (см. ScriptSandboxWorker.js cmd === 'globalEvent').
sb.sendGlobalEvent({
type: 'billboardClick',
ref: realRef,
button: buttonId,
});
}
});
}
} catch (e) {
this._log('error', cmd + ' failed: ' + (e?.message || e));
}
return;
}
if (cmd === 'gui.update') {
// payload: { id, patch }
try {
let id = payload?.id;
const patch = payload?.patch || {};
if (typeof id !== 'string') return;
// Резолвим локальный ref (тот что вернул gui.create) → реальный id
if (this._guiLocalToReal?.has(id)) id = this._guiLocalToReal.get(id);
this.scene3d?.updateGuiElement?.(id, patch);
this.scheduleGuiSnapshot();
} catch (e) {
this._log('error', 'gui.update failed: ' + (e?.message || e));
}
return;
}
if (cmd === 'gui.create') {
try {
const type = payload?.type;
const opts = { ...(payload?.opts || {}) };
const localRef = payload?.localRef;
if (typeof type !== 'string') return;
// Помечаем как созданный скриптом — чтобы НЕ попал в
// сериализацию проекта (иначе автосейв сохранит его в БД
// и после Stop он «вернётся» из сохранённого проекта).
opts._scriptCreated = true;
// Резолвим parentId если это локальный ref из предыдущего create
if (opts.parentId && this._guiLocalToReal?.has(opts.parentId)) {
opts.parentId = this._guiLocalToReal.get(opts.parentId);
}
const realId = this.scene3d?.createGuiElement?.(type, opts);
if (realId && localRef) {
if (!this._guiLocalToReal) this._guiLocalToReal = new Map();
if (!this._guiRealToLocal) this._guiRealToLocal = new Map();
this._guiLocalToReal.set(localRef, realId);
this._guiRealToLocal.set(realId, localRef);
}
this.scheduleGuiSnapshot();
} catch (e) {
this._log('error', 'gui.create failed: ' + (e?.message || e));
}
return;
}
if (cmd === 'gui.remove') {
try {
let id = payload?.id;
if (typeof id !== 'string') return;
const localId = id;
if (this._guiLocalToReal?.has(id)) id = this._guiLocalToReal.get(id);
this.scene3d?.removeGuiElement?.(id);
// Чистим mapping чтобы не утекало
if (this._guiLocalToReal?.has(localId)) this._guiLocalToReal.delete(localId);
this.scheduleGuiSnapshot();
} catch (e) {
this._log('error', 'gui.remove failed: ' + (e?.message || e));
}
return;
}
if (cmd === 'broadcast') {
// Рассылаем именованное сообщение всем sandbox'ам
this.routeGlobalEvent('message', {
name: String(payload?.name || ''),
data: payload?.data ?? null,
});
return;
}
if (cmd === 'player.crosshair') {
const type = String(payload?.type || 'none').toLowerCase();
try { this.scene3d?.setCrosshair?.(type); } catch (e) { /* ignore */ }
if (this._onCrosshair) {
try { this._onCrosshair(type); } catch (e) { /* ignore */ }
}
return;
}
// eslint-disable-next-line no-console
console.warn('[GameRuntime] unknown cmd', cmd);
}
/**
* Создать объект из скрипта.
* payload: { kind: 'block'|'model'|'primitive', subType, x, y, z, ref, ... }
* После создания обновляем `_localToReal` мапу — локальный ref ↔ реальный id.
*/
_applySceneSpawn(scriptId, payload) {
if (!payload) return;
const { kind, subType, ref } = payload;
if (!this._localToReal) this._localToReal = new Map();
try {
if (kind === 'block') {
this.scene3d?.blockManager?.addBlock(payload.x, payload.y, payload.z, subType);
// Для блоков ref детерминированный, но запоминаем — чтобы при
// Stop удалить заспавненные скриптом блоки (см. stop()).
if (ref) this._localToReal.set(ref, ref);
this.scheduleSceneSnapshot();
} else if (kind === 'model') {
// addInstance возвращает Promise<id> (async из-за GLB)
const opts = payload;
const p = this.scene3d?.modelManager?.addInstance(
subType, opts.x, opts.y, opts.z, opts.rotationY || 0
);
Promise.resolve(p).then((instId) => {
if (instId == null) return;
if (opts.name) {
const data = this.scene3d?.modelManager?.instances?.get(instId);
if (data) data.name = opts.name;
}
this._localToReal.set(ref, 'model:' + instId);
this._notifySpawnResolved(ref, 'model:' + instId);
this.scheduleSceneSnapshot();
}).catch((err) => {
this._log('error', 'spawn model failed: ' + (err?.message || err));
});
} else if (kind === 'userModel') {
// Пользовательская воксельная модель: subType = 'user:<id>'.
// addInstance возвращает Promise<id>.
const opts = payload;
const p = this.scene3d?.userModelManager?.addInstance(
subType, opts.x, opts.y, opts.z, opts.rotationY || 0,
);
Promise.resolve(p).then((instId) => {
if (instId == null) return;
if (opts.name) {
const data = this.scene3d?.userModelManager?.instances?.get(instId);
if (data) data.name = opts.name;
}
this._localToReal.set(ref, 'usermodel:' + instId);
this._notifySpawnResolved(ref, 'usermodel:' + instId);
this.scheduleSceneSnapshot();
}).catch((err) => {
this._log('error', 'spawn user model failed: ' + (err?.message || err));
});
} else if (kind === 'primitive') {
const opts = payload;
const id = this.scene3d?.primitiveManager?.addInstance(subType, {
x: opts.x, y: opts.y, z: opts.z,
sx: opts.sx, sy: opts.sy, sz: opts.sz,
color: opts.color, material: opts.material,
rotationY: opts.rotationY,
name: opts.name,
brightness: opts.brightness, range: opts.range,
effect: opts.effect,
// textureAsset — картинка из ассетов проекта на грани.
...(opts.textureAsset != null ? { textureAsset: opts.textureAsset } : {}),
// billboard-параметры (только для type='billboard')
...(opts.template != null ? { template: opts.template } : {}),
...(opts.face != null ? { face: opts.face } : {}),
...(opts.content != null ? { content: opts.content } : {}),
...(opts.elements != null ? { elements: opts.elements } : {}),
// anchored:false → объект падает (физика unanchored).
// canCollide:false → проходимый (зона-триггер).
...(opts.anchored != null ? { anchored: opts.anchored } : {}),
...(opts.canCollide != null ? { canCollide: opts.canCollide } : {}),
...(opts.visible != null ? { visible: opts.visible } : {}),
});
if (id != null) {
this._localToReal.set(ref, 'primitive:' + id);
this._notifySpawnResolved(ref, 'primitive:' + id);
this._drainPendingResolveQueue?.(ref);
const data = this.scene3d?.primitiveManager?.instances?.get(id);
if (data) {
// Помечаем как заспавненный скриптом — движок шлёт
// для таких onPlayerTouch (нужно для «поймай объект»).
data._scriptSpawned = true;
// Если unanchored — регистрируем в физике на лету,
// иначе он не падает (start() уже отработал).
if (opts.anchored === false) {
this.scene3d?.dynamics?.registerPrimitive(data);
}
}
this.scheduleSceneSnapshot();
}
}
} catch (e) {
this._log('error', 'scene.spawn failed: ' + (e?.message || e));
}
}
/** Удалить объект по ref (поддерживает локальный ref от spawn и реальный). */
_applySceneDelete(payload) {
if (!payload?.ref) return;
let ref = payload.ref;
// Резолвим локальный ref → реальный
if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref);
// Ref всё ещё локальный ('_local_') — модель ещё не зарезолвилась
// (асинхронная загрузка GLB). Откладываем удаление: оно сработает
// в _notifySpawnResolved, когда реальный id появится. Без этого
// removeInstance(NaN) промахивался и объект «осиротевал» на сцене.
if (ref.indexOf('_local_') >= 0) {
if (!this._pendingDeletes) this._pendingDeletes = new Set();
this._pendingDeletes.add(ref);
return;
}
try {
const colon = ref.indexOf(':');
if (colon < 0) return;
const kind = ref.slice(0, colon);
const rest = ref.slice(colon + 1);
if (kind === 'block') {
const [xs, ys, zs] = rest.split(',');
this.scene3d?.blockManager?.removeBlock(Number(xs), Number(ys), Number(zs));
} else if (kind === 'model') {
this.scene3d?.modelManager?.removeInstance(Number(rest));
} else if (kind === 'primitive') {
this.scene3d?.primitiveManager?.removeInstance(Number(rest));
}
// Удалили — снимаем mapping
for (const [k, v] of (this._localToReal || new Map()).entries()) {
if (v === ref) this._localToReal.delete(k);
}
this.scheduleSceneSnapshot();
} catch (e) {
this._log('error', 'scene.delete failed: ' + (e?.message || e));
}
}
/**
* Запланировать рассылку sceneSnapshot всем sandbox'ам в следующем кадре.
* Делается отложенно чтобы при массовом spawn (например в onKey) отправить
* snapshot один раз, а не N раз.
*/
scheduleSceneSnapshot() {
if (this._snapshotPending) return;
this._snapshotPending = true;
// microtask — следующий кадр render-loop'а почти наверняка
Promise.resolve().then(() => {
this._snapshotPending = false;
this._broadcastSceneSnapshot();
});
}
/** Рассылка snapshot всем sandbox'ам. */
_broadcastSceneSnapshot() {
if (!this._isRunning || this.sandboxes.length === 0) return;
const snap = this._buildSceneSnapshot();
for (const sb of this.sandboxes) {
sb.sendSceneSnapshot(snap);
}
}
/** Запланировать рассылку GUI-snapshot всем sandbox'ам в следующем microtask. */
scheduleGuiSnapshot() {
if (this._guiSnapshotPending) return;
this._guiSnapshotPending = true;
Promise.resolve().then(() => {
this._guiSnapshotPending = false;
this._broadcastGuiSnapshot();
});
}
_broadcastGuiSnapshot() {
if (!this._isRunning || this.sandboxes.length === 0) return;
const snap = this._buildGuiSnapshot();
for (const sb of this.sandboxes) {
sb.sendGuiSnapshot(snap);
}
}
/** Запланировать рассылку snapshot атрибутов объектов (game.scene.setData). */
scheduleDataSnapshot() {
if (this._dataSnapshotPending) return;
this._dataSnapshotPending = true;
Promise.resolve().then(() => {
this._dataSnapshotPending = false;
this._broadcastDataSnapshot();
});
}
_broadcastDataSnapshot() {
if (!this._isRunning || this.sandboxes.length === 0) return;
for (const sb of this.sandboxes) {
sb.sendDataSnapshot(this._objectData);
}
}
_buildGuiSnapshot() {
const list = this.scene3d?.getGuiElements?.() || [];
return list.map(g => ({
id: g.id, type: g.type, name: g.name,
parentId: g.parentId || null,
x: g.x, y: g.y, w: g.w, h: g.h, anchor: g.anchor,
visible: g.visible !== false,
text: g.text, textColor: g.textColor, textSize: g.textSize,
bgColor: g.bgColor, bgOpacity: g.bgOpacity,
imageUrl: g.imageUrl,
placeholder: g.placeholder,
}));
}
/** Собрать snapshot сцены для синхронных game.scene.find/all/getPosition в Worker'ах. */
_buildSceneSnapshot() {
const blocks = [];
const models = [];
const primitives = [];
const s = this.scene3d;
if (s?.blockManager) {
for (const proxy of s.blockManager.blocks.values()) {
const md = proxy.metadata;
if (!md?.isBlock) continue;
blocks.push({
ref: 'block:' + md.gridX + ',' + md.gridY + ',' + md.gridZ,
type: md.blockTypeId,
x: md.gridX, y: md.gridY, z: md.gridZ,
});
}
}
if (s?.modelManager) {
for (const data of s.modelManager.instances.values()) {
models.push({
ref: 'model:' + data.instanceId,
type: data.modelTypeId,
x: data.x, y: data.y, z: data.z,
name: data.name || null,
});
}
}
if (s?.primitiveManager) {
for (const data of s.primitiveManager.instances.values()) {
primitives.push({
ref: 'primitive:' + data.id,
type: data.type,
x: data.x, y: data.y, z: data.z,
// размеры/поворот нужны для game.physics.raycast (ray vs AABB)
sx: data.sx != null ? data.sx : 1,
sy: data.sy != null ? data.sy : 1,
sz: data.sz != null ? data.sz : 1,
rotationY: data.rotationY || 0,
visible: data.visible !== false,
name: data.name || null,
});
}
}
return { blocks, models, primitives };
}
_applySelfMove(payload) {
if (!payload || !payload.target) return;
const t = payload.target;
const { x, y, z } = payload;
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return;
try {
if (t.kind === 'model') {
let id = t.id ?? t.ref;
const mm = this.scene3d?.modelManager;
if (!mm) return;
// Локальный ref '_local_N' от scene.spawn → реальный id.
if (typeof id === 'string' && id.indexOf('_local_') === 0
&& this._localToReal) {
const real = this._localToReal.get('model:' + id);
if (real) {
const c2 = real.indexOf(':');
id = c2 >= 0 ? real.slice(c2 + 1) : real;
}
}
let data = mm.instances.get(id);
if (!data && typeof id === 'string') {
const n = Number(id);
if (Number.isFinite(n)) data = mm.instances.get(n);
}
if (data) {
data.x = x; data.y = y; data.z = z;
if (data.rootMesh?.position) {
data.rootMesh.position.set(x, y, z);
if (data._worldMatrixFrozen) {
try { data.rootMesh.unfreezeWorldMatrix?.(); } catch (e) {}
if (Array.isArray(data.meshes)) {
for (const m of data.meshes) {
try { m?.unfreezeWorldMatrix?.(); } catch (e) {}
}
}
data._worldMatrixFrozen = false;
}
}
}
} else if (t.kind === 'primitive') {
const pm = this.scene3d?.primitiveManager;
if (!pm) return;
// _resolvePrimitiveId умеет и числовой id, и локальный
// ref '_local_N' (от scene.spawn) — без этого scene.move
// не находит объект, заспавненный скриптом.
const rid = this._resolvePrimitiveId(t.id ?? t.ref);
const data = rid != null ? pm.instances.get(rid) : null;
if (data) {
data.x = x; data.y = y; data.z = z;
if (data.mesh?.position) {
data.mesh.position.set(x, y, z);
if (data._worldMatrixFrozen) {
try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {}
data._worldMatrixFrozen = false;
}
}
}
} else if (t.kind === 'userModel') {
// userModel-инстанс: отдельная нода (rootNode), не thin-instance.
// Двигаем root.position + обновляем data.x/y/z.
const id = t.id ?? t.ref;
const um = this.scene3d?.userModelManager;
if (!um) return;
let data = um.instances.get(id);
if (!data && typeof id === 'string') {
const n = Number(id);
if (Number.isFinite(n)) data = um.instances.get(n);
}
if (data) {
data.x = x; data.y = y; data.z = z;
if (data.rootNode?.position) {
data.rootNode.position.set(x, y, z);
}
}
}
// НЕ шлём sceneSnapshot при move — позиция объекта в snapshot всё
// равно стейл (sandbox использует findOne и сам не зависит от
// координат в snapshot). Иначе при анимации платформ (десятки
// scene.move в секунду) шлём весь snapshot 11000+ объектов в worker
// через структурный postMessage — это может стоить сотни мс.
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[GameRuntime] self.move failed', e);
}
}
_applySelfDelete(payload) {
if (!payload || !payload.target) return;
const t = payload.target;
try {
if (t.kind === 'block') {
const r = t.ref || t;
this.scene3d?.blockManager?.removeBlock(r.x, r.y, r.z);
} else if (t.kind === 'model') {
const id = t.id ?? t.ref;
this.scene3d?.modelManager?.removeInstance(id);
} else if (t.kind === 'primitive') {
const id = t.id ?? t.ref;
this.scene3d?.primitiveManager?.removeInstance(id);
}
this.scheduleSceneSnapshot();
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[GameRuntime] self.delete failed', e);
}
}
_log(level, text) {
if (this._onLog) {
try { this._onLog({ level, text, ts: Date.now() }); } catch (e) { /* ignore */ }
}
}
/**
* Воспроизвести встроенный звуковой эффект через Web Audio API.
* Все звуки генерируются процедурно — никаких mp3-файлов, нагрузка минимальная.
* Поддерживаемые: jump, pickup, win, lose, click, hit, coin.
*/
_playSound(payload) {
if (!payload || typeof payload.name !== 'string') return;
const name = payload.name;
const volume = Number.isFinite(payload.volume) ? Math.max(0, Math.min(2, payload.volume)) : 1;
const pitch = Number.isFinite(payload.pitch) ? Math.max(0.25, Math.min(4, payload.pitch)) : 1;
try {
if (!this._audioCtx) {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return;
this._audioCtx = new Ctx();
}
const ctx = this._audioCtx;
if (ctx.state === 'suspended') ctx.resume();
const t = ctx.currentTime;
// Описание звуков: одна или несколько oscillator-волн с envelope
switch (name) {
case 'jump': this._sfxJump(ctx, t, volume, pitch); break;
case 'pickup': this._sfxPickup(ctx, t, volume, pitch); break;
case 'win': this._sfxWin(ctx, t, volume, pitch); break;
case 'lose': this._sfxLose(ctx, t, volume, pitch); break;
case 'click': this._sfxClick(ctx, t, volume, pitch); break;
case 'hit': this._sfxHit(ctx, t, volume, pitch); break;
case 'coin': this._sfxCoin(ctx, t, volume, pitch); break;
default:
this._log('warn', `Неизвестный звук: ${name}`);
}
} catch (e) {
// ignore
}
}
// === Звуковые пресеты (Web Audio) ===
_sfxOsc(ctx, t, type, freq0, freq1, dur, vol) {
const osc = ctx.createOscillator();
osc.type = type;
osc.frequency.setValueAtTime(freq0, t);
if (freq1 != null) osc.frequency.exponentialRampToValueAtTime(Math.max(1, freq1), t + dur);
const g = ctx.createGain();
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(vol, t + 0.005);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
osc.connect(g).connect(ctx.destination);
osc.start(t);
osc.stop(t + dur + 0.02);
}
_sfxJump(ctx, t, vol, pitch) {
// Похож на встроенный звук прыжка PlayerController.
this._sfxOsc(ctx, t, 'sine', 720 * pitch, 440 * pitch, 0.16, 0.22 * vol);
this._sfxOsc(ctx, t, 'sine', 110 * pitch, 60 * pitch, 0.07, 0.35 * vol);
}
_sfxPickup(ctx, t, vol, pitch) {
// Восходящие два тона — «пик-апнул!»
this._sfxOsc(ctx, t, 'square', 880 * pitch, 1320 * pitch, 0.10, 0.20 * vol);
this._sfxOsc(ctx, t + 0.08, 'square', 1320 * pitch, 1760 * pitch, 0.12, 0.16 * vol);
}
_sfxCoin(ctx, t, vol, pitch) {
// Классический «динь-динь»
this._sfxOsc(ctx, t, 'sine', 988 * pitch, 988 * pitch, 0.06, 0.25 * vol);
this._sfxOsc(ctx, t + 0.05, 'sine', 1318 * pitch, 1318 * pitch, 0.18, 0.25 * vol);
}
_sfxWin(ctx, t, vol, pitch) {
// Мажорный аккорд C-E-G по очереди
const notes = [523, 659, 784];
notes.forEach((f, i) => {
this._sfxOsc(ctx, t + i * 0.08, 'triangle', f * pitch, f * pitch, 0.30, 0.22 * vol);
});
}
_sfxLose(ctx, t, vol, pitch) {
// Нисходящий «провал»
this._sfxOsc(ctx, t, 'sawtooth', 440 * pitch, 110 * pitch, 0.45, 0.22 * vol);
this._sfxOsc(ctx, t + 0.08, 'sawtooth', 330 * pitch, 80 * pitch, 0.50, 0.18 * vol);
}
_sfxClick(ctx, t, vol, pitch) {
// Короткий «тик»
this._sfxOsc(ctx, t, 'square', 1500 * pitch, 800 * pitch, 0.04, 0.15 * vol);
}
/**
* Создать ParticleSystem в указанной точке. Авто-удаляется через duration сек.
* Делегирует в BabylonScene._spawnParticleEffect, который знает о Babylon.
*/
_spawnParticles(payload) {
if (!payload || !this.scene3d?._spawnParticleEffect) return;
try {
this.scene3d._spawnParticleEffect(payload);
} catch (e) {
this._log('error', 'spawnParticles failed: ' + (e?.message || e));
}
}
_sfxHit(ctx, t, vol, pitch) {
// Глухой «тук»: низкий sine + шумовой burst
this._sfxOsc(ctx, t, 'sine', 180 * pitch, 80 * pitch, 0.10, 0.30 * vol);
// Шум через короткий buffer-noise
const bufLen = Math.floor(ctx.sampleRate * 0.06);
const buf = ctx.createBuffer(1, bufLen, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < bufLen; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / bufLen);
const src = ctx.createBufferSource();
src.buffer = buf;
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 1000 * pitch;
const g = ctx.createGain();
g.gain.value = 0.18 * vol;
src.connect(lp).connect(g).connect(ctx.destination);
src.start(t);
}
// === Универсальное хранилище сейвов (game.save.*) ===
_saveProjectId() {
return this.scene3d?._currentProjectId || this.scene3d?.projectId || null;
}
_saveBaseUrl(namespace) {
const pid = this._saveProjectId();
const uid = this.scene3d?._currentUserId;
if (!pid || !uid) return null;
const ns = encodeURIComponent(namespace || 'default');
return `${STORYS_addres}/kubikon3d/savegame/${pid}/${uid}/${ns}`;
}
_saveReply(scriptId, reqId, result) {
for (const sb of this.sandboxes) {
if (sb.scriptId === scriptId) {
try { sb.worker.postMessage({ cmd: 'saveResponse', payload: { reqId, result } }); } catch (e) {}
return;
}
}
}
_saveGet(scriptId, payload) {
const reqId = payload?.reqId;
const url = this._saveBaseUrl(payload?.namespace);
if (!url) { this._saveReply(scriptId, reqId, null); return; }
fetch(url).then(r => r.json())
.then(j => this._saveReply(scriptId, reqId, j.data ?? null))
.catch(() => this._saveReply(scriptId, reqId, null));
}
_saveGetAll(scriptId, payload) {
const reqId = payload?.reqId;
const pid = this._saveProjectId();
const uid = this.scene3d?._currentUserId;
if (!pid || !uid) { this._saveReply(scriptId, reqId, {}); return; }
fetch(`${STORYS_addres}/kubikon3d/savegame/${pid}/${uid}`)
.then(r => r.json())
.then(j => this._saveReply(scriptId, reqId, j.namespaces || {}))
.catch(() => this._saveReply(scriptId, reqId, {}));
}
_saveSet(payload) {
const url = this._saveBaseUrl(payload?.namespace);
if (!url) return;
try {
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: payload.data }),
}).catch(() => {});
} catch (e) {}
}
_saveMerge(payload) {
const url = this._saveBaseUrl(payload?.namespace);
if (!url) return;
try {
fetch(url + '/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
patch: payload.patch || {},
increment: payload.increment || {},
max: payload.max || {},
}),
}).catch(() => {});
} catch (e) {}
}
_saveLeaderboard(scriptId, payload) {
const reqId = payload?.reqId;
const pid = this._saveProjectId();
if (!pid) { this._saveReply(scriptId, reqId, []); return; }
const params = new URLSearchParams({
namespace: payload?.namespace || '',
key: payload?.key || '',
order: payload?.order || 'desc',
limit: '20',
});
fetch(`${STORYS_addres}/kubikon3d/savegame/${pid}/leaderboard?${params}`)
.then(r => r.json())
.then(j => this._saveReply(scriptId, reqId, j.entries || []))
.catch(() => this._saveReply(scriptId, reqId, []));
}
// ============== ECONOMY API (GD-reward через storys) ==============
// Каждый метод асинхронно делает HTTP-запрос с JWT в заголовке Authorization.
// Ответ возвращается в Worker через postMessage cmd='economyResponse'.
_economyReply(scriptId, reqId, result) {
for (const sb of this.sandboxes) {
if (sb.scriptId === scriptId) {
try { sb.worker.postMessage({ cmd: 'economyResponse', payload: { reqId, result } }); } catch (e) {}
return;
}
}
}
_economyAuthHeaders() {
const h = { 'Content-Type': 'application/json' };
try {
const t = localStorage.getItem('Authorization');
if (t) h.Authorization = t;
} catch (e) {}
return h;
}
_economyReward(scriptId, payload) {
const reqId = payload?.reqId;
const aid = String(payload?.achievementId || '');
if (!aid) { this._economyReply(scriptId, reqId, { ok: false, error: 'no_id' }); return; }
fetch(`${STORYS_addres}/kubikon3d/gd/reward`, {
method: 'POST',
headers: this._economyAuthHeaders(),
body: JSON.stringify({ achievement_id: aid }),
})
.then(r => r.json())
.then(j => this._economyReply(scriptId, reqId, j || { ok: false }))
.catch(e => this._economyReply(scriptId, reqId, { ok: false, error: String(e) }));
}
_economyDailyCheck(scriptId, payload) {
const reqId = payload?.reqId;
fetch(`${STORYS_addres}/kubikon3d/gd/daily-check`, {
method: 'POST',
headers: this._economyAuthHeaders(),
body: JSON.stringify({}),
})
.then(r => r.json())
.then(j => this._economyReply(scriptId, reqId, j || { awarded: false }))
.catch(e => this._economyReply(scriptId, reqId, { awarded: false, error: String(e) }));
}
_economyGetBalance(scriptId, payload) {
const reqId = payload?.reqId;
// Алмазы — user/api/v1/users/diamond, рейтинг — user/api/v1/users/rating.
// Делаем оба запроса параллельно.
const USER_BASE = STORYS_addres.replace('/api-storys', '/api-user');
const headers = this._economyAuthHeaders();
Promise.all([
fetch(`${USER_BASE}/api/v1/users/diamond`, { headers }).then(r => r.json()).catch(() => ({ count: 0 })),
fetch(`${USER_BASE}/api/v1/users/rating`, { headers }).then(r => r.json()).catch(() => ({ rating: 0 })),
]).then(([dm, rt]) => {
this._economyReply(scriptId, reqId, {
diamonds: Number(dm.count || 0),
rating: Number(rt.rating || 0),
});
}).catch(() => this._economyReply(scriptId, reqId, { diamonds: 0, rating: 0 }));
}
_economySpend(scriptId, payload) {
const reqId = payload?.reqId;
const amount = Number(payload?.amount || 0);
const reason = String(payload?.reason || 'gd_spend');
if (amount < 1) { this._economyReply(scriptId, reqId, { ok: false, error: 'invalid_amount' }); return; }
const USER_BASE = STORYS_addres.replace('/api-storys', '/api-user');
fetch(`${USER_BASE}/api/v1/users/diamond/spend`, {
method: 'POST',
headers: this._economyAuthHeaders(),
body: JSON.stringify({ amount, reason }),
})
.then(r => r.json())
.then(j => this._economyReply(scriptId, reqId, j || { ok: false }))
.catch(e => this._economyReply(scriptId, reqId, { ok: false, error: String(e) }));
}
// ═══════════════════════════════════════════════════════════════════════
// Phase 6.5: Физика 2.0 (Rapier3D)
// ═══════════════════════════════════════════════════════════════════════
/**
* Lazy-init физ-мира: создаётся при первом запросе скрипта на физику.
* Wasm-инициализация Rapier асинхронная — пока wasm грузится, операции
* откладываются через очередь _physicsPending.
*/
_ensurePhysicsWorld() {
if (this._physicsWorld) return this._physicsWorld;
const pw = new PhysicsWorld();
this._physicsWorld = pw;
this._physicsPending = [];
// Гравитация: тот же -22 что в самописной DynamicsManager.
pw.init(-22).then((ok) => {
if (!ok) {
this._log('error', 'Rapier3D не загрузился — физика 2.0 отключена');
return;
}
// Прогоняем очередь pending-операций.
const queue = this._physicsPending || [];
this._physicsPending = null;
for (const op of queue) {
try { op(); } catch (e) { /* ignore */ }
}
this._log('info', 'Rapier3D инициализирован, ' + queue.length + ' pending op(s)');
});
return pw;
}
/**
* Выполнить операцию с PhysicsWorld немедленно, или отложить если wasm
* ещё грузится. Используется во всех physics-handlers.
*/
_physicsDo(fn) {
const pw = this._ensurePhysicsWorld();
if (pw.isReady()) {
try { return fn(pw); } catch (e) { return null; }
}
// wasm грузится — откладываем
if (this._physicsPending) {
this._physicsPending.push(() => { try { fn(pw); } catch (_) {} });
}
return null;
}
/**
* Sync rigid body transforms → Babylon mesh. Зовётся каждый кадр после
* physicsWorld.step(). Только для dynamic тел (static не двигаются).
*/
_syncPhysicsToScene() {
if (!this._physicsWorld?.isReady()) return;
const pm = this.scene3d?.primitiveManager;
if (!pm) return;
for (const [ref, rec] of this._physicsWorld.bodies) {
// Только dynamic тела требуют синхронизации (kinematic ставится извне,
// static не двигается). Тип хранится в body.bodyType, но проще ---
// если позиция в Rapier отличается от Babylon, синхронизируем.
const t = this._physicsWorld.getBodyTransform(ref);
if (!t) continue;
// Резолвим ref: 'primitive:_local_N' или 'primitive:realId'
const colon = ref.indexOf(':');
if (colon < 0 || ref.slice(0, colon) !== 'primitive') continue;
const pid = this._resolvePrimitiveId(ref.slice(colon + 1));
const data = pm.instances?.get?.(pid);
if (!data || !data.mesh) continue;
// Phase 6.5: если mesh был frozen (статичная оптимизация в Play),
// размораживаем -- иначе позиция не применяется к worldMatrix.
if (data._worldMatrixFrozen) {
try { data.mesh.unfreezeWorldMatrix(); } catch (_) {}
data._worldMatrixFrozen = false;
}
// Применяем позицию и вращение к mesh.
data.mesh.position.set(t.x, t.y, t.z);
// Кватернион. По умолчанию Babylon mesh имеет rotationQuaternion=null
// и использует rotation (euler). Для физики удобнее quaternion --
// создаём его, если ещё нет.
try {
const Q = data.mesh.rotationQuaternion;
if (Q && typeof Q.set === 'function') {
Q.set(t.qx, t.qy, t.qz, t.qw);
} else {
// Импортируем Quaternion лениво через mesh.scene.useRightHandedSystem
// — но проще установить через Quaternion.FromArray.
// В Babylon есть BABYLON.Quaternion -- используем mesh.scene._engine?
// Самый надёжный путь: установить три значения rotation из quaternion.
// Преобразование кватерниона → euler XYZ:
const qx = t.qx, qy = t.qy, qz = t.qz, qw = t.qw;
// pitch (X), yaw (Y), roll (Z)
const sinr_cosp = 2 * (qw * qx + qy * qz);
const cosr_cosp = 1 - 2 * (qx * qx + qy * qy);
const roll = Math.atan2(sinr_cosp, cosr_cosp);
const sinp = 2 * (qw * qy - qz * qx);
const pitch = Math.abs(sinp) >= 1 ? Math.sign(sinp) * Math.PI / 2 : Math.asin(sinp);
const siny_cosp = 2 * (qw * qz + qx * qy);
const cosy_cosp = 1 - 2 * (qy * qy + qz * qz);
const yaw = Math.atan2(siny_cosp, cosy_cosp);
data.mesh.rotation.set(roll, pitch, yaw);
}
} catch (_) {}
// Зеркалим data.x/y/z (для sceneSnapshot и getPosition)
data.x = t.x; data.y = t.y; data.z = t.z;
}
}
/**
* Резолв ref в primitive data для регистрации в физ-мире.
* Возвращает { ref, shape, size, position, rotation, mass } или null.
*/
_resolvePrimitiveForPhysics(ref) {
const pm = this.scene3d?.primitiveManager;
if (!pm) return null;
const colon = ref.indexOf(':');
if (colon < 0 || ref.slice(0, colon) !== 'primitive') return null;
const pid = this._resolvePrimitiveId(ref.slice(colon + 1));
const data = pm.instances?.get?.(pid);
if (!data) return null;
// Преобразование шейпа primitive → Rapier collider.
let shape = 'box';
if (data.type === 'sphere') shape = 'sphere';
else if (data.type === 'cylinder') shape = 'cylinder';
// Полу-размеры (Rapier: cuboid принимает half-extents).
const size = {
x: (data.sx || 1) / 2,
y: (data.sy || 1) / 2,
z: (data.sz || 1) / 2,
};
// Поворот: yaw в кватернион
const yaw = data.rotationY || 0;
const h = yaw / 2;
return {
shape, size,
position: { x: data.x, y: data.y, z: data.z },
rotation: { x: 0, y: Math.sin(h), z: 0, w: Math.cos(h) },
mass: data.mass != null ? data.mass : 1,
};
}
}