NPC Following Player State

[I was gone because the laptop needed some rest, because I accidentally kept the power mode on max for a very long time :sweat_smile:]

hello all, and @Brian_Trotter I’m just tagging you here because I’ll need you, or @bixarrio 's help for this one, so here’s what this topic is about:

How do I get the NPC to “follow” the player? Essentially, I have a function in my game that gets NPCs to join the player’s team through dialogue. Without too much context, it uses the DialogueTrigger.cs script from the RPG Course Series, and then does other things to ensure the NPC safely joins the player, but that’s not the point today

Where I handle this, I invoke a new event system I created, from a script called ‘AllyFollowPlayerManager.cs’ script, which is a simple event script that looks like this:

using System;
using UnityEngine;

namespace RPG.Combat {

public class AllyFollowPlayerManager : MonoBehaviour
{
    public static event Action OnPlayerAllyAdded;
    public static event Action OnPlayerAllyRemoved;

    public static void NotifyPlayerAllyAdded() 
    {
        OnPlayerAllyAdded?.Invoke();
    }

    public static void NotifyPlayerAllyRemoved() 
    {
        OnPlayerAllyRemoved?.Invoke();
    }
}

}

Now, I invite NPCs through an ‘AIAggroGroupSwitcher.cs’ script that I created, which is a little complex but fairly self-explanatory (all that matters is after the 9th debug in each of the inviting and expelling functions):

using UnityEngine;
using RPG.States.Enemies;
using RPG.Respawnables;
using RPG.Dialogue;

namespace RPG.Combat {

public class AIAggroGroupSwitcher : MonoBehaviour
{
    // This script is placed on NPCs, and is essentially responsible for
    // allowing them to enter and exit the player's AggroGroup, through
    // dialogue:

    private AggroGroup playerAggroGroup;

    [Tooltip("This is the conversation you would get if you want to invite an NPC to your AggroGroup (used in 'AIConversant.cs', setup in 'RespawnManager.cs')")]
    [SerializeField] private Dialogue.Dialogue NPCOutOfPlayerGroupInviteDialogue;
    [Tooltip("This is the conversation you would get if you want to kick out an NPC ally from your AggroGroup (used in 'AIConversant.cs', setup in 'RespawnManager.cs')")]
    [SerializeField] private Dialogue.Dialogue NPCInPlayerGroupKickoutDialogue;

    private void Awake()
    {
        playerAggroGroup = GameObject.Find("PlayerAggroGroup").GetComponent<AggroGroup>();
    }

    public void AddNPCToPlayerAggroGroup()
    {
        Debug.Log($"{gameObject.name} added to player AggroGroup - 1");

        // This function essentially deletes the Enemy 
        // that calls it from his own AggroGroup, and adds him to
        // the Player's AggroGroup, and is meant to be called 
        // at the end of the dialogues that lead to that action:
        if (playerAggroGroup.GetGroupMembers().Contains(this.GetComponent<EnemyStateMachine>())) return; // if this script holder is part of the Player's AggroGroup, don't add him again
        Debug.Log($"{gameObject.name} added to player AggroGroup - 2");

        // First, Delete him from his own AggroGroup:
        this.GetComponent<EnemyStateMachine>().GetComponentInParent<RespawnManager>().GetComponentInParent<AggroGroup>().RemoveFighterFromGroup(this.GetComponent<EnemyStateMachine>());
        Debug.Log($"{gameObject.name} added to player AggroGroup - 3");
        // And now, add him to the player's AggroGroup:
        playerAggroGroup.AddFighterToGroup(this.GetComponent<EnemyStateMachine>());
        Debug.Log($"{gameObject.name} added to player AggroGroup - 4");
        // Set the RespawnManager's Dialogue accordingly:
        GetComponentInParent<RespawnManager>().SetCurrentConversation(GetNPCInPlayerGroupKickOutDialogue());
        Debug.Log($"{gameObject.name} added to player AggroGroup - 5");
        // Switch the AggroGroup of the enemy holding this script, to the Player's AggroGroup:
        GetComponentInParent<RespawnManager>().SetAggroGroup(playerAggroGroup);
        Debug.Log($"{gameObject.name} added to player AggroGroup - 6");
        // Set the AIConversant's Dialogue accordingly (this is the core value that determines who holds the conversation):
        GetComponent<AIConversant>().SetDialogue(GetNPCInPlayerGroupKickOutDialogue());
        Debug.Log($"{gameObject.name} added to player AggroGroup - 7");
        // Set the 'AggroGroup' within the 'EnemyStateMachine' as well, otherwise you're getting a NullReferenceException:
        GetComponent<EnemyStateMachine>().SetAggroGroup(playerAggroGroup);
        Debug.Log($"{gameObject.name} added to player AggroGroup - 8");
        // In 'EnemyStateMachine.cs', tick 'isPlayerAlly' (used in 'PlayerRangerAimingState.cs'). No tag resets, because that'll screw the Ranger Aiming State up (it's taken care of in 'PlayerRangerAimingState.cs' and 'PlayerRangerFiringState.cs')
        GetComponent<EnemyStateMachine>().SetIsPlayerAlly(true);
        // Notify the 'AllyFollowPlayerManager' added event, so enemies can follow the player around the game map, from 'EnemyStateMachine.cs'
        Debug.Log($"{gameObject.name} added to player AggroGroup - 9");
        AllyFollowPlayerManager.NotifyPlayerAllyAdded();
    }

