Creating an Underwater Survival Game in Unity - Part 1

Creating an Underwater Survival Game (like Subnautica) Part 1 — Movement and Player Stats System

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 1 of our guide here, where we go through how to set up a player controller and some player stats.

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.

Video authored, edited and subtitled by Sarah Kagda.

1. Setting Up

First of all, we should set up the project scene.

a. Import all assets

In this project, we will be using various different free assets. For today, we will be using the following assets.

Create a folder called Imported Assets in your Assets folder to hold all your imported assets. Put the AQUAS lite resources there. Then, you can create a folder there called Models and put the Mixamo model there.

b. Set up the terrain

In a later episode, we will be learning how to set up your own custom terrain for your game. So, for now, we will be using the default terrain from the demo scene of the AQUAS lite assets. You can copy the terrain game object and the water game object to the sample scene of unity, and work there.

c. Create the Player Character

Now to create our player GameObject. there are a few things we need to do.

  1. Create a capsule GameObject. Name it “Player”.
  2. Add a Rigidbody component to it.
  3. Make the camera a child of the Player GameObject.
  4. Drag the Mixamo model into the scene and make it a child of the Player as well.
  5. Position the Mixamo model so that the model stands neatly at the bottom of the capsule.
  6. Adjust the position of the camera, and adjust the camera properties as you see fit.
  7. Delete the Mesh Filter and Mesh Renderer on the player.
Setting up the player's GameObject
Setting up the player’s GameObject.

2. Player Movement — Looking Around

Now that we have the terrain and a player GameObject, we will be setting up the player controls. To start, we will make the player be able to look around.

a. Create a script

Create a new folder called Scripts, and in there create a folder called Player. Then, we can create the script PlayerController [Assets/Scripts/Player]. We will start by creating a function that allows the player to look around. Here are the steps.

  1. Cache the Transform for better performance — in future episodes, we will be doing a lot of optimisation!
  2. Create two variables to store the input from the mouse movement
  3. Create the function LookAround() to allow the player to look around
  4. Lock the cursor to the center of the screen
  5. Add a debug function to unlock the cursor

PlayerController.cs

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

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

    [Header("Player Rotation")]
    //mouse input
    float rotationX;
    float rotationY;

    // Start is called before the first frame update
    void Start()
    {
        t = transform;
        Cursor.lockState = CursorLockMode.Locked;
        //Debug.Log(Space.World);
    }

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

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

    void LookAround()
    {
        //get the mouse input
        rotationX += Input.GetAxis("Mouse X");
        rotationY += Input.GetAxis("Mouse Y");
        
        //setting the rotation value every update
        t.localRotation = Quaternion.Euler(-rotationY, rotationX, 0);
    }
    
}

b. Clamp the rotation of the LookAround() function

Right now, when we test this function, we find that the player is able to look all the way around and even do a backflip. This is not good, so we need to try to clamp the rotationY variable.

In the script, we can do this by adding maximum and minimum rotation variables, and by clamping the value of the rotationY variable in LookAround(). At the same time, we can also add a variable to adjust the sensitivity of the rotation, and multiply the mouse inputs by it.

PlayerController.cs

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

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

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

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

    //mouse input
    float rotationX;
    float rotationY;

    // Start is called before the first frame update
    void Start()
    {
        t = transform;
        Cursor.lockState = CursorLockMode.Locked;
        //Debug.Log(Space.World);
    }

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

        //just to debug haha we will remove later
        if (Input.GetKey(KeyCode.Escape))
        {
            Cursor.lockState = CursorLockMode.None;
        }
    }

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

        //clamp the values of x and y
        rotationY = Mathf.Clamp(rotationY, rotationMin, rotationMax);
        
        //setting the rotation value every update
        t.localRotation = Quaternion.Euler(-rotationY, rotationX, 0);
    }
    
}

Now when we test the game, the player can no longer do a backflip, which is great!


Article continues after the advertisement:


3. Player Movement — Swimming, Walking and Floating Movement

In our game, the player has three different types of movement: Movement on land, movement when underwater, and movement when the player is swimming along the top of the water (which will be referred to as floating). Let’s set up the underwater movement first.

a. Setting up the Input Manager

