/** * ScriptSandboxWorker.js — код, исполняющийся внутри Web Worker. * * НЕ импортируется напрямую через ES-import. Загружается через blob-URL, * созданный в ScriptSandbox.js. * * Архитектура: пользовательский скрипт получает ТОЛЬКО объект `game`. * Любая операция (двигать игрока, лог) превращается в команду * postMessage в main thread. Main thread исполняет на Babylon-сцене и * присылает обратно state-update'ы. * * API (этап 2.3.1): * game.player.position — {x, y, z}, обновляется main thread'ом * game.player.teleport(x, y, z) * game.onTick(fn) — fn(dt) каждый кадр * game.log(...args) — лог в Console * * game.self — {kind, ref, position} — объект-носитель * (только для скриптов с target) * game.self.onClick(fn) — fn(event) при клике по объекту в Play * game.self.onTouch(fn) — fn(event) когда игрок касается объекта * game.self.move(x, y, z) — переместить объект (для моделей/примитивов) * game.self.delete() — удалить объект-носитель */ const SOURCE = ` "use strict"; // === Внутреннее состояние Worker'а === let _tickHandlers = []; let _playerState = { position: { x: 0, y: 0, z: 0 }, yaw: 0, pitch: 0, forward: { x: 0, y: 0, z: 1 }, // нормализованный вектор взгляда crosshair: 'none', // 'none' | 'dot' | 'cross' | 'circle' hp: 100, maxHp: 100, state: 'ground', // 'ground' | 'air' | 'water' keys: {}, // { 'w': true, 'space': true } — зажатые сейчас клавиши }; // target скрипта (если есть) — пришёл при init let _target = null; // Зеркало position объекта-носителя (если target.kind != null) let _selfPosition = { x: 0, y: 0, z: 0 }; // Снимок живых мобов — обновляется каждый tick из main thread let _mobs = []; // Снимок NPC (Фаза 4.1) — обновляется каждый tick из main thread. // Каждый: { id, name, x, y, z, hp, maxHp, mode }. let _npcs = []; // Счётчик локальных ref'ов для NPC, заспавненных скриптом. let _npcRefSeq = 0; // Маппинг локальный ref ('npc:_local_N') → реальный числовой npcId. // Заполняется когда main thread присылает 'npcSpawned' после async-спавна. let _npcLocalToReal = {}; // Маппинг локальный ref scene.spawn ('primitive:_local_N') → реальный // ('primitive:N'). main thread шлёт 'spawnResolved' после создания. // Нужно чтобы getPosition и др. находили заспавненный объект в _sceneIndex. let _spawnLocalToReal = {}; // Подписки npc.onDeath: ключ = локальный ref ИЛИ строка-id → [fn]. let _npcDeathHandlers = {}; // Глобальные подписки game.onNpcDeath(fn). let _globalNpcDeathHandlers = []; // Снимок инвентаря (Фаза 4.2): { slots: [...], activeIndex }. let _inventory = { slots: [], activeIndex: 0 }; // Подписки game.player.onToolUse(fn). let _toolUseHandlers = []; // Подписки placement-режима (задача 11): game.placement.onPlace/onCancel/onMove. let _placeOnPlaceHandlers = []; let _placeOnCancelHandlers = []; let _placeOnMoveHandlers = []; let _invUiSlotClickHandlers = []; // Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }. let _players = { me: null, list: [] }; // Общее состояние комнаты game.room.get/set — зеркало из main thread. let _roomState = {}; // Подписки game.onPlayerJoin / onPlayerLeave / game.onMessage(name). let _playerJoinHandlers = []; let _playerLeaveHandlers = []; // Подписки game.onCutsceneDone(fn) — катсцена камеры доиграла (Фаза 5.7). let _cutsceneDoneHandlers = []; let _mpMessageHandlers = {}; // name → [fn] // Подписки game.room.onChange(key, fn): key → [fn]. let _roomChangeHandlers = {}; // Команды (Фаза 4.4): массив { name, color } — зеркало из main thread. let _teams = []; // Счётчик локальных ref'ов для связей-constraints (Фаза 5). let _constraintRefSeq = 0; // Счётчик локальных ref'ов для лучей/следов (Фаза 5.2). let _fxRefSeq = 0; // Счётчик локальных ref'ов для звуков (Фаза 5.5). let _soundRefSeq = 0; // Подписки на события объекта (self.*) let _selfClickHandlers = []; let _selfTouchHandlers = []; let _selfUntouchHandlers = []; // Подписки self.onInteract — взаимодействие по клавише E (ProximityPrompt) let _selfInteractHandlers = []; // Подписки на касание/клик ПРОИЗВОЛЬНОГО объекта (findOne(x).onTouch). // ref → { touch:[fn], untouch:[fn], click:[fn] }. Движок следит за AABB этих // объектов (cmd 'inst.watchTouch') и шлёт обратно instTouch/instUntouch/instClick. const _instTouchHandlers = new Map(); function _instHandlerBucket(ref) { let b = _instTouchHandlers.get(ref); if (!b) { b = { touch: [], untouch: [], click: [] }; _instTouchHandlers.set(ref, b); } return b; } // Зеркало всех GUI-элементов (синхронизируется main через cmd='guiSnapshot') let _guiIndex = []; // Задача 07: зеркало скинов (синхронизируется через 'skinsSnapshot'). // _skinsIndex — все встроенные скины [{slug,name,kind,category,price}]. // _unlockedSkins — slug'и доступные игроку. _currentSkin — активный. let _skinsIndex = []; let _unlockedSkins = []; let _currentSkin = null; let _skinChangeHandlers = []; let _skinCoins = 0; // локальная валюта магазина скинов (рублики проекта) // Подписки game.gui.onClick(id, fn) let _guiClickHandlers = {}; // Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter) let _guiSubmitHandlers = {}; // Подписки game.billboard.onClick(ref, buttonId, fn) — клик по кнопке // 3D-таблички. Ключ имеет вид ref + ":" + buttonId где ref — строка // из game.scene.spawn() или game.scene.findOne() в формате // 'primitive:NN' либо локальный '_local_X'. Резолв в main (GameRuntime), // сюда приходит событие 'billboardClick' с готовым ref='primitive:NN'. let _billboardClickHandlers = {}; // Для GUI-события с реальным id вернуть набор ключей, под которыми // Нормализовать точку для fx.beam/fx.pointer перед postMessage. // game.scene.findOne() возвращает Instance-PROXY — его НЕЛЬЗЯ передать через // postMessage (structured clone бросает DataCloneError → весь скрипт молча // падает в воркере, стрелка/луч не создаётся). Конвертируем proxy/объект-с-ref // в ref-строку ('primitive:NN'); 'player' и {x,y,z} пропускаем как есть. function _normFxPoint(p) { if (p == null) return p; if (typeof p === 'string') return p; // 'player' | 'primitive:NN' if (typeof p === 'object') { if (typeof p.ref === 'string') return p.ref; // Instance-proxy if (Number.isFinite(p.x) && Number.isFinite(p.y) && Number.isFinite(p.z)) { return { x: p.x, y: p.y, z: p.z }; // чистая точка } try { const s = String(p); if (s && s !== '[object Object]') return s; } catch (e) {} } return p; } // могли быть зарегистрированы handlers: сам id + имя элемента (скрипт // часто подписывается через game.gui.onClick('ИмяКнопки', fn)). function _guiHandlerKeys(id, localId) { const keys = [id]; // localId — ref который вернул gui.create() ('_gui_local_N'); скрипт мог // подписаться по нему, если не задавал явный id. if (localId != null && localId !== id) keys.push(localId); // name элемента — скрипт часто подписывается game.gui.onClick('ИмяКнопки', fn). const el = _guiIndex.find(g => g.id === id); if (el && el.name && el.name !== id && !keys.includes(el.name)) keys.push(el.name); return keys; } // Найти запись NPC в снапшоте по локальному ref. Снапшот приходит с // числовыми id, локальный ref → id через _npcLocalToReal. function _findNpcState(localRef) { const id = _npcLocalToReal[localRef]; if (id == null) return null; return _npcs.find(n => n.id === id) || null; } // Фабрика прокси-объекта NPC. Методы шлют команды с локальным ref — // main thread резолвит его в реальный npcId. function _makeNpcProxy(ref) { return { get ref() { return ref; }, /** Актуальная позиция NPC {x,y,z} или null (пока не заспавнен). */ get position() { const st = _findNpcState(ref); return st ? { x: st.x, y: st.y, z: st.z } : null; }, /** Текущее HP или null. */ get hp() { const st = _findNpcState(ref); return st ? st.hp : null; }, /** Имя NPC или null. */ get name() { const st = _findNpcState(ref); return st ? st.name : null; }, /** Идти в точку (XZ). */ moveTo(x, z) { _send('npc.moveTo', { ref, x: Number(x) || 0, z: Number(z) || 0 }); }, /** Задать скорость NPC (м/с) — на лету. Напр. медленный подход * в кат-сцене → быстрая погоня в игре. */ setSpeed(speed) { const s = Number(speed); if (Number.isFinite(s) && s > 0) { _send('npc.setSpeed', { ref, speed: s }); } }, /** Следовать за объектом: 'player' или ref объекта сцены. */ follow(target) { _send('npc.follow', { ref, target }); }, /** Остановиться. */ stop() { _send('npc.stop', { ref }); }, /** Реплика над головой на duration секунд (по умолчанию 3). */ say(text, duration) { _send('npc.say', { ref, text: String(text == null ? '' : text), duration: Number.isFinite(Number(duration)) ? Number(duration) : 3, }); }, /** Нанести урон NPC. */ damage(amount) { _send('npc.damage', { ref, amount: Number(amount) || 0 }); }, /** Убрать NPC со сцены. */ remove() { _send('npc.remove', { ref }); }, /** Колбэк при гибели этого NPC. fn получает {id, position}. */ onDeath(fn) { if (typeof fn === 'function') { (_npcDeathHandlers[ref] = _npcDeathHandlers[ref] || []).push(fn); } }, }; } // Глобальные подписки let _globalKeyDownHandlers = {}; // { 'w': [fn, fn], ... } — ключи нормализованы в lower-case let _globalKeyUpHandlers = {}; let _globalClickHandlers = []; let _globalTouchHandlers = []; // Колбэки на движение мыши в UI-режиме (game.input.onMouseMove) let _mouseMoveHandlers = []; let _mouseDownHandlers = []; let _mouseUpHandlers = []; // Колбэк на убийство моба (зомби и т.п.) — fn({mobType, position}) let _mobKilledHandlers = []; // SaveGame API: счётчик request-id и map колбэков по reqId let _saveReqSeq = 0; const _saveCallbacks = {}; // Economy API (GD-награды): request-id → callback let _economyReqSeq = 0; const _economyCallbacks = {}; // Колбэк на изменение HP игрока (для логирования урона/смерти из скриптов) let _hpChangeHandlers = []; // Подписки на события игрока: смерть / прыжок / приземление let _playerDiedHandlers = []; let _playerJumpHandlers = []; let _playerLandHandlers = []; // Broadcast между sandbox'ами: имя сообщения → массив обработчиков let _messageHandlers = {}; // Счётчик для локальных ref'ов спавненных через game.scene.spawn let _localRefSeq = 0; // Твины (game.tween): id → callback onDone. Сами твины крутит main-thread // (GameRuntime), сюда возвращается только событие завершения по reqId. let _tweenSeq = 0; const _tweenCallbacks = {}; // Таймеры (game.after / game.every). Каждый: { id, fn, delay, elapsed, repeat } // repeat=false → after (один раз), repeat=true → every (циклично). // Тикаются в обработчике cmd='tick' по накоплению dt. let _timers = []; let _timerSeq = 0; // Зеркало всех объектов сцены (заполняется main через cmd='sceneSnapshot' каждый раз // когда что-то меняется). Это позволяет game.scene.find/all/get работать синхронно. // { blocks: [{ref, type, x, y, z, name?}], models: [...], primitives: [...] } let _sceneIndex = { blocks: [], models: [], primitives: [] }; // Карта высот гладкого ландшафта — для game.scene.surfaceY(x,z). // Приходит один раз через cmd='terrainHeightmap'. Формат: // { origin:{x,z}, step, cols, rows, heights:number[] } let _terrainHM = null; // Атрибуты объектов (game.scene.setData/getData). Зеркало из main thread. // Формат: { ref: { key: value, ... } }. Синхронизируется через cmd='dataSnapshot'. // getData читает отсюда синхронно, setData шлёт команду в main. let _dataIndex = {}; // ═══════════════════════════════════════════════════════════════════════ // Instance-proxy (паритет со студией): game.scene.find/findOne/all возвращают // Proxy с методами (onTouch/onUntouch/onClick, tween, move, changed.connect, // position/name/parent/children и т.д.). Coerces в строку-ref через // Symbol.toPrimitive/valueOf/toString, поэтому старый код (scene.setColor и // т.п., принимавший строковый ref) продолжает работать без изменений. const _instCache = new Map(); // ref → Instance proxy const _instEvents = new Map(); // ref → { propChanged: [{prop,fn}], destroying: [fn] } const _instLastValues = new Map(); // ref → { x, y, z, name } предыдущий snapshot function _safeCall2(fn, args, where) { try { fn.apply(null, args); } catch (err) { _send('log', { level: 'error', text: 'Ошибка в ' + where + ': ' + (err && err.message ? err.message : err), }); } } function _getOrCreateInstance(ref, kindHint) { if (!ref || typeof ref !== 'string') return null; if (_instCache.has(ref)) return _instCache.get(ref); const target = { get ref() { return ref; }, get kind() { if (kindHint) return kindHint; for (const b of _sceneIndex.blocks) if (b.ref === ref) return 'block'; for (const m of _sceneIndex.models) if (m.ref === ref) return 'model'; for (const p of _sceneIndex.primitives) if (p.ref === ref) return 'primitive'; return 'unknown'; }, toString() { return ref; }, }; const proxy = new Proxy(target, { get(t, prop) { if (prop === 'ref' || prop === 'kind') return t[prop]; if (prop === 'toString') return t.toString; if (prop === Symbol.toPrimitive) return () => ref; if (prop === 'valueOf') return () => ref; // === Геттеры из snapshot === if (prop === 'position') { for (const arr of [_sceneIndex.blocks, _sceneIndex.models, _sceneIndex.primitives]) { for (const o of arr) if (o.ref === ref) return { x: o.x, y: o.y, z: o.z }; } return null; } if (prop === 'name') { for (const arr of [_sceneIndex.models, _sceneIndex.primitives]) { for (const o of arr) if (o.ref === ref) return o.name || null; } return null; } if (prop === 'parent') { const data = _dataIndex[ref]; const parentRef = data && data.__parent; return parentRef ? _getOrCreateInstance(parentRef) : null; } if (prop === 'children') { const data = _dataIndex[ref]; const ids = (data && data.__children) || []; return ids.map(id => _getOrCreateInstance(id)).filter(Boolean); } if (prop === 'descendants') { return () => { const out = []; const stack = [...((_dataIndex[ref] || {}).__children || [])]; while (stack.length) { const id = stack.pop(); const inst = _getOrCreateInstance(id); if (inst) { out.push(inst); const kids = (_dataIndex[id] || {}).__children || []; for (const k of kids) stack.push(k); } } return out; }; } // === События === if (prop === 'changed') { return { connect(propName, fn) { if (typeof fn !== 'function') return; let evs = _instEvents.get(ref); if (!evs) { evs = { propChanged: [], destroying: [] }; _instEvents.set(ref, evs); } evs.propChanged.push({ prop: propName, fn }); }, }; } if (prop === 'destroying') { return { connect(fn) { if (typeof fn !== 'function') return; let evs = _instEvents.get(ref); if (!evs) { evs = { propChanged: [], destroying: [] }; _instEvents.set(ref, evs); } evs.destroying.push(fn); }, }; } // === Методы === if (prop === 'destroy' || prop === 'delete') return () => _send('scene.delete', { ref }); if (prop === 'clone') return (offset) => _send('scene.clone', { ref, offset: offset || {} }); if (prop === 'setAttribute') return (k, v) => _send('scene.setData', { ref, key: k, value: v }); if (prop === 'getAttribute') return (k) => { const data = _dataIndex[ref]; return data ? data[k] : undefined; }; if (prop === 'hasTag') return (t) => { const data = _dataIndex[ref]; return Array.isArray(data && data.__tags) && data.__tags.includes(t); }; if (prop === 'addTag') return (t) => _send('scene.tag', { ref, tag: t }); if (prop === 'removeTag') return (t) => _send('scene.untag', { ref, tag: t }); if (prop === 'tween') return (props, opts) => game.tween(ref, props, opts); if (prop === 'move') return (x, y, z) => _send('scene.move', { ref, x, y, z }); if (prop === 'setLabel') return (text, o) => _send('scene.setLabel', { ref, text, opts: o || {} }); if (prop === 'clearLabel') return () => _send('scene.clearLabel', { ref }); // === События касания/клика ПРОИЗВОЛЬНОГО объекта === // findOne('coin').onTouch(fn) — fn() когда игрок коснулся объекта. // Аналог Roblox part.Touched:Connect. Движок начинает следить за AABB // объекта (inst.watchTouch) и шлёт instTouch на rising edge. if (prop === 'onTouch') return (fn) => { if (typeof fn !== 'function') return; _instHandlerBucket(ref).touch.push(fn); _send('inst.watchTouch', { ref }); }; if (prop === 'onUntouch') return (fn) => { if (typeof fn !== 'function') return; _instHandlerBucket(ref).untouch.push(fn); _send('inst.watchTouch', { ref }); }; if (prop === 'onClick') return (fn) => { if (typeof fn !== 'function') return; _instHandlerBucket(ref).click.push(fn); _send('inst.watchClick', { ref }); }; return undefined; }, set(t, prop, value) { if (prop === 'name') { _send('inst.set', { ref, prop: 'name', value: String(value) }); return true; } if (prop === 'parent') { const parentRef = value && typeof value === 'object' ? (value.ref || value.toString()) : (value || null); _send('inst.setParent', { ref, parentRef }); return true; } if (prop === 'position') { if (value && typeof value === 'object') { const x = Number(value.x), y = Number(value.y), z = Number(value.z); if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) { _send('scene.move', { ref, x, y, z }); } } return true; } if (prop === 'color') { _send('scene.setColor', { ref, color: String(value) }); return true; } if (prop === 'transparency' || prop === 'opacity') { const v = Number(value); if (Number.isFinite(v)) { const op = prop === 'transparency' ? (1 - v) : v; _send('scene.setOpacity', { ref, value: op }); } return true; } if (prop === 'visible') { _send('scene.setVisible', { ref, visible: !!value }); return true; } if (prop === 'canCollide') { _send('scene.setCollide', { ref, collide: !!value }); return true; } if (prop === 'material') { _send('scene.setMaterial', { ref, name: String(value) }); return true; } return true; }, }); _instCache.set(ref, proxy); return proxy; } /** Триггер событий изменения свойства (вызывается при дельте snapshot'а). */ function _emitInstChange(ref, prop, newVal, oldVal) { const evs = _instEvents.get(ref); if (!evs) return; for (const rec of evs.propChanged) { if (rec.prop === prop) { _safeCall2(rec.fn, [newVal, oldVal], 'inst.changed:' + prop); } } } /** Триггер destroying — объект больше не в snapshot. */ function _emitInstDestroying(ref) { const evs = _instEvents.get(ref); if (evs) { for (const fn of evs.destroying) { _safeCall2(fn, [], 'inst.destroying'); } } _instEvents.delete(ref); _instCache.delete(ref); _instLastValues.delete(ref); } /** * Сравнение нового snapshot со старым — детект дельт для событий. * Вызывается из обработчика 'sceneSnapshot' ПОСЛЕ обновления _sceneIndex. */ function _detectSnapshotDeltas() { const live = new Set(); for (const arr of [_sceneIndex.blocks, _sceneIndex.models, _sceneIndex.primitives]) { for (const o of arr) { live.add(o.ref); if (!_instCache.has(o.ref)) continue; const prev = _instLastValues.get(o.ref) || {}; if (prev.x !== undefined && (prev.x !== o.x || prev.y !== o.y || prev.z !== o.z)) { _emitInstChange(o.ref, 'position', { x: o.x, y: o.y, z: o.z }, { x: prev.x, y: prev.y, z: prev.z }); } if (prev.name !== undefined && prev.name !== o.name) { _emitInstChange(o.ref, 'name', o.name, prev.name); } _instLastValues.set(o.ref, { x: o.x, y: o.y, z: o.z, name: o.name }); } } for (const ref of [..._instCache.keys()]) { if (live.has(ref)) continue; const inst = _instCache.get(ref); if (inst && inst.kind === 'primitive') { _emitInstDestroying(ref); } } } // Модули (game.require). Код всех скриптов-модулей приходит при init. // _moduleCode — { 'имя': 'код модуля' } // _moduleCache — { 'имя': exports } — кеш исполненных модулей let _moduleCode = {}; let _moduleCache = {}; // Утилиты безопасной отправки в main const _send = (cmd, payload) => { try { postMessage({ cmd, payload }); } catch (e) {} }; // Нормализация ref: строка → она сама; Instance-прокси → поле .ref; // иначе null. Нужно чтобы billboard.set/update/onClick принимали и // строковый ref ('primitive:NN'), и объект, у которого есть .ref. function _normRef(ref) { if (typeof ref === 'string') return ref || null; if (ref && typeof ref === 'object') { if (typeof ref.ref === 'string' && ref.ref) return ref.ref; const s = String(ref); return s && s !== '[object Object]' ? s : null; } return null; } const _safeCall = (fn, arg, where) => { try { fn(arg); } catch (err) { _send('log', { level: 'error', text: 'Ошибка в ' + where + ': ' + (err && err.message ? err.message : err), }); } }; // Внутренний хелпер: запланировать удаление объекта через seconds секунд. // Используется для lifetime в scene.spawn и для scene.deleteAfter. const _scheduleDelete = (ref, seconds) => { const s = Number(seconds); if (typeof ref !== 'string' || !ref || !Number.isFinite(s) || s < 0) return; _timers.push({ id: ++_timerSeq, fn: () => _send('scene.delete', { ref }), delay: s, elapsed: 0, repeat: false, }); }; // === Публичное API game.* === // Объект self создаётся ПОСЛЕ получения init с target. // Если target нет (глобальный скрипт), game.self === null. let _selfApi = null; function _buildSelfApi() { if (!_target) return null; const isGui = _target.kind === 'gui'; const api = { get kind() { return _target.kind; }, // ref — строка формата 'primitive:N' / 'model:N' / 'block:x,y,z', // единая со scene.find/all и пригодная для scene.* / physics.* / tween. get ref() { const k = _target.kind; if (k === 'primitive' || k === 'model' || k === 'userModel') { const id = _target.id ?? _target.ref; return id != null ? k + ':' + id : null; } if (k === 'block') { const r = _target.ref || _target; if (r && r.x != null) return 'block:' + r.x + ',' + r.y + ',' + r.z; return null; } return _target.ref ?? _target.id ?? null; }, get position() { return { ..._selfPosition }; }, /** * Свойства GUI-элемента (только для скриптов с target.kind='gui'): * game.self.props — текущие свойства (имя, текст, x/y/w/h, цвет...) */ get props() { if (!isGui) return null; const id = _target.id ?? _target.ref; const found = _guiIndex.find(g => g.id === id); return found ? { ...found } : null; }, onClick(fn) { if (typeof fn === 'function') _selfClickHandlers.push(fn); }, onTouch(fn) { if (typeof fn === 'function') _selfTouchHandlers.push(fn); }, /** Игрок ВЫШЕЛ из объекта (был внутри AABB и вышел). Полезно для триггер-зон. */ onUntouch(fn) { if (typeof fn === 'function') _selfUntouchHandlers.push(fn); }, /** * Взаимодействие по клавише E. Когда игрок подходит близко к объекту — * над объектом появляется подсказка «[E] ...», по нажатию E срабатывает fn. * game.self.onInteract(() => { * game.ui.showText('Дверь открыта!'); * }, { text: 'Открыть дверь', distance: 4 }); * opts: { text: 'Взаимодействовать', distance: 4 (метры), key: 'e' }. */ onInteract(fn, opts) { if (typeof fn !== 'function') return; _selfInteractHandlers.push(fn); // регистрируем объект как интерактивный — main покажет подсказку _send('self.registerInteract', { target: _target, text: (opts && typeof opts.text === 'string') ? opts.text : 'Взаимодействовать', distance: (opts && Number.isFinite(opts.distance)) ? opts.distance : 4, key: (opts && typeof opts.key === 'string') ? opts.key.toLowerCase() : 'e', }); }, move(x, y, z) { const nx = Number(x), ny = Number(y), nz = Number(z); if (Number.isFinite(nx) && Number.isFinite(ny) && Number.isFinite(nz)) { _send('self.move', { target: _target, x: nx, y: ny, z: nz }); } }, delete() { _send('self.delete', { target: _target }); }, /** * Изменить свойства GUI-элемента (только для target.kind='gui'). * game.self.update({ text: 'Новый текст', textColor: '#ff0000' }); */ update(patch) { if (!isGui || !patch || typeof patch !== 'object') return; const id = _target.id ?? _target.ref; _send('gui.update', { id, patch }); }, /** Шорткат для смены текста (для Text/Button). */ setText(text) { if (!isGui) return; const id = _target.id ?? _target.ref; _send('gui.update', { id, patch: { text: String(text == null ? '' : text) } }); }, /** Сделать элемент видимым. */ show() { if (!isGui) return; const id = _target.id ?? _target.ref; _send('gui.update', { id, patch: { visible: true } }); }, /** Скрыть элемент. */ hide() { if (!isGui) return; const id = _target.id ?? _target.ref; _send('gui.update', { id, patch: { visible: false } }); }, }; return api; } const game = { player: { /** Позиция «низа ног» игрока. */ get position() { return { ..._playerState.position }; }, /** * Угол поворота игрока вокруг Y (в радианах). * 0 = смотрит в +Z, π/2 = +X, π = -Z, -π/2 = -X. */ get yaw() { return _playerState.yaw || 0; }, /** Наклон вверх/вниз (в радианах). >0 = смотрит вверх. */ get pitch() { return _playerState.pitch || 0; }, /** * Нормализованный вектор взгляда (направление куда смотрит игрок). * Удобно использовать для спавна объектов «перед собой»: * const f = game.player.forward; * game.scene.spawn('block:grass', { x: p.x + f.x*3, y: p.y, z: p.z + f.z*3 }); */ get forward() { return { ..._playerState.forward }; }, /** * Команда локального игрока (Фаза 4.4) — имя команды или null. * Назначается через game.player.setTeam('Красные'). */ get team() { return (_players.me && _players.me.team) || null; }, /** * Прицел в Play: 'none' | 'dot' | 'cross' | 'circle'. * Чтение возвращает текущее значение, запись — меняет в рантайме: * game.player.crosshair = 'cross'; */ get crosshair() { return _playerState.crosshair || 'none'; }, set crosshair(v) { const allowed = ['none', 'dot', 'cross', 'circle']; const s = String(v || 'none').toLowerCase(); if (!allowed.includes(s)) return; _playerState.crosshair = s; _send('player.crosshair', { type: s }); }, teleport(x, y, z) { const nx = Number(x), ny = Number(y), nz = Number(z); if (Number.isFinite(nx) && Number.isFinite(ny) && Number.isFinite(nz)) { _send('player.teleport', { x: nx, y: ny, z: nz }); } }, /** Сдвинуть игрока ТОЛЬКО по X (не трогая Z и Y). Для раннеров — * смена полосы без отмены продвижения autorun. */ setLaneX(x) { const nx = Number(x); if (Number.isFinite(nx)) _send('player.setLaneX', { x: nx }); }, /** Развернуть модель игрока на угол yaw (радианы). Для кат-сцен, * где игрок стоит лицом в нужную сторону. yaw=0 — лицом в +Z. */ setFacing(yaw) { const y = Number(yaw); if (Number.isFinite(y)) _send('player.setFacing', { yaw: y }); }, /** Проиграть эмоцию персонажа: 'wave'|'dance'|'cheer'|'sit'|'paint'. * Работает только для R15-скинов. Разовая анимация поверх движения. */ playEmote(name) { if (typeof name === 'string') _send('player.emote', { name }); }, /** Прервать текущую эмоцию персонажа. */ stopEmote() { _send('player.stopEmote', {}); }, /** Текущее HP игрока (зеркало из main thread). */ get hp() { return _playerState.hp ?? 100; }, get maxHp() { return _playerState.maxHp ?? 100; }, /** Текущее направление гравитации в Кубикон Dash (+1 вниз, -1 вверх). */ get gravityDir() { return _playerState.gravityDir ?? 1; }, /** Жив ли игрок (hp > 0). */ get alive() { return (_playerState.hp ?? 100) > 0; }, /** * Состояние игрока: 'ground' (на земле), 'air' (в воздухе/прыжке), * 'water' (в воде). * if (game.player.state === 'air') { ... } */ get state() { return _playerState.state || 'ground'; }, /** * Зажата ли клавиша ПРЯМО СЕЙЧАС (для плавного движения по удержанию). * key — 'w','a','s','d','space','shift','arrowup'... (lowercase). * game.onTick(() => { if (game.player.isKeyDown('w')) { ... } }); */ isKeyDown(key) { if (typeof key !== 'string') return false; return !!_playerState.keys[key.toLowerCase()]; }, /** * Нанести урон игроку. Учитываются i-frames (повторный вызов * в течение ~0.5с проигнорится). */ damage(amount) { const a = Number(amount); if (!Number.isFinite(a) || a <= 0) return; _send('player.damage', { amount: a }); }, /** Мгновенно убить игрока (игнорит i-frames). */ kill() { _send('player.damage', { amount: 99999 }); }, /** Восстановить здоровье. */ heal(amount) { const a = Number(amount); if (!Number.isFinite(a) || a <= 0) return; _send('player.heal', { amount: a }); }, /** * Вернуть игрока на spawn-point с полным HP. * Если spawnPoint в проекте задан — телепортирует туда. */ respawn() { _send('player.respawn', null); }, /** * Множитель скорости передвижения. 1 = норма, 1.5 = +50%, 0.5 = вдвое медленнее. */ setSpeed(mul) { const m = Number(mul); if (Number.isFinite(m) && m > 0) _send('player.setSpeed', { mul: m }); }, /** * Множитель силы прыжка. 1 = норма, 1.5 = выше, 2 = ещё выше. */ setJumpPower(mul) { const m = Number(mul); if (Number.isFinite(m) && m > 0) _send('player.setJumpPower', { mul: m }); }, /** * Подфаза 3.11 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md. * Надеть на игрока аксессуар (шляпа/инструмент/причёска/лицо) из * каталога Рублокса. itemId — числовой id из rublox_items * (только published — драфты дизайнеров не видны). * Пример: game.player.equipAccessory(42); // надеть шляпу id=42 */ equipAccessory(itemId) { const id = Number(itemId); if (Number.isFinite(id) && id > 0) { _send('player.equipAccessory', { itemId: id }); } }, /** * Снять аксессуар из слота: 'hat'|'tool'|'tool_left'|'hair'|'face'. * Пример: game.player.unequipSlot('hat'); */ unequipSlot(slot) { const s = String(slot || '').trim(); if (s) _send('player.unequipSlot', { slot: s }); }, /** Снять все аксессуары. */ unequipAll() { _send('player.unequipAll', {}); }, /** * Множитель гравитации. 1 = норма (-22 м/с²), 1.23 = GD-стиль (-27 м/с²). * Работает в обоих направлениях gravityDir. */ setGravityMul(mul) { const m = Number(mul); if (Number.isFinite(m) && m > 0) _send('player.setGravityMul', { mul: m }); }, /** * GD-гейммод Ship: тап-удержание = подъём (вертолёт-стиль). * Обычный jump-импульс отключается, корабль управляется только Space. */ setShipMode(enabled) { _send('player.setShipMode', { enabled: !!enabled }); }, /** * GD-гейммод UFO: каждый отдельный тап Space = микропрыжок в воздухе * (даже без касания земли). Обычный прыжок отключается. */ setUfoMode(enabled) { _send('player.setUfoMode', { enabled: !!enabled }); }, /** * GD-гейммод Wave: движение жёстко под ±45°. * Space зажат → vy = +autoRunSpeed; отпущен → vy = -autoRunSpeed. * Гравитация и прыжок отключены. */ setWaveMode(enabled) { _send('player.setWaveMode', { enabled: !!enabled }); }, /** * GD-гейммод Robot: высота прыжка зависит от длительности удержания Space. * Тап = низкий прыжок (~1.5м), удержание 0.35с = высокий (~4м). Прыжок только с земли. */ setRobotMode(enabled) { _send('player.setRobotMode', { enabled: !!enabled }); }, /** * Двойной прыжок (true/false) — второй прыжок в воздухе. */ setDoubleJump(enabled) { _send('player.setDoubleJump', { enabled: !!enabled }); }, /** * Проиграть эмоцию-анимацию персонажа один раз. * name: 'wave' (помахать) | 'dance' (танец) | 'cheer' (радость) | 'sit' (сесть). * game.onKey('e', () => game.player.playAnimation('wave')); */ playAnimation(name) { if (typeof name !== 'string') return; _send('player.playAnimation', { name }); }, /** Прервать текущую эмоцию персонажа. */ stopAnimation() { _send('player.stopAnimation', {}); }, /** * Скользкость поверхности под игроком. 0 = нормальное движение * (мгновенная остановка), 0.85 = «лёд» (скользит после отпускания * клавиш). 1 = полностью скользко (инерция бесконечная). */ setIceFriction(value) { const v = Number(value); if (Number.isFinite(v)) { _send('player.setIceFriction', { value: v }); } }, /** * Кубикон Dash: авто-бег по +X со скоростью speed (м/с). * Передай 0 чтобы отключить. Работает только в sideview-камере — * иначе скрипт сразу после autoRun() должен звать setCameraMode('sideview'). * Пример: game.player.setAutoRun(8); game.player.setCameraMode('sideview'); */ setAutoRun(speed) { const s = Number(speed); if (Number.isFinite(s)) _send('player.setAutoRun', { speed: s }); }, /** * Мгновенный подброс игрока вверх. strength=1 = обычный прыжок, * strength=2 = в 2 раза выше. Не требует Space — срабатывает сразу. * Используется для трамплинов в Кубикон Dash. */ boostJump(strength) { const s = Number(strength); if (Number.isFinite(s) && s > 0) _send('player.boostJump', { strength: s }); }, /** * Кубикон Dash: перевернуть гравитацию (как blue orb / gravity portal в GD). * После flipGravity игрок прыгает к потолку. Повторный вызов возвращает. * Доступно только в sideview-режиме. */ flipGravity() { _send('player.flipGravity', {}); }, /** * Задать вертикальную скорость игрока (м/с). +значение = вверх, - = вниз. * Используется для трамплинов (vy=16), jump orb (vy=14), boost-зон и т.д. * Не зависит от _shipMode/_waveMode/_robotMode — просто перезаписывает _vy. */ setVy(vy) { const v = Number(vy); if (Number.isFinite(v)) _send('player.setVy', { vy: v }); }, /** * Явно установить направление гравитации: 1 = вниз (норма), -1 = вверх. */ setGravityDir(dir) { const d = Number(dir); if (d === 1 || d === -1) _send('player.setGravityDir', { dir: d }); }, /** * Показать/скрыть основной скин игрока. Используется в Кубикон Dash: * скрываем человечка, рисуем куб-примитив через скрипт. */ setSkinVisible(visible) { _send('player.setSkinVisible', { visible: !!visible }); }, /** * === Задача 07: скины игрока (любая 3D-модель + магазин) === * Сменить активный скин в Play (без перезагрузки сцены). * game.player.setSkin('squirrel-donut'); // встроенный * game.player.setSkin('character-a'); // человек * Возвращает «локальный Promise» (объект с .then) — реальная смена * асинхронна (грузится .glb). Для большинства игр можно не ждать. */ setSkin(slug) { if (typeof slug !== 'string' || !slug) return; _currentSkin = slug; if (_unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug); _send('player.setSkin', { slug }); }, /** Дать игроку скин (разблокировать — например после покупки). */ unlockSkin(slug) { if (typeof slug !== 'string' || !slug) return; if (_unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug); _send('player.unlockSkin', { slug }); }, /** Список slug'ов скинов, доступных игроку (разблокированных). */ getAvailableSkins() { return _unlockedSkins.slice(); }, /** Все встроенные скины с метаданными: [{slug,name,kind,category,price}]. */ getAllSkins() { return _skinsIndex.map(s => ({ ...s })); }, /** Текущий активный скин (slug). */ getCurrentSkin() { return _currentSkin; }, /** Подписка на смену скина: fn(slug). */ onSkinChange(fn) { if (typeof fn === 'function') _skinChangeHandlers.push(fn); }, /** Открыть встроенный GUI-магазин скинов (если включён в проекте). */ openSkinShop() { _send('player.openSkinShop', {}); }, /** Закрыть магазин скинов. */ closeSkinShop() { _send('player.closeSkinShop', {}); }, /** Игровая валюта магазина скинов (рублики проекта, ЛОКАЛЬНЫЕ — * не путать с серверной экономикой game.economy). */ getSkinCoins() { return _skinCoins; }, /** Задать баланс валюты магазина (например стартовые 200). */ setSkinCoins(amount) { const n = Number(amount); if (!Number.isFinite(n)) return; _skinCoins = Math.max(0, Math.floor(n)); _send('player.setSkinCoins', { amount: _skinCoins }); }, /** Добавить валюту магазина (награда за что-то). */ addSkinCoins(amount) { const n = Number(amount); if (!Number.isFinite(n)) return; _skinCoins = Math.max(0, _skinCoins + Math.floor(n)); _send('player.setSkinCoins', { amount: _skinCoins }); }, /** * Режим камеры: 'first' | 'third' | 'front' | 'sideview'. * 'sideview' нужен для Кубикон Dash — камера фиксируется сбоку, * yaw/pitch от мыши/тача игнорируются. */ setCameraMode(mode) { if (typeof mode !== 'string') return; _send('player.setCameraMode', { mode }); }, /** Задача 02: установить дистанцию камеры (для third-person). */ setCameraZoom(distance) { const d = Number(distance); if (!Number.isFinite(d)) return; _send('player.setCameraZoom', { distance: d }); }, /** Задача 02: границы зума колесом мыши. По умолчанию 0.5..32. */ setCameraZoomLimits(min, max) { const mn = Number(min), mx = Number(max); if (!Number.isFinite(mn) || !Number.isFinite(mx)) return; _send('player.setCameraZoomLimits', { min: mn, max: mx }); }, /** Задача 02: Shift-Lock — курсор в центре, корпус лицом к камере. */ setShiftLock(on) { _send('player.setShiftLock', { on: !!on }); }, /** * Присед: уменьшает высоту хитбокса игрока с 1.8 до 0.9 ед. * Используется чтобы пройти под низким потолком. */ setCrouch(enabled) { _send('player.setCrouch', { enabled: !!enabled }); }, /** * Назначить активную точку возрождения. При respawn / смерти * игрок появится здесь. Аргумент: * - ref объекта ('primitive:N' / 'model:N' / 'block:x,y,z') — * игрок встанет НАД этим объектом; * - объект {x, y, z} — точные координаты. * game.self.onInteract(() => game.player.setSpawn(game.self.ref)); */ setSpawn(target) { if (typeof target === 'string' && target) { _send('player.setSpawn', { ref: target }); } else if (target && typeof target === 'object' && Number.isFinite(Number(target.x))) { _send('player.setSpawn', { x: Number(target.x), y: Number(target.y), z: Number(target.z), }); } }, /** * Дать игроку инструмент/оружие в инвентарь (Фаза 4.2). * toolType — id модели ('weapon-sword', 'blaster-blaster-a', ...) * или произвольное имя предмета. * opts: { name, equip:true (сразу взять в руки), params }. * Оружие (blaster-* / weapon-*) получает kind='weapon' + параметры * боя; прочее — kind='tool'. * game.player.giveTool('blaster-blaster-a', { equip: true }); */ giveTool(toolType, opts) { if (typeof toolType !== 'string' || !toolType) return; opts = opts || {}; const isBlaster = toolType.indexOf('blaster') === 0; const isMelee = toolType.indexOf('weapon-') === 0; let kind = 'tool'; let params = {}; if (isBlaster) { kind = 'weapon'; params = { damage: 25, fireRate: 0.2, range: 60, magazine: 12, reserve: 48, }; } else if (isMelee) { kind = 'weapon'; params = { weaponKind: 'melee', damage: 35, fireRate: 0.6, range: 3 }; } // opts.params переопределяет дефолты. if (opts.params && typeof opts.params === 'object') { params = { ...params, ...opts.params }; } _send('inventory.give', { kind, modelTypeId: toolType, name: typeof opts.name === 'string' ? opts.name : toolType, params, equip: opts.equip === true, }); }, /** Убрать инструмент/оружие из инвентаря по id модели или имени. */ removeTool(toolType) { if (typeof toolType !== 'string') return; _send('inventory.remove', { modelTypeId: toolType, name: toolType }); }, /** * Подписка: игрок применил инструмент (ЛКМ с предметом в активном * слоте). fn получает { tool: {kind, modelTypeId, name}, point, target }. */ onToolUse(fn) { if (typeof fn === 'function') _toolUseHandlers.push(fn); }, /** * Назначить игроку команду (Фаза 4.4). Команда должна быть * заранее создана через game.teams.create(). null/'' убирает. * game.teams.create('Красные', '#ff3333'); * game.player.setTeam('Красные'); */ setTeam(name) { _send('player.setTeam', { team: typeof name === 'string' ? name : null }); }, }, /** * Таймер прохождения для лидерборда. * game.timer.start() — запустить отсчёт (с нуля, отображается в HUD). * game.timer.stop() — остановить (но не отправлять). * game.timer.submit() — остановить + отправить рекорд в лидерборд. * Сервер сохраняет если время лучше предыдущего. */ timer: { start() { _send('timer.start', null); }, stop() { _send('timer.stop', null); }, submit() { _send('timer.submit', null); }, }, get self() { return _selfApi; }, onTick(fn) { if (typeof fn === 'function') _tickHandlers.push(fn); }, /** * Выполнить fn ОДИН раз через seconds секунд. * Возвращает id таймера — его можно отменить через game.cancel(id). * game.after(3, () => game.ui.showText('Прошло 3 секунды!')); */ after(seconds, fn) { const s = Number(seconds); if (!Number.isFinite(s) || s < 0 || typeof fn !== 'function') return null; const id = ++_timerSeq; _timers.push({ id, fn, delay: s, elapsed: 0, repeat: false }); return id; }, /** * Выполнять fn КАЖДЫЕ seconds секунд (циклично). * Возвращает id таймера — остановить через game.cancel(id). * const t = game.every(1, () => game.log('тик')); * game.after(10, () => game.cancel(t)); // через 10с остановить */ every(seconds, fn) { const s = Number(seconds); if (!Number.isFinite(s) || s <= 0 || typeof fn !== 'function') return null; const id = ++_timerSeq; _timers.push({ id, fn, delay: s, elapsed: 0, repeat: true }); return id; }, /** Отменить таймер (after или every) по id, который вернул after()/every(). */ cancel(id) { if (id == null) return; const i = _timers.findIndex(t => t.id === id); if (i >= 0) _timers.splice(i, 1); }, /** * Плавно изменить свойства объекта (твин — анимация перехода). * ref — объект сцены (то что вернул scene.spawn / scene.find) или GUI-id. * props — что менять и до какого значения: * { x, y, z, rotationX, rotationY, rotationZ, sx, sy, sz, * color: '#ff0000', opacity: 0..1 } * opts — { duration: 1 (сек), easing: 'linear'|'ease'|'bounce'|'elastic'|'back', * delay: 0, repeat: 0 (раз; -1 = бесконечно), yoyo: false, * onDone: fn } * Возвращает tweenId — анимацию можно прервать через game.cancelTween(id). * * // плавно открыть дверь за 1 секунду * game.tween(door, { rotationY: Math.PI/2 }, { duration: 1, easing: 'ease' }); * // пульсирующая монетка * game.tween(coin, { sy: 1.4 }, { duration: 0.5, yoyo: true, repeat: -1 }); */ tween(ref, props, opts) { if (typeof ref !== 'string' || !props || typeof props !== 'object') return null; opts = opts || {}; const id = ++_tweenSeq; if (typeof opts.onDone === 'function') _tweenCallbacks[id] = opts.onDone; _send('tween.start', { tweenId: id, ref, props, duration: Number.isFinite(Number(opts.duration)) ? Number(opts.duration) : 1, easing: typeof opts.easing === 'string' ? opts.easing : 'ease', delay: Number.isFinite(Number(opts.delay)) ? Number(opts.delay) : 0, repeat: Number.isFinite(Number(opts.repeat)) ? Number(opts.repeat) : 0, yoyo: !!opts.yoyo, }); return id; }, /** Прервать твин по id, который вернул game.tween(). */ cancelTween(id) { if (id == null) return; delete _tweenCallbacks[id]; _send('tween.cancel', { tweenId: id }); }, /** * Подписаться на нажатие клавиши. * key — буква 'w', 'a', 's', 'd' или специальные имена 'space', 'shift', * 'enter', 'escape', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'. * Сравнение case-insensitive. Если key не передан — fn вызывается на любую клавишу. */ onKey(key, fn) { if (typeof key === 'function') { fn = key; key = '*'; } if (typeof fn !== 'function') return; const k = String(key).toLowerCase(); (_globalKeyDownHandlers[k] = _globalKeyDownHandlers[k] || []).push(fn); }, /** То же что onKey, но на отпускание клавиши. */ onKeyUp(key, fn) { if (typeof key === 'function') { fn = key; key = '*'; } if (typeof fn !== 'function') return; const k = String(key).toLowerCase(); (_globalKeyUpHandlers[k] = _globalKeyUpHandlers[k] || []).push(fn); }, /** * Глобальный клик в Play-режиме. event = {point, target}. * target = null если клик прошёл мимо объектов. */ onClick(fn) { if (typeof fn === 'function') _globalClickHandlers.push(fn); }, /** * Игрок коснулся любого объекта (с target-скриптом или без — * для глобального события событие шлётся ВСЕГДА). * event = {target}. */ onPlayerTouch(fn) { if (typeof fn === 'function') _globalTouchHandlers.push(fn); }, /** * Моб убит игроком (или другим способом). * fn({mobType, position}). mobType: 'zombie' | ... */ onMobKilled(fn) { if (typeof fn === 'function') _mobKilledHandlers.push(fn); }, /** * Любой NPC погиб (hp дошёл до 0). fn({id, position}). * Для конкретного NPC удобнее npc.onDeath(fn) на объекте-NPC. */ onNpcDeath(fn) { if (typeof fn === 'function') _globalNpcDeathHandlers.push(fn); }, /** * Игрок присоединился к комнате (Фаза 4.3). fn({sessionId, name}). */ onPlayerJoin(fn) { if (typeof fn === 'function') _playerJoinHandlers.push(fn); }, /** * Катсцена камеры доиграла (Фаза 5.7). fn() — без аргументов. * game.camera.cutscene([...]); * game.onCutsceneDone(() => game.camera.reset()); */ onCutsceneDone(fn) { if (typeof fn === 'function') _cutsceneDoneHandlers.push(fn); }, /** Игрок покинул комнату. fn({sessionId, name}). */ onPlayerLeave(fn) { if (typeof fn === 'function') _playerLeaveHandlers.push(fn); }, /** * Подписаться на адресное сообщение (Фаза 4.3). fn({from, data}). * game.onMessage('подарок', (msg) => game.ui.showText('от ' + msg.from)); */ onMessage(name, fn) { if (typeof name !== 'string' || typeof fn !== 'function') return; (_mpMessageHandlers[name] = _mpMessageHandlers[name] || []).push(fn); }, /** * Отправить сообщение игроку (Фаза 4.3). * game.sendTo(player, 'подарок', { gold: 100 }); * player — объект из game.players.* или его sessionId. */ sendTo(player, name, data) { if (typeof name !== 'string') return; const sessionId = typeof player === 'string' ? player : (player && player.sessionId); if (!sessionId) return; _send('mp.sendTo', { sessionId, name, data }); }, /** * Подписаться на изменение HP игрока (получение урона / лечение / смерть). * fn(event) где event = { hp, maxHp, source, damaged, delta }. * - source — строка ('script', 'zombie', 'fall', 'lava', ...) или null. * - delta — изменение HP (отрицательное = урон, положительное = лечение). * - damaged — true если это был урон. */ onHpChange(fn) { if (typeof fn === 'function') _hpChangeHandlers.push(fn); }, /** * Игрок погиб (hp дошло до 0). Срабатывает один раз на смерть. * game.onPlayerDied(() => game.ui.showText('Игра окончена', 3)); */ onPlayerDied(fn) { if (typeof fn === 'function') _playerDiedHandlers.push(fn); }, /** Игрок прыгнул. */ onPlayerJump(fn) { if (typeof fn === 'function') _playerJumpHandlers.push(fn); }, /** Игрок приземлился (коснулся земли после полёта/прыжка). */ onPlayerLand(fn) { if (typeof fn === 'function') _playerLandHandlers.push(fn); }, /** * UI / HUD — текст и счётчики поверх viewport в Play. * game.ui.showText('Привет', 2) — флешит текст в центре * game.ui.score = 100 — счётчик в углу * game.ui.timer = 60 — таймер * game.ui.set('hp', 'HP: 100', {color}) — произвольная именованная метка * game.ui.remove('hp') * game.ui.clear() — убрать всё */ ui: (() => { const _state = { score: null, timer: null }; return { get score() { return _state.score; }, set score(v) { _state.score = v; _send('ui.set', { id: '__score', text: v == null ? null : 'Очки: ' + v }); }, get timer() { return _state.timer; }, set timer(v) { _state.timer = v; if (v == null) { _send('ui.set', { id: '__timer', text: null }); return; } const n = Number(v); if (!Number.isFinite(n)) return; const mm = Math.floor(Math.max(0, n) / 60); const ss = Math.floor(Math.max(0, n) % 60); const txt = (mm < 10 ? '0' : '') + mm + ':' + (ss < 10 ? '0' : '') + ss; _send('ui.set', { id: '__timer', text: txt }); }, /** Кратковременный текст по центру экрана. seconds=2 по умолчанию. */ showText(text, seconds) { _send('ui.flash', { text: String(text == null ? '' : text), seconds: Number.isFinite(Number(seconds)) ? Number(seconds) : 2, }); }, /** * Установить произвольную метку. * id — уникальное имя для последующих обновлений и remove. * opts: { x, y } — позиция в процентах (0..100), { color, size } — стилизация. */ set(id, text, opts) { if (typeof id !== 'string' || !id) return; _send('ui.set', { id, text: text == null ? null : String(text), opts: opts || null, }); }, /** Убрать метку по id. */ remove(id) { if (typeof id !== 'string' || !id) return; _send('ui.set', { id, text: null }); }, /** Убрать весь HUD. */ clear() { _state.score = null; _state.timer = null; _send('ui.clear', null); }, }; })(), /** API сцены: spawn/delete/find/all. */ scene: { /** * Создать объект на сцене. * type: 'block:' / 'primitive:' / 'model:' / 'light:point'. * opts: { x, y, z, sx, sy, sz, color, material, rotationY, name, lifetime, * brightness, range }. * lifetime — если задан (секунды), объект сам удалится через это время. * brightness/range — только для 'light:point' (яркость и радиус лампы). * Возвращает строку-ref (можно использовать в delete/getPosition). */ spawn(type, opts) { if (typeof type !== 'string') return null; opts = opts || {}; // Алиас: 'light:point' — это примитив-лампа. if (type === 'light:point' || type === 'light') type = 'primitive:light'; const x = Number(opts.x) || 0; const y = Number(opts.y) || 0; const z = Number(opts.z) || 0; const colon = type.indexOf(':'); if (colon < 0) return null; const kind = type.slice(0, colon); const subType = type.slice(colon + 1); if (kind === 'block') { const ix = Math.round(x), iy = Math.round(y), iz = Math.round(z); const ref = 'block:' + ix + ',' + iy + ',' + iz; // color — для окрашиваемых блоков (studs-block, задача 09). _send('scene.spawn', { kind: 'block', subType, x: ix, y: iy, z: iz, ref, color: opts.color }); if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime); return ref; } if (kind === 'primitive' || kind === 'model') { _localRefSeq++; const ref = kind + ':_local_' + _localRefSeq; _send('scene.spawn', { kind, subType, x, y, 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, // anchored:false → объект падает (физика). По умолчанию // примитив заякорен (anchored:true) и висит на месте. anchored: opts.anchored, // canCollide — можно сделать объект проходимым (зона). canCollide: opts.canCollide, // visible:false → объект скрыт (показать через setVisible). visible: opts.visible, // textureAsset — id картинки из ассетов проекта на грани. textureAsset: opts.textureAsset, ref, }); if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime); return ref; } // Пользовательская модель из воксельного редактора моделей. // type = 'user:', где — числовой id модели в проекте. // ref пользовательских инстансов в сцене — 'usermodel:'. if (kind === 'user') { _localRefSeq++; const ref = 'usermodel:_local_' + _localRefSeq; _send('scene.spawn', { kind: 'userModel', // subType — это полная строка 'user:' (как принимает // UserModelManager.addInstance). Восстанавливаем её. subType: 'user:' + subType, x, y, z, rotationY: opts.rotationY, scale: opts.scale, name: opts.name, ref, }); if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime); return ref; } return null; }, /** Удалить объект по ref. */ delete(ref) { if (typeof ref !== 'string' || !ref) return; _send('scene.delete', { ref }); }, /** * Удалить объект через seconds секунд (авто-удаление). * const p = game.scene.spawn('primitive:cube', { x, y, z }); * game.scene.deleteAfter(p, 5); // исчезнет через 5 секунд */ deleteAfter(ref, seconds) { _scheduleDelete(ref, seconds); }, /** * Переместить объект (для моделей/примитивов) — без target-скрипта. * ref — то что вернул spawn() или scene.find(). */ move(ref, x, y, z) { if (typeof ref !== 'string') return; const nx = Number(x), ny = Number(y), nz = Number(z); if (!Number.isFinite(nx) || !Number.isFinite(ny) || !Number.isFinite(nz)) return; // Парсим ref: 'primitive:_local_3' или 'primitive:realId' или 'model:id' const colon = ref.indexOf(':'); if (colon < 0) return; const kind = ref.slice(0, colon); const id = ref.slice(colon + 1); if (kind !== 'primitive' && kind !== 'model') return; _send('self.move', { target: { kind, id, ref: id }, x: nx, y: ny, z: nz, }); }, /** * Повернуть объект вокруг Y (в радианах). Только для примитивов. */ rotate(ref, ry) { if (typeof ref !== 'string') return; const r = Number(ry); if (!Number.isFinite(r)) return; const colon = ref.indexOf(':'); if (colon < 0) return; const kind = ref.slice(0, colon); const id = ref.slice(colon + 1); if (kind !== 'primitive') return; _send('scene.rotate', { kind, id, rotationY: r }); }, /** * Установить полный поворот (rx, ry, rz) в радианах. Для примитивов. * Нужно для Кубикон Dash: куб крутится вокруг Z в воздухе. */ setRotation(ref, rx, ry, rz) { if (typeof ref !== 'string') return; const x = Number(rx), y = Number(ry), z = Number(rz); if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return; const colon = ref.indexOf(':'); if (colon < 0) return; const kind = ref.slice(0, colon); const id = ref.slice(colon + 1); if (kind !== 'primitive') return; _send('scene.setRotation', { id, rx: x, ry: y, rz: z }); }, /** * Изменить collision примитива (true = твёрдый, false = проваливается). */ setCollide(ref, can) { if (typeof ref !== 'string') return; const colon = ref.indexOf(':'); if (colon < 0) return; const kind = ref.slice(0, colon); const id = ref.slice(colon + 1); if (kind !== 'primitive') return; _send('scene.setCollide', { id, canCollide: !!can }); }, /** * Изменить видимость примитива/модели (true = видно, false = скрыт). */ setVisible(ref, vis) { if (typeof ref !== 'string') return; const colon = ref.indexOf(':'); if (colon < 0) return; const kind = ref.slice(0, colon); const id = ref.slice(colon + 1); if (kind !== 'primitive' && kind !== 'model') return; _send('scene.setVisible', { kind, id, visible: !!vis }); }, /** * Изменить цвет примитива (hex-строка типа '#ff0000'). */ setColor(ref, color) { if (typeof ref !== 'string' || typeof color !== 'string') return; const colon = ref.indexOf(':'); if (colon < 0) return; const kind = ref.slice(0, colon); const id = ref.slice(colon + 1); if (kind !== 'primitive') return; _send('scene.setColor', { id, color }); }, /** * Повесить текст-метку НАД объектом (имя/HP над персонажем, врагом). * Метка всегда повёрнута к камере и видна поверх геометрии. * game.scene.setLabel(enemy, 'Босс HP: 100', { color: '#ff4444' }); * opts: { color: '#fff', height: 2.5 (м над объектом), size: 1 }. * Работает для примитивов и моделей. */ setLabel(ref, text, opts) { if (typeof ref !== 'string') return; _send('scene.setLabel', { ref, text: String(text == null ? '' : text), opts: opts || {}, }); }, /** Убрать метку с объекта. */ clearLabel(ref) { if (typeof ref !== 'string') return; _send('scene.clearLabel', { ref }); }, /** * Прозрачность примитива: 1 = непрозрачно, 0 = полностью невидимо. * Для плавного исчезновения используй game.tween(ref, {opacity: 0}, ...). */ setOpacity(ref, value) { if (typeof ref !== 'string') return; const v = Number(value); if (!Number.isFinite(v)) return; if (ref.indexOf('primitive:') !== 0) return; // шлём ПОЛНЫЙ ref — GameRuntime._resolvePrimitiveId резолвит // локальный ref ('primitive:_local_N') через _localToReal. _send('scene.setOpacity', { ref, opacity: Math.max(0, Math.min(1, v)) }); }, /** * Масштаб примитива по осям. 1 = обычный размер, 2 = вдвое больше. * Можно передать одно число (одинаково по всем осям) или три. * game.scene.setScale(box, 2); // куб ×2 * game.scene.setScale(box, 1, 3, 1); // вытянуть по Y */ setScale(ref, sx, sy, sz) { if (typeof ref !== 'string') return; let nx = Number(sx); if (!Number.isFinite(nx) || nx <= 0) return; let ny = Number(sy), nz = Number(sz); // один аргумент → одинаково по всем осям if (!Number.isFinite(ny)) ny = nx; if (!Number.isFinite(nz)) nz = nx; if (ref.indexOf('primitive:') !== 0) return; _send('scene.setScale', { ref, sx: nx, sy: ny, sz: nz }); }, /** * Материал примитива: 'default' | 'metal' | 'glass' | 'neon'. * game.scene.setMaterial(box, 'neon'); // куб светится */ setMaterial(ref, name) { if (typeof ref !== 'string' || typeof name !== 'string') return; if (ref.indexOf('primitive:') !== 0) return; _send('scene.setMaterial', { ref, material: name }); }, /** * Создать копию примитива со смещением. Возвращает ref новой копии. * const copy = game.scene.clone(box, { dx: 3 }); // копия на 3 правее * offset: { dx, dy, dz } — смещение относительно оригинала. */ clone(ref, offset) { if (typeof ref !== 'string') return null; if (ref.indexOf('primitive:') !== 0) return null; offset = offset || {}; _localRefSeq++; const newRef = 'primitive:_local_' + _localRefSeq; _send('scene.clone', { ref, newRef, dx: Number(offset.dx) || 0, dy: Number(offset.dy) || 0, dz: Number(offset.dz) || 0, }); return newRef; }, /** * Установить динамическую текстуру примитива из dataURL. * dataUrl — base64 PNG (например, из canvas.toDataURL()). * Используется для GD-скинов: canvas-фабрика рисует лицо куба → шлёт сюда. */ setTexture(ref, dataUrl) { if (typeof ref !== 'string' || typeof dataUrl !== 'string') return; const colon = ref.indexOf(':'); if (colon < 0) return; const kind = ref.slice(0, colon); const id = ref.slice(colon + 1); if (kind !== 'primitive') return; _send('scene.setTexture', { id, dataUrl }); }, /** * Установить АБСОЛЮТНЫЙ угол поворота папки вокруг точки pivot (XZ). * Все примитивы внутри папки повернутся как единое целое. * game.scene.setFolderYaw('Голова куклы', Math.PI, { x: 0, z: 90 }); */ setFolderYaw(folderName, angle, pivot) { if (typeof folderName !== 'string') return; const a = Number(angle); if (!Number.isFinite(a)) return; if (!pivot || !Number.isFinite(Number(pivot.x)) || !Number.isFinite(Number(pivot.z))) return; _send('scene.setFolderYaw', { folderName, angle: a, pivot: { x: Number(pivot.x), z: Number(pivot.z) }, }); }, /** * Найти объекты по name. Возвращает массив Instance-прокси (паритет * со студией). Instance coerces в строку-ref, поэтому код, принимавший * строковый ref, продолжает работать. */ find(name) { const out = []; const n = String(name || '').toLowerCase(); for (const m of _sceneIndex.models) { if (m.name && String(m.name).toLowerCase() === n) { out.push(_getOrCreateInstance(m.ref, 'model')); } } for (const p of _sceneIndex.primitives) { if (p.name && String(p.name).toLowerCase() === n) { out.push(_getOrCreateInstance(p.ref, 'primitive')); } } return out; }, /** Первый объект с таким name или null. */ findOne(name) { const arr = this.find(name); return arr.length > 0 ? arr[0] : null; }, /** Список Instance всех объектов заданного типа: 'block' | 'model' | 'primitive'. */ all(kind) { if (kind === 'block') return _sceneIndex.blocks.map(b => _getOrCreateInstance(b.ref, 'block')); if (kind === 'model') return _sceneIndex.models.map(m => _getOrCreateInstance(m.ref, 'model')); if (kind === 'primitive') return _sceneIndex.primitives.map(p => _getOrCreateInstance(p.ref, 'primitive')); return []; }, /** * Сохранить произвольное значение НА объекте (атрибут). * Видно всем скриптам — скрипт двери ставит, скрипт ключа читает. * game.scene.setData(door, 'locked', true); * game.scene.setData(chest, 'gold', 100); */ setData(ref, key, value) { if (typeof ref !== 'string' || typeof key !== 'string') return; // оптимистично обновляем локальное зеркало (до прихода снапшота) if (!_dataIndex[ref]) _dataIndex[ref] = {}; _dataIndex[ref][key] = value; _send('scene.setData', { ref, key, value }); }, /** * Прочитать атрибут объекта. Возвращает значение или undefined. * if (game.scene.getData(door, 'locked')) { ... } */ getData(ref, key) { if (typeof ref !== 'string' || typeof key !== 'string') return undefined; const bag = _dataIndex[ref]; return bag ? bag[key] : undefined; }, /** * Теги объектов (Фаза 5.6) — как CollectionService в Roblox. * Помечаешь объекты тегом, потом находишь все объекты с тегом. * game.scene.tag(enemy, 'враг'); * for (const e of game.scene.getTagged('враг')) { ... } */ tag(ref, tag) { if (typeof ref !== 'string' || typeof tag !== 'string' || !tag) return; // Оптимистично обновляем локальное зеркало (до прихода снапшота). if (!_dataIndex[ref]) _dataIndex[ref] = {}; const cur = Array.isArray(_dataIndex[ref].__tags) ? _dataIndex[ref].__tags : []; if (!cur.includes(tag)) _dataIndex[ref].__tags = [...cur, tag]; _send('scene.tag', { ref, tag }); }, /** Снять тег с объекта. */ untag(ref, tag) { if (typeof ref !== 'string' || typeof tag !== 'string' || !tag) return; if (_dataIndex[ref] && Array.isArray(_dataIndex[ref].__tags)) { _dataIndex[ref].__tags = _dataIndex[ref].__tags.filter(t => t !== tag); } _send('scene.untag', { ref, tag }); }, /** True если у объекта есть такой тег. */ hasTag(ref, tag) { if (typeof ref !== 'string' || typeof tag !== 'string') return false; const bag = _dataIndex[ref]; return !!(bag && Array.isArray(bag.__tags) && bag.__tags.includes(tag)); }, /** Список ref всех объектов с заданным тегом. */ getTagged(tag) { if (typeof tag !== 'string' || !tag) return []; const out = []; for (const ref of Object.keys(_dataIndex)) { const bag = _dataIndex[ref]; if (bag && Array.isArray(bag.__tags) && bag.__tags.includes(tag)) { out.push(ref); } } return out; }, /** Позиция объекта по ref или null. Работает и с локальным ref * от scene.spawn (резолвит в реальный через _spawnLocalToReal). */ getPosition(ref) { if (typeof ref !== 'string') return null; // Локальный ref scene.spawn → реальный. const r = _spawnLocalToReal[ref] || ref; for (const b of _sceneIndex.blocks) if (b.ref === r) return { x: b.x, y: b.y, z: b.z }; for (const m of _sceneIndex.models) if (m.ref === r) return { x: m.x, y: m.y, z: m.z }; for (const p of _sceneIndex.primitives) if (p.ref === r) return { x: p.x, y: p.y, z: p.z }; return null; }, /** * Создать NPC — управляемого скриптом персонажа (Фаза 4.1). * modelType — id модели (как в game.scene.spawn('model:...')). * opts: { x, y, z, rotationY, hp, name, speed }. * Возвращает объект-NPC с методами: * npc.moveTo(x, z) — идти в точку * npc.follow(ref) — следовать за объектом ('player' или ref) * npc.stop() — остановиться * npc.say(text, sec) — реплика над головой * npc.damage(amount) — нанести урон * npc.remove() — убрать со сцены * npc.onDeath(fn) — колбэк при гибели NPC * npc.position — {x,y,z} (актуальная позиция) * npc.hp / npc.name — текущие значения * npc.ref — строковый ref NPC * * const trader = game.scene.spawnNpc('robot', { x: 5, z: 0, name: 'Боб' }); * trader.say('Привет!'); * trader.follow('player'); */ spawnNpc(modelType, opts) { if (typeof modelType !== 'string') return null; opts = opts || {}; _npcRefSeq++; const ref = 'npc:_local_' + _npcRefSeq; _send('npc.spawn', { modelType, ref, x: Number(opts.x) || 0, y: Number(opts.y) || 0, z: Number(opts.z) || 0, rotationY: Number(opts.rotationY) || 0, hp: Number.isFinite(Number(opts.hp)) ? Number(opts.hp) : undefined, name: typeof opts.name === 'string' ? opts.name : undefined, speed: Number.isFinite(Number(opts.speed)) ? Number(opts.speed) : undefined, }); return _makeNpcProxy(ref); }, /** Список всех NPC на сцене — массив объектов {id, name, x,y,z, hp, ...}. */ npcs() { return _npcs.map(n => ({ ...n })); }, /** * Эффект частиц в точке. Авто-удаляется через duration секунд. * type: 'fire' | 'smoke' | 'sparks' | 'magic' | 'explosion' | 'confetti' * position: {x,y,z} * options: { duration: 2 (сек), count: 50 (множитель), color: '#ffaa00' } */ spawnParticles(type, position, options) { if (typeof type !== 'string' || !position) return; _send('scene.particles', { type, position: { x: Number(position.x) || 0, y: Number(position.y) || 0, z: Number(position.z) || 0, }, duration: options && Number.isFinite(Number(options.duration)) ? Number(options.duration) : 1.5, count: options && Number.isFinite(Number(options.count)) ? Number(options.count) : 1, color: options?.color || null, }); }, /** * Снимок всех живых мобов (зомби и т.д.). Обновляется каждый tick. * Возвращает массив { id, mobType, x, y, z, hp }. * Опциональный фильтр: { mobType?: 'zombie', within?: { x, y?, z, radius } } */ mobs(filter) { const arr = _mobs.slice(); if (!filter) return arr; const wantType = typeof filter.mobType === 'string' ? filter.mobType : null; const within = filter.within; if (wantType == null && !within) return arr; const out = []; const wx = within ? Number(within.x) || 0 : 0; const wz = within ? Number(within.z) || 0 : 0; const wr2 = within ? (Number(within.radius) || 0) ** 2 : 0; for (const m of arr) { if (wantType && m.mobType !== wantType) continue; if (within) { const dx = m.x - wx, dz = m.z - wz; if (dx*dx + dz*dz > wr2) continue; } out.push(m); } return out; }, /** * Убить моба (или массив мобов). Принимает объект из mobs() или его id. * Запускает обычную смерть (с эффектами + onMobKilled). */ killMob(target) { if (target == null) return; const items = Array.isArray(target) ? target : [target]; for (const it of items) { let id = null; if (typeof it === 'number') id = it; else if (it && typeof it === 'object' && 'id' in it) id = Number(it.id); if (Number.isFinite(id)) _send('mob.kill', { id }); } }, /** * Высота поверхности гладкого ландшафта в точке (x, z). * Билинейная интерполяция по карте высот (raycast по реальному * мешу, снятой при старте). Нужно чтобы скрипты ставили объекты * (животных и т.п.) ТОЧНО на землю, а не парили/тонули. * * Возвращает Y поверхности, или null если карта высот не пришла * (нет гладкого ландшафта в проекте). * * const y = game.scene.surfaceY(p.x, p.z); * if (y !== null) game.self.move(nx, y, nz); */ surfaceY(x, z) { const hm = _terrainHM; if (!hm || !hm.heights) return null; const nx = Number(x), nz = Number(z); if (!Number.isFinite(nx) || !Number.isFinite(nz)) return null; const fx = (nx - hm.origin.x) / hm.step; const fz = (nz - hm.origin.z) / hm.step; let c0 = Math.floor(fx), r0 = Math.floor(fz); // clamp в пределы карты if (c0 < 0) c0 = 0; if (c0 > hm.cols - 2) c0 = hm.cols - 2; if (r0 < 0) r0 = 0; if (r0 > hm.rows - 2) r0 = hm.rows - 2; const tx = Math.max(0, Math.min(1, fx - c0)); const tz = Math.max(0, Math.min(1, fz - r0)); const H = hm.heights; const W = hm.cols; const h00 = H[r0 * W + c0]; const h10 = H[r0 * W + c0 + 1]; const h01 = H[(r0 + 1) * W + c0]; const h11 = H[(r0 + 1) * W + c0 + 1]; // null-ячейки заменяем на среднее валидных const vals = []; if (h00 != null) vals.push(h00); if (h10 != null) vals.push(h10); if (h01 != null) vals.push(h01); if (h11 != null) vals.push(h11); if (vals.length === 0) return null; const avg = vals.reduce((a, b) => a + b, 0) / vals.length; const v00 = h00 != null ? h00 : avg; const v10 = h10 != null ? h10 : avg; const v01 = h01 != null ? h01 : avg; const v11 = h11 != null ? h11 : avg; const a = v00 * (1 - tx) + v10 * tx; const b = v01 * (1 - tx) + v11 * tx; return a * (1 - tz) + b * tz; }, }, /** * Физика — луч (raycast), импульсы, взрывы. */ physics: { /** * Пустить луч из точки origin в направлении dir. * Возвращает { hit, ref, point, distance } — hit=true если во что-то * попали. ref — объект (primitive) в который попали. * Синхронный — можно звать прямо в onClick для стрельбы. * const r = game.physics.raycast(game.player.position, game.player.forward); * if (r.hit) game.scene.delete(r.ref); * opts: { maxDistance: 100, ignore: [ref, ...] } */ raycast(origin, dir, opts) { opts = opts || {}; const ox = Number(origin?.x), oy = Number(origin?.y), oz = Number(origin?.z); let dx = Number(dir?.x), dy = Number(dir?.y), dz = Number(dir?.z); if (![ox, oy, oz, dx, dy, dz].every(Number.isFinite)) { return { hit: false, ref: null, point: null, distance: Infinity }; } // нормализуем направление const dlen = Math.sqrt(dx*dx + dy*dy + dz*dz) || 1; dx /= dlen; dy /= dlen; dz /= dlen; const maxDist = Number.isFinite(Number(opts.maxDistance)) ? Number(opts.maxDistance) : 100; const ignore = Array.isArray(opts.ignore) ? opts.ignore : []; let best = { hit: false, ref: null, point: null, distance: Infinity }; // перебираем примитивы — ray vs AABB (с учётом поворота вокруг Y) for (const p of _sceneIndex.primitives) { if (p.visible === false) continue; if (ignore.includes(p.ref)) continue; const hw = (p.sx || 1) / 2, hh = (p.sy || 1) / 2, hd = (p.sz || 1) / 2; // переводим луч в локальные координаты примитива (обратный поворот по Y) const ang = -(p.rotationY || 0); const cos = Math.cos(ang), sin = Math.sin(ang); const rx = ox - p.x, rz = oz - p.z; const lox = rx * cos - rz * sin; const loz = rx * sin + rz * cos; const loy = oy - p.y; const ldx = dx * cos - dz * sin; const ldz = dx * sin + dz * cos; const ldy = dy; // slab-тест ray vs AABB const t = _rayAabb(lox, loy, loz, ldx, ldy, ldz, hw, hh, hd); if (t != null && t >= 0 && t <= maxDist && t < best.distance) { best = { hit: true, ref: p.ref, distance: t, point: { x: ox + dx*t, y: oy + dy*t, z: oz + dz*t }, }; } } return best; }, /** * Задать скорость объекту (м/с). Объект полетит с этой скоростью. * game.physics.setVelocity(ball, { x: 0, y: 10, z: 5 }); */ setVelocity(ref, vel) { if (typeof ref !== 'string' || !vel) return; _send('physics.setVelocity', { ref, vx: Number(vel.x) || 0, vy: Number(vel.y) || 0, vz: Number(vel.z) || 0, }); }, /** * Толкнуть объект импульсом (резкий толчок). * game.physics.applyImpulse(box, { x: 15, y: 5, z: 0 }); */ applyImpulse(ref, impulse) { if (typeof ref !== 'string' || !impulse) return; _send('physics.applyImpulse', { ref, ix: Number(impulse.x) || 0, iy: Number(impulse.y) || 0, iz: Number(impulse.z) || 0, }); }, /** * Взрыв в точке: визуальный эффект + урон игроку и мобам в радиусе. * game.physics.explode({ x, y, z }, 5, { damage: 40 }); * opts: { damage: 30, force: 0 } — урон и сила отброса. */ explode(pos, radius, opts) { if (!pos) return; opts = opts || {}; _send('physics.explode', { x: Number(pos.x) || 0, y: Number(pos.y) || 0, z: Number(pos.z) || 0, radius: Number(radius) || 3, damage: Number.isFinite(Number(opts.damage)) ? Number(opts.damage) : 30, force: Number(opts.force) || 0, }); }, /** * Проходимость объекта или группы (Фаза 5.9, collision groups). * target — ref объекта ИЛИ тег (тогда применяется ко ВСЕМ объектам * с этим тегом — теги работают как collision groups). * on=true — игрок проходит сквозь (объект остаётся видимым), * on=false — снова твёрдый. * game.physics.passThrough(wall, true); // одна стена * game.physics.passThrough('призраки', true); // вся группа по тегу */ passThrough(target, on) { if (typeof target !== 'string' || !target) return; _send('physics.passThrough', { target, on: !!on }); }, }, /** * GUI — управление 2D-интерфейсом (Frame/Text/Button/Image) из скриптов. */ gui: { /** Найти ID элемента по имени. Возвращает строку или null. */ find(name) { if (typeof name !== 'string') return null; const n = name.toLowerCase(); for (const g of _guiIndex) { if (g.name && String(g.name).toLowerCase() === n) return g.id; } return null; }, /** Список ID всех элементов. */ all() { return _guiIndex.map(g => g.id); }, /** Получить копию свойств элемента. */ get(id) { if (typeof id !== 'string') return null; const found = _guiIndex.find(g => g.id === id); return found ? { ...found } : null; }, /** * Изменить свойства элемента. * game.gui.update('gui_xxx', { text: 'Hi', textColor: '#ff0' }); */ update(id, patch) { if (typeof id !== 'string' || !patch || typeof patch !== 'object') return; _send('gui.update', { id, patch }); }, /** Сделать элемент видимым. */ show(id) { if (typeof id !== 'string') return; _send('gui.update', { id, patch: { visible: true } }); }, /** Скрыть элемент (но не удалять). */ hide(id) { if (typeof id !== 'string') return; _send('gui.update', { id, patch: { visible: false } }); }, /** * Создать новый элемент. Возвращает локальный ref-id (строку). * const id = game.gui.create('text', { x: 50, y: 10, text: 'HP: 100' }); */ create(type, opts) { if (typeof type !== 'string') return null; _localRefSeq++; const localRef = '_gui_local_' + _localRefSeq; _send('gui.create', { type, opts: opts || {}, localRef }); return localRef; }, /** Удалить элемент по id. */ remove(id) { if (typeof id !== 'string') return; _send('gui.remove', { id }); }, /** * Подписаться на клик по кнопке (по id). * game.gui.onClick('gui_xxx', () => { game.log('clicked!'); }); */ onClick(id, fn) { if (typeof id !== 'string' || typeof fn !== 'function') return; (_guiClickHandlers[id] = _guiClickHandlers[id] || []).push(fn); }, /** * Подписаться на ввод в поле TextBox — срабатывает когда игрок * нажал Enter. fn получает введённый текст. * game.gui.onSubmit('gui_name', (text) => { * game.ui.showText('Привет, ' + text); * }); */ onSubmit(id, fn) { if (typeof id !== 'string' || typeof fn !== 'function') return; (_guiSubmitHandlers[id] = _guiSubmitHandlers[id] || []).push(fn); }, /** Задача 03: tween свойства GUI-элемента. * props: { x, y, w, h, rotation, scaleX, scaleY, bgOpacity, textSize, * bgColor, textColor, borderColor } (любое числовое или hex-цвет). * opts: { duration, easing, delay, repeat, reverses, onDone } */ tween(id, props, opts) { if (typeof id !== 'string' || !id) return null; if (!props || typeof props !== 'object') return null; opts = opts || {}; const tid = ++_tweenSeq; if (typeof opts.onDone === 'function') _tweenCallbacks[tid] = opts.onDone; _send('gui.tween', { tweenId: tid, id, props, duration: Number.isFinite(Number(opts.duration)) ? Number(opts.duration) : 0.5, easing: typeof opts.easing === 'string' ? opts.easing : 'ease', delay: Number.isFinite(Number(opts.delay)) ? Number(opts.delay) : 0, repeat: Number.isFinite(Number(opts.repeat)) ? Number(opts.repeat) : 0, reverses: !!opts.reverses, }); return tid; }, /** Отменить tween по id (возвращённому из game.gui.tween). */ cancelTween(tweenId) { if (!Number.isFinite(tweenId)) return; _send('gui.cancelTween', { tweenId }); delete _tweenCallbacks[tweenId]; }, }, /** * Камера — тряска, FOV, привязка к объекту, катсцены (Фаза 5.7). */ camera: { /** * Тряска камеры. amp в метрах (0.1 = чуть-чуть, 0.5 = сильно), * dur в секундах. Затухает к 0. */ shake(amp, dur) { const a = Number(amp), d = Number(dur); if (!Number.isFinite(a) || !Number.isFinite(d) || a <= 0 || d <= 0) return; _send('camera.shake', { amp: a, dur: d }); }, /** * Угол обзора камеры (FOV) в градусах. 70 — норма, 90 — широкий, * 40 — «зум». Диапазон 10..130. * game.camera.setFov(90); */ setFov(degrees) { const d = Number(degrees); if (Number.isFinite(d)) _send('camera.fov', { degrees: d }); }, /** * Привязать камеру к объекту — она следит за ним. * ref — объект сцены. opts: { distance, height } — отступ камеры. * game.camera.focusOn(bossRef, { distance: 12, height: 6 }); */ focusOn(ref, opts) { if (typeof ref !== 'string') return; opts = opts || {}; _send('camera.focus', { ref, distance: Number.isFinite(Number(opts.distance)) ? Number(opts.distance) : undefined, height: Number.isFinite(Number(opts.height)) ? Number(opts.height) : undefined, }); }, /** * Катсцена — плавный пролёт камеры по точкам. * points — массив позиций камеры [{x,y,z}, ...]. * opts: { lookAt: [{x,y,z}, ...] — точки взгляда (по одной на * позицию), segDuration: секунд на отрезок }. * game.camera.cutscene( * [{x:0,y:10,z:-20}, {x:0,y:5,z:0}], * { lookAt: [{x:0,y:0,z:0}, {x:0,y:0,z:0}], segDuration: 3 } * ); */ cutscene(points, opts) { if (!Array.isArray(points) || points.length < 2) return; opts = opts || {}; _send('camera.cutscene', { points, lookAt: Array.isArray(opts.lookAt) ? opts.lookAt : [], segDuration: Number.isFinite(Number(opts.segDuration)) ? Number(opts.segDuration) : 2, }); }, /** Вернуть камеру под управление игрока. */ reset() { _send('camera.reset', {}); }, }, /** * Управление стандартным HUD движка (HP-бар, hotbar, кнопка меню, чат). * Нужно для игр которые делают свой UI через game.gui.* и не хотят * чтобы стандартные элементы мешали. */ hud: { /** Скрыть/показать ВСЕ стандартные HUD-элементы. */ setVisible(visible) { _send('hud.setVisible', { visible: !!visible }); }, /** Скрыть/показать только хотбар (5 слотов инвентаря снизу). * Для игр где инвентарь не нужен (магазин/головоломка/симулятор). */ setHotbarVisible(visible) { _send('hud.setHotbarVisible', { visible: !!visible }); }, /** Скрыть/показать только HP-индикатор (полоска жизней). */ setHpVisible(visible) { _send('hud.setHpVisible', { visible: !!visible }); }, }, /** * Задача 04: модальные сцены (затемнение + GUI поверх + блок ввода). * * Типовой кейс: boss-fight intro, открытие лутбокса, диалог с NPC, получил питомца. * * const m = game.modal.open({ * darken: 0.7, // 0..1 — насколько затемнить (по умолчанию 0.5) * darkenColor: '#000', // цвет затемнения * target: 'scene', // 'scene' (HUD виден) | 'screen' (всё затемнено) * blockInput: true, // блокирует WASD/Space/Ctrl (Esc/Tab/Enter работают) * freezeCamera: true, // камера замирает * fadeIn: 0.4, // секунды до полного затемнения * fadeOut: 0.3, * spotlights: [boss, h1, h2], // 3D-рефы, которые остаются яркими (вырезка mask) * spotlightRadius: 120, // пиксели — радиус «прожектора» * pauseSimulation: false, // ставит весь GameRuntime на паузу (мобы замирают) * muteWorld: false, // приглушает ambient/sfx * cameraOverride: { // фокус камеры на цель * target: boss, distance: 8, height: 3, fov: 60, duration: 0.5, * }, * content: { elements: [ // временные GUI поверх модала, удалятся при close * { kind: 'text', x: 50, y: 25, text: 'Франкенслим', textSize: 48, * textStroke: { color: '#000', width: 3 }, textColor: '#fff' }, * { kind: 'button', id: 'fight', x: 50, y: 80, w: 20, h: 6, text: 'В бой!' }, * ]}, * }); * game.gui.onClick('fight', () => game.modal.close(m)); * * Готовые пресеты: * game.modal.bossIntro(name, hp, refs) — intro босса с HP-баром * game.modal.lootbox(items, onPick) — открытие лутбокса * game.modal.dialog(npcName, lines, onDone) — диалог с NPC построчно * game.modal.confirmation(title, body, onYes, onNo) — Да/Нет * * Только ОДИН модал одновременно. Повторный open мгновенно закрывает предыдущий. */ modal: { _localSeq: 0, _localToReal: new Map(), // localId → реальный modalId (приходит в modalOpened) _onCloseFns: [], open(opts) { opts = opts || {}; const localId = ++this._localSeq; const replyId = '_mopen_' + localId; _send('modal.open', { opts, replyId }); // Возвращаем локальный id — реальный придёт асинхронно через modalOpened-event return localId; }, close(modalId) { // Резолвим локальный id → реальный. Если modalId — локальное число, но // реальный ещё не пришёл (гонка modalOpened-event), шлём null: модал // одиночный, null закрывает активный. Передавать локальный id нельзя — // ModalManager.close сверяет его со своим _state.id и молча игнорит. let real = null; if (typeof modalId === 'number') { real = this._localToReal.has(modalId) ? this._localToReal.get(modalId) : null; } else if (modalId != null) { real = modalId; // уже реальный id (строка/число от runtime) } _send('modal.close', { modalId: real }); }, update(modalId, patch) { let real = null; if (typeof modalId === 'number') { real = this._localToReal.has(modalId) ? this._localToReal.get(modalId) : null; } else if (modalId != null) { real = modalId; } _send('modal.update', { modalId: real, patch: patch || {} }); }, isOpen() { return !!this._isOpenLocal; }, onClose(fn) { if (typeof fn === 'function') this._onCloseFns.push(fn); }, // === Пресеты === /** Intro босса с именем + HP-баром + кнопкой «В бой!» через delay секунд. */ bossIntro(name, hp, refs, opts) { opts = opts || {}; const startBtnDelay = Number.isFinite(opts.startBtnDelay) ? opts.startBtnDelay : 2; const buttonText = opts.buttonText || 'В бой!'; const onStart = opts.onStart; const elements = [ { kind: 'text', id: '_boss_name', x: 50, y: 22, w: 60, h: 8, anchor: 'center', text: String(name || 'Босс'), textSize: 48, fontWeight: 900, textColor: '#ffffff', textStroke: { color: '#000', width: 3 }, bgColor: 'transparent', bgOpacity: 0, animationPreset: 'glow' }, { kind: 'text', id: '_boss_hp', x: 50, y: 30, w: 30, h: 6, anchor: 'center', text: '❤ ' + hp + ' / ' + hp, textSize: 28, fontWeight: 800, textColor: '#22ff66', textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0 }, ]; const m = this.open({ darken: 0.7, target: 'scene', blockInput: true, freezeCamera: true, spotlights: Array.isArray(refs) ? refs : (refs ? [refs] : []), cameraOverride: refs ? { target: Array.isArray(refs) ? refs[0] : refs, distance: 8, height: 3, fov: 60, duration: 0.5 } : null, content: { elements }, }); const _modal = this; const _afterTid = ++_timerSeq; _timers.push({ id: _afterTid, fn: () => { _send('gui.create', { type: 'button', opts: { id: '_boss_start', x: 50, y: 78, w: 20, h: 7, anchor: 'center', text: buttonText, bgGradient: { stops: ['#ff5454','#a00000'], angle: 90 }, borderColor: '#000', borderWidth: 3, borderRadius: 14, textColor: '#fff', textSize: 22, fontWeight: 900, textStroke: { color: '#000', width: 2 }, hover: { scale: 1.08, brightness: 1.2, duration: 0.15 }, active: { scale: 0.94, duration: 0.08 }, animationPreset: 'pulse', }, localRef: '_boss_start' }); let _started = false; _guiClickHandlers['_boss_start'] = [() => { if (_started) return; _started = true; delete _guiClickHandlers['_boss_start']; _modal.close(m); if (typeof onStart === 'function') { try { onStart(); } catch (e) {} } }]; }, delay: startBtnDelay, elapsed: 0, repeat: false }); return m; }, /** Открытие лутбокса. items: [{name, color, icon, rarity}]. onPick(item). */ lootbox(items, onPick) { items = Array.isArray(items) ? items.slice(0, 5) : []; const elements = [ { kind: 'frame', id: '_lb_bg', x: 50, y: 50, w: 70, h: 50, anchor: 'center', bgGradient: { stops: ['#1a1a3a','#0a0a1a'], angle: 135 }, borderColor: '#ffd700', borderWidth: 3, borderRadius: 18 }, { kind: 'text', id: '_lb_title', x: 50, y: 28, w: 60, h: 8, anchor: 'center', text: '✨ Лутбокс ✨', textSize: 32, fontWeight: 900, textColor: '#ffd700', textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0, animationPreset: 'glow' }, ]; for (let i = 0; i < items.length; i++) { const it = items[i]; const x = 50 + (i - (items.length - 1) / 2) * 13; elements.push({ kind: 'button', id: '_lb_item_' + i, x: x, y: 50, w: 11, h: 16, anchor: 'center', text: (it.icon || '*') + '\\n' + (it.name || 'Приз'), bgColor: it.color || '#3a3a5a', borderRadius: 12, borderColor: '#ffd700', borderWidth: 2, textColor: '#fff', textSize: 14, fontWeight: 700, hover: { scale: 1.1, brightness: 1.3, duration: 0.15 }, active: { scale: 0.94, duration: 0.08 }, animationPreset: 'pulse', }); } const m = this.open({ darken: 0.6, target: 'screen', blockInput: true, content: { elements }, }); const _modal = this; // _picked: после первого выбора остальные карточки не должны срабатывать, // пока модал доигрывает fadeOut (иначе onPick зовётся несколько раз). let _picked = false; for (let i = 0; i < items.length; i++) { const id = '_lb_item_' + i; const it = items[i]; _guiClickHandlers[id] = [() => { if (_picked) return; _picked = true; for (let j = 0; j < items.length; j++) delete _guiClickHandlers['_lb_item_' + j]; _modal.close(m); if (typeof onPick === 'function') { try { onPick(it); } catch (e) {} } }]; } return m; }, /** Диалог с NPC построчно. lines: ['Привет', 'Как дела?']. onDone() в конце. */ dialog(npcName, lines, onDone) { lines = Array.isArray(lines) ? lines : [String(lines || '')]; let idx = 0; const elements = [ { kind: 'frame', id: '_dlg_bg', x: 50, y: 80, w: 80, h: 25, anchor: 'center', bgGradient: { stops: ['#1a1a3a','#0a0a1a'], angle: 90 }, borderColor: '#fff', borderWidth: 2, borderRadius: 12 }, { kind: 'text', id: '_dlg_name', x: 14, y: 71, w: 22, h: 6, anchor: 'center', text: String(npcName || 'NPC'), textSize: 20, fontWeight: 900, textColor: '#ffd700', textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0 }, { kind: 'text', id: '_dlg_line', x: 50, y: 80, w: 76, h: 12, anchor: 'center', text: lines[0], textSize: 22, fontWeight: 600, textColor: '#fff', textAlign: 'left', bgColor: 'transparent', bgOpacity: 0 }, { kind: 'button', id: '_dlg_next', x: 88, y: 88, w: 8, h: 6, anchor: 'center', // На ПОСЛЕДНЕЙ строке кнопка сразу показывает галочку «завершить», // на остальных — стрелку «дальше». text: lines.length > 1 ? '▶' : '✓', textSize: 22, fontWeight: 900, bgColor: '#ffd700', textColor: '#000', borderRadius: 8, borderColor: '#000', borderWidth: 2, hover: { scale: 1.1, brightness: 1.2 }, active: { scale: 0.92 }, animationPreset: 'pulse' }, ]; const m = this.open({ darken: 0.5, target: 'scene', blockInput: true, freezeCamera: true, content: { elements }, }); const _modal = this; // _done защищает от повторного срабатывания: game.modal.close() доигрывает // fadeOut асинхронно, кнопка остаётся кликабельной — без guard'а каждый // лишний клик снова звал onDone (баг «Диалог завершён ×7»). let _done = false; _guiClickHandlers['_dlg_next'] = [() => { if (_done) return; idx++; if (idx < lines.length) { _send('gui.update', { id: '_dlg_line', patch: { text: lines[idx] } }); // Последняя строка достигнута — превращаем «дальше» в «завершить». if (idx === lines.length - 1) { _send('gui.update', { id: '_dlg_next', patch: { text: '✓' } }); } } else { _done = true; delete _guiClickHandlers['_dlg_next']; // снимаем handler сразу _modal.close(m); if (typeof onDone === 'function') { try { onDone(); } catch (e) {} } } }]; return m; }, /** Подтверждение Да/Нет. */ confirmation(title, body, onYes, onNo) { const elements = [ { kind: 'frame', id: '_cf_bg', x: 50, y: 50, w: 50, h: 30, anchor: 'center', bgGradient: { stops: ['#2a2a4a','#0a0a1a'], angle: 135 }, borderColor: '#fff', borderWidth: 2, borderRadius: 14 }, { kind: 'text', id: '_cf_title', x: 50, y: 41, w: 44, h: 6, anchor: 'center', text: String(title || 'Подтверждение'), textSize: 28, fontWeight: 900, textColor: '#fff', textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0 }, { kind: 'text', id: '_cf_body', x: 50, y: 50, w: 44, h: 8, anchor: 'center', text: String(body || ''), textSize: 16, fontWeight: 500, textColor: '#cfd0e8', bgColor: 'transparent', bgOpacity: 0 }, { kind: 'button', id: '_cf_yes', x: 38, y: 60, w: 14, h: 6, anchor: 'center', text: 'Да', bgGradient: { stops: ['#22ff66','#0a803a'], angle: 90 }, borderColor: '#000', borderWidth: 2, borderRadius: 10, textColor: '#fff', textSize: 18, fontWeight: 900, hover: { scale: 1.08, brightness: 1.2 }, active: { scale: 0.94 } }, { kind: 'button', id: '_cf_no', x: 62, y: 60, w: 14, h: 6, anchor: 'center', text: 'Нет', bgGradient: { stops: ['#ff5454','#a00000'], angle: 90 }, borderColor: '#000', borderWidth: 2, borderRadius: 10, textColor: '#fff', textSize: 18, fontWeight: 900, hover: { scale: 1.08, brightness: 1.2 }, active: { scale: 0.94 } }, ]; const m = this.open({ darken: 0.6, target: 'screen', blockInput: true, content: { elements }, }); const _modal = this; // _answered: одна кнопка снимает обе (Да/Нет) сразу, чтобы пока модал // доигрывает fadeOut нельзя было нажать вторую и продублировать ответ. let _answered = false; const _finish = (cb) => { if (_answered) return; _answered = true; delete _guiClickHandlers['_cf_yes']; delete _guiClickHandlers['_cf_no']; _modal.close(m); if (typeof cb === 'function') { try { cb(); } catch (e) {} } }; _guiClickHandlers['_cf_yes'] = [() => _finish(onYes)]; _guiClickHandlers['_cf_no'] = [() => _finish(onNo)]; return m; }, }, /** * Инвентарь игрока (Фаза 4.2) — 5 слотов hot-bar. * game.inventory.add({ name: 'Зелье', kind: 'item' }) * game.inventory.has('Зелье') — по имени или modelTypeId * game.inventory.remove('Зелье') * game.inventory.list() — массив предметов * game.inventory.clear() */ inventory: { /** Добавить предмет. item: { name, kind?, modelTypeId?, params? }. */ add(item) { if (!item || typeof item !== 'object') return; _send('inventory.give', { kind: item.kind || 'item', modelTypeId: item.modelTypeId || null, name: item.name || 'Предмет', params: item.params || {}, }); }, /** Убрать первый предмет по имени или modelTypeId. */ remove(nameOrModel) { if (typeof nameOrModel !== 'string') return; _send('inventory.remove', { name: nameOrModel, modelTypeId: nameOrModel }); }, /** True если предмет с таким именем/modelTypeId есть в инвентаре. */ has(nameOrModel) { if (typeof nameOrModel !== 'string') return false; return (_inventory.slots || []).some(s => s && (s.name === nameOrModel || s.modelTypeId === nameOrModel)); }, /** Массив всех предметов инвентаря (без пустых слотов). */ list() { return (_inventory.slots || []).filter(Boolean).map(s => ({ ...s })); }, /** Активный предмет (выбранный слот hot-bar) или null. */ active() { const s = (_inventory.slots || [])[_inventory.activeIndex]; return s ? { ...s } : null; }, /** Очистить весь инвентарь. */ clear() { _send('inventory.clear', {}); }, }, /** * Игроки комнаты (Фаза 4.3 — мультиплеер). * В одиночной игре (редактор) — только локальный игрок. * game.players.me() — я { sessionId, name, position, hp, ... } * game.players.all() — массив всех игроков (включая меня) * game.players.count() — сколько игроков */ players: { /** Локальный игрок. */ me() { return _players.me ? { ..._players.me } : null; }, /** Все игроки комнаты (включая меня). */ all() { return (_players.list || []).map(p => ({ ...p })); }, /** Сколько игроков в комнате. */ count() { return (_players.list || []).length; }, /** Найти игрока по sessionId или null. */ get(sessionId) { const p = (_players.list || []).find(x => x.sessionId === sessionId); return p ? { ...p } : null; }, }, /** * Общее состояние комнаты (Фаза 4.3) — данные, видимые всем игрокам. * В одиночной игре работает как локальное хранилище. * game.room.set('счёт', 10) * game.room.get('счёт') → 10 * game.room.onChange('счёт', (v) => game.ui.showText('Счёт: ' + v)) */ room: { /** Установить значение в общем состоянии комнаты. */ set(key, value) { if (typeof key !== 'string' || !key) return; // Оптимистично обновляем локальное зеркало. _roomState[key] = value; _send('room.set', { key, value }); }, /** Прочитать значение из общего состояния комнаты. */ get(key) { if (typeof key !== 'string') return undefined; return _roomState[key]; }, /** Подписаться на изменение ключа общего состояния. fn(value). */ onChange(key, fn) { if (typeof key !== 'string' || typeof fn !== 'function') return; (_roomChangeHandlers[key] = _roomChangeHandlers[key] || []).push(fn); }, }, /** * Команды (Фаза 4.4) — для командных игр. * game.teams.create('Красные', '#ff3333') * game.teams.create('Синие', '#3366ff') * game.player.setTeam('Красные') * game.player.team → 'Красные' */ teams: { /** Создать команду с именем и цветом (#hex). */ create(name, color) { if (typeof name !== 'string' || !name) return; _send('teams.create', { name, color: typeof color === 'string' ? color : '#888888', }); }, /** Удалить команду. */ remove(name) { if (typeof name !== 'string') return; _send('teams.remove', { name }); }, /** Список всех команд — массив { name, color }. */ all() { return _teams.map(t => ({ ...t })); }, /** Найти команду по имени или null. */ get(name) { const t = _teams.find(x => x.name === name); return t ? { ...t } : null; }, }, /** * Связи между объектами (Фаза 5, Constraints). * weld — склейка: объект B движется вместе с A. * hinge — петля: вращение вокруг оси (двери, рычаги). * spring — пружина: упругое колебание (батуты). * Каждый вызов возвращает объект-связь с методами управления. */ constraints: { /** * Жёстко склеить объект B с A — B следует за A. * game.constraints.weld(platformRef, crateRef); */ weld(refA, refB) { if (typeof refA !== 'string' || typeof refB !== 'string') return null; _constraintRefSeq++; const localRef = 'constraint:_local_' + _constraintRefSeq; _send('constraint.create', { kind: 'weld', localRef, refA, refB }); return { get ref() { return localRef; }, remove() { _send('constraint.remove', { ref: localRef }); }, }; }, /** * Петля: объект вращается вокруг вертикальной оси через pivot. * opts: { pivotX, pivotZ — точка оси, angle — стартовый угол (°) }. * Метод setAngle(°) поворачивает объект — для дверей/рычагов. * const door = game.constraints.hinge(doorRef, { pivotX: 5, pivotZ: 0 }); * door.setAngle(90); // открыть дверь */ hinge(ref, opts) { if (typeof ref !== 'string') return null; opts = opts || {}; _constraintRefSeq++; const localRef = 'constraint:_local_' + _constraintRefSeq; _send('constraint.create', { kind: 'hinge', localRef, ref, pivotX: Number.isFinite(Number(opts.pivotX)) ? Number(opts.pivotX) : undefined, pivotZ: Number.isFinite(Number(opts.pivotZ)) ? Number(opts.pivotZ) : undefined, angle: Number(opts.angle) || 0, }); return { get ref() { return localRef; }, /** Повернуть к углу (градусы) — объект плавно довернётся. */ setAngle(deg) { _send('constraint.hingeAngle', { ref: localRef, deg: Number(deg) || 0 }); }, remove() { _send('constraint.remove', { ref: localRef }); }, }; }, /** * Пружина: объект упруго держится в точке покоя (текущая позиция). * opts: { stiffness — жёсткость, damping — затухание }. * Метод push(vx,vy,vz) толкает объект — запускает колебание. * const trampoline = game.constraints.spring(padRef); * trampoline.push(0, 12, 0); // подбросить вверх */ spring(ref, opts) { if (typeof ref !== 'string') return null; opts = opts || {}; _constraintRefSeq++; const localRef = 'constraint:_local_' + _constraintRefSeq; _send('constraint.create', { kind: 'spring', localRef, ref, stiffness: Number.isFinite(Number(opts.stiffness)) ? Number(opts.stiffness) : undefined, damping: Number.isFinite(Number(opts.damping)) ? Number(opts.damping) : undefined, }); return { get ref() { return localRef; }, /** Толкнуть объект (скорость по осям) — запускает колебание. */ push(vx, vy, vz) { _send('constraint.springPush', { ref: localRef, vx: Number(vx) || 0, vy: Number(vy) || 0, vz: Number(vz) || 0, }); }, remove() { _send('constraint.remove', { ref: localRef }); }, }; }, }, /** * Эффекты-объекты сцены (Фаза 5.2): лучи и следы. * beam — светящаяся линия между точками (лазеры, мосты, цепи). * trail — шлейф за движущимся объектом. */ fx: { /** * Луч между двумя точками. opts: { from, to — {x,y,z} или ref * объекта (тогда луч следит за ним); color: '#hex', width }. * game.fx.beam({ from: towerRef, to: {x:0,y:5,z:0}, color: '#ff3344' }); */ beam(opts) { opts = opts || {}; _fxRefSeq++; const localRef = 'fx:_local_' + _fxRefSeq; // Задача 08: расширенные опции (текстура/curved/градиент/billboard). _send('fx.create', { kind: 'beam', localRef, from: _normFxPoint(opts.from), to: _normFxPoint(opts.to), color: typeof opts.color === 'string' ? opts.color : undefined, width: Number.isFinite(Number(opts.width)) ? Number(opts.width) : undefined, texture: opts.texture, customTextureUrl: opts.customTextureUrl, textureMode: opts.textureMode, textureSpeed: opts.textureSpeed, textureScale: opts.textureScale, strokeColor: opts.strokeColor, strokeWidth: opts.strokeWidth, colorSequence: opts.colorSequence, transparencySequence: opts.transparencySequence, widthSequence: opts.widthSequence, faceMode: opts.faceMode, segments: opts.segments, curved: opts.curved, curveHeight: opts.curveHeight, attachOffset: opts.attachOffset, ignoreDepth: opts.ignoreDepth, }); return { get ref() { return localRef; }, /** Сменить цвет луча. */ setColor(color) { _send('fx.beamColor', { ref: localRef, color }); }, /** Сменить концы луча ({x,y,z} или ref). */ setEndpoints(from, to) { _send('fx.beamEndpoints', { ref: localRef, from, to }); }, /** Изменить любые опции луча на лету. */ update(o) { _send('fx.beamUpdate', { ref: localRef, opts: o || {} }); }, hide() { _send('fx.beamVisible', { ref: localRef, visible: false }); }, show() { _send('fx.beamVisible', { ref: localRef, visible: true }); }, remove() { _send('fx.remove', { ref: localRef }); }, }; }, /** * Стрелка-указатель «иди сюда» (бегущие шевроны + парящий quest-marker * над целью). Задача 08. * const arrow = game.fx.pointer({ from: 'player', to: cubeRef, preset: 'guide' }); * arrow.setTarget(otherRef); arrow.update({ preset: 'quest' }); arrow.remove(); * preset: 'guide'|'quest'|'danger'|'gift'|'custom'. * from/to: 'player' | ref-объекта | {x,y,z}. */ pointer(opts) { opts = opts || {}; _fxRefSeq++; const localRef = 'fx:_local_' + _fxRefSeq; _send('fx.createPointer', { localRef, from: _normFxPoint(opts.from !== undefined ? opts.from : 'player'), to: _normFxPoint(opts.to), preset: opts.preset || 'guide', color: opts.color, texture: opts.texture, customTextureUrl: opts.customTextureUrl, textureSpeed: opts.textureSpeed, width: opts.width, strokeColor: opts.strokeColor, colorSequence: opts.colorSequence, curved: opts.curved, curveHeight: opts.curveHeight, faceMode: opts.faceMode, attachOffset: opts.attachOffset, }); return { get ref() { return localRef; }, setTarget(to) { _send('fx.pointerTarget', { ref: localRef, to: _normFxPoint(to) }); }, update(o) { _send('fx.pointerUpdate', { ref: localRef, opts: o || {} }); }, hide() { _send('fx.beamVisible', { ref: localRef, visible: false }); }, show() { _send('fx.beamVisible', { ref: localRef, visible: true }); }, remove() { _send('fx.remove', { ref: localRef }); }, }; }, /** * Шлейф за объектом. ref — ref-строка объекта. * opts: { color: '#hex', width, lifetime (сек) }. * game.fx.trail(ballRef, { color: '#ffcc44', lifetime: 2 }); */ trail(ref, opts) { if (typeof ref !== 'string') return null; opts = opts || {}; _fxRefSeq++; const localRef = 'fx:_local_' + _fxRefSeq; _send('fx.create', { kind: 'trail', localRef, ref, color: typeof opts.color === 'string' ? opts.color : undefined, width: Number.isFinite(Number(opts.width)) ? Number(opts.width) : undefined, lifetime: Number.isFinite(Number(opts.lifetime)) ? Number(opts.lifetime) : undefined, }); return { get ref() { return localRef; }, remove() { _send('fx.remove', { ref: localRef }); }, }; }, }, /** * Звуки. Два вида: * 1. Встроенные пресеты: 'jump', 'pickup', 'win', 'lose', 'click', * 'hit', 'coin' — game.sound.play('jump'). * 2. Свои загруженные (Фаза 5.5) — id вида 'sound_N', можно 3D: * game.sound.play('sound_1', { at: {x,y,z} }) — 3D в точке * game.sound.play('sound_1', { attach: doorRef }) — 3D у объекта * game.sound.play('sound_1', { loop: true }) — зациклить */ sound: { /** * Проиграть звук. id — пресет ('jump'...) или 'sound_N'. * opts: { volume: 0..1, loop, at: {x,y,z}, attach: ref-строка }. * Для пользовательского звука возвращает объект с методом stop(). */ play(id, opts) { if (typeof id !== 'string' || !id) return null; opts = opts || {}; // Встроенный пресет — старый формат {name}. if (id.indexOf('sound_') !== 0) { _send('sound.play', { name: id, volume: Number.isFinite(Number(opts.volume)) ? Number(opts.volume) : 1, pitch: Number.isFinite(Number(opts.pitch)) ? Number(opts.pitch) : 1, }); return null; } // Пользовательский звук из библиотеки проекта. _soundRefSeq++; const localRef = 'sound:_local_' + _soundRefSeq; _send('sound.play', { soundId: id, localRef, volume: Number.isFinite(Number(opts.volume)) ? Number(opts.volume) : 1, loop: !!opts.loop, at: (opts.at && Number.isFinite(Number(opts.at.x))) ? { x: Number(opts.at.x), y: Number(opts.at.y) || 0, z: Number(opts.at.z) || 0 } : undefined, attachRef: typeof opts.attach === 'string' ? opts.attach : undefined, }); return { get ref() { return localRef; }, /** Остановить этот звук. */ stop() { _send('sound.stop', { ref: localRef }); }, }; }, }, /** * Аудио — GD-музыка и SFX. * game.audio.playSfx('jump') — короткий звук (jump/death/orb_tap/...) * game.audio.playMusic('epoch_01_main') — фоновая музыка (зацикленная) * game.audio.stopMusic() * game.audio.setMuted(true) */ audio: { playSfx(name) { if (typeof name !== 'string') return; _send('audio.playSfx', { name }); }, playMusic(trackId) { if (typeof trackId !== 'string') return; _send('audio.playMusic', { trackId }); }, stopMusic() { _send('audio.stopMusic', {}); }, setMuted(muted) { _send('audio.setMuted', { muted: !!muted }); }, }, /** * Экономика — алмазы и рейтинг через серверные API. * Все вызовы асинхронные (с callback), потому что идут через HTTP. * * game.economy.reward('level_1_first_pass', function(res) { * // res = { ok, already_awarded, diamonds, rating, ... } * }); * game.economy.dailyCheck(function(res) { ... }); * game.economy.getBalance(function(res) { * // res = { diamonds, rating } * }); */ economy: { reward(achievementId, fn) { if (typeof achievementId !== 'string') return; const reqId = 'eco_rwd_' + (++_economyReqSeq); if (typeof fn === 'function') _economyCallbacks[reqId] = fn; _send('economy.reward', { reqId, achievementId }); }, dailyCheck(fn) { const reqId = 'eco_daily_' + (++_economyReqSeq); if (typeof fn === 'function') _economyCallbacks[reqId] = fn; _send('economy.dailyCheck', { reqId }); }, getBalance(fn) { const reqId = 'eco_bal_' + (++_economyReqSeq); if (typeof fn === 'function') _economyCallbacks[reqId] = fn; _send('economy.getBalance', { reqId }); }, spend(amount, reason, fn) { const reqId = 'eco_spend_' + (++_economyReqSeq); if (typeof fn === 'function') _economyCallbacks[reqId] = fn; _send('economy.spend', { reqId, amount: Number(amount) || 0, reason: String(reason || '') }); }, }, /** * Billboard — 3D-таблички с GUI (как BillboardGui в Roblox). * Создаются через game.scene.spawn('billboard', {x,y,z, template, content}), * затем настраиваются через game.billboard.set/update. * * Пресеты (template): * - 'shop-item' — иконка + заголовок + sub-строка + кнопка цены * - 'shop-purchase' — иконка + название + цена (для покупки) * - 'banner' — крупный текст * - 'sign' — простой указатель * * Пример (4 таблички-апгрейды): * const refs = ['vis','range','saws','sprink'].map((kind, i) => { * return game.scene.spawn('billboard', { * x: -6 + i*4, y: 3, z: 5, * template: 'shop-item', * content: { icon: 'hammer', title: 'Апгрейд', sub: '1 > 2', * price: '$10,000', gradient: ['#ff5a5a','#ff8a3d'] }, * }); * }); * game.billboard.onClick(refs[0], 'buy', () => { * game.ui.showText('Куплено!'); * game.billboard.update(refs[0], { sub: '2 > 3', price: '$20,000' }); * }); */ billboard: { /** * Полная замена контента таблички. Если пресет тот же — мгновенно * перерисует. Если template другой — пересоздаст текстуру. * ref — string-ref из game.scene.spawn() или game.scene.findOne() * opts — { template?, face?, content?, elements? } */ set(ref, opts) { const refStr = _normRef(ref); if (!refStr || typeof opts !== 'object' || opts == null) return; _send('billboard.set', { ref: refStr, ...opts }); }, /** * Частичное обновление таблички. * Две формы: * 1) update(ref, patch) * patch — частичный content: { sub, price, title, icon, gradient } * Применяется к content пресета (shop-item/banner/sign). * 2) update(ref, elementId, patch) * Обновляет конкретный элемент по id (только для template:'card' * или elements-режима). Например: update(ref, 'buy', { text: '$15,000' }). * Для пресетов: 'title'|'sub'|'price'|'icon'|'gradient' тоже * работают как ключи content. */ update(ref, secondArg, thirdArg) { const refStr = _normRef(ref); if (!refStr) return; // 3-аргументная форма: update(ref, elementId, patch) if (typeof secondArg === 'string' && typeof thirdArg === 'object' && thirdArg !== null) { _send('billboard.update', { ref: refStr, elementId: secondArg, patch: thirdArg, }); return; } // 2-аргументная форма: update(ref, patch) if (typeof secondArg === 'object' && secondArg !== null) { _send('billboard.update', { ref: refStr, patch: secondArg }); } }, /** * Подписаться на клик по кнопке таблички (shop-item: buttonId='buy'; * в кастомных elements — id из элемента kind='button'). * ref — string-ref * buttonId — id кнопки (по умолчанию 'buy') * fn — () => void */ onClick(ref, buttonId, fn) { if (typeof fn !== 'function') { fn = buttonId; buttonId = 'buy'; } // Принудительная нормализация ref в plain-string: Instance-Proxy // не сериализуется через postMessage (DataCloneError). const refStr = _normRef(ref); if (!refStr || typeof fn !== 'function') return; const bid = String(buttonId || 'buy'); const key = refStr + ':' + bid; if (!_billboardClickHandlers[key]) _billboardClickHandlers[key] = []; _billboardClickHandlers[key].push(fn); _send('billboard.onClick', { ref: refStr, buttonId: bid }); }, }, /** Окружение: небо, туман, время суток. */ environment: { /** Установить цвет неба (clearColor сцены). hex или 'r,g,b'. */ setSkyColor(color) { if (typeof color !== 'string') return; _send('environment.setSkyColor', { color }); }, /** Установить туман: {enabled, color, density}. */ setFog(opts) { if (typeof opts !== 'object' || !opts) return; _send('environment.setFog', opts); }, /** Установить время суток (часы, 0..24). */ setTimeOfDay(hours) { const h = Number(hours); if (!Number.isFinite(h)) return; _send('environment.setTimeOfDay', { hours: h }); }, }, /** * Управление режимами ввода — курсор и камера. * В режиме 'ui' мышь работает как обычный курсор (как в браузере), * не вращает камеру. Нужно для меню/инвентарей. */ input: { /** * Установить cursor-режим: 'ui' = курсор-как-в-браузере, * 'game' = pointer-lock (мышь крутит камеру). */ setCursorMode(mode) { if (mode !== 'ui' && mode !== 'game') return; _send('input.setCursorMode', { mode }); }, /** * Подписаться на движение мыши в UI-режиме. * fn(x, y) — нормализованные координаты [0..1] относительно канваса. */ onMouseMove(fn) { if (typeof fn !== 'function') return; _mouseMoveHandlers.push(fn); }, /** Зажатие ЛКМ в UI-режиме. fn(x, y). */ onMouseDown(fn) { if (typeof fn !== 'function') return; _mouseDownHandlers.push(fn); }, /** Отпускание ЛКМ. fn(x, y). */ onMouseUp(fn) { if (typeof fn !== 'function') return; _mouseUpHandlers.push(fn); }, }, /** * Управление приложением целиком: переходы между страницами и т.д. */ app: { /** Выйти из проекта на страницу ленты Kubikon-игр. */ exit() { _send('app.exit', {}); }, /** Перейти на произвольный URL (внутри сайта). */ navigate(url) { if (typeof url !== 'string' || !url) return; _send('app.navigate', { url }); }, }, /** * УНИВЕРСАЛЬНОЕ хранилище сохранений (game saves). * Любая игра может хранить произвольный JSON-стейт игрока. Под каждую * игру таблицу создавать не нужно — всё через эти эндпоинты. * * namespace — строка типа 'progress', 'stats', 'inventory'. Под каждый * одна запись на (project, user). Макс 20 namespace. * data — произвольный объект JSON, до 50KB. * * Примеры: * game.save.set('progress', { level: 3, gold: 250 }); * game.save.get('progress', fn(data) {...}); * game.save.merge('progress', { increment: { attempts: 1 } }); * game.save.leaderboard('progress', 'gold', 'desc', fn(entries) {...}); */ save: { /** Прочитать namespace. fn(data) — data это сохранённый объект или null. */ get(namespace, fn) { if (typeof namespace !== 'string' || !namespace) return; const reqId = 'sg_get_' + (++_saveReqSeq); if (typeof fn === 'function') _saveCallbacks[reqId] = fn; _send('save.get', { reqId, namespace }); }, /** Прочитать ВСЕ сохранения юзера. fn(allNamespaces) — { ns1: data, ns2: data }. */ getAll(fn) { const reqId = 'sg_all_' + (++_saveReqSeq); if (typeof fn === 'function') _saveCallbacks[reqId] = fn; _send('save.getAll', { reqId }); }, /** Записать (полная замена). data — объект/массив. */ set(namespace, data) { if (typeof namespace !== 'string' || !namespace) return; if (data === undefined || data === null) return; _send('save.set', { namespace, data }); }, /** Слияние с существующим. opts: * { patch: {...}, increment: { key: delta }, max: { key: value } } * patch — ключи копируются поверх * increment — атомарный +=delta (нужно для счётчиков, не теряются * данные если игрок играет с двух устройств) * max — новое значение пишется только если оно больше старого */ merge(namespace, opts) { if (typeof namespace !== 'string' || !namespace) return; if (!opts || typeof opts !== 'object') return; _send('save.merge', { namespace, patch: opts.patch || {}, increment: opts.increment || {}, max: opts.max || {}, }); }, /** Шорткат: атомарный +1 к счётчику. */ increment(namespace, key, delta) { if (typeof namespace !== 'string' || typeof key !== 'string') return; const d = Number(delta); const inc = {}; inc[key] = Number.isFinite(d) ? d : 1; _send('save.merge', { namespace, patch: {}, increment: inc, max: {} }); }, /** Лидерборд по ключу. order='asc' (меньше=лучше) | 'desc' (больше=лучше). * fn(entries) — массив { rank, user_id, username, value }. */ leaderboard(namespace, key, order, fn) { if (typeof namespace !== 'string' || typeof key !== 'string') return; const reqId = 'sg_lb_' + (++_saveReqSeq); if (typeof fn === 'function') _saveCallbacks[reqId] = fn; _send('save.leaderboard', { reqId, namespace, key, order: order === 'asc' ? 'asc' : 'desc', }); }, }, log(...args) { const parts = args.map(a => { if (typeof a === 'string') return a; if (typeof a === 'number' || typeof a === 'boolean') return String(a); if (a == null) return String(a); try { return JSON.stringify(a); } catch (e) { return '[object]'; } }); _send('log', { level: 'info', text: parts.join(' ') }); }, /** * Случайное число. * random() → 0..1 * random(max) → 0..max * random(min, max) → min..max * random(min, max, true) → целое min..max включительно */ random(min, max, integer) { if (min === undefined) return Math.random(); if (max === undefined) { max = min; min = 0; } const a = Number(min), b = Number(max); if (!Number.isFinite(a) || !Number.isFinite(b)) return 0; if (integer) { const lo = Math.ceil(Math.min(a, b)); const hi = Math.floor(Math.max(a, b)); return Math.floor(Math.random() * (hi - lo + 1)) + lo; } return a + Math.random() * (b - a); }, // Форматирование чисел/времени/денег для UI. Портировано из студии // (задача 11 — игра «Мой завод» использует game.format.money). format: { time(seconds, fmt) { let sec = Math.max(0, Math.floor(Number(seconds) || 0)); const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; const p2 = (n) => String(n).padStart(2, '0'); if (fmt === 'hh:mm:ss') return p2(h) + ':' + p2(m) + ':' + p2(s); if (fmt === 'mm:ss') { const tm = Math.floor(sec / 60); return p2(tm) + ':' + p2(s); } // auto if (h > 0) return h + 'ч ' + m + 'м'; if (m > 0) return m + 'м ' + s + 'с'; return s + 'с'; }, number(n, fmt) { n = Number(n) || 0; if (fmt === 'percent') return Math.round(n * 100) + '%'; if (fmt === 'short') { const abs = Math.abs(n); if (abs >= 1e9) return (n / 1e9).toFixed(1).replace('.0', '') + 'B'; if (abs >= 1e6) return (n / 1e6).toFixed(1).replace('.0', '') + 'M'; if (abs >= 1e3) return (n / 1e3).toFixed(1).replace('.0', '') + 'K'; return String(Math.round(n)); } // comma — пробелы-разделители тысяч (русский стиль), без regex. const str = String(Math.abs(Math.round(n))); let out = ''; for (let i = 0; i < str.length; i++) { if (i > 0 && (str.length - i) % 3 === 0) out += ' '; out += str[i]; } return (n < 0 ? '-' : '') + out; }, money(amount, unit) { const num = this.number(amount, 'comma'); const u = (unit === 'rubles' || unit === undefined) ? this._plural(Math.round(Number(amount) || 0), 'рублик', 'рублика', 'рубликов') : unit; return num + ' ' + u; }, duration(seconds) { let sec = Math.max(0, Math.floor(Number(seconds) || 0)); const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); if (h > 0) return h + ' ' + this._plural(h, 'час', 'часа', 'часов'); if (m > 0) return m + ' ' + this._plural(m, 'минута', 'минуты', 'минут'); return sec + ' ' + this._plural(sec, 'секунда', 'секунды', 'секунд'); }, // Русское склонение числительных (1 рублик / 2 рублика / 5 рубликов). _plural(n, one, few, many) { n = Math.abs(n) % 100; const n1 = n % 10; if (n > 10 && n < 20) return many; if (n1 > 1 && n1 < 5) return few; if (n1 === 1) return one; return many; }, }, /** * Расстояние между двумя точками или объектами. * Аргументы могут быть {x,y,z} или ref-строкой (для ref берём позицию из sceneIndex). */ distance(a, b) { const pa = _resolveToPos(a); const pb = _resolveToPos(b); if (!pa || !pb) return Infinity; const dx = pa.x - pb.x, dy = pa.y - pb.y, dz = pa.z - pb.z; return Math.sqrt(dx * dx + dy * dy + dz * dz); }, /** * Отправить именованное сообщение всем скриптам (включая себя). * Используется для общения между скриптами в разных sandbox'ах. * game.broadcast('checkpoint', { num: 2 }); */ broadcast(name, data) { if (typeof name !== 'string' || !name) return; _send('broadcast', { name, data: data == null ? null : data }); }, /** * Подключить скрипт-модуль и получить его exports. * Модуль — другой скрипт проекта; в нём пишут в объект exports: * // скрипт "math_utils": * exports.add = (a, b) => a + b; * // обычный скрипт: * const m = game.require('math_utils'); * game.log(m.add(2, 3)); // 5 * Модуль исполняется один раз, дальше отдаётся из кеша. */ require(name) { if (typeof name !== 'string' || !name) return null; if (_moduleCache[name]) return _moduleCache[name]; const code = _moduleCode[name]; if (code == null) { _send('log', { level: 'error', text: 'game.require: модуль не найден — ' + name }); return null; } try { const exportsObj = {}; // модуль видит game и exports; повторный require внутри модуля тоже работает const moduleFn = new Function('game', 'exports', '"use strict";\\n' + code); moduleFn(game, exportsObj); _moduleCache[name] = exportsObj; return exportsObj; } catch (err) { _send('log', { level: 'error', text: 'Ошибка в модуле "' + name + '": ' + (err && err.message ? err.message : err), }); return null; } }, /** * Подписаться на сообщение. * game.onMessage('checkpoint', (data) => { ... }); */ onMessage(name, fn) { if (typeof name !== 'string' || !name) return; if (typeof fn !== 'function') return; (_messageHandlers[name] = _messageHandlers[name] || []).push(fn); }, /** Зажать значение между min и max. */ clamp(value, min, max) { const v = Number(value); const lo = Number(min), hi = Number(max); if (!Number.isFinite(v)) return 0; if (v < lo) return lo; if (v > hi) return hi; return v; }, /** Линейная интерполяция: lerp(a, b, 0)=a, lerp(a, b, 1)=b. */ lerp(a, b, t) { const na = Number(a), nb = Number(b), nt = Number(t); return na + (nb - na) * nt; }, /** * game.placement — drag-and-drop размещение объектов (задача 11). * Фундамент tycoon/farm/simulator: «кликнул предмет → preview за курсором * → ЛКМ ставит». См. 11_placement_mode.md. * * game.placement.start('crate', { * previewType: 'model:crate', surfaceMode: 'ground', grid: 1, * cost: 50, currency: 'rubles', targetZone: game.scene.findOne('plot'), * showArrowFrom: 'player', showZoneOutline: true, chainPlace: true, * }); * game.placement.onPlace(({ itemKey, position, rotationY }) => { ... }); */ placement: { /** Войти в режим расстановки. opts — см. 11_placement_mode.md §2.1. */ start(itemKey, opts) { if (typeof itemKey !== 'string' || !itemKey) return null; const o = opts && typeof opts === 'object' ? opts : {}; // targetZone может прийти как ref-объект findOne — нормализуем в строку. const out = { itemKey, opts: { ...o } }; if (o.targetZone) out.opts.targetZone = _normRef(o.targetZone) || o.targetZone; _send('placement.start', out); return itemKey; }, /** Отменить активный режим (как ПКМ/Esc). */ cancel() { _send('placement.cancel', {}); }, /** Поставить на текущей позиции (как ЛКМ). */ confirm() { _send('placement.confirm', {}); }, /** Повернуть preview на N градусов (по умолчанию rotationStep). */ rotate(deg) { _send('placement.rotate', { deg: Number(deg) || undefined }); }, /** fn({ itemKey, position:{x,y,z}, rotationY }) — объект размещён. */ onPlace(fn) { if (typeof fn === 'function') _placeOnPlaceHandlers.push(fn); }, /** fn() — режим отменён игроком. */ onCancel(fn) { if (typeof fn === 'function') _placeOnCancelHandlers.push(fn); }, /** fn({ position:{x,y,z}, valid }) — каждый кадр, движение preview. */ onMove(fn) { if (typeof fn === 'function') _placeOnMoveHandlers.push(fn); }, }, /** * game.inventoryUi — готовый GUI-кит «слот-инвентарь магазина» (задача 11). * Нижняя/боковая панель кнопок-слотов с иконкой/ценой/hover. Клик по слоту * → onSlotClick(item) (обычно автор зовёт game.placement.start внутри). * Слот серый и некликабельный, если валюты недостаточно (showCurrency + getBalance). * * game.inventoryUi.create({ * items: [{ key:'crate', name:'Базовый ящик', icon:'crate', cost:50, modelType:'model:crate' }], * position: 'bottom', showCost: true, showCurrency: 'rubles', * onSlotClick: (item) => game.placement.start(item.key, {...}), * }); */ inventoryUi: { /** Создать панель слотов. См. 11_placement_mode.md §2.7. */ create(opts) { const o = opts && typeof opts === 'object' ? opts : {}; const items = Array.isArray(o.items) ? o.items : []; if (typeof o.onSlotClick === 'function') { // Регистрируем колбэк под индексом — движок пришлёт invUiSlotClick {key}. _invUiSlotClickHandlers.push(o.onSlotClick); } _send('inventoryUi.create', { items: items.map(it => ({ key: String(it.key || ''), name: String(it.name || ''), icon: it.icon || '', cost: Number(it.cost) || 0, modelType: it.modelType || '', })), position: o.position || 'bottom', slotSize: Number(o.slotSize) || 80, spacing: Number(o.spacing) || 4, showCost: o.showCost !== false, showCurrency: o.showCurrency || '', }); }, /** Обновить баланс валюты (для авто-серых слотов). */ setBalance(currency, amount) { _send('inventoryUi.setBalance', { currency: String(currency || ''), amount: Number(amount) || 0 }); }, /** Скрыть/удалить панель. */ remove() { _send('inventoryUi.remove', {}); }, }, }; /** * Пересечение луча с AABB (в локальных координатах бокса, центр в 0). * Возвращает расстояние t до точки входа или null если не пересекает. * Slab-метод. (ox,oy,oz)=начало луча, (dx,dy,dz)=направление (нормализ.), * (hw,hh,hd)=полуразмеры бокса. */ function _rayAabb(ox, oy, oz, dx, dy, dz, hw, hh, hd) { let tmin = -Infinity, tmax = Infinity; const axes = [ [ox, dx, hw], [oy, dy, hh], [oz, dz, hd], ]; for (const [o, d, h] of axes) { if (Math.abs(d) < 1e-9) { // луч параллелен слою — мимо если начало вне границ if (o < -h || o > h) return null; } else { let t1 = (-h - o) / d; let t2 = (h - o) / d; if (t1 > t2) { const tmp = t1; t1 = t2; t2 = tmp; } if (t1 > tmin) tmin = t1; if (t2 < tmax) tmax = t2; if (tmin > tmax) return null; } } // tmin<0 значит начало внутри бокса — берём 0 return tmin >= 0 ? tmin : (tmax >= 0 ? 0 : null); } /** Резолв позиции из {x,y,z} или ref-строки. */ function _resolveToPos(arg) { if (!arg) return null; if (typeof arg === 'string') { for (const b of _sceneIndex.blocks) if (b.ref === arg) return { x: b.x, y: b.y, z: b.z }; for (const m of _sceneIndex.models) if (m.ref === arg) return { x: m.x, y: m.y, z: m.z }; for (const p of _sceneIndex.primitives) if (p.ref === arg) return { x: p.x, y: p.y, z: p.z }; return null; } if (typeof arg === 'object' && Number.isFinite(arg.x)) { return { x: Number(arg.x) || 0, y: Number(arg.y) || 0, z: Number(arg.z) || 0 }; } return null; } // === Обработчики сообщений из main === self.onmessage = (e) => { const { cmd, payload } = e.data || {}; if (cmd === 'init') { // payload: { code, target?, selfPosition?, modules? } if (payload && payload.target) { _target = payload.target; if (payload.selfPosition) _selfPosition = payload.selfPosition; _selfApi = _buildSelfApi(); } // modules: { 'имя': 'код' } — все скрипты проекта, доступные через game.require if (payload && payload.modules && typeof payload.modules === 'object') { _moduleCode = payload.modules; } // Первичный snapshot сцены — заполняем _sceneIndex ДО исполнения кода, // чтобы findOne()/find() работали в синхронном теле скрипта на старте // (иначе obj.onTouch(...) не подписывался — объект ещё «не существовал»). if (payload && payload.initialScene && typeof payload.initialScene === 'object') { const s = payload.initialScene; _sceneIndex = { blocks: s.blocks || [], models: s.models || [], primitives: s.primitives || [], }; } try { // exports передаём всегда — скрипт может быть и модулем (пишет в // exports), и обычным скриптом (игнорирует его). Без этого // скрипт-модуль падает с 'exports is not defined' при прямом запуске. const exportsObj = {}; const userFn = new Function('game', 'exports', '"use strict";\\n' + payload.code); userFn(game, exportsObj); _send('ready', null); } catch (err) { _send('log', { level: 'error', text: 'Ошибка скрипта: ' + (err && err.message ? err.message : err) }); _send('ready', null); } } else if (cmd === 'tick') { const dt = payload && typeof payload.dt === 'number' ? payload.dt : 0; if (payload && payload.player) { const pp = payload.player; if (pp.position) _playerState.position = pp.position; if (typeof pp.yaw === 'number') _playerState.yaw = pp.yaw; if (typeof pp.pitch === 'number') _playerState.pitch = pp.pitch; if (pp.forward) _playerState.forward = pp.forward; if (typeof pp.crosshair === 'string') _playerState.crosshair = pp.crosshair; if (typeof pp.hp === 'number') _playerState.hp = pp.hp; if (typeof pp.maxHp === 'number') _playerState.maxHp = pp.maxHp; // Кубикон Dash: направление гравитации (+1 / -1). if (typeof pp.gravityDir === 'number') _playerState.gravityDir = pp.gravityDir; if (typeof pp.state === 'string') _playerState.state = pp.state; if (pp.keys && typeof pp.keys === 'object') _playerState.keys = pp.keys; } if (payload && payload.selfPosition) { _selfPosition = payload.selfPosition; } if (payload && Array.isArray(payload.mobs)) { _mobs = payload.mobs; } if (payload && Array.isArray(payload.npcs)) { _npcs = payload.npcs; } if (payload && payload.inventory && typeof payload.inventory === 'object') { _inventory = payload.inventory; } if (payload && payload.players && typeof payload.players === 'object') { _players = payload.players; } if (payload && payload.roomState && typeof payload.roomState === 'object') { _roomState = payload.roomState; } if (payload && Array.isArray(payload.teams)) { _teams = payload.teams; } for (const fn of _tickHandlers) { _safeCall(fn, dt, 'onTick'); } // Таймеры game.after / game.every — копим dt, срабатываем при достижении delay. // Итерируем по копии: callback может вызвать game.after/cancel и изменить _timers. if (_timers.length > 0 && dt > 0) { const due = []; for (const t of _timers) { t.elapsed += dt; if (t.elapsed >= t.delay) due.push(t); } for (const t of due) { if (t.repeat) { // отнимаем delay (не сбрасываем в 0) — равномерный интервал без дрейфа t.elapsed -= t.delay; } else { const i = _timers.indexOf(t); if (i >= 0) _timers.splice(i, 1); } _safeCall(t.fn, undefined, t.repeat ? 'every' : 'after'); } } } else if (cmd === 'state') { if (payload && payload.player) { const pp = payload.player; if (pp.position) _playerState.position = pp.position; if (typeof pp.yaw === 'number') _playerState.yaw = pp.yaw; if (typeof pp.pitch === 'number') _playerState.pitch = pp.pitch; if (pp.forward) _playerState.forward = pp.forward; if (typeof pp.crosshair === 'string') _playerState.crosshair = pp.crosshair; if (typeof pp.hp === 'number') _playerState.hp = pp.hp; if (typeof pp.maxHp === 'number') _playerState.maxHp = pp.maxHp; // Кубикон Dash: направление гравитации (+1 / -1). if (typeof pp.gravityDir === 'number') _playerState.gravityDir = pp.gravityDir; if (typeof pp.state === 'string') _playerState.state = pp.state; if (pp.keys && typeof pp.keys === 'object') _playerState.keys = pp.keys; } if (payload && payload.selfPosition) { _selfPosition = payload.selfPosition; } if (payload && Array.isArray(payload.mobs)) { _mobs = payload.mobs; } if (payload && Array.isArray(payload.npcs)) { _npcs = payload.npcs; } if (payload && payload.inventory && typeof payload.inventory === 'object') { _inventory = payload.inventory; } if (payload && payload.players && typeof payload.players === 'object') { _players = payload.players; } if (payload && payload.roomState && typeof payload.roomState === 'object') { _roomState = payload.roomState; } if (payload && Array.isArray(payload.teams)) { _teams = payload.teams; } } else if (cmd === 'event') { // payload: { type, ...data } const t = payload?.type; if (t === 'click') { // self.onClick — только если есть target и target совпал for (const fn of _selfClickHandlers) _safeCall(fn, payload, 'self.onClick'); } else if (t === 'touch') { for (const fn of _selfTouchHandlers) _safeCall(fn, payload, 'self.onTouch'); } else if (t === 'untouch') { for (const fn of _selfUntouchHandlers) _safeCall(fn, payload, 'self.onUntouch'); } else if (t === 'interact') { for (const fn of _selfInteractHandlers) _safeCall(fn, payload, 'self.onInteract'); } } else if (cmd === 'globalEvent') { // payload: { type, ...data } — глобальные события (всем sandbox'ам) const t = payload?.type; if (t === 'click') { for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick'); } else if (t === 'mouseMove') { for (const fn of _mouseMoveHandlers) { try { fn(payload.x, payload.y); } catch (err) { _send('log', { level: 'error', text: 'onMouseMove: ' + (err && err.message ? err.message : err) }); } } } else if (t === 'mouseDown') { for (const fn of _mouseDownHandlers) { try { fn(payload.x, payload.y); } catch (err) { _send('log', { level: 'error', text: 'onMouseDown: ' + (err && err.message ? err.message : err) }); } } } else if (t === 'mouseUp') { for (const fn of _mouseUpHandlers) { try { fn(payload.x, payload.y); } catch (err) { _send('log', { level: 'error', text: 'onMouseUp: ' + (err && err.message ? err.message : err) }); } } } else if (t === 'playerTouch') { for (const fn of _globalTouchHandlers) _safeCall(fn, payload, 'onPlayerTouch'); } else if (t === 'instTouch' || t === 'instUntouch' || t === 'instClick') { // Касание/клик произвольного объекта (findOne(x).onTouch/onUntouch/onClick). const b = _instTouchHandlers.get(payload && payload.ref); if (b) { const list = t === 'instTouch' ? b.touch : t === 'instUntouch' ? b.untouch : b.click; for (const fn of list) _safeCall(fn, payload, 'inst.' + t); } } else if (t === 'hpChange') { for (const fn of _hpChangeHandlers) _safeCall(fn, payload, 'onHpChange'); } else if (t === 'mobKilled') { for (const fn of _mobKilledHandlers) _safeCall(fn, payload, 'onMobKilled'); } else if (t === 'npcDeath') { // payload: { npcId, position } const npcId = payload.npcId; const ev = { id: npcId, position: payload.position }; // Глобальные подписчики game.onNpcDeath(fn). for (const fn of _globalNpcDeathHandlers) _safeCall(fn, ev, 'onNpcDeath'); // Адресные подписки npc.onDeath — по числовому id ИЛИ по // локальному ref, который при спавне привязали к этому id. const keys = [String(npcId)]; for (const [lref, real] of Object.entries(_npcLocalToReal)) { if (real === npcId) keys.push(lref); } for (const key of keys) { const arr = _npcDeathHandlers[key] || []; for (const fn of arr) _safeCall(fn, ev, 'npc.onDeath'); } } else if (t === 'toolUse') { // payload: { tool: {kind, modelTypeId, name}, point, target } const ev = { tool: payload.tool || null, point: payload.point || null, target: payload.target || null, }; for (const fn of _toolUseHandlers) _safeCall(fn, ev, 'onToolUse'); } else if (t === 'cutsceneDone') { // Катсцена камеры завершилась (Фаза 5.7). for (const fn of _cutsceneDoneHandlers) _safeCall(fn, undefined, 'onCutsceneDone'); } else if (t === 'playerJoin') { // payload: { sessionId, name } for (const fn of _playerJoinHandlers) _safeCall(fn, payload, 'onPlayerJoin'); } else if (t === 'playerLeave') { for (const fn of _playerLeaveHandlers) _safeCall(fn, payload, 'onPlayerLeave'); } else if (t === 'roomChange') { // payload: { key, value } — изменилось общее состояние комнаты. const arr = _roomChangeHandlers[payload.key] || []; for (const fn of arr) _safeCall(fn, payload.value, 'room.onChange:' + payload.key); } else if (t === 'mpMessage') { // payload: { from, name, data } — адресное сообщение. const arr = _mpMessageHandlers[payload.name] || []; for (const fn of arr) { _safeCall(fn, { from: payload.from, data: payload.data }, 'onMessage:' + payload.name); } } else if (t === 'playerDied') { for (const fn of _playerDiedHandlers) _safeCall(fn, undefined, 'onPlayerDied'); } else if (t === 'playerJump') { for (const fn of _playerJumpHandlers) _safeCall(fn, undefined, 'onPlayerJump'); } else if (t === 'playerLand') { for (const fn of _playerLandHandlers) _safeCall(fn, undefined, 'onPlayerLand'); } else if (t === 'keydown') { const key = String(payload.key || '').toLowerCase(); const arr = _globalKeyDownHandlers[key] || []; for (const fn of arr) _safeCall(fn, payload, 'onKey:' + key); const wild = _globalKeyDownHandlers['*'] || []; for (const fn of wild) _safeCall(fn, payload, 'onKey(*)'); } else if (t === 'keyup') { const key = String(payload.key || '').toLowerCase(); const arr = _globalKeyUpHandlers[key] || []; for (const fn of arr) _safeCall(fn, payload, 'onKeyUp:' + key); const wild = _globalKeyUpHandlers['*'] || []; for (const fn of wild) _safeCall(fn, payload, 'onKeyUp(*)'); } else if (t === 'message') { const name = String(payload.name || ''); const arr = _messageHandlers[name] || []; for (const fn of arr) _safeCall(fn, payload.data, 'onMessage:' + name); } else if (t === 'guiClick') { const id = String(payload.id || ''); const localId = payload.localId != null ? String(payload.localId) : null; // Собираем handlers по id, по локальному ref и по имени элемента — // скрипт мог подписаться любым из этих ключей. // _matched защищает от двойного вызова если несколько ключей ведут // к одному и тому же массиву handlers. const _matched = new Set(); for (const key of _guiHandlerKeys(id, localId)) { const arr = _guiClickHandlers[key]; if (!arr || _matched.has(arr)) continue; _matched.add(arr); for (const fn of arr) _safeCall(fn, { id }, 'gui.onClick:' + key); } } else if (t === 'guiSubmit') { const id = String(payload.id || ''); const localId = payload.localId != null ? String(payload.localId) : null; const val = payload.value != null ? String(payload.value) : ''; const _matched = new Set(); for (const key of _guiHandlerKeys(id, localId)) { const arr = _guiSubmitHandlers[key]; if (!arr || _matched.has(arr)) continue; _matched.add(arr); for (const fn of arr) _safeCall(fn, val, 'gui.onSubmit:' + key); } } else if (t === 'billboardClick') { // payload: { ref, button } — клик по кнопке 3D-таблички. // Ищем handlers и по реальному ref (primitive:NN), и по локальному // ref если такой есть (на случай если скрипт подписался по // локальному ref от scene.spawn). const realRef = String(payload.ref || ''); const button = String(payload.button || 'buy'); const tryKeys = [realRef + ':' + button]; // Если есть локальный ref, ведущий к этому real — тоже попробуем // (скрипт мог подписаться на ref сразу после game.scene.spawn, // когда ref был ещё локальным _local_N). for (const [local, real] of Object.entries(_spawnLocalToReal || {})) { if (real === realRef) tryKeys.push(local + ':' + button); } for (const key of tryKeys) { const arr = _billboardClickHandlers[key] || []; for (const fn of arr) _safeCall(fn, { ref: realRef, button }, 'billboard.onClick:' + key); } } else if (t === 'modalOpened') { // Задача 04: реальный modalId от runtime. worker сразу вернул скрипту // локальный id (чтобы он мог его сохранить и звать close/update); здесь // запоминаем маппинг local→real, иначе close(m) уходит с локальным id // и ModalManager.close его не узнаёт (баг «закрывается только по Esc»). try { const mm = (typeof game !== 'undefined') && game.modal; if (mm && payload && payload.replyId) { const localId = Number(String(payload.replyId).replace(/^_mopen_/, '')); if (Number.isFinite(localId) && payload.modalId != null) { mm._localToReal.set(localId, payload.modalId); mm._isOpenLocal = true; } } } catch (e) {} } else if (t === 'modalClosed') { // Модал закрыт (fadeOut доиграл) — снимаем флаг и зовём onClose-подписчиков. try { const mm = (typeof game !== 'undefined') && game.modal; if (mm) { mm._isOpenLocal = false; const cbs = mm._onCloseFns || []; for (const fn of cbs) _safeCall(fn, payload && payload.id, 'modal.onClose'); } } catch (e) {} } else if (t === 'skinChanged') { // Задача 07: скин игрока сменился — обновляем зеркало и зовём подписчиков. const slug = payload && payload.slug; if (slug) { _currentSkin = slug; for (const fn of _skinChangeHandlers) _safeCall(fn, slug, 'player.onSkinChange'); } } else if (t === 'skinUnlocked') { const slug = payload && payload.slug; if (slug && _unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug); } else if (t === 'placeConfirm') { const ev = { itemKey: payload.itemKey, position: payload.position, rotationY: payload.rotationY }; for (const fn of _placeOnPlaceHandlers) _safeCall(fn, ev, 'placement.onPlace'); } else if (t === 'placeCancel') { for (const fn of _placeOnCancelHandlers) _safeCall(fn, undefined, 'placement.onCancel'); } else if (t === 'placeMove') { const ev = { position: payload.position, valid: !!payload.valid }; for (const fn of _placeOnMoveHandlers) _safeCall(fn, ev, 'placement.onMove'); } else if (t === 'invUiSlotClick') { const item = payload.item || { key: payload.key }; for (const fn of _invUiSlotClickHandlers) _safeCall(fn, item, 'inventoryUi.onSlotClick'); } } else if (cmd === 'sceneSnapshot') { // payload: { blocks, models, primitives } — массивы { ref, x, y, z, name?, type? } if (payload) { _sceneIndex = { blocks: payload.blocks || [], models: payload.models || [], primitives: payload.primitives || [], }; // детект дельт и эмит events для Instance (если кто-то подписан). try { _detectSnapshotDeltas(); } catch (e) {} } } else if (cmd === 'guiSnapshot') { // payload: массив всех GUI-элементов (для game.gui.find/get/all) _guiIndex = Array.isArray(payload) ? payload : []; } else if (cmd === 'skinsSnapshot') { // Задача 07: payload { all:[{slug,name,kind,category,price}], unlocked:[slug], current } if (payload && typeof payload === 'object') { _skinsIndex = Array.isArray(payload.all) ? payload.all : []; _unlockedSkins = Array.isArray(payload.unlocked) ? payload.unlocked.slice() : []; _currentSkin = payload.current || _currentSkin; if (Number.isFinite(payload.coins)) _skinCoins = payload.coins; } } else if (cmd === 'dataSnapshot') { // payload: { ref: { key: value } } — атрибуты всех объектов _dataIndex = payload && typeof payload === 'object' ? payload : {}; } else if (cmd === 'terrainHeightmap') { // payload: { origin:{x,z}, step, cols, rows, heights:[] } // Карта высот гладкого ландшафта для game.scene.surfaceY. _terrainHM = payload || null; } else if (cmd === 'saveResponse') { // payload: { reqId, result } const reqId = payload && payload.reqId; const cb = reqId && _saveCallbacks[reqId]; if (cb) { delete _saveCallbacks[reqId]; try { cb(payload.result); } catch (e) {} } } else if (cmd === 'economyResponse') { // payload: { reqId, result } const reqId = payload && payload.reqId; const cb = reqId && _economyCallbacks[reqId]; if (cb) { delete _economyCallbacks[reqId]; try { cb(payload.result); } catch (e) {} } } else if (cmd === 'tweenDone') { // payload: { tweenId } — твин доиграл, зовём onDone const tid = payload && payload.tweenId; const cb = tid != null && _tweenCallbacks[tid]; if (cb) { delete _tweenCallbacks[tid]; _safeCall(cb, undefined, 'tween.onDone'); } } else if (cmd === 'npcSpawned') { // payload: { localRef, npcId } — async-спавн NPC завершён. // Запоминаем маппинг, чтобы npc.onDeath по локальному ref работал. if (payload && payload.localRef != null) { _npcLocalToReal[payload.localRef] = payload.npcId; } } else if (cmd === 'spawnResolved') { // payload: { localRef, realRef } — scene.spawn создал объект. // Запоминаем маппинг для getPosition и т.п. if (payload && payload.localRef && payload.realRef) { _spawnLocalToReal[payload.localRef] = payload.realRef; } } else if (cmd === 'stop') { _tickHandlers = []; _timers = []; _selfClickHandlers = []; _selfTouchHandlers = []; _selfUntouchHandlers = []; _selfInteractHandlers = []; _instTouchHandlers.clear(); _globalKeyDownHandlers = {}; _globalKeyUpHandlers = {}; _globalClickHandlers = []; _globalTouchHandlers = []; _mouseMoveHandlers = []; _mouseDownHandlers = []; _mouseUpHandlers = []; _mobKilledHandlers = []; _hpChangeHandlers = []; _playerDiedHandlers = []; _playerJumpHandlers = []; _playerLandHandlers = []; _messageHandlers = {}; _guiClickHandlers = {}; _guiSubmitHandlers = {}; _npcDeathHandlers = {}; _globalNpcDeathHandlers = []; _npcLocalToReal = {}; _spawnLocalToReal = {}; _npcs = []; _toolUseHandlers = []; _inventory = { slots: [], activeIndex: 0 }; _players = { me: null, list: [] }; _roomState = {}; _playerJoinHandlers = []; _playerLeaveHandlers = []; _cutsceneDoneHandlers = []; _mpMessageHandlers = {}; _roomChangeHandlers = {}; _teams = []; _constraintRefSeq = 0; _fxRefSeq = 0; _soundRefSeq = 0; } }; _send('boot', null); `; /** * Создаёт URL Worker-кода для new Worker(url). */ export function getWorkerSourceUrl() { const blob = new Blob([SOURCE], { type: 'application/javascript' }); return URL.createObjectURL(blob); }