My game project is many things but set in stone. Currently, anything could be subject to change and being my first real-scale video game project, I think it is for the best. One thing I was sure I wanted however was a day/night cycle.
I like a game that immerses its player into a great atmosphere and day/night cycles are a great way to do that. During the day, the game behaves as you would expect any game to, but it is during the night phase that designers can express their art to convey specific atmospheres.
I could keep pumping tons of screenshots night phases from several games that left me with durable memories: my first night in a dirt hut in Minecraft, fending off your first alien attack at nighttime in Factorio or simply sailing through the Ancient Isles under the moon of Sea of Thieves are only a few more examples 😊.
Day/night cycles are essentially a glorified loop. Instinctively, it should include two phases, day and night, but you could easily add more for more precise control: dawn, twilight or any recurring event such as a blood moon or a daily town gathering for instance. For my game, I kept it simple (for now 🙂) with simply day and night. Being a background loop, the go-to system in Unity is coroutines, however, you could probably also make it work with asynchronous methods.
1private void InitializeCycle()
2{
3 phase = DayNightCyclePhases.Day;
4 Start(DayNightCyclePhases.Day);
5}
6
7private void Start(DayNightCyclePhases phase)
8{
9 StartCoroutine(StartCycleCoroutine(phase));
10}
11
12private IEnumerator StartCycleCoroutine(DayNightCyclePhases phase)
13{
14 OnCyclePhaseStart.Invoke(phase);
15
16 float timer = 0;
17 float phaseDuration = phasesDurations.At(phase);
18 while (timer < phaseDuration)
19 {
20 timer += Time.deltaTime;
21 yield return null;
22 }
23
24 OnCyclePhaseEnd.Invoke(phase);
25 DayNightCyclePhases nextPhase = phase.Next();
26 Start(nextPhase);
27}
💡For beginners, a coroutine is a type of method described by the
IEnumerator
return type. It is called using the methodStartCoroutine()
. Theyield return
instructs to re-enter the loop with the current variables which allows to process loops over multiple frames.
This system revolves around a generic coroutine which loops for the duration of the current phase and eventually calls itself again for the next phase. My phases are described in a simple Enum.
1public enum DayNightCyclePhases
2{
3 Day,
4 Night
5}
You may have noticed the line 25 DayNightCyclePhases nextPhase = phase.Next();
and thought something like: “Calling a method on an enumeration instance ? WTF??” or maybe “Enums are not meant to represent an order in elements! Yuck!” or maybe nothing at all 😃. Truth is, I am used to working with Java at work. And, in Java, Enums are such powerful tools, allowing you to store anything in them to the point of using them as highly maintainable maps. I wish this was available in C# be it just to store the duration of each phase in the Enum.
In Java, this is how my implementation would look like. This would even have allowed us to store class instances for each phase to call methods shared by all phases later on.
1public enum DayNightCyclePhases {
2
3DAY(10),
4NIGHT(10);
5
6private final int phaseDuration;
7
8DayNightCyclePhases(final int phaseDuration) {
9 this.phaseDuration = phaseDuration;
10}
11
12public int getPhaseDuration() {...}
13}
However, we are stuck with C#. Do not get me wrong: I love C# and I wish some of its tools were in Java as well. But for now, we will have to make this lame enums work. As such, I need a few extensions, namely:
Next()
method for my enums to get the next phaseThe Next()
method (source).
1public static T Next<T>(this T src) where T : struct
2{
3 if (!typeof(T).IsEnum) throw new ArgumentException(String.Format("Argument {0} is not an Enum", typeof(T).FullName));
4
5 T[] Arr = (T[])Enum.GetValues(src.GetType());
6 int j = Array.IndexOf<T>(Arr, src) + 1;
7 return (Arr.Length == j) ? Arr[0] : Arr[j];
8}
The serializable dictionary.
1public class SerializableDictionary<TKey, TValue>
2{
3 [System.Serializable]
4 public class DictionaryItem
5 {
6 public TKey key;
7 public TValue value;
8
9 public DictionaryItem(TKey key, TValue value)
10 {
11 this.key = key;
12 this.value = value;
13 }
14 }
15
16 public List<DictionaryItem> elements = new List<DictionaryItem>();
17}
18
19// At(), Remove(), Count() methods are implemented below
Our loop loops but it is essentially useless. We need it to do things. To keep it tidy, the best would be to avoid directly calling methods from the coroutine’s body. One solution would be to have several classes for each phase, a way to get these class instances from the phase enum element and then call the methods they have in common. This implementation, again, takes inspiration from Java’s way of doing things, let’s do something else! Why not some event?
To do so, simply declare a delegate and a few events for each phase during which you want to do things.
1public delegate void cyclePhaseHandler(DayNightCyclePhases phase);
2public static event cyclePhaseHandler OnCyclePhaseStart;
3public static event cyclePhaseHandler OnCyclePhaseEnd;
These are the events that are called before and after the loop I presented above. Throughout your components, you can now subscribe methods to these events during initialization and they will be executed at the right time in your cycle.
1private void Awake()
2{
3 OnCyclePhaseStart += OnPhaseStart;
4 OnCyclePhaseEnd += OnPhaseEnd;
5}
6private void OnPhaseStart(DayNightCyclePhases phase)
7{
8 Debug.Log("Phase " + phase.ToString() + " has started.”);
9}
10private void OnPhaseEnd(DayNightCyclePhases phase)
11{
12 Debug.Log("Phase " + phase.ToString() + " has ended.");
13}
In this part, I will show you how to use dynamic lights to light your world throughout the phases of your cycle. We will use a day/night cycle to keep it simple.
Disclaimer: In this part, you will need the Universal Render Pipeline (URP) which can be quite the hassle to set up (I underestimated that part and it took me a couple of painful hours…). Hopefully, I wrote another article to guide you through the process of installation! Everything will revolve around one component: the mighty Light2D component!
Used as “Global” this will set a base lighting on all your sprites. Note that you can make it ignore some sprites by playing around with the sorting layers. This is the base element if you want to set a mood using dynamic lighting. Essentially, what we will be doing here is modifying its colour and intensity throughout the day: during the day warm colours with an intensity close to 1 at midday and during the night cold colours with low intensity.
For each phase, I need a colour gradient. This will allow me to have a corresponding hue for every moment of the day. To ensure the correspondency of the gradient with each phase, I used my Serializable dictionary:
1[Header("Global lighting colors")]
2[SerializeField] private SerializableDictionary<DayNightCyclePhases, Gradient> phaseColorGradients;
Using this, you can simply add elements to your dictionary and set the Gradients manually. For the anecdote, I did not know what colours to use. For the night, I improvised with palettes I found online. For the day, however, I used the gradient of the hues of the Planckian locus according to temperature which I then mirrored horizontally. In physics, this gradient is used to describe the colour of incandescent bodies.
For the intensity, it works similarly. However, we will not use Gradients but rather AnimationCurves which simply allow us to create a curve and evaluate its value.
1[Header("Global lighting intensities")]
2[SerializeField] private SerializableDictionary<DayNightCyclePhases, AnimationCurve> phaseIntensityGradients;
💡 Try as much as possible to create a smooth transition between phases: the last colour of the day should be the same as the first colour of the night. The same applies to the intensity value.
In the end, all there remains to do is to update your Light2D component at every step of your loop.
Methods to call from within your loop.
1private void UpdateGlobalLightColor(DayNightCyclePhases currentPhase, float phaseProgress)
2{
3 Gradient phaseColorGradient = phaseColorGradients.At(currentPhase);
4 if (phaseColorGradient == null)
5 {
6 Debug.LogError("Error : the phase color gradient does not exist for phase " + currentPhase);
7 return;
8 }
9 globalLight.color = phaseColorGradient.Evaluate(phaseProgress);
10}
11
12private void UpdateGlobalLightIntensity(DayNightCyclePhases currentPhase, float phaseProgress)
13{
14 AnimationCurve phaseIntensityCurve = phaseIntensityGradients.At(currentPhase);
15 if (phaseIntensityCurve == null)
16 {
17 Debug.LogError("Error : the phase intensity curve does not exist for phase " + currentPhase);
18 return;
19 }
20 globalLight.intensity = phaseIntensityCurve.Evaluate(phaseProgress);
21}
Once again, thank you for reading to the end! I wanted to do two pieces on UI Toolkit but I would rather write about something I did more recently. I hope to see you in the next post. 🦦