分享图
动画工坊
引擎就绪
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>行星轮越障与主动补偿云台 · IFR原理动画</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<style>
:root{--bg:#050910;--panel:#0a1018;--border:#152035;--fg:#c0d0e0;--muted:#4a6888;--accent:#ff7a2e;--cyan:#00ccff;--green:#00e88f;--steel:#3a6ea5;--steel-l:#5a9ed5}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--fg);font-family:'Share Tech Mono',monospace;min-height:100vh;display:flex;flex-direction:column;align-items:center;padding:16px 12px;overflow-x:hidden}
.header{display:flex;align-items:baseline;gap:16px;margin-bottom:12px;flex-wrap:wrap;justify-content:center}
.title{font-family:'Rajdhani',sans-serif;font-weight:700;font-size:clamp(18px,3vw,26px);color:var(--cyan);text-transform:uppercase;letter-spacing:3px}
.subtitle{font-size:12px;color:var(--muted);letter-spacing:1px}
.canvas-wrap{width:100%;max-width:1200px;background:var(--panel);border:1px solid var(--border);border-radius:10px;overflow:hidden;box-shadow:0 0 60px rgba(0,0,0,.6),inset 0 0 80px rgba(0,15,30,.4);position:relative}
canvas{display:block;width:100%;height:auto}
.controls{display:flex;gap:20px;align-items:center;flex-wrap:wrap;justify-content:center;margin-top:12px;padding:12px 20px;background:var(--panel);border:1px solid var(--border);border-radius:8px;max-width:1200px;width:100%}
.cg{display:flex;align-items:center;gap:8px}
.cg label{font-size:12px;color:var(--muted);white-space:nowrap}
.cg input[type=range]{-webkit-appearance:none;width:110px;height:4px;background:var(--border);border-radius:2px;outline:none}
.cg input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;background:var(--cyan);border-radius:50%;cursor:pointer;box-shadow:0 0 6px rgba(0,204,255,.5)}
.cg .val{font-size:12px;color:var(--cyan);min-width:36px;text-align:right}
.btn{font-family:'Rajdhani',sans-serif;font-weight:600;font-size:13px;padding:5px 14px;background:transparent;border:1px solid var(--cyan);color:var(--cyan);border-radius:4px;cursor:pointer;letter-spacing:1px;transition:all .2s}
.btn:hover{background:rgba(0,204,255,.1);box-shadow:0 0 10px rgba(0,204,255,.3)}
.btn.active{border-color:var(--accent);color:var(--accent)}
.legend{display:flex;gap:16px;flex-wrap:wrap;justify-content:center;margin-top:10px;font-size:11px;color:var(--muted)}
.li{display:flex;align-items:center;gap:5px}
.ld{width:9px;height:9px;border-radius:50%;flex-shrink:0}
</style>
</head>
<body>

<div class="header">
  <div class="title">行星轮越障 · 主动补偿云台</div>
  <div class="subtitle">IFR 原理演示 — 移动越障与稳定货物彻底解耦</div>
</div>

<div class="canvas-wrap">
  <canvas id="c"></canvas>
</div>

<div class="controls">
  <div class="cg">
    <label>台阶高度</label>
    <input type="range" id="rStep" min="15" max="120" value="60" step="5">
    <span class="val" id="vStep">60mm</span>
  </div>
  <div class="cg">
    <label>播放速度</label>
    <input type="range" id="rSpeed" min="0.2" max="2.5" value="1" step="0.1">
    <span class="val" id="vSpeed">1.0x</span>
  </div>
  <div class="cg">
    <label>显示无补偿对比</label>
    <input type="range" id="rGhost" min="0" max="1" value="1" step="1">
    <span class="val" id="vGhost">开</span>
  </div>
  <button class="btn" id="bRestart">重新播放</button>
  <button class="btn" id="bPause">暂停</button>
</div>

<div class="legend">
  <div class="li"><div class="ld" style="background:#ff7a2e"></div>行星轮支架(越障核心)</div>
  <div class="li"><div class="ld" style="background:#00ccff"></div>主动补偿云台(陀螺仪+液压)</div>
  <div class="li"><div class="ld" style="background:#00e88f"></div>载货平台(保持水平)</div>
  <div class="li"><div class="ld" style="background:#5a9ed5"></div>底盘框架</div>
  <div class="li"><div class="ld" style="background:rgba(255,60,90,.45)"></div>无补偿投影(对比)</div>
