分享图
动画工坊
引擎就绪
<!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}
body{background:#080d18;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;font-family:'Segoe UI',system-ui,sans-serif;color:#c0d0e0;overflow:hidden}
.hd{font-size:20px;color:#3de8c8;font-weight:700;letter-spacing:1.5px;margin-bottom:2px}
.sub{font-size:13px;color:#506880;margin-bottom:10px;text-align:center;max-width:700px;line-height:1.5}
.wrap{width:96vw;max-width:1400px;position:relative}
svg{width:100%;height:auto;display:block;border-radius:10px;background:#0a0f1c}
.bar{display:flex;gap:14px;margin-top:10px;align-items:center;justify-content:center;flex-wrap:wrap}
.bar button{background:#111a30;color:#3de8c8;border:1px solid #1e3050;padding:6px 18px;border-radius:6px;cursor:pointer;font-size:13px;transition:all .2s}
.bar button:hover{background:#182848;border-color:#3de8c8}
.bar button.active{background:#1e3050;border-color:#3de8c8}
.sl{display:flex;align-items:center;gap:6px;font-size:12px;color:#506880}
.sl input{width:100px;accent-color:#3de8c8}
.sl span{color:#3de8c8;min-width:32px}
#ptxt{font-size:15px;color:#ffc846;margin-top:8px;text-align:center;min-height:22px;transition:opacity .3s}
</style>
</head>
<body>
<div class="hd">多段铰接式柔性底盘 · 不规则台阶攀爬</div>
<div class="sub">底盘形态随地形被动重塑 — 舱段铰接折叠贴合楼梯轮廓,履带卷动提供抓地力,自重恢复平直</div>
<div class="wrap">
<svg id="scene" viewBox="0 0 1400 640" xmlns="http://www.w3.org/2000/svg">
<defs>
  <linearGradient id="gS" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#182840"/><stop offset="100%" stop-color="#0e1828"/></linearGradient>
  <linearGradient id="gC" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#3a6898"/><stop offset="100%" stop-color="#1c3858"/></linearGradient>
  <linearGradient id="gF" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#e08838"/><stop offset="100%" stop-color="#a05820"/></linearGradient>
  <linearGradient id="gT" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#404040"/><stop offset="100%" stop-color="#2a2a2a"/></linearGradient>
  <filter id="gl"><feGaussianBlur stdDeviation="4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
  <filter id="gl2"><feGaussianBlur stdDeviation="2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
  <filter id="shd"><feGaussianBlur stdDeviation="8"/></filter>
  <clipPath id="sceneClip"><rect x="0" y="0" width="1400" height="640"/></clipPath>
</defs>

<g clip-path="url(#sceneClip)">
<!-- Background grid -->
<g opacity="0.04">
  <pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
    <circle cx="20" cy="20" r="1" fill="#4a7a9a"/>
  </pattern>
  <rect width="1400" height="640" fill="url(#grid)"/>
</g>

<!-- Stairs -->
<g id="stairs">
  <rect x="0" y="520" width="560" height="130" fill="url(#gS)"/>
  <rect x="560" y="435" width="130" height="215" fill="url(#gS)"/>
  <rect x="690" y="330" width="140" height="320" fill="url(#gS)"/>
  <rect x="830" y="330" width="570" height="320" fill="url(#gS)"/>
  <!-- Step edges -->
  <line x1="560" y1="435" x2="690" y2="435" stroke="#2a506e" stroke-width="1.5"/>
  <line x1="560" y1="435" x2="560" y2="520" stroke="#2a506e" stroke-width="1.5"/>
  <line x1="690" y1="330" x2="830" y2="330" stroke="#2a506e" stroke-width="1.5"/>
  <line x1="690" y1="330" x2="690" y2="435" stroke="#2a506e" stroke-width="1.5"/>
  <!-- Edge highlights -->
  <line x1="560" y1="435" x2="690" y2="435" stroke="#4a88b0" stroke-width="0.5" opacity="0.5"/>
  <line x1="690" y1="330" x2="830" y2="330" stroke="#4a88b0" stroke-width="0.5" opacity="0.5"/>
  <!-- Dimension labels -->
  <g fill="#38607a" font-size="10" text-anchor="middle">
    <text x="625" y="490">h85</text>
    <text x="760" y="395">h105</text>
  </g>
  <!-- Ground line -->
  <line x1="0" y1="520" x2="560" y2="520" stroke="#2a506e" stroke-width="1" opacity="0.4"/>
  <line x1="830" y1="330" x2="1400" y2="330" stroke="#2a506e" stroke-width="1" opacity="0.4"/>
</g>

<!-- Vehicle shadow -->
<ellipse id="vshadow" cx="200" cy="526" rx="120" ry="6" fill="#000" opacity="0.25" filter="url(#shd)"/>

<!-- Vehicle -->
<g id="vehicle">
<g id="vm">

  <!-- === TRACK (dynamic path, drawn first) === -->
  <path id="trackFill" d="" fill="#2a2a2a" stroke="none"/>
  <path id="trackStroke" d="" fill="none" stroke="#444" stroke-width="1"/>
  <path id="treadLine" d="" fill="none" stroke="#505050" stroke-width="1.5" stroke-dasharray="6 5" stroke-linecap="round"/>

  <!-- === REAR CABIN === -->
  <g id="cabinR">
    <rect x="3" y="-18" width="72" height="16" rx="3" fill="url(#gC)" stroke="#4a88b8" stroke-width="1"/>
    <circle cx="17" cy="0" r="5.5" fill="#2a4a6a" stroke="#4a88b8" stroke-width="0.8"/>
    <circle cx="63" cy="0" r="5.5" fill="#2a4a6a" stroke="#4a88b8" stroke-width="0.8"/>
    <circle cx="17" cy="0" r="2" fill="#1a2a44"/><circle cx="63" cy="0" r="2" fill="#1a2a44"/>
    <!-- Wheel spokes -->
    <line x1="17" y1="-4" x2="17" y2="4" stroke="#1a2a44" stroke-width="1.2"/>
    <line x1="13" y1="0" x2="21" y2="0" stroke="#1a2a44" stroke-width="1.2"/>
    <line x1="63" y1="-4" x2="63" y2="4" stroke="#1a2a44" stroke-width="1.2"/>
    <line x1="59" y1="0" x2="67" y2="0" stroke="#1a2a44" stroke-width="1.2"/>
    <text x="39" y="-6" fill="#6aaace" font-size="7" text-anchor="middle" font-weight="600">后舱</text>
  </g>

  <!-- === HINGE 1 === -->
  <g transform="translate(78,-10)">
    <g id="h1r">
      <circle cx="0" cy="0" r="5.5" fill="#ffc846" opacity="0.9" filter="url(#gl)"/>
      <circle cx="0" cy="0" r="2.5" fill="#aa8800"/>

      <!-- === MID CABIN === -->
      <g id="cabinM">
        <rect x="3" y="-8" width="72" height="16" rx="3" fill="url(#gC)" stroke="#4a88b8" stroke-width="1"/>
        <circle cx="17" cy="10" r="5.5" fill="#2a4a6a" stroke="#4a88b8" stroke-width="0.8"/>
        <circle cx="63" cy="10" r="5.5" fill="#2a4a6a" stroke="#4a88b8" stroke-width="0.8"/>
        <circle cx="17" cy="10" r="2" fill="#1a2a44"/><circle cx="63" cy="10" r="2" fill="#1a2a44"/>
        <line x1="17" y1="6" x2="17" y2="14" stroke="#1a2a44" stroke-width="1.2"/>
        <line x1="13" y1="10" x2="21" y2="10" stroke="#1a2a44" stroke-width="1.2"/>
        <line x1="63" y1="6" x2="63" y2="14" stroke="#1a2a44" stroke-width="1.2"/>
        <line x1="59" y1="10" x2="67" y2="10" stroke="#1a2a44" stroke-width="1.2"/>
        <text x="39" y="4" fill="#6aaace" font-size="7" text-anchor="middle" font-weight="600">中舱</text>
      </g>

      <!-- === HINGE 2 === -->
      <g transform="translate(78,0)">
        <g id="h2r">
          <circle cx="0" cy="0" r="5.5" fill="#ffc846" opacity="0.9" filter="url(#gl)"/>
          <circle cx="0" cy="0" r="2.5" fill="#aa8800"/>

          <!-- === FRONT CABIN === -->
          <g id="cabinF">
            <rect x="3" y="-8" width="72" height="16" rx="3" fill="url(#gF)" stroke="#e09040" stroke-width="1"/>
            <circle cx="17" cy="10" r="5.5" fill="#a05020" stroke="#e09040" stroke-width="0.8"/>
            <circle cx="63" cy="10" r="5.5" fill="#a05020" stroke="#e09040" stroke-width="0.8"/>
            <circle cx="17" cy="10" r="2" fill="#1a2a44"/><circle cx="63" cy="10" r="2" fill="#1a2a44"/>
            <line x1="17" y1="6" x2="17" y2="14" stroke="#1a2a44" stroke-width="1.2"/>
            <line x1="13" y1="10" x2="21" y2="10" stroke="#1a2a44" stroke-width="1.2"/>
            <line x1="63" y1="6" x2="63" y2="14" stroke="#1a2a44" stroke-width="1.2"/>
            <line x1="59" y1="10" x2="67" y2="10" stroke="#1a2a44" stroke-width="1.2"/>
            <text x="39" y="4" fill="#ffe0a0" font-size="7" text-anchor="middle" font-weight="600">前舱</text>
            <!-- Front indicator arrow -->
            <polygon points="78,2 84,0 78,-2" fill="#e09040" opacity="0.7"/>
          </g>
        </g>
      </g>
    </g>
  </g>

  <!-- Angle indicators (dynamic) -->
  <g id="angIndicators">
    <text id="ang1" x="0" y="0" fill="#ffc846" font-size="10" font-weight="600" opacity="0"></text>
    <text id="ang2" x="0" y="0" fill="#ffc846" font-size="10" font-weight="600" opacity="0"></text>
  </g>

</g>
</g>

<!-- Phase HUD -->
<rect x="16" y="16" width="300" height="38" rx="8" fill="#080d18" fill-opacity="0.88" stroke="#1e3050" stroke-width="1"/>
<circle id="phaseDot" cx="34" cy="35" r="5" fill="#3de8c8" filter="url(#gl2)"/>
<text id="phaseLbl" x="48" y="40" fill="#3de8c8" font-size="14" font-weight="600">准备就绪</text>

<!-- Legend -->
<g transform="translate(16,590)">
  <rect x="0" y="0" width="440" height="36" rx="6" fill="#080d18" fill-opacity="0.8" stroke="#1e3050" stroke-width="0.5"/>
  <circle cx="18" cy="18" r="5" fill="#ffc846" opacity="0.8"/><text x="30" y="22" fill="#8098b0" font-size="10">铰接点 (≤45°)</text>
  <rect x="120" y="13" width="20" height="10" rx="2" fill="#3a6898"/><text x="146" y="22" fill="#8098b0" font-size="10">舱段</text>
  <rect x="195" y="13" width="20" height="10" rx="2" fill="#e08838"/><text x="221" y="22" fill="#8098b0" font-size="10">前舱(高亮)</text>
  <rect x="300" y="13" width="20" height="10" rx="2" fill="#2a2a2a" stroke="#444" stroke-width="0.5"/><text x="326" y="22" fill="#8098b0" font-size="10">柔性履带</text>
</g>

<!-- Principle callout -->
<g id="callout" opacity="0">
  <rect x="900" y="440" width="380" height="90" rx="8" fill="#0c1524" fill-opacity="0.92" stroke="#3de8c8" stroke-width="0.8"/>
  <text x="920" y="465" fill="#3de8c8" font-size="12" font-weight="600">核心原理:形态被动重塑</text>
  <text x="920" y="485" fill="#8098b0" font-size="11">底盘形态随地形被动改变,而非刚性对抗。</text>
  <text x="920" y="503" fill="#8098b0" font-size="11">铰接结构使舱段贴合不规则轮廓,</text>
  <text x="920" y="521" fill="#ffc846" font-size="11">履带持续卷动提供抓地力 → 如毛毛虫蠕动攀爬</text>
</g>

</g>
</svg>
</div>

<div class="bar">
  <button id="btnReplay">🔄 重播</button>
  <button id="btnPause">⏸ 暂停</button>
  <div class="sl">
    <span>速度</span>
    <input type="range" id="spdSlider" min="0.3" max="2.5" step="0.1" value="1">
    <span id="spdLbl">1.0x</span>
  </div>
</div>
<div id="ptxt"></div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script>
(function(){
  /* ── State ── */
  const S = { vx:60, vy:513, a1:0, a2:0, tread:0 };
  const GROUND_Y = 520, STEP1_TOP = 435, STEP2_TOP = 330;
  const TRACK_BOT = 7; // track bottom in VM local y

  /* ── DOM ── */
  const vm = document.getElementById('vm');
  const h1r = document.getElementById('h1r');
  const h2r = document.getElementById('h2r');
  const trackFill = document.getElementById('trackFill');
  const trackStroke = document.getElementById('trackStroke');
  const treadLine = document.getElementById('treadLine');
  const vshadow = document.getElementById('vshadow');
  const ang1 = document.getElementById('ang1');
  const ang2 = document.getElementById('ang2');
  const phaseLbl = document.getElementById('phaseLbl');
  const phaseDot = document.getElementById('phaseDot');
  const callout = document.getElementById('callout');
  const ptxt = document.getElementById('ptxt');

  /* ── Transform helpers ── */
  function h1ToVM(lx,ly){
    const c=Math.cos(S.a1*Math.PI/180), s=Math.sin(S.a1*Math.PI/180);
    return {x:78+lx*c-ly*s, y:-10+lx*s+ly*c};
  }
  function h2ToVM(lx,ly){
    const c1=Math.cos(S.a1*Math.PI/180), s1=Math.sin(S.a1*Math.PI/180);
    const c2=Math.cos(S.a2*Math.PI/180), s2=Math.sin(S.a2*Math.PI/180);
    const rx=lx*c2-ly*s2, ry=lx*s2+ly*c2;
    const tx=rx+78, ty=ry;
    return {x:78+tx*c1-ty*s1, y:-10+tx*s1+ty*c1};
  }

  /* ── Track path ── */
  function buildTrack(){
    const a1r=S.a1*Math.PI/180, a2r=S.a2*Math.PI/180;
    // Bottom contour key points (VM coords)
    const bp=[
      {x:-2,y:TRACK_BOT},{x:82,y:TRACK_BOT},
      h1ToVM(-2,17), h1ToVM(82,17),
      h2ToVM(-2,17), h2ToVM(82,17)
    ];
    // Top contour key points
    const tp=[
      {x:-2,y:-20},{x:82,y:-20},
      h1ToVM(-2,-10), h1ToVM(82,-10),
      h2ToVM(-2,-10), h2ToVM(82,-10)
    ];

    // Front cap control point
    const fmx=(bp[5].x+tp[5].x)/2, fmy=(bp[5].y+tp[5].y)/2;
    const totalA=(S.a1+S.a2)*Math.PI/180;
    const fcx=fmx+14*Math.cos(totalA), fcy=fmy+14*Math.sin(totalA);
    // Rear cap control point
    const rmx=(bp[0].x+tp[0].x)/2, rmy=(bp[0].y+tp[0].y)/2;
    const rcx=rmx-14, rcy=rmy;

    let d=`M${bp[0].x},${bp[0].y}`;
    for(let i=1;i<bp.length;i++) d+=` L${bp[i].x},${bp[i].y}`;
    d+=` Q${fcx},${fcy} ${tp[5].x},${tp[5].y}`;
    for(let i=tp.length-2;i>=0;i--) d+=` L${tp[i].x},${tp[i].y}`;
    d+=` Q${rcx},${rcy} ${bp[0].x},${bp[0].y} Z`;

    trackFill.setAttribute('d',d);
    trackStroke.setAttribute('d',d);

    // Tread line (bottom contour only, offset inward slightly)
    let td=`M${bp[0].x},${bp[0].y-3}`;
    for(let i=1;i<bp.length;i++) td+=` L${bp[i].x},${bp[i].y-3}`;
    treadLine.setAttribute('d',td);
  }

  /* ── Scene update ── */
  function update(){
    vm.setAttribute('transform',`translate(${S.vx},${S.vy})`);
    h1r.setAttribute('transform',`rotate(${S.a1})`);
    h2r.setAttribute('transform',`rotate(${S.a2})`);

    buildTrack();

    // Tread dashoffset animation
    const tl2 = treadLine.getTotalLength();
    treadLine.style.strokeDashoffset = S.tread;

    // Shadow
    vshadow.setAttribute('cx', S.vx+120);
    const surfaceY = S.vy + TRACK_BOT;
    vshadow.setAttribute('cy', surfaceY + 8);
    const shadowScale = Math.max(0.3, 1 - (520 - surfaceY) / 400);
    vshadow.setAttribute('rx', 110 * shadowScale);
    vshadow.setAttribute('opacity', 0.2 * shadowScale);

    // Angle indicators
    const h1pos = {x: S.vx+78, y: S.vy-10};
    const h2pos = h2ScreenPos();
    if(Math.abs(S.a1)>2){
      ang1.setAttribute('x', h1pos.x - 30);
      ang1.setAttribute('y', h1pos.y - 14);
      ang1.textContent = Math.round(Math.abs(S.a1))+'°';
      ang1.setAttribute('opacity','0.9');
    } else { ang1.setAttribute('opacity','0'); }
    if(Math.abs(S.a2)>2){
      ang2.setAttribute('x', h2pos.x - 30);
      ang2.setAttribute('y', h2pos.y - 14);
      ang2.textContent = Math.round(Math.abs(S.a2))+'°';
      ang2.setAttribute('opacity','0.9');
    } else { ang2.setAttribute('opacity','0'); }
  }

  function h2ScreenPos(){
    const c1=Math.cos(S.a1*Math.PI/180), s1=Math.sin(S.a1*Math.PI/180);
    return {x: S.vx+78+78*c1, y: S.vy-10+78*s1};
  }

  /* ── Phase label ── */
  function setPhase(txt,color){
    phaseLbl.textContent = txt;
    phaseDot.setAttribute('fill', color||'#3de8c8');
    ptxt.textContent = txt;
  }

  /* ── Timeline ── */
  let tl = null;
  function buildTimeline(){
    if(tl) tl.kill();
    // Reset state
    gsap.set(S,{vx:60,vy:513,a1:0,a2:0,tread:0});
    vm.setAttribute('transform','translate(60,513)');
    h1r.setAttribute('transform','rotate(0)');
    h2r.setAttribute('transform','rotate(0)');
    setPhase('准备就绪','#3de8c8');
    callout.setAttribute('opacity','0');

    tl = gsap.timeline({
      onUpdate: update,
      defaults: {ease:'power2.inOut'}
    });

    // ── Phase 1: 平地接近 ──
    tl.to(S,{vx:310,duration:2.2,ease:'power1.inOut'})
      .add(()=>setPhase('① 平地接近','#3de8c8'))

    // ── Phase 2: 前舱接触台阶并折叠 ──
    .to(S,{vx:360,a2:-42,duration:2,ease:'power2.inOut'})
      .add(()=>setPhase('② 前舱接触台阶,被动折叠','#ffc846'))

    // ── Phase 3: 中舱折叠,整车抬升上第一级 ──
    .to(S,{vx:445,vy:428,a1:-38,a2:-6,duration:2.8,ease:'power2.inOut'})
      .add(()=>setPhase('③ 中舱被动折叠贴合台阶','#ffc846'))
      .to(callout,{attr:{opacity:0.95},duration:0.8},'<0.5')

    // ── Phase 4: 前舱到达第二级台阶面,折叠 ──
    .to(S,{vx:530,vy:428,a1:0,a2:-42,duration:2.2,ease:'power2.inOut'})
      .add(()=>setPhase('④ 前舱折叠贴合第二级台阶','#e08040'))

    // ── Phase 5: 中舱折叠,整车攀爬第二级 ──
    .to(S,{vx:620,vy:323,a1:-38,a2:-6,duration:2.8,ease:'power2.inOut'})
      .add(()=>setPhase('⑤ 履带卷动攀爬,如毛毛虫蠕动','#3de8c8'))

    // ── Phase 6: 跨越台阶,自重恢复平直 ──
    .to(S,{vx:760,vy:323,a1:0,a2:0,duration:2.5,ease:'power2.inOut'})
      .add(()=>setPhase('⑥ 跨越台阶,自重恢复平直','#3de8c8'))

    // Hold
    .to({},{duration:1.5})
      .add(()=>setPhase('攀爬完成 — 柔性底盘自适应不规则台阶','#3de8c8'))

    // Tread animation throughout
    tl.to(S,{tread:-600,duration:16,ease:'none'},0);

    return tl;
  }

  /* ── Controls ── */
  const btnReplay = document.getElementById('btnReplay');
  const btnPause = document.getElementById('btnPause');
  const spdSlider = document.getElementById('spdSlider');
  const spdLbl = document.getElementById('spdLbl');

  btnReplay.addEventListener('click',()=>{
    buildTimeline();
  });

  btnPause.addEventListener('click',()=>{
    if(!tl) return;
    if(tl.isActive()){
      tl.pause();
      btnPause.textContent = '▶ 继续';
    } else {
      tl.resume();
      btnPause.textContent = '⏸ 暂停';
    }
  });

  spdSlider.addEventListener('input',()=>{
    const v = parseFloat(spdSlider.value);
    spdLbl.textContent = v.toFixed(1)+'x';
    if(tl) tl.timeScale(v);
  });

  /* ── Auto-play on load ── */
  buildTimeline();

})();
</script>
</body>
</html>

实现说明:

  1. 层级结构:三轮舱段通过嵌套 <g> + transform="translate" 实现铰接层级——后舱固定在 vehicle_mover 坐标系内,中舱嵌套在 hinge1_rotator 内绕铰接点旋转,前舱嵌套在 hinge2_rotator 内,旋转自动级联。

  2. 柔性履带:通过 h1ToVM / h2ToVM 函数将各舱段的轨道轮廓关键点换算到统一坐标系,动态拼合底边+顶边+前后弧帽的闭合路径。每帧 onUpdate 重算 d 属性,保证履带始终包裹所有舱段并在弯折处平滑过渡。

  3. 动作时序:GSAP 主时间轴按 6 个阶段编排——平地接近 → 前舱折叠 → 中舱折叠 → 前舱第二级折叠 → 中舱第二级折叠+攀爬 → 自重恢复平直。铰接角度通过 rotate() SVG 属性直接设置,绕各铰链 (0,0) 旋转,不存在 transform-origin 歧义。

  4. 视觉引导:前舱用橙色高亮标记创新焦点;铰接点用黄色光晕强调;弯折时动态显示角度数值;阶段标签实时更新;原理说明浮窗在关键阶段淡入。

  5. 交互:重播按钮重建时间轴从头播放;暂停/继续按钮控制播放状态;速度滑块通过 timeScale() 实时调速。

积分规则:第一轮对话扣减8分,后续每轮扣6分