分享图
动画工坊
引擎就绪
<!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@400;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
  --bg:#060b18;--fg:#d8e4ff;--muted:#4a5580;--accent:#00e5ff;
  --accent2:#ff7b3a;--accent3:#39ff14;--card:rgba(10,18,40,0.92);
  --border:#1e2d55;--suction:#00bcd4;--danger:#ff2d55;
}
html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--fg);font-family:'Noto Sans SC',sans-serif}
body{display:flex;flex-direction:column;align-items:center;justify-content:center}
.canvas-wrap{position:relative;width:100vw;height:100vh;display:flex;align-items:center;justify-content:center}
canvas{display:block;width:100%;height:100%}
header{position:absolute;top:20px;left:32px;z-index:10;pointer-events:none}
header h1{font-family:'Rajdhani',sans-serif;font-size:clamp(22px,2.6vw,38px);font-weight:700;letter-spacing:2px;color:var(--accent);text-shadow:0 0 24px rgba(0,229,255,0.35)}
header p{font-size:clamp(12px,1.2vw,16px);color:var(--muted);margin-top:4px;font-weight:300;letter-spacing:1px}
.controls{position:absolute;bottom:24px;left:50%;transform:translateX(-50%);z-index:10;display:flex;gap:28px;align-items:center;
  background:var(--card);border:1px solid var(--border);border-radius:14px;padding:14px 32px;backdrop-filter:blur(12px)}
.ctrl-group{display:flex;flex-direction:column;align-items:center;gap:6px}
.ctrl-group label{font-size:12px;color:var(--muted);letter-spacing:1px;font-weight:500}
.ctrl-group .val{font-family:'Rajdhani',sans-serif;font-size:16px;font-weight:700;color:var(--accent);min-width:60px;text-align:center}
input[type=range]{-webkit-appearance:none;appearance:none;width:140px;height:6px;border-radius:3px;background:var(--border);outline:none;cursor:pointer}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px rgba(0,229,255,0.5);cursor:pointer}
.btn{padding:8px 18px;border:1px solid var(--border);border-radius:8px;background:transparent;color:var(--fg);
  font-family:'Noto Sans SC',sans-serif;font-size:13px;cursor:pointer;transition:all .2s;letter-spacing:1px}
.btn:hover{background:rgba(0,229,255,0.1);border-color:var(--accent);color:var(--accent)}
.btn.active{background:rgba(0,229,255,0.15);border-color:var(--accent);color:var(--accent)}
.legend{position:absolute;top:20px;right:24px;z-index:10;display:flex;flex-direction:column;gap:8px;
  background:var(--card);border:1px solid var(--border);border-radius:12px;padding:14px 18px;backdrop-filter:blur(12px)}
.legend-item{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}
.legend-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
@media(max-width:768px){
  .controls{flex-wrap:wrap;gap:14px;padding:10px 18px}
  input[type=range]{width:100px}
  .legend{display:none}
}
</style>
</head>
<body>

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

<header>
  <h1>VACUUM-TRACK CLIMBER</h1>
  <p>最终理想解原理演示 — 吸尘场复用:一源双效</p>
</header>

<div class="legend">
  <div class="legend-item"><div class="legend-dot" style="background:#00e5ff;box-shadow:0 0 6px #00e5ff"></div>负压吸附区</div>
  <div class="legend-item"><div class="legend-dot" style="background:#ff7b3a;box-shadow:0 0 6px #ff7b3a"></div>灰尘颗粒</div>
  <div class="legend-item"><div class="legend-dot" style="background:#39ff14;box-shadow:0 0 6px #39ff14"></div>阀门开启</div>
  <div class="legend-item"><div class="legend-dot" style="background:#ff2d55;box-shadow:0 0 6px #ff2d55"></div>阀门关闭</div>
</div>

<div class="controls">
  <div class="ctrl-group">
    <label>负压强度</label>
    <input type="range" id="pressureSlider" min="1" max="10" value="5" step="0.5">
    <div class="val" id="pressureVal">-5.0 kPa</div>
  </div>
  <div class="ctrl-group">
    <label>动画速度</label>
    <input type="range" id="speedSlider" min="1" max="10" value="5" step="0.5">
    <div class="val" id="speedVal">1.0x</div>
  </div>
  <button class="btn" id="toggleIFR">IFR 原理</button>
  <button class="btn" id="toggleDetail">微孔详图</button>
</div>

<script>
/* ============ 配置常量 ============ */
const CFG = {
  stairTread: 170,      // 台阶踏面宽度
  stairRiser: 130,      // 台阶立面高度
  stairCount: 7,        // 台阶数量(多出可见区域用于滚动)
  bodyW: 240,           // 机器人本体宽
  bodyH: 72,            // 机器人本体高
  trackThick: 20,       // 履带厚度
  perfRadius: 2.2,      // 微孔半径
  perfSpacing: 14,      // 微孔间距
  valveW: 6,            // 阀门宽
  valveH: 10,           // 阀门高
  motorR: 22,           // 电机半径
  dustBoxW: 50,         // 集尘盒宽
  dustBoxH: 36,         // 集尘盒高
  maxParticles: 80,     // 最大粒子数
  colors: {
    stair: '#1a2040',
    stairEdge: '#2e3d65',
    stairSurface: '#253058',
    body: '#0c1428',
    bodyStroke: '#2e4070',
    track: '#141e38',
    trackStroke: '#2a3e68',
    cavity: '#0a1020',
    suction: '#00e5ff',
    suctionGlow: 'rgba(0,229,255,0.25)',
    dust: '#ff7b3a',
    valveOpen: '#39ff14',
    valveClosed: '#ff2d55',
    motor: '#1e2e50',
    motorBlade: '#00e5ff',
    label: '#8090b8',
    ifrBg: 'rgba(10,16,32,0.94)',
    ifrBorder: '#1e2d55'
  }
};

/* ============ 状态变量 ============ */
let canvas, ctx, W, H, dpr;
let time = 0;
let pressure = 5;
let speed = 1;
let scrollOffset = 0;
let showIFR = true;
let showDetail = true;
let particles = [];
let stairProfilePoints = [];

/* ============ 初始化 ============ */
function init() {
  canvas = document.getElementById('c');
  ctx = canvas.getContext('2d');
  resize();
  window.addEventListener('resize', resize);
  
  // 控件绑定
  const ps = document.getElementById('pressureSlider');
  const ss = document.getElementById('speedSlider');
  ps.addEventListener('input', () => {
    pressure = parseFloat(ps.value);
    document.getElementById('pressureVal').textContent = `-${pressure.toFixed(1)} kPa`;
  });
  ss.addEventListener('input', () => {
    speed = parseFloat(ss.value) / 5;
    document.getElementById('speedVal').textContent = `${speed.toFixed(1)}x`;
  });
  document.getElementById('toggleIFR').addEventListener('click', function() {
    showIFR = !showIFR;
    this.classList.toggle('active', showIFR);
  });
  document.getElementById('toggleDetail').addEventListener('click', function() {
    showDetail = !showDetail;
    this.classList.toggle('active', showDetail);
  });
  document.getElementById('toggleIFR').classList.add('active');
  document.getElementById('toggleDetail').classList.add('active');
  
  // 初始化粒子
  initParticles();
  
  // 启动动画
  requestAnimationFrame(loop);
}

