The Black Hole Game

Adding the Finishing Touches

Our Black Hole game is functional in terms of the mechanics of the game working and being fairly well balanced, so now we can spend time addressing the gameplay and “fun factor” of the game. The goal is to increase the replay value as much as possible. A neat idea for a game can fizzle pretty quickly unless there is a good reason for the player to come back. What is it about the game that would compel the player to keep playing for hours or days? The goal of an indie developer or a professional (obviously) is to sell a game. There are some games that will sell on the promise of short gameplay if the subject is compelling, but in this market replay sells. So, we’ll see what we can do to improve gameplay and replay value.

Modifying the GameModule Interface Class

To make it possible to leave the PlayingModule and then return to it without shutting down and restarting the game, an enhancement is needed that will reset the gameplay to the starting values. So, a change will be made to the interface class, GameModule. Note the new Reset() method. This will meet our needs. Each of the game modules that use this interface will have to implement the new method:

MySoundEffect Class

The MySoundEffect class, shown in Listing 24.1, was introduced back in Hour 18, “Playing Audio,” and we’ll need it here again. We’ll need to make a minor tweak so that it’s a little easier to use, by adding a Play() method directly to the class rather than requiring use of the instance object.

LISTING 24.1 Source Code for the MySoundEffect Class

[code]
public class MySoundEffect
{
private Game1 game;
private SoundEffect effect;
private SoundEffectInstance instance;
public MySoundEffect(Game1 game)
{
this.game = game;
effect = null;
instance = null;
}
public void Load(string assetName)
{
effect = game.Content.Load<SoundEffect>(assetName);
instance = effect.CreateInstance();
}
public void Play()
{
if (game.globalAudio)
instance.Play();
}
}
[/code]

There is only one sound effect in the Black Hole game—when the ship launches a satellite. I know, the game is shamefully lacking in the audio department. Can you think of any events in the game that would benefit from a sound clip?

GameOverModule Class

The GameOverModule class does not need to use the Reset() method, but it has to be added to the class nonetheless because IGameModule mandates it. The GameOver class displays a message on the screen and waits for the user to press the Return button. This is kind of a no-brainer screen, but it is an important part of the gameplay and helps to separate this functionality from PlayingModule. Figure 24.1 shows the screen, and Listing 24.2 contains the source code.

Game over, man! Game over!
FIGURE 24.1 Game over, man! Game over!

LISTING 24.2 Source Code for the GameOverModule Class

[code]
class GameOverModule : IGameModule
{
Game1 game;
Label lblTitle;
Button btnReturn;
public GameOverModule(Game1 game)
{
this.game = game;
}
public void Reset()
{
}
public void LoadContent(ContentManager content)
{
lblTitle = new Label(content, game.spriteBatch,
game.bigfont);
lblTitle.text = “GAME OVER!”;
Vector2 size = game.font.MeasureString(lblTitle.text);
lblTitle.position = new Vector2(400-size.X, 200);
btnReturn = new Button(content, game.spriteBatch,
game.guifont);
btnReturn.text = “Return”;
btnReturn.position = new Vector2(400, 430);
btnReturn.scaleV = new Vector2(2.0f, 1.0f);
}
public void Update(TouchLocation touch, GameTime gameTime)
{
btnReturn.Update(touch);
if (btnReturn.Tapped)
game.gameState = Game1.GameState.TITLE;
}
public void Draw(GameTime gameTime)
{
lblTitle.Draw();
btnReturn.Draw();
}
}
[/code]

OptionsModule Class

The OptionsModule class represents the Options screen in the game. This is often where game settings can be changed. In this small game, we’ll need one global setting to make the screen useful—a global audio on/off switch. The screen is shown in Figure 24.2. Listing 24.3 shows the source code for the class.

The Options screen.
FIGURE 24.2 The Options screen.

LISTING 24.3 Source Code for the OptionsModule Class

[code]
class OptionsModule : IGameModule
{
Game1 game;
Label lblTitle;
Button btnReturn;
Button btnAudio;
public OptionsModule(Game1 game)
{
this.game = game;
}
public void Reset()
{
}
public void LoadContent(ContentManager content)
{
lblTitle = new Label(content, game.spriteBatch,
game.bigfont);
lblTitle.text = “Options Screen”;
Vector2 size = game.font.MeasureString(lblTitle.text);
lblTitle.position = new Vector2(400-size.X, 10);
btnAudio = new Button(game.Content, game.spriteBatch,
game.guifont);
btnAudio.text = ““;
btnAudio.position = new Vector2(400, 240);
btnAudio.scaleV = new Vector2(4.0f, 1.0f);
btnReturn = new Button(content, game.spriteBatch,
game.guifont);
btnReturn.text = “Return”;
btnReturn.position = new Vector2(400, 430);
btnReturn.scaleV = new Vector2(3.0f, 1.2f);
}
public void Update(TouchLocation touch, GameTime gameTime)
{
if (game.globalAudio)
btnAudio.text = “Turn Sound OFF”;
else
btnAudio.text = “Turn Sound ON”;
btnAudio.Update(touch);
if (btnAudio.Tapped)
{
game.globalAudio = !game.globalAudio;
}
btnReturn.Update(touch);
if (btnReturn.Tapped)
game.gameState = Game1.GameState.TITLE;
}
public void Draw(GameTime gameTime)
{
lblTitle.Draw();
btnAudio.Draw();
btnReturn.Draw();
}
}
[/code]

TitleScreenModule Class

