Skip to content
Hisai Toru edited this page Jan 2, 2015 · 3 revisions

簡単な例:スネークゲーム

次に簡単なゲームを作ろう。簡単とは言っても、ゲームを作るには少なくとも次のような要素が必要である。

  • 入力(キーボード、マウス等)
  • 画面出力
  • 状態のアップデート
  • 上記を繰り返すメインループ
  • タイトル画面、リザルト画面など

ここでは、キー入力や画面出力に ncurses を用いて、テキスト端末でもプレイできるスネークゲームを制作する。ゲームエンジンを使った開発スタイルに近づけるため、ごく簡単な汎用の「ゲームエンジン」をまず実装し、それを拡張する形で Lua を組み込む。

ここで紹介するコードは、サンプルの testbed/engine および testbed/snakegame ディレクトリに入っているので、適宜参照してほしい。ファイルは以下のような構成になっている。

engine/common.mak
engine/game_engine.cpp
engine/game_engine.hpp
engine/ncurses.i
snakegame/bootstrap.lua
snakegame/main.cpp
snakegame/Makefile

ゲームエンジンの部分は engine に、アプリケーションの部分は snakegame に分けて置いてある。まずゲームエンジンの方を見よう。game_engine.cppgame_engine.hpp は C++ で書かれたエンジンのコードである。ncurses.i は ncurses を Lua で使うための SWIG のインターフェイスファイルである。SWIG については、この本の後半で解説する。common.mak は GNU Make の Makefile で、アプリケーションのビルドの際に読み込むことを想定している。

次にアプリケーションの部分を見よう。bootstrap.lua は Lua のコードがすべて入っている。ゲームエンジンはデフォルトでこの名前のファイルを読み込んで実行するようになっている。main.cppmain 関数を含むゲームを起動するための C++ のコードが書かれている。そして Makefile にはビルドの手順が書かれている。

以下では C++ と Lua のコードについて説明する。

ゲームエンジン

メインループ

メインループは次のような初期化の処理から始まる。

~~include_code("samples/testbed/engine/game_engine.cpp", "mainloop_init", "c++")

GameMod はアプリケーションコードで実装するゲームモジュールのクラスである。ここでは init() というメソッドで初期化する。また、メインループで時刻の管理をするためにタイマーを生成している。実際のアプリケーション開発では、プラットフォームによって時間を管理する方法はさまざまである。

次のメインループ本体は次のような単純な無限ループである。

~~include_code("samples/testbed/engine/game_engine.cpp", "mainloop", "c++")

ゲームモジュールクラスで定義された update() というメソッドをただひたすら繰り返し呼び出すだけの単純なものである。

ゲームモジュール

次にアプリケーションを Lua でかけるように GameMod クラスを拡張した LuaGameEngine というクラスを作成する。まずコンストラクタでは、先の例と同様に Lua のインタプリタを準備する。

~~include_code("samples/testbed/engine/game_engine.cpp", "luaengine_ctor", "c++")

ここで使われている dieIfFail() というメソッドは次のように定義されている。

~~include_code("samples/testbed/engine/game_engine.cpp", "luaengine_die", "c++")

Lua のエラーを捕まえて C++ の例外を発生させている。エラー処理の方法はアプリケーションやプラットフォームによってさまざまなので、実際の開発ではそれらの環境に合わせて実装する。

クラス図にすると下の図のようになる。

次はゲームエンジンの初期化のメソッドである。

~~include_code("samples/testbed/engine/game_engine.cpp", "luaengine_init", "c++")

ここで lua_getglobal() を使って、Lua のコード中に定義された init() という関数を実行している。また、のちに見るようにこの init() 関数はゲームの状態を閉じ込めた 1 個のテーブルを返すので、これを luaL_ref() という関数を使ってメモリの中に覚えておく。

さらにゲームの状態のアップデートをするメソッドを定義する。

~~include_code("samples/testbed/engine/game_engine.cpp", "luaengine_update", "c++")

ここでは Lua で定義された update() という関数を呼び出しているが、ここに上の初期化の時に覚えておいたゲームの状態テーブルと前の update からの経過時間を渡している。

最後に、ゲームがまだ進行中かどうかを判定するために使うメソッドを定義する。

~~include_code("samples/testbed/engine/game_engine.cpp", "luaengine_running", "c++")

これも update() と同様に Lua で定義された running() という関数を呼び出している。なお、この関数は、ゲームが進行中なら true を、すでに終了していたら false を返すという約束である。

アプリケーション

C++ のコード

ここからは、アプリケーションとしてゲーム固有の処理や最終的な実行形式を得るための main() 関数などを実装する。Lua を既存のエンジンに組み込む雰囲気を味わうため、上で説明してエンジン部分とは切り離して実装している。

