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

原文载于知乎专栏《【真正的和平模式】一、模组简介与开发环境》,作者为 Viola-Siemens,
原地址:https://zhuanlan.zhihu.com/p/644074598


【真正的和平模式】一、模组简介与开发环境

你可曾想过,为何在Minecraft这片大陆上的怪物总会无差别或有条件地攻击玩家们?生活在这里,难免会感觉到孤独和恐惧——难道只有不停地战斗才是生存下去的唯一途径?不!或许战斗会让你血脉喷张,或许战斗会给你不一样的体验,但这远远不是热闹,而是另一种孤独。
如果,你能够去完成怪物们的心愿,与他们和谐共处,一起愉快地跳舞,一起在阳光下自由地奔跑——这才是真正的和平,这才是真正的热闹。
《真正的和平模式》这个模组添加了十余种自然结构,近十种生物实体,数十个方块和物品,以及对原版的怪物们分别添加了剧情任务。玩家要运用自己的脑力与技术,去完成怪物们的委托,或击败强敌,或探寻珍宝,或生产劳作,或寻觅真理……当玩家完成一种怪物的全部委托后,这种怪物将不再与玩家对立。
去吧,玩家,让这片冷酷而孤独的大陆变得热闹起来吧!

没错,这段内容是我开发的这个新模组的简介内容。由于近日比赛和项目较多,没什么时间来知乎创作了。但TeaCon 2023的这场比赛增设了【言传身教】奖,要求开发者通过编写技术博客为萌新开发者们快速入门、答疑解惑,那我何不写(shuǐ)几篇文章呢?

项目以MIT协议开源,github仓库地址:

https://github.com/Viola-Siemens/Real-Peaceful-Mode


背景

首先我们来看TeaCon 2023的题目:

「热闹」仿佛是中国人的向往的生活目标。春节时节,人们放烟花、贴春联,这是「热闹」的新年;夜市里,人头攒动、摊贩吆喝声不绝于耳,这是「热闹」的集市;闲暇时间,三五好友相约一起,去郊游、去唱歌,这是「热闹」的聚会;灯红酒绿的闹市里,悄然步入远离喧嚣人群的小巷,这是反差的「热闹」
那么你对「热闹」的体会和感觉是什么?什么才是让你感觉身心愉悦的「热闹」?请以此为出发点,写一个模组,自圆其说即可。
示例模组:末影龙舟

审题

题目首先提供了四种热闹:

  1. 放烟花、贴春联,以节日氛围为主,通过喜庆的元素烘托出【热闹】。由此立意大概率会做出别具特色的装饰mod。
  2. 闹市中的叫卖声、丰富而吸引人的商品,通过物质的属性吸引人们一起【热闹】。由此立意,结合前几天我老家淄博烧烤的爆火,说不定可以写出夜市、烧烤等风格的食物mod,比如可以取名为淄博乐事(划掉)。
  3. 三五好友相约郊游唱歌,通过玩家间的交互来体现【热闹】。这是本题中最为扩大开发者立意范围的一条,只要丰富了玩家间的交流,都可以算作热闹,甚至还保留了冒险mod等题材的选择可能。示例模组末影龙舟则是通过玩家间一起齐心协力划龙舟、互相组队赛龙舟体现出了【热闹】的氛围。
  4. 远离喧嚣,背弃世俗,选择孤身奋战——也许这是孤独,也许这十分冷清,但这又何尝不是反差的【热闹】?这是本题最难立意的一条,稍有不慎则会跑题,不少参赛者选择放弃从它立意。既然如此,我选择反其道而行之,从这一条上立意,毕竟我头铁,我怕谁!

另外题目的表述似乎在给予开发者们一个暗示,热闹体现在玩家与玩家之间,体现在物品的使用与交互中,事实上并非如此。我也是考虑到了这一点,萌生了一个大胆的想法——玩家怪物之间,是否也算热闹

基于这个想法,我便在考虑,如果在Minecraft的世界,玩家和怪物能够和平共处,互相帮助,从此玩家不再孤身一人,而是有着无数的旅途伙伴,而无需多人联机,那这样的世界该有多热闹!

这便是反向立意法,在立意时考虑【热闹】的反义词是什么,我认为是【冷清】、【孤独】,而解决掉冷清与孤独之后,便是符合题意的热闹!

进一步的,这片大陆中不止有一种怪物,玩家实现了一种怪物的心愿后,悄然离开去帮助其它的生物,正是符合了第四条的立意。

说干就干!模组策划好了,内容便是,玩家需要通过不断探索世界,帮助怪物们完成他们的心愿,从此对应的怪物将不再攻击玩家,实现真真正正的和平模式

环境配置

Minecraft与Forge版本的选择

根据要求,模组的目标平台为Minecraft 1.20.1,Forge 47.0.1以上——事实上TeaCon开幕不久后,Forge 47.1.0稳定版问世,于是我选择使用它来编译和运行模组。

编写build.gradle

1.20+与先前版本不同,需要使用ForgeGradle 6.0,而gradle的版本也变成了8.1.1,我用之前写1.16.5~1.19.4的build.gradle的写法来配置项目时直接失败了,因此需要注意这一点。

由于需要使用spongepowered mixin,因而需要添加一些dependencies:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
buildscript {
repositories {
maven { url = 'https://maven.minecraftforge.net' }
jcenter()
mavenCentral()
maven { name="sponge"; url 'https://repo.spongepowered.org/repository/maven-public/' }
}
dependencies {
classpath group: 'net.minecraftforge.gradle', name: 'ForgeGradle', version: '[6.0,6.2)', changing: true
classpath 'org.spongepowered:mixingradle:0.7.32'
}
}

plugins {
id 'eclipse'
id 'idea'
id 'maven-publish'
}
apply plugin: 'net.minecraftforge.gradle'

apply plugin: 'org.spongepowered.mixin'

为了方便调试和生成内容,需添加一些runs的配置,这里我写了四个runs,分别是运行客户端、运行服务端、运行服务端测试和生成内容:

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
java.toolchain.languageVersion = JavaLanguageVersion.of(17)

println "Java: ${System.getProperty 'java.version'}, JVM: ${System.getProperty 'java.vm.version'} (${System.getProperty 'java.vendor'}), Arch: ${System.getProperty 'os.arch'}"
minecraft {
mappings channel: 'official', version: '1.20.1'

accessTransformer = file('src/main/resources/META-INF/accesstransformer.cfg')

enableIdeaPrepareRuns = true
copyIdeResources = true
generateRunFolders = true

runs {
client {
workingDirectory project.file('run')

property 'forge.logging.markers', 'REGISTRIES'
property 'mixin.env.remapRefMap', 'true'
property 'mixin.env.refMapRemappingFile', "${projectDir}/build/createSrgToMcp/output.srg"

property 'forge.logging.console.level', 'debug'

property 'forge.enabledGameTestNamespaces', 'real_peaceful_mode'
arg "-mixin.config=real_peaceful_mode.mixins.json"

mods {
real_peaceful_mode {
source sourceSets.main
}
}
}

server {
workingDirectory project.file('run')

property 'forge.logging.markers', 'REGISTRIES'
property 'mixin.env.remapRefMap', 'true'
property 'mixin.env.refMapRemappingFile', "${projectDir}/build/createSrgToMcp/output.srg"

property 'forge.logging.console.level', 'debug'
arg "-mixin.config=real_peaceful_mode.mixins.json"

property 'forge.enabledGameTestNamespaces', 'real_peaceful_mode'

mods {
real_peaceful_mode {
source sourceSets.main
}
}
}

gameTestServer {
workingDirectory project.file('run')

property 'forge.logging.markers', 'REGISTRIES'
property 'mixin.env.remapRefMap', 'true'
property 'mixin.env.refMapRemappingFile', "${projectDir}/build/createSrgToMcp/output.srg"

property 'forge.logging.console.level', 'debug'

property 'forge.enabledGameTestNamespaces', 'real_peaceful_mode'
arg "-mixin.config=real_peaceful_mode.mixins.json"

mods {
real_peaceful_mode {
source sourceSets.main
}
}
}

data {
workingDirectory project.file('run')

property 'forge.logging.markers', 'REGISTRIES'
property 'mixin.env.remapRefMap', 'true'
property 'mixin.env.refMapRemappingFile', "${projectDir}/build/createSrgToMcp/output.srg"

property 'forge.logging.console.level', 'debug'

args '--mod', 'real_peaceful_mode', '--all', '--output', file('src/generated/resources/'), '--existing', file('src/main/resources/')
arg "-mixin.config=real_peaceful_mode.mixins.json"

mods {
real_peaceful_mode {
source sourceSets.main
}
}
}
}
}

sourceSets.main.resources { srcDir 'src/generated/resources' }

最后则是forge版本、mixin的依赖,打jar包和和mixin的配置,还有最重要的标题、作者、版本与发布设置:

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
dependencies {
minecraft 'net.minecraftforge:forge:1.20.1-47.1.0'

annotationProcessor 'org.spongepowered:mixin:0.8.5:processor'
}

jar {
manifest {
attributes([
"Specification-Title" : "Real Peaceful Mode",
"Specification-Vendor" : "Liu Dongyu, foliet, Mon3yr1, D-Sketon",
"Specification-Version" : "1", // We are version 1 of ourselves
"Implementation-Title" : project.name,
"Implementation-Version" : project.jar.archiveVersion,
"Implementation-Vendor" : "Liu Dongyu, foliet, Mon3yr1, D-Sketon",
"Implementation-Timestamp": new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"),
'FMLCorePluginContainsFMLMod': 'true'
])
}
}

jar.finalizedBy('reobfJar')

publishing {
publications {
mavenJava(MavenPublication) {
artifact jar
}
}
repositories {
maven {
url "file://${project.projectDir}/mcmodsrepo"
}
}
}

mixin {
add sourceSets.main, 'real_peaceful_mode.refmap.json'
config 'real_peaceful_mode.mixins.json'

debug.verbose = true
debug.export = true
}

tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}

模组核心架构

常言道,万事开头难,那么编写一个模组该从何处开头呢?

首先,Forge模组通过一个注解了“@Mod(value = “modid”)”的类作为模组入口,通过实例化这样一个类来对Minecraft的游戏内容进行修改。这个类中,你可以监听Forge事件、注册项目,也可以调用和加载其它的类来进行各种修改等。例如,我可以这样编写构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static final String MODID = "real_peaceful_mode";

public RealPeacefulMode() {
RPMLogger.logger = LogManager.getLogger(MODID);
IEventBus bus = FMLJavaModLoadingContext.get().getModEventBus();
MinecraftForge.EVENT_BUS.addListener(this::tagsUpdated);
MinecraftForge.EVENT_BUS.addListener(this::serverStarted);
DeferredWorkQueue queue = DeferredWorkQueue.lookup(Optional.of(ModLoadingStage.CONSTRUCT)).orElseThrow();
Consumer<Runnable> runLater = job -> queue.enqueueWork(
ModLoadingContext.get().getActiveContainer(), job
);
RPMContent.modConstruction(bus, runLater);
DistExecutor.safeRunWhenOn(Dist.CLIENT, bootstrapErrorToXCPInDev(() -> ClientProxy::modConstruction));

ModLoadingContext.get().registerConfig(ModConfig.Type.COMMON, RPMCommonConfig.getConfig());

bus.addListener(this::setup);
MinecraftForge.EVENT_BUS.register(new ForgeEventHandler());
MinecraftForge.EVENT_BUS.register(this);
}