The TitleScreenModule class has seen some major improvements since the early/crude version shown in an earlier hour. Now the buttons are colorful and rotate around a huge version of the animated black hole borrowed right out of the actual gameplay. See Figure 24.3 for the picture, and Listing 24.4 for the code.

The title screen features rotating buttons.
FIGURE 24.3 The title screen features rotating buttons.

What happens to the buttons if you wait too long to make a selection? Actually, nothing! But it would be fun if they would fall into the black hole when the player waits too long!

LISTING 24.4 Source Code for the TitleScreenModule Class

[code]
class TitleScreenModule : IGameModule
{
Game1 game;
Label lblTitle;
Button[] btnMenu;
MassiveObject blackHole;
MassiveObject superCore;
Sprite background;
public TitleScreenModule(Game1 game)
{
this.game = game;
rand = new Random();
}
public void Reset()
{
}
public void LoadContent(ContentManager content)
{
lblTitle = new Label(content, game.spriteBatch, game.bigfont);
lblTitle.text = “The Black Hole Game”;
Vector2 size = game.font.MeasureString(lblTitle.text);
lblTitle.position = new Vector2(400-size.X, 10);
btnMenu = new Button[3];
btnMenu[0] = new Button(content, game.spriteBatch, game.guifont);
btnMenu[0].text = “PLAY!”;
btnMenu[0].scaleV = new Vector2(2.5f, 1.2f);
btnMenu[0].color = Color.Orange;
btnMenu[0].animations.Add(
new OrbitalMovement( new Vector2(400, 240), 40, 0, 0.05f));
btnMenu[1] = new Button(content, game.spriteBatch, game.guifont);
btnMenu[1].text = “OPTIONS”;
btnMenu[1].color = Color.DarkRed;
btnMenu[1].scaleV = new Vector2(2.1f, 1.0f);
btnMenu[1].animations.Add(
new OrbitalMovement(new Vector2(420, 220), 140, 0, 0.04f));
btnMenu[2] = new Button(content, game.spriteBatch, game.guifont);
btnMenu[2].text = “EXIT”;
btnMenu[2].color = Color.DarkSeaGreen;
btnMenu[2].scaleV = new Vector2(1.6f, 0.8f);
btnMenu[2].animations.Add(
new OrbitalMovement(new Vector2(380, 260), 240, 0, 0.03f));
background = new Sprite(game.Content, game.spriteBatch);
background.Load(“space”);
background.origin = Vector2.Zero;
blackHole = new MassiveObject(game.Content, game.spriteBatch);
blackHole.Load(“blackhole”);
blackHole.position = new Vector2(400, 240);
blackHole.scale = 4.0f;
blackHole.mass = 40;
blackHole.color = new Color(255, 100, 100, 200);
blackHole.velocityAngular = 0.1f;
superCore = new MassiveObject(game.Content, game.spriteBatch);
superCore.image = blackHole.image;
superCore.position = new Vector2(blackHole.position.X,
blackHole.position.Y);
superCore.scale = blackHole.scale * 0.4f;
superCore.mass = 60;
superCore.color = new Color(200, 100, 100, 180);
superCore.velocityAngular = 4.0f;
superCore.origin = new Vector2(64, 64);
}
public void Update(TouchLocation touch, GameTime gameTime)
{
blackHole.Update(gameTime);
blackHole.Rotate();
superCore.Update(gameTime);
superCore.Rotate();
int tapped = -1;
int n = 0;
foreach (Button btn in btnMenu)
{
btn.Update(touch);
btn.Animate();
if (btn.Tapped)
tapped = n;
n++;
}
switch (tapped)
{
case 0:
game.gameState = Game1.GameState.PLAYING;
break;
case 1:
game.gameState = Game1.GameState.OPTIONS;
break;
case 2:
game.Exit();
break;
}
}
public void Draw(GameTime gameTime)
{
background.Draw();
superCore.Draw();
blackHole.Draw();
lblTitle.Draw();
foreach (Button btn in btnMenu)
{
btn.Draw();
}
}
}
[/code]

Game1 Class

The primary source code for the game is found in Listing 24.5, for the Game1 class. Only a minor change is needed to support the new Reset() method. This makes it easier to manage swapping between modules. For instance, exiting the PlayingModule and returning to the TitleScreen, then back to PlayingModule, we need to make sure the game is reset. That happens inside Game1. A new state backup variable is also needed, oldState, to keep track of when the state changes, in order to know when to call Reset().

LISTING 24.5 Source Code for the Game1 Class

