3D игра “Space Potatoes” для Arduino Uno на Raycasting движке

Название и идея игры

Сама идея игры и название “Space Potatoes” — отсылка к циклу рассказов Станислава Лема о Ийоне Тихом, а именно «Путешествие двадцать пятое», где главный герой вспоминает о том, как неподалеку от планеты Таирия на него напал хищный картофель.

Движок проекта был основан на проекте «Doom для Arduino Uno» от daveruiz (github.com/daveruiz/doom-nano).

TEST1

TEST2

Состав проекта

  • Arduino Uno (ATmega328P)

  • OLED 0.96" 128x64 (SSD1306)

  • 6 тактовых кнопок

  • Пьезоизлучатель

  • Соединительные провода

  • Breadboard

Использовано ресурсов системы

  • RAM: 88.9% (использовано 1821 байт из 2048 байт)

  • Flash: 99.7% (использовано 32174 байт из 32256 байт)

Общий вид проекта

Управление

  • LEFT (влево) — Поворот налево

  • RIGHT (вправо) — Поворот направо

  • UP (вверх) — Движение вперёд

  • DOWN (вниз) — Движение назад

  • FIRE — Стрельба / Атака кулаком

  • WEAPON — Сменить оружие

Ключевые особенности

  • 3D-графика: Рендеринг 3D-сцены с textures в реальном времени.

  • Игровой мир: 4 уровня (3 основных, 1 секретный).

  • Интерактивность: Открывающиеся двери, текстурированные и анимированные стены.

  • Боевая система: 4 типа оружия, 4 типа рядовых врагов, 3 типа боссов.

  • Игровой процесс: 3 уровня сложности, скрытые пасхалки.

  • Интерфейс и визуал: Анимированный HUD, анимированные заставки начала игры, хорошего и плохого финалов.

  • Аудио: Опция включения/выключения фоновой музыки.

  • Чит-код Konami: FIRE WEAPON

Игровые отсылки и пасхалки

Оружие

  • E_WEAPON_BLASTER — бластер (Star Wars)

  • E_WEAPON_PLASMA_CUTTER — лазерный резак (Dead Space)

  • E_WEAPON_BFG9000 — оружие BFG9000 (Doom)

Предметы

  • E_SEPULKI — сипульки (книги Станислава Лема о Ийоне Тихом)

  • E_METAL_BOX — куб-компаньон (Portal)

Текстовые цитаты в заставках

  • Вступление: "FAR FAR AWAY NEAR TAIRIYA" (отсылка к Star Wars)

  • Хороший финал: "TO INFINITY AND BEYOND" (фраза Базза Лайтера, Toy Story)

  • Плохой финал: "WASTED" (экран смерти, GTA)

Ссылка на GitHub: https://github.com/dotermine/3D-game-Space-Potatoes-for-Arduino-Uno

Конкуренция для @lilik

круто!

Следующая - контроллер пожирнее - ESP32S3 накатить туда библиотеку ИИ и научить играть Львовский гамбит, 100 партий для модели я думаю хватит, увидим такое?

При учете, что разработка этого проекта заняла около полугода (от идеи до реализации), если и увидим, то не скоро))

Модель для ИИ соберётся на современном домашнем компьютере за полчаса, нужны будут партии, дайте знать, их есть у меня )))

Не, я существенно проще… а тут ещё и полгода на реализацию задуманного, здорово.

так это очень быстро

о, есть у меня s3, может действительно портировать…

https://youtu.be/KiyeSGZ2IcY?si=YAuFTaufKZ7S6yUg :slightly_smiling_face:

Жованый крот, там ещё и звук есть :open_mouth:

Чувак, это круто! Звуковые эффекты из Doom, да еще карта на отдельном цветном дисплее, которая сама как полноценная игра!

pirks а миникрафт с 0 слабо сделать ?))) просто набросок, запускается на пк, сохранив в html
конструктор карты

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>3D Конструктор — финальная версия</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            overflow: hidden;
        }
        body {
            background: #1a1a2e;
            font-family: Arial, sans-serif;
        }
        canvas {
            display: block;
        }

        #ui {
            position: fixed;
            bottom: 30px;
            left: 50%;
            transform: translateX(-50%);
            background: rgba(10, 10, 30, 0.85);
            backdrop-filter: blur(8px);
            padding: 12px 24px;
            border-radius: 40px;
            border: 1px solid #3f4f8a;
            display: flex;
            gap: 12px;
            align-items: center;
            flex-wrap: wrap;
            justify-content: center;
            pointer-events: auto;
            box-shadow: 0 8px 30px rgba(0,0,0,0.7);
            z-index: 10;
        }
        #ui button {
            background: #2a3a6a;
            border: none;
            color: #eee;
            padding: 6px 18px;
            border-radius: 30px;
            font-size: 14px;
            font-weight: 600;
            cursor: pointer;
            transition: 0.15s;
            border: 1px solid #4a5a8a;
            letter-spacing: 0.3px;
            white-space: nowrap;
        }
        #ui button:hover {
            background: #3a4a8a;
            transform: scale(1.04);
            border-color: #7a9aff;
        }
        #ui button.active {
            background: #5a7acc;
            border-color: #aac0ff;
            box-shadow: 0 0 16px #4a7aff88;
        }
        #ui .sep {
            width: 2px;
            height: 30px;
            background: #3f4f7a;
            border-radius: 4px;
        }
        #ui label {
            color: #b0c0ee;
            font-size: 13px;
            display: flex;
            align-items: center;
            gap: 6px;
        }
        #ui input[type="number"] {
            background: #1a2a4a;
            border: 1px solid #4a5a8a;
            border-radius: 16px;
            padding: 4px 10px;
            color: #eee;
            width: 60px;
            font-size: 13px;
            outline: none;
        }
        #ui input[type="number"]:focus {
            border-color: #7a9aff;
        }
        #ui input[type="file"] {
            display: none;
        }
        #ui select {
            background: #2a3a6a;
            color: #eee;
            border: 1px solid #4a5a8a;
            border-radius: 20px;
            padding: 6px 12px;
            font-size: 13px;
            cursor: pointer;
            outline: none;
        }
        #ui select:hover {
            background: #3a4a8a;
        }
        #ui option {
            background: #2a3a6a;
        }
        #info {
            position: fixed;
            top: 20px;
            left: 50%;
            transform: translateX(-50%);
            color: rgba(255, 255, 255, 0.8);
            background: rgba(0, 0, 0, 0.6);
            padding: 8px 24px;
            border-radius: 30px;
            font-size: 14px;
            border: 1px solid #2a3a6a;
            backdrop-filter: blur(4px);
            pointer-events: none;
            user-select: none;
            text-align: center;
            z-index: 10;
            white-space: nowrap;
        }
        #info span {
            color: #aaccff;
            font-weight: bold;
        }
        #status {
            position: fixed;
            bottom: 100px;
            left: 50%;
            transform: translateX(-50%);
            color: #88aaff;
            background: rgba(0, 0, 20, 0.7);
            padding: 6px 18px;
            border-radius: 30px;
            font-size: 13px;
            border: 1px solid #3f4f7a;
            backdrop-filter: blur(4px);
            pointer-events: none;
            z-index: 10;
            transition: 0.2s;
            white-space: nowrap;
        }
        #controls-hint {
            position: fixed;
            bottom: 170px;
            left: 50%;
            transform: translateX(-50%);
            color: rgba(255, 255, 255, 0.4);
            background: rgba(0, 0, 0, 0.4);
            padding: 4px 16px;
            border-radius: 20px;
            font-size: 12px;
            backdrop-filter: blur(2px);
            pointer-events: none;
            z-index: 10;
            text-align: center;
            border: 1px solid rgba(255,255,255,0.05);
        }
        #coords {
            position: fixed;
            top: 80px;
            left: 50%;
            transform: translateX(-50%);
            color: rgba(255, 255, 255, 0.5);
            font-size: 13px;
            background: rgba(0, 0, 0, 0.4);
            padding: 4px 16px;
            border-radius: 20px;
            pointer-events: none;
            z-index: 10;
            font-family: monospace;
        }
        .hp-legend {
            position: fixed;
            right: 20px;
            top: 50%;
            transform: translateY(-50%);
            background: rgba(10, 10, 30, 0.85);
            backdrop-filter: blur(8px);
            padding: 12px 16px;
            border-radius: 12px;
            border: 1px solid #3f4f8a;
            color: #b0c0ee;
            font-size: 12px;
            z-index: 10;
            min-width: 80px;
            pointer-events: none;
        }
        .legend-item {
            display: flex;
            align-items: center;
            gap: 8px;
            padding: 3px 0;
        }
        .legend-color {
            width: 20px;
            height: 12px;
            border-radius: 4px;
        }
        #caps-indicator {
            position: fixed;
            top: 60px;
            right: 20px;
            background: rgba(10, 10, 30, 0.7);
            backdrop-filter: blur(8px);
            padding: 6px 14px;
            border-radius: 20px;
            border: 1px solid #4a5a8a;
            color: #6a7aaa;
            font-size: 12px;
            z-index: 10;
            pointer-events: none;
            transition: 0.3s;
        }
        #caps-indicator.active {
            border-color: #44ff88;
            color: #44ff88;
            box-shadow: 0 0 20px rgba(68, 255, 136, 0.2);
        }
        @media (max-width: 700px) {
            #ui { padding: 10px 14px; gap: 6px; bottom: 16px; }
            #ui button { font-size: 12px; padding: 4px 12px; }
            #ui input[type="number"] { width: 50px; font-size: 11px; }
            #info { font-size: 11px; top: 10px; white-space: normal; }
            #status { bottom: 80px; font-size: 11px; }
            #controls-hint { bottom: 140px; font-size: 10px; }
            #coords { top: 70px; font-size: 11px; }
            .hp-legend { right: 10px; padding: 8px 12px; min-width: 60px; font-size: 10px; }
            #caps-indicator { top: 50px; right: 10px; font-size: 10px; padding: 4px 10px; }
        }
    </style>