function resize() {
  dpr = Math.min(window.devicePixelRatio || 1, 2);
  W = window.innerWidth;
  H = window.innerHeight;
  canvas.width = W * dpr;
  canvas.height = H * dpr;
  canvas.style.width = W + 'px';
  canvas.style.height = H + 'px';
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  
  // 生成楼梯轮廓
  buildStairProfile();
}

/* ============ 楼梯轮廓生成 ============ */
function buildStairProfile() {
  stairProfilePoints = [];
  const cx = W * 0.38;  // 楼梯中心X偏移
  const by = H * 0.78;  // 底部Y
  for (let i = 0; i < CFG.stairCount; i++) {
    const x = cx + i * CFG.stairTread;
    const y = by - i * CFG.stairRiser;
    stairProfilePoints.push({ x, y, type: 'tread' });
    const rx = x + CFG.stairTread;
    const ry = y;
    stairProfilePoints.push({ x: rx, y: ry, type: 'riser-top' });
  }
}

/* ============ 粒子系统 ============ */
function initParticles() {
  particles = [];
  for (let i = 0; i < CFG.maxParticles; i++) {
    particles.push(createParticle());
  }
}

function createParticle() {
  return {
    x: 0, y: 0,
    vx: 0, vy: 0,
    life: 0,
    maxLife: 60 + Math.random() * 80,
    size: 1.5 + Math.random() * 2,
    phase: Math.random() * Math.PI * 2,
    active: false
  };
}

function spawnDustParticle(p, contactX, contactY, targetX, targetY) {
  p.x = contactX + (Math.random() - 0.5) * 20;
  p.y = contactY + (Math.random() - 0.5) * 6;
  p.vx = (targetX - p.x) / p.maxLife;
  p.vy = (targetY - p.y) / p.maxLife;
  p.life = 0;
  p.maxLife = 40 + Math.random() * 60;
  p.active = true;
}

/* ============ 获取楼梯面上的点 ============ */
function getStairSurfacePoint(t) {
  // t: 0~1 沿楼梯表面的参数
  const totalLen = (CFG.stairCount - 1) * (CFG.stairTread + CFG.stairRiser);
  const dist = t * totalLen;
  const cx = W * 0.38;
  const by = H * 0.78;
  
  let accumulated = 0;
  for (let i = 0; i < CFG.stairCount - 1; i++) {
    const treadStart = cx + i * CFG.stairTread;
    const treadY = by - i * CFG.stairRiser;
    
    // 踏面段
    if (accumulated + CFG.stairTread >= dist) {
      const frac = (dist - accumulated) / CFG.stairTread;
      return { x: treadStart + frac * CFG.stairTread, y: treadY, normal: { x: 0, y: -1 }, type: 'tread' };
    }
    accumulated += CFG.stairTread;
    
    // 立面段
    const riserX = treadStart + CFG.stairTread;
    if (accumulated + CFG.stairRiser >= dist) {
      const frac = (dist - accumulated) / CFG.stairRiser;
      return { x: riserX, y: treadY - frac * CFG.stairRiser, normal: { x: 1, y: 0 }, type: 'riser' };
    }
    accumulated += CFG.stairRiser;
  }
  
  // 最后一级踏面
  const lastI = CFG.stairCount - 1;
  return { x: cx + lastI * CFG.stairTread, y: by - lastI * CFG.stairRiser, normal: { x: 0, y: -1 }, type: 'tread' };
}

/* ============ 绘制函数 ============ */

// 绘制背景网格
function drawBackground() {
  ctx.fillStyle = '#060b18';
  ctx.fillRect(0, 0, W, H);
  
  // 网格
  ctx.strokeStyle = 'rgba(0,229,255,0.03)';
  ctx.lineWidth = 0.5;
  const gridSize = 40;
  for (let x = 0; x < W; x += gridSize) {
    ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
  }
  for (let y = 0; y < H; y += gridSize) {
    ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
  }
  
  // 氛围光
  const g1 = ctx.createRadialGradient(W * 0.35, H * 0.45, 0, W * 0.35, H * 0.45, W * 0.5);
  g1.addColorStop(0, 'rgba(0,229,255,0.04)');
  g1.addColorStop(1, 'rgba(0,229,255,0)');
  ctx.fillStyle = g1;
  ctx.fillRect(0, 0, W, H);
}

// 绘制楼梯
function drawStairs() {
  const cx = W * 0.38;
  const by = H * 0.78;
  const oy = scrollOffset;
  
  // 楼梯主体多边形
  ctx.beginPath();
  ctx.moveTo(cx - 40, by - oy + 10);
  for (let i = 0; i < CFG.stairCount; i++) {
    const sx = cx + i * CFG.stairTread;
    const sy = by - i * CFG.stairRiser - oy;
    ctx.lineTo(sx, sy);
    ctx.lineTo(sx + CFG.stairTread, sy);
  }
  // 闭合到底部右侧
  const lastX = cx + (CFG.stairCount - 1) * CFG.stairTread + CFG.stairTread;
  ctx.lineTo(lastX, by - oy + 10);
  ctx.closePath();
  
  const sg = ctx.createLinearGradient(0, by - 400, 0, by + 50);
  sg.addColorStop(0, '#1a2548');
  sg.addColorStop(1, '#0e1530');
  ctx.fillStyle = sg;
  ctx.fill();
  
  // 楼梯边缘
  ctx.strokeStyle = '#2e3d65';
  ctx.lineWidth = 1.5;
  ctx.beginPath();
  for (let i = 0; i < CFG.stairCount; i++) {
    const sx = cx + i * CFG.stairTread;
    const sy = by - i * CFG.stairRiser - oy;
    if (i === 0) ctx.moveTo(sx, sy);
    ctx.lineTo(sx + CFG.stairTread, sy);
    if (i < CFG.stairCount - 1) {
      ctx.lineTo(sx + CFG.stairTread, sy - CFG.stairRiser);
    }
  }
  ctx.stroke();
  
  // 踏面高亮
  ctx.strokeStyle = 'rgba(0,229,255,0.08)';
  ctx.lineWidth = 1;
  for (let i = 0; i < CFG.stairCount; i++) {
    const sx = cx + i * CFG.stairTread;
    const sy = by - i * CFG.stairRiser - oy;
    ctx.beginPath();
    ctx.moveTo(sx, sy);
    ctx.lineTo(sx + CFG.stairTread, sy);
    ctx.stroke();
  }
  
  // 表面纹理(细微点阵)
  ctx.fillStyle = 'rgba(46,61,101,0.4)';
  for (let i = 0; i < CFG.stairCount; i++) {
    const sx = cx + i * CFG.stairTread;
    const sy = by - i * CFG.stairRiser - oy;
    for (let dx = 8; dx < CFG.stairTread; dx += 12) {
      for (let dy = 3; dy < 8; dy += 5) {
        ctx.fillRect(sx + dx, sy + dy, 1, 1);
      }
    }
    // 立面纹理
    if (i < CFG.stairCount - 1) {
      for (let dy = 5; dy < CFG.stairRiser; dy += 10) {
        for (let dx = 3; dx < 8; dx += 5) {
          ctx.fillRect(sx + CFG.stairTread + dx, sy - dy, 1, 1);
        }
      }
    }
  }
}