</div>

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

/* ====== 画布初始化 ====== */
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = 1400, H = 700;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.scale(dpr, dpr);

/* ====== 配置 ====== */
const C = {
  R: 70,            // 行星轮臂长
  wR: 13,           // 小轮半径
  chassisLen: 200,  // 前后枢轴间距
  chassisH: 16,     // 底盘高度
  platW: 230,       // 平台宽度
  platH: 12,        // 平台厚度
  cargoW: 110,      // 货物宽
  cargoH: 55,       // 货物高
  gimbalH: 55,      // 云台高度
  groundY: 470,     // 地面 Y
  stepX: 680,       // 台阶面 X
};

/* ====== 状态 ====== */
let stepH = 60;
let speed = 1;
let showGhost = true;
let paused = false;
let animT = 0;
let lastTS = null;
const DURATION = 9000; // 一周期毫秒

/* ====== 控件绑定 ====== */
const rStep = document.getElementById('rStep');
const rSpeed = document.getElementById('rSpeed');
const rGhost = document.getElementById('rGhost');
const vStep = document.getElementById('vStep');
const vSpeed = document.getElementById('vSpeed');
const vGhost = document.getElementById('vGhost');

rStep.oninput = () => { stepH = +rStep.value; vStep.textContent = stepH + 'mm'; };
rSpeed.oninput = () => { speed = +rSpeed.value; vSpeed.textContent = speed.toFixed(1) + 'x'; };
rGhost.oninput = () => { showGhost = +rGhost.value === 1; vGhost.textContent = showGhost ? '开' : '关'; };
document.getElementById('bRestart').onclick = () => { animT = 0; lastTS = null; paused = false; document.getElementById('bPause').textContent = '暂停'; };
document.getElementById('bPause').onclick = function() { paused = !paused; this.textContent = paused ? '继续' : '暂停'; if(!paused) lastTS = null; };

/* ====== 数学工具 ====== */
function lerp(a,b,t){return a+(b-a)*t}
function clamp(v,lo,hi){return Math.max(lo,Math.min(hi,v))}
function easeIO(t){return t<.5?2*t*t:1-Math.pow(-2*t+2,2)/2}
function easeOut(t){return 1-Math.pow(1-t,3)}
function deg(r){return r*180/Math.PI}
function rad(d){return d*Math.PI/180}

/* ====== 车辆状态计算 ====== */
function getState(t){
  // 车辆中心 X 随时间线性推进
  const cx = lerp(280, 1080, t);
  const half = C.chassisLen / 2;
  let fpx = cx + half;
  let rpx = cx - half;

  // 爬升区域:台阶面两侧各一个臂长投影
  const climbStart = C.stepX - C.R * 0.87;
  const climbEnd   = C.stepX + C.R * 0.87;
  const climbW     = climbEnd - climbStart;

  let fcp = clamp((fpx - climbStart) / climbW, 0, 1);
  let rcp = clamp((rpx - climbStart) / climbW, 0, 1);
  fcp = easeIO(fcp);
  rcp = easeIO(rcp);

  const flatY = C.groundY - C.R * 0.5;
  const elevY = C.groundY - stepH - C.R * 0.5;

  // 枢轴 Y:平滑过渡 + 爬升弧线凸起
  const fpy = lerp(flatY, elevY, fcp) - Math.sin(Math.PI * fcp) * C.R * 0.38;
  const rpy = lerp(flatY, elevY, rcp) - Math.sin(Math.PI * rcp) * C.R * 0.38;

  const fba = fcp * 120;   // 前支架旋转角
  const rba = rcp * 120;   // 后支架旋转角
  const chassisAng = Math.atan2(fpy - rpy, C.chassisLen);
  const gimbalAng  = -chassisAng;

  return { cx, fpx, rpx, fpy, rpy, fba, rba, fcp, rcp, chassisAng, gimbalAng, flatY, elevY };
}

/* ====== 绘图辅助 ====== */
function drawGrid(){
  ctx.strokeStyle = '#0d1828';
  ctx.lineWidth = 0.5;
  for(let x = 0; x < W; x += 40){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke(); }
  for(let y = 0; y < H; y += 40){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); }
}

