1. Code
  2. Game Development

Haz un disparo vectorial de neón en XNA: más jugabilidad

Scroll to top
This post is part of a series called Cross-Platform Vector Shooter: XNA.
Make a Neon Vector Shooter in XNA: Basic Gameplay
Make a Neon Vector Shooter in XNA: Bloom and Black Holes

Spanish (Español) translation by Charles (you can also view the original English article)

En esta serie de tutoriales, te mostraré cómo hacer una barra de disparo gemelo de neón, como Geometry Wars, en XNA. El objetivo de estos tutoriales no es dejarte con una réplica exacta de Geometry Wars, sino repasar los elementos necesarios que te permitirán crear tu propia variante de alta calidad.


Visión general

En esta parte nos basaremos en el tutorial anterior mediante la adición de enemigos, detección de colisiones y puntuación.

Esto es lo que tendremos al final:

Agregaremos las siguientes clases nuevas para controlar esto:

  • Enemy
  • EnemySpawner: Responsable de crear enemigos y aumentar gradualmente la dificultad del juego.
  • PlayerStatus: Realiza un seguimiento de la puntuación del jugador, la puntuación más alta y las vidas.

Es posible que hayas notado que hay dos tipos de enemigos en el video, pero solo hay una clase de enemigo. Podríamos derivar subclases de Enemigo para cada tipo. Sin embargo, prefiero evitar las jerarquías de clases profundas porque tienen algunos inconvenientes:

  • Agregan más código reutilizable.
  • Pueden aumentar la complejidad del código y dificultar su comprensión. El estado y la funcionalidad de un objeto se extienden a lo largo de toda su cadena de herencia.
  • No son muy flexibles. No puedes compartir partes de funcionalidad entre diferentes ramas del árbol de herencia si esa funcionalidad no está en la base de clase. Por ejemplo, considera la posibilidad de hacer dos clases, Mammaly Bird, que derivan de Animal. La clase Bird tiene un método Fly(). A continuación, decide agregar una clase Bat que deriva de Mammal y también puede volar. Para compartir esta funcionalidad utilizando solo la herencia, tendrías que mover el método Fly() a la clase Animal, a la que no pertenece. Además, no puedes quitar métodos de las clases derivadas, por lo que si realizaste una clase Penguin que derivara de Bird, también tendría un método Fly().

Para este tutorial, vamos a favorecer la composición sobre la herencia para la implementación de los diferentes tipos de enemigos. Lo haremos creando varios comportamientos reutilizables que podamos añadir a los enemigos. Entonces podemos mezclar y combinar fácilmente comportamientos cuando creamos nuevos tipos de enemigos. Por ejemplo, si ya tuviéramos un comportamiento FollowPlayer y un comportamiento DodgeBullet, podríamos hacer un nuevo enemigo que haga ambas cosas simplemente añadiendo ambos comportamientos.


Enemigos

Los enemigos tendrán algunas propiedades adicionales sobre las entidades. Con el fin de dar al jugador un poco de tiempo para reaccionar, haremos que los enemigos se desvanezcan gradualmente antes de que se vuelvan activos y peligrosos.

Vamos a codificar la estructura básica de la clase Enemy.

1
class Enemy : Entity
2
{
3
	private int timeUntilStart = 60;
4
	public bool IsActive { get { return timeUntilStart <= 0; } }
5
6
	public Enemy(Texture2D image, Vector2 position)
7
	{
8
		this.image = image;
9
		Position = position;
10
		Radius = image.Width / 2f;
11
		color = Color.Transparent;
12
	}
13
14
	public override void Update()
15
	{
16
		if (timeUntilStart <= 0)
17
		{
18
			// enemy behaviour logic goes here.

19
		}
20
		else
21
		{
22
			timeUntilStart--;
23
			color = Color.White * (1 - timeUntilStart / 60f);
24
		}
25
26
		Position += Velocity;
27
		Position = Vector2.Clamp(Position, Size / 2, GameRoot.ScreenSize - Size / 2);
28
29
		Velocity *= 0.8f;
30
	}
31
32
	public void WasShot()
33
	{
34
		IsExpired = true;
35
	}
36
}

