/**
 * BlockManager — управление voxel-блоками в сцене Babylon.js.
 *
 * Поддерживает:
 *   1. Простые кубы — одна текстура на все 6 граней (поле `texture`).
 *   2. Мульти-материал кубы — разные текстуры на верх/стороны/низ
 *      (поля `top`, `side`, `bottom`). Используется для травы, бревна,
 *      печки, тыквы.
 *
 * Все блоки 1×1×1 на целочисленных координатах: блок (gridX, gridY, gridZ)
 * имеет центр в (gridX, gridY + 0.5, gridZ) — низ на y=gridY.
 *
 * Public API:
 *   addBlock(x, y, z, blockTypeId)   — поставить
 *   removeBlock(x, y, z)             — удалить
 *   removeBlockByMesh(mesh)
 *   hasBlock(x, y, z)
 *   count() / clear()
 *   serialize() / loadFromArray()
 *
 * Материалы и текстуры кешируются по типу блока.
 */
import {
    MeshBuilder,
    StandardMaterial,
    MultiMaterial,
    SubMesh,
    Texture,
    DynamicTexture,
    Color3,
    Color4,
    Vector3,
    Matrix,
    Mesh,
    VertexData,
    VertexBuffer,
} from '@babylonjs/core';
import { getBlockType } from './BlockTypes';

/**
 * Какой материал применить к какой грани куба Babylon-MeshBuilder.CreateBox.
 *
 * Babylon BoxBuilder делит куб на 12 треугольников = 6 граней по 2 треугольника.
 * Порядок граней в индексах:
 *   0: +Z (front)
 *   1: -Z (back)
 *   2: +X (right)
 *   3: -X (left)
 *   4: +Y (top)
 *   5: -Y (bottom)
 *
 * Каждая грань = 2 треугольника = 6 индексов. На каждую — отдельный submesh.
 */
const FACE_INDEX_MAP = {
    front:  0,
    back:   1,
    right:  2,
    left:   3,
    top:    4,
    bottom: 5,
};

export class BlockManager {
    constructor(scene) {
        this.scene = scene;
        // Логическая запись блоков: "x,y,z" → metadata (без отдельного меша!)
        // Это позволяет не создавать тысячи individual mesh'ей и использовать
        // thinInstances. Для совместимости со старым кодом ниже мы создаём
        // legacy-обёртку (Map "x,y,z" → mesh-proxy) — proxy это переиспользуемый
        // невидимый pick-mesh, который заполняется при pick/select.
        this.blocks = new Map();
        // Кеш материалов по типу блока — { material, isMulti }
        this._materials = new Map();
        // Prototype-меши для thinInstances: blockTypeId → Mesh (с thinInstances).
        this._protoMeshes = new Map();
        // Для каждого blockTypeId — массив логических ключей по индексу инстанса:
        // _instanceKeys.get(typeId)[idx] === "x,y,z" (или null для удалённых, см. _freeSlots)
        this._instanceKeys = new Map();
        // Свободные слоты в массиве thin-instances (после удаления) — переиспользуем.
        this._freeSlots = new Map();   // blockTypeId → [idx, idx, ...]
        // Обратный индекс: "x,y,z" → { typeId, idx } для быстрого pick/remove
        this._cellToInst = new Map();
        this._onChange = null;
        // Callback вызывается когда создаётся новый proto-меш — BabylonScene
        // подписывается чтобы зарегистрировать его как shadow caster.
        this._onProtoCreated = null;
        // Жидкости — отдельный single-mesh водной поверхности с волнами
        this._waterMeshes = new Set();   // меши-«невидимки» блоков воды (для логики)
        this._lavaMeshes = new Set();
        this._waterSurface = null;       // единый mesh всей воды
        this._waterSurfaceBaseY = null;  // массив исходных Y верхних вершин (для волн)
        this._waterDirty = false;        // нужна ли пересборка surface-меша
        this._lavaSurface = null;
        this._lavaSurfaceBaseY = null;
        this._lavaDirty = false;
        this._animTime = 0;
        // Окрашиваемые блоки (studs-block, задача 09): per-instance color через
        // ThinInstance color buffer. blockTypeId → Float32Array(maxBlocks*4 RGBA).
        this._colorsByProto = new Map();
        this._STUDS_MAX = 20000;   // макс блоков одного окрашиваемого типа
    }

    /** Вызывать каждый кадр для анимации воды/лавы. */
    tick(dt) {
        this._animTime += dt;
        // Пересборка single-mesh при изменениях
        if (this._waterDirty) { this._rebuildLiquidSurface('water'); this._waterDirty = false; }
        if (this._lavaDirty)  { this._rebuildLiquidSurface('lava');  this._lavaDirty = false; }

        // Per-block bobbing — одна формула sin на блок, считается каждый кадр.
        // Это дёшево (≤ 5000 операций для крупного моря) и обеспечивает плавность.
        // Skip только для гигантских поверхностей > 8000 блоков.
        this._waterAnimFrame = ((this._waterAnimFrame || 0) + 1) | 0;
        const waterCount = this._waterSurfaceBaseY?.length || 0;
        if (waterCount > 0) {
            const skip = waterCount > 8000 ? 2 : 1;
            if (this._waterAnimFrame % skip === 0) {
                this._animateLiquidSurface(this._waterSurface, this._waterSurfaceBaseY, 2.0, 0.10);
            }
        }
        const lavaCount = this._lavaSurfaceBaseY?.length || 0;
        if (lavaCount > 0) {
            const skip = lavaCount > 8000 ? 2 : 1;
            if (this._waterAnimFrame % skip === 0) {
                // Лава — медленная (freq=0.7) и амплитуда чуть меньше воды (0.07).
                // Та же per-block bobbing-механика что и у воды.
                this._animateLiquidSurface(this._lavaSurface, this._lavaSurfaceBaseY, 0.7, 0.07);
            }
        }
    }