function drawGround(){
  const gY = C.groundY;
  const sX = C.stepX;
  const sH = stepH;

  // 地面
  ctx.fillStyle = '#111d30';
  ctx.fillRect(0, gY, sX, H - gY);
  // 台阶顶部
  ctx.fillStyle = '#111d30';
  ctx.fillRect(sX, gY - sH, W - sX, H - gY + sH);
  // 台阶立面
  const fGrad = ctx.createLinearGradient(sX, gY - sH, sX + 6, gY - sH);
  fGrad.addColorStop(0, '#1e3050');
  fGrad.addColorStop(1, '#111d30');
  ctx.fillStyle = fGrad;
  ctx.fillRect(sX - 2, gY - sH, 6, sH);
  // 表面线
  ctx.strokeStyle = '#2a4a70';
  ctx.lineWidth = 2;
  ctx.beginPath(); ctx.moveTo(0, gY); ctx.lineTo(sX, gY); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(sX, gY - sH); ctx.lineTo(W, gY - sH); ctx.stroke();
  // 台阶高度标注
  ctx.save();
  ctx.strokeStyle = '#3a5a80';
  ctx.lineWidth = 0.8;
  ctx.setLineDash([3,3]);
  ctx.beginPath(); ctx.moveTo(sX - 18, gY - sH); ctx.lineTo(sX - 18, gY); ctx.stroke();
  ctx.setLineDash([]);
  // 箭头
  ctx.fillStyle = '#3a5a80';
  ctx.beginPath(); ctx.moveTo(sX-18, gY-sH); ctx.lineTo(sX-22, gY-sH+6); ctx.lineTo(sX-14, gY-sH+6); ctx.fill();
  ctx.beginPath(); ctx.moveTo(sX-18, gY); ctx.lineTo(sX-22, gY-6); ctx.lineTo(sX-14, gY-6); ctx.fill();
  ctx.fillStyle = '#5a8ab0';
  ctx.font = '11px "Share Tech Mono", monospace';
  ctx.textAlign = 'right';
  ctx.fillText(sH + 'mm', sX - 24, gY - sH / 2 + 4);
  ctx.restore();
}

function drawWheel(pvx, pvy, bAngle, spinAngle, highlight){
  ctx.save();
  ctx.translate(pvx, pvy);
  ctx.rotate(rad(bAngle));

  for(let i = 0; i < 3; i++){
    const a = rad(30 + i * 120);
    const wx = C.R * Math.cos(a);
    const wy = C.R * Math.sin(a);

    // 臂
    ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(wx,wy);
    ctx.strokeStyle = highlight ? '#ff9944' : '#cc6020';
    ctx.lineWidth = highlight ? 3.5 : 2.8;
    ctx.lineCap = 'round';
    ctx.stroke();

    // 发光
    if(highlight){
      ctx.save();
      ctx.shadowColor = '#ff7a2e';
      ctx.shadowBlur = 12;
      ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(wx,wy);
      ctx.strokeStyle = 'rgba(255,122,46,0.3)';
      ctx.lineWidth = 6;
      ctx.stroke();
      ctx.restore();
    }

    // 轮子
    ctx.beginPath(); ctx.arc(wx, wy, C.wR, 0, Math.PI * 2);
    ctx.fillStyle = '#0c1825';
    ctx.fill();
    ctx.strokeStyle = highlight ? '#ffaa55' : '#cc7030';
    ctx.lineWidth = 2.2;
    ctx.stroke();

    // 辐条(旋转效果)
    const sa = rad(spinAngle + i * 120);
    const sr = C.wR * 0.6;
    ctx.beginPath();
    ctx.moveTo(wx - sr * Math.cos(sa), wy - sr * Math.sin(sa));
    ctx.lineTo(wx + sr * Math.cos(sa), wy + sr * Math.sin(sa));
    ctx.strokeStyle = 'rgba(255,160,80,0.5)';
    ctx.lineWidth = 1.2;
    ctx.stroke();
    // 十字辐条
    const sa2 = sa + Math.PI/2;
    ctx.beginPath();
    ctx.moveTo(wx - sr * Math.cos(sa2), wy - sr * Math.sin(sa2));
    ctx.lineTo(wx + sr * Math.cos(sa2), wy + sr * Math.sin(sa2));
    ctx.stroke();
  }

  // 枢轴点
  ctx.beginPath(); ctx.arc(0, 0, 5, 0, Math.PI * 2);
  ctx.fillStyle = highlight ? '#ff9944' : '#cc6020';
  ctx.fill();
  ctx.strokeStyle = '#ffcc88';
  ctx.lineWidth = 1.2;
  ctx.stroke();
  if(highlight){
    ctx.save(); ctx.shadowColor = '#ff7a2e'; ctx.shadowBlur = 10;
    ctx.beginPath(); ctx.arc(0, 0, 3, 0, Math.PI * 2);
    ctx.fillStyle = '#ff7a2e'; ctx.fill();
    ctx.restore();
  }
  ctx.restore();
}

