All checks were successful
Скрипт «Мой завод» (id 2345) падал в плеере на game.format.money — неймспейс был только в worker студии. Из-за краха в синхронной части не доходило до inventoryUi.create/placement → инвентарь не показывался. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3889 lines
195 KiB
JavaScript
3889 lines
195 KiB
JavaScript
/**
|
||
* 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:<id>' / 'primitive:<id>' / 'model:<id>' / '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>', где <id> — числовой id модели в проекте.
|
||
// ref пользовательских инстансов в сцене — 'usermodel:<id>'.
|
||
if (kind === 'user') {
|
||
_localRefSeq++;
|
||
const ref = 'usermodel:_local_' + _localRefSeq;
|
||
_send('scene.spawn', {
|
||
kind: 'userModel',
|
||
// subType — это полная строка 'user:<id>' (как принимает
|
||
// 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);
|
||
}
|