Canvas如何做款祝福月饼【中秋特别版】

820 阅读8分钟

我正在参加中秋创意投稿大赛,详情请看:中秋创意投稿大赛

前言

但愿人长久,千里共婵娟

——苏轼

介绍

中秋将至,本期借此机会创作了一款祝福月饼的canvas动画,送给大家,祝大家阖家团圆,心想事成~

VID_20210906_084837.gif

接下来就会教大家用Canvas api,且不用任何引擎和插件来制作这款祝福月饼。我们从基础结构,月饼绘制,打字机动画来讲述,系好安全带,我们这就出发~

开启

第一章·基础结构

<canvas id="canvas"></canvas>
<script type="module" src="./app.js"></script>

我们先放html,javasript用module模式,方便后面的js文件引入。

* {
    padding: 0;
    margin: 0;
}

html,
body {
    width: 100%;
    height: 100vh;
    position: relative;
    overflow: hidden;
    display: flex;
    align-items: center;
    justify-content: center;
}

#canvas {
    width: 100%;
    height: 100%;
    cursor: pointer;
    background-image: repeating-radial-gradient(circle at center center,
        transparent 0px,
        transparent 8px,
        rgba(255, 255, 255, 0.05) 8px,
        rgba(255, 255, 255, 0.05) 11px,
        transparent 11px,
        transparent 17px,
        rgba(255, 255, 255, 0.05) 17px,
        rgba(255, 255, 255, 0.05) 25px,
        transparent 25px,
        transparent 38px,
        rgba(255, 255, 255, 0.05) 38px,
        rgba(255, 255, 255, 0.05) 42px),
        repeating-radial-gradient(circle at center center,
            rgb(170, 0, 0) 0px,
            rgb(170, 0, 0) 11px,
            rgb(170, 0, 0) 11px,
            rgb(170, 0, 0) 19px,
            rgb(170, 0, 0) 19px,
            rgb(170, 0, 0) 24px,
            rgb(170, 0, 0) 24px,
            rgb(170, 0, 0) 33px,
            rgb(170, 0, 0) 33px,
            rgb(170, 0, 0) 44px,
            rgb(170, 0, 0) 44px,
            rgb(170, 0, 0) 46px);
    background-size: 60px 60px;
}

再用css3绘制一张喜庆点的背景~

微信截图_20210906090904.png

接下来,我们要开始写主逻辑了,但是我们先计划一下都要有什么:

  1. 我们肯定要获取画布,然后获取整屏的宽高附上。
  2. 期望肯定是要有个绘制月饼的,月饼上有馅的名字,里面有各种线条的绘制,所以抽成一个类,再往主逻辑中实例化一个去实现。
  3. 祝福语因为简单所以我们直接在主逻辑中绘制,一般每次祝福语只有两句,所以我们用逗号隔开这种形式去拆分吧,而且一个字一个字的打所以记录下当前位置,还有循环周期。
/*app.js*/
import MoonCake from "./js/MoonCake.js";

class Application {
  constructor() {
    this.canvas = null;
    this.ctx = null;
    this.w = 0;
    this.h = 0;
    this.types = ["五仁", "蛋黄", "莲蓉", "豆沙", "芝麻"];
    this.words = [
      "但愿人长久,千里共婵娟",
      "花好月圆佳节夜,思念千里梦甜甜",
      "皓月当空洒清辉,中秋良宵念挚心",
    ]
    this.moonCake = null;
    this.text = "";
    this.textIndex = 0;
    this.dt = 0;
    this.init();
  }
  init() {
    // 初始化
    this.canvas = document.getElementById("canvas");
    this.ctx = this.canvas.getContext("2d");
    window.addEventListener("resize", this.reset.bind(this));
    this.reset();
    this.render();
    this.step();
  }
  reset() {
    // 重置
    this.dt = 0;
    this.w = this.canvas.width = this.ctx.width = window.innerWidth;
    this.h = this.canvas.height = this.ctx.height = window.innerHeight;
    this.render()
  }
  render() {
    // 主渲染
    const {w, h, ctx, types, words} = this;
    this.text = words[~~(words.length * Math.random())].split(",")
    this.textIndex = 0;
    // 实例化月饼
    this.moonCake = new MoonCake({
      x: w / 2,
      y: h / 2,
      scale: .85,
      name: types[~~(types.length * Math.random())]
    }).render(ctx); 
  }
  drawText() {
    // 打字机绘制
  }
  step() {
    // 重绘
    requestAnimationFrame(this.step.bind(this));
    const {ctx, w, h} = this;
    ctx.clearRect(0, 0, w, h);
    this.moonCake && this.moonCake.draw();
    this.drawText();
    this.dt++;

  }
}
window.onload = new Application();

