Quantcast
Channel: Envato Tuts+ Game Development
Viewing all articles
Browse latest Browse all 728

Make a Neon Vector Shooter in XNA: More Gameplay

$
0
0
This entry is part 2 of 2 in the series Make a Neon Vector Shooter in XNA

In this series of tutorials, I’ll show you how to make a neon twin stick shooter, like Geometry Wars, in XNA. The goal of these tutorials is not to leave you with an exact replica of Geometry Wars, but rather to go over the necessary elements that will allow you to create your own high-quality variant.


Overview

In this part we will build upon the previous tutorial by adding enemies, collision detection and scoring.

Here’s what we’ll have by the end of it:

Warning: Loud!

We will add the following new classes to handle this:

  • Enemy
  • EnemySpawner:  Responsible for creating enemies and gradually increasing the game’s difficulty.
  • PlayerStatus:  Tracks the player’s score, high score, and lives.

You may have noticed there are two types of enemies in the video, but there’s only one Enemy class. We could derive subclasses from Enemy for each enemy type. However, I prefer to avoid deep class hierarchies because they have some drawbacks:

  • They add more boilerplate code.
  • They can increase the complexity of the code and make it harder to understand. The state and functionality of an object becomes spread out over its entire inheritance chain.
  • They aren’t very flexible. You can’t share pieces of functionality between different branches of the inheritance tree if that functionality isn’t in the base class. For example, consider making two classes, Mammal and Bird, which both derive from Animal. The Bird class has a Fly() method. Then you decide to add a Bat class that derives from Mammal and can also fly. To share this functionality using only inheritance you would have to move the Fly() method to the Animal class where it doesn’t belong. In addition, you can’t remove methods from derived classes, so if you made a Penguin class that derived from Bird, it would also had a Fly() method.

For this tutorial, we will favour composition over inheritance for implementing the different types of enemies. We will do this by creating various, reusable behaviours that we can add to enemies. We can then easily mix and match behaviours when we create new types of enemies. For example, if we already had a FollowPlayer behaviour and a DodgeBullet behaviour, we could make a new enemy that does both simply by adding both behaviours.


Enemies

Enemies will have a few additional properties over entities. In order to give the player some time to react, we’ll make enemies gradually fade in before they become active and dangerous.

Let’s code the basic structure of the Enemy class.

class Enemy : Entity
{
	private int timeUntilStart = 60;
	public bool IsActive { get { return timeUntilStart <= 0; } }

	public Enemy(Texture2D image, Vector2 position)
	{
		this.image = image;
		Position = position;
		Radius = image.Width / 2f;
		color = Color.Transparent;
	}

	public override void Update()
	{
		if (timeUntilStart <= 0)
		{
			// enemy behaviour logic goes here.
		}
		else
		{
			timeUntilStart--;
			color = Color.White * (1 - timeUntilStart / 60f);
		}

		Position += Velocity;
		Position = Vector2.Clamp(Position, Size / 2, GameRoot.ScreenSize - Size / 2);

		Velocity *= 0.8f;
	}

	public void WasShot()
	{
		IsExpired = true;
	}
}

This code will make enemies fade in for 60 frames and will allow their velocity to function. Multiplying the velocity by 0.8 fakes a friction-like effect. If we make enemies accelerate at a constant rate, this friction will cause them to smoothly approach a maximum speed. I like the simplicity and smoothness of this type of friction, but you may want to use a different formula depending on the effect you want.

The WasShot() method will be called when the enemy gets shot. We’ll add more to it later in the series.

We want different types of enemies to behave differently. We’ll accomplish this by assigning behaviours. A behaviour will use some custom function that runs each frame to control the enemy. We’ll implement the behaviour using an iterator.

Iterators (also called generators) in C# are special methods that can stop partway through and later resume where they left off. You can make an iterator by making a method with a return type of IEnumerable<> and using the yield keyword where you want it to return and later resume. Iterators in C# require you to return something when you yield. We don’t really need to return anything, so our iterators will simply yield zero.

Our simplest behaviour will be the FollowPlayer() behaviour shown below.