注意,这里我调用了Forge的两个不同的bus——ForgeBus和ModBus。ModBus一般执行模组生命周期中发生的注册、加载等事件,而ForgeBus则一般执行游戏过程中Forge的各种Hooks捕获到的事件,如TagsUpdatedEvent(数据包被重新加载)、ServerStartedEvent(服务端,启动!)、VillagerTradesEvent(村民职业对应交易表的添加)等。前者往往是静态的,而后者往往是动态的。这些Event的监听则构成了Forge的API,你可以通过查阅Forge文档,了解Forge的Hooks和注册机制,以及参阅minecraftforge的jar包的源代码内容来了解它们。总之能不mixin尽量不要mixin,除非有一些类中没有Forge的Hooks,或者你需要魔改的部分找不到API(如弓的附魔、新的音符盒乐器等等),而accesstransformer又无法单独完成修改,才考虑去使用mixin。

个人喜欢采用的实现一个功能的逻辑如下:

  1. 确定需求
  2. 从minecraft源代码中找到实现这个需求可行的注入点
  3. 确定有无Forge的Event可以通过监听来实现需求
  4. 若无,考虑可否使用AT(Access Transformer)来修改成员、函数或类的权限(也可以用mixin写Accessor)
  5. 若否,再考虑mixin,尽量使用Inject而避免用Redirect,绝对避免Overwrite和捕获局域变量的方法!

回到构造函数,这里调用了RPMContent.modConstruction,这是各种项目注册的入口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static void modConstruction(IEventBus bus, Consumer<Runnable> runLater) {
ModsCompatManager.compatModLoaded();

initTags();

RPMFluids.init(bus);
RPMBlocks.init(bus);
RPMItems.init(bus);
Villages.Registers.init(bus);
RPMBlockEntities.init(bus);
RPMCreativeTabs.init(bus);
RPMMenuTypes.init(bus);
RPMStructureTypes.init(bus);
RPMEnchantments.init(bus);
}

private static void initTags() {
RPMBlockTags.init();
RPMBiomeTags.init();
RPMStructureTags.init();
RPMStructureKeys.init();
RPMStructureSetKeys.init();
}

以创造模式物品栏为例,模组可以选择使用延迟注册机制实现项目的注册:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@SuppressWarnings("unused")
public final class RPMCreativeTabs {
private static final DeferredRegister<CreativeModeTab> REGISTER = DeferredRegister.create(Registries.CREATIVE_MODE_TAB, MODID);

public static final RegistryObject<CreativeModeTab> REAL_PEACEFUL_MODE = register(
"real_peaceful_mode", Component.translatable("itemGroup.real_peaceful_mode"), () -> new ItemStack(RPMItems.SpiritBeads.HUGE_SPIRIT_BEAD),
(parameters, output) -> RPMItems.ItemEntry.ALL_ITEMS.forEach(output::accept)
);

public static void init(IEventBus bus) {
REGISTER.register(bus);
}

@SuppressWarnings("SameParameterValue")
private static RegistryObject<CreativeModeTab> register(String name, Component title, Supplier<ItemStack> icon, CreativeModeTab.DisplayItemsGenerator generator) {
return REGISTER.register(name, () -> CreativeModeTab.builder().title(title).icon(icon).displayItems(generator).build());
}
}

这里定义了一个名为”real_peaceful_mode:real_peaceful_mode”的创造模式物品栏,对照本地化文件(以英语en_us.json为例),我们可以看到这个物品栏标签的名字:

1
"itemGroup.real_peaceful_mode": "Real Peaceful Mode"

没错,这是Minecraft原版的本地化系统,最早是自定义格式的.lang文件,如今则是JSON格式。代码中调用时,只需要使用Component.translatable(“<本地化键名>”)即可。

总结

我们的模组框架成功搭建起来了,那么模组开发的基础部分到此为止,下一篇我将讲解一些或核心或边缘的模组功能的实现。模组仍在持续制作中,敬请期待!

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

原文载于知乎专栏《【真正的和平模式】二、任务系统的实现》,作者为 Viola-Siemens,
原地址:https://zhuanlan.zhihu.com/p/644603030


【真正的和平模式】二、任务系统的实现

我像迷途小鹿 得不到救赎

才会在此后的路 忽视了所有景物

我眼里的天空变得荒芜

连诗里的飞鸟也迷了路

我像迷途小鹿 得不到救赎

才会将所有心迹 毫无保留呈现出

你纵身跃进了满天大雾

我找不到你也忘了归途

——《迷途小鹿》(歌手:葛雨晴,作词:峦无眠)

偶然间听到的歌曲,主题稍微契合就写进来了(逃


背景

正如前文所述,我们的模组要实现一个任务系统,记录着每个玩家完成任务的进度,以及触发任务条件等等。

为了方便数据包作者和其它模组作者的扩展,我决定使用数据包形式,单向链表结构来添加任务。第一步就是确定数据格式——在这里我选择使用如下格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"requires": [
<多个resource location,表示前置任务,未完成所有前置任务的玩家无法触发本任务>
],
"reward": <可选,entity_type的resouce location,表示玩家完成任务后哪种生物将不再攻击他/她>,
"messages": [
<接收任务时的任务对话,包含多条服从如下格式的object>
{
"key": <本地化键名,为显示的文本>,
"speaker": <"player""npc",代表这句话是玩家说出的还是对话NPC说出的>
}
],
"messagesAfter": [
<完成任务时的任务对话,包含多条服从如下格式的object>
{
"key": <本地化键名,为显示的文本>,
"speaker": <"player""npc",代表这句话是玩家说出的还是对话NPC说出的>
}
],
"loot_table": <可选,战利品表id,表示任务完成时或接收时获得的战利品>,
"loot_before": <可选,truefalse(默认false),取决于是否在接受任务时获得战利品>
}

没错,任务强制只在开始和结束时才触发对话,而对话的目标最多只有一位NPC(当然也可以是玩家独白)。这是这个简易系统唯一的局限性。

至于触发,后面再讲。这里提前简要说一下,对于任务开始和结束,原生模组提供了两种任务触发方式:其一是summon_block,数据包作者们可以直接使用;其二是api.MissionHelper#triggerMissionForPlayers和api.MissionHelper#triggerMissionForPlayer,只有衍生和联动模组开发者们可以使用。

接下来就要想如何实现了。之前做独立游戏的时候实现过任务系统,不过跟Minecraft的情况相去较远,至少没办法直接搬。所以我直接重新造一个轮子。

不过做模组,很重要的一点就是,想想原版有什么类似的功能,那么只需要轻松照抄,稍加修改即可。

我最先想到的则是原版的进度系统——数据包作者们可以在data//advancements中自由添加进度。那就容易很多了,说干就干!

ServerAdvancementManager详解

打开net.minecraft.server.ServerAdvancementManager文件,我们可以看到原版进度系统的实现:

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
public class ServerAdvancementManager extends SimpleJsonResourceReloadListener {
private static final Logger LOGGER = LogUtils.getLogger();
private static final Gson GSON = (new GsonBuilder()).create();
private AdvancementList advancements = new AdvancementList();

//...

public ServerAdvancementManager(LootDataManager lootData) {
super(GSON, "advancements");
this.lootData = lootData;
}

//...

@Override
protected void apply(Map<ResourceLocation, JsonElement> jsons, ResourceManager resourceManager, ProfilerFiller profilerFiller) {
Map<ResourceLocation, Advancement.Builder> map = Maps.newHashMap();
jsons.forEach((id, json) -> {
try {
JsonObject jsonobject = GsonHelper.convertToJsonObject(json, "advancement");
Advancement.Builder builder = Advancement.Builder.fromJson(jsonobject, new DeserializationContext(id, this.lootData), this.context);
if (builder == null) {
LOGGER.debug("Skipping loading advancement {} as its conditions were not met", id);
return;
}
map.put(id, builder);
} catch (Exception exception) {
LOGGER.error("Parsing error loading custom advancement {}: {}", id, exception.getMessage());
}

});
AdvancementList advancementlist = new AdvancementList();
advancementlist.add(map);

for(Advancement advancement : advancementlist.getRoots()) {
if (advancement.getDisplay() != null) {
TreeNodePosition.run(advancement);
}
}

this.advancements = advancementlist;
}

@Nullable
public Advancement getAdvancement(ResourceLocation id) {
return this.advancements.get(id);
}

public Collection<Advancement> getAllAdvancements() {
return this.advancements.getAllAdvancements();
}
}

不太重要的部分已经略去,对于这部分我们逐一解读。

1. MissionManager的实现

首先它继承了SimpleJsonResourceReloadListener类,这个类原版有两种Manager继承了它,其一是进度系统,其二是合成系统;而Forge也定义了LootModifierManager,用以实现战利品表的更改。这个父类的功能很简单,可以实现json格式的数据读取和自动加载,只需重写apply函数即可。

也许有写过低版本模组的同仁们就要问了,战利品表系统不也继承了它吗?不错,曾经是,不过1.20这部分被大幅修改了,如今LootDataManager仅仅是实现了SimpleJsonResourceReloadListener的爷爷接口PreparableReloadListener。

回归正题,apply函数传了三个参数,分别是所有JSON文件内容(按id索引在map中)、Resource Manager和Profiler Filler。事实上我们实现自己的需求也无需后两个参数,只要写好读取json文件的处理逻辑即可。

其次,构造函数传递了两个参数,一个是编码JSON文件的方法,一个是扫描文件目录。对于进度系统则是”advancements”,如果我希望任务系统的扫描目录是data//rpm/missions,则传入”rpm/missions”即可。

