Minecraft Modding 教程01 基本概念

你需要知道

本教程的游戏版本为1.20.4,模组加载器为NeoForge,NeoForge版本为20.4.238,如果你要跟随本教程,请务必保持版本一致。还有一点需要说明,Minecraft的模组编写的API变化相当地快,可能在下一个版本,本教程的代码就会部分失效,但是核心的内容不会变,你可以以本教程的代码作为参考,希望能为你提供一些宝贵的思路。在编写代码的时候,如果你对某个游戏效果或者机制的实现感到很困难,你可以仔细看看原版中有无类似机制的实现,大部分时候,你都能从原版中借鉴到解决方法。

由于NeoForge中引入了大量的函数式编程的API,所以作为Java知识的一些补充,你可能还需要去了解一下Java的函数式编程语法以及相关的一些类,这里不再赘述。

环境配置

建议直接去看NeoForge官网的环境准备章节:https://docs.neoforged.net/docs/1.20.4/gettingstarted/

唯一的建议:配置一下gradle的魔法,否则你可能等上几天都下不完依赖文件

重要概念

注册(Registries)

注册在编写Minecraft mod的过程中尤为重要,几乎所有的新增内容都需要用到注册。注册就是告知Minecraft当前有哪些可用的资源,这样Minecraft才能知道你所编写的一系列新的内容。注册的时机非常重要,在错误的游戏阶段尝试注册会导致游戏崩溃!幸运的是,NeoForge为我们提供了一个很便利的注册工具:DeferredRegisterDeferredHolder,这个两个类可以自动在正确的时候注册我们新增的物品、方块、生物群系等等。

其实Registries真正的含义是注册表。Minecraft中的注册表是对原生Java的Map类的一个包装,这个Map保存了注册名(Registry Name)到注册条目(Registry Entry)的映射关系,所谓的注册条目,就是真正的游戏内容,例如物品、方块、实体等等。

而注册名在同一个注册表中必须是唯一的,但是在不同的注册表中不一定唯一,例如泥土的注册名minecraft:dirt既存在于物品(Items)的注册表中,也存在于方块(Blocks)的注册表中。注册名是ResourceLocation的一部分,如果你在游戏中用过/give命令,那么你对ResourceLocation绝对不会陌生,例如,给A玩家10个泥土: /give A minecraft:dirt 10,这里的minecraft:dirt就是典型的ResourceLocation的字符串表示。

这里之所以说注册名是ResourceLocation一部分而不将它们完全等同,完全是为了严谨,从Java的数据类型角度出发,注册名是一个字符串对象,而ResourceLocation是一个ResourceLocation对象

看到这里,是不是就已经清晰了我们应该怎么注册了?例如,我们需要添加物品,就需要往物品注册表注册,并且还要提供一个ResourceLocation来标识物品

ResourceLocation

ResourceLocation由两部分组成:命名空间和路径,这两个部分由一个冒号分隔开。这两个部分有一些字符的使用限制:

命名空间只能包含小写字母、数字、下划线、点和短横线;路径在命名空间的基础上,还能包含正斜杠。一半来说,我们添加的新内容的命名空间是我们的mod id(这完全取决于你自己,也可以不用mod id)。

对于命名空间字符的限制,NeoForge的官方文档是这么说的:

ResourceLocations may only contain lowercase letters, digits, underscores, dots and hyphens. Paths may additionally contain forward slashes. Note that due to Java module restrictions, mod ids may not contain hyphens, which by extension means that mod namespaces may not contain hyphens either (they are still permitted in paths).

最后一句是:注意,由于Java模块的限制,mod id不能包含短横线,这也就意味着 mod 命名空间也不能包含短横线。

这一句可能有点令人迷惑,它想表达的意思是:不用mod id作为命名空间的话就没有不能使用短横线的限制

注册的两种方式

在NeoForge中,有两种常用的注册方式:要么通过DeferredRegister来注册,要么通过注册事件来注册

