DevLog 1:

Bat Out Of Hell

Setup & Packages Used

Programs

  • Unity

  • Blender

  • Cubase Elements (music & other audio)

Unity Packages

  • Unity Input System: the "new" way to manage your inputs. Extremely flexible and adaptable when porting to other systems. Even though we're not going to play this game on any other system than PC - it's still good to have a standardized way of doing things.

  • Amplify Shader Editor: Not only is this an industry standard, but I also learned that this program is the inspiration for both Unity and UnReal's node-based shader editors. Plus, the folks over at amplify are super nice and provide all sorts of support materials and tutorials.

  • TMP Pro Essentials: just some basic pre-built UI components I'm familiar with using.

  • FBX Exporter: This nifty tool lets us export mesh data from Unity (like our grey-boxing primitives) for use in other programs like Blender.

  • Cinemachine: we're going to use this package to handle some features of our 3rd-person camera and our "rail system", which will just be a camera on a dolly.

Player Input

Unity's "New" Input System

We're going to set up a new input actions then create a new action map called "Player". We're going to need to get the mouse movement for aiming the reticle, keyboard inputs for player movement, a button for shooting, and then for good measure we'll also have the escape button act as a quick-quit button that kills the program.

Mouse position will be simply a direct reading of the position on the screen. For movement we'll add an up, down, left, right composite 2d vector which is going to be normalized - we'll attached this to the classic WASD setup. For shooting, we'll just attach that to the left mouse button. The escape button for quit. Finally, we'll save and select "generate c# class”.

Input Manager Class

Now we'll make a folder for scripts, another nested folder for our player scripts, then a new script called "InputManager" I like having a central place to organize and handle inputs. It saves us from constantly enabling/disabling input for different scripts and from trying to remember any complicated relationships between scripts later. This way, all scripts, in any namespace, that need to reference input information simply refer here.

To use Unity's input system we'll need to import InputSystem from the Unity Engine namespace by adding the following line at the top of our new script:

using UnityEngine.InputSystem;

Then we'll bind any methods we need to actions and also create some public methods for reading input.

Player Movement

At this point we've also imported the bat sprite sheet and relevant animations created by Dan for our player to use. We've created an empty "Player" object in our "MainCamera" because eventually we'll be moving the camera along a rail and want the player to always be in front of the camera at a fixed distance. Inside the player object we've got a "Graphics" object which houses our sprite and animator for the sprite. The sprite material is super basic and uses the default sprite shader built into unity because we don't need much beyond that at the moment.

Now we're going to set up player movement, in our "Player" folder for scripts we'll create a new script called "Movement", open it up and put this class in the "Player" namespace so it's not overlapping with any other kind of "movement" class we'll create later.

Basic Movement

The basic movement of the Bat is pretty straightforward: we get the x and y from the players movement input and multiply that by the flySpeed and Time.fixedDeltaTime. We then perform a SmoothDamp() from our current location to the new location we should move to. Finally, we have added methods for keeping the player a set distance from the camera and restricting movement to within the bounds of the viewport. The only special thing we're doing in the movement script is performing our movement calculations in the the Update() call, but executing movement in the FixedUpdate() call, then setting the distance from the camera and handling screen bounds in the LateUpdate() - this is all because we are eventually going to use Cinemachine to control and move the camera along with our player. Cinemachine is going to use its "smart update" algorithm for its update method and "Late Update" for its blend update method. In order to keep the player movement from looking "jumpy" we need to keep all of this in mind while moving the player and determining anything that uses the camera or view as data.

Reticle Movement

The script for controlling the reticle is very similar to the player movement script: calculate the movement desired, then apply the movement using SmoothDamp. In this script we also check if the shoot button is held down to switch the color of the reticle.

Camera Dolly Using Cinemachine

This part is really simple, we just add a cinemachine brain to our main camera, then add a virtual camera and also a cinemachine dolly + cart. On the brain, we just need to make sure our update methods are set correctly. On the virtual camera, we'll set the "follow" field to the new dolly cart, then set the Body to "Tracked Dolly" and the path to our dolly track. We'll also set "camera up" to be path so that the camera rotates with the track if the track rotates. Aim is set to do nothing. Our cart will be set to "update" and "distance" with a speed of 2. Now all we need to do is make some waypoints on our track and Et Viola!

Originally I had crafted my own spline system that would use lerp to find out what position the cart should be at between any collection of at least 4 points in space, but that's when I learned cinemachine already offered a stable and flexible system for cart-and-dolly movement. After adopting cinemachine's system, I created a quick script that controls the speed of the dolly. It also includes public methods for other scripts to call.

