🎮ArcadeLab

僵尸生存模拟【PLA英雄 + 支援警察 + 僵尸全图追杀 + 边境刷新】

by EpicCoder88
1509 lines53.8 KB
▶ Play
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <title>僵尸生存模拟【PLA英雄 + 支援警察 + 僵尸全图追杀 + 边境刷新】</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            background:#222;
            display:flex;
            flex-direction:column;
            align-items:center;
            justify-content:center;
            min-height:100vh;
            overflow:hidden;
        }
        canvas {
            background:#ffffff;
            display:block;
            max-width:100vw;
            max-height:100vh;
            touch-action:none;
        }
    </style>
</head>
<body>
<canvas id="game" width="900" height="620"></canvas>
<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');

function resizeCanvas() {
    const ratio = canvas.width / canvas.height;
    const winW = window.innerWidth;
    const winH = window.innerHeight;
    let w = winW, h = winW / ratio;
    if(h > winH){ h = winH; w = winH * ratio; }
    canvas.style.width = w + 'px';
    canvas.style.height = h + 'px';
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);

// ========== 参数配置 ==========
const WORLD_WIDTH = 1800;
const WORLD_HEIGHT = 1400;
const MAX_HP = 10;

const ZOMBIE_SPEED = 1.3;
const ZOMBIE_DETECT_RANGE = 160;
const RUNNER_SPEED = 2.8;
const RUNNER_DETECT_RANGE = 200;
const RUNNER_COLOR = "#c02020";

const HUMAN_VISION_RANGE = 140;
const POLICE_VISION_RANGE = 180;
const ATTACK_RANGE = 28;
const DAMAGE_ZOMBIE = 1;
const BULLET_DAMAGE = 2;
const BULLET_SPEED = 6;
const SHOOT_COOLDOWN = 500;

const MAG_SIZE = 8;
const RELOAD_TIME = 1500;
const POLICE_RETREAT_RANGE = 60;
const POLICE_RETREAT_SPEED = 0.8;
const GUN_LENGTH = 10;

const HELP_TEXT_DURATION = 1000;
const HELP_FADE_DURATION = 500;
const HELP_TRIGGER_RANGE = 160;
const HELP_RESPONSE_RANGE = 700;
const POLICE_MOVE_TO_HELP_SPEED = 2.5;

const HUMAN_NORMAL_SPEED = 1.6;
const HUMAN_TIRED_SPEED = 0.9;
const HUMAN_POLICE_BOOST_SPEED = 2.6;
const HUMAN_BOOST_VISION_RANGE = 200;

const FATIGUE_TRIGGER_TIME = 180;
const REST_RECOVER_TIME = 120;
const RADIUS = 9;
const COLLISION_RADIUS = 10;
const SELECT_RADIUS = 20;

const BASE_HIT_CHANCE = 0.3;
const MAX_HIT_CHANCE = 0.85;
const CHANCE_PER_KILL = 0.07;

const SLOW_DURATION = 800;
const SLOW_FACTOR = 0.45;

const DAMAGE_TEXT_LIFETIME = 1000;

const ZOMBIE_ATTACK_INTERVAL = 800;
const RUNNER_ATTACK_INTERVAL = 500;

const SHOTGUN_MAG_SIZE = 3;
const SHOTGUN_RELOAD_TIME = 1500;
const SHOTGUN_SHOOT_COOLDOWN = 1000;
const SHOTGUN_PELLET_COUNT = 5;
const SHOTGUN_SPREAD_ANGLE = Math.PI / 3;
const SHOTGUN_MAX_RANGE = 150;

// ===== PLA 配置 =====
const PLA_MAG_SIZE = 15;
const PLA_RELOAD_TIME = 500;
const PLA_SHOOT_COOLDOWN = 100;
const PLA_VISION_RANGE = 220;
const PLA_DAMAGE_PER_BUFF = 12;
const PLA_BUFF_DURATION = 30000;
const PLA_MAX_DAMAGE_BONUS = 60;
const PLA_CHARGE_CHANCE = 0.30;
const PLA_RETREAT_CHANCE = 0.30;
const PLA_KNOCKBACK_DISTANCE = 60;
const PLA_RETREAT_DISTANCE = 180;
const GRENADE_CHANCE = 0.10;
const GRENADE_DAMAGE = 5;
const GRENADE_RADIUS = 60;
const GRENADE_WINDUP = 500;

// ===== 僵尸边境刷新配置 =====
const ZOMBIE_SPAWN_INITIAL_INTERVAL = 5000;
const ZOMBIE_SPAWN_MIN_INTERVAL = 1500;
const ZOMBIE_SPAWN_ACCELERATION = 0.95;
const ZOMBIE_SPAWN_BATCH_MIN = 1;
const ZOMBIE_SPAWN_BATCH_MAX = 2;
const ZOMBIE_MAX_ON_MAP = 60;
const ZOMBIE_SPAWN_RUNNER_CHANCE = 0.20;

let gameSpeed = 1;

// ========== 浮动伤害文字类 ==========
class DamageText {
    constructor(x, y, text, isInvalid, color) {
        this.x = x;
        this.y = y;
        this.startY = y;
        this.text = text;
        this.isInvalid = isInvalid;
        this.color = color || "#ff2222";
        this.born = performance.now();
        this.life = DAMAGE_TEXT_LIFETIME;
    }
    update() {
        const age = performance.now() - this.born;
        const progress = age / this.life;
        this.y = this.startY - progress * 35;
        return progress < 1;
    }
    draw() {
        const age = performance.now() - this.born;
        const alpha = 1 - age / this.life;
        const sx = this.x - camera.x;
        const sy = this.y - camera.y;
        ctx.save();
        ctx.globalAlpha = alpha;
        ctx.font = "bold 16px Arial";
        ctx.textAlign = "center";
        ctx.fillStyle = this.isInvalid ? "#888888" : this.color;
        ctx.strokeStyle = "#ffffff";
        ctx.lineWidth = 3;
        ctx.strokeText(this.text, sx, sy);
        ctx.fillText(this.text, sx, sy);
        ctx.restore();
    }
}
const damageTexts = [];

// ========== 手榴弹爆炸视觉 ==========
class GrenadeExplosion {
    constructor(x, y) {
        this.x = x;
        this.y = y;
        this.born = performance.now();
        this.life = 400;
    }
    isAlive() {
        return performance.now() - this.born < this.life;
    }
    draw() {
        const age = performance.now() - this.born;
        const progress = age / this.life;
        const sx = this.x - camera.x;
        const sy = this.y - camera.y;
        const radius = GRENADE_RADIUS * Math.min(1, progress * 2);
        const alpha = 1 - progress;

        ctx.save();
        ctx.globalAlpha = alpha * 0.5;
        ctx.beginPath();
        ctx.arc(sx, sy, radius, 0, Math.PI * 2);
        ctx.fillStyle = "#ff6600";
        ctx.fill();

        ctx.globalAlpha = alpha;
        ctx.beginPath();
        ctx.arc(sx, sy, radius * 0.7, 0, Math.PI * 2);
        ctx.fillStyle = "#ffdd44";
        ctx.fill();

        ctx.beginPath();
        ctx.arc(sx, sy, radius, 0, Math.PI * 2);
        ctx.strokeStyle = "#ff2200";
        ctx.lineWidth = 3;
        ctx.stroke();
        ctx.restore();
    }
}
const grenadeExplosions = [];

// ========== 选中单位 & 视角跟随 ==========
let selectedUnit = null;
let cameraFollow = false;

document.addEventListener('keydown', (e) => {
    if(e.key === '1') gameSpeed = 1;
    if(e.key === '2') gameSpeed = 2;
    if(e.key === '3') gameSpeed = 3;
    if(e.key === '4') gameSpeed = 4;
    if(e.key === 'Escape') { selectedUnit = null; cameraFollow = false; }
});