我们期望渲染的是月饼的实例,我们应当传递的就是它的坐标和缩放大小以及馅名,接下来我们的重头戏要来了,就是怎么画出一个月饼来~

第二章·绘制月饼

要绘制一款月饼需要堆砌canvas基础绘制,我们观察,一个月饼可以拆分成两个花边,两个圆角矩形,六道横线,和曲折线,和馅名来构成。

微信截图_20210906100646.png

我们的难点在于如何绘制花边图形呢,其实可以想象成一个圆所以一共360度我们要拆分成多少条边就除以多少,这样就能得到角度,利用原点,就可以算出对应的x轴和y轴坐标,然后连接起来就变成了一个三角形,那么问题来了,怎么做出花边呢,我们可以用贝塞尔曲线来完成它,我们给当前三角形平分得到角度,在给圆的半径加一点延长,使其找到中心点,将其用曲线连接就完成了花边的绘制,因为这里用12条边就遍历计算角度然后绘制一个月饼皮就做好了~

微信截图_20210906100455.png

还有就是圆角矩形,我们这里用个arcTo这个万能写法去做边因为一共就四个角很容易能平分出Math.PI。

绘制馅名,因为有可能存在三个字的,我们也要单独处理,缩小调整位置。

另外,要说的就是我们期望的一步步把绘制过程展示出来所以要用一个数组存储绘制过程,记录当前时间,每隔200毫秒追加一次新绘制任务。直至结束,我们要给一个状态通知已经结束了,方面主逻辑进行其他操作。

剩下的就是一些基础绘制了,这里就不做赘述,请看以下月饼类完整代码:

