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 ),我们下次再见~