Forays in Unity 3D Game Development…For sh*ts and giggles

Good evening all,

I hope everyone has been having a super, duper New Year so far. In the process of getting back into the swing of things at work and preparing for my impending big day (well, I say impending, June – Nevertheless I’m sure it’ll come around quickly!) I’ve been spending little pockets of time looking at game development. Specifically, rooting around in Unity 3D in the efforts to produce an FPS shooter.

I ended up with a small wedge of cash for Christmas so I did what anyone nowadays does (not save for the wedding, cough cough) and splurged on a few development books. In addition to a book on AI programming and shaders this little number also crossed my path:

Pro Unity Game Development with C#

The content has been pretty damn good by all accounts and the resources provided have been top notch. The parts that have particularly caught my eye in my short time with this so far have been in handling event notifications between game objects and general asset management (i.e. the import and configuration of meshes created in Blender/Maya). The events manager, as implemented here, is an incredibly simple configuration that consists of the following NotificationsManager class:

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

/// <summary>
/// Represents our core Event Handling Class for the 
/// CMOD FPS game.
/// </summary>
public class NotificationsManager : MonoBehaviour
{
    #region Private Data Fields

    //Private dictionary that tracks listeners for given event types
    private Dictionary<string, List<Component>> listeners = new Dictionary<string, List<Component>>();

    #endregion Private Data Fields

    #region Public Methods

    /// <summary>
    /// Ties the provided listener object (component) to the specified
    /// event type. If the event type is not yet being handled then a new dictionary 
    /// entry is created for the event type (notification name) and a new list of components
    /// instantiated ready for additions as required.
    /// </summary>
    /// <param name="sender">The component to be notified of a given event.</param>
    /// <param name="notificationName">The event to tie to the provided listener object.</param>
    public void AddListener(Component sender, string notificationName)
    {
        //Check to see if this notification (event) type is currently stored locally. If not, create a new dictionary entry for it
        if (!listeners.ContainsKey(notificationName))
        {
            listeners.Add(notificationName, new List<Component>());
        }

        //Tie a listener object to the given notification (event) type
        listeners[notificationName].Add(sender);
    }

    /// <summary>
    /// Allow specific listeners to unregistered themselves for a given
    /// event/notification type.
    /// </summary>
    /// <param name="sender">The object that no longer needs to listen for the given event.</param>
    /// <param name="notificationName">The event/notification type to be removed from.</param>
    public void RemoveListener(Component sender, string notificationName)
    {
        //Debug.Log("Removing listeners");

        //See if the notification type is supported currently. If not, then return
        if (!listeners.ContainsKey(notificationName))
        {
            return;
        }

        //Remove 'all' references that match (by instance id) for the given notification type
        listeners[notificationName].RemoveAll(li => li.GetInstanceID() == sender.GetInstanceID());
    }

    /// <summary>
    /// Allow for an event 'poster' to trigger a named method (based on the notification
    /// name) on all listening objects.
    /// </summary>
    /// <param name="sender">The poster who has latched onto an event in the first instance.</param>
    /// <param name="notificationName">The event/notification name (ties to a method name on listening objects).</param>
    public void PostNotification(Component sender, string notificationName)
    {
        //If there are no references based on the notification name then simply return (no work to do)
        if (!listeners.ContainsKey(notificationName))
        {
            return;
        }

        //Notify each, relevant, object of that a specific event has occurred
        listeners[notificationName].ForEach(li =>
        {
            if (li != null)
            {
                li.SendMessage(notificationName, sender, SendMessageOptions.DontRequireReceiver);
            }
        });
    }

    /// <summary>
    /// Removes redundant listeners (to cover scenarios where objects might be removed
    /// from the scene without detaching themselves from events).
    /// </summary>
    public void RemoveRedundancies()
    {
        //Create a new dictionary (ready for an optimised list of notifications/listeners)
        Dictionary<string, List<Component>> tempListeners = new Dictionary<string, List<Component>>();
        
        //Iterate through the notification/listener list and removing null listener objects. only keep a notification/listener dictionary entry if one or more
        //listening objects still exist for a given notification
        listeners.ToList().ForEach(li =>
        {
            li.Value.RemoveAll(listObj => listObj == null);

            if (li.Value.Count > 0)
            {
                tempListeners.Add(li.Key, li.Value);
            }
        });

        //Set the listener dictionary based on the optimised/temporary dictionary
        listeners = tempListeners;
    }
    