于是我们便可以实现任务系统:

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
120
121
122
123
124
125
126
127
128
129
130
131
132
public class MissionManager extends SimpleJsonResourceReloadListener {
private static final Gson GSON = (new GsonBuilder()).create();

private Map<ResourceLocation, Mission> missionsByName = ImmutableMap.of();

public MissionManager() {
super(GSON, "rpm/missions");
}

@Override
protected void apply(Map<ResourceLocation, JsonElement> missions, ResourceManager resourceManager, ProfilerFiller profilerFiller) {
ImmutableMap.Builder<ResourceLocation, Mission> builder = ImmutableMap.builder();
for(Map.Entry<ResourceLocation, JsonElement> entry: missions.entrySet()) {
ResourceLocation id = entry.getKey();
if (id.getPath().startsWith("_")) {
continue;
}

try {
if (entry.getValue().isJsonObject() && !processConditions(entry.getValue().getAsJsonObject())) {
RPMLogger.debug("Skipping loading mission %s as it's conditions were not met".formatted(id));
continue;
}
JsonObject jsonObject = GsonHelper.convertToJsonObject(entry.getValue(), "top element");
Mission mission = Mission.fromJson(id, jsonObject); //读取每个任务
builder.put(id, mission);
} catch (IllegalArgumentException | JsonParseException exception) {
RPMLogger.error("Parsing error loading mission %s.".formatted(id));
RPMLogger.error(exception);
}
}
this.missionsByName = builder.build();
}

public record Mission(ResourceLocation id,
List<Message> messages, List<Message> messagesAfter,
List<ResourceLocation> formers,
EntityType<?> reward, ResourceLocation rewardLootTable, boolean lootBefore) {
public record Message(String messageKey, Speaker speaker) {
//前文的说话者,包括玩家和NPC
public enum Speaker {
PLAYER,
NPC
}
}

private static Mission fromJson(ResourceLocation id, JsonObject json) {
List<Mission.Message> messages = Lists.newArrayList();
List<Mission.Message> messagesAfter = Lists.newArrayList();
JsonArray messageArray = GsonHelper.getAsJsonArray(json, "messages");
JsonArray messageAfterArray = GsonHelper.getAsJsonArray(json, "messagesAfter");
getMessages(messages, messageArray);
getMessages(messagesAfter, messageAfterArray);
JsonArray requires = GsonHelper.getAsJsonArray(json, "requires");
List<ResourceLocation> formers = Lists.newArrayList();
for(JsonElement element: requires) {
String otherId = GsonHelper.convertToString(element, "elements of requires");
ResourceLocation former = new ResourceLocation(otherId);
formers.add(former);
}
ResourceLocation reward = new ResourceLocation(GsonHelper.getAsString(json, "reward", "minecraft:player"));
EntityType<?> rewardEntityType = ForgeRegistries.ENTITY_TYPES.getValue(reward);
ResourceLocation rewardLootTable = new ResourceLocation(GsonHelper.getAsString(json, "loot_table", BuiltInLootTables.EMPTY.toString()));
boolean lootBefore = GsonHelper.getAsBoolean(json, "loot_before", false);
return new Mission(id, messages, messagesAfter, formers, rewardEntityType == null ? EntityType.PLAYER : rewardEntityType, rewardLootTable, lootBefore);
}

//尝试获得战利品
public void tryGetLoot(ServerPlayer player, LootDataManager lootTables, boolean finished) {
if(this.lootBefore != finished) {
if (this.rewardLootTable != null && !this.rewardLootTable.equals(BuiltInLootTables.EMPTY)) {
LootTable lootTable = lootTables.getLootTable(this.rewardLootTable);
lootTable.getRandomItems(new LootParams.Builder((ServerLevel) player.level()).create(LootContextParamSets.EMPTY), itemStack -> player.level().addFreshEntity(
new ItemEntity(player.level(), player.getX(), player.getY() + 0.5D, player.getZ(), itemStack)
));
}
}
}

//完成任务后的额外工作,包括获得战利品、通知任务完成和(如果有)将对应怪物设为友好
public void finish(ServerPlayer player, LootDataManager lootTables) {
if(!this.reward.equals(EntityType.PLAYER)) {
if(!((IMonsterHero)player).isHero(this.reward)) {
player.sendSystemMessage(Component.translatable(
"message.real_peaceful_mode.reward_monster",
player.getDisplayName(),
Component.translatable(this.reward.getDescriptionId()).withStyle(ChatFormatting.GREEN)
));
((IMonsterHero) player).setHero(this.reward);
}
}
this.tryGetLoot(player, lootTables, true);
}
}

private static final String CONDITIONS_FIELD = "conditions";
private static boolean processConditions(JsonObject json) {
return !json.has(CONDITIONS_FIELD) || MissionLoadCondition.fromJson(json.get(CONDITIONS_FIELD)).test();
}

private static void getMessages(List<Mission.Message> messages, JsonArray messageArray) {
for(JsonElement element: messageArray) {
if(element.isJsonObject()) {
JsonObject json = element.getAsJsonObject();
String speakerType = GsonHelper.getAsString(json, "speaker");
Mission.Message.Speaker speaker = switch(speakerType) {
case "player" -> Mission.Message.Speaker.PLAYER;
case "npc" -> Mission.Message.Speaker.NPC;
default -> throw new IllegalArgumentException("No speaker named \"%s\"!".formatted(speakerType));
};
messages.add(new Mission.Message(GsonHelper.getAsString(json, "key"), speaker));
} else if(element.isJsonPrimitive()) {
String message = element.getAsString();
messages.add(new Mission.Message(message, Mission.Message.Speaker.PLAYER));
} else {
throw new IllegalArgumentException("Field \"messages\" must be an array of strings and json objects!");
}
}
}

public Optional<Mission> getMission(ResourceLocation id) {
return Optional.ofNullable(this.missionsByName.get(id));
}

public Stream<ResourceLocation> getAllMissionIds() {
return this.missionsByName.keySet().stream();
}

public Collection<Mission> getAllMissions() {
return this.missionsByName.values();
}
}

然后,如何将这个监听器真正监听在资源加载阶段呢?当然你可以mixin,但Forge是有这个API的,所以我优先去调用这个API:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ForgeEventHandler {
private static MissionManager missionManager;

@SubscribeEvent
public void onResourceReload(AddReloadListenerEvent event) {
missionManager = new MissionManager();
event.addListener(missionManager);
}

public static MissionManager getMissionManager() {
return missionManager;
}
}

并在主类中,通过Forge bus中注册它。

1
MinecraftForge.EVENT_BUS.register(new ForgeEventHandler());

接着,我们有了总领的任务系统,进一步的,如何去维护每个人的任务的进度?于是我们发现了PlayerAdvancements类。

2. PlayerMissions的实现

首先,我们可以看到在PlayerList中有一个维护每个人进度完成情况的成员:

1
private final Map<UUID, PlayerAdvancements> advancements = Maps.newHashMap();

而在ServerPlayer中也有自身独立的PlayerAdvancements:

1
private final PlayerAdvancements advancements;

当然,这个advancements只是PlayerList中对应的那个PlayerAdvancements的一个影子。

参考这个类,我们可以实现自己的PlayerMissions。它需要包含玩家完成过的任务、玩家正在进行的任务(其余都是还未接收的任务):

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
120
121
122
123
124
125
public record PlayerMissions(Path playerSavePath, ServerPlayer player, List<ResourceLocation> activeMissions, List<ResourceLocation> finishedMissions) {
public PlayerMissions(Path playerSavePath, ServerPlayer player) {
this(playerSavePath, player, Lists.newArrayList(), Lists.newArrayList());
}

private static final String PLAYER_MISSIONS = "RealPeacefulModeMissions";
private static final String ACTIVE_MISSIONS = "activeMissions";
private static final String FINISHED_MISSIONS = "finishedMissions";
public void readNBT(CompoundTag nbt) {
this.activeMissions.clear();
this.finishedMissions.clear();

if(nbt.contains(PLAYER_MISSIONS, Tag.TAG_COMPOUND)) {
CompoundTag missions = nbt.getCompound(PLAYER_MISSIONS);
ListTag activeMissions = missions.getList(ACTIVE_MISSIONS, Tag.TAG_STRING);
ListTag finishedMissions = missions.getList(FINISHED_MISSIONS, Tag.TAG_STRING);
for(Tag tag: activeMissions) {
this.activeMissions.add(new ResourceLocation(tag.getAsString()));
}
for(Tag tag: finishedMissions) {
this.finishedMissions.add(new ResourceLocation(tag.getAsString()));
}
}
}

public void writeNBT(CompoundTag nbt) {
ListTag activeMissions = new ListTag();
ListTag finishedMissions = new ListTag();
for(ResourceLocation id: this.activeMissions) {
activeMissions.add(StringTag.valueOf(id.toString()));
}
for(ResourceLocation id: this.finishedMissions) {
finishedMissions.add(StringTag.valueOf(id.toString()));
}
CompoundTag missions = new CompoundTag();
missions.put(ACTIVE_MISSIONS, activeMissions);
missions.put(FINISHED_MISSIONS, finishedMissions);

nbt.put(PLAYER_MISSIONS, missions);
}

public void replaceWith(PlayerMissions other) {
this.activeMissions.clear();
this.finishedMissions.clear();

this.activeMissions.addAll(other.activeMissions);
this.finishedMissions.addAll(other.finishedMissions);
}

public void receiveNewMission(MissionManager.Mission mission, @Nullable LivingEntity npc) {
if (this.player instanceof FakePlayer) {
return;
}

mission.formers().stream().filter(id -> !this.finishedMissions.contains(id)).findAny().ifPresentOrElse(
id -> RPMLogger.debug("Ignore receive mission %s for not finishing mission %s.".formatted(mission.id(), id)),
() -> {
MessagedMissionInstance instance = new MessagedMissionInstance(this.player, npc, mission.messages());
OptionalInt id = this.player.openMenu(new SimpleMenuProvider((counter, inventory, player) ->
new MissionMessageMenu(counter, instance, () -> {
this.player.sendSystemMessage(Component.translatable(
"message.real_peaceful_mode.receive_mission",
ComponentUtils.wrapInSquareBrackets(
Component.translatable(getMissionDescriptionId(mission))
.withStyle(ChatFormatting.GREEN)
.withStyle((style -> style.withHoverEvent(
new HoverEvent(HoverEvent.Action.SHOW_TEXT, Component.translatable(getMissionInformationId(mission)))
)))
)
));
this.activeMissions().add(mission.id());
mission.tryGetLoot(this.player, Objects.requireNonNull(this.player.getServer()).getLootData(), false);
}), Component.translatable("title.real_peaceful_mode.menu.mission")
));
if(id.isPresent()) {
RealPeacefulMode.packetHandler.send(
PacketDistributor.PLAYER.with(() -> this.player),
new ClientboundMissionMessagePacket(instance, id.getAsInt())
);
}
}
);
}

public void finishMission(MissionManager.Mission mission, @Nullable LivingEntity npc) {
if (this.player instanceof FakePlayer) {
return;
}

MessagedMissionInstance instance = new MessagedMissionInstance(this.player, npc, mission.messagesAfter());
OptionalInt id = this.player.openMenu(new SimpleMenuProvider((counter, inventory, player) ->
new MissionMessageMenu(counter, instance, () -> {
this.player.sendSystemMessage(Component.translatable(
"message.real_peaceful_mode.finish_mission",
ComponentUtils.wrapInSquareBrackets(
Component.translatable(getMissionDescriptionId(mission))
.withStyle(ChatFormatting.GREEN)
.withStyle((style -> style.withHoverEvent(
new HoverEvent(HoverEvent.Action.SHOW_TEXT, Component.translatable(getMissionInformationId(mission)))
)))
)
));
this.activeMissions().remove(mission.id());
this.finishedMissions().add(mission.id());
mission.finish(this.player, Objects.requireNonNull(this.player.getServer()).getLootData());
}), Component.translatable("title.real_peaceful_mode.menu.mission")
));
if(id.isPresent()) {
RealPeacefulMode.packetHandler.send(
PacketDistributor.PLAYER.with(() -> this.player),
new ClientboundMissionMessagePacket(instance, id.getAsInt())
);
}
}

public static String getMissionDescriptionId(MissionManager.Mission mission) {
ResourceLocation id = mission.id();
return "mission.%s.%s.name".formatted(id.getNamespace(), id.getPath());
}

public static String getMissionInformationId(MissionManager.Mission mission) {
ResourceLocation id = mission.id();
return "mission.%s.%s.description".formatted(id.getNamespace(), id.getPath());
}
}

为了安全性,在这里做了检查,允许玩家接收的任务,玩家必须已经完成过所有前置任务。