    public void RemoveNPCFromPlayerAggroGroup()
    {
        Debug.Log($"{gameObject.name} kicked out of player AggroGroup - 1");
        // Similar to how 'AddNPCToPlayerAggroGroup()' adds the script holder to
        // the player's AggroGroup, this function is supposed to delete the script
        // holder from the Player's AggroGroup:

        // The following line is correct, but for the system to fully work as expected, the Saving and Restoring System for 'AggroGroup.cs' must be developed!
        if (!playerAggroGroup.GetGroupMembers().Contains(this.GetComponent<EnemyStateMachine>())) return; // if this script holder is no longer part of the Player's AggroGroup, don't try deleting him again
        Debug.Log($"{gameObject.name} kicked out of player AggroGroup - 2");

        // First, Add the NPC back to his original Individual AggroGroup:
        this.GetComponent<EnemyStateMachine>().GetComponentInParent<RespawnManager>().GetComponentInParent<AggroGroup>().AddFighterToGroup(this.GetComponent<EnemyStateMachine>());
        Debug.Log($"{gameObject.name} kicked out of player AggroGroup - 3");
        // Remove the NPC from the Player's AggroGroup:
        playerAggroGroup.RemoveFighterFromGroup(this.GetComponent<EnemyStateMachine>());
        Debug.Log($"{gameObject.name} kicked out of player AggroGroup - 4");
        // Set the RespawnManager's Dialogue accordingly:
        GetComponentInParent<RespawnManager>().SetCurrentConversation(GetNPCOutOfPlayerGroupInviteDialogue());
        Debug.Log($"{gameObject.name} kicked out of player AggroGroup - 5");
        // Switch the AggroGroup of the enemy holding this script, back to original AggroGroup:
        GetComponentInParent<RespawnManager>().SetAggroGroup(GetComponentInParent<AggroGroup>());
        Debug.Log($"{gameObject.name} kicked out of player AggroGroup - 6");
        // Set the AIConversant's Dialogue accordingly (this is the core value that determines who holds the conversation):
        GetComponent<AIConversant>().SetDialogue(GetNPCOutOfPlayerGroupInviteDialogue());
        Debug.Log($"{gameObject.name} kicked out of player AggroGroup - 7");
        // Set the 'AggroGroup' within the 'EnemyStateMachine' as well, otherwise you're getting a NullReferenceException:
        GetComponent<EnemyStateMachine>().SetAggroGroup(GetComponentInParent<RespawnManager>().GetComponentInParent<AggroGroup>());
        Debug.Log($"{gameObject.name} kicked out of player AggroGroup - 8");
        // In 'EnemyStateMachine.cs', untick 'isPlayerAlly' (used in 'PlayerRangerAimingState.cs'). No tag resets, because that'll screw the Ranger Aiming State up (it's taken care of in 'PlayerRangerAimingState.cs' and 'PlayerRangerFiringState.cs')
        GetComponent<EnemyStateMachine>().SetIsPlayerAlly(false);
        // Notify the 'AllyFollowPlayerManager' removed event, so enemies can stop following the player around the game map, from 'EnemyStateMachine.cs'
        Debug.Log($"{gameObject.name} kicked out of player AggroGroup - 9");
        AllyFollowPlayerManager.NotifyPlayerAllyRemoved();
    }

    public Dialogue.Dialogue GetNPCOutOfPlayerGroupInviteDialogue() 
    {
        return NPCOutOfPlayerGroupInviteDialogue;
    }

    public void SetNPCOutOfPlayerGroupInviteDialogue(Dialogue.Dialogue dialogue) 
    {
        this.NPCOutOfPlayerGroupInviteDialogue = dialogue;
    }

    public Dialogue.Dialogue GetNPCInPlayerGroupKickOutDialogue() 
    {
        return NPCInPlayerGroupKickoutDialogue;
    }

    public void SetNPCInPlayerGroupKickOutDialogue(Dialogue.Dialogue dialogue) 
    {
        this.NPCInPlayerGroupKickoutDialogue = dialogue;
    }

}

}

and the subscriptions for the events mentioned above are used in ‘EnemyStateMachine.cs’:

// in 'Start()':

        // TEST - Follow the player if you're an ally, when the scene starts
        if (isPlayerAlly) // boolean that indicates if the NPC is an ally with the player or not, switched through dialogue or set in the inspector, depending on your needs
        {
            StartFollowingPlayer();
        }
        else 
        {
            StopFollowingPlayer();
        }

// in 'OnEnable()' (the Unity Function):
        // Get Player Ally NPCs to follow or unfollow the player, depending on 'isPlayerAlly'
        AllyFollowPlayerManager.OnPlayerAllyAdded += StartFollowingPlayer;
        AllyFollowPlayerManager.OnPlayerAllyRemoved += StopFollowingPlayer;

// in 'OnDisable()' (the Unity Function):
        // Get Player Ally NPCs to follow or unfollow the player, depending on 'isPlayerAlly'
        AllyFollowPlayerManager.OnPlayerAllyAdded -= StartFollowingPlayer;
        AllyFollowPlayerManager.OnPlayerAllyRemoved -= StopFollowingPlayer;

and then I created the ‘StartFollowingPlayer’ and ‘StopFollowingPlayer’, again in ‘EnemyStateMachine.cs’:

    private void StartFollowingPlayer() 
    {
        Debug.Log($"{this.gameObject.name} will start following the player");
        SwitchState(new EnemyFollowingPlayerState(this));
    }

    private void StopFollowingPlayer() 
    {
        Debug.Log($"{this.gameObject.name} will not be following the player");
        // SwitchState(new EnemyIdleState(this));
    }

So, my question is, is this approach usable? And more importantly, what do I write in ‘EnemyFollowingPlayerState.cs’? I was thinking of copy-pasting my ‘EnemyChasingState.cs’ and then swapping the ‘EnemyAttackingState.cs’ to ‘EnemyIdleState.cs’ in the end, but I’m not sure.

The idea is simple:

  • if the player left this NPC behind, beyond a specific distance, and they’re an ally, they will wait in idle state unless the player returns to them. I’ll also throw in a checkpoint where we check for patrol paths in ‘EnemyIdleState.cs’. If it’s an ally, you continue (not sure how to do that just yet)
  • if they’re in the ‘specific distance’ to the player, they will follow the player (I think using the Chasing here would be a good idea) until they are close enough to him, and once they’re close, simply go to idle until the player does something

This is an additional idea from the top of my head. It’s not necessary though, it’s just from the top of my head:

  • If the player runs, get the NPC to run as well, at a fraction of the player’s speed. I control running through ‘stateMachine.InputReader.IsSpeeding’ (it’s called ‘IsSpeeding’ because it’s a generic variable that’s expected to work for multiple operations)

For the record, this is my current attempt at programming an ‘EnemyFollowingPlayerState.cs’ script:

using System.Collections;
using System.Collections.Generic;
using RPG.States.Enemies;
using UnityEngine;

public class EnemyFollowingPlayerState : EnemyBaseState
{
    public EnemyFollowingPlayerState(EnemyStateMachine stateMachine) : base (stateMachine) {}

    private float stoppingDistance;

    public override void Enter()
    {
        stateMachine.Animator.CrossFadeInFixedTime(FreeLookBlendTreeHash, stateMachine.CrossFadeDuration);
        stoppingDistance = stateMachine.PlayerAllyStoppingDistance;
        stateMachine.Agent.stoppingDistance = stoppingDistance;
    }

    public override void Tick(float deltaTime)
    {
        if (!IsInFollowRange()) 
        {
            stateMachine.TriggerOnPlayerOutOfChaseRange();
            stateMachine.SwitchState(new EnemyIdleState(stateMachine));
            return;
        }

        Vector3 lastPosition = stateMachine.transform.position;

        if (IsInStoppingDistance()) 
        {
            stateMachine.SwitchState(new EnemyIdleState(stateMachine));
            return;
        }
        else 
        {
            MoveToPlayer(deltaTime);
        }

        UpdateMovementAnimation(deltaTime, lastPosition);
    }

    public override void Exit()
    {
        if (!stateMachine.Agent.enabled) 
        {
            stateMachine.Agent.enabled = true;
            stateMachine.Agent.ResetPath();
            stateMachine.Agent.velocity = Vector3.zero;
            stateMachine.Agent.enabled = false;
        }
        else 
        {
            stateMachine.Agent.ResetPath();
            stateMachine.Agent.velocity = Vector3.zero;
        }
    }

    private bool IsInFollowRange() 
    {
        return Vector3.SqrMagnitude(stateMachine.transform.position - stateMachine.Player.transform.position) <= stateMachine.PlayerAllyFollowingRange * stateMachine.PlayerAllyFollowingRange;
    }

    private bool IsInStoppingDistance() 
    {
        return Vector3.SqrMagnitude(stateMachine.Player.transform.position - stateMachine.transform.position) <= stoppingDistance * stoppingDistance;
    }

