First Blog Post

Posted on 14th December 2014 by

I’ve been thinking about writing something for a while but have never really gotten around to it. I sit down ready to go and an e-mail pops up, or a client comes in, or something critical comes up on the issue tracker.

I finally got an excuse thanks to the guys over at Startup Catalyst, who probably want to see me do something public for a change.

So I trawled through our codebase looking for something interesting, and hopefully something that hadn’t been blogged about a hundred times by other developers. It’s strange the sense of protectiveness you feel when you’re about to expose something to the world, even if it’s as simple as a couple of lines of code. Nothing seems good enough. I flicked past the profile system; it was too big. I skimmed over the stats system; it was too basic. Then I found my old Announcer code, and it was just right.

Background

One of the core non-gameplay features in Pogopalypse is our announcer. We took a leaf out of the holy book of Valve and wantonly copied their approach to the announcer in Dota 2. For people not familiar with Dota 2, the announcer has something to say about virtually every aspect of the game. It serves a practical purpose in that it tells you whats happening, but it manages provides some laughs and occasionally some handy tips while doing so. A number of games (such as Portal, Stanley Parable, and Bastion) have released official ‘announcer packs’ that come with an extra helping of guffaws.

There was no way we were going to get those kinds of games to make announcers for us (unless we ripped their audio, threw it in-game, and got our pants sued off), but we figured we could at least capture the essence of what makes the Dota 2 announcer so great. We locked the talented Michael Abdoo and Luke Bermingham in a room together and told them to make the magic happen or they wouldn’t see the light of day again. A couple of hours, blistered lips, and voices lost forever was all it took. We had our first announcer.

Implementation

The Idea

We had the voices recorded, and in short order we had them postprocessed too. Now we had to actually put them in-game. There were a number of solutions ranging from hacky to sleek, but we were in a rush to get it into alpha for feedback so we compromised and went with the slacky approach.

We chose to write the announcer as a ScriptableObject. A lot of Unity3D programmers have probably never seen this class before, and I really can’t blame them. It’s so niche it doesn’t get a lot of publicity, and it probably doesn’t help that the Unity3D team themselves throw a couple of hurdles in your way if you do decide to use it (for example, you need to write an editor menu function to create it, it doesn’t get added automatically to a list).

What a ScriptableObject does is allow you to handle derivative classes as you would an asset. They are stored in your asset tree as a file, you can set the values of their serializable fields, and you can assign them to the fields of MonoBehaviours just like any other asset. Leveraging the power of ScriptableObjects let us keep our announcer data separate, reusable, and interchangeable. Perfect.

The Code

using System;
using System.Collections.Generic;

using UnityEngine;

sealed public class Announcer : ScriptableObject
{
    //basic cues for audio tracks
    public enum Cue
    {
        Idle,
        Kill,
        Suicide,
        Score,
        Win,
        Lose,
        PlayStart,
        PlayEnd,
        Pause,
        Unpause
    }

    [Serializable] //custom MonoBehaviour fields can only be classes marked Serializable
    sealed public class Track
    {
        public AudioClip Audio
        {
            get
            {
                return audio;
            }

            set
            {
                audio = value;
            }
        }

        //weight against alternative tracks with the same cue
        public float Weight
        {
            get
            {
                return weight;
            }

            set
            {
                weight = value;
            }
        }

        //'unnecessary' time at the end of a track
        //important for long tracks that shouldn't have complete priority
        public float TailTime
        {
            get
            {
                return tailTime;
            }

            set
            {
                tailTime = value;
            }
        }

        [SerializeField]
        private AudioClip audio = null;

        [SerializeField]
        private float tailTime = 0.0f;

        [SerializeField]
        private float weight = 1.0f;
    }

    [Serializeable]
    sealed public class CuedTrack
    {
        public Cue Cue
        {
            get
            {
                return cue;
            }

            set
            {
                cue = value;
            }
        }

        public Track Track
        {
            get
            {
                return track;
            }

            set
            {
                track = value;
            }
        }

        [SerializeField]
        private Cue cue = Cue.Idle;

        [SerializeField]
        private Track track = null;
    }

    public bool IsTrackStoreBuilt
    {
        get
        {
            return trackStore != null;
        }
    }

    //a list of tracks exposed in-editor for adding to the store
    [SerializeField]
    private List<CuedTrack> trackBuffer = new List<CuedTrack>();

    //a dictionary of lists would be more efficient when building the store (less resizes)
    //arrays were used instead as thay had runtime gains, and building the store only happens once
    private Dictionary<Cue, Track[]> trackStore = null;

    public void AddTrack(CuedTrack cuedTrack)
    {
        if (IsTrackStoreBuilt)
        {
            BuildTrackStore();
        }

        Track[] tracks = null;
        if (trackStore.TryGetValue(cuedTrack.Cue, out tracks))
        {
            for (int i = 0; i < tracks.Length; ++i)
            {
                if (tracks[i] == cuedTrack.Track)
                {
                    return;
                }
            }

            AddToArray(cuedTrack.Track, ref tracks);
        }
        else
        {
            tracks = new Track[] { cuedTrack.Track };
        }

        trackStore[cuedTrack.Cue] = tracks;
    }

    //translated editor setup to runtime-usable data structures
    //if the editor could serialize dictionaries, this would have been so much easier
    private void BuildTrackStore()
    {
        trackStore = new Dictionary<Cue, Track[]>();

        for (int i = 0; i < trackBuffer.Count; ++i)
        {
            AddTrack(trackBuffer[i]);
        }
    }

    private void AddToArray<ELEMENT_TYPE>(ELEMENT_TYPE element, ref ELEMENT_TYPE[] array)
    {
        ELEMENT_TYPE[] newArray = new ELEMENT_TYPE[array.Length + 1];

        for (int i = 0; i < array.Length; ++i)
        {
            newArray[i] = array[i];
        }

        newArray[array.Length] = element;

        array = newArray;
    }
}

The Pictures

The Asset Browser
The Asset Browser

The Inspector
The Inspector

Assigned to a Field
Assigned to a Field