ドルアーガの塔みたいな薄い壁の迷路を作る

以前2Dマップを作成する記事を投稿しました。
これは1マスが通路または壁となるやり方でマップをつくりました。

今回はそれを改造して薄い壁の迷路を作ってみたいと思います。
1マスの四方に壁があるような感じです。具体的にはドルアーガの塔のようなマップです。

従来のものとの違い

以前のマップは以下の画像のように1マスに壁と通路を表現する内容でした。

しかし1つのマスのみで上下左右の壁を表現することも可能です。
それを実装してみようという試みです。

1マスの考え方

ではどのように1マス上で壁を表現するのか考えてみます。

・壁がない状態 = 0
・上に壁がある = 1
・右に壁がある = 2
・下に壁がある = 4
・左に壁がある = 8

このように条件によって数値を決め、その組み合わせで1マスで上下左右の壁を表現出来ます。
例えば上と下に壁がある場合は 1+4=5
右下左に壁がある場合は 2+4+8=14
上下左右に壁がある場合は 1+2+4+8=15
といった具合になります。
マス毎に上下左右の壁の有無を調べ、数値にしていけばうまく行きそうです。

数値を2進数でみる

実際に作っていく前になぜ壁を表すのに1,2,4,8としたのかを簡単に説明しておきます。
この4つの数字を足した場合、重複するパターンは存在しません。
各数字を2進数で表すと以下のようになります。

1: 0001
2: 0010
4: 0100
8: 1000

各桁の0と1を壁フラグとして考えてみます。

0: 0000 (壁なし)
1: 0001(上に壁)
2: 0010(右に壁)
3: 0011 (1 + 2)(上と右に壁)
4: 0100(下に壁)
5: 0101 (1 + 4)(上と下に壁)
6: 0110 (2 + 4)(右と下に壁)
7: 0111 (1 + 2 + 4)(上と右と下に壁)
8: 1000(左に壁)
9: 1001 (1 + 8)(上と左に壁)
10: 1010 (2 + 8)(右と左に壁)
11: 1011 (1 + 2 + 8)(上と右と左に壁)
12: 1100 (4 + 8)(下と左に壁)
13: 1101 (1 + 4 + 8)(上と下と左に壁)
14: 1110 (2 + 4 + 8)(右と下と左に壁)
15: 1111 (1 + 2 + 4 + 8)(上下左右に壁)

以上のようになりパターンは重複せず四方の壁を表現できました。
このようなフラグを組み合わせる方法は、ビットフィールドビットマスクと呼ばれているそうで、
プログラミングにおいて設定や状態を効率的に管理するためによく使用されるそうです。
前置きが長くなりましたが、この方法を使って1マスに上下左右の壁を配置していきたいと思います。

マップデータの生成と加工

では早速マップデータを作っていきます。
一旦1マス毎に通路(0)と壁(1)を表現するパターンで作成しましょう。

従来のマップを生成

ここでは以前の記事「穴掘り法」を使ってランダムにダンジョンマップを作成していきたいと思います。
今回の記事では穴掘り法のコードのみ掲載しておきます。詳しく知りたい方は以下の記事を参考にしていただければ幸いです。


using System.Collections.Generic;
using UnityEngine;

public class MazeCreator
{
    const int PATH = 0;
    const int WALL = 1;
    int width, height;
    int[,] maze;
    System.Random rnd = new System.Random();
    List startCells = new List();
    enum DIRECTION
    {
        UP,
        RIGHT,
        DOWN,
        LEFT
    }

    public MazeCreator(int width, int height)
    {

        this.width = MakeOddNumber(width);
        this.height = MakeOddNumber(height);

        maze = new int[this.width, this.height];
    }

    public int[,] GenerateMaze()
    {
        InitMaze();
        Dig(1, 1);
        SetWallOutside();
        PrintMaze();
        return maze;

    }


    void InitMaze()
    {
        for (int y = 0; y < height; y++)
        {
            for (int x = 0; x < width; x++)
            {
                if (x == 0 || y == 0 || x == width - 1 || y == height - 1)
                {
                    maze[x, y] = PATH;
                }
                else
                {
                    maze[x, y] = WALL;
                }
            }
        }

    }

    (int, int) SelectStartPoint()
    {
        System.Random rnd = new System.Random();
        int y = rnd.Next(1, height - 1);
        int x = rnd.Next(1, width - 1);
        if (y % 2 == 0)
        {
            y--;
        }
        if (x % 2 == 0)
        {
            x--;
        }
        return (x, y);
    }

