分享图
动画工坊
引擎就绪
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>多段铰接柔性底盘 · 阶梯自适应攀爬</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%}
body{
  background:#0c1117;
  display:flex;flex-direction:column;
  align-items:center;justify-content:center;
  font-family:-apple-system,"Segoe UI",system-ui,sans-serif;
  color:#90a4ae;padding:8px;
}
.hd{text-align:center;margin-bottom:6px}
.hd h2{font-size:18px;color:#4fc3f7;font-weight:700;letter-spacing:.4px}
.hd p{font-size:11px;color:#546e7a;margin-top:2px}
.stg{
  width:96vw;max-width:1160px;
  border:1px solid #1a2a38;border-radius:8px;
  background:#101820;overflow:hidden;
}
svg{display:block;width:100%;height:auto}
.bar{margin-top:8px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;justify-content:center}
.bt{
  padding:3px 13px;border:1px solid #4fc3f7;background:none;
  color:#4fc3f7;border-radius:3px;cursor:pointer;font-size:12px;
}
.bt:hover{background:#4fc3f718}
.lb{font-size:11px}
.vl{color:#4fc3f7;font-weight:600}
input[type=range]{width:88px;accent-color:#4fc3f7}
</style>
</head>
<body>
<div class="hd">
  <h2>多段铰接式柔性底盘 — 阶梯自适应攀爬原理</h2>
  <p>万向节串联独立舱段 · 被动折叠贴合台阶 · 履带持续卷动抓地 · 自重恢复平直</p>
</div>
<div class="stg">
<svg id="scene" viewBox="0 0 1100 560" xmlns="http://www.w3.org/2000/svg">
<defs>
  <filter id="glow"><feGaussianBlur stdDeviation="5" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
  <filter id="glowSm"><feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
  <linearGradient id="gB" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#546e7a"/><stop offset="100%" stop-color="#37474f"/></linearGradient>
  <linearGradient id="gS" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1a2530"/><stop offset="100%" stop-color="#121a20"/></linearGradient>
  <pattern id="gd" width="40" height="40" patternUnits="userSpaceOnUse">
    <path d="M40 0L0 0 0 40" fill="none" stroke="#4fc3f7" stroke-width=".3"/>
  </pattern>
  <marker id="arrM" markerWidth="6" markerHeight="6" refX="3" refY="3" orient="auto">
    <path d="M0,0 L6,3 L0,6 Z" fill="#ff7043"/>
  </marker>
  <marker id="arrC" markerWidth="6" markerHeight="6" refX="3" refY="3" orient="auto">
    <path d="M0,0 L6,3 L0,6 Z" fill="#4fc3f7"/>
  </marker>
</defs>

<!-- Grid -->
<rect width="1100" height="560" fill="url(#gd)" opacity=".04"/>

<!-- Terrain -->
<g id="terrain">
  <path d="M0,470 L370,470 L370,560 L0,560Z" fill="#131b22" stroke="#1e3040" stroke-width=".5"/>
  <path d="M370,470 L370,410 L520,410 L520,560 L370,560Z" fill="url(#gS)" stroke="#1e3040" stroke-width=".5"/>
  <path d="M520,410 L520,350 L670,350 L670,560 L520,560Z" fill="url(#gS)" stroke="#1e3040" stroke-width=".5"/>
  <path d="M670,350 L1100,350 L1100,560 L670,560Z" fill="#131b22" stroke="#1e3040" stroke-width=".5"/>
  <!-- Step edge highlights -->
  <line x1="370" y1="470" x2="370" y2="410" stroke="#4fc3f7" stroke-width="2.5" opacity=".25"/>
  <line x1="370" y1="410" x2="520" y2="410" stroke="#4fc3f7" stroke-width="2.5" opacity=".35"/>
  <line x1="520" y1="410" x2="520" y2="350" stroke="#4fc3f7" stroke-width="2.5" opacity=".25"/>
  <line x1="520" y1="350" x2="670" y2="350" stroke="#4fc3f7" stroke-width="2.5" opacity=".35"/>
  <!-- Dimension annotations -->
  <g opacity=".3" fill="#4fc3f7" font-size="9">
    <text x="345" y="445" text-anchor="end">←60→</text>
    <line x1="350" y1="470" x2="350" y2="410" stroke="#4fc3f7" stroke-width=".6" stroke-dasharray="3 2"/>
    <text x="445" y="403" text-anchor="middle">←150→</text>
  </g>
  <g fill="#4fc3f7" font-size="10" opacity=".22">
    <text x="185" y="490" text-anchor="middle">平地行驶</text>
    <text x="445" y="448" text-anchor="middle">台阶 1</text>
    <text x="595" y="388" text-anchor="middle">台阶 2</text>
    <text x="880" y="370" text-anchor="middle">顶部平台</text>
  </g>
</g>

<!-- Force arrows (appear during contact) -->
<g id="forceArrows" opacity="0">
  <line id="fArr1" x1="370" y1="460" x2="370" y2="432" stroke="#ff7043" stroke-width="2.5" marker-end="url(#arrM)"/>
  <text x="355" y="448" text-anchor="end" fill="#ff7043" font-size="10" font-weight="600">反力</text>
  <line id="fArr2" x1="520" y1="400" x2="520" y2="372" stroke="#ff7043" stroke-width="2.5" marker-end="url(#arrM)"/>
  <text x="505" y="388" text-anchor="end" fill="#ff7043" font-size="10" font-weight="600">反力</text>
</g>

<!-- Trajectory hint -->
<path id="trajHint" d="M370,470 Q370,440 440,410 Q510,380 520,350" fill="none" stroke="#4fc3f7" stroke-width="1.2" stroke-dasharray="6 4" opacity="0" filter="url(#glowSm)"/>

<!-- Chassis root -->
<g id="chassisRoot"></g>

<!-- Callout banners -->
<g id="anns">
  <g id="ann-fold" opacity="0">
    <rect x="310" y="60" width="260" height="34" rx="6" fill="#0a1520" stroke="#ff9800" stroke-width="1.2" opacity=".9"/>
    <text x="440" y="82" text-anchor="middle" fill="#ff9800" font-size="13" font-weight="700">铰接关节被动折叠 ≤ 45°</text>
    <line x1="440" y1="94" x2="440" y2="140" stroke="#ff9800" stroke-width="1" stroke-dasharray="4 3" opacity=".5"/>
  </g>
  <g id="ann-track" opacity="0">
    <rect x="40" y="510" width="230" height="34" rx="6" fill="#0a1520" stroke="#66bb6a" stroke-width="1.2" opacity=".9"/>
    <text x="155" y="532" text-anchor="middle" fill="#66bb6a" font-size="13" font-weight="700">履带持续卷动 → 抓地力</text>
  </g>
  <g id="ann-recover" opacity="0">
    <rect x="730" y="280" width="230" height="34" rx="6" fill="#0a1520" stroke="#4fc3f7" stroke-width="1.2" opacity=".9"/>
    <text x="845" y="302" text-anchor="middle" fill="#4fc3f7" font-size="13" font-weight="700">自重恢复平直形态 ✓</text>
  </g>
</g>

<!-- Phase indicator -->
<g id="phaseLabel" opacity=".5">
  <rect x="16" y="16" width="120" height="24" rx="4" fill="#0a1520" stroke="#263238" stroke-width="1"/>
  <text id="phaseText" x="76" y="33" text-anchor="middle" fill="#78909c" font-size="11" font-weight="600">平地行驶</text>
</g>

</svg>
</div>

<div class="bar">
  <button class="bt" id="bReplay">⏮ 重播</button>
  <button class="bt" id="bPause">⏸ 暂停</button>
  <span class="lb">速度 <span class="vl" id="spdV">1.0x</span></span>
  <input type="range" id="spdR" min="0.3" max="2.5" step="0.1" value="1">
  <span class="lb">弯折角 <span class="vl" id="angV">38°</span></span>
  <input type="range" id="angR" min="20" max="45" step="1" value="38">
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script>
(function(){
  const NS="http://www.w3.org/2000/svg";
  const root=document.getElementById("chassisRoot");

  /* ---- Config ---- */
  const SW=72,BH=18,WR=10,N=5;
  const GY=470,S1Y=410,S2Y=350;
  let SA=-38; // step angle (will be controlled by slider)

  const segRots=[],joints=[],trackPaths=[],wheelSpokes=[];

  /* ---- Build segments ---- */
  function buildSeg(par,idx){
    // Rotation group
    const rg=document.createElementNS(NS,"g");
    rg.id=`sr${idx}`;
    par.appendChild(rg);
    segRots.push(rg);

    // Track outline (dashed for tread motion)
    const trk=document.createElementNS(NS,"rect");
    trk.setAttribute("x","2");trk.setAttribute("y",`${-WR-2}`);
    trk.setAttribute("width",`${SW-4}`);trk.setAttribute("height",`${WR*2+4}`);
    trk.setAttribute("rx",`${WR}`);
    trk.setAttribute("fill","#141c22");trk.setAttribute("stroke","#37474f");
    trk.setAttribute("stroke-width","1.5");
    trk.setAttribute("stroke-dasharray","5 3.5");
    trk.classList.add("tk");
    rg.appendChild(trk);
    trackPaths.push(trk);

    // Tread marks (bottom of track)
    for(let t=0;t<8;t++){
      const tx=6+t*((SW-12)/8);
      const ln=document.createElementNS(NS,"line");
      ln.setAttribute("x1",tx);ln.setAttribute("y1",`${WR-2}`);
      ln.setAttribute("x2",tx);ln.setAttribute("y2",`${WR+2}`);
      ln.setAttribute("stroke","#455a64");ln.setAttribute("stroke-width","1.8");
      ln.setAttribute("opacity",".5");ln.setAttribute("stroke-linecap","round");
      rg.appendChild(ln);
    }

    // Flexible coupling membrane at rear
    if(idx>0){
      const cp=document.createElementNS(NS,"rect");
      cp.setAttribute("x","-7");cp.setAttribute("y",`${-WR}`);
      cp.setAttribute("width","14");cp.setAttribute("height",`${WR*2}`);
      cp.setAttribute("rx","3");cp.setAttribute("fill","#1a252e");
      cp.setAttribute("stroke","#263238");cp.setAttribute("stroke-width",".8");
      rg.appendChild(cp);
    }

    // Body
    const bd=document.createElementNS(NS,"rect");
    bd.setAttribute("x","4");bd.setAttribute("y",`${-WR-BH-2}`);
    bd.setAttribute("width",`${SW-8}`);bd.setAttribute("height",BH);
    bd.setAttribute("rx","3");bd.setAttribute("fill","url(#gB)");
    bd.setAttribute("stroke","#607d8b");bd.setAttribute("stroke-width","1");
    rg.appendChild(bd);

    // Segment label
    const lb=document.createElementNS(NS,"text");
    lb.setAttribute("x",`${SW/2}`);lb.setAttribute("y",`${-WR-BH/2+1}`);
    lb.setAttribute("text-anchor","middle");lb.setAttribute("fill","#b0bec5");
    lb.setAttribute("font-size","9");lb.setAttribute("font-weight","700");
    lb.textContent=idx===0?"尾":idx===N-1?"首":`S${idx+1}`;
    rg.appendChild(lb);

    // Motor indicator (small rect on body)
    const mt=document.createElementNS(NS,"rect");
    mt.setAttribute("x",`${SW/2-6}`);mt.setAttribute("y",`${-WR-BH+2}`);
    mt.setAttribute("width","12");mt.setAttribute("height","5");
    mt.setAttribute("rx","1");mt.setAttribute("fill","#26a69a");
    mt.setAttribute("opacity",".6");
    rg.appendChild(mt);

    // Wheels with spokes
    [WR+2,SW-WR-2].forEach(cx=>{
      const wh=document.createElementNS(NS,"circle");
      wh.setAttribute("cx",cx);wh.setAttribute("cy","0");
      wh.setAttribute("r",WR);wh.setAttribute("fill","#0d1218");
      wh.setAttribute("stroke","#455a64");wh.setAttribute("stroke-width","1.5");
      rg.appendChild(wh);
      // Spoke (for rotation viz)
      const sp=document.createElementNS(NS,"line");
      sp.setAttribute("x1",`${cx-5}`);sp.setAttribute("y1","0");
      sp.setAttribute("x2",`${cx+5}`);sp.setAttribute("y2","0");
      sp.setAttribute("stroke","#546e7a");sp.setAttribute("stroke-width","1.2");
      sp.classList.add("spoke");
      rg.appendChild(sp);
      wheelSpokes.push(sp);
      // Hub
      const hb=document.createElementNS(NS,"circle");
      hb.setAttribute("cx",cx);hb.setAttribute("cy","0");
      hb.setAttribute("r","2");hb.setAttribute("fill","#607d8b");
      rg.appendChild(hb);
    });

    // Joint indicator
    if(idx>0){
      // Outer glow ring
      const jg=document.createElementNS(NS,"circle");
      jg.setAttribute("cx","0");jg.setAttribute("cy","0");
      jg.setAttribute("r","8");jg.setAttribute("fill","none");
      jg.setAttribute("stroke","#ff9800");jg.setAttribute("stroke-width","1");
      jg.setAttribute("opacity","0");
      jg.classList.add("jg");jg.id=`jg${idx}`;
      rg.appendChild(jg);
      // Inner dot
      const jt=document.createElementNS(NS,"circle");
      jt.setAttribute("cx","0");jt.setAttribute("cy","0");
      jt.setAttribute("r","4.5");jt.setAttribute("fill","#ff9800");
      jt.setAttribute("opacity",".45");
      jt.id=`jt${idx}`;
      rg.appendChild(jt);
      joints.push(jt);
    }

    // Front arrow
    if(idx===N-1){
      const ar=document.createElementNS(NS,"polygon");
      ar.setAttribute("points",`${SW},0 ${SW-8},-5 ${SW-8},5`);
      ar.setAttribute("fill","#4fc3f7");ar.setAttribute("opacity",".55");
      rg.appendChild(ar);
    }

    // Nest next segment
    if(idx<N-1){
      const nx=document.createElementNS(NS,"g");
      nx.setAttribute("transform",`translate(${SW},0)`);
      rg.appendChild(nx);
      buildSeg(nx,idx+1);
    }
  }

  buildSeg(root,0);

  /* ---- Keyframes ---- */
  function makeKF(){
    const a=SA;
    return [
      {t:0,   rx:-280,ry:GY, abs:[0,0,0,0,0]},
      {t:2.8, rx:10,  ry:GY, abs:[0,0,0,0,0]},     // approach
      {t:3.9, rx:45,  ry:GY, abs:[0,0,0,0,a]},      // seg4 rising
      {t:5.0, rx:80,  ry:GY, abs:[0,0,0,a,0]},      // seg3 rising
      {t:6.1, rx:115, ry:GY, abs:[0,0,a,0,0]},      // seg2 rising
      {t:7.2, rx:150, ry:GY, abs:[0,a,0,0,0]},      // seg1 rising
      {t:8.3, rx:185, ry:GY-15, abs:[a,0,0,0,0]},   // seg0 rising
      {t:9.4, rx:220, ry:S1Y, abs:[0,0,0,0,0]},     // all on step1
      // Step 2
      {t:10.5,rx:255, ry:S1Y, abs:[0,0,0,0,a]},
      {t:11.6,rx:290, ry:S1Y, abs:[0,0,0,a,0]},
      {t:12.7,rx:325, ry:S1Y, abs:[0,0,a,0,0]},
      {t:13.8,rx:360, ry:S1Y, abs:[0,a,0,0,0]},
      {t:14.9,rx:395, ry:S1Y-15, abs:[a,0,0,0,0]},
      {t:16.0,rx:430, ry:S2Y, abs:[0,0,0,0,0]},     // all on top
      // Exit
      {t:18.5,rx:800, ry:S2Y, abs:[0,0,0,0,0]},
    ];
  }

  function absToRel(abs){
    const r=[abs[0]];
    for(let i=1;i<abs.length;i++) r.push(abs[i]-abs[i-1]);
    return r;
  }

  /* ---- Tread dash animation ---- */
  let treadOffset=0;
  function animateTreads(){
    treadOffset-=0.8;
    trackPaths.forEach(tp=>{
      tp.setAttribute("stroke-dashoffset",treadOffset);
    });
    wheelSpokes.forEach(sp=>{
      const cur=parseFloat(sp.getAttribute("transform")?.replace(/[^0-9.-]/g,"")||"0");
      sp.setAttribute("transform",`rotate(${cur-3},${sp.getAttribute("x1").replace(/-.*/,"")||sp.x1?.baseVal?.value||0},0)`);
    });
    requestAnimationFrame(animateTreads);
  }

  // Simpler spoke rotation via GSAP
  gsap.to(".spoke",{rotation:-360,duration:1,ease:"none",repeat:-1,svgOrigin:"center center"});

  // Tread stroke-dashoffset animation
  gsap.to(trackPaths,{strokeDashoffset:-20,duration:0.6,ease:"none",repeat:-1});

  /* ---- Build timeline ---- */
  let tl;

  function buildTimeline(){
    if(tl) tl.kill();
    tl=gsap.timeline({repeat:-1,repeatDelay:1.2,defaults:{ease:"power2.inOut"}});

    const KF=makeKF();
    // Convert abs to rel
    KF.forEach(k=>k.rel=absToRel(k.abs));

    // Set initial
    gsap.set(root,{x:KF[0].rx,y:KF[0].ry});
    segRots.forEach((sr,i)=>gsap.set(sr,{rotation:KF[0].rel[i],svgOrigin:"0 0"}));

    // Root position keyframes
    for(let k=1;k<KF.length;k++){
      const p=KF[k-1],c=KF[k],dur=c.t-p.t;
      const ez=p.t<2.5?"none":"power2.inOut";
      tl.to(root,{x:c.rx,y:c.ry,duration:dur,ease:ez},p.t);
    }

    // Segment rotation keyframes
    for(let i=0;i<N;i++){
      for(let k=1;k<KF.length;k++){
        const p=KF[k-1],c=KF[k],dur=c.t-p.t;
        tl.to(segRots[i],{rotation:c.rel[i],duration:dur},p.t);
      }
    }

    // Joint highlighting
    const jh=[
      {j:4,s:2.8,e:5.0},{j:3,s:3.9,e:6.1},{j:2,s:5.0,e:7.2},{j:1,s:6.1,e:8.3},
      {j:4,s:10.5,e:11.6},{j:3,s:11.6,e:12.7},{j:2,s:12.7,e:13.8},{j:1,s:13.8,e:14.9},
    ];
    jh.forEach(({j,s,e})=>{
      const jt=document.getElementById(`jt${j}`);
      const jg=document.getElementById(`jg${j}`);
      if(jt){
        tl.to(jt,{attr:{r:6,fill:"#ffeb3b"},opacity:1,duration:.25},s)
          .to(jt,{attr:{r:4.5,fill:"#ff9800"},opacity:.45,duration:.25},e-.25);
      }
      if(jg){
        tl.to(jg,{opacity:.6,duration:.25},s)
          .to(jg,{opacity:0,duration:.25},e-.25);
      }
    });

    // Force arrows
    tl.to("#forceArrows",{opacity:1,duration:.4},2.8)
      .to("#forceArrows",{opacity:0,duration:.4},9.0)
      .to("#forceArrows",{opacity:1,duration:.4},10.5)
      .to("#forceArrows",{opacity:0,duration:.4},16.0);

    // Trajectory hint
    tl.to("#trajHint",{opacity:.35,duration:.5},3.0)
      .to("#trajHint",{opacity:0,duration:.5},9.0);

    // Annotations
    tl.to("#ann-fold",{opacity:1,duration:.4},3.2)
      .to("#ann-fold",{opacity:0,duration:.4},8.5)
      .to("#ann-fold",{opacity:1,duration:.4},10.8)
      .to("#ann-fold",{opacity:0,duration:.4},15.2);

    tl.to("#ann-track",{opacity:1,duration:.4},3.0)
      .to("#ann-track",{opacity:0,duration:.4},7.5);

    tl.to("#ann-recover",{opacity:1,duration:.4},16.0)
      .to("#ann-recover",{opacity:0,duration:.4},18.0);

    // Phase label
    const phases=[
      {t:0,txt:"平地行驶"},{t:2.8,txt:"触碰台阶"},{t:3.9,txt:"被动折叠攀爬"},
      {t:9.4,txt:"台阶1完成"},{t:10.5,txt:"继续攀爬台阶2"},
      {t:16.0,txt:"自重恢复平直"},{t:17.0,txt:"顶部行驶"},
    ];
    phases.forEach(({t,txt},i)=>{
      const dur=i<phases.length-1?phases[i+1].t-t:2;
      tl.to("#phaseText",{textContent:txt,duration:.01},t);
    });
  }

  buildTimeline();

  /* ---- Controls ---- */
  document.getElementById("bReplay").addEventListener("click",()=>tl.restart());

  let paused=false;
  document.getElementById("bPause").addEventListener("click",()=>{
    if(paused){tl.play();paused=false;document.getElementById("bPause").textContent="⏸ 暂停";}
    else{tl.pause();paused=true;document.getElementById("bPause").textContent="▶ 继续";}
  });

  const spdR=document.getElementById("spdR"),spdV=document.getElementById("spdV");
  spdR.addEventListener("input",()=>{
    const v=parseFloat(spdR.value);
    tl.timeScale(v);spdV.textContent=v.toFixed(1)+"x";
  });

  const angR=document.getElementById("angR"),angV=document.getElementById("angV");
  angR.addEventListener("input",()=>{
    const v=parseInt(angR.value);
    SA=-v;angV.textContent=v+"°";
    buildTimeline();
    tl.seek(0);
  });

  /* ---- Keyboard shortcuts ---- */
  document.addEventListener("keydown",e=>{
    if(e.key===" "){e.preventDefault();document.getElementById("bPause").click();}
    if(e.key==="r")document.getElementById("bReplay").click();
  });
})();
</script>
</body>
</html>

实现说明:

  1. 核心机构建模:5 个独立舱段通过嵌套 SVG <g> 元素实现万向节串联,每个舱段包含车体、双驱动轮、履带轮廓(虚线描边模拟滚动纹理)、防滑筋纹路、电机指示块及柔性连接膜片。

  2. 攀爬时序编排:采用绝对角度→相对角度转换策略——先定义每个关键帧下各舱段的绝对倾斜角,再递推算出嵌套结构所需的相对旋转值(rel[i] = abs[i] - abs[i-1]),完美解决嵌套变换的级联补偿问题。整条时间轴统一编排在一个 gsap.timeline() 中,包含平地行驶→触碰台阶→被动折叠→履带卷动→自重恢复的完整过程。

  3. 视觉引导

    • 铰接关节在弯折时从橙色突变为黄色并显示外圈辉光
    • 台阶立面处出现反力箭头(橙红色)
    • 攀爬轨迹虚线提示路径
    • 三个浮动标注(折叠/抓地/恢复)在对应阶段淡入
  4. 交互控制

    • 弯折角滑块(20°–45°):实时改变舱段最大折叠角度,重建时间轴
    • 速度滑块timeScale 控制播放速率
    • 重播/暂停按钮及键盘快捷键(空格暂停、R 重播)
积分规则:第一轮对话扣减8分,后续每轮扣6分