GoogleAdsence

弾幕シューティングゲーム制作(番外編) 会話シーンの作り方

2020年1月9日

どんなジャンルのゲームでも、イベントで会話するシーンがありますよね?
今回はその機能を実装する方法を解説します。
簡易的なノベルゲームを作れる仕組みはすべて実装しました。

ここではテキスト表示のプログラムを扱いますが、ノベルゲーム(文章)を作成することに集中をせず、プログラムの仕組みを理解することに集中してください。ここでの目標は自分のゲームに、テキストデモや会話シーンを組み込んでいくことです。

ノベルゲームで実装する機能

  • 文字をエンターキーを押すまで進める
  • 次のページに行く
  • 背景の画像の変更
  • 文字色の変更
  • キャラ画像の読み込みと描画と消去(LoadGraph対応)
  • キャラ画像の読み込みと描画と消去(LoadDivGraph対応)
  • テキスト用背景の描画
  • テキストスピードの調整
  • テキストサイズの変更
  • フォントカラーの変更
  • フラグ管理により会話の分岐
  • 選択肢による会話の分岐
  • #の後にコマンド名を書くことで以上の機能を使える

以上のノベルゲームを制作するのに必要な最低限の機能を実装しました。
サウンド再生機能も実装することができますが、今回使うシューティングゲームでは、コマンドパターンで実装済みなので追加しませんでした。

スクリプトの読み込み

スクリプトを扱うに当たって、先にファイルからバッファにすべてデータを読み込む必要があります。
それを行っているのが以下のReadScript関数です。

bool ReadScript(string file_name)
{
	int fh = DX.FileRead_open(file_name);
	if (fh == 0)
	{
		return false;
	}

	while (DX.FileRead_eof(fh) == 0)
	{
		// 1行読み込む
		StringBuilder sb = new StringBuilder(BUFF_SIZE);
		DX.FileRead_gets(sb, BUFF_SIZE, fh);
		if (string.IsNullOrWhiteSpace(sb.ToString())) continue;
		// 先頭文字が「//」ならスキップ
		if (sb.ToString().Substring(0, 2) == "//") continue;
		ScriptBuff.Add(sb.ToString());
	}

	DX.FileRead_close(fh);

	return true;
}

この時に注意しないといけないのが、FileRead_openでファイルを開く際にUnicode形式(UTF-16LE形式)でなければ文字化けしてしまうことです。もしなってしまったら、メモ帳で開いて、 UTF-16LE 形式で保存しなおしてから 、読み込みましょう。
処理自体は単純で、List<string>型のScriptBuffに1行ずつ入れていくというものです。

スクリプトを制御するメインの処理

そして、読み込んだスクリプトのコマンドを解釈して、制御するメインの処理を行う関数がMainEngneです。ここで大事なポイントは、#が付いているコマンド処理なら、CommandFuncを読んで、コマンドを実行し、そうでなければ、TextFuncでテキスト表示部の計算処理をするということです。

public bool MainEngne()
{
	if (EndFlag) return false;
	if (StopFlag) return true;

	// エンター入力待ち、ページ切り替えなら
	if (EnterFlag || PageChange) return true;
	//選択肢決定待ち
	if (SelectFlag) return true;
	if (SkipFlag)
	{
		SkipFlag = false;
		while (true)
		{
			if (ScriptBuff[ReadY][0] == '#')
			{
				string res = CommandFunc();
				if (res == "true")
				{
					ReadY++;
				}
				else
				{
					MessageBox.Show($"{res}は定義されてないコマンドです");
					ScriptBuff.RemoveAt(ReadY);
				}
			}
			else
			{
				//テキスト表示部
				TextFunc();
			}

			if (ScriptBuff.Count <= ReadY)
			{
				break;
			}
			else if (ScriptBuff[ReadY] == "#page")
			{
				break;
			}
			else if (ScriptBuff[ReadY] == "#enter")
			{
				break;
			}
		}
	}
	else
	{
		if (++Cnt < TextSpeed)
		{
			return true;
		}
		Cnt = 0;
		if (ScriptBuff[ReadY][0] == '#')
		{
			string res = CommandFunc();
			if (res == "true")
			{
				ReadY++;
			}
			else
			{
				MessageBox.Show($"{res}は定義されてないコマンドです");
				ScriptBuff.RemoveAt(ReadY); // 定義されてないコマンドを消す
			}
		}
		else
		{
			//テキスト表示部
			TextFunc();
		}
	}
	return true;
}

