<!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 思想——摩擦力即推进力。
核心视觉机制:
- 蜿蜒波传递:14 段气腔按正弦行波依次充气(青绿色发光),从尾部向头部传播,形成可见的横向蜿蜒
- 棘刺抓地高亮:当某段弯曲时,该侧底部微棘刺自动变为橙色发光,表示"抓地";另一侧保持暗灰表示"滑动"——这是各向异性摩擦力的可视化
- 力矢量转换:黄色箭头(推进力 F推)从抓地点指向前方,橙色虚线箭头(横向力 F侧)指向弯曲内侧,清晰展示"横向弯曲力 → 抓地反力 → 向前推进"的转换链
- 截面详图(左下角):实时显示第 7 段的横截面,包括气腔膨胀/收缩、30° 微棘刺抓地/滑动状态、力转换示意
交互控制:
- 播放/暂停(空格键):控制动画运行
- 波速滑块:调整蜿蜒波传播速度
- 气压滑块:调整 0.15~0.25 MPa 工作气压,直接影响弯曲幅度和推进速度
- 力矢量开关(F 键):显示/隐藏力箭头
- 轨迹开关(T 键):显示头部运动轨迹
积分规则:第一轮对话扣减6分,后续每轮扣4分
等待动画代码生成...
