🎮ArcadeLab

Burma Expedition · 4 Waves

by UltraNinja80
4745 lines197.2 KB🛠️ Three.js (3D graphics)
▶ Play
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Burma Expedition · 4 Waves</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: 'Segoe UI', sans-serif;
            overflow: hidden;
            background: #1a1a1a;
            color: #eee;
            user-select: none;
        }
        #gameContainer {
            position: relative;
            width: 100vw;
            height: 100vh;
        }
        canvas {
            display: block;
        }

        #hud {
            position: absolute;
            top: 20px;
            left: 20px;
            z-index: 10;
            pointer-events: none;
            font-size: 14px;
            line-height: 1.7;
            text-shadow: 0 0 10px rgba(0, 0, 0, 0.95);
            background: rgba(0, 0, 0, 0.55);
            padding: 12px 18px;
            border-radius: 10px;
            border: 1px solid rgba(255, 255, 255, 0.08);
            min-width: 200px;
            backdrop-filter: blur(4px);
        }
        #hud .health {
            font-size: 22px;
            font-weight: bold;
        }
        #hud .health .hp-fill {
            color: #4caf50;
        }
        #hud .health .hp-fill.low {
            color: #ff4444;
            animation: pulse 0.8s infinite;
        }
        #hud .ammo {
            font-size: 20px;
            font-weight: bold;
            margin-top: 2px;
        }
        #hud .ammo .ammo-num {
            color: #ffd700;
        }
        #hud .weapon-name {
            font-size: 16px;
            color: #88ccff;
            margin-top: 2px;
            font-weight: 600;
        }
        #hud .enemies {
            margin-top: 4px;
            color: #ff8a80;
        }
        #hud .enemies span {
            font-weight: bold;
        }
        #hud .wave-info {
            margin-top: 4px;
            color: #ffd700;
            font-weight: bold;
        }
        #hud .weapon-hint {
            font-size: 12px;
            color: #aaa;
            margin-top: 4px;
            border-top: 1px solid rgba(255, 255, 255, 0.1);
            padding-top: 4px;
        }
        #hud .weapon-hint kbd {
            background: #333;
            padding: 0 8px;
            border-radius: 4px;
            border: 1px solid #555;
            font-size: 11px;
            color: #fff;
        }
        @keyframes pulse {
            0%,
            100% {
                opacity: 1;
            }
            50% {
                opacity: 0.5;
            }
        }

        #crosshair {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            z-index: 5;
            pointer-events: none;
            width: 32px;
            height: 32px;
        }
        #crosshair .dot {
            position: absolute;
            width: 4px;
            height: 4px;
            background: rgba(255, 255, 255, 0.9);
            border-radius: 50%;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
        }
        #crosshair .line {
            position: absolute;
            background: rgba(255, 255, 255, 0.6);
        }
        #crosshair .line-v {
            width: 1.5px;
            height: 12px;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
        }
        #crosshair .line-h {
            width: 12px;
            height: 1.5px;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
        }

        #hitMarker {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            z-index: 6;
            pointer-events: none;
            opacity: 0;
            width: 40px;
            height: 40px;
        }
        #hitMarker .x {
            position: absolute;
            background: #ff4444;
            width: 20px;
            height: 2px;
            top: 50%;
            left: 50%;
        }
        #hitMarker .x1 {
            transform: translate(-50%, -50%) rotate(45deg);
        }
        #hitMarker .x2 {
            transform: translate(-50%, -50%) rotate(-45deg);
        }
        #hitMarker.active {
            animation: hitFlash 0.25s ease-out;
        }
        @keyframes hitFlash {
            0% {
                opacity: 1;
                transform: translate(-50%, -50%) scale(0.6);
            }
            100% {
                opacity: 0;
                transform: translate(-50%, -50%) scale(1.4);
            }
        }

        #damageOverlay {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            z-index: 4;
            pointer-events: none;
            opacity: 0;
            background: radial-gradient(ellipse at center, transparent 50%, rgba(255, 0, 0, 0.35) 100%);
            transition: opacity 0.08s;
        }
        #damageOverlay.show {
            opacity: 1;
        }

        #lockOverlay {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            z-index: 20;
            background: rgba(0, 0, 0, 0.9);
            padding: 30px 40px;
            border-radius: 12px;
            text-align: center;
            border: 1px solid #555;
            pointer-events: auto;
            cursor: pointer;
            user-select: none;
            max-width: 520px;
            backdrop-filter: blur(8px);
        }
        #lockOverlay h2 {
            color: #ffd700;
            margin-bottom: 12px;
            font-size: 28px;
        }
        #lockOverlay p {
            margin: 6px 0;
            font-size: 15px;
            color: #ccc;
        }
        #lockOverlay .keys {
            display: flex;
            flex-wrap: wrap;
            gap: 6px;
            justify-content: center;
            margin: 10px 0;
        }
        #lockOverlay .key {
            background: #333;
            border: 1px solid #666;
            border-radius: 4px;
            padding: 2px 10px;
            font-weight: bold;
            font-size: 13px;
            color: #fff;
        }
        #lockOverlay .warning {
            color: #ff8a80;
            margin-top: 8px;
            font-size: 13px;
        }

        #gameOver {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            z-index: 25;
            background: rgba(0, 0, 0, 0.92);
            padding: 40px 50px;
            border-radius: 16px;
            text-align: center;
            border: 2px solid #ff4444;
            display: none;
            min-width: 320px;
            backdrop-filter: blur(8px);
        }
        #gameOver h2 {
            color: #ff4444;
            font-size: 38px;
            margin-bottom: 16px;
        }
        #gameOver p {
            font-size: 17px;
            margin: 6px 0;
            color: #ddd;
        }
        #gameOver .stat {
            color: #ffd700;
            font-weight: bold;
        }
        #gameOver button {
            margin-top: 22px;
            background: #ffd700;
            color: #000;
            border: none;
            padding: 12px 36px;
            font-size: 18px;
            font-weight: bold;
            border-radius: 8px;
            cursor: pointer;
            transition: background 0.2s;
        }
        #gameOver button:hover {
            background: #ffed4a;
        }

        #reloadIndicator {
            position: absolute;
            bottom: 100px;
            left: 50%;
            transform: translateX(-50%);
            z-index: 10;
            background: rgba(0, 0, 0, 0.8);
            padding: 8px 24px;
            border-radius: 20px;
            font-size: 16px;
            color: #ff9800;
            display: none;
            pointer-events: none;
            border: 1px solid #ff9800;
            font-weight: 600;
        }
        #boltIndicator {
            position: absolute;
            bottom: 140px;
            left: 50%;
            transform: translateX(-50%);
            z-index: 10;
            background: rgba(0, 0, 0, 0.8);
            padding: 6px 20px;
            border-radius: 16px;
            font-size: 14px;
            color: #4caf50;
            display: none;
            pointer-events: none;
            border: 1px solid #4caf50;
            font-weight: 600;
        }

        #killFeed {
            position: absolute;
            top: 80px;
            right: 20px;
            z-index: 10;
            pointer-events: none;
            text-align: right;
        }
        #killFeed .entry {
            background: rgba(0, 0, 0, 0.75);
            padding: 6px 16px;
            margin-bottom: 4px;
            border-radius: 6px;
            border-left: 3px solid #ff4444;
            font-size: 14px;
            animation: slideIn 0.3s ease-out;
            color: #eee;
        }
        @keyframes slideIn {
            from {
                transform: translateX(60px);
                opacity: 0;
            }
            to {
                transform: translateX(0);
                opacity: 1;
            }
        }

        #pickupMsg {
            position: absolute;
            bottom: 180px;
            left: 50%;
            transform: translateX(-50%);
            z-index: 10;
            background: rgba(0, 0, 0, 0.8);
            padding: 8px 24px;
            border-radius: 12px;
            font-size: 16px;
            color: #88ff88;
            display: none;
            pointer-events: none;
            border: 1px solid #88ff88;
            font-weight: 600;
            text-align: center;
            max-width: 400px;
        }

        #pickupPrompt {
            position: absolute;
            bottom: 240px;
            left: 50%;
            transform: translateX(-50%);
            z-index: 10;
            background: rgba(0, 0, 0, 0.85);
            padding: 8px 24px;
            border-radius: 12px;
            font-size: 18px;
            color: #ffd700;
            display: none;
            pointer-events: none;
            border: 1px solid #ffd700;
            font-weight: 600;
            text-align: center;
            animation: pulse 1.2s infinite;
        }

        #loading {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            font-size: 26px;
            color: #ffd700;
            z-index: 30;
        }
        .hidden {
            display: none !important;
        }

        #wheelHint {
            position: absolute;
            bottom: 60px;
            left: 50%;
            transform: translateX(-50%);
            z-index: 10;
            color: rgba(255, 255, 255, 0.3);
            font-size: 13px;
            pointer-events: none;
            background: rgba(0, 0, 0, 0.3);
            padding: 4px 16px;
            border-radius: 12px;
        }

        #waveTransition {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            z-index: 15;
            background: rgba(0, 0, 0, 0.85);
            padding: 30px 50px;
            border-radius: 16px;
            text-align: center;
            border: 2px solid #ffd700;
            display: none;
            pointer-events: none;
            backdrop-filter: blur(8px);
            min-width: 300px;
        }
        #waveTransition h2 {
            color: #ffd700;
            font-size: 36px;
            margin-bottom: 8px;
        }
        #waveTransition p {
            color: #ccc;
            font-size: 18px;
        }
        #waveTransition .sub {
            color: #88ff88;
            font-size: 16px;
            margin-top: 6px;
        }
        #victoryMsg {
            color: #ffd700 !important;
            font-size: 20px !important;
            font-weight: bold;
        }
        #waveCounter {
            position: absolute;
            top: 20px;
            right: 20px;
            z-index: 10;
            pointer-events: none;
            background: rgba(0, 0, 0, 0.6);
            padding: 8px 18px;
            border-radius: 8px;
            border: 1px solid #ffd700;
            color: #ffd700;
            font-weight: bold;
            font-size: 18px;
        }
    </style>
</head>
<body>

    <div id="gameContainer">
        <div id="loading">Loading...</div>

        <div id="lockOverlay">
            <h2>🏯 Burma Expedition</h2>
            <p>Click to lock mouse · WASD move</p>
            <div class="keys">
                <span class="key">W</span><span class="key">A</span>
                <span class="key">S</span><span class="key">D</span>
                <span class="key">Space</span> Jump
                <span class="key">Shift</span> Sprint
            </div>
            <div class="keys">
                <span class="key">LMB</span> Fire
                <span class="key">R</span> Reload
                <span class="key">G</span> Bolt (Lee-Enfield)
                <span class="key">Scroll</span> Switch
                <span class="key">E</span> Pickup
            </div>
            <p style="color:#ff8a80;margin-top:10px;">💀 4 Waves · Enemies do not respawn</p>
            <p style="color:#88ff88;font-size:14px;">🎯 Kill 7/8 of each wave to advance!</p>
            <p style="font-size:13px;color:#888;margin-top:6px;">Start: Lee-Enfield · Scroll to switch</p>
        </div>