次に一番大事な部分であるコマンドを解釈して、実行する部分のCommandFuncです。
一番最初にReadScriptで読み込んだScriptBuffの現在のReadY行目のコマンドをSplit関数で,と空白で区切ってstring型のparamという配列を作ります。そして、Replaceを使って#を取り除いてます。
param[0]にコマンドが入っていてparam[1]以降にパラメーターが入っていて、それぞれのコマンドに対応した処理をします。

string CommandFunc()
{
	System.StringSplitOptions option = System.StringSplitOptions.RemoveEmptyEntries;
	string[] param = ScriptBuff[ReadY].Split(new char[] { ',', ' ' }, option);
	param[0] = param[0].Replace("#", "");

	COneBmpTable bmp_table_temp = new COneBmpTable();
	CPluralsBmpTable plurals_bmp_table = new CPluralsBmpTable();
	CAnimationBmpTable anim_table_temp = new CAnimationBmpTable();
	CSoundDataTable sound_data_table = new CSoundDataTable();
	
	// 次のページへ行く処理
	if (param[0] == "page")
	{
		PageChange = true;
	}
	// エンター待ち
	else if (param[0] == "enter")
	{
		EnterFlag = true;
	}
	// 背景画像
	else if (param[0] == "background")
	{
		for (int i = 0; i < OneTexTable.Count; i++)
		{
			if (param[1] == OneTexTable[i].Name)
			{
				BackBmp = OneTexTable[i].GraphHandle;
				break;
			}
		}
	}
	// キャラ画像読み込み
	else if (param[0] == "loadgraph")
	{
		// 識別用の名前
		bmp_table_temp.Name = param[1];
		// 画像の読み込み
		bmp_table_temp.GraphHandle = DX.LoadGraph(param[2]);
		OneTexTable.Add(bmp_table_temp);
	}
	// キャラ画像分割読み込み
	else if (param[0] == "loaddivgraph")
	{
		// 識別用の名前
		plurals_bmp_table.Name = param[1];
		// 画像の分割数
		plurals_bmp_table.AllNum = int.Parse(param[3]);
		// すべての画像数だけ配列を用意
		plurals_bmp_table.GraphHandles = new int[plurals_bmp_table.AllNum];
		// 画像の読み込み
		if (DX.LoadDivGraph(
			param[2], plurals_bmp_table.AllNum,
			int.Parse(param[4]),
			int.Parse(param[5]),
			int.Parse(param[6]),
			int.Parse(param[7]),
			plurals_bmp_table.GraphHandles) == -1) MessageBox.Show("画像ファイル読み込み失敗");

		PluralsTexTable.Add(plurals_bmp_table);
	}
	// アニメーション用キャラ画像読み込み
	else if (param[0] == "loadanimgraph")
	{
		// AnimTexTableにparam[1]と同じ名前があったらそこに追加
		// なかったら新しくAddする
		bool same = false;
		for (int i = 0; i < AnimTexTable.Count; i++)
		{
			if (AnimTexTable[i].Name == param[1])
			{

				int t = int.Parse(param[2]);
				AnimTexTable[i].GraphHandles[t] = DX.LoadGraph(param[3]);
				same = true;
				break;
			}
		}
		if (!same)
		{
			// 識別用の名前
			anim_table_temp.Name = param[1];
			// キャラアニメ番号
			int t = int.Parse(param[2]);
			// 画像の読み込み
			anim_table_temp.GraphHandles[t] = DX.LoadGraph(param[3]);
			AnimTexTable.Add(anim_table_temp);
		}
	}
	else if (param[0] == "loadsoundmem")
	{
		// 識別用の名前
		sound_data_table.Name = param[1];
		// サウンドデータの読み込み
		sound_data_table.SoundHandle = DX.LoadSoundMem(param[2]);
		// プレイタイプ
		sound_data_table.PlayType = int.Parse(param[3]);

		SoundDataTable.Add(sound_data_table);
	}
	else if (param[0] == "playsoundmem")
	{
		for (int i = 0; i < SoundDataTable.Count; i++)
		{
			if (param[1] == SoundDataTable[i].Name)
			{
				switch (SoundDataTable[i].PlayType)
				{
					case 0:
						DX.PlaySoundMem(SoundDataTable[i].SoundHandle, DX.DX_PLAYTYPE_NORMAL);
						break;
					case 1:
						DX.PlaySoundMem(SoundDataTable[i].SoundHandle, DX.DX_PLAYTYPE_BACK);
						break;
					case 2:
						DX.PlaySoundMem(SoundDataTable[i].SoundHandle, DX.DX_PLAYTYPE_LOOP);
						break;
					default:
						DX.PlaySoundMem(SoundDataTable[i].SoundHandle, DX.DX_PLAYTYPE_BACK);
						break;
				}
			}
		}
	}
	// キャラをセットする
	else if (param[0] == "chardisp")
	{
		CharaLoader();
	}
	// キャラをセットする
	else if (param[0] == "divchardisp")
	{
		DivCharaLoader();
	}
	// アニメ用キャラをセット
	else if (param[0] == "animchardisp")
	{
		AnimCharaLoader();
	}
	// キャラクリア
	else if (param[0] == "charclear")
	{
		CharaClear();
	}
	// キャラクリア
	else if (param[0] == "divcharclear")
	{
		DivCharaClear();
	}
	else if (param[0] == "animcharclear")
	{
		AnimCharaClear();
	}
	// テキスト用背景
	else if (param[0] == "drawbox")
	{
		DrawBoxFlag = !DrawBoxFlag;
	}
	// テキストスピード
	else if (param[0] == "textspeed")
	{
		TextSpeed = int.Parse(param[1]);
	}
	// テキストサイズ
	else if (param[0] == "textsize")
	{
		TextSize = int.Parse(param[1]);
	}
	// フォントカラー
	else if (param[0] == "setcolor")
	{
		FontColBuf = DX.GetColor(int.Parse(param[1]), int.Parse(param[2]), int.Parse(param[3]));
	}
	// キャラと文章とカーソルを初期化
	else if (param[0] == "sceneclear")
	{
		SceneClear(1);
	}
	// キャラと文章のみ初期化
	else if (param[0] == "strclear")
	{
		SceneClear(0);
	}
	// 選択文の実行
	else if (param[0] == "select")
	{
		SetCommando();
	}
	//ジャンプポインタまで移動
	else if (param[0] == "jump")
	{
		MoveJumpPoint(int.Parse(param[1]));
		PageChange = true;
	}
	// スクリプトジャンプ用ラベル
	// 通常通過時はスルー
	else if (param[0] == "label")
	{

	}
	// フラグオン
	else if (param[0] == "flagon")
	{
		CFlag flag = new CFlag();
		flag.Flag = true;
		flag.FlagName = param[1];
		Flag.Add(flag);
	}
	// フラグオフ
	else if (param[0] == "flagoff")
	{
		CFlag flag = new CFlag();
		flag.Flag = false;
		flag.FlagName = param[1];
		Flag.Add(flag);
	}
	// フラグチェック
	else if (param[0] == "flagcheck")
	{
		for(int i = 0; i < Flag.Count; ++i)
		{
			if(param[1] == Flag[i].FlagName)
			{
				if (Flag[i].Flag)
				{
					MoveJumpPoint(int.Parse(param[2]));
					PageChange = true;
				}
				else
				{
					MoveJumpPoint(int.Parse(param[3]));
					PageChange = true;
				}
			}
		}
	}
	else if (param[0] == "end")
	{
		EndFlag = true;
	}
	else // 定義されてないコマンドの場合param[0]を返す
	{
		return param[0];
	}
	return "true";
}