IEnumerable<int> FollowPlayer(float acceleration = 1f)
{
	while (true)
	{
		Velocity += (PlayerShip.Instance.Position - Position).ScaleTo(acceleration);
		if (Velocity != Vector2.Zero)
			Orientation = Velocity.ToAngle();

		yield return 0;
	}
}

This simply makes the enemy accelerate towards the player at a constant rate. The friction we added earlier will ensure it eventually tops out at some max speed (5 pixels per frame when acceleration is 1 since \(0.8 \times 5 + 1 = 5\)). Each frame, this method will run until it hits the yield statement and will then resume where it left off next frame.

You may be wondering why we bothered with iterators at all, since we could have accomplished the same task more easily with a simple delegate. Using iterators pays off with more complex methods that would otherwise require us to store state in member variables in the class.

For example, below is a behaviour that makes an enemy move in a square pattern:

IEnumerable<int> MoveInASquare()
{
	const int framesPerSide = 30;
	while (true)
	{
		// move right for 30 frames
		for (int i = 0; i < framesPerSide; i++)
		{
			Velocity = Vector2.UnitX;
			yield return 0;
		}

		// move down
		for (int i = 0; i < framesPerSide; i++)
		{
			Velocity = Vector2.UnitY;
			yield return 0;
		}

		// move left
		for (int i = 0; i < framesPerSide; i++)
		{
			Velocity = -Vector2.UnitX;
			yield return 0;
		}

		// move up
		for (int i = 0; i < framesPerSide; i++)
		{
			Velocity = -Vector2.UnitY;
			yield return 0;
		}
	}
}

What’s nice about this is that it not only saves us some instance variables, but it also structures the code in a very logical way. You can see right away that the enemy will move right, then down, then left, then up, and then repeat. If you were to implement this method as a state machine instead, the control flow would be less obvious.

Let’s add the scaffolding needed to make behaviours work. Enemies need to store their behaviours, so we’ll add a variable to the Enemy class.

private List<IEnumerator<int>> behaviours = new List<IEnumerator<int>>();

Note that a behaviour has the type IEnumerator<int>, not IEnumerable<int>. You can think of the IEnumerable as the template for the behaviour and the IEnumerator as the running instance. The IEnumerator remembers where we are in the behaviour and will pick up where it left off when you call its MoveNext() method. Each frame we’ll go through all the behaviours the enemy has and call MoveNext() on each of them. If MoveNext() returns false, it means the behaviour has completed so we should remove it from the list.

We’ll add the following methods to the Enemy class:

private void AddBehaviour(IEnumerable<int> behaviour)
{
	behaviours.Add(behaviour.GetEnumerator());
}

private void ApplyBehaviours()
{
	for (int i = 0; i < behaviours.Count; i++)
	{
		if (!behaviours[i].MoveNext())
			behaviours.RemoveAt(i--);
	}
}

And we’ll modify the Update() method to call ApplyBehaviours():

if (timeUntilStart <= 0)
	ApplyBehaviours();
// ...

Now we can make a static method to create seeking enemies. All we have to do is pick the image we want and add the FollowPlayer() behaviour.

public static Enemy CreateSeeker(Vector2 position)
{
	var enemy = new Enemy(Art.Seeker, position);
	enemy.AddBehaviour(enemy.FollowPlayer());

	return enemy;
}

To make an enemy that moves randomly, we’ll have it choose a direction and then make small random adjustments to that direction. However, if we adjust the direction every frame, the movement will be jittery, so we’ll only adjust the direction periodically. If the enemy runs into the edge of the screen, we’ll have it choose a new random direction that points away from the wall.

IEnumerable<int> MoveRandomly()
{
	float direction = rand.NextFloat(0, MathHelper.TwoPi);

	while (true)
	{
		direction += rand.NextFloat(-0.1f, 0.1f);
		direction = MathHelper.WrapAngle(direction);

		for (int i = 0; i < 6; i++)
		{
			Velocity += MathUtil.FromPolar(direction, 0.4f);
			Orientation -= 0.05f;

			var bounds = GameRoot.Viewport.Bounds;
			bounds.Inflate(-image.Width, -image.Height);

			// if the enemy is outside the bounds, make it move away from the edge
			if (!bounds.Contains(Position.ToPoint()))
				direction = (GameRoot.ScreenSize / 2 - Position).ToAngle() + rand.NextFloat(-MathHelper.PiOver2, MathHelper.PiOver2);

			yield return 0;
		}
	}
}

