Unity实现Root Motion动画的Navigation自动导航

Root motion动画可以将角色的根节点(通常是角色的骨盆或脚部)的运动直接应用到游戏对象上,从而实现角色的自然移动和旋转,避免出现脚底打滑的现象。采用Root motion动画的游戏对象,通常是重载了onAnimatorMove函数,在脚本中来设置动画的速度,从而实现角色的移动。Unity的Navigation系统是一个用于实现游戏世界中的寻路和导航功能的组件。它允许游戏角色在复杂的游戏环境中自动找到从一点到另一点的最短路径。如果我们对采用Root motion动画的游戏对象应用Navigation,就会产生冲突,因为这两个组件都会尝试控制游戏对象的移动。有两个解决方式:

一是让动画跟随Navigation agent,通过获取agent.velocity来设置root motion的速度,从而大致匹配Agent的移动到动画的移动。这个方式最简单,但是可能会出现脚底打滑的现象。

二是让Agent跟随动画,关闭agent的updatePosition和updateRotation,通过计算agent的nextPosition和动画根节点的rootPosition的插值来进行控制。这种方式比方式一要复杂,但是效果更好。以下将以一个游戏场景为例子,详细介绍一下如何实现方式二。

游戏场景

在游戏中,对于NPC角色,当前设置了几个状态,分别是漫游Wander,瞄准Aim以及追踪Chase。NPC刚开始是漫游状态,在场景中自由地进行移动,这时是通过Root motion来驱动的。当NPC检测到玩家时,会进入瞄准状态。如果玩家进行躲避NPC,则NPC会进入追踪状态,自动跑到上一次发现玩家的位置,这时NPC是由Navigation来驱动,实现自动寻路。可见对于NPC是需要按照不同的场景来用Root motion或Navigation来驱动的。

Animator设置

建立一个名为Enemy的Animator,包含了两个状态,分别是Aim和Move,设置如下:

添加两个Trigger,分别为Aim和Walk,用于切换状态。定义一个名为Speed的Float变量,用于控制Root motion的移动速度。

Move状态是一个BlendTree,通过Speed来进行Idle,Walk,Run这三种动作的混合,改变Speed的值,可以看到人物动作的改变。

Unity Blendtree动画

改变Speed的值,可以看到人物的动作的改变。

实现漫游状态

现在给游戏对象增加一个名为EnemyAI的脚本文件,实现游戏对象在场景中漫游。代码如下:

public class EnemyAI : MonoBehaviour
{
    [Header("Enemy eyeview")]
    public float eyeviewDistance = 500.0f;
    public float viewAngle = 120f;
    public float obstacleRange = 3.0f;

    [Header("Enemy Property")]
    public float enemyHeight = 1.8f;
    public float enemyWidth = 1.2f;
    public float rotateSpeed = 2.0f;
    public float maxDetectDistance = 10f;

    private float _walkSpeed = 1.5f;
    private float _runSpeed = 3.5f;
    private Animator _animator;
    private Transform _transform;
    private float _currentSpeed;
    private float _targetSpeed;
    private float _statusDuration = 1.0f;
    private bool _isStatusTimerEnds = true;
    private bool _isDetectTimerEnds = true;

    [Flags]
    private enum EnemyStatus {
        Aim,
        Shoot,
        Wander,
        Chase
    }
    private EnemyStatus _enemyStatus;

    void Start()
    {
        _animator = GetComponent<Animator>();
        _rb = GetComponent<Rigidbody>();
        _transform = transform;
        _enemyStatus = EnemyStatus.Wander;
        rayCastOffset = new Vector3(0f, enemyHeight - 0.6f, 0f);
    }

    void Update()
    {
        if (_enemyStatus == EnemyStatus.Wander) {
            Wander();
        } 

        Detect();
    }

    private void OnAnimatorMove() {
        if (_currentSpeed != _targetSpeed) {
            if (Mathf.Abs(_currentSpeed - _targetSpeed) > 0.1) {
                _currentSpeed = Mathf.Lerp(_currentSpeed, _targetSpeed, 0.5f);
            } else {
                _currentSpeed = _targetSpeed;
            } 
        }
        _animator.SetFloat("Speed", _currentSpeed); 
        Vector3 speed = new Vector3(_animator.velocity.x, _rb.velocity.y, _animator.velocity.z);
        _rb.velocity = speed;
    }

    void Wander() {
        if (_isStatusTimerEnds) {
            _targetSpeed = UnityEngine.Random.Range(0, 2) == 0 ? 0f : _walkSpeed;
            _statusDuration = UnityEngine.Random.Range(5f, 10f);
            _isStatusTimerEnds = false;
            StartCoroutine(StatusTimer());
        }
    }

    IEnumerator StatusTimer() {
        float timer = 0;

        while (timer < _statusDuration) {
            timer += Time.deltaTime;
            yield return null; 
        }

        _isStatusTimerEnds = true;
    }
    
    IEnumerator DetectTimer() {
        float timer = 0;

        while (timer < _detectDuration) {
            timer += Time.deltaTime;
            yield return null; 
        }

        _isDetectTimerEnds = true;
    }