Este código hará que los enemigos se desvanezcan durante 60 fotogramas y permitirá que su velocidad funcione. Multiplicar la velocidad por 0,8 finge un efecto similar a la fricción. Si hacemos que los enemigos aceleren a un ritmo constante, esta fricción hará que se acerquen sin problemas a una velocidad máxima. Me gusta la simplicidad y suavidad de este tipo de fricción, pero es posible que desees utilizar una fórmula diferente dependiendo del efecto que desees.

Se llamará al método WasShot() cuando se dispare al enemigo. Le agregaremos más, luego en la serie.

Queremos que los diferentes tipos de enemigos se comporten de manera diferente. Lograremos esto mediante la asignación de comportamientos. Un comportamiento utilizará alguna función personalizada que ejecuta cada fotograma para controlar al enemigo. Implementaremos el comportamiento mediante un iterador.

Los iteradores (también denominados generadores) en C# son métodos especiales que pueden detenerse a mitad de camino y reanudarse más adelante donde lo dejaron. Puedes crear un iterador estableciendo un método con un tipo de valor devuelto de IEnumerable<> y utilizando la palabra clave yield donde deseas que se devuelva y se reanude más adelante. Los iteradores en C# requieren que devuelvas algo cuando cedes. Realmente no necesitamos devolver nada, por lo que nuestros iteradores simplemente producirán cero.

Nuestro comportamiento más simple será el comportamiento FollowPlayer() que se muestra a continuación.

1
IEnumerable<int> FollowPlayer(float acceleration = 1f)
2
{
3
	while (true)
4
	{
5
		Velocity += (PlayerShip.Instance.Position - Position).ScaleTo(acceleration);
6
		if (Velocity != Vector2.Zero)
7
			Orientation = Velocity.ToAngle();
8
9
		yield return 0;
10
	}
11
}

Esto simplemente hace que el enemigo acelere hacia el jugador a un ritmo constante. La fricción que agregamos anteriormente asegurará que eventualmente llegue a cierta velocidad máxima (5 píxeles por fotograma cuando la aceleración es 1 ya que \(0.8 \times 5 + 1 = 5\)). Cada fotograma, este método se ejecutará hasta que llegue a la instrucción de ceder y entonces se reanudará donde lo dejó en el siguiente fotograma.

Puede que te preguntes por qué nos hemos molestado en utilizar iteradores, ya que podríamos haber realizado la misma tarea más fácilmente con un simple delegado. El uso de iteradores vale la pena con métodos más complejos que, de otro modo, requerirían que almacenáramos el estado en variables miembro de la clase.

Por ejemplo, a continuación se muestra un comportamiento que hace que un enemigo se mueva en un patrón de cuadrados:

1
IEnumerable<int> MoveInASquare()
2
{
3
	const int framesPerSide = 30;
4
	while (true)
5
	{
6
		// move right for 30 frames

7
		for (int i = 0; i < framesPerSide; i++)
8
		{
9
			Velocity = Vector2.UnitX;
10
			yield return 0;
11
		}
12
13
		// move down

14
		for (int i = 0; i < framesPerSide; i++)
15
		{
16
			Velocity = Vector2.UnitY;
17
			yield return 0;
18
		}
19
20
		// move left

21
		for (int i = 0; i < framesPerSide; i++)
22
		{
23
			Velocity = -Vector2.UnitX;
24
			yield return 0;
25
		}
26
27
		// move up

28
		for (int i = 0; i < framesPerSide; i++)
29
		{
30
			Velocity = -Vector2.UnitY;
31
			yield return 0;
32
		}
33
	}
34
}

