僵尸生存模拟 伤害浮动文字+警察熟练度机制【支援警察+射程限制】
by EpicCoder881295 lines45.3 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>僵尸生存模拟 伤害浮动文字+警察熟练度机制【支援警察+射程限制】</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; // 【修复】1.5秒装3发,真实节奏
const SHOTGUN_SHOOT_COOLDOWN = 1000;
const SHOTGUN_PELLET_COUNT = 5;
const SHOTGUN_SPREAD_ANGLE = Math.PI / 3; // 60度散射
const SHOTGUN_MAX_RANGE = 150; // 【新增】最大有效射程150像素
let gameSpeed = 1;
// ========== 浮动伤害文字类 ==========
class DamageText {
constructor(x, y, text, isInvalid) {
this.x = x;
this.y = y;
this.startY = y;
this.text = text;
this.isInvalid = isInvalid;
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";
if(this.isInvalid){
ctx.fillStyle = "#888888";
}else{
ctx.fillStyle = "#ff2222";
}
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 3;
ctx.strokeText(this.text, sx, sy);
ctx.fillText(this.text, sx, sy);
ctx.restore();
}
}
const damageTexts = [];
// ========== 选中单位 & 视角跟随 ==========
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; // 记录出生点X(用于射程判定)
this.startY = y; // 记录出生点Y
this.maxRange = (maxRange !== undefined) ? maxRange : 99999; // 默认无限远,霰弹枪会传入150
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 = "#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 : MAG_SIZE;
this.isReloading = false;
this.reloadStart = 0;
this.isCryingForHelp = false;
this.helpStartTime = -9999;
this.killCount = 0;
this.slowUntil = 0;
this.lastAttackTime = 0;
}
distanceTo(other) {
const dx = this.x - other.x;
const dy = this.y - other.y;
return Math.sqrt(dx * dx + dy * dy);
}
getMuzzlePos() {
return {
x: this.x + Math.cos(this.aimAngle) * (RADIUS + (this.type === "swat" ? 24 : GUN_LENGTH)),
y: this.y + Math.sin(this.aimAngle) * (RADIUS + (this.type === "swat" ? 24 : GUN_LENGTH))
};
}
isZombieType() {
return this.type === "zombie" || this.type === "runner";
}
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 "支援警察";
default: return "未知";
}
}
getEffectiveDamageChance(){
let p = BASE_HIT_CHANCE + this.killCount * CHANCE_PER_KILL;
return Math.min(p, MAX_HIT_CHANCE);
}
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"){
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.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.hp > 0 && u.isCryingForHelp) {
const d = this.distanceTo(u);
if (d < minDist) {
minDist = d;
nearest = u;
}
}
}
return nearest;
}
tryShoot(target, bulletList, currentTime) {
if (this.isReloading) return false;
if (this.bulletsInMag <= 0) return false;
if (currentTime - this.lastShootTime < SHOOT_COOLDOWN) return false;
const muzzle = this.getMuzzlePos();
// 普通手枪:无限射程
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;
}
return true;
}
tryFireShotgun(target, bulletList, currentTime) {
if (this.isReloading) return false;
if (this.bulletsInMag <= 0) return false;
if (currentTime - this.lastShootTime < SHOTGUN_SHOOT_COOLDOWN) return false;
const muzzle = this.getMuzzlePos();
// 生成 5 发霰弹丸,每发都带150像素射程限制
for (let i = 0; i < SHOTGUN_PELLET_COUNT; i++) {
let relativeAngle = -SHOTGUN_SPREAD_ANGLE / 2 + i * (SHOTGUN_SPREAD_ANGLE / (SHOTGUN_PELLET_COUNT - 1));
let angle = this.aimAngle + relativeAngle;
let vx = BULLET_SPEED * Math.cos(angle);
let vy = BULLET_SPEED * Math.sin(angle);
// 传入 SHOTGUN_MAX_RANGE 参数
let pellet = new Bullet(muzzle.x, muzzle.y, muzzle.x + 100, muzzle.y + 100, this, SHOTGUN_MAX_RANGE);
pellet.vx = vx;
pellet.vy = vy;
pellet.radius = 2;
bulletList.push(pellet);
}
this.bulletsInMag--;
this.lastShootTime = currentTime;
if (this.bulletsInMag <= 0) {
this.isReloading = true;
this.reloadStart = currentTime;
}
return true;
}
update(allUnits, bulletList, 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;
}
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;
}
}
} 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") {
const targetZombie = this.findNearestZombie(allUnits, POLICE_VISION_RANGE);
if (this.isReloading) {
if (currentTime - this.reloadStart >= RELOAD_TIME) {
this.bulletsInMag = MAG_SIZE;
this.isReloading = false;
this.lastShootTime = currentTime;
}
}
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);
}
}
else if (targetZombie) {
this.aimAngle = Math.atan2(targetZombie.y - this.y, targetZombie.x - this.x);
const distToZombie = this.distanceTo(targetZombie);
if (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;
}
this.tryShoot(targetZombie, bulletList, currentTime);
}
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;
}
}
else if (this.type === "swat") {
const targetZombie = this.findNearestZombie(allUnits, POLICE_VISION_RANGE);
if (this.isReloading) {
if (currentTime - this.reloadStart >= SHOTGUN_RELOAD_TIME) {
this.bulletsInMag = SHOTGUN_MAG_SIZE;
this.isReloading = false;
this.lastShootTime = currentTime;
}
}
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.tryFireShotgun(targetZombie, bulletList, currentTime);
}
}
else if (targetZombie) {
this.aimAngle = Math.atan2(targetZombie.y - this.y, targetZombie.x - this.x);
const distToZombie = this.distanceTo(targetZombie);
if (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.tryFireShotgun(targetZombie, bulletList, currentTime);
}
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));
}
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();
}
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") {
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";
}
ctx.fill();
ctx.strokeStyle = "#222222";
ctx.lineWidth = 1;
ctx.stroke();
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();
if(this.isCryingForHelp){
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);
}
} 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.lineWidth = 1;
ctx.strokeRect(RADIUS, -6, 24, 12);
ctx.restore();
if(this.isCryingForHelp){
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") {
ctx.font = "11px Arial";
ctx.fillStyle = "#fff";
ctx.strokeStyle = "#000";
ctx.lineWidth = 2;
ctx.strokeText(`${this.bulletsInMag}/${MAG_SIZE}`, sx, sy - RADIUS - 6);
ctx.fillText(`${this.bulletsInMag}/${MAG_SIZE}`, sx, sy - RADIUS - 6);
if (this.isReloading && !this.isCryingForHelp) {
ctx.fillStyle = "#ff4444";
ctx.font = "bold 10px Arial";
ctx.strokeText("换弹", sx, sy - RADIUS - 18);
ctx.fillText("换弹", sx, sy - RADIUS - 18);
}
} else if (this.type === "swat") {
ctx.font = "11px Arial";
ctx.fillStyle = "#fff";
ctx.strokeStyle = "#000";
ctx.lineWidth = 2;
ctx.strokeText(`${this.bulletsInMag}/${SHOTGUN_MAG_SIZE}`, sx, sy - RADIUS - 6);
ctx.fillText(`${this.bulletsInMag}/${SHOTGUN_MAG_SIZE}`, sx, sy - RADIUS - 6);
if (this.isReloading && !this.isCryingForHelp) {
ctx.fillStyle = "#ff4444";
ctx.font = "bold 10px Arial";
ctx.strokeText("装填霰弹...", sx, sy - RADIUS - 18);
ctx.fillText("装填霰弹...", sx, sy - RADIUS - 18);
}
}
}
}
// ========== 碰撞处理 ==========
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++) {
const x = Math.random() * (WORLD_WIDTH - 200) + 100;
const y = Math.random() * (WORLD_HEIGHT - 200) + 100;
units.push(new Unit(x, y, "zombie"));
}
for (let i = 0; i < 6; i++) {
const x = Math.random() * (WORLD_WIDTH - 200) + 100;
const y = Math.random() * (WORLD_HEIGHT - 200) + 100;
units.push(new Unit(x, y, "runner"));
}
for (let i = 0; i < 12; i++) {
const x = Math.random() * (WORLD_WIDTH - 200) + 100;
const y = Math.random() * (WORLD_HEIGHT - 200) + 100;
units.push(new Unit(x, y, "civilian"));
}
for (let i = 0; i < 5; i++) {
const x = Math.random() * (WORLD_WIDTH - 200) + 100;
const y = Math.random() * (WORLD_HEIGHT - 200) + 100;
units.push(new Unit(x, y, "police"));
}
for (let i = 0; i < 3; i++) {
const x = Math.random() * (WORLD_WIDTH - 200) + 100;
const y = Math.random() * (WORLD_HEIGHT - 200) + 100;
units.push(new Unit(x, y, "swat"));
}
// ========== 游戏主循环 ==========
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);
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;
}
}
}
break;
}
}
}
}
bullets.splice(0, bullets.length, ...bullets.filter(b => b.alive));
damageTexts.splice(0, damageTexts.length, ...damageTexts.filter(dt => dt.update()));
}
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());
let aliveCivilian = 0, alivePolice = 0, aliveZombie = 0, aliveRunner = 0, aliveSwat = 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++;
}
}
drawRoundRect(10, 10, 210, 110, 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 = "#00a020";
ctx.fillText(`🧟 僵尸:${aliveZombie}`, 100, 102);
ctx.fillStyle = "#c02020";
ctx.fillText(`🏃 跑尸:${aliveRunner}`, 170, 102);
if (selectedUnit && selectedUnit.hp > 0) {
const panelW = 240;
const panelH = 240;
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;
ctx.fillText(`👁 探测范围:${selectedUnit.getZombieDetectRange()}`, 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;
const danger = selectedUnit.findNearestZombie(units, 9999);
const distStr = danger ? Math.round(selectedUnit.distanceTo(danger)) + "px" : "无";
ctx.fillText(`⚠️ 最近僵尸:${distStr}`, 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;
const target = selectedUnit.findNearestZombie(units, POLICE_VISION_RANGE);
const tName = target ? target.getTypeName() : "无";
ctx.fillText(`🎯 目标:${tName}`, 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;
const target = selectedUnit.findNearestZombie(units, POLICE_VISION_RANGE);
const tName = target ? target.getTypeName() : "无";
ctx.fillText(`🎯 目标:${tName}`, 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: 僵尸生存模拟 伤害浮动文字+警察熟练度机制【支援警察+射程限制】
Creator: EpicCoder88
Libraries: none
Complexity: complex (1295 lines, 45.3 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: game-epiccoder88-murxylff" to link back to the original. Then publish at arcadelab.ai/publish.