Procedural Map Generation(随机地图生成)

闲来无事,到Unity官网上看看有没有好玩的东西,然后就看到了这个自动生成随机地图的教程。话说暗黑破坏神还有很多Roguelike的游戏都是随机生成地图的方式,因为要不停刷刷刷,随机生成方式能让人觉得不无聊。但是具体是怎么做的我却不知道,于是决定好好看看这个案例。例子虽然简单一些是2D的,但是相信原理是相通的。

官方教程地址
官方教程分为了9小结,我这里就之间按顺序写下去不分了。
第一个教程十分简单,就是使用一个脚本,用随机二维数组的方式生成一个地图,0表示可以移动区域,用Gizmos画白色方框表示,1表示不可移动区域,用黑色方块表示,并且点击鼠标可以生成一个新地图。

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

public class MapGenerator : MonoBehaviour
{

    public int width; 
    public int height;

    public string seed; //使用时间作为seed生成随机数
    public bool useRandomSeed; //是否使用随机地图

    [Range(0, 100)]
    public int randomFillPercent; //地图中不可以移动区域的比例

    int[,] map;

    void Start()
    {
        GenerateMap();
    }

    void Update()
    {
        if (Input.GetMouseButtonDown(0)) //按键生成新地图
        {
            GenerateMap();
        }
    }

    void GenerateMap()
    {
        map = new int[width, height];
        RandomFillMap();

        for (int i = 0; i < 5; i++)
        {
            SmoothMap(); //迭代五次来使地图更平滑
        }
    }
    void RandomFillMap()
    {
        if (useRandomSeed)
        {
            seed = Time.time.ToString();
        }

        System.Random pseudoRandom = new System.Random(seed.GetHashCode()); //使用时间的hashcode作为随机数

        for (int x = 0; x < width; x++)
        {
            for (int y = 0; y < height; y++)
            {
                if (x == 0 || x == width - 1 || y == 0 || y == height - 1)
                {
                    map[x, y] = 1;  //地图边缘设为墙壁
                }
                else
                {
                    map[x, y] = (pseudoRandom.Next(0, 100) < randomFillPercent) ? 1 : 0; //Next(0, 100)可以设置随机数范围。这里按照randomFillPercent设置墙壁和空地
                }
            }
        }
    }
    void SmoothMap() //根据周围八块地的墙壁块数来smooth这个地图
    {
        for (int x = 0; x < width; x++)
        {
            for (int y = 0; y < height; y++)
            {
                int neighbourWallTiles = GetSurroundingWallCount(x, y);

                if (neighbourWallTiles > 4) //大于4块则这块为墙
                    map[x, y] = 1;
                else if (neighbourWallTiles < 4) //小于4块则这块为地面,注意最好不要设置等于4的情况,这样会导致地图就边缘是墙,中间是空地,缺少变化有些单调,不设置4可以让地图内部也出现墙壁
                    map[x, y] = 0;

            }
        }
    }
    int GetSurroundingWallCount(int gridX, int gridY)
    {
        int wallCount = 0;
        for (int neighbourX = gridX - 1; neighbourX <= gridX + 1; neighbourX++)  
        {
            for (int neighbourY = gridY - 1; neighbourY <= gridY + 1; neighbourY++) 
            {
                if (neighbourX >= 0 && neighbourX < width && neighbourY >= 0 && neighbourY < height)//只有非边缘才计算,边缘地块都设置为墙
                {
                    if (neighbourX != gridX || neighbourY != gridY)
                    {
                        wallCount += map[neighbourX, neighbourY];
                    }
                }
                else
                {
                    wallCount++;
                }
            }
        }

        return wallCount;
    }
    void OnDrawGizmos()
    {
        if (map != null)
        {
            for (int x = 0; x < width; x++)
            {
                for (int y = 0; y < height; y++)
                {
                    Gizmos.color = (map[x, y] == 1) ? Color.black : Color.white;
                    Vector3 pos = new Vector3(-width / 2 + x + .5f, 0, -height / 2 + y + .5f);
                    Gizmos.DrawCube(pos, Vector3.one);
                }
            }
        }
    }

}

以下为运行截图:


Capture.PNG

注意游戏视窗需要选中Gizmos,否则无法正常显示地图,fill percent为45到50值的时候地图看起来比较好。
如果SmoothMap()的时候使用了大于等于或者小于等4,则地图看下来像这样:


Capture.PNG

明显不太好。

第一个小结里面给出了生成地图形状的方法,但是它还只是一个形状,没有mesh。第二节里面介绍了随机地图的自定义结构。第一节里面每画出的一个方块,第二节里面定义了一个叫做Square的类来表示它,所有的方块(Squares)放在一个SquareGrid类里面。
每个Square包含了8个node,分别是topLeft, topRight, bottomRight, bottomLeft 4个ControlNodes和centreTop, centreRight, centreBottom, centreLeft 4个Nodes。这8个Nodes会在之后生成mesh的时候用到,并且他们可以用来表示16种mesh的组合,具体原理和完整源代码可以从这里了解,这里我简单解释一下。
第一节里面讲到每个方块状态是1则表示是墙,0则表示不是墙,现在想象把每个方块缩小,则每个方块实际上表示一个方形的一角。


下面来看这个图



它相当于把一个方形放大来看,1,2,3,4四个角分别是缩小的方块,它相当于一个开关,1表示是墙,0表示不是墙,每个角代表4位数字的一位,所有角为0时这个四位数字是0,全是1时表示15. 这四个node就是之前说到的ControlNodes。每两个ControlNode中间有一个node,它们是用来构建mesh的。




0000到1111共16种组合可以代表一个确定的mesh。下面我直接把16种mesh组合列举出来。
















