Creating an Underwater Survival Game in Unity - Part 2

Creating an Underwater Survival Game (like Subnautica) Part 2 — Terrain and Day-Night Cycle

This article is a part of the series:
Creating an Underwater Survival Game (like Subnautica) in Unity

Ever wanted to create a game like Subnautica in Unity? Check out Part 2 of our guide here, where we go through how to set up a the terrain for the game and the day-night cycle.

A link to a package containing the project files of this tutorial series can also be found at the end of this article, exclusive to Patreon subscribers only.

Author’s Note: If you’ve watched and followed Part 1 of the video tutorial, you’ll realise there is a small error in the code. Make sure you change moveY() to moveZ() to make sure your code is working. You can watch the 1st minute of the video tutorial below for clarification on this.

Video authored, edited and subtitled by Sarah Kagda

1. Day-Night Cycle

First, you’re going to want to create a folder in Assets > Scripts, and name it “Time”. All scripts pertaining to the management of time for the game will be put into this folder. To create the illusion of day and night, we will have to rotate the directional light of the scene. If you take a look at the directional light, rotating it makes it look like the sun in the game is rising and setting. We will have to manipulate this in the script itself to create the day-night cycle.

Directional light emulating the sun
Directional lights can emulate sunrise and sunset in Unity.

a. Setting up the GameTimeStamp class

The time system that we want to create will be rather complex. Let’s start off by creating a class called GameTimeStamp. This class will store the number of minutes, hours and days that have passed in the game. Take note that this will not be a MonoBehaviour class, so you can remove that from your script – and because of this, we can also remove the default Start() and Update() functions. We’ll also need to make the script serializable so that we can see it clearly in the inspector.

Then, we’ll create the integer variables that will store the day, hours and minutes of the game.

b. Creating the class constructor for GameTimeStamp

We’ll need to create two constructors for GameTimeStamp. One to set up the class, and the other that helps create a new timestamp with reference to an existing one.

GameTimeStamp.cs

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

[System.Serializable]
public class GameTimeStamp : MonoBehaviour
{
    public int day;
    public int hour;
    public int minute;

    //class constructor - sets up the class
    public GameTimeStamp(int day, int hour, int minute)
    {
        this.day = day;
        this.hour = hour;
        this.minute = minute;
    }

    //create a new timestamp from an existing one
    public GameTimeStamp(GameTimeStamp timeStamp)
    {
        this.day = timeStamp.day;
        this.hour = timeStamp.hour;
        this.minute = timeStamp.minute;
    }
}

c. UpdateClock() function

The next step is to create a function that will increase the time. Every time it’s called, it should increment each minute by one, and ensure that it doesn’t exceed 60. This function will also convert minutes to hours, and hours to the number of days.

For this game, we’ll change the rate of time a little bit such that one second in real-time would translate into an hour in-game. In the actual game, a day in-game translates to 20 minutes in real-time (i.e 20h to a day in-game), so let’s go ahead and model after that.

d. HoursToMinutes() function

Following up, we’ll have to create a function that will help to convert the number of hours to minutes. Since an hour is equivalent to 60 minutes, we’ll just multiply the number of hours by 60 and return that for this function.

e. TimestampInMinutes() function

This function helps to return the total number of minutes based on the GameTimeStamp function. Since a day in-game is equivalent to 20 in-game hours, we’ll add up the total number of minutes in the current timestamp. We’ll utilize the hour-to-minute function that we’ve created and multiply the number of days by 20 since there are 20h in a day in-game. Similarly, we’ll call the current timestamp’s number of hours, and also use the HoursToMinutes() function we’ve created. Next, we’ll just call up the total number of minutes in the current timestamp as well, where no conversion is necessary. Finally, we’ll just add everything together now that they’re in minutes, and return that in this function.

Here’s what the code should look like after all the functions are added:

GameTimeStamp.cs

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

[System.Serializable]
public class GameTimeStamp
{
    public int day;
    public int hour;
    public int minute;

    //class constructor - sets up the class
    public GameTimeStamp(int day, int hour, int minute)
    {
        this.day = day;
        this.hour = hour;
        this.minute = minute;
    }