那么,如何将它加进PlayerList里呢?其实未必要加进PlayerList中,你也可以自己写一个SavedData来实现,不过这次的mixin没有副作用,而且更符合直觉架构,因此我选择了mixin:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Mixin(PlayerList.class)
public class PlayerListMixin implements IPlayerListWithMissions {
@Shadow @Final
private MinecraftServer server;

private final Map<UUID, PlayerMissions> missions = Maps.newHashMap();

@Override
public PlayerMissions getPlayerMissions(ServerPlayer player) {
UUID uuid = player.getUUID();
PlayerMissions playerMissions = this.missions.get(uuid);
if (playerMissions == null) {
Path path = this.server.getWorldPath(PLAYER_MISSIONS_DIR).resolve(uuid + ".json");
playerMissions = new PlayerMissions(path, player);
this.missions.put(uuid, playerMissions);
}

return playerMissions;
}
}

这里抽象了一个IPlayerListWithMissions接口,作用是,由于Mixin类无法被实例化或强制转化,所以要想调用getPlayerMissions函数,必须通过一个接口来访问。比如:

1
((IPlayerListWithMissions) serverLevel.getServer().getPlayerList()).getPlayerMissions(player)

没办法,都mixin了,还在意啥代码美观(划掉)。

那么如何将它进行序列化呢?我们又要mixin进ServerPlayer类,注入读写nbt和restoreFrom方法:

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
@Mixin(ServerPlayer.class)
public class ServerPlayerMixin implements IMonsterHero {
private final Map<ResourceLocation, Integer> helpedMonsters = Maps.newHashMap();

private PlayerMissions playerMissions;

@Inject(method = "<init>", at = @At(value = "TAIL"))
public void create(MinecraftServer server, ServerLevel level, GameProfile gameProfile, CallbackInfo ci) {
this.playerMissions = ((IPlayerListWithMissions)server.getPlayerList()).getPlayerMissions((ServerPlayer)(Object)this);
}

@Override
public boolean isHero(EntityType<?> monsterType) {
return this.helpedMonsters.containsKey(getRegistryName(monsterType));
}

@Override
public void setHero(EntityType<?> monsterType) {
this.helpedMonsters.compute(getRegistryName(monsterType), (type, count) -> {
if(count == null) {
return 1;
}
return count + 1;
});
}

@Override
public Map<ResourceLocation, Integer> getHelpedMonsters() {
return this.helpedMonsters;
}

@Override
public PlayerMissions getPlayerMissions() {
return this.playerMissions;
}

private static final String HELPED_MONSTERS = "helpedMonsters";
@Inject(method = "readAdditionalSaveData", at = @At(value = "TAIL"))
public void readRPMData(CompoundTag nbt, CallbackInfo ci) {
if(nbt.contains(HELPED_MONSTERS, Tag.TAG_LIST)) {
ListTag list = nbt.getList(HELPED_MONSTERS, Tag.TAG_COMPOUND);
list.forEach(tag -> {
CompoundTag compound = (CompoundTag)tag;
this.helpedMonsters.compute(new ResourceLocation(compound.getString("type")), (type, count) -> compound.getInt("count"));
});
}
this.playerMissions.readNBT(nbt);
}

@Inject(method = "addAdditionalSaveData", at = @At(value = "TAIL"))
public void addRPMData(CompoundTag nbt, CallbackInfo ci) {
ListTag tags = new ListTag();
this.helpedMonsters.forEach((type, count) -> {
CompoundTag tag = new CompoundTag();
tag.putString("type", type.toString());
tag.putInt("count", count);
tags.add(tag);
});
nbt.put(HELPED_MONSTERS, tags);
this.playerMissions.writeNBT(nbt);
}

@Inject(method = "restoreFrom", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/level/ServerPlayer;setLastDeathLocation(Ljava/util/Optional;)V"))
public void restoreRPMDataFrom(ServerPlayer player, boolean won, CallbackInfo ci) {
if(player instanceof IMonsterHero hero) {
hero.getHelpedMonsters().forEach((type, count) -> this.helpedMonsters.compute(type, (type1, count1) -> count));
}
}
}

这里的IMonsterHero接口也是同样,方便其它部分调用,判断玩家是否已经实现了某个怪物的全部委托。

一定不要忘记restoreFrom函数!否则玩家不论是从末地返回主世界,还是死亡后重生,这些信息都会消失!

至于为什么不restore playerMissions,别忘了它只是个影子,之前我也是restore了,debug过程中发现了这个问题,于是把它删掉了。

那么任务系统算是成功实现了,不过还需要客户端的UI,显示任务对话,如题图所示。该怎么实现呢?

首先介绍一下Minecraft的UI架构。一般的功能性UI都是两层结构:第一层是Menu,位于服务端(客户端会同步它),便于与世界交互,如玩家放入熔炉一根烈焰棒(真有人这么富吗?);第二层是Screen,位于客户端,执行显示界面,处理玩家请求的功能,如显示熔炉UI,显示燃料剩余量、烧炼的进度等等。有些UI由于无需与服务端部分交互,便只有Screen没有Menu,比如玩家进度、统计界面等,只需一次发包后便可显示。

而我们的需求是,首先,任务界面打开过程中,怪物不能攻击玩家——这就限制了我们的实现,Menu部分必须要存在;其次,玩家客户端要显示对话,这部分是由服务端的MissionManager.Mission发包过来的;最后,对话结束后要提示玩家接收到了新的任务或完成了任务,这又是服务端向客户端发送的。

那么我们可以做如下设计:

  1. Menu部分维护了该任务的所有对话。
  2. Screen显示了对话内容和讲话的生物,玩家可以按下按钮来向前/向后阅读。

于是我们可以实现如下Menu:

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
public class MissionMessageMenu extends AbstractContainerMenu {
private MessagedMission mission;
private final Runnable onRemoved;

public MissionMessageMenu(int counter, Inventory inventory) {
this(counter, new ClientSideMessagedMission(inventory.player), () -> {});
}

public MissionMessageMenu(int counter, MessagedMission mission, Runnable onRemoved) {
super(RPMMenuTypes.MISSION_MESSAGE_MENU.get(), counter);
this.mission = mission;
this.onRemoved = onRemoved;
}

@Override
public ItemStack quickMoveStack(Player player, int slot) {
return ItemStack.EMPTY;
}

@Override
public boolean stillValid(Player player) {
LivingEntity npc = this.mission.npc();
if(npc == null) {
return true;
}
return this.mission.player().closerThan(npc, 24.0D);
}

public MessagedMission getMission() {
return this.mission;
}

public void setMission(MessagedMission mission) {
this.mission = mission;
}

@Nullable
public LivingEntity getSpeaker(MissionManager.Mission.Message.Speaker speaker) {
return switch (speaker) {
case PLAYER -> this.mission.player();
case NPC -> this.mission.npc();
};
}

@Override
public void removed(Player player) {
super.removed(player);
this.onRemoved.run();
}
}

第一个构造函数用于RPMMenuTypes中的注册:

1
2
3
private static final DeferredRegister<MenuType<?>> REGISTER = DeferredRegister.create(ForgeRegistries.MENU_TYPES, MODID);

public static final RegistryObject<MenuType<MissionMessageMenu>> MISSION_MESSAGE_MENU = REGISTER.register("mission_message", () -> new MenuType<>(MissionMessageMenu::new, FeatureFlags.VANILLA_SET));

第二个构造函数则是用于在接收/完成任务时玩家openMenu的传参。

由于无需物品栏和槽位的操作,quickMoveStack可直接返回空;stillValid也可以随便写写了,这里是根据与NPC的距离判断的。

mission的getter和setter则是用于服务端向客户端的发包:

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
public class ClientboundMissionMessagePacket implements IRPMPacket {
private final MessagedMission mission;
private final int containerId;

public ClientboundMissionMessagePacket(MessagedMission mission, int containerId) {
this.mission = mission;
this.containerId = containerId;
}

public ClientboundMissionMessagePacket(FriendlyByteBuf buf) {
this.mission = new MessagedMissionInstance(Objects.requireNonNull(buf.readNbt()));
this.containerId = buf.readInt();
}

@Override
public void write(FriendlyByteBuf buf) {
buf.writeNbt(this.mission.createTag());
buf.writeInt(this.containerId);
}

@Override
public void handle(NetworkEvent.Context context) {
LocalPlayer player = Minecraft.getInstance().player;
RPMLogger.debug(this.mission.createTag());
if(player != null) {
AbstractContainerMenu menu = player.containerMenu;
if(menu.containerId == this.containerId && menu instanceof MissionMessageMenu missionMessageMenu) {
missionMessageMenu.setMission(mission);
}
}
}
}

这一步将服务端的任务内容传给了客户端,并进行了验证。

注册发包则是在主类中完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static final String VERSION = ModList.get().getModFileById(MODID).versionString();

public static final SimpleChannel packetHandler = NetworkRegistry.ChannelBuilder
.named(new ResourceLocation(MODID, "main"))
.networkProtocolVersion(() -> VERSION)
.serverAcceptedVersions(VERSION::equals)
.clientAcceptedVersions(VERSION::equals)
.simpleChannel();

//...

private static int messageId = 0;
private static <T extends IRPMPacket> void registerMessage(Class<T> packetType,
Function<FriendlyByteBuf, T> constructor) {
packetHandler.registerMessage(messageId++, packetType, IRPMPacket::write, constructor, (packet, ctx) -> packet.handle(ctx.get()));
}

private void setup(final FMLCommonSetupEvent event) {
//...
registerMessage(ClientboundMissionMessagePacket.class, ClientboundMissionMessagePacket::new);
//...
}

Screen的实现

既然Menu实现好了,那Screen不就简单了吗?

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
@OnlyIn(Dist.CLIENT)
public class MissionMessageScreen extends AbstractContainerScreen<MissionMessageMenu> {
private static final ResourceLocation BG_LOCATION = new ResourceLocation(MODID, "textures/gui/mission_message.png");

private int messageIndex = 0;
private int deltaIndex = 0;

private List<FormattedCharSequence> cachedText;

public MissionMessageScreen(MissionMessageMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title);
--this.titleLabelY;
this.loadCachedText();
}

private void loadCachedText() {
List<MissionManager.Mission.Message> messages = this.menu.getMission().messages();
if(messages.size() > 0) {
this.cachedText = this.font.split(Component.translatable(messages.get(this.messageIndex).messageKey()), 140);
}
}

@Override
protected void renderLabels(GuiGraphics transform, int x, int y) {
transform.drawString(this.font, this.title, this.titleLabelX, this.titleLabelY, 0x404040, false);
}

@Override
protected void renderBg(GuiGraphics transform, float ticks, int x, int y) {
int i = (this.width - this.imageWidth) / 2;
int j = (this.height - this.imageHeight) / 2;
transform.blit(BG_LOCATION, i, j, 0, 0, this.imageWidth, this.imageHeight);
List<MissionManager.Mission.Message> messages = this.menu.getMission().messages();
if(messages.size() <= 0) {
return;
}
MissionManager.Mission.Message message = messages.get(this.messageIndex);
LivingEntity currentSpeaker = this.menu.getSpeaker(message.speaker());
if(currentSpeaker != null) {
FormattedCharSequence name = currentSpeaker.getDisplayName().getVisualOrderText();
transform.drawString(this.font, name, i + 116 - this.font.width(name), j + 108, 0xa0a0a0);
InventoryScreen.renderEntityInInventoryFollowsMouse(transform, i + 143, j + 151, 24, i + 143 - x, j + 120 - y, currentSpeaker);
}
if(this.cachedText == null || this.cachedText.size() <= 0) {
this.loadCachedText();
}
for(int l = 0; l < this.cachedText.size(); ++l) {
transform.drawString(this.font, this.cachedText.get(l), i + 16, j + 16 + l * 9, 0x404040, false);
}
this.renderButtons(transform, x, y);
}

private void renderButtons(GuiGraphics transform, int x, int y) {
int buttonX1 = this.leftPos + 13;
int buttonX2 = this.leftPos + 49;
int buttonY = this.topPos + 78;
boolean x1InRange = x >= buttonX1 && x < buttonX1 + 18;
boolean x2InRange = x >= buttonX2 && x < buttonX2 + 18;
boolean yInRange = y >= buttonY && y < buttonY + 18;
int buttonHeightLeft = (x1InRange && yInRange) ? this.imageHeight + 36 : this.imageHeight;
int buttonHeightRight = (x2InRange && yInRange) ? this.imageHeight + 36 : this.imageHeight;
switch(this.deltaIndex) {
case -1 -> buttonHeightLeft = this.imageHeight + 18;
case 1 -> buttonHeightRight = this.imageHeight + 18;
}
transform.blit(BG_LOCATION, buttonX1, buttonY, 0, buttonHeightLeft, 18, 18);
transform.blit(BG_LOCATION, buttonX2, buttonY, 18, buttonHeightRight, 18, 18);
}

@Override
public boolean mouseClicked(double x, double y, int button) {
double buttonX1 = this.leftPos + 13;
double buttonX2 = this.leftPos + 49;
double buttonY = this.topPos + 78;
if(y >= buttonY && y < buttonY + 18.0D) {
if(x >= buttonX1 && x < buttonX1 + 18.0D) {
Minecraft.getInstance().getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_STONECUTTER_SELECT_RECIPE, 1.0F));
this.deltaIndex = -1;
return true;
}
if(x >= buttonX2 && x < buttonX2 + 18.0D) {
Minecraft.getInstance().getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_STONECUTTER_SELECT_RECIPE, 1.0F));
this.deltaIndex = 1;
return true;
}
}
return super.mouseClicked(x, y, button);
}

