分享图
动画工坊
引擎就绪
<!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 rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@300;500;700&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root {
  --bg: #070b14;
  --fg: #d0d8e8;
  --muted: #4a5568;
  --accent: #4cc9f0;
  --grip: #f59e0b;
  --slide: #0d9488;
  --propulsion: #10b981;
  --friction: #ef4444;
  --card: #0f1520;
  --border: #1e293b;
}
*{margin:0;padding:0;box-sizing:border-box}
body{
  background:var(--bg);
  color:var(--fg);
  font-family:'Rajdhani',sans-serif;
  min-height:100vh;
  display:flex;
  flex-direction:column;
  overflow:hidden;
  user-select:none;
}
header{
  padding:14px 28px 6px;
  display:flex;
  align-items:baseline;
  gap:18px;
  flex-shrink:0;
}
header h1{
  font-size:1.35rem;
  font-weight:700;
  letter-spacing:.04em;
  color:#e8ecf4;
}
header h1 span{color:var(--grip)}
header p{
  font-size:.82rem;
  color:var(--muted);
  font-family:'JetBrains Mono',monospace;
  font-weight:300;
}
main{
  flex:1;
  display:flex;
  justify-content:center;
  align-items:center;
  padding:0 16px;
  min-height:0;
}
canvas{
  width:100%;
  max-width:1400px;
  border-radius:8px;
  background:#0a0f1c;
}
footer{
  padding:10px 28px 16px;
  display:flex;
  gap:28px;
  flex-wrap:wrap;
  align-items:center;
  justify-content:center;
  flex-shrink:0;
}
.ctrl{
  display:flex;
  align-items:center;
  gap:8px;
  font-family:'JetBrains Mono',monospace;
  font-size:.72rem;
  color:var(--muted);
}
.ctrl label{white-space:nowrap;min-width:70px}
.ctrl input[type=range]{
  -webkit-appearance:none;
  appearance:none;
  width:110px;height:4px;
  background:var(--border);
  border-radius:2px;
  outline:none;
  cursor:pointer;
}
.ctrl 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;
}
.ctrl .val{
  min-width:38px;
  text-align:right;
  color:var(--accent);
  font-weight:500;
}
.legend{
  display:flex;gap:16px;
  font-family:'JetBrains Mono',monospace;
  font-size:.68rem;
  color:var(--muted);
  align-items:center;
}
.legend i{
  display:inline-block;
  width:10px;height:10px;
  border-radius:2px;
  margin-right:4px;
  vertical-align:middle;
}
.legend .grip-i{background:var(--grip)}
.legend .slide-i{background:var(--slide)}
.legend .prop-i{background:var(--propulsion)}
@media(max-width:700px){
  header{flex-direction:column;gap:4px}
  footer{gap:12px}
  .ctrl input[type=range]{width:80px}
}
</style>
</head>
<body>

<header>
  <h1>仿生柔性鳞片 · <span>单向摩擦推进</span></h1>
  <p>IFR: 正弦波输入 → 物理结构自锁 → 前进位移 | 零算法开销</p>
</header>

<main>
  <canvas id="c"></canvas>
</main>

<footer>
  <div class="ctrl">
    <label>波动频率</label>
    <input type="range" id="rFreq" min="0.2" max="1.6" step="0.05" value="0.65">
    <span class="val" id="vFreq">0.65</span>
  </div>
  <div class="ctrl">
    <label>波动幅度</label>
    <input type="range" id="rAmp" min="5" max="35" step="1" value="20">
    <span class="val" id="vAmp">20°</span>
  </div>
  <div class="ctrl">
    <label>鳞片后掠角</label>
    <input type="range" id="rScale" min="15" max="50" step="1" value="30">
    <span class="val" id="vScale">30°</span>
  </div>
  <div class="ctrl">
    <label>显示力矢量</label>
    <input type="range" id="rArrow" min="0" max="1" step="1" value="1">
    <span class="val" id="vArrow">ON</span>
  </div>
  <div class="legend">
    <span><i class="grip-i"></i>锚定抓地</span>
    <span><i class="slide-i"></i>顺滑滑行</span>
    <span><i class="prop-i"></i>推进合力</span>
  </div>
