地下城地圖圖塊生成演演算法

2022-12-10 18:01:44

一. 概述

生成地下城,包含房間和迷宮通路。類似:

  1. 範例效果一
  2. 範例效果二

二. 思路

1.生成迷宮通路

  • 從房間的邊緣座標XY為奇數的格子生成迷宮,確保房間和迷宮通路之間有間隔牆壁(除了藍色格子視為牆壁)。
  • 迷宮通路生長每次探測兩個格子,確保迷宮通路間有間隔牆壁。

2.生成過程

三. 程式碼範例

位置結構體

public struct Vec2
{
    public int x;
    public int y;

    public Vec2(int x, int y)
    {
        this.x = x;
        this.y = y;
    }

    public static Vec2 Zero
    {
        get
        {
            return new Vec2(0, 0);
        }
    }

    public override bool Equals(object obj)
    {
        if (!(obj is Vec2))
            return false;

        var o = (Vec2)obj;
        return x == o.x && y == o.y;
    }

    public override int GetHashCode()
    {
        return x.GetHashCode() + y.GetHashCode();
    }

    public static Vec2 operator+ (Vec2 a, Vec2 b)
    {
        return new Vec2(a.x + b.x, a.y + b.y);
    }

    public static Vec2 operator* (Vec2 a, int n)
    {
        return new Vec2(a.x * n, a.y * n);
    }

    public static Vec2 operator* (int n, Vec2 a)
    {
        return new Vec2(a.x * n, a.y * n);
    }

    public static bool operator== (Vec2 a, Vec2 b)
    {
        return a.x == b.x && a.y == b.y;
    }

    public static bool operator !=(Vec2 a, Vec2 b)
    {
        return !(a.x == b.x && a.y == b.y);
    }
}

房間,區域的定義

public struct Region
    {
        public int id;
        public List<Vec2> positions;

        public Region(int id)
        {
            this.id = id;
            positions = new List<Vec2>();
        }
    }

    struct Room
    {
        public Rect rect;

        public List<Vec2> borders;
        
        public Room(int x, int y, int w, int h)
        {
            rect = new Rect(x, y, w, h);

            borders = new List<Vec2>();

            for (int i = x; i <= x + w; i++)
                for (int j = y; j <= y + h; j++)
                    if (!(i > x && x < x + w && j > y && j < y + h))
                        borders.Add(new Vec2(i, j));
        }
    }
  • Room中的格子,計算生成迷宮和嘗試連線區域,只需要計算房間邊緣的格子即borders。

生成演演算法上下文

struct Context
    {
        public Vec2 size;
        public int roomCnt;
        public int windingPct;
        public int roomSizeOff;


        public int[,] map;
        public List<Region> regions;
        public List<Room> rooms;
        public int regionIDIdx;
    }
  • size 地圖的大小,由輸入地圖大小裁剪成長寬為奇數的大小。
  • roomCnt 預期生成地圖的數量。
  • windingPct 地圖的蜿蜒程度最大為100 注意:即使為0,地圖的蜿蜒程度一般也會比較大。
  • roomSizeOff 房間長或寬的數值正負偏移
  • map 記錄地圖土塊的型別索引
  • regions 地區列表
  • room 房間列表
  • 地區ID索引計數

生長方向列舉


public enum EDir
{
    Up = 0,
    Down = 1,
    Left = 2,
    Right = 3,
}

static Dictionary<EDir, Vec2> dirDict = new Dictionary<EDir, Vec2> 
    {
        {EDir.Up, new Vec2(0, 1) },
        {EDir.Down, new Vec2(0, -1) },
        {EDir.Left, new Vec2(-1, 0) },
        {EDir.Right, new Vec2(1, 0) },
    };

初始化上下文&裁剪地圖

Context context;

    public void Init(int w, int h, int roomCnt, int windingPct, int roomSizeOff)
    {
        int x = w - w % 2 - 2 - 1;
        int y = h - h % 2 - 2 - 1;
        context = new Context()
        {
            size = new Vec2(x, y),
            roomCnt = roomCnt,
            windingPct = windingPct,
            roomSizeOff = roomSizeOff,

            map = new int[x, y],
            regions = new List<Region>(),
            rooms = new List<Room>(),
            regionIDIdx = -1,
        };
    }
  • 地圖的長寬都被裁剪成奇數,並內縮一個單位。

生成地圖房間