@Override
public boolean mouseReleased(double x, double y, int button) {
double buttonX1 = this.leftPos + 13;
double buttonX2 = this.leftPos + 49;
double buttonY = this.topPos + 78;
if(y >= buttonY && y < buttonY + 18.0D) {
if((x >= buttonX1 && x < buttonX1 + 18.0D && this.deltaIndex == -1) ||
(x >= buttonX2 && x < buttonX2 + 18.0D && this.deltaIndex == 1)) {
this.messageIndex += this.deltaIndex;
this.deltaIndex = 0;
if(this.messageIndex < 0) {
this.messageIndex = 0;
} else if(this.messageIndex >= this.menu.getMission().messages().size()) {
this.onClose();
return true;
}
this.loadCachedText();
return true;
}
}
this.deltaIndex = 0;
return super.mouseReleased(x, y, button);
}
}

注意高版本的PoseStack被UI系统弃用了,改用GuiGraphics做渲染,个人感觉更加方便了。

renderBg函数实现了背景渲染,以及界面右下方对话实体的显示。renderButtons显示了向前向后两个按钮的渲染,而处理玩家请求则是在mouseClicked(按下按钮,改变按钮颜色)和mouseReleased(释放按钮,执行按钮功能)中实现。而对话文本的分行是在loadCachedText函数中实现。

具体blit的数值取决于GUI资源图片的排版,由于我将按钮元素排版在下方,所以便从下方截取图像并贴在对应位置(详见仓库中GUI资源文件)。

实现了核心功能,也许玩家会想查看自己的任务完成情况和任务描述。接下来介绍显示客户端玩家任务这部分的实现方法。

显示玩家任务完成情况

首先我添加了绑定按键(默认M键),按下按键后可显示任务屏幕GUI。

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
@OnlyIn(Dist.CLIENT)
public final class RPMKeys {
public static final KeyEntry MISSION_SCREEN = new KeyEntry("mission_screen", GLFW.GLFW_KEY_M);

public static final class KeyEntry {
public static final List<KeyEntry> ALL_KEYS = Lists.newArrayList();

private final KeyMapping keyMapping;

@SuppressWarnings("SameParameterValue")
private KeyEntry(String name, int defaultKey) {
String descriptionId = MODID + ".keyinfo." + name;
this.keyMapping = new KeyMapping(descriptionId, defaultKey, MODNAME);

ALL_KEYS.add(this);
}

public boolean isDown() {
return this.keyMapping.isDown();
}
}

public static void init() {

}
}

客户端也要监听玩家按键的事件,处理打开窗口的请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Mod.EventBusSubscriber(modid = MODID, bus = Mod.EventBusSubscriber.Bus.FORGE, value = Dist.CLIENT)
public class ClientEventHandler {
@SubscribeEvent
public static void onKeyboardInput(InputEvent.Key event) {
LocalPlayer player = Minecraft.getInstance().player;
if (player == null) {
return;
}
if (RPMKeys.MISSION_SCREEN.isDown()) {
RealPeacefulMode.packetHandler.sendToServer(new GetMissionsPacket());
}
}
}

这里这里通过客户端和服务端之间互相发包的方式,来获取玩家任务列表(包括已完成和进行中):

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 GetMissionsPacket implements IRPMPacket {
private final PacketType type;
private final List<MissionManager.Mission> activeMissions;
private final List<MissionManager.Mission> finishedMissions;

public GetMissionsPacket() {
this.type = PacketType.REQUEST;
this.activeMissions = List.of();
this.finishedMissions = List.of();
}
public GetMissionsPacket(List<MissionManager.Mission> activeMissions, List<MissionManager.Mission> finishedMissions) {
this.type = PacketType.RESPONSE;
this.activeMissions = activeMissions;
this.finishedMissions = finishedMissions;
}

public GetMissionsPacket(FriendlyByteBuf buf) {
this.type = buf.readEnum(PacketType.class);
this.activeMissions = buf.readCollection(Lists::newArrayListWithCapacity, readerBuf -> {
ResourceLocation id = readerBuf.readResourceLocation();
EntityType<?> entityType = ForgeRegistries.ENTITY_TYPES.getValue(readerBuf.readResourceLocation());
if(entityType == null) {
entityType = EntityType.PLAYER;
}
ResourceLocation loot = readerBuf.readResourceLocation();
boolean lootBefore = readerBuf.readBoolean();
return new MissionManager.Mission(id, List.of(), List.of(), List.of(), entityType, loot, lootBefore);
});
this.finishedMissions = buf.readCollection(Lists::newArrayListWithCapacity, readerBuf -> {
ResourceLocation id = readerBuf.readResourceLocation();
EntityType<?> entityType = ForgeRegistries.ENTITY_TYPES.getValue(readerBuf.readResourceLocation());
if(entityType == null) {
entityType = EntityType.PLAYER;
}
ResourceLocation loot = readerBuf.readResourceLocation();
boolean lootBefore = readerBuf.readBoolean();
return new MissionManager.Mission(id, List.of(), List.of(), List.of(), entityType, loot, lootBefore);
});
}

@Override
public void write(FriendlyByteBuf buf) {
buf.writeEnum(this.type);
buf.writeCollection(this.activeMissions, (writerBuf, mission) -> {
writerBuf.writeResourceLocation(mission.id());
writerBuf.writeResourceLocation(getRegistryName(mission.reward()));
writerBuf.writeResourceLocation(mission.rewardLootTable());
writerBuf.writeBoolean(mission.lootBefore());
});
buf.writeCollection(this.finishedMissions, (writerBuf, mission) -> {
writerBuf.writeResourceLocation(mission.id());
writerBuf.writeResourceLocation(getRegistryName(mission.reward()));
writerBuf.writeResourceLocation(mission.rewardLootTable());
writerBuf.writeBoolean(mission.lootBefore());
});
}

@Override
public void handle(NetworkEvent.Context context) {
ServerPlayer sender = context.getSender();
assert (sender == null) ^ (this.type == PacketType.REQUEST);
context.enqueueWork(() -> {
if(sender == null) {
Minecraft.getInstance().setScreen(new MissionListScreen(this.activeMissions, this.finishedMissions));
} else {
PlayerMissions playerMissions = ((IPlayerListWithMissions) Objects.requireNonNull(sender.getServer()).getPlayerList()).getPlayerMissions(sender);
List<MissionManager.Mission> activeMissions = playerMissions.activeMissions()
.stream().map(id -> ForgeEventHandler.getMissionManager().getMission(id))
.filter(Optional::isPresent).map(Optional::get)
.toList();
List<MissionManager.Mission> finishedMissions = playerMissions.finishedMissions()
.stream().map(id -> ForgeEventHandler.getMissionManager().getMission(id))
.filter(Optional::isPresent).map(Optional::get)
.toList();
GetMissionsPacket packet = new GetMissionsPacket(activeMissions, finishedMissions);
RealPeacefulMode.packetHandler.send(PacketDistributor.PLAYER.with(() -> sender), packet);
}
});
}
}

发包注册方式和前文相似:

1
2
3
4
5
private void setup(final FMLCommonSetupEvent event) {
//...
registerMessage(GetMissionsPacket.class, GetMissionsPacket::new);
registerMessage(ClientboundMissionMessagePacket.class, ClientboundMissionMessagePacket::new);
}