Lo bueno de esto es que no sólo nos ahorra algunas variables de instancia, sino que también estructura el código de una manera muy lógica. Puedes ver inmediatamente que el enemigo se moverá hacia la derecha, luego hacia abajo, luego hacia la izquierda, luego hacia arriba, y luego repetirá. Si implementaras este método como una máquina de estados, el flujo de control sería menos obvio.

Vamos a añadir el andamiaje necesario para que los comportamientos funcionen. Los enemigos necesitan almacenar sus comportamientos, así que añadiremos una variable a la clase Enemy.

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

Ten en cuenta que un comportamiento tiene el tipo IEnumerator<int>, no IEnumerable<int>. Puedes pensar en IEnumerable como plantilla para el comportamiento y en IEnumerator como instancia en ejecución. El IEnumerator recuerda dónde estamos en el comportamiento y retomará donde lo dejó cuando llames su método MoveNext(). En cada fotograma repasaremos todos los comportamientos que tiene el enemigo y llamaremos a MoveNext() en cada uno de ellos. Si MoveNext() regresa falso, significa que el comportamiento se ha completado, por lo que debemos eliminarlo de la lista.

Agregaremos los siguientes métodos a la clase Enemy:

1
private void AddBehaviour(IEnumerable<int> behaviour)
2
{
3
	behaviours.Add(behaviour.GetEnumerator());
4
}
5
6
private void ApplyBehaviours()
7
{
8
	for (int i = 0; i < behaviours.Count; i++)
9
	{
10
		if (!behaviours[i].MoveNext())
11
			behaviours.RemoveAt(i--);
12
	}
13
}

Y modificaremos el método Update() para llamar a ApplyBehaviours():

1
if (timeUntilStart <= 0)
2
	ApplyBehaviours();
3
// ...

Ahora podemos hacer un método estático para crear búsqueda de enemigos. Todo lo que tenemos que hacer es elegir la imagen que queremos y añadir el comportamiento FollowPlayer().

1
public static Enemy CreateSeeker(Vector2 position)
2
{
3
	var enemy = new Enemy(Art.Seeker, position);
4
	enemy.AddBehaviour(enemy.FollowPlayer());
5
6
	return enemy;
7
}

Para hacer que un enemigo que se mueve al azar, vamos a tener que elegir una dirección y luego hacer pequeños ajustes aleatorios a esa dirección. Sin embargo, si ajustamos la dirección en cada fotograma, el movimiento será inestable, así que sólo ajustaremos la dirección periódicamente. Si el enemigo se topa con el borde de la pantalla, haremos que elija una nueva dirección aleatoria que apunte lejos de la pared.

1
IEnumerable<int> MoveRandomly()
2
{
3
	float direction = rand.NextFloat(0, MathHelper.TwoPi);
4
5
	while (true)
6
	{
7
		direction += rand.NextFloat(-0.1f, 0.1f);
8
		direction = MathHelper.WrapAngle(direction);
9
10
		for (int i = 0; i < 6; i++)
11
		{
12
			Velocity += MathUtil.FromPolar(direction, 0.4f);
13
			Orientation -= 0.05f;
14
15
			var bounds = GameRoot.Viewport.Bounds;
16
			bounds.Inflate(-image.Width, -image.Height);
17
18
			// if the enemy is outside the bounds, make it move away from the edge

19
			if (!bounds.Contains(Position.ToPoint()))
20
				direction = (GameRoot.ScreenSize / 2 - Position).ToAngle() + rand.NextFloat(-MathHelper.PiOver2, MathHelper.PiOver2);
21
22
			yield return 0;
23
		}
24
	}
25
}

Ahora podemos hacer un método de fábrica para crear enemigos errantes, al igual que hicimos con el buscador:

1
public static Enemy CreateWanderer(Vector2 position)
2
{
3
	var enemy = new Enemy(Art.Wanderer, position);
4
	enemy.AddBehaviour(enemy.MoveRandomly());
5
	return enemy;
6
}

Detección de colisiones