Collision Detection

For collision detection, we’ll model the player’s ship, the enemies, and the bullets as circles. Circular collision detection is nice because it’s simple, it’s fast, and it doesn’t change when the objects rotate. If you recall, the Entity class has a radius and a position (the position refers to the center of the entity). This is all we need for circular collision detection.

Testing each entity against all other entities that could potentially collide can be very slow if you have a large number of entities. There are many techniques you can use to speed up broad phase collision detection, like quadtrees, sweep and prune, and BSP trees. However, for now, we will only have a few dozen entities on screen at a time, so we won’t worry about these more complex techniques. We can always add them later if we need them.

In Shape Blaster, not every entity can collide with every other type of entity. Bullets and the player’s ship can collide only with enemies. Enemies can also collide with other enemies – this will prevent them from overlapping.

To deal with these different types of collisions, we will add two new lists to the EntityManager to keep track of bullets and enemies. Whenever we add an entity to the EntityManager, we’ll want to add it to the appropriate list, so we’ll make a private AddEntity() method to do so. We’ll also be sure to remove any expired entities from all the lists each frame.

static List<Enemy> enemies = new List<Enemy>();
static List<Bullet> bullets = new List<Bullet>();

private static void AddEntity(Entity entity)
{
	entities.Add(entity);
	if (entity is Bullet)
		bullets.Add(entity as Bullet);
	else if (entity is Enemy)
		enemies.Add(entity as Enemy);
}

// ...
// in Update()
bullets = bullets.Where(x => !x.IsExpired).ToList();
enemies = enemies.Where(x => !x.IsExpired).ToList();

Replace the calls to entity.Add() in EntityManager.Add() and EntityManager.Update() with calls to AddEntity().

Now let’s add a method that will determine whether two entities are colliding:

private static bool IsColliding(Entity a, Entity b)
{
	float radius = a.Radius + b.Radius;
	return !a.IsExpired && !b.IsExpired && Vector2.DistanceSquared(a.Position, b.Position) < radius * radius;
}

To determine if two circles overlap, simply check if the distance between them is less than the sum of their radii. Our method optimizes this slightly by checking if the square of the distance is less than the square of the sum of the radii. Remember that it’s a bit faster to compute the distance squared than the actual distance.

Different things will happen depending on which two objects collide. If two enemies collide, we want them to push each other away. If a bullet hits an enemy, the bullet and the enemy should both be destroyed. If the player touches an enemy, the player should die and the level should reset.

We’ll add a HandleCollision() method to the Enemy class to handle collisions between enemies:

public void HandleCollision(Enemy other)
{
	var d = Position - other.Position;
	Velocity += 10 * d / (d.LengthSquared() + 1);
}

This method will push the current enemy away from the other enemy. The closer they are, the harder it will be pushed, because the magnitude of (d / d.LengthSquared()) is just one over the distance.

Respawning the Player

Next we need a method to handle the player’s ship getting killed. When this happens, the player’s ship will disappear for a short time before respawning.

We start by adding two new members to PlayerShip.

int framesUntilRespawn = 0;
public bool IsDead { get { return framesUntilRespawn > 0; } }

At the very beginning of PlayerShip.Update(), add the following:

if (IsDead)
{
	framesUntilRespawn--;				
	return;
}

And we override Draw() as shown:

public override void Draw(SpriteBatch spriteBatch)
{
	if (!IsDead)
		base.Draw(spriteBatch);
}

Finally, we add a Kill() method to PlayerShip.

public void Kill()
{
	framesUntilRespawn = 60;
}

Now that all the pieces are in place, we’ll add a method to the EntityManager that goes through all the entities and checks for collisions.