</footer>

<script>
/* ===== 仿生蛇单向摩擦推进原理动画 ===== */
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const dpr = Math.min(window.devicePixelRatio || 1, 2);

/* ---------- 配置 ---------- */
const CFG = {
  numSeg: 14,
  segLen: 38,
  bodyW: 18,
  waveAmp: 20,      // 度
  waveFreq: 0.65,   // Hz
  waveK: 0.62,      // 空间波数 (rad/segment)
  scaleAngle: 30,   // 鳞片后掠角 度
  scaleLen: 15,
  scalesPerSeg: 3,
  showArrows: true,
};

/* ---------- 状态 ---------- */
let time = 0;
let lastTS = 0;
let worldX = 0;
const prevJoints = [];

/* ---------- 控件 ---------- */
const rFreq = document.getElementById('rFreq');
const rAmp  = document.getElementById('rAmp');
const rScale= document.getElementById('rScale');
const rArrow= document.getElementById('rArrow');
const vFreq = document.getElementById('vFreq');
const vAmp  = document.getElementById('vAmp');
const vScale= document.getElementById('vScale');
const vArrow= document.getElementById('vArrow');

rFreq.oninput  = ()=>{ CFG.waveFreq=+rFreq.value; vFreq.textContent=rFreq.value; };
rAmp.oninput   = ()=>{ CFG.waveAmp =+rAmp.value;  vAmp.textContent=rAmp.value+'°'; };
rScale.oninput = ()=>{ CFG.scaleAngle=+rScale.value; vScale.textContent=rScale.value+'°'; };
rArrow.oninput = ()=>{ CFG.showArrows=+rArrow.value===1; vArrow.textContent=CFG.showArrows?'ON':'OFF'; };

/* ---------- 尺寸 ---------- */
let W, H, cx, groundY;
function resize(){
  const rect = canvas.parentElement.getBoundingClientRect();
  W = Math.round(rect.width - 32);
  H = Math.round(Math.min(rect.height, W * 0.52));
  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);
  cx = W / 2;
  groundY = H * 0.62;
}
window.addEventListener('resize', resize);
resize();

/* ---------- 关节计算 ---------- */
function computeJoints(t){
  const joints = [];
  let x = 0, y = 0, ang = 0;
  for(let i = 0; i <= CFG.numSeg; i++){
    joints.push({x, y, ang, idx:i});
    if(i < CFG.numSeg){
      const ja = CFG.waveAmp * Math.sin(2*Math.PI*CFG.waveFreq*t - CFG.waveK*i);
      ang += ja * Math.PI/180;
      x += CFG.segLen * Math.cos(ang);
      y += CFG.segLen * Math.sin(ang);
    }
  }
  return joints;
}

/* ---------- 鳞片状态计算 ---------- */
function segState(segIdx, t){
  // 角速度 = dθ/dt 方向决定推/滑
  const phase = 2*Math.PI*CFG.waveFreq*t - CFG.waveK*segIdx;
  const dTheta = CFG.waveAmp * 2*Math.PI*CFG.waveFreq * Math.cos(phase);
  // 正值 → 关节张开 → 节段向后推地 → 锚定
  // 负值 → 关节收拢 → 节段向前滑行 → 顺滑
  const grip = dTheta > 0 ? Math.min(dTheta / 40, 1) : 0;
  const slide = dTheta < 0 ? Math.min(-dTheta / 40, 1) : 0;
  return {grip, slide, phase};
}

/* ---------- 绘制辅助 ---------- */
function roundRect(x,y,w,h,r){
  ctx.beginPath();
  ctx.moveTo(x+r,y);
  ctx.lineTo(x+w-r,y);
  ctx.quadraticCurveTo(x+w,y,x+w,y+r);
  ctx.lineTo(x+w,y+h-r);
  ctx.quadraticCurveTo(x+w,y+h,x+w-r,y+h);
  ctx.lineTo(x+r,y+h);
  ctx.quadraticCurveTo(x,y+h,x,y+h-r);
  ctx.lineTo(x,y+r);
  ctx.quadraticCurveTo(x,y,x+r,y);
  ctx.closePath();
}