    private void MoveToPlayer(float deltaTime) 
    {
        if (!stateMachine.Agent.enabled) stateMachine.Agent.enabled = true;

        stateMachine.Agent.destination = stateMachine.Player.transform.position;

        Vector3 desiredVelocity = stateMachine.Agent.desiredVelocity.normalized;

        stateMachine.Agent.velocity = desiredVelocity;
        stateMachine.Agent.nextPosition = stateMachine.transform.position;
        Move(desiredVelocity * stateMachine.MovementSpeed, deltaTime);

        // Face the player
        FaceTarget(stateMachine.Player.transform.position, deltaTime);
    }

    private void UpdateMovementAnimation(float deltaTime, Vector3 lastPosition) 
    {
        Vector3 deltaMovement = lastPosition - stateMachine.transform.position;
        float deltaMagnitude = deltaMovement.magnitude;

        if (deltaMagnitude > 0) 
        {
            // If the game is not paused
            FaceTarget(stateMachine.transform.position - deltaMovement, deltaTime);
            float grossSpeed = deltaMagnitude / deltaTime;
            stateMachine.Animator.SetFloat(FreeLookSpeedHash, grossSpeed / stateMachine.MovementSpeed, stateMachine.AnimatorDampTime, deltaTime);
        }
        else 
        {
            // If the game is not paused
            FaceTarget(stateMachine.Player.transform.position, deltaTime);
            stateMachine.Animator.SetFloat(FreeLookSpeedHash, 0f);
        }
    }

    protected override void FaceTarget(Vector3 targetPosition, float deltaTime) 
    {
        Vector3 direction = (targetPosition - stateMachine.transform.position).normalized;
        if (direction == Vector3.zero) 
        {
            return;
        }
        Quaternion targetRotation = Quaternion.LookRotation(direction, Vector3.up);
        stateMachine.transform.rotation = Quaternion.Slerp(stateMachine.transform.rotation, targetRotation, stateMachine.FreeLookRotationSpeed * deltaTime);
    }
}

If you got any better approaches, please be my guest. This one is event-based, and is a little difficult to work with for this case scenario (I think if I delete my current Ally NPCs and replace them with my most advanced NPC, the issue would go away. Not sure tbh)

Edit 1: Instead of using it in ‘EnemyStateMachine.cs’, I tried subscribing in Dwell, Idle and Patrol State, but it made things worse, so I reversed it. I’m back to trying new stuff out again

This is pretty much the focus of any following state. In fact, it’s also the core of how things work in our stateless version of the game in the RPG course. Following and chasing are the same action, just with different actions at the end of the chain.

@Brian_Trotter (I’ll tag you when I respond since this is a Talk rather than an ask. If you see it, and I don’t need to tag you, let me know) My code gets a little buggy with my current attempt. In other words, the animations act like sliding sometimes or things go wrong, but… the approach in the very first comment (the thing right underneath the title) is feasible at least? I’m still trying to find ways for how to make this actually work.

I know it’s simple, I just can’t get it to act right :sweat_smile: (Especially that I decided that allies will have open fire, this makes things slightly more complex, because now it needs to classify, when they are allies, whether it should go ahead and attack (if the player attacked the NPC for example), or follow and remain idle in the end)

Fairly common with these sorts of follows… My sparetime/reference game right now is DragonsAge: Inquisition, and the follow party routinely slides, gets stuck (with magic resets after they get too far away), etc.

@Brian_Trotter wanna hear a bit of a joke? I just attempted to call the function from ‘AIAggroGroupSwitcher.cs’, where the team handling happens, and let’s just say inviting the NPC gets EVERYONE in my radius, EVERYONE EXCEPT THE GUY I’M TRYING TO INVITE, to follow me like they’re part of my team, xD. I need to fix that, but first of all I need to get the pursuit mechanic to function correctly first

Ah well, welcome to game dev I guess

how was Spellborn hunter, hit or flop?

If you have some time to spare though, can you please check this script for me?:

using RPG.States.Enemies;
using RPG.States.Player;
using UnityEngine;
using RPG.Skills;
using UnityEngine.AI;

public class EnemyFollowingPlayerState : EnemyBaseState
{
    public EnemyFollowingPlayerState(EnemyStateMachine stateMachine) : base (stateMachine) {}

    private float followingRange;

    public override void Enter()
    {
        stateMachine.Animator.CrossFadeInFixedTime(FreeLookBlendTreeHash, stateMachine.CrossFadeDuration);
        followingRange = stateMachine.PlayerAllyFollowingRange;
        followingRange *= followingRange;
    }