<canvas id="radarCanvas" style="position:absolute; top:80px; right:20px; width:120px; height:120px; z-index:10; pointer-events:none; border-radius:50%; background:rgba(0,0,0,0.5); border:2px solid #888;"></canvas>
        <div id="hud">
            <div class="health">❤️ <span class="hp-fill" id="hpText">100</span>%</div>
            <div class="weapon-name" id="weaponName">🔫 Lee-Enfield</div>
            <div class="ammo"><span class="ammo-num" id="ammoText">5 / 30</span></div>
            <div class="enemies">👾 Enemies: <span id="enemyCount">0</span> alive</div>
            <div class="wave-info">🌊 Wave <span id="waveNum">1</span>/4</div>
            <div class="weapon-hint">
                <kbd>Scroll</kbd> Switch · <kbd>R</kbd> Reload · <kbd>E</kbd> Pickup
            </div>
        </div>
        <div id="waveCounter">🌊 Wave <span id="waveNum2">1</span>/4</div>

        <div id="crosshair">
            <div class="dot"></div>
            <div class="line line-v"></div>
            <div class="line line-h"></div>
        </div>

        <div id="hitMarker"><div class="x x1"></div><div class="x x2"></div></div>
        <div id="damageOverlay"></div>

        <div id="reloadIndicator">⏳ Reloading...</div>
        <div id="boltIndicator">🔧 Press G to bolt</div>
        <div id="pickupMsg">📦 Item picked up</div>
        <div id="pickupPrompt">Press <kbd style="background:#333;padding:0 10px;border-radius:4px;border:1px solid #888;">E</kbd> to pickup</div>

        <div id="killFeed"></div>

        <div id="gameOver">
            <h2 id="gameOverTitle">💀 GAME OVER</h2>
            <p>Survived: <span class="stat" id="survivedTime">0</span>s</p>
            <p>Kills: <span class="stat" id="killsCount">0</span></p>
            <p>Accuracy: <span class="stat" id="finalAcc">0%</span></p>
            <button id="restartBtn">↻ Restart</button>
        </div>

        <div id="waveTransition">
            <h2 id="waveTitle">🌊 Wave 1</h2>
            <p id="waveDesc">Jungle · 15 enemies</p>
            <div class="sub" id="waveSub">Kill 7/8 to advance</div>
        </div>

        <div id="wheelHint">🔄 Scroll to switch weapon</div>
    </div>

 <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
    <script>
        (function() {
            'use strict';

            // ============================================================
            //  CONSTANTS
            // ============================================================
            const GRAVITY = 22;
            const PLAYER_SPEED = 15;
            const RUN_MULT = 2.0;
            const MOUSE_SENS = 0.002;
            const MAP_SIZE = 1000;
            const HALF = MAP_SIZE / 2;
            const PLAYER_HEIGHT = 10;
            const PLAYER_RADIUS = 3;
            const ENEMY_HEIGHT = 4;
            const ENEMY_RADIUS = 3;
            const ENEMY_VISION_RANGE = 600;
            const ENEMY_VISION_ANGLE = Math.PI / 1.5;
            const ENEMY_HEARING = 40;
            const ENEMY_PATROL_RADIUS = 200;
            const ENEMY_LOST_TIME = 3.0;
            const BULLET_LIFETIME = 3.0;
            const SUBSTEPS = 30;
            const MAX_DELTA = 1 / 30;

            // ---- Wave config ----
            const WAVE_CONFIG = [{
                wave: 1,
                enemyCount: 15,
                terrain: 'jungle',
                desc: 'Jungle · 15 enemies',
                visual: { fog: 0x2a4a2a, bg: 0x1a2a1a, ambient: 0x445544 }
            }, {
                wave: 2,
                enemyCount: 20,
                terrain: 'city',
                desc: 'City · 20 enemies',
                visual: { fog: 0x6a7a8a, bg: 0x3a4a5a, ambient: 0x667788 }
            }, {
                wave: 3,
                enemyCount: 35,
                terrain: 'city_dense',
                desc: 'Urban · 35 enemies',
                visual: { fog: 0x5a5a5a, bg: 0x2a2a2a, ambient: 0x556666 }
            }, {
                wave: 4,
                enemyCount: 50,
                terrain: 'city_dense',
                desc: 'Urban · 50 enemies',
                visual: { fog: 0x4a4a4a, bg: 0x1a1a1a, ambient: 0x445555 }
            }];

            // ============================================================
            //  WEAPON DEFS (WW2 themed)
            // ============================================================
            const WEAPON_DEFS = {
                lee_enfield: {
                    id: 'lee_enfield',
                    name: 'Lee-Enfield',
                    displayName: 'Lee-Enfield Rifle',
                    damage: 300,
                    fireRate: 40,
                    maxAmmo: 5,
                    totalAmmo: 30,
                    reloadTime: 3.0,
                    spread: 0.015,
                    needsBolt: true,
                    range: 1000,
                    auto: false,
                    icon: '🎯',
                    modelFn: 'createLeeEnfield'
                },
                thompson: {
                    id: 'thompson',
                    name: 'Thompson',
                    displayName: 'Thompson SMG',
                    damage: 25,
                    fireRate: 600,
                    maxAmmo: 30,
                    totalAmmo: 120,
                    reloadTime: 2.0,
                    spread: 0.08,
                    needsBolt: false,
                    range: 300,
                    auto: true,
                    icon: '💥',
                    modelFn: 'createThompson'
                },
                m1garand: {
                    id: 'm1garand',
                    name: 'M1 Garand',
                    displayName: 'M1 Garand',
                    damage: 55,
                    fireRate: 350,
                    maxAmmo: 8,
                    totalAmmo: 48,
                    reloadTime: 2.8,
                    spread: 0.035,
                    needsBolt: false,
                    range: 500,
                    auto: false,
                    icon: '🔫',
                    modelFn: 'createM1Garand'
                },
                stg44: {
                    id: 'stg44',
                    name: 'STG44',
                    displayName: 'STG44 Assault',
                    damage: 45,
                    fireRate: 450,
                    maxAmmo: 30,
                    totalAmmo: 90,
                    reloadTime: 2.5,
                    spread: 0.055,
                    needsBolt: false,
                    range: 450,
                    auto: true,
                    icon: '🔫',
                    modelFn: 'createSTG44'
                },
                colt1911: {
                    id: 'colt1911',
                    name: 'Colt 1911',
                    displayName: 'Colt 1911',
                    damage: 80,
                    fireRate: 120,
                    maxAmmo: 7,
                    totalAmmo: 28,
                    reloadTime: 2.0,
                    spread: 0.04,
                    needsBolt: false,
                    range: 400,
                    auto: false,
                    icon: '🔫',
                    modelFn: 'createColt1911'
                }
            };

            const WEAPON_IDS = ['lee_enfield', 'thompson', 'm1garand', 'stg44', 'colt1911'];

            // ============================================================
            //  STATE
            // ============================================================
            let scene, camera, renderer;
            let player, playerVel = new THREE.Vector3();
            let moveF = false,
                moveB = false,
                moveL = false,
                moveR = false;
            let canJump = true,
                isJumping = false,
                onGround = false;
            let mouseX = 0,
                mouseY = 0;
            let isShift = false,
                isLocked = false;
            let clock = new THREE.Clock();
            let gameOver = false,
                gameStart = 0;
            let enemies = [],
                obstacles = [];
            let bullets = [];
            let kills = 0,
                shotsFired = 0,
                shotsHit = 0;
            let playerHP = 100,
                maxHP = 100;
            let godMode = false,
                infAmmo = false;

            // ---- Wave system ----
            let currentWave = 0; // 0-index
            let waveActive = false;
            let enemiesSpawned = 0;
            let waveKills = 0;
            let waveTotalEnemies = 0;
            let waveComplete = false;
            let gameVictory = false;

            // ---- Weapons ----
            let currentWeaponId = 'lee_enfield';
            let unlockedWeapons = ['lee_enfield'];
            let weaponStates = {};

            function initWeaponStates() {
                for (const id of WEAPON_IDS) {
                    const def = WEAPON_DEFS[id];
                    weaponStates[id] = {
                        currentAmmo: def.maxAmmo,
                        totalAmmo: def.totalAmmo,
                        boltReady: true,
                        isReloading: false,
                        reloadTimer: 0,
                       boltTimer: null, 
                    };
                }
            }
            initWeaponStates();

            function getWeaponState(id) { return weaponStates[id]; }

            function getCurrentState() { return weaponStates[currentWeaponId]; }

            function getCurrentDef() { return WEAPON_DEFS[currentWeaponId]; }

            let lastShot = 0;
            let mouseDown = false;

            let pickups = [];
            let pickupIdCounter = 0;

            let weaponDisplay = null;
            let weaponModels = {};

            // ---- World objects ----
            let trees = [];
            let treeMeshes = [];

            // ============================================================
            //  AABB HELPERS
            // ============================================================
            function aabbFromPosSize(px, py, pz, w, h, d) {
                return { minX: px - w / 2, maxX: px + w / 2, minY: py - h / 2, maxY: py + h / 2, minZ: pz - d / 2,
                    maxZ: pz + d / 2 };
            }

            function aabbFromObstacle(obs) {
                return { minX: obs.minX, maxX: obs.maxX, minY: obs.minY, maxY: obs.maxY, minZ: obs.minZ, maxZ: obs.maxZ };
            }

            function aabbFromEnemy(e) {
                const p = e.position;
                const halfW = ENEMY_RADIUS * 1.1;
                const halfD = ENEMY_RADIUS;
                const minY = p.y - ENEMY_HEIGHT / 2;
                const maxY = p.y + ENEMY_HEIGHT / 2 + ENEMY_RADIUS * 1.6;
                return { minX: p.x - halfW, maxX: p.x + halfW, minY: minY, maxY: maxY, minZ: p.z - halfD, maxZ: p.z +
                        halfD };
            }

            function aabbFromPlayer() {
                const p = player.position;
                return aabbFromPosSize(p.x, p.y, p.z, PLAYER_RADIUS * 2, PLAYER_HEIGHT, PLAYER_RADIUS * 2);
            }

            function pointInAABB(px, py, pz, aabb) {
                return px >= aabb.minX && px <= aabb.maxX && py >= aabb.minY && py <= aabb.maxY && pz >= aabb.minZ && pz <=
                    aabb.maxZ;
            }

            function aabbOverlap(a, b) {
                return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY && a.minZ <= b.maxZ &&
                    a.maxZ >= b.minZ;
            }

            // ============================================================
            //  GRID & A*
            // ============================================================
            const GRID_SIZE = 16;
            const GRID_W = Math.ceil(MAP_SIZE / GRID_SIZE);
            const GRID_H = Math.ceil(MAP_SIZE / GRID_SIZE);
            let grid = [];

            function worldToGrid(wx, wz) {
                const gx = Math.floor((wx + HALF) / GRID_SIZE);
                const gz = Math.floor((wz + HALF) / GRID_SIZE);
                return { gx, gz };
            }

            function gridToWorld(gx, gz) {
                return { wx: gx * GRID_SIZE - HALF + GRID_SIZE / 2, wz: gz * GRID_SIZE - HALF + GRID_SIZE / 2 };
            }

            function isInGrid(gx, gz) { return gx >= 0 && gx < GRID_W && gz >= 0 && gz < GRID_H; }

            function isWalkable(gx, gz) {
                if (!isInGrid(gx, gz)) return false;
                return grid[gz] && grid[gz][gx] === 0;
            }

            function initGrid() {
                grid = [];
                for (let z = 0; z < GRID_H; z++) {
                    grid[z] = new Uint8Array(GRID_W);
                }
                for (const obs of obstacles) {
                    const min = worldToGrid(obs.minX, obs.minZ);
                    const max = worldToGrid(obs.maxX, obs.maxZ);
                    for (let gz = Math.max(0, min.gz - 1); gz <= Math.min(GRID_H - 1, max.gz + 1); gz++) {
                        for (let gx = Math.max(0, min.gx - 1); gx <= Math.min(GRID_W - 1, max.gx + 1); gx++) {
                            grid[gz][gx] = 1;
                        }
                    }
                }
                for (let i = 0; i < GRID_W; i++) {
                    grid[0][i] = 1;
                    grid[GRID_H - 1][i] = 1;
                    grid[i][0] = 1;
                    grid[i][GRID_W - 1] = 1;
                }
                const pc = worldToGrid(0, 0);
                for (let dz = -3; dz <= 3; dz++) {
                    for (let dx = -3; dx <= 3; dx++) {
                        const gx = pc.gx + dx,
                            gz = pc.gz + dz;
                        if (isInGrid(gx, gz)) grid[gz][gx] = 0;
                    }
                }
            }

            class AStarNode {
                constructor(gx, gz, parent = null) { this.gx = gx;
                    this.gz = gz;
                    this.parent = parent;
                    this.g = 0;
                    this.h = 0;
                    this.f = 0; }
            }

            function heuristic(a, b) { return Math.abs(a.gx - b.gx) + Math.abs(a.gz - b.gz); }

            function aStar(startGx, startGz, goalGx, goalGz) {
                if (!isWalkable(startGx, startGz) || !isWalkable(goalGx, goalGz)) return null;
                if (startGx === goalGx && startGz === goalGz) return [{ gx: goalGx, gz: goalGz }];
                const open = [];
                const closed = new Set();
                const openMap = new Map();
                const startNode = new AStarNode(startGx, startGz);
                const goalNode = new AStarNode(goalGx, goalGz);
                startNode.g = 0;
                startNode.h = heuristic(startNode, goalNode);
                startNode.f = startNode.h;
                open.push(startNode);
                openMap.set(`${startGx},${startGz}`, startNode);
                const dirs = [
                    [0, 1],
                    [1, 0],
                    [0, -1],
                    [-1, 0],
                    [1, 1],
                    [1, -1],
                    [-1, 1],
                    [-1, -1]
                ];
                const dirCost = [1, 1, 1, 1, 1.414, 1.414, 1.414, 1.414];
                while (open.length > 0) {
                    let bestIdx = 0;
                    for (let i = 1; i < open.length; i++) {
                        if (open[i].f < open[bestIdx].f) bestIdx = i;
                    }
                    const current = open.splice(bestIdx, 1)[0];
                    const key = `${current.gx},${current.gz}`;
                    closed.add(key);
                    openMap.delete(key);
                    if (current.gx === goalGx && current.gz === goalGz) {
                        const path = [];
                        let node = current;
                        while (node) { path.push({ gx: node.gx, gz: node.gz });
                            node = node.parent; }
                        path.reverse();
                        return path;
                    }
                    for (let d = 0; d < dirs.length; d++) {
                        const ngx = current.gx + dirs[d][0];
                        const ngz = current.gz + dirs[d][1];
                        const nkey = `${ngx},${ngz}`;
                        if (!isWalkable(ngx, ngz)) continue;
                        if (closed.has(nkey)) continue;
                        if (d >= 4) {
                            const ax = current.gx + dirs[d][0];
                            const az = current.gz;
                            const bx = current.gx;
                            const bz = current.gz + dirs[d][1];
                            if (!isWalkable(ax, az) || !isWalkable(bx, bz)) continue;
                        }
                        const newG = current.g + 2.5 * dirCost[d];
                        let neighbor = openMap.get(nkey);
                        if (!neighbor) {
                            neighbor = new AStarNode(ngx, ngz, current);
                            neighbor.g = newG;
                            neighbor.h = heuristic(neighbor, goalNode);
                            neighbor.f = neighbor.g + neighbor.h;
                            open.push(neighbor);
                            openMap.set(nkey, neighbor);
                        } else if (newG < neighbor.g) {
                            neighbor.parent = current;
                            neighbor.g = newG;
                            neighbor.f = neighbor.g + neighbor.h;
                        }
                    }
                }
                return null;
            }

            function hasLineOfSight(wx1, wz1, wx2, wz2) {
                const dist = Math.sqrt((wx2 - wx1) ** 2 + (wz2 - wz1) ** 2);
                const steps = Math.ceil(dist / 2);
                for (let i = 0; i <= steps; i++) {
                    const t = i / steps;
                    const px = wx1 + (wx2 - wx1) * t;
                    const pz = wz1 + (wz2 - wz1) * t;
                    const g = worldToGrid(px, pz);
                    if (!isWalkable(g.gx, g.gz)) return false;
                    for (let dz = -1; dz <= 1; dz++) {
                        for (let dx = -1; dx <= 1; dx++) {
                            const ngx = g.gx + dx,
                                ngz = g.gz + dz;
                            if (isInGrid(ngx, ngz) && grid[ngz][ngx] === 1) return false;
                        }
                    }
                }
                return true;
            }

            function calculateEnemyPath(enemy, target) {
                const start = enemy.position;
                const end = target;
                const dist = start.distanceTo(end);
                if (dist < 5) return [{ wx: end.x, wz: end.z }];
                let startG = worldToGrid(start.x, start.z);
                let endG = worldToGrid(end.x, end.z);
                if (!isWalkable(startG.gx, startG.gz)) {
                    let found = false;
                    for (let r = 1; r <= 3; r++) {
                        for (let dz = -r; dz <= r; dz++) {
                            for (let dx = -r; dx <= r; dx++) {
                                const ngx = startG.gx + dx,
                                    ngz = startG.gz + dz;
                                if (isWalkable(ngx, ngz)) { startG.gx = ngx;
                                    startG.gz = ngz;
                                    found = true; break; }
                            }
                            if (found) break;
                        }
                        if (found) break;
                    }
                    if (!found) return null;
                }
                if (!isWalkable(endG.gx, endG.gz)) {
                    let found = false;
                    for (let r = 1; r <= 3; r++) {
                        for (let dz = -r; dz <= r; dz++) {
                            for (let dx = -r; dx <= r; dx++) {
                                const ngx = endG.gx + dx,
                                    ngz = endG.gz + dz;
                                if (isWalkable(ngx, ngz)) { endG.gx = ngx;
                                    endG.gz = ngz;
                                    found = true; break; }
                            }
                            if (found) break;
                        }
                        if (found) break;
                    }
                    if (!found) return null;
                }
                if (hasLineOfSight(start.x, start.z, end.x, end.z)) {
                    return [{ wx: end.x, wz: end.z }];
                }
                const path = aStar(startG.gx, startG.gz, endG.gx, endG.gz);
                if (!path) return null;
                const result = [];
                for (let i = 1; i < path.length; i++) {
                    const w = gridToWorld(path[i].gx, path[i].gz);
                    result.push({ wx: w.wx, wz: w.wz });
                }
                if (result.length > 0) {
                    const last = result[result.length - 1];
                    if (Math.abs(last.wx - end.x) > 5 || Math.abs(last.wz - end.z) > 5) {
                        result.push({ wx: end.x, wz: end.z });
                    }
                } else {
                    result.push({ wx: end.x, wz: end.z });
                }
                return result;
            }

            // ============================================================
            //  WW2 WEAPON MODELS (voxel style)
            // ============================================================
            function createLeeEnfield() {
                const g = new THREE.Group();
                const m = (c) => new THREE.MeshLambertMaterial({ color: c });
                const parts = [
                    { pos: [0, 0, 0], size: [2.6, 0.4, 0.8], color: 0x3a2a1a },
                    { pos: [0.2, 0.2, 0], size: [1.8, 0.25, 0.5], color: 0x4a3a2a },
                    { pos: [2.0, 0, 0], size: [1.8, 0.18, 0.18], color: 0x2a2a2a },
                    { pos: [2.8, 0, 0], size: [0.6, 0.15, 0.15], color: 0x1a1a1a },
                    { pos: [3.2, 0, 0], size: [0.15, 0.3, 0.3], color: 0x444444 },
                    { pos: [-2.0, 0, 0], size: [1.0, 0.5, 0.5], color: 0x5a4a3a },
                    { pos: [-2.4, 0, 0], size: [0.6, 0.4, 0.4], color: 0x6a5a4a },
                    { pos: [-2.7, 0.1, 0], size: [0.4, 0.3, 0.3], color: 0x7a6a5a },
                    { pos: [-0.6, -0.4, 0], size: [0.4, 0.5, 0.3], color: 0x3a3a3a },
                    { pos: [-0.7, -0.6, 0], size: [0.3, 0.3, 0.25], color: 0x4a4a4a },
                    { pos: [0.6, -0.5, 0], size: [0.5, 0.5, 0.3], color: 0x333333 },
                    { pos: [0.6, -0.7, 0], size: [0.4, 0.3, 0.25], color: 0x222222 },
                    { pos: [0.3, 0.5, 0], size: [0.5, 0.3, 0.3], color: 0x555555 },
                    { pos: [0.3, 0.6, 0], size: [0.4, 0.2, 0.2], color: 0x666666 },
                    { pos: [0.0, 0.5, 0.4], size: [0.1, 0.25, 0.08], color: 0x88aaff },
                    { pos: [0.0, 0.5, -0.4], size: [0.1, 0.25, 0.08], color: 0x88aaff },
                    { pos: [0.6, 0.5, 0.4], size: [0.1, 0.25, 0.08], color: 0x88aaff },
                    { pos: [0.6, 0.5, -0.4], size: [0.1, 0.25, 0.08], color: 0x88aaff },
                    { pos: [2.0, -0.3, 0.4], size: [0.25, 0.08, 0.08], color: 0x555555 },
                    { pos: [2.0, -0.3, -0.4], size: [0.25, 0.08, 0.08], color: 0x555555 },
                    { pos: [-1.0, 0.4, 0.4], size: [0.2, 0.08, 0.08], color: 0x777777 },
                    { pos: [-1.0, 0.4, -0.4], size: [0.2, 0.08, 0.08], color: 0x777777 },
                ];
                parts.forEach(({ pos, size, color }) => {
                    const mesh = new THREE.Mesh(new THREE.BoxGeometry(...size), m(color));
                    mesh.position.set(pos[0], pos[1], pos[2]);
                    g.add(mesh);
                });
                return g;
            }

            function createThompson() {
                const g = new THREE.Group();
                const m = (c) => new THREE.MeshLambertMaterial({ color: c });
                const parts = [
                    { pos: [0, 0, 0], size: [2.0, 0.6, 1.0], color: 0x333333 },
                    { pos: [0.3, 0.3, 0], size: [1.2, 0.3, 0.6], color: 0x444444 },
                    { pos: [1.6, 0, 0], size: [0.8, 0.2, 0.2], color: 0x222222 },
                    { pos: [2.2, 0, 0], size: [0.5, 0.18, 0.18], color: 0x111111 },
                    { pos: [-0.6, -0.4, 0], size: [0.5, 0.6, 0.3], color: 0x5a3a2a },
                    { pos: [-0.7, -0.6, 0], size: [0.4, 0.3, 0.25], color: 0x6a4a3a },
                    { pos: [0.5, -0.5, 0], size: [0.5, 0.5, 0.3], color: 0x3a3a3a },
                    { pos: [0.5, -0.7, 0], size: [0.4, 0.3, 0.25], color: 0x2a2a2a },
                    { pos: [-0.8, 0.4, 0], size: [0.3, 0.15, 0.3], color: 0x6a5a4a },
                    { pos: [-0.9, 0.5, 0], size: [0.2, 0.1, 0.2], color: 0x7a6a5a },
                    { pos: [1.4, 0.3, 0.5], size: [0.5, 0.08, 0.08], color: 0x444444 },
                    { pos: [1.4, 0.3, -0.5], size: [0.5, 0.08, 0.08], color: 0x444444 },
                    { pos: [0.8, 0.6, 0], size: [0.4, 0.1, 0.1], color: 0x555555 },
                    { pos: [2.5, 0, 0], size: [0.15, 0.25, 0.25], color: 0x333333 },
                    { pos: [-0.2, 0.1, 0.6], size: [0.1, 0.1, 0.06], color: 0x666666 },
                    { pos: [-0.2, 0.1, -0.6], size: [0.1, 0.1, 0.06], color: 0x666666 },
                ];
                parts.forEach(({ pos, size, color }) => {
                    const mesh = new THREE.Mesh(new THREE.BoxGeometry(...size), m(color));
                    mesh.position.set(pos[0], pos[1], pos[2]);
                    g.add(mesh);
                });
                return g;
            }

            function createM1Garand() {
                const g = new THREE.Group();
                const m = (c) => new THREE.MeshLambertMaterial({ color: c });
                const parts = [
                    { pos: [0, 0, 0], size: [2.2, 0.5, 0.7], color: 0x3a2a1a },
                    { pos: [0.3, 0.25, 0], size: [1.6, 0.25, 0.4], color: 0x4a3a2a },
                    { pos: [1.8, 0, 0], size: [1.4, 0.18, 0.18], color: 0x2a2a2a },
                    { pos: [2.6, 0, 0], size: [0.5, 0.15, 0.15], color: 0x1a1a1a },
                    { pos: [3.0, 0, 0], size: [0.12, 0.28, 0.28], color: 0x444444 },
                    { pos: [-1.8, 0, 0], size: [0.8, 0.4, 0.4], color: 0x5a4a3a },
                    { pos: [-2.2, 0, 0], size: [0.5, 0.35, 0.35], color: 0x6a5a4a },
                    { pos: [-2.5, 0.1, 0], size: [0.3, 0.3, 0.3], color: 0x7a6a5a },
                    { pos: [-0.4, -0.4, 0], size: [0.4, 0.5, 0.3], color: 0x3a3a3a },
                    { pos: [-0.5, -0.6, 0], size: [0.3, 0.3, 0.25], color: 0x4a4a4a },
                    { pos: [0.4, -0.5, 0], size: [0.5, 0.5, 0.3], color: 0x333333 },
                    { pos: [0.4, -0.7, 0], size: [0.4, 0.3, 0.25], color: 0x222222 },
                    { pos: [0.6, 0.5, 0], size: [0.6, 0.2, 0.2], color: 0x555555 },
                    { pos: [1.2, 0.3, 0.4], size: [0.3, 0.08, 0.08], color: 0x666666 },
                    { pos: [1.2, 0.3, -0.4], size: [0.3, 0.08, 0.08], color: 0x666666 },
                    { pos: [-1.0, 0.35, 0.35], size: [0.15, 0.08, 0.08], color: 0x777777 },
                    { pos: [-1.0, 0.35, -0.35], size: [0.15, 0.08, 0.08], color: 0x777777 },
                    { pos: [-0.2, 0.3, 0.6], size: [0.08, 0.08, 0.08], color: 0x888888 },
                    { pos: [-0.2, 0.3, -0.6], size: [0.08, 0.08, 0.08], color: 0x888888 },
                ];
                parts.forEach(({ pos, size, color }) => {
                    const mesh = new THREE.Mesh(new THREE.BoxGeometry(...size), m(color));
                    mesh.position.set(pos[0], pos[1], pos[2]);
                    g.add(mesh);
                });
                return g;
            }

            function createSTG44() {
                const g = new THREE.Group();
                const m = (c) => new THREE.MeshLambertMaterial({ color: c });
                const parts = [
                    { pos: [0, 0, 0], size: [1.8, 0.5, 0.8], color: 0x444444 },
                    { pos: [0.3, 0.25, 0], size: [1.2, 0.25, 0.5], color: 0x555555 },
                    { pos: [1.6, 0, 0], size: [1.2, 0.2, 0.2], color: 0x333333 },
                    { pos: [2.2, 0, 0], size: [0.6, 0.18, 0.18], color: 0x222222 },
                    { pos: [2.6, 0, 0], size: [0.2, 0.3, 0.3], color: 0x444444 },
                    { pos: [-1.6, 0, 0], size: [0.8, 0.5, 0.5], color: 0x5a4a3a },
                    { pos: [-2.0, 0, 0], size: [0.5, 0.4, 0.4], color: 0x6a5a4a },
                    { pos: [-2.3, 0.1, 0], size: [0.3, 0.3, 0.3], color: 0x7a6a5a },
                    { pos: [0.8, -0.1, 0.5], size: [0.6, 0.2, 0.1], color: 0x4a3a2a },
                    { pos: [0.8, -0.1, -0.5], size: [0.6, 0.2, 0.1], color: 0x4a3a2a },
                    { pos: [0.8, 0.1, 0.5], size: [0.6, 0.1, 0.1], color: 0x3a2a1a },
                    { pos: [0.8, 0.1, -0.5], size: [0.6, 0.1, 0.1], color: 0x3a2a1a },
                    { pos: [-0.4, -0.4, 0], size: [0.4, 0.5, 0.3], color: 0x3a3a3a },
                    { pos: [-0.5, -0.6, 0], size: [0.3, 0.3, 0.25], color: 0x4a4a4a },
                    { pos: [0.1, -0.5, 0], size: [0.4, 0.5, 0.3], color: 0x2a2a2a },
                    { pos: [0.0, -0.7, 0], size: [0.3, 0.3, 0.25], color: 0x333333 },
                    { pos: [1.8, 0.4, 0], size: [0.1, 0.15, 0.1], color: 0x666666 },
                    { pos: [-0.6, 0.4, 0], size: [0.15, 0.1, 0.15], color: 0x666666 },
                    { pos: [0.5, 0.2, 0.55], size: [0.12, 0.08, 0.08], color: 0x555555 },
                ];
                parts.forEach(({ pos, size, color }) => {
                    const mesh = new THREE.Mesh(new THREE.BoxGeometry(...size), m(color));
                    mesh.position.set(pos[0], pos[1], pos[2]);
                    g.add(mesh);
                });
                return g;
            }

            function createColt1911() {
                const g = new THREE.Group();
                const m = (c) => new THREE.MeshLambertMaterial({ color: c });
                const parts = [
                    { pos: [0, 0.2, 0], size: [1.4, 0.35, 0.5], color: 0x444444 },
                    { pos: [0.2, 0.32, 0], size: [1.0, 0.12, 0.35], color: 0x555555 },
                    { pos: [1.2, 0.2, 0], size: [0.6, 0.2, 0.2], color: 0x333333 },
                    { pos: [1.6, 0.2, 0], size: [0.35, 0.18, 0.18], color: 0x222222 },
                    { pos: [1.8, 0.45, 0], size: [0.08, 0.12, 0.08], color: 0x666666 },
                    { pos: [-0.8, 0.45, 0], size: [0.2, 0.08, 0.2], color: 0x666666 },
                    { pos: [-0.6, -0.2, 0], size: [0.4, 0.5, 0.25], color: 0x5a3a2a },
                    { pos: [-0.6, -0.35, 0], size: [0.35, 0.25, 0.2], color: 0x6a4a3a },
                    { pos: [-0.7, -0.25, 0.35], size: [0.25, 0.08, 0.04], color: 0x4a2a1a },
                    { pos: [-0.7, -0.25, -0.35], size: [0.25, 0.08, 0.04], color: 0x4a2a1a },
                    { pos: [-0.2, -0.1, 0.35], size: [0.2, 0.12, 0.06], color: 0x333333 },
                    { pos: [-0.2, -0.1, -0.35], size: [0.2, 0.12, 0.06], color: 0x333333 },
                    { pos: [-0.15, -0.18, 0], size: [0.06, 0.12, 0.06], color: 0x222222 },
                    { pos: [-0.5, -0.5, 0], size: [0.25, 0.25, 0.2], color: 0x333333 },
                    { pos: [-0.5, -0.65, 0], size: [0.2, 0.15, 0.15], color: 0x222222 },
                    { pos: [0.5, 0.1, 0.4], size: [0.08, 0.08, 0.06], color: 0x666666 },
                    { pos: [0.5, 0.1, -0.4], size: [0.08, 0.08, 0.06], color: 0x666666 },
                    { pos: [0.0, 0.5, 0], size: [0.6, 0.04, 0.04], color: 0x555555 },
                    { pos: [1.9, 0.2, 0], size: [0.15, 0.25, 0.25], color: 0x444444 },
                    { pos: [-0.8, 0.25, 0], size: [0.08, 0.12, 0.12], color: 0x333333 },
                    { pos: [-0.6, 0.18, 0.4], size: [0.06, 0.06, 0.06], color: 0xff0000 },
                    { pos: [-0.6, 0.18, -0.4], size: [0.06, 0.06, 0.06], color: 0xff0000 },
                ];
                parts.forEach(({ pos, size, color }) => {
                    const mesh = new THREE.Mesh(new THREE.BoxGeometry(...size), m(color));
                    mesh.position.set(pos[0], pos[1], pos[2]);
                    g.add(mesh);
                });
                return g;
            }

            // ============================================================
            //  TERRAIN BUILDERS
            // ============================================================
            function clearWorldObjects() {
                // Remove trees
                for (const t of treeMeshes) {
                    scene.remove(t);
                }
                treeMeshes = [];
                trees = [];
                // Remove old obstacles that are not buildings/cars/etc? We'll rebuild everything.
                // But we need to keep the ground. Actually we'll rebuild the whole terrain.
                // So we'll remove all obstacles and meshes except player and enemies.
                for (const obs of obstacles) {
                    if (obs.mesh) scene.remove(obs.mesh);
                    // also remove any associated meshes
                }
                obstacles = [];
                // Remove pickups
                for (const p of pickups) {
                    scene.remove(p.group);
                }
                pickups = [];
                // Remove bullets
                for (const b of bullets) {
                    scene.remove(b.mesh);
                    if (b.trail) scene.remove(b.trail);
                }
                bullets = [];
                // Remove enemies
                for (const e of enemies) {
                    scene.remove(e.group);
                }
                enemies = [];
                // Clear grid
                grid = [];
            }

          function buildTree(x, z) {
    const group = new THREE.Group();
    const trunkMat = new THREE.MeshLambertMaterial({ color: 0x5a3a2a });
    const leafMat = new THREE.MeshLambertMaterial({ map: createLeafTexture() }); // 使用树叶纹理

    const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.8, 1.2, 6, 6), trunkMat);
    trunk.position.set(0, 3, 0);
    trunk.castShadow = true;
    trunk.receiveShadow = true;
    group.add(trunk);

    const leaf1 = new THREE.Mesh(new THREE.ConeGeometry(4.5, 4.5, 6), leafMat);
    leaf1.position.set(0, 7, 0);
    leaf1.castShadow = true;
    leaf1.receiveShadow = true;
    group.add(leaf1);

    const leaf2 = new THREE.Mesh(new THREE.ConeGeometry(3.5, 3.5, 6), leafMat);
    leaf2.position.set(0, 9.5, 0);
    leaf2.castShadow = true;
    leaf2.receiveShadow = true;
    group.add(leaf2);

    const leaf3 = new THREE.Mesh(new THREE.ConeGeometry(2.5, 2.5, 6), leafMat);
    leaf3.position.set(0, 11.5, 0);
    leaf3.castShadow = true;
    leaf3.receiveShadow = true;
    group.add(leaf3);

    group.position.set(x, 0, z);
    scene.add(group);
    treeMeshes.push(group);

    // Collision
    const radius = 2.2;
    const height = 12;
    obstacles.push({
        position: new THREE.Vector3(x, height / 2, z),
        width: radius * 2,
        height: height,
        depth: radius * 2,
        minX: x - radius,
        maxX: x + radius,
        minY: 0,
        maxY: height,
        minZ: z - radius,
        maxZ: z + radius,
        isTree: true,
        mesh: group
    });
    return group;
}
          function buildBeetleCar(x, z, rotY) {
    const group = new THREE.Group();
    const metalTex = createRustyMetalTexture(); // 生成一次,所有部件共享

    const bodyMat = new THREE.MeshLambertMaterial({ map: metalTex });
    const darkMat = new THREE.MeshLambertMaterial({ color: 0x5a4a2a });
    const rustMat = new THREE.MeshLambertMaterial({ color: 0x6a4a2a });
    const glassMat = new THREE.MeshLambertMaterial({ color: 0x88bbdd, transparent: true, opacity: 0.5 });

    // Body
    const body = new THREE.Mesh(new THREE.BoxGeometry(5, 2.2, 4.2), bodyMat);
    body.position.y = 1.1;
    body.castShadow = true;
    body.receiveShadow = true;
    group.add(body);

    // Roof / cab (使用玻璃材质)
    const cab = new THREE.Mesh(new THREE.BoxGeometry(2.8, 1.4, 3.6), glassMat);
    cab.position.set(-0.4, 2.8, 0);
    cab.castShadow = true;
    cab.receiveShadow = true;
    group.add(cab);

    // Fenders (rusty)
    const fMat = rustMat;
    const f1 = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.3, 4.6), fMat);
    f1.position.set(2.6, 0.4, 0);
    group.add(f1);
    const f2 = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.3, 4.6), fMat);
    f2.position.set(-2.6, 0.4, 0);
    group.add(f2);

    // Wheels (dark)
    const wheelMat = new THREE.MeshLambertMaterial({ color: 0x222222 });
    const wPos = [
        [-2.2, 0.5, 2.0],
        [-2.2, 0.5, -2.0],
        [2.2, 0.5, 2.0],
        [2.2, 0.5, -2.0]
    ];
    for (const wp of wPos) {
        const wheel = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 0.7, 0.4, 8), wheelMat);
        wheel.rotation.x = Math.PI / 2;
        wheel.position.set(wp[0], wp[1], wp[2]);
        wheel.castShadow = true;
        wheel.receiveShadow = true;
        group.add(wheel);
    }

    // Headlights (rusty)
    const hlMat = new THREE.MeshLambertMaterial({ color: 0xcccc77, emissive: 0x888844, emissiveIntensity: 0.1 });
    const hl1 = new THREE.Mesh(new THREE.SphereGeometry(0.4, 6, 6), hlMat);
    hl1.position.set(2.7, 0.8, 1.0);
    group.add(hl1);
    const hl2 = new THREE.Mesh(new THREE.SphereGeometry(0.4, 6, 6), hlMat);
    hl2.position.set(2.7, 0.8, -1.0);
    group.add(hl2);

    // Tail lights (dim)
    const tlMat = new THREE.MeshLambertMaterial({ color: 0x883333 });
    const tl1 = new THREE.Mesh(new THREE.SphereGeometry(0.3, 6, 6), tlMat);
    tl1.position.set(-2.7, 0.8, 1.0);
    group.add(tl1);
    const tl2 = new THREE.Mesh(new THREE.SphereGeometry(0.3, 6, 6), tlMat);
    tl2.position.set(-2.7, 0.8, -1.0);
    group.add(tl2);

    // Rust spots (decorative)
    const rustSpotMat = new THREE.MeshLambertMaterial({ color: 0x6a4a2a });
    for (let i = 0; i < 8; i++) {
        const rs = new THREE.Mesh(new THREE.SphereGeometry(0.2 + Math.random() * 0.3, 4, 4), rustSpotMat);
        rs.position.set((Math.random() - 0.5) * 4, 0.5 + Math.random() * 1.5, (Math.random() - 0.5) * 3.5);
        group.add(rs);
    }

    group.position.set(x, 0, z);
    group.rotation.y = rotY || Math.random() * Math.PI * 2;
    const scale = 3;
    group.scale.set(scale, scale, scale);
    scene.add(group);

    // Collision (simplified box)
    const halfW = 2.8 * scale;
    const halfD = 2.2 * scale;
    const halfH = 1.3 * scale;
    obstacles.push({
        position: new THREE.Vector3(x, halfH, z),
        width: halfW * 2,
        height: halfH * 2,
        depth: halfD * 2,
        minX: x - halfW,
        maxX: x + halfW,
        minY: 0,
        maxY: halfH * 2,
        minZ: z - halfD,
        maxZ: z + halfD,
        isCar: true,
        mesh: group
    });
    return group;
}
// 生成木纹纹理(丛林)
function createWoodTexture() {
    const canvas = document.createElement('canvas');
    canvas.width = 256;
    canvas.height = 256;
    const ctx = canvas.getContext('2d');

    // 底色
    ctx.fillStyle = '#8B5A2B';
    ctx.fillRect(0, 0, 256, 256);

    // 木纹线
    for (let i = 0; i < 60; i++) {
        const y = 5 + Math.random() * 246;
        ctx.strokeStyle = `hsl(30, 40%, ${20 + Math.random() * 30}%)`;
        ctx.lineWidth = 1 + Math.random() * 3;
        ctx.beginPath();
        let x = 0;
        while (x < 256) {
            ctx.lineTo(x, y + Math.sin(x * 0.05 + i) * 3 + (Math.random() - 0.5) * 2);
            x += 2;
        }
        ctx.stroke();
    }
    // 增加一些节疤
    for (let i = 0; i < 8; i++) {
        const cx = 20 + Math.random() * 216;
        const cy = 20 + Math.random() * 216;
        const r = 6 + Math.random() * 12;
        const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
        grad.addColorStop(0, '#4A2A1A');
        grad.addColorStop(1, '#8B5A2B');
        ctx.fillStyle = grad;
        ctx.beginPath();
        ctx.arc(cx, cy, r, 0, Math.PI * 2);
        ctx.fill();
    }
    const tex = new THREE.CanvasTexture(canvas);
    tex.wrapS = tex.wrapT = THREE.ClampToEdgeWrapping;
    return tex;
}