    float DetectObstacle(float angle) {
        RaycastHit hit;
        int layerMask = ~(1 << 8);
        Quaternion rotation = Quaternion.AngleAxis(angle, Vector3.up);
        bool hitDetect = Physics.BoxCast(
            _transform.position + rayCastOffset, 
            new Vector3(enemyWidth/2, rayCastOffset.y/2, 0.2f), 
            rotation * _transform.forward, 
            out hit,
            transform.rotation * rotation,
            maxDetectDistance,
            layerMask);
        if (hitDetect) {
            return hit.distance;
        } else {
            return 9999.0f;
        }
    }

    void Detect() {
        if (_isDetectTimerEnds) {
            _isDetectTimerEnds = false;
            StartCoroutine(DetectTimer());
            if (_currentSpeed > 0.2) {
                float distance = DetectObstacle(0f);
                if (distance < obstacleRange) {
                    float leftDistance = DetectObstacle(-90f);
                    float rightDistance = DetectObstacle(90f);
                    float startAngle = -45f;
                    float endAngle = -110f;
                    if (rightDistance < obstacleRange && leftDistance < obstacleRange) {
                        startAngle = 180f;
                        endAngle = 180.01f;
                    } else {
                        if (leftDistance < rightDistance) {
                            startAngle *= -1f;
                            endAngle *= -1f;
                        } 
                    }
                    _targetAngle = UnityEngine.Random.Range(startAngle, endAngle);
                    _currentAngle = 0f;
                }
            }

        } else {
            if (Mathf.Abs(_currentAngle - _targetAngle) > 0.1) {
                _prevAngle = _currentAngle;
                _currentAngle = Mathf.Lerp(_currentAngle, _targetAngle, rotateSpeed * Time.deltaTime);
                _transform.Rotate(0, _currentAngle - _prevAngle, 0);
            } else {
                _currentAngle = _targetAngle;
            }
        }
    }
}

以上代码大致逻辑是一开始设置状态为漫游状态,然后通过一个StatusTimer来计时,每次计时器到时就随机设置一个速度值。在onAnimatorMove函数中通过插值的方法来平滑改变速度值,并设置Animator的speed值,实现通过root motion动画来驱动游戏对象。另外还设置一个DetectTimer来计时,定期调用DetectObstacle函数来检测游戏对象行进方向上是否有障碍物,如有则进行随机转向。运行场景,可以看到游戏对象在场景中可以自由地进行漫步。

实现瞄准状态

现在我们要增加一个检测玩家的功能,让游戏对象在漫步过程中能发现玩家,并且进入瞄准状态。对以上代码做改动

public class EnemyAI : MonoBehaviour
{
    ...

    void Update()
    {

        if (_enemyStatus == EnemyStatus.Aim) {
            _prevAngle = _currentAngle;
            _currentAngle = Mathf.Lerp(_currentAngle, _targetAngle, rotateSpeed * Time.deltaTime);
            _transform.Rotate(0, _currentAngle - _prevAngle, 0);
            if (Mathf.Abs(_currentAngle - _targetAngle) < 0.5) {
                _currentAngle = _targetAngle;
            }
        } 
        ...
    }

    bool DetectPlayer() {
        bool findPlayer = false;
        Vector3 position = _transform.position + new Vector3(0f, enemyHeight-0.2f, 0f);
        _spottedPlayers = Physics.OverlapSphere(position, eyeviewDistance, LayerMask.GetMask("Character"));
        
        for (int i=0;i<_spottedPlayers.Length;i++) {
            Vector3 playerPosition = _spottedPlayers[i].transform.position;
            float angle = Vector3.SignedAngle(transform.forward, playerPosition - position, Vector3.up);
            if (angle <= viewAngle/2 && angle >= -viewAngle/2) {
                RaycastHit info;
                int layermask = LayerMask.GetMask("Character", "Default");
                Physics.Raycast(position, playerPosition - position, out info, eyeviewDistance, layermask);
                if (info.collider == _spottedPlayers[i]) {
                    if (_currentSpeed >= 0.1) {
                        _targetSpeed = 0f;
                        _currentSpeed = Mathf.Lerp(_currentSpeed, _targetSpeed, 0.75f);
                        _animator.SetFloat("Speed", _currentSpeed);
                    } else {
                        _prevPlayerPosition = playerPosition;
                        _foundPlayer = true;
                        _enemyStatus = EnemyStatus.Aim;
                        _animator.SetTrigger("Aim");
                        _currentAngle = 0;
                        _targetAngle = angle;
                        _currentSpeed = 0f;
                        findPlayer = true;
                    }
                }
            }
        }
        return findPlayer;
    } 

    void Detect() {
        if (_isDetectTimerEnds) {
            ...
            DetectPlayer()
        }
        ...
    }
}

在原来的Detect代码中增加一个对检测玩家的DetectPlayer的调用,当检测到玩家时,设置状态为Aim,并且设置Animator的Aim触发器,播放瞄准动作。