可以看到由ControlNode和Node顺时针构建的三角形带形成了独一无二的mesh,没种mesh对应一个数字编号。下面来看代码,我们是如何实现这Square和SquareGrid的。
首先来看Node类和ControlNode类

    public class Node
    {
        public Vector3 position;
        public int vertexIndex = -1;

        public Node(Vector3 _pos)
        {
            position = _pos;
        }
    }
    public class ControlNode : Node
    {
        public bool isWall;
        public Node above, right;
        public ControlNode(Vector3 _pos, bool _isWall, float squareSize) :base(_pos)
        {
            isWall = _isWall;
            above = new Node(position + Vector3.forward * squareSize / 2f);
            right = new Node(position + Vector3.right * squareSize / 2f);
        }
    }

Node很简单,只包含位置信息,ControlNode继承自Node,除了位置信息外,还包含了它右侧和上放两个node的信息。
接下来是Square类,每个square有4个ControlNode和4个node

    public class Square
    {
        public ControlNode topLeft, topRight, bottomLeft, bottomRight;
        public Node centerTop, centerBottom, centerLeft, centerRight;

        public Square(ControlNode _topLeft, ControlNode _topRight, ControlNode _bottomLeft, ControlNode _bottomRight)
        {
            topLeft = _topLeft;
            topRight = _topRight;
            bottomLeft = _bottomLeft;
            bottomRight = _bottomRight;

            centerTop = topLeft.right;
            centerLeft = bottomLeft.above;
            centerBottom = bottomLeft.right;
            centerRight = bottomRight.above;
        }
    }

然后是SquareGrid(即整个网格)类的初始化过程,它读取MapGenerator的map信息(数组大小以及是否为墙),构建整个网格。首先循环一遍数组初始化所有的ControlNodes,然后遍历每一个Square(注意SquareGrid的宽和高分别为ControlNodes的宽高减一),为每一个square设置它包含的CotrolNodes。(注意Nodes已经在初始化ControlNodes的时候完成了,因为每个ControlNode管两个node)

public class SquareGrid
    {
        public Square[,] squares;
        public SquareGrid(int[,] map, float squareSize)
        {
            int nodeCountX = map.GetLength(0);
            int nodeCountY = map.GetLength(1);
            float mapWidth = nodeCountX * squareSize;
            float mapHeight = nodeCountY * squareSize;
            ControlNode[,] controlNodes = new ControlNode[nodeCountX, nodeCountY];
            for (int x = 0; x < nodeCountX; x++)
            {
                for (int y = 0; y < nodeCountY; y++)
                {
                    Vector3 position = new Vector3(-1 * mapWidth / 2 + squareSize * x + squareSize / 2f, 0, -1 * mapHeight / 2 + squareSize * y + squareSize / 2f);
                    controlNodes[x, y] = new ControlNode(position, map[x, y] == 1, squareSize);
                }

            }
            squares = new Square[nodeCountX - 1, nodeCountY - 1];
            for (int x = 0; x < nodeCountX - 1; x++)
            {
                for (int y = 0; y < nodeCountY - 1; y++)
                {
                    squares[x, y] = new Square(controlNodes[x, y + 1], controlNodes[x + 1, y + 1], controlNodes[x, y], controlNodes[x + 1, y]);
                }
            }
        }
    }

创建一个方法初始化SquareGrid,并在MapGenerator中调用会这个方法。

    public void GenerateMesh(int[,] map, float squareSize)
    {
        squareGrid = new SquareGrid(map, squareSize);
    }
    void GenerateMap()
    {
        map = new int[width, height];
        RandomFillMap();

        for (int i = 0; i < 5; i++)
        {
            SmoothMap(); //迭代五次来使地图更平滑
        }

        MeshGenerator meshGen = GetComponent();
        meshGen.GenerateMesh(map, 1);
    }

最后使用OnDrawGizmos()把我们生成的网格结构画出来(记得注释掉MapGenerator里面的OnDrawGizmos())

void OnDrawGizmos()
    {
        if (squareGrid != null)
        {
            for (int x = 0; x < squareGrid.squares.GetLength(0); x++)
            {
                for (int y = 0; y < squareGrid.squares.GetLongLength(1); y++)
                {
                    Gizmos.color = (squareGrid.squares[x, y].topLeft.isWall) ? Color.black : Color.white;
                    Gizmos.DrawCube(squareGrid.squares[x, y].topLeft.position, Vector3.one * 0.4f);

                    Gizmos.color = (squareGrid.squares[x, y].topRight.isWall) ? Color.black : Color.white;
                    Gizmos.DrawCube(squareGrid.squares[x, y].topRight.position, Vector3.one * 0.4f);

                    Gizmos.color = (squareGrid.squares[x, y].bottomLeft.isWall) ? Color.black : Color.white;
                    Gizmos.DrawCube(squareGrid.squares[x, y].bottomLeft.position, Vector3.one * 0.4f);

                    Gizmos.color = (squareGrid.squares[x, y].bottomRight.isWall) ? Color.black : Color.white;
                    Gizmos.DrawCube(squareGrid.squares[x, y].bottomRight.position, Vector3.one * 0.4f);
                
                    Gizmos.color = Color.gray;
                    Gizmos.DrawCube(squareGrid.squares[x, y].centerBottom.position, Vector3.one * 0.15f);
                    Gizmos.DrawCube(squareGrid.squares[x, y].centerLeft.position, Vector3.one * 0.15f);
                    Gizmos.DrawCube(squareGrid.squares[x, y].centerTop.position, Vector3.one * 0.15f);
                    Gizmos.DrawCube(squareGrid.squares[x, y].centerRight.position, Vector3.one * 0.15f);
                }
            }
        }
    }

以下是运行截图



网格结构已经搭建好了,下一节就是生成Mesh了


前面我们已经说过,一个square分成了16种情况代表不同的mesh,我们说每一种mesh是一个configuration,所以我们要往Square类里面添加一个configuration属性并把它初始化。

  public class Square {

        public ControlNode topLeft, topRight, bottomRight, bottomLeft;
        public Node centreTop, centreRight, centreBottom, centreLeft;
        public int configuration;

        public Square (ControlNode _topLeft, ControlNode _topRight, ControlNode _bottomRight, ControlNode _bottomLeft) {
            topLeft = _topLeft;
            topRight = _topRight;
            bottomRight = _bottomRight;
            bottomLeft = _bottomLeft;

            centreTop = topLeft.right;
            centreRight = bottomRight.above;
            centreBottom = bottomLeft.right;
            centreLeft = bottomLeft.above;

            if (topLeft.active)
                configuration += 8;
            if (topRight.active)
                configuration += 4;
            if (bottomRight.active)
                configuration += 2;
            if (bottomLeft.active)
                configuration += 1;
        }
    }