</head>
<body>

    <div id="info">
        <span>W A S D</span> — движение &nbsp;|&nbsp; <span>Пробел</span> — вверх &nbsp;|&nbsp; <span>Shift</span> — вниз &nbsp;|&nbsp; <span>Мышь</span> — осмотр
    </div>
    <div id="coords">📍 X: 0.0 | Y: 0.0 | Z: 0.0</div>
    <div id="status">🏗️ Режим: Куб | Объектов: 0</div>
    <div id="controls-hint">🖱️ ЛКМ — поставить | ПКМ — удалить | D — урон | H — лечение</div>
    <div id="caps-indicator">🔒 Caps Lock: OFF</div>

    <div id="ui">
        <button data-shape="box" class="active">📦 Куб</button>
        <button data-shape="sphere">⚪ Сфера</button>
        <button data-shape="cylinder">🧴 Цилиндр</button>
        <button data-shape="cone">🔺 Конус</button>
        <button data-shape="torus">🍩 Тор</button>
        <span class="sep"></span>
        <label>Размер карты:
            <input type="number" id="mapSize" value="20" min="5" max="100" />
        </label>
        <span class="sep"></span>
        <label>Прочность:
            <select id="hpSelect">
                <option value="100">💚 100 HP</option>
                <option value="1000" selected>💛 1000 HP</option>
                <option value="2500">🧡 2500 HP</option>
                <option value="5000">❤️ 5000 HP</option>
            </select>
        </label>
        <span class="sep"></span>
        <button id="clearBtn">🗑️ Очистить</button>
        <button id="saveBtn">💾 Сохранить</button>
        <button id="loadBtn">📂 Загрузить</button>
        <input type="file" id="fileInput" accept=".json" />
    </div>

    <div class="hp-legend">
        <div style="font-weight:bold; margin-bottom:6px; color:#88aaff;">❤️ HP → Цвет</div>
        <div class="legend-item"><span class="legend-color" style="background:#44ff88;"></span> 100 HP</div>
        <div class="legend-item"><span class="legend-color" style="background:#88ff44;"></span> 500 HP</div>
        <div class="legend-item"><span class="legend-color" style="background:#ffaa44;"></span> 1000 HP</div>
        <div class="legend-item"><span class="legend-color" style="background:#ff6644;"></span> 2500 HP</div>
        <div class="legend-item"><span class="legend-color" style="background:#ff2244;"></span> 5000 HP</div>
    </div>

    <script src="three.min.js"></script>
    <script>
        // ----- СЦЕНА, КАМЕРА, РЕНДЕР -----
        const scene = new THREE.Scene();
        scene.background = new THREE.Color(0x1a1a2e);

        const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 500);
        camera.position.set(0, 3, 8);

        const renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
        renderer.shadowMap.enabled = true;
        renderer.shadowMap.type = THREE.PCFSoftShadowMap;
        document.body.appendChild(renderer.domElement);

        // ----- СВЕТ -----
        const ambient = new THREE.AmbientLight(0x404060);
        scene.add(ambient);

        const sun = new THREE.DirectionalLight(0xffeedd, 1.8);
        sun.position.set(10, 20, 5);
        sun.castShadow = true;
        sun.shadow.mapSize.width = 1024;
        sun.shadow.mapSize.height = 1024;
        const d = 50;
        sun.shadow.camera.left = -d;
        sun.shadow.camera.right = d;
        sun.shadow.camera.top = d;
        sun.shadow.camera.bottom = -d;
        sun.shadow.camera.near = 1;
        sun.shadow.camera.far = 100;
        scene.add(sun);

        const fill = new THREE.DirectionalLight(0x4488ff, 0.4);
        fill.position.set(-5, 0, 10);
        scene.add(fill);

        // ----- ПЕРЕМЕННЫЕ КАРТЫ -----
        let mapSize = 20;
        let floor, grid, gridFine;

        // ----- ФУНКЦИЯ ПОСТРОЕНИЯ КАРТЫ -----
        function buildMap(size) {
            if (floor) {
                scene.remove(floor);
                floor.geometry.dispose();
                floor.material.dispose();
            }
            if (grid) {
                scene.remove(grid);
                grid.geometry.dispose();
                grid.material.dispose();
            }
            if (gridFine) {
                scene.remove(gridFine);
                gridFine.geometry.dispose();
                gridFine.material.dispose();
            }

            const floorMat = new THREE.MeshStandardMaterial({ 
                color: 0x2a2a4a, 
                roughness: 0.9, 
                metalness: 0.1 
            });
            floor = new THREE.Mesh(new THREE.PlaneGeometry(size, size), floorMat);
            floor.rotation.x = -Math.PI / 2;
            floor.position.y = -0.5;
            floor.receiveShadow = true;
            floor.name = 'floor';
            scene.add(floor);

            grid = new THREE.GridHelper(size, size, 0x8888ff, 0x444488);
            grid.position.y = -0.49;
            scene.add(grid);

            gridFine = new THREE.GridHelper(size, size * 2, 0x6666aa, 0x333366);
            gridFine.position.y = -0.48;
            gridFine.material.transparent = true;
            gridFine.material.opacity = 0.3;
            scene.add(gridFine);

            const halfSize = size / 2;
            camera.position.set(0, Math.min(size * 0.3, 15), size * 0.6);
        }

        buildMap(mapSize);

        // ----- ПРИЗРАК (Ghost) -----
        let ghostMesh = null;

        function getColorForHP(hp) {
            let color = new THREE.Color();
            
            if (hp <= 100) {
                const t = hp / 100;
                color.lerpColors(new THREE.Color(0x228833), new THREE.Color(0x44ff88), t);
            } else if (hp <= 500) {
                const t = (hp - 100) / 400;
                color.lerpColors(new THREE.Color(0x44ff88), new THREE.Color(0x88ff44), t);
            } else if (hp <= 1000) {
                const t = (hp - 500) / 500;
                color.lerpColors(new THREE.Color(0x88ff44), new THREE.Color(0xffaa44), t);
            } else if (hp <= 2500) {
                const t = (hp - 1000) / 1500;
                color.lerpColors(new THREE.Color(0xffaa44), new THREE.Color(0xff6644), t);
            } else {
                const t = Math.min(1, (hp - 2500) / 2500);
                color.lerpColors(new THREE.Color(0xff6644), new THREE.Color(0xff2244), t);
            }
            
            return color;
        }

        function createGhost(shape) {
            if (ghostMesh) {
                scene.remove(ghostMesh);
                ghostMesh.geometry.dispose();
                ghostMesh.material.dispose();
                ghostMesh = null;
            }

            const geoFn = shapeMap[shape];
            if (!geoFn) return;

            const mat = new THREE.MeshStandardMaterial({
                color: 0x44ff88,
                transparent: true,
                opacity: 0.35,
                roughness: 0.4,
                metalness: 0.2,
                emissive: 0x44ff88,
                emissiveIntensity: 0.1,
            });
            ghostMesh = new THREE.Mesh(geoFn(), mat);
            ghostMesh.position.set(0, -100, 0);
            ghostMesh.name = 'ghost';
            ghostMesh.castShadow = false;
            ghostMesh.receiveShadow = false;
            scene.add(ghostMesh);
        }

        // ----- МЕНЕДЖЕР ОБЪЕКТОВ -----
        const objects = [];
        let objectIdCounter = 0;

        const shapeMap = {
            box: () => new THREE.BoxGeometry(1, 1, 1),
            sphere: () => new THREE.SphereGeometry(0.5, 20, 20),
            cylinder: () => new THREE.CylinderGeometry(0.5, 0.5, 1, 16),
            cone: () => new THREE.ConeGeometry(0.5, 1, 16),
            torus: () => new THREE.TorusGeometry(0.5, 0.25, 12, 20),
        };

        const shapeHeights = {
            box: 1,
            sphere: 1,
            cylinder: 1,
            cone: 1,
            torus: 0.5,
        };

        let currentShape = 'box';
        let currentHP = 1000;
        let isCapsLock = false;

        createGhost(currentShape);

        // ----- УПРАВЛЕНИЕ -----
        const keys = { w: false, a: false, s: false, d: false, space: false, shift: false };

        document.addEventListener('keydown', (e) => {
            switch (e.code) {
                case 'KeyW': keys.w = true; e.preventDefault(); break;
                case 'KeyA': keys.a = true; e.preventDefault(); break;
                case 'KeyS': keys.s = true; e.preventDefault(); break;
                case 'KeyD': keys.d = true; e.preventDefault(); break;
                case 'Space': keys.space = true; e.preventDefault(); break;
                case 'ShiftLeft':
                case 'ShiftRight': keys.shift = true; e.preventDefault(); break;
                case 'CapsLock': 
                    isCapsLock = !isCapsLock;
                    const indicator = document.getElementById('caps-indicator');
                    if (isCapsLock) {
                        indicator.textContent = '⚡ Caps Lock: ON (x5 скорость)';
                        indicator.classList.add('active');
                    } else {
                        indicator.textContent = '🔒 Caps Lock: OFF';
                        indicator.classList.remove('active');
                    }
                    e.preventDefault();
                    break;
                default: break;
            }
        });
        document.addEventListener('keyup', (e) => {
            switch (e.code) {
                case 'KeyW': keys.w = false; e.preventDefault(); break;
                case 'KeyA': keys.a = false; e.preventDefault(); break;
                case 'KeyS': keys.s = false; e.preventDefault(); break;
                case 'KeyD': keys.d = false; e.preventDefault(); break;
                case 'Space': keys.space = false; e.preventDefault(); break;
                case 'ShiftLeft':
                case 'ShiftRight': keys.shift = false; e.preventDefault(); break;
                default: break;
            }
        });

        // Проверка состояния Caps Lock при загрузке
        document.addEventListener('keydown', (e) => {
            if (e.code === 'CapsLock') {
                // Состояние переключится в основном обработчике
            }
        });

        // ----- МЫШЬ (pointer lock) -----
        let isLocked = false;
        renderer.domElement.addEventListener('click', () => {
            if (!isLocked) {
                renderer.domElement.requestPointerLock();
            }
        });
        document.addEventListener('pointerlockchange', () => {
            isLocked = document.pointerLockElement === renderer.domElement;
            if (!isLocked && ghostMesh) {
                ghostMesh.position.y = -100;
            }
        });

        let yaw = 0, pitch = 0;
        document.addEventListener('mousemove', (e) => {
            if (!isLocked) return;
            const sens = 0.002;
            yaw -= e.movementX * sens;
            pitch -= e.movementY * sens;
            pitch = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, pitch));
        });

        // ----- ДВИЖЕНИЕ -----
        const baseSpeed = 4.0;
        const clock = new THREE.Clock();

        function updateCamera() {
            const euler = new THREE.Euler(pitch, yaw, 0, 'YXZ');
            camera.quaternion.setFromEuler(euler);
        }

        function snapToGrid(value) {
            return Math.round(value);
        }

        function updateObjectColor(obj) {
            const hp = obj.userData.hp || 0;
            const color = getColorForHP(hp);
            
            obj.material.color.copy(color);
            
            obj.material.emissive.copy(color);
            const maxHp = obj.userData.maxHp || 1000;
            const percent = Math.max(0, Math.min(1, hp / maxHp));
            obj.material.emissive.multiplyScalar(0.1 + (1 - percent) * 0.3);
        }

        // ----- ДОБАВЛЕНИЕ ОБЪЕКТА -----
        function addObject(shape, position, hp) {
            const geoFn = shapeMap[shape];
            if (!geoFn) return null;
            
            const color = getColorForHP(hp);
            const mat = new THREE.MeshStandardMaterial({
                color: color,
                roughness: 0.4,
                metalness: 0.2,
                emissive: color.clone().multiplyScalar(0.1),
            });
            const mesh = new THREE.Mesh(geoFn(), mat);
            
            const height = shapeHeights[shape] || 1;
            mesh.position.set(position.x, position.y + height/2, position.z);
            
            mesh.castShadow = true;
            mesh.receiveShadow = true;
            mesh.name = 'userObject';
            mesh.userData.shape = shape;
            mesh.userData.height = height;
            mesh.userData.baseY = position.y;
            mesh.userData.hp = hp;
            mesh.userData.maxHp = hp;
            mesh.userData.id = objectIdCounter++;
            
            scene.add(mesh);
            objects.push(mesh);
            
            return mesh;
        }

        // ----- УРОН И ЛЕЧЕНИЕ -----
        function damageObject(obj, damage) {
            if (!obj || obj.userData.hp <= 0) return;
            obj.userData.hp = Math.max(0, obj.userData.hp - damage);
            updateObjectColor(obj);
            if (obj.userData.hp <= 0) {
                scene.remove(obj);
                const idx = objects.indexOf(obj);
                if (idx !== -1) {
                    objects.splice(idx, 1);
                    obj.geometry.dispose();
                    obj.material.dispose();
                    updateStatus();
                }
            }
            updateStatus();
        }

        function healObject(obj, amount) {
            if (!obj || obj.userData.hp <= 0) return;
            obj.userData.hp = Math.min(obj.userData.maxHp, obj.userData.hp + amount);
            updateObjectColor(obj);
            updateStatus();
        }

        // ----- УДАЛЕНИЕ ВСЕХ -----
        function clearObjects() {
            for (const obj of objects) {
                scene.remove(obj);
                obj.geometry.dispose();
                obj.material.dispose();
            }
            objects.length = 0;
            objectIdCounter = 0;
            updateStatus();
        }

        // ----- СОХРАНЕНИЕ И ЗАГРУЗКА -----
        function saveToFile() {
            if (objects.length === 0) {
                document.getElementById('status').textContent = '⚠️ Нет объектов для сохранения';
                setTimeout(() => updateStatus(), 1500);
                return;
            }

            const data = {
                version: '1.0',
                mapSize: mapSize,
                objects: objects.map(obj => ({
                    shape: obj.userData.shape || 'box',
                    x: obj.position.x,
                    y: obj.userData.baseY !== undefined ? obj.userData.baseY : obj.position.y - shapeHeights[obj.userData.shape]/2,
                    z: obj.position.z,
                    hp: obj.userData.hp || 1000,
                    maxHp: obj.userData.maxHp || 1000,
                }))
            };

            const json = JSON.stringify(data, null, 2);
            const blob = new Blob([json], { type: 'application/json' });
            const url = URL.createObjectURL(blob);
            
            const a = document.createElement('a');
            a.href = url;
            a.download = 'map3d_' + new Date().toISOString().slice(0,19).replace(/:/g, '-') + '.json';
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);

            document.getElementById('status').textContent = '✅ Карта сохранена в файл!';
            setTimeout(() => updateStatus(), 1500);
        }

        function loadFromFile(file) {
            const reader = new FileReader();
            reader.onload = (e) => {
                try {
                    const data = JSON.parse(e.target.result);
                    
                    if (data.mapSize) {
                        mapSize = data.mapSize;
                        document.getElementById('mapSize').value = mapSize;
                        buildMap(mapSize);
                    }
                    
                    if (!Array.isArray(data.objects)) {
                        throw new Error('Неверный формат данных');
                    }
                    
                    clearObjects();
                    let loadedCount = 0;
                    for (const item of data.objects) {
                        if (!item.shape || item.x === undefined || item.y === undefined || item.z === undefined) {
                            continue;
                        }
                        const pos = new THREE.Vector3(item.x, item.y, item.z);
                        const hp = item.hp || item.maxHp || 1000;
                        const mesh = addObject(item.shape, pos, hp);
                        if (mesh) {
                            mesh.userData.shape = item.shape;
                            mesh.userData.hp = hp;
                            mesh.userData.maxHp = item.maxHp || hp;
                            updateObjectColor(mesh);
                            loadedCount++;
                        }
                    }
                    
                    document.getElementById('status').textContent = `📂 Загружено из файла: ${loadedCount} объектов`;
                    setTimeout(() => updateStatus(), 2000);
                } catch (err) {
                    console.error(err);
                    document.getElementById('status').textContent = '❌ Ошибка загрузки файла';
                    setTimeout(() => updateStatus(), 2000);
                }
            };
            reader.onerror = () => {
                document.getElementById('status').textContent = '❌ Ошибка чтения файла';
                setTimeout(() => updateStatus(), 2000);
            };
            reader.readAsText(file);
        }

        function updateStatus() {
            document.getElementById('status').textContent = '🏗️ Режим: ' + currentShape + ' | Объектов: ' + objects.length + ' | Карта: ' + mapSize + 'x' + mapSize;
        }

        // ----- UI -----
        const shapeButtons = document.querySelectorAll('[data-shape]');
        const hpSelect = document.getElementById('hpSelect');
        const mapSizeInput = document.getElementById('mapSize');

        shapeButtons.forEach(btn => {
            btn.addEventListener('click', () => {
                shapeButtons.forEach(b => b.classList.remove('active'));
                btn.classList.add('active');
                currentShape = btn.dataset.shape;
                createGhost(currentShape);
                updateStatus();
            });
        });

        hpSelect.addEventListener('change', (e) => {
            currentHP = parseInt(e.target.value);
        });

        mapSizeInput.addEventListener('change', (e) => {
            let size = parseInt(e.target.value);
            if (isNaN(size) || size < 5) size = 5;
            if (size > 100) size = 100;
            mapSize = size;
            e.target.value = size;
            buildMap(size);
            updateStatus();
        });

        document.getElementById('clearBtn').addEventListener('click', () => {
            if (confirm('Удалить все объекты?')) {
                clearObjects();
                if (ghostMesh) {
                    ghostMesh.position.y = -100;
                }
            }
        });

        document.getElementById('saveBtn').addEventListener('click', saveToFile);
        document.getElementById('loadBtn').addEventListener('click', () => {
            document.getElementById('fileInput').click();
        });

        document.getElementById('fileInput').addEventListener('change', (e) => {
            if (e.target.files.length > 0) {
                loadFromFile(e.target.files[0]);
                e.target.value = '';
            }
        });

        // ----- RAYCASTER -----
        const raycaster = new THREE.Raycaster();
        const pointer = new THREE.Vector2();

        function getPlacementPosition(intersect) {
            const point = intersect.point.clone();
            const hitObject = intersect.object;
            
            const halfSize = mapSize / 2;
            
            if (hitObject.name === 'userObject') {
                const topY = hitObject.userData.baseY + hitObject.userData.height;
                const x = snapToGrid(hitObject.position.x);
                const z = snapToGrid(hitObject.position.z);
                if (Math.abs(x) > halfSize || Math.abs(z) > halfSize) return null;
                return new THREE.Vector3(x, topY, z);
            } else if (hitObject.name === 'floor') {
                const x = snapToGrid(point.x);
                const z = snapToGrid(point.z);
                if (Math.abs(x) > halfSize || Math.abs(z) > halfSize) return null;
                return new THREE.Vector3(x, -0.5, z);
            }
            return null;
        }

        function updateGhost(event) {
            if (!isLocked || !ghostMesh) {
                if (ghostMesh) ghostMesh.position.y = -100;
                return;
            }

            const rect = renderer.domElement.getBoundingClientRect();
            pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
            pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;

            raycaster.setFromCamera(pointer, camera);
            const allObjects = [floor, ...objects];
            const intersects = raycaster.intersectObjects(allObjects);

            if (intersects.length > 0) {
                const pos = getPlacementPosition(intersects[0]);
                if (pos) {
                    const height = shapeHeights[currentShape] || 1;
                    ghostMesh.position.set(pos.x, pos.y + height/2, pos.z);
                    ghostMesh.position.x = snapToGrid(ghostMesh.position.x);
                    ghostMesh.position.z = snapToGrid(ghostMesh.position.z);
                    ghostMesh.visible = true;
                } else {
                    ghostMesh.position.y = -100;
                    ghostMesh.visible = false;
                }
            } else {
                ghostMesh.position.y = -100;
                ghostMesh.visible = false;
            }
        }

        document.addEventListener('mousemove', (e) => {
            updateGhost(e);
        });

        // ----- УСТАНОВКА ПО ЛКМ -----
        renderer.domElement.addEventListener('click', (e) => {
            if (!isLocked) return;

            const rect = renderer.domElement.getBoundingClientRect();
            pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
            pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;

            raycaster.setFromCamera(pointer, camera);
            const allObjects = [floor, ...objects];
            const intersects = raycaster.intersectObjects(allObjects);

            if (intersects.length > 0) {
                const pos = getPlacementPosition(intersects[0]);
                if (pos) {
                    const mesh = addObject(currentShape, pos, currentHP);
                    if (mesh) {
                        document.getElementById('status').textContent = `✅ Добавлен ${currentShape} (${objects.length} объектов) | HP: ${currentHP}`;
                        setTimeout(() => updateStatus(), 1500);
                        if (ghostMesh) {
                            const height = shapeHeights[currentShape] || 1;
                            ghostMesh.position.set(pos.x, pos.y + height/2, pos.z);
                            ghostMesh.visible = true;
                        }
                    }
                }
            }
        });

        // ----- УДАЛЕНИЕ ПО ПКМ -----
        renderer.domElement.addEventListener('mousedown', (e) => {
            if (e.button === 2 && isLocked) {
                const rect = renderer.domElement.getBoundingClientRect();
                pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
                pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;

                raycaster.setFromCamera(pointer, camera);
                const intersects = raycaster.intersectObjects(objects);
                if (intersects.length > 0) {
                    const obj = intersects[0].object;
                    scene.remove(obj);
                    const idx = objects.indexOf(obj);
                    if (idx !== -1) {
                        objects.splice(idx, 1);
                        obj.geometry.dispose();
                        obj.material.dispose();
                        document.getElementById('status').textContent = `🗑️ Удалён (осталось ${objects.length})`;
                        setTimeout(() => updateStatus(), 1000);
                        if (ghostMesh) {
                            ghostMesh.position.y = -100;
                        }
                    }
                }
            }
        });
        renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault());

        // ----- КООРДИНАТЫ -----
        function updateCoords() {
            document.getElementById('coords').textContent = 
                `📍 X: ${camera.position.x.toFixed(1)} | Y: ${camera.position.y.toFixed(1)} | Z: ${camera.position.z.toFixed(1)}`;
        }

        // ----- АНИМАЦИЯ -----
        function animate() {
            const delta = clock.getDelta();

            // Скорость с учётом Caps Lock (x5)
            const currentSpeed = isCapsLock ? baseSpeed * 5 : baseSpeed;

            const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
            forward.y = 0;
            forward.normalize();

            const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion);
            right.y = 0;
            right.normalize();

            const move = new THREE.Vector3(0, 0, 0);
            if (keys.w) move.add(forward.clone().multiplyScalar(currentSpeed * delta));
            if (keys.s) move.sub(forward.clone().multiplyScalar(currentSpeed * delta));
            if (keys.a) move.sub(right.clone().multiplyScalar(currentSpeed * delta));
            if (keys.d) move.add(right.clone().multiplyScalar(currentSpeed * delta));

            if (keys.space) move.y += currentSpeed * delta * 1.2;
            if (keys.shift) move.y -= currentSpeed * delta * 1.2;

            camera.position.x += move.x;
            camera.position.y += move.y;
            camera.position.z += move.z;
            camera.position.y = Math.max(0.5, Math.min(20, camera.position.y));

            updateCamera();
            updateCoords();

            renderer.render(scene, camera);
            requestAnimationFrame(animate);
        }

        // ----- РЕСАЙЗ -----
        window.addEventListener('resize', () => {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        });

        // ----- УПРАВЛЕНИЕ УРОНОМ И ЛЕЧЕНИЕМ -----
        document.addEventListener('keydown', (e) => {
            if (e.key === 'd' || e.key === 'D') {
                if (objects.length > 0) {
                    const target = objects[objects.length - 1];
                    damageObject(target, 100);
                    const hp = target.userData.hp;
                    const maxHp = target.userData.maxHp;
                    document.getElementById('status').textContent = `💥 Урон 100 по ${target.userData.shape}! (${hp}/${maxHp} HP)`;
                    setTimeout(() => updateStatus(), 2000);
                }
            }
            if (e.key === 'h' || e.key === 'H') {
                if (objects.length > 0) {
                    const target = objects[objects.length - 1];
                    healObject(target, 50);
                    const hp = target.userData.hp;
                    const maxHp = target.userData.maxHp;
                    document.getElementById('status').textContent = `💚 Лечение 50 для ${target.userData.shape}! (${hp}/${maxHp} HP)`;
                    setTimeout(() => updateStatus(), 2000);
                }
            }
        });

        // ----- СТАРТ -----
        animate();
        updateStatus();
        console.log('🏗️ 3D Конструктор — финальная версия!');
        console.log('🖱️ ЛКМ — поставить, ПКМ — удалить');
        console.log('⚡ Caps Lock — ускорение x5');
        console.log('💥 D — урон 100, 💚 H — лечение 50');
        console.log('📐 Размер карты настраивается');
    </script>
