player/src/engine/ScriptSandboxWorker.js
МИН fe23d099cd feat(player): hud.setHotbarVisible / hud.setHpVisible (паритет со студией)
Игры со студии (1995/2037/2046) звали game.hud.setHotbarVisible/setHpVisible —
в движке плеера были только hud.setVisible (весь HUD). Без них скрипт падал
на первой строке и игра не работала (нет монет, кнопки не жмутся).

Добавлено во все 3 слоя:
- ScriptSandboxWorker: методы hud.setHotbarVisible/setHpVisible → _send
- GameRuntime: обработчики cmd hud.setHotbarVisible/setHpVisible
- BabylonScene: _setHotbarVisible/_setHpVisible + колбэки видимости

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:16:37 +03:00

3366 lines
165 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 = [];
// Мультиплеер (Фаза 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 = [];
// Зеркало всех 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 вернуть набор ключей, под которыми
// могли быть зарегистрированы 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 = {};
// Модули (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;
_send('scene.spawn', { kind: 'block', subType, x: ix, y: iy, z: iz, ref });
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,
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 (для моделей/примитивов). Возвращает string[]. */
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(m.ref);
}
for (const p of _sceneIndex.primitives) {
if (p.name && String(p.name).toLowerCase() === n) out.push(p.ref);
}
return out;
},
/** Первый объект с таким name или null. */
findOne(name) {
const arr = this.find(name);
return arr.length > 0 ? arr[0] : null;
},
/** Список ref'ов всех объектов заданного типа: 'block' | 'model' | 'primitive'. */
all(kind) {
if (kind === 'block') return _sceneIndex.blocks.map(b => b.ref);
if (kind === 'model') return _sceneIndex.models.map(m => m.ref);
if (kind === 'primitive') return _sceneIndex.primitives.map(p => p.ref);
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;
_send('fx.create', {
kind: 'beam', localRef,
from: opts.from, to: opts.to,
color: typeof opts.color === 'string' ? opts.color : undefined,
width: Number.isFinite(Number(opts.width)) ? Number(opts.width) : undefined,
});
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 });
},
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);
},
/**
* Расстояние между двумя точками или объектами.
* Аргументы могут быть {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;
},
};
/**
* Пересечение луча с 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;
}
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 === '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 (cmd === 'sceneSnapshot') {
// payload: { blocks, models, primitives } — массивы { ref, x, y, z, name?, type? }
if (payload) {
_sceneIndex = {
blocks: payload.blocks || [],
models: payload.models || [],
primitives: payload.primitives || [],
};
}
} 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 = [];
_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);
}