Para la detección de colisiones, modelaremos la nave del jugador, los enemigos y las balas como círculos. La detección circular de colisiones es agradable porque es simple, es rápida y no cambia cuando los objetos giran. Si recuerdas, la clase Entity tiene un radio y una posición (la posición hace referencia al centro de la entidad). Esto es todo lo que necesitamos para la detección circular de colisiones.

Probar cada entidad con todas las demás entidades que podrían colisionar puede ser muy lento si tienes un gran número de entidades. Hay muchas técnicas que puedes utilizar para acelerar la detección de colisiones de fase amplia, como quadtrees, barrido y poda, y árboles BSP. Sin embargo, por ahora, solo tendremos unas pocas docenas de entidades en pantalla a la vez, por lo que no nos preocuparemos por estas técnicas más complejas. Sin embargo, lo podemos añadir más tarde si los necesitamos.

En Shape Blaster, no todas las entidades pueden colisionar con todos los demás tipos de entidad. Las balas y la nave del jugador solo pueden chocar con los enemigos. Los enemigos también pueden chocar con otros enemigos - esto evitará que se superpongan.

Para hacer frente a estos diferentes tipos de colisiones, añadiremos dos nuevas listas al EntityManager para realizar un seguimiento de las balas y los enemigos. Cada vez que agreguemos una entidad al EntityManager, queremos agregarla a la lista adecuada, por lo que crearemos un método AddEntity() privado para hacerlo. También nos aseguraremos de quitar las entidades caducadas de todas las listas de cada fotograma.

1
static List<Enemy> enemies = new List<Enemy>();
2
static List<Bullet> bullets = new List<Bullet>();
3
4
private static void AddEntity(Entity entity)
5
{
6
	entities.Add(entity);
7
	if (entity is Bullet)
8
		bullets.Add(entity as Bullet);
9
	else if (entity is Enemy)
10
		enemies.Add(entity as Enemy);
11
}
12
13
// ...

14
// in Update()

15
bullets = bullets.Where(x => !x.IsExpired).ToList();
16
enemies = enemies.Where(x => !x.IsExpired).ToList();

Reemplaza las llamadas a la entity. Add() en EntityManager.Add() y EntityManager.Update() con llamadas a AddEntity().

Ahora vamos a agregar un método que determinará si dos entidades están colisionando:

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

Para determinar si dos círculos se superponen, simplemente comprueba si la distancia entre ellos es menor que la suma de sus radios. Nuestro método optimiza esto ligeramente comprobando si el cuadrado de la distancia es menor que el cuadrado de la suma de los radios. Recuerda que es un poco más rápido calcular la distancia al cuadrado que la distancia real.

Sucederán cosas diferentes dependiendo de qué dos objetos entren en conflicto. Si dos enemigos chocan, queremos que se alejen el uno al otro. Si una bala golpea a un enemigo, la bala y el enemigo deben ser destruidos. Si el jugador toca a un enemigo, el jugador debe morir y el nivel debe restablecerse.

Agregaremos el método HandleCollision() a la clase Enemy para manejar colisiones entre enemigos:

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

Este método alejará al enemigo actual del otro enemigo. Cuanto más cerca estén, más difícil será empujado, porque la magnitud de (d / d.LengthSquared()) es solo uno sobre la distancia.

Reaparecer al jugador

A continuación, necesitamos un método para manejar la nave del jugador que muere. Cuando esto sucede, la nave del jugador desaparecerá por un corto tiempo antes de reaparecer.

Comenzamos agregando dos nuevos miembros a PlayerShip.

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

Al principio de PlayerShip.Update(), agrega lo siguiente:

1
if (IsDead)
2
{
3
	framesUntilRespawn--;				
4
	return;
5
}

Y anulamos Draw() como se muestra:

1
public override void Draw(SpriteBatch spriteBatch)
2
{
3
	if (!IsDead)
4
		base.Draw(spriteBatch);
5
}

Finalmente, agregamos un método Kill() a PlayerShip.

1
public void Kill()
2
{
3
	framesUntilRespawn = 60;
4
}