// ========== 底部加速按钮 ==========
const speedButtons = [
    { label: '1×', speed: 1, x: 0, y: 0, w: 70, h: 52 },
    { label: '2×', speed: 2, x: 0, y: 0, w: 70, h: 52 },
    { label: '3×', speed: 3, x: 0, y: 0, w: 70, h: 52 },
    { label: '4×', speed: 4, x: 0, y: 0, w: 70, h: 52 },
];

function layoutSpeedButtons() {
    const totalW = speedButtons.length * speedButtons[0].w + (speedButtons.length - 1) * 10;
    const startX = (canvas.width - totalW) / 2;
    const btnY = canvas.height - 65;
    for(let i = 0; i < speedButtons.length; i++){
        speedButtons[i].x = startX + i * (speedButtons[i].w + 10);
        speedButtons[i].y = btnY;
    }
}
layoutSpeedButtons();

function getCanvasPos(clientX, clientY) {
    const rect = canvas.getBoundingClientRect();
    return {
        x: (clientX - rect.left) * (canvas.width / rect.width),
        y: (clientY - rect.top) * (canvas.height / rect.height)
    };
}

function isOnSpeedButton(pos) {
    for(const btn of speedButtons){
        if(pos.x >= btn.x && pos.x <= btn.x + btn.w &&
           pos.y >= btn.y && pos.y <= btn.y + btn.h){
            return btn;
        }
    }
    return null;
}

function findUnitAtScreenPos(sx, sy) {
    const wx = sx + camera.x;
    const wy = sy + camera.y;
    let nearest = null;
    let minDist = SELECT_RADIUS;
    for(const u of units){
        if(u.hp <= 0) continue;
        const d = Math.hypot(u.x - wx, u.y - wy);
        if(d < minDist){
            minDist = d;
            nearest = u;
        }
    }
    return nearest;
}

// ========== 相机系统 ==========
const camera = {
    x: WORLD_WIDTH / 2 - canvas.width / 2,
    y: WORLD_HEIGHT / 2 - canvas.height / 2,
    dragging: false,
    lastX: 0,
    lastY: 0,
    dragMoved: false,
    downX: 0,
    downY: 0
};

function handlePointerDown(clientX, clientY) {
    const pos = getCanvasPos(clientX, clientY);
    const btn = isOnSpeedButton(pos);
    if(btn){ gameSpeed = btn.speed; return; }

    camera.dragging = true;
    camera.dragMoved = false;
    camera.lastX = clientX;
    camera.lastY = clientY;
    camera.downX = pos.x;
    camera.downY = pos.y;
    cameraFollow = false;
}

function handlePointerMove(clientX, clientY) {
    if(camera.dragging){
        const dx = clientX - camera.lastX;
        const dy = clientY - camera.lastY;
        if(Math.abs(dx) > 2 || Math.abs(dy) > 2) camera.dragMoved = true;
        camera.x -= dx;
        camera.y -= dy;
        camera.lastX = clientX;
        camera.lastY = clientY;
    }
}

function handlePointerUp(clientX, clientY) {
    if(!camera.dragging) return;
    camera.dragging = false;

    if(!camera.dragMoved){
        const pos = getCanvasPos(clientX, clientY);
        const u = findUnitAtScreenPos(pos.x, pos.y);
        if(u){
            selectedUnit = u;
            cameraFollow = true;
        }else{
            selectedUnit = null;
            cameraFollow = false;
        }
    }
}

canvas.addEventListener('mousedown', (e) => handlePointerDown(e.clientX, e.clientY));
canvas.addEventListener('mousemove', (e) => handlePointerMove(e.clientX, e.clientY));
canvas.addEventListener('mouseup', (e) => handlePointerUp(e.clientX, e.clientY));
canvas.addEventListener('mouseleave', () => { camera.dragging = false; });

canvas.addEventListener('touchstart', (e) => {
    e.preventDefault();
    const t = e.touches[0];
    handlePointerDown(t.clientX, t.clientY);
}, {passive:false});
canvas.addEventListener('touchmove', (e) => {
    e.preventDefault();
    const t = e.touches[0];
    handlePointerMove(t.clientX, t.clientY);
}, {passive:false});
canvas.addEventListener('touchend', (e) => {
    e.preventDefault();
    const t = e.changedTouches[0];
    handlePointerUp(t.clientX, t.clientY);
}, {passive:false});

// ========== 子弹类 ==========
class Bullet {
    constructor(x, y, tx, ty, shooter, maxRange) {
        this.x = x;
        this.y = y;
        this.startX = x;
        this.startY = y;
        this.maxRange = (maxRange !== undefined) ? maxRange : 99999;

        const dx = tx - x;
        const dy = ty - y;
        const dist = Math.hypot(dx, dy);
        if(dist < 0.001){
            this.vx = 0;
            this.vy = 0;
        }else{
            this.vx = (dx/dist)*BULLET_SPEED;
            this.vy = (dy/dist)*BULLET_SPEED;
        }
        this.alive = true;
        this.radius = 3;
        this.shooter = shooter;
    }
    update(){
        this.x += this.vx;
        this.y += this.vy;
        const traveled = Math.hypot(this.x - this.startX, this.y - this.startY);
        if (traveled > this.maxRange) this.alive = false;
        if(this.x < -100 || this.x > WORLD_WIDTH + 100 || this.y < -100 || this.y > WORLD_HEIGHT + 100){
            this.alive = false;
        }
    }
    draw(){
        ctx.beginPath();
        ctx.arc(this.x - camera.x, this.y - camera.y, this.radius, 0, Math.PI * 2);
        ctx.fillStyle = this.shooter && this.shooter.type === "pla" ? "#ffcc00" : "#222222";
        ctx.fill();
    }
}

// ========== 单位类 ==========
class Unit {
    constructor(x, y, type) {
        this.x = x;
        this.y = y;
        this.hp = MAX_HP;
        this.type = type;
        this.target = null;

        this.wanderVx = (Math.random() - 0.5) * 0.8;
        this.wanderVy = (Math.random() - 0.5) * 0.8;
        this.wanderTimer = 0;
        this.wanderInterval = 60 + Math.random() * 120;

        this.runningTimer = 0;
        this.isTired = false;
        this.seePolice = false;

        this.lastShootTime = 0;
        this.aimAngle = 0;
        this.bulletsInMag = (type === "swat") ? SHOTGUN_MAG_SIZE :
                            (type === "pla") ? PLA_MAG_SIZE : MAG_SIZE;
        this.isReloading = false;
        this.reloadStart = 0;
        this.isCryingForHelp = false;
        this.helpStartTime = -9999;

        this.killCount = 0;
        this.slowUntil = 0;
        this.lastAttackTime = 0;

        this.plaDamageBonusTimes = [];
        this.isThrowingGrenade = false;
        this.grenadeStartTime = 0;
        this.grenadeTargetX = 0;
        this.grenadeTargetY = 0;
    }

    distanceTo(other) {
        const dx = this.x - other.x;
        const dy = this.y - other.y;
        return Math.sqrt(dx * dx + dy * dy);
    }

    getMuzzlePos() {
        let gunLen = GUN_LENGTH;
        if (this.type === "swat") gunLen = 24;
        if (this.type === "pla") gunLen = 20;
        return {
            x: this.x + Math.cos(this.aimAngle) * (RADIUS + gunLen),
            y: this.y + Math.sin(this.aimAngle) * (RADIUS + gunLen)
        };
    }

    isZombieType() {
        return this.type === "zombie" || this.type === "runner";
    }

