This chapter will describe a simple game, develop a basic plan, and then cover its implementation. The implementation will be done in a pragmatic iterative style. A high-level first pass will get the game structure working. This will then be refined until it approaches the original description of the game.
A simple game will demonstrate all the techniques that have been covered so far. We’ll build a 2D scrolling shooter game. This type of game is quite simple to make and then easy to expand by adding more features. The pragmatic way to develop this game is to create a working game as quickly as possible, but it’s still important to plan up front what these first stages will be. Figure 10.1 shows a high-level overview of the game flow.
The idea for this game is to create a simple yet complete game. Therefore, there will be a start screen, then an inner game, and finally a game over screen. The inner game will have a spaceship the player can move. The player should be able to press a button to fire a bullet that comes out of the front of the ship. There only needs to be one level, but more would be nice. The level will begin when the player goes from the start state to the inner game. After a set amount of time, the level ends. If the player is still alive at the end of the level, then that counts as a win; otherwise, the player loses the game. The level needs a number of enemies that advance towards the player and are able to shoot bullets. Enemies can be given a health value and take multiple shots to be destroyed. When destroyed they should explode.
By reading this quick description of the game, it’s easy to start building up a list of classes and interactions. A good way to start the technical plan is to draw some boxes for the main classes and some arrows for the major interactions. We’ll need three main game states, and the inner game state will be the most complicated. By looking at the game description, you can see that some of the important classes needed include Player
, Level
, Enemy
, and Bullet
. The level needs to contain and update the players, enemies, and bullets. Bullets should collide with enemies and players.
The inner game is where the player will fly the spaceship and blow up the oncoming enemies. The player ship will not actually move through space; instead, the movement will be faked. The player can move the player anywhere on the screen but the “camera” will stay fixed dead center. To give the impression of speeding through space, the background will be scrolled in the opposite direction the player is traveling. This greatly simplifies any player tracking or camera code.
This is a small game so we can start coding with this rather informal description. All game code goes in the game project and any code we generate that might be useful for multiple projects can go in the engine library. A more ambitious game plan might require a few small test programs—game states are very good for sketching out such code ideas.
The high-level view has broken the game down into three states. This first coding pass will create these three states and make them functional.
Create a new Windows Forms Application project. I’ve called the project Shooter, but feel free to choose whatever name you want. You are probably familiar with how to set up a project, but here is a quick overview. The solution will be set up in a very similar way to the EngineTest project in the previous chapters. The Shooter project uses the following references: Tao.DevIL, Tao. OpenGL, Tao.Platform.Windows, and System.Drawing. It will also need a reference to the Engine project. To do this, the Engine project should be added to the solution (right-click the Solution folder, choose Add > Existing Project, find the Engine project, and select it). Once the Engine project exists in the solution then the Shooter project can add it as a reference. To add the Engine project as a reference right-click the Shooter project references folder and choose Add Reference, navigate to the Projects tab, and choose the Engine project.
The Shooter project will use OpenGL, so in the Form editor, drag and drop a SimpleOpenGLControl
onto the form and set its Dock
property to “Fill.” Right-click the Form1.cs and choose View Code. This file needs a game loop and initialization code added, which is supplied below.
using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Drawing; using System.Linq; using System.Text; using System.Windows.Forms; using Engine; using Engine.Input; using Tao.OpenGl; using Tao.DevIl; namespace Shooter { public partial class Form1 : Form { bool _fullscreen = false; FastLoop _fastLoop; StateSystem _system = new StateSystem(); Input _input = new Input(); TextureManager _textureManager = new TextureManager(); SoundManager _soundManager = new SoundManager(); public Form1() { InitializeComponent(); simpleOpenGlControl1.InitializeContexts(); _input.Mouse = new Mouse(this, simpleOpenGlControl1); _input.Keyboard = new Keyboard(simpleOpenGlControl1); InitializeDisplay(); InitializeSounds(); InitializeTextures(); InitializeFonts(); InitializeGameState(); _fastLoop = new FastLoop(GameLoop); } private void InitializeFonts() { // Fonts are loaded here. } private void InitializeSounds() { // Sounds are loaded here. } private void InitializeGameState() { // Game states are loaded here } private void InitializeTextures() { // Init DevIl Il.ilInit(); Ilu.iluInit(); Ilut.ilutInit(); Ilut.ilutRenderer(Ilut.ILUT_OPENGL); // Textures are loaded here. } private void UpdateInput(double elapsedTime) { _input.Update(elapsedTime); } private void GameLoop(double elapsedTime) { UpdateInput(elapsedTime); _system.Update(elapsedTime); _system.Render(); simpleOpenGlControl1.Refresh(); } private void InitializeDisplay() { if (_fullscreen) { FormBorderStyle = FormBorderStyle.None; WindowState = FormWindowState.Maximized; } else { ClientSize = new Size(1280, 720); } Setup2DGraphics(ClientSize.Width, ClientSize.Height); } protected override void OnClientSizeChanged(EventArgs e) { base.OnClientSizeChanged(e); Gl.glViewport(0, 0, this.ClientSize.Width, this.ClientSize. Height); Setup2DGraphics(ClientSize.Width, ClientSize.Height); } private void Setup2DGraphics(double width, double height) { double halfWidth = width / 2; double halfHeight = height / 2; Gl.glMatrixMode(Gl.GL_PROJECTION); Gl.glLoadIdentity(); Gl.glOrtho(-halfWidth, halfWidth, -halfHeight, halfHeight, -100, 100); Gl.glMatrixMode(Gl.GL_MODELVIEW); Gl.glLoadIdentity(); } } }
In this Form.cs code, a Keyboard
object is created and assigned to the Input
object. For the code to work a Keyboard
member must be added to the Input
class as below.
public class Input { public Mouse Mouse { get; set; } public Keyboard Keyboard { get; set; } public XboxController Controller { get; set; }
The following DLL files will need to be added to the binDebug and binRelease folders: alut.dll, DevIL.dll, ILU.dll, ILUT.dll, OpenAL32.dll, and SDL.dll. The project is now ready to use for developing a game.
This is the first game we’ve created, and it would be nice if the form title bar said something other than “Form1” when the game was running. It’s easy to change this text in Visual Studio. In the solution explorer double-click the file Form1.cs; this will open the form designer. Click the form and go to the properties window. (If you can’t find the properties window then go to the menu bar and choose View > Properties Window.) This will list all the properties associated with the form. Find the property labeled Text, and change the value to Shooter, as shown in Figure 10.2.
The first state to create is the start menu. For a first pass, the menu only needs two options: Start Game and Exit. These options are a kind of button; this state therefore needs two buttons and some title text. A mock-up for this screen is shown in Figure 10.3.
The title will be created using the Font
and Text
classes defined earlier in the book. There’s a font on the CD called “title font”; it’s a 48pt font with a suitably video game look. Add the .fnt and .tga files to the project and set the properties of each so that they are copied to the bin directory when the project is built.
The font file needs to be loaded in the Form.cs. If we were dealing with many fonts, it might be worth creating a FontManager
class, but because we’re only using one or two they can just be stored as member variables. Here is the code to load the font files.
private void InitializeTextures() { // Init DevIl Il.ilInit(); Ilu.iluInit(); Ilut.ilutInit(); Ilut.ilutRenderer(Ilut.ILUT_OPENGL); // Textures are loaded here. _textureManager.LoadTexture("title_font" "title_font.tga"); } Engine.Font _titleFont; private void InitializeFonts() { _titleFont = new Engine.Font(_textureManager.Get("title_font"), FontParser.Parse("title_font.fnt")); }
The font texture is loaded in the IntializeTextures
function and this is used when the font object is created in the IntializeFonts
method.
The title font can then be passed into the StartMenuState
constructor. Add the following new StartMenuState
to the Shooter project.
class StartMenuState : IGameObject { Renderer _renderer = new Renderer(); Text _title; public StartMenuState(Engine.Font titleFont) { _title = new Text("Shooter", titleFont); _title.SetColor(new Color(0, 0, 0, 1)); // Center on the x and place somewhere near the top _title.SetPosition(-_title.Width/2,300); } public void Update(double elapsedTime) { } public void Render() { Gl.glClearColor(1, 1, 1, 0); Gl.glClear(Gl.GL_COLOR_BUFFER_BIT); _renderer.DrawText(_title); _renderer.Render(); } }
The StartMenuState
uses the font passed into the constructor to make the title text. The text is colored black, and then it’s centered horizontally. The render loop clears the screen to white and draws the text. To run the state it needs to be added to the state system and set as the default state.
private void InitializeGameState() { // Game states are loaded here _system.AddState("start_menu", new StartMenuState(_titleFont)); _system.ChangeState("start_menu"); }
Run the program and you should see something similar to Figure 10.4.
This stage is just a first pass. The title page can be refined and made prettier later. At the moment, functionality is the most important thing. To finish the title screen, the start and exit options are needed. These will be buttons, which means a button class will need to be made.
The buttons will be presented in a vertical list. At all times one of the buttons will be the selected button. If the user presses Enter on the keyboard or the A button on the gamepad, then the currently selected button will be pressed.
A button needs to know when it’s selected; this is also known as having focus. Buttons also need to know what to do when they’ve been pressed; this is a great place to use a delegate. A button can be passed a delegate in the constructor and call that when it’s pressed, executing any code we want. The button will also need methods to set its position. These requirements for the button describe something like the following class. The Button
class is reusable so it can be added to the Engine project so it may be used in future projects.
public class Button { EventHandler _onPressEvent; Text _label; Vector _position = new Vector(); public Vector Position { get { return _position; } set { _position = value; UpdatePosition(); } } public Button(EventHandler onPressEvent, Text label) { _onPressEvent = onPressEvent; _label = label; _label.SetColor(new Color(0, 0, 0, 1)); UpdatePosition(); } public void UpdatePosition() { // Center label text on position. _label.SetPosition(_position.X - (_label.Width / 2), _position.Y + (_label.Height / 2)); } public void OnGainFocus() { _label.SetColor(new Color(1, 0, 0, 1)); } public void OnLoseFocus() { _label.SetColor(new Color(0, 0, 0, 1)); } public void OnPress() { _onPressEvent(this, EventArgs.Empty); } public void Render(Renderer renderer) { renderer.DrawText(_label); } }
The button
class doesn’t directly handle the user input; instead, it relies on whichever piece of code uses it to pass on relevant input events. The OnGain-Focus
and OnLoseFocus
methods will be used to change the appearance of the button depending on the focus. This will let the user know which button he currently has selected. When the button position is changed, the label text position is also updated and centered. EventHandler
is used to hold the function that will be called when the button is pressed. EventHandler
describes a delegate that takes an object and event argument’s enum.
Player input is detected by a class called Menu
; it informs the buttons if they are selected or pressed. The Menu
class contains a list of buttons, and only one button may have focus at any one time. The user can navigate the menu with the control pad or keyboard. The OnGainFocus
and OnLoseFocus
will change the button label text; this will let us know which button currently has the focus.
The color will be red when focused; otherwise, it will be black. Alternatively, the text could be enlarged, a background image could change, or some other values could be tweened in or out, but not now as this is the very first pass.
The menu will list the buttons vertically in a column, so a good name might be VerticalMenu
. VerticalMenu
is another reusable class so it can be added to the Engine project. The menu will need methods for adding buttons and a Render
method.
using System; using System.Collections.Generic; using System.Linq; using System.Text; using Engine.Input; // Input needs to be added for gamepad input. using System.Windows.Forms; // Used for keyboard input namespace Engine { public class VerticalMenu { Vector _position = new Vector(); Input.Input _input; List<Button> _buttons = new List<Button>(); public double Spacing { get; set; } public VerticalMenu(double x, double y, Input.Input input) { _input = input; _position = new Vector(x, y, 0); Spacing = 50; } public void AddButton(Button button) { double _currentY = _position.Y; if (_buttons.Count != 0) { _currentY = _buttons.Last().Position.Y; _currentY -= Spacing; } else { // It's the first button added it should have // focus button.OnGainFocus(); } button.Position = new Vector(_position.X, _currentY, 0); _buttons.Add(button); } public void Render(Renderer renderer) { _buttons.ForEach(x => x.Render(renderer)); } } }
The position of the buttons is handled automatically. Each time a button is added, it is put below the other buttons on the Y axis. The Spacing
member determines how far apart the buttons are spaced, and this defaults to 50 pixels. The menu itself also has a position that allows the buttons to be moved around as a group. The position is only set in the constructor. The VerticalMenu
doesn’t allow its position to be changed after it is constructed because this would require an extra method to rearrange all the buttons for the new position. This would be nice functionality to have, but it’s not necessary. The Render
method uses C#’s new lamba operator to render all the buttons.
The menu class doesn’t handle user input yet, but before adding that, let’s hook the menu up to StartMenuState
so we can see if everything is working. The label on buttons will use a different font than the one used for the title. Find general_font.fnt and general_font.tga on the CD and add them to the project. Then this new font needs to be set up in the Form.cs file.
// In form.cs private void InitializeTextures() { // Init DevIl Il.ilInit(); Ilu.iluInit(); Ilut.ilutInit(); Ilut.ilutRenderer(Ilut.ILUT_OPENGL); // Textures are loaded here. _textureManager.LoadTexture("title_font", "title_font.tga"); _textureManager.LoadTexture("general_font", "general_font.tga"); } Engine.Font _generalFont; Engine.Font _titleFont; private void InitializeFonts() { // Fonts are loaded here. _titleFont = new Engine.Font(_textureManager.Get("title_font"), FontParser.Parse("title_font.fnt")); _generalFont = new Engine.Font(_textureManager.Get("general_font"), FontParser.Parse("general_font.fnt")); }
This new general font can now be passed through to the StartMenuState
in the constructor and will be used to construct the vertical menu. The Input
class is also passed along at this point and therefore the using Engine.Input
statement must be added to the other using
statements at the top of the Start-MenuState.cs file.
Engine.Font _generalFont; Input _input; VerticalMenu _menu; public StartMenuState(Engine.Font titleFont, Engine.Font generalFont, Input input) { _input = input; _generalFont = generalFont; InitializeMenu();
The actual menu creation is done in the InitializeMenu
function because this stops it from cluttering up the StartMenuState
constructor. The StartMenuState
creates a vertical menu centered on the X axis and 150 pixels up on the Y axis. This positions the menu neatly below the title text.
private void InitializeMenu() { _menu = new VerticalMenu(0, 150, _input); Button startGame = new Button( delegate(object o, EventArgs e) { // Do start game functionality. }, new Text("Start", _generalFont)); Button exitGame = new Button( delegate(object o, EventArgs e) { // Quit System.Windows.Forms.Application.Exit(); }, new Text("Exit", _generalFont)); _menu.AddButton(startGame); _menu.AddButton(exitGame); }
Two buttons are created: one for exit and one for start. It’s easy to see how additional buttons could be added (e.g., load saved game, credits, settings, or website would all be fairly trivial to add using this system). The exit button delegate is fully implemented, and when called, it will exit the program. The start menu button functionality is empty for the time being; it will be filled in when we make the inner game state.
The vertical menu is now being successfully created, but it won’t be visible until it’s added to the render loop.
public void Render() { Gl.glClearColor(1, 1, 1, 0); Gl.glClear(Gl.GL_COLOR_BUFFER_BIT); _renderer.DrawText(_title); _menu.Render(_renderer); _renderer.Render(); }
Run the program and the menu will be rendered under the title.
Only the input handling remains to be implemented. The gamepad will navigate the menu with the left control stick or the keyboard. This requires some extra logic to decide when a control stick has been flicked up or down. This logic is shown below in the HandleInput
function, which belongs to the VerticalMenu
class. You may have to make a change to the Input
class to make the Controller
member public so it’s accessible from outside the Engine project.
bool _inDown = false; bool _inUp = false; int _currentFocus = 0; public void HandleInput() { bool controlPadDown = false; bool controlPadUp = false; float invertY = _input.Controller.LeftControlStick.Y * -1; if (invertY< -0.2) { // The control stick is pulled down if (_inDown == false) { controlPadDown = true; _inDown = true; } } else { _inDown = false; } if (invertY> 0.2) { if (_inUp == false) { controlPadUp = true; _inUp = true; } } else { _inUp = false; } if (_input.Keyboard.IsKeyPressed(Keys.Down) || controlPadDown) { OnDown(); } else if(_input.Keyboard.IsKeyPressed(Keys.Up) || controlPadUp) { OnUp(); } }
The HandleInput
function needs to be called in the StartMenuState. Update
method. If you don’t add this call then none of the input will be detected. HandleInput
detects the particular input that the vertical menu is interested in and then calls other functions to deal with it. At the moment there are only two functions, OnUp
and OnDown
; these will change the currently focused menu item.
private void OnUp() { int oldFocus = _currentFocus; _currentFocus ++; if (_currentFocus == _buttons.Count) { _currentFocus = 0; } ChangeFocus(oldFocus, _currentFocus); } private void OnDown() { int oldFocus = _currentFocus; _currentFocus-; if (_currentFocus == -1) { _currentFocus = (_buttons.Count - 1); } ChangeFocus(oldFocus, _currentFocus); } private void ChangeFocus (int from, int to) { if (from != to) { _buttons[from].OnLoseFocus(); _buttons[to].OnGainFocus(); } }
By pressing up or down on the keyboard, the focus is moved up and down the buttons on the vertical menu. The focus also wraps around. If you are at the top of the menu and press up, then the focus will wrap around and go to the bottom of the menu. The ChangeFocus
method reduces repeated code; it tells one button it’s lost focus and another button that it’s gained focus.
Buttons can now be selected, but there is no code to handle buttons being pressed. The VerticalMenu
class needs to be modified to detect when the A button on the gamepad or the Enter key on the keyboard is pressed. Once this is detected, the currently selected button delegate is called.
// Inside the HandleInput function else if(_input.Keyboard.IsKeyPressed(Keys.Up) || controlPadUp) { OnUp(); } else if (_input.Keyboard.IsKeyPressed(Keys.Enter) || _input.Controller.ButtonA.Pressed) { OnButtonPress(); } } private void OnButtonPress() { _buttons[_currentFocus].OnPress(); }
Run the code and use the keyboard or gamepad to navigate the menu. Pressing the exit button will exit the game, but pressing the start button will currently do nothing. The start button needs to change the state to the inner game state. This means that StartMenuState
needs access to the state system.
private void InitializeGameState () { _system.AddState("start_menu", new StartMenuState(_titleFont, _generalFont, _input, _system));
The StartMenuState
constructor will also need to be modified, and it will keep a reference to the state system.
StateSystem _system; public StartMenuState(Engine.Font titleFont, Engine.Font generalFont, Input input, StateSystem system) { _system = system;
This can be used by the start button to change states when it is pressed. The start button is set up in the InitializeMenu
method and needs to be modified like so.
Button startGame = new Button( delegate(object o, EventArgs e) { _system.ChangeState("inner_game"); }, new Text("Start", _generalFont));
The inner_game
state doesn’t exist yet but that’s what we’ll develop next. For a first pass, the start menu is now complete. Running the program will produce something similar to Figure 10.5.
Subsequent passes can change this menu as needed, adding more animation, demo modes, or whatever you like!
For the first pass, the inner game is going to be as simple as possible. It will wait a few seconds and then change to the game over
state. It needs to pass some information over to the game over
state to report if the player won or lost the game.
A PersistantGameData
class will be used to store information about the player, including if he had just lost or won a game. Eventually the inner game will allow the player to play a shooting game, but not in this first pass.
The inner game level will last for a fixed period of time; if the player is alive when the time is up the player wins. The time a level takes is described by a LevelDescription
class. For now, the only thing this class contains is how long the level will last.
class LevelDescription { // Time a level lasts in seconds. public double Time { get; set; } }
The PersistentGameData
class will have a description of the current level and information about whether the player has just won that level.
class PersistantGameData { public bool JustWon { get; set; } public LevelDescription CurrentLevel { get; set; } public PersistantGameData() { JustWon = false; } }
The JustWon
member is set to false
in the constructor because the player cannot have won a game before the game data is created. The persistent game data class needs to be created in the Form.cs file. Add a new function to be called from the constructor called InitializeGameData
; it should be called just after InitializeTextures
and just before the game fonts are created.
PersistantGameData _persistantGameData = new PersistantGameData(); private void InitializeGameData() { LevelDescription level = new LevelDescription(); level.Time = 1; // level only lasts for a second _persistantGameData.CurrentLevel = level; }
With this class set up it’s now easy to design the InnerGameState
.
using System; using System.Collections.Generic; using System.Linq; using System.Text; using Engine; using Engine.Input; using Tao.OpenGl; namespace Shooter { class InnerGameState : IGameObject { Renderer _renderer = new Renderer(); Input _input; StateSystem _system; PersistantGameData _gameData; Font _generalFont; double _gameTime; public InnerGameState(StateSystem system, Input input, Persis- tantGameData gameData, Font generalFont) { _input = input; _system = system; _gameData = gameData; _generalFont = generalFont; OnGameStart(); } public void OnGameStart() { _gameTime = _gameData.CurrentLevel.Time; } #region IGameObject Members public void Update(double elapsedTime) { _gameTime -= elapsedTime; if (_gameTime<= 0) { OnGameStart(); _gameData.JustWon = true; _system.ChangeState("game_over"); } } public void Render() { Gl.glClearColor(1, 0, 1, 0); Gl.glClear(Gl.GL_COLOR_BUFFER_BIT); _renderer.Render(); } #endregion } }
The constructor takes in the state system and persistent game data. Using these classes the InnerGameState
can determine when the game is over and change the game state. The constructor also takes in the input and general font as these will be of use when adding the second pass of functionality. The constructor calls OnGameStart
, which sets the gameTime
that will determine how long the level lasts. There will be no level content at this time so the level time is set to 1 second.
The Update
function counts down the level time. When the time is up it and the state changes to game_over
. The gameTime
is reset by calling OnGameState
and because the player is still alive then, the JustWon
flag is set in the persistent data object. The inner game state Render
function clears the screen to a pink color so it’s obvious when the state change occurs.
The InnerGameState
class should be used to add another state to the state system in the Form.cs file.
_system.AddState("inner_game", new InnerGameState(_system, _input, _persistantGameData, _generalFont));
That’s it for the first pass of the inner game.
The gameover
state is a simple state that tells the player that the game has ended and if he won or lost. The state determines if the player won or lost the game by using the PersistentGameData
class. The state will display its information for a short time and then return the player to the start menu. The player can return to the start menu earlier by pressing a button and forcing the GameOverState
to finish.
%using System; using System.Collections.Generic; using System.Linq; using System.Text; using Engine; using Engine.Input; using Tao.OpenGl; namespace Shooter { class GameOverState : IGameObject { const double _timeOut = 4; double _countDown = _timeOut; StateSystem _system; Input _input; Font _generalFont; Font _titleFont; PersistantGameData _gameData; Renderer _renderer = new Renderer(); Text _titleWin; Text _blurbWin; Text _titleLose; Text _blurbLose; public GameOverState(PersistantGameData data, StateSystem system, Input input, Font generalFont, Font titleFont) { _gameData = data; _system = system; _input = input; _generalFont = generalFont; _titleFont = titleFont; _titleWin = new Text("Complete!", _titleFont); _blurbWin = new Text("Congratulations, you won!", _generalFont); _titleLose = new Text("Game Over!", _titleFont); _blurbLose = new Text("Please try again...", _generalFont); FormatText(_titleWin, 300); FormatText(_blurbWin, 200); FormatText(_titleLose, 300); FormatText(_blurbLose, 200); } private void FormatText(Text _text, int yPosition) { _text.SetPosition(-_text.Width / 2, yPosition); _text.SetColor(new Color(0, 0, 0, 1)); } #region IGameObject Members public void Update(double elapsedTime) { _countDown -= elapsedTime; if ( _countDown <= 0 || _input.Controller.ButtonA.Pressed || _input.Keyboard.IsKeyPressed(System.Windows.Forms.Keys. Enter)) { Finish(); } } private void Finish() { _gameData.JustWon = false; _system.ChangeState("start_menu"); _countDown = _timeOut; } public void Render() { Gl.glClearColor(1, 1, 1, 0); Gl.glClear(Gl.GL_COLOR_BUFFER_BIT); if (_gameData.JustWon) { _renderer.DrawText(_titleWin); _renderer.DrawText(_blurbWin); } else { _renderer.DrawText(_titleLose); _renderer.DrawText(_blurbLose); } _renderer.Render(); } #endregion } } This class needs to be loaded like the rest in the form.cs class. private void InitializeGameState() { // Game states are loaded here _system.AddState("start_menu", new StartMenuState(_titleFont, _generalFont, _input, _system)); _system.AddState("inner_game", new InnerGameState(_system, _input, _persistantGameData, _generalFont)); _system.AddState("game_over", new GameOverState(_persistantGameData, _system, _input, _generalFont, _titleFont)); _system.ChangeState("start_menu"); }
The GameOverState
creates a title and message for winning and losing the game. It then uses the persistent data JustWon
member to decide which message to display. It also has a counter, and the state eventually times out returning the user to the start menu.
The creation of these three states completes the first pass of the game. The game, while currently not very fun, is already in a complete state. The next section will add more detail to the inner game and refine the overall structure to make it look better.
The inner game currently doesn’t allow any interaction and times out after a few seconds. To make the inner game state more game-like, a PlayerCharacter
needs to be introduced and the player needs to be able to move the character around. This will be the first goal. It’s important to create a game in a series of small achievable goals that are well defined; it makes it much easier to write the code. In this case, the PlayerCharacter
will be some type of spaceship.
Once the first goal is reached then the player needs to feel as though he is advancing through a level. This will be done by scrolling the background texture. The next small goal is to let the player shoot bullets. Bullets need something to hit, so the enemies will also be needed. Each goal is a small step that leads logically on to the next. It’s very quick to build up a game in this manner.
The player will be represented as a spaceship created using a sprite and a texture. The spaceship will be controlled by the arrow keys or the left control stick on the gamepad.
The code for controlling the PlayerCharacter
won’t go directly into the InnerGameState
class. The InnerGameState
class is meant to be a light, easy to understand class. The bulk of the level code will be stored in a class called Level
. Each time the player plays a level, a new level object is made and the old one is replaced. Creating a new level object each time ensures that there’s no strange error caused by leftover data from a previous play through.
using System; using System.Collections.Generic; using System.Linq; using System.Text; using Engine; using Engine.Input; using System.Windows.Forms; using System.Drawing; namespace Shooter { class Level { Input _input; PersistantGameData _gameData; PlayerCharacter _playerCharacter; TextureManager _textureManager; public Level(Input input, TextureManager textureManager, PersistantGameData gameData) { _input = input; _gameData = gameData; _textureManager = textureManager; _playerCharacter = new PlayerCharacter(_textureManager); } public void Update(double elapsedTime) { // Get controls and apply to player character } public void Render(Renderer renderer) { _playerCharacter.Render(renderer); } } }
This code describes a level. It takes the input and persistent game data in the constructor. The input object is used to move the PlayerCharacter
. The persistent game data can be used to keep track of such things as the score or any other data that should be recorded over a number of levels. The texture manager is used to create the player, enemies, and background sprites.
The PlayerCharacter
class will contain a sprite that represents the player spaceship. The CD contains a sprite called spaceship.tga in the Assets directory. This needs to be added to the project and its properties changed so that it is copied into the build directory. Load this texture in the form.cs InitializeTextures
method.
private void InitializeTextures() { // Init DevIl Il.ilInit(); Ilu.iluInit(); Ilut.ilutInit(); Ilut.ilutRenderer(Ilut.ILUT_OPENGL); _textureManager.LoadTexture("player_ship", "spaceship.tga");
Now that the player sprite is loaded into the TextureManager,
the PlayerCharacter
class can be written.
public class PlayerCharacter { Sprite _spaceship = new Sprite(); public PlayerCharacter(TextureManager textureManager) { _spaceship.Texture = textureManager.Get("player_ship"); _spaceship.SetScale(0.5, 0.5); // spaceship is quite big, scale it down. } public void Render(Renderer renderer) { renderer.DrawSprite(_spaceship); } }
The PlayerCharacter
class at this stage only renders out the spaceship. To see this is in action a Level
object needs to be created in the InnerGameState
and hooked up to the Update
and Render
methods. The structure of the level class has its own Render
and Update
methods so it’s very easy to plug into the InnerGame State
. The Level
class makes use of the TextureManager
class and this means the InnerGameState
must change its constructor so that it takes in a TextureManager
object. In the form.cs file the textureManager
object needs to be passed into the InnerGameState
constructor.
class InnerGameState : IGameObject { Level _level; TextureManager _textureManager; // Code omitted public InnerGameState( StateSystem system, Input input, TextureManager textureManager, PersistantGameData gameData, Font generalFont) { _ textureManager = textureManager; // Code omitted public void OnGameStart() { _level = new Level(_input, _textureManager, _gameData); _gameTime = _gameData.CurrentLevel.Time; } // Code omitted public void Update(double elapsedTime) { _level.Update(elapsedTime); // Code omitted public void Render() { Gl.glClearColor(1, 0, 1, 0); Gl.glClear(Gl.GL_COLOR_BUFFER_BIT); _level.Render(_renderer);
Run the code and start the game. The spaceship will flash, giving a brief glance of this new player sprite and then suddenly the game will end. To test the InnerGameState
thoroughly, the level length must be increased. The level length is set in the form.cs file in the InitializeGameData
function. Find the code and make the length longer; 30 seconds is probably fine.
The spaceship movement is going to be very simple, with no acceleration or physics modeling. The control stick and arrow keys map directly to the movement of the ship. The PlayerCharacter
class needs a new method called Move
.
double _speed = 512; // pixels per second public void Move(Vector amount) { amount *= _speed; _spaceship.SetPosition(_spaceship.GetPosition() + amount); }
The Move
method takes in a vector that gives the direction and amount to move the spaceship. The vector is then multiplied by the speed
value to increase the movement length. The new vector is then added to the current position of the ship to create a new position in space, and the sprite is moved there. This is how all basic movement is done in arcade-style games. The movement can be given a different feel by modeling more physical systems such as acceleration and friction, but we will stick with the basic movement code.
The spaceship is moved around by the values in the Input
class, which is handled in the Level Update
loop.
public void Update(double elapsedTime) { // Get controls and apply to player character double _x = _input.Controller.LeftControlStick.X; double _y = _input.Controller.LeftControlStick.Y * - 1; Vector controlInput = new Vector(_x, _y, 0); if (Math.Abs(controlInput.Length()) < 0.0001) { // If the input is very small, then the player may not be using // a controller; he might be using the keyboard. if (_input.Keyboard.IsKeyHeld(Keys.Left)) { controlInput.X = -1; } if (_input.Keyboard.IsKeyHeld(Keys.Right)) { controlInput.X = 1; } if (_input.Keyboard.IsKeyHeld(Keys.Up)) { controlInput.Y = 1; } if (_input.Keyboard.IsKeyHeld(Keys.Down)) { controlInput.Y = -1; } } _playerCharacter.Move(controlInput * elapsedTime); }
The controls are quite simple for the gamepad. A vector is created that describes how the control stick is pushed (The Y axis is reversed by multiplying the value by minus 1 so that the ship will go up when you push up rather than down). This vector is then multiplied by the elapsed time so that the movement will be constant no matter the frame rate. The scaled vector is then used to move the ship. There is also support for the keyboard. The values of the control stick are checked, and if the control stick doesn’t seem to have moved, then the keyboard keys are checked. It’s assumed if you’re not moving the control stick, then you might be playing on the keyboard. The keyboard is less granular than the control stick; it can only give up, down, left, and right as absolute, 0 or 1, values. The keyboard input is used to make a vector so that it can be treated in the same way as the control stick input. IsKeyHeld
is used instead of IsKeyPressed
because we assume that if the user is holding down the left key, he wants to continue to move left rather than move left once and stop.
Run the program and you will be able to move the ship around the screen. The movement goal is complete!
Adding a background is going to be fairly easy. There are two basic starfield textures on the CD in the Assets directory called background.tga and background_p. tga. Add these files to the solution and alter the properties so they’re copied to the build directory as you’ve done for all the other textures. Then load them into the texture manager in the form.cs InitializeTextures
function.
_textureManager.LoadTexture("background", "background.tga"); _textureManager.LoadTexture("background_layer_1", "background_p.tga");
Two backgrounds have been chosen so that they can be layered on top of each other to make a more interesting effect than would be achievable with only one texture.
This background is going to be animated by using UV scrolling. This can all be done by making a new class called ScrollingBackground
. This scrolling background class could also be reused to make the start and game over menu more interesting, but the priority at the moment is the inner game.
using System; using System.Collections.Generic; using System.Linq; using System.Text; using Engine; namespace Shooter { class ScrollingBackground { Sprite _background = new Sprite(); public float Speed { get; set; } public Vector Direction { get; set; } Point _topLeft = new Point(0, 0); Point _bottomRight = new Point(1, 1); public void SetScale(double x, double y) { _background.SetScale(x, y); } public ScrollingBackground(Texture background) { _background.Texture = background; Speed = 0.15f; Direction = new Vector(1, 0, 0); } public void Update(float elapsedTime) { _background.SetUVs(_topLeft, _bottomRight); _topLeft.X += (float)(0.15f * Direction.X * elapsedTime); _bottomRight.X += (float)(0.15f * Direction.X * elapsedTime); _topLeft.Y += (float)(0.15f * Direction.Y * elapsedTime); _bottomRight.Y += (float)(0.15f * Direction.Y * elapsedTime); } public void Render(Renderer renderer) { renderer.DrawSprite(_background); } } }
An interesting thing to note about the scrolling background class is that it has a direction vector (called Direction
). This vector can be used to alter the direction of the scrolling. In a normal shooting game, a background usually scrolls right to left. Altering the scrolling direction can cause the background to scroll in any direction desired. This would be useful in a space exploration game where the background could move in the opposite direction to the player’s movement.
The scrolling class also has a Speed
member; this isn’t strictly necessary as the speed of the scrolling could be encoded as the magnitude of the vector, but separating the speed makes it simpler to alter. The direction and speed are used to move the U,V data of the vertices in the Update
method.
This background can now be added into the Level
class.
ScrollingBackground _background; ScrollingBackground _backgroundLayer; public Level(Input input, TextureManager textureManager, PersistantGa- meData gameData) { _input = input; _gameData = gameData; _textureManager = textureManager; _background = new ScrollingBackground(textureManager.Get ("background")); _background.SetScale(2, 2); _background.Speed = 0.15f; _backgroundLayer = new ScrollingBackground(textureManager.Get ("background_layer_1")); _backgroundLayer.Speed = 0.1f; _backgroundLayer.SetScale(2.0, 2.0);
These two background objects are created in the constructor and each is scaled by two. The backgrounds are scaled up because the texture is about half the size of the screen area. The texture is scaled to make the texture large enough to entirely cover the playing area without leaving gaps at the edges.
The two backgrounds scroll at different speeds. This produces what is known as a parallax effect. The human brain understands the 3D world through a number of different cues known as depth cues. For instance, each eye sees a slightly different angle of the world, and the differences between these views can be used to determine the third dimension. This is known as the binocular cue.
Parallax is another one of these cues; simply put, objects further from the viewer appear to move slower than those closer to the view. Think of driving in a car with a large mountain in the distance. The mountain appears to move very slowly, but trees next to the road fly past. This is a depth cue, and the brain knows that the mountain is far away.
Parallax is easy to fake. A fast scrolling star field appears to have stars close to the spaceship; another background moving more slowly appears to have stars far away. The background classes merely need to move at different speeds, and this gives the background a feeling of depth.
The background objects need to be rendered and updated. This requires more code changes.
public void Update(double elapsedTime) { _background.Update((float)elapsedTime); _backgroundLayer.Update((float)elapsedTime); // A little later in the code public void Render(Renderer renderer) { _background.Render(renderer); _backgroundLayer.Render(renderer);
Run the code and check out the parallax effect. It’s quite subtle with the given star fields, so feel free to modify the images or add several more layers.
The ship now appears to be zooming along in space, and everything suddenly feels a lot more game-like. The next task is to add an enemy.
The enemies will be represented by a sprite and therefore they will use the Sprite
class. The enemy sprite should be different from the player sprite so add a new sprite texture called spaceship2.tga from the CD Assets directory. Change its properties so that it will be copied to the in directories when the program is built.
This snippet of code loads the texture into the texture manager.
_textureManager.LoadTexture("enemy_ship", "spaceship2.tga");
Once this has been added, a class can be constructed to simply represent the enemy.
using System; using System.Collections.Generic; using System.Linq; using System.Text; using Engine; namespace Shooter { class Enemy { Sprite _spaceship = new Sprite(); double _scale = 0.3; public Enemy(TextureManager textureManager) { _spaceship.Texture = textureManager.Get("enemy_ship"); _spaceship.SetScale(_scale, _scale); _spaceship.SetRotation(Math.PI); // make it face the player _spaceship.SetPosition(200, 0); // put it somewhere easy to see } public void Update(double elapsedTime) { } public void Render(Renderer renderer) { renderer.DrawSprite(_spaceship); } } }
The enemies will be rendered and controlled by the Level
class. It’s very likely we’ll have more than one enemy at a time, so it’s best to make a list of enemies.
class Level { List<Enemy> _enemyList = new List<Enemy>(); // A little later in the code public Level(Input input, TextureManager textureManager, Persis- tantGameData gameData) { _input = input; _gameData = gameData; _textureManager = textureManager; _enemyList.Add(new Enemy(_textureManager)); // A little later in the code public void Update(double elapsedTime) { _background.Update((float)elapsedTime); _backgroundLayer.Update((float)elapsedTime); _enemyList.ForEach(x => x.Update(elapsedTime)); // A little later in the code public void Render(Renderer renderer) { _background.Render(renderer); _backgroundLayer.Render(renderer); _enemyList.ForEach(x => x.Render(renderer));
This code is all pretty standard. A list of enemies is created, and an enemy is added to it. The list is then updated and rendered using the lambda syntax. Run the program now and you should see two spaceships: the player spaceship and an enemy facing it. With the current code, the player can fly through the enemy ship with no reaction. If the player crashes into the enemy ship then the player should take some damage or the state should change to the game over state. Before any of that can happen, the collision needs to be detected.
The collision detection will be the simple rectangle-rectangle collision explored earlier in the book. Before coding the collision it may be useful to visualize the bounding box around the enemy. This is quite simple to code using OpenGL’s immediate mode and GL_LINE_LOOP
. Add the following code to the Enemy
class.
public RectangleF GetBoundingBox() { float width = (float)(_spaceship.Texture.Width * _scale); float height = (float)(_spaceship.Texture.Height * _scale); return new RectangleF( (float)_spaceship.GetPosition().X - width / 2, (float)_spaceship.GetPosition().Y - height / 2, width, height); } // Render a bounding box public void Render_Debug() { Gl.glDisable(Gl.GL_TEXTURE_2D); RectangleF bounds = GetBoundingBox(); Gl.glBegin(Gl.GL_LINE_LOOP); { Gl.glColor3f(1, 0, 0); Gl.glVertex2f(bounds.Left, bounds.Top); Gl.glVertex2f(bounds.Right, bounds.Top); Gl.glVertex2f(bounds.Right, bounds.Bottom); Gl.glVertex2f(bounds.Left, bounds.Bottom); } Gl.glEnd(); Gl.glEnable(Gl.GL_TEXTURE_2D); }
C#’s RectangleF
class is used; therefore, the System.Drawing
library needs to be added to the using
statements at the top of Enemy.cs
. The function GetBoundingBox
uses the sprite to calculate a bounding box around it. The width and height are scaled according to the sprite, so even if the sprite is scaled, the bounding box will be correct. The RectangleF
constructor takes in the x and y position of the top-left corner, and then the width and height of the rectangle. The position of the sprite is its center, so to get the top-left corner, half the width and height must be subtracted from the position.
The Render_Debug
method draws a red box around the sprite. The Render_Debug
method should be called from the Enemy.Render
method. This debug function can be removed at any time.
public void Render(Renderer renderer) { renderer.DrawSprite(_spaceship); Render_Debug(); }
Run the code and a red box will be drawn around the enemy, as can be seen in Figure 10.7. Visual debug routines are a great way to understand what your code is really doing.
The GetBoundingBox
function can be used to determine if the enemy is colliding with anything else. At the moment, the player ship doesn’t have a GetBoundingBox
function, and the principle of DRY (Don’t Repeat Yourself) means you shouldn’t just copy this code! Instead, a new parent class should be created that centralizes this functionality; then the Enemy
and PlayerCharacter
can both inherit from this.
Before the Enemy
and the PlayerCharacter
classes are generalized, this Sprite
class needs to be modified. To make the bounding box drawing functions simpler, the sprite should have some methods to report the current scale.
public class Sprite { double _scaleX = 1; double _scaleY = 1; public double ScaleX { get { return _scaleX; } } public double ScaleY { get { return _scaleY; } }
Changing the Sprite
class is a change to the Engine library, which is a change that shouldn’t be taken lightly. In this case, it is a good change that will be beneficial to any future project using the Engine library. With the Sprite
method updated, the Entity
class can be created back in the Shooter project.
using System; using System.Collections.Generic; using System.Linq; using System.Text; using Engine; using Tao.OpenGl; using System.Drawing; namespace Shooter { public class Entity { protected Sprite _sprite = new Sprite(); public RectangleF GetBoundingBox() { float width = (float)(_sprite.Texture.Width * _sprite.ScaleX); float height = (float)(_sprite.Texture.Height * _sprite.ScaleY); return new RectangleF((float)_sprite.GetPosition().X - width / 2, (float)_sprite.GetPosition().Y - height / 2, width, height); } // Render a bounding box protected void Render_Debug() { Gl.glDisable(Gl.GL_TEXTURE_2D); RectangleF bounds = GetBoundingBox(); Gl.glBegin(Gl.GL_LINE_LOOP); { Gl.glColor3f(1, 0, 0); Gl.glVertex2f(bounds.Left, bounds.Top); Gl.glVertex2f(bounds.Right, bounds.Top); Gl.glVertex2f(bounds.Right, bounds.Bottom); Gl.glVertex2f(bounds.Left, bounds.Bottom); } Gl.glEnd(); Gl.glEnable(Gl.GL_TEXTURE_2D); } } }
The Entity
class contains a sprite and some code to render that sprite’s bounding box.
With this entity definition, the Enemy
class can be greatly simplified.
public class Enemy : Entity { double _scale = 0.3; public Enemy(TextureManager textureManager) { _sprite.Texture = textureManager.Get("enemy_ship"); _sprite.SetScale(_scale , _scale); _sprite.SetRotation(Math.PI); // make it face the player _sprite.SetPosition(200, 0); // put it somewhere easy to see } public void Update(double elapsedTime) { } public void Render(Renderer renderer) { renderer.DrawSprite(_sprite); Render_Debug(); } public void SetPosition(Vector position) { _ sprite.SetPosition(position); } }
The Enemy
is now a type of Entity
and no longer needs its own reference to a sprite. This same refactoring can be applied to the PlayerCharacter
class.
public class PlayerCharacter : Entity { double _speed = 512; // pixels per second public void Move(Vector amount) { amount *= _speed; _sprite.SetPosition(_sprite.GetPosition() + amount); } public PlayerCharacter(TextureManager textureManager) { _sprite.Texture = textureManager.Get("player_ship"); _sprite.SetScale(0.5, 0.5); // spaceship is quite big, scale it down. } public void Render(Renderer renderer) { Render_Debug(); renderer.DrawSprite(_sprite); } }
Run the code again, and now both the enemy and player will have appropriate bounding boxes.
For now, the rule will be that if the PlayerCharacter
hits an enemy, the game ends. This can be refined later by giving the player some health. To get a game working as fast as possible, it will for now be instant death.
The first change is in the InnerGameState
; it needs to recognize when the player has died and therefore failed to complete the current level.
public void Update(double elapsedTime) { _level.Update(elapsedTime); _gameTime -= elapsedTime; if (_gameTime <= 0) { OnGameStart(); _gameData.JustWon = true; _system.ChangeState("game_over"); } if (_level.HasPlayerDied()) { OnGameStart(); _gameData.JustWon = false; _system.ChangeState("game_over"); } }
Here the Level
class has been given an extra function, HasPlayerDied
, that reports if the player has died. In this case, the player’s death is checked after the gameTime
. This means that if the time runs out, but the player died in the last possible second, he still won’t win the level.
In the level
class, the HasPlayerDied
method needs to be implemented. It’s just a simple wrapper around the current PlayerCharacter
’s state.
public bool HasPlayerDied() { return _playerCharacter.IsDead; }
The death flag is contained in the PlayerCharacter
class.
bool _dead = false; public bool IsDead { get { return _dead; } }
When the player collides with an enemy, this death flag can be set and the game will end with the player losing the level. The level needs some code to process the collisions that happen between the enemy craft and the player. This collision processing is done in the level
class, which has access to the PlayerCharacter
and the list of enemies.
private void UpdateCollisions () { foreach (Enemy enemy in _enemyList) { if (enemy.GetBoundingBox().IntersectsWith(_playerCharacter. GetBoundingBox())) { enemy.OnCollision(_playerCharacter); _playerCharacter.OnCollision(enemy); } } } public void Update(double elapsedTime) { UpdateCollisions();
The collision processing code is called each frame by the level’s Update
method. The collisions are determined by iterating through the list of enemies and checking if their bounding box intersects with the player. The intersection is worked out using C#’s RectangleF IntersectsWith
method. If the bounding box of the player and enemy do intersect, then OnCollision
is called for the player and the enemy. The Player.OnCollision
method is passed the enemy object. It collides with it and the Enemy.OnCollision
is passed the player object. There is no test for enemies colliding with other enemies; it’s assumed this is no problem if it happens in the game.
The OnCollision
class needs to be implemented for both the Enemy
and PlayerCharacter
classes. Here is the skeleton method that needs to be added to the Enemy
class.
internal void OnCollision(PlayerCharacter player) { // Handle collision with player. }
Unlike Enemy
, the PlayerCharacter
class actually has some functionality. Its implementation is as follows.
internal void OnCollision(Enemy enemy) { _dead = true; }
When the player collides with the enemy, its dead flag is set to true, which will cause the game to end. The game is now partially playable with an outcome for losing or winning. From this point on, the refinements to the game will start to make it more fun to play.
Weapons in the game mainly take the form of different types of bullets. A good goal to aim for is to have the player shoot a bullet each time the A button or spacebar is pressed. Eventually the enemies will also be firing bullets, which is important to bear in mind when creating the bullet system.
To experiment with bullets, another texture is needed. Find bullet.tga on the CD in Assets directory and add it to the project, remembering to set the properties as before. Then this texture needs to be loaded into the texture manager.
_textureManager.LoadTexture("bullet", "bullet.tga");
Once the texture is loaded, the next logical class to create is the Bullet
class. This will have a bounding box and a sprite so it too can inherit from Entity
. The class should be created in the Shooter project.
public class Bullet : Entity { public bool Dead { get; set; } public Vector Direction { get; set; } public double Speed { get; set; } public double X { get { return _sprite.GetPosition().X; } } public double Y { get { return _sprite.GetPosition().Y; } } public void SetPosition(Vector position) { _sprite.SetPosition(position); } public void SetColor(Color color) { _sprite.SetColor(color); } public Bullet(Texture bulletTexture) { _sprite.Texture = bulletTexture; // Some default values Dead = false; Direction = new Vector(1, 0, 0); Speed = 512;// pixels per second } public void Render(Renderer renderer) { if (Dead) { return; } renderer.DrawSprite(_sprite); } public void Update(double elapsedTime) { if (Dead) { return; } Vector position = _sprite.GetPosition(); position += Direction * Speed * elapsedTime; _sprite.SetPosition(position); } }
The bullet has three members: the direction the bullet will travel, the speed it will travel, and a flag to tell if the bullet is dead or not. There are also position setters and getters for the bullet sprite. There is also a setter for the color; it makes sense to allow the bullets to be colored. The player bullets will only hurt the enemies and the enemy bullets will only hurt the player. To let the player know which bullets are which, they are given different colors.
You may see the position getter and setter and color setter and wonder if it would be better just to make the sprite
class public. Then if we wanted to change the position or color, we could just alter the bullet sprite directly. Every situation is different but as a general rule, it’s better to keep more data private and provide an interface to the data that needs to be changed. Also bullet.SetColor()
is more straightforward to read than bullet.Sprite.SetColor()
.
The constructor takes in a texture for the bullet and sets some default values for the color, direction, and speed. The speed is measured in pixels per second. The final two methods are Render
and Update
. The Update
loop updates the position of the bullet using the direction and speed. The position increase is scaled by the amount of time since the last frame, so the movement will be consistent on any speed of computer. The render is quite straightforward; it just draws the bullet sprite. Both the render and update loops do nothing if the bullet has its dead flag set to true.
A lot of bullets are going to be flying about and there needs to be a certain amount of logic to deal with that. Bullets that leave the screen need to be turned off. A BulletManager
class is a fine place to put all this logic. There are two ways to write a BulletManager
class: the simple straightforward way and the memory-efficient way. The BulletManager
introduced here is the straightforward type; when an enemy is destroyed on screen its reference is removed from the BulletManager
and the object is destroyed in code; freeing any memory it was using. Every time the player fires, a new bullet is created. This is basic, but creating and deleting lots of objects in the game loop is a bad thing; it will make your code slow if you do it too much. Creation and deletion of objects tends to slow operations.
A more memory-efficient method of managing the bullets is to have a big list of, say, 1,000 bullets. Most of the bullets are dead; every time the user fires the list is searched and a dead bullet is brought to life. No new objects need to be created. If all 1,000 bullets are alive, then either the player can’t fire or a heuristic (such as bullet that’s been alive longest) is used to kill one of the current bullets and let the player use that one. Recycling bullets in this way is a better way to write the BulletManager
. Once you’ve seen the simple manager in action, you can always have a go at converting it to the more memory-efficient one yourself.
using System; using System.Collections.Generic; using System.Linq; using System.Text; using Engine; namespace Shooter { public class Bullet : Entity { public bool Dead { get; set; } public Vector Direction { get; set; } public double Speed { get; set; } public double X { get { return _sprite.GetPosition().X; } } public double Y { get { return _sprite.GetPosition().Y; } } public void SetPosition(Vector position) { _sprite.SetPosition(position); } public void SetColor(Color color) { _sprite.SetColor(color); } public Bullet(Texture bulletTexture) { _sprite.Texture = bulletTexture; // Some default values Dead = false; Direction = new Vector(1, 0, 0); Speed = 512;// pixels per second } public void Render(Renderer renderer) { if (Dead) { return; } renderer.DrawSprite(_sprite); } public void Update(double elapsedTime) { if (Dead) { return; } Vector position = _sprite.GetPosition(); position += Direction * Speed * elapsedTime; _sprite.SetPosition(position); } } }
The BulletManager
only has two member variables: a list of bullets it’s managing and a rectangle representing the screen bounds. Remember to include the using System.Drawing
statement at the top of the file so that the RectangleF
class can be used. The screen bounds are used to determine if a bullet has left the screen and can be destroyed.
The constructor takes in a rectangle, playArea
, describing the playing area and assigns it to the _bounds
member. The Shoot
method is used to add a bullet to the BulletManager
. Once a bullet is added, the BulletManager
tracks it until it leaves the play area or hits a ship. The Update
method updates all the bullets being tracked and then checks if any are out of bounds; finally, it deletes any bullets that have the Dead
flag set to true.
The CheckOutOfBounds
function uses the rectangle intersection test between the bullet and playing area to determine if it’s off-screen. The RemoveDeadBullets
performs an interesting trick; it iterates through the list of bullets backwards and removes any bullets that are dead. Foreach
can’t be used here and neither can forward iteration; if you were doing forward iteration and removed a bullet, then the list would become shorter by one element, and when the loop got to the end of the list, it would have an out of bounds error. Reversing the iteration of the loop fixes this problem. The length of the list doesn’t matter; it will always head to 0.
The Render
method is quite standard; it just renders out all of the bullets.
This BulletManager
is best placed in the Level
class. If you don’t have a using System.Drawing
statement at the top of the Level.cs file then you will need to add one before you can use the RectangleF
class.
class Level { BulletManager _bulletManager = new BulletManager(new RectangleF(-1300 / 2, -750 / 2, 1300, 750));
The BulletManager
is given a playing area. This is a little bigger than the actual window size. This provides a buffer so that the bullets are totally off-screen before they are destroyed. The BulletManager
then needs to be added to the Update
and Render
methods in the Level
class.
public void Update(double elapsedTime) { UpdateCollisions(); _bulletManager.Update(elapsedTime); // A little later in the code public void Render(Renderer renderer) { _background.Render(renderer); _backgroundLayer.Render(renderer); _enemyList.ForEach(x => x.Render(renderer)); _playerCharacter.Render(renderer); _bulletManager.Render(renderer); }
The BulletManager
is rendered last so that bullets will be rendered on top of everything. At this point, the BulletManager
is fully integrated, but there’s no way to test it without giving the player a way to fire bullets. For this to happen, the PlayerCharacter
class needs access to the manager. In the Level
constructor, pass the BulletManager
into the PlayerCharacter
constructor.
_playerCharacter = new PlayerCharacter(_textureManager, _bulletManager);
The PlayerCharacter
class code then needs to be altered to accept and store a reference to the BulletManager
.
BulletManager _bulletManager; Texture _bulletTexture; public PlayerCharacter(TextureManager textureManager, BulletManager bulletManager) { _bulletManager = bulletManager; _bulletTexture = textureManager.Get("bullet");
The PlayerCharacter
constructor also stores the bulletTexture
that will be used when firing bullets. To fire a bullet, a bullet
object needs to be created and positioned so that it starts near the player and then passes into the BulletManager
. A new Fire
method in the PlayerCharacter
class will be responsible for this.
Vector _gunOffset = new Vector(55, 0, 0); public void Fire() { Bullet bullet = new Bullet(_bulletTexture); bullet.SetColor(new Color(0, 1, 0, 1)); bullet.SetPosition(_sprite.GetPosition() + _gunOffset); _bulletManager.Shoot(bullet); }
The bullet is created using the bulletTexture
that was set up in the constructor. It’s then colored green, but you can choose any color you want. The position of the bullet is set so that it is the same position as the player’s ship, but with an offset so that the bullet appears to come from the front of the ship. If there was no offset, the bullet would appear right in the middle of the ship
sprite and this would look a little weird. The bullet direction isn’t altered because forward on the X axis is the default value. The default speed is also fine. Finally, the bullet is given to the BulletManager
and is officially fired using the Shoot
method.
The player can now fire bullets, but there is no code to detect input and call the Fire
method. All the input for the player is handled in the Level
class in the Update
method. It’s a bit messy to have the input code in the root of the Update
method, so I’ve extracted a new function called UpdateInput
; this helps keep things a bit tidier.
public void Update(double elapsedTime) { UpdateCollisions(); _bulletManager.Update(elapsedTime); _background.Update((float)elapsedTime); _backgroundLayer.Update((float)elapsedTime); _enemyList.ForEach(x => x.Update(elapsedTime)); // Input code has been moved into this method UpdateInput(elapsedTime); } private void UpdateInput(double elapsedTime) { if (_input.Keyboard.IsKeyPressed(Keys.Space) || _input.Controller. ButtonA.Pressed) { _playerCharacter.Fire(); } // Pre-existing input code omitted.
Take all the input code out of the Update
loop and put it at the end of the new UpdateInput
method. This UpdateInput
method is then called from the Update
method. Some new code has also been added to handle the player firing. If the space bar on the keyboard or the A button on the gamepad is pressed, then the player fires a bullet. Run the program and try the spaceship’s new firing abilities.
The new bullets can be seen in Figure 10.8. The bullets are created every time the player hits the fire button. For gameplay reasons, it’s probably best to slow this down a little and give the spaceship a small recovery time between shots. Modify the PlayerCharacter
class as follows.
Vector _gunOffset = new Vector(55, 0, 0); static readonly double FireRecovery = 0.25; double _fireRecoveryTime = FireRecovery; public void Update(double elapsedTime) { _fireRecoveryTime = Math.Max(0, (_fireRecoveryTime - elapsedTime)); } public void Fire() { if (_fireRecoveryTime> 0) { return; } else { _fireRecoveryTime = FireRecovery; } Bullet bullet = new Bullet(_bulletTexture); bullet.SetColor(new Color(0, 1, 0, 1)); bullet.SetPosition(_sprite.GetPosition() + _gunOffset); _bulletManager.Shoot(bullet); }
To count down the recovery time, the PlayerCharacter
class needs an Update
method to be added. The Update
method will count down the recovery time, but it never goes below 0. This is done by using the Math.Max
function. With the recovery time set, the Fire
command returns immediately if the spaceship is still recovering from the last shot. If the recovery time is 0, then the ship can fire and the recovery time is reset so it can start counting down again.
The Level
also needs a minor change; it needs to call the PlayerCharacter
’s Update
method.
public void Update(double elapsedTime) { _playerCharacter.Update(elapsedTime); _background.Update((float)elapsedTime); _backgroundLayer.Update((float)elapsedTime); UpdateCollisions(); _enemyList.ForEach(x => x.Update(elapsedTime)); _bulletManager.Update(elapsedTime); UpdateInput(elapsedTime); }
Run the program again and you won’t be able to fire as fast. This is your game, so tweak the recovery rate to whatever feels right to you!
The bullets have been added, but they sail right through the enemy inflicting no damage; it’s time to change that. The enemy should know when it’s been hit and respond appropriately. We’ll start by handling the collisions and then create an animated explosion.
The collision code is handled in the Level
class, in the UpdateCollisions
function. This function needs to be extended to also handle collisions between bullets and enemies.
private void UpdateCollisions() { foreach (Enemy enemy in _enemyList) { if (enemy.GetBoundingBox().IntersectsWith(_playerCharacter. GetBoundingBox())) { enemy.OnCollision(_playerCharacter); _playerCharacter.OnCollision(enemy); } _bulletManager.UpdateEnemyCollisions(enemy); } }
One extra line has been added to the end of the for
loop. The BulletManager
is asked to check any collisions between the bullets and the current enemy. UpdateEnemyCollisions
is a new function for the BulletManager
, so it needs to be implemented.
internal void UpdateEnemyCollisions(Enemy enemy) { foreach (Bullet bullet in _bullets) { if(bullet.GetBoundingBox().IntersectsWith(enemy.GetBounding- Box())) { bullet.Dead = true; enemy.OnCollision(bullet); } } }
The collision between the bullet and enemy is determined by checking the intersection between the bounding boxes. If the bullet has hit the enemy, then the bullet is destroyed and the enemy is notified about the collision.
If a bullet hits an enemy, there are a number of different ways to react. The enemy could be immediately destroyed and explode, or the enemy could take damage, requiring a few more shots to be destroyed. Assigning health levels to the enemy is probably something we’d like in the future, so we may as well do it now. Let’s add a Health
member variable to the enemy and then we can implement the OnCollision
function for bullets.
public int Health { get; set; } public Enemy(TextureManager textureManager) { Health = 50; // default health value. //Remaining constructor code omitted
The Enemy
class already has one OnCollision
method, but that is for colliding with the PlayerCharacter
. We will create a new overloaded OnCollision
method that is only concerned about colliding with bullets. When a bullet hits the enemy, it will take some damage and lower its health value. If the health of the enemy drops below 0, then it will be destroyed. If the player shoots the enemy and causes some damage, there needs to be some visual feedback to indicate the enemy has taken a hit. A good way to present this feedback is to flash the enemy yellow for a fraction of a second.
static readonly double HitFlashTime = 0.25; double _hitFlashCountDown = 0; internal void OnCollision(Bullet bullet) { // If the ship is already dead then ignore any more bullets. if (Health == 0) { return; } Health = Math.Max(0, Health - 25); _hitFlashCountDown = HitFlashTime; // half _sprite.SetColor(new Engine.Color(1, 1, 0, 1)); if (Health == 0) { OnDestroyed(); } } private void OnDestroyed() { // Kill the enemy here. }
The OnDestroyed
function is a placeholder for now; we’ll worry about how the enemy is destroyed a little later. In the OnCollision
function, the first if
statement checks if the ship is already at 0 health. In this case, any additional damage is ignored; the player has already killed the enemy and the game doesn’t need to acknowledge any more shots. Next the Health
is reduced by 25, an arbitrary damage number, to represent the damage of a single bullet hit. Math.
Max
is used to ensure that the health never falls below 0. The ship should flash yellow when hit. The countdown is set to represent how long the flash should take. The ship sprite is set to a yellow color, which in RGBA is 1,1,0,1. Finally, the health is checked, and if it equals 0, then the placeholder OnDestroyed
method is called. This is the function where the explosion will be triggered.
To cause the ship to flash, the Update
loop will also need to be modified. It needs to count down the flash and change the color from yellow to white.
public void Update(double elapsedTime) { if (_hitFlashCountDown != 0) { _hitFlashCountDown = Math.Max(0, _hitFlashCountDown - elapsedTime); double scaledTime = 1 - (_hitFlashCountDown / HitFlashTime); _sprite.SetColor(new Engine.Color(1, 1, (float)scaledTime, 1)); } }
The Update
loop modifies the flash color of the enemy spaceship. If the flash countdown has already dropped to 0, then the flash has finished and doesn’t need to be updated. If the _hitFlashCountDown
doesn’t equal 0, then it is reduced by the amount of time that has passed since the last frame. Math.Max
is used again to ensure the count doesn’t fall below 0. The countdown is then scaled to get a value from 0 to 1, indicating how far through the flash we currently are; 0 indicates the flash has just started and 1 indicates it’s finished. This number is inversed by subtracting it from 1 so that 1 indicates that the flash has just started and 0 indicates that it’s just finished. This scaled number is then used to move the blue channel of the color from 0 to 1. This will flash the ship from yellow to white.
Run the program and shoot the enemy ship a few times; it will flash yellow a few times and then stop responding because it’s been destroyed. Enemy ships shouldn’t just stop responding; they should explode!
The easiest way to produce a good explosion is to use an animated sprite. Figure 10.9 shows a keyframe texture map of an explosion. This texture was created using a procedural explosion generator available for free from Positech games (http://www.positech.co.uk/content/explosion/explosiongenerator.html).
Figure 10.9 has 16 frames in total; four frames in height and four frames in length. An animated sprite can be created by reading in this texture and changing the U,V coordinates so that it moves from the first frame to the last frame as time passes. An animated sprite is really just a different type of sprite, so to create it, we can extend the existing Sprite
class. An animated sprite is something that can be used by many different games, so it should be created in the Engine project rather than the game project.
public class AnimatedSprite : Sprite { int _framesX; int _framesY; int _currentFrame = 0; double _currentFrameTime = 0.03; public double Speed { get; set; } // seconds per frame
public bool Looping { get; set; } public bool Finished { get; set; } public AnimatedSprite() { Looping = false; Finished = false; Speed = 0.03; // 30 fps-ish _currentFrameTime = Speed; } public System.Drawing.Point GetIndexFromFrame(int frame) { System.Drawing.Point point = new System.Drawing.Point(); point.Y = frame / _framesX; point.X = frame - (point.Y * _framesY); return point; } private void UpdateUVs() { System.Drawing.Point index = GetIndexFromFrame(_currentFrame); float frameWidth = 1.0f / (float)_framesX; float frameHeight = 1.0f / (float)_framesY; SetUVs(new Point(index.X * frameWidth, index.Y * frameHeight), new Point((index.X + 1) * frameWidth, (index.Y + 1) * frameHeight)); } public void SetAnimation(int framesX, int framesY) { _framesX = framesX; _framesY = framesY; UpdateUVs(); } private int GetFrameCount() { return _framesX * _framesY; } public void AdvanceFrame() { int numberOfFrames = GetFrameCount(); _currentFrame = (_currentFrame + 1) % numberOfFrames; } public int GetCurrentFrame() { return _currentFrame; } public void Update(double elapsedTime) { if (_currentFrame == GetFrameCount() - 1 && Looping == false) { Finished = true; return; } _currentFrameTime -= elapsedTime; if (_currentFrameTime< 0) { AdvanceFrame(); _currentFrameTime = Speed; UpdateUVs(); } } }
This AnimatedSprite
class works exactly the same as the Sprite
class, except for the AnimatedSprite
class can be told how many frames the texture has in X and Y dimensions. When the Update
loop is called, the frame is changed over time.
This class has quite a few members, but they are mostly used for describing the animation and tracking its progress. The number of frames in X and Y dimension are described by the _framesX
and _framesY
member variables. For the Figure 10.9 example, both these variables would be set to four. The _currentFrame
variable is the frame that the sprite U,Vs are currently set to. The _currentFrameTime
is the amount of time that will be spent on the current frame before the animation advances to the next frame. Speed
is a measure of how much time is spent on each frame in seconds. Looping
determines if the animation should loop, and Finished
is a flag that is set to true once the animation has ended.
The constructor of the AnimatedSprite
sets some default values. A freshly created sprite doesn’t loop, and has its Finished
flag set to false, its frame speed set to about 30 frames per second, and the _currentFrameTime
is set to 0.03 seconds, which will make the animation run at 30 frames per second.
The GetIndexFromFrame
method takes an index as shown in Figure 10.10 and returns an X,Y coordinate of the index position. For example, index 0 would return 0,0 and index 15 would return 3,3. The index number is broken into an X and Y coordinate by dividing the index by the row length; this gives the number of rows and therefore the Y coordinate of the index. The X coordinate is then whatever is left of the index when the Y rows are removed. This function is very useful when translating, calculating the U,Vs for a certain frame.
UpdateUVs
uses the current frame index to change the U,Vs so the sprite correctly represents that frame. It first gets the X,Y coordinates of the current frame using GetIndexFromFrame
. Then it calculates the width and height of an individual frame. As texture coordinates range from 0 to 1, the width and height of a single frame is calculated by dividing the number of frames along the X and the Y by 1. Once the dimensions of a single frame are calculated, the positions of the U,Vs can be worked out by multiplying the frame width and height by the X,Y coordinates of the current frame; this gets the top-left point of the frame on the texture map. The SetUVs
method requires a TopLeft
and BottomRight
point. The BottomRight
position is calculated from the TopLeft
position by adding an extra frame width and height.
SetAnimation
is the method used to set the number of frames along the X and Y of the texture map. It makes a call to UpdateUVs
so that the sprite is updated to display the correct frame. GetFrameCount
gets the total number of frames in the animation. The AdvanceFrame
method moves the animation to the next frame if it comes to the end of the frames; then the frame index wraps around to 0 again. The wrap around is done using modulus—the %
operator. The modulus operator computes the remainder that results from performing integer division. The best way to understand the use of the modulus operator is to provide an example you are probably already familiar with: time. A clock face has 12 numbers, and it works in modulo 12: 13:00 hours in modulo 12 is 1 o’clock. In our case, the modulo is equal to the total number of frames in the animation.
The Update
method is responsible for updating the current frame and making the explosion appear to animate. If Looping
is set to false and the current frame is the last frame, then the Update
method returns immediately and the Finished
flag is set to true. If the animation hasn’t finished or is looping, then the frame countdown, _currentFrameTime
, is updated, and if it goes below 0, the frame needs to be changed. The frame is updated by making a call to AdvanceFrame
, resetting the _currentFrameTime
, and finally updating the U,Vs.
With the AnimatedSprite
class added to the Engine project, the explosion animation can be tested. Find the explode.tga file on the CD in the Assets folder and add it to the project, setting the properties as usual. It can then be loaded in the form.cs file with the other textures.
_textureManager.LoadTexture("explosion", "explode.tga");
A quick way to test the animation is to load it directly into the Level
as an animated sprite.
AnimatedSprite _testSprite = new AnimatedSprite();public Level(Input input, TextureManager textureManager, PersistantGameData gameData) { _testSprite.Texture = textureManager.Get("explosion"); _testSprite.SetAnimation(4, 4); // a little later in the code public void Update(double elapsedTime) { _testSprite.Update(elapsedTime); // a little later in the code public void Render(Renderer renderer) { // Background and other sprite code omitted. renderer.DrawSprite(_testSprite); renderer.Render(); }
Running the program and entering a level will now play the explosion animation once. This confirms everything is working fine (see Figure 10.11).
In the last section, we got an example explosion working, but it really needs to be set off only when enemies are destroyed. To this end, two new systems need to be created: one to handle the explosions and general game effects, and one to handle the oncoming enemies.
The explosions should be handled in a similar way to the bullets—creating a dedicated manager that handles the creation and destruction of the explosions. In the future of your project, it’s possible you’ll want more effects—smokes, sparks, or even power ups—than explosions. The EffectsManager
class should be created in the Shooter project.
using System; using System.Collections.Generic; using System.Linq; using System.Text; using Engine; namespace Shooter { public class EffectsManager { List<AnimatedSprite> _effects = new List<AnimatedSprite>(); TextureManager _textureManager; public EffectsManager(TextureManager textureManager) { _textureManager = textureManager; } public void AddExplosion(Vector position) { AnimatedSprite explosion = new AnimatedSprite(); explosion.Texture = _textureManager.Get("explosion"); explosion.SetAnimation(4, 4); explosion.SetPosition(position); _effects.Add(explosion); } public void Update(double elapsedTime) { _effects.ForEach(x => x.Update(elapsedTime)); RemoveDeadExplosions(); } public void Render(Renderer renderer) { _effects.ForEach(x => renderer.DrawSprite(x)); } private void RemoveDeadExplosions() { for (int i = _effects.Count - 1; i>= 0; i-) { if (_effects[i].Finished) { _effects.RemoveAt(i); } } } } }
This EffectManager
allows an explosion to be set off, runs the explosion animation until it ends, and then removes the explosion effect. You may notice it’s very similar to the BulletManager
class. These separate managers could all be combined in one generalized manager, but by keeping them separate, the interactions between the game objects can be specific and more efficient. Explosions don’t care about collision detection with enemies or players but bullets do. In separate managers, it’s easy to separate out the particular requirements of each object; explosions only need to run an animation, whereas bullets need to check for intersection with all of the enemies. Separate managers work great when only a limited number of objects are in the game, but if there are going to be many different entities, then a more generalized entity manager is a better choice.
The EffectsManager
needs to be initialized in the Level
class and hooked up to the render and update loops.
EffectsManager _effectsManager; public Level(Input input, TextureManager textureManager, PersistantGa- meData gameData) { _input = input; _gameData = gameData; _textureManager = textureManager; _effectsManager = new EffectsManager(_textureManager); // code omitted public void Update(double elapsedTime) { _effectsManager.Update(elapsedTime); // code omitted public void Render(Renderer renderer) { // Background, sprites and bullet code omitted _effectsManager.Render(renderer); renderer.Render(); }
The ExplosionManager
is now hooked up and can be used to launch several explosions at once. For the enemies to launch explosions when they die, they need access to the manager, which can be passed into the constructor.
EffectsManager _effectsManager; public Enemy(TextureManager textureManager, EffectsManager effectsManager) { _effectsManager = effectsManager;
The enemy can now set off an explosion when it dies.
private void OnDestroyed() { // Kill the enemy here. _effectsManager.AddExplosion(_sprite.GetPosition()); }
In the Level.cs file, the EffectsManager
needs to be passed into the Enemy
constructor. Once this is done, shooting the enemy a couple of times in the game will cause an explosion when the enemy is destroyed.
Next, the enemies will get their own manager; this will be the final manager needed to create a full, working game.
public class EnemyManager { List<Enemy> _enemies = new List<Enemy>(); TextureManager _textureManager; EffectsManager _effectsManager; int _leftBound; public List<Enemy> EnemyList { get { return _enemies; } } public EnemyManager(TextureManager textureManager, EffectsManager effectsManager, int leftBound) { _textureManager = textureManager; _effectsManager = effectsManager; _leftBound = leftBound; // Add a test enemy. Enemy enemy = new Enemy(_textureManager, _effectsManager); _enemies.Add(enemy); } public void Update(double elapsedTime) { _enemies.ForEach(x => x.Update(elapsedTime)); CheckForOutOfBounds(); RemoveDeadEnemies(); } private void CheckForOutOfBounds() { foreach (Enemy enemy in _enemies) { if (enemy.GetBoundingBox().Right< _leftBound) { enemy.Health = 0; // kill the enemy off } } } public void Render(Renderer renderer) { _enemies.ForEach(x => x.Render(renderer)); } private void RemoveDeadEnemies() { for (int i = _enemies.Count - 1; l> = 0; i-) { if (_enemies[i].IsDead) { _enemies.RemoveAt(i); } } } }
An extra function needs to be added to the Enemy
class to check if the enemy has been destroyed.
class Enemy : Entity { public bool IsDead { get { return Health == 0; } }
The IsDead
method of the Enemy
class returns true
if the enemy’s health is equal to 0; otherwise, it returns false
. The EnemyManager
, like the BulletManager
, has an out of bounds check, but it’s a little different. Enemies in a scrolling shooter game tend to start off on the far right of the screen and then move past the player exiting to the left. The out of bounds check compares the right-most point of the enemy bounding box against the left-most part of the screen. This removes enemies that the player fails to destroy and that escape off the left of the screen.
The Level
class now needs to be modified to introduce this new manager and get rid of the old list.
// List<Enemy> _enemyList = new List<Enemy>();< - Removed EnemyManager _enemyManager; public Level(Input input, TextureManager textureManager, PersistantGa- meData gameData) { _input = input; _gameData = gameData; _textureManager = textureManager; _background = new ScrollingBackground(textureManager.Get ("background")); _background.SetScale(2, 2); _background.Speed = 0.15f; _backgroundLayer = new ScrollingBackground(textureManager.Get ("background_layer_1")); _backgroundLayer.Speed = 0.1f; _backgroundLayer.SetScale(2.0, 2.0); _playerCharacter = new PlayerCharacter(_textureManager, _bulletManager); _effectsManager = new EffectsManager(_textureManager); // _enemyList.Add(new Enemy(_textureManager, _effectsManager)); <- Removed _enemyManager = new EnemyManager(_textureManager, _effectsMana- ger, -1300); }
The collision processing needs to change a little as well; it will now use the list of enemies in the EnemyManager
when checking for enemy collisions.
private void UpdateCollisions() { foreach (Enemy enemy in _enemyManager.EnemyList)
To be able to see the enemies, the Update
and Render
loops need to be modified.
public void Update(double elapsedTime) { // _enemyList.ForEach(x => x.Update(elapsedTime));<- Remove this line _enemyManager.Update(elapsedTime); // Code omitted public void Render(Renderer renderer) { _background.Render(renderer); _backgroundLayer.Render(renderer); //_enemyList.ForEach(x => x.Render(renderer));<- remove this line _enemyManager.Render(renderer);
Run the program now. Shooting the enemy a couple of times will make it explode and disappear. This has started to become much more game-like. The most obvious failings at the moment are that there is only one enemy and it doesn’t move.
The current level lasts for 30 seconds and has one enemy at the start—this isn’t a very interesting level. If there was some system for defining levels, then it would be easier to add a bit more excitement to this level. The level definition is a list of enemies to spawn at certain times. A level definition will therefore need some way to define enemies; the following code is a good starting point. The EnemyDef
class should be added to the Engine project.
using System; using System.Collections.Generic; using System.Linq; using System.Text; using Engine; namespace Shooter { class EnemyDef { public string EnemyType { get; set; } public Vector StartPosition { get; set; } public double LaunchTime { get; set; } public EnemyDef() { EnemyType = "cannon_fodder"; StartPosition = new Vector(300, 0, 0); LaunchTime = 0; } public EnemyDef(string enemyType, Vector startPosition, double launchTime) { EnemyType = enemyType; StartPosition = startPosition; LaunchTime = launchTime; } } }
There is a string that describes the enemy type. In the code, we might provide several different types of enemies: small fast ones, big slow ones, etc. The default enemy type is cannon fodder, and that’s what we’ve got now. The start position is off the right of the screen. The launch time is the time at which the enemy will appear in the level. The level time counts down from some large number to 0. If the gameTime
goes lower than the launch time, then an enemy
object will be created and it will be launched into the level.
The EnemyManager
is the class that will handle the enemy spawning. This means the constructor needs to be modified, and a list of upcoming enemies needs to be added.
List<EnemyDef> _upComingEnemies = new List<EnemyDef>(); public EnemyManager(TextureManager textureManager, EffectsManager effectsManager, int leftBound) { _textureManager = textureManager; _effectsManager = effectsManager; _leftBound = leftBound; _upComingEnemies.Add(new EnemyDef("cannon_fodder", new Vector(300, 300, 0), 25)); _upComingEnemies.Add(new EnemyDef("cannon_fodder", new Vector(300, -300, 0), 30)); _upComingEnemies.Add(new EnemyDef("cannon_fodder", new Vector(300, 0, 0), 29)); // Sort enemies so the greater launch time appears first. _upComingEnemies.Sort(delegate(EnemyDef firstEnemy, EnemyDef secondEnemy) { return firstEnemy.LaunchTime.CompareTo(secondEnemy.LaunchTime); }); }
The _upcomingEnemies
list is a list of enemy definitions sorted by launch time. The greater the launch time, the higher in the list the definition appears. Each frame the top item of the list is checked to see if it’s ready to launch. Only the top enemy definition needs to be checked because the list is sorted. If the list wasn’t sorted, then every item in the list would need to be checked to decide which of the enemy definitions had a launch time greater than the current gameTime
, and therefore needed to be launched next.
This enemy launching is done in the Update
loop of the EnemyManager
, which calls the new method UpdateEnemySpawns
.
private void UpdateEnemySpawns(double gameTime) { // If no upcoming enemies then there's nothing to spawn. if (_upComingEnemies.Count == 0) { return; } EnemyDef lastElement = _upComingEnemies[_upComingEnemies.Count - 1]; if (gameTime< lastElement.LaunchTime) { _upComingEnemies.RemoveAt(_upComingEnemies.Count - 1); _enemies.Add(CreateEnemyFromDef(lastElement)); } } private Enemy CreateEnemyFromDef(EnemyDef definition) { Enemy enemy = new Enemy(_textureManager, _effectsManager); enemy.SetPosition(definition.StartPosition); if (definition.EnemyType == "cannon_fodder") { // The enemy type could be used to alter the health or texture // but we're using the default texture and health for the cannon fodder type } else { System.Diagnostics.Debug.Assert(false, "Unknown enemy type."); } return enemy; } public void Update(double elapsedTime, double gameTime) { UpdateEnemySpawns(gameTime);
The Update
methods in the EnemyManager
and Level
class have been modified to take in a gameTime
parameter. The gameTime
is a number that counts down to zero, at which point the level will end. This value is used to determine when to create new enemies. The InnerGameState
has to pass this gameTime
value into the Update
method of the Level
object, and the Level
passes it on to the EnemyManager
.
// In Level.cs public void Update(double elapsedTime, double gameTime) { _enemyManager.Update(elapsedTime, gameTime); // In InnerGameState.cs public void Update(double elapsedTime) { _level.Update(elapsedTime, _gameTime);
The gameTime
is passed all the way from the inner game state down to the UpdateEnemySpawns
function in the EnemyManager
. UpdateEnemy-Spawns
first checks if there are any upcoming enemies in the _upcomingEnemies
list; if there aren’t, then the method does nothing. If there are some upcoming enemies, the code checks the top of the list to see if it’s ready to be launched. If the enemy definition is ready to be launched, then it’s removed from the _upcomingEnemies
list and the definition is used to make a new enemy object. The newly created enemy is then added to the _enemies
list, spawning it in the game world.
CreateEnemyFromDef
does pretty much what it says; it takes an EnemyDef
object and returns an Enemy object. There’s only one type of enemy at the moment so it’s quite a simple function, but there’s a lot of scope for adding new enemy types.
Run the program now and as the level time ticks down, three enemies will spawn in the level.
Enemies in a scrolling shooter should sweep in from the right of the screen and attempt to exit to the right without getting blown up. The enemy advance is shown in Figure 10.12. The player bullets already have movement code so the enemies could reuse that code. This would work, but the enemy movement would be pretty boring; they’d move from right to left in a straight line. Enemy movement should be far more interesting, and the easiest way to do this is to give each enemy a predefined path with a number of way points. The enemy will hit all the way points and then exit to the left.
A path can be described easily as a series of points that lead from the right of the screen to the left of the screen. Figure 10.13 shows a path made up of points that could describe an enemy’s path through the playing area.
This path can be joined together to produce something like Figure 10.14. This shows the path the enemy would use, but the corners are very jagged. It would be nice if we could get something smoother. Splines are a nice way of creating smooth paths. Figure 10.15 shows a Catmull-Rom spline; this type of spline is guaranteed to pass through all the control points. Edwin Catmull who worked at Pixar and helped create Toy Story co-invented this type of spline with Raphael Rom.
The spline is obviously smoother, but it does require another class to be created. Splines are a mathematical description of a curve.
Catmull-Rom splines are simply a way to get a position, t, between any two of the points that make up the spline. In Catmull-Rom splines, the two points on either side of t are used in the calculation, as are their two neighbors, as shown in Figure 10.16.
Once some position can be obtained for a value of t (0-1) between any two neighboring points, then this can be extended so that t (0-1) can be mapped on to the entire line, not just one section. The calculation to get a position from t and four points is as follows.
This looks a little intimidating; three matrices are multiplied by a scalar that weighs all four points and decides how the t value is transformed into a position. It’s not important to understand exactly how this works (though you are encouraged to investigate!); it’s good enough to know what results will occur when you apply it.
Here is the C# implementation of a Catmull-Rom spline. This class should be added to the Engine project as it will be useful for more than this project. The spline code works in 3D so it can also be useful for tasks such as manipulating cameras or moving 3D entities along a path. The interface for this spline
class is based on Radu Gruian’s C++ Overhauser code (http://www.codeproject.com/KB/recipes/Overhauser.aspx —the Code Project website may require you to register before it allows you to view the article. Registration is free. ).
public class Spline { List<Vector> _points = new List<Vector>(); double _segmentSize = 0; public void AddPoint(Vector point) { _points.Add(point); _segmentSize = 1 / (double)_points.Count; } private int LimitPoints(int point) { if(point< 0) { return 0; } else if (point> _points.Count - 1) { return _points.Count - 1; } else { return point; } } // t ranges from 0 - 1 public Vector GetPositionOnLine(double t) { if (_points.Count<= 1) { return new Vector(0,0,0); } // Get the segment of the line we're dealing with. int interval = (int)(t / _segmentSize); // Get the points around the segment int p0 = LimitPoints(interval - 1); int p1 = LimitPoints(interval); int p2 = LimitPoints(interval + 1); int p3 = LimitPoints(interval + 2); // Scale t to the current segment double scaledT = (t - _segmentSize * (double)interval) / _segmentSize; return CalculateCatmullRom(scaledT, _points[p0], _points[p1], _points[p2], _points[p3]); } private Vector CalculateCatmullRom(double t, Vector p1, Vector p2, Vector p3, Vector p4) { double t2 = t * t; double t3 = t2 * t; double b1 = 0.5 * (-t3 + 2 * t2 - t); double b2 = 0.5 * (3 * t3 - 5 * t2 + 2); double b3 = 0.5 * (-3 * t3 + 4 * t2 + t); double b4 = 0.5 * (t3 - t2); return (p1 * b1 + p2 * b2 + p3 * b3 + p4 * b4); } }
This spline
class is very simple to use. Any number of points can be added and the spline will join them together. The line is indexed from 0 to 1; a position on the line of 0.5 will return whatever point in space the middle of the line crosses. This makes the spline very easy to use with the earlier tween
class. The spline requires all control points to be evenly spaced to give uniform values of t.
Each enemy is given a new Path
class that will guide it across the level. This Path
class is specific to the shooting game and should be created in the Shooter project.
public class Path { Spline _spline = new Spline(); Tween _tween; public Path(List<Vector> points, double travelTime) { foreach (Vector v in points) { _spline.AddPoint(v); } _tween = new Tween(0, 1, travelTime); } public void UpdatePosition(double elapsedTime, Enemy enemy) { _tween.Update(elapsedTime); Vector position = _spline.GetPositionOnLine(_tween.Value()); enemy.SetPosition(position); } }
The class constructor takes in a time and a list of points; from this it creates a spline
and a tween
object. The travelTime
determines how long the enemy will take to travel the path defined by the spline. The UpdatePosition
method updates the tween and gets a new position from the spline, which is used to reposition the enemy. The following code modifies the Enemy
to use the Path
class.
public Path Path { get; set; } public void Update(double elapsedTime) { if (Path != null) { Path.UpdatePosition(elapsedTime, this); } if (_hitFlashCountDown != 0) { _hitFlashCountDown = Math.Max(0, _hitFlashCountDown - elapsedTime); double scaledTime = 1 - (_hitFlashCountDown / HitFlashTime); _sprite.SetColor(new Engine.Color(1, 1, (float)scaledTime, 1)); } }
Now that all enemies have paths, the StartPosition
variable from the EnemyDef
can be removed, as the path will define where the enemy starts. Enemies can move through the level, but to do this they need to be given a path. In the EnemyManager
, when an enemy is created, it needs to be given a path. In the following the cannon_fodder
enemy type is given a path that goes from right to left, veering upwards as it reaches the middle. The time for the enemy to follow the full path takes ten seconds.
private Enemy CreateEnemyFromDef(EnemyDef definition) { Enemy enemy = new Enemy(_textureManager, _effectsManager); //enemy.SetPosition(definition.StartPosition);<- this line can be removed if (definition.EnemyType == "cannon_fodder") { List<Vector> _pathPoints = new List<Vector>(); _pathPoints.Add(new Vector(1400, 0, 0)); _pathPoints.Add(new Vector(0, 250, 0)); _pathPoints.Add(new Vector(-1400, 0, 0)); enemy.Path = new Path(_pathPoints, 10); } else { System.Diagnostics.Debug.Assert(false, "Unknown enemy type."); } return enemy; }
Now a more interesting level can be defined by editing the EnemyManager
constructor.
public EnemyManager(TextureManager textureManager, EffectsManager effectsManager, int leftBound) { _textureManager = textureManager; _effectsManager = effectsManager; _leftBound = leftBound; _textureManager = textureManager; _effectsManager = effectsManager; _leftBound = leftBound; _upComingEnemies.Add(new EnemyDef("cannon_fodder", 30)); _upComingEnemies.Add(new EnemyDef("cannon_fodder", 29.5)); _upComingEnemies.Add(new EnemyDef("cannon_fodder", 29)); _upComingEnemies.Add(new EnemyDef("cannon_fodder", 28.5)); _upComingEnemies.Add(new EnemyDef("cannon_fodder", 25)); _upComingEnemies.Add(new EnemyDef("cannon_fodder", 24.5)); _upComingEnemies.Add(new EnemyDef("cannon_fodder", 24)); _upComingEnemies.Add(new EnemyDef("cannon_fodder", 23.5)); _upComingEnemies.Add(new EnemyDef("cannon_fodder", 20)); _upComingEnemies.Add(new EnemyDef("cannon_fodder", 19.5)); _upComingEnemies.Add(new EnemyDef("cannon_fodder", 19)); _upComingEnemies.Add(new EnemyDef("cannon_fodder", 18.5)); // Sort enemies so the greater launch time appears first. _upComingEnemies.Sort(delegate(EnemyDef firstEnemy, EnemyDef secondEnemy) { return firstEnemy.LaunchTime.CompareTo(secondEnemy.LaunchTime); }); }
The enemies now use paths to describe how they move through the level so a start position for each enemy definition is no longer required. This means the EnemyDef
class needs to be rewritten.
public class EnemyDef { public string EnemyType { get; set; } public double LaunchTime { get; set; } public EnemyDef() { EnemyType = "cannon_fodder"; LaunchTime = 0; } public EnemyDef(string enemyType, double launchTime) { EnemyType = enemyType; LaunchTime = launchTime; } }
Run the code again, and you will see a stream of enemies arcing through the top half of the screen, as shown in Figure 10.17.
At this point, it might be nice to add a few more enemy types to spice up the level.
private Enemy CreateEnemyFromDef(EnemyDef definition) { Enemy enemy = new Enemy(_textureManager, _effectsManager); if (definition.EnemyType == "cannon_fodder") { List<Vector> _pathPoints = new List<Vector>(); _pathPoints.Add(new Vector(1400, 0, 0)); _pathPoints.Add(new Vector(0, 250, 0)); _pathPoints.Add(new Vector(-1400, 0, 0)); enemy.Path = new Path(_pathPoints, 10); } else if (definition.EnemyType == "cannon_fodder_low") { List<Vector> _pathPoints = new List<Vector> (); _pathPoints.Add(new Vector(1400, 0, 0)); _pathPoints.Add(new Vector(0, -250, 0)); _pathPoints.Add(new Vector(-1400, 0, 0)); enemy.Path = new Path(_pathPoints, 10); } else if (definition.EnemyType == "cannon_fodder_straight") { List<Vector> _pathPoints = new List<Vector> (); _pathPoints.Add(new Vector(1400, 0, 0)); _pathPoints.Add(new Vector(-1400, 0, 0)); enemy.Path = new Path(_pathPoints, 14); } else if (definition.EnemyType == "up_l") { List<Vector> _pathPoints = new List<Vector> (); _pathPoints.Add(new Vector(500, -375, 0)); _pathPoints.Add(new Vector(500, 0, 0)); _pathPoints.Add(new Vector(500, 0, 0)); _pathPoints.Add(new Vector(-1400, 200, 0)); enemy.Path = new Path(_pathPoints, 10); } else if (definition.EnemyType == "down_l") { List<Vector> _pathPoints = new List<Vector> (); _pathPoints.Add(new Vector(500, 375, 0)); _pathPoints.Add(new Vector(500, 0, 0)); _pathPoints.Add(new Vector(500, 0, 0)); _pathPoints.Add(new Vector(-1400, -200, 0)); enemy.Path = new Path(_pathPoints, 10); } else { System.Diagnostics.Debug.Assert(false, "Unknown enemy type."); } return enemy; }
Each of these enemies has an interesting path and can be put together to form a more interesting level. Here is some new Level
set up code for the EnemyManager
constructor.
public EnemyManager(TextureManager textureManager, EffectsManager effectsManager, int leftBound) { _textureManager = textureManager; _effectsManager = effectsManager; _leftBound = leftBound; _upComingEnemies.Add(new EnemyDef("cannon_fodder", 30)); _upComingEnemies.Add(new EnemyDef("cannon_fodder", 29.5)); _upComingEnemies.Add(new EnemyDef("cannon_fodder", 29)); _upComingEnemies.Add(new EnemyDef("cannon_fodder", 28.5)); _upComingEnemies.Add(new EnemyDef("cannon_fodder_low", 30)); _upComingEnemies.Add(new EnemyDef("cannon_fodder_low", 29.5)); _upComingEnemies.Add(new EnemyDef("cannon_fodder_low", 29)); _upComingEnemies.Add(new EnemyDef("cannon_fodder_low", 28.5)); _upComingEnemies.Add(new EnemyDef("cannon_fodder", 25)); _upComingEnemies.Add(new EnemyDef("cannon_fodder", 24.5)); _upComingEnemies.Add(new EnemyDef("cannon_fodder", 24)); _upComingEnemies.Add(new EnemyDef("cannon_fodder", 23.5)); _upComingEnemies.Add(new EnemyDef("cannon_fodder_low", 20)); _upComingEnemies.Add(new EnemyDef("cannon_fodder_low", 19.5)); _upComingEnemies.Add(new EnemyDef("cannon_fodder_low", 19)); _upComingEnemies.Add(new EnemyDef("cannon_fodder_low", 18.5)); _upComingEnemies.Add(new EnemyDef("cannon_fodder_straight", 16)); _upComingEnemies.Add(new EnemyDef("cannon_fodder_straight", 15.8)); _upComingEnemies.Add(new EnemyDef("cannon_fodder_straight", 15.6)); _upComingEnemies.Add(new EnemyDef("cannon_fodder_straight", 15.4)); _upComingEnemies.Add(new EnemyDef("up_l", 10)); _upComingEnemies.Add(new EnemyDef("down_l", 9)); _upComingEnemies.Add(new EnemyDef("up_l", 8)); _upComingEnemies.Add(new EnemyDef("down_l", 7)); _upComingEnemies.Add(new EnemyDef("up_l", 6)); // Sort enemies so the greater launch time appears first. _upComingEnemies.Sort(delegate(EnemyDef firstEnemy, EnemyDef secondEnemy) { return firstEnemy.LaunchTime.CompareTo(secondEnemy.LaunchTime); }); }
Try out the level; you may find it a little challenging so this is a great time to play around with the code and balance the game a little. Try increasing the player firing rate or the spaceship speed.
Enemies move in interesting ways across the level, but they’re still quite passive. The player can blast away at them and they do nothing! The enemies should have some kind of recourse, so in this section we’ll look at turning the tables a little.
There’s already a BulletManager
, and this currently handles only the player bullets. The enemy bullets will only affect the player, and the player bullets will only affect the enemies. For this reason, it’s easiest to have separate lists of bullets. This means a few of the functions need to be generalized to accept a list of bullets.
public class BulletManager { List<Bullet> _bullets = new List<Bullet>(); List<Bullet> _enemyBullets = new List<Bullet>(); // Code omitted public void Update(double elapsedTime) { UpdateBulletList(_bullets, elapsedTime); UpdateBulletList(_enemyBullets, elapsedTime); } public void UpdateBulletList(List<Bullet> bulletList, double elapsedTime) { bulletList.ForEach(x => x.Update(elapsedTime)); CheckOutOfBounds(_bullets); RemoveDeadBullets(bulletList); } private void CheckOutOfBounds(List<Bullet> bulletList) { foreach (Bullet bullet in bulletList) { if (!bullet.GetBoundingBox().IntersectsWith(_bounds)) { bullet.Dead = true; } } } private void RemoveDeadBullets(List<Bullet> bulletList) { for (int i = bulletList.Count - 1; i> = 0; i-) { if (bulletList[i].Dead) { bulletList.RemoveAt(i); } } } internal void Render(Renderer renderer) { _bullets.ForEach(x => x.Render(renderer)); _enemyBullets.ForEach(x => x.Render(renderer)); }
The above code introduces a second list for enemy bullets; it now requires a function that will let the enemy shoot bullets and a function to check if any of them hit the player.
public void EnemyShoot(Bullet bullet) { _enemyBullets.Add(bullet); } public void UpdatePlayerCollision(PlayerCharacter playerCharacter) { foreach (Bullet bullet in _enemyBullets) { if(bullet.GetBoundingBox().IntersectsWith(playerCharacter. GetBoundingBox())) { bullet.Dead = true; playerCharacter.OnCollision(bullet); } } }
The UpdatePlayerCollision
is quite similar to the existing UpdateEnemyCollision
method and eventually they should be combined, but for this iteration of the game development, it’s easier if they stay separate. The PlayerCharacter
class needs a new OnCollision
method that takes in a bullet object.
internal void OnCollision(Bullet bullet) { _dead = true; }
The PlayerCharacter
now has two collision methods: one for bullets and one for enemies. The PlayerCharacter
dies if he touches an enemy or a bullet so these methods are redundant. The reason they have been written this way is to make extending the game easier. It’s important to know what the player is colliding with. If the player is given a health value, then colliding with an enemy may cause more damage than a bullet. If missiles, mines, or various types of power-up are added, they too can have an extra collision method to deal with that case.
Shooter is a very strict game. If the player hits an enemy, he immediately loses. The same is true if he hits a bullet. The BulletManager
now needs an extra call in the Update
loop of the Level
class to test if an enemy bullet has hit the player.
private void UpdateCollisions() { _bulletManager.UpdatePlayerCollision(_playerCharacter);
For the enemies to use their newfound shooting powers, they need access to the BulletManager
class. This can be passed into the EnemyManager
and into each individual enemy from there. Here it’s passed into the EnemyManager
from the Level
class constructor.
public Level(Input input, TextureManager textureManager, Persistant GameData gameData) { _input = input; _gameData = gameData; _textureManager = textureManager; _effectsManager = new EffectsManager(_textureManager); _enemyManager = new EnemyManager(_textureManager, _effectsManager, _bulletManager, -1300);
In the following code, the EnemyManager
stores a reference to the Bullet-Manager
and uses it when constructing enemies.
BulletManager _bulletManager; public EnemyManager(TextureManager textureManager, EffectsManager effectsManager, BulletManager bulletManger, int leftBound) { _bulletManager = bulletManger; // Code omitted private Enemy CreateEnemyFromDef(EnemyDef definition) { Enemy enemy = new Enemy(_textureManager, _effectsManager, _bulletManager);
The enemies now have the BulletManager
and with it the power to start shooting bullets. The question now is when should the enemies shoot? They can’t shoot every frame or the game would be far too hard. The enemies shouldn’t all fire at the same time or it will be far too difficult. The trick is to set the firing times randomly for each enemy.
public double MaxTimeToShoot { get; set; } public double MinTimeToShoot { get; set; } Random _random = new Random(); double _shootCountDown; public void RestartShootCountDown() { _shootCountDown = MinTimeToShoot + (_random.NextDouble() * MaxTimeToShoot); } BulletManager _bulletManager; Texture _bulletTexture; public Enemy(TextureManager textureManager, EffectsManager effectsManager, BulletManager bulletManager) { _bulletManager = bulletManager; _bulletTexture = textureManager.Get("bullet"); MaxTimeToShoot = 12; MinTimeToShoot = 1; RestartShootCountDown(); // Code omitted public void Update(double elapsedTime) { _shootCountDown = _shootCountDown - elapsedTime; if (_shootCountDown<= 0) { Bullet bullet = new Bullet(_bulletTexture); bullet.Speed = 350; bullet.Direction = new Vector(-1, 0, 0); bullet.SetPosition(_sprite.GetPosition()); bullet.SetColor(new Engine.Color(1, 0, 0, 1)); _bulletManager.EnemyShoot(bullet); RestartShootCountDown(); }
When the enemy is created, it sets a timer for the next time it will shoot. The timer is set using C#’s Random
class and a minimum and maximum time. The timer will be set somewhere in between these minimum and maximum values. All ships will shoot at different times. The RestartShootCountDown
method sets the random time when the enemy will shoot. Math.NextDouble
returns a random number from 0 to 1, which is scaled between the MinTimeToShoot
and MaxTimeToShoot
member variables.
The Update
loop ticks down the _shootCountDown,
and once it is equal to or below 0 the enemy fires a bullet. The bullet is made to be slower than the player bullets and it’s shot in the opposite direction. The enemy bullets are also colored red so it’s obvious they’re different from the players. Once the enemy shoots, the _shootCountDown
timer is reset.
The enemies shoot towards the left of the screen. You may want to make it a little harder and have the enemies aim at the player. To do this, the enemies must have a reference to the PlayerCharacter
. Then it’s a simple matter of working out the direction of the player in reference to the enemy ship. If you decide to add aiming to the enemies, here’s a little snippet of code that might help.
Vector currentPosition = _sprite.GetPosition(); Vector bulletDir = _playerCharacter.GetPosition() - currentPosition; bulletDir = bulletDir.Normalize(bulletDir); bullet.Direction = bulletDir;
This concludes this second refinement of the game. The enemies can fire on the player and move about in interesting ways. The enemies can be destroyed and will explode in a satisfying ball of flame.
After two basic iterations of development, we have a wonderful but basic side-scrolling shooter. There is massive scope for developing this project into something totally individual. The project is yours now and you can develop it as you want. If you feel a little lost, here are some suggestions.
A very simple first step is to introduce a new enemy type; just add an extra else if
and perhaps modify a path or the health. Once you’ve done that, consider making a new enemy texture and changing the new enemy to use this texture. This will suddenly make the game a lot more interesting.
A score is important in scrolling shooters. The score can be displayed using the Text
class. The score should increase every time the player destroys an enemy.
Sound is also very simple to add. A sound manager needs to be created as it was earlier in the book, and a number of suitable sounds could be generated for shooting, exploding, and taking damage. Then you just need to find the places where explode, damage, and shoot events occur and make a call to the sound manager to play the correct sound. The main bulk of the work is passing the sound manager through all the objects so that it can be used where it’s needed.
Regarding the code, there is a large number of managers and a few scattered functions with similar code. The code could be made tighter and easier to extend if these managers were generalized and any repeated code was removed. A good starting point is to see what similar methods each of the managers use and then consider extending the Entity
class so one general EntityManager
could be created.
The game’s single level is defined in the EnemyManager
constructor. This isn’t very extendable. A good starting project might be to define the level definitions in a text file. On load-up the program can read the text and load the level definition. The level definition could be very simple, such as
cannon_fodder, 30 cannon_fodder, 29.5 cannon_fodder, 29 cannon_fodder, 28.5
Each line has an enemy type and a launch time separated by a comma. This is very easy to parse and read into a Level
definition class. The level data should probably be stored in a PersistantGameData
class.
Once you have one level loaded, it’s easy to make a new level file, and then suddenly you have the potential for a multi-level game. When one level is successfully finished, instead of returning the StartGameState
, the state could return to the InnerGameState
but using the next level. If the game has multiple levels, then it would good if the game was able to save the player’s progress through these levels. A very simple way to save the game data would be to write out the score and current level to a text file.
Instead of having a linear level progression (1,2,3,4), the user could be presented with an overworld map to select which levels he’d like to complete next. An overworld map generally indicates all the levels as nodes linked by paths. Such a system has been used in some of the Super Mario games. An overworld map makes it very easy to introduce secret paths and levels that are discovered by doing particularly well in the previous level.
If the player is hit by an enemy spaceship or bullet, the PlayerCharacter
dies and it’s game over. It would be preferable to give the player some margin for error, perhaps by giving the spaceship some health—allowing it to take damage like the enemies. The health could be represented by a health bar on screen that goes down each time the ship takes a hit. You may also want to introduce the concept of lives—the player starts with several lives, each allowing one more go at the level before the game is lost.
As levels progress, they tend to get more difficult. To help the player, you could give him better weapons and items to repair any damage to his ship. In scrolling shooting games, power-ups and other items tend to be dropped by enemies. A new item
class needs to be created, and it can be added to the scene (possibly via the EffectsManager
) to be picked up by the player. A health pack can repair some amount of the damage the player has received. New weapons can deal more damage or perhaps there can be two bullets every time the player shoots instead of one.
You could also add alternate weapons that are triggered by different buttons. Bombs or lasers, for instance, could be secondary weapons that have a limited number of shots.
RPG elements are a very popular way to add a greater degree of depth. Enemies could drop money (or scrap that could later be sold for money). After each level the player could buy new weapons, upgrade existing ones, or even buy a new type of ship. You may even want to allow players to place weapons at different locations on the ship by altering the PlayerCharacter
’s _gunOffset
member.
The RPG elements could be taken even further by adding a layer of narrative to the game. This could be done during the level with text boxes and scripted movements of enemies and the player. Story elements could also be added to an overworld map or after each level.
Large boss enemies at the end of the level are also a staple of the side-scrolling shooter. You could make the boss enemy an aggregate of several different types of enemies. Then parts of the boss can be destroyed, but the PlayerCharacter
only wins when all the boss parts are destroyed.
The scrolling space backgrounds are quite dull and could be made more lively with animated space debris, far away supernova, and planets. The scrolling background can be altered at any time, so with a little work it would be easy to give the impression the spaceship was traveling toward the surface of a planet.
As a final suggestion, you could add a local multiplayer mode. This is pretty easy to do. A second game controller or the keyboard would need its input to be redirected to a second PlayerCharacter
. Some logic would also need to be changed so that if one player died the other could keep on playing.
13.58.220.83