Ahora que todas las piezas están en su lugar, agregaremos un método al EntityManager que pasa por todas las entidades y comprueba si hay colisiones.

1
static void HandleCollisions()
2
{
3
	// handle collisions between enemies

4
	for (int i = 0; i < enemies.Count; i++)
5
		for (int j = i + 1; j < enemies.Count; j++)
6
		{
7
			if (IsColliding(enemies[i], enemies[j]))
8
			{
9
				enemies[i].HandleCollision(enemies[j]);
10
				enemies[j].HandleCollision(enemies[i]);
11
			}
12
		}
13
14
	// handle collisions between bullets and enemies

15
	for (int i = 0; i < enemies.Count; i++)
16
		for (int j = 0; j < bullets.Count; j++)
17
		{
18
			if (IsColliding(enemies[i], bullets[j]))
19
			{
20
				enemies[i].WasShot();
21
				bullets[j].IsExpired = true;
22
			}
23
		}
24
25
	// handle collisions between the player and enemies

26
	for (int i = 0; i < enemies.Count; i++)
27
	{
28
		if (enemies[i].IsActive && IsColliding(PlayerShip.Instance, enemies[i]))
29
		{
30
			PlayerShip.Instance.Kill();
31
			enemies.ForEach(x => x.WasShot());
32
			break;
33
		}
34
	}
35
}

Llama a este método desde Update() inmediatamente después de establecer isUpdating en true.


Enemigo spawner

Lo último que hay que hacer es hacer la clase EnemySpawner, que es responsable de crear enemigos. Queremos que el juego comience fácil y se vuelva más difícil, por lo que el EnemySpawner creará enemigos a un ritmo cada vez mayor a medida que avance el tiempo. Cuando el jugador muera, restableceremos el EnemySpawner a su dificultad inicial.

1
static class EnemySpawner
2
{
3
	static Random rand = new Random();
4
	static float inverseSpawnChance = 60;
5
6
	public static void Update()
7
	{
8
		if (!PlayerShip.Instance.IsDead && EntityManager.Count < 200)
9
		{
10
			if (rand.Next((int)inverseSpawnChance) == 0)
11
				EntityManager.Add(Enemy.CreateSeeker(GetSpawnPosition()));
12
13
			if (rand.Next((int)inverseSpawnChance) == 0)
14
				EntityManager.Add(Enemy.CreateWanderer(GetSpawnPosition()));
15
		}
16
			
17
		// slowly increase the spawn rate as time progresses

18
		if (inverseSpawnChance > 20)
19
			inverseSpawnChance -= 0.005f;
20
	}
21
22
	private static Vector2 GetSpawnPosition()
23
	{
24
		Vector2 pos;
25
		do
26
		{
27
			pos = new Vector2(rand.Next((int)GameRoot.ScreenSize.X), rand.Next((int)GameRoot.ScreenSize.Y));
28
		} 
29
		while (Vector2.DistanceSquared(pos, PlayerShip.Instance.Position) < 250 * 250);
30
31
		return pos;
32
	}
33
34
	public static void Reset()
35
	{
36
		inverseSpawnChance = 60;
37
	}
38
}

En cada marco, hay uno en inverseSpawnChance de generar cada tipo de enemigo. La posibilidad de engendrar un enemigo aumenta gradualmente hasta que alcanza un máximo de uno de cada veinte. Los enemigos siempre se crean al menos a 250 píxeles de distancia del jugador.

Ten cuidado con el bucle en GetSpawnPosition(). Funcionará de manera eficiente siempre y cuando el área en la que los enemigos puedan engendrar más grande que el área donde no pueden engnedrar. Sin embargo, si haces que el área prohibida sea demasiado grande, obtendrás un bucle infinito.

Llama a EnemySpawner.Update() desde GameRoot.Update() y llama a EnemySpawner.Reset() cuando el jugador sea asesinado.


Partitura y Vidas

