基於FML的MinecraftMod製作教程(4) - 實體

上一章我們學習了Mod製作的基本要素,以及Block和Item的創建.

本章我們要進行:
對實體進行操縱
創建新的實體
為物品添加額外的功能

什麼是Entity

"一切事物都依存於Entity,即是說,沒有做不出的東西."
--考驗東方眾的時刻到了,這句話的原型出自哪?

知識點:什麼是實體(Entity)
///////////////////////////////////////////////////////////////////////////////////////////////////////////
Entity是續Block,Item後第三個MC世界的重要組成部分,在遊戲中,地上跑的動物,洞穴里潛行的炸彈魔,怪物死後掉落的經驗球,水上漂浮的舟,它們都是不同的Entity.
一個Entity除了具有XYZ坐標以外還具有一些特殊的參數,比如高度,寬度(某些摸不着的Entity沒有這些參數)生命值(無敵的Entity無需考慮)Yaw,Pitch,速度...
什麼是Yaw和Pitch?按照立體幾何的定義,在右手坐標中,Yaw是物體圍繞Y軸(即高度軸)旋轉的參數,在MC中他通常用來控制有形實體的模型角度.Pitch是物體圍繞X軸旋轉的參數,遊戲默認實體的正朝向是Z軸正方向,所以Pitch控制的是實體的面朝上和面朝下.
(如果你看過第一版教程的話,你會記得那時候我說過Pitch控制的是左傾和右傾,事實上這是錯誤的結論...那時候我誤以為實體的正朝向是X軸正方向.遊戲也不是通過Pitch來控制實體死掉時的側倒,而是通過渲染器(Render)來實現.)

然而我們很少直接使用Entity,我們會根據需要從Entity派生出新的類,比如EntityBoat(已放置的船)就是從Entity中派生出來的.
然而說到Entity就不得不提NBT.
///////////////////////////////////////////////////////////////////////////////////////////////////////////

知識點:NBT初解
///////////////////////////////////////////////////////////////////////////////////////////////////////////
NBT是MC的數據儲存格式,它是Notch設計的一種樹形數據儲存格式(類似XML).它與地圖存檔密不可分,在存檔時,NBT會將Entity的數據寫入NBT文件,讀檔時NBT會讀取NBT文件並將數據還原給實體.
NBT是個很複雜的東西,幸運的是,在這一部分中,我們還無需操作NBT.
///////////////////////////////////////////////////////////////////////////////////////////////////////////

對實體的簡單操作

接下來我們創建一個被稱為DiracWand的物品,先讓它實現個最簡單的功能:擊飛實體

首先新建一個叫ItemDiracWand的物品,並讓它繼承Item類.

A4-1

然後要為物品類添加相關的方法,Minecraft採用類似事件驅動的方式,當玩家使用物品與外界互動時,會引發相應的方法,查閱Plus篇教程的Item章節部分可知當玩家用物品擊中NPC時引發的是hitEntity方法.於是為類添加上hitEntity方法的重寫.
(注:Eclipse可以通過右鍵-Source-Override/Implement Methods,或者Alt+Shift+S - Ctrl+V來打開重寫/實現方法窗口)

@Override
public boolean hitEntity(ItemStack par1ItemStack,
	EntityLivingBase par2EntityLivingBase,
	EntityLivingBase par3EntityLivingBase) {
	return true;
}

A4-2
hitEntity的返回值代表是否算作一次成功的攻擊,目前僅用來統計玩家數據.

知識點:Minecraft的坐標系
///////////////////////////////////////////////////////////////////////////////////////////////////////////
本來這個東西在第一版教程是沒有的,因為當時作為高二黨的我認為數學與坐標系是不用說就明白的,但現在高考後我發現數學這東西不用就立刻忘光了...所以我補上了坐標系這一節.
現在Minecraft採用標準的(三維)右手坐標系,即向東為X軸正方向,向南為Z軸正方向,向上為Y軸正方向.
在空間計算上,並沒有什麼太多需要注意的,只要記住Y軸代表高度就好了.

但在角度計算上比較麻煩,首先,Java的Math類使用的是笛卡爾直角坐標系(簡稱直角坐標系),即X軸向右為正,Y軸向上為正.第二,實體的rotationYaw字段表示實體所朝向的水平角度,它以右手坐標系的Z軸正方向為0度,沿Y軸順時針轉動為正角度轉動.而我們知道在直角坐標系中角度是以X軸正方向為0度,逆時針為正角度轉動.因此如果要在直角坐標系中使用rotationYaw的話很可能需要換算.
然後,實體的rotationPitch表示實體朝向的高度角度,即表示實體面朝上還是面朝下.常識認為Pitch為正表示的是面朝上,然而在遊戲中Pitch為負數才表示面朝上,正數表示面朝下.比如rotationPitch為-90.0時表示頭朝正上方仰望,視野與Y軸平行且同向,-45.0表示向斜向上45度仰視,45.0表示面朝斜向下45度在找金子,90.0表示在看自己的事業線(如果有的話).因此你在實際運算時可能需要變換符號.
rhcs
右手坐標系,紅色為正朝向.
ccs
笛卡爾坐標系