</body>
</html>

игра

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>3D Игра — точная атака с прицелом</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            overflow: hidden;
        }
        body {
            background: #1a1a2e;
            font-family: Arial, sans-serif;
        }
        canvas {
            display: block;
        }

        #ui {
            position: fixed;
            bottom: 30px;
            left: 50%;
            transform: translateX(-50%);
            background: rgba(10, 10, 30, 0.85);
            backdrop-filter: blur(8px);
            padding: 12px 24px;
            border-radius: 40px;
            border: 1px solid #3f4f8a;
            display: flex;
            gap: 12px;
            align-items: center;
            flex-wrap: wrap;
            justify-content: center;
            pointer-events: auto;
            box-shadow: 0 8px 30px rgba(0,0,0,0.7);
            z-index: 10;
        }
        #ui button {
            background: #2a3a6a;
            border: none;
            color: #eee;
            padding: 6px 18px;
            border-radius: 30px;
            font-size: 14px;
            font-weight: 600;
            cursor: pointer;
            transition: 0.15s;
            border: 1px solid #4a5a8a;
            letter-spacing: 0.3px;
            white-space: nowrap;
        }
        #ui button:hover {
            background: #3a4a8a;
            transform: scale(1.04);
            border-color: #7a9aff;
        }
        #ui input[type="file"] {
            display: none;
        }
        #ui .sep {
            width: 2px;
            height: 30px;
            background: #3f4f7a;
            border-radius: 4px;
        }
        #info {
            position: fixed;
            top: 20px;
            left: 50%;
            transform: translateX(-50%);
            color: rgba(255, 255, 255, 0.8);
            background: rgba(0, 0, 0, 0.6);
            padding: 8px 24px;
            border-radius: 30px;
            font-size: 14px;
            border: 1px solid #2a3a6a;
            backdrop-filter: blur(4px);
            pointer-events: none;
            user-select: none;
            text-align: center;
            z-index: 10;
            white-space: nowrap;
        }
        #info span {
            color: #aaccff;
            font-weight: bold;
        }
        #status {
            position: fixed;
            bottom: 100px;
            left: 50%;
            transform: translateX(-50%);
            color: #88aaff;
            background: rgba(0, 0, 20, 0.7);
            padding: 6px 18px;
            border-radius: 30px;
            font-size: 13px;
            border: 1px solid #3f4f7a;
            backdrop-filter: blur(4px);
            pointer-events: none;
            z-index: 10;
            transition: 0.2s;
            white-space: nowrap;
        }
        #controls-hint {
            position: fixed;
            bottom: 170px;
            left: 50%;
            transform: translateX(-50%);
            color: rgba(255, 255, 255, 0.4);
            background: rgba(0, 0, 0, 0.4);
            padding: 4px 16px;
            border-radius: 20px;
            font-size: 12px;
            backdrop-filter: blur(2px);
            pointer-events: none;
            z-index: 10;
            text-align: center;
            border: 1px solid rgba(255,255,255,0.05);
        }
        #coords {
            position: fixed;
            top: 80px;
            left: 50%;
            transform: translateX(-50%);
            color: rgba(255, 255, 255, 0.5);
            font-size: 13px;
            background: rgba(0, 0, 0, 0.4);
            padding: 4px 16px;
            border-radius: 20px;
            pointer-events: none;
            z-index: 10;
            font-family: monospace;
        }
        #hp-panel {
            position: fixed;
            right: 20px;
            top: 50%;
            transform: translateY(-50%);
            background: rgba(10, 10, 30, 0.85);
            backdrop-filter: blur(8px);
            padding: 12px 16px;
            border-radius: 12px;
            border: 1px solid #3f4f8a;
            color: #b0c0ee;
            font-size: 12px;
            z-index: 10;
            max-height: 70vh;
            overflow-y: auto;
            min-width: 140px;
            pointer-events: none;
        }
        #hp-panel::-webkit-scrollbar {
            width: 4px;
        }
        #hp-panel::-webkit-scrollbar-track {
            background: transparent;
        }
        #hp-panel::-webkit-scrollbar-thumb {
            background: #4a5a8a;
            border-radius: 10px;
        }
        .hp-item {
            padding: 4px 0;
            border-bottom: 1px solid #2a3a6a;
            font-family: monospace;
            font-size: 11px;
        }
        .hp-item:last-child {
            border-bottom: none;
        }
        .hp-bar {
            display: inline-block;
            height: 8px;
            border-radius: 4px;
            margin-left: 6px;
            transition: width 0.3s;
        }
        .hp-bar.high {
            background: #44ff88;
        }
        .hp-bar.mid {
            background: #ffaa44;
        }
        .hp-bar.low {
            background: #ff2244;
        }
        #caps-indicator {
            position: fixed;
            top: 60px;
            right: 20px;
            background: rgba(10, 10, 30, 0.7);
            backdrop-filter: blur(8px);
            padding: 6px 14px;
            border-radius: 20px;
            border: 1px solid #4a5a8a;
            color: #6a7aaa;
            font-size: 12px;
            z-index: 10;
            pointer-events: none;
            transition: 0.3s;
        }
        #caps-indicator.active {
            border-color: #44ff88;
            color: #44ff88;
            box-shadow: 0 0 20px rgba(68, 255, 136, 0.2);
        }
        
        /* ЯРКИЙ ПРИЦЕЛ */
        #crosshair {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            z-index: 5;
            pointer-events: none;
            color: #00ff88;
            font-size: 36px;
            font-family: monospace;
            text-shadow: 
                0 0 10px rgba(0, 255, 136, 0.8),
                0 0 20px rgba(0, 255, 136, 0.5),
                0 0 40px rgba(0, 255, 136, 0.3),
                0 0 60px rgba(0, 255, 136, 0.2);
            opacity: 0.9;
            font-weight: bold;
        }
        #crosshair::before {
            content: '+';
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
        }
        #crosshair-dot {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            z-index: 6;
            pointer-events: none;
            width: 4px;
            height: 4px;
            background: #ff2244;
            border-radius: 50%;
            box-shadow: 0 0 10px rgba(255, 34, 68, 0.8);
        }
        #crosshair-ring {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            z-index: 5;
            pointer-events: none;
            width: 60px;
            height: 60px;
            border: 2px solid rgba(0, 255, 136, 0.15);
            border-radius: 50%;
            box-shadow: 0 0 20px rgba(0, 255, 136, 0.05);
        }
        
        @media (max-width: 700px) {
            #ui { padding: 10px 14px; gap: 6px; bottom: 16px; }
            #ui button { font-size: 12px; padding: 4px 12px; }
            #info { font-size: 11px; top: 10px; white-space: normal; }
            #status { bottom: 80px; font-size: 11px; }
            #controls-hint { bottom: 140px; font-size: 10px; }
            #coords { top: 70px; font-size: 11px; }
            #hp-panel { right: 10px; padding: 8px 12px; min-width: 80px; font-size: 10px; }
            .hp-item { font-size: 9px; }
            #caps-indicator { top: 50px; right: 10px; font-size: 10px; padding: 4px 10px; }
            #crosshair { font-size: 28px; }
            #crosshair-ring { width: 40px; height: 40px; }
        }
    </style>