static void HandleCollisions()
{
	// handle collisions between enemies
	for (int i = 0; i < enemies.Count; i++)
		for (int j = i + 1; j < enemies.Count; j++)
		{
			if (IsColliding(enemies[i], enemies[j]))
			{
				enemies[i].HandleCollision(enemies[j]);
				enemies[j].HandleCollision(enemies[i]);
			}
		}

	// handle collisions between bullets and enemies
	for (int i = 0; i < enemies.Count; i++)
		for (int j = 0; j < bullets.Count; j++)
		{
			if (IsColliding(enemies[i], bullets[j]))
			{
				enemies[i].WasShot();
				bullets[j].IsExpired = true;
			}
		}

	// handle collisions between the player and enemies
	for (int i = 0; i < enemies.Count; i++)
	{
		if (enemies[i].IsActive && IsColliding(PlayerShip.Instance, enemies[i]))
		{
			PlayerShip.Instance.Kill();
			enemies.ForEach(x => x.WasShot());
			break;
		}
	}
}

Call this method from Update() immediately after setting isUpdating to true.


Enemy Spawner

The last thing to do is make the EnemySpawner class, which is responsible for creating enemies. We want the game to start off easy and get harder, so the EnemySpawner will create enemies at an increasing rate as time progresses. When the player dies, we’ll reset the EnemySpawner to its initial difficulty.

static class EnemySpawner
{
	static Random rand = new Random();
	static float inverseSpawnChance = 60;

	public static void Update()
	{
		if (!PlayerShip.Instance.IsDead && EntityManager.Count < 200)
		{
			if (rand.Next((int)inverseSpawnChance) == 0)
				EntityManager.Add(Enemy.CreateSeeker(GetSpawnPosition()));

			if (rand.Next((int)inverseSpawnChance) == 0)
				EntityManager.Add(Enemy.CreateWanderer(GetSpawnPosition()));
		}
			
		// slowly increase the spawn rate as time progresses
		if (inverseSpawnChance > 20)
			inverseSpawnChance -= 0.005f;
	}

	private static Vector2 GetSpawnPosition()
	{
		Vector2 pos;
		do
		{
			pos = new Vector2(rand.Next((int)GameRoot.ScreenSize.X), rand.Next((int)GameRoot.ScreenSize.Y));
		} 
		while (Vector2.DistanceSquared(pos, PlayerShip.Instance.Position) < 250 * 250);

		return pos;
	}

	public static void Reset()
	{
		inverseSpawnChance = 60;
	}
}

Each frame, there is a one in inverseSpawnChance of generating each type of enemy. The chance of spawning an enemy gradually increases until it reaches a maximum of one in twenty. Enemies are always created at least 250 pixels away from the player.

Be careful about the while loop in GetSpawnPosition(). It will work efficiently as long as the area in which enemies can spawn is bigger than the area where they can’t spawn. However, if you make the forbidden area too large, you will get an infinite loop.

Call EnemySpawner.Update() from GameRoot.Update() and call EnemySpawner.Reset() when the player is killed.


Score and Lives

In Shape Blaster, you will begin with four lives, and will gain an additional life every 2000 points. You receive points for destroying enemies, with different types of enemies being worth different amounts of points. Each enemy destroyed also increases your score multiplier by one. If you don’t kill any enemies within a short amount of time, your multiplier will be reset. The total amount of points received from each enemy you destroy is the number of points the enemy is worth multiplied by your current multiplier. If you lose all your lives, the game is over and you start a new game with your score reset to zero.

To handle all this, we will make a static class called PlayerStatus.

static class PlayerStatus
{
	// amount of time it takes, in seconds, for a multiplier to expire.
	private const float multiplierExpiryTime = 0.8f;
	private const int maxMultiplier = 20;

	public static int Lives { get; private set; }
	public static int Score { get; private set; }
	public static int Multiplier { get; private set; }

	private static float multiplierTimeLeft;	// time until the current multiplier expires
	private static int scoreForExtraLife;		// score required to gain an extra life

	// Static constructor
	static PlayerStatus()
	{
		Reset();
	}

	public static void Reset()
	{
		Score = 0;
		Multiplier = 1;
		Lives = 4;
		scoreForExtraLife = 2000;
		multiplierTimeLeft = 0;
	}

	public static void Update()
	{
		if (Multiplier > 1)
		{
			// update the multiplier timer
			if ((multiplierTimeLeft -= (float)GameRoot.GameTime.ElapsedGameTime.TotalSeconds) <= 0)
			{
				multiplierTimeLeft = multiplierExpiryTime;
				ResetMultiplier();
			}
		}
	}

