Creating a Farming RPG (like Harvest Moon) in Unity — Part 24: NPC Movement

This article is a part of the series:
Creating a Farming RPG (like Harvest Moon) in Unity

Ever wanted to create a game like Harvest Moon in Unity? Check out Part 24 of our guide here, where we set up the NPC Movement. You can also find Part 23 of our guide here, where we went through how to set up NPC routines.

  1. Update Project Version
  2. Make NPC Snap to the NavMesh Surface
  3. Fix Console Error
  4. CharacterMovement Class
  5. Set Up NPC Movement
    1. The NPC moves from one point to another within the same scene.
    2. The NPC moves from the scene the player is in, to another scene the player is not in.
    3. The NPC moves from a scene the player is not in, to the scene the player is in.
  6. Conclusion

1. Update Project Version

If you attempt to set up the schedule scriptable object for our NPCs, you may encounter difficulty navigating around the NPC Schedule list.

The good news is that in newer Unity versions (Unity 2020 onwards), lists and arrays are automatically displayed in the Editor as reorderable lists. This feature makes it easier for us to reorder the elements inside the list.

Therefore, we decided to upgrade the project from Unity 2019 to Unity 2023, specifically, Unity 2023.2.2f1. Be sure to back up your project before upgrading the project version, so you can always revert to Unity 2019 if you regret upgrading.

Install Unity 2023.2.2f1 and open the project.

2. Make NPC Snap to the NavMesh Surface

Back to our game, it seems that some of the coordinates for our NPCs are off. One example is Uncle Ben floating in Yodel Ranch.

Uncle Ben is literally on another level.

To address that, we will have them confined to the NavMeshes, so they’ll just snap to it. To do so, we first need to bake the NavMesh Surface for all the scenes.

In the newer version of Unity, NavMesh Surface is a separate component, and we have to bake it separately on a GameObject instead of going to the scene navigation window and baking it directly. Remember to do it for all the scenes, except for the title scene.

Recreate NavMesh Surface through AI > NavMesh Surface.
Click on the “Bake” button to rebake the NavMesh Surface.

After baking the NavMesh Surface for all the scenes, head over to the character Prefabs, and add a NavMesh Agent component for all your characters.

With that, the NPCs should be snapping to the floor.

3. Fix Console Error

If you pay attention to the console, you will probably notice that we get a lot of error messages after the clock goes past midnight.

To fix this, we just have to add a default location at 0000 hours for each of the characters.

Remember to do this for all the characters.

4. CharacterMovement Class

Next up, create a new class, CharacterMovement which we’ll use to handle the NPC movement. Attach it to all the character Prefabs. Besides handling the NPC movement, we will also use it as the parent class of the AnimalMovement class.

CharacterMovement.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

[RequireComponent(typeof(NavMeshAgent))]
public class CharacterMovement : MonoBehaviour
{

    protected NavMeshAgent agent;

    Vector3 destination;
    // Start is called before the first frame update
    protected virtual void Start()
    {
        agent = GetComponent<NavMeshAgent>();
    }


    public void ToggleMovement(bool enabled)
    {
        agent.enabled = enabled;
    }

}

AnimalMovement.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

[RequireComponent(typeof(NavMeshAgent))]
public class AnimalMovement : MonoBehaviourCharacterMovement
{
    NavMeshAgent agent; 

    //Time before the navmesh sets another destination to move towards
    [SerializeField] float cooldownTime;
    float cooldownTimer; 
    // Start is called before the first frame update
    void Start()
    {
        agent = GetComponent<NavMeshAgent>();
        cooldownTimer = Random.Range(0, cooldownTime);
    }

    public void ToggleMovement(bool enabled)
    {
        agent.enabled = enabled;
    }

    protected override void Start()
    {
        base.Start();
        cooldownTimer = Random.Range(0, cooldownTime);
    }

    // Update is called once per frame
    void Update()
    {
        Wander(); 
    }

    void Wander()
    {
        if (!agent.enabled) return;
        if(cooldownTimer > 0)
        {
            cooldownTimer -= Time.deltaTime;
            return;
        }

        if (!agent.pathPending && agent.remainingDistance < 0.5f)
        {
            //Generate a random direction within a sphere with a radius of 10
            Vector3 randomDirection = Random.insideUnitSphere * 10f;

            //Offset the random direction by the current position of the animal
            randomDirection += transform.position;

            NavMeshHit hit;
            //Sample the nearest valid position on the Navmesh
            NavMesh.SamplePosition(randomDirection, out hit, 10f, NavMesh.AllAreas);

            //Get the final target position
            Vector3 targetPos = hit.position;

            agent.SetDestination(targetPos);
            cooldownTimer = cooldownTime; 
        }
     

    }
}

5. Set Up NPC Movement

With that, we can start handling the NPC movement. Whenever the player sees an NPC move from place to place, there are 3 main possibilities:

  1. The NPC moves from one point to another within the same scene.
  2. The NPC moves from the scene the player is in, to another scene the player is not in.
  3. The NPC moves from a scene the player is not in, to the scene the player is in.

Let’s handle each of the possibilities one by one.

a. The NPC moves from one point to another within the same scene

Moving within the same scene is pretty straightforward, we just need to check where the NPC should be and have the NavMesh Agent set the destination for them to move. This logic is handled by a new function, MoveTo() in CharacterMovement.

CharacterMovement.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

[RequireComponent(typeof(NavMeshAgent))]
public class CharacterMovement : MonoBehaviour
{
    protected NavMeshAgent agent;


    // Start is called before the first frame update
    protected virtual void Start()
    {
        agent = GetComponent<NavMeshAgent>();
    }


    public void ToggleMovement(bool enabled)
    {
        agent.enabled = enabled;
    }

    public void MoveTo(NPCLocationState locationState)
    {
        SceneTransitionManager.Location locationToMoveTo = locationState.location;

        //Check if location is the same 
        if (locationToMoveTo == SceneTransitionManager.Instance.currentLocation)
        {
            //Check if the coord is the same 
            NavMeshHit hit;
            //Sample the nearest valid position on the Navmesh
            NavMesh.SamplePosition(locationState.coord, out hit, 10f, NavMesh.AllAreas);

            //If the npc is already where he should be just carry on
            if (Vector3.Distance(transform.position, hit.position) < 1) return;
            agent.SetDestination(hit.position);
            
            return; 
        }
        
    }
}

But how do we trigger this? It’s not a good idea to call the function every frame, so let’s make the function run every 15 in-game minutes.

To do so, we first add a helper function, GetNPCLocation() in the NPCManager class which helps us to get the location of a specific NPC.

NPCManager.cs

using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public class NPCManager : MonoBehaviour, ITimeTracker
{
    public static NPCManager Instance { get; private set; }
    
    private void Awake()
    {
        //If there is more than one instance, destroy the extra
        if (Instance != null && Instance != this)
        {
            Destroy(this);
        }
        else
        {
            //Set the static instance to this instance
            Instance = this;
        }
    }

    

    List<CharacterData> characters = null; 

    List<NPCScheduleData> npcSchedules;

    [SerializeField]
    List<NPCLocationState> npcLocations; 


    //Load all character data
    public List<CharacterData> Characters()
    {
        if (characters != null) return characters; 
        CharacterData[] characterDatabase = Resources.LoadAll<CharacterData>("Characters");
        characters = characterDatabase.ToList();
        return characters;
    }

    private void OnEnable()
    {
        //Load NPC Schedules
        NPCScheduleData[] schedules = Resources.LoadAll<NPCScheduleData>("Schedules");
        npcSchedules = schedules.ToList();
        InitNPCLocations();
        
    }

    private void Start()
    {
        //Add this to TimeManager's Listener list
        TimeManager.Instance.RegisterTracker(this);
        SceneTransitionManager.Instance.onLocationLoad.AddListener(RenderNPCs); 
    }

    private void InitNPCLocations()
    {
        npcLocations = new List<NPCLocationState>(); 
        foreach(CharacterData character in Characters())
        {
            npcLocations.Add(new NPCLocationState(character)); 
        }
        
    }

    void RenderNPCs()
    {
        foreach(NPCLocationState npc in npcLocations)
        {
            if(npc.location == SceneTransitionManager.Instance.currentLocation)
            {
                Instantiate(npc.character.prefab, npc.coord, Quaternion.Euler(npc.facing)); 
            }
        }
    }

    public void ClockUpdate(GameTimestamp timestamp) {
        UpdateNPCLocations(timestamp); 
    }

    public NPCLocationState GetNPCLocation(string name)
    {
        return npcLocations.Find(x => x.character.name == name); 
    }

    private void UpdateNPCLocations(GameTimestamp timestamp)
    {
        for (int i = 0; i < npcLocations.Count; i++)
        {
            NPCLocationState npcLocator = npcLocations[i]; 
            //Find the schedule belonging to the NPC
            NPCScheduleData schedule = npcSchedules.Find(x => x.character == npcLocator.character);
            if(schedule == null)
            {
                Debug.LogError("No schedule found for " + npcLocator.character.name);
                continue; 
            }

            //Current time 
            
            GameTimestamp.DayOfTheWeek dayOfWeek = timestamp.GetDayOfTheWeek();

            //Find the events that correspond to the current time
            //E.g. if the event is set to 8am, the current time must be after 8am, so the hour of timeNow has to be greater than the event
            //Either the day of the week matches or it is set to ignore the day of the week

            //In future we will also have the Seasons factored in
            List<ScheduleEvent> eventsToConsider = schedule.npcScheduleList.FindAll(x => x.time.hour <= timestamp.hour && (x.dayOfTheWeek == dayOfWeek || x.ignoreDayOfTheWeek));
            //Check if the events are empty
            if(eventsToConsider.Count < 1)
            {
                Debug.LogError("None found for " + npcLocator.character.name);
                Debug.LogError(timestamp.hour); 
                continue;
            }
            
            //Remove all the events with the hour that is lower than the max time (The time has already elapsed)
            int maxHour = eventsToConsider.Max(x => x.time.hour);
            eventsToConsider.RemoveAll(x => x.time.hour < maxHour);

            //Get the event with the highest priority
            ScheduleEvent eventToExecute = eventsToConsider.OrderByDescending(x => x.priority).First();
            //Set the NPC Locator value accordingly
            npcLocations[i] = new NPCLocationState(schedule.character, eventToExecute.location, eventToExecute.coord, eventToExecute.facing);
        }

    }
}

Next up, let’s create an UnityEvent, OnIntervalUpdate that will be triggered every 15 in-game minutes in the GameStateManager class. We also created a new integer variable called minutesElapsed, which we use to track the time elapsed. Whenever minutesElapsed exceeds 15, we will reset it and invoke OnIntervalUpdate.

GameStateManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events; 