Now we are going to add the swimming movement. First, we need to edit the input axes in the Input Manager in the Project Settings.

  1. Change the name of the “Vertical” axis to the “Forward” axis. This will move the player forward or backward, along the Z axis, based on the w and s keys.
  2. change the name of “fire1” to “Vertical”, and set it to left shift and space. This axis will move our player up and down, along the Y axis.

Now we have three input axes for movement: the X axis for left and right movement, the Y axis for up and down movement, and the Z axis for forward and backward movement. When it comes to swimming movement, we need to remember that the X and Z movement needs to be relative to the local rotation of the player, so we can move diagonally when swimming. However, the Y axis should always be relative to the world space. Thus for movement, we will use the Transform.Translate() function as follows.

t.Translate(new Vector3(moveX, 0, moveZ) * Time.deltaTime * speed);
t.Translate(new Vector3(0, moveY, 0) * Time.deltaTime * speed, Space.World);

By default, the function moves the game object relative to its local rotation, but when we use Space.World, we can also make it move relative to the world space.

b. Creating the Move Function

The next step is to add a new function to the PlayerController script. The new function will be called Move(). When swimming,

  1. Create Input variables for all three axes
  2. Get the input values
  3. Use t.Translate to move the character
  4. Call the Move() function in FixedUpdate

Remember to call the Move() function in FixedUpdate instead of Update, because the movement should interact with collisions.

PlayerController.cs

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

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

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

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

    //mouse input
    float rotationX;
    float rotationY;

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


    // Start is called before the first frame update
    void Start()
    {
        t = transform;
        Cursor.lockState = CursorLockMode.Locked;
        //Debug.Log(Space.World);
    }

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

        //just to debug haha we will remove later
        if (Input.GetKey(KeyCode.Escape))
        {
            Cursor.lockState = CursorLockMode.None;
        }
    }

    private void FixedUpdate()
    {
        Move();
    }

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

        //clamp the values of x and y
        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");
        moveZ = Input.GetAxis("Forward");
        moveY = Input.GetAxis("Vertical");

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

Now, we have set up underwater swimming movement for the player. Next, we want to make the PlayerController able to know when to walk, when to swim, or when to float.

c. Setting up Colliders in the Scene

In order for the PlayerController to know what kind of movement to use, we need to do a few things in the Unity scene.

  1. Create a large box collider at water level and call it “Water Collider”. Set isTrigger to true.
  2. Add a new tag called “Ground” and add it to the terrain.
  3. Set Water Collider to the Water layer.
Setting up the water BoxCollider.
The water’s BoxCollider has been set up at the water level and isTrigger is set to be on.

d. Keeping Track of the State of the Player’s Movement

For this, we need to keep track of two things, whether the player is in water or not, and whether the player has their head above the surface of the water. For whether the player is in water, we can keep track of that via onTriggerEnter and onTriggerExit. However, to keep track of whether the player’s head is above water, we will use unity’s Physics.Raycast().

Physics.Raycast(new Vector3(t.position.x, t.position.y + 0.5f, t.position.z), Vector3.down, out hit, Mathf.Infinity, waterMask);

We will take the raycast position to be slightly above the center of the player, and send a raycast down to check if the water collider is there. If the water collider is there, that means it is above the water and we can check the height above the water. If it does not find the water collider that means the player is on land or underwater completely. Now we can write the code.

  1. Create two static bools, inWater and isSwimming.
  2. Create a function SwitchMovement() to toggle inWater.
  3. Call SwitchMovement() when the player enters or exits the Water Collider.
  4. Create a function SwimingOrFloating() to check whether the player is swimming underwater or floating along the surface of the water.
  5. Called SwimmingOrFloating() before the Move() function in FixedUpdate().

PlayerController.cs

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

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

    public static bool isSwimming;
    public static bool inWater;

    //if inwater and not swimming, then activate float on top of water code. 
    //if not in water, activate walk code
    //if swimming, activate swimming code

    public LayerMask waterMask;

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

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

    //mouse input
    float rotationX;
    float rotationY;

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


    // Start is called before the first frame update
    void Start()
    {
        t = transform;
        Cursor.lockState = CursorLockMode.Locked;

        inWater = false;
    }

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

        //just to debug haha we will remove later
        if (Input.GetKey(KeyCode.Escape))
        {
            Cursor.lockState = CursorLockMode.None;
        }
    }

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

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

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

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

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

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

        //clamp the values of x and y
        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");
        moveZ = Input.GetAxis("Forward");
        moveY = Input.GetAxis("Vertical");

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

