vol.7 ローグライクゲームの実装 エリアの分割

目次

はじめに

vol.1~vol.6までの内容で、基本的なRPGの概形は構成できます。もちろんまだすごく目の粗い説明しかしてませんので大それたゲームには至りませんが、依然と比べればゲームプログラムに関する知識が随分と備わったと思います。(ちゃんとvol.6までの内容を理解していればの話。)
今回からはRPGの一部である「ローグライクゲーム」を実装していきたいと思います。
といってもローグライクゲームという名称はあまり耳にしないと思うので、まずはこのローグライクゲームについて説明を入れていきます。(知ってる人は読み飛ばしてくれて構いませんよー。)

ローグライクゲームとは

ローグライクゲームを一言で表すなら、ダンジョンを攻略するゲームとなります。
通常のRPGでは世界地図を片手に色々な場所に訪れ、それぞれのイベントを通して伝説の武具を手に入れて、ラスボスに挑む...みたいな構成が多いとは思います。今回のこのローグライクゲームは世界を冒険するわけではなく、一つの建物の中に生成されるダンジョンを攻略していき、最終的にお宝を持ち帰ることができればゲームクリアとなります。
ところが、歴史的にはあまりゲームクリアをさせるつもりはなく、どこまでフロアを進め、高いスコアを獲得するかを競うケースが多かったです。それだけ難易度が高かったということですね。
ローグライクゲームの大きな特徴として次のものが挙げられます。
・ランダム生成ダンジョン(いわゆる、不思議のダンジョンと呼ばれるもの)
・ターン制の戦闘システム
・ノンモーダル
・シングルプレイヤーゲーム
・所持金などに応じて生じるスコア
・空腹要素
・クリア後の初期化

順に説明を加えていきます。

ローグライクゲームでのダンジョンは自動生成されたもので、階層を移動するたびに異なったマップが生成されます。ダンジョン自体は何階もあるので、マップデータをいちいち用意していては大変です。
ちなみに、敵キャラもマップ上に自動生成されます。

・ターン制の戦闘システムということで、これは通常のRPGでも多いケースですね。
次に書いてあるノンモーダルというのが大きな特徴となります。
ノンモーダルというのは、移動と戦闘で画面変遷を伴わないということです。わかりやすく言うと、戦闘画面がないということです。通常の移動フィールド上でそのまま戦闘が始まります。これはvol.2で書いたビーム照射がそれにあたります。
ターンはプレイヤーが行動すると経過します。つまり、プレイヤーは動かない限り敵も動かないということです。これは戦闘でも同様のことが言えます。

・シングルプレイヤーゲームということで、これは読んで字のごとくですね。
仲間はおらず、主人公一人でゲームが進行します。

・スコアについてですが、詳しいスコア算出方法を知らないので何とも言えないのですが、スコアは高いほうがいいですよね。ゲーム終了時にスコアが確定します。

空腹要素があるのがローグライクゲームにおける大きな特徴となります。プレイヤーは定期的に食事をとらないと最終的には餓死します。食糧はダンジョン内に落ちていたり、店で購入できたりします。

・初期化ということで、ぎょっとした人もいるのではないでしょうか。ローグライクゲームではダンジョンをクリアすることが第一目標なので、一度ダンジョンをクリアしてしまえば再びダンジョンに入るときはレベルは1からになります。常にダンジョンはレベル1からスタートするということです。
ちなみに所持金は持ち帰ることができ、アイテムもすべて売却し、家の貯金になります
そのあたりの細かい説明はあとにしましょう。

さて、このようなゲーム機構を実装していく次第ですが、今回はゲームの継続性(ダンジョンをクリアしてお金を家にためて、またダンジョンに行く)を意識していきたいので、従来のローグライクゲームのように激ムズにはしません。ちゃんとクリアできるものを作成します。

トルネコの大冒険 不思議のダンジョン

ローグライクゲームの代表作として古くからあるのがトルネコの大冒険です。
トルネコドラクエIVのキャラとして登場していました。その後日譚(その後の物語)ということになります。ストーリーを以下に記します。

レイクナバに住む武器屋トルネコ。彼は武器屋の雇われだったが、一つの夢があった。自分の店を持ちその店を世界一にすること。トルネコは旅に出て、鉄の金庫を探したり、ボンモールへ行ったりと様々な仕事をこなし、ついにエンドールに店を持つ。妻ネネの働きもあり、トルネコの店は大繁盛。そんな折、トルネコは「不思議なダンジョンには誰も見たことも無いようなすごいお宝が眠っているらしい」という噂を耳にする。

トルネコはこれを聞いて不思議のダンジョンに行ってみたいと思うが、店や家族のことを考えると悩んでしまう。妻のネネはそんなトルネコに理解を示し、不思議なダンジョンに行くことを決定する。トルネコと妻のネネ、息子のポポロは海を渡り、山を越え、何年も旅を続け、ついに不思議のダンジョンにたどり着く。

不思議のダンジョンの近くにある新たな村の一本の大樹のそばにトルネコは店を構える。