显示任务列表的Screen无需与世界交互,所以只实现一个Screen即可,无需Menu:

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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
public class MissionListScreen extends Screen {
private static final int MAX_MISSIONS_PER_SCREEN = 6;

protected final int imageWidth = 176;
protected final int imageHeight = 166;
protected final int titleLabelX = 16;
protected final int titleLabelY = 6;
protected int leftPos;
protected int topPos;

private int beginIndex = 0;
private float scrollOffs;
private boolean showFinished = false;
private boolean scrolling = false;

public static final ResourceLocation BG_LOCATION = new ResourceLocation(MODID, "textures/gui/mission_list.png");

private final List<MissionManager.Mission> activeMissions;
private final List<MissionManager.Mission> finishedMissions;

private List<Tuple<MissionManager.Mission, Boolean>> shadows;

@Nullable
private List<FormattedCharSequence> noMissionText = null;

public MissionListScreen(List<MissionManager.Mission> activeMissions, List<MissionManager.Mission> finishedMissions) {
super(Component.translatable("title.real_peaceful_mode.menu.mission_list"));
this.activeMissions = activeMissions;
this.finishedMissions = finishedMissions;
this.shadows = this.activeMissions.stream().map(mission -> new Tuple<>(mission, true)).collect(Collectors.toList());
}

@Override
protected void init() {
this.leftPos = (this.width - this.imageWidth) / 2;
this.topPos = (this.height - this.imageHeight) / 2;
}

@Override
public void render(GuiGraphics transform, int x, int y, float ticks) {
transform.drawString(this.font, this.title, this.leftPos + this.titleLabelX, this.topPos + this.titleLabelY, 0x404040);
this.renderBg(transform, x, y);
super.render(transform, x, y, ticks);
}

protected void renderBg(GuiGraphics transform, int x, int y) {
transform.blit(BG_LOCATION, this.leftPos, this.topPos, 0, 0, this.imageWidth, this.imageHeight);
int k = (int)(91.0F * this.scrollOffs);
transform.blit(BG_LOCATION, this.leftPos + 149, this.topPos + 39 + k, 176 + (this.isScrollBarActive() ? 0 : 12), 0, 12, 15);
this.renderButtons(transform, x, y);
this.renderMissions(transform);
this.renderTooltip(transform, x, y);
}

private void renderButtons(GuiGraphics transform, int x, int y) {
int buttonX = this.leftPos + 116;
int buttonY = this.topPos + 6;
boolean xInRange = x >= buttonX && x < buttonX + 54;
boolean yInRange = y >= buttonY && y < buttonY + 18;
int buttonHeight = (xInRange && yInRange) ? this.imageHeight + 36 : this.imageHeight;
if(this.showFinished) {
buttonHeight += 18;
}
transform.blit(BG_LOCATION, buttonX, buttonY, 0, buttonHeight, 54, 18);
}

private void renderMissions(GuiGraphics transform) {
int bound = Math.min(this.shadows.size(), MAX_MISSIONS_PER_SCREEN);
if(bound == 0) {
if(this.noMissionText == null) {
this.noMissionText = this.font.split(Component.translatable("gui.real_peaceful_mode.menu.mission_list.no_mission"), 128);
}
for(int i = 0; i < this.noMissionText.size(); ++i) {
transform.drawString(this.font, this.noMissionText.get(i), this.leftPos + 6, this.topPos + 38 + i * 9, 0xa0a0a0, false);
}
} else {
for (int i = 0; i < bound; ++i) {
if(this.shadows.get(this.beginIndex + i).getB()) {
transform.blit(BG_LOCATION, this.leftPos + 6, this.topPos + 38 + 18 * i, 54, 166, 140, 18);
} else {
transform.blit(BG_LOCATION, this.leftPos + 6, this.topPos + 38 + 18 * i, 54, 184, 140, 18);
}
ResourceLocation id = this.shadows.get(this.beginIndex + i).getA().id();
transform.drawString(this.font, Component.translatable("mission.%s.%s.name".formatted(id.getNamespace(), id.getPath())), this.leftPos + 8, this.topPos + 42 + 18 * i, 0xffffff, false);
}
}
}

private void renderTooltip(GuiGraphics transform, int x, int y) {
if(x >= this.leftPos + 6 && x < this.leftPos + 6 + 140) {
int bound = Math.min(this.shadows.size(), MAX_MISSIONS_PER_SCREEN);
int i = (y - this.topPos - 38) / 18;
if(i >= 0 && i < bound) {
ResourceLocation id = this.shadows.get(this.beginIndex + i).getA().id();
if(this.shadows.get(this.beginIndex + i).getB()) {
transform.renderTooltip(this.font, Component.translatable("mission.%s.%s.description".formatted(id.getNamespace(), id.getPath())), x, y);
} else {
transform.renderTooltip(this.font, Component.translatable("mission.%s.%s.after".formatted(id.getNamespace(), id.getPath())), x, y);
}
}
}
}

@Override
public boolean mouseClicked(double x, double y, int button) {
this.scrolling = false;
int buttonX = this.leftPos + 116;
int buttonY = this.topPos + 6;
if(y >= buttonY && y < buttonY + 18.0D) {
if(x >= buttonX && x < buttonX + 54.0D) {
Minecraft.getInstance().getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_STONECUTTER_SELECT_RECIPE, 1.0F));
this.showFinished = !this.showFinished;
if(this.showFinished) {
ImmutableList.Builder<Tuple<MissionManager.Mission, Boolean>> builder = ImmutableList.builder();
builder.addAll(this.activeMissions.stream().map(mission -> new Tuple<>(mission, true)).collect(Collectors.toList()));
builder.addAll(this.finishedMissions.stream().map(mission -> new Tuple<>(mission, false)).collect(Collectors.toList()));
this.shadows = builder.build();
} else {
this.shadows = this.activeMissions.stream().map(mission -> new Tuple<>(mission, true)).collect(Collectors.toList());
}
return true;
}
}
buttonX = this.leftPos + 148;
buttonY = this.topPos + 38;
if (x >= buttonX && x < buttonX + 12 && y >= buttonY && y < buttonY + 108) {
this.scrolling = true;
}
return super.mouseClicked(x, y, button);
}

@Override
public boolean mouseDragged(double fromX, double fromY, int activeButton, double toX, double toY) {
if (this.scrolling && this.isScrollBarActive()) {
int i = this.topPos + 14;
int j = i + 54;
this.scrollOffs = ((float)fromY - (float)i - 7.5F) / ((float)(j - i) - 15.0F);
this.scrollOffs = Mth.clamp(this.scrollOffs, 0.0F, 1.0F);
this.beginIndex = (int)(this.scrollOffs * this.getScreenTotalScrollRows());
return true;
}
return super.mouseDragged(fromX, fromY, activeButton, toX, toY);
}

@Override
public boolean mouseScrolled(double x, double y, double delta) {
if (this.isScrollBarActive()) {
int totalRows = this.getScreenTotalScrollRows();
float f = (float)delta / totalRows;
this.scrollOffs = Mth.clamp(this.scrollOffs - f, 0.0F, 1.0F);
this.beginIndex = (int)(this.scrollOffs * totalRows);
}

return true;
}

private boolean isScrollBarActive() {
return this.shadows.size() > MAX_MISSIONS_PER_SCREEN;
}

private int getScreenTotalScrollRows() {
return this.shadows.size() - 5;
}
}

这里UI右上方有一个按钮,用来决定客户端玩家是否查看自己已完成的任务。这是唯一需要额外接收请求的部分。对于已完成与否的任务,要显示不同的提示,由于比较简单,不做额外讲解了。

总结

这样,我们成功地把整个任务与对话系统搬到了Minecraft中,而且支持数据包作者和拓展模组开发者们添加任务,可以说实现地非常完美了。下一部分打算讲轻松点的,主要是模组世界生成中添加结构的方法。

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数值即为调色板的下标。


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

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

TeaCon 2023 言传身教奖获奖文章·三·弹幕聊天

原文载于哔哩哔哩专栏《MC 1.20.1 ForgeMod开发小记:从mod界面的config按钮说起》,作者为 Locus_Natit,
原地址:https://www.bilibili.com/read/cv25140572/


MC 1.20.1 ForgeMod开发小记:从mod界面的config按钮说起

写在开头

本文的所有内容基于 Minecraft 1.20.1 Forge 47.1.0 版本,使用 CC BY-NC-ND 4.0 协议发布至公有领域。

如果你是一个 Mod 开发老手,那么我建议你看完前言后直接跳到解决方法部分,我相信你已经在 Mod 开发的历程中有自己的思考和感悟,细枝末节的过程不再重要。

如果你是一个 Mod 开发新手,那么我还是建议你仔细阅读这篇文章的内容,重要的不是具体的问题,而是解决问题的过程和方法,希望我的讲解可以带你一窥在抄写教程和疯狂画饼之外的、独立解决问题的 Modding 魅力。

* 建议使用 Bilibili 网页版阅读

* 本文参与 TeaCon 2023 “言传身教” 奖项评选


前言

今年,笔者以参赛者(也不一定,万一到时候造不出来展馆就变成参展者了,乐.jpg)的身份参加了 2023 TeaCon 模组开发茶会,参赛作品是一个很简单的客户端 Mod:弹幕聊天。在这里,笔者无意详述 Mod 功能实现的具体细节,而是想与各位一起讨论一个在 Mod 开发中经常被许多开发者忽略的一个用户体验上的小细节,即 Mod 的配置项。

有一定项目经验的开发者肯定对 Forge 提供的配置文件系统有了不少了解,或许也都上手写过自己 Mod 的配置文件。但是就笔者的经验来看,许多开发者都没有注意到 Forge 的一个小细节:Forge 的 Mod 列表界面其实是有一个写着“配置”的按钮的。

至少截至发稿,TeaCon 2023 测试服的 Mod 界面里也只有弹幕聊天适配了这个 Forge 提供的按钮。作为对比,测试服的 config 文件夹内共有 29 个文件(不计算文件夹嵌套的情况下)

有的开发者可能觉得写出来配置文件就已经足够方便玩家了,但笔者本人还是比较推荐大家积极地去适配 Forge 提供的功能,这不仅能够方便玩家,也能够方便你被问到各种奇葩问题的血压(当然笔者想去适配这个按钮的部分原因可能是因为自己的强迫症)。

那么接下来就直接进入我们的正题:这个按钮我们到底该如何去适配呢?
开发中遇到了困难,那自然是去翻源码。Minecraft 的游戏内容显示是以 Screen 为基本单元的,那与 Mod 列表界面强相关的自然就是 ModListScreen 类了,让我们来看看这个类的源码:

1
2
3
4
5
6
public class ModListScreen extends Screen
{
...
private Button configButton, openModsFolderButton, doneButton;
...
}

我们一眼就看到这个类下面有三个 Button 类型的字段,对应的名字也正好就是我们刚刚在 Mod 列表界面看到的三个按钮,那配置按钮就是里面的 configButton 没跑了。那么这个按钮是怎么初始化的呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ModListScreen extends Screen
{
...
public void init()
{
...
configButton = Button
.builder(Component.translatable("fml.menu.mods.config"),
b -> ModListScreen.this.displayModConfig()
)
.bounds(6, y, this.listWidth, 20)
.build();
...
}
...
}

每个 Screen 在被渲染到屏幕上之前都会调用一遍 init() 方法完成内容的初始化,Mod 列表界面自然也不例外,在这里我们顺利的找到了这个按钮的初始化方法。可以看到,这里调用了 Button 类的构造器方法生成了一个 Button 实例。bounds() 方法很好理解,就算看参数都能猜出来这是一个定义按钮位置的方法,那么定义按钮按下逻辑的方法自然就在构造器方法的参数中了,我们来看看构造器方法的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Button extends AbstractButton
{
...
public static Button.Builder builder(Component p_254439_, Button.OnPress p_254567_)
{
return new Button.Builder(p_254439_, p_254567_);
}
...
public interface OnPress
{
void onPress(Button p_93751_);
}
}

很好,参数类型把作用都告诉我们了,很显然这个方法的第二个参数传递的就是一个定义按钮按下后逻辑的实例,由于第二个参数类型是一个函数式接口(即只有一个方法的接口)Forge 在这里直接传入了一个 Lambda 表达式:

1
b -> ModListScreen.this.displayModConfig()

接下来我们只需要找到 displayModConfig() 这个方法,它的代码是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ModListScreen extends Screen
{
...
private void displayModConfig()
{
if (selected == null) return;
try
{
ConfigScreenHandler
.getScreenFactoryFor(selected.getInfo())
.map(f -> f.apply(this.minecraft, this))
.ifPresent(newScreen -> this.minecraft.setScreen(newScreen));
}
catch (final Exception e)
{
...
}
}
...
}

这段代码有些复杂,我们一行一行分析,首先看 ConfigScreenHandler.getScreenFactoryFor() 方法:

1
2
3
4
5
6
7
8
9
public class ConfigScreenHandler
{
...
public static Optional<BiFunction<Minecraft, Screen, Screen>>
getScreenFactoryFor(IModInfo selectedMod)
{
...
}
}