// 生成混凝土纹理(带弹坑)
function createConcreteTexture(withCraters = true) {
    const canvas = document.createElement('canvas');
    canvas.width = 256;
    canvas.height = 256;
    const ctx = canvas.getContext('2d');

    // 混凝土底色(灰色带随机颗粒)
    ctx.fillStyle = '#9A9A9A';
    ctx.fillRect(0, 0, 256, 256);
    for (let i = 0; i < 2000; i++) {
        const x = Math.random() * 256;
        const y = Math.random() * 256;
        const v = 120 + Math.random() * 80;
        ctx.fillStyle = `rgb(${v},${v},${v})`;
        ctx.fillRect(x, y, 1, 1);
    }

    if (withCraters) {
        // 弹坑(暗色凹陷,边缘亮色)
        for (let i = 0; i < 30; i++) {
            const cx = 20 + Math.random() * 216;
            const cy = 20 + Math.random() * 216;
            const r = 4 + Math.random() * 18;
            // 坑底
            const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
            grad.addColorStop(0, '#2A2A2A');
            grad.addColorStop(0.6, '#4A4A4A');
            grad.addColorStop(1, '#AAAAAA');
            ctx.fillStyle = grad;
            ctx.beginPath();
            ctx.arc(cx, cy, r, 0, Math.PI * 2);
            ctx.fill();
            // 边缘高光
            ctx.strokeStyle = '#BBBBBB';
            ctx.lineWidth = 1.5;
            ctx.beginPath();
            ctx.arc(cx - 2, cy - 2, r * 0.8, 0, Math.PI * 2);
            ctx.stroke();
        }
        // 额外小弹坑
        for (let i = 0; i < 60; i++) {
            const cx = Math.random() * 256;
            const cy = Math.random() * 256;
            const r = 2 + Math.random() * 5;
            ctx.fillStyle = '#3A3A3A';
            ctx.beginPath();
            ctx.arc(cx, cy, r, 0, Math.PI * 2);
            ctx.fill();
        }
        // 裂纹(模拟弹坑引起的裂缝)
        ctx.strokeStyle = '#6A6A6A';
        ctx.lineWidth = 0.5;
        for (let i = 0; i < 20; i++) {
            const sx = Math.random() * 256;
            const sy = Math.random() * 256;
            ctx.beginPath();
            ctx.moveTo(sx, sy);
            let x = sx, y = sy;
            for (let j = 0; j < 8; j++) {
                x += (Math.random() - 0.5) * 20;
                y += (Math.random() - 0.5) * 20;
                ctx.lineTo(x, y);
            }
            ctx.stroke();
        }
    }

    const tex = new THREE.CanvasTexture(canvas);
    tex.wrapS = tex.wrapT = THREE.ClampToEdgeWrapping;
    return tex;
}
function createLeafTexture() {
    const canvas = document.createElement('canvas');
    canvas.width = 256;
    canvas.height = 256;
    const ctx = canvas.getContext('2d');

    ctx.fillStyle = '#2a7a2a';
    ctx.fillRect(0, 0, 256, 256);

    for (let i = 0; i < 800; i++) {
        const x = Math.random() * 256;
        const y = Math.random() * 256;
        const r = 3 + Math.random() * 12;
        const g = 120 + Math.random() * 80;
        ctx.fillStyle = `rgba(${40 + Math.random()*30}, ${g}, ${40 + Math.random()*30}, 0.6)`;
        ctx.beginPath();
        ctx.arc(x, y, r, 0, Math.PI * 2);
        ctx.fill();
    }
    // 叶脉线条 - 这里使用了 const x, y,但重新赋值会报错,应该改为 let
    ctx.strokeStyle = '#1a5a1a';
    ctx.lineWidth = 0.5;
    for (let i = 0; i < 30; i++) {
        let x = Math.random() * 256; // 改为 let
        let y = Math.random() * 256; // 改为 let
        ctx.beginPath();
        ctx.moveTo(x, y);
        for (let j = 0; j < 6; j++) {
            x += (Math.random() - 0.5) * 20; // 修改 x
            y += (Math.random() - 0.5) * 20; // 修改 y
            ctx.lineTo(x, y);
        }
        ctx.stroke();
    }

    const tex = new THREE.CanvasTexture(canvas);
    tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
    tex.repeat.set(1, 1);
    return tex;
}
function createGrassTexture() {
    const canvas = document.createElement('canvas');
    canvas.width = 512;
    canvas.height = 512;
    const ctx = canvas.getContext('2d');

    // 底色改为深绿色
    ctx.fillStyle = '#2a5a2a';
    ctx.fillRect(0, 0, 512, 512);

    // 草叶斑点(亮度降低)
    for (let i = 0; i < 3000; i++) {
        const x = Math.random() * 512;
        const y = Math.random() * 512;
        const g = 80 + Math.random() * 60;       // 从 120+80 降至 80+60
        const r = 30 + Math.random() * 20;       // 从 40+30 降至 30+20
        ctx.fillStyle = `rgba(${r}, ${g}, ${r-20}, 0.6)`;
        ctx.fillRect(x, y, 1 + Math.random() * 4, 1 + Math.random() * 4);
    }

    // 随机草叶线条(也适当调暗)
    ctx.strokeStyle = 'rgba(40,80,40,0.3)'; // 从 50,100,50 改为 40,80,40
    ctx.lineWidth = 0.5;
    for (let i = 0; i < 200; i++) {
        const x = Math.random() * 512;
        const y = Math.random() * 512;
        ctx.beginPath();
        ctx.moveTo(x, y);
        ctx.lineTo(x + (Math.random() - 0.5) * 10, y + 2 + Math.random() * 6);
        ctx.stroke();
    }

    const tex = new THREE.CanvasTexture(canvas);
    tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
    tex.repeat.set(30, 30);
    return tex;
}function createAsphaltTexture() {
    const canvas = document.createElement('canvas');
    canvas.width = 256;
    canvas.height = 256;
    const ctx = canvas.getContext('2d');

    ctx.fillStyle = '#3a3a3a';
    ctx.fillRect(0, 0, 256, 256);

    for (let i = 0; i < 2000; i++) {
        const x = Math.random() * 256;
        const y = Math.random() * 256;
        const v = 50 + Math.random() * 60;
        ctx.fillStyle = `rgb(${v}, ${v}, ${v})`;
        ctx.fillRect(x, y, 1, 1);
    }

    ctx.strokeStyle = 'rgba(20,20,20,0.3)';
    ctx.lineWidth = 0.8;
    for (let i = 0; i < 30; i++) {
        let sx = Math.random() * 256;
        let sy = Math.random() * 256;
        ctx.beginPath();
        ctx.moveTo(sx, sy);
        for (let j = 0; j < 5; j++) {
            sx += (Math.random() - 0.5) * 30;
            sy += (Math.random() - 0.5) * 30;
            ctx.lineTo(sx, sy);
        }
        ctx.stroke();
    }

    const tex = new THREE.CanvasTexture(canvas);
    tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
    tex.repeat.set(20, 20);
    return tex;
}// 生成锈蚀金属纹理(用于车辆)
function createRustyMetalTexture() {
    const canvas = document.createElement('canvas');
    canvas.width = 256;
    canvas.height = 256;
    const ctx = canvas.getContext('2d');

    // 金属底色
    ctx.fillStyle = '#8a7a3a';
    ctx.fillRect(0, 0, 256, 256);

    // 锈斑
    for (let i = 0; i < 300; i++) {
        const x = Math.random() * 256;
        const y = Math.random() * 256;
        const r = 2 + Math.random() * 15;
        const c = Math.floor(60 + Math.random() * 80);
        ctx.fillStyle = `rgba(${c}, ${c-20}, ${c-40}, 0.7)`;
        ctx.beginPath();
        ctx.arc(x, y, r, 0, Math.PI * 2);
        ctx.fill();
    }
    // 划痕
    ctx.strokeStyle = 'rgba(50,40,20,0.3)';
    ctx.lineWidth = 0.8;
    for (let i = 0; i < 50; i++) {
        const sx = Math.random() * 256;
        const sy = Math.random() * 256;
        ctx.beginPath();
        ctx.moveTo(sx, sy);
        ctx.lineTo(sx + (Math.random()-0.5)*30, sy + (Math.random()-0.5)*30);
        ctx.stroke();
    }
    // 高光斑点
    for (let i = 0; i < 100; i++) {
        const x = Math.random() * 256;
        const y = Math.random() * 256;
        const v = 180 + Math.random() * 70;
        ctx.fillStyle = `rgba(${v}, ${v-20}, ${v-40}, 0.2)`;
        ctx.beginPath();
        ctx.arc(x, y, 1+Math.random()*3, 0, Math.PI*2);
        ctx.fill();
    }

    const tex = new THREE.CanvasTexture(canvas);
    tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
    tex.repeat.set(2, 2);
    return tex;
}
// ============================================================
//  SKY GENERATOR (Canvas 2D)
// ============================================================
function createSkyTexture(waveIdx) {
    const canvas = document.createElement('canvas');
    canvas.width = 1024;
    canvas.height = 512;
    const ctx = canvas.getContext('2d');

    const configs = [
        // 波次1: 丛林 - 暖黄绿
        { top: '#87CEEB', mid: '#b8d4a0', bottom: '#7a9a6a', cloud: '#ffffff' },
        // 波次2: 城市 - 灰蓝
        { top: '#6a8a9a', mid: '#8a9aa0', bottom: '#5a6a6a', cloud: '#ccdde0' },
        // 波次3: 城市密集 - 黄昏
        { top: '#d4a050', mid: '#c49070', bottom: '#5a4a3a', cloud: '#e8c8a0' },
        // 波次4: 城市密集 - 阴天/战火
        { top: '#4a5a5a', mid: '#6a6a5a', bottom: '#3a3a3a', cloud: '#8a8a7a' }
    ];
    const cfg = configs[waveIdx] || configs[0];

    // ---- 1. 渐变天空 ----
    const grad = ctx.createLinearGradient(0, 0, 0, canvas.height);
    grad.addColorStop(0, cfg.top);
    grad.addColorStop(0.5, cfg.mid);
    grad.addColorStop(1, cfg.bottom);
    ctx.fillStyle = grad;
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    // ---- 2. 太阳/光晕(可选) ----
    if (waveIdx < 3) {
        const sunX = canvas.width * 0.75;
        const sunY = canvas.height * 0.15;
        const gradSun = ctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, 200);
        gradSun.addColorStop(0, 'rgba(255,240,200,0.6)');
        gradSun.addColorStop(0.3, 'rgba(255,220,150,0.3)');
        gradSun.addColorStop(1, 'rgba(255,200,100,0)');
        ctx.fillStyle = gradSun;
        ctx.fillRect(0, 0, canvas.width, canvas.height);
    }

    // ---- 3. 云朵(随机分布) ----
    function drawCloud(cx, cy, scale, alpha) {
        const count = 5 + Math.floor(Math.random() * 4);
        for (let i = 0; i < count; i++) {
            const x = cx + (Math.random() - 0.5) * 200 * scale;
            const y = cy + (Math.random() - 0.5) * 40 * scale;
            const r = (20 + Math.random() * 40) * scale;
            const gradCloud = ctx.createRadialGradient(x, y, 0, x, y, r);
            gradCloud.addColorStop(0, `rgba(255,255,255,${alpha * 0.9})`);
            gradCloud.addColorStop(0.7, `rgba(255,255,255,${alpha * 0.4})`);
            gradCloud.addColorStop(1, `rgba(255,255,255,0)`);
            ctx.fillStyle = gradCloud;
            ctx.beginPath();
            ctx.arc(x, y, r, 0, Math.PI * 2);
            ctx.fill();
        }
    }

    const cloudColors = [cfg.cloud, '#ffffff', '#e8e8e8'];
    const cloudCount = 12 + Math.floor(Math.random() * 8);
    for (let i = 0; i < cloudCount; i++) {
        const x = Math.random() * canvas.width;
        const y = 20 + Math.random() * canvas.height * 0.5;
        const scale = 0.5 + Math.random() * 1.5;
        const alpha = 0.3 + Math.random() * 0.5;
        drawCloud(x, y, scale, alpha);
    }

    // ---- 4. 微弱的雾气/尘埃(低空) ----
    const fogGrad = ctx.createLinearGradient(0, canvas.height * 0.6, 0, canvas.height);
    fogGrad.addColorStop(0, 'rgba(200,200,200,0)');
    fogGrad.addColorStop(1, `rgba(${waveIdx < 2 ? '180,200,160' : '160,160,150'}, 0.15)`);
    ctx.fillStyle = fogGrad;
    ctx.fillRect(0, canvas.height * 0.6, canvas.width, canvas.height * 0.4);

    const texture = new THREE.CanvasTexture(canvas);
    texture.magFilter = THREE.LinearFilter;
    texture.minFilter = THREE.LinearMipmapLinearFilter;
    texture.wrapS = THREE.RepeatWrapping;
    texture.wrapT = THREE.ClampToEdgeWrapping;
    texture.repeat.set(1, 1);
    return texture;
}
function buildTerrain(waveIdx) {
    const config = WAVE_CONFIG[waveIdx];
    const terrain = config.terrain;
const groundGeo = new THREE.PlaneGeometry(MAP_SIZE, MAP_SIZE);
let groundMat;
if (terrain === 'jungle') {
    groundMat = new THREE.MeshLambertMaterial({ map: createGrassTexture() });
} else {
    groundMat = new THREE.MeshLambertMaterial({ map: createAsphaltTexture() });
}
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);

    // ---- Road lines ----
    const lineMat = new THREE.MeshLambertMaterial({ color: 0x555555 });
    for (let i = -HALF + 50; i < HALF - 50; i += 60) {
        const l1 = new THREE.Mesh(new THREE.PlaneGeometry(2, MAP_SIZE - 100), lineMat);
        l1.rotation.x = -Math.PI / 2;
        l1.position.set(i, 0.1, 0);
        scene.add(l1);
        const l2 = new THREE.Mesh(new THREE.PlaneGeometry(MAP_SIZE - 100, 2), lineMat);
        l2.rotation.x = -Math.PI / 2;
        l2.position.set(0, 0.1, i);
        scene.add(l2);
    }

    const buildingColors = [0x8a7a6a, 0x9a8a7a, 0x7a8a9a, 0x6a7a8a, 0xaa9a8a, 0x8a9a7a, 0x7a6a5a, 0x5a6a7a];

    // 辅助函数:为建筑物添加窗户装饰(从原代码移植)
    function addWindows(mesh, w, h, d, x, y, z) {
        const winMat = new THREE.MeshLambertMaterial({ color: 0x88aacc, emissive: 0x224466, emissiveIntensity: 0.15 });
        const winCount = Math.floor(Math.random() * 6) + 4;
        for (let wi = 0; wi < winCount; wi++) {
            const wx = (Math.random() - 0.5) * w * 0.8;
            const wz = (Math.random() - 0.5) * d * 0.8;
            const wy = (Math.random() - 0.5) * h * 0.8;
            const win = new THREE.Mesh(new THREE.PlaneGeometry(2.5, 3.5), winMat);
            const face = Math.floor(Math.random() * 4);
            if (face === 0) {
                win.position.set(w / 2 + 0.1, h / 2 + wy, wz);
                win.rotation.y = Math.PI / 2;
            } else if (face === 1) {
                win.position.set(-w / 2 - 0.1, h / 2 + wy, wz);
                win.rotation.y = -Math.PI / 2;
            } else if (face === 2) {
                win.position.set(wx, h / 2 + wy, d / 2 + 0.1);
            } else {
                win.position.set(wx, h / 2 + wy, -d / 2 - 0.1);
                win.rotation.y = Math.PI;
            }
            scene.add(win);
        }
    }