Projectiles

VFX

The player is going to shoot at the flying enemies and the enemies are going to shoot back. We need a visual for the projectiles, and to do this we'll use unity's built in particle system. Because this game will be mostly pixel art, we can keep things simple by assigning the default Sprite material to our particles, which will have all the features we need to make our particle look right and also make each particle look like a square, which vibes with our pixel style. To make our projectile, we'll set the particle speed to zero, make some changes to size-over-lifespan and color over lifespan, and finally set the simulation space to "world" instead of local so that when the object moves through the air the particles will trail behind it in a believable fashion. We'll duplicate this particle and parent the duplicate to the original, then make the color slightly darker. This gives the projectile a little more realism and weight than it would otherwise have. We'll duplicate our entire projectile and make one green, for the player, and one purple, for the enemies. Finally, we'll duplicate both of these and set their emission shape to "sphere" and change the speed of the particles so they move out from the center - these will be our BatSpitImpact and DemonShotImpact particles.

Script

Our projectile's going to have collider and rigidbody components for detecting collisions with other objects. Here's how the script works: 1) our player or enemy shooting script will instantiate a new projectile 2) the shooting script will call the public Launch() script on the projectile and pass in an argument for the direction the projectile should go. 3) we send the projectile in the necessary direction by applying force to the rigidbody. 4) If the projectile doesn't hit anything, it will destroy itself after a set period of time. Otherwise, when it hits something, it will check the layer of the object it hit and attempt to grab the Health component (which we'll make in a bit) and then call the Health component function for TakeDamage() and pass it its damage amount in order to hurt the collided entity. 5) Upon collision with any object, we'll instantiate the correct impact particle prefab (that has a self-destroy timer on it) and then destroy our projectile object.

Enemies

Enemy Movement

We want our enemies to move around within the boundaries of the screen and shoot at the player. We also want the enemies to remain a set distance away from the camera at all times. To do this, we'll make sure every enemy is parented to an empty object that acts as a container and is locked to the position and rotation of the main camera. Then, we'll perform all of our calculations for getting screen boundaries and moving the enemies around in local space.

To begin, we'll set up a simple state system for the enemy with two states: moving and idle. Each state will consist of a coroutine that contains all the logic for that state. Changing a state stops the current coroutine, then entering a new state starts the correct coroutine for the new state. To make things simple, we'll set a target position to move to then we'll have one coroutine called MoveToTargetPosition that takes in a duration for the movement and the state to change to once the routine finishes. The coroutine simply moves the enemy to the targetPosition over the duration. For idle, the target position is just the current position.

For calculating the movement and keeping the enemy within screen bounds, we’ll actually use the same logic as our player movement script. Only this time, our target position come rom selecting that random point within local space instead of reading from player input.

Enemy Shooting

To shoot at the player, the enemy shooting script simply picks a random point around the player then instantiates a prefab projectile and calls the launch method on the projectile passing in the direction from the enemy to the point aimed at. The enemy also has a cooldown for being able to shoot at the player. The radius of the area the enemy can randomly select its target from is the enemy’s “accuracy.”

Health

Health Class

Enemies and the player are both going to have health, and both are going to need to be able to take damage and die. Our base Health script contains the relevant fields for health stats, along with public methods for taking damage and death which each call their own virtuals which will get overwritten by derived classes.

Player & Enemy Health

The health components for the player and enemies hold references specific to what they need to control their own audio, animations, or take any other special actions when their health changes or they die. They are similar, but also specific to what each type of entity may need.

Note: the images below include logic for the high score counter and player/enemy animations which we haven’t talked about yet.

Animations

I decided to create a separate component for controlling animations so that the other scripts aren’t cluttered up by the information we need to manage our animations. First, I created a class called AnimationController which contains public static methods for changing between animation states in an animator using the built in CrossFadeInFixedTime() method - it is much more manageable in many cases to simply switch between animation states by script than try to set up and keep track of a bunch of states and animation parameters.

The components for managing player and enemy animations simply use these methods to change animation state.

Enemy Spawning

For spawning flying enemies, we create two new scripts: a spawner and a spawer controller. The spawner is going to reposition itself at a random point along the top and bottom edges of the screen using screen boundary calculations we’ve already seen before in our enemy and player movement scripts. We’ll have multiple spawners all randomly selecting new positions at a regular interval of time.

