Unity-随机地牢开发日记#1

本文章首发于我的个人博客,希望大家多多支持!


Hi! This is Showhoop Studio!

你即将看到的是我的Unity随机地牢游戏的第一篇开发日记。这个游戏的灵感来源于小白的大冒险,我借鉴了它的主要玩法——也就是随机地牢,来作为练习,提高自己的游戏开发技术,同时也丰富一下自己的作品集。在这一篇日记中,我将详细为大家介绍一下我所采用的随机生成算法,供大家学习。废话不多说,立马进入正题。

概览

在开始之前,我们先来看看效果(浅色为地面,深色为围墙障碍)。
Unity-随机地牢开发日记#1

正如你们所看到的一样,这个过程实际上非常的简单:通过若干个方块在平面内随机移动来生成地面,之后在其周围生成围墙,最后去掉中间那些意义不大的单独的墙。那么现在我们对这个过程有了一个基本的了解,然后我们便可以着手开始写代码了。

我们下面讨论的脚本代码不会有上面图片中那样的过程演示效果,是直接生成的!

数据结构

我们首先定义一些数据结构。显然对于上述过程我们应该采用一个二维数组来标记一个空间内的每一个小格,而每一个小格会有三种类型:地面,墙壁和空类型__(注意,之前我们没有提到空类型,但实际上它是存在的,地面和墙壁没有充满整个空间,剩余的地方就是空的)__。

// 包含空,地面和墙壁的枚举类型
private enum Grid {empty, floor, wall};
// 整个可生成的正方形空间
private Grid[,] grid;
// 正方形空间的大小
// 你也可以使用public类型以便于调试
private int width = 30;
private int height = 30;

到这里我们已经迈出了第一步,接下来我们将开始定义“移动”的方块的数据结构,我们先姑且称之为indicator。

private struct Indicator
{
    // Indicator的坐标位置
    public Vector2 pos
    // Indicator的方向
    public Vector2 dir
}
// 一个Indicator类型的链表,它将包含若干个indicators
private List<Indicator> inds;
// Indicator转变方向的概率
private float chanceToTurn = 0.5f;
// 生成一个新的Indicator的概率
private float chanceToSpawn = 0.05f;
// 销毁一个Indicator的概率
private float chanceToDestroy = 0.05f;
// 同时存在的Indicators的最大数目
private int maxAmount = 10;

最后,我们需要指定地牢的填充比率

填充比率指的是地面数量与整个网格空间格子数量的比率。这里我们不想让地牢充满整个空间,只让它充满一部分,以此增加其随机性。

private float fillRatio = 0.4f;

到这里,核心算法所需的所有变量都创建完毕了,接下来我们将具体实现核心算法。

核心算法

初始化

首先,我们需要一个初始化函数来配置好环境。

void Init()
{
    // 初始化网格
    grid = new Grid[width, height];
    foreach(Grid sqr in grid)
    {
        sqr = Grid.empty;
    }
    // 初始化Indicator
    inds = new List<Indicator>();
    Indicator ind = new Indicator();
    // 随机选择初始的方向
    // RandomDirection()会随机生成一个Vector2的变量,稍后会提供
    ind.dir = RandomDirection();
    // 初始化生成位置
    ind.pos = new Vector2(Mathf.RoundToInt(width / 2.0f), Mathf.RoundToInt(height / 2.0f));

    inds.Add(ind);
}

生成地面

在初始化完成之后,我们就可以开始生成地面了。

void FloorGeneration()
{
    // 在达到一定覆盖率之前,循环维持执行,但是while(true)可能会造成不可预计的后果
    int counter = 0;

    while(counter < 123456)
    {
        // 生成地面
        foreach(Indicator floor in inds)
        {
            grid[(int)floor.pos.x, (int)floor.pos.y] = Grid.floor;
        }

        // 销毁Indicator
        int numbers = inds.Count;
        for(int i = 0; i < numbers; i++)
        {
            if(Random.value < chanceToDestroy && numbers > 1)
            {
                inds.RemoveAt(i);
                break; // 每次循环只销毁一个Indicator
            }
        }

        // Indicator变换方向
        for(int i = 0; i < inds.Count; i++)
        {
            if(Random.value < chanceToTurn)
            {
                Indicator tempInd = inds[i];
                tempInd.dir = RandomDirection();
                inds[i] = tempInd;
            }
        }

        // 生成新的Indicator
        int num = inds.Count;
        for(int i = 0; i < num; i++)
        {
            // Note:生成的概率不宜太高
            if(Random.value < chanceToSpawn && num < maxAmount)
            {
                Indicator newInd = new Indicator()
                newInd.dir = RandomDirection();
                newInd.pos = inds[i].pos;
                inds.Add(newInd);
            }
        }

        // 移动Indicator
        foreach(Indicator ind in inds)
        {
            Indicator tempInd = ind;
            // 方向是规范化的,而且只有上下左右四个方向,所以可直接加上方向来移动
            tempInd.pos += tempInd.dir;
            ind = tempInd;
        }

        // 避免Indicator移动到边缘,因为至少需要为墙壁预留一格的空间
        foreach(Indicator ind in inds)
        {
            Indicator tempInd = ind;
            // Clamp将一个值限制在一个给定的最小值和给定的最大值之间
            tempInd.pos.x = Mathf.Clamp(tempInd.pos.x, 1, width - 2);
            tempInd.pos.y = Mathf.Clamp(tempInd.pos.y, 1, height - 2);
            ind = tempInd;
        }

        // 检查循环结束条件,即覆盖率
        if((float)NumberOfFloors() / (float)grid.length > fillRatio)
        {
            break;
        }
    }
}

关于Clamp的详细介绍可以参考官方文档

生成墙壁

