Creating a Farming RPG (like Harvest Moon) in Unity – Part 32: Touch Ups & Weather System

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 32 of our guide here, where we added touchups to our in-game scenes and a new weather system. You can also find Part 31 of our guide here, where we went through the food system, Inn characters and lighting.

  1. Touching up scenes and adding the town square
    1. Adding new furniture from imported asset packs
    2. Creating the town square
  2. Building our Weather System
    1. Creating our Rain and Snow particles
    2. Programming the Weather System
    3. Implementing the Weather Effects
    4. Creating a Weather Forecast System
    5. Adding Gameplay Mechanics to the Weather
    6. Load Weather and Weather UI
  3. Stacking Inventory Items and dialogue improvements
    1. Freezing the player during ongoing dialogue
    2. Stacking harvest in the inventory
  4. Conclusion

1. Touching up scenes and adding the town square

Before we start on our basic weather system, we first need to touch up all the overworld scenes in our project.

a. Adding new furniture from imported asset packs

This whole touching-up process can be summarised into these simple steps:

  1. Add suitable furniture/props to decorate each scene
  2. Rebake the navmesh surface
  3. Rebake the lighting settings
  4. Adjust the location entry points to fit each expanded/relocated scene
  5. Adjust the NPC locations during their schedules

Repeat the same five steps for each scene to give more character to each of them. Here is the list of new imported asset packs that we used in this part:

And we can see there is a significant improvement in the visuals of our game.

b. Creating the town square

The town square is mainly a separate part of the town that will hold special events.

Thus, we will leave the plot in the middle of the land that will have exclusive features like outdoor benches, a stage and pop-up booths which will only appear when an event is happening.

And just like the rest of our scenes, add the location entry points, baked lighting and navmesh, and the preview of this scene in the main town scene.

2. Building our Weather System

Now let’s start with our weather system, it will consist of 5 different weather states: Sunny, Rainy, Snow, Heavy Snow and Typhoon

a. Creating our Rain and Snow particles

We can create simple rain using Unity’s inbuilt particle system. To set it up, here are the values in the particle system that need to be modified:

  • Duration – 5 seconds
  • Looping box is checked
  • Start Lifetime – 2
  • 3D Start Size is set to Random Between Two Constants:
    • Ranges between 0.1, 0.5, 0.1 to 0.1, 1, 0.1
  • Max Particles – 3000
  • Emission rate over time – 3000
  • Shape – Box
    • Scale is 50, 1, 50
  • Velocity over Lifetime is set to Random Between Two Constants
    • Ranges between 1, -45, 1 to 0, -55, 0
  • Collision is
    • Type – World
    • Dampen – 1
  • Renderer material is Default – Particle

For our snow particles, we will clone the rain particles and make a few adjustments:

  • Duration – 4 seconds
  • Prewarm is checked
  • Start Lifetime – 4
  • 3D Start Size – 0.5, 0.5, 0.5
  • Emission rate over time – 500

And we should have 2 particle systems that look like this:

To create heavy snow particles, we will clone the snow particle system and change the emission rate over time from 500 to 3000. Put all three particle systems under our Essentials parent game object.

b. Programming the Weather System

Let’s start by making our scriptable object, WeatherData. It will contain the enum of our different weather types and the probabilities of them in each season.

WeatherData.cs

using UnityEngine;

[CreateAssetMenu(fileName = "WeatherData", menuName = "Weather/WeatherData")]
//Script for handling the Weather Data
public class WeatherData : ScriptableObject
{

    public enum WeatherType {
    Sunny, Rain, Snow, Typhoon, HeavySnow
    }

    public WeatherProbability[] springWeather;

    public WeatherProbability[] summerWeather;

    public WeatherProbability[] fallWeather;

    public WeatherProbability[] winterWeather; 

    
}

We will also need a WeatherProbability struct, that will pair each WeatherData with a value.

WeatherProbability.cs

using UnityEngine;
[System.Serializable]
public struct WeatherProbability
{
    public WeatherData.WeatherType weatherType;

    [Range(0f, 1f)]
    public float probability;
    
}

Lastly, we will need to create the WeatherManager class, which will track, randomise and update the weather in the game. We added a debug log at the bottom to track in the debug panel what is the current weather state in the game.

WeatherManager.cs

using UnityEngine;

public class WeatherManager : MonoBehaviour, ITimeTracker
{
    public static WeatherManager 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;
        }
    }

    public WeatherData.WeatherType WeatherToday { get; private set; }

    public WeatherData.WeatherType WeatherTomorrow { get; private set; }

    //Check if the weather has been set before
    bool weatherSet = false; 

    //The weather data
    [SerializeField] WeatherData weatherData;


    void Start()
    {
        //Add this to TimeManager's Listener list
        TimeManager.Instance.RegisterTracker(this);
    }




        public WeatherData.WeatherType ComputeWeather(GameTimestamp.Season season)
    {
        if(weatherData == null)
        {
            throw new System.Exception("No weather data loaded"); 
        }

        //What are the possible weathers to compute
        WeatherProbability[] weatherSet = null;

        switch (season)
        {
            case GameTimestamp.Season.Spring:
                weatherSet = weatherData.springWeather;
                break; 
            case GameTimestamp.Season.Summer:
                weatherSet = weatherData.summerWeather;
                break;

            case GameTimestamp.Season.Fall:
                weatherSet = weatherData.fallWeather;
                break;

            case GameTimestamp.Season.Winter:
                weatherSet = weatherData.winterWeather;
                break; 
        }

        //Roll a random value 
        float randomValue = Random.Range(0, 1f);

        //Initialise probability
        float culmProbability = 0; 
        foreach(WeatherProbability weatherProbability in weatherSet)
        {
            culmProbability += weatherProbability.probability;
            //If in the probability
            if (randomValue <= culmProbability) {
                return weatherProbability.weatherType;
            }

        }

        //Sunny by default
        return WeatherData.WeatherType.Sunny; 
    }


    public void ClockUpdate(GameTimestamp timestamp)
    {
        //Check if it is 6am 
        if(timestamp.hour == 6 && timestamp.minute == 0)
        {
            //Set the current weather
            if (!weatherSet)
            {
                WeatherToday = ComputeWeather(timestamp.season);
               
            }
            else
            {
                WeatherToday = WeatherTomorrow;
                
            }

            //Set the forecast 
            WeatherTomorrow = ComputeWeather(timestamp.season);

            weatherSet = true;
            Debug.Log("The weather is " + WeatherToday.ToString());


        }
    }
}

Next, we will create a WeatherData scriptable object and set the frequency of each weather type for each season

c. Implementing the Weather Effects

Now we will make the weather effects appear in the game. Create a new script WeatherEffectController, which will manage what particles will follow the player depending on the weather state

WeatherEffectController.cs

using System.Collections;
using UnityEngine;

public class WeatherEffectController : MonoBehaviour
{
    [SerializeField]
    GameObject rain,snow,heavySnow;
    Transform player;
    private void Start()
    {
        SceneTransitionManager.Instance.onLocationLoad.AddListener(LoadParticle);
        player = FindFirstObjectByType<PlayerController>().transform;
    }


    private void FixedUpdate()
    {
        //Move the particle to the player
        if (player != null)
        {
            Vector3 pos = new Vector3(player.position.x, transform.position.y, player.position.z);
            transform.position = Vector3.Lerp(transform.position, pos, Time.fixedDeltaTime);
        }
    }

    void LoadParticle()
    {
        //Disable everything
        rain.SetActive(false);
        snow.SetActive(false);
        heavySnow.SetActive(false);

        //Move the particle to the player
        if (player != null)
        {
            Vector3 pos = new Vector3(player.position.x, transform.position.y, player.position.z);
            transform.position = pos; 
        }


        //Check if indoor
        if (SceneTransitionManager.Instance.CurrentlyIndoor())
        {
            return; 
        }

        if(WeatherManager.Instance.WeatherToday == WeatherData.WeatherType.Rain)
        {
            //Rain
            rain.SetActive(true); 
        }

        if (WeatherManager.Instance.WeatherToday == WeatherData.WeatherType.Snow)
        {
            snow.SetActive(true);
        }

        if (WeatherManager.Instance.WeatherToday == WeatherData.WeatherType.HeavySnow)
        {
            heavySnow.SetActive(true); 
        }
    }

}

d. Creating a Weather Forecast System

Let’s create a simple weather forecast system, which will tell the player what tomorrow’s weather will be through their home television.

We will first create an interactive object, Television, which will display the weather the next day.

Television.cs

using UnityEngine;

public class Television : InteractableObject
{

    public override void Pickup()
    {
        //For now, just do a weather forecast
        WeatherData.WeatherType weather = WeatherManager.Instance.WeatherTomorrow;
        DialogueManager.Instance.StartDialogue(DialogueManager.CreateSimpleMessage(
            "The weather tomorrow is " + weather.ToString()
            ));;
    }
}

Afterwards, the script will be attached to a TV game object in the player’s home as shown below:

This feature should be executed like this once the TV game object is interacted with:

e. Adding Gameplay Mechanics to the Weather

Now that we added the weather visual effects into the game, we also want them to affect the gameplay.

Firstly, we will make it such that when it rains, the farming plots outside will be automatically watered. In GameStateManager, we will create a function called RainOnLand that starts at 6:01 am to check if it’s raining and change the land status to watered.

GameStateManager.cs

using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices.WindowsRuntime;
using UnityEngine;
using UnityEngine.Events; 

public class GameStateManager : MonoBehaviour, ITimeTracker
{
    //lights that turn on at night
    public GameObject[] nightLights;
    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;

    [SerializeField]
    GameBlackboard blackboard = new GameBlackboard();
    //This blackboard will not be saved 
    GameBlackboard sceneItemsBoard = new GameBlackboard();