まずは、ゲームエンジンやその他のライブラリ関数を使用するためにヘッダファイルをインクルードする。

~~include_code("samples/testbed/snakegame/main.cpp", "includes", "c++")

ここでも時間を処理する関数を定義したいので、boost/timer/timer.hpp などが入っている。そして、ゲームエンジンの API が宣言された game_engine.hpp#include しておく。

次に main() を見てみよう。

~~include_code("samples/testbed/snakegame/main.cpp", "main", "c++")

ここで、LuaGameEngine のインスタンスを生成し、幾つかの Lua 関数を登録し、ゲームのメインループを開始している。

ここで登録している Lua 関数の sleep()elapsed_time() の 2 個の関数は、以下のように C++ で実装されている。

~~include_code("samples/testbed/snakegame/main.cpp", "sleep", "c++")

sleep()nanosleep() を使って一定時間スリープする。Lua からは秒単位の値として引数を受け取るので、これを 10 億倍してナノ秒に変換している。

~~include_code("samples/testbed/snakegame/main.cpp", "elapsed_time", "c++")

elapsed_time() はプログラム開始からの経過時間を秒単位で返す。これらの関数を使って Lua からでも時間を正確に知ることができるようにし、正確にゲームの進行スピードを制御することができる。

Lua のコード

Lua では上で作ったゲームモジュールから呼び出すように、最低限 init()update()running() の 3 個の関数を実装する。まず init() の実装を見てみよう。

~~include_code("samples/testbed/snakegame/bootstrap.lua", "init", "lua")

ここで、ゲームの状態を保持するテーブルを作っている。また、setmetatable() を使って、このテーブルが ModState クラスのインスタンスであるように設定している。厳密には Lua にはクラスやオブジェクトシステムは存在しないが、メタテーブルとメソッドという仕組みを使うことで似たようなことが実現できる。

また、この中で coroutine.create() を使ってコルーチンを生成している。このコードでは main_coro という名前の関数からコルーチンを作っている。このコルーチンは次の update() の中から繰り返し呼び出される。

~~include_code("samples/testbed/snakegame/bootstrap.lua", "update", "lua")

ここで C++ 側から受け取っている stat は、上の init() で作ったオブジェクトである。coroutine.resume() はコルーチンの実行を再開する。コルーチンがあるおかげでアップデート関数の中にゲームロジックを記述する必要がなく、それらの詳細を全てコルーチンに閉じ込めることができる。

~~include_code("samples/testbed/snakegame/bootstrap.lua", "running", "lua")

running() はゲームがまだ進行中かどうかを判定する。ここでは単にコルーチンが生きているかどうかだけを見ている。

ゲームロジック

ここまではどちらかというとゲームの種類によらず共通するような基礎の部分であったが、ここから先はゲームに固有の画面遷移やゲームのルールを実装する。

メインループ

上の init() でコルーチンとして作られた main_coro() の中身はまず次のように画面の初期化から始まる。

~~include_code("samples/testbed/snakegame/bootstrap.lua", "main_coro_init", "lua")

init_curses() は別途定義してある画面表示を初期化する関数である。

その次に呼び出している stat:next_frame() は、次のフレームまで待つという動作をする。これはどういうことかというと、一旦コルーチンから抜けて、次に update() が呼ばれた時に再びここに戻ってくる。詳細は後で見るが、このようにコルーチンから抜けたり再開したりする処理が、単なる関数呼び出しと同じように書けることに注意してほしい。

次に画面サイズを取得し、その値を表示する。

~~include_code("samples/testbed/snakegame/bootstrap.lua", "main_coro_screen", "lua")

ここで nc. で始まる関数は ncurses の関数である。SWIG というツールを使うと、このように C や C++ で書かれたネイティブのライブラリを Lua から簡単に呼ぶ出すことができる。なお、上で出てきた init_curses() の中でも ncurses の関数をいくつか呼び出している。

ncurses は Mac や Linux などの OS に標準でインストールされているので、この機能を使っている。ncurses についてここで詳しくは説明しないが、テキストベースのアプリケーションを作るのに便利な機能がたくさん含まれている。

次がメインループである。

~~include_code("samples/testbed/snakegame/bootstrap.lua", "main_coro_loop", "lua")

ここで running という局所変数を宣言して、この値が true である間は次の 3 つの関数を繰り返し呼び出している。

  • InGameState:new(stat) は新しく InGameState オブジェクトを作る
  • stat:show_title_screen() でタイトル画面を出す
  • game_state:main() でゲーム本体のメインループを開始する
  • stat:show_result(game_state.foods) でゲームの結果を表示する