    /// <summary>
    /// Removes all listener dictionary references.
    /// </summary>
    public void ClearListeners()
    {
        listeners.Clear();
    }
    
    #endregion Public Methods
}

Using a centralised game manager class (that holds a reference to a single instance of the NotificationsManager class) it’s been surprisingly quick to setup a solid event based system. I want to tweak this implementation however for future projects. As you can see here, this class revolves around a dictionary of type string/Component. This allows for classes registering as ‘listeners’ for a given event to provide a reference to themselves (the component) along with a string representing an event name (which matches a public method implemented on the class registering as a listener). For this to work, posting notifications relies on calling the SendMessage method on listening objects, a process which relies heavily on reflection based on my studies. In a small scale project where limited amounts of messages will be getting passed around this will perform fine (and this has been the case thus far for me). In the long run however (maybe my next project), it seems like a better approach will be to define interfaces and perhaps use a dedicated system structured around delegates. High levels of reflection are not going to cut the mustard when the levels of notifications being passed around start hitting even modest levels.

As far as handling the content pipeline and getting stuck into Lightmapping, Navigation Meshes (for AI Pathfinding) and the construction of Colliders when importing assets the instruction provided has been incredibly easy to follow.

Sprites are provided with the book assets and with a couple of basic scripts it was fairly simple to get a billboard sprite enemies up and running (with flip-book style animations). I’ve worked with 3D models in the past (using the Unity Mecanim system) and have produced rigged and animated models but I’ve really enjoyed utilising some 2D animation techniques (quite a bit of the 2D support now in Unity is quite new so it’s always great to skim over new material like this).

Unity UI.

Unity UI.

The enemies themselves use a basic FSM (Finite State Machine) to manage Patrol, Chase and Attack states as shown below. I’ve defined the class as abstract with the coroutines that handle each enemy state made virtual (to allow certain enemies to override the behaviour as necessary). Surfacing variables in the Unity Editor requires them to be public (and they can’t be properties), something that is driving me a little nuts – Treating as a nuance for the time being:

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

/// <summary>
/// Abstract base class that outlines the 
/// concept of an enemy.
/// </summary>
public abstract class Enemy : MonoBehaviour
{
    #region Public Enums

    /// <summary>
    /// Enum state for the enemy FSM.
    /// </summary>
    public enum EnemyState
    {
        Patrol = 0,
        Chase = 1,
        Attack = 2
    }

    /// <summary>
    /// Enum for enemy types.
    /// </summary>
    public enum EnemyType
    {
        Drone = 0,
        ToughGuy = 1,
        Boss = 2
    }

    #endregion Public Enums

    #region Unity Inspector Public Variables

    /// <summary>
    /// The enemies type.
    /// </summary>
    public EnemyType Type = EnemyType.Drone;

    /// <summary>
    /// Active enemy state (defaulted to patrol).
    /// </summary>
    public EnemyState ActiveState = EnemyState.Patrol;

    /// <summary>
    /// The custom ID of this enemy.
    /// </summary>
    public int EnemyID = 0;

    /// <summary>
    /// Represents the enemies current health.
    /// </summary>
    public int Health = 100;

    /// <summary>
    /// Attack Damage - amount of damage the enemy
    /// deals to the player.
    /// </summary>
    public int AttackDamage = 10;

    /// <summary>
    /// Recovery delay in seconds after launching an attack.
    /// </summary>
    public float RecoveryDelay = 1.0f;

    /// <summary>
    /// Total distance in Unity Units from current position 
    /// that agent can wander when patrolling.
    /// </summary>
    public float PatrolDistance = 10.0f;

    /// <summary>
    /// Total distance the enemy must be from player, in Unity units, before
    /// chasing them (entering chase state).
    /// </summary>
    public float ChaseDistance = 10.0f;