    const string TIMESTAMP = "Timestamp";
    public GameBlackboard GetBlackboard()
    {
        return blackboard;
    }

    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);
        SceneTransitionManager.Instance.onLocationLoad.AddListener(RenderPersistentObjects); 
    }

    public void ClockUpdate(GameTimestamp timestamp)
    {
        UpdateShippingState(timestamp); 
        UpdateFarmState(timestamp);
        IncubationManager.UpdateEggs();
        blackboard.SetValue(TIMESTAMP, timestamp);

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

        //6:01 am, do rain on land logic
        if(timestamp.hour == 6 && timestamp.minute == 1) { 
            RainOnLand();
        
        }

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

        } 
        else
        {
            minutesElapsed++; 
        }

        // the lights will turn on at 6pm
        nightLights = GameObject.FindGameObjectsWithTag("Lights");
        foreach (GameObject i in nightLights)
        {
            if (timestamp.hour >= 18)
            {
                i.GetComponent<Light>().enabled = true;
            }
            else
            {
                i.GetComponent<Light>().enabled = false;
            }
        }

    }

    //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 RainOnLand()
    {
        //Check if raining
        if (WeatherManager.Instance.WeatherToday != WeatherData.WeatherType.Rain) {
            return;
        }

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

        for(int i=0; i< landData.Count; i++)
        {
            //Set the last watered to now
            if (landData[i].landStatus != Land.LandStatus.Soil)
            {
                landData[i] = new LandSaveState(Land.LandStatus.Watered, TimeManager.Instance.GetGameTimestamp(), landData[i].obstacleStatus);

            }
        }
        
    }

    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());
            });*/
        }
    }

    //Instantiate the GameObject such that it stays in the scene even after the player leaves 
    public GameObject PersistentInstantiate(GameObject gameObject, Vector3 position, Quaternion rotation)
    {
        GameObject item = Instantiate(gameObject, position, rotation);
        //Save it to the blackboard
        (GameObject, Vector3) itemInformation = (gameObject, position);
        List<(GameObject, Vector3)> items = sceneItemsBoard.GetOrInitList<(GameObject, Vector3)>(SceneTransitionManager.Instance.currentLocation.ToString());
        items.Add(itemInformation);
        sceneItemsBoard.Debug();
        return item;
    }

    public void PersistentDestroy(GameObject item)
    {
        if (sceneItemsBoard.TryGetValue(SceneTransitionManager.Instance.currentLocation.ToString(), out List<(GameObject, Vector3)> items))
        {
            int index = items.FindIndex(i => i.Item2 == item.transform.position);
            items.RemoveAt(index); 
        }
        Destroy(item); 
    }

    void RenderPersistentObjects()
    {
        Debug.Log("Rendering Persistent Objects");
        if (sceneItemsBoard.TryGetValue(SceneTransitionManager.Instance.currentLocation.ToString(), out List<(GameObject, Vector3)> items)) { 
            foreach((GameObject, Vector3) item in items)
            {
                Instantiate(item.Item1, item.Item2, Quaternion.identity); 
            }
        }
    }

    public void Sleep()
    {
        //Call a fadeout
        UIManager.Instance.FadeOutScreen();
        screenFadedOut = false;
        StartCoroutine(TransitionTime());
        //Restore Stamina fully when sleeping
        PlayerStats.RestoreStamina();
    }

    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();

        WeatherSaveState weather = WeatherSaveState.Export();

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

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

        blackboard = save.blackboard;
        blackboard.Debug();

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

        //Inventory
        save.inventorySaveState.LoadData();

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

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

        //Weather
        save.weatherSaveState.LoadData();

        //Relationship stats
        AnimalStats.LoadStats();
        RelationshipStats.LoadStats(); 
        
        //save.relationshipSaveState.LoadData();

    }
}

Now in our Land script, we will add logic to check for rain

Land.cs

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

public class Land : MonoBehaviour, ITimeTracker
{
    public int id; 
    public enum LandStatus
    {
        Soil, Farmland, Watered
    }

    public LandStatus landStatus;

    public Material soilMat, farmlandMat, wateredMat;
    new Renderer renderer;

    //The selection gameobject to enable when the player is selecting the land
    public GameObject select;

    //Cache the time the land was watered 
    GameTimestamp timeWatered;

    [Header("Crops")]
    //The crop prefab to instantiate
    public GameObject cropPrefab;

    //The crop currently planted on the land
    CropBehaviour cropPlanted = null;

    //Obstacles
    public enum FarmObstacleStatus { None, Rock, Wood, Weeds}
    [Header("Obstacles")]
    public FarmObstacleStatus obstacleStatus;
    public GameObject rockPrefab, woodPrefab, weedsPrefab;

    //Store the instantiated obstacle as a variable so we can access it 
    GameObject obstacleObject; 

    // Start is called before the first frame update
    void Start()
    {
        //Get the renderer component
        renderer = GetComponent<Renderer>();

        //Set the land to soil by default
        SwitchLandStatus(LandStatus.Soil);

        //Deselect the land by default
        Select(false);

        //Add this to TimeManager's Listener list
        TimeManager.Instance.RegisterTracker(this);


    }

    public void LoadLandData(LandStatus landStatusToSwitch, GameTimestamp lastWatered, FarmObstacleStatus obstacleStatusToSwitch)
    {
        //Set land status accordingly
        landStatus = landStatusToSwitch;
        timeWatered = lastWatered;

        Material materialToSwitch = soilMat;

        //Decide what material to switch to
        switch (landStatusToSwitch)
        {
            case LandStatus.Soil:
                //Switch to the soil material
                materialToSwitch = soilMat;
                break;
            case LandStatus.Farmland:
                //Switch to farmland material 
                materialToSwitch = farmlandMat;
                break;

            case LandStatus.Watered:
                //Switch to watered material
                materialToSwitch = wateredMat;
                break;

        }

        //Get the renderer to apply the changes
        renderer.material = materialToSwitch;

        switch (obstacleStatusToSwitch)
        {
            case FarmObstacleStatus.None:
                //Destroy the Obstacle object, if any
                if (obstacleObject != null) Destroy(obstacleObject);
                break;
            case FarmObstacleStatus.Rock:
                //Instantiate the obstacle prefab on the land and assign it to obstacleObject
                obstacleObject = Instantiate(rockPrefab, transform);
                break;
            case FarmObstacleStatus.Wood:
                //Instantiate the obstacle prefab on the land and assign it to obstacleObject
                obstacleObject = Instantiate(woodPrefab, transform);
                break;
            case FarmObstacleStatus.Weeds:
                //Instantiate the obstacle prefab on the land and assign it to obstacleObject
                obstacleObject = Instantiate(weedsPrefab, transform);
                break;
        }

        //Move the obstacle object to the top of the land gameobject
        if (obstacleObject != null) obstacleObject.transform.position = new Vector3(transform.position.x, 0.1f, transform.position.z);

        //Set the status accordingly
        obstacleStatus = obstacleStatusToSwitch;

    }

    public void SwitchLandStatus(LandStatus statusToSwitch)
    {
        //Set land status accordingly
        landStatus = statusToSwitch;

        Material materialToSwitch = soilMat; 

        //Decide what material to switch to
        switch (statusToSwitch)
        {
            case LandStatus.Soil:
                //Switch to the soil material
                materialToSwitch = soilMat;
                break;
            case LandStatus.Farmland:
                //Switch to farmland material 
                materialToSwitch = farmlandMat;

                break;

            case LandStatus.Watered:
                //Switch to watered material
                materialToSwitch = wateredMat;

                //Cache the time it was watered
                timeWatered = TimeManager.Instance.GetGameTimestamp(); 
                break; 

        }

        //Get the renderer to apply the changes
        renderer.material = materialToSwitch;

        LandManager.Instance.OnLandStateChange(id, landStatus, timeWatered, obstacleStatus);
    }

    public void SetObstacleStatus(FarmObstacleStatus statusToSwitch)
    {
        switch (statusToSwitch)
        {
            case FarmObstacleStatus.None:
                //Destroy the Obstacle object, if any
                if (obstacleObject != null) Destroy(obstacleObject); 
                break;
            case FarmObstacleStatus.Rock:
                //Instantiate the obstacle prefab on the land and assign it to obstacleObject
                obstacleObject = Instantiate(rockPrefab, transform); 
                break;
            case FarmObstacleStatus.Wood:
                //Instantiate the obstacle prefab on the land and assign it to obstacleObject
                obstacleObject = Instantiate(woodPrefab, transform);
                break;
            case FarmObstacleStatus.Weeds:
                //Instantiate the obstacle prefab on the land and assign it to obstacleObject
                obstacleObject = Instantiate(weedsPrefab, transform);
                break; 
        }

        //Move the obstacle object to the top of the land gameobject
        if(obstacleObject != null) obstacleObject.transform.position = new Vector3(transform.position.x, 0.1f, transform.position.z);

        //Set the status accordingly
        obstacleStatus = statusToSwitch;

        LandManager.Instance.OnLandStateChange(id, landStatus, timeWatered, obstacleStatus);
    }

    public void Select(bool toggle)
    {
        select.SetActive(toggle);
    }

    //When the player presses the interact button while selecting this land
    public void Interact()
    {
        //Check the player's tool slot
        ItemData toolSlot = InventoryManager.Instance.GetEquippedSlotItem(InventorySlot.InventoryType.Tool);

        //If there's nothing equipped, return
        if (!InventoryManager.Instance.SlotEquipped(InventorySlot.InventoryType.Tool))
        {
            return; 
        }

        //Try casting the itemdata in the toolslot as EquipmentData
        EquipmentData equipmentTool = toolSlot as EquipmentData; 

        //Check if it is of type EquipmentData 
        if(equipmentTool != null)
        {
            //Get the tool type
            EquipmentData.ToolType toolType = equipmentTool.toolType;

            switch (toolType)
            {
                case EquipmentData.ToolType.Hoe:
                    SwitchLandStatus(LandStatus.Farmland);
                    break;
                case EquipmentData.ToolType.WateringCan:
                    //The land must be tilled first
                    if (landStatus != LandStatus.Soil)
                    {
                        SwitchLandStatus(LandStatus.Watered);
                    }
                    
                    break;

                case EquipmentData.ToolType.Shovel:

                    //Remove the crop from the land
                    if(cropPlanted != null)
                    {
                        cropPlanted.RemoveCrop();
                    }

                    //Remove weed obstacle
                    if (obstacleStatus == FarmObstacleStatus.Weeds) SetObstacleStatus(FarmObstacleStatus.None); 

                    break;

                case EquipmentData.ToolType.Axe:
                    //Remove wood obstacle
                    if (obstacleStatus == FarmObstacleStatus.Wood) SetObstacleStatus(FarmObstacleStatus.None);
                    break;

                case EquipmentData.ToolType.Pickaxe:
                    //Remove rock obstacle
                    if (obstacleStatus == FarmObstacleStatus.Rock) SetObstacleStatus(FarmObstacleStatus.None);
                    break;

            }

            //We don't need to check for seeds if we have already confirmed the tool to be an equipment
            return; 
        }

        //Try casting the itemdata in the toolslot as SeedData
        SeedData seedTool = toolSlot as SeedData; 

        ///Conditions for the player to be able to plant a seed
        ///1: He is holding a tool of type SeedData
        ///2: The Land State must be either watered or farmland
        ///3. There isn't already a crop that has been planted
        ///4. There are no obstacles
        if(seedTool != null && landStatus != LandStatus.Soil && cropPlanted == null && obstacleStatus == FarmObstacleStatus.None)
        {
            SpawnCrop();
            //Plant it with the seed's information
            cropPlanted.Plant(id, seedTool);

            //Consume the item
            InventoryManager.Instance.ConsumeItem(InventoryManager.Instance.GetEquippedSlot(InventorySlot.InventoryType.Tool));

        }
    }

    public CropBehaviour SpawnCrop()
    {
        //Instantiate the crop object parented to the land
        GameObject cropObject = Instantiate(cropPrefab, transform);
        //Move the crop object to the top of the land gameobject
        cropObject.transform.position = new Vector3(transform.position.x, 0.1f, transform.position.z);

        //Access the CropBehaviour of the crop we're going to plant
        cropPlanted = cropObject.GetComponent<CropBehaviour>();
        return cropPlanted; 
    }