    public override void Tick(float deltaTime)
    {
        if (!IsInChaseRange() && !IsAggrevated())
        {
            stateMachine.TriggerOnPlayerOutOfChaseRange();
            stateMachine.SwitchState(new EnemyIdleState(stateMachine));
            return;
        }

        // If you're angry, with a Last Attacker, 
        // and you're in chase range, then you're here

        Vector3 lastPosition = stateMachine.transform.position;

        if (IsInFollowingRange())
        {
            if (stateMachine.LastAttacker != null)
            {
                if (stateMachine.Fighter.GetCurrentWeaponConfig().GetSkill() == Skill.Ranged || stateMachine.Fighter.GetCurrentWeaponConfig().GetSkill() == Skill.Magic || stateMachine.Fighter.GetCurrentWeaponConfig().GetSkill() == Skill.Attack)
                {
                    Debug.Log($"{stateMachine.gameObject.name} is wielding a weapon, and LastAttacker is {stateMachine.LastAttacker.gameObject.name}");
                    if (!HasLineOfSight())
                    {
                        stateMachine.Animator.SetFloat(FreeLookSpeedHash, 1f, stateMachine.AnimatorDampTime, deltaTime);
                        Debug.Log($"{stateMachine.gameObject.name} has no line of sight of {stateMachine.LastAttacker.gameObject.name}");

                        if (CanMoveTo(stateMachine.LastAttacker.transform.position))
                        {
                            Debug.Log($"{stateMachine.gameObject.name} is attempting to move to {stateMachine.LastAttacker.gameObject.name}");
                            Vector3 movementDirection = stateMachine.Agent.desiredVelocity.normalized;
                            FaceMovementDirection(movementDirection, deltaTime);
                            MoveToTarget(deltaTime);
                            return;
                        }
                    }
                }
            }

            if (!stateMachine.CooldownTokenManager.HasCooldown("Attack"))
            {
                Debug.Log($"{stateMachine.gameObject.name} is exiting chase state to attack state");
                stateMachine.SwitchState(new EnemyIdleState(stateMachine));
                return;
            }
            else
            {
                Move(deltaTime);
                stateMachine.Animator.SetFloat(FreeLookSpeedHash, 0);
                if (stateMachine.LastAttacker == null)
                {
                    stateMachine.SwitchState(new EnemyIdleState(stateMachine));
                }
                else
                {
                    FaceTarget(stateMachine.LastAttacker.transform.position, deltaTime);
                }
                return;
            }
        }
        else
        {
            MoveToTarget(deltaTime);
        }

        UpdateMovementAnimation(deltaTime, lastPosition);
    }

    public override void Exit()
    {
        
    }

    private bool IsInFollowingRange()
    {
        if (stateMachine.LastAttacker == null)
        {
            return Vector3.SqrMagnitude(stateMachine.Player.transform.position - stateMachine.transform.position) <= followingRange;
        }
        else
        {
            return Vector3.SqrMagnitude(stateMachine.LastAttacker.transform.position - stateMachine.transform.position) <= followingRange;
        }
    }

    private void UpdateMovementAnimation(float deltaTime, Vector3 lastPosition)
    {
        Vector3 deltaMovement = lastPosition - stateMachine.transform.position;
        float deltaMagnitude = deltaMovement.magnitude;

        if (deltaMagnitude > 0)
        {
            // if the game is not paused:
            FaceTarget(stateMachine.transform.position - deltaMovement, deltaTime);
            float grossSpeed = deltaMagnitude / deltaTime;
            stateMachine.Animator.SetFloat(FreeLookSpeedHash, grossSpeed / stateMachine.MovementSpeed, stateMachine.AnimatorDampTime, deltaTime);
        }

        else
        {
            // if the game is paused:
            FaceTarget(stateMachine.Player.transform.position, deltaTime);
            stateMachine.Animator.SetFloat(FreeLookSpeedHash, 1f);
        }
    }

    private void MoveToTarget(float deltaTime)
    {
        if (!stateMachine.Agent.enabled) stateMachine.Agent.enabled = true;

        stateMachine.Agent.destination = stateMachine.LastAttacker != null ? stateMachine.LastAttacker.transform.position : stateMachine.Player.transform.position;

        Vector3 desiredVelocity = stateMachine.Agent.desiredVelocity.normalized;

        stateMachine.Agent.velocity = desiredVelocity;
        stateMachine.Agent.nextPosition = stateMachine.transform.position;
        Move(desiredVelocity * stateMachine.MovementSpeed, deltaTime);
    }

    private void FaceMovementDirection(Vector3 forward, float deltaTime)
    {
        if (forward == Vector3.zero) return;
        Quaternion desiredRotation = Quaternion.LookRotation(forward, Vector3.up);
        stateMachine.transform.rotation = Quaternion.Slerp(stateMachine.transform.rotation, desiredRotation, stateMachine.FreeLookRotationSpeed * deltaTime);
    }