	public static void AddPoints(int basePoints)
	{
		if (PlayerShip.Instance.IsDead)
			return;

		Score += basePoints * Multiplier;
		while (Score >= scoreForExtraLife)
		{
			scoreForExtraLife += 2000;
			Lives++;
		}
	}

	public static void IncreaseMultiplier()
	{
		if (PlayerShip.Instance.IsDead)
			return;

		multiplierTimeLeft = multiplierExpiryTime;
		if (Multiplier < maxMultiplier)
			Multiplier++;
	}

	public static void ResetMultiplier()
	{
		Multiplier = 1;
	}

	public static void RemoveLife()
	{
		Lives--;
	}
}

Call PlayerStatus.Update() from GameRoot.Update() when the game is not paused.

Next we want to display your score, lives, and multiplier on screen. To do this we’ll need to add a SpriteFont in the Content project and a corresponding variable in the Art class, which we will name Font. Load the font in Art.Load() as we did with the textures.

Note: There is a font called Nova Square included with the Shape Blaster source files that you may use. To use the font, you must first install it and then restart Visual Studio if it was open. You can then change the font name in the sprite font file to “Nova Square”. The demo project does not use this font by default because it will prevent the project from compiling if the font is not installed.

Modify the end of GameRoot.Draw() where the cursor is drawn as shown below.

spriteBatch.Begin(0, BlendState.Additive);

spriteBatch.DrawString(Art.Font, "Lives: " + PlayerStatus.Lives, new Vector2(5), Color.White);
DrawRightAlignedString("Score: " + PlayerStatus.Score, 5);
DrawRightAlignedString("Multiplier: " + PlayerStatus.Multiplier, 35);

// draw the custom mouse cursor
spriteBatch.Draw(Art.Pointer, Input.MousePosition, Color.White);
spriteBatch.End();

DrawRightAlignedString() is a helper method for drawing text aligned on the right side of the screen. Add it to GameRoot by adding the code below.

private void DrawRightAlignedString(string text, float y)
{
	var textWidth = Art.Font.MeasureString(text).X;
	spriteBatch.DrawString(Art.Font, text, new Vector2(ScreenSize.X - textWidth - 5, y), Color.White);
}

Now your lives, score, and multiplier should display on screen. However, we still need to modify these values in response to game events. Add a property called PointValue to the Enemy class.

public int PointValue { get; private set; }

Set the point value for different enemies to something you feel is appropriate. I made the wandering enemies worth one point, and the seeking enemies worth two points.

Next, add the following two lines to Enemy.WasShot() to increase the player’s score and multiplier:

PlayerStatus.AddPoints(PointValue);
PlayerStatus.IncreaseMultiplier();

Call PlayerStatus.RemoveLife() in PlayerShip.Kill(). If the player loses all their lives, call PlayerStatus.Reset() to reset their score and lives at the start of a new game.

High Scores

Let’s add the ability for the game to track your best score. We want this score to persist across plays so we’ll save it to a file. We’ll keep it really simple and save the high score as a single plain-text number in a file in the current working directory (this will be the same directory that contains the game’s .exe file).

Add the following methods to PlayerStatus:

private const string highScoreFilename = "highscore.txt";

private static int LoadHighScore() 
{
	// return the saved high score if possible and return 0 otherwise
	int score;
	return File.Exists(highScoreFilename) && int.TryParse(File.ReadAllText(highScoreFilename), out score) ? score : 0;
}

private static void SaveHighScore(int score)
{
	File.WriteAllText(highScoreFilename, score.ToString());
}

The LoadHighScore() method first checks that the high score file exists, and then checks that it contains a valid integer. The second check will most likely never fail unless the user manually edits the high score file to something invalid, but it’s good to be cautious.

We want to load the high score when the game starts up, and save it when the player gets a new high score. We’ll modify the static constructor and Reset() methods in PlayerStatus to do so. We’ll also add a helper property, IsGameOver which we’ll use in a moment.

public static bool IsGameOver { get { return Lives == 0; } }

static PlayerStatus()
{
	HighScore = LoadHighScore();
	Reset();
}

