Lesson 10: FPS-style Games

This week we are looking at some advanced Unity features that we can use to create our own “first person shooter” (FPS) style game. We will take a look at tools for quick level generation, dive into Unity’s Character Controller, take a deeper examination of the offerings of the Physics library, and finally dip our toes into some Enemy AI. Starting today, our remaining lessons are no longer required to complete the assignments. From this point on, all lessons are strictly educational, but I strongly recommend trying to follow along as these systems are very useful for developing your own games.

Part 1: Level Prototyping (with ProBuilder)

ProBuilder is a very useful plugin for Unity, extending the editor by offering simple (but significantly improved) modeling and alignment tools.  Once its own standalone product in the Asset Store, Unity has purchased it and now offers it free in the package manager.

ProBuilder is helpful if you need to quickly prototype some objects, rough out a level design, or generate a quick mesh for collisions.  This provides enhanced mesh generation, and access down to the vertex level, as well as a quick way to create and adjust UV mapping for objects.   

Another plugin from the same creators, PolyBrush, lets you sculpt, shape, and paint your meshes.  It is also available for free in the Package Manager.

Installing ProBuilder

ProBuilder is available as a packet in the Package Manager (which you can access through the menus at Window > Package Manager). Select the Unity Registry option to see all available packages. ProBuilder is available on its own, and also as part of the “3D World Building” bundle that also includes PolyBrush, FBX Exporter, and advanced Terrain Tools.

Select either the individual package or bundle and click “Install”.

  • Open the “Package Manager”, accessed by clicking on Window > Package Manager
  • Select the “Unity Registry” option to see all available packages
  • Scroll down until you see the “ProBuilder” entry, and then click “Install”  

ProBuilder Interface

To start ProBuilder, navigate the top menu to Tools > ProBuilder > ProBuilder Window. 

This will launch a window with either Text or Icon buttons.  You can switch between modes by right-clicking a blank area in the window and selecting the other menu type.

ProBuilder Menu

Notice that when you launch ProBuilder, you will also see a small icon menu in your Scene window.  This indicates the selection type when interacting with a ProBuilder object.

These are, from left to right:

  • OBJECT Selection
  • VERTEX Selection
  • EDGE Selection
  • FACE Selection

The ProBuilder Button menu will change depending on what is available for that selection type.

By using these selectors, you can grab just a part of an object and use the various transform tools to deform them into the shapes that you want. The ProBuilder menu contains a number of tools that can split, offset, subdivide, and transform these. Additionally, you can use the “shift” key to generate new geometry, including Shift-Move (face) to Extrude and Shift-Scale (face) to create an inset poly.

Shift-Move (Extrude) will generate new geometry along the offset
Shift-Scale (Inset) will shrink the face and generate border polys

