Blog
Programando el juego Space Invaders en Java paso a paso
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.
Programando el juego Hundir la flota en Java paso a paso
Introducción
En esta unidad, vamos a desglosar una implementación avanzada del juego clásico de estrategia naval. A diferencia de otros ejemplos que pueden ser más simples, este código introduce los siguientes conceptos a nivel más avanzado:
- Motor de renderizado ANSI: Manipulación de la consola del sistema operativo para mostrar colores.
- Modelo de datos robusto: Uso de enumerados (
enum) para encapsular comportamiento y configuración. - Algoritmos de colocación espacial: Lógica matemática para gestionar colisiones y perímetros de seguridad en una matriz.
- Niebla de guerra: Separación entre el estado interno del programa y lo que se muestra al usuario.
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.

Estructuras de datos: El uso de enum frente a enteros
Implementación en el enum TipoBarco
El objetivo principal de este enum es actuar como bloque de definición de datos para la configuración de la flota, persiguiendo los siguientes objetivos:
- Centralización de reglas: Imagina que quieres cambiar las reglas del juego para que el submarino sea más grande (4 casillas en vez de 3). Si usaras variables sueltas dispersas por el código, tendrías que buscar y cambiar ese número en varios sitios. Con este
enum, cambias el número en una sola línea (SUBMARINO(4)) y todo el juego (condiciones de victoria, lógica de colocación, etc.) se actualiza automáticamente. - Eliminación de «números mágicos»: En programación, encontrar un
5suelto en el código es confuso (¿es la munición? ¿es el tamaño del tablero?). Al usarTipoBarco.PORTAAVIONES.getLongitud(), el código se explica por sí mismo. - Agrupación lógica: Vincula inseparablemente el nombre de la entidad (portaaviones) con su propiedad (tamaño 5). No puedes tener un portaaviones sin tamaño, ni un tamaño sin barco.
Cuando el programa arranca, Java crea automáticamente una única instancia para cada uno de estos nombres. Por ejemplo, al escribir PORTAAVIONES(5), Java está haciendo internamente algo similar a: new TipoBarco(5). Le está pasando el valor 5 al constructor. De esta forma, cada barco tiene su propia variable interna llamada longitud, con las siguientes características:
- Es
private: Nadie desde fuera puede modificarla directamente. Para acceder a su valor, utilizaremos un método público para que el resto del programa pueda preguntar: «¿Cuánto mide este barco?» - Es
final: Una vez asignada, no puede cambiar. Un destructor siempre medirá 2 durante toda la ejecución del programa. Esto garantiza la integridad de las reglas.
/**
* Define los tipos de barcos y sus longitudes.
* Centraliza la configuración: si cambiamos un número aquí, el juego se adapta.
*/
enum TipoBarco {
PORTAAVIONES(5),
ACORAZADO(4),
CRUCERO(3),
SUBMARINO(3),
DESTRUCTOR(2);
private final int longitud;
TipoBarco(int longitud) {
this.longitud = longitud;
}
public int getLongitud() {
return longitud;
}
}
Estructura de colores RGB en consola
Esta es una de las partes más interesantes del código. Java, por defecto, imprime texto plano en la consola. Sin embargo, los terminales modernos (CMD, PowerShell, Bash) soportan Secuencias de Escape ANSI.
Cuando imprimimos un carácter especial llamado ESCAPE (código ASCII 27, representado en Java como \u001B), le estamos diciendo a la consola: «Atención, lo que viene a continuación no es texto para leer, es una orden de configuración».
El código implementa el estándar RGB TrueColor con la estructura «\u001B[38;2;R;G;Bm«:
\u001B[: Inicio de la secuencia de control (CSI).38: Indica que vamos a cambiar el color del texto (foreground).2: Indica que usaremos el modo RGB (Red, Green, Blue).R;G;B: Son tres números del 0 al 255 que definen la mezcla de color.m: Indica el fin de la instrucción.
Implementación en el enum EstadoCasilla
En lugar de escribir estos códigos crípticos cada vez que queremos imprimir algo, los encapsulamos en el constructor del enum:
- Constructor: Recibe los valores enteros R, G y B.
String.format: Construye la secuencia ANSI dinámica.toString: Sobrescribe el método estándar para devolver:COLOR + SÍMBOLO + RESET. El código deRESET(\u001B[0m) es vital; si no lo pusiéramos, toda la consola se quedaría pintada de ese color indefinidamente.
/**
* Motor de colores RGB.
* Transforma códigos numéricos en secuencias de escape ANSI.
*/
enum EstadoCasilla {
// Definición semántica con sus valores visuales (Símbolo, R, G, B)
AGUA ( "~", 0, 180, 255), // Agua (azul claro)
BARCO ( "B", 160, 32, 240), // Púrpura (visible al acabar)
TOCADO ( "X", 255, 0, 0), // Rojo puro (impacto)
FALLO ( "o", 180, 180, 180); // Gris (agua con impacto)
private final String simbolo;
private final String codigoColor;
// Secuencia para restablecer el color por defecto de la terminal
private static final String RESET = "\u001B[0m";
/**
* Constructor que inyecta la lógica de color.
* La secuencia mágica es: \u001B[38;2;R;G;Bm
*/
EstadoCasilla(String simbolo, int r, int g, int b) {
this.simbolo = simbolo;
this.codigoColor = String.format("\u001B[38;2;%d;%d;%dm", r, g, b);
}
@Override
public String toString() {
// Devuelve: [Instrucción Color] + [Carácter] + [Instrucción Reset]
return codigoColor + simbolo + RESET;
}
}
Gestión de memoria y estado global
Para gestionar el tablero, utilizamos una matriz bidimensional de objetos EstadoCasilla.
static EstadoCasilla[][] oceano: Es la representación en memoria del mar. Cada celda[fila][columna]contiene una referencia a una de las constantes del Enum (AGUA,BARCO, etc.).- Cálculo dinámico de victoria: La constante
IMPACTOS_NECESARIOSno se fija a mano (ej: 17). Se calcula llamando a una función que suma las longitudes de todos los barcos definidos. Esto hace el código extremadamente robusto: si mañana añadimos un nuevo barco alenum, la condición de victoria se recalcula sola sin tocar el resto del código.
// CONSTANTES Y ESTADO GLOBAL
static final int DIMENSION = 10; // Tamaño del tablero 10x10
static final int NO_ENCONTRADO = -1; // Centinela para búsquedas fallidas
// Configuración de dificultad
static final int MUNICION_MAXIMA = 50;
// Cálculo dinámico de la condición de victoria
static final int IMPACTOS_NECESARIOS = calcularPuntosTotales();
// La Matriz Principal (El "Tablero")
static EstadoCasilla[][] oceano = new EstadoCasilla[DIMENSION][DIMENSION];
// Herramientas de entrada y aleatoriedad
static Scanner teclado = new Scanner(System.in);
static Random radar = new Random();
El ciclo de vida del juego
El método main se encarga de la ejecución temporal del programa. Podemos distinguir tres fases claras:
- Fase de inicialización:
- Limpia el tablero (
inicializarOceano). - Coloca los barcos sin que el jugador sepa dónde están (
colocarFlotaCompleta).
- Limpia el tablero (
- Fase de ejecución (bucle
while):- Renderizado: Llama a
imprimirOceano(false). El parámetrofalseactiva la «niebla de guerra», ocultando los barcos. - Input: Solicita coordenadas y valida que no se repitan.
- Lógica: Actualiza el estado de la matriz (de
AGUA/BARCOaFALLO/TOCADO).
- Renderizado: Llama a
- Fase de clausura:
- Al terminar (victoria o derrota), llama a
imprimirOceano(true). El parámetrotruedesactiva la niebla y revela la ubicación de todos los barcos.
- Al terminar (victoria o derrota), llama a
public static void main(String[] args) {
System.out.println("--- HUNDIR LA FLOTA ---");
System.out.println("Tablero: " + DIMENSION + "x" + DIMENSION);
System.out.println("Objetivo: " + IMPACTOS_NECESARIOS + " impactos.");
System.out.println("Munición: " + MUNICION_MAXIMA + " misiles.");
// 1. Fase de Preparación
inicializarOceano();
colocarFlotaCompleta();
// Variables de control de flujo
int aciertos = 0;
int misilesRestantes = MUNICION_MAXIMA;
boolean juegoTerminado = false;
// 2. Bucle Principal (Game Loop)
while (!juegoTerminado) {
// Renderizamos con Niebla de Guerra (false)
imprimirOceano(false);
System.out.println("------------------------------------------------");
System.out.println("Misiles: " + misilesRestantes + " | Aciertos: " + aciertos + "/" + IMPACTOS_NECESARIOS);
// Turno del jugador
boolean impacto = realizarDisparo();
misilesRestantes--;
// Feedback visual inmediato
if (impacto) {
aciertos++;
System.out.println(">>> ¡IMPACTO CONFIRMADO! <<<");
} else {
System.out.println(">>> Agua. Sin rastro del enemigo. <<<");
}
// 3. Comprobación de condiciones de fin
if (aciertos == IMPACTOS_NECESARIOS) {
juegoTerminado = true;
imprimirOceano(true); // Revelamos el mapa (Cheat mode activado legalmente)
System.out.println("\n¡VICTORIA! Has desmantelado la flota enemiga.");
} else if (misilesRestantes == 0) {
juegoTerminado = true;
imprimirOceano(true);
System.out.println("\n¡MUNICIÓN AGOTADA! Retirada táctica.");
}
}
}
La meta del juego: calcularPuntosTotales()
Esta función es muy inteligente porque no usa un número «fijo» (como decir que se gana con 10 puntos). En su lugar, pregunta a los barcos cuánto miden.
TipoBarco.values(): Esto obtiene una lista de todos los tipos de barcos que has definido (Portaaviones, Submarino, etc.).- El bucle
for: Recorre cada barco, mira su longitud (getLongitud()) y la suma a la variablesuma. - ¿Por qué es útil?: Si mañana decides añadir un barco más o cambiar el tamaño de uno, no tienes que tocar el código de victoria; el programa calculará automáticamente que ahora hacen falta, por ejemplo, 17 puntos en lugar de 15.
/**
* Calcula dinámicamente cuántos puntos hacen falta para ganar.
*/
static int calcularPuntosTotales() {
int suma = 0;
for (TipoBarco barco : TipoBarco.values()) {
suma += barco.getLongitud();
}
return suma;
}
El turno del jugador: realizarDisparo()
Esta función debe validar los datos introducidos por el usuario y actualizar el mapa. Se divide en dos bloques:
El bucle de validación (while)
El objetivo de este bloque es que el jugador no pierda el turno por un error en la introducción de datos o en la elección de la casilla donde quiere efectuar el disparo.
- Entrada de datos: Pide fila y columna.
- Comprobación de repetición: Mira en la matriz
oceano[fila][col].- Si el valor es
TOCADOoFALLO, significa que ya hubo un proyectil ahí. - Importante: Mientras el disparo no sea «nuevo»,
disparoValidosigue siendofalsey el bucle se repite indefinidamente. El jugador no saldrá de aquí hasta que dé una coordenada válida donde no se haya disparado todavía.
- Si el valor es
Resolución del impacto (if-else)
Una vez que tenemos una coordenada válida, el programa decide qué hay debajo:
- Si hay un
BARCO:- Cambia el estado a
TOCADO(para que el dibujo del mapa cambie). return true: Esto le dice al resto del programa: «¡Oye, suma un punto al marcador!».
- Cambia el estado a
- Si no hay barco (es decir, hay agua):
- Cambia el estado a
FALLO. return false: El contador de impactos no cambia.
- Cambia el estado a
Conceptos clave de Java en este código
- Enums (
EstadoCasillayTipoBarco): El código no usa números sueltos (como 0 o 1), sino nombres claros. Es mucho más fácil leerEstadoCasilla.TOCADOque leer un2y tener que recordar qué significaba. - Booleans como control: El uso de
disparoValidoactúa como un «cerrojo» para asegurar que la gestión del impacto se realice con datos correctos.
/**
* Procesa el turno de disparo.
* Devuelve true si acertamos a un barco, false si damos en agua.
*/
static boolean realizarDisparo() {
boolean disparoValido = false;
int fila = NO_ENCONTRADO;
int col = NO_ENCONTRADO;
// Bucle de validación de entrada lógica
while (!disparoValido) {
fila = pedirCoordenada("Fila (0-" + (DIMENSION - 1) + "): ");
col = pedirCoordenada("Columna (0-" + (DIMENSION - 1) + "): ");
// Evitar disparar dos veces al mismo sitio
if (oceano[fila][col] == EstadoCasilla.TOCADO || oceano[fila][col] == EstadoCasilla.FALLO) {
System.out.println("Ya has disparado en esa zona. Elige otra.");
} else {
disparoValido = true;
}
}
// Resolución del impacto en la matriz
if (oceano[fila][col] == EstadoCasilla.BARCO) {
oceano[fila][col] = EstadoCasilla.TOCADO;
return true;
} else {
oceano[fila][col] = EstadoCasilla.FALLO;
return false;
}
}
Distribución de barcos: geometría y «regla del aire»
Esta es la sección más compleja algorítmicamente. El objetivo es colocar barcos aleatoriamente pero cumpliendo dos reglas estrictas:
- El barco no debe salirse del tablero.
- Regla del aire: El barco no puede tocar a otro, ni siquiera en diagonal. Debe haber al menos una casilla de agua de separación.
El algoritmo de «bounding box» (caja delimitadora)
En la función esPosicionValida, calculamos un rectángulo de seguridad. Por ejemplo, si el barco va de la fila F a la fila F+3 (tamaño 4), revisamos desde F-1 hasta F+4.
Para evitar errores de «índice fuera de rango» (por ejemplo, si intentáramos revisar la fila -1), utilizamos las funciones matemáticas Math.max(0, ...) y Math.min(DIMENSION-1, ...). Esto «recorta» el área de búsqueda a los límites reales del tablero.
// ALGORITMOS DE COLOCACIÓN
static void colocarFlotaCompleta() {
for (TipoBarco barco : TipoBarco.values()) {
colocarBarcoAleatorio(barco);
}
}
/**
* Algoritmo de Fuerza Bruta (Trial & Error).
* Intenta coordenadas al azar hasta encontrar una válida.
*/
static void colocarBarcoAleatorio(TipoBarco barco) {
boolean colocado = false;
while (!colocado) {
int fila = radar.nextInt(DIMENSION);
int col = radar.nextInt(DIMENSION);
boolean horizontal = radar.nextBoolean();
if (esPosicionValida(fila, col, barco.getLongitud(), horizontal)) {
pintarBarcoEnMatriz(fila, col, barco.getLongitud(), horizontal);
colocado = true;
}
}
}
/**
* Valida si un barco cabe y respeta el perímetro de seguridad.
*/
static boolean esPosicionValida(int f, int c, int longitud, boolean horizontal) {
// 1. Determinar dimensiones del barco
int anchoBarco = horizontal ? longitud : 1;
int altoBarco = horizontal ? 1 : longitud;
// 2. Verificar límites del tablero
if (f + altoBarco > DIMENSION || c + anchoBarco > DIMENSION) {
return false;
}
// 3. Definir el "Marco de Seguridad" (Bounding Box)
// Usamos Math.max y min para no salirnos de los índices 0-9
int filaInicio = Math.max(0, f - 1);
int colInicio = Math.max(0, c - 1);
int filaFin = Math.min(DIMENSION - 1, f + altoBarco);
int colFin = Math.min(DIMENSION - 1, c + anchoBarco);
// 4. Escaneo de Área
for (int i = filaInicio; i <= filaFin; i++) {
for (int j = colInicio; j <= colFin; j++) {
// Si encontramos CUALQUIER COSA que no sea agua pura, la posición es inválida
if (oceano[i][j] != EstadoCasilla.AGUA) {
return false;
}
}
}
return true; // Zona despejada
}
static void pintarBarcoEnMatriz(int f, int c, int longitud, boolean horizontal) {
for (int i = 0; i < longitud; i++) {
if (horizontal) {
oceano[f][c + i] = EstadoCasilla.BARCO;
} else {
oceano[f + i][c] = EstadoCasilla.BARCO;
}
}
}
Renderizado y utilidades
Finalmente, necesitamos dibujar el tablero. La función imprimirOceano aplica el concepto de niebla de guerra.
- Recorre la matriz celda por celda.
- Si la celda contiene un
BARCOy estamos en modo juego (revelarTodo == false), el programa miente al usuario e imprime el símbolo deAGUA. - Esto demuestra cómo separar los datos (lo que hay en memoria) de la vista (lo que ve el usuario).
También incluimos pedirCoordenada con hasNextInt() para evitar que el programa falle si el usuario introduce letras.
// UTILIDADES Y VISTA
static void inicializarOceano() {
for (int f = 0; f < DIMENSION; f++) {
for (int c = 0; c < DIMENSION; c++) {
oceano[f][c] = EstadoCasilla.AGUA;
}
}
}
/**
* Dibuja el tablero aplicando la lógica de ocultación.
* @param revelarTodo Si es true, muestra los barcos (Game Over).
*/
static void imprimirOceano(boolean revelarTodo) {
System.out.println();
// Eje de coordenadas X (Columnas)
System.out.print(" ");
for (int c = 0; c < DIMENSION; c++) {
System.out.print(c + " ");
}
System.out.println();
for (int f = 0; f < DIMENSION; f++) {
System.out.print(f + "| "); // Eje de coordenadas Y (Filas)
for (int c = 0; c < DIMENSION; c++) {
EstadoCasilla actual = oceano[f][c];
// LÓGICA DE NIEBLA DE GUERRA
if (actual == EstadoCasilla.BARCO && !revelarTodo) {
// Si hay barco pero el juego sigue, ocultamos con agua
System.out.print(EstadoCasilla.AGUA + " ");
} else {
// En cualquier otro caso, mostramos la realidad
System.out.print(actual + " ");
}
}
System.out.println("|");
}
}
/**
* Lectura segura de enteros desde teclado.
*/
static int pedirCoordenada(String mensaje) {
int valor = NO_ENCONTRADO;
boolean valido = false;
while (!valido) {
System.out.print(mensaje);
if (teclado.hasNextInt()) {
valor = teclado.nextInt();
if (valor >= 0 && valor < DIMENSION) {
valido = true;
} else {
System.out.println("Error: El número debe estar entre 0 y " + (DIMENSION - 1));
}
} else {
teclado.next(); // Limpiar buffer de entrada errónea
System.out.println("Error: Debes introducir un número entero.");
}
}
return valor;
}
}
Todo el código
import java.util.Random;
import java.util.Scanner;
/**
* Juego de "Hundir la Flota" (Versión un jugador).
* El objetivo es encontrar todos los barcos enemigos ocultos en el tablero
* antes de que se agoten los intentos (misiles) disponibles.
*
* Este ejemplo refuerza el uso de matrices para ocultar información (niebla de guerra)
* y la gestión de estados mediante Enums.
*/
public class Main {
// --- ENUMS: CONFIGURACIÓN Y ESTÉTICA ---
/**
* Define los tipos de barcos y sus longitudes.
*/
enum TipoBarco {
PORTAAVIONES(5),
ACORAZADO(4),
CRUCERO(3),
SUBMARINO(3),
DESTRUCTOR(2);
private final int longitud;
TipoBarco(int longitud) {
this.longitud = longitud;
}
public int getLongitud() {
return longitud;
}
}
/**
* Enum que define los colores usando formato RGB (Red, Green, Blue).
* Esto permite usar cualquier color de los 16 millones disponibles.
*/
enum EstadoCasilla {
// Símbolo y color en formato RGB
AGUA ( "~", 0, 180, 255), // Azul cielo (agua)
BARCO ( "B", 160, 32, 240), // Púrpura (barco sin impacto)
TOCADO ( "X", 255, 0, 0), // Rojo puro (impacto)
FALLO ( "o", 180, 180, 180); // Gris claro (fallo)
private final String simbolo;
private final String codigoColor;
private static final String RESET = "\u001B[0m";
/**
* Constructor que acepta valores RGB (0-255).
* Convierte los números a la secuencia ANSI TrueColor automáticamente.
*/
EstadoCasilla(String simbolo, int r, int g, int b) {
this.simbolo = simbolo;
// La secuencia mágica para RGB es: \u001B[38;2;R;G;Bm
this.codigoColor = String.format("\u001B[38;2;%d;%d;%dm", r, g, b);
}
@Override
public String toString() {
return codigoColor + simbolo + RESET;
}
}
// --- CONSTANTES Y ESTADO GLOBAL ---
static final int DIMENSION = 10;
static final int NO_ENCONTRADO = -1;
// Configuración de dificultad
static final int MUNICION_MAXIMA = 50;
static final int IMPACTOS_NECESARIOS = calcularPuntosTotales();
// Matriz del tablero y herramientas
static EstadoCasilla[][] oceano = new EstadoCasilla[DIMENSION][DIMENSION];
static Scanner teclado = new Scanner(System.in);
static Random radar = new Random();
/**
* Hilo principal de ejecución.
* Gestiona el bucle de juego, el control de turnos y las condiciones de victoria/derrota.
* @param args Argumentos de consola (no utilizados).
*/
public static void main(String[] args) {
System.out.println("--- HUNDIR LA FLOTA ---");
System.out.println("Tablero: " + DIMENSION + "x" + DIMENSION);
System.out.println("Regla especial: Los barcos no pueden tocarse entre sí.");
System.out.println("Objetivo: " + IMPACTOS_NECESARIOS + " impactos.");
System.out.println("Munición: " + MUNICION_MAXIMA + " misiles.");
// 1. Preparación
inicializarOceano();
colocarFlotaCompleta();
// 2. Variables de estado
int aciertos = 0;
int misilesRestantes = MUNICION_MAXIMA;
boolean juegoTerminado = false;
// 3. Bucle Principal
while (!juegoTerminado) {
imprimirOceano(false); // false = Modo Niebla de Guerra
System.out.println("------------------------------------------------");
System.out.println("Misiles: " + misilesRestantes + " | Aciertos: " + aciertos + "/" + IMPACTOS_NECESARIOS);
// Turno de juego
boolean impacto = realizarDisparo();
misilesRestantes--;
// Feedback inmediato
if (impacto) {
aciertos++;
System.out.println(">>> ¡IMPACTO CONFIRMADO! <<<");
} else {
System.out.println(">>> Agua. Sin rastro del enemigo. <<<");
}
// Comprobación de fin de partida
if (aciertos == IMPACTOS_NECESARIOS) {
juegoTerminado = true;
imprimirOceano(true); // Revelamos el mapa
System.out.println("\n¡VICTORIA! Has desmantelado la flota enemiga.");
} else if (misilesRestantes == 0) {
juegoTerminado = true;
imprimirOceano(true);
System.out.println("\n¡MUNICIÓN AGOTADA! Retirada táctica.");
}
}
}
// --- LÓGICA DE JUEGO ---
/**
* Calcula dinámicamente cuántos aciertos hacen falta para ganar.
* @return Total de casillas ocupadas por barcos.
*/
static int calcularPuntosTotales() {
int suma = 0;
for (TipoBarco barco : TipoBarco.values()) {
suma += barco.getLongitud();
}
return suma;
}
/**
* Gestiona la interacción con el usuario para realizar un disparo.
* Verifica que la coordenada sea válida y no se haya disparado antes allí.
* @return true si el disparo acierta en un barco, false si falla.
*/
static boolean realizarDisparo() {
boolean disparoValido = false;
int fila = NO_ENCONTRADO;
int col = NO_ENCONTRADO;
while (!disparoValido) {
fila = pedirCoordenada("Fila (0-" + (DIMENSION - 1) + "): ");
col = pedirCoordenada("Columna (0-" + (DIMENSION - 1) + "): ");
if (oceano[fila][col] == EstadoCasilla.TOCADO || oceano[fila][col] == EstadoCasilla.FALLO) {
System.out.println("Ya has disparado en esa zona. Elige otra.");
} else {
disparoValido = true;
}
}
if (oceano[fila][col] == EstadoCasilla.BARCO) {
oceano[fila][col] = EstadoCasilla.TOCADO;
return true;
} else {
oceano[fila][col] = EstadoCasilla.FALLO;
return false;
}
}
// --- ALGORITMOS DE COLOCACIÓN (IA) ---
/**
* Recorre el catálogo de barcos y delega la colocación de cada uno.
*/
static void colocarFlotaCompleta() {
for (TipoBarco barco : TipoBarco.values()) {
colocarBarcoAleatorio(barco);
}
}
/**
* Intenta colocar un barco en una posición aleatoria.
* Si la posición elegida no es válida (choca, se toca con otro o se sale), repite el intento.
* @param barco El tipo de barco a colocar.
*/
static void colocarBarcoAleatorio(TipoBarco barco) {
boolean colocado = false;
while (!colocado) {
int fila = radar.nextInt(DIMENSION);
int col = radar.nextInt(DIMENSION);
boolean horizontal = radar.nextBoolean();
if (esPosicionValida(fila, col, barco.getLongitud(), horizontal)) {
pintarBarcoEnMatriz(fila, col, barco.getLongitud(), horizontal);
colocado = true;
}
}
}
/**
* Verifica si el barco cabe y cumple la "Regla del Aire".
* La regla del aire implica que no solo las casillas del barco deben estar libres,
* sino también todas las casillas adyacentes (incluyendo diagonales).
* @param f Fila inicial.
* @param c Columna inicial.
* @param longitud Tamaño del barco.
* @param horizontal Orientación (true = horizontal, false = vertical).
* @return true si el barco y su perímetro están libres.
*/
static boolean esPosicionValida(int f, int c, int longitud, boolean horizontal) {
// 1. Calculamos las dimensiones que ocupará el barco
int anchoBarco = horizontal ? longitud : 1;
int altoBarco = horizontal ? 1 : longitud;
// 2. Validar límites del tablero (Si se sale, devolvemos false)
if (f + altoBarco > DIMENSION || c + anchoBarco > DIMENSION) {
return false;
}
// 3. Definir el "Marco de Seguridad" (Barco + 1 casilla alrededor)
// Usamos Math.max/min para no salirnos de los bordes (0 y 9)
int filaInicio = Math.max(0, f - 1);
int colInicio = Math.max(0, c - 1);
int filaFin = Math.min(DIMENSION - 1, f + altoBarco);
int colFin = Math.min(DIMENSION - 1, c + anchoBarco);
// 4. Escanear esa área buscando obstáculos
for (int i = filaInicio; i <= filaFin; i++) {
for (int j = colInicio; j <= colFin; j++) {
if (oceano[i][j] != EstadoCasilla.AGUA) {
return false; // Colisión detectada (barco o vecino)
}
}
}
return true; // Todo limpio
}
/**
* Escribe el barco en la matriz una vez validada la posición.
*/
static void pintarBarcoEnMatriz(int f, int c, int longitud, boolean horizontal) {
for (int i = 0; i < longitud; i++) {
if (horizontal) {
oceano[f][c + i] = EstadoCasilla.BARCO;
} else {
oceano[f + i][c] = EstadoCasilla.BARCO;
}
}
}
// --- UTILIDADES Y VISTA ---
/**
* Limpia el tablero llenándolo de agua.
*/
static void inicializarOceano() {
for (int f = 0; f < DIMENSION; f++) {
for (int c = 0; c < DIMENSION; c++) {
oceano[f][c] = EstadoCasilla.AGUA;
}
}
}
/**
* Dibuja el tablero en consola.
* Utiliza la lógica de "Niebla de Guerra".
* @param revelarTodo true para mostrar la ubicación de los barcos (Game Over).
*/
static void imprimirOceano(boolean revelarTodo) {
System.out.println();
// Cabecera de columnas
System.out.print(" ");
for (int c = 0; c < DIMENSION; c++) {
System.out.print(c + " ");
}
System.out.println();
for (int f = 0; f < DIMENSION; f++) {
System.out.print(f + "| "); // Índice de fila
for (int c = 0; c < DIMENSION; c++) {
EstadoCasilla actual = oceano[f][c];
// Si es un barco y estamos jugando, lo mostramos como agua
if (actual == EstadoCasilla.BARCO && !revelarTodo) {
System.out.print(EstadoCasilla.AGUA + " ");
} else {
System.out.print(actual + " ");
}
}
System.out.println("|");
}
}
/**
* Pide un número entero al usuario de forma segura.
* @param mensaje Texto a mostrar.
* @return Un entero validado dentro del rango del tablero.
*/
static int pedirCoordenada(String mensaje) {
int valor = NO_ENCONTRADO;
boolean valido = false;
while (!valido) {
System.out.print(mensaje);
if (teclado.hasNextInt()) {
valor = teclado.nextInt();
if (valor >= 0 && valor < DIMENSION) {
valido = true;
} else {
System.out.println("Error: El número debe estar entre 0 y " + (DIMENSION - 1));
}
} else {
teclado.next(); // Limpiar el buffer
System.out.println("Error: Debes introducir un número entero.");
}
}
return valor;
}
}
Programando el juego Conecta 4 en Java paso a paso
Introducción
El desarrollo de videojuegos de consola es una de las herramientas pedagógicas más recomendadas para comprender la programación estructurada. En esta unidad, no nos limitaremos a «copiar y pegar» código; realizaremos una disección técnica completa de una implementación del clásico Conecta 4.
Analizaremos el uso de enumerados (enums) para la seguridad de tipos, la gestión de la memoria en arrays bidimensionales, la validación robusta de entradas de usuario y, finalmente, cómo implementar una Inteligencia Artificial basada en algoritmos de simulación y backtracking.
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.

Estructuras de Datos: El uso de Enum frente a enteros
En programas más antiguos o muy simples, es común ver el uso de números enteros para representar estados (ej: 0 para vacío, 1 para jugador). Sin embargo, esto es una mala práctica conocida como «números mágicos», ya que carecen de significado semántico y pueden llevar a errores si asignamos un número no válido (ej: tablero[0][0] = 99).
Para solucionar esto, utilizamos tipos enumerados (enum).
Enumerado Dificultad
Este enumerado define los niveles de inteligencia de la máquina. Observad que no es una lista simple; cada elemento (FACIL, MEDIO, DIFICIL) invoca a un constructor que almacena una descripción de texto.
Al sobrescribir el método toString(), permitimos que, al imprimir la dificultad por pantalla, se muestre automáticamente el texto amigable («Fácil (Aleatorio)») en lugar del nombre técnico de la constante.
enum Dificultad {
FACIL("Fácil (Aleatorio)"),
MEDIO("Medio (Defensivo)"),
DIFICIL("Difícil (Inteligente)");
private final String descripcion;
Dificultad(String descripcion) {
this.descripcion = descripcion;
}
@Override
public String toString() {
return descripcion;
}
}
Enumerado Ficha
Define los tres estados posibles de una celda en el tablero.
Debemos destacar que con este código llevamos a cabo separación de responsabilidades. Este enumerado vincula la lógica del juego (JUGADOR, MAQUINA) con su representación visual (X, O). Si mañana quisiéramos cambiar la X por un color o un emoji, solo tendríamos que modificar la cadena de texto en este enumerado, sin tocar el resto del código.
enum Ficha {
VACIO("-"),
JUGADOR("X"),
MAQUINA("O");
private final String simbolo;
Ficha(String simbolo) {
this.simbolo = simbolo;
}
@Override
public String toString() {
return simbolo;
}
}
Configuración del entorno y constantes
Antes de iniciar la lógica, debemos preparar el «terreno de juego». En Java, dentro del paradigma estructurado en una clase principal, utilizamos variables y constantes static.
- Constantes (
final): DefinimosFILASyCOLUMNASpara evitar escribir6y7repetidamente. Esto facilita la escalabilidad: si queremos cambiar el tamaño del tablero a 10×10, solo modificamos estas dos líneas. - La constante
NO_ENCONTRADO: Asignamos el valor-1a una constante con nombre descriptivo. En programación, esto mejora la legibilidad. Es mucho más claro leerif (resultado == NO_ENCONTRADO)queif (resultado == -1). - El tablero: Declaramos una matriz de tipo
Ficha. Inicialmente, Java llenará esto connull, por lo que deberemos inicializarlo más adelante.
// Dimensiones del tablero
static final int FILAS = 6;
static final int COLUMNAS = 7;
// Constante para indicar que una búsqueda no ha tenido éxito.
static final int NO_ENCONTRADO = -1;
// Estado global del juego
static Ficha[][] tablero = new Ficha[FILAS][COLUMNAS];
static Scanner teclado = new Scanner(System.in);
static Random azar = new Random();
El flujo principal (main) y validación de entrada
El método main actúa como director de orquesta. Su función es inicializar el tablero, gestionar la selección de dificultad y ejecutar el bucle principal del juego (Game Loop).
Generación dinámica de menús
Fijaos en el bucle for que imprime el menú. No escribimos las opciones a mano. Usamos Dificultad.values() para iterar sobre los elementos del enumerado. Esto significa que el menú se autogenera basándose en la definición del enum:
// Generamos el menú dinámicamente
for (Dificultad d : Dificultad.values()) {
System.out.println((d.ordinal() + 1) + ". " + d);
}
Validación de entrada de datos (Scanner)
Uno de los puntos críticos es leer un número del teclado. Si el usuario introduce una letra, el método nextInt() lanzaría una excepción InputMismatchException y el programa terminaría abruptamente.
Para evitarlo, implementamos un patrón de comprobación:
teclado.hasNextInt(): Preguntamos al buffer de entrada si lo siguiente es un entero.- Si es Sí: Leemos el dato y comprobamos que esté en el rango correcto (1-3).
- Si es No: Es vital ejecutar
teclado.next(). Esta instrucción «consume» y elimina la entrada errónea (la letra) del buffer. Si no hiciéramos esto, el buclewhileleería la misma letra infinitamente, bloqueando el programa en el bucle.
Código del método principal
public static void main(String[] args) {
inicializarTablero();
System.out.println("--- CUATRO EN RAYA ---");
System.out.println("Elige dificultad:");
// Generamos el menú dinámicamente
for (Dificultad d : Dificultad.values()) {
System.out.println((d.ordinal() + 1) + ". " + d);
}
// Selección y validación de la dificultad
Dificultad dificultadSeleccionada = null;
while (dificultadSeleccionada == null) {
System.out.print("Opción: ");
if (teclado.hasNextInt()) {
int opcion = teclado.nextInt();
// Verificamos rango válido (1 a N)
if (opcion > 0 && opcion <= Dificultad.values().length) {
dificultadSeleccionada = Dificultad.values()[opcion - 1];
} else {
System.out.println("Número no válido. Elige entre 1 y " + Dificultad.values().length);
}
} else {
teclado.next(); // IMPORTANTE: Consumir entrada incorrecta
System.out.println("Por favor, introduce un número entero.");
}
}
System.out.println("Has elegido: " + dificultadSeleccionada);
// Bucle principal del juego
boolean turnoJugador = true;
boolean juegoTerminado = false;
while (!juegoTerminado) {
imprimirTablero();
if (turnoJugador) {
hacerMovimientoJugador();
} else {
hacerMovimientoMaquina(dificultadSeleccionada);
}
// Verificación de estado tras el movimiento
Ficha fichaActual = turnoJugador ? Ficha.JUGADOR : Ficha.MAQUINA;
if (comprobarVictoria(fichaActual)) {
imprimirTablero();
System.out.println(turnoJugador ? "¡ENHORABUENA! Has ganado." : "FIN. Ha ganado el ordenador.");
juegoTerminado = true;
} else if (tableroLleno()) {
imprimirTablero();
System.out.println("¡EMPATE! No quedan casillas libres.");
juegoTerminado = true;
}
// Alternar turno
turnoJugador = !turnoJugador;
}
}
Lógica de interacción del jugador
Esta función encapsula la interacción humana. Se asegura de no permitir avanzar al juego hasta que el jugador haya introducido una columna que cumpla dos condiciones:
- Ser un número entero dentro de los límites (0-6).
- Ser una columna con espacio disponible (no llena hasta el tope).
Nuevamente, utilizamos el patrón hasNextInt() para prevenir errores de tipo de dato.
static void hacerMovimientoJugador() {
boolean movimientoValido = false;
int c;
while (!movimientoValido) {
System.out.print("Tu turno (Columna 0-" + (COLUMNAS - 1) + "): ");
if (teclado.hasNextInt()) {
c = teclado.nextInt();
if (columnaValida(c)) {
colocarFicha(c, Ficha.JUGADOR);
movimientoValido = true;
} else {
System.out.println("Movimiento no válido (columna llena o fuera de rango).");
}
} else {
teclado.next();
System.out.println("Por favor, introduce un número entero.");
}
}
}
Algoritmos de Decisión
Aquí reside la lógica más avanzada del programa. No pretendemos jugar simplemente al azar (a menos que esté en modo fácil), si no que debemos intentar tomar decisiones basadas en prioridades.
Sistema de prioridades en cascada
La función hacerMovimientoMaquina evalúa la situación en orden de importancia:
- Ataque (modo difícil): ¿Puedo ganar en este turno? Si la respuesta es sí, lo hace inmediatamente.
- Defensa (modo medio/difícil): ¿Puede ganar el rival en su siguiente turno? Si la respuesta es sí, la máquina debe bloquear esa columna obligatoriamente.
- Movimiento aleatorio: Si no hay victoria inminente ni amenaza de derrota, juega en cualquier columna válida al azar.
Algoritmo de simulación (backtracking simplificado)
La función buscarMovimientoGanador implementa una técnica muy habitual en estos casos: la simulación. El ordenador «imagina» qué pasaría si colocara una ficha en cada columna:
- Hacer: Coloca una ficha temporalmente (
tablero[f][c] = ficha). - Verificar: Llama a
comprobarVictoriapara ver si esa jugada resulta en éxito. - Deshacer (backtracking): Es fundamental retirar la ficha (
tablero[f][c] = Ficha.VACIO) antes de continuar el bucle. Si no hiciéramos esto, el tablero se llenaría de fichas de prueba y la partida se corrompería.
Código de movimientos
static void hacerMovimientoMaquina(Dificultad dificultad) {
System.out.println("Turno del ordenador...");
int columnaElegida = NO_ENCONTRADO;
// 1. PRIORIDAD DE ATAQUE
if (dificultad == Dificultad.DIFICIL) {
columnaElegida = buscarMovimientoGanador(Ficha.MAQUINA);
}
// 2. PRIORIDAD DE DEFENSA
if (columnaElegida == NO_ENCONTRADO && (dificultad == Dificultad.MEDIO || dificultad == Dificultad.DIFICIL)) {
columnaElegida = buscarMovimientoGanador(Ficha.JUGADOR);
}
// 3. MOVIMIENTO POR DEFECTO (ALEATORIO)
if (columnaElegida == NO_ENCONTRADO) {
do {
columnaElegida = azar.nextInt(COLUMNAS);
} while (!columnaValida(columnaElegida));
}
colocarFicha(columnaElegida, Ficha.MAQUINA);
}
static int buscarMovimientoGanador(Ficha ficha) {
int columnaGanadora = NO_ENCONTRADO;
for (int c = 0; c < COLUMNAS && columnaGanadora == NO_ENCONTRADO; c++) {
if (columnaValida(c)) {
int f = obtenerFilaLibre(c);
// Simular jugada
tablero[f][c] = ficha;
// Comprobar resultado
if (comprobarVictoria(ficha)) {
columnaGanadora = c;
}
// Deshacer jugada (Backtracking)
tablero[f][c] = Ficha.VACIO;
}
}
return columnaGanadora;
}
Física del tablero: gravedad y renderizado
Estas funciones auxiliares manejan la lógica interna de la matriz.
inicializarTablero: Recorre la matriz estableciendoFicha.VACIO. Es necesario porque al crear el array, Java lo llena connull.imprimirTablero: Recorre la matriz e imprime el método.toString()de cada ficha (definido en elenum).columnaValida: Verifica si la columna está dentro de los límites y si la fila superior (0) está vacía.obtenerFilaLibre: Simulación de gravedad. A diferencia de una lectura normal, recorremos la columna de abajo hacia arriba (desdeFILAS - 1hasta0). La primera casilla vacía encontrada es donde la ficha se «posará».
static void inicializarTablero() {
for (int f = 0; f < FILAS; f++) {
for (int c = 0; c < COLUMNAS; c++) {
tablero[f][c] = Ficha.VACIO;
}
}
}
static void imprimirTablero() {
System.out.println();
System.out.print(" ");
for(int c = 0; c < COLUMNAS; c++) System.out.print(c + " ");
System.out.println();
System.out.println("---------------");
for (int f = 0; f < FILAS; f++) {
System.out.print("|");
for (int c = 0; c < COLUMNAS; c++) {
System.out.print(tablero[f][c] + "|");
}
System.out.println();
}
System.out.println("---------------");
}
static boolean columnaValida(int c) {
return c >= 0 && c < COLUMNAS && tablero[0][c] == Ficha.VACIO;
}
static int obtenerFilaLibre(int c) {
int filaEncontrada = NO_ENCONTRADO;
// Bucle inverso para simular gravedad
for (int f = FILAS - 1; f >= 0 && filaEncontrada == NO_ENCONTRADO; f--) {
if (tablero[f][c] == Ficha.VACIO) {
filaEncontrada = f;
}
}
return filaEncontrada;
}
static void colocarFicha(int c, Ficha ficha) {
int f = obtenerFilaLibre(c);
if (f != NO_ENCONTRADO) {
tablero[f][c] = ficha;
}
}
static boolean tableroLleno() {
for (int c = 0; c < COLUMNAS; c++) {
if (tablero[0][c] == Ficha.VACIO) {
return false;
}
}
return true;
}
Verificación de victoria
Esta es la función algorítmicamente más densa. Para detectar una victoria, debemos buscar 4 fichas consecutivas idénticas en cuatro direcciones: Horizontal, Vertical, Diagonal ascendente y Diagonal descendente.
En este paso es habitual cometer el error de no comprobar bien los límites del Array. Si intentamos buscar 4 fichas a la derecha estando en la última columna, obtendremos un error ArrayIndexOutOfBoundsException. Por ello, ajustamos los límites de los bucles for:
- Horizontal: El bucle de columnas solo llega hasta
COLUMNAS - 3. - Vertical: El bucle de filas solo llega hasta
FILAS - 3. - Diagonales: Combinan ambas restricciones.
static boolean comprobarVictoria(Ficha ficha) {
// Horizontal (-)
for (int f = 0; f < FILAS; f++) {
for (int c = 0; c < COLUMNAS - 3; c++) {
if (tablero[f][c] == ficha &&
tablero[f][c+1] == ficha &&
tablero[f][c+2] == ficha &&
tablero[f][c+3] == ficha) return true;
}
}
// Vertical (|)
for (int f = 0; f < FILAS - 3; f++) {
for (int c = 0; c < COLUMNAS; c++) {
if (tablero[f][c] == ficha &&
tablero[f+1][c] == ficha &&
tablero[f+2][c] == ficha &&
tablero[f+3][c] == ficha) return true;
}
}
// Diagonal Ascendente (/)
// Empieza en fila 3 porque necesita espacio hacia arriba
for (int f = 3; f < FILAS; f++) {
for (int c = 0; c < COLUMNAS - 3; c++) {
if (tablero[f][c] == ficha &&
tablero[f-1][c+1] == ficha &&
tablero[f-2][c+2] == ficha &&
tablero[f-3][c+3] == ficha) return true;
}
}
// Diagonal Descendente (\)
for (int f = 0; f < FILAS - 3; f++) {
for (int c = 0; c < COLUMNAS - 3; c++) {
if (tablero[f][c] == ficha &&
tablero[f+1][c+1] == ficha &&
tablero[f+2][c+2] == ficha &&
tablero[f+3][c+3] == ficha) return true;
}
}
return false;
}
Todo el código
import java.util.Random;
import java.util.Scanner;
/**
* Juego de Cuatro en Raya para consola.
* Este programa implementa una versión del clásico juego utilizando programación estructurada.
* Se hace uso de Enums para gestionar estados y dificultades.
* Se evita el uso de "break" para mantener un flujo de control limpio y predecible.
*/
public class Main {
/**
* Define los niveles de dificultad del juego.
* Cada nivel contiene su propia descripción textual para facilitar la generación de menús.
*/
enum Dificultad {
FACIL("Fácil (Aleatorio)"),
MEDIO("Medio (Defensivo)"),
DIFICIL("Difícil (Inteligente)");
private final String descripcion;
Dificultad(String descripcion) {
this.descripcion = descripcion;
}
@Override
public String toString() {
return descripcion;
}
}
/**
* Representa los posibles estados de una casilla en el tablero.
* Encapsula el símbolo visual asociado a cada estado para separar lógica de presentación.
*/
enum Ficha {
VACIO("-"),
JUGADOR("X"),
MAQUINA("O");
private final String simbolo;
Ficha(String simbolo) {
this.simbolo = simbolo;
}
@Override
public String toString() {
return simbolo;
}
}
// --- CONFIGURACIÓN DEL JUEGO ---
// Dimensiones del tablero
static final int FILAS = 6;
static final int COLUMNAS = 7;
// Constante para indicar que una búsqueda no ha tenido éxito.
// Usamos esto en lugar de -1 directamente para mejorar la legibilidad.
static final int NO_ENCONTRADO = -1;
// Estado global del juego
static Ficha[][] tablero = new Ficha[FILAS][COLUMNAS];
static Scanner teclado = new Scanner(System.in);
static Random azar = new Random();
/**
* Punto de entrada principal del programa.
* Orquesta el flujo de la aplicación: configuración inicial, bucle de juego y finalización.
* @param args Argumentos de consola (no utilizados).
*/
public static void main(String[] args) {
inicializarTablero();
System.out.println("--- CUATRO EN RAYA ---");
System.out.println("Elige dificultad:");
// Generamos el menú dinámicamente basándonos en los valores del Enum.
// Esto permite añadir nuevas dificultades en el futuro sin modificar este código.
for (Dificultad d : Dificultad.values()) {
System.out.println((d.ordinal() + 1) + ". " + d);
}
// Selección y validación de la dificultad
Dificultad dificultadSeleccionada = null;
while (dificultadSeleccionada == null) {
System.out.print("Opción: ");
if (teclado.hasNextInt()) {
int opcion = teclado.nextInt();
// Verificamos rango válido (1 a N)
if (opcion > 0 && opcion <= Dificultad.values().length) {
dificultadSeleccionada = Dificultad.values()[opcion - 1];
} else {
System.out.println("Número no válido. Elige entre 1 y " + Dificultad.values().length);
}
} else {
teclado.next(); // Consumir entrada incorrecta para evitar bucle infinito
System.out.println("Por favor, introduce un número entero.");
}
}
System.out.println("Has elegido: " + dificultadSeleccionada);
// Bucle principal del juego
boolean turnoJugador = true;
boolean juegoTerminado = false;
while (!juegoTerminado) {
imprimirTablero();
if (turnoJugador) {
hacerMovimientoJugador();
} else {
hacerMovimientoMaquina(dificultadSeleccionada);
}
// Determinamos qué ficha se acaba de colocar para verificar victoria
Ficha fichaActual = turnoJugador ? Ficha.JUGADOR : Ficha.MAQUINA;
if (comprobarVictoria(fichaActual)) {
imprimirTablero();
System.out.println(turnoJugador ? "¡ENHORABUENA! Has ganado." : "FIN. Ha ganado el ordenador.");
juegoTerminado = true;
} else if (tableroLleno()) {
imprimirTablero();
System.out.println("¡EMPATE! No quedan casillas libres.");
juegoTerminado = true;
}
// Alternar turno
turnoJugador = !turnoJugador;
}
}
// --- LÓGICA DE TURNOS ---
/**
* Gestiona el turno del jugador humano.
* Solicita una columna repetidamente hasta que el usuario introduce una válida.
*/
static void hacerMovimientoJugador() {
boolean movimientoValido = false;
int c;
// Bucle controlado por bandera (flag) para evitar el uso de 'break'
while (!movimientoValido) {
// Usamos las constantes para que el texto coincida siempre con el tamaño real del tablero
System.out.print("Tu turno (Columna 0-" + (COLUMNAS - 1) + "): ");
if (teclado.hasNextInt()) {
c = teclado.nextInt();
if (columnaValida(c)) {
colocarFicha(c, Ficha.JUGADOR);
movimientoValido = true;
} else {
System.out.println("Movimiento no válido (columna llena o fuera de rango).");
}
} else {
teclado.next();
System.out.println("Por favor, introduce un número entero.");
}
}
}
/**
* Ejecuta la lógica de la Inteligencia Artificial.
* Decide el movimiento basándose en un sistema de prioridades en cascada.
* @param dificultad Nivel de inteligencia seleccionado.
*/
static void hacerMovimientoMaquina(Dificultad dificultad) {
System.out.println("Turno del ordenador...");
int columnaElegida = NO_ENCONTRADO;
// PRIORIDAD DE ATAQUE: Si puedo ganar, lo hago. (Solo en modo Difícil)
if (dificultad == Dificultad.DIFICIL) {
columnaElegida = buscarMovimientoGanador(Ficha.MAQUINA);
}
// PRIORIDAD DE DEFENSA: Si no tengo ataque, bloqueo al rival si va a ganar.
// (Disponible en Medio y Difícil)
if (columnaElegida == NO_ENCONTRADO && (dificultad == Dificultad.MEDIO || dificultad == Dificultad.DIFICIL)) {
columnaElegida = buscarMovimientoGanador(Ficha.JUGADOR);
}
// MOVIMIENTO POR DEFECTO: Si no hay estrategia, elijo al azar.
if (columnaElegida == NO_ENCONTRADO) {
do {
columnaElegida = azar.nextInt(COLUMNAS);
} while (!columnaValida(columnaElegida));
}
colocarFicha(columnaElegida, Ficha.MAQUINA);
}
/**
* Simula colocar una ficha en cada columna disponible para ver si produce una victoria.
* Utiliza la técnica de "Backtracking" (colocar, comprobar, retirar).
* @param ficha La ficha para la cual buscamos la victoria (propia o del rival).
* @return El índice de la columna ganadora o NO_ENCONTRADO si no existe.
*/
static int buscarMovimientoGanador(Ficha ficha) {
int columnaGanadora = NO_ENCONTRADO;
// Recorremos las columnas. La condición 'columnaGanadora == NO_ENCONTRADO'
// detiene el bucle eficientemente en cuanto encontramos una solución.
for (int c = 0; c < COLUMNAS && columnaGanadora == NO_ENCONTRADO; c++) {
if (columnaValida(c)) {
int f = obtenerFilaLibre(c);
// Simular jugada
tablero[f][c] = ficha;
// Comprobar resultado
if (comprobarVictoria(ficha)) {
columnaGanadora = c;
}
// Deshacer jugada (dejar el tablero como estaba)
tablero[f][c] = Ficha.VACIO;
}
}
return columnaGanadora;
}
// --- FUNCIONES DEL TABLERO ---
/**
* Inicializa el tablero llenando todas las celdas con símbolo VACIO.
*/
static void inicializarTablero() {
for (int f = 0; f < FILAS; f++) {
for (int c = 0; c < COLUMNAS; c++) {
tablero[f][c] = Ficha.VACIO;
}
}
}
/**
* Dibuja el estado actual del tablero en la consola.
* Utiliza el método toString() de cada Ficha para la representación visual.
*/
static void imprimirTablero() {
System.out.println();
// Cabecera con números de columna
System.out.print(" ");
for(int c = 0; c < COLUMNAS; c++) {
System.out.print(c + " ");
}
System.out.println();
System.out.println("---------------");
for (int f = 0; f < FILAS; f++) {
System.out.print("|");
for (int c = 0; c < COLUMNAS; c++) {
System.out.print(tablero[f][c] + "|");
}
System.out.println();
}
System.out.println("---------------");
}
/**
* Verifica si es legal colocar una ficha en la columna indicada.
* @param c Índice de la columna.
* @return true si la columna está dentro del rango y tiene espacio libre arriba.
*/
static boolean columnaValida(int c) {
return c >= 0 && c < COLUMNAS && tablero[0][c] == Ficha.VACIO;
}
/**
* Busca la primera posición libre en una columna, empezando desde abajo.
* Simula el efecto de la gravedad.
* @param c Columna donde buscar.
* @return El índice de la fila libre o NO_ENCONTRADO si la columna está llena.
*/
static int obtenerFilaLibre(int c) {
int filaEncontrada = NO_ENCONTRADO;
// Recorremos de abajo hacia arriba. Paramos al encontrar el primer hueco.
for (int f = FILAS - 1; f >= 0 && filaEncontrada == NO_ENCONTRADO; f--) {
if (tablero[f][c] == Ficha.VACIO) {
filaEncontrada = f;
}
}
return filaEncontrada;
}
/**
* Coloca una ficha de manera definitiva en el tablero.
* Se asume que la validación de la columna se ha hecho previamente.
* @param c Columna seleccionada.
* @param ficha Tipo de ficha a colocar.
*/
static void colocarFicha(int c, Ficha ficha) {
int f = obtenerFilaLibre(c);
if (f != NO_ENCONTRADO) {
tablero[f][c] = ficha;
}
}
/**
* Comprueba si el tablero está lleno (condición de empate).
* Basta con verificar si quedan huecos en la fila superior de alguna columna.
* @return true si el tablero está completo y no caben más fichas.
*/
static boolean tableroLleno() {
// Si encontramos al menos una casilla vacía arriba, el tablero NO está lleno.
for (int c = 0; c < COLUMNAS; c++) {
// El 'return' hace que salgamos de la función inmediatamente.
if (tablero[0][c] == Ficha.VACIO) {
return false;
}
}
// Si el bucle termina sin haber ejecutado el return anterior,
// significa que no había huecos: el tablero está lleno.
return true;
}
/**
* Algoritmo de comprobación de victoria.
* Verifica secuencias de 4 fichas iguales en horizontal, vertical y diagonales.
* @param ficha La ficha que acaba de jugar (JUGADOR o MAQUINA).
* @return true si hay 4 en raya.
*/
static boolean comprobarVictoria(Ficha ficha) {
// Horizontal (-)
for (int f = 0; f < FILAS; f++) {
for (int c = 0; c < COLUMNAS - 3; c++) {
if (tablero[f][c] == ficha &&
tablero[f][c+1] == ficha &&
tablero[f][c+2] == ficha &&
tablero[f][c+3] == ficha) return true;
}
}
// Vertical (|)
for (int f = 0; f < FILAS - 3; f++) {
for (int c = 0; c < COLUMNAS; c++) {
if (tablero[f][c] == ficha &&
tablero[f+1][c] == ficha &&
tablero[f+2][c] == ficha &&
tablero[f+3][c] == ficha) return true;
}
}
// Diagonal Ascendente (/)
for (int f = 3; f < FILAS; f++) {
for (int c = 0; c < COLUMNAS - 3; c++) {
if (tablero[f][c] == ficha &&
tablero[f-1][c+1] == ficha &&
tablero[f-2][c+2] == ficha &&
tablero[f-3][c+3] == ficha) return true;
}
}
// Diagonal Descendente (\)
for (int f = 0; f < FILAS - 3; f++) {
for (int c = 0; c < COLUMNAS - 3; c++) {
if (tablero[f][c] == ficha &&
tablero[f+1][c+1] == ficha &&
tablero[f+2][c+2] == ficha &&
tablero[f+3][c+3] == ficha) return true;
}
}
return false;
}
}
Laberinto, un juego arcade con música, nave, obstáculos, colisiones, enemigos, niveles, imagen de fondo y game over, hecho con Godot
Las imágenes y los ficheros de audio
Para nuestro juego necesitaremos varias imágenes y ficheros de audio. Para poder incluirlas en nuestro proyecto, primero debemos descargarlas en nuestro ordenador, y a continuación las arrastraremos a la zona de «Sistema de Archivos» de Godot. Las tienes disponibles en este archivo ZIP.
Pasos a seguir
Esqueleto del juego (todas las funciones vacías)
extends Node2D
# ==============================================================================
# CONFIGURACIÓN Y CONSTANTES
# ==============================================================================
# --- TAMAÑOS Y AJUSTES ---
const TAM_CELDA = 100 # Píxeles por bloque
const MARGEN = 5 # Píxeles de separación interna
# --- VELOCIDADES ---
const VEL_JUGADOR = 350
const VEL_ENEMIGO = 150
# --- CARGA DE IMÁGENES ---
const TEX_FONDO = preload("res://assets/fondo.png")
const TEX_JUGADOR = preload("res://assets/jugador.png")
const TEX_ENEMIGO = preload("res://assets/enemigo.png")
const TEX_PARED = preload("res://assets/bloque.png")
const TEX_META = preload("res://assets/meta.png")
const TEX_CERRAR = preload("res://assets/boton_cerrar.png")
const TEX_REINICIAR = preload("res://assets/boton_reiniciar.png")
const TEX_GAMEOVER = preload("res://assets/game_over.png")
const TEX_SIGUIENTE = preload("res://assets/siguiente_nivel.png")
# --- CARGA DE AUDIO ---
const AUDIO_FONDO = preload("res://assets/musica_fondo.mp3")
const AUDIO_MUERTE = preload("res://assets/sfx_muerte.mp3")
# ==============================================================================
# VARIABLES GLOBALES
# ==============================================================================
var pantalla: Vector2
var tam_tablero: Vector2
var celda_dim: Vector2
var paredes: Array[Rect2] = []
var enemigos: Array[Dictionary] = []
var jugador: Rect2
var meta: Rect2
var nivel_actual: int = 1
var game_over: bool = false
var siguiente_nivel: bool = false
var btn_cerrar: Rect2
var btn_reiniciar: Rect2
var music_player: AudioStreamPlayer
var sfx_player: AudioStreamPlayer
# ==============================================================================
# 1. HERRAMIENTAS BÁSICAS
# ==============================================================================
func _crear_rect(x_grid, y_grid, margen_interno):
# Ayuda a convertir coordenadas de la cuadrícula (ej: 2,3) a píxeles reales en pantalla.
pass
func _es_zona_segura(pos: Vector2):
# Devuelve true si la coordenada es inicio, fin o esquinas clave.
pass
func _colisiona_con_paredes(rect: Rect2):
# Comprueba si un rectángulo intercepta alguna pared del array.
pass
func _reproducir_sonido(stream):
# Función auxiliar para reproducir un efecto de sonido puntual.
pass
func _es_posicion_valida(rect: Rect2):
# Valida si un objeto está dentro de la pantalla y libre de paredes.
pass
# ==============================================================================
# 2. GENERACIÓN DE DATOS (Lógica de Nivel)
# ==============================================================================
func _generar_paredes(cantidad):
# Coloca obstáculos aleatorios asegurándose de no bloquear el inicio o fin.
pass
func _generar_enemigos(cantidad):
# Genera enemigos solo en la mitad inferior para dar tiempo al jugador.
pass
func _iniciar_nivel():
# Resetea las variables y genera un nuevo mapa limpio.
# Calcula la dificultad basada en el nivel actual.
pass
# ==============================================================================
# 3. CONFIGURACIÓN Y VISUALIZACIÓN
# ==============================================================================
func _ready():
# Inicialización básica: Configura el tamaño de pantalla, la cuadrícula y el audio.
# Se ejecuta una única vez al arrancar la escena.
pass
func _draw():
# Dibuja texturas, enemigos, jugador e interfaz en pantalla.
# IMPORTANTE: El orden importa. Lo primero que se dibuja queda al fondo.
pass
# ==============================================================================
# 4. MOVIMIENTO Y FÍSICAS
# ==============================================================================
func _mover_jugador(delta):
# Calcula el movimiento del jugador y evita que atraviese paredes.
pass
func _mover_enemigos(delta):
# Mueve enemigos verticalmente y los hace rebotar al chocar.
pass
func _comprobar_colisiones():
# Verifica condiciones de victoria o derrota por colisión.
pass
# ==============================================================================
# 5. BUCLE PRINCIPAL E INPUT
# ==============================================================================
func _process(delta):
# Bucle principal: Gestiona movimiento y colisiones en cada frame.
pass
func _input(event):
# Gestiona clics del ratón para botones y reinicio de juego.
pass
crear_rect()
func _crear_rect(x_grid, y_grid, margen_interno) -> Rect2: # Ayuda a convertir coordenadas de la cuadrícula (ej: 2,3) a píxeles reales en pantalla. # Retorna un Rect2. La posición X es (columna * ancho_celda) + margen. # El tamaño es (ancho_celda - doble margen) para que quede centrado. return Rect2( x_grid * celda_dim.x + margen_interno, y_grid * celda_dim.y + margen_interno, celda_dim.x - margen_interno * 2, celda_dim.y - margen_interno * 2 )
es_zona_segura()
func _es_zona_segura(pos: Vector2) -> bool: # Devuelve true si la coordenada es inicio, fin o esquinas clave. if pos == Vector2(0, 0): return true # Esquina Jugador if pos == Vector2(tam_tablero.x - 1, tam_tablero.y - 1): return true # Esquina Meta # Protegemos las otras dos esquinas para evitar bloqueos imposibles en mapas pequeños if pos == Vector2(tam_tablero.x - 1, 0): return true if pos == Vector2(0, tam_tablero.y - 1): return true return false
colisiona_con_paredes()
func _colisiona_con_paredes(rect: Rect2) -> bool: # Comprueba si un rectángulo intercepta alguna pared del array. for pared in paredes: # Usamos .grow(-1) para reducir un píxel el hitbox de chequeo. # Esto evita que detecte colisión si solo se están rozando lado con lado. if rect.grow(-1).intersects(pared): return true return false
reproducir_sonido()
func _reproducir_sonido(stream): # Función auxiliar para reproducir un efecto de sonido puntual. sfx_player.stream = stream sfx_player.play()
es_posicion_valida()
func _es_posicion_valida(rect: Rect2) -> bool: # Valida si un objeto está dentro de la pantalla y libre de paredes. # 'encloses' devuelve true si el rect está TOTALMENTE dentro de la pantalla if not get_viewport_rect().encloses(rect): return false # Si choca con pared, devuelve false (posición no válida) return not _colisiona_con_paredes(rect)
generar_paredes()
func _generar_paredes(cantidad): # Coloca obstáculos aleatorios asegurándose de no bloquear el inicio o fin. var intentos = 0 # Usamos un while con límite de intentos para evitar bucles infinitos si no hay sitio while paredes.size() < cantidad and intentos < 1000: intentos += 1 # Elegimos una columna y fila al azar var pos = Vector2(randi() % int(tam_tablero.x), randi() % int(tam_tablero.y)) # Si la posición elegida es vital (inicio/fin), la descartamos y probamos otra if _es_zona_segura(pos): continue # Creamos el rectángulo provisional var nueva_pared = _crear_rect(pos.x, pos.y, 0) # Verificamos que no caiga encima de otra pared ya existente if not _colisiona_con_paredes(nueva_pared): paredes.append(nueva_pared)
generar_enemigos()
func _generar_enemigos(cantidad):
# Genera enemigos solo en la mitad inferior para dar tiempo al jugador.
var intentos = 0
var fila_minima = int(tam_tablero.y / 2) # Calculamos la mitad del tablero
while enemigos.size() < cantidad and intentos < 1000:
intentos += 1
# Coordenada X aleatoria (cualquier columna)
var cx = randi() % int(tam_tablero.x)
# Coordenada Y aleatoria (solo desde la mitad hacia abajo)
var cy = randi_range(fila_minima, int(tam_tablero.y) - 1)
if _es_zona_segura(Vector2(cx, cy)): continue
var rect_temp = _crear_rect(cx, cy, 0)
# Evitamos que un enemigo nazca dentro de una pared
if _colisiona_con_paredes(rect_temp): continue
# Añadimos el enemigo como un diccionario con sus propiedades
enemigos.append({
"rect": rect_temp.grow(-15), # Hacemos la hitbox más pequeña para perdonar roces
"dir": 1 if randf() > 0.5 else -1, # 50% probabilidad de ir arriba o abajo
"velocidad": VEL_ENEMIGO
})
iniciar_nivel()
func _iniciar_nivel(): # Resetea las variables y genera un nuevo mapa limpio. # Calcula la dificultad basada en el nivel actual. # Limpiamos los arrays para borrar el nivel anterior paredes.clear() enemigos.clear() # Reiniciamos estados lógicos game_over = false siguiente_nivel = false # Creamos al jugador en la posición (0,0) (Arriba-Izquierda) jugador = _crear_rect(0, 0, MARGEN) # Creamos la meta en la última celda disponible (Abajo-Derecha) meta = _crear_rect(tam_tablero.x - 1, tam_tablero.y - 1, 0) # Fórmula de dificultad: Más nivel = más paredes y enemigos var cant_paredes = 5 + (nivel_actual * 3) var cant_enemigos = 2 + (nivel_actual * 1) # Ejecutamos los bucles de generación _generar_paredes(cant_paredes) _generar_enemigos(cant_enemigos)
ready()
func _ready(): # Inicialización básica: Configura el tamaño de pantalla, la cuadrícula y el audio. # Se ejecuta una única vez al arrancar la escena. # Inicializa la semilla aleatoria para que cada partida sea distinta randomize() # Obtenemos el tamaño visible de la ventana del juego pantalla = get_viewport_rect().size # Calculamos cuántas columnas y filas caben dividiendo el ancho/alto por el tamaño de celda var cols = round(pantalla.x / TAM_CELDA) var filas = round(pantalla.y / TAM_CELDA) tam_tablero = Vector2(cols, filas) # Recalculamos el tamaño exacto de la celda para que se ajuste perfectamente a la pantalla celda_dim = Vector2(pantalla.x / cols, pantalla.y / filas) # Definimos la geometría de los botones UI # Botón cerrar: Esquina superior derecha btn_cerrar = Rect2(pantalla.x - celda_dim.x, 0, celda_dim.x, celda_dim.y) # Botón reiniciar: Esquina inferior izquierda btn_reiniciar = Rect2(0, (tam_tablero.y - 1) * celda_dim.y, celda_dim.x, celda_dim.y) # Instanciamos y configuramos el nodo de música music_player = AudioStreamPlayer.new() music_player.stream = AUDIO_FONDO music_player.volume_db = -10 # Bajamos un poco el volumen add_child(music_player) # Lo añadimos al árbol de nodos music_player.play() # Instanciamos el nodo para efectos de sonido (SFX) sfx_player = AudioStreamPlayer.new() add_child(sfx_player) # Llamamos a la función que prepara el primer nivel _iniciar_nivel()
draw()
func _draw(): # Dibuja texturas, enemigos, jugador e interfaz en pantalla. # IMPORTANTE: El orden importa. Lo primero que se dibuja queda al fondo. # 1. Fondo (Capa más baja) draw_texture_rect(TEX_FONDO, Rect2(Vector2.ZERO, pantalla), false) # 2. Objetivos draw_texture_rect(TEX_META, meta, false) # 3. Obstáculos for p in paredes: draw_texture_rect(TEX_PARED, p, false, Color(1,1,1,0.5)) # Transparencia 0.5 # 4. Entidades (enemigos y jugador) for e in enemigos: draw_texture_rect(TEX_ENEMIGO, e.rect, false) draw_texture_rect(TEX_JUGADOR, jugador, false) # 5. Interfaz de Usuario (Capa superior) draw_texture_rect(TEX_CERRAR, btn_cerrar, false) draw_texture_rect(TEX_REINICIAR, btn_reiniciar, false) # Texto informativo draw_string(ThemeDB.fallback_font, Vector2(0, 50), "NIVEL " + str(nivel_actual), HORIZONTAL_ALIGNMENT_CENTER, pantalla.x, 40) # 6. Pantallas Superpuestas (Overlays) if game_over: # Dibuja game over con un tinte ligeramente transparente (0.8) draw_texture_rect(TEX_GAMEOVER, Rect2(Vector2.ZERO, pantalla), false, Color(1,1,1,0.8)) elif siguiente_nivel: draw_texture_rect(TEX_SIGUIENTE, Rect2(Vector2.ZERO, pantalla), false, Color(1,1,1,0.8))
mover_jugador()
func _mover_jugador(delta):
# Calcula el movimiento del jugador y evita que atraviese paredes.
# Obtiene un vector (-1, 0, 1) según las teclas pulsadas
var dir = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
# Si no se pulsa nada, salimos para ahorrar cálculos
if dir == Vector2.ZERO: return
# Calculamos cuánto desplazarse en este frame (Dirección * Velocidad * Tiempo)
var velocidad = dir * VEL_JUGADOR * delta
# --- MOVIMIENTO EJE X ---
# Creamos una copia temporal del jugador movida en X
var test_x = jugador
test_x.position.x += velocidad.x
# Si la copia no choca, movemos al jugador real
if _es_posicion_valida(test_x): jugador.position.x = test_x.position.x
# --- MOVIMIENTO EJE Y ---
# Hacemos lo mismo para el eje Y por separado.
# (Hacerlo separado permite deslizarse por las paredes en lugar de atascarse)
var test_y = jugador
test_y.position.y += velocidad.y
if _es_posicion_valida(test_y): jugador.position.y = test_y.position.y
mover_enemigos()
func _mover_enemigos(delta): # Mueve enemigos verticalmente y los hace rebotar al chocar. for e in enemigos: # Calculamos el paso vertical var paso = e.dir * e.velocidad * delta e.rect.position.y += paso # Verificamos si se salió de la pantalla (Arriba < 0, Abajo > pantalla) var choca_borde = e.rect.position.y < 0 or e.rect.end.y > pantalla.y # Si toca borde o pared... if choca_borde or _colisiona_con_paredes(e.rect): e.rect.position.y -= paso # Revertimos el movimiento para que no se quede pegado e.dir *= -1 # Invertimos la dirección (1 a -1, o viceversa)
comprobar_colisiones()
func _comprobar_colisiones(): # Verifica condiciones de victoria o derrota por colisión. # 1. VICTORIA: ¿El jugador toca la meta? if jugador.intersects(meta): siguiente_nivel = true _reproducir_sonido(AUDIO_FONDO) # 2. DERROTA: ¿El jugador toca algún enemigo? for e in enemigos: # Reducimos un poco el hitbox del enemigo (grow -4) para ser benévolos con el jugador if jugador.intersects(e.rect.grow(-4)): game_over = true music_player.stop() # Silenciamos música _reproducir_sonido(AUDIO_MUERTE) # Sonido de choque return
process()
func _process(delta): # Bucle principal: Gestiona movimiento y colisiones en cada frame. # Si el juego acabó, salimos de la función inmediatamente (no procesamos nada más) if game_over or siguiente_nivel: return # Actualizamos la lógica _mover_jugador(delta) _mover_enemigos(delta) _comprobar_colisiones() # Solicitamos redibujar la pantalla (llama a _draw automáticamente) queue_redraw()
input()
func _input(event): # Gestiona clics del ratón para botones y reinicio de juego. # Solo nos interesan los eventos que sean Clics de ratón y que estén Presionados if not (event is InputEventMouseButton and event.pressed): return # Chequeo de botones de UI if btn_cerrar.has_point(event.position): get_tree().quit() # Cierra el juego return if btn_reiniciar.has_point(event.position): _iniciar_nivel() # Reinicia el nivel actual return # Lógica para continuar tras ganar o perder if game_over: music_player.play() _iniciar_nivel() # Reintenta el mismo nivel elif siguiente_nivel: nivel_actual += 1 # Sube la dificultad _iniciar_nivel()
El resultado
Desde Godot podemos exportar este mismo proyecto para poder jugar en cualquier navegador. Puedes ver el resultado y jugar directamente mediante el siguiente enlace:
HTML. Unidad 7. Formato de texto avanzado.
Presentación
Infografías


Diapositivas y vídeos
Podrás familiarizarte con los contenidos clave de esta unidad a través de esta presentación y esta otra; estas diapositivas; y este vídeo y finalmente, este otro .
Introducción
HTML dispone de numerosos elementos para dar formato al texto que no abordamos en las unidades anteriores. Aunque las etiquetas que se describen en esta unidad son menos conocidas o de uso más específico, conocerlas resulta de gran utilidad para enriquecer nuestros documentos. Aprenderás a realizar el marcado semántico correcto de abreviaturas, citas y referencias, fragmentos de código informático, ecuaciones matemáticas y datos de contacto.
Abreviaturas
El elemento de abreviatura de HTML (<abbr>) representa una abreviatura o un acrónimo. A través del atributo opcional title, podemos proporcionar el significado desarrollado del término o una descripción legible para el usuario.
Por lo general, los navegadores presentan este texto mediante una etiqueta emergente (tooltip) que aparece automáticamente cuando se pasa el cursor del ratón sobre el elemento. Es importante tener en cuenta que, si se incluye el atributo title, este debe contener única y exclusivamente la descripción completa, sin añadir texto adicional.
A continuación, veamos algunos ejemplos prácticos en los que resulta adecuado el uso de abreviaturas o acrónimos:
Puedes utilizar CSS para dar estilo a tu HTML.
La NASA realiza un trabajo fascinante, sin duda.
El chiste de Ashok me hizo LOL muchísimo.
Veamos ahora qué código HTML debemos escribir para conseguir ese resultado:
<p>Usamos <abbr title="Lenguaje de Marcado de Hipertexto">HTML</abbr> para estructurar nuestros documentos web.</p> <p>Puedes utilizar <abbr title="Hojas de Estilo en Cascada">CSS</abbr> para dar estilo a tu <abbr title="Lenguaje de Marcado de Hipertexto">HTML</abbr>.</p> <p>La <abbr title="Administración Nacional de Aeronáutica y el Espacio">NASA</abbr> realiza un trabajo fascinante, sin duda.</p> <p>El chiste de Ashok me hizo <abbr title="Reír a carcajadas">LOL</abbr> muchísimo.</p>
El objetivo principal de este elemento es aportar valor semántico y facilitar información adicional. Aunque todos los navegadores lo muestran como un elemento en línea (inline) de forma predeterminada, su estilo visual por defecto no es consistente y varía según el navegador (si bien esto siempre puede unificarse añadiendo reglas CSS).
Las diferencias de estilo más comunes son las siguientes:
- Sin estilo visual: Algunos navegadores, como Internet Explorer, no le aplican ninguna apariencia distintiva, mostrándolo exactamente igual que si fuera un elemento
<span>. - Subrayado: Navegadores como Opera o Firefox suelen añadir un subrayado de puntos al texto de la abreviatura para diferenciarlo.
- Versalitas: Unos pocos navegadores van un paso más allá y, además del subrayado punteado, transforman el texto a versalitas (small caps).
Ejercicio propuesto: Ejemplo de uso de abreviaturas
Crea una página web utilizando el código del ejemplo anterior, y añade varios párrafos nuevos que contengan algunas abreviaturas y verifica el resultado en tu navegador. Recuerda incluir el resto de etiquetas básicas de HTML necesarias para la estructura del documento y no olvides validar tu código.
Ejercicio propuesto: Abreviaturas para chatear
El mundo del correo electrónico, los mensajes de texto y la mensajería instantánea han dado lugar a toda una serie de acrónimos y abreviaturas que permiten a los usuarios redactar sus mensajes con mayor rapidez. En este ejercicio deberás crear un par de listas. La primera incluirá la relación de abreviaturas que se enumeran a continuación (puedes añadir otras que conozcas). La segunda lista deberá contener al menos veinte frases que ilustren cómo se utilizan dichas abreviaturas en una conversación de chat.
Además, utiliza encabezados<h1>y<h2>para añadir títulos a las listas y proporcionar así un texto descriptivo previo.
Por ejemplo, la primera lista podría mostrar las abreviaturas de la siguiente manera:
- bn – Bien
- bss – Besos
- finde – Fin de semana
- hl – Hola
- kdms – Quedamos
- npi – Ni puñetera idea
- ns / nc – No sabe / No contesta
- pf / xfa – Por favor
- pq / xq – Por qué
- q tal – Qué tal
- rt – Retuit (compartido / de acuerdo)
- salu2 – Saludos
- tb – También
- tqm – Te quiero mucho
- tpc – Tampoco
- vdd – Verdad
- xo – Pero
- +o- – Más o menos
La segunda lista deberá incluir al menos veinte frases en las que se empleen dichas abreviaturas. Por ejemplo:
- Espero que te encuentres bn, hace tiempo que no hablamos.
- Me tengo que ir corriendo, hablamos luego. ¡bss!
- ¿Tenéis algún plan interesante para este finde?
- hl, ¿te ha llegado ya el paquete que te envié?
- Si os parece bien, kdms a las 20:00 en la plaza mayor.
- No me preguntes cómo arreglarlo porque no tengo npi.
- Le he preguntado si vendrá a la cena de empresa, pero de momento ns / nc.
- …
Puedes utilizar el siguiente código como ejemplo para saber qué etiquetas debes utilizar para obtener ambas listas:
<h1>Algunas abreviaturas que uso para chatear</h1>
<h2>Las abreviaturas</h2>
<ul>
<li>bn - Bien</li>
<li>bss - Besos</li>
<li>finde - Fin de semana</li>
<li>hl - Hola</li>
<li>kdms - Quedamos</li>
<li>npi - Ni puñetera idea</li>
<li>ns / nc - No sabe / No contesta</li>
<li>...</li>
</ul>
<h2>Algunos ejemplos</h2>
<ul>
<li>Espero que te encuentres <abbr title="Bien">bn</abbr>, hace tiempo que no hablamos.</li>
<li>Me tengo que ir corriendo, hablamos luego. ¡<abbr title="Besos">bss</abbr>!</li>
<li>¿Tenéis algún plan interesante para este <abbr title="Fin de semana">finde</abbr>?</li>
<li><abbr title="Hola">hl</abbr>, ¿te ha llegado ya el paquete que te envié?</li>
<li>Si os parece bien, <abbr title="Quedamos">kdms</abbr> a las 20:00 en la plaza mayor.</li>
<li>No me preguntes cómo arreglarlo porque no tengo <abbr title="Ni puñetera idea">npi</abbr>.</li>
<li>Le he preguntado si vendrá a la cena de empresa, pero de momento <abbr title="No sabe / No contesta">ns / nc</abbr>.</li>
<li>...</li>
</ul>
Citas
El elemento <q>
El elemento HTML <q> indica que el texto que encierra es una cita corta en línea (inline). La gran mayoría de los navegadores modernos interpretan este elemento envolviendo automáticamente el texto entre comillas.
Asimismo, puedes utilizar el atributo cite para especificar una URL que señale el documento de origen o la fuente de la información citada, aunque es importante tener en cuenta que, por defecto, el navegador no muestra esta URL al usuario de forma visible.
A continuación mostramos un par de ejemplos analizando primero el resultado visual (en la mayoría de los casos la única diferencia es que las comillas aparecen automáticamente para acotar la cita):
Cuando Dave le pide a HAL que abra las compuertas de la cápsula, HAL responde: "Lo siento, Dave. Me temo que no puedo hacerlo"
.
Según el sitio web de Mozilla,
"Firefox 1.0 se lanzó en 2004 y se convirtió en un gran éxito"
.
Y ahora el código fuente correspondiente:
<p>
Cuando Dave le pide a HAL que abra las compuertas de la cápsula, HAL responde: <q cite="https://www.imdb.com/es-es/title/tt0062622/">"Lo siento, Dave. Me temo que no puedo hacerlo"</q>.
</p>
<p>
Según el sitio web de Mozilla,
<q cite="https://www.mozilla.org/en-US/about/history/details/">"Firefox 1.0 se lanzó en 2004 y se convirtió en un gran éxito"</q>.
</p>
Importante: El elemento <q> está diseñado para citas breves que no requieren saltos de párrafo. Para citas extensas, utiliza el elemento <blockquote>.
Ejercicio propuesto: Citas en línea
Crea una página web utilizando el código del ejemplo anterior, y luego añade varios párrafos que contenga citas de tu elección y verifica el resultado en el navegador. Recuerda incluir el resto de etiquetas básicas de HTML para estructurar el documento y no olvides validar tu código.
Puedes usar por ejemplo citas de tu película favorita, tal como aparecen en «https://www.fotogramas.es/noticias-cine/g32183447/star-wars-mejores-frases-de-la-saga/«.
El elemento <blockquote>
El elemento HTML <blockquote> (o elemento de cita en bloque) indica que el texto que encierra constituye una cita extensa. Por lo general, los navegadores representan este elemento visualmente aplicando una sangría o indentación al texto.
Es posible especificar la URL de la fuente de la cita mediante el atributo cite, pero para indicar la referencia de la fuente en formato de texto visible se emplea el elemento completo <cite>. Por ejemplo:
Vuestro trabajo va a llenar gran parte de vuestra vida, y la única forma de estar realmente satisfecho es hacer lo que creéis que es un gran trabajo. Y la única forma de hacer un gran trabajo es amar lo que hacéis. Si aún no lo habéis encontrado, seguid buscando. No os conforméis. Como con todo lo que tiene que ver con el corazón, lo sabréis cuando lo encontréis. Steve Jobs
<blockquote cite="https://www.brainyquote.com/quotes/steve_jobs_416859">
Vuestro trabajo va a llenar gran parte de vuestra vida, y la única forma de estar realmente satisfecho es hacer lo que creéis que es un gran trabajo. Y la única forma de hacer un gran trabajo es amar lo que hacéis. Si aún no lo habéis encontrado, seguid buscando. No os conforméis. Como con todo lo que tiene que ver con el corazón, lo sabréis cuando lo encontréis.
<a href="https://www.brainyquote.com/quotes/steve_jobs_416859"><cite>Steve Jobs</cite></a>
</blockquote>
Ejercicio propuesto: Citas célebres
Crea una página web que recopile al menos veinte de las citas más famosas de la historia. Puedes encontrar muchas de ellas en sitios como «https://www.proverbia.net«, «https://www.mundifrases.com/«, o también puedes utilizar cualquier otra fuente que prefieras.
Debes utilizar el elemento<blockquote>para envolver la cita completa, y el elemento<cite>dentro de cada cita para indicar el nombre del autor, tal como se muestra en el ejemplo anterior y en los siguientes:
La mayor gloria de vivir no radica en no caer nunca, sino en levantarnos cada vez que caemos. Nelson Mandela
La manera de empezar es dejar de hablar y comenzar a actuar. Walt Disney
Vuestro tiempo es limitado, así que no lo malgastéis viviendo la vida de otro. No os dejéis atrapar por el dogma, que es vivir según los resultados del pensamiento de otros. No dejéis que el ruido de las opiniones de los demás ahogue vuestra propia voz interior. Y lo más importante, tened el coraje de seguir a vuestro corazón y vuestra intuición. Steve Jobs
Si la vida fuera predecible, dejaría de ser vida y no tendría sabor. Eleanor Roosevelt
Si miras lo que tienes en la vida, siempre tendrás más. Si miras lo que no tienes en la vida, nunca tendrás suficiente. Oprah Winfrey
…
Combinando los elementos <q>, <blockquote> y <cite>
El elemento de cita HTML (<cite>) se utiliza para referenciar el título de una obra creativa citada (como un libro, un artículo, un ensayo, una película, etc.). Según las convenciones de metadatos más apropiadas para el contexto, esta referencia puede presentarse de forma abreviada. Veamos un ejemplo práctico en el que resulta útil combinar estos tres elementos para crear una mejor estructura semántica:
Hola y bienvenidos a mi página de motivación. Como dice el sitio Citas de Confucio :
No importa lo lento que vayas mientras no te detengas.
También me encanta el concepto del pensamiento positivo y la necesidad de
«mantener tus pensamientos positivos»
(como se menciona en
Citas Positivas
).
Y ahora echemos un vistazo al código fuente que debemos escribir para obtener ese resultado:
<p>
Hola y bienvenidos a mi página de motivación. Como dice el sitio
<a href="https://proverbia.net/autor/frases-de-confucio">
<cite>Citas de Confucio</cite>
</a>:
</p>
<blockquote cite="https://www.proverbia.net/citasautor/confucio">
No importa lo lento que vayas mientras no te detengas.
</blockquote>
<p>
También me encanta el concepto del pensamiento positivo y la necesidad de
<q cite="https://www.mundifrases.com/tema/pensamiento-positivo/">
mantener tus pensamientos positivos
</q>
(como se menciona en
<a href="https://psicologiaymente.com/reflexiones/frases-positivas">
<cite>Citas Positivas</cite>
</a>).
</p>
Ejercicio propuesto: Ejemplo de citas completas
Crea una página web utilizando primero el código del ejemplo anterior y verifica el resultado en tu navegador. A continuación añade otro bloque similar combinando las tres etiquetas mencionadas, y vuelve a comprobar el resultado en el navegador.
No olvides incluir el resto de las etiquetas básicas de HTML (incluyendo títulos
<h1>y<h2>) y valida tu código para asegurar que es correcto.
Representación de código informático
HTML pone a nuestra disposición una serie de elementos específicos para el marcado de código informático:
<code>: Se utiliza para marcar fragmentos genéricos de código informático.<pre>: Se usa para preservar los espacios en blanco (generalmente empleado para bloques de código). Si utilizas sangría o espacios en blanco adicionales dentro del texto, los navegadores los ignorarán por defecto y no se verán reflejados en la página renderizada. No obstante, como ya explicamos en una unidad anterior, si envuelves el texto entre etiquetas<pre></pre>, los espacios en blanco se mostrarán idénticos a como aparecen en tu editor de texto.<var>: Sirve para marcar específicamente nombres de variables.<kbd>: Se utiliza para marcar la entrada de teclado (u otros tipos de entrada de datos) introducida en el ordenador.<samp>: Se emplea para marcar la salida (output) de un programa informático.
El elemento <code>
El elemento HTML <code> muestra su contenido con un estilo visual diseñado para indicar que el texto es un fragmento breve de código informático. Por defecto, los navegadores muestran el texto de este elemento utilizando la fuente monoespaciada predeterminada del sistema.
Veamos un par de ejemplos:
El método push() añade uno o más elementos al final de un array y devuelve la nueva longitud del mismo.
La función selectAll() resalta todo el texto en el campo de entrada para que el usuario pueda, por ejemplo, copiar o eliminar el texto.
Y este sería el código HTML del ejemplo anterior:
<p>
El método <code>push()</code> añade uno o más elementos al final de un array y devuelve la nueva longitud del mismo.
</p>
<p>
La función <code>selectAll()</code> resalta todo el texto en el campo de entrada para que el usuario pueda, por ejemplo, copiar o eliminar el texto.
</p>
Ejercicio propuesto: Código en línea
Crea una página web con el código del ejemplo anterior, luego añade algunos ejemplos más, y verifica los resultados en tu navegador.
No olvides incluir el resto de las etiquetas básicas de HTML y validar tu código.
Los elementos <pre> + <code>
El elemento <code> por sí solo representa únicamente una instrucción o una única línea de código. Para representar múltiples líneas de código (un bloque), debemos envolver el elemento <code> dentro de un elemento <pre>. De esta forma, respetaremos los espacios y saltos de línea. Por ejemplo:
if (a > b) {
console.log('¡Hola!'); // Ejemplo de código en el lenguaje JavaScript
}
Código HTML del ejemplo:
<pre><code>
if (a > b) {
console.log('¡Hola!'); // Ejemplo de código en el lenguaje JavaScript
}
</code></pre>
Ejercicio propuesto: Bloque de código
Crea una página web con el código del ejemplo anterior, añade algunos bloque adicionales con cualquier fragmento de código de cualquier lenguaje y comprueba los resultados en tu navegador.
No olvides incluir el resto de las etiquetas básicas de HTML y validar tu código
El elemento <var>
El elemento de variable HTML (<var>) representa el nombre de una variable dentro de una expresión matemática o en un contexto de programación. Por lo general, se presenta visualmente utilizando una versión en cursiva de la tipografía actual, aunque este comportamiento depende de cada navegador.
Por ejemplo:
Una ecuación simple: x = y + 2
El volumen de una caja es l × w × h, donde l representa la longitud, w la anchura y h la altura de la caja.
Las variables minSpeed y maxSpeed controlan la velocidad mínima y máxima del aparato en revoluciones por minuto (RPM).
Código HTML del ejemplo:
<p>
Una ecuación simple: <var>x</var> = <var>y</var> + 2
</p>
<p>
El volumen de una caja es <var>l</var> × <var>w</var> × <var>h</var>, donde <var>l</var>
representa la longitud, <var>w</var> la anchura y <var>h</var> la
altura de la caja.
</p>
<p>
Las variables <var>minSpeed</var> y <var>maxSpeed</var> controlan la velocidad mínima y máxima
del aparato en revoluciones por minuto (RPM).
</p>
Ejercicio propuesto: Ecuaciones y variables
Crea una página web con el código del ejemplo anterior, y añade un par de párrafos adicionales con cualquier ecuación que desees y comprueba los resultados en tu navegador.
No olvides incluir el resto de las etiquetas básicas de HTML y validar tu código
El elemento <kbd>
El elemento HTML de entrada de teclado (<kbd>) representa un fragmento de texto en línea que denota una entrada del usuario, ya sea a través de un teclado convencional, comandos de voz o cualquier otro dispositivo de entrada de texto.
Por convención, el navegador renderiza el contenido de un elemento <kbd> utilizando su fuente monoespaciada predeterminada para que destaque visualmente. Veamos algunos ejemplos:
Por favor, pulsa Ctrl + Mayús + R para recargar una página de MDN.
Usa el comando help micomando para ver la documentación del comando «micomando».
Puedes crear un nuevo documento utilizando el atajo de teclado Ctrl + N.
<p>
Por favor, pulsa <kbd>Ctrl</kbd> + <kbd>Mayús</kbd> + <kbd>R</kbd> para recargar una página de MDN.
</p>
<p>
Usa el comando <kbd>help micomando</kbd> para ver la documentación del comando "micomando".
</p>
<p>
Puedes crear un nuevo documento utilizando el atajo de teclado <kbd>Ctrl</kbd> + <kbd>N</kbd>.
</p>
Ejercicio propuesto: Atajos de teclado
Crea una página web con el código del ejemplo anterior, añade un par de párrafos con cualquier atajo de teclado que conozcas y comprueba los resultados en tu navegador.
No olvides incluir el resto de las etiquetas básicas de HTML y validar tu código.
El elemento <samp>
El elemento de muestra HTML (<samp>) se utiliza para encerrar texto en línea que representa la salida (output) de muestra o el resultado devuelto por un programa informático. Su contenido se muestra habitualmente utilizando la fuente monoespaciada predeterminada del navegador (como Courier o Lucida Console) para diferenciarlo del texto normal.
Veamos un par de ejemplos:
Estaba intentando arrancar mi ordenador, pero me salió este mensaje tan irónico:
Teclado no encontrado
Pulse F1 para continuar
Cuando el proceso finalice, la utilidad mostrará el texto Escaneo completado. Se han encontrado N resultados. Entonces podrás proceder al siguiente paso.
Código HTML del ejemplo:
<p>Estaba intentando arrancar mi ordenador, pero me salió este mensaje tan irónico:</p>
<p>
<samp>Teclado no encontrado <br>Pulse F1 para continuar</samp>
</p>
<p>...</p>
<p>
Cuando el proceso finalice, la utilidad mostrará el texto <samp>Escaneo completado. Se han encontrado <em>N</em> resultados.</samp> Entonces podrás proceder al siguiente paso.
</p>
Ejercicio propuesto: Salida de muestra
Crea una página web con el código del ejemplo anterior, junto con un par de párrafos más, y comprueba los resultados en tu navegador.
No olvides incluir el resto de las etiquetas básicas de HTML y validar tu código para asegurar que es correcto
Un ejemplo completo
Veamos ahora un ejemplo completo que combina todos estos elementos (<code>, <pre>, <var>, <kbd>, <samp>) para ver cómo interactúan entre sí.
A continuación mostramos el resultado visual:
var para = document.querySelector('p');
para.onclick = function() {
alert('¡Ay, deja de pincharme!');
}
No deberías utilizar elementos de presentación como <font> y <center>.
En el ejemplo de JavaScript anterior, para representa un elemento de párrafo.
Selecciona todo el texto con Ctrl/Cmd + A.
$ ping mozilla.org PING mozilla.org (63.245.215.20): 56 data bytes 64 bytes from 63.245.215.20: icmp_seq=0 ttl=40 time=158.233 ms
Y aquí el código fuente correspondiente:
<pre><code>var para = document.querySelector('p');
para.onclick = function() {
alert('¡Ay, deja de pincharme!');
}</code></pre>
<p>No deberías utilizar elementos de presentación como <code><font></code> y <code><center></code>.</p>
<p>En el ejemplo de JavaScript anterior, <var>para</var> representa un elemento de párrafo.</p>
<p>Selecciona todo el texto con <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>A</kbd>.</p>
<pre>$ <kbd>ping mozilla.org</kbd>
<samp>PING mozilla.org (63.245.215.20): 56 data bytes
64 bytes from 63.245.215.20: icmp_seq=0 ttl=40 time=158.233 ms</samp></pre>
Ejercicio propuesto: Código, atajos y salida
Crea una página web con el código del ejemplo anterior, junto con algún bloque
<pre>...</pre>adicional, y comprueba los resultados en tu navegador.No olvides incluir el resto de las etiquetas básicas de HTML y validar tu código .
Ejercicio propuesto: Comandos de Linux
Crea una página web que muestre una tabla con algunos de los comandos de Linux más importantes y su descripción de uso, siguiendo aproximadamente el modelo de la tabla que se muestra a continuación.
Debes cumplir los siguientes requisitos semánticos:
1. Utiliza la etiqueta<code>para los comandos en la columna de la izquierda.
2. Utiliza la etiqueta<kbd>para los ejemplos de entrada del usuario (user’s input) dentro de las descripciones.
3. Recuerda que la tabla debe tener encabezados (<th>) y un título o leyenda (<caption>).
| Comandos | Descripción |
|---|---|
passwd |
Cambia tu contraseña de usuario: 1. Escribe tu antigua contraseña 2. Introduce la nueva contraseña 3. Confirma la nueva contraseña |
~ |
Directorio personal del usuario (Home) (atajo para: /home/usuario) |
ls |
Lista las carpetas y archivos del directorio actual |
mkdir |
Crea un nuevo directorio dentro del actual: mkdir nuevodir |
cd |
Cambia de directorio: cd test (ir a un directorio llamado ‘test’) cd .. (ir al directorio padre/superior) cd ~ (ir al directorio personal) |
rm |
Elimina el archivo o directorio especificado: rm nombrearchivo (elimina un solo archivo) rm *.txt (elimina TODOS los archivos .txt del directorio actual) rm -r nombredir (elimina el directorio y sus archivos) ¡Por favor, ten cuidado al usar la opción -f! |
rmdir |
Elimina el directorio VACÍO especificado rmdir nombredir |
pwd |
Imprime la ruta absoluta actual |
man |
Muestra la página de manual del comando especificado: man ls (muestra la ayuda de ls) |
vi x.sh |
VI es un editor de texto. Si x.sh no existe, vi crea un nuevo archivo llamado
x.sh y lo abre; de lo contrario, simplemente abre el archivo existente. |
less archivotexto |
less es un paginador de texto. Abre (solo lectura) el archivo archivotexto. Puedes usar las flechas arriba y abajo para desplazarte por el texto; comparte muchos comandos con VI. |
chmod |
Cambia los permisos POSIX de un archivo o directorio. Permite proteger archivos contra accesos no deseados: r : permiso de lectura w : permiso de escritura x : permiso de ejecución chmod +x archivo.sh (permite la ejecución) chmod -w archivo.sh (deniega la escritura) |
chown |
Cambia el propietario de un archivo o directorio: chown usuario archivo.sh |
top |
Muestra los procesos en ejecución actualmente |
cat |
Imprime el contenido de un archivo en pantalla |
grep |
Filtra el archivo de texto especificado y muestra las líneas que contienen el patrón: grep patron archivo.sh También puedes usar tuberías (pipes) con la salida de otro comando: cat archivo.sh | grep home cat archivo.sh | grep "home page" |
Marcado de datos de contacto
HTML proporciona un elemento específico para marcar semánticamente la información de contacto: <address>. Este elemento es muy versátil y puede utilizarse en diversos contextos; por ejemplo, para facilitar los datos de contacto de una empresa en el encabezado o pie de página de un sitio web, o para indicar quién es el autor de un artículo o una publicación concreta.
Su funcionamiento es sencillo: basta con envolver los detalles de contacto dentro de esta etiqueta. Veamos un ejemplo básico:
<address>
<p>Fernando Ruiz, IES San Vicente, España</p>
</address>
No obstante, el contenido del elemento <address> puede albergar un marcado mucho más complejo. De hecho, la información suministrada puede adoptar la forma que mejor se ajuste al contexto, incluyendo cualquier dato necesario: dirección física, URL, correo electrónico, número de teléfono, redes sociales, coordenadas geográficas, etc. .
Regla importante: Debes tener en cuenta que siempre hay que incluir, como mínimo, el nombre de la persona, grupo u organización a la que hacen referencia dichos datos de contacto.
Veamos un ejemplo más completo y estructurado:
<address>
<p>
Juan Pérez<br>
Calle Mayor, 10<br>
Madrid, 28013<br>
España
</p>
<ul>
<li>Tel: 91 123 45 67</li>
<li>Email: [email protected]</li>
</ul>
</address>
También es correcto utilizar este elemento para referenciar información de contacto que se encuentra en otra página o enlace, como en el siguiente caso:
<address>
Puedes contactar con el autor en <a href="http://www.midominio.com/contacto">www.midominio.com</a>.<br>
Si encuentras algún error, por favor <a href="mailto:[email protected]">contacta con el webmaster</a>.<br>
También puedes visitarnos en:<br>
Fundación Mozilla<br>
331 E Evelyn Ave<br>
Mountain View, CA 94041<br>
EE. UU.
</address>
Ejercicio propuesto: Información de contacto
Crea una página web utilizando el código de los ejemplos anteriores y verifica el resultado en tu navegador.
No olvides incluir el resto de las etiquetas básicas de HTML y validar tu código para asegurar que la estructura es correcta .
El elemento <figure>
El elemento HTML <figure> (Figura con leyenda opcional) representa contenido independiente (self-contained content), el cual puede ir acompañado de un título o leyenda opcional especificado mediante el elemento <figcaption>.
La figura, su leyenda y su contenido se referencian semánticamente como una única unidad.
Figuras con imágenes
Este es el uso más común: asociar imágenes con sus pies de foto. Por ejemplo:
Código HTML del ejemplo:
<figure>
<img src="https://picsum.photos/id/235/300/200"
alt="Paisaje nórdico.">
<figcaption>Paisaje nórdico</figcaption>
</figure>
<hr>
<figure>
<img src="https://picsum.photos/id/237/300/200"
alt="Perro pidiendo algo de comida">
<figcaption>Perro pidiendo comida</figcaption>
</figure>
Ejercicio propuesto: Imágenes con leyendas
Crea una página web utilizando el código del ejemplo anterior, añade un par de figuras (
Puedes utilizar cualquier imagen que te guste; por ejemplo, las del servicio «https://picsum.photos/images«, tal como hicimos en ejercicios de la unidad anterior.<figure>) nuevas con imágenes y elige una leyenda adecuada (<figcaption>) para cada una. Finalmente, comprueba los resultados en tu navegador. No olvides incluir el resto de las etiquetas básicas de HTML y validar tu código.
Figuras con poemas
El elemento <figure> no sirve solo para imágenes; también es excelente para enmarcar poemas o fragmentos literarios, como por ejemplo:
Caminante, son tus huellas
el camino y nada más;
Caminante, no hay camino,
se hace camino al andar.
Al andar se hace el camino,
y al volver la vista atrás
se ve la senda que nunca
se ha de volver a pisar.
Código HTML del ejemplo:
<figure>
<p>
Caminante, son tus huellas<br>
el camino y nada más;<br>
Caminante, no hay camino,<br>
se hace camino al andar.<br>
Al andar se hace el camino,<br>
y al volver la vista atrás<br>
se ve la senda que nunca<br>
se ha de volver a pisar.
</p>
<figcaption>
<cite>Proverbios y cantares</cite>, de Antonio Machado
</figcaption>
</figure>
Ejercicio propuesto: Poemas
Crea una página web con el código del ejemplo anterior (el de Machado), añade una nueva figura con otro poema que te guste (quizás de Lorca, Bécquer o Neruda) y comprueba los resultados en tu navegador.
No olvides incluir el resto de las etiquetas básicas de HTML y validar tu código .
Figuras con código
El elemento <figure> es ideal para mostrar fragmentos de código que tienen un título descriptivo o que funcionan como ejemplos independientes.
Veamos un ejemplo práctico:
navigator:function EjemploNavigator() {
var txt;
txt = "Nombre en clave: " + navigator.appCodeName + "; ";
txt+= "Nombre del navegador: " + navigator.appName + "; ";
txt+= "Versión del navegador: " + navigator.appVersion + "; ";
txt+= "Cookies habilitadas: " + navigator.cookieEnabled + "; ";
txt+= "Plataforma: " + navigator.platform + "; ";
txt+= "Cabecera User-agent: " + navigator.userAgent + "; ";
console.log("EjemploNavigator", txt);
}
Código HTML del ejemplo:
<figure>
<figcaption>Obtener detalles del navegador usando <code>navigator</code>:</figcaption>
<pre><code>
function EjemploNavigator() {
var txt;
txt = "Nombre en clave: " + navigator.appCodeName + "; ";
txt+= "Nombre del navegador: " + navigator.appName + "; ";
txt+= "Versión del navegador: " + navigator.appVersion + "; ";
txt+= "Cookies habilitadas: " + navigator.cookieEnabled + "; ";
txt+= "Plataforma: " + navigator.platform + "; ";
txt+= "Cabecera User-agent: " + navigator.userAgent + "; ";
console.log("EjemploNavigator", txt);
}
</code></pre>
</figure>
Ejercicio propuesto: Bloque de código en figura
Crea una página web con el código del ejemplo anterior, junto con otro más, y comprueba los resultados en tu navegador.
No olvides incluir el resto de las etiquetas básicas de HTML y validar tu código .
Figuras con citas
Como vimos anteriormente, podemos usar <blockquote> por sí solo, pero si envolvemos la cita dentro de un elemento <figure>, podemos usar <figcaption> para indicar el autor o la fuente con mayor implicación semánticamente. Por ejemplo:
Si depurar es el proceso de eliminar errores de software, entonces programar debe ser el proceso de introducirlos.
Código HTML del ejemplo:
<figure>
<figcaption><cite>Edsger Dijkstra:</cite></figcaption>
<blockquote>
Si depurar es el proceso de eliminar errores de software,
entonces programar debe ser el proceso de introducirlos.
</blockquote>
</figure>
Ejercicio propuesto: Citas famosas (versión con figuras)
Crea una página web con el código del ejemplo anterior y añade algunas citas famosas más utilizando este mismo formato (
<figure>+<figcaption>+<blockquote>) para mostrar al menos diez citas.Finalmente, comprueba los resultados en tu navegador. No olvides incluir el resto de las etiquetas básicas de HTML y validar tu código.
Puedes reutilizar las citas que empleaste en ejercicios anteriores o buscar nuevas en los sitios que recomendamos previamente «https://proverbia.net/«, «https://www.mundifrases.com/«, o alguna en inglés como «https://www.brainyquote.com/«, o cualquier otra página web que conozcas.
Test
Comprueba tus conocimientos con este test sobre tablas y otros conceptos relacionados con esta unidad.
Aprende el abecedario en tres idiomas
Español
Resumen de audio
Resumen de vídeo
Canción del abecedario
A de abeja vuela sin parar,
Be de barco que va por el mar,
Ce de casa donde voy a jugar,
De de dado me gusta lanzar.
E de elefante grandote es,
Efe de flor perfumada a la vez,
Ge de gato saltando otra vez,
Hache de héroe valiente ya ves.
(Estribillo)
A be ce, vamos a aprender,
cantando juntos lo vas a saber.
Del a a la zeta lo vas a lograr,
el abecedario vas a dominar.
I de isla en el medio del mar,
Jota de jirafa que quiere bailar,
Ka de koala que quiere abrazar,
Ele de luna que se ve al brillar.
Eme de manzana roja y genial,
Ene de nube que vuela en el cielo,
Eñe de ñandú caminando ligero,
O de oso en un bosque de hielo.
(Estribillo)
A be ce, vamos a aprender,
cantando juntos lo vas a saber.
De la a a la zeta lo vas a lograr,
el abecedario vas a dominar.
Pe de pez nadando feliz,
Cu de queso que guardo yo aquí,
Erre de rana que hace “croac” así,
Ese de sol que ilumina mi país.
Te de tren que hace “chucu chucu” ya,
U de unicornio con magia y paz,
Uve de vaca que hace “muuu” de verdad,
Uve doble wifi pa’ conectarnos más.
Equis de xilófono que suena genial,
I griega de yogur que me voy a tomar,
Zeta de zorro que saluda al final.
(Estribillo final)
A be ce, ya lo sabes bien,
siempre cantando lo harás muy muy bien.
A be ce, vamos a aplaudir,
con el abecedario puedes sonreír.
Inglés
Resumen de audio
Resumen de vídeo
Catalán
Resumen de audio
Resumen de vídeo
Serpiente v2, un juego con música, efectos de sonido, colisiones, comida, imagen de fondo y game over, hecho con Godot
Assets
Pasos a seguir
Esqueleto del juego
extends Node2D
# ----------------------------------------
# SERPIENTE (SNAKE) v2 GODOT 4.x
# ----------------------------------------
#
# - Incorpora música de fondo, efectos de sonido (comer, morir).
# - Fondo de pantalla (imagen).
# - Imágenes para los segmentos de la serpiente (cabeza, cuerpo, cola) y la comida.
# - Pantalla de "Game Over" con imagen.
# - Botón de cerrar el juego.
# - Estructura de funciones similar al ejemplo de Asteroides.
#
# INPUT MAP necesario (Proyecto → Configuración del Proyecto → Mapa de Entrada):
# "ui_left" (← / A), "ui_right" (→ / D), "ui_up" (↑ / W), "ui_down" (↓ / S)
# "ui_accept" (Enter/Espacio) para reiniciar.
#
# Imágenes y sonidos en la carpeta 'assets':
# cabeza_serpiente.png, cuerpo_serpiente.png, cola_serpiente.png, comida_manzana.png
# fondo_jungla.png, game_over_snake.png, boton_cerrar.png
# musica_fondo_snake.mp3, sfx_comer.mp3, sfx_muerte_snake.mp3
# -------------------------
# CONSTANTES DE CONFIGURACIÓN
# -------------------------
const TAM_CUADRICULA = 75 # Tamaño de cada "cuadrado" de la cuadrícula en píxeles
const TAM_CABEZA = 90 # Tamaño de la cabeza en píxeles
const TAM_COMIDA = 90 # Tamaño de la comida en píxeles
const VELOCIDAD_JUEGO = 0.2 # Segundos entre cada movimiento (más bajo = más rápido)
const TAM_BOTON_CERRAR = 50
const TAM_TEXTO = 40
const PAUSA_GAME_OVER = 1
# Colores (para el texto de la UI)
const COLOR_TEXTO = Color(1, 1, 1, 0.8)
# Imágenes (pon los archivos en la carpeta 'assets')
const TEX_FONDO: Texture2D = preload("res://assets/fondo.png")
const TEX_CABEZA_SERPIENTE: Texture2D = preload("res://assets/cabeza.png")
const TEX_CUERPO_SERPIENTE: Texture2D = preload("res://assets/cuerpo.png")
const TEX_COLA_SERPIENTE: Texture2D = preload("res://assets/cola.png")
const TEX_COMIDA: Texture2D = preload("res://assets/comida.png")
const TEX_GAME_OVER: Texture2D = preload("res://assets/game_over.png")
const TEX_BOTON_CERRAR: Texture2D = preload("res://assets/boton_cerrar.png")
# Audios (pon los archivos en la carpeta 'assets')
const MUSICA_FONDO: AudioStream = preload("res://assets/musica_fondo.mp3")
const SFX_COMER: AudioStream = preload("res://assets/sfx_comer.mp3")
const SFX_MUERTE: AudioStream = preload("res://assets/sfx_muerte.mp3")
# -------------------------
# ESTADO DEL JUEGO
# -------------------------
var pantalla: Vector2
var cuadricula_ancho: int
var cuadricula_alto: int
# La serpiente es un array de posiciones en la *cuadrícula* (no píxeles)
# La cabeza es el *último* elemento (serpiente.back())
var serpiente: Array[Vector2] = []
var comida: Vector2 # Posición de la comida en la cuadrícula
var direccion: Vector2 = Vector2.RIGHT
var proxima_direccion: Vector2 = Vector2.RIGHT
var muerto: bool = false
var crecer_serpiente: bool = false
var puntuacion: int = 0
# Control del "tick" del juego usando _process
var tiempo_acumulado: float = 0.0
# UI
var etiqueta_puntuacion: Label
var boton_cerrar: Rect2
# Audio players
var musica_fondo_player: AudioStreamPlayer
var sfx_comer_player: AudioStreamPlayer
var sfx_muerte_player: AudioStreamPlayer
# -------------------------
# CICLO DE VIDA PRINCIPAL
# -------------------------
func _ready():
# Ejecutar al iniciar la escena
randomize()
_inicializar_pantalla()
_crear_ui()
_crear_boton_cerrar()
_inicializar_audio()
_reproducir_audio(musica_fondo_player) # Reproducir música de fondo en bucle
_iniciar_juego()
func _process(delta: float):
# Ejecutar la lógica del juego en cada frame
# Si estamos muertos o en pausa, no hacemos nada más que esperar el reinicio
if muerto: return
# Acumulamos el tiempo para simular un "tick" de juego
tiempo_acumulado += delta
# Si no ha pasado suficiente tiempo, salimos
if tiempo_acumulado < VELOCIDAD_JUEGO: return
# ¡Tick! Es hora de moverse. Reiniciamos el acumulador
tiempo_acumulado = 0.0
# La lógica principal se ejecuta aquí, a la velocidad de VELOCIDAD_JUEGO
_actualizar_movimiento()
# Pedir a Godot que vuelva a dibujar la pantalla
queue_redraw()
func _draw():
# Dibujar en pantalla los elementos del juego
# El orden importa (lo que se dibuja último, queda encima)
_dibujar_fondo()
_dibujar_serpiente()
_dibujar_comida()
_mostrar_boton_cerrar()
if muerto: _mostrar_game_over()
func _input(event: InputEvent):
# Comprobar la entrada de teclado, ratón o táctil
_comprobar_controles(event)
# -------------------------
# INICIALIZACIÓN BÁSICA
# -------------------------
func _inicializar_pantalla():
# Guardar el tamaño actual de la pantalla (ancho y alto)
pass
# -------------------------
# AUDIO
# -------------------------
func _crear_audio_player(stream: AudioStream, bus: String, volumen = 0.0):
# Instanciar y configurar un AudioStreamPlayer con stream, bus y volumen inicial, y devolverlo
pass
func _inicializar_audio():
# Construir y registrar players de música y SFX con buses/volúmenes apropiados
pass
func _reproducir_audio(audio_player: AudioStreamPlayer):
# Reproducir el AudioStreamPlayer
pass
func _detener_audio(audio_player: AudioStreamPlayer):
# Parar el AudioStreamPlayer
pass
# -------------------------
# UI (INTERFAZ DE USUARIO)
# -------------------------
func _crear_ui():
# Crear un Label para la puntuación
pass
func _actualizar_etiqueta_puntuacion():
# Muestra la puntuación actual en pantalla
pass
# -------------------------
# BOTÓN CERRAR
# -------------------------
func _crear_boton_cerrar():
# Definir el área clicable del botón de cierre en la esquina superior derecha
pass
func _mostrar_boton_cerrar():
# Dibujar la textura del botón de cierre dentro de su rectángulo clicable
pass
# -------------------------
# LÓGICA DEL JUEGO (MOVIMIENTO)
# -------------------------
func _iniciar_juego():
# (Re)iniciar todas las variables del juego
pass
func _actualizar_movimiento():
# Esta función se llama en cada "tick" (definido por VELOCIDAD_JUEGO)
pass
# -------------------------
# SERPIENTE
# -------------------------
func _mover_serpiente(nueva_cabeza: Vector2):
# Añadir la nueva cabeza al final del array
pass
func _convertir_direccion_a_angulo(dir: Vector2):
# Convertir dirección (Vector2) a ángulo en radianes (0° = derecha)
pass
func _dibujar_textura_rotada_centrada(tex: Texture2D, centro: Vector2, angulo: float, tamano: Vector2):
# Dibujar textura rotada alrededor de un punto central
pass
func _dibujar_serpiente():
pass
# -------------------------
# COMIDA
# -------------------------
func _mover_comida():
# Mover la comida a una posición aleatoria que no esté sobre la serpiente
pass
func _dibujar_comida():
# Dibujar la comida en su posición
pass
func _comprobar_comida(cabeza: Vector2):
# Comprobar si la cabeza está en la misma casilla que la comida
pass
# -------------------------
# CONTROLES
# -------------------------
func _comprobar_controles(event: InputEvent):
# Comprobación del botón de cerrar
pass
# -------------------------
# COLISIONES Y GAME OVER
# -------------------------
func _comprobar_colisiones(cabeza: Vector2):
# Comprueba si la cabeza ha chocado con algo. Devuelve 'true' si hay colisión.
pass
func _pausa_game_over():
# Activar una pausa breve no bloqueante tras el Game Over esperando a un temporizador asincrónico
pass
func _game_over():
# Termina el juego
pass
func _reiniciar_juego():
# Reiniciar la escena actual para devolver todo a su estado inicial
pass
# -------------------------
# FONDO Y CAPAS
# -------------------------
func _dibujar_fondo():
# Dibujar la imagen de fondo que cubre toda la pantalla
pass
func _mostrar_game_over():
# Dibujar la textura de Game Over. Podemos modularla para un efecto.
pass
inicializar_pantalla()
Esta función se encarga de configurar las dimensiones del juego. Primero, obtiene el tamaño actual de la ventana o viewport (el área visible del juego) y lo almacena en la variable pantalla. Luego, basándose en el tamaño de la pantalla en píxeles y la constante TAM_CUADRICULA (que define el tamaño de un «cuadrado» de nuestro tablero), calcula cuántos cuadrados caben a lo ancho (cuadricula_ancho) y a lo alto (cuadricula_alto). Esto nos permite trabajar con coordenadas de cuadrícula (ej. 0,0 o 5,10) en lugar de píxeles (ej. 0,0 o 640,1280).
func _inicializar_pantalla(): # Guardar el tamaño actual de la pantalla (ancho y alto) pantalla = get_viewport_rect().size # Calcular cuántos "cuadrados" de la cuadrícula caben en la pantalla cuadricula_ancho = int(pantalla.x / TAM_CUADRICULA) cuadricula_alto = int(pantalla.y / TAM_CUADRICULA)
crear_audio_player()
Esta es una función «ayudante» (o helper function) diseñada para crear y configurar reproductores de sonido de forma limpia y reutilizable. Recibe el archivo de audio (stream), el canal por donde debe sonar (bus, ej: «Music» o «SFX») y un volumen opcional. Crea un nuevo nodo AudioStreamPlayer, le asigna estas propiedades, lo añade como hijo a la escena actual (usando add_child, lo cual es crucial para que funcione) y finalmente devuelve el nodo ya configurado.
func _crear_audio_player(stream: AudioStream, bus: String, volumen = 0.0): # Instanciar y configurar un AudioStreamPlayer con stream, bus y volumen inicial, y devolverlo var player = AudioStreamPlayer.new() player.stream = stream player.bus = bus player.volume_db = volumen add_child(player) return player
inicializar_audio()
Esta función utiliza la función ayudante _crear_audio_player (que acabamos de ver) para preparar todos los sonidos que el juego necesitará. Crea tres reproductores de audio y los asigna a las variables globales del script: musica_fondo_player (asignado al bus «Music»), sfx_comer_player (asignado al bus «SFX») y sfx_muerte_player (también en «SFX»).
func _inicializar_audio(): # Construir y registrar players de música y SFX con buses/volúmenes apropiados musica_fondo_player = _crear_audio_player(MUSICA_FONDO, "Music", -5.0) sfx_comer_player = _crear_audio_player(SFX_COMER, "SFX", 0.0) sfx_muerte_player = _crear_audio_player(SFX_MUERTE, "SFX") # La música de fondo se reproduce en bloque musica_fondo_player.finished.connect(func(): musica_fondo_player.play())
reproducir_audio()
Esta función se usa para reproducir un sonido. Recibe el audio_player que debe sonar. Primero comprueba que el player existe (if audio_player) y que tiene un archivo de sonido cargado (and audio_player.stream). Si es así, inicia la reproducción con play(). Adicionalmente, tiene un parámetro loop (bucle) que si se establece en true, conectará una señal (finished) para que, cuando el sonido termine, vuelva a empezar automáticamente.
func _reproducir_audio(audio_player: AudioStreamPlayer): # Reproducir el AudioStreamPlayer if audio_player and audio_player.stream: audio_player.play()
detener_audio()
Función simple para parar un sonido. Comprueba si el audio_player existe y si se está reproduciendo actualmente (.playing). Si ambas condiciones son ciertas, detiene la reproducción inmediatamente usando stop().
func _detener_audio(audio_player: AudioStreamPlayer): # Parar el AudioStreamPlayer if audio_player and audio_player.playing: audio_player.stop()
crear_ui()
Prepara la interfaz de usuario básica. En este caso, crea un nuevo nodo de tipo Label (etiqueta de texto) y lo asigna a la variable etiqueta_puntuacion. Establece su posición en la esquina superior izquierda (10, 10). Luego, configura programáticamente su apariencia (tamaño de fuente TAM_TEXTO y color COLOR_TEXTO) usando un objeto LabelSettings. Finalmente, añade la etiqueta a la escena con add_child().
func _crear_ui(): # Crear un Label para la puntuación etiqueta_puntuacion = Label.new() etiqueta_puntuacion.position = Vector2(10, 10) # Configurar la fuente (programáticamente) var fuente_puntuacion = LabelSettings.new() fuente_puntuacion.font_size = TAM_TEXTO fuente_puntuacion.font_color = COLOR_TEXTO etiqueta_puntuacion.label_settings = fuente_puntuacion add_child(etiqueta_puntuacion)
actualizar_etiqueta_puntuacion()
Esta función se llama cada vez que la puntuación cambia (al comer una fruta o al iniciar el juego). Simplemente actualiza la propiedad text de la etiqueta_puntuacion (creada en la función anterior) para mostrar el texto «Puntuación: » seguido del valor actual de la variable puntuacion.
func _actualizar_etiqueta_puntuacion(): # Muestra la puntuación actual en pantalla etiqueta_puntuacion.text = "Puntuación: %s" % puntuacion
crear_boton_cerrar()
Esta función no crea un nodo de botón, sino que define el área donde el botón de cerrar será «clicable». Crea un Rect2 (un rectángulo) usando las coordenadas de la pantalla (pantalla.x) y el tamaño del botón (TAM_BOTON_CERRAR) para posicionarlo en la esquina superior derecha, con un pequeño margen de 10 píxeles. Esta área se almacenará en la variable boton_cerrar y se usará después en _input para detectar clics.
func _crear_boton_cerrar(): # Definir el área clicable del botón de cierre en la esquina superior derecha boton_cerrar = Rect2(pantalla.x - TAM_BOTON_CERRAR - 10, 10, TAM_BOTON_CERRAR, TAM_BOTON_CERRAR)
mostrar_boton_cerrar()
Esta función se llama dentro de _draw(). Se encarga de dibujar la textura del botón (TEX_BOTON_CERRAR) en la pantalla, usando exactamente el rectángulo (boton_cerrar) que definimos en la función anterior. También le aplica un tinte (un color gris semitransparente) para que no sea tan brillante.
func _mostrar_boton_cerrar(): # Dibujar la textura del botón de cierre dentro de su rectángulo clicable draw_texture_rect(TEX_BOTON_CERRAR, boton_cerrar, false, Color(0.8, 0.8, 0.8, 1.0))
iniciar_juego()
Esta es la función clave para empezar (o reiniciar) una partida. Restablece todas las variables de estado a sus valores iniciales: vacía la serpiente (serpiente.clear()), pone la puntuación a 0, actualiza la etiqueta, quita el estado muerto, resetea la direccion a Vector2.RIGHT y reinicia el tiempo_acumulado. Después, crea la serpiente inicial añadiendo tres segmentos (cola, cuerpo y cabeza) al array serpiente en posiciones de cuadrícula fijas. Finalmente, llama a _mover_comida() para colocar la primera manzana.
func _iniciar_juego(): # (Re)iniciar todas las variables del juego serpiente.clear() puntuacion = 0 _actualizar_etiqueta_puntuacion() muerto = false crecer_serpiente = false direccion = Vector2.RIGHT proxima_direccion = Vector2.RIGHT tiempo_acumulado = 0.0 # Crear serpiente inicial (en posiciones de cuadrícula) serpiente.push_back(Vector2(3, 5)) # Cola serpiente.push_back(Vector2(4, 5)) # Cuerpo serpiente.push_back(Vector2(5, 5)) # Cabeza _mover_comida()
actualizar_movimiento()
Este es el corazón de la lógica del juego, se ejecuta en cada «tick» (controlado por VELOCIDAD_JUEGO en _process). Primero, actualiza la direccion actual con la proxima_direccion (que guardó el input). Segundo, calcula cuál será la nueva_cabeza sumando la dirección a la posición de la cabeza actual. Tercero, comprueba si esa nueva_cabeza choca con algo (_comprobar_colisiones); si choca, llama a _game_over() y se detiene. Cuarto, comprueba si come (_comprobar_comida). Quinto y último, llama a _mover_serpiente() para efectuar el movimiento.
func _actualizar_movimiento(): # Esta función se llama en cada "tick" (definido por VELOCIDAD_JUEGO) # 1. Actualizar dirección direccion = proxima_direccion # 2. Calcular nueva posición de la cabeza var cabeza_actual = serpiente.back() # .back() es el último elemento var nueva_cabeza = cabeza_actual + direccion # 3. Comprobar colisiones if _comprobar_colisiones(nueva_cabeza): _game_over() return # Detener movimiento si morimos # 4. Comprobar si comemos _comprobar_comida(nueva_cabeza) # 5. Mover serpiente _mover_serpiente(nueva_cabeza)
mover_serpiente()
Esta función actualiza el array serpiente para simular el movimiento. Siempre añade la nueva_cabeza al final del array (push_back). Luego, comprueba la variable crecer_serpiente. Si es false (no hemos comido), elimina el primer segmento del array (pop_front), que es la cola. Esto hace que la serpiente mantenga su tamaño pero avance. Si crecer_serpiente es true (hemos comido), no elimina la cola, haciendo que la serpiente crezca un segmento, y resetea el flag a false.
func _mover_serpiente(nueva_cabeza: Vector2): # Añadir la nueva cabeza al final del array serpiente.push_back(nueva_cabeza) # Si no hemos comido (no crecemos), borramos el primer segmento (la cola) if not crecer_serpiente: serpiente.pop_front() # .pop_front() elimina el primer elemento else: # Si crecimos, reseteamos el flag y no borramos la cola crecer_serpiente = false
convertir_direccion_a_angulo()
Esta función recibe una dirección representada como un vector 2D (como Vector2.UP, Vector2.RIGHT, etc.) y devuelve el ángulo correspondiente en radianes, asumiendo que la orientación por defecto apunta hacia la derecha (orientación estándar en muchos motores 2D). Usa una estructura match para evaluar la dirección de entrada: si es UP, devuelve -90 grados convertidos a radianes (lo que apunta hacia arriba); si es DOWN, devuelve 90 grados (hacia abajo); si es LEFT, devuelve 180 grados (hacia la izquierda); y para cualquier otro caso —especialmente Vector2.RIGHT—, devuelve 0.0, ya que esa es la orientación base. Esta función es útil para rotar texturas (como la cabeza de la serpiente) de forma que siempre miren en la dirección en la que se están moviendo.
func _convertir_direccion_a_angulo(dir: Vector2): # Convertir dirección (Vector2) a ángulo en radianes (0° = derecha) match dir: Vector2.UP: return deg_to_rad(-90) Vector2.DOWN: return deg_to_rad(90) Vector2.LEFT: return deg_to_rad(180) _: return 0.0 # Vector2.RIGHT
dibujar_textura_rotada_centrada()
Esta función se encarga de dibujar una textura (tex) en la pantalla rotada alrededor de su propio centro, en una posición específica (centro). Para lograrlo, primero aplica una transformación de dibujo con draw_set_transform(centro, angulo, Vector2(1, 1)), que desplaza el origen del sistema de coordenadas al punto centro y lo rota según el ángulo dado (en radianes). Luego, dibuja la textura usando draw_texture_rect, pero con un rectángulo cuyo origen está en -tamano / 2.0, lo que centra la textura respecto al nuevo origen (el punto centro). Finalmente, restablece la transformación de dibujo a la identidad con draw_set_transform(Vector2.ZERO, 0.0, Vector2(1, 1)) para que los siguientes dibujos no se vean afectados por esta rotación ni traslación. Es una forma común y eficaz de dibujar sprites rotados centrados en Godot usando el sistema de dibujo personalizado (_draw).
func _dibujar_textura_rotada_centrada(tex: Texture2D, centro: Vector2, angulo: float, tamano: Vector2): # Dibujar textura rotada alrededor de un punto central draw_set_transform(centro, angulo, Vector2(1, 1)) draw_texture_rect(tex, Rect2(-tamano / 2.0, tamano), false) draw_set_transform(Vector2.ZERO, 0.0, Vector2(1, 1))
dibujar_serpiente()
Esta función se encarga de renderizar visualmente todos los segmentos de la serpiente en la pantalla, diferenciando entre cabeza, cuerpo y cola, y orientando cada parte según la dirección en la que apunta. Primero verifica que la serpiente no esté vacía; si lo está, no hace nada. Luego, recorre cada segmento (almacenado como coordenadas en una cuadrícula) y convierte su posición a píxeles. Calcula el centro de la celda para dibujar la textura centrada. Dependiendo de la posición del segmento en el array (i), asigna la textura correspondiente: la cabeza (último elemento) se orienta según la dirección actual de movimiento (direccion); la cola (primer elemento) se orienta según la dirección desde la cola hacia el siguiente segmento (serpiente[1] - serpiente[0]); y los segmentos intermedios del cuerpo se orientan según la dirección entre el segmento actual y el siguiente (serpiente[i + 1] - serpiente[i]). En todos los casos, usa _dibujar_textura_rotada_centrada() para dibujar la textura rotada correctamente alrededor de su centro, logrando una serpiente visualmente coherente y alineada con su trayectoria.
func _dibujar_serpiente(): # Dibujar todos los segmentos de la serpiente (cabeza, cuerpo, cola) en la pantalla if serpiente.is_empty(): return for i in range(serpiente.size()): var posicion_celda = serpiente[i] var posicion_pixel = posicion_celda * TAM_CUADRICULA var centro = posicion_pixel + Vector2(TAM_CUADRICULA, TAM_CUADRICULA) / 2.0 var textura: Texture2D var angulo_rotacion = 0.0 if i == serpiente.size() - 1: # Cabeza textura = TEX_CABEZA_SERPIENTE angulo_rotacion = _convertir_direccion_a_angulo(direccion) _dibujar_textura_rotada_centrada(textura, centro, angulo_rotacion, Vector2(TAM_CABEZA, TAM_CABEZA)) elif i == 0: # Cola textura = TEX_COLA_SERPIENTE var dir_cola = serpiente[1] - serpiente[0] angulo_rotacion = _convertir_direccion_a_angulo(dir_cola) _dibujar_textura_rotada_centrada(textura, centro, angulo_rotacion, Vector2(TAM_CUADRICULA, TAM_CUADRICULA)) else: # Cuerpo textura = TEX_CUERPO_SERPIENTE var dir_cuerpo = serpiente[i + 1] - serpiente[i] angulo_rotacion = _convertir_direccion_a_angulo(dir_cuerpo) _dibujar_textura_rotada_centrada(textura, centro, angulo_rotacion, Vector2(TAM_CUADRICULA, TAM_CUADRICULA))
mover_comida()
Esta función coloca la comida en una posición aleatoria dentro de la cuadrícula del juego, asegurándose de que no aparezca encima de ningún segmento de la serpiente. Para ello, entra en un bucle infinito (while true) que genera repetidamente coordenadas aleatorias (nueva_pos) usando randi_range, dentro de los límites horizontales (cuadricula_ancho) y verticales (cuadricula_alto). Cada vez que genera una posición, verifica si esa coordenada no está ya ocupada por la serpiente (con nueva_pos not in serpiente). En cuanto encuentra una ubicación libre, sale del bucle con break y asigna esa posición a la variable global comida. Este enfoque garantiza que la comida siempre aparezca en un lugar accesible.
func _mover_comida(): # Mover la comida a una posición aleatoria que no esté sobre la serpiente var nueva_pos: Vector2 # Generar posiciones aleatorias hasta encontrar una que no esté ocupada por la serpiente while true: nueva_pos = Vector2( randi_range(0, cuadricula_ancho - 1), randi_range(0, cuadricula_alto - 1) ) if nueva_pos not in serpiente: break # Salir del bucle cuando la posición sea válida # Asignar la posición válida a la comida comida = nueva_pos
dibujar_comida()
Esta función se encarga de renderizar la comida en la pantalla de forma centrada dentro de su celda de la cuadrícula. Primero calcula el centro exacto de la celda donde se encuentra la comida: multiplica las coordenadas de la cuadrícula (comida) por el tamaño de cada celda (TAM_CUADRICULA) para obtener la esquina superior izquierda, y luego suma la mitad del tamaño de la celda (TAM_CUADRICULA / 2) en ambas direcciones (usando Vector2.ONE) para llegar al centro. Luego, llama a la función auxiliar _dibujar_textura_rotada_centrada() pasando la textura de la comida (TEX_COMIDA), ese punto central, un ángulo de rotación de 0.0 (ya que la comida no necesita girar) y su tamaño personalizado (TAM_COMIDA). Esto asegura que la comida se dibuje perfectamente centrada en su celda, independientemente del tamaño de la textura o de la cuadrícula, manteniendo una apariencia limpia y alineada con el resto del juego.
func _dibujar_comida(): # Dibujar la comida en su posición, centrada y con su tamaño personalizado # 1. Calcular el centro de la celda de la cuadrícula var centro_celda = (comida * TAM_CUADRICULA) + (Vector2.ONE * TAM_CUADRICULA / 2.0) # 2. Llamar a nuestra función auxiliar para dibujar la comida _dibujar_textura_rotada_centrada( TEX_COMIDA, centro_celda, 0.0, # Ángulo (0.0 para la comida) Vector2(TAM_COMIDA, TAM_COMIDA) # Tamaño )
comprobar_comida()
Esta función, llamada en _actualizar_movimiento, comprueba si la cabeza de la serpiente ha aterrizado en la misma casilla que la comida. Si cabeza == comida, activa el flag crecer_serpiente a true (para que _mover_serpiente la haga crecer), incrementa la puntuacion, actualiza la etiqueta de texto, llama a _mover_comida() para buscar una nueva posición, y reproduce el sonido de comer (sfx_comer_player).
func _comprobar_comida(cabeza: Vector2): # Comprobar si la cabeza está en la misma casilla que la comida if cabeza == comida: crecer_serpiente = true # Marcar para crecer en el próximo movimiento puntuacion += 1 _actualizar_etiqueta_puntuacion() _mover_comida() # Mover la comida a un nuevo sitio _reproducir_audio(sfx_comer_player) # Reproducir SFX de comer
comprobar_controles()
Esta función gestiona toda la entrada del jugador de forma ordenada y segura. Primero, verifica si el usuario ha hecho clic (con ratón o pantalla táctil) dentro del área del botón de cerrar; si es así, finaliza la aplicación de forma segura usando get_tree().quit.call_deferred(). Luego, si el juego ya terminó (muerto == true), únicamente permite reiniciar al pulsar la acción "ui_accept" (como Enter o Espacio), ignorando cualquier otro input. Finalmente, cuando el juego está en marcha, traduce las entradas de dirección (ui_up, ui_down, etc.) a vectores (Vector2.UP, DOWN, etc.), pero evita giros completos sobre sí misma (por ejemplo, de abajo a arriba) comparando la dirección propuesta con la opuesta a la actual (-direccion). Solo si la nueva dirección es válida (distinta de la opuesta), se asigna a proxima_direccion, lo que garantiza un movimiento fluido y evita que la serpiente se suicide al retroceder directamente sobre su propio cuerpo.
func _comprobar_controles(event: InputEvent):
# Comprobación del botón de cerrar
if (event is InputEventScreenTouch or event is InputEventMouseButton) and event.pressed:
if boton_cerrar.has_point(event.position):
if get_tree(): get_tree().quit.call_deferred() # Cerrar de forma segura
return
# Si el juego ha terminado, solo escuchamos 'Enter' para reiniciar
if muerto:
# 'ui_accept' es 'Enter' o 'Espacio'
if event.is_action_pressed("ui_accept"): _reiniciar_juego()
return
# Si el juego está activo, leemos las flechas de dirección
var direccion_opuesta = -direccion
var nueva_dir = null
if event.is_action_pressed("ui_up"): nueva_dir = Vector2.UP
elif event.is_action_pressed("ui_down"): nueva_dir = Vector2.DOWN
elif event.is_action_pressed("ui_left"): nueva_dir = Vector2.LEFT
elif event.is_action_pressed("ui_right"): nueva_dir = Vector2.RIGHT
# Evitar que la serpiente se dé la vuelta sobre sí misma
if nueva_dir and nueva_dir != direccion_opuesta: proxima_direccion = nueva_dir
comprobar_colisiones()
Esta función recibe la posición de la cabeza y devuelve true si hay colisión, o false si es seguro. Comprueba dos cosas: 1) Si la cabeza está fuera de los límites de la cuadrícula (ej. cabeza.x < 0). 2) Recorre el array serpiente (excepto el último segmento, que es la cabeza actual) y comprueba si la cabeza es igual a la posición de alguno de esos segmentos. Si cualquiera de estas comprobaciones es positiva, devuelve true.
func _comprobar_colisiones(cabeza: Vector2): # Comprueba si la cabeza ha chocado con algo. Devuelve 'true' si hay colisión. # Colisión con bordes if cabeza.x < 0 or cabeza.x >= cuadricula_ancho or \ cabeza.y < 0 or cabeza.y >= cuadricula_alto: return true # Colisión con el cuerpo (excluyendo la cabeza) return cabeza in serpiente.slice(0, -1)
pausa_game_over()
Esta es una función asíncrona (usa await) que sirve para crear una pequeña pausa después de morir. Al ser llamada, crea un temporizador (create_timer) con la duración PAUSA_GAME_OVER (1 segundo) y «espera» (await) a que ese temporizador emita la señal timeout. Esto evita que el jugador pueda reiniciar el juego instantáneamente al morir, dándole un segundo para ver qué pasó.
func _pausa_game_over(): # Activar una pausa breve no bloqueante tras el Game Over esperando a un temporizador asincrónico await get_tree().create_timer(PAUSA_GAME_OVER).timeout
game_over()
Esta función se llama cuando _comprobar_colisiones detecta un choque. Establece el estado muerto a true, lo que detiene el bucle principal en _process. Detiene la música de fondo y reproduce el sonido de muerte. Llama a queue_redraw() para forzar un último dibujado (que mostrará la pantalla de Game Over) y finalmente llama a _pausa_game_over() para iniciar la pausa antes de poder reiniciar.
func _game_over(): # Termina el juego muerto = true _detener_audio(musica_fondo_player) _reproducir_audio(sfx_muerte_player) queue_redraw() # Forzar un último dibujado para mostrar la pantalla de Game Over _pausa_game_over() # Añadir una breve pausa ant
reiniciar_juego()
La forma más sencilla y robusta de reiniciar el juego. Simplemente le pide al árbol de escenas (get_tree()) que vuelva a cargar la escena actual (reload_current_scene()). Esto restablece todas las variables y nodos a su estado inicial, como si el juego se acabara de abrir.
func _reiniciar_juego(): # Reiniciar la escena actual para devolver todo a su estado inicial get_tree().reload_current_scene()
dibujar_fondo()
Función de dibujo llamada en _draw(). Dibuja la textura TEX_FONDO ocupando toda la pantalla (desde Vector2.ZERO hasta pantalla). Le aplica un tinte gris semitransparente para oscurecerla ligeramente y que la serpiente y la comida destaquen más.
func _dibujar_fondo(): # Dibujar la imagen de fondo que cubre toda la pantalla draw_texture_rect(TEX_FONDO, Rect2(Vector2.ZERO, pantalla), false, Color(0.5, 0.5, 0.5))
mostrar_game_over()
Esta función de dibujo solo se activa (gracias a la comprobación if muerto: en _draw()) cuando el juego ha terminado. Dibuja la textura TEX_GAME_VER ocupando toda la pantalla, también con un tinte semitransparente.
func _mostrar_game_over(): # Dibujar la textura de Game Over. Podemos modularla para un efecto. draw_texture_rect(TEX_GAME_OVER, Rect2(Vector2.ZERO, pantalla), false, Color(0.75, 0.75, 0.75, 0.75))
El resultado
Serpiente, un juego muy básico de movimiento y colisiones, hecho con Godot
Pasos a seguir
El esqueleto del juego
extends Node2D
# ----------------------------------------
# SERPIENTE (SNAKE) v1 GODOT 4.x
# ----------------------------------------
#
# - Usa _process y _draw para la lógica y el dibujado (sin Timer).
# - No usa imágenes, solo 'draw_rect' para los gráficos.
# - La UI (puntuación y Game Over) se crea como nodos Label.
#
# INPUT MAP necesario (Proyecto → Configuración del Proyecto → Mapa de Entrada):
# "ui_left" (← / A), "ui_right" (→ / D), "ui_up" (↑ / W), "ui_down" (↓ / S)
# "ui_accept" (Enter/Espacio) para reiniciar.
#
# -------------------------
# CONSTANTES DE CONFIGURACIÓN
# -------------------------
const TAM_CUADRICULA = 20 # Tamaño de cada "cuadrado" de la cuadrícula en píxeles
const VELOCIDAD_JUEGO = 0.1 # Segundos entre cada movimiento (más bajo = más rápido)
# Colores (en lugar de texturas)
const COLOR_FONDO = Color(0.1, 0.1, 0.1)
const COLOR_SERPIENTE = Color(0.2, 1, 0.2)
const COLOR_COMIDA = Color(1, 0, 0)
const COLOR_TEXTO = Color(1, 1, 1, 0.8)
const COLOR_TEXTO_GAME_OVER = Color(1, 0.8, 0.8)
# -------------------------
# ESTADO DEL JUEGO
# -------------------------
var pantalla: Vector2
var cuadricula_ancho: int
var cuadricula_alto: int
# La serpiente es un array de posiciones en la *cuadrícula* (no píxeles)
# La cabeza es el *último* elemento (serpiente.back())
var serpiente: Array[Vector2] = []
var comida: Vector2 # Posición de la comida en la cuadrícula
var direccion: Vector2 = Vector2.RIGHT
var proxima_direccion: Vector2 = Vector2.RIGHT # Para bufferizar el input
var muerto: bool = false
var crecer_serpiente: bool = false
var puntuacion: int = 0
# Control del "tick" del juego usando _process
var tiempo_acumulado: float = 0.0
# UI
var etiqueta_puntuacion: Label
var etiqueta_game_over: Label
# -------------------------
# CICLO DE VIDA PRINCIPAL
# -------------------------
func _ready():
# Ejecutar al iniciar la escena
randomize()
_inicializar_pantalla()
_crear_ui()
_iniciar_juego()
func _process(delta: float):
# Ejecutar la lógica del juego en cada frame
# Si estamos muertos, no hacemos nada más que esperar el reinicio
if muerto: return
# Acumulamos el tiempo para simular un "tick" de juego
tiempo_acumulado += delta
# Si no ha pasado suficiente tiempo, salimos
if tiempo_acumulado < VELOCIDAD_JUEGO: return
# ¡Tick! Es hora de moverse. Reiniciamos el acumulador
tiempo_acumulado = 0.0
# La lógica principal se ejecuta aquí, a la velocidad de VELOCIDAD_JUEGO
_actualizar_movimiento()
# Pedir a Godot que vuelva a dibujar la pantalla
queue_redraw()
func _draw():
# Dibujar en pantalla los elementos del juego
# El orden importa (lo que se dibuja último, queda encima)
_dibujar_fondo()
_dibujar_comida()
_dibujar_serpiente()
func _input(event: InputEvent):
# Comprobar la entrada de teclado, ratón o táctil
_comprobar_controles(event)
# -------------------------
# INICIALIZACIÓN BÁSICA
# -------------------------
func _inicializar_pantalla():
# Guardar el tamaño actual de la pantalla y calcular la cuadrícula
pass
# -------------------------
# UI (INTERFAZ DE USUARIO)
# -------------------------
func _crear_ui():
# Crear y configurar los nodos Label para la UI
pass
func _actualizar_etiqueta_puntuacion():
# Actualizar el texto del Label de puntuación
pass
# -------------------------
# LÓGICA DEL JUEGO (MOVIMIENTO)
# -------------------------
func _iniciar_juego():
# (Re)iniciar todas las variables del juego
pass
func _actualizar_movimiento():
# Esta función se llama en cada "tick"
pass
# -------------------------
# SERPIENTE
# -------------------------
func _mover_serpiente(nueva_cabeza: Vector2):
# Añadir nueva cabeza y quitar la cola (si no crecemos)
pass
func _dibujar_serpiente():
# Dibujar cada segmento de la serpiente
pass
# -------------------------
# COMIDA
# -------------------------
func _mover_comida():
# Mover la comida a una posición aleatoria válida
pass
func _dibujar_comida():
# Dibujar la comida en su posición
pass
func _comprobar_comida(cabeza: Vector2):
# Comprobar si la cabeza ha comido la comida
pass
# -------------------------
# CONTROLES
# -------------------------
func _comprobar_controles(event: InputEvent):
# Leer el input del jugador para cambiar de dirección o reiniciar
pass
# -------------------------
# COLISIONES Y GAME OVER
# -------------------------
func _comprobar_colisiones(cabeza: Vector2):
# Comprobar si la cabeza choca con los bordes o consigo misma
pass # Reemplazar con 'return false' por ahora
func _game_over():
# Termina el juego y muestra la pantalla de Game Over
pass
# -------------------------
# FONDO
# -------------------------
func _dibujar_fondo():
# Dibujar el rectángulo del fondo
pass
inicializar_pantalla()
Esta función se llama una sola vez al principio, desde _ready, porque solo necesitamos calcular estos valores una vez. Su trabajo es «medir» la pantalla. Primero, get_viewport_rect().size nos da el tamaño en píxeles de la ventana del juego (por ejemplo, 1024×600). Luego, dividimos ese ancho y alto por nuestra TAM_CUADRICULA (que es 20). Si la pantalla mide 1024 píxeles de ancho, 1024 / 20 = 51.2. Al usar int(), nos quedamos con la parte entera (51). Esto nos dice que nuestra cuadrícula de juego tiene 51 celdas de ancho. Guardamos estos valores en cuadricula_ancho y cuadricula_alto para que más tarde, en _comprobar_colisiones, podamos saber si la serpiente se ha chocado contra el «borde».
func _inicializar_pantalla(): # Guardar el tamaño actual de la pantalla (ancho y alto) pantalla = get_viewport_rect().size # Calcular cuántos "cuadrados" de la cuadrícula caben en la pantalla cuadricula_ancho = int(pantalla.x / TAM_CUADRICULA) cuadricula_alto = int(pantalla.y / TAM_CUADRICULA)
crear_ui()
Esta función también se llama desde _ready porque queremos que la Interfaz de Usuario (UI) exista desde el principio. En lugar de añadir los nodos Label arrastrándolos en el editor de escenas de Godot, los creamos «programáticamente» (por código) usando Label.new(). Esto mantiene nuestra escena principal más limpia y nos da control total. Para cada etiqueta, creamos una instancia, le damos propiedades (como la position o el text), y luego configuramos su estilo (fuente, color, tamaño) creando un recurso LabelSettings. Lo más importante es que, después de crearlos, debemos «engancharlos» a nuestra escena principal usando add_child(), de lo contrario, existirían en la memoria pero no se verían. Fíjate que la etiqueta etiqueta_game_over se crea, se centra, y al final se oculta con hide(); está lista y esperando, pero no la mostraremos hasta que el jugador pierda.
func _crear_ui(): # Crear un Label para la puntuación etiqueta_puntuacion = Label.new() etiqueta_puntuacion.position = Vector2(10, 10) # Configurar la fuente (programáticamente) var fuente_puntuacion = LabelSettings.new() fuente_puntuacion.font_size = 24 fuente_puntuacion.font_color = COLOR_TEXTO etiqueta_puntuacion.label_settings = fuente_puntuacion add_child(etiqueta_puntuacion) # ¡Importante añadirlo a la escena! # Crear un Label para el Game Over etiqueta_game_over = Label.new() etiqueta_game_over.text = "GAME OVER\nPulsa 'Enter' para reiniciar" etiqueta_game_over.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER # Configurar la fuente var fuente_game_over = LabelSettings.new() fuente_game_over.font_size = 32 fuente_game_over.font_color = COLOR_TEXTO_GAME_OVER fuente_game_over.outline_size = 4 fuente_game_over.outline_color = Color(0,0,0) etiqueta_game_over.label_settings = fuente_game_over # Centrarla etiqueta_game_over.size.x = pantalla.x etiqueta_game_over.position = Vector2(0, pantalla.y / 2 - 50) etiqueta_game_over.hide() # Ocultarla al inicio add_child(etiqueta_game_over) # ¡Añadirlo también!
actualizar_etiqueta_puntuacion()
Es una función «ayudante» (o helper) muy simple. Podríamos haber escrito etiqueta_puntuacion.text = ... en dos sitios distintos (en _iniciar_juego y en _comprobar_comida), pero es una buena práctica de programación crear una función para tareas que se repiten (principio «Don’t Repeat Yourself» o DRY). Su única tarea es actualizar la propiedad text de nuestra etiqueta_puntuacion. La línea "...Puntuación: %s" % puntuacion usa un formato de string: el %s actúa como un «marcador de posición» (placeholder) que es reemplazado por el valor de la variable puntuacion. Godot convertirá automáticamente el número puntuacion a un string de texto.
func _actualizar_etiqueta_puntuacion(): etiqueta_puntuacion.text = "Puntuación: %s" % puntuacion
iniciar_juego()
Esta es la función clave de (re)inicio. La llamamos en _ready para empezar la primera partida, pero también la llamaremos cada vez que el jugador pierda y pulse ‘Enter’. Por eso, tiene que ser un «reseteo» total. «Limpia» todas las variables de estado: serpiente.clear() vacía el Array de la serpiente anterior; puntuacion vuelve a 0 (e informamos a la UI con _actualizar_etiqueta_puntuacion()); muerto se pone en false para que _process vuelva a funcionar; y se resetea la dirección. Luego, «construimos» la serpiente inicial: usamos push_back() para añadir tres Vector2 (posiciones de cuadrícula) al array. El orden es importante: primero añadimos la cola (3,5), luego el cuerpo (4,5) y finalmente la cabeza (5,5). Al final del array (serpiente.back()) siempre estará la cabeza. Por último, llamamos a _mover_comida() para que ponga la primera manzana en el tablero.
func _iniciar_juego(): # (Re)iniciar todas las variables del juego serpiente.clear() puntuacion = 0 _actualizar_etiqueta_puntuacion() muerto = false crecer_serpiente = false direccion = Vector2.RIGHT proxima_direccion = Vector2.RIGHT etiqueta_game_over.hide() tiempo_acumulado = 0.0 # Crear serpiente inicial (en posiciones de cuadrícula) # La cabeza será el último elemento (5,5) serpiente.push_back(Vector2(3, 5)) # Cola serpiente.push_back(Vector2(4, 5)) # Cuerpo serpiente.push_back(Vector2(5, 5)) # Cabeza _mover_comida()
actualizar_movimiento()
¡Este es el corazón de la lógica del juego! Esta función se llama en cada «tick» (es decir, cada VELOCIDAD_JUEGO segundos, gracias a nuestro contador en _process). Orquesta la secuencia completa de lo que debe pasar en un solo «paso» de la serpiente. El orden es muy importante: 1) Leemos la proxima_direccion (que el jugador pulsó) y la convertimos en la direccion actual. 2) Calculamos dónde estará la cabeza en el siguiente fotograma (nueva_cabeza). 3) ¡Paramos! Antes de movernos, comprobamos si esa nueva_cabeza choca con algo (_comprobar_colisiones). Si choca, llamamos a _game_over() y usamos return para salir de la función inmediatamente, cancelando el movimiento. 4) Si no chocamos, comprobamos si la nueva_cabeza está sobre la comida (_comprobar_comida). 5) Finalmente, y solo si hemos superado las comprobaciones, le decimos a la serpiente que se mueva a esa nueva_cabeza (_mover_serpiente).
func _actualizar_movimiento(): # Esta función se llama en cada "tick" (definido por VELOCIDAD_JUEGO) # 1. Actualizar dirección direccion = proxima_direccion # 2. Calcular nueva posición de la cabeza var cabeza_actual = serpiente.back() # .back() es el último elemento var nueva_cabeza = cabeza_actual + direccion # 3. Comprobar colisiones if _comprobar_colisiones(nueva_cabeza): _game_over() return # Detener movimiento si morimos # 4. Comprobar si comemos _comprobar_comida(nueva_cabeza) # 5. Mover serpiente _mover_serpiente(nueva_cabeza)
mover_serpiente()
Esta función se encarga de la mecánica de «oruga» de la serpiente. Es un truco muy ingenioso que usa un Array (una lista). La serpiente siempre se mueve añadiendo una nueva cabeza en la posición calculada: serpiente.push_back(nueva_cabeza). Esto añade un elemento al final del array. Ahora mismo, la serpiente tiene un segmento de más. Entonces, comprobamos la variable crecer_serpiente. Si es false (no hemos comido en este tick), tenemos que mantener el tamaño original, así que eliminamos el primer segmento del array (la cola) usando serpiente.pop_front(). Si crecer_serpiente es true (porque acabamos de comer), ¡simplemente no llamamos a pop_front()! El resultado es que la serpiente tiene un segmento más, y hemos crecido. Reseteamos la variable a false para que en el próximo tick, si no comemos, volvamos a movernos con normalidad.
func _mover_serpiente(nueva_cabeza: Vector2): # Añadir la nueva cabeza al final del array serpiente.push_back(nueva_cabeza) # Si no hemos comido (no crecemos), borramos el primer segmento (la cola) if not crecer_serpiente: serpiente.pop_front() # .pop_front() elimina el primer elemento else: # Si crecimos, reseteamos el flag y no borramos la cola crecer_serpiente = false
dibujar_serpiente()
Esta función se llama automáticamente cada vez que Godot ejecuta _draw (lo cual forzamos en _process con queue_redraw()). Su trabajo es dibujar la serpiente, sin importar dónde esté o cuán larga sea. Lo hace recorriendo cada elemento (cada Vector2 de posición) en nuestro array serpiente con un bucle for segmento in serpiente:. Para cada uno de esos segmentos, tiene que convertir su posición de cuadrícula (ej: (5,5)) a píxeles en pantalla (ej: (100,100)). Esto lo hace con una simple multiplicación: pos_pixel = segmento * TAM_CUADRICULA. Finalmente, usa el comando draw_rect para dibujar un rectángulo de COLOR_SERPIENTE en esa posición de píxeles.
func _dibujar_serpiente(): # Dibujar cada segmento de la serpiente for segmento in serpiente: # Convertir la posición de cuadrícula (ej: 5,5) a píxeles (ej: 100,100) var pos_pixel = segmento * TAM_CUADRICULA var rect = Rect2(pos_pixel, Vector2(TAM_CUADRICULA, TAM_CUADRICULA)) draw_rect(rect, COLOR_SERPIENTE)
mover_comida()
Esta función tiene que encontrar un nuevo lugar para la manzana. No podemos simplemente ponerla en un sitio aleatorio, porque podría aparecer debajo de la serpiente, ¡haciendo imposible comerla! Por eso usamos un bucle while not pos_valida. Es un bucle que dice: «Repítete mientras no encuentres una posición válida». Dentro del bucle, primero somos optimistas (pos_valida = true). Luego, generamos una nueva_pos aleatoria en la cuadrícula (usando randi_range). Entonces, comprobamos esa nueva_pos contra todos los segmentos de la serpiente. Si encontramos una coincidencia (if segmento == nueva_pos), ponemos pos_valida = false y rompemos el bucle for. El bucle while ve que pos_valida es falsa y vuelve a empezar, buscando otra posición. Solo cuando el bucle for termina sin encontrar coincidencias, pos_valida se mantiene true y el while por fin termina. Asignamos esa nueva_pos 100% segura a la variable comida.
func _mover_comida(): # Mover la comida a una posición aleatoria que no esté sobre la serpiente var pos_valida = false var nueva_pos: Vector2 while not pos_valida: # Calcular una posición aleatoria en la cuadrícula nueva_pos = Vector2( randi_range(0, cuadricula_ancho - 1), randi_range(0, cuadricula_alto - 1) ) # Comprobar si esa posición está sobre la serpiente pos_valida = true # Asumir que es válida... for segmento in serpiente: if segmento == nueva_pos: pos_valida = false # ...ops, no lo era. Repetir el 'while'. break # Si salimos del 'while', 'nueva_pos' es válida comida = nueva_pos
dibujar_comida()
Se llama dentro de la función principal _draw. Es muy similar a _dibujar_serpiente, pero mucho más simple porque solo hay una comida. No necesita un bucle for. Simplemente coge la posición de cuadrícula actual de la variable comida (que _mover_comida nos aseguró que es válida), la convierte a píxeles en pantalla (multiplicando por TAM_CUADRICULA), y dibuja un solo rectángulo de COLOR_COMIDA en esa posición usando draw_rect.
func _dibujar_comida(): # Dibujar la comida en su posición var pos_pixel = comida * TAM_CUADRICULA var rect = Rect2(pos_pixel, Vector2(TAM_CUADRICULA, TAM_CUADRICULA)) draw_rect(rect, COLOR_COMIDA)
comprobar_comida()
Se llama desde _actualizar_movimiento en cada tick. Recibe la nueva_cabeza (dónde va a estar la serpiente) como argumento. Su trabajo es una simple comprobación: if cabeza == comida:. Si la posición de la cabeza es exactamente la misma que la de la comida, ¡hemos comido! Esto dispara tres acciones: 1) Activamos el flag crecer_serpiente = true. _mover_serpiente verá esto en un momento y sabrá que no debe borrar la cola. 2) Aumentamos la puntuacion y llamamos a _actualizar_etiqueta_puntuacion para que el jugador vea su nuevo punto. 3) Inmediatamente llamamos a _mover_comida() para que busque un nuevo sitio para la siguiente manzana, antes de que volvamos a dibujar.
func _comprobar_comida(cabeza: Vector2): # Comprobar si la cabeza está en la misma casilla que la comida if cabeza == comida: crecer_serpiente = true # Marcar para crecer en el próximo movimiento puntuacion += 1 _actualizar_etiqueta_puntuacion() _mover_comida() # Mover la comida a un nuevo sitio
comprobar_controles()
Esta función se llama automáticamente en _input cada vez que Godot detecta una entrada (como pulsar una tecla). A diferencia de _process, que se ejecuta constantemente, _input solo se ejecuta cuando algo pasa. Primero, comprueba si estamos muerto. Si es así, ignora todas las flechas y solo escucha ui_accept (Enter/Espacio) para reiniciar el juego. Si estamos vivos, escucha las flechas. La parte más importante es que no cambiamos direccion directamente, sino proxima_direccion. Esto es un «buffer» de entrada. ¿Por qué? Primero, evita que el jugador pulse dos teclas tan rápido dentro de un mismo tick que confunda al juego. Segundo, nos permite la lógica anti-suicidio: if direccion != Vector2.DOWN:. Esto comprueba que, si el jugador pulsa «arriba», nosotros no estemos ya yendo «abajo». Esto evita que la serpiente se choque consigo misma instantáneamente. La direccion real solo se actualiza en _actualizar_movimiento, al ritmo del «tick» del juego.
func _comprobar_controles(event: InputEvent):
# Si el juego ha terminado, solo escuchamos 'Enter' para reiniciar
if muerto:
if event.is_action_pressed("ui_accept"): # 'ui_accept' es 'Enter' o 'Espacio'
_iniciar_juego()
return
# Si el juego está activo, leemos las flechas de dirección
if event.is_action_pressed("ui_up"):
# Evitar que la serpiente se dé la vuelta sobre sí misma
if direccion != Vector2.DOWN:
proxima_direccion = Vector2.UP
elif event.is_action_pressed("ui_down"):
if direccion != Vector2.UP:
proxima_direccion = Vector2.DOWN
elif event.is_action_pressed("ui_left"):
if direccion != Vector2.RIGHT:
proxima_direccion = Vector2.LEFT
elif event.is_action_pressed("ui_right"):
if direccion != Vector2.LEFT:
proxima_direccion = Vector2.RIGHT
comprobar_colisiones()
Esta función crucial decide si morimos o no. Fíjate en -> bool: esto significa que la función está obligada a devolver (return) un valor booleano (true o false). Se llama desde _actualizar_movimiento antes de mover la serpiente. Comprueba dos tipos de colisión: 1) Colisión con los bordes: Comprueba si la cabeza se ha salido de los límites de la cuadrícula. Es decir, si su x es menor que 0 (borde izquierdo) o mayor o igual que cuadricula_ancho (borde derecho), y lo mismo para y (arriba y abajo). 2) Colisión consigo misma: Recorre cada segmento en el array serpiente y comprueba si la nueva_cabeza está en la misma posición que cualquier otro segmento. Si cualquiera de estas dos condiciones es cierta, la función devuelve true (¡colisión!). Si se comprueban todos los bordes y todos los segmentos y no hay colisión, la función devuelve false (seguro).
func _comprobar_colisiones(cabeza: Vector2): # Comprueba si la cabeza ha chocado con algo. Devuelve 'true' si hay colisión. # 1. Colisión con los bordes de la pantalla (en coordenadas de cuadrícula) if cabeza.x < 0 or cabeza.x >= cuadricula_ancho or \ cabeza.y < 0 or cabeza.y >= cuadricula_alto: return true # Hay colisión # 2. Colisión consigo misma (chocar con cualquier segmento) for segmento in serpiente: if cabeza == segmento: return true # Hay colisión # Si no hemos chocado con nada return false
game_over()
Se llama desde _actualizar_movimiento tan pronto como _comprobar_colisiones devuelve true. Esta función simplemente activa el flag muerto = true. Al principio de _process, tenemos una línea que dice if muerto: return. Así que, al poner muerto = true, estamos deteniendo toda la lógica de movimiento y el juego se «congela» eficazmente. Segundo, llama a show() en la etiqueta de «Game Over» que creamos y ocultamos al inicio, mostrando el mensaje de reinicio al jugador.
func _game_over(): # Termina el juego muerto = true etiqueta_game_over.show() queue_redraw() # Forzar un último dibujado (aunque _process esté parado)
dibujar_fondo()
La última función de dibujado, y la más simple. Es muy importante que sea la primera función que llamamos dentro de _draw. El dibujado en Godot funciona como un pintor: lo que dibujas primero, queda debajo. Por lo tanto, dibujamos el fondo primero, luego la comida, y finalmente la serpiente, para que la serpiente aparezca «encima» de la comida y del fondo. La función simplemente usa draw_rect para dibujar un rectángulo gigante del COLOR_FONDO que ocupa toda la pantalla, desde (0,0) (Vector2.ZERO) hasta el tamaño de la pantalla.
func _dibujar_fondo(): # Dibujar un rectángulo grande del color de fondo que cubra toda la pantalla draw_rect(Rect2(Vector2.ZERO, pantalla), COLOR_FONDO)
El resultado
Desde Godot podemos exportar este mismo proyecto para poder jugar en cualquier navegador. Puedes ver el resultado y jugar directamente mediante el siguiente enlace:
Asteroides v2, un juego arcade con música, nave, disparos, colisiones, estrellas, imagen de fondo y game over, hecho con Godot
Introducción
En esta segunda versión de Asteroides para Godot 4.5 vamos a transformar un mini–arcade en un proyecto con más vida, más feedback y más profundidad visual, manteniendo una premisa clave: que cada paso sea claro y aislado para quien está aprendiendo. Si en la primera versión nos centramos en la estructura básica —jugador, asteroides, colisiones y ciclo de juego—, en esta nueva versión nos proponemos añadir:
- Fondo desplazándose en bucle y estrellas con parallax (capas que se mueven a distintas velocidades según su “profundidad”).
- Disparos desde la nave (ratón y táctil, y también teclado), con colisiones que destruyen asteroides.
- Música de fondo y SFX (disparo, destrucción, muerte) para reforzar cada acción.
- Game Over visual (overlay) y pausa breve para evitar reinicios accidentales.
- Dificultad progresiva: la partida acelera poco a poco (más velocidad, mayor frecuencia de asteroides), potenciando el “pico de atención” típico del arcade.
La idea no es “tirar código y ya”, sino aprender a diseñar un juego por capas: primero el esqueleto con todas las funciones vacías (para ver el mapa completo), y luego la implementación función a función, explicando qué hace cada trozo de código, y cómo se integra en el ciclo de vida de Godot: _ready() para preparar, _process() para actualizar lógicas dependientes de tiempo, _draw() para dibujar en orden de capas, e _input() para traducir interacciones en estado de juego.
Además, reforzamos principios que conviene tener en cuenta cuando estás empezando:
- Separación de responsabilidades: los eventos de entrada solo cambian el valor de ciertas variables; el movimiento real se realiza en
_process()y el pintado en_draw(). - Independencia de FPS: cualquier desplazamiento (jugador, asteroides, disparos, fondo) se calcula con
delta, garantizando la misma experiencia a 30 o a 144 FPS. - Datos → Lógica → Render: representamos entidades con estructuras simples (
Rect2y diccionarios) y las transformamos en imágenes con dibujo inmediato, sin necesidad de nodos por cada elemento (ideal para comprender el pipeline y para arcades ligeros). - Programación defensiva y funciones reutilizables: inicialización y reproducción de audio, hitboxes ajustadas, reciclaje de entidades y “puntos únicos” de configuración.
Nociones básicas
Con el siguiente vídeo te harás una idea en unos pocos minutos del trabajo que llevaremos a cabo en esta unidad. Además, se explican conceptos básicos de forma muy sencilla, y así podrás entender más fácilmente cómo se puede implementar el juego propuesto utilizando Godot.
Análisis previo del código fuente
En el siguiente enlace encontrarás una presentación estructurada con varias diapositivas explicando paso a paso el desarrollo del juego (qué hace cada función, cómo se implementa, qué efectos visuales tienen, etc.):
Las imágenes y los ficheros de audio
Para nuestro juego necesitaremos varias imágenes y ficheros de audio. Para poder incluirlas en nuestro proyecto, primero debemos descargarlas en nuestro ordenador, y a continuación las arrastraremos a la zona de «Sistema de Archivos» de Godot. Las tienes disponibles en este archivo ZIP.
Imágenes





