汉阳造步枪 - 移动端摇杆自由地图
by TurboKoala83553 lines26.5 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>汉阳造步枪 - 移动端摇杆自由地图</title>
<style>
body { margin: 0; background: #1a2a1f; overflow: hidden; font-family: 'Microsoft YaHei', sans-serif; touch-action: none; }
canvas { display: block; }
.info { position: absolute; top: 15px; 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, // 世界 X 坐标
worldY: 0, // 世界 Y 坐标 (上下移动)
speed: 180, // 移动速度
facing: 1, // 朝向:1 为右,-1 为左
isMoving: false
};
const camera = {
x: 0, y: 0,
shakeX: 0, shakeY: 0
};
// 摇杆状态
const joystick = {
active: false,
startX: 0, startY: 0, // 触摸起点
stickX: 0, stickY: 0, // 摇杆头当前位置
maxRadius: 60, // 最大半径
inputX: 0, inputY: 0 // 归一化的输入 (-1 到 1)
};
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 drawJoystick() {
if (!joystick.active) return;
ctx.save();
// 摇杆底座
ctx.beginPath();
ctx.arc(joystick.startX, joystick.startY, joystick.maxRadius, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
ctx.fill();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
ctx.lineWidth = 3;
ctx.stroke();
// 摇杆头
ctx.beginPath();
ctx.arc(joystick.stickX, joystick.stickY, 25, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(196, 30, 58, 0.8)';
ctx.fill();
ctx.restore();
}
function drawMobileUI() {
ctx.save();
// 瞄准按钮 (右侧靠上)
ctx.fillStyle = input.aim ? 'rgba(196, 30, 58, 0.8)' : 'rgba(255, 255, 255, 0.15)';
ctx.beginPath(); ctx.arc(W - 80, H - 140, 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 = '14px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('瞄准', W - 80, H - 140);
// 开火按钮 (右侧靠下)
ctx.fillStyle = input.fire ? 'rgba(196, 30, 58, 0.9)' : 'rgba(255, 255, 255, 0.15)';
ctx.beginPath(); ctx.arc(W - 50, H - 60, 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 = '16px sans-serif';
ctx.fillText('开火', W - 50, H - 60);
ctx.restore();
}
// =====================================================================
// 主循环
// =====================================================================
function animate(now) {
const dt = (now - lastTime) / 1000; lastTime = now;
if (dt > 0.1) return;
ctx.clearRect(0, 0, W, H);
// ===== 输入处理 =====
// 摇杆控制移动
if (!aimState.isAiming && joystick.active) {
player.worldX += joystick.inputX * player.speed * dt;
player.worldY += joystick.inputY * player.speed * dt;
player.isMoving = true;
// 根据摇杆水平方向判断朝向
if (joystick.inputX > 0.1) player.facing = 1;
if (joystick.inputX < -0.1) player.facing = -1;
} else {
player.isMoving = false;
}
// 瞄准状态
aimState.isAiming = input.aim;
// 摄像机死死锁住人物 (平滑跟随)
const targetCamX = player.worldX - W / 2;
const targetCamY = player.worldY - H / 2;
camera.x = lerp(camera.x, targetCamX, 0.15);
camera.y = lerp(camera.y, targetCamY, 0.15);
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 moveMult = player.isMoving ? 1 : 0;
const leftLegRot = Math.sin(phase) * 0.45 * moveMult;
const rightLegRot = Math.sin(phase - Math.PI * 0.6) * 0.45 * moveMult;
// 姿态插值
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 * moveMult, -1.1, p);
const currentRightArmRot = lerp(0.1 + Math.sin(phase - Math.PI * 0.6) * 0.05 * moveMult, -0.45, p);
const currentBob = lerp(Math.abs(Math.sin(phase)) * 3.5 * moveMult, 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 scale = 1.5;
// ===== 绘制世界 =====
ctx.save();
// 根据摄像机偏移整个世界
ctx.translate(-camera.x + camera.shakeX, -camera.y + camera.shakeY);
// 1. 绘制大面积草地背景 (带有地形网格)
ctx.fillStyle = '#1a3a2a'; // 深草地
ctx.fillRect(camera.x - W * 2, camera.y - H * 2, W * 5, H * 5); // 铺满视野
// 2. 动态绘制网格 (根据摄像机位置计算)
const gridSize = 120;
const startX = Math.floor((camera.x - W/2) / gridSize) * gridSize;
const startY = Math.floor((camera.y - H/2) / gridSize) * gridSize;
const endX = startX + W + gridSize * 2;
const endY = startY + H + gridSize * 2;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)';
ctx.lineWidth = 1;
for (let x = startX; x < endX; x += gridSize) {
ctx.beginPath(); ctx.moveTo(x, startY); ctx.lineTo(x, endY); ctx.stroke();
}
for (let y = startY; y < endY; y += gridSize) {
ctx.beginPath(); ctx.moveTo(startX, y); ctx.lineTo(endX, y); ctx.stroke();
}
// 3. 在地图上放置一些参照物 (木箱/石头)
// 我们用固定种子生成一些木箱
const boxPositions = [
{x: 0, y: 0}, {x: 300, y: -200}, {x: -400, y: 300},
{x: 600, y: 100}, {x: -200, y: -500}, {x: 800, y: 400}
];
boxPositions.forEach(box => {
// 绘制木箱
ctx.fillStyle = '#4A3A2A';
ctx.fillRect(box.x - 30, box.y - 30, 60, 60);
ctx.fillStyle = '#6A5A4A';
ctx.fillRect(box.x - 30, box.y - 30, 60, 10);
// 木箱的顶部高光
ctx.fillStyle = '#8A7A6A';
ctx.fillRect(box.x - 30, box.y - 30, 60, 4);
});
// 4. 绘制人物与枪
// 人物世界坐标转屏幕坐标 (在世界坐标系里直接绘制)
const worldCenterX = player.worldX;
const groundY = player.worldY; // Y轴直接作为地面的基准线
const anchors = drawSoldierBody(ctx, worldCenterX, groundY, 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(worldCenterX, groundY + 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(); // 结束世界变换
// 5. 绘制摇杆和 UI (固定在屏幕上)
drawJoystick();
drawMobileUI();
requestAnimationFrame(animate);
}
// =====================================================================
// 移动端触摸事件 (摇杆 + 按钮)
// =====================================================================
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;
const id = touch.identifier;
// 左侧触摸 -> 激活摇杆
if (tx < W * 0.5) {
if (!joystick.active) {
joystick.active = true;
joystick.touchId = id;
joystick.startX = tx;
joystick.startY = ty;
joystick.stickX = tx;
joystick.stickY = ty;
joystick.inputX = 0;
joystick.inputY = 0;
}
}
// 右侧触摸 -> 检查按钮
else {
// 瞄准按钮 (圆心 W-80, H-140, 半径 35)
const distAim = Math.hypot(tx - (W - 80), ty - (H - 140));
// 开火按钮 (圆心 W-50, H-60, 半径 45)
const distFire = Math.hypot(tx - (W - 50), ty - (H - 60));
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];
const tx = touch.clientX;
const ty = touch.clientY;
const id = touch.identifier;
// 更新摇杆
if (joystick.active && id === joystick.touchId) {
let dx = tx - joystick.startX;
let dy = ty - joystick.startY;
const dist = Math.hypot(dx, dy);
// 限制摇杆头在最大半径内
if (dist > joystick.maxRadius) {
dx = (dx / dist) * joystick.maxRadius;
dy = (dy / dist) * joystick.maxRadius;
}
joystick.stickX = joystick.startX + dx;
joystick.stickY = joystick.startY + dy;
// 计算归一化输入
joystick.inputX = dx / joystick.maxRadius;
joystick.inputY = dy / joystick.maxRadius;
}
}
}
function handleTouchEnd(e) {
e.preventDefault();
// 重置摇杆
let joystickFound = false;
for (let i = 0; i < e.touches.length; i++) {
if (e.touches[i].identifier === joystick.touchId) {
joystickFound = true;
}
}
if (!joystickFound) {
joystick.active = false;
joystick.inputX = 0;
joystick.inputY = 0;
}
// 重置按钮 (简单处理,避免多点触控冲突)
// 在真实游戏中应精确追踪每个手指的 ID 和其所按的按钮
// 这里为了测试,简单地将按钮状态重置
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 });
// 桌面端兼容测试(可无视)
window.addEventListener('mousedown', (e) => {
e.preventDefault();
initAudio();
if (e.clientX < W * 0.5) {
// 模拟摇杆测试
} else {
// 模拟按钮测试
}
});
requestAnimationFrame(animate);
</script>
</body>
</html>Game Source: 汉阳造步枪 - 移动端摇杆自由地图
Creator: TurboKoala83
Libraries: none
Complexity: complex (553 lines, 26.5 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-mupb3f82" to link back to the original. Then publish at arcadelab.ai/publish.