/**
 * 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 = [];
// Phase 6.4: game.tools.create(name, opts) -- кастомные tool с своим onActivated.
// Per-tool коллбэки. Ключ -- toolId (modelTypeId или 'custom:<seq>').
// При toolUse-событии воркер сначала вызывает per-tool колбэк, потом глобальные.
let _toolCallbacks = {};  // { 'custom:1': { activated: fn, equipped: fn, unequipped: fn } }
let _toolSeq = 0;
// Задача 05: зеркало видимости экрана загрузки (для game.loading.isVisible()).
let _loadingVisible = false;
// Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }.
let _players = { me: null, list: [] };
// Общее состояние комнаты game.room.get/set — зеркало из main thread.
let _roomState = {};
// Задача 20: зеркала лидербордов/достижений для синхронного get/has в скриптах.
let _lsMirror = {};            // { playerId: { statName: value } }
let _achUnlocked = {};         // { id: true }
let _lsChangeHandlers = [];    // game.leaderstats.onChange подписки
// Подписки game.onPlayerJoin / onPlayerLeave / game.onMessage(name).
let _playerJoinHandlers = [];
let _playerLeaveHandlers = [];
// Подписки game.onCutsceneDone(fn) — катсцена камеры доиграла (Фаза 5.7).
let _cutsceneDoneHandlers = [];
let _vehicleEnterHandlers = [];   // задача 14
let _vehicleExitHandlers = [];
let _mpMessageHandlers = {};   // name → [fn]
let _remoteHandlers = {};      // Phase 6.6: RemoteEvent handlers, 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 = [];
// Подписки placement-режима (задача 11): game.placement.onPlace/onCancel/onMove.
let _placeOnPlaceHandlers = [];
let _placeOnCancelHandlers = [];
let _placeOnMoveHandlers = [];
// Подписки слот-инвентаря магазина: game.inventoryUi onSlotClick.
let _invUiSlotClickHandlers = [];
// Подписки на касание/клик ПРОИЗВОЛЬНОГО объекта (через findOne(x).onTouch).
// ref → { touch:[fn], untouch:[fn], click:[fn] }. Движок следит за AABB этих
// объектов (cmd 'inst.watchTouch') и шлёт обратно instTouch/instUntouch/instClick.
const _instTouchHandlers = new Map();
function _instHandlerBucket(ref) {
    let b = _instTouchHandlers.get(ref);
    if (!b) { b = { touch: [], untouch: [], click: [], interact: [] }; _instTouchHandlers.set(ref, b); }
    return b;
}
// Зеркало всех GUI-элементов (синхронизируется main через cmd='guiSnapshot')
let _guiIndex = [];
// Задача 07: зеркало скинов (синхронизируется через 'skinsSnapshot').
// _skinsIndex — все встроенные скины [{slug,name,kind,category,price}].
// _unlockedSkins — slug'и доступные игроку. _currentSkin — активный.
let _skinsIndex = [];
let _unlockedSkins = [];
let _currentSkin = null;
let _skinChangeHandlers = [];
let _skinCoins = 0;   // локальная валюта магазина скинов (рублики проекта)
// Подписки game.gui.onClick(id, fn)
let _guiClickHandlers = {};
// Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter)
let _guiSubmitHandlers = {};
// Подписки game.billboard.onClick(ref, buttonId, fn) — клик по кнопке
// 3D-таблички. Ключ имеет вид ref + ":" + buttonId где ref — строка
// из game.scene.spawn() или game.scene.findOne() в формате
// 'primitive:NN' либо локальный '_local_X'. Резолв в main (GameRuntime),
// сюда приходит событие 'billboardClick' с готовым ref='primitive:NN'.
let _billboardClickHandlers = {};
// Для GUI-события с реальным id вернуть набор ключей, под которыми
// могли быть зарегистрированы handlers: сам id + имя элемента (скрипт
// часто подписывается через game.gui.onClick('ИмяКнопки', fn)).
// Нормализовать точку для fx.beam/fx.pointer перед postMessage.
// game.scene.findOne() возвращает Instance-PROXY — его НЕЛЬЗЯ передать через
// postMessage (structured clone бросает DataCloneError → весь скрипт молча
// падает в воркере, стрелка/луч не создаётся). Конвертируем proxy/объект-с-ref
// в ref-строку ('primitive:NN'); 'player' и {x,y,z} пропускаем как есть.
function _normFxPoint(p) {
    if (p == null) return p;
    if (typeof p === 'string') return p;                 // 'player' | 'primitive:NN'
    if (typeof p === 'object') {
        // Instance-proxy: есть .ref (через toString/valueOf даёт ref-строку).
        if (typeof p.ref === 'string') return p.ref;
        // Чистая точка {x,y,z} — безопасна для clone, отдаём копию.
        if (Number.isFinite(p.x) && Number.isFinite(p.y) && Number.isFinite(p.z)) {
            return { x: p.x, y: p.y, z: p.z };
        }
        // Любой другой объект (вдруг proxy без .ref) — пробуем String().
        try { const s = String(p); if (s && s !== '[object Object]') return s; } catch (e) {}
    }
    return p;
}

function _guiHandlerKeys(id, localId) {
    const keys = [id];
    // localId — ref который вернул gui.create() ('_gui_local_N'); скрипт мог
    // подписаться по нему, если не задавал явный id.
    if (localId != null && localId !== id) keys.push(localId);
    // name элемента — скрипт часто подписывается game.gui.onClick('ИмяКнопки', fn).
    const el = _guiIndex.find(g => g.id === id);
    if (el && el.name && el.name !== id && !keys.includes(el.name)) keys.push(el.name);
    return keys;
}

// Найти запись NPC в снапшоте по локальному ref. Снапшот приходит с
// числовыми id, локальный ref → id через _npcLocalToReal.
function _findNpcState(localRef) {
    const id = _npcLocalToReal[localRef];
    if (id == null) return null;
    return _npcs.find(n => n.id === id) || null;
}

// Фабрика прокси-объекта NPC. Методы шлют команды с локальным ref —
// main thread резолвит его в реальный npcId.
function _makeNpcProxy(ref) {
    return {
        get ref() { return ref; },
        /** Актуальная позиция NPC {x,y,z} или null (пока не заспавнен). */
        get position() {
            const st = _findNpcState(ref);
            return st ? { x: st.x, y: st.y, z: st.z } : null;
        },
        /** Текущее HP или null. */
        get hp() {
            const st = _findNpcState(ref);
            return st ? st.hp : null;
        },
        /** Имя NPC или null. */
        get name() {
            const st = _findNpcState(ref);
            return st ? st.name : null;
        },
        /** Идти в точку (XZ). */
        moveTo(x, z) {
            _send('npc.moveTo', { ref, x: Number(x) || 0, z: Number(z) || 0 });
        },
        /** Задать скорость NPC (м/с) — на лету. Напр. медленный подход
         *  в кат-сцене → быстрая погоня в игре. */
        setSpeed(speed) {
            const s = Number(speed);
            if (Number.isFinite(s) && s > 0) {
                _send('npc.setSpeed', { ref, speed: s });
            }
        },
        /** Следовать за объектом: 'player' или ref объекта сцены. */
        follow(target) {
            _send('npc.follow', { ref, target });
        },
        /** Остановиться. */
        stop() {
            _send('npc.stop', { ref });
        },
        /** Реплика над головой на duration секунд (по умолчанию 3). */
        say(text, duration) {
            _send('npc.say', {
                ref,
                text: String(text == null ? '' : text),
                duration: Number.isFinite(Number(duration)) ? Number(duration) : 3,
            });
        },
        /** Нанести урон NPC. */
        damage(amount) {
            _send('npc.damage', { ref, amount: Number(amount) || 0 });
        },
        /** Включить/выключить анимацию атаки (удары руками). */
        setAttacking(on) {
            _send('npc.setAttacking', { ref, on: !!on });
        },
        /** Убрать 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;
// Биндинги лейблов (задача 10): ref → { timerId, lastText }. bindLabel/bindTimer
// создают повторяющийся таймер, который зовёт fn() и шлёт setLabel при изменении
// текста. Автоотменяются при scene.delete(ref) (через instTouch/destroying).
const _labelBindings = new Map();
// Зеркало всех объектов сцены (заполняется 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) {}
};

/**
 * 6.2: нормализация ref-аргумента. Принимает:
 *   - string (старое API) -- возвращается как есть.
 *   - Instance-прокси (Phase 6.2) -- достаём поле .ref.
 *   - всё остальное -- null (молча игнорится в методах).
 *
 * Все методы game.scene.set* в начале вызывают ref = _normRef(ref),
 * чтобы и setColor(refStr, ...), и setColor(instance, ...) работали.
 */
function _normRef(ref) {
    if (typeof ref === 'string') return ref || null;
    if (ref && typeof ref === 'object') {
        // Instance-прокси имеет геттер .ref. Fallback на toString().
        if (typeof ref.ref === 'string' && ref.ref) return ref.ref;
        const s = String(ref);
        return s && s !== '[object Object]' ? s : null;
    }
    return null;
}

const _safeCall = (fn, arg, where) => {
    try { fn(arg); }
    catch (err) {
        _send('log', {
            level: 'error',
            text: 'Ошибка в ' + where + ': ' + (err && err.message ? err.message : err),
        });
    }
};

// Внутренний хелпер: запланировать удаление объекта через seconds секунд.
// Используется для lifetime в scene.spawn и для scene.deleteAfter.
const _scheduleDelete = (ref, seconds) => {
    const s = Number(seconds);
    ref = _normRef(ref);
    if (!ref || !Number.isFinite(s) || s < 0) return;
    _timers.push({
        id: ++_timerSeq,
        fn: () => _send('scene.delete', { ref }),
        delay: s, elapsed: 0, repeat: false,
    });
};

// ═══════════════════════════════════════════════════════════════════════
// Instance-модель (Фаза 6.2 — компонентная модель объектов сцены).
//
// Идея: вместо game.scene.setColor(ref, '#fff') юзер пишет obj.color = '#fff'.
// findOne/spawn возвращают Instance-прокси, который под капотом шлёт те же
// команды что и старые scene.set*. Старые методы продолжают работать —
// Instance имеет toString → ref, поэтому coerces в строку.
//
// Поддержано в 6.2:
//   - Свойства: ref, kind, name, position, parent, children, color,
//     transparency, opacity, visible, canCollide, material.
//   - Иерархия: obj.parent = newParent, obj.children, obj.descendants().
//   - События: obj.changed.connect(prop, fn), obj.destroying.connect(fn).
//   - Методы: destroy(), clone(), tween(), setAttribute/getAttribute,
//     addTag/removeTag/hasTag, move(), setLabel(), clearLabel().
// ═══════════════════════════════════════════════════════════════════════

const _instCache = new Map();          // ref → Instance proxy
const _instEvents = new Map();         // ref → { propChanged: [{prop,fn}], destroying: [fn] }
const _instLastValues = new Map();     // ref → { x, y, z, name } предыдущий snapshot

function _safeCall2(fn, args, where) {
    try { fn.apply(null, args); }
    catch (err) {
        _send('log', {
            level: 'error',
            text: 'Ошибка в ' + where + ': ' + (err && err.message ? err.message : err),
        });
    }
}

function _getOrCreateInstance(ref, kindHint) {
    if (!ref || typeof ref !== 'string') return null;
    if (_instCache.has(ref)) return _instCache.get(ref);

    const target = {
        get ref() { return ref; },
        get kind() {
            if (kindHint) return kindHint;
            for (const b of _sceneIndex.blocks)     if (b.ref === ref) return 'block';
            for (const m of _sceneIndex.models)     if (m.ref === ref) return 'model';
            for (const p of _sceneIndex.primitives) if (p.ref === ref) return 'primitive';
            return 'unknown';
        },
        toString() { return ref; },
    };

    const proxy = new Proxy(target, {
        get(t, prop) {
            if (prop === 'ref' || prop === 'kind') return t[prop];
            if (prop === 'toString') return t.toString;
            if (prop === Symbol.toPrimitive) return () => ref;
            if (prop === 'valueOf') return () => ref;

            // === Геттеры из snapshot ===
            if (prop === 'position') {
                for (const arr of [_sceneIndex.blocks, _sceneIndex.models, _sceneIndex.primitives]) {
                    for (const o of arr) if (o.ref === ref) return { x: o.x, y: o.y, z: o.z };
                }
                return null;
            }
            if (prop === 'name') {
                for (const arr of [_sceneIndex.models, _sceneIndex.primitives]) {
                    for (const o of arr) if (o.ref === ref) return o.name || null;
                }
                return null;
            }
            if (prop === 'parent') {
                const data = _dataIndex[ref];
                const parentRef = data && data.__parent;
                return parentRef ? _getOrCreateInstance(parentRef) : null;
            }
            if (prop === 'children') {
                const data = _dataIndex[ref];
                const ids = (data && data.__children) || [];
                return ids.map(id => _getOrCreateInstance(id)).filter(Boolean);
            }
            if (prop === 'descendants') {
                return () => {
                    const out = [];
                    const stack = [...((_dataIndex[ref] || {}).__children || [])];
                    while (stack.length) {
                        const id = stack.pop();
                        const inst = _getOrCreateInstance(id);
                        if (inst) {
                            out.push(inst);
                            const kids = (_dataIndex[id] || {}).__children || [];
                            for (const k of kids) stack.push(k);
                        }
                    }
                    return out;
                };
            }

            // === События ===
            if (prop === 'changed') {
                return {
                    connect(propName, fn) {
                        if (typeof fn !== 'function') return;
                        let evs = _instEvents.get(ref);
                        if (!evs) { evs = { propChanged: [], destroying: [] }; _instEvents.set(ref, evs); }
                        evs.propChanged.push({ prop: propName, fn });
                    },
                };
            }
            if (prop === 'destroying') {
                return {
                    connect(fn) {
                        if (typeof fn !== 'function') return;
                        let evs = _instEvents.get(ref);
                        if (!evs) { evs = { propChanged: [], destroying: [] }; _instEvents.set(ref, evs); }
                        evs.destroying.push(fn);
                    },
                };
            }

            // === Методы ===
            if (prop === 'destroy' || prop === 'delete') return () => _send('scene.delete', { ref });
            if (prop === 'clone') return (offset) => _send('scene.clone', { ref, offset: offset || {} });
            if (prop === 'setAttribute') return (k, v) => _send('scene.setData', { ref, key: k, value: v });
            if (prop === 'getAttribute') return (k) => {
                const data = _dataIndex[ref];
                return data ? data[k] : undefined;
            };
            if (prop === 'hasTag') return (t) => {
                const data = _dataIndex[ref];
                return Array.isArray(data && data.__tags) && data.__tags.includes(t);
            };
            if (prop === 'addTag') return (t) => _send('scene.tag', { ref, tag: t });
            if (prop === 'removeTag') return (t) => _send('scene.untag', { ref, tag: t });
            if (prop === 'tween') return (props, opts) => game.tween(ref, props, opts);
            if (prop === 'move') return (x, y, z) => _send('scene.move', { ref, x, y, z });
            // obj.rotate(ry) — поворот объекта вокруг Y (рад). Для примитивов И
            // моделей (kind берётся из ref). Нужно вращающимся предметам витрины.
            if (prop === 'rotate' || prop === 'rotateY') return (ry) => {
                const colon = ref.indexOf(':');
                if (colon < 0) return;
                _send('scene.rotate', { ref, kind: ref.slice(0, colon), id: ref.slice(colon + 1), rotationY: Number(ry) || 0 });
            };
            if (prop === 'setLabel') return (text, o) => _send('scene.setLabel', { ref, text, opts: o || {} });
            if (prop === 'clearLabel') return () => _send('scene.clearLabel', { ref });

            // === События касания/клика ПРОИЗВОЛЬНОГО объекта ===
            // findOne('coin').onTouch(fn) — fn() когда игрок коснулся объекта.
            // Аналог Roblox part.Touched:Connect. Движок начинает следить за AABB
            // объекта (inst.watchTouch) и шлёт instTouch на rising edge.
            if (prop === 'onTouch') return (fn) => {
                if (typeof fn !== 'function') return;
                _instHandlerBucket(ref).touch.push(fn);
                _send('inst.watchTouch', { ref });
            };
            if (prop === 'onUntouch') return (fn) => {
                if (typeof fn !== 'function') return;
                _instHandlerBucket(ref).untouch.push(fn);
                _send('inst.watchTouch', { ref });
            };
            if (prop === 'onClick') return (fn) => {
                if (typeof fn !== 'function') return;
                _instHandlerBucket(ref).click.push(fn);
                _send('inst.watchClick', { ref });
            };
            // Задача 14: findOne(ref).onInteract(fn, {text,distance,key,holdDuration})
            if (prop === 'onInteract') return (fn, opts) => {
                if (typeof fn !== 'function') return;
                _instHandlerBucket(ref).interact.push(fn);
                _send('inst.registerInteract', {
                    ref,
                    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',
                    holdDuration: (opts && Number.isFinite(opts.holdDuration)) ? opts.holdDuration : 0,
                });
            };

            return undefined;
        },
        set(t, prop, value) {
            if (prop === 'name') {
                _send('inst.set', { ref, prop: 'name', value: String(value) });
                return true;
            }
            if (prop === 'parent') {
                const parentRef = value && typeof value === 'object'
                    ? (value.ref || value.toString())
                    : (value || null);
                _send('inst.setParent', { ref, parentRef });
                return true;
            }
            if (prop === 'position') {
                if (value && typeof value === 'object') {
                    const x = Number(value.x), y = Number(value.y), z = Number(value.z);
                    if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {
                        _send('scene.move', { ref, x, y, z });
                    }
                }
                return true;
            }
            if (prop === 'color') {
                _send('scene.setColor', { ref, color: String(value) });
                return true;
            }
            if (prop === 'scale') {
                // Равномерный визуальный масштаб объекта (1 = исходный размер).
                const k = Number(value);
                if (Number.isFinite(k) && k >= 0) _send('scene.setScale', { ref, scale: k });
                return true;
            }
            if (prop === 'transparency' || prop === 'opacity') {
                const v = Number(value);
                if (Number.isFinite(v)) {
                    const op = prop === 'transparency' ? (1 - v) : v;
                    _send('scene.setOpacity', { ref, value: op });
                }
                return true;
            }
            if (prop === 'visible') {
                _send('scene.setVisible', { ref, visible: !!value });
                return true;
            }
            if (prop === 'canCollide') {
                _send('scene.setCollide', { ref, collide: !!value });
                return true;
            }
            if (prop === 'material') {
                _send('scene.setMaterial', { ref, name: String(value) });
                return true;
            }
            return true;
        },
    });

    _instCache.set(ref, proxy);
    return proxy;
}