    private bool HasLineOfSight()
    {
        Debug.Log($"HasLineOfSight has been called");
        Ray ray = new Ray(stateMachine.transform.position + Vector3.up * 1.6f, stateMachine.LastAttacker.transform.position - stateMachine.transform.position);
        if (Physics.Raycast(ray, out RaycastHit hit, Vector3.Distance(stateMachine.transform.position, stateMachine.LastAttacker.transform.position), stateMachine.LayerMask))
        {
            return false;
        }
        return true;
    }

    private bool CanMoveTo(Vector3 destination)
    {
        var path = new NavMeshPath();
        var hasPath = NavMesh.CalculatePath(stateMachine.transform.position, destination, NavMesh.AllAreas, path);
        if (!hasPath) return false;
        if (path.status != NavMeshPathStatus.PathComplete) return false;
        if (GetPathLength(path) > stateMachine.maxNavPathLength) return false;
        return true;
    }

    private float GetPathLength(NavMeshPath path)
    {
        float total = 0;
        if (path.corners.Length < 2) return total;

        for (int i = 0; i < path.corners.Length - 1; i++)
        {
            total += Vector3.Distance(path.corners[i], path.corners[i + 1]);
        }
        return total;
    }
}

Again, to call it, have a look at the original post of this thread (this is a modified version of my chasing state, which still has a little bit of a minor mess, but for the most part it’s fine I guess)

Move should go before the telling the Agent.

Otherwise things look right, at least as far as I can tell outside of a working environment.

@Brian_Trotter ahh… something is so wrong here, because when he’s so close, he still wants to walk into the player

I thought I can rush this one out in a day and move on to the next big thing :sweat_smile:

ANYWAY for the time being I kept it in a bit of a stable position and isolated the test character, so I can identify where the issue is coming from.

I’ll try again tomorrow, for now I have to go get some rest

For now, here’s a good attempt at trying to get the following to work. It’s still slightly flawed, but for the time being it somewhat works:

using UnityEngine;
using UnityEngine.AI;

namespace RPG.States.Enemies
{
    public class EnemyFollowingPlayerState : EnemyBaseState
    {
        public EnemyFollowingPlayerState(EnemyStateMachine stateMachine) : base(stateMachine) { }

        private float detectionRange;
        private float lastSeenTime;
        private float searchDuration = 3.0f; // Time to wait before going idle when the player is lost

        public override void Enter()
        {
            stateMachine.Animator.CrossFadeInFixedTime(FreeLookBlendTreeHash, stateMachine.CrossFadeDuration);
            detectionRange = stateMachine.PlayerChasingRangedSquared;
            lastSeenTime = 0.0f; // Reset the last seen timer
        }

        public override void Tick(float deltaTime)
        {
            if (!IsPlayerInRange() && !IsAggrevated())
            {
                lastSeenTime += deltaTime;

                // If the player is not seen for the duration of searchDuration, switch to idle
                if (lastSeenTime >= searchDuration)
                {
                    stateMachine.TriggerOnPlayerOutOfChaseRange();
                    stateMachine.SwitchState(new EnemyIdleState(stateMachine));
                }

                return;
            }

            // If the player is within range and visible, reset the lastSeenTime
            lastSeenTime = 0.0f;

            Vector3 lastPosition = stateMachine.transform.position;

            if (!HasLineOfSight())
            {
                // Try to move to the last known position if no line of sight
                if (CanMoveTo(stateMachine.Player.transform.position))
                {
                    MoveToTarget(deltaTime);
                }
                else
                {
                    // If unable to move directly, use NavMesh to find a path
                    MoveAroundObstacles(deltaTime);
                }
            }
            else
            {
                // Directly follow the player
                FollowPlayer(deltaTime);
            }

            // Update the movement animation based on speed
            UpdateMovementAnimation(deltaTime, lastPosition);
        }

        public override void Exit()
        {
            if (!stateMachine.Agent.enabled)
            {
                stateMachine.Agent.enabled = true;
                stateMachine.Agent.ResetPath();
                stateMachine.Agent.velocity = Vector3.zero;
                stateMachine.Agent.enabled = false;
            }
            else
            {
                stateMachine.Agent.ResetPath();
                stateMachine.Agent.velocity = Vector3.zero;
            }
        }

        private bool IsPlayerInRange()
        {
            // Calculate the squared distance for optimization
            float sqrDistanceToPlayer = Vector3.SqrMagnitude(stateMachine.Player.transform.position - stateMachine.transform.position);

            // Check if the player is within the detection range
            return sqrDistanceToPlayer <= detectionRange;
        }

