看了结论基本就可以溜了。。。
【结论】
GetComponent()
方法获取GameObject对象上组件的机制为:从上往下搜索类型为 T
的组件,并获取第一个。这就是说,如果一个GameObject对象有多个类型 T
的组件,它只得到第一个,因此需要注意AddComponent()
的顺序。T
,不仅仅指类型为 T
的组件,它还包括 T
的派生类,这是由于泛型的限定条件所致。【顺序带来的问题】
这是我在制作FPS网络多人游戏时遇到的一个小问题,就是子弹击中敌方单位后出现Null Reference Exception
的Error,错误原因是动画状态机(playerAnim
)的私有字段为空,原本的预期是子弹与敌方单位碰撞时播放角色"受伤"动画,而我在相应类的Start()方法已经给该Animator
赋值,这个错误的出现不符合预期,我因此想通过设置测试来找出错因。
我不习惯动态添加脚本,很多脚本都提前挂在预制体(Prefab
)上了,这里面就包括角色基类BasePlayer
、负责角色控制的派生类PlayerController
以及负责同步角色行为的派生类SyncPlayerController
,悬挂顺序如下图所示:
注:运行时会根据情况对脚本的enabled
进行设置,测试时PlayerController
与BasePlayer
都是选中状态。
与错误产生相关的类代码如下:
BulletManager : MonoBehaviour(动态挂载于子弹上,处理子弹的行为)
void OnCollisionEnter(Collision collisionInfo)
{
GameObject collobj = collisionInfo.gameObject; //获取碰撞物体的信息
if(collobj.tag == enemyTag) //确定为敌人
{
Debug.Log(collobj.name + " got attacked!");
collobj.GetComponent<BasePlayer>().Attacked(gun_dmg); //扣血
}
}
BasePlayer : MonoBehaviour (角色基类)
private Animator playerAnim; //动画状态机,播放角色相应状态的动画
void Start()
{
playerAnim = GetComponent<Animator>();
}
public void Attacked(float att)
{
hp -= att; //扣血
playerAnim.SetBool("IsAttacked", true); //受伤动画
this.Invoke("RestoreAnimAttackedState", 0.5f); //播放后恢复原先的动画状态
//死亡的相关处理
if (IsDie())
{
SetDeath(); //死亡相关行为处理,包括播放死亡动画,消除碰撞能力等
Debug.Log("Player died!");
}
}
PlayerController : BasePlayer(角色控制类)
void Start(){
//...
//没法给playerAnim赋值,因为它是基类私有字段
//...
}
SyncPlayerController : BasePlayer(同步角色类)
void Start(){
//...
//没法给playerAnim赋值,因为它是基类私有字段
//...
}
派生类是不能直接访问私有成员的,结合以上代码,它们只有可能通过基类公共方法Attacked()
访问playerAnim
,而这正是问题所在,挂载在物体上的三个类看似是有继承关系,但实际上是不同的实例,它们之间的成员是不共享的(除非字段声明为 static
)。而在这个例子中没有给派生类提供修改playerAnim
的公共方法,因此,两个派生类在调用Attacked()
方法时执行到语句playerAnim.SetBool("IsAttacked", true);
时,都会因playerAnim
为空而触发NullReference
异常。
那么问题来了,为何会调用派生类的Attacked()
方法呢?问题出在我对于collobj.GetComponent
的理解上。按照图1中的顺序并结合结论, 上述代码等价于collobj.GetComponent
。上面已经分析过,这当然会导致NullReferenceException
。
【解决方案】
把BasePlayer
放在最上面,这样collobj.GetComponent
获取的就是BasePlayer
对象的playerAnim
,这是已经赋值了的。如图2所示:
BasePlayer
private Animator playerAnim;
public virtual void Start()
{
playerAnim = GetComponent<Animator>();
}
PlayerController / SyncPlayerController
public override void Start()
{
base.Start();
//code
}
这样还不够。对于控制角色来说,由于SyncPlayerController
不激活,其不会执行Start()
方法,因此还需要把该脚本组件置后;对于同步角色来说,由于PlayerController
不激活,其不会执行Start()
方法,因此还需要把该脚本置后。
void OnCollisionEnter(Collision collisionInfo)
{
GameObject collobj = collisionInfo.gameObject;
//敌人掉血
if(collobj.tag == enemyTag)
{
//掉血时的处理
Debug.Log(collobj.name + " got attacked!");
//collobj.GetComponent().Attacked(gun_dmg);
BasePlayer[] basePlayers = collobj.GetComponents<BasePlayer>();
//类型比对,然后筛选
foreach (BasePlayer player in basePlayers)
{
if( player.GetType() != typeof(BasePlayer))
{
continue;
}
else
{
player.Attacked(gun_dmg);
break;
}
}
}
}
如果同类型挂件太多,查找会带来较大开销,这时方案3就不划算,除非目标挂件放在最上面。
由于3个方案都会受到挂件顺序的影响,采用方案1
更加简单且实用,但也不能保证对其余逻辑来说方案1
最好,这得看后续的实践情况。
本萌新并没有深入了解API,可能对一些函数的功能描述存在问题;另外,对于以上情况的解决方案也不唯一,我提供的这三种方案不见得适用于任何情况。
===================================
后续
前面这种把基类、派生类都挂在物体上的情况基本见不着,因为这种设计方式有很大的问题。就拿生命值hp
来说,它是基类BasePlayer
的公共成员,一个物体上分别挂了BasePlayer
、PlayerController
和SyncPlayerController
,也就有3份hp
,那么就需要确定——按照哪个脚本来执行对hp
的操作,如果变量变得更多了,情况会更复杂,代码逻辑就更混乱了。
因此,我的做法是:不要给一个物体同时挂上基类和派生类。于是我把基类卸掉了,并且根据是否是可控角色来调整派生类脚本的顺序。对于可控角色,其组件的排序如左图(无需执行组件排序,因为其排序与预制体顺序一样);对于同步角色,其组件的排序如右图。
组件重新排序的方法
这样一来,GetComponent
就会获取第一个兼容BasePlayer类型的脚本,也就是图中画线的脚本组件。
最后的击杀效果:
如果有啥建议或意见,欢迎各位大佬们批评指正~