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