[code]
public class Game1 : Microsoft.Xna.Framework.Game
{
public enum GameState
{
TITLE = 0,
PLAYING = 1,
OPTIONS = 2,
GAMEOVER = 3
}
public GraphicsDeviceManager graphics;
public SpriteBatch spriteBatch;
public GameState gameState, oldState;
public Color backColor;
Random rand;
TouchLocation oldTouch;
IGameModule[] modules;
public bool globalAudio;
public SpriteFont font, guifont, bigfont;
public Game1()
{
graphics = new GraphicsDeviceManager(this);
Content.RootDirectory = “Content”;
TargetElapsedTime = TimeSpan.FromTicks(333333);
oldTouch = new TouchLocation();
rand = new Random();
backColor = new Color(32, 32, 32);
globalAudio = true;
gameState = GameState.TITLE;
oldState = gameState;
}
protected override void Initialize()
{
base.Initialize();
}
protected override void LoadContent()
{
spriteBatch = new SpriteBatch(GraphicsDevice);
font = Content.Load<SpriteFont>(“WascoSans”);
guifont = Content.Load<SpriteFont>(“GUIFont”);
bigfont = Content.Load<SpriteFont>(“BigFont”);
modules = new IGameModule[4];
modules[0] = new TitleScreenModule(this);
modules[1] = new PlayingModule(this);
modules[2] = new OptionsModule(this);
modules[3] = new GameOverModule(this);
foreach (IGameModule mod in modules)
{
mod.LoadContent(Content);
}
}
protected override void Update(GameTime gameTime)
{
if (GamePad.GetState(PlayerIndex.One).Buttons.Back ==
ButtonState.Pressed)
this.Exit();
TouchCollection touchInput = TouchPanel.GetState();
TouchLocation touch = new TouchLocation();
if (touchInput.Count > 0)
{
touch = touchInput[0];
oldTouch = touch;
}
if (gameState != oldState)
{
oldState = gameState;
modules[(int)gameState].Reset();
}
//update current module
modules[(int)gameState].Update(touch ,gameTime);
base.Update(gameTime);
}
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(backColor);
spriteBatch.Begin(SpriteSortMode.Deferred,
BlendState.AlphaBlend);
//draw current module
modules[(int)gameState].Draw(gameTime);
spriteBatch.End();
base.Draw(gameTime);
}
public bool BoundaryCollision(Rectangle A, Rectangle B)
{
return A.Intersects(B);
}
public bool RadialCollision(Sprite A, Sprite B)
{
float radius1 = A.image.Width / 2;
float radius2 = B.image.Width / 2;
return RadialCollision(A.position, B.position, radius1,
radius2);
}
public bool RadialCollision(Vector2 A, Vector2 B, float radius1,
float radius2)
{
float dist = Distance(A, B);
return (dist < radius1 + radius2);
}
public float Distance(Vector2 A, Vector2 B)
{
double diffX = A.X – B.X;
double diffY = A.Y – B.Y;
double dist = Math.Sqrt(Math.Pow(diffX, 2) +
Math.Pow(diffY, 2));
return (float)dist;
}
public float TargetAngle(Vector2 p1, Vector2 p2)
{
return TargetAngle(p1.X, p1.Y, p2.X, p2.Y);
}
public float TargetAngle(double x1, double y1, double x2,
double y2)
{
double deltaX = (x2 – x1);
double deltaY = (y2 – y1);
return (float)Math.Atan2(deltaY, deltaX);
}
}
[/code]

MassiveObject Class

In an earlier hour, we introduced the new MassiveObject class to make working with “gravity” calculations a bit easier than using global variables (or worse, having to modify Sprite). So, this class just inherits from Sprite and adds a few new tidbits of its own. Well, the class is now a full-blown entity that draws and updates, so we need to take a look at the changes. Listing 24.6 shows the source code for the class.

LISTING 24.6 Source Code for the MassiveObject Class

[code]
class MassiveObject : Sprite
{
public string name;
public double mass;
public Vector2 acceleration;
public float radius,angle;
public bool captured;
public int lifetime, startTime;
public MassiveObject(ContentManager content,
SpriteBatch spriteBatch)
: base(content, spriteBatch)
{
name = “object”;
mass = 1.0f;
acceleration = Vector2.Zero;
radius = 50.0f;
angle = 0.0f;
this.captured = false;
lifetime = 0;
startTime = 0;
}
public void Update(GameTime gameTime)
{
position.X += velocityLinear.X;
position.Y += velocityLinear.Y;
}
public override void Draw()
{
base.Draw();
}
public void Attract(MassiveObject other)
{
//calculate DISTANCE
double distX = this.position.X – other.position.X;
double distY = this.position.Y – other.position.Y;
double dist = distX*distX + distY*distY;
double distance = 0;
if (dist != 0.0) distance = 1 / dist;
//update ACCELERATION (mass * distance)
this.acceleration.X = (float)(-1 * other.mass * distX
* distance);
this.acceleration.Y = (float)(-1 * other.mass * distY
* distance);
//update VELOCITY
this.velocityLinear.X += this.acceleration.X;
this.velocityLinear.Y += this.acceleration.Y;
//update POSITION
this.position.X += this.velocityLinear.X;
this.position.Y += this.velocityLinear.Y;
}
}
[/code]

PlayingModule Class

Now we come to the primary gameplay class of the game, PlayingModule. There’s quite a bit of code here, so I won’t break it up; it will just be listed without interruption. The gameplay could use some fine-tuning and tweaking, but the gist of it is that the player is the captain of a spaceship (insert filler story here). The ship has been caught in the strong gravity field of a black hole and you must help it to escape. Or the ship is mining the black hole for energy and gets caught. Whatever the story, it’s a compelling little game that’s worth your time to study.

As for the logic, when the energy level goes below 50, the ship begins to rotate around the black hole. When the energy reaches 0, the ship begins to also lose its orbit and slowly move inward, closer and closer to the event horizon. The player helps the ship stay out of the black hole by launching satellites/probes to collect energy. When the satellites are caught by the ship again, they will have gathered energy from the radiation field surrounding the black hole. If the ship has at least 1 or more energy, it will begin moving outward away from the black hole. When at least 50 energy is accumulated again, the ship will stop rotating. Remaining fixed in one position is definitely the preferred way to play, since you can launch satellites into a “known good” angle where the return will maintain your energy. That’s it! Short and sweet.

Figure 24.5 shows the game running. The source code to the class is found in Listing 24.7.

The finished Black Hole game.
FIGURE 24.5 The finished Black Hole game.

The black hole simulated in this game uses a mass factor of only 100 total (between the blackHole and superCore objects), which is only 100 times more massive than the asteroids and satellites. It’s this way for gameplay, but in the real universe, a typical black hole will be much more massive than a typical star. Plus, there is a supermassive black hole at the center of the Milky Way galaxy with a mass 4 million times greater than that of the Sun!

