分享图
动画工坊
引擎就绪
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>行星式翻转轮组 · 主动自平衡云台 — 原理动画</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Exo+2:wght@300;400;600;700&family=JetBrains+Mono:wght@300;500;700&display=swap" rel="stylesheet">
<style>
:root{
  --bg:#060a14;--fg:#d8e2f0;--muted:#4a5c78;
  --cyan:#00e5ff;--amber:#ffab00;--green:#00e676;--red:#ff3d5a;
  --card:#0c1628;--border:#162040;
}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--fg);font-family:'Exo 2',sans-serif;
  min-height:100vh;display:flex;flex-direction:column;align-items:center;
  overflow-x:hidden;padding:18px 12px 32px}
header{text-align:center;margin-bottom:14px}
header h1{font-family:'Orbitron',sans-serif;font-weight:700;font-size:clamp(18px,2.6vw,28px);
  letter-spacing:2px;color:var(--fg);
  background:linear-gradient(90deg,var(--cyan),var(--amber));
  -webkit-background-clip:text;-webkit-text-fill-color:transparent}
header p{font-size:13px;color:var(--muted);margin-top:4px;letter-spacing:1px}
.canvas-wrap{width:100%;max-width:1100px;position:relative;
  border:1px solid var(--border);border-radius:12px;overflow:hidden;
  background:linear-gradient(170deg,#080e1e 0%,#0a1224 100%);
  box-shadow:0 0 40px rgba(0,229,255,.06),0 0 80px rgba(255,171,0,.03)}
canvas{display:block;width:100%;height:auto}
.controls{display:flex;flex-wrap:wrap;gap:16px 28px;align-items:center;
  justify-content:center;margin-top:16px;max-width:1100px;width:100%;
  padding:14px 20px;background:var(--card);border:1px solid var(--border);border-radius:10px}
.ctrl-group{display:flex;align-items:center;gap:8px;font-size:13px}
.ctrl-group label{color:var(--muted);white-space:nowrap;font-weight:600;letter-spacing:.5px}
.ctrl-group input[type=range]{-webkit-appearance:none;width:120px;height:5px;
  background:var(--border);border-radius:4px;outline:none}
.ctrl-group input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;
  width:16px;height:16px;border-radius:50%;background:var(--cyan);cursor:pointer;
  box-shadow:0 0 8px rgba(0,229,255,.5)}
.ctrl-group span.val{font-family:'JetBrains Mono',monospace;font-size:12px;
  color:var(--cyan);min-width:36px;text-align:right}
.toggle-btn{padding:6px 16px;border:1px solid var(--border);border-radius:6px;
  background:transparent;color:var(--muted);font-family:'Exo 2',sans-serif;
  font-size:13px;font-weight:600;cursor:pointer;transition:all .25s}
.toggle-btn.active{background:rgba(255,171,0,.12);color:var(--amber);
  border-color:var(--amber);box-shadow:0 0 12px rgba(255,171,0,.15)}
.toggle-btn:hover{border-color:var(--amber)}
.info-row{display:flex;flex-wrap:wrap;gap:10px 20px;justify-content:center;
  margin-top:12px;max-width:1100px;width:100%}
.info-chip{padding:5px 14px;border-radius:6px;font-size:12px;
  font-family:'JetBrains Mono',monospace;
  background:rgba(12,22,40,.7);border:1px solid var(--border)}
.info-chip .lbl{color:var(--muted);margin-right:6px}
.info-chip .v{font-weight:700}
.info-chip .v.cyan{color:var(--cyan)}
.info-chip .v.amber{color:var(--amber)}
.info-chip .v.green{color:var(--green)}
.info-chip .v.red{color:var(--red)}
.legend{display:flex;gap:18px;justify-content:center;margin-top:10px;font-size:12px;color:var(--muted)}
.legend i{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:5px;vertical-align:middle}
</style>
</head>
<body>

<header>
  <h1>行星式翻转轮组 · 主动自平衡云台</h1>
  <p>TRIZ 最终理想解 (IFR) 原理演示 — 移动与平衡解耦</p>
</header>

<div class="canvas-wrap">
  <canvas id="c" width="1200" height="650"></canvas>
</div>

<div class="controls">
  <div class="ctrl-group">
    <label>播放速度</label>
    <input type="range" id="speedSlider" min="0.2" max="2.5" step="0.1" value="1">
    <span class="val" id="speedVal">1.0x</span>
  </div>
  <div class="ctrl-group">
    <label>台阶高度</label>
    <input type="range" id="stepSlider" min="40" max="110" step="5" value="85">
    <span class="val" id="stepVal">85</span>
  </div>
  <button class="toggle-btn active" id="gimbalBtn">云台补偿: 开启</button>
  <button class="toggle-btn" id="ghostBtn">对比模式: 关闭</button>
</div>

<div class="info-row">
  <div class="info-chip"><span class="lbl">底盘倾角</span><span class="v cyan" id="vTilt">0.0°</span></div>
  <div class="info-chip"><span class="lbl">云台补偿</span><span class="v amber" id="vGimbal">0.0°</span></div>
  <div class="info-chip"><span class="lbl">支架旋转</span><span class="v cyan" id="vBracket">-60°</span></div>
  <div class="info-chip"><span class="lbl">阶段</span><span class="v green" id="vPhase">平地行驶</span></div>
</div>

<div class="legend">
  <span><i style="background:var(--cyan)"></i>行星轮系</span>
  <span><i style="background:var(--amber)"></i>自平衡云台</span>
  <span><i style="background:var(--green)"></i>稳定货台</span>
  <span><i style="background:var(--red)"></i>无补偿对比</span>
</div>