    _animateLiquidSurface(mesh, baseY, freq, amp) {
        if (!mesh || mesh.isDisposed?.()) return;
        const positions = mesh.getVerticesData(VertexBuffer.PositionKind);
        if (!positions || !baseY) return;
        const t = this._animTime;
        // Per-block bobbing: одна высота волны на блок (4 вершины двигаются синхронно).
        // Фаза = пространственная (x*0.6+z*0.4) → волна плавно бежит через поверхность.
        for (let k = 0; k < baseY.length; k++) {
            const grp = baseY[k];
            const wave = Math.sin(t * freq + grp.phase) * amp;
            const newY = grp.y + wave;
            if (Array.isArray(grp.i)) {
                positions[grp.i[0] * 3 + 1] = newY;
                positions[grp.i[1] * 3 + 1] = newY;
                positions[grp.i[2] * 3 + 1] = newY;
                positions[grp.i[3] * 3 + 1] = newY;
            } else {
                positions[grp.i * 3 + 1] = newY;
            }
        }
        mesh.updateVerticesData(VertexBuffer.PositionKind, positions);
    }

    /** Пересоздать single-mesh для воды или лавы. */
    _rebuildLiquidSurface(kind) {
        const isWater = kind === 'water';
        const oldMesh = isWater ? this._waterSurface : this._lavaSurface;
        if (oldMesh) {
            try { oldMesh.dispose(); } catch (e) {}
        }
        const blocks = isWater ? this._waterMeshes : this._lavaMeshes;
        if (blocks.size === 0) {
            if (isWater) { this._waterSurface = null; this._waterSurfaceBaseY = null; }
            else         { this._lavaSurface  = null; this._lavaSurfaceBaseY  = null; }
            return;
        }

        // Собираем set координат блоков для проверки соседей
        const set = new Set();
        for (const m of blocks) {
            const md = m.metadata;
            set.add(`${md.gridX},${md.gridY},${md.gridZ}`);
        }
        const has = (x, y, z) => set.has(`${x},${y},${z}`);

        const positions = [];
        const indices = [];
        const normals = [];
        const uvs = [];
        // baseY теперь — массив групп { i: [v0..v3], y: baseY, phase, freq }
        // Каждая группа = один блок воды с верхней гранью; все 4 вершины
        // двигаются вместе → блок остаётся плоским квадратом, без сглаживания.
        const baseY = [];

        let vIdx = 0;
        // Для каждого блока — добавляем грани, у которых нет соседа той же жидкости.
        for (const m of blocks) {
            const md = m.metadata;
            const x = md.gridX, y = md.gridY, z = md.gridZ;
            // ВЕРХ — рисуем если выше нет блока этой же жидкости
            if (!has(x, y + 1, z)) {
                this._addQuad(positions, indices, normals, uvs,
                    x - 0.5, y + 1, z - 0.5,
                    x + 0.5, y + 1, z - 0.5,
                    x + 0.5, y + 1, z + 0.5,
                    x - 0.5, y + 1, z + 0.5,
                    0, 1, 0,
                    vIdx);
                // Записываем группу из 4 вершин с фазой, плавно зависящей от координат
                // блока. Главная компонента — `x*0.6 + z*0.4` — даёт волну, которая
                // непрерывно «бежит» через поверхность (соседи отличаются мало).
                // Поверх — крошечный рандом, чтобы блоки не были полностью синхронны.
                const seed = (x * 73856093) ^ (z * 83492791);
                const r1 = ((seed * 1103515245 + 12345) >>> 16) & 0x7FFF;
                const jitter = (r1 / 0x7FFF - 0.5) * 0.5; // ±0.25 рад
                const phase = x * 0.6 + z * 0.4 + jitter;
                baseY.push({
                    i: [vIdx, vIdx + 1, vIdx + 2, vIdx + 3],
                    y: y + 1,
                    phase,
                });
                vIdx += 4;
            }
            // НИЗ — рисуем если ниже нет блока этой же жидкости (часто пол)
            if (!has(x, y - 1, z)) {
                this._addQuad(positions, indices, normals, uvs,
                    x - 0.5, y, z + 0.5,
                    x + 0.5, y, z + 0.5,
                    x + 0.5, y, z - 0.5,
                    x - 0.5, y, z - 0.5,
                    0, -1, 0,
                    vIdx);
                vIdx += 4;
            }
            // СТОРОНЫ
            if (!has(x + 1, y, z)) {
                this._addQuad(positions, indices, normals, uvs,
                    x + 0.5, y, z - 0.5,
                    x + 0.5, y, z + 0.5,
                    x + 0.5, y + 1, z + 0.5,
                    x + 0.5, y + 1, z - 0.5,
                    1, 0, 0,
                    vIdx);
                vIdx += 4;
            }
            if (!has(x - 1, y, z)) {
                this._addQuad(positions, indices, normals, uvs,
                    x - 0.5, y, z + 0.5,
                    x - 0.5, y, z - 0.5,
                    x - 0.5, y + 1, z - 0.5,
                    x - 0.5, y + 1, z + 0.5,
                    -1, 0, 0,
                    vIdx);
                vIdx += 4;
            }
            if (!has(x, y, z + 1)) {
                this._addQuad(positions, indices, normals, uvs,
                    x + 0.5, y, z + 0.5,
                    x - 0.5, y, z + 0.5,
                    x - 0.5, y + 1, z + 0.5,
                    x + 0.5, y + 1, z + 0.5,
                    0, 0, 1,
                    vIdx);
                vIdx += 4;
            }
            if (!has(x, y, z - 1)) {
                this._addQuad(positions, indices, normals, uvs,
                    x - 0.5, y, z - 0.5,
                    x + 0.5, y, z - 0.5,
                    x + 0.5, y + 1, z - 0.5,
                    x - 0.5, y + 1, z - 0.5,
                    0, 0, -1,
                    vIdx);
                vIdx += 4;
            }
        }

        const mesh = new Mesh(`liquidSurface_${kind}`, this.scene);
        const vd = new VertexData();
        vd.positions = positions;
        vd.indices = indices;
        vd.normals = normals;
        vd.uvs = uvs;
        vd.applyToMesh(mesh, true);   // updatable=true — будем менять positions
        mesh.isPickable = false;
        mesh.alphaIndex = 100;        // рендерить после непрозрачных
        mesh.material = isWater ? this._buildWaterMaterial() : this._buildLavaMaterial();
        // Метим что это жидкость — игрок не должен по нему «коллидить»
        mesh.metadata = { isLiquidSurface: true, kind };
        if (isWater) {
            this._waterSurface = mesh;
            this._waterSurfaceBaseY = baseY;
        } else {
            this._lavaSurface = mesh;
            this._lavaSurfaceBaseY = baseY;
        }
    }

