查看原文
其他

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

Unity Unity官方平台 2018-11-15

许多创作者使用过程序化生成功能来为游戏添加多样的内容。我们熟知的一些游戏包括《我的世界》、《挺进地牢》和《Descenders》。在Unity 2017.2中Tilemap作为2D功能被引入。本文将介绍一些可以和Tilemap和RuleTile结合使用的算法


使用程序化创建的地图,你可以使游戏关卡拥有完全不同的游戏体验。你也可以使用多种输入信息,使游戏内容能够动态改变。

 

主要内容

我们将简单了解创建程序化世界最为常见的方法,以及创建的一些自定义方法。本文包含一个示例,你可以在完成阅读后使用它来尝试创建程序化世界。要创建一个地图需要三个算法协同运作,配合使用一个Tilemap 和一个RuleTile。


当我们使用这些算法生成地图时,我们会收到一个包含所有新数据的int数组。然后我们可以使用这些数据并对它们进行修改或渲染到一个Tilemap上。


 

在深入阅读前需要了解:

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

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

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

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

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


GenerateArray生成数组的函数

GenerateArray( )生成数组的函数会新建一个指定大小的int数组。我们可以用1或0表示数组是满的或是空的。


代码如下:

public static int[,] GenerateArray(int width, int height, bool empty)

{

    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 (empty)

            {

                map[x, y] = 0;

            }

            else

            {

                map[x, y] = 1;

            }

        }

    }

    return map;

} 

Render Map渲染地图的函数

RenderMap( )渲染地图函数用来将我们的地图渲染到Tilemap。我们可以遍历地图的宽度和高度,如果数组在我们检查的位置包含1,才会放置瓦片。


代码如下:

public static void RenderMap(int[,] map, Tilemap tilemap, TileBase tile)

{

    //清除地图

    tilemap.ClearAllTiles();

    //遍历地图宽度

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

    {

        //遍历地图高度

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

        {

            // 1 = 瓦片, 0 = 不是瓦片

            if (map[x, y] == 1)

            {

                tilemap.SetTile(new Vector3Int(x, y, 0), tile);

            }

        }

    }

}

Update Map更新地图的函数

Update Map( )更新地图函数只用来更新地图,不会重新渲染。这样资源使用得更少,我们不用重新绘制每个瓦片和瓦片数据。

 

代码如下:

public static void UpdateMap(int[,] map, Tilemap tilemap) //进入地图和tilemap,在需要的地方设置空瓦片

{

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

    {

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

        {

            //只更新地图,而不是再次渲染

            //使用较少的资源来更新瓦片为空

            if (map[x, y] == 0)

            {

                tilemap.SetTile(new Vector3Int(x, y, 0), null);

            }

        }

    }

}

Perlin noise柏林噪音算法

Perlin noise柏林噪音算法有很多使用方式。我们使用它得第一个方式是为地图创建顶部图层。这就像使用x轴的当前位置和种子代码获取新的点一样简单。

 

简单

下面生成过程通过柏林噪音的最简单实现形式来生成关卡。我们可以使用Unity函数的柏林噪音算法来帮助我们,所以这个过程不需要什么华丽的编程。我们会使用Mathf.FloorToInt( )函数来给Tilemap提供整数。

 

public static int[,] PerlinNoise(int[,] map, float seed)

{

    int newPoint;

    //用于减少Perlin点的位置

    float reduction = 0.5f;

    //创建Perlin

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

    {

        newPoint = Mathf.FloorToInt((Mathf.PerlinNoise(x, seed) - reduction) * map.GetUpperBound(1));

 

        //确保噪音在高度的中点附近开始

        newPoint += (map.GetUpperBound(1) / 2);

        for (int y = newPoint; y >= 0; y--)

        {

            map[x, y] = 1;

        }

    }

    return map;

}

 

下图是它被渲染到Tilemap上时的样子:




平滑

我们也可以使用下面函数使关卡更为平滑。首先设置间隔来记录柏林高度,然后使点与点之间变得平滑。因为我们必须为间隔考虑整型数列表,所以这个函数稍微更高级一点。


public static int[,] PerlinNoiseSmooth(int[,] map, float seed, int interval)