下の 3 個の項目については、関数呼び出しの結果を and でつないで、どれかひとつでも false を返したら直ちにループを終了する。

ここで、タイトル画面、ゲーム本体、リザルト表示の 3 個の状態を単に並べて書いているのに注意してほしい。コード上では 3 状態の遷移を 1 個の文としてあっさりと表現しているが、コルーチンのない言語ではこのような書き方はできない。コルーチンがあることで、ユーザと対話的に動作するスクリプトが非常に簡単にかける。

コルーチンのない言語の場合は、状態遷移図など作って状態を設計し、しかるべき条件が整った時に次の状態に遷移するというような処理を自分で書かないといけない。あるいは、JavaScript などのようにクロージャをもつ言語では継続渡しスタイルやジェネレータ、future/promise API 等を使って表現することもできるが、しかしそれでも Lua のように単なる関数呼び出しのように簡単に扱うことはできない。

また、ネットワーク越しのメッセージのやりとりのように、いつ返事が返ってくるかわからないような非同期でノンブロッキングな処理もコルーチンを使うと簡単に書ける。

タイトル画面

タイトル画面は、まずタイトルを画面に表示する処理から始まる。

~~include_code("samples/testbed/snakegame/bootstrap.lua", "title_draw", "lua")

ここで画面を初期化し、タイトルの文字を表示している。すでに述べたように nc. で始まる関数は ncurses のものである。詳しくは ncurses のドキュメントを参照のこと。

次に、キーが入力されるまで待つ。

~~include_code("samples/testbed/snakegame/bootstrap.lua", "title_input", "lua")

ここでもループが使われている。ループの中ではキー入力の状態を取得して、エスケープキーかエンターキーが押されたかを判定している。エスケープキーが押された場合はいつでもプログラムを終了できるように、running の値を false に設定して、この値を後で関数の呼び出し元に返す。

ループ処理の最後では self:next_frame() を呼び出して、次のフレームに映るまで処理を中断している。

最後にタイトル画面を消去して呼び出し元に戻る。

~~include_code("samples/testbed/snakegame/bootstrap.lua", "title_cleanup", "lua")

next_frame() メソッド

上ですでに何度か登場した next_frame() メソッドは次のように実装されている。

~~include_code("samples/testbed/snakegame/bootstrap.lua", "next_frame", "lua")

ここで呼び出している elapsed_time()sleep() はともに上述のように C++ で実装したものである。これらを使って、現在のフレームが始まった時刻と現在の時刻の差を求め、1 フレームにかかる時間がぴったり 33 ミリ秒になるようにスリープさせている。このようにして、毎秒 30 フレームにできるだけ正確に合わせる。

そして最後に coroutine.yield() を呼んでコルーチンを中断し、コルーチンの呼び出し元に処理を戻している。ここで注意して欲しいのは、yield() すると関数の呼び出し元ではなくコルーチンの呼び出し元に処理が戻ることである。この結果、update() 関数の coroutine.resume() の呼び出しにまで戻り、一旦 C++ まで戻るわけである。しかし、ゲームのロジックを書いている間はこのような詳細に注意する必要はなく、素朴にループ処理を書くことができる。

ゲーム本体

ゲームの本体部分の関数は次のようにして始まる。

~~include_code("samples/testbed/snakegame/bootstrap.lua", "ingame_main", "lua")

まず、局所変数としてゲームがまだ終わってないかを表す running というフラグを導入する。これは関数の最後に呼び出し元に値を返す変数でもある。そして次に最初の餌を画面に表示する。

つぎにまたメインループが始まる。すでに何度も「メインループ」が出てきているが、これがゲーム本体のメインループである。

~~include_code("samples/testbed/snakegame/bootstrap.lua", "ingame_main_loop", "lua")

このループは毎フレーム実行する内容が書かれている。その意味で、他の言語で実装する場合のメインループに最も近い構造をしている。ループの条件部分では、runningself.alive の 2 個の値を監視している。それぞれ、ゲームが中断されていないかとゲームの主人公がまだ生きているかをループの継続する条件としている。

check_key_and_game_running() メソッドではまずキー入力を調べて、もしエスケープキーが押されていたら、ゲームを中断してプログラムを停止するために、running フラグに false をセットする。またエスケープキー以外が押されていたらそれをゲーム内の状態に反映させるような処理をしている。

次にそれぞれのゲームのルールに関する処理を実行する。

