マガジン

Canvasゲームの基本:描画・入力・更新の3つのループ

遊びをつくる

執筆にClaudeを使用しています。

同じ玉転がしゲームの試作から改良版までを、三段階で並べた写真風イメージ。
記事のテーマをもとに制作したAI生成イラスト。

ブラウザでゲームをつくるとき、HTMLの<canvas>要素はもっとも基本的な選択肢です。スプライトもUIも物理演算も、すべては「毎秒何十回も画面を塗り直す」という一つの仕組みの上に乗っています。それがゲームループと呼ばれる構造です。 Canvasゲームが動き続けるためには、「状態を更新する」「画面に描く」「入力を受け取る」という三つの役割を、それぞれ適切なタイミングで実行する必要があります。この三つをどこに置き、どう連携させるかが、ゲームの動作を左右します。チュートリアルでコードをコピーしただけでは「なぜこう書くのか」が見えにくい部分ですが、構造の意味を押さえると、自分でゲームを設計するときの判断が格段に速くなります。

この記事の目次
  1. ゲームループとはなにか
  2. 「更新」の役割と時間管理
  3. 「描画」の役割と注意点
  4. 「入力」をループのどこに置くか
  5. 三つの役割を整理して設計する
  6. 次の行動
  7. 参考資料

ゲームループとはなにか

ゲームループとは、毎フレーム繰り返し呼ばれる処理のサイクルです。ブラウザではrequestAnimationFrame(以下rAF)という関数を使い、ディスプレイのリフレッシュレートに合わせてコールバックを呼び出します。一般的なディスプレイなら1秒間に60回、高リフレッシュレートのモニタなら120回以上呼ばれます。

最小の構造はこうなります。

javascript
function loop(timestamp) {
  update(timestamp);
  draw();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

updateでゲームの状態(位置・速度・スコアなど)を変え、drawでその状態をCanvasに描く。この二段階を毎フレーム繰り返すことで、絵が動いているように見えます。setTimeoutやsetIntervalではなくrAFを使う理由は、ブラウザがタブを非アクティブにしたときに自動で停止し、描画タイミングと同期するためです。

「更新」の役割と時間管理

update関数の仕事は、ゲーム内の状態を一フレーム分だけ進めることです。キャラクターの座標に速度を足す、カウントダウンタイマーを減らす、衝突を判定する、といった処理がここに入ります。

注意が必要なのはフレームレートへの依存です。毎フレーム「速度をそのまま座標に加算」する書き方では、60fpsと120fpsで動く速さが変わります。この問題を避けるために、rAFが渡すtimestamp(ミリ秒単位の経過時間)を使ってデルタタイム(前フレームからの経過時間)を計算します。

javascript
let lastTime = 0;

function update(timestamp) {
  const delta = (timestamp - lastTime) / 1000; // 秒に変換
  lastTime = timestamp;

  player.x += player.vx * delta;
  player.y += player.vy * delta;
}

deltaを掛けることで、フレームレートが変動しても「1秒あたりの移動量」が一定になります。ただし、ブラウザのタブを切り替えて戻ったとき、deltaが数秒になる場合があります。Math.min(delta, 0.1)のように上限を設けると、再表示時にオブジェクトが飛び散るバグを防げます。

「描画」の役割と注意点

draw関数は、更新後の状態をそのままCanvasに映し出す役割です。ここでは状態を変えません。描くだけです。この分離が守られていると、デバッグのときに「おかしいのは状態か、描画か」を切り分けやすくなります。

描画で最初に失敗しやすいのは、前のフレームの絵を消し忘れることです。Canvasは上書きされるまで前の内容が残るため、キャラクターが動くたびに軌跡が残ります。毎フレームの最初にclearRectで全体を消すのが基本です。

javascript
function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  // ここから描画
  ctx.fillRect(player.x, player.y, 32, 32);
}

描画コストが高くなると、ループ全体が詰まってカクつきます。背景など動かない要素は別のCanvasレイヤーに描いて一度だけ更新する、オフスクリーンCanvas(画面外に用意したCanvas)にあらかじめ画像を合成してから本体にコピーする、といった手法でコストを下げられます。最初のうちはシンプルに1枚のCanvasで書き始め、実際に重くなってから最適化を検討するのが現実的です。

「入力」をループのどこに置くか

入力処理は、ループの構造の中でもっとも位置を迷いやすい部分です。キーボードやマウスのイベントはrAFとは独立して非同期に発火するため、イベントリスナーはループの外で登録し、状態だけをオブジェクトに記録するのが定石です。

javascript
const keys = {};

window.addEventListener('keydown', e => { keys[e.code] = true; });
window.addEventListener('keyup',   e => { keys[e.code] = false; });

そしてupdateの中でこの状態を読みます。

javascript
function update(delta) {
  if (keys['ArrowRight']) player.vx = 200;
  else if (keys['ArrowLeft']) player.vx = -200;
  else player.vx = 0;
}

この構造にすることで、キーを押しっぱなしにしたときも毎フレーム滑らかに反応します。keydownイベントの中で直接座標を動かすと、OSのキーリピート設定に左右されてぎこちない動きになります。

マウス・タッチの場合も同様に、イベントで座標と状態をオブジェクトに記録し、updateで参照します。「クリックされた瞬間」だけ反応したい処理(ジャンプや攻撃など)は、フラグを立ててupdateで消費する形にすると、一フレームに一度だけ処理される動作を安全に実装できます。

三つの役割を整理して設計する

まとめると、Canvasゲームの基本構造は次の関係になります。

  • 入力受付、タイミング:イベント発火時(非同期)、やること:状態オブジェクトに記録するだけ
  • 更新、タイミング:毎フレーム(rAF内)、やること:状態を読んで次の状態を計算する
  • 描画、タイミング:毎フレーム(更新の直後)、やること:現在の状態をCanvasに映す

この分離が崩れると、たとえば「描画の中で座標を変える」「イベントリスナーの中でCanvasを直接触る」といったコードが生まれ、後から追いかけるのが難しくなります。コードが数百行を超えてきたとき、この構造が守られているかどうかで、機能追加のしやすさが大きく変わります。

AIツールでゲームのひな形を生成した場合も、出力されたコードをこの三つの役割に照らして読み解くと、「ここを変えれば速さが変わる」「この条件がゲームオーバー判定だ」という把握が早くなります。生成コードをそのまま動かすだけでなく、構造を読む習慣が、次の改造につながります。

次の行動

requestAnimationFrameを使った最小のループを自分で書き、キー入力でオブジェクトを動かすところまで手を動かしてみてください。動いたら、フレームレートを制限しているデバイスでもズレなく動くか、デルタタイムの計算で確かめてみましょう。作ったゲームが形になったら、アソバース(https://aso-verse.com)にHTMLファイルやZIPとして投稿して公開できます。

参考資料

  • MDN Web Docs「Canvas API」
  • MDN Web Docs「requestAnimationFrame」
  • MDN Web Docs「KeyboardEvent.code」