    //create a new timestamp from an existing one
    public GameTimeStamp(GameTimeStamp timeStamp)
    {
        this.day = timeStamp.day;
        this.hour = timeStamp.hour;
        this.minute = timeStamp.minute;
    }

    //this function increases the time incrementally
    public void UpdateClock()
    {
        minute++;

        //60 minutes in 1 hour
        if (minute >= 60)
        {
            //reset minutes
            minute = 0;
            hour++;
        }

        //20 hours in 1 day
        if (hour >= 20)
        {
            //Reset hours 
            hour = 0;

            day++;
        }
    }

    //Convert hours to minutes
    public static int HoursToMinutes(int hour)
    {
        //60 minutes = 1 hour
        return hour * 60;
    }

    //returns the current time stamp in minutes.
    public static int TimestampInMinutes(GameTimeStamp timestamp)
    {
        return (HoursToMinutes(timestamp.day * 20) + HoursToMinutes(timestamp.hour) + timestamp.minute);
    }
}

Our GameTimeStamp code is now complete! We can go back into unity now and move on to the next step, which is creating a new script for our game’s TimeManager.

f. Setting up the TimeManager class

We’ll need to create another class that will help us manage the game timestamps. So we’ll head into the Time folder we’ve created and create a new script called TimeManager. This script is a MonoBehavior script and also a singleton class.

Since there is only 1 copy of the TimeManager class at any given time, we will create a static Instance variable in the class, so that whenever we want to access our TimeManager instance, we can simply use TimeManager.Instance.

For a more detailed explanation of singleton classes, as well as the TimeManager.Instance setup, check out this article about singletons in Unity.

We also make the Instance variable have a public getter, but a private setter. This will ensure that other scripts can only access the instance variable, but cannot modify it. The TimeManager class itself will also ensure that there is only 1 copy of the TimeManager in the scene at any time (see the Awake() function below).

We want this class to handle the game time stamp and also update the timestamp. So in order to do this, we’ll create a header called Internal Clock, serialize the script, and also create a GameTimeStamp called timestamp. We’ll also create a timeScale variable, which we will set to a default value of 1.0. This will allow us to multiply the speed of the game time in the event we want to fast forward time within the game. Sort of like in Sims!

g. Awake() function

The next thing we must do is ensure that there is only a single instance of the class at one time, so we’ll create a function called Awake() to do so. The logic is that if there is more than one instance, we will delete the extra instance (so that there is only 1 instance of the class at any time).

h. Start() & Update() function

First, we’ll need to initialise the timestamp. Then, in order to update the game timestamp, we’ll use a co-routine with a while loop within it. Since a minute in-game is equivalent to one second in real-time, we will update the game timestamp every second. Let’s create the co-routine and call it TimeUpdate().

With that, our TimeManager should be able to initialize a game timestamp, and continually update it every second. We can now return to Unity and create an empty GameObject called “Game Manager”, which will be used to store the UI as well as the other management classes. Make the PlayerCanvas a child of Game Manager simply by dragging it to re-assign it. Remember to reset the position of Game Manager as well!

You can now drag the TimeManager script into the Game Manager object to make it a component of Game Manager. You will see that you can now change the time scale and also set the day, hour and minute.

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;


    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(1, 6, 0);
        StartCoroutine(TimeUpdate());
    }



    IEnumerator TimeUpdate()
    {
        while (true)
        {
            timestamp.UpdateClock();

            yield return new WaitForSeconds(1 / timeScale);
        }

    }
}

i. Setting up the Sun’s rotation

For this portion, we’ll want the sun (i.e. Directional Light in the Scene) to rotate every second and reflect the current time. In Subnautica, day and night are split up such that 15 hours in-game (15 minutes in real-time) are considered ‘Day’ and 5 hours in-game (5 minutes in real-time) are considered ‘Night’. So taking the horizon as the axis, we’ll want the sun to move from east to west (180 degrees) in 15 in-game hours, and for it to move back to its original position (next 180 degrees) afterwards in 5 in-game hours. Don’t worry – we did the math for you this time because we’re awesome:

  • During the day – rotate 12 degrees an hour; 0.2 degrees every minute (in-game)
  • During the night – rotate 36 degrees an hour; 0.6 degrees every minute (in-game)

