<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>鹈鹕骑自行车 · SVG 2D 动画</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
overflow: hidden; background: #8fd0ef;
font-family: ui-sans-serif, system-ui, -apple-system, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
.stage { position: fixed; inset: 0; }
.stage svg { width: 100%; height: 100%; display: block; }
/* ---------- 动画 ---------- */
@keyframes scroll { to { transform: translateX(-960px); } }
.scroll { animation: scroll linear infinite; }
#clouds { animation-duration: 48s; }
#hillsFar { animation-duration: 36s; }
#hillsNear { animation-duration: 24s; }
#trees { animation-duration: 8s; }
#ground { animation-duration: 2.26s; } /* 与车速 425px/s 同步 */
@keyframes bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
@keyframes headbob { 0%,100% { transform: rotate(0deg); } 50% { transform: rotate(2.5deg); } }
@keyframes flap { 0%,84%,100% { transform: rotate(0deg); } 89% { transform: rotate(9deg); } 93% { transform: rotate(-2deg); } }
@keyframes blink { 0%,93%,100% { transform: scaleY(1); } 95.5% { transform: scaleY(.08); } 98% { transform: scaleY(1); } }
@keyframes chainmove { to { stroke-dashoffset: -12; } }
@keyframes speedpulse{ 0% { opacity: 0; transform: translateX(16px); } 30% { opacity: .5; } 100% { opacity: 0; transform: translateX(-30px); } }
#upperBody { animation: bob 1.2s ease-in-out infinite; } /* 身体随踏板起伏 */
#headGroup { transform-origin: 450px 250px; animation: headbob 1.2s ease-in-out .1s infinite; }
#pouch { transform-origin: 464px 222px; animation: flap 3.6s ease-in-out infinite; } /* 嘴囊张合 */
#eye { transform-box: fill-box; transform-origin: center; animation: blink 4.2s infinite; }
#chain { animation: chainmove .135s linear infinite; }
.speedline { animation: speedpulse .9s linear infinite; }
.speedline.d2 { animation-delay: .3s; }
.speedline.d3 { animation-delay: .6s; }
/* ---------- UI ---------- */
.card, .controls {
position: absolute;
background: rgba(255,255,255,.78);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
box-shadow: 0 8px 24px rgba(23,58,82,.16);
user-select: none;
}
.card { top: 18px; left: 18px; border-radius: 14px; padding: 10px 18px 12px; }
.card h1 { font-size: 17px; font-weight: 700; color: #173a52; letter-spacing: 1px; }
.card p { font-size: 11px; color: #4a6b82; margin-top: 3px; letter-spacing: .5px; }
.controls {
right: 18px; bottom: 18px;
display: flex; align-items: center; gap: 12px;
border-radius: 999px; padding: 8px 16px 8px 8px;
}
.controls button {
width: 36px; height: 36px; border-radius: 50%; border: none;
background: #173a52; color: #fff; font-size: 12px; cursor: pointer;
display: grid; place-items: center;
transition: transform .15s ease, background .15s ease;
}
.controls button:hover { transform: scale(1.08); background: #24567a; }
.controls label { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 600; color: #173a52; }
.controls input[type="range"] { width: 110px; accent-color: #173a52; cursor: pointer; }
.controls .val { font-variant-numeric: tabular-nums; min-width: 34px; color: #4a6b82; }
</style>
</head>
<body>
<div class="stage">
<svg viewBox="-40 0 960 540" preserveAspectRatio="xMidYMid slice" role="img" aria-label="一只鹈鹕骑着自行车在路上飞驰">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#8fd0ef"/>
<stop offset="1" stop-color="#eef9ff"/>
</linearGradient>
<radialGradient id="sunCore">
<stop offset="0" stop-color="#fff6c9"/><stop offset="1" stop-color="#ffd94a"/>
</radialGradient>
<radialGradient id="sunGlow">
<stop offset="0" stop-color="#ffe98a" stop-opacity=".8"/><stop offset="1" stop-color="#ffe98a" stop-opacity="0"/>
</radialGradient>
<clipPath id="bodyClip"><ellipse cx="352" cy="300" rx="62" ry="48"/></clipPath>
<!-- 车轮(辐条旋转) -->
<g id="wheel">
<circle r="46" fill="none" stroke="#2b2d33" stroke-width="9"/>
<circle r="39" fill="none" stroke="#d7dde3" stroke-width="3"/>
<g stroke="#aab4bf" stroke-width="2.5">
<line x1="-39" y1="0" x2="39" y2="0"/>
<line x1="0" y1="-39" x2="0" y2="39"/>
<line x1="-27.6" y1="-27.6" x2="27.6" y2="27.6"/>
<line x1="-27.6" y1="27.6" x2="27.6" y2="-27.6"/>
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="0.68s" repeatCount="indefinite"/>
</g>
<circle r="6.5" fill="#3a3f4a"/><circle r="2.5" fill="#8d99ae"/>
</g>
<!-- 云 -->
<g id="cloud" fill="#ffffff" opacity=".95">
<ellipse rx="52" ry="19"/><ellipse cx="40" cy="-12" rx="36" ry="16"/>
<ellipse cx="-38" cy="-8" rx="32" ry="14"/><ellipse cx="8" cy="-18" rx="28" ry="13"/>
</g>
<g id="cloudTile">
<use href="#cloud" transform="translate(150 95)"/>
<use href="#cloud" transform="translate(470 62) scale(.75)"/>
<use href="#cloud" transform="translate(775 140) scale(1.15)"/>
</g>
<!-- 远山 / 近山(首尾切线水平,可无缝循环) -->
<g id="hillsFarTile">
<path fill="#c2e2cc" d="M0 400 C40 400 60 336 110 336 C160 336 180 400 220 400 L300 400 C340 400 360 312 430 312 C500 312 520 400 560 400 L620 400 C655 400 670 344 720 344 C770 344 785 400 820 400 L880 400 C900 400 905 370 920 370 C935 370 940 400 960 400 L960 484 L0 484 Z"/>
</g>
<g id="hillsNearTile">
<path fill="#9ccfa8" d="M0 400 C50 400 70 352 130 352 C190 352 210 400 260 400 L380 400 C420 400 440 330 510 330 C580 330 600 400 650 400 L760 400 C790 400 800 356 850 356 C900 356 910 400 960 400 L960 484 L0 484 Z"/>
</g>
<!-- 树 / 灌木 -->
<g id="tree">
<rect x="-6" y="-30" width="12" height="30" rx="3" fill="#8a5a3b"/>
<circle cy="-46" r="27" fill="#4f9e60"/><circle cx="-10" cy="-52" r="14" fill="#63b374"/>
</g>
<g id="bush">
<ellipse rx="24" ry="14" fill="#4f9e60"/><ellipse cx="-8" cy="-6" rx="12" ry="8" fill="#63b374"/>
</g>
<g id="treesTile">
<use href="#tree" transform="translate(120 465)"/>
<use href="#bush" transform="translate(300 470)"/>
<use href="#tree" transform="translate(430 465) scale(.8)"/>
<use href="#bush" transform="translate(580 472) scale(1.2)"/>
<use href="#tree" transform="translate(720 465) scale(1.05)"/>
<use href="#bush" transform="translate(870 471) scale(.9)"/>
</g>
<!-- 草丛 + 路面虚线(与车速同步) -->
<g id="groundTile">
<g stroke="#5ea86e" stroke-width="2.5" stroke-linecap="round" fill="none">
<path d="M60 470 l4 -9 M66 470 l0 -10 M72 470 l-4 -9"/>
<path d="M210 440 l4 -9 M216 440 l0 -10 M222 440 l-4 -9"/>
<path d="M360 465 l4 -9 M366 465 l0 -10 M372 465 l-4 -9"/>
<path d="M520 430 l4 -9 M526 430 l0 -10 M532 430 l-4 -9"/>
<path d="M680 468 l4 -9 M686 468 l0 -10 M692 468 l-4 -9"/>
<path d="M840 445 l4 -9 M846 445 l0 -10 M852 445 l-4 -9"/>
</g>
<circle cx="150" cy="455" r="3" fill="#ffd94a"/>
<circle cx="450" cy="452" r="3" fill="#ff8fa3"/>
<circle cx="760" cy="458" r="3" fill="#ffd94a"/>
<g fill="#f4f7f9" opacity=".92">
<rect x="10" y="504" width="55" height="7" rx="3.5"/><rect x="130" y="504" width="55" height="7" rx="3.5"/>
<rect x="250" y="504" width="55" height="7" rx="3.5"/><rect x="370" y="504" width="55" height="7" rx="3.5"/>
<rect x="490" y="504" width="55" height="7" rx="3.5"/><rect x="610" y="504" width="55" height="7" rx="3.5"/>
<rect x="730" y="504" width="55" height="7" rx="3.5"/><rect x="850" y="504" width="55" height="7" rx="3.5"/>
</g>
</g>
</defs>
<!-- 天空 / 太阳 -->
<rect x="-40" width="1000" height="540" fill="url(#sky)"/>
<g>
<circle cx="830" cy="85" r="62" fill="url(#sunGlow)"/>
<g stroke="#ffd94a" stroke-width="4" stroke-linecap="round" opacity=".7">
<line x1="830" y1="31" x2="830" y2="45"/>
<line x1="830" y1="31" x2="830" y2="45" transform="rotate(30 830 85)"/>
<line x1="830" y1="31" x2="830" y2="45" transform="rotate(60 830 85)"/>
<line x1="830" y1="31" x2="830" y2="45" transform="rotate(90 830 85)"/>
<line x1="830" y1="31" x2="830" y2="45" transform="rotate(120 830 85)"/>
<line x1="830" y1="31" x2="830" y2="45" transform="rotate(150 830 85)"/>
<line x1="830" y1="31" x2="830" y2="45" transform="rotate(180 830 85)"/>
<line x1="830" y1="31" x2="830" y2="45" transform="rotate(210 830 85)"/>
<line x1="830" y1="31" x2="830" y2="45" transform="rotate(240 830 85)"/>
<line x1="830" y1="31" x2="830" y2="45" transform="rotate(270 830 85)"/>
<line x1="830" y1="31" x2="830" y2="45" transform="rotate(300 830 85)"/>
<line x1="830" y1="31" x2="830" y2="45" transform="rotate(330 830 85)"/>
<animateTransform attributeName="transform" type="rotate" from="0 830 85" to="360 830 85" dur="40s" repeatCount="indefinite"/>
</g>
<circle cx="830" cy="85" r="30" fill="url(#sunCore)"/>
</g>
<!-- 视差滚动层 -->
<g id="clouds" class="scroll"><use href="#cloudTile" x="-960"/><use href="#cloudTile"/><use href="#cloudTile" x="960"/></g>
<g id="hillsFar" class="scroll"><use href="#hillsFarTile" x="-960"/><use href="#hillsFarTile"/><use href="#hillsFarTile" x="960"/></g>
<g id="hillsNear" class="scroll"><use href="#hillsNearTile" x="-960"/><use href="#hillsNearTile"/><use href="#hillsNearTile" x="960"/></g>
<rect x="-40" y="400" width="1000" height="84" fill="#79bd88"/>
<g id="trees" class="scroll"><use href="#treesTile" x="-960"/><use href="#treesTile"/><use href="#treesTile" x="960"/></g>
<rect x="-40" y="484" width="1000" height="56" fill="#55606c"/>
<rect x="-40" y="484" width="1000" height="3" fill="#7b8794"/>
<g id="ground" class="scroll"><use href="#groundTile" x="-960"/><use href="#groundTile"/><use href="#groundTile" x="960"/></g>
<!-- 车身阴影 + 速度线 -->
<ellipse cx="430" cy="488" rx="185" ry="9" fill="#1f2a33" opacity=".18"/>
<g stroke="#ffffff" stroke-width="5" stroke-linecap="round">
<line class="speedline" x1="150" y1="372" x2="215" y2="372"/>
<line class="speedline d2" x1="135" y1="412" x2="205" y2="412"/>
<line class="speedline d3" x1="150" y1="452" x2="220" y2="452"/>
</g>
<!-- 远侧腿 + 远侧脚(在车身之后) -->
<path id="legFar" d="M360 332 L417.8 384.5 L379 452" fill="none" stroke="#c96f2a" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<ellipse id="footFar" rx="8" ry="4.5" fill="#c96f2a" transform="translate(379 452)"/>
<!-- 自行车 -->
<g>
<use href="#wheel" x="295" y="438"/>
<use href="#wheel" x="565" y="438"/>
<path d="M295 438 L405 452 L378 352 L295 438 M378 352 L546 358 M405 452 L546 358"
stroke="#e63946" stroke-width="7" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M546 358 L557 381 L565 438" stroke="#8d99ae" stroke-width="6" fill="none" stroke-linecap="round"/>
<path d="M546 358 L540 332" stroke="#8d99ae" stroke-width="6" stroke-linecap="round"/>
<path d="M540 332 C540 318 528 312 516 318" stroke="#8d99ae" stroke-width="6" fill="none" stroke-linecap="round"/>
<circle cx="516" cy="318" r="4" fill="#2b2d33"/>
<path d="M378 352 L373 345" stroke="#8d99ae" stroke-width="5" stroke-linecap="round"/>
<path d="M346 350 C356 341 388 339 400 348 C390 356 356 358 346 350 Z" fill="#2b2d33"/>
<path id="chain" d="M295 430 L405 435 A17 17 0 0 1 405 469 L295 446 A8 8 0 0 1 295 430 Z"
fill="none" stroke="#3a3f4a" stroke-width="3" stroke-dasharray="7 5"/>
<circle cx="295" cy="438" r="8" fill="none" stroke="#555c66" stroke-width="3"/>
<circle cx="405" cy="452" r="17" fill="none" stroke="#555c66" stroke-width="4"/>
<g transform="translate(482.7 415.8) rotate(56.4)">
<rect x="-4.5" y="-10" width="9" height="20" rx="4" fill="#4d96c9"/>
<rect x="-4.5" y="-10" width="9" height="5" rx="2.5" fill="#d7e6f2"/>
</g>
<g id="crank">
<line x1="405" y1="452" x2="431" y2="452" stroke="#3a3f4a" stroke-width="6" stroke-linecap="round"/>
<line x1="405" y1="452" x2="379" y2="452" stroke="#3a3f4a" stroke-width="6" stroke-linecap="round"/>
<circle cx="416" cy="452" r="2.5" fill="#555c66"/>
<g id="pedal1"><rect x="423" y="449" width="17" height="6" rx="2.5" fill="#2b2d33"/></g>
<g id="pedal2"><rect x="371" y="449" width="17" height="6" rx="2.5" fill="#2b2d33"/></g>
</g>
<circle cx="405" cy="452" r="5" fill="#2b2d33"/>
</g>
<!-- 近侧腿 + 近侧脚 -->
<path id="legNear" d="M368 336 L436.1 374.2 L431 452" fill="none" stroke="#f4913e" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"/>
<ellipse id="footNear" rx="8" ry="4.5" fill="#f4913e" transform="translate(431 452)"/>
<!-- 鹈鹕(上半身) -->
<g id="upperBody">
<path d="M302 286 L258 272 L286 296 L252 302 L288 312 L262 332 L304 320 Z" fill="#eef3f7" stroke="#d3dfe8" stroke-width="1.5"/>
<ellipse cx="352" cy="300" rx="62" ry="48" fill="#ffffff" stroke="#d3dfe8" stroke-width="1.5"/>
<ellipse cx="352" cy="345" rx="60" ry="26" fill="#dde7ee" clip-path="url(#bodyClip)"/>
<g><!-- 翅膀,前伸握住车把 -->
<path d="M372 282 C420 288 478 304 522 318 C530 321 530 330 521 329 C470 318 420 306 380 302 C368 298 366 286 372 282 Z" fill="#b9c7d2"/>
<path d="M400 296 C430 300 458 307 486 315" stroke="#9fb0bd" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<path d="M420 303 C448 308 474 315 500 322" stroke="#9fb0bd" stroke-width="2.5" fill="none" stroke-linecap="round"/>
</g>
<path d="M396 284 C430 276 444 254 450 228" stroke="#ffffff" stroke-width="23" fill="none" stroke-linecap="round"/>
<g id="headGroup">
<path d="M447 198 C444 189 447 183 452 180" stroke="#ffffff" stroke-width="4" fill="none" stroke-linecap="round"/>
<path d="M455 195 C455 186 459 181 464 180" stroke="#ffffff" stroke-width="4" fill="none" stroke-linecap="round"/>
<circle cx="455" cy="216" r="21" fill="#ffffff" stroke="#d3dfe8" stroke-width="1.5"/>
<path d="M462 202 L594 224 L594 230 L462 222 Z" fill="#f6a821"/>
<path d="M584 222.8 L594 224 L594 230 L584 229.2 Z" fill="#d98a12"/>
<path id="pouch" d="M464 222 L594 228 C552 254 500 254 464 222 Z" fill="#ef8b3f"/>
<path d="M464 222 L594 228" stroke="#d97b30" stroke-width="2" stroke-linecap="round"/>
<path d="M478 210 L492 213" stroke="#d98a12" stroke-width="2" stroke-linecap="round"/>
<g id="eye">
<circle cx="460" cy="210" r="4.2" fill="#2b2d33"/>
<circle cx="461.5" cy="208.5" r="1.4" fill="#ffffff"/>
</g>
</g>
</g>
</svg>
<div class="card">
<h1>鹈鹕骑自行车</h1>
<p>SVG · CSS · JS 手绘 2D 动画</p>
</div>
<div class="controls">
<button id="playBtn" title="播放 / 暂停">❚❚</button>
<label>速度
<input id="speed" type="range" min="0.4" max="2" step="0.1" value="1"/>
<span class="val" id="speedVal">1.0×</span>
</label>
</div>
</div>
<script>
(() => {
const $ = id => document.getElementById(id);
const crank = $('crank'), pedal1 = $('pedal1'), pedal2 = $('pedal2');
const playBtn = $('playBtn'), speedInput = $('speed'), speedVal = $('speedVal');
const BB = { x: 405, y: 452 }, R = 26; // 中轴与曲柄半径
const LEGS = [
{ hip: { x: 368, y: 336 }, L1: 78, L2: 78, path: $('legNear'), foot: $('footNear'), phase: 0 },
{ hip: { x: 360, y: 332 }, L1: 78, L2: 78, path: $('legFar'), foot: $('footFar'), phase: Math.PI }
];
let theta = 0, speed = 1, playing = true;
const OMEGA = (Math.PI * 2) / 1.2; // 1× 速度下 1.2s 一圈
// 两骨 IK:求向前弯曲的膝关节
function knee(hip, foot, L1, L2) {
let dx = foot.x - hip.x, dy = foot.y - hip.y;
let d = Math.hypot(dx, dy);
const maxD = L1 + L2 - 0.5, minD = Math.abs(L1 - L2) + 0.5;
if (d > maxD) { dx *= maxD / d; dy *= maxD / d; d = maxD; }
else if (d < minD) { dx *= minD / d; dy *= minD / d; d = minD; }
const a = (L1 * L1 - L2 * L2 + d * d) / (2 * d);
const h = Math.sqrt(Math.max(L1 * L1 - a * a, 0));
const mx = hip.x + a * dx / d, my = hip.y + a * dy / d;
const px = -dy / d, py = dx / d;
const ax = mx + px * h, ay = my + py * h;
const bx = mx - px * h, by = my - py * h;
return bx > ax ? { x: bx, y: by } : { x: ax, y: ay };
}
function render() {
const deg = theta * 180 / Math.PI;
crank.setAttribute('transform', `rotate(${deg} ${BB.x} ${BB.y})`);
pedal1.setAttribute('transform', `rotate(${-deg} ${BB.x + R} ${BB.y})`); // 踏板保持水平
pedal2.setAttribute('transform', `rotate(${-deg} ${BB.x - R} ${BB.y})`);
for (const L of LEGS) {
const a = theta + L.phase;
const f = { x: BB.x + R * Math.cos(a), y: BB.y + R * Math.sin(a) };
const k = knee(L.hip, f, L.L1, L.L2);
L.path.setAttribute('d',
`M ${L.hip.x} ${L.hip.y} L ${k.x.toFixed(1)} ${k.y.toFixed(1)} L ${f.x.toFixed(1)} ${f.y.toFixed(1)}`);
L.foot.setAttribute('transform', `translate(${f.x.toFixed(1)} ${f.y.toFixed(1)})`);
}
}
let last = performance.now();
function tick(now) {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
if (playing) theta = (theta + OMEGA * speed * dt) % (Math.PI * 2);
render();
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
function setPlaying(p) {
playing = p;
playBtn.textContent = p ? '❚❚' : '▶';
document.getAnimations().forEach(a => (p ? a.play() : a.pause()));
}
playBtn.addEventListener('click', () => setPlaying(!playing));
speedInput.addEventListener('input', () => {
speed = parseFloat(speedInput.value);
speedVal.textContent = speed.toFixed(1) + '×';
document.getAnimations().forEach(a => { a.playbackRate = speed; });
});
if (matchMedia('(prefers-reduced-motion: reduce)').matches) setPlaying(false);
})();
</script>
</body>
</html>
