在之前的文章中,我介绍了如何开发一个FPS游戏,添加一个第一人称的主角,并设置武器。现在我将继续完善这个游戏,打算添加敌人,实现其智能寻找玩家并进行对抗。完成的效果如下:
fps_enemy_demo
下载资源
首先是设计敌人,我们可以在网上找到一些好的免费素材,例如在Unity商店里面有一个不错的免费素材, Low Poly Soldiers Demo | 3D 角色 | Unity Asset Store,里面提供了3D模型和动画,其收费版提供了更多的模型,武器以及动画,收费10美刀也不贵。这里我以收费版为素材,把其加到我之前开发的FPS游戏中。
下载资源后导入到项目中,然后在项目文件的LowPoly Soldiers的prefab目录下,可以看到有多个不同服装和武器的Prefab。选择一个拖动到项目中的Prefab目录。然后打开Prefab,在模型中找到其武器,在其枪口位置新增一个名为muzzle的空的GameObject,这将作为敌人发射子弹的位置。如下图:
点击这个Prefab的根对象,即左侧导航树的Soldier_marine variant,为其增加一个Capsule collider,调整其设置,使得这个Collider能覆盖整个人物。
增加一个Script的组件,重用之前创建的MuzzleEffect脚本。
添加一个Nav Mesh Agent组件,使得敌人能在烘培的路面上具备自动寻路的功能。
烘培路线
要让敌人能自动寻路,需要在场景中进行路线的烘培。打开菜单的Window->AI->Navigation,选择Bake,设置Agent的相关参数,然后点击Bake按钮即可。
定义敌人状态和动画切换
在项目的Scripts目录新增一个名为WanderingAI的Script文件,定义敌人的行为模式。这里我们可以定义敌人有行走,快跑,瞄准,射击,重载弹药,受伤害和死亡这几种行为。为了方便起见,在这个Script里面可以定义一个enum来统一管理当前的状态,如以下代码:
[Flags]
private enum EnemyStatus {
Idle,
Walk,
Aim,
AimLeft,
AimRight,
Shoot,
Reload,
Sprint,
Damage,
Death
}
在以上状态中,瞄准状态有三个,分别对应原地瞄准,向左移动瞄准和向右移动瞄准。
另外再定义其他的一些属性,并进行初始化,如以下代码:
[Header("Enemy speed")]
public float speed = 3.0f;
public float sprintSpeed = 5.0f;
[Header("Enemy eyeview")]
public float eyeviewDistance = 500.0f;
public float viewAngle = 120f;
public float obstacleRange = 1.0f;
[Header("Enemy behavior")]
public float aimPeriod = 3.0f;
[Header("Shoot Setting")]
[SerializeField]
private Transform muzzleTransform;
[SerializeField] GameObject bulletPrefab;
public int ammo = 5;
[Header("Enemy life")]
public int health = 10;
private Animator _animator;
private Collider[] SpottedPlayers;
private GameObject bullet;
private MuzzleEffect muzzleEffect;
private long shootTS = 0;
private Vector3 prevPlayerPosition = new Vector3(100f, 100f, 100f);
private Vector3 enemyMuzzleDelta = new Vector3(0f, 2.0f, 0f);
private Vector3 playerPositionDelta = new Vector3(0f, 1.0f, 0f);
private Vector3 playerDirection;
private int currentAmmo;
private float lerpValue = 10f;
private bool randomSearch = true;
private NavMeshAgent _agent;
private Coroutine corDiscoverPlayer = null;
private Coroutine corReload = null;
private Vector3 attackDirection;
private EnemyStatus status;
private EnemyStatus prevStatus;
void Start()
{
_animator = GetComponent<Animator>();
muzzleEffect = GetComponent<MuzzleEffect>();
_agent = GetComponent<NavMeshAgent>();
currentAmmo = ammo;
status = EnemyStatus.Idle;
}
在项目的Animator目录,新建一个Animator controller文件,把LowPoly Soldiers的animation目录下需要用到的动画拖动到窗口,定义动画状态的切换。
在上图的左侧定义了相关的参数,主要是Trigger类型的,用于切换不同的动画状态。其中还有两个参数是Bool类型,用于判断动画是否播放完毕。在动画切换的Transition中,如果是由运动的动画切换到相对静止的动画,例如从Aim_Left切换到Shoot,需要开启Has Exit Time选项,这样就能平滑的过渡。如果其他不需要平滑过渡,需要立即切换状态的,则Transition不要选择Has Exit Time。
对于Damage和Reload这几个动画,需要添加一个Script,里面的OnStateExit函数需要设置animator.SetBool("E_IsDamage", false); 或者animator.SetBool("E_IsReload", false); 这样我们可以通过获取相应的参数来判断是否播放完毕。
检测玩家
当敌人没有发现玩家时,敌人处于随机搜索状态,这时敌人步行巡逻,当将要碰到障碍物时随机转向,如以下代码:
void Walk() {
float distance = DetectObstacle(transform.forward);
if (distance < obstacleRange) {
float angle = UnityEngine.Random.Range(-110, 110);
transform.Rotate(0, angle, 0);
}
transform.Translate(0, 0, speed * Time.deltaTime);
}
float DetectObstacle(Vector3 direction) {
Ray ray = new Ray(transform.position, direction);
RaycastHit hit;
if (Physics.SphereCast(ray, 0.75f, out hit)) {
return hit.distance;
} else {
return 9999.0f;
}
}
在敌人巡逻的过程中,将检测其前方目视范围内是否有发现玩家,我们可以使用Physics.OverlapSphere函数,将检测以敌人当前位置为圆心,以视野范围为半径的球体内满足一定条件的所有碰撞体的集合,然后判断检测到的物体是否处于敌人的视野范围内,并且中间没有障碍物阻挡,如果满足条件,意味着敌人发现了玩家。然后我们可以进一步扩展这个检测之后触发的状态,当敌人发现玩家,我们可以进一步判断,是要先进入瞄准状态还是直接进入射击状态。考虑到这个检测是每次Update都调用的,如果在上一帧调用时第一次检测到了玩家,那么应该先进入瞄准模式,当下一帧调用时同样检测到了玩家,这时就不需要再次进入瞄准模式了,只需等待一段时间后进入射击模式即可。因此我们可以在每次检测到玩家时保存其位置,和之前保存的位置做比较,如果这个位置之间相差的范围超过一个阈值,则需要重新瞄准,否则的话直接射击。
如果没有检测到玩家,那么也分两种情况,一种情况是之前曾经检测过玩家,并保存了玩家的位置,这意味着玩家躲避敌人的攻击,从视野范围中消失。这时敌人应该迅速跑到玩家之前的位置,继续搜索。另一种情况就是之前没有检测到玩家,这时应该继续步行随机搜索的状态。
以下是检测玩家的代码:
void DetectPlayer() {
Vector3 position = transform.position;
SpottedPlayers = Physics.OverlapSphere(transform.position, eyeviewDistance, LayerMask.GetMask("Character"));
for (int i=0;i<SpottedPlayers.Length;i++) {
Vector3 playerPosition = SpottedPlayers[i].transform.position;
if (Vector3.Angle(transform.forward, playerPosition - position) <= viewAngle/2) {
RaycastHit info = new RaycastHit();
int layermask = LayerMask.GetMask("Character", "Default");
Physics.Raycast(position, playerPosition - position, out info, eyeviewDistance, layermask);
if (info.collider == SpottedPlayers[i]) {
randomSearch = false;
playerDirection = playerPosition - prevPlayerPosition;
float distance = (playerDirection).magnitude;
if (distance > 0.5f || prevStatus != EnemyStatus.Shoot) {
prevPlayerPosition = playerPosition;
switch(UnityEngine.Random.Range(1, 4)) {
case 1:
_animator.SetTrigger("E_Aim");
status = EnemyStatus.Aim;
break;
case 2:
if (DetectObstacle(-transform.right) >= 3.0f) {
_animator.SetTrigger("E_Aim_L");
status = EnemyStatus.AimLeft;
} else {
_animator.SetTrigger("E_Aim");
status = EnemyStatus.Aim;
}
break;
case 3:
if (DetectObstacle(transform.right) >= 3.0f) {
_animator.SetTrigger("E_Aim_R");
status = EnemyStatus.AimRight;
} else {
_animator.SetTrigger("E_Aim");
status = EnemyStatus.Aim;
}
break;
}
}
_agent.isStopped = true;
if (status == EnemyStatus.Aim || status == EnemyStatus.AimLeft) {
corDiscoverPlayer = StartCoroutine(DiscoverPlayer());
} else {
StartCoroutine(DiscoverPlayer());
}
return;
}
}
}
// Can not detect player
if (randomSearch) {
if (status != EnemyStatus.Walk) {
_animator.SetTrigger("E_Walk");
status = EnemyStatus.Walk;
}
} else {
if (status != EnemyStatus.Sprint) {
_animator.SetTrigger("E_Sprint");
status = EnemyStatus.Sprint;
_agent.destination = prevPlayerPosition - playerPositionDelta;
_agent.isStopped = false;
_agent.speed = sprintSpeed;
}
}
if (corDiscoverPlayer != null) {
StopCoroutine(corDiscoverPlayer);
}
return;
}
在以上代码中,当检测到玩家时,敌人将进入瞄准或射击状态并启动一个协程运行DiscoverPlayer,采用协程的原因是,需要瞄准一段时间之后才能射击。在Shoot函数中,会判断当前的弹药,如果为0,则启动一个协程运行Reload。如果弹药不为0,则按照一定的射速来发射子弹。另外,考虑到游戏难度,特意在发射子弹时,给其位置增加一点随机的小的偏移量,这样即使敌人瞄准了玩家,也不一定能打准。其代码如下:
private IEnumerator DiscoverPlayer() {
if (status == EnemyStatus.Aim || status == EnemyStatus.AimLeft || status == EnemyStatus.AimRight) {
yield return new WaitForSecondsRealtime(aimPeriod);
}
Shoot();
}
private void Shoot() {
if (currentAmmo==0) {
corReload = StartCoroutine(Reload());
return;
}
long nowTS = DateTime.UtcNow.Ticks;
float shootInterval = (nowTS - shootTS)/10000000.0f;
status = EnemyStatus.Shoot;
if (shootInterval >= 0.5) {
TurnToPlayer();
_animator.SetTrigger("E_Shoot");
Vector3 bulletPosition = new Vector3(UnityEngine.Random.Range(-0.1f, 0.1f), UnityEngine.Random.Range(-0.1f, 0.1f), 0f) + muzzleTransform.position;
bullet = Instantiate(bulletPrefab, bulletPosition, transform.rotation);
Vector3 tempForward = prevPlayerPosition - muzzleTransform.position;
bullet.GetComponent<Rigidbody>().velocity = new Vector3(tempForward.x, 0f, tempForward.z) * 5.0f;
muzzleEffect.Effect(muzzleTransform.position);
shootTS = nowTS;
currentAmmo--;
}
}
private IEnumerator Reload() {
_animator.SetTrigger("E_Reload");
status = EnemyStatus.Reload;
_animator.SetBool("E_IsReload", true);
yield return new WaitUntil(()=>!_animator.GetBool("E_IsReload"));
currentAmmo = ammo;
status = EnemyStatus.Idle;
}
如果没有检测到玩家,则判断当前是否要随机搜索,如果是,则设置状态为Walk并进行搜索,如果不是,意味着之前是有检测到玩家的,则根据之前保存的玩家的位置,快跑到该位置,这里是采用Nav Mesh Agent的自动导航功能来实现。
射击敌人
在上一篇博客中,我们定义了玩家可以发射子弹,并且进行碰撞判断,那么当碰撞的物体是敌人时,应该扣除敌人的生命值。
修改之前的Bullet.cs程序的OnCollisionEnter,另外要把敌人预制件的Tag设置为Enemy
private void OnCollisionEnter(Collision collision) {
if (collision.gameObject.CompareTag("Enemy")) {
WanderingAI behavior = collision.gameObject.GetComponent<WanderingAI>();
behavior.TakeDamage(damage, transform.forward);
}
Destroy(this.gameObject);
}
在WanderingAI.cs中,增加一个TakeDamage函数和相应的其他函数,当受到伤害时,需要立即切换到受伤害的动画,之前如果有未完成的Reload或者DiscoverPlayer的协程,需要立即关闭。如果敌人的health为0,则切换到Death的动画,并在等待一段时间后销毁该Gameobject。如以下代码:
public void TakeDamage(int damage, Vector3 direction) {
if (health > 0) {
health -= damage;
attackDirection = -direction;
if (corDiscoverPlayer != null) {
StopCoroutine(corDiscoverPlayer);
corDiscoverPlayer = null;
}
if (corReload != null) {
StopCoroutine(corReload);
corReload = null;
}
if (health <= 0) {
StartCoroutine(Death());
} else {
Damage();
}
}
}
private void Damage() {
switch(UnityEngine.Random.Range(1, 4)) {
case 1:
_animator.SetTrigger("E_Damage_A");
break;
case 2:
_animator.SetTrigger("E_Damage_B");
break;
default:
_animator.SetTrigger("E_Damage_C");
break;
}
_animator.SetBool("E_IsDamage", true);
status = EnemyStatus.Damage;
}
private IEnumerator Death() {
switch(UnityEngine.Random.Range(1, 4)) {
case 1:
_animator.SetTrigger("E_Death_A");
break;
case 2:
_animator.SetTrigger("E_Death_B");
break;
default:
_animator.SetTrigger("E_Death_C");
break;
}
status = EnemyStatus.Death;
yield return new WaitForSecondsRealtime(6.0f);
Destroy(this.gameObject);
}
状态处理
定义了以上关键的检测玩家和瞄准射击的功能,以及敌人的不同状态后,我们可以在Update函数里面根据不同的状态来调用不同的功能。如以下代码:
void Update()
{
if (status == EnemyStatus.AimLeft || status == EnemyStatus.AimRight) {
AimMove();
} else if (status == EnemyStatus.Reload || status == EnemyStatus.Death) {
return;
} else if (status == EnemyStatus.Aim) {
TurnToPlayer();
} else if (status == EnemyStatus.Damage) {
TurnToDamage();
if (!_animator.GetBool("E_IsDamage")) {
status = EnemyStatus.Idle;
prevPlayerPosition = new Vector3(100f, 100f, 100f);
}
} else {
if (status == EnemyStatus.Walk) {
Walk();
}
if (status == EnemyStatus.Sprint) {
Sprint();
}
DetectPlayer();
}
prevStatus = status;
}
void TurnToPlayer() {
Vector3 targetDirection = (prevPlayerPosition - transform.position).normalized;
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(targetDirection), lerpValue*Time.deltaTime);
}
void TurnToDamage() {
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(attackDirection), lerpValue*Time.deltaTime);
}
void AimMove() {
TurnToPlayer();
float distance = 0f;
if (status == EnemyStatus.AimLeft) {
distance = DetectObstacle(-transform.right);
}
if (status == EnemyStatus.AimRight) {
distance = DetectObstacle(transform.right);
}
if (distance < 0.5) {
_animator.SetTrigger("E_Aim");
status = EnemyStatus.Aim;
} else {
if (status == EnemyStatus.AimLeft) {
transform.Translate(speed * Time.deltaTime * -Vector3.right);
}
if (status == EnemyStatus.AimRight) {
transform.Translate(speed * Time.deltaTime * Vector3.right);
}
}
}
void Sprint() {
if (_agent.remainingDistance < 3.0f) {
_agent.isStopped = true;
randomSearch = true;
_animator.SetTrigger("E_Walk");
status = EnemyStatus.Walk;
transform.forward = playerDirection;
}
}
解释一下以上的代码,当瞄准状态为AimLeft或者AimRight的时候,因为需要向旁边移动,所以需要先判断一下距离旁边障碍物的距离是否大于一个阈值,如果不是则把状态设置为静止的Aim。当状态为Sprint的时候,敌人将快速跑到玩家之前出现的位置,当距离这个位置很接近时,将停止并切换为随机搜索状态。
设置玩家生命值
当敌人发射子弹的时候,如果子弹碰撞到玩家,那么玩家的生命值需要扣减。我们需要在游戏界面上用显示当前的生命值,可以用一个血槽来显示。
在上一篇博客中,我们创建了一个名为GameScreen的Canvas预制体,在其上显示当前的弹药数。同样我们需要在这个预制体里面增加生命值的显示控件。在这个预制体上创建一个新的UI->Slider,命名为Health。设置其Scale X和Y都为3。
在Assests的Images目录下,导入一个新的Asset,选择一张16*16大小的白色图片,类型选择为UI Sprite。
点击Health slider下的background,颜色RGB都设置为0,Alpha设置为102。
点击Fill Area下的Fill,拖动之前导入的白色图片到Source Image,Color设置为红色。然后把Fill从Fill Area的子物体拖动到上一层,和Fill Area平级。
现在改动Health slider的Value,可以看到红色血量可以跟随Value值变动,但是可以看到Fill的区域和Background的区域没有对齐。 分别选择Fill和Background,点击Rect Transform的Stretch按钮,然后按着Alt键选择右下方的Stretch。这样两个区域就能对齐。
最后可以在添加一个十字Icon的Image在这个血槽的左边,以达到更美观的效果。
增加一个新的消息类型来传递当前的生命值给新加的Health UI组件。编辑GameMessage.cs文件,增加一个新的消息,如下:
public interface IGameMessage : IEventSystemHandler
{
...
void HealthMessage(float currentHealth);
}
编辑UIController.cs文件,增加对Health组件的引用和消息的处理
public class UIController : MonoBehaviour, IGameMessage
{
...
[SerializeField] Slider playerHealth;
public void HealthMessage(float health) {
playerHealth.value = health;
}
}
编辑PlayerController.cs文件,增加一个TakeDamage方法来扣减Health
[Header("Player")]
...
[Tooltip("Player health")]
public int PlayerHealth = 10;
...
private int _health;
private void Start()
{
...
_health = PlayerHealth;
ExecuteEvents.Execute<IGameMessage>(_gameManager, null, (x,y)=>x.HealthMessage(1.0f));
}
public void TakeDamage(int damage) {
_health -= damage;
float healthValue = (float) _health/PlayerHealth;
ExecuteEvents.Execute<IGameMessage>(_gameManager, null, (x,y)=>x.HealthMessage(healthValue));
}
最后就是修改bullet.cs,当判断碰撞的物体Tag是Player时,调用PlayerController的TakeDamage方法。
private void OnCollisionEnter(Collision collision) {
...
if (collision.gameObject.CompareTag("Player")) {
PlayerController player = collision.gameObject.GetComponent<PlayerController>();
player.TakeDamage(damage);
}
Destroy(this.gameObject);
}
现在生命值的处理就完成了。
玩家受到攻击的闪红处理
通常当玩家受到伤害时,屏幕都会用红色闪现一下,代表玩家掉血。现在继续完善添加这个效果。
在GameScreen下增加一个新的Canvas,名字为FeedbackFlashCanvas,然后在其下新增一个名为FlashImage的Canvas Group,Source Image的color为红色,Alpha为255。
在GameScreen新增一个Script组件,名字为FeedbackFlash.cs,代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class FeedbackFlash : MonoBehaviour
{
[Header("References")] [Tooltip("Image component of the flash")]
public Image FlashImage;
[Tooltip("CanvasGroup to fade the damage flash, used when recieving damage end healing")]
public CanvasGroup FlashCanvasGroup;
[Header("Damage")] [Tooltip("Color of the damage flash")]
public Color DamageFlashColor;
[Tooltip("Duration of the damage flash")]
public float DamageFlashDuration;
[Tooltip("Max alpha of the damage flash")]
public float DamageFlashMaxAlpha = 1f;
bool m_FlashActive;
float m_LastTimeFlashStarted = Mathf.NegativeInfinity;
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
if (m_FlashActive) {
float normalizedTimeSinceDamage = (Time.time - m_LastTimeFlashStarted) / DamageFlashDuration;
if (normalizedTimeSinceDamage < 1f)
{
float flashAmount = DamageFlashMaxAlpha * (1f - normalizedTimeSinceDamage);
FlashCanvasGroup.alpha = flashAmount;
}
else
{
FlashCanvasGroup.gameObject.SetActive(false);
m_FlashActive = false;
}
}
}
void ResetFlash()
{
m_LastTimeFlashStarted = Time.time;
m_FlashActive = true;
FlashCanvasGroup.alpha = 0f;
FlashCanvasGroup.gameObject.SetActive(true);
}
public void OnTakeDamage()
{
ResetFlash();
FlashImage.color = DamageFlashColor;
}
}
把刚才建的FlashImage拖动到Script的相应属性。Damage Flash Color设置为红色,Alpha为255,Flash Duration设置为0.2, Flash max alpha设置为0.7
回到UIController.cs这个脚本,在收到HealthMessage的时候调用FeedbackFlash的OnTakeDamage,这样就可以出现红色闪烁的效果。文章来源:https://www.toymoban.com/news/detail-840863.html
public class UIController : MonoBehaviour, IGameMessage
{
...
private FeedbackFlash _flash;
void Start()
{
_flash = GetComponent<FeedbackFlash>();
}
public void HealthMessage(float health) {
...
_flash.OnTakeDamage();
}
}
总结
以上就是对这个FPS游戏增加了具备智能行为的敌人的相关设计介绍,可以看到整个游戏的可玩性有了进一步的提高。下一步将进行关卡的设计,完善游戏场景。文章来源地址https://www.toymoban.com/news/detail-840863.html
到了这里,关于Unity开发一个FPS游戏之二的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!