function arrow(x1,y1,x2,y2,color,lw){
  const dx=x2-x1, dy=y2-y1;
  const len=Math.sqrt(dx*dx+dy*dy);
  if(len<2)return;
  const ux=dx/len, uy=dy/len;
  const hl=Math.min(10,len*0.35);
  ctx.save();
  ctx.strokeStyle=color;
  ctx.fillStyle=color;
  ctx.lineWidth=lw||2;
  ctx.lineCap='round';
  ctx.beginPath();
  ctx.moveTo(x1,y1);
  ctx.lineTo(x2-ux*hl*0.5, y2-uy*hl*0.5);
  ctx.stroke();
  ctx.beginPath();
  ctx.moveTo(x2,y2);
  ctx.lineTo(x2-ux*hl-uy*hl*0.4, y2-uy*hl+ux*hl*0.4);
  ctx.lineTo(x2-ux*hl+uy*hl*0.4, y2-uy*hl-ux*hl*0.4);
  ctx.closePath();
  ctx.fill();
  ctx.restore();
}

/* ---------- 背景 ---------- */
function drawBG(){
  // 渐变背景
  const g = ctx.createLinearGradient(0,0,0,H);
  g.addColorStop(0,'#0a0f1c');
  g.addColorStop(0.5,'#0d1222');
  g.addColorStop(1,'#0a0e18');
  ctx.fillStyle=g;
  ctx.fillRect(0,0,W,H);

  // 点阵网格
  ctx.fillStyle='rgba(60,80,120,0.08)';
  const sp=28;
  const ox=(worldX*0.05)%sp;
  for(let x=-sp+ox;x<W+sp;x+=sp){
    for(let y=sp;y<H;y+=sp){
      ctx.fillRect(x-0.7,y-0.7,1.4,1.4);
    }
  }
}

/* ---------- 地面 ---------- */
function drawGround(){
  // 地面区域
  const gg = ctx.createLinearGradient(0,groundY,0,H);
  gg.addColorStop(0,'#1a1510');
  gg.addColorStop(0.08,'#15110c');
  gg.addColorStop(1,'#0c0a07');
  ctx.fillStyle=gg;
  ctx.fillRect(0,groundY,W,H-groundY);

  // 地面线
  ctx.strokeStyle='#3a2f20';
  ctx.lineWidth=1.5;
  ctx.beginPath();
  ctx.moveTo(0,groundY);
  ctx.lineTo(W,groundY);
  ctx.stroke();

  // 滚动纹理
  ctx.strokeStyle='rgba(80,65,40,0.15)';
  ctx.lineWidth=1;
  const sp=40;
  const off=(worldX*0.8)%sp;
  for(let x=-sp+off;x<W+sp;x+=sp){
    ctx.beginPath();
    ctx.moveTo(x,groundY+4);
    ctx.lineTo(x-6,groundY+12);
    ctx.stroke();
  }

  // 位移刻度尺
  ctx.fillStyle='rgba(76,201,240,0.25)';
  ctx.font='500 9px "JetBrains Mono"';
  ctx.textAlign='center';
  const rulerY=groundY+22;
  const majorSp=100;
  const minorSp=20;
  const startM=Math.floor((worldX-W/2)/minorSp)*minorSp;
  for(let wx=startM;wx<worldX+W;wx+=minorSp){
    const sx=wx-worldX+cx;
    if(sx<-20||sx>W+20)continue;
    const isMajor=(wx%majorSp===0);
    ctx.fillStyle=isMajor?'rgba(76,201,240,0.4)':'rgba(76,201,240,0.12)';
    ctx.fillRect(sx-0.5, groundY+2, 1, isMajor?8:4);
    if(isMajor){
      ctx.fillStyle='rgba(76,201,240,0.35)';
      ctx.fillText((wx/10).toFixed(0), sx, rulerY);
    }
  }
}