    /// <summary>
    /// Total distance enemy must be from the player before
    /// attacking them.
    /// </summary>
    public float AttackDistance = 0.1f;

    #endregion Unity Inspector Public Variables

    #region Protected Fields

    //Reference to the active PlayerController component for the player
    protected PlayerController playerCont = null;

    //Enemy cached transform
    protected Transform thisTranform = null;

    //Reference to the player transform
    protected Transform playerTransform = null;

    //The Nav Mesh attached to this enemy (for pathfinding)
    protected NavMeshAgent agent = null;

    #endregion Protected Fields

    #region Start

    /// <summary>
    /// Initialisation logic for an enemy.
    /// </summary>
    protected virtual void Start()
    {
        //Retrieve the Nav Mesh Agent for this enemy (cache it)
        agent = GetComponent<NavMeshAgent>();

        //How about? Get reference to player controller by using the controller 'tag'
        playerCont = GameObject.FindGameObjectWithTag("Player").GetComponent<PlayerController>();

        //Get player transform
        playerTransform = playerCont.transform;

        //This enemies transform
        thisTranform = transform;

        //Set default state
        ChangeState(ActiveState);

        GameManager.Notifications.AddListener(this, "SaveGamePrepare");
        GameManager.Notifications.AddListener(this, "LoadGameComplete");
    }

    #endregion Start

    #region Public Methods

    /// <summary>
    /// Change AI State.
    /// </summary>
    /// <param name="ActiveState"></param>
    public void ChangeState(EnemyState state)
    {
        //Maybe consider checking the state? Has it changed?

        //First, stop all currently running AI processing
        StopAllCoroutines();

        //Set new state and activate it
        ActiveState = state;

        //Start coroutines and in each case notify the game object in case we want to handle state change (might not just be here, perhaps in other components)
        switch (ActiveState)
        {
            case EnemyState.Attack:
                {
                    StartCoroutine(AiAttack());
                    SendMessage("Attack", SendMessageOptions.DontRequireReceiver);
                    return;
                }
            case EnemyState.Chase:
                {
                    StartCoroutine(AiChase());
                    SendMessage("Chase", SendMessageOptions.DontRequireReceiver);
                    return;
                }
            case EnemyState.Patrol:
                {
                    StartCoroutine(AiPatrol());
                    SendMessage("Patrol", SendMessageOptions.DontRequireReceiver);
                    return;
                }
            default:
                {
                    return; //Nothing else to do, return
                }
        }
    }

    /// <summary>
    /// Prepare data to save this enemy.
    /// </summary>
    /// <param name="sender">The component sender object.</param>
    public void SaveGamePrepare(Component sender)
    {
        //Create a reference for this enemy
        LoadSaveManager.GameStateData.DataEnemy thisEnemy = new LoadSaveManager.GameStateData.DataEnemy();
        
        //Fill in data for the current enemy
        thisEnemy.EnemyID = EnemyID;
        thisEnemy.Health = Health;
        thisEnemy.PosRotScale.X = thisTranform.position.x;
        thisEnemy.PosRotScale.Y = thisTranform.position.y;
        thisEnemy.PosRotScale.Z = thisTranform.position.z;
        thisEnemy.PosRotScale.RotX = thisTranform.localEulerAngles.x;
        thisEnemy.PosRotScale.RotY = thisTranform.localEulerAngles.y;
        thisEnemy.PosRotScale.RotZ = thisTranform.localEulerAngles.z;
        thisEnemy.PosRotScale.ScaleX = thisTranform.localScale.x;
        thisEnemy.PosRotScale.ScaleY = thisTranform.localScale.y;
        thisEnemy.PosRotScale.ScaleZ = thisTranform.localScale.z;
        
        //Add this enemy to the list
        GameManager.StateManager.GameState.Enemies.Add(thisEnemy);
    }