// 在 buildTerrain 中,进入建筑物循环之前
let texTop, texSide;
if (terrain === 'jungle') {
    texTop = createWoodTexture();   // 顶面用木纹
    texSide = createWoodTexture();  // 侧面也用木纹(也可稍暗)
} else {
    texTop = createConcreteTexture(false); // 顶面干净混凝土
    texSide = createConcreteTexture(true); // 侧面带弹坑
}
// 在 buildTerrain 开头,定义纹理变量
let stairTexture;
if (terrain === 'jungle') {
    stairTexture = createWoodTexture();   // 木纹
} else {
    stairTexture = createConcreteTexture(true); // 带弹坑的混凝土
}
// 然后在创建楼梯时使用:
const stairMat = new THREE.MeshLambertMaterial({ map: stairTexture });
    if (terrain === 'jungle') {
        // ---- Jungle: lots of trees, few buildings ----
        const treeCount = 120 + Math.floor(Math.random() * 40);
        for (let i = 0; i < treeCount; i++) {
            const x = (Math.random() - 0.5) * MAP_SIZE * 0.9;
            const z = (Math.random() - 0.5) * MAP_SIZE * 0.9;
            if (Math.abs(x) < 30 && Math.abs(z) < 30) continue;
            let overlap = false;
            for (const obs of obstacles) {
                if (Math.abs(x - obs.position.x) < 5 + obs.width / 2 &&
                    Math.abs(z - obs.position.z) < 5 + obs.depth / 2) { overlap = true; break; }
            }
            if (!overlap) buildTree(x, z);
        }
        // Few buildings
        for (let i = 0; i < 15; i++) {
            let ok = false, tries = 0;
            while (!ok && tries < 200) {
                tries++;
                const w = 15 + Math.random() * 25;
                const h = 10 + Math.random() * 8;
                const d = 15 + Math.random() * 25;
                const x = (Math.random() - 0.5) * MAP_SIZE * 0.75;
                const z = (Math.random() - 0.5) * MAP_SIZE * 0.75;
                if (Math.abs(x) < 50 && Math.abs(z) < 50) continue;
                let overlap = false;
                for (const obs of obstacles) {
                    if (Math.abs(x - obs.position.x) < w / 2 + obs.width / 2 + 8 &&
                        Math.abs(z - obs.position.z) < d / 2 + obs.depth / 2 + 8) { overlap = true; break; }
                }
                if (!overlap) {
// 替换原来的 mesh 创建
const materials = [
    new THREE.MeshLambertMaterial({ map: texSide }), // +x
    new THREE.MeshLambertMaterial({ map: texSide }), // -x
    new THREE.MeshLambertMaterial({ map: texTop }),  // +y (顶面)
    new THREE.MeshLambertMaterial({ map: texSide }), // -y (底面,也可用侧面)
    new THREE.MeshLambertMaterial({ map: texSide }), // +z
    new THREE.MeshLambertMaterial({ map: texSide })  // -z
];
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), materials);
                    mesh.position.set(x, h / 2, z);
                    mesh.castShadow = true;
                    mesh.receiveShadow = true;
                    scene.add(mesh);
                    addWindows(mesh, w, h, d, x, h / 2, z);
                    obstacles.push({
                        mesh: mesh,
                        width: w,
                        height: h,
                        depth: d,
                        position: new THREE.Vector3(x, h / 2, z),
                        minX: x - w / 2,
                        maxX: x + w / 2,
                        minY: 0,
                        maxY: h,
                        minZ: z - d / 2,
                        maxZ: z + d / 2,
                    });
                    // ---- 护栏 ----
                    const railHeight = 0.5;
                    const railThick = 0.3;
                    const railMat = new THREE.MeshLambertMaterial({ color: 0xcccccc });
                    // 前
                    const rf = new THREE.Mesh(new THREE.BoxGeometry(w, railHeight, railThick), railMat);
                    rf.position.set(x, h, z + d / 2 - railThick / 2);
                    rf.castShadow = true;
                    rf.receiveShadow = true;
                    scene.add(rf);
                    obstacles.push({
mesh: rf, // 添加这一行
                        position: new THREE.Vector3(x, h + railHeight / 2, z + d / 2 - railThick / 2),
                        width: w, height: railHeight, depth: railThick,
                        minX: x - w / 2, maxX: x + w / 2,
                        minY: h, maxY: h + railHeight,
                        minZ: z + d / 2 - railThick, maxZ: z + d / 2,
                        isCar: false, isRail: true
                    });
                    // 后
                    const rb = new THREE.Mesh(new THREE.BoxGeometry(w, railHeight, railThick), railMat);
                    rb.position.set(x, h, z - d / 2 + railThick / 2);
                    rb.castShadow = true;
                    rb.receiveShadow = true;
                    scene.add(rb);
                    obstacles.push({
mesh: rb,
                        position: new THREE.Vector3(x, h + railHeight / 2, z - d / 2 + railThick / 2),
                        width: w, height: railHeight, depth: railThick,
                        minX: x - w / 2, maxX: x + w / 2,
                        minY: h, maxY: h + railHeight,
                        minZ: z - d / 2, maxZ: z - d / 2 + railThick,
                        isCar: false, isRail: true
                    });
                    // 左
                    const rl = new THREE.Mesh(new THREE.BoxGeometry(railThick, railHeight, d), railMat);
                    rl.position.set(x - w / 2 + railThick / 2, h, z);
                    rl.castShadow = true;
                    rl.receiveShadow = true;
                    scene.add(rl);
                    obstacles.push({
mesh: rl,
                        position: new THREE.Vector3(x - w / 2 + railThick / 2, h + railHeight / 2, z),
                        width: railThick, height: railHeight, depth: d,
                        minX: x - w / 2, maxX: x - w / 2 + railThick,
                        minY: h, maxY: h + railHeight,
                        minZ: z - d / 2, maxZ: z + d / 2,
                        isCar: false, isRail: true
                    });
                    // 右
                    const rr = new THREE.Mesh(new THREE.BoxGeometry(railThick, railHeight, d), railMat);
                    rr.position.set(x + w / 2 - railThick / 2, h, z);
                    rr.castShadow = true;
                    rr.receiveShadow = true;
                    scene.add(rr);
                    obstacles.push({
mesh: rr,
                        position: new THREE.Vector3(x + w / 2 - railThick / 2, h + railHeight / 2, z),
                        width: railThick, height: railHeight, depth: d,
                        minX: x + w / 2 - railThick, maxX: x + w / 2,
                        minY: h, maxY: h + railHeight,
                        minZ: z - d / 2, maxZ: z + d / 2,
                        isCar: false, isRail: true
                    });
                    // ---- 楼梯(南北方向) ----
                    const dirZ = Math.random() < 0.5 ? 1 : -1;
                    const stairWidth = w * 0.6;
                    const stepDepth = 2;
                    const stepHeight = 2;
                    const numSteps = Math.ceil(h / stepHeight);
                    let startZ = (dirZ === 1) ? z + d / 2 + stepDepth / 2 : z - d / 2 - stepDepth / 2;

                    for (let step = 0; step < numSteps; step++) {
                        const stepX = x;
                        const stepZ = startZ + dirZ * (step * stepDepth + stepDepth / 2);
                        const stepY = h - step * stepHeight - stepHeight / 2;
                        if (stepY < 0) break;
                        const meshStair = new THREE.Mesh(new THREE.BoxGeometry(stairWidth, stepHeight, stepDepth), stairMat);
                        meshStair.position.set(stepX, stepY, stepZ);
                        meshStair.castShadow = true;
                        meshStair.receiveShadow = true;
                        scene.add(meshStair);
                        obstacles.push({
mesh: meshStair, 
                            position: new THREE.Vector3(stepX, stepY, stepZ),
                            width: stairWidth,
                            height: stepHeight,
                            depth: stepDepth,
                            minX: stepX - stairWidth / 2,
                            maxX: stepX + stairWidth / 2,
                            minY: stepY - stepHeight / 2,
                            maxY: stepY + stepHeight / 2,
                            minZ: stepZ - stepDepth / 2,
                            maxZ: stepZ + stepDepth / 2,
                            isCar: false,
                            isStair: true
                        });
                    }
                    ok = true;
                }
            }
        }
        // Few cars
        for (let i = 0; i < 8; i++) {
            let ok = false, tries = 0;
            while (!ok && tries < 100) {
                tries++;
                const x = (Math.random() - 0.5) * MAP_SIZE * 0.8;
                const z = (Math.random() - 0.5) * MAP_SIZE * 0.8;
                if (Math.abs(x) < 40 && Math.abs(z) < 40) continue;
                let overlap = false;
                for (const obs of obstacles) {
                    if (Math.abs(x - obs.position.x) < 4 + obs.width / 2 &&
                        Math.abs(z - obs.position.z) < 3 + obs.depth / 2) { overlap = true; break; }
                }
                if (!overlap) {
                    buildBeetleCar(x, z, Math.random() * Math.PI * 2);
                    ok = true;
                }
            }
        }
    } else {
        // ---- City: lots of buildings, some trees, many cars ----
        const buildingCount = terrain === 'city_dense' ? 70 : 40;
        for (let i = 0; i < buildingCount; i++) {
            let ok = false, tries = 0;
            while (!ok && tries < 300) {
                tries++;
                const w = 15 + Math.random() * 35;
                const h = 12 + Math.random() * 12;
                const d = 15 + Math.random() * 35;
                const x = (Math.random() - 0.5) * MAP_SIZE * 0.85;
                const z = (Math.random() - 0.5) * MAP_SIZE * 0.85;
                if (Math.abs(x) < 45 && Math.abs(z) < 45) continue;
                let overlap = false;
                for (const obs of obstacles) {
                    if (Math.abs(x - obs.position.x) < w / 2 + obs.width / 2 + 10 &&
                        Math.abs(z - obs.position.z) < d / 2 + obs.depth / 2 + 10) { overlap = true; break; }
                }
                if (!overlap) {
// 替换原来的 mesh 创建
const materials = [
    new THREE.MeshLambertMaterial({ map: texSide }), // +x
    new THREE.MeshLambertMaterial({ map: texSide }), // -x
    new THREE.MeshLambertMaterial({ map: texTop }),  // +y (顶面)
    new THREE.MeshLambertMaterial({ map: texSide }), // -y (底面,也可用侧面)
    new THREE.MeshLambertMaterial({ map: texSide }), // +z
    new THREE.MeshLambertMaterial({ map: texSide })  // -z
];
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), materials);
                    mesh.position.set(x, h / 2, z);
                    mesh.castShadow = true;
                    mesh.receiveShadow = true;
                    scene.add(mesh);
                    addWindows(mesh, w, h, d, x, h / 2, z);
                    obstacles.push({
                        mesh: mesh,
                        width: w,
                        height: h,
                        depth: d,
                        position: new THREE.Vector3(x, h / 2, z),
                        minX: x - w / 2,
                        maxX: x + w / 2,
                        minY: 0,
                        maxY: h,
                        minZ: z - d / 2,
                        maxZ: z + d / 2,
                    });
                            // ---- 楼梯 ----
                    const dirZ = Math.random() < 0.5 ? 1 : -1;
                    const stairWidth = w * 0.6;
                    const stepDepth = 2;
                    const stepHeight = 2;
                    const numSteps = Math.ceil(h / stepHeight);
                    let startZ = (dirZ === 1) ? z + d / 2 + stepDepth / 2 : z - d / 2 - stepDepth / 2;

                    for (let step = 0; step < numSteps; step++) {
                        const stepX = x;
                        const stepZ = startZ + dirZ * (step * stepDepth + stepDepth / 2);
                        const stepY = h - step * stepHeight - stepHeight / 2;
                        if (stepY < 0) break;
                        const meshStair = new THREE.Mesh(new THREE.BoxGeometry(stairWidth, stepHeight, stepDepth), stairMat);
                        meshStair.position.set(stepX, stepY, stepZ);
                        meshStair.castShadow = true;
                        meshStair.receiveShadow = true;
                        scene.add(meshStair);
                        obstacles.push({
mesh: meshStair, 
                            position: new THREE.Vector3(stepX, stepY, stepZ),
                            width: stairWidth,
                            height: stepHeight,
                            depth: stepDepth,
                            minX: stepX - stairWidth / 2,
                            maxX: stepX + stairWidth / 2,
                            minY: stepY - stepHeight / 2,
                            maxY: stepY + stepHeight / 2,
                            minZ: stepZ - stepDepth / 2,
                            maxZ: stepZ + stepDepth / 2,
                            isCar: false,
                            isStair: true
                        });
                    }
                    ok = true;
                }
            }
        }
        // Trees in city
        const treeCount = terrain === 'city_dense' ? 20 + Math.floor(Math.random() * 15) : 30 + Math.floor(Math.random() * 20);
        for (let i = 0; i < treeCount; i++) {
            const x = (Math.random() - 0.5) * MAP_SIZE * 0.85;
            const z = (Math.random() - 0.5) * MAP_SIZE * 0.85;
            if (Math.abs(x) < 40 && Math.abs(z) < 40) continue;
            let overlap = false;
            for (const obs of obstacles) {
                if (Math.abs(x - obs.position.x) < 5 + obs.width / 2 &&
                    Math.abs(z - obs.position.z) < 5 + obs.depth / 2) { overlap = true; break; }
            }
            if (!overlap) buildTree(x, z);
        }
        // Cars (many)
        const carCount = terrain === 'city_dense' ? 45 + Math.floor(Math.random() * 20) : 25 + Math.floor(Math.random() * 15);
        for (let i = 0; i < carCount; i++) {
            let ok = false, tries = 0;
            while (!ok && tries < 150) {
                tries++;
                const x = (Math.random() - 0.5) * MAP_SIZE * 0.82;
                const z = (Math.random() - 0.5) * MAP_SIZE * 0.82;
                if (Math.abs(x) < 40 && Math.abs(z) < 40) continue;
                let overlap = false;
                for (const obs of obstacles) {
                    if (Math.abs(x - obs.position.x) < 4 + obs.width / 2 &&
                        Math.abs(z - obs.position.z) < 3 + obs.depth / 2) { overlap = true; break; }
                }
                if (!overlap) {
                    buildBeetleCar(x, z, Math.random() * Math.PI * 2);
                    ok = true;
                }
            }
        }
        // Street lamps
        const poleMat = new THREE.MeshLambertMaterial({ color: 0x666666 });
        const lampMat = new THREE.MeshLambertMaterial({ color: 0xffee88, emissive: 0xffdd44, emissiveIntensity: 0.15 });
        for (let i = 0; i < 30; i++) {
            const x = (Math.random() - 0.5) * MAP_SIZE * 0.85;
            const z = (Math.random() - 0.5) * MAP_SIZE * 0.85;
            if (Math.abs(x) < 40 && Math.abs(z) < 40) continue;
            let overlap = false;
            for (const obs of obstacles) {
                if (Math.abs(x - obs.position.x) < 3 + obs.width / 2 &&
                    Math.abs(z - obs.position.z) < 3 + obs.depth / 2) { overlap = true; break; }
            }
            if (overlap) continue;
            const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.5, 7, 6), poleMat);
            pole.position.set(x, 3.5, z);
            pole.castShadow = true;
            pole.receiveShadow = true;
            scene.add(pole);
            const arm = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.15, 0.15), poleMat);
            arm.position.set(x + 0.7, 7.2, z);
            scene.add(arm);
            const lamp = new THREE.Mesh(new THREE.SphereGeometry(0.6, 6, 6), lampMat);
            lamp.position.set(x + 1.0, 7.0, z);
            scene.add(lamp);
            const light = new THREE.PointLight(0xffdd44, 0.2, 15);
            light.position.set(x + 1.0, 7.0, z);
            scene.add(light);
        }
    }

    // ---- Finalize grid ----
    initGrid();

    // ---- Apply visual style ----
    const vis = config.visual;
// 在 buildTerrain 函数中,原有代码:
// scene.background = new THREE.Color(vis.bg);

// 替换为:
const skyTex = createSkyTexture(waveIdx);
const skyMat = new THREE.MeshBasicMaterial({ map: skyTex, side: THREE.BackSide });
const skyGeo = new THREE.SphereGeometry(4000, 32, 16);
const skyMesh = new THREE.Mesh(skyGeo, skyMat);
// 如果已有旧天空,移除
if (scene._skyMesh) scene.remove(scene._skyMesh);
scene._skyMesh = skyMesh;
scene.add(skyMesh);

// 同时保留背景色作为 fallback(当纹理未加载时)
scene.background = new THREE.Color(vis.bg);
    // 确保 ambientLight 存在
    if (!scene.ambientLight) {
        scene.ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
    }
    scene.ambientLight.color.setHex(vis.ambient);
}

            // ============================================================
            //  PLAYER
            // ============================================================
            function createPlayer() {
                const geo = new THREE.BoxGeometry(PLAYER_RADIUS * 2, PLAYER_HEIGHT, PLAYER_RADIUS * 2);
                const mat = new THREE.MeshLambertMaterial({ color: 0x4682B4 });
                player = new THREE.Mesh(geo, mat);
                player.position.set(0, PLAYER_HEIGHT / 2, 0);
                scene.add(player);
            }

            // ============================================================
            //  ENEMY (WW2 themed: Japanese soldiers)
            // ============================================================
// ============================================================
//  ENEMY FACE TEXTURES (16x16 Canvas)
// ============================================================
function createFaceTexture(type) {
    const size = 16;
    const canvas = document.createElement('canvas');
    canvas.width = size;
    canvas.height = size;
    const ctx = canvas.getContext('2d');

    // 基础肤色 (轴心国军装风格,偏黄)
    const skinColor = '#d4a574';

    if (type === 'him') {
        // HIM: 白眼史蒂夫
        ctx.fillStyle = skinColor;
        ctx.fillRect(0, 0, size, size);
        // 头发 (深棕色)
        ctx.fillStyle = '#5a3a1a';
        ctx.fillRect(2, 0, 12, 4);
        ctx.fillRect(0, 2, 4, 3);
        ctx.fillRect(12, 2, 4, 3);
        // 眼睛 (全白)
        ctx.fillStyle = '#ffffff';
        ctx.fillRect(3, 6, 3, 3);
        ctx.fillRect(10, 6, 3, 3);
        // 嘴巴 (简单线条)
        ctx.fillStyle = '#8a6a4a';
        ctx.fillRect(5, 12, 6, 1);
    } else if (type === 'entity303') {
        // 实体303: 黑色面孔,红色发光眼睛
        ctx.fillStyle = '#000000';
        ctx.fillRect(0, 0, size, size);
        // 眼睛 (红色,带发光效果)
        ctx.fillStyle = '#ff2200';
        ctx.fillRect(3, 5, 3, 4);
        ctx.fillRect(10, 5, 3, 4);
        // 瞳孔 (更亮的红)
        ctx.fillStyle = '#ff4444';
        ctx.fillRect(4, 6, 1, 2);
        ctx.fillRect(11, 6, 1, 2);
        // 嘴巴 (暗红色)
        ctx.fillStyle = '#881111';
        ctx.fillRect(5, 12, 6, 1);
    } else if (type === 'spider') {
        // 跳蛛: 模仿现实跳蛛面部
        ctx.fillStyle = skinColor;
        ctx.fillRect(0, 0, size, size);
        // 绒毛/斑纹 (浅色斑点)
        ctx.fillStyle = '#c49a6c';
        for (let i = 0; i < 20; i++) {
            ctx.fillRect(Math.floor(Math.random() * 16), Math.floor(Math.random() * 16), 1, 1);
        }
        // 两颗标志性大眼 (黑色)
        ctx.fillStyle = '#111111';
        ctx.fillRect(2, 4, 5, 6);
        ctx.fillRect(9, 4, 5, 6);
        // 眼睛高光 (白色)
        ctx.fillStyle = '#ffffff';
        ctx.fillRect(3, 5, 2, 2);
        ctx.fillRect(10, 5, 2, 2);
        // 小眼睛 (两侧)
        ctx.fillStyle = '#222222';
        ctx.fillRect(0, 6, 1, 2);
        ctx.fillRect(15, 6, 1, 2);
        // 小颚 (下方)
        ctx.fillStyle = '#8a6a4a';
        ctx.fillRect(6, 13, 4, 1);
    }

    const texture = new THREE.CanvasTexture(canvas);
    texture.minFilter = THREE.NearestFilter;
    texture.magFilter = THREE.NearestFilter;
    return texture;
}

