player/src/engine/ScriptSandboxWorker.js
МИН 87444ee2c8 Initial public release: Rublox Player v1.0
Open-source web player for Rublox games, dual-licensed under
AGPL-3.0 + Commercial.

Highlights:
- Babylon.js 7 + React 18 + Vite 5 stack
- Self-contained engine (~46k lines): BlockManager, ModelManager,
  PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD
  gamemodes
- Configurable backend via VITE_API_BASE and friends — works against
  staging (dev-api.rublox.pro) out of the box
- Standalone mode (VITE_STANDALONE=true) loads a bundled sample game
  for first-run without any backend
- Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG
- Lint + format scaffolding (ESLint + Prettier + EditorConfig)
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Removed before public release:
- frontend_deploy.py (contained production SSH credentials)
- ~27 admin endpoints (kept in private repo)
- Hard-coded internal URLs and IPs
- All previous git history (clean repo init)
2026-05-27 23:04:04 +03:00

2773 lines
132 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 = [];
// Подписки game.gui.onClick(id, fn)
let _guiClickHandlers = {};
// Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter)
let _guiSubmitHandlers = {};
// Для GUI-события с реальным id вернуть набор ключей, под которыми
// могли быть зарегистрированы handlers: сам id + имя элемента (скрипт
// часто подписывается через game.gui.onClick('ИмяКнопки', fn)).
function _guiHandlerKeys(id) {
const keys = [id];
const el = _guiIndex.find(g => g.id === id);
if (el && el.name && el.name !== id) 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) {}
};
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 });
},
/**
* Режим камеры: 'first' | 'third' | 'front' | 'sideview'.
* 'sideview' нужен для Кубикон Dash — камера фиксируется сбоку,
* yaw/pitch от мыши/тача игнорируются.
*/
setCameraMode(mode) {
if (typeof mode !== 'string') return;
_send('player.setCameraMode', { mode });
},
/**
* Присед: уменьшает высоту хитбокса игрока с 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);
},
},
/**
* Камера — тряска, 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 });
},
},
/**
* Инвентарь игрока (Фаза 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 || '') });
},
},
/**
* Управление режимами ввода — курсор и камера.
* В режиме '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 || '');
// Собираем handlers и по id, и по имени элемента — скрипт
// мог подписаться через game.gui.onClick('ИмяКнопки', fn).
for (const key of _guiHandlerKeys(id)) {
const arr = _guiClickHandlers[key] || [];
for (const fn of arr) _safeCall(fn, { id }, 'gui.onClick:' + key);
}
} else if (t === 'guiSubmit') {
const id = String(payload.id || '');
const val = payload.value != null ? String(payload.value) : '';
for (const key of _guiHandlerKeys(id)) {
const arr = _guiSubmitHandlers[key] || [];
for (const fn of arr) _safeCall(fn, val, 'gui.onSubmit:' + key);
}
}
} 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 === '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);
}