独立渲染引擎就绪引擎就绪
<!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分
等待动画代码生成...