// 预生成三种面部纹理
const FACE_TEXTURES = {
    him: createFaceTexture('him'),
    entity303: createFaceTexture('entity303'),
    spider: createFaceTexture('spider')
};
const FACE_TYPES = ['him', 'entity303', 'spider'];
function createUniformTexture() {
    const size = 32;
    const canvas = document.createElement('canvas');
    canvas.width = size;
    canvas.height = size;
    const ctx = canvas.getContext('2d');

    // 基础卡其色
    ctx.fillStyle = '#c4a56a';
    ctx.fillRect(0, 0, size, size);

    // 添加一些纹理噪声(布料颗粒)
    for (let i = 0; i < 200; i++) {
        const x = Math.floor(Math.random() * size);
        const y = Math.floor(Math.random() * size);
        const v = 160 + Math.random() * 60;
        ctx.fillStyle = `rgb(${v}, ${v-20}, ${v-40})`;
        ctx.fillRect(x, y, 1, 1);
    }

    // 添加一些缝线或口袋暗示(仅在中心区域,对应躯干正面,但每个面都会显示,不过效果还行)
    ctx.fillStyle = '#a08050';
    // 横向腰带线
    for (let i = 0; i < size; i++) {
        ctx.fillRect(i, 16, 1, 1);
        ctx.fillRect(i, 17, 1, 1);
    }
    // 口袋方块
    ctx.fillStyle = '#b09060';
    ctx.fillRect(6, 10, 6, 6);
    ctx.fillRect(20, 10, 6, 6);
    // 纽扣
    ctx.fillStyle = '#6a5a3a';
    ctx.fillRect(14, 8, 2, 2);
    ctx.fillRect(14, 14, 2, 2);

    // 稍微变暗,模拟旧化
    // 不做了

    const texture = new THREE.CanvasTexture(canvas);
    texture.minFilter = THREE.NearestFilter;
    texture.magFilter = THREE.NearestFilter;
    texture.wrapS = THREE.RepeatWrapping;
    texture.wrapT = THREE.RepeatWrapping;
    texture.repeat.set(1, 1); // 每个面显示整个纹理,不重复
    return texture;
}
          function buildEnemy(x, z) {
    const group = new THREE.Group();
    group.position.set(x, ENEMY_HEIGHT / 2, z);
    group.castShadow = true;
    group.receiveShadow = true;

    const colorPool = [0xffffff, 0x000000, 0x0000ff, 0xff0000];
    const mainColor = colorPool[Math.floor(Math.random() * colorPool.length)];
    const mainMat = new THREE.MeshLambertMaterial({ color: mainColor });

    const scale = 6.5 / 32;
    const headSize = [8, 8, 8].map(v => v * scale);
    const bodySize = [8, 12, 4].map(v => v * scale);
    const armSize = [4, 12, 4].map(v => v * scale);
    const legSize = [7, 12, 7].map(v => v * scale);
    const shoeSize = [4, 4, 4].map(v => v * scale);

// ---- 随机选择面部纹理 ----
const faceType = FACE_TYPES[Math.floor(Math.random() * FACE_TYPES.length)];
const faceTexture = FACE_TEXTURES[faceType];
const skinMat = new THREE.MeshLambertMaterial({ color: 0xd4a574 }); // 皮肤色,与纹理底色一致
const faceMat = new THREE.MeshLambertMaterial({ map: faceTexture });

// 材质数组:顺序 +x, -x, +y, -y, +z, -z
const headMaterials = [
    skinMat, // +x
    skinMat, // -x
    skinMat, // +y
    skinMat, // -y
    faceMat, // +z (正面)
    skinMat  // -z
];

const head = new THREE.Mesh(new THREE.BoxGeometry(...headSize), headMaterials);
head.position.set(0, 6.5 - headSize[1] / 2, 0);
head.castShadow = true;
head.receiveShadow = true;
group.add(head);
const uniformMat = new THREE.MeshLambertMaterial({ map: createUniformTexture() });
    const body = new THREE.Mesh(new THREE.BoxGeometry(...bodySize), uniformMat);
    body.position.set(0, 4.875 - bodySize[1] / 2, 0);
    body.castShadow = true;
    body.receiveShadow = true;
    group.add(body);

    const leftArm = new THREE.Mesh(new THREE.BoxGeometry(...armSize), uniformMat);
    leftArm.position.set(-(bodySize[0] / 2 + armSize[0] / 2), 4.875 - armSize[1] / 2, 0);
    leftArm.castShadow = true;
    leftArm.receiveShadow = true;
    group.add(leftArm);

    const rightArm = new THREE.Mesh(new THREE.BoxGeometry(...armSize), uniformMat);
    rightArm.position.set((bodySize[0] / 2 + armSize[0] / 2), 4.875 - armSize[1] / 2, 0);
    rightArm.castShadow = true;
    rightArm.receiveShadow = true;
    group.add(rightArm);

    const leftLeg = new THREE.Mesh(new THREE.BoxGeometry(...legSize), uniformMat);
    leftLeg.position.set(-0.65, legSize[1] / 2, 0);
    leftLeg.castShadow = true;
    leftLeg.receiveShadow = true;
    group.add(leftLeg);

    const rightLeg = new THREE.Mesh(new THREE.BoxGeometry(...legSize), uniformMat);
    rightLeg.position.set(0.65, legSize[1] / 2, 0);
    rightLeg.castShadow = true;
    rightLeg.receiveShadow = true;
    group.add(rightLeg);
// 对于鞋子,我们可以使用深色材质
const bootMat = new THREE.MeshLambertMaterial({ color: 0x3a2a1a }); // 棕色靴子
    const leftShoe = new THREE.Mesh(new THREE.BoxGeometry(...shoeSize), bootMat);
    leftShoe.position.set(-0.65, 0.25, 0);
    leftShoe.castShadow = true;
    leftShoe.receiveShadow = true;
    group.add(leftShoe);

    const rightShoe = new THREE.Mesh(new THREE.BoxGeometry(...shoeSize), bootMat);
    rightShoe.position.set(0.65, 0.25, 0);
    rightShoe.castShadow = true;
    rightShoe.receiveShadow = true;
    group.add(rightShoe);

// 随机二战枪模
const ww2GunMap = {
    'LeeEnfield': createLeeEnfield,
    'Thompson': createThompson,
    'M1Garand': createM1Garand,
    'STG44': createSTG44,
    'Colt1911': createColt1911
};
const gunNames = Object.keys(ww2GunMap);
const chosenName = gunNames[Math.floor(Math.random() * gunNames.length)];
const gunModel = ww2GunMap[chosenName]();
gunModel.scale.set(3, 2, 2);          // 与原版缩放一致
gunModel.rotation.y = -Math.PI / 2;   // 与原版旋转一致
const rightArmPos = rightArm.position.clone();
gunModel.position.set(rightArmPos.x + 0.5, rightArmPos.y - 0.1, 0.6);
group.add(gunModel);

    // 视野锥(调试用,默认隐藏)
    const visionCone = new THREE.Mesh(
        new THREE.ConeGeometry(ENEMY_VISION_RANGE * Math.tan(ENEMY_VISION_ANGLE / 2), ENEMY_VISION_RANGE, 8),
        new THREE.MeshBasicMaterial({ color: 0xff0000, transparent: true, opacity: 0.12, side: THREE.DoubleSide })
    );
    visionCone.rotation.x = Math.PI / 2;
    visionCone.position.y = ENEMY_HEIGHT / 2;
    visionCone.visible = false;
    group.add(visionCone);

    // 血条
    const canvas = document.createElement('canvas');
    canvas.width = 64;
    canvas.height = 10;
    const texture = new THREE.CanvasTexture(canvas);

    const spriteMat = new THREE.SpriteMaterial({ map: texture, depthTest: false, depthWrite: false });
    const healthBarSprite = new THREE.Sprite(spriteMat);
    healthBarSprite.position.set(0, ENEMY_HEIGHT + 3.0, 0);
    healthBarSprite.scale.set(5, 0.9, 1);
   group.add(healthBarSprite);

    // 返回敌人对象
    const enemy = {
        group: group,
        position: new THREE.Vector3(x, ENEMY_HEIGHT / 2, z),
        velocity: new THREE.Vector3(0, 0, 0),
        velY: 0,
        onGround: true,
        rotationY: Math.random() * Math.PI * 2,
        health: 100,
        maxHealth: 100,
        isAlive: true,
        state: 'patrol',
        detection: 0,
        lastKnownPos: null,
        stateTime: 0,
        targetPos: new THREE.Vector3(x + (Math.random() - 0.5) * 100, ENEMY_HEIGHT / 2, z + (Math.random() - 0.5) * 100),
        patrolCenter: new THREE.Vector3(x, ENEMY_HEIGHT / 2, z),
        speed: 7,
        runSpeed: 10,
        attackRange: 0,
        attackDamage: 8,
        attackCooldown: 1,
        lastAttack: 0,
        lastRanged: 0,
        rangedRange: 500,
        healthCanvas: canvas,
        healthTex: texture,
        healthSpr: healthBarSprite,
        visionCone: visionCone,
        path: [],
        pathIndex: 0,
        pathTimer: 0,
        pathUpdateInterval: 0.5,
        lastPathUpdate: 0,
        prevPosition: new THREE.Vector3(x, ENEMY_HEIGHT / 2, z),
// 在 buildEnemy 返回对象中增加
stuck: false,              // 是否处于脱困状态
escapeDirection: null,     // 当前逃逸方向向量 (THREE.Vector3)
escapeTarget: null,        // 当前目标点(用于判断到达与否)
escapeTimer: 0,            // 当前方向已用时间(秒)
        animationTime: 0,
        stopDistance: 20,
        head: head,
        body: body,
        leftArm: leftArm,
        rightArm: rightArm,
        leftLeg: leftLeg,
        rightLeg: rightLeg,
        leftShoe: leftShoe,
        rightShoe: rightShoe,
        gun: gunModel,
        mainMat: mainMat,
        mainColor: mainColor,
        aabb: aabbFromPosSize(x, ENEMY_HEIGHT / 2, z, ENEMY_RADIUS * 2, ENEMY_HEIGHT, ENEMY_RADIUS * 2),
        isShooting: false,
        shootAnimTime: 0,
        gunRecoil: 0,
        muzzleFlash: null,
    };
    updateEnemyBar(enemy);
    return enemy;
}

            function updateEnemyBar(e) {
                const c = e.healthCanvas;
                const ctx = c.getContext('2d');
                ctx.clearRect(0, 0, c.width, c.height);
                ctx.fillStyle = '#550000';
                ctx.fillRect(0, 0, c.width, c.height);
                const pct = Math.max(0, e.health / e.maxHealth);
                const w = Math.max(0, (c.width - 2) * pct);
                const grad = ctx.createLinearGradient(0, 0, w, 0);
                grad.addColorStop(0, '#00aa00');
                grad.addColorStop(1, '#44ff44');
                ctx.fillStyle = grad;
                ctx.fillRect(1, 1, w, c.height - 2);
                e.healthTex.needsUpdate = true;
                e.healthSpr.visible = e.isAlive;
            }

            function spawnEnemiesForWave(count) {
                const placed = [];
                for (let i = 0; i < count; i++) {
                    let ok = false,
                        tries = 0;
                    while (!ok && tries < 800) {
                        tries++;
                        const x = (Math.random() - 0.5) * MAP_SIZE * 0.8;
                        const z = (Math.random() - 0.5) * MAP_SIZE * 0.8;
                        if (Math.abs(x) < 40 && Math.abs(z) < 40) continue;
                        let overlap = false;
                        for (const p of placed) {
                            if (Math.abs(x - p.x) < ENEMY_RADIUS * 5 && Math.abs(z - p.z) < ENEMY_RADIUS * 5) { overlap =
                                    true; break; }
                        }
                        if (!overlap) {
                            const e = buildEnemy(x, z);
                            scene.add(e.group);
                            enemies.push(e);
                            placed.push({ x, z });
                            ok = true;
                        }
                    }
                }
                waveTotalEnemies = enemies.length;
                enemiesSpawned = enemies.length;
                waveKills = 0;
                updateHUD();
            }

            // ============================================================
            //  PLAYER MOVEMENT
            // ============================================================
            function updatePlayer(delta) {
                const clamped = Math.min(delta, MAX_DELTA);
                const sub = clamped / SUBSTEPS;
                for (let s = 0; s < SUBSTEPS; s++) {
                    playerVel.y -= GRAVITY * sub;
                    const fwd = new THREE.Vector3(0, 0, -1).applyAxisAngle(new THREE.Vector3(0, 1, 0), mouseX);
                    const right = new THREE.Vector3(1, 0, 0).applyAxisAngle(new THREE.Vector3(0, 1, 0), mouseX);
                    const move = new THREE.Vector3();
                    if (moveF) move.add(fwd);
                    if (moveB) move.add(fwd.clone().multiplyScalar(-1));
                    if (moveL) move.add(right.clone().multiplyScalar(-1));
                    if (moveR) move.add(right);
                    if (move.length() > 0) {
                        const speed = isShift ? PLAYER_SPEED * RUN_MULT : PLAYER_SPEED;
                        move.normalize().multiplyScalar(speed * sub);
                        const oldX = player.position.x;
                        const oldZ = player.position.z;
                        player.position.x += move.x;
                        player.position.z += move.z;
                        resolvePlayerAABB(oldX, oldZ);
                    }
                    player.position.y += playerVel.y * sub;
                    resolvePlayerVerticalAABB();
                }
                const margin = 15;
                player.position.x = THREE.MathUtils.clamp(player.position.x, -HALF + margin, HALF - margin);
                player.position.z = THREE.MathUtils.clamp(player.position.z, -HALF + margin, HALF - margin);
                camera.position.copy(player.position);
                camera.position.y += 2;
                camera.rotation.set(mouseY, mouseX, 0, 'YXZ');
            }

            function resolvePlayerAABB(oldX, oldZ) {
                const pa = aabbFromPlayer();
                for (const obs of obstacles) {
                    const oa = aabbFromObstacle(obs);
                    if (!(pa.minY < oa.maxY && pa.maxY > oa.minY)) continue;
                    if (aabbOverlap(pa, oa)) {
                        player.position.x = oldX;
                        player.position.z = oldZ;
                        return;
                    }
                }
            }

            function resolvePlayerVerticalAABB() {
                const pa = aabbFromPlayer();
                let landed = false;
                if (pa.minY <= 0 && playerVel.y <= 0) {
                    player.position.y = PLAYER_HEIGHT / 2;
                    playerVel.y = 0;
                    canJump = true;
                    isJumping = false;
                    onGround = true;
                    landed = true;
                }
                for (const obs of obstacles) {
                    const oa = aabbFromObstacle(obs);
                    const hOverlap = pa.minX < oa.maxX && pa.maxX > oa.minX &&
                        pa.minZ < oa.maxZ && pa.maxZ > oa.minZ;
                    if (hOverlap) {
                        const bottom = pa.minY;
                        const top = oa.maxY;
                        if (bottom <= top + 0.5 && bottom >= top - 0.5 && playerVel.y <= 0) {
                            player.position.y = top + PLAYER_HEIGHT / 2;
                            playerVel.y = 0;
                            canJump = true;
                            isJumping = false;
                            onGround = true;
                            landed = true;
                            break;
                        }
                    }
                }
                for (const obs of obstacles) {
                    const oa = aabbFromObstacle(obs);
                    const hOverlap = pa.minX < oa.maxX && pa.maxX > oa.minX &&
                        pa.minZ < oa.maxZ && pa.maxZ > oa.minZ;
                    if (hOverlap) {
                        const head = pa.maxY;
                        const bottom = oa.minY;
                        if (head >= bottom - 0.5 && head <= bottom + 0.5 && playerVel.y > 0) {
                            player.position.y = bottom - PLAYER_HEIGHT / 2 - 0.1;
                            playerVel.y = 0;
                        }
                    }
                }
                if (!landed && playerVel.y <= 0) {
                    const pa2 = aabbFromPlayer();
                    if (pa2.minY <= 0.05) {
                        player.position.y = PLAYER_HEIGHT / 2;
                        playerVel.y = 0;
                        canJump = true;
                        isJumping = false;
                        onGround = true;
                    } else {
                        onGround = false;
                    }
                }
            }

            // ============================================================
            //  SHOOTING
            // ============================================================
// ============================================================
//  AUDIO SYSTEM (Web Audio)
// ============================================================
let sharedAudioCtx = null;

function getAudioContext() {
    if (!sharedAudioCtx) {
        sharedAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
    }
    if (sharedAudioCtx.state === 'suspended') {
        sharedAudioCtx.resume();
    }
    return sharedAudioCtx;
}

// ---------- 爆炸/重击音效(你提供的) ----------
function playExplosionSynth(volume = 0.7) {
    try {
        const ctx = getAudioContext();
        const totalTime = 0.7;
        const now = ctx.currentTime;

        const VOL_IMPACT  = 0.85;
        const VOL_CRACKLE = 0.65;
        const SEND_REVERB = 0.25;

        const masterGain = ctx.createGain();
        masterGain.gain.value = volume;
        masterGain.connect(ctx.destination);

        const convolver = ctx.createConvolver();
        const impulseLen = ctx.sampleRate * 0.3;
        const impulseBuf = ctx.createBuffer(1, impulseLen, ctx.sampleRate);
        const impData = impulseBuf.getChannelData(0);
        for (let i = 0; i < impulseLen; i++) {
            impData[i] = (Math.random() * 2 - 1) * Math.exp(-i / (impulseLen * 0.15));
        }
        convolver.buffer = impulseBuf;

        const reverbSend = ctx.createGain();
        reverbSend.gain.value = SEND_REVERB;
        reverbSend.connect(convolver);
        convolver.connect(masterGain);

        const oscImpact = ctx.createOscillator();
        oscImpact.type = 'sawtooth';
        oscImpact.frequency.setValueAtTime(130, now);
        oscImpact.frequency.exponentialRampToValueAtTime(28, now + 0.12);

        const gainImpact = ctx.createGain();
        gainImpact.gain.setValueAtTime(0, now);
        gainImpact.gain.linearRampToValueAtTime(VOL_IMPACT, now + 0.005);
        gainImpact.gain.exponentialRampToValueAtTime(0.001, now + 0.22);

        oscImpact.connect(gainImpact);
        gainImpact.connect(masterGain);
        gainImpact.connect(reverbSend);

        const noiseBufSize = ctx.sampleRate * totalTime;
        const noiseBuf = ctx.createBuffer(1, noiseBufSize, ctx.sampleRate);
        const nData = noiseBuf.getChannelData(0);
        for (let i = 0; i < noiseBufSize; i++) {
            const t = i / noiseBufSize;
            const env = Math.pow(Math.max(0, 1 - t / totalTime), 1.5);
            nData[i] = (Math.random() * 2 - 1) * env;
        }
        const noiseSrc = ctx.createBufferSource();
        noiseSrc.buffer = noiseBuf;

        const noiseGain = ctx.createGain();
        noiseGain.gain.value = VOL_CRACKLE;

        const filterHigh = ctx.createBiquadFilter();
        filterHigh.type = 'bandpass';
        filterHigh.frequency.setValueAtTime(2400, now);
        filterHigh.frequency.exponentialRampToValueAtTime(400, now + 0.45);
        filterHigh.Q.value = 2.0;

        noiseSrc.connect(filterHigh);
        filterHigh.connect(noiseGain);
        noiseGain.connect(masterGain);
        noiseGain.connect(reverbSend);

        oscImpact.start(now);
        oscImpact.stop(now + 0.25);
        noiseSrc.start(now);
        noiseSrc.stop(now + totalTime);

        console.log('💥爆炸音效');
    } catch (e) {
        console.warn('爆炸音效合成失败', e);
    }
}

// ---------- 轻量级枪声 ----------
function playGunshotSound(volume = 0.25, pitch = 200) {
    try {
        const ctx = getAudioContext();
        const now = ctx.currentTime;
        const osc = ctx.createOscillator();
        const gain = ctx.createGain();
        osc.type = 'sawtooth';
        osc.frequency.setValueAtTime(pitch, now);
        osc.frequency.exponentialRampToValueAtTime(pitch * 0.25, now + 0.08);
        gain.gain.setValueAtTime(volume, now);
        gain.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
        osc.connect(gain);
        gain.connect(ctx.destination);
        osc.start(now);
        osc.stop(now + 0.08);
    } catch (e) {
        // 静默失败
    }
}

function playHitSound(volume = 0.15) {
    try {
        const ctx = getAudioContext();
        const now = ctx.currentTime;
        const osc = ctx.createOscillator();
        const gain = ctx.createGain();
        osc.type = 'square';
        osc.frequency.setValueAtTime(900, now);
        osc.frequency.exponentialRampToValueAtTime(500, now + 0.04);
        gain.gain.setValueAtTime(volume, now);
        gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
        osc.connect(gain);
        gain.connect(ctx.destination);
        osc.start(now);
        osc.stop(now + 0.05);
    } catch (e) {}
}

function playPickupSound(volume = 0.12) {
    try {
        const ctx = getAudioContext();
        const now = ctx.currentTime;
        const osc = ctx.createOscillator();
        const gain = ctx.createGain();
        osc.type = 'sine';
        osc.frequency.setValueAtTime(500, now);
        osc.frequency.exponentialRampToValueAtTime(1000, now + 0.1);
        gain.gain.setValueAtTime(volume, now);
        gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
        osc.connect(gain);
        gain.connect(ctx.destination);
        osc.start(now);
        osc.stop(now + 0.12);
    } catch (e) {}
}