LISTING 24.7 Source Code for the PlayingModule Class

[code]
public class PlayingModule : IGameModule
{
Game1 game;
Button btnFire, btnQuit;
Label lblAngle, lblPower;
HSlider hsAngle, hsPower;
Random rand;
Sprite background;
MassiveObject blackHole;
MassiveObject superCore;
MassiveObject ship;
float energy;
int startTime, lifetime;
MySoundEffect launchSound;
int lastLaunch, launchTime;
List<MassiveObject> objects;
public PlayingModule(Game1 game)
{
this.game = game;
rand = new Random();
objects = new List<MassiveObject>();
ship = new MassiveObject(game.Content,game.spriteBatch);
blackHole = new MassiveObject(game.Content,game.spriteBatch);
superCore = new MassiveObject(game.Content,game.spriteBatch);
hsAngle = new HSlider(game.Content, game.spriteBatch,game.guifont);
lblAngle = new Label(game.Content,game.spriteBatch,game.guifont);
hsPower = new HSlider(game.Content,game.spriteBatch,game.guifont);
lblPower = new Label(game.Content,game.spriteBatch,game.guifont);
btnFire = new Button(game.Content,game.spriteBatch,game.guifont);
btnQuit = new Button(game.Content,game.spriteBatch,game.guifont);
Reset();
}
public void Reset()
{
startTime = 0;
lifetime = 4000;
lastLaunch = 0;
launchTime = 2000;
energy = 100.0f;
ship.position = new Vector2(200, 240);
hsAngle.Value = 30;
hsPower.Value = 50;
ship.lifetime = 0;
ship.radius = 250;
ship.angle = MathHelper.ToRadians(180);
ship.rotation = MathHelper.ToRadians(90);
objects.Clear();
}
public void LoadContent(ContentManager content)
{
launchSound = new MySoundEffect(game);
launchSound.Load(“launch”);
background = new Sprite(game.Content, game.spriteBatch);
background.Load(“space”);
background.origin = Vector2.Zero;
blackHole.Load(“blackhole”);
blackHole.position = new Vector2(400, 240);
blackHole.scale = 2.0f;
blackHole.mass = 40;
blackHole.color = new Color(255, 100, 100, 210);
blackHole.velocityAngular = 0.1f;
superCore.image = blackHole.image;
superCore.position = new Vector2(blackHole.position.X,
blackHole.position.Y);
superCore.scale = blackHole.scale * 0.4f;
superCore.mass = 60;
superCore.color = new Color(200, 100, 100, 190);
superCore.velocityAngular = 4.0f;
superCore.origin = new Vector2(64, 64);
//player ship
ship.Load(“ship”);
ship.mass = 20f;
ship.scale = 0.2f;
//angle slider
hsAngle.SetStartPosition(new Vector2(170, 445));
hsAngle.color = Color.Orange;
hsAngle.Limit = 108;
//angle label
lblAngle.position = new Vector2(hsAngle.X, hsAngle.Y-40);
lblAngle.text = “ANGLE”;
//power slider
hsPower.SetStartPosition(new Vector2(530, 445));
hsPower.color = Color.Orange;
hsPower.Limit = 100;
//power label
lblPower.position = new Vector2(hsPower.X, hsPower.Y-40);
lblPower.text = “POWER”;
//fire button
btnFire.position = new Vector2(400, 440);
btnFire.color = Color.Orange;
btnFire.UseShadow = false;
btnFire.text = “LAUNCH”;
btnFire.scaleV = new Vector2(1.5f, 0.7f);
//quit button
btnQuit.text = “X”;
btnQuit.position = new Vector2(800 – 20, 480 – 20);
btnQuit.scaleV = new Vector2(0.3f, 0.5f);
}
public void Update(TouchLocation touch, GameTime gameTime)
{
//update user controls
btnFire.Update(touch);
hsAngle.Update(touch);
hsPower.Update(touch);
lblAngle.Update(touch);
lblPower.Update(touch);
btnQuit.Update(touch);
//update gameplay objects
blackHole.Update(gameTime);
blackHole.Rotate();
superCore.Update(gameTime);
superCore.Rotate();
UpdateObjects(gameTime);
UpdateShip(gameTime);
//check user input
if (btnFire.Tapped)
{
if (lastLaunch + launchTime < gameTime.TotalGameTime.
TotalMilliseconds)
{
lastLaunch = (int)gameTime.TotalGameTime.
TotalMilliseconds;
launchSound.Play();
CreateSatellite();
}
}
//rotate ship with slider
float angle = hsAngle.Value * 3.3f;
ship.rotation = MathHelper.ToRadians(angle);
lblAngle.text = “ANGLE:” + angle.ToString(“N0”);
//set power label
lblPower.text = “POWER:” + hsPower.Value.ToString();
//time to add another random asteroid?
if (startTime + lifetime < gameTime.TotalGameTime.
TotalMilliseconds)
{
startTime = (int)gameTime.TotalGameTime.TotalMilliseconds;
CreateAsteroid();
}
//user quit?
if (btnQuit.Tapped)
{
game.gameState = Game1.GameState.TITLE;
}
}
public void UpdateObjects(GameTime gameTime)
{
int time = gameTime.ElapsedGameTime.Milliseconds;
foreach (MassiveObject obj in objects)
{
if (!obj.alive) continue;
obj.Update(gameTime);
obj.Rotate();
obj.Animate(time);
obj.Animate();
//allow ship to collect energy satellite
if (obj.scale >= 1.0f && obj.name == “satellite”)
{
//when large, cause satellites to seek the ship
obj.Attract(ship);
//reset satellite to white when seeking ship
obj.color = Color.White;
//look for collision with ship
if (game.RadialCollision(obj.position, ship.position,
obj.size.X, 40))
{
obj.alive = false;
energy += obj.scale * 4;
if (energy > 200) energy = 200;
}
}
if (!obj.captured)
{
//only attract when object is near the black hole
if (game.RadialCollision(obj.position,
blackHole.position, obj.size.X, 500))
{
obj.Attract(blackHole);
//touching the outer edges of the black hole?
if (game.RadialCollision(obj.position,
blackHole.position, obj.size.X, 120))
{
//turn red when going through inner gravity well
obj.color = Color.Red;
if (obj.name == “satellite”)
{
obj.scale += 0.1f;
if (obj.scale > 5.0f) obj.scale = 5.0f;
energy += 0.1f;
if (energy > 200) energy = 200;
}
obj.Attract(superCore);
//object is caught by the black hole
if (game.RadialCollision(obj.position,
superCore.position, 16, 60))
{
obj.captured = true;
//set a lifetime delay once captured
obj.lifetime = 3000;
obj.startTime = (int)gameTime.TotalGameTime.
TotalMilliseconds;
//cause object to spin around the black hole
OrbitalMovement anim1 = new OrbitalMovement(
blackHole.position, 10 + rand.Next(40),
obj.rotation, -0.8f);
obj.animations.Add(anim1);
}
}
else
{
obj.color = Color.White;
}
}
}
//when captured, time runs out
if (obj.lifetime > 0)
{
if (obj.startTime + obj.lifetime < gameTime.
TotalGameTime.TotalMilliseconds)
obj.alive = false;
}
//see if object has gone too far out of bounds
if (obj.position.X < -200 || obj.position.X > 1000 ||
obj.position.Y < -200 || obj.position.Y > 700)
obj.alive = false;
}
}
public void UpdateShip(GameTime gameTime)
{
int time = gameTime.ElapsedGameTime.Milliseconds;
ship.Update(gameTime);
ship.Rotate();
ship.Animate();
//cause ship to fall into black hole
if (!ship.captured)
{
//update ship position
ship.X = 400 + (float)(Math.Cos(ship.angle) * ship.radius);
ship.Y = 240 + (float)(Math.Sin(ship.angle) * ship.radius);
//consume energy
energy -= 0.05f;
if (energy > 0)
{
//while we have energy, try to get away
ship.radius += 0.2f;
if (ship.radius > 250)
ship.radius = 250;
}
if (energy < 50)
{
//rotate ship around black hole (custom)
ship.angle += 0.01f;
if (energy <= 0)
{
energy = 0;
ship.radius -= 0.1f;
//ship is caught by the black hole
if (game.RadialCollision(ship.position,
superCore.position, 64, 40))
{
ship.captured = true;
ship.velocityAngular = 1.0f;
ship.lifetime = 5000;
ship.startTime = (int)gameTime.TotalGameTime.
TotalMilliseconds;
OrbitalMovement anim1 = new OrbitalMovement(
blackHole.position, 10 + rand.Next(40),
0, 0.4f);
ship.animations.Add(anim1);
}
}
}
}
//ship fell into black hole?
if (ship.lifetime > 0)
{
if (ship.startTime + ship.lifetime <
gameTime.TotalGameTime.TotalMilliseconds)
{
game.gameState = Game1.GameState.GAMEOVER;
return;
}
}
}
public void Draw(GameTime gameTime)
{
background.Draw();
superCore.Draw();
blackHole.Draw();
ship.Draw();
foreach (MassiveObject obj in objects)
{
if (obj.alive)
{
obj.Draw();
}
}
btnFire.Draw();
hsAngle.Draw();
hsPower.Draw();
lblAngle.Draw();
lblPower.Draw();
btnQuit.Draw();
string text;
text = “Energy “ + energy.ToString(“N0”);
game.spriteBatch.DrawString(game.font, text,
new Vector2(650, 0), Color.White);
}
public void CreateAsteroid()
{
MassiveObject obj = new MassiveObject(game.Content,
game.spriteBatch);
obj.Load(“asteroid”);
obj.columns = 8;
obj.totalFrames = 64;
obj.scale = 0.1f + (float)rand.NextDouble();
obj.size = new Vector2(60, 60);
obj.radius = 80;
//randomly place at top or bottom of screen
obj.position = new Vector2(rand.Next(100, 800), -100);
obj.velocityLinear = new Vector2(4.0f, (float)
(rand.NextDouble() * 6.0));
if (rand.Next(2) == 1)
{
obj.position.Y = -obj.position.Y;
obj.velocityLinear.Y = -obj.velocityLinear.Y;
}
obj.scale = (0.5f + (float)rand.NextDouble()) * 0.5f;
obj.mass = 1;
obj.velocityAngular = 0.001f;
obj.lifetime = 0;
obj.name = “asteroid”;
objects.Add(obj);
}
public void CreateSatellite()
{
MassiveObject obj;
obj = new MassiveObject(game.Content, game.spriteBatch);
obj.Load(“plasma32”);
obj.position = ship.position;
obj.mass = 1;
obj.scale = 0.5f;
obj.lifetime = 0;
obj.name = “satellite”;
//calculate velocity based on ship’s angle
float accel = 1 + (float)(hsPower.Value / 10);
float angle = ship.rotation – MathHelper.ToRadians(90);
float x = (float)Math.Cos(angle) * accel;
float y = (float)Math.Sin(angle) * accel;
obj.velocityLinear = new Vector2(x,y);
//use energy to launch satellite
energy -= 1;
objects.Add(obj);
}
}
[/code]