public class GameStateManager : MonoBehaviour, ITimeTracker
{
    public static GameStateManager Instance { get; private set; }

    //Check if the screen has finished fading out
    bool screenFadedOut;

    //To track interval updates
    private int minutesElapsed = 0;

    //Event triggered every 15 minutes
    public UnityEvent onIntervalUpdate; 

    private void Awake()
    {
        //If there is more than one instance, destroy the extra
        if (Instance != null && Instance != this)
        {
            Destroy(this);
        }
        else
        {
            //Set the static instance to this instance
            Instance = this;
        }
    }

    // Start is called before the first frame update
    void Start()
    {
        //Add this to TimeManager's Listener list
        TimeManager.Instance.RegisterTracker(this); 
    }

    public void ClockUpdate(GameTimestamp timestamp)
    {
        UpdateShippingState(timestamp); 
        UpdateFarmState(timestamp);
        IncubationManager.UpdateEggs();

        if(timestamp.hour == 0 && timestamp.minute == 0)
        {
            OnDayReset(); 
        }

        if(minutesElapsed >= 15)
        {
            minutesElapsed = 0;
            onIntervalUpdate?.Invoke();

        } else
        {
            minutesElapsed++; 
        }
    }

    //Called when the day has been reset
    void OnDayReset()
    {
        Debug.Log("Day has been reset"); 
        foreach(NPCRelationshipState npc in RelationshipStats.relationships)
        {
            npc.hasTalkedToday = false;
            npc.giftGivenToday = false; 
        }
        AnimalFeedManager.ResetFeedboxes();
        AnimalStats.OnDayReset();

       
    }

    void UpdateShippingState(GameTimestamp timestamp)
    {
        //Check if the hour is here (Exactly 1800 hours)
        if(timestamp.hour == ShippingBin.hourToShip && timestamp.minute == 0)
        {
            ShippingBin.ShipItems();
        }
    }

    void UpdateFarmState(GameTimestamp timestamp)
    {
        //Update the Land and Crop Save states as long as the player is outside of the Farm scene
        if (SceneTransitionManager.Instance.currentLocation != SceneTransitionManager.Location.Farm)
        {
            //If there is nothing to update to begin with, stop 
            if (LandManager.farmData == null) return;

            //Retrieve the Land and Farm data from the static variable
            List<LandSaveState> landData = LandManager.farmData.Item1;
            List<CropSaveState> cropData = LandManager.farmData.Item2;

            //If there are no crops planted, we don't need to worry about updating anything
            if (cropData.Count == 0) return;

            for (int i = 0; i < cropData.Count; i++)
            {
                //Get the crop and corresponding land data
                CropSaveState crop = cropData[i];
                LandSaveState land = landData[crop.landID];

                //Check if the crop is already wilted
                if (crop.cropState == CropBehaviour.CropState.Wilted) continue;

                //Update the Land's state
                land.ClockUpdate(timestamp);
                //Update the crop's state based on the land state
                if (land.landStatus == Land.LandStatus.Watered)
                {
                    crop.Grow();
                }
                else if (crop.cropState != CropBehaviour.CropState.Seed)
                {
                    crop.Wither();
                }

                //Update the element in the array
                cropData[i] = crop;
                landData[crop.landID] = land;

            }

            /*LandManager.farmData.Item2.ForEach((CropSaveState crop) => {
                Debug.Log(crop.seedToGrow + "\n Health: " + crop.health + "\n Growth: " + crop.growth + "\n State: " + crop.cropState.ToString());
            });*/
        }
    }

Once done, we need to hook the MoveTo() function in the CharacterMovement class to the OnIntervalUpdate() event, this is done in the InteractableCharacter class.

InteractableCharacter.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(CharacterMovement))]
public class InteractableCharacter : InteractableObject
{
    public CharacterData characterData;

    //Cache the relationship data of the NPC so we can access it
    NPCRelationshipState relationship;

    //The rotation it should be facing by default
    Quaternion defaultRotation;
    //Check if the LookAt coroutine is currently being executed
    bool isTurning = false;

    CharacterMovement movement; 

    private void Start()
    {
        relationship = RelationshipStats.GetRelationship(characterData);

        movement = GetComponent<CharacterMovement>(); 

        //Cache the original rotation of the characters
        defaultRotation = transform.rotation; 

        //Add listener
        GameStateManager.Instance.onIntervalUpdate.AddListener(OnIntervalUpdate);
    }

    void OnIntervalUpdate()
    {
        //Get data on its location
        NPCLocationState locationState = NPCManager.Instance.GetNPCLocation(characterData.name);
        movement.MoveTo(locationState);
        StartCoroutine(LookAt(Quaternion.Euler(locationState.facing)));

    }

    public override void Pickup()
    {
        LookAtPlayer();
        TriggerDialogue(); 
    }

Besides that, when the player talks to the walking NPC, we want them to stop moving. To disable the movement, we can simply call the ToggleMovement() function whenever a dialogue is triggered.

We also created a function, IsMoving() in CharacterMovement to check whether the NPC is moving. If they are moving, the LookAt() function in InteractableCharacter will not rotate the character, as the rotation will be handled by the NavMesh agent.

CharacterMovement.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

[RequireComponent(typeof(NavMeshAgent))]
public class CharacterMovement : MonoBehaviour
{
    protected NavMeshAgent agent;
    // Start is called before the first frame update
    protected virtual void Start()
    {
        agent = GetComponent<NavMeshAgent>();        
    }


    public void ToggleMovement(bool enabled)
    {
        agent.enabled = enabled;
    }

    public bool IsMoving()
    {
        float v = agent.velocity.sqrMagnitude;
        Debug.Log("Moving at: " + v);
        return v > 0; 
    }


    public void MoveTo(NPCLocationState locationState)
    {
        SceneTransitionManager.Location locationToMoveTo = locationState.location; 

        //Check if location is the same 
        if(locationToMoveTo == SceneTransitionManager.Instance.currentLocation)
        {
            //Check if the coord is the same 
            NavMeshHit hit;
            //Sample the nearest valid position on the Navmesh
            NavMesh.SamplePosition(locationState.coord, out hit, 10f, NavMesh.AllAreas);

            //If the npc is already where he should be just carry on
            if (Vector3.Distance(transform.position, hit.position) < 4) return;
            agent.SetDestination(hit.position);
            
            return; 
        }
    }
}

InteractableCharacter.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(CharacterMovement))]
public class InteractableCharacter : InteractableObject
{
    public CharacterData characterData;

    //Cache the relationship data of the NPC so we can access it
    NPCRelationshipState relationship;

    //The rotation it should be facing by default
    Quaternion defaultRotation;
    //Check if the LookAt coroutine is currently being executed
    bool isTurning = false;

    CharacterMovement movement; 

    private void Start()
    {
        relationship = RelationshipStats.GetRelationship(characterData);

        movement = GetComponent<CharacterMovement>(); 

        //Cache the original rotation of the characters
        defaultRotation = transform.rotation; 

        //Add listener
        GameStateManager.Instance.onIntervalUpdate.AddListener(OnIntervalUpdate);
    }

    void OnIntervalUpdate()
    {
        //Get data on its location
        NPCLocationState locationState = NPCManager.Instance.GetNPCLocation(characterData.name);
        movement.MoveTo(locationState);
        StartCoroutine(LookAt(Quaternion.Euler(locationState.facing)));

    }

    public override void Pickup()
    {
        LookAtPlayer();
        TriggerDialogue(); 
    }

    #region Rotation
    void LookAtPlayer()
    {
        //Get the player's transform
        Transform player = FindObjectOfType<PlayerController>().transform;

        //Get a vector for the direction towards the player
        Vector3 dir = player.position - transform.position;
        //Lock the y axis of the vector so the npc doesn't look up or down to face the player
        dir.y = 0;
        //Convert the direction vector into a quaternion
        Quaternion lookRot = Quaternion.LookRotation(dir);
        //Look at the player
        StartCoroutine(LookAt(lookRot)); 
    }
    //Coroutine for the character to progressively turn towards a rotation
    IEnumerator LookAt(Quaternion lookRot)
    {
        //Check if the coroutine is already running
        if (isTurning)
        {
            //Stop the coroutine 
            isTurning = false;
        }
        else
        {
            isTurning = true; 
        }

        while(transform.rotation != lookRot)
        {
            if (!isTurning)
            {
                //Stop coroutine execution
                yield break; 
            }

            //Dont do anything if moving            
            if(!movement.IsMoving())transform.rotation = Quaternion.RotateTowards(transform.rotation, lookRot, 720 * Time.fixedDeltaTime);
            yield return new WaitForFixedUpdate(); 
        }
        isTurning = false; 
    }

    //Rotate back to its original rotation
    void ResetRotation()
    {
        StartCoroutine(LookAt(defaultRotation)); 
    }
    #endregion

    #region Conversation Interactions
    void TriggerDialogue()
    {
        movement.ToggleMovement(false);
        //Check if the player is holding anything 
        if (InventoryManager.Instance.SlotEquipped(InventorySlot.InventoryType.Item))
        {
            //Switch over to the Gift Dialogue function
            GiftDialogue();
            return; 
        }

        List<DialogueLine> dialogueToHave = characterData.defaultDialogue;

        System.Action onDialogueEnd = null;
        System.Action onDialogueEnd = () =>
        {
            //Allow for movement
            movement.ToggleMovement(true);
        };

        //Have the character reset their rotation after the conversation is over
        onDialogueEnd += ResetRotation; 

        //Do the checks to determine which dialogue to put out

        //Is the player meeting for the first time?
        if (RelationshipStats.FirstMeeting(characterData))
        {
            //Assign the first meet dialogue
            dialogueToHave = characterData.onFirstMeet;
            onDialogueEnd += OnFirstMeeting;

        }

        if (RelationshipStats.IsFirstConversationOfTheDay(characterData))
        {
            onDialogueEnd += OnFirstConversation;
        }

        DialogueManager.Instance.StartDialogue(dialogueToHave, onDialogueEnd);
    }

