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!

The Physics of Gravity

Simulating Gravity

Gravity is an interesting phenomenon of physics. Every moment of every day, you are literally falling inward toward the center of the earth. The surface of the earth keeps you from falling farther, but the same force that causes a person parachuting out of an airplane to fall downward, which is the same force that causes satellites in orbit to gradually lose position and require constant adjustments, causes you to fall downward at every moment. It actually takes quite a bit of energy to stand up against the force of the earth’s gravity. Fortunately, a human body is relatively small, so the gravity exerted is not too great that we can’t survive. But try to imagine, the same gravity pulling you toward the center of the earth also keeps the moon in orbit! The moon is gradually losing its orbit, by the way. Over time, it will draw closer and closer to the earth and eventually collide. The time frame is huge, but it is happening nonetheless.

Escape Velocity

To break out of the earth’s gravity field requires a huge amount of thrust! The velocity required to do so is called escape velocity, which is the velocity required to escape the gravity of a planet or another large, massive object. Gravity, according to physics, is called gravitational potential energy. The term escape velocity is somewhat incorrect, but the term has stuck over the years. Velocity, as you have learned in this book, affects movement in a specific direction calculated with cosine (for X) and sine (for Y). Escape velocity is actually a speed, not a velocity, because an object moving at escape speed will break orbit no matter which direction it is moving in.

Earth’s escape velocity is 6.96 miles per second (11.2 kilometers per second). In terms of aircraft speed, that is Mach 34, about 10 times faster than a bullet! But these terms are applicable only to ballistic objects, like a rocket launched from the surface. Ballistic is a term that means something is fired or launched and then the object coasts once it reaches a desired speed. The ballistic rocket that launched the Apollo astronauts toward the moon had to reach escape velocity with two or more rocket stages, plus an outer space thruster that sent the spaceship coasting toward the moon. But a spacecraft lifting off from the earth does not need to reach this ballistic escape velocity if it can just maintain a consistent thrust for a longer period. A U.S. Air Force F-22 Raptor jet, for instance, has enough thrust to go ballistic in terms of its own weight (meaning it can continue to speed up while going straight up, without ever slowing down), with fuel being the only limitation.

Calculating “Gravity”

This is not really “gravity” we’re calculating, but rather the force two objects exert on each other. The result looks very much like the pull of gravity. Just note that the code we’re about to write is simplified for a gameplay mechanic, not for spacecraft trajectories.

The formulas involved in “rocket science” are not overly complicated, but we’re going to use a simpler technique to simulate gravity between any two massive objects. The end result will be similar as far as a game is concerned. What we need to be concerned with is the mass, position, and acceleration factor for each object.

Using these three basic pieces of information, we can cause two massive objects to affect each other gravitationally. During every update, the position, acceleration, and velocity are all updated. While the two objects are far apart, their interaction will be negligible. But as they draw closer, the acceleration factor will affect the velocity, which will cause the two objects to speed up toward each other. In the terms of rocket science, this is called “the whiplash effect.” NASA and other space agencies often use whiplash gravity to propel their spacecraft toward a destination more quickly, costing less fuel as a result. After we have a simulation running, we can try this out!

To cause two massive objects to interact, first we have to calculate the distance between them. This will have a direct effect on the amount of force the objects exert upon each other. This code takes into account the situation in which the two objects have collided, in which case the distance factor is inverted:

[code]
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) distance = 1 / dist;
[/code]

Next, we use these distance values to calculate the acceleration of the object. Since this is directly affected by the distance to the other object, the acceleration will increase as the objects grow closer, which further increases acceleration. That is the nature of the whiplash effect, and it works well as long as the objects do not collide:

[code]
this.acceleration.X = (float)(-1 * other.mass * distX * distance);
this.acceleration.Y = (float)(-1 * other.mass * distY * distance);
[/code]

Velocity is updated directly with the acceleration values:

[code]
this.velocityLinear.X += this.acceleration.X;
this.velocityLinear.Y += this.acceleration.Y;
[/code]

The position, likewise, is updated directly with the velocity values:

[code]
this.position.X += this.velocityLinear.X;
this.position.Y += this.velocityLinear.Y;
[/code]

It is very easy to lose track of game objects that interact with this gravitational code. If two objects collide, so that the distance between them is zero, then they will fling away from each other at high speed. I recommend adding boundary code to the velocity and acceleration values so that this doesn’t happen in a playable game.

The Gravity Demo

The sample project can be found again under the name “Black Hole Game” in this hour’s resource files. Because all the concepts and code learned during these last remaining chapters is directly applied to the sample game in the final hour, the project will just grow and evolve, and we’ll continue to use the same name, as well as use code developed during previous hours. The example will be based on the game state example in the preceding hour, and we’ll use the PlayingModule.cs file rather than Game1.cs as has been the norm previously. Figure 22.1 shows the example from this hour running.

The “plasma” sprite is rotating around the black hole in an elliptical orbit.
FIGURE 22.1 The “plasma” sprite is rotating around the black hole in an elliptical orbit.

This program requires several bitmap files. You can copy them directly out of the project included in the book resource files, or create your own. For the “black hole” image, I have just created a filled black circle with transparency around the outside. The “plasma” image is an alpha blended particle that I’ve had for quite a while, and have used as a weapon projectile in some past games. The asteroid in this example is just window dressing, included to show how an object with an OrbitalMovement animation looks compared to the calculated trajectories of a MassiveObject object. To get a head start on the gameplay that will be needed for this game, I have added some code to cause the orbiting power satellites (currently represented just as a “plasma” ball) to get sucked into the black hole if they get too close. When this happens, the gravity code is replaced with the OrbitalMovement animation class. In Figure 22.2, we see that the projectile, or “plasma” sprite, has been captured by the black hole.

