查看原文
其他

创建程序化的游戏世界(2)

Unity Unity官方平台 2018-11-15

在《创建程序化的游戏世界(1)》中,我们介绍了使用多种方法来程序化创建顶部图层,例如:使用柏林噪音和随机游走。在这篇文章,我们将介绍使用程序化生成功能创建洞穴的方法,从而给予开发者一些变化的可能性启发。


本文探讨的内容在ProceduralPatterns2D项目内实现。你可以下载这些资源并尝试使用程序化算法。下载地址:

https://github.com/UnityTechnologies/ProceduralPatterns2D



本文与本系列第一篇文章使用相同的规则:

  • 我们将使用二进制来区分什么是否是瓦片。数字1指的是瓦片,0则不是。

  • 我们会将所有地图存进一个2D整型数组中,除了在渲染的时候外,它都将会在每个函数结尾返回给用户。

  • 我们将使用数组函数GetUpperBound( )来获取每个地图的高度和宽度,这样进入每个函数的变量会更少,从而使代码更简洁。

  • 我们会经常使用Mathf.FloorToInt( ),这是因为Tilemap坐标系统会从左下方开始,使用Mathf.FloorToInt( )可以将这些数字转换为一个整数。

  • 本文中所有代码都是C#代码。


柏林噪音算法

本系列第一篇文章中,我们了解了一些使用柏林噪音算法创建顶部图层的方式。在这篇文章中,我们也将使用柏林噪音算法来创建洞穴。

 

我们首先获取一个新的柏林噪音参数,它将得到我们当前位置参数乘以一个修饰符后的结果。这个修饰符是介于0和1之间的数值。修饰符越大,柏林生成结果越混乱。然后我们会将这个数值四舍五入为整数,得到0或1,它将被存在地图数组中。

 

实现过程代码如下:

public static int[,] PerlinNoiseCave(int[,] map, float modifier, bool edgesAreWalls)

{

int newPoint;

for (int x = 0; x < map.GetUpperBound(0); x++)

{

for (int y = 0; y < map.GetUpperBound(1); y++)

{

 

if (edgesAreWalls && (x == 0 || y == 0 || x == map.GetUpperBound(0) - 1 || y == map.GetUpperBound(1) - 1))

{

map[x, y] = 1; //将边缘保留为墙

}

else

{

//使用柏林噪音生成一个新的点,然后将其环绕设置0或1

newPoint = Mathf.RoundToInt(Mathf.PerlinNoise(x * modifier, y * modifier));

map[x, y] = newPoint;

}

}

}

return map;

}


我们使用修饰符而不使用种子代码的原因,是当我们将数值乘以一个0和0.5之间的数字时,柏林生成功能的结果看起来会更好。数值越小,地图的方块越大。

 

如下图所示,这个动图开始时使用的修饰符数值为0.01,之后会慢慢递增到0.25。从这个动图你可以了解到,柏林生成过程其实只是在每次调整时把同样的地图模式放大而已。

 

随机游走

在第一篇文章中,我们看到可以使用一个类似抛硬币的方法来决定平台是上升还是下降。这篇文章中,我们将使用同样的思路,但增加了额外的二个选项,即向左和向右。

 

随机游走算法的这一变化能够让我们创造洞穴。首先我们会获取一个随机方向,然后移动位置并移除瓦片。这个过程将一直继续,直到我们达到所要摧毁的指定地板瓦片数量。现在我们使用四个方向:上、下、左、右。

public static int[,] RandomWalkCave(int[,] map, float seed,  int requiredFloorPercent)