生成墙壁的过程比生成地面要来的简单,我们遍历每一个网格,如果某个网格是floor,那么我们就检查这个网格的四周,如果它周围存在empty的格子,那么我们就把这个empty的格子赋值为wall

void WallGeneration()
{
    for(int i = 0; i < width; i++)
    {
        for(int j = 0; j < height; j++)
        {
            if(grid[i, j] == Grid.floor)
            {
                if(grid[i - 1, j] == Grid.empty)
                    grid[i - 1, j] = Grid.wall;
                if(grid[i + 1, j] == Grid.empty)
                    grid[i + 1, j] = Grid.wall;
                if(grid[i, j - 1] == Grid.empty)
                    grid[i, j - 1] = Grid.wall;
                if(grid[i, j + 1] == Grid.empty)
                    grid[i, j + 1] = Grid.wall;
            }
        }
    }
}

移除单独的墙壁

在上面两个过程完成后,不可避免的会生成一些单个的(即四面都是地面)墙壁,这些墙壁并没有很大的存在价值,所以我们现在需要写一个函数移除它们(当然你也可以选择保留)。移除的方法也很简单,检查每一个网格,如果一个wall的四周都是floor,那我们就将他赋值为floor

void RemoveSingle()
{
    for(int i = 0; i < width; i++)
    {
        for(int j = 0; j < height; j++)
        {
            if(grid[i, j] == Grid.wall)
            {
                // 假设其周围全是floor
                bool all = true;
                for(int x = -1; x <= 1; x++)
                {
                    for(int y = -1; y <= 1; y++)
                    {
                        // 边界检查
                        if(i + x < 0 || i + x > width - 1 || j + y < 0 || j + y > height - 1)
                            continue;

                        // 跳过中心和四个角落,只检查上下左右四个方位
                        if((x != 0 && y != 0) || (x == 0 && y == 0))
                            continue;

                        if(grid[i + x, j + y] != Grid.floor)
                            all = false;
                    }
                }
                if(all)
                {
                    grid[i, j] = Grid.floor;
                }
            }
        }
    }
}

其他

随机选择方向

还记得我们在生成地面时遗留的RandomDirection()吗?现在我们来将它搞定!

Vector2 RandomDirection()
{
    // 生成一个0-3之间的随机整数
    int choice = Mathf.FloatToInt(Random.value * 3.99f);

    switch(choice)
    {
        case 0:
            return Vector2.down;
        case 1:
            return Vector2.up;
        case 2:
            return Vector2.left;
        default:
            return Vector2.right;
    }
}

可视地图生成

现在我们已经可以生成地图了,但是我们什么也看不到,因为我们生成的地图还在一个矩阵里,我们需要将其可视化(不然有什么意义呢)。这里我采取的方法是,定义两个publicGameObject类型的变量,在脚本的Inspector窗口内绑定预制体,然后利用函数将矩阵坐标转换的3D世界坐标(这里我将Y轴始终设为0,便于坐标转换)并生成游戏对像实例。这里我简单制作了几个不同的地面和墙壁模型,因为我希望地图看上不那么单一,在这种情况下,我们还需要在生成预制体时随机选取其中一个。下面贴上代码。

public GameObject[] floors;
public GameObject[] walls;

void Spawn(float x, float y, GameObjct go)
{
    // 首先要确保预制体顶视图是一个正方形
    // 这里的length是一顶视图的边长
    int length = 1;
    // 计算生成位置的坐标
    Vector2 spawnPos = new Vector2(x, y) * length;
    // 给预制体指定一个朝向,让地图更加多样化
    Quaternion orientation = Quaternion.identity;
    orientation.eulerAngles = ObjOrientation();

    GameObject toSpawn = Instantiate(go, new Vector3(spawnPos.x, 0, spawnPos.y), orientation) as GameObject;
}

// 生成地图
void SpawnMap()
{
    for (int x = 0; x < width; x++)
    {
        for (int y = 0; y < height; y++)
        {
            switch(grid[x, y])
            {
                case gridSpace.empty:
                    break;
                case gridSpace.floor:
                    Spawn(x, y, selectFloor());
                    break;
                case gridSpace.wall:
                    Spawn(x, y, selectWall());
                    break;
            }
        }
    }

}

// 随机选取一个朝向
Quaternion ObjOrientation()
{
    int choice = Mathf.FloorToInt(Random.value * 3.99f);

    switch (choice)
    {
        case 0:
            return new Vector3 (0, 0, 0);
        case 1:
            return new Vector3 (0, 90, 0);
        case 2:
            return new Vector3 (0, -90, 0);
        default:
            return new Vector3 (0, 180, 0);
    }
}

随机选取地面和墙壁类型也是用上述方法,再将返回值传递给Spawn(float, float, GameObject),这里我就不赘述了。

完成

最后我们只需要在Start()函数里面依次调用这些函数就可以了。

void Start()
{
    Init();
    FloorGeneration();
    WallGeneration();
    RemoveSingle();
    SpawnMap();
}

最终成果

一路写下来辛苦了!接下来让我们看看成果吧!
Unity-随机地牢开发日记#1
Unity-随机地牢开发日记#1

参考

[1] Procedural Generation link
[2] Roguelike Generation link
[3] Random Dungeon Generator link

结束语

如果你读到了这句话,说明你已经看完这篇博客啦!非常感谢您的阅读,希望这篇博客能帮你您!下一期我们将一起研究学习通过角色控制器来控制人物的移动和转向,如果您感兴趣,不妨关注一下。下次再见。


欢迎在评论区留下自己的问题,我会尽快给出回复。你也可以指出文章的纰漏,或是给出你对文章的其他看法。你也可以在文章页面右侧的在线聊天室与我取得联系。如果你喜欢这篇文章,可以点击文末或者页面左侧的分享按钮分享给其他人,非常感谢!