用户:MashKJo/1.12.2模组开发教程/4.Forge事件系统
事件系统,是Forge的一大特色——没错,Minecraft原版并没有事件这一概念。
在上一节,我们已经看到了事件系统的应用——就是那三个被@EventHandler
注解的监听器方法。读者可能已经注意到了:这三个方法的形参都是唯一的,实际上,这是必须的,且它们各自的形参类型就代表了监听器监听的事件类型。
FMLPreInitializationEvent、FMLInitializationEvent和FMLPostInitializationEvent这些事件类型,都和FML加载Forge Mod的行为密切相关,它们通常被归为一类,被称为Forge事件;而事件还有另一类:就是玩家在实际进行Minecraft游戏时,发挥作用的事件,如:实体受到伤害时发布的事件,玩家钓鱼钓到物品时发布的事件,等等,通常被称为Minecraft事件。Forge事件不能胡乱监听,在绝大多数情况下都是要放在Mod主类里监听才行。而本节主要讲述Minecraft事件的应用。
事件系统存在的意义
作为Modder,肯定有修改原版的游戏逻辑的需求,但是原版的很多东西都是写死的,如果不借助黑魔法之类的东西,根本没法直接修改原版的源码。这里的黑魔法指的是什么?如果是高级一点的,那就用Mixin——一种能让Modder在Minecraft源码中插入自己的代码的工具,被Fabric尤为推崇;如果再原始一点,那就是手搓ASM……至于反射,不提也罢。
上面提到的这些黑魔法,比较难用,而且很容易把游戏搞崩。于是Forge出手了,通过监听Minecraft事件,Modder们可以轻松地改变原版的很多游戏逻辑。原理是什么?其实就是Forge帮Modder们把黑魔法的步骤做完了,它在Minecraft原版代码中插入了很多代码,一般流程是:在Minecraft相关游戏逻辑执行前后,Forge发布某个特定类型的Minecraft事件,再根据这个事件的监听情况决定原版逻辑到底会不会被执行,如果不会,到底要怎么改变。很明显,Forge帮模组开发者们统一执行黑魔法步骤,而非让模组开发者们各自使用黑魔法,大大提高了Forge Mod之间的兼容性,保证了良好的跨Mod交互。这就是Minecraft事件存在的意义:让Modder们能轻松干涉原版机制,且同时保证Mod之间良好的交互。
至于Forge事件?这个也有保证良好的跨Mod交互的功能,此外还有为Forge的一些机制服务的功能,这些用的很少,因此不是本节的重点内容。
事件监听方法的格式
对于Forge事件的监听方法格式,上一节已有示例。
而对于Minecraft事件的监听方法,有以下几点要求:
- 访问修饰符必须为public
- 返回值类型必须为void
- 形参必须唯一,且形参类型必须为Event类的子类——Event类实际上是所有Minecraft事件的共同父类
- 必须打上@SubscribeEvent注解
比如,举个例子:
@SubscribeEvent public void exampleEventHandler(ExampleEvent event) { ... }
通过这个event,你通常会有一堆getter和setter能用,由此你就能干涉原版的机制。
另外注意到,这个方法是非静态的,但其实,是静态的也无妨,只是注册的时候会略有点小区别,读者往下看即可。
Minecraft事件的注册、取消和优先级
事件总线
这是一个解释起来非常抽象的概念,读者可以简单地把它理解为一个“中转站”——所有事件被发布后,会被传到事件总线中处理,而事件总线就会尝试去获取所有事件监听方法,并产生实际效果。
Forge内建了三条事件总线(EventBus):一般事件总线(EVENT_BUS)、矿物生成总线(ORE_GEN_BUS)和地形生成总线(TERRAIN_GEN_BUS),它们在net.minecraftforge.common.MinecraftForge
类中以静态字段的形式存在。看看它们的名字,你就知道它们各自的用途都是什么。你当然也可以new一个自己的事件总线,但是没必要。
事件的注册
很简单,用EventBus#register
这一方法就好了。注意它要求传入一个Object——这有点让人摸不着头脑了,不是吗?
事情是这样的:传入什么东西,取决于你的事件监听方法是不是静态的。如果是静态的,那么传入该方法所在的类对应的Class<?>对象;如果是非静态的,则直接new一个该类的对象,传入进去。
不过,对于static的情况,实际上,还有一种便捷的注册方法:直接给监听方法所在的类打上@Mod.EventBusSubscriber
这个注解,这个注解可以传入你的modid,以及应该发挥作用的物理端。
实例
src/main/java/net/tutorial_mod/event/EventHandler.java:
package net.tutorial_mod.event; import net.minecraft.entity.player.EntityPlayer; import net.minecraftforge.event.entity.EntityJoinWorldEvent; import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; import net.tutorial_mod.TutorialMod; @Mod.EventBusSubscriber(modid = TutorialMod.MODID) public class EventHandler { @SubscribeEvent public static void testEventHandler(EntityJoinWorldEvent event){ if(event.getEntity() instanceof EntityPlayer) System.out.println("My event handler works!"); } }
这里我们新建了一个EventHandler类来存放事件监听器。这里我们监听了EntityJoinWorldEvent——顾名思义,实体在世界上生成时,这个事件会被发布。这个事件有着若干getter,我们通过getEntity这一getter获取了生成在世界中的实体对象,并通过instanceof运算符判断其是否为玩家——没错,玩家加入世界的实质是生成了EntityPlayer对象(注意这里笔者的用词,笔者并没有说“生成了一个EntityPlayer对象”,想想看这是为什么)。然后有鉴于这个方法是static的,我们用了自动注册的注解。
现在运行游戏客户端,随便进入一个存档,你应该能在命令行中看到输出的字符串了。但是注意:这个字符串输出了两次,这实际上是因为两个逻辑端各调用了一次这个监听方法。那么,我们有什么办法能判断当前所处的逻辑端呢?这个会在后面讲到,读者现在留个心眼就行。
如何知道自己要用什么事件?
所有Minecraft事件都是Event类的子类,因此理论上你只需要去看看Event的继承树就好。但实际上这么做并不妥,因为Forge的事件类型非常多,你这么搞,效率低下且不说,而且Forge的有些事件的名字起得并不好,你不一定看到名字就知道某个事件是在什么时候发布的、是用来干什么的。因此,最好的方法是:先思考你要干涉什么原版机制,再找到原版相关的类,看看其中有没有Forge插入进去的代码,如果有,看看Forge在这里面发布了什么事件即可。另外说一句,Forge插入原版类的代码中,Forge的类一般都会写全包名而非在源文件开头import,因此会显得很突兀;且Forge的代码一般都有详尽的注释。所以,有没有Forge插进去的代码,一看即知。
那么,如果真的找不到能用的事件,怎么办?尽管Forge的事件非常多,有的甚至做到了仅仅针对某个单一的游戏元素(如RenderItemInFrameEvent,这个事件影响物品在展示框中的渲染情况),但任何API都做不到完全满足使用者的需求。Forge官方早在多年前就停止了对1.12 Forge的维护,所以指望着去Pull Request是不行了。这个时候,你就只能考虑使用一些黑魔法了。
事件的优先级
我们可以为事件监听方法指定被调用的优先级,因为@SubscribeEvent注解中有一个参数priority,类型为EventPriority,是个枚举类,有五个实例:HIGHEST、HIGH、NORMAL、LOW、LOWEST。各自的含义应该不需要笔者多说。priority这一参数默认为NORMAL。
@Cancelable
这个注解用于修饰一个事件类:该事件可被取消。被取消的事件对应的其他尚未被调用的事件监听方法,则不会被调用,所以这个最好搭配着EventPriority来操作。
另外,@SubscribeEvent还有一个参数:boolean receiveCanceled,默认为false,设定为true则又可以让被取消的事件继续发挥作用。
@HasResult
这个注解同样用于修饰一个事件类,表明:该事件有“结果”——没错,事件监听方法一定为void,但是事件本身可以有结果——Event.Result这个枚举类中的三个实例:ALLOW、DEFAULT和DENY。它们的含义随着具体事件的类型而变化。这个说实话,不太常用,可以直接忽略。
自定义Minecraft事件
说实话,这个在几乎所有情况下都没必要。既然你要自定义Minecraft事件了,那显然是给你自己的游戏元素用的——但是你直接去对应的类中写相应的实现代码不就行了?何必多此一举搞个事件?就算你想让你的模组和其他模组的联动更容易,你也大可暴露一些公开方法即可。
然而,还是说说如何操作吧。
首先你需要根据你的事件发挥的具体作用来判断你的事件类到底该继承什么已有的事件类——直接继承Event类是非常非常罕见的。然后再思考:你的事件都需要有哪些getter和setter?你的事件类的父类又有哪些getter和setter?理清楚你的事件类的构造方法所需要的形参列表。
然后就要到你想发布事件的方法里去new一个你的事件类型了,然后再发布事件:EventBus#post
这个方法用于发布某个事件。这个方法是有boolean返回值的,返回true代表事件被取消。所以你一般该这么写:if(!MinecraftForge.EVENT_BUS.post(new MyCustomEvent(...)))
。这个if语句后的代码块中,写你期望的默认实现。它的意思即:事件若未被取消,则按照默认实现处理游戏逻辑,同时执行事件监听方法中的追加逻辑。很明显,如果取消了该事件,则可以彻底接管这里的游戏逻辑。
实际上,Forge发布事件的原理就是这样的。但Forge还把发布事件的过程进行了一些包装:对于可取消的事件,if语句中调用的并非post方法,而是位于ForgeHooks和ForgeHooksClient这两个类中的静态方法。这些静态方法先对传入的参数进行了一定的处理,再return post方法的返回值。对于不可取消的事件,post方法一定返回false,那就不会用if了,直接post,相关的静态工具方法的返回值类型也是void而非boolean了。另外有时候ForgeHooks和ForgeHooksClient中的静态方法,还会再把post方法的调用代理给ForgeEventFactory中的静态方法。
另外,很多事件都有子类型:Pre和Post(一般是以静态内部类的形式出现在其父类中)。它们的发布时机通常是这样:Pre发布在默认的游戏逻辑执行前,而Post发布在这之后。一般的规律是:Pre可取消,而Post不可取消——毕竟Post发布的时候,默认的游戏逻辑都执行完了,取消了也没用了,因此就干脆设定为不可取消。所以一般来说,如果我们监听事件只是为了给原版的某些机制附加额外行为,那么监听Pre或Post都没区别;但如果想彻底覆盖原版的游戏逻辑,那么监听Pre并取消是最好的:Event#setCanceled
。