此外,rotationYaw還有個很要命的特性,通常我們認為使用弧度製表示角度時,取值範圍在[0,360]或[-180,180],但rotationYaw不會在遊戲中對角度進行約束,換句話說如果你現在的rotationYaw是90,那麼你原地向右轉一圈後rotationYaw會變為450,再向左轉兩圈rotationYaw會變為-270.
不過好在Minecraft的MathHelper類提供了wrapAngleTo180_float和wrapAngleTo180_double方法來將角度約束在[-180,180]的範圍內.這樣正好和直角坐標系中的角度範圍([-180,180]或[-π,π])相匹了.(順便一提,MathHelper類的sin和cos也是採用直角坐標系.)
///////////////////////////////////////////////////////////////////////////////////////////////////////////

之後要添加相關的代碼使玩家可以使用物品擊飛NPC,Entity的motionX,motionY,motionZ三個字段分別表示其在該方向上的分速度,我們可以用正交分解算出實體的分速度,至於擊飛的角度,由於玩家必須面朝NPC才能攻擊他們,因此可以假設飛出的方向就是玩家的朝向方向,但對於角度的計算,這裡面還有些學問.

知識點:左手坐標系的計算,以及與笛卡爾直角坐標系的換算
///////////////////////////////////////////////////////////////////////////////////////////////////////////
以前我一直不明白為什麼有人說左手系與右手系各有優缺點,今天在思考換算時才明白原來直角坐標系不能直接映射到左手系的XZ平面上,因為直角系的Y軸與左手系的Z軸方向是相反的...
rhvsc
為了方便計算,有時我們會將立體坐標系中的東西投影到平面坐標繫上計算,對於右手系(微軟的DirectX使用的就是右手系,壯哉!微軟!壯哉!DirectX!),我們可以像左下圖那樣直接按照"右手系X軸-平面系X軸,右手系Z軸-平面系Y"的映射關係來計算,但對於左手系(Minecraft採用的坐標系,哦對了OpenGL也使用的是左手系,不過你不用管它.)我們卻會發現很難直接將平面系映射到立體系上,因為映射完後平面系的Y軸和直角系的Z軸方向是相反的,更要命的是Minecraft中角度以順時針為正方向,笛卡爾坐標系卻是以逆時針為正方向,兩角的起始角度又不同,更進一步加大了計算難度.
對程序員而言擁有數學背景是再好不過的事,遺憾的是並非所有人都能做到,因此這裡給出個最簡單最土的方法:如果你想用直角系計算左手系中的XZ軸,就把角度加上90度,然後一切按照直角系中的方法計算.
這個方法的原理是利用錯誤來抵消錯誤,比如你要計算一條直線在X軸和Z軸上的投影,那麼就是:

float rad = ((angle + 90f)/ 180f) * 3.141593f;
float x = length * MathHelper.cos(rad);
float z = length * MathHelper.sin(rad);

其中angle為直線相對正方向的夾角度數,採用角度制,rad為換算為弧度制的結果.length為線長.很顯然在Minecraft的左手系中這樣計算是錯誤的,X軸肯定不能用Cos來計算,但這個土算法的微妙之處就在於用錯誤抵消了錯誤...總之,如果你想使用這個,就記得一切都要按照直角系中的方式來.

不過,我相信你肯定不會滿足於止步於此,既然我們已經了解左手系的秘♂密,那就直接在左手系中計算好了,如果你使用這種方法計算,那麼就記住Z軸使用Cos計算,X軸使用Sin計算並且結果要變負.
以此方法書寫,則上面的算法應寫成:

float rad = (angle/ 180f) * 3.141593f;
float x = length * -MathHelper.sin(rad);
float z = length * MathHelper.cos(rad);

兩種方法怎麼寫都無所謂,但我更傾向於第二種,剛開始看上去很難習慣它,但多用用的話就會習慣.
///////////////////////////////////////////////////////////////////////////////////////////////////////////

既然我們已經完全了解原理那麼便可以開始着手編寫代碼了,將DiracWand類中的hitEntity方法寫成:

@Override
public boolean hitEntity(ItemStack par1ItemStack,
	EntityLivingBase par2EntityLivingBase,
	EntityLivingBase par3EntityLivingBase) {
	par1ItemStack.damageItem(1, par3EntityLivingBase);
	if (par3EntityLivingBase.worldObj.isRemote) {
		return true;
	}
	float Angle = (par3EntityLivingBase.rotationYaw/ 180F) * 3.141593F;
	float x = 3f * -MathHelper.sin(Angle);
	float y = 1f;
	float z = 3f * MathHelper.cos(Angle);
	par2EntityLivingBase.setVelocity(x, y, z);
	return true;
}