    void SetWallOutside()
    {
        for (int y = 0; y < height; y++)
        {
            for (int x = 0; x < width; x++)
            {
                if (x == 0 || y == 0 || x == width - 1 || y == height - 1)
                {
                    maze[x, y] = WALL;
                }
            }
        }
    }

    void Dig(int _x, int _y)
    {
        while (true)
        {
            List directions = new List();
            if (maze[_x, _y - 1] == WALL && maze[_x, _y - 2] == WALL)
                directions.Add(DIRECTION.UP);
            if (maze[_x + 1, _y] == WALL && maze[_x + 2, _y] == WALL)
                directions.Add(DIRECTION.RIGHT);
            if (maze[_x, _y + 1] == WALL && maze[_x, _y + 2] == WALL)
                directions.Add(DIRECTION.DOWN);
            if (maze[_x - 1, _y] == WALL && maze[_x - 2, _y] == WALL)
                directions.Add(DIRECTION.LEFT);

            if (directions.Count == 0) break;

            SetPath(_x, _y);
            int directionIndex = rnd.Next(directions.Count);
            switch (directions[directionIndex])
            {
                case DIRECTION.UP:
                    SetPath(_x, --_y);
                    SetPath(_x, --_y);
                    break;
                case DIRECTION.RIGHT:
                    SetPath(++_x, _y);
                    SetPath(++_x, _y);
                    break;
                case DIRECTION.DOWN:
                    SetPath(_x, ++_y);
                    SetPath(_x, ++_y);
                    break;
                case DIRECTION.LEFT:
                    SetPath(--_x, _y);
                    SetPath(--_x, _y);
                    break;
            }
        }
        Cell cell = GetStartCell();
        if (cell != null)
        {
            Dig(cell.x, cell.y);
        }
    }
    void SetPath(int _x, int _y)
    {
        maze[_x, _y] = PATH;
        if (_x % 2 == 1 && _y % 2 == 1)
        {
            startCells.Add(new Cell() { x = _x, y = _y });
        }
    }

    Cell GetStartCell()
    {
        if (startCells.Count == 0) return null;
        int index = rnd.Next(startCells.Count);
        Cell startCell = startCells[index];
        startCells.RemoveAt(index);
        return startCell;
    }
    
    void PrintMaze()
    {
        for (int y = 0; y < height; y++)
        {
            string line = "";
            for (int x = 0; x < width; x++)
            {
                line += maze[x, y] == WALL ? "■" : "□";
            }
            Debug.Log(line);
        }
    }
    int MakeOddNumber(int value)
    {
        if(value % 2 == 0)
        {
            value += 1;
        }
        return value;
    }
}

public class Cell
{
    public int x, y;
}


続いてHierarchy上にGameObjectを作成しMazeとします。
同様にMazeスクリプトを作成しGameObject Mazeにアタッチしておきます。
ソースコードは以下の通りです。


using System;
using System.Collections.Generic;
using UnityEngine;

public class Maze : MonoBehaviour
{
    MazeCreator maze;
    public GameObject groundPrefab, wallPrefab;
    [SerializeField] int mapSizeX, mapSizeY;
    int width, height;
    float tileSize;
    Vector3 mapCenter;
    int[,] map;
    enum DIRECTION
    {
        UP,
        RIGHT,
        DOWN,
        LEFT
    }
    void Start()
    {

        maze = new MazeCreator(mapSizeX, mapSizeY);        
        map = maze.GenerateMaze();        
        width = map.GetLength(0);
        height = map.GetLength(1);
        PlaceTiles();
    }

    void PlaceTiles()
    {
        tileSize= groundPrefab.GetComponent().bounds.size.x;
        mapCenter = new Vector3(width * tileSize/ 2,height * tileSize/ 2 - (tileSize/ 2), 0);
        for (int y = 0; y < height; y++)
        {
            for (int x = 0; x < width; x++)
            {
                Vector3 position = GetWorldPositionFromCell(x, y);
                Instantiate(groundPrefab, position, Quaternion.Euler(0, 0, 0.0f), transform);
                Debug.Log(map[x, y]);
                if(map[x,y] == 1)
                {
                    Instantiate(wallPrefab, position, Quaternion.Euler(0, 0, 0.0f), transform);
                }
            }
        }
    }
    Vector3 GetWorldPositionFromCell(int x, int y)
    {
        Vector3 pos = new Vector3(x * tileSize, (height - 1 - y) * tileSize, 0) - mapCenter;
        return pos;
    }
}

