🎮ArcadeLab

汉阳造步枪 - 移动端自由移动测试

by TurboKoala83
558 lines27.7 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>汉阳造步枪 - 移动端自由移动测试</title>
<style>
  body { margin: 0; background: #1a1a2e; overflow: hidden; font-family: 'Microsoft YaHei', sans-serif; touch-action: none; }
  canvas { display: block; background: #16213e; }
  .info { position: absolute; top: 10px; left: 50%; transform: translateX(-50%); color: #c41e3a; font-size: 12px; letter-spacing: 2px; text-shadow: 0 0 10px rgba(196, 30, 58, 0.8); pointer-events: none; z-index: 10; }
</style>
</head>
<body>
<div class="info">★ 红军战士 · 自由移动与射击测试 ★</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;
});

// ========== 声音引擎 ==========
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 bufferSize = audioCtx.sampleRate * 0.2;
  const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
  const data = buffer.getChannelData(0);
  for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
  const noise = audioCtx.createBufferSource(); noise.buffer = buffer;
  const filter = audioCtx.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.value = 800;
  const gain = audioCtx.createGain(); gain.gain.setValueAtTime(1, now); gain.gain.exponentialRampToValueAtTime(0.01, now + 0.15);
  noise.connect(filter); filter.connect(gain); gain.connect(audioCtx.destination); noise.start(now);
  const osc = audioCtx.createOscillator(); osc.type = 'sine'; osc.frequency.setValueAtTime(150, now); osc.frequency.exponentialRampToValueAtTime(50, now + 0.1);
  const oscGain = audioCtx.createGain(); oscGain.gain.setValueAtTime(1, now); oscGain.gain.exponentialRampToValueAtTime(0.01, now + 0.15);
  osc.connect(oscGain); oscGain.connect(audioCtx.destination); osc.start(now); osc.stop(now + 0.2);
}
function playBoltAction() {
  if (!audioCtx) return; const now = audioCtx.currentTime;
  const bufferSize = audioCtx.sampleRate * 0.05;
  const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
  const data = buffer.getChannelData(0);
  for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
  const noise = audioCtx.createBufferSource(); noise.buffer = buffer;
  const filter = audioCtx.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.value = 3000; filter.Q.value = 2;
  const gain = audioCtx.createGain(); gain.gain.setValueAtTime(0.6, now); gain.gain.exponentialRampToValueAtTime(0.01, now + 0.04);
  noise.connect(filter); filter.connect(gain); gain.connect(audioCtx.destination); noise.start(now);
}

// ========== 游戏状态 ==========
const player = {
  worldX: 0, worldY: 0,    // 世界坐标 (X, Y 平面)
  vx: 0, vy: 0,            // 速度
  speed: 250,              // 移动速度
  facing: 1,               // 朝向:1 为右,-1 为左
  state: 'idle'            // idle, walk
};

const camera = { 
  x: 0, y: 0,              // 摄像机中心的世界坐标
  shakeX: 0, shakeY: 0     // 震动偏移
};

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

// 输入状态
const input = { aim: false, fire: false };

// ========== 人物尺寸与动画参数 ==========
const S = { headW: 52, headH: 52, torsoW: 84, torsoH: 76, armW: 36, armH: 76, legW: 44, legH: 84 };
let aimState = { isAiming: false, progress: 0 };
let lastTime = performance.now();
let time = 0;
let fireState = { isFiring: false, fireTime: 0, recoilX: 0, recoilY: 0, recoilRot: 0, flashTimer: 0, shells: [], smokeParticles: [], boltOffset: 0, shellEjected: false };

function lerp(start, end, amt) { return (1 - amt) * start + amt * end; }

// =====================================================================
// 绘制人物与枪械 (复用之前的逻辑)
// =====================================================================
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(); }