/* ---------- 蛇身 ---------- */
function drawSnake(joints){
  if(joints.length<2)return;

  // 身体中心线偏移到 groundY 上方
  const bodyCenterY = groundY - CFG.bodyW*0.5 - 4;

  ctx.save();

  // 整体平移:蛇中心对齐画布中心
  const headX = joints[0].x;
  const tailX = joints[CFG.numSeg].x;
  const snakeMidX = (headX+tailX)/2;
  const offsetX = cx - snakeMidX;

  ctx.translate(offsetX, bodyCenterY);

  // 绘制身体轮廓(上下偏移)
  // 上轮廓
  ctx.beginPath();
  for(let i=0;i<=CFG.numSeg;i++){
    const j=joints[i];
    const nx=-Math.sin(j.ang);
    const ny= Math.cos(j.ang);
    const px=j.x+nx*CFG.bodyW*0.5;
    const py=j.y+ny*CFG.bodyW*0.5;
    if(i===0)ctx.moveTo(px,py);
    else ctx.lineTo(px,py);
  }
  // 下轮廓(反向)
  for(let i=CFG.numSeg;i>=0;i--){
    const j=joints[i];
    const nx=-Math.sin(j.ang);
    const ny= Math.cos(j.ang);
    const px=j.x-nx*CFG.bodyW*0.5;
    const py=j.y-ny*CFG.bodyW*0.5;
    ctx.lineTo(px,py);
  }
  ctx.closePath();

  // 身体渐变填充
  const bg=ctx.createLinearGradient(0,-CFG.bodyW,0,CFG.bodyW);
  bg.addColorStop(0,'#6b7a8e');
  bg.addColorStop(0.35,'#8a9aad');
  bg.addColorStop(0.65,'#5a6878');
  bg.addColorStop(1,'#3e4a58');
  ctx.fillStyle=bg;
  ctx.fill();
  ctx.strokeStyle='#2a3444';
  ctx.lineWidth=1;
  ctx.stroke();

  // 绘制节段分界线
  for(let i=1;i<CFG.numSeg;i++){
    const j=joints[i];
    const nx=-Math.sin(j.ang);
    const ny= Math.cos(j.ang);
    ctx.beginPath();
    ctx.moveTo(j.x+nx*CFG.bodyW*0.45, j.y+ny*CFG.bodyW*0.45);
    ctx.lineTo(j.x-nx*CFG.bodyW*0.45, j.y-ny*CFG.bodyW*0.45);
    ctx.strokeStyle='rgba(30,41,59,0.5)';
    ctx.lineWidth=0.8;
    ctx.stroke();
  }

  // 绘制鳞片
  for(let i=0;i<CFG.numSeg;i++){
    const j1=joints[i], j2=joints[i+1];
    const segAng=Math.atan2(j2.y-j1.y, j2.x-j1.x);
    const midX=(j1.x+j2.x)/2;
    const midY=(j1.y+j2.y)/2;
    const state=segState(i, time);

    // 下法线方向(指向地面)
    const bnx=-Math.sin(segAng);
    const bny= Math.cos(segAng);

    for(let s=0;s<CFG.scalesPerSeg;s++){
      const t_param=(s+0.5)/CFG.scalesPerSeg;
      const sx=j1.x+(j2.x-j1.x)*t_param;
      const sy=j1.y+(j2.y-j1.y)*t_param;

      // 鳞片底部附着点
      const attachX=sx-bnx*CFG.bodyW*0.48;
      const attachY=sy-bny*CFG.bodyW*0.48;

      // 鳞片角度:基于状态
      const baseAngle=segAng + Math.PI/2; // 指向下方
      let sAng;
      if(state.grip>0){
        // 锚定:鳞片向外张开
        sAng=baseAngle + (CFG.scaleAngle*Math.PI/180)*state.grip*0.6;
      } else {
        // 滑行:鳞片顺向折叠
        sAng=baseAngle - (CFG.scaleAngle*Math.PI/180)*0.7 + segAng*0.3*state.slide;
      }

      const tipX=attachX+Math.cos(sAng)*CFG.scaleLen;
      const tipY=attachY+Math.sin(sAng)*CFG.scaleLen;

      // 鳞片宽度
      const perpAng=sAng+Math.PI/2;
      const sw=2.5;

      ctx.beginPath();
      ctx.moveTo(attachX+Math.cos(perpAng)*sw, attachY+Math.sin(perpAng)*sw);
      ctx.lineTo(tipX, tipY);
      ctx.lineTo(attachX-Math.cos(perpAng)*sw, attachY-Math.sin(perpAng)*sw);
      ctx.closePath();

      if(state.grip>0){
        const alpha=0.3+state.grip*0.7;
        ctx.fillStyle=`rgba(245,158,11,${alpha})`;
        ctx.fill();
        // 发光
        if(state.grip>0.4){
          ctx.shadowColor='#f59e0b';
          ctx.shadowBlur=6*state.grip;
          ctx.fill();
          ctx.shadowBlur=0;
        }
      } else {
        const alpha=0.15+state.slide*0.15;
        ctx.fillStyle=`rgba(13,148,136,${alpha})`;
        ctx.fill();
      }
      ctx.strokeStyle='rgba(200,210,225,0.1)';
      ctx.lineWidth=0.5;
      ctx.stroke();
    }
  }

  // 力矢量
  if(CFG.showArrows){
    for(let i=0;i<CFG.numSeg;i++){
      const j1=joints[i], j2=joints[i+1];
      const state=segState(i, time);
      const segAng=Math.atan2(j2.y-j1.y, j2.x-j1.x);
      const midX=(j1.x+j2.x)/2;
      const midY=(j1.y+j2.y)/2;

      if(state.grip>0.3){
        // 锚定段:向后摩擦力(红色)+ 向前推进力(绿色)
        const bnx=-Math.sin(segAng);
        const bny= Math.cos(segAng);
        const baseX=midX-bnx*CFG.bodyW*0.7;
        const baseY=midY-bny*CFG.bodyW*0.7;
        const fLen=18*state.grip;

        // 摩擦力(向后,红色)
        arrow(baseX, baseY,
              baseX-Math.cos(segAng)*fLen,
              baseY-Math.sin(segAng)*fLen,
              `rgba(239,68,68,${0.4+state.grip*0.5})`, 1.8);

        // 推进反力(向前,绿色)
        arrow(baseX, baseY,
              baseX+Math.cos(segAng)*fLen*0.85,
              baseY+Math.sin(segAng)*fLen*0.85,
              `rgba(16,185,129,${0.3+state.grip*0.6})`, 2.2);
      }
    }
  }

  // 蛇头
  const hj=joints[0];
  ctx.save();
  ctx.translate(hj.x, hj.y);
  ctx.rotate(hj.ang);
  // 头部形状
  ctx.beginPath();
  ctx.moveTo(CFG.bodyW*0.5, 0);
  ctx.quadraticCurveTo(CFG.bodyW*0.15, -CFG.bodyW*0.55, -CFG.bodyW*0.3, -CFG.bodyW*0.42);
  ctx.lineTo(-CFG.bodyW*0.3, CFG.bodyW*0.42);
  ctx.quadraticCurveTo(CFG.bodyW*0.15, CFG.bodyW*0.55, CFG.bodyW*0.5, 0);
  ctx.closePath();
  const headG=ctx.createRadialGradient(0,0,2,0,0,CFG.bodyW*0.5);
  headG.addColorStop(0,'#a0b0c4');
  headG.addColorStop(1,'#5a6a7e');
  ctx.fillStyle=headG;
  ctx.fill();
  ctx.strokeStyle='#3a4a5a';
  ctx.lineWidth=0.8;
  ctx.stroke();
  // 眼睛
  ctx.fillStyle='#ef4444';
  ctx.beginPath();ctx.arc(CFG.bodyW*0.12,-CFG.bodyW*0.18,2.2,0,Math.PI*2);ctx.fill();
  ctx.beginPath();ctx.arc(CFG.bodyW*0.12, CFG.bodyW*0.18,2.2,0,Math.PI*2);ctx.fill();
  ctx.fillStyle='#1a1a2e';
  ctx.beginPath();ctx.arc(CFG.bodyW*0.14,-CFG.bodyW*0.18,1,0,Math.PI*2);ctx.fill();
  ctx.beginPath();ctx.arc(CFG.bodyW*0.14, CFG.bodyW*0.18,1,0,Math.PI*2);ctx.fill();
  ctx.restore();

  // 蛇尾
  const tj=joints[CFG.numSeg];
  ctx.save();
  ctx.translate(tj.x, tj.y);
  ctx.rotate(tj.ang);
  ctx.beginPath();
  ctx.moveTo(0,-CFG.bodyW*0.35);
  ctx.lineTo(CFG.bodyW*0.6,0);
  ctx.lineTo(0,CFG.bodyW*0.35);
  ctx.closePath();
  ctx.fillStyle='#4a5a6a';
  ctx.fill();
  ctx.restore();

  ctx.restore(); // 整体平移
}