    _addQuad(positions, indices, normals, uvs, ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz, nx, ny, nz, vIdx) {
        positions.push(ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz);
        normals.push(nx, ny, nz, nx, ny, nz, nx, ny, nz, nx, ny, nz);
        // UV — мировые координаты делёные на 1 (плитка совпадает с блоком)
        // Для верха/низа берём X/Z, для боков — соответствующие.
        if (Math.abs(ny) > 0.5) {
            uvs.push(ax, az, bx, bz, cx, cz, dx, dz);
        } else if (Math.abs(nx) > 0.5) {
            uvs.push(az, ay, bz, by, cz, cy, dz, dy);
        } else {
            uvs.push(ax, ay, bx, by, cx, cy, dx, dy);
        }
        indices.push(vIdx, vIdx + 1, vIdx + 2, vIdx, vIdx + 2, vIdx + 3);
    }

    _buildWaterMaterial() {
        // Кешируем — один материал на всю сцену
        if (this._waterMatCache) return this._waterMatCache;
        const mat = new StandardMaterial('waterSurfaceMat', this.scene);
        // Простой материал без текстуры — анимация даётся per-block bobbing.
        // Чистый цвет без тайлинга = никаких швов и решёток.
        mat.diffuseColor = new Color3(0.30, 0.62, 0.92);
        // disableLighting=true — без освещения нет «диагональных» полос от
        // треугольных триангуляций quad'ов (когда нормали вершин слегка разъехались
        // после анимации). Вода становится плоского цвета — это и есть Roblox-look.
        mat.disableLighting = true;
        mat.emissiveColor = new Color3(0.30, 0.62, 0.92); // дублируем как «светящийся» цвет
        mat.alpha = 0.78;
        mat.backFaceCulling = false;
        // disableDepthWrite=true — устраняет alpha-blend «полоски»
        mat.disableDepthWrite = true;
        this._waterMatCache = mat;
        return mat;
    }

    _buildLavaMaterial() {
        // Кешируем — один материал на сцену
        if (this._lavaMatCache) return this._lavaMatCache;
        const mat = new StandardMaterial('lavaSurfaceMat', this.scene);
        // По аналогии с водой: чистый цвет + disableLighting → ровный flat shading,
        // никаких швов/диагоналей. Per-block bobbing анимирует поверхность.
        mat.diffuseColor = new Color3(1.0, 0.45, 0.08);
        mat.disableLighting = true;
        mat.emissiveColor = new Color3(1.0, 0.45, 0.08); // лава светится
        mat.specularColor = new Color3(0, 0, 0);
        mat.alpha = 0.95;
        mat.backFaceCulling = false;
        mat.disableDepthWrite = true;
        this._lavaMatCache = mat;
        return mat;
    }

