【Unity3d开发笔记】 -FPS- GameObject.GetComponent<T>()获取组件的顺序

【Unity3d开发笔记】 -FPS- GameObject.GetComponent<T>()获取组件的顺序

看了结论基本就可以溜了。。。
【结论】

  • Unity3d中的GetComponent<T>()方法获取GameObject对象上组件的机制为:从上往下搜索类型为 T 的组件,并获取第一个。这就是说,如果一个GameObject对象有多个类型 T 的组件,它只得到第一个,因此需要注意AddComponent()的顺序。
  • 这里的类型 T,不仅仅指类型为 T的组件,它还包括 T 的派生类,这是由于泛型的限定条件所致。

【顺序带来的问题】
这是我在制作FPS网络多人游戏时遇到的一个小问题,就是子弹击中敌方单位后出现Null Reference Exception的Error,错误原因是动画状态机(playerAnim)的私有字段为空,原本的预期是子弹与敌方单位碰撞时播放角色"受伤"动画,而我在相应类的Start()方法已经给该Animator赋值,这个错误的出现不符合预期,我因此想通过设置测试来找出错因。

我不习惯动态添加脚本,很多脚本都提前挂在预制体(Prefab)上了,这里面就包括角色基类BasePlayer、负责角色控制的派生类PlayerController以及负责同步角色行为的派生类SyncPlayerController,悬挂顺序如下图所示:

挂件顺序

图1 挂件顺序

注:运行时会根据情况对脚本的enabled进行设置,测试时PlayerControllerBasePlayer都是选中状态。

与错误产生相关的类代码如下:

  1. 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); //扣血
    		}
    }
    
  2. 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!");
        }
    }
    
  3. PlayerController : BasePlayer(角色控制类)

    void Start(){
          
          
    	//...
    	//没法给playerAnim赋值,因为它是基类私有字段
    	//...
    }
    
  4. SyncPlayerController : BasePlayer(同步角色类)

    void Start(){
          
          
    	//...
    	//没法给playerAnim赋值,因为它是基类私有字段
    	//...
    }
    

派生类是不能直接访问私有成员的,结合以上代码,它们只有可能通过基类公共方法Attacked()访问playerAnim,而这正是问题所在,挂载在物体上的三个类看似是有继承关系,但实际上是不同的实例,它们之间的成员是不共享的(除非字段声明为 static )。而在这个例子中没有给派生类提供修改playerAnim的公共方法,因此,两个派生类在调用Attacked()方法时执行到语句playerAnim.SetBool("IsAttacked", true);时,都会因playerAnim为空而触发NullReference异常。

那么问题来了,为何会调用派生类的Attacked()方法呢?问题出在我对于collobj.GetComponent<BasePlayer>().Attacked(gun_dmg);的理解上。按照图1中的顺序并结合结论, 上述代码等价于collobj.GetComponent<PlayerController>().Attacked(gun_dmg);。上面已经分析过,这当然会导致NullReferenceException

【解决方案】

【方案1】调换挂件顺序

BasePlayer放在最上面,这样collobj.GetComponent<BasePlayer>().Attacked(gun_dmg);获取的就是BasePlayer对象的playerAnim,这是已经赋值了的。如图2所示:
在这里插入图片描述

图2 修正后的挂件顺序
【方案2】将基类的Start()方法定义为虚方法,在派生类中重载,然后适当调整顺序

BasePlayer

private Animator playerAnim;
public virtual void Start()
{
    
    
    playerAnim = GetComponent<Animator>();
}

PlayerController / SyncPlayerController

public override void Start()
{
    
    
    base.Start();
    //code
}

这样还不够。对于控制角色来说,由于SyncPlayerController不激活,其不会执行Start()方法,因此还需要把该脚本组件置后;对于同步角色来说,由于PlayerController不激活,其不会执行Start()方法,因此还需要把该脚本置后。

【方案3】用GetComponents<BasePlayer>()获取所有相关组件,然后进行类型筛选
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[] 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的公共成员,一个物体上分别挂了BasePlayerPlayerControllerSyncPlayerController,也就有3份hp,那么就需要确定——按照哪个脚本来执行对hp的操作,如果变量变得更多了,情况会更复杂,代码逻辑就更混乱了。

因此,我的做法是:不要给一个物体同时挂上基类和派生类。于是我把基类卸掉了,并且根据是否是可控角色来调整派生类脚本的顺序。对于可控角色,其组件的排序如左图(无需执行组件排序,因为其排序与预制体顺序一样);对于同步角色,其组件的排序如右图。

组件重新排序的方法

图3 去掉基类BasePlayer后的挂件顺序

这样一来,GetComponent<BasePlayer>()就会获取第一个兼容BasePlayer类型的脚本,也就是图中画线的脚本组件。

最后的击杀效果:

在这里插入图片描述

如果有啥建议或意见,欢迎各位大佬们批评指正~

猜你喜欢

转载自blog.csdn.net/weixin_42430021/article/details/108525186