        private void FollowPlayer(float deltaTime)
        {
            if (!stateMachine.Agent.enabled) stateMachine.Agent.enabled = true;

            // Set the player's position as the destination
            stateMachine.Agent.destination = stateMachine.Player.transform.position;

            // Calculate desired velocity for movement
            Vector3 desiredVelocity = stateMachine.Agent.desiredVelocity.normalized;

            // Move the enemy towards the player
            stateMachine.Agent.velocity = desiredVelocity;
            stateMachine.Agent.nextPosition = stateMachine.transform.position;
            Move(desiredVelocity * stateMachine.MovementSpeed, deltaTime);

            // Face movement direction smoothly
            FaceMovementDirection(desiredVelocity, deltaTime);
        }

        private void MoveToTarget(float deltaTime)
        {
            if (!stateMachine.Agent.enabled) stateMachine.Agent.enabled = true;

            stateMachine.Agent.destination = stateMachine.Player.transform.position;

            Vector3 desiredVelocity = stateMachine.Agent.desiredVelocity.normalized;

            stateMachine.Agent.velocity = desiredVelocity;
            stateMachine.Agent.nextPosition = stateMachine.transform.position;
            Move(desiredVelocity * stateMachine.MovementSpeed, deltaTime);

            // Smoothly face the movement direction
            FaceMovementDirection(desiredVelocity, deltaTime);
        }

        private bool HasLineOfSight()
        {
            Ray ray = new Ray(stateMachine.transform.position + Vector3.up * 1.6f, stateMachine.Player.transform.position - stateMachine.transform.position);
            if (Physics.Raycast(ray, out RaycastHit hit, Vector3.Distance(stateMachine.transform.position, stateMachine.Player.transform.position), stateMachine.LayerMask))
            {
                // Return false if something is blocking the line of sight
                return hit.transform == stateMachine.Player.transform;
            }
            return true;
        }

        private bool CanMoveTo(Vector3 destination)
        {
            var path = new NavMeshPath();
            var hasPath = NavMesh.CalculatePath(stateMachine.transform.position, destination, NavMesh.AllAreas, path);
            if (!hasPath) return false;
            if (path.status != NavMeshPathStatus.PathComplete) return false;
            if (GetPathLength(path) > stateMachine.maxNavPathLength) return false;
            return true;
        }

        private float GetPathLength(NavMeshPath path)
        {
            float total = 0;
            if (path.corners.Length < 2) return total;

            for (int i = 0; i < path.corners.Length - 1; i++)
            {
                total += Vector3.Distance(path.corners[i], path.corners[i + 1]);
            }
            return total;
        }

        private void MoveAroundObstacles(float deltaTime)
        {
            if (!stateMachine.Agent.enabled) stateMachine.Agent.enabled = true;

            Vector3 targetPosition = stateMachine.Player.transform.position;

            // Calculate the path around obstacles
            if (CanMoveTo(targetPosition))
            {
                stateMachine.Agent.SetDestination(targetPosition);

                Vector3 desiredVelocity = stateMachine.Agent.desiredVelocity.normalized;

                stateMachine.Agent.velocity = desiredVelocity;
                stateMachine.Agent.nextPosition = stateMachine.transform.position;
                Move(desiredVelocity * stateMachine.MovementSpeed, deltaTime);

                // Smoothly face the movement direction
                FaceMovementDirection(desiredVelocity, deltaTime);
            }
            else
            {
                // Handle cases where the path is blocked
                stateMachine.SwitchState(new EnemyIdleState(stateMachine));
            }
        }

        private void UpdateMovementAnimation(float deltaTime, Vector3 lastPosition)
        {
            Vector3 deltaMovement = lastPosition - stateMachine.transform.position;
            float deltaMagnitude = deltaMovement.magnitude;

            if (deltaMagnitude > 0)
            {
                // If the game is not paused, update the animation speed
                FaceTarget(stateMachine.transform.position - deltaMovement, deltaTime);
                float grossSpeed = deltaMagnitude / deltaTime;
                stateMachine.Animator.SetFloat(FreeLookSpeedHash, grossSpeed / stateMachine.MovementSpeed, stateMachine.AnimatorDampTime, deltaTime);
            }
            else
            {
                // If the game is paused, set a default animation speed
                FaceTarget(stateMachine.Player.transform.position, deltaTime);
                stateMachine.Animator.SetFloat(FreeLookSpeedHash, 1f);
            }
        }

        private void FaceMovementDirection(Vector3 forward, float deltaTime)
        {
            if (forward == Vector3.zero) return;
            Quaternion desiredRotation = Quaternion.LookRotation(forward, Vector3.up);
            stateMachine.transform.rotation = Quaternion.Slerp(stateMachine.transform.rotation, desiredRotation, stateMachine.FreeLookRotationSpeed * deltaTime);
        }
    }
}

both my modified enemy chasing state and this script look quite different code-wise, but fundamentally they work in a similar way

and here’s a modified version:

using UnityEngine;

namespace RPG.States.Enemies
{
    public class EnemyFollowingPlayerState : EnemyBaseState
    {
        public EnemyFollowingPlayerState(EnemyStateMachine stateMachine) : base(stateMachine) { }

