基于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,单纯地实例化一个实体类是在任意端都允许的.另外,有些开发者喜欢利用幽灵实体的特性,故意单方面在客户端创建实体,我说不出这样做有何坏处...但就我个人而言我是绝对不会这么做的...