{

    //平滑噪音并将其存储在int数组中

    if (interval > 1)

    {

        int newPoint, points;

        //用于减少Perlin点的位置

        float reduction = 0.5f;

 

        //用于平滑处理

        Vector2Int currentPos, lastPos;

        // 平滑的相应点

        List<int> noiseX = new List<int>();

        List<int> noiseY = new List<int>();

 

        //创建噪音

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

        {

            newPoint = Mathf.FloorToInt((Mathf.PerlinNoise(x, (seed * reduction))) * map.GetUpperBound(1));

            noiseY.Add(newPoint);

            noiseX.Add(x);

        }

 

        points = noiseY.Count;


 

对于这个函数的第一部分,我们首先会检查间隔是否不止一个。如果答案为是,我们会生成噪音。我们会在间隔生成噪音来实现平滑。下一部分代码会使这些点变得平滑。

 

//从1开始,我们已经有了先前的位置。

            for (int i = 1; i < points; i++)

            {

                //获取当前位置

                currentPos = new Vector2Int(noiseX[i], noiseY[i]);

                //获取最后位置

                lastPos = new Vector2Int(noiseX[i - 1], noiseY[i - 1]);

 

                //找出二个位置之间的差距

                Vector2 diff = currentPos - lastPos;

 

                //设置高度变化值

                float heightChange = diff.y / interval;

                //确定当前高度

                float currHeight = lastPos.y;

 

                for (int x = lastPos.x; x < currentPos.x; x++)

                {

                    for (int y = Mathf.FloorToInt(currHeight); y > 0; y--)

                    {

                        map[x, y] = 1;

                    }

                    currHeight += heightChange;

                }

            }

        }


这个平滑过程通过以下步骤实现:

  • 获取当前位置和最后一个位置;

  • 获取二个位置之间的区别,在此我们想要的关键信息是y轴的区别;

  • 接下来,我们会决定要改变命中的数量是多少,这一步通过使用间隔变量分离y轴差值来完成;

  • 现在可以开始设置位置了。我们会一直降为0;

  • 当我们在y轴按下0时,会添加高度变化到当前高度,并为下一x轴位置重复这个过程;

  • 当完成最后一个位置和当前位置中间的每个位置时,我们会继续处理下一个点。

 

如果间隔少于1,我们会使用前一个函数来完成。

else

        {

            //默认正常的Perlin gen

            map = PerlinNoise(map, seed);

        }

 

        return map;


现在看看它会如何渲染:

 

随机游走函数

随机游走顶部函数

Random Walk Top( )随机游走顶部函数的工作方式如同抛硬币一样。抛出硬币后我们会得到二个结果中的一个。如果得到了正面,我们向上移动一个方块,如果是反面,我们会向下移动一个方块。这个算法的唯一缺点是生成的关卡看起来是一块一块的方块。

 

我们来看看它是如何工作的:

public static int[,] RandomWalkTop(int[,] map, float seed)

{

    //设置随机数

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

 

    //设置起始高度

    int lastHeight = Random.Range(0, map.GetUpperBound(1));

        

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

    {

        //抛硬币

        int nextMove = rand.Next(2);

 

        //减少一些高度

        if (nextMove == 0 && lastHeight > 2)

        {

            lastHeight--;

        }

        //增加一些高度

        else if (nextMove == 1 && lastHeight < map.GetUpperBound(1) - 2)

        {

            lastHeight++;

        }

 

        // lastheight到底部进行循环 

       for (int y = lastHeight; y >= 0; y--)

        {

            map[x, y] = 1;

        }

    }

    //返回地图

    return map;

}


相对于柏林噪音算法的生成方式,这个生成方式给我们带来了平滑的高度。

 


随机游走顶部平滑函数

相对于柏林噪音算法的生成方式,这个生成方式给我们带来了平滑的高度。

 

这个随机游走算法变体会得到相对之前版本更为平滑的高度。我们通过将二个新变量加入函数来实现它:

  • 第一个变量用于决定保持当前高度的时间。这个变量是个整型数,会在我们改变高度值时重置。

  • 第二个变量是个函数的输入变量,它会用作高度的最小部分宽度。当你看到函数代码时会更为理解这个过程。

 

现在我们知道了需要添加什么。那么来一起看看这个函数吧:

public static int[,] RandomWalkTopSmoothed(int[,] map, float seed, int minSectionWidth)

{

    //设置随机数

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

 

    //确定开始位置

    int lastHeight = Random.Range(0, map.GetUpperBound(1));

 

    //确定方向

    int nextMove = 0;

    int sectionWidth = 0;

 

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

    {

        nextMove = rand.Next(2);

 

        if (nextMove == 0 && lastHeight > 0 && sectionWidth > minSectionWidth)

        {

            lastHeight--;

            sectionWidth = 0;

        }

        else if (nextMove == 1 && lastHeight < map.GetUpperBound(1) && sectionWidth > minSectionWidth)

        {

            lastHeight++;

            sectionWidth = 0;

        }

        sectionWidth++;

 

        for (int y = lastHeight; y >= 0; y--)

        {

            map[x, y] = 1;

        }

    }

 

    //返回修改后的地图

    return map;

}

 

从下面的动图可以看出,随机游走算法的平滑方式会在关卡中得到一些效果不错的平坦片段。

 

参考资料

  • RuleTile

    https://github.com/Unity-Technologies/2d-extras/tree/master/Assets/Tilemap/Tiles/Rule%20Tile


  • GetUpperBound() 

    https://msdn.microsoft.com/en-us/library/system.array.getupperbound(v=vs.110).aspx


  • Mathf.FloorToInt()

    https://docs.unity3d.com/ScriptReference/Mathf.FloorToInt.html


结语

我们希望这篇文章能启发开发者在自己的项目中开始使用一些程序化生成的功能。如果你想要了解更多关于程序化生成地图的内容,后续我们会介绍如何使用程序化生成功能创建洞穴系统。尽请期待,更多Unity技术内容分享尽在Unity官方中文论坛(Unitychina.cn)!

 

推荐阅读


优惠促销

Unity Plus加强版春季优惠促销活动

Unity Plus加强版春季优惠促销活动仅剩下最后一周,想订阅Unity Plus加强版,想享受实惠价格以及更多好礼的朋友,千万不要再犹豫了,快快把握最后一周的机会订阅吧 !


促销时间:截至到6月15日(星期五)

促销地址:https://store.unity.com/cn/offer/plus-triple-boost



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

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

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