    isFriendly() {
        return this.type === "civilian" || this.type === "police" ||
               this.type === "swat" || this.type === "pla";
    }

    getZombieSpeed() {
        let base = this.type === "runner" ? RUNNER_SPEED : ZOMBIE_SPEED;
        if(performance.now() < this.slowUntil){
            base *= SLOW_FACTOR;
        }
        return base;
    }
    getZombieDetectRange() {
        return this.type === "runner" ? RUNNER_DETECT_RANGE : ZOMBIE_DETECT_RANGE;
    }

    getTypeName() {
        switch(this.type){
            case "zombie": return "僵尸";
            case "runner": return "跑尸";
            case "civilian": return "普通人";
            case "police": return "警察";
            case "swat": return "支援警察";
            case "pla": return "PLA 战士";
            default: return "未知";
        }
    }

    getEffectiveDamageChance(){
        let p = BASE_HIT_CHANCE + this.killCount * CHANCE_PER_KILL;
        return Math.min(p, MAX_HIT_CHANCE);
    }

    getPlaDamage() {
        const stacks = this.plaDamageBonusTimes.length;
        return BULLET_DAMAGE + Math.min(stacks * PLA_DAMAGE_PER_BUFF, PLA_MAX_DAMAGE_BONUS);
    }

    getCurrentSpeed() {
        if(this.type === "zombie" || this.type === "runner"){
            return this.target ? this.getZombieSpeed() : 0.4 + Math.random()*0.4;
        } else if(this.type === "civilian"){
            if(this.isTired) return HUMAN_TIRED_SPEED;
            if(this.seePolice) return HUMAN_POLICE_BOOST_SPEED;
            return HUMAN_NORMAL_SPEED;
        } else if(this.type === "police" || this.type === "swat" || this.type === "pla"){
            return POLICE_MOVE_TO_HELP_SPEED;
        }
        return 0;
    }

    findNearestZombie(allUnits, range) {
        let nearest = null;
        let minDist = range;
        for (const u of allUnits) {
            if (u.isZombieType() && u.hp > 0) {
                const d = this.distanceTo(u);
                if (d < minDist) {
                    minDist = d;
                    nearest = u;
                }
            }
        }
        return nearest;
    }

    // 【僵尸全图锁定】不再有视野范围限制,直接找最近的人类
    findHumanTarget(allUnits) {
        let nearest = null;
        let minDist = Infinity;
        for (const u of allUnits) {
            if (!u.isZombieType() && u.hp > 0) {
                const d = this.distanceTo(u);
                if (d < minDist) {
                    minDist = d;
                    nearest = u;
                }
            }
        }
        this.target = nearest;
    }

    findNearestPolice(allUnits, range) {
        let nearest = null;
        let minDist = range;
        for (const u of allUnits) {
            if ((u.type === "police" || u.type === "swat" || u.type === "pla") && u.hp > 0) {
                const d = this.distanceTo(u);
                if (d < minDist) {
                    minDist = d;
                    nearest = u;
                }
            }
        }
        return nearest;
    }

    findHelpingPolice(allUnits) {
        let nearest = null;
        let minDist = HELP_RESPONSE_RANGE;
        for (const u of allUnits) {
            if (u === this) continue;
            if ((u.type === "police" || u.type === "swat" || u.type === "pla") && u.hp > 0 && u.isCryingForHelp) {
                const d = this.distanceTo(u);
                if (d < minDist) {
                    minDist = d;
                    nearest = u;
                }
            }
        }
        return nearest;
    }

    onPlaDamaged(attacker, allUnits) {
        const roll = Math.random();
        if (roll < PLA_CHARGE_CHANCE) {
            const dx = attacker.x - this.x;
            const dy = attacker.y - this.y;
            const dist = Math.hypot(dx, dy) || 1;
            this.x += (dx/dist) * 20;
            this.y += (dy/dist) * 20;
            attacker.x += (dx/dist) * PLA_KNOCKBACK_DISTANCE;
            attacker.y += (dy/dist) * PLA_KNOCKBACK_DISTANCE;
            attacker.x = Math.max(RADIUS + 15, Math.min(WORLD_WIDTH - RADIUS - 15, attacker.x));
            attacker.y = Math.max(RADIUS + 15, Math.min(WORLD_HEIGHT - RADIUS - 15, attacker.y));
            damageTexts.push(new DamageText(this.x, this.y - RADIUS - 8, "冲锋!", false, "#ffaa00"));
        } else if (roll < PLA_CHARGE_CHANCE + PLA_RETREAT_CHANCE) {
            const dx = this.x - attacker.x;
            const dy = this.y - attacker.y;
            const dist = Math.hypot(dx, dy) || 1;
            this.x += (dx/dist) * PLA_RETREAT_DISTANCE;
            this.y += (dy/dist) * PLA_RETREAT_DISTANCE;
            this.x = Math.max(RADIUS + 15, Math.min(WORLD_WIDTH - RADIUS - 15, this.x));
            this.y = Math.max(RADIUS + 15, Math.min(WORLD_HEIGHT - RADIUS - 15, this.y));
            damageTexts.push(new DamageText(this.x, this.y - RADIUS - 8, "后撤!", false, "#4488ff"));
        }
    }

    static onCivilianDamaged(allUnits) {
        for (const u of allUnits) {
            if (u.type === "pla" && u.hp > 0) {
                u.plaDamageBonusTimes.push(performance.now() + PLA_BUFF_DURATION);
            }
        }
    }

    hasFriendInArea(allUnits, x, y, radius) {
        for (const u of allUnits) {
            if (u.hp <= 0) continue;
            if (!u.isFriendly()) continue;
            const d = Math.hypot(u.x - x, u.y - y);
            if (d < radius) return true;
        }
        return false;
    }

    tryShoot(target, bulletList, currentTime, allUnits) {
        if (this.isThrowingGrenade) return false;
        if (this.isReloading) return false;
        if (this.bulletsInMag <= 0) return false;

        const cooldown = this.type === "pla" ? PLA_SHOOT_COOLDOWN :
                         this.type === "swat" ? SHOTGUN_SHOOT_COOLDOWN : SHOOT_COOLDOWN;
        if (currentTime - this.lastShootTime < cooldown) return false;

        const muzzle = this.getMuzzlePos();

        if (this.type === "swat") {
            for (let i = 0; i < SHOTGUN_PELLET_COUNT; i++) {
                const rel = -SHOTGUN_SPREAD_ANGLE/2 + i * (SHOTGUN_SPREAD_ANGLE/(SHOTGUN_PELLET_COUNT-1));
                const angle = this.aimAngle + rel;
                const pellet = new Bullet(muzzle.x, muzzle.y, muzzle.x + 100, muzzle.y + 100, this, SHOTGUN_MAX_RANGE);
                pellet.vx = BULLET_SPEED * Math.cos(angle);
                pellet.vy = BULLET_SPEED * Math.sin(angle);
                pellet.radius = 2;
                bulletList.push(pellet);
            }
        } else {
            bulletList.push(new Bullet(muzzle.x, muzzle.y, target.x, target.y, this));
        }

        this.bulletsInMag--;
        this.lastShootTime = currentTime;

        if (this.bulletsInMag <= 0) {
            this.isReloading = true;
            this.reloadStart = currentTime;
        }

        if (this.type === "pla" && !this.isThrowingGrenade) {
            if (Math.random() < GRENADE_CHANCE) {
                if (!this.hasFriendInArea(allUnits, target.x, target.y, GRENADE_RADIUS)) {
                    this.isThrowingGrenade = true;
                    this.grenadeStartTime = currentTime;
                    this.grenadeTargetX = target.x;
                    this.grenadeTargetY = target.y;
                }
            }
        }
        return true;
    }