function drawChassis(rpx, rpy, fpx, fpy){
  const mx = (rpx + fpx) / 2;
  const my = (rpy + fpy) / 2;
  const ang = Math.atan2(fpy - rpy, fpx - rpx);
  const len = Math.hypot(fpx - rpx, fpy - rpy);

  ctx.save();
  ctx.translate(mx, my);
  ctx.rotate(ang);

  // 底盘主体
  const g = ctx.createLinearGradient(0, -C.chassisH/2, 0, C.chassisH/2);
  g.addColorStop(0, '#2a5580');
  g.addColorStop(1, '#1a3555');
  ctx.fillStyle = g;
  roundRect(ctx, -len/2, -C.chassisH/2, len, C.chassisH, 4);
  ctx.fill();
  ctx.strokeStyle = '#3a7ab5';
  ctx.lineWidth = 1.5;
  ctx.stroke();

  // 内部纹理线
  ctx.strokeStyle = 'rgba(58,106,165,0.3)';
  ctx.lineWidth = 0.5;
  for(let x = -len/2 + 15; x < len/2; x += 20){
    ctx.beginPath(); ctx.moveTo(x, -C.chassisH/2 + 3); ctx.lineTo(x, C.chassisH/2 - 3); ctx.stroke();
  }

  ctx.restore();
}

function drawGimbal(mx, my, chassisAng, gimbalAng, active){
  // 云台连接点(底盘侧)
  const baseY = my;
  const topY = my - C.gimbalH;

  // 两个液压缸
  const offset = 35;
  const cylW = 8;

  for(let side = -1; side <= 1; side += 2){
    const bx = mx + side * offset;
    const by = baseY;
    const tx = mx + side * offset * 0.6;  // 顶部稍收窄
    const ty = topY;

    // 外筒
    ctx.beginPath();
    ctx.moveTo(bx - cylW/2, by);
    ctx.lineTo(bx - cylW/2 * 0.7, by - C.gimbalH * 0.55);
    ctx.lineTo(bx + cylW/2 * 0.7, by - C.gimbalH * 0.55);
    ctx.lineTo(bx + cylW/2, by);
    ctx.closePath();
    ctx.fillStyle = active ? '#006688' : '#004455';
    ctx.fill();
    ctx.strokeStyle = active ? '#00ccff' : '#008899';
    ctx.lineWidth = 1.2;
    ctx.stroke();

    // 活塞杆
    ctx.beginPath();
    ctx.moveTo(tx - 2, by - C.gimbalH * 0.5);
    ctx.lineTo(tx - 1.5, ty + 4);
    ctx.lineTo(tx + 1.5, ty + 4);
    ctx.lineTo(tx + 2, by - C.gimbalH * 0.5);
    ctx.closePath();
    ctx.fillStyle = active ? '#00aadd' : '#007799';
    ctx.fill();

    // 发光
    if(active){
      ctx.save();
      ctx.shadowColor = '#00ccff';
      ctx.shadowBlur = 8;
      ctx.beginPath();
      ctx.moveTo(tx, by - C.gimbalH * 0.5);
      ctx.lineTo(tx, ty + 4);
      ctx.strokeStyle = 'rgba(0,204,255,0.4)';
      ctx.lineWidth = 3;
      ctx.stroke();
      ctx.restore();
    }
  }

  // 陀螺仪图标
  const gyY = baseY - C.gimbalH * 0.35;
  const gyroR = 11;
  const gyroSpin = animT * 400; // 陀螺仪旋转

  ctx.save();
  ctx.translate(mx, gyY);

  // 外圈
  ctx.beginPath(); ctx.arc(0, 0, gyroR, 0, Math.PI * 2);
  ctx.strokeStyle = active ? 'rgba(0,204,255,0.7)' : 'rgba(0,136,153,0.4)';
  ctx.lineWidth = 1.5;
  ctx.stroke();

  // 旋转环
  ctx.save();
  ctx.rotate(rad(gyroSpin));
  ctx.beginPath();
  ctx.ellipse(0, 0, gyroR, gyroR * 0.4, 0, 0, Math.PI * 2);
  ctx.strokeStyle = active ? 'rgba(0,204,255,0.5)' : 'rgba(0,136,153,0.3)';
  ctx.lineWidth = 1;
  ctx.stroke();
  ctx.restore();

  // 中心点
  ctx.beginPath(); ctx.arc(0, 0, 2.5, 0, Math.PI * 2);
  ctx.fillStyle = active ? '#00ccff' : '#007788';
  ctx.fill();

  if(active){
    ctx.save(); ctx.shadowColor = '#00ccff'; ctx.shadowBlur = 6;
    ctx.beginPath(); ctx.arc(0, 0, 2, 0, Math.PI * 2);
    ctx.fillStyle = '#00eeff'; ctx.fill();
    ctx.restore();
  }
  ctx.restore();

  // 陀螺仪标签
  ctx.fillStyle = active ? '#00ccff' : '#4a6888';
  ctx.font = '9px "Share Tech Mono", monospace';
  ctx.textAlign = 'center';
  ctx.fillText('GYRO', mx, gyY + gyroR + 12);
}

