
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>鹈鹕骑自行车</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
<style>
:root {
--sky-top: #a9d3e6;
--sky-bottom: #f6e3b8;
--sea: #4a8ea3;
--sea-light: #7fb8c9;
--sand: #e8cf97;
--road: #6a5f57;
--feather: #fbf7ee;
--feather-shade: #d9d2c4;
--bill: #ef9f4f;
--pouch: #f2c46b;
--ink: #2a2622;
--frame: #c9473b;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background: var(--sky-bottom);
color: var(--ink);
font-family: "Fraunces", Georgia, serif;
display: grid;
grid-template-rows: 1fr auto;
min-height: 100vh;
}
.stage {
width: 100%;
height: 100%;
display: block;
}
figcaption {
padding: 14px 24px 22px;
font-size: 17px;
line-height: 1.5;
font-style: italic;
text-align: center;
max-width: 40em;
margin: 0 auto;
}
@media (prefers-reduced-motion: reduce) {
.cloud, .wave { animation: none !important; }
}
</style>
</head>
<body>
<svg class="stage" viewBox="0 0 800 500" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="一只鹈鹕沿着海边公路骑自行车的动画">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--sky-top)"/>
<stop offset="1" stop-color="var(--sky-bottom)"/>
</linearGradient>
<linearGradient id="sea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--sea-light)"/>
<stop offset="1" stop-color="var(--sea)"/>
</linearGradient>
<!-- 云 -->
<g id="cloud">
<ellipse cx="0" cy="0" rx="42" ry="16" fill="#fff" opacity=".85"/>
<ellipse cx="-22" cy="4" rx="26" ry="12" fill="#fff" opacity=".85"/>
<ellipse cx="26" cy="5" rx="24" ry="11" fill="#fff" opacity=".85"/>
</g>
<!-- 车轮 -->
<g id="wheel">
<circle r="58" fill="none" stroke="var(--ink)" stroke-width="7"/>
<circle r="52" fill="none" stroke="#8c8177" stroke-width="2"/>
<g stroke="#8c8177" stroke-width="1.6">
<line x1="0" y1="-52" x2="0" y2="52"/>
<line x1="-52" y1="0" x2="52" y2="0"/>
<line x1="-36.8" y1="-36.8" x2="36.8" y2="36.8"/>
<line x1="-36.8" y1="36.8" x2="36.8" y2="-36.8"/>
<line x1="-19.9" y1="-48" x2="19.9" y2="48"/>
<line x1="-48" y1="-19.9" x2="48" y2="19.9"/>
<line x1="19.9" y1="-48" x2="-19.9" y2="48"/>
<line x1="48" y1="-19.9" x2="-48" y2="19.9"/>
</g>
<circle r="7" fill="var(--ink)"/>
</g>
<!-- 蹼足 -->
<path id="foot" d="M-14 0 L14 0 L20 -6 L10 -3 L6 -8 L0 -3 L-6 -8 L-10 -3 L-20 -6 Z" fill="var(--bill)" stroke="var(--ink)" stroke-width="1.5" stroke-linejoin="round"/>
</defs>
<!-- 天空 -->
<rect width="800" height="500" fill="url(#sky)"/>
<circle cx="640" cy="120" r="46" fill="#ffe7a8"/>
<circle cx="640" cy="120" r="70" fill="#ffe7a8" opacity=".25"/>
<!-- 云层(两组,循环平移) -->
<g id="clouds" opacity=".9">
<use href="#cloud" x="120" y="90"/>
<use href="#cloud" x="380" y="60" transform="scale(1)"/>
<use href="#cloud" x="720" y="110"/>
</g>
<g id="clouds2" opacity=".9">
<use href="#cloud" x="920" y="90"/>
<use href="#cloud" x="1180" y="60"/>
<use href="#cloud" x="1520" y="110"/>
</g>
<!-- 海 -->
<rect y="300" width="800" height="90" fill="url(#sea)"/>
<g id="waves" fill="none" stroke="#fff" stroke-width="2" opacity=".55" stroke-linecap="round">
<path d="M-40 330 q20 -8 40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0"/>
<path d="M-60 358 q20 -8 40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0 t40 0" opacity=".6"/>
</g>
<!-- 沙滩与公路 -->
<rect y="386" width="800" height="114" fill="var(--sand)"/>
<rect y="418" width="800" height="44" fill="var(--road)"/>
<line id="roadline" x1="0" y1="440" x2="800" y2="440" stroke="#f3e2b0" stroke-width="3" stroke-dasharray="38 34"/>
<!-- 自行车与鹈鹕 -->
<g id="rider">
<!-- 远侧腿(车架后面) -->
<g id="legFar" stroke="var(--feather-shade)" stroke-width="9" stroke-linecap="round" fill="none">
<polyline id="legFarLine" points="0,0 0,0 0,0"/>
</g>
<use id="footFar" href="#foot" opacity=".7"/>
<!-- 后轮 -->
<g id="rearWheel" transform="translate(240 360)"><use href="#wheel"/></g>
<!-- 前轮 -->
<g id="frontWheel" transform="translate(500 360)"><use href="#wheel"/></g>
<!-- 车架 -->
<g fill="none" stroke="var(--frame)" stroke-width="7" stroke-linecap="round" stroke-linejoin="round">
<polyline points="240,360 350,340 300,250 240,360"/>
<polyline points="300,250 470,250 500,360"/>
<line x1="350" y1="340" x2="470" y2="250"/>
<line x1="300" y1="250" x2="292" y2="222"/>
<line x1="470" y1="250" x2="476" y2="222"/>
</g>
<!-- 车座 -->
<path d="M268 220 q26 -10 54 0 l-6 8 q-20 -4 -42 0 z" fill="var(--ink)"/>
<!-- 车把 -->
<path d="M476 222 q14 -12 26 -6 l-6 6 q-8 -2 -14 6" fill="none" stroke="var(--ink)" stroke-width="6" stroke-linecap="round"/>
<!-- 车把上的小铃铛 -->
<circle cx="488" cy="214" r="5" fill="#f2c46b" stroke="var(--ink)" stroke-width="1.5"/>
<!-- 鹈鹕身体(随踩踏轻微起伏) -->
<g id="pelican">
<!-- 尾羽 -->
<path d="M262 204 l-38 -6 l6 12 l-30 4 l30 8 l-10 10 l40 -8 z" fill="var(--feather)" stroke="var(--ink)" stroke-width="2" stroke-linejoin="round"/>
<!-- 身体 -->
<ellipse cx="305" cy="205" rx="62" ry="38" transform="rotate(-8 305 205)" fill="var(--feather)" stroke="var(--ink)" stroke-width="2.5"/>
<!-- 颈 -->
<path d="M340 180 C 372 160, 380 140, 392 118" fill="none" stroke="var(--feather)" stroke-width="26" stroke-linecap="round"/>
<path d="M340 180 C 372 160, 380 140, 392 118" fill="none" stroke="var(--ink)" stroke-width="2.5" stroke-linecap="round" transform="translate(13 3)"/>
<path d="M340 180 C 372 160, 380 140, 392 118" fill="none" stroke="var(--ink)" stroke-width="2.5" stroke-linecap="round" transform="translate(-13 -3)"/>
<!-- 头 -->
<circle cx="398" cy="112" r="24" fill="var(--feather)" stroke="var(--ink)" stroke-width="2.5"/>
<!-- 头顶小羽冠 -->
<path d="M382 92 q8 -14 20 -8 q-6 2 -10 10" fill="var(--feather)" stroke="var(--ink)" stroke-width="2" stroke-linejoin="round"/>
<!-- 喉囊(下颌) -->
<path d="M414 118 Q 460 170, 508 140 Q 470 150, 418 128 Z" fill="var(--pouch)" stroke="var(--ink)" stroke-width="2.5" stroke-linejoin="round"/>
<!-- 上颌 -->
<path d="M412 108 L 512 134 Q 514 140, 508 140 L 414 120 Z" fill="var(--bill)" stroke="var(--ink)" stroke-width="2.5" stroke-linejoin="round"/>
<path d="M506 136 q4 -4 6 -2" fill="none" stroke="var(--ink)" stroke-width="2"/>
<!-- 眼睛 -->
<circle cx="406" cy="106" r="5" fill="var(--ink)"/>
<circle cx="408" cy="104" r="1.6" fill="#fff"/>
<!-- 翅膀(当作手臂握住车把) -->
<path d="M330 190 Q 380 200, 430 214 Q 470 222, 480 216 L 486 224 Q 460 236, 420 228 Q 370 220, 326 214 Z"
fill="var(--feather)" stroke="var(--ink)" stroke-width="2.5" stroke-linejoin="round"/>
<path d="M360 214 q20 -2 40 4" fill="none" stroke="var(--feather-shade)" stroke-width="2"/>
<path d="M345 200 l-8 16" fill="none" stroke="var(--ink)" stroke-width="2"/>
<!-- 头巾式的小围巾,随风飘 -->
<path id="scarf" d="M378 128 q-30 6 -50 -4" fill="none" stroke="var(--frame)" stroke-width="7" stroke-linecap="round"/>
</g>
<!-- 曲柄 -->
<g id="crank" transform="translate(350 340)">
<circle r="12" fill="var(--ink)"/>
<line x1="-30" y1="0" x2="30" y2="0" stroke="var(--ink)" stroke-width="5" stroke-linecap="round"/>
</g>
<!-- 近侧腿(车架前面) -->
<g id="legNear" stroke="var(--feather)" stroke-width="10" stroke-linecap="round" fill="none">
<polyline id="legNearLine" points="0,0 0,0 0,0"/>
</g>
<g stroke="var(--ink)" stroke-width="2.5" stroke-linecap="round" fill="none" opacity=".9">
<polyline id="legNearOutline" points="0,0 0,0 0,0"/>
</g>
<use id="footNear" href="#foot"/>
</g>
</svg>
<figcaption>一只鹈鹕,一辆红色自行车,一条通往傍晚的海边公路。</figcaption>
<script>
(function () {
const $ = id => document.getElementById(id);
// 场景常量
const CRANK = { x: 350, y: 340, r: 30 };
const HIP = { x: 300, y: 218 }; // 髋关节(车座上方)
const THIGH = 84, SHIN = 84; // 腿长
const WHEEL_R = 58;
const CRANK_RPS = 0.85; // 踩踏转速(圈/秒)
const WHEEL_RPS = 1.9; // 车轮转速(圈/秒)
const GROUND_SPEED = 2 * Math.PI * WHEEL_R * WHEEL_RPS; // px/s
// 双段反向运动学:给定髋与踏板位置,求膝盖
function solveKnee(hip, target) {
let dx = target.x - hip.x, dy = target.y - hip.y;
let d = Math.hypot(dx, dy);
const max = THIGH + SHIN - 0.5;
if (d > max) { dx *= max / d; dy *= max / d; d = max; }
const base = Math.atan2(dy, dx);
const cosA = (THIGH * THIGH + d * d - SHIN * SHIN) / (2 * THIGH * d);
const a = Math.acos(Math.max(-1, Math.min(1, cosA)));
// 膝盖朝前(+x)弯曲
const k1 = { x: hip.x + THIGH * Math.cos(base - a), y: hip.y + THIGH * Math.sin(base - a) };
const k2 = { x: hip.x + THIGH * Math.cos(base + a), y: hip.y + THIGH * Math.sin(base + a) };
return k1.x > k2.x ? k1 : k2;
}
const rearWheel = $('rearWheel'), frontWheel = $('frontWheel'), crank = $('crank');
const pelican = $('pelican'), roadline = $('roadline');
const clouds = $('clouds'), clouds2 = $('clouds2'), waves = $('waves'), scarf = $('scarf');
const legNearLine = $('legNearLine'), legNearOutline = $('legNearOutline'), legFarLine = $('legFarLine');
const footNear = $('footNear'), footFar = $('footFar');
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
let start = null;
function frame(now) {
if (start === null) start = now;
const t = reduceMotion ? 0 : (now - start) / 1000;
// 车轮与曲柄
const wheelDeg = (t * WHEEL_RPS * 360) % 360;
rearWheel.setAttribute('transform', `translate(240 360) rotate(${wheelDeg})`);
frontWheel.setAttribute('transform', `translate(500 360) rotate(${wheelDeg})`);
const crankAng = t * CRANK_RPS * 2 * Math.PI;
crank.setAttribute('transform', `translate(${CRANK.x} ${CRANK.y}) rotate(${crankAng * 180 / Math.PI})`);
// 身体起伏(每踩一圈起伏两次)
const bob = Math.sin(crankAng * 2) * 2.2;
const hip = { x: HIP.x, y: HIP.y + bob };
pelican.setAttribute('transform', `translate(0 ${bob})`);
// 两条腿:踏板相差 180°
const pedalNear = { x: CRANK.x + CRANK.r * Math.cos(crankAng), y: CRANK.y + CRANK.r * Math.sin(crankAng) };
const pedalFar = { x: CRANK.x - CRANK.r * Math.cos(crankAng), y: CRANK.y - CRANK.r * Math.sin(crankAng) };
const kneeNear = solveKnee(hip, pedalNear);
const kneeFar = solveKnee(hip, pedalFar);
const nearPts = `${hip.x},${hip.y} ${kneeNear.x},${kneeNear.y} ${pedalNear.x},${pedalNear.y}`;
legNearLine.setAttribute('points', nearPts);
legNearOutline.setAttribute('points', nearPts);
legFarLine.setAttribute('points', `${hip.x},${hip.y} ${kneeFar.x},${kneeFar.y} ${pedalFar.x},${pedalFar.y}`);
// 脚掌随踏板微微摆动
const tiltNear = Math.sin(crankAng) * 14;
const tiltFar = -Math.sin(crankAng) * 14;
footNear.setAttribute('transform', `translate(${pedalNear.x} ${pedalNear.y + 2}) rotate(${tiltNear})`);
footFar.setAttribute('transform', `translate(${pedalFar.x} ${pedalFar.y + 2}) rotate(${tiltFar})`);
// 围巾飘动
const flap = Math.sin(t * 9) * 5;
scarf.setAttribute('d', `M378 128 q-30 ${6 + flap} -50 ${-4 - flap * 1.4}`);
// 地面、云、海浪的视差滚动
roadline.setAttribute('stroke-dashoffset', (t * GROUND_SPEED) % 72);
const cloudX = -((t * 28) % 800);
clouds.setAttribute('transform', `translate(${cloudX} 0)`);
clouds2.setAttribute('transform', `translate(${cloudX} 0)`);
waves.setAttribute('transform', `translate(${-((t * 70) % 80)} ${Math.sin(t * 1.6) * 2})`);
if (!reduceMotion) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>