<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>仿生腱绳传动 · IFR原理动画</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#060a12;--panel:rgba(8,14,26,0.88);--border:#12233d;
--text:#b8c8dc;--text2:#5a7394;--text3:#2d4262;
--amber:#e8a838;--amber-g:rgba(232,168,56,0.25);
--cyan:#00ddb5;--cyan-g:rgba(0,221,181,0.22);
--red:#ff4433;--green:#22ff88;
--metal:#1c2840;--metal2:#2a3d5c;
}
html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--text);font-family:'Share Tech Mono',monospace}
#app{width:100%;height:100%;position:relative}
#svgWrap{position:fixed;inset:0;z-index:0}
#mainSvg{width:100%;height:100%}
/* 标题栏 */
#topBar{position:fixed;top:0;left:0;right:0;z-index:20;display:flex;align-items:center;justify-content:space-between;padding:10px 24px;background:linear-gradient(180deg,rgba(6,10,18,0.95),rgba(6,10,18,0));pointer-events:none}
#topBar h1{font-family:'Orbitron',sans-serif;font-size:15px;font-weight:700;letter-spacing:2px;color:var(--amber);text-transform:uppercase}
#topBar .sub{font-size:11px;color:var(--text2);margin-left:12px;letter-spacing:1px}
.ifr-badge{background:linear-gradient(135deg,var(--cyan),#0088aa);color:#000;font-family:'Orbitron',sans-serif;font-size:10px;font-weight:900;padding:3px 10px;border-radius:3px;letter-spacing:2px}
/* 面板通用 */
.panel{position:fixed;z-index:15;width:250px;background:var(--panel);border:1px solid var(--border);border-radius:8px;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);padding:16px;font-size:12px}
.panel h3{font-family:'Orbitron',sans-serif;font-size:10px;font-weight:700;letter-spacing:2px;color:var(--text2);text-transform:uppercase;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid var(--border)}
/* 左面板 */
#phasePanel{top:56px;left:16px}
.phase-name{font-family:'Orbitron',sans-serif;font-size:18px;font-weight:900;margin-bottom:6px;transition:color 0.4s}
.phase-name.swing{color:var(--amber)}
.phase-name.impact{color:var(--red)}
.phase-name.pushoff{color:var(--green)}
.phase-name.transition{color:var(--text2)}
.phase-desc{font-size:11px;color:var(--text2);line-height:1.6;margin-bottom:14px;min-height:50px}
.ifr-insight{background:rgba(0,221,181,0.06);border-left:2px solid var(--cyan);padding:8px 10px;font-size:11px;color:var(--cyan);line-height:1.5;border-radius:0 4px 4px 0;min-height:48px;transition:all 0.4s}
.energy-row{display:flex;align-items:center;margin-bottom:8px;gap:8px}
.energy-label{font-size:10px;color:var(--text2);width:60px;flex-shrink:0}
.energy-track{flex:1;height:6px;background:var(--metal);border-radius:3px;overflow:hidden;position:relative}
.energy-fill{height:100%;border-radius:3px;transition:width 0.08s}
.energy-fill.spring-fill{background:linear-gradient(90deg,var(--cyan),#00ffcc)}
.energy-fill.motor-fill{background:linear-gradient(90deg,var(--amber),#ffcc44)}
.energy-val{font-size:10px;color:var(--text2);width:50px;text-align:right;flex-shrink:0}
/* 右面板 */
#controlPanel{top:56px;right:16px}
.ctrl-row{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.ctrl-row label{font-size:10px;color:var(--text2);width:64px;flex-shrink:0}
.ctrl-row input[type=range]{flex:1;-webkit-appearance:none;height:4px;background:var(--metal);border-radius:2px;outline:none}
.ctrl-row input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--amber);cursor:pointer;border:2px solid var(--bg)}
.ctrl-row .val{font-size:10px;color:var(--text);width:56px;text-align:right;flex-shrink:0}
.btn-row{display:flex;gap:8px;margin-bottom:12px}
.btn-row button{flex:1;padding:7px 0;border:1px solid var(--border);background:var(--metal);color:var(--text);border-radius:4px;cursor:pointer;font-family:'Share Tech Mono',monospace;font-size:12px;transition:all 0.2s}
.btn-row button:hover{background:var(--metal2);border-color:var(--amber)}
.btn-row button.active{background:rgba(232,168,56,0.15);border-color:var(--amber);color:var(--amber)}
.data-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.data-item{background:var(--metal);border-radius:4px;padding:6px 8px}
.data-item .dl{font-size:9px;color:var(--text2);display:block;margin-bottom:2px}
.data-item .dv{font-size:13px;font-weight:bold;color:var(--text)}
.toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}
.toggle-row label{font-size:11px;color:var(--text2)}
.toggle{position:relative;width:36px;height:18px;cursor:pointer}
.toggle input{opacity:0;width:0;height:0}
.toggle .slider{position:absolute;inset:0;background:var(--metal);border-radius:9px;transition:0.3s}
.toggle .slider::before{content:'';position:absolute;width:14px;height:14px;left:2px;top:2px;background:var(--text2);border-radius:50%;transition:0.3s}
.toggle input:checked+.slider{background:rgba(0,221,181,0.3)}
.toggle input:checked+.slider::before{transform:translateX(18px);background:var(--cyan)}
/* 底部时间轴 */
#timeline{position:fixed;bottom:0;left:0;right:0;z-index:15;padding:12px 24px 16px;background:linear-gradient(0deg,rgba(6,10,18,0.95),rgba(6,10,18,0))}
#tlTrack{position:relative;height:6px;background:var(--metal);border-radius:3px;cursor:pointer;margin-bottom:8px}
#tlProgress{position:absolute;left:0;top:0;height:100%;border-radius:3px;background:linear-gradient(90deg,var(--cyan),var(--amber));transition:width 0.05s;pointer-events:none}
#tlHead{position:absolute;top:50%;width:14px;height:14px;border-radius:50%;background:var(--amber);border:2px solid var(--bg);transform:translate(-50%,-50%);pointer-events:none;transition:left 0.05s}
.tl-phases{display:flex;justify-content:space-between;font-size:9px;color:var(--text2);letter-spacing:1px}
.tl-phases span{position:relative}
.tl-phases span::before{content:'';position:absolute;top:-12px;left:50%;width:1px;height:6px;background:var(--text3)}
/* 惯量对比小图 */
.inertia-compare{display:flex;gap:8px;margin-top:10px;align-items:flex-end}
.inertia-bar-wrap{flex:1;text-align:center}
.inertia-bar{width:100%;border-radius:3px 3px 0 0;transition:height 0.4s}
.inertia-bar.trad{background:var(--red);opacity:0.5}
.inertia-bar.ifr{background:var(--cyan)}
.inertia-lbl{font-size:8px;color:var(--text2);margin-top:3px}
.inertia-pct{font-size:10px;font-weight:bold;margin-top:1px}
.inertia-pct.red{color:var(--red)}
.inertia-pct.grn{color:var(--cyan)}
@media(max-width:900px){
.panel{width:200px;padding:10px;font-size:11px}
.phase-name{font-size:14px}
.data-grid{grid-template-columns:1fr}
}
@media(max-width:640px){
.panel{width:160px;padding:8px}
#phasePanel{left:8px}#controlPanel{right:8px}
}
</style>
</head>
<body>
<div id="app">
<div id="topBar">
<div style="display:flex;align-items:baseline">
<h1>Bionic Tendon-Drive Leg</h1>
<span class="sub">仿生腱绳传动腿部机构</span>
</div>
<span class="ifr-badge">IFR</span>
</div>
<div id="svgWrap">
<svg id="mainSvg" viewBox="0 0 1000 650" preserveAspectRatio="xMidYMid meet">
<defs>
<!-- 网格图案 -->
<pattern id="gridP" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#0c1628" stroke-width="0.5"/>
</pattern>
<!-- 发光滤镜 -->
<filter id="glowA" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="glowC" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="6" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="glowR" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="5" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<!-- 渐变 -->
<linearGradient id="bodyGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#1e2d48"/><stop offset="100%" stop-color="#0f1a2e"/>
</linearGradient>
<linearGradient id="groundGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#0a1220"/><stop offset="100%" stop-color="#060a12"/>
</linearGradient>
</defs>
<!-- 背景网格 -->
<rect width="1000" height="650" fill="url(#gridP)" opacity="0.7"/>
<!-- 地面 -->
<rect id="groundRect" x="0" y="560" width="1000" height="90" fill="url(#groundGrad)"/>
<line id="groundLine" x1="0" y1="560" x2="1000" y2="560" stroke="#1a3050" stroke-width="1.5"/>
<g id="groundMarkers"></g>
<!-- 对侧腿(背景) -->
<g id="leg2" opacity="0.18">
<line id="l2thigh" stroke="#4a5a70" stroke-width="8" stroke-linecap="round"/>
<line id="l2calf" stroke="#4a5a70" stroke-width="6" stroke-linecap="round"/>
<circle id="l2foot" r="5" fill="#4a5a70"/>
</g>
<!-- 力矢量 -->
<g id="forceArrows">
<g id="impactArrow" opacity="0">
<line stroke="var(--red)" stroke-width="2.5"/>
<polygon fill="var(--red)"/>
</g>
<g id="pushoffArrow" opacity="0">
<line stroke="var(--green)" stroke-width="2.5"/>
<polygon fill="var(--green)"/>
</g>
</g>
<!-- 能量流粒子 -->
<g id="particles"></g>
<!-- 主腿组件 -->
<g id="legAssembly">
<!-- 机身 -->
<g id="bodyGroup">
<rect id="chassis" x="-105" y="-30" width="210" height="56" rx="8" fill="url(#bodyGrad)" stroke="#2a3d5c" stroke-width="1.5"/>
<!-- 电机图标 -->
<g id="hipMotorIcon" transform="translate(-40,0)">
<circle r="14" fill="#0f1a2e" stroke="var(--amber)" stroke-width="1.2" opacity="0.8"/>
<circle r="5" fill="var(--amber)" opacity="0.4"/>
<line x1="-3" y1="0" x2="3" y2="0" stroke="var(--amber)" stroke-width="1.5" opacity="0.7"/>
<text x="0" y="-19" text-anchor="middle" fill="var(--amber)" font-size="7" font-family="Orbitron" opacity="0.6">HIP</text>
</g>
<g id="kneeMotorIcon" transform="translate(40,0)">
<circle r="14" fill="#0f1a2e" stroke="var(--amber)" stroke-width="1.2" opacity="0.8"/>
<circle r="5" fill="var(--amber)" opacity="0.4"/>
<line x1="-3" y1="0" x2="3" y2="0" stroke="var(--amber)" stroke-width="1.5" opacity="0.7"/>
<text x="0" y="-19" text-anchor="middle" fill="var(--amber)" font-size="7" font-family="Orbitron" opacity="0.6">KNEE</text>
</g>
</g>
<!-- 髋关节 -->
<circle id="hipJoint" r="8" fill="#0f1a2e" stroke="#3a5a80" stroke-width="2"/>
<circle id="hipPulley" r="5" fill="none" stroke="var(--amber)" stroke-width="1" opacity="0.5"/>
<!-- 腱绳 - 髋关节线 -->
<path id="hipCable" fill="none" stroke="var(--amber)" stroke-width="2" opacity="0.7" stroke-linecap="round"/>
<!-- 大腿 -->
<g id="thighGroup">
<rect id="thighRod" x="-5" width="10" rx="3" fill="#151c28" stroke="#2a3548" stroke-width="1"/>
<!-- 碳纤维纹理线 -->
<line id="thighTex1" stroke="#1e2a3a" stroke-width="0.5" opacity="0.6"/>
<line id="thighTex2" stroke="#1e2a3a" stroke-width="0.5" opacity="0.6"/>
</g>
<!-- 腱绳 - 膝关节线(穿过大腿内部) -->
<path id="kneeCable" fill="none" stroke="var(--amber)" stroke-width="2.2" opacity="0.8" stroke-linecap="round"/>
<!-- 膝关节 -->
<circle id="kneeJoint" r="7" fill="#0f1a2e" stroke="#3a5a80" stroke-width="1.8"/>
<circle id="kneePulley" r="5" fill="none" stroke="var(--amber)" stroke-width="1" opacity="0.5"/>
<!-- 小腿刚性段 -->
<g id="calfGroup">
<rect id="calfRod" x="-4.5" width="9" rx="2.5" fill="#151c28" stroke="#2a3548" stroke-width="1"/>
</g>
<!-- 串联弹簧 -->
<path id="springPath" fill="none" stroke="var(--cyan)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<!-- 弹簧发光背景 -->
<path id="springGlow" fill="none" stroke="var(--cyan)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0" filter="url(#glowC)"/>
<!-- 足端 -->
<g id="footGroup">
<rect id="footPlate" x="-12" width="24" height="6" rx="2" fill="#1a2538" stroke="#3a5a80" stroke-width="1"/>
<circle id="footSensor" r="3" fill="var(--cyan)" opacity="0.3"/>
</g>
</g>
<!-- 标注文字 -->
<g id="annotations" font-family="'Share Tech Mono',monospace" font-size="10">
<text id="annoSwing" fill="var(--amber)" opacity="0"></text>
<text id="annoImpact" fill="var(--red)" opacity="0"></text>
<text id="annoSpring" fill="var(--cyan)" opacity="0"></text>
</g>
<!-- 相位标签 (SVG内) -->
<g id="svgPhaseLabel" font-family="Orbitron,sans-serif">
<text id="svgPhaseText" x="500" y="620" text-anchor="middle" fill="var(--text2)" font-size="10" letter-spacing="3" opacity="0.5"></text>
</g>
</svg>
</div>
<!-- 左面板 -->
<div class="panel" id="phasePanel">
<h3>Gait Phase</h3>
<div id="phaseName" class="phase-name swing">摆动相</div>
<div id="phaseDesc" class="phase-desc">电机快速收放腱绳,弹簧自由伸长,腿部轻巧前摆</div>
<h3>IFR Insight</h3>
<div id="ifrInsight" class="ifr-insight">电机上置 → 转动惯量↓65% → 摆动加速,无额外控制负担</div>
<h3>Energy</h3>
<div class="energy-row">
<span class="energy-label">弹簧储能</span>
<div class="energy-track"><div id="springFill" class="energy-fill spring-fill" style="width:0%"></div></div>
<span id="springEV" class="energy-val">0 mJ</span>
</div>
<div class="energy-row">
<span class="energy-label">电机出力</span>
<div class="energy-track"><div id="motorFill" class="energy-fill motor-fill" style="width:0%"></div></div>
<span id="motorEV" class="energy-val">0 N</span>
</div>
<h3>Inertia Compare</h3>
<div class="inertia-compare">
<div class="inertia-bar-wrap">
<div class="inertia-bar trad" style="height:48px"></div>
<div class="inertia-lbl">传统</div>
<div class="inertia-pct red">100%</div>
</div>
<div class="inertia-bar-wrap">
<div class="inertia-bar ifr" style="height:17px"></div>
<div class="inertia-lbl">IFR</div>
<div class="inertia-pct grn">35%</div>
</div>
</div>
</div>
<!-- 右面板 -->
<div class="panel" id="controlPanel">
<h3>Control</h3>
<div class="btn-row">
<button id="playBtn"><i class="fas fa-pause"></i> 暂停</button>
<button id="resetBtn"><i class="fas fa-redo"></i> 复位</button>
</div>
<div class="ctrl-row">
<label>速度</label>
<input type="range" id="speedSl" min="10" max="200" value="60">
<span id="speedV" class="val">0.6x</span>
</div>
<div class="ctrl-row">
<label>弹簧刚度</label>
<input type="range" id="springKSl" min="40" max="160" value="80">
<span id="springKV" class="val">80 N/mm</span>
</div>
<div class="ctrl-row">
<label>预压缩</label>
<input type="range" id="preCompSl" min="0" max="10" value="5">
<span id="preCompV" class="val">5 mm</span>
</div>
<h3>Realtime Data</h3>
<div class="data-grid">
<div class="data-item"><span class="dl">髋角</span><span id="dHip" class="dv">0°</span></div>
<div class="data-item"><span class="dl">膝角</span><span id="dKnee" class="dv">0°</span></div>
<div class="data-item"><span class="dl">压缩量</span><span id="dComp" class="dv">0 mm</span></div>
<div class="data-item"><span class="dl">弹簧力</span><span id="dForce" class="dv">0 N</span></div>
<div class="data-item"><span class="dl">腱绳力</span><span id="dTendon" class="dv">0 N</span></div>
<div class="data-item"><span class="dl">足端力</span><span id="dFoot" class="dv">0 N</span></div>
</div>
<h3>Display</h3>
<div class="toggle-row">
<label>力矢量</label>
<label class="toggle"><input type="checkbox" id="tgForce" checked><span class="slider"></span></label>
</div>
<div class="toggle-row">
<label>能量流</label>
<label class="toggle"><input type="checkbox" id="tgFlow" checked><span class="slider"></span></label>
</div>
<div class="toggle-row">
<label>对侧腿</label>
<label class="toggle"><input type="checkbox" id="tgLeg2" checked><span class="slider"></span></label>
</div>
</div>
<!-- 时间轴 -->
<div id="timeline">
<div id="tlTrack">
<div id="tlProgress" style="width:0%"></div>
<div id="tlHead" style="left:0%"></div>
</div>
<div class="tl-phases">
<span>摆动相</span><span>触地冲击</span><span>储能释放</span><span>过渡</span>
</div>
</div>
</div>
<script>
/* ========== 工具函数 ========== */
const lerp = (a,b,t) => a + (b - a) * t;
const clamp = (v,lo,hi) => Math.max(lo, Math.min(hi, v));
const smoothstep = t => { t = clamp(t,0,1); return t*t*(3-2*t); };
const deg2rad = d => d * Math.PI / 180;
/* ========== 状态 ========== */
const S = {
t: 0, playing: true, speed: 0.6,
springK: 80, preComp: 5,
showForce: true, showFlow: true, showLeg2: true,
lastTime: 0
};
/* ========== 常量 ========== */
const HIP_X = 500, HIP_BASE_Y = 175;
const THIGH_LEN = 170, CALF_RIGID = 105, SPRING_REST = 60;
const SPRING_MAX_PX = 28; // 最大视觉压缩像素
const SPRING_COILS = 7, SPRING_AMP = 8;
const GROUND_Y = 558;
/* ========== 步态计算 ========== */
function getGait(t) {
let hip, knee, comp, phase, pp;
if (t < 0.33) {
// 摆动相
const p = t / 0.33;
hip = lerp(-18, 25, smoothstep(p));
knee = p < 0.45 ? lerp(15, 78, smoothstep(p/0.45)) : lerp(78, 10, smoothstep((p-0.45)/0.55));
comp = 0; phase = 'swing'; pp = p;
} else if (t < 0.52) {
// 支撑初期 - 冲击吸收
const p = (t - 0.33) / 0.19;
hip = lerp(25, 6, smoothstep(p));
knee = lerp(10, 32, smoothstep(p));
comp = smoothstep(p); phase = 'impact'; pp = p;
} else if (t < 0.82) {
// 支撑末期 - 蹬地释放
const p = (t - 0.52) / 0.30;
hip = lerp(6, -18, smoothstep(p));
knee = lerp(32, 6, smoothstep(p));
comp = 1 - smoothstep(p); phase = 'pushoff'; pp = p;
} else {
// 过渡 - 趾离
const p = (t - 0.82) / 0.18;
hip = lerp(-18, -18, p);
knee = lerp(6, 15, p);
comp = 0; phase = 'transition'; pp = p;
}
return { hip, knee, comp, phase, pp };
}
/* 身体垂直偏移 */
function getBodyY(t) {
if (t < 0.33) return -16;
if (t < 0.42) { const p=(t-0.33)/0.09; return lerp(-16, 8, smoothstep(p)); }
if (t < 0.62) { const p=(t-0.42)/0.20; return lerp(8, 2, smoothstep(p)); }
if (t < 0.82) { const p=(t-0.62)/0.20; return lerp(2, -16, smoothstep(p)); }
return -16;
}
/* ========== 位置计算 ========== */
function calcPositions(gait, bodyYOff) {
const hx = HIP_X, hy = HIP_BASE_Y + bodyYOff;
const tRad = deg2rad(gait.hip);
const kx = hx + THIGH_LEN * Math.sin(tRad);
const ky = hy + THIGH_LEN * Math.cos(tRad);
const cRad = deg2rad(gait.hip - gait.knee);
const compPx = gait.comp * SPRING_MAX_PX * (80 / S.springK);
const curSpringLen = Math.max(8, SPRING_REST - compPx);
const sTopX = kx + CALF_RIGID * Math.sin(cRad);
const sTopY = ky + CALF_RIGID * Math.cos(cRad);
const footX = sTopX + curSpringLen * Math.sin(cRad);
const footY = sTopY + curSpringLen * Math.cos(cRad);
return { hx, hy, kx, ky, sTopX, sTopY, footX, footY, tRad, cRad, compPx, curSpringLen };
}
/* ========== 弹簧路径生成 ========== */
function springPath(sx, sy, ex, ey, coils, amp) {
const dx = ex-sx, dy = ey-sy;
const len = Math.sqrt(dx*dx+dy*dy);
if (len < 4) return `M${sx},${sy}L${ex},${ey}`;
const ux=dx/len, uy=dy/len, nx=-uy, ny=ux;
const str = len * 0.07;
const zLen = len - 2*str;
const step = zLen / (coils*2);
let d = `M${sx.toFixed(1)},${sy.toFixed(1)}`;
let cx = sx + str*ux, cy = sy + str*uy;
d += ` L${cx.toFixed(1)},${cy.toFixed(1)}`;
for (let i=0; i<coils*2; i++) {
const side = (i%2===0)?1:-1;
cx += step*ux; cy += step*uy;
d += ` L${(cx+side*amp*nx).toFixed(1)},${(cy+side*amp*ny).toFixed(1)}`;
}
d += ` L${ex.toFixed(1)},${ey.toFixed(1)}`;
return d;
}
/* ========== SVG元素引用 ========== */
const $ = id => document.getElementById(id);
const svgEl = {
bodyG: $('bodyGroup'), chassis: $('chassis'),
hipMotor: $('hipMotorIcon'), kneeMotor: $('kneeMotorIcon'),
hipJoint: $('hipJoint'), hipPulley: $('hipPulley'),
thighG: $('thighGroup'), thighRod: $('thighRod'),
thighT1: $('thighTex1'), thighT2: $('thighTex2'),
hipCable: $('hipCable'), kneeCable: $('kneeCable'),
kneeJoint: $('kneeJoint'), kneePulley: $('kneePulley'),
calfG: $('calfGroup'), calfRod: $('calfRod'),
springPath: $('springPath'), springGlow: $('springGlow'),
footG: $('footGroup'), footPlate: $('footPlate'), footSensor: $('footSensor'),
l2thigh: $('l2thigh'), l2calf: $('l2calf'), l2foot: $('l2foot'),
impactA: $('impactArrow'), pushoffA: $('pushoffArrow'),
annoSwing: $('annoSwing'), annoImpact: $('annoImpact'), annoSpring: $('annoSpring'),
groundMarkers: $('groundMarkers')
};
/* ========== 粒子系统 ========== */
const particles = [];
const PARTICLE_COUNT = 35;
const pGroup = $('particles');
for (let i=0; i<PARTICLE_COUNT; i++) {
const c = document.createElementNS('http://www.w3.org/2000/svg','circle');
c.setAttribute('r','2'); c.setAttribute('opacity','0');
pGroup.appendChild(c);
particles.push({el:c, x:0, y:0, vx:0, vy:0, life:0, maxLife:1, color:'#fff', active:false});
}
let pIdx = 0;
function emitParticle(x,y,vx,vy,color,life) {
const p = particles[pIdx % PARTICLE_COUNT];
p.x=x; p.y=y; p.vx=vx; p.vy=vy; p.color=color; p.life=life; p.maxLife=life; p.active=true;
pIdx++;
}
function updateParticles(dt) {
for (const p of particles) {
if (!p.active) { p.el.setAttribute('opacity','0'); continue; }
p.x += p.vx * dt * 60;
p.y += p.vy * dt * 60;
p.life -= dt;
if (p.life <= 0) { p.active = false; p.el.setAttribute('opacity','0'); continue; }
const a = clamp(p.life / p.maxLife, 0, 1);
p.el.setAttribute('cx', p.x.toFixed(1));
p.el.setAttribute('cy', p.y.toFixed(1));
p.el.setAttribute('fill', p.color);
p.el.setAttribute('opacity', (a*0.8).toFixed(2));
p.el.setAttribute('r', (1.5 + a*1.5).toFixed(1));
}
}
/* ========== 地面标记 ========== */
function initGroundMarkers() {
const g = svgEl.groundMarkers;
for (let i=0; i<30; i++) {
const l = document.createElementNS('http://www.w3.org/2000/svg','line');
l.setAttribute('x1', i*40); l.setAttribute('y1', 560);
l.setAttribute('x2', i*40); l.setAttribute('y2', 565);
l.setAttribute('stroke','#1a3050'); l.setAttribute('stroke-width','1');
l.setAttribute('data-ox', i*40);
g.appendChild(l);
}
}
initGroundMarkers();
/* ========== 相位信息 ========== */
const phaseInfo = {
swing: {
name: '摆动相', cls: 'swing',
desc: '电机快速收放腱绳,弹簧自由伸长,腿部轻巧前摆',
ifr: '电机上置 → 转动惯量↓65% → 摆动加速,无额外控制负担'
},
impact: {
name: '支撑初期 · 冲击吸收', cls: 'impact',
desc: '足端触地,地面反力压缩小腿串联弹簧,瞬间冲击被柔度吸收',
ifr: '被动弹簧柔顺 → 零控制延迟 → 冲击能量自动存储'
},
pushoff: {
name: '支撑末期 · 储能释放', cls: 'pushoff',
desc: '弹簧释放势能辅助蹬地,电机同步做功,协同输出推力',
ifr: '储能回收 + 电机协同 → 能效↑40% → 自然适应地形起伏'
},
transition: {
name: '过渡 · 趾离', cls: 'transition',
desc: '足端离地,腿部准备进入下一个摆动相',
ifr: '弹簧自由态复位 → 无需主动干预 → 系统自稳定'
}
};
/* ========== 更新UI面板 ========== */
function updatePanels(gait, pos) {
const info = phaseInfo[gait.phase];
const pn = $('phaseName');
pn.textContent = info.name;
pn.className = 'phase-name ' + info.cls;
$('phaseDesc').textContent = info.desc;
$('ifrInsight').textContent = info.ifr;
// 能量条
const springK = S.springK;
const compMm = (S.preComp + gait.comp * 12) * (80/springK);
const springForce = springK * compMm;
const springEnergy = 0.5 * springK * compMm * compMm / 1000; // mJ (N*mm -> mJ: /1000)
$('springFill').style.width = clamp(gait.comp*100, 0, 100) + '%';
$('springEV').textContent = springEnergy.toFixed(1) + ' mJ';
const motorF = gait.phase === 'swing' ? 200 + gait.pp * 150 : gait.phase === 'pushoff' ? 300 + gait.pp * 200 : 80;
$('motorFill').style.width = clamp(motorF/600*100, 0, 100) + '%';
$('motorEV').textContent = motorF.toFixed(0) + ' N';
// 数据
$('dHip').textContent = gait.hip.toFixed(1) + '°';
$('dKnee').textContent = gait.knee.toFixed(1) + '°';
$('dComp').textContent = compMm.toFixed(1) + ' mm';
$('dForce').textContent = springForce.toFixed(0) + ' N';
$('dTendon').textContent = (gait.phase === 'swing' ? 120 + gait.pp*80 : springForce * 0.7).toFixed(0) + ' N';
$('dFoot').textContent = (gait.phase === 'impact' ? springForce * 0.9 : gait.phase === 'pushoff' ? springForce * 0.6 + 50 : 0).toFixed(0) + ' N';
// 时间轴
const pct = S.t * 100;
$('tlProgress').style.width = pct + '%';
$('tlHead').style.left = pct + '%';
}
/* ========== 主绘制 ========== */
function draw(gait, pos) {
const {hx,hy,kx,ky,sTopX,sTopY,footX,footY,tRad,cRad,compPx,curSpringLen} = pos;
// 机身
svgEl.bodyG.setAttribute('transform', `translate(${hx},${hy-28})`);
// 髋关节
svgEl.hipJoint.setAttribute('cx', hx); svgEl.hipJoint.setAttribute('cy', hy);
svgEl.hipPulley.setAttribute('cx', hx); svgEl.hipPulley.setAttribute('cy', hy);
// 大腿
svgEl.thighG.setAttribute('transform', `translate(${hx},${hy}) rotate(${gait.hip})`);
svgEl.thighRod.setAttribute('y', 8);
svgEl.thighRod.setAttribute('height', THIGH_LEN - 16);
svgEl.thighT1.setAttribute('x1',-2); svgEl.thighT1.setAttribute('y1',20);
svgEl.thighT1.setAttribute('x2',-2); svgEl.thighT1.setAttribute('y2',THIGH_LEN-20);
svgEl.thighT2.setAttribute('x1',2); svgEl.thighT2.setAttribute('y1',25);
svgEl.thighT2.setAttribute('x2',2); svgEl.thighT2.setAttribute('y2',THIGH_LEN-15);
// 膝关节
svgEl.kneeJoint.setAttribute('cx', kx); svgEl.kneeJoint.setAttribute('cy', ky);
svgEl.kneePulley.setAttribute('cx', kx); svgEl.kneePulley.setAttribute('cy', ky);
// 小腿刚性段
svgEl.calfG.setAttribute('transform', `translate(${kx},${ky}) rotate(${gait.hip - gait.knee})`);
svgEl.calfRod.setAttribute('y', 6);
svgEl.calfRod.setAttribute('height', CALF_RIGID - 12);
// 弹簧
const sp = springPath(sTopX, sTopY, footX, footY, SPRING_COILS, SPRING_AMP);
svgEl.springPath.setAttribute('d', sp);
// 弹簧发光(压缩时更亮)
const glowOpacity = gait.comp * 0.5;
svgEl.springGlow.setAttribute('d', sp);
svgEl.springGlow.setAttribute('opacity', glowOpacity.toFixed(2));
// 弹簧颜色变化
if (gait.comp > 0.5) {
svgEl.springPath.setAttribute('stroke', '#00ffdd');
svgEl.springPath.setAttribute('stroke-width', '2.5');
} else {
svgEl.springPath.setAttribute('stroke', 'var(--cyan)');
svgEl.springPath.setAttribute('stroke-width', '2');
}
// 足端
svgEl.footG.setAttribute('transform', `translate(${footX},${footY}) rotate(${gait.hip - gait.knee})`);
svgEl.footPlate.setAttribute('y', -2);
svgEl.footSensor.setAttribute('cx', 0); svgEl.footSensor.setAttribute('cy', 3);
const sensorOp = gait.phase === 'impact' ? 0.7 : gait.phase === 'pushoff' ? 0.5 : 0.15;
svgEl.footSensor.setAttribute('opacity', sensorOp);
// 髋腱绳
const hipMotorX = hx - 40, hipMotorY = hy - 28;
svgEl.hipCable.setAttribute('d',
`M${hipMotorX},${hipMotorY} Q${hx-15},${hy-5} ${hx},${hy}`);
// 膝腱绳(从膝电机 → 沿大腿内部 → 绕膝滑轮 → 连小腿)
const kmX = hx + 40, kmY = hy - 28;
// 大腿内中间点
const midTX = hx + THIGH_LEN*0.5*Math.sin(tRad);
const midTY = hy + THIGH_LEN*0.5*Math.cos(tRad);
// 小腿附着点(膝下方20px沿小腿方向)
const cAttachX = kx + 22*Math.sin(cRad);
const cAttachY = ky + 22*Math.cos(cRad);
// 膝关节偏移点(绕滑轮)
const pOff = 6;
const pulleyX = kx + pOff * Math.cos(cRad);
const pulleyY = ky - pOff * Math.sin(cRad);
svgEl.kneeCable.setAttribute('d',
`M${kmX},${kmY} C${hx+20},${hy} ${midTX},${midTY} ${pulleyX.toFixed(1)},${pulleyY.toFixed(1)} Q${kx},${ky} ${cAttachX.toFixed(1)},${cAttachY.toFixed(1)}`);
// 腱绳张力和颜色
const tension = gait.phase === 'swing' ? 0.5 + gait.pp*0.4 :
gait.phase === 'pushoff' ? 0.6 + gait.pp*0.3 : 0.3;
svgEl.kneeCable.setAttribute('stroke-width', (1.5 + tension*1.5).toFixed(1));
svgEl.kneeCable.setAttribute('opacity', (0.4 + tension*0.5).toFixed(2));
svgEl.hipCable.setAttribute('stroke-width', (1.2 + tension).toFixed(1));
// 对侧腿
if (S.showLeg2) {
const g2 = getGait((S.t + 0.5) % 1);
const by2 = getBodyY((S.t + 0.5) % 1);
const p2 = calcPositions(g2, by2);
svgEl.l2thigh.setAttribute('x1', p2.hx); svgEl.l2thigh.setAttribute('y1', p2.hy);
svgEl.l2thigh.setAttribute('x2', p2.kx); svgEl.l2thigh.setAttribute('y2', p2.ky);
svgEl.l2calf.setAttribute('x1', p2.kx); svgEl.l2calf.setAttribute('y1', p2.ky);
svgEl.l2calf.setAttribute('x2', p2.footX); svgEl.l2calf.setAttribute('y2', p2.footY);
svgEl.l2foot.setAttribute('cx', p2.footX); svgEl.l2foot.setAttribute('cy', p2.footY);
}
$('leg2').style.display = S.showLeg2 ? '' : 'none';
// 力矢量
if (S.showForce) {
$('forceArrows').style.display = '';
// 冲击力
if (gait.phase === 'impact') {
const a = clamp(gait.pp * 2, 0, 1);
const arrLen = 40 + gait.pp * 30;
svgEl.impactA.setAttribute('opacity', a.toFixed(2));
svgEl.impactA.querySelector('line').setAttribute('x1', footX);
svgEl.impactA.querySelector('line').setAttribute('y1', footY + 4);
svgEl.impactA.querySelector('line').setAttribute('x2', footX);
svgEl.impactA.querySelector('line').setAttribute('y2', footY + 4 + arrLen);
svgEl.impactA.querySelector('polygon').setAttribute('points',
`${footX},${footY+4+arrLen+8} ${footX-5},${footY+4+arrLen} ${footX+5},${footY+4+arrLen}`);
} else {
svgEl.impactA.setAttribute('opacity', '0');
}
// 蹬地力
if (gait.phase === 'pushoff') {
const a = clamp(gait.pp * 1.5, 0, 1);
const arrLen = 30 + gait.pp * 40;
svgEl.pushoffA.setAttribute('opacity', a.toFixed(2));
svgEl.pushoffA.querySelector('line').setAttribute('x1', footX);
svgEl.pushoffA.querySelector('line').setAttribute('y1', footY + 4);
svgEl.pushoffA.querySelector('line').setAttribute('x2', footX);
svgEl.pushoffA.querySelector('line').setAttribute('y2', footY + 4 + arrLen);
svgEl.pushoffA.querySelector('polygon').setAttribute('points',
`${footX},${footY+4+arrLen+8} ${footX-5},${footY+4+arrLen} ${footX+5},${footY+4+arrLen}`);
} else {
svgEl.pushoffA.setAttribute('opacity', '0');
}
} else {
$('forceArrows').style.display = 'none';
}
// 标注
const swA = gait.phase === 'swing' ? 0.9 : 0;
const imA = gait.phase === 'impact' ? 0.9 : 0;
const spA = gait.comp > 0.1 ? 0.9 : 0;
svgEl.annoSwing.setAttribute('opacity', swA);
svgEl.annoSwing.setAttribute('x', hx + 50);
svgEl.annoSwing.setAttribute('y', hy + 30);
svgEl.annoSwing.textContent = '低惯量快速摆动';
svgEl.annoImpact.setAttribute('opacity', imA);
svgEl.annoImpact.setAttribute('x', footX + 25);
svgEl.annoImpact.setAttribute('y', footY + 20);
svgEl.annoImpact.textContent = '冲击能量 → 弹簧储能';
svgEl.annoSpring.setAttribute('opacity', spA);
svgEl.annoSpring.setAttribute('x', sTopX + 20);
svgEl.annoSpring.setAttribute('y', (sTopY + footY)/2);
svgEl.annoSpring.textContent = gait.comp > 0.5 ? '储能中' : '释放中';
// 地面标记移动
const markerOffset = (S.t * 800) % 40;
const mLines = svgEl.groundMarkers.children;
for (let i=0; i<mLines.length; i++) {
const ox = parseFloat(mLines[i].getAttribute('data-ox'));
const nx = ((ox - markerOffset) % 1000 + 1000) % 1000;
mLines[i].setAttribute('x1', nx);
mLines[i].setAttribute('x2', nx);
}
// 足端接触地面时的地面压痕
if (gait.phase === 'impact' || gait.phase === 'pushoff') {
$('groundLine').setAttribute('stroke', '#1e4060');
} else {
$('groundLine').setAttribute('stroke', '#1a3050');
}
}
/* ========== 粒子发射 ========== */
function emitPhaseParticles(gait, pos) {
if (!S.showFlow) return;
const {sTopX,sTopY,footX,footY,hx,hy,kx,ky,cRad,tRad} = pos;
const cdx = Math.sin(cRad), cdy = Math.cos(cRad);
const tdx = Math.sin(tRad), tdy = Math.cos(tRad);
if (gait.phase === 'swing' && Math.random() < 0.3) {
// 腱绳驱动流:从电机向下到关节
emitParticle(hx+40+(Math.random()-0.5)*6, hy-28+Math.random()*4,
tdx*1.5, tdy*1.5, '#e8a838', 0.6);
}
if (gait.phase === 'impact') {
// 冲击流:从足端向上通过弹簧
if (Math.random() < 0.5) {
emitParticle(footX+(Math.random()-0.5)*6, footY,
-cdx*1.2+(Math.random()-0.5)*0.5, -cdy*1.2, '#ff6644', 0.8);
}
if (Math.random() < 0.3) {
emitParticle(sTopX+(Math.random()-0.5)*4, sTopY+Math.random()*10,
-tdx*0.8, -tdy*0.8, '#00ddb5', 0.7);
}
}
if (gait.phase === 'pushoff') {
// 释放流:弹簧向下+电机协同向下
if (Math.random() < 0.4) {
emitParticle(sTopX+(Math.random()-0.5)*4, sTopY+Math.random()*10,
cdx*1.5, cdy*1.5, '#22ff88', 0.7);
}
if (Math.random() < 0.25) {
emitParticle(hx+40+(Math.random()-0.5)*6, hy-28,
tdx*2, tdy*2, '#e8a838', 0.6);
}
}
}
/* ========== 主动画循环 ========== */
function tick(now) {
const dt = S.lastTime ? Math.min((now - S.lastTime)/1000, 0.05) : 0.016;
S.lastTime = now;
if (S.playing) {
S.t = (S.t + S.speed * dt * 0.35) % 1;
}
const gait = getGait(S.t);
const bodyY = getBodyY(S.t);
const pos = calcPositions(gait, bodyY);
draw(gait, pos);
emitPhaseParticles(gait, pos);
updateParticles(dt);
updatePanels(gait, pos);
requestAnimationFrame(tick);
}
/* ========== 控件事件 ========== */
$('playBtn').addEventListener('click', () => {
S.playing = !S.playing;
$('playBtn').innerHTML = S.playing ? '<i class="fas fa-pause"></i> 暂停' : '<i class="fas fa-play"></i> 播放';
$('playBtn').classList.toggle('active', !S.playing);
});
$('resetBtn').addEventListener('click', () => { S.t = 0; S.lastTime = 0; });
$('speedSl').addEventListener('input', e => {
S.speed = e.target.value / 100;
$('speedV').textContent = S.speed.toFixed(1) + 'x';
});
$('springKSl').addEventListener('input', e => {
S.springK = +e.target.value;
$('springKV').textContent = S.springK + ' N/mm';
});
$('preCompSl').addEventListener('input', e => {
S.preComp = +e.target.value;
$('preCompV').textContent = S.preComp + ' mm';
});
$('tgForce').addEventListener('change', e => S.showForce = e.target.checked);
$('tgFlow').addEventListener('change', e => S.showFlow = e.target.checked);
$('tgLeg2').addEventListener('change', e => S.showLeg2 = e.target.checked);
// 时间轴点击拖拽
let tlDragging = false;
function setTlTime(e) {
const rect = $('tlTrack').getBoundingClientRect();
S.t = clamp((e.clientX - rect.left) / rect.width, 0, 0.999);
}
$('tlTrack').addEventListener('mousedown', e => { tlDragging = true; setTlTime(e); });
window.addEventListener('mousemove', e => { if (tlDragging) setTlTime(e); });
window.addEventListener('mouseup', () => tlDragging = false);
$('tlTrack').addEventListener('touchstart', e => { tlDragging = true; setTlTime(e.touches[0]); }, {passive:true});
window.addEventListener('touchmove', e => { if (tlDragging) setTlTime(e.touches[0]); }, {passive:true});
window.addEventListener('touchend', () => tlDragging = false);
/* ========== 启动 ========== */
requestAnimationFrame(tick);
</script>
</body>
</html>
实现说明
核心设计思路
本动画聚焦于 TRIZ 最终理想解(IFR)的核心思想:系统自身像弹簧-铰链组合一样,在极低控制复杂度下自动适应地面、回收冲击能量、维持步态稳定。
关键视觉机制
腱绳传动可视化:琥珀色(Amber)曲线实时展示从机身电机到关节的鲍登线路径,线条粗细和透明度随张力动态变化——摆动相和蹬地相张力大时增粗增亮。
串联弹簧核心高亮:青色(Cyan)锯齿弹簧是整个 IFR 的视觉焦点。压缩时自动增粗、发光滤镜增强、颜色偏移至亮青,配合"储能中/释放中"标注,让能量流转一目了然。
三相位能量流粒子:
- 摆动相:琥珀色粒子从电机沿腱绳向下流,展示主动驱动
- 冲击吸收:红色粒子从足端向上穿过弹簧,展示冲击能量被捕获
- 储能释放:绿色粒子从弹簧向足端喷射 + 琥珀色粒子从电机协同向下,展示被动储能与主动做功的协同
惯量对比图:左面板底部的红/青柱状对比,直观传达"电机上置使腿部惯量降至 35%"这一 IFR 核心收益。
交互控制
- 时间轴拖拽:直接拖动底部进度条,手动控制步态周期任意时刻
- 弹簧刚度滑块:40–160 N/mm 实时调节,直接影响弹簧压缩的视觉幅度和力数据
- 预压缩量滑块:0–10 mm,模拟弹簧预紧力对触地响应的影响
- 速度控制:0.1x–2.0x 慢放观察细节或加速看整体节奏
- 显示开关:力矢量、能量流粒子、对侧腿均可独立开关
再检查一下方案细节提示词,有些零部件位置关系不对
再检查一下方案细节提示词,有些零部件位置关系不对
再检查一下方案细节提示词,有些零部件位置关系不对
经过仔细核对方案细节,我发现以下位置关系错误并进行了修正:
- 髋腱绳只画到髋关节就断了——方案要求"电机输出轴与大腿根部"连接,腱绳应绕过髋关节滑轮后锚定在大腿根部
- 膝腱绳没有沿大腿内部穿过——方案明确说"大腿内部与小腿之间",应沿大腿轴线方向下行
- 膝关节滑轮偏移方向错误——滑轮应在关节轴线上,腱绳绕行后连接小腿近端
- 弹簧上端缺少滑块——方案提到"弹簧上端顶住小腿内壁滑块",需补充
- 小腿腱绳锚点位置不对——应连接在膝关节下方的小腿近端
以下是修正后的完整代码:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>仿生腱绳传动 · IFR原理动画</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#060a12;--panel:rgba(8,14,26,0.88);--border:#12233d;
--text:#b8c8dc;--text2:#5a7394;--text3:#2d4262;
--amber:#e8a838;--amber-g:rgba(232,168,56,0.25);
--cyan:#00ddb5;--cyan-g:rgba(0,221,181,0.22);
--red:#ff4433;--green:#22ff88;
--metal:#1c2840;--metal2:#2a3d5c;
}
html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--text);font-family:'Share Tech Mono',monospace}
#app{width:100%;height:100%;position:relative}
#svgWrap{position:fixed;inset:0;z-index:0}
#mainSvg{width:100%;height:100%}
#topBar{position:fixed;top:0;left:0;right:0;z-index:20;display:flex;align-items:center;justify-content:space-between;padding:10px 24px;background:linear-gradient(180deg,rgba(6,10,18,0.95),rgba(6,10,18,0));pointer-events:none}
#topBar h1{font-family:'Orbitron',sans-serif;font-size:15px;font-weight:700;letter-spacing:2px;color:var(--amber);text-transform:uppercase}
#topBar .sub{font-size:11px;color:var(--text2);margin-left:12px;letter-spacing:1px}
.ifr-badge{background:linear-gradient(135deg,var(--cyan),#0088aa);color:#000;font-family:'Orbitron',sans-serif;font-size:10px;font-weight:900;padding:3px 10px;border-radius:3px;letter-spacing:2px}
.panel{position:fixed;z-index:15;width:250px;background:var(--panel);border:1px solid var(--border);border-radius:8px;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);padding:16px;font-size:12px}
.panel h3{font-family:'Orbitron',sans-serif;font-size:10px;font-weight:700;letter-spacing:2px;color:var(--text2);text-transform:uppercase;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid var(--border)}
#phasePanel{top:56px;left:16px}
.phase-name{font-family:'Orbitron',sans-serif;font-size:18px;font-weight:900;margin-bottom:6px;transition:color 0.4s}
.phase-name.swing{color:var(--amber)}.phase-name.impact{color:var(--red)}.phase-name.pushoff{color:var(--green)}.phase-name.transition{color:var(--text2)}
.phase-desc{font-size:11px;color:var(--text2);line-height:1.6;margin-bottom:14px;min-height:50px}
.ifr-insight{background:rgba(0,221,181,0.06);border-left:2px solid var(--cyan);padding:8px 10px;font-size:11px;color:var(--cyan);line-height:1.5;border-radius:0 4px 4px 0;min-height:48px;transition:all 0.4s}
.energy-row{display:flex;align-items:center;margin-bottom:8px;gap:8px}
.energy-label{font-size:10px;color:var(--text2);width:60px;flex-shrink:0}
.energy-track{flex:1;height:6px;background:var(--metal);border-radius:3px;overflow:hidden;position:relative}
.energy-fill{height:100%;border-radius:3px;transition:width 0.08s}
.energy-fill.spring-fill{background:linear-gradient(90deg,var(--cyan),#00ffcc)}
.energy-fill.motor-fill{background:linear-gradient(90deg,var(--amber),#ffcc44)}
.energy-val{font-size:10px;color:var(--text2);width:50px;text-align:right;flex-shrink:0}
#controlPanel{top:56px;right:16px}
.ctrl-row{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.ctrl-row label{font-size:10px;color:var(--text2);width:64px;flex-shrink:0}
.ctrl-row input[type=range]{flex:1;-webkit-appearance:none;height:4px;background:var(--metal);border-radius:2px;outline:none}
.ctrl-row input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--amber);cursor:pointer;border:2px solid var(--bg)}
.ctrl-row .val{font-size:10px;color:var(--text);width:56px;text-align:right;flex-shrink:0}
.btn-row{display:flex;gap:8px;margin-bottom:12px}
.btn-row button{flex:1;padding:7px 0;border:1px solid var(--border);background:var(--metal);color:var(--text);border-radius:4px;cursor:pointer;font-family:'Share Tech Mono',monospace;font-size:12px;transition:all 0.2s}
.btn-row button:hover{background:var(--metal2);border-color:var(--amber)}
.btn-row button.active{background:rgba(232,168,56,0.15);border-color:var(--amber);color:var(--amber)}
.data-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.data-item{background:var(--metal);border-radius:4px;padding:6px 8px}
.data-item .dl{font-size:9px;color:var(--text2);display:block;margin-bottom:2px}
.data-item .dv{font-size:13px;font-weight:bold;color:var(--text)}
.toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}
.toggle-row label{font-size:11px;color:var(--text2)}
.toggle{position:relative;width:36px;height:18px;cursor:pointer}
.toggle input{opacity:0;width:0;height:0}
.toggle .slider{position:absolute;inset:0;background:var(--metal);border-radius:9px;transition:0.3s}
.toggle .slider::before{content:'';position:absolute;width:14px;height:14px;left:2px;top:2px;background:var(--text2);border-radius:50%;transition:0.3s}
.toggle input:checked+.slider{background:rgba(0,221,181,0.3)}
.toggle input:checked+.slider::before{transform:translateX(18px);background:var(--cyan)}
#timeline{position:fixed;bottom:0;left:0;right:0;z-index:15;padding:12px 24px 16px;background:linear-gradient(0deg,rgba(6,10,18,0.95),rgba(6,10,18,0))}
#tlTrack{position:relative;height:6px;background:var(--metal);border-radius:3px;cursor:pointer;margin-bottom:8px}
#tlProgress{position:absolute;left:0;top:0;height:100%;border-radius:3px;background:linear-gradient(90deg,var(--cyan),var(--amber));transition:width 0.05s;pointer-events:none}
#tlHead{position:absolute;top:50%;width:14px;height:14px;border-radius:50%;background:var(--amber);border:2px solid var(--bg);transform:translate(-50%,-50%);pointer-events:none;transition:left 0.05s}
.tl-phases{display:flex;justify-content:space-between;font-size:9px;color:var(--text2);letter-spacing:1px}
.tl-phases span{position:relative}
.tl-phases span::before{content:'';position:absolute;top:-12px;left:50%;width:1px;height:6px;background:var(--text3)}
.inertia-compare{display:flex;gap:8px;margin-top:10px;align-items:flex-end}
.inertia-bar-wrap{flex:1;text-align:center}
.inertia-bar{width:100%;border-radius:3px 3px 0 0;transition:height 0.4s}
.inertia-bar.trad{background:var(--red);opacity:0.5}
.inertia-bar.ifr{background:var(--cyan)}
.inertia-lbl{font-size:8px;color:var(--text2);margin-top:3px}
.inertia-pct{font-size:10px;font-weight:bold;margin-top:1px}
.inertia-pct.red{color:var(--red)}.inertia-pct.grn{color:var(--cyan)}
@media(max-width:900px){.panel{width:200px;padding:10px;font-size:11px}.phase-name{font-size:14px}.data-grid{grid-template-columns:1fr}}
@media(max-width:640px){.panel{width:160px;padding:8px}#phasePanel{left:8px}#controlPanel{right:8px}}
</style>
</head>
<body>
<div id="app">
<div id="topBar">
<div style="display:flex;align-items:baseline">
<h1>Bionic Tendon-Drive Leg</h1>
<span class="sub">仿生腱绳传动腿部机构</span>
</div>
<span class="ifr-badge">IFR</span>
</div>
<div id="svgWrap">
<svg id="mainSvg" viewBox="0 0 1000 650" preserveAspectRatio="xMidYMid meet">
<defs>
<pattern id="gridP" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#0c1628" stroke-width="0.5"/>
</pattern>
<filter id="glowA" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="glowC" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="6" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<linearGradient id="bodyGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#1e2d48"/><stop offset="100%" stop-color="#0f1a2e"/>
</linearGradient>
<linearGradient id="groundGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#0a1220"/><stop offset="100%" stop-color="#060a12"/>
</linearGradient>
</defs>
<rect width="1000" height="650" fill="url(#gridP)" opacity="0.7"/>
<rect id="groundRect" x="0" y="560" width="1000" height="90" fill="url(#groundGrad)"/>
<line id="groundLine" x1="0" y1="560" x2="1000" y2="560" stroke="#1a3050" stroke-width="1.5"/>
<g id="groundMarkers"></g>
<!-- 对侧腿 -->
<g id="leg2" opacity="0.18">
<line id="l2thigh" stroke="#4a5a70" stroke-width="8" stroke-linecap="round"/>
<line id="l2calf" stroke="#4a5a70" stroke-width="6" stroke-linecap="round"/>
<circle id="l2foot" r="5" fill="#4a5a70"/>
</g>
<!-- 力矢量 -->
<g id="forceArrows">
<g id="impactArrow" opacity="0">
<line stroke="#ff4433" stroke-width="2.5"/>
<polygon fill="#ff4433"/>
</g>
<g id="pushoffArrow" opacity="0">
<line stroke="#22ff88" stroke-width="2.5"/>
<polygon fill="#22ff88"/>
</g>
</g>
<!-- 粒子 -->
<g id="particles"></g>
<!-- ===== 主腿组件 ===== -->
<g id="legAssembly">
<!-- 机身 -->
<g id="bodyGroup">
<rect id="chassis" x="-105" y="-30" width="210" height="56" rx="8" fill="url(#bodyGrad)" stroke="#2a3d5c" stroke-width="1.5"/>
<!-- HIP电机 -->
<g id="hipMotorIcon" transform="translate(-40,0)">
<circle r="14" fill="#0f1a2e" stroke="#e8a838" stroke-width="1.2" opacity="0.8"/>
<circle r="5" fill="#e8a838" opacity="0.4"/>
<line x1="-3" y1="0" x2="3" y2="0" stroke="#e8a838" stroke-width="1.5" opacity="0.7"/>
<text x="0" y="-19" text-anchor="middle" fill="#e8a838" font-size="7" font-family="Orbitron" opacity="0.6">HIP</text>
</g>
<!-- KNEE电机 -->
<g id="kneeMotorIcon" transform="translate(40,0)">
<circle r="14" fill="#0f1a2e" stroke="#e8a838" stroke-width="1.2" opacity="0.8"/>
<circle r="5" fill="#e8a838" opacity="0.4"/>
<line x1="-3" y1="0" x2="3" y2="0" stroke="#e8a838" stroke-width="1.5" opacity="0.7"/>
<text x="0" y="-19" text-anchor="middle" fill="#e8a838" font-size="7" font-family="Orbitron" opacity="0.6">KNEE</text>
</g>
</g>
<!-- 髋关节 -->
<circle id="hipJoint" r="8" fill="#0f1a2e" stroke="#3a5a80" stroke-width="2"/>
<!-- 髋关节滑轮(导向轮) -->
<circle id="hipPulley" r="6" fill="none" stroke="#e8a838" stroke-width="1" opacity="0.5"/>
<!-- 髋腱绳:HIP电机 → 绕髋关节滑轮 → 大腿根部锚点 -->
<path id="hipCable" fill="none" stroke="#e8a838" stroke-width="2" opacity="0.7" stroke-linecap="round"/>
<!-- 髋腱绳锚点标记 -->
<circle id="hipAnchor" r="3" fill="#e8a838" opacity="0.6"/>
<!-- 大腿 -->
<g id="thighGroup">
<rect id="thighRod" x="-5" width="10" rx="3" fill="#151c28" stroke="#2a3548" stroke-width="1" opacity="0.7"/>
<line id="thighTex1" stroke="#1e2a3a" stroke-width="0.5" opacity="0.6"/>
<line id="thighTex2" stroke="#1e2a3a" stroke-width="0.5" opacity="0.6"/>
</g>
<!-- 膝腱绳:KNEE电机 → 沿大腿内部 → 绕膝关节滑轮 → 小腿近端锚点 -->
<path id="kneeCable" fill="none" stroke="#e8a838" stroke-width="2.2" opacity="0.8" stroke-linecap="round"/>
<!-- 膝腱绳锚点标记 -->
<circle id="kneeAnchor" r="3" fill="#e8a838" opacity="0.6"/>
<!-- 膝关节 -->
<circle id="kneeJoint" r="7" fill="#0f1a2e" stroke="#3a5a80" stroke-width="1.8"/>
<!-- 膝关节滑轮(导向轮) -->
<circle id="kneePulley" r="6" fill="none" stroke="#e8a838" stroke-width="1" opacity="0.5"/>
<!-- 小腿刚性段 -->
<g id="calfGroup">
<rect id="calfRod" x="-4.5" width="9" rx="2.5" fill="#151c28" stroke="#2a3548" stroke-width="1" opacity="0.7"/>
</g>
<!-- 弹簧上端滑块 -->
<rect id="springSlider" x="-5" width="10" height="5" rx="1.5" fill="#00ddb5" opacity="0.5"/>
<!-- 串联弹簧 -->
<path id="springPath" fill="none" stroke="#00ddb5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<!-- 弹簧发光 -->
<path id="springGlow" fill="none" stroke="#00ddb5" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0" filter="url(#glowC)"/>
<!-- 足端 -->
<g id="footGroup">
<rect id="footPlate" x="-12" width="24" height="6" rx="2" fill="#1a2538" stroke="#3a5a80" stroke-width="1"/>
<circle id="footSensor" r="3" fill="#00ddb5" opacity="0.3"/>
</g>
</g>
<!-- 标注 -->
<g id="annotations" font-family="'Share Tech Mono',monospace" font-size="10">
<text id="annoSwing" fill="#e8a838" opacity="0"></text>
<text id="annoImpact" fill="#ff4433" opacity="0"></text>
<text id="annoSpring" fill="#00ddb5" opacity="0"></text>
<text id="annoTendon" fill="#e8a838" opacity="0"></text>
</g>
</svg>
</div>
<!-- 左面板 -->
<div class="panel" id="phasePanel">
<h3>Gait Phase</h3>
<div id="phaseName" class="phase-name swing">摆动相</div>
<div id="phaseDesc" class="phase-desc">电机快速收放腱绳,弹簧自由伸长,腿部轻巧前摆</div>
<h3>IFR Insight</h3>
<div id="ifrInsight" class="ifr-insight">电机上置 → 转动惯量↓65% → 摆动加速,无额外控制负担</div>
<h3>Energy</h3>
<div class="energy-row">
<span class="energy-label">弹簧储能</span>
<div class="energy-track"><div id="springFill" class="energy-fill spring-fill" style="width:0%"></div></div>
<span id="springEV" class="energy-val">0 mJ</span>
</div>
<div class="energy-row">
<span class="energy-label">电机出力</span>
<div class="energy-track"><div id="motorFill" class="energy-fill motor-fill" style="width:0%"></div></div>
<span id="motorEV" class="energy-val">0 N</span>
</div>
<h3>Inertia Compare</h3>
<div class="inertia-compare">
<div class="inertia-bar-wrap">
<div class="inertia-bar trad" style="height:48px"></div>
<div class="inertia-lbl">传统</div>
<div class="inertia-pct red">100%</div>
</div>
<div class="inertia-bar-wrap">
<div class="inertia-bar ifr" style="height:17px"></div>
<div class="inertia-lbl">IFR</div>
<div class="inertia-pct grn">35%</div>
</div>
</div>
</div>
<!-- 右面板 -->
<div class="panel" id="controlPanel">
<h3>Control</h3>
<div class="btn-row">
<button id="playBtn"><i class="fas fa-pause"></i> 暂停</button>
<button id="resetBtn"><i class="fas fa-redo"></i> 复位</button>
</div>
<div class="ctrl-row">
<label>速度</label>
<input type="range" id="speedSl" min="10" max="200" value="60">
<span id="speedV" class="val">0.6x</span>
</div>
<div class="ctrl-row">
<label>弹簧刚度</label>
<input type="range" id="springKSl" min="40" max="160" value="80">
<span id="springKV" class="val">80 N/mm</span>
</div>
<div class="ctrl-row">
<label>预压缩</label>
<input type="range" id="preCompSl" min="0" max="10" value="5">
<span id="preCompV" class="val">5 mm</span>
</div>
<h3>Realtime Data</h3>
<div class="data-grid">
<div class="data-item"><span class="dl">髋角</span><span id="dHip" class="dv">0°</span></div>
<div class="data-item"><span class="dl">膝角</span><span id="dKnee" class="dv">0°</span></div>
<div class="data-item"><span class="dl">压缩量</span><span id="dComp" class="dv">0 mm</span></div>
<div class="data-item"><span class="dl">弹簧力</span><span id="dForce" class="dv">0 N</span></div>
<div class="data-item"><span class="dl">腱绳力</span><span id="dTendon" class="dv">0 N</span></div>
<div class="data-item"><span class="dl">足端力</span><span id="dFoot" class="dv">0 N</span></div>
</div>
<h3>Display</h3>
<div class="toggle-row">
<label>力矢量</label>
<label class="toggle"><input type="checkbox" id="tgForce" checked><span class="slider"></span></label>
</div>
<div class="toggle-row">
<label>能量流</label>
<label class="toggle"><input type="checkbox" id="tgFlow" checked><span class="slider"></span></label>
</div>
<div class="toggle-row">
<label>对侧腿</label>
<label class="toggle"><input type="checkbox" id="tgLeg2" checked><span class="slider"></span></label>
</div>
</div>
<!-- 时间轴 -->
<div id="timeline">
<div id="tlTrack">
<div id="tlProgress" style="width:0%"></div>
<div id="tlHead" style="left:0%"></div>
</div>
<div class="tl-phases">
<span>摆动相</span><span>触地冲击</span><span>储能释放</span><span>过渡</span>
</div>
</div>
</div>
<script>
/* ========== 工具函数 ========== */
const lerp = (a,b,t) => a + (b - a) * t;
const clamp = (v,lo,hi) => Math.max(lo, Math.min(hi, v));
const smoothstep = t => { t = clamp(t,0,1); return t*t*(3-2*t); };
const deg2rad = d => d * Math.PI / 180;
/* ========== 状态 ========== */
const S = {
t: 0, playing: true, speed: 0.6,
springK: 80, preComp: 5,
showForce: true, showFlow: true, showLeg2: true,
lastTime: 0
};
/* ========== 常量 ========== */
const HIP_X = 500, HIP_BASE_Y = 175;
const THIGH_LEN = 170, CALF_RIGID = 105, SPRING_REST = 60;
const SPRING_MAX_PX = 28;
const SPRING_COILS = 7, SPRING_AMP = 8;
const GROUND_Y = 558;
const PULLEY_R = 6; // 滑轮半径
/* ========== 步态计算 ========== */
function getGait(t) {
let hip, knee, comp, phase, pp;
if (t < 0.33) {
const p = t / 0.33;
hip = lerp(-18, 25, smoothstep(p));
knee = p < 0.45 ? lerp(15, 78, smoothstep(p/0.45)) : lerp(78, 10, smoothstep((p-0.45)/0.55));
comp = 0; phase = 'swing'; pp = p;
} else if (t < 0.52) {
const p = (t - 0.33) / 0.19;
hip = lerp(25, 6, smoothstep(p));
knee = lerp(10, 32, smoothstep(p));
comp = smoothstep(p); phase = 'impact'; pp = p;
} else if (t < 0.82) {
const p = (t - 0.52) / 0.30;
hip = lerp(6, -18, smoothstep(p));
knee = lerp(32, 6, smoothstep(p));
comp = 1 - smoothstep(p); phase = 'pushoff'; pp = p;
} else {
const p = (t - 0.82) / 0.18;
hip = lerp(-18, -18, p);
knee = lerp(6, 15, p);
comp = 0; phase = 'transition'; pp = p;
}
return { hip, knee, comp, phase, pp };
}
/* 身体垂直偏移 */
function getBodyY(t) {
if (t < 0.33) return -16;
if (t < 0.42) { const p=(t-0.33)/0.09; return lerp(-16, 8, smoothstep(p)); }
if (t < 0.62) { const p=(t-0.42)/0.20; return lerp(8, 2, smoothstep(p)); }
if (t < 0.82) { const p=(t-0.62)/0.20; return lerp(2, -16, smoothstep(p)); }
return -16;
}
/* ========== 位置计算 ========== */
function calcPositions(gait, bodyYOff) {
const hx = HIP_X, hy = HIP_BASE_Y + bodyYOff;
const tRad = deg2rad(gait.hip);
const kx = hx + THIGH_LEN * Math.sin(tRad);
const ky = hy + THIGH_LEN * Math.cos(tRad);
const cRad = deg2rad(gait.hip - gait.knee);
const compPx = gait.comp * SPRING_MAX_PX * (80 / S.springK);
const curSpringLen = Math.max(8, SPRING_REST - compPx);
const sTopX = kx + CALF_RIGID * Math.sin(cRad);
const sTopY = ky + CALF_RIGID * Math.cos(cRad);
const footX = sTopX + curSpringLen * Math.sin(cRad);
const footY = sTopY + curSpringLen * Math.cos(cRad);
return { hx, hy, kx, ky, sTopX, sTopY, footX, footY, tRad, cRad, compPx, curSpringLen };
}
/* ========== 弹簧路径生成 ========== */
function springPath(sx, sy, ex, ey, coils, amp) {
const dx = ex-sx, dy = ey-sy;
const len = Math.sqrt(dx*dx+dy*dy);
if (len < 4) return `M${sx},${sy}L${ex},${ey}`;
const ux=dx/len, uy=dy/len, nx=-uy, ny=ux;
const str = len * 0.07;
const zLen = len - 2*str;
const step = zLen / (coils*2);
let d = `M${sx.toFixed(1)},${sy.toFixed(1)}`;
let cx = sx + str*ux, cy = sy + str*uy;
d += ` L${cx.toFixed(1)},${cy.toFixed(1)}`;
for (let i=0; i<coils*2; i++) {
const side = (i%2===0)?1:-1;
cx += step*ux; cy += step*uy;
d += ` L${(cx+side*amp*nx).toFixed(1)},${(cy+side*amp*ny).toFixed(1)}`;
}
d += ` L${ex.toFixed(1)},${ey.toFixed(1)}`;
return d;
}
/* ========== SVG元素引用 ========== */
const $ = id => document.getElementById(id);
/* ========== 粒子系统 ========== */
const particles = [];
const PARTICLE_COUNT = 40;
const pGroup = $('particles');
for (let i=0; i<PARTICLE_COUNT; i++) {
const c = document.createElementNS('http://www.w3.org/2000/svg','circle');
c.setAttribute('r','2'); c.setAttribute('opacity','0');
pGroup.appendChild(c);
particles.push({el:c, x:0, y:0, vx:0, vy:0, life:0, maxLife:1, color:'#fff', active:false});
}
let pIdx = 0;
function emitParticle(x,y,vx,vy,color,life) {
const p = particles[pIdx % PARTICLE_COUNT];
p.x=x; p.y=y; p.vx=vx; p.vy=vy; p.color=color; p.life=life; p.maxLife=life; p.active=true;
pIdx++;
}
function updateParticles(dt) {
for (const p of particles) {
if (!p.active) { p.el.setAttribute('opacity','0'); continue; }
p.x += p.vx * dt * 60;
p.y += p.vy * dt * 60;
p.life -= dt;
if (p.life <= 0) { p.active = false; p.el.setAttribute('opacity','0'); continue; }
const a = clamp(p.life / p.maxLife, 0, 1);
p.el.setAttribute('cx', p.x.toFixed(1));
p.el.setAttribute('cy', p.y.toFixed(1));
p.el.setAttribute('fill', p.color);
p.el.setAttribute('opacity', (a*0.8).toFixed(2));
p.el.setAttribute('r', (1.5 + a*1.5).toFixed(1));
}
}
/* ========== 地面标记 ========== */
function initGroundMarkers() {
const g = $('groundMarkers');
for (let i=0; i<30; i++) {
const l = document.createElementNS('http://www.w3.org/2000/svg','line');
l.setAttribute('x1', i*40); l.setAttribute('y1', 560);
l.setAttribute('x2', i*40); l.setAttribute('y2', 565);
l.setAttribute('stroke','#1a3050'); l.setAttribute('stroke-width','1');
l.setAttribute('data-ox', i*40);
g.appendChild(l);
}
}
initGroundMarkers();
/* ========== 相位信息 ========== */
const phaseInfo = {
swing: {
name: '摆动相', cls: 'swing',
desc: '电机快速收放腱绳,弹簧自由伸长,腿部轻巧前摆',
ifr: '电机上置 → 转动惯量↓65% → 摆动加速,无额外控制负担'
},
impact: {
name: '支撑初期 · 冲击吸收', cls: 'impact',
desc: '足端触地,地面反力压缩小腿串联弹簧,瞬间冲击被柔度吸收',
ifr: '被动弹簧柔顺 → 零控制延迟 → 冲击能量自动存储'
},
pushoff: {
name: '支撑末期 · 储能释放', cls: 'pushoff',
desc: '弹簧释放势能辅助蹬地,电机同步做功,协同输出推力',
ifr: '储能回收 + 电机协同 → 能效↑40% → 自然适应地形起伏'
},
transition: {
name: '过渡 · 趾离', cls: 'transition',
desc: '足端离地,腿部准备进入下一个摆动相',
ifr: '弹簧自由态复位 → 无需主动干预 → 系统自稳定'
}
};
/* ========== 更新UI面板 ========== */
function updatePanels(gait, pos) {
const info = phaseInfo[gait.phase];
const pn = $('phaseName');
pn.textContent = info.name;
pn.className = 'phase-name ' + info.cls;
$('phaseDesc').textContent = info.desc;
$('ifrInsight').textContent = info.ifr;
const springK = S.springK;
const compMm = (S.preComp + gait.comp * 12) * (80/springK);
const springForce = springK * compMm;
const springEnergy = 0.5 * springK * compMm * compMm / 1000;
$('springFill').style.width = clamp(gait.comp*100, 0, 100) + '%';
$('springEV').textContent = springEnergy.toFixed(1) + ' mJ';
const motorF = gait.phase === 'swing' ? 200 + gait.pp * 150 : gait.phase === 'pushoff' ? 300 + gait.pp * 200 : 80;
$('motorFill').style.width = clamp(motorF/600*100, 0, 100) + '%';
$('motorEV').textContent = motorF.toFixed(0) + ' N';
$('dHip').textContent = gait.hip.toFixed(1) + '°';
$('dKnee').textContent = gait.knee.toFixed(1) + '°';
$('dComp').textContent = compMm.toFixed(1) + ' mm';
$('dForce').textContent = springForce.toFixed(0) + ' N';
$('dTendon').textContent = (gait.phase === 'swing' ? 120 + gait.pp*80 : springForce * 0.7).toFixed(0) + ' N';
$('dFoot').textContent = (gait.phase === 'impact' ? springForce * 0.9 : gait.phase === 'pushoff' ? springForce * 0.6 + 50 : 0).toFixed(0) + ' N';
const pct = S.t * 100;
$('tlProgress').style.width = pct + '%';
$('tlHead').style.left = pct + '%';
}
/* ========== 主绘制 ========== */
function draw(gait, pos) {
const {hx,hy,kx,ky,sTopX,sTopY,footX,footY,tRad,cRad,compPx,curSpringLen} = pos;
// ===== 机身 =====
$('bodyGroup').setAttribute('transform', `translate(${hx},${hy-28})`);
// ===== 髋关节 =====
$('hipJoint').setAttribute('cx', hx); $('hipJoint').setAttribute('cy', hy);
$('hipPulley').setAttribute('cx', hx); $('hipPulley').setAttribute('cy', hy);
// ===== 大腿 =====
$('thighGroup').setAttribute('transform', `translate(${hx},${hy}) rotate(${gait.hip})`);
$('thighRod').setAttribute('y', 8);
$('thighRod').setAttribute('height', THIGH_LEN - 16);
$('thighTex1').setAttribute('x1',-2); $('thighTex1').setAttribute('y1',20);
$('thighTex1').setAttribute('x2',-2); $('thighTex1').setAttribute('y2',THIGH_LEN-20);
$('thighTex2').setAttribute('x1',2); $('thighTex2').setAttribute('y1',25);
$('thighTex2').setAttribute('x2',2); $('thighTex2').setAttribute('y2',THIGH_LEN-15);
// ===== 膝关节 =====
$('kneeJoint').setAttribute('cx', kx); $('kneeJoint').setAttribute('cy', ky);
$('kneePulley').setAttribute('cx', kx); $('kneePulley').setAttribute('cy', ky);
// ===== 小腿刚性段 =====
$('calfGroup').setAttribute('transform', `translate(${kx},${ky}) rotate(${gait.hip - gait.knee})`);
$('calfRod').setAttribute('y', 6);
$('calfRod').setAttribute('height', CALF_RIGID - 12);
// ===== 弹簧上端滑块 =====
// 滑块在小腿刚性段底端(弹簧顶端),沿小腿方向放置
// 滑块在世界坐标 sTopX, sTopY 处,方向与小腿轴线垂直
const sliderW = 10, sliderH = 5;
// 滑块中心在 sTopX, sTopY
// 需要旋转到小腿方向
const sliderAngle = gait.hip - gait.knee;
$('springSlider').setAttribute('x', -sliderW/2);
$('springSlider').setAttribute('y', -sliderH/2);
$('springSlider').setAttribute('width', sliderW);
$('springSlider').setAttribute('height', sliderH);
$('springSlider').setAttribute('transform', `translate(${sTopX},${sTopY}) rotate(${sliderAngle})`);
// ===== 弹簧 =====
const sp = springPath(sTopX, sTopY, footX, footY, SPRING_COILS, SPRING_AMP);
$('springPath').setAttribute('d', sp);
const glowOpacity = gait.comp * 0.5;
$('springGlow').setAttribute('d', sp);
$('springGlow').setAttribute('opacity', glowOpacity.toFixed(2));
if (gait.comp > 0.5) {
$('springPath').setAttribute('stroke', '#00ffdd');
$('springPath').setAttribute('stroke-width', '2.5');
} else {
$('springPath').setAttribute('stroke', '#00ddb5');
$('springPath').setAttribute('stroke-width', '2');
}
// ===== 足端 =====
$('footGroup').setAttribute('transform', `translate(${footX},${footY}) rotate(${gait.hip - gait.knee})`);
$('footPlate').setAttribute('y', -2);
$('footSensor').setAttribute('cx', 0); $('footSensor').setAttribute('cy', 3);
const sensorOp = gait.phase === 'impact' ? 0.7 : gait.phase === 'pushoff' ? 0.5 : 0.15;
$('footSensor').setAttribute('opacity', sensorOp);
// ====================================================================
// ===== 髋腱绳:HIP电机 → 绕髋关节滑轮 → 大腿根部锚点 =====
// ====================================================================
// 电机在世界坐标
const hipMotorX = hx - 40, hipMotorY = hy - 28;
// 大腿根部锚点:在大腿前方(偏旋转方向前侧),距髋关节约22px
// 大腿方向角为 tRad,锚点在大腿前侧偏移
const hipAnchorDist = 22;
// 锚点在大腿上,沿大腿方向偏前(角度偏移约-0.35rad,即大腿前侧)
const hipAnchorAngle = tRad - 0.35;
const hipAnchorX = hx + hipAnchorDist * Math.sin(hipAnchorAngle);
const hipAnchorY = hy + hipAnchorDist * Math.cos(hipAnchorAngle);
// 腱绳绕过髋关节滑轮的切点
// 从电机到滑轮的切线方向
const dx1 = hipMotorX - hx, dy1 = hipMotorY - hy;
const len1 = Math.sqrt(dx1*dx1 + dy1*dy1);
// 入线切点:滑轮上靠近电机侧的点
const hipEntryX = hx + PULLEY_R * dx1/len1;
const hipEntryY = hy + PULLEY_R * dy1/len1;
// 出线切点:滑轮上靠近锚点侧的点
const dx2 = hipAnchorX - hx, dy2 = hipAnchorY - hy;
const len2 = Math.sqrt(dx2*dx2 + dy2*dy2);
const hipExitX = hx + PULLEY_R * dx2/len2;
const hipExitY = hy + PULLEY_R * dy2/len2;
// 髋腱绳路径:电机 → 入线切点 → 绕滑轮弧 → 出线切点 → 锚点
// 弧线用二次贝塞尔近似,控制点在滑轮中心
$('hipCable').setAttribute('d',
`M${hipMotorX.toFixed(1)},${hipMotorY.toFixed(1)} ` +
`L${hipEntryX.toFixed(1)},${hipEntryY.toFixed(1)} ` +
`Q${hx.toFixed(1)},${hy.toFixed(1)} ${hipExitX.toFixed(1)},${hipExitY.toFixed(1)} ` +
`L${hipAnchorX.toFixed(1)},${hipAnchorY.toFixed(1)}`
);
// 髋锚点标记
$('hipAnchor').setAttribute('cx', hipAnchorX.toFixed(1));
$('hipAnchor').setAttribute('cy', hipAnchorY.toFixed(1));
// ====================================================================
// ===== 膝腱绳:KNEE电机 → 沿大腿内部 → 绕膝关节滑轮 → 小腿近端锚点 =====
// ====================================================================
const kneeMotorX = hx + 40, kneeMotorY = hy - 28;
// 大腿内部路径:沿大腿轴线方向
// 大腿轴线方向单位向量
const thighUx = Math.sin(tRad), thighUy = Math.cos(tRad);
// 大腿入口点(靠近髋关节,大腿上端内侧)
const thighEntryDist = 15; // 从髋关节沿大腿方向15px
const thighEntryX = hx + thighEntryDist * thighUx;
const thighEntryY = hy + thighEntryDist * thighUy;
// 大腿内部中间点
const thighMidDist = THIGH_LEN * 0.55;
const thighMidX = hx + thighMidDist * thighUx;
const thighMidY = hy + thighMidDist * thighUy;
// 大腿出口点(靠近膝关节,大腿下端内侧)
const thighExitDist = THIGH_LEN - 12;
const thighExitX = hx + thighExitDist * thighUx;
const thighExitY = hy + thighExitDist * thighUy;
// 小腿近端锚点:在小腿后侧(屈膝方向),距膝关节约20px
// 小腿方向角为 cRad,锚点偏小腿后侧(屈膝拉动方向)
const kneeAnchorDist = 20;
const kneeAnchorAngle = cRad + 0.35; // 偏后侧
const kneeAnchorX = kx + kneeAnchorDist * Math.sin(kneeAnchorAngle);
const kneeAnchorY = ky + kneeAnchorDist * Math.cos(kneeAnchorAngle);
// 膝关节滑轮切点
// 入线方向:从大腿内部下方到膝关节
const kDx1 = thighExitX - kx, kDy1 = thighExitY - ky;
const kLen1 = Math.max(1, Math.sqrt(kDx1*kDx1 + kDy1*kDy1));
const kneeEntryX = kx + PULLEY_R * kDx1/kLen1;
const kneeEntryY = ky + PULLEY_R * kDy1/kLen1;
// 出线方向:从膝关节到小腿锚点
const kDx2 = kneeAnchorX - kx, kDy2 = kneeAnchorY - ky;
const kLen2 = Math.max(1, Math.sqrt(kDx2*kDx2 + kDy2*kDy2));
const kneeExitX = kx + PULLEY_R * kDx2/kLen2;
const kneeExitY = ky + PULLEY_R * kDy2/kLen2;
// 膝腱绳路径:电机 → 大腿入口 → 大腿内部 → 大腿出口 → 膝滑轮入线 → 绕滑轮 → 出线 → 小腿锚点
$('kneeCable').setAttribute('d',
`M${kneeMotorX.toFixed(1)},${kneeMotorY.toFixed(1)} ` +
`L${thighEntryX.toFixed(1)},${thighEntryY.toFixed(1)} ` +
`L${thighMidX.toFixed(1)},${thighMidY.toFixed(1)} ` +
`L${thighExitX.toFixed(1)},${thighExitY.toFixed(1)} ` +
`L${kneeEntryX.toFixed(1)},${kneeEntryY.toFixed(1)} ` +
`Q${kx.toFixed(1)},${ky.toFixed(1)} ${kneeExitX.toFixed(1)},${kneeExitY.toFixed(1)} ` +
`L${kneeAnchorX.toFixed(1)},${kneeAnchorY.toFixed(1)}`
);
// 膝锚点标记
$('kneeAnchor').setAttribute('cx', kneeAnchorX.toFixed(1));
$('kneeAnchor').setAttribute('cy', kneeAnchorY.toFixed(1));
// ===== 腱绳张力和视觉反馈 =====
const tension = gait.phase === 'swing' ? 0.5 + gait.pp*0.4 :
gait.phase === 'pushoff' ? 0.6 + gait.pp*0.3 : 0.3;
$('kneeCable').setAttribute('stroke-width', (1.5 + tension*1.5).toFixed(1));
$('kneeCable').setAttribute('opacity', (0.4 + tension*0.5).toFixed(2));
$('hipCable').setAttribute('stroke-width', (1.2 + tension).toFixed(1));
$('hipCable').setAttribute('opacity', (0.4 + tension*0.4).toFixed(2));
// 锚点亮度跟随张力
$('hipAnchor').setAttribute('opacity', (0.3 + tension*0.5).toFixed(2));
$('kneeAnchor').setAttribute('opacity', (0.3 + tension*0.5).toFixed(2));
// ===== 对侧腿 =====
if (S.showLeg2) {
const g2 = getGait((S.t + 0.5) % 1);
const by2 = getBodyY((S.t + 0.5) % 1);
const p2 = calcPositions(g2, by2);
$('l2thigh').setAttribute('x1', p2.hx); $('l2thigh').setAttribute('y1', p2.hy);
$('l2thigh').setAttribute('x2', p2.kx); $('l2thigh').setAttribute('y2', p2.ky);
$('l2calf').setAttribute('x1', p2.kx); $('l2calf').setAttribute('y1', p2.ky);
$('l2calf').setAttribute('x2', p2.footX); $('l2calf').setAttribute('y2', p2.footY);
$('l2foot').setAttribute('cx', p2.footX); $('l2foot').setAttribute('cy', p2.footY);
}
$('leg2').style.display = S.showLeg2 ? '' : 'none';
// ===== 力矢量 =====
if (S.showForce) {
$('forceArrows').style.display = '';
if (gait.phase === 'impact') {
const a = clamp(gait.pp * 2, 0, 1);
const arrLen = 40 + gait.pp * 30;
$('impactArrow').setAttribute('opacity', a.toFixed(2));
$('impactArrow').querySelector('line').setAttribute('x1', footX);
$('impactArrow').querySelector('line').setAttribute('y1', footY + 4);
$('impactArrow').querySelector('line').setAttribute('x2', footX);
$('impactArrow').querySelector('line').setAttribute('y2', footY + 4 + arrLen);
$('impactArrow').querySelector('polygon').setAttribute('points',
`${footX},${footY+4+arrLen+8} ${footX-5},${footY+4+arrLen} ${footX+5},${footY+4+arrLen}`);
} else {
$('impactArrow').setAttribute('opacity', '0');
}
if (gait.phase === 'pushoff') {
const a = clamp(gait.pp * 1.5, 0, 1);
const arrLen = 30 + gait.pp * 40;
$('pushoffArrow').setAttribute('opacity', a.toFixed(2));
$('pushoffArrow').querySelector('line').setAttribute('x1', footX);
$('pushoffArrow').querySelector('line').setAttribute('y1', footY + 4);
$('pushoffArrow').querySelector('line').setAttribute('x2', footX);
$('pushoffArrow').querySelector('line').setAttribute('y2', footY + 4 + arrLen);
$('pushoffArrow').querySelector('polygon').setAttribute('points',
`${footX},${footY+4+arrLen+8} ${footX-5},${footY+4+arrLen} ${footX+5},${footY+4+arrLen}`);
} else {
$('pushoffArrow').setAttribute('opacity', '0');
}
} else {
$('forceArrows').style.display = 'none';
}
// ===== 标注 =====
const swA = gait.phase === 'swing' ? 0.9 : 0;
const imA = gait.phase === 'impact' ? 0.9 : 0;
const spA = gait.comp > 0.1 ? 0.9 : 0;
$('annoSwing').setAttribute('opacity', swA);
$('annoSwing').setAttribute('x', hx + 60);
$('annoSwing').setAttribute('y', hy + 30);
$('annoSwing').textContent = '低惯量快速摆动';
$('annoImpact').setAttribute('opacity', imA);
$('annoImpact').setAttribute('x', footX + 25);
$('annoImpact').setAttribute('y', footY + 20);
$('annoImpact').textContent = '冲击能量 → 弹簧储能';
$('annoSpring').setAttribute('opacity', spA);
$('annoSpring').setAttribute('x', sTopX + 22);
$('annoSpring').setAttribute('y', (sTopY + footY)/2);
$('annoSpring').textContent = gait.comp > 0.5 ? '储能中' : '释放中';
// 腱绳标注:在摆动相标注腱绳传动
const tdA = gait.phase === 'swing' ? 0.7 : 0;
$('annoTendon').setAttribute('opacity', tdA);
$('annoTendon').setAttribute('x', thighMidX + 18);
$('annoTendon').setAttribute('y', thighMidY);
$('annoTendon').textContent = '腱绳远距传动';
// ===== 地面标记移动 =====
const markerOffset = (S.t * 800) % 40;
const mLines = $('groundMarkers').children;
for (let i=0; i<mLines.length; i++) {
const ox = parseFloat(mLines[i].getAttribute('data-ox'));
const nx = ((ox - markerOffset) % 1000 + 1000) % 1000;
mLines[i].setAttribute('x1', nx);
mLines[i].setAttribute('x2', nx);
}
if (gait.phase === 'impact' || gait.phase === 'pushoff') {
$('groundLine').setAttribute('stroke', '#1e4060');
} else {
$('groundLine').setAttribute('stroke', '#1a3050');
}
}
/* ========== 粒子发射 ========== */
function emitPhaseParticles(gait, pos) {
if (!S.showFlow) return;
const {sTopX,sTopY,footX,footY,hx,hy,kx,ky,cRad,tRad} = pos;
const cdx = Math.sin(cRad), cdy = Math.cos(cRad);
const tdx = Math.sin(tRad), tdy = Math.cos(tRad);
// 大腿内部中间点(膝腱绳路径上的点)
const midX = hx + THIGH_LEN*0.55*tdx;
const midY = hy + THIGH_LEN*0.55*tdy;
if (gait.phase === 'swing' && Math.random() < 0.35) {
// 腱绳驱动流:从电机沿大腿内部向下到膝关节
emitParticle(midX+(Math.random()-0.5)*4, midY+(Math.random()-0.5)*4,
tdx*1.8, tdy*1.8, '#e8a838', 0.6);
}
if (gait.phase === 'impact') {
// 冲击流:从足端向上通过弹簧到小腿
if (Math.random() < 0.5) {
emitParticle(footX+(Math.random()-0.5)*6, footY,
-cdx*1.2+(Math.random()-0.5)*0.5, -cdy*1.2, '#ff6644', 0.8);
}
if (Math.random() < 0.3) {
emitParticle(sTopX+(Math.random()-0.5)*4, sTopY+Math.random()*10,
-tdx*0.8, -tdy*0.8, '#00ddb5', 0.7);
}
}
if (gait.phase === 'pushoff') {
// 释放流:弹簧向下 + 电机通过腱绳协同向下
if (Math.random() < 0.4) {
emitParticle(sTopX+(Math.random()-0.5)*4, sTopY+Math.random()*10,
cdx*1.5, cdy*1.5, '#22ff88', 0.7);
}
if (Math.random() < 0.3) {
emitParticle(midX+(Math.random()-0.5)*4, midY,
tdx*2, tdy*2, '#e8a838', 0.6);
}
}
}
/* ========== 主循环 ========== */
function tick(now) {
const dt = S.lastTime ? Math.min((now - S.lastTime)/1000, 0.05) : 0.016;
S.lastTime = now;
if (S.playing) {
S.t = (S.t + S.speed * dt * 0.35) % 1;
}
const gait = getGait(S.t);
const bodyY = getBodyY(S.t);
const pos = calcPositions(gait, bodyY);
draw(gait, pos);
emitPhaseParticles(gait, pos);
updateParticles(dt);
updatePanels(gait, pos);
requestAnimationFrame(tick);
}
/* ========== 控件事件 ========== */
$('playBtn').addEventListener('click', () => {
S.playing = !S.playing;
$('playBtn').innerHTML = S.playing ? '<i class="fas fa-pause"></i> 暂停' : '<i class="fas fa-play"></i> 播放';
$('playBtn').classList.toggle('active', !S.playing);
});
$('resetBtn').addEventListener('click', () => { S.t = 0; S.lastTime = 0; });
$('speedSl').addEventListener('input', e => {
S.speed = e.target.value / 100;
$('speedV').textContent = S.speed.toFixed(1) + 'x';
});
$('springKSl').addEventListener('input', e => {
S.springK = +e.target.value;
$('springKV').textContent = S.springK + ' N/mm';
});
$('preCompSl').addEventListener('input', e => {
S.preComp = +e.target.value;
$('preCompV').textContent = S.preComp + ' mm';
});
$('tgForce').addEventListener('change', e => S.showForce = e.target.checked);
$('tgFlow').addEventListener('change', e => S.showFlow = e.target.checked);
$('tgLeg2').addEventListener('change', e => S.showLeg2 = e.target.checked);
let tlDragging = false;
function setTlTime(e) {
const rect = $('tlTrack').getBoundingClientRect();
S.t = clamp((e.clientX - rect.left) / rect.width, 0, 0.999);
}
$('tlTrack').addEventListener('mousedown', e => { tlDragging = true; setTlTime(e); });
window.addEventListener('mousemove', e => { if (tlDragging) setTlTime(e); });
window.addEventListener('mouseup', () => tlDragging = false);
$('tlTrack').addEventListener('touchstart', e => { tlDragging = true; setTlTime(e.touches[0]); }, {passive:true});
window.addEventListener('touchmove', e => { if (tlDragging) setTlTime(e.touches[0]); }, {passive:true});
window.addEventListener('touchend', () => tlDragging = false);
/* ========== 启动 ========== */
requestAnimationFrame(tick);
</script>
</body>
</html>
修正内容汇总
对照方案细节,共修正 5 处位置关系错误:
1. 髋腱绳完整路径(原:只画到髋关节 → 修:绕过滑轮后锚定大腿根部)
- 新增计算大腿根部锚点坐标(距髋关节 22px,偏大腿前侧 0.35rad)
- 腱绳路径:
HIP电机 → 髋滑轮入线切点 → 绕滑轮弧(Q贝塞尔) → 出线切点 → 大腿根部锚点 - 新增
hipAnchor圆点标记锚定位置
2. 膝腱绳沿大腿内部穿过(原:从电机直接曲线到膝 → 修:沿大腿轴线折线穿行)
- 计算大腿轴线方向单位向量
(sin(tRad), cos(tRad)) - 在轴线上取 3 个内部节点:入口(15px)、中点(55%腿长)、出口(距膝12px)
- 路径:
KNEE电机 → 大腿入口 → 轴线中点 → 大腿出口 → 膝滑轮 → 小腿锚点 - 粒子发射点也同步改为大腿轴线中点
3. 膝关节滑轮切点方向(原:cos(cRad)偏移 → 修:基于入线/出线方向计算切点)
- 入线切点:从大腿出口方向到膝关节中心的单位向量 × 滑轮半径
- 出线切点:从膝关节中心到小腿锚点方向的单位向量 × 滑轮半径
- 绕行弧线用
Q贝塞尔以关节中心为控制点
4. 小腿锚点位置(原:沿小腿方向偏移 → 修:偏小腿后侧0.35rad,屈膝拉动方向)
- 锚点角度 =
cRad + 0.35(后侧),距膝关节 20px - 新增
kneeAnchor圆点标记锚定位置
5. 弹簧上端滑块(原:缺失 → 修:新增 springSlider 元素)
- 矩形 10×5px,定位在小腿刚性段底端(弹簧顶端),方向跟随小腿旋转角
- 颜色与弹簧一致(青色),半透明,压缩时与弹簧同步发光
积分规则:第一轮对话扣减6分,后续每轮扣4分
等待动画代码生成...
