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
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:
- Add suitable furniture/props to decorate each scene
- Rebake the navmesh surface
- Rebake the lighting settings
- Adjust the location entry points to fit each expanded/relocated scene
- 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:
- Medieval Interior Asset Pack by SOI – Used for the Inn Scene
- Village Asset Pack by Styloo – Used for both Town and Town Square
- 700+ Pixel Art Texture – 256×256 by FlakDeu – Textures for general walls and floors.
- Furniture Kit by Kenney – Large interior furniture pack for homes
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(); } }