    /** Установить колбэк изменения (BabylonScene → KubikonEditor.markDirty). */
    setOnChange(cb) {
        this._onChange = cb;
    }

    _notifyChange() {
        // Инвалидируем кэш высоты поверхности (используется ZombieManager).
        // Просто инкрементим версию — старые записи в _surfaceCache при
        // следующем чтении не пройдут проверку cached.v === version.
        this._surfaceCacheVersion = (this._surfaceCacheVersion || 0) + 1;
        if (this._onChange) this._onChange();
    }

    _key(x, y, z) {
        return `${Math.round(x)},${Math.round(y)},${Math.round(z)}`;
    }

    /**
     * Создать StandardMaterial с одной текстурой и Minecraft-параметрами.
     */
    _createSingleMat(blockType, texturePath, name) {
        const mat = new StandardMaterial(name, this.scene);
        mat.specularColor = new Color3(0, 0, 0);

        // Окрашиваемый блок (studs-block): цвет берётся per-instance из vertex
        // color буфера ThinInstance и умножается на серую текстуру. Включаем
        // useVertexColors, normal map (выпуклость кружков), мягкий спекуляр.
        if (blockType.colorable) {
            const tex = new Texture(texturePath, this.scene);
            mat.diffuseTexture = tex;
            mat.diffuseColor = new Color3(1, 1, 1);   // нейтраль — цвет идёт из vertex color
            if (blockType.normal) {
                try { mat.bumpTexture = new Texture(blockType.normal, this.scene); } catch (e) {}
            }
            // Сочность (Roblox-look): почти-белая текстура × яркий vertex color,
            // specular убран (он белит/тускнит цвет). vertex color (RGBA из
            // thin-instance color buffer) умножается на diffuse → цвет насыщенный.
            mat.specularColor = new Color3(0, 0, 0);
            mat.useVertexColors = true;
            return mat;
        }

        if (texturePath) {
            const tex = new Texture(texturePath, this.scene);
            tex.updateSamplingMode(Texture.NEAREST_SAMPLINGMODE);
            mat.diffuseTexture = tex;
            if (blockType.alpha != null && blockType.alpha < 1) {
                mat.diffuseTexture.hasAlpha = true;
                mat.useAlphaFromDiffuseTexture = true;
                mat.alpha = blockType.alpha;
            }
            if (Array.isArray(blockType.emissive)) {
                mat.emissiveColor = new Color3(
                    blockType.emissive[0], blockType.emissive[1], blockType.emissive[2]
                );
            }
            // Запоминаем материалы жидкостей для анимации (uOffset/vOffset)
            if (blockType.isWater && !this._waterMat) {
                this._waterMat = mat;
                tex.wrapU = Texture.WRAP_ADDRESSMODE;
                tex.wrapV = Texture.WRAP_ADDRESSMODE;
            }
            if (blockType.isLava && !this._lavaMat) {
                this._lavaMat = mat;
                tex.wrapU = Texture.WRAP_ADDRESSMODE;
                tex.wrapV = Texture.WRAP_ADDRESSMODE;
            }
        } else {
            mat.diffuseColor = Color3.FromHexString(blockType.color || '#888');
        }
        return mat;
    }

    /**
     * Получить (создать если нет) материал для типа блока.
     * Возвращает: { material, isMulti } — где material это StandardMaterial
     * (для simple куба) или MultiMaterial (для куба с разными гранями).
     */
    _getMaterial(blockTypeId) {
        if (this._materials.has(blockTypeId)) {
            return this._materials.get(blockTypeId);
        }
        const blockType = getBlockType(blockTypeId);

        let entry;
        if (blockType.top || blockType.side || blockType.bottom) {
            // Мульти-материал куб
            const matTop    = this._createSingleMat(blockType, blockType.top    || blockType.side, `mat_${blockTypeId}_top`);
            const matSide   = this._createSingleMat(blockType, blockType.side   || blockType.top,  `mat_${blockTypeId}_side`);
            const matBottom = this._createSingleMat(blockType, blockType.bottom || blockType.side, `mat_${blockTypeId}_bot`);

            const multi = new MultiMaterial(`multi_${blockTypeId}`, this.scene);
            multi.subMaterials[FACE_INDEX_MAP.front]  = matSide;
            multi.subMaterials[FACE_INDEX_MAP.back]   = matSide;
            multi.subMaterials[FACE_INDEX_MAP.right]  = matSide;
            multi.subMaterials[FACE_INDEX_MAP.left]   = matSide;
            multi.subMaterials[FACE_INDEX_MAP.top]    = matTop;
            multi.subMaterials[FACE_INDEX_MAP.bottom] = matBottom;

            // Замораживаем sub-материалы — Babylon перестанет проверять
            // их dirty-флаги каждый кадр (мы их уже не меняем).
            try { matSide.freeze?.(); matTop.freeze?.(); matBottom.freeze?.(); } catch (e) {}
            entry = { material: multi, isMulti: true };
        } else {
            // Простой куб с одной текстурой
            const mat = this._createSingleMat(blockType, blockType.texture, `mat_${blockTypeId}`);
            try { mat.freeze?.(); } catch (e) {}
            entry = { material: mat, isMulti: false };
        }

        this._materials.set(blockTypeId, entry);
        return entry;
    }