    update(allUnits, bulletList, currentTime) {
        if (this.type === "pla") {
            this.plaDamageBonusTimes = this.plaDamageBonusTimes.filter(t => t > currentTime);
        }

        if (this.hp <= 0) {
            if (!this.isZombieType()) {
                this.type = "zombie";
                this.hp = MAX_HP;
                this.runningTimer = 0;
                this.isTired = false;
                this.target = null;
                this.isCryingForHelp = false;
                this.seePolice = false;
                this.bulletsInMag = MAG_SIZE;
                this.isReloading = false;
                this.helpStartTime = -9999;
                this.slowUntil = 0;
                this.lastAttackTime = 0;
                this.isThrowingGrenade = false;
                this.plaDamageBonusTimes = [];
            }
            return;
        }

        if (this.isZombieType()) {
            this.findHumanTarget(allUnits);
            if (this.target) {
                const dx = this.target.x - this.x;
                const dy = this.target.y - this.y;
                const dist = Math.hypot(dx, dy);
                if (dist > ATTACK_RANGE) {
                    const spd = this.getZombieSpeed();
                    this.x += (dx / dist) * spd;
                    this.y += (dy / dist) * spd;
                } else {
                    const attackInterval = this.type === "runner" ? RUNNER_ATTACK_INTERVAL : ZOMBIE_ATTACK_INTERVAL;
                    if (currentTime - this.lastAttackTime >= attackInterval) {
                        this.target.hp -= DAMAGE_ZOMBIE;
                        damageTexts.push(new DamageText(this.target.x, this.target.y - RADIUS, `-${DAMAGE_ZOMBIE}`, false));
                        this.lastAttackTime = currentTime;

                        if (this.target.type === "pla") {
                            this.target.onPlaDamaged(this, allUnits);
                        }
                        if (this.target.type === "civilian") {
                            Unit.onCivilianDamaged(allUnits);
                        }
                    }
                }
            } else {
                this.wanderTimer++;
                if (this.wanderTimer > this.wanderInterval) {
                    this.wanderVx = (Math.random() - 0.5) * 0.8;
                    this.wanderVy = (Math.random() - 0.5) * 0.8;
                    this.wanderTimer = 0;
                    this.wanderInterval = 60 + Math.random() * 120;
                }
                this.x += this.wanderVx;
                this.y += this.wanderVy;
            }
        }
        else if (this.type === "civilian") {
            const dangerZombie = this.findNearestZombie(allUnits, HUMAN_VISION_RANGE);
            if (dangerZombie) {
                const nearbyPolice = this.findNearestPolice(allUnits, HUMAN_BOOST_VISION_RANGE);
                this.seePolice = !!nearbyPolice;

                this.runningTimer++;
                if (this.runningTimer >= FATIGUE_TRIGGER_TIME) {
                    this.isTired = true;
                }

                let speed = this.isTired ? HUMAN_TIRED_SPEED :
                            this.seePolice ? HUMAN_POLICE_BOOST_SPEED : HUMAN_NORMAL_SPEED;

                const dx = this.x - dangerZombie.x;
                const dy = this.y - dangerZombie.y;
                const dist = Math.hypot(dx, dy);
                this.x += (dx / dist) * speed;
                this.y += (dy / dist) * speed;
            } else {
                this.seePolice = false;
                this.runningTimer = Math.max(0, this.runningTimer - 1);
                if (this.runningTimer === 0) {
                    this.isTired = false;
                }
                this.wanderTimer++;
                if (this.wanderTimer > this.wanderInterval) {
                    this.wanderVx = (Math.random() - 0.5) * 0.7;
                    this.wanderVy = (Math.random() - 0.5) * 0.7;
                    this.wanderTimer = 0;
                    this.wanderInterval = 70 + Math.random() * 130;
                }
                this.x += this.wanderVx;
                this.y += this.wanderVy;
            }
        }
        else if (this.type === "police" || this.type === "swat" || this.type === "pla") {
            const reloadTime = this.type === "pla" ? PLA_RELOAD_TIME :
                               this.type === "swat" ? SHOTGUN_RELOAD_TIME : RELOAD_TIME;
            const magSize = this.type === "pla" ? PLA_MAG_SIZE :
                            this.type === "swat" ? SHOTGUN_MAG_SIZE : MAG_SIZE;
            const visionRange = this.type === "pla" ? PLA_VISION_RANGE : POLICE_VISION_RANGE;

            if (this.isReloading) {
                if (currentTime - this.reloadStart >= reloadTime) {
                    this.bulletsInMag = magSize;
                    this.isReloading = false;
                    this.lastShootTime = currentTime;
                }
            }

            if (this.type === "pla" && this.isThrowingGrenade) {
                if (currentTime - this.grenadeStartTime >= GRENADE_WINDUP) {
                    const gx = this.grenadeTargetX;
                    const gy = this.grenadeTargetY;
                    grenadeExplosions.push(new GrenadeExplosion(gx, gy));

                    for (const u of allUnits) {
                        if (u.isZombieType() && u.hp > 0) {
                            const d = Math.hypot(u.x - gx, u.y - gy);
                            if (d < GRENADE_RADIUS) {
                                u.hp -= GRENADE_DAMAGE;
                                damageTexts.push(new DamageText(u.x, u.y - RADIUS, `-${GRENADE_DAMAGE}`, false, "#ff8800"));
                                if (u.hp <= 0) this.killCount += 1;
                            }
                        }
                    }
                    this.isThrowingGrenade = false;
                }
                const edgeBuffer = 15;
                this.x = Math.max(RADIUS + edgeBuffer, Math.min(WORLD_WIDTH - RADIUS - edgeBuffer, this.x));
                this.y = Math.max(RADIUS + edgeBuffer, Math.min(WORLD_HEIGHT - RADIUS - edgeBuffer, this.y));
                return;
            }

            const targetZombie = this.findNearestZombie(allUnits, visionRange);

            if (this.isReloading && targetZombie) {
                const distToZombie = this.distanceTo(targetZombie);
                if (distToZombie < HELP_TRIGGER_RANGE) {
                    this.isCryingForHelp = true;
                    this.helpStartTime = currentTime;
                }
            }
            if (this.isCryingForHelp) {
                if (currentTime - this.helpStartTime > HELP_TEXT_DURATION + HELP_FADE_DURATION) {
                    this.isCryingForHelp = false;
                }
            }

            const helpingPolice = this.findHelpingPolice(allUnits);
            if (helpingPolice && !this.isReloading) {
                const dx = helpingPolice.x - this.x;
                const dy = helpingPolice.y - this.y;
                const dist = Math.hypot(dx, dy);
                if (dist > 40) {
                    this.x += (dx / dist) * POLICE_MOVE_TO_HELP_SPEED;
                    this.y += (dy / dist) * POLICE_MOVE_TO_HELP_SPEED;
                }
                if (targetZombie) {
                    this.aimAngle = Math.atan2(targetZombie.y - this.y, targetZombie.x - this.x);
                    this.tryShoot(targetZombie, bulletList, currentTime, allUnits);
                }
            }
            else if (targetZombie) {
                this.aimAngle = Math.atan2(targetZombie.y - this.y, targetZombie.x - this.x);
                const distToZombie = this.distanceTo(targetZombie);

                if (this.type === "police" && distToZombie < POLICE_RETREAT_RANGE) {
                    const dx = this.x - targetZombie.x;
                    const dy = this.y - targetZombie.y;
                    const dist = Math.hypot(dx, dy);
                    this.x += (dx/dist)*POLICE_RETREAT_SPEED;
                    this.y += (dy/dist)*POLICE_RETREAT_SPEED;
                } else if (this.type === "swat" && distToZombie < 20) {
                    const dx = this.x - targetZombie.x;
                    const dy = this.y - targetZombie.y;
                    const dist = Math.hypot(dx, dy);
                    this.x += (dx/dist)*POLICE_RETREAT_SPEED;
                    this.y += (dy/dist)*POLICE_RETREAT_SPEED;
                }

                this.tryShoot(targetZombie, bulletList, currentTime, allUnits);
            }
            else {
                this.wanderTimer++;
                if (this.wanderTimer > this.wanderInterval) {
                    this.wanderVx = (Math.random() - 0.5) * 0.6;
                    this.wanderVy = (Math.random() - 0.5) * 0.6;
                    this.wanderTimer = 0;
                    this.wanderInterval = 80 + Math.random() * 140;
                    this.aimAngle = Math.random()*Math.PI*2;
                }
                this.x += this.wanderVx;
                this.y += this.wanderVy;
            }
        }

        const edgeBuffer = 15;
        this.x = Math.max(RADIUS + edgeBuffer, Math.min(WORLD_WIDTH - RADIUS - edgeBuffer, this.x));
        this.y = Math.max(RADIUS + edgeBuffer, Math.min(WORLD_HEIGHT - RADIUS - edgeBuffer, this.y));
    }