/* ---------- 波形信号面板 ---------- */
function drawSignalPanel(){
  const pw=180, ph=60;
  const px=W-pw-18, py=14;

  // 面板背景
  ctx.fillStyle='rgba(10,15,28,0.85)';
  roundRect(px,py,pw,ph,6);
  ctx.fill();
  ctx.strokeStyle='rgba(76,201,240,0.2)';
  ctx.lineWidth=1;
  ctx.stroke();

  // 标签
  ctx.fillStyle='rgba(76,201,240,0.6)';
  ctx.font='500 9px "JetBrains Mono"';
  ctx.textAlign='left';
  ctx.fillText('INPUT SIGNAL', px+8, py+12);

  // 正弦波
  ctx.beginPath();
  ctx.strokeStyle='#10b981';
  ctx.lineWidth=1.5;
  const wX=px+8, wY=py+36, wW=pw-16, wH=18;
  for(let i=0;i<=wW;i++){
    const t_local=i/wW*4*Math.PI;
    const val=Math.sin(t_local - 2*Math.PI*CFG.waveFreq*time*2);
    const y=wY-val*wH*0.4;
    if(i===0)ctx.moveTo(wX+i,y);else ctx.lineTo(wX+i,y);
  }
  ctx.stroke();

  // 扫描线
  const scanX=wX+((time*CFG.waveFreq*80)%wW);
  ctx.strokeStyle='rgba(16,185,129,0.5)';
  ctx.lineWidth=1;
  ctx.beginPath();
  ctx.moveTo(scanX,wY-wH);
  ctx.lineTo(scanX,wY+wH);
  ctx.stroke();
}