A4-3

暫且先忽略那條對par3EntityLivingBase.worldObj.isRemote的判斷,下一節我們再解釋它,這段代碼的意義是:首先,它降低了物品的耐久(關於物品方法的參數的含義可以參見Plus篇的Item部分,par1ItemStack為當前物品的物品棧,par2EntityLivingBase是被攻擊的實體,par3EntityLivingBase是攻擊者).然後根據攻擊者的角度算出擊飛的方向,然後算出速度在各方向上的分量,並通過setVelocity來設置速度.
(其實你也可以通過直接修改實體的motionX等字段來直接修改速度.)

然後在Mod主類中添加相關的代碼.(不知道該添加什麼?咳咳,你真的讀完了第三篇了嗎...)

B4-1

B4-2

B4-3

以及物品圖標

diracwand

還有不要忘了語言文件和模型文件.

B4-4

B4-5

之後就開遊戲測試一下吧,在生存模式下用/give調出物品(在創造模式下直接刷物品也行,只不過就沒有物品損壞效果了)然後找只動物敲一下試試...
E4-2

實體的部署

相比簡單地修改實體的參數,大多數人更對創造一個實體感興趣,實體的創造其實是很簡單的呢,至少是對站着的人來說.

知識點:在遊戲中放置一個實體
///////////////////////////////////////////////////////////////////////////////////////////////////////////
World類的spawnEntityInWorld方法允許你在遊戲中放置一個實體,例如:

if(!world.isRemote)
{
EntityTNTPrimed entity = new EntityTNTPrimed(world, x, y, z, player);
entity.fuse = 80;
world.spawnEntityInWorld(entity);
}

這便是一個在遊戲中創建一個已點燃的TNT實體,world是它所屬的世界,xyz是坐標,player是TNT實體的放置者.但重點不是這些,而是那個world.isRemote.
///////////////////////////////////////////////////////////////////////////////////////////////////////////

知識點:代碼執行位置(客戶端or服務器),以及一些關於數據同步的淺談
///////////////////////////////////////////////////////////////////////////////////////////////////////////
這一個知識點比較長,事實上它應該被放在Extra篇中來講,網絡就是這麼操蛋的東西,"你要不談網絡,咱們還是好朋友"

從字面上講,isRemote是判斷一個遊戲世界是否是遠程的,即是否是在客戶端上,Remote引發人許多的遐想,從只敢遠觀不敢褻玩的花瓶遊戲Distant Worlds,到風雪交加的守矢神社下的一曲動人的Last Remote...艹,扯遠了.

我們暫且將isRemote=false的世界稱為服務器世界,將isRemote=true的稱為客戶端世界,顯然我們需要將運算內容放在服務器世界,將效果表達部分放在客戶端世界,但實際上要想判斷誰放在客戶端誰放在服務器並不容易,就拿上個章節的代碼舉例:

par1ItemStack.damageItem(1, par3EntityLivingBase);
if (par3EntityLivingBase.worldObj.isRemote)
{
return true;
}
......

par1ItemStack.damageItem(1, par3EntityLivingBase)被放到了客戶端/服務器判斷之前,也就是說它在無論哪個端都是可執行的,這顯然違背了我們的原則,因為damageItem的作用是降低物品的耐久值,顯而易見它是個邏輯運算,只應當在服務器端運行,但實際上它的"正確"用法就是在兩個端都執行...因為對玩家(客戶端)而言,物品被損壞是可以看得到的(物品欄中物品的耐久度條縮短),換而言之,對客戶端,damageItem讓玩家能看到自己的物品慢慢損壞,對服務器,damageItem讓服務器默默記下物品的耐久度損壞狀況,並適時告訴客戶端"您的話費餘額已用盡,快給我們塞錢吧".
那麼,如果我們只讓damageItem在服務器端運行,那是不是就成了像中國電信的■翼那樣只管停機不管通知的坑爹貨,只管銷毀玩家的物品,不告訴玩家物品耐久程度?實際測試一下你會發現玩家依然能看到耐久度慢慢下降...這是因為遊戲會定期進行一次"大"同步("大"只是相對的,並不一定真的是一次大規模的全部數據同步),將玩家手持物品的信息同步到各客戶端注1.那時玩家便會看到自己的物品耐久度下降了.
然而,也有些東西是強制要求必須在服務器進行,比如實體的創建,如果在客戶端執行的話,就會變出來一個無法正常運行的幽靈實體,正常的實體必須創建在服務器,然後由服務器向客戶端同步實體信息,你可能會想為何不像damageItem那樣雙方同時進行?那樣不是更能剋制延遲問題嗎?但這裡卻沒有任何可商量的餘地,實體的創建就是必須在服務器進行.注2
(其實要想解釋也能解釋的通,同一個實體在服務器和客戶端的EntityID(實體ID)一定是相同的,兩端同時進行無法保證ID相同或不出現衝突,因此只能由服務器統一進行實體ID管理和分配,然後同步給客戶端.)

