3.4 敌人
3.4.1 寻路
在很多游戏中,敌人经常要在复杂的地形中追着主角跑,因为场景中存在很多障碍物,所以敌人的AI要足够聪明,才能找出到达目标点的最近道路,且绕开障碍物。写一个完善的寻路算法是比较有挑战性的,特别是在复杂的3D场景中,好在Unity提供了一个非常实用的寻路功能,只需要较少的代码即可实现复杂的寻路功能。
Unity的寻路系统分为两部分:一部分是对场景进行设置,使其满足寻路算法的需求;另一部分是设置寻路者。
步骤 01 选择场景模型(名为level),然后单击Inspector窗口Static旁边的小三角形按钮显示出下拉列表,确认【Navigation Static】被选中,Unity将指导这样的模型用于寻路计算,如图3-6所示。
图3-6 第一人称视角
步骤 02 在菜单栏中选择【Window】→【Navigation】,打开Navigation窗口,如图3-7所示。
图3-7 寻路窗口
Bake窗口主要是定义地形对寻路的影响。
• Agent Radius和Height可以理解为寻路者的半径和高度。
• Max Slope是最大坡度,超过这个坡度寻路者则无法通过。
• Step Height是楼梯的最大高度,超过这个高度寻路者则无法通过。
• Drop Height表示寻路者可以跳落的高度极限。
• Jump Distance表示寻路者的跳跃距离极限。
步骤 03 在Navigation窗口中设置好选项后,单击【Bake】按钮对地形进行计算,单击【Clear】按钮会清除计算结果。
Bake出来的寻路数据,会被保存为一个文件,名为NavMesh.asset,当前关卡相关联,这一点与Lightmap很像。
接下来设置寻路者,也就是游戏中的敌人。
步骤 04 在当前工程Assets/Prefabs内找到Zombie.prefab,将其拖入场景,它是一个僵尸模型,将作为游戏中的敌人。
步骤 05 在菜单栏中选择【Component】→【Nav Mesh Agent】,将寻路组件指定给敌人。然后在Inspector窗口中进行进一步的设置,Speed是最大运动速度,Angular Speed是最大旋转速度,如图3-8所示。在Agent Type中选择【Open Agent Settings】,可以打Navigation的Agents窗口,设置Radius和Height,表示寻路者的半径和高度。
图3-8 设置敌人的寻路
步骤 06 创建脚本Enemy.cs,添加代码如下:
using UnityEngine; using UnityEngine.AI; using System.Collections; public class Enemy : MonoBehaviour { Transform m_transform; // 主角 Player m_player; // 寻路组件 NavMeshAgent m_agent; //移动速度 float m_movSpeed = 2.5f; void Start() { m_transform = this.transform; // 获得主角 m_player = GameObject.FindGameObjectWithTag("Player").GetComponent<Player>(); // 获得寻路组件 m_agent = GetComponent<NavMeshAgent>(); m_agent.speed=m_movSpeed; // 设置寻路器的行走速度 // 设置寻路目标 m_agent.SetDestination(m_player.m_transform.position); } }
这是敌人的脚本。在Start函数中获得NavMeshAgent组件,然后调用SetDestination函数设置一个目标点,即可自动追击主角。如果希望自行控制移动,可以使用CalculatePath函数计算出路径,然后按路径节点移动。
步骤 07 将脚本Enemy.cs指定给敌人。运行游戏,敌人会找出最短路径向主角位置前进,并躲开障碍物。
3.4.2 设置动画
我们在前面创建了可以自动寻路的敌人角色,接下来将为其增加动画效果。敌人共有4种动画,对应其状态,包括待机、行走、攻击和死亡。在本示例中,敌人的动画已经预先导入Unity工程并进行了基本设置。
步骤 01 在场景中选择敌人(或选择它的Prefab),默认它有一个Animator组件,并在Controller中已经预设了一个Animator Controller。取消选中【Apply Root Motion】复选框,强迫使用脚本控制游戏体的位置而不是通过动画,如图3-9所示。
图3-9 Animator组件
步骤 02 在菜单栏中选择【Window】→【Animator】,打开Animator窗口,Animator Controller的信息都显示在这里。在这个窗口中能看到敌人全部的动画,双击动画图标即可在Project窗口中找到原始动画资源。单击Parameters旁边的“+”号按钮,然后在展开的子菜单中选择【Bool】,创建4个Bool类型数值,名称分别为idle、run、attack、death,我们将会使其与动画过渡关联,并在脚本中控制它们,如图3-10所示。
图3-10 Animator窗口
步骤 03 在当前工程中,动画之间已预设好动画过渡,不同动画之间过渡是用连接线表示的,默认情况下动画之间通过播放时间自动过渡,我们需要使其受脚本控制。选择连线,比如从待机动画到跑步动画,在Conditions中将动画过渡条件设为run,当Bool值run为true时即从待机动画过渡到跑步动画,如图3-11所示。
图3-11 设置动画过渡
步骤 04 重复步骤(3)的操作为每个动画过渡设置条件。
3.4.3 行为
敌人的行为与动画的状态紧密关联,我们将修改敌人的脚本,在不同的动画状态使敌人的行为也发生改变。
步骤 01 打开Enemy.cs脚本,添加动画组件等属性,更新代码如下:
public class Enemy : MonoBehaviour { Transform m_transform; // 动画组件 Animator m_ani; // 寻路组件 NavMeshAgent m_agent; // 主角实例 Player m_player; // 移动速度 float m_movSpeed = 2.5f; // 旋转速度 float m_rotSpeed = 5.0f; // 计时器 float m_timer=2; // 生命值 int m_life = 15; // 出生点 protected EnemySpawn m_spawn; void Start () { m_transform = this.transform; // 获得动画播放器 m_ani = this.GetComponent<Animator>(); // 获得主角 m_player = GameObject.FindGameObjectWithTag("Player").GetComponent<Player>(); m_agent = GetComponent<UnityEngine.AI.NavMeshAgent>(); m_agent.speed = m_movSpeed; // 获得寻路组件 m_agent.SetDestination(m_player.m_transform.position); }
m_rotSpeed用于控制旋转速度,当敌人进攻主角时,它将始终旋转到面向主角的角度。m_timer用来计算时间间隔,比如待机一定时间,每隔一定时间更新寻路。m_life是敌人的生命值。
步骤 02 添加RotateTo函数,它的作用是使敌人始终旋转到面向主角的角度:
// 转向目标点 void RotateTo() { // 获取目标(Player)方向 Vector3 targetdir = m_player.m_transform.position - m_transform.position; // 计算出新方向 Vector3 newDir = Vector3.RotateTowards(transform.forward, targetdir, m_rotSpeed * Time.deltaTime, 0.0f); // 旋转至新方向 m_transform.rotation = Quaternion.LookRotation(newDir); }
Vector3.RotateTowards是一个实用的函数,通过目标点和自身的位置,计算出转向目标点的角度。
步骤 03 修改Update函数:
void Update() { // 如果主角生命值为0,什么也不做 if (m_player.m_life <= 0) return; // 更新计时器 m_timer -= Time.deltaTime; // 获取当前动画状态 AnimatorStateInfo stateInfo = m_ani.GetCurrentAnimatorStateInfo(0); // 如果处于待机且不是过渡状态 if (stateInfo.fullPathHash == Animator.StringToHash("Base Layer.idle") && ! m_ani.IsInTransition(0)) { m_ani.SetBool("idle", false); // 待机一定时间 if (m_timer > 0) return; // 如果距离主角小于1.5m,进入攻击动画状态 if (Vector3.Distance(m_transform.position, m_player.m_transform.position) < 1.5f) { // 停止寻路 m_agent.ResetPath(); m_ani.SetBool("attack", true); } else { // 重置定时器 m_timer = 1; // 设置寻路目标点 m_agent.SetDestination(m_player.m_transform.position); // 进入跑步动画状态 m_ani.SetBool("run", true); } } // 如果处于跑步且不是过渡状态 if (stateInfo.fullPathHash == Animator.StringToHash("Base Layer.run") && ! m_ani.IsInTransition(0)) { m_ani.SetBool("run", false); // 每隔1秒重新定位主角的位置 if (m_timer < 0) { m_agent.SetDestination(m_player.m_transform.position); m_timer = 1; } // 如果距离主角小于1.5m,向主角攻击 if (Vector3.Distance(m_transform.position, m_player.m_transform.position) <= 1.5f) { // 停止寻路 m_agent.ResetPath(); // 进入攻击状态 m_ani.SetBool("attack", true); } } // 如果处于攻击且不是过渡状态 if (stateInfo.fullPathHash == Animator.StringToHash("Base Layer.attack") && ! m_ani.IsInTransition(0)) { // 面向主角 RotateTo(); m_ani.SetBool("attack", false); // 如果动画播完,重新进入待机状态 if (stateInfo.normalizedTime >= 1.0f) { m_ani.SetBool("idle", true); // 重置计时器 待机2秒 m_timer = 2; } } }
在Update函数中,首先获得了一个AnimatorStateInfo对象,它保存着动画的状态,敌人包括待机、跑步、攻击、死亡4种状态,我们根据不同的状态处理不同的逻辑。无论哪种状态,都使用了IsInTransition判断是否是过渡状态,如果是,则什么也不做。
SetBool函数有两个参数:第一个是动画的名称,我们前面在Animator窗口中定义了它;第二个是布尔值,如果是true,则播放相应的动画,如果是false,则停止该动画。如果我们的程序是一个状态机结构,可以在进入某个状态时将相应动画的值设为true,离开该状态时设为false。
默认敌人处于待机状态,并播放待机动画,我们使用了一个计时器,当待机时间超过2秒并距离主角1.5m以内,则播放攻击动画,进入攻击状态,否则进入跑步状态。
在跑步状态中,使用计时器每间隔1秒更新一次主角的位置进行寻路,并始终追击主角,当距离主角1.5m以内,停止寻路,播放攻击动画进入攻击状态。
在攻击状态中,如果攻击动画播完则回到待机状态。
运行游戏,敌人在不同的状态下会播放相应的动作,当距离主角较近时,则会攻击主角。