The “plasma” sprite has been captured by the black hole.
FIGURE 22.2 The “plasma” sprite has been captured by the black hole.

MassiveObject Class

A new class called MassiveObject, which inherits from Sprite, will handle our gravitational needs. Listing 22.1 provides the source code for this new class.

LISTING 22.1 Source Code for the MassiveObject Class

[code]
class MassiveObject : Sprite
{
public double mass;
public Vector2 acceleration;
public float radius,angle;
public bool captured;
public MassiveObject(ContentManager content,
SpriteBatch spriteBatch)
: base(content, spriteBatch)
{
mass = 1.0f;
acceleration = Vector2.Zero;
radius = 50.0f;
angle = 0.0f;
this.captured = false;
}
public void Update(GameTime gameTime)
{
}
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.cs

This is the main source code file for the Gravity Demo, where the black hole and other MassiveObject (Sprite) objects are created, updated, and drawn. In other words, this is our main source code file. The original game state code is still present in the other files, but that has been removed from PlayingModule.cs. To return to the title screen, it’s still possible to set game.gameMode as before! Listing 22.2 shares the source code for the class.

LISTING 22.2 Source Code for the PlayingModule Class

[code]
public class PlayingModule : IGameModule
{
Game1 game;
SpriteFont font;
Random rand;
MassiveObject blackHole;
MassiveObject asteroid;
MassiveObject plasma;
public PlayingModule(Game1 game)
{
this.game = game;
rand = new Random();
}
public void LoadContent(ContentManager content)
{
font = content.Load<SpriteFont>(“WascoSans”);
blackHole = new MassiveObject(game.Content,
game.spriteBatch);
blackHole.Load(“blackhole”);
blackHole.position = new Vector2(400, 240);
blackHole.velocityAngular = -.05f;
blackHole.scale = 1.0f;
blackHole.mass = 100;
asteroid = new MassiveObject(game.Content,
game.spriteBatch);
asteroid.Load(“asteroid”);
asteroid.columns = 8;
asteroid.totalFrames = 64;
asteroid.size = new Vector2(60, 60);
asteroid.radius = 80;
asteroid.animations.Add(new OrbitalMovement(
new Vector2(400,240), 80, 0, 0.08f));
asteroid.scale = 0.5f;
plasma = new MassiveObject(game.Content,
game.spriteBatch);
plasma.Load(“plasma32”);
plasma.position = new Vector2(200, 240);
plasma.mass = 1;
plasma.velocityLinear = new Vector2(1.0f, 7.0f);
}
public void Update(TouchLocation touch, GameTime gameTime)
{
int time = gameTime.ElapsedGameTime.Milliseconds;
blackHole.Update(gameTime);
blackHole.Rotate();
asteroid.angle += 0.001f;
asteroid.Update(gameTime);
asteroid.Rotate();
asteroid.Animate(time);
asteroid.Animate();
plasma.Update(gameTime);
plasma.Rotate();
plasma.Animate();
if (!plasma.captured)
{
plasma.Attract(blackHole);
if (game.RadialCollision(plasma, blackHole))
{
plasma.captured = true;
OrbitalMovement anim1 = new OrbitalMovement(
blackHole.position, 20 + rand.Next(20),
plasma.rotation, 0.8f);
plasma.animations.Add(anim1);
CycleColorBounce anim2 = new CycleColorBounce(
0, 10, 10, 0);
plasma.animations.Add(anim2);
}
}
}
public void Draw(GameTime gameTime)
{
blackHole.Draw();
asteroid.Draw();
plasma.Draw();
string text = “Position “ +
((int)plasma.position.X).ToString() + “,” +
((int)plasma.position.Y).ToString();
game.spriteBatch.DrawString(font, text,
new Vector2(0, 0), Color.White);
float dist = game.Distance(plasma.position, blackHole.position);
text = “Distance “ + ((int)dist).ToString();
game.spriteBatch.DrawString(font, text,
new Vector2(0, 20), Color.White);
}
}
[/code]

Game1.cs

The source code to Game1.cs has not changed since the example in the preceding hour, but we need some reusable methods in this example added to the Game1.cs file, where they will be more useful. We have seen all of this code before, but just to be thorough, Listing 22.3 contains the complete code for the file with the additions.

LISTING 22.3 Main Source Code for the Example

[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;
SpriteFont font;
Random rand;
TouchLocation oldTouch;
IGameModule[] modules;
public Game1()
{
graphics = new GraphicsDeviceManager(this);
Content.RootDirectory = “Content”;
TargetElapsedTime = TimeSpan.FromTicks(333333);
oldTouch = new TouchLocation();
rand = new Random();
}
protected override void Initialize()
{
base.Initialize();
}
protected override void LoadContent()
{
spriteBatch = new SpriteBatch(GraphicsDevice);
font = Content.Load<SpriteFont>(“WascoSans”);
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);
}
gameState = GameState.PLAYING;
}
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;
}
//update current module
modules[(int)gameState].Update(touch ,gameTime);
base.Update(gameTime);
}
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(Color.CornflowerBlue);
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]