实现追踪状态

当游戏对象检测到玩家之后,玩家可以躲避游戏对象的瞄准,例如跑到一旁的障碍物隐藏。游戏对象找不到玩家,这时应该跑去之前发现玩家的地方,进行搜索。要实现这个功能,简单的一个想法是通过Unity的Navigation自动寻路功能来实现,让游戏对象自行寻路,而不是通过代码来控制。但是如前面提到的,Navigation和Root motion同时驱动游戏对象就会产生冲突,因此我们可以采取方式二来解决,即让Navigation agent跟随动画来移动。

给游戏对象增加一个Navmesh agent组件,然后对代码进行如下改动:

public class EnemyAI : MonoBehaviour
{
    ...
    private NavMeshAgent _agent;

    Vector2 smoothDeltaPosition = Vector2.zero;
    Vector2 velocity = Vector2.zero;

    void Start()
    {
        ...
        _agent = GetComponent<NavMeshAgent>();
        _agent.updatePosition = false;
        _agent.speed = _runSpeed;
    }


    void Update()
    {
        ...
        if (_enemyStatus == EnemyStatus.Chase) {
            Vector3 worldDeltaPosition = _agent.nextPosition - _transform.position;

            // Map 'worldDeltaPosition' to local space
            float dx = Vector3.Dot(_transform.right, worldDeltaPosition);
            float dy = Vector3.Dot(_transform.forward, worldDeltaPosition);
            Vector2 deltaPosition = new Vector2(dx, dy);

            // Low-pass filter the deltaMove
            float smooth = Mathf.Min(1.0f, Time.deltaTime/0.15f);
            smoothDeltaPosition = Vector2.Lerp (smoothDeltaPosition, deltaPosition, smooth);

            // Update velocity if time advances
            if (Time.deltaTime > 1e-5f)
                velocity = smoothDeltaPosition / Time.deltaTime;
            //Debug.LogFormat("Chase, speed:{0}", velocity.magnitude);
            _animator.SetFloat("Speed", velocity.magnitude); 
            _transform.LookAt(_agent.steeringTarget + transform.forward);

            if (_agent.remainingDistance < _agent.radius) {
                _enemyStatus = EnemyStatus.Wander;
            }
        }
        
        Detect()
    }

    private void OnAnimatorMove() {
        if (_enemyStatus == EnemyStatus.Chase) {
            _transform.position = _agent.nextPosition;
        }
        else {
            if (_currentSpeed != _targetSpeed) {
                if (Mathf.Abs(_currentSpeed - _targetSpeed) > 0.1) {
                    _currentSpeed = Mathf.Lerp(_currentSpeed, _targetSpeed, 0.5f);
                } else {
                    _currentSpeed = _targetSpeed;
                } 
            }
            _animator.SetFloat("Speed", _currentSpeed); 
            Vector3 speed = new Vector3(_animator.velocity.x, _rb.velocity.y, _animator.velocity.z);
            _rb.velocity = speed;
        }
    }


    void Detect() {
        if (_isDetectTimerEnds) {
            ...
            //DetectPlayer();
            if (!DetectPlayer()) {
                if (_foundPlayer) {
                    _foundPlayer = false;
                    _agent.nextPosition = _transform.position;
                    _agent.destination = _prevPlayerPosition;
                    _enemyStatus = EnemyStatus.Chase;
                    _animator.SetTrigger("Walk");
                    _targetSpeed = _runSpeed;
                }
            } 
        ...
    }
}

以上的代码值得详细讲解一下,在Start函数中,设置了agent的updatePosition为false,即不让agent来移动游戏对象,同时设置agent的最大速度不要超过runspeed。在Update函数中,判断如果当前是Chase状态,那么计算agent的nextPosition与当前位置的差值,然后计算在deltaTime时间间隔中,需要以什么速度来移动,并设置animator的speed,使得游戏对象的动作与移动速度保持同步,不会出现脚底打滑的现象。在onAnimatorMove函数中,通过设置transform的位置为agent的nextPosition来实现移动。在Detect函数中进行修改,如果之前发现玩家,但现在没有发现,则进入Chase状态, 把之前发现玩家的位置设置为agent的目的地,让agent来进行自动寻路。注意在进入Chase状态时需要更新一下agent的nextPosition为当前游戏对象的位置,因为我们之前设置了updatePostion为false,所以agent的当前位置并不同步。

实现效果

Root Motion动画与Navigation结合

FPS教程

另外我之前也写了一系列文章介绍如何实现FPS游戏,有兴趣的可以了解一下

Unity开发一个FPS游戏_unity 模仿开发fps 游戏-优快云博客

Unity开发一个FPS游戏之二_unity 模仿开发fps 游戏-优快云博客

Unity开发一个FPS游戏之三-优快云博客

Unity开发一个FPS游戏之四_unity fps-优快云博客

Unity开发一个FPS游戏之五-优快云博客

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

gzroy

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

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

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

打赏作者

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

抵扣说明:

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

余额充值