Unity3D RPG Core | 16 攻击属性

继上一节完成角色属性参数之后,这一节需要完成角色的攻击参数。并且完善史莱姆的攻击逻辑。

1. 创建 ScriptableObject 脚本/资产文件

和角色属性参数如出一辙,我们首先创建角色攻击参数的 ScriptableObject 脚本。如代码清单 1 所示,我们定义了攻击距离、技能距离、冷却时间、最小/最大攻击力、暴击加成倍率以及暴击几率。

代码清单 1 攻击参数 ScriptableObject 脚本
  1. using System.Collections;
  2. using System.Collections.Generic;
  3. using UnityEngine;
  4.  
  5. [CreateAssetMenu(fileName = "Attack Data", menuName = "ScriptableObject/Attack Data")]
  6. public class AttackDataScriptableObject : ScriptableObject
  7. {
  8.     public float m_attackRange;
  9.     public float m_skillRange;
  10.     public float m_coolDown;
  11.     public int   m_minDamage;
  12.     public int   m_maxDamage;
  13.  
  14.     public float m_criticalMultiplier;
  15.     public float m_criticalChance;
  16. }

同样的,在 Unity Assets 窗体中创建对应的 ScriptableObject 资产文件。并如图 1 所示,在 Inspector 窗体中指定各个属性的值。

图1 设置 ScriptableObject 资产文件

同样的,如代码清单 2 所示,我们编写相应的访问脚本。视频中是把 AttackDataScriptableObject 直接放在先前的角色参数访问脚本中,共有一个访问。但是感觉有点奇怪了,觉得既然都分离开了,就彻底一点;而且视频中原先角色属性参数用访问器,现在攻击参数就直接访问了,看着不统一。

代码清单 2 访问脚本
  1. using System.Collections;
  2. using System.Collections.Generic;
  3. using UnityEngine;
  4.  
  5. public class AttackData : MonoBehaviour
  6. {
  7.     [SerializeField]
  8.     private AttackDataScriptableObject m_attackData = null;
  9.  
  10.     public float AttackRange
  11.     {
  12.         get { return m_attackData != null ? m_attackData.m_attackRange : 0; }
  13.         set { if (m_attackData != null) m_attackData.m_attackRange = value; }
  14.     }
  15.  
  16.     public float SkillRange
  17.     {
  18.         get { return m_attackData != null ? m_attackData.m_skillRange : 0; }
  19.         set { if (m_attackData != null) m_attackData.m_skillRange = value; }
  20.     }
  21.  
  22.     public float CoolDown
  23.     {
  24.         get { return m_attackData != null ? m_attackData.m_coolDown : 0; }
  25.         set { if (m_attackData != null) m_attackData.m_coolDown = value; }
  26.     }
  27.  
  28.     public int MinDamage
  29.     {
  30.         get { return m_attackData != null ? m_attackData.m_minDamage : 0; }
  31.         set { if (m_attackData != null) m_attackData.m_minDamage = value; }
  32.     }
  33.     public int MaxDamage
  34.     {
  35.         get { return m_attackData != null ? m_attackData.m_maxDamage : 0; }
  36.         set { if (m_attackData != null) m_attackData.m_maxDamage = value; }
  37.     }
  38.  
  39.     public float CriticalMultiplier
  40.     {
  41.         get { return m_attackData != null ? m_attackData.m_criticalMultiplier : 0; }
  42.         set { if (m_attackData != null) m_attackData.m_criticalMultiplier = value; }
  43.     }
  44.     public float CriticalChance
  45.     {
  46.         get { return m_attackData != null ? m_attackData.m_criticalChance : 0; }
  47.         set { if (m_attackData != null) m_attackData.m_criticalChance = value; }
  48.     }
  49. }

视频中没有这样做。视频中是把 AttackDataScriptableObject 直接放在先前的角色参数访问脚本中,共用一个访问脚本。

1.1 玩家角色使用攻击属性

我们将访问脚本挂载在玩家人物对象上之后,就可以如代码清单 3 一样访问攻击参数了(第 28 行获取到脚本组件)。是否还记得之前第 9 行中的人物攻击距离是硬编码写死的,现在我们把它更改为 AttackRange 参数。