廢了這麼多話,就總結一下我對代碼執行位置和數據同步的看法吧:
1.客觀要求最優先
就像實體的創建那樣,它就是為在服務器執行而設計的!因此就不要想着怎麼拿到客戶端了!
2.考慮硬件能力
這個其實可以和第一條合併,這條的意思是:"從硬件條件考慮,有些代碼確實無法在某些端執行",比如圖像渲染,你在服務器端執行圖象渲染的代碼?請允許我做一個悲傷的表情.
既然說這一條是"從硬件條件考慮",那麼上一條或許就可以稱為"從軟件條件考慮"了.上一條的不能執行是因為代碼編寫成那尿性,這一條的不能執行是因為服務器端沒有相關的類!Minecraft源代碼中任何名字形如xxx.client或xxx.client.ooo的包下的類在獨立服務器(minecraft_server.jar)中都是不存在的.
3.異步與同步
(注:這裡的"異步"並非是指通常意義上的異步(Async),而是一種比喻,與同步相對應,泛指服務器允許客戶端在短時間內與服務器不同步的行為,事實上,有一個專業的術語來描述這種行為:軌跡推測法(Dead Reckoning))
C/S模式的多人遊戲多允許客戶端與服務器間適當異步運行,比如Minecraft中實體的移動就並非絕對同步的,服務器會在實體移動時發送它的的移動方向和速度等信息,而精確的大規模同步則是每隔一段時間進行一次,在大規模同步之前,實體的移動在客戶端就是異步的,客戶端會根據服務器之前的信息來計算實體的移動,因此在網絡環境較差的情況下,玩家會看見實體走一段距離後突然又瞬移到另一個位置,這就是客戶端收到了實體移動的信息,但沒及時收到精確同步的信息.之前提到的damageItem也是如此,客戶端無需等待服務器的信息便可先異步降低物品耐久度,等收到服務器的同步信息後再確定物品準確耐久度,但倘若網絡環境差,就會出現物品用壞後過幾秒突然又恢復的情況.
說完異步再說同步,Minecraft中依賴同步的有實體的創建,玩家對實體的攻擊,實體的死亡等等...對於依賴同步的行為來說,在糟糕的網絡狀況下可能會出現玩家進行操作很久後才有響應的情況,比如打開箱子,高延遲時開箱子過很久才會出界面.
何時同步何時允許適當異步,除了客觀條件外,就要看開發者的取捨了...
4.特效
大多數人認為特效無需同步,事實上,無需同步的是特效的細節,特效的發生是需要同步的...你總不希望看到一個玩家喊"聽,是炸彈魔的聲音!",別的玩家嘲諷說"哪jb有聲,你丫耳拙了吧..."然後被突然冒出的Creeper炸死吧...再比如粒子特效,你需要同步特效的發生,即何時出現粒子,但不必精確地同步每個粒子(事實上,我想你即使想做也做不到...),不過,如果有需要還可以同步一下粒子的數量或密度,爭取給每個玩家提供相同的體驗.

說了這麼多,其實歸根結底就只有一句話:"憑經驗判斷"......如果是這樣那麼對新手來說這就是個無解的問題了?其實還有兩種辦法可以解決,第一種是:所有操作都先按兩端同時執行編寫(一些明顯不能的就不要這樣寫了...比如產生實體),單人遊戲下成功運行後再在多人遊戲下調試,找出那些不能正常運行的功能,再重新編寫.第二種是:所有操作都僅在服務器端執行,然後再找出不能正常運行的功能並進行修正.我比較推薦第一種,因為第二種中由於未能同步造成的bug可能與程序中的邏輯bug混雜在一起,難以除錯.
///////////////////////////////////////////////////////////////////////////////////////////////////////////

看了那麼一大段話,估計大家也都累了(寫的人也累呀...),那就糊弄個簡單的功能來展示實體的創建 - 讓DiracWand能右鍵發射TNT.

Minecraft中物品右鍵引發的方法是onItemRightClick方法,通過重寫它來實現按右鍵執行某功能.
(注:onItemUse與onItemRightClick的區別 - onItemUse只會在有效範圍內右鍵一個磚塊時引發,onItemRightClick在任何時候按右鍵時都會引發.其中,如果onItemUse可以觸發的話,它會在onItemRightClick之前觸發.onItemUse是否會阻止onItemRightClick的發生取決於onItemUse的返回值,EnumActionResult.SUCCESS會阻止onItemRightClick的發生,其餘值會允許其被觸發.)