{

    //设置随机数

    System.Random rand = new System.Random(seed.GetHashCode());

 

    //定义起始X位置

    int floorX = rand.Next(1, map.GetUpperBound(0) - 1);

    //定义起始Y位置

    int floorY = rand.Next(1, map.GetUpperBound(1) - 1);

    //确认我们所需要的floorAmount

    int reqFloorAmount = ((map.GetUpperBound(1) * map.GetUpperBound(0)) * requiredFloorPercent) / 100;

    int floorCount = 0;

 

    //将起始位置设置为不是瓦片(0 =无瓦片,1 =瓦片)

    map[floorX, floorY] = 0;

    floorCount++;


函数的执行过程是这样的:

  1. 找到开始位置

  2. 计算需要移除的地板瓦片数量

  3. 移除起始位置的瓦片

  4. 给地板瓦片计数器加一

 

下一步,我们会进行while循环。这个循环将为我们创造洞穴。

while (floorCount < reqFloorAmount)

    {

        //确定下一个方向

        int randDir = rand.Next(4);

        switch (randDir)

        {

            //上

            case 0:

                if ((floorY + 1) < map.GetUpperBound(1) - 1)

                { 

                    floorY++;

                    if (map[floorX, floorY] == 1)

                    {

                        map[floorX, floorY] = 0;

                        floorCount++;

                    }

                }

                break;

            //下

            case 1:

                if ((floorY - 1) > 1)

                {

                    floorY--;

                    if (map[floorX, floorY] == 1)

                    {

                        map[floorX, floorY] = 0;

                        floorCount++;

                    }

                }

                break;

            //右

            case 2:

                if ((floorX + 1) < map.GetUpperBound(0) - 1)

                {

                    floorX++;

                    if (map[floorX, floorY] == 1)

                    {

                        map[floorX, floorY] = 0;

                        floorCount++;

                    }

                }

                break;

            //左

            case 3:

                if ((floorX - 1) > 1)

                {

                    floorX--;

                    if (map[floorX, floorY] == 1)

                    {

                        map[floorX, floorY] = 0;

                        floorCount++;

                    }

                }

                break;

        }

    }

    //返回更新的地图

    return map;

}


函数讲解

首先我们会使用一个随机数来决定我们应该往哪个方向移动。然后,我们会用Switch条件语句来检查方向。在这个语句中,我们检查这个位置是否是墙体。如果不是墙体的话,我们会从数组移除这个瓦片块。我们会反复这样做直到我们达到指定的地板数量。

 

最终结果如下:



我们还创建了这个函数的一个自定义版本,它包含斜线方向。这个函数的代码较长,要是你想查看代码的话,请下载并参考ProceduralPatterns2D项目项目代码。


方向通道

方向通道开始于地图的一端,结束于地图的另一端。我们可以通过把通道的曲线和粗糙度输入到函数中来控制这二个属性。我们也可以决定通道部分长度的上限和下限。

 

代码如下:

public static int[,] DirectionalTunnel(int[,] map, int minPathWidth, int maxPathWidth, int maxPathChange, int roughness, int curvyness)

{

    int tunnelWidth = 1;

    int x = map.GetUpperBound(0) / 2;


    System.Random rand = new System.Random(Time.time.GetHashCode());


    for (int i = -tunnelWidth; i <= tunnelWidth; i++)

    {

        map[x + i, 0] = 0;

    }


函数讲解

我们首先会设置一个宽度数值,这个宽度数值将从它的对应负值转为正值,它最后会给我们想要的实际大小。本示例中,我们使用的数值为1。这个数值将给我们总宽度为3,因为我们会使用数值-1、0和1。

 

下一步要设置起始X点,我们会通过获取地图宽度的中间值来完成。现在这些数值已经设置好,我们可以生成地图第一部分的通道了。



现在继续创建地图的剩余部分。

    for (int y = 1; y < map.GetUpperBound(1); y++)

    {

        if (rand.Next(0, 100) > roughness)

        {

            int widthChange = Random.Range(-maxPathWidth, maxPathWidth);

            tunnelWidth += widthChange;

            if (tunnelWidth < minPathWidth)

            {

                tunnelWidth = minPathWidth;

            }

            if (tunnelWidth > maxPathWidth)

            {

                tunnelWidth = maxPathWidth;

            }

        }

 

        if (rand.Next(0, 100) > curvyness)

        {

            int xChange = Random.Range(-maxPathChange, maxPathChange);

            x += xChange;

            if (x < maxPathWidth)

            {

                x = maxPathWidth;

            }

            if (x > (map.GetUpperBound(0) - maxPathWidth))

            {

                x = map.GetUpperBound(0) - maxPathWidth;

            }

        }

 

        for (int i = -tunnelWidth; i <= tunnelWidth; i++)

        {

            map[x + i, y] = 0;

        }

    }

    return map;

}