Don’t forget to set the waterMask variable to the water layer in the Player GameObject inspector.


Article continues after the advertisement:


e. Adding Walking and Floating Movement

Next, we are going to add the other two movements.

For walking, we want the Rigidbody to use gravity, and we want the player to move based on their Y-rotation but not their X rotation. For the gravity, we can just add and get the Rigidbody component of the player and toggle the gravity in SwitchMovement().

Then, the movement. If you use local rotation, the player will be able to move diagonally upwards while on land, which is not good. As such, we will multiply the world space vector with the player’s Y rotation quaternion. This is the equation we get.

t.Translate(new Quaternion(0, t.rotation.y, 0, t.rotation.w) * new Vector3(moveX, 0, moveZ) * Time.deltaTime * speed, Space.World);

Then, floating movement. The floating movement is similar to the swimming movement, except that the player should not be able to increase their Y height. To accomplish this, first we clamp the moveY input.

moveY = Mathf.Min(moveY, 0);

For clamping the movement along the x-axis, we need a way to allow diagonal movement (movement with reference to the local rotation) without allowing the Y height to increase. This was accomplished by converting the movement vector to a world space vector, then clamping the y increase of that vector.

Vector3 clampedDirection = transform.TransformDirection(new Vector3(moveX, moveY, moveZ));
clampedDirection = new Vector3(clampedDirection.x, Mathf.Min(clampedDirection.y, 0), clampedDirection.z);

Now we can add the code.

  1. Add if statements to the Move() function.
  2. Add and get the Rigidbody component
  3. Toggle the Rigidbody’s gravity in SwitchMovement().

PlayerController.cs

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

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

    public static bool isSwimming;
    public static bool inWater;

    //if inwater and not swimming, then activate float on top of water code. 
    //if not in water, activate walk code
    //if swimming, activate swimming code

    public LayerMask waterMask;

    Rigidbody rb;

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

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

    //mouse input
    float rotationX;
    float rotationY;

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


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

        inWater = false;
    }

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

        //just to debug haha we will remove later
        if (Input.GetKey(KeyCode.Escape))
        {
            Cursor.lockState = CursorLockMode.None;
        }
    }

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

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

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

    void SwitchMovement()
    {
        //toggle isSwimming
        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(isSwimming);
    }

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

        //clamp the values of x and y
        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");
        moveZ = Input.GetAxis("Forward");
        moveY = Input.GetAxis("Vertical");

        //check if player is on land
        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 here if the player is swimming above water
            if (!isSwimming)
            {
                //if the character is floating on the water, clamp the moveY value, so they cant rise further than that.
                moveY = Mathf.Min(moveY, 0);

                //convert the local direction vector into a worldspace vector. 
                Vector3 clampedDirection = transform.TransformDirection(new Vector3(moveX, moveY, moveZ));
                //clamp the values
                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);
            }
        }
    }
}

Article continues after the advertisement:


f. Adjusting the velocity of the player’s Rigidbody

We have completed the three types of movement now. However, you might notice the player still moves when none of the inputs are entered. This is due to the leftover velocity in the Rigidbody component. We could set it to zero all the time, but Unity’s gravity in its physics engine uses the Rigidbody’s velocity to affect the player. So in the code, we just need to clamp it to zero when not on land, and otherwise just clamp the x-value of the velocity.

PlayerController.cs

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

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

    public static bool isSwimming;
    public static bool inWater;

    //if inwater and not swimming, then activate float on top of water code. 
    //if not in water, activate walk code
    //if swimming, activate swimming code

    public LayerMask waterMask;

    Rigidbody rb;

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

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

    //mouse input
    float rotationX;
    float rotationY;

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


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

        inWater = false;
    }

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

        //just to debug haha we will remove later
        if (Input.GetKey(KeyCode.Escape))
        {
            Cursor.lockState = CursorLockMode.None;
        }
    }

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

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

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

    void SwitchMovement()
    {
        //toggle isSwimming
        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(isSwimming);
    }

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

        //clamp the values of x and y
        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");
        moveZ = Input.GetAxis("Forward");
        moveY = Input.GetAxis("Vertical");

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

        //check if player is on land
        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 here if the player is swimming above water
            if (!isSwimming)
            {
                //if the character is floating on the water, clamp the moveY value, so they cant rise further than that.
                moveY = Mathf.Min(moveY, 0);

                //convert the local direction vector into a worldspace vector. 
                Vector3 clampedDirection = transform.TransformDirection(new Vector3(moveX, moveY, moveZ));
                //clamp the values
                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);
            }
        }
    }
}