function drawHead(ctx, x, y) {
  ctx.save(); ctx.translate(x, y);
  const { headW, headH } = S; const headX = -headW / 2; const headY = -headH;
  ctx.fillStyle = '#E8B88A'; roundRect(ctx, headX, headY, headW, headH, 6); ctx.fill();
  ctx.fillStyle = '#C99A6E'; 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 = '#4A5A6B'; 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 = '#37444F'; ctx.fillRect(hatX + 2, hatY + hatH - 4, hatW - 4, 4); ctx.beginPath(); ctx.moveTo(hatX, hatY + 8); ctx.lineTo(hatX + 8, hatY + 2); ctx.lineTo(hatX + 8, hatY + hatH); ctx.lineTo(hatX + 2, hatY + hatH); ctx.closePath(); ctx.fill();
  const starCY = hatY + 9, starR = 7; ctx.fillStyle = '#C41E3A'; ctx.beginPath(); for (let i = 0; i < 5; i++) { const angle = (i * 72 - 90) * Math.PI / 180; const x1 = starR * Math.cos(angle), y1 = starCY + starR * Math.sin(angle); const angle2 = ((i + 1) * 72 - 90) * Math.PI / 180; const innerR = starR * 0.4; const x2 = innerR * Math.cos(angle2 - Math.PI / 5), y2 = starCY + innerR * Math.sin(angle2 - 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) {
  ctx.save(); ctx.translate(x, y);
  const { torsoW, torsoH } = S; const torsoX = -torsoW / 2; const torsoY = -torsoH;
  ctx.fillStyle = '#5A6B7C'; roundRect(ctx, torsoX, torsoY, torsoW, torsoH, 4); ctx.fill();
  ctx.fillStyle = '#3D4A57'; ctx.fillRect(torsoX, torsoY, 8, torsoH); ctx.fillStyle = '#6E8090'; ctx.fillRect(torsoX + torsoW - 6, torsoY, 6, torsoH);
  ctx.fillStyle = '#C41E3A'; ctx.fillRect(torsoX + 12, torsoY + 2, 14, 8); ctx.fillRect(torsoX + torsoW - 26, torsoY + 2, 14, 8);
  ctx.strokeStyle = '#3D4A57'; 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 = '#3A2A1A'; 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 = '#5A6B7C'; roundRect(ctx, armX, 0, armW, armH - 12, 3); ctx.fill();
  if (side === 'left') { ctx.fillStyle = '#3D4A57'; ctx.fillRect(armX, 0, 5, armH - 12); } else { ctx.fillStyle = '#6E8090'; ctx.fillRect(armX + armW - 5, 0, 5, armH - 12); }
  ctx.fillStyle = '#E8B88A'; 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 = '#5A6B7C'; roundRect(ctx, legX, 0, legW, legH * 0.55, 3); ctx.fill();
  ctx.fillStyle = '#3D4A57'; ctx.fillRect(legX, 0, 6, legH * 0.55);
  const bindY = legH * 0.5; const bindH = legH * 0.42;
  ctx.fillStyle = '#8A8A8A'; 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 = '#9A7A4A'; roundRect(ctx, legX - 3, shoeY, legW + 6, shoeH, 3); ctx.fill();
  ctx.strokeStyle = '#7A5A2A'; 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 drawSoldierBody(ctx, centerX, groundY, scale, anim) {
  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(centerX, groundY + anim.bob); 
  ctx.scale(anim.facing * scale, scale);
  ctx.rotate(anim.lean);
  ctx.rotate(anim.bodyRotation);
  
  drawLeg(ctx, -legSpacing, 0, anim.leftLegRot); 
  drawLeg(ctx, legSpacing, 0, anim.rightLegRot);
  drawTorso(ctx, 0, 0);
  drawHead(ctx, 0, headY);
  drawArm(ctx, armSpacing, shoulderY, anim.rightArmRot, 'right', 0.85);
  ctx.restore();

  const bob = anim.bob;
  const bodyRot = anim.bodyRotation;
  const facing = anim.facing;
  
  function handWorld(armPivotLX, armPivotLY, armRot, scaleY) {
    const handLY = (S.armH - 6) * scaleY;
    const armC = Math.cos(armRot); const armS = Math.sin(armRot);
    const localX = armPivotLX - handLY * armS;
    const localY = armPivotLY + handLY * armC;
    
    const bodyC = Math.cos(bodyRot); const bodyS = Math.sin(bodyRot);
    const rotX = localX * bodyC - localY * bodyS;
    const rotY = localX * bodyS + localY * bodyC;
    
    return { 
      x: centerX + rotX * facing * scale, 
      y: groundY + bob + rotY * scale 
    };
  }

  return {
    leftHand: handWorld(-armSpacing, shoulderY, anim.leftArmRot, 1),
    rightHand: handWorld(armSpacing, shoulderY, anim.rightArmRot, 0.85)
  };
}

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(fireState.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 triggerFire() {
  if (fireState.isFiring) return;
  fireState.isFiring = true; fireState.fireTime = performance.now(); fireState.flashTimer = 0.06; fireState.boltOffset = 0; fireState.shellEjected = false;
  playGunshot();
  const shakeMult = aimState.isAiming ? 1.5 : 1.0;
  camera.shakeX = (Math.random() - 0.5) * 12 * shakeMult; camera.shakeY = (Math.random() - 0.5) * 12 * shakeMult;
  for (let i = 0; i < 5; i++) { fireState.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 updateFire(dt) {
  if (!fireState.isFiring) return; const now = performance.now(); const elapsed = now - fireState.fireTime;
  const recoilMult = aimState.isAiming ? 1.3 : 1.0;
  if (elapsed < 150) { const t = elapsed / 150; fireState.recoilX = -15 * Math.sin(t * Math.PI) * recoilMult; fireState.recoilY = 8 * Math.sin(t * Math.PI) * recoilMult; fireState.recoilRot = 0.08 * Math.sin(t * Math.PI) * recoilMult; }
  else { const t = Math.min((elapsed - 150) / 200, 1); const easeOut = 1 - Math.pow(1 - t, 3); fireState.recoilX = -15 * (1 - easeOut) * recoilMult; fireState.recoilY = 8 * (1 - easeOut) * recoilMult; fireState.recoilRot = 0.08 * (1 - easeOut) * recoilMult; }
  if (fireState.flashTimer > 0) fireState.flashTimer -= dt;
  if (elapsed < 80) fireState.boltOffset = 10 * (elapsed / 80);
  else if (elapsed < 150) { fireState.boltOffset = 10; if (!fireState.shellEjected) { playBoltAction(); fireState.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 }); fireState.shellEjected = true; } }
  else if (elapsed < 300) fireState.boltOffset = 10 * (1 - ((elapsed - 150) / 150)); else fireState.boltOffset = 0;
  for (let i = fireState.shells.length - 1; i >= 0; i--) { const s = fireState.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) fireState.shells.splice(i, 1); }
  for (let i = fireState.smokeParticles.length - 1; i >= 0; i--) { const p = fireState.smokeParticles[i]; p.x += p.vx; p.y += p.vy; p.vy -= 0.1; p.vx *= 0.96; p.size += 0.5; p.alpha -= p.decay; p.life -= p.decay; if (p.alpha <= 0 || p.life <= 0) fireState.smokeParticles.splice(i, 1); }
  if (elapsed > 500 && fireState.flashTimer <= 0 && fireState.shells.length === 0 && fireState.smokeParticles.length === 0) { fireState.isFiring = false; fireState.recoilX = 0; fireState.recoilY = 0; fireState.recoilRot = 0; fireState.boltOffset = 0; }
}
function drawFireEffects(ctx) {
  if (fireState.flashTimer > 0) { const flashAlpha = fireState.flashTimer / 0.06; ctx.save(); ctx.globalAlpha = flashAlpha; 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(); }
  fireState.smokeParticles.forEach(p => { ctx.save(); ctx.globalAlpha = p.alpha; ctx.fillStyle = '#AAAAAA'; ctx.fillRect(p.x - p.size/2, p.y - p.size/2, p.size, p.size); ctx.restore(); });
  fireState.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(); });
}

// =====================================================================
// 移动端 UI 绘制(摇杆 + 动作按钮)
// =====================================================================
function drawMobileUI() {
  ctx.save();
  
  // 1. 虚拟摇杆底座
  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();

  // 2. 摇杆头
  ctx.fillStyle = joystick.active ? 'rgba(196, 30, 58, 0.8)' : 'rgba(196, 30, 58, 0.5)';
  ctx.beginPath(); ctx.arc(joystick.thumbX, joystick.thumbY, 25, 0, Math.PI * 2); ctx.fill();
  ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; ctx.stroke();

  // 3. 右侧动作按钮
  // 瞄准按钮
  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();
}

// =====================================================================
// 主循环
// =====================================================================
function animate(now) {
  const dt = (now - lastTime) / 1000; lastTime = now;
  if (dt > 0.1) return;
  
  ctx.clearRect(0, 0, W, H);

  // ===== 输入处理(摇杆 & 键盘) =====
  player.vx = joystick.dx * player.speed;
  player.vy = joystick.dy * player.speed;

  // 键盘辅助(方便PC测试)
  if (keys['a'] || keys['arrowleft']) player.vx = -player.speed;
  if (keys['d'] || keys['arrowright']) player.vx = player.speed;
  if (keys['w'] || keys['arrowup']) player.vy = -player.speed;
  if (keys['s'] || keys['arrowdown']) player.vy = player.speed;

  // 瞄准时禁止移动(或减速)
  if (aimState.isAiming) {
    player.vx *= 0.2;
    player.vy *= 0.2;
  }

  // 归一化斜向移动速度(防止按斜向移动过快)
  const speedMag = Math.hypot(player.vx, player.vy);
  if (speedMag > player.speed) {
    player.vx = (player.vx / speedMag) * player.speed;
    player.vy = (player.vy / speedMag) * player.speed;
  }

  player.worldX += player.vx * dt;
  player.worldY += player.vy * dt;

  // 朝向:根据水平速度判断
  if (player.vx > 10) player.facing = 1;
  if (player.vx < -10) player.facing = -1;

  // 瞄准状态
  aimState.isAiming = input.aim;
  
  // 状态机
  let moveState = 'idle';
  if (Math.abs(player.vx) > 10 || Math.abs(player.vy) > 10) moveState = 'walk';

  // 摄像机跟随(平滑追踪世界坐标 X 和 Y)
  const targetCamX = player.worldX;
  const targetCamY = player.worldY;
  camera.x = lerp(camera.x, targetCamX, 0.1);
  camera.y = lerp(camera.y, targetCamY, 0.1);

  updateFire(dt); time += 0.02;
  
  // 瞄准进度
  const aimSpeed = 8.0;
  if (aimState.isAiming) { aimState.progress = Math.min(1, aimState.progress + dt * aimSpeed); }
  else { aimState.progress = Math.max(0, aimState.progress - dt * aimSpeed); }
  const p = aimState.progress;

  // 行走相位
  const phase = time * 4.0;
  const leftLegRot = Math.sin(phase) * 0.45 * (moveState === 'walk' ? 1 : 0);
  const rightLegRot = Math.sin(phase - Math.PI * 0.6) * 0.45 * (moveState === 'walk' ? 1 : 0);
  
  // 姿态插值
  const currentBodyRotation = lerp(0, -0.2, p);
  const currentLean = lerp(0.05, 0.1, p);
  const currentLeftArmRot = lerp(-0.7 + Math.sin(phase) * 0.05 * (moveState === 'walk' ? 1 : 0), -1.1, p);
  const currentRightArmRot = lerp(0.1 + Math.sin(phase - Math.PI * 0.6) * 0.05 * (moveState === 'walk' ? 1 : 0), -0.45, p);
  const currentBob = lerp(Math.abs(Math.sin(phase)) * 3.5 * (moveState === 'walk' ? 1 : 0), 0, p);
  const currentLeftLegRot = lerp(leftLegRot, 0.05, p);
  const currentRightLegRot = lerp(rightLegRot, -0.05, p);

  const anim = { 
    bob: currentBob, 
    lean: currentLean, 
    bodyRotation: currentBodyRotation,
    leftLegRot: currentLeftLegRot, 
    rightLegRot: currentRightLegRot, 
    leftArmRot: currentLeftArmRot, 
    rightArmRot: currentRightArmRot,
    facing: player.facing
  };

  // ===== 世界坐标与屏幕坐标转换 =====
  // 人物脚底在屏幕上的映射坐标(屏幕中央偏下一点)
  const screenX = W / 2 + (player.worldX - camera.x);
  const screenY = H / 2 + 80 + (player.worldY - camera.y); // H/2 + 80 是为了让人物在屏幕中心偏下

  const scale = 1.5; // 手机端比例

  // ===== 绘制世界 =====
  ctx.save();
  // 全局世界平移(摄像机 + 震动)
  ctx.translate(W / 2 - camera.x + camera.shakeX, H / 2 + 80 - camera.y + camera.shakeY);

  // 1. 绘制地面网格(无限延伸)
  ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)'; ctx.lineWidth = 2;
  const gridSize = 200;
  const startGridX = Math.floor((camera.x - W) / gridSize) * gridSize;
  const startGridY = Math.floor((camera.y - H) / gridSize) * gridSize;
  for (let x = startGridX; x < camera.x + W; x += gridSize) {
    for (let y = startGridY; y < camera.y + H; y += gridSize) {
      ctx.strokeRect(x, y, gridSize, gridSize);
    }
  }

  // 2. 地面上的随机参照物(木箱/草丛)
  // 使用伪随机算法,根据坐标生成固定的障碍物
  for (let gx = startGridX; gx < camera.x + W; gx += gridSize) {
    for (let gy = startGridY; gy < camera.y + H; gy += gridSize) {
      // 基于坐标的伪随机种子
      const seed = Math.sin(gx * 12.9898 + gy * 78.233) * 43758.5453;
      const rand = seed - Math.floor(seed);
      if (rand > 0.7) { // 30% 概率生成一个障碍物
        const objX = gx + (rand * 100);
        const objY = gy + ((seed * 100) % 100);
        // 绘制木箱/石块
        ctx.fillStyle = '#4A3A2A';
        ctx.fillRect(objX - 15, objY - 20, 30, 20);
        ctx.fillStyle = '#2A1A0A';
        ctx.fillRect(objX - 15, objY - 20, 30, 5);
      }
    }
  }

  // 3. 人物与枪械
  const anchors = drawSoldierBody(ctx, screenX, screenY, scale, anim);

  ctx.save();
  const gunOffsetX = lerp(0, 15, p) * player.facing;
  const gunOffsetY = lerp(0, -18, p);
  
  ctx.translate(anchors.leftHand.x + gunOffsetX, anchors.leftHand.y + gunOffsetY); 
  ctx.scale(player.facing, 1); // 枪械左右翻转
  
  const gunRot = lerp(-0.05, 0, p);
  ctx.rotate(gunRot); 
  
  ctx.save();
  ctx.translate(fireState.recoilX, -fireState.recoilY); ctx.rotate(fireState.recoilRot);
  drawHanyangRifle(ctx);
  ctx.restore();
  ctx.save(); drawFireEffects(ctx); ctx.restore();
  ctx.restore();

  // 补画后手(扣扳机的手)
  ctx.save();
  ctx.translate(screenX, screenY + anim.bob); 
  ctx.scale(player.facing * scale, scale); 
  ctx.rotate(anim.lean);
  ctx.rotate(anim.bodyRotation);
  drawArm(ctx, -(S.torsoW / 2 + S.armW / 2 - 2), -S.torsoH, anim.leftArmRot, 'left');
  ctx.restore();

  ctx.restore(); // 结束世界变换

  // 4. 绘制移动端 UI(固定在屏幕上的摇杆和按钮)
  drawMobileUI();

  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();
  
  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 dist = Math.hypot(dx, dy);
        if (dist < joystick.maxRadius) {
          joystick.thumbX = tx;
          joystick.thumbY = ty;
          joystick.dx = dx / joystick.maxRadius;
          joystick.dy = dy / joystick.maxRadius;
        } else {
          joystick.thumbX = joystick.baseX + (dx / dist) * joystick.maxRadius;
          joystick.thumbY = joystick.baseY + (dy / dist) * joystick.maxRadius;
          joystick.dx = (dx / dist);
          joystick.dy = (dy / dist);
        }
      }
    } 
    // 右侧区域:动作按钮
    else {
      // 瞄准按钮 (圆心 W-110, H-70, 半径 35)
      const distAim = Math.hypot(tx - (W - 110), ty - (H - 70));
      // 开火按钮 (圆心 W-50, H-140, 半径 45)
      const distFire = Math.hypot(tx - (W - 50), ty - (H - 140));

      if (distFire < 45) {
        input.fire = true;
        triggerFire();
      } else if (distAim < 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 dist = Math.hypot(dx, dy);
      
      if (dist < joystick.maxRadius) {
        joystick.thumbX = tx; joystick.thumbY = ty;
        joystick.dx = dx / joystick.maxRadius;
        joystick.dy = dy / joystick.maxRadius;
      } else {
        joystick.thumbX = joystick.baseX + (dx / dist) * joystick.maxRadius;
        joystick.thumbY = joystick.baseY + (dy / dist) * joystick.maxRadius;
        joystick.dx = (dx / dist);
        joystick.dy = (dy / dist);
      }
    }
  }
}

function handleTouchEnd(e) {
  e.preventDefault();
  // 检查摇杆是否松开
  let joystickStillActive = false;
  for (let i = 0; i < e.touches.length; i++) {
    if (e.touches[i].identifier === joystick.touchId) {
      joystickStillActive = true;
    }
  }
  if (!joystickStillActive) {
    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 });

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

Game Source: 汉阳造步枪 - 移动端自由移动测试

Creator: TurboKoala83

Libraries: none

Complexity: complex (558 lines, 27.7 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: game-turbokoala83-mupb584x" to link back to the original. Then publish at arcadelab.ai/publish.