噪声适合生成洞穴、山脉和矿物等自然地形。给定种子与坐标后,噪声算法就可以计算出一个稳定的值。

但类似未知伤亡的维生舱,十字木头,物资房之类具有明确特征的事先约定好的结构,几乎无法单靠噪声来描述。和未知伤亡不同,本文采用了目前比较成熟且轻量化的方案:字符蓝图。

《未知伤亡》的结构生成

未知伤亡的结构生成有两种生成方式。

算法生成

很难说这种类型应该归类为结构还是地形。我理解中的地形是那种覆盖整个地图(或者地图中的大量区域),由随机算法生成的tile群体,结构是小的离散的tile群体。

用算法生成的结构体,不同类型的结构体使用不同类型的算法。其优点在于灵活性高,各个参数很容易调整。

最典型的例子就是铜矿,在第一层,铜矿生成是在上一期讲的地形生成之后,调用GenerateOres,采用随机游走算法生成的。

还有就是第二层的混凝土障碍物。简单来说就是一个高度和宽度的双重循环,往里面塞混泥土方块,然后随机加一些缺省和其他类型方块的替换。比较朴素,就不贴源代码了。

预制体烘焙

比如维生舱。要人血才能打开的物资房。这类结构的特点通常是具有提前设计好的布局,内部还有其他可交互物品的生成。

在未知伤亡中,这类结构体的生成出现在地形生成之后,例如第二层,在噪声生成地形,然后算法生成本层独有的结构体(混凝土柱)后,调用WorldGenerateStructures来生成预制结构体。