/**
 * Найти Instance по имени (или вернуть массив всех совпадающих).
 * Используется в game.scene.findOne/find.
 */
function _findInstancesByName(name) {
    const out = [];
    for (const arr of [_sceneIndex.models, _sceneIndex.primitives]) {
        for (const o of arr) {
            if (o.name === name) {
                const inst = _getOrCreateInstance(o.ref);
                if (inst) out.push(inst);
            }
        }
    }
    return out;
}

/**
 * Триггер событий изменения свойства (вызывается при дельте snapshot'а).
 */
function _emitInstChange(ref, prop, newVal, oldVal) {
    const evs = _instEvents.get(ref);
    if (!evs) return;
    for (const rec of evs.propChanged) {
        if (rec.prop === prop) {
            _safeCall2(rec.fn, [newVal, oldVal], 'inst.changed:' + prop);
        }
    }
}

/**
 * Триггер destroying — объект больше не в snapshot.
 */
function _emitInstDestroying(ref) {
    const evs = _instEvents.get(ref);
    if (evs) {
        for (const fn of evs.destroying) {
            _safeCall2(fn, [], 'inst.destroying');
        }
    }
    _instEvents.delete(ref);
    _instCache.delete(ref);
    _instLastValues.delete(ref);
    // Задача 10: снять биндинг лейбла удалённого объекта (иначе таймер утечёт).
    const b = _labelBindings.get(ref);
    if (b) {
        const i = _timers.findIndex(t => t.id === b.timerId);
        if (i >= 0) _timers.splice(i, 1);
        _labelBindings.delete(ref);
    }
}

/**
 * Сравнение нового snapshot со старым — детект дельт для событий.
 * Вызывается из обработчика 'sceneSnapshot' ПОСЛЕ обновления _sceneIndex.
 */
function _detectSnapshotDeltas() {
    const live = new Set();
    for (const arr of [_sceneIndex.blocks, _sceneIndex.models, _sceneIndex.primitives]) {
        for (const o of arr) {
            live.add(o.ref);
            if (!_instCache.has(o.ref)) continue;  // никто не подписан — пропустить детект
            const prev = _instLastValues.get(o.ref) || {};
            if (prev.x !== undefined && (prev.x !== o.x || prev.y !== o.y || prev.z !== o.z)) {
                _emitInstChange(o.ref, 'position',
                    { x: o.x, y: o.y, z: o.z },
                    { x: prev.x, y: prev.y, z: prev.z });
            }
            if (prev.name !== undefined && prev.name !== o.name) {
                _emitInstChange(o.ref, 'name', o.name, prev.name);
            }
            _instLastValues.set(o.ref, { x: o.x, y: o.y, z: o.z, name: o.name });
        }
    }
    // Объекты которых больше нет — emit destroying. Только для тех кто в кеше
    // и не появился в новом snapshot. Игнорим блоки — они не всегда в snapshot.
    for (const ref of [..._instCache.keys()]) {
        if (live.has(ref)) continue;
        const inst = _instCache.get(ref);
        if (inst && inst.kind === 'primitive') {
            // только примитивы — у них точно был snapshot до удаления
            _emitInstDestroying(ref);
        }
    }
}