En Shape Blaster, comenzarás con cuatro vidas, y ganarás una vida adicional cada 2000 puntos. Recibirás puntos para destruir a los enemigos, con diferentes tipos de enemigos que valen diferentes cantidades de puntos. Cada enemigo destruido también aumenta su multiplicador de puntuación por uno. Si no matas a ningún enemigo en un corto período de tiempo, tu multiplicador se restablecerá. La cantidad total de puntos recibidos de cada enemigo que destruyes es el número de puntos que vale el enemigo multiplicado por tu multiplicador actual. Si pierdes todas tus vidas, el juego ha terminado y comienzas un nuevo juego con tu puntuación restablecida a cero.

Para manejar todo esto, haremos una clase estática llamada PlayerStatus.

1
static class PlayerStatus
2
{
3
	// amount of time it takes, in seconds, for a multiplier to expire.

4
	private const float multiplierExpiryTime = 0.8f;
5
	private const int maxMultiplier = 20;
6
7
	public static int Lives { get; private set; }
8
	public static int Score { get; private set; }
9
	public static int Multiplier { get; private set; }
10
11
	private static float multiplierTimeLeft;	// time until the current multiplier expires

12
	private static int scoreForExtraLife;		// score required to gain an extra life

13
14
	// Static constructor

15
	static PlayerStatus()
16
	{
17
		Reset();
18
	}
19
20
	public static void Reset()
21
	{
22
		Score = 0;
23
		Multiplier = 1;
24
		Lives = 4;
25
		scoreForExtraLife = 2000;
26
		multiplierTimeLeft = 0;
27
	}
28
29
	public static void Update()
30
	{
31
		if (Multiplier > 1)
32
		{
33
			// update the multiplier timer

34
			if ((multiplierTimeLeft -= (float)GameRoot.GameTime.ElapsedGameTime.TotalSeconds) <= 0)
35
			{
36
				multiplierTimeLeft = multiplierExpiryTime;
37
				ResetMultiplier();
38
			}
39
		}
40
	}
41
42
	public static void AddPoints(int basePoints)
43
	{
44
		if (PlayerShip.Instance.IsDead)
45
			return;
46
47
		Score += basePoints * Multiplier;
48
		while (Score >= scoreForExtraLife)
49
		{
50
			scoreForExtraLife += 2000;
51
			Lives++;
52
		}
53
	}
54
55
	public static void IncreaseMultiplier()
56
	{
57
		if (PlayerShip.Instance.IsDead)
58
			return;
59
60
		multiplierTimeLeft = multiplierExpiryTime;
61
		if (Multiplier < maxMultiplier)
62
			Multiplier++;
63
	}
64
65
	public static void ResetMultiplier()
66
	{
67
		Multiplier = 1;
68
	}
69
70
	public static void RemoveLife()
71
	{
72
		Lives--;
73
	}
74
}

Llama a PlayerStatus.Update() desde GameRoot.Update() cuando el juego no está en pausa.

A continuación, queremos mostrar tu puntuación, vidas y multiplicador en la pantalla. Para ello necesitaremos añadir un SpriteFont en el proyecto Content y una variable correspondiente en la clase Art, a la que llamaremos Font. Cargue la fuente en Art.Load() como lo hicimos con las texturas.

Nota: Hay una fuente llamada Nova Square incluida con los archivos de origen de Shape Blaster que puede utilizar. Para usar la fuente, primero debe instalarla y, a continuación, reiniciar Visual Studio, en caso esté abierto. A continuación, puedes cambiar el nombre de la fuente en el archivo de fuente sprite a "Nova Square". El proyecto de demostración no utiliza esta fuente de forma predeterminada porque impedirá que el proyecto se compile si la fuente no está instalada.

Modifica el final de GameRoot.Draw() donde se dibuja el cursor como te muestro a continuación.