@Override
public ActionResult<ItemStack> onItemRightClick(ItemStack par1ItemStack, World par2World,
		EntityPlayer par3EntityPlayer, EnumHand hand) {
	if (!par2World.isRemote) {
		EntityTNTPrimed entity = new EntityTNTPrimed(par2World, par3EntityPlayer.posX,
				par3EntityPlayer.posY + par3EntityPlayer.getEyeHeight(), par3EntityPlayer.posZ, par3EntityPlayer);// getEyeHeight方法是獲取物體的"眼高",即頭部到腳底的距離
		float angle = (par3EntityPlayer.rotationYaw / 180F) * 3.141593F; // 水平方向的角度
		float angle2 = (-par3EntityPlayer.rotationPitch / 180F) * 3.141593F; // 垂直方向的仰角
		final float speed = 2f; // TNT飛行速度 - 抱歉我賣了個萌
		entity.motionY = speed * MathHelper.sin(angle2); // 算出三個方向上的速度,為了方便閱讀我先計算的Y軸分速度
		entity.motionX = speed * MathHelper.cos(angle2) * -MathHelper.sin(angle); // 根據仰角算出速度在XZ平面上的投影,再正交分解投影
		entity.motionZ = speed * MathHelper.cos(angle2) * MathHelper.cos(angle);
		par2World.spawnEntityInWorld(entity); // 放置實體咯
	}
	return new ActionResult<ItemStack>(EnumActionResult.SUCCESS, par1ItemStack);
}

E4-6
之後進遊戲試試,按住右鍵然後就毀滅世界吧.你可以開一個獨立服務器然後自己在客戶端中聯進去測試,可以正常運行哦.掌握了這麼強力的物品(Item),你已經可以去ITEM組申請取代芙蘭達的職位了! (笑)

點此展開摺疊頁↓

番外:學擼都市愉快的每一日


教練啊...麥野不要我她說我射的距離太短不給力.
這個啊...改一改那個speed就行了哦,我將它單獨寫出來就是為了方便改速度的.
......
可是...速度改高了之後就出現了各種詭異Bug啊!
...what?

將速度改為10之後,我們遇到了各種詭異bug,比如TNT飛出去後突然消失了;TNT落地後突然穿到了另一個位置;在面朝特定角度時,發射的TNT會向其他方向飛去(和面朝方向有一點點偏差),但過1秒左右它就會回到正常位置上...
尼瑪這還玩個蛋啊...
不過程序員就是為解決問題而生的,對不?那我們就看看這幾個問題是怎麼回事吧.
TNT飛出去突然消失其實很好解釋,MC有個實體渲染範圍,超過一定範圍就不渲染了...(事實上還有個實體交互範圍,超過這個範圍的服務器就不會通報實體信息給客戶端了,不過TNT的交互範圍是160格,幾乎等同於玩家的最大視野,不會是它引發問題的.)原先我們速度不夠快飛不出這個範圍,現在速度變快就一下飛出去了...
那麼,關於TNT漂移和向別的方向飛又何解呢...
好吧,其實是這樣的,我們之前提到Minecraft允許異步執行,而每隔一段時間就會進行一次精確同步.除此之外,為了壓縮服務器到客戶端之間傳輸的信息的體積,MC將一個完整的360度分割為256份(即一個byte),每次傳輸角度數據時只傳輸一個大致的方向(換句話說傳輸時角度的最小單位不是1.0也不是0.1,而是1.406左右),客戶端收到的只是一個大致的方向,通常來說,在速度很低的情況下這不會產生什麼明顯的誤差,因為每次同步會重新校正實體的位置,但在速度很高時,未等服務器發來精確同步數據,實體在客戶端上便已按照有誤差的角度跑了很遠了,而且角度這東西...再小的誤差距離遠了也會變明顯,因此出現了TNT斜着飛一段然後又突然回到正確的位置了.至於TNT漂移移位之類的,我想也是相同的原因造成.

那就只好把速度降低一點了,5.0f的速度就已經沒有明顯的誤差和bug了.
......
可是教練啊...麥野她還是不要我她說我不能控制射的遠近.
丫的要求還挺高...那就改成蓄力發射好了.

Minecraft中物品可以有一種特殊的使用狀態:Using("持續動作"?隨便了...我懶得想個譯名),Using表示物體在持續使用中,比如劍的格擋,弓的拉箭,食物的食用等,說白了就是一切按住右鍵後會持續的動作都是Using,但要把它和連續瞬發動作分清,持續仍藥水和我們剛才連射TNT都屬於連續執行的瞬發動作,不屬於Using.
Item類中有幾個和Using有關的方法:
getItemUseAction 返回一個物品的使用動作,默認為無特殊動作(EnumAction.none)
getMaxItemUseDuration 返回一個物品的最大使用時間,超過此時間後會自動停止使用,默認是一動就射...(0)
onPlayerStoppedUsing 當一個物品使用完畢或停止使用時引發.
onEaten 當一個物品被吃下去後引發...好吧,大多數物品都吃不下去,對嗎?另外這和Using有什麼關係?
onUsingItemTick Forge新增的方法,當物品處於Using時每tick都會觸發一次.

