TeaCon 2023 言传身教奖获奖文章·一·真正的和平模式(三)

原文载于知乎专栏《【真正的和平模式】三、结构的生成》,作者为 Viola-Siemens,
原地址:https://zhuanlan.zhihu.com/p/646365902


【真正的和平模式】三、结构的生成

上一篇讲了任务系统的实现,内容毕竟偏,并非所有人都需要它。本篇讲结构如何生成。

开头想说点骚话,实在想不出就算了,直接步入正题。


一、结构的分类

生成结构多种多样,为了方便区分,我根据生成方式大致可以分为三类:普通结构、多级结构和拼图结构。

从原版里举几个例子:

  • 埋藏的宝藏、沉船、沙漠神殿、丛林神庙等属于普通结构,特点是它们只有一个piece,而且通常使用nbt来保存结构的模板。
  • 林地府邸、要塞、下界要塞、海底神殿等属于多级结构,特点是它们有多个piece,生成分多个层级,而且没有nbt模板,仅通过代码控制生成,不同piece随着结构的生成通过某种状态转移来切换。
  • 掠夺者前哨站、村庄、堡垒遗迹(即猪堡)、远古城市属于拼图结构,特点是有一个中心、仍然是多层生成、有多个结构中可能存在的模板、模板中通过拼图方块控制接下来的生成方向与目标池。
  • 需要注意的是,有些结构比较特殊,如雪屋这个结构似乎要介于普通结构与多级结构之间——取决于它是否有地下室,不过我们可以把它归入第二类中。

在代码实现方面,拼图结构无需额外代码控制生成,只需完成数据包即可。而其他两种则需要额外的结构注册与生成,尤其是第二种,需要相对多一些的代码量。

二、结构的注册

1.19.3及之后,结构和结构集本身无需多余的注册,通过数据包即可实现生成。不过StructureType和StructurePieceType依然需要常规注册。

StructureType用于世界生成时唯一标识当前区块包含的结构类型,并链接特定的CODEC完成序列化和反序列化。

以《真正的和平模式》中的五个结构举例(无需包含拼图结构),注册方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
private static final DeferredRegister<StructureType<?>> REGISTER = DeferredRegister.create(Registries.STRUCTURE_TYPE, MODID);

public static final RegistryObject<StructureType<CrystalSkullIslandFeature>> CRYSTAL_SKULL_ISLAND = register("crystal_skull_island", () -> CrystalSkullIslandFeature.CODEC);
public static final RegistryObject<StructureType<AbandonedMagicPoolFeature>> ABANDONED_MAGIC_POOL = register("abandoned_magic_pool", () -> AbandonedMagicPoolFeature.CODEC);
public static final RegistryObject<StructureType<PinkCreeperFeature>> PINK_CREEPER = register("pink_creeper", () -> PinkCreeperFeature.CODEC);
public static final RegistryObject<StructureType<ZombieFortFeature>> ZOMBIE_FORT = register("zombie_fort", () -> ZombieFortFeature.CODEC);
public static final RegistryObject<StructureType<SkeletonPalaceFeature>> SKELETON_PALACE = register("skeleton_palace", () -> SkeletonPalaceFeature.CODEC);


private static <T extends Structure> RegistryObject<StructureType<T>> register(String name, StructureType<T> codec) {
return REGISTER.register(name, () -> codec);
}

而StructurePieceType则用于世界生成中标识结构的每一个部分——比如多级结构中的要塞的每个房间、雪屋的主体、地下室和梯子等等,对于普通结构而言则只需要注册本体的PieceType:

1
2
3
4
5
6
7
8
9
public static final StructurePieceType CRYSTAL_SKULL_ISLAND_TYPE = register("crystal_skull_island", CrystalSkullIslandPieces.CrystalSkullIslandPiece::new);
public static final StructurePieceType ABANDONED_MAGIC_POOL_TYPE = register("abandoned_magic_pool", AbandonedMagicPoolPieces.AbandonedMagicPoolPiece::new);
public static final StructurePieceType PINK_CREEPER_TYPE = register("pink_creeper", PinkCreeperPieces.PinkCreeperPiece::new);
public static final StructurePieceType ZOMBIE_FORT_TYPE = register("zombie_fort", ZombieFortPieces.ZombieFortPiece::new);
public static final StructurePieceType SKELETON_PALACE_TYPE = register("skeleton_palace", SkeletonPalacePieces.SkeletonPalacePiece::new);

private static StructurePieceType register(String name, StructurePieceType type) {
return Registry.register(BuiltInRegistries.STRUCTURE_PIECE, new ResourceLocation(MODID, name), type);
}