    /// <summary>
    /// Prepare data to load this enemy.
    /// </summary>
    /// <param name="sender">The component sender object.</param>
    public void LoadGameComplete(Component sender)
    {
        //Cycle through enemies and find matching ID
        List<LoadSaveManager.GameStateData.DataEnemy> enemies = GameManager.StateManager.GameState.Enemies;

        //Reference to this enemy
        LoadSaveManager.GameStateData.DataEnemy thisEnemy = null;

        for (int i = 0; i < enemies.Count; i++)
        {
            if (enemies[i].EnemyID == EnemyID)
            {
                //Found enemy. Now break break from loop
                thisEnemy = enemies[i];
                break;
            }
        }

        //If we can't find this enemy then it must have been destroyed on save
        if (thisEnemy == null)
        {
            DestroyImmediate(gameObject);
            return;
        }
          
        //We've got this far so load the enemy data
        EnemyID = thisEnemy.EnemyID;    //This is set from the inspector so not much point in reloading, keeping with the book code however
        Health = thisEnemy.Health;
            
        //Set position, rotation and scale (position done with warp)
        agent.Warp(new Vector3(thisEnemy.PosRotScale.X, thisEnemy.PosRotScale.Y, thisEnemy.PosRotScale.Z));
        thisTranform.localRotation = Quaternion.Euler(thisEnemy.PosRotScale.RotX, thisEnemy.PosRotScale.RotY, thisEnemy.PosRotScale.RotZ);
        thisTranform.localScale = new Vector3(thisEnemy.PosRotScale.ScaleX, thisEnemy.PosRotScale.ScaleY, thisEnemy.PosRotScale.ScaleZ);
    }

    #endregion Public Methods

    #region Coroutines

    /// <summary>
    /// AI method that handles attack behaviour for the enemy.
    /// Can exit this state and enter either patrol or chase.
    /// </summary>
    /// <returns>IEnumerator.</returns>
    public virtual IEnumerator AiAttack()
    {
        //Stop agent, ready for a new instruction
        agent.Stop();

        //Elapsed time, to calculate strike intervals (set to recovery delay so an attack is possible immediately after the enemy closes distance)
        float elapsedTime = RecoveryDelay;

        //Loop forever while in the attack state
        while (ActiveState == EnemyState.Attack)
        {
            //Update elapsed time
            elapsedTime += Time.deltaTime;

            //Check distances and state exit conditions
            float distanceFromPlayer = Vector3.Distance(thisTranform.position, playerTransform.position);

            //If outside of chase range, then revert to patrol
            if (distanceFromPlayer > ChaseDistance)
            {
                ChangeState(EnemyState.Patrol);
                yield break;
            }

            //If outside of attack range, then change to chase
            if (distanceFromPlayer > AttackDistance)
            {
                ChangeState(EnemyState.Chase);
                yield break;
            }

            //Make strike
            if (elapsedTime >= RecoveryDelay)
            {
                elapsedTime = 0;
                SendMessage("Strike", SendMessageOptions.DontRequireReceiver);
            }

            //Wait until the next frame
            yield return null;
        }
    }

    /// <summary>
    /// AI method that handles attack behaviour for the enemy.
    /// Can exit this state and enter either attack or patrol.
    /// </summary>
    /// <returns>IEumerator.</returns>
    public virtual IEnumerator AiChase()
    {
        //Stop agent, ready for a new instruction
        agent.Stop();

        //Whilst we are in the chase state then loop forever
        while (ActiveState == EnemyState.Chase)
        {
            //Set destination to the player
            agent.SetDestination(playerTransform.position);

            //Check the distance between the enemy and the player to look for state changes
            float distanceFromPlayer = Vector3.Distance(thisTranform.position, playerTransform.position);

            //If within attack range then alter to that state
            if (distanceFromPlayer < AttackDistance)
            {
                ChangeState(EnemyState.Attack);
                yield break;
            }

            //Enemy is out of range to chase, revert to the patrol state
            if (distanceFromPlayer > ChaseDistance)
            {
                ChangeState(EnemyState.Patrol);
                yield break;
            }

            //Wait until the next frame
            yield return null;
        }
    }