この国の王様から不思議のダンジョン探索の許可を得ようとするが、王様は危険だと首を縦に振らない。しかしトルネコは引き下がらず、王様は「ちょっと不思議のダンジョン」から宝石箱を取って帰れたら許可を出すと約束する。トルネコは王様の宝石箱を持ち帰り、不思議のダンジョン探索の許可を得る。
(引用:トルネコの大冒険 不思議のダンジョン - Wikipedia


まあこんな流れで不思議のダンジョンに行くことになります。
このゲームのメインは、ダンジョンを攻略し、ゴールドを貯めて自分の店舗を成長させていくことです。
これらをもとにプログラムを実際に書いていきたいと思います。
プログラムの詳細も大事ですが、どのようにゲームを構成していくかという手順を意識していきたいと思います。

ダンジョンの自動生成実装について

※注意
ダンジョンの自動生成が最初にして最大の山です。
おそらく長期化するので今回は途中段階で終了となります。
自分も調べながらやっていますが、何せC言語での資料が少ないので手探りになります。
ご了承くださいな。

ダンジョンの自動生成を実装するために、4つのプロセスを立てます。
①エリアの分割
②ルームの作成
③ドアの設置
④廊下(コリドー、Corridor)の作成

※関数で機能を分けていこうと思うので、フィールド関連の変数はグローバル変数(main関数の外)で宣言してあげると扱いやすくなります。
フィールドの配列を操作したいときにいちいち引数に配列のポインタを渡すのはめんどくさいので、引数に渡さなくてもいつどこからでも配列を操作できるようにすればだいぶ楽になります。

エリアの分割

エリアの分割をする理由は、ルームを作成するにあたってルームが重なってしまうとよくないので、エリアに分割して各エリア内にルームを作成できるようにしたいからです。
簡略化のために分割は半分ずつとしましょう。
エリアを分割することによって複数個のエリアが作成されますが、エリアを区別するためにそれらのエリアの座標(左上の点を見る)と横幅・高さがわかれば良いので、それを関数spritAreaで実装しましょう。

spritArea :
簡略化のために分割は半分ずつとします。
縦・横のどちらで切るかは乱数(0 or 1)で決定して、分割された上・左のエリアを「旧エリア」、下・右のエリアを「新エリア」と呼ぶことにします。
旧エリアと新エリアに分割したら、その二つを更に分割する処理を行いたいので、これには再帰処理が有効です。
これにあたって再帰処理には終了条件が必要なので、エリアの最小の大きさを定義しておきます。

分割しても目に見えなければ正常に動作しているか分からないので、分割しているかどうかを確認するデバッグ用の関数displayAreaも作成します。
displayArea :
spritAreaで取得したエリアの情報をもとに分割された順番にその順番の数字をエリア領域に書き込みます。具体的にはこのような感じになればOKです。
最初のエリアはすべて0のものとして、そこから1, 2,...と分割されたエリアに対して番号を振っていきます。

例:縦16、横16、最小のエリア幅4で定義されたエリア分割

0 0 0 0 6 6 6 6 4 4 4 4 5 5 5 5
0 0 0 0 6 6 6 6 4 4 4 4 5 5 5 5
0 0 0 0 6 6 6 6 4 4 4 4 5 5 5 5
0 0 0 0 6 6 6 6 4 4 4 4 5 5 5 5
8 8 8 8 7 7 7 7 4 4 4 4 5 5 5 5
8 8 8 8 7 7 7 7 4 4 4 4 5 5 5 5
8 8 8 8 7 7 7 7 4 4 4 4 5 5 5 5
8 8 8 8 7 7 7 7 4 4 4 4 5 5 5 5
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1  
2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3
2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3
2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3
2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3  

※因みにこの時のエリア情報は、
( x, y) w × h
_____________________________
areas[ 0] : ( 0, 0) 4 × 4
areas[ 1] : ( 0, 8) 16 × 4
areas[ 2] : ( 0, 12) 8 × 4
areas[ 3] : ( 8, 12) 8 × 4
areas[ 4] : ( 8, 0) 4 × 8
areas[ 5] : (12, 0) 4 × 8
areas[ 6] : ( 4, 0) 4 × 4
areas[ 7] : ( 4, 4) 4 × 4
areas[ 8] : ( 0, 4) 4 × 4

分割の過程を絵で表現してみたので見て下さいな。
f:id:sion2000114:20190906110503p:plain

以下、ソースコード

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<time.h>
#include<conio.h>

#define FIELD_HEIGHT 64
#define FIELD_WIDTH 64
#define AREA_MAX 64 //エリアの最大個数
#define AREA_SIZE_MIN 16 //エリア

//エリアの座標・横幅・高さをひとまとめに管理するために構造体AREA_tで定義
typedef struct{
	int x;
	int y;
	int w; //width
	int h; //height
}AREA_t;

AREA_t areas[AREA_MAX]; //各エリアの座標・横幅・高さの情報をしまっておける
int areaCount; //エリアの個数

//エリアを分割して、エリア毎に座標・横幅・高さを取得する関数
//oldAreaIndex : 分割前のエリア番号
//newAreaIndex : 分割後のエリア番号
void spritArea(int oldAreaIndex)
{
	int newAreaIndex = areaCount;
	//分割をキャンセルする時に戻せるようにする(再帰処理なので、終了条件を満たしたときに直前の分割をキャンセルしたい)
	int w = areas[oldAreaIndex].w; //保存用
	int h = areas[oldAreaIndex].h; //保存用

	//縦切り
	if(rand()%2==0){
		areas[oldAreaIndex].w /= 2; //半分
		areas[newAreaIndex].x = areas[oldAreaIndex].x + areas[oldAreaIndex].w; //新しいエリアのx座標
		areas[newAreaIndex].y = areas[oldAreaIndex].y; //新しいエリアのy座標
		areas[newAreaIndex].w = w - areas[oldAreaIndex].w; //新しいエリアの横幅
		areas[newAreaIndex].h = areas[oldAreaIndex].h; //新しいエリアの高さ
	}
	//横切り
	else {
		areas[oldAreaIndex].h /= 2; //半分
		areas[newAreaIndex].x = areas[oldAreaIndex].x; //新しいエリアのx座標
		areas[newAreaIndex].y = areas[oldAreaIndex].y + areas[oldAreaIndex].h; //新しいエリアのy座標
		areas[newAreaIndex].w = areas[oldAreaIndex].w; //新しいエリアの横幅
		areas[newAreaIndex].h = h - areas[oldAreaIndex].h; //新しいエリアの高さ
	}
	//分割後の二つのエリアがエリアの条件(AREA_SIZE_MINの範囲内かどうか)を満たしているか
	if ((areas[oldAreaIndex].w < AREA_SIZE_MIN) || (areas[oldAreaIndex].h < AREA_SIZE_MIN)
		|| (areas[newAreaIndex].w < AREA_SIZE_MIN) || (areas[newAreaIndex].w < AREA_SIZE_MIN)) {
		areas[oldAreaIndex].w = w;
		areas[oldAreaIndex].h = h;
		return; //void型関数を終了させたいときは、単にreturnと書けばよい
	}

	areaCount++;

	spritArea(newAreaIndex); //新エリアの分割
	spritArea(oldAreaIndex); //旧エリアの分割
	
}

//デバッグ用
void displayArea() {
	int x, y, i;
	int field_num[FIELD_HEIGHT][FIELD_WIDTH] = { 0 };

	for (i = 0; i < areaCount; i++) //全ての分割されたエリアに対して
		for (y = areas[i].y; y < areas[i].y + areas[i].h; y++) //エリアの上から下まで
			for (x = areas[i].x; x < areas[i].x + areas[i].w; x++) //エリアの左から右まで
				field_num[y][x] = i; //iの番号でエリアを区別してみる

	system("cls");
	for (y = 0; y < FIELD_HEIGHT; y++) {
		for (x = 0; x < FIELD_WIDTH; x++) {
			printf("%2d", field_num[y][x]);
		}
		printf("\n");
	}
}

int main(void)
{
	int y;
	srand((unsigned int)time(NULL));

	//最初のエリアの情報
	//つまり、エリアの初期化
	areaCount = 0;
	areas[0].x = 0;
	areas[0].y = 0;
	areas[0].w = FIELD_WIDTH;
	areas[0].h = FIELD_HEIGHT;
	areaCount++;

	spritArea(0);
	displayArea();

	//エリアの情報確認用
	for (y = 0; y < areaCount; y++) {
		printf("areas[%2d] : (%2d, %2d) %2d × %2d\n",y, areas[y].x, areas[y].y, areas[y].w, areas[y].h);
	}

	_getch();
	return 0;
}

おわりに

今回は、ローグライクゲームの説明をして実際にエリアの分割まで行いました。
そこそこ難しくなってきているので、1つ1つ確実に把握していくことが大切ですね。
結局ゲームを作る時に自分の中で思い描いた実装のプロセスをいかにプログラムに書けるかが大切になってくるので、プログラム内容を理解するというよりは「こういう書き方もあるんだな」という把握の方が大切です。
今回のプログラム内に、「デバッグ」と書かれたものがありましたが、これがとても大切になってきます。実際に作りたいプログラムを作る前に一つ一つの動作がちゃんと正常に行われるかを確認することデバッグと言います。エリア分割を実装する最終目標は、分割されたエリアごとの座標と縦横の長さを取得することです。別に画像のように数字で出力する必要はないのですが、出力してちゃんと目で見えるようにしておけば作ってる側としてもわかりやすいので、ミスが減ります。(というか、今回はこれを使ってちゃんと確認しながら作らないとほぼ無理。)
一気にプログラムを書こうとせず、パーツごとにしっかり確認しながら進めるようにしましょう。
次回は、作成したエリアの中にルームを作成していきたいと思います。
お疲れ様でした。