血染红旗 - 测试版 v0.1
by TurboKoala83960 lines40.6 KB
<!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.