Unityで実装するローグライクなマップ自動生成
今までマップの自動生成についていくつか記事を紹介してきました。
今回はローグライクゲームでよく使われそうなマップ生成方法に焦点を当ててみたいと思います。
ローグライクのマップはランダムに生成された部屋と通路で構成されていて、毎回異なるレイアウトのマップが自動的に作られます。
今回はその基本的な作り方をやってみたいと思います。
マップ生成の流れ
では大まかにどのようにしてマップを作るか整理してみます。
まずマップ全体の矩形があるとします。
次にこのマップを適当な場所で線を引き親エリアと子エリアに分割します。
さらに子エリアを分割して孫エリアを作ります。
エリアを分割していき、部屋が作れる最小の大きさになったら次は各エリアに部屋を作っていきます。
続いて部屋と部屋を繋いでいきます。
親区画は子区画と、子区画は孫区画と分割したラインに接していますので、それぞれの区画から分割ラインまで道を作ります。
最後に区画から伸びた道を端として分割ラインを道にすれば部屋同士が繋がります。
すべての部屋を接続したら完成となります。
マップ生成の理屈はこんな感じです。なんとなくイメージできたでしょうか?
それではこれを作っていきましょう。
マップ生成の準備
プロジェクトの準備
それではUnityを起動して必要なものを準備していきましょう。
壁のプレファブと道のプレファブ、あと境界線がわかりやすくするため壁プレファブの色を緑にしたものを用意します。
少し見づらいですがこんな感じです。
続いてC#スクリプトを2つ用意します。役割は以下の通りです。
・Map マップデータを元に画面上にマップを表示させる
そしてHierarchyに空のゲームオブジェクトMapを作りMapスクリプトをアタッチしておきましょう。
こんな状態になりました。
次はマップデータを作るために必要なクラスや列挙型、メソッドなどを準備していきます。
矩形を管理するRectクラス
ではまずエリアを管理するクラスを作りましょう。
別ファイルにしても良いのですが、今回はRougeGenerator.csのRougeGeneratorクラスの外に書いておきましょう。
まずは矩形を管理するRectクラスです。コメントに説明を書いておきます。
//区画、部屋、通路などの矩形用のクラス
public class Rect
{
int top = 0; //矩形の上座標
int right = 0; //矩形の右座標
int bottom = 0; //矩形の下座標
int left = 0; //矩形の左座標
public int Top {
get { return top; }
set { top = value; }
}
public int Right
{
get { return right; }
set { right = value; }
}
public int Bottom
{
get { return bottom; }
set { bottom = value; }
}
public int Left
{
get { return left; }
set { left = value; }
}
//区画の横幅(右から左を引く)
public int Width { get => right - left; }
//区画の高さ(下から上をひく)
public int Height { get => bottom - top; }
//矩形の面積
public int Size { get => Width * Height; }
//コンストラクタ
public Rect(int left = 0,int top = 0, int right = 0, int bottom = 0)
{
this.left = left;
this.top = top;
this.right = right;
this.bottom = bottom;
}
//矩形の座標を指定するメソッド
public void SetPoints(int left,int top,int right,int bottom)
{
this.left = left;
this.top = top;
this.right = right;
this.bottom = bottom;
}
}
エリアを管理するAreaクラス
続いてエリアを管理するAreaクラスです。
//区画、部屋、通路などの矩形用のクラス
public class Area
{
private Rect section; //エリアの大枠の矩形
private Rect room; //エリアの部屋の矩形
private Rect road; //エリアの道の矩形
public Rect Section { get => section; set => section = value; }
public Rect Room { get => room; set => room = value; }
public Rect Road { get => road; set => road = value; }
//コンストラクタ
public Area()
{
section = new Rect(); //エリア大枠矩形を作る(初期値は上下左右0)
room = new Rect(); //エリアの部屋矩形を作る(初期値は上下左右0)
road = null; //エリアの道矩形を作る(初期値はnull)
}
//道の矩形を作るメソッド
public void SetRoad(int left, int top, int right, int bottom)
{
road = new Rect(left, top, right, bottom);
}
}
列挙型を2つ作る
次に列挙型を2つ作っておきます。
矩形を分割する向きと、セルのタイプの列挙型です。
public enum DivideDirection
{
Vertical, //垂直(縦)方向
Horizontal //水平(横)方向
}
public enum CellType
{
Path, //空白
Wall, //壁
BorderLine //境界線
}
こんな感じです。
Mapクラス
続いて先にMapを描画するクラスを作ってしまいましょう。
内容はコードにコメントをしておきます。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Map : MonoBehaviour
{
[SerializeField] GameObject groundPrefab, wallPrefab,sectionLinePrefab; //各種プレファブ
public int mapWidth, mapHeight; //マップの縦横サイズ
RougeGenerator mapGenerator; //RougeGenerator型の変数
int[,] map; //マップデータ
float tileSize; //プレファブのサイズ
Vector2 mapCenterPos; //マップのセンター位置
void Start()
{
mapGenerator = new RougeGenerator(mapWidth, mapHeight); //RougeGeneratorインスタンス生成
map = mapGenerator.GenerateMap();//マップ作成
PlaceTiles();//プレファブを並べる処理
}
void PlaceTiles()
{
tileSize = groundPrefab.GetComponent().bounds.size.x; //タイルサイズ取得
mapCenterPos = new Vector2(mapWidth * tileSize / 2, mapHeight * tileSize / 2); //中心座標取得
for(int y = 0;y < mapHeight; y++) //マップデータ読込ループ
{
for(int x = 0; x < mapWidth; x++)
{
int tileType = map[x, y]; //マップの種類取得
Vector2 pos = GetWorldPositionFromTile(x, y);//座標を計算
//マップの種類毎にゲームオブジェクト作成
if(tileType == 0)
{
Instantiate(groundPrefab, pos, Quaternion.Euler(0, 0, 0f));
}
else if(tileType == 2)
{
Instantiate(sectionLinePrefab, pos, Quaternion.Euler(0, 0, 0f));
}
else if (tileType == 1)
{
Instantiate(wallPrefab, pos, Quaternion.Euler(0, 0, 0f));
}
}
}
}
//座標を取得するメソッド
Vector2 GetWorldPositionFromTile(int x,int y)
{
return new Vector2(x * tileSize, (mapHeight- y) * tileSize) - mapCenterPos;
}
}
このような感じです。
以前作った以下の記事に詳しく説明が書いてありますのでここでは詳しく触れません。興味がある方は是非ご覧ください。
必要なプレファブはInspector上からアタッチし、マップの縦横サイズも指定しておきましょう。
ここまでが準備となります。
次からいよいよメインとなるRougeGeneratorクラスに必要なフィールドや定数、メソッドを書いていきます。
マップの初期化とエリア分割用メソッドの準備
フィールドや定数を宣言
ではRougeGeneratorクラスで使うフィールドや定数を宣言していきましょう。
using System.Collections.Generic;
using UnityEngine;
using System.Linq; //後にLinqを使うので記述しておく
public class RougeGenerator
{
const int MIN_ROOM_SIZE = 4; //部屋の最小サイズ
const int MAX_ROOM_SIZE = 8; //部屋の最大サイズ
const int MIN_SPACE_BETWEEN_ROOM_AND_ROAD = 2; //部屋と道との余白
int width, height; //マップ全体の横幅と高さ
int[,] map; //マップデータを格納する配列
List
areaList; //エリアを格納しておくArea型のList
/* 略 */
続いてコンストラクタを作ります。
public class RougeGenerator
{
/* 略 */
public RougeGenerator(int width, int height) //後にMapクラスから引数を受け取ります。
{
this.width = width;
this.height = height;
map = new int[this.width, this.height];
}
/* 略 */
これでmap全体の大きさとそれを保存しておく配列の準備が出来ました。
続いてRougeGeneratorクラス内で必要なメソッドを作っておきます。
メソッドそれぞれの中身に関しては準備した後、順番に作っていきます。
初期化メソッドとエリア分割メソッドの準備
それでは必要なメソッドを作っていきましょう。
それぞれの役割はコメントで記述しておきます。
public class RougeGenerator
{
/* 略 */
//マップを作るメインのメソッド int[,]型のマップデータを返却します。
public int[,] GenerateMap()
{
return map;
}
//最初のエリアを作るメソッド
void InitFirstArea()
{
}
//マップデータをすべて壁で埋めて初期化するメソッド
void InitMap()
{
}
/* ここからエリアを分割して行く処理 */
//エリアを分割するメインのメソッド 引数にどちら向きに分割するかを判断するbool型の値を取ります
void DivideArea(bool horizontalDivide)
{
}
//水平方向にエリアを分割するメソッド 引数で親のAreaを受け取り、子エリアとなるchildAreaを返します。
Area DivideHorizontally(Area area)
{
Area childArea = new Area();
return childArea;
}
//垂直方向にエリアを分割するメソッド 引数で親のAreaを受け取り、子エリアとなるchildAreaを返します。
Area DivideVertially(Area area)
{
Area childArea = new Area();
return childArea;
}
//セクションのサイズをチェックするメソッド 引数にintを受け取り、エリアのセクションの大きさが十分かを判断しbool型を返します。
bool CheckRectSize(int size)
{
return true;
}
//分割ラインを計算するメソッド 引数にintを2つ取り、どこを分割ラインにするかの計算結果をint型で返します。
int CalculateDivideLine(int start,int end)
{
return 0;
}
//境界線をマップデータに書き込むメソッド 確認用なので最終的には不要になる
void DrawBorder(Area area)
{
}
/* 略 */
ここまででエリアを分割するために必要なメソッドを準備しました。
続いて中身を記述していきましょう。
初期化メソッド実装
まずマップ生成時に呼び出され中心となるメソッド、GenerateMapを作っていきましょう。
このメソッドは後々処理を追記していきますが、まずはエリアを分割するために必要なメソッドを呼び出す処理となります。
GenerateMap
public class RougeGenerator
{
/* 略 */
public int[,] GenerateMap()
{
areaList = new List
(); //エリアを格納するListの初期化
InitMap(); //マップ初期化メソッド実行
InitFirstArea(); //最初のエリアを決めるメソッド実行
//エリアを分割するメソッドを実行 引数はランダムで0か1を渡す。0の場合はtrue,1の場合はfalse
DivideArea(Random.Range(0, 2) == 0);
return map; //マップデータを返す
}
/* 略 */
以上のような流れになります。
Listを初期化し、エリア分割に必要なメソッドを順番に呼び出しています。
続いて初期化メソッド2つ,InitMapとInitFirstAreaを実装していきましょう。
InitMap
まず最初にマップデータを壁で埋め尽くします。
public class RougeGenerator
{
/* 略 */
void InitMap()
{
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
map[x, y] = (int)CellType.Wall;
}
}
}
/* 略 */
このメソッドは単純に高さと横幅の二重ループで、マップ全体を壁のデータに埋めて行きます。
続いて最初のエリアを作りましょう。
InitFirstArea
一番元となるエリアを作ります。
Areaクラスを生成し、Sectionの上下左右の角の座標を登録します。
最後にListに追加して終了です。
public class RougeGenerator
{
/* 略 */
void InitFirstArea()
{
//インスタンス生成
Area area = new Area();
//四隅の座標登録(左、上、右、下)の並びで(0,0,マップ全体の横幅-1,マップ全体の縦幅-1)
area.Section.SetPoints(0, 0, width - 1, height - 1);
//リストに追加
areaList.Add(area);
}
/* 略 */
ここまでの処理はシンプルだと思います。
次にエリアをランダムで分割していく処理を実装していきます。
エリア分割処理の実装
エリア分割用メソッドの実装
具体的にエリアを分割する処理を実装していきます。
全体の流れは以下の通りです。
・分割する親エリアを取得する
・決まった方向に分割する
・分割した子エリアを取得する
・親エリアと子エリアのSectionの面積を比較する
・面積の大きい方を次の分割候補にする
・再帰的に分割するメソッドを呼び出す。
では早速やっていきましょう。
エリアを分割するDivideAreaメソッドから作っていきます。
DivideArea
public class RougeGenerator
{
/* 略 */
//GenerateMapメソッドで呼び出されたときのtrueまたはfalseが渡ってくる
void DivideArea(bool horizontalDivide)
{
//Listの末尾からAreaを取り出す。初回はInitFirstAreaで作られたエリア
Area parentArea = areaList.LastOrDefault();
//エリアが無ければreturnして終了
if (parentArea == null) return;
//Listから先程取り出したエリアを削除する
areaList.Remove(parentArea);
//分割方向に応じてparentAreaを分割する 返り値に子エリアを受け取りchildAreaに保存
Area childArea = horizontalDivide ? DivideHorizontally(parentArea) : DivideVertially(parentArea);
//childAreaがnullではない場合の処理
if (childArea != null)
{
//親と子の境界線をマップデータに保存する
DrawBorder(parentArea);
DrawBorder(childArea);
//親と子のエリアのSectionサイズを比較し、大きい方を次の分割エリアにする
if(parentArea.Section.Size > childArea.Section.Size)
{
//親エリアが大きい場合はparentAreaを後にListへ保存 次回はparentAreaが分割される
areaList.Add(childArea);
areaList.Add(parentArea);
}
else
{
//子エリアが大きい場合はchildAreaを後にListへ保存 次回はchildAreaが分割される
areaList.Add(parentArea);
areaList.Add(childArea);
}
//再度分割処理を行う このとき分割方向を逆にする
DivideArea(!horizontalDivide);
}
}
/* 略 */
このように作ってみました。
詳細に関してはコメントに残した通りです。
続いて分割方向によって子エリアを作成するメソッド2つを作っていきます。
DivideHorizontallyとDivideVertially
エリアを分割するメソッドです。
分割方向が違うだけでやっていることは、ほとんど同じです。
分割ラインを決めて子エリアを作成し、親エリアのSectionの再設定をしています。
まずは水平分割の場合です。
DivideHorizontally
public class RougeGenerator
{
/* 略 */
//分割するエリア(親エリア)を引数で受け取る(参照渡し)
Area DivideHorizontally(Area area)
{
//エリアの高さが分割するために十分な高さかどうかチェックする
if (!CheckRectSize(area.Section.Height)) {
//もし高さが不十分な場合は分割せず、areaListに戻す
areaList.Add(area);
//nullを返して処理を終了する。
return null;
};
//エリアのSectionの上下をCalculateDivideLineメソッドに渡し分割位置を決める
int divideLine = CalculateDivideLine(area.Section.Top,area.Section.Bottom);
//子エリアを作成する
Area childArea = new Area();
//子エリアSectionの上下左右座標を登録する
childArea.Section.SetPoints(area.Section.Left, divideLine, area.Section.Right, area.Section.Bottom);
//親エリアのSectionの下座標を分割ラインに設定する
area.Section.Bottom = divideLine;
//子エリアを返却して終了
return childArea;
}
/* 略 */
続いて垂直分割です。
DivideVertially
public class RougeGenerator
{
/* 略 */
Area DivideVertially(Area area)
{
//エリアの横幅が分割するために十分な幅かどうかチェックする
if (!CheckRectSize(area.Section.Width))
{
//もし幅が不十分な場合は分割せず、areaListに戻す
areaList.Add(area);
//nullを返して処理を終了する。
return null;
};
//エリアのSectionの左右をCalculateDivideLineメソッドに渡し分割位置を決める
int divideLine = CalculateDivideLine(area.Section.Left, area.Section.Right);
Area childArea = new Area();
childArea.Section.SetPoints(divideLine, area.Section.Top, area.Section.Right, area.Section.Bottom);
//親エリアのSectionの右座標を分割ラインに設定する
area.Section.Right = divideLine;
return childArea;
}
/* 略 */
このような感じになります。
縦横の違いはありますが、やっていることは同じということがおわかりいただけると思います。
エリア分割するための大きさをチェックします。
至らなかった場合にareaListにareaを戻しているのは、後に部屋を作る際にこのareaListを使うためです。
では次に大きさチェックを行うメソッドを作っていきましょう。
分割できるかどうかを判別
CheckRectSize
このメソッドでは高さ若しくは横幅を受け取り、分割できる大きさかどうかをチェックしboolを返します。
具体的にはこのようになりました。
public class RougeGenerator
{
/* 略 */
//Height,Widthのいずれかの値からエリアを分割できるかをチェックする
bool CheckRectSize(int size)
{
//分割に必要となる最低限の大きさ計算
//最小の部屋サイズ+区画のマージンを*2(2分割するため)+1(道幅)
int min = (MIN_ROOM_SIZE + MIN_SPACE_BETWEEN_ROOM_AND_ROAD) * 2 + 1;
//渡ってきたsizeと最低限の大きさを比較してboolを返却
return size >= min;
}
/* 略 */
このようにしてエリアが分割可能かどうかをチェックしました。
続いて分割するラインを決めます。
分割ラインの計算
エリアが分割可能と判断されたら、次は分割ラインを決めましょう。
分割範囲を計算するため、開始位置と終了位置を受け取り計算していきます。
これにより縦横どちらの分割でも対応できるようにしてあります。
CalculateDivideLine
public class RougeGenerator
{
/* 略 */
//startとendを受け取る
int CalculateDivideLine(int start,int end)
{
//分割する最小値を計算
//startに部屋の最小サイズと部屋と通路までの余白を足して算出
int min = start + (MIN_ROOM_SIZE + MIN_SPACE_BETWEEN_ROOM_AND_ROAD);
//分割する最大値を計算
//endから部屋の最小サイズと部屋と通路までの余白の合計を引いて算出
int max = end - (MIN_ROOM_SIZE + MIN_SPACE_BETWEEN_ROOM_AND_ROAD);
//最小値から最大値の間をランダムで取得しintを返す
return Random.Range(min, max + 1);
}
/* 略 */
このようになりました。
具体的な処理の内容はコメントに書いてあるとおりです。
Random.Rangeで第二引数のmaxに1を足していますが、これをすることでmaxの値を含むランダムの数値が取得出来ます。
ではエリア分割の仕上げです。
確認のため境界線をマップデータに書き込んでいきましょう。
境界線を作る
これは単純にエリア毎にループする感じです。
具体的には以下のようになります。
DrawBorder
public class RougeGenerator
{
/* 略 */
void DrawBorder(Area area)
{
//エリアのセクションのTopからBottomまでループ
for (int y = area.Section.Top; y <= area.Section.Bottom; y++)
{
//エリアのセクションのLeftからRightまでループ
for (int x = area.Section.Left; x <= area.Section.Right; x++)
{
//xとyがセクションの上下左右と同じならば境界線を書き込む
if (x == area.Section.Left || x == area.Section.Right || y == area.Section.Top || y == area.Section.Bottom)
{
map[x, y] = (int)CellType.BorderLine;
}
}
}
/* 略 */
シンプルな縦横の二重ループです。
これで区画が正常に描画されるかチェックしてみましょう。緑のラインが各エリアの境界線です。
このような形でエリアを分割することが出来ました。
次はそのエリアに部屋を作っていく処理を実装していきます。
部屋の作成
では引き続きやっていきます。
部屋の作成もエリア分割の時と同じように先に必要となるメソッドを準備しておきましょう。
部屋作成処理に必要なメソッド準備
部屋を作成するメソッドは大きく3つに分けました。具体的には以下の通りです。
public class RougeGenerator
{
/* 略 */
//すべてのエリアに対して部屋を作るメソッド
void CreateRoom()
{
}
//エリア内に部屋を作るメソッド
void CreateRoomInArea(Area area)
{
}
//エリア内の部屋のポジションを設定するメソッド
void AdjustRoomSidePosition(ref int minPosition,ref int maxPosition)
{
}
/* 略 */
では一つずつ作っていきましょう。
すべてのエリアに部屋を作る
先程のエリア分割ですべてのエリアに部屋を作る広さを確保していますのでここでは単純にareaListのループ処理のみです。
CreateRoom
public class RougeGenerator
{
/* 略 */
void CreateRoom()
{
foreach (Area area in areaList)
{
CreateRoomInArea(area);
}
}
/* 略 */
これだけです。最終的にこのメソッドをGenerateMapメソッドから呼び出すことになります。
次に各エリアに部屋をランダムの大きさで作っていきましょう。
部屋をランダムの大きさで作る
CreateRoomInAreaメソッドで部屋を作っていきます。
ここでは左辺と右辺、上辺と下辺を決め、ランダムで位置を調整し、最後にエリアのRoomに作られた部屋の矩形座標をセットします。
具体的には以下のようになります。
CreateRoomInArea
public class RougeGenerator
{
/* 略 */
void CreateRoomInArea(Area area)
{
// 部屋の基本的な左辺と右辺の位置を計算
//エリアのSectionの左辺に道と部屋の余白を足す
int roomLeft = area.Section.Left + MIN_SPACE_BETWEEN_ROOM_AND_ROAD;
//エリアのSectionの右辺から道と部屋の余白を引く
int roomRight = area.Section.Right - MIN_SPACE_BETWEEN_ROOM_AND_ROAD + 1;
// 部屋の左辺と右辺の位置をランダムに調整 参照渡しにしたいのでrefをつける
AdjustRoomSidePosition(ref roomLeft,ref roomRight);
// 同様に上辺と下辺の位置を計算
int roomTop = area.Section.Top + MIN_SPACE_BETWEEN_ROOM_AND_ROAD;
int roomBottom = area.Section.Bottom - MIN_SPACE_BETWEEN_ROOM_AND_ROAD + 1;
//部屋の上辺と下辺の位置をランダムに調整 参照渡しにしたいのでrefをつける
AdjustRoomSidePosition(ref roomTop,ref roomBottom);
//areaのRoomに部屋座標をセット
area.Room.SetPoints(roomLeft, roomTop, roomRight, roomBottom);
// 部屋をマップに配置 部屋のTopからBottomまでをループ
for (int y = roomTop; y < roomBottom; y++)
{
//部屋のLeftからRightまでをループ
for (int x = roomLeft; x < roomRight; x++)
{
map[x, y] = (int)CellType.Path;
}
}
}
/* 略 */
このようになりました。
このメソッド単体では部屋の大きさは決まっていません。
基本となる部屋の上下左右の座標をAdjustRoomSidePositionに渡して部屋の大きさをランダムにしていきます。
それでは最後に部屋の大きさを決める肝となるAdjustRoomSidePositionメソッドを作っていきましょう。
部屋の大きさとポジションを決める
では説明の前に先にコードを書いてみます。
AdjustRoomSidePosition
public class RougeGenerator
{
/* 略 */
void AdjustRoomSidePosition(ref int minPosition,ref int maxPosition)
{
if(minPosition + MIN_ROOM_SIZE < maxPosition)
{
// 与えられた範囲内でランダムな部屋の辺の位置を計算
//ランダムの最大値取得
int maxRange = Mathf.Min(minPosition + MAX_ROOM_SIZE, maxPosition);
//ランダムの最小値取得
int minRange = minPosition + MIN_ROOM_SIZE;
int position = Random.Range(minRange, maxRange + 1);//ポジション計算
int diff = Random.Range(0, maxPosition - position);//すらす値を計算
//水平(垂直)に座標をずらす
minPosition += diff;
maxPosition = position + diff;
}
}
/* 略 */
このようになりました。
やっている事は以下の通りです
・minPositionに部屋の最小値を足しmaxPositionとを比較する。
・maxPositionが大きい場合のみランダムの処理に入る。
・minPositionに部屋の最小値を足した値が大きい場合はランダムにしない(部屋を作れる限界の広さ)
・minPositionに部屋の最大値を足したものとmaxPositionを比較して小さい方をmaxRangeとする
・minPositionと部屋の最小値を足したものがminRangeとする。
・minRangeとmaxRangeの範囲で部屋のpositionを決める
・このままだと部屋の位置はエリアの左上に寄るのでずらせる範囲をランダムで取得しdiffに代入する
・minPositonとmaxPositionの位置を修正する
箇条書きにするとこのような感じです。
最後にCreateメソッドをGenerateMapから呼び出してあげます。
public class RougeGenerator
{
/* 略 */
public int[,] GenerateMap()
{
areaList = new List();
InitMap();
InitFirstArea();
DivideArea(Random.Range(0, 2) == 0);
CreateRoom(); //追加
return map;
}
/* 略 */
いまいちわかりにくい場合は、一度AdjustRoomSidePositionを使わずにエリア内全体に部屋を作ってみると良いかもしれません。
AdjustRoomSidePositionを使った場合と使わなかった場合の実行結果です。
まずはAdjustRoomSidePositionを使わなかった場合です。
すべての部屋の周りはエリアの1マス分空けた位置になっていることがわかります。
続いてAdjustRoomSidePositionを使った場合です。
部屋の大きさもランダムになり、部屋の左上の位置もエリア内でランダムになっているかと思います。
ではいよいよ部屋と部屋をつなぐ処理を実装していきましょう。
部屋と部屋をつなぐ道を作る
では道を作るメソッドを準備していきましょう。
道作成処理に必要なメソッド準備
public class RougeGenerator
{
/* 略 */
//部屋をつなぐメソッド
void ConnectRooms()
{
}
//エリア間をつなぐメソッド
void CreateRoadBetweenAreas(Area parentArea, Area childArea)
{
}
//縦方向に道をつなぐメソッド
void CreateVerticalRoad(Area parentArea, Area childArea)
{
}
//横方向に道をつなぐメソッド
void CreateHorizontalRoad(Area parentArea, Area childArea)
{
}
//部屋から分割地点まで道をつなぐメソッド
void DrawRoadFromRoomToConnectLine(Area area)
{
}
//分割地点上に縦方向へ道を作るメソッド
void DrawVerticalRoad(int xStart, int xEnd, int y)
{
}
//分割地点上に横方向へ道を作るメソッド
void DrawHorizontalRoad(int yStart, int yEnd, int x)
{
}
/* 略 */
このような感じになります。
ではまず各エリアをループで回すConnectRoomsメソッドを作っていきます。
隣合わせのエリアを取得する
このメソッドは隣り合っているエリアをループで回し、エリア間をつなぐメソッドを呼び出します。
ConnectRooms
public class RougeGenerator
{
/* 略 */
//部屋をつなぐメソッド
void ConnectRooms()
{
for (int i = 0; i < areaList.Count - 1; i++)
{
//親エリア取得
Area parentArea = areaList[i];
//子エリア取得
Area childArea = areaList[i + 1];
//エリア間をつなぐメソッドにわたす
CreateRoadBetweenAreas(parentArea, childArea);
}
}
/* 略 */
これでareaListに保存されたエリアを順番に取得しCreateRoadBetweenAreasにわたすことが出来ました。
続いてCreateRoadBetweenAreasメソッドを作っていきます。
エリアがどのように接続しているか判別する
このメソッドでは渡ってきたエリアがどの方向で隣り合っているかをチェックします。
その方向によって作るべき道の方向を決めます。
以下のようになりました。
CreateRoadBetweenAreas
public class RougeGenerator
{
/* 略 */
//部屋をつなぐメソッド
void CreateRoadBetweenAreas(Area parentArea, Area childArea)
{
//上下でエリアが繋がっている場合
if (parentArea.Section.Bottom == childArea.Section.Top || parentArea.Section.Top == childArea.Section.Bottom)
{
//縦に道を作る
CreateVerticalRoad(parentArea, childArea);
}
//左右でエリアが繋がっている場合
else if (parentArea.Section.Right == childArea.Section.Left || parentArea.Section.Left == childArea.Section.Right)
{
//横に道を作る
CreateHorizontalRoad(parentArea, childArea);
}
}
/* 略 */
親エリアと子エリアが隣り合っています。
上下で繋がっている場合、左右で繋がっている場合に合わせて道を作るメソッドを呼び出します。
続いてCreateVerticalRoadとCreateHorizontalRoadを作っていきます。
垂直、水平方向に道を作る
まずは縦方向に道を作ってみます。
CreateVerticalRoad
public class RougeGenerator
{
/* 略 */
void CreateVerticalRoad(Area parentArea, Area childArea)
{
//親の部屋からの座標Xをランダムで取得
int xStart = Random.Range(parentArea.Room.Left, parentArea.Room.Right);
//子の部屋からの座標Xをランダムで取得
int xEnd = Random.Range(childArea.Room.Left, childArea.Room.Right);
//接続するY座標を取得 親が上なら子のSection.Topを、そうでなければ親のSection.Topを取得
int connectY = parentArea.Section.Bottom == childArea.Section.Top ? childArea.Section.Top : parentArea.Section.Top;
//部屋から接続部分まで道を作る
//parentAareaがchildAreaよりも下にある場合
if (parentArea.Section.Top > childArea.Section.Top)
{
//各エリアに幅1マス分の道の矩形をセットする
parentArea.SetRoad(xStart, connectY, xStart + 1, parentArea.Room.Top);
childArea.SetRoad(xEnd, childArea.Room.Bottom, xEnd + 1, connectY);
}
//childAreaがparentAareaよりも下にある場合
else
{
//各エリアに幅1マス分の道の矩形をセットする
parentArea.SetRoad(xStart, parentArea.Room.Bottom, xStart + 1, connectY);
childArea.SetRoad(xEnd, connectY, xEnd + 1, childArea.Room.Top);
}
//各エリアの部屋から接続部分までのRoadを描画する
DrawRoadFromRoomToConnectLine(parentArea);
DrawRoadFromRoomToConnectLine(childArea);
//接続部分の道を描画する
DrawVerticalRoad(xStart, xEnd, connectY);
}
/* 略 */
このようになりました。簡単に処理の流れをまとめると以下のようになります。
・子の部屋の横幅からランダムで接続部分(分割ライン)までの位置を決める
・接続部分(分割ライン)の縦座標を取得しておく
・親のSection.Topと子のSection.Topを比較し、上下の位置関係を調べる
・各エリアのRoadの上下左右の座標をSetRoadメソッドで設定する
・各エリアの部屋から接続部分(分割ライン)まで道を作るメソッドを呼び出す
・接続部分(分割ライン)に道を作るメソッドを呼び出す
このような感じです。
同様に横に道を作るメソッドも作っておきましょう。
道の向きが異なりますがやってることは同じです。
CreateHorizontalRoad
public class RougeGenerator
{
/* 略 */
void CreateHorizontalRoad(Area parentArea, Area childArea)
{
int yStart = Random.Range(parentArea.Room.Top, parentArea.Room.Bottom);
int yEnd = Random.Range(childArea.Room.Top, childArea.Room.Bottom);
int connectX = parentArea.Section.Right == childArea.Section.Left ? childArea.Section.Left : parentArea.Section.Left;
if (parentArea.Section.Left > childArea.Section.Left)
{
parentArea.SetRoad(connectX, yStart, parentArea.Room.Left, yStart + 1);
childArea.SetRoad(childArea.Room.Right, yEnd, connectX, yEnd + 1);
}
else
{
connectX = childArea.Section.Left;
parentArea.SetRoad(parentArea.Room.Right, yStart, connectX, yStart + 1);
childArea.SetRoad(connectX, yEnd, childArea.Room.Left, yEnd + 1);
}
DrawRoadFromRoomToConnectLine(parentArea);
DrawRoadFromRoomToConnectLine(childArea);
DrawHorizontalRoad(yStart, yEnd, connectX);
}
/* 略 */
このようになりました。
続いて部屋から接続部分(分割ライン)まで道を伸ばしてみましょう。
部屋から接続部分まで道を伸ばす
では部屋から接続部分まで道をつくります。
この処理は部屋の時同様、縦横の二重ループをさせるだけになります。
DrawRoadFromRoomToConnectLine
public class RougeGenerator
{
/* 略 */
void DrawRoadFromRoomToConnectLine(Area area)
{
//縦のループ
for (int y = 0; y < area.Road.Height; y++)
{
//横のループ
for (int x = 0; x < area.Road.Width; x++)
{
//マップデータを道にする
map[x + area.Road.Left, y + area.Road.Top] = (int)CellType.Path;
}
}
}
/* 略 */
このような感じです。
エリアを引数で受け取り、エリアの高さ、エリアの横幅分ループさせます。
map配列のインデックスはxとyではなくxにarea.Road.Leftを足した値、yにarea.Road.Topを足した値になっています。
では実行してみます。
このように部屋から接続部分(分割ライン)まで道が出来ました。
接続部分の道を作って各部屋の道作りは完成です。
接続部分の道を作る
接続部分の道に関しては矩形を改めて作る必要はありません。
それぞれの方向に対してループし、マップデータを道のデータに上書きするだけでOKです。
まずは垂直方向に道をつくります。
DrawVerticalRoad
public class RougeGenerator
{
/* 略 */
//開始位置X座標、終点位置X座標、y座標
void DrawVerticalRoad(int xStart, int xEnd, int y)
{
//xの1ループのみで完結。
//エリア分割によって開始位置と終了位置が逆になっている場合があるのでそれぞれ判定してループ開始
for (int x = Mathf.Min(xStart, xEnd); x <= Mathf.Max(xStart, xEnd); x++)
{
//マップデータを上書き
map[x, y] = (int)CellType.Path;
}
}
/* 略 */
このようになりました。
xの初期値がxStartとxEndの小さい方の値、ループ条件がxStart、xEnd以下の場合としている点に注意してください。
続いて水平方向に道をつくりましょう。
DrawHorizontalRoad
public class RougeGenerator
{
/* 略 */
void DrawHorizontalRoad(int yStart, int yEnd, int x)
{
for (int y = Mathf.Min(yStart, yEnd); y <= Mathf.Max(yStart, yEnd); y++)
{
map[x, y] = (int)CellType.Path;
}
}
/* 略 */
さて、これで道がつながるかどうかテストしてみます。
無事に部屋同士が道でつながることが出来ました。
このようにしてローグライクのようなマップを作ることが出来ます。
しかしこのままでは親と子のエリア同士でしか繋がっていないため非常にシンプルなマップです。
ある条件下では親と孫をつなげるが可能ですのでそれをやってみたいと思います。
親と孫の接続
ではどういう条件の時に親と孫がつながるか考えてみます。
親と孫の接続条件
まず縦横いずれかの分割ラインが重なっている時は接続できそうです。
エリアを分割しareaListに格納する際に親、子の順番にして、分割方向も縦、横、縦、横と互い違いにしていけば親と孫はつながるのですが、今回の仕組みでは次に分割する候補が面積が大きい方にしているため、親と孫が繋がらないケースも出てきます。
そして道ですが、すでに親と子は道で繋がっている場合は親から分割ラインまで伸びている道をそのまま使うこともできそうです。
この仕組みを実装すれば親と孫も接続でき、より複雑なマップを作ることができそうです。
では試しにやってみます。
孫接続の為の修正
ConnectRoomsメソッドではareaListから親エリアと子エリアを取得して道を作っていました。
その中に孫エリアを取得して親エリアと孫エリアをCreateRoadBetweenAreasに渡せば良さそうです。
ConnectRooms
public class RougeGenerator
{
/* 略 */
void ConnectRooms()
{
for (int i = 0; i < areaList.Count - 1; i++)
{
Area parentArea = areaList[i];
Area childArea = areaList[i + 1];
CreateRoadBetweenAreas(parentArea, childArea);
// 追加 孫エリアとの接続を試みる
// iがareaList.Conunt-2よりも小さい場合に孫エリア取得可能
if (i < areaList.Count - 2)
{
//孫エリア取得
Area grandchildArea = areaList[i + 2];
//親と孫の接続関係を調べる
CreateRoadBetweenAreas(parentArea, grandchildArea, true);
}
}
}
/* 略 */
このようにしてみます。CreateRoadBetweenAreasへ渡す引数が一つ増えました。
孫との接続フラグとして使います。
続いてCreateRoadBetweenAreasメソッドを修正します。
とは言っても簡単な修正です。受け取る引数と次のメソッドへ渡す引数を一つ増やすだけです。
CreateRoadBetweenAreas
public class RougeGenerator
{
/* 略 */
void CreateRoadBetweenAreas(Area parentArea, Area childArea, bool isGrandchild = false)//引数一つ追加 初期値はfalse
{
if (parentArea.Section.Bottom == childArea.Section.Top || parentArea.Section.Top == childArea.Section.Bottom)
{
//CreateVerticalRoadメソッドへ孫フラグを渡す
CreateVerticalRoad(parentArea, childArea, isGrandchild);
}
else if (parentArea.Section.Right == childArea.Section.Left || parentArea.Section.Left == childArea.Section.Right)
{
//CreateHorizontalRoadメソッドへ孫フラグを渡す
CreateHorizontalRoad(parentArea, childArea, isGrandchild);
}
else //孫と接続できなかったときの確認
{
Debug.Log("孫との接続不可能");
}
}
/* 略 */
このような感じです。
確認の為、親と孫が隣り合わせになっていない場合はログを出すようにしてみました。
親エリアと孫エリアが隣り合わせの場合は接続処理を行っていきます。
親と孫をつなぐ仕組みのところで道の再利用をする仕組みにしようと書きました。
それを考慮して改修していきます。
まずはCreateVerticalRoadメソッドを修正します。
CreateVerticalRoad
public class RougeGenerator
{
/* 略 */
//引数追加
void CreateVerticalRoad(Area parentArea, Area childArea, bool isGrandchild)
{
//xStartの取得方法変更 孫接続時で親エリアのRoadがnullでなければ親エリアのRoad.LeftをxStartにする
int xStart = isGrandchild && parentArea.Road != null ? parentArea.Road.Left : Random.Range(parentArea.Room.Left, parentArea.Room.Right);
//xEndの取得方法変更 孫接続時で孫エリアのRoadがnullでなければ親エリアのRoad.LeftをxStartにする
int xEnd = isGrandchild && childArea.Road != null ? childArea.Road.Left : Random.Range(childArea.Room.Left, childArea.Room.Right);
int connectY = parentArea.Section.Bottom == childArea.Section.Top ? childArea.Section.Top : parentArea.Section.Top;
//部屋から接続部分まで道を作る
if (parentArea.Section.Top > childArea.Section.Top)
{
parentArea.SetRoad(xStart, connectY, xStart + 1, parentArea.Room.Top);
childArea.SetRoad(xEnd, childArea.Room.Bottom, xEnd + 1, connectY);
}
else
{
parentArea.SetRoad(xStart, parentArea.Room.Bottom, xStart + 1, connectY);
childArea.SetRoad(xEnd, connectY, xEnd + 1, childArea.Room.Top);
}
//部屋から接続部分までを道にする
DrawRoadFromRoomToConnectLine(parentArea);
DrawRoadFromRoomToConnectLine(childArea);
//接続部分を道にする
DrawVerticalRoad(xStart, xEnd, connectY);
}
/* 略 */
続いてCreateHorizontalRoadメソッドです。
やっていることはほとんど同じです。
CreateHorizontalRoad
public class RougeGenerator
{
/* 略 */
//引数追加
void CreateHorizontalRoad(Area parentArea, Area childArea, bool isGrandchild)
{
int yStart = isGrandchild && parentArea.Road != null ? parentArea.Road.Top : Random.Range(parentArea.Room.Top, parentArea.Room.Bottom);
int yEnd = isGrandchild && childArea.Road != null ? childArea.Road.Top : Random.Range(childArea.Room.Top, childArea.Room.Bottom);
int connectX = parentArea.Section.Right == childArea.Section.Left ? childArea.Section.Left : parentArea.Section.Left;
if (parentArea.Section.Left > childArea.Section.Left)
{
parentArea.SetRoad(connectX, yStart, parentArea.Room.Left, yStart + 1);
childArea.SetRoad(childArea.Room.Right, yEnd, connectX, yEnd + 1);
}
else
{
connectX = childArea.Section.Left;
parentArea.SetRoad(parentArea.Room.Right, yStart, connectX, yStart + 1);
childArea.SetRoad(connectX, yEnd, childArea.Room.Left, yEnd + 1);
}
DrawRoadFromRoomToConnectLine(parentArea);
DrawRoadFromRoomToConnectLine(childArea);
DrawHorizontalRoad(yStart, yEnd, connectX);
}
/* 略 */
このようになりました。
では実行してみましょう。
親と孫がつながることで単なる一本道ではなく、より複雑なマップが完成しました。
更に工夫するならば
このマップ生成方法は割りとシンプルな作り方ですが、色々改良出来そうです。
例えば最初の区画は大きくなりやすいので、さらに分割出来そうです。
また分割方向を毎回ランダムにしてみるのも良いかもしれません。
すこし工夫すれば以前投稿した薄い壁の迷路を作るロジックを組み込むことなども可能です。
こんな感じになります。
ローグライクっぽい自動マップ生成に、1セルを薄い壁にする仕組みを掛け合わせてみた。これにドアシステム付けたいな。 pic.twitter.com/KKIXQLUUmm
— タニス谷山 (@non_tanisu) February 27, 2024
また今回はわかりやすくするために、区画、部屋、道などを毎回ループさせていました。
この処理はどれも似ていますので工夫して一つのメソッドにまとめれると思います。
是非挑戦してみてください。
今回の内容をGitHubにアップしておきますので、参考にしてください。
GitHub Rougelike
関連記事
ドルアーガの塔みたいな薄い壁の迷路を作る
薄い壁の2Dダンジョンを作ってみたいと思います。
1マスに上下左右の壁を設置する感じです。
ウィザードリィのような疑似3Dダンジョンを作る
ウィザードリィのような疑似3DダンジョンをUnity2Dで作成してみたいと思います。
穴掘り法で2D迷路を作る
穴掘り法を使って2Dマップを作成してみます。
プレイヤーの移動まで実装しています。
見下ろし2Dマップ上でプレイヤーを動かす
プレイヤーを2Dマップ上で移動させてみます。
見下ろし型のRPGとかでありそうなやつです。
テキストデータからマップを作ってみる
Unity2Dでテキストのデータを用いた簡単な2Dのマップ作成をしてみたいと思います。
色々なゲームで使えそうです。
最後までご覧頂いてありがとうございました。