注意,世界生成是允许动态注册的,因此没有客户端服务端的同步检测(因而仅修改世界生成的mod可以仅服务端加载)、甚至有些内容并不需要延迟注册(使用DeferredRegister)。你可以直接在模组的构造时加载这个注册工具类。

以废弃的魔法池为例,生成Piece的写法如下:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
public class AbandonedMagicPoolPieces {
private static final ResourceLocation ABANDONED_MAGIC_POOL = new ResourceLocation(MODID, "abandoned_magic_pool/abandoned_magic_pool");

public static void addPieces(StructureTemplateManager structureManager, BlockPos pos, Rotation rotation, StructurePieceAccessor pieces) {
pieces.addPiece(new AbandonedMagicPoolPiece(structureManager, ABANDONED_MAGIC_POOL, pos, rotation));
}

public static class AbandonedMagicPoolPiece extends TemplateStructurePiece {
public AbandonedMagicPoolPiece(StructureTemplateManager structureManager, ResourceLocation location, BlockPos pos, Rotation rotation) {
super(RPMStructurePieceTypes.ABANDONED_MAGIC_POOL_TYPE, 0, structureManager, location, location.toString(), makeSettings(rotation), pos.offset(-5, -1, -5));
}

public AbandonedMagicPoolPiece(StructurePieceSerializationContext context, CompoundTag tag) {
super(RPMStructurePieceTypes.ABANDONED_MAGIC_POOL_TYPE, tag, context.structureTemplateManager(), (location) -> makeSettings(Rotation.valueOf(tag.getString("Rot"))));
}


private static StructurePlaceSettings makeSettings(Rotation rotation) {
return (new StructurePlaceSettings())
.setRotation(rotation)
.setMirror(Mirror.LEFT_RIGHT)
.setRotationPivot(new BlockPos(5, 1, 5))
.addProcessor(BlockIgnoreProcessor.STRUCTURE_BLOCK);
}


@Override
protected void addAdditionalSaveData(StructurePieceSerializationContext context, CompoundTag tag) {
super.addAdditionalSaveData(context, tag);
tag.putString("Rot", this.placeSettings.getRotation().name());
}

@Override
protected void handleDataMarker(String function, BlockPos pos, ServerLevelAccessor level, RandomSource random, BoundingBox sbb) {
}

public static final double BROKEN_BRICKS_PERCENTAGE = 0.25D;
public static final double BROKEN_WOOD_PERCENTAGE = 0.15D;
public static final double COBWEB_PERCENTAGE = 0.4D;

@Override
public void postProcess(WorldGenLevel level, StructureManager structureManager, ChunkGenerator chunkGenerator, RandomSource random,
BoundingBox boundingBox, ChunkPos chunkPos, BlockPos blockPos) {
super.postProcess(level, structureManager, chunkGenerator, random, boundingBox, chunkPos, blockPos);
BoundingBox curBoundingBox = this.getBoundingBox();
for(int x = 0; x < curBoundingBox.getXSpan(); ++x) {
for(int z = 0; z < curBoundingBox.getZSpan(); ++z) {
for(int y = 0; y < curBoundingBox.getYSpan(); ++y) {
BlockState blockstate = this.getBlock(level, x, y, z, boundingBox);
if(isBricks(blockstate)) {
if (random.nextDouble() < BROKEN_BRICKS_PERCENTAGE) {
if(random.nextDouble() < COBWEB_PERCENTAGE) {
this.placeBlock(level, Blocks.COBWEB.defaultBlockState(), x, y, z, boundingBox);
} else {
this.placeBlock(level, Blocks.AIR.defaultBlockState(), x, y, z, boundingBox);
}
}
} else if(isWood(blockstate)) {
if (random.nextDouble() < BROKEN_WOOD_PERCENTAGE) {
if(random.nextDouble() < COBWEB_PERCENTAGE) {
this.placeBlock(level, Blocks.COBWEB.defaultBlockState(), x, y, z, boundingBox);
} else {
this.placeBlock(level, Blocks.AIR.defaultBlockState(), x, y, z, boundingBox);
}
}
}
}
}
}
}

private static boolean isBricks(BlockState blockstate) {
return blockstate.is(Blocks.BRICKS) || blockstate.is(Blocks.BRICK_SLAB) || blockstate.is(Blocks.BRICK_STAIRS) || blockstate.is(Blocks.BRICK_WALL);
}

private static boolean isWood(BlockState blockstate) {
return blockstate.is(Blocks.MANGROVE_PLANKS) || blockstate.is(Blocks.MANGROVE_STAIRS) || blockstate.is(Blocks.MANGROVE_SLAB) || blockstate.is(Blocks.MANGROVE_TRAPDOOR) || blockstate.is(Blocks.MANGROVE_LOG);
}
}
}

