1. Code
  2. Game Development

Crear un Neon Vector Shooter en XNA: Juego básico

Scroll to top
This post is part of a series called Cross-Platform Vector Shooter: XNA.
Make a Neon Vector Shooter in XNA: More Gameplay

Spanish (Español) translation by Elías Nicolás (you can also view the original English article)

En esta serie de tutoriales, te mostraré cómo hacer un tirador de gemelos neón como Geometry Wars, que llamaremos Shape Blaster, en XNA. El objetivo de estos tutoriales no es dejarle una réplica exacta de Geometry Wars, sino repasar los elementos necesarios que le permitirán crear su propia variante de alta calidad.

Los animo a ampliar y experimentar con el código que se proporciona en estos tutoriales. Cubriremos estos temas en toda la serie:

  1. Configura el juego básico, crea la nave del jugador y maneja la entrada, el sonido y la música.
  2. Finaliza la implementación de la mecánica del juego agregando enemigos, manejando la detección de colisiones y rastreando el puntaje y la vida del jugador.
  3. Agregue un filtro bloom, que es el efecto que dará a los gráficos un brillo de neón.
  4. Agregue efectos de partículas extravagantes.
  5. Agregue al fondo la deformada cuadrícula.

Esto es lo que tendremos al final de la serie:

Please accept marketing cookies to load this content.

Y esto es lo que tendremos al final de esta primera parte:

Please accept marketing cookies to load this content.

La música y los efectos de sonido que puedes escuchar en estos videos fueron creados por RetroModular, y puedes leer sobre cómo lo hizo en Audiotuts+.

Los sprites son de Jacob Zinman-Jeanes, nuestro diseñador residente de Tuts+. Todas las ilustraciones se pueden encontrar en el archivo zip para la descarga del codigo fuente.

Shape_Blaster_SpritesShape_Blaster_SpritesShape_Blaster_Sprites
La fuente es Nova Square, de Wojciech Kalinowski.

Empecemos.


Visión de conjunto

En este tutorial crearemos un tirador de doble palo; el jugador controlará la nave con el teclado, el teclado y el mouse, o las dos barras de un gamepad.

Usamos varias clases para lograr esto:

  • Entity: la clase base para enemigos, balas y la nave del jugador.
  • Bullet y PlayerShip.
  • EntityManager: realiza un seguimiento de todas las entidades en el juego y realiza la detección de colisión.
  • Input: ayuda a administrar la entrada desde el teclado, el mouse y el gamepad.
  • Art: Carga y guarda referencias a las texturas necesarias para el juego.
  • Sound: carga y mantiene referencias a los sonidos y la música.
  • MathUtil y Extensions: contiene algunos métodos estáticos útiles y métodos de extensión.
  • GameRoot: controla el ciclo principal del juego. Esta es la clase Game1 que XNA genera automáticamente, renombrado.

El código en este tutorial pretende ser simple y fácil de entender. No tendrá todas las características o una arquitectura complicada diseñada para satisfacer todas las necesidades posibles. Por el contrario, solo hará lo que necesita hacer. Manteniéndolo simple hará que sea más fácil para usted comprender los conceptos, y luego modificarlos y expandirlos en su propio juego único.


Las entidades y el barco del jugador

Crea un nuevo proyecto XNA. Cambia el nombre de la clase Game1 a algo más adecuado. Lo llamé GameRoot.

Ahora comencemos creando una clase base para nuestras entidades de juego.

1
2
abstract class Entity
3
{
4
  protected Texture2D image;
5
	// The tint of the image. This will also allow us to change the transparency.

6
	protected Color color = Color.White;
7
8
	public Vector2 Position, Velocity;
9
	public float Orientation;
10
	public float Radius = 20;	// used for circular collision detection

11
	public bool IsExpired;		// true if the entity was destroyed and should be deleted.

12
13
	public Vector2 Size
14
	{
15
		get
16
		{
17
			return image == null ? Vector2.Zero : new Vector2(image.Width, image.Height);
18
		}
19
	}
20
21
	public abstract void Update();
22
23
	public virtual void Draw(SpriteBatch spriteBatch)
24
	{
25
		spriteBatch.Draw(image, Position, null, color, Orientation, Size / 2f, 1f, 0, 0);
26
	}
27
}