翻阅源码可以注意到,这个方法中也是调用了一些算法生成结构的算法。不过是在预制结构体生成之前,时序上是没问题,但感觉有点乱(

在未知伤亡中,生成预制体结构,首先会调用GenerateObjectAtPos,读取预制体上挂载的tilemap,将其烘焙到世界方块中。例如

1
GenerateObjectAtPos(pos4,Resources.Load<GameObject("Structures/CratePod").GetComponent<Tilemap>(), 0.82f, genMode: true);

随后调用GenerateEntityAtPos,将其他部分(物品或建筑)实例化到对应的位置。

流程参考下图

1
2
3
4
5
6
7
8
9
选择结构

确定结构原点

把结构中的方块写入世界数组

生成结构内的物品和其他对象

把世界数组更新到 Tilemap

生成的确定性保证

首先,我们可以说:CU 的生成并不完全由地图种子决定

FastNoiseLite 内部会根据种子和坐标进行哈希。对于相同的输入会返回相同的噪声值。

但CU出现了大量类似下面的生成:

1
2
3
4
5
6
if (worldBlocks[i, k] > 0 &&
UnityEngine.Random.Range(0f, 1f) > 0.99f)
{
worldBlocks[i, k] =
(ushort)UnityEngine.Random.Range(1, 5);
}

UnityEngine.Random是一个全局随机数生成器,这意味着在其他地方的随机过程会影响随机数的产出。这可能也是未知伤亡本体无法进行地图保存的一个重要原因。

在程序化生成的过程中,如果想要保证生成过程的确定性,随机数的生产状态是需要隔离的。因此不能使用全局随机数生成器,而是要自己构建一个随机数生成器,并且保证用于程序生成的生成器只在当前系统中使用,并且当前系统中也不能使用其他系统的生成器。

使用字符蓝图来生成结构

字符蓝图实际上是一段规范化的字符串,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
e=Clear
s=Set(Steel)
---
sssss
seees
seees
seees
sssss
[Prefabs]
.=None
C=Spawn(TestCrate)
U=SpawnOnce(TestCrate)@8|None@2
---
.....
.C.C.
..U..
.U.U.
.....

这段文本描述了一种结构的方块和物品。

语法介绍

Tile语法

第一部分是支持自定义的符号规则

1
2
e=Clear
s=Set(Steel)

等号左边是蓝图使用的字符,右边是符号对应的操作。Clear代表清除,Set代表写入方块,括号里面是具体的方块类型

---是终止符,后面的字符区域代表方块图案。

1
2
3
4
5
sssss
seees
seees
seees
sssss

这意味着最终会生成一个5×5的结构,外围一圈是钢板,内部是中空。

我一共实现了四种Tile操作:

1
2
3
4
Keep
Clear
Set(TileType)
SetIfEmpty(TileType)

Keep:不做任何处理

Clear:清空方块

Set(TileType):写入指定方块

SetIfEmpty(TileType):填充方块,如果已有方块则不做处理。

并且语法支持随机操作。比如:

1
r=Set(Rock)@7|Set(Gravel)@2|Clear@1

表示在符号r对应的位置,生成岩石,生成砾石,和清空方块的权重分别为7,2和1。

Prefab语法

对于结构中还需要生成的物品,比如灯泡或者箱子,需要添加[Prefab]区段

1
2
3
4
5
6
7
8
9
10
[Prefabs]
.=None
C=Spawn(TestCrate)
U=SpawnOnce(TestCrate)@8|None@2
---
.....
.C.C.
..U..
.U.U.
.....

目前开发了三种Prefab操作:

1
2
3
None
Spawn(PrefabId)
SpawnOnce(PrefabId)

None表示不生成任何对象

Spawn表示在该位置生成指定对象

SpawnOnce表示会搜集所有相同的具有SpawnOnce符号,这些所有符号对应的位置都是候选者,最后在候选者中选择其中一个来生成指定的对象(也有可能不生成)。

也就是说,在这个例子中,C代表的所有位置必定生成一个物品,所有U代表的位置,有80%的概率在其中一个地方生成物品,有20%的概率不会在任何地方生成物品。

数据结构

约定一个这样的规范是比较简单的,难的在于解析。不过好在我们不需要像公开的json解析器一样考虑健壮性,只需要假定一切输入都是合法的,然后按照规则解析即可。

首先我们考虑这个过程的原子操作,那就是一个含权重的针对方块或者物品的特定操作。

因此对于这两种操作,实现对应的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public enum TilePlacementOperation
{
Keep,
Clear,
Set,
SetIfEmpty
}

public readonly struct WeightedTileAction
{
readonly public TileType tileType;
readonly public TilePlacementOperation operation;
readonly public int weight;

private WeightedTileAction(
TilePlacementOperation operation,
TileType tileType,
int weight)
{
this.operation = operation;
this.tileType = tileType;
this.weight = weight;
}

// Pass
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public enum PrefabPlacementOperation
{
None,
Spawn,
SpawnOnce
}

public readonly struct WeightedPrefabAction
{
public readonly PrefabPlacementOperation operation;
readonly public int weight;
readonly public GameObject prefab;

private WeightedPrefabAction(PrefabPlacementOperation operation,GameObject prefab,int weight)
{
this.operation = operation;
this.prefab = prefab;
this.weight = weight;
}

// Pass
}

再从底层往上推,一个字符就对应着多个行为,最终是根据这些行为的权重随机抽取一个作为真正需要执行的行为。所以需要构造一个数据结构来存储字符和行为的映射关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class TileSymbolRule
{
public readonly char symbol;

public readonly IReadOnlyList<WeightedTileAction> actions;

public readonly int totalWeight;
// Pass
}

public class PrefabSymbolRule
{
public readonly char symbol;

public readonly IReadOnlyList<WeightedPrefabAction> actions;

public readonly int totalWeight;
// Pass
}

再往上推,我们需要一个解析器来解析字符串,然后返回一个内存对象,这个内存对象存储着可以直接用来生成具体结构的无需解包的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 被解析完毕的结构数据
public sealed class ParsedStructure
{
public readonly int width;
public readonly int height;

private readonly char[,] tileCells;
private readonly Dictionary<char, TileSymbolRule> tileRules;

private readonly char[,] prefabCells;
private readonly Dictionary<char, PrefabSymbolRule> prefabRules;

// 一些公共方法
}

// 解析器实现
public static class StructureParser{
public static ParsedStructure Parse(string source)
{
// Pass
}
}

解析器实现

解析器首先按换行符切分文本,然后寻找第一处---,将---前面的内容解析为 Tile 规则,后面的解析成Tile布局。如果后续有[Prefabs],则寻找第二处 ---并解析 Prefab 规则和 Prefab 布局。可以参考下面流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
规范化换行

寻找 Tile 分隔符

解析 Tile 规则 → 寻找=分隔符 → 寻找|分割符解析出具体Action对应的文本 → 寻找@解析权重

解析 Tile 分布

寻找可选的 [Prefabs]

解析 Prefab 规则与字符网格 → 寻找=分隔符 → 寻找|分割符解析出具体Action对应的文本 → 寻找@解析权重

构造 ParsedStructure

值得注意的是,文本中的第一行对应图案顶部,但二维数组中的 y=0 位于底部。因此在解析图案时,还需要反转文本行对应的 Y 坐标。

结果

最终,我可以通过一段能够直接阅读和修改的字符蓝图,在噪声地形上生成由钢板、玻璃和 Prefab 组成的建筑。

相同的结构种子和结构原点会得到相同的 Tile 权重选择、Prefab 选择和 SpawnOnce 位置。由于每个位置和随机通道都有独立的随机种子,改变遍历顺序也不会影响生成结果。

1

白色是钢板,红色是测试物品。

还有我瞎吉儿搭建的丐版维生舱

2

蓝图是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
k=Keep
e=Clear
s=Set(Steel)
g=Set(Glass)
a=Set(Steel)@1|Keep@1
---
kkkkkkkkkskkkkkkkkk
kkkkkkkkssskkkkkkkk
kkkkkkkssssskkkkkkk
kkkkkkssssssskkkkkk
kkkkkssssssssskkkkk
kkkkssssssssssskkkk
kkkssssssssssssskkk
kksseeeeeeeeeeesskk
kksseeeeeeeeeeesskk
kksseeeeeeeeeeesskk
kkssgggggggggggsskk
kkssgggggggggggsskk
kkssgggggggggggsskk
aasssssssssssssssaa
kksseeeeeeeeeeesskk
kksseeeeeeeeeeesskk
kksseeeeeeeeeeesskk
kksseeeeeeeeeeesskk
kksseeeeeeeeeeesskk
kkssssssssssssssskk
kkssseeeeeeeeessskk
kkkksseeeeeeesskkkk
kkkksseeeeeeesskkkk
kkkkssssssssssskkkk
kkkkkkssssssskkkkkk