// 获取机器人当前在楼梯上的位置
function getRobotOnStairs() {
  const cx = W * 0.38;
  const by = H * 0.78;
  const oy = scrollOffset;
  
  // 机器人中心位于第2-3级台阶上
  const stepIdx = 2;
  const stepX = cx + stepIdx * CFG.stairTread;
  const stepY = by - stepIdx * CFG.stairRiser - oy;
  
  return { cx: stepX + CFG.stairTread * 0.5, cy: stepY, stepX, stepY, stepIdx };
}

// 绘制机器人(剖面视图)
function drawRobot() {
  const info = getRobotOnStairs();
  const rcx = info.cx;
  const rcy = info.cy;
  const bw = CFG.bodyW;
  const bh = CFG.bodyH;
  const tt = CFG.trackThick;
  const oy = scrollOffset;
  const cx = W * 0.38;
  const by = H * 0.78;
  
  // 机器人角度(跟随楼梯倾斜)
  const angle = Math.atan2(CFG.stairRiser, CFG.stairTread);
  
  // 机器人本体位置(底部贴在台阶上)
  const bodyLeft = rcx - bw * 0.55;
  const bodyBottom = rcy - tt;
  const bodyTop = bodyBottom - bh;
  
  ctx.save();
  
  // ---- 履带外轮廓(贴合楼梯表面) ----
  // 履带底部路径跟随楼梯表面(踏面+立面+踏面)
  const trackBottomPath = [];
  const trackTopPath = [];
  
  // 生成履带底部路径点(沿楼梯表面)
  const stepI = info.stepIdx;
  const s1x = cx + stepI * CFG.stairTread;
  const s1y = by - stepI * CFG.stairRiser - oy;
  const s2x = cx + (stepI + 1) * CFG.stairTread;
  const s2y = by - (stepI + 1) * CFG.stairRiser - oy;
  
  // 底部路径:沿踏面 → 上立面 → 沿上级踏面
  trackBottomPath.push({ x: s1x - 30, y: s1y });
  trackBottomPath.push({ x: s2x, y: s1y });
  trackBottomPath.push({ x: s2x, y: s2y });
  trackBottomPath.push({ x: s2x + CFG.stairTread + 30, y: s2y });
  
  // 顶部路径:平滑弧线(履带绕过本体顶部)
  const topY = s2y - bh - tt - 15;
  const topLeftX = s1x - 30;
  const topRightX = s2x + CFG.stairTread + 30;
  trackTopPath.push({ x: topRightX, y: s2y - tt });
  trackTopPath.push({ x: topRightX, y: topY + 20 });
  trackTopPath.push({ x: topRightX - 20, y: topY });
  trackTopPath.push({ x: topLeftX + 20, y: topY });
  trackTopPath.push({ x: topLeftX, y: topY + 20 });
  trackTopPath.push({ x: topLeftX, y: s1y - tt });
  
  // 绘制履带外形
  ctx.beginPath();
  ctx.moveTo(trackBottomPath[0].x, trackBottomPath[0].y);
  for (let i = 1; i < trackBottomPath.length; i++) {
    ctx.lineTo(trackBottomPath[i].x, trackBottomPath[i].y);
  }
  for (let i = 0; i < trackTopPath.length; i++) {
    ctx.lineTo(trackTopPath[i].x, trackTopPath[i].y);
  }
  ctx.closePath();
  
  const tg = ctx.createLinearGradient(0, topY, 0, s1y);
  tg.addColorStop(0, '#141e38');
  tg.addColorStop(1, '#1a2848');
  ctx.fillStyle = tg;
  ctx.fill();
  ctx.strokeStyle = '#2a3e68';
  ctx.lineWidth = 1.5;
  ctx.stroke();
  
  // 履带内腔
  const innerOffset = tt * 0.6;
  ctx.beginPath();
  ctx.moveTo(trackBottomPath[0].x, trackBottomPath[0].y - innerOffset);
  for (let i = 1; i < trackBottomPath.length; i++) {
    ctx.lineTo(trackBottomPath[i].x, trackBottomPath[i].y - innerOffset);
  }
  for (let i = trackTopPath.length - 1; i >= 0; i--) {
    const dy = trackTopPath[i].y < topY + 40 ? innerOffset : -innerOffset;
    ctx.lineTo(trackTopPath[i].x + (trackTopPath[i].x > rcx ? -innerOffset : innerOffset), trackTopPath[i].y + dy);
  }
  ctx.closePath();
  ctx.fillStyle = '#080e1e';
  ctx.fill();
  
  // ---- 本体 ----
  const bodyPath = [
    { x: s1x - 10, y: s1y - tt - innerOffset - 2 },
    { x: s2x + CFG.stairTread * 0.6, y: s2y - tt - innerOffset - 2 },
    { x: s2x + CFG.stairTread * 0.6, y: topY + 28 },
    { x: s1x - 10, y: topY + 35 }
  ];
  
  ctx.beginPath();
  ctx.moveTo(bodyPath[0].x, bodyPath[0].y);
  for (let i = 1; i < bodyPath.length; i++) ctx.lineTo(bodyPath[i].x, bodyPath[i].y);
  ctx.closePath();
  const bg = ctx.createLinearGradient(0, topY, 0, s1y);
  bg.addColorStop(0, '#0c1428');
  bg.addColorStop(1, '#101c34');
  ctx.fillStyle = bg;
  ctx.fill();
  ctx.strokeStyle = '#2e4070';
  ctx.lineWidth = 1;
  ctx.stroke();
  
  // ---- 内部组件 ----
  const bodyCx = (bodyPath[0].x + bodyPath[1].x) / 2;
  const bodyCy = (bodyPath[0].y + bodyPath[2].y) / 2;
  
  // 电机
  const motorX = bodyCx - 30;
  const motorY = bodyCy - 5;
  ctx.beginPath();
  ctx.arc(motorX, motorY, CFG.motorR, 0, Math.PI * 2);
  ctx.fillStyle = '#0e1830';
  ctx.fill();
  ctx.strokeStyle = '#2e4070';
  ctx.lineWidth = 1;
  ctx.stroke();
  
  // 电机风扇叶片(旋转动画)
  const fanAngle = time * 4;
  ctx.strokeStyle = CFG.colors.motorBlade;
  ctx.lineWidth = 2;
  ctx.globalAlpha = 0.7;
  for (let i = 0; i < 5; i++) {
    const a = fanAngle + i * Math.PI * 2 / 5;
    ctx.beginPath();
    ctx.moveTo(motorX, motorY);
    ctx.lineTo(motorX + Math.cos(a) * CFG.motorR * 0.85, motorY + Math.sin(a) * CFG.motorR * 0.85);
    ctx.stroke();
  }
  ctx.globalAlpha = 1;
  
  // 电机标签
  ctx.fillStyle = '#5a6a90';
  ctx.font = '10px "Noto Sans SC"';
  ctx.textAlign = 'center';
  ctx.fillText('吸尘电机', motorX, motorY + CFG.motorR + 14);
  
  // 集尘盒
  const dbX = bodyCx + 20;
  const dbY = bodyCy - CFG.dustBoxH / 2 - 5;
  ctx.fillStyle = '#0e1830';
  ctx.strokeStyle = '#2e4070';
  ctx.lineWidth = 1;
  roundRect(ctx, dbX, dbY, CFG.dustBoxW, CFG.dustBoxH, 4);
  ctx.fill();
  ctx.stroke();
  
  // 集尘盒填充量(动态)
  const fillH = CFG.dustBoxH * (0.3 + 0.2 * Math.sin(time * 0.5));
  ctx.fillStyle = 'rgba(255,123,58,0.25)';
  ctx.fillRect(dbX + 2, dbY + CFG.dustBoxH - fillH, CFG.dustBoxW - 4, fillH - 2);
  
  ctx.fillStyle = '#5a6a90';
  ctx.font = '10px "Noto Sans SC"';
  ctx.textAlign = 'center';
  ctx.fillText('集尘盒', dbX + CFG.dustBoxW / 2, dbY + CFG.dustBoxH + 14);
  
  // 导管:从履带腔体到集尘盒
  ctx.strokeStyle = 'rgba(0,229,255,0.15)';
  ctx.lineWidth = 3;
  ctx.setLineDash([4, 4]);
  ctx.lineDashOffset = -time * 30;
  ctx.beginPath();
  ctx.moveTo(bodyCx - 60, bodyPath[0].y - 15);
  ctx.quadraticCurveTo(bodyCx - 30, bodyCy + 10, dbX, dbY + CFG.dustBoxH / 2);
  ctx.stroke();
  ctx.setLineDash([]);
  
  // ---- 履带微孔(动画滚动) ----
  const perfOffset = (time * 40) % CFG.perfSpacing;
  ctx.fillStyle = 'rgba(0,229,255,0.5)';
  
  // 底部履带面的微孔
  for (let i = 0; i < trackBottomPath.length - 1; i++) {
    const p1 = trackBottomPath[i];
    const p2 = trackBottomPath[i + 1];
    const dx = p2.x - p1.x;
    const dy = p2.y - p1.y;
    const len = Math.sqrt(dx * dx + dy * dy);
    const nx = -dy / len; // 法线方向(朝外)
    const ny = dx / len;
    const count = Math.floor(len / CFG.perfSpacing);
    
    for (let j = 0; j < count; j++) {
      const t2 = ((j * CFG.perfSpacing + perfOffset) % len) / len;
      const px = p1.x + dx * t2 + nx * tt * 0.35;
      const py = p1.y + dy * t2 + ny * tt * 0.35;
      
      // 判断是否在接触面附近(阀门开启)
      const isContact = isNearStairSurface(px, py, oy);
      
      ctx.beginPath();
      ctx.arc(px, py, CFG.perfRadius, 0, Math.PI * 2);
      ctx.fillStyle = isContact ? 'rgba(0,229,255,0.7)' : 'rgba(30,45,85,0.5)';
      ctx.fill();
    }
  }
  
  // 顶部履带面的微孔
  for (let i = 0; i < trackTopPath.length - 1; i++) {
    const p1 = trackTopPath[i];
    const p2 = trackTopPath[i + 1];
    const dx = p2.x - p1.x;
    const dy = p2.y - p1.y;
    const len = Math.sqrt(dx * dx + dy * dy);
    if (len < 1) continue;
    const nx = dy / len;
    const ny = -dx / len;
    const count = Math.floor(len / CFG.perfSpacing);
    
    for (let j = 0; j < count; j++) {
      const t2 = ((j * CFG.perfSpacing + perfOffset) % len) / len;
      const px = p1.x + dx * t2 + nx * tt * 0.35;
      const py = p1.y + dy * t2 + ny * tt * 0.35;
      
      ctx.beginPath();
      ctx.arc(px, py, CFG.perfRadius, 0, Math.PI * 2);
      ctx.fillStyle = 'rgba(30,45,85,0.4)';
      ctx.fill();
    }
  }
  
  // ---- 吸附区光晕 ----
  drawSuctionGlow(s1x, s1y, s2x, s2y, oy);
  
  // ---- 阀门指示 ----
  drawValves(s1x, s1y, s2x, s2y, oy, perfOffset);
  
  ctx.restore();
}