function playReloadSound(volume = 0.1) {
    try {
        const ctx = getAudioContext();
        const now = ctx.currentTime;
        // 两个短促的"咔嗒"声模拟装弹
        for (let i = 0; i < 2; i++) {
            const osc = ctx.createOscillator();
            const gain = ctx.createGain();
            osc.type = 'square';
            osc.frequency.setValueAtTime(300 + i * 150, now + i * 0.15);
            gain.gain.setValueAtTime(volume * (1 - i * 0.3), now + i * 0.15);
            gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.15 + 0.03);
            osc.connect(gain);
            gain.connect(ctx.destination);
            osc.start(now + i * 0.15);
            osc.stop(now + i * 0.15 + 0.03);
        }
    } catch (e) {}
}
         function shoot() {
    const def = getCurrentDef();
    const state = getCurrentState();
    const now = Date.now();
    const interval = 60 / def.fireRate * 1000;
    if (now - lastShot < interval) return;
    if (state.currentAmmo <= 0) {
        if (state.totalAmmo > 0 && !state.isReloading) startReload();
        return;
    }
    if (def.needsBolt && !state.boltReady) { showBoltHint(); return; }
    if (!infAmmo) state.currentAmmo--;
    lastShot = now;
playExplosionSynth(1);

// 连发武器后坐力更小,半自动/栓动武器后坐力较大
const recoilScale = def.auto ? 0.35 : 1.0; 
// 水平后坐力(左右随机晃动)
const recoilAngle = (Math.random() - 0.5) * 0.2 * recoilScale;
mouseX += recoilAngle;
// 垂直后坐力(上跳)
const recoilY = (Math.random() - 0.5) * 0.02 * (def.auto ? 0.5 : 1.0);
mouseY += recoilY;
mouseY = THREE.MathUtils.clamp(mouseY, -Math.PI / 2, Math.PI / 2);

    if (def.needsBolt) {
        state.boltReady = false;
        showBoltHint();
    }

    // 射线起点和方向
    const origin = camera.position.clone();
    const dir = new THREE.Vector3();
    camera.getWorldDirection(dir);
    if (def.spread > 0) {
        dir.x += (Math.random() - 0.5) * def.spread;
        dir.y += (Math.random() - 0.5) * def.spread;
        dir.z += (Math.random() - 0.5) * def.spread;
        dir.normalize();
    }
    const MAX_DIST = def.range || 1000;

    // ---- 预筛选阈值(点线距离) ----
    const PROXIMITY_THRESHOLD = 20; // 可根据需要调整

    // 1. 寻找最近的敌人(先粗筛,再精确 Slab 检测)
    let closestEnemy = null;
    let closestEnemyDist = Infinity;

    for (const e of enemies) {
        if (!e.isAlive) continue;

        // 快速粗筛:计算敌人中心到射线的距离
        const toEnemy = new THREE.Vector3().subVectors(e.position, origin);
        const projLength = toEnemy.dot(dir);
        if (projLength < 0 || projLength > MAX_DIST) continue; // 在射线后方或超出射程

        // 垂直距离 = |toEnemy × dir|
        const perpDist = new THREE.Vector3().crossVectors(toEnemy, dir).length();
        if (perpDist > PROXIMITY_THRESHOLD) continue; // 离射线太远,跳过

        // 粗筛通过,执行精确 AABB Slab 检测
        const ea = aabbFromEnemy(e);
        const dist = getRayAABBDist(origin, dir, ea);
        if (dist > 0 && dist < closestEnemyDist && dist <= MAX_DIST) {
            closestEnemy = e;
            closestEnemyDist = dist;
        }
    }

    // 2. 检测障碍物阻挡(同样先粗筛?这里保留原逻辑,因为障碍物数量通常较少,直接检测)
    let blocked = false;
    let hitPoint = null;
    let hitObstacle = false;
    for (const obs of obstacles) {
    // 粗略判断障碍物是否在子弹路径附近(例如:距离射线起点 80 单位以内)
    const dx = obs.position.x - origin.x;
    const dz = obs.position.z - origin.z;
    if (dx*dx + dz*dz > 10000) continue; // 跳过太远的障碍物
        const oa = aabbFromObstacle(obs);
        const dist = getRayAABBDist(origin, dir, oa);
        if (dist > 0 && dist < closestEnemyDist) {
            blocked = true;
            hitObstacle = true;
            hitPoint = origin.clone().add(dir.clone().multiplyScalar(dist));
            break;
        }
    }

    // 3. 处理命中
    if (blocked) {
        shotsFired++;
        createHitEffect(hitPoint, 0x888888);
        notifyGunshot(origin);
        updateHUD();
        updateWeaponUI();
        return;
    }

    if (closestEnemy) {
        shotsHit++;
        const hitPoint = origin.clone().add(dir.clone().multiplyScalar(closestEnemyDist));
        let dmg = def.damage;
        if (hitPoint.y > closestEnemy.position.y + ENEMY_HEIGHT * 0.65) dmg *= 2;
        closestEnemy.health -= dmg;
        updateEnemyBar(closestEnemy);
        showHitMarker();
        createHitEffect(hitPoint, 0xffaa00);
        if (closestEnemy.health <= 0) {
            killEnemy(closestEnemy);
        } else {
            closestEnemy.state = 'chase';
            closestEnemy.detection = 1.0;
            closestEnemy.lastKnownPos = player.position.clone();
            closestEnemy.stateTime = 0;
        }
        notifyGunshot(origin);
    } else {
        const farPoint = origin.clone().add(dir.clone().multiplyScalar(MAX_DIST));
        createHitEffect(farPoint, 0xcccccc);
        notifyGunshot(origin);
    }

    shotsFired++;
    updateHUD();
    updateWeaponUI();

    if (def.auto && mouseDown && state.currentAmmo > 0 && !state.isReloading && !gameOver) {
        setTimeout(() => {
            if (mouseDown && !state.isReloading && !gameOver) shoot();
        }, interval);
    }
}
let particles = []; // 活跃碎片数组
function createHitParticles(position, color, count = 30, speed = 5, life = 1.5) {
    // 使用 Points 方式,生成粒子位置和颜色
    const positions = new Float32Array(count * 3);
    const colors = new Float32Array(count * 3);
    const velocities = [];

    const baseColor = new THREE.Color(color);

    for (let i = 0; i < count; i++) {
        // 位置从中心偏移,随机方向
        const theta = Math.random() * Math.PI * 2;
        const phi = Math.random() * Math.PI * 2;
        const spd = speed * (0.3 + Math.random() * 0.7);
        const vx = Math.sin(theta) * Math.cos(phi) * spd;
        const vy = Math.sin(phi) * spd * 0.5 + 1.0; // 轻微上冲
        const vz = Math.cos(theta) * Math.cos(phi) * spd;

        positions[i*3] = position.x + (Math.random() - 0.5) * 0.5;
        positions[i*3+1] = position.y + (Math.random() - 0.5) * 0.5;
        positions[i*3+2] = position.z + (Math.random() - 0.5) * 0.5;

        // 颜色轻微随机
        const c = baseColor.clone().multiplyScalar(0.6 + Math.random() * 0.4);
        colors[i*3] = c.r;
        colors[i*3+1] = c.g;
        colors[i*3+2] = c.b;

        velocities.push({ x: vx, y: vy, z: vz });
    }

    const geometry = new THREE.BufferGeometry();
    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));

    const material = new THREE.PointsMaterial({
        size: 0.8,
        vertexColors: true,
        transparent: true,
        opacity: 1.0,
        blending: THREE.AdditiveBlending,
        depthWrite: false,
        sizeAttenuation: true
    });

    const points = new THREE.Points(geometry, material);
    scene.add(points);

    // 存储粒子数据
    const particleData = {
        points: points,
        velocities: velocities,
        age: 0,
        life: life * (0.6 + Math.random() * 0.4),
        isAlive: true
    };
    particles.push(particleData);
}function updateParticles(delta) {
    const clamped = Math.min(delta, 0.05);
    for (let i = particles.length - 1; i >= 0; i--) {
        const p = particles[i];
        if (!p.isAlive) {
            // 清理
            scene.remove(p.points);
            p.points.geometry.dispose();
            p.points.material.dispose();
            particles.splice(i, 1);
            continue;
        }

        p.age += clamped;
        if (p.age > p.life) {
            p.isAlive = false;
            continue;
        }

        // 更新位置
        const pos = p.points.geometry.attributes.position.array;
        const vel = p.velocities;
        for (let j = 0; j < vel.length; j++) {
            pos[j*3] += vel[j].x * clamped;
            pos[j*3+1] += vel[j].y * clamped;
            pos[j*3+2] += vel[j].z * clamped;
            // 简单空气阻力
            vel[j].x *= 0.99;
            vel[j].y *= 0.99;
            vel[j].z *= 0.99;
        }
        p.points.geometry.attributes.position.needsUpdate = true;

        // 逐渐缩小和淡出
        const lifeRatio = p.age / p.life;
        p.points.material.opacity = 1.0 - lifeRatio;
        p.points.material.size = 0.8 * (1.0 - lifeRatio * 0.7);
    }
}
            // ============================================================
            //  ENEMY SHOOT
            // ============================================================
            function enemyShoot(enemy) {
                if (!enemy.isAlive) return;
                const now = Date.now();
                const cooldown = 1200 + Math.random() * 800;
                if (now - enemy.lastRanged < cooldown) return;
                const dir = new THREE.Vector3().subVectors(player.position, enemy.position).normalize();
                const spread = 0.08;
const ddistToPlayer = enemy.position.distanceTo(player.position);
const volume = 0.2 + 0.4 * Math.min(1, ddistToPlayer / 200);
playExplosionSynth(volume);
                dir.x += (Math.random() - 0.5) * spread;
                dir.y += (Math.random() - 0.5) * spread;
                dir.z += (Math.random() - 0.5) * spread;
                dir.normalize();
                const start = enemy.position.clone();
                start.y += ENEMY_HEIGHT * 0.7;

                const pa = aabbFromPlayer();
                const distToPlayer = getRayAABBDist(start, dir, pa);
                if (!isFinite(distToPlayer) || distToPlayer <= 0) {
                    enemy.lastRanged = now;
                    triggerEnemyShootAnim(enemy);
                    return;
                }
                let blocked = false;
                for (const obs of obstacles) {
                    const oa = aabbFromObstacle(obs);
                    if (!rayAABBIntersect(start, dir, oa)) continue;
                    const hitDist = getRayAABBDist(start, dir, oa);
                    if (hitDist > 0.5 && hitDist < distToPlayer) { blocked = true; break; }
                }
                if (blocked) {
                    enemy.lastRanged = now;
                    triggerEnemyShootAnim(enemy);
                    return;
                }
                if (!godMode) {
                    playerHP -= 4;
                    updateHUD();
                    showDamage();
                    const angle = (Math.random() - 0.5) * Math.PI / 3;
                    mouseX += angle;
                    mouseY += (Math.random() - 0.5) * 0.15;
                    mouseY = THREE.MathUtils.clamp(mouseY, -Math.PI / 2, Math.PI / 2);
                    if (playerHP <= 0) { playerHP = 0;
                        endGame(); }
                }
                createHitEffect(player.position.clone().add(new THREE.Vector3(0, PLAYER_HEIGHT / 2, 0)), 0xff4444);
                enemy.lastRanged = now;
                triggerEnemyShootAnim(enemy);
            }

            function triggerEnemyShootAnim(enemy) {
                if (!enemy.isAlive) return;
                enemy.isShooting = true;
                enemy.shootAnimTime = 0;
                enemy.gunRecoil = 0.6;
                if (enemy.muzzleFlash) { scene.remove(enemy.muzzleFlash); }
                const flashGeo = new THREE.SphereGeometry(0.5, 6, 6);
                const flashMat = new THREE.MeshBasicMaterial({ color: 0xff8800, transparent: true, opacity: 1.0 });
                const flash = new THREE.Mesh(flashGeo, flashMat);
                const gunPos = enemy.gun.position.clone();
                gunPos.x += 2.0;
                gunPos.y += 0.2;
                flash.position.copy(gunPos);
                flash.position.x += 1.0;
                enemy.group.add(flash);
                enemy.muzzleFlash = flash;
                setTimeout(() => {
                    if (enemy.muzzleFlash) { scene.remove(enemy.muzzleFlash);
                        enemy.muzzleFlash = null; }
                }, 150);
                const particleCount = 12;
                const pGeo = new THREE.BufferGeometry();
                const pPos = new Float32Array(particleCount * 3);
                for (let i = 0; i < particleCount; i++) {
                    const gp = gunPos.clone();
                    gp.x += 1.0 + (Math.random() - 0.5) * 0.6;
                    gp.y += (Math.random() - 0.5) * 0.6;
                    gp.z += (Math.random() - 0.5) * 0.6;
                    pPos[i * 3] = gp.x;
                    pPos[i * 3 + 1] = gp.y;
                    pPos[i * 3 + 2] = gp.z;
                }
                pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3));
                const pMat = new THREE.PointsMaterial({ color: 0xff8800, size: 0.25, transparent: true, opacity: 0.9,
                    blending: THREE.AdditiveBlending });
                const particles = new THREE.Points(pGeo, pMat);
                enemy.group.add(particles);
                let age = 0;
                const life = 0.3;
                const updParticles = () => {
                    age += 0.016;
                    if (age < life) {
                        const posAttr = particles.geometry.attributes.position;
                        const arr = posAttr.array;
                        for (let i = 0; i < arr.length; i += 3) {
                            arr[i] += (Math.random() - 0.5) * 0.4;
                            arr[i + 1] += (Math.random() - 0.5) * 0.4 + 0.2;
                            arr[i + 2] += (Math.random() - 0.5) * 0.4;
                        }
                        posAttr.needsUpdate = true;
                        particles.material.opacity = 0.9 * (1 - age / life);
                        requestAnimationFrame(updParticles);
                    } else { enemy.group.remove(particles); }
                };
                updParticles();
            }

            function rayAABBIntersect(origin, dir, aabb) {
                const invDir = new THREE.Vector3(1 / dir.x, 1 / dir.y, 1 / dir.z);
                let t1 = (aabb.minX - origin.x) * invDir.x;
                let t2 = (aabb.maxX - origin.x) * invDir.x;
                let tmin = Math.min(t1, t2);
                let tmax = Math.max(t1, t2);
                t1 = (aabb.minY - origin.y) * invDir.y;
                t2 = (aabb.maxY - origin.y) * invDir.y;
                tmin = Math.max(tmin, Math.min(t1, t2));
                tmax = Math.min(tmax, Math.max(t1, t2));
                t1 = (aabb.minZ - origin.z) * invDir.z;
                t2 = (aabb.maxZ - origin.z) * invDir.z;
                tmin = Math.max(tmin, Math.min(t1, t2));
                tmax = Math.min(tmax, Math.max(t1, t2));
                return tmax >= tmin && tmax > 0;
            }

            // ============================================================
            //  ENEMY AI
            // ============================================================
       function updateEnemies(delta) {
    const now = Date.now();
    let playerOnBuilding = false;
    let stairTarget = null;
    const playerFeet = player.position.y - PLAYER_HEIGHT / 2;
    for (const obs of obstacles) {
        if (obs.height > 10) {
            const oa = aabbFromObstacle(obs);
            if (player.position.x > oa.minX && player.position.x < oa.maxX &&
                player.position.z > oa.minZ && player.position.z < oa.maxZ &&
                playerFeet > oa.maxY - 2 && playerFeet < oa.maxY + 2) {
                playerOnBuilding = true;
                let bestStair = null;
                for (const stair of obstacles) {
                    if (stair.isStair) {
                        const dx = stair.position.x - obs.position.x;
                        const dz = stair.position.z - obs.position.z;
                        const halfW = obs.width / 2 + 8;
                        const halfD = obs.depth / 2 + 8;
                        if (Math.abs(dx) < halfW && Math.abs(dz) < halfD) {
                            if (!bestStair || stair.position.y > bestStair.position.y) { bestStair = stair; }
                        }
                    }
                }
                if (bestStair) { stairTarget = bestStair.position.clone();
                    stairTarget.y += ENEMY_HEIGHT / 2; }
                break;
            }
        }
    }

    for (const e of enemies) {
        if (!e.isAlive) continue;

        // ============================================================
        // 脱困检测:如果当前位置不可通行,进入随机移动模式
        // ============================================================
        const gPos = worldToGrid(e.position.x, e.position.z);
        const walkable = isWalkable(gPos.gx, gPos.gz);

        if (!walkable) {
            e.stuck = true;
            e.escapeTimer += delta;

            // 每10秒 或 到达目标点 → 重新选择方向
            if (!e.escapeDirection || e.escapeTimer >= 5.0 ||
                (e.escapeTarget && e.position.distanceTo(e.escapeTarget) < 5)) {
                const angle = Math.random() * Math.PI * 2;
                const dist = 30 + Math.random() * 80;
                const dir = new THREE.Vector3(Math.cos(angle), 0, Math.sin(angle));
                e.escapeDirection = dir.clone();
                e.escapeTarget = e.position.clone().add(dir.clone().multiplyScalar(dist));
                // 限制目标在地图边界内
                e.escapeTarget.x = THREE.MathUtils.clamp(e.escapeTarget.x, -HALF + 20, HALF - 20);
                e.escapeTarget.z = THREE.MathUtils.clamp(e.escapeTarget.z, -HALF + 20, HALF - 20);
                e.escapeTimer = 0;
            }

            // 向逃逸目标直线移动
            const dirToTarget = new THREE.Vector3().subVectors(e.escapeTarget, e.position);
            if (dirToTarget.length() > 0.5) {
                dirToTarget.normalize();
                const speed = e.speed * 1.2;   // 略快于步行
                e.position.x += dirToTarget.x * speed * delta;
                e.position.z += dirToTarget.z * speed * delta;
            }
            // 边界限制
            e.position.x = THREE.MathUtils.clamp(e.position.x, -HALF + 20, HALF - 20);
            e.position.z = THREE.MathUtils.clamp(e.position.z, -HALF + 20, HALF - 20);

            // 重力与垂直碰撞
            e.velY -= GRAVITY * delta;
            e.position.y += e.velY * delta;
            if (e.position.y - ENEMY_HEIGHT / 2 <= 0) {
                e.position.y = ENEMY_HEIGHT / 2;
                e.velY = 0;
            }

            // 碰撞解析(防止卡墙)
            resolveEnemyAABB(e);

            // 旋转朝向移动方向
            if (e.escapeDirection && e.escapeDirection.length() > 0.01) {
                e.rotationY = Math.atan2(e.escapeDirection.x, e.escapeDirection.z);
                e.group.rotation.y = e.rotationY;
            }

            // 更新组位置和包围盒
            e.group.position.copy(e.position);
            e.aabb = aabbFromEnemy(e);

            // 跳过后续所有正常 AI 逻辑(状态机、射击、寻路等)
            continue;
        } else {
            // 如果之前 stuck,现在可通行了,恢复正常
            if (e.stuck) {
                e.stuck = false;
                e.escapeDirection = null;
                e.escapeTarget = null;
                e.escapeTimer = 0;
                e.path = [];            // 清空旧路径,强制重新规划
                e.lastPathUpdate = 0;
            }
        }

        // ============================================================
        // 以下是原有的状态机和寻路逻辑(保持不变)
        // ============================================================

        e.stateTime += delta;
        if (e.isShooting) {
            e.shootAnimTime += delta;
            e.gunRecoil *= 0.92;
            if (e.gunRecoil < 0.01) e.gunRecoil = 0;
            if (e.shootAnimTime > 0.4) { e.isShooting = false; e.gunRecoil = 0; }
            if (e.gun) e.gun.rotation.x = -e.gunRecoil * 0.5;
        } else {
            if (e.gun) { e.gun.rotation.x *= 0.9; if (Math.abs(e.gun.rotation.x) < 0.005) e.gun.rotation.x = 0; }
        }

        const distToPlayer = e.position.distanceTo(player.position);
        if (distToPlayer < 900) {
            if (e.state !== 'chase') { e.state = 'chase'; e.detection = 1.0; e.stateTime = 0; }
            e.lastKnownPos = player.position.clone();
        } else {
            const det = detectPlayer(e);
            if (det.detected && det.type === 'sight') {
                e.state = 'chase';
                e.detection = 1.0;
                e.lastKnownPos = player.position.clone();
                e.stateTime = 0;
                notifyNearby(e, player.position.clone(), 200);
            } else if (det.detected && det.type === 'sound') {
                if (e.state === 'patrol') { e.state = 'search'; e.lastKnownPos = player.position.clone(); e.stateTime = 0; }
            }
        }

        switch (e.state) {
            case 'patrol':
                const d2 = e.position.distanceTo(e.targetPos);
                if (d2 < 10 || e.stateTime > 8) pickNewTarget(e);
                break;
            case 'chase':
                if (distToPlayer >= 200) {
                    e.detection -= delta / ENEMY_LOST_TIME;
                    if (e.detection <= 0) { e.state = 'patrol'; e.detection = 0; e.lastKnownPos = null; e.stateTime = 0; }
                    else if (e.stateTime > ENEMY_LOST_TIME) { e.state = 'search'; e.stateTime = 0; }
                } else { e.detection = 1.0; e.stateTime = 0; }
                break;
            case 'search':
                if (distToPlayer < 200) { e.state = 'chase'; e.detection = 1.0; e.lastKnownPos = player.position.clone(); e.stateTime = 0; }
                else if (e.lastKnownPos) {
                    e.targetPos.copy(e.lastKnownPos);
                    if (e.position.distanceTo(e.lastKnownPos) < 15 || e.stateTime > 5) { e.state = 'patrol'; e.lastKnownPos = null; e.stateTime = 0; }
                } else { e.state = 'patrol'; e.stateTime = 0; }
                break;
        }

        let target;
        if (e.state === 'chase') {
            if (playerOnBuilding && stairTarget) {
                const distToStair = e.position.distanceTo(stairTarget);
                if (distToStair > 5) { target = stairTarget.clone(); e.useDirectMove = false; }
                else { target = player.position.clone(); e.useDirectMove = true; e.path = []; }
            } else { target = e.lastKnownPos || player.position; e.useDirectMove = false; }
        } else if (e.state === 'search') { target = e.lastKnownPos || e.targetPos; e.useDirectMove = false; }
        else { target = e.targetPos; e.useDirectMove = false; }

        if (e.state === 'chase' && distToPlayer < e.stopDistance) {
            enemyShoot(e);
            const dirToPlayer = new THREE.Vector3().subVectors(player.position, e.position);
            if (dirToPlayer.length() > 0.01) { e.rotationY = Math.atan2(dirToPlayer.x, dirToPlayer.z); e.group.rotation.y = e.rotationY; }
            e.group.position.copy(e.position);
            e.aabb = aabbFromEnemy(e);
            for (let i = 0; i < enemies.length; i++) {
                const a = enemies[i];
                if (!a.isAlive) continue;
                for (let j = i + 1; j < enemies.length; j++) {
                    const b = enemies[j];
                    if (!b.isAlive) continue;
                    const aabbA = aabbFromEnemy(a);
                    const aabbB = aabbFromEnemy(b);
                    if (aabbOverlap(aabbA, aabbB)) {
                        const overlapX = Math.min(aabbA.maxX, aabbB.maxX) - Math.max(aabbA.minX, aabbB.minX);
                        const overlapZ = Math.min(aabbA.maxZ, aabbB.maxZ) - Math.max(aabbA.minZ, aabbB.minZ);
                        if (overlapX < overlapZ) {
                            const sign = (a.position.x < b.position.x) ? -1 : 1;
                            const push = sign * overlapX / 2;
                            a.position.x += push;
                            b.position.x -= push;
                        } else {
                            const sign = (a.position.z < b.position.z) ? -1 : 1;
                            const push = sign * overlapZ / 2;
                            a.position.z += push;
                            b.position.z -= push;
                        }
                        resolveEnemyAABB(a);
                        resolveEnemyAABB(b);
                        a.aabb = aabbFromEnemy(a);
                        b.aabb = aabbFromEnemy(b);
                    }
                }
            }
            continue;
        }

        if (!e.useDirectMove && (e.state === 'chase' || e.state === 'search')) {
            if (now - e.lastPathUpdate > e.pathUpdateInterval * 1000 || !e.path || e.path.length === 0) {
                const path = calculateEnemyPath(e, target);
                if (path && path.length > 0) { e.path = path; e.pathIndex = 0; }
                else { e.path = []; }
                e.lastPathUpdate = now;
            }
        } else { e.path = []; }

        if (e.path && e.path.length > 0) {
            let waypoint = e.path[e.pathIndex];
            if (!waypoint) waypoint = { wx: target.x, wz: target.z };
            const dx = waypoint.wx - e.position.x;
            const dz = waypoint.wz - e.position.z;
            const distToWaypoint = Math.sqrt(dx * dx + dz * dz);
            if (distToWaypoint < 3) {
                e.pathIndex++;
                if (e.pathIndex >= e.path.length) {
                    const dirToTarget = new THREE.Vector3().subVectors(target, e.position);
                    if (dirToTarget.length() > 0.1) {
                        const speed = e.state === 'chase' ? e.runSpeed : e.speed;
                        const move = dirToTarget.normalize().multiplyScalar(speed * delta);
                        e.position.x += move.x;
                        e.position.z += move.z;
                    }
                    e.path = [];
                }
            } else {
                const dirToWaypoint = new THREE.Vector3(waypoint.wx - e.position.x, 0, waypoint.wz - e.position.z).normalize();
                const speed = e.state === 'chase' ? e.runSpeed : e.speed;
                const move = dirToWaypoint.clone().multiplyScalar(speed * delta);
                e.position.x += move.x;
                e.position.z += move.z;
            }
        } else {
            const dirToTarget = new THREE.Vector3().subVectors(target, e.position);
            if (dirToTarget.length() > 0.1) {
                const speed = e.state === 'chase' ? e.runSpeed : e.speed;
                const move = dirToTarget.normalize().multiplyScalar(speed * delta);
                e.position.x += move.x;
                e.position.z += move.z;
            }
        }

        resolveEnemyAABB(e);
        e.velY -= GRAVITY * delta;
        e.position.y += e.velY * delta;
        if (e.position.y - ENEMY_HEIGHT / 2 <= 0) { e.position.y = ENEMY_HEIGHT / 2; e.velY = 0; e.onGround = true; }
        else { e.onGround = false; }
        for (const obs of obstacles) {
            const oa = aabbFromObstacle(obs);
            const ea = aabbFromEnemy(e);
            if (aabbOverlap(ea, oa)) {
                if (e.position.y - ENEMY_HEIGHT / 2 <= obs.maxY + 0.5 &&
                    e.position.y - ENEMY_HEIGHT / 2 >= obs.maxY - 0.5 && e.velY <= 0) {
                    e.position.y = obs.maxY + ENEMY_HEIGHT / 2;
                    e.velY = 0;
                    e.onGround = true;
                } else {
                    const dx = Math.abs(e.position.x - obs.position.x);
                    const dz = Math.abs(e.position.z - obs.position.z);
                    const ox = ENEMY_RADIUS + obs.width / 2 - dx;
                    const oz = ENEMY_RADIUS + obs.depth / 2 - dz;
                    if (ox < oz) {
                        if (e.position.x < obs.position.x) e.position.x = obs.minX - ENEMY_RADIUS;
                        else e.position.x = obs.maxX + ENEMY_RADIUS;
                    } else {
                        if (e.position.z < obs.position.z) e.position.z = obs.minZ - ENEMY_RADIUS;
                        else e.position.z = obs.maxZ + ENEMY_RADIUS;
                    }
                }
            }
        }
        const m = 20;
        e.position.x = THREE.MathUtils.clamp(e.position.x, -HALF + m, HALF - m);
        e.position.z = THREE.MathUtils.clamp(e.position.z, -HALF + m, HALF - m);
        const dirToTarget3 = new THREE.Vector3().subVectors(target, e.position);
        if (dirToTarget3.length() > 0.01) { e.rotationY = Math.atan2(dirToTarget3.x, dirToTarget3.z); e.group.rotation.y = e.rotationY; }
        e.group.position.copy(e.position);
        const currentPos = e.position.clone();
        const sspeed = currentPos.distanceTo(e.prevPosition || currentPos) / (delta || 0.001);
        e.prevPosition = currentPos.clone();
        updateParticles(delta);
        updateEnemyAnimation(e, sspeed, delta);
        if (e.state === 'chase' && distToPlayer < 50) { enemyShoot(e); }
        e.aabb = aabbFromEnemy(e);
    }
    const alive = enemies.filter(e => e.isAlive).length;
    document.getElementById('enemyCount').textContent = alive;
}

            function updateEnemyAnimation(enemy, speed, delta) {
                if (!enemy.isAlive) return;
                const isMoving = speed > 0.5;
                if (isMoving) {
                    enemy.animationTime = (enemy.animationTime || 0) + delta * speed * 2;
                    const swing = Math.sin(enemy.animationTime) * 0.8;
                    if (enemy.leftArm) { enemy.leftArm.rotation.x = swing * 0.8; }
                    if (enemy.rightArm) { enemy.rightArm.rotation.x = -swing * 0.8; }
                    if (enemy.leftLeg) { enemy.leftLeg.rotation.x = -swing * 0.6; }
                    if (enemy.rightLeg) { enemy.rightLeg.rotation.x = swing * 0.6; }
                } else {
                    if (enemy.leftArm) enemy.leftArm.rotation.x = 0.5;
                    if (enemy.rightArm) enemy.rightArm.rotation.x = -0.5;
                    if (enemy.leftLeg) enemy.leftLeg.rotation.x = 0.1;
                    if (enemy.rightLeg) enemy.rightLeg.rotation.x = -0.1;
                }
            }

            function resolveEnemyAABB(e) {
                const ea = aabbFromEnemy(e);
                for (const obs of obstacles) {
                    const oa = aabbFromObstacle(obs);
                    if (aabbOverlap(ea, oa)) {
                        const dx = Math.abs(e.position.x - obs.position.x);
                        const dz = Math.abs(e.position.z - obs.position.z);
                        const ox = ENEMY_RADIUS + obs.width / 2 - dx;
                        const oz = ENEMY_RADIUS + obs.depth / 2 - dz;
                        if (ox < oz) {
                            if (e.position.x < obs.position.x) e.position.x = obs.minX - ENEMY_RADIUS;
                            else e.position.x = obs.maxX + ENEMY_RADIUS;
                        } else {
                            if (e.position.z < obs.position.z) e.position.z = obs.minZ - ENEMY_RADIUS;
                            else e.position.z = obs.maxZ + ENEMY_RADIUS;
                        }
                        const newEa = aabbFromEnemy(e);
                        if (aabbOverlap(newEa, oa)) {
                            if (e.position.x < obs.position.x) e.position.x = obs.minX - ENEMY_RADIUS;
                            else e.position.x = obs.maxX + ENEMY_RADIUS;
                            if (e.position.z < obs.position.z) e.position.z = obs.minZ - ENEMY_RADIUS;
                            else e.position.z = obs.maxZ + ENEMY_RADIUS;
                        }
                    }
                }
            }

            function detectPlayer(e) {
                const p = player.position;
                const dist = e.position.distanceTo(p);
                if (dist > ENEMY_VISION_RANGE) return { detected: false };
                const dir = new THREE.Vector3().subVectors(p, e.position).normalize();
                const fwd = new THREE.Vector3(0, 0, 1).applyAxisAngle(new THREE.Vector3(0, 1, 0), e.rotationY);
                const angle = fwd.angleTo(dir);
                if (angle < ENEMY_VISION_ANGLE / 2) {
                    if (!lineOfSightBlocked(e.position, p)) { return { detected: true, type: 'sight' }; }
                }
                if (dist < ENEMY_HEARING && isPlayerMoving()) { return { detected: true, type: 'sound' }; }
                return { detected: false };
            }

            function lineOfSightBlocked(start, end) {
                const dir = new THREE.Vector3().subVectors(end, start).normalize();
                const dist = start.distanceTo(end);
                for (const obs of obstacles) {
                    const oa = aabbFromObstacle(obs);
                    if (rayAABBIntersect(start, dir, oa)) {
                        const hitDist = getRayAABBDist(start, dir, oa);
                        if (hitDist < dist && hitDist > 0.5) return true;
                    }
                }
                return false;
            }

            function getRayAABBDist(origin, dir, aabb) {
                const inv = new THREE.Vector3(1 / dir.x, 1 / dir.y, 1 / dir.z);
                let t1 = (aabb.minX - origin.x) * inv.x;
                let t2 = (aabb.maxX - origin.x) * inv.x;
                let tmin = Math.min(t1, t2);
                let tmax = Math.max(t1, t2);
                t1 = (aabb.minY - origin.y) * inv.y;
                t2 = (aabb.maxY - origin.y) * inv.y;
                tmin = Math.max(tmin, Math.min(t1, t2));
                tmax = Math.min(tmax, Math.max(t1, t2));
                t1 = (aabb.minZ - origin.z) * inv.z;
                t2 = (aabb.maxZ - origin.z) * inv.z;
                tmin = Math.max(tmin, Math.min(t1, t2));
                tmax = Math.min(tmax, Math.max(t1, t2));
                if (tmax >= tmin && tmax > 0) return tmin > 0 ? tmin : tmax;
                return Infinity;
            }

            function isPlayerMoving() { return playerVel.length() > 2; }

            function notifyNearby(source, pos, range) {
                for (const e of enemies) {
                    if (!e.isAlive || e === source) continue;
                    if (e.position.distanceTo(pos) < range) { e.state = 'chase';
                        e.detection = 1.0;
                        e.lastKnownPos = pos.clone();
                        e.stateTime = 0; }
                }
            }

            function notifyGunshot(pos) {
                for (const e of enemies) {
                    if (!e.isAlive) continue;
                    if (e.position.distanceTo(pos) < 200) {
                        if (e.state !== 'chase') { e.state = 'chase';
                            e.detection = 1.0;
                            e.lastKnownPos = pos.clone();
                            e.stateTime = 0; }
                    }
                }
            }

            function pickNewTarget(e) {
                const angle = Math.random() * Math.PI * 2;
                const dist = 50 + Math.random() * ENEMY_PATROL_RADIUS;
                e.targetPos.x = e.patrolCenter.x + Math.cos(angle) * dist;
                e.targetPos.z = e.patrolCenter.z + Math.sin(angle) * dist;
                e.targetPos.y = ENEMY_HEIGHT / 2;
                e.targetPos.x = THREE.MathUtils.clamp(e.targetPos.x, -HALF + 40, HALF - 40);
                e.targetPos.z = THREE.MathUtils.clamp(e.targetPos.z, -HALF + 40, HALF - 40);
                e.stateTime = 0;
            }

            // ============================================================
            //  KILL & DROPS
            // ============================================================