接下来对于每一个square,我们需要把它的三角形网格生成出来。Unity生成Mesh需要用到两个component,分别是MeshFilter和MeshRenderer。MeshFilter需要指定一个Mesh,我们需要设定这个Mesh的Vertices和Triangles,这两个分别是顶点的坐标和三角形的顶点索引,由顶点索引找到坐标,3个坐标就是一个三角形了。
更新的GenerateMesh()如下

    public void GenerateMesh(int[,] map, float squareSize) {
        squareGrid = new SquareGrid(map, squareSize);

        vertices = new List();
        triangles = new List();

        for (int x = 0; x < squareGrid.squares.GetLength(0); x ++) {
            for (int y = 0; y < squareGrid.squares.GetLength(1); y ++) {
                TriangulateSquare(squareGrid.squares[x,y]);
            }
        }

        Mesh mesh = new Mesh();
        GetComponent().mesh = mesh;

        mesh.vertices = vertices.ToArray();
        mesh.triangles = triangles.ToArray();
        mesh.RecalculateNormals();
    }

那么vertices和triangles是如何得到的呢?我们看到在循环内对于每一个square都调用了TriangulateSquare(),这个方法会根据square的configuration来调用MeshFromPoints方法,这个方法根据传入的多个node,把这些node的坐标添加到vertices数组,然后把这些nodes编队成不同的三角形。
先来看TriangulateSquare

   void TriangulateSquare(Square square) {
        switch (square.configuration) {
        case 0:
            break;

        // 1 points:
        case 1:
            MeshFromPoints(square.centreBottom, square.bottomLeft, square.centreLeft);
            break;
        case 2:
            MeshFromPoints(square.centreRight, square.bottomRight, square.centreBottom);
            break;
        case 4:
            MeshFromPoints(square.centreTop, square.topRight, square.centreRight);
            break;
        case 8:
            MeshFromPoints(square.topLeft, square.centreTop, square.centreLeft);
            break;

        // 2 points:
        case 3:
            MeshFromPoints(square.centreRight, square.bottomRight, square.bottomLeft, square.centreLeft);
            break;
        case 6:
            MeshFromPoints(square.centreTop, square.topRight, square.bottomRight, square.centreBottom);
            break;
        case 9:
            MeshFromPoints(square.topLeft, square.centreTop, square.centreBottom, square.bottomLeft);
            break;
        case 12:
            MeshFromPoints(square.topLeft, square.topRight, square.centreRight, square.centreLeft);
            break;
        case 5:
            MeshFromPoints(square.centreTop, square.topRight, square.centreRight, square.centreBottom, square.bottomLeft, square.centreLeft);
            break;
        case 10:
            MeshFromPoints(square.topLeft, square.centreTop, square.centreRight, square.bottomRight, square.centreBottom, square.centreLeft);
            break;

        // 3 point:
        case 7:
            MeshFromPoints(square.centreTop, square.topRight, square.bottomRight, square.bottomLeft, square.centreLeft);
            break;
        case 11:
            MeshFromPoints(square.topLeft, square.centreTop, square.centreRight, square.bottomRight, square.bottomLeft);
            break;
        case 13:
            MeshFromPoints(square.topLeft, square.topRight, square.centreRight, square.centreBottom, square.bottomLeft);
            break;
        case 14:
            MeshFromPoints(square.topLeft, square.topRight, square.bottomRight, square.centreBottom, square.centreLeft);
            break;

        // 4 point:
        case 15:
            MeshFromPoints(square.topLeft, square.topRight, square.bottomRight, square.bottomLeft);
            break;
        }
    }

可以看到16同情况,根据一个suaqre中为1的ControlNode个数分开了,然后按照顺时针的方式把这些ControlNodes和包含的Nodes传入MeshFromPoints,在这个函数里把它们组装。

    void MeshFromPoints(params Node[] points) {
        AssignVertices(points);

        if (points.Length >= 3)
            CreateTriangle(points[0], points[1], points[2]);
        if (points.Length >= 4)
            CreateTriangle(points[0], points[2], points[3]);
        if (points.Length >= 5) 
            CreateTriangle(points[0], points[3], points[4]);
        if (points.Length >= 6)
            CreateTriangle(points[0], points[4], points[5]);
    }

    void AssignVertices(Node[] points) {
        for (int i = 0; i < points.Length; i ++) {
            if (points[i].vertexIndex == -1) {
                points[i].vertexIndex = vertices.Count;
                vertices.Add(points[i].position);
            }
        }
    }

    void CreateTriangle(Node a, Node b, Node c) {
        triangles.Add(a.vertexIndex);
        triangles.Add(b.vertexIndex);
        triangles.Add(c.vertexIndex);
    }

可以看到在MeshFromPoints里,首先把所有nodes加入vertices并设置vertexIndex为当前vertices内元素的个数(如果之前没有加入的话),并根据输入的node个数的不同,分别创建了1到4个triangle,把三角形顶点的index加入triangle的数组中。 这样,triangles数组中就包含了所有三角形的信息,每三个元素代表一个三角形,单个元素代表三角形顶点坐标的索引,可以根据索引去vertices里找到对应node的坐标。这样Unity就能画出mesh了。
我们把OnDrawGizmos注释,然后代码更代码更新后(不要忘记给脚本所在Gameobject添加MeshFilter和MeshRenderer),运行游戏,可以看到如下的Mesh。(我给Mesh添加了一个红色material,所以是红色的)


这样我们的Mesh就生成好了


生成好了Mesh,下一节需要把我们这个2D的mesh面变成3D的,大体思路是找到地图中的边缘节线(outline)然后生成一个Wall。


那么什么样的边是outline呢?
Capture.PNG

