ソースコード解説・目次

CHAPTER 02 · 全23項目

ゲームループと時間

経過時間 dt、フレームの予約、速い画面での描画の間引き、ポーズ中の停止処理を読み解きます。

主な参照先:frame / ensureLoop / update / redrawIfPaused

次のフレームを自分で予約する

game.html · L2298–2323ソースを見る ↗
// 描画の目標間隔。プレイ中は 60fps、動きの少ないタイトルとゲームオーバーは 30fps に抑える。
// 120Hz・144Hz などの画面では requestAnimationFrame がもっと頻繁に呼ばれるので、間引いて更新と描画の回数を減らす
// (動きは経過時間 dt で進めるので、間引いても速さは変わらない)。
const PLAY_FRAME_MS = 1000 / 60, IDLE_FRAME_MS = 1000 / 30;
let vsyncMs = 1000 / 60, lastVsync = last; // 画面の更新間隔の推定値(rAF が呼ばれる間隔の移動平均)と、直前に呼ばれた時刻

function frame(now) {
  rafId = 0;
  const gap = now - lastVsync;
  lastVsync = now;
  if (gap > 0 && gap < 50) vsyncMs += (gap - vsyncMs) * 0.1; // ポーズ明けなどの長い間隔は推定に使わない
  // 目標間隔に収まる回数(60Hz のプレイ中は 1、120Hz なら 2)ごとに描く。90Hz や 100Hz は毎回描き、60fps を下回らせない。
  // 判定には半回分の余裕を持たせ、呼ばれる時刻の揺れで描くはずの回を飛ばさないようにする。
  const target = state === "title" || state === "gameover" ? IDLE_FRAME_MS : PLAY_FRAME_MS;
  const every = Math.max(1, Math.floor(target / vsyncMs + 0.15));
  if (now - last < (every - 0.5) * vsyncMs) {
    rafId = requestAnimationFrame(frame);
    return;
  }
  const dt = Math.min((now - last) / 1000, 0.05);
  last = now;
  if (!paused) update(dt);
  draw(now / 1000);
  // ポーズ中は静止画のままにしてループを止める。再開は setPaused(false) → ensureLoop()。
  if (!paused) rafId = requestAnimationFrame(frame);
}

requestAnimationFrame(frame) は、次に画面が書き換わるタイミングで frame を呼ぶ予約です。1回の呼び出しで更新と描画を行い、必要なら次回を予約します。呼ばれる回数は画面のリフレッシュレートしだいで、60 Hzの画面なら毎秒60回、120 Hzの画面なら毎秒120回です。更新回数を常に60回と決め打ちしているわけではありません。

速い画面では描画を間引く

120 Hzや144 Hzの画面で毎回更新と描画をすると、60 Hzの画面の2倍以上の処理をすることになります。このゲームの動きは60 fpsあれば十分滑らかなので、frame() の前半で、今回描くかどうかを決めます。

  1. 画面の更新間隔を推定呼ばれた間隔 gap を1割ずつ混ぜた移動平均 vsyncMs
  2. 何回に1回描くか目標の間隔に更新が何回収まるかを every に
  3. まだなら予約だけ前回の描画から every 回ぶんたつまで戻る

目標の間隔は、プレイ中が PLAY_FRAME_MS(1/60秒)、動きの少ないタイトルとゲームオーバーが IDLE_FRAME_MS(1/30秒)です。every は切り捨てで求めるので、90 Hzや100 Hzの画面では1になり、間引かずに毎回描きます。60 fpsを下回らせないためです。

画面プレイ中タイトル・ゲームオーバー
60 Hz毎回(60 fps)2回に1回(30 fps)
90 Hz毎回(90 fps)3回に1回(30 fps)
120 Hz2回に1回(60 fps)4回に1回(30 fps)
144 Hz2回に1回(72 fps)4回に1回(36 fps)

速度に経過秒数を掛ける

now はミリ秒単位なので、前回との差を1000で割って秒に直します。速度が毎秒120 pxなら移動量は 120 * dt。30 fpsでも60 fpsでも、同じ1秒ならほぼ同じ距離を進みます。ただし本体は dt を最大0.05秒に制限し、長い停止後に大きく飛ばないようにしています。前の節の間引きで描画が毎秒60回になっても72回になっても、自機や敵の速さが変わらないのはこのためです。

計算してみる:フレーム数と移動量

毎秒120 pxで進む場合の、1回あたりの移動量を比較します。

ポーズではループの予約も止める

game.html · L2325–2329ソースを見る ↗
function ensureLoop() {
  if (rafId || paused) return;
  last = performance.now();
  rafId = requestAnimationFrame(frame);
}

ポーズ中は update() を呼ばず、次のフレームも予約しません。再開時の ensureLoop() は時刻を取り直すため、止めていた時間が一度に加算されません。言語やミュートをポーズ中に変えた場合は、redrawIfPaused() が1回だけ描き直します。