Additionally, you can use Vertex Color to set the color tint of surfaces. You can also set materials, and make adjustments to the UVW mapping (the way that texture images are applied to the surfaces.

ProBuilder can also Export a model into common 3D formats, including OBJ and STL. (I recommend using .obj as that tends to be the most common amongst other modeling applications).

ADVICE: ProBuilder is great for quickly generating or editing assets, but is not recommended to be used throughout the entire process. The procedural generation of the mesh at runtime makes it susceptible to small errors, at which point your work may be lost without an option for recovery. Once you are happy with an asset, it is highly recommended that you run the EXPORT process to convert it into a mesh file in your Asset folder.

Resources

Part 2: FPS Character Controller

CharacterController Component

Unity has a number of standard assets available for use, including prebuilt first person and third person character systems, but for this class it is good to take a look at the methods that can be used to create your own, and for that we will use the CharacterController component.

The CharacterController looks very similar to the Capsule Collider, which is a reasonable shape for what is likely a humanoid character, but it behaves very differently. First, there is no Rigidbody used in the collider, which means that we will not have momentum or transfer force if we should collider with a Rigidbody object. The CharacterController moves along a provided vector, and uses colliders of the surroundings to inform or restrict its movement. Among the useful aspects of the CharacterController, you can:

  • Set a slope limit to prevent your player from climbing surfaces at a steep angle.
  • Set a height offset limit to allow your player to overcome smaller changes in elevation like stairs.
  • See whether or not the controller is on top of a collider using the isGrounded property.

The CharacterController moves you along using a heading or motion vector through one of two commands:

  • CharacterController.SimpleMove( ) will attempt move your character at a speed provided with a Vector 3, and only along the X-Z directions (any Y-axis velocity is ignored). Gravity is automatically applied, making this method appropriate for games where player movement is primarily grounded or level, without jumping.
  • CharacterController.Move( ) will attempt to move your character along a movement vector and will be affected by the colliders. Movement occurs one axis at a time, and gravity is NOT included, so you must process and your own downward y-velocity as part of the Move command.

In our workshop, we modify the example script provided in the Scripting API for the Move( ) command and include it in our player movement script. In the Update( ) command we perform the following tasks:

  • Check to see if the player is grounded. If so, we reset a y-velocity variable to zero, as we are not currently moving downward.
  • Build a movement vector using Input.GetAxis( ) to supply the x and z axis movement.
  • Use the Move( ) command to move by that vector (adjusted for speed * Time.deltaTime)
  • Check for a “Jump” command and if so, replace the y-velocity with an upward thrust.
  • Process gravity by decrementing our y-velocity by our gravity value * Time.deltaTime
  • Run Move( ) again to account for gravity.

This gives us a method where our character can move forward and back, and strafe left to right, but only in relation to world coordinate. In the next video, we use mouse input to adjust our heading and edit our movement vectors to correspond to the forward direction of our rotated player object.

Mouse Look

In 1995, the Quake demo introduced “mouselook” as an option for rotating our player and gamers have never looked back. This method of using x-axis mouse movement to rotate the player heading, and y-axis mouse movement to tilt our camera up and down has become the defacto standard of FPS style games, and so we will build this here today.

It is important to think about how we intend to structure this camera work, as we are not performing a free 6 DOF style navigation of a world – certain rules will apply.

First, we will always remain standing upright and will rotate our entire body to turn, rather than our “head”. This allows us to define our “body” as an object that is restricted to rotation around the positive-y or “up” axis, and we can turn as much as we want without restruction. much as we want around the up-axis.

Second, our “head” should be at eye-level, and should only tilt up-and-down, not side-to-side. This means that the world will always be rendered sky-up (we never see things upside down). We can accomplish this by placing a camera as a child of the body object, facing forward (positive-z) and restrict movement to never exceed a rotation more than 90 degrees from 0 on the x-axis. (Never more than straight up / straight down)

To accomplish this, we use the Input.GetAxis( ) command to read the “Mouse X” and “Mouse Y” values, which will return a “delta” value rather than a coordinate. This delta is an offset value representing the distance traveled since the last Update. Using this value we can rotate these objects around their corresponding axis.

We rotate the “body” object using the Transform.Rotate(Vector3 axis, float angle) command to rotate an adjusted amount informed by the Mouse X around the up-vector. We then use the Mouse Y value to decrement the “head” rotation around its x-axis, updating a floating point value that represents the tiltRotation and clamping that rotation at +/- 90 degrees.

Part 3: Physics

Raycasting

One of the basic functions for a physics engine is “raycasting” – calculating a ray along a heading from a point of origin, and continuing until it intersects with an object, and returning information about that point of contact.

This method will be particularly useful to us, as we will use it as a method of “shooting” our gun. To do this, we will cast a ray originating from the position of our camera and have it move directly out to center of our view, along what is the “forward” (or positive-z) axis of the camera. We use the Physics library to create our Ray and then Raycast( ) it, reading the result. We use the version of Raycast that takes both a Ray and a RaycastHit as arguments. The RaycastHit uses an out method to return more than one parameter, and this result contains information about what the Ray connected with, similar to the Collision type that we receive when a Rigidbody collides.

Using this, we can test to see if the object we hit reacts to bullets, and in this case our barrel will.

Say your prayers, barrel!

OverlaySphere

What about explosions? If our barrel is exploding, it should have an effect on the objects nearby that are capable of movement. Thankfully, the Physics library has a function that allows us to quickly identify all nearby colliders – OverlapSphere. This method allows us to designate a sphere by defining a center point and radius, and will return an array of all Colliders that are found partially or fully inside of that sphere.

Using this array, we can then access each individual collider’s Rigidbody and apply an explosive force using AddExplosionForce( ), which is what we used during Astral Attackers to make our “dead enemy” blocks explode in a satisfying manner.

Finally, since exploding barrels have consequences, we also check for objects in that collider array that also have the BarrelScript component, and if so we call a public function there to start a coroutine sequence that begins by smoldering and ends by exploding, allowing us to set off chain reactions. Very cool!

I told you!

Part 4: The Navmesh

While it is fun to wander around blowing things up, it will be more fun if we add some enemy objects. I want to create a zombie horde that has some intelligence and can pursue me through the level. In order to do this, I am going to use Unity’s AI Navigation package to give my zombie model some smarts.

There’s one now!

In order to make this object “intelligent”, or at least smart enough to figure out how to navigate my level, I need to define the areas that it is capable of travelling. This involves generating a surface shape that is comprised of all of the valid positions that one of the intelligent agents could occupy, which we call a Navmesh.

To create a NavMesh, you must first install the AI Navigation package from the Package Manager. Once you do this, you can add the NavMesh Surface component to your level object.

The Navmesh is created by using a voxel system to analyze the environment to determine what areas are safe for a Nav Mesh Agent to go. Each “agent” has a profile – the default is “Humanoid” -that behaves in a similar manner to the CharacterController in that it defines a radius, height, slope limit, and step offset. When we “bake” a NavMesh, the voxel analysis determines where this agent could travel on that surface and builds a simplified mesh object that will be the traveling surface. Then, Agents are given a destination and will move according to their speed and rotation settings towards that location, following an optimized path along that mesh.

In the past, navigable areas were designated by using a Navigation Static property, but this has been deprecated in newer versions. Now the “bake” process will occur on objects with the modifier which indicates that this is an element to be included in the baking process. You can include or exclude objects, and you can designate objects as obstacles – such as our barrels – that the agent will avoid but that will not affect the bake of navigable areas. This is done using the NavMeshObstacle component.

To make my zombies work, I add a Navmesh Agent component to our enemy objects, and then a small script to tell that Agent component where to go. If we feed the agent a position, it will automatically move at the designated speed to to that point considering the shortest path and moving around obstacles and other agents.


PlayerFPS.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerFPS : MonoBehaviour
{
    private CharacterController controller;
    private Vector3 playerVelocity;
    public bool groundedPlayer;
    public float playerSpeed = 2.0f;
    public float jumpHeight = 1.0f;
    private float gravityValue = -9.81f;

    // this script pushes all rigidbodies that the character touches
    public float pushPower = 2.0f;

    private void Start()
    {
        controller = GetComponent<CharacterController>();
    }

    void Update()
    {
        // checking for grounded status
        groundedPlayer = controller.isGrounded;
        if (groundedPlayer && playerVelocity.y < 0)
        {
            // gravity reset (hitting the ground)
            playerVelocity.y = 0f;
        }

        // determining direction
        float x = Input.GetAxis("Horizontal");
        float z = Input.GetAxis("Vertical");

        Vector3 move = (transform.right * x) + (transform.forward * z);

        // Vector3 move = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"));
        //move.Normalize();
        controller.Move(move * Time.deltaTime * playerSpeed); // apply the direction

        /* we don't need these anymore
        if (move != Vector3.zero)
        {
            gameObject.transform.forward = move;
        }
        */

        // Makes the player jump
        if (Input.GetButtonDown("Jump") && groundedPlayer)
        {
            playerVelocity.y += Mathf.Sqrt(jumpHeight * -2.0f * gravityValue);
        }

        playerVelocity.y += gravityValue * Time.deltaTime;
        controller.Move(playerVelocity * Time.deltaTime);
    }


    void OnControllerColliderHit(ControllerColliderHit hit)
    {
        Rigidbody body = hit.collider.attachedRigidbody;

        // no rigidbody
        if (body == null || body.isKinematic)
        {
            return;
        }

        // We dont want to push objects below us
        if (hit.moveDirection.y < -0.3)
        {
            return;
        }

        // Calculate push direction from move direction,
        // we only push objects to the sides never up and down
        Vector3 pushDir = new Vector3(hit.moveDirection.x, 0, hit.moveDirection.z);

        // If you know how fast your character is trying to move,
        // then you can also multiply the push velocity by that.

        // Apply the push
        body.velocity = pushDir * pushPower;
    }

}

MouseLook.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MouseLook : MonoBehaviour
{
    public Transform playerBody;
    public float mouseSpeed;

    private float tiltRotation = 0f;

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

    // Update is called once per frame
    void Update()
    {
        float mouseX = Input.GetAxis("Mouse X");
        float mouseY = Input.GetAxis("Mouse Y");

        // rotate horizontally
        playerBody.Rotate(Vector3.up, (mouseX * mouseSpeed * Time.deltaTime));

        // tilt camera up/down
        tiltRotation -= mouseY * mouseSpeed * Time.deltaTime;

        // clamp our rotation
        tiltRotation = Mathf.Clamp(tiltRotation, -90.0f, 90.0f);

        // rotate the camera
        transform.localRotation = Quaternion.Euler(tiltRotation, 0f, 0f);

    }
}

RaycastScript.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class RaycastScript : MonoBehaviour
{
    private AudioSource audio;
    public AudioClip gunshot;


    // Start is called before the first frame update
    void Start()
    {
        audio = GetComponent<AudioSource>();
    }

    // Update is called once per frame
    void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            // make the noise
            audio.PlayOneShot(gunshot, 0.5f);

            // set up our ray
            Transform cameraTransform = Camera.main.transform;

            Ray ray = new Ray(cameraTransform.position, cameraTransform.forward);
            RaycastHit hit;

            // cast the ray
            if (Physics.Raycast(ray, out hit))
            {
                Debug.Log("Raycast hit " + hit.transform.name);

                if (hit.transform.tag == "Barrel")
                {
                    // explode the barrel
                    BarrelScript barrelScript = hit.transform.GetComponent<BarrelScript>();

                    if (barrelScript)
                    {
                        // tell the barrel it has been shot
                        barrelScript.BarrelShot();
                    }
                }
            }
        }
    }
}