从这张图可以看出来,当连接两个相邻的node,如果这条线只属于一个三角形(连接2,3)则它是一个outline,也就是我们生成墙所需要的,如果属于多个三角形(连接1,2),则它是一条网格内部的线段,不是一条outline。
接下来在视频里作证更改了部分上一届MapGenerator中TriangulateSquare里生成三角形的node顺序,他认为这个会导致outline edge的方向出现错误,他举的的例子是
这个:

如果初始点是C,则outline edge的方向是CA,如果初始点是A,在AB不是outline Edge的情况下会找到C点(具体算法后边代码里会说到),这样方向就成了AC,变成了逆时针就不一致了。于是作者修改了TriangulateSquare里数值为1,2,8的三角形的起始顶点。但是我不是很理解这里,因为按照他的说法,很多其他情况(如三角形值为3)的outline edge也会变成逆时针,所以我暂时没有做视频里的修改,想看看出来是什么情况。Youtube里的评论有一个人和我有一样的疑惑,他还做了修复,我之后可能用他的修复试试看。

Thanks for the tutorial Sebastian, really enjoying it so far! I think there's some confusion in your reasoning about the outline edge fix at the beginning of the video though. It shouldn't matter whether you define a triangle ABC or CAB or any clockwise permutation, as long as you're keeping edge direction consistent in other places. In both ABC and CAB, CA has the same direction. It looks like the issue arises from GetConnectedOutlineVertex(), which doesn't preserve edge direction. Consider a triangle ABC, where CA is an outline edge (and clockwise with respect to the triangle and our perspective because we defined the triangle that way). As written, if you call GetConnectedOutlineVertex() on vertex A, it will look at AB first, which is clockwise, but not an outline edge. Then it will continue onto AC and determine that AC is an outline edge, but AC is counterclockwise with respect to the triangle, which causes the outline direction to run the wrong way. I've re-written GetConnectedOutlineVertex() at the gist below such that it preserves the clockwise edge direction by only looking at clockwise edges: https://gist.github.com/anonymous/e7ead60368e53440d93655e73bf7853e

视频的下一步为我们的Map添加了一个边,即扩展了地图的边缘,以确保地图边缘不会是镂空的,更改MapGenerator的GenerateMap方法,把border宽设为1,border的部分全是墙壁:

 void GenerateMap() {
        map = new int[width,height];
        RandomFillMap();

        for (int i = 0; i < 5; i ++) {
            SmoothMap();
        }

        int borderSize = 1;
        int[,] borderedMap = new int[width + borderSize * 2,height + borderSize * 2];

        for (int x = 0; x < borderedMap.GetLength(0); x ++) {
            for (int y = 0; y < borderedMap.GetLength(1); y ++) {
                if (x >= borderSize && x < width + borderSize && y >= borderSize && y < height + borderSize) {
                    borderedMap[x,y] = map[x-borderSize,y-borderSize];
                }
                else {
                    borderedMap[x,y] =1;
                }
            }
        }

        MeshGenerator meshGen = GetComponent();
        meshGen.GenerateMesh(borderedMap, 1);
    }

回到MeshGenrerator, 上一节我们有vertices来储存所有顶点坐标,已经triangles来储存三角形的索引,但是我们还需要一个三角形类来让我们更加方便的获取某个三角形的三个顶点

   struct Triangle {
        public int vertexIndexA;
        public int vertexIndexB;
        public int vertexIndexC;
        int[] vertices;

        public Triangle (int a, int b, int c) {
            vertexIndexA = a;
            vertexIndexB = b;
            vertexIndexC = c;

            vertices = new int[3];
            vertices[0] = a;
            vertices[1] = b;
            vertices[2] = c;
        }

        public int this[int i] {
            get {
                return vertices[i];
            }
        }

        public bool Contains(int vertexIndex) {
            return vertexIndex == vertexIndexA || vertexIndex == vertexIndexB || vertexIndex == vertexIndexC;
        }
    }

这个类定义了一个三角形,里边包括获取顶点索引以及判断一个顶点是否属于这个三角形的方法。然后由于我们要判断一个edge是不是outline edge,我们需要知道一个顶点属于哪些三角形,以判断两个相邻顶点组成的edge是否只有一个公共三角形,如果是,它就是一条outline edge。所以我们建立一个dictionary,key为顶点索引,value是一个三角形列表

Dictionary> triangleDictionary = new Dictionary> ();

既然有了三角形类,那么在之前创建三角形的时候就要同时创建具体的三角形对象,并把
它们加入dictionary里面。

  void CreateTriangle(Node a, Node b, Node c) {
        triangles.Add(a.vertexIndex);
        triangles.Add(b.vertexIndex);
        triangles.Add(c.vertexIndex);

        Triangle triangle = new Triangle (a.vertexIndex, b.vertexIndex, c.vertexIndex);
        AddTriangleToDictionary (triangle.vertexIndexA, triangle);
        AddTriangleToDictionary (triangle.vertexIndexB, triangle);
        AddTriangleToDictionary (triangle.vertexIndexC, triangle);
    }

    void AddTriangleToDictionary(int vertexIndexKey, Triangle triangle) {
        if (triangleDictionary.ContainsKey (vertexIndexKey)) {
            triangleDictionary [vertexIndexKey].Add (triangle);
        } else {
            List triangleList = new List();
            triangleList.Add(triangle);
            triangleDictionary.Add(vertexIndexKey, triangleList);
        }
    }

有了triangleDictionary,我们可以写一个方法,判断两个node组成的edge是不是outline edge。获取第一个node所属于的所有三角形,逐个判断该三角形是否包含第二个node,若有一个以上三角形包括,则不是outline edge。

    bool IsOutlineEdge(int vertexA, int vertexB) {
        List trianglesContainingVertexA = triangleDictionary [vertexA];
        int sharedTriangleCount = 0;

        for (int i = 0; i < trianglesContainingVertexA.Count; i ++) {
            if (trianglesContainingVertexA[i].Contains(vertexB)) {
                sharedTriangleCount ++;
                if (sharedTriangleCount > 1) {
                    break;
                }
            }
        }
        return sharedTriangleCount == 1;
    }

