Simplifying Wave Creation with Custom Attributes in Unity
Objective: Easily create and edit the spawn sequence of objects in a wave based on a dynamic list of objects specifically available to that wave.
When approaching this scenario, the initial reaction may be to gravitate towards using enums, lists, or arrays. Obviously, enums are an excellent way to create a selectable dropdown list in Unity’s Inspector, and lists and arrays can be added to with ease.
Unfortunately, using an enum would define the list of objects in code instead of acting as a dynamic list of objects for users to add or remove from, and creating and maintaining a list or an array of objects for each wave could quickly become tedious and prone to errors. Each making it difficult to design, test, and modify how individual waves should perform.
The Setup
In the Hierarchy, a Wave Manager game object holds a singleton WaveManager script which maintains a reference to each wave used in the game via a list. The manager also controls the logic for how and when to process them. An enum is used for the two types of sequences each wave may select from, block and random, which either requests objects in groups (e.g., 5 of x, 3 of y, etc.) or randomly from the list of objects available to that particular wave, respectfully. This block wave sequence is where the editing challenge lies.
The waves themselves are scriptable objects (WaveDataSO) which use the enum declared in the WaveManager to dictate their sequence type and hold the information for those sequences to use, including the list of objects to choose from. These wave objects (WaveObjSO) are also scriptable objects in order to make it easier to add and remove them from the wave.
The Solution
To map the block sequence’s values to what objects are available within the wave, a custom property attribute and drawer can be created which invokes the EditorGUI.Popup() method. This solution is based off a tutorial by URocks! on YouTube which shows a list as a popup, and has been extended to work with this wave system.
As mentioned, the goal of the block wave sequence is to select an object from the wave’s list of available objects and then provide the information for how many and how often those objects should be spawned per block. A string (waveObj
), int (waveCount
), and float (waveFrequency
) variable are used within a BlockWaveSequence class to store the selected object, amount to spawn, and spawn frequency, respectfully.
The custom property attribute (ListPopupAttribute) is created by inheriting from PropertyAttribute and is applied to the waveObj
variable to link the popup selection with the object list. The attribute has a constructor with parameters of type Type and string which reference the WaveDataSO and static list within it containing the popup items (tempList
).
On the WaveDataSO, the ISerializationCallbackReceiver interface is implemented in order to receive a callback before Unity serializes the object.
The interface method, OnBeforeSerialize(), sets a private list of strings (_popupList
) equal to all the names of the wave objects currently associated with that wave using a GetAllWaveItems() return method and then passes that list into the static tempList
. This list must be static in order to use it with the ListPopupAttribute’s constructor.
With the ListPopupAttribute created and successfully applied within the BlockWaveSequence, a custom property drawer (ListPopupDrawer) can then be created to edit and customize how the property is shown in the Inspector.
Note that property drawers are Editor-only code so their scripts should be placed within an Editor folder. If not, the #if UNITY_EDITOR… #endif
directive must be used to encapsulate and conditionally compile the code.
The ListPopupDrawer inherits from PropertyDrawer in the UnityEditor namespace, and uses the CustomPropertyDrawer attribute in order to designate it as the drawer for the ListPopupAttribute. It also only overrides one method, OnGUI(), which can be divided into three parts which are themselves wrapped between EditorGUI.BeginProperty() and .EndProperty().
First, a ListPopupAttribute variable (lpAttribute
) is created and set equal to the PropertyDrawer.attribute getter while a string list (stringList
) is initialized to null. This stringList
will be responsible for holding the values of the tempList
passed into the ListPopupAttribute’s constructor.
Second, the tempList
is checked to see if it’s null, and if not, the previous stringList
is set equal to its values.
Third, the stringList
is checked for a non null value and count greater than 0. If so, the index of the option shown in the field is stored in a selectedIndex
variable which is then passed as the index for the stringList
. The stringList[selectedIndex]
can then be used by the property to set its string value.
However, if the stringList
is null or the count is 0, then the string value of the property is set to the message, “No Objects Available” to prevent a string value from being entered in the Inspector.
With each piece working together, modifying the sequence of a wave system should be easy, efficient, and painless!
Querying the Selection
The last item to address involves the WaveManager knowing which object/item was selected in each block sequence for each wave.
Since these wave objects will be spawning over time, a coroutine can be used which takes a WaveDataSO object as a parameter. A foreach() loop then iterates over each BlockWaveSequence object, that object is queried to match with and return a wave object, and then that wave object is spawned using its pool and item ID along with the count and frequency defined in the current block sequence.