Reminder: 1 minute of in-game time is equivalent to 1 second in real-time.

Here’s what you need to do in the TimeManager script:

  1. Create a header called Day and Night Cycle
  2. Create a Transform variable called sunTransform
  3. Create new function to update sun movement – UpdateSunMovement()

In order to move the sun according to what we calculated earlier, we can simply use if statements.

Set the variable sunAngle to 0 to initialise it. Then, we’ll set the condition such that if the time in minutes is less than 15 * 60 (total number of minutes in-game per day), we’ll multiply the angle of the sun during the day to be 0.2 degrees multiplied by the number of minutes. Otherwise, if the time in minutes is more than so, we’ll calculate where the sun should be during the night. However, for the angle of the sun during the night, we must remember that its position has passed the first 180 degrees (which is the horizon), so ensure that you include this in your calculations for the sun angle:

  • Take the total time in minutes and deduct 1 in-game day (15*60) from it
  • Multiply the above by the night angle calculation above, which is 0.6 degrees per minute
  • Finally, add 180 degrees to it since the sun has set below the axis (horizon)

The last thing we have to do is apply the angle to the directional light. We can then add the newly created UpdateSunMovement() function into the timeUpdate() function. To make the script less cluttered, we’ll create another function called Tick() that has the UpdateSunMovement() function, then put the Tick() function within the TimeUpdate() function.

The code should look like this:

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;
    Vector3 sunAngle;

    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(1, 6, 0);
        StartCoroutine(TimeUpdate());

    }

    private void Update()
    {
        sunTransform.rotation = Quaternion.Slerp(sunTransform.rotation, Quaternion.Euler(sunAngle), 1f * Time.deltaTime);
    }

    IEnumerator TimeUpdate()
    {
        while (true)
        {
            timestamp.UpdateClock();
            Tick();

            yield return new WaitForSeconds(1 / timeScale);
        }

    }

    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;

        //during daytime
        //Sun moves .2 degrees in a minute
        //during nighttime
        //Sun moves .6 degrees in a minute
        //At midnight (0:00), the angle of the sun should be -90

        float sunAngle = 0;
        if(timeInMinutes <= 15 * 60) 
        {
            sunAngle = .2f * timeInMinutes;
        }
        else if(timeInMinutes > 15 * 60)
        {
            sunAngle = 180f + 0.6f * (timeInMinutes - (15 * 60));
        }

        //Apply the angle to the directional light
        this.sunAngle = new Vector3(sunAngle, 0, 0);
    }
}

Now we can go back to Unity and go into the Game Manager object and add the Directional Light (Transform) into the TimeManager (Script) – you should now be able to see the movement of the directional light once you hit play! To smooth out the movement of the sun, we’ve added another line of code in the Update() function by using Quaternion.Slerp().

Lerp stands for Linear Interpolation. It takes 2 values and finds a point between the 2 values, and is often used to simulate movement between 2 points (see this part of our article for more information). Slerp stands for Spherical Linear Interpolation, and works similarly, except that it is applied to rotations as opposed to movement in a straight line.

j. Setting up the Game Manager GameObject

We’ve covered a little bit about how to set up the Game Manager game object earlier, but here’s a summarised version of what you should have:

  • Create an empty GameObject called “Game Manager”, which will be used to store the UI as well as the other management classes
  • Make the PlayerCanvas a child of Game Manager simply by dragging it to re-assign it
  • Reset the position of Game Manager
  • Drag the TimeManager script into the Game Manager object to make it a component of Game Manager
  • Move the player canvas onto the Game Manager object you’ve created
  • Put the sun on the Game Manager object

k. Setting up Debug functions (to skip time)

To begin debugging the program, let’s start with creating a function to skip time and a function to retrieve the current time stamp.

In the SkipTime() function, it should ensure that as time is being skipped, it simultaneously updates everything in the Tick() function correctly. Essentially, it will take in a timestamp that the you want to skip to, compute the difference between the specified timestamp and the current time stamp in minutes, then repeatedly call the Tick() function until it reaches the specified timestamp. 

The code should look like this:

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;
    Vector3 sunAngle;

    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(1, 6, 0);
        StartCoroutine(TimeUpdate());

    }

    private void Update()
    {
        sunTransform.rotation = Quaternion.Slerp(sunTransform.rotation, Quaternion.Euler(sunAngle), 1f * Time.deltaTime);
    }

    //Load the time from a save 
    public void LoadTime(GameTimeStamp timestamp)
    {

    IEnumerator TimeUpdate()
    {
        while (true)
        {
            Tick();

            yield return new WaitForSeconds(1 / timeScale);
        }

    }

    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;

        //during daytime
        //Sun moves .2 degrees in a minute
        //during nighttime
        //Sun moves .6 degrees in a minute
        //At midnight (0:00), the angle of the sun should be -90

        float sunAngle = 0;
        if(timeInMinutes <= 15 * 60) 
        {
            sunAngle = .2f * timeInMinutes;
        }
        else if(timeInMinutes > 15 * 60)
        {
            sunAngle = 180f + 0.6f * (timeInMinutes - (15 * 60));
        }

        //Apply the angle to the directional light
        //sunTransform.eulerAngles = new Vector3(sunAngle, 0, 0);
        this.sunAngle = new Vector3(sunAngle, 0, 0);
    }

    // function to skip time
    public void SkipTime(GameTimeStamp timeToSkipTo)
    {
        //Convert to minutes
        int timeToSkipInMinutes = GameTimeStamp.TimestampInMinutes(timeToSkipTo);
        Debug.Log("Time to skip to:" + timeToSkipInMinutes);
        int timeNowInMinutes = GameTimeStamp.TimestampInMinutes(timestamp);
        Debug.Log("Time now: " + timeNowInMinutes);

        int differenceInMinutes = timeToSkipInMinutes - timeNowInMinutes;
        Debug.Log(differenceInMinutes + " minutes will be advanced");

        //Check if the timestamp to skip to has already been reached
        if (differenceInMinutes <= 0) return;

        for (int i = 0; i < differenceInMinutes; i++)
        {
            Tick();
        }
    }

    //Get the timestamp
    public GameTimeStamp GetGameTimestamp()
    {
        //Return a cloned instance
        return new GameTimeStamp(timestamp);
    }
}

l. Setting up the ITimeTracker interface

The last part of the time system that we need to create would be a time tracker interface. The proper definition of what an interface does is summarised below:

“An interface is a collection of method signatures and properties. You can think of interfaces like a contract that classes can implement: when a class implements an interface, a class instance can be treated as an instance of that interface. This functionality means that you can group together multiple disparate types and treat them in the same way.”

Explanation from Unity Learn

In essence, we want to be able to inform a class when a certain time has been met so that the class can execute an action. For instance, if we want to trigger an event during the gameplay during a specified time, the interface helps to keep track of what time it currently is before the event can happen, as opposed to constantly referring to the TimeManager script. Here are the steps to begin coding this portion:

  1. Create ITimeTracker in the Time folder
  2. Remove Monobehavior and Start & Update scripts
  3. Create a function called ClockUpdate()

ITimeTracker.cs

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

public interface ITimeTracker
{
    void ClockUpdate(GameTimeStamp timestamp);
}
  • Go back into the TimeManager script and create a list of time tracker interfaces below the variables you’ve declared
  • Call ClockUpdate() with the current timestamp under Tick() function: This means that every time the clock is updated, it sends a message to every time tracker. We will further explore this in the later parts of the tutorial. Feel free to modify this how you like depending on what you want to do with it!
  • Create functions to manage listeners (adding and removing) – RegisterTracker() and UnregisterTracker()

