这是为您生成的一个展示蛇腹鳞片“被动自适应各向异性摩擦”原理的高保真 SVG 交互动画,用于直观演示无电机驱动的横向抓地与纵向顺滑机理。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>蛇腹鳞片各向异性摩擦 - 被动自适应原理动画</title>
<style>
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--text: #c9d1d9;
--text-secondary: #8b949e;
--accent-gold: #d4a853;
--accent-amber: #f0c060;
--accent-cyan: #58a6ff;
--accent-green: #3fb950;
--accent-red: #f85149;
--accent-magenta: #db61a2;
--glow-gold: rgba(212, 168, 83, 0.5);
--glow-cyan: rgba(88, 166, 255, 0.4);
--glow-green: rgba(63, 185, 80, 0.4);
--glow-red: rgba(248, 81, 73, 0.5);
--font-display: 'Georgia', 'Times New Roman', serif;
--font-mono: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
--font-ui: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--bg);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
font-family: var(--font-ui);
color: var(--text);
background-image:
radial-gradient(ellipse at 50% 30%, rgba(88, 166, 255, 0.04) 0%, transparent 70%),
radial-gradient(ellipse at 70% 60%, rgba(212, 168, 83, 0.05) 0%, transparent 60%),
radial-gradient(ellipse at 30% 70%, rgba(63, 185, 80, 0.03) 0%, transparent 50%);
background-attachment: fixed;
padding: 20px;
overflow-x: hidden;
}
.main-container {
width: 100%;
max-width: 960px;
display: flex;
flex-direction: column;
gap: 24px;
align-items: center;
}
.header {
text-align: center;
display: flex;
flex-direction: column;
gap: 6px;
}
.header .badge {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 20px;
padding: 6px 16px;
font-size: 0.8rem;
letter-spacing: 0.06em;
color: var(--accent-amber);
font-family: var(--font-mono);
align-self: center;
text-transform: uppercase;
}
.header .badge .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-amber);
animation: pulse-dot 2s ease-in-out infinite;
box-shadow: 0 0 8px var(--glow-gold);
}
@keyframes pulse-dot {
0%,
100% {
opacity: 0.6;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.5);
}
}
.header h1 {
font-family: var(--font-display);
font-size: 1.6rem;
font-weight: 400;
letter-spacing: 0.04em;
color: #e6edf3;
line-height: 1.4;
}
.header h1 .highlight {
color: var(--accent-amber);
font-style: italic;
}
.header .subtitle {
font-size: 0.85rem;
color: var(--text-secondary);
font-family: var(--font-mono);
letter-spacing: 0.03em;
}
.svg-wrapper {
width: 100%;
aspect-ratio: 900 / 600;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
overflow: hidden;
position: relative;
box-shadow:
0 4px 32px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
transition: box-shadow 0.5s ease;
}
.svg-wrapper:hover {
box-shadow:
0 8px 48px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.05) inset,
0 0 80px rgba(212, 168, 83, 0.08);
}
.svg-wrapper svg {
width: 100%;
height: 100%;
display: block;
}
.controls-panel {
display: flex;
flex-wrap: wrap;
gap: 14px;
justify-content: center;
align-items: center;
width: 100%;
max-width: 700px;
}
.btn-group {
display: flex;
gap: 2px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.btn {
padding: 10px 18px;
font-family: var(--font-ui);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
border: none;
background: transparent;
color: var(--text-secondary);
transition: all 0.25s ease;
letter-spacing: 0.03em;
white-space: nowrap;
position: relative;
outline: none;
}
.btn:hover {
color: #e6edf3;
background: rgba(255, 255, 255, 0.04);
}
.btn.active {
color: #fff;
background: rgba(212, 168, 83, 0.18);
box-shadow: inset 0 0 0 1px rgba(212, 168, 83, 0.35);
font-weight: 600;
}
.btn.active::after {
content: '';
position: absolute;
bottom: 0;
left: 20%;
width: 60%;
height: 2px;
background: var(--accent-amber);
border-radius: 1px;
}
.btn.play-btn {
border-radius: 10px;
padding: 10px 22px;
font-weight: 600;
letter-spacing: 0.05em;
border: 1px solid var(--border);
background: var(--surface);
color: var(--accent-cyan);
transition: all 0.3s ease;
}
.btn.play-btn:hover {
border-color: var(--accent-cyan);
box-shadow: 0 0 20px var(--glow-cyan);
}
.btn.play-btn.playing {
color: var(--accent-green);
border-color: var(--accent-green);
box-shadow: 0 0 20px var(--glow-green);
animation: btn-glow-pulse 2s ease-in-out infinite;
}
@keyframes btn-glow-pulse {
0%,
100% {
box-shadow: 0 0 16px var(--glow-green);
}
50% {
box-shadow: 0 0 32px var(--glow-green), 0 0 48px rgba(63, 185, 80, 0.2);
}
}
.slider-group {
display: flex;
align-items: center;
gap: 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px 16px;
}
.slider-group label {
font-size: 0.8rem;
color: var(--text-secondary);
font-family: var(--font-mono);
letter-spacing: 0.04em;
white-space: nowrap;
}
.slider-group input[type="range"] {
-webkit-appearance: none;
width: 100px;
height: 6px;
border-radius: 3px;
background: var(--border);
outline: none;
cursor: pointer;
}
.slider-group input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--accent-amber);
cursor: pointer;
border: 2px solid var(--bg);
box-shadow: 0 0 12px var(--glow-gold);
transition: all 0.2s ease;
}
.slider-group input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.15);
box-shadow: 0 0 20px var(--glow-gold), 0 0 36px rgba(212, 168, 83, 0.3);
}
.slider-group .val-display {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--accent-amber);
min-width: 32px;
text-align: center;
font-weight: 600;
}
.legend-mini {
display: flex;
flex-wrap: wrap;
gap: 14px;
justify-content: center;
font-size: 0.75rem;
color: var(--text-secondary);
font-family: var(--font-mono);
letter-spacing: 0.03em;
}
.legend-mini span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.legend-mini .dot-sm {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.dot-sm.gold {
background: var(--accent-amber);
box-shadow: 0 0 6px var(--glow-gold);
}
.dot-sm.cyan {
background: var(--accent-cyan);
box-shadow: 0 0 6px var(--glow-cyan);
}
.dot-sm.green {
background: var(--accent-green);
box-shadow: 0 0 6px var(--glow-green);
}
.dot-sm.red {
background: var(--accent-red);
box-shadow: 0 0 6px var(--glow-red);
}
@media (max-width: 640px) {
.header h1 {
font-size: 1.2rem;
}
.btn {
padding: 8px 12px;
font-size: 0.75rem;
}
.slider-group {
padding: 6px 10px;
gap: 6px;
}
.slider-group input[type="range"] {
width: 60px;
}
.controls-panel {
gap: 8px;
}
}
</style>
</head>
<body>
<div class="main-container">
<!-- Header -->
<div class="header">
<div class="badge">
<span class="dot"></span> TRIZ · 最终理想解 IFR
</div>
<h1>
被动自适应<span class="highlight">各向异性摩擦</span>原理
</h1>
<p class="subtitle">无电机 · 纯机械 · 横向抓 / 纵向顺</p>
</div>
<!-- SVG Animation Container -->
<div class="svg-wrapper" id="svgContainer">
<svg id="mainSvg" viewBox="0 0 900 600" xmlns="http://www.w3.org/2000/svg">
<!-- Definitions -->
<defs>
<!-- Ground texture gradient -->
<linearGradient id="groundGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#5a5d63" />
<stop offset="15%" stop-color="#4a4d53" />
<stop offset="100%" stop-color="#2a2d33" />
</linearGradient>
<!-- Snake body gradient -->
<linearGradient id="bodyGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#3d4a3b" />
<stop offset="40%" stop-color="#2d382b" />
<stop offset="100%" stop-color="#1a2319" />
</linearGradient>
<!-- Scale (鳞片) gradient - active/high friction -->
<linearGradient id="scaleActiveGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#f0c060" />
<stop offset="100%" stop-color="#c48530" />
</linearGradient>
<!-- Scale gradient - passive/low friction -->
<linearGradient id="scalePassiveGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#8b9db5" />
<stop offset="100%" stop-color="#5a6d80" />
</linearGradient>
<!-- Glow filters -->
<filter id="glowGold" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="glowCyan" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="3.5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="glowGreen" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="glowRed" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="softShadow" x="-20%" y="-10%" width="140%" height="160%">
<feDropShadow dx="0" dy="3" stdDeviation="5" flood-color="#000000" flood-opacity="0.5" />
</filter>
<!-- Noise texture for ground -->
<filter id="groundNoise">
<feTurbulence type="fractalNoise" baseFrequency="0.65" numOctaves="3" result="noise" />
<feColorMatrix type="saturate" values="0" in="noise" result="grayNoise" />
<feBlend in="SourceGraphic" in2="grayNoise" mode="multiply" result="textured" />
<feComponentTransfer in="textured">
<feFuncA type="linear" slope="1" />
</feComponentTransfer>
</filter>
<!-- Arrow marker -->
<marker id="arrowCyan" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 L2,4 Z" fill="#58a6ff" />
</marker>
<marker id="arrowRed" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 L2,4 Z" fill="#f85149" />
</marker>
<marker id="arrowGreen" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 L2,4 Z" fill="#3fb950" />
</marker>
<marker id="arrowAmber" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto">
<path d="M0,0 L7,3.5 L0,7 L1.8,3.5 Z" fill="#d4a853" />
</marker>
<!-- Clip path for ground surface highlight -->
<clipPath id="groundClip">
<rect x="20" y="435" width="860" height="165" />
</clipPath>
</defs>
<!-- Background subtle grid -->
<g opacity="0.06">
<pattern id="grid" width="30" height="30" patternUnits="userSpaceOnUse">
<path d="M30 0L0 0L0 30" fill="none" stroke="#ffffff" stroke-width="0.5" />
</pattern>
<rect x="0" y="0" width="900" height="600" fill="url(#grid)" />
</g>
<!-- Ground surface -->
<g id="groundGroup">
<!-- Ground body -->
<rect id="groundBody" x="20" y="440" width="860" height="160" rx="4" fill="url(#groundGrad)"
filter="url(#groundNoise)" />
<!-- Ground top highlight line -->
<line x1="25" y1="440" x2="875" y2="440" stroke="#7a7d84" stroke-width="1.5" opacity="0.7" />
<!-- Ground texture particles -->
<g opacity="0.25">
<circle cx="120" cy="465" r="1.2" fill="#9a9da4" />
<circle cx="340" cy="478" r="0.8" fill="#9a9da4" />
<circle cx="560" cy="460" r="1" fill="#9a9da4" />
<circle cx="710" cy="490" r="1.3" fill="#9a9da4" />
<circle cx="800" cy="472" r="0.9" fill="#9a9da4" />
<circle cx="200" cy="500" r="1.1" fill="#8a8d94" />
<circle cx="450" cy="510" r="0.7" fill="#8a8d94" />
<circle cx="630" cy="495" r="1.4" fill="#8a8d94" />
</g>
</g>
<!-- Ground contact zone highlight - dynamically shown -->
<g id="contactHighlight" opacity="0">
<ellipse cx="450" cy="442" rx="180" ry="18" fill="none" stroke="#f0c060" stroke-width="2.5"
stroke-dasharray="8 4" filter="url(#glowGold)" opacity="0.8">
<animate attributeName="stroke-dashoffset" from="0" to="-24" dur="1.5s" repeatCount="indefinite" />
</ellipse>
</g>
<!-- Snake body segments -->
<g id="snakeBodyGroup" filter="url(#softShadow)">
<!-- Segment 1 (left) -->
<g id="segment1" class="segment" data-index="0">
<rect x="100" y="280" width="140" height="85" rx="12" fill="url(#bodyGrad)" stroke="#4a5748"
stroke-width="1.5" />
<!-- Ventral surface (腹部) highlight -->
<rect x="104" y="345" width="132" height="16" rx="6" fill="#252f23" stroke="#3a4538"
stroke-width="1" />
<!-- Scale slots (斜槽) - 15° inclination -->
<g class="slots-group">
<rect x="140" y="356" width="8" height="6" rx="1" fill="#1a2018" transform="rotate(-15, 144, 359)"
stroke="#2a3328" stroke-width="0.8" />
<rect x="170" y="356" width="8" height="6" rx="1" fill="#1a2018" transform="rotate(-15, 174, 359)"
stroke="#2a3328" stroke-width="0.8" />
<rect x="200" y="356" width="8" height="6" rx="1" fill="#1a2018" transform="rotate(-15, 204, 359)"
stroke="#2a3328" stroke-width="0.8" />
</g>
<!-- Scales for segment 1 -->
<g class="scales-group" id="scales1">
<!-- These will be dynamically generated/updated -->
</g>
</g>
<!-- Segment 2 (center) -->
<g id="segment2" class="segment" data-index="1">
<rect x="280" y="275" width="150" height="88" rx="13" fill="url(#bodyGrad)" stroke="#4a5748"
stroke-width="1.5" />
<rect x="284" y="343" width="142" height="16" rx="6" fill="#252f23" stroke="#3a4538"
stroke-width="1" />
<g class="slots-group">
<rect x="325" y="354" width="8" height="6" rx="1" fill="#1a2018" transform="rotate(-15, 329, 357)"
stroke="#2a3328" stroke-width="0.8" />
<rect x="358" y="354" width="8" height="6" rx="1" fill="#1a2018" transform="rotate(-15, 362, 357)"
stroke="#2a3328" stroke-width="0.8" />
<rect x="391" y="354" width="8" height="6" rx="1" fill="#1a2018" transform="rotate(-15, 395, 357)"
stroke="#2a3328" stroke-width="0.8" />
</g>
<g class="scales-group" id="scales2">
</g>
</g>
<!-- Segment 3 (right) -->
<g id="segment3" class="segment" data-index="2">
<rect x="470" y="278" width="145" height="86" rx="12" fill="url(#bodyGrad)" stroke="#4a5748"
stroke-width="1.5" />
<rect x="474" y="344" width="137" height="16" rx="6" fill="#252f23" stroke="#3a4538"
stroke-width="1" />
<g class="slots-group">
<rect x="515" y="355" width="8" height="6" rx="1" fill="#1a2018" transform="rotate(-15, 519, 358)"
stroke="#2a3328" stroke-width="0.8" />
<rect x="548" y="355" width="8" height="6" rx="1" fill="#1a2018" transform="rotate(-15, 552, 358)"
stroke="#2a3328" stroke-width="0.8" />
<rect x="581" y="355" width="8" height="6" rx="1" fill="#1a2018" transform="rotate(-15, 585, 358)"
stroke="#2a3328" stroke-width="0.8" />
</g>
<g class="scales-group" id="scales3">
</g>
</g>
<!-- Segment 4 (far right) -->
<g id="segment4" class="segment" data-index="3">
<rect x="655" y="281" width="135" height="83" rx="11" fill="url(#bodyGrad)" stroke="#4a5748"
stroke-width="1.5" />
<rect x="659" y="343" width="127" height="15" rx="5" fill="#252f23" stroke="#3a4538"
stroke-width="1" />
<g class="slots-group">
<rect x="698" y="354" width="8" height="6" rx="1" fill="#1a2018" transform="rotate(-15, 702, 357)"
stroke="#2a3328" stroke-width="0.8" />
<rect x="730" y="354" width="8" height="6" rx="1" fill="#1a2018" transform="rotate(-15, 734, 357)"
stroke="#2a3328" stroke-width="0.8" />
<rect x="762" y="354" width="8" height="6" rx="1" fill="#1a2018" transform="rotate(-15, 766, 357)"
stroke="#2a3328" stroke-width="0.8" />
</g>
<g class="scales-group" id="scales4">
</g>
</g>
</g>
<!-- Force arrows and annotations - dynamic group -->
<g id="annotationsGroup">
<!-- Horizontal motion arrow -->
<g id="arrowHorizontal" opacity="0">
<line x1="380" y1="520" x2="580" y2="520" stroke="#58a6ff" stroke-width="2.5"
marker-end="url(#arrowCyan)" stroke-dasharray="6 3" filter="url(#glowCyan)" />
<text x="480" y="540" text-anchor="middle" font-family="'SF Mono','Consolas',monospace" font-size="12"
fill="#58a6ff" letter-spacing="0.05em">纵向滑动方向(低摩擦)</text>
</g>
<!-- Vertical/diagonal force arrow -->
<g id="arrowVertical" opacity="0">
<line x1="450" y1="500" x2="450" y2="400" stroke="#f85149" stroke-width="2.5"
marker-end="url(#arrowRed)" stroke-dasharray="6 3" filter="url(#glowRed)" />
<text x="460" y="460" text-anchor="start" font-family="'SF Mono','Consolas',monospace" font-size="12"
fill="#f85149" letter-spacing="0.05em">横向压地力(高摩擦)</text>
</g>
<!-- IFR annotation -->
<g id="ifrBadge" opacity="0.9">
<rect x="620" y="70" width="240" height="48" rx="24" fill="rgba(22,27,34,0.85)"
stroke="rgba(212,168,83,0.5)" stroke-width="1.2" />
<circle cx="648" cy="94" r="7" fill="#d4a853" filter="url(#glowGold)" />
<text x="664" y="89" font-family="'Georgia',serif" font-size="13" fill="#d4a853"
letter-spacing="0.04em" font-style="italic">理想解 IFR</text>
<text x="664" y="107" font-family="'PingFang SC','Microsoft YaHei',sans-serif" font-size="10"
fill="#8b949e" letter-spacing="0.03em">零电机 · 纯被动自适应</text>
</g>
<!-- Friction coefficient indicators -->
<g id="frictionIndicator" opacity="0">
<!-- High friction - transverse -->
<rect x="55" y="490" width="110" height="55" rx="10" fill="rgba(248,81,73,0.1)"
stroke="rgba(248,81,73,0.4)" stroke-width="1.5" />
<text x="110" y="512" text-anchor="middle" font-family="'SF Mono','Consolas',monospace" font-size="11"
fill="#f85149" letter-spacing="0.04em">横向 μ<sub>⊥</sub> ≈ 高</text>
<text x="110" y="532" text-anchor="middle" font-family="'SF Mono','Consolas',monospace" font-size="10"
fill="#f85149" opacity="0.7">鳞片压平 · 抓地</text>
<!-- Low friction - longitudinal -->
<rect x="730" y="490" width="120" height="55" rx="10" fill="rgba(63,185,80,0.1)"
stroke="rgba(63,185,80,0.4)" stroke-width="1.5" />
<text x="790" y="512" text-anchor="middle" font-family="'SF Mono','Consolas',monospace" font-size="11"
fill="#3fb950" letter-spacing="0.04em">纵向 μ<sub>∥</sub> ≈ 低</text>
<text x="790" y="532" text-anchor="middle" font-family="'SF Mono','Consolas',monospace" font-size="10"
fill="#3fb950" opacity="0.7">鳞片滑行 · 顺滑</text>
</g>
</g>
<!-- Scale detail callout -->
<g id="scaleCallout" opacity="0.85">
<line x1="430" y1="310" x2="530" y2="195" stroke="#d4a853" stroke-width="1" stroke-dasharray="4 3"
opacity="0.6" />
<circle cx="430" cy="310" r="4" fill="#d4a853" filter="url(#glowGold)" />
<rect x="470" y="155" width="200" height="70" rx="8" fill="rgba(22,27,34,0.9)"
stroke="rgba(212,168,83,0.45)" stroke-width="1" />
<text x="485" y="176" font-family="'SF Mono','Consolas',monospace" font-size="10" fill="#d4a853"
letter-spacing="0.04em">鳞片参数</text>
<text x="485" y="193" font-family="'PingFang SC','Microsoft YaHei',sans-serif" font-size="10"
fill="#c9d1d9">翘角 25° · 长 4mm · 槽倾角 15°</text>
<text x="485" y="210" font-family="'PingFang SC','Microsoft YaHei',sans-serif" font-size="10"
fill="#8b949e">间距 2mm · 厚 0.3mm · 聚氨酯</text>
</g>
<!-- Particle effects for friction visualization -->
<g id="frictionParticles" opacity="0">
</g>
</svg>
</div>
<!-- Controls -->
<div class="controls-panel" id="controlsPanel">
<button class="btn play-btn" id="btnAutoPlay" title="自动循环演示">
▶ 自动演示
</button>
<div class="btn-group" id="modeGroup">
<button class="btn active" data-mode="transverse" id="btnTransverse">
←→ 横向抓地
</button>
<button class="btn" data-mode="longitudinal" id="btnLongitudinal">
↑↓ 纵向顺滑
</button>
<button class="btn" data-mode="free" id="btnFree">
◇ 自由状态
</button>
</div>
<div class="slider-group">
<label for="forceSlider">接触力度</label>
<input type="range" id="forceSlider" min="0" max="100" value="60" />
<span class="val-display" id="forceVal">60%</span>
</div>
</div>
<!-- Legend -->
<div class="legend-mini">
<span><span class="dot-sm gold"></span> 鳞片(弹性聚氨酯)</span>
<span><span class="dot-sm cyan"></span> 纵向滑动(低摩擦)</span>
<span><span class="dot-sm red"></span> 横向压地(高摩擦)</span>
<span><span class="dot-sm green"></span> 弹性恢复</span>
</div>
</div>
<script>
(function() {
// --- DOM references ---
const svg = document.getElementById('mainSvg');
const btnAutoPlay = document.getElementById('btnAutoPlay');
const btnTransverse = document.getElementById('btnTransverse');
const btnLongitudinal = document.getElementById('btnLongitudinal');
const btnFree = document.getElementById('btnFree');
const forceSlider = document.getElementById('forceSlider');
const forceVal = document.getElementById('forceVal');
const modeGroup = document.getElementById('modeGroup');
const contactHighlight = document.getElementById('contactHighlight');
const arrowHorizontal = document.getElementById('arrowHorizontal');
const arrowVertical = document.getElementById('arrowVertical');
const frictionIndicator = document.getElementById('frictionIndicator');
const frictionParticles = document.getElementById('frictionParticles');
const ifrBadge = document.getElementById('ifrBadge');
const scaleCallout = document.getElementById('scaleCallout');
// --- State ---
let currentMode = 'transverse'; // 'transverse' | 'longitudinal' | 'free'
let forceAmount = 60; // 0-100
let isAutoPlaying = false;
let autoPlayTimer = null;
let autoPlayPhase = 0; // 0: transverse, 1: free, 2: longitudinal, 3: free
let animFrameId = null;
// --- Segment and scale definitions ---
// Each segment has slots at specific x positions (in SVG coords)
const segmentDefs = [
{ id: 'segment1', slots: [144, 174, 204], bodyBottom: 359, groundY: 440 },
{ id: 'segment2', slots: [329, 362, 395], bodyBottom: 357, groundY: 440 },
{ id: 'segment3', slots: [519, 552, 585], bodyBottom: 358, groundY: 440 },
{ id: 'segment4', slots: [702, 734, 766], bodyBottom: 357, groundY: 440 },
];
// Scale data: for each slot, we create a scale path
const scaleData = []; // { slotX, bodyBottomY, groundY, segmentIndex, slotIndex }
segmentDefs.forEach((seg, segIdx) => {
seg.slots.forEach((slotX, slotIdx) => {
scaleData.push({
slotX: slotX,
bodyBottomY: seg.bodyBottom,
groundY: seg.groundY,
segmentIndex: segIdx,
slotIndex: slotIdx,
id: `scale-${segIdx}-${slotIdx}`,
});
});
});
// --- Create scale SVG elements ---
const scalesGroups = {
'scales1': document.getElementById('scales1'),
'scales2': document.getElementById('scales2'),
'scales3': document.getElementById('scales3'),
'scales4': document.getElementById('scales4'),
};
const allScaleElements = [];
scaleData.forEach((sd) => {
const groupKey = `scales${sd.segmentIndex + 1}`;
const parentGroup = scalesGroups[groupKey];
if (!parentGroup) return;
// Create scale path group
const scaleG = document.createElementNS('http://www.w3.org/2000/svg', 'g');
scaleG.setAttribute('class', 'scale-instance');
scaleG.setAttribute('data-id', sd.id);
scaleG.setAttribute('data-slot-x', sd.slotX);
scaleG.setAttribute('data-body-bottom', sd.bodyBottomY);
// Main scale body - thick path representing the thin elastic sheet
const scalePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
scalePath.setAttribute('class', 'scale-path');
scalePath.setAttribute('fill', 'none');
scalePath.setAttribute('stroke', 'url(#scaleActiveGrad)');
scalePath.setAttribute('stroke-width', '5');
scalePath.setAttribute('stroke-linecap', 'round');
scalePath.setAttribute('stroke-linejoin', 'round');
scalePath.setAttribute('filter', 'url(#glowGold)');
scalePath.setAttribute('data-id', sd.id);
// Scale tip highlight
const scaleTip = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
scaleTip.setAttribute('class', 'scale-tip');
scaleTip.setAttribute('r', '3.5');
scaleTip.setAttribute('fill', '#f0c060');
scaleTip.setAttribute('filter', 'url(#glowGold)');
scaleTip.setAttribute('data-id', sd.id);
scaleG.appendChild(scalePath);
scaleG.appendChild(scaleTip);
parentGroup.appendChild(scaleG);
allScaleElements.push({
group: scaleG,
path: scalePath,
tip: scaleTip,
data: sd,
});
});
// --- Compute scale geometry based on mode and force ---
function getScaleState(sd, mode, force) {
const slotX = sd.slotX;
const bodyBottomY = sd.bodyBottomY;
const groundY = sd.groundY;
const scaleLength = 52; // SVG units representing ~4mm
const freeAngle = 25; // degrees -翘角
const slotAngle = -15; // degrees - 槽倾角(负表示向后倾斜)
// Root point: where scale emerges from slot
const rootX = slotX;
const rootY = bodyBottomY + 2;
// In free state: scale extends down-backward, tip curves up (翘起25°)
// The scale emerges from the 15° slot, then the free end tilts up 25° relative to horizontal
// Effectively the tip is at angle = -15° + 25° = 10° above horizontal? No...
// The scale comes out of a 15° backward-inclined slot, so it initially goes down-backward at ~15° from vertical
// Then the free end翘起25° means the tip is 25° above the line of the slot
// In side view: slot inclines 15° backward from vertical -> scale base direction is 15° backward from downward
// Free end翘起25° -> tip is 25° above that base direction
// So total angle from horizontal: the scale goes mostly downward with a slight backward lean
// Simplified model:
// - Root at (rootX, rootY)
// - Scale extends roughly downward and slightly backward
// - In FREE state: tip is at angle such that the scale curves back up slightly (25°翘角)
// - In TRANSVERSE (pressed): tip is pushed forward and down, contacting ground, scale flattens
// - In LONGITUDINAL: scale can slide, tip stays near ground but with minimal resistance
const rad = (deg) => deg * Math.PI / 180;
// Base direction (from slot): 15° backward from vertical -> 105° from positive x-axis
const baseAngle = 105; // degrees from positive x (pointing down-right-backward)
const baseAngleRad = rad(baseAngle);
// Free state tip position (翘起25° from base direction -> tip is 25° higher/closer to body)
const freeTipAngle = baseAngle - 25; //翘起means tip rises, reducing the downward angle
const freeTipAngleRad = rad(freeTipAngle);
// Contact state: scale is pressed flat against ground
// Tip is at ground level or slightly below, scale is flatter
let tipX, tipY;
let controlOffsetX, controlOffsetY;
if (mode === 'free') {
// Free state: scale翘起, tip well above ground
const tipDist = scaleLength * (1 - force * 0.003);
tipX = rootX + tipDist * Math.cos(freeTipAngleRad);
tipY = rootY + tipDist * Math.sin(freeTipAngleRad);
// Control point for bezier - gives the scale a slight S-curve
const cpDist = scaleLength * 0.55;
const cpAngle = baseAngle - 8;
const cpAngleRad = rad(cpAngle);
controlOffsetX = cpDist * Math.cos(cpAngleRad);
controlOffsetY = cpDist * Math.sin(cpAngleRad);
} else if (mode === 'transverse') {
// Transverse: scale pressed flat, tip contacts ground
// Force pushes scale down and forward, flattening it
const flattenFactor = 0.15 + force * 0.0085; // 0.15 to 1.0
const tipDist = scaleLength * (0.85 + flattenFactor * 0.15);
// Tip moves toward ground and slightly forward
const targetTipY = groundY - 2 + force * 0.06;
const targetTipX = rootX + scaleLength * 0.7 * Math.cos(rad(baseAngle - 15));
tipX = rootX + (targetTipX - rootX) * flattenFactor;
tipY = rootY + (targetTipY - rootY) * flattenFactor;
// Ensure tip doesn't go below ground
if (tipY > groundY - 0.5) tipY = groundY - 0.5;
// Flatter control point
const cpDist = scaleLength * 0.5;
const cpAngle = baseAngle - 20 * flattenFactor;
const cpAngleRad = rad(Math.max(cpAngle, 75));
controlOffsetX = cpDist * Math.cos(cpAngleRad);
controlOffsetY = cpDist * Math.sin(cpAngleRad);
} else {
// Longitudinal: scale slides along ground, low resistance
// Scale is in contact but oriented for easy sliding
const slideFactor = 0.3 + force * 0.005;
const tipDist = scaleLength * 0.9;
// Tip is near ground but the scale orientation allows sliding
const targetTipY = groundY - 1.5 + force * 0.04;
const targetTipX = rootX + scaleLength * 0.8 * Math.cos(rad(baseAngle - 5));
tipX = rootX + (targetTipX - rootX) * slideFactor;
tipY = rootY + (targetTipY - rootY) * slideFactor;
if (tipY > groundY - 0.3) tipY = groundY - 0.3;
const cpDist = scaleLength * 0.5;
const cpAngle = baseAngle - 10;
const cpAngleRad = rad(Math.max(cpAngle, 78));
controlOffsetX = cpDist * Math.cos(cpAngleRad);
controlOffsetY = cpDist * Math.sin(cpAngleRad);
}
return {
rootX,
rootY,
tipX,
tipY,
controlX: rootX + controlOffsetX,
controlY: rootY + controlOffsetY,
isContactingGround: tipY >= (sd.groundY - 3),
contactIntensity: mode === 'transverse' ? force / 100 : (mode === 'longitudinal' ? force * 0.4 / 100 : 0),
};
}
// --- Render all scales ---
function renderAllScales(mode, force) {
allScaleElements.forEach((el) => {
const state = getScaleState(el.data, mode, force);
const { rootX, rootY, tipX, tipY, controlX, controlY, isContactingGround, contactIntensity } =
state;
// Update path - use quadratic bezier for smooth curve
const d =
`M${rootX},${rootY} Q${controlX},${controlY} ${tipX},${tipY}`;
el.path.setAttribute('d', d);
// Update tip circle
el.tip.setAttribute('cx', tipX);
el.tip.setAttribute('cy', tipY);
// Color based on state
if (mode === 'transverse' && contactIntensity > 0.3) {
// High friction - warm gold/amber
el.path.setAttribute('stroke', 'url(#scaleActiveGrad)');
el.path.setAttribute('stroke-width', '5.5');
el.path.setAttribute('opacity', '1');
el.tip.setAttribute('fill', '#f0c060');
el.tip.setAttribute('r', '4');
el.tip.setAttribute('opacity', '1');
} else if (mode === 'longitudinal' && contactIntensity > 0.1) {
// Low friction - cooler tone
el.path.setAttribute('stroke', 'url(#scalePassiveGrad)');
el.path.setAttribute('stroke-width', '4.5');
el.path.setAttribute('opacity', '0.85');
el.tip.setAttribute('fill', '#8b9db5');
el.tip.setAttribute('r', '3');
el.tip.setAttribute('opacity', '0.8');
} else {
// Free state - muted but visible
el.path.setAttribute('stroke', '#b8a070');
el.path.setAttribute('stroke-width', '4');
el.path.setAttribute('opacity', '0.75');
el.tip.setAttribute('fill', '#c4a870');
el.tip.setAttribute('r', '3');
el.tip.setAttribute('opacity', '0.7');
}
// Glow intensity
if (mode === 'transverse' && contactIntensity > 0.5) {
el.path.setAttribute('filter', 'url(#glowGold)');
el.tip.setAttribute('filter', 'url(#glowGold)');
} else if (mode === 'longitudinal') {
el.path.setAttribute('filter', 'url(#glowCyan)');
el.tip.setAttribute('filter', 'url(#glowCyan)');
} else {
el.path.setAttribute('filter', 'none');
el.tip.setAttribute('filter', 'none');
}
});
}
// --- Update annotations and visual effects ---
function updateAnnotations(mode, force) {
// Contact highlight
if (mode === 'transverse' && force > 30) {
contactHighlight.setAttribute('opacity', force / 100 * 0.9);
} else if (mode === 'longitudinal' && force > 20) {
contactHighlight.setAttribute('opacity', force / 100 * 0.35);
} else {
contactHighlight.setAttribute('opacity', '0');
}
// Arrows
if (mode === 'transverse') {
arrowVertical.setAttribute('opacity', force / 100 * 1);
arrowHorizontal.setAttribute('opacity', '0');
} else if (mode === 'longitudinal') {
arrowHorizontal.setAttribute('opacity', force / 100 * 1);
arrowVertical.setAttribute('opacity', '0');
} else {
arrowVertical.setAttribute('opacity', '0');
arrowHorizontal.setAttribute('opacity', '0');
}
// Friction indicators
if (mode !== 'free' && force > 20) {
frictionIndicator.setAttribute('opacity', '0.9');
} else if (mode === 'free') {
frictionIndicator.setAttribute('opacity', '0');
} else {
frictionIndicator.setAttribute('opacity', force / 100 * 0.8);
}
// IFR badge - always visible but subtle
ifrBadge.setAttribute('opacity', '0.85');
// Scale callout
scaleCallout.setAttribute('opacity', '0.8');
// Friction particles
if (mode === 'transverse' && force > 40) {
frictionParticles.setAttribute('opacity', '0.7');
updateFrictionParticles('high', force);
} else if (mode === 'longitudinal' && force > 30) {
frictionParticles.setAttribute('opacity', '0.4');
updateFrictionParticles('low', force);
} else {
frictionParticles.setAttribute('opacity', '0');
frictionParticles.innerHTML = '';
}
}
// --- Friction particles ---
function updateFrictionParticles(type, force) {
frictionParticles.innerHTML = '';
const count = type === 'high' ? Math.floor(force * 0.3) : Math.floor(force * 0.12);
const color = type === 'high' ? '#f0c060' : '#8b9db5';
const spreadX = type === 'high' ? 200 : 300;
const spreadY = type === 'high' ? 25 : 15;
for (let i = 0; i < count; i++) {
const particle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
const cx = 350 + Math.random() * spreadX;
const cy = 432 + Math.random() * spreadY;
const r = 0.8 + Math.random() * 2.2;
const opacity = 0.3 + Math.random() * 0.7;
particle.setAttribute('cx', cx);
particle.setAttribute('cy', cy);
particle.setAttribute('r', r);
particle.setAttribute('fill', color);
particle.setAttribute('opacity', opacity);
// Animation
const anim = document.createElementNS('http://www.w3.org/2000/svg', 'animate');
anim.setAttribute('attributeName', 'opacity');
anim.setAttribute('values', `${opacity};${opacity*0.2};${opacity}`);
anim.setAttribute('dur', `${0.6 + Math.random()*1.2}s`);
anim.setAttribute('repeatCount', 'indefinite');
particle.appendChild(anim);
const animY = document.createElementNS('http://www.w3.org/2000/svg', 'animate');
animY.setAttribute('attributeName', 'cy');
animY.setAttribute('values',
`${cy};${cy - 3 - Math.random()*6};${cy}`);
animY.setAttribute('dur', `${0.8 + Math.random()*1.5}s`);
animY.setAttribute('repeatCount', 'indefinite');
particle.appendChild(animY);
frictionParticles.appendChild(particle);
}
}
// --- Update everything ---
function updateAll(mode, force) {
renderAllScales(mode, force);
updateAnnotations(mode, force);
}
// --- Mode switching ---
function setMode(mode) {
currentMode = mode;
// Update button states
document.querySelectorAll('#modeGroup .btn').forEach(b => b.classList.remove('active'));
if (mode === 'transverse') btnTransverse.classList.add('active');
if (mode === 'longitudinal') btnLongitudinal.classList.add('active');
if (mode === 'free') btnFree.classList.add('active');
updateAll(mode, forceAmount);
}
// --- Auto-play ---
function startAutoPlay() {
if (isAutoPlaying) return;
isAutoPlaying = true;
btnAutoPlay.textContent = '⏸ 停止演示';
btnAutoPlay.classList.add('playing');
autoPlayPhase = 0;
runAutoPlayCycle();
}
function stopAutoPlay() {
isAutoPlaying = false;
btnAutoPlay.textContent = '▶ 自动演示';
btnAutoPlay.classList.remove('playing');
if (autoPlayTimer) clearTimeout(autoPlayTimer);
autoPlayTimer = null;
if (animFrameId) cancelAnimationFrame(animFrameId);
animFrameId = null;
}
function runAutoPlayCycle() {
if (!isAutoPlaying) return;
const phases = [
{ mode: 'transverse', forceTarget: 75, duration: 1800, label: '横向抓地' },
{ mode: 'transverse', forceTarget: 20, duration: 600, label: '释放' },
{ mode: 'free', forceTarget: 10, duration: 1000, label: '自由恢复' },
{ mode: 'longitudinal', forceTarget: 60, duration: 1600, label: '纵向顺滑' },
{ mode: 'longitudinal', forceTarget: 15, duration: 600, label: '释放' },
{ mode: 'free', forceTarget: 5, duration: 1000, label: '自由恢复' },
];
const phase = phases[autoPlayPhase % phases.length];
setMode(phase.mode);
// Animate force to target
const startForce = forceAmount;
const targetForce = phase.forceTarget;
const startTime = performance.now();
const duration = phase.duration;
function animateForce(ts) {
const elapsed = ts - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease in-out
const eased = progress < 0.5 ?
2 * progress * progress :
-1 + (4 - 2 * progress) * progress;
const currentForce = startForce + (targetForce - startForce) * eased;
forceAmount = Math.round(currentForce);
forceSlider.value = forceAmount;
forceVal.textContent = forceAmount + '%';
updateAll(phase.mode, forceAmount);
if (progress < 1) {
animFrameId = requestAnimationFrame(animateForce);
} else {
forceAmount = targetForce;
forceSlider.value = forceAmount;
forceVal.textContent = forceAmount + '%';
updateAll(phase.mode, forceAmount);
autoPlayPhase++;
autoPlayTimer = setTimeout(() => {
runAutoPlayCycle();
}, 300);
}
}
animFrameId = requestAnimationFrame(animateForce);
}
// --- Event listeners ---
btnAutoPlay.addEventListener('click', () => {
if (isAutoPlaying) {
stopAutoPlay();
} else {
startAutoPlay();
}
});
btnTransverse.addEventListener('click', () => {
stopAutoPlay();
setMode('transverse');
});
btnLongitudinal.addEventListener('click', () => {
stopAutoPlay();
setMode('longitudinal');
});
btnFree.addEventListener('click', () => {
stopAutoPlay();
setMode('free');
});
forceSlider.addEventListener('input', () => {
forceAmount = parseInt(forceSlider.value);
forceVal.textContent = forceAmount + '%';
updateAll(currentMode, forceAmount);
});
// --- Keyboard controls ---
document.addEventListener('keydown', (e) => {
switch (e.key.toLowerCase()) {
case '1':
stopAutoPlay();
setMode('transverse');
break;
case '2':
stopAutoPlay();
setMode('longitudinal');
break;
case '3':
stopAutoPlay();
setMode('free');
break;
case ' ':
e.preventDefault();
if (isAutoPlaying) stopAutoPlay();
else startAutoPlay();
break;
case 'arrowleft':
forceAmount = Math.max(0, forceAmount - 10);
forceSlider.value = forceAmount;
forceVal.textContent = forceAmount + '%';
updateAll(currentMode, forceAmount);
break;
case 'arrowright':
forceAmount = Math.min(100, forceAmount + 10);
forceSlider.value = forceAmount;
forceVal.textContent = forceAmount + '%';
updateAll(currentMode, forceAmount);
break;
default:
break;
}
});
// --- Touch support for slider ---
forceSlider.addEventListener('touchmove', (e) => {
forceAmount = parseInt(forceSlider.value);
forceVal.textContent = forceAmount + '%';
updateAll(currentMode, forceAmount);
});
// --- Initial render ---
setMode('transverse');
forceAmount = 60;
forceSlider.value = 60;
forceVal.textContent = '60%';
updateAll('transverse', 60);
// --- Handle SVG container resize ---
const resizeObserver = new ResizeObserver(() => {
// SVG viewBox handles scaling automatically
updateAll(currentMode, forceAmount);
});
const svgContainer = document.getElementById('svgContainer');
if (svgContainer) resizeObserver.observe(svgContainer);
console.log('🐍 蛇腹鳞片各向异性摩擦原理动画已就绪');
console.log(' TRIZ IFR:零电机 · 纯被动自适应');
console.log(' 操作:按钮切换模式 | 空格键自动演示 | ← → 调整力度 | 1/2/3 切换状态');
})();
</script>
</body>
</html>
动画机制与交互
这个动画演绎了您提出的“最后理想化”构想,借助鳞片弹性变形来呈现“横向抓地/纵向顺滑”的被动摩擦分离效应。
- 核心机制:动画的核心在于鳞片形态的动态计算。它模拟了鳞片根部嵌入倾角为-15°的斜槽,在横向压地时,鳞片受“地面反力”被压平,增大接触面积并呈现暖金色高亮;在纵向滑动时,鳞片顺向滑行,呈现冷色调低摩擦状态。
- 操作方式:您可以通过下方的按钮组切换“横向抓地”、“纵向顺滑”或“自由状态”。使用力度滑块可以控制接触力的大小,直观感受摩擦差异。点击“自动演示”按钮或按下空格键,动画将循环展示完整的“抓地-释放-滑行-恢复”时序。
- 视觉风格:整体采用深色科技感主题。鳞片在受压时会产生金色光晕与粒子特效,突出高摩擦接触点;滑动时则切换为青色光效。画面中的箭头、摩擦系数指示器和参数标注,清晰地引导您关注核心创新点。
优化建议: 您可以在代码开头的<style>标签中修改CSS变量(如--accent-gold)来调整主题色;鳞片的物理参数(如翘角25°、长度4mm)位于getScaleState函数开头的变量中,可根据需要微调。
积分规则:第一轮对话扣减6分,后续每轮扣4分
等待动画代码生成...