Then, our spawner controller will count up the number of living enemies in the world at regular intervals, decide if it should spawn any more enemies, and, if it should, select a random spawner and call its Spawn method and pass in the enemy type it wants spawned. At this time, the spawn controller is simply trying to maintain one of each kind of enemy on the screen at a time.

Player Collisions

As a new feature, I though it would make the game better if the player had to avoid crashing into walls as they fly. To facilitate this, I added a quick script that manages the speed of the camera dolly with a public method for moving the camera in reverse for a set number of seconds at a set speed. When the player collides with a wall, it calls it’s own TakeDamage() method on its health component, then calls a method to reverse the camera temporarily, creating this feeling of “bouncing” off the wall.

Player’s Health Bar

The player’s health bar is a UI component made up of heart sprites, each with an on/off state. The animator for the UI has a layer specifically for the health bar, and a parameter for the percentage of health the player still has. The animator then has a 1D blend tree which reads from the PlayerHealth parameter and sets the number of hearts to be shown on screen. The script for the Health Bar uses getters on the player health component to read the player’s current and max health, then sets the animator’s parameter for PlayerHealth.

High Score Counter

The high score counter is a simple text component on the UI canvas with a script that contains public methods for setting the score and adding to the current score. AddToScore and SetScore are written the way they are for specific use-cases. We could always just manipulate the currentScore and update the text accordingly, however due to the possibility of needing to override the score with the score from a previous level (planned), and for add and set to be options available to other components, the organization shown below was chosen.

Environment Shader

A friend of mine, Anthony, has been teaching me some tech art skills. After Dan had made textures for the environment, Anthony taught me how to write a tri-planer shader to project those textures onto environment objects instead of handling each object individually. I plan to write a more in-depth article on how to make a simple tri-planer shader, but for now, here’s the jist of it.

We get the UV information for our world space and feed that into how we project our texture onto our model’s UVs. We’ll do this for each of the 6 directions. We’ll also include a basic setup for texture tiling and offset so that our texture is repeated throughout the world space, and so that we can make the texture more or less dense if we really need to. In the graph below, the middle two rows are accounting for the positive and negative directions of each of the horizontal directions, the top and bottom rows are the y-axis viewing down and the y-axis viewing up respectively.

If apply one of the sampled textures now or only one direction, we’ll see the texture properly applied on one side and it’s opposite, but then stretched along all of the other sides. This “stretching” is essentially just the same image being seen from the edge over and over again along each incremental value of the missing axis. If you’ve ever drawn or seen a closed flip-book from the side and noticed he ink on the edge of the page along each page in the book, that’s a similar idea to what we’re seeing.

We don’t want that stretching to show, so we’ll have to mask it out. The way we do this is by multiplying the RGBA information rom the texture sample by the world normal on the axis that does not belong to the plane we are projecting the texture onto - I.E. the axis perpendicular to the direction we want (which will always have a value of 0). For example, if we’re projecting onto a plane created on the Z and Y axes of the world, we need to multiply by the X of the world normal. The 0 value of X will zero-out our colors projected onto the X direction, effectively masking out what we don’t want: stretchy textures. Since we want a unique texture projected on the top of each object, we’ll also add a quick bit of math to check if we’re looking at the top of the object or not in order to mask out a little more of our top-only texture.

Audio Controller

Audio controller handles both sound effects and music tracks. For sound effects, a new instance of audio controller is instantiated for the sound clip with proper settings, then immediately destroyed after clips played.

For music tracks, we keep a list of a newly created Music class which we’ve made serializable and which holds an ID for the track and a reference to the audio itself. The list of music tracks is then converted into a series of audio source components with some default settings at start. Changing between music tracks is as simple as stopping in starting each audio source and manipulating default settings like volume.

Finishing Touches

At this point, the remainder of features for the game are relatively straight forward.

There are a game controller and a scene controller which manage pausing and unpausing the game along with changing scenes as selected in any menus.

There’s a collider at the end of the level that detects if it should pause the game and show the end of level screen.

There are relevant menus and end credits screens for different occasions.

The music and sound effects were all created in Cubase Elements, a digital audio workstation that I use quite a lot. I have my own selection of sounds, VSTs, and audio plugins that I’ve collected over the years to create all sorts of fun audio work.

Future Plans

The game still needs some features and bugfixes.

  • The player’s aim is changed and made more difficult by the momentum of the player’s movement as the camera moves along its track.

  • Players who bounce off of the environment often move backwards into another piece on environment, bounce off it and die.

  • Enemies need to have a better system for interacting with the environment that allows them to avoid moving backwards into walls or getting stuck in a position where they can’t see the player.

  • Playtesters have requested powerups for the player.