    /**
     * Поставить блок в (x, y, z).
     * Возвращает meshProxy (для совместимости со старым API) или null если занято.
     *
     * Производительность: для обычных блоков используем thinInstances —
     * один meshes-prototype на тип блока, тысячи позиций в одном draw call.
     * Жидкости (water/lava) идут по старому пути — у них свой single-surface.
     */
    addBlock(x, y, z, blockTypeId, color) {
        const ix = Math.round(x);
        const iy = Math.round(y);
        const iz = Math.round(z);
        const key = this._key(ix, iy, iz);
        if (this.blocks.has(key)) return null;

        const typeDef = getBlockType(blockTypeId);
        const isWater = !!typeDef?.isWater;
        const isLava = !!typeDef?.isLava;
        // Окрашиваемый блок: цвет инстанса (из аргумента или дефолт типа).
        const colorable = !!typeDef?.colorable;
        const instColor = colorable ? (color || typeDef.defaultColor || '#cccccc') : null;

        // Для жидкостей оставляем старую логику: невидимый куб + единый surface
        if (isWater || isLava) {
            const mesh = MeshBuilder.CreateBox(`block_${key}`, { size: 1 }, this.scene);
            mesh.position = new Vector3(ix, iy + 0.5, iz);
            mesh.isPickable = false;  // surface ловит pick
            mesh.metadata = {
                isBlock: true, blockTypeId,
                gridX: ix, gridY: iy, gridZ: iz,
                anchored: true,
                canCollide: false,
                visible: true,
                isWater, isLava,
                mass: 1,
                folderId: null,
                _liquidProxy: true,  // признак proxy-mesh (не настоящий блок)
            };
            mesh.setEnabled(false);
            if (isWater) { this._waterMeshes.add(mesh); this._waterDirty = true; }
            else         { this._lavaMeshes.add(mesh);  this._lavaDirty  = true; }
            this.blocks.set(key, mesh);
            this._notifyChange();
            return mesh;
        }

        // === ОБЫЧНЫЕ БЛОКИ — через thinInstances ===
        const proto = this._getOrCreateProto(blockTypeId);
        if (!proto) return null;

        // Берём свободный слот или append
        let idx;
        const freeList = this._freeSlots.get(blockTypeId);
        const matrix = Matrix.Translation(ix, iy + 0.5, iz);
        // refresh=false и для set, и для add — финальный refresh делаем один раз в loadFromArray
        // или при первом render-кадре через thinInstanceBufferUpdated.
        if (freeList && freeList.length > 0) {
            idx = freeList.pop();
            proto.thinInstanceSetMatrixAt(idx, matrix, !this._batchMode);
        } else {
            idx = proto.thinInstanceAdd(matrix, !this._batchMode);
        }

        // Сохраняем обратный индекс
        const keysArr = this._instanceKeys.get(blockTypeId);
        keysArr[idx] = key;
        this._cellToInst.set(key, { typeId: blockTypeId, idx });

        // Окрашиваемый блок — пишем цвет инстанса в color buffer.
        if (colorable) this._setBlockColorAt(blockTypeId, idx, instColor);

        // Логический «meshProxy» — объект, имитирующий API mesh для совместимости.
        // НЕ создаёт реального меша. Используется selection / removeBlockByMesh.
        const meshProxy = {
            _isBlockProxy: true,
            metadata: {
                isBlock: true, blockTypeId,
                gridX: ix, gridY: iy, gridZ: iz,
                anchored: true,
                canCollide: true,
                visible: true,
                isWater: false,
                isLava: false,
                mass: 1,
                folderId: null,
                _thinIdx: idx,
                color: instColor,   // per-instance цвет окрашиваемого блока
            },
            // Минимальные методы, которые ожидает остальной код
            position: new Vector3(ix, iy + 0.5, iz),
            isDisposed: () => !this.blocks.has(key),
            dispose: () => this.removeBlock(ix, iy, iz),
            setEnabled: () => { /* видимость через thinInstance scaling, упрощённо */ },
            getTotalVertices: () => 36,
            // Pick-helper: возвращает proto-меш и индекс инстанса (для подсветки)
            _proto: proto,
            _thinIdx: idx,
        };

        this.blocks.set(key, meshProxy);
        this._notifyChange();
        return meshProxy;
    }