<script>
(function(){
'use strict';

/* ====== 画布与上下文 ====== */
const cv = document.getElementById('c');
const ctx = cv.getContext('2d');
const W = 1200, H = 650;
const dpr = Math.min(window.devicePixelRatio||1, 2);
cv.width = W * dpr; cv.height = H * dpr;
ctx.scale(dpr, dpr);

/* ====== 配置常量 ====== */
const GROUND_Y   = 510;
const STEP_X     = 570;
const ARM_LEN    = 75;
const WHEEL_R    = 13;
const CHASSIS_W  = 190;
const CHASSIS_H  = 26;
const GIMBAL_H   = 30;
const PLAT_W     = 158;
const PLAT_H     = 12;
const CARGO_W    = 88;
const CARGO_H    = 52;
const CYCLE_MS   = 9500;

/* ====== 可变状态 ====== */
let speed       = 1;
let stepH       = 85;
let gimbalOn    = true;
let ghostOn     = false;
let progress    = 0;
let lastTs      = null;

/* ====== 轨迹缓存 ====== */
let traceChassis = [];
let traceCargo   = [];
let traceGhost   = [];

/* ====== 控件绑定 ====== */
const elSpeed   = document.getElementById('speedSlider');
const elSpeedV  = document.getElementById('speedVal');
const elStep    = document.getElementById('stepSlider');
const elStepV   = document.getElementById('stepVal');
const elGimbal  = document.getElementById('gimbalBtn');
const elGhost   = document.getElementById('ghostBtn');

elSpeed.oninput = ()=>{ speed = +elSpeed.value; elSpeedV.textContent = speed.toFixed(1)+'x'; };
elStep.oninput  = ()=>{ stepH = +elStep.value; elStepV.textContent = stepH; resetTraces(); };
elGimbal.onclick = ()=>{
  gimbalOn = !gimbalOn;
  elGimbal.textContent = '云台补偿: '+(gimbalOn?'开启':'关闭');
  elGimbal.classList.toggle('active', gimbalOn);
  resetTraces();
};
elGhost.onclick = ()=>{
  ghostOn = !ghostOn;
  elGhost.textContent = '对比模式: '+(ghostOn?'开启':'关闭');
  elGhost.classList.toggle('active', ghostOn);
};

function resetTraces(){ traceChassis=[]; traceCargo=[]; traceGhost=[]; }

/* ====== 缓动函数 ====== */
function eio2(t){ return t<.5?2*t*t:-1+(4-2*t)*t; }
function eio3(t){ return t<.5?4*t*t*t:(t-1)*(2*t-2)*(2*t-2)+1; }
function eo3(t){ return 1-Math.pow(1-t,3); }
function eo4(t){ return 1-Math.pow(1-t,4); }

/* ====== 角度转弧度 ====== */
const rad = d => d * Math.PI / 180;

/* ====== 计算动画状态 ====== */
function calcState(p){
  const startX   = 155;
  const contactX = STEP_X - ARM_LEN * 0.42;
  const flatCY   = GROUND_Y - ARM_LEN*0.5 - WHEEL_R;
  const upperCY  = GROUND_Y - stepH - ARM_LEN*0.5 - WHEEL_R;
  const rise     = flatCY - upperCY;

  let x, cy, cAngle, bAngle, gAngle, phase, phaseLabel;

  if(p < 0.20){
    /* 平地行驶 */
    phase = 0; phaseLabel = '平地行驶';
    const t = p / 0.20;
    x = startX + t * (contactX - startX);
    cy = flatCY;
    cAngle = 0; bAngle = -60; gAngle = 0;
  } else if(p < 0.33){
    /* 撞击台阶 */
    phase = 1; phaseLabel = '撞击台阶';
    const t = (p-0.20)/0.13;
    const e = eio2(t);
    x = contactX + e * 25;
    cy = flatCY - e * rise * 0.12;
    cAngle = e * 10;
    bAngle = -60 + e * 28;
    gAngle = gimbalOn ? -cAngle : 0;
  } else if(p < 0.58){
    /* 行星翻转跨步 */
    phase = 2; phaseLabel = '翻转跨步';
    const t = (p-0.33)/0.25;
    const e = eio3(t);
    x = contactX + 25 + e * 75;
    cy = flatCY - rise*0.12 - e * rise*0.88;
    cAngle = 10 + e * 22;
    bAngle = -60 + 28 + e * 92;
    gAngle = gimbalOn ? -cAngle : 0;
  } else if(p < 0.72){
    /* 落位稳定 */
    phase = 3; phaseLabel = '落位稳定';
    const t = (p-0.58)/0.14;
    const e = eo4(t);
    x = contactX + 100 + e * 65;
    cy = upperCY + (1-e)*4;
    cAngle = 32 * (1-e);
    bAngle = 60;
    gAngle = gimbalOn ? -cAngle : 0;
  } else if(p < 0.87){
    /* 跨越完成 */
    phase = 4; phaseLabel = '跨越完成';
    x = contactX + 165;
    cy = upperCY;
    cAngle = 0; bAngle = 60; gAngle = 0;
  } else {
    /* 重置 */
    phase = 5; phaseLabel = '—';
    const t = (p-0.87)/0.13;
    x = contactX + 165;
    cy = upperCY;
    cAngle = 0; bAngle = 60; gAngle = 0;
  }

  return { x, cy, cAngle, bAngle, gAngle, phase, phaseLabel };
}

/* ====== 绘图辅助 ====== */
function drawRoundRect(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 drawBg(){
  const grd = ctx.createLinearGradient(0,0,0,H);
  grd.addColorStop(0,'#070c1a');
  grd.addColorStop(1,'#0a1228');
  ctx.fillStyle = grd;
  ctx.fillRect(0,0,W,H);

  ctx.strokeStyle = 'rgba(22,40,72,0.35)';
  ctx.lineWidth = 0.5;
  for(let gx=0; gx<W; gx+=40){ ctx.beginPath(); ctx.moveTo(gx,0); ctx.lineTo(gx,H); ctx.stroke(); }
  for(let gy=0; gy<H; gy+=40){ ctx.beginPath(); ctx.moveTo(0,gy); ctx.lineTo(W,gy); ctx.stroke(); }
}

/* 绘制地面和台阶 */
function drawGround(){
  /* 下层地面 */
  const grd = ctx.createLinearGradient(0,GROUND_Y,0,GROUND_Y+60);
  grd.addColorStop(0,'#1a2640');
  grd.addColorStop(1,'#0d1628');
  ctx.fillStyle = grd;
  ctx.fillRect(0, GROUND_Y, STEP_X, H-GROUND_Y);

  /* 上层地面 */
  const grd2 = ctx.createLinearGradient(0,GROUND_Y-stepH,0,H);
  grd2.addColorStop(0,'#1e2d4a');
  grd2.addColorStop(1,'#0d1628');
  ctx.fillStyle = grd2;
  ctx.fillRect(STEP_X, GROUND_Y-stepH, W-STEP_X, H-(GROUND_Y-stepH));

  /* 台阶立面 */
  ctx.fillStyle = '#2a3d60';
  ctx.fillRect(STEP_X-2, GROUND_Y-stepH, 4, stepH);

  /* 台阶顶边高亮 */
  ctx.strokeStyle = 'rgba(0,229,255,0.25)';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(STEP_X, GROUND_Y-stepH);
  ctx.lineTo(W, GROUND_Y-stepH);
  ctx.stroke();

  /* 台阶立面高亮 */
  ctx.strokeStyle = 'rgba(0,229,255,0.15)';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(STEP_X, GROUND_Y-stepH);
  ctx.lineTo(STEP_X, GROUND_Y);
  ctx.stroke();

  /* 地面线 */
  ctx.strokeStyle = 'rgba(100,140,200,0.2)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(0,GROUND_Y);
  ctx.lineTo(STEP_X,GROUND_Y);
  ctx.stroke();

  /* 台阶高度标注 */
  ctx.save();
  ctx.strokeStyle = 'rgba(0,229,255,0.35)';
  ctx.lineWidth = 1;
  ctx.setLineDash([4,4]);
  const hx = STEP_X + 22;
  ctx.beginPath();
  ctx.moveTo(hx, GROUND_Y);
  ctx.lineTo(hx, GROUND_Y-stepH);
  ctx.stroke();
  ctx.setLineDash([]);
  /* 箭头 */
  ctx.fillStyle = 'rgba(0,229,255,0.5)';
  ctx.beginPath(); ctx.moveTo(hx,GROUND_Y); ctx.lineTo(hx-4,GROUND_Y-6); ctx.lineTo(hx+4,GROUND_Y-6); ctx.fill();
  ctx.beginPath(); ctx.moveTo(hx,GROUND_Y-stepH); ctx.lineTo(hx-4,GROUND_Y-stepH+6); ctx.lineTo(hx+4,GROUND_Y-stepH+6); ctx.fill();
  ctx.font = '600 11px "JetBrains Mono"';
  ctx.fillStyle = 'rgba(0,229,255,0.6)';
  ctx.textAlign = 'left';
  ctx.fillText(stepH+'mm', hx+6, GROUND_Y-stepH/2+4);
  ctx.restore();
}

/* 绘制行星轮组 */
function drawPlanetaryWheels(cx, cy, bAngle, highlight){
  ctx.save();
  ctx.translate(cx, cy);

  /* 发光效果 */
  if(highlight > 0){
    ctx.shadowColor = 'rgba(0,229,255,'+0.5*highlight+')';
    ctx.shadowBlur = 18 * highlight;
  }

  /* 中心轮毂 */
  ctx.fillStyle = '#1a3050';
  ctx.strokeStyle = 'rgba(0,229,255,'+(0.5+0.5*highlight)+')';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.arc(0, 0, 10, 0, Math.PI*2);
  ctx.fill(); ctx.stroke();

  /* 三个臂和轮 */
  for(let i=0; i<3; i++){
    const a = rad(bAngle + i*120);
    const wx = ARM_LEN * Math.sin(a);
    const wy = ARM_LEN * Math.cos(a);

    /* 臂 */
    ctx.strokeStyle = 'rgba(0,229,255,'+(0.4+0.6*highlight)+')';
    ctx.lineWidth = 3;
    ctx.beginPath();
    ctx.moveTo(0,0);
    ctx.lineTo(wx, wy);
    ctx.stroke();

    /* 轮 */
    ctx.fillStyle = '#0d1a2e';
    ctx.strokeStyle = 'rgba(0,229,255,'+(0.5+0.5*highlight)+')';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(wx, wy, WHEEL_R, 0, Math.PI*2);
    ctx.fill(); ctx.stroke();

    /* 轮辐 */
    ctx.strokeStyle = 'rgba(0,229,255,'+(0.2+0.3*highlight)+')';
    ctx.lineWidth = 1;
    for(let s=0;s<3;s++){
      const sa = s*Math.PI/3;
      ctx.beginPath();
      ctx.moveTo(wx+WHEEL_R*0.4*Math.cos(sa), wy+WHEEL_R*0.4*Math.sin(sa));
      ctx.lineTo(wx+WHEEL_R*0.85*Math.cos(sa), wy+WHEEL_R*0.85*Math.sin(sa));
      ctx.stroke();
    }
  }

  ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0;
  ctx.restore();
}

/* 绘制底盘 */
function drawChassis(cx, cy, angle){
  ctx.save();
  ctx.translate(cx, cy);
  ctx.rotate(rad(angle));

  const hw = CHASSIS_W/2, hh = CHASSIS_H/2;

  /* 底盘主体 */
  const grd = ctx.createLinearGradient(0,-hh,0,hh);
  grd.addColorStop(0,'#3d5a80');
  grd.addColorStop(1,'#2a4060');
  ctx.fillStyle = grd;
  drawRoundRect(-hw, -hh, CHASSIS_W, CHASSIS_H, 4);
  ctx.fill();

  ctx.strokeStyle = 'rgba(100,160,220,0.3)';
  ctx.lineWidth = 1;
  drawRoundRect(-hw, -hh, CHASSIS_W, CHASSIS_H, 4);
  ctx.stroke();

  /* 底盘细节线 */
  ctx.strokeStyle = 'rgba(100,160,220,0.15)';
  ctx.lineWidth = 0.5;
  for(let lx=-hw+20; lx<hw; lx+=25){
    ctx.beginPath(); ctx.moveTo(lx,-hh+3); ctx.lineTo(lx,hh-3); ctx.stroke();
  }

  ctx.restore();
}

/* 绘制云台机构 */
function drawGimbal(cx, chassisTopY, platBottomY, cAngle, gAngle, highlight){
  ctx.save();
  ctx.translate(cx, 0);

  const cTop = chassisTopY;
  const pBot = platBottomY;
  const midY = (cTop + pBot) / 2;

  /* 陀螺仪感知标记 */
  if(highlight > 0){
    ctx.shadowColor = 'rgba(255,171,0,'+0.5*highlight+')';
    ctx.shadowBlur = 14*highlight;
  }

  /* 左液压杆 */
  const lOff = 45;
  const lTopX = -lOff, lTopY = cTop;
  const lBotX = -lOff-3, lBotY = pBot;
  drawHydraulicRod(lTopX, lTopY, lBotX, lBotY, highlight);

  /* 右液压杆 */
  const rTopX = lOff, rTopY = cTop;
  const rBotX = lOff+3, rBotY = pBot;
  drawHydraulicRod(rTopX, rTopY, rBotX, rBotY, highlight);

  /* 中心枢轴 */
  ctx.fillStyle = highlight>0 ? 'rgba(255,171,0,'+(0.5+0.5*highlight)+')' : '#3a4a60';
  ctx.strokeStyle = 'rgba(255,171,0,'+(0.3+0.7*highlight)+')';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.arc(0, midY, 7, 0, Math.PI*2);
  ctx.fill(); ctx.stroke();

  /* 枢轴内圈 */
  ctx.fillStyle = '#0d1a2e';
  ctx.beginPath();
  ctx.arc(0, midY, 3, 0, Math.PI*2);
  ctx.fill();

  ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0;
  ctx.restore();
}

function drawHydraulicRod(x1,y1,x2,y2, hl){
  /* 外筒 */
  ctx.strokeStyle = hl>0 ? 'rgba(255,171,0,'+(0.4+0.6*hl)+')' : '#3a5070';
  ctx.lineWidth = 5;
  ctx.lineCap = 'round';
  ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke();

  /* 内杆 */
  ctx.strokeStyle = hl>0 ? 'rgba(255,171,0,'+(0.6+0.4*hl)+')' : '#5a7a9a';
  ctx.lineWidth = 2;
  ctx.beginPath();
  const mx = (x1+x2)/2, my = (y1+y2)/2;
  ctx.moveTo(mx, my-2); ctx.lineTo(x2, y2);
  ctx.stroke();
  ctx.lineCap = 'butt';
}

/* 绘制平台 */
function drawPlatform(cx, py, angle){
  ctx.save();
  ctx.translate(cx, py);
  ctx.rotate(rad(angle));

  const hw = PLAT_W/2, hh = PLAT_H/2;
  ctx.fillStyle = '#2a3a52';
  ctx.strokeStyle = 'rgba(100,160,220,0.3)';
  ctx.lineWidth = 1;
  drawRoundRect(-hw, -hh, PLAT_W, PLAT_H, 3);
  ctx.fill(); ctx.stroke();

  ctx.restore();
}

/* 绘制货物 */
function drawCargo(cx, py, angle, color, alpha){
  ctx.save();
  ctx.translate(cx, py);
  ctx.rotate(rad(angle));
  ctx.globalAlpha = alpha;

  const hw = CARGO_W/2, hh = CARGO_H/2;
  const yOff = -PLAT_H/2 - hh - 1;

  /* 箱体 */
  ctx.fillStyle = color === 'green' ? '#0a3a2a' : '#3a1a1a';
  ctx.strokeStyle = color === 'green' ? 'rgba(0,230,118,0.7)' : 'rgba(255,61,90,0.7)';
  ctx.lineWidth = 1.5;
  drawRoundRect(-hw, yOff, CARGO_W, CARGO_H, 4);
  ctx.fill(); ctx.stroke();

  /* 箱体标识 */
  ctx.strokeStyle = color === 'green' ? 'rgba(0,230,118,0.3)' : 'rgba(255,61,90,0.3)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(-hw+8, yOff+8); ctx.lineTo(hw-8, yOff+8);
  ctx.stroke();
  ctx.beginPath();
  ctx.moveTo(-hw+8, yOff+CARGO_H-8); ctx.lineTo(hw-8, yOff+CARGO_H-8);
  ctx.stroke();

  /* 小标签 */
  ctx.font = '600 9px "JetBrains Mono"';
  ctx.fillStyle = color === 'green' ? 'rgba(0,230,118,0.6)' : 'rgba(255,61,90,0.6)';
  ctx.textAlign = 'center';
  ctx.fillText('CARGO', 0, yOff + CARGO_H/2 + 3);

  ctx.globalAlpha = 1;
  ctx.restore();
}

/* 绘制陀螺仪指示器 */
function drawGyroIndicator(cAngle, gAngle, phase){
  const gx = 1080, gy = 80, gr = 36;

  ctx.save();
  ctx.translate(gx, gy);

  /* 外圈 */
  ctx.strokeStyle = 'rgba(100,140,200,0.2)';
  ctx.lineWidth = 1.5;
  ctx.beginPath(); ctx.arc(0,0,gr,0,Math.PI*2); ctx.stroke();

  /* 水平参考线 */
  ctx.strokeStyle = 'rgba(0,230,118,0.3)';
  ctx.lineWidth = 1;
  ctx.setLineDash([3,3]);
  ctx.beginPath(); ctx.moveTo(-gr+4,0); ctx.lineTo(gr-4,0); ctx.stroke();
  ctx.setLineDash([]);

  /* 底盘倾角指针 */
  ctx.strokeStyle = 'rgba(0,229,255,0.8)';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(0,0);
  ctx.lineTo(gr*0.8*Math.sin(rad(cAngle)), gr*0.8*Math.cos(rad(cAngle)));
  ctx.stroke();

  /* 云台补偿角指针 */
  if(gimbalOn && Math.abs(gAngle) > 0.5){
    ctx.strokeStyle = 'rgba(255,171,0,0.8)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(0,0);
    ctx.lineTo(gr*0.6*Math.sin(rad(gAngle)), gr*0.6*Math.cos(rad(gAngle)));
    ctx.stroke();
  }

  /* 中心点 */
  ctx.fillStyle = '#4a6080';
  ctx.beginPath(); ctx.arc(0,0,3,0,Math.PI*2); ctx.fill();

  /* 标签 */
  ctx.font = '600 9px "JetBrains Mono"';
  ctx.textAlign = 'center';
  ctx.fillStyle = 'rgba(0,229,255,0.6)';
  ctx.fillText('GYRO', 0, gr+14);
  ctx.fillStyle = 'rgba(0,229,255,0.5)';
  ctx.fillText(cAngle.toFixed(1)+'°', 0, -gr-6);

  ctx.restore();
}

/* 绘制轨迹 */
function drawTraces(){
  /* 底盘轨迹 - 青色 */
  if(traceChassis.length > 1){
    ctx.strokeStyle = 'rgba(0,229,255,0.25)';
    ctx.lineWidth = 1.5;
    ctx.setLineDash([6,4]);
    ctx.beginPath();
    ctx.moveTo(traceChassis[0].x, traceChassis[0].y);
    for(let i=1;i<traceChassis.length;i++) ctx.lineTo(traceChassis[i].x, traceChassis[i].y);
    ctx.stroke();
    ctx.setLineDash([]);
  }

  /* 货物轨迹 - 绿色 */
  if(traceCargo.length > 1){
    ctx.strokeStyle = 'rgba(0,230,118,0.35)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(traceCargo[0].x, traceCargo[0].y);
    for(let i=1;i<traceCargo.length;i++) ctx.lineTo(traceCargo[i].x, traceCargo[i].y);
    ctx.stroke();
  }

  /* 对比轨迹 - 红色 */
  if(ghostOn && traceGhost.length > 1){
    ctx.strokeStyle = 'rgba(255,61,90,0.3)';
    ctx.lineWidth = 1.5;
    ctx.setLineDash([4,4]);
    ctx.beginPath();
    ctx.moveTo(traceGhost[0].x, traceGhost[0].y);
    for(let i=1;i<traceGhost.length;i++) ctx.lineTo(traceGhost[i].x, traceGhost[i].y);
    ctx.stroke();
    ctx.setLineDash([]);
  }
}

/* 绘制阶段标注 */
function drawPhaseLabel(phase, phaseLabel, highlight){
  if(phase === 5) return;

  ctx.save();
  ctx.font = '700 15px "Exo 2"';
  ctx.textAlign = 'center';

  const labels = {
    0: { text: '平地行驶', sub: '双轮触地,稳定前行', color: 'rgba(100,160,220,0.7)' },
    1: { text: '撞击台阶', sub: '前行惯性驱动翻转', color: 'rgba(0,229,255,0.85)' },
    2: { text: '翻转跨步', sub: '行星支架旋转 120°,上层轮越顶落地', color: 'rgba(0,229,255,0.95)' },
    3: { text: '落位稳定', sub: '云台持续补偿,平台恢复水平', color: 'rgba(255,171,0,0.85)' },
    4: { text: '跨越完成', sub: '货物全程保持水平', color: 'rgba(0,230,118,0.85)' },
  };

  const info = labels[phase] || labels[0];

  /* 主标签 */
  ctx.fillStyle = info.color;
  ctx.fillText(info.text, W/2, 32);

  /* 副标签 */
  ctx.font = '400 12px "Exo 2"';
  ctx.fillStyle = 'rgba(160,185,220,0.5)';
  ctx.fillText(info.sub, W/2, 50);

  ctx.restore();
}

/* 绘制关键参数标注 */
function drawParamAnnotations(state){
  const {x, cy, cAngle, bAngle, gAngle, phase} = state;

  /* 臂长标注 (在翻转阶段显示) */
  if(phase >= 1 && phase <= 3){
    ctx.save();
    ctx.font = '500 10px "JetBrains Mono"';
    ctx.fillStyle = 'rgba(0,229,255,0.5)';
    ctx.textAlign = 'left';
    ctx.fillText('臂长 120mm', x + ARM_LEN + 18, cy - 8);
    ctx.restore();
  }

  /* 云台响应标注 */
  if(phase >= 2 && phase <= 3 && gimbalOn){
    ctx.save();
    ctx.font = '500 10px "JetBrains Mono"';
    ctx.fillStyle = 'rgba(255,171,0,0.55)';
    ctx.textAlign = 'left';
    const py = cy - CHASSIS_H/2 - GIMBAL_H/2;
    ctx.fillText('响应 < 20ms', x + 60, py - 10);
    ctx.restore();
  }

  /* IFR理想水平参考线 */
  if(phase >= 1 && phase <= 4){
    const idealY = GROUND_Y - ARM_LEN*0.5 - WHEEL_R - CHASSIS_H/2 - GIMBAL_H - PLAT_H/2 - 1 - CARGO_H/2;
    ctx.save();
    ctx.strokeStyle = gimbalOn ? 'rgba(0,230,118,0.15)' : 'rgba(255,61,90,0.15)';
    ctx.lineWidth = 1;
    ctx.setLineDash([8,6]);
    ctx.beginPath();
    ctx.moveTo(60, idealY);
    ctx.lineTo(W-60, idealY);
    ctx.stroke();
    ctx.setLineDash([]);

    ctx.font = '500 9px "JetBrains Mono"';
    ctx.fillStyle = gimbalOn ? 'rgba(0,230,118,0.3)' : 'rgba(255,61,90,0.3)';
    ctx.textAlign = 'right';
    ctx.fillText('IFR 理想水平面', W-65, idealY - 5);
    ctx.restore();
  }
}

/* 绘制翻转弧线指示 */
function drawFlipArc(cx, cy, bAngle, phase){
  if(phase < 1 || phase > 3) return;

  const startA = -60;
  const endA = bAngle;
  if(Math.abs(endA - startA) < 2) return;

  ctx.save();
  ctx.translate(cx, cy);

  /* 弧线 */
  ctx.strokeStyle = 'rgba(0,229,255,0.3)';
  ctx.lineWidth = 1.5;
  ctx.setLineDash([3,3]);
  ctx.beginPath();
  /* 在屏幕坐标中,0°是向下,顺时针为正 */
  const sa = rad(-startA + 90);
  const ea = rad(-endA + 90);
  ctx.arc(0, 0, ARM_LEN + 20, Math.min(sa,ea), Math.max(sa,ea));
  ctx.stroke();
  ctx.setLineDash([]);

  /* 箭头 */
  const ax = (ARM_LEN+20) * Math.cos(ea);
  const ay = (ARM_LEN+20) * Math.sin(ea);
  ctx.fillStyle = 'rgba(0,229,255,0.5)';
  ctx.beginPath();
  ctx.moveTo(ax, ay);
  ctx.lineTo(ax + 6*Math.cos(ea+0.5), ay + 6*Math.sin(ea+0.5));
  ctx.lineTo(ax + 6*Math.cos(ea-0.5), ay + 6*Math.sin(ea-0.5));
  ctx.fill();

  ctx.restore();
}

/* 绘制惯性力箭头 */
function drawInertiaArrow(x, cy, phase){
  if(phase < 1 || phase > 2) return;

  ctx.save();
  ctx.translate(x - CHASSIS_W/2 - 30, cy);

  const alpha = phase === 1 ? 0.6 : 0.4;
  ctx.strokeStyle = 'rgba(0,229,255,'+alpha+')';
  ctx.fillStyle = 'rgba(0,229,255,'+alpha+')';
  ctx.lineWidth = 2;

  /* 箭头线 */
  ctx.beginPath();
  ctx.moveTo(-25, 0);
  ctx.lineTo(10, 0);
  ctx.stroke();

  /* 箭头头 */
  ctx.beginPath();
  ctx.moveTo(15, 0);
  ctx.lineTo(8, -5);
  ctx.lineTo(8, 5);
  ctx.closePath();
  ctx.fill();

  /* 标签 */
  ctx.font = '500 9px "Exo 2"';
  ctx.textAlign = 'center';
  ctx.fillText('惯性', -5, -10);

  ctx.restore();
}

/* ====== 更新信息面板 ====== */
function updateInfoPanel(state){
  document.getElementById('vTilt').textContent = state.cAngle.toFixed(1)+'°';
  document.getElementById('vGimbal').textContent = state.gAngle.toFixed(1)+'°';
  document.getElementById('vBracket').textContent = state.bAngle.toFixed(0)+'°';

  const phaseEl = document.getElementById('vPhase');
  phaseEl.textContent = state.phaseLabel;
  phaseEl.className = 'v ' + (
    state.phase === 2 ? 'cyan' :
    state.phase === 3 ? 'amber' :
    state.phase === 4 ? 'green' :
    state.phase === 1 ? 'red' : 'green'
  );

  const gimbalEl = document.getElementById('vGimbal');
  gimbalEl.className = 'v ' + (gimbalOn ? 'amber' : 'muted');

  const tiltEl = document.getElementById('vTilt');
  tiltEl.className = 'v ' + (Math.abs(state.cAngle) > 5 ? 'red' : 'cyan');
}

/* ====== 主绘制函数 ====== */
function draw(state){
  ctx.clearRect(0,0,W,H);
  drawBg();
  drawGround();
  drawTraces();

  const {x, cy, cAngle, bAngle, gAngle, phase} = state;

  /* 计算关键位置 */
  const chassisTopY    = cy - CHASSIS_H/2;
  const gimbalTopY     = chassisTopY - GIMBAL_H;
  const platformY      = gimbalTopY - PLAT_H/2;
  const cargoCenterY   = gimbalTopY - PLAT_H/2 - 1 - CARGO_H/2;

  /* 翻转弧线 */
  drawFlipArc(x, cy, bAngle, phase);

  /* 惯性箭头 */
  drawInertiaArrow(x, cy, phase);

  /* 行星轮组 */
  const wheelHighlight = (phase >= 1 && phase <= 3) ? Math.min(1, (phase===2?1:0.5)) : 0;
  drawPlanetaryWheels(x, cy, bAngle, wheelHighlight);

  /* 底盘 */
  drawChassis(x, cy, cAngle);

  /* 云台 */
  const gimbalHighlight = (phase >= 2 && phase <= 3 && gimbalOn) ? 1 : 
                          (phase === 1 && gimbalOn ? 0.4 : 0);
  /* 云台连接点在底盘局部坐标的上边缘 */
  const gTopLocalY = -CHASSIS_H/2;
  const gBotLocalY = gTopLocalY - GIMBAL_H;

  /* 底盘旋转后的实际位置 */
  const cosA = Math.cos(rad(cAngle)), sinA = Math.sin(rad(cAngle));
  const chassisTopWorldY = cy + gTopLocalY * cosA;
  const platConnWorldY   = cy + gBotLocalY * cosA;

  drawGimbal(x, chassisTopWorldY, platConnWorldY, cAngle, gAngle, gimbalHighlight);

  /* 平台 - 以云台枢轴为中心旋转 */
  const pivotY = (chassisTopWorldY + platConnWorldY) / 2;
  const platY = pivotY;
  drawPlatform(x, platY, gimbalOn ? gAngle + cAngle : cAngle);

  /* 稳定货物 */
  const actualCargoAngle = gimbalOn ? gAngle + cAngle : cAngle;
  const cargoY = platY;
  drawCargo(x, cargoY, actualCargoAngle, gimbalOn ? 'green' : 'red', 1);

  /* 对比模式: 无补偿幽灵货物 */
  if(ghostOn){
    const ghostCargoY = cy + (gBotLocalY - PLAT_H/2 - 1 - CARGO_H/2) * cosA;
    drawCargo(x, cy, cAngle, 'red', 0.35);
  }

  /* 参数标注 */
  drawParamAnnotations(state);

  /* 阶段标注 */
  const phaseHighlight = phase === 2 ? 1 : (phase === 3 ? 0.7 : 0.3);
  drawPhaseLabel(phase, state.phaseLabel, phaseHighlight);

  /* 陀螺仪指示器 */
  drawGyroIndicator(cAngle, gAngle, phase);

  /* 失效边界提示 */
  if(stepH > ARM_LEN){
    ctx.save();
    ctx.font = '700 13px "Exo 2"';
    ctx.fillStyle = 'rgba(255,61,90,0.8)';
    ctx.textAlign = 'center';
    ctx.fillText('⚠ 台阶高度超过臂长,行星轮系失效', W/2, H-20);
    ctx.restore();
  }
}

/* ====== 动画主循环 ====== */
function animate(ts){
  if(!lastTs) lastTs = ts;
  const dt = Math.min(ts - lastTs, 50);
  lastTs = ts;

  progress += (dt * speed) / CYCLE_MS;
  if(progress >= 1){
    progress -= 1;
    resetTraces();
  }

  const state = calcState(progress);

  /* 记录轨迹 (每隔几帧) */
  const {x, cy, cAngle, gAngle} = state;
  const cosA = Math.cos(rad(cAngle));
  const chassisMidY = cy;
  const cargoTrackY = cy + (-CHASSIS_H/2 - GIMBAL_H - PLAT_H/2 - 1 - CARGO_H/2) * (gimbalOn ? 1 : cosA);
  const ghostTrackY = cy + (-CHASSIS_H/2 - GIMBAL_H - PLAT_H/2 - 1 - CARGO_H/2) * cosA;

  if(traceChassis.length === 0 || Math.abs(x - traceChassis[traceChassis.length-1].x) > 3){
    traceChassis.push({x, y: chassisMidY});
    traceCargo.push({x, y: gimbalOn ? cargoTrackY : ghostTrackY});
    if(ghostOn) traceGhost.push({x, y: ghostTrackY});
  }

  draw(state);
  updateInfoPanel(state);

  requestAnimationFrame(animate);
}

/* ====== 启动 ====== */
requestAnimationFrame(animate);

})();
</script>
</body>
</html>

这个实现是一个完整的、自动播放的原理动画,核心设计思路如下:

视觉分层与 IFR 表达

  • 深色工程蓝图风格背景配合网格,营造技术分析氛围
  • 行星轮系以青色(Cyan)高亮,自平衡云台以琥珀色(Amber)高亮——两种核心创新用截然不同的色彩语言区分
  • 绿色货物代表"理想状态"——全程水平;对比模式下红色幽灵货物展示"无补偿"后果
  • IFR 理想水平参考线(虚线)贯穿画面,直观表明货物垂直位移被消除

动画时序

  • 五个阶段自动循环:平地行驶 → 撞击台阶 → 翻转跨步 → 落位稳定 → 跨越完成
  • 翻转阶段最长(占 25% 时间),充分展示行星支架 120° 旋转的关键动作
  • 惯性力箭头和翻转弧线在关键阶段出现,强调"利用前行惯性驱动翻转"的资源利用思想

交互控制

  • 播放速度:0.2x–2.5x 可调,便于慢放观察细节
  • 台阶高度:40–110 可调,当超过臂长(75mm 对应值)时显示失效警告,演示适用边界
  • 云台补偿开关:关闭后货物随底盘倾斜变红,直观对比有无补偿的差异
  • 对比模式:同时显示半透明红色幽灵货物,一目了然

信息反馈

  • 右上角陀螺仪指示器实时显示底盘倾角和云台补偿角
  • 底部数据面板实时更新底盘倾角、云台补偿角、支架旋转角和当前阶段
  • 关键参数(臂长 120mm、响应 < 20ms)在对应阶段自动浮现标注
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>行星式翻转轮组 · 主动自平衡云台 — 原理动画</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Exo+2:wght@300;400;600;700&family=JetBrains+Mono:wght@300;500;700&display=swap" rel="stylesheet">
<style>
:root{
  --bg:#060a14;--fg:#d8e2f0;--muted:#4a5c78;
  --cyan:#00e5ff;--amber:#ffab00;--green:#00e676;--red:#ff3d5a;
  --card:#0c1628;--border:#162040;
}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--fg);font-family:'Exo 2',sans-serif;
  min-height:100vh;display:flex;flex-direction:column;align-items:center;
  overflow-x:hidden;padding:18px 12px 32px}