このゲームの主人公であるスネークは常に前に進み続けているので、move_snake() で毎フレームスネークの位置を更新している。次に check_got_food() で餌にたどり着いたかどうかの判定や餌を食べた時の処理をおこなう。check_died() ではスネークが壁や自分自身の体にぶつかったかどうかを調べ、その結果を処理する。

以上のルールに関する処理の後に draw_snake() を使ってスネークを描画する。また、nc.wrefresh() で物理的に画面を更新し、stat:next_frame() で次のフレームまで待つ。

~~include_code("samples/testbed/snakegame/bootstrap.lua", "ingame_main_return", "lua")

ループから抜けると、関数の呼び出し元に running の値を返す。スネークが死んだ場合は次にリザルト画面が待っているので、running の値は true のままであることに注意してほしい。

スネークの移動

上で出てきた move_snake() メソッドの実装は次のようになっている。

~~include_code("samples/testbed/snakegame/bootstrap.lua", "move_snake", "lua")

スネークは頭が自分の体の一部に当たると死ぬので、その体を構成する軌道を覚えておくために trajectory というメンバに配列を格納している。この配列は内部でリングバッファになっていて、現在の先頭要素のインデックスが pos_in_trajectory というメンバに入っている。

最後に head_pos に現在位置と移動方向から移動後の座標を計算して代入している。

当たり判定

スネークゲームでは餌にぶつかった場合と、壁や自分の体にぶつかった場合の 2 種類の当たり判定をしている。

まず餌に当たったかどうかを判定するメソッドは次のように実装されている。

~~include_code("samples/testbed/snakegame/bootstrap.lua", "check_got_food", "lua")

ここでは現在の餌の位置とスネークの頭の位置を比較して、それらが重なっていれば餌を取ったことにする。そして、餌を取ると次のような処理をする。

  • 新しい餌の位置をランダムに決める
  • スネークの長さのパラメータを増やす
  • 取得済みの餌の個数を 1 増やす
  • 新しい餌を描画する

次に、スネークが死んでしまうような当たり判定をみてみよう。まずは画面の上下左右の枠とぶつかったかを判定する。

~~include_code("samples/testbed/snakegame/bootstrap.lua", "check_died_collided_with_frame", "lua")

ここでは単にスネークの頭の座標を見て、画面からはみ出してないかを見ているだけである。もしスネークが画面からはみ出していたら alive メンバの値を false にセットする。

スネークゲームの特徴は長く伸びた自分自身の体が障害物となってしまうところである。その判定は次のように実装している。

~~include_code("samples/testbed/snakegame/bootstrap.lua", "check_died_collided_with_self", "lua")

for in ipairs() do というスタイルのループで配列 self.trajectory のそれぞれの要素について、スネークの頭の座標を比較している。ひとつでも重なっていればスネークは死ぬので、alive メンバを false に設定し return で関数ごとループから抜けている。

リザルト画面

スネークが死ぬとリザルト画面に遷移する。

~~include_code("samples/testbed/snakegame/bootstrap.lua", "show_result", "lua")

まずリザルトの情報を画面内の小さな枠に表示するために、ncurses の subwin() を使って子ウィンドウを作っている。box() はウィンドウの枠を書く関数でちょっとした飾りをつけるのに便利だ。

~~include_code("samples/testbed/snakegame/bootstrap.lua", "show_result_display", "lua")

ここで「GAME OVER」のテキストと取得した餌の数などを表示し、最後にエンターキーが押されるまでループを続け、画面をクリアして関数の呼び出し元に戻っている。

~~include_code("samples/testbed/snakegame/bootstrap.lua", "show_result_loop", "lua")

ここでもエスケープキーが押された時にはプログラムそのものを終了するように、running の値を戻している。

まとめ

この章では C++ と Lua、それにいくつかのプラットフォーム固有のライブラリを組み合わせて、実際に動くゲームを作った。

ホスト言語に C++ を使い、C++ だけで書かれた「ゲームエンジン」を用意した。そしてそれを拡張して Lua インタプリタを組み込み、アプリケーションをすべて Lua で記述した。また、ゲームエンジンに足りない機能は C++ で実装して Lua から呼び出せるようにした。さらに、ncurses というプラットフォーム固有のライブラリの機能を呼び出すために SWIG を使用した。

シンプルでなるべく多くのプラットフォームで動作するような構成ししたかったため、見た目はかなり貧相ではあるが、ゲームやインタラクティブなソフトウェアを作るための基礎はここにだいたい含まれている。

この本の残りの部分では、Lua インタプリタを組み込んでソフトウェアを構築するための、さまざまなテクニックや考え方を紹介する。内容が抽象的で分かり難い部分もあるかもしれないが、その時はまたこの章に戻ってきて動くコードを眺めてみてほしい。