BarrelScript.cs
using System.Collections;
using System.Collections.Generic;
using System.Security.Cryptography;
using JetBrains.Annotations;
using UnityEngine;

public class BarrelScript : MonoBehaviour
{
    public GameObject explosionPrefab;
    public GameObject smokePrefab;

    // explosion parameters
    public float forceRadius;
    public float forceApplied;

    public ForceMode forceMode;

    private bool _barrelOnFire = false;

    public void BarrelShot()
    {
        // the barrel is hit - explode the barrel
        BarrelExplode();
    }

    public void BarrelBurned()
    {
        if (_barrelOnFire)
        {
            return;

            // barrel is already weak, explode it
           //  BarrelExplode();
        } else
        {
            // now barrel is on fire
            _barrelOnFire = true;

            // run a coroutine to smoke
            StartCoroutine(BarrelFuse());

        }
    }


    private void BarrelExplode()
    {
        // make the explosion
        GameObject explosion = Instantiate(explosionPrefab, transform.position, Quaternion.identity);

        // queue the explosion for destruction
        Destroy(explosion, 2.0f);

        // destroy this object
        Destroy(this.gameObject);

        // Find all of the colliders within our radius
        Collider[] explodedColliders = Physics.OverlapSphere(transform.position, forceRadius);

        foreach (Collider collider in explodedColliders)
        {
            // apply forces to rigidbodies
            Rigidbody rb = collider.GetComponent<Rigidbody>();

            if (rb)
            {
                // apply the explosive force
                rb.AddExplosionForce(forceApplied, transform.position, forceRadius,10.0f, forceMode);
            }

            // look for barrelscripts
            BarrelScript barrel = collider.GetComponent<BarrelScript>();
            if (barrel) { barrel.BarrelBurned(); } // set it on fire, or cause it to explode

        }

    }

    public IEnumerator BarrelFuse()
    {
        // start the smoke
        Instantiate(smokePrefab, transform);

        // wait for the fuse
        yield return new WaitForSeconds(2.0f);

        // time to die
        BarrelExplode();
    }
}

EnemyChase.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

public class EnemyChase : MonoBehaviour
{
    private NavMeshAgent agent;

    public Transform player;


    // Start is called before the first frame update
    void Start()
    {
        agent = GetComponent<NavMeshAgent>();

        GameObject playerObject = GameObject.FindGameObjectWithTag("Player");
        player = playerObject.transform;

        StartCoroutine(UpdateTarget());

    }

    // Update is called once per frame
    void Update()
    {
        // agent.SetDestination(player.position);
    }

    public IEnumerator UpdateTarget()
    {
        while(true)
        {
            agent.SetDestination(player.position);
            yield return new WaitForSeconds(2.0f);
        }
    }
}