    /** Получить или создать prototype-меш для типа блока. */
    _getOrCreateProto(blockTypeId) {
        let proto = this._protoMeshes.get(blockTypeId);
        if (proto) return proto;

        proto = MeshBuilder.CreateBox(`proto_${blockTypeId}`, { size: 1 }, this.scene);
        const { material, isMulti } = this._getMaterial(blockTypeId);
        proto.material = material;
        if (isMulti) this._setupSubmeshes(proto);

        // Окрашиваемый блок — включаем per-instance color buffer (vertex colors).
        const _bt = getBlockType(blockTypeId);
        if (_bt && _bt.colorable) {
            proto.useVertexColors = true;
            proto.hasVertexAlpha = false;
            const buf = new Float32Array(this._STUDS_MAX * 4);
            // Дефолт-цвет (белый) для всех слотов — иначе невыставленные = чёрные.
            buf.fill(1);
            proto.thinInstanceSetBuffer('color', buf, 4, false);
            this._colorsByProto.set(blockTypeId, buf);
        }

        proto.checkCollisions = false;  // коллизии через thin-instances не работают штатно;
        // PlayerController в Play-режиме читает blocks Map напрямую (см. PhysicsAABB).
        proto.isPickable = true;
        proto.thinInstanceEnablePicking = true;
        // alwaysSelectAsActiveMesh = true: на сцене ~500 блоков, прото — это
        // mesh с гигантским bounding box покрывающим всю карту. Frustum-test
        // на нём всё равно почти всегда true → пропускаем дорогие проверки.
        proto.alwaysSelectAsActiveMesh = true;
        // doNotSyncBoundingInfo = true — не пересчитывать bbox при изменениях
        // thin-instances (мы не используем bbox для frustum, см. выше).
        proto.doNotSyncBoundingInfo = true;
        proto.metadata = {
            _isBlockProto: true,
            blockTypeId,
        };
        // Тени: блоки принимают тени от других объектов.
        proto.receiveShadows = true;

        this._protoMeshes.set(blockTypeId, proto);
        this._instanceKeys.set(blockTypeId, []);
        this._freeSlots.set(blockTypeId, []);

        if (typeof this._onProtoCreated === 'function') {
            try { this._onProtoCreated(proto); } catch (e) { /* ignore */ }
        }
        return proto;
    }

    /** Подписка для уведомлений о создании prototype-меша (для shadow setup). */
    setOnProtoCreated(cb) { this._onProtoCreated = cb; }

    /**
     * Записать цвет инстанса окрашиваемого блока в color buffer (RGBA float).
     * idx — индекс thin-instance. hex — '#rrggbb'. В batch-режиме обновление
     * GPU откладывается (флаг dirty), иначе сразу thinInstanceBufferUpdated.
     */
    _setBlockColorAt(blockTypeId, idx, hex) {
        const buf = this._colorsByProto.get(blockTypeId);
        if (!buf) return;
        const c = Color3.FromHexString(hex || '#cccccc');
        const o = idx * 4;
        buf[o] = c.r; buf[o + 1] = c.g; buf[o + 2] = c.b; buf[o + 3] = 1;
        const proto = this._protoMeshes.get(blockTypeId);
        if (!proto) return;
        if (this._batchMode) {
            if (!this._colorDirtyProtos) this._colorDirtyProtos = new Set();
            this._colorDirtyProtos.add(blockTypeId);
        } else {
            try { proto.thinInstanceBufferUpdated('color'); } catch (e) { /* ignore */ }
        }
    }

    /**
     * Сменить цвет окрашиваемого блока в (x,y,z) на лету (для scene.setColor /
     * color-пикера). Возвращает true если блок окрашиваемый и цвет применён.
     */
    setBlockColor(x, y, z, hex) {
        const key = this._key(Math.round(x), Math.round(y), Math.round(z));
        const inst = this._cellToInst.get(key);
        if (!inst) return false;
        const bt = getBlockType(inst.typeId);
        if (!bt || !bt.colorable) return false;
        this._setBlockColorAt(inst.typeId, inst.idx, hex);
        const mp = this.blocks.get(key);
        if (mp && mp.metadata) mp.metadata.color = hex;
        this._notifyChange();
        return true;
    }

    /** Установить флаг anchored у блока. */
    setBlockAnchored(x, y, z, anchored) {
        const mesh = this.blocks.get(this._key(x, y, z));
        if (!mesh) return;
        mesh.metadata.anchored = !!anchored;
        // Поддерживаем Set _unanchoredBlocks — для O(1)-доступа в PhysicsAABB.
        if (!this._unanchoredBlocks) this._unanchoredBlocks = new Set();
        if (anchored) this._unanchoredBlocks.delete(mesh);
        else this._unanchoredBlocks.add(mesh);
        this._notifyChange();
    }

    /** Установить свойства блока (canCollide / visible / mass). */
    setBlockProps(x, y, z, patch) {
        const key = this._key(x, y, z);
        const mesh = this.blocks.get(key);
        if (!mesh) return;
        if (patch.canCollide !== undefined) mesh.metadata.canCollide = !!patch.canCollide;
        if (patch.visible !== undefined) {
            mesh.metadata.visible = !!patch.visible;
            // Для thin-instance proxy: переключаем матрицу (нормальная ↔ scale=0)
            if (mesh._isBlockProxy) {
                const inst = this._cellToInst.get(key);
                if (inst) {
                    const proto = this._protoMeshes.get(inst.typeId);
                    if (proto) {
                        const m = mesh.metadata.visible
                            ? Matrix.Translation(mesh.metadata.gridX, mesh.metadata.gridY + 0.5, mesh.metadata.gridZ)
                            : Matrix.Scaling(0, 0, 0);
                        proto.thinInstanceSetMatrixAt(inst.idx, m, true);
                    }
                }
            } else if (typeof mesh.setEnabled === 'function') {
                mesh.setEnabled(mesh.metadata.visible);
            }
        }
        if (patch.mass !== undefined) {
            const m = Number(patch.mass);
            if (Number.isFinite(m) && m > 0) mesh.metadata.mass = m;
        }
        this._notifyChange();
    }