Música de fondo y efectos de sonido
Antes de empezar
- Crear una escena con un nodo raíz Node2D y un script donde colocaremos toda la programación de nuestro juego.
- Establecer en el mapa de entrada (Proyecto → Configuración del Proyecto → Mapa de Entrada) las teclas que vamos a usar:
ui_left(← / A),ui_right(→ / D),ui_accept(Enter/Espacio),ui_up(disparo opcional por teclado).- El disparo con ratón usa botón izquierdo y táctil sin mapear acciones extra.
- Grabar dentro del proyecto los recursos gráficos y de audio (deberán colocarse en
res://assets/):fondo.png,estrella.png,jugador.png,asteroide.png,disparo.png,boton_cerrar.png,game_over.pngmusica_fondo.mp3,sfx_disparo.mp3,sfx_destruccion.mp3,sfx_muerte.mp3
Pasos a seguir
Primero analizaremos el esqueleto completo con todas las funciones vacías (comentadas en una línea dentro de cada función). Después, iremos sección por sección implementándolas y explicando con detalle qué hace cada una, cómo se prueba y qué efecto observable produce en el juego. Al final tendremos un mini–arcade con parallax, disparos, audio y progresión, listo para ampliar (power-ups, marcadores, menús, etc.) o para usar en clase como práctica guiada.
Esqueleto del juego (todas las funciones vacías)
Cuando empezamos a programar un videojuego, lo primero no es ponerse a escribir la lógica directamente, sino planificar su estructura. En Godot esto se traduce en crear un script inicial con todas las funciones vacías (utilizando pass), para poder disponer de un diseño del juego antes de empezar a programar cada funcionalidad específica. Esto es especialmente útil por varios motivos:
- Nos da una visión global de qué partes tendrá el proyecto (jugador, fondo, estrellas, disparos, asteroides, colisiones, interfaz, sonido…).
- Permite que el juego ya se pueda ejecutar desde el principio, aunque aún no haga nada, porque Godot no dará errores al encontrar llamadas a funciones que ya existen, aunque estén vacías.
- Sirve como guía pedagógica: cada función tiene un comentario que indica qué va a hacer, lo que nos permite entender el flujo del programa sin necesidad de mirar todo el código a la vez.
En este caso, al ser la versión 2 del juego de Asteroides, añadiremos varias características nuevas respecto a la versión básica:
- Un fondo animado con estrellas en parallax, que proporcionará sensación de movimiento constante.
- Disparos desde la nave para poder destruir asteroides.
- Sonido y música de fondo, además de efectos de disparo, explosión y muerte.
- Un sistema de dificultad progresiva, que aumenta la velocidad y la frecuencia de los asteroides con el tiempo.
Todo esto lo reflejaremos desde el principio en el esqueleto, de manera que cada bloque de funciones ya está pensado para lo que vendrá después. A continuación mostramos el código base, con todas las funciones vacías y los comentarios explicativos dentro de cada una. Este será nuestro punto de partida:
extends Node2D
# ----------------------------------------
# ASTEROIDES v2 GODOT 4.5
# ----------------------------------------
# Añadidos respecto al original:
# - Fondo de estrellas en parallax simple (dibujo inmediato).
# - Música de fondo (utilizando el archivo correspondiente de la carpeta assets).
# - Disparos con botón izquierdo del ratón (y táctil) desde la nave.
# - Los disparos destruyen asteroides (suena un SFX por cada destrucción).
# - Al hacer clic, la nave también se mueve hacia ese lado.
# - SFX al disparar, al destruir asteroides, y al morir (game over).
# - Dunciones pequeñas e independientes, llamadas desde
# _ready, _process, _draw e _input para observar el progreso al implementar cada función.
#
# INPUT MAP necesario (Proyecto → Configuración del Proyecto → Mapa de Entrada):
# "ui_left" (← / A), "ui_right" (→ / D), "ui_accept" (Enter/Espacio)
# (El disparo se hace con botón izquierdo del ratón; no hace falta mapearlo.)
#
# RECURSOS que debes aportar:
# jugador.png, asteroide.png, disparo.png, boton_cerrar.png, estrella.png, fondo.png, game_over.png
# musica_fondo.mp3, sfx_destruccion.mp3, sfx_muerte.mp3, sfx_disparo
# -------------------------
# CONSTANTES DE CONFIGURACIÓN
# -------------------------
const TAM_JUGADOR = 128
const TAM_ASTEROIDE = 64
const TAM_ESTRELLAS = 8
const NUM_ESTRELLAS = 128
const TAM_DISPARO = 16
const FACTOR_HITBOX = 0.75
const TAM_BOTON_CERRAR = 32
const TAM_TEXTO = 32
const INC_VELOCIDAD = 8
const PAUSA_GAME_OVER = 1
# Imágenes (pon los archivos en la carpeta 'assets')
const TEX_FONDO: Texture2D = preload("res://assets/fondo.png")
const TEX_ESTRELLA: Texture2D = preload("res://assets/estrella.png")
const TEX_GAME_OVER: Texture2D = preload("res://assets/game_over.png")
const TEX_JUGADOR: Texture2D = preload("res://assets/jugador.png")
const TEX_ASTEROIDE: Texture2D = preload("res://assets/asteroide.png")
const TEX_DISPARO: Texture2D = preload("res://assets/disparo.png")
const TEX_BOTON_CERRAR: Texture2D = preload("res://assets/boton_cerrar.png")
# Audios (pon los archivos en la carpeta 'assets')
const MUSICA_FONDO: AudioStream = preload("res://assets/musica_fondo.mp3")
const SFX_DESTRUCCION: AudioStream = preload("res://assets/sfx_destruccion.mp3")
const SFX_MUERTE: AudioStream = preload("res://assets/sfx_muerte.mp3")
const SFX_DISPARO: AudioStream = preload("res://assets/sfx_disparo.mp3")
# Velocidades y tiempos
var vel_jugador = 500
var vel_asteroides = 250
var vel_disparo = 750
var vel_estrellas = 16
var vel_fondo = 8 # Muy lento: 8 px/s
var intervalo_asteroides = 0.75
# -------------------------
# ESTADO DEL JUEGO
# -------------------------
var boton_cerrar: Rect2
var jugador: Rect2
var asteroides: Array[Rect2] = []
var disparos: Array[Rect2] = []
var estrellas = []
var pos_fondo = 0
var tiempo_proximo_asteroide = 0.0
var muerto = false
var pausa = false
var pantalla: Vector2
var tiempo_total = 0.0
# UI
var etiqueta_tiempo: Label
# Controles
var tocando_izquierda = false
var tocando_derecha = false
# Audio players
var musica_fondo: AudioStreamPlayer
var sfx_destruccion: AudioStreamPlayer
var sfx_muerte: AudioStreamPlayer
var sfx_disparo: AudioStreamPlayer
# -------------------------
# CICLO DE VIDA PRINCIPAL
# -------------------------
func _ready():
# Ejecutar al iniciar la escena
randomize()
_inicializar_pantalla()
_inicializar_jugador()
_inicializar_estrellas()
_crear_ui_tiempo()
_crear_boton_cerrar()
_inicializar_audio()
_reproducir(musica_fondo)
func _process(delta: float):
# Ejecutar la lógica del juego en cada frame
if muerto: return
_mover_jugador(delta)
_mover_estrellas(delta)
_mover_fondo(delta)
_crear_asteroides(delta)
_mover_asteroides(delta)
_mover_disparos(delta)
_colisiones_disparos_asteroides()
_comprobar_colision_jugador()
_actualizar_tiempo(delta)
_actualizar_dificultad(delta)
queue_redraw()
func _draw():
# Dibujar en pantalla los elementos del juego
_mostrar_fondo_jugando()
_dibujar_estrellas()
_dibujar_asteroides()
_dibujar_disparos()
_dibujar_jugador()
_mostrar_boton_cerrar()
if muerto: _mostrar_game_over()
func _input(event: InputEvent):
# Comprobar la entrada de teclado, ratón o táctil
if muerto and pausa: return
_comprobar_pantalla_tactil_y_raton(event)
_comprobar_teclado(event)
# Estructura inicial con todas las funciones vacías.
# Más adelante iremos completándolas paso a paso.
# -------------------------
# INICIALIZACIÓN BÁSICA
# -------------------------
func _inicializar_pantalla():
# Guardar el tamaño del viewport de juego
pass
func _inicializar_jugador():
# Colocar la nave del jugador en la posición inicial
pass
# -------------------------
# ESTRELLAS DE FONDO
# -------------------------
func _inicializar_estrellas():
# Crear las estrellas del fondo con parallax
pass
func _mover_estrellas(delta):
# Desplazar las estrellas del fondo para simular movimiento
pass
func _dibujar_estrellas():
# Dibujar las estrellas en el fondo
pass
# -------------------------
# AUDIO
# -------------------------
func _crear_audio_player(stream: AudioStream, bus: String, volumen = 0.0):
# Crear un reproductor de audio con parámetros
pass
func _inicializar_audio():
# Preparar los reproductores de música y efectos de sonido
pass
func _reproducir(audio: AudioStreamPlayer):
# Reproducir un audio si está cargado
pass
# -------------------------
# JUGADOR
# -------------------------
func _dibujar_jugador():
# Dibujar la nave del jugador en pantalla
pass
func _mover_jugador(delta: float):
# Actualizar la posición de la nave según controles
pass
# -------------------------
# CONTROLES
# -------------------------
func _comprobar_pantalla_tactil_y_raton(event: InputEvent):
# Detectar toques en pantalla o clics con el ratón
pass
func _comprobar_teclado(event: InputEvent):
# Detectar teclas pulsadas o soltadas
pass
# -------------------------
# BOTÓN CERRAR
# -------------------------
func _crear_boton_cerrar():
# Crear el botón de cierre en la esquina superior derecha
pass
func _mostrar_boton_cerrar():
# Dibujar el icono de cierre en la esquina
pass
# -------------------------
# UI
# -------------------------
func _crear_ui_tiempo():
# Crear la etiqueta para mostrar el tiempo jugado
pass
func _actualizar_tiempo(delta: float):
# Actualizar el cronómetro del tiempo jugado
pass
# -------------------------
# ASTEROIDES
# -------------------------
func _crear_asteroides(delta: float):
# Generar nuevos asteroides en la parte superior
pass
func _mover_asteroides(delta: float):
# Hacer descender los asteroides hacia el jugador
pass
func _dibujar_asteroides():
# Dibujar los asteroides en pantalla
pass
# -------------------------
# DISPAROS
# -------------------------
func _crear_disparo():
# Crear un disparo nuevo desde la posición del jugador
pass
func _mover_disparos(delta: float):
# Mover los disparos hacia arriba
pass
func _dibujar_disparos():
# Dibujar los disparos en pantalla
pass
func _colisiones_disparos_asteroides():
# Detectar colisiones entre disparos y asteroides
pass
# -------------------------
# COLISIONES Y GAME OVER
# -------------------------
func _escalar_rect(r: Rect2):
# Reducir el área de colisión de un rectángulo
pass
func _comprobar_colision_jugador():
# Detectar si el jugador choca con un asteroide
pass
func _pausa():
# Hacer una pausa breve tras morir
pass
func _game_over():
# Ejecutar la lógica de Game Over
pass
func _reiniciar_juego():
# Reiniciar la partida desde el estado inicial
pass
# -------------------------
# FONDO Y CAPAS
# -------------------------
func _mostrar_fondo_jugando():
# Dibujar el fondo en movimiento
pass
func _mover_fondo(delta: float):
# Desplazar el fondo lentamente en bucle
pass
func _mostrar_game_over():
# Mostrar capa visual semitransparente de Game Over
pass
# -------------------------
# DIFICULTAD PROGRESIVA
# -------------------------
func _actualizar_dificultad(delta: float):
# Aumentar velocidad y frecuencia de asteroides con el tiempo
pass
inicializar_pantalla()
Cuando se abre un juego en Godot, todo lo que se dibuja ocurre dentro de un espacio rectangular llamado viewport. Ese viewport no es más que la “ventana” donde aparece nuestro juego. Puede ser una ventana en el escritorio, a pantalla completa en el ordenador, o la pantalla completa de un móvil.
Para programar correctamente necesitamos que el juego sepa cuánto mide esa ventana en cada momento (tanto de ancho como de alto). ¿Por qué es importante? Porque muchas de las acciones y cálculos que haremos más adelante dependen de conocer esos límites:
- Colocación del jugador: cuando iniciamos la partida, la nave debe aparecer centrada en la parte inferior. Para eso necesitamos saber el ancho y el alto de la pantalla, de manera que el cálculo sea siempre correcto, aunque el juego se ejecute en un monitor más grande o más pequeño.
- Creación de asteroides: cada asteroide se genera en una posición horizontal aleatoria, pero siempre dentro de los márgenes del juego. Si no conociéramos el tamaño de la pantalla, podríamos crearlos fuera del área visible.
- Dibujos de fondo y UI: desde el color de fondo hasta la posición del botón de cierre, todo se ajusta en base a las dimensiones de la ventana.
En resumen, esta función es como medir la pizarra antes de empezar a dibujar en ella: una vez sabemos el espacio que tenemos, podemos organizar todo lo demás sin miedo a que se salga de los bordes.
El método que utilizaremos en Godot es get_viewport_rect().size, que devuelve un Vector2 con el ancho y el alto de la pantalla actual. Guardaremos ese valor en la variable global pantalla para poder usarlo en todas las demás funciones.
Aquí está la implementación de la función:
func _inicializar_pantalla():
# Guardar el tamaño actual de la pantalla (ancho y alto)
pantalla = get_viewport_rect().size
A partir de ahora, cada vez que necesitemos saber los límites de nuestra ventana de juego, podremos usar la variable
pantalla.
Por sí sola, esta función no cambia nada visible en pantalla, pero es fundamental porque nos da la referencia con la que trabajarán todas las demás.
inicializar_jugador()
Después de haber guardado las dimensiones de la pantalla en la función anterior, el siguiente paso lógico es colocar al jugador en su posición inicial. En este caso, el jugador es una nave espacial que controlaremos horizontalmente para esquivar los asteroides y dispararles.
Para representar gráficamente la nave dentro de nuestro juego utilizamos un objeto Rect2. Un Rect2 en Godot no es más que un rectángulo definido por dos cosas:
- Una posición
(x, y)que marca la esquina superior izquierda. - Un tamaño
(ancho, alto)que indica cuánto mide ese rectángulo.
Ese rectángulo cumple un doble propósito en nuestro juego:
- Por un lado, sirve para dibujar la textura del jugador en la posición correcta de la pantalla.
- Por otro, nos servirá como caja de colisión (hitbox) para saber si el jugador choca con asteroides o interactúa con otros elementos.
¿Dónde colocamos al jugador?
La lógica que seguiremos es muy sencilla:
- En el eje horizontal (x) → queremos que la nave empiece centrada en la pantalla. Para ello, tomamos la anchura de la pantalla (
pantalla.x), la dividimos entre 2 (para ir al centro) y luego restamos la mitad del tamaño de la nave (TAM_JUGADOR / 2). Con ese cálculo conseguimos que la nave quede perfectamente alineada en el centro. - En el eje vertical (y) → queremos que la nave aparezca en la parte inferior, pero no exactamente pegada al borde. Para que quede un poco elevada (y no dé la sensación de estar cortada por abajo), usamos la altura de la pantalla (
pantalla.y) y le restamosTAM_JUGADOR * 1.25. Ese 1.25 es un pequeño ajuste visual que deja un margen. - Tamaño del rectángulo → tanto el ancho como el alto del rectángulo serán iguales a
TAM_JUGADOR, de modo que la nave tenga siempre la misma forma cuadrada definida en nuestras constantes.
¿Por qué es importante esta función?
Sin una posición inicial clara, el jugador podría aparecer en cualquier sitio: fuera de la pantalla, en una esquina o en un lugar poco lógico. Gracias a esta función, cada partida empieza siempre igual, con el jugador visible en el centro inferior. Esto da consistencia al juego y permite al jugador situarse rápidamente en la acción.
func _inicializar_jugador(): # Colocar al jugador centrado abajo de la pantalla con un pequeño margen jugador = Rect2( pantalla.x * 0.5 - TAM_JUGADOR * 0.5, # Coordenada X: centrado pantalla.y - TAM_JUGADOR * 1.25, # Coordenada Y: cerca del borde inferior TAM_JUGADOR, # Ancho del rectángulo TAM_JUGADOR # Alto del rectángulo )
Con esta función, la nave ya tiene una posición de inicio perfectamente definida. Todavía no es visible, porque solo hemos creado la “caja” que la representa en memoria. Para que realmente aparezca en pantalla, necesitaremos la siguiente función: dibujar_jugador(), que será la encargada de mostrar su textura.
inicializar_estrellas()
El fondo estrellado es una de las mejoras clave, porque introduce profundidad aparente sin recurrir a sprites extra ni shaders: con un simple listado de “estrellas” y un par de funciones de movimiento/dibujo logramos un efecto de parallax.
Esta función es el primer paso de ese sistema: sembrar el cielo con estrellas distribuidas aleatoriamente por toda la pantalla y, sobre todo, asignar a cada una una “profundidad” (un valor continuo entre 0 y 1) que después determinará su tamaño, brillo y velocidad. La idea didáctica es potentísima: cada estrella no es más que un diccionario con dos campos—pos (un Vector2 con su posición en píxeles) y profundidad (un float donde 0 significa “muy cerca” y 1 “muy lejos”). Con esa única pieza de estado, las otras dos funciones del módulo (mover y dibujar) pueden interpolar tamaño, brillo y velocidad con lerp, creando la ilusión de capas: las estrellas cercanas se verán más grandes, más brillantes y más rápidas; las lejanas, más pequeñas, más apagadas y más lentas. Este diseño, que separa de forma cristalina inicialización, lógica de movimiento y renderizado, sigue el mismo enfoque pedagógico de tu PDF anterior: primero sembramos datos coherentes; después, cada subsistema los transforma sin acoplamientos innecesarios.
En cuanto a los detalles prácticos:
- Distribución: usamos
randf_range(0, pantalla.x)yrandf_range(0, pantalla.y)para repartir estrellas por toda el área visible al empezar. Así el cielo nace “lleno” desde el primer frame, sin que el jugador vea “aparecer” estrellas de golpe. - Profundidad:
randf()nos da un valor uniforme entre 0 y 1. Esa única variable permitirá que en_mover_estrellas()calculemos la velocidad efectiva de cada estrella mezclandovel_estrellascon una fracción (p. ej.,vel_estrellas * 0.1para las lejanas), y en_dibujar_estrellas()escalemos su tamaño (lerp(TAM_ESTRELLAS, 1, z)) y modulemos su brillo (lerp(0.75, 0.25, z)), logrando el parallax sin fórmulas complicadas. - Limpieza e idempotencia: empezamos con
estrellas.clear()para garantizar que la función es reentrante (si la llamas tras un cambio de resolución, por ejemplo), y que el arreglo siempre queda en un estado bien definido.
En resumen: esta función solo siembra (estado inicial correcto y completo). El movimiento y el dibujo quedan delegados a sus funciones específicas para mantener el código enseñable, testeable y extensible (p. ej., añadir colores fríos/cálidos por capa, o introducir “meteoros” como una subclase de estrellas cercanas).
func _inicializar_estrellas():
# Rellenar la lista con estrellas aleatorias (posición en pantalla y profundidad para parallax)
estrellas.clear()
for _i in range(NUM_ESTRELLAS):
var x = randf_range(0.0, pantalla.x)
var y = randf_range(0.0, pantalla.y)
var profundidad = randf() # 0 = cerca, 1 = lejos
estrellas.append({ "pos": Vector2(x, y), "profundidad": profundidad })
Con esta implementación, al iniciar la partida ya hay
NUM_ESTRELLASdistribuidas por toda la pantalla, cada una con una profundidad distinta que más adelante controlará velocidad, tamaño y brillo mediante interpolaciones en_mover_estrellas()y_dibujar_estrellas(). El efecto práctico es que, sin sprites complejos ni shaders, obtenemos un efecto parallax : las estrellas “cercanas” (profundidad baja) se ven más grandes, más brillantes y se desplazan más deprisa, mientras que las “lejanas” (profundidad alta) son pequeñas, apagadas y lentas, creando sensación de profundidad y movimiento continuo que enmarca la acción del arcade.
mover_estrellas()
Esta función se encarga de darles vida a las estrellas en cada frame. La idea clave es que cada estrella se mueve a una velocidad distinta según su “profundidad”: las cercanas (profundidad ≈ 0) se desplazan más deprisa y las lejanas (profundidad ≈ 1) más despacio. Lo conseguimos interpolando una velocidad efectiva con lerp, mezclando una velocidad base (vel_estrellas) con su versión “lejana” (vel_estrellas * 0.1). Ese factor 0.1 es un coeficiente que podemos ajustar para cambiar la sensación de parallax.
Además, cuando una estrella sale por la parte inferior de la pantalla, la reciclamos: hacemos que vuelva a aparecer arriba (por encima del viewport) y le asignamos una nueva X aleatoria. Con esto la densidad de estrellas se mantiene constante sin crear ni destruir nodos; solo reciclamos datos en un array, que es una técnica eficiente y muy clara para explicar gestión de entidades en arcades simples.
func _mover_estrellas(delta):
# Desplazar estrellas según profundidad y reciclar al salir por abajo
for e in estrellas:
var velocidad = lerp(float(vel_estrellas), vel_estrellas * 0.1, e.profundidad)
e.pos.y += velocidad * delta
if e.pos.y > pantalla.y:
e.pos.y = -TAM_ESTRELLAS
e.pos.x = randf_range(0.0, pantalla.x)
A partir de ahora el fondo nunca está quieto: las estrellas se deslizan verticalmente con un ritmo que depende de su “capa”, construyendo un parallax muy legible: cerca = grande, brillante y rápida; lejos = pequeña, tenue y lenta (cuando lo combinemos con
_dibujar_estrellas()). El reciclaje por la parte superior evita “huecos” y mantiene la densidad visual sin coste extra de instanciación. En términos docentes, tus alumnos verán cómo una interpolación simple y un reciclaje controlado bastan para pasar de un fondo estático a un espacio vivo que contextualiza la acción sin distraer, reforzando la arquitectura datos→lógica→render que vertebra todo el proyecto.
dibujar_estrellas()
Tras crear las estrellas (_inicializar_estrellas()) y animarlas (_mover_estrellas()), ahora debemos mostrarlas en pantalla. Esta función convierte cada “estrella–dato” (diccionario con pos y profundidad) en píxeles renderizados usando dibujo inmediato. El truco para el parallax es derivar el tamaño y el brillo de la profundidad: si z = 0 consideramos la estrella cercana (más grande y luminosa), y si z = 1 la consideramos lejana (más pequeña y tenue). Con una sola variable continua (profundidad) podemos controlar varios rasgos visuales a la vez:
- Tamaño:
tam = lerp(TAM_ESTRELLAS, 1, z)hace que las cercanas usenTAM_ESTRELLASpx y las lejanas tiendan a 1 px. - Brillo:
a = lerp(0.75, 0.25, z)modula el alfa (transparencia) – más opacas las cercanas, más sutiles las lejanas. - Dibujo:
draw_texture_rect(TEX_ESTRELLA, Rect2(e.pos, Vector2(tam, tam)), false, Color(1,1,1,a))pinta cada estrella ya escalada y modulada. - Orden en
_draw(): conviene pintar fondo → estrellas → asteroides → disparos → jugador → overlays, para que las estrellas queden “bajo” el juego. Mantener este orden estable es una excelente lección sobre capas y composición del frame.
func _dibujar_estrellas():
# Dibujar cada estrella escalando tamaño y modulando brillo según su profundidad
for e in estrellas:
var z = e.profundidad
var tam = lerp(TAM_ESTRELLAS as float, 1.0, z)
var a = lerp(0.75, 0.25, z)
draw_texture_rect(TEX_ESTRELLA, Rect2(e.pos, Vector2(tam, tam)), false, Color(1, 1, 1, a))
Ahora ya tenemos un fondo dinámico y profundo: las estrellas cercanas se ven más grandes, más brillantes y “se mueven” más deprisa, mientras que las lejanas son más pequeñas, aportando contexto sin distraer. El coste computacional es mínimo (un bucle y un
draw_texture_rectpor estrella), y la técnica es extremadamente transferible: se pueden cambiar las texturas, curvas delerp, densidad o incluso añadir diferentes colores por capa sin tocar el resto del juego.
crear_audio_player()
En esta sección encapsulamos todo el “ritual” de preparar audio en Godot en una sola función: recibimos un AudioStream (la pista a reproducir), el nombre del bus de audio (por ejemplo "Music" o "SFX") y un posible ajuste de volumen. A cambio devolvemos un AudioStreamPlayer ya configurado y añadido al árbol. Esta función centraliza la configuración (stream, bus, volumen) y separa responsabilidades: aquí solo se crea y se deja listo; la reproducción se realizará en _reproducir().
func _crear_audio_player(stream: AudioStream, bus: String, volumen := 0.0):
# Instanciar y configurar un AudioStreamPlayer con stream, bus y volumen inicial, y devolverlo
var player = AudioStreamPlayer.new()
player.stream = stream
player.bus = bus
player.volume_db = volumen
add_child(player)
return player
Con esta función disponemos de un punto único de creación de audio que garantiza consistencia (todos los reproductores de audio se crean con los mismos parámetros clave), código más limpio en
_inicializar_audio(), y una arquitectura fácil de ampliar: si más adelante por ejemplo quieres añadir filtros, sólo tendrás que hacerlo en un único lugar.
inicializar_audio()
Después de tener lista la función _crear_audio_player(...), necesitamos instanciar y registrar todos los reproductores de sonido que usará el juego: música de fondo y tres SFX (disparo, destrucción y muerte).
En esta función guardamos referencias a cada AudioStreamPlayer en variables miembro (musica_fondo, sfx_*). Tenerlas accesibles facilita acciones posteriores (parar música en game_over, reproducir efectos en crear_disparo o colisiones_disparos_asteroides, etc.). Esta organización deja claro cómo se inyecta audio en un proyecto de Godot de forma limpia y escalable.
func _inicializar_audio():
# Construir y registrar players de música y SFX con buses/volúmenes apropiados
musica_fondo = _crear_audio_player(MUSICA_FONDO, "Music", -5.0)
sfx_destruccion = _crear_audio_player(SFX_DESTRUCCION, "SFX")
sfx_muerte = _crear_audio_player(SFX_MUERTE, "SFX")
sfx_disparo = _crear_audio_player(SFX_DISPARO, "SFX", -10.0)
Con sólo echar un vistazo a esta función podemos saber qué recursos sonoros vamos a utilizar en nuestro juego. A partir de ahora disponemos del código necesario para reproducir la música de fondo (
_reproducir(musica_fondo)en_ready()) o reproducir efectos de audio por ejemplo al colisionar o al disparar (_reproducir(sfx_disparo)), etc.Además, nuestro código sigue siendo modular: si en el futuro queremos ajustar volúmenes o añadir filtros/efectos, sólo tendremos que cambiar el código de esta función. Esta es una práctica habitual en proyectos con audio.
reproducir()
Tras centralizar la creación de reproductores de audio en _crear_audio_player(...) y organizarlos en _inicializar_audio(), conviene disponer de algún método para comenzar la reproducción. La función _reproducir(audio) cumple exactamente ese papel: recibe una referencia a un AudioStreamPlayer y se asegura de que existe y tiene un stream válido antes de llamar a play(). Aunque parezca trivial, esto es muy importante porque es posible que algún recurso no esté cargado, y debemos evitar errores y bloqueos, manteniendo el flujo del juego estable.
A esde otras funciones solo te preocupas de invocar _reproducir(sfx_disparo) o _reproducir(musica_fondo) y listo: el código que juega no sabe —ni necesita saber— si el player está en el bus “SFX”, si su volumen se ajustó, o si mañana decides envolverlo con effects en el Audio Bus Layout.
func _reproducir(audio: AudioStreamPlayer):
# Reproducir el AudioStreamPlayer si existir y tener stream válido para evitar errores en tiempo de ejecución
if audio and audio.stream:
audio.play()
Esta función nos permite iniciar sonidos y música, manteniendo la legibilidad del resto del código (la intención queda clara: “reproducir”) y facilita el mantenimiento y la evolución: si más adelante queremos por ejemplo aplicar un fade-in a la música, o comprobar si ya está sonando para no solapar, se puede hacer aquí, sin tocar el resto del código del proyecto.
dibujar_jugador()
Hasta ahora hemos definido el rectángulo lógico del jugador con _inicializar_jugador(): sabemos dónde está y cuánto mide, pero todavía es invisible. Esta función es la encargada de convertir esos datos en píxeles en pantalla, es decir, de pintar la nave en su posición actual en cada frame del juego. En Godot, cuando optamos por dibujo inmediato (en vez de usar nodos Sprite2D), el lugar adecuado para hacerlo es _draw(), que es donde el motor nos “presta el lienzo” del frame actual para trazar formas, imágenes y overlays. Nuestra función _dibujar_jugador() encapsula ese trabajo para mantener el código limpio: _draw() solo orquesta el orden de pintado y delega el dibujo concreto de cada sistema (jugador, asteroides, disparos, fondo) en sus funciones respectivas, lo que mejora la legibilidad y da estructura al proyecto (misma filosofía que usaste en la v1 del post/pdf).
¿Qué dibujamos exactamente?
Usaremos draw_texture_rect(texture, rect, tile := false, modulate := Color(1,1,1,1)). Esta llamada coloca una textura (la imagen de la nave, ya preloaded como TEX_JUGADOR) dentro del rectángulo jugador que definimos antes. Puntos clave:
- El tercer parámetro lo ponemos en
falsepara no “repetir baldosa” (tiling). Queremos una sola nave, no un mosaico. - Al pasar un
Rect2, Godot escalará la imagen para que llene exactamente ese rectángulo. Esto es perfecto si tu textura está diseñada con las mismas proporciones queTAM_JUGADOR(por ejemplo, 128×128). Si no, se deformaría (estiramiento no uniforme). Por eso, en proyectos educativos como este conviene que la textura del jugador sea cuadrada y del tamaño de referencia para evitar artefactos. - El color por defecto es blanco opaco; si alguna vez quisieras efectos (p. ej., “invencibilidad” con transparencia), podrías usar el cuarto parámetro
modulatepara atenuar (ej.:Color(1,1,1,0.5)).
Orden de pintado
En _draw() tiene importancia el orden: primero fondo, luego estrellas, obstáculos, disparos y al final el jugador (o en el lugar que decidas según prioridades visuales). Si más adelante pintas overlays (como el “Game Over”), estos deben ir después para que queden por encima de todo lo demás, tal y como hiciste en la primera versión (capa tintada/overlay) y como explicas en tu artículo original, manteniendo una jerarquía de capas clara.
El dibujo inmediato no crea nodos adicionales por cada entidad visible, lo que lo hace muy eficiente para arcades simples con muchos elementos que cambian cada frame (estrellas, disparos, asteroides). A cambio, tú te responsabilizas de dibujar cada frame lo que deba verse (si no llamas a queue_redraw(), no se repinta y podrías ver “fantasmas” del frame anterior). Esta separación “estado (datos) ↔ render (dibujo)” está muy alineada con el enfoque pedagógico de tu PDF: primero modelamos el mundo con Rect2 y listas, y luego lo proyectamos en pantalla función a función.
func _dibujar_jugador():
# Dibujar la textura del jugador dentro de su Rect2 (respetando el orden de capas)
draw_texture_rect(TEX_JUGADOR, jugador, false)
Con esto, la nave ya se ve. A partir de aquí, cada vez que
_draw()se ejecute,_dibujar_jugador()colocará la textura en la posición actual del rectángulojugador. Si la mueves en_mover_jugador(delta), este dibujo reflejará el cambio en el siguiente frame, haciendo que la sensación de movimiento sea fluida y consistente con el estado lógico del juego. Esta forma de trabajo —función por función, del esqueleto al resultado— replica el método docente de tu primer post y su PDF asociado, pero ampliado a las nuevas mecánicas de la v2.
mover_jugador()
Una vez capturemos la entrada de ratón o teclado y la hayamos traducido a variables de estado (tocando_izquierda, tocando_derecha), nos toca convertir esas intenciones en movimiento real. Esta función es el corazón de la locomoción horizontal: se ejecuta cada frame desde _process() y actualiza la posición de la nave proporcional al tiempo transcurrido. Ese “tiempo entre frames” es delta (en segundos). Usarlo es crucial: si multiplicáramos solo por una velocidad fija, la nave iría más rápida en equipos con más FPS y más lenta en equipos con menos FPS. Al escalar por delta, garantizamos velocidad constante independientemente del rendimiento de la máquina, una idea que recalcabas en tu post/PDF original y que es vital para juegos justos y predecibles.
El flujo es simple y muy legible:
- Determinar la dirección: partimos de
dir = 0(quieto). Sitocando_izquierdaestá activa,dir = -1; si no ytocando_derechalo está,dir = +1. Este esquema evita dobles pulsaciones simultáneas y deja claro qué orden prevalece (en tu código usasif/elif, así que “izquierda” gana si ambas estuvieran activas a la vez, lo cual es coherente con el control táctil de tocar a un lado). - Aplicar desplazamiento: sumamos
dir * vel_jugador * deltaa la coordenada X del rectángulojugador.vel_jugadores pixeles/segundo, de modo que “velocidad × delta” da pixeles en este frame; multiplicar pordirnos aporta el signo. - Encerrar en límites: con
clamp(x, min, max)nos aseguramos de que nunca salga de la pantalla. Elmines 0 (borde izquierdo) y elmaxespantalla.x - TAM_JUGADOR(borde derecho menos el ancho de la nave), manteniendo siempre visible la textura. Este patrón exacto —sumar desplazamiento y clipear— es el que mostraste en la versión anterior por su claridad y robustez.
Al centralizar el movimiento aquí (en vez de mover “desde el evento”), obtienes varias ventajas:
- Determinismo temporal: todo el movimiento sucede en el mismo paso del bucle, tras leer entradas y antes de dibujar.
- Extensibilidad: si más adelante quisieras añadir inercia, aceleración, easing o un tope de velocidad, este es el lugar natural.
- Depuración sencilla: puedes imprimir
dirojugador.positionpara explicar visualmente cómo cambian a lo largo del tiempo, algo muy útil en clase.
func _mover_jugador(delta: float):
# Calcular dirección (-1, 0, +1), aplicar velocidad con delta y limitar dentro de pantalla
var dir = 0
if tocando_izquierda:
dir = -1
elif tocando_derecha:
dir = 1
jugador.position.x = clamp(
jugador.position.x + dir * vel_jugador * delta,
0.0,
pantalla.x - TAM_JUGADOR
)
Con esto, el movimiento depende solo de
deltay de una velocidad definida, no de cuántos eventos o repeticiones de teclas haya, y el jugador nunca saldrá de los límites de la pantalla.Esta separación de código (entrada → estado, loop → movimiento, draw → representación) refuerza el aprendizaje paso a paso y evita mezclar responsabilidades.
comprobar_pantalla_tactil_y_raton()
Esta función es el puente entre las acciones físicas del jugador (tocar la pantalla o hacer clic con el ratón) y el estado interno con el que gobiernas el movimiento de la nave y los disparos. En la arquitectura que estamos siguiendo, no movemos la nave “desde el evento”; en su lugar, el evento solo activa o desactiva banderas (tocando_izquierda, tocando_derecha) que luego serán leídas en _mover_jugador(delta). De ese modo, toda la lógica de movimiento vive en el game loop (en _process), asegurando un comportamiento uniforme e independiente de la tasa de frames y evitando que pequeños “picos” de eventos hardware desincronicen la jugabilidad. Esta separación (evento → estado; loop → movimiento) es exactamente la que presentabas en tu primer post/PDF y facilita mucho la comprensión para tus alumnos.
Qué eventos nos interesan
Godot dispara distintos tipos de InputEvent. Aquí filtramos toque de pantalla (InputEventScreenTouch) y botón de ratón (InputEventMouseButton). Con eso cubrimos móvil/tablet y PC sin código duplicado. La función procesa dos momentos clave del input:
Pulsación (event.pressed == true)
- Cerrar la aplicación: si el toque/clic cae dentro del rectángulo
boton_cerrar(calculado en otra función), salimos de la app conget_tree().quit.call_deferred(). Llamarlo deferred evita conflictos si otras operaciones están en curso en el mismo frame. - Elegir la dirección: calculamos el centro horizontal de la nave (
centro_nave) y comparamos con laevent.position.x. Si el toque/clic queda a la izquierda, activamostocando_izquierda = truey apagamostocando_derecha; si queda a la derecha, al revés. Este gesto resulta intuitivo en móvil (pulsar “a un lado” para mover en esa dirección) y muy rápido en PC con ratón. - Disparar: en esta versión v2, cada pulsación también dispara (
_crear_disparo()). La elección de disparar al presionar, no al soltar, da una sensación de respuesta más inmediata.
Soltar (event.pressed == false)
- Detener el movimiento: al levantar el dedo o el botón del ratón, desactivamos ambas variables (
tocando_izquierda = falseytocando_derecha = false). Así la nave solo se mueve mientras el jugador mantiene la presión, comportamiento muy cómodo para pantallas táctiles.
Detalles prácticos y consideraciones
- En Godot 4,
InputEventMouseButtontienebutton_index; tu implementación no distingue botón y por tanto reaccionará a cualquier botón del ratón. Para restringir a botón izquierdo podrías comprobarevent.button_index == MOUSE_BUTTON_LEFT(opcional; tu texto del post ya dice que el disparo es con el izquierdo). - La detección del cierre usa
Rect2.has_point(position), que es perfecta para UI dibujada por “inmediate mode” (sinControlUI). - Recuerda que
_inputa nivel superior ya filtra cuandomuerto && pausa(tu condición de guardia): mientras dure la pausa breve tras morir, ignoramos el input, evitando estados raros. Esa lógica está en_input(...), y esta función asume que el evento ha sido “aprobado” para procesarse. - Si quisieras fuego continuo manteniendo pulsado, podrías combinar una bandera estilo
disparando = true/falsey disparar por rate limiting en_process. Pero para la claridad pedagógica de v2, disparo por pulsación es ideal.
func _comprobar_pantalla_tactil_y_raton(event: InputEvent):
# Gestionar toques/clics: cerrar si pulsar en botón, fijar dirección según lado de la nave y disparar al presionar
if event is InputEventScreenTouch or event is InputEventMouseButton:
if event.pressed:
if boton_cerrar.has_point(event.position):
if get_tree():
get_tree().quit.call_deferred() # Cerrar de forma segura
return
var centro_nave = jugador.position.x + TAM_JUGADOR * 0.5
if event.position.x < centro_nave:
tocando_izquierda = true
tocando_derecha = false
else:
tocando_derecha = true
tocando_izquierda = false
_crear_disparo() # Disparar al presionar (clic o toque)
else:
# Al soltar: detener movimiento
tocando_izquierda = false
tocando_derecha = false
Con esto, tu juego queda totalmente controlable en PC y en móvil: el jugador pulsa a la izquierda/derecha para moverse y, además, dispara en el mismo gesto, algo que simplifica el control táctil y hace la experiencia muy accesible. Esta aproximación minimalista está muy en línea con el enfoque didáctico de tu recurso original: menos UI, menos botones y más acción inmediata, todo ello apoyado por una separación clara entre entrada y lógica de movimiento.
comprobar_teclado()
En esta función centralizamos toda la interacción por teclado para que el juego sea cómodo en ordenador y, a la vez, consistente con el bucle principal. Siguiendo el patrón pedagógico del proyecto, no movemos la nave desde el propio evento: el evento solo actualiza banderas de estado (tocando_izquierda, tocando_derecha) y lanza acciones puntuales (reinicio y disparo). Después, el movimiento real se calcula en _mover_jugador(delta) dentro de _process, lo que asegura que la simulación dependa del tiempo (delta) y del orden del bucle, no del ritmo con que el sistema dispare eventos de teclado. Este desacoplamiento —detección en _input, resolución en _process— es el mismo esquema que explicabas en tu post/pdf original y facilita muchísimo la comprensión para el alumnado.
Mapa de entrada (Input Map) y por qué usar acciones
En lugar de comprobar teclas concretas (códigos físicos), trabajamos con acciones abstractas definidas en Project → Project Settings → Input Map:
ui_left: pestaña izquierda / tecla A (o las que añadas).ui_right: pestaña derecha / tecla D (o las que añadas).ui_accept: por defecto Enter/Espacio (la usaremos para reiniciar).ui_up: en esta versión la aprovechamos como botón de disparo adicional (útil en portátil sin ratón).
Programar contra acciones y no contra teclas fijas hace que el código sea portable y accesible: podrás cambiar el mapeo para mando, teclado alternativo o plataformas móviles sin tocar la lógica. Así lo defendías ya en la v1 y encaja perfecto con el enfoque docente del proyecto.
Flujo y decisiones de diseño
- Reinicio tras Game Over: si
muertoestruey el jugador pulsaui_accept, llamamos a_reiniciar_juego()y salimos de la función. Esto evita que, en el mismo frame del reinicio, se procesen otras entradas residuales (por ejemplo, que un Enter sostenido active también otra acción). La elección deui_accepttiene sentido pedagógico: en casi cualquier dispositivo, Enter o Espacio reinician, sin añadir botones en pantalla. - Disparo por teclado: además del clic/táctil, permitimos disparar con
ui_up. No es obligatorio (con ratón ya disparas), pero en portátiles o sobremesas sin ratón resulta muy cómodo. - Movimiento lateral: al presionar
ui_left/ui_rightactivamos la bandera correspondiente; al soltar, la desactivamos. Esta distinciónpressed/releasedevita depender del key repeat del sistema operativo y da un control preciso y continuo. - Consistencia con el control táctil/ratón: mantén presente que la lógica es simétrica a la de 6.5; ambas rutas de entrada convergen en las mismas banderas y el mismo algoritmo de movimiento.
Detalles prácticos
- Si quisieras evitar “pequeños arrastres” al cambiar rápido de izquierda a derecha, puedes ajustar
_mover_jugador(delta)para que el último pressed prevalezca (ya lo hace tu lógica actual al usarelif). - Si prefieres disparo automático al mantener
ui_up, cambia el disparo a una bandera (disparando = true/false) y aplica rate limiting en_process. Para la versión didáctica v2, mantenerlo en evento puntual es más claro.
func _comprobar_teclado(event: InputEvent):
# Gestionar reinicio con ui_accept, disparo con ui_up y banderas de movimiento con ui_left/ui_right
if muerto and event.is_action_pressed("ui_accept"):
_reiniciar_juego()
return
if event.is_action_pressed("ui_up"):
_crear_disparo()
if event.is_action_pressed("ui_left"):
tocando_izquierda = true
if event.is_action_released("ui_left"):
tocando_izquierda = false
if event.is_action_pressed("ui_right"):
tocando_derecha = true
if event.is_action_released("ui_right"):
tocando_derecha = false
Con esto, el esquema de entrada por teclado queda pulido y coherente con el resto del motor del juego: las teclas solo modifican estado y activan acciones instantáneas, mientras que el movimiento se calcula en otra función y a ritmo de
delta. Este patrón garantiza un comportamiento estable, reproducible y fácil de extender (p. ej., añadir turbo, freno, o key remapping) sin enredar la lógica del bucle principal.
crear_boton_cerrar()
Este paso define la zona interactiva del botón de cierre (la “X”) en la esquina superior derecha. Igual que con el jugador, utilizamos un Rect2 para representar su área clicable y, posteriormente, también para dibujarlo. Separar “definir/crear” (lógica) de “dibujar” (render) mantiene el código pedagógicamente claro: primero establecemos qué es y dónde está el botón; después decidimos cómo y cuándo se pinta. Colocarlo en la esquina superior derecha es tan simple como fijar su x en pantalla.x - TAM_BOTON_CERRAR (pegado al borde derecho, restando su propio ancho) y su y en 0 (arriba del todo). Al usar un tamaño cuadrado (TAM_BOTON_CERRAR × TAM_BOTON_CERRAR), evitamos desproporciones y simplificamos el hit test. Esta técnica ya aparecía en tu v1 como patrón de “UI minimalista con dibujo inmediato”: el botón no es un Control, sino un rectángulo que nosotros gestionamos y comprobamos con has_point(...) en la ruta de entrada; es ligero, transparente y muy didáctico.
Ventajas de este enfoque:
- Claridad conceptual: un botón no es “mágico”, es un rectángulo con una textura encima y una prueba de punto dentro.
- Portabilidad: el mismo patrón sirve para otros hit areas (p. ej., botones táctiles, zonas de HUD, power-ups).
- Control total: si quisieras márgenes de seguridad (ampliar la zona clicable sin cambiar el dibujo), basta con ajustar el
Rect2.
func _crear_boton_cerrar():
# Definir el área clicable del botón de cierre en la esquina superior derecha
boton_cerrar = Rect2(pantalla.x - TAM_BOTON_CERRAR, 0, TAM_BOTON_CERRAR, TAM_BOTON_CERRAR)
Con esto, ya existe en memoria un área de interacción. Todavía no se ve nada; para eso pasamos al siguiente punto, donde lo dibujaremos y, gracias a la lógica de entrada (6.5), cerrará la aplicación al pulsarlo. Este flujo —crear rectángulos lógicos y luego pintarlos— reproduce el método que explicaste en tu post/pdf original para asentar bien las ideas antes de añadir adornos visuales.
mostrar_boton_cerrar()
Tras definir el rectángulo del botón, necesitamos hacerlo visible. Igual que con la nave y los asteroides, utilizamos draw_texture_rect(...) para pintar la textura del icono (una “X” o similar) dentro del Rect2 boton_cerrar. Al trabajar con dibujo inmediato conviene decidir en qué orden se pinta la UI frente al resto: lo normal es dibujar el botón después del fondo y de los elementos del juego, pero antes de overlays como el “Game Over”. Así garantizamos que el botón se vea durante la partida y, si estás en la pantalla de fin, la capa semitransparente lo cubra (o que decidas explícitamente pintarlo por encima si quisieses permitir cerrar también desde ahí). Mantener esta jerarquía de capas de forma explícita —en _draw()— ayuda a tus alumnos a comprender cómo se construye la imagen final del frame sumando capas.
Un apunte visual práctico: si tu textura del botón no es cuadrada o no está preparada al tamaño exacto, al dibujarla dentro de un Rect2 cuadrado podría deformarse. En ejercicios didácticos como este, lo más sencillo es preparar un PNG cuadrado (por ejemplo, 32×32) que encaje perfecto con TAM_BOTON_CERRAR, evitando trabajo extra sobre aspect ratios.
func _mostrar_boton_cerrar():
# Dibujar la textura del botón de cierre dentro de su rectángulo clicable
draw_texture_rect(TEX_BOTON_CERRAR, boton_cerrar, false)
A partir de este punto, tu botón está visible y funcional: la comprobación del clic o toque se hace en 6.5 con
boton_cerrar.has_point(event.position)y, si el jugador pulsa dentro, llamas aget_tree().quit.call_deferred()para cerrar de forma segura. Este patrón —crear área, dibujar icono, chequear punto— es exactamente el que promovías en tu primera versión: simple, claro y portable a cualquier otra pieza de UI que quieras añadir más adelante.
crear_ui_tiempo()
Nuestro primer elemento de interfaz será un cronómetro de supervivencia que muestre, en todo momento, cuántos segundos llevamos vivos. Pedagógicamente, este paso es muy útil porque introduce a tus alumnos en el ecosistema de nodos de UI de Godot sin abandonar el enfoque de dibujo inmediato para el resto: aquí sí creamos un Label real (UI declarativa), lo configuramos y lo añadimos al árbol de la escena. Así se ve la diferencia entre “pintar píxeles” y “usar nodos de interfaz”, manteniendo ambos mundos separados y bien entendidos:
- Instanciamos un
LabelconLabel.new()y lo guardamos en la variable globaletiqueta_tiempo. - Texto inicial: arrancamos en
"0.0 s", de modo que el jugador ve el marcador desde el primer frame. - Tamaño de fuente: para que sea legible en distintas resoluciones, ajustamos el tamaño con
set("theme_override_font_sizes/font_size", TAM_TEXTO). Esto no obliga a crear un tema; basta con esta sobrescritura puntual, perfecta para ejercicios educativos. - Visibilidad: lo dejamos en
truedesde el principio para que el cronómetro aparezca sin necesidad de interacción adicional. - Añadimos el nodo al árbol con
add_child(etiqueta_tiempo). Por defecto, unLabelaparece en la esquina superior izquierda (coordenadas UI 0,0), que es un lugar natural para un contador. Si quisieras moverlo, podrías usaradd_theme_font_size_override,positionen un contenedor, o unMarginContainercon anclajes.
Este enfoque combina muy bien con tu filosofía en la v1: mantener el render del juego con dibujo inmediato para entender el pipeline de _draw(), pero aprovechar los nodos de UI para elementos textuales o interactivos estándar. Es lo mejor de ambos mundos y resulta extremadamente claro para el alumnado.
func _crear_ui_tiempo():
# Crear un Label para el cronómetro y añadirlo a la escena con tamaño de fuente legible
etiqueta_tiempo = Label.new()
etiqueta_tiempo.text = "0.0 s"
etiqueta_tiempo.set("theme_override_font_sizes/font_size", TAM_TEXTO)
etiqueta_tiempo.visible = true
add_child(etiqueta_tiempo)
Con esto, la UI ya muestra un contador. Todavía no avanza, porque ese será el trabajo de la siguiente función, que lo actualizará cada frame en función del tiempo real transcurrido.
actualizar_tiempo()
Ahora sí, toca hacer que el cronómetro cobre vida. La idea es muy similar a la del movimiento: usar delta (el tiempo en segundos desde el frame anterior) para que el contador avance con precisión, independientemente de los FPS del dispositivo. Si en un ordenador el juego va a 144 FPS y en otro a 30 FPS, ambos verán avanzar el tiempo al mismo ritmo porque sumamos tiempo real, no “frames”. Este principio —lógica dependiente del tiempo, no de los frames— es una de las lecciones más valiosas del proyecto.
Detalles importantes del diseño
- Solo sumamos tiempo si el jugador no ha muerto (
if not muerto:). Así, el contador se congela cuando llegamos a “Game Over”, reflejando la duración real de la partida. - Mostramos el tiempo con un decimal usando
snappedf(tiempo_total, 0.1). Podríamos mostrar milisegundos, pero para un arcade resulta más legible y suficiente un decimal (por ejemplo,12.7 s). Además, esto evitara un “bailoteo” de cifras que distraiga. - Construimos la cadena con
str(...) + " s". Si quisieras internacionalizar, podrías usartr()o reemplazar la “s” por un icono/texto, pero para un proyecto docente es ideal mantenerlo simple y explícito.
Por qué hacerlo en una función separada
Mantener esta lógica en su propia función (_actualizar_tiempo) tiene varias ventajas didácticas: el game loop en _process(delta) queda más limpio y modular; además, si más adelante quisieras pausar el tiempo (por ejemplo, en menús) o añadir bonificaciones (sumar/retar tiempo), ya tienes el bloque listo para extender sin mezclar conceptos.
func _actualizar_tiempo(delta: float):
# Acumular delta si seguimos vivos y actualizar el Label con tiempo redondeado a 0.1 s
if not muerto:
tiempo_total += delta
etiqueta_tiempo.text = str(snappedf(tiempo_total, 0.1)) + " s"
A partir de ahora podremos ver los segundos que han transcurrido desde que empezó la partida.
crear_asteroides()
Aquí empezamos a poblar el espacio con obstáculos. La idea es sencilla y muy didáctica: mantenemos un temporizador acumulado (tiempo_proximo_asteroide) que suma delta cada frame; cuando supera intervalo_asteroides, disparamos la creación de un nuevo asteroide y reseteamos el acumulador. Este patrón (acumulador + umbral) ilustra el principio de “acciones por intervalo” sin necesidad de Timers ni señales, perfecto para ver la relación entre tiempo y lógica dentro de _process.
A continuación enumeramos las consideraciones que utiliza esta función para generar asteroides diferentes:
- Posición X aleatoria dentro de pantalla:
x = randf_range(0, pantalla.x - TAM_ASTEROIDE). - Tamaño aleatorio entre la mitad y el doble del base: más pequeños → más esquivables; más grandes → más tensión.
- Entrada desde fuera de cámara: colocamos el rectángulo por encima (y negativo) para que “entre” cayendo.
Esta diversidad reproduce un flujo de juego vivo incluso sin animaciones complejas, y refuerza la comprensión de arrays dinámicos (asteroides: Array[Rect2]) y la representación de entidades como datos (Rect2) que luego se dibujan con una textura. El alumnado ve claramente “datos → render”.
func _crear_asteroides(delta: float):
# Acumular tiempo y crear un asteroide cuando se supera el intervalo configurado
tiempo_proximo_asteroide += delta
if tiempo_proximo_asteroide >= intervalo_asteroides:
tiempo_proximo_asteroide = 0.0
var x := randf_range(0.0, pantalla.x - TAM_ASTEROIDE)
var tam := randf_range(TAM_ASTEROIDE * 0.5, TAM_ASTEROIDE * 2.0)
var asteroide := Rect2(x, -tam, tam, tam) # Entrar desde arriba del viewport
asteroides.append(asteroide)
Con esto, el juego empieza a producir amenazas a ritmo constante (que luego intensificaremos más adelante).
Este método refuerza el uso de
deltay delArraycomo contenedor de entidades activas.
mover_asteroides()
Los asteroides creados arriba deben descender con una velocidad controlada por vel_asteroides. Esta función es un espejo de _mover_jugador(): de nuevo, movemos en función de delta (independencia de FPS) y operamos sobre una colección. Aquí, además, introducimos un patrón imprescindible: limpieza de entidades que abandonan el área de juego. Mantener esa “higiene” evita que el array crezca indefinidamente con objetos ya invisibles que gastarían memoria y CPU sin aportar nada.
Para poder borrar los asteroides que salgan de la pantalla deberemos recorrer el array de atrás hacia delante (índices decrecientes), ya que cuando vayamos a eliminar un elemento del array (remove_at(i)), debemos tener mucho cuidado y no invalidar los índices pendientes de recorrer. Como alternativa, podríamos crear una nueva lista con filter y reasignar (muy legible, aunque realizamos una copia del array, lo que incrementa el coste computacional).
Nosotros vamos a optar por la opción del bucle inverso con remove_at, que es excelente para enseñar el problema clásico de “eliminar mientras iteras”:
func _mover_asteroides(delta: float):
# Desplazar asteroides hacia abajo y eliminar los que salen por la parte inferior
for i in range(asteroides.size() - 1, -1, -1):
asteroides[i].position.y += vel_asteroides * delta
if asteroides[i].position.y > pantalla.y:
asteroides.remove_at(i)
Aquí observamos tres ideas fundamentales muy reutilizables en juegos 2D:
- Movimiento uniforme por
delta.- Gestión de arrays de entidades.
- Saneamiento de objetos fuera de cámara.
dibujar_asteroides()
Como hicimos con la nave, ahora proyectamos a pantalla los datos de nuestros asteroides. Mantenemos la separación entre datos y render: cada Rect2 del array asteroides se dibuja en _draw() delegando el trabajo a esta función, lo que refuerza la modularidad (“_draw orquesta, las funciones específicas pintan”). Usamos draw_texture_rect(TEX_ASTEROIDE, rect, false) para que cada rectángulo se llene con la textura del asteroide.
func _dibujar_asteroides():
# Dibujar cada asteroide usando su Rect2 como destino para la textura
for a in asteroides:
draw_texture_rect(TEX_ASTEROIDE, a, false)
Con esto, los asteroides pasan de ser “datos que caen” a objetos visibles que el jugador puede percibir y esquivar. En la secuencia del post (igual que en tu documento original), ya tenemos: se crean, se mueven y se dibujan. El siguiente bloque natural será introducir disparos y colisiones, y más adelante la pantalla de Game Over y el incremento progresivo de dificultad, para cerrar el bucle de juego.
crear_disparo()
Para introducir una mecánica ofensiva simple y didáctica, añadimos disparos rectangulares que salen desde la parte superior de la nave y viajan hacia arriba. Esta función instancia el proyectil como un Rect2 y lo apila en la lista disparos. El proceso es exactamente el mismo que con los asteroides: los proyectiles son datos (rectángulos) que luego moveremos y dibujaremos en sus funciones correspondientes. Este patrón —“colección de entidades” → “bucle de movimiento” → “dibujo inmediato”— refuerza la arquitectura que ya utilizábamos en la primera versión del juego. Además, aquí reproducimos un sonido (sfx_disparo) para dar feedback inmediato, subrayando el bucle “acción → respuesta audiovisual”.
En este punto, debemos tener en cuenta algunas consideraciones de diseño para conseguir cierto realismo con nuestros disparos:
- Origen del disparo: centramos el rectángulo del proyectil respecto al centro horizontal de la nave, colocándolo justo por encima de ella para evitar solapes visuales.
- Tamaño y colisión: usamos
TAM_DISPAROpara que sea consistente y fácil de leer. ConRect2puedes aprovecharintersectsen la detección contra asteroides sin depender aún deArea2D. - Acoplamiento mínimo: esta función solo crea y añade a la lista; no decide velocidades ni colisiona. Eso mantiene una separación clara de responsabilidades entre las diferentes funciones del código.
func _crear_disparo():
# Crear un Rect2 para el proyectil centrado en la nave y enlistarlo, reproduciendo SFX de disparo
var x = jugador.position.x + TAM_JUGADOR * 0.5 - TAM_DISPARO * 0.5
var y = jugador.position.y - TAM_DISPARO
disparos.append(Rect2(x, y, TAM_DISPARO, TAM_DISPARO))
_reproducir(sfx_disparo)
Con esto ya podemos generar disparos desde cualquier ruta de entrada: clic/táctil, o teclado. Ahora podemos observar claramente cómo una acción del jugador se traduce en nueva entidad en el juego.
mover_disparos()
Exactamente igual que con asteroides (pero en sentido inverso), aquí actualizamos la posición de cada proyectil en cada frame (desde _process(delta)) usando delta para garantizar independencia de FPS. Como viajan hacia arriba, restamos a y el desplazamiento vel_disparo * delta. Además, realizamos la limpieza de proyectiles que ya han salido por la parte superior (y ≤ 0), eliminándolos del array. Este patrón (for inverso + remove_at) ya lo has trabajado con asteroides y es ideal para enseñar las buenas prácticas de gestión de colecciones mutables en tiempo real.
func _mover_disparos(delta: float):
# Desplazar proyectiles hacia arriba y eliminar los que salen por el borde superior
for i in range(disparos.size() - 1, -1, -1):
disparos[i].position.y -= vel_disparo * delta
if disparos[i].position.y <= 0.0:
disparos.remove_at(i)
En esta función podemos observar que utilizamos un código similar para los asteroides y los disparos, cambiando solo la dirección en la que se mueven, y el borde por el cual desaparecen.
dibujar_disparos()
Tal como hacemos con el resto de entidades, convertimos cada rectángulo de disparos en imagen mediante dibujo inmediato. Con draw_texture_rect(TEX_DISPARO, rect, false) llenamos el Rect2 con la textura del proyectil. Es coherente mantener el orden en _draw() para que los disparos se vean por encima del fondo y de las estrellas, pero por debajo o encima del jugador según tu preferencia estética (normalmente, encima del fondo y bajo la UI). Este paso remarca el flujo “estado → render” que estructura todo el proyecto.
func _dibujar_disparos():
# Pintar cada disparo con su textura correspondiente sobre el rectángulo destino
for d in disparos:
draw_texture_rect(TEX_DISPARO, d, false)
Una vez hayamos implementado este patrón, podríamos añadir balas especiales (más grandes, múltiples, con animación) sin tocar la infraestructura básica del juego.
colisiones_disparos_asteroides()
Cerramos el ciclo “ofensivo” con la detección de impactos: cada disparo que intersecte un asteroide lo destruye (eliminamos ambos rectángulos de sus listas) y reproducimos sfx_destruccion. La implementación recorre las dos colecciones en bucles inversos para permitir remove_at sin romper índices.
func _colisiones_disparos_asteroides():
# Detectar impactos entre disparos y asteroides, eliminarlos y reproducir SFX de destrucción
if disparos.is_empty() or asteroides.is_empty():
return
for a in range(asteroides.size() - 1, -1, -1):
for d in range(disparos.size() - 1, -1, -1):
if asteroides[a].intersects(disparos[d]):
asteroides.remove_at(a)
disparos.remove_at(d)
_reproducir(sfx_destruccion)
break
Con esta función conseguimos que cada disparo elimine asteroides cuando se produzca una colisión, y que además se reproduzca un efecto de sonido al mismo tiempo.
escalar_rect()
En muchos juegos 2D, la imagen visible (sprite/texture) tiene bordes irregulares o partes “decorativas” (brillos, humos, alas) que no deberían contar a efectos de choque. Si usamos la caja exacta del sprite, los impactos se sienten injustos (“me ha tocado por un píxel”). Para suavizar la experiencia, es habitual reducir ligeramente la hitbox: seguimos usando un Rect2 para las colisiones, pero lo encogemos un porcentaje configurable (FACTOR_HITBOX). Así, la sensación para el jugador es que “si roza, no cuenta; si impacta de verdad, sí”. Esta función implementa ese ajuste y se convierte en una pieza reutilizable en cualquier arcade.
Técnicamente, explotamos Rect2.grow(margin): con margen negativo el rectángulo se encoge por todos los lados la misma cantidad. Para que el encogimiento sea proporcional al tamaño actual, calculamos el margen a partir del ancho (r.size.x) y de nuestro factor. Por ejemplo, con FACTOR_HITBOX = 0.75, cada lado se retrae un 12.5 % del ancho (porque el 25 % total se reparte entre los dos lados). Este enfoque es estable para sprites escalados dinámicamente (como nuestros asteroides de tamaño aleatorio) y mantiene una relación visual coherente entre lo que se ve y lo que “cuenta”.
En resumen, primero definimos la colisión con algo tan simple como un rectángulo, y luego ajustamos la hitbox con una función muy sencilla:
func _escalar_rect(r: Rect2):
# Encoger uniformemente la hitbox según FACTOR_HITBOX para hacer la colisión más justa
return r.grow((FACTOR_HITBOX - 1.0) * r.size.x / 2.0)
Con esta pieza pequeña y reutilizable, desacoplamos la noción de colisión justa del resto del sistema. Si mañana quisieras otro estilo (p. ej., elíptico o por píxeles), basta con cambiar solo esta función o su uso, sin tocar la lógica que detecta intersecciones.
comprobar_colision_jugador()
Aquí resolvemos cuándo termina la partida por choque directo entre la nave y cualquier asteroide:
- Calculamos la hitbox ajustada del jugador con
_escalar_rect(jugador). - Recorremos todos los asteroides y comparamos contra su hitbox ajustada.
- Si dos rectángulos intersectan, la colisión está probada: cambiamos el estado del juego delegando en
_game_over().
Este enfoque mantiene la separación del código del juego: detección (geometría sencilla) por un lado y gestión del estado/efectos por otro (parar música, mostrar overlay, congelar lógica). El uso de Rect2.intersects() mantiene el código muy legible y, junto con _escalar_rect, constituye un esqueleto ideal para implementar colisiones sencillas.
func _comprobar_colision_jugador():
# Comprobar intersección entre la hitbox ajustada del jugador y la de cada asteroide, y finalizar partida si chocar
var jugador_escalado = _escalar_rect(jugador)
for a in asteroides:
if jugador_escalado.intersects(_escalar_rect(a)):
_game_over()
return
Con esto, enlazamos mundo lógico (rectángulos y arrays) con cambios de estado del juego. No hace falta un sistema complejo para conseguir un resultado convincente y, sobre todo, comprensible: geometría básica + una buena organización de funciones.
pausa()
Después de un Game Over conviene dar un pequeño colchón temporal antes de aceptar nuevas entradas. Pedagógicamente es muy útil para mostrar cómo orquestar tiempos en Godot sin bloquear el juego: en lugar de “dormir” el hilo (lo cual sería mala práctica), creamos un temporizador con get_tree().create_timer(PAUSA_GAME_OVER) y esperamos su señal usando await … .timeout. Durante esa ventana, la variable pausa queda en true, y el _input(event) ya está protegido con una guardia (if muerto and pausa: return), así que se ignora cualquier tecla/clic. Esto evita reinicios accidentales por la misma pulsación que causó la muerte, permite que suene el SFX de muerte y que el jugador perciba el overlay de fin de partida. Es, además, una oportunidad excelente para explicar asincronía simple en Godot 4: await cede el control al motor y reanuda la función cuando vence el temporizador, sin congelar el resto del juego ni la interfaz.
func _pausa():
# Activar una pausa breve no bloqueante tras el Game Over esperando a un temporizador asincrónico
pausa = true
await get_tree().create_timer(PAUSA_GAME_OVER).timeout
pausa = false
Con esta función establecemos un periodo de seguridad inmediatamente tras morir en el que no se procesa input, evitando reinicios involuntarios y reforzando la puesta en escena (SFX de muerte + overlay visible). Al usar
awaitcon unSceneTreeTimer, la pausa es no bloqueante: el motor sigue dibujando y actualizando otros sistemas que quieras mantener activos, y al cumplirse el tiempo (PAUSA_GAME_OVER, por ejemplo 1 segundo) la variablepausavuelve afalsey el flujo normal puede continuar (p. ej., aceptarui_acceptpara reiniciar).
game_over()
Llegado el momento del impacto (cuando la nave colisiona con un asteroide), el juego debe transitar con orden a un estado de fin de partida: detener la lógica jugable, comunicar de forma clara lo sucedido y preparar el terreno para un reinicio limpio. Esta función orquesta precisamente esa transición. Didácticamente, es muy valiosa porque concentra en un solo sitio las decisiones de cambio de estado: la variable muerto pasa a true (con lo que _process(delta) deja de mover jugador/asteroides/disparos), las banderas de control se apagan (evitando “arrastres” de entrada), la música se detiene y se dispara el SFX de muerte, y (si has añadido un Label para el mensaje, como en 6.12) se actualiza y muestra el texto de Game Over junto con el tiempo sobrevivido. Por último, lanzamos una pausa breve (con _pausa()) que “acolcha” la experiencia: el jugador ve lo ocurrido, escucha el efecto y, pasados unos instantes, ya puede reiniciar. Esta estrategia de empaquetar la transición en una única función (estado → UI → audio → pausa) replica el enfoque de tu recurso original, donde la claridad y la modularidad priman sobre “parches” repartidos por el código.
func _game_over():
# Marcar estado de muerte, detener controles y música, mostrar mensaje y lanzar pausa breve
muerto = true
tocando_izquierda = false
tocando_derecha = false
if musica_fondo:
musica_fondo.stop()
_reproducir(sfx_muerte)
_pausa()
Con esto, el juego queda “congelado” de manera coherente: no se siguen creando/moviendo entidades, el overlay de Game Over se pintará en
_draw()(verás la escena oscurecida) y el jugador recibe una señal sonora clara de que la partida terminó. Es exactamente la atmósfera que buscas en un arcade educativo: feedback audiovisual inmediato, estado estable y un reinicio sencillo.
reiniciar_juego()
Una vez mostrado el Game Over, la acción natural es empezar de nuevo. En Godot, la forma más sencilla de reiniciar es recargar la escena actual. Así evitamos tener que “resetear a mano” cada lista y cada variable (asteroides, disparos, contadores, flags…). Con get_tree().reload_current_scene() el motor destruye la escena en curso y crea una copia fresca del estado inicial definido por el script (constantes, variables inicializadas, recursos precargados, etc.).
func _reiniciar_juego():
# Recargar la escena actual para devolver todo a su estado inicial
get_tree().reload_current_scene()
Con esto, al pulsar Enter/Espacio tras morir (o el control que prefieras), la escena vuelve a su estado base: nave centrada, arrays vacíos, cronómetro a cero, música lista… La experiencia es instantánea y predecible, justo lo que buscamos en un mini-arcade que sirva de plantilla para aprender.
mostrar_fondo_jugando()
Esta función es la responsable de pintar el fondo desplazándose en bucle, una de las mejoras visuales clave de esta nueva versión del juego. El planteamiento es muy didáctico y reutilizable: en lugar de mover una única imagen hasta que “desaparezca”, dibujamos dos copias de la misma textura (TEX_FONDO) apiladas verticalmente, separadas exactamente por la altura del viewport. El desplazamiento vertical actual lo llevamos en una variable pos_fondo (que actualizamos en mover_fondo()), y con ella colocamos las dos imágenes de la siguiente forma:
- Una copia en
(0, pos_fondo)y - Otra justo “por encima”, en
(0, pos_fondo - pantalla.y).
Cuando pos_fondo crece y supera la altura de pantalla, la parte inferior ya tapa por completo a la superior; en el siguiente frame, gracias a wrapf en mover_fondo(delta), pos_fondo se “reinicia” dentro del rango y el patrón vuelve a encajar como un scroll infinito sin cortes. Este truco de “dos draw + aritmética modular” permite enseñar un parallax simple sin TileMap ni shaders: si además combinas el fondo lento con estrellas que se mueven a distinta velocidad (capas “rápidas” y “lentas”), el jugador percibe con claridad la sensación de profundidad (fondo → estrellas → juego → overlays).
func _mostrar_fondo_jugando():
# Pintar dos copias del fondo (offset y offset-altura) para simular scroll vertical infinito
draw_texture_rect(TEX_FONDO, Rect2(0, pos_fondo, pantalla.x, pantalla.y), false)
draw_texture_rect(TEX_FONDO, Rect2(0, pos_fondo - pantalla.y, pantalla.x, pantalla.y), false)
Logramos un scroll continuo y suave del fondo que nunca se corta y que sirve como base del “viaje” espacial del arcade. Combinado con
mover_fondo()(que limitapos_fondoconwrapf) y con el módulo de estrellas (donde el movimiento depende de la profundidad), obtenemos un parallax convincente a coste computacional mínimo: solo dosdraw_texture_rectpor frame para el fondo, más un bucle ligero para las estrellas.En resumen, esta función consigue utilizar un offset cíclico para generar continuidad visual sin estructuras complejas.
mover_fondo()
El fondo desplazándose da “vida” a la escena aunque el jugador esté quieto. Esta función actualiza un offset vertical (pos_fondo) que usamos para pintar dos copias del fondo en mostrar_fondo_jugando(). El truco es avanzar ese offset de forma proporcional al tiempo real (delta) con una velocidad muy baja (vel_fondo) para que el scroll sea suave y no distraiga. Cuando el offset supera la altura del viewport, lo reiniciamos dentro del rango con wrapf. Usar wrapf en lugar de un “if + resta” evita el acumulado de error y funciona incluso si —por un pico de delta— el offset se desplazase más de una altura en un solo frame.
En resumen, todo el cálculo se basa en una sola variable (pos_fondo), cuyo valor se mantiene siempre en [0, pantalla.y) consiguiendo de forma sencilla todos los objetivos que nos habíamos propuesto:
- Movimiento dependiente del tiempo (no de FPS).
- Parámetro de velocidad fácilmente ajustable.
- Bucle infinito de movimiento sin saltos visuales.
func _mover_fondo(delta: float):
# Actualizar offset vertical del fondo con velocidad constante y envolverlo para scroll infinito
pos_fondo = wrapf(pos_fondo + vel_fondo * delta, 0.0, pantalla.y)
A partir de ahora realizaremos un scroll vertical continuo y estable del fondo, independiente de los FPS y sin cortes cuando se repite la textura. Combinada con
mostrar_fondo_jugando()(que dibuja dos copias separadas por la altura de pantalla) obtenemos un bucle perfecto: al llegar al final, el offset vuelve al inicio y el patrón encaja otra vez. Si más adelante subimos ligeramentevel_fondoenactualizar_dificultad(), tendremos la sensación de que la partida acelera sin necesidad de efectos adicionales, reforzando la sensación de progresión con un coste computacional mínimo.
mostrar_game_over()
Mediante esta función generamos un overlay gráfico de Game Over que tapa sutilmente la escena con una textura semitransparente (TEX_GAME_OVER). Este overlay demuestra cómo componer capas para mostrar estados del juego sin necesidad de cambiar de escena o borrar entidades. El jugador ve exactamente dónde murió (nave, asteroides y disparos congelados), pero bajo un velo que indica que la partida ha terminado.
Técnicamente, dibujamos la textura cubriendo toda la pantalla con un Color(1,1,1,0.5) (por ejemplo) que le otorga transparencia. El lugar correcto para pintarla es al final de _draw(), condicionado por if muerto: _mostrar_game_over(), para que quede por encima de fondo, estrellas y sprites.
func _mostrar_game_over():
# Cubrir la pantalla con la textura de Game Over modulada para crear un velo semitransparente
draw_texture_rect(TEX_GAME_OVER, Rect2(Vector2.ZERO, pantalla), false, Color(1, 1, 1, 0.5))
El resultado es limpio y elegante: no cortamos de golpe la escena ni la sustituimos por otra; mostramos el «game over» con una capa visual y dejamos la lógica congelada (gracias a
muerto = true).
actualizar_dificultad()
Para que el juego tenga curva de tensión, incrementamos gradualmente la dificultad. Esta función demuestra cómo parametrizar el reto sin trucos súbitos: cada frame añadimos un pequeño incremento inc_velocidad = INC_VELOCIDAD * delta a varios subsistemas:
vel_asteroides→ caen cada vez más rápido, elevando la exigencia de reflejos.vel_estrellas→ el parallax acelera ligeramente (× 0.1) para apuntalar la sensación de velocidad global sin distraer.vel_fondo→ el fondo gana un pelín de velocidad (× 0.01) como corolario visual sutil.intervalo_asteroides→ se reduce con un factor leve (× 0.001 del incremento), aumentando la frecuencia de aparición. Lo acotamos conmax(0.1, ...)para evitar el colapso (no más de 10 por segundo).
func _actualizar_dificultad(delta: float):
# Incrementar gradualmente la velocidad global y reducir el intervalo de spawn con límites seguros
var inc_velocidad = INC_VELOCIDAD * delta
vel_asteroides += inc_velocidad
vel_estrellas += inc_velocidad * 0.1
vel_fondo += inc_velocidad * 0.01
intervalo_asteroides = max(0.1, intervalo_asteroides - inc_velocidad * 0.001)
Con esta función, nuestro juego arcade se transforma durante la sesión de juego: gana ritmo y cada vez nos obliga a esforzarnos un poco más, ofreciendo partidas más intensas.
Ejercicios propuestos
A continuación te proponemos una serie de ejercicios prácticos para que experimentes creando tu propia versión del juego Asteroides en Godot. La idea es que cambies el código fuente del juego, las imágenes y los sonidos, y observes cómo los distintos cambios afectan a la jugabilidad y a la experiencia del jugador. Te sugerimos que ajustes los parámetros que establecen la dificultad del juego (velocidad de los asteroides, precisión de las colisiones o ritmo de progresión) y además también es muy importante que cambies aspectos estéticos (cantidad y tamaño de los objetos o recursos gráficos) según tus propios gustos y preferencias.
Algunas de las páginas web que te pueden ayudar a completar estos ejercicios son las siguientes:
- OpenGameArt (https://opengameart.org/): repositorio de gráficos y recursos libres para videojuegos.
- Kenney (https://kenney.nl/): colección de assets gratuitos y de calidad profesional para juegos.
- Qwen (https://chat.qwen.ai/) y Google AI Studio (https://aistudio.google.com/): entornos online gratuitos con inteligencia artificial para generar y modificar imágenes, obtener sugerencias de colores, etc.
- I love IMG (https://www.iloveimg.com/es): herramienta práctica para recortar, redimensionar, quitar fondo o convertir imágenes.
- Generadores de sonidos sencillos:
- Sonidos de stock:
- 123APPS (https://online-audio-converter.com/sp/): Conversor de formato de audio.
- Suno (https://suno.com/): Generación de música mediante inteligencia artificial.
Modificar la velocidad inicial de los objetos
Cambia el valor de las siguientes variables para que los diferentes objetos se muevan más rápidos o más lentos:
vel_jugador,vel_asteroides, yvel_disparo. Comprueba cómo afectan los diferentes valores a la dificultad de las partidas.
Modificar el incremento de la dificultad
Modifica el valor de la constante
INC_VELOCIDADy la variableintervalo_asteroidesy observa cómo cambia también la dificultad del juego a medida que avanza la partida.
Cambiar la cantidad y el tamaño de estrellas
Cambia el valor de las constantes
NUM_ESTRELLASyTAM_ESTRELLAS. Prueba diferentes valores y observa el resultado.
Ajustar el tamaño de la nave, los asteroides, y los disparos
Modifica las constantes
TAM_JUGADOR,TAM_ASTEROIDEyTAM_DISPAROpara que los objetos se vean más grandes o más pequeños. Observa cómo un cambio de tamaño afecta a la dificultad.
Modificar la detección de colisiones
Cambia el valor de
FACTOR_HITBOX(que se utiliza en la función_escalar_rect()) para ajustar el tamaño de la «hitbox». Si lo haces más grande, las colisiones serán más estrictas; si lo haces más pequeño, será más fácil esquivar los asteroides. Encuentra el valor que te parezca más equilibrado.
Cambiar las imágenes del juego
Busca o genera con IA (o dibuja tú mismo) nuevas imágenes para el fondo, las estrellas, la pantalla de game over, el jugador, los asteroides, el disparo y el botón de cerrar. Si cambias los nombres de los ficheros, actualiza las variables correspondientes (por ejemplo
TEX_JUGADOR,TEX_ASTEROIDE, etc.) para utilizar el nombre de tus imágenes y juega para ver cómo cambia la estética del juego.
Cambiar la música de fondo y los archivos de audio
Busca o genera con IA (o graba tú mismo) una música diferente de fondo y nuevos sonidos para los disparos, la destrucción de los asteroides y la muerte. Si cambias los nombres de los ficheros, actualiza las variables correspondientes (por ejemplo
MUSICA_FONDO,SFX_DESTRUCCION, etc.) para utilizar el nombre de tus sonidos.
Cambiar la posición inicial del jugador
En la función
_inicializar_jugador(), modifica la fórmula de la posición para que la nave no empiece centrada, sino más a la izquierda, a la derecha o incluso más arriba. Comprueba si esto hace el juego más fácil o más complicado.
El juego completo
Desde el siguiente enlace te puedes descargar un ZIP con todo el código del proyecto:
El resultado
Desde Godot podemos exportar este mismo proyecto para poder jugar en cualquier navegador. Puedes ver el resultado y jugar directamente mediante el siguiente enlace: