Minecraft 中的端
制作 Minecraft Mod 时,理解客户端和服务端这两个“端”非常重要。关于端的误解和错误很常见,可能导致游戏不崩溃但出现意外且难以预测的行为。
原创说明图,依据 Forge 1.21.x 官方文档 绘制。
不同类型的端
“客户端”和“服务端”通常分别指用户交互的程序和多人游戏连接的服务器,但实际上它们可能有四种含义:
- 物理客户端:从启动器启动 Minecraft 时运行的整个程序,包括游戏图形界面运行期间的所有线程、进程和服务。
- 物理服务端:通常称为独立服务器,指启动不提供可玩图形界面的
minecraft_server.jar时运行的整个程序。 - 逻辑服务端:运行游戏逻辑,包括生物生成、天气、物品栏、生命值、AI 和其他机制。它存在于物理服务端,也可以在物理客户端内部与逻辑客户端一起运行,即单人世界。它始终在名为
Server Thread的线程中运行。 - 逻辑客户端:接收玩家输入并转发给逻辑服务端,同时接收逻辑服务端的信息并以图形形式展示给玩家。它运行在
Render Thread中,但通常还会启动其他线程处理音频和区块渲染批处理等工作。
MinecraftForge 代码中,物理端由 Dist 枚举表示,逻辑端由 LogicalSide 枚举表示。
执行端专属操作
Level#isClientSide
这个布尔值是最常用的端检查方式。查询 Level 对象的此字段可确定其所属逻辑端:true 表示逻辑客户端,false 表示逻辑服务端。物理服务端上的值始终是 false;但 false 不一定意味着物理服务端,因为物理客户端内的逻辑服务端(即单人世界)也会返回 false。
判断是否应执行游戏逻辑和其他机制时使用此检查。例如,每次点击方块时伤害玩家,或让机器将泥土加工为钻石,都应先确认 #isClientSide 为 false。在逻辑客户端应用游戏逻辑,轻则导致不同步(幽灵实体、状态不同步等),重则导致崩溃。
默认优先使用此检查。除 DistExecutor 外,很少需要其他判断端并调整行为的方法。
DistExecutor
客户端和服务端 Mod 使用同一个通用 JAR,但物理端的游戏代码分属两个 JAR。net.minecraft.client 中的代码仅存在于物理客户端。如果你编写的类以任何方式引用这些名称,在不存在它们的环境加载该类时就会崩溃。新手常犯的错误是在方块或方块实体类中调用 Minecraft.getInstance().<doStuff>(),这会使物理服务端在类加载时崩溃。
FML 提供 DistExecutor,可以在不同物理端运行不同方法,或仅在一个端运行某方法。
注意:FML 根据物理端检查。单人世界在物理客户端中同时运行逻辑服务端和逻辑客户端,因此始终使用
Dist.CLIENT。
DistExecutor 接收一个提供 supplier 的 supplier 来执行方法,利用 JVM 的 invokedynamic 指令避免类加载。执行的方法应为其他类中的静态方法;如果该方法没有参数,应使用方法引用,而不是执行方法的 supplier。
主要方法为 #runWhenOn 和 #callWhenOn:它们接收目标物理端及要执行的方法,分别运行该方法或返回其结果。两者又分为 #safe* 和 #unsafe* 变体;这些名字容易误导。主要区别是开发环境中 #safe* 会验证提供的执行方法是否为返回其他类方法引用的 lambda,否则抛出错误。在生产环境中二者功能相同。
// 在客户端类 ExampleClass 中
public static void unsafeRunMethodExample(Object param1, Object param2) {
// ...
}
public static Object safeCallMethodExample() {
// ...
}
// 在某个通用类中
DistExecutor.unsafeRunWhenOn(Dist.CLIENT, () -> ExampleClass.unsafeRunMethodExample(var1, var2));
DistExecutor.safeCallWhenOn(Dist.CLIENT, () -> ExampleClass::safeCallMethodExample);
警告:由于 Java 9 及以上版本中
invokedynamic的行为变化,开发环境中的DistExecutor所有#safe*变体会将原异常包装在BootstrapMethodError中抛出。应改用#unsafe*变体或检查 FMLEnvironment#dist。
线程组
如果 Thread.currentThread().getThreadGroup() == SidedThreadGroups.SERVER 为真,当前线程很可能在逻辑服务端;否则很可能在逻辑客户端。无法访问 Level 来检查 isClientSide 时,可用它获取逻辑端。它根据当前线程组猜测逻辑端,因此只有其他方法都不可用时才应使用。几乎所有情况下都应优先检查 Level#isClientSide。
FMLEnvironment#dist 和 @OnlyIn
FMLEnvironment#dist 保存代码运行的物理端。它在启动时确定,无须猜测,但适用场景有限。
使用 @OnlyIn(Dist) 注解方法或字段会指示加载器:在指定物理端以外,将该成员完全从定义中移除。通常只在浏览反编译的 Minecraft 代码时看到它,用于标明 Mojang 混淆器移除的方法。没有理由直接使用此注解;应使用 DistExecutor 或检查 FMLEnvironment#dist。
常见错误
跨越逻辑端
在逻辑端之间传递信息时,必须始终使用网络数据包。单人游戏中直接从逻辑服务端向逻辑客户端传递数据很有诱惑力,常见的无意做法是使用静态字段。单人游戏的逻辑客户端和逻辑服务端共享 JVM,两个线程对静态字段的读写会造成各种竞态条件和典型的线程问题。
也可能显式地犯下这个错误:从在逻辑服务端运行或可能运行的通用代码中访问仅物理客户端存在的类,例如 Minecraft。在物理客户端调试的新手容易漏掉此错误:代码在那里可以工作,但在物理服务端会立即崩溃。
编写单端 Mod
近期版本的 Minecraft Forge 已从 mods.toml 中移除“sidedness”属性,因此 Mod 应能在物理客户端和物理服务端加载。对于单端 Mod,通常应在 DistExecutor#safeRunWhenOn 或 DistExecutor#unsafeRunWhenOn 内注册事件处理器,而不是在 Mod 构造函数中直接调用注册方法。基本原则是:当 Mod 在错误的端加载时,它应什么都不做、不监听事件等。单端 Mod 本身不应注册方块、物品等,因为这些对象在另一端也必须存在。
此外,单端 Mod 通常不会禁止用户加入没有该 Mod 的服务器。因此,应在 mods.toml 中将 displayTest 设为所需值。
[[mods]]
# ...
# MATCH_VERSION:客户端和服务端版本不同时显示红叉。默认行为;
# 如果 Mod 同时有服务端和客户端组件,应选择此值。
# IGNORE_SERVER_VERSION:服务端有 Mod、客户端没有时不显示红叉。
# 仅服务端 Mod 应使用此值。
# IGNORE_ALL_VERSION:客户端或服务端有 Mod 时不显示红叉。
# 这是特殊情况,仅当 Mod 没有服务端组件时使用。
# NONE:不为 Mod 设置显示测试。需要自行设置,详见 IExtensionPoint.DisplayTest。
# 可以使用此值定义任何方案。
# 重要:这并不指示 Mod 在哪些环境(客户端或独立服务端)加载。
# Mod 应在任何所在环境加载(也许什么都不做)。
displayTest="IGNORE_ALL_VERSION" # 未指定时默认为 MATCH_VERSION(可选)
要使用自定义显示测试,将 displayTest 设为 NONE,并注册 IExtensionPoint$DisplayTest 扩展:
// 确保网络另一端没有此 Mod 时,客户端不会将服务器显示为不兼容
ModLoadingContext.get().registerExtensionPoint(IExtensionPoint.DisplayTest.class, () -> new IExtensionPoint.DisplayTest(() -> NetworkConstants.IGNORESERVERONLY, (a, b) -> true));
这会告诉客户端忽略服务器缺少该版本,也告诉服务器不要要求客户端必须安装此 Mod,因此该代码适用于仅客户端和仅服务端 Mod。
译注:原文的
unsafeRunWhenOn示例可能存在 lambda 返回值问题,尚未编译验证;使用前请核对目标 Forge 版本的 API。