之前聊过了战斗系统的优化,今天聊聊《终面》中的关卡系统,关于我是如何实现的,以及应该怎么去优化它。

首先,《终面》的关卡结构是下面这样的一颗固定的树状结构:

可以看到,不包括开场CG,整个游戏一共有24个关卡(场景),每个关卡有独立的编号、类型,以及所指向的后续关卡(这是固定的)。

我的做法

由于每个场景的房间布局是确定的,因此我为每种类型的关卡创建了一个场景:

其中 0_Driver 只会在刚进入游戏时加载一次,用于各种系统(包括关卡系统)的初始化。这个场景存在的目的是避免过场景不销毁的一些组件在游戏返回标题时与新建的组件产生冲突。

关卡的数据结构是一个结构体:

1
2
3
4
5
6
7
8
9
10
11
[System.Serializable]public struct LevelStruct {
public string name; // 关卡类型
public int id; // 关卡id(用来确定进度)
public int[] childId; // 子关卡id
// 构造
public LevelStruct(string n, int i, int[] p) {
name = n;
id = i;
childId = p;
}
}

随后,在关卡管理器中维护一个记录了所有关卡结构体信息的列表:

1
[SerializeField] public List<LevelStruct> levels = new List<LevelStruct>();

然后我们就可以在 0_Driver 场景为关卡管理器手动填写所有关卡的信息,如下所示:

通过当前关卡的id和子关卡id,就可以确定玩家在完成关卡后进行房间选择时,接下来去的关卡是什么房间。房间门上的logo也会根据id对应的房间名称而改变。

1
2
3
public int curID;
public List<int> nextLevel;
public FinishLevel[] gates; // 这是管理场景切换逻辑的“房门”

这样的写法虽然可以实现关卡树的逻辑,但需要开发者手动将内容填入检查器中的脚本里,比较麻烦也不好看。经过一段时间的思考,我想到了对于关卡树实现的一种更好的优化方式。

优化

首先,关卡的基本结构不需要改变,按照之前的就可以:

1
2
3
4
5
public class Level {
public string name; // 关卡类型
public int id; // 关卡id(用来确定进度)
public int[] childId; // 子关卡id
}

然后我们可以配置一个本地文件(JSON或者xml都可以),用来存放所有的关卡:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let level = [
{
"id" : 0,
"name" : "CG",
"childId" : [1]
},
{
"id" : 1,
"name" : "Tutorial",
"childId" : [2]
},
{
"id" : 2,
"name" : "Normal1",
"childId" : [3, 4]
},
{
"id" : 3,
"name" : "Normal2",
"childId" : [5, 6]
}
// 所有关卡
]

把关卡配置放在配置文件的好处是方便策划等其他人员修改,而且JSON的可读性比较高(看起来比较直观),不然每次想要修改关卡都要打开Unity编辑器的 0_Driver 场景,有点麻烦。

我们仍然需要 LevelManager 脚本来管理当前的关卡进度。在 LevelManager 中,我们读取JSON配置文件把它存到脚本中。然后,我们就可以根据当前的房间 id 拿取 level 中对应 id 的对象(当前关卡信息),进而得到它的子关卡 id 数组信息,然后我们就可以根据信息做别的操作(例如设置房间门的logo)了。

关于如何维护 id 其实很简单,由 0_Driver 、“返回标题”按钮、玩家死亡时回回到CG场景,这时触发重开游戏的事件,调用重置关卡管理器 id 为 0 (也就是对应第0个关卡)的回调函数即可。