/* ---------- 鳞片机理详图 ---------- */
function drawDetailInset(){
  const iw=200, ih=140;
  const ix=16, iy=H-ih-12;

  // 背景
  ctx.fillStyle='rgba(10,15,28,0.9)';
  roundRect(ix,iy,iw,ih,8);
  ctx.fill();
  ctx.strokeStyle='rgba(76,201,240,0.15)';
  ctx.lineWidth=1;
  ctx.stroke();

  // 标题
  ctx.fillStyle='rgba(76,201,240,0.6)';
  ctx.font='500 9px "JetBrains Mono"';
  ctx.textAlign='left';
  ctx.fillText('SCALE MECHANISM', ix+8, iy+14);

  const cy=iy+50;
  const segW=70;

  // === 锚定状态 ===
  const ax=ix+35;
  // 蛇身截面
  ctx.fillStyle='#5a6878';
  roundRect(ax-25,cy-8,50,16,3);
  ctx.fill();
  ctx.strokeStyle='#3a4a5a';
  ctx.lineWidth=0.8;
  ctx.stroke();

  // 鳞片张开
  const gripPhase=(Math.sin(time*3)*0.5+0.5);
  const sAng1=Math.PI/2 + CFG.scaleAngle*Math.PI/180*0.6*(0.6+gripPhase*0.4);
  const sLen=22;
  ctx.beginPath();
  ctx.moveTo(ax-4, cy+8);
  ctx.lineTo(ax-4+Math.cos(sAng1)*sLen, cy+8+Math.sin(sAng1)*sLen);
  ctx.lineTo(ax+4+Math.cos(sAng1)*sLen, cy+8+Math.sin(sAng1)*sLen);
  ctx.lineTo(ax+4, cy+8);
  ctx.closePath();
  ctx.fillStyle=`rgba(245,158,11,${0.5+gripPhase*0.5})`;
  ctx.fill();
  if(gripPhase>0.3){
    ctx.shadowColor='#f59e0b';
    ctx.shadowBlur=5;
    ctx.fill();
    ctx.shadowBlur=0;
  }

  // 地面线
  ctx.strokeStyle='#3a2f20';
  ctx.lineWidth=1;
  ctx.beginPath();
  ctx.moveTo(ax-30,cy+22);
  ctx.lineTo(ax+30,cy+22);
  ctx.stroke();

  // 锚定标记
  ctx.fillStyle='rgba(245,158,11,0.7)';
  ctx.font='700 8px "Rajdhani"';
  ctx.textAlign='center';
  ctx.fillText('ANCHOR',ax,cy+35);

  // 摩擦力箭头
  arrow(ax-5,cy+20,ax-20,cy+20,`rgba(239,68,68,${0.5+gripPhase*0.3})`,1.5);
  // 推力箭头
  arrow(ax+5,cy+14,ax+22,cy+14,`rgba(16,185,129,${0.5+gripPhase*0.3})`,1.5);

  // === 滑行状态 ===
  const bx=ix+145;
  // 蛇身截面
  ctx.fillStyle='#5a6878';
  roundRect(bx-25,cy-8,50,16,3);
  ctx.fill();
  ctx.strokeStyle='#3a4a5a';
  ctx.lineWidth=0.8;
  ctx.stroke();

  // 鳞片折叠
  const slidePhase=(Math.cos(time*3)*0.5+0.5);
  const sAng2=Math.PI/2 - CFG.scaleAngle*Math.PI/180*0.5 - slidePhase*0.3;
  ctx.beginPath();
  ctx.moveTo(bx-3, cy+8);
  ctx.lineTo(bx-3+Math.cos(sAng2)*sLen*0.8, cy+8+Math.sin(sAng2)*sLen*0.8);
  ctx.lineTo(bx+3+Math.cos(sAng2)*sLen*0.8, cy+8+Math.sin(sAng2)*sLen*0.8);
  ctx.lineTo(bx+3, cy+8);
  ctx.closePath();
  ctx.fillStyle=`rgba(13,148,136,${0.2+slidePhase*0.15})`;
  ctx.fill();

  // 地面线
  ctx.strokeStyle='#3a2f20';
  ctx.lineWidth=1;
  ctx.beginPath();
  ctx.moveTo(bx-30,cy+22);
  ctx.lineTo(bx+30,cy+22);
  ctx.stroke();

  // 滑行标记
  ctx.fillStyle='rgba(13,148,136,0.7)';
  ctx.font='700 8px "Rajdhani"';
  ctx.textAlign='center';
  ctx.fillText('GLIDE',bx,cy+35);

  // 滑行方向箭头
  arrow(bx-5,cy+14,bx+18,cy+14,`rgba(13,148,136,${0.3+slidePhase*0.2})`,1.3);

  // 底部说明
  ctx.fillStyle='rgba(200,210,225,0.3)';
  ctx.font='300 8px "JetBrains Mono"';
  ctx.textAlign='center';
  ctx.fillText('← push: scale opens & grips    slide: scale folds →',ix+iw/2,iy+ih-8);
}