    public void ClockUpdate(GameTimestamp timestamp)
    {

        //When raining, have it watered if it is farmland
        if (WeatherManager.Instance.WeatherToday == WeatherData.WeatherType.Rain && landStatus == LandStatus.Farmland)
        {
            SwitchLandStatus(LandStatus.Watered);

        }

        //Checked if 24 hours has passed since last watered
        if (landStatus == LandStatus.Watered)
        {
            //Hours since the land was watered
            int hoursElapsed = GameTimestamp.CompareTimestamps(timeWatered, timestamp);
            Debug.Log(hoursElapsed + " hours since this was watered");

            //Grow the planted crop, if any
            if(cropPlanted != null)
            {
                cropPlanted.Grow();
            }

            if(hoursElapsed > 24)
            {
                //Dry up (Switch back to farmland)
                SwitchLandStatus(LandStatus.Farmland);
            }
        }

        //Handle the wilting of the plant when the land is not watered
        if(landStatus != LandStatus.Watered && cropPlanted != null)
        {
            //If the crop has already germinated, start the withering
            if (cropPlanted.cropState != CropBehaviour.CropState.Seed)
            {
                cropPlanted.Wither();
            }
        }
    }

    private void OnDestroy()
    {
        //Unsubscribe from the list on destroy
        TimeManager.Instance.UnregisterTracker(this);
    }
}

Now during the case of a typhoon, we don’t want the player to leave the house as it is unsafe. Thus we created an if statement to restrict the player’s movement during a typhoon.

LocationEntryPoint.cs

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

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

    [SerializeField]
    //For locking doors
    bool locked = false; 

    private void OnTriggerEnter(Collider other)
    {
        //Check if the collider belongs to the player
        if(other.tag == "Player")
        {
            if(WeatherManager.Instance.WeatherToday == WeatherData.WeatherType.Typhoon && SceneTransitionManager.Instance.currentLocation == SceneTransitionManager.Location.PlayerHome || locked)
            {
                //You cant go out in a typhoon
                DialogueManager.Instance.StartDialogue(DialogueManager.CreateSimpleMessage("It's not safe to go out."));
                return;
            }

            //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);
        }
        
    }



}

f. Load Weather and Weather UI

Before adding the final touches to our weather system, we will create a debug feature to skip entire days by holding shift and pressing the right square bracket key

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))
        {
            if (Input.GetKey(KeyCode.LeftShift)) { 
                //Advance the entire day
                for(int i =0; i< 60*24;  i++)
                {
                    TimeManager.Instance.Tick();
                }
            } else
            {
                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 if allowed
            if (controller.enabled)
            {
                controller.Move(velocity);
            }
            
            
        }

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



    }
}

Now it’s time to add the weather into our save system to make it persist across game states.

We will create a new struct WeatherSaveState.

WeatherSaveState.cs

using UnityEngine;

[System.Serializable]
public struct WeatherSaveState
{
    public WeatherData.WeatherType weather; 

    public WeatherSaveState(WeatherData.WeatherType weather)
    {
        this.weather = weather;
    }

    public void LoadData()
    {
        WeatherManager.Instance.LoadWeather(this);
    }

    public static WeatherSaveState Export()
    {
        return new WeatherSaveState(WeatherManager.Instance.WeatherTomorrow); 
    }
    
}

Next, we will update our GameSaveState to include the weather.

GameSaveState.cs

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

[System.Serializable]
public class GameSaveState
{
    //Blackboard 
    public GameBlackboard blackboard; 
    //Farm Data
    public FarmSaveState farmSaveState;
    //Inventory
    public InventorySaveState inventorySaveState;
    //Time
    public GameTimestamp timestamp;

    //PlayerStats
    public PlayerSaveState playerSaveState;

    //Weather State
    public WeatherSaveState weatherSaveState;

    //Relationships
    //public RelationshipSaveState relationshipSaveState;

    public GameSaveState(
        GameBlackboard blackboard,
        FarmSaveState farmSaveState,
        InventorySaveState inventorySaveState,
        GameTimestamp timestamp,
        PlayerSaveState playerSaveState,
        WeatherSaveState weatherSaveState
        //RelationshipSaveState relationshipSaveState
        )
        
    {
        this.blackboard = blackboard;
        this.farmSaveState = farmSaveState;
        this.inventorySaveState = inventorySaveState;
        this.timestamp = timestamp;
        this.playerSaveState = playerSaveState;
        this.weatherSaveState = weatherSaveState;
        //this.relationshipSaveState = relationshipSaveState;
    }
}

Then we naturally have to add a LoadWeather function to the WeatherManager script

WeatherManager.cs

using UnityEngine;

public class WeatherManager : MonoBehaviour, ITimeTracker
{
    public static WeatherManager 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;
        }
    }

    public WeatherData.WeatherType WeatherToday { get; private set; }

    public WeatherData.WeatherType WeatherTomorrow { get; private set; }

    //Check if the weather has been set before
    bool weatherSet = false; 

    //The weather data
    [SerializeField] WeatherData weatherData;


    void Start()
    {
        //Add this to TimeManager's Listener list
        TimeManager.Instance.RegisterTracker(this);
    }




        public WeatherData.WeatherType ComputeWeather(GameTimestamp.Season season)
    {
        if(weatherData == null)
        {
            throw new System.Exception("No weather data loaded"); 
        }

        //What are the possible weathers to compute
        WeatherProbability[] weatherSet = null;

        switch (season)
        {
            case GameTimestamp.Season.Spring:
                weatherSet = weatherData.springWeather;
                break; 
            case GameTimestamp.Season.Summer:
                weatherSet = weatherData.summerWeather;
                break;

            case GameTimestamp.Season.Fall:
                weatherSet = weatherData.fallWeather;
                break;

            case GameTimestamp.Season.Winter:
                weatherSet = weatherData.winterWeather;
                break; 
        }

        //Roll a random value 
        float randomValue = Random.Range(0, 1f);

        //Initialise probability
        float culmProbability = 0; 
        foreach(WeatherProbability weatherProbability in weatherSet)
        {
            culmProbability += weatherProbability.probability;
            //If in the probability
            if (randomValue <= culmProbability) {
                return weatherProbability.weatherType;
            }

        }

        //Sunny by default
        return WeatherData.WeatherType.Sunny; 
    }

    public void LoadWeather(WeatherSaveState saveState)
    {
        weatherSet = true;
        WeatherToday = saveState.weather;

        //Set the forecast 
        WeatherTomorrow = ComputeWeather(TimeManager.Instance.GetGameTimestamp().season);
    }

    public void ClockUpdate(GameTimestamp timestamp)
    {
        //Check if it is 6am 
        if(timestamp.hour == 6 && timestamp.minute == 0)
        {
            //Set the current weather
            if (!weatherSet)
            {
                WeatherToday = ComputeWeather(timestamp.season);
               
            }
            else
            {
                WeatherToday = WeatherTomorrow;
                
            }

            //Set the forecast 
            WeatherTomorrow = ComputeWeather(timestamp.season);

            weatherSet = true;
            Debug.Log("The weather is " + WeatherToday.ToString());


        }
    }
}

Lastly, we will integrate the weather UI into the game, updating UIManager to include a ChangeStaminaUI function and calling that function in WeatherManager.

UIManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;

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

    [Header("Screen Management")]
    public GameObject menuScreen;
    public enum Tab
    {
        Inventory, Relationships, Animals
    }
    //The current selected tab
    public Tab selectedTab;

    [Header("Status Bar")]
    //Tool equip slot on the status bar
    public Image toolEquipSlot;
    //Tool Quantity text on the status bar 
    public TextMeshProUGUI toolQuantityText;
    //Time UI
    public TextMeshProUGUI timeText;
    public TextMeshProUGUI dateText;


    [Header("Inventory System")]
    //The inventory panel
    public GameObject inventoryPanel;

    //The tool equip slot UI on the Inventory panel
    public HandInventorySlot toolHandSlot;

    //The tool slot UIs
    public InventorySlot[] toolSlots;

    //The item equip slot UI on the Inventory panel
    public HandInventorySlot itemHandSlot;

    //The item slot UIs
    public InventorySlot[] itemSlots;

    [Header("Item info box")]
    public GameObject itemInfoBox;
    public TextMeshProUGUI itemNameText;
    public TextMeshProUGUI itemDescriptionText;

    [Header("Screen Transitions")]
    public GameObject fadeIn;
    public GameObject fadeOut;

    [Header("Prompts")]
    public YesNoPrompt yesNoPrompt;
    public NamingPrompt namingPrompt;
    [SerializeField] InteractBubble interactBubble;

    [Header("Player Stats")]
    public TextMeshProUGUI moneyText;

    [Header("Shop")]
    public ShopListingManager shopListingManager;

    [Header("Relationships")]
    public RelationshipListingManager relationshipListingManager;
    public AnimalListingManager animalRelationshipListingManager;

    [Header("Calendar")]
    public CalendarUIListing calendar;

    [Header("Stamina")]
    public Sprite[] staminaUI;
    public Image StaminaUIImage;
    public int staminaCount;

    [Header("Weather")]
    public Sprite[] weatherUI;
    public Image WeatherUIImage;


    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;
        }
    }

    private void Start()
    {
        PlayerStats.RestoreStamina();
        RenderInventory();
        AssignSlotIndexes();
        RenderPlayerStats();
        DisplayItemInfo(null);
        ChangeWeatherUI();

        //Add UIManager to the list of objects TimeManager will notify when the time updates
        TimeManager.Instance.RegisterTracker(this);
    }

    #region Prompts

    public void TriggerNamingPrompt(string message, System.Action<string> onConfirmCallback)
    {
        //Check if another prompt is already in progress
        if (namingPrompt.gameObject.activeSelf)
        {
            //Queue the prompt
            namingPrompt.QueuePromptAction(() => TriggerNamingPrompt(message, onConfirmCallback));
            return;
        }

        //Open the panel
        namingPrompt.gameObject.SetActive(true);

        namingPrompt.CreatePrompt(message, onConfirmCallback);
    }

    public void TriggerYesNoPrompt(string message, System.Action onYesCallback)
    {
        //Set active the gameobject of the Yes No Prompt
        yesNoPrompt.gameObject.SetActive(true);

        yesNoPrompt.CreatePrompt(message, onYesCallback);
    }
    #endregion

    #region Tab Management
    public void ToggleMenuPanel()
    {
        menuScreen.SetActive(!menuScreen.activeSelf);

        OpenWindow(selectedTab);

        TabBehaviour.onTabStateChange?.Invoke();
    }

    //Manage the opening of windows associated with the tab
    public void OpenWindow(Tab windowToOpen)
    {
        //Disable all windows
        relationshipListingManager.gameObject.SetActive(false);
        inventoryPanel.SetActive(false);
        animalRelationshipListingManager.gameObject.SetActive(false);

        //Open the corresponding window and render it
        switch (windowToOpen)
        {
            case Tab.Inventory:
                inventoryPanel.SetActive(true);
                RenderInventory();
                break;
            case Tab.Relationships:
                relationshipListingManager.gameObject.SetActive(true);
                relationshipListingManager.Render(RelationshipStats.relationships);
                break;
            case Tab.Animals:
                animalRelationshipListingManager.gameObject.SetActive(true);
                animalRelationshipListingManager.Render(AnimalStats.animalRelationships);
                break;

        }

        //Set the selected tab
        selectedTab = windowToOpen;


    }
    #endregion

    #region Fadein Fadeout Transitions

    public void FadeOutScreen()
    {
        fadeOut.SetActive(true);
    }

    public void FadeInScreen()
    {
        fadeIn.SetActive(true);
    }

    public void OnFadeInComplete()
    {
        //Disable Fade in Screen when animation is completed
        fadeIn.SetActive(false);
    }

    //Reset the fadein fadeout screens to their default positions
    public void ResetFadeDefaults()
    {
        fadeOut.SetActive(false);
        fadeIn.SetActive(true);
    }



    #endregion

    #region Inventory
    //Iterate through the slot UI elements and assign it its reference slot index
    public void AssignSlotIndexes()
    {
        for (int i = 0; i < toolSlots.Length; i++)
        {
            toolSlots[i].AssignIndex(i);
            itemSlots[i].AssignIndex(i);
        }
    }

    //Render the inventory screen to reflect the Player's Inventory. 
    public void RenderInventory()
    {
        //Get the respective slots to process
        ItemSlotData[] inventoryToolSlots = InventoryManager.Instance.GetInventorySlots(InventorySlot.InventoryType.Tool);
        ItemSlotData[] inventoryItemSlots = InventoryManager.Instance.GetInventorySlots(InventorySlot.InventoryType.Item);

        //Render the Tool section
        RenderInventoryPanel(inventoryToolSlots, toolSlots);

        //Render the Item section
        RenderInventoryPanel(inventoryItemSlots, itemSlots);

        //Render the equipped slots
        toolHandSlot.Display(InventoryManager.Instance.GetEquippedSlot(InventorySlot.InventoryType.Tool));
        itemHandSlot.Display(InventoryManager.Instance.GetEquippedSlot(InventorySlot.InventoryType.Item));

        //Get Tool Equip from InventoryManager
        ItemData equippedTool = InventoryManager.Instance.GetEquippedSlotItem(InventorySlot.InventoryType.Tool);

        //Text should be empty by default
        toolQuantityText.text = "";
        //Check if there is an item to display
        if (equippedTool != null)
        {
            //Switch the thumbnail over
            toolEquipSlot.sprite = equippedTool.thumbnail;

            toolEquipSlot.gameObject.SetActive(true);

            //Get quantity 
            int quantity = InventoryManager.Instance.GetEquippedSlot(InventorySlot.InventoryType.Tool).quantity;
            if (quantity > 1)
            {
                toolQuantityText.text = quantity.ToString();
            }
            return;
        }

        toolEquipSlot.gameObject.SetActive(false);
    }

    //Iterate through a slot in a section and display them in the UI
    void RenderInventoryPanel(ItemSlotData[] slots, InventorySlot[] uiSlots)
    {
        for (int i = 0; i < uiSlots.Length; i++)
        {
            //Display them accordingly
            uiSlots[i].Display(slots[i]);
        }
    }

    public void ToggleInventoryPanel()
    {
        //If the panel is hidden, show it and vice versa
        inventoryPanel.SetActive(!inventoryPanel.activeSelf);

        RenderInventory();
    }

    //Display Item info on the Item infobox
    public void DisplayItemInfo(ItemData data)
    {
        //If data is null, reset
        if (data == null)
        {
            itemNameText.text = "";
            itemDescriptionText.text = "";
            itemInfoBox.SetActive(false);
            return;
        }
        itemInfoBox.SetActive(true);
        itemNameText.text = data.name;
        itemDescriptionText.text = data.description;
    }
    #endregion

    #region Time
    //Callback to handle the UI for time
    public void ClockUpdate(GameTimestamp timestamp)
    {
        //Handle the time
        //Get the hours and minutes
        int hours = timestamp.hour;
        int minutes = timestamp.minute;

        //AM or PM
        string prefix = "AM ";

        //Convert hours to 12 hour clock
        if (hours >= 12)
        {
            //Time becomes PM 
            prefix = "PM ";
            //12 PM and later
            hours = hours - 12;
            Debug.Log(hours);
        }
        //Special case for 12am/pm to display it as 12 instead of 0
        hours = hours == 0 ? 12 : hours;

        //Format it for the time text display
        timeText.text = prefix + hours + ":" + minutes.ToString("00");

        //Handle the Date
        int day = timestamp.day;
        string season = timestamp.season.ToString();
        string dayOfTheWeek = timestamp.GetDayOfTheWeek().ToString();

        //Format it for the date text display
        dateText.text = season + " " + day + " (" + dayOfTheWeek + ")";

    }
    #endregion

    //Render the UI of the player stats in the HUD
    public void RenderPlayerStats()
    {
        moneyText.text = PlayerStats.Money + PlayerStats.CURRENCY;
        staminaCount = PlayerStats.Stamina;
        ChangeStaminaUI();

    }

    //Open the shop window with the shop items listed
    public void OpenShop(List<ItemData> shopItems)
    {
        //Set active the shop window
        shopListingManager.gameObject.SetActive(true);
        shopListingManager.Render(shopItems);
    }

    public void ToggleRelationshipPanel()
    {
        GameObject panel = relationshipListingManager.gameObject;
        panel.SetActive(!panel.activeSelf);

        //If open, render the screen
        if (panel.activeSelf)
        {
            relationshipListingManager.Render(RelationshipStats.relationships);
        }
    }

    public void InteractPrompt(Transform item, string message, float offset)
    {
        interactBubble.gameObject.SetActive(true);
        interactBubble.transform.position = item.transform.position + new Vector3(0, offset, 0);
        interactBubble.Display(message);
    }

    public void DeactivateInteractPrompt()
    {
        interactBubble.gameObject.SetActive(false);
    }

    public void ChangeStaminaUI()
    {
        if (staminaCount <= 45) StaminaUIImage.sprite = staminaUI[3]; // exhausted
        else if (staminaCount <= 80) StaminaUIImage.sprite = staminaUI[2]; // tired
        else if (staminaCount <= 115) StaminaUIImage.sprite = staminaUI[1]; // active
        else if (staminaCount <= 150) StaminaUIImage.sprite = staminaUI[0]; // energised

    }

    public void ChangeWeatherUI()
    {
        var WeatherToday = WeatherManager.Instance.WeatherToday;

        switch (WeatherToday)
        {
            case WeatherData.WeatherType.Sunny:
                WeatherUIImage.sprite = weatherUI[0];
                break;
            case WeatherData.WeatherType.Rain:
                WeatherUIImage.sprite = weatherUI[1];
                break;
            case WeatherData.WeatherType.Snow:
                WeatherUIImage.sprite = weatherUI[2];
                break;
            case WeatherData.WeatherType.HeavySnow:
                WeatherUIImage.sprite = weatherUI[3];
                break;
        }
    }
}
    


3. Stacking Inventory Items and dialogue improvements

a. Freezing the player during ongoing dialogue

Firstly, we will add an if statement to DialogueManager so the player will not move during dialogue.

DialogueManager.cs

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using System.Text.RegularExpressions;

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

    [Header("Dialogue Components")]
    public GameObject dialoguePanel;
    public TextMeshProUGUI speakerText;
    public TextMeshProUGUI dialogueText;

    //The lines to queue during the dialogue sequence
    Queue<DialogueLine> dialogueQueue;
    Action onDialogueEnd = null;

    bool isTyping = false;

    //TODO: Implement a proper player control stop mechanism
    PlayerController playerController; 

    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;
        }
    }

    private void Start()
    {
        playerController = FindFirstObjectByType<PlayerController>();
    }

    //Initialises the dialogue
    public void StartDialogue(List<DialogueLine> dialogueLinesToQueue)
    {
        //Convert the list to a queue
        dialogueQueue = new Queue<DialogueLine>(dialogueLinesToQueue);


        if (playerController != null)
        {
            playerController.enabled = false;
        }

        UpdateDialogue(); 
    }

    //Initialises the dialogue, but with an Action to execute once it finishes
    public void StartDialogue(List<DialogueLine> dialogueLinesToQueue, Action onDialogueEnd)
    {
        StartDialogue(dialogueLinesToQueue);
        this.onDialogueEnd = onDialogueEnd;

    }

    //Cycle through the dialogue lines
    public void UpdateDialogue()
    {
        if (isTyping)
        {
            isTyping = false;
            return; 
        }

        //Reset our dialogue text 
        dialogueText.text = string.Empty; 

        //Check if there are any more lines in the queue
        if(dialogueQueue.Count == 0)
        {
            //If not, end the dialogue
            EndDialogue();
            return;
        }

        //The current dialogue line to put in
        DialogueLine line = dialogueQueue.Dequeue();

        Talk(line.speaker, ParseVariables(line.message)); 
    }

    //Closes the dialogue
    public void EndDialogue()
    {
        //Close the dialogue panel
        dialoguePanel.SetActive(false);

        if (playerController != null)
        {
            playerController.enabled = true;
        }

        //Invoke whatever Action queued on dialogue end (if any)
        onDialogueEnd?.Invoke();



        //Reset the Action 
        onDialogueEnd = null; 


    }

    public void Talk(string speaker, string message)
    {
        //Set the dialogue panel active
        dialoguePanel.SetActive(true);

        //Set the speaker text to the speaker
        speakerText.text = speaker;

        //If there is no speaker, do not show the speaker text panel
        speakerText.transform.parent.gameObject.SetActive(speaker != "");

        //Set the dialogue text to the message
        //dialogueText.text = message;
        StartCoroutine(TypeText(message));
    }

    IEnumerator TypeText(string textToType)
    {
        isTyping = true; 
        //Convert the string to an array of chars
        char[] charsToType = textToType.ToCharArray(); 
        for(int i =0; i < charsToType.Length; i++)
        {
            dialogueText.text += charsToType[i];
            yield return new WaitForEndOfFrame();

            //Skip the typing sequence and just show the full text
            if (!isTyping)
            {
                dialogueText.text = textToType;
                //Break out from the loop
                break; 
            }
        }

        //Typing sequence complete
        isTyping = false; 
    }

    //Converts a simple string into a List of Dialogue lines to put into DialogueManager
    public static List<DialogueLine> CreateSimpleMessage(string message)
    {
        //The Dialogue line we want to output 
        DialogueLine messageDialogueLine = new DialogueLine("",message);

        List<DialogueLine> listToReturn = new List<DialogueLine>();

        listToReturn.Add(messageDialogueLine);

        return listToReturn; 
    }

    //Filter to see if there is any dialogue lines we can overwrite with
    public static List<DialogueLine> SelectDialogue(List<DialogueLine> dialogueToExecute, DialogueCondition[] conditions)
    {
        //Replace the dialogue set with the highest condition score
        int highestConditionScore = -1; 
        foreach(DialogueCondition condition in conditions)
        {
            //Check if conditions met first
            if(condition.CheckConditions(out int score))
            {
                if(score > highestConditionScore)
                {
                    highestConditionScore = score;
                    dialogueToExecute = condition.dialogueLine;
                    Debug.Log("Will play " + condition.id); 
                }
            }
        }

        

        return dialogueToExecute; 
    }

    /// <summary>
    /// Any {} in the message will be retrieved from the blackboard
    /// </summary>
    /// <param name="message">The string to pass in </param>
    /// <returns></returns>
    string ParseVariables(string message)
    {
        if(GameStateManager.Instance != null)
        {
            //Get the blackboard
            GameBlackboard blackboard = GameStateManager.Instance.GetBlackboard();
        
            if(blackboard != null)
            {
                //Look for strings enclosed with {}
                string pattern = @"\{([^}]+?)\}";
                //Regex replacement step
                message = Regex.Replace(message, pattern, match => {
                    //The variable name enclosed in the "{}"
                    string variableName = match.Groups[1].Value;

                    //If there is a string value, return it
                    if (blackboard.TryGetValueAsString(variableName, out string strValue))
                    {
                        return strValue;
                    }
                    //Nothing found, so nothing is returned
                    return ""; 
                });

            }
        }
        return message;
    }
}