    //Handle Gift Giving
    void GiftDialogue()
    {
        if (!EligibleForGift()) return;

        //Get the ItemSlotData of what the player is holding
        ItemSlotData handSlot = InventoryManager.Instance.GetEquippedSlot(InventorySlot.InventoryType.Item);

        List<DialogueLine> dialogueToHave = characterData.neutralGiftDialogue;

        System.Action onDialogueEnd = () =>
        {
            //Allow for movement
            movement.ToggleMovement(true);

            //Mark gift as given for today
            relationship.giftGivenToday = true;

            //Remove the item from the player's hand
            InventoryManager.Instance.ConsumeItem(handSlot); 
        };

        //Have the character reset their rotation after the conversation is over
        onDialogueEnd += ResetRotation;

        bool isBirthday = RelationshipStats.IsBirthday(characterData);

        //The friendship points to add from the gift
        int pointsToAdd = 0;
        //Do the checks to determine which dialogue to put out
        switch(RelationshipStats.GetReactionToGift(characterData, handSlot.itemData))
        {
            case RelationshipStats.GiftReaction.Like:
                dialogueToHave = characterData.likedGiftDialogue;
                //80
                pointsToAdd = 80;
                if (isBirthday) dialogueToHave = characterData.birthdayLikedGiftDialogue;
                break;

            case RelationshipStats.GiftReaction.Dislike:
                dialogueToHave = characterData.dislikedGiftDialogue;
                //-20
                pointsToAdd = -20;
                if (isBirthday) dialogueToHave = characterData.birthdayDislikedGiftDialogue;
                break;
            case RelationshipStats.GiftReaction.Neutral:
                dialogueToHave = characterData.neutralGiftDialogue;
                //20
                pointsToAdd = 20;
                if (isBirthday) dialogueToHave = characterData.birthdayNeutralGiftDialogue;

                break; 
        }
        //Birthday multiplier
        if (isBirthday) pointsToAdd *= 8;


        RelationshipStats.AddFriendPoints(characterData, pointsToAdd);

        DialogueManager.Instance.StartDialogue(dialogueToHave, onDialogueEnd);
    }

    //Check if the character can be given a gift
    bool EligibleForGift()
    {
        //Reject condition: Player has not unlocked this character yet
        if (RelationshipStats.FirstMeeting(characterData))
        {
            DialogueManager.Instance.StartDialogue(DialogueManager.CreateSimpleMessage("You have not unlocked this character yet.")); 
            return false; 
        }

        //Reject condition: Player has already given this character a gift today
        if (RelationshipStats.GiftGivenToday(characterData))
        {
            DialogueManager.Instance.StartDialogue(DialogueManager.CreateSimpleMessage($"You have already given {characterData.name} a gift today."));
            return false; 
        }


        return true; 
    }

    void OnFirstMeeting()
    {
        //Unlock the character on the relationships
        RelationshipStats.UnlockCharacter(characterData);
        //Update the relationship data
        relationship = RelationshipStats.GetRelationship(characterData);
    }

    void OnFirstConversation()
    {
        Debug.Log("This is the first conversation of the day");
        //Add 20 friend points 
        RelationshipStats.AddFriendPoints(characterData, 20);
 
        relationship.hasTalkedToday = true;
    }
    #endregion
}


With that, when you talk to the NPCs, they will stop moving toward their destinations. But there is one problem with this: after the whole conversation is done, they will remain standing where they are, which isn’t what we want. Instead, we want them to continue walking.

Addressing that is fairly simple; we just need to queue the OnIntervalUpdate() function to be called when the dialogue ends through the OnDialogueEnd action in the InteractableCharacter class. Additionally, we will comment out the function to reset rotation, as we no longer need it.

InteractableCharacter.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(CharacterMovement))]
public class InteractableCharacter : InteractableObject
{
    public CharacterData characterData;

    //Cache the relationship data of the NPC so we can access it
    NPCRelationshipState relationship;

    //The rotation it should be facing by default
    Quaternion defaultRotation;
    //Check if the LookAt coroutine is currently being executed
    bool isTurning = false;

    CharacterMovement movement; 

    private void Start()
    {
        relationship = RelationshipStats.GetRelationship(characterData);

        movement = GetComponent<CharacterMovement>(); 

        //Cache the original rotation of the characters
        defaultRotation = transform.rotation;

        //Add listener
        GameStateManager.Instance.onIntervalUpdate.AddListener(OnIntervalUpdate);
    }

    void OnIntervalUpdate()
    {
        //Get data on its location
        NPCLocationState locationState = NPCManager.Instance.GetNPCLocation(characterData.name);
        movement.MoveTo(locationState);
        StartCoroutine(LookAt(Quaternion.Euler(locationState.facing)));

    }

    public override void Pickup()
    {
        LookAtPlayer();
        TriggerDialogue(); 
    }

    #region Rotation
    void LookAtPlayer()
    {
        //Get the player's transform
        Transform player = FindObjectOfType<PlayerController>().transform;

        //Get a vector for the direction towards the player
        Vector3 dir = player.position - transform.position;
        //Lock the y axis of the vector so the npc doesn't look up or down to face the player
        dir.y = 0;
        //Convert the direction vector into a quaternion
        Quaternion lookRot = Quaternion.LookRotation(dir);
        StartCoroutine(LookAt(lookRot));

    }
    //Coroutine for the character to progressively turn towards a rotation
    IEnumerator LookAt(Quaternion lookRot)
    {
        //Check if the coroutine is already running
        if (isTurning)
        {
            //Stop the coroutine 
            isTurning = false;
        }
        else
        {
            isTurning = true; 
        }

        while(transform.rotation != lookRot)
        {
            if (!isTurning)
            {
                //Stop coroutine execution
                yield break; 
            }

            //Dont do anything if moving
            
            if(!movement.IsMoving())transform.rotation = Quaternion.RotateTowards(transform.rotation, lookRot, 720 * Time.fixedDeltaTime);
            yield return new WaitForFixedUpdate(); 
        }
        isTurning = false; 
    }

    //Rotate back to its original rotation
    void ResetRotation()
    {
        StartCoroutine(LookAt(defaultRotation)); 
    }
    #endregion

    #region Conversation Interactions
    void TriggerDialogue()
    {
        movement.ToggleMovement(false);
        //Check if the player is holding anything 
        if (InventoryManager.Instance.SlotEquipped(InventorySlot.InventoryType.Item))
        {
            //Switch over to the Gift Dialogue function
            GiftDialogue();
            return; 
        }

        List<DialogueLine> dialogueToHave = characterData.defaultDialogue;

        System.Action onDialogueEnd = () =>
        {
            //Allow for movement
            movement.ToggleMovement(true);
            //Continue going to its destination if it was on the way/Reset its initial position
            OnIntervalUpdate(); 
        };

        //Have the character reset their rotation after the conversation is over
        //onDialogueEnd += ResetRotation; 

        //Do the checks to determine which dialogue to put out

        //Is the player meeting for the first time?
        if (RelationshipStats.FirstMeeting(characterData))
        {
            //Assign the first meet dialogue
            dialogueToHave = characterData.onFirstMeet;
            onDialogueEnd += OnFirstMeeting;

        }

        if (RelationshipStats.IsFirstConversationOfTheDay(characterData))
        {
            onDialogueEnd += OnFirstConversation;
        }

        DialogueManager.Instance.StartDialogue(dialogueToHave, onDialogueEnd);
    }

    //Handle Gift Giving
    void GiftDialogue()
    {
        if (!EligibleForGift()) return;

        //Get the ItemSlotData of what the player is holding
        ItemSlotData handSlot = InventoryManager.Instance.GetEquippedSlot(InventorySlot.InventoryType.Item);

        List<DialogueLine> dialogueToHave = characterData.neutralGiftDialogue;

        System.Action onDialogueEnd = () =>
        {
            //Allow for movement
            movement.ToggleMovement(true);

            //Continue going to its destination if it was on the way/Reset its initial position
            OnIntervalUpdate();
            //Mark gift as given for today
            relationship.giftGivenToday = true;

            //Remove the item from the player's hand
            InventoryManager.Instance.ConsumeItem(handSlot); 
        };

        //Have the character reset their rotation after the conversation is over 
        //onDialogueEnd += ResetRotation;

        bool isBirthday = RelationshipStats.IsBirthday(characterData);

        //The friendship points to add from the gift
        int pointsToAdd = 0;
        //Do the checks to determine which dialogue to put out
        switch(RelationshipStats.GetReactionToGift(characterData, handSlot.itemData))
        {
            case RelationshipStats.GiftReaction.Like:
                dialogueToHave = characterData.likedGiftDialogue;
                //80
                pointsToAdd = 80;
                if (isBirthday) dialogueToHave = characterData.birthdayLikedGiftDialogue;
                break;

            case RelationshipStats.GiftReaction.Dislike:
                dialogueToHave = characterData.dislikedGiftDialogue;
                //-20
                pointsToAdd = -20;
                if (isBirthday) dialogueToHave = characterData.birthdayDislikedGiftDialogue;
                break;
            case RelationshipStats.GiftReaction.Neutral:
                dialogueToHave = characterData.neutralGiftDialogue;
                //20
                pointsToAdd = 20;
                if (isBirthday) dialogueToHave = characterData.birthdayNeutralGiftDialogue;

                break; 
        }
        //Birthday multiplier
        if (isBirthday) pointsToAdd *= 8;


        RelationshipStats.AddFriendPoints(characterData, pointsToAdd);

        DialogueManager.Instance.StartDialogue(dialogueToHave, onDialogueEnd);
    }

    //Check if the character can be given a gift
    bool EligibleForGift()
    {
        //Reject condition: Player has not unlocked this character yet
        if (RelationshipStats.FirstMeeting(characterData))
        {
            DialogueManager.Instance.StartDialogue(DialogueManager.CreateSimpleMessage("You have not unlocked this character yet.")); 
            return false; 
        }

        //Reject condition: Player has already given this character a gift today
        if (RelationshipStats.GiftGivenToday(characterData))
        {
            DialogueManager.Instance.StartDialogue(DialogueManager.CreateSimpleMessage($"You have already given {characterData.name} a gift today."));
            return false; 
        }


        return true; 
    }

    void OnFirstMeeting()
    {
        //Unlock the character on the relationships
        RelationshipStats.UnlockCharacter(characterData);
        //Update the relationship data
        relationship = RelationshipStats.GetRelationship(characterData);
    }

    void OnFirstConversation()
    {
        Debug.Log("This is the first conversation of the day");
        //Add 20 friend points 
        RelationshipStats.AddFriendPoints(characterData, 20);
 
        relationship.hasTalkedToday = true;
    }
    #endregion
}

Lastly, let’s set up the walk animation for our NPCs. Previously, we just slapped our player’s animator onto them, but now we want them to have their own animator.

To do so, we first duplicate the player’s animator, rename it to NPC, and assign it to all our character Prefabs.

“NPC” is a duplicate of the player’s animator.

Rearrange the transitions just like what we did previously for the chickens.

Idle -> Walking if “Walk” is true, Walking -> Idle if “Walk” is false.

Disable exit time for both transitions.

Once done, create a new class, CharacterRenderer to handle the walk animation. Don’t forget to attach it to all your character Prefabs.

CharacterRenderer.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(CharacterMovement), typeof(Animator))]
public class CharacterRenderer : MonoBehaviour
{
    CharacterMovement movement;
    Animator animator; 
    // Start is called before the first frame update
    void Start()
    {
        animator = GetComponent<Animator>();
        movement = GetComponent<CharacterMovement>(); 
    }

