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