Here’s what it will look like:

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;
    Vector3 sunAngle;

    //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(1, 6, 0);
        StartCoroutine(TimeUpdate());

    }

    private void Update()
    {
        sunTransform.rotation = Quaternion.Slerp(sunTransform.rotation, Quaternion.Euler(sunAngle), 1f * Time.deltaTime);
    }

    //Load the time from a save 
    public void LoadTime(GameTimeStamp timestamp)
    {

    IEnumerator TimeUpdate()
    {
        while (true)
        {
            Tick();

            yield return new WaitForSeconds(1 / timeScale);
        }

    }

    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;

        //during daytime
        //Sun moves .2 degrees in a minute
        //during nighttime
        //Sun moves .6 degrees in a minute
        //At midnight (0:00), the angle of the sun should be -90

        float sunAngle = 0;
        if(timeInMinutes <= 15 * 60) 
        {
            sunAngle = .2f * timeInMinutes;
        }
        else if(timeInMinutes > 15 * 60)
        {
            sunAngle = 180f + 0.6f * (timeInMinutes - (15 * 60));
        }

        //Apply the angle to the directional light
        //sunTransform.eulerAngles = new Vector3(sunAngle, 0, 0);
        this.sunAngle = new Vector3(sunAngle, 0, 0);
    }

    // function to skip time
    public void SkipTime(GameTimeStamp timeToSkipTo)
    {
        //Convert to minutes
        int timeToSkipInMinutes = GameTimeStamp.TimestampInMinutes(timeToSkipTo);
        Debug.Log("Time to skip to:" + timeToSkipInMinutes);
        int timeNowInMinutes = GameTimeStamp.TimestampInMinutes(timestamp);
        Debug.Log("Time now: " + timeNowInMinutes);

        int differenceInMinutes = timeToSkipInMinutes - timeNowInMinutes;
        Debug.Log(differenceInMinutes + " minutes will be advanced");

        //Check if the timestamp to skip to has already been reached
        if (differenceInMinutes <= 0) return;

        for (int i = 0; i < differenceInMinutes; i++)
        {
            Tick();
        }
    }

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

}

2. About Terrain

a. Installing Terrain Package & Previewing Packages

Unity has some built-in terrain tools, as well as a terrain package.

i. To access the Unity terrain package, go to Window > Package Manager.

Where to find the Package Manager.
Where to find the Package Manager.

ii. Make sure Unit Registry is selected, then go into Advanced Project Settings.

Make sure Unity Registry is selected.
Make sure Unity Registry is selected.
Where to find Advanced Project Settings.
Where to find Advanced Project Settings.

iii. Select Enable Preview Packages

Enabling Preview Packages.
Enabling Preview Packages.

iv. Type into the search bar “Terrain” and the Terrain Tools package should show up. Then click Install to add it to your project.

Finding the Terrain Tools package
Finding the Terrain Tools package.

Note: You’ll see after you’ve downloaded it that there is an option to Download Asset Samples from Asset Store. The asset store has some samples of terrain that you can select from, so you can actually browse through these assets and use any ones that you might like.

v. Go to Window > Terrain > Terrain Toolbox, and drag it next to the Inspector.

The terrain toolbox enables you to create new terrains, edit its utilities, visualisation modes, and more. These tools help with creating terrains easily with functionalities like adjusting its settings and creating terrain presets!

b. Building the Terrain

Let’s begin by creating a new terrain. For this step, we’ll set the total terrain width and total terrain height to 500m, and the terrain height to 1000m. Then, click “Create” at the very bottom. This will create a terrain group in which it has the terrain.

If we go into the Terrain Group, you’ll notice that the inspector shows that it has a script and a group ID. This ID is used for all terrains created within the group if you have more than one.

i. Terrain Brushes

The terrain tools are quite powerful in that it allows you to manipulate the terrains in multitudes of methods, depending on what you want. This includes creating neighbor terrains, painting terrains, and more.

Here is a rundown of the main functionalities of terrain brushes:

Raising or Lowering the TerrainEnables you to adjust the terrain’s height in the brush shape that you have selected
Set HeightEnables you to sample height level
SculptCreate terraces, bridges, clone terrains, add noise to create the illusion of randomness of the terrain
EffectsEnables the addition of contrast between different portions of terrain, ability to sharped peaks of raised terrains, or flatten slopes
ErosionSimulates different types of erosion on terrain
Paint HolesEnables you to create holes in terrain
Stamp TerrainAllows you to sample the terrain and use it as a brush
TransformEnables you to pinch, smudge, and twist your terrain
If you’re still unsure how this works, at 24:40 of the video tutorial, we go into detail of how these functions work and what they do with examples.

c. Expanding the Terrain

You can expand the terrain by creating neighbour terrains. This automatically matches the height to your selected terrain, and you can have them mirror or clamp your terrain!

d. Sample of completed Terrain

sample completed terrain brush tools overview unity engine game programming
sample completed terrain brush tool unity engine game programming

Conclusion

Now, with your terrain, you can go ahead at put your player on it and let them enjoy their new little playground. Thank you for tuning in to the second part of this Subnautica game tutorial. We hope this was useful! Let us know what you think in the comments section below, or on the video itself!

Below are the final codes that we have at the end of this part. If you like to, you can also download the project files.

PlayerController.cs

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

public class PlayerController : MonoBehaviour
{
    //reference the transform
    Transform t;

    public static bool inWater;
    public static bool isSwimming;
    //if not in water, walk
    //if in water and not swimming, float
    //if in water and swimming, swim

    public LayerMask waterMask;

    [Header("Player Rotation")]
    public float sensitivity = 1;

    //clamp variables
    public float rotationMin;
    public float rotationMax;

    //mouse input variables
    float rotationX;
    float rotationY;

    [Header("Player Movement")]
    public float speed = 1;
    float moveX;
    float moveY;
    float moveZ;

    Rigidbody rb;

    // Start is called before the first frame update
    void Start()
    {
        rb = GetComponent<Rigidbody>();
        t = this.transform;

        Cursor.lockState = CursorLockMode.Locked;

        inWater = false;
    }

    private void FixedUpdate()
    {
        SwimmingOrFloating();
        Move();
    }

    private void OnTriggerEnter(Collider other)
    {
        SwitchMovement();
    }

    private void OnTriggerExit(Collider other)
    {
        SwitchMovement();
    }

    void SwitchMovement()
    {
        //toggle inWater
        inWater = !inWater;

        //change the rigidbody accordingly.
        rb.useGravity = !rb.useGravity;
    }

    void SwimmingOrFloating()
    {
        bool swimCheck = false;

        if (inWater)
        {
            RaycastHit hit;
            if(Physics.Raycast(new Vector3(t.position.x,t.position.y + 0.5f,t.position.z),Vector3.down,out hit, Mathf.Infinity, waterMask))
            {
                if(hit.distance < 0.1f)
                {
                    swimCheck = true;
                }
            }
            else
            {
                swimCheck = true;
            }
        }

        isSwimming = swimCheck;
        //Debug.Log("isSwiming = " + isSwimming);
    }

    // Update is called once per frame
    void Update()
    {
        LookAround();

        //debug function to unlock cursor
        if (Input.GetKey(KeyCode.Escape))
        {
            Cursor.lockState = CursorLockMode.None;
        }
    }

    void LookAround()
    {
        //get the mous input
        rotationX += Input.GetAxis("Mouse X")*sensitivity;
        rotationY += Input.GetAxis("Mouse Y")*sensitivity;

        //clamp the y rotation
        rotationY = Mathf.Clamp(rotationY, rotationMin, rotationMax);

        //setting the rotation value every update
        t.localRotation = Quaternion.Euler(-rotationY, rotationX, 0);
    }

