Unity开发一个FPS游戏之二

在之前的文章中,我介绍了如何开发一个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,这样就可以出现红色闪烁的效果。

public class UIController : MonoBehaviour, IGameMessage
{
    ...
    private FeedbackFlash _flash;

    void Start()
    {
        _flash = GetComponent<FeedbackFlash>();
    }

    public void HealthMessage(float health) {
        ...
        _flash.OnTakeDamage();
    }
}

总结

以上就是对这个FPS游戏增加了具备智能行为的敌人的相关设计介绍,可以看到整个游戏的可玩性有了进一步的提高。下一步将进行关卡的设计,完善游戏场景。

  • 8
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

gzroy

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值