With that, we have finished the basic movement of the player.

4. Stats System

Moving on, we will create a simple stats system for our player.

a. Create the player stats UI

  1. Create a Canvas, set its size to 1920 by 1080, name it “Player Canvas”
  2. Create 4 sliders to work as the UI, colour and adjust them accordingly
  3. Position them using a vertical layout group component
  4. Add TextMeshPro UGUIs as children of the sliders to be text
Setting up the Stats UI
This is what the Stats UI should look like.

b. Decreasing Stats at an interval

Next, we will create a new folder under Scripts. Name it UI. Then we will create a new class PlayerStats [Assets/Scripts/UI]. In this class, we will store the values of the stats, and create functions to change the stat values.

We will first store the values of each stat and its possible maximum. Then, we will create a coroutine that decreases a stat at an interval. This will be used to decrease hunger and thirst over time, and oxygen when underwater (which we will get to in the next section). We will also create a function to change the value of a stat. In the script, create the following.

  1. Two integer lists, maxStats and currentStats
  2. ChangeStat() function
  3. DecreaseStats() coroutine that decreases a stat at an interval
  4. Three coroutine variables to store the DecreaseStat() coroutines
  5. Set the hunger and thirst decrease in start.

PlayerStats.cs

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

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;

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

    // Update is called once per frame
    void Update()
    {
        
    }

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

Add the PlayerStats script to the player game object and Set the values of the maxStats. Remember 0 is oxygen, 1 is hunger, 2 is thirst, and 3 is health.

c. Oxygen control

Now, to control the oxygen stat, we will reference the isSwimming static bool from PlayerController. This way, we can start the coroutine for decreasing oxygen when isSwimming is true, and refresh the oxygen when isSwimming is false.

However, we run into a problem. this code will run over and over, which will cause problems with the coroutine. However, we can get around this by creating a bool swimCheck. If we change swimCheck whenever the player swims or stops swimming, then we can use it to specify our if statements to only be during the frames where the player is changing between swimming and not swimming, solving our problem. Add to the script the following.

  1. Add a new bool swimCheck
  2. Add the oxygen stat control

PlayerStats.cs

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

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;

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

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

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

Article continues after the advertisement:


d. Apply the Stats to the UI

Now we can apply the stats to the UI, so that the slider and text reflect the stat values. Add UnityEngine.UI and TMPro to the PlayerStats script, and create a reference for the sliders and text.

  1. Store the UI sliders and text as variables
  2. Set the max value of each slider in Start()
  3. Update every stat in the Update() function

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

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

Conclusion

In this article, we went through how to implement the Dialogue System as well as the NPC Relationship System.

If you would like to download a copy of the codes we have worked on, you can download the project files here. To use the files, you will have to unzip the file (7-Zip can help you do that), and open the folder with Assets and ProjectSettings as a project using Unity. If you have trouble opening the project files, you can check out this article here.

Here are the finished codes for all the scripts we have worked with today:

PlayerController.cs

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

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

    public static bool isSwimming;
    public static bool inWater;

    //if inwater and not swimming, then activate float on top of water code. 
    //if not in water, activate walk code
    //if swimming, activate swimming code

    public LayerMask waterMask;

    Rigidbody rb;

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

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

    //mouse input
    float rotationX;
    float rotationY;

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


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

        inWater = false;
    }

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

        //just to debug haha we will remove later
        if (Input.GetKey(KeyCode.Escape))
        {
            Cursor.lockState = CursorLockMode.None;
        }
    }

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

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

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

    void SwitchMovement()
    {
        //toggle isSwimming
        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(isSwimming);
    }

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

        //clamp the values of x and y
        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");
        moveZ = Input.GetAxis("Forward");
        moveY = Input.GetAxis("Vertical");

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

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

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

Article continues after the advertisement:


Leave a Reply

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

Note: You can use Markdown to format your comments.

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