    drawPlaRifle(sx, sy, aimAngle) {
        ctx.save();
        ctx.translate(sx, sy);
        ctx.rotate(aimAngle);

        ctx.fillStyle = "#1a1a1a";
        ctx.fillRect(RADIUS + 6, 6, 6, 12);
        ctx.strokeStyle = "#000";
        ctx.lineWidth = 1;
        ctx.strokeRect(RADIUS + 6, 6, 6, 12);

        ctx.fillStyle = "#1a1a1a";
        ctx.fillRect(RADIUS, -4, 22, 8);
        ctx.strokeStyle = "#000";
        ctx.strokeRect(RADIUS, -4, 22, 8);

        ctx.fillStyle = "#2a2a2a";
        ctx.fillRect(RADIUS + 4, -10, 14, 6);
        ctx.strokeRect(RADIUS + 4, -10, 14, 6);

        ctx.fillStyle = "#ffffff";
        ctx.fillRect(RADIUS + 7, -9, 8, 4);
        ctx.fillStyle = "#1a1a1a";

        ctx.fillStyle = "#000";
        ctx.fillRect(RADIUS + 20, -2, 4, 4);

        ctx.restore();
    }

    draw() {
        if (this.hp <= 0) return;
        const sx = this.x - camera.x;
        const sy = this.y - camera.y;

        // 【已删除】僵尸和跑尸的视野圈绘制
        if (this.type === "civilian") {
            if (this.seePolice && !this.isTired) {
                ctx.beginPath();
                ctx.arc(sx, sy, HUMAN_BOOST_VISION_RANGE, 0, Math.PI * 2);
                ctx.strokeStyle = "rgba(255, 140, 0, 0.45)";
                ctx.lineWidth = 2;
                ctx.setLineDash([6, 4]);
                ctx.stroke();
                ctx.setLineDash([]);
            }
            ctx.beginPath();
            ctx.arc(sx, sy, HUMAN_VISION_RANGE, 0, Math.PI * 2);
            ctx.strokeStyle = "rgba(80, 160, 255, 0.3)";
            ctx.lineWidth = 1;
            ctx.stroke();
        } else if (this.type === "police" || this.type === "swat") {
            ctx.beginPath();
            ctx.arc(sx, sy, POLICE_VISION_RANGE, 0, Math.PI * 2);
            ctx.strokeStyle = "rgba(60, 220, 255, 0.35)";
            ctx.lineWidth = 1;
            ctx.stroke();
        } else if (this.type === "pla") {
            ctx.beginPath();
            ctx.arc(sx, sy, PLA_VISION_RANGE, 0, Math.PI * 2);
            ctx.strokeStyle = "rgba(212, 175, 55, 0.45)";
            ctx.lineWidth = 1;
            ctx.stroke();
        }

        if (selectedUnit === this) {
            ctx.beginPath();
            ctx.arc(sx, sy, RADIUS + 8, 0, Math.PI * 2);
            ctx.strokeStyle = "#ffaa00";
            ctx.lineWidth = 3;
            ctx.stroke();

            if (this.type === "police" || this.type === "swat" || this.type === "pla") {
                ctx.beginPath();
                ctx.moveTo(sx, sy);
                ctx.lineTo(sx + Math.cos(this.aimAngle) * 35, sy + Math.sin(this.aimAngle) * 35);
                ctx.strokeStyle = "rgba(255, 170, 0, 0.7)";
                ctx.lineWidth = 2;
                ctx.stroke();
            }
        }

        ctx.beginPath();
        ctx.arc(sx, sy, RADIUS, 0, Math.PI * 2);
        if (this.type === "zombie") {
            ctx.fillStyle = "#00c020";
        } else if (this.type === "runner") {
            ctx.fillStyle = RUNNER_COLOR;
        } else if (this.type === "civilian") {
            if (this.isTired) ctx.fillStyle = "#ccb880";
            else if (this.seePolice) ctx.fillStyle = "#ffcc44";
            else ctx.fillStyle = "#f7e4a5";
        } else if (this.type === "police") {
            ctx.fillStyle = "#0088ff";
        } else if (this.type === "swat") {
            ctx.fillStyle = "#3a86ff";
        } else if (this.type === "pla") {
            ctx.fillStyle = "#4a5d23";
        }
        ctx.fill();

        if (this.type === "pla") {
            ctx.strokeStyle = "#d4af37";
            ctx.lineWidth = 2.5;
        } else {
            ctx.strokeStyle = "#222222";
            ctx.lineWidth = 1;
        }
        ctx.stroke();

        if (this.type === "pla") {
            ctx.font = "bold 9px Arial";
            ctx.textAlign = "center";
            ctx.textBaseline = "middle";
            ctx.fillStyle = "#ffd700";
            ctx.fillText("PLA", sx, sy);
            ctx.textBaseline = "alphabetic";
        }

        if (this.type === "runner") {
            ctx.strokeStyle = "#ff6060";
            ctx.lineWidth = 2;
            ctx.beginPath();
            ctx.moveTo(sx - 6, sy - 3);
            ctx.lineTo(sx - 12, sy - 3);
            ctx.moveTo(sx - 6, sy + 3);
            ctx.lineTo(sx - 12, sy + 3);
            ctx.stroke();
        }

        if (this.type === "civilian" && this.seePolice && !this.isTired) {
            ctx.font = "bold 14px Arial";
            ctx.textAlign = "center";
            ctx.fillStyle = "#ff6600";
            ctx.fillText("⚡", sx, sy - RADIUS - 6);
        }

        if (this.type === "police") {
            ctx.save();
            ctx.translate(sx, sy);
            ctx.rotate(this.aimAngle);
            ctx.fillStyle = "#000000";
            ctx.fillRect(RADIUS, -3, GUN_LENGTH, 6);
            ctx.restore();
        } else if (this.type === "swat") {
            ctx.save();
            ctx.translate(sx, sy);
            ctx.rotate(this.aimAngle);
            ctx.fillStyle = "#D2691E";
            ctx.fillRect(RADIUS + 3, -5, 10, 20);
            ctx.strokeStyle = "#8B4513";
            ctx.lineWidth = 1;
            ctx.strokeRect(RADIUS + 3, -5, 10, 20);
            ctx.fillStyle = "#222222";
            ctx.fillRect(RADIUS, -6, 24, 12);
            ctx.strokeStyle = "#000";
            ctx.strokeRect(RADIUS, -6, 24, 12);
            ctx.restore();
        } else if (this.type === "pla") {
            this.drawPlaRifle(sx, sy, this.aimAngle);
        }

        if (this.isCryingForHelp && (this.type === "police" || this.type === "swat" || this.type === "pla")) {
            ctx.font = "bold 13px Arial";
            ctx.textAlign = "center";
            ctx.fillStyle="#ff2222";
            ctx.strokeStyle="#fff";
            ctx.lineWidth=2;
            ctx.strokeText("I need help!", sx, sy - RADIUS - 12);
            ctx.fillText("I need help!", sx, sy - RADIUS - 12);
        }

        ctx.font = "bold 11px Arial";
        ctx.textAlign = "center";
        ctx.fillStyle = "#fff";
        ctx.strokeStyle = "#000";
        ctx.lineWidth = 2;
        ctx.strokeText(String(this.hp), sx, sy + 4);
        ctx.fillText(String(this.hp), sx, sy + 4);

        if (this.type === "police" || this.type === "swat" || this.type === "pla") {
            const magSize = this.type === "pla" ? PLA_MAG_SIZE :
                            this.type === "swat" ? SHOTGUN_MAG_SIZE : MAG_SIZE;

            ctx.font = "11px Arial";
            ctx.fillStyle = "#fff";
            ctx.strokeStyle = "#000";
            ctx.lineWidth = 2;
            ctx.strokeText(`${this.bulletsInMag}/${magSize}`, sx, sy - RADIUS - 6);
            ctx.fillText(`${this.bulletsInMag}/${magSize}`, sx, sy - RADIUS - 6);

            if (this.isReloading && !this.isCryingForHelp) {
                ctx.fillStyle = "#ff4444";
                ctx.font = "bold 10px Arial";
                const reloadTxt = this.type === "swat" ? "装填霰弹..." :
                                  this.type === "pla" ? "换弹" : "换弹";
                ctx.strokeText(reloadTxt, sx, sy - RADIUS - 18);
                ctx.fillText(reloadTxt, sx, sy - RADIUS - 18);
            }

            if (this.type === "pla") {
                if (this.isThrowingGrenade) {
                    ctx.fillStyle = "#ff8800";
                    ctx.font = "bold 11px Arial";
                    ctx.strokeStyle = "#000";
                    ctx.lineWidth = 2;
                    ctx.strokeText("💣 投雷中", sx, sy - RADIUS - 30);
                    ctx.fillText("💣 投雷中", sx, sy - RADIUS - 30);
                }
                const stacks = this.plaDamageBonusTimes.length;
                if (stacks > 0) {
                    ctx.fillStyle = "#ffd700";
                    ctx.font = "bold 11px Arial";
                    ctx.strokeStyle = "#000";
                    ctx.lineWidth = 2;
                    const dmgBonus = Math.min(stacks * PLA_DAMAGE_PER_BUFF, PLA_MAX_DAMAGE_BONUS);
                    ctx.strokeText(`+${dmgBonus}伤害`, sx, sy + RADIUS + 14);
                    ctx.fillText(`+${dmgBonus}伤害`, sx, sy + RADIUS + 14);
                }
            }
        }
    }
}