header{text-align:center;margin-bottom:14px}
header h1{font-family:'Orbitron',sans-serif;font-weight:700;font-size:clamp(18px,2.6vw,28px);
  letter-spacing:2px;color:var(--fg);
  background:linear-gradient(90deg,var(--cyan),var(--amber));
  -webkit-background-clip:text;-webkit-text-fill-color:transparent}
header p{font-size:13px;color:var(--muted);margin-top:4px;letter-spacing:1px}
.canvas-wrap{width:100%;max-width:1100px;position:relative;
  border:1px solid var(--border);border-radius:12px;overflow:hidden;
  background:linear-gradient(170deg,#080e1e 0%,#0a1224 100%);
  box-shadow:0 0 40px rgba(0,229,255,.06),0 0 80px rgba(255,171,0,.03)}
canvas{display:block;width:100%;height:auto}
.controls{display:flex;flex-wrap:wrap;gap:16px 28px;align-items:center;
  justify-content:center;margin-top:16px;max-width:1100px;width:100%;
  padding:14px 20px;background:var(--card);border:1px solid var(--border);border-radius:10px}
.ctrl-group{display:flex;align-items:center;gap:8px;font-size:13px}
.ctrl-group label{color:var(--muted);white-space:nowrap;font-weight:600;letter-spacing:.5px}
.ctrl-group input[type=range]{-webkit-appearance:none;width:120px;height:5px;
  background:var(--border);border-radius:4px;outline:none}
.ctrl-group input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;
  width:16px;height:16px;border-radius:50%;background:var(--cyan);cursor:pointer;
  box-shadow:0 0 8px rgba(0,229,255,.5)}
.ctrl-group span.val{font-family:'JetBrains Mono',monospace;font-size:12px;
  color:var(--cyan);min-width:36px;text-align:right}
.toggle-btn{padding:6px 16px;border:1px solid var(--border);border-radius:6px;
  background:transparent;color:var(--muted);font-family:'Exo 2',sans-serif;
  font-size:13px;font-weight:600;cursor:pointer;transition:all .25s}
.toggle-btn.active{background:rgba(255,171,0,.12);color:var(--amber);
  border-color:var(--amber);box-shadow:0 0 12px rgba(255,171,0,.15)}
.toggle-btn:hover{border-color:var(--amber)}
.info-row{display:flex;flex-wrap:wrap;gap:10px 20px;justify-content:center;
  margin-top:12px;max-width:1100px;width:100%}
.info-chip{padding:5px 14px;border-radius:6px;font-size:12px;
  font-family:'JetBrains Mono',monospace;
  background:rgba(12,22,40,.7);border:1px solid var(--border)}
.info-chip .lbl{color:var(--muted);margin-right:6px}
.info-chip .v{font-weight:700}
.info-chip .v.cyan{color:var(--cyan)}
.info-chip .v.amber{color:var(--amber)}
.info-chip .v.green{color:var(--green)}
.info-chip .v.red{color:var(--red)}
.legend{display:flex;gap:18px;justify-content:center;margin-top:10px;font-size:12px;color:var(--muted)}
.legend i{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:5px;vertical-align:middle}
</style>
</head>
<body>

<header>
  <h1>行星式翻转轮组 · 主动自平衡云台</h1>
  <p>TRIZ 最终理想解 (IFR) 原理演示 — 移动与平衡解耦</p>
</header>

<div class="canvas-wrap">
  <canvas id="c" width="1200" height="650"></canvas>
</div>

<div class="controls">
  <div class="ctrl-group">
    <label>播放速度</label>
    <input type="range" id="speedSlider" min="0.2" max="2.5" step="0.1" value="1">
    <span class="val" id="speedVal">1.0x</span>
  </div>
  <div class="ctrl-group">
    <label>台阶高度</label>
    <input type="range" id="stepSlider" min="40" max="110" step="5" value="85">
    <span class="val" id="stepVal">85</span>
  </div>
  <button class="toggle-btn active" id="gimbalBtn">云台补偿: 开启</button>
  <button class="toggle-btn" id="ghostBtn">对比模式: 关闭</button>