WeatherManager.cs

using UnityEngine;

public class WeatherManager : MonoBehaviour, ITimeTracker
{
    public static WeatherManager 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;
        }
    }

    public WeatherData.WeatherType WeatherToday { get; private set; }

    public WeatherData.WeatherType WeatherTomorrow { get; private set; }

    //Check if the weather has been set before
    bool weatherSet = false; 

    //The weather data
    [SerializeField] WeatherData weatherData;


    void Start()
    {
        //Add this to TimeManager's Listener list
        TimeManager.Instance.RegisterTracker(this);
    }




        public WeatherData.WeatherType ComputeWeather(GameTimestamp.Season season)
    {
        if(weatherData == null)
        {
            throw new System.Exception("No weather data loaded"); 
        }

        //What are the possible weathers to compute
        WeatherProbability[] weatherSet = null;

        switch (season)
        {
            case GameTimestamp.Season.Spring:
                weatherSet = weatherData.springWeather;
                break; 
            case GameTimestamp.Season.Summer:
                weatherSet = weatherData.summerWeather;
                break;

            case GameTimestamp.Season.Fall:
                weatherSet = weatherData.fallWeather;
                break;

            case GameTimestamp.Season.Winter:
                weatherSet = weatherData.winterWeather;
                break; 
        }

        //Roll a random value 
        float randomValue = Random.Range(0, 1f);

        //Initialise probability
        float culmProbability = 0; 
        foreach(WeatherProbability weatherProbability in weatherSet)
        {
            culmProbability += weatherProbability.probability;
            //If in the probability
            if (randomValue <= culmProbability) {
                return weatherProbability.weatherType;
            }

        }

        //Sunny by default
        return WeatherData.WeatherType.Sunny; 
    }

    public void LoadWeather(WeatherSaveState saveState)
    {
        weatherSet = true;
        WeatherToday = saveState.weather;

        //Set the forecast 
        WeatherTomorrow = ComputeWeather(TimeManager.Instance.GetGameTimestamp().season);
    }

    public void ClockUpdate(GameTimestamp timestamp)
    {
        //Check if it is 6am 
        if(timestamp.hour == 6 && timestamp.minute == 0)
        {
            //Set the current weather
            if (!weatherSet)
            {
                WeatherToday = ComputeWeather(timestamp.season);
               
            }
            else
            {
                WeatherToday = WeatherTomorrow;
                
            }
            UIManager.Instance.ChangeWeatherUI();

            //Set the forecast 
            WeatherTomorrow = ComputeWeather(timestamp.season);

            weatherSet = true;
            Debug.Log("The weather is " + WeatherToday.ToString());


        }
    }
}

b. Stacking harvest in the inventory

Initially, when we harvest crops from our farm, they will not stack in the inventory and instead override what the player already has in hand. Thus we will fix that in InteractableObject.

InteractableObject.cs

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

public class InteractableObject : MonoBehaviour
{
    //The item information the GameObject is supposed to represent
    public ItemData item;
    public UnityEvent onInteract = new UnityEvent();

    [SerializeField]
    protected string interactText = "Interact";
    [SerializeField]
    protected float offset = 1.5f; 

    public virtual void Pickup()
    {
        //Call the OnInteract Callback
        onInteract?.Invoke();

        //Check if the player is holding on to an item
        if (InventoryManager.Instance.SlotEquipped(InventorySlot.InventoryType.Item))
        {
            //Send the item to inventory before equipping
            InventoryManager.Instance.HandToInventory(InventorySlot.InventoryType.Item);
        }

        //Set the player's inventory to the item
        InventoryManager.Instance.EquipHandSlot(item);

        //Update the changes in the scene
        InventoryManager.Instance.RenderHand();

        //Disable the prompt
        OnMoveAway();
        //Destroy this instance so as to not have multiple copies
        GameStateManager.Instance.PersistentDestroy(gameObject); 
    }

    //When the player is hovering around the item
    public virtual void OnHover()
    {
        UIManager.Instance.InteractPrompt(transform, interactText, offset);
    }

    //What happens when the player is in front of the item
    public virtual void OnMoveAway()
    {
        UIManager.Instance.DeactivateInteractPrompt(); 
    }
}

4. Conclusion

In this article, we added touchups to our in-game scenes and a new weather system.

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 on today:

WeatherData.cs

using UnityEngine;

[CreateAssetMenu(fileName = "WeatherData", menuName = "Weather/WeatherData")]
//Script for handling the Weather Data
public class WeatherData : ScriptableObject
{

    public enum WeatherType {
    Sunny, Rain, Snow, Typhoon, HeavySnow
    }

    public WeatherProbability[] springWeather;

    public WeatherProbability[] summerWeather;

    public WeatherProbability[] fallWeather;

    public WeatherProbability[] winterWeather; 

    
    
}

WeatherProbability.cs

using UnityEngine;
[System.Serializable]
public struct WeatherProbability
{
    public WeatherData.WeatherType weatherType;

    [Range(0f, 1f)]
    public float probability;
    
}

WeatherManager.cs

using UnityEngine;

public class WeatherManager : MonoBehaviour, ITimeTracker
{
    public static WeatherManager 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;
        }
    }

    public WeatherData.WeatherType WeatherToday { get; private set; }

    public WeatherData.WeatherType WeatherTomorrow { get; private set; }

    //Check if the weather has been set before
    bool weatherSet = false; 

    //The weather data
    [SerializeField] WeatherData weatherData;


    void Start()
    {
        //Add this to TimeManager's Listener list
        TimeManager.Instance.RegisterTracker(this);
    }




        public WeatherData.WeatherType ComputeWeather(GameTimestamp.Season season)
    {
        if(weatherData == null)
        {
            throw new System.Exception("No weather data loaded"); 
        }

        //What are the possible weathers to compute
        WeatherProbability[] weatherSet = null;

        switch (season)
        {
            case GameTimestamp.Season.Spring:
                weatherSet = weatherData.springWeather;
                break; 
            case GameTimestamp.Season.Summer:
                weatherSet = weatherData.summerWeather;
                break;

            case GameTimestamp.Season.Fall:
                weatherSet = weatherData.fallWeather;
                break;

            case GameTimestamp.Season.Winter:
                weatherSet = weatherData.winterWeather;
                break; 
        }

        //Roll a random value 
        float randomValue = Random.Range(0, 1f);

        //Initialise probability
        float culmProbability = 0; 
        foreach(WeatherProbability weatherProbability in weatherSet)
        {
            culmProbability += weatherProbability.probability;
            //If in the probability
            if (randomValue <= culmProbability) {
                return weatherProbability.weatherType;
            }

        }

        //Sunny by default
        return WeatherData.WeatherType.Sunny; 
    }

    public void LoadWeather(WeatherSaveState saveState)
    {
        weatherSet = true;
        WeatherToday = saveState.weather;

        //Set the forecast 
        WeatherTomorrow = ComputeWeather(TimeManager.Instance.GetGameTimestamp().season);
    }

    public void ClockUpdate(GameTimestamp timestamp)
    {
        //Check if it is 6am 
        if(timestamp.hour == 6 && timestamp.minute == 0)
        {
            //Set the current weather
            if (!weatherSet)
            {
                WeatherToday = ComputeWeather(timestamp.season);
               
            }
            else
            {
                WeatherToday = WeatherTomorrow;
                
            }
            UIManager.Instance.ChangeWeatherUI();

            //Set the forecast 
            WeatherTomorrow = ComputeWeather(timestamp.season);

            weatherSet = true;
            Debug.Log("The weather is " + WeatherToday.ToString());


        }
    }
}

WeatherEffectController.cs

using System.Collections;
using UnityEngine;

public class WeatherEffectController : MonoBehaviour
{
    [SerializeField]
    GameObject rain,snow,heavySnow;
    Transform player;
    private void Start()
    {
        SceneTransitionManager.Instance.onLocationLoad.AddListener(LoadParticle);
        player = FindFirstObjectByType<PlayerController>().transform;
    }


    private void FixedUpdate()
    {
        //Move the particle to the player
        if (player != null)
        {
            Vector3 pos = new Vector3(player.position.x, transform.position.y, player.position.z);
            transform.position = Vector3.Lerp(transform.position, pos, Time.fixedDeltaTime);
        }
    }

    void LoadParticle()
    {
        //Disable everything
        rain.SetActive(false);
        snow.SetActive(false);
        heavySnow.SetActive(false);

        //Move the particle to the player
        if (player != null)
        {
            Vector3 pos = new Vector3(player.position.x, transform.position.y, player.position.z);
            transform.position = pos; 
        }


        //Check if indoor
        if (SceneTransitionManager.Instance.CurrentlyIndoor())
        {
            return; 
        }

        if(WeatherManager.Instance.WeatherToday == WeatherData.WeatherType.Rain)
        {
            //Rain
            rain.SetActive(true); 
        }

        if (WeatherManager.Instance.WeatherToday == WeatherData.WeatherType.Snow)
        {
            snow.SetActive(true);
        }

        if (WeatherManager.Instance.WeatherToday == WeatherData.WeatherType.HeavySnow)
        {
            heavySnow.SetActive(true); 
        }
    }

}

Television.cs

using UnityEngine;

public class Television : InteractableObject
{

    public override void Pickup()
    {
        //For now, just do a weather forecast
        WeatherData.WeatherType weather = WeatherManager.Instance.WeatherTomorrow;
        DialogueManager.Instance.StartDialogue(DialogueManager.CreateSimpleMessage(
            "The weather tomorrow is " + weather.ToString()
            ));;
    }
}

GameStateManager.cs

using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices.WindowsRuntime;
using UnityEngine;
using UnityEngine.Events; 

public class GameStateManager : MonoBehaviour, ITimeTracker
{
    //lights that turn on at night
    public GameObject[] nightLights;
    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;

    [SerializeField]
    GameBlackboard blackboard = new GameBlackboard();
    //This blackboard will not be saved 
    GameBlackboard sceneItemsBoard = new GameBlackboard();