接下来,生成一个随机数来检查我们的粗糙度数值,如果这个随机数大于该数值,我们可以修改路径的宽度。我们也会检查宽度是否太小。有了下一部分代码,我们将会继续处理地图并制作通道。

 

下面的每一步,我们都会做这些事情:

  1. 生成一个新随机数,用来检查曲线值。就如之前的检查一样,如果随机数大于这个数值,我们可以修改路径的中心点。我们也会通过检查确保不会离开地图边界。

  2. 最后我们会在创建的新区域创造通道。

 

最终实现结果如下:


元胞自动机

元胞自动机(Cellular Automata)使用附近的元胞来决定当前元胞是活跃(1)或是不活跃(0)。附近元胞的基础是使用随机生成的细胞网格形成的。本示例中,我们将使用C#代码的Random.Next函数来生成初始网格。

 

因为拥有多个不同的元胞自动机实现,我们为生成这个基本网格制作了一个独立函数。代码如下:

public static int[,] GenerateCellularAutomata(int width, int height, float seed, int fillPercent, bool edgesAreWalls)

{

    System.Random rand = new System.Random(seed.GetHashCode());

 

    int[,] map = new int[width, height];

 

    for (int x = 0; x < map.GetUpperBound(0); x++)

    {

        for (int y = 0; y < map.GetUpperBound(1); y++)

        {

            if (edgesAreWalls && (x == 0 || x == map.GetUpperBound(0) - 1 || y == 0 || y == map.GetUpperBound(1) - 1))

            {

                map[x, y] = 1;

            }

            else

            {

                map[x, y] = (rand.Next(0, 100) < fillPercent) ? 1 : 0;

            }

        }

    }

    return map;

}


在这个函数中,我们也可以决定在网格上是否有墙体。除此之外,我们会针对填充百分比检查一个随机数,从而决定当前的元胞是活跃还是不活跃。


实现结果如下图所示:


摩尔邻域

摩尔邻域(Moore Neighbourhood)用来使初始元胞自动机生成结果变得平滑。摩尔邻域示意图如下:


 

邻域的规则如下:

  • 为邻域检查每个方向。

  • 如果一个邻域是个活跃瓦片,为其周围增加一个瓦片。

  • 如果一个邻域并非活跃瓦片,则什么也不做。

  • 如果这个元胞有四个以上的邻近瓦片,将这个元胞转变为活跃瓦片。

  • 如果这个元胞正好有四个邻近瓦片,就不管这个瓦片。

  • 重复以上步骤,直到完成检查地图上的每个瓦片。

 

检查摩尔邻域的函数代码如下:

static int GetMooreSurroundingTiles(int[,] map, int x, int y, bool edgesAreWalls)

{

    

    int tileCount = 0;      

    

    for(int neighbourX = x - 1; neighbourX <= x + 1; neighbourX++)

    {

        for(int neighbourY = y - 1; neighbourY <= y + 1; neighbourY++)

        {

            if (neighbourX >= 0 && neighbourX < map.GetUpperBound(0) && neighbourY >= 0 && neighbourY < map.GetUpperBound(1))

            {

                if(neighbourX != x || neighbourY != y)

                {

                    tileCount += map[neighbourX, neighbourY];

                }

            }

        }

    }

    return tileCount;

}


在检查完瓦片之后,我们会将得到的信息用在平滑函数中。就如同初始元胞自动机生成过程一样,我们可以设置地图的边界是否有墙。

public static int[,] SmoothMooreCellularAutomata(int[,] map, bool edgesAreWalls, int smoothCount)

{

for (int i = 0; i < smoothCount; i++)

{

for (int x = 0; x < map.GetUpperBound(0); x++)

{

for (int y = 0; y < map.GetUpperBound(1); y++)

{

int surroundingTiles = GetMooreSurroundingTiles(map, x, y, edgesAreWalls);

 

if (edgesAreWalls && (x == 0 || x == (map.GetUpperBound(0) - 1) || y == 0 || y == (map.GetUpperBound(1) - 1)))

{

map[x, y] = 1;

}

else if (surroundingTiles > 4)

{

map[x, y] = 1;

}

else if (surroundingTiles < 4)

{

map[x, y] = 0;

}

}

}

}

    return map;

}