</div>

<div class="info-row">
  <div class="info-chip"><span class="lbl">底盘倾角</span><span class="v cyan" id="vTilt">0.0°</span></div>
  <div class="info-chip"><span class="lbl">云台补偿</span><span class="v amber" id="vGimbal">0.0°</span></div>
  <div class="info-chip"><span class="lbl">支架旋转</span><span class="v cyan" id="vBracket">-60°</span></div>
  <div class="info-chip"><span class="lbl">阶段</span><span class="v green" id="vPhase">平地行驶</span></div>
</div>

<div class="legend">
  <span><i style="background:var(--cyan)"></i>行星轮系</span>
  <span><i style="background:var(--amber)"></i>自平衡云台</span>
  <span><i style="background:var(--green)"></i>稳定货台</span>
  <span><i style="background:var(--red)"></i>无补偿对比</span>
</div>

<script>
(function(){
'use strict';

/* ====== 画布与上下文 ====== */
const cv = document.getElementById('c');
const ctx = cv.getContext('2d');
const W = 1200, H = 650;
const dpr = Math.min(window.devicePixelRatio||1, 2);
cv.width = W * dpr; cv.height = H * dpr;
ctx.scale(dpr, dpr);

/* ====== 配置常量 ====== */
const GROUND_Y   = 510;
const STEP_X     = 570;
const ARM_LEN    = 75;
const WHEEL_R    = 13;
const CHASSIS_W  = 190;
const CHASSIS_H  = 26;
const GIMBAL_H   = 30;
const PLAT_W     = 158;
const PLAT_H     = 12;
const CARGO_W    = 88;
const CARGO_H    = 52;
const CYCLE_MS   = 9500;

/* ====== 可变状态 ====== */
let speed       = 1;
let stepH       = 85;
let gimbalOn    = true;
let ghostOn     = false;
let progress    = 0;
let lastTs      = null;

/* ====== 轨迹缓存 ====== */
let traceChassis = [];
let traceCargo   = [];
let traceGhost   = [];

/* ====== 控件绑定 ====== */
const elSpeed   = document.getElementById('speedSlider');
const elSpeedV  = document.getElementById('speedVal');
const elStep    = document.getElementById('stepSlider');
const elStepV   = document.getElementById('stepVal');
const elGimbal  = document.getElementById('gimbalBtn');
const elGhost   = document.getElementById('ghostBtn');

elSpeed.oninput = ()=>{ speed = +elSpeed.value; elSpeedV.textContent = speed.toFixed(1)+'x'; };
elStep.oninput  = ()=>{ stepH = +elStep.value; elStepV.textContent = stepH; resetTraces(); };
elGimbal.onclick = ()=>{
  gimbalOn = !gimbalOn;
  elGimbal.textContent = '云台补偿: '+(gimbalOn?'开启':'关闭');
  elGimbal.classList.toggle('active', gimbalOn);
  resetTraces();
};
elGhost.onclick = ()=>{
  ghostOn = !ghostOn;
  elGhost.textContent = '对比模式: '+(ghostOn?'开启':'关闭');
  elGhost.classList.toggle('active', ghostOn);
};

function resetTraces(){ traceChassis=[]; traceCargo=[]; traceGhost=[]; }

/* ====== 缓动函数 ====== */
function eio2(t){ return t<.5?2*t*t:-1+(4-2*t)*t; }
function eio3(t){ return t<.5?4*t*t*t:(t-1)*(2*t-2)*(2*t-2)+1; }
function eo3(t){ return 1-Math.pow(1-t,3); }
function eo4(t){ return 1-Math.pow(1-t,4); }

/* ====== 角度转弧度 ====== */
const rad = d => d * Math.PI / 180;

/* ====== 计算动画状态 ====== */
/*
 * 修正说明:行星轮组在撞台阶后应顺时针旋转(屏幕坐标),
 * 即支架角 bAngle 从 -60° 递减至 -180°(递减 120°)。
 * 这样顶部小轮向右前方翻转越过台阶,成为新的支撑点。
 * 底盘倾角 cAngle 相应取负值(前端抬起)。
 */
function calcState(p){
  const startX   = 155;
  const contactX = STEP_X - ARM_LEN * 0.42;
  const flatCY   = GROUND_Y - ARM_LEN*0.5 - WHEEL_R;
  const upperCY  = GROUND_Y - stepH - ARM_LEN*0.5 - WHEEL_R;
  const rise     = flatCY - upperCY;

  let x, cy, cAngle, bAngle, gAngle, phase, phaseLabel;

  if(p < 0.20){
    /* 平地行驶 */
    phase = 0; phaseLabel = '平地行驶';
    const t = p / 0.20;
    x = startX + t * (contactX - startX);
    cy = flatCY;
    cAngle = 0; bAngle = -60; gAngle = 0;
  } else if(p < 0.33){
    /* 撞击台阶 */
    phase = 1; phaseLabel = '撞击台阶';
    const t = (p-0.20)/0.13;
    const e = eio2(t);
    x = contactX + e * 25;
    cy = flatCY - e * rise * 0.12;
    cAngle = -e * 10;            // 前端抬起 → 负角
    bAngle = -60 - e * 28;       // 递减:支架顺时针翻转
    gAngle = gimbalOn ? -cAngle : 0;
  } else if(p < 0.58){
    /* 行星翻转跨步 */
    phase = 2; phaseLabel = '翻转跨步';
    const t = (p-0.33)/0.25;
    const e = eio3(t);
    x = contactX + 25 + e * 75;
    cy = flatCY - rise*0.12 - e * rise*0.88;
    cAngle = -10 - e * 22;       // 继续负向增大
    bAngle = -88 - e * 92;       // 继续递减至 -180°
    gAngle = gimbalOn ? -cAngle : 0;
  } else if(p < 0.72){
    /* 落位稳定 */
    phase = 3; phaseLabel = '落位稳定';
    const t = (p-0.58)/0.14;
    const e = eo4(t);
    x = contactX + 100 + e * 65;
    cy = upperCY + (1-e)*4;
    cAngle = -32 * (1-e);        // 负角逐渐回到 0
    bAngle = -180;               // 翻转完成
    gAngle = gimbalOn ? -cAngle : 0;
  } else if(p < 0.87){
    /* 跨越完成 */
    phase = 4; phaseLabel = '跨越完成';
    x = contactX + 165;
    cy = upperCY;
    cAngle = 0; bAngle = -180; gAngle = 0;
  } else {
    /* 重置 */
    phase = 5; phaseLabel = '—';
    x = contactX + 165;
    cy = upperCY;
    cAngle = 0; bAngle = -180; gAngle = 0;
  }

  return { x, cy, cAngle, bAngle, gAngle, phase, phaseLabel };
}

/* ====== 绘图辅助 ====== */
function drawRoundRect(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 drawBg(){
  const grd = ctx.createLinearGradient(0,0,0,H);
  grd.addColorStop(0,'#070c1a');
  grd.addColorStop(1,'#0a1228');
  ctx.fillStyle = grd;
  ctx.fillRect(0,0,W,H);

  ctx.strokeStyle = 'rgba(22,40,72,0.35)';
  ctx.lineWidth = 0.5;
  for(let gx=0; gx<W; gx+=40){ ctx.beginPath(); ctx.moveTo(gx,0); ctx.lineTo(gx,H); ctx.stroke(); }
  for(let gy=0; gy<H; gy+=40){ ctx.beginPath(); ctx.moveTo(0,gy); ctx.lineTo(W,gy); ctx.stroke(); }
}

/* 绘制地面和台阶 */
function drawGround(){
  /* 下层地面 */
  const grd = ctx.createLinearGradient(0,GROUND_Y,0,GROUND_Y+60);
  grd.addColorStop(0,'#1a2640');
  grd.addColorStop(1,'#0d1628');
  ctx.fillStyle = grd;
  ctx.fillRect(0, GROUND_Y, STEP_X, H-GROUND_Y);

  /* 上层地面 */
  const grd2 = ctx.createLinearGradient(0,GROUND_Y-stepH,0,H);
  grd2.addColorStop(0,'#1e2d4a');
  grd2.addColorStop(1,'#0d1628');
  ctx.fillStyle = grd2;
  ctx.fillRect(STEP_X, GROUND_Y-stepH, W-STEP_X, H-(GROUND_Y-stepH));

  /* 台阶立面 */
  ctx.fillStyle = '#2a3d60';
  ctx.fillRect(STEP_X-2, GROUND_Y-stepH, 4, stepH);

  /* 台阶顶边高亮 */
  ctx.strokeStyle = 'rgba(0,229,255,0.25)';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(STEP_X, GROUND_Y-stepH);
  ctx.lineTo(W, GROUND_Y-stepH);
  ctx.stroke();

  /* 台阶立面高亮 */
  ctx.strokeStyle = 'rgba(0,229,255,0.15)';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(STEP_X, GROUND_Y-stepH);
  ctx.lineTo(STEP_X, GROUND_Y);
  ctx.stroke();

  /* 地面线 */
  ctx.strokeStyle = 'rgba(100,140,200,0.2)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(0,GROUND_Y);
  ctx.lineTo(STEP_X,GROUND_Y);
  ctx.stroke();

  /* 台阶高度标注 */
  ctx.save();
  ctx.strokeStyle = 'rgba(0,229,255,0.35)';
  ctx.lineWidth = 1;
  ctx.setLineDash([4,4]);
  const hx = STEP_X + 22;
  ctx.beginPath();
  ctx.moveTo(hx, GROUND_Y);
  ctx.lineTo(hx, GROUND_Y-stepH);
  ctx.stroke();
  ctx.setLineDash([]);
  ctx.fillStyle = 'rgba(0,229,255,0.5)';
  ctx.beginPath(); ctx.moveTo(hx,GROUND_Y); ctx.lineTo(hx-4,GROUND_Y-6); ctx.lineTo(hx+4,GROUND_Y-6); ctx.fill();
  ctx.beginPath(); ctx.moveTo(hx,GROUND_Y-stepH); ctx.lineTo(hx-4,GROUND_Y-stepH+6); ctx.lineTo(hx+4,GROUND_Y-stepH+6); ctx.fill();
  ctx.font = '600 11px "JetBrains Mono"';
  ctx.fillStyle = 'rgba(0,229,255,0.6)';
  ctx.textAlign = 'left';
  ctx.fillText(stepH+'mm', hx+6, GROUND_Y-stepH/2+4);
  ctx.restore();
}

/* 绘制行星轮组 */
function drawPlanetaryWheels(cx, cy, bAngle, highlight){
  ctx.save();
  ctx.translate(cx, cy);

  /* 发光效果 */
  if(highlight > 0){
    ctx.shadowColor = 'rgba(0,229,255,'+0.5*highlight+')';
    ctx.shadowBlur = 18 * highlight;
  }

  /* 中心轮毂 */
  ctx.fillStyle = '#1a3050';
  ctx.strokeStyle = 'rgba(0,229,255,'+(0.5+0.5*highlight)+')';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.arc(0, 0, 10, 0, Math.PI*2);
  ctx.fill(); ctx.stroke();

  /* 三个臂和轮 */
  for(let i=0; i<3; i++){
    const a = rad(bAngle + i*120);
    const wx = ARM_LEN * Math.sin(a);
    const wy = ARM_LEN * Math.cos(a);

    /* 臂 */
    ctx.strokeStyle = 'rgba(0,229,255,'+(0.4+0.6*highlight)+')';
    ctx.lineWidth = 3;
    ctx.beginPath();
    ctx.moveTo(0,0);
    ctx.lineTo(wx, wy);
    ctx.stroke();

    /* 轮 */
    ctx.fillStyle = '#0d1a2e';
    ctx.strokeStyle = 'rgba(0,229,255,'+(0.5+0.5*highlight)+')';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(wx, wy, WHEEL_R, 0, Math.PI*2);
    ctx.fill(); ctx.stroke();

    /* 轮辐 */
    ctx.strokeStyle = 'rgba(0,229,255,'+(0.2+0.3*highlight)+')';
    ctx.lineWidth = 1;
    for(let s=0;s<3;s++){
      const sa = s*Math.PI/3;
      ctx.beginPath();
      ctx.moveTo(wx+WHEEL_R*0.4*Math.cos(sa), wy+WHEEL_R*0.4*Math.sin(sa));
      ctx.lineTo(wx+WHEEL_R*0.85*Math.cos(sa), wy+WHEEL_R*0.85*Math.sin(sa));
      ctx.stroke();
    }
  }

  ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0;
  ctx.restore();
}

/* 绘制底盘 */
function drawChassis(cx, cy, angle){
  ctx.save();
  ctx.translate(cx, cy);
  ctx.rotate(rad(angle));

  const hw = CHASSIS_W/2, hh = CHASSIS_H/2;

  const grd = ctx.createLinearGradient(0,-hh,0,hh);
  grd.addColorStop(0,'#3d5a80');
  grd.addColorStop(1,'#2a4060');
  ctx.fillStyle = grd;
  drawRoundRect(-hw, -hh, CHASSIS_W, CHASSIS_H, 4);
  ctx.fill();

  ctx.strokeStyle = 'rgba(100,160,220,0.3)';
  ctx.lineWidth = 1;
  drawRoundRect(-hw, -hh, CHASSIS_W, CHASSIS_H, 4);
  ctx.stroke();

  ctx.strokeStyle = 'rgba(100,160,220,0.15)';
  ctx.lineWidth = 0.5;
  for(let lx=-hw+20; lx<hw; lx+=25){
    ctx.beginPath(); ctx.moveTo(lx,-hh+3); ctx.lineTo(lx,hh-3); ctx.stroke();
  }

  ctx.restore();
}

/* 绘制云台机构 */
function drawGimbal(cx, chassisTopY, platBottomY, highlight){
  ctx.save();
  ctx.translate(cx, 0);

  const cTop = chassisTopY;
  const pBot = platBottomY;
  const midY = (cTop + pBot) / 2;

  if(highlight > 0){
    ctx.shadowColor = 'rgba(255,171,0,'+0.5*highlight+')';
    ctx.shadowBlur = 14*highlight;
  }

  const lOff = 45;
  drawHydraulicRod(-lOff, cTop, -lOff-3, pBot, highlight);
  drawHydraulicRod(lOff, cTop, lOff+3, pBot, highlight);

  /* 中心枢轴 */
  ctx.fillStyle = highlight>0 ? 'rgba(255,171,0,'+(0.5+0.5*highlight)+')' : '#3a4a60';
  ctx.strokeStyle = 'rgba(255,171,0,'+(0.3+0.7*highlight)+')';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.arc(0, midY, 7, 0, Math.PI*2);
  ctx.fill(); ctx.stroke();

  ctx.fillStyle = '#0d1a2e';
  ctx.beginPath();
  ctx.arc(0, midY, 3, 0, Math.PI*2);
  ctx.fill();

  ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0;
  ctx.restore();
}

function drawHydraulicRod(x1,y1,x2,y2, hl){
  ctx.strokeStyle = hl>0 ? 'rgba(255,171,0,'+(0.4+0.6*hl)+')' : '#3a5070';
  ctx.lineWidth = 5;
  ctx.lineCap = 'round';
  ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke();

  ctx.strokeStyle = hl>0 ? 'rgba(255,171,0,'+(0.6+0.4*hl)+')' : '#5a7a9a';
  ctx.lineWidth = 2;
  const mx = (x1+x2)/2, my = (y1+y2)/2;
  ctx.beginPath();
  ctx.moveTo(mx, my-2); ctx.lineTo(x2, y2);
  ctx.stroke();
  ctx.lineCap = 'butt';
}

/* 绘制平台 */
function drawPlatform(cx, py, angle){
  ctx.save();
  ctx.translate(cx, py);
  ctx.rotate(rad(angle));

  const hw = PLAT_W/2, hh = PLAT_H/2;
  ctx.fillStyle = '#2a3a52';
  ctx.strokeStyle = 'rgba(100,160,220,0.3)';
  ctx.lineWidth = 1;
  drawRoundRect(-hw, -hh, PLAT_W, PLAT_H, 3);
  ctx.fill(); ctx.stroke();

  ctx.restore();
}

/* 绘制货物 */
function drawCargo(cx, py, angle, color, alpha){
  ctx.save();
  ctx.translate(cx, py);
  ctx.rotate(rad(angle));
  ctx.globalAlpha = alpha;

  const hw = CARGO_W/2, hh = CARGO_H/2;
  const yOff = -PLAT_H/2 - hh - 1;

  ctx.fillStyle = color === 'green' ? '#0a3a2a' : '#3a1a1a';
  ctx.strokeStyle = color === 'green' ? 'rgba(0,230,118,0.7)' : 'rgba(255,61,90,0.7)';
  ctx.lineWidth = 1.5;
  drawRoundRect(-hw, yOff, CARGO_W, CARGO_H, 4);
  ctx.fill(); ctx.stroke();

  ctx.strokeStyle = color === 'green' ? 'rgba(0,230,118,0.3)' : 'rgba(255,61,90,0.3)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(-hw+8, yOff+8); ctx.lineTo(hw-8, yOff+8);
  ctx.stroke();
  ctx.beginPath();
  ctx.moveTo(-hw+8, yOff+CARGO_H-8); ctx.lineTo(hw-8, yOff+CARGO_H-8);
  ctx.stroke();

  ctx.font = '600 9px "JetBrains Mono"';
  ctx.fillStyle = color === 'green' ? 'rgba(0,230,118,0.6)' : 'rgba(255,61,90,0.6)';
  ctx.textAlign = 'center';
  ctx.fillText('CARGO', 0, yOff + CARGO_H/2 + 3);

  ctx.globalAlpha = 1;
  ctx.restore();
}

/* 绘制陀螺仪指示器 */
function drawGyroIndicator(cAngle, gAngle){
  const gx = 1080, gy = 80, gr = 36;

  ctx.save();
  ctx.translate(gx, gy);

  ctx.strokeStyle = 'rgba(100,140,200,0.2)';
  ctx.lineWidth = 1.5;
  ctx.beginPath(); ctx.arc(0,0,gr,0,Math.PI*2); ctx.stroke();

  /* 水平参考线 */
  ctx.strokeStyle = 'rgba(0,230,118,0.3)';
  ctx.lineWidth = 1;
  ctx.setLineDash([3,3]);
  ctx.beginPath(); ctx.moveTo(-gr+4,0); ctx.lineTo(gr-4,0); ctx.stroke();
  ctx.setLineDash([]);

  /* 底盘倾角指针 */
  ctx.strokeStyle = 'rgba(0,229,255,0.8)';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(0,0);
  ctx.lineTo(gr*0.8*Math.sin(rad(cAngle)), gr*0.8*Math.cos(rad(cAngle)));
  ctx.stroke();

  /* 云台补偿角指针 */
  if(gimbalOn && Math.abs(gAngle) > 0.5){
    ctx.strokeStyle = 'rgba(255,171,0,0.8)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(0,0);
    ctx.lineTo(gr*0.6*Math.sin(rad(gAngle)), gr*0.6*Math.cos(rad(gAngle)));
    ctx.stroke();
  }

  ctx.fillStyle = '#4a6080';
  ctx.beginPath(); ctx.arc(0,0,3,0,Math.PI*2); ctx.fill();

  ctx.font = '600 9px "JetBrains Mono"';
  ctx.textAlign = 'center';
  ctx.fillStyle = 'rgba(0,229,255,0.6)';
  ctx.fillText('GYRO', 0, gr+14);
  ctx.fillStyle = 'rgba(0,229,255,0.5)';
  ctx.fillText(cAngle.toFixed(1)+'°', 0, -gr-6);

  ctx.restore();
}

/* 绘制轨迹 */
function drawTraces(){
  if(traceChassis.length > 1){
    ctx.strokeStyle = 'rgba(0,229,255,0.25)';
    ctx.lineWidth = 1.5;
    ctx.setLineDash([6,4]);
    ctx.beginPath();
    ctx.moveTo(traceChassis[0].x, traceChassis[0].y);
    for(let i=1;i<traceChassis.length;i++) ctx.lineTo(traceChassis[i].x, traceChassis[i].y);
    ctx.stroke();
    ctx.setLineDash([]);
  }

  if(traceCargo.length > 1){
    ctx.strokeStyle = 'rgba(0,230,118,0.35)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(traceCargo[0].x, traceCargo[0].y);
    for(let i=1;i<traceCargo.length;i++) ctx.lineTo(traceCargo[i].x, traceCargo[i].y);
    ctx.stroke();
  }

  if(ghostOn && traceGhost.length > 1){
    ctx.strokeStyle = 'rgba(255,61,90,0.3)';
    ctx.lineWidth = 1.5;
    ctx.setLineDash([4,4]);
    ctx.beginPath();
    ctx.moveTo(traceGhost[0].x, traceGhost[0].y);
    for(let i=1;i<traceGhost.length;i++) ctx.lineTo(traceGhost[i].x, traceGhost[i].y);
    ctx.stroke();
    ctx.setLineDash([]);
  }
}

/* 绘制阶段标注 */
function drawPhaseLabel(phase){
  if(phase === 5) return;

  ctx.save();
  ctx.font = '700 15px "Exo 2"';
  ctx.textAlign = 'center';

  const labels = {
    0: { text: '平地行驶', sub: '双轮触地,稳定前行', color: 'rgba(100,160,220,0.7)' },
    1: { text: '撞击台阶', sub: '前行惯性驱动翻转', color: 'rgba(0,229,255,0.85)' },
    2: { text: '翻转跨步', sub: '行星支架顺时针旋转 120°,上方小轮越过台阶落地', color: 'rgba(0,229,255,0.95)' },
    3: { text: '落位稳定', sub: '云台持续补偿,平台恢复水平', color: 'rgba(255,171,0,0.85)' },
    4: { text: '跨越完成', sub: '货物全程保持水平', color: 'rgba(0,230,118,0.85)' },
  };

  const info = labels[phase] || labels[0];
  ctx.fillStyle = info.color;
  ctx.fillText(info.text, W/2, 32);
  ctx.font = '400 12px "Exo 2"';
  ctx.fillStyle = 'rgba(160,185,220,0.5)';
  ctx.fillText(info.sub, W/2, 50);
  ctx.restore();
}

/* 绘制关键参数标注 */
function drawParamAnnotations(state){
  const {x, cy, cAngle, bAngle, gAngle, phase} = state;

  if(phase >= 1 && phase <= 3){
    ctx.save();
    ctx.font = '500 10px "JetBrains Mono"';
    ctx.fillStyle = 'rgba(0,229,255,0.5)';
    ctx.textAlign = 'left';
    ctx.fillText('臂长 120mm', x + ARM_LEN + 18, cy - 8);
    ctx.restore();
  }

  if(phase >= 2 && phase <= 3 && gimbalOn){
    ctx.save();
    ctx.font = '500 10px "JetBrains Mono"';
    ctx.fillStyle = 'rgba(255,171,0,0.55)';
    ctx.textAlign = 'left';
    const py = cy - CHASSIS_H/2 - GIMBAL_H/2;
    ctx.fillText('响应 < 20ms', x + 60, py - 10);
    ctx.restore();
  }

  if(phase >= 1 && phase <= 4){
    const idealY = GROUND_Y - ARM_LEN*0.5 - WHEEL_R - CHASSIS_H/2 - GIMBAL_H - PLAT_H/2 - 1 - CARGO_H/2;
    ctx.save();
    ctx.strokeStyle = gimbalOn ? 'rgba(0,230,118,0.15)' : 'rgba(255,61,90,0.15)';
    ctx.lineWidth = 1;
    ctx.setLineDash([8,6]);
    ctx.beginPath();
    ctx.moveTo(60, idealY);
    ctx.lineTo(W-60, idealY);
    ctx.stroke();
    ctx.setLineDash([]);

    ctx.font = '500 9px "JetBrains Mono"';
    ctx.fillStyle = gimbalOn ? 'rgba(0,230,118,0.3)' : 'rgba(255,61,90,0.3)';
    ctx.textAlign = 'right';
    ctx.fillText('IFR 理想水平面', W-65, idealY - 5);
    ctx.restore();
  }
}

/* 绘制翻转弧线指示 — 修正方向 */
function drawFlipArc(cx, cy, bAngle, phase){
  if(phase < 1 || phase > 3) return;

  /* 起始支架角 -60° 对应的 canvas 弧角 */
  const startCanvas = Math.PI/2 - rad(-60);  // = 5π/6 ≈ 150°
  /* 当前支架角对应的 canvas 弧角 */
  const endCanvas   = Math.PI/2 - rad(bAngle);

  const r = ARM_LEN + 22;

  ctx.save();
  ctx.translate(cx, cy);

  /* 弧线 — clockwise (anticlockwise=false) */
  ctx.strokeStyle = 'rgba(0,229,255,0.3)';
  ctx.lineWidth = 1.5;
  ctx.setLineDash([3,3]);
  ctx.beginPath();
  ctx.arc(0, 0, r, startCanvas, endCanvas, false);
  ctx.stroke();
  ctx.setLineDash([]);

  /* 箭头:顺时针切线方向 */
  const tipX = r * Math.cos(endCanvas);
  const tipY = r * Math.sin(endCanvas);
  const tangentDir = Math.atan2(-Math.cos(endCanvas), Math.sin(endCanvas));

  ctx.strokeStyle = 'rgba(0,229,255,0.5)';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(tipX, tipY);
  ctx.lineTo(tipX - 9*Math.cos(tangentDir - 0.45), tipY - 9*Math.sin(tangentDir - 0.45));
  ctx.stroke();
  ctx.beginPath();
  ctx.moveTo(tipX, tipY);
  ctx.lineTo(tipX - 9*Math.cos(tangentDir + 0.45), tipY - 9*Math.sin(tangentDir + 0.45));
  ctx.stroke();

  /* 旋转方向文字 */
  ctx.font = '500 9px "Exo 2"';
  ctx.fillStyle = 'rgba(0,229,255,0.45)';
  ctx.textAlign = 'center';
  const labelAngle = (startCanvas + endCanvas) / 2;
  const labelR = r + 14;
  ctx.fillText('顺时针翻转', labelR * Math.cos(labelAngle), labelR * Math.sin(labelAngle) + 3);

  ctx.restore();
}

/* 绘制惯性力箭头 */
function drawInertiaArrow(x, cy, phase){
  if(phase < 1 || phase > 2) return;

  ctx.save();
  ctx.translate(x - CHASSIS_W/2 - 30, cy);

  const alpha = phase === 1 ? 0.6 : 0.4;
  ctx.strokeStyle = 'rgba(0,229,255,'+alpha+')';
  ctx.fillStyle = 'rgba(0,229,255,'+alpha+')';
  ctx.lineWidth = 2;

  ctx.beginPath();
  ctx.moveTo(-25, 0);
  ctx.lineTo(10, 0);
  ctx.stroke();

  ctx.beginPath();
  ctx.moveTo(15, 0);
  ctx.lineTo(8, -5);
  ctx.lineTo(8, 5);
  ctx.closePath();
  ctx.fill();

  ctx.font = '500 9px "Exo 2"';
  ctx.textAlign = 'center';
  ctx.fillText('惯性', -5, -10);

  ctx.restore();
}

/* ====== 更新信息面板 ====== */
function updateInfoPanel(state){
  document.getElementById('vTilt').textContent = state.cAngle.toFixed(1)+'°';
  document.getElementById('vGimbal').textContent = state.gAngle.toFixed(1)+'°';

  /* 支架旋转显示:相对于初始 -60° 的旋转量 */
  const bracketDelta = state.bAngle - (-60);
  document.getElementById('vBracket').textContent = bracketDelta.toFixed(0)+'°';

  const phaseEl = document.getElementById('vPhase');
  phaseEl.textContent = state.phaseLabel;
  phaseEl.className = 'v ' + (
    state.phase === 2 ? 'cyan' :
    state.phase === 3 ? 'amber' :
    state.phase === 4 ? 'green' :
    state.phase === 1 ? 'red' : 'green'
  );

  const gimbalEl = document.getElementById('vGimbal');
  gimbalEl.className = 'v ' + (gimbalOn ? 'amber' : 'muted');

  const tiltEl = document.getElementById('vTilt');
  tiltEl.className = 'v ' + (Math.abs(state.cAngle) > 5 ? 'red' : 'cyan');
}

/* ====== 主绘制函数 ====== */
function draw(state){
  ctx.clearRect(0,0,W,H);
  drawBg();
  drawGround();
  drawTraces();

  const {x, cy, cAngle, bAngle, gAngle, phase} = state;

  /* 底盘局部坐标系中的关键 Y 偏移 */
  const cosA = Math.cos(rad(cAngle));
  const sinA = Math.sin(rad(cAngle));

  /* 底盘上边缘在局部坐标中的偏移 */
  const topLocalY = -CHASSIS_H/2;
  /* 底盘上边缘在世界坐标中的位置(近似) */
  const chassisTopWorldY = cy + topLocalY * cosA;
  /* 云台底部 = 底盘上边缘 - GIMBAL_H */
  const gimbalBotWorldY = chassisTopWorldY - GIMBAL_H;

  /* 翻转弧线 */
  drawFlipArc(x, cy, bAngle, phase);

  /* 惯性箭头 */
  drawInertiaArrow(x, cy, phase);

  /* 行星轮组 */
  const wheelHighlight = (phase >= 1 && phase <= 3) ? Math.min(1, (phase===2?1:0.5)) : 0;
  drawPlanetaryWheels(x, cy, bAngle, wheelHighlight);

  /* 底盘 */
  drawChassis(x, cy, cAngle);

  /* 云台 */
  const gimbalHighlight = (phase >= 2 && phase <= 3 && gimbalOn) ? 1 :
                          (phase === 1 && gimbalOn ? 0.4 : 0);
  drawGimbal(x, chassisTopWorldY, gimbalBotWorldY, gimbalHighlight);

  /* 平台和货物 — 以云台枢轴为旋转中心 */
  const pivotY = (chassisTopWorldY + gimbalBotWorldY) / 2;
  const absolutePlatAngle = gimbalOn ? (cAngle + gAngle) : cAngle;

  drawPlatform(x, pivotY, absolutePlatAngle);
  drawCargo(x, pivotY, absolutePlatAngle, gimbalOn ? 'green' : 'red', 1);

  /* 对比模式:无补偿幽灵货物 */
  if(ghostOn){
    /* 幽灵货物跟随底盘倾斜,无云台补偿 */
    drawCargo(x, cy, cAngle, 'red', 0.35);
  }

  /* 参数标注 */
  drawParamAnnotations(state);

  /* 阶段标注 */
  drawPhaseLabel(phase);

  /* 陀螺仪指示器 */
  drawGyroIndicator(cAngle, gAngle);

  /* 失效边界提示 */
  if(stepH > ARM_LEN){
    ctx.save();
    ctx.font = '700 13px "Exo 2"';
    ctx.fillStyle = 'rgba(255,61,90,0.8)';
    ctx.textAlign = 'center';
    ctx.fillText('⚠ 台阶高度超过臂长,行星轮系失效', W/2, H-20);
    ctx.restore();
  }
}

/* ====== 动画主循环 ====== */
function animate(ts){
  if(!lastTs) lastTs = ts;
  const dt = Math.min(ts - lastTs, 50);
  lastTs = ts;

  progress += (dt * speed) / CYCLE_MS;
  if(progress >= 1){
    progress -= 1;
    resetTraces();
  }

  const state = calcState(progress);

  /* 记录轨迹 */
  const {x, cy, cAngle, gAngle} = state;
  const cosA = Math.cos(rad(cAngle));
  const topLocalY = -CHASSIS_H/2;
  const chassisTopWorldY = cy + topLocalY * cosA;
  const pivotY = (chassisTopWorldY + (chassisTopWorldY - GIMBAL_H)) / 2;

  /* 货物中心 Y:云台补偿时保持水平,无补偿时随底盘倾斜 */
  const cargoTrackOffset = -PLAT_H/2 - 1 - CARGO_H/2;
  const cargoY = pivotY + cargoTrackOffset * (gimbalOn ? 1 : cosA);
  const ghostY = cy + (topLocalY - GIMBAL_H/2 + cargoTrackOffset) * cosA;

  if(traceChassis.length === 0 || Math.abs(x - traceChassis[traceChassis.length-1].x) > 3){
    traceChassis.push({x, y: cy});
    traceCargo.push({x, y: cargoY});
    if(ghostOn) traceGhost.push({x, y: ghostY});
  }

  draw(state);
  updateInfoPanel(state);

  requestAnimationFrame(animate);
}

/* ====== 启动 ====== */
requestAnimationFrame(animate);

})();
</script>
</body>
</html>

修正内容说明:

核心修复:行星轮组旋转方向

原代码中 bAngle-60° 递增到 +60°,导致顶部小轮向左后方翻转——方向错误。修正后 bAngle-60° 递减至 -180°(减少 120°),顶部小轮现在正确地向右前方翻转越过台阶边缘落地。

具体变更点:

参数 原值 修正值 物理含义
bAngle 终态 +60° -180° 支架顺时针旋转 120°,上方小轮越过台阶
cAngle 峰值 +32° -32° 前端抬起(负角=右端上行),与翻转方向一致
gAngle -cAngle -cAngle(不变) 正角补偿负倾角,平台保持水平

同步修正的其他部分:

  • drawFlipArc:弧线从 5π/6π/2 - rad(bAngle) 顺时针绘制,箭头指向顺时针切线方向,并标注"顺时针翻转"
  • 阶段 2 副标题改为"行星支架顺时针旋转 120°"
  • 信息面板中"支架旋转"显示相对初始角的偏移量(0° → -120°)
  • 幽灵货物和轨迹计算适配负角底盘倾角

我来修正行星轮系的旋转方向。问题在于:车辆向右行驶、攀登台阶时,行星支架应顺时针旋转(顶部轮子向右前方摆下,落在台阶顶面),之前的代码中旋转方向确实是反的。同时底盘倾角方向也需要一并修正——攀登时前侧(右侧)抬起,底盘应逆时针倾斜(负角度)。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>行星式翻转轮组 · 主动自平衡云台 — 原理动画</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Exo+2:wght@300;400;600;700&family=JetBrains+Mono:wght@300;500;700&display=swap" rel="stylesheet">
<style>
:root{
  --bg:#060a14;--fg:#d8e2f0;--muted:#4a5c78;
  --cyan:#00e5ff;--amber:#ffab00;--green:#00e676;--red:#ff3d5a;
  --card:#0c1628;--border:#162040;
}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--fg);font-family:'Exo 2',sans-serif;
  min-height:100vh;display:flex;flex-direction:column;align-items:center;
  overflow-x:hidden;padding:18px 12px 32px}