這一次我們需要重寫(真正意義上的重寫)onItemRightClick和重寫(Override)getItemUseAction,getMaxItemUseDuration與onPlayerStoppedUsing.
將我們之前在onItemRightClick內的代碼刪去,寫為:

par3EntityPlayer.setItemInUse(par1ItemStack, this.getMaxItemUseDuration(par1ItemStack));
return par1ItemStack;

EntityPlayer的setItemInUse是設置玩家正在使用一個物品,第二個參數是最大使用時間.

然後再加上重寫(Override)的三個方法:

public EnumAction getItemUseAction(ItemStack par1ItemStack) {
return EnumAction.block; //讓使用動作為格擋
}

public void onPlayerStoppedUsing(ItemStack par1ItemStack, World par2World,
EntityPlayer par3EntityPlayer, int par4) {
if(!par2World.isRemote)
{
EntityTNTPrimed entity = new EntityTNTPrimed(par2World,
par3EntityPlayer.posX, par3EntityPlayer.posY + par3EntityPlayer.getEyeHeight(), par3EntityPlayer.posZ, par3EntityPlayer);
float angle = (par3EntityPlayer.rotationYaw/ 180F) * 3.141593F;
float angle2 = (-par3EntityPlayer.rotationPitch/ 180F) * 3.141593F;
int duration = this.getMaxItemUseDuration(par1ItemStack) - par4; //計算真正的使用持續時間
float speed = duration > 50 ? 5f : 0.5f+0.09f*duration;
entity.motionY = speed * MathHelper.sin(angle2);
entity.motionX = speed * MathHelper.cos(angle2) * -MathHelper.sin(angle);
entity.motionZ = speed * MathHelper.cos(angle2) * MathHelper.cos(angle);
par2World.spawnEntityInWorld(entity);
}
}

public int getMaxItemUseDuration(ItemStack par1ItemStack) {
return 100000; //最大使用時間為100000tick - 這個數多少都行,別爆了Int32的上限就好...
}

這段代碼沒有太多可解釋的,唯一需要注意的是onPlayerStoppedUsing的par4參數和onUsingItemTick的count參數都是剩餘可使用時間,即"最大使用時間 - 使用時間",因此我們需要自行算出使用時間來.
E4-7
之後進入遊戲測試一下.你的DiracWand已經變成了可以蓄力發射TNT的法杖了...


俗話說福利都在番外里,看不看由你哦.

設計一種新Entity

在舊教程中,這個是第四篇的第一節,而在新教程中卻剛剛到一半,這可真要人命啊...
(2013.8.3 因為明天要出去旅遊所以今晚把這一部分草草寫完然後發上來吧,沒有時間做示例只好純寫知識點了,時間略緊迫所以寫的比較凌亂)

知識點:實體的原理與創建
///////////////////////////////////////////////////////////////////////////////////////////////////////////
老實說寫到這時我一頭霧水,這個知識點的標題是我過去起的,但現在想想,這玩意有什麼原理可說呢...能說的都已經在前頭的"什麼是原理"中說了...反正還是扯一些能說的吧...
每一個世界都有一個實體列表(loadedEntityList字段)和玩家實體列表(playerEntities字段,包括所有位於此世界的玩家實體,玩家實體會同時存在於兩個列表當中),用以維持實體循環,此外,為了進一步優化性能,Minecraft為每一個Chunk還準備了16個子實體列表(Chunk類的entityList字段),若將一個Chunk(16x256x16個磚塊)按高度(Y軸)均分成16個區域的話,每個區域內的實體會屬於一個代表該區域內實體的子列表中(地表下的實體會被歸為最下面的子列表,256高度以上的實體同理),子列表主要用於優化數據存取以及使用AABB盒搜索實體.

最後就是關於創建一種新的實體的辦法,我們需要考慮5個問題.
1.實體的原型
我知道這個名字叫的不好,這幾天JavaScript寫多了...我想說的是,磚塊和物品採用的是類似單例模式的方式,在遊戲中放置一個磚塊,實際上只是將那個位置的磚塊ID改為新磚塊的ID,而實體不同,實體不需要你在Mod主類中new XXX一個原型,那樣做實際上近似於放置一個實體,創建一種新的實體,只需要創建一個繼承Entity類的類就好了.此外它還有個講就:在客戶端中被放置的實體只會被調用參數為World的構造函數,如果不明白的話可以看第四個問題.
2.實體的註冊
當然,我們不可能什麼都不做,單單地新建一個類便創建了一個實體了,我們還需要註冊它,註冊的最基本的目的是為了給實體一個名字和一個類型ID,名字是用於NBT存儲時使用,類型ID用於網絡通信時使用.此外還有個叫做EntityTracker的東西,它是下面提到的網絡同步的組成部分之一,用於檢測和決定實體數據的同步,Minecraft對它編寫的很死,新增的實體無法被EntityTracker識別,因此就需要註冊器來解決.註冊的辦法是調用EntityRegistry的registerModEntity方法或registerGlobalEntityID方法,這裡我推薦registerModEntity方法.
registerModEntity方法的參數包括"實體的類","名字","類型ID","所屬的Mod","實體交互範圍","信息更新頻率","是否同步速度數據".例如:

EntityRegistry.registerModEntity(EntityNew.class, "MyEntity", 1, this, 64, 3, true)

它註冊的實體是EntityNew類描述的實體,名稱為"MyEntity",類型ID為1,所屬Mod為這個代碼所在的Mod主類的Mod,實體交互範圍為64(64格之外的玩家不會被通告關於此實體的變化),每3Tick進行一次大同步.
關於"是否同步速度數據"這個不太好說...因為從代碼上看即使不需要同步速度數據的實體也有發送速度數據的代碼,可以參考一下EntityTracker中對各個實體的同步參數.
registerModEntity方法的可貴之處不但在於用它註冊的實體可以被EntityTracker識別,還在於它不用擔心實體ID衝突的問題...
相比之下,registerGlobalEntityID就有些見拙了,它的參數包括"實體的類","名字","類型ID",以及兩個可選參數:生物蛋的兩個顏色參數...它註冊的實體可能會有ID衝突,而且你得自己想辦法如何讓它能被EntityTracker識別.
3.實體的渲染
實體通過Render來渲染,如果有時間我會再詳細寫一下...可以參考一下舊教程或其他的代碼...
註冊Render通過RenderingRegistry類的registerEntityRenderingHandler方法來實現,它是個客戶端專用的代碼,很可能是本教程中第一個需要用到代理器(Proxy,老讀者可能會注意到關於代理器的部分在前兩篇中被刪去了)的部分.
4.實體的同步
這是最後一個問題了,我們在第一個問題的結尾說到了"在客戶端中被放置的實體只會被調用參數為World的構造函數",以前又提到過"實體必須在服務器中被創建,然後讓遊戲同步到客戶端".遊戲中,當你在服務器中創建一個實體後,服務器在確定它的實體ID後,會發送封包(Packet)到客戶端,客戶端接受後解包並處理信息,根據類型ID找出對應的實體,再調用它的參數為World的構造函數來創建,然後放置到客戶端世界中.這只是它的創建流程,平時我們還需要考慮其他的數據同步問題.
(1)初始化數據同步:毫無疑問,第一次同步發生在它創建的時候,有時候我們希望實體在創建之初便擁有一些特殊的屬性或信息啥的,然後遊戲默認的同步方式可不負責同步這些數據,因此我們需要手動同步這些數據,同步方法是讓實體類實現IEntityAdditionalSpawnData接口,實現此接口的實體類在實體被服務器創建時會被調用writeSpawnData方法,你會獲得一個可向其中寫入"任意"數據的ByteArrayDataOutput,而在客戶端,當它被創建時會被引發readSpawnData方法,你可以從中讀取數據.憑此你可以實現初始化數據同步:在writeSpawnData中按順序寫入數據,再在readSpawnData中按順序讀出數據.
此外,IThrowableEntity接口是一個用於記錄"發射者"的接口,實現此接口的實體類在創建時遊戲會訪問實體的getThrower方法來獲取發射者的實體ID,然後在客戶端通過setThrower來通告客戶端發射者的ID,雖然感覺命名有些怪異,但確實是這樣的.
(2)實時數據同步:除了死亡與稅收外,第三件在世界上可以肯定的事就是你需要服務器源源不斷地將數據同步給客戶端,DataWatcher是實現這件事的利♂器,以前我花了大量的時間和Modder無雲討論DataWatcher的使用,以至於現在我發現我把通信貼上來就能作為一個教程了:
DataWatcher除了存讀數據以外,它還有保證客戶端與服務器端之間數據同步的功能.DataWatcher有0至31共計32個可用的數據值(其實我喜歡把它叫”通道”或”頻道”),每個數據值可以存儲一個數據,通過底層實現,DataWatcher會盡量保證一個數據值在客戶端和服務器端都是相同的.
DataWatcher的使用非常酷似C#的訪問器,即通過setXXX來修改XXX變量,getXXX來獲取XXX變量的值.在Minecraft中你能看到類似的寫法.
使用DataWatcher前,先要在實體的DataWatcher中註冊數據值,註冊數據值通常在entityInit中進行,以EntityCreeper為例,0~15號數據值被基類使用了,16號數據值被用來儲存它爆炸前蓄力時間,17號用來存儲它是否被閃電充能過.因此它的entityInit是:

protected void entityInit()
{
super.entityInit();
this.dataWatcher.addObject(16, Byte.valueOf((byte) – 1));
this.dataWatcher.addObject(17, Byte.valueOf((byte)0));
}

addObject是向DataWatcher中註冊一個數據值(萬不可用它來修改值),數據類型只能為Byte,Short,Int,Float,String,ItemStack和ChunkCoordinates中的一種.
修改數據值通過updateObject來進行.獲取數據值通過getWatchableObjectXXX來進行,XXX為類型名.
一個數據值在被註冊時以及被修改後會被自動設為待更新狀態,DataWatcher會儘快完成同步並撤銷狀態,你也可以通過setObjectWatched來手動將一個數據值設為待更新狀態.
另外,有人之前和我聊過這個問題,我想可能你也會問,就是DataWatcher怎麼和Entity中的各個字段(或者叫變量吧,雖然很不準確.我不知道Field在台灣翻譯為什麼.)保持關聯的,事實上,它們並沒有關聯,開發者可以手動將DataWatcher中的數據刷新入字段,也可以完全不管字段,直接從DataWatcher中讀取值來用.通常來說是採用直接從DataWatcher中讀取值來用的方式.
(3)輕量級的信號發送:DataWatcher雖屌,但我們有時卻不需要同步,我們僅僅只是希望向客戶端發個信號,這時候就是靠setEntityState了,同樣摘自通信:
setEntityState的作用是從服務器端發一個信號給客戶端,該信號代表某Entity的某個狀態發生變化,換句話說這是個輕量級的數據傳輸方案~只不過是單向的(其實DataWatcher應該也是單向的吧…)
setEntityState的第二個參數代表信號類型.任何一個繼承了Entity類的類都可以通過重寫handleHealthUpdate方法來處理信號(別忘了將不能處理的信號通過super.handleHealthUpdate傳給基類去處理).只有2(代表遭受攻擊)和3(代表被殺死)這兩個信號是”公用”的,是所有EntityLivingBase類的派生類都能識別的,其它的則是由接受的類來實現.
舉例,當服務器端判斷某Entity受到攻擊時,會調用world.setEntityState(this,(byte)2) (假設world是其所處世界,this指該Entity).服務器會將信息廣播給有機會與此Entity互動的玩家,玩家的客戶端收到信息後會調用entity.handleHealthUpdate((byte)2) (假設entity為此Entity).效果是讓它做出被傷害的特效.
對於各個信號值的含義,只要知道2和3分別代表遭受攻擊和被殺死就可以了,因為除此之外的信號值是不通用的,儘管Minecraft為每種信號值只規定了一種含義…
如果要是發送帶有數據的信號的話,就是用自定義封包吧...
(4)自定義封包:好吧,這個我也沒弄過,看老外是怎麼弄的http://www.minecraftforge.net/wiki/Packet_Handling
5.實體的保存
保存我們通過NBT來實現,對於自己新增的實體,可以通過重寫writeEntityToNBT和readEntityFromNBT來直接存讀數據,對於不是我們自己寫的實體,可以通過往customEntityData字段中讀寫數據來實現存讀.
哎呀,我忘了我還沒寫NBT的教程...先湊活看下別人的NBT教程或舊教程中的NBT部分吧...
///////////////////////////////////////////////////////////////////////////////////////////////////////////

最後再蛋逼一句實體循環.

知識點:實體循環
///////////////////////////////////////////////////////////////////////////////////////////////////////////
用一句話來解釋:實體循環就是遊戲每一幀對所有實體進行的運算...
稍微了解遊戲引擎原理的人都會知道遊戲循環(Game Loop)這個東西,簡單來說,就是每一秒鐘遊戲都會對一段程序循環運算幾十遍(通常是25~30遍),更進一步了解引擎原理的人會知道通常遊戲循環分為兩部分:Update(數據運算,也有寫作Tick(幀),因為一次遊戲循環算一幀)和Render(圖像繪製,也有寫作Draw)
Minecraft類的runTick方法以及MinecraftServer類的tick方法就相當於Update,在runTick中,遊戲會將玩家所在的世界中的所有實體的onUpdate方法調用一遍.(tick更為複雜一點,它要將所有世界的實體依次調用一遍)onUpdate方法會執行對實體的運算...
另外,Update和Render並非一一對應關係,即不一定每次Render時都有一次Update.但對於不涉及圖形學的Modder來說,就不用太在意這個了...
///////////////////////////////////////////////////////////////////////////////////////////////////////////

注釋s:
注1 對玩家物品的同步:事實上這裡有個疑點,從代碼的角度看,遊戲只有在玩家切換了物品的情況下才會同步玩家手中或服飾欄中的物品,但實測即使玩家不換物品也會正常同步數據.
注2 實體的創建必須在服務器進行:這裡說的實體的創建是World類的spawnEntityInWorld,單純地實例化一個實體類是在任意端都允許的.另外,有些開發者喜歡利用幽靈實體的特性,故意單方面在客戶端創建實體,我說不出這樣做有何壞處...但就我個人而言我是絕對不會這麼做的...