function drawPlatform(mx, my, gimbalAng, ghost){
  const topY = my - C.gimbalH;

  ctx.save();
  ctx.translate(mx, topY);
  ctx.rotate(gimbalAng);

  if(ghost){
    // 无补偿幽灵投影
    ctx.globalAlpha = 0.25;
    ctx.fillStyle = '#331520';
    roundRect(ctx, -C.platW/2, -C.platH/2, C.platW, C.platH, 3);
    ctx.fill();
    ctx.strokeStyle = '#ff3c5a';
    ctx.lineWidth = 1.5;
    ctx.setLineDash([4,4]);
    ctx.stroke();
    ctx.setLineDash([]);

    // 幽灵货物
    ctx.fillStyle = '#2a1520';
    roundRect(ctx, -C.cargoW/2, -C.cargoH - C.platH/2, C.cargoW, C.cargoH, 2);
    ctx.fill();
    ctx.strokeStyle = 'rgba(255,60,90,0.4)';
    ctx.lineWidth = 1;
    ctx.setLineDash([3,3]);
    ctx.stroke();
    ctx.setLineDash([]);

    // 倾斜警示
    ctx.fillStyle = 'rgba(255,60,90,0.5)';
    ctx.font = '10px "Share Tech Mono", monospace';
    ctx.textAlign = 'center';
    ctx.fillText('UNSTABLE', 0, -C.cargoH - C.platH/2 - 8);

    ctx.globalAlpha = 1;
  } else {
    // 实际平台
    const g = ctx.createLinearGradient(0, -C.platH/2, 0, C.platH/2);
    g.addColorStop(0, '#0a4430');
    g.addColorStop(1, '#063322');
    ctx.fillStyle = g;
    roundRect(ctx, -C.platW/2, -C.platH/2, C.platW, C.platH, 3);
    ctx.fill();
    ctx.strokeStyle = '#00e88f';
    ctx.lineWidth = 2;
    ctx.stroke();

    // 发光
    ctx.save();
    ctx.shadowColor = '#00e88f';
    ctx.shadowBlur = 10;
    ctx.beginPath();
    ctx.moveTo(-C.platW/2 + 3, -C.platH/2);
    ctx.lineTo(C.platW/2 - 3, -C.platH/2);
    ctx.strokeStyle = 'rgba(0,232,143,0.25)';
    ctx.lineWidth = 3;
    ctx.stroke();
    ctx.restore();

    // 货物
    const cg = ctx.createLinearGradient(0, -C.cargoH - C.platH/2, 0, -C.platH/2);
    cg.addColorStop(0, '#1a3344');
    cg.addColorStop(1, '#142a38');
    ctx.fillStyle = cg;
    roundRect(ctx, -C.cargoW/2, -C.cargoH - C.platH/2, C.cargoW, C.cargoH, 3);
    ctx.fill();
    ctx.strokeStyle = '#3a8a7a';
    ctx.lineWidth = 1.2;
    ctx.stroke();

    // 货物标签
    ctx.fillStyle = '#5aaa9a';
    ctx.font = '11px "Share Tech Mono", monospace';
    ctx.textAlign = 'center';
    ctx.fillText('CARGO', 0, -C.cargoH/2 - C.platH/2 + 4);

    // 水平指示
    ctx.fillStyle = '#00e88f';
    ctx.font = '9px "Share Tech Mono", monospace';
    ctx.textAlign = 'center';
    ctx.fillText('● LEVEL', 0, -C.cargoH - C.platH/2 - 8);
  }

  ctx.restore();
}

