🎮ArcadeLab

红军战士 - 动画调试工具

by TurboKoala83
845 lines35.9 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: #0a0a12; overflow: hidden; font-family: 'Microsoft YaHei', sans-serif; touch-action: none; }
  canvas { display: block; cursor: grab; }
  canvas:active { cursor: grabbing; }
  .topbar { position: absolute; top: 0; left: 0; right: 0; height: 40px; background: rgba(0,0,0,0.85); border-bottom: 1px solid #333; display: flex; align-items: center; padding: 0 16px; z-index: 20; }
  .topbar .title { color: #C41E3A; font-weight: bold; letter-spacing: 3px; font-size: 14px; }
  .topbar .hint { color: #666; font-size: 11px; margin-left: 20px; }
  .panel { position: absolute; top: 50px; right: 10px; width: 240px; max-height: calc(100vh - 70px); overflow-y: auto; background: rgba(10,10,18,0.92); border: 1px solid #333; border-radius: 8px; padding: 10px; z-index: 20; }
  .panel h3 { color: #C41E3A; font-size: 12px; letter-spacing: 2px; margin: 8px 0 6px 0; border-bottom: 1px solid #333; padding-bottom: 4px; }
  .panel h3:first-child { margin-top: 0; }
  .btn { display: inline-block; background: #1a1a2e; border: 1px solid #444; color: #ccc; padding: 5px 10px; margin: 3px; border-radius: 4px; font-size: 11px; cursor: pointer; transition: all 0.15s; user-select: none; }
  .btn:hover { background: #2a2a3e; border-color: #666; }
  .btn.active { background: #C41E3A; border-color: #C41E3A; color: #fff; font-weight: bold; }
  .row { display: flex; align-items: center; margin: 4px 0; font-size: 11px; color: #aaa; }
  .row label { flex: 1; }
  .row input[type=range] { width: 100px; }
  .row .val { width: 40px; text-align: right; color: #fff; font-family: monospace; }
  .info-bottom { position: absolute; bottom: 10px; left: 10px; color: #666; font-size: 11px; background: rgba(0,0,0,0.7); padding: 6px 10px; border-radius: 4px; z-index: 20; }
  .state-display { position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%); color: #C41E3A; font-size: 16px; letter-spacing: 4px; font-weight: bold; background: rgba(0,0,0,0.7); padding: 6px 20px; border-radius: 4px; z-index: 20; text-shadow: 0 0 10px rgba(196,30,58,0.8); }
</style>
</head>
<body>
<div class="topbar">
  <span class="title">★ 红军战士 · 动画调试工具</span>
  <span class="hint">左键平移 | 右键旋转 | 滚轮缩放</span>
</div>

<div class="panel">
  <h3>▸ 动画状态</h3>
  <div>
    <div class="btn" data-state="idle">待机</div>
    <div class="btn" data-state="walk">行走</div>
    <div class="btn" data-state="run">奔跑</div>
    <div class="btn" data-state="aim">瞄准</div>
    <div class="btn" data-state="fire">开火</div>
    <div class="btn" data-state="bolt">拉栓</div>
    <div class="btn" data-state="hit">受击</div>
    <div class="btn" data-state="swing">挥砍</div>
  </div>

  <h3>▸ 动画参数</h3>
  <div class="row"><label>步频</label><input type="range" id="walkSpeed" min="1" max="10" step="0.1" value="4"><span class="val" id="walkSpeedVal">4.0</span></div>
  <div class="row"><label>腿摆幅</label><input type="range" id="legSwing" min="0" max="1" step="0.01" value="0.45"><span class="val" id="legSwingVal">0.45</span></div>
  <div class="row"><label>臂摆幅</label><input type="range" id="armSwing" min="0" max="1" step="0.01" value="0.45"><span class="val" id="armSwingVal">0.45</span></div>
  <div class="row"><label>身体起伏</label><input type="range" id="bodyBob" min="0" max="15" step="0.1" value="3"><span class="val" id="bodyBobVal">3.0</span></div>
  <div class="row"><label>前倾角</label><input type="range" id="lean" min="-0.5" max="0.5" step="0.01" value="0.05"><span class="val" id="leanVal">0.05</span></div>

  <h3>▸ 播放控制</h3>
  <div class="row"><label>播放速度</label><input type="range" id="playSpeed" min="0.1" max="3" step="0.1" value="1"><span class="val" id="playSpeedVal">1.0</span></div>
  <div>
    <div class="btn" id="btnPause">⏸ 暂停</div>
    <div class="btn" id="btnStep">▶ 单帧</div>
    <div class="btn" id="btnReset">↺ 重置</div>
  </div>

  <h3>▸ 摄像机</h3>
  <div class="row"><label>缩放</label><input type="range" id="camZoom" min="0.3" max="3" step="0.05" value="1.2"><span class="val" id="camZoomVal">1.2</span></div>
  <div class="row"><label>旋转</label><input type="range" id="camRot" min="-180" max="180" step="1" value="0"><span class="val" id="camRotVal">0</span></div>
  <div>
    <div class="btn" id="btnResetCam">重置视角</div>
  </div>

  <h3>▸ 辅助显示</h3>
  <div>
    <div class="btn active" id="btnGrid">网格</div>
    <div class="btn" id="btnAnchor">锚点</div>
    <div class="btn" id="btnShadow">阴影</div>
  </div>
</div>

<div class="state-display" id="stateDisplay">待机</div>
<div class="info-bottom">拖动滑条实时调节参数 | 点击按钮切换动画 | 空格键单帧</div>

<canvas id="game"></canvas>

<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
let W = window.innerWidth, H = window.innerHeight;
canvas.width = W; canvas.height = H;
window.addEventListener('resize', () => {
  W = window.innerWidth; H = window.innerHeight;
  canvas.width = W; canvas.height = H;
});

// ============================================================
// 配色
// ============================================================
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 D = {
  blade:'#8A8A8A', bladeDark:'#5A5A5A', bladeLite:'#B0B0B0', bladeEdge:'#C8C8C8',
  bloodGroove:'#6A6A6A', back:'#4A4A4A', guard:'#B8963A', guardDark:'#8A6A1A',
  handle:'#3A2A1A', wrap:'#5A6B7C', wrapDark:'#3D4A57', ring:'#8A8A8A',
  redCloth:'#C41E3A',
};

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

// ============================================================
// 摄像机
// ============================================================
const camera = {
  x: W / 2, y: H / 2, zoom: 1.2, rotation: 0,
  isDragging: false, dragButton: 0, lastX: 0, lastY: 0,
};

// ============================================================
// 动画状态
// ============================================================
let currentState = 'idle';
let stateStartTime = performance.now();
let time = 0;
let playing = true;
let playSpeed = 1.0;

// 可调参数
const params = {
  walkSpeed: 4.0,
  legSwing: 0.45,
  armSwing: 0.45,
  bodyBob: 3.0,
  lean: 0.05,
};

// 显示选项
const show = { grid: true, anchor: false, shadow: true };

// 火器/冷兵器状态
let useMelee = false;
const gunState = { flashTimer: 0, boltOffset: 0, recoilX: 0, recoilY: 0, recoilRot: 0 };
const meleeState = { isSwinging: false, startTime: 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();
}

// ============================================================
// 人物部件
// ============================================================
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 drawDadao(ctx) {
  const bladeStartX = 5, bladeLength = 140, bladeEndX = bladeStartX + bladeLength;
  const backY0 = -5, backY1 = -6, edgeY0 = 2, edgeY1 = 5, tipY = -10;
  ctx.fillStyle = D.blade; ctx.beginPath();
  ctx.moveTo(bladeStartX, backY0); ctx.lineTo(bladeEndX - 15, backY1);
  ctx.lineTo(bladeEndX, tipY); ctx.lineTo(bladeEndX - 5, edgeY1);
  ctx.lineTo(bladeStartX, edgeY0); ctx.closePath(); ctx.fill();
  ctx.fillStyle = D.bladeLite; ctx.beginPath();
  ctx.moveTo(bladeStartX, backY0); ctx.lineTo(bladeEndX - 15, backY1);
  ctx.lineTo(bladeEndX - 8, (backY1 + tipY) / 2); ctx.lineTo(bladeStartX, backY0 + 3);
  ctx.closePath(); ctx.fill();
  ctx.fillStyle = D.bladeDark; ctx.beginPath();
  ctx.moveTo(bladeStartX, edgeY0); ctx.lineTo(bladeEndX - 5, edgeY1);
  ctx.lineTo(bladeEndX - 12, edgeY1 - 2); ctx.lineTo(bladeStartX, edgeY0 - 2);
  ctx.closePath(); ctx.fill();
  ctx.strokeStyle = D.back; ctx.lineWidth = 2;
  ctx.beginPath(); ctx.moveTo(bladeStartX, backY0); ctx.lineTo(bladeEndX - 15, backY1); ctx.stroke();
  ctx.strokeStyle = D.bladeEdge; ctx.lineWidth = 1.5;
  ctx.beginPath(); ctx.moveTo(bladeStartX, edgeY0); ctx.lineTo(bladeEndX - 5, edgeY1); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(bladeEndX - 15, backY1); ctx.lineTo(bladeEndX, tipY); ctx.lineTo(bladeEndX - 5, edgeY1); ctx.stroke();
  ctx.strokeStyle = D.bloodGroove; ctx.lineWidth = 1;
  ctx.beginPath(); ctx.moveTo(bladeStartX + 8, backY0 + 2); ctx.lineTo(bladeEndX - 20, backY1 + 3); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(bladeStartX + 8, backY0 + 4); ctx.lineTo(bladeEndX - 20, backY1 + 5); ctx.stroke();
  const guardW = 14, guardH = 20, guardX = bladeStartX - guardW + 1, guardY = -guardH / 2;
  ctx.fillStyle = D.guard; ctx.beginPath();
  ctx.moveTo(guardX, guardY); ctx.lineTo(guardX + guardW, guardY - 2);
  ctx.lineTo(guardX + guardW, guardY + guardH); ctx.lineTo(guardX, guardY + guardH + 2);
  ctx.closePath(); ctx.fill();
  ctx.fillStyle = D.guardDark; ctx.fillRect(guardX, guardY + guardH - 3, guardW, 3);
  ctx.fillStyle = '#D4B04A'; ctx.fillRect(guardX + 1, guardY + 1, 2, guardH - 2);
  const handleStartX = guardX - 3, handleLength = 70, handleEndX = handleStartX - handleLength;
  const handleW = 10, handleY = -handleW / 2;
  ctx.fillStyle = D.handle; ctx.fillRect(handleEndX, handleY, handleLength, handleW);
  ctx.fillStyle = '#5A3A2A'; ctx.fillRect(handleEndX, handleY, handleLength, 2);
  ctx.strokeStyle = D.wrap; ctx.lineWidth = 2;
  for (let i = 0; i < 10; i++) {
    const wx = handleEndX + 5 + i * 6;
    ctx.beginPath(); ctx.moveTo(wx, handleY - 1); ctx.lineTo(wx + 3, handleY + handleW + 1); ctx.stroke();
  }
  ctx.strokeStyle = D.wrapDark; ctx.lineWidth = 0.8;
  for (let i = 0; i < 10; i++) {
    const wx = handleEndX + 5 + i * 6;
    ctx.beginPath(); ctx.moveTo(wx + 2, handleY - 1); ctx.lineTo(wx + 5, handleY + handleW + 1); ctx.stroke();
  }
  const ringX = handleEndX - 3, ringY = 0, ringR = 7;
  ctx.strokeStyle = D.ring; ctx.lineWidth = 3;
  ctx.beginPath(); ctx.arc(ringX, ringY, ringR, 0, Math.PI * 2); ctx.stroke();
  ctx.strokeStyle = '#B0B0B0'; ctx.lineWidth = 1;
  ctx.beginPath(); ctx.arc(ringX - 1, ringY - 1, ringR - 1, 0, Math.PI * 2); ctx.stroke();
  ctx.strokeStyle = D.redCloth; ctx.lineWidth = 1.5;
  ctx.beginPath(); ctx.moveTo(ringX - ringR, ringY - 2);
  ctx.quadraticCurveTo(ringX - ringR - 10, ringY - 10, ringX - ringR - 18, ringY - 5); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(ringX - ringR, ringY + 2);
  ctx.quadraticCurveTo(ringX - ringR - 8, ringY + 9, ringX - ringR - 15, ringY + 5); ctx.stroke();
  ctx.fillStyle = D.bladeDark; ctx.fillRect(bladeStartX - 2, backY0 - 2, 4, edgeY0 - backY0 + 4);
}

// ============================================================
// 动画参数计算
// ============================================================
function computeAnim() {
  const now = performance.now();
  const elapsed = (now - stateStartTime) / 1000;
  const phase = time * params.walkSpeed;

  const anim = {
    bob: 0, lean: params.lean, bodyRotation: 0, headRot: 0,
    leftLegRot: 0, rightLegRot: 0,
    leftArmRot: 0, rightArmRot: 0,
    aiming: false,
    armSwingMod: 0, bodySwingMod: 0, dadaoRot: 0,
  };

  switch (currentState) {
    case 'idle':
      anim.leftLegRot = 0;
      anim.rightLegRot = 0;
      anim.leftArmRot = -0.3;
      anim.rightArmRot = 0.2;
      anim.bob = Math.sin(time * 2) * 1.5;
      break;

    case 'walk':
      anim.leftLegRot = Math.sin(phase) * params.legSwing;
      anim.rightLegRot = Math.sin(phase - Math.PI * 0.6) * params.legSwing;
      anim.leftArmRot = -0.7 - Math.sin(phase) * params.armSwing;
      anim.rightArmRot = 0.2 + Math.sin(phase - Math.PI * 0.6) * params.armSwing;
      anim.bob = Math.abs(Math.sin(phase)) * params.bodyBob;
      break;

    case 'run':
      anim.leftLegRot = Math.sin(phase) * (params.legSwing * 1.4);
      anim.rightLegRot = Math.sin(phase - Math.PI * 0.65) * (params.legSwing * 1.4);
      anim.leftArmRot = -0.9 - Math.sin(phase) * (params.armSwing * 1.6);
      anim.rightArmRot = 0.4 + Math.sin(phase - Math.PI * 0.65) * (params.armSwing * 1.6);
      anim.bob = Math.abs(Math.sin(phase)) * (params.bodyBob * 1.7);
      anim.lean = params.lean + 0.15;
      break;

    case 'aim':
      anim.leftLegRot = 0.05;
      anim.rightLegRot = -0.05;
      anim.leftArmRot = -1.1;
      anim.rightArmRot = -0.45;
      anim.bodyRotation = -0.2;
      anim.lean = params.lean + 0.05;
      anim.aiming = true;
      break;

    case 'fire': {
      // 开火后坐力
      const t = Math.min(1, elapsed / 0.5);
      let recoil;
      if (t < 0.3) recoil = Math.sin((t / 0.3) * Math.PI);
      else recoil = Math.max(0, 1 - (t - 0.3) / 0.7);
      anim.leftLegRot = 0.05;
      anim.rightLegRot = -0.05;
      anim.leftArmRot = -1.1 + recoil * 0.15;
      anim.rightArmRot = -0.45 + recoil * 0.2;
      anim.bodyRotation = -0.2 + recoil * 0.05;
      anim.lean = params.lean + 0.05 - recoil * 0.08;
      anim.aiming = true;
      break;
    }

    case 'bolt': {
      // 拉栓动画:0-0.4 拉栓,0.4-0.7 复位
      const t = elapsed % 1.5;
      let boltOffset = 0;
      if (t < 0.3) boltOffset = t / 0.3 * 10;
      else if (t < 0.6) boltOffset = 10;
      else if (t < 0.9) boltOffset = 10 * (1 - (t - 0.6) / 0.3);
      else boltOffset = 0;
      gunState.boltOffset = boltOffset;
      anim.leftLegRot = 0.05;
      anim.rightLegRot = -0.05;
      anim.leftArmRot = -1.1;
      anim.rightArmRot = -0.45 - (boltOffset > 5 ? 0.3 : 0);
      anim.bodyRotation = -0.2;
      anim.aiming = true;
      break;
    }

    case 'hit': {
      // 受击动画
      const t = Math.min(1, elapsed / 0.8);
      let leanMod, headMod, recoilX;
      if (t < 0.2) {
        const k = t / 0.2;
        leanMod = lerp(0, -0.4, k);
        headMod = lerp(0, 0.35, k);
        recoilX = lerp(0, -20, k);
      } else if (t < 0.5) {
        const k = (t - 0.2) / 0.3;
        leanMod = lerp(-0.4, -0.2, easeInOut(k));
        headMod = lerp(0.35, 0.1, easeInOut(k));
        recoilX = lerp(-20, -8, easeInOut(k));
      } else {
        const k = (t - 0.5) / 0.5;
        leanMod = lerp(-0.2, 0, easeOut(k));
        headMod = lerp(0.1, 0, easeOut(k));
        recoilX = lerp(-8, 0, easeOut(k));
      }
      anim.leftLegRot = 0;
      anim.rightLegRot = 0;
      anim.leftArmRot = -0.3 + leanMod * 0.5;
      anim.rightArmRot = 0.2 + leanMod * 0.3;
      anim.headRot = headMod;
      anim.lean = params.lean + leanMod;
      anim.bodyRecoilX = recoilX;
      break;
    }

    case 'swing': {
      // 大刀挥砍
      const t = Math.min(1, elapsed / 0.6);
      let swingP = t;
      if (swingP < 0.2) {
        const k = swingP / 0.2;
        anim.armSwingMod = lerp(0, -1.2, easeOut(k));
        anim.bodySwingMod = lerp(0, -0.15, easeOut(k));
        anim.dadaoRot = lerp(-0.6, -1.4, easeOut(k));
      } else if (swingP < 0.45) {
        const k = (swingP - 0.2) / 0.25;
        anim.armSwingMod = lerp(-1.2, 0.6, easeInOut(k));
        anim.bodySwingMod = lerp(-0.15, 0.2, easeInOut(k));
        anim.dadaoRot = lerp(-1.4, 0.5, easeInOut(k));
      } else {
        const k = (swingP - 0.45) / 0.55;
        anim.armSwingMod = lerp(0.6, 0, easeOut(k));
        anim.bodySwingMod = lerp(0.2, 0, easeOut(k));
        anim.dadaoRot = lerp(0.5, -0.6, easeOut(k));
      }
      anim.leftLegRot = 0.1;
      anim.rightLegRot = -0.1;
      break;
    }
  }

  return anim;
}

// ============================================================
// 绘制完整人物
// ============================================================
function drawSoldier(ctx, cx, 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;

  const bodyRot = (anim.bodyRotation || 0) + (anim.bodySwingMod || 0);
  const bodyRecoilX = anim.bodyRecoilX || 0;

  // ===== 绘制身体 =====
  ctx.save();
  ctx.translate(cx + bodyRecoilX, groundY + anim.bob);
  ctx.scale(scale, scale);
  ctx.rotate(anim.lean + bodyRot);

  drawLeg(ctx, -legSpacing, 0, anim.leftLegRot);
  drawLeg(ctx, legSpacing, 0, anim.rightLegRot);
  drawTorso(ctx, 0, 0, 0);
  drawArm(ctx, armSpacing, shoulderY, anim.rightArmRot + (anim.armSwingMod || 0) * 0.5, 'right', 0.85);
  drawHead(ctx, 0, headY, anim.headRot);
  ctx.restore();

  // ===== 绘制武器 =====
  if (useMelee) {
    // 大刀
    const handX = cx + bodyRecoilX;
    const handY = groundY + anim.bob - 100 * scale;
    const dadaoAngle = (anim.dadaoRot || 0) - 0.1;
    ctx.save();
    ctx.translate(handX, handY);
    ctx.scale(scale, scale);
    ctx.rotate(dadaoAngle);
    ctx.translate(-5, 0);
    drawDadao(ctx);
    ctx.restore();
  } else {
    // 步枪
    const handLY = S.armH - 6;
    const armC = Math.cos(anim.leftArmRot); const armS = Math.sin(anim.leftArmRot);
    const localX = -armSpacing - handLY * armS;
    const localY = shoulderY + handLY * armC;
    const bodyC = Math.cos(bodyRot); const bodyS = Math.sin(bodyRot);
    const rotX = localX * bodyC - localY * bodyS;
    const rotY = localX * bodyS + localY * bodyC;
    const handX = cx + (bodyRecoilX + rotX) * scale;
    const handY = groundY + anim.bob + rotY * scale;

    const aimP = anim.aiming ? 1 : 0;
    const gunOffsetX = lerp(0, 15, aimP);
    const gunOffsetY = lerp(0, -18, aimP);

    ctx.save();
    ctx.translate(handX + gunOffsetX, handY + gunOffsetY);
    ctx.scale(scale, scale);
    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();
    }
    ctx.restore();
  }

  // ===== 前手(画在武器上层) =====
  ctx.save();
  ctx.translate(cx + bodyRecoilX, groundY + anim.bob);
  ctx.scale(scale, scale);
  ctx.rotate(anim.lean + bodyRot);
  if (useMelee) {
    drawArm(ctx, -armSpacing, shoulderY, anim.leftArmRot - 0.8 - (anim.armSwingMod || 0) * 0.5, 'left');
  } else {
    drawArm(ctx, -armSpacing, shoulderY, anim.leftArmRot, 'left');
  }
  ctx.restore();

  // 锚点显示
  if (show.anchor) {
    ctx.fillStyle = 'rgba(255, 50, 50, 0.9)';
    const legSpacing2 = S.torsoW/4 - 6;
    const armSpacing2 = S.torsoW/2 + S.armW/2 - 2;
    // 肩膀
    ctx.beginPath(); ctx.arc(cx - armSpacing2 * scale, groundY + shoulderY * scale, 4, 0, Math.PI * 2); ctx.fill();
    ctx.beginPath(); ctx.arc(cx + armSpacing2 * scale, groundY + shoulderY * scale, 4, 0, Math.PI * 2); ctx.fill();
    // 髋部
    ctx.beginPath(); ctx.arc(cx - legSpacing2 * scale, groundY, 4, 0, Math.PI * 2); ctx.fill();
    ctx.beginPath(); ctx.arc(cx + legSpacing2 * scale, groundY, 4, 0, Math.PI * 2); ctx.fill();
    // 头
    ctx.beginPath(); ctx.arc(cx, groundY + headY * scale, 4, 0, Math.PI * 2); ctx.fill();
  }
}

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

  if (playing) time += dt * playSpeed;

  // 火器状态更新
  if (currentState === 'fire') {
    const elapsed = (performance.now() - stateStartTime) / 1000;
    if (elapsed < 0.06) {
      gunState.flashTimer = 0.06 - elapsed;
    } else {
      gunState.flashTimer = 0;
    }
    let recoil;
    if (elapsed < 0.15) {
      const t = elapsed / 0.15;
      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 - 0.15) / 0.2, 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);
    }
  } else {
    gunState.flashTimer = 0;
    gunState.recoilX = 0; gunState.recoilY = 0; gunState.recoilRot = 0;
  }

  if (currentState !== 'bolt') gunState.boltOffset = 0;

  ctx.clearRect(0, 0, W, H);
  ctx.fillStyle = '#0F1A0F'; ctx.fillRect(0, 0, W, H);

  // 网格
  if (show.grid) {
    const gridSize = 80;
    ctx.strokeStyle = 'rgba(74, 90, 74, 0.12)'; ctx.lineWidth = 1;
    for (let x = 0; x < W; x += gridSize) {
      for (let y = 0; y < H; y += gridSize) {
        ctx.strokeRect(x, y, gridSize, gridSize);
      }
    }
  }

  // 应用摄像机
  ctx.save();
  ctx.translate(camera.x, camera.y);
  ctx.rotate(camera.rotation);
  ctx.scale(camera.zoom, camera.zoom);
  ctx.translate(-camera.x, -camera.y);

  const groundY = H / 2 + 100;
  const cx = W / 2;
  const scale = 1.8;

  // 阴影
  if (show.shadow) {
    const anim = computeAnim();
    const shadowScale = 1 - anim.bob * 0.01;
    ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
    ctx.beginPath();
    ctx.ellipse(cx, groundY + 5, 75 * shadowScale, 12 * shadowScale, 0, 0, Math.PI * 2);
    ctx.fill();
  }

  // 地面线
  ctx.strokeStyle = 'rgba(74, 90, 74, 0.4)'; ctx.lineWidth = 2;
  ctx.beginPath(); ctx.moveTo(0, groundY); ctx.lineTo(W, groundY); ctx.stroke();

  // 绘制人物
  const anim = computeAnim();
  drawSoldier(ctx, cx, groundY, scale, anim);

  ctx.restore();

  requestAnimationFrame(animate);
}

// ============================================================
// 交互事件
// ============================================================
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('mousedown', (e) => {
  camera.isDragging = true; camera.dragButton = e.button;
  camera.lastX = e.clientX; camera.lastY = e.clientY;
});
canvas.addEventListener('mousemove', (e) => {
  if (!camera.isDragging) return;
  const dx = e.clientX - camera.lastX;
  const dy = e.clientY - camera.lastY;
  if (camera.dragButton === 0) { camera.x += dx; camera.y += dy; }
  else if (camera.dragButton === 2) { camera.rotation += dx * 0.005; }
  camera.lastX = e.clientX; camera.lastY = e.clientY;
});
canvas.addEventListener('mouseup', () => { camera.isDragging = false; });
canvas.addEventListener('mouseleave', () => { camera.isDragging = false; });
canvas.addEventListener('wheel', (e) => {
  e.preventDefault();
  camera.zoom *= e.deltaY > 0 ? 0.9 : 1.1;
  camera.zoom = Math.max(0.2, Math.min(5.0, camera.zoom));
  document.getElementById('camZoom').value = camera.zoom;
  document.getElementById('camZoomVal').textContent = camera.zoom.toFixed(2);
}, { passive: false });

// 状态切换
document.querySelectorAll('[data-state]').forEach(btn => {
  btn.addEventListener('click', () => {
    document.querySelectorAll('[data-state]').forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
    currentState = btn.dataset.state;
    stateStartTime = performance.now();
    // 切换状态时重置时间,让动画从头播放
    time = 0;
    // 使用冷兵器或火器
    useMelee = (currentState === 'swing');
    // 更新状态显示
    const names = { idle: '待机', walk: '行走', run: '奔跑', aim: '瞄准', fire: '开火', bolt: '拉栓', hit: '受击', swing: '挥砍' };
    document.getElementById('stateDisplay').textContent = names[currentState];
    // 初始化枪声/挥砍声
    if (currentState === 'fire') playGunshot();
    if (currentState === 'swing') playSwing();
  });
});
// 默认选中待机
document.querySelector('[data-state="idle"]').classList.add('active');

// 参数滑条
function bindSlider(id, key, transform = v => v) {
  const el = document.getElementById(id);
  const valEl = document.getElementById(id + 'Val');
  el.addEventListener('input', () => {
    params[key] = transform(parseFloat(el.value));
    valEl.textContent = parseFloat(el.value).toFixed(2);
  });
}
bindSlider('walkSpeed', 'walkSpeed');
bindSlider('legSwing', 'legSwing');
bindSlider('armSwing', 'armSwing');
bindSlider('bodyBob', 'bodyBob');
bindSlider('lean', 'lean');

// 播放控制
document.getElementById('playSpeed').addEventListener('input', (e) => {
  playSpeed = parseFloat(e.target.value);
  document.getElementById('playSpeedVal').textContent = playSpeed.toFixed(1);
});
document.getElementById('btnPause').addEventListener('click', () => {
  playing = !playing;
  document.getElementById('btnPause').textContent = playing ? '⏸ 暂停' : '▶ 继续';
});
document.getElementById('btnStep').addEventListener('click', () => {
  playing = false;
  document.getElementById('btnPause').textContent = '▶ 继续';
  time += 0.02;
});
document.getElementById('btnReset').addEventListener('click', () => {
  time = 0;
  stateStartTime = performance.now();
  playing = true;
  document.getElementById('btnPause').textContent = '⏸ 暂停';
});

// 摄像机滑条
document.getElementById('camZoom').addEventListener('input', (e) => {
  camera.zoom = parseFloat(e.target.value);
  document.getElementById('camZoomVal').textContent = camera.zoom.toFixed(2);
});
document.getElementById('camRot').addEventListener('input', (e) => {
  camera.rotation = parseFloat(e.target.value) * Math.PI / 180;
  document.getElementById('camRotVal').textContent = e.target.value;
});
document.getElementById('btnResetCam').addEventListener('click', () => {
  camera.x = W / 2; camera.y = H / 2;
  camera.zoom = 1.2; camera.rotation = 0;
  document.getElementById('camZoom').value = 1.2;
  document.getElementById('camZoomVal').textContent = '1.20';
  document.getElementById('camRot').value = 0;
  document.getElementById('camRotVal').textContent = '0';
});

// 辅助显示
document.getElementById('btnGrid').addEventListener('click', (e) => {
  show.grid = !show.grid; e.target.classList.toggle('active');
});
document.getElementById('btnAnchor').addEventListener('click', (e) => {
  show.anchor = !show.anchor; e.target.classList.toggle('active');
});
document.getElementById('btnShadow').addEventListener('click', (e) => {
  show.shadow = !show.shadow; e.target.classList.toggle('active');
});

// 空格单帧
window.addEventListener('keydown', (e) => {
  if (e.code === 'Space') {
    e.preventDefault();
    playing = false;
    document.getElementById('btnPause').textContent = '▶ 继续';
    time += 0.02;
  }
});

// 声音
let audioCtx = null;
function initAudio() {
  if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  if (audioCtx.state === 'suspended') audioCtx.resume();
}
function playGunshot() {
  initAudio(); 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 playSwing() {
  initAudio(); if (!audioCtx) return;
  const now = audioCtx.currentTime;
  const bs = audioCtx.sampleRate * 0.15;
  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.setValueAtTime(800, now);
  f.frequency.exponentialRampToValueAtTime(2000, now + 0.1); f.Q.value = 3;
  const g = audioCtx.createGain();
  g.gain.setValueAtTime(0.001, now);
  g.gain.exponentialRampToValueAtTime(0.4, now + 0.05);
  g.gain.exponentialRampToValueAtTime(0.01, now + 0.15);
  n.connect(f); f.connect(g); g.connect(audioCtx.destination); n.start(now);
}

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

Game Source: 红军战士 - 动画调试工具

Creator: TurboKoala83

Libraries: none

Complexity: complex (845 lines, 35.9 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-muwjb1q4" to link back to the original. Then publish at arcadelab.ai/publish.