    void Move()
    {
        //get the movement input
        moveX = Input.GetAxis("Horizontal");
        moveY = Input.GetAxis("Vertical");
        moveZ = Input.GetAxis("Forward");

        //check if the player is in water
        if (inWater)
        {
            rb.velocity = new Vector2(0,0);
        }
        else
        {
            //check if the player is standing still
            if(moveX == 0 && moveZ == 0)
            {
                rb.velocity = new Vector2(0, rb.velocity.y);
            }
        }

        if (!inWater)
        {
            //move the character (land ver)
            t.Translate(new Quaternion(0, t.rotation.y, 0, t.rotation.w) * new Vector3(moveX, 0, moveZ) * Time.deltaTime * speed, Space.World);
        }
        else
        {
            //check if the player is swimming under water or floating along the top
            if (!isSwimming)
            {
                //move the player (floating ver)
                //clamp the moveY value, so they cannot use space or shift to move up
                moveY = Mathf.Min(moveY, 0);

                //conver the local direction vector into a worldspace vector/ 
                Vector3 clampedDirection = t.TransformDirection(new Vector3(moveX, moveY, moveZ));

                //clamp the values of this worldspace vector
                clampedDirection = new Vector3(clampedDirection.x, Mathf.Min(clampedDirection.y, 0), clampedDirection.z);

                t.Translate(clampedDirection * Time.deltaTime * speed, Space.World);
            }
            else
            {
                //move the character (swimming ver)
                t.Translate(new Vector3(moveX, 0, moveZ) * Time.deltaTime * speed);
                t.Translate(new Vector3(0, moveY, 0) * Time.deltaTime * speed, Space.World);
            }
        }

    }
}

GameTimeStamp.cs

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

[System.Serializable]
public class GameTimeStamp
{
    public int day;
    public int hour;
    public int minute;

    //class constructor - sets up the class
    public GameTimeStamp(int day, int hour, int minute)
    {
        this.day = day;
        this.hour = hour;
        this.minute = minute;
    }

    //create a new timestamp from an existing one
    public GameTimeStamp(GameTimeStamp timeStamp)
    {
        this.day = timeStamp.day;
        this.hour = timeStamp.hour;
        this.minute = timeStamp.minute;
    }

    //function to increase the time incrementally
    public void UpdateClock()
    {
        minute++;

        //60 minutes in an hour
        if(minute >= 60)
        {
            //reset minutes
            minute = 0;
            hour++;
        }

        //20 hours in 1 day
        if(hour >= 20)
        {
            //reset hours
            hour = 0;
            day++;
        }
    }

    //convert hours to minutes
    public static int HoursToMinutes(int hour)
    {
        //60 minutes = 1 hour
        return hour * 60;
    }

    //returns the current time stamp in minutes
    public static int TimestampInMinutes(GameTimeStamp timestamp)
    {
        return (HoursToMinutes(timestamp.day * 20) + HoursToMinutes(timestamp.hour) + timestamp.minute);
    }
}

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;
    Vector3 sunAngle;

    //list of objects to inpform 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(1, 6, 0);
        StartCoroutine(TimeUpdate());
    }

    IEnumerator TimeUpdate()
    {
        while (true)
        {
            Tick();

            yield return new WaitForSeconds(1 / timeScale);
        }
    }

    void Tick()
    {
        timestamp.UpdateClock();
        UpdateSunMovement();

        //inform each listener of the new timestate
        foreach(ITimeTracker listener in listeners)
        {
            listener.ClockUpdate(timestamp);
        }
    }

    //Day and Night cycle
    void UpdateSunMovement()
    {
        //convert the current time to minutes
        int timeInMinutes = GameTimeStamp.HoursToMinutes(timestamp.hour) + timestamp.minute;

        //during daytime
        //sun moves 0.2 degrees in a minute
        //during nighttime
        //sun moves 0.6 degrees in a minute

        float sunAngle = 0;
        if(timeInMinutes <= 15 * 60)
        {
            sunAngle = 0.2f * timeInMinutes;
        }
        else if(timeInMinutes > 15* 60)
        {
            sunAngle = 180f + 0.6f * (timeInMinutes - (15 * 60));
        }

        //Apply angle to the directional light
        //sunTransform.eulerAngles = new Vector3(sunAngle, 0, 0);
        this.sunAngle = new Vector3(sunAngle, 0, 0);
    }

    // Update is called once per frame
    void Update()
    {
        sunTransform.rotation = Quaternion.Slerp(sunTransform.rotation, Quaternion.Euler(sunAngle), 1f * Time.deltaTime);
    }

    //function to skip time
    public void SkipTime(GameTimeStamp timeToSkipTo)
    {
        //convert to minutes
        int timeToSkipInMinutes = GameTimeStamp.TimestampInMinutes(timeToSkipTo);
        Debug.Log("Time Skip to:" + timeToSkipInMinutes);
        int timeNowInMinutes = GameTimeStamp.TimestampInMinutes(timestamp);
        Debug.Log("Time now: " + timeNowInMinutes);

        int differenceInMinutes = timeToSkipInMinutes - timeNowInMinutes;
        Debug.Log("Skip " + differenceInMinutes + " minutes");

        //Check if the timestamp to skip to has already been reached
        if (differenceInMinutes <= 0) return;

        for (int i=0; i < differenceInMinutes; i++)
        {
            Tick();
        }
    }

    public GameTimeStamp GetGameTimestamp()
    {
        //return a cloned instance
        return new GameTimeStamp(timestamp);
    }

    //handle listeners list

    //add an object to the list
    public void RegisterTracker(ITimeTracker listener)
    {
        listeners.Add(listener);
    }

    //Remove an object from the list
    public void UnregisterTracker(ITimeTracker listener)
    {
        listeners.Remove(listener);
    }
}