    /// <summary>
    /// AI method that handles attack behaviour for the enemy.
    /// Can only enter the chase state from here (once the distance
    /// to the player closes sufficiently).
    /// </summary>
    /// <returns>IEnumerator.</returns>
    public virtual IEnumerator AiPatrol()
    {
        //Stop agent, ready for a new destination
        agent.Stop();

        //Loop forever whilst in the patrol state
        while (ActiveState == EnemyState.Patrol)
        {
            //Get random location destination on the map (somewhere within a sphere with a radius based on PatrolDistance (center at zero))
            Vector3 randomPosition = (Random.insideUnitSphere * PatrolDistance);

            //Add as offset from current position
            randomPosition += thisTranform.position;

            //Get nearest valid position (on the Nav Mesh)
            NavMeshHit hit;
            NavMesh.SamplePosition(randomPosition, out hit, PatrolDistance, 1);

            //Set destination for this enemy
            agent.SetDestination(hit.position);

            /*
             * Set distance range between object and destination to classify as 'arrived' +
             * Set timeout before new path is generated (as a fail safe, destination too far or enemy could be having difficulty reaching the location) +
             * Create a elapsed time float to measure against the timeout
            */
            float arrivalDistance = 2.0f, timeOut = 5.0f, elapsedTime = 0;

            //Wait until the enemy reaches the destination or times-out, then get the new position
            while (Vector3.Distance(thisTranform.position, hit.position) > arrivalDistance && elapsedTime < timeOut)
            {
                //Update elapsed time
                elapsedTime += Time.deltaTime;

                //Can only enter chase (once the distance closes sufficiently). Can then move to other FSM states from there
                if (Vector3.Distance(thisTranform.position, playerTransform.position) < ChaseDistance)
                {
                    ChangeState(EnemyState.Chase);
                    yield break;
                }

                yield return null;
            }
        }
    }

    #endregion Coroutines
}

Coroutines (that have a return type of IEnumerator) are a useful feature within the Unity engine that allow you to mimic asynchronous behaviour. When previously using Unity, I would definitely be guilty of cramming all of the logic into the Update method (run every frame) which invariably lead to performance issues. Using an event based system and coroutines it’s been possible (in most cases) to abstract heavy lifting code out of areas where it gets hit every frame and is instead called when required (or as a side-line to the main thread).

This class also showcases the use of a NavMeshAgent. During the patrol state (where the most interesting implementation lies) a location is ‘sampled’ and then the nearest, valid position found on the scene Navigation Mesh is chosen as the enemy destination. When the destination is reached, within a certain tolerance range (time out also provided just in case), the enemy marches on his way to the next generated destination (until the player is found and a different state is initiated or the enemy is killed). This works surprisingly well.

I’ve got a book that talks through more advanced scenarios using Behaviour Trees which I’ll try and implement in a future project.

In all honesty, I think that’s probably where I’m headed. The assets provided in this book allow the rapid creation of a modular level environment and to this end the internet has been scoured for tutorials on creating this in Blender (including environment texturing and UV creation). I’ve picked a direction for this blog at long last, hoorah! A preliminary promise of sorts then…I aim to cover (not all strictly code based but indulge me!):

  1. Stick with the FPS formula and create a new project from scratch.
  2. Undertake some 3D content creation in Blender (modular environments and UV creation). I don’t have a clue what I’m doing here so I expect that to be damn funny to witness!
  3. Keep with the 2D sprite setup for enemies, perhaps making a set of 5 or 6 enemy types and at least 3 weapon types.
  4. Make a handful of 3D game world assets using Blender.
  5. Implement an enhanced NotificationsManager in Unity.
  6. Get all of the content into Unity and make a multi-level FPS shooter.
  7. Investigate the new UI components in Unity (to build a dynamic GUI of some sort).
  8. Produce my own sound content. I’ve tried this already via some online music/sound effect creation tools, the results were not good. If my brother is reading this he’ll know that I’ve never been all that musical and is probably cringing at the thought of anything knocked up by me in this department!

Big promises eh! Let’s see what gets delivered. I’m not expecting an AAA experience to fall out of the dredges of my file system and arrive on the doorstep of steam, this will just be for fun!

I’ll leave you with a few screenshots taken from the (pretty much finished) product. Enjoy!

Gun-play in action.

Gun-play in action.

Getting beaten around the head by an enemy drone.

Getting beaten around the head by an enemy drone.

Options GUI.

Options GUI.

Collecting cash before getting gunned down.

Collecting cash before getting gunned down.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s