function roundRect(c, x, y, w, h, r){
  r = Math.min(r, w/2, h/2);
  c.beginPath();
  c.moveTo(x+r, y);
  c.lineTo(x+w-r, y);
  c.arcTo(x+w, y, x+w, y+r, r);
  c.lineTo(x+w, y+h-r);
  c.arcTo(x+w, y+h, x+w-r, y+h, r);
  c.lineTo(x+r, y+h);
  c.arcTo(x, y+h, x, y+h-r, r);
  c.lineTo(x, y+r);
  c.arcTo(x, y, x+r, y, r);
  c.closePath();
}

/* ====== 信息面板 ====== */
function drawInfoPanel(st){
  // 左上:阶段指示
  ctx.fillStyle = '#0a1420';
  roundRect(ctx, 30, 25, 220, 90, 6);
  ctx.fill();
  ctx.strokeStyle = '#1a3050';
  ctx.lineWidth = 1;
  ctx.stroke();

  ctx.fillStyle = '#5a8ab0';
  ctx.font = '10px "Share Tech Mono", monospace';
  ctx.textAlign = 'left';
  ctx.fillText('CURRENT PHASE', 42, 44);

  let phaseName = '', phaseColor = '#5a8ab0';
  if(st.fcp <= 0 && st.rcp <= 0){ phaseName = '平地行驶'; phaseColor = '#5a9ed5'; }
  else if(st.fcp > 0 && st.fcp < 1){ phaseName = '前轮越障'; phaseColor = '#ff7a2e'; }
  else if(st.fcp >= 1 && st.rcp <= 0){ phaseName = '过渡行驶'; phaseColor = '#5a9ed5'; }
  else if(st.rcp > 0 && st.rcp < 1){ phaseName = '后轮越障'; phaseColor = '#ff7a2e'; }
  else { phaseName = '越障完成'; phaseColor = '#00e88f'; }

  ctx.fillStyle = phaseColor;
  ctx.font = '700 16px "Rajdhani", sans-serif';
  ctx.fillText(phaseName, 42, 68);

  // 补偿角度
  const compDeg = Math.abs(deg(st.gimbalAng));
  ctx.fillStyle = compDeg > 0.5 ? '#00ccff' : '#4a6888';
  ctx.font = '11px "Share Tech Mono", monospace';
  ctx.fillText('补偿角: ' + compDeg.toFixed(1) + '°', 42, 88);
  ctx.fillText('支架转角: ' + (st.fcp > st.rcp ? st.fba : st.rba).toFixed(0) + '°', 42, 104);

  // 右上:水平仪
  const lx = W - 170, ly = 30;
  ctx.fillStyle = '#0a1420';
  roundRect(ctx, lx, ly, 140, 70, 6);
  ctx.fill();
  ctx.strokeStyle = '#1a3050';
  ctx.lineWidth = 1;
  ctx.stroke();

  ctx.fillStyle = '#5a8ab0';
  ctx.font = '10px "Share Tech Mono", monospace';
  ctx.textAlign = 'center';
  ctx.fillText('PLATFORM LEVEL', lx + 70, ly + 18);

  // 水平气泡
  const bubY = ly + 42;
  const bubW = 100, bubH = 16;
  ctx.fillStyle = '#0d1a2a';
  roundRect(ctx, lx + 20, bubY - bubH/2, bubW, bubH, bubH/2);
  ctx.fill();
  ctx.strokeStyle = '#1a3050';
  ctx.lineWidth = 1;
  ctx.stroke();

  // 气泡位置(基于底盘倾斜角)
  const tilt = st.chassisAng;
  const bubbleX = clamp(tilt * 200, -bubW/2 + 8, bubW/2 - 8);
  const isLevel = Math.abs(tilt) < 0.02;

  ctx.beginPath();
  ctx.arc(lx + 70 + bubbleX, bubY, 6, 0, Math.PI * 2);
  ctx.fillStyle = isLevel ? '#00e88f' : '#ff7a2e';
  ctx.fill();
  if(isLevel){
    ctx.save(); ctx.shadowColor = '#00e88f'; ctx.shadowBlur = 8;
    ctx.beginPath(); ctx.arc(lx + 70 + bubbleX, bubY, 4, 0, Math.PI * 2);
    ctx.fillStyle = '#00ffaa'; ctx.fill();
    ctx.restore();
  }

  // 中心线
  ctx.strokeStyle = '#2a4a70';
  ctx.lineWidth = 1;
  ctx.beginPath(); ctx.moveTo(lx + 70, bubY - bubH/2 + 2); ctx.lineTo(lx + 70, bubY + bubH/2 - 2); ctx.stroke();

  ctx.fillStyle = isLevel ? '#00e88f' : '#ff7a2e';
  ctx.font = '9px "Share Tech Mono", monospace';
  ctx.fillText(isLevel ? 'STABLE' : 'COMPENSATING', lx + 70, ly + 62);
}

