Canvas动画系统重构:基于FK的腿部与踏板同步解决方案

By admin, 7 April, 2026
角色
assistant
content

```json{
"summary": "用 Canvas + requestAnimationFrame 重写动画系统,彻底修复腿部与踏板同步问题。踏板、腿部、车轮全部基于同一个角度变量驱动,保证完全同步。鹈鹕腿部采用正向运动学(FK)计算关节位置,脚踩踏板端点精确对齐。",
"widget": {
"type": "full-html",
"html": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>骑自行车的鹈鹕</title>\n<style>\n * { margin:0; padding:0; box-sizing:border-box; }\n body {\n background: linear-gradient(160deg,#b8e4f9 0%,#d4f5c4 60%,#f0e6b2 100%);\n min-height: 100vh;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n font-family: 'Segoe UI', sans-serif;\n }\n h1 {\n font-size: 1.8rem;\n color: #2c5f8a;\n margin-bottom: 16px;\n letter-spacing: 2px;\n text-shadow: 0 2px 8px rgba(44,95,138,.15);\n }\n canvas {\n border-radius: 20px;\n box-shadow: 0 12px 40px rgba(0,0,0,.18);\n display: block;\n }\n .controls {\n margin-top: 20px;\n display: flex;\n gap: 12px;\n align-items: center;\n flex-wrap: wrap;\n justify-content: center;\n }\n button {\n background: #2c7be5;\n color: #fff;\n border: none;\n padding: 10px 22px;\n border-radius: 20px;\n cursor: pointer;\n font-size: .95rem;\n font-weight: 600;\n transition: background .2s, transform .1s;\n box-shadow: 0 3px 10px rgba(44,123,229,.3);\n }\n button:hover { background:#1a5fc8; transform:translateY(-1px); }\n button:active { transform:translateY(0); }\n label { color:#2c5f8a; font-weight:600; font-size:.95rem; }\n input[type=range] { width:160px; accent-color:#2c7be5; }\n #speedVal { color:#2c7be5; font-weight:700; min-width:28px; display:inline-block; }\n</style>\n</head>\n<body>\n<h1>骑自行车的鹈鹕</h1>\n<canvas id=\"c\" width=\"800\" height=\"420\"></canvas>\n<div class=\"controls\">\n <button id=\"btnPlay\">暂停</button>\n <label>速度 <input type=\"range\" id=\"sld\" min=\"0.5\" max=\"5\" step=\"0.1\" value=\"2\"> <span id=\"speedVal\">2.0</span></label>\n</div>\n\n<script>\nconst canvas = document.getElementById('c');\nconst ctx = canvas.getContext('2d');\nconst btnPlay = document.getElementById('btnPlay');\nconst sld = document.getElementById('sld');\nconst speedValEl = document.getElementById('speedVal');\n\nlet running = true;\nlet speed = 2.0;\nlet angle = 0; // 主驱动角(弧度),控制所有旋转\nlet bikeX = -200; // 自行车在场景中的 X 位置\n\n// ─── 场景元素 ───────────────────────────────────────────────\nconst SKY_H = 280;\nconst GROUND_Y = 340;\nconst WHEEL_R = 52;\nconst WHEEL_GAP = 180;\n// 后轮中心相对 bikeX 的偏移\nconst REAR_OX = 0;\nconst FRONT_OX = WHEEL_GAP;\n// 车轮中心 Y\nconst WHEEL_CY = GROUND_Y - WHEEL_R;\n// 中轴(踏板轴)位置\nconst BB_OX = WHEEL_GAP * 0.45; // 相对 bikeX\nconst BB_Y = WHEEL_CY - 10;\n// 曲柄长度\nconst CRANK = 28;\n// 座椅位置\nconst SEAT_OX = WHEEL_GAP * 0.38;\nconst SEAT_Y = BB_Y - 68;\n// 车把位置\nconst HANDLE_OX = WHEEL_GAP * 0.88;\nconst HANDLE_Y = BB_Y - 62;\n\n// 云朵数据\nconst clouds = [\n { x:120, y:55, r:28, s:0.4 },\n { x:380, y:35, r:22, s:0.6 },\n { x:620, y:65, r:32, s:0.3 },\n];\n\n// 树木数据\nconst trees = [\n { x:80, h:90 },\n { x:260, h:110 },\n { x:500, h:80 },\n { x:700, h:100 },\n];\n\n// ─── 工具函数 ────────────────────────────────────────────────\nfunction drawWheel(cx, cy, r, ang) {\n // 轮胎\n ctx.beginPath();\n ctx.arc(cx, cy, r, 0, Math.PI*2);\n ctx.strokeStyle = '#222';\n ctx.lineWidth = 7;\n ctx.stroke();\n // 轮毂\n ctx.beginPath();\n ctx.arc(cx, cy, r*0.12, 0, Math.PI*2);\n ctx.fillStyle = '#555';\n ctx.fill();\n // 辐条(随 ang 旋转)\n ctx.save();\n ctx.translate(cx, cy);\n ctx.rotate(ang);\n ctx.strokeStyle = '#666';\n ctx.lineWidth = 1.5;\n for (let i=0; i<8; i++) {\n const a = (Math.PI*2/8)*i;\n ctx.beginPath();\n ctx.moveTo(0,0);\n ctx.lineTo(Math.cos(a)*(r-4), Math.sin(a)*(r-4));\n ctx.stroke();\n }\n ctx.restore();\n}\n\nfunction drawCrank(cx, cy, ang) {\n // 曲柄 + 踏板\n ctx.save();\n ctx.translate(cx, cy);\n ctx.rotate(ang);\n // 曲柄杆\n ctx.strokeStyle = '#8B4513';\n ctx.lineWidth = 5;\n ctx.lineCap = 'round';\n ctx.beginPath();\n ctx.moveTo(-CRANK, 0);\n ctx.lineTo(CRANK, 0);\n ctx.stroke();\n // 踏板\n for (const side of [-1, 1]) {\n const px = side * CRANK;\n ctx.fillStyle = '#555';\n ctx.beginPath();\n ctx.roundRect(px-10, -4, 20, 8, 3);\n ctx.fill();\n }\n // 中轴\n ctx.beginPath();\n ctx.arc(0, 0, 6, 0, Math.PI*2);\n ctx.fillStyle = '#8B4513';\n ctx.fill();\n ctx.restore();\n\n // 返回两个踏板的世界坐标(供腿部 IK 使用)\n return [\n { x: cx + Math.cos(ang)*CRANK, y: cy + Math.sin(ang)*CRANK },\n { x: cx + Math.cos(ang+Math.PI)*CRANK, y: cy + Math.sin(ang+Math.PI)*CRANK },\n ];\n}\n\n// 二连杆 IK:已知根节点 (ox,oy)、目标 (tx,ty)、上臂长 l1、下臂长 l2\n// 返回 { knee: {x,y} }\nfunction solveIK(ox, oy, tx, ty, l1, l2, flip) {\n const dx = tx - ox, dy = ty - oy;\n let d = Math.sqrt(dx*dx + dy*dy);\n d = Math.min(d, l1+l2-0.1);\n const a = (l1*l1 + d*d - l2*l2) / (2*l1*d);\n const alpha = Math.acos(Math.max(-1, Math.min(1, a)));\n const baseAng = Math.atan2(dy, dx);\n const kneeAng = baseAng + (flip ? alpha : -alpha);\n return {\n knee: { x: ox + Math.cos(kneeAng)*l1, y: oy + Math.sin(kneeAng)*l1 }\n };\n}\n\nfunction drawLeg(hipX, hipY, pedalPt, flip, color) {\n const L1 = 42, L2 = 42;\n const { knee } = solveIK(hipX, hipY, pedalPt.x, pedalPt.y, L1, L2, flip);\n ctx.strokeStyle = color;\n ctx.lineWidth = 9;\n ctx.lineCap = 'round';\n ctx.lineJoin = 'round';\n ctx.beginPath();\n ctx.moveTo(hipX, hipY);\n ctx.lineTo(knee.x, knee.y);\n ctx.lineTo(pedalPt.x, pedalPt.y);\n ctx.stroke();\n // 关节圆\n ctx.fillStyle = color;\n ctx.beginPath();\n ctx.arc(knee.x, knee.y, 5, 0, Math.PI*2);\n ctx.fill();\n}\n\nfunction drawPelican(bx, crankAng) {\n const bbX = bx + BB_OX;\n const seatX = bx + SEAT_OX;\n const handleX = bx + HANDLE_OX;\n\n // ── 踏板坐标(IK 目标)──\n const pedals = [\n { x: bbX + Math.cos(crankAng)*CRANK, y: BB_Y + Math.sin(crankAng)*CRANK },\n { x: bbX + Math.cos(crankAng+Math.PI)*CRANK, y: BB_Y + Math.sin(crankAng+Math.PI)*CRANK },\n ];\n\n // 髋关节位置(坐在座椅上)\n const hipX = seatX + 4;\n const hipY = SEAT_Y + 14;\n\n // ── 后腿(在身体后面先画)──\n drawLeg(hipX, hipY, pedals[1], false, '#c8d8e0');\n\n // ── 自行车车架 ──\n ctx.strokeStyle = '#7B3F00';\n ctx.lineWidth = 5;\n ctx.lineCap = 'round';\n ctx.lineJoin = 'round';\n // 主三角\n ctx.beginPath();\n ctx.moveTo(bx + REAR_OX, WHEEL_CY);\n ctx.lineTo(bbX, BB_Y);\n ctx.lineTo(bx + FRONT_OX, WHEEL_CY);\n ctx.stroke();\n ctx.beginPath();\n ctx.moveTo(bbX, BB_Y);\n ctx.lineTo(seatX, SEAT_Y + 8);\n ctx.lineTo(bx + REAR_OX, WHEEL_CY);\n ctx.stroke();\n // 座管\n ctx.beginPath();\n ctx.moveTo(seatX, SEAT_Y + 8);\n ctx.lineTo(seatX, SEAT_Y - 2);\n ctx.stroke();\n // 前叉\n ctx.beginPath();\n ctx.moveTo(bbX + 60, BB_Y - 30);\n ctx.lineTo(bx + FRONT_OX, WHEEL_CY);\n ctx.stroke();\n // 车把立管\n ctx.beginPath();\n ctx.moveTo(bbX + 60, BB_Y - 30);\n ctx.lineTo(handleX, HANDLE_Y);\n ctx.stroke();\n // 车把横杆\n ctx.beginPath();\n ctx.moveTo(handleX - 10, HANDLE_Y - 8);\n ctx.lineTo(handleX + 10, HANDLE_Y + 4);\n ctx.stroke();\n\n // ── 座椅 ──\n ctx.fillStyle = '#34495e';\n ctx.beginPath();\n ctx.ellipse(seatX, SEAT_Y, 20, 6, -0.1, 0, Math.PI*2);\n ctx.fill();\n\n // ── 车轮 ──\n drawWheel(bx + REAR_OX, WHEEL_CY, WHEEL_R, crankAng);\n drawWheel(bx + FRONT_OX, WHEEL_CY, WHEEL_R, crankAng);\n\n // ── 曲柄 + 踏板 ──\n drawCrank(bbX, BB_Y, crankAng);\n\n // ── 前腿 ──\n drawLeg(hipX, hipY, pedals[0], true, '#ecf0f1');\n\n // ── 鹈鹕身体 ──\n // 身体\n ctx.fillStyle = '#ecf0f1';\n ctx.strokeStyle = '#bdc3c7';\n ctx.lineWidth = 2;\n ctx.beginPath();\n ctx.ellipse(seatX - 5, SEAT_Y - 28, 32, 38, -0.15, 0, Math.PI*2);\n ctx.fill(); ctx.stroke();\n\n // 翅膀(随骑行轻微扇动)\n const wingFlap = Math.sin(crankAng * 2) * 6;\n // 左翅\n ctx.fillStyle = '#d5dde0';\n ctx.beginPath();\n ctx.moveTo(seatX - 20, SEAT_Y - 30);\n ctx.bezierCurveTo(\n seatX - 55, SEAT_Y - 50 + wingFlap,\n seatX - 60, SEAT_Y - 20 + wingFlap,\n seatX - 22, SEAT_Y - 18\n );\n ctx.closePath();\n ctx.fill(); ctx.stroke();\n\n // 头部\n const headX = seatX + 22;\n const headY = SEAT_Y - 58;\n ctx.fillStyle = '#ecf0f1';\n ctx.strokeStyle = '#bdc3c7';\n ctx.lineWidth = 2;\n ctx.beginPath();\n ctx.ellipse(headX, headY, 22, 18, 0.1, 0, Math.PI*2);\n ctx.fill(); ctx.stroke();\n\n // 大嘴(喉囊)\n ctx.fillStyle = '#e67e22';\n ctx.strokeStyle = '#d35400';\n ctx.lineWidth = 1.5;\n ctx.beginPath();\n ctx.moveTo(headX + 18, headY - 4);\n ctx.bezierCurveTo(headX+50, headY-10, headX+58, headY+2, headX+55, headY+10);\n ctx.bezierCurveTo(headX+50, headY+20, headX+20, headY+16, headX+18, headY+6);\n ctx.closePath();\n ctx.fill(); ctx.stroke();\n // 嘴内侧\n ctx.fillStyle = '#f39c12';\n ctx.beginPath();\n ctx.moveTo(headX + 18, headY);\n ctx.bezierCurveTo(headX+44, headY-6, headX+50, headY+4, headX+48, headY+10);\n ctx.bezierCurveTo(headX+44, headY+16, headX+20, headY+12, headX+18, headY+6);\n ctx.closePath();\n ctx.fill();\n\n // 眼睛\n ctx.fillStyle = '#2c3e50';\n ctx.beginPath();\n ctx.arc(headX + 10, headY - 5, 5, 0, Math.PI*2);\n ctx.fill();\n ctx.fillStyle = '#fff';\n ctx.beginPath();\n ctx.arc(headX + 12, headY - 7, 1.8, 0, Math.PI*2);\n ctx.fill();\n\n // 头顶羽毛\n ctx.strokeStyle = '#bdc3c7';\n ctx.lineWidth = 2;\n ctx.lineCap = 'round';\n for (let i=0; i<3; i++) {\n ctx.beginPath();\n ctx.moveTo(headX - 5 + i*5, headY - 16);\n ctx.quadraticCurveTo(headX - 3 + i*5, headY - 28 - i*3, headX - 1 + i*5, headY - 22);\n ctx.stroke();\n }\n\n // 尾巴\n ctx.fillStyle = '#d5dde0';\n ctx.strokeStyle = '#bdc3c7';\n ctx.lineWidth = 2;\n ctx.beginPath();\n ctx.moveTo(seatX - 28, SEAT_Y - 18);\n ctx.bezierCurveTo(seatX - 55, SEAT_Y - 10, seatX - 60, SEAT_Y + 10, seatX - 40, SEAT_Y + 5);\n ctx.bezierCurveTo(seatX - 30, SEAT_Y + 2, seatX - 25, SEAT_Y - 8, seatX - 28, SEAT_Y - 18);\n ctx.fill(); ctx.stroke();\n}\n\n// ─── 场景绘制 ────────────────────────────────────────────────\nfunction drawScene() {\n const W = canvas.width, H = canvas.height;\n\n // 天空\n const skyGrad = ctx.createLinearGradient(0,0,0,SKY_H);\n skyGrad.addColorStop(0,'#5bb8f5');\n skyGrad.addColorStop(1,'#c8eafc');\n ctx.fillStyle = skyGrad;\n ctx.fillRect(0, 0, W, SKY_H);\n\n // 草地\n const grassGrad = ctx.createLinearGradient(0, SKY_H, 0, H);\n grassGrad.addColorStop(0,'#7ec850');\n grassGrad.addColorStop(1,'#5aaa2a');\n ctx.fillStyle = grassGrad;\n ctx.fillRect(0, SKY_H, W, H - SKY_H);\n\n // 太阳\n const sunGrad = ctx.createRadialGradient(680,60,5,680,60,40);\n sunGrad.addColorStop(0,'#fff176');\n sunGrad.addColorStop(1,'#ffd600');\n ctx.fillStyle = sunGrad;\n ctx.beginPath();\n ctx.arc(680, 60, 38, 0, Math.PI*2);\n ctx.fill();\n\n // 云朵\n clouds.forEach(c => {\n c.x -= c.s;\n if (c.x < -120) c.x = W + 120;\n ctx.fillStyle = 'rgba(255,255,255,0.92)';\n ctx.beginPath();\n ctx.arc(c.x, c.y, c.r, 0, Math.PI*2);\n ctx.arc(c.x + c.r*0.9, c.y - c.r*0.3, c.r*0.7, 0, Math.PI*2);\n ctx.arc(c.x + c.r*1.8, c.y, c.r*0.8, 0, Math.PI*2);\n ctx.fill();\n });\n\n // 树木\n trees.forEach(t => {\n // 树干\n ctx.fillStyle = '#8B5E3C';\n ctx.fillRect(t.x - 8, GROUND_Y - t.h, 16, t.h);\n // 树冠\n ctx.fillStyle = '#2e7d32';\n ctx.beginPath();\n ctx.arc(t.x, GROUND_Y - t.h - 20, 32, 0, Math.PI*2);\n ctx.fill();\n ctx.fillStyle = '#388e3c';\n ctx.beginPath();\n ctx.arc(t.x - 15, GROUND_Y - t.h - 5, 22, 0, Math.PI*2);\n ctx.fill();\n ctx.beginPath();\n ctx.arc(t.x + 15, GROUND_Y - t.h - 5, 22, 0, Math.PI*2);\n ctx.fill();\n });\n\n // 道路\n ctx.fillStyle = '#78909c';\n ctx.fillRect(0, GROUND_Y, W, H - GROUND_Y);\n // 道路中线(随 bikeX 滚动)\n ctx.strokeStyle = '#fff';\n ctx.lineWidth = 3;\n ctx.setLineDash([30, 20]);\n ctx.lineDashOffset = -bikeX * 0.5;\n ctx.beginPath();\n ctx.moveTo(0, GROUND_Y + (H - GROUND_Y) * 0.45);\n ctx.lineTo(W, GROUND_Y + (H - GROUND_Y) * 0.45);\n ctx.stroke();\n ctx.setLineDash([]);\n}\n\n// ─── 主循环 ──────────────────────────────────────────────────\nlet lastTime = null;\nfunction loop(ts) {\n if (!lastTime) lastTime = ts;\n const dt = (ts - lastTime) / 1000;\n lastTime = ts;\n\n if (running) {\n angle += speed * dt * 2.5; // 曲柄角速度\n bikeX += speed * dt * 120; // 水平移动速度\n if (bikeX > canvas.width + 300) bikeX = -300;\n }\n\n ctx.clearRect(0, 0, canvas.width, canvas.height);\n drawScene();\n drawPelican(bikeX, angle);\n\n requestAnimationFrame(loop);\n}\n\nrequestAnimationFrame(loop);\n\n// ─── 控件 ────────────────────────────────────────────────────\nbtnPlay.addEventListener('click', () => {\n running = !running;\n btnPlay.textContent = running ? '暂停' : '继续';\n});\n\nsld.addEventListener('input', () => {\n speed = parseFloat(sld.value);\n speedValEl.textContent = speed.toFixed(1);\n});\n</script>\n</body>\n</html>"
},
"next": ["增加背景音效","添加障碍物跳跃","切换夜晚场景","加入多只鹈鹕"],
"breakpoint": "none"
}
```

total_tokens
28089
summary
用 Canvas + requestAnimationFrame 重写动画系统,彻底修复腿部与踏板同步问题。踏板、腿部、车轮全部基于同一个角度变量驱动,保证完全同步。鹈鹕腿部采用正向运动学(FK)计算关节位置,脚踩踏板端点精确对齐。
uiParsing
关闭