Todas nuestras entidades (enemigos, balas y la nave del jugador) tienen algunas propiedades básicas, como una imagen y una posición. IsExpired se usará para indicar que la entidad ha sido destruida y debe eliminarse de cualquier lista que contenga una referencia a ella.

A continuación, creamos un EntityManager para rastrear nuestras entidades y actualizarlas y dibujarlas.

1
2
static class EntityManager
3
{
4
	static List<Entity> entities = new List<Entity>();
5
6
	static bool isUpdating;
7
	static List<Entity> addedEntities = new List<Entity>();
8
9
	public static int Count { get { return entities.Count; } }
10
11
	public static void Add(Entity entity)
12
	{
13
		if (!isUpdating)
14
			entities.Add(entity);
15
		else
16
			addedEntities.Add(entity);
17
	}
18
19
	public static void Update()
20
	{
21
		isUpdating = true;
22
23
		foreach (var entity in entities)
24
			entity.Update();
25
26
		isUpdating = false;
27
28
		foreach (var entity in addedEntities)
29
			entities.Add(entity);
30
31
		addedEntities.Clear();
32
33
		// remove any expired entities.

34
		entities = entities.Where(x => !x.IsExpired).ToList();
35
	}
36
37
	public static void Draw(SpriteBatch spriteBatch)
38
	{
39
		foreach (var entity in entities)
40
			entity.Draw(spriteBatch);
41
	}
42
}

Recuerde, si modifica una lista mientras la itera, obtendrá una excepción. El código anterior se encarga de esto al poner en cola las entidades agregadas durante la actualización en una lista separada, y agregarlas después de que termine de actualizar las entidades existentes.

Haciendolas visibles

Tendremos que cargar algunas texturas si queremos dibujar algo. Crearemos una clase estática para mantener referencias a todas nuestras texturas.

1
2
static class Art
3
{
4
	public static Texture2D Player { get; private set; }
5
	public static Texture2D Seeker { get; private set; }
6
	public static Texture2D Wanderer { get; private set; }
7
	public static Texture2D Bullet { get; private set; }
8
	public static Texture2D Pointer { get; private set; }
9
10
11
	public static void Load(ContentManager content)
12
	{
13
		Player = content.Load<Texture2D>("Player");
14
		Seeker = content.Load<Texture2D>("Seeker");
15
		Wanderer = content.Load<Texture2D>("Wanderer");
16
		Bullet = content.Load<Texture2D>("Bullet");
17
		Pointer = content.Load<Texture2D>("Pointer");
18
	}
19
}

Cargue el arte llamando a Art.Load(Content) en GameRoot.LoadContent(). Además, varias clases necesitarán conocer las dimensiones de la pantalla, así que agrega las siguientes propiedades a GameRoot:

1
2
public static GameRoot Instance { get; private set; }
3
public static Viewport Viewport { get { return Instance.GraphicsDevice.Viewport; } }
4
public static Vector2 ScreenSize { get { return new Vector2(Viewport.Width, Viewport.Height); } }

Y en el constructor GameRoot, agregue:

1
2
Instance = this;

Ahora comenzaremos a escribir la clase PlayerShip.

1
2
class PlayerShip : Entity
3
{
4
	private static PlayerShip instance;
5
	public static PlayerShip Instance 
6
	{
7
		get
8
		{
9
			if (instance == null)
10
				instance = new PlayerShip();
11
12
			return instance;
13
		}
14
	}
15
16
	private PlayerShip()
17
	{
18
		image = Art.Player;
19
		Position = GameRoot.ScreenSize / 2;
20
		Radius = 10;
21
	}
22
23
	public override void Update()
24
	{
25
		// ship logic goes here

26
	}
27
}