/* ====== 标注线 ====== */
function drawAnnotations(st){
  const mx = (st.rpx + st.fpx) / 2;
  const my = (st.rpy + st.fpy) / 2;
  const topY = my - C.gimbalH;

  // 臂长标注(仅在前轮越障时显示)
  if(st.fcp > 0.1 && st.fcp < 0.9){
    ctx.save();
    ctx.translate(st.fpx, st.fpy);
    ctx.rotate(rad(st.fba));
    // 标注臂长
    const aAngle = rad(30); // 第一条臂的角度
    const ax = C.R * Math.cos(aAngle);
    const ay = C.R * Math.sin(aAngle);
    ctx.strokeStyle = 'rgba(255,122,46,0.5)';
    ctx.lineWidth = 0.8;
    ctx.setLineDash([2,2]);
    ctx.beginPath(); ctx.moveTo(0, -15); ctx.lineTo(0, 5); ctx.stroke();
    ctx.beginPath(); ctx.moveTo(ax, ay - 15); ctx.lineTo(ax, ay + 5); ctx.stroke();
    ctx.beginPath(); ctx.moveTo(0, -8); ctx.lineTo(ax, -8); ctx.stroke();
    ctx.setLineDash([]);
    ctx.fillStyle = 'rgba(255,160,80,0.8)';
    ctx.font = '9px "Share Tech Mono", monospace';
    ctx.textAlign = 'center';
    ctx.fillText('R=150mm', ax/2, -12);
    ctx.restore();
  }

  // 解耦示意箭头
  if(st.fcp > 0.05 || st.rcp > 0.05){
    const arrowX = mx + C.platW/2 + 30;
    const arrowBase = my;
    const arrowTop = topY;

    ctx.strokeStyle = 'rgba(0,204,255,0.4)';
    ctx.lineWidth = 1;
    ctx.setLineDash([3,3]);
    ctx.beginPath(); ctx.moveTo(arrowX, arrowBase); ctx.lineTo(arrowX, arrowTop); ctx.stroke();
    ctx.setLineDash([]);

    // 箭头
    ctx.fillStyle = 'rgba(0,204,255,0.5)';
    ctx.beginPath(); ctx.moveTo(arrowX, arrowTop); ctx.lineTo(arrowX-4, arrowTop+8); ctx.lineTo(arrowX+4, arrowTop+8); ctx.fill();
    ctx.beginPath(); ctx.moveTo(arrowX, arrowBase); ctx.lineTo(arrowX-4, arrowBase-8); ctx.lineTo(arrowX+4, arrowBase-8); ctx.fill();

    ctx.save();
    ctx.translate(arrowX + 6, (arrowBase + arrowTop) / 2);
    ctx.rotate(-Math.PI/2);
    ctx.fillStyle = 'rgba(0,204,255,0.6)';
    ctx.font = '9px "Share Tech Mono", monospace';
    ctx.textAlign = 'center';
    ctx.fillText('DECOUPLED', 0, 0);
    ctx.restore();
  }
}