header{text-align:center;margin-bottom:14px}
header h1{font-family:'Orbitron',sans-serif;font-weight:700;font-size:clamp(18px,2.6vw,28px);
  letter-spacing:2px;color:var(--fg);
  background:linear-gradient(90deg,var(--cyan),var(--amber));
  -webkit-background-clip:text;-webkit-text-fill-color:transparent}
header p{font-size:13px;color:var(--muted);margin-top:4px;letter-spacing:1px}
.canvas-wrap{width:100%;max-width:1100px;position:relative;
  border:1px solid var(--border);border-radius:12px;overflow:hidden;
  background:linear-gradient(170deg,#080e1e 0%,#0a1224 100%);
  box-shadow:0 0 40px rgba(0,229,255,.06),0 0 80px rgba(255,171,0,.03)}
canvas{display:block;width:100%;height:auto}
.controls{display:flex;flex-wrap:wrap;gap:16px 28px;align-items:center;
  justify-content:center;margin-top:16px;max-width:1100px;width:100%;
  padding:14px 20px;background:var(--card);border:1px solid var(--border);border-radius:10px}
.ctrl-group{display:flex;align-items:center;gap:8px;font-size:13px}
.ctrl-group label{color:var(--muted);white-space:nowrap;font-weight:600;letter-spacing:.5px}
.ctrl-group input[type=range]{-webkit-appearance:none;width:120px;height:5px;
  background:var(--border);border-radius:4px;outline:none}
.ctrl-group input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;
  width:16px;height:16px;border-radius:50%;background:var(--cyan);cursor:pointer;
  box-shadow:0 0 8px rgba(0,229,255,.5)}