在这个函数中要注意,我们会在整个地图通过for循环以固定次数来进行平滑处理。这个循环结束后会给我们如下更好的地图:

 

 

我们可以通过继续修改这个算法,使隔离的空间连接起来。

冯·诺依曼邻域

冯·诺依曼邻域(von Neumann Neighbourhood)是元胞自动机的另一个常用实现。在这个生成过程中,我们使用一个相较摩尔生成更为简单的邻域。这个邻域示意图如下:

 

 

这个邻域的规则如下:

  • 检查直接相邻的瓦片,不包括斜线部分。

  • 如果这个元胞是活跃的,给计数器加一。

  • 如果这个元胞不是活跃的,什么都不做。

  • 如果我们拥有超过二个邻域,使当前元胞变为活跃。

  • 如果我们拥有的邻域少于二个,使当前元胞变得不活跃。

  • 如果我们正好有二个邻域,不要修改当前元胞。


第二个结果和第一个结果的规则一样,但是会拓展邻域区域。

 

我们会通过以下函数检查邻域:

static int GetVNSurroundingTiles(int[,] map, int x, int y, bool edgesAreWalls)

{

    

int tileCount = 0;

 

    if(edgesAreWalls && (x - 1 == 0 || x + 1 == map.GetUpperBound(0) || y - 1 == 0 || y + 1 == map.GetUpperBound(1)))

    {

        tileCount++;

    }

 

if(x - 1 > 0)

{

tileCount += map[x - 1, y];

}

 

if(y - 1 > 0)

{

tileCount += map[x, y - 1];

}


if(x + 1 < map.GetUpperBound(0))

{

tileCount += map[x + 1, y];

}

 

if(y + 1 < map.GetUpperBound(1))

{

tileCount += map[x, y + 1];

}

 

return tileCount;

}


在得到已有邻域数量后,我们可以移动到数组的平滑部分。和之前一样,我们有一个for循环来在指定输入数量的平滑计数迭代。

public static int[,] SmoothVNCellularAutomata(int[,] map, bool edgesAreWalls, int smoothCount)

{

for (int i = 0; i < smoothCount; i++)

{

for (int x = 0; x < map.GetUpperBound(0); x++)

{

for (int y = 0; y < map.GetUpperBound(1); y++)

{

int surroundingTiles = GetVNSurroundingTiles(map, x, y, edgesAreWalls);

 

if (edgesAreWalls && (x == 0 || x == map.GetUpperBound(0) - 1 || y == 0 || y == map.GetUpperBound(1)))

{

map[x, y] = 1;

}

else if (surroundingTiles > 2)

{

map[x, y] = 1;

}

else if (surroundingTiles < 2)

{

map[x, y] = 0;

}

}

}

}

    return map;

}

 

这样得到的结果比摩尔邻域方法得到的更为分散,如下图所示:

 

 

和摩尔领域一样,我们可以基于生成结果运行一个额外脚本,从而为地图各领域之间提供更好的连接。

 

小结

希望这篇文章能启发开发者在项目中开始使用一些程序化生成形式。如果你想要了解更多关于程序化生成地图的信息,请查看Roguebasin.com。更多Unity技术内容请访问Unity官方中文论坛(UnityChina.cn)

 

推荐阅读

官方活动

Unity与腾讯云,与你一道玩转世界杯

Unity与腾讯云在Unity Connect社区正式上线#码上猜,“云”气来#的世界杯竞猜活动,欢迎广大开发者参与世界杯的相关讨论,竞猜每日比赛结果,赢取丰厚好礼!

活动地址:

https://connect.unity.com/g/5b1f71a7090915001cea53cc


技术直播 | 使用ML-Agents快速掌握模仿学习

6月27日星期三晚8点 Unity技术经理鲍健运将为广大开发者进一步介绍ML-Agents,并带回Unite Berlin 2018关于机器学习最新的资讯,结合模仿学习(Imitation Learning)实例教程引领各位开发者。

直播地址:

https://connect.unity.com/events/unitychina-imitationlearning


点击“阅读原文”访问Unity官方论坛

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存