1
spriteBatch.Begin(0, BlendState.Additive);
2
3
spriteBatch.DrawString(Art.Font, "Lives: " + PlayerStatus.Lives, new Vector2(5), Color.White);
4
DrawRightAlignedString("Score: " + PlayerStatus.Score, 5);
5
DrawRightAlignedString("Multiplier: " + PlayerStatus.Multiplier, 35);
6
7
// draw the custom mouse cursor

8
spriteBatch.Draw(Art.Pointer, Input.MousePosition, Color.White);
9
spriteBatch.End();

DrawRightAlignedString() es un método auxiliar para dibujar texto alineado en el lado derecho de la pantalla. Agrégalo a GameRoot agregando el  siguiente código .

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

Ahora tus vidas, puntuación y multiplicador deben mostrarse en la pantalla. Sin embargo, todavía tenemos que modificar estos valores en respuesta a los eventos del juego. Agrega una propiedad denominada PointValue a la clase Enemy.

1
public int PointValue { get; private set; }

Establece el valor de punto para diferentes enemigos en algo que sientas que es apropiado. Hice que los enemigos errantes valgan un punto, y los enemigos que buscan valen dos puntos.

A continuación, añade las dos líneas siguientes a Enemy.WasShot() para aumentar la puntuación y el multiplicador del jugador:

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

Llame a PlayerStatus.RemoveLife() en PlayerShip.Kill(). Si el jugador pierde todas sus vidas, llame a PlayerStatus.Reset() para restablecer su puntuación y vidas al comienzo de un nuevo juego.

Puntuaciones altas

Vamos a añadir la capacidad para el juego para realizar un seguimiento de su mejor puntuación. Queremos que esta puntuación persista en todas las jugadas, por lo que la guardaremos en un archivo. Lo mantendremos realmente simple y guardaremos la puntuación más alta como un solo número de texto sin formato en un archivo en el directorio de trabajo actual (este será el mismo directorio que contiene el archivo de .exe del juego).

Agrega los siguientes métodos a PlayerStatus:

1
private const string highScoreFilename = "highscore.txt";
2
3
private static int LoadHighScore() 
4
{
5
	// return the saved high score if possible and return 0 otherwise

6
	int score;
7
	return File.Exists(highScoreFilename) && int.TryParse(File.ReadAllText(highScoreFilename), out score) ? score : 0;
8
}
9
10
private static void SaveHighScore(int score)
11
{
12
	File.WriteAllText(highScoreFilename, score.ToString());
13
}

El método LoadHighScore() comprueba primero que existe el archivo de puntuación máxima y, a continuación, comprueba que contiene un entero válido. Lo más probable es que la segunda comprobación nunca falle a menos que el usuario edite manualmente el archivo de puntuación alta a algo no válido, pero es bueno tener cuidado.

Queremos cargar la puntuación más alta cuando se inicia el juego, y guardarla cuando el jugador obtiene una nueva puntuación alta. Modificaremos el constructor estático y los métodos Reset() en PlayerStatus para hacerlo. También agregaremos una propiedad auxiliar, IsGameOver, que usaremos en un momento.

1
public static bool IsGameOver { get { return Lives == 0; } }
2
3
static PlayerStatus()
4
{
5
	HighScore = LoadHighScore();
6
	Reset();
7
}
8
9
public static void Reset()
10
{
11
	if (Score > HighScore)
12
		SaveHighScore(HighScore = Score);
13
14
	Score = 0;
15
	Multiplier = 1;
16
	Lives = 4;
17
	scoreForExtraLife = 2000;
18
	multiplierTimeLeft = 0;
19
}

Eso se encarga de hacer un seguimiento de la puntuación más alta. Ahora tenemos que mostrarlo. Agrega el código siguiente a GameRoot.Draw() en el mismo bloque SpriteBatch donde se dibuja el otro texto:

1
if (PlayerStatus.IsGameOver)
2
{
3
	string text = "Game Over\n" +
4
		"Your Score: " + PlayerStatus.Score + "\n" +
5
		"High Score: " + PlayerStatus.HighScore;
6
7
	Vector2 textSize = Art.Font.MeasureString(text);
8
	spriteBatch.DrawString(Art.Font, text, ScreenSize / 2 - textSize / 2, Color.White);
9
}