    // Update is called once per frame
    void Update()
    {
        animator.SetBool("Walk", movement.IsMoving()); 
    }
}

b. The NPC moves from the scene the player is in, to another scene the player is not in.

This is the hardest out of the 3 possibilities, we need the NPC to know where is the entrance of the next scene, and have them walk to the entrance to that scene.

For example, if Uncle Ben is in the Town, and his next position is in the Forest, he should walk to the entrance of the Forest (Town -> Forest).

If that was it, then it would be simple. But what if Uncle Ben started in Yodel Ranch, and he had to end up in the Forest (Yodel Ranch -> Town -> Forest)? In this scenario, he would have to walk to the Town first before proceeding to the Forest.

Therefore, we need to implement a logic to get the path that leads to the destination and have the NPC walk the path using the MoveTo() function in the CharacterMovement class.

To do so, we will make use of a graph traversal algorithm called Breadth First Search (BFS). We found the algorithm from this Stack Overflow question. First, we will come up with a Dictionary called sceneConnections that represent the connections between scenes.

LocationManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static SceneTransitionManager; 

public class LocationManager : MonoBehaviour
{
    public static LocationManager Instance { get; private set; }

    public List<StartPoint> startPoints;

    //The connections between scenes
    private static readonly Dictionary<Location, List<Location>> sceneConnections = new Dictionary<Location, List<Location>>() {
        {Location.PlayerHome, new List<Location>{ Location.Farm} },
        {Location.Farm, new List<Location>{ Location.PlayerHome, Location.Town } },
        {Location.Town, new List<Location>{Location.YodelRanch, Location.Forest, Location.Farm } },
        {Location.YodelRanch, new List<Location>{ Location.Town} },
        {Location.Forest, new List<Location>{Location.Town} }
    };

    private void Awake()
    {
        //If there is more than one instance, destroy the extra
        if (Instance != null && Instance != this)
        {
            Destroy(this);
        }
        else
        {
            //Set the static instance to this instance
            Instance = this;
        }
    }
    
    //Find the player's start position based on where he's coming from
    public Transform GetPlayerStartingPosition(SceneTransitionManager.Location enteringFrom)
    {
        //Tries to find the matching startpoint based on the Location given
        StartPoint startingPoint = startPoints.Find(x => x.enteringFrom == enteringFrom);

        //Return the transform
        return startingPoint.playerStart; 
    }  
}

This Dictionary will give us something like the graph below.

With that, we can proceed to create a function, GetNextLocation() that gives us the next scene in the traversal path. Copy the code first and we will explain it later.

LocationManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static SceneTransitionManager; 

public class LocationManager : MonoBehaviour
{
    public static LocationManager Instance { get; private set; }

    public List<StartPoint> startPoints;

    //The connections between scenes
    private static readonly Dictionary<Location, List<Location>> sceneConnections = new Dictionary<Location, List<Location>>() {
        {Location.PlayerHome, new List<Location>{ Location.Farm} },
        {Location.Farm, new List<Location>{ Location.PlayerHome, Location.Town } },
        {Location.Town, new List<Location>{Location.YodelRanch, Location.Forest, Location.Farm } },
        {Location.YodelRanch, new List<Location>{ Location.Town} },
        {Location.Forest, new List<Location>{Location.Town} }
    };

    private void Awake()
    {
        //If there is more than one instance, destroy the extra
        if (Instance != null && Instance != this)
        {
            Destroy(this);
        }
        else
        {
            //Set the static instance to this instance
            Instance = this;
        }
    }
    
    //Find the player's start position based on where he's coming from
    public Transform GetPlayerStartingPosition(SceneTransitionManager.Location enteringFrom)
    {
        //Tries to find the matching startpoint based on the Location given
        StartPoint startingPoint = startPoints.Find(x => x.enteringFrom == enteringFrom);

        //Return the transform
        return startingPoint.playerStart; 
    }

    //Get the next scene in the traversal path
    public static Location GetNextLocation(Location currentScene, Location finalDestination)
    {
        //Track visited locations
        Dictionary<Location, bool> visited = new Dictionary<Location, bool>();

        //Store previous location in the traversal path
        Dictionary<Location, Location> previousLocation = new Dictionary<Location, Location>();

        //Queue for BFS traversal
        Queue<Location> worklist = new Queue<Location>();

        //Mark the current scene as visited and start the traversal
        visited.Add(currentScene, false); 
        worklist.Enqueue(currentScene);

        //BFS traversal
        while(worklist.Count != 0)
        {
            Location scene = worklist.Dequeue(); 
            if(scene == finalDestination)
            {
                //Reconstruct the path and return the preceding scene
                while(previousLocation.ContainsKey(scene) && previousLocation[scene] != currentScene)
                {
                    scene = previousLocation[scene]; 
                }
                return scene; 
                
            }
            //Enqueue possible destinations connected to the current scene
            if (sceneConnections.ContainsKey(scene))
            {
                List<Location> possibleDestinations = sceneConnections[scene]; 

                foreach(Location neighbour in possibleDestinations)
                {
                    if (!visited.ContainsKey(neighbour))
                    {
                        visited.Add(neighbour, false);
                        previousLocation.Add(neighbour, scene);
                        worklist.Enqueue(neighbour);
                    }
                }
            }
        }

        return currentScene; 
    }

   
}

Inside the function, we created 2 Dictionaries and a Queue named visited, previousLocation and worklist respectively.

visited contains the scene that we’ve visited. When we mark a scene as visited, we want to enqueue it to the worklist Queue to prepare for dequeuing it. To start the traversal path, we first mark the current scene as visited and add it to the worklist Queue.

LocationManager.cs

    //Get the next scene in the traversal path
    public static Location GetNextLocation(Location currentScene, Location finalDestination)
    {
        //Track visited locations
        Dictionary<Location, bool> visited = new Dictionary<Location, bool>();

        //Store previous location in the traversal path
        Dictionary<Location, Location> previousLocation = new Dictionary<Location, Location>();

        //Queue for BFS traversal
        Queue<Location> worklist = new Queue<Location>();

        //Mark the current scene as visited and start the traversal
        visited.Add(currentScene, false); 
        worklist.Enqueue(currentScene);

        //BFS traversal
        while(worklist.Count != 0)
        {
            Location scene = worklist.Dequeue(); 
            if(scene == finalDestination)
            {
                //Reconstruct the path and return the preceding scene
                while(previousLocation.ContainsKey(scene) && previousLocation[scene] != currentScene)
                {
                    scene = previousLocation[scene]; 
                }
                return scene; 
                
            }
            //Enqueue possible destinations connected to the current scene
            if (sceneConnections.ContainsKey(scene))
            {
                List<Location> possibleDestinations = sceneConnections[scene]; 

                foreach(Location neighbour in possibleDestinations)
                {
                    if (!visited.ContainsKey(neighbour))
                    {
                        visited.Add(neighbour, false);
                        previousLocation.Add(neighbour, scene);
                        worklist.Enqueue(neighbour);
                    }
                }
            }
        }

        return currentScene; 
    }

   
}

We will use an example to illustrate this, let’s say if we want to move from Yodel Ranch to the Forest, the graph will look like this.

Notice the changes in the Dictionaries and Queue.

With that, we can start traversing the graph using the while loop. This loop will continue running until the worklist is empty. Inside the loop, we first dequeue worklist and save the scene in a variable called scene, essentially removing the oldest element in the queue, which in this case is Yodel Ranch.

Next up, we will mark all the scenes connected to Yodel Ranch as visited, then add them to worklist and previousLocation.

LocationManager.cs

    //Get the next scene in the traversal path
    public static Location GetNextLocation(Location currentScene, Location finalDestination)
    {
        //Track visited locations
        Dictionary<Location, bool> visited = new Dictionary<Location, bool>();

        //Store previous location in the traversal path
        Dictionary<Location, Location> previousLocation = new Dictionary<Location, Location>();

        //Queue for BFS traversal
        Queue<Location> worklist = new Queue<Location>();

        //Mark the current scene as visited and start the traversal
        visited.Add(currentScene, false); 
        worklist.Enqueue(currentScene);

        //BFS traversal
        while(worklist.Count != 0)
        {
            Location scene = worklist.Dequeue(); 
            if(scene == finalDestination)
            {
                //Reconstruct the path and return the preceding scene
                while(previousLocation.ContainsKey(scene) && previousLocation[scene] != currentScene)
                {
                    scene = previousLocation[scene]; 
                }
                return scene; 
                
            }
            //Enqueue possible destinations connected to the current scene
            if (sceneConnections.ContainsKey(scene))
            {
                List<Location> possibleDestinations = sceneConnections[scene]; 

                foreach(Location neighbour in possibleDestinations)
                {
                    if (!visited.ContainsKey(neighbour))
                    {
                        visited.Add(neighbour, false);
                        previousLocation.Add(neighbour, scene);
                        worklist.Enqueue(neighbour);
                    }
                }
            }
        }

        return currentScene; 
    }

   
}

This is how the graph looks after Yodel Ranch is dequeued.

Yodel Ranch is dequeued.

The loop continues until the final destination, Forest is dequeued.

Town is dequeued.
Final destination, Forest is dequeued, Town is returned.

When the final destination is dequeued, we will enter the first if-statement, reconstruct the path, and return the preceding scene. In this example, scene = previousLocation[Forest]; will return Town.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static SceneTransitionManager; 

public class LocationManager : MonoBehaviour
{
    public static LocationManager Instance { get; private set; }

    public List<StartPoint> startPoints;

    //The connections between scenes
    private static readonly Dictionary<Location, List<Location>> sceneConnections = new Dictionary<Location, List<Location>>() {
        {Location.PlayerHome, new List<Location>{ Location.Farm} },
        {Location.Farm, new List<Location>{ Location.PlayerHome, Location.Town } },
        {Location.Town, new List<Location>{Location.YodelRanch, Location.Forest, Location.Farm } },
        {Location.YodelRanch, new List<Location>{ Location.Town} },
        {Location.Forest, new List<Location>{Location.Town} }
    };

    private void Awake()
    {
        //If there is more than one instance, destroy the extra
        if (Instance != null && Instance != this)
        {
            Destroy(this);
        }
        else
        {
            //Set the static instance to this instance
            Instance = this;
        }
    }
    
    //Find the player's start position based on where he's coming from
    public Transform GetPlayerStartingPosition(SceneTransitionManager.Location enteringFrom)
    {
        //Tries to find the matching startpoint based on the Location given
        StartPoint startingPoint = startPoints.Find(x => x.enteringFrom == enteringFrom);

        //Return the transform
        return startingPoint.playerStart; 
    }
  
