分享图
A
动画渲染工坊
就绪
<!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=Syne:wght@400;600;700;800&family=JetBrains+Mono:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
  --bg:#06090f;--surface:#0d1420;--border:#1a2a40;
  --text:#c0c8d8;--muted:#4a5a70;
  --accent:#00e5a0;--accent-dim:rgba(0,229,160,0.15);
  --thrust:#ff6b35;--thrust-dim:rgba(255,107,53,0.15);
  --force:#ffe14d;--motion:#4a9eff;
  --body-fill:#0f1a2d;--body-stroke:#1a3050;
}
body{
  background:var(--bg);color:var(--text);
  font-family:'JetBrains Mono',monospace;
  overflow:hidden;height:100vh;width:100vw;
  display:flex;flex-direction:column;align-items:center;justify-content:center;
}
body::before{
  content:'';position:fixed;inset:0;
  background:radial-gradient(ellipse at 50% 30%,#0a1525 0%,var(--bg) 70%);
  z-index:0;
}
body::after{
  content:'';position:fixed;inset:0;opacity:0.025;pointer-events:none;z-index:999;
  background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  background-size:128px 128px;
}
.main-wrap{
  position:relative;z-index:1;width:96vw;max-width:1400px;
  display:flex;flex-direction:column;align-items:center;gap:12px;
}
.title-bar{
  display:flex;align-items:center;justify-content:space-between;width:100%;
  padding:8px 0;
}
.title-bar h1{
  font-family:'Syne',sans-serif;font-weight:800;font-size:1.3rem;
  letter-spacing:-0.02em;color:var(--text);
}
.ifr-badge{
  display:inline-flex;align-items:center;gap:6px;
  background:var(--accent-dim);border:1px solid rgba(0,229,160,0.3);
  border-radius:20px;padding:4px 14px;font-size:0.7rem;
  color:var(--accent);font-weight:600;letter-spacing:0.04em;
}
.ifr-badge .dot{width:6px;height:6px;border-radius:50%;background:var(--accent);
  animation:pulse-dot 1.5s ease-in-out infinite;}
@keyframes pulse-dot{0%,100%{opacity:1;transform:scale(1)}50%{opacity:0.4;transform:scale(0.7)}}
.svg-container{
  position:relative;width:100%;
  aspect-ratio:2.1/1;
  background:var(--surface);
  border:1px solid var(--border);
  border-radius:16px;overflow:hidden;
  box-shadow:0 0 60px rgba(0,229,160,0.04),0 4px 30px rgba(0,0,0,0.5);
}
.svg-container svg{width:100%;height:100%;display:block}
.detail-panel{
  position:absolute;bottom:12px;left:12px;
  width:280px;background:rgba(10,15,25,0.92);
  border:1px solid var(--border);border-radius:12px;
  padding:12px;backdrop-filter:blur(12px);
  font-size:0.65rem;
}
.detail-panel .dp-title{
  font-family:'Syne',sans-serif;font-weight:700;font-size:0.75rem;
  color:var(--thrust);margin-bottom:8px;letter-spacing:0.02em;
}
.detail-panel svg{width:100%;height:130px;border-radius:8px;background:rgba(0,0,0,0.3)}
.param-panel{
  position:absolute;top:12px;right:12px;
  background:rgba(10,15,25,0.88);border:1px solid var(--border);
  border-radius:10px;padding:10px 14px;backdrop-filter:blur(10px);
  font-size:0.6rem;display:flex;flex-direction:column;gap:5px;
}
.param-row{display:flex;justify-content:space-between;gap:16px}
.param-label{color:var(--muted)}
.param-val{color:var(--accent);font-weight:600}
.ifr-panel{
  position:absolute;top:12px;left:12px;max-width:260px;
  background:rgba(10,15,25,0.88);border:1px solid var(--border);
  border-radius:10px;padding:10px 14px;backdrop-filter:blur(10px);
  font-size:0.58rem;line-height:1.55;color:var(--muted);
}
.ifr-panel strong{color:var(--accent);font-weight:600}
.ifr-panel .highlight{color:var(--thrust);font-weight:600}
.controls{
  display:flex;align-items:center;gap:16px;width:100%;
  padding:8px 16px;background:var(--surface);
  border:1px solid var(--border);border-radius:12px;
}
.ctrl-btn{
  width:36px;height:36px;border:1px solid var(--border);
  border-radius:8px;background:transparent;color:var(--text);
  cursor:pointer;display:flex;align-items:center;justify-content:center;
  font-size:1rem;transition:all 0.2s;
}
.ctrl-btn:hover{background:var(--accent-dim);border-color:var(--accent);color:var(--accent)}
.ctrl-btn.active{background:var(--accent-dim);border-color:var(--accent);color:var(--accent)}
.ctrl-group{display:flex;align-items:center;gap:8px;flex:1}
.ctrl-label{font-size:0.6rem;color:var(--muted);white-space:nowrap;min-width:48px}
.ctrl-val{font-size:0.6rem;color:var(--accent);min-width:52px;text-align:right;font-weight:500}
input[type=range]{
  -webkit-appearance:none;appearance:none;flex:1;height:4px;
  background:var(--border);border-radius:2px;outline:none;cursor:pointer;
}
input[type=range]::-webkit-slider-thumb{
  -webkit-appearance:none;width:14px;height:14px;border-radius:50%;
  background:var(--accent);border:2px solid var(--bg);cursor:pointer;
  box-shadow:0 0 8px rgba(0,229,160,0.4);
}
.legend{
  display:flex;align-items:center;gap:14px;font-size:0.58rem;color:var(--muted);
  margin-left:auto;
}
.legend-item{display:flex;align-items:center;gap:4px}
.legend-dot{width:8px;height:8px;border-radius:2px}
@media(max-width:900px){
  .detail-panel{width:200px;padding:8px}
  .ifr-panel{max-width:180px;font-size:0.52rem}
  .param-panel{font-size:0.55rem}
  .controls{flex-wrap:wrap;gap:8px}
}
</style>
</head>
<body>
<div class="main-wrap">
  <div class="title-bar">
    <h1>软体蛇形机器人 · 蜿蜒推进原理</h1>
    <div class="ifr-badge"><span class="dot"></span>IFR 理想解演示</div>
  </div>
  <div class="svg-container" id="svgContainer">
    <svg id="mainSvg" viewBox="0 0 1200 570" xmlns="http://www.w3.org/2000/svg">
      <defs>
        <filter id="glowTeal" x="-50%" y="-50%" width="200%" height="200%">
          <feGaussianBlur in="SourceGraphic" stdDeviation="4" result="b"/>
          <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
        </filter>
        <filter id="glowOrange" x="-50%" y="-50%" width="200%" height="200%">
          <feGaussianBlur in="SourceGraphic" stdDeviation="5" result="b"/>
          <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
        </filter>
        <filter id="glowYellow" x="-50%" y="-50%" width="200%" height="200%">
          <feGaussianBlur in="SourceGraphic" stdDeviation="3" result="b"/>
          <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
        </filter>
        <filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
          <feGaussianBlur in="SourceAlpha" stdDeviation="6"/>
          <feOffset dx="2" dy="4"/>
          <feComponentTransfer><feFuncA type="linear" slope="0.4"/></feComponentTransfer>
          <feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
        </filter>
        <linearGradient id="bodyGrad" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stop-color="#162840"/>
          <stop offset="100%" stop-color="#0c1622"/>
        </linearGradient>
        <linearGradient id="groundGrad" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stop-color="transparent"/>
          <stop offset="100%" stop-color="rgba(0,229,160,0.03)"/>
        </linearGradient>
        <marker id="arrowYellow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
          <polygon points="0 0, 8 3, 0 6" fill="#ffe14d"/>
        </marker>
        <marker id="arrowTeal" markerWidth="6" markerHeight="5" refX="6" refY="2.5" orient="auto">
          <polygon points="0 0, 6 2.5, 0 5" fill="#00e5a0"/>
        </marker>
        <marker id="arrowOrange" markerWidth="7" markerHeight="5" refX="7" refY="2.5" orient="auto">
          <polygon points="0 0, 7 2.5, 0 5" fill="#ff6b35"/>
        </marker>
      </defs>
      <g id="groundLayer"></g>
      <g id="snakeLayer"></g>
      <g id="forceLayer"></g>
      <g id="labelLayer"></g>
    </svg>
    <!-- IFR 说明面板 -->
    <div class="ifr-panel">
      <div style="font-family:'Syne',sans-serif;font-weight:700;color:var(--accent);margin-bottom:4px;font-size:0.68rem">最终理想解 (IFR)</div>
      矛盾:摩擦力既是阻力又是必需条件。<br>
      <strong>理想解</strong>:摩擦力本身成为<strong>唯一推进源</strong>。<br>
      关键:通过<span class="highlight">各向异性微棘刺</span>,<br>
      使摩擦力方向可控 → <strong>阻力即动力</strong>。
    </div>
    <!-- 参数面板 -->
    <div class="param-panel" id="paramPanel">
      <div class="param-row"><span class="param-label">工作气压</span><span class="param-val" id="pVal">0.20 MPa</span></div>
      <div class="param-row"><span class="param-label">棘刺倾角</span><span class="param-val">30°</span></div>
      <div class="param-row"><span class="param-label">前移速度</span><span class="param-val" id="vVal">0.0 m/s</span></div>
      <div class="param-row"><span class="param-label">波相位</span><span class="param-val" id="phVal">0.0°</span></div>
    </div>
    <!-- 横截面详图 -->
    <div class="detail-panel">
      <div class="dp-title">仿生底皮微棘刺 · 截面详图</div>
      <svg id="detailSvg" viewBox="0 0 256 130" xmlns="http://www.w3.org/2000/svg"></svg>
    </div>
  </div>
  <!-- 控制面板 -->
  <div class="controls">
    <button class="ctrl-btn active" id="btnPlay" title="播放/暂停">▶</button>
    <div class="ctrl-group">
      <span class="ctrl-label">波速</span>
      <input type="range" id="sliderSpeed" min="0.5" max="5" step="0.1" value="2.2">
      <span class="ctrl-val" id="speedVal">2.2 rad/s</span>
    </div>
    <div class="ctrl-group">
      <span class="ctrl-label">气压</span>
      <input type="range" id="sliderPressure" min="0.15" max="0.25" step="0.005" value="0.20">
      <span class="ctrl-val" id="pressVal">0.20 MPa</span>
    </div>
    <button class="ctrl-btn active" id="btnForce" title="力矢量">⇀</button>
    <button class="ctrl-btn" id="btnTrail" title="轨迹">〜</button>
    <div class="legend">
      <div class="legend-item"><div class="legend-dot" style="background:#00e5a0"></div>充气腔</div>
      <div class="legend-item"><div class="legend-dot" style="background:#ff6b35"></div>棘刺抓地</div>
      <div class="legend-item"><div class="legend-dot" style="background:#ffe14d"></div>推进力</div>
    </div>
  </div>
</div>

<script>
// ==================== 常量与状态 ====================
const SVG_NS = 'http://www.w3.org/2000/svg';
const NUM_SEG = 14;
const SEG_LEN = 30;
const BODY_W = 22;
const SPINE_LEN = 7;
const SPINE_ANGLE = 30 * Math.PI / 180;
const WAVE_K = 0.52; // 空间波数
const EPS = 1e-6;

let animTime = 0;
let lastTs = 0;
let playing = true;
let waveSpeed = 2.2;
let pressure = 0.20;
let showForces = true;
let showTrail = false;
let trailPoints = [];

// ==================== 蛇体模型 ====================
function computeSnake(t) {
  // 振幅随气压线性缩放
  const amp = 0.06 + (pressure - 0.15) * 2.8;
  const segs = [];
  let x = 0, y = 0, heading = 0;

  for (let i = 0; i < NUM_SEG; i++) {
    const phase = waveSpeed * t - WAVE_K * i;
    const curv = amp * Math.sin(phase);
    const midH = heading + curv * 0.5;
    x += SEG_LEN * Math.cos(midH);
    y += SEG_LEN * Math.sin(midH);
    heading += curv;
    // inflation: 0~1,充气程度
    const infl = (Math.sin(phase) + 1) * 0.5;
    segs.push({ x, y, heading, curv, infl, phase });
  }

  // 居中:计算质心,偏移到 viewBox 中心偏左
  let cx = 0, cy = 0;
  for (const s of segs) { cx += s.x; cy += s.y; }
  cx /= NUM_SEG; cy /= NUM_SEG;
  const ox = 480 - cx, oy = 290 - cy;
  for (const s of segs) { s.vx = s.x + ox; s.vy = s.y + oy; }

  return segs;
}

// 获取平滑中心线路径(细分)
function smoothCenterline(segs, sub = 4) {
  const pts = [];
  for (let i = 0; i < segs.length - 1; i++) {
    for (let j = 0; j < sub; j++) {
      const t = j / sub;
      pts.push({
        vx: segs[i].vx + t * (segs[i + 1].vx - segs[i].vx),
        vy: segs[i].vy + t * (segs[i + 1].vy - segs[i].vy),
        heading: segs[i].heading + t * (segs[i + 1].heading - segs[i].heading)
      });
    }
  }
  pts.push({ vx: segs[segs.length - 1].vx, vy: segs[segs.length - 1].vy, heading: segs[segs.length - 1].heading });
  return pts;
}

// 计算体侧轮廓
function bodyOutline(segs, halfW) {
  const pts = smoothCenterline(segs, 4);
  const left = [], right = [];
  for (const p of pts) {
    const nx = -Math.sin(p.heading), ny = Math.cos(p.heading);
    left.push({ x: p.vx + nx * halfW, y: p.vy + ny * halfW });
    right.push({ x: p.vx - nx * halfW, y: p.vy - ny * halfW });
  }
  return { left, right };
}

function ptsToPath(pts, close = false) {
  if (pts.length < 2) return '';
  let d = `M${pts[0].x.toFixed(1)},${pts[0].y.toFixed(1)}`;
  for (let i = 1; i < pts.length; i++) {
    d += ` L${pts[i].x.toFixed(1)},${pts[i].y.toFixed(1)}`;
  }
  if (close) d += 'Z';
  return d;
}

// ==================== 渲染:地面 ====================
function renderGround(t) {
  const speed = (0.06 + (pressure - 0.15) * 2.8) * waveSpeed * 28;
  const scroll = (t * speed) % 50;
  let svg = '';
  // 地面网格点
  for (let gy = 160; gy <= 420; gy += 50) {
    for (let gx = -50; gx <= 1250; gx += 50) {
      const px = ((gx - scroll) % 50 + 50) % 50 + Math.floor((gx - 50) / 50) * 50;
      const opacity = 0.12 + 0.06 * Math.sin(gy * 0.02);
      svg += `<circle cx="${px.toFixed(1)}" cy="${gy}" r="1.2" fill="#1a2a40" opacity="${opacity.toFixed(2)}"/>`;
    }
  }
  // 前进方向指示箭头
  const arrowY = 440;
  for (let ax = 100; ax < 1100; ax += 180) {
    const aox = ((ax - scroll * 1.5) % 180 + 180) % 180 + Math.floor((ax - 100) / 180) * 180;
    svg += `<line x1="${aox}" y1="${arrowY}" x2="${aox + 30}" y2="${arrowY}" stroke="#1e3450" stroke-width="1.5" marker-end="url(#arrowTeal)" opacity="0.3"/>`;
  }
  // 地面线
  svg += `<line x1="0" y1="350" x2="1200" y2="350" stroke="#1a2a40" stroke-width="0.5" stroke-dasharray="4,8" opacity="0.4"/>`;
  svg += `<text x="1160" y="355" fill="#2a3a50" font-size="9" text-anchor="end" font-family="'JetBrains Mono',monospace">地面</text>`;
  return svg;
}

// ==================== 渲染:蛇体 ====================
function renderSnake(segs) {
  let svg = '';
  const { left, right } = bodyOutline(segs, BODY_W / 2);
  const { left: leftOuter, right: rightOuter } = bodyOutline(segs, BODY_W / 2 + 3);

  // 阴影
  const shadowPts = [];
  for (const p of leftOuter) shadowPts.push({ x: p.x + 3, y: p.y + 6 });
  for (const p of rightOuter.reverse()) shadowPts.push({ x: p.x + 3, y: p.y + 6 });
  svg += `<path d="${ptsToPath(shadowPts, true)}" fill="rgba(0,0,0,0.25)" filter="url(#softShadow)"/>`;

  // 外轮廓
  const outlinePts = [];
  for (const p of leftOuter) outlinePts.push(p);
  for (const p of rightOuter.reverse()) outlinePts.push(p);
  svg += `<path d="${ptsToPath(outlinePts, true)}" fill="#1a2d48" stroke="#243a58" stroke-width="0.5"/>`;

  // 主体
  const bodyPts = [];
  for (const p of left) bodyPts.push(p);
  for (const p of right.reverse()) bodyPts.push(p);
  svg += `<path d="${ptsToPath(bodyPts, true)}" fill="url(#bodyGrad)" stroke="#1e3450" stroke-width="0.8"/>`;

  // 气腔
  for (let i = 0; i < segs.length; i++) {
    const s = segs[i];
    const cx = s.vx, cy = s.vy;
    const infl = s.infl;
    const rx = 10 + infl * 4;
    const ry = 7 + infl * 3;
    const deg = (s.heading * 180 / Math.PI).toFixed(1);
    // 充气发光
    if (infl > 0.3) {
      const glowOpacity = (infl - 0.3) * 1.2;
      svg += `<ellipse cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" rx="${(rx + 4).toFixed(1)}" ry="${(ry + 4).toFixed(1)}" transform="rotate(${deg},${cx.toFixed(1)},${cy.toFixed(1)})" fill="rgba(0,229,160,${(glowOpacity * 0.2).toFixed(2)})"/>`;
    }
    // 气腔本体
    const fillColor = infl > 0.3
      ? `rgba(0,229,160,${(0.15 + infl * 0.55).toFixed(2)})`
      : `rgba(22,40,64,0.7)`;
    svg += `<ellipse cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" rx="${rx.toFixed(1)}" ry="${ry.toFixed(1)}" transform="rotate(${deg},${cx.toFixed(1)},${cy.toFixed(1)})" fill="${fillColor}" stroke="${infl > 0.3 ? 'rgba(0,229,160,0.6)' : 'rgba(30,50,80,0.5)'}" stroke-width="0.8"/>`;
    // 气腔内网纹
    if (infl > 0.4) {
      const nd = `rotate(${deg},${cx.toFixed(1)},${cy.toFixed(1)})`;
      svg += `<g transform="${nd}" opacity="${(infl * 0.4).toFixed(2)}">`;
      for (let li = -2; li <= 2; li++) {
        const ly = cy + li * (ry / 3);
        svg += `<line x1="${(cx - rx * 0.7).toFixed(1)}" y1="${ly.toFixed(1)}" x2="${(cx + rx * 0.7).toFixed(1)}" y2="${ly.toFixed(1)}" stroke="rgba(0,229,160,0.3)" stroke-width="0.4"/>`;
      }
      svg += `</g>`;
    }
  }

  // 底部微棘刺
  const amp = 0.06 + (pressure - 0.15) * 2.8;
  for (let i = 0; i < segs.length; i++) {
    const s = segs[i];
    const nx = -Math.sin(s.heading), ny = Math.cos(s.heading);
    // 底侧偏移(往"下方"偏移,模拟腹面露出)
    const bellyOffX = -nx * (BODY_W / 2 - 2);
    const bellyOffY = -ny * (BODY_W / 2 - 2);
    const bx = s.vx + bellyOffX;
    const by = s.vy + bellyOffY;

    // 判断抓地状态:曲率绝对值大于阈值 → 一侧抓地
    const gripStrength = Math.abs(s.curv) / amp;
    const isGripping = gripStrength > 0.35;
    // 棘刺方向:朝尾部倾斜 30°
    const tailAngle = s.heading + Math.PI; // 朝尾方向
    const spineBaseAngle = tailAngle - SPINE_ANGLE; // 倾斜 30°

    const numSpines = 3;
    for (let si = 0; si < numSpines; si++) {
      const along = (si - 1) * 5;
      const sx = bx + Math.cos(s.heading) * along;
      const sy = by + Math.sin(s.heading) * along;
      const ex = sx + Math.cos(spineBaseAngle) * SPINE_LEN;
      const ey = sy + Math.sin(spineBaseAngle) * SPINE_LEN;

      if (isGripping) {
        // 抓地状态:亮橙色 + 发光
        const go = Math.min(1, gripStrength);
        svg += `<line x1="${sx.toFixed(1)}" y1="${sy.toFixed(1)}" x2="${ex.toFixed(1)}" y2="${ey.toFixed(1)}" stroke="rgba(255,107,53,${(0.5 + go * 0.5).toFixed(2)})" stroke-width="1.8" stroke-linecap="round" filter="url(#glowOrange)"/>`;
      } else {
        // 滑动状态:暗色
        svg += `<line x1="${sx.toFixed(1)}" y1="${sy.toFixed(1)}" x2="${ex.toFixed(1)}" y2="${ey.toFixed(1)}" stroke="rgba(42,53,69,0.7)" stroke-width="1.2" stroke-linecap="round"/>`;
      }
    }

    // 顶部棘刺(另一侧,通常滑动)
    const topBellyOffX = nx * (BODY_W / 2 - 2);
    const topBellyOffY = ny * (BODY_W / 2 - 2);
    const tbx = s.vx + topBellyOffX;
    const tby = s.vy + topBellyOffY;
    const topSpineAngle = s.heading + Math.PI + SPINE_ANGLE;
    for (let si = 0; si < numSpines; si++) {
      const along = (si - 1) * 5;
      const sx = tbx + Math.cos(s.heading) * along;
      const sy = tby + Math.sin(s.heading) * along;
      const ex = sx + Math.cos(topSpineAngle) * SPINE_LEN * 0.8;
      const ey = sy + Math.sin(topSpineAngle) * SPINE_LEN * 0.8;
      svg += `<line x1="${sx.toFixed(1)}" y1="${sy.toFixed(1)}" x2="${ex.toFixed(1)}" y2="${ey.toFixed(1)}" stroke="rgba(42,53,69,0.4)" stroke-width="0.8" stroke-linecap="round"/>`;
    }
  }

  // 头部标记
  const head = segs[segs.length - 1];
  const hnx = -Math.sin(head.heading), hny = Math.cos(head.heading);
  const tipX = head.vx + Math.cos(head.heading) * 12;
  const tipY = head.vy + Math.sin(head.heading) * 12;
  svg += `<circle cx="${tipX.toFixed(1)}" cy="${tipY.toFixed(1)}" r="4" fill="#0d1820" stroke="#00e5a0" stroke-width="1.2"/>`;
  svg += `<circle cx="${tipX.toFixed(1)}" cy="${tipY.toFixed(1)}" r="1.5" fill="#00e5a0"/>`;
  // 眼睛
  const eyeOff = 6;
  svg += `<circle cx="${(tipX + hnx * eyeOff * 0.3 + Math.cos(head.heading) * 3).toFixed(1)}" cy="${(tipY + hny * eyeOff * 0.3 + Math.sin(head.heading) * 3).toFixed(1)}" r="1.2" fill="#00e5a0" opacity="0.7"/>`;
  svg += `<circle cx="${(tipX - hnx * eyeOff * 0.3 + Math.cos(head.heading) * 3).toFixed(1)}" cy="${(tipY - hny * eyeOff * 0.3 + Math.sin(head.heading) * 3).toFixed(1)}" r="1.2" fill="#00e5a0" opacity="0.7"/>`;

  // 尾部
  const tail = segs[0];
  const tailX = tail.vx - Math.cos(tail.heading) * 10;
  const tailY = tail.vy - Math.sin(tail.heading) * 10;
  svg += `<circle cx="${tailX.toFixed(1)}" cy="${tailY.toFixed(1)}" r="3" fill="#0d1820" stroke="#1a3050" stroke-width="0.8"/>`;

  return svg;
}

// ==================== 渲染:力矢量 ====================
function renderForces(segs) {
  if (!showForces) return '';
  let svg = '';
  const amp = Math.max(EPS, 0.06 + (pressure - 0.15) * 2.8);
  for (let i = 1; i < segs.length - 1; i++) {
    const s = segs[i];
    const gripStrength = Math.abs(s.curv) / amp;
    if (gripStrength < 0.35) continue;

    const nx = -Math.sin(s.heading), ny = Math.cos(s.heading);
    // 弯曲方向决定哪侧抓地
    const side = s.curv > 0 ? 1 : -1;
    const contactX = s.vx - side * nx * (BODY_W / 2 + 2);
    const contactY = s.vy - side * ny * (BODY_W / 2 + 2);

    // 推进力箭头(向前 = heading 方向)
    const thrustLen = 18 + gripStrength * 20;
    const tx = contactX + Math.cos(s.heading) * thrustLen;
    const ty = contactY + Math.sin(s.heading) * thrustLen;
    svg += `<line x1="${contactX.toFixed(1)}" y1="${contactY.toFixed(1)}" x2="${tx.toFixed(1)}" y2="${ty.toFixed(1)}" stroke="#ffe14d" stroke-width="2" marker-end="url(#arrowYellow)" opacity="${(0.4 + gripStrength * 0.6).toFixed(2)}" filter="url(#glowYellow)"/>`;

    // 横向弯曲力(向弯曲内侧)
    const lateralLen = 12 + gripStrength * 10;
    const lx = contactX - side * nx * lateralLen;
    const ly = contactY - side * ny * lateralLen;
    svg += `<line x1="${contactX.toFixed(1)}" y1="${contactY.toFixed(1)}" x2="${lx.toFixed(1)}" y2="${ly.toFixed(1)}" stroke="#ff6b35" stroke-width="1.2" stroke-dasharray="3,2" marker-end="url(#arrowOrange)" opacity="${(0.3 + gripStrength * 0.4).toFixed(2)}"/>`;

    // 抓地标记
    svg += `<circle cx="${contactX.toFixed(1)}" cy="${contactY.toFixed(1)}" r="${(2 + gripStrength * 3).toFixed(1)}" fill="rgba(255,107,53,${(0.2 + gripStrength * 0.4).toFixed(2)})"/>`;
  }
  return svg;
}

// ==================== 渲染:轨迹 ====================
function renderTrail(segs) {
  if (!showTrail) return '';
  const head = segs[segs.length - 1];
  const tipX = head.vx + Math.cos(head.heading) * 12;
  const tipY = head.vy + Math.sin(head.heading) * 12;
  trailPoints.push({ x: tipX, y: tipY, t: animTime });
  // 保留最近 120 帧
  if (trailPoints.length > 120) trailPoints.shift();
  let svg = '';
  for (let i = 1; i < trailPoints.length; i++) {
    const alpha = i / trailPoints.length;
    svg += `<line x1="${trailPoints[i - 1].x.toFixed(1)}" y1="${trailPoints[i - 1].y.toFixed(1)}" x2="${trailPoints[i].x.toFixed(1)}" y2="${trailPoints[i].y.toFixed(1)}" stroke="rgba(74,158,255,${(alpha * 0.5).toFixed(2)})" stroke-width="${(0.5 + alpha * 1.5).toFixed(1)}"/>`;
  }
  return svg;
}

// ==================== 渲染:标注 ====================
function renderLabels(segs) {
  let svg = '';
  // 头部标签
  const head = segs[segs.length - 1];
  const hx = head.vx + Math.cos(head.heading) * 20;
  const hy = head.vy + Math.sin(head.heading) * 20 - 20;
  svg += `<text x="${hx.toFixed(1)}" y="${hy.toFixed(1)}" fill="#00e5a0" font-size="10" font-family="'Syne',sans-serif" font-weight="700" opacity="0.7">HEAD</text>`;

  // 充气腔标注(找最活跃的腔)
  let maxInfl = 0, maxIdx = -1;
  for (let i = 0; i < segs.length; i++) {
    if (segs[i].infl > maxInfl) { maxInfl = segs[i].infl; maxIdx = i; }
  }
  if (maxIdx >= 0 && maxInfl > 0.6) {
    const s = segs[maxIdx];
    const ly = s.vy - BODY_W / 2 - 18;
    svg += `<text x="${s.vx.toFixed(1)}" y="${ly.toFixed(1)}" fill="#00e5a0" font-size="8" font-family="'JetBrains Mono',monospace" text-anchor="middle" opacity="0.6">充气 ${((pressure * s.infl) / 0.25 * 100).toFixed(0)}%</text>`;
  }

  // 前进方向大箭头
  svg += `<text x="600" y="530" fill="#1e3450" font-size="11" font-family="'Syne',sans-serif" font-weight="700" text-anchor="middle" letter-spacing="4">推进方向 →</text>`;

  // 波传播方向
  svg += `<text x="600" y="548" fill="#162840" font-size="8" font-family="'JetBrains Mono',monospace" text-anchor="middle">蜿蜒波:尾 → 头传播</text>`;

  return svg;
}

// ==================== 渲染:截面详图 ====================
function renderDetail(segs) {
  // 取第 7 段(中间偏后)作为详图焦点
  const idx = Math.min(6, segs.length - 1);
  const s = segs[idx];
  const infl = s.infl;
  const gripStr = Math.abs(s.curv) / Math.max(EPS, 0.06 + (pressure - 0.15) * 2.8);
  const isGrip = gripStr > 0.35;

  let svg = '';
  const cx = 128, cy = 50;

  // 地面
  svg += `<line x1="20" y1="95" x2="236" y2="95" stroke="#1e3450" stroke-width="1.5"/>`;
  svg += `<text x="128" y="108" fill="#2a3a50" font-size="7" text-anchor="middle" font-family="'JetBrains Mono',monospace">地面</text>`;

  // 体截面(弹性体)
  const bodyW = 50, bodyH = 30 + infl * 8;
  const bodyY = cy + 10 - infl * 4;
  svg += `<rect x="${cx - bodyW / 2}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="12" fill="#0f1a2d" stroke="#1e3450" stroke-width="1"/>`;

  // 气腔(椭圆)
  const chRx = 16 + infl * 6, chRy = 10 + infl * 4;
  const chCy = bodyY + bodyH / 2;
  if (infl > 0.3) {
    svg += `<ellipse cx="${cx}" cy="${chCy}" rx="${chRx + 5}" ry="${chRy + 5}" fill="rgba(0,229,160,${(infl * 0.12).toFixed(2)})"/>`;
  }
  svg += `<ellipse cx="${cx}" cy="${chCy}" rx="${chRx}" ry="${chRy}" fill="rgba(0,229,160,${(0.08 + infl * 0.4).toFixed(2)})" stroke="${infl > 0.3 ? 'rgba(0,229,160,0.6)' : 'rgba(30,50,80,0.5)'}" stroke-width="0.8"/>`;
  // 网纹
  if (infl > 0.3) {
    for (let li = -2; li <= 2; li++) {
      const ly = chCy + li * (chRy / 3);
      svg += `<line x1="${cx - chRx * 0.7}" y1="${ly}" x2="${cx + chRx * 0.7}" y2="${ly}" stroke="rgba(0,229,160,0.2)" stroke-width="0.5"/>`;
    }
  }
  // 气压标注
  svg += `<text x="${cx}" y="${chCy + 3}" fill="${infl > 0.4 ? '#00e5a0' : '#2a3a50'}" font-size="7" text-anchor="middle" font-family="'JetBrains Mono',monospace" font-weight="600">${(pressure * infl / 0.25).toFixed(2)} MPa</text>`;

  // 微棘刺
  const spineBase = bodyY + bodyH;
  const numSp = 7;
  for (let si = 0; si < numSp; si++) {
    const sx = cx - bodyW / 2 + 8 + si * (bodyW - 16) / (numSp - 1);
    // 倾斜 30° 向左(朝尾部方向)
    const sAngle = Math.PI + SPINE_ANGLE; // 向左且倾斜
    const ex = sx + Math.cos(sAngle) * SPINE_LEN * 1.2;
    const ey = spineBase + Math.sin(-SPINE_ANGLE) * SPINE_LEN * 1.2 + 2;

    if (isGrip && si % 2 === 0) {
      svg += `<line x1="${sx.toFixed(1)}" y1="${spineBase}" x2="${ex.toFixed(1)}" y2="${ey.toFixed(1)}" stroke="#ff6b35" stroke-width="2" stroke-linecap="round" filter="url(#glowOrange)"/>`;
      // 抓地标记
      svg += `<circle cx="${ex.toFixed(1)}" cy="${(ey + 2).toFixed(1)}" r="2" fill="rgba(255,107,53,0.3)"/>`;
    } else {
      svg += `<line x1="${sx.toFixed(1)}" y1="${spineBase}" x2="${ex.toFixed(1)}" y2="${ey.toFixed(1)}" stroke="#2a3545" stroke-width="1.2" stroke-linecap="round"/>`;
    }
  }

  // 30° 角度标注
  const aStart = spineBase - 2;
  svg += `<line x1="${cx + 28}" y1="${aStart}" x2="${cx + 28}" y2="${aStart + 14}" stroke="#2a3a50" stroke-width="0.5" stroke-dasharray="2,1"/>`;
  svg += `<path d="M${cx + 28},${aStart + 14} A8,8 0 0,1 ${cx + 28 + 7},${aStart + 9}" fill="none" stroke="#ff6b35" stroke-width="0.8" opacity="0.6"/>`;
  svg += `<text x="${cx + 40}" y="${aStart + 14}" fill="#ff6b35" font-size="6" font-family="'JetBrains Mono',monospace" opacity="0.7">30°</text>`;

  // 力转换示意
  if (isGrip) {
    // 横向力 → 推进力
    const fx = cx - 5, fy = bodyY + 5;
    // 横向力(向下压)
    svg += `<line x1="${fx}" y1="${fy}" x2="${fx}" y2="${fy + 16}" stroke="#ff6b35" stroke-width="1.5" marker-end="url(#arrowOrange)" opacity="0.7"/>`;
    svg += `<text x="${fx - 14}" y="${fy + 10}" fill="#ff6b35" font-size="5.5" font-family="'JetBrains Mono',monospace" opacity="0.6">F侧</text>`;
    // 推进力(向前/右)
    svg += `<line x1="${fx + 10}" y1="${fy + 5}" x2="${fx + 30}" y2="${fy + 5}" stroke="#ffe14d" stroke-width="2" marker-end="url(#arrowYellow)" opacity="0.8" filter="url(#glowYellow)"/>`;
    svg += `<text x="${fx + 32}" y="${fy + 8}" fill="#ffe14d" font-size="5.5" font-family="'JetBrains Mono',monospace" opacity="0.7">F推</text>`;
    // 转换标记
    svg += `<text x="${fx + 5}" y="${fy + 3}" fill="#00e5a0" font-size="7" font-family="'Syne',sans-serif" font-weight="700" opacity="0.5">→</text>`;
  }

  // 抓地/滑动状态指示
  const stateText = isGrip ? '抓地' : '滑动';
  const stateColor = isGrip ? '#ff6b35' : '#2a3a50';
  svg += `<rect x="185" y="8" width="55" height="16" rx="4" fill="${isGrip ? 'rgba(255,107,53,0.15)' : 'rgba(42,53,69,0.3)'}" stroke="${stateColor}" stroke-width="0.6"/>`;
  svg += `<text x="212" y="19" fill="${stateColor}" font-size="8" text-anchor="middle" font-family="'Syne',sans-serif" font-weight="700">${stateText}</text>`;

  // 棘刺方向说明
  svg += `<text x="128" y="125" fill="#2a3a50" font-size="6" text-anchor="middle" font-family="'JetBrains Mono',monospace">← 尾部方向 | 头部方向 →</text>`;

  return svg;
}

// ==================== 主动画循环 ====================
const groundLayer = document.getElementById('groundLayer');
const snakeLayer = document.getElementById('snakeLayer');
const forceLayer = document.getElementById('forceLayer');
const labelLayer = document.getElementById('labelLayer');
const detailSvg = document.getElementById('detailSvg');

function animate(ts) {
  if (!lastTs) lastTs = ts;
  const dt = Math.min((ts - lastTs) / 1000, 0.05);
  lastTs = ts;

  if (playing) {
    animTime += dt;
  }

  const segs = computeSnake(animTime);

  // 渲染各层
  groundLayer.innerHTML = renderGround(animTime);
  snakeLayer.innerHTML = renderSnake(segs);
  forceLayer.innerHTML = renderForces(segs) + renderTrail(segs);
  labelLayer.innerHTML = renderLabels(segs);
  detailSvg.innerHTML = renderDetail(segs);

  // 更新参数面板
  const fwdSpeed = (0.06 + (pressure - 0.15) * 2.8) * waveSpeed * 0.12;
  document.getElementById('vVal').textContent = fwdSpeed.toFixed(2) + ' m/s';
  document.getElementById('phVal').textContent = ((waveSpeed * animTime * 180 / Math.PI) % 360).toFixed(0) + '°';
  document.getElementById('pVal').textContent = pressure.toFixed(2) + ' MPa';

  requestAnimationFrame(animate);
}

// ==================== 控件事件 ====================
const btnPlay = document.getElementById('btnPlay');
const btnForce = document.getElementById('btnForce');
const btnTrail = document.getElementById('btnTrail');
const sliderSpeed = document.getElementById('sliderSpeed');
const sliderPressure = document.getElementById('sliderPressure');

btnPlay.addEventListener('click', () => {
  playing = !playing;
  btnPlay.textContent = playing ? '▶' : '⏸';
  btnPlay.classList.toggle('active', playing);
});

btnForce.addEventListener('click', () => {
  showForces = !showForces;
  btnForce.classList.toggle('active', showForces);
});

btnTrail.addEventListener('click', () => {
  showTrail = !showTrail;
  btnTrail.classList.toggle('active', showTrail);
  if (!showTrail) trailPoints = [];
});

sliderSpeed.addEventListener('input', (e) => {
  waveSpeed = parseFloat(e.target.value);
  document.getElementById('speedVal').textContent = waveSpeed.toFixed(1) + ' rad/s';
});

sliderPressure.addEventListener('input', (e) => {
  pressure = parseFloat(e.target.value);
  document.getElementById('pressVal').textContent = pressure.toFixed(2) + ' MPa';
});

// 键盘控制
document.addEventListener('keydown', (e) => {
  if (e.code === 'Space') { e.preventDefault(); btnPlay.click(); }
  if (e.code === 'KeyF') btnForce.click();
  if (e.code === 'KeyT') btnTrail.click();
});

// ==================== 启动 ====================
requestAnimationFrame(animate);
</script>
</body>
</html>

实现说明:

本动画以深色科研风视觉语言,完整呈现了"气动网络软体躯干 + 仿生底皮微棘刺"方案如何将横向蜿蜒转化为向前推力,紧扣 IFR 思想——摩擦力即推进力

核心视觉机制:

  1. 蜿蜒波传递:14 段气腔按正弦行波依次充气(青绿色发光),从尾部向头部传播,形成可见的横向蜿蜒
  2. 棘刺抓地高亮:当某段弯曲时,该侧底部微棘刺自动变为橙色发光,表示"抓地";另一侧保持暗灰表示"滑动"——这是各向异性摩擦力的可视化
  3. 力矢量转换:黄色箭头(推进力 F推)从抓地点指向前方,橙色虚线箭头(横向力 F侧)指向弯曲内侧,清晰展示"横向弯曲力 → 抓地反力 → 向前推进"的转换链
  4. 截面详图(左下角):实时显示第 7 段的横截面,包括气腔膨胀/收缩、30° 微棘刺抓地/滑动状态、力转换示意

交互控制:

  • 播放/暂停(空格键):控制动画运行
  • 波速滑块:调整蜿蜒波传播速度
  • 气压滑块:调整 0.15~0.25 MPa 工作气压,直接影响弯曲幅度和推进速度
  • 力矢量开关(F 键):显示/隐藏力箭头
  • 轨迹开关(T 键):显示头部运动轨迹
积分规则:第一轮对话扣减6分,后续每轮扣4分