This concludes the Black Hole game, and, well, the whole book! I hope you have enjoyed working with the WP7 platform, the emulator, and XNA Game Studio. These tools really are very rewarding, and you can’t beat the price! If you have any questions or just want to chat, come visit my website at http://www.jharbour.com/ forum. See you there!

Rocket Science: Acceleration

Building the Game

The Black Hole game is based on most of the code we’ve developed in each of the previous hours of the book, and there really is no single previous hour that gets more credit than others since each hour has built upon the hours that came before. Let’s dig into the game by going over the major sections and get something up and running fairly quickly. Then we’ll continue our work on the game into the following hour, where it will get some polish and fine-tuning of the fun factor.

This game is based on the theories of Stephen Hawking. If you’re interested in black hole physics, be sure to read his popular books for more information! The Universe in a Nutshell is one of my favorites.

Gravity Well Regions

There are three regions around the black hole that affect game objects. The outer gravity well affects objects passing by, drawing them toward the black hole with relatively light force. This force is increased by an equal amount in the next two inner regions, with each region generating an equivalent gravity “tug” on objects. But the cumulative effect of all three gravity wells in the inner region of the black hole will cause objects to become hopelessly trapped.

The third and innermost region might be thought of as the event horizon, that region of a black hole where things disappear into the void, never to be seen again. It is this region that mathematics cannot penetrate, so while it appears that gravity increases toward infinity in the middle of a black hole, the truth of the matter is, there may be nothing at the very center of a black hole! The gravity might be so powerful that matter just rotates around the center of mass and no matter actually exists at that center point, which would be quite small at the center of a black hole. Then again, there might be a superdense material like a neutron star. It is at this point that physics just cannot explain it, because we don’t actually have a black hole nearby to study. Even if we did, it’s not like a spacecraft could be sent to investigate!

