作为一只前端白菜,一直都不太敢碰Canvas。最近粗浅的学习了一下canvas的操作,也算是了结了自己的一个心愿。简单整理了一点自己的笔记和学习心得。

目的是创建一个Flappy Bird的基本动画场景。

用canvas是真的会上头的(雾
HTML5-Canvas学习笔记封面图

Part.1 准备

提前准备了一些用于制作这个简单动画的小素材,如下:

鸟:https://i.loli.net/2019/08/14/yRFjQEdoSpx9YDa.png

地面:https://i.loli.net/2019/08/14/QyhMSrTwul2H7A9.png

天空(背景):https://i.loli.net/2019/08/14/YHtj95f2MxOduKh.png

Part.2 开始

PS:感觉更好的做法是将背景和地面用两个Canvas分开绘制,但在这篇文章中,会将所有动画元素全部绘制到同一个Canvas中。

首先,创建一个Canvas(注意:使用css修改canvas可能会导致画面扭曲,尽量使用 heightwidth 属性定义canvas的宽高)

<canvas id="scene" height="640" width="481"></canvas>

接着使用其自身的 getContext() 以获取Canvas的上下文

var canvas = document.getElementById('scene');
var ctx = canvas.getContext('2d');

Part.2-1 载入图片资源

载入图片有很多种方式,我采用的是下面这种。不知道一般情况下是如何实现的,但这种方法维护和开发时非常方便。

// 定义需要的图片资源路径
var images = {
            bg: "https://i.loli.net/2019/08/14/YHtj95f2MxOduKh.png",
            bird: "https://i.loli.net/2019/08/14/yRFjQEdoSpx9YDa.png",
            ground: "https://i.loli.net/2019/08/14/QyhMSrTwul2H7A9.png"
}
// 载入所有图片
var completed = []; // 用于确定已经完成加载的图片数量
var keys = Object.keys(images); // 用于取出所有图片的名称
var keysLength = keys.length; // 获取图片数量
for (let i = 0; i < keysLength; i++) {
    let name = keys[i]; // 获取当前图片名称
    let image = new Image(); // 实例化一个Image对象用于加载图片
    image.src = images[name]; //加载图片
    image.onload = function () {
        images[name] = image; // 将images对象的指定图片路径替换成对应image实例
        completed.push(1); // 记录加载完成的图片个数
        if (completed.length === keysLength) {
            // 图片全部加载完成
            run();
        }
    }
}
// 画布的初始化方法
const run = () => {
    draw();
}

// 画布的绘制方法
const draw = () => {
    //TODO
}

此时我们需要的三个图片资源已经全部载入完毕了,需要使用时也非常简单,images["bg"]、 images["bird"] 、 images["ground"] 即可取出对应的图片。

Part.3 绘制背景

这是最简单的一个部分,在绘制之前,我们先来了解一下Canvas的 drawImage() 方法。

ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

每个参数分别对应着什么,我放张MDN上的图就明白了。想了解更多内容,MDN有较为详细的描述,这里就不再赘述。

现在绘制背景对于我们来说就不在话下了。我们来补充之前的draw函数

const draw = () => {
    // 绘制背景,预留80px的高度给地面
    ctx.drawImage(images["bg"], 0, 0, images["bg"].width, images["bg"].height, 0, 0, canvas.width, canvas.height - 80);
}

See the Pen FlappyBird-demo1 by 戴兜 (@DaiDR) on CodePen.

Part.4 绘制地面

地面的图片我们也用同样的方法绘制进Canvas中。为了将地面的高度限制在80px,我们需要在绘制时进行等比缩放。(更好的处理方案是对图片进行预处理避免缩放操作,但我懒

const draw = () => {
    // 绘制背景,预留80px的高度给地面
    ctx.drawImage(images["bg"], 0, 0, images["bg"].width, images["bg"].height, 0, 0, canvas.width, canvas.height - 80);
    // 绘制地面
    ctx.drawImage(images["ground"], 0, 0, images["ground"].width, images["ground"].height, 0, canvas.height - 80, images["ground"].width*(80/images["ground"].height), 80);
}

See the Pen FlappyBird-demo2 by 戴兜 (@DaiDR) on CodePen.

然后...就会发现地面的宽度远远不够,如何让图像平铺呢?答案是:循环。

大概估摸着给个值,能铺满整个地面就可以了。 (为了做动画不露馅可以稍微多给一两个)

const draw = () => {
    // 绘制背景,预留80px的高度给地面
    ctx.drawImage(images["bg"], 0, 0, images["bg"].width, images["bg"].height, 0, 0, canvas.width, canvas.height - 80);
    // 绘制地面
    let groundWidth = images["ground"].width * (80 / images["ground"].height); // 提前计算地面图片的绘制宽度,减少计算次数
    for(let i = 0 ;i<22;i++){
        // 使用for循环让地面重复绘制多次,从而得到完整的地面
        ctx.drawImage(images["ground"], 0, 0, images["ground"].width, images["ground"].height, groundWidth * i, canvas.height - 80, groundWidth, 80);
    }
}

See the Pen FlappyBird-demo3 by 戴兜 (@DaiDR) on CodePen.

背景和地面都已经绘制完了,那么如何给地面添加一个向后运动的位移效果呢?

Part.4-1 帧的更新

我们可能首先会想到使用 setTimeout()setInterval() ,但其实对于Canvas,浏览器的window全局对象提供了 requestAnimationFrame() 以更加高效的方式来更新帧内容。

为什么要使用 requestAnimationFrame() 而不是 setTimeout()setInterval() 呢?

  1. requestAnimationFrame() 能够更加精确地让帧率保持在60fps左右,避免过度绘制或动画卡顿。
  2. requestAnimationFrame() 会在元素不可见、浏览器处于后台、标签页未激活等情况时自动停止绘制,节省性能开销。相比于 setTimeout()setInterval()requestAnimationFrame() 由浏览器优化其调用时机,更加高效可靠。

根据我们的需求修改 run()draw() ,不要忘了在 draw() 的开头使用 clearRect() 来清空Canvas。

// 画布的初始化方法
const run = () => {
    window.requestAnimationFrame(draw);
}

// 画布的绘制方法
const draw = () => {
    ctx.clearRect(0, 0, canvas.width, canvas.height); // 清空画布
    // 绘制背景,预留80px的高度给地面
    ctx.drawImage(images["bg"], 0, 0, images["bg"].width, images["bg"].height, 0, 0, canvas.width, canvas.height - 80);
    // 绘制地面
    let groundWidth = images["ground"].width * (80 / images["ground"].height); // 提前计算地面图片的绘制宽度,减少计算次数
    for(let i = 0 ;i<22;i++){
        // 使用for循环让地面重复绘制多次,从而得到完整的地面
        ctx.drawImage(images["ground"], 0, 0, images["ground"].width, images["ground"].height, groundWidth * i, canvas.height - 80, groundWidth, 80);
    }
    window.requestAnimationFrame(draw);
}

现在的地面还不能动起来,为了让地面能够动起来,我们需要在每一帧对整个地面的位置进行改变。让地面无限后退的可能性是极小的,因为我们在这个demo中,只绘制了22份宽度的地面。但是,我们可以模拟出一种地面在无限后退的错觉。

最简单的方法,每组的前3帧,让地面后退1/3个宽度(指22份中一份的宽度),每组的第4帧,让地面前进1份的宽度。这样,就能制造出一种地面在无限后退的错觉了。

Part.4-2 位移的实现

那么,如何实现这22份“地面”同时向后退呢?第一种方法,直接在每次绘制时修改地面x轴坐标。第二种方法,每次绘制地面前,移动坐标系。我们采用第二种。

这就涉及到了渲染上下文绘制状态的保存与恢复,分别对应 save()restore() ,按照MDN的描述,能够被保存的属性有:strokeStyle, fillStyle, globalAlpha, lineWidth, lineCap, lineJoin, miterLimit, lineDashOffset, shadowOffsetX, shadowOffsetY, shadowBlur, shadowColor, globalCompositeOperation, font, textAlign, textBaseline, direction, imageSmoothingEnabled。

我们使用 translate() 来移动地面坐标系。

emmm,当然,1/3格/帧的速度对于地面来说实在是太快了,在对速度进行微调后,绘制部分代码补充如下:

var groundAnimationCount = 0;
// 画布的绘制方法
const draw = () => {
    ctx.clearRect(0, 0, canvas.width, canvas.height); // 清空画布
    // 绘制背景,预留80px的高度给地面
    ctx.drawImage(images["bg"], 0, 0, images["bg"].width, images["bg"].height, 0, 0, canvas.width, canvas.height - 80);
    // 绘制地面
    let groundWidth = images["ground"].width * (80 / images["ground"].height); // 提前计算地面图片的绘制宽度,减少计算次数
    if (groundAnimationCount == 12) {
        groundAnimationCount = 0;
    }
    ctx.save(); // 保存原坐标系
    ctx.translate(-groundAnimationCount * (groundWidth / 12), 0); // 移动坐标系
    for (let i = 0; i < 22; i++) {
        // 使用for循环让地面重复绘制多次,从而得到完整的地面
        ctx.drawImage(images["ground"], 0, 0, images["ground"].width, images["ground"].height, groundWidth * i, canvas.height - 80, groundWidth, 80);
    }
    ctx.restore(); // 恢复原坐标系
    groundAnimationCount++;
    window.requestAnimationFrame(draw);
};

See the Pen FlappyBird-demo4 by 戴兜 (@DaiDR) on CodePen.

Part.5 绘制鸟的动画

鸟的动画有3帧不同的状态,被处理成了雪碧图存放到了PNG图片中。有了前面的经验,处理这只鸟就简单多了。还记得之前讲到drawImage()方法时,sx/sy/sWidth/sHeight参数么?使用这四个参数来分割每一帧的画面。

不要忘了限制鸟扇动翅膀的速率,否则会谜之鬼畜的...

var groundAnimationCount = 0;
var birdAnimationCount = 0;
// 画布的绘制方法
const draw = () => {
    ctx.clearRect(0, 0, canvas.width, canvas.height); // 清空画布
    // 绘制背景,预留80px的高度给地面
    ctx.drawImage(images["bg"], 0, 0, images["bg"].width, images["bg"].height, 0, 0, canvas.width, canvas.height - 80);
    // 绘制地面
    let groundWidth = images["ground"].width * (80 / images["ground"].height); // 提前计算地面图片的绘制宽度,减少计算次数
    if (groundAnimationCount == 12) {
        groundAnimationCount = 0;
    }
    ctx.save(); // 保存原坐标系
    ctx.translate(-groundAnimationCount * (groundWidth / 12), 0); // 移动坐标系
    for (let i = 0; i < 22; i++) {
        // 使用for循环让地面重复绘制多次,从而得到完整的地面
        ctx.drawImage(images["ground"], 0, 0, images["ground"].width, images["ground"].height, groundWidth * i, canvas.height - 80, groundWidth, 80);
    }
    ctx.restore(); // 恢复原坐标系
    groundAnimationCount++;
    // 绘制鸟
    if (birdAnimationCount == 15) {
        birdAnimationCount = 0;
    }
    // 限制鸟扇动翅膀的帧率,每5帧才变成下一个状态
    ctx.drawImage(images["bird"], images["bird"].width / 3 * ~~(birdAnimationCount / 5),
      0,
      images["bird"].width / 3,
      images["bird"].height,
      canvas.width / 2 - 45 / 2,
      canvas.height / 2 ,
      45,
      images["bird"].height * (45 / images["bird"].width * 3)
    );
    birdAnimationCount++;
    window.requestAnimationFrame(draw);
};

See the Pen FlappyBird-demo5 by 戴兜 (@DaiDR) on CodePen.

- The End -


在迷失中寻找自我