function killEnemy(e) {
    e.isAlive = false;
    e.health = 0;
    waveKills++;
    kills++;
    e.healthSpr.visible = false;
    if (e.visionCone) e.visionCone.visible = false;
    addKillFeed();
    updateHUD();
    spawnPickup(e.position.clone());
createHitParticles(e.position.clone(), e.mainColor, 10, 7, 20.5);
    // 倒地动画
    const dir = new THREE.Vector3().subVectors(player.position, e.position);
    dir.y = 0;
    if (dir.length() > 0.01) {
        e.rotationY = Math.atan2(dir.x, dir.z);
        e.group.rotation.y = e.rotationY;
        e.group.rotation.x = -Math.PI / 2;
    } else {
        e.group.rotation.x = -Math.PI / 2;
    }
    e.group.position.y = ENEMY_HEIGHT / 2 - 0.2;
    setTimeout(() => {
        scene.remove(e.group);
    }, 300);

    // ---- 延迟3秒检测波次完成 ----
    if (!window._waveCheckPending) {
        window._waveCheckPending = true;
        setTimeout(() => {
            window._waveCheckPending = false;
            const alive = enemies.filter(en => en.isAlive).length;
            const total = waveTotalEnemies;
            const killed = total - alive;
            const ratio = total > 0 ? killed / total : 0;
            if (ratio >= 7 / 8 && !waveComplete) {
                waveComplete = true;
                if (currentWave < 3) {
                    showWaveTransition(currentWave + 1);
                } else {
                    gameVictory = true;
                    document.getElementById('gameOverTitle').textContent = '🏆 VICTORY!';
                    document.getElementById('gameOver').style.display = 'block';
                    document.exitPointerLock();
                }
            }
        }, 3000);
    }
}
            function spawnPickup(pos) {
                const rand = Math.random();
                let type, data = {};
                if (rand < 0.25) {
                    const locked = WEAPON_IDS.filter(id => !unlockedWeapons.includes(id));
                    if (locked.length > 0) {
                        const id = locked[Math.floor(Math.random() * locked.length)];
                        type = 'weapon';
                        data = { weaponId: id };
                    } else { type = 'ammo';
                        data = { amount: 20 }; }
                } else if (rand < 0.65) { type = 'health';
                    data = { amount: 30 }; } else if (rand < 1.0) {
                    type = 'ammo';
                    const def = getCurrentDef();
                    data = { amount: Math.ceil(def.maxAmmo * 0.5), weaponId: currentWeaponId };
                } else { return; }

                const pickupGroup = new THREE.Group();
                pickupGroup.position.copy(pos);
                pickupGroup.position.y += 0.5;
                let color, label;
                if (type === 'weapon') {
                    const def = WEAPON_DEFS[data.weaponId];
                    color = 0x00ff88;
                    const modelMap = {
                        lee_enfield: createLeeEnfield,
                        thompson: createThompson,
                        m1garand: createM1Garand,
                        stg44: createSTG44,
                        colt1911: createColt1911
                    };
                    const model = modelMap[data.weaponId]();
                    model.scale.set(1.5, 1.5, 2.0);
                    model.rotation.y = Math.random() * Math.PI * 2;
                    pickupGroup.add(model);
                    label = def.displayName;
                } else if (type === 'health') {
                    color = 0xff4444;
                    const cross = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.3, 0.3), new THREE
                    .MeshLambertMaterial({ color: 0xff4444 }));
                    const cross2 = new THREE.Mesh(new THREE.BoxGeometry(0.3, 1.2, 0.3), new THREE
                    .MeshLambertMaterial({ color: 0xff4444 }));
                    pickupGroup.add(cross);
                    pickupGroup.add(cross2);
                    const ring = new THREE.Mesh(new THREE.RingGeometry(0.8, 1.2, 16),
                        new THREE.MeshBasicMaterial({ color: 0xff4444, transparent: true, opacity: 0.4, side: THREE
                                .DoubleSide }));
                    ring.rotation.x = -Math.PI / 2;
                    ring.position.y = -0.2;
                    pickupGroup.add(ring);
                    label = 'Medkit +' + data.amount;
                } else if (type === 'ammo') {
                    color = 0xffdd44;
                    const box = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.5, 0.6),
                        new THREE.MeshLambertMaterial({ color: 0xccaa44 }));
                    pickupGroup.add(box);
                    const bullet = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, 0.3, 6),
                        new THREE.MeshLambertMaterial({ color: 0xffdd44 }));
                    bullet.rotation.x = Math.PI / 2;
                    bullet.position.set(0, 0.3, 0);
                    pickupGroup.add(bullet);
                    label = 'Ammo +' + data.amount;
                }
                const glowGeo = new THREE.SphereGeometry(0.6, 8, 8);
                const glowMat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.25 });
                const glow = new THREE.Mesh(glowGeo, glowMat);
                pickupGroup.add(glow);
                const light = new THREE.PointLight(color, 0.5, 8);
                light.position.set(0, 1, 0);
                pickupGroup.add(light);
                scene.add(pickupGroup);
                const pickup = {
                    id: pickupIdCounter++,
                    group: pickupGroup,
                    position: pos.clone(),
                    type: type,
                    data: data,
                    label: label,
                    color: color,
                    alive: true,
                    bobPhase: Math.random() * Math.PI * 2,
                };
                pickups.push(pickup);
                showPickupMsg('📦 Dropped: ' + label + ' (Press E)');
            }

            function updatePickups(delta) {
                const playerPos = player.position;
                const pickupDist = 8;
                let nearest = null;
                let nearestDist = pickupDist;
                for (const p of pickups) {
                    if (!p.alive) continue;
                    p.bobPhase += delta * 1.5;
                    p.group.position.y = p.position.y + 0.5 + Math.sin(p.bobPhase) * 0.3;
                    p.group.rotation.y += delta * 0.5;
                    if (p.group.children.length > 0) {
                        const glow = p.group.children.find(c => c.isMesh && c.material && c.material.opacity !== undefined &&
                            c.material.opacity < 0.5);
                        if (glow) { glow.material.opacity = 0.2 + Math.sin(p.bobPhase) * 0.1; }
                    }
                    const dist = playerPos.distanceTo(p.position);
                    if (dist < pickupDist) { if (dist < nearestDist) { nearest = p;
                            nearestDist = dist; } }
                }
                const prompt = document.getElementById('pickupPrompt');
                if (nearest) {
                    prompt.style.display = 'block';
                    prompt.textContent = 'Press E to pickup ' + nearest.label;
                    if (nearest.group.children.length > 0) {
                        const glow = nearest.group.children.find(c => c.isMesh && c.material && c.material.opacity !==
                            undefined && c.material.opacity < 0.5);
                        if (glow) { glow.material.opacity = 0.5; }
                    }
                } else { prompt.style.display = 'none'; }
                window._nearestPickup = nearest;
            }

            function doPickup() {
                const nearest = window._nearestPickup;
                if (!nearest || !nearest.alive) return;
                const p = nearest;
                p.alive = false;
                if (p.type === 'weapon') {
                    const id = p.data.weaponId;
                    if (!unlockedWeapons.includes(id)) {
                        unlockedWeapons.push(id);
                        const def = WEAPON_DEFS[id];
                        const state = weaponStates[id];
                        state.currentAmmo = def.maxAmmo;
                        state.totalAmmo = def.totalAmmo;
                        state.boltReady = true;
                        showPickupMsg('🎯 Unlocked: ' + def.displayName + '!');
                    } else {
                        const def = WEAPON_DEFS[id];
                        const state = weaponStates[id];
                        state.totalAmmo += def.maxAmmo * 2;
                        showPickupMsg('📦 ' + def.displayName + ' ammo');
                    }
                } else if (p.type === 'health') {
                    const heal = p.data.amount || 30;
                    playerHP = Math.min(maxHP, playerHP + 80);
                    updateHUD();
                    showPickupMsg('❤️ +' + heal + ' HP (' + Math.round(playerHP) + '/' + maxHP + ')');
                } else if (p.type === 'ammo') {
                    const amt = p.data.amount || 10;
                    if (p.data.weaponId) {
                        const state = weaponStates[p.data.weaponId];
                        state.totalAmmo += amt;
                        showPickupMsg('📦 +' + amt + ' ' + WEAPON_DEFS[p.data.weaponId].name + ' ammo');
                    } else { addAmmoForAll();
                        showPickupMsg('📦 All weapons ammo'); }
                    updateHUD();
                    updateWeaponUI();
                }
                scene.remove(p.group);
                const idx = pickups.indexOf(p);
                if (idx > -1) pickups.splice(idx, 1);
                document.getElementById('pickupPrompt').style.display = 'none';
                window._nearestPickup = null;
                createPickupEffect(p.position, p.color, p.label);
            }

            function addAmmoForAll() {
                for (const id of WEAPON_IDS) {
                    const def = WEAPON_DEFS[id];
                    const state = weaponStates[id];
                    state.totalAmmo += def.maxAmmo * 2;
                }
            }

            function createPickupEffect(pos, color, label) {
                const count = 40;
                const geo = new THREE.BufferGeometry();
                const positions = new Float32Array(count * 3);
                const colors = new Float32Array(count * 3);
                for (let i = 0; i < count; i++) {
                    positions[i * 3] = pos.x + (Math.random() - 0.5) * 4;
                    positions[i * 3 + 1] = pos.y + (Math.random() - 0.5) * 4 + 1;
                    positions[i * 3 + 2] = pos.z + (Math.random() - 0.5) * 4;
                    const c = new THREE.Color(color);
                    c.multiplyScalar(0.6 + Math.random() * 0.4);
                    colors[i * 3] = c.r;
                    colors[i * 3 + 1] = c.g;
                    colors[i * 3 + 2] = c.b;
                }
                geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
                geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
                const mat = new THREE.PointsMaterial({ size: 0.8, vertexColors: true, transparent: true, opacity: 0.9,
                    blending: THREE.AdditiveBlending });
                const points = new THREE.Points(geo, mat);
                scene.add(points);
                let age = 0;
                const life = 1.0;
                const upd = () => {
                    age += 0.016;
                    if (age < life) {
                        const posAttr = points.geometry.attributes.position;
                        const arr = posAttr.array;
                        for (let i = 0; i < arr.length; i += 3) {
                            arr[i] += (Math.random() - 0.5) * 0.2;
                            arr[i + 1] += (Math.random() - 0.5) * 0.2 + 0.1;
                            arr[i + 2] += (Math.random() - 0.5) * 0.2;
                        }
                        posAttr.needsUpdate = true;
                        points.material.opacity = 0.9 * (1 - age / life);
                        requestAnimationFrame(upd);
                    } else { scene.remove(points); }
                };
                upd();
            }

            function showPickupMsg(msg) {
                const el = document.getElementById('pickupMsg');
                el.textContent = msg;
                el.style.display = 'block';
                el.style.opacity = '1';
                clearTimeout(el._timeout);
                el._timeout = setTimeout(() => {
                    el.style.opacity = '0';
                    setTimeout(() => { el.style.display = 'none'; }, 300);
                }, 2500);
            }

            // ============================================================
            //  RELOAD & BOLT
            // ============================================================
            function startReload() {
                const def = getCurrentDef();
                const state = getCurrentState();
                if (state.isReloading || state.currentAmmo === def.maxAmmo || state.totalAmmo === 0) return;
                state.isReloading = true;
                state.reloadTimer = Date.now();
                document.getElementById('reloadIndicator').style.display = 'block';
                updateWeaponUI();
                setTimeout(() => {
                    if (!state.isReloading) return;
                    const need = def.maxAmmo - state.currentAmmo;
                    const avail = infAmmo ? 9999 : state.totalAmmo;
                    const toLoad = Math.min(need, avail);
                    if (!infAmmo) state.totalAmmo -= toLoad;
                    state.currentAmmo += toLoad;
                    if (def.needsBolt) state.boltReady = true;
                    state.isReloading = false;
                    document.getElementById('reloadIndicator').style.display = 'none';
                    updateWeaponUI();
                    updateHUD();
                }, def.reloadTime * 1000);
            }
let boltAnim = null; // { startTime, duration, weaponId }
function doBoltAction() {
    const def = getCurrentDef();
    const state = getCurrentState();
    // 只有在需要拉栓、未就绪、未换弹、未在动画中、未结束游戏时执行
    if (def.needsBolt && !state.boltReady && !state.isReloading && !gameOver && !boltAnim) {
        // 播放拉栓音效
        playReloadSound(0.08);
        // 启动动画
        boltAnim = {
            startTime: Date.now(),
            duration: 1000,
            weaponId: currentWeaponId
        };
        // 更新UI提示
        const boltEl = document.getElementById('boltIndicator');
        boltEl.textContent = '⏳ Bolting...';
        boltEl.style.display = 'block';
        // 状态未就绪(已在条件中确认)
        state.boltReady = false;
        updateWeaponUI();
    }
}

            function showBoltHint() {
                const def = getCurrentDef();
                if (def.needsBolt && !getCurrentState().boltReady) {
                    document.getElementById('boltIndicator').style.display = 'block';
                }
            }

            // ============================================================
            //  WEAPON SWITCH
            // ============================================================
            function switchWeapon(direction) {
                if (gameOver) return;
                const currentIdx = WEAPON_IDS.indexOf(currentWeaponId);
                let newIdx = (currentIdx + direction + WEAPON_IDS.length) % WEAPON_IDS.length;
                let tries = 0;
                while (!unlockedWeapons.includes(WEAPON_IDS[newIdx]) && tries < WEAPON_IDS.length) {
                    newIdx = (newIdx + direction + WEAPON_IDS.length) % WEAPON_IDS.length;
                    tries++;
                }
                if (!unlockedWeapons.includes(WEAPON_IDS[newIdx])) { showPickupMsg('🔒 Weapon not unlocked'); return; }
                const newId = WEAPON_IDS[newIdx];
                if (newId === currentWeaponId) return;
                const oldState = getCurrentState();
                if (oldState.isReloading) { oldState.isReloading = false;
                    document.getElementById('reloadIndicator').style.display = 'none'; }
                currentWeaponId = newId;
                const def = getCurrentDef();
                const state = getCurrentState();
                if (!infAmmo) {
                    const need = def.maxAmmo - state.currentAmmo;
                    const avail = state.totalAmmo;
                    const toLoad = Math.min(need, avail);
                    state.currentAmmo += toLoad;
                    state.totalAmmo -= toLoad;
                } else { state.currentAmmo = def.maxAmmo; }
                state.boltReady = true;
                state.isReloading = false;
                document.getElementById('reloadIndicator').style.display = 'none';
                document.getElementById('boltIndicator').style.display = 'none';
                for (const id of WEAPON_IDS) {
                    if (weaponModels[id]) weaponModels[id].visible = (id === currentWeaponId);
                }
                updateHUD();
                updateWeaponUI();
                showPickupMsg('🔫 Switched to ' + def.displayName);
            }

            function onWheel(e) {
                e.preventDefault();
                if (!isLocked) return;
                const direction = e.deltaY > 0 ? 1 : -1;
                switchWeapon(direction);
            }

            // ============================================================
            //  UI HELPERS
            // ============================================================