    //Get the next scene in the traversal path
    public static Location GetNextLocation(Location currentScene, Location finalDestination)
    {
        //Track visited locations
        Dictionary<Location, bool> visited = new Dictionary<Location, bool>();

        //Store previous location in the traversal path
        Dictionary<Location, Location> previousLocation = new Dictionary<Location, Location>();

        //Queue for BFS traversal
        Queue<Location> worklist = new Queue<Location>();

        //Mark the current scene as visited and start the traversal
        visited.Add(currentScene, false); 
        worklist.Enqueue(currentScene);

        //BFS traversal
        while(worklist.Count != 0)
        {
            Location scene = worklist.Dequeue(); 
            if(scene == finalDestination)
            {
                //Reconstruct the path and return the preceding scene
                while(previousLocation.ContainsKey(scene) && previousLocation[scene] != currentScene)
                {
                    scene = previousLocation[scene]; 
                }
                return scene; 
                
            }
            //Enqueue possible destinations connected to the current scene
            if (sceneConnections.ContainsKey(scene))
            {
                List<Location> possibleDestinations = sceneConnections[scene]; 

                foreach(Location neighbour in possibleDestinations)
                {
                    if (!visited.ContainsKey(neighbour))
                    {
                        visited.Add(neighbour, false);
                        previousLocation.Add(neighbour, scene);
                        worklist.Enqueue(neighbour);
                    }
                }
            }
        }

        return currentScene; 
    }

   
}


With that, the function to get the next scene is complete. The next step is to call the function in the CharacterMovement class and have the NavMesh agent move to the entrance of the next scene.

To get the position of the entrance of the next scene, we created a function, GetExitPosition() in the LocationManager class, and call it in the CharacterMovement class.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static SceneTransitionManager; 

public class LocationManager : MonoBehaviour
{
    public static LocationManager Instance { get; private set; }

    public List<StartPoint> startPoints;

    //The connections between scenes
    private static readonly Dictionary<Location, List<Location>> sceneConnections = new Dictionary<Location, List<Location>>() {
        {Location.PlayerHome, new List<Location>{ Location.Farm} },
        {Location.Farm, new List<Location>{ Location.PlayerHome, Location.Town } },
        {Location.Town, new List<Location>{Location.YodelRanch, Location.Forest, Location.Farm } },
        {Location.YodelRanch, new List<Location>{ Location.Town} },
        {Location.Forest, new List<Location>{Location.Town} }
    };

    private void Awake()
    {
        //If there is more than one instance, destroy the extra
        if (Instance != null && Instance != this)
        {
            Destroy(this);
        }
        else
        {
            //Set the static instance to this instance
            Instance = this;
        }
    }
    
    //Find the player's start position based on where he's coming from
    public Transform GetPlayerStartingPosition(SceneTransitionManager.Location enteringFrom)
    {
        //Tries to find the matching startpoint based on the Location given
        StartPoint startingPoint = startPoints.Find(x => x.enteringFrom == enteringFrom);

        //Return the transform
        return startingPoint.playerStart; 
    }

    public Transform GetExitPosition(Location exitingTo)
    {
        Transform startPoint = GetPlayerStartingPosition(exitingTo);
        return startPoint.parent.GetComponentInChildren<LocationEntryPoint>().transform;
        
    }
    

    //Get the next scene in the traversal path
    public static Location GetNextLocation(Location currentScene, Location finalDestination)
    {
        //Track visited locations
        Dictionary<Location, bool> visited = new Dictionary<Location, bool>();

        //Store previous location in the traversal path
        Dictionary<Location, Location> previousLocation = new Dictionary<Location, Location>();

        //Queue for BFS traversal
        Queue<Location> worklist = new Queue<Location>();

        //Mark the current scene as visited and start the traversal
        visited.Add(currentScene, false); 
        worklist.Enqueue(currentScene);

        //BFS traversal
        while(worklist.Count != 0)
        {
            Location scene = worklist.Dequeue(); 
            if(scene == finalDestination)
            {
                //Reconstruct the path and return the preceding scene
                while(previousLocation.ContainsKey(scene) && previousLocation[scene] != currentScene)
                {
                    scene = previousLocation[scene]; 
                }
                return scene; 
                
            }
            //Enqueue possible destinations connected to the current scene
            if (sceneConnections.ContainsKey(scene))
            {
                List<Location> possibleDestinations = sceneConnections[scene]; 

                foreach(Location neighbour in possibleDestinations)
                {
                    if (!visited.ContainsKey(neighbour))
                    {
                        visited.Add(neighbour, false);
                        previousLocation.Add(neighbour, scene);
                        worklist.Enqueue(neighbour);
                    }
                }
            }
        }

        return currentScene; 
    }

   
}

CharacterMovement.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

[RequireComponent(typeof(NavMeshAgent))]
public class CharacterMovement : MonoBehaviour
{

    protected NavMeshAgent agent;

    Vector3 destination;
    // Start is called before the first frame update
    protected virtual void Start()
    {
        agent = GetComponent<NavMeshAgent>();
        
    }


    public void ToggleMovement(bool enabled)
    {

        agent.enabled = enabled;
    }

    public bool IsMoving()
    {
        //If the agent is disabled it is automatically false
        if (!agent.enabled) return false; 
        float v = agent.velocity.sqrMagnitude;
        return v > 0; 
    }


    public void MoveTo(NPCLocationState locationState)
    {
        SceneTransitionManager.Location locationToMoveTo = locationState.location;
        SceneTransitionManager.Location currentLocation = SceneTransitionManager.Instance.currentLocation; 
        //Check if location is the same 
        if(locationToMoveTo == SceneTransitionManager.Instance.currentLocation)
        {
            //Check if the coord is the same 
            NavMeshHit hit;
            //Sample the nearest valid position on the Navmesh
            NavMesh.SamplePosition(locationState.coord, out hit, 10f, NavMesh.AllAreas);

            //If the npc is already where he should be just carry on
            if (Vector3.Distance(transform.position, hit.position) < 1) return;
            agent.SetDestination(hit.position);
            
            return; 
        }

        SceneTransitionManager.Location nextLocation = LocationManager.GetNextLocation(currentLocation, locationToMoveTo);


        //Find the exit point
        Vector3 destination = LocationManager.Instance.GetExitPosition(nextLocation).position;
        agent.SetDestination(destination); 
        
    }


}

Lastly, whenever the NPC touches the exit point, we want to destroy their GameObject.

LocationEntryPoint.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class LocationEntryPoint : MonoBehaviour
{
    [SerializeField]
    SceneTransitionManager.Location locationToSwitch;

    private void OnTriggerEnter(Collider other)
    {
        //Check if the collider belongs to the player
        if(other.tag == "Player")
        {
            //Switch scenes to the location of the entry point
            SceneTransitionManager.Instance.SwitchLocation(locationToSwitch);
        }

        //Characters walking through here and items thrown will be despawned
        if(other.tag == "Item")
        {
            Destroy(other.gameObject);
        }
        
    }



}

Don’t forget to add a Rigidbody to the character Prefabs and mark it as Kinematic to ensure OnTriggerEnter works with the characters.

Make sure is Kinematic is checked.

It’s optional, but you can add the highlighted lines in the PlayerController class for debugging purposes.

PlayerController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    //Movement Components
    private CharacterController controller; 
    private Animator animator;

    private float moveSpeed = 4f;

    [Header("Movement System")]
    public float walkSpeed = 4f;
    public float runSpeed = 8f;

    private float gravity = 9.81f;


    //Interaction components
    PlayerInteraction playerInteraction; 

    // Start is called before the first frame update
    void Start()
    {
        //Get movement components
        controller = GetComponent<CharacterController>();
        animator = GetComponent<Animator>();

        //Get interaction component
        playerInteraction = GetComponentInChildren<PlayerInteraction>(); 

    }

    // Update is called once per frame
    void Update()
    {
        //Runs the function that handles all movement
        Move();

        //Runs the function that handles all interaction
        Interact();


        //Debugging purposes only
        //Skip the time when the right square bracket is pressed
        if (Input.GetKey(KeyCode.RightBracket))
        {
            TimeManager.Instance.Tick();
        }

        //Toggle relationship panel
        if (Input.GetKeyDown(KeyCode.R))
        {
            UIManager.Instance.ToggleRelationshipPanel();
        }

        if (Input.GetKeyDown(KeyCode.N))
        {
            SceneTransitionManager.Location location = LocationManager.GetNextLocation(SceneTransitionManager.Location.PlayerHome, SceneTransitionManager.Location.Farm);
            Debug.Log(location);
        }
    }

    public void Interact()
    {
        //Tool interaction
        if (Input.GetButtonDown("Fire1"))
        {
            //Interact
            playerInteraction.Interact(); 
        }

        //Item interaction
        if (Input.GetButtonDown("Fire2"))
        {
            playerInteraction.ItemInteract();
        }

        //Keep items 
        if (Input.GetButtonDown("Fire3"))
        {
            playerInteraction.ItemKeep();
        }
    }

    
    public void Move()
    {
        //Get the horizontal and vertical inputs as a number
        float horizontal = Input.GetAxisRaw("Horizontal");
        float vertical = Input.GetAxisRaw("Vertical");

        //Direction in a normalised vector
        Vector3 dir = new Vector3(horizontal, 0f, vertical).normalized;
        Vector3 velocity = moveSpeed * Time.deltaTime * dir;

        if (controller.isGrounded)
        {
            velocity.y = 0; 
        }
        velocity.y -= Time.deltaTime * gravity; 

        //Is the sprint key pressed down?
        if (Input.GetButton("Sprint"))
        {
            //Set the animation to run and increase our movespeed
            moveSpeed = runSpeed;
            animator.SetBool("Running", true);
        } else
        {
            //Set the animation to walk and decrease our movespeed
            moveSpeed = walkSpeed;
            animator.SetBool("Running", false);
        }


        //Check if there is movement
        if (dir.magnitude >= 0.1f)
        {
            //Look towards that direction
            transform.rotation = Quaternion.LookRotation(dir);

            //Move
            controller.Move(velocity); 
            
        }

        //Animation speed parameter
        animator.SetFloat("Speed", dir.magnitude); 



    }
}

c. The NPC moves from a scene the player is not in, to the scene the player is in.

With that, we are down to the last scenario. This is relatively simple, in the NPCManager class, we will add a new function, SpawnInNPC() to instantiate the NPC according to the passed parameters.

Under the UpdateNPCLocations() function, we will check for a change in scene. If there is a change in scene, and the new scene is where we are, we will instantiate the NPC at the starting position of where it is entering from using the SpawnInNPC() function.

NPCManager.cs