下面我们创建一个方法,给定一个outline vertex,找到它的outline edge

int GetConnectedOutlineVertex(int vertexIndex) {
    // given a vertex, if it is in an outline, return the next vertex in the outline.
    //   returns -1 if vertexIndex is not in an outline.
    List triangles = triangleDict[vertexIndex];
    foreach (Triangle triangle in triangles) {
        for (int i = 0; i < 3; i++) {
            // we want to examine the clockwise edge in the triangle which starts with vertexIndex (there is only one).
            //   since we defined the vertices in clockwise order, we just have to wrap around to the first vertex
            //   in the case where vertexIndex is the third vertex.
            if (vertexIndex == triangle.Vertices[i]) {
                int nextVertexIndex = triangle.Vertices[(i + 1) % 3];  // mod 3 wraps the index
                if (IsOutlineEdge(vertexIndex, nextVertexIndex)) {
                    return nextVertexIndex;
                }
            }
        }
    }
    return -1;
}

要注意的是这个方法的实现和作者是不一样的,而是取自我之前引用的评论里面的实现。没有使用作者的实现是因为我没有使用作者关于上一节TriangulateSquare的修改,不知道他那么改是如何保证outline edge是顺时针的,而这个实现就很清楚了,一个节点在一个三角形中连着两条边,只有它和顺时针的下一个节点组成的edge是outline edge时, 我们才需+要返回。如图所示,当我们找寻A节点的outline edge时,其实只需考虑顺时针方向的的B节点,至于CA是不是outline edge,我们会在找寻C节点的outline edge的时候判断。所以上面的代码就很好理解,我们只考虑输入的节点所在三角的下一个顶点,如果那个点是outline,则返回,否则返回-1.



然后我们需要一个数组来储存outlines,每个outline又是多个vertex组成的,所以需要一个list的list。同时为了不搜索重复的node,我们创建一个HashSet来保存已经搜索过的node。

    Dictionary> triangleDictionary = new Dictionary> ();
    List> outlines = new List> ();
    HashSet checkedVertices = new HashSet();

接下来我们需要遍历所有的vertex,然后得到所有的outlines

    void CalculateMeshOutlines() {

        for (int vertexIndex = 0; vertexIndex < vertices.Count; vertexIndex ++) {
            if (!checkedVertices.Contains(vertexIndex)) {
               
                int newOutlineVertex = GetConnectedOutlineVertex(vertexIndex); //得到下一个outline vertex
                if (newOutlineVertex != -1) {     
                     checkedVertices.Add(vertexIndex);  //vertexIndex 是outline中的一个vertex
                    List newOutline = new List();
                    newOutline.Add(vertexIndex);
                    outlines.Add(newOutline);
                    FollowOutline(newOutlineVertex, outlines.Count-1);
                    outlines[outlines.Count-1].Add(vertexIndex);
                }
            }
        }
    }

    void FollowOutline(int vertexIndex, int outlineIndex) {
        outlines [outlineIndex].Add (vertexIndex); //递归的找寻outline 上的下一个vertex
        checkedVertices.Add (vertexIndex);
        int nextVertexIndex = GetConnectedOutlineVertex (vertexIndex);

        if (nextVertexIndex != -1) {
            FollowOutline(nextVertexIndex, outlineIndex);
        }
    }

这样我们就有了所有我们创建outline wall的信息了,在真正创建之前还有两个优化可以做。因为我们创建了checkedVertices数组,所以在GetConnectedOutlineVertex的时候如果vertex已经check过了,说明它已经被加入了一条outline中,我们就不需要再做处理。

if (vertexIndex == triangle.Vertices[i]) {
                int nextVertexIndex = triangle.Vertices[(i + 1) % 3];  // mod 3 wraps the index
                if (!checkedVertices.Contains(nextVertexIndex) && IsOutlineEdge(vertexIndex, nextVertexIndex)) {
                    return nextVertexIndex;
                }
            }

另一个优化是当一个suqare的4个control node都为1时,这4个vertex则一定不属于outline edge,所以更改TriangulateSquare函数

 case 15:
            MeshFromPoints(square.topLeft, square.topRight, square.bottomRight, square.bottomLeft);
            checkedVertices.Add(square.topLeft.vertexIndex);
            checkedVertices.Add(square.topRight.vertexIndex);
            checkedVertices.Add(square.bottomRight.vertexIndex);
            checkedVertices.Add(square.bottomLeft.vertexIndex);
            break;

接下来就是生成WallMesh了,在GenerateMesh里先reset triangleDictionary,outlines和checkedVertices,然后调用生成WallMesh的方法。

 public void GenerateMesh(int[,] map, float squareSize) {
        triangleDictionary.Clear ();
        outlines.Clear ();
        checkedVertices.Clear ();
        ......
        CreateWallMesh ();
}

在CreateWallMesh小调用CalculateMeshOutlines得到outlines。对于每一条outline,遍历其vertex并手动添加墙面所需vertex到wallVertices里。要注意因为我们的墙是从里面看的(视频说法view from inside,我不是很理解),所以我们需要逆时针安排这些vertex组成三角形。

  void CreateWallMesh() {

        CalculateMeshOutlines ();

        List wallVertices = new List ();
        List wallTriangles = new List ();
        Mesh wallMesh = new Mesh ();
        float wallHeight = 5;

        foreach (List outline in outlines) {
            for (int i = 0; i < outline.Count -1; i ++) {
                int startIndex = wallVertices.Count;
                wallVertices.Add(vertices[outline[i]]); // left
                wallVertices.Add(vertices[outline[i+1]]); // right
                wallVertices.Add(vertices[outline[i]] - Vector3.up * wallHeight); // bottom left
                wallVertices.Add(vertices[outline[i+1]] - Vector3.up * wallHeight); // bottom right

                wallTriangles.Add(startIndex + 0);
                wallTriangles.Add(startIndex + 2);
                wallTriangles.Add(startIndex + 3);

                wallTriangles.Add(startIndex + 3);
                wallTriangles.Add(startIndex + 1);
                wallTriangles.Add(startIndex + 0);
            }
        }
        wallMesh.vertices = wallVertices.ToArray ();
        wallMesh.triangles = wallTriangles.ToArray ();
        walls.mesh = wallMesh;
    }