/* ---------- IFR 原理标注 ---------- */
function drawAnnotations(joints){
  const headX=cx; // 蛇头在画布中心附近

  // 前进方向大箭头
  const arrY=groundY - CFG.bodyW - 30;
  const arrLen=50;
  ctx.save();
  ctx.globalAlpha=0.25+Math.sin(time*4)*0.1;
  arrow(headX-arrLen, arrY, headX+arrLen, arrY, '#4cc9f0', 2.5);
  ctx.restore();

  ctx.fillStyle='rgba(76,201,240,0.4)';
  ctx.font='500 10px "JetBrains Mono"';
  ctx.textAlign='center';
  ctx.fillText('FORWARD', headX, arrY-8);

  // 波传播方向
  const waveArrY=groundY + 50;
  ctx.save();
  ctx.globalAlpha=0.2;
  arrow(cx+60, waveArrY, cx-60, waveArrY, '#f59e0b', 1.5);
  ctx.restore();
  ctx.fillStyle='rgba(245,158,11,0.3)';
  ctx.font='400 9px "JetBrains Mono"';
  ctx.textAlign='center';
  ctx.fillText('WAVE PROPAGATION ←', cx, waveArrY-6);
}

/* ---------- 位移追踪线 ---------- */
let displHistory=[];
const maxDisplHist=200;
function drawDisplacementTrace(){
  // 记录蛇头位移
  const joints=computeJoints(time);
  const headWorldX=worldX;
  displHistory.push(headWorldX);
  if(displHistory.length>maxDisplHist)displHistory.shift();

  // 在画布底部绘制位移-时间曲线
  const graphH=30;
  const graphY=H-graphH-8;
  const graphW=W*0.4;
  const graphX=W*0.55;

  ctx.fillStyle='rgba(10,15,28,0.6)';
  roundRect(graphX,graphY-5,graphW,graphH+10,4);
  ctx.fill();

  ctx.fillStyle='rgba(76,201,240,0.35)';
  ctx.font='500 8px "JetBrains Mono"';
  ctx.textAlign='left';
  ctx.fillText('DISPLACEMENT',graphX+6,graphY+4);

  if(displHistory.length>2){
    const minD=displHistory[0];
    const maxD=displHistory[displHistory.length-1];
    const range=Math.max(maxD-minD,1);

    ctx.beginPath();
    ctx.strokeStyle='rgba(16,185,129,0.6)';
    ctx.lineWidth=1.5;
    for(let i=0;i<displHistory.length;i++){
      const x=graphX+6+(i/(maxDisplHist-1))*(graphW-12);
      const y=graphY+graphH-2-((displHistory[i]-minD)/range)*(graphH-10);
      if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);
    }
    ctx.stroke();
  }
}