/*MoonCake.js*/
class MoonCake {
  constructor(options) {
    this.x = 0;                              // x轴坐标
    this.y = 0;                              // y轴坐标
    this.name = "五仁"                        // 馅名
    this.strokeStyle = "rgb(180,110,48)";    // 线条色
    this.fillStyle = "rgb(250,201,81)";      // 填充色
    this.fontSize = 36;                      // 字体大小
    this.scale = 1;                          // 缩放大小
    Object.assign(this, options)
    this.ctx = null;
    this.progress = 0;                       // 绘制进度
    this.stepFn = []                         // 绘制步骤
    this.isComplete = false;                 // 是否绘制结束
    this.nowDate = new Date();               // 当前时间
    this.lastDate = new Date();              // 结束时间
    return this;
  }
  render(ctx) {
     // 渲染
    if (!ctx)
      throw new Error("context is undefined.");
    this.ctx = ctx;
    this.stepFn.length = 0;
    this.stepFn.push(() => this.drawEdge(180, 20))
    this.stepFn.push(() => this.drawEdge(140, 12))
    this.stepFn.push(() => this.drawRoundRectPath(140, 220, 40))
    this.stepFn.push(() => this.drawRoundRectPath(220, 140, 40))
    this.stepFn.push(() => this.drawLine(30, -110, 30, 110))
    this.stepFn.push(() => this.drawLine(0, -110, 0, 110))
    this.stepFn.push(() => this.drawLine(-30, -110, -30, 110))
    this.stepFn.push(() => this.drawLine(-110, -30, 110, -30))
    this.stepFn.push(() => this.drawLine(-110, 0, 110, 0))
    this.stepFn.push(() => this.drawLine(-110, 30, 110, 30))
    this.stepFn.push(() => this.drawRect(140, 140))
    this.stepFn.push(() => this.drawBox(140))
    this.stepFn.push(() => this.drawText())
    return this;
  }
  draw() {
    // 绘制
    for (let i = 0; i < this.progress; i++) {
      this.stepFn[i] && this.stepFn[i]()
    }
    if (this.progress > this.stepFn.length) return this.isComplete = true;
    this.nowDate = new Date();

    if(this.nowDate-this.lastDate>200){
      this.progress++;
      this.lastDate = this.nowDate;
    }
  }
  drawText(n) {
    // 绘制文字
    const {ctx, x, y, name, fontSize, strokeStyle, scale} = this;
    let size = fontSize;
    ctx.save()
    ctx.translate(x, y);
    ctx.scale(scale, scale)
    ctx.fillStyle = strokeStyle;
    ctx.textAlign = "center";
    ctx.font = `bolder ${size}px fangsong,self`
    ctx.shadowColor = strokeStyle;
    ctx.shadowBlur = 1;
    if (name.length == 2) {
      ctx.fillText(name.charAt(0), 0, -size * 0.5 + 5);
      ctx.fillText(name.charAt(1), 0, size * 0.5 + 5);
    }
    if (name.length >= 3) {
      size *= 0.7;
      ctx.font = `bolder ${size}px fangsong,self`
      ctx.fillText(name.charAt(0), 0, -size * 1 + 2);
      ctx.fillText(name.charAt(1), 0, size * 0 + 2);
      ctx.fillText(name.charAt(2), 0, size * 1 + 2);
    }
    ctx.restore();
  }
  drawBox(size) {
    // 绘制折线盒子
    const {ctx, x, y, strokeStyle, scale} = this;
    let v = 17,
      n = -size / 2;
    ctx.save()
    ctx.translate(x, y);
    ctx.scale(scale, scale)
    ctx.beginPath();
    ctx.lineCap = "round";
    ctx.lineWidth = 4;
    ctx.strokeStyle = strokeStyle;
    ctx.moveTo(v + n, n)
    ctx.lineTo(v + n, size - v + n)
    ctx.lineTo(size - v + n, size - v + n)
    ctx.lineTo(size - v + n, v + n)
    ctx.lineTo(v * 2 + n, v + n)
    ctx.lineTo(v * 2 + n, size - v * 2 + n)
    ctx.lineTo(size - v * 2 + n, size - v * 2 + n)
    ctx.lineTo(size - v * 2 + n, 45 + n)
    ctx.stroke()
    ctx.restore();

  }
  drawLine(x1, y1, x2, y2) {
    // 绘制线条
    const {ctx, x, y, strokeStyle, scale} = this;
    ctx.save()
    ctx.translate(x, y);
    ctx.scale(scale, scale)
    ctx.beginPath();
    ctx.lineCap = "round";
    ctx.lineWidth = 4;
    ctx.strokeStyle = strokeStyle;
    ctx.moveTo(x1, y1)
    ctx.lineTo(x2, y2)
    ctx.stroke()
    ctx.restore();
  }
  drawRect(width, height) {
    // 绘制矩形
    const {ctx, x, y, strokeStyle, fillStyle, scale} = this;
    ctx.save()
    ctx.translate(x, y);
    ctx.scale(scale, scale)
    ctx.beginPath();
    ctx.lineCap = "round";
    ctx.lineWidth = 4;
    ctx.strokeStyle = strokeStyle;
    ctx.fillStyle = fillStyle;
    ctx.rect(-width / 2, -height / 2, width, width)
    ctx.fill();
    ctx.stroke()
    ctx.restore();
  }
  drawRoundRectPath(width, height, radius) {
    // 绘制圆角矩形
    const {ctx, x, y, strokeStyle, fillStyle, scale} = this;
    let w = -width / 2,
      h = -height / 2
    ctx.save()
    ctx.translate(x, y);
    ctx.scale(scale, scale)
    ctx.lineCap = "round";
    ctx.strokeStyle = strokeStyle;
    ctx.fillStyle = fillStyle;
    ctx.lineWidth = 5;
    ctx.beginPath();
    ctx.arc(width - radius + w, height - radius + h, radius, 0, Math.PI / 2);
    ctx.lineTo(radius + w, height + h);
    ctx.arc(radius + w, height - radius + h, radius, Math.PI / 2, Math.PI);
    ctx.lineTo(w, radius + h);
    ctx.arc(radius + w, radius + h, radius, Math.PI, Math.PI * 3 / 2);
    ctx.lineTo(width - radius + w, h);
    ctx.arc(width - radius + w, radius + h, radius, Math.PI * 3 / 2, Math.PI * 2);
    ctx.lineTo(width + w, height - radius + h);
    ctx.closePath();
    ctx.stroke()
    ctx.restore();
  }
  drawEdge(radius, lineWidth) {
    // 绘制花边
    const {ctx, x, y, strokeStyle, fillStyle, scale} = this;
    let n = 12,
      v = 360 / n,
      m = 30;
    ctx.save()
    ctx.translate(x, y);
    ctx.scale(scale, scale)
    ctx.beginPath();
    ctx.lineCap = "round";
    for (let i = 0; i < n; i++) {
      let angle1 = i * v * Math.PI / 180;
      let angle2 = (i + 1) * v * Math.PI / 180;
      let angle3 = (i + 0.5) * v * Math.PI / 180;
      ctx.lineWidth = lineWidth;
      ctx.strokeStyle = strokeStyle;
      ctx.fillStyle = fillStyle;
      let _sx = radius * Math.cos(angle1),
        _sy = radius * Math.sin(angle1);
      ctx.lineTo(_sx, _sy);
      let _ex = radius * Math.cos(angle2),
        _ey = radius * Math.sin(angle2);

      let _mx = (radius + m) * Math.cos(angle3),
        _my = (radius + m) * Math.sin(angle3);
      ctx.bezierCurveTo(_mx, _my, _ex, _ey, _ex, _ey)
    }
    ctx.closePath();
    ctx.stroke()
    ctx.fill();
    ctx.restore();
  }
}
export default MoonCake;