// 判断点是否在楼梯表面附近
function isNearStairSurface(px, py, oy) {
  const cx = W * 0.38;
  const by = H * 0.78;
  const margin = 8;
  
  for (let i = 0; i < CFG.stairCount; i++) {
    const sx = cx + i * CFG.stairTread;
    const sy = by - i * CFG.stairRiser - oy;
    // 踏面
    if (px >= sx - margin && px <= sx + CFG.stairTread + margin && Math.abs(py - sy) < margin + 4) return true;
    // 立面
    if (i < CFG.stairCount - 1) {
      const rx = sx + CFG.stairTread;
      if (Math.abs(px - rx) < margin + 4 && py >= sy - CFG.stairRiser - margin && py <= sy + margin) return true;
    }
  }
  return false;
}

// 绘制吸附区发光效果
function drawSuctionGlow(s1x, s1y, s2x, s2y, oy) {
  const intensity = pressure / 10;
  const pulse = 0.7 + 0.3 * Math.sin(time * 3);
  const alpha = intensity * pulse;
  
  // 下踏面吸附
  const g1 = ctx.createLinearGradient(s1x - 30, s1y, s1x - 30, s1y - 25);
  g1.addColorStop(0, `rgba(0,229,255,${0.35 * alpha})`);
  g1.addColorStop(1, 'rgba(0,229,255,0)');
  ctx.fillStyle = g1;
  ctx.fillRect(s1x - 30, s1y - 25, (s2x - s1x + 30), 25);
  
  // 立面吸附
  const g2 = ctx.createLinearGradient(s2x, s1y, s2x + 25, s1y);
  g2.addColorStop(0, `rgba(0,229,255,${0.35 * alpha})`);
  g2.addColorStop(1, 'rgba(0,229,255,0)');
  ctx.fillStyle = g2;
  ctx.fillRect(s2x, s2y, 25, s1y - s2y);
  
  // 上踏面吸附
  const g3 = ctx.createLinearGradient(s2x, s2y, s2x, s2y - 25);
  g3.addColorStop(0, `rgba(0,229,255,${0.35 * alpha})`);
  g3.addColorStop(1, 'rgba(0,229,255,0)');
  ctx.fillStyle = g3;
  ctx.fillRect(s2x, s2y - 25, CFG.stairTread + 30, 25);
  
  // 接触面发光线
  ctx.strokeStyle = `rgba(0,229,255,${0.6 * alpha})`;
  ctx.lineWidth = 2;
  ctx.shadowColor = '#00e5ff';
  ctx.shadowBlur = 12 * alpha;
  
  // 踏面接触线
  ctx.beginPath();
  ctx.moveTo(s1x - 25, s1y);
  ctx.lineTo(s2x, s1y);
  ctx.stroke();
  
  // 立面接触线
  ctx.beginPath();
  ctx.moveTo(s2x, s1y);
  ctx.lineTo(s2x, s2y);
  ctx.stroke();
  
  // 上踏面接触线
  ctx.beginPath();
  ctx.moveTo(s2x, s2y);
  ctx.lineTo(s2x + CFG.stairTread + 25, s2y);
  ctx.stroke();
  
  ctx.shadowBlur = 0;
  
  // 吸附力箭头(指向楼梯面)
  drawAdhesionArrows(s1x, s1y, s2x, s2y, alpha);
}