// ========== 碰撞处理 ==========
function resolveCollisions(units) {
    for (let i = 0; i < units.length; i++) {
        if (units[i].hp <= 0) continue;
        for (let j = i + 1; j < units.length; j++) {
            if (units[j].hp <= 0) continue;
            const dx = units[j].x - units[i].x;
            const dy = units[j].y - units[i].y;
            const dist = Math.hypot(dx, dy);
            const minDist = COLLISION_RADIUS * 2;
            if (dist < minDist && dist > 0) {
                const overlap = (minDist - dist) / 2;
                const nx = dx / dist;
                const ny = dy / dist;
                units[i].x -= nx * overlap;
                units[i].y -= ny * overlap;
                units[j].x += nx * overlap;
                units[j].y += ny * overlap;
            }
        }
    }
    for (const u of units) {
        const edgeBuffer = 15;
        u.x = Math.max(RADIUS + edgeBuffer, Math.min(WORLD_WIDTH - RADIUS - edgeBuffer, u.x));
        u.y = Math.max(RADIUS + edgeBuffer, Math.min(WORLD_HEIGHT - RADIUS - edgeBuffer, u.y));
    }
}

// ========== 创建实体 ==========
const units = [];
const bullets = [];

for (let i = 0; i < 18; i++) {
    units.push(new Unit(Math.random() * (WORLD_WIDTH - 200) + 100, Math.random() * (WORLD_HEIGHT - 200) + 100, "zombie"));
}
for (let i = 0; i < 6; i++) {
    units.push(new Unit(Math.random() * (WORLD_WIDTH - 200) + 100, Math.random() * (WORLD_HEIGHT - 200) + 100, "runner"));
}
for (let i = 0; i < 12; i++) {
    units.push(new Unit(Math.random() * (WORLD_WIDTH - 200) + 100, Math.random() * (WORLD_HEIGHT - 200) + 100, "civilian"));
}
for (let i = 0; i < 5; i++) {
    units.push(new Unit(Math.random() * (WORLD_WIDTH - 200) + 100, Math.random() * (WORLD_HEIGHT - 200) + 100, "police"));
}
for (let i = 0; i < 3; i++) {
    units.push(new Unit(Math.random() * (WORLD_WIDTH - 200) + 100, Math.random() * (WORLD_HEIGHT - 200) + 100, "swat"));
}
// 唯一 PLA 英雄
units.push(new Unit(WORLD_WIDTH / 2, WORLD_HEIGHT / 2, "pla"));

// ========== 僵尸刷新状态 ==========
let currentSpawnInterval = ZOMBIE_SPAWN_INITIAL_INTERVAL;
let zombieSpawnTimer = 0;

function spawnZombieFromEdge() {
    const edge = Math.floor(Math.random() * 4);
    const margin = 25;
    let x, y;
    if (edge === 0) {
        x = Math.random() * WORLD_WIDTH;
        y = margin;
    } else if (edge === 1) {
        x = Math.random() * WORLD_WIDTH;
        y = WORLD_HEIGHT - margin;
    } else if (edge === 2) {
        x = margin;
        y = Math.random() * WORLD_HEIGHT;
    } else {
        x = WORLD_WIDTH - margin;
        y = Math.random() * WORLD_HEIGHT;
    }
    const isRunner = Math.random() < ZOMBIE_SPAWN_RUNNER_CHANCE;
    units.push(new Unit(x, y, isRunner ? "runner" : "zombie"));
}

function updateZombieSpawning(delta) {
    let currentZombies = 0;
    for (const u of units) {
        if (u.hp > 0 && u.isZombieType()) currentZombies++;
    }
    if (currentZombies >= ZOMBIE_MAX_ON_MAP) return;

    zombieSpawnTimer += delta;
    if (zombieSpawnTimer >= currentSpawnInterval) {
        zombieSpawnTimer = 0;
        const count = ZOMBIE_SPAWN_BATCH_MIN +
            Math.floor(Math.random() * (ZOMBIE_SPAWN_BATCH_MAX - ZOMBIE_SPAWN_BATCH_MIN + 1));
        for (let i = 0; i < count; i++) spawnZombieFromEdge();
        currentSpawnInterval = Math.max(
            ZOMBIE_SPAWN_MIN_INTERVAL,
            currentSpawnInterval * ZOMBIE_SPAWN_ACCELERATION
        );
    }
}

// ========== 游戏主循环 ==========
let gameTime = 0;
let lastFrameTime = performance.now();

function drawRoundRect(x, y, w, h, r) {
    ctx.beginPath();
    ctx.moveTo(x + r, y);
    ctx.lineTo(x + w - r, y);
    ctx.quadraticCurveTo(x + w, y, x + w, y + r);
    ctx.lineTo(x + w, y + h - r);
    ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
    ctx.lineTo(x + r, y + h);
    ctx.quadraticCurveTo(x, y + h, x, y + h - r);
    ctx.lineTo(x, y + r);
    ctx.quadraticCurveTo(x, y, x + r, y);
    ctx.closePath();
}