Creamos PlayerShip como singleton, configuramos su imagen y lo colocamos en el centro de la pantalla.

Finalmente, agreguemos la nave del jugador al EntityManager y la actualizaremos y dibujaremos. Agregue el siguiente código en GameRoot:

1
2
// in Initialize(), after the call to base.Initialize()

3
EntityManager.Add(PlayerShip.Instance);
4
5
// in Update()

6
EntityManager.Update();
7
8
// in Draw()

9
GraphicsDevice.Clear(Color.Black);
10
11
spriteBatch.Begin(SpriteSortMode.Texture, BlendState.Additive);
12
EntityManager.Draw(spriteBatch);
13
spriteBatch.End();

Dibujamos los sprites con mezcla de aditivos, que es parte de lo que les dará su aspecto de neón. Si ejecuta el juego en este punto, debería ver su nave en el centro de la pantalla. Sin embargo, aún no responde a la entrada. Arreglemos eso.


Entrada

Para el movimiento, el jugador puede usar WASD en el teclado, o el pulgar izquierdo en un gamepad. Para apuntar, pueden usar las teclas de flecha, el pulgar derecho o el mouse. No le pediremos al jugador que mantenga presionado el botón del mouse para disparar porque es incómodo mantener el botón continuamente. Esto nos deja un pequeño problema: ¿cómo sabemos si el jugador apunta con el mouse, el teclado o el gamepad?

Usaremos el siguiente sistema: agregaremos la entrada de teclado y gamepad juntos. Si el jugador mueve el mouse, cambiamos al objetivo del mouse. Si el jugador presiona las teclas de flecha o usa el thumbstick derecho, desactivamos apuntar con el mouse.

Una cosa a tener en cuenta: al empujar un joystick hacia adelante, se devolverá un valor y positivo. En coordenadas de pantalla, los valores y aumentan hacia abajo. Queremos invertir el eje y en el controlador para que empujando la palanca de control hacia arriba apunte o nos mueva hacia la parte superior de la pantalla.

Haremos una clase estática para realizar un seguimiento de los diversos dispositivos de entrada y nos ocuparemos de cambiar entre los diferentes tipos de puntería.