// 绘制吸附力箭头
function drawAdhesionArrows(s1x, s1y, s2x, s2y, alpha) {
  ctx.strokeStyle = `rgba(0,229,255,${0.5 * alpha})`;
  ctx.fillStyle = `rgba(0,229,255,${0.5 * alpha})`;
  ctx.lineWidth = 1.5;
  
  const arrowLen = 18 + 6 * Math.sin(time * 4);
  
  // 下踏面向下的力
  for (let i = 0; i < 3; i++) {
    const ax = s1x + (s2x - s1x) * (0.2 + i * 0.3);
    const ay = s1y - 30;
    drawArrow(ax, ay, ax, ay + arrowLen, 4);
  }
  
  // 立面向右的力
  for (let i = 0; i < 2; i++) {
    const ax = s2x - 30;
    const ay = s1y - (s1y - s2y) * (0.3 + i * 0.4);
    drawArrow(ax, ay, ax + arrowLen, ay, 4);
  }
  
  // 上踏面向下的力
  for (let i = 0; i < 3; i++) {
    const ax = s2x + (CFG.stairTread + 20) * (0.15 + i * 0.3);
    const ay = s2y - 30;
    drawArrow(ax, ay, ax, ay + arrowLen, 4);
  }
}

function drawArrow(x1, y1, x2, y2, headLen) {
  const angle = Math.atan2(y2 - y1, x2 - x1);
  ctx.beginPath();
  ctx.moveTo(x1, y1);
  ctx.lineTo(x2, y2);
  ctx.lineTo(x2 - headLen * Math.cos(angle - 0.4), y2 - headLen * Math.sin(angle - 0.4));
  ctx.moveTo(x2, y2);
  ctx.lineTo(x2 - headLen * Math.cos(angle + 0.4), y2 - headLen * Math.sin(angle + 0.4));
  ctx.stroke();
}

// 绘制阀门
function drawValves(s1x, s1y, s2x, s2y, oy, perfOffset) {
  const cx = W * 0.38;
  const by = H * 0.78;
  
  // 在接触面区域画开启阀门,非接触区域画关闭阀门
  const valvePositions = [
    // 下踏面阀门(开启)
    { x: s1x + 30, y: s1y - CFG.trackThick * 0.7, open: true },
    { x: s1x + 80, y: s1y - CFG.trackThick * 0.7, open: true },
    { x: s2x - 20, y: s1y - CFG.trackThick * 0.7, open: true },
    // 立面阀门(开启)
    { x: s2x + CFG.trackThick * 0.7, y: s1y - 30, open: true },
    { x: s2x + CFG.trackThick * 0.7, y: s2y + 30, open: true },
    // 上踏面阀门(开启)
    { x: s2x + 40, y: s2y - CFG.trackThick * 0.7, open: true },
    { x: s2x + 100, y: s2y - CFG.trackThick * 0.7, open: true },
    // 顶部阀门(关闭)
    { x: (s1x + s2x) / 2, y: s2y - 60, open: false },
    { x: (s1x + s2x) / 2 + 40, y: s2y - 60, open: false },
  ];
  
  valvePositions.forEach(v => {
    ctx.fillStyle = v.open ? CFG.colors.valveOpen : CFG.colors.valveClosed;
    ctx.globalAlpha = v.open ? (0.6 + 0.3 * Math.sin(time * 5)) : 0.3;
    
    if (v.open) {
      ctx.shadowColor = CFG.colors.valveOpen;
      ctx.shadowBlur = 6;
    }
    
    roundRect(ctx, v.x - CFG.valveW / 2, v.y - CFG.valveH / 2, CFG.valveW, CFG.valveH, 1.5);
    ctx.fill();
    
    ctx.shadowBlur = 0;
    ctx.globalAlpha = 1;
  });
}

// 绘制灰尘粒子
function drawParticles() {
  const info = getRobotOnStairs();
  const cx = W * 0.38;
  const by = H * 0.78;
  const oy = scrollOffset;
  const stepI = info.stepIdx;
  const s1x = cx + stepI * CFG.stairTread;
  const s1y = by - stepI * CFG.stairRiser - oy;
  const s2x = cx + (stepI + 1) * CFG.stairTread;
  const s2y = by - (stepI + 1) * CFG.stairRiser - oy;
  
  const intensity = pressure / 10;
  
  particles.forEach((p, idx) => {
    if (!p.active) {
      // 在接触面附近生成新粒子
      if (Math.random() < 0.06 * intensity * speed) {
        const zone = Math.random();
        let sx, sy, tx, ty;
        if (zone < 0.4) {
          // 下踏面
          sx = s1x + Math.random() * (s2x - s1x);
          sy = s1y + 2;
          tx = info.cx - 30 + Math.random() * 20;
          ty = info.cy - CFG.bodyH * 0.5;
        } else if (zone < 0.7) {
          // 立面
          sx = s2x + 2;
          sy = s2y + Math.random() * (s1y - s2y);
          tx = info.cx + 10 + Math.random() * 20;
          ty = info.cy - CFG.bodyH * 0.4;
        } else {
          // 上踏面
          sx = s2x + Math.random() * CFG.stairTread;
          sy = s2y + 2;
          tx = info.cx + 20 + Math.random() * 20;
          ty = s2y - CFG.bodyH * 0.6;
        }
        spawnDustParticle(p, sx, sy, tx, ty);
      }
      return;
    }
    
    p.life++;
    if (p.life >= p.maxLife) {
      p.active = false;
      return;
    }
    
    const t2 = p.life / p.maxLife;
    const ease = t2 * t2 * (3 - 2 * t2); // smoothstep
    
    p.x += p.vx * speed;
    p.y += p.vy * speed;
    
    // 添加一些随机扰动
    p.x += Math.sin(time * 8 + p.phase) * 0.3;
    p.y += Math.cos(time * 6 + p.phase) * 0.2;
    
    const alpha = t2 < 0.1 ? t2 / 0.1 : (t2 > 0.8 ? (1 - t2) / 0.2 : 1);
    const size = p.size * (1 - t2 * 0.5);
    
    ctx.beginPath();
    ctx.arc(p.x, p.y, Math.max(0.5, size), 0, Math.PI * 2);
    ctx.fillStyle = `rgba(255,123,58,${0.8 * alpha})`;
    ctx.fill();
    
    // 粒子尾迹
    if (alpha > 0.3) {
      ctx.beginPath();
      ctx.arc(p.x - p.vx * 2, p.y - p.vy * 2, Math.max(0.3, size * 0.5), 0, Math.PI * 2);
      ctx.fillStyle = `rgba(255,123,58,${0.3 * alpha})`;
      ctx.fill();
    }
  });
}