方式一:通过DeferredRegister注册(推荐)

分三步,首先要创建一个注册器,然后通过这个注册器来注册我们新增的游戏内容,最后将注册器注册到mod总线上,下面给出示例代码(注册物品):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 第一步:创建注册器
public final static DeferredRegister ITEMS = DeferredRegister.create(
// 表示我们要向ITEM注册表注册,也就是新增物品
Registries.ITEM,
// 你的mod id
YourMod.ModId
);

// 第二步,通过注册器注册物品
public final static DeferredHolder<Item, Item> EXAMPLE_ITEM = ITEMS.register(
// 资源位置的路径部分
"example_item",
// 一个Supplier,如果你看不懂这种语法,请去补一补Java的函数式编程
()->new Item(new Item.Properties())
);

// 第三步,在mod总线上注册注册器
ITEMS.register(modEventBus); // modEventBus对象在你的mod主类的构造函数参数中

// 使用物品
EXAMPLE_ITEM.get();

你可能想问,为什么不传入完整的ResourceLocation,而是只传入其路径部分呢?这其实是因为你的注册器ITEMS中指定了你的mod id,后续通过这个注册器进行的注册都会自动将命名空间设置为你的mod id。

注册完毕后,我们后续使用这个物品只需要访问相应的DeferredHolder然后调用get()方法就可以了

这里补充一点,在创建注册器时,我们也可以将Registries.ITEM换成BuiltInRegistries.ITEM,从而实现向原版的注册表添加物品,这样可以实现与一些原版内容的交互,但是会牺牲一些跨版本兼容性,而且由于其他mod通常是通过Registries而非BuiltInRegistries来判断一些内容是否存在,所以在没有与原版内容交互的需求的前提下,建议新增的内容都使用Registries来注册

DeferredHolder的两个泛型的定义为:<R, T extends R>R表示要注册的注册表类型,T则表示我们要注册的内容的类型,对于第二个泛型参数,上面的例子中我们直接注册了一个空物品,所以直接写Item就可以。当你编写了一个特定的物品类后,你可以将第二个泛型参数填写成你编写的类名(当然直接填Item也是可以的)

对于物品和方块,NeoForge还提供了两个工具类,能更加方便地进行注册,这里以物品为例,如下:

1
2
3
4
5
// 物品工具类:创建注册表
public final static DeferredRegister.Item ITEMS = DeferredRegister.createItem(YourMod.ModId);
public final static EXAMPLE_ITEM = ITEMS.registerItem("example_item", (pProp)->new Item(new Item.Properties()));
// 或者也可以写成:
public final static EXAMPLE_ITEM = ITEMS.registerItem("example_item", (pProp)->new Item(pProp)); // registerItem会在内部创建一个Item.Properties对象并通过pProp传入

DeferredRegister.ItemregisterItem方法还有很多其他的重载,都非常有用,可以自行去源代码中查看

方式二:通过注册事件注册

建议先看看后面的事件系统再回来看这一小节

注册事件在mod总线上触发,所以我们需要在mod总线上注册我们的事件处理器,首先来看看应该如何写事件处理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// mod主类中
public void registerItemHandler(RegisterEvent event){
event.register(
Registries.ITEM,
registry -> {
registry.register(new ResourceLocation(ModId, "example_item"), new Item(new Item.Properties()));
}
);
}

public YourMod(IEventBus modEventBus){
// 将事件处理器注册到mod总线
modEventBus.addListener(this::registerItemHandler);
}

除了使用modEventBus对象的addListener方法来注册单个事件处理器外,还可以编写一个专门事件处理类,然后使用modEventBusregister方法将这个类的实例注册,详见事件系统一节

事件系统(Event)

Minecraft采用事件驱动的设计,几乎每个游戏事件都可以进行监听并且做出自定义处理。

事件系统分为两个主要部分:

  • 事件本身
  • 事件处理器

事件本身是我们不能改变的,我们可以做的就是监听我们感兴趣的事件并且编写事件处理器来进行我们自定义的处理。