Esto hará que muestre tu puntuación y puntuación alta en el juego, centrado en la pantalla.

Como ajuste final, aumentaremos el tiempo antes de que la nave reaparexca en el juego para dar al jugador tiempo para ver su puntuación. Modifica PlayerShip.Kill() estableciendo el tiempo de reaparecen en 300 fotogramas (cinco segundos) si el jugador está sin vida.

1
// in PlayerShip.Kill()

2
PlayerStatus.RemoveLife();
3
framesUntilRespawn = PlayerStatus.IsGameOver ? 300 : 120;

El juego ya está listo para jugar. Puede que no parezca mucho, pero tiene todas las mecánicas básicas implementadas. En futuros tutoriales agregaremos un filtro de floración y efectos de partículas para condimentarlo. Pero en este momento, vamos a agregar rápidamente un poco de sonido y música para que sea más interesante.


Sonido y Música

Reproducir sonido y música es fácil en XNA. En primer lugar, agregamos nuestros efectos de sonido y música a la canalización de contenido. En el panel Propiedades, asegúrate de que el procesador de contenido esté establecido en Canción para la música y Efecto de sonido para los sonidos.

A continuación, hacemos una clase auxiliar estática para los sonidos.

1
static class Sound
2
{
3
	public static Song Music { get; private set; }
4
5
	private static readonly Random rand = new Random();
6
7
	private static SoundEffect[] explosions;
8
	// return a random explosion sound

9
	public static SoundEffect Explosion { get { return explosions[rand.Next(explosions.Length)]; } }
10
11
	private static SoundEffect[] shots;
12
	public static SoundEffect Shot { get { return shots[rand.Next(shots.Length)]; } }
13
14
	private static SoundEffect[] spawns;
15
	public static SoundEffect Spawn { get { return spawns[rand.Next(spawns.Length)]; } }
16
17
	public static void Load(ContentManager content)
18
	{
19
		Music = content.Load<Song>("Sound/Music");
20
21
		// These linq expressions are just a fancy way loading all sounds of each category into an array.

22
		explosions = Enumerable.Range(1, 8).Select(x => content.Load<SoundEffect>("Sound/explosion-0" + x)).ToArray();
23
		shots = Enumerable.Range(1, 4).Select(x => content.Load<SoundEffect>("Sound/shoot-0" + x)).ToArray();
24
		spawns = Enumerable.Range(1, 8).Select(x => content.Load<SoundEffect>("Sound/spawn-0" + x)).ToArray();
25
	}
26
}

Dado que tenemos varias variaciones de cada sonido, las propiedades Explosion, Shot y Spawn elegirán un sonido al azar entre las variantes.

Llame a Sound.Load() en GameRoot.LoadContent(). Para reproducir la música, agregue las dos líneas siguientes al final de GameRoot.Initialize().

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

Para reproducir sonidos en XNA, simplemente puedes llamar al método Play() en un SoundEffect. Este método también proporciona una sobrecarga que te permite ajustar el volumen, el tono y la panorámica del sonido. Un truco para hacer más variados nuestros sonidos es ajustar estas cantidades en cada jugada.

Para activar el efecto de sonido para disparar, agrega la siguiente línea en PlayerShip.Update(), dentro de la instrucción if donde se crean las viñetas. Tenga en cuenta que cambiamos aleatoriamente el tono hacia arriba o hacia abajo, hasta una quinta parte de una octava, para hacer que los sonidos sean menos repetitivos.

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

Del mismo modo, activa un efecto de sonido de explosión cada vez que un enemigo sea destruido agregando lo siguiente a Enemy.WasShot().

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

Ahora tienes sonido y música en tu juego. Fácil, ¿no es así?


Conclusión

Eso envuelve las mecánicas básicas de juego. En el siguiente tutorial, agregaremos un filtro de floración para que las luces de neón brillen.