Introducción
¿Alguna vez te has preguntado cómo funcionan las entrañas de un videojuego clásico? Hoy no vamos a usar librerías gráficas pesadas como Unity o Unreal. Vamos a bajar al metal, al código puro. Vamos a construir un juego funcional para Space Invaders utilizando únicamente la consola de Java.
Aprenderemos sobre Herencia, Polimorfismo, Ciclos de Juego (Game Loops) y Detección de Colisiones en una rejilla.
En este enlace puedes encontrar una presentación explicando el código, y a continuación dispones de una infografía y un vídeo explicativo.

Control de entrada con Enum
En lugar de esparcir caracteres mágicos ('a', 'd', 'f') por todo el código, vamos a profesionalizar la entrada del usuario. Usaremos un Enum.
¿Y por qué un Enum? Un enumerado nos permite definir un tipo de dato que tiene un conjunto fijo de constantes. Esto hace que el código sea seguro (no puedes pasar una tecla que no exista) y legible. Además, encapsulamos la lógica: cada tecla sabe cuál es su descripción y su carácter asociado. Fíjate por ejemplo en el método detectar. Recorre todos los valores posibles y compara. Si el usuario escribe «I» o «i», el juego entenderá Input.IZQUIERDA independientemente de lo que pase después.
import java.util.Scanner;
/**
* Enumeración que gestiona el control de entrada del usuario.
* Define las teclas válidas y asocia cada una con una acción del juego.
*/
enum Input {
IZQUIERDA("i", "Izquierda"),
DERECHA("d", "Derecha"),
FUEGO("f", "FUEGO"),
SALIR("x", "Salir"),
NADA("", "");
private String tecla;
private String descripcion;
/**
* Constructor del enum Input.
* @param tecla Carácter que activa la acción.
* @param descripcion Nombre legible de la acción.
*/
Input(String tecla, String descripcion) {
this.tecla = tecla;
this.descripcion = descripcion;
}
/**
* Imprime en la consola la lista de controles disponibles para el jugador.
*/
public static void imprimirControles() {
for (Input i : Input.values()) {
if (!i.tecla.isEmpty()) System.out.print("[" + i.tecla + "]" + i.descripcion + " ");
}
System.out.print("\nAcción > ");
}
/**
* Detecta qué comando corresponde al texto introducido por el usuario.
* @param texto Entrada del teclado.
* @return La instancia de Input correspondiente o NADA si no coincide.
*/
public static Input detectar(String texto) {
for (Input i : Input.values()) {
if (i.tecla.equalsIgnoreCase(texto)) return i;
}
return NADA;
}
}
Herencia y polimorfismo
Aplicamos el principio DRY (Don’t Repeat Yourself). Una Nave, un Alien y una Bala son cosas muy distintas, pero comparten cierta información: todos tienen una coordenada X, una coordenada Y y un icono para dibujarse.
La clase Entidad
Esta clase define lo que es un objeto en nuestro juego. Además, incluye una lógica de protección: el método mover base se asegura de que ningún objeto se salga del mapa usando Math.max y Math.min.
/**
* Clase base para todos los objetos del juego (Nave, Alien, Bala).
* Contiene la posición y el icono representativo.
*/
class Entidad {
protected int x, y;
protected char icono;
public Entidad(int x, int y, char icono) {
this.x = x;
this.y = y;
this.icono = icono;
}
/**
* Actualiza la posición de la entidad y aplica restricciones de los límites del mapa.
* @param accion La acción de entrada del usuario.
*/
public void mover(Input accion) {
// Restringir X entre 0 y ANCHO - 1
x = Math.max(0, Math.min(x, JuegoInvasores.ANCHO - 1));
// Restringir Y entre -1 y ALTO
y = Math.max(-1, Math.min(y, JuegoInvasores.ALTO));
}
}
Las especializaciones: Nave, Alien y Bala
Aquí es donde brilla la Orientación a Objetos. Cada clase sobrescribe (@Override) el método mover para comportarse de forma distinta, pero el motor del juego las tratará a todas igual (como Entidad).
- Nave: Solo reacciona si el jugador pulsa Izquierda o Derecha.
- Alien: Ignora al jugador y siempre baja (
y++). - Bala: Ignora al jugador y siempre sube (
y--).
class Nave extends Entidad {
public Nave(int x, int y) { super(x, y, 'A'); }
@Override
public void mover(Input accion) {
if (accion == Input.IZQUIERDA) x--;
if (accion == Input.DERECHA) x++;
super.mover(accion); // Importante: Llamamos al padre para que verifique los bordes
}
}
class Alien extends Entidad {
public Alien(int x, int y) { super(x, y, 'V'); }
@Override
public void mover(Input accion) {
y++; // El alien cae por gravedad
super.mover(accion);
}
}
class Bala extends Entidad {
public Bala(int x, int y) { super(x, y, '|'); }
@Override
public void mover(Input accion) {
y--; // La bala desafía la gravedad
super.mover(accion);
}
}
Gestión de memoria y bucle principal
En Java, especialmente en juegos simples, un Array es la estructura más eficiente. Definimos un array Entidad[] objetos que contendrá todo lo que existe en el universo del juego.
El Bucle de Juego (Game Loop) es infinito (while(jugando)) y sigue estos pasos:
- Render: Dibujar el mundo.
- Input: Leer al usuario.
- Update: Mover cosas y disparar.
- Collision: Verificar reglas del juego.
Inicialización y disparo
Observa cómo manejamos el disparo. No creamos listas dinámicas (ArrayList) para simplificar. Buscamos un hueco null en el array y metemos la bala ahí. Es una técnica de «Object Pooling» primitiva pero eficaz.
public class JuegoInvasores {
static final int ANCHO = 10, ALTO = 12;
static final int MAX_ALIENS = 2, MAX_ENTIDADES = 30;
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
Entidad[] objetos = new Entidad[MAX_ENTIDADES];
// Inicializamos la Nave y los Aliens
objetos[0] = new Nave(ANCHO / 2, ALTO - 1);
for (int i = 1; i <= MAX_ALIENS; i++) {
objetos[i] = new Alien((int)(Math.random() * ANCHO), 0);
}
boolean jugando = true;
while (jugando) {
dibujarJuego(objetos);
Input.imprimirControles();
Input accion = Input.detectar(scanner.nextLine());
if (accion == Input.SALIR) {
jugando = false;
} else {
// LOGICA DE DISPARO
if (accion == Input.FUEGO) {
boolean balaDisparada = false;
// Buscamos un hueco vacío en el array para crear la bala
for (int i = 0; i < objetos.length && !balaDisparada; i++) {
if (objetos[i] == null && objetos[0] != null) {
objetos[i] = new Bala(objetos[0].x, objetos[0].y);
balaDisparada = true;
}
}
}
// ACTUALIZACION DE POSICIONES (POLIMORFISMO)
for (Entidad e : objetos) {
if (e != null) e.mover(accion);
}
// ... (Ver siguiente sección)
Detección de Colisiones
Esta es la parte más compleja matemáticamente. En un juego por turnos en consola, una bala se mueve de la casilla 5 a la 4, y un alien de la 3 a la 4. ¿Pero qué pasa si se cruzan? Hemos implementado una verificación predictiva. Comprobamos si la bala está en la misma casilla que el alien (posibleBala.y == alien.y) O si acaba de cruzarlo (posibleBala.y == alien.y - 1).
// ... dentro del bucle main
int estadoJuego = procesarColisiones(objetos);
if (estadoJuego == -1) {
dibujarJuego(objetos);
System.out.println("\n¡GAME OVER!");
jugando = false;
} else if (estadoJuego == 0) {
dibujarJuego(objetos);
System.out.println("\n¡VICTORIA!");
jugando = false;
}
Funciones de lógica del juego
Aquí se decide quién vive y quién muere. Usamos instanceof para saber qué tipo de entidad estamos comprobando.
static int procesarColisiones(Entidad[] objetos) {
int aliensVivos = 0;
boolean invasion = false;
for (int i = 0; i < objetos.length; i++) {
Entidad ent = objetos[i];
if (ent != null) {
// Limpieza: Si algo se sale del mapa, lo borramos (ponemos a null)
if (ent.y < 0 || ent.y >= ALTO) {
if (!(ent instanceof Nave)) objetos[i] = null;
} else {
if (ent instanceof Alien) {
// Si un alien toca el fondo, perdemos
if (ent.y >= ALTO - 1) invasion = true;
// Verificamos si este alien ha chocado con alguna bala
boolean impactado = verificarImpacto(ent, objetos);
if (impactado) {
objetos[i] = null; // Eliminamos el Alien
System.out.println("¡ALIEN DESTRUIDO!");
} else {
aliensVivos++;
}
}
}
}
}
if (invasion) return -1;
return aliensVivos;
}
// Algoritmo de colisión cruzada
static boolean verificarImpacto(Entidad alien, Entidad[] todosLosObjetos) {
boolean impacto = false;
for (int j = 0; j < todosLosObjetos.length; j++) {
if (!impacto) {
Entidad posibleBala = todosLosObjetos[j];
if (posibleBala instanceof Bala
&& posibleBala.x == alien.x
&& (posibleBala.y == alien.y || posibleBala.y == alien.y - 1)) {
todosLosObjetos[j] = null; // Eliminamos la Bala
impacto = true;
}
}
}
return impacto;
}
Dibujando la matriz
Finalmente, necesitamos ver lo que ocurre. La técnica usada aquí es crear un «Lienzo en blanco» (matriz de puntos), pintar las entidades encima y luego imprimirlo todo de golpe. Esto evita parpadeos y asegura que si dos objetos están en la misma casilla, uno se dibuje sobre el otro (el último en procesarse gana).
static void dibujarJuego(Entidad[] lista) {
// 1. Crear lienzo vacío
char[][] matriz = new char[ALTO][ANCHO];
for(int y=0; y<ALTO; y++) {
for(int x=0; x<ANCHO; x++) {
matriz[y][x] = '.';
}
}
// 2. Pintar entidades
for(Entidad e : lista) {
if (e != null && e.y >= 0 && e.y < ALTO) {
matriz[e.y][e.x] = e.icono;
}
}
// 3. Imprimir en consola
System.out.print("\n\n");
for(int y=0; y<ALTO; y++) {
for(int x=0; x<ANCHO; x++) {
System.out.print(matriz[y][x] + " ");
}
System.out.println();
}
}
}
Todo el código
import java.util.Scanner;
/**
* Enumeración que gestiona el control de entrada del usuario.
* Define las teclas válidas y asocia cada una con una acción del juego.
*/
enum Input {
IZQUIERDA("i", "Izquierda"),
DERECHA("d", "Derecha"),
FUEGO("f", "FUEGO"),
SALIR("x", "Salir"),
NADA("", "");
private String tecla;
private String descripcion;
/**
* Constructor del enum Input.
* @param tecla Carácter que activa la acción.
* @param descripcion Nombre legible de la acción.
*/
Input(String tecla, String descripcion) {
this.tecla = tecla;
this.descripcion = descripcion;
}
/**
* Imprime en la consola la lista de controles disponibles para el jugador.
*/
public static void imprimirControles() {
for (Input i : Input.values()) {
if (!i.tecla.isEmpty()) System.out.print("[" + i.tecla + "]" + i.descripcion + " ");
}
System.out.print("\nAcción > ");
}
/**
* Detecta qué comando corresponde al texto introducido por el usuario.
* @param texto Entrada del teclado.
* @return La instancia de Input correspondiente o NADA si no coincide.
*/
public static Input detectar(String texto) {
for (Input i : Input.values()) {
if (i.tecla.equalsIgnoreCase(texto)) return i;
}
return NADA;
}
}
// ========================================================
// 2. CLASES (HERENCIA Y COMPORTAMIENTO BASE)
// ========================================================
/**
* Clase base para todos los objetos del juego (Nave, Alien, Bala).
* Contiene la posición y el icono representativo.
*/
class Entidad {
protected int x, y;
protected char icono;
/**
* Constructor de una Entidad.
* @param x Posición horizontal inicial.
* @param y Posición vertical inicial.
* @param icono Carácter que representa a la entidad en el mapa.
*/
public Entidad(int x, int y, char icono) {
this.x = x;
this.y = y;
this.icono = icono;
}
/**
* Actualiza la posición de la entidad y aplica restricciones de los límites del mapa.
* @param accion La acción de entrada del usuario (opcional según el tipo de entidad).
*/
public void mover(Input accion) {
// Restringir X entre 0 y ANCHO - 1
x = Math.max(0, Math.min(x, JuegoInvasores.ANCHO - 1));
// Restringir Y entre -1 (para que las balas salgan) y ALTO
y = Math.max(-1, Math.min(y, JuegoInvasores.ALTO));
}
}
/**
* Representa la nave controlada por el jugador.
*/
class Nave extends Entidad {
/**
* Crea una nave en una posición específica con el icono 'A'.
* @param x Posición horizontal.
* @param y Posición vertical.
*/
public Nave(int x, int y) {
super(x, y, 'A');
}
/**
* Mueve la nave horizontalmente según el input.
* @param accion Dirección del movimiento (IZQUIERDA o DERECHA).
*/
@Override
public void mover(Input accion) {
if (accion == Input.IZQUIERDA) x--;
if (accion == Input.DERECHA) x++;
super.mover(accion); // Ejecuta el control de límites de la clase Entidad
}
}
/**
* Representa a los enemigos que descienden por la pantalla.
*/
class Alien extends Entidad {
/**
* Crea un alien con el icono 'V'.
* @param x Posición horizontal.
* @param y Posición vertical.
*/
public Alien(int x, int y) {
super(x, y, 'V');
}
/**
* Mueve al alien una posición hacia abajo en cada turno.
* @param accion No se utiliza para el alien, ya que su movimiento es automático.
*/
@Override
public void mover(Input accion) {
y++; // El alien siempre baja
super.mover(accion);
}
}
/**
* Representa el proyectil disparado por la nave.
*/
class Bala extends Entidad {
/**
* Crea una bala con el icono '|'.
* @param x Posición horizontal.
* @param y Posición vertical.
*/
public Bala(int x, int y) {
super(x, y, '|');
}
/**
* Mueve la bala una posición hacia arriba en cada turno.
* @param accion No se utiliza para la bala.
*/
@Override
public void mover(Input accion) {
y--; // La bala siempre sube
super.mover(accion);
}
}
// ========================================================
// 3. MOTOR DEL JUEGO
// ========================================================
/**
* Clase principal que contiene el bucle del juego y la lógica de renderizado.
*/
public class JuegoInvasores {
static final int ANCHO = 12, ALTO = 12;
static final int MAX_ALIENS = 3, MAX_ENTIDADES = 30; // Limite total de entidades
/**
* Punto de entrada principal del programa.
* @param args Argumentos de línea de comandos.
*/
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
Entidad[] objetos = new Entidad[MAX_ENTIDADES];
// --- INICIO ---
objetos[0] = new Nave(ANCHO / 2, ALTO - 1);
for (int i = 1; i <= MAX_ALIENS; i++) {
objetos[i] = new Alien((int)(Math.random() * ANCHO), 0);
}
boolean jugando = true;
while (jugando) {
dibujarJuego(objetos);
Input.imprimirControles();
Input accion = Input.detectar(scanner.nextLine());
if (accion == Input.SALIR) {
jugando = false;
} else {
// --- DISPARAR (Crea una bala en la posición de la nave) ---
if (accion == Input.FUEGO) {
boolean balaDisparada = false;
for (int i = 0; i < objetos.length && !balaDisparada; i++) {
if (objetos[i] == null) {
if (objetos[0] != null) {
objetos[i] = new Bala(objetos[0].x, objetos[0].y);
balaDisparada = true; // Evita seguir buscando huecos
}
}
}
}
// --- ACTUALIZAR POSICIONES ---
for (Entidad e : objetos) {
if (e != null) e.mover(accion);
}
// --- GESTIÓN DE COLISIONES Y ESTADO ---
int estadoJuego = procesarColisiones(objetos);
if (estadoJuego == -1) {
dibujarJuego(objetos);
System.out.println("\n¡GAME OVER!");
jugando = false;
} else if (estadoJuego == 0) {
dibujarJuego(objetos);
System.out.println("\n¡VICTORIA!");
jugando = false;
}
}
}
scanner.close();
}
/**
* Procesa la limpieza de proyectiles fuera del mapa, detecta invasiones y gestiona colisiones.
* @param objetos Array que contiene todas las entidades activas del juego.
* @return El número de aliens vivos, o -1 si el jugador ha perdido (invasión).
*/
static int procesarColisiones(Entidad[] objetos) {
int aliensVivos = 0;
boolean invasion = false;
for (int i = 0; i < objetos.length; i++) {
Entidad ent = objetos[i];
if (ent != null) {
// 1. Limpieza de proyectiles o aliens fuera de rango
if (ent.y < 0 || ent.y >= ALTO) {
if (!(ent instanceof Nave)) objetos[i] = null;
} else {
// 2. Lógica específica para Aliens
if (ent instanceof Alien) {
// Comprobar si ha llegado al final (invasión)
if (ent.y >= ALTO - 1) {
invasion = true;
}
// 3. Colisión Bala-Alien
// Pasamos el alien actual (ent) y la lista completa para buscar balas
boolean impactado = verificarImpacto(ent, objetos);
if (impactado) {
objetos[i] = null; // Borra Alien
System.out.println("¡ALIEN DESTRUIDO!");
} else {
// Solo contamos el alien si NO ha sido destruido
aliensVivos++;
}
}
}
}
}
if (invasion) return -1; // Código de derrota
return aliensVivos; // Retorna cuántos quedan
}
/**
* Verifica si una bala ha impactado en un alien específico.
* @param alien El alien que se va a comprobar.
* @param todosLosObjetos Array completo para buscar balas activas.
* @return true si el alien fue impactado por una bala, false en caso contrario.
*/
static boolean verificarImpacto(Entidad alien, Entidad[] todosLosObjetos) {
boolean impacto = false;
for (int j = 0; j < todosLosObjetos.length; j++) {
// Comprobamos solo si no hemos impactado ya en este ciclo (sin break)
if (!impacto) {
Entidad posibleBala = todosLosObjetos[j];
// Verificamos si es Bala y si las coordenadas coinciden
// Nota: (alien.y - 1) arregla el problema de colisiones cruzadas
if (posibleBala instanceof Bala
&& posibleBala.x == alien.x
&& (posibleBala.y == alien.y || posibleBala.y == alien.y - 1)) {
todosLosObjetos[j] = null; // Borra Bala
impacto = true; // Marca que hubo impacto
}
}
}
return impacto;
}
/**
* Dibuja el estado actual del juego en la consola mediante una matriz de caracteres.
* @param lista Array de entidades a dibujar sobre el mapa.
*/
static void dibujarJuego(Entidad[] lista) {
char[][] matriz = new char[ALTO][ANCHO];
for(int y=0; y<ALTO; y++) {
for(int x=0; x<ANCHO; x++) {
matriz[y][x] = '.';
}
}
for(Entidad e : lista) {
if (e != null && e.y >= 0 && e.y < ALTO) {
matriz[e.y][e.x] = e.icono;
}
}
System.out.print("\n\n");
for(int y=0; y<ALTO; y++) {
for(int x=0; x<ANCHO; x++) {
System.out.print(matriz[y][x] + " ");
}
System.out.println();
}
}
}
Conclusión
Hemos construido un juego completo en unas pocas clases. Lo importante de este ejercicio no es el juego en sí, sino cómo hemos estructurado los datos:
- Entidades autónomas: La clase
Naveno necesita saber cómo funciona elAlien. - Control centralizado: El
Mainorquesta todo, pero delega el movimiento a cada objeto. - Tipado fuerte: El uso de
Enumpreviene errores tontos de teclado.