1
2
static class Input
3
{
4
	private static KeyboardState keyboardState, lastKeyboardState;
5
	private static MouseState mouseState, lastMouseState;
6
	private static GamePadState gamepadState, lastGamepadState;
7
8
	private static bool isAimingWithMouse = false;
9
10
	public static Vector2 MousePosition { get { return new Vector2(mouseState.X, mouseState.Y); } }
11
12
	public static void Update()
13
	{
14
		lastKeyboardState = keyboardState;
15
		lastMouseState = mouseState;
16
		lastGamepadState = gamepadState;
17
18
		keyboardState = Keyboard.GetState();
19
		mouseState = Mouse.GetState();
20
		gamepadState = GamePad.GetState(PlayerIndex.One);
21
22
		// If the player pressed one of the arrow keys or is using a gamepad to aim, we want to disable mouse aiming. Otherwise,

23
		// if the player moves the mouse, enable mouse aiming.

24
		if (new[] { Keys.Left, Keys.Right, Keys.Up, Keys.Down }.Any(x => keyboardState.IsKeyDown(x)) || gamepadState.ThumbSticks.Right != Vector2.Zero)
25
			isAimingWithMouse = false;
26
		else if (MousePosition != new Vector2(lastMouseState.X, lastMouseState.Y))
27
			isAimingWithMouse = true;
28
	}
29
30
	// Checks if a key was just pressed down

31
	public static bool WasKeyPressed(Keys key)
32
	{
33
		return lastKeyboardState.IsKeyUp(key) && keyboardState.IsKeyDown(key);
34
	}
35
36
	public static bool WasButtonPressed(Buttons button)
37
	{
38
		return lastGamepadState.IsButtonUp(button) && gamepadState.IsButtonDown(button);
39
	}
40
41
	public static Vector2 GetMovementDirection()
42
	{
43
			
44
		Vector2 direction = gamepadState.ThumbSticks.Left;
45
		direction.Y *= -1;	// invert the y-axis

46
47
		if (keyboardState.IsKeyDown(Keys.A))
48
			direction.X -= 1;
49
		if (keyboardState.IsKeyDown(Keys.D))
50
			direction.X += 1;
51
		if (keyboardState.IsKeyDown(Keys.W))
52
			direction.Y -= 1;
53
		if (keyboardState.IsKeyDown(Keys.S))
54
			direction.Y += 1;
55
56
		// Clamp the length of the vector to a maximum of 1.

57
		if (direction.LengthSquared() > 1)
58
			direction.Normalize();
59
60
		return direction;
61
	}
62
63
	public static Vector2 GetAimDirection()
64
	{
65
		if (isAimingWithMouse)
66
			return GetMouseAimDirection();
67
68
		Vector2 direction = gamepadState.ThumbSticks.Right;
69
		direction.Y *= -1;
70
71
		if (keyboardState.IsKeyDown(Keys.Left))
72
			direction.X -= 1;
73
		if (keyboardState.IsKeyDown(Keys.Right))
74
			direction.X += 1;
75
		if (keyboardState.IsKeyDown(Keys.Up))
76
			direction.Y -= 1;
77
		if (keyboardState.IsKeyDown(Keys.Down))
78
			direction.Y += 1;
79
80
		// If there's no aim input, return zero. Otherwise normalize the direction to have a length of 1.

81
		if (direction == Vector2.Zero)
82
			return Vector2.Zero;
83
		else
84
			return Vector2.Normalize(direction);
85
	}
86
87
	private static Vector2 GetMouseAimDirection()
88
	{
89
		Vector2 direction = MousePosition - PlayerShip.Instance.Position;
90
91
		if (direction == Vector2.Zero)
92
			return Vector2.Zero;
93
		else
94
			return Vector2.Normalize(direction);
95
	}
96
97
	public static bool WasBombButtonPressed()
98
	{
99
		return WasButtonPressed(Buttons.LeftTrigger) || WasButtonPressed(Buttons.RightTrigger) || WasKeyPressed(Keys.Space);
100
	}
101
}

Llame Input.Update() al comienzo de GameRoot.Update() para que la clase de entrada funcione.

Consejo: Es posible que notes que incluí un método para bombas. No implementaremos bombas ahora, pero ese método está ahí para su uso futuro.

También puede observar en GetMovementDirection() escribí direction.LengthSquared() > 1. Using LengthSquared() es una pequeña optimización del rendimiento; calcular el cuadrado de la longitud es un poco más rápido que calcular la longitud en sí porque evita la operación de raíz cuadrada relativamente lenta. Verás el código usando los cuadrados de longitudes o distancias a lo largo del programa. En este caso particular, la diferencia de rendimiento es insignificante, pero esta optimización puede marcar la diferencia cuando se usa en ciclos ajustados.

Movimiento

Ahora estamos listos para hacer que la nave se mueva. Agregue este código al método PlayerShip.Update():

1
2
const float speed = 8;
3
Velocity = speed * Input.GetMovementDirection();
4
Position += Velocity;
5
Position = Vector2.Clamp(Position, Size / 2, GameRoot.ScreenSize - Size / 2);
6
			
7
if (Velocity.LengthSquared() > 0)
8
	Orientation = Velocity.ToAngle();

Esto hará que la nave se mueva a una velocidad de hasta ocho píxeles por cuadro, sujete su posición para que no pueda salir de la pantalla y gire la nave para mirar hacia la dirección en que se mueve.

ToAngle() es un método de extensión simple definido en nuestra clase de Extensions así:

1
2
public static float ToAngle(this Vector2 vector)
3
{
4
	return (float)Math.Atan2(vector.Y, vector.X);
5
}

Disparos

Si ejecuta el juego ahora, debería poder volar el barco. Ahora hagámoslo disparar.

Primero, necesitamos una clase para balas.

