概要
本稿では、おもに普段ゲーム開発に従事していない開発者(とくにRailsユーザー)を想定読者に、ゲームの実装パターンの典型である ゲームループ が、ゲームの各オブジェクトの状態を経時的に変化させる仕組みについて解説します。
また、オブジェクト状態の経時的変化が、 アニメーション としてアウトプットされるまでの過程も確認します。題材として、RubyKaigi 2024でデモ披露された Rails版Flappy Bird を扱います。
Rails版FlappyBird
2024年5月の RubyKaigi 2024 の Samuel Williams さんの基調講演で、Ruby on RailsでのFlappyBird実装 のデモがありました。
ビデオゲームが要求するようなリアルタイムなインタラクティブ性を、Railsサーバーでも効率的に実現できる環境が整いつつあるようです。
(各社のブログ等でレポートもあります)
- https://developers.techouse.com/entry/RubyKaigi-2024-ioquatix-day2
- https://gihyo.jp/article/2024/06/rubykaigi-2024-keynote-report-day2
本稿のモチベーション: ビデオゲームの実装パターン?
この Rails版FlappyBird の実装を読み解くにあたり、前提となるビデオゲームの実装パターンについての理解があれば、Railsでそれを実現するフレームワークの優位性がより理解しやすくなると考えます。
他方、RailsはWebアプリケーション開発の文脈での利用が多数派であると推定すると、ユーザー操作をリアルタイムにアニメーションへ昇華するビデオゲームの実装に、馴染みのないユーザーが多いかもしれません。
筆者も普段Railsに触れていますが、ゲーム開発には従事していません。ただ、プログラミングを始めたきっかけはゲームです。過去に Game Programming Patterns くらいは読みました。Rails版FlappyBird の登場は、筆者自身の理解の整理にも都合が良さそうです。これを機にまとめます。
ビデオゲームで「絵を動かす」
「絵を動かす」ことは、差分を作ったキャラクターや背景画像を用意し、それらを連続的に切り替えることで実現できます。
たとえばテレビアニメーションは、制作側が固定したカメラワーク・カットに対応する大量の一枚絵、で構成されます。
他方、ビデオゲームでは、ユーザーの操作によってプレイヤーやカメラの位置が自由に変更されます。プレイヤー、エネミー、背景…など諸要素の組み合わせを考慮すると、すべてのシーンについて、事前に一枚絵を用意することは現実的ではありません。
状態演算と絵の生成
組み合わせ爆発の問題は回避できます。起こりうる全てのシーンの一枚絵の用意を諦め、プレイヤーやエネミーの状態、ユーザーのボタン操作等から、表示すべき絵を都度演算するのです。
すなわち「時間経過やユーザー操作を元に、ゲーム内の各要素の状態を演算する」「現在のゲーム状態から、一枚絵の情報を生成する」を高速に繰り返すことで、テレビアニメーションのような「動く一枚絵」が得られるはずです。
イベントループ
ユーザー操作の受付、ゲーム内の時間経過、グラフィック情報の生成…これらの高速な反復実行は、イベントループというプログラム構造によって実現が可能です。
ゲーム状態の経時的変化 - ゲームループ
FlappyBirdをはじめ多くのアクションゲームでは、現実の世界と同様、ゲームの世界の中にも時間が流れます。画面上の要素は、ユーザーの操作が無くともに常に変化します。
ユーザー操作から独立したゲーム内の状態更新ループは、「ゲームループ」と呼称されることがあります。1
単純化したゲームループの実装例
while true
経過時間 = 現在時刻 - 前回の実行時刻
関数_時間の経過処理(経過時間)
end
def 関数_時間の経過処理(経過時間)
enemyのx座標 += 経過時間 * 補正定数
# etc...
end
この実装例では、enemy
のx座標が、ループ実行のたびに加算される…つまりゲーム内時間の経過により、ある敵のオブジェクトが(勝手に)一定距離横に移動する結果となります。
ユーザー操作の待ち受け
多くのアクションゲームにおいては、ユーザー操作を待つ間も、ゲーム内の時間は流れます。見方を変えれば、操作の待ち受けが、ゲーム時間の経過を止めてはなりません。
ユーザー操作は、ゲームループから独立したループでハンドリングすることになります。2
while true # 永遠に繰り返し
ユーザー操作 = 関数_ユーザーの操作を待ち受け
関数_ユーザー操作をハンドリング(ユーザー操作)
end
def 関数_ユーザー操作をハンドリング(ユーザー操作)
case ユーザー操作
when 右ボタン
プレイヤーの移動速度 += 1
# etc...
end
グラフィック情報の生成・表示
ゲームループやユーザー操作よって変更されたゲーム状態は、最終的にプレイヤーやエネミーといった画面上の要素として描画可能な様式(以降「グラフィック情報」)に変換される必要があります。
各種ループによるゲーム状態の更新に並列して、一定間隔でグラフィック情報を生成すれば、状態が少しずつ変化した大量の一枚絵、ひいてはアニメーションが得られます。
while true # 永遠に繰り返し
if 前回実行から 1/60秒 経過したら
グラフィック情報 = 関数_状態をグラフィック情報に変換(現在のゲーム状態)
関数_グラフィック情報を画面に表示(グラフィック情報)
end
end
上記の単純化された疑似コードでは、1/60秒の経過ごとに、現在のゲーム内の状態をもとにグラフィック情報を生成し、画面描画します。
(理論上は、1/60 秒などの閾値が細かいほど滑らかなアニメーションとなる)
Anatomy of Flappy Bird
ここからは、socketry / flappy-bird のコードと、利用ライブラリである socketry / live の内容を横断しつつ、各種のイベントループによるアニメーション描画の実際を追います。
(ブラウザ側の実装には踏み込まず、サーバー側の解説にとどめます。また、socketry / live による ruby クラスと html のバインディングのおおまかな仕組みについては、基調講演を参照ください)
socketry / flappy-bird について
- Ruby on Rails で実装された、Webブラウザで遊べる Flappy Bird
- ゲーム画面はほぼ HTML と CSS で構成
- 通信にWebSocketを利用し、ユーザー操作のサーバー処理 / サーバーで生成したHTMLのブラウザ描画 をリアルタイムに実現
2つのイベントループ
Rails版Flappy Birdは、大まかには2つのイベントループから構成されます。
記載のコンポーネント・イベント名は筆者による便宜上のものであり、公式なものではありません。
1. メインループ (Main Loop)
ゲームのWebページの描画後、起動した状態を維持するループです。
クライアント(ブラウザ) / サーバー 双方で発生するイベントを待ち受け、発生の検出時に後続処理へ中継します。前掲の「ユーザー操作のハンドリング」「グラフィック情報の作成・表示(の一部)」を担います。
ライブラリであるsocketry / live の Live::Page#runの実装が相当し、FlappyBird側のControllerでWebSocket通信の確立時にコールされます。
def live
self.response = Async::WebSocket::Adapters::Rails.open(request) do |connection|
Live::Page.new(RESOLVER).run(connection)
end
end
ブラウザ -> サーバー 方向のイベント
WebSocketにより、主にはユーザーのキー操作を受け取ります。
while message = connection.read # 筆者注: WebSocketのconnectionのmessage受信を待機
last_update = Async::Clock.now
process_message(message.parse)
end
キー操作を受け取った process_message
は、操作に対応した処理の呼び出し、自機の「ジャンプ」状態への更新などが実行されます。
def handle(event)
detail = event[:detail]
case event[:type]
when "keypress", "touchstart"
if @game.nil?
self.start_game!
elsif detail[:key] == " " || detail[:touch]
self.jump
end
end
end
サーバー -> ブラウザ 方向のイベント
生成された最新のグラフィック情報(HTML)をWebSocket経由でブラウザに送信します。
HTMLはゲームループにより逐次キューに蓄積されます。メインループはキュー経由で最新のグラフィック情報を受け取り、WebSocketのメッセージに変換のうえブラウザに送信します。
queue_task = task.async do
while update = @updates.dequeue
update.send(connection) # 筆者注: queue に新しいhtmlが存在した場合、websocket経由で送信
# Flush the output if there are no more updates:
if @updates.empty?
connection.flush
end
end
end
2. ゲームループ (Game Loop)
タイトル画面でユーザーがゲームの開始操作をした場合に開始するループです。
自機や土管などのオブジェクトの状態を、時間経過を踏まえて再計算します。状態計算後、状態からグラフィック情報(HTML)を生成してキューにenqueueします。
Rails版FlappyBirdでは、グラフィック情報の作成ループとゲームループの実装が一致しているようです。
Steamで配信されるPCゲームなどユーザーの動作環境が不定の場合、レンダリングの頻度がゲームループと結合していると不都合がありそうですが、シンプルなブラウザゲームのため簡略化しているものと考えられます。
def run!(dt = 1.0/30.0) # 筆者注: デフォルトでは 秒間 30 回の描画
Async do
start_time = Async::Clock.now
while true
self.step(dt) # 筆者注: 経過時間を踏まえて、自機や土管の座標情報を更新
self.update! # 筆者注: 現在の各オブジェクト状態をベースに HTML の生成を実行、メインループの更新キューにenqueue
duration = Async::Clock.now - start_time
if duration < dt
sleep(dt - duration)
else
Console.info(self, "Running behind by #{duration - dt} seconds")
end
start_time = Async::Clock.now
end
end
end
-
self.step!(dt)
- 更新後の状態で自機と土管の接触判定を実施し、接触したら game_over! (ゲームループ停止)
-
self.update!
2つのループが並行しつつ相互作用することで、現在のゲーム状態に対応するHTMLが、デフォルトで秒間 30パターン程度ブラウザに送信される結果となります。
おわりに
元々、大量接続・大量通信におけるFalconサーバー利用の優位性を掘り下げようとしたのですが、イベントループまわりの実装サンプルとして単純に面白いなと思い、こういう切り口に落ち着きました。
イベントループのような低レベルの制御機構は、実際のゲーム開発ではフレームワーク化・隠蔽化されることが予想されますが、Rails版FlappyBirdのように、プログラミング言語の基本的な制御構文で自前実装することも可能そうです。
普段RailsでWeb開発に携わっている方々が面白がって読める内容になれば幸甚です。
-
ゲームループという呼称はGame Programming Patterns ソフトウェア開発の問題解決メニューで紹介されているデザインパターンからの引用です。 ↩
-
ゲームループというデザインパターン自体「ユーザー操作の待ち受けやレンダリングからゲーム状態演算のループを分離する」というものなので、"ユーザー操作はゲームループから独立したループでハンドリングする"と書くのは頭痛が痛い感じがあります。 ↩