In this series of tutorials, I'll show you how to make a Geometry Wars-inspired twin-stick shooter, with neon graphics, crazy particle effects, and awesome music, for iOS using C++ and OpenGL ES 2.0.
Rather than rely on an existing game framework or sprite library, we'll attempt to program as close to the hardware (or "bare metal") as we possibly can. Since devices running iOS run on smaller scale hardware compared to a desktop PC or games console, this will allow us to get as much bang for our buck as possible.
The goal of these tutorials is to go over the necessary elements that will allow you to create your own high-quality mobile game for iOS, either from scratch or based on an existing desktop game. I encourage you to download and play with the code, or even to use it as a basis for your own projects.
We'll cover the following topics during this series:
- First steps, introducing the Utility library, setting up the basic gameplay, creating the player's ship, sound and music.
- Finish implementing the gameplay mechanics by adding enemies, handling collision detection, and tracking the player's score and lives.
- Add a virtual gamepad on-screen, so we can control the game using multi-touch input.
- Add crazy, over-the-top particle effects.
- Add the warping background grid.
Here's what we'll have by the end of the series:
And here's what we'll have by the end of this first part:
The music and sound effects you can hear in these videos were created by RetroModular, and you can read about how he did so over at our audio section.
The sprites are by Jacob Zinman-Jeanes, our resident Tuts+ designer.
The font we'll use is a bitmap font (in other words, not an actual "font", but an image file), which is something I've created for this tutorial.
All the artwork can be found in the source files.
Let's get started.
Overview
Before we dive into the specifics of the game, let's talk about the Utility Library and Application Bootstrap code I've provided to support developing our game.
The Utility Library
Though we'll primarily be using C++ and OpenGL to code our game, we'll need some additional utility classes. These are all classes I've written to help development in other projects, so they're time tested and usable for new projects such as this one.
package.h
: A convenience header used to include all relevant headers from the Utility library. We'll include it by stating#include "Utility/package.h"
without having to include anything else.
Patterns
We'll leverage some existing tried and true programming patterns used in C++ and other languages.
tSingleton
: Implements a singleton class using a "Meyers Singleton" pattern. It's template based, and extensible, so we can abstract all singleton code to a single class.tOptional
: This is a feature from C++14 (calledstd::optional
) that's not quite available in current versions of C++ yet (we're still at C++11). It's also a feature available in XNA and C# (where it's calledNullable
.) It allows us to have "optional" parameters for methods. It's used in thetSpriteBatch
class.
Vector Math
Since we're not using an existing game framework, we'll need some classes to deal with the mathematics behind the scenes.
tMath
: A static class taht provides some methods beyond what's available in C++, such as converting from degrees to radians or rounding numbers to powers of two.tVector
: A basic set of Vector classes, providing 2-element, 3-element, and 4-element variants. We also typedef this structure for Points and Colors.tMatrix
: Two matrix definitions, a 2x2 variant (for rotation operations), and a 4x4 option (for the projection matrix required to get things on-screen),tRect
: A rectangle class providing location, size, and a method to determine whether points lie inside rectangles or not.
OpenGL Wrapper Classes
Although OpenGL is a powerful API, it's C-based, and managing objects can be somewhat difficult to do in practice. So, we'll have a small handful of classes to manage the OpenGL objects for us.
tSurface
: Offers a way to create a bitmap based on an image loaded from the application's bundle.tTexture
: Wraps the interface to OpenGL's texture commands, and loadstSurfaces
into textures.tShader
: Wraps the interface to OpenGL's shader compiler, making it easy to compile shaders.tProgram
: Wraps the interface to OpenGL's shader program interface, which is essentially the combination of twotShader
classes.
Game Support Classes
These classes represent the closest we'll get to having a "game framework"; they provide some high level concepts that are not typical to OpenGL, but that are useful for game development purposes.
tViewport
: Contains the state of the viewport. We use this primarily to handle changes to device orientation.tAutosizeViewport
: A class that manages changes to the viewport. It handles device orientation changes directly, and scales the viewport to fit the screen of the device so that the aspect ratio stays the same—meaning that things don't get stretched or squashed.tSpriteFont
: Allows us to load a "bitmap font" from the application bundle, and use it to write text on the screen.tSpriteBatch
: Inspired by XNA'sSpriteBatch
class, I wrote this class to encapsulate the best of what's needed by our game. It allows us to sort sprites when drawing in such a way so we get the best possible speed gains on the hardware we have. We'll also use it directly to write text on screen.
Miscellaneous Classes
A minimal set of classes to round things out.
tTimer
: A system timer, used primarily for animations.tInputEvent
: Basic class definitions to provide orientation changes (tilting the device), touch events, and a "virtual keyboard" event to emulate a gamepad more discretely.tSound
: A class dedicated to loading and playing sound effects and music.
Application Bootstrap
We'll also need what I call "Boostrap" code—that is, code that abstracts away how an application starts, or "boots up."
Here's what's in Bootstrap
:
AppDelegate
: This class handles application launch, as well as suspend and resume events for when the user presses the Home button.ViewController
: This class handles device orientation events, and creates our OpenGL viewOpenGLView
: This class initializes OpenGL, tells the device to refresh at 60 frames per second, and handles touch events.
Overview of the Game
In this tutorial we will create a twin-stick shooter; the player will control the ship using on-screen multi-touch controls.
We'll use a number of classes to accomplish this:
Entity
: The base class for enemies, bullets, and the player's ship. Entities can move and be drawn.Bullet
andPlayerShip
.EntityManager
: Keeps track of all entities in the game and performs collision detection.Input
: Helps manage input from the touch screen.Art
: Loads and holds references to the textures needed for the game.Sound
: Loads and holds references to the sounds and music.MathUtil
andExtensions
: Contains some helpful static methods and
extension methods.GameRoot
: Controls the main loop of the game. This is our main class.
The code in this tutorial aims to be simple and easy to understand. It will not have every feature designed to support every possible need; rather, it will do only what it needs to do. Keeping it simple will make it easier for you to understand the concepts, and then modify and expand them into your own unique game.
Entities and the Player's Ship
Open the existing Xcode project. GameRoot is our application's main class.
We'll start by creating a base class for our game entities. Take a look at the
class Entity { public: enum Kind { kDontCare = 0, kBullet, kEnemy, kBlackHole, }; protected: tTexture* mImage; tColor4f mColor; tPoint2f mPosition; tVector2f mVelocity; float mOrientation; float mRadius; bool mIsExpired; Kind mKind; public: Entity(); virtual ~Entity(); tDimension2f getSize() const; virtual void update() = 0; virtual void draw(tSpriteBatch* spriteBatch); tPoint2f getPosition() const; tVector2f getVelocity() const; void setVelocity(const tVector2f& nv); float getRadius() const; bool isExpired() const; Kind getKind() const; void setExpired(); };
All our entities (enemies, bullets and the player's ship) have some basic properties, such as an image and a position. mIsExpired
will be used to indicate that the entity has been destroyed and should be removed from any lists holding a reference to it.
Next we create an EntityManager
to track our entities and to update and draw them:
class EntityManager : public tSingleton<EntityManager> { protected: std::list<Entity*> mEntities; std::list<Entity*> mAddedEntities; std::list<Bullet*> mBullets; bool mIsUpdating; protected: EntityManager(); public: int getCount() const; void add(Entity* entity); void addEntity(Entity* entity); void update(); void draw(tSpriteBatch* spriteBatch); bool isColliding(Entity* a, Entity* b); friend class tSingleton<EntityManager>; }; void EntityManager::add(Entity* entity) { if (!mIsUpdating) { addEntity(entity); } else { mAddedEntities.push_back(entity); } } void EntityManager::update() { mIsUpdating = true; for(std::list<Entity*>::iterator iter = mEntities.begin(); iter != mEntities.end(); iter++) { (*iter)->update(); if ((*iter)->isExpired()) { *iter = NULL; } } mIsUpdating = false; for(std::list<Entity*>::iterator iter = mAddedEntities.begin(); iter != mAddedEntities.end(); iter++) { addEntity(*iter); } mAddedEntities.clear(); mEntities.remove(NULL); for(std::list<Bullet*>::iterator iter = mBullets.begin(); iter != mBullets.end(); iter++) { if ((*iter)->isExpired()) { delete *iter; *iter = NULL; } } mBullets.remove(NULL); } void EntityManager::draw(tSpriteBatch* spriteBatch) { for(std::list<Entity*>::iterator iter = mEntities.begin(); iter != mEntities.end(); iter++) { (*iter)->draw(spriteBatch); } }
Remember, if you modify a list while iterating over it, you will get a runtime exception. The above code takes care of this by queuing up any entities added during updating in a separate list, and adding them after it finishes updating the existing entities.
Making Them Visible
We will need to load some textures if we want to draw anything, so we'll make a static class to hold references to all our textures:
class Art : public tSingleton<Art> { protected: tTexture* mPlayer; tTexture* mSeeker; tTexture* mWanderer; tTexture* mBullet; tTexture* mPointer; protected: Art(); public: tTexture* getPlayer() const; tTexture* getSeeker() const; tTexture* getWanderer() const; tTexture* getBullet() const; tTexture* getPointer() const; friend class tSingleton<Art>; }; Art::Art() { mPlayer = new tTexture(tSurface("player.png")); mSeeker = new tTexture(tSurface("seeker.png")); mWanderer = new tTexture(tSurface("wanderer.png")); mBullet = new tTexture(tSurface("bullet.png")); mPointer = new tTexture(tSurface("pointer.png")); }
We load the art by calling Art::getInstance()
in GameRoot::onInitView()
. This causes the Art
singleton to get constructed and to call the constructor, Art::Art()
.
Also, a number of classes will need to know the screen dimensions, so we have the following members in GameRoot
:
tDimension2f mViewportSize; tSpriteBatch* mSpriteBatch; tAutosizeViewport* mViewport;
And in the GameRoot
constructor, we set the size:
GameRoot::GameRoot() : mViewportSize(800, 600), mSpriteBatch(NULL) { }
The resolution 800x600px is what the original XNA-based Shape Blaster used. We could use any resolution we wish (like one closer to an iPhone or iPad's specific resolution), but we'll stick with the original resolution just to make sure our game matches the look and feel of the original.
Now we'll go over the PlayerShip
class:
class PlayerShip : public Entity, public tSingleton<PlayerShip> { protected: static const int kCooldownFrames; int mCooldowmRemaining; int mFramesUntilRespawn; protected: PlayerShip(); public: void update(); void draw(tSpriteBatch* spriteBatch); bool getIsDead(); void kill(); friend class tSingleton<PlayerShip>; }; PlayerShip::PlayerShip() : mCooldowmRemaining(0), mFramesUntilRespawn(0) { mImage = Art::getInstance()->getPlayer(); mPosition = tPoint2f(GameRoot::getInstance()->getViewportSize().x / 2, GameRoot::getInstance()->getViewportSize().y / 2); mRadius = 10; }
We made PlayerShip
a singleton, set its image, and placed it in the center of the screen.
Finally, let's add the player ship to the EntityManager
. The code in GameRoot::onInitView
looks like this:
//In GameRoot::onInitView EntityManager::getInstance()->add(PlayerShip::getInstance()); . . . glClearColor(0,0,0,1); glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glHint(GL_GENERATE_MIPMAP_HINT, GL_DONT_CARE); glDisable(GL_DEPTH_TEST); glDisable(GL_CULL_FACE);
We're drawing the sprites with additive blending, which is part of what will give them their "neon" look. We also don't want any bluring or blending, so we use GL_NEAREST
for our filters. We don't need or care about depth testing or backface culling (it just adds unnecessary overhead anyway), so we turn it off.
The code in GameRoot::onRedrawView
looks like this:
//In GameRoot::onRedrawView EntityManager::getInstance()->update(); EntityManager::getInstance()->draw(mSpriteBatch); mSpriteBatch->draw(0, Art::getInstance()->getPointer(), Input::getInstance()->getMousePosition(), tOptional<tRectf>()); mViewport->run(); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); mSpriteBatch->end(); glFlush();
If you run the game at this point, you should see your ship in the center of the screen. However, it doesn't respond to input. Let's add some input to the game next.
Input
For movement, we'll use a multi-touch interface. Before we go full force with on-screen gamepads, we'll just get a basic touch interface up and running.
In the original Shape Blaster for Windows, player movement could be done with the WASD keys the keyboard. For aiming, they could use the arrow keys or the mouse. This is meant to emulate Geometry Wars's twin-stick controls: one analog stick for movement, one for aiming.
Since Shape Blaster already uses the concept of keyboard and mouse movement, the easiest way to add input would by emulating keyboard and mouse commands through touch. We'll start with mouse movement, as both touch and mouse share a similar component: a point containing X and Y coordinates.
We'll make a static class to keep track of the various input devices and to take care of switching between the different types of aiming:
class Input : public tSingleton<Input> { protected: tPoint2f mMouseState; tPoint2f mLastMouseState; tPoint2f mFreshMouseState; std::vector<bool> mKeyboardState; std::vector<bool> mLastKeyboardState; std::vector<bool> mFreshKeyboardState; bool mIsAimingWithMouse; uint8_t mLeftEngaged; uint8_t mRightEngaged; public: enum KeyType { kUp = 0, kLeft, kDown, kRight, kW, kA, kS, kD, }; protected: tVector2f GetMouseAimDirection() const; protected: Input(); public: tPoint2f getMousePosition() const; void update(); // Checks if a key was just pressed down bool wasKeyPressed(KeyType) const; tVector2f getMovementDirection() const; tVector2f getAimDirection() const; void onKeyboard(const tKeyboardEvent& msg); void onTouch(const tTouchEvent& msg); friend class tSingleton<Input>; }; void Input::update() { mLastKeyboardState = mKeyboardState; mLastMouseState = mMouseState; mKeyboardState = mFreshKeyboardState; mMouseState = mFreshMouseState; if (mKeyboardState[kLeft] || mKeyboardState[kRight] || mKeyboardState[kUp] || mKeyboardState[kDown]) { mIsAimingWithMouse = false; } else if (mMouseState != mLastMouseState) { mIsAimingWithMouse = true; } }
We call Input::update()
at the beginning of GameRoot::onRedrawView()
for the input class to work.
As stated previously, we'll use the keyboard
state later on in the series to account for movement.
Shooting
Now let's make the ship shoot.
First, we need a class for bullets.
class Bullet : public Entity { public: Bullet(const tPoint2f& position, const tVector2f& velocity); void update(); }; Bullet::Bullet(const tPoint2f& position, const tVector2f& velocity) { mImage = Art::getInstance()->getBullet(); mPosition = position; mVelocity = velocity; mOrientation = atan2f(mVelocity.y, mVelocity.x); mRadius = 8; mKind = kBullet; } void Bullet::update() { if (mVelocity.lengthSquared() > 0) { mOrientation = atan2f(mVelocity.y, mVelocity.x); } mPosition += mVelocity; if (!tRectf(0, 0, GameRoot::getInstance()->getViewportSize()).contains(tPoint2f((int32_t)mPosition.x, (int32_t)mPosition.y))) { mIsExpired = true; } }
We want a brief cooldown period between bullets, so we'll have a constant for that:
const int PlayerShip::kCooldownFrames = 6;
Also, we'll add the following code to PlayerShip::Update()
:
tVector2f aim = Input::getInstance()->getAimDirection(); if (aim.lengthSquared() > 0 && mCooldowmRemaining <= 0) { mCooldowmRemaining = kCooldownFrames; float aimAngle = atan2f(aim.y, aim.x); float cosA = cosf(aimAngle); float sinA = sinf(aimAngle); tMatrix2x2f aimMat(tVector2f(cosA, sinA), tVector2f(-sinA, cosA)); float randomSpread = tMath::random() * 0.08f + tMath::random() * 0.08f - 0.08f; tVector2f vel = 11.0f * (tVector2f(cosA, sinA) + tVector2f(randomSpread, randomSpread)); tVector2f offset = aimMat * tVector2f(35, -8); EntityManager::getInstance()->add(new Bullet(mPosition + offset, vel)); offset = aimMat * tVector2f(35, 8); EntityManager::getInstance()->add(new Bullet(mPosition + offset, vel)); tSound* curShot = Sound::getInstance()->getShot(); if (!curShot->isPlaying()) { curShot->play(0, 1); } } if (mCooldowmRemaining > 0) { mCooldowmRemaining--; }
This code creates two bullets that travel parallel to each other. It adds a small amount of randomness to the direction, which makes the shots spread out a little bit like a machine gun. We add two random numbers together because this makes their sum more likely to be centered (around zero) and less likely to send bullets far off. We use a two-dimensional matrix to rotate the initial position of the bullets in the direction they're travelling.
We also used two new helper methods:
Extensions::NextFloat()
: Returns a random float between a minimum and maximum value.MathUtil::FromPolar()
: Creates atVector2f
from an angle and magnitude.
So let's see what they look like:
//In Extensions float Extensions::nextFloat(float minValue, float maxValue) { return (float)tMath::random() * (maxValue - minValue) + minValue; } //In MathUtil tVector2f MathUtil::fromPolar(float angle, float magnitude) { return magnitude * tVector2f((float)cosf(angle), (float)sinf(angle)); }
Custom Cursor
There's one more thing we should do now that we have the inital Input
class: let's draw a custom mouse cursor to make it easier to see where the ship is aiming. In GameRoot.Draw
, simply draw Art's mPointer
at the "mouse's" position.
mSpriteBatch->draw(0, Art::getInstance()->getPointer(), Input::getInstance()->getMousePosition(), tOptional<tRectf>());
Conclusion
If you test the game now, you'll be able to touch anywhere on screen to aim the continuous stream of bullets, which is a good start.
In the next part, we will complete the initial gameplay by adding enemies and a score.