不要忘记在前面新建一个mesh filter

 public MeshFilter walls;

在editor的gameobject里新建一个子物体Walls,给他添加Mesh filter和Mesh renderer,并把这个mesh filter分配给MeshGenerator。
运行游戏,可以看到墙面已经正常生成出来了,点击鼠标可以生成带墙面的新地图


Capture.PNG

我试了下顺时钟添加墙面三角形顶点,出来的墙面果然有问题



下一步是清理地图中的很小的墙体或者很小的空洞,让地图看起来更平滑漂亮,思路就是用flood fill算法得到所有的空洞放到一个数组里,得到所有的相连墙块放到一个数组里,然后设定一个阈值,小于阈值的墙体设为空地,空地设为墙体。
首先为了更方便的flood fill,我们创建一个struct用来记录tile的坐标,在MapGenerator中:

    struct Coord {
        public int tileX;
        public int tileY;

        public Coord(int x, int y) {
            tileX = x;
            tileY = y;
        }
    }

我们想用list来保存数组,所以

using System.Collections.Generic;

然后创建flood fill的方法,该方法返回Coord的list。

 List GetRegionTiles(int startX, int startY) {
        List tiles = new List ();
        int[,] mapFlags = new int[width,height];//表示这个tile是否已经搜索过
        int tileType = map [startX, startY];//确定这个tile是墙还是地

        Queue queue = new Queue ();//下面是flood fill
        queue.Enqueue (new Coord (startX, startY));
        mapFlags [startX, startY] = 1;

        while (queue.Count > 0) {
            Coord tile = queue.Dequeue();
            tiles.Add(tile); //弹出的时候加入数组

            for (int x = tile.tileX - 1; x <= tile.tileX + 1; x++) { //查看tile的所有相邻tile
                for (int y = tile.tileY - 1; y <= tile.tileY + 1; y++) {
                    if (IsInMapRange(x,y) && (y == tile.tileY || x == tile.tileX)) {//tile的位置必须是合法的并且flood fill只考虑上下左右不考虑斜方向
                        if (mapFlags[x,y] == 0 && map[x,y] == tileType) {
                            mapFlags[x,y] = 1; //满足条件的设为已搜索并加入队列
                            queue.Enqueue(new Coord(x,y));
                        }
                    }
                }
            }
        }
        return tiles;
    }

IsInMapRange是一个用来判断一个tile是否合法的方法

  bool IsInMapRange(int x, int y) {
        return x >= 0 && x < width && y >= 0 && y < height;
    }

以上的方法能够从一个tile找到这个tile所在的空地或墙体包含的所有tile,一张地图有多个独立空地或者独立墙体,所以我们需要一个方法来得到所有的独立空地或墙体。

List> GetRegions(int tileType) {
        List> regions = new List> ();
        int[,] mapFlags = new int[width,height];

        for (int x = 0; x < width; x ++) {
            for (int y = 0; y < height; y ++) {
                if (mapFlags[x,y] == 0 && map[x,y] == tileType) {
                    List newRegion = GetRegionTiles(x,y);
                    regions.Add(newRegion);

                    foreach (Coord tile in newRegion) {
                        mapFlags[tile.tileX, tile.tileY] = 1;
                    }
                }
            }
        }
        return regions;
    }

这个方法输入一个类型,返回这个类型的所有Regions,方法是遍历整个地图,从第一个tile开始调用GetRegionTiles(如果tile类型正确),得到它的region并把region里的tile都设置为已经搜索过,循环下去。
然后只需要写一个方法来遍历regions并吧小于阈值的tile改变类型即可。

void ProcessMap() {
        List> wallRegions = GetRegions (1);
        int wallThresholdSize = 50; //墙的region内的tile数量小于50则设为空地
        foreach (List wallRegion in wallRegions) {
            if (wallRegion.Count < wallThresholdSize) {
                foreach (Coord tile in wallRegion) {
                    map[tile.tileX,tile.tileY] = 0;
                }
            }
        }
        List> roomRegions = GetRegions (0);
        int roomThresholdSize = 50;//空地的region内的tile数量小于50则设为墙
        foreach (List roomRegion in roomRegions) {
            if (roomRegion.Count < roomThresholdSize) {
                foreach (Coord tile in roomRegion) {
                    map[tile.tileX,tile.tileY] = 1;
                }
            }
        }
    }

最后只需要在生成地图边框前调用ProcessMap即可。
不使用ProcesMap:


使用ProcessMap:


下一节是要在离散的Room中创建一条把两个Room相连起来,这里我们定义两个Room相连是他们有一条公共的通道,如果A连B,B连C,AC是并不相连的。
首先我们创建一个Room类来储存我们所有需要的Room信息:

class Room {
        public List tiles;
        public List edgeTiles;
        public List connectedRooms;
        public int roomSize;

        public Room() {
        }

        public Room(List roomTiles, int[,] map) {
            tiles = roomTiles;
            roomSize = tiles.Count;
            connectedRooms = new List();

            edgeTiles = new List();
            foreach (Coord tile in tiles) {
                for (int x = tile.tileX-1; x <= tile.tileX+1; x++) {
                    for (int y = tile.tileY-1; y <= tile.tileY+1; y++) {
                        if (x == tile.tileX || y == tile.tileY) {
                            if (map[x,y] == 1) {
                                edgeTiles.Add(tile);
                            }
                        }
                    }
                }
            }
        }

        public static void ConnectRooms(Room roomA, Room roomB) {
            roomA.connectedRooms.Add (roomB);
            roomB.connectedRooms.Add (roomA);
        }

        public bool IsConnected(Room otherRoom) {
            return connectedRooms.Contains(otherRoom);
        }
    }
}