描画

次は描画する部分を見てみましょう。SetDrawBlendModeでアルファブレンドを設定し、DrawStringで文字を書き、DrawBoxで文字の背景を描画し、画像をDrawGraphで描きます。

public void TextDisp()
{
	TextDisp1();
	TextDisp2();
}

void TextDisp1()
{
	BackDisp();
	CharaDisp();
	DivCharaDisp();
	AnimCharaDisp();
}

void TextDisp2()
{
	if (DrawBoxFlag)
	{
		// 背景を暗く
		DX.SetDrawBlendMode(DX.DX_BLENDMODE_ALPHA, 120);
		// バックを暗くする
		DX.DrawBox(BOX_START_X, BOX_START_Y, 640, 480, DX.GetColor(0, 0, 0), 1);
		// 背景を明るく戻す
		DX.SetDrawBlendMode(DX.DX_BLENDMODE_NOBLEND, 0);
	}

	SelectDisp();
	for (int i = 0; i < DispMessage.Count; i++)
	{
		DX.DrawString(STR_START_X, STR_START_Y + i * 15, DispMessage[i], FontColor[i]);
	}
}

void BackDisp()
{
	if (BackBmp != -1)
	{
		DX.DrawGraph(0, 0, BackBmp, 1);
	}
}

//------------------------------
//選択肢表示
void SelectDisp()
{
	int temp_y = 0;
	//フラグが立っているなら
	if (SelectFlag)
	{
		//文字描画
		for (int i = 0; i < SelectTable.Count; ++i)
		{
			for(int j = 0; j < SelectTable[i].SelectMessage.Count; ++j)
			{					
				if (string.IsNullOrWhiteSpace(SelectTable[i].SelectMessage[j]))
				{
					temp_y += j - 1;
					break;
				}
				DX.DrawString(SELECT_STR_X, SELECT_STR_Y + ((i + j + temp_y) * SELECT_STR_DIST),
					SelectTable[i].SelectMessage[j],
					SelectPoint == i ? DX.GetColor(255, 255, 255) : DX.GetColor(0, 0, 255));
			}
		}
	}
}