某个事件处理器要监听的是哪个事件是由事件处理器的参数来决定的,举个例子:

1
2
3
public static void onLivingHurt(LivingHurtEvent event){
// 处理逻辑...
}

上面这个事件处理器就是监听的生物受伤的事件,因为这个方法的参数是LivingHurtEvent类型,通过这些事件参数,例如上面的event,我们能获取到详细的事件上下文,伤害源,受伤的生物对象等等

还需要说明的是,不同的事件会在不同的事件总线上触发,我们只能在正确的总线上监听并处理事件,如果监听到了错误的总线,Minecraft在启动阶段就会失败。总的来说,Minecraft的事件总线分为mod总线forge总线两类。

  • mod总线在游戏的启动的加载阶段时为每个mod派发的事件,mod主类中的构造函数参数中的IEventBus对象就是在这个时候传入进来的,在这个阶段我们可以检测某个特定mod的存在等等。但是不是所有的mod总线上的事件都仅是在游戏启动的加载阶段触发的,我们后面会讲到一个例子
  • forge总线中会派发游戏逻辑相关的事件,例如生物跳跃、生物受伤等等。

创建事件处理器

其实,上面给出的例子并不是一个完整的事件处理器,我们还需要加一些注解,来让Minecraft知道我们定义的事件处理器,有两种方式

静态方法作为事件监听器

你可以编写一个类,将这个类用@Mod.EventBusSubscriber(modid=YourModId)进行注解,然后再在这个类中编写静态方法,被@SubscribeEvent注解标记的静态方法会被注册为事件处理器。例如:

1
2
3
4
5
6
7
8
9
10
11
@Mod.EventBusSubscriber(modid = GTest.MODID, bus = Mod.EventBusSubscriber.Bus.MOD)
public class EventHandler{
@SubscribeEvent
public static void onBuildCreativeTab(BuildCreativeModeTabContentsEvent event){
GTest.LOGGER.info("Building Creative Tab Content");
}

public static String getName(){
return "No Name";
}
}

上述EventHandler类被@Mod.EventBusSubscriber注解所标记,其内部所有被@SubscribeEvent注解所标记的静态方法都会被视为事件处理器。

注意:上面的getName方法没有被@SubscribeEvent注解标记,所以其不会被注册为事件处理器。@Mod.EventBusSubscriber的第二个bus参数,是指定该类中的事件处理器监听的是哪个总线上的事件

上面的事件处理器处理的是构建创造模式物品栏事件:该事件在玩家处于创造模式且首次打开创造模式物品栏时被触发,对于每个创造模式物品栏的标签页都会调用一次,我们在这个事件中仅仅是打印了一句日志。这个事件有些特殊,可以看到我们在@Mod.EventBusSubscriber注解的第二个bus参数中指定了事件总线为mod总线,这是因为这个事件只在mod总线派发,如果我们不传入bus参数,那么默认是在forge总线上进行监听,这会导致游戏无法正常启动。

举这个事件的例子是为了让大家明白,在进行事件的监听处理之前,一定要搞清楚自己要处理的事件是在哪个总线上派发的,不能想当然地认为。

通过事件处理器类的实例来注册事件处理器

上面的方法是将事件处理器写成静态方法,而下面要介绍的方法就是将事件处理器写成成员方法。基本格式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class EventHandler{
@SubscribeEvent
public void onServerStarting(ServerStartingEvent event){
// Do something when the server starts
LOGGER.info("HELLO from server starting");
}

@SubscribeEvent
public static void onLivingEntityJump(LivingEvent.LivingJumpEvent event){
LivingEntity entity = event.getEntity();
if(!entity.level().isClientSide()){
entity.heal(1);
}
}
}

写完上面的代码后,还没完,还需要在你的mod主类中去注册这些事件处理器,以让Minecraft知晓这些事件处理器的存在:

