はじめに
こんにちは、ココネでクライアントエンジニアをしているMです
みなさん、ローグライクなゲーム遊んでいますか?
僕は、学生時代にチーム制作でローグライクなホラーゲームを作っていました。
ローグライクなマップを作るのに当たって、当時参考にしていたブログを久しぶりに見に行こうとしたらなんと閉鎖。もう帰れぬ場所となっていました。
そこで今回はローグライクな迷路を自動生成するプログラムをご紹介しようと思います
コピーするだけでマップを作れますのでぜひお試しください
実行結果
まずはどういったマップが生成されるのか簡単にご紹介します。
以下4回分の生成結果で、毎回別のマップが生成されます
分かりやすいように赤は部屋、緑は壁、黄色は道と区別しています(クリスマスカラー)
スクリプト
Script(長いので折りたたんでいます)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
using Random = UnityEngine.Random;
public class StageGenerator : MonoBehaviour
{
[SerializeField] ObjectState WallSetting;
[SerializeField] ObjectState GroundSetting;
[SerializeField] ObjectState RoadSetting;
[SerializeField] Vector3 defaultPosition;
[Serializable]
class ObjectState
{
public Color color;
public Vector3 size;
}
[SerializeField] private int mapSizeW; // マップの横サイズ
[SerializeField] private int mapSizeH; // マップの縦サイズ
private int[,] map; // マップの管理配列
[SerializeField] private int roomNum; // 部屋の数
private int roomMin = 10; // 生成する部屋の最小値
private int parentNum = 0; // 分割する部屋番号
private int max = 0; // 最大面積
private int roomCount; // 部屋カウント
private int line = 0; // 分割点
private int[,] roomStatus; // 部屋の管理配列
private enum RoomStatus // 部屋の配列ステータス
{
x,// マップ座標x
y,// マップ座標y
w,// 分割した幅
h,// 分割した高さ
rx,// 部屋の生成位置
ry,// 部屋の生成位置
rw,// 部屋の幅
rh,// 部屋の高さ
}
enum objectType
{
ground = 0,
wall = 1,
road = 2,
}
private GameObject[] mapObjects; // マップ生成用のオブジェクト配列
private GameObject[] objectParents; // 各タイプ別の親オブジェクト配列
private const int offsetWall = 2; // 壁から離す距離
private const int offset = 1; // 調整用
void Start()
{
MapGenerate();
}
// 生成用のオブジェクトを用意
void initPrefab()
{
// 親オブジェクトの生成
GameObject groundParent = new GameObject("Ground");
GameObject wallParent = new GameObject("Wall");
GameObject roadParent = new GameObject("Road");
// 配列にプレハブを入れる
objectParents = new GameObject[] { groundParent, wallParent, roadParent };
// 迷路オブジェクトの初期化
GameObject ground = GameObject.CreatePrimitive(PrimitiveType.Cube);
ground.transform.localScale = GroundSetting.size;
ground.GetComponent<Renderer>().material.color = GroundSetting.color;
ground.name = "ground";
ground.transform.SetParent(objectParents[(int)objectType.ground].transform);
GameObject wall = GameObject.CreatePrimitive(PrimitiveType.Cube);
wall.transform.localScale = WallSetting.size;
wall.GetComponent<Renderer>().material.color = WallSetting.color;
wall.name = "wall";
wall.transform.SetParent(objectParents[(int)objectType.wall].transform);
GameObject road = GameObject.CreatePrimitive(PrimitiveType.Cube);
road.transform.localScale = RoadSetting.size;
road.GetComponent<Renderer>().material.color = RoadSetting.color;
road.name = "road";
road.transform.SetParent(objectParents[(int)objectType.road].transform);
// 配列にプレハブを入れる
mapObjects = new GameObject[] { ground, wall, road };
}
private void MapGenerate()
{
initPrefab();
// 部屋(StartX、StartY、幅、高さ)
roomStatus = new int[System.Enum.GetNames(typeof(RoomStatus)).Length, roomNum];
// フロア設定
map = new int[mapSizeW, mapSizeH];
// フロアの初期化
for (int nowW = 0; nowW < mapSizeW; nowW++)
{
for (int nowH = 0; nowH < mapSizeH; nowH++)
{
// 壁を貼る
map[nowW, nowH] = 2;
}
}
// フロアを入れる
roomStatus[(int)RoomStatus.x, roomCount] = 0;
roomStatus[(int)RoomStatus.y, roomCount] = 0;
roomStatus[(int)RoomStatus.w, roomCount] = mapSizeW;
roomStatus[(int)RoomStatus.h, roomCount] = mapSizeH;
// カウント追加
roomCount++;
// 部屋の数だけ分割する
for (int splitNum = 0; splitNum < roomNum - 1; splitNum++)
{
// 変数初期化
parentNum = 0; // 分割する部屋番号
max = 0; // 最大面積
// 最大の部屋番号を調べる
for (int maxCheck = 0; maxCheck < roomNum; maxCheck++)
{
// 面積比較
if (max < roomStatus[(int)RoomStatus.w, maxCheck] * roomStatus[(int)RoomStatus.h, maxCheck])
{
// 最大面積上書き
max = roomStatus[(int)RoomStatus.w, maxCheck] * roomStatus[(int)RoomStatus.h, maxCheck];
// 親の部屋番号セット
parentNum = maxCheck;
}
}
// 取得した部屋をさらに割る
if (SplitPoint(roomStatus[(int)RoomStatus.w, parentNum], roomStatus[(int)RoomStatus.h, parentNum]))
{
// 取得
roomStatus[(int)RoomStatus.x, roomCount] = roomStatus[(int)RoomStatus.x, parentNum];
roomStatus[(int)RoomStatus.y, roomCount] = roomStatus[(int)RoomStatus.y, parentNum];
roomStatus[(int)RoomStatus.w, roomCount] = roomStatus[(int)RoomStatus.w, parentNum] - line;
roomStatus[(int)RoomStatus.h, roomCount] = roomStatus[(int)RoomStatus.h, parentNum];
// 親の部屋を整形する
roomStatus[(int)RoomStatus.x, parentNum] += roomStatus[(int)RoomStatus.w, roomCount];
roomStatus[(int)RoomStatus.w, parentNum] -= roomStatus[(int)RoomStatus.w, roomCount];
}
else
{
// 取得
roomStatus[(int)RoomStatus.x, roomCount] = roomStatus[(int)RoomStatus.x, parentNum];
roomStatus[(int)RoomStatus.y, roomCount] = roomStatus[(int)RoomStatus.y, parentNum];
roomStatus[(int)RoomStatus.w, roomCount] = roomStatus[(int)RoomStatus.w, parentNum];
roomStatus[(int)RoomStatus.h, roomCount] = roomStatus[(int)RoomStatus.h, parentNum] - line;
// 親の部屋を整形する
roomStatus[(int)RoomStatus.y, parentNum] += roomStatus[(int)RoomStatus.h, roomCount];
roomStatus[(int)RoomStatus.h, parentNum] -= roomStatus[(int)RoomStatus.h, roomCount];
}
// カウントを加算
roomCount++;
}
// 分割した中にランダムな大きさの部屋を生成
for (int i = 0; i < roomNum; i++)
{
// 生成座標の設定
roomStatus[(int)RoomStatus.rx, i] = Random.Range(roomStatus[(int)RoomStatus.x, i] + offsetWall, (roomStatus[(int)RoomStatus.x, i] + roomStatus[(int)RoomStatus.w, i]) - (roomMin + offsetWall));
roomStatus[(int)RoomStatus.ry, i] = Random.Range(roomStatus[(int)RoomStatus.y, i] + offsetWall, (roomStatus[(int)RoomStatus.y, i] + roomStatus[(int)RoomStatus.h, i]) - (roomMin + offsetWall));
// 部屋の大きさを設定
roomStatus[(int)RoomStatus.rw, i] = Random.Range(roomMin, roomStatus[(int)RoomStatus.w, i] - (roomStatus[(int)RoomStatus.rx, i] - roomStatus[(int)RoomStatus.x, i]) - offset);
roomStatus[(int)RoomStatus.rh, i] = Random.Range(roomMin, roomStatus[(int)RoomStatus.h, i] - (roomStatus[(int)RoomStatus.ry, i] - roomStatus[(int)RoomStatus.y, i]) - offset);
}
// マップ上書き
for (int count = 0; count < roomNum; count++)
{
// 取得した部屋の確認
for (int h = 0; h < roomStatus[(int)RoomStatus.h, count]; h++)
{
for (int w = 0; w < roomStatus[(int)RoomStatus.w, count]; w++)
{
// 部屋チェックポイント
map[w + roomStatus[(int)RoomStatus.x, count], h + roomStatus[(int)RoomStatus.y, count]] = 1;
}
}
// 生成した部屋
for (int h = 0; h < roomStatus[(int)RoomStatus.rh, count]; h++)
{
for (int w = 0; w < roomStatus[(int)RoomStatus.rw, count]; w++)
{
map[w + roomStatus[(int)RoomStatus.rx, count], h + roomStatus[(int)RoomStatus.ry, count]] = 0;
}
}
}
// 道の生成
int[] splitLength = new int[4];
int roodPoint = 0;// 道を引く場所
// 部屋から一番近い境界線を調べる(十字に調べる)
for (int nowRoom = 0; nowRoom < roomNum; nowRoom++)
{
// 左の壁からの距離
splitLength[0] = roomStatus[(int)RoomStatus.x, nowRoom] > 0 ?
roomStatus[(int)RoomStatus.rx, nowRoom] - roomStatus[(int)RoomStatus.x, nowRoom] : int.MaxValue;
// 右の壁からの距離
splitLength[1] = (roomStatus[(int)RoomStatus.x, nowRoom] + roomStatus[(int)RoomStatus.w, nowRoom]) < mapSizeW ?
(roomStatus[(int)RoomStatus.x, nowRoom] + roomStatus[(int)RoomStatus.w, nowRoom]) - (roomStatus[(int)RoomStatus.rx, nowRoom] + roomStatus[(int)RoomStatus.rw, nowRoom]) : int.MaxValue;
// 下の壁からの距離
splitLength[2] = roomStatus[(int)RoomStatus.y, nowRoom] > 0 ?
roomStatus[(int)RoomStatus.ry, nowRoom] - roomStatus[(int)RoomStatus.y, nowRoom] : int.MaxValue;
// 上の壁からの距離
splitLength[3] = (roomStatus[(int)RoomStatus.y, nowRoom] + roomStatus[(int)RoomStatus.h, nowRoom]) < mapSizeH ?
(roomStatus[(int)RoomStatus.y, nowRoom] + roomStatus[(int)RoomStatus.h, nowRoom]) - (roomStatus[(int)RoomStatus.ry, nowRoom] + roomStatus[(int)RoomStatus.rh, nowRoom]) : int.MaxValue;
// マックスでない物のみ先へ
for (int j = 0; j < splitLength.Length; j++)
{
if (splitLength[j] != int.MaxValue)
{
// 上下左右判定
if (j < 2)
{
// 道を引く場所を決定
roodPoint = Random.Range(roomStatus[(int)RoomStatus.ry, nowRoom] + offset, roomStatus[(int)RoomStatus.ry, nowRoom] + roomStatus[(int)RoomStatus.rh, nowRoom] - offset);
// マップに書き込む
for (int w = 1; w <= splitLength[j]; w++)
{
// 左右判定
if (j == 0)
{
// 左
map[(-w) + roomStatus[(int)RoomStatus.rx, nowRoom], roodPoint] = 2;
}
else
{
// 右
map[w + roomStatus[(int)RoomStatus.rx, nowRoom] + roomStatus[(int)RoomStatus.rw, nowRoom] - offset, roodPoint] = 2;
// 最後
if (w == splitLength[j])
{
// 一つ多く作る
map[w + offset + roomStatus[(int)RoomStatus.rx, nowRoom] + roomStatus[(int)RoomStatus.rw, nowRoom] - offset, roodPoint] = 2;
}
}
}
}
else
{
// 道を引く場所を決定
roodPoint = Random.Range(roomStatus[(int)RoomStatus.rx, nowRoom] + offset, roomStatus[(int)RoomStatus.rx, nowRoom] + roomStatus[(int)RoomStatus.rw, nowRoom] - offset);
// マップに書き込む
for (int h = 1; h <= splitLength[j]; h++)
{
// 上下判定
if (j == 2)
{
// 下
map[roodPoint, (-h) + roomStatus[(int)RoomStatus.ry, nowRoom]] = 2;
}
else
{
// 上
map[roodPoint, h + roomStatus[(int)RoomStatus.ry, nowRoom] + roomStatus[(int)RoomStatus.rh, nowRoom] - offset] = 2;
// 最後
if (h == splitLength[j])
{
// 一つ多く作る
map[roodPoint, h + offset + roomStatus[(int)RoomStatus.ry, nowRoom] + roomStatus[(int)RoomStatus.rh, nowRoom] - offset] = 2;
}
}
}
}
}
}
}
int roadVec1 = 0;// 道の始点
int roadVec2 = 0;// 道の終点
// 道の接続
for (int nowRoom = 0; nowRoom < roomNum; nowRoom++)
{
roadVec1 = 0;
roadVec2 = 0;
// 道を繋げる
for (int roodScan = 0; roodScan < roomStatus[(int)RoomStatus.w, nowRoom]; roodScan++)
{
// 道を検索
if (map[roodScan + roomStatus[(int)RoomStatus.x, nowRoom], roomStatus[(int)RoomStatus.y, nowRoom]] == 2)
{
// 道の座標セット
if (roadVec1 == 0)
{
// 始点セット
roadVec1 = roodScan + roomStatus[(int)RoomStatus.x, nowRoom];
}
else
{
// 終点セット
roadVec2 = roodScan + roomStatus[(int)RoomStatus.x, nowRoom];
}
}
}
// 道を引く
for (int roadSet = roadVec1; roadSet < roadVec2; roadSet++)
{
// 境界線を上書き
map[roadSet, roomStatus[(int)RoomStatus.y, nowRoom]] = 2;
}
roadVec1 = 0;
roadVec2 = 0;
for (int roadScan = 0; roadScan < roomStatus[(int)RoomStatus.h, nowRoom]; roadScan++)
{
// 道を検索
if (map[roomStatus[(int)RoomStatus.x, nowRoom], roadScan + roomStatus[(int)RoomStatus.y, nowRoom]] == 2)
{
// 道の座標セット
if (roadVec1 == 0)
{
// 始点セット
roadVec1 = roadScan + roomStatus[(int)RoomStatus.y, nowRoom];
}
else
{
// 終点セット
roadVec2 = roadScan + roomStatus[(int)RoomStatus.y, nowRoom];
}
}
}
// 道を引く
for (int roadSet = roadVec1; roadSet < roadVec2; roadSet++)
{
// 境界線を上書き
map[roomStatus[(int)RoomStatus.x, nowRoom], roadSet] = 2;
}
}
// オブジェクトを生成する
for (int nowH = 0; nowH < mapSizeH; nowH++)
{
for (int nowW = 0; nowW < mapSizeW; nowW++)
{
// 壁の生成
if (map[nowW, nowH] == (int)objectType.wall)
{
GameObject mazeObject = Instantiate(
mapObjects[map[nowW, nowH]],
new Vector3(
defaultPosition.x + nowW * mapObjects[map[nowW, nowH]].transform.localScale.x,
defaultPosition.y + ((WallSetting.size.y - 1) * 0.5f),
defaultPosition.z + nowH * mapObjects[map[nowW, nowH]].transform.localScale.z),
Quaternion.identity,objectParents[map[nowW, nowH]].transform);
}
// 部屋の生成
if (map[nowW, nowH] == (int)objectType.ground)
{
GameObject mazeObject = Instantiate(
mapObjects[map[nowW, nowH]],
new Vector3(
defaultPosition.x + nowW * mapObjects[map[nowW, nowH]].transform.localScale.x,
defaultPosition.y,
defaultPosition.z + nowH * mapObjects[map[nowW, nowH]].transform.localScale.z),
Quaternion.identity, objectParents[map[nowW, nowH]].transform);
}
// 通路の生成
if (map[nowW, nowH] == (int)objectType.road)
{
GameObject mazeObject = Instantiate(
mapObjects[map[nowW, nowH]],
new Vector3(
defaultPosition.x + nowW * mapObjects[map[nowW, nowH]].transform.localScale.x,
defaultPosition.y,
defaultPosition.z + nowH * mapObjects[map[nowW, nowH]].transform.localScale.z),
Quaternion.identity,objectParents[map[nowW, nowH]].transform);
}
}
}
}
// 分割点のセット(int x, int y)、大きい方を分割する
private bool SplitPoint(int x, int y)
{
// 分割位置の決定
if (x > y)
{
line = Random.Range(roomMin + (offsetWall * 2), x - (offsetWall * 2 + roomMin));// 縦割り
return true;
}
else
{
line = Random.Range(roomMin + (offsetWall * 2), y - (offsetWall * 2 + roomMin));// 横割り
return false;
}
}
}
マップ生成には「区域分割法」を採用しています
区域分割法はマップの分割を繰り返し区域を生成、生成した区域の中に部屋を作り、最終的に部屋同士を道でつなげるという手法になります
上記scriptから抜粋し流れに沿って簡単に解説をしたいと思います
Step1 : 区域を分ける
SplitPointという関数を使用し縦に分けるか、横に分けるかを決めます
if (SplitPoint(roomStatus[(int)RoomStatus.w, parentNum], roomStatus[(int)RoomStatus.h, parentNum]))
Step2 : 区域内にランダムなサイズの部屋を生成
for (int i = 0; i < roomNum; i++)
{
// 生成座標の設定
roomStatus[(int)RoomStatus.rx, i] = Random.Range(roomStatus[(int)RoomStatus.x, i] + offsetWall, (roomStatus[(int)RoomStatus.x, i] + roomStatus[(int)RoomStatus.w, i]) - (roomMin + offsetWall));
roomStatus[(int)RoomStatus.ry, i] = Random.Range(roomStatus[(int)RoomStatus.y, i] + offsetWall, (roomStatus[(int)RoomStatus.y, i] + roomStatus[(int)RoomStatus.h, i]) - (roomMin + offsetWall));
// 部屋の大きさを設定
roomStatus[(int)RoomStatus.rw, i] = Random.Range(roomMin, roomStatus[(int)RoomStatus.w, i] - (roomStatus[(int)RoomStatus.rx, i] - roomStatus[(int)RoomStatus.x, i]) - offset);
roomStatus[(int)RoomStatus.rh, i] = Random.Range(roomMin, roomStatus[(int)RoomStatus.h, i] - (roomStatus[(int)RoomStatus.ry, i] - roomStatus[(int)RoomStatus.y, i]) - offset);
}
Step3 : map配列に情報を書き込み
for (int count = 0; count < roomNum; count++)
{
// 取得した部屋の確認
for (int h = 0; h < roomStatus[(int)RoomStatus.h, count]; h++)
{
for (int w = 0; w < roomStatus[(int)RoomStatus.w, count]; w++)
{
// 部屋チェックポイント
map[w + roomStatus[(int)RoomStatus.x, count], h + roomStatus[(int)RoomStatus.y, count]] = 1;
}
}
// 生成した部屋
for (int h = 0; h < roomStatus[(int)RoomStatus.rh, count]; h++)
{
for (int w = 0; w < roomStatus[(int)RoomStatus.rw, count]; w++)
{
map[w + roomStatus[(int)RoomStatus.rx, count], h + roomStatus[(int)RoomStatus.ry, count]] = 0;
}
}
}
Step4 : ループの中で道を生成
道を作る際に、単に部屋同士を直接つなげるのではなく区分した境界線をまずは目指します
その境界線をたどり、次の部屋へと繋げていくイメージになります
↓こういうイメージ
for (int nowRoom = 0; nowRoom < roomNum; nowRoom++)
Step5 : 最後に配列mapをループし該当のオブジェクトを生成
for (int nowH = 0; nowH < mapSizeH; nowH++)
{
for (int nowW = 0; nowW < mapSizeW; nowW++)
{
ざっくりと手順としてはこんな感じです
実行してみる
スクリプトを空のオブジェクトにアタッチし、inspector上の各項目を設定します
項目名 | 説明 |
---|---|
Wall Setting | 壁の設定です。色とサイズを指定できます。 |
Ground Setting | 床の設定です。色とサイズを指定できます。 |
Road Setting | 道の設定です。色とサイズを指定できます。 |
Default Position | マップを生成する初期位置を指定できます。 |
mapSizeX | マップ全体の横サイズを指定できます。 |
mapSizeY | マップ全体の縦サイズを指定できます。 |
RoomNum | 生成する部屋の数を指定できます。 |
マップの縦横サイズに対して、生成できる部屋数には上限があります
あまりにも部屋数が多すぎるとエラーが発生するのでお気をつけください
おわりに
アドベントカレンダーから空いている日を取ったらまさかの最終日(取った2日後に気づいた)
変なプレッシャーを感じていましたが書き切ることができました。
ホラーゲームを作る際は道、部屋を検索しアイテムや敵を生成したり、map配列を2次元に生成してミニマップを作ってみたりと応用方法は様々かと思います!
今回はコピーしていただくだけで作れるスクリプトを作りたかったのでオブジェクトもスクリプト内で動的に生成しているのですが、もちろん自身で作成したオブジェクトを紐づけてもらうことで違った見え方のするマップを作ることも可能です!
(自分は学校をイメージしたマップを作りたかったので床はフローリングっぽくしていました)
当スクリプトはご自由に改ざん・使用していただいて構いませんので是非役立てていただければと思います!
メリークリスマス!!