基於FML的MinecraftMod製作教程(1) - Forge的事件系統

"That,good sir,is but a phantasy of yours."
-Arthur Dimmesdale,The Scarlet Letter

在等東方心綺樓的試玩版發布前有些無聊,因此就寫一寫Forge的事件系統吧.

事件(Event)系統是Forge在4.0版本後引入的,用最通俗的話解釋,事件就是一件事發生後,就會觸發與它相關聯的事.比如一個玩家不幸掉進岩漿里受到傷害,就會引發一個事件(之後我們會了解到這個事件是LivingHurtEvent事件),Forge的系統會將這個事件派發給相關聯的方法(Method),那些方法來決定是否處理,如何處理.
很不幸這僅僅只是通俗的解釋,實際實現起來要比這個困難一些,首先Java不原生支持事件,它採用的替代方案(基於監聽者模式,使用監聽器和匿名類)相當令人噁心,因此Forge設計了一套自己的事件系統(基於發布/訂閱模式).Forge的事件總線(EventBus)是事件系統的處理中樞,任何人都可以發布事件,任何人也都可以訂閱事件並在事件被發布時接收到它.如果有複數個訂閱者訂閱了同一事件,那麼系統會將事件按優先級讓訂閱者們依次傳閱,事件在被傳閱完畢後會重新交還給系統並返還給發布者.遊戲中支持的事件類型是被預先編程好的,當然開發者可以自己定製事件.

訂閱一個事件

事件的訂閱是通過Annotation來實現,任何一個被添加了@SubscribeEvent的方法都會被視為事件訂閱者,但要想實際起作用必須滿足這三個條件:
①:方法有且只有一個參數,並且此參數的類型必須是Event類或其派生類.
②:方法的訪問級是public,不能是靜態(static)
③:方法必須屬於一個非靜態的類.

(注:這裡所說的Event是指net.minecraftforge.event包下的Event類,而不是cpw.mods.fml.common.event包下的FMLEvent)

首先先添加一個處理事件的方法(我這裡添加到了Mod主類當中),比如這裡我想在訂閱"生物下落"(一個生物從高處摔到地上時觸發),就讓它的參數是LivingFallEvent.然後為它加上@SubscribeEvent

@SubscribeEvent
	public void test1(LivingFallEvent event)
	{
		if(event.entityLiving instanceof EntityPlayer)
		{
			EntityPlayer entityPlayer = (EntityPlayer)event.entityLiving;
			entityPlayer.addChatMessage(new ChatComponentText("Falling Star! You fell "+event.distance+ " meters.That's cool, man!"));
		}
	}

然後我們要通過MinecraftForge.EVENT_BUS.register來註冊方法所在的那個類,通常來說我們是在Mod載入時進行註冊,如果你將處理事件的方法單獨寫在一個類中(假設叫做EventContainer),那麼就在Mod主類的Init方法中添加
MinecraftForge.EVENT_BUS.register(new EventContainer());
如果你直接把處理事件的方法寫在了Mod主類中,那就直接在Init方法中添加
MinecraftForge.EVENT_BUS.register(this);
(順便一提,EVENT_BUS也提供了一個相應的方法來進行反註冊,也就是將已註冊的實例解除掉)

E1-1
注:這些圖是我在舊版時截的,因此sendChatToPlayer和新版有差別...

之後進入遊戲測試一下(注意要在生存/H模式下,創造模式玩家觸發不了LivingFallEvent事件喲...)

e1-2
到了遊戲中確實有效...可是這坑爹玩意卻一口氣來了兩個啊!
這是因為新版Minecraft的單人模式採用了本地服務器模擬的方式,雖然你在運行着單人模式,但實質是你在本機運行着一個服務器,然後你的客戶端連接着你自己的本機服務器.然而客戶端和服務器都個會有一個玩家實體(EntityPlayer的派生類),客戶端的是net.minecraft.client.entity.EntityClientPlayerMP,服務器端的是net.minecraft.entity.player.EntityPlayerMP.兩者都繼承自EntityPlayer,因此我們在驗證event.entityLiving instanceof EntityPlayer會返回true.這時我們就需要討論如何取捨了.

執行端的選擇,事件的監聽位置,事件的過濾

我們先依次說起這三件事,首先是執行端的選擇,我們是在服務器端對事件進行響應,還是在客戶端?通常來說,我們認為凡是需要執行實際操作的東西都要放在服務器端,畢竟客戶端和服務器端存在延遲(對於更糟的情況,客戶端還會存在作弊插件),當玩家跳到地上時,他可能會認為自己完成了一次"下落",它的客戶端也是這樣認為,因為EntityClientPlayerMP這個實體確確實實落地了,並在客戶端引發了一個LivingFallEvent事件.然而由於延遲的存在(某些服務器能存在10秒的延遲),服務器端認為EntityPlayerMP依然停留在遠處的一個平地,並沒有進行下落,由此便產生了分歧,究竟玩家是否從高處落下來了?很不幸在這裡服務器是大佬,它認為既然EntityPlayerMP沒有下落,那玩家就是沒有下落,不管客戶端再怎麼鬧騰也沒有作用,如果客戶端是個圖形效果增強Mod之類的不影響實際數據的Mod,也許問題不大,如果是會對遊戲產生實際影響的Mod,只是神知道接下來會發生什麼可怕的事情,有可能是數據不同步造成的斷線.不過假如玩家足夠幸運,在更糟糕的事情發生之前讓自己的移動數據發送到了服務器那,讓服務器做出及時更新的話,或許結果會好一點,但我們禁不起這樣的風險,玩家會認為是我們的Mod有問題,而服務器管理者會考慮卸載掉我們的Mod,畢竟與其花高價租個更好的服務器,管理者更傾向於點點鼠標刪掉一個Mod,然後隨便敷衍玩家幾句了事.在這裡,我選擇在服務器端處理這個事件.對於如何控制執行端,一個是通過事件的過濾,一個是通過@SideOnly,但我不建議使用@SideOnly,除非是必要,否則最好不要考慮它.
對於事件的監聽位置,我建議在一個統一的類內執行,但也並非沒有特例,設想這樣的一個情景,我們做了一把很NB的劍,被它砍中的實體會立刻飛到高度255的地方然後信仰之墜,而且我們為了秀技術刻意不使用onLeftClickEntity方法,而使用一個訂閱LivingHurtEvent事件的方法,那麼我們該怎麼做?我們可以將事件放在Mod主類里,也可以專門建立一個類來監聽,但我們也可以把它直接放在那把劍的物品類里,並在"很NB"的劍的類的構造函數(希望你沒看暈)內註冊它自己.別忘了Minecraft的物品是採用類似於享元模式的方式啊...但注意不要讓事件監聽被註冊的太多,否則會對性能產生巨大的負面影響.
最後我們到了事件的過濾這個問題了,Forge的事件系統的發布方式需要稍微解釋一下,Forge的所有事件(再次強調,不是FML的事件)都繼承自Event類,如果你訂閱了一個事件(類),那麼任何該類及其派生類都會在發布時發送給你.比如LivingFallEvent就是繼承自EntityEvent,那麼如果你訂閱了EntityEvent的話,不光是EntityEvent事件發布時你會收到一份,LivingFallEvent事件發布時也會發給你一份,因為LivingFallEvent是EntityEvent的派生類."既然你連大事都管了,那麼小事你也得管",這或許和現實以及大多數人的認識相反,但這確實是Forge的事件系統的實際寫照.因此事件的過濾就分為兩部分,首先是事件類型的過濾,如果你真的訂閱EntityEvent事件的話我建議你一定要三思,因為LivingUpdateEvent這個事件是每一個實體在每一個Tick(幀)都要執行一次,你最好把它過濾掉,它的過濾判斷表達式是"傳來的事件 instanceof LivingUpdateEvent == false".第二部分的過濾是事件源的過濾,就拿我們實際遇到的情況舉例,EntityClientPlayerMP和EntityPlayerMP都會在同一時間引發相同的事件,既然我選擇在服務器端執行更新,那麼我就過濾掉只存在於客戶端的EntityClientPlayerMP,保留EntityPlayerMP.這個的過濾方法就有很多,對事件的參數進行判斷就行了.
另外再提出一個忠告:訂閱Event事件之前請三思.

小小修改一下代碼,將

event.entityLiving instanceof EntityPlayer

改為

event.entityLiving instanceof EntityPlayerMP

(至於下面的"(EntityPlayer)event.entityLiving"是否修改,在這裡是隨便的,畢竟我只是想過濾一下事件源而已...)

再次測試,這一次我們成功了.(教練我懶得截圖了...自行腦補成功的樣子)
(說的輕巧,事實上作者為了研究這個問題燒了一個晚上的時間...)

事件的優先權

有些時候,會有複數個訂閱者同時監聽着一個事件,有些情況下他們是互相競爭的,因此就有了優先權的設定,優先權分為五級:最高,高,普通(這個是默認的),低,最低.事實上只有前三個才會被開發者考慮,SB才會讓自己的事件排到最低級...對吧= = 所以必要時就有些公德心吧,別把無聊的東西排在高優先級處.
安排優先級的方法是修改@SubscribeEvent的priority屬性.比如:

@SubscribeEvent(priority = EventPriority.HIGH)

這個訂閱的優先級是High,它只會和同優先級的訂閱者競爭,對於比它低的訂閱者,它佔有絕對的優先權.我們這就來試試它的效果,將我們之前的方法改為(即刪掉整個test1然後輸入下面的代碼):

@SubscribeEvent(priority = EventPriority.HIGH)
 public void test2a(LivingFallEvent event)
 {
 if(event.entityLiving instanceof EntityPlayerMP)
 {
 EntityPlayer entityPlayer = (EntityPlayer)event.entityLiving;
 entityPlayer.addChatMessage(new ChatComponentText(
 "Falling Star! You fell "+event.distance+ " meters. That's cool, man! I will protect you from the falling damage!"));
 event.distance = 0.0f;
 }
 }

 @SubscribeEvent(priority = EventPriority.LOW)
 public void test2b(LivingFallEvent event)
 {
 if(event.entityLiving instanceof EntityPlayerMP)
 {
 EntityPlayer entityPlayer = (EntityPlayer)event.entityLiving;
 entityPlayer.addChatMessage(new ChatComponentText(
 "Falling fool! You had fallen... Damn it, WTF "+event.distance+ " meters? Are you fooling me?"));
 }
 }

再次進行測試,這次無論我們從多高的地方跳下去,都不會受傷(雖然會發出pia的聲音),因為test2a方法先截獲了跌落事件,並篡改了結果,因此它即糊弄了test2b方法,又欺騙了Minecraft的系統.使得玩家免遭傷筋斷骨之苦.
e1-5

事件的結果

有時我們我們處理完一個事件後,需要告訴後來人我們已經處理掉它了,還有些情況下,這個事件最終會傳回Minecraft內核,供它就事件的結果進行決策,因此我們就有了Result這個東西.
Result是一個Enum(枚舉)類型.我們通過setResult和getResult來設置或讀取.Result有三個值:ALLOW(允許),DENY(否決)和DEFAULT(默認),然而,這玩意耍起來有風險.
首先,有些事件的Result是供你隨便耍的,有些則會影響到遊戲的運行,前者我稱之為無Result事件,後者我稱其為有Result事件,判斷它的辦法是調用hasResult方法,如果它返回true就代表它有Result,那麼就請仔細閱讀這個事件的JavaDoc.比如玩家拿着水桶對着物體(不一定是實體)按下鼠標右鍵時引發的FillBucketEvent事件就是有Result的事件,如果在最終傳回遊戲內核時它的Result的結果是ALLOW的話,那麼就會強制視為一次成功的操作.對於無Result的事件,它僅僅只是"Result這個參數對它沒有意義",而不代表我們用不了Result.LivingFallEvent就是無Result事件,但我們接下來就用它展示一下Result的使用.
(另外,由於Forge製作組的烏龍,不要將Result和某些事件里的result參數搞混,前者才是我們現在所說的Result,後者只是代表着傳來事件的某一參數.)

在test2a中加入:

event.setResult(Result.DENY);

在test2b中加入:

entityPlayer.addChatMessage(new ChatComponentText(
 "I understood... She made the event " + event.getResult().toString() + "."));

e1-4

之後進入遊戲測試一下.

E1-2

事件的取消

有時在我們執行完一個事件的操作後,希望能取消掉這次事件,讓後面的訂閱者無法處理它,同時也讓遊戲內核認為這個事件沒有發生.對於任何具有Cancelable(可取消)屬性的事件來說,我們都可以實現這一目標.
要判斷一個事件是否是可取消的,就調用它的isCancelable方法,如果返回的是true就代表它是可取消的,此外你也可以通過在IDE中直接觀察那個事件是否具有@Cancelable注釋來判斷它能否被取消.
e1-6
圖:LivingFallEvent具有@Cancelable注釋,因此它是可取消的.

設置一個事件被取消的辦法是調用它的setCanceled方法,setCanceled的參數是一個bool,true代表此事件取消,被取消的事件在調用isCanceled時會返回true,並且尚未接收它的訂閱者們默認將不再會接收它,Forge的事件系統也將向發布者返回一個true(默認是返回false,具體的返回機制我會在後面解釋),對於已取消的事件,你也可以通過setCanceled(false)來複活它.
(注意,對於不可取消的事件,千萬不要setCanceled(true),遊戲會立刻拋錯的)

現在,將我們的test2a方法中的

event.distance = 0.0f;

替換為

event.setCanceled(true);

然後保存,測試.

這一次你依然是不會受到跌落傷害,甚至都不會收到第二個訂閱者的信息,因為你將這個事件取消掉了.

那麼被取消的事件該如何復活呢?如果要讓別的訂閱者復活它的話,首先我們得讓那個訂閱者能接收它,默認情況下訂閱者不會再接收被取消的事件,但我們可以通過修改@SubscribeEvent的receiveCanceled屬性來讓它能接收被取消的事件.對於任何receiveCanceled = true的訂閱者來說,它都可以接收被取消的事件.

將我們的test2b替換為

@SubscribeEvent(priority = EventPriority.LOW,receiveCanceled = true)
 public void test2b(LivingFallEvent event)
 {
 if(event.entityLiving instanceof EntityPlayerMP)
 {
 EntityPlayer entityPlayer = (EntityPlayer)event.entityLiving;
 entityPlayer.addChatMessage(new ChatComponentText("F@CK U Maribel!I know you are there!"));
 event.setCanceled(false);
 }
 }

E-10

再次測試,這一次瑪艾露貝莉可保不了你了.
E-11

事件的發布

事件的發布不比發一顆衛星容易多少,首先你要掌握軌道事件學,應用事件學,量子事件學,理論事件學,高維度事件學,隙間事件學,虛數維度事件學,亞空間事件學,節點空間事件學,遷躍斷層事件學,翹曲事件學,子維度事件學,相對事件學,形而上事件學,混沌事件學,核事件學,高等事件學,代數事件學,幾何事件學,事件工程學,事件哲學,事件心理學.此外你還得了解十事件問題,最小事件問題,事件樹等算法.最後你還得祈禱Yukari在和Cthulhu對轟時不會從你這裡神隱走幾個即將發送的事件,當做彈幕發射出去.

幸運的是,這些事情Forge全幫你包辦了!所以發布一個事件的方法是很容易的!

開發者可以通過調用事件總線(EVENT_BUS)的post方法來發布一個事件,post方法會返回一個bool值,true代表這個事件被取消了,false代表它順利執行到了最後.建議的格式是這樣的.

初始化一個事件的實例
MinecraftForge.EVENT_BUS.post(事件)
判斷事件的結果

看到的時候被雷了一下對吧...我這裡破天荒地使用了偽代碼,因為我自己實在懶得編一個拋出事件的例子,因此我就以Minecraft的ItemBow(弓)的拉弓部分為例:

ArrowNockEvent event = new ArrowNockEvent(par3EntityPlayer, par1ItemStack);
MinecraftForge.EVENT_BUS.post(event);
if (event.isCanceled())
{
return event.result;
}