addPieces方法则是入口方法,将结构的所以部分加入StructurePieceAccessor参与世界生成。注意ABANDONED_MAGIC_POOL表征了结构nbt的路径,它被放在数据包的data\real_peaceful_mode\structures中。

AbandonedMagicPoolPiece继承了TemplateStructurePiece结构部分模板类,若无其它内容,只需重写一个空的handleDataMarker函数即可——如果你使用了数据模式的结构方块,在这里当然需要作额外处理,这部分接下来再讲;如果有其它需要存储的数据,则需要重写addAdditionalSaveData函数;另外生成过程中可能需要额外的方块变化,如随机苔藓化、裂纹、氧化等等,这部分可以用postProcess来实现,其过程中调用this.placeBlock函数改变模板的方块来放置到世界中——本例则实现了砖块和木头的随机破坏和替换为蜘蛛网。

最终效果

那么入口函数如何调用呢?此处需要一个继承Structure并定义CODEC的类来解决:

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
public class AbandonedMagicPoolFeature extends Structure {
public static final Codec<AbandonedMagicPoolFeature> CODEC = simpleCodec(AbandonedMagicPoolFeature::new);

public AbandonedMagicPoolFeature(StructureSettings settings) {
super(settings);
}

@Override
protected Optional<GenerationStub> findGenerationPoint(GenerationContext context) {
return Optional.of(new GenerationStub(context.chunkPos().getWorldPosition(), builder -> generatePieces(builder, context)));
}

private static void generatePieces(StructurePiecesBuilder builder, GenerationContext context) {
BlockPos centerOfChunk = new BlockPos(context.chunkPos().getMinBlockX() + 2, 0, context.chunkPos().getMinBlockZ() + 2);
BlockPos blockpos = new BlockPos(
centerOfChunk.getX(),
context.chunkGenerator().getBaseHeight(
centerOfChunk.getX(), centerOfChunk.getZ(), Heightmap.Types.WORLD_SURFACE_WG, context.heightAccessor(), context.randomState()
),
centerOfChunk.getZ()
);
Rotation rotation = Rotation.getRandom(context.random());
AbandonedMagicPoolPieces.addPieces(context.structureTemplateManager(), blockpos, rotation, builder);
}

@Override
public StructureType<?> type() {
return RPMStructureTypes.ABANDONED_MAGIC_POOL.get();
}
}

其中findGenerationPoint用来找到区块中哪个位置可用于结构生成,如果存在便执行生成函数——generatePieces是生成结构的函数,addPieces入口便是在这里被调用。type则是生成结构的类型,对应着前文的StructureType。

以前还需重写step函数,否则会抛出异常——1.19.3后不再需要多此一举,因为可以直接在数据包的json里写了。

三、普通结构数据包的编写

前文只是实现了结构的注册和生成逻辑的编写,还没能真正将结构生成在世界中。由于1.19.3代码结构的大改,仅需实现数据包即可实现结构的生成。

以废弃的魔法池为例,我们在data\real_peaceful_mode\worldgen\structure目录下创建abandoned_magic_pool.json:

1
2
3
4
5
6
7
{
"type": "real_peaceful_mode:abandoned_magic_pool",
"biomes": "#real_peaceful_mode:has_structure/abandoned_magic_pool",
"spawn_overrides": {},
"step": "surface_structures",
"terrain_adaptation": "beard_thin"
}

其中type对应着StructureType,需要与注册保持一致;spawn_overrides重写了结构内部的生物生成,如古城不会生成任何生物、掠夺者前哨站会源源不断生成掠夺者等;step表示生成的步骤,最常见的则是”underground_structures”和”surface_structures”;terrain_adaptation是生成后的地形调整,建议地表建筑使用”beard_thin”保证不会悬空,而地下建筑则多为”bury”,古城使用了”beard_box”,悬空建筑(如水晶头骨浮岛)则留空;最重要的,biomes表示结构生成的群系,最好使用TagKey表示,于是我们在data\real_peaceful_mode\tags\worldgen\biome\has_structure目录下创建abandoned_magic_pool.json:

1
2
3
4
5
6
{
"values": [
"minecraft:swamp",
"minecraft:mangrove_swamp"
]
}

实现了结构,接下来实现结构集——即多个变种的相同类型结构的集合,如村庄结构集包括平原、热带草原、沙漠、雪原和针叶林五个变种,废弃的传送门结构集则包括下界、高山、深海、沙漠和普通等变种。