// 绘制气流线
function drawAirflow() {
  const info = getRobotOnStairs();
  const cx = W * 0.38;
  const by = H * 0.78;
  const oy = scrollOffset;
  const stepI = info.stepIdx;
  const s1x = cx + stepI * CFG.stairTread;
  const s1y = by - stepI * CFG.stairRiser - oy;
  const s2x = cx + (stepI + 1) * CFG.stairTread;
  const s2y = by - (stepI + 1) * CFG.stairRiser - oy;
  
  const intensity = pressure / 10;
  
  ctx.strokeStyle = `rgba(0,229,255,${0.12 * intensity})`;
  ctx.lineWidth = 1.5;
  ctx.setLineDash([6, 8]);
  ctx.lineDashOffset = -time * 50 * speed;
  
  // 从接触面向腔体内部的气流
  const flowLines = [
    // 下踏面 → 腔体
    { from: { x: s1x + 40, y: s1y - 5 }, to: { x: info.cx - 40, y: s1y - CFG.trackThick - 10 }, cp: { x: s1x + 20, y: s1y - CFG.trackThick } },
    { from: { x: s1x + 90, y: s1y - 5 }, to: { x: info.cx - 20, y: s1y - CFG.trackThick - 15 }, cp: { x: s1x + 70, y: s1y - CFG.trackThick - 5 } },
    // 立面 → 腔体
    { from: { x: s2x + 5, y: (s1y + s2y) / 2 }, to: { x: info.cx, y: (s1y + s2y) / 2 - CFG.trackThick }, cp: { x: s2x + CFG.trackThick, y: (s1y + s2y) / 2 - 10 } },
    // 上踏面 → 腔体
    { from: { x: s2x + 50, y: s2y - 5 }, to: { x: info.cx + 20, y: s2y - CFG.trackThick - 15 }, cp: { x: s2x + 40, y: s2y - CFG.trackThick - 5 } },
  ];
  
  flowLines.forEach(fl => {
    ctx.beginPath();
    ctx.moveTo(fl.from.x, fl.from.y);
    ctx.quadraticCurveTo(fl.cp.x, fl.cp.y, fl.to.x, fl.to.y);
    ctx.stroke();
  });
  
  ctx.setLineDash([]);
}

// 绘制标注
function drawLabels() {
  const info = getRobotOnStairs();
  const cx = W * 0.38;
  const by = H * 0.78;
  const oy = scrollOffset;
  const stepI = info.stepIdx;
  const s1x = cx + stepI * CFG.stairTread;
  const s1y = by - stepI * CFG.stairRiser - oy;
  const s2x = cx + (stepI + 1) * CFG.stairTread;
  const s2y = by - (stepI + 1) * CFG.stairRiser - oy;
  
  ctx.font = '500 12px "Noto Sans SC"';
  ctx.textAlign = 'left';
  
  const labels = [
    { text: '微孔 Ø1.5mm', x: s1x - 100, y: s1y - 8, lx: s1x - 20, ly: s1y - 4 },
    { text: `腔体负压 -${pressure.toFixed(1)}kPa`, x: info.cx - 70, y: s1y - CFG.trackThick - 30, lx: info.cx, ly: s1y - CFG.trackThick - 10 },
    { text: '分区阀门(开)', x: s2x + CFG.trackThick + 10, y: (s1y + s2y) / 2 + 4, lx: s2x + CFG.trackThick, ly: (s1y + s2y) / 2 },
    { text: '柔性硅胶履带', x: s1x - 110, y: s1y + 25, lx: s1x - 15, ly: s1y + 5 },
  ];
  
  labels.forEach(l => {
    // 引线
    ctx.strokeStyle = 'rgba(128,144,184,0.3)';
    ctx.lineWidth = 0.8;
    ctx.beginPath();
    ctx.moveTo(l.lx, l.ly);
    ctx.lineTo(l.x + (l.lx > l.x ? 5 : -5), l.y - 4);
    ctx.stroke();
    
    // 小圆点
    ctx.fillStyle = 'rgba(0,229,255,0.5)';
    ctx.beginPath();
    ctx.arc(l.lx, l.ly, 2.5, 0, Math.PI * 2);
    ctx.fill();
    
    // 文字
    ctx.fillStyle = '#8090b8';
    ctx.fillText(l.text, l.x, l.y);
  });
}

// 绘制 IFR 原理图
function drawIFRPanel() {
  if (!showIFR) return;
  
  const px = W - 340;
  const py = 70;
  const pw = 310;
  const ph = 200;
  
  // 面板背景
  ctx.fillStyle = CFG.colors.ifrBg;
  roundRect(ctx, px, py, pw, ph, 10);
  ctx.fill();
  ctx.strokeStyle = CFG.colors.ifrBorder;
  ctx.lineWidth = 1;
  roundRect(ctx, px, py, pw, ph, 10);
  ctx.stroke();
  
  // 标题
  ctx.fillStyle = '#00e5ff';
  ctx.font = '700 14px "Rajdhani","Noto Sans SC"';
  ctx.textAlign = 'center';
  ctx.fillText('IFR · 最终理想解', px + pw / 2, py + 24);
  
  // 中心:负压场
  const ccx = px + pw / 2;
  const ccy = py + 100;
  const pulseR = 28 + 4 * Math.sin(time * 3);
  
  // 发光圈
  const g = ctx.createRadialGradient(ccx, ccy, 0, ccx, ccy, pulseR + 15);
  g.addColorStop(0, 'rgba(0,229,255,0.3)');
  g.addColorStop(0.6, 'rgba(0,229,255,0.08)');
  g.addColorStop(1, 'rgba(0,229,255,0)');
  ctx.fillStyle = g;
  ctx.beginPath();
  ctx.arc(ccx, ccy, pulseR + 15, 0, Math.PI * 2);
  ctx.fill();
  
  // 内圈
  ctx.strokeStyle = '#00e5ff';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.arc(ccx, ccy, pulseR, 0, Math.PI * 2);
  ctx.stroke();
  
  ctx.fillStyle = '#00e5ff';
  ctx.font = '700 13px "Noto Sans SC"';
  ctx.fillText('负压场', ccx, ccy + 5);
  
  // 左侧:吸附固定
  const lx = px + 55;
  const ly = ccy;
  
  ctx.fillStyle = '#00e5ff';
  ctx.font = '600 12px "Noto Sans SC"';
  ctx.textAlign = 'center';
  ctx.fillText('吸附固定', lx, ly - 14);
  ctx.fillStyle = '#5a7a9a';
  ctx.font = '300 10px "Noto Sans SC"';
  ctx.fillText('攀爬摩擦力', lx, ly + 2);
  ctx.fillText('防跌落力', lx, ly + 16);
  
  // 左箭头
  ctx.strokeStyle = 'rgba(0,229,255,0.5)';
  ctx.lineWidth = 1.5;
  drawArrow(lx + 42, ly, ccx - pulseR - 8, ly, 6);
  
  // 右侧:灰尘抽吸
  const rx = px + pw - 55;
  const ry = ccy;
  
  ctx.fillStyle = '#ff7b3a';
  ctx.font = '600 12px "Noto Sans SC"';
  ctx.textAlign = 'center';
  ctx.fillText('灰尘抽吸', rx, ry - 14);
  ctx.fillStyle = '#7a6a5a';
  ctx.font = '300 10px "Noto Sans SC"';
  ctx.fillText('表面清洁', rx, ry + 2);
  ctx.fillText('灰尘收集', rx, ry + 16);
  
  // 右箭头
  ctx.strokeStyle = 'rgba(255,123,58,0.5)';
  ctx.lineWidth = 1.5;
  drawArrow(ccx + pulseR + 8, ry, rx - 42, ry, 6);
  
  // 底部说明
  ctx.fillStyle = '#4a5580';
  ctx.font = '300 10px "Noto Sans SC"';
  ctx.textAlign = 'center';
  ctx.fillText('同一负压场 → 同时完成两项功能,零额外执行机构', px + pw / 2, py + ph - 14);
}