可以看到,这个方法的返回值是一个 Optional<BiFunction<Minecraft, Screen, Screen>> 类型的对象,然后调用了 map() 方法将这个对象映射为了一个 Optional 对象,最后判断这个 Screen 是否存在,若存在则调用 minecraft.setScreen() 方法替换游戏内的界面。

可以想当然地认为(这部分的讨论请见后文),只要 map() 方法能够返回一个存在 Screen 的 Optional 对象,便可以让 Mod 列表界面的 config 按钮正常工作,所以我们还是需要返回 ConfigScreenHandler.getScreenFactoryFor() 方法,看看它到底返回了一个什么样的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ConfigScreenHandler
{
public record ConfigScreenFactory(BiFunction<Minecraft, Screen, Screen> screenFunction)
implements IExtensionPoint<ConfigScreenFactory> {}
public static Optional<BiFunction<Minecraft, Screen, Screen>>
getScreenFactoryFor(IModInfo selectedMod)
{
return ModList.get()
.getModContainerById(selectedMod.getModId())
.flatMap(mc -> mc.getCustomExtension(ConfigScreenFactory.class)
.map(ConfigScreenFactory::screenFunction)
);
}
}

首先,它调用了 ModList.get() 方法得到了游戏的 Mod 列表,然后调用 getModContainerById() 方法,获取到了对应 Mod 的 Container(可以简单理解为一个 Mod 对应一个 Container ),最后调用 flatMap() 方法将对应的 Container 实例映射为我们需要的 Optional<BiFunction<Minecraft, Screen, Screen>> 对象(在这里我们可以认为 flatMap() 和 map() 方法效果是一样的)。

那么具体的映射方法这里还是使用了 Lambda 表达式进行了定义:

1
2
3
mc -> mc
.getCustomExtension(ConfigScreenFactory.class)
.map(ConfigScreenFactory::screenFunction)

当然这里也用了一个 map() 方法,不过结合 ConfigScreenHandler 类中对 ConfigScreenFactory 类( record 类,JDK 16 中引入的新特性)的定义相信读者不难看出这段代码的作用,这里就不再赘述了(注意:这里的 mc 指的不是minecraft ,而是 modContainer )。

那么我们只需要知道 mc.getCustomExtension() 究竟返回了什么,就能知道我们该怎么给 config 按钮添加功能了,直接上源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package net.minecraftforge.fml;
...
public abstract class ModContainer
{
...
protected final Map<Class<? extends IExtensionPoint<?>>, Supplier<?>>
extensionPoints = new IdentityHashMap<>();
...
public <T extends Record> Optional<T>
getCustomExtension(Class<? extends IExtensionPoint<T>> point)
{
return Optional.ofNullable(
(T) extensionPoints.getOrDefault(point,()-> null).get()
);
}

public <T extends Record & IExtensionPoint<T>> void
registerExtensionPoint(Class<? extends IExtensionPoint<T>> point,
Supplier<T> extension)
{
extensionPoints.put(point, extension);
}
...
}

从代码中我们很容易就能看出来,其实这就是一个从键是类,值是 Supplier 对象的 map 中取值的方法,对应放键值对进入这个 map 的方法正好挨在一块,也省得我们继续找了。

不过这里还有一个问题:我们该怎么获取自己 Mod 对应的 Container 呢?

可以看到 ModContainer 类是位于 net.minecraftforge.fml 包下的,都属于 fml 了那自然是去找模组加载上下文( ModLoadingContext )啦。查阅对应代码后可以找到 ModLoadingContext 类中有一个 getActiveContainer() 方法正好可以拿到我们 Mod 对应的 Container ,不过这里我们还有一个更棒的选择:ModLoadingContext 类已经给我们提供了往 extensionPoints 中添加键值对的方法,我们只需要直接调用 registerExtensionPoint() 方法就可以了。

1
2
3
4
5
6
7
8
9
10
11
public class ModLoadingContext
{
...
public <T extends Record & IExtensionPoint<T>> void
registerExtensionPoint(Class<? extends IExtensionPoint<T>> point,
Supplier<T> extension)
{
getActiveContainer().registerExtensionPoint(point, extension);
}
...
}

想当然?

当然,现在回头看我们会发现还有一个小问题没有解决,那就是前文提到的 “想当然” ,那么我们的判断究竟对不对呢?其实写出来跑一遍就肯定能知道答案,但这里我们还是想从代码层面找出证据证明我们的 “想当然” 是对的。

文章都写到这了,那我们想的肯定是对的,这里我也就不卖关子,直接把对应的源码贴出来大家就能明白。

其实在 init() 方法的后面部分,我们能看到这里对 configButton 做了一个奇怪的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ModListScreen extends Screen
{
...
public void init()
{
...
configButton = Button
.builder(Component.translatable("fml.menu.mods.config"),
b -> ModListScreen.this.displayModConfig()
)
.bounds(6, y, this.listWidth, 20)
.build();
...
configButton.active = false;
...
}
...
}

从字段名称我们就能看出来,这个语句是禁用 configButton 的功能的,那么这个字段在什么时候被设置为 true 了呢?在后面的 updateCache() 方法里:

1
2
3
4
5
6
7
8
9
10
11
12
public class ModListScreen extends Screen
{
...
private void updateCache()
{
...
this.configButton.active =
ConfigScreenHandler.getScreenFactoryFor(selectedMod).isPresent();
...
}
...
}

这与我们在 displayModConfig() 方法中找到的代码不谋而合,补上了这最后一块漏洞。


解决方法

根据我们在上面的推理,只需要在自己的 Mod 主类中添加这样一段代码,就可以实现按下 config 按钮完成我们想要的逻辑了,在这里我就以最简单的实现:按下按钮打开配置文件为例(这是弹幕聊天里的代码,不过现在已经不是这样的了)。

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 class BulletChat
{
public BulletChat()
{
if (FMLLoader.getDist() == Dist.DEDICATED_SERVER)
return;

ModLoadingContext
.get()
.registerExtensionPoint(FACTORY.getClass(), () -> FACTORY);
}

@OnlyIn(Dist.CLIENT)
public static final class BulletChatClient
{
public static final ConfigScreenHandler.ConfigScreenFactory FACTORY =
new ConfigScreenHandler.ConfigScreenFactory(BulletChatClient::openConfig);

public static Screen openConfig(Minecraft mc, Screen screen)
{
Util.getPlatform().openFile(
FMLPaths.CONFIGDIR.get().resolve("bchat-client.toml").toFile()
);
return screen;
}
}
}

在这里单独写出一个 @OnlyIn() 注解的类是为了防止在服务端上触发客户端的类加载导致的崩服,不过我想应该也不会有人把弹幕聊天装到服务器上吧(笑 TeaCon 除外)。

打开游戏,就可以看到 Mod 列表界面的 config 按钮已经亮起来了,按下就可以弹出配置文件啦!


后记

本来计划用一篇文章讲完 Forge 的 config 按钮和原版风格配置界面的,但是写完第一部分才发现字数已经不少了,所以今天就先写到这儿吧(第二篇写不写还两说呢,一是最近实在是抽不开身来,二是关于 Screen 的内容其实前人已经有很多介绍了)。

其实 Forge 提供的配置文件系统在 1.12.2 版本后进行了一次大改,原来的自动配置界面系统被舍弃了,这也是让我想要适配这个界面的原因之一吧(顺带一提,这玩意前段时间还有人给 Forge 提 PR 想把它加回来,结果被否了)。各位读者如果对 1.12.2 版本及以前的自动配置界面系统有兴趣可以参考 3Tusk 所撰写的 Harbinger Mod 开发教程中的相关内容,这里我就不再贴链接了。