ArrowNockEvent是拉弓事件,它的參數分別是觸發玩家的實體和玩家所拿的弓.
MinecraftForge.EVENT_BUS.post(event)則是將剛剛初始化完畢的事件向訂閱者發送出去,事件總線會完成事件的發布工作(為你省了很多事呢!笑).並最終返還給你返回值.
之後就到了對事件的處理工作了,由面向對象的特性可知,event這個事件在被發布出去後已經被各位訂閱者們糟蹋了一通了.因此我們不必寫一個專門接收處理結果的代碼,直接對event操作就能獲取處理結果.
(小吐槽:其實官方的那段代碼可以被簡化的,post返回的就是isCanceled的結果)

自定義事件

現在開始才是高潮吧,Satori?
自定義事件的發布和固有事件的發布沒有差別,唯一問題在於如何創建自定義事件.
既然所有事件都是繼承自Event類,那我們也創建一個繼承自Event的類好了.

e1-9

並沒有太多需要注意的東西,一個是在構造函數中要調用基類的構造函數(super();),第二就是如果你不希望訂閱者修改你的事件中的參數的話,就將它們聲明為final.

此外我們可以設定它有無Result,能否被取消.
設定一個事件有Result,是在它的類上方加入@Event.HasResult
設定一個事件可以取消,是在它的類上方加入@Cancelable
e1-10

之後我寫了一個很無聊的代碼,它的作用是在玩家輸入KABOOM時讓自己周圍的生物全都遭遇一次爆炸.

	@SubscribeEvent
	public void letsrock(ServerChatEvent event)
	{
		if(event.message.equals("KABOOM"))
		{
			event.setCanceled(true);//截獲玩家的指令並不讓它顯示在屏幕上,用來模擬遊戲指令(Command)
			EntityPlayer player = event.player;
			EventHANDRU eventHANDRU = new EventHANDRU(player);//初始化一個事件
			MinecraftForge.EVENT_BUS.post(eventHANDRU);//發布它
			if(eventHANDRU.getResult() == Result.ALLOW)
			{
				//這個長的讓人髮指的東西是獲取玩家附近的生物
				List list = player.worldObj.getEntitiesWithinAABB(EntityLiving.class, AxisAlignedBB.getBoundingBox(player.posX-30D, player.posY-20D, player.posZ-30D, player.posX+30D, player.posY+20D, player.posZ+30D));
				//值得一提的是我這裡使用的是遍歷器,傳統的下標遍歷因為無法鎖定資源可能導致ConcurrentModificationException...
				for(Iterator iterator = list.iterator();iterator.hasNext();)
				{
					EntityLiving entity = (EntityLiving)iterator.next();
					if(entity.equals(player)) //別把自己也給炸了...
					{
						continue;
					}
					player.worldObj.createExplosion(player, entity.posX, entity.posY, entity.posZ, 4f, true);
				}
			}
		}
	}

	@SubscribeEvent
	public void goodbyeRenko(EventHANDRU event)
	{
event.entityPlayer.addChatMessage(new ChatComponentText("Have a nice day, Renko Usami."));//歡迎來到冥界,宇佐見蓮子.
		event.setResult(Result.ALLOW);
	}

進入遊戲後輸入KABOOM,然後看煙花吧...不過有些耐操的動物比如豬可能無法一發炸死.
E-12

結尾

"別這樣,紫,我們都是女孩子..."
-Reimu Hakurei,紫妹異聞錄

就這樣結束了嗎...也許是吧,Forge的事件系統就只有這麼點東西可說.然而如果你有興趣,你可以去研究更加高深的東西,例如事件的getListenerList是什麼東西?FML的事件系統怎麼使用?它和Forge的事件系統有什麼關係?學海無涯啊少年...願你在Forge之路上愈行愈遠吧,裝哉我大Forge!F@CK THE BUKKIT!

附錄1.OMG到底有幾條總線!?
事實上,Forge不只有Event_Bus這1個事件總線,Forge總共有3個事件總線,分別是TERRAIN_GEN_BUS(地形生成總線),ORE_GEN_BUS(礦物生成總線)和EVENT_BUS(我們敬愛的,事件總線).3個總線很好區分,分工也很明確,我就不詳細說明了...(群眾:我們去年買了個表)