.ctrl-group span.val{font-family:'JetBrains Mono',monospace;font-size:12px;
  color:var(--cyan);min-width:36px;text-align:right}
.toggle-btn{padding:6px 16px;border:1px solid var(--border);border-radius:6px;
  background:transparent;color:var(--muted);font-family:'Exo 2',sans-serif;
  font-size:13px;font-weight:600;cursor:pointer;transition:all .25s}
.toggle-btn.active{background:rgba(255,171,0,.12);color:var(--amber);
  border-color:var(--amber);box-shadow:0 0 12px rgba(255,171,0,.15)}
.toggle-btn:hover{border-color:var(--amber)}
.info-row{display:flex;flex-wrap:wrap;gap:10px 20px;justify-content:center;
  margin-top:12px;max-width:1100px;width:100%}
.info-chip{padding:5px 14px;border-radius:6px;font-size:12px;
  font-family:'JetBrains Mono',monospace;
  background:rgba(12,22,40,.7);border:1px solid var(--border)}
.info-chip .lbl{color:var(--muted);margin-right:6px}
.info-chip .v{font-weight:700}
.info-chip .v.cyan{color:var(--cyan)}
.info-chip .v.amber{color:var(--amber)}
.info-chip .v.green{color:var(--green)}
.info-chip .v.red{color:var(--red)}
.legend{display:flex;gap:18px;justify-content:center;margin-top:10px;font-size:12px;color:var(--muted)}
.legend i{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:5px;vertical-align:middle}
</style>
</head>
<body>

<header>
  <h1>行星式翻转轮组 · 主动自平衡云台</h1>
  <p>TRIZ 最终理想解 (IFR) 原理演示 — 移动与平衡解耦</p>
</header>

<div class="canvas-wrap">
  <canvas id="c" width="1200" height="650"></canvas>
</div>

<div class="controls">
  <div class="ctrl-group">
    <label>播放速度</label>
    <input type="range" id="speedSlider" min="0.2" max="2.5" step="0.1" value="1">
    <span class="val" id="speedVal">1.0x</span>
  </div>
  <div class="ctrl-group">
    <label>台阶高度</label>
    <input type="range" id="stepSlider" min="40" max="110" step="5" value="85">
    <span class="val" id="stepVal">85</span>
  </div>
  <button class="toggle-btn active" id="gimbalBtn">云台补偿: 开启</button>
  <button class="toggle-btn" id="ghostBtn">对比模式: 关闭</button>
</div>

<div class="info-row">
  <div class="info-chip"><span class="lbl">底盘倾角</span><span class="v cyan" id="vTilt">0.0°</span></div>
  <div class="info-chip"><span class="lbl">云台补偿</span><span class="v amber" id="vGimbal">0.0°</span></div>
  <div class="info-chip"><span class="lbl">支架旋转</span><span class="v cyan" id="vBracket">-60°</span></div>
  <div class="info-chip"><span class="lbl">阶段</span><span class="v green" id="vPhase">平地行驶</span></div>
</div>

<div class="legend">
  <span><i style="background:var(--cyan)"></i>行星轮系</span>
  <span><i style="background:var(--amber)"></i>自平衡云台</span>
  <span><i style="background:var(--green)"></i>稳定货台</span>
  <span><i style="background:var(--red)"></i>无补偿对比</span>
</div>

