血染红旗
by EpicCoder881065 lines31.2 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>
html, body {
margin: 0; height: 100%;
background: #0a0806;
display: flex; align-items: center; justify-content: center;
overflow: hidden;
font-family: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
canvas {
width: min(97vw, 175vh);
height: auto;
display: block;
border-radius: 10px;
box-shadow: 0 0 90px rgba(180, 30, 30, 0.3), 0 0 0 1px rgba(120, 40, 40, 0.3);
touch-action: none;
}
#touch {
position: fixed; inset: 0;
pointer-events: none;
display: none;
}
#touch button {
pointer-events: auto;
position: absolute;
bottom: 24px;
width: 76px; height: 76px;
border-radius: 50%;
border: 2px solid rgba(200, 80, 60, 0.5);
background: rgba(80, 20, 20, 0.5);
color: #ffd0b0;
font-size: 24px;
backdrop-filter: blur(6px);
}
#touch button:active { background: rgba(180, 40, 30, 0.7); }
#btnL { left: 20px; }
#btnR { left: 114px; }
#btnF { right: 20px; }
#btnRl { right: 114px; }
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="touch">
<button id="btnL">◀</button>
<button id="btnR">▶</button>
<button id="btnRl">R</button>
<button id="btnF">开火</button>
</div>
<script>
(() => {
'use strict';
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const W = 960, H = 540;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.scale(dpr, dpr);
// ==================== 音效 ====================
let audioCtx = null;
function initAudio() {
if (!audioCtx) {
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) {}
}
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
}
function tone(freq, dur, type = 'square', vol = 0.05, slideTo = null) {
if (!audioCtx) return;
const t = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, t);
if (slideTo) osc.frequency.exponentialRampToValueAtTime(Math.max(20, slideTo), t + dur);
gain.gain.setValueAtTime(0.0001, t);
gain.gain.exponentialRampToValueAtTime(vol, t + 0.008);
gain.gain.exponentialRampToValueAtTime(0.0001, t + dur);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(t);
osc.stop(t + dur + 0.03);
}
function noiseBurst(dur, vol) {
if (!audioCtx) return;
const bufferSize = audioCtx.sampleRate * dur;
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / bufferSize, 2);
const source = audioCtx.createBufferSource();
source.buffer = buffer;
const gain = audioCtx.createGain();
gain.gain.value = vol;
source.connect(gain);
gain.connect(audioCtx.destination);
source.start();
}
// ==================== 常量 ====================
const FLAG = { x: 480, y: 270, r: 26, hp: 100, maxHp: 100 };
const PLAYER_SPEED = 2.6;
const MAG_SIZE = 5;
const RELOAD_TIME = 48;
const FIRE_COOLDOWN = 30;
const BULLET_SPEED = 12;
const BULLET_DAMAGE = 60;
// ==================== 状态 ====================
let player, zombies, bullets, particles, bloodStains, pickups;
let wave, waveTimer, enemiesToSpawn, spawnTimer, state, frame, shake;
let score, kills, message, messageTimer;
const keys = {};
let mouse = { x: W / 2, y: H / 2, down: false };
// ==================== 地图障碍 ====================
const obstacles = [
{ x: 160, y: 120, w: 60, h: 60 },
{ x: 740, y: 120, w: 60, h: 60 },
{ x: 160, y: 360, w: 60, h: 60 },
{ x: 740, y: 360, w: 60, h: 60 },
{ x: 420, y: 80, w: 120, h: 40 },
{ x: 420, y: 420, w: 120, h: 40 },
{ x: 80, y: 240, w: 40, h: 80 },
{ x: 840, y: 240, w: 40, h: 80 },
];
// ==================== 工具 ====================
function rectsOverlap(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}
function dist(ax, ay, bx, by) {
const dx = ax - bx, dy = ay - by;
return Math.sqrt(dx * dx + dy * dy);
}
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
function roundRect(x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function burst(x, y, color, n, power = 4, size = 2) {
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const sp = 0.6 + Math.random() * power;
particles.push({
x, y,
vx: Math.cos(a) * sp,
vy: Math.sin(a) * sp - 0.8,
life: 1,
decay: 0.018 + Math.random() * 0.025,
color,
size: size * (0.6 + Math.random() * 0.8)
});
}
}
// ==================== 初始化 ====================
function resetPlayer() {
player = {
x: FLAG.x, y: FLAG.y + 80,
w: 18, h: 24,
vx: 0, vy: 0,
hp: 100, maxHp: 100,
ammo: MAG_SIZE,
reloading: false,
reloadTimer: 0,
fireCooldown: 0,
facing: -Math.PI / 2,
hurtTimer: 0,
down: false,
downTimer: 0,
};
}
function startWave(n) {
wave = n;
// 每波敌人数量递增
enemiesToSpawn = 3 + n * 2;
spawnTimer = 0;
// 每 3 波出一个重装僵尸
message = n === 1 ? '第一波 · 守住军旗' : `第 ${n} 波`;
messageTimer = 100;
}
function init() {
zombies = [];
bullets = [];
particles = [];
bloodStains = [];
pickups = [];
frame = 0;
shake = 0;
score = 0;
kills = 0;
state = 'playing';
message = '湘江之后 · 血染红旗';
messageTimer = 160;
resetPlayer();
FLAG.hp = FLAG.maxHp;
startWave(1);
}
// ==================== 生成僵尸 ====================
function spawnZombie() {
const side = Math.floor(Math.random() * 4);
let x, y;
const m = 40;
if (side === 0) { x = Math.random() * W; y = -m; }
else if (side === 1) { x = W + m; y = Math.random() * H; }
else if (side === 2) { x = Math.random() * W; y = H + m; }
else { x = -m; y = Math.random() * H; }
// 类型:0普通 1冲锋 2重装
let type = 0;
const r = Math.random();
if (wave >= 3 && r < 0.2) type = 2;
else if (wave >= 2 && r < 0.45) type = 1;
const base = {
x, y, w: 18, h: 22,
vx: 0, vy: 0,
type,
hp: type === 2 ? 160 : 60,
maxHp: type === 2 ? 160 : 60,
speed: type === 1 ? 1.5 : type === 2 ? 0.65 : 0.95,
damage: type === 2 ? 22 : 12,
attackTimer: 0,
hitTimer: 0,
wanderAngle: Math.random() * Math.PI * 2,
wanderTimer: 0,
};
zombies.push(base);
enemiesToSpawn--;
}
// ==================== 玩家射击 ====================
function tryFire() {
if (player.down || player.reloading || player.fireCooldown > 0) return;
if (player.ammo <= 0) {
startReload();
return;
}
player.ammo--;
player.fireCooldown = FIRE_COOLDOWN;
const a = player.facing;
const sx = player.x + Math.cos(a) * 18;
const sy = player.y + Math.sin(a) * 18;
bullets.push({
x: sx, y: sy,
vx: Math.cos(a) * BULLET_SPEED,
vy: Math.sin(a) * BULLET_SPEED,
life: 50,
});
// 枪口火焰
burst(sx, sy, '#ffcc66', 4, 2.5, 1.6);
tone(180, 0.08, 'sawtooth', 0.08, 60);
noiseBurst(0.06, 0.04);
shake = Math.max(shake, 5);
if (player.ammo <= 0) startReload();
}
function startReload() {
if (player.reloading || player.ammo === MAG_SIZE) return;
player.reloading = true;
player.reloadTimer = RELOAD_TIME;
tone(320, 0.06, 'square', 0.03);
}
function finishReload() {
player.reloading = false;
player.ammo = MAG_SIZE;
tone(480, 0.08, 'square', 0.04, 640);
}
// ==================== 伤害 ====================
function damagePlayer(amount) {
if (player.down) return;
player.hp -= amount;
player.hurtTimer = 12;
shake = Math.max(shake, 6);
burst(player.x, player.y, '#ff3b3b', 8, 3, 2);
tone(160, 0.12, 'sawtooth', 0.06, 60);
if (player.hp <= 0) {
player.hp = 0;
player.down = true;
player.downTimer = 180; // 3秒倒地
burst(player.x, player.y, '#8a1010', 18, 4, 3);
}
}
function damageZombie(z, amount) {
z.hp -= amount;
z.hitTimer = 8;
burst(z.x, z.y, '#8a1010', 5, 2.5, 2);
if (z.hp <= 0) {
kills++;
score += z.type === 2 ? 30 : z.type === 1 ? 15 : 10;
burst(z.x, z.y, '#6a0808', 14, 4, 2.5);
bloodStains.push({ x: z.x, y: z.y, r: 12 + Math.random() * 10, a: 0.5 });
tone(90, 0.14, 'sawtooth', 0.05, 40);
const idx = zombies.indexOf(z);
if (idx >= 0) zombies.splice(idx, 1);
}
}
// ==================== 更新 ====================
function update() {
frame++;
if (shake > 0.4) shake *= 0.86; else shake = 0;
// 粒子
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.vy += 0.12;
p.vx *= 0.97;
p.x += p.vx;
p.y += p.vy;
p.life -= p.decay;
if (p.life <= 0) particles.splice(i, 1);
}
// 血迹渐隐
for (let i = bloodStains.length - 1; i >= 0; i--) {
bloodStains[i].a -= 0.0006;
if (bloodStains[i].a <= 0) bloodStains.splice(i, 1);
}
if (messageTimer > 0) messageTimer--;
if (state !== 'playing') return;
// 玩家倒地处理
if (player.down) {
player.downTimer--;
if (player.downTimer <= 0) {
state = 'lost';
message = '军旗未倒 · 战士已逝';
messageTimer = 99999;
}
return;
}
// ===== 玩家移动 =====
const left = keys['ArrowLeft'] || keys['KeyA'];
const right = keys['ArrowRight'] || keys['KeyD'];
const up = keys['ArrowUp'] || keys['KeyW'];
const down = keys['ArrowDown'] || keys['KeyS'];
let mx = 0, my = 0;
if (left) mx -= 1;
if (right) mx += 1;
if (up) my -= 1;
if (down) my += 1;
if (mx !== 0 && my !== 0) { mx *= 0.7071; my *= 0.7071; }
player.vx = mx * PLAYER_SPEED;
player.vy = my * PLAYER_SPEED;
// X 轴碰撞
player.x += player.vx;
for (const o of obstacles) if (rectsOverlap(player, o)) {
if (player.vx > 0) player.x = o.x - player.w;
else if (player.vx < 0) player.x = o.x + o.w;
}
player.x = clamp(player.x, player.w / 2, W - player.w / 2);
// Y 轴碰撞
player.y += player.vy;
for (const o of obstacles) if (rectsOverlap(player, o)) {
if (player.vy > 0) player.y = o.y - player.h;
else if (player.vy < 0) player.y = o.y + o.h;
}
player.y = clamp(player.y, player.h / 2, H - player.h / 2);
// ===== 瞄准 =====
const pdx = mouse.x - player.x;
const pdy = mouse.y - player.y;
player.facing = Math.atan2(pdy, pdx);
// 射击冷却
if (player.fireCooldown > 0) player.fireCooldown--;
if (player.hurtTimer > 0) player.hurtTimer--;
// 换弹
if (player.reloading) {
player.reloadTimer--;
if (player.reloadTimer <= 0) finishReload();
}
// 鼠标开火
if (mouse.down) tryFire();
// ===== 子弹 =====
for (let i = bullets.length - 1; i >= 0; i--) {
const b = bullets[i];
b.x += b.vx;
b.y += b.vy;
b.life--;
let hit = false;
// 撞墙
for (const o of obstacles) {
if (b.x > o.x && b.x < o.x + o.w && b.y > o.y && b.y < o.y + o.h) { hit = true; break; }
}
// 撞僵尸
if (!hit) {
for (const z of zombies) {
if (dist(b.x, b.y, z.x, z.y) < 16) {
damageZombie(z, BULLET_DAMAGE);
hit = true;
break;
}
}
}
if (hit || b.life <= 0 || b.x < 0 || b.x > W || b.y < 0 || b.y > H) {
if (hit) burst(b.x, b.y, '#ffaa55', 3, 2, 1.4);
bullets.splice(i, 1);
}
}
// ===== 僵尸 AI =====
for (const z of zombies) {
if (z.hitTimer > 0) z.hitTimer--;
// 目标选择:优先玩家(如果玩家活着且在附近),否则军旗
let tx = FLAG.x, ty = FLAG.y;
let targetIsPlayer = false;
if (!player.down) {
const dPlayer = dist(z.x, z.y, player.x, player.y);
const dFlag = dist(z.x, z.y, FLAG.x, FLAG.y);
if (dPlayer < 180 || dPlayer < dFlag) {
tx = player.x; ty = player.y;
targetIsPlayer = true;
}
}
const dx = tx - z.x, dy = ty - z.y;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
// 加入一点游荡,避免完全直线
z.wanderTimer--;
if (z.wanderTimer <= 0) {
z.wanderTimer = 30 + Math.random() * 40;
z.wanderAngle = Math.random() * Math.PI * 2;
}
const wanderX = Math.cos(z.wanderAngle) * 0.3;
const wanderY = Math.sin(z.wanderAngle) * 0.3;
z.vx = (dx / d) * z.speed + wanderX;
z.vy = (dy / d) * z.speed + wanderY;
// 碰撞
z.x += z.vx;
for (const o of obstacles) if (rectsOverlap(z, o)) {
if (z.vx > 0) z.x = o.x - z.w;
else if (z.vx < 0) z.x = o.x + o.w;
}
z.y += z.vy;
for (const o of obstacles) if (rectsOverlap(z, o)) {
if (z.vy > 0) z.y = o.y - z.h;
else if (z.vy < 0) z.y = o.y + o.h;
}
// 攻击
z.attackTimer--;
if (targetIsPlayer && dist(z.x, z.y, player.x, player.y) < 22) {
if (z.attackTimer <= 0) {
z.attackTimer = 36;
damagePlayer(z.damage);
}
} else if (!targetIsPlayer && dist(z.x, z.y, FLAG.x, FLAG.y) < 30) {
if (z.attackTimer <= 0) {
z.attackTimer = 50;
FLAG.hp -= z.damage * 0.6;
burst(FLAG.x, FLAG.y, '#cc2222', 6, 2.5, 1.8);
tone(100, 0.1, 'sawtooth', 0.04, 50);
if (FLAG.hp <= 0) {
FLAG.hp = 0;
state = 'lost';
message = '军旗倒下 · 阵地失守';
messageTimer = 99999;
burst(FLAG.x, FLAG.y, '#ff2a2a', 30, 6, 3);
}
}
}
}
// ===== 波次逻辑 =====
if (enemiesToSpawn > 0) {
spawnTimer--;
if (spawnTimer <= 0) {
spawnZombie();
spawnTimer = Math.max(14, 40 - wave * 2);
}
} else if (zombies.length === 0) {
// 波次结束
if (wave < 5) {
startWave(wave + 1);
// 奖励:回复一些生命,补满弹药
player.hp = Math.min(player.maxHp, player.hp + 25);
player.ammo = MAG_SIZE;
player.reloading = false;
} else {
state = 'won';
message = '血染红旗 · 阵地仍在';
messageTimer = 99999;
score += player.hp * 5 + FLAG.hp * 3;
}
}
// 站在旗边缓慢回血
if (!player.down && dist(player.x, player.y, FLAG.x, FLAG.y) < FLAG.r + 30) {
player.hp = Math.min(player.maxHp, player.hp + 0.04);
}
}
// ==================== 绘制 ====================
function drawBackground() {
// 夜色与血色交织
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, '#0d0808');
g.addColorStop(0.5, '#1a0d0a');
g.addColorStop(1, '#120806');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
// 远处血色雾气
const waveIntensity = Math.min(1, wave / 5);
const g2 = ctx.createRadialGradient(W / 2, H / 2, 60, W / 2, H / 2, 620);
g2.addColorStop(0, `rgba(120, 20, 15, ${0.12 + waveIntensity * 0.15})`);
g2.addColorStop(1, 'rgba(120, 20, 15, 0)');
ctx.fillStyle = g2;
ctx.fillRect(0, 0, W, H);
// 地面纹理点
ctx.fillStyle = 'rgba(60, 30, 20, 0.25)';
for (let i = 0; i < 80; i++) {
const x = (i * 137.5) % W;
const y = (i * 89.7) % H;
ctx.fillRect(x, y, 2, 2);
}
}
function drawBloodStains() {
for (const s of bloodStains) {
ctx.globalAlpha = s.a;
ctx.fillStyle = '#5a0808';
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawObstacles() {
for (const o of obstacles) {
ctx.fillStyle = '#2a1a14';
roundRect(o.x, o.y, o.w, o.h, 5);
ctx.fill();
ctx.fillStyle = '#3d261c';
roundRect(o.x + 3, o.y + 3, o.w - 6, 4, 2);
ctx.fill();
ctx.strokeStyle = 'rgba(80, 40, 30, 0.5)';
ctx.lineWidth = 1;
roundRect(o.x, o.y, o.w, o.h, 5);
ctx.stroke();
}
}
function drawFlag() {
const { x, y } = FLAG;
const hpRatio = FLAG.hp / FLAG.maxHp;
// 旗杆
ctx.strokeStyle = '#6b4a2a';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(x, y + 30);
ctx.lineTo(x, y - 55);
ctx.stroke();
// 旗面(动态飘动)
const t = frame * 0.06;
const wave1 = Math.sin(t) * 4;
const wave2 = Math.sin(t + 1.2) * 3;
ctx.save();
ctx.globalAlpha = 0.9;
const flagGrad = ctx.createLinearGradient(x, y - 55, x + 48, y - 5);
flagGrad.addColorStop(0, hpRatio > 0.4 ? '#c41e1e' : '#6a0e0e');
flagGrad.addColorStop(1, hpRatio > 0.4 ? '#8a1010' : '#3a0606');
ctx.fillStyle = flagGrad;
ctx.beginPath();
ctx.moveTo(x, y - 55);
ctx.quadraticCurveTo(x + 24, y - 58 + wave1, x + 48, y - 50 + wave2);
ctx.lineTo(x + 48, y - 12 + wave2);
ctx.quadraticCurveTo(x + 24, y - 10 + wave1, x, y - 5);
ctx.closePath();
ctx.fill();
// 五角星
ctx.fillStyle = '#ffd93d';
ctx.save();
ctx.translate(x + 22, y - 32 + wave1 * 0.5);
ctx.beginPath();
for (let i = 0; i < 10; i++) {
const r = i % 2 === 0 ? 9 : 4;
const a = -Math.PI / 2 + i * Math.PI / 5;
const px = Math.cos(a) * r;
const py = Math.sin(a) * r;
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
}
ctx.closePath();
ctx.fill();
ctx.restore();
ctx.restore();
// 旗帜血条
const barW = 70, barH = 6;
const bx = x - barW / 2, by = y + 38;
ctx.fillStyle = 'rgba(0,0,0,0.6)';
roundRect(bx, by, barW, barH, 3);
ctx.fill();
ctx.fillStyle = hpRatio > 0.5 ? '#e03030' : hpRatio > 0.25 ? '#d08020' : '#a01010';
roundRect(bx, by, barW * hpRatio, barH, 3);
ctx.fill();
ctx.strokeStyle = 'rgba(200, 80, 60, 0.6)';
ctx.lineWidth = 1;
roundRect(bx, by, barW, barH, 3);
ctx.stroke();
}
function drawPlayer() {
if (player.down) {
// 倒地状态
ctx.save();
ctx.globalAlpha = 0.7;
ctx.fillStyle = '#5a3030';
roundRect(player.x - 14, player.y - 8, 28, 16, 6);
ctx.fill();
ctx.restore();
// 倒地计时圈
const ratio = player.downTimer / 180;
ctx.strokeStyle = `rgba(255, ${100 + 100 * (1 - ratio)}, 50, 0.9)`;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(player.x, player.y, 22, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * ratio);
ctx.stroke();
return;
}
// 阴影
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.beginPath();
ctx.ellipse(player.x, player.y + 14, 12, 5, 0, 0, Math.PI * 2);
ctx.fill();
// 身体
const hurt = player.hurtTimer > 0 && Math.floor(player.hurtTimer / 3) % 2 === 0;
ctx.save();
if (hurt) ctx.globalAlpha = 0.6;
ctx.fillStyle = '#5a6a4a';
roundRect(player.x - player.w / 2, player.y - player.h / 2, player.w, player.h, 5);
ctx.fill();
// 军帽
ctx.fillStyle = '#3d4a30';
roundRect(player.x - 10, player.y - 16, 20, 8, 3);
ctx.fill();
// 红星
ctx.fillStyle = '#d02020';
ctx.beginPath();
ctx.arc(player.x, player.y - 12, 2.5, 0, Math.PI * 2);
ctx.fill();
// 枪
ctx.save();
ctx.translate(player.x, player.y);
ctx.rotate(player.facing);
ctx.fillStyle = '#3a2818';
ctx.fillRect(6, -2, 22, 4);
ctx.fillStyle = '#6a4a28';
ctx.fillRect(6, -1.5, 8, 3);
ctx.restore();
ctx.restore();
// 准星线
ctx.save();
ctx.globalAlpha = 0.25;
ctx.strokeStyle = '#ffcc66';
ctx.lineWidth = 1;
ctx.setLineDash([6, 8]);
ctx.beginPath();
ctx.moveTo(player.x, player.y);
ctx.lineTo(mouse.x, mouse.y);
ctx.stroke();
ctx.restore();
// 玩家血条
const hpRatio = player.hp / player.maxHp;
const barW = 36, barH = 4;
ctx.fillStyle = 'rgba(0,0,0,0.6)';
roundRect(player.x - barW / 2, player.y - 28, barW, barH, 2);
ctx.fill();
ctx.fillStyle = hpRatio > 0.5 ? '#50c050' : hpRatio > 0.25 ? '#d0a020' : '#d03030';
roundRect(player.x - barW / 2, player.y - 28, barW * hpRatio, barH, 2);
ctx.fill();
// 换弹进度
if (player.reloading) {
const r = 1 - player.reloadTimer / RELOAD_TIME;
ctx.strokeStyle = '#ffcc66';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(player.x, player.y, 24, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * r);
ctx.stroke();
}
}
function drawZombies() {
for (const z of zombies) {
// 阴影
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(z.x, z.y + 12, 10, 4, 0, 0, Math.PI * 2);
ctx.fill();
const hit = z.hitTimer > 0;
ctx.save();
if (hit) ctx.globalAlpha = 0.7;
// 身体颜色
let bodyColor = '#4a5a3a';
let headColor = '#5a6a4a';
if (z.type === 1) { bodyColor = '#6a3a2a'; headColor = '#7a4a3a'; }
if (z.type === 2) { bodyColor = '#3a3a3a'; headColor = '#4a4a4a'; }
// 身体
ctx.fillStyle = bodyColor;
roundRect(z.x - z.w / 2, z.y - z.h / 2, z.w, z.h, 4);
ctx.fill();
// 头
ctx.fillStyle = headColor;
ctx.beginPath();
ctx.arc(z.x, z.y - z.h / 2 - 2, 7, 0, Math.PI * 2);
ctx.fill();
// 眼睛(红色)
ctx.fillStyle = '#ff2020';
ctx.beginPath();
ctx.arc(z.x - 2.5, z.y - z.h / 2 - 3, 1.8, 0, Math.PI * 2);
ctx.arc(z.x + 2.5, z.y - z.h / 2 - 3, 1.8, 0, Math.PI * 2);
ctx.fill();
// 血迹
ctx.fillStyle = 'rgba(100, 10, 10, 0.6)';
ctx.beginPath();
ctx.arc(z.x + 3, z.y + 3, 3, 0, Math.PI * 2);
ctx.fill();
// 重装僵尸有护甲标记
if (z.type === 2) {
ctx.strokeStyle = '#888';
ctx.lineWidth = 1.5;
roundRect(z.x - z.w / 2 - 1, z.y - z.h / 2 - 1, z.w + 2, z.h + 2, 5);
ctx.stroke();
}
ctx.restore();
// 血条
if (z.hp < z.maxHp) {
const hpRatio = z.hp / z.maxHp;
const barW = 26, barH = 3;
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(z.x - barW / 2, z.y - z.h / 2 - 14, barW, barH);
ctx.fillStyle = z.type === 2 ? '#a06060' : '#d03030';
ctx.fillRect(z.x - barW / 2, z.y - z.h / 2 - 14, barW * hpRatio, barH);
}
}
}
function drawBullets() {
for (const b of bullets) {
ctx.save();
ctx.shadowColor = '#ffcc66';
ctx.shadowBlur = 8;
ctx.fillStyle = '#ffe080';
ctx.beginPath();
ctx.arc(b.x, b.y, 3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// 拖尾
ctx.strokeStyle = 'rgba(255, 200, 100, 0.4)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(b.x, b.y);
ctx.lineTo(b.x - b.vx * 1.5, b.y - b.vy * 1.5);
ctx.stroke();
}
}
function drawParticles() {
for (const p of particles) {
ctx.globalAlpha = Math.max(0, p.life);
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawHUD() {
ctx.save();
ctx.textBaseline = 'middle';
// 左上:波次
ctx.fillStyle = '#ffd0b0';
ctx.font = 'bold 20px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`第 ${wave} 波`, 24, 32);
// 弹药
const ammoStr = player.reloading ? '装填中…' : `${player.ammo} / ${MAG_SIZE}`;
ctx.fillStyle = player.ammo === 0 && !player.reloading ? '#ff5050' : '#e8d8c0';
ctx.font = 'bold 18px system-ui, -apple-system, sans-serif';
ctx.fillText(ammoStr, 24, 62);
// 换弹提示
if (player.ammo < MAG_SIZE && !player.reloading) {
ctx.fillStyle = 'rgba(255, 200, 100, 0.6)';
ctx.font = '14px system-ui, -apple-system, sans-serif';
ctx.fillText('按 R 装填', 24, 86);
}
// 右上:击杀/得分
ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(230, 210, 190, 0.85)';
ctx.font = 'bold 18px system-ui, -apple-system, sans-serif';
ctx.fillText(`击杀 ${kills}`, W - 24, 32);
ctx.fillStyle = 'rgba(230, 210, 190, 0.5)';
ctx.font = '15px system-ui, -apple-system, sans-serif';
ctx.fillText(`得分 ${score}`, W - 24, 58);
// 玩家血量
const hpRatio = player.hp / player.maxHp;
ctx.fillStyle = 'rgba(0,0,0,0.5)';
roundRect(W - 200, H - 40, 176, 16, 8);
ctx.fill();
ctx.fillStyle = hpRatio > 0.5 ? '#50c050' : hpRatio > 0.25 ? '#d0a020' : '#d03030';
roundRect(W - 200, H - 40, 176 * hpRatio, 16, 8);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = 'bold 12px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`生命 ${Math.ceil(player.hp)} / ${player.maxHp}`, W - 112, H - 32);
ctx.restore();
// 中央消息
if (messageTimer > 0 && message) {
ctx.save();
const a = Math.min(1, messageTimer / 40) * 0.9;
ctx.globalAlpha = a;
ctx.textAlign = 'center';
ctx.font = 'bold 32px system-ui, -apple-system, sans-serif';
ctx.fillStyle = '#ffd0b0';
ctx.shadowColor = '#8a1010';
ctx.shadowBlur = 16;
ctx.fillText(message, W / 2, H / 2 - 80);
ctx.restore();
}
// 操作提示(前几秒)
if (frame < 240 && state === 'playing') {
ctx.save();
ctx.globalAlpha = Math.max(0, 1 - frame / 240) * 0.55;
ctx.textAlign = 'center';
ctx.fillStyle = '#d0b8a0';
ctx.font = '15px system-ui, -apple-system, sans-serif';
ctx.fillText('WASD / 方向键移动 鼠标瞄准 左键开火 R 装填', W / 2, H - 24);
ctx.restore();
}
}
function drawEndScreen() {
if (state !== 'won' && state !== 'lost') return;
ctx.save();
ctx.fillStyle = state === 'won' ? 'rgba(10, 15, 8, 0.8)' : 'rgba(15, 5, 5, 0.82)';
ctx.fillRect(0, 0, W, H);
ctx.textAlign = 'center';
if (state === 'won') {
ctx.shadowColor = '#ffd93d';
ctx.shadowBlur = 26;
ctx.fillStyle = '#ffd93d';
ctx.font = 'bold 56px system-ui, -apple-system, sans-serif';
ctx.fillText('阵地仍在', W / 2, H / 2 - 70);
ctx.shadowBlur = 0;
ctx.fillStyle = '#e8d8c0';
ctx.font = 'bold 26px system-ui, -apple-system, sans-serif';
ctx.fillText(`击杀 ${kills} 得分 ${score}`, W / 2, H / 2 + 5);
ctx.fillStyle = 'rgba(230, 210, 190, 0.7)';
ctx.font = '18px system-ui, -apple-system, sans-serif';
ctx.fillText('大部队已经归来', W / 2, H / 2 + 55);
} else {
ctx.shadowColor = '#8a1010';
ctx.shadowBlur = 26;
ctx.fillStyle = '#d02020';
ctx.font = 'bold 56px system-ui, -apple-system, sans-serif';
ctx.fillText('红旗不倒', W / 2, H / 2 - 70);
ctx.shadowBlur = 0;
ctx.fillStyle = '#e8d8c0';
ctx.font = 'bold 26px system-ui, -apple-system, sans-serif';
ctx.fillText(`坚守至第 ${wave} 波 击杀 ${kills}`, W / 2, H / 2 + 5);
ctx.fillStyle = 'rgba(230, 210, 190, 0.7)';
ctx.font = '18px system-ui, -apple-system, sans-serif';
ctx.fillText('但火种已经留下', W / 2, H / 2 + 55);
}
const a = 0.5 + Math.sin(frame * 0.08) * 0.4;
ctx.fillStyle = `rgba(200, 180, 160, ${a})`;
ctx.font = 'bold 18px system-ui, -apple-system, sans-serif';
ctx.fillText('按 R 重新开始', W / 2, H / 2 + 120);
ctx.restore();
}
function render() {
ctx.save();
ctx.clearRect(0, 0, W, H);
if (shake > 0.4) {
ctx.translate((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake);
}
drawBackground();
drawBloodStains();
drawObstacles();
drawFlag();
drawZombies();
drawBullets();
drawPlayer();
drawParticles();
drawHUD();
drawEndScreen();
ctx.restore();
}
// ==================== 主循环 ====================
const STEP = 1000 / 60;
let lastTime = performance.now();
let acc = 0;
function loop(now) {
requestAnimationFrame(loop);
let delta = now - lastTime;
lastTime = now;
if (delta > 200) delta = 200;
acc += delta;
let guard = 0;
while (acc >= STEP && guard++ < 5) {
update();
acc -= STEP;
}
render();
}
// ==================== 输入 ====================
window.addEventListener('keydown', e => {
initAudio();
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) e.preventDefault();
if (!e.repeat) {
if (e.code === 'KeyR') {
if (state === 'won' || state === 'lost') init();
else startReload();
}
}
keys[e.code] = true;
});
window.addEventListener('keyup', e => { keys[e.code] = false; });
canvas.addEventListener('mousemove', e => {
const r = canvas.getBoundingClientRect();
mouse.x = (e.clientX - r.left) * (W / r.width);
mouse.y = (e.clientY - r.top) * (H / r.height);
});
canvas.addEventListener('mousedown', e => {
initAudio();
if (e.button === 0) mouse.down = true;
});
window.addEventListener('mouseup', () => { mouse.down = false; });
canvas.addEventListener('contextmenu', e => e.preventDefault());
// 触屏
const touchBar = document.getElementById('touch');
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
touchBar.style.display = 'block';
const bind = (id, code, isFire, isReload) => {
const el = document.getElementById(id);
const on = ev => {
ev.preventDefault();
initAudio();
if (isFire) mouse.down = true;
else if (isReload) {
if (state === 'won' || state === 'lost') init();
else startReload();
} else keys[code] = true;
};
const off = ev => {
ev.preventDefault();
if (isFire) mouse.down = false;
else keys[code] = false;
};
el.addEventListener('pointerdown', on);
el.addEventListener('pointerup', off);
el.addEventListener('pointercancel', off);
el.addEventListener('pointerleave', off);
};
bind('btnL', 'ArrowLeft', false, false);
bind('btnR', 'ArrowRight', false, false);
bind('btnRl', null, false, true);
bind('btnF', null, true, false);
// 触屏瞄准:滑动屏幕
canvas.addEventListener('touchmove', e => {
e.preventDefault();
const t = e.touches[0];
const r = canvas.getBoundingClientRect();
mouse.x = (t.clientX - r.left) * (W / r.width);
mouse.y = (t.clientY - r.top) * (H / r.height);
}, { passive: false });
canvas.addEventListener('touchstart', e => {
initAudio();
const t = e.touches[0];
const r = canvas.getBoundingClientRect();
mouse.x = (t.clientX - r.left) * (W / r.width);
mouse.y = (t.clientY - r.top) * (H / r.height);
}, { passive: true });
}
// ==================== 启动 ====================
init();
requestAnimationFrame(loop);
})();
</script>
</body>
</html>Game Source: 血染红旗
Creator: EpicCoder88
Libraries: none
Complexity: complex (1065 lines, 31.2 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: game-epiccoder88-murutl7p" to link back to the original. Then publish at arcadelab.ai/publish.