Developed by myself as part of the coursework for the Computer Games Architecture module of my Computer Games Technology MSc degree.
The purpose of this module was to develop a game engine using XNA as opposed to focusing on the quality of gameplay so that the game can easily be adapted through minimal code changes to achieve big changes.
The objective of the game is to avoid the enemies’ line of sight while sneaking round the level to reach the exit while picking up loot, which increases your speed and score, and taking out enemies along the way. Attacking an enemy from behind will take them out instantly, but if they spot you first, you’ll be taken out, and you lose the game with, with your score being saved to the leaderboard if good enough.
Levels are loaded in from text files which are created using specific characters denoting what should appear in that space. As long as each line in the text file is the same length and it includes all necessary components, a level will be created with obstacles automatically adapting to the resolution of the text file and screen. If you watch the video and see the following text files, you can see how they are formed (key: s = player’s ship, b = block, c = crate of loot, e r l d = enemies and anything else will be a space): Level 1, level 2 and level 3.
A more in-depth technical look:
Level assets are loaded in on a per level basis. When a new Level is created in the game world, a new ContentManager is created. All assets for that level are then loaded using that ContentManager, except for the player which persists across the different levels because the player can pick up powerups etc. When the player loses or reaches the end of a level, the level is disposed of by resetting the world (clearing the lists of objects) and unloading the current ContentManager, before loading the next level with its new ContentManager.
In the main game class I created a CommandManager which handles keyboard and mouse input. Keyboard and mouse bindings are added to dictionaries in the command manager by specifying the input and game action this input should lead to, and the input is also added to the input listener. The input listener then checks the state of the inputs that have been bound to game actions and fires off events based on their state. Keyboard and mouse event args classes are used to specify the states of these inputs.
The command manager will then check the list of bindings to see which input has been triggered, for example pressing the P key, and firing off the appropriate event using that key as an index and passing the state of that input, so in this example the keyboard button state is down, and an amount, which for keyboard input is simply a new Vector2(1.0f) because a key cannot be partly down. The appropriate event, so pressing P pauses, will check the button state is what we expected before doing what it should do. Additional inputs therefore only need to be initialised by adding a binding to the command manager in the main game class.
In the Level class is a CollisionManager which handles collisions. The various game objects that can be collided all inherit from the Collidable class which has a pair of virtual functions to be overriden by the various game object classes which are CollisionTest, which is used to test if a collision has occurred, and OnCollision, which is used when two objects have collided to handle the collision.
When the level is initialised, the collidable game objects are added to the collision manager. In the collision manager is two lists, one with all collidable objects, and another with just the ones that can move, which is used later to prevent checking collisions between static objects such as walls.
When the level is updated (each tick), the collision manager is updated which empties the list of collisions that we want to resolve (so that we don’t handle collisions from the previous pass) and then steps through the list of colliders (non-static collidables), and then for each collider we step through all collidable objects, make sure it isn’t the same object as from the first loop, then check if they are colliding using the CollisionTest function, and if they are, create a new collision object using those two objects as parameters and add that collision to a hash set of collisions.
Note: The CollisionTest function checks if the object is a block or an enemy ship in order to pass the appropriate parameter when checking the ship’s bounding sphere is intersecting the opposing object as blocks use bounding boxes and ships use bounding spheres. If they are intersecting, we return true.
Now that we have verified if any collisions have occurred, we step through our collisions and resolve them. Resolve calls OnCollision, which as I mentioned earlier is overriden by the appropriate object class. For this game, ships are the only moving objects and therefore are the only objects which need an OnCollision function (with enemies inheriting from ship). Firstly we check if we are colliding with a block or an enemy, because they are handled differently again because one uses a bounding box and the other a bounding sphere. The basis is the same however; We calculate the distance between the two objects and how far apart they should be at a minimum, work out the difference, and then we have a distance of how far into the object they have penetrated. We then calculate the normal for the collision, apply a force on the collider based on that direction and the penetration depth.
A couple of extra things happen on top of simply preventing objects from overlapping. Firstly, enemies will turn around when they reach a wall so that they keep patrolling a specific corridor back and forth. Also, when a player collides with an enemy, we don’t need to bounce off them, but instead we call game over or kill off the enemy depending on whether or not that enemy had seen the player first.
The game levels and key information about the game is all data driven.
To start with, the levels are loaded from text files which use key characters to indicate where objects should be placed within the scene. Certain objects are even scaled based on the size of the level, such as wall blocks (you’ll notice all three levels are different shapes / sizes). When a level is loaded I use a custom Loader class to read lines from the text file which returns a list of strings, one for each line in the text file. We then store the length of the first line (every line needs to be the same length so it’s easiest just picking the first) and count the number of lines which is simply the number of strings in the list for use later.
Next we step through the list of strings (for each row) and each character in the string (for each column in that row) and read the value of the character in that position. Using that character we have just read we put it through a switch to see if it is one we have assigned to an object in our game world and create the appropriate game object for that character and set the position (which we calculate using the position in the text file and the world limits), before finally adding the object to any appropriate lists.
A number of key pieces of information about the game world are stored in an xml file so that they can easily be updated without having to alter code, for example the player’s speed and enemy speeds for chasing or when idle. In the constructor for the Ship class (and others too but using the ship as example) a number of fields are updated by being read from the xml file.
The game states are handled using an enum with just a few possible states. In the Update and Draw methods I use a switch to check the current game state and determine what should be done in this state, so on the main menu we don’t want to update the game world but simply draw some buttons and the high scored and handle input.
Enemies are controlled using a finite state machine which handles what state they are in and when they change state. Each enemy has their own state machine which takes a list of states, each with their own transitions. Each transition is checked for each state on each update tick to see if the condition for switching to that state is met, and if it is we then trigger the exit function of the current state, change to the new state, trigger the enter function of the new state, and execute that new state, otherwise we simply execute the current state. When an enemy is created they are initialised into the idle state which sees them slowly patrolling back and forth in a straight line from where they start using the idle speed set in the xml loaded at the start of the game. There are two possible states for each enemy, which are idle and chase, and the state machine has transitions between the two based on a boolean called IsTagged. When the enemy IsTagged, they change from idle to chase, and vice versa. Adding new states with new conditions requires simply creating new state classes and adding the transition the enemy class state machine.
Each enemy has a ray cast from their position in the direction they are facing, and that ray is checked on each tick to see if the enemy can see the player. This is done by seeing if the ray intersects each block on the level, and for any block it does intersect, it compares the distance to that block with the previous nearest block so we can see how far the nearest block in front of the enemy is. We then also check to see if the ray intersects the player, and finally compare the distance to the player to the nearest block to see if the player is closer than the nearest block and thus can be seen by the enemy. If this is the case, then the PlayerSpotted function is called, in turn calling the Tag function which updates the IsTagged boolean to true which triggers the chase state. Upon entering the ChaseState the enemy’s velocity is changed to the chase speed set in the xml loaded at the start of the game and their target is set to the player. While in the chase state the enemy’s direction is constantly update to point towards the player by normalising the difference in the position of the two ships. This will cause the enemy to constantly chase the player.