function gameLoop() {
    const now = performance.now();
    const realDelta = now - lastFrameTime;
    lastFrameTime = now;

    const gameDelta = realDelta * gameSpeed;
    gameTime += gameDelta;

    const steps = Math.max(1, Math.floor(gameSpeed));
    const stepDelta = gameDelta / steps;

    for (let s = 0; s < steps; s++) {
        const stepTime = gameTime - gameDelta + stepDelta * (s + 1);
        units.forEach(u => u.update(units, bullets, stepTime));
        resolveCollisions(units);
        updateZombieSpawning(stepDelta);
        bullets.forEach(b => b.update());

        for (const bullet of bullets) {
            if (!bullet.alive) continue;
            for (const u of units) {
                if (u.isZombieType() && u.hp > 0) {
                    const dist = Math.hypot(bullet.x - u.x, bullet.y - u.y);
                    if (dist < RADIUS + bullet.radius) {
                        bullet.alive = false;
                        u.slowUntil = now + SLOW_DURATION;

                        const shooter = bullet.shooter;
                        if (shooter && shooter.hp > 0) {
                            if (shooter.type === "police") {
                                const chance = shooter.getEffectiveDamageChance();
                                if (Math.random() < chance) {
                                    u.hp -= BULLET_DAMAGE;
                                    damageTexts.push(new DamageText(u.x, u.y - RADIUS, `-${BULLET_DAMAGE}`, false));
                                    if (u.hp <= 0) shooter.killCount += 1;
                                } else {
                                    damageTexts.push(new DamageText(u.x, u.y - RADIUS, "伤害无效", true));
                                }
                            } else if (shooter.type === "swat") {
                                u.hp -= BULLET_DAMAGE;
                                damageTexts.push(new DamageText(u.x, u.y - RADIUS, `-${BULLET_DAMAGE}`, false));
                                if (u.hp <= 0) shooter.killCount += 1;
                            } else if (shooter.type === "pla") {
                                const dmg = shooter.getPlaDamage();
                                u.hp -= dmg;
                                damageTexts.push(new DamageText(u.x, u.y - RADIUS, `-${dmg}`, false, "#ffd700"));
                                if (u.hp <= 0) shooter.killCount += 1;
                            }
                        }
                        break;
                    }
                }
            }
        }
        bullets.splice(0, bullets.length, ...bullets.filter(b => b.alive));
        damageTexts.splice(0, damageTexts.length, ...damageTexts.filter(dt => dt.update()));
        grenadeExplosions.splice(0, grenadeExplosions.length, ...grenadeExplosions.filter(g => g.isAlive()));
    }

    // 清理死单位(防止内存爆)
    if (units.length > 200) {
        for (const u of units) {
            if (u.hp <= 0 && !u._deathTime) u._deathTime = now;
        }
        const filtered = units.filter(u => u.hp > 0 || (now - u._deathTime) < 3000);
        units.length = 0;
        units.push(...filtered);
    }

    if (cameraFollow && selectedUnit && selectedUnit.hp > 0) {
        const targetX = selectedUnit.x - canvas.width / 2;
        const targetY = selectedUnit.y - canvas.height / 2;
        camera.x += (targetX - camera.x) * 0.1;
        camera.y += (targetY - camera.y) * 0.1;
    }
    if (selectedUnit && selectedUnit.hp <= 0) {
        selectedUnit = null;
        cameraFollow = false;
    }

    ctx.clearRect(0, 0, canvas.width, canvas.height);

    const gridSize = 50;
    ctx.strokeStyle = "#eee";
    ctx.lineWidth = 1;
    const startX = Math.floor(camera.x / gridSize) * gridSize - camera.x;
    const startY = Math.floor(camera.y / gridSize) * gridSize - camera.y;
    for (let x = startX; x < canvas.width; x += gridSize) {
        ctx.beginPath();
        ctx.moveTo(x, 0);
        ctx.lineTo(x, canvas.height);
        ctx.stroke();
    }
    for (let y = startY; y < canvas.height; y += gridSize) {
        ctx.beginPath();
        ctx.moveTo(0, y);
        ctx.lineTo(canvas.width, y);
        ctx.stroke();
    }

    ctx.strokeStyle = "#999";
    ctx.lineWidth = 3;
    ctx.strokeRect(-camera.x, -camera.y, WORLD_WIDTH, WORLD_HEIGHT);

    bullets.forEach(b => { if (b.alive) b.draw(); });
    units.forEach(u => u.draw());
    damageTexts.forEach(dt => dt.draw());
    grenadeExplosions.forEach(g => g.draw());

    // 顶部统计
    let aliveCivilian = 0, alivePolice = 0, aliveZombie = 0, aliveRunner = 0, aliveSwat = 0, alivePla = 0;
    for (const u of units) {
        if (u.hp > 0) {
            if (u.type === "zombie") aliveZombie++;
            else if (u.type === "runner") aliveRunner++;
            else if (u.type === "civilian") aliveCivilian++;
            else if (u.type === "police") alivePolice++;
            else if (u.type === "swat") aliveSwat++;
            else if (u.type === "pla") alivePla++;
        }
    }

    drawRoundRect(10, 10, 250, 165, 8);
    ctx.fillStyle = "rgba(255,255,255,0.92)";
    ctx.fill();
    ctx.strokeStyle = "#aaa";
    ctx.lineWidth = 1;
    ctx.stroke();

    ctx.font = "bold 15px Arial";
    ctx.textAlign = "left";
    ctx.fillStyle = "#333";
    ctx.fillText(`👤 普通人:${aliveCivilian}`, 20, 33);
    ctx.fillText(`👮 警察:${alivePolice}`, 20, 56);
    ctx.fillStyle = "#3a86ff";
    ctx.fillText(`🔫 支援警察:${aliveSwat}`, 20, 79);
    ctx.fillStyle = "#4a5d23";
    ctx.fillText(`🪖 PLA:${alivePla}`, 20, 102);
    ctx.fillStyle = "#00a020";
    ctx.fillText(`🧟 僵尸:${aliveZombie}`, 20, 125);
    ctx.fillStyle = "#c02020";
    ctx.fillText(`🏃 跑尸:${aliveRunner}`, 110, 125);

    const nextWaveIn = Math.max(0, (currentSpawnInterval - zombieSpawnTimer) / 1000).toFixed(1);
    ctx.fillStyle = "#888";
    ctx.font = "bold 13px Arial";
    ctx.fillText(`⏱ 下一波僵尸:${nextWaveIn}s`, 20, 150);

    // 详情面板
    if (selectedUnit && selectedUnit.hp > 0) {
        const panelW = 260;
        const panelH = 280;
        const px = canvas.width - panelW - 12;
        const py = 12;

        drawRoundRect(px, py, panelW, panelH, 10);
        ctx.fillStyle = "rgba(255,255,255,0.95)";
        ctx.fill();
        ctx.strokeStyle = "#ffaa00";
        ctx.lineWidth = 2;
        ctx.stroke();

        ctx.fillStyle = "#ffaa00";
        ctx.font = "bold 16px Arial";
        ctx.textAlign = "left";
        ctx.fillText(`📌 ${selectedUnit.getTypeName()}`, px + 14, py + 26);

        ctx.font = "11px Arial";
        ctx.fillStyle = cameraFollow ? "#00a020" : "#888";
        ctx.textAlign = "right";
        ctx.fillText(cameraFollow ? "● 跟随中" : "○ 未跟随", px + panelW - 14, py + 24);

        ctx.beginPath();
        ctx.moveTo(px + 12, py + 36);
        ctx.lineTo(px + panelW - 12, py + 36);
        ctx.strokeStyle = "#ddd";
        ctx.lineWidth = 1;
        ctx.stroke();

        ctx.font = "13px Arial";
        ctx.textAlign = "left";
        ctx.fillStyle = "#333";
        let ly = py + 54;

        ctx.fillStyle = "#eee";
        ctx.fillRect(px + 14, ly - 10, panelW - 28, 8);
        const hpRatio = selectedUnit.hp / MAX_HP;
        ctx.fillStyle = hpRatio > 0.5 ? "#00c040" : hpRatio > 0.25 ? "#ffaa00" : "#ff4444";
        ctx.fillRect(px + 14, ly - 10, (panelW - 28) * hpRatio, 8);
        ctx.strokeStyle = "#999";
        ctx.lineWidth = 1;
        ctx.strokeRect(px + 14, ly - 10, panelW - 28, 8);

        ctx.fillStyle = "#333";
        ctx.fillText(`❤️ 生命值:${selectedUnit.hp} / ${MAX_HP}`, px + 14, ly + 10);
        ly += 26;

        ctx.fillText(`📍 位置:(${Math.round(selectedUnit.x)}, ${Math.round(selectedUnit.y)})`, px + 14, ly);
        ly += 22;

        ctx.fillText(`🏃 速度:${selectedUnit.getCurrentSpeed().toFixed(1)}`, px + 14, ly);
        ly += 22;

        if (selectedUnit.isZombieType()) {
            const targetName = selectedUnit.target ? selectedUnit.target.getTypeName() : "无";
            ctx.fillText(`🎯 目标:${targetName}`, px + 14, ly);
            ly += 22;
            if(now < selectedUnit.slowUntil){
                ctx.fillStyle="#4488ff";
                ctx.fillText(`❄️ 减速中`, px + 14, ly);
                ly +=22;
            }
        } else if (selectedUnit.type === "civilian") {
            const state = selectedUnit.isTired ? "😩 疲劳" :
                         selectedUnit.seePolice ? "⚡ 加速逃跑" : "🚶 正常";
            ctx.fillText(`📊 状态:${state}`, px + 14, ly);
            ly += 22;
        } else if (selectedUnit.type === "police") {
            ctx.fillText(`🔫 弹药:${selectedUnit.bulletsInMag} / ${MAG_SIZE}`, px + 14, ly);
            ly += 22;
            const state = selectedUnit.isReloading ? "🔄 换弹中" :
                         selectedUnit.isCryingForHelp ? "🆘 求救中" : "✅ 就绪";
            ctx.fillText(`📊 状态:${state}`, px + 14, ly);
            ly += 22;
            ctx.fillText(`💀 击杀:${selectedUnit.killCount}`, px + 14, ly);
            ly +=22;
            const hitPct = (selectedUnit.getEffectiveDamageChance() *100).toFixed(0);
            ctx.fillText(`🎯 有效伤害概率:${hitPct}%`, px +14, ly);
            ly +=22;
        } else if (selectedUnit.type === "swat") {
            ctx.fillText(`🔫 弹药:${selectedUnit.bulletsInMag} / ${SHOTGUN_MAG_SIZE} (霰弹)`, px + 14, ly);
            ly += 22;
            const state = selectedUnit.isReloading ? "🔄 装填中(1.5秒)" :
                         selectedUnit.isCryingForHelp ? "🆘 求救中" : "✅ 就绪";
            ctx.fillText(`📊 状态:${state}`, px + 14, ly);
            ly += 22;
            ctx.fillText(`💀 击杀:${selectedUnit.killCount}`, px + 14, ly);
            ly +=22;
            ctx.fillText(`🎯 有效射程:${SHOTGUN_MAX_RANGE}px`, px +14, ly);
            ly +=22;
        } else if (selectedUnit.type === "pla") {
            ctx.fillText(`🔫 弹药:${selectedUnit.bulletsInMag} / ${PLA_MAG_SIZE} (95式)`, px + 14, ly);
            ly += 22;
            const state = selectedUnit.isThrowingGrenade ? "💣 投掷手榴弹中" :
                         selectedUnit.isReloading ? "🔄 换弹中(0.5秒)" :
                         selectedUnit.isCryingForHelp ? "🆘 求救中" : "✅ 就绪";
            ctx.fillText(`📊 状态:${state}`, px + 14, ly);
            ly += 22;
            ctx.fillText(`💀 击杀:${selectedUnit.killCount}`, px + 14, ly);
            ly +=22;
            const dmg = selectedUnit.getPlaDamage();
            ctx.fillStyle = dmg > BULLET_DAMAGE ? "#d4af37" : "#333";
            ctx.fillText(`💥 当前伤害:${dmg}`, px + 14, ly);
            ly += 22;
            const stacks = selectedUnit.plaDamageBonusTimes.length;
            if (stacks > 0) {
                const nowT = performance.now();
                let minRemain = Infinity;
                for (const t of selectedUnit.plaDamageBonusTimes) {
                    minRemain = Math.min(minRemain, (t - nowT) / 1000);
                }
                ctx.fillStyle = "#d4af37";
                ctx.fillText(`⚔️ 加成:+${Math.min(stacks * PLA_DAMAGE_PER_BUFF, PLA_MAX_DAMAGE_BONUS)} (${stacks}层, 剩${minRemain.toFixed(1)}s)`, px + 14, ly);
                ly += 22;
            } else {
                ctx.fillStyle = "#888";
                ctx.fillText(`⚔️ 加成:无 (平民受伤叠加)`, px + 14, ly);
                ly += 22;
            }
        }

        ctx.font = "10px Arial";
        ctx.fillStyle = "#aaa";
        ctx.textAlign = "center";
        ctx.fillText("点击空白处取消选中 / 拖动视角", px + panelW / 2, py + panelH - 10);
    }

    // 加速按钮
    for (const btn of speedButtons) {
        const isActive = gameSpeed === btn.speed;
        drawRoundRect(btn.x + 2, btn.y + 3, btn.w, btn.h, 10);
        ctx.fillStyle = "rgba(0,0,0,0.15)";
        ctx.fill();
        drawRoundRect(btn.x, btn.y, btn.w, btn.h, 10);
        if (isActive) {
            const grad = ctx.createLinearGradient(btn.x, btn.y, btn.x, btn.y + btn.h);
            grad.addColorStop(0, "#ff7b3a");
            grad.addColorStop(1, "#e85a1a");
            ctx.fillStyle = grad;
        } else {
            const grad = ctx.createLinearGradient(btn.x, btn.y, btn.x, btn.y + btn.h);
            grad.addColorStop(0, "#f5f5f5");
            grad.addColorStop(1, "#e0e0e0");
            ctx.fillStyle = grad;
        }
        ctx.fill();
        ctx.strokeStyle = isActive ? "#cc4400" : "#bbb";
        ctx.lineWidth = 2;
        ctx.stroke();

        ctx.fillStyle = isActive ? "#fff" : "#444";
        ctx.font = "bold 26px Arial";
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.fillText(btn.label, btn.x + btn.w/2, btn.y + btn.h/2);
    }
    ctx.textBaseline = "alphabetic";

    requestAnimationFrame(gameLoop);
}
gameLoop();
</script>
</body>
</html>

Game Source: 僵尸生存模拟【PLA英雄 + 支援警察 + 僵尸全图追杀 + 边境刷新】

Creator: EpicCoder88

Libraries: none

Complexity: complex (1509 lines, 53.8 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: pla-epiccoder88-muryyd3x" to link back to the original. Then publish at arcadelab.ai/publish.