🎮ArcadeLab

血染红旗 - 测试版 v0.1

by TurboKoala83
960 lines40.6 KB
▶ Play
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>血染红旗 - 测试版 v0.1</title>
<style>
  body { margin: 0; background: #0a0a12; overflow: hidden; font-family: 'Microsoft YaHei', sans-serif; touch-action: none; }
  canvas { display: block; }
  .info { position: absolute; top: 8px; left: 50%; transform: translateX(-50%); color: #8A9A7A; font-size: 11px; letter-spacing: 3px; text-shadow: 0 0 10px rgba(138, 154, 122, 0.8); pointer-events: none; z-index: 10; }
</style>
</head>
<body>
<div class="info">★ 血染红旗 · 测试版 v0.1 ★</div>
<canvas id="game"></canvas>

<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');

let W = window.innerWidth;
let H = window.innerHeight;
canvas.width = W;
canvas.height = H;

window.addEventListener('resize', () => {
  W = window.innerWidth; H = window.innerHeight;
  canvas.width = W; canvas.height = H;
  joystick.baseX = 100; joystick.baseY = H - 120;
  if (!joystick.active) { joystick.thumbX = joystick.baseX; joystick.thumbY = joystick.baseY; }
});

// ============================================================
// 配色
// ============================================================
const Z = {
  uniform:'#5A6B4A', uniformDark:'#3D4A32', uniformLite:'#7A8B6A',
  skin:'#7A8A6A', skinDark:'#5A6A4A', skinRotten:'#4A5A3A',
  blood:'#4A1A1A', bloodLite:'#7A2A2A', bloodFresh:'#8A1A1A',
  bone:'#E8E0D0', helmet:'#4A4A4A', helmetDark:'#2A2A2A',
  helmetLite:'#6A6A6A', eye:'#FF3333', eyeDark:'#8A1A1A',
  strap:'#6B5A3A', bind:'#5A6A4A', shoe:'#1A1A1A',
};
const C = {
  uniform:'#5A6B7C', uniformDark:'#3D4A57', uniformLite:'#6E8090',
  collar:'#C41E3A', skin:'#E8B88A', skinDark:'#C99A6E',
  hat:'#4A5A6B', hatDark:'#37444F', bind:'#8A8A8A',
  straw:'#9A7A4A', strawDark:'#7A5A2A', belt:'#3A2A1A',
};

// ============================================================
// 尺寸
// ============================================================
const S = { headW:52, headH:52, torsoW:84, torsoH:76, armW:36, armH:76, legW:44, legH:84 };

// ============================================================
// 游戏状态
// ============================================================
const gameState = {
  player: {
    x: 0, y: 0,
    hp: 100,
    maxHp: 100,
    facing: 1,
    speed: 200,
    isHit: false, hitTime: 0,
    isAiming: false,
    killCount: 0,
  },
  camera: { x: 0, y: 0, zoom: 0.9, shakeX: 0, shakeY: 0 },
  zombies: [],
  bullets: [],
  bloodParticles: [],
  damageTexts: [],
  spawnTimer: 0,
  wave: 1,
  gameOver: false,
};

// 摇杆
const joystick = {
  active: false, touchId: null,
  baseX: 100, baseY: H - 120,
  thumbX: 100, thumbY: H - 120,
  maxRadius: 60,
  dx: 0, dy: 0,
};

const input = { aim: false, fire: false };

// 射击状态
const gunState = {
  isFiring: false, fireTime: 0,
  recoilX: 0, recoilY: 0, recoilRot: 0,
  flashTimer: 0, boltOffset: 0, shellEjected: false,
  shells: [], smokeParticles: [],
};

let lastTime = performance.now();
let time = 0;

// 工具函数
function lerp(a, b, t) { return (1 - t) * a + t * b; }
function easeInOut(t) { return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; }
function easeOut(t) { return 1 - Math.pow(1 - t, 3); }
function dist(x1, y1, x2, y2) { return Math.hypot(x2 - x1, y2 - y1); }
function roundRect(ctx, 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();
}

// ============================================================
// 声音引擎
// ============================================================
let audioCtx = null;
function initAudio() {
  if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  if (audioCtx.state === 'suspended') audioCtx.resume();
}
function playGunshot() {
  if (!audioCtx) return; const now = audioCtx.currentTime;
  const bs = audioCtx.sampleRate * 0.2;
  const buf = audioCtx.createBuffer(1, bs, audioCtx.sampleRate);
  const d = buf.getChannelData(0);
  for (let i = 0; i < bs; i++) d[i] = Math.random() * 2 - 1;
  const n = audioCtx.createBufferSource(); n.buffer = buf;
  const f = audioCtx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 800;
  const g = audioCtx.createGain(); g.gain.setValueAtTime(1, now); g.gain.exponentialRampToValueAtTime(0.01, now + 0.15);
  n.connect(f); f.connect(g); g.connect(audioCtx.destination); n.start(now);
  const o = audioCtx.createOscillator(); o.type = 'sine'; o.frequency.setValueAtTime(150, now); o.frequency.exponentialRampToValueAtTime(50, now + 0.1);
  const og = audioCtx.createGain(); og.gain.setValueAtTime(1, now); og.gain.exponentialRampToValueAtTime(0.01, now + 0.15);
  o.connect(og); og.connect(audioCtx.destination); o.start(now); o.stop(now + 0.2);
}
function playBoltAction() {
  if (!audioCtx) return; const now = audioCtx.currentTime;
  const bs = audioCtx.sampleRate * 0.05;
  const buf = audioCtx.createBuffer(1, bs, audioCtx.sampleRate);
  const d = buf.getChannelData(0);
  for (let i = 0; i < bs; i++) d[i] = Math.random() * 2 - 1;
  const n = audioCtx.createBufferSource(); n.buffer = buf;
  const f = audioCtx.createBiquadFilter(); f.type = 'bandpass'; f.frequency.value = 3000; f.Q.value = 2;
  const g = audioCtx.createGain(); g.gain.setValueAtTime(0.6, now); g.gain.exponentialRampToValueAtTime(0.01, now + 0.04);
  n.connect(f); f.connect(g); g.connect(audioCtx.destination); n.start(now);
}

// ============================================================
// 绘制红军战士(带受击 + 瞄准 + 持枪)
// ============================================================
function drawHead(ctx, x, y, rotation = 0) {
  ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
  const { headW, headH } = S; const headX = -headW / 2; const headY = -headH;
  ctx.fillStyle = C.skin; roundRect(ctx, headX, headY, headW, headH, 6); ctx.fill();
  ctx.fillStyle = C.skinDark; ctx.fillRect(headX, headY, 6, headH);
  ctx.fillStyle = '#2A1A0A'; ctx.fillRect(headX + 12, headY + 22, 7, 5); ctx.fillRect(headX + headW - 19, headY + 22, 7, 5);
  ctx.strokeStyle = '#2A1A0A'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(headX + 10, headY + 18); ctx.lineTo(headX + 20, headY + 20); ctx.stroke(); ctx.beginPath(); ctx.moveTo(headX + headW - 10, headY + 18); ctx.lineTo(headX + headW - 20, headY + 20); ctx.stroke();
  ctx.strokeStyle = '#8A5A3A'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(headX + 20, headY + 40); ctx.lineTo(headX + headW - 20, headY + 40); ctx.stroke();
  const hatW = headW + 14; const hatH = 22; const hatX = -hatW / 2; const hatY = headY - hatH + 4;
  ctx.fillStyle = C.hat; ctx.beginPath(); ctx.moveTo(hatX + 10, hatY); ctx.lineTo(hatX + hatW - 10, hatY); ctx.lineTo(hatX + hatW, hatY + 8); ctx.lineTo(hatX + hatW - 4, hatY + hatH); ctx.lineTo(hatX + 4, hatY + hatH); ctx.lineTo(hatX, hatY + 8); ctx.closePath(); ctx.fill();
  ctx.fillStyle = C.hatDark; ctx.fillRect(hatX + 2, hatY + hatH - 4, hatW - 4, 4);
  const starCY = hatY + 9, starR = 7; ctx.fillStyle = C.collar; ctx.beginPath(); for (let i = 0; i < 5; i++) { const a = (i * 72 - 90) * Math.PI / 180; const x1 = starR * Math.cos(a), y1 = starCY + starR * Math.sin(a); const a2 = ((i + 1) * 72 - 90) * Math.PI / 180; const ir = starR * 0.4; const x2 = ir * Math.cos(a2 - Math.PI / 5), y2 = starCY + ir * Math.sin(a2 - Math.PI / 5); if (i === 0) ctx.moveTo(x1, y1); else ctx.lineTo(x1, y1); ctx.lineTo(x2, y2); } ctx.closePath(); ctx.fill(); ctx.strokeStyle = '#8A0A1A'; ctx.lineWidth = 1; ctx.stroke();
  ctx.restore();
}
function drawTorso(ctx, x, y, rotation = 0) {
  ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
  const { torsoW, torsoH } = S; const torsoX = -torsoW / 2; const torsoY = -torsoH;
  ctx.fillStyle = C.uniform; roundRect(ctx, torsoX, torsoY, torsoW, torsoH, 4); ctx.fill();
  ctx.fillStyle = C.uniformDark; ctx.fillRect(torsoX, torsoY, 8, torsoH);
  ctx.fillStyle = C.uniformLite; ctx.fillRect(torsoX + torsoW - 6, torsoY, 6, torsoH);
  ctx.fillStyle = C.collar; ctx.fillRect(torsoX + 12, torsoY + 2, 14, 8); ctx.fillRect(torsoX + torsoW - 26, torsoY + 2, 14, 8);
  ctx.strokeStyle = C.uniformDark; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(0, torsoY + 12); ctx.lineTo(0, torsoY + torsoH - 4); ctx.stroke();
  const beltY = torsoY + torsoH * 0.55; ctx.fillStyle = C.belt; ctx.fillRect(torsoX, beltY, torsoW, 7);
  ctx.fillStyle = '#B8963A'; ctx.fillRect(-5, beltY + 1, 10, 5);
  ctx.restore();
}
function drawArm(ctx, x, y, rotation, side, scaleY = 1) {
  ctx.save(); ctx.translate(x, y); ctx.rotate(rotation); ctx.scale(1, scaleY);
  const { armW, armH } = S; const armX = -armW / 2;
  ctx.fillStyle = C.uniform; roundRect(ctx, armX, 0, armW, armH - 12, 3); ctx.fill();
  if (side === 'left') { ctx.fillStyle = C.uniformDark; ctx.fillRect(armX, 0, 5, armH - 12); }
  else { ctx.fillStyle = C.uniformLite; ctx.fillRect(armX + armW - 5, 0, 5, armH - 12); }
  ctx.fillStyle = C.skin; roundRect(ctx, armX + 4, armH - 14, armW - 8, 14, 3); ctx.fill();
  ctx.restore();
}
function drawLeg(ctx, x, y, rotation) {
  ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
  const { legW, legH } = S; const legX = -legW / 2;
  ctx.fillStyle = C.uniform; roundRect(ctx, legX, 0, legW, legH * 0.55, 3); ctx.fill();
  ctx.fillStyle = C.uniformDark; ctx.fillRect(legX, 0, 6, legH * 0.55);
  const bindY = legH * 0.5; const bindH = legH * 0.42;
  ctx.fillStyle = C.bind; roundRect(ctx, legX - 1, bindY, legW + 2, bindH, 2); ctx.fill();
  ctx.strokeStyle = '#6A6A6A'; ctx.lineWidth = 1.2;
  for (let i = 0; i < 5; i++) { const yy = bindY + 4 + i * (bindH - 8) / 4; ctx.beginPath(); ctx.moveTo(legX - 1, yy); ctx.lineTo(legX + legW + 1, yy); ctx.stroke(); }
  const shoeY = bindY + bindH; const shoeH = legH * 0.08;
  ctx.fillStyle = C.straw; roundRect(ctx, legX - 3, shoeY, legW + 6, shoeH, 3); ctx.fill();
  ctx.strokeStyle = C.strawDark; ctx.lineWidth = 1;
  for (let i = 0; i < 3; i++) { ctx.beginPath(); ctx.moveTo(legX - 3, shoeY + 2 + i * 2); ctx.lineTo(legX + legW + 3, shoeY + 2 + i * 2); ctx.stroke(); }
  ctx.restore();
}

function drawHanyangRifle(ctx) {
  ctx.fillStyle = '#5E3A20'; ctx.beginPath(); ctx.moveTo(-30, -6); ctx.lineTo(-160, -6); ctx.lineTo(-165, 20); ctx.lineTo(-130, 25); ctx.lineTo(-30, 15); ctx.closePath(); ctx.fill();
  ctx.fillStyle = '#3A2210'; ctx.fillRect(-160, 18, 130, 7); ctx.fillStyle = '#4A2E15'; ctx.fillRect(-160, -6, 130, 3);
  ctx.fillStyle = '#2A2A2A'; ctx.beginPath(); ctx.moveTo(-160, -6); ctx.lineTo(-165, 18); ctx.lineTo(-160, 20); ctx.lineTo(-155, 2); ctx.closePath(); ctx.fill();
  ctx.fillStyle = 'rgba(0,0,0,0.15)'; ctx.beginPath(); ctx.arc(-70, 18, 12, 0, Math.PI, true); ctx.fill();
  ctx.fillStyle = '#5E3A20'; ctx.fillRect(-30, -6, 215, 16);
  ctx.fillStyle = '#8A5A3A'; ctx.fillRect(-30, -6, 215, 3); ctx.fillStyle = '#3A2210'; ctx.fillRect(-30, 7, 215, 3);
  ctx.fillStyle = '#4A2E15'; ctx.fillRect(-30, -18, 195, 12); ctx.fillStyle = '#1A1A1A'; ctx.fillRect(-30, -6, 215, 2);
  [60, 150].forEach(x => { ctx.fillStyle = '#333333'; ctx.fillRect(x, -20, 8, 32); ctx.fillStyle = '#666666'; ctx.fillRect(x, -20, 2, 32); });
  ctx.fillStyle = '#5A5A5A'; ctx.fillRect(185, -5, 30, 10); ctx.fillStyle = '#9A9A9A'; ctx.fillRect(185, -5, 30, 2);
  ctx.fillStyle = '#2A2A2A'; ctx.fillRect(210, -7, 5, 14);
  ctx.fillStyle = '#555555'; ctx.fillRect(200, -12, 6, 5); ctx.fillStyle = '#888888'; ctx.beginPath(); ctx.moveTo(202, -12); ctx.lineTo(205, -12); ctx.lineTo(203.5, -20); ctx.closePath(); ctx.fill();
  ctx.fillStyle = '#2A2A2A'; ctx.fillRect(-30, -12, 35, 24); ctx.fillStyle = '#666666'; ctx.fillRect(-30, -12, 35, 2);
  ctx.save(); ctx.translate(gunState.boltOffset, 0);
  ctx.fillStyle = '#4A4A4A'; ctx.fillRect(-10, -24, 6, 12); ctx.fillStyle = '#888888'; ctx.beginPath(); ctx.arc(-7, -24, 6, 0, Math.PI * 2); ctx.fill();
  ctx.restore();
  ctx.strokeStyle = '#555555'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(0, 12, 10, 0, Math.PI); ctx.stroke(); ctx.fillStyle = '#333333'; ctx.fillRect(-2, 8, 4, 12);
  ctx.fillStyle = '#333333'; ctx.fillRect(20, -18, 12, 6); ctx.fillStyle = '#666666'; ctx.fillRect(24, -32, 4, 14); ctx.fillStyle = '#1A1A1A'; ctx.fillRect(25, -32, 2, 4);
  ctx.fillStyle = '#222222'; ctx.beginPath(); ctx.arc(64, 10, 4, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(-145, 25, 4, 0, Math.PI * 2); ctx.fill();
  ctx.strokeStyle = '#5A4A3A'; ctx.lineWidth = 5; ctx.beginPath(); ctx.moveTo(-145, 25); ctx.quadraticCurveTo(-50, 65, 20, 40); ctx.lineTo(64, 10); ctx.stroke();
}

function drawSoldier(ctx, worldX, worldY, scale, anim) {
  const p = gameState.player;
  const a = {
    bob: anim.bob || 0, lean: anim.lean || 0, bodyRotation: anim.bodyRotation || 0,
    leftLegRot: anim.leftLegRot || 0, rightLegRot: anim.rightLegRot || 0,
    leftArmRot: anim.leftArmRot || 0, rightArmRot: anim.rightArmRot || 0,
    facing: anim.facing || 1,
    recoilX: 0, hitLean: 0, hitRecoilX: 0, headHitRot: 0,
  };

  // 计算受击进度
  if (p.isHit) {
    const t = Math.min(1, (performance.now() - p.hitTime) / 700);
    if (t < 0.2) { const k = t / 0.2; a.hitLean = lerp(0, -0.35, k); a.headHitRot = lerp(0, 0.35, k); a.hitRecoilX = lerp(0, -20, k); }
    else if (t < 0.5) { const k = (t - 0.2) / 0.3; a.hitLean = lerp(-0.35, -0.15, easeInOut(k)); a.headHitRot = lerp(0.35, 0.1, easeInOut(k)); a.hitRecoilX = lerp(-20, -8, easeInOut(k)); }
    else { const k = (t - 0.5) / 0.5; a.hitLean = lerp(-0.15, 0, easeOut(k)); a.headHitRot = lerp(0.1, 0, easeOut(k)); a.hitRecoilX = lerp(-8, 0, easeOut(k)); }
    if (t >= 1) p.isHit = false;
  }

  const shoulderY = -S.torsoH;
  const headY = shoulderY;
  const legSpacing = S.torsoW / 4 - 6;
  const armSpacing = S.torsoW / 2 + S.armW / 2 - 2;

  ctx.save();
  ctx.translate(worldX + a.hitRecoilX * a.facing, worldY + a.bob);
  ctx.scale(a.facing * scale, scale);
  ctx.rotate(a.lean + a.hitLean);
  ctx.rotate(a.bodyRotation);

  drawLeg(ctx, -legSpacing, 0, a.leftLegRot);
  drawLeg(ctx, legSpacing, 0, a.rightLegRot);
  drawTorso(ctx, 0, 0, 0);
  drawArm(ctx, armSpacing, shoulderY, a.rightArmRot, 'right', 0.85);
  drawHead(ctx, 0, headY, a.headHitRot);

  // 计算后手锚点
  const handLY = (S.armH - 6);
  const armC = Math.cos(a.leftArmRot); const armS = Math.sin(a.leftArmRot);
  const localX = -armSpacing - handLY * armS;
  const localY = shoulderY + handLY * armC;
  const bodyC = Math.cos(a.bodyRotation); const bodyS = Math.sin(a.bodyRotation);
  const rotX = localX * bodyC - localY * bodyS;
  const rotY = localX * bodyS + localY * bodyC;
  const leftHandX = worldX + (a.hitRecoilX + rotX) * a.facing * scale;
  const leftHandY = worldY + a.bob + (rotY) * scale;

  ctx.restore();

  // 绘制枪(挂载在手部)
  const aimP = p.isAiming ? 1 : 0;
  const gunOffsetX = lerp(0, 15, aimP) * a.facing;
  const gunOffsetY = lerp(0, -18, aimP);

  ctx.save();
  ctx.translate(leftHandX + gunOffsetX, leftHandY + gunOffsetY);
  ctx.scale(a.facing, 1);
  ctx.rotate(lerp(-0.05, 0, aimP));
  ctx.save();
  ctx.translate(gunState.recoilX, -gunState.recoilY); ctx.rotate(gunState.recoilRot);
  drawHanyangRifle(ctx);
  ctx.restore();
  // 火光
  if (gunState.flashTimer > 0) {
    const fa = gunState.flashTimer / 0.06;
    ctx.save(); ctx.globalAlpha = fa; ctx.translate(200, -5);
    ctx.fillStyle = '#FFFF00'; ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(30,-15); ctx.lineTo(15,0); ctx.lineTo(35,15); ctx.lineTo(0,5); ctx.closePath(); ctx.fill();
    ctx.fillStyle = '#FFAA00'; ctx.beginPath(); ctx.arc(5,0,15,0,Math.PI*2); ctx.fill();
    ctx.fillStyle = '#FFFFFF'; ctx.beginPath(); ctx.arc(5,0,8,0,Math.PI*2); ctx.fill();
    ctx.restore();
  }
  // 烟雾
  gunState.smokeParticles.forEach(pt => {
    ctx.save(); ctx.globalAlpha = pt.alpha; ctx.fillStyle = '#AAAAAA';
    ctx.fillRect(pt.x - pt.size/2, pt.y - pt.size/2, pt.size, pt.size);
    ctx.restore();
  });
  // 弹壳
  gunState.shells.forEach(s => {
    ctx.save(); ctx.translate(s.x, s.y); ctx.rotate(s.rotation);
    ctx.fillStyle = '#D4AF37'; ctx.fillRect(-4,-2,8,4);
    ctx.fillStyle = '#B8963A'; ctx.fillRect(-4,-2,2,4);
    ctx.restore();
  });
  ctx.restore();
}

// ============================================================
// 绘制僵尸
// ============================================================
function drawZombieHead(ctx, x, y, rot = 0) {
  ctx.save(); ctx.translate(x, y); ctx.rotate(rot);
  const { headW, headH } = S; const headX = -headW/2; const headY = -headH;
  ctx.fillStyle = Z.skinRotten; ctx.fillRect(-8,-8,16,8);
  ctx.fillStyle = Z.skin; roundRect(ctx, headX, headY, headW, headH, 6); ctx.fill();
  ctx.fillStyle = Z.skinDark; ctx.fillRect(headX, headY, 6, headH);
  ctx.fillStyle = Z.skinRotten; ctx.fillRect(headX+6, headY+2, headW-12, 8);
  ctx.fillStyle = '#1A1A1A'; ctx.fillRect(headX+10, headY+20, 9, 7);
  ctx.fillStyle = Z.eye; ctx.fillRect(headX+12, headY+22, 5, 3);
  ctx.fillStyle = Z.eyeDark; ctx.fillRect(headX+12, headY+22, 2, 1);
  ctx.fillStyle = Z.blood; ctx.fillRect(headX+headW-19, headY+19, 9, 8);
  ctx.fillStyle = Z.bloodFresh; ctx.fillRect(headX+headW-18, headY+21, 7, 4);
  ctx.fillStyle = Z.blood; ctx.beginPath(); ctx.moveTo(headX+12,headY+36); ctx.lineTo(headX+20,headY+33); ctx.lineTo(headX+28,headY+38); ctx.lineTo(headX+36,headY+34); ctx.lineTo(headX+40,headY+40); ctx.lineTo(headX+30,headY+43); ctx.lineTo(headX+18,headY+42); ctx.closePath(); ctx.fill();
  ctx.fillStyle = Z.bone; ctx.fillRect(headX+18, headY+36, 4, 4); ctx.fillRect(headX+24, headY+37, 4, 3); ctx.fillRect(headX+30, headY+36, 4, 4);
  const hw = headW+20, hh = 30, hx = -hw/2, hy = headY - hh + 6;
  ctx.fillStyle = Z.helmet; ctx.beginPath(); ctx.moveTo(hx+10,hy); ctx.quadraticCurveTo(hx+hw/2, hy-8, hx+hw-10, hy); ctx.quadraticCurveTo(hx+hw, hy+10, hx+hw-6, hy+hh); ctx.lineTo(hx+6, hy+hh); ctx.quadraticCurveTo(hx, hy+10, hx+10, hy); ctx.closePath(); ctx.fill();
  ctx.fillStyle = Z.helmetDark; ctx.beginPath(); ctx.moveTo(hx, hy+8); ctx.lineTo(hx+14, hy+2); ctx.lineTo(hx+14, hy+hh); ctx.lineTo(hx+4, hy+hh); ctx.closePath(); ctx.fill();
  ctx.fillStyle = Z.helmetLite; ctx.beginPath(); ctx.moveTo(hx+18, hy+2); ctx.quadraticCurveTo(hx+hw/2, hy-6, hx+hw-18, hy+2); ctx.lineTo(hx+hw-22, hy+6); ctx.quadraticCurveTo(hx+hw/2, hy-2, hx+22, hy+6); ctx.closePath(); ctx.fill();
  ctx.fillStyle = '#2A2A2A'; ctx.beginPath(); ctx.moveTo(hx+6, hy+hh-4); ctx.lineTo(hx+hw-6, hy+hh-4); ctx.lineTo(hx+hw-4, hy+hh); ctx.lineTo(hx+4, hy+hh); ctx.closePath(); ctx.fill();
  ctx.fillStyle = '#1A1A1A'; ctx.beginPath(); ctx.arc(hx+hw-15, hy+12, 5, 0, Math.PI*2); ctx.fill();
  ctx.fillStyle = Z.blood; ctx.fillRect(hx+hw-17, hy+14, 3, 8);
  ctx.restore();
}
function drawZombieTorso(ctx, x, y, rot = 0) {
  ctx.save(); ctx.translate(x, y); ctx.rotate(rot);
  const { torsoW, torsoH } = S; const torsoX = -torsoW/2; const torsoY = -torsoH;
  ctx.fillStyle = Z.uniform; roundRect(ctx, torsoX, torsoY, torsoW, torsoH, 4); ctx.fill();
  ctx.fillStyle = Z.uniformDark; ctx.fillRect(torsoX, torsoY, 8, torsoH);
  ctx.fillStyle = Z.uniformLite; ctx.fillRect(torsoX+torsoW-6, torsoY, 6, torsoH);
  ctx.fillStyle = '#4A5A6A'; ctx.fillRect(torsoX+12, torsoY+2, 14, 8); ctx.fillRect(torsoX+torsoW-26, torsoY+2, 14, 8);
  const beltY = torsoY + torsoH*0.55; ctx.fillStyle = '#2A1A0A'; ctx.fillRect(torsoX, beltY, torsoW, 7);
  ctx.strokeStyle = Z.strap; ctx.lineWidth = 6;
  ctx.beginPath(); ctx.moveTo(torsoX+8, torsoY+4); ctx.lineTo(torsoX+torsoW-8, beltY+3); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(torsoX+torsoW-8, torsoY+4); ctx.lineTo(torsoX+8, beltY+3); ctx.stroke();
  ctx.fillStyle = '#5A4A3A'; ctx.fillRect(torsoX+20, torsoY+20, 10, 12); ctx.fillRect(torsoX+20, torsoY+36, 10, 12);
  ctx.fillRect(torsoX+torsoW-30, torsoY+20, 10, 12); ctx.fillRect(torsoX+torsoW-30, torsoY+36, 10, 12);
  ctx.fillStyle = Z.blood; ctx.beginPath(); ctx.moveTo(torsoX+torsoW-25, torsoY+15); ctx.lineTo(torsoX+torsoW-15, torsoY+20); ctx.lineTo(torsoX+torsoW-20, torsoY+30); ctx.closePath(); ctx.fill();
  ctx.restore();
}
function drawZombieArm(ctx, x, y, rot = 0, side = 'left') {
  ctx.save(); ctx.translate(x, y); ctx.rotate(rot);
  const { armW, armH } = S; const armX = -armW/2;
  ctx.fillStyle = Z.uniform; roundRect(ctx, armX, 0, armW, armH-12, 3); ctx.fill();
  if (side === 'left') { ctx.fillStyle = Z.uniformDark; ctx.fillRect(armX, 0, 5, armH-12); }
  else { ctx.fillStyle = Z.uniformLite; ctx.fillRect(armX+armW-5, 0, 5, armH-12); }
  ctx.fillStyle = Z.skin; roundRect(ctx, armX+4, armH-14, armW-8, 14, 3); ctx.fill();
  ctx.strokeStyle = Z.skinDark; ctx.lineWidth = 2;
  for (let i = 0; i < 3; i++) { ctx.beginPath(); ctx.moveTo(armX+8+i*6, armH); ctx.lineTo(armX+8+i*6, armH+8); ctx.stroke(); }
  ctx.restore();
}
function drawZombieLeg(ctx, x, y, rot = 0, side = 'left') {
  ctx.save(); ctx.translate(x, y); ctx.rotate(rot);
  const { legW, legH } = S; const legX = -legW/2;
  ctx.fillStyle = Z.uniform; roundRect(ctx, legX, 0, legW, legH*0.55, 3); ctx.fill();
  ctx.fillStyle = Z.uniformDark; ctx.fillRect(legX, 0, 6, legH*0.55);
  const bindY = legH*0.5; const bindH = legH*0.42;
  ctx.fillStyle = Z.bind; roundRect(ctx, legX-1, bindY, legW+2, bindH, 2); ctx.fill();
  const shoeY = bindY+bindH; const shoeH = legH*0.08;
  ctx.fillStyle = Z.shoe; roundRect(ctx, legX-3, shoeY, legW+6, shoeH, 3); ctx.fill();
  ctx.restore();
}

function drawZombie(ctx, z, scale = 1) {
  const t = time;
  const phase = t * 3.5 + (z.seed || 0) * 10;
  const leftLegRot = Math.sin(phase) * 0.35;
  const rightLegRot = Math.sin(phase - Math.PI * 0.7) * 0.25 - 0.1;
  const leftArmRot = -1.2 + Math.sin(phase) * 0.1;
  const rightArmRot = -1.0 + Math.sin(phase - Math.PI * 0.7) * 0.1;
  const bob = Math.abs(Math.sin(phase)) * 4.0;
  const headRot = Math.sin(phase * 3) * 0.08;

  let lungeOffset = 0, bodyPitch = 0, headPitch = 0, armForward = 0, legSplit = 0;

  if (z.state === 'attack') {
    const atkProgress = Math.min(1, (performance.now() - z.attackStartTime) / 800);
    if (atkProgress < 0.3) {
      const k = atkProgress / 0.3;
      lungeOffset = lerp(0, -15, k); bodyPitch = lerp(0, -0.3, k); headPitch = lerp(0, 0.2, k);
      armForward = lerp(0, -0.3, k); legSplit = lerp(0, 15, k);
    } else if (atkProgress < 0.5) {
      const k = (atkProgress - 0.3) / 0.2;
      lungeOffset = lerp(-15, 60, easeInOut(k)); bodyPitch = lerp(-0.3, 0.5, easeInOut(k));
      headPitch = lerp(0.2, 0.4, easeInOut(k)); armForward = lerp(-0.3, 1.5, easeInOut(k));
      legSplit = lerp(15, -10, easeInOut(k));
    } else if (atkProgress < 0.7) {
      const k = (atkProgress - 0.5) / 0.2;
      lungeOffset = lerp(60, 65, k); bodyPitch = lerp(0.5, 0.6, k);
      headPitch = lerp(0.4, -0.5, k); armForward = lerp(1.5, 1.8, k);
    } else {
      const k = (atkProgress - 0.7) / 0.3;
      lungeOffset = lerp(65, 0, easeInOut(k)); bodyPitch = lerp(0.6, 0, easeInOut(k));
      headPitch = lerp(-0.5, 0, easeInOut(k)); armForward = lerp(1.8, 0, easeInOut(k));
    }
    if (atkProgress >= 1) z.state = 'walk';
  }

  const shoulderY = -S.torsoH; const headY = shoulderY;
  const legSpacing = S.torsoW/4 - 4; const armSpacing = S.torsoW/2 + S.armW/2 - 2;

  ctx.save();
  ctx.translate(z.x + lungeOffset * z.facing * scale, z.y + bob);
  ctx.scale(z.facing * scale, scale);
  ctx.rotate(bodyPitch);

  drawZombieLeg(ctx, -legSpacing, 0, leftLegRot + legSplit*0.05, 'left');
  drawZombieLeg(ctx, legSpacing, 0, rightLegRot - legSplit*0.05, 'right');
  drawZombieTorso(ctx, 0, 0, 0);
  drawZombieArm(ctx, armSpacing, shoulderY, rightArmRot - armForward, 'right');
  ctx.save(); ctx.translate(0, headY); ctx.rotate(headPitch); ctx.translate(0, -headY);
  drawZombieHead(ctx, 0, headY, headRot); ctx.restore();
  drawZombieArm(ctx, -armSpacing, shoulderY, leftArmRot - armForward, 'left');

  ctx.restore();

  // 血条
  if (z.hp < z.maxHp) {
    const barW = 50, barH = 5;
    const bx = z.x - barW/2, by = z.y - 200;
    ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fillRect(bx-1, by-1, barW+2, barH+2);
    ctx.fillStyle = '#8A1A1A'; ctx.fillRect(bx, by, barW, barH);
    ctx.fillStyle = '#C41E3A'; ctx.fillRect(bx, by, barW * (z.hp / z.maxHp), barH);
  }
}

// ============================================================
// 生成僵尸
// ============================================================
function spawnZombie() {
  const p = gameState.player;
  // 在玩家周围随机距离生成
  const angle = Math.random() * Math.PI * 2;
  const range = 500 + Math.random() * 200;
  const z = {
    x: p.x + Math.cos(angle) * range,
    y: p.y + Math.sin(angle) * range * 0.3,
    hp: 60, maxHp: 60,
    speed: 50 + Math.random() * 30,
    state: 'walk',
    attackStartTime: 0,
    attackCooldown: 0,
    facing: 1,
    seed: Math.random(),
    hasHitPlayer: false,
  };
  gameState.zombies.push(z);
}

// ============================================================
// 射击
// ============================================================
function fire() {
  if (gunState.isFiring) return;
  const p = gameState.player;
  gunState.isFiring = true;
  gunState.fireTime = performance.now();
  gunState.flashTimer = 0.06;
  gunState.boltOffset = 0;
  gunState.shellEjected = false;

  playGunshot();
  gameState.camera.shakeX = (Math.random() - 0.5) * 10;
  gameState.camera.shakeY = (Math.random() - 0.5) * 10;

  // 子弹
  const dir = p.facing;
  gameState.bullets.push({
    x: p.x + dir * 200,
    y: p.y - 100,
    vx: dir * 1500,
    life: 1.0,
  });

  for (let i = 0; i < 5; i++) {
    gunState.smokeParticles.push({
      x: 200 + (Math.random()-0.5)*10, y: -5 + (Math.random()-0.5)*10,
      vx: 2 + Math.random()*4, vy: -1 - Math.random()*3,
      size: 5 + Math.random()*10, alpha: 0.8, life: 1.0, decay: 0.02 + Math.random()*0.03,
    });
  }
}

function updateGun(dt) {
  if (!gunState.isFiring) return;
  const now = performance.now();
  const elapsed = now - gunState.fireTime;

  if (elapsed < 150) {
    const t = elapsed / 150;
    gunState.recoilX = -15 * Math.sin(t * Math.PI);
    gunState.recoilY = 8 * Math.sin(t * Math.PI);
    gunState.recoilRot = 0.08 * Math.sin(t * Math.PI);
  } else {
    const t = Math.min((elapsed - 150) / 200, 1);
    const eo = 1 - Math.pow(1 - t, 3);
    gunState.recoilX = -15 * (1 - eo);
    gunState.recoilY = 8 * (1 - eo);
    gunState.recoilRot = 0.08 * (1 - eo);
  }

  if (gunState.flashTimer > 0) gunState.flashTimer -= dt;

  if (elapsed < 80) gunState.boltOffset = 10 * (elapsed / 80);
  else if (elapsed < 150) {
    gunState.boltOffset = 10;
    if (!gunState.shellEjected) {
      playBoltAction();
      gunState.shells.push({ x: 3, y: -24, vx: 2 + Math.random()*3, vy: -6 - Math.random()*4, rotation: Math.random()*Math.PI, vRot: (Math.random()-0.5)*0.4, bounce: 0, life: 1.0 });
      gunState.shellEjected = true;
    }
  } else if (elapsed < 300) gunState.boltOffset = 10 * (1 - ((elapsed - 150) / 150));
  else gunState.boltOffset = 0;

  for (let i = gunState.shells.length - 1; i >= 0; i--) {
    const s = gunState.shells[i];
    s.vy += 0.4; s.x += s.vx; s.y += s.vy; s.rotation += s.vRot;
    if (s.y > 40 && s.vy > 0) { s.y = 40; s.vy *= -0.4; s.vx *= 0.7; s.vRot *= -0.6; s.bounce++; }
    if (s.bounce > 2 || s.y > 100) gunState.shells.splice(i, 1);
  }
  for (let i = gunState.smokeParticles.length - 1; i >= 0; i--) {
    const pt = gunState.smokeParticles[i];
    pt.x += pt.vx; pt.y += pt.vy; pt.vy -= 0.1; pt.vx *= 0.96; pt.size += 0.5;
    pt.alpha -= pt.decay; pt.life -= pt.decay;
    if (pt.alpha <= 0 || pt.life <= 0) gunState.smokeParticles.splice(i, 1);
  }

  if (elapsed > 500 && gunState.flashTimer <= 0 && gunState.shells.length === 0 && gunState.smokeParticles.length === 0) {
    gunState.isFiring = false;
    gunState.recoilX = 0; gunState.recoilY = 0; gunState.recoilRot = 0;
  }
}

// ============================================================
// 主循环
// ============================================================
function animate(now) {
  const dt = (now - lastTime) / 1000; lastTime = now;
  if (dt > 0.1) return;
  time += dt;

  const p = gameState.player;
  const cam = gameState.camera;

  if (!gameState.gameOver) {
    // ===== 输入 =====
    let vx = joystick.dx * p.speed;
    let vy = joystick.dy * p.speed;

    if (p.isAiming) { vx *= 0.2; vy *= 0.2; }

    const mag = Math.hypot(vx, vy);
    if (mag > p.speed) { vx = (vx/mag)*p.speed; vy = (vy/mag)*p.speed; }

    p.x += vx * dt;
    p.y += vy * dt;

    if (vx > 10) p.facing = 1;
    if (vx < -10) p.facing = -1;

    p.isAiming = input.aim;

    // ===== 僵尸 AI =====
    gameState.zombies.forEach(z => {
      if (z.state === 'attack') return;
      const dx = p.x - z.x;
      const dy = p.y - z.y;
      const d = Math.hypot(dx, dy);
      if (d < 150 && z.attackCooldown <= 0) {
        z.state = 'attack';
        z.attackStartTime = performance.now();
        z.attackCooldown = 1500;
        z.hasHitPlayer = false;
        // 在攻击后半段造成伤害
        setTimeout(() => {
          if (z.hasHitPlayer) return;
          const dd = Math.hypot(p.x - z.x, p.y - z.y);
          if (dd < 250) {
            p.hp -= 15;
            p.isHit = true;
            p.hitTime = performance.now();
            gameState.camera.shakeX = (Math.random() - 0.5) * 20;
            gameState.camera.shakeY = (Math.random() - 0.5) * 20;
            // 伤害飘字
            gameState.damageTexts.push({ x: p.x, y: p.y - 200, text: '-15', life: 1.0, color: '#FF3333' });
          }
          z.hasHitPlayer = true;
        }, 500);
      } else if (d > 0) {
        const speed = z.speed * (z.state === 'walk' ? 1 : 0);
        z.x += (dx / d) * speed * dt;
        z.y += (dy / d) * speed * dt;
        z.facing = dx > 0 ? 1 : -1;
      }
      if (z.attackCooldown > 0) z.attackCooldown -= dt * 1000;
    });

    // 移除死亡僵尸
    for (let i = gameState.zombies.length - 1; i >= 0; i--) {
      if (gameState.zombies[i].hp <= 0) {
        gameState.zombies.splice(i, 1);
        p.killCount++;
      }
    }

    // ===== 子弹 =====
    for (let i = gameState.bullets.length - 1; i >= 0; i--) {
      const b = gameState.bullets[i];
      b.x += b.vx * dt;
      b.life -= dt;
      let hit = false;
      for (let j = gameState.zombies.length - 1; j >= 0; j--) {
        const z = gameState.zombies[j];
        if (Math.abs(b.x - z.x) < 60 && Math.abs(b.y - (z.y - 100)) < 100) {
          z.hp -= 40;
          gameState.damageTexts.push({ x: z.x, y: z.y - 220, text: '-40', life: 1.0, color: '#FFAA00' });
          for (let k = 0; k < 8; k++) {
            gameState.bloodParticles.push({
              x: z.x, y: z.y - 100,
              vx: (Math.random()-0.5)*300, vy: -Math.random()*300,
              life: 0.6, size: 3 + Math.random()*4,
            });
          }
          hit = true;
          break;
        }
      }
      if (hit || b.life <= 0) gameState.bullets.splice(i, 1);
    }

    // ===== 血粒子 =====
    for (let i = gameState.bloodParticles.length - 1; i >= 0; i--) {
      const bp = gameState.bloodParticles[i];
      bp.vy += 800 * dt;
      bp.x += bp.vx * dt;
      bp.y += bp.vy * dt;
      bp.life -= dt;
      if (bp.life <= 0) gameState.bloodParticles.splice(i, 1);
    }

    // ===== 伤害飘字 =====
    for (let i = gameState.damageTexts.length - 1; i >= 0; i--) {
      const t = gameState.damageTexts[i];
      t.y -= 40 * dt;
      t.life -= dt;
      if (t.life <= 0) gameState.damageTexts.splice(i, 1);
    }

    // ===== 生成僵尸 =====
    gameState.spawnTimer -= dt;
    if (gameState.spawnTimer <= 0 && gameState.zombies.length < 8) {
      spawnZombie();
      gameState.spawnTimer = 2.0 + Math.random() * 2.0;
    }

    // ===== 游戏结束 =====
    if (p.hp <= 0) {
      p.hp = 0;
      gameState.gameOver = true;
    }

    // ===== 摄像机跟随 =====
    cam.x = lerp(cam.x, p.x, 0.08);
    cam.y = lerp(cam.y, p.y, 0.08);
  }

  updateGun(dt);

  // ===== 渲染 =====
  ctx.clearRect(0, 0, W, H);
  ctx.fillStyle = '#0A0A12';
  ctx.fillRect(0, 0, W, H);

  // 世界坐标变换
  ctx.save();
  ctx.translate(W/2, H/2 + 80);
  ctx.scale(cam.zoom, cam.zoom);
  ctx.translate(-cam.x + cam.shakeX, -cam.y + cam.shakeY);

  // 地面
  ctx.fillStyle = '#0F1A0F';
  ctx.fillRect(cam.x - W, cam.y - H, W * 2, H * 2);

  // 网格
  const gridSize = 100;
  ctx.strokeStyle = 'rgba(74, 90, 74, 0.15)';
  ctx.lineWidth = 1;
  const startX = Math.floor((cam.x - W) / gridSize) * gridSize;
  const startY = Math.floor((cam.y - H) / gridSize) * gridSize;
  for (let x = startX; x < cam.x + W; x += gridSize) {
    for (let y = startY; y < cam.y + H; y += gridSize) {
      ctx.strokeRect(x, y, gridSize, gridSize);
    }
  }

  // 血迹地面
  ctx.fillStyle = 'rgba(74, 26, 26, 0.15)';
  for (let x = startX; x < cam.x + W; x += gridSize * 3) {
    for (let y = startY; y < cam.y + H; y += gridSize * 3) {
      const seed = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453;
      const rand = seed - Math.floor(seed);
      if (rand > 0.8) {
        ctx.beginPath();
        ctx.ellipse(x + 50, y + 50, 30, 10, 0, 0, Math.PI * 2);
        ctx.fill();
      }
    }
  }

  // 血粒子
  gameState.bloodParticles.forEach(bp => {
    ctx.fillStyle = `rgba(138, 26, 26, ${bp.life})`;
    ctx.fillRect(bp.x - bp.size/2, bp.y - bp.size/2, bp.size, bp.size);
  });

  // 僵尸
  gameState.zombies.forEach(z => drawZombie(ctx, z, 1.5));

  // 玩家
  let moveState = 'idle';
  if (Math.abs(joystick.dx) > 0.05 || Math.abs(joystick.dy) > 0.05) moveState = 'walk';
  const phase = time * 4.0;
  const walkLeftLeg = Math.sin(phase) * 0.45 * (moveState === 'walk' ? 1 : 0);
  const walkRightLeg = Math.sin(phase - Math.PI*0.6) * 0.45 * (moveState === 'walk' ? 1 : 0);
  const aimP = p.isAiming ? 1 : 0;
  const soldierAnim = {
    bob: lerp(Math.abs(Math.sin(phase)) * 3 * (moveState === 'walk' ? 1 : 0), 0, aimP),
    lean: lerp(0.05, 0.1, aimP),
    bodyRotation: lerp(0, -0.2, aimP),
    leftLegRot: lerp(walkLeftLeg, 0.05, aimP),
    rightLegRot: lerp(walkRightLeg, -0.05, aimP),
    leftArmRot: lerp(-0.7 + Math.sin(phase)*0.05*(moveState==='walk'?1:0), -1.1, aimP),
    rightArmRot: lerp(0.1 + Math.sin(phase - Math.PI*0.6)*0.05*(moveState==='walk'?1:0), -0.45, aimP),
    facing: p.facing,
  };
  drawSoldier(ctx, p.x, p.y, 1.5, soldierAnim);

  // 子弹
  gameState.bullets.forEach(b => {
    ctx.fillStyle = '#FFDD44';
    ctx.fillRect(b.x - 8, b.y - 2, 16, 4);
    ctx.fillStyle = '#FFFFFF';
    ctx.fillRect(b.x - 8, b.y - 1, 16, 2);
  });

  // 伤害飘字
  gameState.damageTexts.forEach(t => {
    ctx.save();
    ctx.globalAlpha = t.life;
    ctx.fillStyle = t.color;
    ctx.font = 'bold 20px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText(t.text, t.x, t.y);
    ctx.restore();
  });

  ctx.restore();

  // ===== UI =====
  // 血条
  ctx.fillStyle = 'rgba(0,0,0,0.6)';
  ctx.fillRect(20, 20, 200, 24);
  ctx.strokeStyle = '#444'; ctx.lineWidth = 2;
  ctx.strokeRect(20, 20, 200, 24);
  const hpRatio = p.hp / p.maxHp;
  ctx.fillStyle = hpRatio > 0.5 ? '#C41E3A' : '#FF3333';
  ctx.fillRect(22, 22, 196 * hpRatio, 20);
  ctx.fillStyle = '#FFF';
  ctx.font = 'bold 14px sans-serif';
  ctx.textAlign = 'left';
  ctx.fillText(`HP: ${Math.ceil(p.hp)} / ${p.maxHp}`, 30, 37);

  // 击杀数
  ctx.fillStyle = 'rgba(0,0,0,0.6)';
  ctx.fillRect(W - 150, 20, 130, 24);
  ctx.strokeStyle = '#444'; ctx.strokeRect(W - 150, 20, 130, 24);
  ctx.fillStyle = '#C41E3A';
  ctx.font = 'bold 14px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText(`击杀: ${p.killCount}`, W - 85, 37);

  // 游戏结束
  if (gameState.gameOver) {
    ctx.fillStyle = 'rgba(0,0,0,0.7)';
    ctx.fillRect(0, 0, W, H);
    ctx.fillStyle = '#C41E3A';
    ctx.font = 'bold 40px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText('战 死', W/2, H/2 - 20);
    ctx.fillStyle = '#FFF';
    ctx.font = '18px sans-serif';
    ctx.fillText(`最终击杀: ${p.killCount}`, W/2, H/2 + 30);
    ctx.font = '14px sans-serif';
    ctx.fillStyle = '#888';
    ctx.fillText('点击屏幕重新开始', W/2, H/2 + 70);
  }

  // 摇杆
  ctx.save();
  ctx.globalAlpha = 0.6;
  ctx.fillStyle = 'rgba(255,255,255,0.1)';
  ctx.beginPath(); ctx.arc(joystick.baseX, joystick.baseY, joystick.maxRadius, 0, Math.PI*2); ctx.fill();
  ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 2; ctx.stroke();
  ctx.fillStyle = joystick.active ? 'rgba(196, 30, 58, 0.8)' : 'rgba(196, 30, 58, 0.4)';
  ctx.beginPath(); ctx.arc(joystick.thumbX, joystick.thumbY, 25, 0, Math.PI*2); ctx.fill();
  ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.stroke();
  ctx.restore();

  // 瞄准按钮
  ctx.save();
  ctx.fillStyle = input.aim ? 'rgba(196, 30, 58, 0.9)' : 'rgba(255,255,255,0.15)';
  ctx.beginPath(); ctx.arc(W - 110, H - 70, 35, 0, Math.PI*2); ctx.fill();
  ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 2; ctx.stroke();
  ctx.fillStyle = '#fff'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
  ctx.fillText('瞄准', W - 110, H - 70);
  ctx.restore();

  // 开火按钮
  ctx.save();
  ctx.fillStyle = input.fire ? 'rgba(196, 30, 58, 0.95)' : 'rgba(255,255,255,0.15)';
  ctx.beginPath(); ctx.arc(W - 50, H - 140, 45, 0, Math.PI*2); ctx.fill();
  ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 2; ctx.stroke();
  ctx.fillStyle = '#fff'; ctx.font = 'bold 16px sans-serif';
  ctx.fillText('开火', W - 50, H - 140);
  ctx.restore();

  requestAnimationFrame(animate);
}

// ============================================================
// 输入事件
// ============================================================
const keys = {};
window.addEventListener('keydown', e => keys[e.key.toLowerCase()] = true);
window.addEventListener('keyup', e => keys[e.key.toLowerCase()] = false);

function handleTouchStart(e) {
  e.preventDefault();
  initAudio();
  if (gameState.gameOver) {
    // 重新开始
    gameState.gameOver = false;
    gameState.player.hp = 100;
    gameState.player.killCount = 0;
    gameState.player.x = 0;
    gameState.player.y = 0;
    gameState.zombies = [];
    gameState.bullets = [];
    gameState.bloodParticles = [];
    gameState.damageTexts = [];
    return;
  }
  for (let i = 0; i < e.touches.length; i++) {
    const touch = e.touches[i];
    const tx = touch.clientX;
    const ty = touch.clientY;

    if (tx < W * 0.5) {
      if (!joystick.active) {
        joystick.active = true;
        joystick.touchId = touch.identifier;
        const dx = tx - joystick.baseX;
        const dy = ty - joystick.baseY;
        const d = Math.hypot(dx, dy);
        if (d < joystick.maxRadius) {
          joystick.thumbX = tx; joystick.thumbY = ty;
          joystick.dx = dx / joystick.maxRadius; joystick.dy = dy / joystick.maxRadius;
        } else {
          joystick.thumbX = joystick.baseX + (dx/d)*joystick.maxRadius;
          joystick.thumbY = joystick.baseY + (dy/d)*joystick.maxRadius;
          joystick.dx = dx / d; joystick.dy = dy / d;
        }
      }
    } else {
      const dAim = Math.hypot(tx - (W - 110), ty - (H - 70));
      const dFire = Math.hypot(tx - (W - 50), ty - (H - 140));
      if (dFire < 45) { input.fire = true; fire(); }
      else if (dAim < 35) { input.aim = true; }
    }
  }
}

function handleTouchMove(e) {
  e.preventDefault();
  for (let i = 0; i < e.touches.length; i++) {
    const touch = e.touches[i];
    if (touch.identifier === joystick.touchId) {
      const tx = touch.clientX;
      const ty = touch.clientY;
      const dx = tx - joystick.baseX;
      const dy = ty - joystick.baseY;
      const d = Math.hypot(dx, dy);
      if (d < joystick.maxRadius) {
        joystick.thumbX = tx; joystick.thumbY = ty;
        joystick.dx = dx / joystick.maxRadius; joystick.dy = dy / joystick.maxRadius;
      } else {
        joystick.thumbX = joystick.baseX + (dx/d)*joystick.maxRadius;
        joystick.thumbY = joystick.baseY + (dy/d)*joystick.maxRadius;
        joystick.dx = dx / d; joystick.dy = dy / d;
      }
    }
  }
}

function handleTouchEnd(e) {
  e.preventDefault();
  let stillActive = false;
  for (let i = 0; i < e.touches.length; i++) {
    if (e.touches[i].identifier === joystick.touchId) stillActive = true;
  }
  if (!stillActive) {
    joystick.active = false;
    joystick.thumbX = joystick.baseX; joystick.thumbY = joystick.baseY;
    joystick.dx = 0; joystick.dy = 0;
  }
  input.aim = false; input.fire = false;
}

window.addEventListener('touchstart', handleTouchStart, { passive: false });
window.addEventListener('touchmove', handleTouchMove, { passive: false });
window.addEventListener('touchend', handleTouchEnd, { passive: false });
window.addEventListener('touchcancel', handleTouchEnd, { passive: false });

// PC 辅助
window.addEventListener('mousedown', (e) => {
  initAudio();
  if (gameState.gameOver) {
    gameState.gameOver = false;
    gameState.player.hp = 100;
    gameState.player.killCount = 0;
    gameState.player.x = 0;
    gameState.player.y = 0;
    gameState.zombies = [];
    return;
  }
  if (e.button === 0) fire();
});

// 键盘辅助(方便PC调试)
window.addEventListener('keydown', e => {
  if (e.code === 'Space') { e.preventDefault(); fire(); }
});

requestAnimationFrame(animate);
</script>
</body>
</html>

Game Source: 血染红旗 - 测试版 v0.1

Creator: TurboKoala83

Libraries: none

Complexity: complex (960 lines, 40.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: v0-1-turbokoala83" to link back to the original. Then publish at arcadelab.ai/publish.