フロントエンドの初心者として、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:背景と地面を 2 つの Canvas に分けて描画する方が良いと思いますが、この記事ではすべてのアニメーション要素を同じ Canvas に描画します。
まず、Canvas を作成します(注意:CSS で Canvas を変更すると画面が歪む可能性があるため、できるだけ height
と width
属性を使用して 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
}
この時点で、必要な 3 つの画像リソースはすべて読み込まれました。使用する際も非常に簡単で、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);
}
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()
を提供しており、より効率的な方法でフレーム内容を更新します。
なぜ setTimeout()
や setInterval()
ではなく requestAnimationFrame()
を使用するのでしょうか?
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);
}
現在の地面はまだ動いていません。地面を動かすためには、各フレームで地面全体の位置を変更する必要があります。地面が無限に後退する可能性は非常に低いですが、このデモでは 22 個の幅の地面しか描画していません。しかし、無限に後退しているように見せることはできます。
最も簡単な方法は、各グループの最初の 3 フレームで地面を 1/3 の幅後退させ、各グループの第 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()
を使用して地面の座標系を移動させます。
ええと、もちろん、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 パラメータを思い出してください。これらの 4 つのパラメータを使用して、各フレームの画面を分割します。
鳥が羽ばたく速度を制限することを忘れないでください。さもないと、謎の鬼畜になります…
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);
};