代码
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>鹈鹕骑行记</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#fbf5e9;color:#253f4a;font-family:ui-rounded,'Microsoft YaHei',sans-serif;min-height:100vh;display:grid;place-items:center}main{width:min(100%,1100px);padding:28px 24px 20px}header{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:0 5%}h1{font-size:clamp(20px,3vw,30px);margin:8px 0;font-weight:800;letter-spacing:2px}.eyebrow{font-size:10px;letter-spacing:4px;color:#7d8e8e}.tag{font-size:12px;background:#f0e8d9;border-radius:30px;padding:9px 14px;white-space:nowrap}svg{display:block;width:100%;height:auto;overflow:visible}.controls{display:flex;align-items:center;justify-content:center;gap:20px;font-size:12px;color:#677b80}button{border:1px solid #253f4a;background:#253f4a;color:white;border-radius:30px;padding:10px 23px;font:inherit;cursor:pointer;min-width:84px}button:hover{background:#386271}button:focus-visible,input:focus-visible{outline:3px solid #f5b84c;outline-offset:4px}label{display:flex;align-items:center;gap:10px}input{width:90px;accent-color:#ef8860}output{min-width:32px}.caption{text-align:center;color:#91a09d;font-size:11px;margin-top:18px;letter-spacing:2px}@media(max-width:500px){main{padding:25px 8px}header{padding:0 8%}.tag{display:none}.controls{gap:15px}}
</style>
</head>
<body>
<main>
<header><div><div class="eyebrow">A LITTLE JOY IN MOTION</div><h1>鹈鹕骑行记</h1></div><span class="tag">今天也要快乐兜风</span></header>
<svg id="scene" viewBox="0 0 1000 610" role="img" aria-labelledby="sceneTitle sceneDesc" xmlns="http://www.w3.org/2000/svg">
<title id="sceneTitle">一只骑着迷你彩色自行车的可爱鹈鹕</title>
<desc id="sceneDesc">鹈鹕张开橙色大嘴,微张翅膀保持平衡,两只蹼脚轮流踩动踏板。自行车的彩色车轮不断滚动,云朵和地面向后移动,形成流畅的骑行循环。</desc>
<defs>
<pattern id="road" width="180" height="35" patternUnits="userSpaceOnUse"><path d="M25 18h33m46 0h8" stroke="#d5deca" stroke-width="4" stroke-linecap="round"/></pattern>
<g id="wheel" fill="none"><circle r="63" stroke="#253f4a" stroke-width="12"/><circle r="54" stroke="#fffdf6" stroke-width="5"/><g stroke="#8bc8c2" stroke-width="3"><path d="M0-50V50M-50 0H50M-35-35L35 35M-35 35L35-35"/></g><circle r="9" fill="#f9c450" stroke="#253f4a" stroke-width="4"/><path d="M-45-37A58 58 0 0 1-16-56" stroke="#f9c450" stroke-width="5" stroke-linecap="round"/><path d="M45 37A58 58 0 0 1 16 56" stroke="#ef8860" stroke-width="5" stroke-linecap="round"/></g>
<g id="cloud" fill="#fffdf8"><path d="M-59 15C-74 15-72-8-55-10C-55-36-15-43-6-15C18-38 45-18 40 0C68-6 74 24 50 25H-48Z"/></g>
</defs>
<!-- 背景元素按同一周期复位;超出画面的云朵保持在裁切区域之外。 -->
<clipPath id="skyClip"><rect x="65" y="40" width="870" height="450" rx="180"/></clipPath>
<g clip-path="url(#skyClip)">
<rect x="65" y="40" width="870" height="450" rx="180" fill="#e5f2e9"/>
<circle cx="757" cy="143" r="52" fill="#f8ca62"/><circle cx="742" cy="137" r="3" fill="#b58139"/><circle cx="763" cy="137" r="3" fill="#b58139"/><path d="M745 150q7 6 14 0" stroke="#b58139" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<g id="cloudA" transform="translate(250 136)"><use href="#cloud"/></g><g id="cloudB" transform="translate(590 97) scale(.75)"><use href="#cloud"/></g>
<path d="M10 468Q183 365 335 451Q543 371 701 456Q835 391 1010 460V530H10Z" fill="#d4e8d5"/>
<path d="M15 491Q240 420 469 487Q731 429 1009 489V530H15Z" fill="#c4dfc8"/>
</g>
<ellipse id="shadow" cx="505" cy="548" rx="213" ry="16" fill="#293f40" opacity=".09"/>
<path d="M165 544H846" stroke="#c3d3bd" stroke-width="3" stroke-linecap="round"/>
<rect x="160" y="549" width="680" height="35" fill="url(#road)"/>
<g id="bicycle" stroke="#253f4a" stroke-width="5" stroke-linejoin="round" stroke-linecap="round">
<g id="rearWheel" transform="translate(374 478)"><use href="#wheel"/></g><g id="frontWheel" transform="translate(635 478)"><use href="#wheel"/></g>
<!-- 远侧曲柄与腿在车架之后,近侧蹼脚在车架之前。 -->
<path id="farLeg" d="M447 357L470 404L475 453" stroke="#dba048" stroke-width="12" fill="none"/>
<g id="farFoot" fill="#e89944"><path d="M-10-9Q-14 0-12 7L24 7L19-1L10 0L6-7L1-4Z" stroke-width="3"/><path d="M-14 11H23" stroke-width="6"/></g>
<path d="M374 478L441 401L500 454Z" fill="none" stroke="#ec797e" stroke-width="11"/>
<path d="M441 401H595L500 454" fill="none" stroke="#51b9b4" stroke-width="11"/>
<path d="M595 401L635 478" fill="none" stroke="#f8c457" stroke-width="12"/>
<path d="M595 401L578 349Q575 335 590 332L606 333" fill="none" stroke="#51b9b4" stroke-width="9"/>
<path d="M598 332h22" stroke-width="10"/><path d="M441 401L434 378" stroke-width="8"/><path d="M411 376Q437 369 455 378" stroke-width="12"/>
<path d="M374 478L500 454" stroke="#253f4a" stroke-width="3" opacity=".35"/>
<circle cx="500" cy="454" r="20" fill="#f9c450"/><circle cx="500" cy="454" r="7" fill="#fffaf0" stroke-width="3"/>
<path id="cranks" d="M475 454H525" fill="none" stroke-width="6"/>
</g>
<g id="rider" stroke="#253f4a" stroke-width="5" stroke-linecap="round" stroke-linejoin="round">
<g id="farWing"><path d="M471 290Q511 252 535 275L517 287L532 289L510 301L520 308Q491 320 471 312Z" fill="#c9e0dc"/></g>
<path d="M364 327Q335 324 322 298Q344 306 368 303L348 283Q382 291 394 308Z" fill="#fffdf5"/>
<path d="M367 289C385 262 433 267 465 286C474 275 477 250 474 231C469 196 487 176 514 178C545 179 551 207 542 231C534 249 522 277 522 310C523 348 485 376 437 375C394 375 355 345 356 313Q356 298 367 289Z" fill="#fffdf5"/>
<path d="M375 333Q398 361 441 359Q481 360 505 334" fill="none" stroke="#d9e6de" stroke-width="10"/>
<path d="M486 190L480 175Q496 176 503 184L505 169Q518 175 523 185" fill="#fffdf5" stroke-width="4"/>
<!-- 张开的上喙与宽大的下喙囊共同表现鹈鹕的标志性嘴型。 -->
<path d="M532 220L663 222Q651 254 610 260L537 246Z" fill="#734e3f"/>
<path d="M536 231Q575 246 661 228Q650 293 590 290Q548 285 536 249Z" fill="#f5b850"/>
<path d="M553 250Q586 278 632 253" fill="none" stroke="#e79b37" stroke-width="4"/>
<path d="M531 211Q578 199 661 207Q682 209 676 219Q626 231 535 225Z" fill="#f48b42"/>
<path d="M549 213Q592 208 642 213" stroke="#ffbf64" stroke-width="4" fill="none"/>
<ellipse cx="519" cy="202" rx="13" ry="17" fill="#fff" stroke-width="3.5"/>
<g id="eye"><ellipse cx="522" cy="205" rx="5.5" ry="8" fill="#253f4a" stroke="none"/><circle cx="524" cy="201" r="2" fill="#fff" stroke="none"/></g>
<path d="M505 178Q515 171 525 178" fill="none" stroke-width="3"/><ellipse cx="507" cy="229" rx="10" ry="6" fill="#f3b7a0" stroke="none"/>
<g id="nearWing"><path d="M420 291Q384 281 352 300Q320 316 292 298Q299 324 324 330L309 333Q330 345 350 339L341 349Q381 349 415 321" fill="#f6fcf6"/><path d="M351 318Q377 321 399 306" fill="none" stroke="#b9d4ce" stroke-width="4"/></g>
</g>
<g stroke="#253f4a" stroke-linecap="round" stroke-linejoin="round">
<path id="nearLeg" d="M453 365L493 394L525 454" stroke="#f3ae46" stroke-width="13" fill="none"/>
<g id="nearFoot" fill="#f4a342"><path d="M-11-11Q-15 0-12 7L27 7L21-3L12 0L6-9L1-4Z" stroke-width="3.5"/><path d="M-15 13H27" stroke-width="6"/><path d="M2 0L7 4M13 2L16 4" stroke="#d58334" stroke-width="2"/></g>
</g>
<g fill="none" stroke="#87b9ad" stroke-width="4" stroke-linecap="round"><path id="wind1" d="M255 355h-33m49 16h-53"/><path id="wind2" d="M721 406h22m-10 14h39"/></g>
<g fill="#ec9c76"><path d="M194 265l4-11 4 11 11 4-11 4-4 11-4-11-11-4Z"/><circle cx="783" cy="328" r="4"/></g>
</svg>
<div class="controls"><button id="toggle" type="button" aria-label="暂停动画">暂停</button><label>骑行速度 <input id="speed" type="range" min="0.5" max="2" step="0.25" value="1" aria-label="骑行速度"><output id="speedValue">1×</output></label></div>
<p class="caption">微风正好,快乐不必赶路。</p>
</main>
<script>
const el = id => document.getElementById(id);
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
let playing = !reducedMotion, speed = 1, elapsed = 0, previous;
const cycle = 2.4;
function updateButton(){el('toggle').textContent=playing?'暂停':'播放';el('toggle').setAttribute('aria-label',playing?'暂停动画':'播放动画');}
el('toggle').onclick=()=>{playing=!playing;updateButton();};
el('speed').oninput=e=>{speed=Number(e.target.value);el('speedValue').textContent=speed+'×';};
// 两段腿使用反向运动学追随踏板,脚面保持水平,避免脚与踏板脱离。
function leg(phase,near,bob){
const foot={x:500+26*Math.cos(phase),y:454+26*Math.sin(phase)};
const hip={x:near?448:438,y:(near?362:355)+bob};
const dx=foot.x-hip.x,dy=foot.y-hip.y,dist=Math.hypot(dx,dy);
const a=65,b=65,along=(a*a-b*b+dist*dist)/(2*dist),height=Math.sqrt(Math.max(0,a*a-along*along));
const knee={x:hip.x+dx*along/dist+dy*height/dist,y:hip.y+dy*along/dist-dx*height/dist};
el(near?'nearLeg':'farLeg').setAttribute('d',`M${hip.x} ${hip.y}Q${knee.x} ${knee.y} ${knee.x} ${knee.y}L${foot.x} ${foot.y-8}`);
el(near?'nearFoot':'farFoot').setAttribute('transform',`translate(${foot.x} ${foot.y-9})`);
return foot;
}
function render(t){
const phase=t/cycle*Math.PI*2,bob=Math.sin(phase*2)*2.5;
// 一个踏板周期内车轮转两圈;地面和云层用取模实现无缝移动。
const turn=phase*360/Math.PI;
el('rearWheel').setAttribute('transform',`translate(374 478) rotate(${turn})`);
el('frontWheel').setAttribute('transform',`translate(635 478) rotate(${turn})`);
el('rider').setAttribute('transform',`translate(0 ${bob})`);
el('nearWing').setAttribute('transform',`rotate(${Math.sin(phase)*6} 416 301)`);
el('farWing').setAttribute('transform',`rotate(${-Math.sin(phase)*5} 475 295)`);
const front=leg(phase,true,bob),back=leg(phase+Math.PI,false,bob);
el('cranks').setAttribute('d',`M${back.x} ${back.y}L500 454L${front.x} ${front.y}`);
el('road').setAttribute('patternTransform',`translate(${-((t*180)%180)} 0)`);
el('cloudA').setAttribute('transform',`translate(${((350-t*13)%1200+1200)%1200-100} 136)`);
el('cloudB').setAttribute('transform',`translate(${((690-t*13)%1200+1200)%1200-100} 97) scale(.75)`);
el('shadow').setAttribute('rx',213+bob);
el('wind1').setAttribute('opacity',.45+.4*(Math.sin(phase)+1)/2);
el('wind2').setAttribute('opacity',.35+.4*(Math.cos(phase)+1)/2);
const blink=t%6.2;el('eye').setAttribute('transform',blink>5.99&&blink<6.12?'translate(0 184) scale(1 .1)':'');
}
function frame(now){if(previous!==undefined&&playing)elapsed+=Math.min((now-previous)/1000,.05)*speed;previous=now;render(elapsed);requestAnimationFrame(frame);}
document.addEventListener('visibilitychange',()=>{previous=undefined;});
updateButton();render(0);requestAnimationFrame(frame);
</script>
</body>
</html>