そして壁と床のプレファブを作成したらMazeオブジェクトにアタッチし、Inspectorからマップサイズを指定して実行してみましょう。
このとき縦横いずれも5以上の数値にしてください。穴掘りができなくなります。

HierarchyとInspectorはこのようになっています。

それでは実行してみましょう。

とりあえず従来の穴掘り法によるマップが出来上がりました。
ここから1マスに上下左右の壁を表現させるように修正していきます。

MazeCreatorクラスの修正

それではまずMazeCreatorを修正していきます。
途中まではほぼ同じですが、必要なプロパティを追加し、GenerateMazeメソッド内で新たなメソッドを呼び出すことにします。
最初のマップデータをstring型の変数で格納するためのプロパティを作成しましょう。


public class MazeCreator
{
  /* 略 */    
    string[,] hexMaze { get; set; }
    public string[,] HexMaze { get { return hexMaze; } }

続いて作成したmapデータを16進数の文字列に変換します。
こうしておいたほうがマップデータをログで確認する際に桁が揃い読みやすくなります。
またマップデータをデータベースに保存しておきたい場合などデータサイズを小さくできるメリットがあります。
具体的にはMazeToHexというメソッドを作成しました。


public class MazeCreator
{
  /* 略 */    
    void MazeToHex()
    {

        hexMaze = new string[(width - 1) / 2, (height - 1) / 2];
        for (int y = 1; y < height - 1; y += 2)
        {
            for (int x = 1; x < width - 1; x += 2)
            {
                int cellValue = 0;
                // 上のセルを確認
                if (y > 0 && maze[y - 1, x] == WALL) cellValue += 1;
                // 右のセルを確認
                if (x < width - 1 && maze[y, x + 1] == WALL) cellValue += 2;
                // 下のセルを確認
                if (y < height - 1 && maze[y + 1, x] == WALL) cellValue += 4;
                // 左のセルを確認
                if (x > 0 && maze[y, x - 1] == WALL) cellValue += 8;
                hexMaze[(x - 1) / 2, (y - 1) / 2] = cellValue.ToString("X");
            }
        }
    }
  /* 略 */    

やっていることはシンプルで、マップの縦横でループし、各マス目のmazeデータから上下左右をチェックし、もしその方角に壁があるならcellValueに最初の方で決めたルールの値を足して行ってます。
hexMazeには奇数のセルデータを保持したいためhexMaze = new string[(width - 1) / 2, (height - 1) / 2];としています。
hexMazeはmazeで作ったマップデータの長さとは異なり、ループ内で隣のセル(上下左右)をチェックしています。
なので全てのmazeのマスをチェックし格納しておく必要はなく、1マス飛ばしでチェック&格納していけば良いのでこのようにしています。
widthとheightからマイナス1しているのは外周分を引き、利用可能なマスを計算しています。
ループの最後のhexMaze[(x - 1) / 2, (y - 1) / 2] = cellValue.ToString("X");も同じ理由でhexMazeのインデックスを指定し、cellValueを16進数の文字列に変換し代入しています。
続いてMazeクラスを修正していきます。

Mazeの修正

こちらも修正が必要になってきます。
まず壁のprefabを修正しないといけませんね。
一辺の薄い壁素材を作って回転させてもいいですし、上下左右の素材を作ってもいいと思います。
今回私は上下左右の素材を作りました。
合わせて壁の角になるような素材も作りましたので、そのPrefab用の変数も用意しておきます。
なのでwallPrefabは複数になりますので以下のようにしました。


public class Maze : MonoBehaviour
{
    MazeCreator maze;
    public GameObject groundPrefab,cornerPrefab;
    public GameObject[] wallPrafabs;
  /* 略 */    

続いてMazeCreatorクラスで生成した16進数のマップデータを受け取る用の変数と、それを元の10進数に戻す用の変数を用意します。


public class Maze : MonoBehaviour
{