    /**
     * Разбить cube-mesh на 6 SubMesh (по одной на грань).
     * Babylon CreateBox использует 36 индексов (12 треугольников = 36 вершин-индексов),
     * по 6 индексов на грань. Грани идут в порядке +Z, -Z, +X, -X, +Y, -Y.
     */
    _setupSubmeshes(mesh) {
        const verticesCount = mesh.getTotalVertices();
        mesh.subMeshes = [];
        const indicesPerFace = 6;
        for (let face = 0; face < 6; face++) {
            new SubMesh(
                face,                       // material index
                0,                          // verticesStart
                verticesCount,              // verticesCount
                face * indicesPerFace,      // indexStart
                indicesPerFace,             // indexCount
                mesh
            );
        }
    }

    removeBlock(x, y, z) {
        const key = this._key(x, y, z);
        const mesh = this.blocks.get(key);
        if (!mesh) return false;
        // Удаляем из set unanchored если он там был — иначе утечка.
        if (this._unanchoredBlocks) this._unanchoredBlocks.delete(mesh);
        // Жидкости — старый путь: dispose + dirty
        if (mesh.metadata?._liquidProxy) {
            if (mesh.metadata.isWater) this._waterDirty = true;
            if (mesh.metadata.isLava) this._lavaDirty = true;
            this._waterMeshes.delete(mesh);
            this._lavaMeshes.delete(mesh);
            try { mesh.dispose(); } catch (e) { /* ignore */ }
            this.blocks.delete(key);
            this._notifyChange();
            return true;
        }
        // Обычный блок — thin instance: «погасить» матрицу (схлопнуть в 0) и пометить слот свободным
        const inst = this._cellToInst.get(key);
        if (inst) {
            const proto = this._protoMeshes.get(inst.typeId);
            if (proto) {
                // Прячем экземпляр через scale=0 — его не видно, но остаётся в буфере
                const zeroMat = Matrix.Scaling(0, 0, 0);
                proto.thinInstanceSetMatrixAt(inst.idx, zeroMat, true);
            }
            const keysArr = this._instanceKeys.get(inst.typeId);
            if (keysArr) keysArr[inst.idx] = null;
            const free = this._freeSlots.get(inst.typeId);
            if (free) free.push(inst.idx);
            this._cellToInst.delete(key);
        }
        this.blocks.delete(key);
        this._notifyChange();
        return true;
    }

    hasBlock(x, y, z) {
        return this.blocks.has(this._key(x, y, z));
    }

    removeBlockByMesh(mesh) {
        if (!mesh) return false;
        // Прокси-mesh (water/lava или старый legacy)
        if (mesh.metadata?.isBlock) {
            const { gridX, gridY, gridZ } = mesh.metadata;
            return this.removeBlock(gridX, gridY, gridZ);
        }
        return false;
    }

    /**
     * Найти proxy-блок по результату raycast'а.
     * Если попало в proto-меш с thinInstanceIndex — возвращает соответствующий proxy.
     */
    findProxyByPickInfo(pickInfo) {
        if (!pickInfo || !pickInfo.pickedMesh) return null;
        const m = pickInfo.pickedMesh;
        if (m.metadata?._isBlockProto) {
            // В разных версиях Babylon индекс инстанса называется по-разному.
            // Пробуем все известные варианты.
            const idx = (typeof pickInfo.thinInstanceIndex === 'number' && pickInfo.thinInstanceIndex >= 0)
                ? pickInfo.thinInstanceIndex
                : (typeof pickInfo.instanceIndex === 'number' && pickInfo.instanceIndex >= 0)
                    ? pickInfo.instanceIndex
                    : -1;
            const typeId = m.metadata.blockTypeId;
            if (idx >= 0) {
                const keysArr = this._instanceKeys.get(typeId);
                const key = keysArr ? keysArr[idx] : null;
                if (key) {
                    const proxy = this.blocks.get(key);
                    if (proxy) return proxy;
                }
            }
            // Fallback: ищем по точке контакта. Учитываем нормаль грани —
            // точка лежит на поверхности блока, нужно сместиться "внутрь"
            // на 0.01 по противоположному направлению нормали и взять клетку.
            const p = pickInfo.pickedPoint;
            if (!p) return null;
            // Нормаль грани (если доступна)
            let nx = 0, ny = 0, nz = 0;
            try {
                const n = pickInfo.getNormal && pickInfo.getNormal(true);
                if (n) { nx = n.x; ny = n.y; nz = n.z; }
            } catch (e) { /* ignore */ }
            // Точка чуть «внутрь» блока против направления нормали
            const innerX = p.x - nx * 0.05;
            const innerY = p.y - ny * 0.05;
            const innerZ = p.z - nz * 0.05;
            // Координаты клетки: блок (gx, gy, gz) занимает Y от gy до gy+1,
            // X/Z центр на gx (от gx-0.5 до gx+0.5).
            const gx = Math.round(innerX);
            const gy = Math.floor(innerY);
            const gz = Math.round(innerZ);
            const key = this._key(gx, gy, gz);
            if (this.blocks.has(key)) return this.blocks.get(key);
            // Расширенный поиск в окрестности (на случай погрешностей)
            for (let ddy = -1; ddy <= 1; ddy++) {
                for (let ddx = -1; ddx <= 1; ddx++) {
                    for (let ddz = -1; ddz <= 1; ddz++) {
                        if (ddx === 0 && ddy === 0 && ddz === 0) continue;
                        const k2 = this._key(gx + ddx, gy + ddy, gz + ddz);
                        if (this.blocks.has(k2)) {
                            const proxy = this.blocks.get(k2);
                            // Только если это блок ИМЕННО того же типа что прото (точно тот меш)
                            if (proxy?.metadata?.blockTypeId === typeId) return proxy;
                        }
                    }
                }
            }
            return null;
        }
        if (m.metadata?.isBlock) return m; // legacy/liquid proxy
        return null;
    }