Figure 23.1 shows an illustration of the gravity well of the black hole in the game. The outer gravity well is quite large and draws most game objects inward at a steady “weight” or “pull,” with some help from the inner core of the black hole, also exerting force. The inner gravity well is the region where energy can be mined by the “Hawking” satellites. At any rate, that’s one of the enjoyable aspects of this game, pondering the infinite possibilities!

The code to simulate the gravitational pull of the black hole is coming up. Now let’s just take a look again at some of our earlier helper methods used in this game. All the collision code in the Black Hole game is based around this RadialCollision() method and its overloaded friend:

[code]
public bool RadialCollision(Sprite A, Sprite B)
{
float radius1 = A.image.Width / 2;
float radius2 = B.image.Width / 2;
return RadialCollision(A.position, B.position,
radius1, radius2);
}
public bool RadialCollision(Vector2 A, Vector2 B, float radius1,
float radius2)
{
float dist = Distance(A, B);
return (dist < radius1 + radius2);
}
public float Distance(Vector2 A, Vector2 B)
{
double diffX = A.X – B.X;
double diffY = A.Y – B.Y;
double dist = Math.Sqrt(Math.Pow(diffX, 2) +
Math.Pow(diffY, 2));
return (float)dist;
}
[/code]

The gravity well of the black hole covers most of the Windows Phone screen.
FIGURE 23.1 The gravity well of the black hole covers most of the Windows Phone screen.

Enhancing MassiveObject

Some minor changes need to be made to MassiveObject to support some new features needed in the game that were not in the example in the preceding hour. Following is what the class now looks like, with the new variables and updated constructor:

[code]
class MassiveObject : Sprite
{
public string name;
public bool captured;
public double mass;
public Vector2 acceleration;
public float radius, angle;
public int lifetime, startTime;
public MassiveObject(ContentManager content,
SpriteBatch spriteBatch)
: base(content, spriteBatch)
{
name = “object”;
mass = 1.0f;
acceleration = Vector2.Zero;
radius = 50.0f;
angle = 0.0f;
captured = false;
lifetime = 0;
startTime = 0;
}
// . . . note: some code omitted here
}
[/code]

Game1.cs

There are no changes to be made to Game1.cs, because the main source code file is now PlayingModule.cs. In the final hour coming up, we will again use the game state modules for a more polished gameplay experience.

Gameplay Source Code

The most significant code of the game is found in PlayingModule.cs. If you skipped ahead, you may have missed Hour 21, “Finite State Gameplay,” which explained how to use states to improve a game in many ways. The PlayingModule class is the primary gameplay class where the bulk of the game code will be found. The first lines of code in the class declare all the variables, including the key objects variable, defined as a List of MassiveObjects. We also see the black hole, the super core gravity well, and the player’s ship here, among other things. Figure 23.2 shows the game as it is just getting started, and Listing 23.1 shows the source code for the class.

The Black Hole game soon after startup.
FIGURE 23.2 The Black Hole game soon after startup.

LISTING 23.1 Source Code for the PlayingModule Class