我们在data\real_peaceful_mode\worldgen\structure_set目录下创建abandoned_magic_pools.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"placement": {
"type": "minecraft:random_spread",
"salt": 2123071613,
"separation": 9,
"spacing": 30
},
"structures": [
{
"structure": "real_peaceful_mode:abandoned_magic_pool",
"weight": 1
}
]
}

salt建议随机生成,不要跟任何结构重复;separation是同一结构集内两个结构间的最小距离(单位:区块),而spacing则是两个结构间的平均距离(单位:区块),显然这个值要大于separation;type则是结构的分布,random_spread是随机分布,而concentric_rings则是类似要塞的分布方式——若是random_spread,还有可选的字段spread_type,可选值包括linear和triangular,即距离分布的类型,triangular使得结构更加平均分散,而linear距离的方差则更大。structures包括了这个结构集的所有结构,并以权重组织着它们的生成。

按照这个步骤走下来的话,你的结构大概率就生成在世界中了。不过还有一些特殊情况,比如拼图结构的定义,以及processor list的编写。

四、拼图结构的模板池和processor list
首先给原版知识储备较少的朋友们介绍一下什么是拼图结构。在处理结构群(如村庄)或较大面积结构的生成(如古城)时,或者希望在结构生成过程中不同“部分”的连接引入随机性时(如堡垒遗迹),可以考虑使用拼图结构。拼图结构的每个模板结构都应该包含拼图方块,并指定name(拼图名称)、final_state(拼图方块最终转变成什么方块)、joint(拼接类型)、pool(目标池)、target(需要对接的拼图名称)——两个拼图方块之间分别以相同的name与target互相连接,而生成过程中拼图方块则从pool目标模板池中选择新的结构并拼接好后生成。