ITimeTracker.cs

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

public interface ITimeTracker
{
    void ClockUpdate(GameTimeStamp timestamp);
}

PlayerStats.cs

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

public class PlayerStats : MonoBehaviour
{
    public List<int> maxStats; //0 is oxygen, 1 is food, 2 is thirst, 3 is health
    List<int> currentStats;

    Coroutine oxygenCo;
    Coroutine hungerCo;
    Coroutine thirstCo;

    bool swimCheck;

    [Header("UI")]
    public List<Slider> statBars;
    public List<TextMeshProUGUI> statNums;

    // Start is called before the first frame update
    void Start()
    {
        //initialise currentStats to the max amount
        currentStats = new List<int>(maxStats);

        //start decreasing the values of hunger and thirst
        hungerCo = StartCoroutine(DecreaseStats(1, 20, 1));
        thirstCo = StartCoroutine(DecreaseStats(2, 20, 1));

        //initialise the statBars
        for (int i = 0; i < maxStats.Count; i++)
        {
            statBars[i].maxValue = maxStats[i];
        }
    }

    // Update is called once per frame
    void Update()
    {
        if (!PlayerController.isSwimming && swimCheck == true)
        {
            swimCheck = false;
            StopCoroutine(oxygenCo);
            ChangeStat(0, maxStats[0]);
        }
        if (PlayerController.isSwimming && swimCheck == false)
        {
            swimCheck = true;
            oxygenCo = StartCoroutine(DecreaseStats(0, 3, 3));
        }

        //displaye currentstats in stat ui
        for(int i = 0; i < maxStats.Count; i++)
        {
            statBars[i].value = currentStats[i];
            statNums[i].text = currentStats[i].ToString();
        }
    }

    IEnumerator DecreaseStats(int stat, int interval, int amount)
    {
        while (true)
        {
            yield return new WaitForSeconds(interval);

            if(currentStats[stat] > 0)
            {
                currentStats[stat] = Mathf.Max(currentStats[stat] - amount, 0);
            }
        }
    }

    public void ChangeStat(int stat, int refreshAmount)
    {
        if(refreshAmount > 0)
        {
            currentStats[stat] = Mathf.Min(currentStats[stat] + refreshAmount, maxStats[stat]);
        }
        else
        {
            currentStats[stat] = Mathf.Max(currentStats[stat] + refreshAmount, 0);
        }
    }
}

Leave a Reply

Your email address will not be published. Required fields are marked *

Note: You can use Markdown to format your comments.

For security, use of Google's reCAPTCHA service is required which is subject to the Google Privacy Policy and Terms of Use.

I agree to these terms.

This site uses Akismet to reduce spam. Learn how your comment data is processed.