// 绘制微孔详图
function drawDetailPanel() {
  if (!showDetail) return;
  
  const px = W - 340;
  const py = 290;
  const pw = 310;
  const ph = 260;
  
  // 面板背景
  ctx.fillStyle = CFG.colors.ifrBg;
  roundRect(ctx, px, py, pw, ph, 10);
  ctx.fill();
  ctx.strokeStyle = CFG.colors.ifrBorder;
  ctx.lineWidth = 1;
  roundRect(ctx, px, py, pw, ph, 10);
  ctx.stroke();
  
  // 标题
  ctx.fillStyle = '#00e5ff';
  ctx.font = '700 14px "Rajdhani","Noto Sans SC"';
  ctx.textAlign = 'center';
  ctx.fillText('微孔截面详图', px + pw / 2, py + 24);
  
  // 截面图
  const sx = px + 30;
  const sy = py + 45;
  const sw = pw - 60;
  const sh = 180;
  
  // 外层柔性硅胶
  const siliconeY = sy;
  const siliconeH = 35;
  ctx.fillStyle = '#1e2a48';
  roundRect(ctx, sx, siliconeY, sw, siliconeH, 4);
  ctx.fill();
  ctx.strokeStyle = '#2e3e68';
  ctx.lineWidth = 1;
  roundRect(ctx, sx, siliconeY, sw, siliconeH, 4);
  ctx.stroke();
  
  // 硅胶标签
  ctx.fillStyle = '#5a6a90';
  ctx.font = '400 10px "Noto Sans SC"';
  ctx.textAlign = 'left';
  ctx.fillText('柔性硅胶外层', sx + sw + 8, siliconeY + siliconeH / 2 + 4);
  
  // 微孔(3个放大的孔)
  const holeR = 8;
  const holeY = siliconeY + siliconeH / 2;
  const holeSpacing = sw / 4;
  
  for (let i = 0; i < 3; i++) {
    const hx = sx + holeSpacing * (i + 0.5);
    
    // 孔洞
    ctx.beginPath();
    ctx.arc(hx, holeY, holeR, 0, Math.PI * 2);
    ctx.fillStyle = '#060b18';
    ctx.fill();
    ctx.strokeStyle = '#2e4070';
    ctx.lineWidth = 0.8;
    ctx.stroke();
    
    // 气流箭头(向上进入腔体)
    const isContact = i < 2; // 前两个孔在接触面
    if (isContact) {
      const flowAlpha = 0.4 + 0.3 * Math.sin(time * 4 + i);
      ctx.strokeStyle = `rgba(0,229,255,${flowAlpha})`;
      ctx.lineWidth = 1.5;
      
      // 向上的气流
      const arrowY1 = holeY + holeR + 15;
      const arrowY2 = holeY - holeR - 5;
      ctx.beginPath();
      ctx.moveTo(hx, arrowY1);
      ctx.lineTo(hx, arrowY2);
      ctx.stroke();
      drawArrow(hx, arrowY1, hx, arrowY2, 4);
      
      // 灰尘粒子进入
      const dustAlpha = 0.5 + 0.3 * Math.sin(time * 6 + i * 2);
      ctx.fillStyle = `rgba(255,123,58,${dustAlpha})`;
      for (let d = 0; d < 3; d++) {
        const dx = hx + (Math.sin(time * 3 + d * 2.1 + i) * 6);
        const dy = holeY + holeR + 5 + d * 6 + (time * 20 + i * 30) % 20;
        if (dy < holeY + holeR + 25) {
          ctx.beginPath();
          ctx.arc(dx, dy, 2, 0, Math.PI * 2);
          ctx.fill();
        }
      }
      
      // 阀门开启指示
      ctx.fillStyle = CFG.colors.valveOpen;
      ctx.globalAlpha = 0.5 + 0.3 * Math.sin(time * 5);
      roundRect(ctx, hx - 3, holeY - holeR - 3, 6, 4, 1);
      ctx.fill();
      ctx.globalAlpha = 1;
    } else {
      // 阀门关闭指示
      ctx.fillStyle = CFG.colors.valveClosed;
      ctx.globalAlpha = 0.4;
      roundRect(ctx, hx - 3, holeY - holeR - 3, 6, 4, 1);
      ctx.fill();
      ctx.globalAlpha = 1;
    }
  }
  
  // 中空腔体
  const cavityY = siliconeY + siliconeH + 2;
  const cavityH = 40;
  ctx.fillStyle = '#080e1e';
  roundRect(ctx, sx, cavityY, sw, cavityH, 4);
  ctx.fill();
  ctx.strokeStyle = '#1e2d55';
  ctx.lineWidth = 0.8;
  roundRect(ctx, sx, cavityY, sw, cavityH, 4);
  ctx.stroke();
  
  // 腔体标签
  ctx.fillStyle = '#5a6a90';
  ctx.font = '400 10px "Noto Sans SC"';
  ctx.textAlign = 'left';
  ctx.fillText('中空腔体(连通电机)', sx + sw + 8, cavityY + cavityH / 2 + 4);
  
  // 腔体内气流
  ctx.strokeStyle = `rgba(0,229,255,${0.15 + 0.1 * Math.sin(time * 3)})`;
  ctx.lineWidth = 1;
  ctx.setLineDash([4, 6]);
  ctx.lineDashOffset = -time * 40;
  ctx.beginPath();
  ctx.moveTo(sx + 20, cavityY + cavityH / 2);
  ctx.lineTo(sx + sw - 20, cavityY + cavityH / 2);
  ctx.stroke();
  ctx.setLineDash([]);
  
  // 气流方向箭头
  ctx.fillStyle = `rgba(0,229,255,${0.3 + 0.15 * Math.sin(time * 3)})`;
  ctx.font = '16px "Rajdhani"';
  ctx.textAlign = 'center';
  ctx.fillText('→  →  →', sx + sw / 2, cavityY + cavityH / 2 + 5);
  
  // 阀门分区标注
  ctx.fillStyle = '#4a5580';
  ctx.font = '300 9px "Noto Sans SC"';
  ctx.textAlign = 'center';
  ctx.fillText('阀门开', sx + holeSpacing * 0.5, siliconeY - 6);
  ctx.fillText('阀门开', sx + holeSpacing * 1.5, siliconeY - 6);
  ctx.fillStyle = '#6a4050';
  ctx.fillText('阀门关', sx + holeSpacing * 2.5, siliconeY - 6);
  
  // 尺寸标注
  ctx.strokeStyle = 'rgba(128,144,184,0.3)';
  ctx.lineWidth = 0.5;
  // 微孔孔径标注
  const标注X = sx + holeSpacing * 0.5;
  ctx.beginPath();
  ctx.moveTo(标注X - holeR, holeY + holeR + 2);
  ctx.lineTo(标注X - holeR, holeY + holeR + 8);
  ctx.moveTo(标注X + holeR, holeY + holeR + 2);
  ctx.lineTo(标注X + holeR, holeY + holeR + 8);
  ctx.moveTo(标注X - holeR, holeY + holeR + 5);
  ctx.lineTo(标注X + holeR, holeY + holeR + 5);
  ctx.stroke();
  
  ctx.fillStyle = '#5a6a90';
  ctx.font = '300 8px "Rajdhani"';
  ctx.textAlign = 'center';
  ctx.fillText('Ø1.5mm', 标注X, holeY + holeR + 15);
}