可以看到他的构造方法根据ROOM的所有tiles坐标计算得到edge tile,并且有连接相邻room的方法,以及判断room是否相邻的方法。
在PrcessMap中我们除去了过小的Room,我们需要把剩下的Room储存起来并且相连:

List> roomRegions = GetRegions (0);
        int roomThresholdSize = 50;
        List survivingRooms = new List ();

        foreach (List roomRegion in roomRegions) {
            if (roomRegion.Count < roomThresholdSize) {
                foreach (Coord tile in roomRegion) {
                    map[tile.tileX,tile.tileY] = 1;
                }
            }
            else {
                survivingRooms.Add(new Room(roomRegion, map));
            }
        }
        ConnectClosestRooms (survivingRooms);

连接Room的方法非常暴力,循环所有的rooms,每两个room再对他们所有的tile做循环比较距离,取最短的距离:

void ConnectClosestRooms(List allRooms) {

        int bestDistance = 0;
        Coord bestTileA = new Coord ();
        Coord bestTileB = new Coord ();
        Room bestRoomA = new Room ();
        Room bestRoomB = new Room ();
        bool possibleConnectionFound = false;

        foreach (Room roomA in allRooms) {
            possibleConnectionFound = false;

            foreach (Room roomB in allRooms) {
                if (roomA == roomB) {
                    continue;
                }
                if (roomA.IsConnected(roomB)) {//防止已经找到AB后又找BA
                    possibleConnectionFound = false;
                    break;
                }

                for (int tileIndexA = 0; tileIndexA < roomA.edgeTiles.Count; tileIndexA ++) {
                    for (int tileIndexB = 0; tileIndexB < roomB.edgeTiles.Count; tileIndexB ++) {
                        Coord tileA = roomA.edgeTiles[tileIndexA];
                        Coord tileB = roomB.edgeTiles[tileIndexB];
                        int distanceBetweenRooms = (int)(Mathf.Pow (tileA.tileX-tileB.tileX,2) + Mathf.Pow (tileA.tileY-tileB.tileY,2));

                        if (distanceBetweenRooms < bestDistance || !possibleConnectionFound) { //没有找到过潜在的连接或者新距离小于目前最好距离,则视为找到了新连接
                            bestDistance = distanceBetweenRooms;
                            possibleConnectionFound = true;
                            bestTileA = tileA;
                            bestTileB = tileB;
                            bestRoomA = roomA;
                            bestRoomB = roomB;
                        }
                    }
                }
            }
            if (possibleConnectionFound) { //对新连接创造Pass
                CreatePassage(bestRoomA, bestRoomB, bestTileA, bestTileB);
            }
        }
    }

然后我们在两个tile之间画一条debug线

    void CreatePassage(Room roomA, Room roomB, Coord tileA, Coord tileB) {
        Room.ConnectRooms (roomA, roomB);
        Debug.DrawLine (CoordToWorldPoint (tileA), CoordToWorldPoint (tileB), Color.green, 100);
    }

    Vector3 CoordToWorldPoint(Coord tile) {
        return new Vector3 (-width / 2 + .5f + tile.tileX, 2, -height / 2 + .5f + tile.tileY);
    }

我们稍微调大randomFillPercent,然后运行游戏



可以看到一条绿色的线表示两个Room连起来了,但是我们目前只保证一个Room一定会和另一个相连,却不保证所有Room最后是连通的。我们将在下一节处理这个问题。


作者在这一节使用的连接所有房间的方法emm非常绕,我没有看懂,但是我在Youtube的评论里面发现了更好理解的方法,那就是在部分Room连接之后,对于整个地图再做一遍和之前一样的flood fill,形成新的room regions,然后再把最近的rooms相连接,知道最后做flood fill的时候只出现一个region,所有的room就连通了。但是这样做的话需要先把上一节中用debugdraw连起来的room确确实实的连起来。这是下一节的内容。我把它提前放到这一节来讲。而且我使用的找到相连的square的方法和作者介绍的也有所不同。
基本思路是这样的,前面一节CreatePassage中我们已经得到需要连接的两个room中的点,我们把靠左边的点作为起始点,另一个作为终点。然后计算这条线的斜率。设定一个当前的探索点为起始点。
如果斜率为正,而且斜率大于1,则这条线向上的趋势大,我们就把探索点的Y坐标加一,重新计算该点到终点的斜率,并把新探索点加入path的列表中,重复该步骤。如果斜率小于一,则说明向右的趋势大,我们就把探索点的X坐标加一,并重新计算斜率,并把新探索点加入path的列表中,重复该步骤。需要注意如果探索点和目标点X相同,计算斜率会出现除0,这时需要手动把斜率设为无穷大。
如果斜率为负,而且斜率小于-1,则这条线向下的趋势大,我们就把探索点的Y坐标减一,重新计算该点到终点的斜率,并把新探索点加入path的列表中,重复该步骤。如果斜率大于-1,则说明向右的趋势大,我们就把探索点的X坐标加一,并重新计算斜率,并把新探索点加入path的列表中,重复该步骤。需要注意如果探索点和目标点X相同,计算斜率会出现除0,这时需要手动把斜率设为无穷小。
这样计算完毕之后列表里就会有从一个Room到另一个Room的完整路径坐标,把这些坐标的map对应值设置为0,就可以挖出一条通道了,注意这样挖通道的宽度只有1,所以我使用了作者下一节提到的DrawCircle函数来拓展通道的宽度。

   void CreatePassage(Room roomA, Room roomB, Coord tileA, Coord tileB)
    {
        Room.ConnectRooms(roomA, roomB);
        Debug.Log("connnect:"+"("+tileA.tileX+","+tileA.tileY+")"+" and "+ "(" + tileB.tileX + "," + tileB.tileY + ")");
        Debug.DrawLine(CoordToWorldPoint(tileA), CoordToWorldPoint(tileB), Color.green, 100);
        RemovePassageWalls(tileA,  tileB);
    }

    void RemovePassageWalls(Coord tileA, Coord tileB)
    {
        List pendingRemove = new List
        {
            tileA,
            tileB
        };
        Coord startTile = tileA.tileX <= tileB.tileX ? tileA : tileB;
        Coord endTile = tileA.tileX > tileB.tileX ? tileA : tileB;
        float gradient = (endTile.tileY - startTile.tileY)*1.0f / (endTile.tileX - startTile.tileX);
        Coord searchTile = startTile;
        if(gradient>=0)
        {
            while(!searchTile.Equal(endTile))
            {
                if(gradient>=1)
                {
                    searchTile.tileY++;
                }
                else
                {
                    searchTile.tileX++;
                }
                if (endTile.tileX != searchTile.tileX)
                {
                    gradient = (endTile.tileY - searchTile.tileY) * 1.0f / (endTile.tileX - searchTile.tileX);
                }
                else
                {
                    gradient = Mathf.Infinity;
                }
                Coord nextTile = searchTile;
                pendingRemove.Add(nextTile);
            }
        }
        else
        {
            while (!searchTile.Equal(endTile))
            {
                if (gradient < -1.0f)
                {
                    searchTile.tileY--;
                }
                else
                {
                    searchTile.tileX++;
                }
                if (endTile.tileX != searchTile.tileX)
                {
                    gradient = (endTile.tileY - searchTile.tileY)*1.0f / (endTile.tileX - searchTile.tileX);
                }
                else
                {
                    gradient = Mathf.NegativeInfinity;
                }
                Coord nextTile = searchTile;
                pendingRemove.Add(nextTile);
            }
        }

        foreach (Coord pendCoord in pendingRemove)
        {
            DrawCircle(pendCoord, 1);
        }

    }
    void DrawCircle(Coord c, int r)
    {
        for (int x = -r; x <= r; x++)
        {
            for (int y = -r; y <= r; y++)
            {
                if (x * x + y * y <= r * r)
                {
                    int drawX = c.tileX + x;
                    int drawY = c.tileY + y;
                    if (IsInMapRange(drawX, drawY))
                    {
                        map[drawX, drawY] = 0;
                    }
                }
            }
        }
    }

