<!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>
修正说明:
核心修改了两处,使旋转方向符合物理实际:
行星轮组绘制:
wx = -ARM_LEN * Math.sin(a),取负号后当bAngle从 -60° 增大到 +60° 时,顶部轮向右前方摆下(顺时针),这正是车辆向右行驶、受惯性驱动攀登台阶时的正确旋转方向。此前未取负,旋转为逆时针——顶部轮向左后方摆,与物理矛盾。底盘倾角方向:
cAngle在攀登阶段取负值(最小约 -36°),表示底盘前侧(右侧)抬起——对应 canvas 中逆时针旋转。此前为正值(顺时针=右侧下沉),与攀登时前侧抬高的实际运动矛盾。云台补偿角gAngle = -cAngle仍为正值(顺时针),与底盘倾斜方向相反,保持平台水平。翻转弧线指示:重新计算了 canvas 角度映射,箭头指向顺时针方向,并标注 "CW" 字样。
接触点位置:
contactX调整为STEP_X - ARM_LEN * 0.72,更准确地对应右下轮抵达台阶立面时支架中心的位置。
等待动画代码生成...