    const string TIMESTAMP = "Timestamp";
    public GameBlackboard GetBlackboard()
    {
        return blackboard;
    }

    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);
        SceneTransitionManager.Instance.onLocationLoad.AddListener(RenderPersistentObjects); 
    }

    public void ClockUpdate(GameTimestamp timestamp)
    {
        UpdateShippingState(timestamp); 
        UpdateFarmState(timestamp);
        IncubationManager.UpdateEggs();
        blackboard.SetValue(TIMESTAMP, timestamp);

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

        //6:01 am, do rain on land logic
        if(timestamp.hour == 6 && timestamp.minute == 1) { 
            RainOnLand();
        
        }

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

        } 
        else
        {
            minutesElapsed++; 
        }

        // the lights will turn on at 6pm
        nightLights = GameObject.FindGameObjectsWithTag("Lights");
        foreach (GameObject i in nightLights)
        {
            if (timestamp.hour >= 18)
            {
                i.GetComponent<Light>().enabled = true;
            }
            else
            {
                i.GetComponent<Light>().enabled = false;
            }
        }

    }

    //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 RainOnLand()
    {
        //Check if raining
        if (WeatherManager.Instance.WeatherToday != WeatherData.WeatherType.Rain) {
            return;
        }

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

        for(int i=0; i< landData.Count; i++)
        {
            //Set the last watered to now
            if (landData[i].landStatus != Land.LandStatus.Soil)
            {
                landData[i] = new LandSaveState(Land.LandStatus.Watered, TimeManager.Instance.GetGameTimestamp(), landData[i].obstacleStatus);

            }
        }
        
    }

    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());
            });*/
        }
    }

    //Instantiate the GameObject such that it stays in the scene even after the player leaves 
    public GameObject PersistentInstantiate(GameObject gameObject, Vector3 position, Quaternion rotation)
    {
        GameObject item = Instantiate(gameObject, position, rotation);
        //Save it to the blackboard
        (GameObject, Vector3) itemInformation = (gameObject, position);
        List<(GameObject, Vector3)> items = sceneItemsBoard.GetOrInitList<(GameObject, Vector3)>(SceneTransitionManager.Instance.currentLocation.ToString());
        items.Add(itemInformation);
        sceneItemsBoard.Debug();
        return item;
    }

    public void PersistentDestroy(GameObject item)
    {
        if (sceneItemsBoard.TryGetValue(SceneTransitionManager.Instance.currentLocation.ToString(), out List<(GameObject, Vector3)> items))
        {
            int index = items.FindIndex(i => i.Item2 == item.transform.position);
            items.RemoveAt(index); 
        }
        Destroy(item); 
    }

    void RenderPersistentObjects()
    {
        Debug.Log("Rendering Persistent Objects");
        if (sceneItemsBoard.TryGetValue(SceneTransitionManager.Instance.currentLocation.ToString(), out List<(GameObject, Vector3)> items)) { 
            foreach((GameObject, Vector3) item in items)
            {
                Instantiate(item.Item1, item.Item2, Quaternion.identity); 
            }
        }
    }

    public void Sleep()
    {
        //Call a fadeout
        UIManager.Instance.FadeOutScreen();
        screenFadedOut = false;
        StartCoroutine(TransitionTime());
        //Restore Stamina fully when sleeping
        PlayerStats.RestoreStamina();
    }

    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();

        WeatherSaveState weather = WeatherSaveState.Export();

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

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

        blackboard = save.blackboard;
        blackboard.Debug();

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

        //Inventory
        save.inventorySaveState.LoadData();

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

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

        //Weather
        save.weatherSaveState.LoadData();

        //Relationship stats
        AnimalStats.LoadStats();
        RelationshipStats.LoadStats(); 
        
        //save.relationshipSaveState.LoadData();

    }
}

Land.cs

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

public class Land : MonoBehaviour, ITimeTracker
{
    public int id; 
    public enum LandStatus
    {
        Soil, Farmland, Watered
    }

    public LandStatus landStatus;

    public Material soilMat, farmlandMat, wateredMat;
    new Renderer renderer;

    //The selection gameobject to enable when the player is selecting the land
    public GameObject select;

    //Cache the time the land was watered 
    GameTimestamp timeWatered;

    [Header("Crops")]
    //The crop prefab to instantiate
    public GameObject cropPrefab;

    //The crop currently planted on the land
    CropBehaviour cropPlanted = null;

    //Obstacles
    public enum FarmObstacleStatus { None, Rock, Wood, Weeds}
    [Header("Obstacles")]
    public FarmObstacleStatus obstacleStatus;
    public GameObject rockPrefab, woodPrefab, weedsPrefab;

    //Store the instantiated obstacle as a variable so we can access it 
    GameObject obstacleObject; 

    // Start is called before the first frame update
    void Start()
    {
        //Get the renderer component
        renderer = GetComponent<Renderer>();

        //Set the land to soil by default
        SwitchLandStatus(LandStatus.Soil);

        //Deselect the land by default
        Select(false);

        //Add this to TimeManager's Listener list
        TimeManager.Instance.RegisterTracker(this);


    }

    public void LoadLandData(LandStatus landStatusToSwitch, GameTimestamp lastWatered, FarmObstacleStatus obstacleStatusToSwitch)
    {
        //Set land status accordingly
        landStatus = landStatusToSwitch;
        timeWatered = lastWatered;

        Material materialToSwitch = soilMat;

        //Decide what material to switch to
        switch (landStatusToSwitch)
        {
            case LandStatus.Soil:
                //Switch to the soil material
                materialToSwitch = soilMat;
                break;
            case LandStatus.Farmland:
                //Switch to farmland material 
                materialToSwitch = farmlandMat;
                break;

            case LandStatus.Watered:
                //Switch to watered material
                materialToSwitch = wateredMat;
                break;

        }

        //Get the renderer to apply the changes
        renderer.material = materialToSwitch;

        switch (obstacleStatusToSwitch)
        {
            case FarmObstacleStatus.None:
                //Destroy the Obstacle object, if any
                if (obstacleObject != null) Destroy(obstacleObject);
                break;
            case FarmObstacleStatus.Rock:
                //Instantiate the obstacle prefab on the land and assign it to obstacleObject
                obstacleObject = Instantiate(rockPrefab, transform);
                break;
            case FarmObstacleStatus.Wood:
                //Instantiate the obstacle prefab on the land and assign it to obstacleObject
                obstacleObject = Instantiate(woodPrefab, transform);
                break;
            case FarmObstacleStatus.Weeds:
                //Instantiate the obstacle prefab on the land and assign it to obstacleObject
                obstacleObject = Instantiate(weedsPrefab, transform);
                break;
        }

        //Move the obstacle object to the top of the land gameobject
        if (obstacleObject != null) obstacleObject.transform.position = new Vector3(transform.position.x, 0.1f, transform.position.z);

        //Set the status accordingly
        obstacleStatus = obstacleStatusToSwitch;

    }

    public void SwitchLandStatus(LandStatus statusToSwitch)
    {
        //Set land status accordingly
        landStatus = statusToSwitch;

        Material materialToSwitch = soilMat; 

        //Decide what material to switch to
        switch (statusToSwitch)
        {
            case LandStatus.Soil:
                //Switch to the soil material
                materialToSwitch = soilMat;
                break;
            case LandStatus.Farmland:
                //Switch to farmland material 
                materialToSwitch = farmlandMat;

                break;

            case LandStatus.Watered:
                //Switch to watered material
                materialToSwitch = wateredMat;

                //Cache the time it was watered
                timeWatered = TimeManager.Instance.GetGameTimestamp(); 
                break; 

        }

        //Get the renderer to apply the changes
        renderer.material = materialToSwitch;

        LandManager.Instance.OnLandStateChange(id, landStatus, timeWatered, obstacleStatus);
    }

    public void SetObstacleStatus(FarmObstacleStatus statusToSwitch)
    {
        switch (statusToSwitch)
        {
            case FarmObstacleStatus.None:
                //Destroy the Obstacle object, if any
                if (obstacleObject != null) Destroy(obstacleObject); 
                break;
            case FarmObstacleStatus.Rock:
                //Instantiate the obstacle prefab on the land and assign it to obstacleObject
                obstacleObject = Instantiate(rockPrefab, transform); 
                break;
            case FarmObstacleStatus.Wood:
                //Instantiate the obstacle prefab on the land and assign it to obstacleObject
                obstacleObject = Instantiate(woodPrefab, transform);
                break;
            case FarmObstacleStatus.Weeds:
                //Instantiate the obstacle prefab on the land and assign it to obstacleObject
                obstacleObject = Instantiate(weedsPrefab, transform);
                break; 
        }

        //Move the obstacle object to the top of the land gameobject
        if(obstacleObject != null) obstacleObject.transform.position = new Vector3(transform.position.x, 0.1f, transform.position.z);

        //Set the status accordingly
        obstacleStatus = statusToSwitch;

        LandManager.Instance.OnLandStateChange(id, landStatus, timeWatered, obstacleStatus);
    }

    public void Select(bool toggle)
    {
        select.SetActive(toggle);
    }

    //When the player presses the interact button while selecting this land
    public void Interact()
    {
        //Check the player's tool slot
        ItemData toolSlot = InventoryManager.Instance.GetEquippedSlotItem(InventorySlot.InventoryType.Tool);

        //If there's nothing equipped, return
        if (!InventoryManager.Instance.SlotEquipped(InventorySlot.InventoryType.Tool))
        {
            return; 
        }

        //Try casting the itemdata in the toolslot as EquipmentData
        EquipmentData equipmentTool = toolSlot as EquipmentData; 

        //Check if it is of type EquipmentData 
        if(equipmentTool != null)
        {
            //Get the tool type
            EquipmentData.ToolType toolType = equipmentTool.toolType;

            switch (toolType)
            {
                case EquipmentData.ToolType.Hoe:
                    SwitchLandStatus(LandStatus.Farmland);
                    break;
                case EquipmentData.ToolType.WateringCan:
                    //The land must be tilled first
                    if (landStatus != LandStatus.Soil)
                    {
                        SwitchLandStatus(LandStatus.Watered);
                    }
                    
                    break;

                case EquipmentData.ToolType.Shovel:

                    //Remove the crop from the land
                    if(cropPlanted != null)
                    {
                        cropPlanted.RemoveCrop();
                    }

                    //Remove weed obstacle
                    if (obstacleStatus == FarmObstacleStatus.Weeds) SetObstacleStatus(FarmObstacleStatus.None); 

                    break;

                case EquipmentData.ToolType.Axe:
                    //Remove wood obstacle
                    if (obstacleStatus == FarmObstacleStatus.Wood) SetObstacleStatus(FarmObstacleStatus.None);
                    break;

                case EquipmentData.ToolType.Pickaxe:
                    //Remove rock obstacle
                    if (obstacleStatus == FarmObstacleStatus.Rock) SetObstacleStatus(FarmObstacleStatus.None);
                    break;

            }

            //We don't need to check for seeds if we have already confirmed the tool to be an equipment
            return; 
        }

        //Try casting the itemdata in the toolslot as SeedData
        SeedData seedTool = toolSlot as SeedData; 

        ///Conditions for the player to be able to plant a seed
        ///1: He is holding a tool of type SeedData
        ///2: The Land State must be either watered or farmland
        ///3. There isn't already a crop that has been planted
        ///4. There are no obstacles
        if(seedTool != null && landStatus != LandStatus.Soil && cropPlanted == null && obstacleStatus == FarmObstacleStatus.None)
        {
            SpawnCrop();
            //Plant it with the seed's information
            cropPlanted.Plant(id, seedTool);

            //Consume the item
            InventoryManager.Instance.ConsumeItem(InventoryManager.Instance.GetEquippedSlot(InventorySlot.InventoryType.Tool));

        }
    }

