GameDevHQ — Day 32 — NovaStar Dev Diary: Abstract Classes and Picking up where we left off
Aloha!
It’s been a while and as development of the NovaStar project continued in full swing keeping up with the workload along with some other commitments meant I had to put the daily dev diary posts on hold for a few weeks. With my schedule opening up again I can now resume providing updates on our progress for the project as well as my own personal pursuits. The good news is that in the time I have been away from these posts we were able to finish development of the 1.0 version of the game and publish it on itch.io. You can play this published version at the following link:
I want to make up for the lost time by covering some of the developments that have happened over the last few weeks and detail the part I played in the development and how we were able to get to our finished published product over a few separate posts. Thank you all for sticking around.
Where we last left off back on December 9th I was detailing the work that went into creating the speed cruiser enemy type. This enemy was created with it’s own script and worked in isolation to the rest of the enemies that the other members of the team created. Once we had a version of the game that combined all of the changes we made on our local versions into one project file we set to figuring out a way to make designing and implementing enemies faster and more efficient. Our solution to this problem was to develop an enemy abstract class.
What is an abstract class?
To start off I feel we should go over what an abstract class is an how to implement one as this is a very useful tool that you can implement into your own development. An abstract class is a class that can be used as the base class for other classes, similar to how MonoBehaviour is used for most classes. When used as the base for a class the newly created class that calls upon the abstract will have access to all variables and methods that exist within the abstract class. If the newly created class needs to perform any actions that are unique to itself, then there exists the ability to override any specific part of the abstract class that needs to be replaced while keeping the rest that are still useful to the script’s operation.
To create an abstract class the modifier “abstract” has to be included in the class declaration in the following format:
public abstract class EnemyAbstractClass : Monobehaviour
From this point you can now write the rest of the class as if it was a regular script to allow it to perform operations that you feel are actions that are necessary for the baseline performance. For example since this class will be acting as the foundation for almost all enemy types in the game we provide the class with a few different methods such as movement, damage calculations, powerup spawning, collision detection, hit invulnerability, and onscreen detection. These methods need to be given the “protected” modifier on declaration to allow them to only be accessible in a derived class that uses the abstract as a base.
Once the abstract class is created we can then use this as the baseline for any new enemy script that we make to allow each of our enemies to have unique capabilities while also operating on a similar foundation. To set the abstract class as the base for a new class it is written where MonoBehaviour usually is when declaring a class like what is shown below:
public class Enemy_SpeedCruiser : EnemyAbstractClass
This means that if we were to add nothing to the script besides that declaration and then apply it to a gameobject it should then operate exactly as the abstract class. If we needed to give the inheriting object unique methods, such as the stop and start movement of the speed cruiser or a unique firing method, we have to override any methods that share the same name between the abstract class and the inheriting class. To do this we use the modifier “override” in the declaration for any methods that are being declared in the inheriting class while already existing in the abstract class. For example, if I wanted the inheriting class to perform different actions in the Start() method than what is being done in the abstract class we would write the Start() method as:
protected override void Start()
Doing so means that on execution the script will only perform the actions specified in the version of the method created in the inheriting class rather than the abstract class.
However what if we want to use all the functions that are being used in the Start() method of the abstract class and simply want to add new functionality to go along with them in the inheriting class? There are two ways we could do this, the first and less time efficient way of doing it would be to override the Start() method as we did before and then also rewrite all of the contents from the Start() method in the abstract class into the inheriting class along with the new actions we want to add. This method works but can leave room for error and takes more time to do, especially if we decide to make any changes to the abstract class we would have to retype it into all of the inheriting classes. Instead what we can do is use the command “base” within the Start() method of the inheriting class to include all of the contents of the abstract class version of the Start() method alongside any new changes we want to add. To do so you must use the command “base.” then the name of the method from the abstract class that you want to include. For example if we wanted to include the contents from the abstract Start() method into the inheriting Start() method, you would write the following line inside the inheriting Start() method.
base.Start();
This means that we can include everything we need from the abstract class with one line of code and make any changes we need that are specific to the inheriting class. Additionally if there are any changes made to the abstract class, they will automatically be included into any inheriting class that may be using an override.
Developing the abstract class
With that explanation out of the way we can now go over what went into our enemy abstract class to serve as what we felt was a solid foundation for most of our enemy types. The final version of the abstract class went over multiple revisions and we will most likely add even more if we want to expand our enemy capabilities. To create a fully functioning enemy type we needed to provide it with a few core capabilities. The main four being that the enemy needed to be able to:
- move from right to left
- fire their weapon
- detect collisions
- calculate damage
Each of these functions required their own method and worked on fairly simple logic. For the movement we simply ran a Translate() call to move the object forward at a rate determined by the value given by the _speed variable.
Weapon firing operated similarly to the 2D Space Shooter project where the weapon will instantiate a laser object after a certain amount of time has passed since the enemy spawned or since the last shot was fired.
Collision detection was handled using OnTriggerEnter to look for any collisions with the player’s shots or character. If either of these collisions occur then the enemy runs its Damage() method to update its health values and destroy the enemy if its health reaches 0.
With these four functions implemented we would be able to spawn a basic enemy that could attack the player and be destroyed. Following this we can then add features that are more custom fit to other mechanics that we have planned for the game such as power up spawning on death. One of the major additions that was made to accommodate another aspect of the game was the ability to receive damage by a weapon that deals multiple hits over a period of time such as the charge laser. This required using the OnStriggerStay() function which activates on every frame that the assigned object is colliding with something and then specifying that it will only perform damage calculations when colliding with an object with the specific tag “Beam”. This was needed as OnTriggerEnter() only runs on the single frame that the collision first happens which means that if this was used instead of OnTriggerStay() the damage would only be calculated when being hit by the laser once, as opposed to receiving damage as long as the enemy stays in contact with the laser. However the issue side effect of using OnTriggerStay() is that it runs its actions for every frame that the collision is taking place, meaning that if we simply tell it to take 1 point of damage every time it detects a collision we would run into the issue of the enemy taking 1 point of damage on every frame that it is colliding with the laser. This is an undesired behavior as this rate of damage is incredibly fast. To fix this we then apply a period of invulnerability where damage is not dealt by the beam to the enemy that is in contact to it. This delay is handled on the enemy’s end to allow these invincibility windows to happen to each possible enemy independently rather than disabling the laser’s ability to deal damage entirely every 0.3 seconds it comes in contact with an enemy. To do this we activate a coroutine every time a damage calculation is run due to a beam collision that activates a boolean that prevents any further damage calculations or coroutines from being conducted while it is active. With this implemented we now have the functionality to make every enemy compatible with the charge laser weapon.
The other important mechanic that we added was the ability for every enemy to have a chance to spawn a power up upon being destroyed that the player can collect. To determine this we would randomly generate a value between one and six where if the number chosen is equal to one then the script will instantiate a power up once the enemy is destroyed.
The final behavioral addition that was made to the abstract was one that was an attempt to prevent possible problematic interactions between the player and the enemies. The interaction in this case was the ability for the player to destroy an enemy that spawns off screen before it is even visible. To remedy this we also implemented a function that runs in the Update() block that activates a boolean called “_onScreen” as long as the object is between the x-axis values of -33 and 33, which is the size of the visible play area. If the boolean is deactivated then no damage is calculated when the enemy comes into contact with an player shot.
Abstract Class Implementation and Variation
With this abstract class in place we then set about implementing it into the different enemy types. In our 1.0 build for the game we used this abstract class to act as the foundation for every enemy type in the game except for the mid-boss and final boss. Each had some overrides to provide more tailored variation to their behavior such as the hit and run behavior of the speed cruiser or the ramming and player tracking behavior of the charging ships. The extensive amounts of serialized fields also allowed us to easily customize the details of the each enemy prefab quickly and easily within the Unity editor which made experimentation with balance tweaks a breeze.
That about wraps it up for today’s recap of the programming that we performed for the project. Tomorrow I will be covering our how we used singletons to implement the various sounds of the game to make our game sound more lively while also allowing us to give control of the audio to the player. Until then mahalo for reading and I will see you next time. Aloha!
— Kurt