    MazeCreator maze;
    public GameObject groundPrefab,cornerPrefab;
    public GameObject[] wallPrafabs;
  /* 略 */  
    string[,] hexMapData;//16進数用
    int[,] binaryMaze;//10進数用
  /* 略 */    

次に16進数から10進数に変換するメソッドを作ります。
ConvertHexToBinaryとしました。


public class Maze : MonoBehaviour
{
  /* 略 */  
   void ConvertHexToBinary()
    {
        for (int y = 0; y < height; y++)
        {
            for (int x = 0; x < width; x++)
            {
                string hexValue = hexMapData[x, y];
                binaryMaze[x, y] = Convert.ToInt32(hexValue, 16);
            }
        }
    }
  /* 略 */    

次にStart内を修正していきましょう。
具体的には

・hexMapDataの受け取り
・width,heightの設定変更
・10進数用の配列(binaryMaze)の初期化
・ConvertHexToBinaryの呼び出し

となります。


public class Maze : MonoBehaviour
{
  /* 略 */  
    void Start()
    {

        maze = new MazeCreator(mapSizeX, mapSizeY);
        maze.GenerateMaze();
        hexMapData = maze.HexMaze;
        width = hexMapData.GetLength(0);
        height = hexMapData.GetLength(1);        
        binaryMaze = new int[width,height];

        ConvertHexToBinary();
        PlaceTiles();
    }
  /* 略 */    

このようになりました。
続いてUnityEditorに戻りMazeオブジェクトに上下左右の壁Prefabとコーナー用Prefabをアタッチしておきます。
このときWallPrefabsの順番は上右下左の順番にアタッチしておくと管理が楽になります。

それではマップを描画するPlaceTilesメソッドを修正とプレファブを生成するメソッドを新たに作成していきましょう。
ソースコードはこのようになります。


public class Maze : MonoBehaviour
{
  /* 略 */  
    void PlaceTiles()
    {
        tileSize= groundPrefab.GetComponent().bounds.size.x;
        mapCenter = new Vector3(width * tileSize/ 2,height * tileSize/ 2 - (tileSize/ 2), 0);
        for (int y = 0; y < height; y++)
        {
            for (int x = 0; x < width; x++)
            {
                Vector3 position = GetWorldPositionFromCell(x, y);
                Instantiate(groundPrefab, position, Quaternion.Euler(0, 0, 0.0f), transform);

                int cellValue = binaryMaze[x, y];
                Instantiate(groundPrefab, position, Quaternion.Euler(0, 0, 0.0f), transform);
                Instantiate(cornerPrefab, position, Quaternion.Euler(0, 0, 0.0f), transform);
                if ((cellValue & 1) != 0) InstantiateWall(position, DIRECTION.UP);
                if ((cellValue & 2) != 0) InstantiateWall(position, DIRECTION.RIGHT);
                if ((cellValue & 4) != 0) InstantiateWall(position, DIRECTION.DOWN);
                if ((cellValue & 8) != 0) InstantiateWall(position, DIRECTION.LEFT);
            }
        }
    }
    void InstantiateWall(Vector3 postiosn, DIRECTION dir)
    {
        Instantiate(wallPrafabs[(int)dir], postiosn, Quaternion.Euler(0, 0, 0), transform);
    }
  /* 略 */    

このようになりました。
PlaceTilesメソッドのループ内で大きく変化があります。
特に以下の部分です。


if ((cellValue & 1) != 0) InstantiateWall(position, DIRECTION.UP);
if ((cellValue & 2) != 0) InstantiateWall(position, DIRECTION.RIGHT);
if ((cellValue & 4) != 0) InstantiateWall(position, DIRECTION.DOWN);
if ((cellValue & 8) != 0) InstantiateWall(position, DIRECTION.LEFT);

ここではビット演算を利用しています。
(cellValue & 1) != 0
例えば上記の場合では、cellValueと1のビット単位の論理積(AND)を計算しています。
1は2進数の4ビットパターン(4桁)では0001と表現されると冒頭で説明しました。
このパターンでは上に壁があるという意味でした。

なのでcellValue & 1 が != 0、すなわち、0ではない場合はcellValueには上の壁が存在するパターン0001であるとなります。
条件が一致している場合はInstantiateWallメソッドを実行します。
以下も同様でそれぞれの壁のパターンをチェックしています。
ビット演算は慣れるまでに時間がかかるかもしれません。私もそうでした。

ではここまで出来たら薄い壁のダンジョンが出来ているか実行してみましょう。

何度か繰り返しダンジョンを作成してみましたが、無事に薄い壁でマップが作られていることが確認出来ました。
これを使って迷路ゲームを作ってみるのも面白いかもしれません。

このUnityプロジェクトをGithubに公開しておきますので、もし参考にしたい方がいらっしゃればご自由にどうぞ。
ThinWallMaze(薄壁ダンジョン)

関連記事

最後までご覧頂いてありがとうございました。