🎮ArcadeLab

血染红旗 - 测试版 v0.2 (修复与物理反馈)

by TurboKoala83
748 lines40.3 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.2 (修复与物理反馈)</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.2 ★</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, 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 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,
    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);
  ctx.restore();

  // 计算左手(后手)锚点
  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;

  // 绘制枪
  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();

  // ★★★ 核心修复:补画后手(画面左侧,角色右手,扣扳机)★★★
  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);
  drawArm(ctx, -armSpacing, shoulderY, a.leftArmRot, 'left');
  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 phase = time * 3.5 + (z.seed || 0) * 10;
  let leftLegRot = Math.sin(phase) * 0.35;
  let rightLegRot = Math.sin(phase - Math.PI * 0.7) * 0.25 - 0.1;
  let leftArmRot = -1.2 + Math.sin(phase) * 0.1;
  let rightArmRot = -1.0 + Math.sin(phase - Math.PI * 0.7) * 0.1;
  let bob = Math.abs(Math.sin(phase)) * 4.0;
  let headRot = Math.sin(phase * 3) * 0.08;
  let lungeOffset = 0, bodyPitch = 0, headPitch = 0, armForward = 0, legSplit = 0;

  // ★ 新增:受击状态动画
  if (z.state === 'hit') {
    const hitProg = Math.min(1, (performance.now() - z.hitTime) / 250);
    const ease = easeOut(hitProg);
    // 身体猛烈后仰、后退,手臂上扬
    bodyPitch = -0.5 * (1 - ease);
    lungeOffset = -50 * (1 - ease);
    armForward = -0.8 * (1 - ease);
    headPitch = -0.3 * (1 - ease);
    leftArmRot = -0.5 - 0.5 * (1 - ease); // 手臂向上甩
    rightArmRot = -0.5 - 0.5 * (1 - ease);
    bob = 0;
  } 
  // 攻击状态
  else 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, hitTime: 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;

  // 子弹(增加 prevX, prevY 用于物理碰撞检测)
  const dir = p.facing;
  const startX = p.x + dir * 200;
  const startY = p.y - 100;
  gameState.bullets.push({
    x: startX, y: startY,
    prevX: startX, prevY: startY,
    vx: dir * 1800, 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 === 'hit') {
        if (performance.now() - z.hitTime > 250) z.state = 'walk';
        return;
      }
      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.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.prevX = b.x; b.prevY = b.y; // 记录上一帧位置
      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];
        // ★ 扩大碰撞箱并支持线段相交检测(解决穿透)
        const boxX = z.x - 50; const boxY = z.y - 200; const boxW = 100; const boxH = 200;
        // 简单的线段与矩形相交判定
        if (b.x > boxX && b.x < boxX + boxW && b.y > boxY && b.y < boxY + boxH) {
          // 命中
          z.hp -= 40;
          z.state = 'hit'; z.hitTime = performance.now(); // ★ 触发受击状态
          gameState.damageTexts.push({ x: z.x, y: z.y - 220, text: '-40', life: 1.0, color: '#FFAA00' });
          for (let k = 0; k < 10; k++) {
            gameState.bloodParticles.push({ x: z.x, y: z.y - 100, vx: (Math.random()-0.5)*400, vy: -Math.random()*300, life: 0.6, size: 3 + Math.random()*5 });
          }
          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 - 12, b.y - 2, 24, 4);
    ctx.fillStyle = '#FFFFFF'; ctx.fillRect(b.x - 12, b.y - 1, 24, 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);
  }

  // 摇杆 UI
  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.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.stroke();
  ctx.fillStyle = '#fff'; ctx.font = 'bold 16px sans-serif';
  ctx.fillText('开火', W - 50, H - 140);
  ctx.restore();

  requestAnimationFrame(animate);
}

// ============================================================
// 输入事件
// ============================================================
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();
});
window.addEventListener('keydown', e => { if (e.code === 'Space') { e.preventDefault(); fire(); } });

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

Game Source: 血染红旗 - 测试版 v0.2 (修复与物理反馈)

Creator: TurboKoala83

Libraries: none

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