/** * 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'; 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); 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(); }; if (typeof requestAnimationFrame !== 'undefined') { requestAnimationFrame(sendInitial); } else { setTimeout(sendInitial, 16); } } /** * Разослать карту высот гладкого ландшафта всем 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); } } /** * Получить позицию объекта по его 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 */ } 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; 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); // 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 секунд. */ _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:') → числовой npcId. * Возвращает number или null. */ _resolveNpcId(ref) { if (typeof ref !== 'string') return null; let v = ref; // Локальный ref воркера → реальный 'npc:'. 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. */ routeGlobalEvent(eventType, extra = {}) { if (!eventType) return; // Спецслучай: guiClick приходит с realId, но worker подписан на localRef // (потому что gui.create() возвращает worker'у только localRef). // Резолвим обратно по реверс-карте. if ((eventType === 'guiClick' || eventType === 'guiSubmit' || eventType === 'guiTextChange') && extra && extra.id != null && this._guiRealToLocal) { const local = this._guiRealToLocal.get(extra.id); if (local) extra = { ...extra, id: 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(); } /** Команда от 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:'. 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 } const inv = this.scene3d?.inventory; if (inv && payload) { const idx = inv.add({ kind: payload.kind || 'item', modelTypeId: payload.modelTypeId || null, name: payload.name || 'Предмет', params: payload.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; } 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; } // === Команды / 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; } // Подфаза 3.11 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md: // game.player.equipAccessory(itemId) — надеть аксессуар прямо // из скрипта игры (например выдать всем хеллоуинскую шапку при // спавне). itemId — числовой id из rublox_items. // Бэк фильтрует только published — на сервере ничего не настроишь. if (cmd === 'player.equipAccessory') { const player = this.scene3d?.player; const itemId = Number(payload?.itemId); if (!player || !Number.isFinite(itemId) || itemId <= 0) return; (async () => { try { // Грузим item через публичный catalog (только published) const resp = await fetch(`/api-storys/rublox/catalog/${itemId}`); if (!resp.ok) return; const item = await resp.json(); if (item && typeof player.equipAccessory === 'function') { await player.equipAccessory(item); } } catch (e) { // eslint-disable-next-line no-console console.warn('[GameRuntime] equipAccessory failed', e); } })(); return; } if (cmd === 'player.unequipSlot') { const player = this.scene3d?.player; const slot = String(payload?.slot || ''); if (player && slot && typeof player.unequipSlot === 'function') { player.unequipSlot(slot); } return; } if (cmd === 'player.unequipAll') { const player = this.scene3d?.player; if (player && typeof player.unequipAll === 'function') { player.unequipAll(); } 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 === '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 { // На Майнкрафтия-плеере это шло на свой роут /kubikon3d // (лента игр). В выделенном плеере (player.rublox.pro) // таких роутов нет — переходим на ленту Рублокса. window.location.assign(this._resolveExternalUrl('/kubikon3d')); } catch (e) {} return; } if (cmd === 'app.navigate') { try { const url = String(payload?.url || ''); if (url) window.location.assign(this._resolveExternalUrl(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; } if (cmd === 'player.setCameraMode') { const player = this.scene3d?.player; if (player && typeof payload?.mode === 'string') { const valid = ['first', 'third', 'front', 'sideview']; if (valid.includes(payload.mode)) { player._cameraMode = payload.mode; try { player._applyCameraMode?.(); } 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) { const { LabelManager } = require('./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; } // === Теги объектов (Фаза 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; } // === 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; } 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; } 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 (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:'. // addInstance возвращает Promise. 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 } : {}), // 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); 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; } // GET savegame теперь тоже требует JWT (бэк ужесточили после // Этапа 4 — выдаёт 401 без, 403 если чужой). Используем те же // headers что _saveSet/_saveMerge. const headers = {}; try { const t = localStorage.getItem('Authorization'); if (t) headers.Authorization = t; } catch (e) {} fetch(url, { headers }).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; } const headers = {}; try { const t = localStorage.getItem('Authorization'); if (t) headers.Authorization = t; } catch (e) {} fetch(`${STORYS_addres}/kubikon3d/savegame/${pid}/${uid}`, { headers }) .then(r => r.json()) .then(j => this._saveReply(scriptId, reqId, j.namespaces || {})) .catch(() => this._saveReply(scriptId, reqId, {})); } // Превращает относительный путь (/kubikon/gd, /kubikon3d, /app/...) // во ВНЕШНИЙ URL правильного хоста, потому что в выделенном плеере // (player.rublox.pro) этих SPA-роутов нет. // // Карта роутов (rublox.pro вместо mnk — у юзеров плеера нет сессии // на mnk, разные домены/localStorage; получался 401): // - http(s)://... → как есть (уже абсолютный) // - /kubikon/gd* → rublox.pro/app/gd (порт меню GD) // - /kubikon/play/N → ticket-flow в плеер уже идёт; // сюда попасть можно только через // app.navigate из скрипта уровня // (Например 'играть ещё раз' → // /kubikon/play/296?play=1&t=ts). // Парсим id и шлём прямо в плеер. // - /kubikon*, /kubikon3d* → rublox.pro/app (лента игр) // - /app, /app/* → rublox.pro // - всё остальное → rublox.pro/app (фоллбек) // // На localhost — dev-порт rublox-site (3004), на проде — rublox.pro. _resolveExternalUrl(url) { try { // VITE_RUBLOX_HOME = главный сайт-витрина (default: https://rublox.pro/app). // На dev rublox-site обычно крутится на :3004 — можно переопределить. const env = (typeof import.meta !== 'undefined' && import.meta.env) || {}; const RUBLOX_HOME = env.VITE_RUBLOX_HOME || 'https://rublox.pro/app'; const rubloxBase = RUBLOX_HOME.replace(/\/app\/?$/, ''); // .../app → ... if (!url) return RUBLOX_HOME; if (/^https?:\/\//i.test(url)) return url; // /kubikon/play/ — рестарт уровня. Перезагружаем плеер сам. const playMatch = url.match(/^\/kubikon\/play\/(\d+)/); if (playMatch) { const playerBase = typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.host}` : ''; return `${playerBase}/${playMatch[1]}`; } // Legacy /kubikon/* роуты — редирект на главный сайт. if (url.startsWith('/kubikon/gd')) return rubloxBase + '/app/gd'; if (url.startsWith('/kubikon')) return rubloxBase + '/app'; if (url.startsWith('/app')) return rubloxBase + url; return rubloxBase + '/app'; } catch (e) { const env = (typeof import.meta !== 'undefined' && import.meta.env) || {}; return env.VITE_RUBLOX_HOME || 'https://rublox.pro/app'; } } // ВАЖНО: POST savegame/merge на бэке требует JWT (no_token → 401). // Оригинальный код Майнкрафтии fetch БЕЗ Authorization-заголовка // (этот же баг там тоже есть — сохранения GD-прогресса не работали // молча, потому что .catch(()=>{}) глушит). В плеере добавляем JWT // через зеркало localStorage['Authorization'] (см. auth/ticketExchange.js // saveJWT — он кладёт JWT в оба ключа). _saveAuthHeaders() { const h = { 'Content-Type': 'application/json' }; try { const t = localStorage.getItem('Authorization'); if (t) h.Authorization = t; } catch (e) {} return h; } _saveSet(payload) { const url = this._saveBaseUrl(payload?.namespace); if (!url) return; try { fetch(url, { method: 'POST', headers: this._saveAuthHeaders(), 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: this._saveAuthHeaders(), 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) })); } }