当然,也希望在 TeaCon 2023 开发截止前看到这篇专栏的参赛者们积极修改自己的 Mod(s) (笑

这里是 Locus_Natit(你也可以叫我 Loci_Natit ),我们下次再见~

TeaCon 2023 言传身教奖获奖文章·二·Romatic Tp

原文载于知乎专栏《minecraft mod开发日志:在游戏中实现midi系统》,作者为 modist,
原地址:https://zhuanlan.zhihu.com/p/652658921


minecraft mod开发日志:在游戏中实现midi系统

0. 前言

参与过两届teacon模组开发茶会后,本人对minecraft模组制作也有了些许经验。为了响应teacon 2023“言传身教”的号召,给新手们提供技术参考,同时也是为了将来的回顾与反思,特作此文章,记录下自己走过的种种弯路。

本届teacon中,本人的参赛mod为Romantic Tp(名称来源于ZUN号的音源名称),内容包括各类虚拟乐器的演奏与midi文件播放。相比于去年的Art of TNT,规模是小了许多,但技术上的难点有增无减。其中,最主要的便是本篇文章的主题——midi系统的搭建。

Romantic Tp的github仓库位于https://github.com/ModistAndrew/RomanticTp,代码实现细节可供参考。

1. 综述

minecraft中实现midi系统的mod并不少:MusicCraftmimi-mod,包括同是本届mod的Bocchi_The_Rock,都为本人提供了许多参考。于这些mod,本人有所学习借鉴,也有所改进创新;对于各类可能的实现,在不断的重构中,有所选择,也有所舍弃。

我们的目标,是实现一套能够接收midi设备或键盘输入,以及播放midi文件;加载音色库,合成音频;并将音频接入原版openAL管线进行播放的midi系统。对于游戏内的物品,方块等,就不多做介绍了。

2. 前置知识介绍

Java内置音频与midi系统

java sound API位于javax.sound包中,提供了与音频相关的一系列功能。选择以下重点作简要介绍:

Sequencer(音序器)

Sequencer用于读取midi文件并转化为一系列Midi Message,在特定时刻发送给Synthesizer。Midi Message即midi信号,可以传输Note on,Note off,Program Change,Control Change等信息1

Synthesizer(合成器)

Synthesizer根据音色库和Midi Message合成音频,输出数据流到Source Data Line。系统内部提供默认的音色库;也可以自己加载sf2音色库2

SourceDataLine(源数据线)

Source Data Line接收音频数据流,这里的数据会直接作为音频输出播放。

openAL音频系统

openAL的音频系统在官网上有详细介绍3。它没有提供midi处理,但作为原版MC使用的音频API,它提供了良好的3D音效支持。

其中,我们最为关心的是流式播放的内容。openAL采取了一种较为原始的方法实现流式播放:我们需要向缓冲区中写入byte[]数据,定期查询已经处理完毕的数据并写入新数据,以此保持音频数据流的持续播放。

openAL还有一套EFX拓展,便于使用,能够为音频提供混响效果4

原版音频系统

原版MC的音频系统由Sound Engine管理,各个音频被包装为Channel与openAL进行互动。在上层,还有Sound Instance的封装,包含各类音频设置,并提供tick方法进行更多控制。

3. midi系统设计

许多类通过重写进行修改,这些类有Instrument或Al前缀

本人将整套系统大致分为4个部分,以下一一介绍。

红:音频输出对接到原版openAL管线

前文提到,Java的midi系统最终会通过Source Data Line进行输出。然而,我们希望不直接输出,而是对接到原版openAL管线,以获得3D音效与混响效果,同时也更方便进行管理。

首先,我们借助原版提供的Sound Buffer将Al Data Line中的数据处理成合适的格式,再写入到Al Channel中。原版的Sound Engine在tickNonPaused方法中会实时更新Channel,包括位置、音量等信息,同时对openAL缓冲区进行处理。

黄:midi信号处理与游戏数据更新

这一部分主要是通过Instrument Sound Instance进行管理的。该类负责:

  • midi信号传输。提供sendMessage方法直接传输信号,以及attachSequencer方法播放midi文件。信号会输入到Midi Filter中进行过滤(比如设置合适的乐器);再输入到Synthesizer中进行合成。
  • 资源的申请与释放。通过Instrument Sound Manager创建后,会申请Synthesizer等资源;在tick方法中实时检查是否应当调用destroy方法对自身进行资源释放。也可通过外部控制,如游戏退出时强制destroy。
  • 游戏数据更新。包括声音的位置信息,玩家使用了何种乐器,何种混响,是否被移除等等。这些都在tick方法中进行管理。

绿:游戏输入与数据同步

客户端/服务端数据同步一直是mod开发中的重难点。游戏内有三种不同的实体可以进行midi输入,需要不同的数据同步方式:

  • 实体,只能播放midi文件。服务端播放midi文件(数据直接存储在Score物品中,方便同步,且midi文件大小较小),会借助Server Instrument Sound Manager类广播数据到所有客户端。
  • AutoPlayer(自动播放器),只能播放midi文件。会自动同步数据到客户端,服务端开始播放时,会更新状态到客户端,通过Instrument Sound Manager进行播放。
  • 玩家,可播放midi文件或直接输入midi信号。前者同实体;后者输入到Local Receiver,通过Instrument Sound Manager的broadcast发送数据到服务端,再同步到所有客户端。

最终,客户端的Instrument Sound Manager接收信息,获取或创建Instrument Sound Instance并发送midi信号。

(必须承认,由于多次重构,以及原版数据同步奇奇怪怪的问题,这一部分的代码显得十分臃肿,还请各位谅解)

蓝:资源加载与分配

  • MidiFileLoader & SoundbankLoader。通过ResourceManager::listResources,我们很容易加载位于assets文件夹下的各种资源;继承Resource Manager Reload Listener,即可实现F3+T资源热重载。
  • MidiKeyboardLoader。通过配置文件中指定的名称,查找对应的midi输入设备,加载后接入Local Receiver,midi信号便会直接输入到这里。
  • SynthesizerPool。Synthesizer(通过Synthesizer Wrapper封装)申请与释放会消耗大量时间资源,需要统一管理。在游戏启动时,一次性创建指定数目(默认64个)Synthesizer Wrapper;可通过request方法申请,通过free方法释放;F3+T资源重载时,清空重建所有Synthesizer Wrapper,以更新音色库信息。

4. 翻车记录(划掉)注意事项

cpu占用

本人因cpu占用问题被组委会多次点名,实在是惨痛的经历!问题出在Source Data Line的write方法,该方法要求在无需处理音频时阻塞。然而本人起初在这里直接返回(没有Al Channel时,无需输出),造成死循环,cpu占用一度飙升到80%。后来,本人在无需输出时调用内部Source Data Line的write方法,以为完事大吉,却仍然被指cpu占用过高(可见IO永远是性能瓶颈)。最后的解决方法是短短一行:

1
Thread.sleep(10);

音频延迟

那么问题来了,我们为什么不用wait/notify让线程一直睡到Al Channel存在呢?因为阻塞太久会造成延迟:开始播放时,之前写入的数据需要先播放完毕,这造成了从启动游戏到开始播放之间的巨量延迟。打断点,游戏卡顿也会造成类似的情况,因为Sound Engine被阻塞,无法及时处理数据。

关于延迟,我们还需要了解一下Jitter Corrector5:输入端的数据不可能一直稳定,因而直接输出会造成音乐的节奏不稳。为此,Jitter Corrector被引入,在输入数据有波动时,维持输出的稳定,类似电路中的稳压器。显然,这种算法需要一定的时间来处理数据。在使用设备进行输入时,造成的延迟是很明显的。

关闭Jitter Correction后,输入延迟会大大降低;同时,由于不知名原因(可能是缓冲方式不同),阻塞造成的延迟也消失了。当然,此时播放midi音乐,能感受到十足的Rubato风格。

体积压缩

鉴于本次teacon 10MB的mod jar大小限制,在准备自带音源时有必要进行体积压缩。之前编曲用Kontakt音源动辄几十G上百G,如今却要将音源压缩到仅仅10MB,这难道不是时代的倒退吗?(划掉)

为了达到相对较高的音质,将近100MB的sf2音源是必须的。本人先后尝试了合并双声道,降低采样率等方法,最终得到的听感十分塑料。经过大佬的指点,我转而研究采样格式:sf2格式音源内部采用原始的wav格式存储,若是使用ogg等压缩格式,大小能降低至约1/8。幸运的是,恰好有一种格式的音源(.sf3)实现了这一点,而且除了压缩采样,其余没有什么改动;不幸的是,这种非官方的格式并没有什么编辑器支持,也无法通过Java Sound API直接加载。

有困难就得克服。本人利用sftools工具将sf2转换为sf3,又借助原版提供的Ogg Audio Stream,在SF2 Soundbank的基础上完成了sf3音源的支持。

内存占用

从事过编曲工作的人都知道,音频处理对于内存的需求是十分巨大的。本mod提供的音源体积不算大,但是64个Synthesizer一开,若是没有合理共享资源,内存也会吃紧。

全部Synthesizer使用的音源都是相同的,因此本人建立了Instrument Cache,相同的虚拟乐器,就不需要加载第二次了。

线程安全

音频处理中,多线程的使用是十分有必要的,因为许多操作需要消耗大量时间或是持续进行,不可阻塞主线程。这就涉及到线程安全的问题。

本人相当喜欢使用Atomic,Concurrent,Completable Future这些工具,它们为异步处理带来许多便利,基本无需考虑那些底层;但一些奇奇怪怪的问题也随之浮现,包括初始化时无法加载类,上述的内存共享方案失效等问题。最终的解决方案是,将初始化全部放在加载阶段的主线程中,同步进行。

启示:非必要不使用多线程。

我的世界为什么没有声音

无声、延迟问题是开发过程中极为常见的,当然,重启或F3+T热重载能够解决其中的绝大多数。一般来说,插拔耳机会导致音频系统重启,声音消失,这点目前还没有什么解决方案;游戏被静音时,Channel会被删除,导致对应的Instrument Sound Instance失效,我们需要在创建时检测,确保Instrument Sound Instance最新。

5. 反思与展望

  • 利用好midi channel。midi信号可指定16个channel,对应不同的乐器和控制器。起初本人将所有信号发送到一个channel上(因为乐器只能指定一个),不同channel逐个新建,这样既占用资源,又需要手动分离midi文件中的channel,十分麻烦。后来搞了个“万能乐器”保留channel信息,但觉得这种方案也不算最优。最好是可以在游戏中指定每个channel对应的乐器。
  • 更多音色效果。目前对于EFX的使用还仅限于几种混响,更多的效果有待探索。
  • midi信号可以显示、控制音乐的速度,音量,位置,演奏技法等信息,这方面大有发挥空间。
  • midi文件名支持字符少,需要转义,能否通过mixin放宽限制?有何副作用?或是自己实现资源加载。以及,通过铁砧命名Score以存入midi信息有一种WIP的感觉,需要有专门的机器做这事情。
  • 总而言之,有必要设计一些GUI;要充分利用midi的优势,对音乐进行精准控制;要利用openAL管线提供的一系列声音处理功能。

-1.后记

今年是我第二次参加teacon了:2020年了解到它,2021年前来参观,2022年正式参赛。回想起去年高二升高三的暑假,每天从早7到晚11,干得如火如荼,当时想着,到了大学,就不会对游戏有如此热情了吧。还听说,teacon 2022可能是最后一届了,之后大家都要各奔东西。不过,今年的teacon还是如期举办了,我还是着手准备起来了,还是花了一个暑假在上面。

如果说去年的Art of TNT内容太过臃肿,那么今年的Romantic Tp就有些简陋了,而且没有什么吸引力——这种移植实在是吃力不讨好的事情。想搞视听结合,搞行为艺术,原版朴素的音符盒还更浪漫些;追求方便,纯粹为了听听音乐,那又比不上同是本届mod的网络音乐机了。

在开发Romantic Tp的过程中,盘根错节的地方多,柳暗花明的地方少。我常常碰到全新的领域,需要从头学起,那个c++项目sftools的构建,就花了我几天的时间;有些地方,没有前人的经验,我也不确定该怎么做,我把JDK中不可见的Gervill包整个fork进来,并稍作修改,但不知道这样是否合规,是否显得很蠢。

初期阶段,无声、延迟、杂音是常有的事。好不容易听到声音了,发现效果不够理想,发现有bug,发现框架需要重写,又花了几天时间,最终效果也没有说好很多。确实在一点点优化,但少豁然开朗,只是热情不再。

去年凑齐了四个人的队伍,今年只剩两人参与了。当然,主要的工作总是我来完成。按Zbx的话说,我需要更push一些?不过,不要把自己的喜好强加给别人,还是一开始就少些人好。

每当看到群里有人@我,我总是感到大事不妙,心急如焚,想着肯定是自己的mod写糠了,事实也往往如此。于是加急调试、修改、推送。这些问题都是因为自身经验不足,考虑不周,在此向各位道歉,会努力改进提升。

…其实也是有一些进步的。至今我的github仓库还全部都是MC mod,但Romantic Tp的开发已让我接触了许多新知识。学习新事物的意愿与能力在开发程序的过程中总是最为重要的。另一方面,今年请到了优秀的建筑师,还主动联系了其他队伍的人员进行联合,包括btr的作者mcczai,mtr的开发者Zbx1425等。(Zbx大佬待人温和又能力超强,他是我学习的榜样!)

为什么要继续参加teacon呢?它确实带来了一些收获,但也实在耗费了大量时间。开始时可能觉得Romantic Tp是个很好的创意,但现在我得重新考虑一下了。

截止到今天(9月9日),54个参赛mod的展馆中,有1个中途退赛,17个尚未动工,16个正在建设,5个基本完成,15个已经完成。我想,既然是参赛选手,就要去迎头赶上。

许多事情,开始时可能只是一种想法,一个创意,一次心血来潮;而这份热情褪去后,推动我们不断前进的,有惯性,有信念,还有大家的一起前行。

9.19 00:58:36更新:建筑服在线人数历史新高,大家都是ddl人!

参考

TeaCon 2023 言传身教奖获奖文章·写在前面

依原定计划,我们会将 TeaCon 2023 中获得言传身教奖前三的技术文章在本博客进行全文转载。

这是该转载计划的序言。

实际上也没什么好说的。奖品都发完了,再不把文章们转载分享一下,难道要拖到过年吗。

让我们开始吧。

Waiting for Server 背后的真相

引随着 TeaCon 2020 落下帷幕,奖项颁发结果也已公布。其中,模因污染奖项的结果格外引人注目——模因污染奖的第 0 名颁发给了 McJty 的 TOP(The One Probe) 模组,因为其 Waiting for Server 直观地体现了 TeaCon 服务器的几次卡顿。然而,服务器卡顿究竟是何物?

阅读全文

Hello Minecraft Mod

我们来了,我们做了,我们不奢望以一己之力改变这个世界,我们只希望能点亮一些人心中的火光;
我们来过,我们做过,我们没有在历史长河中留下浓墨重彩,我们只是在这岁月里写下了那一句话:

“Hello Minecraft Mod”