void GenRooms()
    {
        for (int i = 0; i < context.roomCnt;)
        {
            var size = R.Range(1, 3 + context.roomSizeOff) * 2 + 1;
            var off = R.Range(0, 1 + size / 2) * 2;
            var w = size;
            var h = size;

            if (R.Range(0, 2) > 0)
                w += off;
            else
                h += off;

            var x = R.Range(0, (context.size.x - w) / 2) * 2 ;
            var y = R.Range(0, (context.size.y - h) / 2) * 2 ;

            var rect = new Rect(x, y, w, h);

            bool isOverlap = false;
            foreach (var room in context.rooms)
                if (rect.Overlaps(room.rect))
                {
                    isOverlap = true;
                    break;
                }

            if (isOverlap)
                continue;

            ++i;
            GrowRegion();
            for (int m = x; m < x + w; m++)
                for (int n = y; n < y + h; n++)
                    Carve(m, n);

            context.rooms.Add(new Room(x, y, w, h));
        }
    }
  • 根據roomCnt引數生成房間
  • 隨機生成房間的大小和位置
  • 若是新生成的房間和其他的房間重合則重新生成
  • 生成房間的位置都是奇數,所以每個房間之上隔一個單位

生成迷宮通路

void GenMaze()
    {
        foreach (var room in context.rooms)
            foreach (var pos in room.borders)
                if (pos.x % 2 == 0 && pos.y % 2 == 0)
                    GrowMaze(pos);
    }

    void GrowMaze(Vec2 pos)
    {
        Vec2 forward = Vec2.Zero;
        var stack = new Stack<Vec2>();
        stack.Push(pos);
        GrowRegion();
        bool isStart = true;
        while (stack.Count > 0)
        {
            var p = stack.Pop();
            List<Vec2> dirs = new List<Vec2>();
            foreach (var pair in dirDict)
            {
                var dir = pair.Value;
                if (CanMazeGrow(p, dir))
                    dirs.Add(dir);
            }

            if (dirs.Count > 0)
            {
                if (!(dirs.Contains(forward) && R.Range(0, 100) < context.windingPct))
                    forward = dirs[R.Range(0, dirs.Count)];

                if (isStart)
                    isStart = false;
                else
                    Carve(p + forward);

                Carve(p + 2 * forward);
                stack.Push(p + 2 * forward);
            }
            else
                forward = Vec2.Zero;
        }
        TryShrinkRegion();
    }

	bool CanMazeGrow(Vec2 pos, Vec2 dir)
    {
        return CanCarve(pos + dir) && CanCarve(pos + 2 * dir);
    }

    bool CanCarve(Vec2 pos)
    {
        return CanCarve(pos.x, pos.y);
    }

    bool CanCarve(int x, int y)
    {
        return InMap(x, y) && context.map[x, y] == 0;
    }

    bool InMap(Vec2 pos)
    {
        return InMap(pos.x, pos.y);
    }

    bool InMap(int x, int y)
    {
        var size = context.size;
        return x >= 0 && x < size.x && y >= 0 && y < size.y;
    }

    void Carve(Vec2 pos, int ty = 1)
    {
        Carve(pos.x, pos.y, ty);
    }

    void Carve(int x, int y, int ty = 1)
    {
        context.map[x, y] = ty;
        context.regions[context.regionIDIdx].positions.Add(new Vec2(x, y));
    }
  • 從每個房間的邊緣XY為奇數嘗試生長迷宮通路
  • 遞迴的生長迷宮向四周探測兩個單位,符合條件則向那個方向生長兩個單位,直到不能生長
  • 若至少生長了一次,則形成一個迷宮通路區域
  • 第一次生長時,為了避免後面影響連線的計算,第一次的第一個單位填充被忽略

連線區域