运行游戏,可以看到地图是这样的


创建通道前

创建通道后

然后为了把所有的Room连接起来,我们修改ProcessMap函数,再做flood fill,直到Region的数目只有1.

void ProcessMap()
    {
        List> wallRegions = GetRegions(1);
        int wallThresholdSize = 50; //墙的region内的tile数量小于50则设为Room
        foreach (List wallRegion in wallRegions)
        {
            if (wallRegion.Count < wallThresholdSize)
            {
                foreach (Coord tile in wallRegion)
                {
                    map[tile.tileX, tile.tileY] = 0;
                }
            }
        }
        List> roomRegions = GetRegions(0);
        int roomThresholdSize = 50;//Room的region内的tile数量小于50则设为墙
        List survivingRooms = new List();

        foreach (List roomRegion in roomRegions)
        {
            if (roomRegion.Count < roomThresholdSize)
            {
                foreach (Coord tile in roomRegion)
                {
                    map[tile.tileX, tile.tileY] = 1;
                }
            }
            else
            {
                survivingRooms.Add(new Room(roomRegion, map));
            }
        }
        ConnectClosestRooms(survivingRooms);
        int numRooms = survivingRooms.Count;
        while (numRooms > 1)
        {
            List> updatedRoomRegions = GetRegions(0);
            List updatedSurvivingRooms = new List();
            foreach (List roomRegion in updatedRoomRegions)
            {
                updatedSurvivingRooms.Add(new Room(roomRegion, map));
            }
            ConnectClosestRooms(updatedSurvivingRooms);
            numRooms = updatedSurvivingRooms.Count;
        }
    }
只进行一次flood fill

多次flood fill

最后一节作者给地图添加了Collision和Texture。并且Collision分为了3D和2D模式。
我们只看3DCollision。想要了解2D的可以前往:
这里查看源码与步骤
作者首先改变了一下我们Map的hierachy。在Wall的同级建了一个Cave的Object,并且把原来父Object的mesh filter和renderer都移动了进来。然后创建了一个ground平面,和新建了一个player的object,写了一个简单操控player的脚本。




public class Player : MonoBehaviour
{

    Rigidbody rigidbody;
    Vector3 velocity;

    void Start()
    {
        rigidbody = GetComponent();
    }

    void Update()
    {
        velocity = new Vector3(Input.GetAxisRaw("Horizontal"), 0, Input.GetAxisRaw("Vertical")).normalized * 10;
    }

    void FixedUpdate()
    {
        rigidbody.MovePosition(rigidbody.position + velocity * Time.fixedDeltaTime);
    }
}

然后代码里MeshGenerator创建一个cave的Meshfilter

public MeshFilter Caves; 
 public bool is2D; //2D不生成墙

GenerateMesh中

 Caves.mesh = mesh; //原来是 GetComponent().mesh = mesh;

CreateWallMesh中给墙添加Collision:

MeshCollider wallCollider = walls.gameObject.AddComponent();
wallCollider.sharedMesh = wallMesh;

运行游戏发现小方块已经会被墙阻挡了。



接下来添加Texture。
我们在GenerateMesh方法设定Mesh的vertices和triangles之后添加下面的代码来个Texture设定UV

        int tileAmount = 10;//这个值可以作坊Texture,让它出现tile的重复效果
        Vector2[] uvs = new Vector2[vertices.Count];
        for (int i = 0; i < vertices.Count; i++)
        {
            float percentX = Mathf.InverseLerp(-map.GetLength(0) / 2 * squareSize, map.GetLength(0) / 2 * squareSize, vertices[i].x) * tileAmount;
            float percentY = Mathf.InverseLerp(-map.GetLength(1) / 2 * squareSize, map.GetLength(1) / 2 * squareSize, vertices[i].z) * tileAmount;
            uvs[i] = new Vector2(percentX, percentY);
        }
        mesh.uv = uvs;

然后给Cave的MeshRenderer添加一个Texture,效果如下



这样官网的这个自动生成地图教程就完成了,学无止尽。

你可能感兴趣的:(Procedural Map Generation(随机地图生成))