</head>
<body>

    <!-- ЯРКИЙ ПРИЦЕЛ -->
    <div id="crosshair"></div>
    <div id="crosshair-dot"></div>
    <div id="crosshair-ring"></div>

    <div id="info">
        <span>W A S D</span> — движение &nbsp;|&nbsp; <span>Пробел</span> — вверх &nbsp;|&nbsp; <span>Shift</span> — вниз &nbsp;|&nbsp; <span>Мышь</span> — осмотр
    </div>
    <div id="coords">📍 X: 0.0 | Y: 0.0 | Z: 0.0</div>
    <div id="status">🎮 Игра загружена | Блоков: 0</div>
    <div id="controls-hint">🖱️ ЛКМ — атака (50 урона) | ПКМ — удалить</div>
    <div id="caps-indicator">🔒 Caps Lock: OFF</div>

    <div id="ui">
        <button id="loadBtn">📂 Загрузить карту</button>
        <input type="file" id="fileInput" accept=".json" />
        <span class="sep"></span>
        <button id="clearBtn">🗑️ Очистить</button>
        <button id="saveBtn">💾 Сохранить</button>
        <span class="sep"></span>
        <span style="color:#ff6644; font-weight:bold;">⚔️ Урон: 50</span>
        <span class="sep"></span>
        <span id="totalHp" style="color:#88aaff;">❤️ Общий HP: 0</span>
    </div>

    <div id="hp-panel">
        <div style="font-weight:bold; margin-bottom:8px; color:#88aaff;">📊 Блоки</div>
        <div id="hpList">Нет блоков</div>
    </div>

    <script src="three.min.js"></script>
    <script>
        // ----- СЦЕНА, КАМЕРА, РЕНДЕР -----
        const scene = new THREE.Scene();
        scene.background = new THREE.Color(0x1a1a2e);

        const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 500);
        camera.position.set(0, 3, 8);

        const renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
        renderer.shadowMap.enabled = true;
        renderer.shadowMap.type = THREE.PCFSoftShadowMap;
        document.body.appendChild(renderer.domElement);

        // ----- СВЕТ -----
        const ambient = new THREE.AmbientLight(0x404060);
        scene.add(ambient);

        const sun = new THREE.DirectionalLight(0xffeedd, 1.8);
        sun.position.set(10, 20, 5);
        sun.castShadow = true;
        sun.shadow.mapSize.width = 1024;
        sun.shadow.mapSize.height = 1024;
        const d = 50;
        sun.shadow.camera.left = -d;
        sun.shadow.camera.right = d;
        sun.shadow.camera.top = d;
        sun.shadow.camera.bottom = -d;
        sun.shadow.camera.near = 1;
        sun.shadow.camera.far = 100;
        scene.add(sun);

        const fill = new THREE.DirectionalLight(0x4488ff, 0.4);
        fill.position.set(-5, 0, 10);
        scene.add(fill);

        // ----- ПЕРЕМЕННЫЕ КАРТЫ -----
        let mapSize = 20;
        let floor, grid, gridFine;

        function buildMap(size) {
            if (floor) {
                scene.remove(floor);
                floor.geometry.dispose();
                floor.material.dispose();
            }
            if (grid) {
                scene.remove(grid);
                grid.geometry.dispose();
                grid.material.dispose();
            }
            if (gridFine) {
                scene.remove(gridFine);
                gridFine.geometry.dispose();
                gridFine.material.dispose();
            }

            const floorMat = new THREE.MeshStandardMaterial({ color: 0x2a2a4a, roughness: 0.9, metalness: 0.1 });
            floor = new THREE.Mesh(new THREE.PlaneGeometry(size, size), floorMat);
            floor.rotation.x = -Math.PI / 2;
            floor.position.y = -0.5;
            floor.receiveShadow = true;
            floor.name = 'floor';
            scene.add(floor);

            grid = new THREE.GridHelper(size, size, 0x8888ff, 0x444488);
            grid.position.y = -0.49;
            scene.add(grid);

            gridFine = new THREE.GridHelper(size, size * 2, 0x6666aa, 0x333366);
            gridFine.position.y = -0.48;
            gridFine.material.transparent = true;
            gridFine.material.opacity = 0.3;
            scene.add(gridFine);

            const halfSize = size / 2;
            camera.position.set(0, Math.min(size * 0.3, 15), size * 0.6);
        }

        buildMap(mapSize);

        // ----- МЕНЕДЖЕР ОБЪЕКТОВ -----
        const objects = [];
        let objectIdCounter = 0;

        const shapeMap = {
            box: () => new THREE.BoxGeometry(1, 1, 1),
            sphere: () => new THREE.SphereGeometry(0.5, 20, 20),
            cylinder: () => new THREE.CylinderGeometry(0.5, 0.5, 1, 16),
            cone: () => new THREE.ConeGeometry(0.5, 1, 16),
            torus: () => new THREE.TorusGeometry(0.5, 0.25, 12, 20),
        };

        const shapeHeights = {
            box: 1,
            sphere: 1,
            cylinder: 1,
            cone: 1,
            torus: 0.5,
        };

        // ----- ФУНКЦИЯ ЦВЕТА ПО HP -----
        function getColorForHP(hp) {
            let color = new THREE.Color();
            if (hp <= 100) {
                const t = hp / 100;
                color.lerpColors(new THREE.Color(0x228833), new THREE.Color(0x44ff88), t);
            } else if (hp <= 500) {
                const t = (hp - 100) / 400;
                color.lerpColors(new THREE.Color(0x44ff88), new THREE.Color(0x88ff44), t);
            } else if (hp <= 1000) {
                const t = (hp - 500) / 500;
                color.lerpColors(new THREE.Color(0x88ff44), new THREE.Color(0xffaa44), t);
            } else if (hp <= 2500) {
                const t = (hp - 1000) / 1500;
                color.lerpColors(new THREE.Color(0xffaa44), new THREE.Color(0xff6644), t);
            } else {
                const t = Math.min(1, (hp - 2500) / 2500);
                color.lerpColors(new THREE.Color(0xff6644), new THREE.Color(0xff2244), t);
            }
            return color;
        }

        function updateObjectColor(obj) {
            const hp = obj.userData.hp || 0;
            const color = getColorForHP(hp);
            obj.material.color.copy(color);
            const maxHp = obj.userData.maxHp || 1000;
            const percent = Math.max(0, Math.min(1, hp / maxHp));
            obj.material.emissive.copy(color);
            obj.material.emissive.multiplyScalar(0.1 + (1 - percent) * 0.3);
        }

        // ----- ДОБАВЛЕНИЕ ОБЪЕКТА -----
        function addObject(shape, position, hp) {
            const geoFn = shapeMap[shape];
            if (!geoFn) return null;
            
            const color = getColorForHP(hp);
            const mat = new THREE.MeshStandardMaterial({
                color: color,
                roughness: 0.4,
                metalness: 0.2,
                emissive: color.clone().multiplyScalar(0.1),
            });
            const mesh = new THREE.Mesh(geoFn(), mat);
            
            const height = shapeHeights[shape] || 1;
            mesh.position.set(position.x, position.y + height/2, position.z);
            
            mesh.castShadow = true;
            mesh.receiveShadow = true;
            mesh.name = 'userObject';
            mesh.userData.shape = shape;
            mesh.userData.height = height;
            mesh.userData.baseY = position.y;
            mesh.userData.hp = hp;
            mesh.userData.maxHp = hp;
            mesh.userData.id = objectIdCounter++;
            
            scene.add(mesh);
            objects.push(mesh);
            updateUI();
            return mesh;
        }

        // ----- ПОДСВЕТКА БЛОКА ПРИ НАВЕДЕНИИ -----
        let highlightedObject = null;

        function updateHighlight() {
            const rect = renderer.domElement.getBoundingClientRect();
            const centerX = rect.left + rect.width / 2;
            const centerY = rect.top + rect.height / 2;
            
            const pointer = new THREE.Vector2();
            pointer.x = ((centerX - rect.left) / rect.width) * 2 - 1;
            pointer.y = -((centerY - rect.top) / rect.height) * 2 + 1;
            
            const raycaster = new THREE.Raycaster();
            raycaster.setFromCamera(pointer, camera);
            
            const intersects = raycaster.intersectObjects(objects);
            
            // Снимаем подсветку с предыдущего объекта
            if (highlightedObject && highlightedObject !== (intersects.length > 0 ? intersects[0].object : null)) {
                const hp = highlightedObject.userData.hp || 0;
                const color = getColorForHP(hp);
                highlightedObject.material.color.copy(color);
                highlightedObject.material.emissive.copy(color);
                const maxHp = highlightedObject.userData.maxHp || 1000;
                const percent = Math.max(0, Math.min(1, hp / maxHp));
                highlightedObject.material.emissive.multiplyScalar(0.1 + (1 - percent) * 0.3);
                highlightedObject = null;
            }
            
            if (intersects.length > 0) {
                const obj = intersects[0].object;
                highlightedObject = obj;
                // Подсвечиваем белым
                obj.material.color.set(0xffffff);
                obj.material.emissive.set(0x4488ff);
                obj.material.emissiveIntensity = 0.5;
                document.getElementById('crosshair-dot').style.background = '#44ff88';
                document.getElementById('crosshair-dot').style.boxShadow = '0 0 15px rgba(68, 255, 136, 0.9)';
            } else {
                document.getElementById('crosshair-dot').style.background = '#ff2244';
                document.getElementById('crosshair-dot').style.boxShadow = '0 0 10px rgba(255, 34, 68, 0.8)';
            }
        }

        // ----- АТАКА (50 урона) строго по прицелу -----
        function attackAtCrosshair() {
            const rect = renderer.domElement.getBoundingClientRect();
            const centerX = rect.left + rect.width / 2;
            const centerY = rect.top + rect.height / 2;
            
            const pointer = new THREE.Vector2();
            pointer.x = ((centerX - rect.left) / rect.width) * 2 - 1;
            pointer.y = -((centerY - rect.top) / rect.height) * 2 + 1;
            
            const raycaster = new THREE.Raycaster();
            raycaster.setFromCamera(pointer, camera);
            
            const intersects = raycaster.intersectObjects(objects);
            
            if (intersects.length > 0) {
                const obj = intersects[0].object;
                attackObject(obj);
                return true;
            } else {
                document.getElementById('status').textContent = '❌ Промах!';
                setTimeout(() => updateStatus(), 1000);
                return false;
            }
        }

        function attackObject(obj) {
            if (!obj || obj.userData.hp <= 0) return;
            
            const damage = 50;
            obj.userData.hp = Math.max(0, obj.userData.hp - damage);
            updateObjectColor(obj);
            
            // Эффект удара
            obj.material.emissiveIntensity = 0.8;
            setTimeout(() => {
                if (obj.material) {
                    const percent = obj.userData.hp / obj.userData.maxHp;
                    obj.material.emissiveIntensity = 0.1 + (1 - percent) * 0.3;
                }
            }, 100);
            
            if (obj.userData.hp <= 0) {
                scene.remove(obj);
                const idx = objects.indexOf(obj);
                if (idx !== -1) {
                    objects.splice(idx, 1);
                    obj.geometry.dispose();
                    obj.material.dispose();
                    document.getElementById('status').textContent = `💥 Блок уничтожен! Осталось: ${objects.length}`;
                    setTimeout(() => updateStatus(), 2000);
                }
                if (highlightedObject === obj) {
                    highlightedObject = null;
                }
            } else {
                document.getElementById('status').textContent = `⚔️ Атака! -${damage} HP (${obj.userData.hp}/${obj.userData.maxHp})`;
                setTimeout(() => updateStatus(), 1500);
            }
            updateUI();
            updateStatus();
        }

        // ----- УДАЛЕНИЕ ВСЕХ -----
        function clearObjects() {
            for (const obj of objects) {
                scene.remove(obj);
                obj.geometry.dispose();
                obj.material.dispose();
            }
            objects.length = 0;
            objectIdCounter = 0;
            highlightedObject = null;
            updateUI();
            updateStatus();
        }

        // ----- СОХРАНЕНИЕ -----
        function saveToFile() {
            if (objects.length === 0) {
                document.getElementById('status').textContent = '⚠️ Нет объектов для сохранения';
                setTimeout(() => updateStatus(), 1500);
                return;
            }

            const data = {
                version: '1.0',
                mapSize: mapSize,
                objects: objects.map(obj => ({
                    shape: obj.userData.shape || 'box',
                    x: obj.position.x,
                    y: obj.userData.baseY !== undefined ? obj.userData.baseY : obj.position.y - shapeHeights[obj.userData.shape]/2,
                    z: obj.position.z,
                    hp: obj.userData.hp || 1000,
                    maxHp: obj.userData.maxHp || 1000,
                }))
            };

            const json = JSON.stringify(data, null, 2);
            const blob = new Blob([json], { type: 'application/json' });
            const url = URL.createObjectURL(blob);
            
            const a = document.createElement('a');
            a.href = url;
            a.download = 'map3d_' + new Date().toISOString().slice(0,19).replace(/:/g, '-') + '.json';
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);

            document.getElementById('status').textContent = '✅ Карта сохранена!';
            setTimeout(() => updateStatus(), 1500);
        }

        // ----- ЗАГРУЗКА -----
        function loadFromFile(file) {
            const reader = new FileReader();
            reader.onload = (e) => {
                try {
                    const data = JSON.parse(e.target.result);
                    
                    if (data.mapSize) {
                        mapSize = data.mapSize;
                        buildMap(mapSize);
                    }
                    
                    if (!Array.isArray(data.objects)) {
                        throw new Error('Неверный формат данных');
                    }
                    
                    clearObjects();
                    let loadedCount = 0;
                    for (const item of data.objects) {
                        if (!item.shape || item.x === undefined || item.y === undefined || item.z === undefined) {
                            continue;
                        }
                        const pos = new THREE.Vector3(item.x, item.y, item.z);
                        const hp = item.hp || item.maxHp || 1000;
                        const mesh = addObject(item.shape, pos, hp);
                        if (mesh) {
                            mesh.userData.shape = item.shape;
                            mesh.userData.hp = hp;
                            mesh.userData.maxHp = item.maxHp || hp;
                            updateObjectColor(mesh);
                            loadedCount++;
                        }
                    }
                    
                    document.getElementById('status').textContent = `📂 Загружено: ${loadedCount} блоков`;
                    setTimeout(() => updateStatus(), 2000);
                    updateUI();
                } catch (err) {
                    console.error(err);
                    document.getElementById('status').textContent = '❌ Ошибка загрузки файла';
                    setTimeout(() => updateStatus(), 2000);
                }
            };
            reader.onerror = () => {
                document.getElementById('status').textContent = '❌ Ошибка чтения файла';
                setTimeout(() => updateStatus(), 2000);
            };
            reader.readAsText(file);
        }

        // ----- ОБНОВЛЕНИЕ UI -----
        function updateUI() {
            const hpList = document.getElementById('hpList');
            const totalHp = document.getElementById('totalHp');
            
            if (objects.length === 0) {
                hpList.innerHTML = '<div style="color: #6a7aaa; font-size: 12px;">Нет блоков</div>';
                totalHp.textContent = '❤️ Общий HP: 0';
                return;
            }
            
            let total = 0;
            let html = '';
            const displayObjects = objects.slice(-15);
            for (const obj of displayObjects) {
                const hp = obj.userData.hp || 0;
                const maxHp = obj.userData.maxHp || 1000;
                total += hp;
                const percent = Math.max(0, (hp / maxHp) * 100);
                const shapeName = obj.userData.shape || 'box';
                const color = obj.material.color.getHexString();
                
                let hpClass = 'high';
                if (percent < 25) hpClass = 'low';
                else if (percent < 50) hpClass = 'mid';
                
                html += `<div class="hp-item">
                    <span style="display:inline-block;width:10px;height:10px;background:#${color};border-radius:3px;vertical-align:middle;"></span>
                    ${shapeName} 
                    <span style="color:#88aaff;">${Math.round(hp)}/${maxHp}</span>
                    <span class="hp-bar ${hpClass}" style="width:${percent}%;"></span>
                </div>`;
            }
            if (objects.length > 15) {
                html += `<div style="color: #6a7aaa; font-size: 10px; text-align:center; padding-top:4px;">+ ещё ${objects.length - 15} блоков</div>`;
            }
            hpList.innerHTML = html;
            totalHp.textContent = `❤️ Общий HP: ${Math.round(total)}`;
        }

        function updateStatus() {
            document.getElementById('status').textContent = `🎮 Игра | Блоков: ${objects.length} | Карта: ${mapSize}x${mapSize}`;
        }

        // ----- УПРАВЛЕНИЕ -----
        const keys = { w: false, a: false, s: false, d: false, space: false, shift: false };
        let isCapsLock = false;

        document.addEventListener('keydown', (e) => {
            switch (e.code) {
                case 'KeyW': keys.w = true; e.preventDefault(); break;
                case 'KeyA': keys.a = true; e.preventDefault(); break;
                case 'KeyS': keys.s = true; e.preventDefault(); break;
                case 'KeyD': keys.d = true; e.preventDefault(); break;
                case 'Space': keys.space = true; e.preventDefault(); break;
                case 'ShiftLeft':
                case 'ShiftRight': keys.shift = true; e.preventDefault(); break;
                case 'CapsLock': 
                    isCapsLock = !isCapsLock;
                    const indicator = document.getElementById('caps-indicator');
                    if (isCapsLock) {
                        indicator.textContent = '⚡ Caps Lock: ON (x5)';
                        indicator.classList.add('active');
                    } else {
                        indicator.textContent = '🔒 Caps Lock: OFF';
                        indicator.classList.remove('active');
                    }
                    e.preventDefault();
                    break;
                default: break;
            }
        });
        document.addEventListener('keyup', (e) => {
            switch (e.code) {
                case 'KeyW': keys.w = false; e.preventDefault(); break;
                case 'KeyA': keys.a = false; e.preventDefault(); break;
                case 'KeyS': keys.s = false; e.preventDefault(); break;
                case 'KeyD': keys.d = false; e.preventDefault(); break;
                case 'Space': keys.space = false; e.preventDefault(); break;
                case 'ShiftLeft':
                case 'ShiftRight': keys.shift = false; e.preventDefault(); break;
                default: break;
            }
        });

        // ----- МЫШЬ -----
        let isLocked = false;
        renderer.domElement.addEventListener('click', () => {
            if (!isLocked) {
                renderer.domElement.requestPointerLock();
            }
        });
        document.addEventListener('pointerlockchange', () => {
            isLocked = document.pointerLockElement === renderer.domElement;
        });

        let yaw = 0, pitch = 0;
        document.addEventListener('mousemove', (e) => {
            if (!isLocked) return;
            const sens = 0.002;
            yaw -= e.movementX * sens;
            pitch -= e.movementY * sens;
            pitch = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, pitch));
            // Обновляем подсветку при движении мыши
            updateHighlight();
        });

        // ----- ДВИЖЕНИЕ -----
        const baseSpeed = 4.0;
        const clock = new THREE.Clock();

        function updateCamera() {
            const euler = new THREE.Euler(pitch, yaw, 0, 'YXZ');
            camera.quaternion.setFromEuler(euler);
        }

        // ----- АТАКА ПО ЛКМ -----
        renderer.domElement.addEventListener('click', (e) => {
            if (!isLocked) return;
            attackAtCrosshair();
        });

        // ----- УДАЛЕНИЕ ПО ПКМ -----
        renderer.domElement.addEventListener('mousedown', (e) => {
            if (e.button === 2 && isLocked) {
                const rect = renderer.domElement.getBoundingClientRect();
                const centerX = rect.left + rect.width / 2;
                const centerY = rect.top + rect.height / 2;
                
                const pointer = new THREE.Vector2();
                pointer.x = ((centerX - rect.left) / rect.width) * 2 - 1;
                pointer.y = -((centerY - rect.top) / rect.height) * 2 + 1;
                
                const raycaster = new THREE.Raycaster();
                raycaster.setFromCamera(pointer, camera);
                const intersects = raycaster.intersectObjects(objects);
                
                if (intersects.length > 0) {
                    const obj = intersects[0].object;
                    scene.remove(obj);
                    const idx = objects.indexOf(obj);
                    if (idx !== -1) {
                        objects.splice(idx, 1);
                        obj.geometry.dispose();
                        obj.material.dispose();
                        document.getElementById('status').textContent = `🗑️ Удалён (осталось ${objects.length})`;
                        setTimeout(() => updateStatus(), 1000);
                        updateUI();
                        if (highlightedObject === obj) {
                            highlightedObject = null;
                        }
                    }
                }
            }
        });
        renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault());

        // ----- КООРДИНАТЫ -----
        function updateCoords() {
            document.getElementById('coords').textContent = 
                `📍 X: ${camera.position.x.toFixed(1)} | Y: ${camera.position.y.toFixed(1)} | Z: ${camera.position.z.toFixed(1)}`;
        }

        // ----- АНИМАЦИЯ -----
        function animate() {
            const delta = clock.getDelta();
            const currentSpeed = isCapsLock ? baseSpeed * 5 : baseSpeed;

            const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
            forward.y = 0;
            forward.normalize();

            const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion);
            right.y = 0;
            right.normalize();

            const move = new THREE.Vector3(0, 0, 0);
            if (keys.w) move.add(forward.clone().multiplyScalar(currentSpeed * delta));
            if (keys.s) move.sub(forward.clone().multiplyScalar(currentSpeed * delta));
            if (keys.a) move.sub(right.clone().multiplyScalar(currentSpeed * delta));
            if (keys.d) move.add(right.clone().multiplyScalar(currentSpeed * delta));

            if (keys.space) move.y += currentSpeed * delta * 1.2;
            if (keys.shift) move.y -= currentSpeed * delta * 1.2;

            camera.position.x += move.x;
            camera.position.y += move.y;
            camera.position.z += move.z;
            camera.position.y = Math.max(0.5, Math.min(20, camera.position.y));

            updateCamera();
            updateCoords();

            renderer.render(scene, camera);
            requestAnimationFrame(animate);
        }

        // ----- UI -----
        document.getElementById('loadBtn').addEventListener('click', () => {
            document.getElementById('fileInput').click();
        });

        document.getElementById('fileInput').addEventListener('change', (e) => {
            if (e.target.files.length > 0) {
                loadFromFile(e.target.files[0]);
                e.target.value = '';
            }
        });

        document.getElementById('clearBtn').addEventListener('click', () => {
            if (confirm('Удалить все блоки?')) {
                clearObjects();
            }
        });

        document.getElementById('saveBtn').addEventListener('click', saveToFile);

        // ----- РЕСАЙЗ -----
        window.addEventListener('resize', () => {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        });

        // ----- СТАРТ -----
        animate();
        updateStatus();
        updateUI();
        console.log('🎮 Игра запущена! Яркий прицел + подсветка блоков');
        console.log('🖱️ ЛКМ — атака (50 урона) строго по центру экрана');
        console.log('💡 Блок подсвечивается белым при наведении');
    </script>
</body>
</html>

можно портировать на esp32, сделать загрузку частей карты с sd карты, по мере продвижения, и добавить много чего, но это все в теории! а это просто набросок, и времени проверять нету, но может заинтересует, извините за флуд не по теме

p.s. там переведено на локальную библиотеку https://github.com/mrdoob/three.js/blob/r128/build/three.min.js которую надо положить рядом с html, что бы без доступа к сети работало, изменил код и забыл)))