    public CropBehaviour SpawnCrop()
    {
        //Instantiate the crop object parented to the land
        GameObject cropObject = Instantiate(cropPrefab, transform);
        //Move the crop object to the top of the land gameobject
        cropObject.transform.position = new Vector3(transform.position.x, 0.1f, transform.position.z);

        //Access the CropBehaviour of the crop we're going to plant
        cropPlanted = cropObject.GetComponent<CropBehaviour>();
        return cropPlanted; 
    }

    public void ClockUpdate(GameTimestamp timestamp)
    {

        //When raining, have it watered if it is farmland
        if (WeatherManager.Instance.WeatherToday == WeatherData.WeatherType.Rain && landStatus == LandStatus.Farmland)
        {
            SwitchLandStatus(LandStatus.Watered);

        }

        //Checked if 24 hours has passed since last watered
        if (landStatus == LandStatus.Watered)
        {
            //Hours since the land was watered
            int hoursElapsed = GameTimestamp.CompareTimestamps(timeWatered, timestamp);
            Debug.Log(hoursElapsed + " hours since this was watered");

            //Grow the planted crop, if any
            if(cropPlanted != null)
            {
                cropPlanted.Grow();
            }

            if(hoursElapsed > 24)
            {
                //Dry up (Switch back to farmland)
                SwitchLandStatus(LandStatus.Farmland);
            }
        }

        //Handle the wilting of the plant when the land is not watered
        if(landStatus != LandStatus.Watered && cropPlanted != null)
        {
            //If the crop has already germinated, start the withering
            if (cropPlanted.cropState != CropBehaviour.CropState.Seed)
            {
                cropPlanted.Wither();
            }
        }
    }

    private void OnDestroy()
    {
        //Unsubscribe from the list on destroy
        TimeManager.Instance.UnregisterTracker(this);
    }
}

LocationEntryPoint.cs

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

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

    [SerializeField]
    //For locking doors
    bool locked = false; 

    private void OnTriggerEnter(Collider other)
    {
        //Check if the collider belongs to the player
        if(other.tag == "Player")
        {
            if(WeatherManager.Instance.WeatherToday == WeatherData.WeatherType.Typhoon && SceneTransitionManager.Instance.currentLocation == SceneTransitionManager.Location.PlayerHome || locked)
            {
                //You cant go out in a typhoon
                DialogueManager.Instance.StartDialogue(DialogueManager.CreateSimpleMessage("It's not safe to go out."));
                return;
            }

            //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))
        {
            if (Input.GetKey(KeyCode.LeftShift)) { 
                //Advance the entire day
                for(int i =0; i< 60*24;  i++)
                {
                    TimeManager.Instance.Tick();
                }
            } else
            {
                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 if allowed
            if (controller.enabled)
            {
                controller.Move(velocity);
            }
            
            
        }

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



    }
}

WeatherSaveState.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))
        {
            if (Input.GetKey(KeyCode.LeftShift)) { 
                //Advance the entire day
                for(int i =0; i< 60*24;  i++)
                {
                    TimeManager.Instance.Tick();
                }
            } else
            {
                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 if allowed
            if (controller.enabled)
            {
                controller.Move(velocity);
            }
            
            
        }

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



    }
}

GameSaveState.cs

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

[System.Serializable]
public class GameSaveState
{
    //Blackboard 
    public GameBlackboard blackboard; 
    //Farm Data
    public FarmSaveState farmSaveState;
    //Inventory
    public InventorySaveState inventorySaveState;
    //Time
    public GameTimestamp timestamp;

    //PlayerStats
    public PlayerSaveState playerSaveState;

    //Weather State
    public WeatherSaveState weatherSaveState;

    //Relationships
    //public RelationshipSaveState relationshipSaveState;

    public GameSaveState(
        GameBlackboard blackboard,
        FarmSaveState farmSaveState,
        InventorySaveState inventorySaveState,
        GameTimestamp timestamp,
        PlayerSaveState playerSaveState,
        WeatherSaveState weatherSaveState
        //RelationshipSaveState relationshipSaveState
        )
        
    {
        this.blackboard = blackboard;
        this.farmSaveState = farmSaveState;
        this.inventorySaveState = inventorySaveState;
        this.timestamp = timestamp;
        this.playerSaveState = playerSaveState;
        this.weatherSaveState = weatherSaveState;
        //this.relationshipSaveState = relationshipSaveState;
    }
}

UIManager.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;

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

    [Header("Screen Management")]
    public GameObject menuScreen;
    public enum Tab
    {
        Inventory, Relationships, Animals
    }
    //The current selected tab
    public Tab selectedTab;

    [Header("Status Bar")]
    //Tool equip slot on the status bar
    public Image toolEquipSlot;
    //Tool Quantity text on the status bar 
    public TextMeshProUGUI toolQuantityText;
    //Time UI
    public TextMeshProUGUI timeText;
    public TextMeshProUGUI dateText;


    [Header("Inventory System")]
    //The inventory panel
    public GameObject inventoryPanel;

    //The tool equip slot UI on the Inventory panel
    public HandInventorySlot toolHandSlot;

    //The tool slot UIs
    public InventorySlot[] toolSlots;

    //The item equip slot UI on the Inventory panel
    public HandInventorySlot itemHandSlot;

    //The item slot UIs
    public InventorySlot[] itemSlots;

    [Header("Item info box")]
    public GameObject itemInfoBox;
    public TextMeshProUGUI itemNameText;
    public TextMeshProUGUI itemDescriptionText;

    [Header("Screen Transitions")]
    public GameObject fadeIn;
    public GameObject fadeOut;

    [Header("Prompts")]
    public YesNoPrompt yesNoPrompt;
    public NamingPrompt namingPrompt;
    [SerializeField] InteractBubble interactBubble;

    [Header("Player Stats")]
    public TextMeshProUGUI moneyText;

    [Header("Shop")]
    public ShopListingManager shopListingManager;

    [Header("Relationships")]
    public RelationshipListingManager relationshipListingManager;
    public AnimalListingManager animalRelationshipListingManager;

    [Header("Calendar")]
    public CalendarUIListing calendar;

    [Header("Stamina")]
    public Sprite[] staminaUI;
    public Image StaminaUIImage;
    public int staminaCount;

    [Header("Weather")]
    public Sprite[] weatherUI;
    public Image WeatherUIImage;


    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;
        }
    }

    private void Start()
    {
        PlayerStats.RestoreStamina();
        RenderInventory();
        AssignSlotIndexes();
        RenderPlayerStats();
        DisplayItemInfo(null);
        ChangeWeatherUI();

        //Add UIManager to the list of objects TimeManager will notify when the time updates
        TimeManager.Instance.RegisterTracker(this);
    }

    #region Prompts

    public void TriggerNamingPrompt(string message, System.Action<string> onConfirmCallback)
    {
        //Check if another prompt is already in progress
        if (namingPrompt.gameObject.activeSelf)
        {
            //Queue the prompt
            namingPrompt.QueuePromptAction(() => TriggerNamingPrompt(message, onConfirmCallback));
            return;
        }

        //Open the panel
        namingPrompt.gameObject.SetActive(true);

        namingPrompt.CreatePrompt(message, onConfirmCallback);
    }

    public void TriggerYesNoPrompt(string message, System.Action onYesCallback)
    {
        //Set active the gameobject of the Yes No Prompt
        yesNoPrompt.gameObject.SetActive(true);

        yesNoPrompt.CreatePrompt(message, onYesCallback);
    }
    #endregion

    #region Tab Management
    public void ToggleMenuPanel()
    {
        menuScreen.SetActive(!menuScreen.activeSelf);

        OpenWindow(selectedTab);

        TabBehaviour.onTabStateChange?.Invoke();
    }

    //Manage the opening of windows associated with the tab
    public void OpenWindow(Tab windowToOpen)
    {
        //Disable all windows
        relationshipListingManager.gameObject.SetActive(false);
        inventoryPanel.SetActive(false);
        animalRelationshipListingManager.gameObject.SetActive(false);

        //Open the corresponding window and render it
        switch (windowToOpen)
        {
            case Tab.Inventory:
                inventoryPanel.SetActive(true);
                RenderInventory();
                break;
            case Tab.Relationships:
                relationshipListingManager.gameObject.SetActive(true);
                relationshipListingManager.Render(RelationshipStats.relationships);
                break;
            case Tab.Animals:
                animalRelationshipListingManager.gameObject.SetActive(true);
                animalRelationshipListingManager.Render(AnimalStats.animalRelationships);
                break;

        }

        //Set the selected tab
        selectedTab = windowToOpen;


    }
    #endregion

    #region Fadein Fadeout Transitions

    public void FadeOutScreen()
    {
        fadeOut.SetActive(true);
    }

    public void FadeInScreen()
    {
        fadeIn.SetActive(true);
    }

    public void OnFadeInComplete()
    {
        //Disable Fade in Screen when animation is completed
        fadeIn.SetActive(false);
    }

    //Reset the fadein fadeout screens to their default positions
    public void ResetFadeDefaults()
    {
        fadeOut.SetActive(false);
        fadeIn.SetActive(true);
    }



    #endregion

    #region Inventory
    //Iterate through the slot UI elements and assign it its reference slot index
    public void AssignSlotIndexes()
    {
        for (int i = 0; i < toolSlots.Length; i++)
        {
            toolSlots[i].AssignIndex(i);
            itemSlots[i].AssignIndex(i);
        }
    }

    //Render the inventory screen to reflect the Player's Inventory. 
    public void RenderInventory()
    {
        //Get the respective slots to process
        ItemSlotData[] inventoryToolSlots = InventoryManager.Instance.GetInventorySlots(InventorySlot.InventoryType.Tool);
        ItemSlotData[] inventoryItemSlots = InventoryManager.Instance.GetInventorySlots(InventorySlot.InventoryType.Item);

        //Render the Tool section
        RenderInventoryPanel(inventoryToolSlots, toolSlots);

        //Render the Item section
        RenderInventoryPanel(inventoryItemSlots, itemSlots);

        //Render the equipped slots
        toolHandSlot.Display(InventoryManager.Instance.GetEquippedSlot(InventorySlot.InventoryType.Tool));
        itemHandSlot.Display(InventoryManager.Instance.GetEquippedSlot(InventorySlot.InventoryType.Item));

        //Get Tool Equip from InventoryManager
        ItemData equippedTool = InventoryManager.Instance.GetEquippedSlotItem(InventorySlot.InventoryType.Tool);

        //Text should be empty by default
        toolQuantityText.text = "";
        //Check if there is an item to display
        if (equippedTool != null)
        {
            //Switch the thumbnail over
            toolEquipSlot.sprite = equippedTool.thumbnail;

            toolEquipSlot.gameObject.SetActive(true);

            //Get quantity 
            int quantity = InventoryManager.Instance.GetEquippedSlot(InventorySlot.InventoryType.Tool).quantity;
            if (quantity > 1)
            {
                toolQuantityText.text = quantity.ToString();
            }
            return;
        }

        toolEquipSlot.gameObject.SetActive(false);
    }

    //Iterate through a slot in a section and display them in the UI
    void RenderInventoryPanel(ItemSlotData[] slots, InventorySlot[] uiSlots)
    {
        for (int i = 0; i < uiSlots.Length; i++)
        {
            //Display them accordingly
            uiSlots[i].Display(slots[i]);
        }
    }

    public void ToggleInventoryPanel()
    {
        //If the panel is hidden, show it and vice versa
        inventoryPanel.SetActive(!inventoryPanel.activeSelf);

        RenderInventory();
    }

    //Display Item info on the Item infobox
    public void DisplayItemInfo(ItemData data)
    {
        //If data is null, reset
        if (data == null)
        {
            itemNameText.text = "";
            itemDescriptionText.text = "";
            itemInfoBox.SetActive(false);
            return;
        }
        itemInfoBox.SetActive(true);
        itemNameText.text = data.name;
        itemDescriptionText.text = data.description;
    }
    #endregion

    #region Time
    //Callback to handle the UI for time
    public void ClockUpdate(GameTimestamp timestamp)
    {
        //Handle the time
        //Get the hours and minutes
        int hours = timestamp.hour;
        int minutes = timestamp.minute;

        //AM or PM
        string prefix = "AM ";

        //Convert hours to 12 hour clock
        if (hours >= 12)
        {
            //Time becomes PM 
            prefix = "PM ";
            //12 PM and later
            hours = hours - 12;
            Debug.Log(hours);
        }
        //Special case for 12am/pm to display it as 12 instead of 0
        hours = hours == 0 ? 12 : hours;

        //Format it for the time text display
        timeText.text = prefix + hours + ":" + minutes.ToString("00");

        //Handle the Date
        int day = timestamp.day;
        string season = timestamp.season.ToString();
        string dayOfTheWeek = timestamp.GetDayOfTheWeek().ToString();

        //Format it for the date text display
        dateText.text = season + " " + day + " (" + dayOfTheWeek + ")";

    }
    #endregion

    //Render the UI of the player stats in the HUD
    public void RenderPlayerStats()
    {
        moneyText.text = PlayerStats.Money + PlayerStats.CURRENCY;
        staminaCount = PlayerStats.Stamina;
        ChangeStaminaUI();

    }

    //Open the shop window with the shop items listed
    public void OpenShop(List<ItemData> shopItems)
    {
        //Set active the shop window
        shopListingManager.gameObject.SetActive(true);
        shopListingManager.Render(shopItems);
    }

    public void ToggleRelationshipPanel()
    {
        GameObject panel = relationshipListingManager.gameObject;
        panel.SetActive(!panel.activeSelf);

        //If open, render the screen
        if (panel.activeSelf)
        {
            relationshipListingManager.Render(RelationshipStats.relationships);
        }
    }

    public void InteractPrompt(Transform item, string message, float offset)
    {
        interactBubble.gameObject.SetActive(true);
        interactBubble.transform.position = item.transform.position + new Vector3(0, offset, 0);
        interactBubble.Display(message);
    }

    public void DeactivateInteractPrompt()
    {
        interactBubble.gameObject.SetActive(false);
    }

    public void ChangeStaminaUI()
    {
        if (staminaCount <= 45) StaminaUIImage.sprite = staminaUI[3]; // exhausted
        else if (staminaCount <= 80) StaminaUIImage.sprite = staminaUI[2]; // tired
        else if (staminaCount <= 115) StaminaUIImage.sprite = staminaUI[1]; // active
        else if (staminaCount <= 150) StaminaUIImage.sprite = staminaUI[0]; // energised

    }

    public void ChangeWeatherUI()
    {
        var WeatherToday = WeatherManager.Instance.WeatherToday;

        switch (WeatherToday)
        {
            case WeatherData.WeatherType.Sunny:
                WeatherUIImage.sprite = weatherUI[0];
                break;
            case WeatherData.WeatherType.Rain:
                WeatherUIImage.sprite = weatherUI[1];
                break;
            case WeatherData.WeatherType.Snow:
                WeatherUIImage.sprite = weatherUI[2];
                break;
            case WeatherData.WeatherType.HeavySnow:
                WeatherUIImage.sprite = weatherUI[3];
                break;
        }
    }
}
    