// ============================================================
//  RADAR SYSTEM
// ============================================================
const radarCanvas = document.getElementById('radarCanvas');
const radarCtx = radarCanvas.getContext('2d');
const RADAR_SIZE = 540; // 与 CSS 尺寸一致
radarCanvas.width = RADAR_SIZE;
radarCanvas.height = RADAR_SIZE;
const RADAR_CENTER = RADAR_SIZE / 2;
const RADAR_MAX_RADIUS = RADAR_SIZE / 2 - 6;
const RADAR_MAX_DIST = 250; // 雷达显示的最大距离(单位:游戏世界单位)
const RADAR_ROTATION = 180; // 顺时针旋转角度(度),0=前方在顶部,90=右侧,180=底部,270=左侧
function updateRadar() {
    radarCtx.clearRect(0, 0, RADAR_SIZE, RADAR_SIZE);
    const cx = RADAR_CENTER;
    const cy = RADAR_CENTER;
    const radius = RADAR_MAX_RADIUS;

    // ---- 绘制背景圆 ----
    radarCtx.beginPath();
    radarCtx.arc(cx, cy, radius, 0, Math.PI * 2);
    radarCtx.fillStyle = 'rgba(0,0,0,0.6)';
    radarCtx.fill();
    radarCtx.strokeStyle = 'rgba(255,255,255,0.3)';
    radarCtx.lineWidth = 1.5;
    radarCtx.stroke();

    // ---- 绘制距离环(50, 150, 250 单位) ----
    const rings = [50, 150, 250];
    for (const r of rings) {
        const ringRadius = (r / RADAR_MAX_DIST) * radius;
        radarCtx.beginPath();
        radarCtx.arc(cx, cy, ringRadius, 0, Math.PI * 2);
        radarCtx.strokeStyle = 'rgba(255,255,255,0.15)';
        radarCtx.lineWidth = 1;
        radarCtx.stroke();
    }

    // ---- 绘制方向十字线 ----
    radarCtx.beginPath();
    radarCtx.moveTo(cx, cy - radius);
    radarCtx.lineTo(cx, cy + radius);
    radarCtx.moveTo(cx - radius, cy);
    radarCtx.lineTo(cx + radius, cy);
    radarCtx.strokeStyle = 'rgba(255,255,255,0.1)';
    radarCtx.lineWidth = 1;
    radarCtx.stroke();

    // ---- 玩家朝向箭头(上方) ----
    const arrowLen = 12;
    const arrowBase = radius - 4;
    radarCtx.beginPath();
    radarCtx.moveTo(cx, cy - arrowBase);
    radarCtx.lineTo(cx - 8, cy - arrowBase + arrowLen);
    radarCtx.lineTo(cx + 8, cy - arrowBase + arrowLen);
    radarCtx.closePath();
    radarCtx.fillStyle = 'rgba(100,255,100,0.8)';
    radarCtx.fill();
    // 玩家中心点
    radarCtx.beginPath();
    radarCtx.arc(cx, cy, 34, 0, Math.PI * 2);
    radarCtx.fillStyle = '#ffffff';
    radarCtx.fill();

    // ---- 绘制敌人 ----
    const px = player.position.x;
    const pz = player.position.z;
    const angle = -mouseX; // 玩家朝向角(弧度),看向 -Z 方向
// ---- 绘制敌人 ----
for (const e of enemies) {
    if (!e.isAlive) continue;
    const dx = e.position.x - px;
    const dz = e.position.z - pz;
    const dist = Math.sqrt(dx * dx + dz * dz);
    if (dist > RADAR_MAX_DIST || dist < 0.5) continue;

    const cosA = Math.cos(angle);
    const sinA = Math.sin(angle);
    const localX = dx * cosA + dz * sinA;
    const localZ = -dx * sinA + dz * cosA;

    const norm = Math.sqrt(localX * localX + localZ * localZ);
    if (norm < 0.01) continue;
    const ratio = Math.min(dist / RADAR_MAX_DIST, 1) * (radius - 5) / norm;

    // 基础位置(无旋转,前方在顶部)
    const baseX = cx + localX * ratio;
    const baseY = cy - localZ * ratio;

    // 应用整体旋转
    const rad = RADAR_ROTATION * Math.PI / 180;
    const dx2 = baseX - cx;
    const dy2 = baseY - cy;
    const cosR = Math.cos(rad);
    const sinR = Math.sin(rad);
    const screenX = cx - dx2 * cosR + dy2 * sinR;
    const screenY = cy - dx2 * sinR + dy2 * cosR;

    // 绘制红点
    radarCtx.beginPath();
    radarCtx.arc(screenX, screenY, 14 + 2 * (1 - Math.min(dist / RADAR_MAX_DIST, 1)), 0, Math.PI * 2);
    radarCtx.fillStyle = '#ff4444';
    radarCtx.shadowColor = '#ff4444';
    radarCtx.shadowBlur = 12;
    radarCtx.fill();
    radarCtx.shadowBlur = 0;
}

}
            function updateHUD() {
                const hpPct = Math.max(0, Math.round((playerHP / maxHP) * 100));
                const hpEl = document.getElementById('hpText');
                hpEl.textContent = hpPct;
                hpEl.className = 'hp-fill' + (hpPct < 30 ? ' low' : '');
                const def = getCurrentDef();
                const state = getCurrentState();
                document.getElementById('weaponName').textContent = `${def.icon} ${def.displayName}`;
                document.getElementById('ammoText').textContent = `${state.currentAmmo} / ${infAmmo ? '∞' : state.totalAmmo}`;
                const alive = enemies.filter(e => e.isAlive).length;
                document.getElementById('enemyCount').textContent = alive;
                document.getElementById('waveNum').textContent = currentWave + 1;
                document.getElementById('waveNum2').textContent = currentWave + 1;
            }

            function updateWeaponUI() {
                const def = getCurrentDef();
                const state = getCurrentState();
                document.getElementById('weaponName').textContent = `${def.icon} ${def.displayName}`;
                document.getElementById('ammoText').textContent = `${state.currentAmmo} / ${infAmmo ? '∞' : state.totalAmmo}`;
                if (state.isReloading) { document.getElementById('reloadIndicator').style.display = 'block'; } else { document
                        .getElementById('reloadIndicator').style.display = 'none'; }
                if (def.needsBolt && !state.boltReady && state.currentAmmo > 0) { document.getElementById('boltIndicator')
                        .style.display = 'block'; } else { document.getElementById('boltIndicator').style.display = 'none'; }
            }

            function showHitMarker() {
                const el = document.getElementById('hitMarker');
                el.classList.remove('active');
                void el.offsetWidth;
                el.classList.add('active');
            }

            function showDamage() {
                const el = document.getElementById('damageOverlay');
                el.classList.add('show');
                setTimeout(() => el.classList.remove('show'), 150);
            }

            function addKillFeed() {
                const feed = document.getElementById('killFeed');
                const entry = document.createElement('div');
                entry.className = 'entry';
                entry.textContent = '💀 Enemy eliminated';
                feed.prepend(entry);
                setTimeout(() => { if (entry.parentNode) entry.remove(); }, 3000);
                if (feed.children.length > 6) feed.lastChild.remove();
            }

            function createHitEffect(pos, color) {
                const count = 25;
                const geo = new THREE.BufferGeometry();
                const positions = new Float32Array(count * 3);
                const colors = new Float32Array(count * 3);
                for (let i = 0; i < count; i++) {
                    positions[i * 3] = pos.x + (Math.random() - 0.5) * 2;
                    positions[i * 3 + 1] = pos.y + (Math.random() - 0.5) * 2;
                    positions[i * 3 + 2] = pos.z + (Math.random() - 0.5) * 2;
                    const c = new THREE.Color(color);
                    c.multiplyScalar(0.6 + Math.random() * 0.4);
                    colors[i * 3] = c.r;
                    colors[i * 3 + 1] = c.g;
                    colors[i * 3 + 2] = c.b;
                }
                geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
                geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
                const mat = new THREE.PointsMaterial({ size: 0.6, vertexColors: true, transparent: true, opacity: 0.9,
                    blending: THREE.AdditiveBlending });
                const points = new THREE.Points(geo, mat);
                scene.add(points);
                let age = 0;
                const life = 0.8;
                const upd = () => {
                    age += 0.016;
                    if (age < life) {
                        const posAttr = points.geometry.attributes.position;
                        const arr = posAttr.array;
                        for (let i = 0; i < arr.length; i += 3) {
                            arr[i] += (Math.random() - 0.5) * 0.3;
                            arr[i + 1] += (Math.random() - 0.5) * 0.3 + 0.05;
                            arr[i + 2] += (Math.random() - 0.5) * 0.3;
                        }
                        posAttr.needsUpdate = true;
                        points.material.opacity = 0.9 * (1 - age / life);
                        requestAnimationFrame(upd);
                    } else { scene.remove(points); }
                };
                upd();
            }

            // ============================================================
            //  WAVE TRANSITION
            // ============================================================
            function showWaveTransition(nextWaveIdx) {
                const config = WAVE_CONFIG[nextWaveIdx];
                const el = document.getElementById('waveTransition');
                document.getElementById('waveTitle').textContent = '🌊 Wave ' + (nextWaveIdx + 1);
                document.getElementById('waveDesc').textContent = config.desc;
                document.getElementById('waveSub').textContent = 'Kill 7/8 to advance';
                el.style.display = 'block';
                setTimeout(() => {
                    el.style.display = 'none';
                    startWave(nextWaveIdx);
                }, 2500);
            }

            function startWave(waveIdx) {
                currentWave = waveIdx;
                waveComplete = false;
                waveKills = 0;
                // Clear old enemies, pickups, bullets
                for (const e of enemies) { scene.remove(e.group); }
                enemies = [];
                for (const p of pickups) { scene.remove(p.group); }
                pickups = [];
                for (const b of bullets) { scene.remove(b.mesh); if (b.trail) scene.remove(b.trail); }
                bullets = [];
                // Clear obstacles and terrain meshes
                for (const obs of obstacles) {
                    if (obs.mesh) scene.remove(obs.mesh);
                }
                obstacles = [];
                for (const t of treeMeshes) { scene.remove(t); }
                treeMeshes = [];
                trees = [];
                grid = [];
                // Reset player position
                player.position.set(0, PLAYER_HEIGHT / 2, 0);
                playerVel.set(0, 0, 0);
                mouseX = 0;
                mouseY = 0;
                canJump = true;
                isJumping = false;
                onGround = false;
                // Build terrain
                buildTerrain(waveIdx);
                // Spawn enemies
                spawnEnemiesForWave(WAVE_CONFIG[waveIdx].enemyCount);
                // Update HUD
                updateHUD();
                updateWeaponUI();
                // Reset weapon states for ammo balance
                for (const id of WEAPON_IDS) {
                    const def = WEAPON_DEFS[id];
                    const state = weaponStates[id];
                    if (unlockedWeapons.includes(id)) {
                        state.currentAmmo = def.maxAmmo;
                        state.totalAmmo = def.totalAmmo;
                        state.boltReady = true;
                        state.isReloading = false;
                    }
                }
                document.getElementById('reloadIndicator').style.display = 'none';
                document.getElementById('boltIndicator').style.display = 'none';
                // Show wave message
// 清理粒子
for (const p of particles) {
    scene.remove(p.mesh);
    p.mesh.geometry.dispose();
    p.mesh.material.dispose();
}
particles = [];
                showPickupMsg('🌊 Wave ' + (waveIdx + 1) + ' started!');
                gameStart = Date.now();
            }

            // ============================================================
            //  GAME OVER / RESTART
            // ============================================================
            function endGame() {
                if (gameOver) return;
                gameOver = true;
                document.exitPointerLock();
                const surv = Math.floor((Date.now() - gameStart) / 1000);
                const acc = shotsFired > 0 ? Math.round((shotsHit / shotsFired) * 100) : 0;
                document.getElementById('survivedTime').textContent = surv;
                document.getElementById('killsCount').textContent = kills;
                document.getElementById('finalAcc').textContent = acc + '%';
                if (!gameVictory) {
                    document.getElementById('gameOverTitle').textContent = '💀 GAME OVER';
                }
                document.getElementById('gameOver').style.display = 'block';
            }

           function restart() {
    gameOver = false;
    gameVictory = false;
    document.getElementById('gameOver').style.display = 'none';
    // 恢复血量
    playerHP = maxHP;
    // 重置玩家位置到原点
    player.position.set(0, PLAYER_HEIGHT / 2, 0);
    playerVel.set(0, 0, 0);
    // 重置视角
    mouseX = 0;
    mouseY = 0;
    // 重置跳跃状态
    canJump = true;
    isJumping = false;
    onGround = false;
    // 更新HUD
    updateHUD();
    // 重新锁定鼠标?
    // 不需要,因为游戏结束已经退出锁定了,但用户需要点击屏幕重新锁定?当前逻辑:游戏结束界面出现,点击restart按钮后,需要重新锁定鼠标?通常点击restart后,应自动锁定鼠标(如果浏览器允许)或让用户点击屏幕锁定。我们可以在restart后调用 renderer.domElement.requestPointerLock()? 但可能被浏览器阻止,所以最好让用户点击屏幕。
    // 但为了体验,我们可以尝试请求锁定。
    // 更安全:不自动锁定,用户点击屏幕后会锁定。
    // 可以保留。
    // 另外,需要重置游戏开始时间?可能不需要。
    // 还要确保敌人AI继续运行,因为gameOver为false了。
    // 显示复活提示。
    showPickupMsg('💪 已复活!');
}
            // ============================================================
            //  EVENTS
            // ============================================================
            function onKey(e) {
                switch (e.code) {
                    case 'KeyW':
                        moveF = true;
                        break;
                    case 'KeyA':
                        moveL = true;
                        break;
                    case 'KeyS':
                        moveB = true;
                        break;
                    case 'KeyD':
                        moveR = true;
                        break;
                    case 'ShiftLeft':
                    case 'ShiftRight':
                        isShift = true;
                        break;
                    case 'Space':
                        e.preventDefault();
                        if (canJump && !gameOver) {
                            const force = 20;
                            playerVel.y = force;
                            canJump = false;
                            isJumping = true;
                            onGround = false;
                        }
                        break;
                    case 'KeyR':
                        if (!gameOver) startReload();
                        break;
                    case 'KeyG':
                        if (!gameOver) doBoltAction();
                        break;
                    case 'KeyE':
                        if (!gameOver) doPickup();
                        break;
                    case 'Escape':
                        document.exitPointerLock();
                        break;
                }
            }

            function onKeyUp(e) {
                switch (e.code) {
                    case 'KeyW':
                        moveF = false;
                        break;
                    case 'KeyA':
                        moveL = false;
                        break;
                    case 'KeyS':
                        moveB = false;
                        break;
                    case 'KeyD':
                        moveR = false;
                        break;
                    case 'ShiftLeft':
                    case 'ShiftRight':
                        isShift = false;
                        break;
                }
            }
// 新函数:基于鼠标位置(相对于屏幕中心)控制视角
function onMouseMove(e) {
    // 计算鼠标在屏幕上的位置偏移(相对于中心)
    const centerX = window.innerWidth / 2;
    const centerY = window.innerHeight / 2;
    const deltaX = (e.clientX - centerX) * MOUSE_SENS;
    const deltaY = (e.clientY - centerY) * MOUSE_SENS;
    
    // 累加到视角(注意方向)
    mouseX += deltaX;
    mouseY += -deltaY;   // 注意负号,因为 Y 轴向上
    mouseY = THREE.MathUtils.clamp(mouseY, -Math.PI / 2, Math.PI / 2);
}

            function onMouseDown(e) {
                if (e.button !== 0) return;
                if (!isLocked) { // 先让画布获得焦点(某些浏览器需要)
renderer.domElement.focus();
document.body.requestPointerLock(); }
                if (gameOver) return;
                mouseDown = true;
                const state = getCurrentState();
                if (state.isReloading) return;
                if (state.currentAmmo <= 0) { if (state.totalAmmo > 0) startReload(); return; }
                shoot();
            }

            function onMouseUp(e) { if (e.button === 0) mouseDown = false; }

            function onResize() {
                camera.aspect = window.innerWidth / window.innerHeight;
                camera.updateProjectionMatrix();
                renderer.setSize(window.innerWidth, window.innerHeight);
            }

            // ============================================================
            //  INIT
            // ============================================================
            function init() {
                scene = new THREE.Scene();
                camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 10000);
                camera.position.set(0, 12, 0);
                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;
                renderer.shadowMap.bias = 0.0001;
                document.getElementById('gameContainer').appendChild(renderer.domElement);

                const ambient = new THREE.AmbientLight(0xffffff, 0.5);
                scene.add(ambient);
                const hemi = new THREE.HemisphereLight(0x87CEEB, 0x3a7c3a, 0.6);
                scene.add(hemi);
                const dirLight = new THREE.DirectionalLight(0xffffff, 1.0);
                dirLight.position.set(80, 150, 60);
                dirLight.castShadow = true;
                dirLight.shadow.camera.left = -200;
                dirLight.shadow.camera.right = 200;
                dirLight.shadow.camera.top = 200;
                dirLight.shadow.camera.bottom = -200;
                dirLight.shadow.mapSize.width = 4096;
                dirLight.shadow.mapSize.height = 4096;
                scene.add(dirLight);
                const fillLight = new THREE.DirectionalLight(0xffeedd, 0.3);
                fillLight.position.set(-60, 80, -40);
                scene.add(fillLight);

                createPlayer();

                // Weapon display
                weaponDisplay = new THREE.Group();
                scene.add(weaponDisplay);
                const modelMap = {
                    lee_enfield: createLeeEnfield,
                    thompson: createThompson,
                    m1garand: createM1Garand,
                    stg44: createSTG44,
                    colt1911: createColt1911
                };
                for (const id of WEAPON_IDS) {
                    const model = modelMap[id]();
    // 添加手部
    const handMat = new THREE.MeshLambertMaterial({ color: 0xd4a574 });

                    model.rotation.y = Math.PI / 2;
 
    const skinMat = new THREE.MeshLambertMaterial({ color: 0xd4a574 });
const sleeveMat = new THREE.MeshLambertMaterial({ color: 0x2d5a27 }); // 深军绿

    // ===== 左手(X↔Z 互换) =====
    // 原来: (-2.2, -0.2, 0.6) → 现在: (0.6, -0.2, -2.2)
    const leftHand = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.8, 0.8), skinMat);
    leftHand.position.set(0.6, -0.2, -1.2);
    leftHand.castShadow = true;
    model.add(leftHand);

    // ===== 右手(对称互换) =====
    // 原来: (2.2, -0.2, 0.6) → 现在: (-0.6, -0.2, 2.2)
    const rightHand = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.8, 0.8), skinMat);
    rightHand.position.set(-0.6, -0.2, 1.2);
    rightHand.castShadow = true;
    model.add(rightHand);

    // ===== 左衣袖(跟随左手互换) =====
    // 原来: (-2.5, -0.6, 0.6) → 现在: (0.6, -0.6, -2.5)
    const leftSleeve = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.5, 12, 6), sleeveMat);
    leftSleeve.position.set(-5.6, -0.6, -1.5);
    leftSleeve.rotation.z = Math.PI / 2;
    leftSleeve.castShadow = true;
    model.add(leftSleeve);

    // ===== 右衣袖(跟随右手互换) =====
    // 原来: (2.5, -0.6, 0.6) → 现在: (-0.6, -0.6, 2.5)
    const rightSleeve = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.5, 12, 6), sleeveMat);
    rightSleeve.position.set(-5.6, -0.6, 0.5);
    rightSleeve.rotation.z = Math.PI / 2;
    rightSleeve.castShadow = true;
    model.add(rightSleeve);
                    model.scale.set(2.3, 2.3, 3.3);
                    model.visible = false;
                    weaponDisplay.add(model);
    weaponHands[id] = {
        leftHand,
        rightHand,
        leftSleeve,
        rightSleeve,
        leftHandInitPos: leftHand.position.clone(),   // (0.6, -0.2, -2.2)
        leftSleeveInitPos: leftSleeve.position.clone() // (0.6, -0.6, -2.5)
    };
                    weaponModels[id] = model;
                }
                weaponModels.lee_enfield.visible = true;

                // Start wave 1
                buildTerrain(0);
                spawnEnemiesForWave(WAVE_CONFIG[0].enemyCount);

                // Events
                document.addEventListener('keydown', onKey);
                document.addEventListener('keyup', onKeyUp);
                document.addEventListener('mousemove', onMouseMove);
                document.addEventListener('mousedown', onMouseDown);
                document.addEventListener('mouseup', onMouseUp);
                document.addEventListener('wheel', onWheel, { passive: false });
                window.addEventListener('resize', onResize);
document.getElementById('lockOverlay').addEventListener('click', () => {
    if (!isLocked) {
        renderer.domElement.focus();   // 确保画布焦点
        document.body.requestPointerLock();
    }
});     document.addEventListener('pointerlockchange', () => {
                    isLocked = !!document.pointerLockElement;
                    document.getElementById('lockOverlay').style.display = isLocked ? 'none' : 'block';
                    if (!isLocked) mouseDown = false;
                });
                document.getElementById('restartBtn').addEventListener('click', restart);

                document.getElementById('loading').classList.add('hidden');
                gameStart = Date.now();
                updateHUD();
                updateWeaponUI();
                showPickupMsg('🌊 Wave 1: Jungle');
                animate();
            }

            // ============================================================
            //  ANIMATION LOOP
            // ============================================================
let weaponHands = {};
let lastFootstepTime = 0;
let footstepInterval = 0;

function playFootstepSound(volume = 0.08, pitch = 120) {
    try {
        const ctx = getAudioContext();
        const now = ctx.currentTime;
        console.log(1);
        // 噪声发生器(模拟踩踏)
        const bufferSize = ctx.sampleRate * 0.06; // 60ms
        const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
        const data = buffer.getChannelData(0);
        for (let i = 0; i < bufferSize; i++) {
            const env = Math.exp(-i / (bufferSize * 0.3));
            data[i] = (Math.random() * 2 - 1) * env * 0.8;
        }
        const noise = ctx.createBufferSource();
        noise.buffer = buffer;
        
        const gain = ctx.createGain();
        gain.gain.setValueAtTime(volume, now);
        gain.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
        
        // 低通滤波让声音变闷(更像脚步声)
        const filter = ctx.createBiquadFilter();
        filter.type = 'lowpass';
        filter.frequency.value = 300 + Math.random() * 200;
        
        // 增加一个低频振荡器加强“重踏”感
        const osc = ctx.createOscillator();
        osc.type = 'sine';
        osc.frequency.setValueAtTime(pitch, now);
        osc.frequency.exponentialRampToValueAtTime(pitch * 0.5, now + 0.04);
        const oscGain = ctx.createGain();
        oscGain.gain.setValueAtTime(volume * 0.6, now);
        oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.04);
        
        noise.connect(filter);
        filter.connect(gain);
        gain.connect(ctx.destination);
        
        osc.connect(oscGain);
        oscGain.connect(ctx.destination);
        
        noise.start(now);
        noise.stop(now + 0.06);
        osc.start(now);
        osc.stop(now + 0.04);
    } catch (e) {}
}
let prevPlayerPos = new THREE.Vector3();
            function animate() {
                requestAnimationFrame(animate);
                const delta = clock.getDelta();

                if (isLocked && !gameOver) {
                    updatePlayer(delta);
// ---- 脚步声 ----
if (isLocked && !gameOver && onGround) {
    // 计算水平速度(通过位置变化)
    const pos = player.position;
    const dx = pos.x - prevPlayerPos.x;
    const dz = pos.z - prevPlayerPos.z;
    const speed = Math.sqrt(dx*dx + dz*dz) / (delta || 0.016); // 单位/秒
    
    // 更新上一帧位置
    prevPlayerPos.copy(pos);
    
    const isMoving = speed > 0.5;
    const isRunning = speed > 8;
    
    if (isMoving) {
        const baseInterval = isRunning ? 0.28 : 0.45;
        const jitter = 0.8 + Math.random() * 0.4;
        const interval = baseInterval * jitter;
        const now = Date.now();
        if (now - lastFootstepTime > interval * 1000) {
            const vol = isRunning ? 0.12 : 0.08;
            const pitch = isRunning ? 160 : 120;
            playFootstepSound(vol * 3, pitch);
            lastFootstepTime = now;
        }
    } else {
        lastFootstepTime = 0; // 停止后重置计时器
    }
}                    updatePickups(delta);
                    if (weaponDisplay) {
                        weaponDisplay.quaternion.copy(camera.quaternion);
                        const right = new THREE.Vector3(2, 0, 0).applyQuaternion(camera.quaternion);
                        const up = new THREE.Vector3(0, 10, 0).applyQuaternion(camera.quaternion);
                        const forward = new THREE.Vector3(0, 0, -42).applyQuaternion(camera.quaternion);
                        const offset = right.clone().multiplyScalar(1.5).add(up.clone().multiplyScalar(-0.3)).add(forward
                            .clone().multiplyScalar(0.2));
                        weaponDisplay.position.copy(camera.position).add(offset);
                    }
                    updateEnemies(delta);
// ---- 拉栓动画更新 ----
if (boltAnim) {
    const now = Date.now();
    const elapsed = now - boltAnim.startTime;
    const progress = Math.min(elapsed / boltAnim.duration, 1);
    let angle = 0;
    if (progress < 0.5) {
        const t = progress / 0.5;
        angle = -90 * t * Math.PI / 180;
    } else {
        const t = (progress - 0.5) / 0.5;
        angle = -90 * (1 - t) * Math.PI / 180;
    }
    const model = weaponModels[currentWeaponId];
    if (model) {
        const baseAngle = Math.PI / 2;
        const currentAngle = -angle + baseAngle;
        model.rotation.y = currentAngle;

        const hands = weaponHands[currentWeaponId];
        if (hands) {
            const deltaAngle = currentAngle - baseAngle;
            const quat = new THREE.Quaternion().setFromAxisAngle(
                new THREE.Vector3(0, 1, 0),
                -deltaAngle
            );

            // ---- 计算 Z 轴后退偏移 ----
            let zOffset = 0;
            const maxOffset = -0.5; // 后退距离,可调整
            if (progress < 0.5) {
                const t = progress / 0.5;
                zOffset = maxOffset * t; // 0 -> maxOffset
            } else {
                const t = (progress - 0.5) / 0.5;
                zOffset = maxOffset * (1 - t); // maxOffset -> 0
            }

            // 左手:位置补偿 + Z偏移
            if (hands.leftHand) {
                const pos = hands.leftHandInitPos.clone();
                pos.z += zOffset;
                hands.leftHand.position.copy(pos.applyQuaternion(quat));
                hands.leftHand.rotation.y = -deltaAngle;
            }
            // 左衣袖:同样处理
            if (hands.leftSleeve) {
                const pos = hands.leftSleeveInitPos.clone();
                pos.z += zOffset;
                hands.leftSleeve.position.copy(pos.applyQuaternion(quat));
                hands.leftSleeve.rotation.y = -deltaAngle;
            }
        }
    }
    // 动画完成
    if (progress >= 1) {
        if (model) model.rotation.y = Math.PI / 2;
        const hands = weaponHands[currentWeaponId];
        if (hands) {
            if (hands.leftHand) {
                hands.leftHand.position.copy(hands.leftHandInitPos);
                hands.leftHand.rotation.y = 0;
            }
            if (hands.leftSleeve) {
                hands.leftSleeve.position.copy(hands.leftSleeveInitPos);
                hands.leftSleeve.rotation.y = 0;
            }
        }
        const state = getCurrentState();
        state.boltReady = true;
        document.getElementById('boltIndicator').style.display = 'none';
        boltAnim = null;
        updateWeaponUI();
    }
}  
  }
// 更新雷达
if (isLocked && !gameOver) {
    updateRadar();
}
                renderer.render(scene, camera);
            }

            // ============================================================
            //  START
            // ============================================================
            init();
//password:12348888
            window.__debug = {
                enemies,
                player,
                weaponStates,
                unlockedWeapons,
                currentWeaponId,
                WEAPON_DEFS,
                switchWeapon,
                pickups,
                doPickup,
                currentWave,
                waveComplete,
                waveKills,
                waveTotalEnemies
            };

        })();
    </script>

</body>
</html>

Game Source: Burma Expedition · 4 Waves

Creator: UltraNinja80

Libraries: three

Complexity: complex (4745 lines, 197.2 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: burma-expedition-4-waves-ultraninja80-mth9eila" to link back to the original. Then publish at arcadelab.ai/publish.