        private float detectionRange;
        private float lastSeenTime;
        private float searchDuration = 3.0f;  // Time to wait before going idle when player is lost
        private bool idleSpeedReached = false;

        public override void Enter()
        {
            stateMachine.Animator.CrossFadeInFixedTime(FreeLookBlendTreeHash, stateMachine.CrossFadeDuration);
            detectionRange = stateMachine.PlayerChasingRangedSquared;
            lastSeenTime = 0.0f; // Reset last seen timer
        }

        public override void Tick(float deltaTime)
        {
            if (!IsPlayerInRange() && !IsAggrevated())
            {
                lastSeenTime += deltaTime;

                // If the player is not seen for the duration of searchDuration, freeze the enemy
                if (lastSeenTime >= searchDuration)
                {
                    FreezeInPlace(deltaTime);
                    return;
                }

                return;
            }

            // If the player is within range and visible, reset the lastSeenTime
            lastSeenTime = 0.0f;

            Vector3 lastPosition = stateMachine.transform.position;

            // Follow the player
            FollowPlayer(deltaTime);

            // Update the movement animation based on speed
            UpdateMovementAnimation(deltaTime, lastPosition);
        }

        public override void Exit()
        {
            if (!stateMachine.Agent.enabled)
            {
                stateMachine.Agent.enabled = true;
                stateMachine.Agent.ResetPath();
                stateMachine.Agent.velocity = Vector3.zero;
                stateMachine.Agent.enabled = false;
            }
            else
            {
                stateMachine.Agent.ResetPath();
                stateMachine.Agent.velocity = Vector3.zero;
            }
        }

        private bool IsPlayerInRange()
        {
            // Calculate the squared distance for optimization
            float sqrDistanceToPlayer = Vector3.SqrMagnitude(stateMachine.Player.transform.position - stateMachine.transform.position);

            // Check if the player is within the detection range
            return sqrDistanceToPlayer <= detectionRange;
        }

        private void FollowPlayer(float deltaTime)
        {
            if (!stateMachine.Agent.enabled) stateMachine.Agent.enabled = true;

            // Set the player's position as the destination
            stateMachine.Agent.destination = stateMachine.Player.transform.position;

            // Calculate desired velocity for movement
            Vector3 desiredVelocity = stateMachine.Agent.desiredVelocity.normalized;

            // Move the enemy towards the player
            stateMachine.Agent.velocity = desiredVelocity;
            stateMachine.Agent.nextPosition = stateMachine.transform.position;
            Move(desiredVelocity * stateMachine.MovementSpeed, deltaTime);
        }

        private void UpdateMovementAnimation(float deltaTime, Vector3 lastPosition)
        {
            Vector3 deltaMovement = lastPosition - stateMachine.transform.position;
            float deltaMagnitude = deltaMovement.magnitude;

            if (deltaMagnitude > 0)
            {
                // If the game is not paused, update the animation speed
                FaceTarget(stateMachine.transform.position - deltaMovement, deltaTime);
                float grossSpeed = deltaMagnitude / deltaTime;
                stateMachine.Animator.SetFloat(FreeLookSpeedHash, grossSpeed / stateMachine.MovementSpeed, stateMachine.AnimatorDampTime, deltaTime);
            }
            else
            {
                // If the game is paused, set a default animation speed
                FaceTarget(stateMachine.Player.transform.position, deltaTime);
                stateMachine.Animator.SetFloat(FreeLookSpeedHash, 1f);
            }
        }

        private void FaceMovementDirection(Vector3 forward, float deltaTime)
        {
            if (forward == Vector3.zero) return;
            Quaternion desiredRotation = Quaternion.LookRotation(forward, Vector3.up);
            stateMachine.transform.rotation = Quaternion.Slerp(stateMachine.transform.rotation, desiredRotation, stateMachine.FreeLookRotationSpeed * deltaTime);
        }

        private void FreezeInPlace(float deltaTime)
        {
            // Set agent velocity to zero to stop movement
            stateMachine.Agent.velocity = Vector3.zero;

            // Play idle animation
            if (!idleSpeedReached)
            {
                stateMachine.Animator.SetFloat(FreeLookSpeedHash, 0f, stateMachine.AnimatorDampTime, deltaTime);
                if (stateMachine.Animator.GetFloat(FreeLookSpeedHash) < 0.05f)
                {
                    stateMachine.Animator.CrossFadeInFixedTime(IdleHash, stateMachine.CrossFadeDuration);
                    idleSpeedReached = true;
                }
            }

            // Check if the player is moving again
            if (IsPlayerInRange() || IsAggrevated())
            {
                idleSpeedReached = false; // Reset idle speed flag
                stateMachine.SwitchState(new EnemyChasingState(stateMachine)); // Switch back to chasing
            }
        }
    }
}

Privacy & Terms