{
"title": "HTML5 Canvas学习笔记",
"tags": [
"post",
"技术"
],
"sources": [
"xlog"
],
"external_urls": [
"https://daidr.xlog.app/canvas-newbie"
],
"date_published": "2019-08-14T06:50:00.000Z",
"content": "\n![image](ipfs://bafkreiaa3ukvd6dxqfnqbizkxxujksayyxao6v45ekwcru2ui57mlf3rqi)\n\n> 作为一只前端白菜,一直都不太敢碰Canvas。最近粗浅的学习了一下canvas的操作,也算是了结了自己的一个心愿。简单整理了一点自己的笔记和学习心得。\n> \n> 目的是创建一个Flappy Bird的基本动画场景。\n> \n> <small>用canvas是真的会上头的(雾</small>\n\n## Part.1 准备\n\n提前准备了一些用于制作这个简单动画的小素材,如下:\n\n鸟:https://i.loli.net/2019/08/14/yRFjQEdoSpx9YDa.png\n\n地面:https://i.loli.net/2019/08/14/QyhMSrTwul2H7A9.png\n\n天空(背景):https://i.loli.net/2019/08/14/YHtj95f2MxOduKh.png\n\n## Part.2 开始\n\nPS:感觉更好的做法是将背景和地面用两个Canvas分开绘制,但在这篇文章中,会将所有动画元素全部绘制到同一个Canvas中。\n\n首先,创建一个Canvas(注意:使用css修改canvas可能会导致画面扭曲,尽量使用 `height` 和 `width` 属性定义canvas的宽高)\n\n```html\n<canvas id=\"scene\" height=\"640\" width=\"481\"></canvas>\n```\n\n接着使用其自身的 `getContext()` 以获取Canvas的上下文\n\n```js\nconst canvas = document.getElementById('scene');\nconst ctx = canvas.getContext('2d');\n```\n\n### Part.2-1 载入图片资源\n\n载入图片有很多种方式,我采用的是下面这种。不知道一般情况下是如何实现的,但这种方法维护和开发时非常方便。\n\n```js\n// 定义需要的图片资源路径\nconst images = {\n bg: \"https://i.loli.net/2019/08/14/YHtj95f2MxOduKh.png\",\n bird: \"https://i.loli.net/2019/08/14/yRFjQEdoSpx9YDa.png\",\n ground: \"https://i.loli.net/2019/08/14/QyhMSrTwul2H7A9.png\"\n}\n// 载入所有图片\nlet completed = []; // 用于确定已经完成加载的图片数量\nlet keys = Object.keys(images); // 用于取出所有图片的名称\nlet keysLength = keys.length; // 获取图片数量\nfor (let i = 0; i < keysLength; i++) {\n let name = keys[i]; // 获取当前图片名称\n let image = new Image(); // 实例化一个Image对象用于加载图片\n image.src = images[name]; //加载图片\n image.onload = function () {\n images[name] = image; // 将images对象的指定图片路径替换成对应image实例\n completed.push(1); // 记录加载完成的图片个数\n if (completed.length === keysLength) {\n // 图片全部加载完成\n run();\n }\n }\n}\n// 画布的初始化方法\nconst run = () => {\n draw();\n}\n\n// 画布的绘制方法\nconst draw = () => {\n //TODO\n}\n```\n\n此时我们需要的三个图片资源已经全部载入完毕了,需要使用时也非常简单,images[\"bg\"]、 images[\"bird\"] 、 images[\"ground\"] 即可取出对应的图片。\n\n## Part.3 绘制背景\n\n这是最简单的一个部分,在绘制之前,我们先来了解一下Canvas的 `drawImage()` 方法。\n\n```js\nctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);\n```\n\n每个参数分别对应着什么,我放张MDN上的图就明白了。想了解更多内容,MDN有较为详细的描述,这里就不再赘述。\n\n![image](ipfs://bafkreifkt7czpld3455i2bw277zand6hh5r6xgkyqrc5lkfbl6yal5ql6i)\n\n现在绘制背景对于我们来说就不在话下了。我们来补充之前的draw函数\n\n```js\nconst draw = () => {\n // 绘制背景,预留80px的高度给地面\n ctx.drawImage(images[\"bg\"], 0, 0, images[\"bg\"].width, images[\"bg\"].height, 0, 0, canvas.width, canvas.height - 80);\n}\n```\n\n<iframe allowfullscreen=\"true\" allowpaymentrequest=\"true\" allowtransparency=\"true\" class=\"cp_embed_iframe \" frameborder=\"0\" height=\"342\" width=\"100%\" name=\"cp_embed_1\" scrolling=\"no\" src=\"https://codepen.io/DaiDR/embed/KKPVxKV?height=342&theme-id=light&default-tab=js&user=DaiDR&slug-hash=KKPVxKV&pen-title=FlappyBird-demo1&name=cp_embed_1\" style=\"width: 100%; overflow:hidden; display:block;\" title=\"FlappyBird-demo1\" loading=\"lazy\" id=\"cp_embed_KKPVxKV\"></iframe>\n\n## Part.4 绘制地面\n\n地面的图片我们也用同样的方法绘制进Canvas中。为了将地面的高度限制在80px,我们需要在绘制时进行等比缩放。(更好的处理方案是对图片进行预处理避免缩放操作,但我懒)\n\n```js\nconst draw = () => {\n // 绘制背景,预留80px的高度给地面\n ctx.drawImage(images[\"bg\"], 0, 0, images[\"bg\"].width, images[\"bg\"].height, 0, 0, canvas.width, canvas.height - 80);\n // 绘制地面\n ctx.drawImage(images[\"ground\"], 0, 0, images[\"ground\"].width, images[\"ground\"].height, 0, canvas.height - 80, images[\"ground\"].width*(80/images[\"ground\"].height), 80);\n}\n```\n\n然后…就会发现地面的宽度远远不够,如何让图像平铺呢?答案是:循环。\n\n大概估摸着给个值,能铺满整个地面就可以了。 (为了做动画不露馅可以稍微多给一两个)\n\n```js\nconst draw = () => {\n // 绘制背景,预留80px的高度给地面\n ctx.drawImage(images[\"bg\"], 0, 0, images[\"bg\"].width, images[\"bg\"].height, 0, 0, canvas.width, canvas.height - 80);\n // 绘制地面\n let groundWidth = images[\"ground\"].width * (80 / images[\"ground\"].height); // 提前计算地面图片的绘制宽度,减少计算次数\n for(let i = 0 ;i<22;i++){\n // 使用for循环让地面重复绘制多次,从而得到完整的地面\n ctx.drawImage(images[\"ground\"], 0, 0, images[\"ground\"].width, images[\"ground\"].height, groundWidth * i, canvas.height - 80, groundWidth, 80);\n }\n}\n```\n\n<iframe allowfullscreen=\"true\" allowpaymentrequest=\"true\" allowtransparency=\"true\" class=\"cp_embed_iframe \" frameborder=\"0\" height=\"342\" width=\"100%\" name=\"cp_embed_3\" scrolling=\"no\" src=\"https://codepen.io/DaiDR/embed/GRKowyG?height=342&theme-id=light&default-tab=js&user=DaiDR&slug-hash=GRKowyG&pen-title=FlappyBird-demo3&name=cp_embed_3\" style=\"width: 100%; overflow:hidden; display:block;\" title=\"FlappyBird-demo3\" loading=\"lazy\" id=\"cp_embed_GRKowyG\"></iframe>\n\n背景和地面都已经绘制完了,那么如何给地面添加一个向后运动的位移效果呢?\n\n### Part.4-1 帧的更新\n\n我们可能首先会想到使用 `setTimeout()` 或 `setInterval()` ,但其实对于Canvas,浏览器的window全局对象提供了 `requestAnimationFrame()` 以更加高效的方式来更新帧内容。\n\n为什么要使用 `requestAnimationFrame()` 而不是 `setTimeout()` 或 `setInterval()` 呢?\n\n`requestAnimationFrame()` 能够更加精确地让帧率保持在60fps左右,避免过度绘制或动画卡顿。\n\n`requestAnimationFrame()` 会在元素不可见、浏览器处于后台、标签页未激活等情况时自动停止绘制,节省性能开销。相比于 `setTimeout()` 或 `setInterval()` ,`requestAnimationFrame()` 由浏览器优化其调用时机,更加高效可靠。\n\n根据我们的需求修改 `run()` 和 `draw()` ,不要忘了在 `draw()` 的开头使用 `clearRect()` 来清空Canvas。\n\n```js\n// 画布的初始化方法\nconst run = () => {\n window.requestAnimationFrame(draw);\n}\n\n// 画布的绘制方法\nconst draw = () => {\n ctx.clearRect(0, 0, canvas.width, canvas.height); // 清空画布\n // 绘制背景,预留80px的高度给地面\n ctx.drawImage(images[\"bg\"], 0, 0, images[\"bg\"].width, images[\"bg\"].height, 0, 0, canvas.width, canvas.height - 80);\n // 绘制地面\n let groundWidth = images[\"ground\"].width * (80 / images[\"ground\"].height); // 提前计算地面图片的绘制宽度,减少计算次数\n for(let i = 0 ;i<22;i++){\n // 使用for循环让地面重复绘制多次,从而得到完整的地面\n ctx.drawImage(images[\"ground\"], 0, 0, images[\"ground\"].width, images[\"ground\"].height, groundWidth * i, canvas.height - 80, groundWidth, 80);\n }\n window.requestAnimationFrame(draw);\n}\n```\n\n现在的地面还不能动起来,为了让地面能够动起来,我们需要在每一帧对整个地面的位置进行改变。让地面无限后退的可能性是极小的,因为我们在这个demo中,只绘制了22份宽度的地面。但是,我们可以模拟出一种地面在无限后退的错觉。\n\n最简单的方法,每组的前3帧,让地面后退1/3个宽度(指22份中一份的宽度),每组的第4帧,让地面前进1份的宽度。这样,就能制造出一种地面在无限后退的错觉了。\n\n### Part.4-2 位移的实现\n\n那么,如何实现这22份“地面”同时向后退呢?第一种方法,直接在每次绘制时修改地面x轴坐标。第二种方法,每次绘制地面前,移动坐标系。我们采用第二种。\n\n这就涉及到了渲染上下文绘制状态的保存与恢复,分别对应 `save()` 和 `restore()`,按照[MDN](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/save)的描述,能够被保存的属性有:strokeStyle, fillStyle, globalAlpha, lineWidth, lineCap, lineJoin, miterLimit, lineDashOffset, shadowOffsetX, shadowOffsetY, shadowBlur, shadowColor, globalCompositeOperation, font, textAlign, textBaseline, direction, imageSmoothingEnabled。\n\n我们使用 `translate()` 来移动地面坐标系。\n\nemmm,当然,1/3格/帧的速度对于地面来说实在是太快了,在对速度进行微调后,绘制部分代码补充如下:\n\n```js\nlet groundAnimationCount = 0;\n// 画布的绘制方法\nconst draw = () => {\n ctx.clearRect(0, 0, canvas.width, canvas.height); // 清空画布\n // 绘制背景,预留80px的高度给地面\n ctx.drawImage(images[\"bg\"], 0, 0, images[\"bg\"].width, images[\"bg\"].height, 0, 0, canvas.width, canvas.height - 80);\n // 绘制地面\n let groundWidth = images[\"ground\"].width * (80 / images[\"ground\"].height); // 提前计算地面图片的绘制宽度,减少计算次数\n if (groundAnimationCount == 12) {\n groundAnimationCount = 0;\n }\n ctx.save(); // 保存原坐标系\n ctx.translate(-groundAnimationCount * (groundWidth / 12), 0); // 移动坐标系\n for (let i = 0; i < 22; i++) {\n // 使用for循环让地面重复绘制多次,从而得到完整的地面\n ctx.drawImage(images[\"ground\"], 0, 0, images[\"ground\"].width, images[\"ground\"].height, groundWidth * i, canvas.height - 80, groundWidth, 80);\n }\n ctx.restore(); // 恢复原坐标系\n groundAnimationCount++;\n window.requestAnimationFrame(draw);\n};\n```\n\n<iframe allowfullscreen=\"true\" allowpaymentrequest=\"true\" allowtransparency=\"true\" class=\"cp_embed_iframe \" frameborder=\"0\" height=\"342\" width=\"100%\" name=\"cp_embed_4\" scrolling=\"no\" src=\"https://codepen.io/DaiDR/embed/zYOryYz?height=342&theme-id=light&default-tab=js&user=DaiDR&slug-hash=zYOryYz&pen-title=FlappyBird-demo4&name=cp_embed_4\" style=\"width: 100%; overflow:hidden; display:block;\" title=\"FlappyBird-demo4\" loading=\"lazy\" id=\"cp_embed_zYOryYz\"></iframe>\n\n## Part.5 绘制鸟的动画\n\n鸟的动画有3帧不同的状态,被处理成了雪碧图存放到了PNG图片中。有了前面的经验,处理这只鸟就简单多了。还记得之前讲到drawImage()方法时,sx/sy/sWidth/sHeight参数么?使用这四个参数来分割每一帧的画面。\n\n不要忘了限制鸟扇动翅膀的速率,否则会谜之鬼畜的…\n\n```js\nlet groundAnimationCount = 0;\nlet birdAnimationCount = 0;\n// 画布的绘制方法\nconst draw = () => {\n ctx.clearRect(0, 0, canvas.width, canvas.height); // 清空画布\n // 绘制背景,预留80px的高度给地面\n ctx.drawImage(images[\"bg\"], 0, 0, images[\"bg\"].width, images[\"bg\"].height, 0, 0, canvas.width, canvas.height - 80);\n // 绘制地面\n let groundWidth = images[\"ground\"].width * (80 / images[\"ground\"].height); // 提前计算地面图片的绘制宽度,减少计算次数\n if (groundAnimationCount == 12) {\n groundAnimationCount = 0;\n }\n ctx.save(); // 保存原坐标系\n ctx.translate(-groundAnimationCount * (groundWidth / 12), 0); // 移动坐标系\n for (let i = 0; i < 22; i++) {\n // 使用for循环让地面重复绘制多次,从而得到完整的地面\n ctx.drawImage(images[\"ground\"], 0, 0, images[\"ground\"].width, images[\"ground\"].height, groundWidth * i, canvas.height - 80, groundWidth, 80);\n }\n ctx.restore(); // 恢复原坐标系\n groundAnimationCount++;\n // 绘制鸟\n if (birdAnimationCount == 15) {\n birdAnimationCount = 0;\n }\n // 限制鸟扇动翅膀的帧率,每5帧才变成下一个状态\n ctx.drawImage(images[\"bird\"], images[\"bird\"].width / 3 * ~~(birdAnimationCount / 5),\n 0,\n images[\"bird\"].width / 3,\n images[\"bird\"].height,\n canvas.width / 2 - 45 / 2,\n canvas.height / 2 ,\n 45,\n images[\"bird\"].height * (45 / images[\"bird\"].width * 3)\n );\n birdAnimationCount++;\n window.requestAnimationFrame(draw);\n};\n```\n\n<iframe allowfullscreen=\"true\" allowpaymentrequest=\"true\" allowtransparency=\"true\" class=\"cp_embed_iframe \" frameborder=\"0\" height=\"342\" width=\"100%\" name=\"cp_embed_5\" scrolling=\"no\" src=\"https://codepen.io/DaiDR/embed/VwZeqQR?height=342&theme-id=light&default-tab=js&user=DaiDR&slug-hash=VwZeqQR&pen-title=FlappyBird-demo5&name=cp_embed_5\" style=\"width: 100%; overflow:hidden; display:block;\" title=\"FlappyBird-demo5\" loading=\"lazy\" id=\"cp_embed_VwZeqQR\"></iframe>",
"attributes": [
{
"value": "canvas-newbie",
"trait_type": "xlog_slug"
}
]
}