using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public class NPCManager : MonoBehaviour, ITimeTracker
{
    public static NPCManager Instance { get; private set; }

    

    private void Awake()
    {
        //If there is more than one instance, destroy the extra
        if (Instance != null && Instance != this)
        {
            Destroy(this);
        }
        else
        {
            //Set the static instance to this instance
            Instance = this;
        }
    }

    

    List<CharacterData> characters = null; 

    List<NPCScheduleData> npcSchedules;

    [SerializeField]
    List<NPCLocationState> npcLocations; 


    //Load all character data
    public List<CharacterData> Characters()
    {
        if (characters != null) return characters; 
        CharacterData[] characterDatabase = Resources.LoadAll<CharacterData>("Characters");
        characters = characterDatabase.ToList();
        return characters;
    }

    private void OnEnable()
    {
        //Load NPC Schedules
        NPCScheduleData[] schedules = Resources.LoadAll<NPCScheduleData>("Schedules");
        npcSchedules = schedules.ToList();
        InitNPCLocations();
        
    }

    private void Start()
    {
        //Add this to TimeManager's Listener list
        TimeManager.Instance.RegisterTracker(this);
        SceneTransitionManager.Instance.onLocationLoad.AddListener(RenderNPCs); 
    }

    private void InitNPCLocations()
    {
        npcLocations = new List<NPCLocationState>(); 
        foreach(CharacterData character in Characters())
        {
            npcLocations.Add(new NPCLocationState(character)); 
        }
        
    }

    void RenderNPCs()
    {
        foreach(NPCLocationState npc in npcLocations)
        {
            if(npc.location == SceneTransitionManager.Instance.currentLocation)
            {
                Instantiate(npc.character.prefab, npc.coord, Quaternion.Euler(npc.facing)); 
            }
        }
    }

    void SpawnInNPC(CharacterData npc, SceneTransitionManager.Location comingFrom)
    {
        Transform start = LocationManager.Instance.GetPlayerStartingPosition(comingFrom);
        Instantiate(npc.prefab, start.position, start.rotation);

    }

    public void ClockUpdate(GameTimestamp timestamp) {
        UpdateNPCLocations(timestamp); 
    }

    public NPCLocationState GetNPCLocation(string name)
    {
        return npcLocations.Find(x => x.character.name == name); 
    }

    private void UpdateNPCLocations(GameTimestamp timestamp)
    {
        for (int i = 0; i < npcLocations.Count; i++)
        { 
            NPCLocationState npcLocator = npcLocations[i];
            SceneTransitionManager.Location previousLocation = npcLocator.location; 
            //Find the schedule belonging to the NPC
            NPCScheduleData schedule = npcSchedules.Find(x => x.character == npcLocator.character);
            if(schedule == null)
            {
                Debug.LogError("No schedule found for " + npcLocator.character.name);
                continue; 
            }

            //Current time 
            
            GameTimestamp.DayOfTheWeek dayOfWeek = timestamp.GetDayOfTheWeek();

            //Find the events that correspond to the current time
            //E.g. if the event is set to 8am, the current time must be after 8am, so the hour of timeNow has to be greater than the event
            //Either the day of the week matches or it is set to ignore the day of the week

            //In future we will also have the Seasons factored in
            List<ScheduleEvent> eventsToConsider = schedule.npcScheduleList.FindAll(x => x.time.hour <= timestamp.hour && (x.dayOfTheWeek == dayOfWeek || x.ignoreDayOfTheWeek));
            //Check if the events are empty
            if(eventsToConsider.Count < 1)
            {
                Debug.LogError("None found for " + npcLocator.character.name);
                Debug.LogError(timestamp.hour); 
                continue;
            }
            
            //Remove all the events with the hour that is lower than the max time (The time has already elapsed)
            int maxHour = eventsToConsider.Max(x => x.time.hour);
            eventsToConsider.RemoveAll(x => x.time.hour < maxHour);

            //Get the event with the highest priority
            ScheduleEvent eventToExecute = eventsToConsider.OrderByDescending(x => x.priority).First();
            //Set the NPC Locator value accordingly
            npcLocations[i] = new NPCLocationState(schedule.character, eventToExecute.location, eventToExecute.coord, eventToExecute.facing);
            SceneTransitionManager.Location newLocation = eventToExecute.location; 
            //If there has been a change in location
            if(newLocation != previousLocation)
            {
                Debug.Log("New location: " + newLocation); 
                //If the location is where we are
                if(SceneTransitionManager.Instance.currentLocation == newLocation)
                {
                    SpawnInNPC(schedule.character, previousLocation);
                }
            }
        }

    }
}

6. Conclusion

In this article, we covered how to implement the NPC Movement.

If you are a Patreon supporter, you can download the project files for what we have done so far. To use the files, you will have to unzip the file (7-Zip can help you do that).

Here is the final code for all the scripts we have worked with today:

CharacterMovement.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

[RequireComponent(typeof(NavMeshAgent))]
public class CharacterMovement : MonoBehaviour
{

    protected NavMeshAgent agent;

    Vector3 destination;
    // Start is called before the first frame update
    protected virtual void Start()
    {
        agent = GetComponent<NavMeshAgent>();
        
    }


    public void ToggleMovement(bool enabled)
    {

        agent.enabled = enabled;
    }

    public bool IsMoving()
    {
        //If the agent is disabled it is automatically false
        if (!agent.enabled) return false; 
        float v = agent.velocity.sqrMagnitude;
        return v > 0; 
    }


    public void MoveTo(NPCLocationState locationState)
    {
        SceneTransitionManager.Location locationToMoveTo = locationState.location;
        SceneTransitionManager.Location currentLocation = SceneTransitionManager.Instance.currentLocation; 
        //Check if location is the same 
        if (locationToMoveTo == currentLocation)
        {
            //Check if the coord is the same 
            NavMeshHit hit;
            //Sample the nearest valid position on the Navmesh
            NavMesh.SamplePosition(locationState.coord, out hit, 10f, NavMesh.AllAreas);

            //If the npc is already where he should be just carry on
            if (Vector3.Distance(transform.position, hit.position) < 1) return;
            agent.SetDestination(hit.position);
            
            return; 
        }

        SceneTransitionManager.Location nextLocation = LocationManager.GetNextLocation(currentLocation, locationToMoveTo);


        //Find the exit point
        Vector3 destination = LocationManager.Instance.GetExitPosition(nextLocation).position;
        agent.SetDestination(destination); 
        
    }


}

AnimalMovement.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

[RequireComponent(typeof(NavMeshAgent))]
public class AnimalMovement : CharacterMovement
{
    //Time before the navmesh sets another destination to move towards
    [SerializeField] float cooldownTime;
    float cooldownTimer;

     protected override void Start()
    {
        base.Start();
        cooldownTimer = Random.Range(0, cooldownTime);
    }

    // Update is called once per frame
    void Update()
    {
        Wander(); 
    }

    void Wander()
    {
        if (!agent.enabled) return; 
        if(cooldownTimer > 0)
        {
            cooldownTimer -= Time.deltaTime;
            return;
        }

        if (!agent.pathPending && agent.remainingDistance < 0.5f)
        {
            //Generate a random direction within a sphere with a radius of 10
            Vector3 randomDirection = Random.insideUnitSphere * 10f;

            //Offset the random direction by the current position of the animal
            randomDirection += transform.position;

            NavMeshHit hit;
            //Sample the nearest valid position on the Navmesh
            NavMesh.SamplePosition(randomDirection, out hit, 10f, NavMesh.AllAreas);

            //Get the final target position
            Vector3 targetPos = hit.position;

            agent.SetDestination(targetPos);
            cooldownTimer = cooldownTime; 
        }
        

    }
}

InteractableCharacter.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(CharacterMovement))]
public class InteractableCharacter : InteractableObject
{
    public CharacterData characterData;

    //Cache the relationship data of the NPC so we can access it
    NPCRelationshipState relationship;

    //The rotation it should be facing by default
    Quaternion defaultRotation;
    //Check if the LookAt coroutine is currently being executed
    bool isTurning = false;

    CharacterMovement movement; 

    private void Start()
    {
        relationship = RelationshipStats.GetRelationship(characterData);

        movement = GetComponent<CharacterMovement>(); 

        //Cache the original rotation of the characters
        defaultRotation = transform.rotation;

        //Add listener
        GameStateManager.Instance.onIntervalUpdate.AddListener(OnIntervalUpdate);
    }

    void OnIntervalUpdate()
    {
        //Get data on its location
        NPCLocationState locationState = NPCManager.Instance.GetNPCLocation(characterData.name);
        movement.MoveTo(locationState);
        StartCoroutine(LookAt(Quaternion.Euler(locationState.facing)));

    }

    public override void Pickup()
    {
        LookAtPlayer();
        TriggerDialogue(); 
    }

    #region Rotation
    void LookAtPlayer()
    {
        //Get the player's transform
        Transform player = FindObjectOfType<PlayerController>().transform;

        //Get a vector for the direction towards the player
        Vector3 dir = player.position - transform.position;
        //Lock the y axis of the vector so the npc doesn't look up or down to face the player
        dir.y = 0;
        //Convert the direction vector into a quaternion
        Quaternion lookRot = Quaternion.LookRotation(dir);
        StartCoroutine(LookAt(lookRot));

    }
    //Coroutine for the character to progressively turn towards a rotation
    IEnumerator LookAt(Quaternion lookRot)
    {
        //Check if the coroutine is already running
        if (isTurning)
        {
            //Stop the coroutine 
            isTurning = false;
        }
        else
        {
            isTurning = true; 
        }

        while(transform.rotation != lookRot)
        {
            if (!isTurning)
            {
                //Stop coroutine execution
                yield break; 
            }

            //Dont do anything if moving
            
            if(!movement.IsMoving())transform.rotation = Quaternion.RotateTowards(transform.rotation, lookRot, 720 * Time.fixedDeltaTime);
            yield return new WaitForFixedUpdate(); 
        }
        isTurning = false; 
    }

    //Rotate back to its original rotation
    void ResetRotation()
    {
        StartCoroutine(LookAt(defaultRotation)); 
    }
    #endregion

    #region Conversation Interactions
    void TriggerDialogue()
    {
        movement.ToggleMovement(false);
        //Check if the player is holding anything 
        if (InventoryManager.Instance.SlotEquipped(InventorySlot.InventoryType.Item))
        {
            //Switch over to the Gift Dialogue function
            GiftDialogue();
            return; 
        }

        List<DialogueLine> dialogueToHave = characterData.defaultDialogue;

        System.Action onDialogueEnd = () =>
        {
            //Allow for movement
            movement.ToggleMovement(true);
            //Continue going to its destination if it was on the way/Reset its initial position
            OnIntervalUpdate(); 
        };

        //Have the character reset their rotation after the conversation is over
        //onDialogueEnd += ResetRotation; 

        //Do the checks to determine which dialogue to put out

        //Is the player meeting for the first time?
        if (RelationshipStats.FirstMeeting(characterData))
        {
            //Assign the first meet dialogue
            dialogueToHave = characterData.onFirstMeet;
            onDialogueEnd += OnFirstMeeting;

        }

        if (RelationshipStats.IsFirstConversationOfTheDay(characterData))
        {
            onDialogueEnd += OnFirstConversation;
        }

        DialogueManager.Instance.StartDialogue(dialogueToHave, onDialogueEnd);
    }