<script>
(function(){
'use strict';

/* ====== 画布与上下文 ====== */
const cv = document.getElementById('c');
const ctx = cv.getContext('2d');
const W = 1200, H = 650;
const dpr = Math.min(window.devicePixelRatio||1, 2);
cv.width = W * dpr; cv.height = H * dpr;
ctx.scale(dpr, dpr);

/* ====== 配置常量 ====== */
const GROUND_Y   = 510;
const STEP_X     = 570;
const ARM_LEN    = 75;
const WHEEL_R    = 13;
const CHASSIS_W  = 190;
const CHASSIS_H  = 26;
const GIMBAL_H   = 30;
const PLAT_W     = 158;
const PLAT_H     = 12;
const CARGO_W    = 88;
const CARGO_H    = 52;
const CYCLE_MS   = 9500;

/* ====== 可变状态 ====== */
let speed       = 1;
let stepH       = 85;
let gimbalOn    = true;
let ghostOn     = false;
let progress    = 0;
let lastTs      = null;

/* ====== 轨迹缓存 ====== */
let traceChassis = [];
let traceCargo   = [];
let traceGhost   = [];

/* ====== 控件绑定 ====== */
const elSpeed   = document.getElementById('speedSlider');
const elSpeedV  = document.getElementById('speedVal');
const elStep    = document.getElementById('stepSlider');
const elStepV   = document.getElementById('stepVal');
const elGimbal  = document.getElementById('gimbalBtn');
const elGhost   = document.getElementById('ghostBtn');

elSpeed.oninput = ()=>{ speed = +elSpeed.value; elSpeedV.textContent = speed.toFixed(1)+'x'; };
elStep.oninput  = ()=>{ stepH = +elStep.value; elStepV.textContent = stepH; resetTraces(); };
elGimbal.onclick = ()=>{
  gimbalOn = !gimbalOn;
  elGimbal.textContent = '云台补偿: '+(gimbalOn?'开启':'关闭');
  elGimbal.classList.toggle('active', gimbalOn);
  resetTraces();
};
elGhost.onclick = ()=>{
  ghostOn = !ghostOn;
  elGhost.textContent = '对比模式: '+(ghostOn?'开启':'关闭');
  elGhost.classList.toggle('active', ghostOn);
};

function resetTraces(){ traceChassis=[]; traceCargo=[]; traceGhost=[]; }

/* ====== 缓动函数 ====== */
function eio2(t){ return t<.5?2*t*t:-1+(4-2*t)*t; }
function eio3(t){ return t<.5?4*t*t*t:(t-1)*(2*t-2)*(2*t-2)+1; }
function eo3(t){ return 1-Math.pow(1-t,3); }
function eo4(t){ return 1-Math.pow(1-t,4); }

/* ====== 角度转弧度 ====== */
const rad = d => d * Math.PI / 180;

/* ====== 计算动画状态 ====== */
function calcState(p){
  const startX   = 155;
  /* 右下轮接触台阶面时的支架中心X:
     右下轮相对中心: wx = ARM_LEN*sin(60°) ≈ 65, 再加轮半径 */
  const contactX = STEP_X - ARM_LEN * 0.72;
  const flatCY   = GROUND_Y - ARM_LEN*0.5 - WHEEL_R;
  const upperCY  = GROUND_Y - stepH - ARM_LEN*0.5 - WHEEL_R;
  const rise     = flatCY - upperCY;

  let x, cy, cAngle, bAngle, gAngle, phase, phaseLabel;

  if(p < 0.20){
    /* 平地行驶 */
    phase = 0; phaseLabel = '平地行驶';
    const t = p / 0.20;
    x = startX + t * (contactX - startX);
    cy = flatCY;
    cAngle = 0; bAngle = -60; gAngle = 0;
  } else if(p < 0.33){
    /* 撞击台阶:底盘前侧(右侧)抬起 → cAngle 为负(逆时针) */
    phase = 1; phaseLabel = '撞击台阶';
    const t = (p-0.20)/0.13;
    const e = eio2(t);
    x = contactX + e * 20;
    cy = flatCY - e * rise * 0.10;
    cAngle = -e * 12;           /* 负角度:右侧抬起 */
    bAngle = -60 + e * 30;     /* 顺时针旋转 */
    gAngle = gimbalOn ? -cAngle : 0;
  } else if(p < 0.58){
    /* 行星翻转跨步:顺时针旋转120° */
    phase = 2; phaseLabel = '翻转跨步';
    const t = (p-0.33)/0.25;
    const e = eio3(t);
    x = contactX + 20 + e * 80;
    cy = flatCY - rise*0.10 - e * rise*0.90;
    cAngle = -12 - e * 24;     /* 持续负角度,最大约-36° */
    bAngle = -60 + 30 + e * 90;/* 从-30° 顺时针转到 +60° */
    gAngle = gimbalOn ? -cAngle : 0;
  } else if(p < 0.72){
    /* 落位稳定:底盘恢复水平 */
    phase = 3; phaseLabel = '落位稳定';
    const t = (p-0.58)/0.14;
    const e = eo4(t);
    x = contactX + 100 + e * 60;
    cy = upperCY + (1-e)*5;
    cAngle = -36 * (1-e);      /* 从-36° 恢复到 0° */
    bAngle = 60;
    gAngle = gimbalOn ? -cAngle : 0;
  } else if(p < 0.87){
    /* 跨越完成 */
    phase = 4; phaseLabel = '跨越完成';
    x = contactX + 160;
    cy = upperCY;
    cAngle = 0; bAngle = 60; gAngle = 0;
  } else {
    /* 重置 */
    phase = 5; phaseLabel = '—';
    const t = (p-0.87)/0.13;
    x = contactX + 160;
    cy = upperCY;
    cAngle = 0; bAngle = 60; gAngle = 0;
  }

  return { x, cy, cAngle, bAngle, gAngle, phase, phaseLabel };
}

/* ====== 绘图辅助 ====== */
function drawRoundRect(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 drawBg(){
  const grd = ctx.createLinearGradient(0,0,0,H);
  grd.addColorStop(0,'#070c1a');
  grd.addColorStop(1,'#0a1228');
  ctx.fillStyle = grd;
  ctx.fillRect(0,0,W,H);
  ctx.strokeStyle = 'rgba(22,40,72,0.35)';
  ctx.lineWidth = 0.5;
  for(let gx=0;gx<W;gx+=40){ctx.beginPath();ctx.moveTo(gx,0);ctx.lineTo(gx,H);ctx.stroke();}
  for(let gy=0;gy<H;gy+=40){ctx.beginPath();ctx.moveTo(0,gy);ctx.lineTo(W,gy);ctx.stroke();}
}

/* 绘制地面和台阶 */
function drawGround(){
  /* 下层地面 */
  const grd = ctx.createLinearGradient(0,GROUND_Y,0,GROUND_Y+60);
  grd.addColorStop(0,'#1a2640');grd.addColorStop(1,'#0d1628');
  ctx.fillStyle = grd;
  ctx.fillRect(0, GROUND_Y, STEP_X, H-GROUND_Y);
  /* 上层地面 */
  const grd2 = ctx.createLinearGradient(0,GROUND_Y-stepH,0,H);
  grd2.addColorStop(0,'#1e2d4a');grd2.addColorStop(1,'#0d1628');
  ctx.fillStyle = grd2;
  ctx.fillRect(STEP_X, GROUND_Y-stepH, W-STEP_X, H-(GROUND_Y-stepH));
  /* 台阶立面 */
  ctx.fillStyle = '#2a3d60';
  ctx.fillRect(STEP_X-2, GROUND_Y-stepH, 4, stepH);
  /* 台阶顶边高亮 */
  ctx.strokeStyle = 'rgba(0,229,255,0.25)';ctx.lineWidth = 2;
  ctx.beginPath();ctx.moveTo(STEP_X,GROUND_Y-stepH);ctx.lineTo(W,GROUND_Y-stepH);ctx.stroke();
  /* 台阶立面高亮 */
  ctx.strokeStyle = 'rgba(0,229,255,0.15)';ctx.lineWidth = 2;
  ctx.beginPath();ctx.moveTo(STEP_X,GROUND_Y-stepH);ctx.lineTo(STEP_X,GROUND_Y);ctx.stroke();
  /* 地面线 */
  ctx.strokeStyle = 'rgba(100,140,200,0.2)';ctx.lineWidth = 1;
  ctx.beginPath();ctx.moveTo(0,GROUND_Y);ctx.lineTo(STEP_X,GROUND_Y);ctx.stroke();
  /* 台阶高度标注 */
  ctx.save();
  ctx.strokeStyle = 'rgba(0,229,255,0.35)';ctx.lineWidth = 1;ctx.setLineDash([4,4]);
  const hx = STEP_X + 22;
  ctx.beginPath();ctx.moveTo(hx,GROUND_Y);ctx.lineTo(hx,GROUND_Y-stepH);ctx.stroke();
  ctx.setLineDash([]);
  ctx.fillStyle = 'rgba(0,229,255,0.5)';
  ctx.beginPath();ctx.moveTo(hx,GROUND_Y);ctx.lineTo(hx-4,GROUND_Y-6);ctx.lineTo(hx+4,GROUND_Y-6);ctx.fill();
  ctx.beginPath();ctx.moveTo(hx,GROUND_Y-stepH);ctx.lineTo(hx-4,GROUND_Y-stepH+6);ctx.lineTo(hx+4,GROUND_Y-stepH+6);ctx.fill();
  ctx.font = '600 11px "JetBrains Mono"';ctx.fillStyle = 'rgba(0,229,255,0.6)';ctx.textAlign = 'left';
  ctx.fillText(stepH+'mm', hx+6, GROUND_Y-stepH/2+4);
  ctx.restore();
}

/* ====================================================
   绘制行星轮组 — 修正旋转方向为顺时针
   wx = -ARM_LEN * sin(a):取负号使 bAngle 增大时
   轮组顺时针旋转(顶部轮向右前方摆下)
   ==================================================== */
function drawPlanetaryWheels(cx, cy, bAngle, highlight){
  ctx.save();
  ctx.translate(cx, cy);
  if(highlight > 0){
    ctx.shadowColor = 'rgba(0,229,255,'+0.5*highlight+')';
    ctx.shadowBlur = 18 * highlight;
  }
  /* 中心轮毂 */
  ctx.fillStyle = '#1a3050';
  ctx.strokeStyle = 'rgba(0,229,255,'+(0.5+0.5*highlight)+')';
  ctx.lineWidth = 2;
  ctx.beginPath(); ctx.arc(0,0,10,0,Math.PI*2); ctx.fill(); ctx.stroke();

  for(let i=0;i<3;i++){
    const a = rad(bAngle + i*120);
    /* ★ 关键修正:sin 取负实现顺时针旋转 */
    const wx = -ARM_LEN * Math.sin(a);
    const wy =  ARM_LEN * Math.cos(a);
    /* 臂 */
    ctx.strokeStyle = 'rgba(0,229,255,'+(0.4+0.6*highlight)+')';
    ctx.lineWidth = 3;
    ctx.beginPath();ctx.moveTo(0,0);ctx.lineTo(wx,wy);ctx.stroke();
    /* 轮圈 */
    ctx.fillStyle = '#0d1a2e';
    ctx.strokeStyle = 'rgba(0,229,255,'+(0.5+0.5*highlight)+')';
    ctx.lineWidth = 2;
    ctx.beginPath();ctx.arc(wx,wy,WHEEL_R,0,Math.PI*2);ctx.fill();ctx.stroke();
    /* 轮辐 */
    ctx.strokeStyle = 'rgba(0,229,255,'+(0.2+0.3*highlight)+')';
    ctx.lineWidth = 1;
    for(let s=0;s<3;s++){
      const sa = s*Math.PI/3;
      ctx.beginPath();
      ctx.moveTo(wx+WHEEL_R*0.4*Math.cos(sa),wy+WHEEL_R*0.4*Math.sin(sa));
      ctx.lineTo(wx+WHEEL_R*0.85*Math.cos(sa),wy+WHEEL_R*0.85*Math.sin(sa));
      ctx.stroke();
    }
  }
  ctx.shadowColor='transparent';ctx.shadowBlur=0;
  ctx.restore();
}

/* 绘制底盘 */
function drawChassis(cx, cy, angle){
  ctx.save();
  ctx.translate(cx, cy);
  ctx.rotate(rad(angle));
  const hw=CHASSIS_W/2, hh=CHASSIS_H/2;
  const grd = ctx.createLinearGradient(0,-hh,0,hh);
  grd.addColorStop(0,'#3d5a80');grd.addColorStop(1,'#2a4060');
  ctx.fillStyle = grd;
  drawRoundRect(-hw,-hh,CHASSIS_W,CHASSIS_H,4);ctx.fill();
  ctx.strokeStyle = 'rgba(100,160,220,0.3)';ctx.lineWidth = 1;
  drawRoundRect(-hw,-hh,CHASSIS_W,CHASSIS_H,4);ctx.stroke();
  ctx.strokeStyle = 'rgba(100,160,220,0.15)';ctx.lineWidth = 0.5;
  for(let lx=-hw+20;lx<hw;lx+=25){ctx.beginPath();ctx.moveTo(lx,-hh+3);ctx.lineTo(lx,hh-3);ctx.stroke();}
  ctx.restore();
}

/* 绘制云台机构 */
function drawGimbal(cx, chassisTopY, platBottomY, highlight){
  ctx.save();
  ctx.translate(cx, 0);
  if(highlight > 0){
    ctx.shadowColor = 'rgba(255,171,0,'+0.5*highlight+')';
    ctx.shadowBlur = 14*highlight;
  }
  const cTop = chassisTopY;
  const pBot = platBottomY;
  const midY = (cTop + pBot) / 2;
  const lOff = 45;
  drawHydraulicRod(-lOff, cTop, -lOff-3, pBot, highlight);
  drawHydraulicRod(lOff, cTop, lOff+3, pBot, highlight);
  /* 中心枢轴 */
  ctx.fillStyle = highlight>0?'rgba(255,171,0,'+(0.5+0.5*highlight)+')':'#3a4a60';
  ctx.strokeStyle = 'rgba(255,171,0,'+(0.3+0.7*highlight)+')';
  ctx.lineWidth = 2;
  ctx.beginPath();ctx.arc(0,midY,7,0,Math.PI*2);ctx.fill();ctx.stroke();
  ctx.fillStyle = '#0d1a2e';
  ctx.beginPath();ctx.arc(0,midY,3,0,Math.PI*2);ctx.fill();
  ctx.shadowColor='transparent';ctx.shadowBlur=0;
  ctx.restore();
}

function drawHydraulicRod(x1,y1,x2,y2,hl){
  ctx.strokeStyle = hl>0?'rgba(255,171,0,'+(0.4+0.6*hl)+')':'#3a5070';
  ctx.lineWidth = 5;ctx.lineCap = 'round';
  ctx.beginPath();ctx.moveTo(x1,y1);ctx.lineTo(x2,y2);ctx.stroke();
  ctx.strokeStyle = hl>0?'rgba(255,171,0,'+(0.6+0.4*hl)+')':'#5a7a9a';
  ctx.lineWidth = 2;
  const mx=(x1+x2)/2, my=(y1+y2)/2;
  ctx.beginPath();ctx.moveTo(mx,my-2);ctx.lineTo(x2,y2);ctx.stroke();
  ctx.lineCap = 'butt';
}

/* 绘制平台 */
function drawPlatform(cx, py, angle){
  ctx.save();
  ctx.translate(cx, py);
  ctx.rotate(rad(angle));
  const hw=PLAT_W/2, hh=PLAT_H/2;
  ctx.fillStyle = '#2a3a52';ctx.strokeStyle = 'rgba(100,160,220,0.3)';ctx.lineWidth = 1;
  drawRoundRect(-hw,-hh,PLAT_W,PLAT_H,3);ctx.fill();ctx.stroke();
  ctx.restore();
}

/* 绘制货物 */
function drawCargo(cx, py, angle, color, alpha){
  ctx.save();
  ctx.translate(cx, py);
  ctx.rotate(rad(angle));
  ctx.globalAlpha = alpha;
  const hw=CARGO_W/2, hh=CARGO_H/2;
  const yOff = -PLAT_H/2 - hh - 1;
  ctx.fillStyle = color==='green'?'#0a3a2a':'#3a1a1a';
  ctx.strokeStyle = color==='green'?'rgba(0,230,118,0.7)':'rgba(255,61,90,0.7)';
  ctx.lineWidth = 1.5;
  drawRoundRect(-hw,yOff,CARGO_W,CARGO_H,4);ctx.fill();ctx.stroke();
  ctx.strokeStyle = color==='green'?'rgba(0,230,118,0.3)':'rgba(255,61,90,0.3)';
  ctx.lineWidth = 1;
  ctx.beginPath();ctx.moveTo(-hw+8,yOff+8);ctx.lineTo(hw-8,yOff+8);ctx.stroke();
  ctx.beginPath();ctx.moveTo(-hw+8,yOff+CARGO_H-8);ctx.lineTo(hw-8,yOff+CARGO_H-8);ctx.stroke();
  ctx.font = '600 9px "JetBrains Mono"';
  ctx.fillStyle = color==='green'?'rgba(0,230,118,0.6)':'rgba(255,61,90,0.6)';
  ctx.textAlign = 'center';
  ctx.fillText('CARGO', 0, yOff + CARGO_H/2 + 3);
  ctx.globalAlpha = 1;
  ctx.restore();
}

/* 绘制陀螺仪指示器 */
function drawGyroIndicator(cAngle, gAngle){
  const gx=1080, gy=80, gr=36;
  ctx.save();ctx.translate(gx,gy);
  ctx.strokeStyle = 'rgba(100,140,200,0.2)';ctx.lineWidth = 1.5;
  ctx.beginPath();ctx.arc(0,0,gr,0,Math.PI*2);ctx.stroke();
  /* 水平参考线 */
  ctx.strokeStyle = 'rgba(0,230,118,0.3)';ctx.lineWidth = 1;ctx.setLineDash([3,3]);
  ctx.beginPath();ctx.moveTo(-gr+4,0);ctx.lineTo(gr-4,0);ctx.stroke();ctx.setLineDash([]);
  /* 底盘倾角指针(cAngle为负时指针偏左=前侧抬起) */
  ctx.strokeStyle = 'rgba(0,229,255,0.8)';ctx.lineWidth = 2;
  ctx.beginPath();ctx.moveTo(0,0);
  ctx.lineTo(gr*0.8*Math.sin(rad(cAngle)), gr*0.8*Math.cos(rad(cAngle)));
  ctx.stroke();
  /* 云台补偿角指针 */
  if(gimbalOn && Math.abs(gAngle)>0.5){
    ctx.strokeStyle = 'rgba(255,171,0,0.8)';ctx.lineWidth = 2;
    ctx.beginPath();ctx.moveTo(0,0);
    ctx.lineTo(gr*0.6*Math.sin(rad(gAngle)), gr*0.6*Math.cos(rad(gAngle)));
    ctx.stroke();
  }
  ctx.fillStyle = '#4a6080';
  ctx.beginPath();ctx.arc(0,0,3,0,Math.PI*2);ctx.fill();
  ctx.font = '600 9px "JetBrains Mono"';ctx.textAlign = 'center';
  ctx.fillStyle = 'rgba(0,229,255,0.6)';ctx.fillText('GYRO',0,gr+14);
  ctx.fillStyle = 'rgba(0,229,255,0.5)';ctx.fillText(cAngle.toFixed(1)+'°',0,-gr-6);
  ctx.restore();
}

/* 绘制轨迹 */
function drawTraces(){
  if(traceChassis.length>1){
    ctx.strokeStyle='rgba(0,229,255,0.2)';ctx.lineWidth=1.5;ctx.setLineDash([6,4]);
    ctx.beginPath();ctx.moveTo(traceChassis[0].x,traceChassis[0].y);
    for(let i=1;i<traceChassis.length;i++) ctx.lineTo(traceChassis[i].x,traceChassis[i].y);
    ctx.stroke();ctx.setLineDash([]);
  }
  if(traceCargo.length>1){
    ctx.strokeStyle='rgba(0,230,118,0.35)';ctx.lineWidth=2;
    ctx.beginPath();ctx.moveTo(traceCargo[0].x,traceCargo[0].y);
    for(let i=1;i<traceCargo.length;i++) ctx.lineTo(traceCargo[i].x,traceCargo[i].y);
    ctx.stroke();
  }
  if(ghostOn && traceGhost.length>1){
    ctx.strokeStyle='rgba(255,61,90,0.3)';ctx.lineWidth=1.5;ctx.setLineDash([4,4]);
    ctx.beginPath();ctx.moveTo(traceGhost[0].x,traceGhost[0].y);
    for(let i=1;i<traceGhost.length;i++) ctx.lineTo(traceGhost[i].x,traceGhost[i].y);
    ctx.stroke();ctx.setLineDash([]);
  }
}

/* 绘制阶段标注 */
function drawPhaseLabel(phase){
  if(phase===5) return;
  ctx.save();ctx.font='700 15px "Exo 2"';ctx.textAlign='center';
  const labels={
    0:{text:'平地行驶',sub:'双轮触地,稳定前行',color:'rgba(100,160,220,0.7)'},
    1:{text:'撞击台阶',sub:'前行惯性驱动翻转',color:'rgba(0,229,255,0.85)'},
    2:{text:'翻转跨步',sub:'行星支架顺时针旋转 120°,上层轮越顶落地',color:'rgba(0,229,255,0.95)'},
    3:{text:'落位稳定',sub:'云台持续补偿,平台恢复水平',color:'rgba(255,171,0,0.85)'},
    4:{text:'跨越完成',sub:'货物全程保持水平',color:'rgba(0,230,118,0.85)'},
  };
  const info = labels[phase]||labels[0];
  ctx.fillStyle = info.color;ctx.fillText(info.text,W/2,32);
  ctx.font = '400 12px "Exo 2"';ctx.fillStyle = 'rgba(160,185,220,0.5)';
  ctx.fillText(info.sub,W/2,50);
  ctx.restore();
}

/* 绘制关键参数标注 */
function drawParamAnnotations(state){
  const {x,cy,cAngle,bAngle,gAngle,phase} = state;
  if(phase>=1 && phase<=3){
    ctx.save();ctx.font='500 10px "JetBrains Mono"';
    ctx.fillStyle='rgba(0,229,255,0.5)';ctx.textAlign='left';
    ctx.fillText('臂长 120mm', x+ARM_LEN+18, cy-8);
    ctx.restore();
  }
  if(phase>=2 && phase<=3 && gimbalOn){
    ctx.save();ctx.font='500 10px "JetBrains Mono"';
    ctx.fillStyle='rgba(255,171,0,0.55)';ctx.textAlign='left';
    const py = cy - CHASSIS_H/2 - GIMBAL_H/2;
    ctx.fillText('响应 < 20ms', x+60, py-10);
    ctx.restore();
  }
  /* IFR理想水平参考线 */
  if(phase>=1 && phase<=4){
    const idealY = GROUND_Y - ARM_LEN*0.5 - WHEEL_R - CHASSIS_H/2 - GIMBAL_H - PLAT_H/2 - 1 - CARGO_H/2;
    ctx.save();
    ctx.strokeStyle = gimbalOn?'rgba(0,230,118,0.15)':'rgba(255,61,90,0.15)';
    ctx.lineWidth = 1;ctx.setLineDash([8,6]);
    ctx.beginPath();ctx.moveTo(60,idealY);ctx.lineTo(W-60,idealY);ctx.stroke();
    ctx.setLineDash([]);
    ctx.font = '500 9px "JetBrains Mono"';
    ctx.fillStyle = gimbalOn?'rgba(0,230,118,0.3)':'rgba(255,61,90,0.3)';
    ctx.textAlign = 'right';ctx.fillText('IFR 理想水平面',W-65,idealY-5);
    ctx.restore();
  }
}

/* ====================================================
   绘制翻转弧线指示 — 顺时针方向
   ==================================================== */
function drawFlipArc(cx, cy, bAngle, phase){
  if(phase<1||phase>3) return;
  const startA = -60;
  const endA = bAngle;
  if(Math.abs(endA-startA)<2) return;

  ctx.save();ctx.translate(cx,cy);

  /* 用与轮组相同的公式确定弧线角度 */
  /* 轮组中 W0 的角度 a = rad(bAngle),位置 wx = -sin(a), wy = cos(a)
     对应 canvas 角度: atan2(wx, wy) = atan2(-sin(a), cos(a)) = -a
     所以 canvas 角度 = -rad(bAngle)
     startA=-60 → canvas角 = -rad(-60) = +60° = π/3
     bAngle 递增 → canvas角递减(顺时针方向)*/

  const arcR = ARM_LEN + 22;
  const canvasStart = -rad(startA);   /* 起始 canvas 角度 */
  const canvasEnd   = -rad(endA);     /* 当前 canvas 角度 */

  ctx.strokeStyle = 'rgba(0,229,255,0.3)';ctx.lineWidth = 1.5;ctx.setLineDash([3,3]);
  ctx.beginPath();
  /* 顺时针:从较大的 canvasStart 到较小的 canvasEnd */
  ctx.arc(0,0,arcR, canvasEnd, canvasStart);
  ctx.stroke();ctx.setLineDash([]);

  /* 箭头在当前角度位置,指向顺时针方向 */
  const ax = arcR * Math.cos(canvasEnd);
  const ay = arcR * Math.sin(canvasEnd);
  ctx.fillStyle = 'rgba(0,229,255,0.6)';
  /* 顺时针箭头:切线方向为 canvasEnd - π/2 */
  const tang = canvasEnd - Math.PI/2;
  ctx.beginPath();
  ctx.moveTo(ax, ay);
  ctx.lineTo(ax+8*Math.cos(tang+0.4), ay+8*Math.sin(tang+0.4));
  ctx.lineTo(ax+8*Math.cos(tang-0.4), ay+8*Math.sin(tang-0.4));
  ctx.closePath();ctx.fill();

  /* "顺时针" 文字标注 */
  if(phase===2){
    ctx.font = '600 10px "Exo 2"';
    ctx.fillStyle = 'rgba(0,229,255,0.55)';
    ctx.textAlign = 'center';
    const labelAngle = (canvasStart + canvasEnd) / 2;
    const lx = (arcR+16) * Math.cos(labelAngle);
    const ly = (arcR+16) * Math.sin(labelAngle);
    ctx.fillText('CW', lx, ly+4);
  }

  ctx.restore();
}

/* 绘制惯性力箭头 */
function drawInertiaArrow(x, cy, phase){
  if(phase<1||phase>2) return;
  ctx.save();ctx.translate(x - CHASSIS_W/2 - 35, cy);
  const alpha = phase===1?0.6:0.4;
  ctx.strokeStyle='rgba(0,229,255,'+alpha+')';
  ctx.fillStyle='rgba(0,229,255,'+alpha+')';
  ctx.lineWidth=2;
  ctx.beginPath();ctx.moveTo(-25,0);ctx.lineTo(10,0);ctx.stroke();
  ctx.beginPath();ctx.moveTo(15,0);ctx.lineTo(8,-5);ctx.lineTo(8,5);ctx.closePath();ctx.fill();
  ctx.font='500 9px "Exo 2"';ctx.textAlign='center';ctx.fillText('惯性',-5,-10);
  ctx.restore();
}

/* ====== 更新信息面板 ====== */
function updateInfoPanel(state){
  const tiltEl = document.getElementById('vTilt');
  tiltEl.textContent = state.cAngle.toFixed(1)+'°';
  tiltEl.className = 'v '+(Math.abs(state.cAngle)>5?'red':'cyan');

  const gimbalEl = document.getElementById('vGimbal');
  gimbalEl.textContent = state.gAngle.toFixed(1)+'°';
  gimbalEl.className = 'v '+(gimbalOn?'amber':'muted');

  document.getElementById('vBracket').textContent = state.bAngle.toFixed(0)+'°';

  const phaseEl = document.getElementById('vPhase');
  phaseEl.textContent = state.phaseLabel;
  phaseEl.className = 'v '+(
    state.phase===2?'cyan':state.phase===3?'amber':
    state.phase===4?'green':state.phase===1?'red':'green'
  );
}

/* ====== 主绘制函数 ====== */
function draw(state){
  ctx.clearRect(0,0,W,H);
  drawBg();
  drawGround();
  drawTraces();

  const {x,cy,cAngle,bAngle,gAngle,phase} = state;

  /* 底盘旋转后的上边缘世界Y */
  const cosA = Math.cos(rad(cAngle));
  const sinA = Math.sin(rad(cAngle));
  /* 底盘上边缘局部坐标 (0, -CHASSIS_H/2) → 世界坐标 */
  const chassisTopWorldY = cy + (-CHASSIS_H/2)*cosA;
  /* 云台底部=平台上边缘(未旋转时) */
  const gimbalBottomWorldY = chassisTopWorldY - GIMBAL_H;
  /* 平台中心Y */
  const platformCY = gimbalBottomWorldY - PLAT_H/2;

  /* 翻转弧线 */
  drawFlipArc(x, cy, bAngle, phase);
  /* 惯性箭头 */
  drawInertiaArrow(x, cy, phase);

  /* 行星轮组 */
  const wheelHL = (phase>=1&&phase<=3)?(phase===2?1:0.5):0;
  drawPlanetaryWheels(x, cy, bAngle, wheelHL);

  /* 底盘 */
  drawChassis(x, cy, cAngle);

  /* 云台 */
  const gimbalHL = (phase>=2&&phase<=3&&gimbalOn)?1:(phase===1&&gimbalOn?0.4:0);
  drawGimbal(x, chassisTopWorldY, gimbalBottomWorldY, gimbalHL);

  /* 平台角度:云台补偿后保持水平 */
  const platAngle = gimbalOn ? (cAngle + gAngle) : cAngle;
  drawPlatform(x, platformCY, platAngle);

  /* 稳定货物 */
  drawCargo(x, platformCY, platAngle, gimbalOn?'green':'red', 1);

  /* 对比模式: 无补偿幽灵货物(随底盘倾斜) */
  if(ghostOn){
    drawCargo(x, cy, cAngle, 'red', 0.35);
  }

  /* 参数标注 */
  drawParamAnnotations(state);
  /* 阶段标注 */
  drawPhaseLabel(phase);
  /* 陀螺仪指示器 */
  drawGyroIndicator(cAngle, gAngle);

  /* 失效边界提示 */
  if(stepH > ARM_LEN){
    ctx.save();ctx.font='700 13px "Exo 2"';ctx.fillStyle='rgba(255,61,90,0.8)';
    ctx.textAlign='center';ctx.fillText('⚠ 台阶高度超过臂长,行星轮系失效',W/2,H-20);
    ctx.restore();
  }
}

/* ====== 动画主循环 ====== */
function animate(ts){
  if(!lastTs) lastTs = ts;
  const dt = Math.min(ts-lastTs, 50);
  lastTs = ts;
  progress += (dt*speed)/CYCLE_MS;
  if(progress>=1){progress-=1;resetTraces();}

  const state = calcState(progress);

  /* 记录轨迹 */
  const {x,cy,cAngle,gAngle} = state;
  const cosA = Math.cos(rad(cAngle));
  const cargoTrackY_base = cy - CHASSIS_H/2*cosA - GIMBAL_H - PLAT_H/2 - 1 - CARGO_H/2;
  const cargoTrackY_gimbal = cy - CHASSIS_H/2 - GIMBAL_H - PLAT_H/2 - 1 - CARGO_H/2;

  if(traceChassis.length===0 || Math.abs(x-traceChassis[traceChassis.length-1].x)>3){
    traceChassis.push({x, y:cy});
    traceCargo.push({x, y: gimbalOn ? cargoTrackY_gimbal : cargoTrackY_base});
    if(ghostOn) traceGhost.push({x, y: cargoTrackY_base});
  }

  draw(state);
  updateInfoPanel(state);
  requestAnimationFrame(animate);
}

/* ====== 启动 ====== */
requestAnimationFrame(animate);

})();
</script>
</body>
</html>

修正说明:

核心修改了两处,使旋转方向符合物理实际:

  1. 行星轮组绘制wx = -ARM_LEN * Math.sin(a),取负号后当 bAngle 从 -60° 增大到 +60° 时,顶部轮向右前方摆下(顺时针),这正是车辆向右行驶、受惯性驱动攀登台阶时的正确旋转方向。此前未取负,旋转为逆时针——顶部轮向左后方摆,与物理矛盾。

  2. 底盘倾角方向cAngle 在攀登阶段取负值(最小约 -36°),表示底盘前侧(右侧)抬起——对应 canvas 中逆时针旋转。此前为正值(顺时针=右侧下沉),与攀登时前侧抬高的实际运动矛盾。云台补偿角 gAngle = -cAngle 仍为正值(顺时针),与底盘倾斜方向相反,保持平台水平。

  3. 翻转弧线指示:重新计算了 canvas 角度映射,箭头指向顺时针方向,并标注 "CW" 字样。

  4. 接触点位置contactX 调整为 STEP_X - ARM_LEN * 0.72,更准确地对应右下轮抵达台阶立面时支架中心的位置。

积分规则:第一轮对话扣减8分,后续每轮扣6分