[code]
public class PlayingModule : IGameModule
{
Game1 game;
SpriteFont font;
Random rand;
Sprite background;
MassiveObject blackHole;
MassiveObject superCore;
MassiveObject ship;
float energy = 100.0f;
int startTime, lifetime;
List<MassiveObject> objects;
public PlayingModule(Game1 game)
{
this.game = game;
rand = new Random();
startTime = 0;
lifetime = 4000;
}
public void LoadContent(ContentManager content)
{
font = content.Load<SpriteFont>(“WascoSans”);
background = new Sprite(game.Content, game.spriteBatch);
background.Load(“space”);
background.origin = Vector2.Zero;
blackHole = new MassiveObject(game.Content, game.spriteBatch);
blackHole.Load(“blackhole”);
blackHole.position = new Vector2(500, 240);
blackHole.scale = 2.0f;
blackHole.mass = 40;
blackHole.color = new Color(255, 100, 100, 200);
blackHole.velocityAngular = 0.1f;
superCore = new MassiveObject(game.Content, game.spriteBatch);
superCore.image = blackHole.image;
superCore.position = new Vector2(blackHole.position.X,
blackHole.position.Y);
superCore.scale = blackHole.scale * 0.4f;
superCore.mass = 60;
superCore.color = new Color(200, 100, 100, 180);
superCore.velocityAngular = 4.0f;
superCore.origin = new Vector2(64, 64);
//create objects list
objects = new List<MassiveObject>();
//create player ship
ship = new MassiveObject(game.Content, game.spriteBatch);
ship.Load(“ship”);
ship.position = new Vector2(200, 240);
ship.mass = 100f;
ship.scale = 0.2f;
ship.rotation = MathHelper.ToRadians(90);
}
[/code]

The Update() method is a bit monolithic at this stage, but the code is easier to follow this way than if it had been divided into several smaller methods. I usually divide a method like this when it grows too large to be easily maintained, but since the gameplay code in PlayingModule is only 300 lines long, there isn’t too much to consume here at once. There’s a lot going on here in Update(), but we won’t break up the code listing and break the flow of the code, which can be distracting.

First of all, the blackHole and superCore objects are updated. Then we go into a foreach loop that processes all the MassiveObject objects in the objects list (there’s a tongue twister!). Each object is updated, rotated, and animated. Within the foreach is where the bulk of the code is found for the game.

When one of the satellites grows to a certain size (from collecting energy), that triggers a subset of code here in the foreach block where the player’s ship actually attracts the satellite toward it, using the same code used to simulate the gravitational pull of the black hole on the same objects. The slight gravity “tug” causes the satellites to veer toward the ship and increase the chances of their being caught by it, without making it too easy for the player. After all, the ship doesn’t move yet, it only rotates in place!

Next up is the code that tugs objects inward toward the black hole, if they are in range. Figure 23.3 shows another screenshot of the game, this time with a lot of satellites in orbit. Note the addition of animated asteroids in the scene. The asteroids serve no purpose, but just fill in some detail to make the scene look more interesting. A new asteroid is added every few seconds at a random direction and velocity, and over time they do tend to add up to quite a large mass of rotation around the black hole, which only increases the fun factor. Now, there is also potential use for these asteroid sprites beyond just “for looks.”

A large number of objects are orbiting the black hole, and they tend to fall in quite frequently.
FIGURE 23.3 A large number of objects are orbiting the black hole, and they tend to fall in quite frequently.

Listing 23.2 contains the source code for the Update() method.

It’s quite a challenge to come up with mass values for the black hole, the super core, and each of the objects that not only result in a realistic simulation of gravity’s effect on objects of mass but also make the game fun. Fun is more important than realism, but we want to have a little of both if possible. But when a trade-off is required, always go with that which helps the game to sell: the fun factor.

LISTING 23.2 Source Code for the Update() Method

[code]
public void Update(TouchLocation touch, GameTime gameTime)
{
int time = gameTime.ElapsedGameTime.Milliseconds;
blackHole.Update(gameTime);
blackHole.Rotate();
superCore.Update(gameTime);
superCore.Rotate();
foreach (MassiveObject obj in objects)
{
if (!obj.alive) continue;
obj.Update(gameTime);
obj.Rotate();
obj.Animate(time); //frame animation
obj.Animate(); //mod animation
//allow ship to collect energy satellites for bonus energy
if (obj.scale > 3.0f && obj.name == “satellite”)
{
obj.Attract(ship);
obj.color = Color.White;
if (game.RadialCollision(obj.position, ship.position,
obj.size.X, 40))
{
obj.alive = false;
energy += obj.scale;
}
}
if (!obj.captured)
{
//attract when object is near the black hole
if (game.RadialCollision(obj.position,
blackHole.position, 10, 500))
{
obj.Attract(blackHole);
obj.Attract(superCore);
//is object touching the outer edges of the black hole?
if (game.RadialCollision(obj.position,
blackHole.position, 10, 120))
{
obj.color = Color.Red;
if (obj.name == “satellite”)
{
obj.scale += 0.1f;
energy += 0.5f;
}
obj.Attract(blackHole); //outer black hole
obj.Attract(superCore); //inner black hole
//oh no, object is caught by the black hole!
if (game.RadialCollision(obj.position,
superCore.position, 16, 60))
{
obj.captured = true;
obj.lifetime = 3000;
obj.startTime = (int)
gameTime.TotalGameTime.TotalMilliseconds;
OrbitalMovement anim1 = new OrbitalMovement(
blackHole.position, 10 + rand.Next(40),
obj.rotation, -0.8f);
obj.animations.Add(anim1);
}
}
else
{
obj.color = Color.White;
}
}
}
//when captured, time runs out
if (obj.lifetime > 0)
{
if (obj.startTime + obj.lifetime <
gameTime.TotalGameTime.TotalMilliseconds)
obj.alive = false;
}
//see if object has gone too far out of bounds
if (obj.position.X < -200 || obj.position.X > 1000 ||
obj.position.Y < -200 || obj.position.Y > 700)
obj.alive = false;
}
//update ship
ship.Update(gameTime);
ship.Rotate();
ship.Animate(time);
if (energy <= 0)
{
ship.Attract(blackHole);
//object is caught by the black hole
if (game.RadialCollision(ship.position, superCore.position,
64, 40))
{
ship.captured = true;
ship.lifetime = 3000;
ship.startTime = (int)
gameTime.TotalGameTime.TotalMilliseconds;
OrbitalMovement anim1 = new OrbitalMovement(
blackHole.position, 10 + rand.Next(40),
ship.rotation, -0.8f);
ship.animations.Add(anim1);
}
//done being squished?
if (ship.lifetime > 0)
{
if (ship.startTime + ship.lifetime <
gameTime.TotalGameTime.TotalMilliseconds)
ship.alive = false;
}
}
else
{
energy -= 0.05f;
ship.velocityLinear.X = 0.0f;
}
//check user input
if (touch.State == TouchLocationState.Released)
{
if (touch.Position.X > ship.position.X)
{
CreateSatellite();
}
else
{
if (touch.Position.Y < 200)
ship.velocityAngular = -0.01f;
else if (touch.Position.Y > 280)
ship.velocityAngular = 0.01f;
else
ship.velocityAngular = 0;
}
}
//time to add another random asteroid?
if (startTime + lifetime < gameTime.TotalGameTime.
TotalMilliseconds)
{
startTime = (int)gameTime.TotalGameTime.TotalMilliseconds;
CreateAsteroid();
}
//clean out the dead objects
foreach (MassiveObject obj in objects)
{
if (obj.alive == false)
{
objects.Remove(obj);
break;
}
}
}
[/code]

The Draw() method is next, with its source code in Listing 23.3. This is a rather small method because the gameplay objects are managed.

LISTING 23.3 Source Code for the Draw() Method

[code]
public void Draw(GameTime gameTime)
{
background.Draw();
superCore.Draw();
blackHole.Draw();
ship.Draw();
foreach (MassiveObject obj in objects)
{
if (obj.alive)
{
obj.Draw();
}
}
string text = “Ship rot “ + MathHelper.ToDegrees(
ship.rotation).ToString();
game.spriteBatch.DrawString(font, text, new Vector2(0, 0),
Color.White);
text = “Objects “ + objects.Count.ToString();
game.spriteBatch.DrawString(font, text, new Vector2(0, 20),
Color.White);
text = “Energy “ + energy.ToString(“N0”);
game.spriteBatch.DrawString(font, text, new Vector2(650, 0),
Color.White);
}
[/code]

Finally, we have two helper methods, CreateAsteroid() and CreateSatellite(), that generate a random asteroid and random satellite, respectively. These two methods, shown in Listing 23.4, are quite important to the gameplay because they determine whether the objects will actually move reasonably on the screen. I say reasonably rather than realistically because, again, we don’t want absolute realism; we want some realism with gobs of fun gameplay. The asteroids aren’t important to the gameplay, because they are just for looks, but we do want them to start off in such a way that they end up rotating around the black hole. Likewise, the satellite must be launched in such a way that it moves reasonably well. At this stage, our satellites move at a constant speed, but in the next (and final) hour, we will add GUI controls that allow the player to adjust the power.

LISTING 23.4 Source Code for the CreateAsteroid() and CreateSatellite() Methods

[code]
public void CreateAsteroid()
{
MassiveObject obj = new MassiveObject(game.Content, game.spriteBatch);
obj.Load(“asteroid”);
obj.columns = 8;
obj.totalFrames = 64;
obj.scale = 0.1f + (float)rand.NextDouble();
obj.size = new Vector2(60, 60);
obj.radius = 80;
//randomly place at top or bottom of screen
obj.position = new Vector2(rand.Next(100, 800), -100);
obj.velocityLinear = new Vector2(4.0f, (float)(rand.NextDouble() *
6.0));
if (rand.Next(2) == 1)
{
obj.position.Y = -obj.position.Y;
obj.velocityLinear.Y = -obj.velocityLinear.Y;
}
obj.scale = 0.4f;
obj.mass = 1;
obj.velocityAngular = 0.001f;
obj.lifetime = 0;
obj.name = “asteroid”;
objects.Add(obj);
}
public void CreateSatellite()
{
MassiveObject obj;
obj = new MassiveObject(game.Content, game.spriteBatch);
obj.Load(“plasma32”);
obj.position = ship.position;
obj.mass = 1;
obj.scale = 0.5f;
obj.lifetime = 0;
obj.name = “satellite”;
//calculate velocity based on ship’s angle
float accel = 4.0f;
float angle = ship.rotation – MathHelper.ToRadians(90);
float x = (float)Math.Cos(angle) * accel;
float y = (float)Math.Sin(angle) * accel;
obj.velocityLinear = new Vector2(x,y);
//load energy to launch
energy -= 1;
objects.Add(obj);
}
}
[/code]
This all sounds like fun, but is there even a way to lose the game? Certainly! If the player runs out of energy, the ship will fall into the black hole! At this stage, the ship just loses its “traction” or station-keeping thrusters and is drawn into the black hole, only to be whipped around by the acceleration code. Some sort of fantastic animation will have to be added so that the ship gets sucked into the black hole like the other objects—a task for the next hour! Figure 23.4 shows what happens now if the player runs out of energy. Another improvement to be made in the next hour is an energy bar rather than just a text display. We have a lot of work yet to do on this game, but it’s already showing promise.

Running out of energy spells doom for the poor ship and its crew!
FIGURE 23.4 Running out of energy spells doom for the poor ship and its crew!