    //Handle Gift Giving
    void GiftDialogue()
    {
        if (!EligibleForGift()) return;

        //Get the ItemSlotData of what the player is holding
        ItemSlotData handSlot = InventoryManager.Instance.GetEquippedSlot(InventorySlot.InventoryType.Item);

        List<DialogueLine> dialogueToHave = characterData.neutralGiftDialogue;

        System.Action onDialogueEnd = () =>
        {
            //Allow for movement
            movement.ToggleMovement(true);

            //Continue going to its destination if it was on the way/Reset its initial position
            OnIntervalUpdate();
            //Mark gift as given for today
            relationship.giftGivenToday = true;

            //Remove the item from the player's hand
            InventoryManager.Instance.ConsumeItem(handSlot); 
        };

        //Have the character reset their rotation after the conversation is over 
        //onDialogueEnd += ResetRotation;

        bool isBirthday = RelationshipStats.IsBirthday(characterData);

        //The friendship points to add from the gift
        int pointsToAdd = 0;
        //Do the checks to determine which dialogue to put out
        switch(RelationshipStats.GetReactionToGift(characterData, handSlot.itemData))
        {
            case RelationshipStats.GiftReaction.Like:
                dialogueToHave = characterData.likedGiftDialogue;
                //80
                pointsToAdd = 80;
                if (isBirthday) dialogueToHave = characterData.birthdayLikedGiftDialogue;
                break;

            case RelationshipStats.GiftReaction.Dislike:
                dialogueToHave = characterData.dislikedGiftDialogue;
                //-20
                pointsToAdd = -20;
                if (isBirthday) dialogueToHave = characterData.birthdayDislikedGiftDialogue;
                break;
            case RelationshipStats.GiftReaction.Neutral:
                dialogueToHave = characterData.neutralGiftDialogue;
                //20
                pointsToAdd = 20;
                if (isBirthday) dialogueToHave = characterData.birthdayNeutralGiftDialogue;

                break; 
        }
        //Birthday multiplier
        if (isBirthday) pointsToAdd *= 8;


        RelationshipStats.AddFriendPoints(characterData, pointsToAdd);

        DialogueManager.Instance.StartDialogue(dialogueToHave, onDialogueEnd);
    }

    //Check if the character can be given a gift
    bool EligibleForGift()
    {
        //Reject condition: Player has not unlocked this character yet
        if (RelationshipStats.FirstMeeting(characterData))
        {
            DialogueManager.Instance.StartDialogue(DialogueManager.CreateSimpleMessage("You have not unlocked this character yet.")); 
            return false; 
        }

        //Reject condition: Player has already given this character a gift today
        if (RelationshipStats.GiftGivenToday(characterData))
        {
            DialogueManager.Instance.StartDialogue(DialogueManager.CreateSimpleMessage($"You have already given {characterData.name} a gift today."));
            return false; 
        }


        return true; 
    }

    void OnFirstMeeting()
    {
        //Unlock the character on the relationships
        RelationshipStats.UnlockCharacter(characterData);
        //Update the relationship data
        relationship = RelationshipStats.GetRelationship(characterData);
    }

    void OnFirstConversation()
    {
        Debug.Log("This is the first conversation of the day");
        //Add 20 friend points 
        RelationshipStats.AddFriendPoints(characterData, 20);
 
        relationship.hasTalkedToday = true;
    }
    #endregion
}

GameStateManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events; 

public class GameStateManager : MonoBehaviour, ITimeTracker
{
    public static GameStateManager Instance { get; private set; }

    //Check if the screen has finished fading out
    bool screenFadedOut;

    //To track interval updates
    private int minutesElapsed = 0;

    //Event triggered every 15 minutes
    public UnityEvent onIntervalUpdate; 

    private void Awake()
    {
        //If there is more than one instance, destroy the extra
        if (Instance != null && Instance != this)
        {
            Destroy(this);
        }
        else
        {
            //Set the static instance to this instance
            Instance = this;
        }
    }

    // Start is called before the first frame update
    void Start()
    {
        //Add this to TimeManager's Listener list
        TimeManager.Instance.RegisterTracker(this); 
    }

    public void ClockUpdate(GameTimestamp timestamp)
    {
        UpdateShippingState(timestamp); 
        UpdateFarmState(timestamp);
        IncubationManager.UpdateEggs();

        if(timestamp.hour == 0 && timestamp.minute == 0)
        {
            OnDayReset(); 
        }

        if(minutesElapsed >= 15)
        {
            minutesElapsed = 0;
            onIntervalUpdate?.Invoke();

        } else
        {
            minutesElapsed++; 
        }
    }

    //Called when the day has been reset
    void OnDayReset()
    {
        Debug.Log("Day has been reset"); 
        foreach(NPCRelationshipState npc in RelationshipStats.relationships)
        {
            npc.hasTalkedToday = false;
            npc.giftGivenToday = false; 
        }
        AnimalFeedManager.ResetFeedboxes();
        AnimalStats.OnDayReset();

       
    }

    void UpdateShippingState(GameTimestamp timestamp)
    {
        //Check if the hour is here (Exactly 1800 hours)
        if(timestamp.hour == ShippingBin.hourToShip && timestamp.minute == 0)
        {
            ShippingBin.ShipItems();
        }
    }