代码清单 3 玩家角色使用攻击属性
  1. public class PlayerController : MonoBehaviour
  2. {
  3.     CharacterData m_characterData;
  4.     AttackData    m_attackData;
  5.    
  6.     IEnumerator CoroutineAttackEnemy(GameObject obj)
  7.     {
  8.         transform.LookAt(obj.transform);
  9.         while (Vector3.Distance(obj.transform.position, transform.position) > m_attackData.AttackRange)
  10.         {
  11.             m_navMeshAgent.destination = obj.transform.position;
  12.             yield return null;
  13.         }
  14.  
  15.         m_navMeshAgent.isStopped = true;
  16.  
  17.         if (m_attackCoolTime <= 0)
  18.         {
  19.             m_animator.SetTrigger("Attack");
  20.             m_attackCoolTime = 0.5f;
  21.         }
  22.     }
  23.     void Awake()
  24.     {
  25.         m_navMeshAgent  = GetComponent<NavMeshAgent>();
  26.         m_animator      = GetComponent<Animator>();
  27.         m_characterData = GetComponent<CharacterData>();
  28.         m_attackData    = GetComponent<AttackData>();
  29.     }
  30. }

2. 实现敌人攻击逻辑

在为史莱姆创建好攻击属性资产文件,并将相关访问脚本附加到对象上之后,我们开始完善史莱姆的攻击逻辑。

整体的逻辑是,追击着敌人直到在攻击范围之内,停下来攻击玩家。首先是否达到攻击范围可以这样判断:

  1. bool IsPlayerInAttackRange(GameObject player)
  2. {
  3.     return Vector3.Distance(player.transform.position, transform.position) <= m_attackData.AttackRange;
  4. }

史莱姆的攻击也分为普通攻击和暴击,我们先在动画控制器中将攻击对应的动画,以及触发条件设置好。

如图 2 所示,我们在待机状态的基础上设置两个攻击动画,左边是普通攻击的动画,右边是暴击动画。同时创建触发变量 Attack,并创建布尔变量 CriticalHit 区分是否暴击。

图2 设置动画

一切准备就绪之后,写代码实现攻击逻辑。如代码清单 4 所示,首先获取到属性的访问脚本变量(第 15、16 行)。攻击逻辑在原先的追击逻辑上完善。如果追击到攻击范围之内(第 77 行,IsPlayerInAttackRange),则不播放追击动画,设置 Following 变量为 false(第 79 行),并停止运动(第 80 行)。

和人物攻击一样,敌人攻击也有冷却时间,如果可以攻击,则首先计算此次攻击是否暴击。判断的方法是使用 Random.value,Random.value 会产生 (0,1) 之间的随机数,正好和我们定义暴击率范围是一致的。

攻击的逻辑看到 Attack() 函数(第 115 至 123 行),首先朝向玩家,然后设置 CriticalHit 变量决定是否暴击,接着设置 Attack 变量触发攻击动画。

最后不要忘记让角色不攻击时恢复运动(第 68 行)。