DialogueManager.cs

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using System.Text.RegularExpressions;

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

    [Header("Dialogue Components")]
    public GameObject dialoguePanel;
    public TextMeshProUGUI speakerText;
    public TextMeshProUGUI dialogueText;

    //The lines to queue during the dialogue sequence
    Queue<DialogueLine> dialogueQueue;
    Action onDialogueEnd = null;

    bool isTyping = false;

    //TODO: Implement a proper player control stop mechanism
    PlayerController playerController; 

    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;
        }
    }

    private void Start()
    {
        playerController = FindFirstObjectByType<PlayerController>();
    }

    //Initialises the dialogue
    public void StartDialogue(List<DialogueLine> dialogueLinesToQueue)
    {
        //Convert the list to a queue
        dialogueQueue = new Queue<DialogueLine>(dialogueLinesToQueue);


        if (playerController != null)
        {
            playerController.enabled = false;
        }

        UpdateDialogue(); 
    }

    //Initialises the dialogue, but with an Action to execute once it finishes
    public void StartDialogue(List<DialogueLine> dialogueLinesToQueue, Action onDialogueEnd)
    {
        StartDialogue(dialogueLinesToQueue);
        this.onDialogueEnd = onDialogueEnd;

    }

    //Cycle through the dialogue lines
    public void UpdateDialogue()
    {
        if (isTyping)
        {
            isTyping = false;
            return; 
        }

        //Reset our dialogue text 
        dialogueText.text = string.Empty; 

        //Check if there are any more lines in the queue
        if(dialogueQueue.Count == 0)
        {
            //If not, end the dialogue
            EndDialogue();
            return;
        }

        //The current dialogue line to put in
        DialogueLine line = dialogueQueue.Dequeue();

        Talk(line.speaker, ParseVariables(line.message)); 
    }

    //Closes the dialogue
    public void EndDialogue()
    {
        //Close the dialogue panel
        dialoguePanel.SetActive(false);

        if (playerController != null)
        {
            playerController.enabled = true;
        }

        //Invoke whatever Action queued on dialogue end (if any)
        onDialogueEnd?.Invoke();



        //Reset the Action 
        onDialogueEnd = null; 


    }

    public void Talk(string speaker, string message)
    {
        //Set the dialogue panel active
        dialoguePanel.SetActive(true);

        //Set the speaker text to the speaker
        speakerText.text = speaker;

        //If there is no speaker, do not show the speaker text panel
        speakerText.transform.parent.gameObject.SetActive(speaker != "");

        //Set the dialogue text to the message
        //dialogueText.text = message;
        StartCoroutine(TypeText(message));
    }

    IEnumerator TypeText(string textToType)
    {
        isTyping = true; 
        //Convert the string to an array of chars
        char[] charsToType = textToType.ToCharArray(); 
        for(int i =0; i < charsToType.Length; i++)
        {
            dialogueText.text += charsToType[i];
            yield return new WaitForEndOfFrame();

            //Skip the typing sequence and just show the full text
            if (!isTyping)
            {
                dialogueText.text = textToType;
                //Break out from the loop
                break; 
            }
        }

        //Typing sequence complete
        isTyping = false; 
    }

    //Converts a simple string into a List of Dialogue lines to put into DialogueManager
    public static List<DialogueLine> CreateSimpleMessage(string message)
    {
        //The Dialogue line we want to output 
        DialogueLine messageDialogueLine = new DialogueLine("",message);

        List<DialogueLine> listToReturn = new List<DialogueLine>();

        listToReturn.Add(messageDialogueLine);

        return listToReturn; 
    }

    //Filter to see if there is any dialogue lines we can overwrite with
    public static List<DialogueLine> SelectDialogue(List<DialogueLine> dialogueToExecute, DialogueCondition[] conditions)
    {
        //Replace the dialogue set with the highest condition score
        int highestConditionScore = -1; 
        foreach(DialogueCondition condition in conditions)
        {
            //Check if conditions met first
            if(condition.CheckConditions(out int score))
            {
                if(score > highestConditionScore)
                {
                    highestConditionScore = score;
                    dialogueToExecute = condition.dialogueLine;
                    Debug.Log("Will play " + condition.id); 
                }
            }
        }

        

        return dialogueToExecute; 
    }

    /// <summary>
    /// Any {} in the message will be retrieved from the blackboard
    /// </summary>
    /// <param name="message">The string to pass in </param>
    /// <returns></returns>
    string ParseVariables(string message)
    {
        if(GameStateManager.Instance != null)
        {
            //Get the blackboard
            GameBlackboard blackboard = GameStateManager.Instance.GetBlackboard();
        
            if(blackboard != null)
            {
                //Look for strings enclosed with {}
                string pattern = @"\{([^}]+?)\}";
                //Regex replacement step
                message = Regex.Replace(message, pattern, match => {
                    //The variable name enclosed in the "{}"
                    string variableName = match.Groups[1].Value;

                    //If there is a string value, return it
                    if (blackboard.TryGetValueAsString(variableName, out string strValue))
                    {
                        return strValue;
                    }
                    //Nothing found, so nothing is returned
                    return ""; 
                });

            }
        }
        return message;
    }
}

InteractableObject.cs

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

public class InteractableObject : MonoBehaviour
{
    //The item information the GameObject is supposed to represent
    public ItemData item;
    public UnityEvent onInteract = new UnityEvent();

    [SerializeField]
    protected string interactText = "Interact";
    [SerializeField]
    protected float offset = 1.5f; 

    public virtual void Pickup()
    {
        //Call the OnInteract Callback
        onInteract?.Invoke();

        //Check if the player is holding on to an item
        if (InventoryManager.Instance.SlotEquipped(InventorySlot.InventoryType.Item))
        {
            //Send the item to inventory before equipping
            InventoryManager.Instance.HandToInventory(InventorySlot.InventoryType.Item);
        }

        //Set the player's inventory to the item
        InventoryManager.Instance.EquipHandSlot(item);

        //Update the changes in the scene
        InventoryManager.Instance.RenderHand();

        //Disable the prompt
        OnMoveAway();
        //Destroy this instance so as to not have multiple copies
        GameStateManager.Instance.PersistentDestroy(gameObject); 
    }

    //When the player is hovering around the item
    public virtual void OnHover()
    {
        UIManager.Instance.InteractPrompt(transform, interactText, offset);
    }

    //What happens when the player is in front of the item
    public virtual void OnMoveAway()
    {
        UIManager.Instance.DeactivateInteractPrompt(); 
    }
}