僵尸生存模拟【PLA英雄 + 支援警察 + 僵尸边境刷新】
by EpicCoder881525 lines54.6 KB
<!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;
// 刚刷新的僵尸短暂无敌,防止一出来就被秒
this.spawnGrace = (type === "zombie" || type === "runner") ? performance.now() + 500 : 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 = this.getZombieDetectRange();
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 === "zombie") {
ctx.beginPath();
ctx.arc(sx, sy, ZOMBIE_DETECT_RANGE, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(40, 220, 40, 0.3)";
ctx.lineWidth = 1;
ctx.stroke();
} else if (this.type === "runner") {
ctx.beginPath();
ctx.arc(sx, sy, RUNNER_DETECT_RANGE, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(220, 40, 40, 0.3)";
ctx.lineWidth = 1;
ctx.stroke();
} else 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) {
const aliveOrRecent = units.filter(u => u.hp > 0 || (now - (u._deathTime || 0)) < 100);
// 简单策略:直接过滤掉死亡超过 3 秒的
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 (1525 lines, 54.6 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-muryfx2t" to link back to the original. Then publish at arcadelab.ai/publish.