1
2
3
4
5
6
7
8
9
10
11
@Mod(YourMod.MODID)
public class YourMod{
public static String MODID = "your_modid";

public YourMod(IEventBus event){
// 在这个创建你的事件处理器类的实例即可, 此处将事件处理器绑定到Forge总线上
NeoForge.EVENT_BUS.register(new EventHandler());
// 如果要注册在mod总线上的事件处理,需要用到你的mod主类的构造函数参数
event.register(new ModEventHandler());
}
}

同样的,在EventHandler类中,没有被@SubscribeEvent注解标记的方法不会被视为事件处理器

注意:要特别小心,当类中没有任何@SubscribeEvent注解的方法时,千万不要给这个类加上@Mod.EventBusSubscriber注解,并且将该类的实例通过NeoForge.EVENT_BUS.register方法进行注册,否则会导致编译失败

上面两种方式在功能上都是一样的,没有任何区别,但是我更偏向于使用第一种方式,因为不用每次编写了一个新的事件处理类后都在mod主类中进行注册,一来简化了事件处理器编写的流程,二来还能让mod主类的代码更加简洁

客户端/服务端

Minecraft采用的是C/S架构设计,即客户端/服务端架构,之所以这样设计是为了多人游戏考虑。其实,当你在本地游玩单人游戏时,Minecraft会在你的本地创建一个服务端,负责整个游戏的逻辑,而客户端负责和用户交互并且渲染服务端发送过来的数据。而当你游玩多人游戏时,服务端就转移到了一个远程服务器上,你的本地就只运行一个客户端。

我们要搞清楚如下概念:

物理客户端、物理服务端、逻辑客户端、逻辑服务端

(是不是已经晕了

其实NeoForge的官方文档对这四个概念解释得很清楚,只是没有中文翻译,所以我在此处就直接翻译一下官方对这四个概念的解释:

以下内容译自NeoForge Document>Sides

物理端

当你打开你的Minecraft启动器,安装并启动游戏后,你就开启了一个物理客户端。“物理”这个词用在此处,表示的含义是“这是一个运行在你机器上的实际的程序”。物理客户端这个名字意味着它有客户端所需要的所有代码,例如渲染相关的一些东西,能够在开发者有需要时随时被调用。相比之下,物理服务端,也被称作专用服务器(开过服务器的朋友不会陌生,就是你运行的一个无GUI的服务器JAR包)。物理服务端仅仅有着最微量的GUI元素,它缺少所有仅在客户端存在的功能。注意,这意味着许多客户端存在的类对于物理服务端来说是不存在的。如果在物理服务端的代码中尝试调用客户端的类或方法,会导致物理服务端崩溃,所以我们应当避免发生这一点。

逻辑端

逻辑端主要关注点在于Minecraft内部的程序设计结构。逻辑服务端就是游戏逻辑运行的地方,像游戏的时间、天气、实体的tick、实体的生成等等,这些全部都是运行在服务端的。各种各样的数据,例如物品栏的内容,也是属于服务端管辖的范畴。另一方面,逻辑客户端负责显示一切可以显示给玩家的数据。Minecraft将所有客户端相关的代码都隔离在一个单独的包中,名为net.minecraft.client,并且在运行时,客户端也是在一个单独的名为Render Thread线程中运行(仔细观察过Minecraft启动日志的朋友应该对这个不陌生),除了该包中的代码外,客户端和服务端享有相同的代码。

这下明白为什么区分客户端和服务端是如此重要了吧,这是你的mod正常运行的基础,在编写mod的时候,我们常常通过下面的方式来确定我们当前处于哪个端:

1
2
3
4
5
6
if(level.isClientSide()){
// 客户端代码
}
else{
// 服务端代码
}

上面的level变量是一个Level类型的对象

isClientSide()方法返回true时,说明我们处于逻辑客户端,否则我们处于逻辑服务端,需要注意的是,这个方法在专用服务器上始终返回false

本篇教程就此结束吧,下一篇我们就会开始为游戏添加第一个自定义物品啦!