// === Публичное 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',
                // Задача 14: hold-to-action — сколько секунд держать клавишу (0=мгновенно).
                holdDuration: (opts && Number.isFinite(opts.holdDuration)) ? opts.holdDuration : 0,
            });
        },
        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 });
            }
        },
        /**
         * Повернуть объект-носитель вокруг вертикальной оси Y на угол ry (радианы).
         *   game.onTick((dt) => { a += dt; game.self.rotate(a); });
         */
        rotate(ry) {
            const r = Number(ry);
            if (!Number.isFinite(r)) return;
            const k = _target.kind;
            const id = _target.id ?? _target.ref;
            _send('scene.rotate', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, rotationY: r });
        },
        rotateY(ry) { this.rotate(ry); },
        /** Показать/скрыть объект-носитель. */
        setVisible(vis) {
            const k = _target.kind;
            const id = _target.id ?? _target.ref;
            _send('scene.setVisible', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, visible: !!vis });
        },
        /** Включить/выключить столкновения объекта-носителя (проходимость). */
        setCollide(can) {
            const k = _target.kind;
            const id = _target.id ?? _target.ref;
            _send('scene.setCollide', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, canCollide: !!can });
        },
        /** Перекрасить объект-носитель (только примитив). */
        setColor(hex) {
            if (typeof hex !== 'string') return;
            const k = _target.kind;
            const id = _target.id ?? _target.ref;
            _send('scene.setColor', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, color: hex });
        },
        /** Повесить текст-метку над объектом-носителем (имя/HP). */
        setLabel(text, opts) {
            const k = _target.kind;
            const id = _target.id ?? _target.ref;
            const ref = (k && id != null) ? (k + ':' + id) : undefined;
            _send('scene.setLabel', { ref, text: String(text == null ? '' : text), opts: opts || {} });
        },
        /** Убрать метку с объекта-носителя. */
        clearLabel() {
            const k = _target.kind;
            const id = _target.id ?? _target.ref;
            const ref = (k && id != null) ? (k + ':' + id) : undefined;
            _send('scene.clearLabel', { ref });
        },
        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 });
            }
        },
        /** Заблокировать/разблокировать управление игроком (WASD/прыжок). */
        setInputBlocked(blocked) {
            _send('player.setInputBlocked', { blocked: !!blocked });
        },
        /** Сдвинуть игрока ТОЛЬКО по 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 });
        },
        /**
         * Множитель гравитации. 1 = норма (-22 м/с²), 1.23 = GD-стиль (-27 м/с²).
         * Работает в обоих направлениях gravityDir.
         */
        setGravityMul(mul) {
            const m = Number(mul);
            if (Number.isFinite(m) && m > 0) _send('player.setGravityMul', { mul: m });
        },
        /**
         * GD-гейммод Ship: тап-удержание = подъём (вертолёт-стиль).
         * Обычный jump-импульс отключается, корабль управляется только Space.
         */
        setShipMode(enabled) {
            _send('player.setShipMode', { enabled: !!enabled });
        },
        /**
         * GD-гейммод UFO: каждый отдельный тап Space = микропрыжок в воздухе
         * (даже без касания земли). Обычный прыжок отключается.
         */
        setUfoMode(enabled) {
            _send('player.setUfoMode', { enabled: !!enabled });
        },
        /**
         * GD-гейммод Wave: движение жёстко под ±45°.
         * Space зажат → vy = +autoRunSpeed; отпущен → vy = -autoRunSpeed.
         * Гравитация и прыжок отключены.
         */
        setWaveMode(enabled) {
            _send('player.setWaveMode', { enabled: !!enabled });
        },
        /**
         * GD-гейммод Robot: высота прыжка зависит от длительности удержания Space.
         * Тап = низкий прыжок (~1.5м), удержание 0.35с = высокий (~4м). Прыжок только с земли.
         */
        setRobotMode(enabled) {
            _send('player.setRobotMode', { enabled: !!enabled });
        },
        /**
         * Двойной прыжок (true/false) — второй прыжок в воздухе.
         */
        setDoubleJump(enabled) {
            _send('player.setDoubleJump', { enabled: !!enabled });
        },
        /**
         * Проиграть эмоцию-анимацию персонажа один раз.
         * name: 'wave' (помахать) | 'dance' (танец) | 'cheer' (радость) | 'sit' (сесть).
         *   game.onKey('e', () => game.player.playAnimation('wave'));
         */
        playAnimation(name) {
            if (typeof name !== 'string') return;
            _send('player.playAnimation', { name });
        },
        /** Прервать текущую эмоцию персонажа. */
        stopAnimation() {
            _send('player.stopAnimation', {});
        },
        /**
         * Скользкость поверхности под игроком. 0 = нормальное движение
         * (мгновенная остановка), 0.85 = «лёд» (скользит после отпускания
         * клавиш). 1 = полностью скользко (инерция бесконечная).
         */
        setIceFriction(value) {
            const v = Number(value);
            if (Number.isFinite(v)) {
                _send('player.setIceFriction', { value: v });
            }
        },
        /**
         * Кубикон Dash: авто-бег по +X со скоростью speed (м/с).
         * Передай 0 чтобы отключить. Работает только в sideview-камере —
         * иначе скрипт сразу после autoRun() должен звать setCameraMode('sideview').
         * Пример: game.player.setAutoRun(8); game.player.setCameraMode('sideview');
         */
        setAutoRun(speed) {
            const s = Number(speed);
            if (Number.isFinite(s)) _send('player.setAutoRun', { speed: s });
        },
        /**
         * Мгновенный подброс игрока вверх. strength=1 = обычный прыжок,
         * strength=2 = в 2 раза выше. Не требует Space — срабатывает сразу.
         * Используется для трамплинов в Кубикон Dash.
         */
        boostJump(strength) {
            const s = Number(strength);
            if (Number.isFinite(s) && s > 0) _send('player.boostJump', { strength: s });
        },
        /**
         * Кубикон Dash: перевернуть гравитацию (как blue orb / gravity portal в GD).
         * После flipGravity игрок прыгает к потолку. Повторный вызов возвращает.
         * Доступно только в sideview-режиме.
         */
        flipGravity() {
            _send('player.flipGravity', {});
        },
        /**
         * Задать вертикальную скорость игрока (м/с). +значение = вверх, - = вниз.
         * Используется для трамплинов (vy=16), jump orb (vy=14), boost-зон и т.д.
         * Не зависит от _shipMode/_waveMode/_robotMode — просто перезаписывает _vy.
         */
        setVy(vy) {
            const v = Number(vy);
            if (Number.isFinite(v)) _send('player.setVy', { vy: v });
        },
        /**
         * Явно установить направление гравитации: 1 = вниз (норма), -1 = вверх.
         */
        setGravityDir(dir) {
            const d = Number(dir);
            if (d === 1 || d === -1) _send('player.setGravityDir', { dir: d });
        },
        /**
         * Показать/скрыть основной скин игрока. Используется в Кубикон Dash:
         * скрываем человечка, рисуем куб-примитив через скрипт.
         */
        setSkinVisible(visible) {
            _send('player.setSkinVisible', { visible: !!visible });
        },
        /**
         * === Задача 07: скины игрока (любая 3D-модель + магазин) ===
         * Сменить активный скин в Play (без перезагрузки сцены).
         *   game.player.setSkin('squirrel-donut');  // встроенный
         *   game.player.setSkin('character-a');      // человек
         * Возвращает «локальный Promise» (объект с .then) — реальная смена
         * асинхронна (грузится .glb). Для большинства игр можно не ждать.
         */
        setSkin(slug) {
            if (typeof slug !== 'string' || !slug) return;
            _currentSkin = slug;
            if (_unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug);
            _send('player.setSkin', { slug });
        },
        /** Дать игроку скин (разблокировать — например после покупки). */
        unlockSkin(slug) {
            if (typeof slug !== 'string' || !slug) return;
            if (_unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug);
            _send('player.unlockSkin', { slug });
        },
        /** Список slug'ов скинов, доступных игроку (разблокированных). */
        getAvailableSkins() {
            return _unlockedSkins.slice();
        },
        /** Все встроенные скины с метаданными: [{slug,name,kind,category,price}]. */
        getAllSkins() {
            return _skinsIndex.map(s => ({ ...s }));
        },
        /** Текущий активный скин (slug). */
        getCurrentSkin() {
            return _currentSkin;
        },
        /** Подписка на смену скина: fn(slug). */
        onSkinChange(fn) {
            if (typeof fn === 'function') _skinChangeHandlers.push(fn);
        },
        /** Открыть встроенный GUI-магазин скинов (если включён в проекте). */
        openSkinShop() {
            _send('player.openSkinShop', {});
        },
        /** Закрыть магазин скинов. */
        closeSkinShop() {
            _send('player.closeSkinShop', {});
        },
        /** Игровая валюта магазина скинов (рублики проекта, ЛОКАЛЬНЫЕ —
         *  не путать с серверной экономикой game.economy). */
        getSkinCoins() {
            return _skinCoins;
        },
        /** Задать баланс валюты магазина (например стартовые 200). */
        setSkinCoins(amount) {
            const n = Number(amount);
            if (!Number.isFinite(n)) return;
            _skinCoins = Math.max(0, Math.floor(n));
            _send('player.setSkinCoins', { amount: _skinCoins });
        },
        /** Добавить валюту магазина (награда за что-то). */
        addSkinCoins(amount) {
            const n = Number(amount);
            if (!Number.isFinite(n)) return;
            _skinCoins = Math.max(0, _skinCoins + Math.floor(n));
            _send('player.setSkinCoins', { amount: _skinCoins });
        },
        /**
         * Режим камеры: 'first' | 'third' | 'front' | 'sideview'.
         * 'sideview' нужен для Кубикон Dash — камера фиксируется сбоку,
         * yaw/pitch от мыши/тача игнорируются.
         */
        setCameraMode(mode) {
            if (typeof mode !== 'string') return;
            _send('player.setCameraMode', { mode });
        },
        /** Задача 02: установить дистанцию камеры (для third-person). */
        setCameraZoom(distance) {
            const d = Number(distance);
            if (!Number.isFinite(d)) return;
            _send('player.setCameraZoom', { distance: d });
        },
        /** Задача 02: границы зума колесом мыши. По умолчанию 0.5..32. */
        setCameraZoomLimits(min, max) {
            const mn = Number(min), mx = Number(max);
            if (!Number.isFinite(mn) || !Number.isFinite(mx)) return;
            _send('player.setCameraZoomLimits', { min: mn, max: mx });
        },
        /** Задача 02: Shift-Lock — курсор в центре, корпус лицом к камере. */
        setShiftLock(on) {
            _send('player.setShiftLock', { on: !!on });
        },
        /**
         * Присед: уменьшает высоту хитбокса игрока с 1.8 до 0.9 ед.
         * Используется чтобы пройти под низким потолком.
         */
        setCrouch(enabled) {
            _send('player.setCrouch', { enabled: !!enabled });
        },
        /**
         * Назначить активную точку возрождения. При respawn / смерти
         * игрок появится здесь. Аргумент:
         *   - ref объекта ('primitive:N' / 'model:N' / 'block:x,y,z') —
         *     игрок встанет НАД этим объектом;
         *   - объект {x, y, z} — точные координаты.
         *   game.self.onInteract(() => game.player.setSpawn(game.self.ref));
         */
        setSpawn(target) {
            if (typeof target === 'string' && target) {
                _send('player.setSpawn', { ref: target });
            } else if (target && typeof target === 'object'
                && Number.isFinite(Number(target.x))) {
                _send('player.setSpawn', {
                    x: Number(target.x),
                    y: Number(target.y),
                    z: Number(target.z),
                });
            }
        },
        /**
         * Дать игроку инструмент/оружие в инвентарь (Фаза 4.2).
         * toolType — id модели ('weapon-sword', 'blaster-blaster-a', ...)
         *   или произвольное имя предмета.
         * opts: { name, equip:true (сразу взять в руки), params }.
         * Оружие (blaster-* / weapon-*) получает kind='weapon' + параметры
         * боя; прочее — kind='tool'.
         *   game.player.giveTool('blaster-blaster-a', { equip: true });
         */
        giveTool(toolType, opts) {
            // Phase 6.4: принимаем и Tool-объект (из game.tools.create), и строку.
            if (toolType && typeof toolType === 'object' && toolType.id) {
                _send('inventory.give', {
                    kind: toolType.kind || 'tool',
                    modelTypeId: toolType.modelTypeId || null,
                    name: toolType.name,
                    customToolId: toolType.id,   // для роутинга onActivated
                    params: {},
                    equip: opts?.equip === true,
                });
                return;
            }
            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) {
        ref = _normRef(ref);
            if (!ref || !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);
    },
    /** Задача 14: игрок сел в машину. fn(vehicleRef). */
    onVehicleEnter(fn) {
        if (typeof fn === 'function') _vehicleEnterHandlers.push(fn);
    },
    /** Задача 14: игрок вышел из машины. fn(vehicleRef). */
    onVehicleExit(fn) {
        if (typeof fn === 'function') _vehicleExitHandlers.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 });
    },

    /**
     * Phase 6.6: RemoteEvent — именованные сетевые события (как в Roblox).
     *   const ev = game.remote.create('PlayerShoot');
     *   ev.fireAllClients({ x: 10, y: 5 });
     *   ev.fireClient(targetSid, { ... });
     *   ev.fireOthers({ ... });
     *   ev.on(({ from, data }) => { ... });   // получить событие от любого клиента
     * В одиночке (без kubikon-realtime) команды fire* идут в /dev/null,
     * .on срабатывает только когда события придут с сервера в мультиплеере.
     */
    remote: {
        create(name) {
            const evName = String(name || '');
            return {
                get name() { return evName; },
                fireAllClients(data) {
                    _send('mp.remoteFire', { name: evName, target: 'all', data });
                },
                fireOthers(data) {
                    _send('mp.remoteFire', { name: evName, target: 'others', data });
                },
                fireClient(player, data) {
                    const sid = typeof player === 'string'
                        ? player
                        : (player && player.sessionId);
                    if (!sid) return;
                    _send('mp.remoteFire', { name: evName, target: sid, data });
                },
                on(fn) {
                    if (typeof fn !== 'function') return;
                    (_remoteHandlers[evName] = _remoteHandlers[evName] || []).push(fn);
                },
            };
        },
    },
    /**
     * Подписаться на изменение 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 });
            },
            /** Алиас remove. */
            delete(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';
            // Шорткаты — позволяем писать просто 'billboard' / 'cube' / 'trigger' и т.п.
            // вместо 'primitive:billboard'. Если нет двоеточия — это шорткат
            // на primitive:<type>.
            if (type.indexOf(':') < 0) type = 'primitive:' + type;
            const x = Number(opts.x) || 0;
            const y = Number(opts.y) || 0;
            const z = Number(opts.z) || 0;
            const colon = type.indexOf(':');
            if (colon < 0) return null;
            const kind = type.slice(0, colon);
            const subType = type.slice(colon + 1);
            if (kind === 'block') {
                const ix = Math.round(x), iy = Math.round(y), iz = Math.round(z);
                const ref = 'block:' + ix + ',' + iy + ',' + iz;
                // color — для окрашиваемых блоков (studs-block, задача 09).
                _send('scene.spawn', { kind: 'block', subType, x: ix, y: iy, z: iz, ref, color: opts.color });
                if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime);
                // 6.2: возвращаем Instance вместо строки (он coerces в строку через toString).
                return _getOrCreateInstance(ref, 'block') || 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,
                    rotationX: opts.rotationX,
                    rotationY: opts.rotationY,
                    rotationZ: opts.rotationZ,
                    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,
                    // Billboard-специфичные (template/content/face/elements)
                    template: opts.template,
                    content: opts.content,
                    face: opts.face,
                    elements: opts.elements,
                    ref,
                });
                if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime);
                // 6.2: Instance вместо строки.
                return _getOrCreateInstance(ref, kind) || ref;
            }
            // Пользовательская модель из воксельного редактора моделей.
            // type = 'user:<id>', где <id> — числовой id модели в проекте.
            // ref пользовательских инстансов в сцене — 'usermodel:<id>'.
            if (kind === 'user') {
                _localRefSeq++;
                const ref = 'usermodel:_local_' + _localRefSeq;
                _send('scene.spawn', {
                    kind: 'userModel',
                    // subType — это полная строка 'user:<id>' (как принимает
                    // UserModelManager.addInstance). Восстанавливаем её.
                    subType: 'user:' + subType,
                    x, y, z,
                    rotationY: opts.rotationY,
                    // scale — воксельная модель мелкая (VOXEL_SIZE 0.0625);
                    // для placement обычно нужен крупнее. Прокидываем в addInstance.
                    scale: opts.scale,
                    name: opts.name,
                    ref,
                });
                if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime);
                return ref;
            }
            // Задача 14: машина. type = 'vehicle:car' (subType сейчас не важен).
            if (kind === 'vehicle') {
                _localRefSeq++;
                const ref = 'vehicle:_local_' + _localRefSeq;
                _send('scene.spawn', {
                    kind: 'vehicle', subType,
                    model: opts.model || 'car-sedan', color: opts.color, name: opts.name,
                    params: opts.params || {},
                    x, y, z, rotationY: opts.rotationY || 0, ref,
                });
                return _getOrCreateInstance(ref, 'vehicle') || ref;
            }
            return null;
        },
        /** Удалить объект по ref. */
        delete(ref) {
            ref = _normRef(ref);
            if (!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) {
            ref = _normRef(ref);
            if (!ref) 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) {
            ref = _normRef(ref);
            if (!ref) 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) {
            ref = _normRef(ref);
            if (!ref) 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) {
            ref = _normRef(ref);
            if (!ref) 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) {
            ref = _normRef(ref);
            if (!ref) 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) {
            ref = _normRef(ref);
            if (!ref || 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) {
            ref = _normRef(ref);
            if (!ref) return;
            _send('scene.setLabel', {
                ref,
                text: String(text == null ? '' : text),
                opts: opts || {},
            });
        },
        /** Убрать метку с объекта. */
        clearLabel(ref) {
            ref = _normRef(ref);
            if (!ref) return;
            this.unbindLabel(ref);
            _send('scene.clearLabel', { ref });
        },
        /**
         * Привязать плашку к функции — текст обновляется автоматически раз в
         * interval секунд (по умолчанию 0.5). setLabel шлётся только при
         * ИЗМЕНЕНИИ текста (диф-чек). Биндинг автоотменяется при clearLabel/
         * scene.delete(ref). Возвращает ref (для unbindLabel).
         *   game.scene.bindLabel(chest, () => game.save_cache.coins + ' монет',
         *                        { preset: 'gameui', interval: 0.5 });
         */
        bindLabel(ref, fn, opts) {
            ref = _normRef(ref);
            if (!ref || typeof fn !== 'function') return null;
            opts = opts || {};
            this.unbindLabel(ref);   // снять прошлый биндинг этого объекта
            const interval = Number.isFinite(opts.interval) && opts.interval > 0 ? opts.interval : 0.5;
            const sceneApi = this;
            const tick = () => {
                let txt;
                try { txt = String(fn() == null ? '' : fn()); }
                catch (e) { return; }
                const b = _labelBindings.get(ref);
                if (b && b.lastText === txt) return;     // диф-чек: текст не изменился
                if (b) b.lastText = txt;
                _send('scene.setLabel', { ref, text: txt, opts });
            };
            const id = ++_timerSeq;
            _timers.push({ id, fn: tick, delay: interval, elapsed: interval, repeat: true });
            _labelBindings.set(ref, { timerId: id, lastText: null });
            tick();   // сразу первый рендер (не ждать interval)
            return ref;
        },
        /** Снять биндинг плашки (остановить автообновление). */
        unbindLabel(ref) {
            ref = _normRef(ref);
            if (!ref) return;
            const b = _labelBindings.get(ref);
            if (!b) return;
            const i = _timers.findIndex(t => t.id === b.timerId);
            if (i >= 0) _timers.splice(i, 1);
            _labelBindings.delete(ref);
        },
        /**
         * Плашка-таймер обратного отсчёта до endTs (мс epoch). Формат hh:mm:ss/
         * mm:ss/auto. onEnd зовётся при достижении 0.
         *   game.scene.bindTimer(tower, Date.now()+16*60*1000,
         *       { format:'mm:ss', prefix:'Сбросится через ', preset:'gameui',
         *         onEnd: () => game.log('сброс!') });
         */
        bindTimer(ref, endTs, opts) {
            ref = _normRef(ref);
            if (!ref) return null;
            opts = opts || {};
            const end = Number(endTs);
            const fmt = opts.format || 'auto';
            const prefix = opts.prefix || '';
            const suffix = opts.suffix || '';
            let ended = false;
            return this.bindLabel(ref, () => {
                const sec = Math.max(0, Math.floor((end - Date.now()) / 1000));
                if (sec <= 0 && !ended) {
                    ended = true;
                    if (typeof opts.onEnd === 'function') { try { opts.onEnd(); } catch (e) {} }
                }
                return prefix + game.format.time(sec, fmt) + suffix;
            }, { interval: opts.interval || 1, ...opts });
        },
        /**
         * Прозрачность примитива: 1 = непрозрачно, 0 = полностью невидимо.
         * Для плавного исчезновения используй game.tween(ref, {opacity: 0}, ...).
         */
        setOpacity(ref, value) {
            ref = _normRef(ref);
            if (!ref) 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) {
            ref = _normRef(ref);
            if (!ref) 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) {
            ref = _normRef(ref);
            if (!ref || 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) {
            ref = _normRef(ref);
            if (!ref) 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) {
            ref = _normRef(ref);
            if (!ref || typeof dataUrl !== 'string') return;
            const colon = ref.indexOf(':');
            if (colon < 0) return;
            const kind = ref.slice(0, colon);
            const id = ref.slice(colon + 1);
            if (kind !== 'primitive') return;
            _send('scene.setTexture', { id, dataUrl });
        },
        /**
         * Установить АБСОЛЮТНЫЙ угол поворота папки вокруг точки pivot (XZ).
         * Все примитивы внутри папки повернутся как единое целое.
         *   game.scene.setFolderYaw('Голова куклы', Math.PI, { x: 0, z: 90 });
         */
        setFolderYaw(folderName, angle, pivot) {
            if (typeof folderName !== 'string') return;
            const a = Number(angle);
            if (!Number.isFinite(a)) return;
            if (!pivot || !Number.isFinite(Number(pivot.x))
                || !Number.isFinite(Number(pivot.z))) return;
            _send('scene.setFolderYaw', {
                folderName,
                angle: a,
                pivot: { x: Number(pivot.x), z: Number(pivot.z) },
            });
        },
        /**
         * Найти объекты по name. Возвращает массив Instance-прокси
         * (Фаза 6.2 -- раньше возвращал string[]; Instance coerces в строку,
         * поэтому старые scene.setColor продолжают работать).
         */
        find(name) {
            const out = [];
            const n = String(name || '').toLowerCase();
            for (const m of _sceneIndex.models) {
                if (m.name && String(m.name).toLowerCase() === n) {
                    out.push(_getOrCreateInstance(m.ref, 'model'));
                }
            }
            for (const p of _sceneIndex.primitives) {
                if (p.name && String(p.name).toLowerCase() === n) {
                    out.push(_getOrCreateInstance(p.ref, 'primitive'));
                }
            }
            return out;
        },
        /** Первый объект с таким name или null. */
        findOne(name) {
            const arr = this.find(name);
            return arr.length > 0 ? arr[0] : null;
        },
        /** Список Instance всех объектов заданного типа: 'block' | 'model' | 'primitive'. */
        all(kind) {
            if (kind === 'block') return _sceneIndex.blocks.map(b => _getOrCreateInstance(b.ref, 'block'));
            if (kind === 'model') return _sceneIndex.models.map(m => _getOrCreateInstance(m.ref, 'model'));
            if (kind === 'primitive') return _sceneIndex.primitives.map(p => _getOrCreateInstance(p.ref, 'primitive'));
            return [];
        },
        /**
         * Сохранить произвольное значение НА объекте (атрибут).
         * Видно всем скриптам — скрипт двери ставит, скрипт ключа читает.
         *   game.scene.setData(door, 'locked', true);
         *   game.scene.setData(chest, 'gold', 100);
         */
        setData(ref, key, value) {
            // 6.2: принимаем и Instance-прокси (он coerces в строку через toString).
            const r = (ref && typeof ref === 'object') ? (ref.ref || String(ref)) : ref;
            if (typeof r !== 'string' || typeof key !== 'string') return;
            // оптимистично обновляем локальное зеркало (до прихода снапшота)
            if (!_dataIndex[r]) _dataIndex[r] = {};
            _dataIndex[r][key] = value;
            _send('scene.setData', { ref: r, key, value });
        },
        /**
         * Прочитать атрибут объекта. Возвращает значение или undefined.
         *   if (game.scene.getData(door, 'locked')) { ... }
         */
        getData(ref, key) {
            const r = (ref && typeof ref === 'object') ? (ref.ref || String(ref)) : ref;
            if (typeof r !== 'string' || typeof key !== 'string') return undefined;
            const bag = _dataIndex[r];
            return bag ? bag[key] : undefined;
        },
        // === Небо и атмосфера (задача 16) ===
        /**
         * Установить небо. Либо пресет, либо ручной gradient:
         *   game.scene.setSkybox({ preset: 'lowpoly-roblox' });
         *   game.scene.setSkybox({ mode:'gradient', topColor:'#4a90e2', bottomColor:'#cfd8dc' });
         * Пресеты: clear-summer-day / lowpoly-roblox / cloudy / sunset / starry-night / space.
         */
        setSkybox(opts) { _send('scene.setSkybox', { opts: opts || {} }); },
        /**
         * Облака поверх неба:
         *   game.scene.setClouds({ enabled:true, cover:0.5, speed:0.02, color:'#ffffff' });
         */
        setClouds(opts) { _send('scene.setClouds', { opts: opts || {} }); },
        /**
         * Атмосферный туман:
         *   game.scene.setFog({ color:'#dddddd', density:0.005 });
         *   game.scene.setFog({ enabled:false });
         */
        setFog(opts) { _send('scene.setFog', { opts: opts || {} }); },
        /** Объект управления небом: плавный переход + солнце. */
        skybox: {
            /** Плавный переход к пресету за N секунд: skybox.fadeTo({preset:'sunset'}, 2). */
            fadeTo(opts, durationSec) { _send('scene.skyboxFadeTo', { opts: opts || {}, duration: Number(durationSec) || 2 }); },
            /** Направление солнца (для анимации дуги): setSunDirection({x,y,z}). */
            setSunDirection(dir) { _send('scene.skyboxSunDir', { dir: dir || {} }); },
        },
        /**
         * Теги объектов (Фаза 5.6) — как CollectionService в Roblox.
         * Помечаешь объекты тегом, потом находишь все объекты с тегом.
         *   game.scene.tag(enemy, 'враг');
         *   for (const e of game.scene.getTagged('враг')) { ... }
         */
        tag(ref, tag) {
            ref = _normRef(ref);
            if (!ref || 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) {
            ref = _normRef(ref);
            if (!ref || 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) {
            ref = _normRef(ref);
            if (!ref || 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) {
            ref = _normRef(ref);
            if (!ref) 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: {
        /**
         * Phase 6.5: переключить примитив в Rapier-режим физики.
         * bodyType: 'dynamic' (падает, катится) | 'static' (не двигается)
         *         | 'kinematic' (скриптом двигается, физика учитывает столкновения)
         *         | 'none' (отключить Rapier для этого объекта)
         * @example
         *   const cube = game.scene.spawn('primitive:cube', {x:0, y:10, z:0});
         *   game.physics.setBodyType(cube, 'dynamic', { mass: 5, friction: 0.3 });
         *   // куб падает и катится по физ-движку!
         */
        setBodyType(ref, bodyType, opts) {
            ref = _normRef(ref);
            if (!ref) return;
            _send('physics.setBodyType', {
                ref, bodyType: String(bodyType || 'dynamic'),
                mass: opts && Number.isFinite(opts.mass) ? Number(opts.mass) : undefined,
                friction: opts && Number.isFinite(opts.friction) ? Number(opts.friction) : undefined,
                restitution: opts && Number.isFinite(opts.restitution) ? Number(opts.restitution) : undefined,
            });
        },

        /**
         * Phase 6.5: настоящий импульс через Rapier (только для dynamic-тел).
         * В отличие от старого applyImpulse (kinematic), этот делает реальное
         * физическое толкание.
         */
        applyImpulseV2(ref, ix, iy, iz) {
            ref = _normRef(ref);
            if (!ref) return;
            _send('physics.applyImpulseV2', {
                ref, ix: Number(ix) || 0, iy: Number(iy) || 0, iz: Number(iz) || 0,
            });
        },

        setVelocityV2(ref, vx, vy, vz) {
            ref = _normRef(ref);
            if (!ref) return;
            _send('physics.setVelocityV2', {
                ref, vx: Number(vx) || 0, vy: Number(vy) || 0, vz: Number(vz) || 0,
            });
        },

        /**
         * Пустить луч из точки 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) {
            ref = _normRef(ref);
            if (!ref || !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) {
            ref = _normRef(ref);
            if (!ref || !impulse) return;
            _send('physics.applyImpulse', {
                ref,
                ix: Number(impulse.x) || 0, iy: Number(impulse.y) || 0, iz: Number(impulse.z) || 0,
            });
        },
        /**
         * Взрыв в точке: визуальный эффект + урон игроку и мобам в радиусе.
         *   game.physics.explode({ x, y, z }, 5, { damage: 40 });
         * opts: { damage: 30, force: 0 }  — урон и сила отброса.
         */
        explode(pos, radius, opts) {
            if (!pos) return;
            opts = opts || {};
            _send('physics.explode', {
                x: Number(pos.x) || 0, y: Number(pos.y) || 0, z: Number(pos.z) || 0,
                radius: Number(radius) || 3,
                damage: Number.isFinite(Number(opts.damage)) ? Number(opts.damage) : 30,
                force: Number(opts.force) || 0,
            });
        },
        /**
         * Проходимость объекта или группы (Фаза 5.9, collision groups).
         * target — ref объекта ИЛИ тег (тогда применяется ко ВСЕМ объектам
         * с этим тегом — теги работают как collision groups).
         * on=true — игрок проходит сквозь (объект остаётся видимым),
         * on=false — снова твёрдый.
         *   game.physics.passThrough(wall, true);        // одна стена
         *   game.physics.passThrough('призраки', true);  // вся группа по тегу
         */
        passThrough(target, on) {
            if (typeof target !== 'string' || !target) return;
            _send('physics.passThrough', { target, on: !!on });
        },
    },

    /**
     * GUI — управление 2D-интерфейсом (Frame/Text/Button/Image) из скриптов.
     */
    gui: {
        /** Найти ID элемента по имени. Возвращает строку или null. */
        find(name) {
            if (typeof name !== 'string') return null;
            const n = name.toLowerCase();
            for (const g of _guiIndex) {
                if (g.name && String(g.name).toLowerCase() === n) return g.id;
            }
            return null;
        },
        /** Список ID всех элементов. */
        all() {
            return _guiIndex.map(g => g.id);
        },
        /** Получить копию свойств элемента. */
        get(id) {
            if (typeof id !== 'string') return null;
            const found = _guiIndex.find(g => g.id === id);
            return found ? { ...found } : null;
        },
        /**
         * Изменить свойства элемента.
         *   game.gui.update('gui_xxx', { text: 'Hi', textColor: '#ff0' });
         */
        update(id, patch) {
            if (typeof id !== 'string' || !patch || typeof patch !== 'object') return;
            _send('gui.update', { id, patch });
        },
        /** Сделать элемент видимым. */
        show(id) {
            if (typeof id !== 'string') return;
            _send('gui.update', { id, patch: { visible: true } });
        },
        /** Скрыть элемент (но не удалять). */
        hide(id) {
            if (typeof id !== 'string') return;
            _send('gui.update', { id, patch: { visible: false } });
        },
        /**
         * Создать новый элемент. Возвращает локальный ref-id (строку).
         *   const id = game.gui.create('text', { x: 50, y: 10, text: 'HP: 100' });
         */
        create(type, opts) {
            if (typeof type !== 'string') return null;
            _localRefSeq++;
            const localRef = '_gui_local_' + _localRefSeq;
            _send('gui.create', { type, opts: opts || {}, localRef });
            return localRef;
        },
        /** Удалить элемент по id. */
        remove(id) {
            if (typeof id !== 'string') return;
            _send('gui.remove', { id });
        },
        /**
         * Подписаться на клик по кнопке (по id).
         *   game.gui.onClick('gui_xxx', () => { game.log('clicked!'); });
         */
        onClick(id, fn) {
            if (typeof id !== 'string' || typeof fn !== 'function') return;
            (_guiClickHandlers[id] = _guiClickHandlers[id] || []).push(fn);
        },
        /**
         * Подписаться на ввод в поле TextBox — срабатывает когда игрок
         * нажал Enter. fn получает введённый текст.
         *   game.gui.onSubmit('gui_name', (text) => {
         *     game.ui.showText('Привет, ' + text);
         *   });
         */
        onSubmit(id, fn) {
            if (typeof id !== 'string' || typeof fn !== 'function') return;
            (_guiSubmitHandlers[id] = _guiSubmitHandlers[id] || []).push(fn);
        },
        /** Задача 03: tween свойства GUI-элемента.
         *  props: { x, y, w, h, rotation, scaleX, scaleY, bgOpacity, textSize,
         *           bgColor, textColor, borderColor } (любое числовое или hex-цвет).
         *  opts:  { duration, easing, delay, repeat, reverses, onDone } */
        tween(id, props, opts) {
            if (typeof id !== 'string' || !id) return null;
            if (!props || typeof props !== 'object') return null;
            opts = opts || {};
            const tid = ++_tweenSeq;
            if (typeof opts.onDone === 'function') _tweenCallbacks[tid] = opts.onDone;
            _send('gui.tween', {
                tweenId: tid, id, props,
                duration: Number.isFinite(Number(opts.duration)) ? Number(opts.duration) : 0.5,
                easing: typeof opts.easing === 'string' ? opts.easing : 'ease',
                delay: Number.isFinite(Number(opts.delay)) ? Number(opts.delay) : 0,
                repeat: Number.isFinite(Number(opts.repeat)) ? Number(opts.repeat) : 0,
                reverses: !!opts.reverses,
            });
            return tid;
        },
        /** Отменить tween по id (возвращённому из game.gui.tween). */
        cancelTween(tweenId) {
            if (!Number.isFinite(tweenId)) return;
            _send('gui.cancelTween', { tweenId });
            delete _tweenCallbacks[tweenId];
        },
    },
    /**
     * Камера — тряска, FOV, привязка к объекту, катсцены (Фаза 5.7).
     */
    camera: {
        /**
         * Тряска камеры. amp в метрах (0.1 = чуть-чуть, 0.5 = сильно),
         * dur в секундах. Затухает к 0.
         */
        shake(amp, dur) {
            const a = Number(amp), d = Number(dur);
            if (!Number.isFinite(a) || !Number.isFinite(d) || a <= 0 || d <= 0) return;
            _send('camera.shake', { amp: a, dur: d });
        },
        /**
         * Угол обзора камеры (FOV) в градусах. 70 — норма, 90 — широкий,
         * 40 — «зум». Диапазон 10..130.
         *   game.camera.setFov(90);
         */
        setFov(degrees) {
            const d = Number(degrees);
            if (Number.isFinite(d)) _send('camera.fov', { degrees: d });
        },
        /**
         * Привязать камеру к объекту — она следит за ним.
         * ref — объект сцены. opts: { distance, height } — отступ камеры.
         *   game.camera.focusOn(bossRef, { distance: 12, height: 6 });
         */
        focusOn(ref, opts) {
            ref = _normRef(ref);
            if (!ref) 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', {});
        },
    },
    /**
     * Главное меню игры (задача 13) — оркестратор поверх camera.cutscene + gui
     * + audio + loading. Показывает живую 3D-сцену с cinematic-камерой, GUI
     * (логотип/патч-ноуты/кнопка ИГРАТЬ) и музыкой; клик ИГРАТЬ → переход в игру.
     *
     *   game.mainMenu.show({
     *     camera: { mode:'orbit', center:{x:0,y:1,z:0}, radius:6, height:2, duration:12 },
     *     patchNotes: { version:'3.10', title:'DISPATCH CENTER', items:['NEW ...','2 ...'] },
     *     title: 'ГАРАЖ БОССА',
     *     music: 'epoch_01_main',
     *     playButtonText: 'ИГРАТЬ',
     *     onPlay: () => { game.mainMenu.hide(); game.player.teleport(0,2,0); },
     *   });
     */
    mainMenu: {
        _onShow: [], _onPlay: [], _onHide: [],
        _active: false,
        _curMusic: null,
        _opts: null,
        _camCfg: null,
        _loopArmed: false,
        show(opts) {
            opts = opts && typeof opts === 'object' ? opts : {};
            this._opts = opts;
            this._active = true;
            // Колбэки можно передавать прямо в опциях show({ onPlay, onShow, onHide }).
            if (typeof opts.onPlay === 'function') this._onPlay.push(opts.onPlay);
            if (typeof opts.onShow === 'function') this._onShow.push(opts.onShow);
            if (typeof opts.onHide === 'function') this._onHide.push(opts.onHide);
            // 1) Заблокировать управление игроком (наблюдатель).
            _send('player.setInputBlocked', { blocked: true });
            game.hud.setVisible(false);
            // 2) Камера-катсцена по выбранному режиму (с зацикливанием).
            this._camCfg = opts.camera || { mode: 'orbit', center: { x: 0, y: 1, z: 0 }, radius: 6, height: 2, duration: 12 };
            this.setCamera(this._camCfg);
            // Зацикливаем облёт: когда катсцена доиграла и меню ещё активно —
            // запускаем её заново. Иначе камера отпускается к игроку (за стену).
            if (!this._loopArmed) {
                this._loopArmed = true;
                const self = this;
                game.onCutsceneDone(() => {
                    if (self._active && self._camCfg) self.setCamera(self._camCfg);
                });
            }
            // 3) GUI меню: логотип + патч-ноуты + кнопка ИГРАТЬ.
            this._buildGui(opts);
            // 4) Музыка на лупе.
            if (opts.music && typeof opts.music === 'string') {
                this._curMusic = opts.music;
                _send('audio.playMusic', { trackId: opts.music });
            }
            for (const fn of this._onShow) _safeCall(fn, undefined, 'mainMenu.onShow');
        },
        /** Перестроить камеру меню. mode: cinematic | orbit | static | preset-cuts. */
        setCamera(cam) {
            cam = cam && typeof cam === 'object' ? cam : {};
            if (this._active) this._camCfg = cam;   // запомнить для зацикливания
            const mode = cam.mode || 'orbit';
            const dur = Number.isFinite(Number(cam.duration)) ? Number(cam.duration) : 12;
            if (mode === 'static') {
                const p = cam.position || { x: 0, y: 5, z: 8 };
                const t = cam.target || { x: 0, y: 1, z: 0 };
                // Две одинаковые точки = неподвижная камера.
                _send('camera.cutscene', { points: [p, p], lookAt: [t, t], segDuration: 9999 });
                return;
            }
            if (mode === 'orbit') {
                const c = cam.center || { x: 0, y: 1, z: 0 };
                const r = Number.isFinite(Number(cam.radius)) ? Number(cam.radius) : 6;
                const h = Number.isFinite(Number(cam.height)) ? Number(cam.height) : 2;
                const N = 16;
                const pts = [], looks = [];
                for (let i = 0; i <= N; i++) {
                    const a = (i / N) * Math.PI * 2;
                    pts.push({ x: c.x + Math.cos(a) * r, y: c.y + h, z: c.z + Math.sin(a) * r });
                    looks.push({ x: c.x, y: c.y, z: c.z });
                }
                _send('camera.cutscene', { points: pts, lookAt: looks, segDuration: dur / N });
                return;
            }
            if (mode === 'preset-cuts') {
                const cuts = Array.isArray(cam.cuts) ? cam.cuts : [];
                if (cuts.length < 1) return;
                // Дублируем каждую точку (резкая «выдержка» segDuration), без плавного пролёта.
                const pts = [], looks = [];
                for (const c of cuts) {
                    const p = c.position || { x: 0, y: 3, z: 6 };
                    const t = c.target || { x: 0, y: 1, z: 0 };
                    pts.push(p, p); looks.push(t, t);
                }
                _send('camera.cutscene', { points: pts, lookAt: looks, segDuration: (cuts[0].duration || 2) });
                return;
            }
            // cinematic — пролёт по waypoints с плавным lerp.
            const wps = Array.isArray(cam.waypoints) ? cam.waypoints : [];
            if (wps.length < 2) return;
            const pts = wps.map(w => w.position || { x: 0, y: 2, z: 0 });
            const looks = wps.map(w => w.target || { x: 0, y: 1, z: 0 });
            _send('camera.cutscene', { points: pts, lookAt: looks, segDuration: dur / Math.max(1, pts.length - 1) });
        },
        /** Карточка патч-нот в меню (заголовок версии + bullet-список). */
        setPatchNotes(pn) {
            pn = pn && typeof pn === 'object' ? pn : {};
            const title = (pn.title || '') + (pn.version ? ' (' + pn.version + '):' : ':');
            const items = Array.isArray(pn.items) ? pn.items : [];
            // Заголовок.
            game.gui.create('text', {
                id: '_mm_pn_title', x: 78, y: 28, w: 40, h: 6, anchor: 'center',
                text: title, textColor: '#ffffff', textSize: 26, fontWeight: 900,
                textStroke: { color: '#000', width: 3 }, bgColor: 'transparent', bgOpacity: 0,
            });
            // Пункты списком.
            items.slice(0, 7).forEach((it, i) => {
                game.gui.create('text', {
                    id: '_mm_pn_' + i, x: 78, y: 36 + i * 6, w: 40, h: 5, anchor: 'center',
                    text: '- ' + it, textColor: '#e8edf5', textSize: 20, fontWeight: 700,
                    textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0,
                });
            });
        },
        _buildGui(opts) {
            // Логотип-заголовок сверху.
            if (opts.title) {
                game.gui.create('text', {
                    id: '_mm_logo', x: 30, y: 12, w: 56, h: 12, anchor: 'center',
                    text: String(opts.title), textColor: '#ffd23a', textSize: 48, fontWeight: 900,
                    textStroke: { color: '#000', width: 4 }, bgColor: 'transparent', bgOpacity: 0,
                    animationPreset: 'glow',
                });
            }
            // Патч-ноуты справа.
            if (opts.patchNotes) this.setPatchNotes(opts.patchNotes);
            // Кнопка ИГРАТЬ снизу-справа.
            game.gui.create('button', {
                id: '_mm_play', x: 84, y: 90, w: 28, h: 11, anchor: 'center',
                text: opts.playButtonText || 'ИГРАТЬ',
                bgGradient: { stops: ['#ffe066', '#e0a000'], angle: 90 },
                textColor: '#3a2a00', textSize: 30, fontWeight: 900, borderRadius: 14,
                textStroke: { color: '#fff7d0', width: 1 },
                hover: { scale: 1.06, brightness: 1.1 }, active: { scale: 0.96 },
            });
            const self = this;
            game.gui.onClick('_mm_play', () => {
                for (const fn of self._onPlay) _safeCall(fn, undefined, 'mainMenu.onPlay');
            });
        },
        /** Скрыть меню: убрать GUI, остановить музыку, вернуть камеру и управление. */
        hide() {
            if (!this._active) return;
            this._active = false;
            this._camCfg = null;   // остановить зацикливание облёта
            const ids = ['_mm_logo', '_mm_play', '_mm_pn_title', '_mm_pn_0', '_mm_pn_1', '_mm_pn_2', '_mm_pn_3', '_mm_pn_4', '_mm_pn_5', '_mm_pn_6'];
            for (const id of ids) { try { game.gui.remove(id); } catch (e) {} }
            if (this._curMusic) { _send('audio.stopMusic', {}); this._curMusic = null; }
            _send('camera.reset', {});
            _send('player.setInputBlocked', { blocked: false });
            game.hud.setVisible(true);
            for (const fn of this._onHide) _safeCall(fn, undefined, 'mainMenu.onHide');
        },
        onShow(fn) { if (typeof fn === 'function') this._onShow.push(fn); },
        onPlay(fn) { if (typeof fn === 'function') this._onPlay.push(fn); },
        onHide(fn) { if (typeof fn === 'function') this._onHide.push(fn); },
        isActive() { return this._active; },
    },
    /**
     * Управление стандартным HUD движка (HP-бар, hotbar, кнопка меню, чат).
     * Нужно для игр которые делают свой UI через game.gui.* и не хотят
     * чтобы стандартные элементы мешали.
     */
    hud: {
        /** Скрыть/показать ВСЕ стандартные HUD-элементы. */
        setVisible(visible) {
            _send('hud.setVisible', { visible: !!visible });
        },
        /** Скрыть/показать только хотбар (5 слотов инвентаря снизу).
         *  В играх где инвентарь не нужен (магазин/головоломка/симулятор кликера). */
        setHotbarVisible(visible) {
            _send('hud.setHotbarVisible', { visible: !!visible });
        },
        /** Скрыть/показать только HP-индикатор (полоска жизней слева сверху). */
        setHpVisible(visible) {
            _send('hud.setHpVisible', { visible: !!visible });
        },
    },
    /**
     * Задача 04: модальные сцены (затемнение + GUI поверх + блок ввода).
     *
     * Типовой кейс: boss-fight intro, открытие лутбокса, диалог с NPC, получил питомца.
     *
     *   const m = game.modal.open({
     *       darken: 0.7,              // 0..1 — насколько затемнить (по умолчанию 0.5)
     *       darkenColor: '#000',      // цвет затемнения
     *       target: 'scene',          // 'scene' (HUD виден) | 'screen' (всё затемнено)
     *       blockInput: true,         // блокирует WASD/Space/Ctrl (Esc/Tab/Enter работают)
     *       freezeCamera: true,       // камера замирает
     *       fadeIn: 0.4,              // секунды до полного затемнения
     *       fadeOut: 0.3,
     *       spotlights: [boss, h1, h2],  // 3D-рефы, которые остаются яркими (вырезка mask)
     *       spotlightRadius: 120,     // пиксели — радиус «прожектора»
     *       pauseSimulation: false,   // ставит весь GameRuntime на паузу (мобы замирают)
     *       muteWorld: false,         // приглушает ambient/sfx
     *       cameraOverride: {         // фокус камеры на цель
     *           target: boss, distance: 8, height: 3, fov: 60, duration: 0.5,
     *       },
     *       content: { elements: [    // временные GUI поверх модала, удалятся при close
     *           { kind: 'text', x: 50, y: 25, text: 'Франкенслим', textSize: 48,
     *             textStroke: { color: '#000', width: 3 }, textColor: '#fff' },
     *           { kind: 'button', id: 'fight', x: 50, y: 80, w: 20, h: 6, text: 'В бой!' },
     *       ]},
     *   });
     *   game.gui.onClick('fight', () => game.modal.close(m));
     *
     * Готовые пресеты:
     *   game.modal.bossIntro(name, hp, refs)          — intro босса с HP-баром
     *   game.modal.lootbox(items, onPick)             — открытие лутбокса
     *   game.modal.dialog(npcName, lines, onDone)     — диалог с NPC построчно
     *   game.modal.confirmation(title, body, onYes, onNo)  — Да/Нет
     *
     * Только ОДИН модал одновременно. Повторный open мгновенно закрывает предыдущий.
     */
    modal: {
        _localSeq: 0,
        _localToReal: new Map(),  // localId → реальный modalId (приходит в modalOpened)
        _onCloseFns: [],
        open(opts) {
            opts = opts || {};
            const localId = ++this._localSeq;
            const replyId = '_mopen_' + localId;
            _send('modal.open', { opts, replyId });
            // Возвращаем локальный id — реальный придёт асинхронно через modalOpened-event
            return localId;
        },
        close(modalId) {
            // Резолвим локальный id → реальный. Если modalId — локальное число, но
            // реальный ещё не пришёл (гонка modalOpened-event), шлём null: модал
            // одиночный, null закрывает активный. Передавать локальный id нельзя —
            // ModalManager.close сверяет его со своим _state.id и молча игнорит.
            let real = null;
            if (typeof modalId === 'number') {
                real = this._localToReal.has(modalId) ? this._localToReal.get(modalId) : null;
            } else if (modalId != null) {
                real = modalId;  // уже реальный id (строка/число от runtime)
            }
            _send('modal.close', { modalId: real });
        },
        update(modalId, patch) {
            let real = null;
            if (typeof modalId === 'number') {
                real = this._localToReal.has(modalId) ? this._localToReal.get(modalId) : null;
            } else if (modalId != null) {
                real = modalId;
            }
            _send('modal.update', { modalId: real, patch: patch || {} });
        },
        isOpen() { return !!this._isOpenLocal; },
        onClose(fn) {
            if (typeof fn === 'function') this._onCloseFns.push(fn);
        },

        // === Пресеты ===
        /** Intro босса с именем + HP-баром + кнопкой «В бой!» через delay секунд. */
        bossIntro(name, hp, refs, opts) {
            opts = opts || {};
            const startBtnDelay = Number.isFinite(opts.startBtnDelay) ? opts.startBtnDelay : 2;
            const buttonText = opts.buttonText || 'В бой!';
            const onStart = opts.onStart;
            const elements = [
                { kind: 'text', id: '_boss_name', x: 50, y: 22, w: 60, h: 8, anchor: 'center',
                  text: String(name || 'Босс'), textSize: 48, fontWeight: 900, textColor: '#ffffff',
                  textStroke: { color: '#000', width: 3 }, bgColor: 'transparent', bgOpacity: 0,
                  animationPreset: 'glow' },
                { kind: 'text', id: '_boss_hp', x: 50, y: 30, w: 30, h: 6, anchor: 'center',
                  text: '❤ ' + hp + ' / ' + hp, textSize: 28, fontWeight: 800, textColor: '#22ff66',
                  textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0 },
            ];
            const m = this.open({
                darken: 0.7, target: 'scene',
                blockInput: true, freezeCamera: true,
                spotlights: Array.isArray(refs) ? refs : (refs ? [refs] : []),
                cameraOverride: refs ? { target: Array.isArray(refs) ? refs[0] : refs,
                    distance: 8, height: 3, fov: 60, duration: 0.5 } : null,
                content: { elements },
            });
            const _modal = this;
            const _afterTid = ++_timerSeq;
            _timers.push({ id: _afterTid, fn: () => {
                _send('gui.create', { type: 'button', opts: {
                    id: '_boss_start', x: 50, y: 78, w: 20, h: 7, anchor: 'center',
                    text: buttonText,
                    bgGradient: { stops: ['#ff5454','#a00000'], angle: 90 },
                    borderColor: '#000', borderWidth: 3, borderRadius: 14,
                    textColor: '#fff', textSize: 22, fontWeight: 900,
                    textStroke: { color: '#000', width: 2 },
                    hover: { scale: 1.08, brightness: 1.2, duration: 0.15 },
                    active: { scale: 0.94, duration: 0.08 },
                    animationPreset: 'pulse',
                }, localRef: '_boss_start' });
                let _started = false;
                _guiClickHandlers['_boss_start'] = [() => {
                    if (_started) return;
                    _started = true;
                    delete _guiClickHandlers['_boss_start'];
                    _modal.close(m);
                    if (typeof onStart === 'function') { try { onStart(); } catch (e) {} }
                }];
            }, delay: startBtnDelay, elapsed: 0, repeat: false });
            return m;
        },
        /** Открытие лутбокса. items: [{name, color, icon, rarity}]. onPick(item). */
        lootbox(items, onPick) {
            items = Array.isArray(items) ? items.slice(0, 5) : [];
            const elements = [
                { kind: 'frame', id: '_lb_bg', x: 50, y: 50, w: 70, h: 50, anchor: 'center',
                  bgGradient: { stops: ['#1a1a3a','#0a0a1a'], angle: 135 },
                  borderColor: '#ffd700', borderWidth: 3, borderRadius: 18 },
                { kind: 'text', id: '_lb_title', x: 50, y: 28, w: 60, h: 8, anchor: 'center',
                  text: '✨ Лутбокс ✨', textSize: 32, fontWeight: 900, textColor: '#ffd700',
                  textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0,
                  animationPreset: 'glow' },
            ];
            for (let i = 0; i < items.length; i++) {
                const it = items[i];
                const x = 50 + (i - (items.length - 1) / 2) * 13;
                elements.push({
                    kind: 'button', id: '_lb_item_' + i,
                    x: x, y: 50, w: 11, h: 16, anchor: 'center',
                    text: (it.icon || '*') + '\\n' + (it.name || 'Приз'),
                    bgColor: it.color || '#3a3a5a', borderRadius: 12,
                    borderColor: '#ffd700', borderWidth: 2,
                    textColor: '#fff', textSize: 14, fontWeight: 700,
                    hover: { scale: 1.1, brightness: 1.3, duration: 0.15 },
                    active: { scale: 0.94, duration: 0.08 },
                    animationPreset: 'pulse',
                });
            }
            const m = this.open({
                darken: 0.6, target: 'screen', blockInput: true,
                content: { elements },
            });
            const _modal = this;
            // _picked: после первого выбора остальные карточки не должны срабатывать,
            // пока модал доигрывает fadeOut (иначе onPick зовётся несколько раз).
            let _picked = false;
            for (let i = 0; i < items.length; i++) {
                const id = '_lb_item_' + i;
                const it = items[i];
                _guiClickHandlers[id] = [() => {
                    if (_picked) return;
                    _picked = true;
                    for (let j = 0; j < items.length; j++) delete _guiClickHandlers['_lb_item_' + j];
                    _modal.close(m);
                    if (typeof onPick === 'function') { try { onPick(it); } catch (e) {} }
                }];
            }
            return m;
        },
        /** Диалог с NPC построчно. lines: ['Привет', 'Как дела?']. onDone() в конце. */
        dialog(npcName, lines, onDone) {
            lines = Array.isArray(lines) ? lines : [String(lines || '')];
            let idx = 0;
            const elements = [
                { kind: 'frame', id: '_dlg_bg', x: 50, y: 80, w: 80, h: 25, anchor: 'center',
                  bgGradient: { stops: ['#1a1a3a','#0a0a1a'], angle: 90 },
                  borderColor: '#fff', borderWidth: 2, borderRadius: 12 },
                { kind: 'text', id: '_dlg_name', x: 14, y: 71, w: 22, h: 6, anchor: 'center',
                  text: String(npcName || 'NPC'), textSize: 20, fontWeight: 900,
                  textColor: '#ffd700', textStroke: { color: '#000', width: 2 },
                  bgColor: 'transparent', bgOpacity: 0 },
                { kind: 'text', id: '_dlg_line', x: 50, y: 80, w: 76, h: 12, anchor: 'center',
                  text: lines[0], textSize: 22, fontWeight: 600, textColor: '#fff',
                  textAlign: 'left', bgColor: 'transparent', bgOpacity: 0 },
                { kind: 'button', id: '_dlg_next', x: 88, y: 88, w: 8, h: 6, anchor: 'center',
                  // На ПОСЛЕДНЕЙ строке кнопка сразу показывает галочку «завершить»,
                  // на остальных — стрелку «дальше».
                  text: lines.length > 1 ? '▶' : '✓', textSize: 22, fontWeight: 900,
                  bgColor: '#ffd700', textColor: '#000', borderRadius: 8,
                  borderColor: '#000', borderWidth: 2,
                  hover: { scale: 1.1, brightness: 1.2 }, active: { scale: 0.92 },
                  animationPreset: 'pulse' },
            ];
            const m = this.open({
                darken: 0.5, target: 'scene', blockInput: true, freezeCamera: true,
                content: { elements },
            });
            const _modal = this;
            // _done защищает от повторного срабатывания: game.modal.close() доигрывает
            // fadeOut асинхронно, кнопка остаётся кликабельной — без guard'а каждый
            // лишний клик снова звал onDone (баг «Диалог завершён ×7»).
            let _done = false;
            _guiClickHandlers['_dlg_next'] = [() => {
                if (_done) return;
                idx++;
                if (idx < lines.length) {
                    _send('gui.update', { id: '_dlg_line', patch: { text: lines[idx] } });
                    // Последняя строка достигнута — превращаем «дальше» в «завершить».
                    if (idx === lines.length - 1) {
                        _send('gui.update', { id: '_dlg_next', patch: { text: '✓' } });
                    }
                } else {
                    _done = true;
                    delete _guiClickHandlers['_dlg_next'];  // снимаем handler сразу
                    _modal.close(m);
                    if (typeof onDone === 'function') { try { onDone(); } catch (e) {} }
                }
            }];
            return m;
        },
        /** Подтверждение Да/Нет. */
        confirmation(title, body, onYes, onNo) {
            const elements = [
                { kind: 'frame', id: '_cf_bg', x: 50, y: 50, w: 50, h: 30, anchor: 'center',
                  bgGradient: { stops: ['#2a2a4a','#0a0a1a'], angle: 135 },
                  borderColor: '#fff', borderWidth: 2, borderRadius: 14 },
                { kind: 'text', id: '_cf_title', x: 50, y: 41, w: 44, h: 6, anchor: 'center',
                  text: String(title || 'Подтверждение'), textSize: 28, fontWeight: 900,
                  textColor: '#fff', textStroke: { color: '#000', width: 2 },
                  bgColor: 'transparent', bgOpacity: 0 },
                { kind: 'text', id: '_cf_body', x: 50, y: 50, w: 44, h: 8, anchor: 'center',
                  text: String(body || ''), textSize: 16, fontWeight: 500,
                  textColor: '#cfd0e8', bgColor: 'transparent', bgOpacity: 0 },
                { kind: 'button', id: '_cf_yes', x: 38, y: 60, w: 14, h: 6, anchor: 'center',
                  text: 'Да', bgGradient: { stops: ['#22ff66','#0a803a'], angle: 90 },
                  borderColor: '#000', borderWidth: 2, borderRadius: 10,
                  textColor: '#fff', textSize: 18, fontWeight: 900,
                  hover: { scale: 1.08, brightness: 1.2 }, active: { scale: 0.94 } },
                { kind: 'button', id: '_cf_no', x: 62, y: 60, w: 14, h: 6, anchor: 'center',
                  text: 'Нет', bgGradient: { stops: ['#ff5454','#a00000'], angle: 90 },
                  borderColor: '#000', borderWidth: 2, borderRadius: 10,
                  textColor: '#fff', textSize: 18, fontWeight: 900,
                  hover: { scale: 1.08, brightness: 1.2 }, active: { scale: 0.94 } },
            ];
            const m = this.open({
                darken: 0.6, target: 'screen', blockInput: true,
                content: { elements },
            });
            const _modal = this;
            // _answered: одна кнопка снимает обе (Да/Нет) сразу, чтобы пока модал
            // доигрывает fadeOut нельзя было нажать вторую и продублировать ответ.
            let _answered = false;
            const _finish = (cb) => {
                if (_answered) return;
                _answered = true;
                delete _guiClickHandlers['_cf_yes'];
                delete _guiClickHandlers['_cf_no'];
                _modal.close(m);
                if (typeof cb === 'function') { try { cb(); } catch (e) {} }
            };
            _guiClickHandlers['_cf_yes'] = [() => _finish(onYes)];
            _guiClickHandlers['_cf_no']  = [() => _finish(onNo)];
            return m;
        },
    },
    /**
     * Экран загрузки (задача 12) — программный mid-game transition.
     *   const lo = game.loading.show({ progressBar:true, spinner:true });
     *   lo.setProgress(0.5); lo.close();
     *   await game.loading.transition({ cover:{sceneSnapshot:true}, duration:4 });
     * Хэндл возвращается синхронно (локальный id). Колбэки onSkip/onComplete
     * приходят через globalEvent (loadingSkip/loadingComplete) — см. ниже.
     */
    loading: {
        _localSeq: 0,
        _localToReal: new Map(),     // localId → реальный loadingId (приходит в loadingShown)
        _handlers: new Map(),        // localId → { onSkip:[], onComplete:[] }
        _onHide: [],                 // задача 05 — глобальные подписки на скрытие
        show(opts) {
            opts = opts && typeof opts === 'object' ? opts : {};
            const localId = ++this._localSeq;
            const replyId = '_lshow_' + localId;
            const h = { onSkip: [], onComplete: [] };
            if (typeof opts.onSkip === 'function') h.onSkip.push(opts.onSkip);
            if (typeof opts.onComplete === 'function') h.onComplete.push(opts.onComplete);
            this._handlers.set(localId, h);
            // Функции нельзя слать в main — вырезаем перед _send.
            const safe = {};
            for (const k in opts) { if (typeof opts[k] !== 'function') safe[k] = opts[k]; }
            _send('loading.show', { opts: safe, replyId });
            const self = this;
            return {
                _localId: localId,
                setProgress(v) { _send('loading.setProgress', { localId, value: Number(v) || 0 }); },
                setText(t) { _send('loading.setText', { localId, text: String(t == null ? '' : t) }); },
                setCover(c) { _send('loading.setCover', { localId, cover: c }); },
                setBackground(b) { _send('loading.setBackground', { localId, background: b }); },
                close() { _send('loading.close', { localId }); },
                onSkip(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onSkip.push(fn); },
                onComplete(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onComplete.push(fn); },
            };
        },
        // --- Задача 05: управление активным экраном без хэндла (стартовый/любой текущий) ---
        /** Подписаться на скрытие экрана загрузки. */
        onHide(fn) { if (typeof fn === 'function') this._onHide.push(fn); },
        /** Сменить фоновое изображение текущего экрана. */
        setBackground(b) { _send('loading.setBackground', { background: b }); },
        /** Сменить текст текущего экрана. */
        setText(t) { _send('loading.setText', { text: String(t == null ? '' : t) }); },
        /** Сменить cover текущего экрана. */
        setCover(c) { _send('loading.setCover', { cover: c }); },
        /** Ручной прогресс текущего экрана. */
        setProgress(v) { _send('loading.setProgress', { value: Number(v) || 0 }); },
        /** Скрыть текущий экран. */
        hide() { _send('loading.close', {}); },
        /** Виден ли экран сейчас (синхронно из локального зеркала). */
        isVisible() { return !!_loadingVisible; },
        /** Типовой переход с фейковым прогрессом. Возвращает Promise (резолв по complete/skip). */
        transition(opts) {
            opts = opts && typeof opts === 'object' ? { ...opts } : {};
            if (!Number.isFinite(opts.duration) || opts.duration <= 0) opts.duration = 3;
            const self = this;
            return new Promise((resolve) => {
                const h = self.show(opts);
                let done = false;
                const finish = () => { if (done) return; done = true; resolve(); };
                h.onComplete(finish);
                h.onSkip(finish);
            });
        },
    },
    /**
     * Инвентарь игрока (Фаза 4.2) — 5 слотов hot-bar.
     *   game.inventory.add({ name: 'Зелье', kind: 'item' })
     *   game.inventory.has('Зелье')   — по имени или modelTypeId
     *   game.inventory.remove('Зелье')
     *   game.inventory.list()         — массив предметов
     *   game.inventory.clear()
     */
    /**
     * Phase 6.4: game.tools -- кастомные tool с собственным onActivated.
     * В отличие от game.player.giveTool() (готовое оружие из палитры),
     * tools.create() позволяет сделать СВОЙ инструмент с любой логикой
     * (удочка/лопата/фонарик/мегафон).
     *
     *   const sword = game.tools.create('Меч огня', {
     *       model: 'weapon-sword',          // 3D-модель из палитры (опц.)
     *       cooldown: 0.5,                   // мин. интервал между активациями
     *       params: { damage: 50 },          // произвольные параметры
     *   });
     *   sword.onActivated((ctx) => {
     *       // ctx = { tool, point, target } -- что под прицелом
     *       if (ctx.target) game.scene.killMob(ctx.target.ref);
     *   });
     *   sword.onEquipped((ctx) => game.ui.set('held', sword.name));
     *   sword.onUnequipped(() => game.ui.remove('held'));
     *   game.player.giveTool(sword);   // дать игроку в инвентарь
     */
    tools: {
        create(name, opts) {
            opts = opts || {};
            _toolSeq++;
            const toolId = 'custom:' + _toolSeq;
            _toolCallbacks[toolId] = {};
            const tool = {
                get id() { return toolId; },
                get name() { return String(name || ('Tool ' + _toolSeq)); },
                get modelTypeId() { return opts.model || null; },
                get kind() { return opts.kind || 'tool'; },
                /** ЛКМ когда tool активен в hotbar. */
                onActivated(fn) {
                    if (typeof fn === 'function') _toolCallbacks[toolId].activated = fn;
                },
                /** Игрок взял tool в руки (выбрал слот). */
                onEquipped(fn) {
                    if (typeof fn === 'function') _toolCallbacks[toolId].equipped = fn;
                },
                /** Игрок убрал tool из рук (сменил слот). */
                onUnequipped(fn) {
                    if (typeof fn === 'function') _toolCallbacks[toolId].unequipped = fn;
                },
                /** Уронить tool на землю как pickup-объект. */
                dropAt(pos) {
                    if (!pos || typeof pos !== 'object') return;
                    _send('tools.drop', {
                        toolId,
                        name: String(name),
                        model: opts.model || null,
                        params: opts.params || {},
                        x: Number(pos.x) || 0,
                        y: Number(pos.y) || 0,
                        z: Number(pos.z) || 0,
                    });
                },
            };
            return tool;
        },
    },

    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', {});
        },
        // === Задача 44: drag-drop инвентарь (сетка 8×5 + hotbar 9 + стаки) ===
        /** Добавить предмет по itemId со стаком. game.inventory.give('berry', 5). */
        give(itemId, count) { _send('inv2.add', { itemId, count: Number(count) || 1 }); },
        /** Убрать N предметов по itemId. */
        take(itemId, count) { _send('inv2.remove', { itemId, count: Number(count) || 1 }); },
        /** Открыть/закрыть/тоггл окна инвентаря. */
        open() { _send('inv2.open', {}); },
        closeUi() { _send('inv2.close', {}); },
        toggle() { _send('inv2.toggle', {}); },
        /** Сортировать (by: 'rarity'|'name'). */
        sort(by) { _send('inv2.sort', { by: by || 'rarity' }); },
        /** Активный слот хотбара (0..8). */
        setActiveHotbar(i) { _send('inv2.setActive', { i: Number(i) || 0 }); },
    },

    // === Определения предметов (задача 44) ===
    items: {
        /** Зарегистрировать предмет: {id,name,emoji|icon,rarity,maxStack,description,value,tags,onUseEffect}. */
        define(def) {
            if (Array.isArray(def)) { for (const d of def) _send('items.define', { def: d }); return; }
            _send('items.define', { def: def || {} });
        },
    },
    /**
     * Игроки комнаты (Фаза 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;
        },
    },

    // === Лидерборды (leaderstats) — задача 20 ===
    leaderstats: {
        /** Зарегистрировать стат: define('Монеты', {initial,format,icon,color,primary}). */
        define(name, opts) {
            if (typeof name !== 'string' || !name) return;
            _send('leaderstats.define', { name, opts: opts || {} });
        },
        /** Установить стат игрока (playerId=null → текущий). */
        set(playerId, name, value) {
            _send('leaderstats.set', { playerId: playerId == null ? null : String(playerId), name, value: Number(value) || 0 });
            const pid = playerId == null ? '@me' : String(playerId);
            if (!_lsMirror[pid]) _lsMirror[pid] = {};
            _lsMirror[pid][name] = Number(value) || 0;
        },
        /** Прибавить к стату. */
        add(playerId, name, delta) {
            _send('leaderstats.add', { playerId: playerId == null ? null : String(playerId), name, delta: Number(delta) || 0 });
            const pid = playerId == null ? '@me' : String(playerId);
            if (!_lsMirror[pid]) _lsMirror[pid] = {};
            _lsMirror[pid][name] = (_lsMirror[pid][name] || 0) + (Number(delta) || 0);
        },
        /** Прочитать стат (из локального зеркала). */
        get(playerId, name) {
            const pid = playerId == null ? '@me' : String(playerId);
            return (_lsMirror[pid] && _lsMirror[pid][name]) || 0;
        },
        /** Подписка на изменение: onChange((playerId, name, newVal, oldVal) => {}). */
        onChange(fn) { if (typeof fn === 'function') _lsChangeHandlers.push(fn); },
        /** Шорткат для текущего игрока. */
        me: {
            set(name, value) { game.leaderstats.set(null, name, value); },
            add(name, delta) { game.leaderstats.add(null, name, delta); },
            get(name) { return game.leaderstats.get(null, name); },
        },
    },

    // === Достижения — задача 20 ===
    achievements: {
        /** Объявить достижения: define([{id,name,description,icon,rarity,points,hidden}]). */
        define(list) { _send('achievements.define', { list: Array.isArray(list) ? list : [list] }); },
        /** Разблокировать достижение. */
        unlock(id, playerId) {
            if (typeof id !== 'string') return;
            _achUnlocked[id] = true;
            _send('achievements.unlock', { id, playerId: playerId == null ? null : String(playerId) });
        },
        /** Разблокировано ли (из зеркала). */
        has(id) { return !!_achUnlocked[id]; },
        /** Авто-unlock по достижению значения leaderstat. */
        bindToStat(id, statName, cond) { _send('achievements.bindToStat', { id, statName, cond: cond || {} }); },
        /** Показать/скрыть кнопку-кубок. */
        setButtonVisible(v) { _send('achievements.setButtonVisible', { visible: !!v }); },
        /** Открыть страницу достижений. */
        openPage() { _send('achievements.openPage', {}); },
    },
    /**
     * Общее состояние комнаты (Фаза 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: {
        /**
         * Phase 6.5: настоящая физ-петля Rapier между двумя rigid bodies.
         * Тело A может вращаться вокруг axis относительно тела B.
         * Оба тела должны быть зарегистрированы через physics.setBodyType.
         * @example
         *   game.physics.setBodyType(door, 'dynamic');
         *   game.physics.setBodyType(post, 'static');
         *   game.constraints.physicsHinge(door, post, {
         *       anchorA: {x: 0, y: 0, z: 0},
         *       anchorB: {x: 1, y: 0, z: 0},
         *       axis: {x: 0, y: 1, z: 0},
         *   });
         */
        physicsHinge(refA, refB, opts) {
            refA = _normRef(refA); refB = _normRef(refB);
            if (!refA || !refB) return null;
            _constraintRefSeq++;
            const localRef = 'physJoint:_local_' + _constraintRefSeq;
            _send('physics.addJoint', {
                type: 'hinge', refA, refB, localRef,
                anchorA: opts?.anchorA, anchorB: opts?.anchorB, axis: opts?.axis,
            });
            return {
                get ref() { return localRef; },
                remove() { _send('physics.removeJoint', { localRef }); },
            };
        },
        /**
         * Phase 6.5: жёсткая дистанция между двумя rigid bodies (верёвка).
         */
        physicsDistance(refA, refB, opts) {
            refA = _normRef(refA); refB = _normRef(refB);
            if (!refA || !refB) return null;
            _constraintRefSeq++;
            const localRef = 'physJoint:_local_' + _constraintRefSeq;
            _send('physics.addJoint', {
                type: 'distance', refA, refB, localRef,
                anchorA: opts?.anchorA, anchorB: opts?.anchorB,
            });
            return {
                get ref() { return localRef; },
                remove() { _send('physics.removeJoint', { localRef }); },
            };
        },
        /**
         * Жёстко склеить объект 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) {
            ref = _normRef(ref);
            if (!ref) 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) {
            ref = _normRef(ref);
            if (!ref) 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: {
        /**
         * Всплывающая цифра урона (задача 40). position — {x,y,z} или ref
         * объекта; value — число или строка; opts — color/isCrit/isHeal/isMana/
         * isMiss/fontSize/floatHeight/lifetime/randomOffset/stackKey/comicStyle.
         *   game.fx.damageFloater(enemy.position, 25);
         *   game.fx.damageFloater(pos, 100, { isCrit: true });
         *   game.fx.damageFloater(pos, 30, { isHeal: true });
         */
        damageFloater(position, value, opts) {
            const pos = _normFxPoint(position);
            _send('fx.damageFloater', { position: pos, value, opts: opts || {} });
        },
        /**
         * Авто-floater'ы над мобами (NPC) при потере HP. Включил один раз — любой
         * урон по NPC сам показывает облачко «-N». game.fx.autoMobFloaters(true).
         */
        autoMobFloaters(enabled, opts) {
            _send('fx.autoMobFloaters', { enabled: enabled !== false, opts: opts || {} });
        },
        /**
         * Луч между двумя точками. opts: { from, to — {x,y,z} или ref
         * объекта (тогда луч следит за ним); color: '#hex', width }.
         *   game.fx.beam({ from: towerRef, to: {x:0,y:5,z:0}, color: '#ff3344' });
         */
        beam(opts) {
            opts = opts || {};
            _fxRefSeq++;
            const localRef = 'fx:_local_' + _fxRefSeq;
            // Задача 08: расширенные опции (текстура/curved/градиент/billboard).
            _send('fx.create', {
                kind: 'beam', localRef,
                from: _normFxPoint(opts.from), to: _normFxPoint(opts.to),
                color: typeof opts.color === 'string' ? opts.color : undefined,
                width: Number.isFinite(Number(opts.width)) ? Number(opts.width) : undefined,
                texture: opts.texture, customTextureUrl: opts.customTextureUrl,
                textureMode: opts.textureMode, textureSpeed: opts.textureSpeed,
                textureScale: opts.textureScale,
                strokeColor: opts.strokeColor, strokeWidth: opts.strokeWidth,
                colorSequence: opts.colorSequence,
                transparencySequence: opts.transparencySequence,
                widthSequence: opts.widthSequence,
                faceMode: opts.faceMode, segments: opts.segments,
                curved: opts.curved, curveHeight: opts.curveHeight,
                attachOffset: opts.attachOffset, ignoreDepth: opts.ignoreDepth,
            });
            return {
                get ref() { return localRef; },
                /** Сменить цвет луча. */
                setColor(color) {
                    _send('fx.beamColor', { ref: localRef, color });
                },
                /** Сменить концы луча ({x,y,z} или ref). */
                setEndpoints(from, to) {
                    _send('fx.beamEndpoints', { ref: localRef, from, to });
                },
                /** Изменить любые опции луча на лету. */
                update(o) {
                    _send('fx.beamUpdate', { ref: localRef, opts: o || {} });
                },
                hide() { _send('fx.beamVisible', { ref: localRef, visible: false }); },
                show() { _send('fx.beamVisible', { ref: localRef, visible: true }); },
                remove() { _send('fx.remove', { ref: localRef }); },
            };
        },
        /**
         * Стрелка-указатель «иди сюда» (бегущие шевроны). Задача 08.
         *   const arrow = game.fx.pointer({ from: 'player', to: cubeRef, preset: 'guide' });
         *   arrow.setTarget(otherRef); arrow.update({ preset: 'quest' }); arrow.remove();
         * preset: 'guide'|'quest'|'danger'|'gift'|'custom'.
         * from/to: 'player' | ref-объекта | {x,y,z}.
         */
        pointer(opts) {
            opts = opts || {};
            _fxRefSeq++;
            const localRef = 'fx:_local_' + _fxRefSeq;
            _send('fx.createPointer', {
                localRef,
                from: _normFxPoint(opts.from !== undefined ? opts.from : 'player'),
                to: _normFxPoint(opts.to),
                preset: opts.preset || 'guide',
                color: opts.color, texture: opts.texture,
                customTextureUrl: opts.customTextureUrl,
                textureSpeed: opts.textureSpeed, width: opts.width,
                strokeColor: opts.strokeColor, colorSequence: opts.colorSequence,
                curved: opts.curved, curveHeight: opts.curveHeight,
                faceMode: opts.faceMode, attachOffset: opts.attachOffset,
            });
            return {
                get ref() { return localRef; },
                setTarget(to) { _send('fx.pointerTarget', { ref: localRef, to: _normFxPoint(to) }); },
                update(o) { _send('fx.pointerUpdate', { ref: localRef, opts: o || {} }); },
                hide() { _send('fx.beamVisible', { ref: localRef, visible: false }); },
                show() { _send('fx.beamVisible', { ref: localRef, visible: true }); },
                remove() { _send('fx.remove', { ref: localRef }); },
            };
        },
        /**
         * Шлейф за объектом. ref — ref-строка объекта.
         * opts: { color: '#hex', width, lifetime (сек) }.
         *   game.fx.trail(ballRef, { color: '#ffcc44', lifetime: 2 });
         */
        trail(ref, opts) {
            ref = _normRef(ref);
            if (!ref) return null;
            opts = opts || {};
            _fxRefSeq++;
            const localRef = 'fx:_local_' + _fxRefSeq;
            _send('fx.create', {
                kind: 'trail', localRef, ref,
                color: typeof opts.color === 'string' ? opts.color : undefined,
                width: Number.isFinite(Number(opts.width)) ? Number(opts.width) : undefined,
                lifetime: Number.isFinite(Number(opts.lifetime)) ? Number(opts.lifetime) : undefined,
            });
            return {
                get ref() { return localRef; },
                remove() { _send('fx.remove', { ref: localRef }); },
            };
        },
    },
    /**
     * Звуки. Два вида:
     *   1. Встроенные пресеты: 'jump', 'pickup', 'win', 'lose', 'click',
     *      'hit', 'coin' — game.sound.play('jump').
     *   2. Свои загруженные (Фаза 5.5) — id вида 'sound_N', можно 3D:
     *      game.sound.play('sound_1', { at: {x,y,z} })   — 3D в точке
     *      game.sound.play('sound_1', { attach: doorRef }) — 3D у объекта
     *      game.sound.play('sound_1', { loop: true })    — зациклить
     */
    sound: {
        /**
         * Проиграть звук. id — пресет ('jump'...) или 'sound_N'.
         * opts: { volume: 0..1, loop, at: {x,y,z}, attach: ref-строка }.
         * Для пользовательского звука возвращает объект с методом stop().
         */
        play(id, opts) {
            if (typeof id !== 'string' || !id) return null;
            opts = opts || {};
            // Встроенный пресет — старый формат {name}.
            if (id.indexOf('sound_') !== 0) {
                _send('sound.play', {
                    name: id,
                    volume: Number.isFinite(Number(opts.volume)) ? Number(opts.volume) : 1,
                    pitch: Number.isFinite(Number(opts.pitch)) ? Number(opts.pitch) : 1,
                });
                return null;
            }
            // Пользовательский звук из библиотеки проекта.
            _soundRefSeq++;
            const localRef = 'sound:_local_' + _soundRefSeq;
            _send('sound.play', {
                soundId: id, localRef,
                volume: Number.isFinite(Number(opts.volume)) ? Number(opts.volume) : 1,
                loop: !!opts.loop,
                at: (opts.at && Number.isFinite(Number(opts.at.x)))
                    ? { x: Number(opts.at.x), y: Number(opts.at.y) || 0, z: Number(opts.at.z) || 0 }
                    : undefined,
                attachRef: typeof opts.attach === 'string' ? opts.attach : undefined,
            });
            return {
                get ref() { return localRef; },
                /** Остановить этот звук. */
                stop() { _send('sound.stop', { ref: localRef }); },
            };
        },
    },
    /**
     * Аудио — GD-музыка и SFX.
     *   game.audio.playSfx('jump')          — короткий звук (jump/death/orb_tap/...)
     *   game.audio.playMusic('epoch_01_main') — фоновая музыка (зацикленная)
     *   game.audio.stopMusic()
     *   game.audio.setMuted(true)
     */
    audio: {
        playSfx(name) {
            if (typeof name !== 'string') return;
            _send('audio.playSfx', { name });
        },
        playMusic(trackId) {
            if (typeof trackId !== 'string') return;
            _send('audio.playMusic', { trackId });
        },
        stopMusic() {
            _send('audio.stopMusic', {});
        },
        setMuted(muted) {
            _send('audio.setMuted', { muted: !!muted });
        },
    },
    /**
     * Экономика — алмазы и рейтинг через серверные API.
     * Все вызовы асинхронные (с callback), потому что идут через HTTP.
     *
     *   game.economy.reward('level_1_first_pass', function(res) {
     *     // res = { ok, already_awarded, diamonds, rating, ... }
     *   });
     *   game.economy.dailyCheck(function(res) { ... });
     *   game.economy.getBalance(function(res) {
     *     // res = { diamonds, rating }
     *   });
     */
    economy: {
        reward(achievementId, fn) {
            if (typeof achievementId !== 'string') return;
            const reqId = 'eco_rwd_' + (++_economyReqSeq);
            if (typeof fn === 'function') _economyCallbacks[reqId] = fn;
            _send('economy.reward', { reqId, achievementId });
        },
        dailyCheck(fn) {
            const reqId = 'eco_daily_' + (++_economyReqSeq);
            if (typeof fn === 'function') _economyCallbacks[reqId] = fn;
            _send('economy.dailyCheck', { reqId });
        },
        getBalance(fn) {
            const reqId = 'eco_bal_' + (++_economyReqSeq);
            if (typeof fn === 'function') _economyCallbacks[reqId] = fn;
            _send('economy.getBalance', { reqId });
        },
        spend(amount, reason, fn) {
            const reqId = 'eco_spend_' + (++_economyReqSeq);
            if (typeof fn === 'function') _economyCallbacks[reqId] = fn;
            _send('economy.spend', { reqId, amount: Number(amount) || 0, reason: String(reason || '') });
        },
    },
    /**
     * Billboard — 3D-таблички с GUI (как BillboardGui в Roblox).
     * Создаются через game.scene.spawn('billboard', {x,y,z, template, content}),
     * затем настраиваются через game.billboard.set/update.
     *
     * Пресеты (template):
     *   - 'shop-item'     — иконка + заголовок + sub-строка + кнопка цены
     *   - 'shop-purchase' — иконка + название + цена (для покупки)
     *   - 'banner'        — крупный текст
     *   - 'sign'          — простой указатель
     *
     * Пример (4 таблички-апгрейды):
     *   const refs = ['vis','range','saws','sprink'].map((kind, i) => {
     *     return game.scene.spawn('billboard', {
     *       x: -6 + i*4, y: 3, z: 5,
     *       template: 'shop-item',
     *       content: { icon: 'hammer', title: 'Апгрейд', sub: '1 > 2',
     *                  price: '$10,000', gradient: ['#ff5a5a','#ff8a3d'] },
     *     });
     *   });
     *   game.billboard.onClick(refs[0], 'buy', () => {
     *     game.ui.showText('Куплено!');
     *     game.billboard.update(refs[0], { sub: '2 > 3', price: '$20,000' });
     *   });
     */
    billboard: {
        /**
         * Полная замена контента таблички. Если пресет тот же — мгновенно
         * перерисует. Если template другой — пересоздаст текстуру.
         *   ref      — string-ref из game.scene.spawn() или game.scene.findOne()
         *   opts     — { template?, face?, content?, elements? }
         */
        set(ref, opts) {
            const refStr = _normRef(ref);
            if (!refStr || typeof opts !== 'object' || opts == null) return;
            _send('billboard.set', { ref: refStr, ...opts });
        },
        /**
         * Частичное обновление таблички.
         * Две формы:
         *   1) update(ref, patch)
         *      patch — частичный content: { sub, price, title, icon, gradient }
         *      Применяется к content пресета (shop-item/banner/sign).
         *   2) update(ref, elementId, patch)
         *      Обновляет конкретный элемент по id (только для template:'card'
         *      или elements-режима). Например: update(ref, 'buy', { text: '$15,000' }).
         *      Для пресетов: 'title'|'sub'|'price'|'icon'|'gradient' тоже
         *      работают как ключи content.
         */
        update(ref, secondArg, thirdArg) {
            const refStr = _normRef(ref);
            if (!refStr) return;
            // 3-аргументная форма: update(ref, elementId, patch)
            if (typeof secondArg === 'string' && typeof thirdArg === 'object' && thirdArg !== null) {
                _send('billboard.update', {
                    ref: refStr,
                    elementId: secondArg,
                    patch: thirdArg,
                });
                return;
            }
            // 2-аргументная форма: update(ref, patch)
            if (typeof secondArg === 'object' && secondArg !== null) {
                _send('billboard.update', { ref: refStr, patch: secondArg });
            }
        },
        /**
         * Подписаться на клик по кнопке таблички (shop-item: buttonId='buy';
         * в кастомных elements — id из элемента kind='button').
         *   ref      — string-ref
         *   buttonId — id кнопки (по умолчанию 'buy')
         *   fn       — () => void
         */
        onClick(ref, buttonId, fn) {
            if (typeof fn !== 'function') {
                fn = buttonId;
                buttonId = 'buy';
            }
            // Принудительная нормализация ref в plain-string: Instance-Proxy
            // не сериализуется через postMessage (DataCloneError).
            const refStr = _normRef(ref);
            if (!refStr || typeof fn !== 'function') return;
            const bid = String(buttonId || 'buy');
            const key = refStr + ':' + bid;
            if (!_billboardClickHandlers[key]) _billboardClickHandlers[key] = [];
            _billboardClickHandlers[key].push(fn);
            _send('billboard.onClick', { ref: refStr, buttonId: bid });
        },
    },
    /**
     * Управление режимами ввода — курсор и камера.
     * В режиме 'ui' мышь работает как обычный курсор (как в браузере),
     * не вращает камеру. Нужно для меню/инвентарей.
     */
    input: {
        /**
         * Установить cursor-режим: 'ui' = курсор-как-в-браузере,
         * 'game' = pointer-lock (мышь крутит камеру).
         */
        setCursorMode(mode) {
            if (mode !== 'ui' && mode !== 'game') return;
            _send('input.setCursorMode', { mode });
        },
        /** Задача 02: Roblox-style MouseBehavior.
         *  'default'     — свободный курсор (по умолчанию в third-person).
         *  'lockcenter'  — pointer-lock (мышь крутит камеру, курсор скрыт).
         *  'lockcurrent' — pointer-lock без скрытия курсора (визуально-default).
         */
        setMouseBehavior(mode) {
            if (mode !== 'default' && mode !== 'lockcenter' && mode !== 'lockcurrent') return;
            _send('input.setMouseBehavior', { mode });
        },
        /** Задача 02: показать/скрыть иконку курсора. */
        setMouseIconVisible(visible) {
            _send('input.setMouseIconVisible', { visible: !!visible });
        },
        /**
         * Подписаться на движение мыши в 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) {...});
     */
    /** Окружение: небо, туман, время суток. */
    environment: {
        /** Установить цвет неба (clearColor сцены). hex или 'r,g,b'. */
        setSkyColor(color) {
            if (typeof color !== 'string') return;
            _send('environment.setSkyColor', { color });
        },
        /** Установить туман: {enabled, color, density}. */
        setFog(opts) {
            if (typeof opts !== 'object' || !opts) return;
            _send('environment.setFog', opts);
        },
        /** Установить время суток (часы, 0..24). */
        setTimeOfDay(hours) {
            const h = Number(hours);
            if (!Number.isFinite(h)) return;
            _send('environment.setTimeOfDay', { hours: h });
        },
    },
    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);
    },

    /**
     * Хелперы форматирования (задача 10) — для счётчиков/таймеров в плашках.
     *   game.format.time(959, 'mm:ss')      → "15:59"
     *   game.format.time(57555, 'hh:mm:ss') → "15:59:15"
     *   game.format.time(959, 'auto')       → "15м 59с"
     *   game.format.number(1234567, 'short')→ "1.2M"
     *   game.format.number(1234567, 'comma')→ "1 234 567"
     *   game.format.number(0.42, 'percent') → "42%"
     *   game.format.money(199)              → "199 рубликов"
     *   game.format.duration(3600)          → "1 час"
     */
    format: {
        time(seconds, fmt) {
            let sec = Math.max(0, Math.floor(Number(seconds) || 0));
            const h = Math.floor(sec / 3600);
            const m = Math.floor((sec % 3600) / 60);
            const s = sec % 60;
            const p2 = (n) => String(n).padStart(2, '0');
            if (fmt === 'hh:mm:ss') return p2(h) + ':' + p2(m) + ':' + p2(s);
            if (fmt === 'mm:ss') {
                const tm = Math.floor(sec / 60);
                return p2(tm) + ':' + p2(s);
            }
            // auto
            if (h > 0) return h + 'ч ' + m + 'м';
            if (m > 0) return m + 'м ' + s + 'с';
            return s + 'с';
        },
        number(n, fmt) {
            n = Number(n) || 0;
            if (fmt === 'percent') return Math.round(n * 100) + '%';
            if (fmt === 'short') {
                const abs = Math.abs(n);
                if (abs >= 1e9) return (n / 1e9).toFixed(1).replace('.0', '') + 'B';
                if (abs >= 1e6) return (n / 1e6).toFixed(1).replace('.0', '') + 'M';
                if (abs >= 1e3) return (n / 1e3).toFixed(1).replace('.0', '') + 'K';
                return String(Math.round(n));
            }
            // comma — пробелы-разделители тысяч (русский стиль), без regex.
            const str = String(Math.abs(Math.round(n)));
            let out = '';
            for (let i = 0; i < str.length; i++) {
                if (i > 0 && (str.length - i) % 3 === 0) out += ' ';
                out += str[i];
            }
            return (n < 0 ? '-' : '') + out;
        },
        money(amount, unit) {
            const num = this.number(amount, 'comma');
            const u = (unit === 'rubles' || unit === undefined)
                ? this._plural(Math.round(Number(amount) || 0), 'рублик', 'рублика', 'рубликов')
                : unit;
            return num + ' ' + u;
        },
        duration(seconds) {
            let sec = Math.max(0, Math.floor(Number(seconds) || 0));
            const h = Math.floor(sec / 3600);
            const m = Math.floor((sec % 3600) / 60);
            if (h > 0) return h + ' ' + this._plural(h, 'час', 'часа', 'часов');
            if (m > 0) return m + ' ' + this._plural(m, 'минута', 'минуты', 'минут');
            return sec + ' ' + this._plural(sec, 'секунда', 'секунды', 'секунд');
        },
        // Русское склонение числительных (1 рублик / 2 рублика / 5 рубликов).
        _plural(n, one, few, many) {
            n = Math.abs(n) % 100;
            const n1 = n % 10;
            if (n > 10 && n < 20) return many;
            if (n1 > 1 && n1 < 5) return few;
            if (n1 === 1) return one;
            return many;
        },
    },

    /**
     * Расстояние между двумя точками или объектами.
     * Аргументы могут быть {x,y,z} или ref-строкой (для ref берём позицию из sceneIndex).
     */
    distance(a, b) {
        const pa = _resolveToPos(a);
        const pb = _resolveToPos(b);
        if (!pa || !pb) return Infinity;
        const dx = pa.x - pb.x, dy = pa.y - pb.y, dz = pa.z - pb.z;
        return Math.sqrt(dx * dx + dy * dy + dz * dz);
    },

    /**
     * Отправить именованное сообщение всем скриптам (включая себя).
     * Используется для общения между скриптами в разных sandbox'ах.
     *   game.broadcast('checkpoint', { num: 2 });
     */
    broadcast(name, data) {
        if (typeof name !== 'string' || !name) return;
        _send('broadcast', { name, data: data == null ? null : data });
    },

    /**
     * Подключить скрипт-модуль и получить его exports.
     * Модуль — другой скрипт проекта; в нём пишут в объект exports:
     *   // скрипт "math_utils":
     *   exports.add = (a, b) => a + b;
     *   // обычный скрипт:
     *   const m = game.require('math_utils');
     *   game.log(m.add(2, 3));   // 5
     * Модуль исполняется один раз, дальше отдаётся из кеша.
     */
    require(name) {
        if (typeof name !== 'string' || !name) return null;
        if (_moduleCache[name]) return _moduleCache[name];
        const code = _moduleCode[name];
        if (code == null) {
            _send('log', { level: 'error', text: 'game.require: модуль не найден — ' + name });
            return null;
        }
        try {
            const exportsObj = {};
            // модуль видит game и exports; повторный require внутри модуля тоже работает
            const moduleFn = new Function('game', 'exports', '"use strict";\\n' + code);
            moduleFn(game, exportsObj);
            _moduleCache[name] = exportsObj;
            return exportsObj;
        } catch (err) {
            _send('log', {
                level: 'error',
                text: 'Ошибка в модуле "' + name + '": ' + (err && err.message ? err.message : err),
            });
            return null;
        }
    },

    /**
     * Подписаться на сообщение.
     *   game.onMessage('checkpoint', (data) => { ... });
     */
    onMessage(name, fn) {
        if (typeof name !== 'string' || !name) return;
        if (typeof fn !== 'function') return;
        (_messageHandlers[name] = _messageHandlers[name] || []).push(fn);
    },

    /** Зажать значение между min и max. */
    clamp(value, min, max) {
        const v = Number(value);
        const lo = Number(min), hi = Number(max);
        if (!Number.isFinite(v)) return 0;
        if (v < lo) return lo;
        if (v > hi) return hi;
        return v;
    },

    /** Линейная интерполяция: lerp(a, b, 0)=a, lerp(a, b, 1)=b. */
    lerp(a, b, t) {
        const na = Number(a), nb = Number(b), nt = Number(t);
        return na + (nb - na) * nt;
    },

    /**
     * game.placement — drag-and-drop размещение объектов (задача 11).
     * Фундамент tycoon/farm/simulator: «кликнул предмет → preview за курсором
     * → ЛКМ ставит». См. 11_placement_mode.md.
     *
     *   game.placement.start('crate', {
     *     previewType: 'model:crate', surfaceMode: 'ground', grid: 1,
     *     cost: 50, currency: 'rubles', targetZone: game.scene.findOne('plot'),
     *     showArrowFrom: 'player', showZoneOutline: true, chainPlace: true,
     *   });
     *   game.placement.onPlace(({ itemKey, position, rotationY }) => { ... });
     */
    placement: {
        /** Войти в режим расстановки. opts — см. 11_placement_mode.md §2.1. */
        start(itemKey, opts) {
            if (typeof itemKey !== 'string' || !itemKey) return null;
            const o = opts && typeof opts === 'object' ? opts : {};
            // targetZone может прийти как ref-объект findOne — нормализуем в строку.
            const out = { itemKey, opts: { ...o } };
            if (o.targetZone) out.opts.targetZone = _normRef(o.targetZone) || o.targetZone;
            _send('placement.start', out);
            return itemKey;
        },
        /** Отменить активный режим (как ПКМ/Esc). */
        cancel() { _send('placement.cancel', {}); },
        /** Поставить на текущей позиции (как ЛКМ). */
        confirm() { _send('placement.confirm', {}); },
        /** Повернуть preview на N градусов (по умолчанию rotationStep). */
        rotate(deg) { _send('placement.rotate', { deg: Number(deg) || undefined }); },
        /** fn({ itemKey, position:{x,y,z}, rotationY }) — объект размещён. */
        onPlace(fn) { if (typeof fn === 'function') _placeOnPlaceHandlers.push(fn); },
        /** fn() — режим отменён игроком. */
        onCancel(fn) { if (typeof fn === 'function') _placeOnCancelHandlers.push(fn); },
        /** fn({ position:{x,y,z}, valid }) — каждый кадр, движение preview. */
        onMove(fn) { if (typeof fn === 'function') _placeOnMoveHandlers.push(fn); },
    },

    /**
     * game.inventoryUi — готовый GUI-кит «слот-инвентарь магазина» (задача 11).
     * Нижняя/боковая панель кнопок-слотов с иконкой/ценой/hover. Клик по слоту
     * → onSlotClick(item) (обычно автор зовёт game.placement.start внутри).
     * Слот серый и некликабельный, если валюты недостаточно (showCurrency + getBalance).
     *
     *   game.inventoryUi.create({
     *     items: [{ key:'crate', name:'Базовый ящик', icon:'crate', cost:50, modelType:'model:crate' }],
     *     position: 'bottom', showCost: true, showCurrency: 'rubles',
     *     onSlotClick: (item) => game.placement.start(item.key, {...}),
     *   });
     */
    inventoryUi: {
        /** Создать панель слотов. См. 11_placement_mode.md §2.7. */
        create(opts) {
            const o = opts && typeof opts === 'object' ? opts : {};
            const items = Array.isArray(o.items) ? o.items : [];
            if (typeof o.onSlotClick === 'function') {
                // Регистрируем колбэк под индексом — движок пришлёт invUiSlotClick {key}.
                _invUiSlotClickHandlers.push(o.onSlotClick);
            }
            _send('inventoryUi.create', {
                items: items.map(it => ({
                    key: String(it.key || ''),
                    name: String(it.name || ''),
                    icon: it.icon || '',
                    cost: Number(it.cost) || 0,
                    modelType: it.modelType || '',
                })),
                position: o.position || 'bottom',
                slotSize: Number(o.slotSize) || 80,
                spacing: Number(o.spacing) || 4,
                showCost: o.showCost !== false,
                showCurrency: o.showCurrency || '',
            });
        },
        /** Обновить баланс валюты (для авто-серых слотов). */
        setBalance(currency, amount) {
            _send('inventoryUi.setBalance', { currency: String(currency || ''), amount: Number(amount) || 0 });
        },
        /** Скрыть/удалить панель. */
        remove() { _send('inventoryUi.remove', {}); },
    },
};

/**
 * Пересечение луча с AABB (в локальных координатах бокса, центр в 0).
 * Возвращает расстояние t до точки входа или null если не пересекает.
 * Slab-метод. (ox,oy,oz)=начало луча, (dx,dy,dz)=направление (нормализ.),
 * (hw,hh,hd)=полуразмеры бокса.
 */
function _rayAabb(ox, oy, oz, dx, dy, dz, hw, hh, hd) {
    let tmin = -Infinity, tmax = Infinity;
    const axes = [
        [ox, dx, hw], [oy, dy, hh], [oz, dz, hd],
    ];
    for (const [o, d, h] of axes) {
        if (Math.abs(d) < 1e-9) {
            // луч параллелен слою — мимо если начало вне границ
            if (o < -h || o > h) return null;
        } else {
            let t1 = (-h - o) / d;
            let t2 = (h - o) / d;
            if (t1 > t2) { const tmp = t1; t1 = t2; t2 = tmp; }
            if (t1 > tmin) tmin = t1;
            if (t2 < tmax) tmax = t2;
            if (tmin > tmax) return null;
        }
    }
    // tmin<0 значит начало внутри бокса — берём 0
    return tmin >= 0 ? tmin : (tmax >= 0 ? 0 : null);
}

/** Резолв позиции из {x,y,z} или ref-строки. */
function _resolveToPos(arg) {
    if (!arg) return null;
    if (typeof arg === 'string') {
        for (const b of _sceneIndex.blocks)     if (b.ref === arg) return { x: b.x, y: b.y, z: b.z };
        for (const m of _sceneIndex.models)     if (m.ref === arg) return { x: m.x, y: m.y, z: m.z };
        for (const p of _sceneIndex.primitives) if (p.ref === arg) return { x: p.x, y: p.y, z: p.z };
        return null;
    }
    if (typeof arg === 'object' && Number.isFinite(arg.x)) {
        return { x: Number(arg.x) || 0, y: Number(arg.y) || 0, z: Number(arg.z) || 0 };
    }
    return null;
}

// === Обработчики сообщений из main ===
self.onmessage = (e) => {
    const { cmd, payload } = e.data || {};
    if (cmd === 'init') {
        // payload: { code, target?, selfPosition?, modules? }
        if (payload && payload.target) {
            _target = payload.target;
            if (payload.selfPosition) _selfPosition = payload.selfPosition;
            _selfApi = _buildSelfApi();
        }
        if (payload && payload.modules && typeof payload.modules === 'object') {
            _moduleCode = payload.modules;
        }
        // Первичный snapshot сцены — заполняем _sceneIndex ДО исполнения кода,
        // чтобы findOne()/find() работали в синхронном теле скрипта на старте
        // (иначе obj.onTouch(...) не подписывался — объект ещё «не существовал»).
        if (payload && payload.initialScene && typeof payload.initialScene === 'object') {
            const s = payload.initialScene;
            _sceneIndex = {
                blocks: s.blocks || [],
                models: s.models || [],
                primitives: s.primitives || [],
            };
        }
        try {
            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 === 'leaderstatsChange') {
            // Задача 20: стат изменился на сервере/main — обновляем зеркало + onChange.
            const pid = payload.playerId == null ? '@me' : String(payload.playerId);
            if (!_lsMirror[pid]) _lsMirror[pid] = {};
            _lsMirror[pid][payload.name] = payload.newValue;
            if (payload.isMe) { if (!_lsMirror['@me']) _lsMirror['@me'] = {}; _lsMirror['@me'][payload.name] = payload.newValue; }
            for (const fn of _lsChangeHandlers) { try { fn(payload.playerId, payload.name, payload.newValue, payload.oldValue); } catch (err) { _send('log', { level: 'error', text: 'leaderstats.onChange: ' + (err && err.message ? err.message : err) }); } }
        } else if (t === 'achievementUnlocked') {
            _achUnlocked[payload.id] = true;
        } else if (t === 'mouseMove') {
            for (const fn of _mouseMoveHandlers) {
                try { fn(payload.x, payload.y); }
                catch (err) {
                    _send('log', { level: 'error', text: 'onMouseMove: ' + (err && err.message ? err.message : err) });
                }
            }
        } else if (t === 'mouseDown') {
            for (const fn of _mouseDownHandlers) {
                try { fn(payload.x, payload.y); }
                catch (err) {
                    _send('log', { level: 'error', text: 'onMouseDown: ' + (err && err.message ? err.message : err) });
                }
            }
        } else if (t === 'mouseUp') {
            for (const fn of _mouseUpHandlers) {
                try { fn(payload.x, payload.y); }
                catch (err) {
                    _send('log', { level: 'error', text: 'onMouseUp: ' + (err && err.message ? err.message : err) });
                }
            }
        } else if (t === 'playerTouch') {
            for (const fn of _globalTouchHandlers) _safeCall(fn, payload, 'onPlayerTouch');
        } else if (t === 'instTouch' || t === 'instUntouch' || t === 'instClick') {
            // Касание/клик произвольного объекта (findOne(x).onTouch/onUntouch/onClick).
            const b = _instTouchHandlers.get(payload && payload.ref);
            if (b) {
                const list = t === 'instTouch' ? b.touch : t === 'instUntouch' ? b.untouch : b.click;
                for (const fn of list) _safeCall(fn, payload, 'inst.' + t);
            }
        } else if (t === 'instInteract') {
            // Задача 14: взаимодействие (F) с объектом по findOne(ref).onInteract.
            const b = _instTouchHandlers.get(payload && payload.ref);
            if (b) for (const fn of b.interact) _safeCall(fn, payload, 'inst.onInteract');
        } 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, customToolId?}, point, target }
            const ev = {
                tool: payload.tool || null,
                point: payload.point || null,
                target: payload.target || null,
            };
            // Phase 6.4: per-tool callback из game.tools.create -> onActivated.
            const customId = payload.tool && payload.tool.customToolId;
            if (customId && _toolCallbacks[customId] && _toolCallbacks[customId].activated) {
                _safeCall(_toolCallbacks[customId].activated, ev, 'tool.onActivated:' + customId);
            }
            for (const fn of _toolUseHandlers) _safeCall(fn, ev, 'onToolUse');
        } else if (t === 'toolEquipped') {
            // Phase 6.4: tool взят в руки.
            const customId = payload && payload.tool && payload.tool.customToolId;
            if (customId && _toolCallbacks[customId] && _toolCallbacks[customId].equipped) {
                _safeCall(_toolCallbacks[customId].equipped, payload, 'tool.onEquipped:' + customId);
            }
        } else if (t === 'toolUnequipped') {
            const customId = payload && payload.tool && payload.tool.customToolId;
            if (customId && _toolCallbacks[customId] && _toolCallbacks[customId].unequipped) {
                _safeCall(_toolCallbacks[customId].unequipped, payload, 'tool.onUnequipped:' + customId);
            }
        } else if (t === 'cutsceneDone') {
            // Катсцена камеры завершилась (Фаза 5.7).
            for (const fn of _cutsceneDoneHandlers) _safeCall(fn, undefined, 'onCutsceneDone');
        } else if (t === 'vehicleEnter') {
            // Задача 14: игрок сел в машину. payload: { vehicleId }.
            const vref = 'vehicle:' + (payload && payload.vehicleId);
            for (const fn of _vehicleEnterHandlers) _safeCall(fn, vref, 'onVehicleEnter');
        } else if (t === 'vehicleExit') {
            const vref = 'vehicle:' + (payload && payload.vehicleId);
            for (const fn of _vehicleExitHandlers) _safeCall(fn, vref, 'onVehicleExit');
        } 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 === 'remoteEvent') {
            // Phase 6.6: RemoteEvent от сервера. payload: { from, name, data }
            const arr = _remoteHandlers[payload.name] || [];
            for (const fn of arr) {
                _safeCall(fn, { from: payload.from, data: payload.data },
                    'remote.on:' + payload.name);
            }
        } else if (t === 'playerDied') {
            for (const fn of _playerDiedHandlers) _safeCall(fn, undefined, 'onPlayerDied');
        } else if (t === 'playerJump') {
            for (const fn of _playerJumpHandlers) _safeCall(fn, undefined, 'onPlayerJump');
        } else if (t === 'playerLand') {
            for (const fn of _playerLandHandlers) _safeCall(fn, undefined, 'onPlayerLand');
        } else if (t === 'keydown') {
            const key = String(payload.key || '').toLowerCase();
            const arr = _globalKeyDownHandlers[key] || [];
            for (const fn of arr) _safeCall(fn, payload, 'onKey:' + key);
            const wild = _globalKeyDownHandlers['*'] || [];
            for (const fn of wild) _safeCall(fn, payload, 'onKey(*)');
        } else if (t === 'keyup') {
            const key = String(payload.key || '').toLowerCase();
            const arr = _globalKeyUpHandlers[key] || [];
            for (const fn of arr) _safeCall(fn, payload, 'onKeyUp:' + key);
            const wild = _globalKeyUpHandlers['*'] || [];
            for (const fn of wild) _safeCall(fn, payload, 'onKeyUp(*)');
        } else if (t === 'message') {
            const name = String(payload.name || '');
            const arr = _messageHandlers[name] || [];
            for (const fn of arr) _safeCall(fn, payload.data, 'onMessage:' + name);
        } else if (t === 'guiClick') {
            const id = String(payload.id || '');
            const localId = payload.localId != null ? String(payload.localId) : null;
            // Собираем handlers по id, по локальному ref и по имени элемента —
            // скрипт мог подписаться любым из этих ключей.
            // _matched защищает от двойного вызова если несколько ключей ведут
            // к одному и тому же массиву handlers.
            const _matched = new Set();
            for (const key of _guiHandlerKeys(id, localId)) {
                const arr = _guiClickHandlers[key];
                if (!arr || _matched.has(arr)) continue;
                _matched.add(arr);
                for (const fn of arr) _safeCall(fn, { id }, 'gui.onClick:' + key);
            }
        } else if (t === 'guiSubmit') {
            const id = String(payload.id || '');
            const localId = payload.localId != null ? String(payload.localId) : null;
            const val = payload.value != null ? String(payload.value) : '';
            const _matched = new Set();
            for (const key of _guiHandlerKeys(id, localId)) {
                const arr = _guiSubmitHandlers[key];
                if (!arr || _matched.has(arr)) continue;
                _matched.add(arr);
                for (const fn of arr) _safeCall(fn, val, 'gui.onSubmit:' + key);
            }
        } else if (t === 'billboardClick') {
            // payload: { ref, button } — клик по кнопке 3D-таблички.
            // Ищем handlers и по реальному ref (primitive:NN), и по локальному
            // ref если такой есть (на случай если скрипт подписался по
            // локальному ref от scene.spawn).
            const realRef = String(payload.ref || '');
            const button = String(payload.button || 'buy');
            const tryKeys = [realRef + ':' + button];
            // Если есть локальный ref, ведущий к этому real — тоже попробуем
            // (скрипт мог подписаться на ref сразу после game.scene.spawn,
            // когда ref был ещё локальным _local_N).
            for (const [local, real] of Object.entries(_spawnLocalToReal || {})) {
                if (real === realRef) tryKeys.push(local + ':' + button);
            }
            for (const key of tryKeys) {
                const arr = _billboardClickHandlers[key] || [];
                for (const fn of arr) _safeCall(fn, { ref: realRef, button },
                    'billboard.onClick:' + key);
            }
        } else if (t === 'modalOpened') {
            // Задача 04: реальный modalId от runtime. worker сразу вернул скрипту
            // локальный id (чтобы он мог его сохранить и звать close/update); здесь
            // запоминаем маппинг local→real, иначе close(m) уходит с локальным id
            // и ModalManager.close его не узнаёт (баг «закрывается только по Esc»).
            try {
                const mm = (typeof game !== 'undefined') && game.modal;
                if (mm && payload && payload.replyId) {
                    const localId = Number(String(payload.replyId).replace(/^_mopen_/, ''));
                    if (Number.isFinite(localId) && payload.modalId != null) {
                        mm._localToReal.set(localId, payload.modalId);
                        mm._isOpenLocal = true;
                    }
                }
            } catch (e) {}
        } else if (t === 'modalClosed') {
            // Модал закрыт (fadeOut доиграл) — снимаем флаг и зовём onClose-подписчиков.
            try {
                const mm = (typeof game !== 'undefined') && game.modal;
                if (mm) {
                    mm._isOpenLocal = false;
                    const cbs = mm._onCloseFns || [];
                    for (const fn of cbs) _safeCall(fn, payload && payload.id, 'modal.onClose');
                }
            } catch (e) {}
        } else if (t === 'loadingShown') {
            // Задача 12: реальный loadingId от runtime — маппим local→real, чтобы
            // setProgress/close/колбэки нашли нужный экран.
            _loadingVisible = true;
            try {
                const lo = (typeof game !== 'undefined') && game.loading;
                if (lo && payload && payload.replyId) {
                    const localId = Number(String(payload.replyId).replace(/^_lshow_/, ''));
                    if (Number.isFinite(localId) && payload.loadingId != null) {
                        lo._localToReal.set(localId, payload.loadingId);
                    }
                }
            } catch (e) {}
        } else if (t === 'loadingHidden') {
            // Задача 05: экран загрузки скрылся — обновляем зеркало + onHide-подписки.
            _loadingVisible = false;
            try {
                const lo = (typeof game !== 'undefined') && game.loading;
                if (lo) for (const fn of (lo._onHide || [])) _safeCall(fn, undefined, 'loading.onHide');
            } catch (e) {}
        } else if (t === 'loadingSkip' || t === 'loadingComplete') {
            // Игрок нажал «Пропустить» (skip) или прогресс достиг 100% (complete).
            // Находим local по real loadingId и зовём соответствующие подписчики.
            try {
                const lo = (typeof game !== 'undefined') && game.loading;
                const real = payload && payload.loadingId;
                if (lo && real != null) {
                    for (const [local, r] of lo._localToReal) {
                        if (r === real) {
                            const h = lo._handlers.get(local);
                            if (h) {
                                const arr = t === 'loadingSkip' ? h.onSkip : h.onComplete;
                                for (const fn of arr) _safeCall(fn, undefined, 'loading.' + t);
                            }
                        }
                    }
                }
            } catch (e) {}
        } else if (t === 'skinChanged') {
            // Задача 07: скин игрока сменился — обновляем зеркало и зовём подписчиков.
            const slug = payload && payload.slug;
            if (slug) {
                _currentSkin = slug;
                for (const fn of _skinChangeHandlers) _safeCall(fn, slug, 'player.onSkinChange');
            }
        } else if (t === 'skinUnlocked') {
            const slug = payload && payload.slug;
            if (slug && _unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug);
        } else if (t === 'placeConfirm') {
            // Задача 11: объект размещён. payload: { itemKey, position, rotationY }
            const ev = { itemKey: payload.itemKey, position: payload.position, rotationY: payload.rotationY };
            for (const fn of _placeOnPlaceHandlers) _safeCall(fn, ev, 'placement.onPlace');
        } else if (t === 'placeCancel') {
            for (const fn of _placeOnCancelHandlers) _safeCall(fn, undefined, 'placement.onCancel');
        } else if (t === 'placeMove') {
            // payload: { position, valid } — каждый кадр placement.
            const ev = { position: payload.position, valid: !!payload.valid };
            for (const fn of _placeOnMoveHandlers) _safeCall(fn, ev, 'placement.onMove');
        } else if (t === 'invUiSlotClick') {
            // payload: { key, item } — клик по слоту магазина.
            const item = payload.item || { key: payload.key };
            for (const fn of _invUiSlotClickHandlers) _safeCall(fn, item, 'inventoryUi.onSlotClick');
        }
    } else if (cmd === 'sceneSnapshot') {
        // payload: { blocks, models, primitives } — массивы { ref, x, y, z, name?, type? }
        if (payload) {
            _sceneIndex = {
                blocks: payload.blocks || [],
                models: payload.models || [],
                primitives: payload.primitives || [],
            };
            // 6.2: детект дельт и эмит events для Instance (если кто-то подписан).
            try { _detectSnapshotDeltas(); } catch (e) {}
        }
    } else if (cmd === 'guiSnapshot') {
        // payload: массив всех GUI-элементов (для game.gui.find/get/all)
        _guiIndex = Array.isArray(payload) ? payload : [];
    } else if (cmd === 'skinsSnapshot') {
        // Задача 07: payload { all:[{slug,name,kind,category,price}], unlocked:[slug], current }
        if (payload && typeof payload === 'object') {
            _skinsIndex = Array.isArray(payload.all) ? payload.all : [];
            _unlockedSkins = Array.isArray(payload.unlocked) ? payload.unlocked.slice() : [];
            _currentSkin = payload.current || _currentSkin;
            if (Number.isFinite(payload.coins)) _skinCoins = payload.coins;
        }
    } else if (cmd === 'dataSnapshot') {
        // payload: { ref: { key: value } } — атрибуты всех объектов
        _dataIndex = payload && typeof payload === 'object' ? payload : {};
    } else if (cmd === 'terrainHeightmap') {
        // payload: { origin:{x,z}, step, cols, rows, heights:[] }
        // Карта высот гладкого ландшафта для game.scene.surfaceY.
        _terrainHM = payload || null;
    } else if (cmd === 'saveResponse') {
        // payload: { reqId, result }
        const reqId = payload && payload.reqId;
        const cb = reqId && _saveCallbacks[reqId];
        if (cb) {
            delete _saveCallbacks[reqId];
            try { cb(payload.result); } catch (e) {}
        }
    } else if (cmd === 'economyResponse') {
        // payload: { reqId, result }
        const reqId = payload && payload.reqId;
        const cb = reqId && _economyCallbacks[reqId];
        if (cb) {
            delete _economyCallbacks[reqId];
            try { cb(payload.result); } catch (e) {}
        }
    } else if (cmd === 'tweenDone') {
        // payload: { tweenId } — твин доиграл, зовём onDone
        const tid = payload && payload.tweenId;
        const cb = tid != null && _tweenCallbacks[tid];
        if (cb) {
            delete _tweenCallbacks[tid];
            _safeCall(cb, undefined, 'tween.onDone');
        }
    } else if (cmd === 'npcSpawned') {
        // payload: { localRef, npcId } — async-спавн NPC завершён.
        // Запоминаем маппинг, чтобы npc.onDeath по локальному ref работал.
        if (payload && payload.localRef != null) {
            _npcLocalToReal[payload.localRef] = payload.npcId;
        }
    } else if (cmd === 'spawnResolved') {
        // payload: { localRef, realRef } — scene.spawn создал объект.
        // Запоминаем маппинг для getPosition и т.п.
        if (payload && payload.localRef && payload.realRef) {
            _spawnLocalToReal[payload.localRef] = payload.realRef;
        }
    } else if (cmd === 'stop') {
        _tickHandlers = [];
        _timers = [];
        _labelBindings.clear();
        _selfClickHandlers = [];
        _selfTouchHandlers = [];
        _selfUntouchHandlers = [];
        _selfInteractHandlers = [];
        _instTouchHandlers.clear();
        _globalKeyDownHandlers = {};
        _globalKeyUpHandlers = {};
        _globalClickHandlers = [];
        _globalTouchHandlers = [];
        _mouseMoveHandlers = [];
        _mouseDownHandlers = [];
        _mouseUpHandlers = [];
        _mobKilledHandlers = [];
        _hpChangeHandlers = [];
        _playerDiedHandlers = [];
        _playerJumpHandlers = [];
        _playerLandHandlers = [];
        _messageHandlers = {};
        _guiClickHandlers = {};
        _guiSubmitHandlers = {};
        _billboardClickHandlers = {};
        _npcDeathHandlers = {};
        _globalNpcDeathHandlers = [];
        _npcLocalToReal = {};
        _spawnLocalToReal = {};
        _npcs = [];
        _toolUseHandlers = [];
        _toolCallbacks = {};
        _toolSeq = 0;
        _inventory = { slots: [], activeIndex: 0 };
        _players = { me: null, list: [] };
        _roomState = {};
        _playerJoinHandlers = [];
        _playerLeaveHandlers = [];
        _cutsceneDoneHandlers = [];
        _mpMessageHandlers = {};
        _remoteHandlers = {};
        _roomChangeHandlers = {};
        _teams = [];
        _constraintRefSeq = 0;
        _fxRefSeq = 0;
        _soundRefSeq = 0;
    }
};

_send('boot', null);
`;

/**
 * Создаёт URL Worker-кода для new Worker(url).
 */
export function getWorkerSourceUrl() {
    // type: 'application/javascript; charset=utf-8' — без charset Chrome иногда
    // декодирует blob как Latin-1, и surrogate pair (эмодзи) ломаются в SyntaxError.
    const blob = new Blob([SOURCE], { type: 'application/javascript; charset=utf-8' });
    return URL.createObjectURL(blob);
}