这部分同样只需要添加数据包。以苦力怕小镇为例,需要在data\real_peaceful_mode\worldgen\template_pool\creeper_town目录下编写模板池,以房屋池为例(houses.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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
{
"elements": [
{
"element": {
"element_type": "minecraft:legacy_single_pool_element",
"location": "real_peaceful_mode:mission/creeper_town/houses/small_house1",
"processors": "real_peaceful_mode:ruin_8_and_crack_40_percent",
"projection": "rigid"
},
"weight": 4
},
{
"element": {
"element_type": "minecraft:legacy_single_pool_element",
"location": "real_peaceful_mode:mission/creeper_town/houses/small_house2",
"processors": "real_peaceful_mode:ruin_8_and_crack_40_percent",
"projection": "rigid"
},
"weight": 4
},
{
"element": {
"element_type": "minecraft:legacy_single_pool_element",
"location": "real_peaceful_mode:mission/creeper_town/houses/small_house3",
"processors": "real_peaceful_mode:ruin_8_and_crack_40_percent",
"projection": "rigid"
},
"weight": 3
},
{
"element": {
"element_type": "minecraft:legacy_single_pool_element",
"location": "real_peaceful_mode:mission/creeper_town/houses/middle_house1",
"processors": "real_peaceful_mode:ruin_8_and_crack_40_percent",
"projection": "rigid"
},
"weight": 2
},
{
"element": {
"element_type": "minecraft:legacy_single_pool_element",
"location": "real_peaceful_mode:mission/creeper_town/houses/middle_house2",
"processors": "real_peaceful_mode:ruin_8_and_crack_40_percent",
"projection": "rigid"
},
"weight": 2
},
{
"element": {
"element_type": "minecraft:legacy_single_pool_element",
"location": "real_peaceful_mode:mission/creeper_town/houses/middle_house3",
"processors": "real_peaceful_mode:ruin_8_and_crack_40_percent",
"projection": "rigid"
},
"weight": 2
},
{
"element": {
"element_type": "minecraft:legacy_single_pool_element",
"location": "real_peaceful_mode:mission/creeper_town/houses/large_house1",
"processors": "real_peaceful_mode:ruin_8_and_crack_40_percent",
"projection": "rigid"
},
"weight": 1
},
{
"element": {
"element_type": "minecraft:legacy_single_pool_element",
"location": "real_peaceful_mode:mission/creeper_town/houses/large_house2",
"processors": "real_peaceful_mode:ruin_8_and_crack_40_percent",
"projection": "rigid"
},
"weight": 1
},
{
"element": {
"element_type": "minecraft:legacy_single_pool_element",
"location": "real_peaceful_mode:mission/creeper_town/houses/farm1",
"processors": "real_peaceful_mode:ruin_8_percent",
"projection": "rigid"
},
"weight": 2
},
{
"element": {
"element_type": "minecraft:legacy_single_pool_element",
"location": "real_peaceful_mode:mission/creeper_town/houses/farm2",
"processors": "real_peaceful_mode:ruin_8_percent",
"projection": "rigid"
},
"weight": 2
},
{
"element": {
"element_type": "minecraft:legacy_single_pool_element",
"location": "real_peaceful_mode:mission/creeper_town/houses/fountain",
"processors": "real_peaceful_mode:ruin_8_and_crack_40_percent",
"projection": "rigid"
},
"weight": 1
},
{
"element": {
"element_type": "minecraft:legacy_single_pool_element",
"location": "real_peaceful_mode:mission/creeper_town/houses/well",
"processors": "real_peaceful_mode:ruin_8_and_crack_40_percent",
"projection": "rigid"
},
"weight": 1
},
{
"element": {
"element_type": "minecraft:empty_pool_element"
},
"weight": 5
}
],
"fallback": "real_peaceful_mode:creeper_town/terminators"
}

elements包含了所有池内的模板结构,每个结构由element和weight组成,分别表示结构细节和权重。

element_type常有以下三种取值:

minecraft:empty_pool_element:即空结构,表示在一定权重下,该位置不生成新的模板。
minecraft:legacy_single_pool_element:单结构,通过location定位在structures文件夹中的模板结构。
minecraft:feature_pool_element:即地物结构,直接生成一个地物,通过feature定位一个地物的注册名。
另外两个很好理解,主要介绍一下单结构的一些属性——

location:结构模板的id
processors:processor list的id
projection:投影类型,包括rigid(视为整体放置,多用于房屋、农田、水井等不因地形改变而改变的结构部分)和terrain_matching(匹配地形,多用于道路)。
那么processor list又是什么?其实就是一个结构的部分生成结束后再进行的处理,与前文的postProcess函数功能类似。如苦力怕小镇的道路使用了如下处理,使得水和极少部分的沙砾被替换为了凝灰岩砖(street_creeper_town.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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
{
"processors": [
{
"processor_type": "minecraft:rule",
"rules": [
{
"input_predicate": {
"block": "minecraft:gravel",
"predicate_type": "minecraft:block_match"
},
"location_predicate": {
"block": "minecraft:water",
"predicate_type": "minecraft:block_match"
},
"output_state": {
"Name": "real_peaceful_mode:tuff_bricks"
}
},
{
"input_predicate": {
"block": "minecraft:gravel",
"predicate_type": "minecraft:random_block_match",
"probability": 0.1
},
"location_predicate": {
"predicate_type": "minecraft:always_true"
},
"output_state": {
"Name": "real_peaceful_mode:tuff_bricks"
}
},
{
"input_predicate": {
"block": "real_peaceful_mode:tuff_bricks",
"predicate_type": "minecraft:block_match"
},
"location_predicate": {
"block": "minecraft:water",
"predicate_type": "minecraft:block_match"
},
"output_state": {
"Name": "real_peaceful_mode:cracked_tuff_bricks"
}
}
]
}
]
}

其详细写法,建议参考官方wiki

编者注:上述链接目前已随 Minecraft Wiki 迁移而转移至 https://zh.minecraft.wiki/w/%E8%87%AA%E5%AE%9A%E4%B9%89%E4%B8%96%E7%95%8C%E7%94%9F%E6%88%90/processor

五、怎么生成和编辑结构nbt文件?

前文的所有介绍,默认你知道了所有关于生成nbt文件的知识,这里额外多提一嘴,防止有人不知道这个问题的答案,影响阅读体验。

首先随便建点什么,想生成啥就建啥:

粉色苦力怕的“结构”

然后拿出结构方块,调整到save模式,然后调整边框和大小使得整个结构完全被白线包裹:

结构方块UI

然后输入结构名——这是个id,以命名空间:名称的方式命名。如果需要储存实体,请将右侧的Include Entities调为ON。

最后点击右侧SAVE保存,便可在世界文件夹/generated/<命名空间>/structures目录下找到它。将这个nbt复制到你的模组数据包目录的对应位置中,必要时可打开NBTExplorer,甚至IDEA安装Minecraft Development插件也可直接编辑:

IDEA中直接编辑nbt内容

这些nbt由三部分构成——方块、实体、调色板。方块即结构每个位置的方块,包括方块实体等;而实体则在开启Include Entities时被保存;调色板包括所有可能的方块状态,方块中state数值即为调色板的下标。


接下来我可能会讲讲地物和群系的生成,这部分也以数据包为主,涉及代码较少,敬请期待!

笔者从事开发不久,文章如有疏漏,敬请斧正!