代码清单 4 攻击逻辑
  1. public class EnemyController : MonoBehaviour
  2. {
  3.     CharacterData    m_characterData;
  4.     AttackData       m_attackData;
  5.  
  6.     float            m_attackCoolTime = 0;
  7.  
  8.     void Awake()
  9.     {
  10.         m_navMeshAgent  = GetComponent<NavMeshAgent>();
  11.         m_animator      = GetComponent<Animator>();
  12.         m_defaultSpeed  = m_navMeshAgent.speed;
  13.         m_initPosition  = transform.position;
  14.  
  15.         m_characterData = GetComponent<CharacterData>();
  16.         m_attackData    = GetComponent<AttackData>();
  17.     }
  18.  
  19.     void SwitchState()
  20.     {
  21.         GameObject attackObject = null;
  22.  
  23.         switch (m_enemyStates)
  24.         {
  25.             case EnemyStates.GUARD:
  26.                 if (FindPlayer() != null)
  27.                 {
  28.                     Debug.Log("HasFoundPlayer");
  29.                     m_enemyStates = EnemyStates.PURSUIT;
  30.                 }
  31.                 break;
  32.             case EnemyStates.PATROL:
  33.                 m_navMeshAgent.isStopped = false;
  34.                 if (FindPlayer() != null)
  35.                 {
  36.                     Debug.Log("HasFoundPlayer");
  37.                     m_enemyStates = EnemyStates.PURSUIT;
  38.                     break;
  39.                 }
  40.  
  41.                 m_navMeshAgent.speed = m_defaultSpeed / 1.5f;
  42.                 m_animatorState.isPursuing = false;
  43.  
  44.                 //Debug.Log("dist=" + Vector3.Distance(m_patrolDestPoint, transform.position));
  45.                 if (Vector3.Distance(m_patrolDestPoint, transform.position) <= m_navMeshAgent.stoppingDistance)
  46.                 {
  47.                     m_navMeshAgent.destination = transform.position;
  48.                     m_animatorState.isWalking = false;
  49.                     m_animatorState.isSensing = true;
  50.                     if (m_patrolStoppingRemainTime > 0)
  51.                     {
  52.                         m_patrolStoppingRemainTime -= Time.deltaTime;
  53.                     }
  54.                     else
  55.                     {
  56.                         m_patrolStoppingRemainTime = m_patrolStoppingTime;
  57.                         m_patrolDestPoint = GetNewPatrolDestPoint();
  58.                     }
  59.                 }
  60.                 else
  61.                 {
  62.                     m_animatorState.isWalking = true;
  63.                     m_animatorState.isSensing = false;
  64.                     m_navMeshAgent.destination = m_patrolDestPoint;
  65.                 }
  66.                 break;
  67.             case EnemyStates.PURSUIT:
  68.                 m_navMeshAgent.isStopped = false;
  69.                 m_navMeshAgent.speed = m_defaultSpeed;
  70.                 m_animatorState.isWalking = false;
  71.                 m_animatorState.isSensing = false;
  72.                 m_animatorState.isPursuing = true;
  73.                 if ((attackObject = FindPlayer()) != null)
  74.                 {
  75.                     m_animatorState.isFollowing = true;
  76.                     m_navMeshAgent.destination = attackObject.transform.position;
  77.                     if (IsPlayerInAttackRange(attackObject))
  78.                     {
  79.                         m_animatorState.isFollowing = false;
  80.                         m_navMeshAgent.isStopped = true;
  81.  
  82.                         if (m_attackCoolTime < 0)
  83.                         {
  84.                             m_attackCoolTime = m_attackData.CoolDown;
  85.                             m_attackData.isCriticalHit = Random.value < m_attackData.CriticalChance;
  86.                             Attack(attackObject);
  87.                         }
  88.                     }
  89.                 }
  90.                 else
  91.                 {
  92.                     m_animatorState.isFollowing = false;
  93.                     m_navMeshAgent.destination = transform.position;
  94.  
  95.                     if (m_patrolStoppingRemainTime > 0)
  96.                     {
  97.                         m_patrolStoppingRemainTime -= Time.deltaTime;
  98.                     }
  99.                     else
  100.                     {
  101.                         m_patrolStoppingRemainTime = m_patrolStoppingTime;
  102.  
  103.                         if (m_enemyType == EnemyType.PATROL)
  104.                             m_enemyStates = EnemyStates.PATROL;
  105.                         else
  106.                             m_enemyStates = EnemyStates.GUARD;
  107.                     }
  108.                 }
  109.                 break;
  110.             case EnemyStates.DEATH:
  111.                 break;
  112.         }
  113.     }
  114.  
  115.     void Attack(GameObject player)
  116.     {
  117.         transform.LookAt(player.transform);
  118.         if (IsPlayerInAttackRange(player))
  119.         {
  120.             m_animator.SetBool("CriticalHit", m_attackData.isCriticalHit);
  121.             m_animator.SetTrigger("Attack");
  122.         }
  123.     }
  124.  
  125.     bool IsPlayerInAttackRange(GameObject player)
  126.     {
  127.         return Vector3.Distance(player.transform.position, transform.position) <= m_attackData.AttackRange;
  128.     }
  129. }

最终实现的效果如图 3 所示,可以看到史莱姆有普通攻击和暴击。

图3 最终效果