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.
- Update Project Version
- Make NPC Snap to the NavMesh Surface
- Fix Console Error
- CharacterMovement Class
- Set Up NPC Movement
- 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.
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.
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.
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.
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:
- The NPC moves from one point to another within the same scene.
- The NPC moves from the scene the player is in, to another scene the player is not in.
- 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.
Rearrange the transitions just like what we did previously for the chickens.
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.
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.
The loop continues until the final destination, Forest is dequeued.
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.
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()); } }
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?
Hi Fathurrachman, what does the error say?