第三章·打字机动画

/*api.js*/
drawText() {
    const {ctx, w, h, text} = this;
    ctx.save();
    ctx.fillStyle = "rgb(253,190,0)";
    ctx.textAlign = "center";
    ctx.font = `bolder 32px fangsong,self`
    ctx.shadowColor = "rgb(253,190,0)";
    ctx.shadowBlur = 10;
    ctx.fillText(text[0].substr(0, this.textIndex), w / 2, h * 0.36 + 240);
    if (text[0].length < this.textIndex) {
        ctx.fillText(text[1].substr(0, this.textIndex - text[0].length), w / 2, h * 0.36 + 240 + 52);
    }
    ctx.restore()
}
step() {
    requestAnimationFrame(this.step.bind(this));
    const {ctx, w, h} = this;
    ctx.clearRect(0, 0, w, h);
    this.moonCake && this.moonCake.draw();
    if (this.moonCake.isComplete) {
        this.moonCake.y -= 1.2;
        this.moonCake.y = Math.max(h * 0.36, this.moonCake.y)
        if (this.moonCake.y == h * 0.36) {
            this.drawText();
            if (this.dt % 20 == 0 && this.textIndex < this.text.join("").length) {
                this.textIndex++;
            }
        }
    }
    this.dt++;
}

我们期望是当月饼绘制结束之时,再出现打字机效果所以用到刚才的isComplete做判断一旦结束上移一定距离后,再做文字绘制。

因为我们刚才写的祝福语是这样的:

this.words = [
      "但愿人长久,千里共婵娟",
      "花好月圆佳节夜,思念千里梦甜甜",
      "皓月当空洒清辉,中秋良宵念挚心",
    ]

所以要输出两行,且把都号去掉,所以我们要生成一个由逗号分开的二维数组,分别表示第一行和第二行,随着dt的增加而改变绘制的位置,就是这么简单。


讲到这里我们就完成了,也想动手做一款了吗,在线演示

拓展

另外,其实还有很多瑕疵,比如本期用到了字体是仿宋,很多电脑都会带这个字体,但是手机端就很少了,所以我们有时间根据自己要写的文字,拆出一套仿宋字体集来使用。毕竟中文字体太大了,没发直接用的。

我们本期用了不少的图形绘制说难不难说简单也不简单,其实但从效果方面还是用svg做路径动画效果更好些。但是canvas绘制运用得当是非常灵活的,用简单图形的组合也能完成惊艳的作品。希望大家受此启发,也完成一款自己的中秋祝福送给亲朋好友。


如果有帮助的话,小伙伴们别忘了,点赞,评论,收藏,一键三连哟~~