    count() {
        return this.blocks.size;
    }

    serialize() {
        const out = [];
        for (const mesh of this.blocks.values()) {
            const m = mesh.metadata;
            out.push({
                x: m.gridX, y: m.gridY, z: m.gridZ,
                type: m.blockTypeId,
                anchored: m.anchored !== false,
                canCollide: m.canCollide !== false,
                visible: m.visible !== false,
                mass: m.mass ?? 1,
                // per-instance цвет окрашиваемого блока (studs-block); иначе пропускаем
                ...(m.color ? { color: m.color } : {}),
            });
        }
        return out;
    }

    loadFromArray(arr) {
        this.clear();
        // Массовый режим — буферы thinInstances обновляются один раз в конце
        this._batchMode = true;
        try {
            for (const b of arr) {
                const mesh = this.addBlock(b.x, b.y, b.z, b.type, b.color);
                if (!mesh) continue;
                if (b.anchored === false) {
                    mesh.metadata.anchored = false;
                    if (!this._unanchoredBlocks) this._unanchoredBlocks = new Set();
                    this._unanchoredBlocks.add(mesh);
                }
                if (b.canCollide === false) mesh.metadata.canCollide = false;
                if (b.visible === false) {
                    mesh.metadata.visible = false;
                    // setBlockProps вызовет setMatrixAt — но мы в batchMode, так что без refresh
                }
                if (b.mass != null) mesh.metadata.mass = b.mass;
            }
        } finally {
            this._batchMode = false;
        }
        // Финальный refresh всех буферов thinInstances + bounding info
        for (const proto of this._protoMeshes.values()) {
            try {
                if (proto.thinInstanceBufferUpdated) proto.thinInstanceBufferUpdated('matrix');
                proto.thinInstanceRefreshBoundingInfo(true);
            } catch (e) { /* ignore */ }
        }
        // Финальный refresh color-буферов окрашиваемых блоков (batch).
        if (this._colorDirtyProtos) {
            for (const typeId of this._colorDirtyProtos) {
                const proto = this._protoMeshes.get(typeId);
                try { proto?.thinInstanceBufferUpdated('color'); } catch (e) { /* ignore */ }
            }
            this._colorDirtyProtos.clear();
        }
    }

    clear() {
        const had = this.blocks.size > 0;
        // Жидкости-проксы — dispose
        for (const mesh of this.blocks.values()) {
            if (mesh && mesh.metadata?._liquidProxy && typeof mesh.dispose === 'function') {
                try { mesh.dispose(); } catch (e) { /* ignore */ }
            }
        }
        // Прототипы thin-instances — сбрасываем все instances но сами proto оставляем
        // (можно переиспользовать; материалы тоже остаются)
        for (const proto of this._protoMeshes.values()) {
            try { proto.thinInstanceCount = 0; } catch (e) { /* ignore */ }
        }
        for (const arr of this._instanceKeys.values()) arr.length = 0;
        for (const arr of this._freeSlots.values()) arr.length = 0;
        this._cellToInst.clear();

        this.blocks.clear();
        this._waterMeshes.clear();
        this._lavaMeshes.clear();
        this._waterDirty = true;
        this._lavaDirty = true;
        if (had) this._notifyChange();
    }

    dispose() {
        this.clear();
        // Дисозим proto-меши блоков
        for (const proto of this._protoMeshes.values()) {
            try { proto.dispose(); } catch (e) { /* ignore */ }
        }
        this._protoMeshes.clear();
        this._instanceKeys.clear();
        this._freeSlots.clear();
        for (const entry of this._materials.values()) {
            const mat = entry.material;
            if (entry.isMulti) {
                // MultiMaterial — диспозим под-материалы и их текстуры
                for (const sub of mat.subMaterials) {
                    if (sub) {
                        if (sub.diffuseTexture) sub.diffuseTexture.dispose();
                        sub.dispose();
                    }
                }
            } else {
                if (mat.diffuseTexture) mat.diffuseTexture.dispose();
            }
            mat.dispose();
        }
        this._materials.clear();
    }
}