    void UpdateFarmState(GameTimestamp timestamp)
    {
        //Update the Land and Crop Save states as long as the player is outside of the Farm scene
        if (SceneTransitionManager.Instance.currentLocation != SceneTransitionManager.Location.Farm)
        {
            //If there is nothing to update to begin with, stop 
            if (LandManager.farmData == null) return;

            //Retrieve the Land and Farm data from the static variable
            List<LandSaveState> landData = LandManager.farmData.Item1;
            List<CropSaveState> cropData = LandManager.farmData.Item2;

            //If there are no crops planted, we don't need to worry about updating anything
            if (cropData.Count == 0) return;

            for (int i = 0; i < cropData.Count; i++)
            {
                //Get the crop and corresponding land data
                CropSaveState crop = cropData[i];
                LandSaveState land = landData[crop.landID];

                //Check if the crop is already wilted
                if (crop.cropState == CropBehaviour.CropState.Wilted) continue;

                //Update the Land's state
                land.ClockUpdate(timestamp);
                //Update the crop's state based on the land state
                if (land.landStatus == Land.LandStatus.Watered)
                {
                    crop.Grow();
                }
                else if (crop.cropState != CropBehaviour.CropState.Seed)
                {
                    crop.Wither();
                }

                //Update the element in the array
                cropData[i] = crop;
                landData[crop.landID] = land;

            }

            LandManager.farmData.Item2.ForEach((CropSaveState crop) => {
            /*LandManager.farmData.Item2.ForEach((CropSaveState crop) => {
                Debug.Log(crop.seedToGrow + "\n Health: " + crop.health + "\n Growth: " + crop.growth + "\n State: " + crop.cropState.ToString());
            });*/
        }
    }

    public void Sleep()
    {
        //Call a fadeout
        UIManager.Instance.FadeOutScreen();
        screenFadedOut = false;
        StartCoroutine(TransitionTime()); 
    }

    IEnumerator TransitionTime()
    {
        //Calculate how many ticks we need to advance the time to 6am

        //Get the time stamp of 6am the next day
        GameTimestamp timestampOfNextDay = TimeManager.Instance.GetGameTimestamp();
        timestampOfNextDay.day += 1;
        timestampOfNextDay.hour = 6;
        timestampOfNextDay.minute = 0;
        Debug.Log(timestampOfNextDay.day + " " + timestampOfNextDay.hour + ":" + timestampOfNextDay.minute);
        
        
        //Wait for the scene to finish fading out before loading the next scene
        while (!screenFadedOut)
        {
            yield return new WaitForSeconds(1f);
        }
        TimeManager.Instance.SkipTime(timestampOfNextDay);
        //Save
        SaveManager.Save(ExportSaveState());
        //Reset the boolean
        screenFadedOut = false;
        UIManager.Instance.ResetFadeDefaults();

    }

    //Called when the screen has faded out
    public void OnFadeOutComplete()
    {
        screenFadedOut = true;

    }

    public GameSaveState ExportSaveState()
    {
        //Retrieve Farm Data 
        FarmSaveState farmSaveState = FarmSaveState.Export();

        //Retrieve inventory data 
        InventorySaveState inventorySaveState = InventorySaveState.Export();

        PlayerSaveState playerSaveState = PlayerSaveState.Export();

        //Time
        GameTimestamp timestamp = TimeManager.Instance.GetGameTimestamp();
        //Relationships
        RelationshipSaveState relationshipSaveState = RelationshipSaveState.Export();

        return new GameSaveState(farmSaveState, inventorySaveState, timestamp, playerSaveState, relationshipSaveState);
    }

    public void LoadSave()
    {
        //Retrieve the loaded save
        GameSaveState save = SaveManager.Load();
        //Load up the parts

        //Time
        TimeManager.Instance.LoadTime(save.timestamp);

        //Inventory
        save.inventorySaveState.LoadData();

        //Farming data 
        save.farmSaveState.LoadData();

        //Player Stats
        save.playerSaveState.LoadData();

        //Relationship stats
        save.relationshipSaveState.LoadData();

    }
}

LocationManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static SceneTransitionManager; 

public class LocationManager : MonoBehaviour
{
    public static LocationManager Instance { get; private set; }

    public List<StartPoint> startPoints;

    //The connections between scenes
    private static readonly Dictionary<Location, List<Location>> sceneConnections = new Dictionary<Location, List<Location>>() {
        {Location.PlayerHome, new List<Location>{ Location.Farm} },
        {Location.Farm, new List<Location>{ Location.PlayerHome, Location.Town } },
        {Location.Town, new List<Location>{Location.YodelRanch, Location.Forest, Location.Farm } },
        {Location.YodelRanch, new List<Location>{ Location.Town} },
        {Location.Forest, new List<Location>{Location.Town} }
    };

    private void Awake()
    {
        //If there is more than one instance, destroy the extra
        if (Instance != null && Instance != this)
        {
            Destroy(this);
        }
        else
        {
            //Set the static instance to this instance
            Instance = this;
        }
    }
    
    //Find the player's start position based on where he's coming from
    public Transform GetPlayerStartingPosition(SceneTransitionManager.Location enteringFrom)
    {
        //Tries to find the matching startpoint based on the Location given
        StartPoint startingPoint = startPoints.Find(x => x.enteringFrom == enteringFrom);

        //Return the transform
        return startingPoint.playerStart; 
    }

    public Transform GetExitPosition(Location exitingTo)
    {
        Transform startPoint = GetPlayerStartingPosition(exitingTo);
        return startPoint.parent.GetComponentInChildren<LocationEntryPoint>().transform;
        
    }
    
    //Get the next scene in the traversal path
    public static Location GetNextLocation(Location currentScene, Location finalDestination)
    {
        //Track visited locations
        Dictionary<Location, bool> visited = new Dictionary<Location, bool>();

        //Store previous location in the traversal path
        Dictionary<Location, Location> previousLocation = new Dictionary<Location, Location>();

        //Queue for BFS traversal
        Queue<Location> worklist = new Queue<Location>();

        //Mark the current scene as visited and start the traversal
        visited.Add(currentScene, false); 
        worklist.Enqueue(currentScene);

        //BFS traversal
        while(worklist.Count != 0)
        {
            Location scene = worklist.Dequeue(); 
            if(scene == finalDestination)
            {
                //Reconstruct the path and return the preceding scene
                while(previousLocation.ContainsKey(scene) && previousLocation[scene] != currentScene)
                {
                    scene = previousLocation[scene]; 
                }
                return scene; 
                
            }
            //Enqueue possible destinations connected to the current scene
            if (sceneConnections.ContainsKey(scene))
            {
                List<Location> possibleDestinations = sceneConnections[scene]; 

                foreach(Location neighbour in possibleDestinations)
                {
                    if (!visited.ContainsKey(neighbour))
                    {
                        visited.Add(neighbour, false);
                        previousLocation.Add(neighbour, scene);
                        worklist.Enqueue(neighbour);
                    }
                }
            }
        }

        return currentScene; 
    }

   
}

LocationEntryPoint.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class LocationEntryPoint : MonoBehaviour
{
    [SerializeField]
    SceneTransitionManager.Location locationToSwitch;

    private void OnTriggerEnter(Collider other)
    {
        //Check if the collider belongs to the player
        if(other.tag == "Player")
        {
            //Switch scenes to the location of the entry point
            SceneTransitionManager.Instance.SwitchLocation(locationToSwitch);
        }

        //Characters walking through here and items thrown will be despawned
        if(other.tag == "Item")
        {
            Destroy(other.gameObject);
        }
        
    }



}

PlayerController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    //Movement Components
    private CharacterController controller; 
    private Animator animator;

    private float moveSpeed = 4f;

    [Header("Movement System")]
    public float walkSpeed = 4f;
    public float runSpeed = 8f;

    private float gravity = 9.81f;


    //Interaction components
    PlayerInteraction playerInteraction; 

    // Start is called before the first frame update
    void Start()
    {
        //Get movement components
        controller = GetComponent<CharacterController>();
        animator = GetComponent<Animator>();

        //Get interaction component
        playerInteraction = GetComponentInChildren<PlayerInteraction>(); 

    }

    // Update is called once per frame
    void Update()
    {
        //Runs the function that handles all movement
        Move();

        //Runs the function that handles all interaction
        Interact();


        //Debugging purposes only
        //Skip the time when the right square bracket is pressed
        if (Input.GetKey(KeyCode.RightBracket))
        {
            TimeManager.Instance.Tick();
        }

        //Toggle relationship panel
        if (Input.GetKeyDown(KeyCode.R))
        {
            UIManager.Instance.ToggleRelationshipPanel();
        }

        if (Input.GetKeyDown(KeyCode.N))
        {
            SceneTransitionManager.Location location = LocationManager.GetNextLocation(SceneTransitionManager.Location.PlayerHome, SceneTransitionManager.Location.Farm);
            Debug.Log(location);
        }
    }

    public void Interact()
    {
        //Tool interaction
        if (Input.GetButtonDown("Fire1"))
        {
            //Interact
            playerInteraction.Interact(); 
        }

        //Item interaction
        if (Input.GetButtonDown("Fire2"))
        {
            playerInteraction.ItemInteract();
        }

        //Keep items 
        if (Input.GetButtonDown("Fire3"))
        {
            playerInteraction.ItemKeep();
        }
    }

    
    public void Move()
    {
        //Get the horizontal and vertical inputs as a number
        float horizontal = Input.GetAxisRaw("Horizontal");
        float vertical = Input.GetAxisRaw("Vertical");

        //Direction in a normalised vector
        Vector3 dir = new Vector3(horizontal, 0f, vertical).normalized;
        Vector3 velocity = moveSpeed * Time.deltaTime * dir;

        if (controller.isGrounded)
        {
            velocity.y = 0; 
        }
        velocity.y -= Time.deltaTime * gravity; 

        //Is the sprint key pressed down?
        if (Input.GetButton("Sprint"))
        {
            //Set the animation to run and increase our movespeed
            moveSpeed = runSpeed;
            animator.SetBool("Running", true);
        } else
        {
            //Set the animation to walk and decrease our movespeed
            moveSpeed = walkSpeed;
            animator.SetBool("Running", false);
        }


        //Check if there is movement
        if (dir.magnitude >= 0.1f)
        {
            //Look towards that direction
            transform.rotation = Quaternion.LookRotation(dir);

            //Move
            controller.Move(velocity); 
            
        }

        //Animation speed parameter
        animator.SetFloat("Speed", dir.magnitude); 



    }
}

NPCManager.cs

using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public class NPCManager : MonoBehaviour, ITimeTracker
{
    public static NPCManager Instance { get; private set; }

    

    private void Awake()
    {
        //If there is more than one instance, destroy the extra
        if (Instance != null && Instance != this)
        {
            Destroy(this);
        }
        else
        {
            //Set the static instance to this instance
            Instance = this;
        }
    }

    

    List<CharacterData> characters = null; 

    List<NPCScheduleData> npcSchedules;

    [SerializeField]
    List<NPCLocationState> npcLocations; 


    //Load all character data
    public List<CharacterData> Characters()
    {
        if (characters != null) return characters; 
        CharacterData[] characterDatabase = Resources.LoadAll<CharacterData>("Characters");
        characters = characterDatabase.ToList();
        return characters;
    }

    private void OnEnable()
    {
        //Load NPC Schedules
        NPCScheduleData[] schedules = Resources.LoadAll<NPCScheduleData>("Schedules");
        npcSchedules = schedules.ToList();
        InitNPCLocations();
        
    }

    private void Start()
    {
        //Add this to TimeManager's Listener list
        TimeManager.Instance.RegisterTracker(this);
        SceneTransitionManager.Instance.onLocationLoad.AddListener(RenderNPCs); 
    }

    private void InitNPCLocations()
    {
        npcLocations = new List<NPCLocationState>(); 
        foreach(CharacterData character in Characters())
        {
            npcLocations.Add(new NPCLocationState(character)); 
        }
        
    }

    void RenderNPCs()
    {
        foreach(NPCLocationState npc in npcLocations)
        {
            if(npc.location == SceneTransitionManager.Instance.currentLocation)
            {
                Instantiate(npc.character.prefab, npc.coord, Quaternion.Euler(npc.facing)); 
            }
        }
    }

    void SpawnInNPC(CharacterData npc, SceneTransitionManager.Location comingFrom)
    {
        Transform start = LocationManager.Instance.GetPlayerStartingPosition(comingFrom);
        Instantiate(npc.prefab, start.position, start.rotation);

    }

    public void ClockUpdate(GameTimestamp timestamp) {
        UpdateNPCLocations(timestamp); 
    }

    public NPCLocationState GetNPCLocation(string name)
    {
        return npcLocations.Find(x => x.character.name == name); 
    }

    private void UpdateNPCLocations(GameTimestamp timestamp)
    {
        for (int i = 0; i < npcLocations.Count; i++)
        {
            NPCLocationState npcLocator = npcLocations[i];
            SceneTransitionManager.Location previousLocation = npcLocator.location; 
            //Find the schedule belonging to the NPC
            NPCScheduleData schedule = npcSchedules.Find(x => x.character == npcLocator.character);
            if(schedule == null)
            {
                Debug.LogError("No schedule found for " + npcLocator.character.name);
                continue; 
            }

            //Current time 
            
            GameTimestamp.DayOfTheWeek dayOfWeek = timestamp.GetDayOfTheWeek();

            //Find the events that correspond to the current time
            //E.g. if the event is set to 8am, the current time must be after 8am, so the hour of timeNow has to be greater than the event
            //Either the day of the week matches or it is set to ignore the day of the week

            //In future we will also have the Seasons factored in
            List<ScheduleEvent> eventsToConsider = schedule.npcScheduleList.FindAll(x => x.time.hour <= timestamp.hour && (x.dayOfTheWeek == dayOfWeek || x.ignoreDayOfTheWeek));
            //Check if the events are empty
            if(eventsToConsider.Count < 1)
            {
                Debug.LogError("None found for " + npcLocator.character.name);
                Debug.LogError(timestamp.hour); 
                continue;
            }
            
            //Remove all the events with the hour that is lower than the max time (The time has already elapsed)
            int maxHour = eventsToConsider.Max(x => x.time.hour);
            eventsToConsider.RemoveAll(x => x.time.hour < maxHour);

            //Get the event with the highest priority
            ScheduleEvent eventToExecute = eventsToConsider.OrderByDescending(x => x.priority).First();
            //Set the NPC Locator value accordingly
            npcLocations[i] = new NPCLocationState(schedule.character, eventToExecute.location, eventToExecute.coord, eventToExecute.facing);
            SceneTransitionManager.Location newLocation = eventToExecute.location; 
            //If there has been a change in location
            if(newLocation != previousLocation)
            {
                Debug.Log("New location: " + newLocation); 
                //If the location is where we are
                if(SceneTransitionManager.Instance.currentLocation == newLocation)
                {
                    SpawnInNPC(schedule.character, previousLocation);
                }
            }
        }

    }







}

CharacterRenderer.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(CharacterMovement), typeof(Animator))]
public class CharacterRenderer : MonoBehaviour
{
    CharacterMovement movement;
    Animator animator; 
    // Start is called before the first frame update
    void Start()
    {
        animator = GetComponent<Animator>();
        movement = GetComponent<CharacterMovement>(); 
    }

    // Update is called once per frame
    void Update()
    {
        animator.SetBool("Walk", movement.IsMoving()); 
    }
}

There are 2 comments:

  1. Hi, i have some bugs when the NPC is going to another scene, the NPC will stuck on the GameObject door (something like the npc GameObject is not destroyed) but if i go to outside the door (change the scene) the NPC will be on the place, what happens?

Leave a Reply

Your email address will not be published. Required fields are marked *

Note: You can use Markdown to format your comments.

For security, use of Google's reCAPTCHA service is required which is subject to the Google Privacy Policy and Terms of Use.

I agree to these terms.

This site uses Akismet to reduce spam. Learn how your comment data is processed.