public static void Reset()
{
	if (Score > HighScore)
		SaveHighScore(HighScore = Score);

	Score = 0;
	Multiplier = 1;
	Lives = 4;
	scoreForExtraLife = 2000;
	multiplierTimeLeft = 0;
}

That takes care of tracking the high score. Now we need to display it. Add the following code to GameRoot.Draw() in the same SpriteBatch block where the other text is drawn:

if (PlayerStatus.IsGameOver)
{
	string text = "Game Over\n" +
		"Your Score: " + PlayerStatus.Score + "\n" +
		"High Score: " + PlayerStatus.HighScore;

	Vector2 textSize = Art.Font.MeasureString(text);
	spriteBatch.DrawString(Art.Font, text, ScreenSize / 2 - textSize / 2, Color.White);
}

This will make it display your score and high score on game over, centered in the screen.

As a final adjustment, we’ll increase the time before the ship respawns on game over to give the player time to see their score. Modify PlayerShip.Kill() by setting the respawn time to 300 frames (five seconds) if the player is out of lives.

// in PlayerShip.Kill()
PlayerStatus.RemoveLife();
framesUntilRespawn = PlayerStatus.IsGameOver ? 300 : 120;

The game is now ready to play. It may not look like much, but it has all the basic mechanics implemented. In future tutorials we will add a bloom filter and particle effects to spice it up. But right now, let’s quickly add some sound and music to make it more interesting.


Sound and Music

Playing sound and music is easy in XNA. First, we add our sound effects and music to the content pipeline. In the Properties pane, ensure the content processor is set to Song for the music and Sound Effect for the sounds.

Next, we make a static helper class for the sounds.

static class Sound
{
	public static Song Music { get; private set; }

	private static readonly Random rand = new Random();

	private static SoundEffect[] explosions;
	// return a random explosion sound
	public static SoundEffect Explosion { get { return explosions[rand.Next(explosions.Length)]; } }

	private static SoundEffect[] shots;
	public static SoundEffect Shot { get { return shots[rand.Next(shots.Length)]; } }

	private static SoundEffect[] spawns;
	public static SoundEffect Spawn { get { return spawns[rand.Next(spawns.Length)]; } }

	public static void Load(ContentManager content)
	{
		Music = content.Load<Song>("Sound/Music");

		// These linq expressions are just a fancy way loading all sounds of each category into an array.
		explosions = Enumerable.Range(1, 8).Select(x => content.Load<SoundEffect>("Sound/explosion-0" + x)).ToArray();
		shots = Enumerable.Range(1, 4).Select(x => content.Load<SoundEffect>("Sound/shoot-0" + x)).ToArray();
		spawns = Enumerable.Range(1, 8).Select(x => content.Load<SoundEffect>("Sound/spawn-0" + x)).ToArray();
	}
}

Since we have multiple variations of each sound, the Explosion, Shot, and Spawn properties will pick a sound at random among the variants.

Call Sound.Load() in GameRoot.LoadContent(). To play the music, add the following two lines at the end of GameRoot.Initialize().

MediaPlayer.IsRepeating = true;
MediaPlayer.Play(Sound.Music);

To play sounds in XNA, you can simply call the Play() method on a SoundEffect. This method also provides an overload that allows you to adjust the volume, pitch and pan of the sound. A trick to make our sounds more varied is to adjust these quantities on each play.

To trigger the sound effect for shooting, add the following line in PlayerShip.Update(), inside the if-statement where the bullets are created. Note that we randomly shift the pitch up or down, up to a fifth of an octave, to make the sounds less repetitive.

Sound.Shot.Play(0.2f, rand.NextFloat(-0.2f, 0.2f), 0);

Likewise, trigger an explosion sound effect each time an enemy is destroyed by adding the following to Enemy.WasShot().

Sound.Explosion.Play(0.5f, rand.NextFloat(-0.2f, 0.2f), 0);

You now have sound and music in your game. Easy, isn’t it?


Conclusion

That wraps up the basic gameplay mechanics. In the next tutorial, we’ll add a bloom filter to make the neon lights glow.


Viewing all articles
Browse latest Browse all 728

Trending Articles