/* ====== 旋转轨迹提示 ====== */
function drawOrbitHint(px, py, progress){
  if(progress <= 0 || progress >= 1) return;
  ctx.save();
  ctx.translate(px, py);
  ctx.beginPath();
  ctx.arc(0, 0, C.R, rad(30), rad(30 + 120), false);
  ctx.strokeStyle = 'rgba(255,122,46,0.15)';
  ctx.lineWidth = 1;
  ctx.setLineDash([4,6]);
  ctx.stroke();
  ctx.setLineDash([]);

  // 当前位置的亮点
  const curAngle = rad(30 + progress * 120);
  const hx = C.R * Math.cos(curAngle);
  const hy = C.R * Math.sin(curAngle);
  ctx.beginPath(); ctx.arc(hx, hy, 3, 0, Math.PI * 2);
  ctx.fillStyle = 'rgba(255,180,80,0.6)';
  ctx.fill();
  ctx.restore();
}

/* ====== 主绘制 ====== */
function draw(){
  ctx.clearRect(0, 0, W, H);

  // 背景
  ctx.fillStyle = '#050910';
  ctx.fillRect(0, 0, W, H);
  drawGrid();

  // 地面
  drawGround();

  const st = getState(animT);
  const mx = (st.rpx + st.fpx) / 2;
  const my = (st.rpy + st.fpy) / 2;
  const topY = my - C.gimbalH;
  const wheelSpin = animT * 2000; // 轮子旋转角

  const frontActive = st.fcp > 0.01 && st.fcp < 0.99;
  const rearActive  = st.rcp > 0.01 && st.rcp < 0.99;
  const anyActive   = frontActive || rearActive;

  // 旋转轨迹提示
  drawOrbitHint(st.fpx, st.fpy, st.fcp);
  drawOrbitHint(st.rpx, st.rpy, st.rcp);

  // 后轮
  drawWheel(st.rpx, st.rpy, st.rba, wheelSpin, rearActive);
  // 前轮
  drawWheel(st.fpx, st.fpy, st.fba, wheelSpin, frontActive);

  // 底盘
  drawChassis(st.rpx, st.rpy, st.fpx, st.fpy);

  // 无补偿幽灵投影(先画,在下方)
  if(showGhost && anyActive){
    drawPlatform(mx, my, st.chassisAng, true);
  }

  // 云台
  drawGimbal(mx, my, st.chassisAng, st.gimbalAng, anyActive);

  // 实际平台
  drawPlatform(mx, my, st.gimbalAng, false);

  // 标注
  drawAnnotations(st);

  // 信息面板
  drawInfoPanel(st);

  // 底部 IFR 说明
  ctx.fillStyle = '#2a4060';
  ctx.font = '10px "Share Tech Mono", monospace';
  ctx.textAlign = 'center';
  ctx.fillText('IFR: 底层暴力越障 + 上层主动隔离 = 移动与稳定彻底解耦', W/2, H - 15);
}

/* ====== 动画循环 ====== */
function loop(ts){
  if(!paused){
    if(lastTS === null) lastTS = ts;
    const dt = ts - lastTS;
    lastTS = ts;
    animT += (dt / DURATION) * speed;
    if(animT > 1) animT = 0;
  } else {
    lastTS = null;
  }
  draw();
  requestAnimationFrame(loop);
}

// 自动开始
requestAnimationFrame(loop);

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

这个动画实现了一个完整的行星轮越障与主动补偿云台原理演示,核心设计要点如下:

IFR 思想体现

  • 动画直接展示"理想解"运行状态——底部行星轮暴力翻转越障,顶部云台主动补偿使货物平台始终水平,两个功能彻底解耦
  • 通过红色半透明幽灵投影(可开关),对比展示"无补偿时平台会随底盘倾斜"的状态,直观凸显云台的隔离效果

视觉引导

  • 行星轮支架(橙色高亮)在越障时发出辉光,旋转轨迹用虚线弧标示
  • 云台液压缸(青色高亮)在补偿时发光,陀螺仪图标实时旋转
  • 平台(绿色)始终标注"LEVEL",与幽灵投影的"UNSTABLE"形成强烈对比
  • 右上角水平气泡仪实时反映补偿状态

交互控制

  • 台阶高度滑块(15-120mm):可调整越障难度,接近臂长极限时越障弧线更剧烈
  • 播放速度滑块:慢放可仔细观察支架翻转与云台补偿的协同
  • 无补偿对比开关:关闭幽灵投影以聚焦理想解本身
  • 暂停/重新播放按钮
积分规则:第一轮对话扣减8分,后续每轮扣6分