全文載せたものをこちらに掲載しときます。

読み込むスクリプトの部分

次に実際に処理を記述するスクリプトを見てみましょう。

script1.txt

#setcolor,255,255,255
#loadgraph,Sky,advdata\sky.bmp
#loadgraph,Sun,advdata\sun.bmp
#loadgraph,Boy,advdata\boy.bmp
#loadgraph,Girl1,advdata\girl.bmp
#loadgraph,Girl2,advdata\girl2.bmp
#loaddivgraph,Crea,advdata\crea.png,8,4,2,96,96
#label,2
#drawbox
#background,Sky
#chardisp,Boy,200,100
#divchardisp,Crea,100,100,1
あいうえお
#enter
#strclear
#divcharclear,Crea,100,100
#chardisp,Girl1,0,100
かきくけこ。
#enter
#strclear
#chardisp,Girl2,300,100
さしすせそ
#enter
テストです。
#chardisp,Girl1,0,100
どうかな?
#enter
背景変更してみます。
#label,5
#background,Sun
#charclear,Boy,200,100
#charclear,Girl1,0,100
#chardisp,Girl2,300,100
キャラクタも変えました。
選択肢のテストをしてみます。
#enter
あなたは大人ですか?
#select,3
#ans,1
はい。大人です。;
#ans,3
いいえ。子供です。;
#ans,4
はい。ですがまだまだ子供です。;
#label,1
そうですか。
お酒もタバコもできますね。
次のステージ行きます。
#sceneclear
#jump,2
#label,3
そうですか。
子供のうちにできることをたくさんしておきましょう。
最初に戻りますね。
#sceneclear
#jump,2
#label,4
よくわかりません。
早く大人になってくださいね。
もう一度選択肢からやり直します。
#jump,5

スクリプトのコマンドの説明

コマンド名効果   param[0]param[1]param[2]param[3]param[4]paoam[5]param[6]
#setcolor文字色を設定する赤色緑色青色
#loadgraph画像を読み込む識別用の名前画像のパス
#loaddivgraph画像を分割して読み込む識別用の名前画像のパス画像のすべての枚数画像の横の枚数画像の縦の枚数画像の横のサイズ画像の縦のサイズ
#labellabelを付けたところにジャンプできる識別用番号
#drawbox四角を描画
#background背景を描画 #loadgraph でつけた識別用の名前と一致した画像を描画
#chardispキャラを描画 #loadgraph でつけた識別用の名前と一致した画像を描画 X座標Y座標
#divchardisp分割読み込みしたキャラを描画 # loaddivgraph でつけた識別用の名前と一致した画像を描画 X座標Y座標
#enterエンターキー待ち
#strclearすべての文字を消す
#charclear描画したキャラを消す #chardisp で描画したキャラを消すX座標Y座標
#divcharclear分割して描画したキャラを消す #divchardisp で描画したキャラを消すX座標Y座標
#select選択肢を表示選択肢の数
#ans選択肢の答え答えの後にこの番号と同じlabelに飛ぶ
#sceneclearシーンをクリアする
#jump同じパラメーターを持つラベルに飛ぶこの番号と同じlabelに飛ぶ

実行結果

今回のプロジェクトをダウンロードする。 これで簡単なノベルのゲームを作成することができるようになりました。次回はシューティングゲームの会話シーンを実際に組み込んでみる予定です。