/* ---------- 主动画循环 ---------- */
function animate(ts){
  if(!lastTS)lastTS=ts;
  const dt=Math.min((ts-lastTS)/1000, 0.05);
  lastTS=ts;

  // 前进速度与波参数关联
  const fwdSpeed=CFG.waveAmp*CFG.waveFreq*0.32*CFG.segLen/18;
  worldX+=fwdSpeed*60*dt;
  time+=dt;

  // 计算关节
  const joints=computeJoints(time);

  // 清空画布
  ctx.clearRect(0,0,W,H);

  // 绘制各层
  drawBG();
  drawGround();
  drawAnnotations(joints);
  drawSnake(joints);
  drawSignalPanel();
  drawDetailInset();
  drawDisplacementTrace();

  requestAnimationFrame(animate);
}

/* ---------- 启动 ---------- */
document.addEventListener('DOMContentLoaded', ()=>{
  resize();
  requestAnimationFrame(animate);
});

/* 重开即播:处理 pageshow 事件(含 bfcache 恢复) */
window.addEventListener('pageshow', ()=>{
  lastTS=0;
  time=0;
  worldX=0;
  displHistory=[];
});
</script>
</body>
</html>

实现说明:

  1. 核心原理可视化:蛇身由 14 节刚性连杆通过关节链式串联,关节角度服从行波方程 θ_i(t) = A·sin(2πft - ki)。鳞片状态(锚定/滑行)由关节角速度方向自动判定——角速度为正时鳞片张开抓地(琥珀色高亮+辉光),角速度为负时鳞片折叠滑行(青色淡化),无需任何软件闭环控制。

  2. IFR 体现:画面直接展示"正弦波信号输入 → 物理结构自锁 → 前进位移输出"的完整链路。右上角信号面板显示简单正弦输入,右下角位移曲线显示匀速前进输出——中间零算法开销,由鳞片的单向力学特性自动完成摩擦不对称转换。

  3. 视觉引导

    • 鳞片辉光:锚定态鳞片以琥珀色发光标记,滑行态以暗青色淡化,对比鲜明
    • 力矢量:锚定段同时显示红色摩擦力(向后)和绿色推进力(向前),直观展示力学转化
    • 波形传播:底部标注波传播方向(向后)与蛇前进方向(向前)的对比
  4. 交互控制:四个滑块分别控制波动频率、幅度、鳞片后掠角和力矢量显示开关,调节任一参数即可实时观察蛇身步态和推进效率的变化。

  5. 细节插图:左下角鳞片机理详图以截面视角动态演示"锚定→鳞片张开抓地"与"滑行→鳞片折叠顺滑"两种状态的交替过程。

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