Ever wanted to create a game like Harvest Moon in Unity? Check out Part 6 of our guide here, where we go through how to set up a day-night cycle and manage in-game time. You can also find Part 5 of our guide here, where we went through how to create an item equipping system.
1. Creating the in-game time system
Unlike most games, we cannot simply use Unity’s built-in Time
as we are looking to store more complex information than that. DateTime
might be more helpful, but our game’s dating system does not exactly follow real life. Instead of 12 months, there are 4 seasons in a year, spanning 30 days each. Hence, we will set up our own class to represent how in-game time will pass.
a. Setting up our own time class
For starters, we want to record the following information:
- Year
- Season
- Day
- Hour
- Minute
Create a new script called GameTimestamp
:
There are 4 seasons in a year: Spring, Summer, Fall, Winter. These seasons will be represented as an enumeration (i.e. enum).
GameTimestamp.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class GameTimestamp { public int year; public enum Season { Spring, Summer, Fall, Winter } public Season season; public int day; public int hour; public int minute;// Start is called before the first frame update void Start() { } // Update is called once per frame public void Update() { }//Constructor to set up the class public GameTimestamp(int year, Season season, int day, int hour, int minute) { this.year = year; this.season = season; this.day = day; this.hour = hour; this.minute = minute; } }
As GameTimestamp
is a class that will be instantiated whenever we want to record in-game time, notice that we also create a constructor for it. To read more about constructors, check out this article.
b. Updating the Timestamp by the minute
Each of the variables in the class is affected by one another. Our in-game clock will update in increments of the minute. When that happens, the following conversions must also be made:
- 60 minutes to 1 hour
- 24 hours to 1 day
- 30 days to 1 season
- 4 seasons to 1 year
To this end, let’s make a function to handle this updating of the clock:
Note: The days start from 1 because there are no ‘Day 0s’ in a calendar month.
GameTimestamp.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class GameTimestamp { public int year; public enum Season { Spring, Summer, Fall, Winter } public Season season; public int day; public int hour; public int minute; //Constructor to set up the class public GameTimestamp(int year, Season season, int day, int hour, int minute) { this.year = year; this.season = season; this.day = day; this.hour = hour; this.minute = minute; } //Makes an increment of the time by 1 minute public void UpdateClock() { minute++; //60 minutes in 1 hour if(minute >= 60) { //reset minutes minute = 0; hour++; } //24 hours in 1 day if(hour >= 24) { //Reset hours hour = 0; day++; } //30 days in a season if(day > 30) { //Reset days day = 1; //If at the final season, reset and change to spring if(season == Season.Winter) { season = Season.Spring; //Start of a new year year++; } else { season++; } } } }
c. Handling conversions
A lot of the game’s mechanics are affected by time. This is especially necessary for our core farming system. Consequently, we will have to convert to different units to compare between two timestamps often. To make this process easier, we can set up some static functions to do the calculations ahead of time.
Let’s do the straightforward calculations first:
- 1 hour = 60 minutes
- 1 day = 24 hours
- 1 year = 30 days * 4 seasons
Converting seasons to days might be a bit more challenging. After all, we have represented them with enums, so how are the calculations going to be done?
- At Spring: 0 seasons have passed × 30 days in a season = 0 days
- At Summer: 1 season has passed × 30 days in a season = 30 days
- At Fall: 2 seasons have passed × 30 days in a season = 60 days
- At Winter: 3 seasons have passed × 30 days in a season = 90 days
GameTimestamp.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class GameTimestamp { public int year; public enum Season { Spring, Summer, Fall, Winter } public Season season; public int day; public int hour; public int minute; //Constructor to set up the class public GameTimestamp(int year, Season season, int day, int hour, int minute) { this.year = year; this.season = season; this.day = day; this.hour = hour; this.minute = minute; } //Makes an increment of the time by 1 minute public void UpdateClock() { minute++; //60 minutes in 1 hour if(minute >= 60) { //reset minutes minute = 0; hour++; } //24 hours in 1 day if(hour >= 24) { //Reset hours hour = 0; day++; } //30 days in a season if(day > 30) { //Reset days day = 1; //If at the final season, reset and change to spring if(season == Season.Winter) { season = Season.Spring; //Start of a new year year++; } else { season++; } } } //Convert hours to minutes public static int HoursToMinutes(int hour) { //60 minutes = 1 hour return hour * 60; } //Convert Days to Hours public static int DaysToHours(int days) { //24 Hours in a day return days * 24; } //Convert Seasons to days public static int SeasonsToDays(Season season) { int seasonIndex = (int)season; return seasonIndex * 30; } //Years to Days public static int YearsToDays(int years) { return years * 4 * 30; } }
d. Calculating the days of the week
In the Status Bar of the Player’s HUD, the date includes the season, day, and day of the week.
The days of the week will be represented as an enum in GameTimestamp
:
GameTimestamp.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class GameTimestamp { public int year; public enum Season { Spring, Summer, Fall, Winter } public Season season; public enum DayOfTheWeek { Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, } public int day; public int hour; public int minute; //Constructor to set up the class public GameTimestamp(int year, Season season, int day, int hour, int minute) { this.year = year; this.season = season; this.day = day; this.hour = hour; this.minute = minute; } //Makes an increment of the time by 1 minute public void UpdateClock() { minute++; //60 minutes in 1 hour if(minute >= 60) { //reset minutes minute = 0; hour++; } //24 hours in 1 day if(hour >= 24) { //Reset hours hour = 0; day++; } //30 days in a season if(day > 30) { //Reset days day = 1; //If at the final season, reset and change to spring if(season == Season.Winter) { season = Season.Spring; //Start of a new year year++; } else { season++; } } } //Convert hours to minutes public static int HoursToMinutes(int hour) { //60 minutes = 1 hour return hour * 60; } //Convert Days to Hours public static int DaysToHours(int days) { //24 Hours in a day return days * 24; } //Convert Seasons to days public static int SeasonsToDays(Season season) { int seasonIndex = (int)season; return seasonIndex * 30; } //Years to Days public static int YearsToDays(int years) { return years * 4 * 30; } }
Now comes the tricky part: how do we figure out which day of the week it is depending on the day?
To find out, we look to a calendar in real life.
Do you notice that if you were to divide all the dates under a certain day of the week by 7, it would consistently return the same remainder every time? Likewise, to find the day of the week in our game, we just have to calculate the total number of days and perform a modulo operation (with the %
operator) on it and find the corresponding day of the week with it.
Therefore, let us create a function to compute the day of the week in GameTimestamp
:
GameTimestamp.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class GameTimestamp { public int year; public enum Season { Spring, Summer, Fall, Winter } public Season season; public enum DayOfTheWeek { Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday } public int day; public int hour; public int minute; //Constructor to set up the class public GameTimestamp(int year, Season season, int day, int hour, int minute) { this.year = year; this.season = season; this.day = day; this.hour = hour; this.minute = minute; } //Makes an increment of the time by 1 minute public void UpdateClock() { minute++; //60 minutes in 1 hour if(minute >= 60) { //reset minutes minute = 0; hour++; } //24 hours in 1 day if(hour >= 24) { //Reset hours hour = 0; day++; } //30 days in a season if(day > 30) { //Reset days day = 1; //If at the final season, reset and change to spring if(season == Season.Winter) { season = Season.Spring; //Start of a new year year++; } else { season++; } } } public DayOfTheWeek GetDayOfTheWeek() { //Convert the total time passed into days int daysPassed = YearsToDays(year) + SeasonsToDays(season) + day; //Remainder after dividing daysPassed by 7 int dayIndex = daysPassed % 7; //Cast into Day of the Week return (DayOfTheWeek)dayIndex; } //Convert hours to minutes public static int HoursToMinutes(int hour) { //60 minutes = 1 hour return hour * 60; } //Convert Days to Hours public static int DaysToHours(int days) { //24 Hours in a day return days * 24; } //Convert Seasons to days public static int SeasonsToDays(Season season) { int seasonIndex = (int)season; return seasonIndex * 30; } //Years to Days public static int YearsToDays(int years) { return years * 4 * 30; } }
Based on what we have now, Day 1 will begin on a Monday, as our enum assigns Monday to have an index of 1. You can hence determine what day of the week the game will start in by reordering them such that your desired day ends up with an index of 1.
At least in the case of this tutorial, we want the game’s calendar to begin on a Sunday as it is the first day of the week. For Sunday to have an index of 1, it has to be preceded by Saturday. Thus, the order of the days in DayOfTheWeek enum should be rearranged.
Note: You can also explicitly assign integer values to each of them in the set (e.g. {Sunday=1, Monday=2, Saturday=0}
), but simply reordering them keeps it tidier and less confusing to read.
Make the following changes to GameTimestamp
:
GameTimestamp.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class GameTimestamp { public int year; public enum Season { Spring, Summer, Fall, Winter } public Season season; public enum DayOfTheWeek Saturday, Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday} public int day; public int hour; public int minute; //Constructor to set up the class public GameTimestamp(int year, Season season, int day, int hour, int minute) { this.year = year; this.season = season; this.day = day; this.hour = hour; this.minute = minute; } //Makes an increment of the time by 1 minute public void UpdateClock() { minute++; //60 minutes in 1 hour if(minute >= 60) { //reset minutes minute = 0; hour++; } //24 hours in 1 day if(hour >= 24) { //Reset hours hour = 0; day++; } //30 days in a season if(day > 30) { //Reset days day = 1; //If at the final season, reset and change to spring if(season == Season.Winter) { season = Season.Spring; //Start of a new year year++; } else { season++; } } } public DayOfTheWeek GetDayOfTheWeek() { //Convert the total time passed into days int daysPassed = YearsToDays(year) + SeasonsToDays(season) + day; //Remainder after dividing daysPassed by 7 int dayIndex = daysPassed % 7; //Cast into Day of the Week return (DayOfTheWeek)dayIndex; } //Convert hours to minutes public static int HoursToMinutes(int hour) { //60 minutes = 1 hour return hour * 60; } //Convert Days to Hours public static int DaysToHours(int days) { //24 Hours in a day return days * 24; } //Convert Seasons to days public static int SeasonsToDays(Season season) { int seasonIndex = (int)season; return seasonIndex * 30; } //Years to Days public static int YearsToDays(int years) { return years * 4 * 30; } }
What we just created is just a representation of the in-game time that can be instantiated as and when we need it. However, we have not set up our actual in-game clock.
e. TimeManager
Let’s create a Manager class to instantiate a GameTimestamp
for the game’s official clock.
SerializeField
forces Unity to serialize a private field, meaning that it will be displayed in the Inspector even though it is set to private.
The game should start at 6am, Spring 1 Year 0.
Hence, create a new script called TimeManager
and add the following:
TimeManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class TimeManager : MonoBehaviour { public static TimeManager Instance { get; private set; } [SerializeField] GameTimestamp timestamp; 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() { //Initialise the time stamp timestamp = new GameTimestamp(0, GameTimestamp.Season.Spring, 1, 6, 0); } // Update is called once per frame void Update() { } }
Attach the TimeManager
script to the Manager GameObject.
Even though we forced Unity to display the in-game clock in the inspector, it just shows an empty component. This is because we have not indicated that the GameTimestamp
class can be serialized.
Unity uses Serialization to convert data within a script for display and editing in the Inspector.
Hence, add the following to GameTimestamp
:
GameTimestamp.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; [System.Serializable] public class GameTimestamp { public int year; public enum Season { Spring, Summer, Fall, Winter } public Season season; public enum DayOfTheWeek Saturday, Sunday, Monday, Tuesday, Wednesday, Thursday, Friday } public int day; public int hour; public int minute; //Constructor to set up the class public GameTimestamp(int year, Season season, int day, int hour, int minute) { this.year = year; this.season = season; this.day = day; this.hour = hour; this.minute = minute; } //Makes an increment of the time by 1 minute public void UpdateClock() { minute++; //60 minutes in 1 hour if(minute >= 60) { //reset minutes minute = 0; hour++; } //24 hours in 1 day if(hour >= 24) { //Reset hours hour = 0; day++; } //30 days in a season if(day > 30) { //Reset days day = 1; //If at the final season, reset and change to spring if(season == Season.Winter) { season = Season.Spring; //Start of a new year year++; } else { season++; } } } public DayOfTheWeek GetDayOfTheWeek() { //Convert the total time passed into days int daysPassed = YearsToDays(year) + SeasonsToDays(season) + day; //Remainder after dividing daysPassed by 7 int dayIndex = daysPassed % 7; //Cast into Day of the Week return (DayOfTheWeek)dayIndex; } //Convert hours to minutes public static int HoursToMinutes(int hour) { //60 minutes = 1 hour return hour * 60; } //Convert Days to Hours public static int DaysToHours(int days) { //24 Hours in a day return days * 24; } //Convert Seasons to days public static int SeasonsToDays(Season season) { int seasonIndex = (int)season; return seasonIndex * 30; } //Years to Days public static int YearsToDays(int years) { return years * 4 * 30; } }
The timestamp should now be visible in the Inspector.
We need a function that will update the timestamp clock (add 1 minute to the in-game time) and execute all time-based tasks. Create one in TimeManager
:
TimeManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class TimeManager : MonoBehaviour { public static TimeManager Instance { get; private set; } [SerializeField] GameTimestamp timestamp; 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() { //Initialise the time stamp timestamp = new GameTimestamp(0, GameTimestamp.Season.Spring, 1, 6, 0); } // Update is called once per frame void Update() { } //A tick of the in-game time public void Tick() { timestamp.UpdateClock(); } }
The next step is to figure out how to call this function. Update
is not a good place to call it, as we do not want the minutes to update every frame. Instead, we will set up a coroutine for the loop:
TimeManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class TimeManager : MonoBehaviour { public static TimeManager Instance { get; private set; } [SerializeField] GameTimestamp timestamp; public float timeScale = 1.0f; 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() { //Initialise the time stamp timestamp = new GameTimestamp(0, GameTimestamp.Season.Spring, 1, 6, 0); StartCoroutine(TimeUpdate()); } IEnumerator TimeUpdate() { while (true) { Tick(); yield return new WaitForSeconds(1 / timeScale); } }// Update is called once per frame void Update() { }//A tick of the in-game time public void Tick() { timestamp.UpdateClock(); } }
The speed of the in-game time can be controlled with timeScale
. A higher number means the minutes will go by faster, and a lower number means it will go by slower.
2. Creating the day-night cycle
With a working in-game clock in place, we can now change up the lighting to reflect the time of day. This can be controlled by altering the angle of the directional light. This serves as our game’s ‘sun’.
Add a reference to the directional light in TimeManager
:
TimeManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class TimeManager : MonoBehaviour { public static TimeManager Instance { get; private set; } [SerializeField] GameTimestamp timestamp; public float timeScale = 1.0f; //The transform of the directional light (sun) public Transform sunTransform; 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() { //Initialise the time stamp timestamp = new GameTimestamp(0, GameTimestamp.Season.Spring, 1, 6, 0); StartCoroutine(TimeUpdate()); } IEnumerator TimeUpdate() { while (true) { Tick(); yield return new WaitForSeconds(1 / timeScale); } } //A tick of the in-game time public void Tick() { timestamp.UpdateClock(); } }
Assign the Directional Light in the scene to the newly-declared variable.
a. Calculating the angle
We know the sun moves 360° in a day, hence:
- 360° ÷ 24 hours = 15° per hour
- 15° ÷ 60 minutes = 0.25° per minute
The Gradient: Every time the Tick()
function is called, the directional light should move by 0.25°.
The y-intercept: Based on the diagram above, we know the sun will have an angle of -90° at midnight (0:00).
Therefore, the formula to calculate the angle of our directional light will be:
Angle of the Sun = 0.25° × Time in Minutes – 90°
b. Implementing the movement
Before we find the angle of the sun, we must first find the time in minutes. This is where the conversion functions defined in (1c) come in. Simply convert the hours in the timestamp to minutes with GameTimestamp.HoursToMinutes
and add the timestamp’s minutes to get the day’s time in minutes.
In TimeManager
, create a function to handle the day-night cycle and call it in Tick()
:
TimeManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class TimeManager : MonoBehaviour { public static TimeManager Instance { get; private set; } [SerializeField] GameTimestamp timestamp; public float timeScale = 1.0f; //The transform of the directional light (sun) public Transform sunTransform; 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() { //Initialise the time stamp timestamp = new GameTimestamp(0, GameTimestamp.Season.Spring, 1, 6, 0); StartCoroutine(TimeUpdate()); } IEnumerator TimeUpdate() { while (true) { Tick(); yield return new WaitForSeconds(1 / timeScale); } } //A tick of the in-game time public void Tick() { timestamp.UpdateClock(); UpdateSunMovement(); } //Day and night cycle void UpdateSunMovement() { //Convert the current time to minutes int timeInMinutes = GameTimestamp.HoursToMinutes(timestamp.hour) + timestamp.minute; //Sun moves 15 degrees in an hour //.25 degrees in a minute //At midnight (0:00), the angle of the sun should be -90 float sunAngle = .25f * timeInMinutes - 90; //Apply the angle to the directional light sunTransform.eulerAngles = new Vector3(sunAngle, 0, 0); } }
c. Debugging
Playtesting the game should look something like this:
Since the sun’s angle moves in small increments, you might have to wait a long time before you can observe the full Day/Night cycle. You could always check to see if the sun is positioned correctly by manually changing up the timestamp values in the inspector, but it’s harder to see the movement that way.
Let’s make it so that time is sped up when the player holds a certain key (let’s make it the ] for now). Add the following to PlayerController
:
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; //Interaction components PlayerInteraction playerInteraction; // Start is called before the first frame update void Start() { //Get movement components controller = GetComponent<CharacterController>(); animator = GetComponent<Animator>(); //Get interaction component playerInteraction = GetComponentInChildren<PlayerInteraction>(); } // Update is called once per frame void Update() { //Runs the function that handles all movement Move(); //Runs the function that handles all interaction Interact(); //Debugging purposes only //Skip the time when the right square bracket is pressed if (Input.GetKey(KeyCode.RightBracket)) { TimeManager.Instance.Tick(); } } public void Interact() { //Tool interaction if (Input.GetButtonDown("Fire1")) { //Interact playerInteraction.Interact(); } //TODO: Set up item interaction } 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; //Is the sprint key pressed down? if (Input.GetButton("Sprint")) { //Set the animation to run and increase our movespeed moveSpeed = runSpeed; animator.SetBool("Running", true); } else { //Set the animation to walk and decrease our movespeed moveSpeed = walkSpeed; animator.SetBool("Running", false); } //Check if there is movement if (dir.magnitude >= 0.1f) { //Look towards that direction transform.rotation = Quaternion.LookRotation(dir); //Move controller.Move(velocity); } //Animation speed parameter animator.SetFloat("Speed", velocity.magnitude); } }
At this point when you play test, you should be able to speed up time for various tests by holding ].
Note: Remember to disable that block of code in future when releasing the game build, so that your players won’t be able to speed time up.
3. Displaying the time on the HUD
We are almost done with the Time Management system in itself. Now to get UIManager
to display the information in the HUD. In making our inventory system, we did the following:
- Create a function in
UIManager
to render the inventory - Call this function from
InventoryManager
whenever an update to the system happens
This is all well and good as UIManager
is a singleton, and serves as the Manager class to inform all affected objects of inventory state changes. However, this will not work in the context of our time system.
This is because many objects, such as the Land and Crops the players will plant in our game are going to be reliant on time changes. The number of objects reliant on time will always be changing throughout the course of the game. If we were to do the same thing as we did with the inventory, we would have to do a FindObjectsOfType()
every time we want to get a list of the dependent objects. This will slow down the game a lot as the number of objects increase.
a. Using the Observer Design Pattern
A better approach to this would be using the Observer Design Pattern, a subscription-based system in which:
- A blueprint (interface) for observers is defined for objects interested in observing the subject
- The objects that will take action on state changes (in this case changes to the time) ‘registers’ as an observer to the subject (
TimeManager
). In other words, it ‘subscribes’ to the subject to start receiving notifications from it. - The observable subject (
TimeManager
), automatically notifies all observers through the interface when a change occurs.
This makes the system more flexible as it minimizes interdependency between the subject and the observers. The only thing the subject needs to care about is for its observer to implement the interface. This is what we want to do:
First, let’s set up the blueprint for the Observers. Create a new script called ITimeTracker
:
ITimeTracker.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; publicclassinterface ITimeTracker: MonoBehaviour{ void ClockUpdate(GameTimestamp timestamp);// Start is called before the first frame update void Start() { } // Update is called once per frame public void Update() { }}
In TimeManager
, declare a list of observers (listeners) and set up functions to register and unregister them:
TimeManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class TimeManager : MonoBehaviour { public static TimeManager Instance { get; private set; } [Header("Internal Clock")] [SerializeField] GameTimestamp timestamp; public float timeScale = 1.0f; [Header ("Day and Night cycle")] //The transform of the directional light (sun) public Transform sunTransform; //List of Objects to inform of changes to the time List<ITimeTracker> listeners = new List<ITimeTracker>(); 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() { //Initialise the time stamp timestamp = new GameTimestamp(0, GameTimestamp.Season.Spring, 1, 6, 0); StartCoroutine(TimeUpdate()); } IEnumerator TimeUpdate() { while (true) { Tick(); yield return new WaitForSeconds(1 / timeScale); } } //A tick of the in-game time public void Tick() { timestamp.UpdateClock(); //Inform each of the listeners of the new time state foreach(ITimeTracker listener in listeners) { listener.ClockUpdate(timestamp); } UpdateSunMovement(); } //Day and night cycle void UpdateSunMovement() { //Convert the current time to minutes int timeInMinutes = GameTimestamp.HoursToMinutes(timestamp.hour) + timestamp.minute; //Sun moves 15 degrees in an hour //.25 degrees in a minute //At midnight (0:00), the angle of the sun should be -90 float sunAngle = .25f * timeInMinutes - 90; //Apply the angle to the directional light sunTransform.eulerAngles = new Vector3(sunAngle, 0, 0); } //Handling Listeners //Add the object to the list of listeners public void RegisterTracker(ITimeTracker listener) { listeners.Add(listener); } //Remove the object from the list of listeners public void UnregisterTracker(ITimeTracker listener) { listeners.Remove(listener); } }
Implement the ITimeTracker
interface on the UIManager
, and register it as a listener / observer:
UIManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class UIManager : MonoBehaviour, ITimeTracker { public static UIManager Instance { get; private set; } [Header("Status Bar")] //Tool equip slot on the status bar public Image toolEquipSlot; [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; //Item info box public Text itemNameText; public Text itemDescriptionText; 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() { RenderInventory(); AssignSlotIndexes(); //Add UIManager to the list of objects TimeManager will notify when the time updates TimeManager.Instance.RegisterTracker(this); } //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 inventory tool slots from Inventory Manager ItemData[] inventoryToolSlots = InventoryManager.Instance.tools; //Get the inventory item slots from Inventory Manager ItemData[] inventoryItemSlots = InventoryManager.Instance.items; //Render the Tool section RenderInventoryPanel(inventoryToolSlots, toolSlots); //Render the Item section RenderInventoryPanel(inventoryItemSlots, itemSlots); //Render the equipped slots toolHandSlot.Display(InventoryManager.Instance.equippedTool); itemHandSlot.Display(InventoryManager.Instance.equippedItem); //Get Tool Equip from InventoryManager ItemData equippedTool = InventoryManager.Instance.equippedTool; //Check if there is an item to display if (equippedTool != null) { //Switch the thumbnail over toolEquipSlot.sprite = equippedTool.thumbnail; toolEquipSlot.gameObject.SetActive(true); return; } toolEquipSlot.gameObject.SetActive(false); } //Iterate through a slot in a section and display them in the UI void RenderInventoryPanel(ItemData[] 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 = ""; return; } itemNameText.text = data.name; itemDescriptionText.text = data.description; } //Callback to handle the UI for time public void ClockUpdate(GameTimestamp timestamp) { } }
With the ClockUpdate()
function implemented, we can finally start rendering the time in the HUD.
b. Displaying the Time
In UIManager
, reference the Time UI components from the status bar:
UIManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class UIManager : MonoBehaviour, ITimeTracker { public static UIManager Instance { get; private set; } [Header("Status Bar")] //Tool equip slot on the status bar public Image toolEquipSlot; //Time UI public Text timeText; public Text 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; //Item info box public Text itemNameText; public Text itemDescriptionText; 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() { RenderInventory(); AssignSlotIndexes(); //Add UIManager to the list of objects TimeManager will notify when the time updates TimeManager.Instance.RegisterTracker(this); } //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 inventory tool slots from Inventory Manager ItemData[] inventoryToolSlots = InventoryManager.Instance.tools; //Get the inventory item slots from Inventory Manager ItemData[] inventoryItemSlots = InventoryManager.Instance.items; //Render the Tool section RenderInventoryPanel(inventoryToolSlots, toolSlots); //Render the Item section RenderInventoryPanel(inventoryItemSlots, itemSlots); //Render the equipped slots toolHandSlot.Display(InventoryManager.Instance.equippedTool); itemHandSlot.Display(InventoryManager.Instance.equippedItem); //Get Tool Equip from InventoryManager ItemData equippedTool = InventoryManager.Instance.equippedTool; //Check if there is an item to display if (equippedTool != null) { //Switch the thumbnail over toolEquipSlot.sprite = equippedTool.thumbnail; toolEquipSlot.gameObject.SetActive(true); return; } toolEquipSlot.gameObject.SetActive(false); } //Iterate through a slot in a section and display them in the UI void RenderInventoryPanel(ItemData[] 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 = ""; return; } itemNameText.text = data.name; itemDescriptionText.text = data.description; } //Callback to handle the UI for time public void ClockUpdate(GameTimestamp timestamp) { } }
Assign the Text components from the Status Bar accordingly:
We want the time to be displayed as a 12-hour clock in the status bar. However, the GameTimestamp
saves the hours in terms of 24 hours. Hence, we have to convert it over in UIManager
:
UIManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class UIManager : MonoBehaviour, ITimeTracker { public static UIManager Instance { get; private set; } [Header("Status Bar")] //Tool equip slot on the status bar public Image toolEquipSlot; //Time UI public Text timeText; public Text 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; //Item info box public Text itemNameText; public Text itemDescriptionText; 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() { RenderInventory(); AssignSlotIndexes(); //Add UIManager to the list of objects TimeManager will notify when the time updates TimeManager.Instance.RegisterTracker(this); } //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 inventory tool slots from Inventory Manager ItemData[] inventoryToolSlots = InventoryManager.Instance.tools; //Get the inventory item slots from Inventory Manager ItemData[] inventoryItemSlots = InventoryManager.Instance.items; //Render the Tool section RenderInventoryPanel(inventoryToolSlots, toolSlots); //Render the Item section RenderInventoryPanel(inventoryItemSlots, itemSlots); //Render the equipped slots toolHandSlot.Display(InventoryManager.Instance.equippedTool); itemHandSlot.Display(InventoryManager.Instance.equippedItem); //Get Tool Equip from InventoryManager ItemData equippedTool = InventoryManager.Instance.equippedTool; //Check if there is an item to display if (equippedTool != null) { //Switch the thumbnail over toolEquipSlot.sprite = equippedTool.thumbnail; toolEquipSlot.gameObject.SetActive(true); return; } toolEquipSlot.gameObject.SetActive(false); } //Iterate through a slot in a section and display them in the UI void RenderInventoryPanel(ItemData[] 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 = ""; return; } itemNameText.text = data.name; itemDescriptionText.text = data.description; } //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 "; hours = hours - 12; Debug.Log(hours); } timeText.text = prefix + hours + minutes; } }
The status bar should crudely display the time in 12-hour format with the minutes value right beside:
We want it to show it in the format of H:MM, where the minutes are displayed as double digits. We can easily do this with the ToString()
function:
UIManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class UIManager : MonoBehaviour, ITimeTracker { public static UIManager Instance { get; private set; } [Header("Status Bar")] //Tool equip slot on the status bar public Image toolEquipSlot; //Time UI public Text timeText; public Text 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; //Item info box public Text itemNameText; public Text itemDescriptionText; 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() { RenderInventory(); AssignSlotIndexes(); //Add UIManager to the list of objects TimeManager will notify when the time updates TimeManager.Instance.RegisterTracker(this); } //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 inventory tool slots from Inventory Manager ItemData[] inventoryToolSlots = InventoryManager.Instance.tools; //Get the inventory item slots from Inventory Manager ItemData[] inventoryItemSlots = InventoryManager.Instance.items; //Render the Tool section RenderInventoryPanel(inventoryToolSlots, toolSlots); //Render the Item section RenderInventoryPanel(inventoryItemSlots, itemSlots); //Render the equipped slots toolHandSlot.Display(InventoryManager.Instance.equippedTool); itemHandSlot.Display(InventoryManager.Instance.equippedItem); //Get Tool Equip from InventoryManager ItemData equippedTool = InventoryManager.Instance.equippedTool; //Check if there is an item to display if (equippedTool != null) { //Switch the thumbnail over toolEquipSlot.sprite = equippedTool.thumbnail; toolEquipSlot.gameObject.SetActive(true); return; } toolEquipSlot.gameObject.SetActive(false); } //Iterate through a slot in a section and display them in the UI void RenderInventoryPanel(ItemData[] 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 = ""; return; } itemNameText.text = data.name; itemDescriptionText.text = data.description; } //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 "; hours = hours - 12; Debug.Log(hours); } //Format it for the time text display timeText.text = prefix + hours + ":" + minutes.ToString("00"); } }
When the game is played now, everything should work fine until you fast forward the time to a certain point:
This is because the text is too long to fit within its space. Make the size adjustments accordingly in the scene:
c. Displaying the Date
The date is to be displayed in the following format: Season Day (Day of the Week).
Get the values of each and display them according to the format:
UIManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class UIManager : MonoBehaviour, ITimeTracker { public static UIManager Instance { get; private set; } [Header("Status Bar")] //Tool equip slot on the status bar public Image toolEquipSlot; //Time UI public Text timeText; public Text 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; //Item info box public Text itemNameText; public Text itemDescriptionText; 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() { RenderInventory(); AssignSlotIndexes(); //Add UIManager to the list of objects TimeManager will notify when the time updates TimeManager.Instance.RegisterTracker(this); } //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 inventory tool slots from Inventory Manager ItemData[] inventoryToolSlots = InventoryManager.Instance.tools; //Get the inventory item slots from Inventory Manager ItemData[] inventoryItemSlots = InventoryManager.Instance.items; //Render the Tool section RenderInventoryPanel(inventoryToolSlots, toolSlots); //Render the Item section RenderInventoryPanel(inventoryItemSlots, itemSlots); //Render the equipped slots toolHandSlot.Display(InventoryManager.Instance.equippedTool); itemHandSlot.Display(InventoryManager.Instance.equippedItem); //Get Tool Equip from InventoryManager ItemData equippedTool = InventoryManager.Instance.equippedTool; //Check if there is an item to display if (equippedTool != null) { //Switch the thumbnail over toolEquipSlot.sprite = equippedTool.thumbnail; toolEquipSlot.gameObject.SetActive(true); return; } toolEquipSlot.gameObject.SetActive(false); } //Iterate through a slot in a section and display them in the UI void RenderInventoryPanel(ItemData[] 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 = ""; return; } itemNameText.text = data.name; itemDescriptionText.text = data.description; } //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 "; hours = hours - 12; Debug.Log(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 +")"; } }
When you speed up time, it should look like this:
4. Implementing a soil-drying system
As of now, when Farmland is Watered, it remains that way indefinitely. We want it to dry up every 24 hours so the player has to water it again. The logic to accomplish this is as follows:
- When the Land changes its state to Watered it saves a timestamp of when it was last watered
- At every
Tick()
fromTimeManager
, the current time is compared with the saved timestamp to find out how much time has passed since then. - If more than 24 hours have passed, the Land should change its state back to Farmland.
a. Saving timestamps
If you noticed, we kept TimeManager's
timestamp private in 1e. This is because retrieving the timestamp directly from TimeManager
would just pass it as a reference instead of a value. This means that if Land
saved the timestamp as a variable at 6am, and when it checks the value at 12pm, the variable would return 12pm.
User-defined objects such as GameTimestamp
are passed by reference. We wrote an article explaining the difference between value and reference types in-depth.
To get around this, we should create a get function to return a copy of the game’s clock rather than a reference to it. To this end, we will overload GameTimestamp's
constructor that makes a copy of an existing instance:
GameTimestamp.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; [System.Serializable] public class GameTimestamp { public int year; public enum Season { Spring, Summer, Fall, Winter } public Season season; public enum DayOfTheWeek { Saturday, Sunday, Monday, Tuesday, Wednesday, Thursday, Friday } public int day; public int hour; public int minute; //Constructor to set up the class public GameTimestamp(int year, Season season, int day, int hour, int minute) { this.year = year; this.season = season; this.day = day; this.hour = hour; this.minute = minute; } //Creating a new instance of a GameTimestamp from another pre-existing one public GameTimestamp(GameTimestamp timestamp) { this.year = timestamp.year; this.season = timestamp.season; this.day = timestamp.day; this.hour = timestamp.hour; this.minute = timestamp.minute; } //Makes an increment of the time by 1 minute public void UpdateClock() { minute++; //60 minutes in 1 hour if(minute >= 60) { //reset minutes minute = 0; hour++; } //24 hours in 1 day if(hour >= 24) { //Reset hours hour = 0; day++; } //30 days in a season if(day > 30) { //Reset days day = 1; //If at the final season, reset and change to spring if(season == Season.Winter) { season = Season.Spring; //Start of a new year year++; } else { season++; } } } public DayOfTheWeek GetDayOfTheWeek() { //Convert the total time passed into days int daysPassed = YearsToDays(year) + SeasonsToDays(season) + day; //Remainder after dividing daysPassed by 7 int dayIndex = daysPassed % 7; //Cast into Day of the Week return (DayOfTheWeek)dayIndex; } //Convert hours to minutes public static int HoursToMinutes(int hour) { //60 minutes = 1 hour return hour * 60; } //Convert Days to Hours public static int DaysToHours(int days) { //24 Hours in a day return days * 24; } //Convert Seasons to days public static int SeasonsToDays(Season season) { int seasonIndex = (int)season; return seasonIndex * 30; } //Years to Days public static int YearsToDays(int years) { return years * 4 * 30; } }
With this, create a function in TimeManager
that returns the copied instance of the timestamp:
TimeManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class TimeManager : MonoBehaviour { public static TimeManager Instance { get; private set; } [Header("Internal Clock")] [SerializeField] GameTimestamp timestamp; public float timeScale = 1.0f; [Header ("Day and Night cycle")] //The transform of the directional light (sun) public Transform sunTransform; //List of Objects to inform of changes to the time List<ITimeTracker> listeners = new List<ITimeTracker>(); 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() { //Initialise the time stamp timestamp = new GameTimestamp(0, GameTimestamp.Season.Spring, 1, 6, 0); StartCoroutine(TimeUpdate()); } IEnumerator TimeUpdate() { while (true) { Tick(); yield return new WaitForSeconds(1 / timeScale); } } //A tick of the in-game time public void Tick() { timestamp.UpdateClock(); //Inform each of the listeners of the new time state foreach(ITimeTracker listener in listeners) { listener.ClockUpdate(timestamp); } UpdateSunMovement(); } //Day and night cycle void UpdateSunMovement() { //Convert the current time to minutes int timeInMinutes = GameTimestamp.HoursToMinutes(timestamp.hour) + timestamp.minute; //Sun moves 15 degrees in an hour //.25 degrees in a minute //At midnight (0:00), the angle of the sun should be -90 float sunAngle = .25f * timeInMinutes - 90; //Apply the angle to the directional light sunTransform.eulerAngles = new Vector3(sunAngle, 0, 0); } //Get the timestamp public GameTimestamp GetGameTimestamp() { //Return a cloned instance return new GameTimestamp(timestamp); } //Handling Listeners //Add the object to the list of listeners public void RegisterTracker(ITimeTracker listener) { listeners.Add(listener); } //Remove the object from the list of listeners public void UnregisterTracker(ITimeTracker listener) { listeners.Remove(listener); } }
In Land
, declare a new variable to keep track of when the Land was last Watered and cache the timestamp at its state change:
Land.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class Land : MonoBehaviour { 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; // 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); } 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; } 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.equippedTool; //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: SwitchLandStatus(LandStatus.Watered); break; } } } }
b. Checking how much time has passed
The Land
script should check how much time has elapsed at every update of the clock. Hence, it should register as an observer of TimeManager
:
Land.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class Land : MonoBehaviour, ITimeTracker { 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; // 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 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; } 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.equippedTool; //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: SwitchLandStatus(LandStatus.Watered); break; } } } public void ClockUpdate(GameTimestamp timestamp) { } }
Next, we need a way to calculate the difference between timestamps. This can be done by converting each of the timestamps into hours and subtracting them from one another.
We can do the conversions using GameTimestamps
functions like this:
- Years: Convert to days with
YearsToDays
and convert the result withDaysToHours
- Season: Convert to days with
SeasonsToDays
and convert the result withDaysToHours
- Day: Convert to hours with
DaysToHours
Add all of them together with the hours and you get the total hours elapsed in the timestamp. Once this is done for both timestamps, subtract them from one another and ensure it returns a positive value with Mathf.Abs()
. Let’s make a static function to handle this calculation in GameTimestamp
:
GameTimestamp.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; [System.Serializable] public class GameTimestamp { public int year; public enum Season { Spring, Summer, Fall, Winter } public Season season; public enum DayOfTheWeek { Saturday, Sunday, Monday, Tuesday, Wednesday, Thursday, Friday } public int day; public int hour; public int minute; //Constructor to set up the class public GameTimestamp(int year, Season season, int day, int hour, int minute) { this.year = year; this.season = season; this.day = day; this.hour = hour; this.minute = minute; } //Creating a new instance of a GameTimestamp from another pre-existing one public GameTimestamp(GameTimestamp timestamp) { this.year = timestamp.year; this.season = timestamp.season; this.day = timestamp.day; this.hour = timestamp.hour; this.minute = timestamp.minute; } //Makes an increment of the time by 1 minute public void UpdateClock() { minute++; //60 minutes in 1 hour if(minute >= 60) { //reset minutes minute = 0; hour++; } //24 hours in 1 day if(hour >= 24) { //Reset hours hour = 0; day++; } //30 days in a season if(day > 30) { //Reset days day = 1; //If at the final season, reset and change to spring if(season == Season.Winter) { season = Season.Spring; //Start of a new year year++; } else { season++; } } } public DayOfTheWeek GetDayOfTheWeek() { //Convert the total time passed into days int daysPassed = YearsToDays(year) + SeasonsToDays(season) + day; //Remainder after dividing daysPassed by 7 int dayIndex = daysPassed % 7; //Cast into Day of the Week return (DayOfTheWeek)dayIndex; } //Convert hours to minutes public static int HoursToMinutes(int hour) { //60 minutes = 1 hour return hour * 60; } //Convert Days to Hours public static int DaysToHours(int days) { //24 Hours in a day return days * 24; } //Convert Seasons to days public static int SeasonsToDays(Season season) { int seasonIndex = (int)season; return seasonIndex * 30; } //Years to Days public static int YearsToDays(int years) { return years * 4 * 30; } // Calculate the difference between 2 timestamps in hours public static int CompareTimestamps(GameTimestamp timestamp1, GameTimestamp timestamp2) { //Convert timestamps to hours int timestamp1Hours = DaysToHours(YearsToDays(timestamp1.year)) + DaysToHours(SeasonsToDays(timestamp1.season)) + DaysToHours(timestamp1.day) + timestamp1.hour; int timestamp2Hours = DaysToHours(YearsToDays(timestamp2.year)) + DaysToHours(SeasonsToDays(timestamp2.season)) + DaysToHours(timestamp2.day) + timestamp2.hour; int difference = timestamp2Hours - timestamp1Hours; return Mathf.Abs(difference); } }
With this function, we can now check for how many hours have passed under ClockUpdate
. Add the following to Land
:
Land.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class Land : MonoBehaviour, ITimeTracker { 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; // 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 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; } 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.equippedTool; //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: SwitchLandStatus(LandStatus.Watered); break; } } } public void ClockUpdate(GameTimestamp timestamp) { //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"); if(hoursElapsed > 24) { //Dry up (Switch back to farmland) SwitchLandStatus(LandStatus.Farmland); } } } }
Conclusion
As usual, to wrap up, below are the final versions of the script:
GameTimestamp.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; [System.Serializable] public class GameTimestamp { public int year; public enum Season { Spring, Summer, Fall, Winter } public Season season; public enum DayOfTheWeek { Saturday, Sunday, Monday, Tuesday, Wednesday, Thursday, Friday } public int day; public int hour; public int minute; //Constructor to set up the class public GameTimestamp(int year, Season season, int day, int hour, int minute) { this.year = year; this.season = season; this.day = day; this.hour = hour; this.minute = minute; } //Creating a new instance of a GameTimestamp from another pre-existing one public GameTimestamp(GameTimestamp timestamp) { this.year = timestamp.year; this.season = timestamp.season; this.day = timestamp.day; this.hour = timestamp.hour; this.minute = timestamp.minute; } //Makes an increment of the time by 1 minute public void UpdateClock() { minute++; //60 minutes in 1 hour if(minute >= 60) { //reset minutes minute = 0; hour++; } //24 hours in 1 day if(hour >= 24) { //Reset hours hour = 0; day++; } //30 days in a season if(day > 30) { //Reset days day = 1; //If at the final season, reset and change to spring if(season == Season.Winter) { season = Season.Spring; //Start of a new year year++; } else { season++; } } } public DayOfTheWeek GetDayOfTheWeek() { //Convert the total time passed into days int daysPassed = YearsToDays(year) + SeasonsToDays(season) + day; //Remainder after dividing daysPassed by 7 int dayIndex = daysPassed % 7; //Cast into Day of the Week return (DayOfTheWeek)dayIndex; } //Convert hours to minutes public static int HoursToMinutes(int hour) { //60 minutes = 1 hour return hour * 60; } //Convert Days to Hours public static int DaysToHours(int days) { //24 Hours in a day return days * 24; } //Convert Seasons to days public static int SeasonsToDays(Season season) { int seasonIndex = (int)season; return seasonIndex * 30; } //Years to Days public static int YearsToDays(int years) { return years * 4 * 30; } // Calculate the difference between 2 timestamps in hours public static int CompareTimestamps(GameTimestamp timestamp1, GameTimestamp timestamp2) { //Convert timestamps to hours int timestamp1Hours = DaysToHours(YearsToDays(timestamp1.year)) + DaysToHours(SeasonsToDays(timestamp1.season)) + DaysToHours(timestamp1.day) + timestamp1.hour; int timestamp2Hours = DaysToHours(YearsToDays(timestamp2.year)) + DaysToHours(SeasonsToDays(timestamp2.season)) + DaysToHours(timestamp2.day) + timestamp2.hour; int difference = timestamp2Hours - timestamp1Hours; return Mathf.Abs(difference); } }
TimeManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class TimeManager : MonoBehaviour { public static TimeManager Instance { get; private set; } [Header("Internal Clock")] [SerializeField] GameTimestamp timestamp; public float timeScale = 1.0f; [Header ("Day and Night cycle")] //The transform of the directional light (sun) public Transform sunTransform; //List of Objects to inform of changes to the time List<ITimeTracker> listeners = new List<ITimeTracker>(); 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() { //Initialise the time stamp timestamp = new GameTimestamp(0, GameTimestamp.Season.Spring, 1, 6, 0); StartCoroutine(TimeUpdate()); } IEnumerator TimeUpdate() { while (true) { Tick(); yield return new WaitForSeconds(1 / timeScale); } } //A tick of the in-game time public void Tick() { timestamp.UpdateClock(); //Inform each of the listeners of the new time state foreach(ITimeTracker listener in listeners) { listener.ClockUpdate(timestamp); } UpdateSunMovement(); } //Day and night cycle void UpdateSunMovement() { //Convert the current time to minutes int timeInMinutes = GameTimestamp.HoursToMinutes(timestamp.hour) + timestamp.minute; //Sun moves 15 degrees in an hour //.25 degrees in a minute //At midnight (0:00), the angle of the sun should be -90 float sunAngle = .25f * timeInMinutes - 90; //Apply the angle to the directional light sunTransform.eulerAngles = new Vector3(sunAngle, 0, 0); } //Get the timestamp public GameTimestamp GetGameTimestamp() { //Return a cloned instance return new GameTimestamp(timestamp); } //Handling Listeners //Add the object to the list of listeners public void RegisterTracker(ITimeTracker listener) { listeners.Add(listener); } //Remove the object from the list of listeners public void UnregisterTracker(ITimeTracker listener) { listeners.Remove(listener); } }
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; //Interaction components PlayerInteraction playerInteraction; // Start is called before the first frame update void Start() { //Get movement components controller = GetComponent<CharacterController>(); animator = GetComponent<Animator>(); //Get interaction component playerInteraction = GetComponentInChildren<PlayerInteraction>(); } // Update is called once per frame void Update() { //Runs the function that handles all movement Move(); //Runs the function that handles all interaction Interact(); //Debugging purposes only //Skip the time when the right square bracket is pressed if (Input.GetKey(KeyCode.RightBracket)) { TimeManager.Instance.Tick(); } } public void Interact() { //Tool interaction if (Input.GetButtonDown("Fire1")) { //Interact playerInteraction.Interact(); } //TODO: Set up item interaction } 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; //Is the sprint key pressed down? if (Input.GetButton("Sprint")) { //Set the animation to run and increase our movespeed moveSpeed = runSpeed; animator.SetBool("Running", true); } else { //Set the animation to walk and decrease our movespeed moveSpeed = walkSpeed; animator.SetBool("Running", false); } //Check if there is movement if (dir.magnitude >= 0.1f) { //Look towards that direction transform.rotation = Quaternion.LookRotation(dir); //Move controller.Move(velocity); } //Animation speed parameter animator.SetFloat("Speed", velocity.magnitude); } }
ITimeTracker.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public interface ITimeTracker { void ClockUpdate(GameTimestamp timestamp); }
UIManager.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class UIManager : MonoBehaviour, ITimeTracker { public static UIManager Instance { get; private set; } [Header("Status Bar")] //Tool equip slot on the status bar public Image toolEquipSlot; //Time UI public Text timeText; public Text 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; //Item info box public Text itemNameText; public Text itemDescriptionText; 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() { RenderInventory(); AssignSlotIndexes(); //Add UIManager to the list of objects TimeManager will notify when the time updates TimeManager.Instance.RegisterTracker(this); } //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 inventory tool slots from Inventory Manager ItemData[] inventoryToolSlots = InventoryManager.Instance.tools; //Get the inventory item slots from Inventory Manager ItemData[] inventoryItemSlots = InventoryManager.Instance.items; //Render the Tool section RenderInventoryPanel(inventoryToolSlots, toolSlots); //Render the Item section RenderInventoryPanel(inventoryItemSlots, itemSlots); //Render the equipped slots toolHandSlot.Display(InventoryManager.Instance.equippedTool); itemHandSlot.Display(InventoryManager.Instance.equippedItem); //Get Tool Equip from InventoryManager ItemData equippedTool = InventoryManager.Instance.equippedTool; //Check if there is an item to display if (equippedTool != null) { //Switch the thumbnail over toolEquipSlot.sprite = equippedTool.thumbnail; toolEquipSlot.gameObject.SetActive(true); return; } toolEquipSlot.gameObject.SetActive(false); } //Iterate through a slot in a section and display them in the UI void RenderInventoryPanel(ItemData[] 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 = ""; return; } itemNameText.text = data.name; itemDescriptionText.text = data.description; } //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 "; hours = hours - 12; Debug.Log(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 +")"; } }
Land.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class Land : MonoBehaviour, ITimeTracker { 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; // 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 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; } 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.equippedTool; //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: SwitchLandStatus(LandStatus.Watered); break; } } } public void ClockUpdate(GameTimestamp timestamp) { //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"); if(hoursElapsed > 24) { //Dry up (Switch back to farmland) SwitchLandStatus(LandStatus.Farmland); } } } }
Hi,
I am an avid watcher of this series, It is awesome! I have added the “//Add UIManager to the list of objects TimeManager will notify when the time updates
TimeManager.Instance.RegisterTracker(this);” code to the Start function in UIManager. However the MonoBehaviour error still exists, saying that you cannot use the “new” keyword. Is there a fix for this? Thanks, Rob
Hi Rob, I’m glad you enjoy this series! Can you send over the entire error message in the MonoBehaviour error?
Hi Robert, I’m not sure if you got notified of my last reply. I had some time to take a closer look at what your error might have been, and I suspect that it might be that you forgot to remove the
: MonoBehaviour
portion in yourGameTimestamp
class.This is because
GameTimestamp
is initialised with thenew
keyword in the code, but MonoBehaviour objects cannot be initialised like that.As a heads up, you forgot to include adding:
//Add UIManager to the list of objects TimeManager will notify when the time updates
TimeManager.Instance.RegisterTracker(this);
to the private void Start() of the UIManager in the transcript of this tutorial. I spent forever trying to figure out why my time wasn’t updating on the UI until I went to check the video version of the tutorial where it is mentioned (glad you have the multiple resources!).
Otherwise, great tutorial, I have absolutely been loving this series and its making a dream come true. Thank you!
Sorry, by “adding” I mean highlighting when its added to the scripts. Your scripts are correct, I just couldn’t find where in the tutorial it was included.
Hi Kailey, we have updated the article to highlight the part you mentioned. Thank you for pointing this out!