void Connect()
    {
        for (int i = 0; i < context.rooms.Count; i++)
        {
            context.regions[i].positions.Clear();
            foreach (var pos in context.rooms[i].borders)
                context.regions[i].positions.Add(pos);
        }

        Dictionary<int, Region> dict = new Dictionary<int, Region>();
        for (int i = 0; i < context.regions.Count; i++)
            dict[i] = context.regions[i];

        var idxs = new List<int>();
        while (dict.Count > 1)
        {
            idxs.Clear();
            foreach (var pair in dict)
                idxs.Add(pair.Key);

            var dest = idxs[idxs.Count - 1];
            idxs.RemoveAt(idxs.Count - 1);

            bool isMerge = false;
            foreach (var idx in idxs)
            {
                var connectPos = Vec2.Zero;
                if (TryConnectRegion(dict[dest], dict[idx], out connectPos))
                {
                    GrowRegion();
                    dict[context.regionIDIdx] = context.regions[context.regionIDIdx];
                    foreach (var pos in dict[dest].positions)
                        dict[context.regionIDIdx].positions.Add(pos);

                    foreach (var pos in dict[idx].positions)
                        dict[context.regionIDIdx].positions.Add(pos);

                    Carve(connectPos);
                    dict.Remove(dest);
                    dict.Remove(idx);
                    isMerge = true;
                    break;
                }
            }

            if (!isMerge)
            {
                Debug.Log("Region Merge Failed!");
                return;
            }
        }
    }
	
	bool TryConnectRegion(Region a, Region b, out Vec2 connectPos)
    {
        for (int i = 0; i < a.positions.Count; i++)
        {
            var ap = a.positions[i];
            if (ap.x % 2 == 0 && ap.y % 2 == 0)
                for (int j = 0; j < b.positions.Count; j++)
                {
                    var bp = b.positions[j];
                    if (bp.x % 2 == 0 && bp.y % 2 == 0)
                        if (ap.y == bp.y)
                        {
                            if (ap.x - bp.x == 2)
                            {
                                connectPos = ap + new Vec2(-1, 0);
                                return true;
                            }

                            if (ap.x - bp.x == -2)
                            {
                                connectPos = ap + new Vec2(1, 0);
                                return true;
                            }
                        }
                        else if (ap.x == bp.x)
                        {
                            if (ap.y - bp.y == 2)
                            {
                                connectPos = ap + new Vec2(0, -1);
                                return true;
                            }

                            if (ap.y - bp.y == -2)
                            {
                                connectPos = ap + new Vec2(0, 1);
                                return true;
                            }
                        }
                }
        }

        connectPos = Vec2.Zero;
        return false;
    }
  • 遞迴的對區域進行兩兩合併
  • 兩個區域若是各自存在一個格子,若是這兩個格子的距離為2個單位則這兩個單位可通過Lerp(格子a,格子b,0.5)這個格子連線,這兩個區域合併成為一個區域。
  • 若是有需要可以判斷兩個區域中是否有房間,可以把這個格子作為"門"之類的東西。

裁剪迷宮通路

void CutMaze()
    {
        var region = context.regions[context.regionIDIdx];
        List<Vec2> dirs = new List<Vec2>();
        foreach (var pos in region.positions)
            CutMazeArrangement(pos);
    }

    void CutMazeArrangement(Vec2 pos)
    {
        if (context.map[pos.x, pos.y] == 0)
            return;

        List<Vec2> dirs = new List<Vec2>();
        foreach (var pair in dirDict)
        {
            var dir = pair.Value;
            var test = pos + dir;
            if (!InMap(test))
                continue;
            if (context.map[test.x, test.y] == 1)
                dirs.Add(test);
        }

        if (dirs.Count == 1)
        {
            context.map[pos.x, pos.y] = 0;
            CutMazeArrangement(dirs[0]);
        }
    }
  • 遞迴的檢查所有迷宮通路的格子,若是此格子的連通方向只有一個,則消除此格子。

完整程式碼

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using R = UnityEngine.Random;

public struct Vec2
{
    public int x;
    public int y;

    public Vec2(int x, int y)
    {
        this.x = x;
        this.y = y;
    }

    public static Vec2 Zero
    {
        get
        {
            return new Vec2(0, 0);
        }
    }

    public override bool Equals(object obj)
    {
        if (!(obj is Vec2))
            return false;

        var o = (Vec2)obj;
        return x == o.x && y == o.y;
    }

    public override int GetHashCode()
    {
        return x.GetHashCode() + y.GetHashCode();
    }

    public static Vec2 operator+ (Vec2 a, Vec2 b)
    {
        return new Vec2(a.x + b.x, a.y + b.y);
    }

    public static Vec2 operator* (Vec2 a, int n)
    {
        return new Vec2(a.x * n, a.y * n);
    }

    public static Vec2 operator* (int n, Vec2 a)
    {
        return new Vec2(a.x * n, a.y * n);
    }

    public static bool operator== (Vec2 a, Vec2 b)
    {
        return a.x == b.x && a.y == b.y;
    }

    public static bool operator !=(Vec2 a, Vec2 b)
    {
        return !(a.x == b.x && a.y == b.y);
    }

    
}


public enum EDir
{
    Up = 0,
    Down = 1,
    Left = 2,
    Right = 3,
}

