使用canvas绘制一个哆啦A梦

144 阅读7分钟

说在前面

哆啦A梦应该是不少90后的童年回忆吧,今天让我们一起来看看怎么用canvas来画一个哆啦A梦。

效果展示

codepen在线查看:codepen.io/yongtaozhen…

码上掘金在线查看:code.juejin.cn/pen/7492379…

代码实现

基础图形绘制

椭圆

function drawEllipse(ctx, x, y, radiusX, radiusY) {
  const kappa = 0.5522848;
  const ox = radiusX * kappa; // 水平控制点偏移量
  const oy = radiusY * kappa; // 垂直控制点偏移量
  ctx.beginPath();
  ctx.moveTo(x, y - radiusY);
  ctx.bezierCurveTo(
    x + ox,
    y - radiusY,
    x + radiusX,
    y - oy,
    x + radiusX,
    y
  );
  ctx.bezierCurveTo(
    x + radiusX,
    y + oy,
    x + ox,
    y + radiusY,
    x,
    y + radiusY
  );
  ctx.bezierCurveTo(
    x - ox,
    y + radiusY,
    x - radiusX,
    y + oy,
    x - radiusX,
    y
  );
  ctx.bezierCurveTo(
    x - radiusX,
    y - oy,
    x - ox,
    y - radiusY,
    x,
    y - radiusY
  );
  ctx.closePath();
}
kappa常数

0.5522848是绘制标准圆弧所需的贝塞尔曲线修正系数,由公式 (4/3)*(sqrt(2)-1) 计算得出,保证四段贝塞尔曲线拼接后的图形接近真实圆弧。

控制点偏移量
const ox = radiusX * kappa;  // 水平控制点偏移
const oy = radiusY * kappa;  // 垂直控制点偏移

通过椭圆长短轴半径与kappa值的乘积,动态计算贝塞尔控制点的坐标偏移量。

起点定位
ctx.moveTo(x, y - radiusY);  // 定位到椭圆顶点
四次贝塞尔曲线拼接
  • 右上半圆
ctx.bezierCurveTo(x + ox, y - radiusY, x + radiusX, y - oy, x + radiusX, y)

控制点分布在顶点右侧和右端点上方,形成顺时针弧线。

  • 右下半圆
ctx.bezierCurveTo(x + radiusX, y + oy, x + ox, y + radiusY, x, y + radiusY)
  • 左下半圆
ctx.bezierCurveTo(x - ox, y + radiusY, x - radiusX, y + oy, x - radiusX, y)
  • 左上半圆
ctx.bezierCurveTo(x - radiusX, y - oy, x - ox, y - radiusY, x, y - radiusY)
示例
ctx.beginPath();
drawEllipse(ctx, 170, 100, 30, 35);
ctx.fillStyle = "white";
ctx.fill();
ctx.stroke();
ctx.beginPath();

圆角矩形

function drawRoundedRect(x, y, w, h, radius, rotationAngle = 0) {
  ctx.save(); // 保存当前绘图状态
  ctx.translate(x, y); // 移动到指定位置
  ctx.rotate(rotationAngle); // 旋转指定角度

  ctx.beginPath();
  ctx.moveTo(radius, 0);
  ctx.arcTo(w, 0, w, h, radius); // 右上角
  ctx.arcTo(w, h, 0, h, radius); // 右下角
  ctx.arcTo(0, h, 0, 0, radius); // 左下角
  ctx.arcTo(0, 0, w, 0, radius); // 左上角
  ctx.closePath();

  ctx.fill();

  ctx.restore(); // 恢复之前保存的绘图状态
}

每个arcTo依次处理相邻两条边的连接点,根据当前点、控制点1、控制点2自动计算切线圆弧,相比直接使用arc,避免手动计算圆心坐标的复杂度。

示例
ctx.beginPath();
drawRoundedRect(85, 310, 235, 100, 50);
ctx.fillStyle = "white";
ctx.fill();
ctx.stroke();
ctx.beginPath();

桶状矩形

function drawBarrelShape(
  startX,
  startY,
  width,
  height,
  curveRadius,
  fillColor,
  strokeColor,
  lineWidth
) {
  // 移动到起始点
  ctx.beginPath();
  ctx.moveTo(startX + curveRadius, startY);

  // 绘制上侧边直线
  ctx.lineTo(startX + width - curveRadius, startY);

  // 绘制右侧曲线
  const controlX1 = startX + width;
  const controlY1 = startY + height / 2;
  ctx.quadraticCurveTo(
    controlX1,
    controlY1,
    startX + width - curveRadius,
    startY + height
  );

  // 绘制下侧边直线
  ctx.lineTo(startX + curveRadius, startY + height);

  // 绘制左侧曲线
  const controlX2 = startX;
  const controlY2 = startY + height / 2;
  ctx.quadraticCurveTo(
    controlX2,
    controlY2,
    startX + curveRadius,
    startY
  );

  // 闭合路径
  ctx.closePath();

  // 设置填充颜色和边框颜色
  ctx.fillStyle = fillColor;
  ctx.strokeStyle = strokeColor;
  ctx.lineWidth = lineWidth;

  // 填充和绘制边框
  ctx.fill();
  ctx.stroke();
}
上侧直线
ctx.moveTo(startX + curveRadius, startY);

