アドベンチャーゲームエンジンの作り方
プログラマーの尾関です。
今回は「アドベンチャーゲームエンジン」の作り方を紹介したいと思います。
■1. アドベンチャーゲームエンジンとは
ここで紹介するアドベンチャーゲームエンジンとは、会話イベントが記述されたスクリプトを読み込んで実行するものを想定しています。例えばノベルゲームやRPGの会話イベントなどがそれに該当します。
ちなみにこういったスクリプトを実装すると、副産物として、敵のAI(思考ルーチン) や、リアルタイムCGの3Dキャラ劇などもスクリプトで作成が可能となります。
■2. 最もかんたんなアドベンチャーゲームエンジン
最も簡単なアドベンチャーゲームエンジンとして以下のような実装です。
- プログラムに直接スクリプトデータを記述
- 上から順番にそのデータを実行する
- IF文などの制御構造はなし
プログラムの実装例は以下のとおりです。
// コマンドオブジェクト.
struct COMMAND_OBJ {
int Code; // 命令コード.
int Params[8]; // パラメータ.
};
// 命令コード.
enum eCmd {
eCmd_Message, // テキストメッセージ表示.
eCmd_Bg, // 背景表示.
eCmd_BgErase, // 背景非表示.
eCmd_Wait, // 一時停止.
eCmd_End, // 終了.
};
// 実行コードテーブル(スクリプトデータ).
static COMMAND_OBJ s_CommandList[] = {
{eCmd_Bg, {1}}, // 背景画像 "1" を表示する.
{eCmd_Message, {3}}, // テキストID "3" を表示する.
{eCmd_Wait, {30}}, // 30フレーム待つ.
{eCmd_BgErase, {}}, // 背景消去.
{eCmd_End, {}}, // 終了.
};
// コマンド実行オブジェクト.
class Interpreter {
enum eState {
eState_Exec, // 実行中.
eState_KeyWait, // キー入力待ち.
eState_Wait, // 一時停止.
eState_End, // 終了.
};
int m_Pc = 0; // プログラムカウンタ.
eState m_State = eState_Exec; // 状態.
int m_MessageID = 0; // 表示するテキストのID.
int m_WaitTimer = 0; // 一時停止するフレーム数.
int m_Bg = 0; // 描画する背景番号 (0で非表示).
// 終了したかどうか.
bool IsEnd() {
return m_State == eState_End;
}
// 更新.
void Update() {
switch(m_State) {
case eState_Exec: // 実行中.
{
COMMAND_OBJ* pCmd = &s_CommandList[m_Pc];
switch(pCmd->Code) {
case eCmd_Message: // テキストメッセージ表示.
m_State = eState_KeyWait;
break;
case eCmd_Bg: // 背景表示.
m_Bg = pCmd->Params[0]; // 背景番号を設定.
break;
case eCmd_BgErase: // 背景非表示.
m_Bg = 0;
break;
case eCmd_Wait: // 一時停止.
m_State = eState_Wait;
m_WaitTimer = pCmd->Params[0];
break;
case eCmd_End: // 終了.
m_State = eState_End;
break;
}
// 実行カウンタを進める.
m_Pc++;
}
break;
case eState_KeyWait: // キー入力待ち.
if(/* Aボタンを押した */) {
m_State = eState_Exec; // スクリプト実行に戻る.
}
break;
case eState_Wait: // 一時停止.
m_WaitTimer--;
if(m_WaitTimer <= 0) {
m_State = eState_Exec; // スクリプト実行に戻る.
}
break;
}
}
// 描画.
void Draw() {
if(m_Bg > 0) {
// 背景の描画.
}
}
};
スクリプトを実行するタイミングで、Interpreterクラスを生成し、Interpreter::IsEnd() が true になるまで、Interpreter::Update() / Draw() を呼び続けることとなります。
2.1. スクリプトデータの外部化
この方法はちょっとしたスクリプトを作るときには便利です。
ただ、プログラムコードに直接「実行コードテーブル」を配置しているので、基本的にプログラマー以外は編集できないため不便です。
そこで、CSVファイル (カンマ区切りのテキストデータ) を読み込むようにすると、プログラマー以外でも編集が可能となります。
記述例としては以下のCSVファイルを用意します。
BG,1 // 背景画像 "1" を表示する.
MSG,3 // テキストID "3" を表示する.
WAIT,30 // 30フレーム待つ.
BG_ERASE // 背景消去.
END // 終了.
このようなデータであれば、”,” で文字列を区切って、命令コードと値を取り出すだけなので、プログラム的にも難しくなく、外部データにすることが可能です。
2.2 Luaを組み込む。またはスクリプト言語を自作する
ただCSV形式の場合、複雑な制御構造を持つスクリプトを記述することが難しいです。
例えば、特定のイベントフラグがONのときにあるイベントを発生させる、といった分岐処理の記述です。
ラベルの実装と、指定のラベルへジャンプ可能な IF-GOTO命令を作ることでひとまずは実装できますが、なかなか記述難易度が高いスクリプトになります。
そこでちゃんとしたプログラム的な構造を持ったスクリプトを作ります。
もし、Luaなどのスクリプト言語を組み込むことができる環境であればそれを使うとよいです。
スクリプト言語の組み込みが難しい環境、または独自の記述でスクリプトを書きたい、という場合はスクリプト言語を自作する必要があります。
スクリプト言語を自作するためには、字句解析・構文解析を作ります。
字句解析とは、スクリプトを最小の単位の意味に分解することです。
例えば以下のようなスクリプトを字句解析してみます。
if(a == 0) {
print("hello");
}
これを字句解析すると、以下のように分解できます。
- if : ifキーワード
- ( : 式のかたまり開始
- a : 変数 “a”
- == : 比較演算子
- 0 : 数値 “0”
- ) : 式のかたまり終了
- { : ブロック開始
- print : printキーワード
- ( : 関数パラメータ開始
- “hello”:文字列 “hello”
- ) : 関数パラメータ終了
- } : ブロック終了
字句解析は自作しても良いですが、 “lex” というツールを使うと、字句のルールを定義するだけでそれに対応するプログラムコードを出力することができます。
続いて、構文解析です。構文解析とは、字句解析して得られた字句を意味のある構造にする処理となります。
例えば先程の字句であれば、
“if”文の下に “( a == 0 )” という式がぶら下がり、それを評価することで “{” ~ “}” のブロックを実行する、という構造を構築することとなります。
ちなみに構文解析は “yacc” というツールで作ることも可能です。
このようにして分解・再構築したデータ構造を、先程のような「命令コード+パラメータ」の形式のデータに出力します。
以下は趣味で作った自作スクリプトの記述です。
if($1 == 0) {
"こんにちは/"
}
アドベンチャーゲーム用に作ったので、ダブルクオーテーションで囲むだけでテキスト表示命令になる、というのが強みの言語です。(ただし、文字列を引数で渡すのが文法上難しいですが)
この独自スクリプトの説明をすると、$1 というのは変数番号 “1” の定義で、この値が “0” であれば、”こんにちは” というメッセージを表示する処理となります。
そしてこのスクリプトをコンバートすると以下のような CSVファイルを出力します。
VAR,1
INT,0
EQ
IF,00000007
MSG,1,こんにちは
GOTO,00000007
END
上から順に説明すると、変数 “1” の値をスタックに積み、さらに数値 “0” をスタックに積みます。
“EQ” は比較演算子のコマンドなので、スタックから右辺値と左辺値を取り出し比較して、その結果をスタックに積みます。
次に “IF” 命令なので、スタックから値を取り出し、真偽判定(0でないかどうか)を行い、成立すればそのまま下に進みます。
もし成立しない場合、アドレス “00000007” (このスクリプトでは 7行目) に進みます。
“MSG” はテキスト表示命令です。”こんにちは” というテキストを表示して、キー入力待ちをします。
“GOTO” はアドレスジャンプで、 “00000007” にジャンプします。
最後に “END” でスクリプトを終了します。
このようなアドレスジャンプの構造を作るには、構文解析中には飛び先の仮アドレスを書き込んで、すべての命令コードを書き込んだ後、仮アドレスを実アドレスに置き換える、という処理を行います。
以下は最初のコンバートで作られたテキストです。
VAR,1
INT,0
EQ
IF,#00000001 ←仮アドレス
MSG,1,こんにちは
GOTO,#00000001 ←仮アドレス
END
この書き出したテキストとは別に、仮アドレス #00000001 は 7行目にある、というアドレステーブルを作っておき、最後に実アドレスに置き換える、という流れとなります。
ちなみに CSVファイルのテキストをリアルタイムゲームのAIで使うと結構重たい処理となるので、速度が要求されるスクリプトの場合、事前にバイナリに置き換えるようにしておいたほうが良いと思います。
■3. アドベンチャーゲームエンジンを作る際に留意しておくと良い仕様
以下は、実際にアドベンチャーゲームを作ったときに実装してよかったと思うものです。
- 会話ウィンドウモジュールはスクリプト制御側から細かく制御できると良い
- 会話ウィンドウのテキスト送りを会話ウィンドウ側にすべて任せてしまうと、スクリプト側から制御したいとき不便になる
- 例えば、スクリプトコマンドでテキスト送りのタイミングを制御したい場合に問題が起きる
- 選択肢を早めに実装しておくと、スクリプト制御でデバッグ機能が自由に実装できる
- 選択肢はフラグでON/OFFできる機能があると、物語の進行によって特定の項目を表示・非表示する場合に役立つ
- キャラクターバストアップの表示開始演出(例えばスライドインするなど)を待つ・待たないをコマンドで制御できるようにすると良い
- メッセージ高速送り、イベント演出の早回し(フェードインやキャラスライドインの速度を高速にする)を早めに実装しておくとデバッグが楽になる
- スクリプトのコンパイルエラー時には、エラーが発生した行の前後のスクリプトをまとめて出力すると、どこでどのような問題が起きたかわかりやすい
例えば私の自作スクリプトではエラーがあった場合に以下のような出力をしています。
Exception: Fatal :Illigal grammar define symbol :TokenType 'CRLF' Token 'a'
1: if($1 == 0) {
2: "こんにちは/" a
> 3: }
このように、エラーが発生した前後の行を表示することで、エラーをできるだけスムーズに発見・修正できる仕組みを用意しています。
■4. RPGでのイベントの扱い
以下はRPGのイベントを作ったことがない、というプログラマーのために参考として作成した資料です。
RPGの場合は、マップ上にイベント発生情報を配置します。そのイベントのコリジョンに衝突することでイベントが発生する仕組みとなります。このあたりはRPGツクールでも同じ仕組みなので、RPGツクールを実際に使ってみると理解がしやすいと思います。
そしてイベント発生テーブルは以下のような情報があると良いです。
- イベントID: 発生するイベントID。もしくはイベント名
- イベントコリジョン: コリジョンの座標。サイズ
- 発生条件:
- 開始フラグ:イベントを発生させるためのフラグ。このフラグがONになると発生
- 終了フラグ:イベントを発生させなくするためのフラグ
- 発生トリガー:自動発生。Aボタンで発生など
4.1. RPGでよくあるイベントコマンド
RPGで優先して実装が必要なイベントコマンドは以下のものになると思います。
- メッセージ、テキスト表示
- メッセージウィンドウ非表示:メッセージを表示するたびにウィンドウを表示するとちらつきが起こるので、基本的に表示したら出したままにしておく必要があり、消去命令が必要となる
- 選択肢
- イベントフラグのON/OFF
- イベント変数の値の設定、取得、加算
- BGM/SE再生
- モデルの表示と消去 (3Dゲームの場合)
- モデルのモーション再生 (3Dゲームの場合)
- バストアップの2D画像表示、消去
- 背景2D画像の表示、消去
- 画面を揺らす
- フェードイン、フェードアウト
(ノベルゲームなどでも必須といえるコマンドかもしれない…)
以上、アドベンチャーゲームの作り方でした。