1
2
class Bullet : Entity
3
{
4
	public Bullet(Vector2 position, Vector2 velocity)
5
	{
6
		image = Art.Bullet;
7
		Position = position;
8
		Velocity = velocity;
9
		Orientation = Velocity.ToAngle();
10
		Radius = 8;
11
	}
12
13
	public override void Update()
14
	{
15
		if (Velocity.LengthSquared() > 0)
16
			Orientation = Velocity.ToAngle();
17
18
		Position += Velocity;
19
20
		// delete bullets that go off-screen

21
		if (!GameRoot.Viewport.Bounds.Contains(Position.ToPoint()))
22
			IsExpired = true;
23
	}
24
}

Queremos un breve período de recuperación entre viñetas, así que agregue los siguientes campos a la clase PlayerShip.

1
2
const int cooldownFrames = 6;
3
int cooldownRemaining = 0;
4
static Random rand = new Random();

Además, agregue el siguiente código a PlayerShip.Update().

1
2
var aim = Input.GetAimDirection();
3
if (aim.LengthSquared() > 0 && cooldownRemaining <= 0)
4
{
5
	cooldownRemaining = cooldownFrames;
6
	float aimAngle = aim.ToAngle();
7
	Quaternion aimQuat = Quaternion.CreateFromYawPitchRoll(0, 0, aimAngle);
8
9
	float randomSpread = rand.NextFloat(-0.04f, 0.04f) + rand.NextFloat(-0.04f, 0.04f);
10
	Vector2 vel = MathUtil.FromPolar(aimAngle + randomSpread, 11f);
11
12
	Vector2 offset = Vector2.Transform(new Vector2(25, -8), aimQuat);
13
	EntityManager.Add(new Bullet(Position + offset, vel));
14
15
	offset = Vector2.Transform(new Vector2(25, 8), aimQuat);
16
	EntityManager.Add(new Bullet(Position + offset, vel));
17
}
18
19
if (cooldownRemaining > 0)
20
	cooldownRemaining--;

Este código crea dos viñetas que viajan paralelas entre sí. Agrega una pequeña cantidad de aleatoriedad a la dirección. Esto hace que los disparos se extiendan un poco como una ametralladora. Agregamos dos números aleatorios juntos porque esto hace que su suma sea más probable que esté centrada (alrededor de cero) y es menos probable que envíe balas muy lejos. Usamos un cuaternión para rotar la posición inicial de las balas en la dirección en que viajan.

También utilizamos dos nuevos métodos de ayuda:    

  • Random.NextFloat() devuelve un flotante entre un valor mínimo y máximo.
  • MathUtil.FromPolar() crea un Vector2 desde un ángulo y magnitud.
1
2
// in Extensions

3
public static float NextFloat(this Random rand, float minValue, float maxValue)
4
{
5
	return (float)rand.NextDouble() * (maxValue - minValue) + minValue;
6
}
7
8
// in MathUtil

9
public static Vector2 FromPolar(float angle, float magnitude)
10
{
11
	return magnitude * new Vector2((float)Math.Cos(angle), (float)Math.Sin(angle));
12
}

Cursor personalizado

Hay una cosa más que deberíamos hacer ahora que tenemos la clase Input. Dibujemos un cursor de mouse personalizado para que sea más fácil ver hacia dónde apunta el barco. En GameRoot.Draw, simplemente dibuja Art.Pointer en la posición del mouse.

1
2
spriteBatch.Begin(SpriteSortMode.Texture, BlendState.Additive);
3
EntityManager.Draw(spriteBatch);
4
5
// draw the custom mouse cursor

6
spriteBatch.Draw(Art.Pointer, Input.MousePosition, Color.White);
7
spriteBatch.End();

Conclusión

Si pruebas el juego ahora, podrás mover el barco con las teclas WASD o el joystick izquierdo, y apuntar el flujo continuo de balas con las teclas de flecha, el mouse o el thumbstick derecho.

En la siguiente parte, completaremos la jugabilidad agregando enemigos y un puntaje.