// 绘制参数面板
function drawParamPanel() {
  const px = W - 340;
  const py = 570;
  const pw = 310;
  const ph = 140;
  
  ctx.fillStyle = CFG.colors.ifrBg;
  roundRect(ctx, px, py, pw, ph, 10);
  ctx.fill();
  ctx.strokeStyle = CFG.colors.ifrBorder;
  ctx.lineWidth = 1;
  roundRect(ctx, px, py, pw, ph, 10);
  ctx.stroke();
  
  ctx.fillStyle = '#00e5ff';
  ctx.font = '700 14px "Rajdhani","Noto Sans SC"';
  ctx.textAlign = 'center';
  ctx.fillText('核心参数 · 适用边界', px + pw / 2, py + 24);
  
  ctx.textAlign = 'left';
  ctx.font = '400 11px "Noto Sans SC"';
  
  const params = [
    { label: '微孔孔径', value: '1.5 mm', color: '#00e5ff' },
    { label: '腔体负压', value: `-${pressure.toFixed(1)} kPa`, color: '#00e5ff' },
    { label: '履带材质', value: '柔性硅胶', color: '#8090b8' },
    { label: '失效条件', value: '镂空格栅 / 长毛地毯', color: '#ff2d55' },
  ];
  
  params.forEach((p, i) => {
    const ty = py + 48 + i * 24;
    ctx.fillStyle = '#4a5580';
    ctx.fillText(p.label, px + 20, ty);
    ctx.fillStyle = p.color;
    ctx.textAlign = 'right';
    ctx.fillText(p.value, px + pw - 20, ty);
    ctx.textAlign = 'left';
  });
}

/* ============ 工具函数 ============ */
function roundRect(ctx, x, y, w, h, r) {
  r = Math.min(r, w / 2, h / 2);
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.lineTo(x + w - r, y);
  ctx.arcTo(x + w, y, x + w, y + r, r);
  ctx.lineTo(x + w, y + h - r);
  ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
  ctx.lineTo(x + r, y + h);
  ctx.arcTo(x, y + h, x, y + h - r, r);
  ctx.lineTo(x, y + r);
  ctx.arcTo(x, y, x + r, y, r);
  ctx.closePath();
}

/* ============ 主动画循环 ============ */
let lastTime = 0;

function loop(timestamp) {
  const dt = Math.min((timestamp - lastTime) / 1000, 0.05);
  lastTime = timestamp;
  
  time += dt * speed;
  
  // 楼梯滚动(模拟攀爬)
  scrollOffset = (time * 18) % (CFG.stairRiser + CFG.stairTread);
  
  // 清空画布
  ctx.clearRect(0, 0, W, H);
  
  // 绘制各层
  drawBackground();
  drawStairs();
  drawAirflow();
  drawRobot();
  drawParticles();
  drawLabels();
  drawIFRPanel();
  drawDetailPanel();
  drawParamPanel();
  
  // 左下角攀爬进度指示
  drawClimbIndicator();
  
  requestAnimationFrame(loop);
}

function drawClimbIndicator() {
  const ix = 40;
  const iy = H - 80;
  
  ctx.fillStyle = 'rgba(10,16,32,0.85)';
  roundRect(ctx, ix, iy, 160, 50, 8);
  ctx.fill();
  ctx.strokeStyle = '#1e2d55';
  ctx.lineWidth = 1;
  roundRect(ctx, ix, iy, 160, 50, 8);
  ctx.stroke();
  
  ctx.fillStyle = '#5a6a90';
  ctx.font = '400 10px "Noto Sans SC"';
  ctx.textAlign = 'left';
  ctx.fillText('攀爬状态', ix + 12, iy + 18);
  
  // 进度条
  const progress = (scrollOffset / (CFG.stairRiser + CFG.stairTread));
  const barW = 136;
  const barH = 6;
  const barX = ix + 12;
  const barY = iy + 28;
  
  ctx.fillStyle = '#1a2540';
  roundRect(ctx, barX, barY, barW, barH, 3);
  ctx.fill();
  
  ctx.fillStyle = '#00e5ff';
  roundRect(ctx, barX, barY, barW * progress, barH, 3);
  ctx.fill();
  
  ctx.fillStyle = '#8090b8';
  ctx.font = '600 10px "Rajdhani"';
  ctx.textAlign = 'right';
  ctx.fillText(`${Math.floor(progress * 100)}%`, ix + 148, iy + 18);
}

/* ============ 启动 ============ */
window.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>

这是一个完整的负压吸附履带攀爬机器人 IFR 原理动画实现,以下是关键设计要点:

视觉设计

  • 采用深色"全息蓝图"风格,以深海军蓝为底色,青色(#00e5ff)作为负压吸附的主视觉色,橙色(#ff7b3a)标识灰尘颗粒,绿色/红色区分阀门开关状态
  • 背景带有半透明网格和径向氛围光,营造技术感

核心动画

  1. 楼梯滚动 — 楼梯持续向下平移,机器人相对静止,形成攀爬的视觉错觉
  2. 微孔滚动 — 履带表面微孔沿履带方向移动,模拟履带旋转
  3. 吸附区脉冲 — 接触面处的青色发光区和力箭头呈呼吸式脉动,强度随负压滑块联动
  4. 灰尘粒子流 — 粒子从楼梯表面生成,沿曲线流入履带腔体再进入集尘盒
  5. 阀门状态 — 接触面阀门绿色闪烁(开启),非接触面阀门红色(关闭)
  6. 电机风扇旋转 — 内部吸尘电机叶片持续转动

IFR 原理突出

  • 右上角 IFR 面板以"同一负压场"为中心,向左右分别指向"吸附固定"和"灰尘抽吸"两项功能,用箭头和色彩区分,直观展示"一源双效"
  • 右侧微孔截面详图放大展示分区阀门开闭逻辑与气流路径
  • 参数面板列出关键数值与失效边界

交互控制

  • 负压强度滑块:实时调节吸附区发光强度和粒子生成密度
  • 动画速度滑块:控制整体播放速度
  • IFR 原理 / 微孔详图按钮:切换右侧面板显示
积分规则:第一轮对话扣减8分,后续每轮扣6分