public class DungeonBuild
{
    public struct Region
    {
        public int id;
        public List<Vec2> positions;

        public Region(int id)
        {
            this.id = id;
            positions = new List<Vec2>();
        }
    }

    struct Room
    {
        public Rect rect;

        public List<Vec2> borders;
        
        public Room(int x, int y, int w, int h)
        {
            rect = new Rect(x, y, w, h);

            borders = new List<Vec2>();

            for (int i = x; i <= x + w; i++)
                for (int j = y; j <= y + h; j++)
                    if (!(i > x && x < x + w && j > y && j < y + h))
                        borders.Add(new Vec2(i, j));
        }
    }


    struct Context
    {
        public Vec2 size;
        public int roomCnt;
        public int windingPct;
        public int roomSizeOff;


        public int[,] map;
        public List<Region> regions;
        public List<Room> rooms;
        public int regionIDIdx;
    }

    static Dictionary<EDir, Vec2> dirDict = new Dictionary<EDir, Vec2> 
    {
        {EDir.Up, new Vec2(0, 1) },
        {EDir.Down, new Vec2(0, -1) },
        {EDir.Left, new Vec2(-1, 0) },
        {EDir.Right, new Vec2(1, 0) },
    };
    

    Context context;

    public void Init(int w, int h, int roomCnt, int windingPct, int roomSizeOff)
    {
        int x = w - w % 2 - 2 - 1;
        int y = h - h % 2 - 2 - 1;
        context = new Context()
        {
            size = new Vec2(x, y),
            roomCnt = roomCnt,
            windingPct = windingPct,
            roomSizeOff = roomSizeOff,

            map = new int[x, y],
            regions = new List<Region>(),
            rooms = new List<Room>(),
            regionIDIdx = -1,
        };
    }

    public void Build()
    {
        GenRooms();

        GenMaze();

        Connect();

        CutMaze();
    }

    public int[,] GetResult()
    {
        return context.map;
    }

    public Vector2 GetSize()
    {
        return new Vector2(context.size.x, context.size.y);
    }

    void GrowRegion()
    {
        context.regionIDIdx++;
        context.regions.Add(new Region(context.regionIDIdx));
    }

    void TryShrinkRegion()
    {
        if (context.regions[context.regionIDIdx].positions.Count == 0)
        {
            context.regions.RemoveAt(context.regionIDIdx);
            context.regionIDIdx--;
        }
    }

    void GenRooms()
    {
        for (int i = 0; i < context.roomCnt;)
        {
            var size = R.Range(1, 3 + context.roomSizeOff) * 2 + 1;
            var off = R.Range(0, 1 + size / 2) * 2;
            var w = size;
            var h = size;

            if (R.Range(0, 2) > 0)
                w += off;
            else
                h += off;

            var x = R.Range(0, (context.size.x - w) / 2) * 2 ;
            var y = R.Range(0, (context.size.y - h) / 2) * 2 ;

            var rect = new Rect(x, y, w, h);

            bool isOverlap = false;
            foreach (var room in context.rooms)
                if (rect.Overlaps(room.rect))
                {
                    isOverlap = true;
                    break;
                }

            if (isOverlap)
                continue;

            ++i;
            GrowRegion();
            for (int m = x; m < x + w; m++)
                for (int n = y; n < y + h; n++)
                    Carve(m, n);

            context.rooms.Add(new Room(x, y, w, h));
        }
    }

    void GenMaze()
    {
        foreach (var room in context.rooms)
            foreach (var pos in room.borders)
                if (pos.x % 2 == 0 && pos.y % 2 == 0)
                    GrowMaze(pos);
    }

    void GrowMaze(Vec2 pos)
    {
        Vec2 forward = Vec2.Zero;
        var stack = new Stack<Vec2>();
        stack.Push(pos);
        GrowRegion();
        bool isStart = true;
        while (stack.Count > 0)
        {
            var p = stack.Pop();
            List<Vec2> dirs = new List<Vec2>();
            foreach (var pair in dirDict)
            {
                var dir = pair.Value;
                if (CanMazeGrow(p, dir))
                    dirs.Add(dir);
            }

            if (dirs.Count > 0)
            {
                if (!(dirs.Contains(forward) && R.Range(0, 100) < context.windingPct))
                    forward = dirs[R.Range(0, dirs.Count)];

                if (isStart)
                    isStart = false;
                else
                    Carve(p + forward);

                Carve(p + 2 * forward);
                stack.Push(p + 2 * forward);
            }
            else
                forward = Vec2.Zero;
        }
        TryShrinkRegion();
    }