moveTo从左侧起点(含曲线偏移)向右侧绘制水平线,预留右侧曲线空间。

右侧贝塞尔曲线
const controlX1 = startX + width;
const controlY1 = startY + height / 2;
ctx.quadraticCurveTo(
  controlX1,
  controlY1,
  startX + width - curveRadius,
  startY + height
);

控制点(controlX1, controlY1)位于右侧边中点,使曲线向外凸起形成弧形。终点位于右下侧,与下边直线起点衔接。

下侧直线与左侧曲线
// 绘制下侧边直线
ctx.lineTo(startX + curveRadius, startY + height);
// 绘制左侧曲线
const controlX2 = startX;
const controlY2 = startY + height / 2;
ctx.quadraticCurveTo(
  controlX2,
  controlY2,
  startX + curveRadius,
  startY
);
// 闭合路径
ctx.closePath();

下边反向绘制至左侧后,左侧控制点(controlX2, controlY2)同样位于中点,使左右对称弯曲。

示例
ctx.beginPath();
drawBarrelShape(40, 310, 325, 230, 50, "#0096FF", "black", 1);
ctx.fillStyle = "white";
ctx.fill();
ctx.stroke();
ctx.beginPath();

哆啦A梦绘制

头部绘制

头部最外圈

一个3/4圆。

ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(200, 200, 160, (3 / 4) * Math.PI, (9 / 4) * Math.PI);
ctx.fillStyle = "#0096FF";
ctx.fill();
ctx.stroke();

脸部白色区域

一个3/4圆。

ctx.beginPath();
ctx.arc(200, 220, 130, (3 / 4) * Math.PI, (9 / 4) * Math.PI);
ctx.fillStyle = "white";
ctx.fill();
ctx.stroke();

眼睛

最外圈是一个椭圆,眼黑部分为一个小椭圆,内部再点缀一个白色的圆形高光。

// 左眼
ctx.beginPath();
drawEllipse(ctx, 170, 100, 30, 35);
ctx.fillStyle = "white";
ctx.fill();
ctx.stroke();
ctx.beginPath();
drawEllipse(ctx, 175, 110, 8, 10);
ctx.fillStyle = "black";
ctx.fill();
ctx.beginPath();
ctx.arc(175, 110, 3, 0, 2 * Math.PI);
ctx.fillStyle = "#FFF7FF";
ctx.fill();
ctx.stroke();
// 右眼
ctx.beginPath();
drawEllipse(ctx, 230, 100, 30, 35);
ctx.fillStyle = "white";
ctx.fill();
ctx.stroke();
ctx.restore();
ctx.beginPath();
drawEllipse(ctx, 225, 110, 8, 10);
ctx.fillStyle = "black";
ctx.fill();
ctx.beginPath();
ctx.arc(225, 110, 3, 0, 2 * Math.PI);
ctx.fillStyle = "#FFF7FF";
ctx.fill();
ctx.stroke();

鼻子

红色的圆形鼻子,内有一个白色圆形高光。

ctx.beginPath();
ctx.arc(200, 140, 18, 0, 2 * Math.PI);
ctx.fillStyle = "red";
ctx.lineWidth = 1;
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.arc(200, 135, 5, 0, 2 * Math.PI);
ctx.fillStyle = "#FFF7FF";
ctx.strokeStyle = "rgba(0, 0, 0, 0)";
ctx.fill();
ctx.stroke();

嘴巴

一段1/3圆弧。

ctx.beginPath();
ctx.arc(200, 180, 100, (1 / 6) * Math.PI, (5 / 6) * Math.PI);
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.stroke();

鼻子到嘴巴的连线

连接鼻子和嘴巴的直线。

ctx.beginPath();
ctx.moveTo(200, 155);
ctx.lineTo(200, 280);
ctx.strokeStyle = "black";
ctx.lineWidth = 3;
ctx.stroke();

胡须

嘴巴和鼻子之间有三组对称的直线。

// 左边胡须
ctx.lineWidth = 2;
ctx.moveTo(100, 150);
ctx.lineTo(180, 180);
ctx.stroke();
ctx.moveTo(100, 200);
ctx.lineTo(180, 200);
ctx.stroke();
ctx.moveTo(100, 240);
ctx.lineTo(180, 220);
ctx.stroke();
// 右边胡须
ctx.moveTo(220, 180);
ctx.lineTo(300, 150);
ctx.stroke();
ctx.moveTo(220, 200);
ctx.lineTo(300, 200);
ctx.stroke();
ctx.moveTo(220, 220);
ctx.lineTo(300, 240);
ctx.stroke();

