戴兜

戴兜的小屋

Coding the world.
github
bilibili
twitter

HTML5 Canvas学习笔记

image

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

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

用 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 的上下文

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

Part.2-1 载入图片资源#

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

// 定义需要的图片资源路径
const 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"
}
// 载入所有图片
let completed = []; // 用于确定已经完成加载的图片数量
let keys = Object.keys(images); // 用于取出所有图片的名称
let 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 有较为详细的描述,这里就不再赘述。

image

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

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

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);
}

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

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

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);
    }
}

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

Part.4-1 帧的更新#

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

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

requestAnimationFrame() 能够更加精确地让帧率保持在 60fps 左右,避免过度绘制或动画卡顿。

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 格 / 帧的速度对于地面来说实在是太快了,在对速度进行微调后,绘制部分代码补充如下:

let 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);
};

Part.5 绘制鸟的动画#

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

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

let groundAnimationCount = 0;
let 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);
};
加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。