    void Connect()
    {
        for (int i = 0; i < context.rooms.Count; i++)
        {
            context.regions[i].positions.Clear();
            foreach (var pos in context.rooms[i].borders)
                context.regions[i].positions.Add(pos);
        }

        Dictionary<int, Region> dict = new Dictionary<int, Region>();
        for (int i = 0; i < context.regions.Count; i++)
            dict[i] = context.regions[i];

        var idxs = new List<int>();
        while (dict.Count > 1)
        {
            idxs.Clear();
            foreach (var pair in dict)
                idxs.Add(pair.Key);

            var dest = idxs[idxs.Count - 1];
            idxs.RemoveAt(idxs.Count - 1);

            bool isMerge = false;
            foreach (var idx in idxs)
            {
                var connectPos = Vec2.Zero;
                if (TryConnectRegion(dict[dest], dict[idx], out connectPos))
                {
                    GrowRegion();
                    dict[context.regionIDIdx] = context.regions[context.regionIDIdx];
                    foreach (var pos in dict[dest].positions)
                        dict[context.regionIDIdx].positions.Add(pos);

                    foreach (var pos in dict[idx].positions)
                        dict[context.regionIDIdx].positions.Add(pos);

                    Carve(connectPos);
                    dict.Remove(dest);
                    dict.Remove(idx);
                    isMerge = true;
                    break;
                }
            }

            if (!isMerge)
            {
                Debug.Log("Region Merge Failed!");
                return;
            }
        }
    }

    bool TryConnectRegion(Region a, Region b, out Vec2 connectPos)
    {
        for (int i = 0; i < a.positions.Count; i++)
        {
            var ap = a.positions[i];
            if (ap.x % 2 == 0 && ap.y % 2 == 0)
                for (int j = 0; j < b.positions.Count; j++)
                {
                    var bp = b.positions[j];
                    if (bp.x % 2 == 0 && bp.y % 2 == 0)
                        if (ap.y == bp.y)
                        {
                            if (ap.x - bp.x == 2)
                            {
                                connectPos = ap + new Vec2(-1, 0);
                                return true;
                            }

                            if (ap.x - bp.x == -2)
                            {
                                connectPos = ap + new Vec2(1, 0);
                                return true;
                            }
                        }
                        else if (ap.x == bp.x)
                        {
                            if (ap.y - bp.y == 2)
                            {
                                connectPos = ap + new Vec2(0, -1);
                                return true;
                            }

                            if (ap.y - bp.y == -2)
                            {
                                connectPos = ap + new Vec2(0, 1);
                                return true;
                            }
                        }
                }
        }

        connectPos = Vec2.Zero;
        return false;
    }

    void CutMaze()
    {
        var region = context.regions[context.regionIDIdx];
        List<Vec2> dirs = new List<Vec2>();
        foreach (var pos in region.positions)
            CutMazeArrangement(pos);
    }

    void CutMazeArrangement(Vec2 pos)
    {
        if (context.map[pos.x, pos.y] == 0)
            return;

        List<Vec2> dirs = new List<Vec2>();
        foreach (var pair in dirDict)
        {
            var dir = pair.Value;
            var test = pos + dir;
            if (!InMap(test))
                continue;
            if (context.map[test.x, test.y] == 1)
                dirs.Add(test);
        }

        if (dirs.Count == 1)
        {
            context.map[pos.x, pos.y] = 0;
            CutMazeArrangement(dirs[0]);
        }
    }

    bool CanMazeGrow(Vec2 pos, Vec2 dir)
    {
        return CanCarve(pos + dir) && CanCarve(pos + 2 * dir);
    }

    bool CanCarve(Vec2 pos)
    {
        return CanCarve(pos.x, pos.y);
    }

    bool CanCarve(int x, int y)
    {
        return InMap(x, y) && context.map[x, y] == 0;
    }

    bool InMap(Vec2 pos)
    {
        return InMap(pos.x, pos.y);
    }

    bool InMap(int x, int y)
    {
        var size = context.size;
        return x >= 0 && x < size.x && y >= 0 && y < size.y;
    }

    void Carve(Vec2 pos, int ty = 1)
    {
        Carve(pos.x, pos.y, ty);
    }

    void Carve(int x, int y, int ty = 1)
    {
        context.map[x, y] = ty;
        context.regions[context.regionIDIdx].positions.Add(new Vec2(x, y));
    }
}