铃铛绘制

脖子?

红色圆边矩阵

ctx.beginPath();
ctx.fillStyle = "#CD0833"; // 设置填充颜色
drawRoundedRect(85, 310, 235, 10, 5);
ctx.stroke();

铃铛

主体是一个圆形,中间有一个小圆洞,小洞到底部有一条小缝,上方用一个圆边矩形模拟铃铛的凸起部分。

ctx.beginPath();
ctx.arc(200, 340, 30, 0, 2 * Math.PI);
ctx.fillStyle = "#F6E03D";
ctx.lineWidth = 1;
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.fillStyle = "#F6E03D"; // 设置填充颜色
drawRoundedRect(170, 325, 60, 5, 2.5);
ctx.stroke();
ctx.beginPath();
ctx.arc(200, 345, 8, 0, 2 * Math.PI);
ctx.fillStyle = "#816D4C";
ctx.fill();
ctx.beginPath();
ctx.moveTo(200, 353);
ctx.lineTo(200, 370);
ctx.stroke();

身体绘制

身体主体

一个蓝底桶状,直接调用前面准备好的基础图形来绘制。

drawBarrelShape(40, 310, 325, 230, 50, "#0096FF", "black", 1);

肚子白色区域

一个白色圆形,中间有一个半圆四次元口袋。

ctx.beginPath();
ctx.arc(200, 410, 100, 0, 2 * Math.PI);
ctx.fillStyle = "white";
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.lineWidth = 2;
ctx.arc(200, 410, 80, 0, Math.PI);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(120, 410);
ctx.lineTo(280, 410);
ctx.stroke();

手臂绘制

一个倾斜的矩阵,可以直接调用前面准备好的基础图形来绘制。

ctx.lineWidth = 1;
ctx.strokeStyle = "black";
ctx.fillStyle = "#0096FF"; // 设置填充颜色
drawRoundedRect(90, 310, 40, 80, 0, Math.PI / 4);
ctx.stroke();
ctx.lineWidth = 1;
ctx.strokeStyle = "black";
ctx.fillStyle = "#0096FF"; // 设置填充颜色
drawRoundedRect(315, 310, 80, 40, 0, Math.PI / 4);
ctx.stroke();

手掌绘制

一个圆形,覆盖到手臂的下部末端。

ctx.beginPath();
// 左手
ctx.lineWidth = 1;
ctx.arc(40, 405, 40, 0, Math.PI * 2);
ctx.fillStyle = "#ffffff";
ctx.fill();
ctx.stroke();

// 右手
ctx.beginPath();
ctx.lineWidth = 1;
ctx.arc(360, 405, 40, 0, Math.PI * 2);
ctx.fillStyle = "#ffffff";
ctx.fill();
ctx.stroke();

脚部绘制

腿部

一个T字形划分出两条腿

ctx.moveTo(190, 515);
ctx.lineTo(210, 515);
ctx.stroke();
ctx.moveTo(200, 515);
ctx.lineTo(200, 550);
ctx.stroke();

脚掌

一个椭圆形,可以直接调用前面准备好的基础图形来绘制。

ctx.beginPath();
ctx.ellipse(131, 550, 70, 30, 0, 0, 2 * Math.PI);
ctx.fillStyle = "white";
ctx.fill();
ctx.strokeStyle = "black";
ctx.lineWidth = 2;
ctx.stroke();
ctx.beginPath();
ctx.ellipse(269, 550, 70, 30, 0, 0, 2 * Math.PI);
ctx.fillStyle = "white";
ctx.fill();
ctx.strokeStyle = "black";
ctx.lineWidth = 2;
ctx.stroke();

绘制顺序

绘制的时候我们需要根据各个身体部位的覆盖关系来调整绘制顺序,底层优先绘制。

drawHands();//手臂
drawBody();//身体
drawPalms();//手掌
drawFeet();//脚
drawHead();//头
drawBell();//铃铛

源码

gitee

仓库地址:gitee.com/zheng_yongt…


  • 🌟觉得有点意思的可以点个star~
  • 🖊有什么问题或错误可以指出,欢迎pr~
  • 📬有什么想要实现的功能或想法可以联系我~

codePen

代码地址:codepen.io/yongtaozhen…

码上掘金

代码地址:code.juejin.cn/pen/7492379…

公众号

关注公众号『 前端也能这么有趣 』,获取更多有趣内容。

发送 加群 还可以加入群聊,一起来学习(摸鱼)吧~

说在后面

🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。