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 (Rect2 y 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

Fondo
Game Over
Jugador
Asteroide
Disparo

Música de fondo y efectos de sonido

Música de fondo
Disparo
Destrucción
Muerte

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.png
    • musica_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:

  1. Una posición (x, y) que marca la esquina superior izquierda.
  2. 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 restamos TAM_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) y randf_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 mezclando vel_estrellas con una fracción (p. ej., vel_estrellas * 0.1 para 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_ESTRELLAS distribuidas 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 usen TAM_ESTRELLAS px 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_rect por estrella), y la técnica es extremadamente transferible: se pueden cambiar las texturas, curvas de lerp, 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 false para 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 que TAM_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 modulate para 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ángulo jugador. 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:

  1. Determinar la dirección: partimos de dir = 0 (quieto). Si tocando_izquierda está activa, dir = -1; si no y tocando_derecha lo está, dir = +1. Este esquema evita dobles pulsaciones simultáneas y deja claro qué orden prevalece (en tu código usas if/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).
  2. Aplicar desplazamiento: sumamos dir * vel_jugador * delta a la coordenada X del rectángulo jugador. vel_jugador es pixeles/segundo, de modo que “velocidad × delta” da pixeles en este frame; multiplicar por dir nos aporta el signo.
  3. Encerrar en límites: con clamp(x, min, max) nos aseguramos de que nunca salga de la pantalla. El min es 0 (borde izquierdo) y el max es pantalla.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 dir o jugador.position para 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 delta y 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 con get_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 la event.position.x. Si el toque/clic queda a la izquierda, activamos tocando_izquierda = true y apagamos tocando_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 = false y tocando_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, InputEventMouseButton tiene button_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 comprobar event.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” (sin Control UI).
  • Recuerda que _input a nivel superior ya filtra cuando muerto && 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/false y 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

  1. Reinicio tras Game Over: si muerto es true y el jugador pulsa ui_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 de ui_accept tiene sentido pedagógico: en casi cualquier dispositivo, Enter o Espacio reinician, sin añadir botones en pantalla.
  2. 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.
  3. Movimiento lateral: al presionar ui_left/ui_right activamos la bandera correspondiente; al soltar, la desactivamos. Esta distinción pressed/released evita depender del key repeat del sistema operativo y da un control preciso y continuo.
  4. 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 usar elif).
  • 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 a get_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:

  1. Instanciamos un Label con Label.new() y lo guardamos en la variable global etiqueta_tiempo.
  2. Texto inicial: arrancamos en "0.0 s", de modo que el jugador ve el marcador desde el primer frame.
  3. 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.
  4. Visibilidad: lo dejamos en true desde el principio para que el cronómetro aparezca sin necesidad de interacción adicional.
  5. Añadimos el nodo al árbol con add_child(etiqueta_tiempo). Por defecto, un Label aparece en la esquina superior izquierda (coordenadas UI 0,0), que es un lugar natural para un contador. Si quisieras moverlo, podrías usar add_theme_font_size_override, position en un contenedor, o un MarginContainer con 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 usar tr() 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 delta y del Array como 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_DISPARO para que sea consistente y fácil de leer. Con Rect2 puedes aprovechar intersects en la detección contra asteroides sin depender aún de Area2D.
  • 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:

  1. Calculamos la hitbox ajustada del jugador con _escalar_rect(jugador).
  2. Recorremos todos los asteroides y comparamos contra su hitbox ajustada.
  3. 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 await con un SceneTreeTimer, 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 variable pausa vuelve a false y el flujo normal puede continuar (p. ej., aceptar ui_accept para 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:

  1. Una copia en (0, pos_fondo) y
  2. 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 limita pos_fondo con wrapf) y con el módulo de estrellas (donde el movimiento depende de la profundidad), obtenemos un parallax convincente a coste computacional mínimo: solo dos draw_texture_rect por 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 ligeramente vel_fondo en actualizar_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 con max(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 asteroidesprecisió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:

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, y vel_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_VELOCIDAD y la variable intervalo_asteroides y 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_ESTRELLAS y TAM_ESTRELLAS. Prueba diferentes valores y observa el resultado.

Ajustar el tamaño de la nave, los asteroides, y los disparos

Modifica las constantes TAM_JUGADORTAM_ASTEROIDE y TAM_DISPARO para 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_JUGADORTEX_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_FONDOSFX_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:

Asteroides, un juego arcade muy básico con nave y colisiones, hecho con Godot

Introducción

En esta unidad proponemos desarrollar desde cero un juego tipo arcade muy sencillo con Godot, centrado en los conceptos fundamentales: crear una nave que se pueda controlar, generar obstáculos (asteroides) que se muevan, detectar colisiones e implementar una interfaz básica. A lo largo de los apartados siguientes aprenderás cómo estructurar el código, organizar la escena, gestionar la entrada del usuario, mostrar los gráficos correspondientes y añadir elementos como un marcador de tiempo o un texto de Game Over.

El objetivo no es construir un juego complejo, sino entender claramente cada pieza que lo conforma, de modo que puedas modificarlo o ampliarlo por ti mismo, afianzando los fundamentos esenciales del desarrollo de juegos 2D con Godot.

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

Para nuestro juego necesitaremos las siguientes imágenes. 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.

Antes de empezar

  • Crea una escena con un nodo raíz Node2D y crea un script donde colocaremos toda la programación de nuestro juego.
  • Asegúrate de tener los ficheros res://jugador.png, res://asteroide.png y res://boton_cerrar.png en nuestra zona de Sistema de Archivos.
  • En Proyecto > Configuración del Proyecto > Mapa de Entrada, puedes configurar las teclas asociadas a las acciones ui_left, ui_right y ui_accept. Godot ya nos proporciona algunas teclas configuradas por defecto (cursores izquierda/derecha y Enter/Espacio), y nosotros podemos añadir las que creamos convenientes, como por ejemplo las teclas A y D.
  • Para poder ejecutar nuestro juego en dispositivos móviles y utilizar pantallas táctiles y ratón, controlaremos las acciones a realizar en la función _input().
  • En Proyecto > Configuración del Proyecto > Visualización > Ventana, puedes configurar cómo se ajusta la visualización del juego al tamaño de la ventana. Por ejemplo:
    • Resolución Full HD: Ancho del Viewport = 1920 y Alto del Viewport = 1080.
    • Abrir la aplicación directamente en pantalla completa: Modo = Fullscreen.
    • Escalar todo el viewport para llenar la pantalla: Estirar → Modo = viewport.
    • Evitar las barras negras cuando se ajuste el contenido: Estirar → Aspecto = expand.
    • Mantener proporciones de forma fluida al escalar: Estirar → Modo Escala = fractional.
    • Bloquear la orientación en horizontal: Manipulador → Orientación = Landscape.

Pasos a seguir

A continuación explicaremos el proceso de programación paso a paso, definiendo primero el esqueleto del código con constantes, variables (como jugador y asteroides) y funciones vacías. A continuación detallaremos la implementación de las funciones principales, cubriendo aspectos cruciales como la inicialización de la pantalla y el jugador, el movimiento del personaje mediante el parámetro delta, la creación y movimiento de obstáculos (asteroides), la detección de colisiones y el manejo de los estados como el Game Over y reinicio del juego.

Esqueleto del juego (todas las funciones vacías)

Cuando se empieza a programar un juego, primero debemos organizar el esqueleto del proyecto. Por eso en este primer paso crearemos constantes, variables y funciones vacías que solo contienen la palabra pass. De esta forma, dejamos preparado el esquema con todo lo que necesitaremos, tanto los parámetros o ajustes iniciales, como las acciones a realizar en cada momento: inicializar la pantalla, mover al jugador, crear asteroides, comprobar colisiones, mostrar el “Game Over”, etc., aunque las funciones todavía no estén implementadas. Es como escribir un índice antes de comenzar para definir las partes que debemos desarrollar.

En primer lugar utilizaremos constantes, que son valores que se definen una vez al inicio y que no cambian durante la partida. En el código del juego aparecen con la palabra clave const. Algunos ejemplos son los siguientes:

  • const TAM_JUGADOR = 64 → define el tamaño de la nave (ancho y alto del rectángulo del jugador).
  • const COLOR_FONDO = Color.BLACK → el color del fondo cuando estás jugando.
  • const COLOR_GAME_OVER = Color(1, 0, 0, 0.5) → el color rojizo y semitransparente que aparece cuando pierdes.
  • const TAM_BOTON_CERRAR = 48 → el tamaño del botón de cerrar en la esquina.
  • const TAM_TEXTO = 32 → el tamaño de la fuente para los textos (tiempo y “GAME OVER”).
  • const TEX_JUGADOR, const TEX_ASTEROIDE, const TEX_BOTON_CERRAR → rutas de las imágenes que se usan como texturas en el juego.

Además, definiremos variables, para establecer la configuración o los datos que cambian durante la partida. En este proyecto hay varias importantes:

  • pantalla: guarda el tamaño de la ventana del juego (ancho y alto). Sirve para colocar todo dentro de los límites visibles.
  • jugador: es un rectángulo (Rect2) que representa la nave, con su posición y tamaño.
  • asteroides: es una lista donde se van guardando todos los asteroides que aparecen.
  • tiempo_total: acumula los segundos que el jugador ha sobrevivido en la partida.
  • muerto: es un valor booleano (verdadero/falso) que indica si el jugador sigue vivo o ya ha perdido.
  • tocando_izquierda y tocando_derecha: valores booleanos que indicarán si el jugador está pulsando los controles para moverse en esas direcciones.

Las variables son la memoria del juego: sin ellas, Godot no tendría forma de saber dónde está el jugador, cuántos asteroides hay, si la partida sigue activa o cuánto tiempo ha pasado.

Por último definiremos una serie de funciones vacías con cada uno de los pasos que debemos seguir para completar nuestro juego. Además, estas funciones vacías también ayudan a evitar errores. En Godot, si el juego intenta llamar a una función que no existe, se bloquea con un error y no se inicia. En cambio, si la función existe aunque esté vacía, Godot la reconoce y el juego funciona sin problema. Así se puede ir construyendo el programa paso a paso, completando cada función cuando toque, sin miedo a que el proyecto falle por estar incompleto.

Además, al utilizar Godot dispondremos de unas funciones especiales, que actuarán como relojes internos, ejecutando el código correspondiente en cada momento. Gracias a ellas, el código del juego está bien organizado y cada tarea se ejecuta en el instante adecuado:

  • “La escena está lista” → _ready()
  • “Ha pasado un frame” → _process(delta)
  • “Hay que dibujar de nuevo” → _draw()
  • “El jugador ha pulsado algo” → _input(event)

Este el código que deberás copiar y pegar inicialmente en el fichero «script» creado dentro de nuestro proyecto. Luego iremos reemplazando paso a paso las funciones vacías, una a una, hasta completar toda la lógica del juego:

extends Node2D
# Este script controla un minijuego muy simple:
# - Una nave (el jugador) se mueve a izquierda/derecha en la parte inferior.
# - Aparecen asteroides desde arriba.
# - Si un asteroide choca con la nave: GAME OVER.
#
# ARQUITECTURA EN GODOT:
# - _ready() se ejecuta una vez al iniciar la escena (inicializaciones).
# - _process(delta) se ejecuta cada frame (lógica del juego “por frames”).
# - _draw() se ejecuta cuando se redibuja el nodo (dibujo 2D inmediato).
# - _input(event) recibe los eventos de entrada (teclado, ratón, táctil…).
#
# IMPORTANTE:
# - Mapea las acciones en Project → Project Settings → Input Map:
#   "ui_left", "ui_right" y "ui_accept".
# - Coloca las imágenes en las rutas indicadas (res://jugador.png, etc.).
# - Este ejemplo usa “dibujo inmediato” (draw_* en _draw) en lugar de nodos Sprite.

# -------------------------
# CONSTANTES DE CONFIGURACIÓN
# -------------------------

# Tamaños de jugador, asteroides, etc.
const TAM_JUGADOR = 128
const TAM_ASTEROIDE = 64
const FACTOR_HITBOX = 0.75   # Factor para reducir un poco las "cajas de choque" (colisiones menos “injustas”)
const TAM_BOTON_CERRAR = 32  # Tamaño del botón para cerrar el juego (arriba derecha)
const TAM_TEXTO = 25         # Tamaño del texto de las etiquetas (UI)

# Colores que se usan en el juego (Color(r, g, b, a) con valores 0..1)
const COLOR_FONDO = Color(0, 0, 0)                 # Fondo negro cuando jugamos
const COLOR_GAME_OVER = Color(0.2, 0.0, 0.0, 0.5)  # Capa roja semitransparente al perder (efecto “oscurecido”)

# Imágenes (texturas) del jugador, del asteroide y del botón cerrar.
# preload() carga el recurso al iniciar el juego (más eficiente que load() en tiempo de ejecución)
const TEX_JUGADOR = preload("res://jugador.png")
const TEX_ASTEROIDE = preload("res://asteroide.png")
const TEX_BOTON_CERRAR = preload("res://boton_cerrar.png")

# Velocidades y tiempos (valores “jugables”, se pueden tunear)
var vel_jugador = 500.0           # Pixels/segundo que se moverá el jugador
var vel_asteroides = 250.0        # Velocidad vertical de los asteroides (pixels/segundo)
var intervalo_asteroides = 0.75   # Cada cuánto aparece un nuevo asteroide (en segundos)

# -------------------------
# ESTADO DEL JUEGO
# -------------------------

# “Rect2” define posición y tamaño (position.x, position.y, size.x, size.y)
var boton_cerrar: Rect2
var jugador: Rect2
var asteroides: Array[Rect2] = []    # Lista de rectángulos de asteroides

# Variables de control
var tiempo_proximo_asteroide = 0.0   # Acumula el tiempo hasta crear el siguiente asteroide
var muerto = false                   # Marca si ya hemos perdido (pausa la lógica de juego)
var pantalla: Vector2                # Tamaño de la ventana/viewport (ancho, alto)
var tiempo_total = 0.0               # Cronómetro de la partida (en segundos)

# Etiquetas de texto (UI)
var etiqueta_game_over: Label
var etiqueta_tiempo: Label

# Controles del jugador (banderas para izquierda/derecha)
var tocando_izquierda = false
var tocando_derecha = false

# -------------------------
# CICLO DE VIDA PRINCIPAL
# -------------------------

func _ready():
	# Se llama una vez al cargar la escena. Preparamos todo.
	randomize() # Se utiliza una "semilla" distinta en cada partida para generar números aleatorios diferentes
	_inicializar_pantalla()
	_inicializar_jugador()
	_crear_ui_tiempo()
	_crear_boton_cerrar()
	_crear_ui_game_over()

func _process(delta: float):
	# delta = tiempo (en segundos) entre este frame y el anterior.
	# Si el jugador está muerto, detenemos la lógica (pero se puede seguir dibujando).
	if muerto: return

	# Orden típico de actualización por frames:
	_mover_jugador(delta)
	_crear_asteroides(delta)
	_mover_asteroides(delta)
	_comprobar_colision()
	_actualizar_tiempo(delta)
	_actualizar_dificultad(delta)

	# Pedimos que se llame a _draw() para redibujar (importante para ver cambios visuales).
	queue_redraw()

func _draw():
	# Todo lo que dibujamos aquí se "pinta" encima del lienzo del Node2D en este frame.
	_color_fondo_jugando()  # Capa de fondo (negro)
	_dibujar_asteroides()   # Asteroides (texturas)
	_dibujar_jugador()      # Nave del jugador (textura)
	_mostrar_boton_cerrar() # Botón "X" arriba a la derecha
	if muerto: _color_game_over()  # Capa roja semitransparente al perder (se dibuja al final)

func _input(event: InputEvent):
	# _input recibe eventos de teclado, ratón y táctiles tal cual ocurren.
	# Los separamos en dos funciones para mantener limpio el código.
	_comprobar_pantalla_tactil_y_raton(event)
	_comprobar_teclado(event)

# -------------------------
# FUNCIONES DE INICIO
# -------------------------

func _inicializar_pantalla():
	# Configura el tamaño de la ventana y otros ajustes iniciales de la pantalla
	pass

func _inicializar_jugador():
	# Crea y coloca al jugador en la posición inicial
	pass

func _dibujar_jugador():
	# Dibuja al jugador en la pantalla
	pass

# -------------------------
# CONTROLES
# -------------------------

func _comprobar_pantalla_tactil_y_raton(event: InputEvent):
	# Detecta y gestiona entradas de ratón o pantalla táctil
	pass

func _comprobar_teclado(event: InputEvent):
	# Detecta y gestiona las teclas pulsadas
	pass

# -------------------------
# MOVIMIENTO DEL JUGADOR
# -------------------------

func _mover_jugador(delta: float):
	# Actualiza la posición del jugador según la entrada de controles
	pass

# -------------------------
# BOTÓN PARA CERRAR LA APLICACIÓN
# -------------------------

func _crear_boton_cerrar():
	# Genera el botón de cerrar/salir del juego
	pass

func _mostrar_boton_cerrar():
	# Muestra el botón de cerrar para poder finalizar la ejecución del juego
	pass

# -------------------------
# INTERFAZ DE USUARIO
# -------------------------  

func _crear_ui_tiempo():
	# Crea el marcador visual que muestra el tiempo jugado
	pass

func _actualizar_tiempo(delta: float):
	# Incrementa y refresca el tiempo mostrado en la pantalla
	pass

func _crear_ui_game_over():
	# Prepara el mensaje de GAME OVER en la pantalla
	pass

# -------------------------
# ASTEROIDES
# -------------------------

func _crear_asteroides(delta: float):
	# Genera nuevos asteroides que caen desde arriba
	pass

func _mover_asteroides(delta: float):
	# Hace que los asteroides se desplacen hacia abajo
	pass

func _dibujar_asteroides():
	# Dibuja todos los asteroides en la pantalla
	pass

# -------------------------
# COLISIONES Y GAME OVER
# -------------------------

func _game_over():
	# Activa el estado de GAME OVER y detiene la partida
	pass

func _reiniciar_juego():
	# Reinicia la partida para volver a empezar
	pass

func _escalar_rect(r: Rect2):
	# Escala un rectángulo (útil para ajustar colisiones)
	pass

func _comprobar_colision():
	# Comprueba si el jugador choca con algún asteroide
	pass

# -------------------------
# DIBUJO DE FONDOS
# -------------------------

func _color_fondo_jugando():
	# Cambia el color de fondo cuando la partida está activa
	pass
		
func _color_game_over():
	# Cambia el color de fondo al llegar a GAME OVER
	pass

# -------------------------
# DIFICULTAD PROGRESIVA
# -------------------------

func _actualizar_dificultad(delta: float):
	# Ajusta la dificultad aumentando velocidad y reduciendo intervalos
	pass

Este primer código no hace que el juego “funcione” todavía, pero marca el punto de partida: Le dice a Godot que este será un juego 2D, y define la estructura básica para que luego sepamos dónde colocar cada parte de la lógica del juego (inicialización, movimiento, dibujo, controles).

De esta forma, establecemos el guion antes de comenzar a trabajar: todavía no vemos ningún resultado, pero sabemos cómo se va a organizar todo.

inicializar_pantalla()

Esta función sirve para que el juego “sepa” qué tamaño tiene la ventana en la que se va a mostrar. Cuando un juego se abre, Godot crea un espacio llamado viewport, que no es más que la zona rectangular donde se dibuja todo lo que aparece en pantalla. Esa zona tiene un ancho y un alto (por ejemplo, 1920 píxeles de ancho y 1080 de alto). Con esta función, el juego pregunta a Godot: “¿qué tamaño tiene la ventana ahora mismo?” y guarda esa información en una variable llamada pantalla. Esa variable no es más que un par de números: el primero indica el ancho y el segundo la altura.

¿Por qué es importante guardar esto? Porque a lo largo del juego necesitas saber los límites de la pantalla para que las cosas no se salgan. Por ejemplo, cuando el jugador mueve su nave, el código comprueba que no se vaya más allá de la izquierda o la derecha usando el ancho guardado. También, cuando se crean asteroides que caen desde arriba, el juego elige una posición aleatoria dentro del ancho de la pantalla para que siempre aparezcan dentro de los bordes visibles. Incluso el color de fondo o los botones de salir se dibujan ocupando todo el espacio según el tamaño que guardaste.

En resumen: esta función es como medir la pizarra antes de empezar a escribir en ella. Una vez que sabes sus dimensiones, puedes colocar correctamente todo lo que quieras pintar, mover o limitar dentro de tu “ventana de juego”.

func _inicializar_pantalla():
	# Guardamos el tamaño actual de la pantalla/viewport (Vector2(ancho, alto)).
	pantalla = get_viewport_rect().size

Por sí sola, esta función no “muestra” nada en la pantalla ni cambia nada visible al jugador. Por lo tanto, de momento no notarás ninguna diferencia en el juego.

inicializar_jugador()

Esta función se encarga de crear y colocar al jugador en su posición inicial dentro del juego. Para ello utiliza un objeto Rect2, que en Godot es básicamente un rectángulo definido por una posición (x, y) y un tamaño (ancho y alto). Ese rectángulo será la “caja” que representa al jugador: tanto para dibujarlo en pantalla como para detectar colisiones con asteroides.

En el código se indica que la posición horizontal del rectángulo es pantalla.x / 2 - TAM_JUGADOR / 2. Esto significa: toma el ancho total de la pantalla (pantalla.x), divídelo entre dos para ir al centro, y después resta la mitad del tamaño del jugador. El resultado es que el rectángulo queda perfectamente centrado en la parte inferior.

La posición vertical, en cambio, se calcula con pantalla.y - TAM_JUGADOR * 1.25. Aquí se parte de la altura total de la pantalla (pantalla.y) y se resta algo más que el tamaño del jugador. Ese “1.25” hace que no quede justo en el borde, sino un poco por encima, lo que da margen visual y evita que parezca que el jugador está pegado al suelo.

Finalmente, el ancho y el alto del rectángulo son simplemente TAM_JUGADOR, que es una constante que define el tamaño del jugador. Así se garantiza que siempre tenga la misma forma cuadrada.

func _inicializar_jugador():
	# Colocamos al jugador centrado horizontalmente y un poco por encima del borde inferior.
	jugador = Rect2(
		pantalla.x / 2 - TAM_JUGADOR / 2,     # x (centrado)
		pantalla.y - TAM_JUGADOR * 1.25,      # y (cerca del borde inferior)
		TAM_JUGADOR,                          # ancho
		TAM_JUGADOR                           # alto
	)

Cuando arranque la partida, el jugador aparecerá dibujado en el centro inferior de la pantalla, ligeramente elevado respecto al borde. Si esta función no existiera, el jugador no tendría un lugar inicial bien definido: podría aparecer fuera de la pantalla, en una esquina o ni siquiera estar visible. Con _inicializar_jugador(), en cambio, siempre empieza en el mismo punto, lo que da consistencia y claridad al juego.

dibujar_jugador()

Esta función se encarga de mostrar gráficamente al jugador en pantalla. Hasta ahora, en funciones como _inicializar_jugador(), solo se había definido la “caja” (Rect2) que representa al jugador en memoria, pero eso por sí solo no se ve. Aquí es donde realmente se dibuja la nave.

El código utiliza el método draw_texture_rect, que sirve para pintar una imagen (en Godot se llama textura) dentro de un rectángulo concreto. En este caso, la textura es TEX_JUGADOR, que seguramente es una variable que guarda la imagen de la nave, y el rectángulo es jugador, que ya tiene las coordenadas y el tamaño que definimos antes. El tercer parámetro (false) indica que no se estire la imagen para ajustarse al rectángulo más allá de lo necesario, respetando su escala.

func _dibujar_jugador():
	# Dibujamos la nave del jugador usando su rectángulo como destino.
	draw_texture_rect(TEX_JUGADOR, jugador, false)

Cuando se llame a _dibujar_jugador(), la nave aparecerá en pantalla justo en la posición que calculamos en _inicializar_jugador(). Antes de esta función, el jugador existía “en datos” pero era invisible; ahora, con el dibujo, se convierte en algo que el jugador puede ver y controlar. Sin esta función, por mucho que movieras el jugador o comprobaras colisiones, no verías ninguna nave en pantalla.

comprobar_pantalla_tactil_y_raton()

Esta función es la que conecta al juego con la interacción táctil o con el ratón, es decir, traduce lo que hace el jugador con los dedos o el clic en acciones dentro del juego.

El parámetro event representa lo que ha hecho el jugador (pulsar la pantalla, hacer clic con el ratón, soltar, etc.), y se utilizará de la siguiente forma:

  1. Filtrado del tipo de evento: Primero, la función comprueba si el evento es de tipo toque de pantalla (InputEventScreenTouch) o clic de ratón (InputEventMouseButton). Si no es uno de esos, no hace nada.
  2. Cuando se pulsa (event.pressed):
    • Lo primero que revisa es si la pulsación ocurrió dentro del rectángulo boton_cerrar, y entonces llama a get_tree().quit.call_deferred(), que es la manera segura de cerrar la aplicación en Godot. Con esto, el botón de la “X” funciona tanto con el ratón como al pulsar con el dedo.
    • Si el clic no se realizó sobre el botón de cerrar, entonces mira dónde pulsó el jugador respecto a la nave. Calcula el centro de la nave (centro_nave) y si el toque/clic está a la izquierda de ese centro, activa la variable tocando_izquierda, y si está a la derecha, activa tocando_derecha. Estas variables son las que usa luego la función _mover_jugador() para desplazar la nave en la dirección correcta.
  3. Cuando se suelta (else): Si el jugador levanta el dedo o suelta el clic, las variables tocando_izquierda y tocando_derecha se ponen en false. Eso significa que la nave deja de moverse y se queda quieta.
func _comprobar_pantalla_tactil_y_raton(event: InputEvent):
	# Control con pantalla táctil o ratón:
	# - Si pulsas sobre el botón de cerrar: se termina el juego.
	# - Si pulsas a la izquierda de la nave: mueves a la izquierda (y al revés).
	# - Al soltar: se detiene el movimiento.
	if event is InputEventScreenTouch or event is InputEventMouseButton:
		if event.pressed:
			# Si se ha pulsado el botón de cerrar el juego, finalizamos la ejecución de la aplicación de forma segura.
			if boton_cerrar.has_point(event.position):
				if get_tree(): 
					get_tree().quit.call_deferred()
				return
			
			# Decidimos la dirección según dónde has pulsado respecto al centro de la nave.
			var centro_nave = jugador.position.x + TAM_JUGADOR / 2
			if event.position.x < centro_nave:
				tocando_izquierda = true
				tocando_derecha = false
			else:
				tocando_derecha = true
				tocando_izquierda = false
		else:
			# Al soltar botón o dejar de tocar la pantalla, detenemos el movimiento.
			tocando_izquierda = false
			tocando_derecha = false

A partir de ahora dispondremos de la siguiente funcionalidad, que nos permitirá un poco más adelante mover nuestra nave horizontalmente:

  • Si tocas/haces clic en la parte izquierda de la pantalla (a la izquierda de la nave), activaremos una variable para que la nave se mueva a la izquierda.
  • Si lo haces en la parte derecha, activaremos otra variable para que la nave se mueve a la derecha.
  • Cuando levantas el dedo o sueltas el clic, la nave se detiene (ya que desactivaremos las variables de movimiento).
  • Si tocas/haces clic en la esquina superior derecha (el botón «X»), el juego se cierra.

Observaremos el resultado de esta función en breve.

comprobar_teclado()

Esta función hace lo mismo que la de pantalla táctil/ratón, pero con teclado. Es decir, escucha qué tecla ha pulsado el jugador y ajusta el comportamiento del juego en consecuencia. Principalmente traduce las pulsaciones de teclas en movimientos o reinicios, manteniendo el mismo sistema de variables booleanas (tocando_izquierda y tocando_derecha) que después usa _mover_jugador() para calcular el desplazamiento real:

  1. Reinicio tras el “Game Over”: Al principio se comprueba si el jugador ha perdido (muerto == true) y pulsa la acción "ui_accept" (que en Godot suele estar asociada a Enter o Espacio), y se llama a _reiniciar_juego(). Esto permite reiniciar la partida de forma rápida con una tecla, sin necesidad de ratón ni botones extra.
  2. Movimiento hacia la izquierda: Cuando se detecta que se ha pulsado la acción de moverse a la izquierda (normalmente la flecha izquierda o la tecla A), se activa la variable tocando_izquierda = true, y cuando se suelta esa tecla, se pone en false.
  3. Movimiento hacia la derecha: Igual que lo anterior, pero con "ui_right" (flecha derecha o tecla D). Mientras esté pulsada se pone a true ( tocando_derecha = true). Al soltarla, se pone a false.
func _comprobar_teclado(event: InputEvent):
	# Reiniciar el juego si hemos perdido: “ui_accept” suele ser Enter o Espacio.
	if muerto and event.is_action_pressed("ui_accept"):	
		_reiniciar_juego()
		return

	# Controles con teclado. Usamos acciones abstractas (Input Map) en lugar de teclas fijas.
	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

A partir de ahora, dispondremos de la siguiente funcionalidad:

  • Si la partida está en “Game Over”, al pulsar Espacio o Enter se reinicia el juego.
  • Si mantienes pulsada la flecha izquierda (o la tecla asignada) mientras estás jugando, la nave se desplazará a la izquierda.
  • Al mantener pulsada la flecha derecha (o la tecla asignada) mientras estás jugando, la nave se moverá a la derecha.
  • Al soltar la tecla, la nave se detiene automáticamente.

En Proyecto > Configuración del Proyecto > Mapa de Entrada, puedes configurar las teclas asociadas a las acciones ui_leftui_right y ui_accept. Godot ya nos proporciona algunas teclas configuradas por defecto (cursores izquierda/derecha y Enter/Espacio), y nosotros podemos añadir las que creamos convenientes, como por ejemplo las teclas A y D.

A continuación vamos a implementar la función que nos permite mover el jugador dependiendo de las variables que estamos actualizando a medida que pulsamos las teclas correspondientes o pulsamos en la pantalla. Observaremos el resultado conseguido en el siguiente punto.

mover_jugador(delta)

Esta función es clave porque no solo define o dibuja al jugador, sino que le da movimiento, es decir, controla la posición horizontal de la nave del jugador. Aquí entra en juego un concepto importante en videojuegos: el parámetro delta. Ese número representa el tiempo transcurrido entre un frame y el siguiente. Usarlo asegura que la velocidad de movimiento de la nave y los asteroides no dependa de la velocidad de ejecución del juego en ordenadores o dispositivos diferentes.

Primero se define una variable dir que indica hacia dónde quiere moverse el jugador:

  • -1 significa izquierda,
  • 0 significa que está quieto,
  • +1 significa derecha.

Después, el código mira qué controles están activos (tocando_izquierda o tocando_derecha). Según eso, cambia el valor de dir, y con esa dirección ya decidida, se calcula el nuevo movimiento utilizando una sencilla fórmula (jugador.position.x + dir * vel_jugador * delta), donde:

  • vel_jugador es la velocidad base de la nave.
  • dir indica hacia dónde multiplicar esa velocidad (positivo a la derecha, negativo a la izquierda).
  • delta ajusta el movimiento según el tiempo real entre frames.

Ese valor se mete dentro de clamp(), que es una función para poner límites. En este caso, el límite inferior es 0 (borde izquierdo de la pantalla) y el superior es pantalla.x - TAM_JUGADOR (borde derecho menos el tamaño de la nave). Gracias a clamp, el jugador nunca se sale de la pantalla aunque insista en seguir moviéndose.

func _mover_jugador(delta: float):
	var dir = 0  # Dirección horizontal: -1 izquierda, 0 quieto, +1 derecha
	
	# Revisamos qué bandera de control está activa.
	if tocando_izquierda: dir = -1
	elif tocando_derecha: dir = +1

	# Sumamos desplazamiento = velocidad * tiempo * dirección.
	# clamp evita que el jugador se salga por los bordes (0 a pantalla.x - tamaño).
	jugador.position.x = clamp(
		jugador.position.x + dir * vel_jugador * delta,
		0, pantalla.x - TAM_JUGADOR
	)

Después de implementar esta función, cuando mantengas pulsada la tecla o el control de izquierda/derecha, la nave se desplazará en esa dirección. Si sueltas el control, dir vuelve a ser 0 y la nave se queda quieta. Además, siempre se mantiene dentro de los límites visibles de la pantalla: nunca se pierde por un lado ni aparece cortada.

Dicho comportamiento también se producirá cuando usemos el ratón o pantallas táctiles, pulsando a la izquierda o derecha de la nave, tal como establecimos en la función comprobar_pantalla_tactil_y_raton().

crear_boton_cerrar()

Esta función se encarga de definir el área del botón para cerrar el juego. Igual que antes con el jugador, aquí se utiliza un Rect2 (un rectángulo con posición y tamaño). Ese rectángulo será la “zona clicable” o el espacio donde luego se dibujará el botón:

  • pantalla.x - TAM_BOTON_CERRAR: coloca el rectángulo pegado al borde derecho de la pantalla, restando su propio tamaño para que encaje justo dentro.
  • 0: en el eje vertical, empieza en la parte más arriba de la pantalla (la esquina superior).
  • TAM_BOTON_CERRAR: tanto ancho como alto son iguales, de forma que queda un cuadrado.
func _crear_boton_cerrar():
	# Creamos un rectángulo en la esquina superior derecha con el tamaño indicado.
	boton_cerrar = Rect2(pantalla.x - TAM_BOTON_CERRAR, 0, TAM_BOTON_CERRAR, TAM_BOTON_CERRAR)

Después de esta función, tienes definido un área cuadrada en la esquina superior derecha de la pantalla. Esa será la posición reservada para el botón de “cerrar”. Por sí sola, esta función no dibuja nada todavía: solo establece el rectángulo donde luego se podrá pintar el botón y detectar si el jugador hace clic ahí.

En otras palabras: es como poner un marco invisible en la esquina superior derecha, que más adelante se convertirá en un botón visible y funcional.

mostrar_boton_cerrar()

Esta función se encarga de dibujar en pantalla el botón de cerrar que antes definimos en _crear_boton_cerrar(). Hasta ese momento, el rectángulo boton_cerrar existía en memoria, pero era invisible. Aquí es donde se le coloca una imagen para que el jugador la vea:

  • TEX_BOTON_CERRAR es la textura, es decir, la imagen del icono del botón (se suele utilizar un dibujo con una “X”).
  • boton_cerrar es el rectángulo (Rect2) que define la posición y el tamaño en la pantalla, creado antes.
  • false significa que la imagen se debe estirar para que ocupe todo el rectángulo definido para el botón.
func _mostrar_boton_cerrar():
	# Dibujamos el icono del botón de cerrar. Al hacer clic/tap encima, cerramos el juego.
	draw_texture_rect(TEX_BOTON_CERRAR, boton_cerrar, false)	

Gracias a esta función, en la esquina superior derecha de la pantalla aparecerá un pequeño botón con el icono que hayas puesto en TEX_BOTON_CERRAR. A partir de ese momento, el jugador puede reconocer visualmente dónde debe hacer clic si quiere cerrar el juego. Y como ya existe la comprobación en la función de entrada (_comprobar_pantalla_tactil_y_raton), hacer clic dentro de esa zona provocará que la aplicación se cierre.

En resumen: _crear_boton_cerrar() definió el área, y _mostrar_boton_cerrar() coloca la imagen encima para que ese área se vea como un botón real y funcional.

crear_ui_tiempo()

Esta función prepara el marcador que mostrará el tiempo que el jugador ha conseguido sobrevivir. Para ello, crea una nueva etiqueta de texto (Label.new()) y la guarda en la variable etiqueta_tiempo. Esa etiqueta comienza mostrando "0.0 s", lo que representa el punto de partida del cronómetro.

Además, se personaliza su aspecto para que sea fácilmente legible: en el código se ajusta el tamaño de la fuente con la constante TAM_TEXTO. De esta manera, aunque la etiqueta aparezca por defecto en la esquina superior izquierda de la pantalla, el texto no se verá demasiado pequeño. También se marca explícitamente que la etiqueta debe ser visible desde el principio, asegurando que el jugador vea el contador nada más empezar la partida.

Finalmente, se añade la etiqueta como un child de la escena principal mediante add_child(...). Esto es lo que hace que el texto realmente aparezca en pantalla. En resumen, al iniciar la partida aparecerá en la esquina superior izquierda un contador que marca 0.0 s, listo para ir actualizándose en cada frame gracias a otra función que lo hará avanzar con el tiempo.

func _crear_ui_tiempo():
	# Crea la etiqueta que muestra el tiempo de supervivencia.
	# Por defecto la Label aparece en (0, 0), es decir, en la esquina superior izquierda
	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)

Después de añadir esta función aparecerá un contador visible en la esquina superior izquierda, que empieza en 0.0 s y luego se irá actualizando con otra función. Esto le da al juego un elemento extra de retroalimentación: el jugador ya no solo intenta sobrevivir, sino que también puede medir cuánto tiempo ha resistido, o competir con otros para ver quién dura más.

actualizar_tiempo(delta)

Esta función es la encargada de que el contador de supervivencia vaya avanzando mientras dura la partida. En Godot, el parámetro delta representa el tiempo que ha pasado desde el último frame, normalmente una fracción de segundo. Usar delta en lugar de sumar simplemente 1 hace que el cronómetro sea exacto aunque el juego vaya más rápido o más lento en distintos ordenadores o dispositivos.

Dentro de la función, primero se comprueba si el jugador no está muerto. Si sigue vivo, se suma delta a la variable tiempo_total. De esa manera, el cronómetro acumula el tiempo real que pasa durante la partida. Si el jugador ha perdido, ya no se actualiza más, y el contador se queda congelado mostrando el último tiempo conseguido.

Finalmente, se actualiza el texto de la etiqueta de tiempo. Aquí entra en juego snappedf(tiempo_total, 0.1): esta función redondea el número para mostrarlo con un paso de 0.1 segundos, es decir, con un solo decimal. De esa forma, en la pantalla no aparecen números con demasiados decimales, sino algo legible como 3.4 s, 12.7 s, etc. Ese valor se convierte en texto y se muestra en etiqueta_tiempo.

func _actualizar_tiempo(delta: float):
	# Si seguimos vivos, sumamos delta al cronómetro.
	if not muerto:
		tiempo_total += delta
	# snappedf(x, step) redondea con un “paso”. Aquí dejamos 1 decimal.
	etiqueta_tiempo.text = str(snappedf(tiempo_total, 0.1)) + " s"

Antes de esta función, el contador creado en _crear_ui_tiempo() estaba siempre fijo en 0.0 s. Después de añadir _actualizar_tiempo(), el marcador se va incrementando en tiempo real, dándole al jugador una referencia clara de cuánto ha durado su partida. Además, al morir, el tiempo se congela, permitiendo comparar resultados entre partidas.

crear_ui_game_over()

Esta función prepara la etiqueta de texto que mostrará el mensaje de “GAME OVER” cuando el jugador pierda la partida. Igual que con el cronómetro, se utiliza un nodo Label para mostrar texto en pantalla.

Primero se crea la etiqueta (Label.new()) y se le ajusta el tamaño de la fuente usando la constante TAM_TEXTO, para que el mensaje sea lo bastante grande y visible. A diferencia del cronómetro, aquí la etiqueta empieza oculta (visible = false). Esto es importante: el mensaje no debe aparecer desde el inicio, sino únicamente cuando el jugador muera.

Finalmente, se añade a la escena con add_child(...), lo que significa que la etiqueta ya está preparada en memoria y lista para mostrarse en el momento adecuado, aunque por ahora siga invisible.

func _crear_ui_game_over():
	# Crea la etiqueta de "GAME OVER" (oculta hasta que perdamos).
	etiqueta_game_over = Label.new()
	etiqueta_game_over.set("theme_override_font_sizes/font_size", TAM_TEXTO)
	etiqueta_game_over.visible = false
	add_child(etiqueta_game_over)

Antes de esta función, al perder simplemente se detenía la partida, pero no había un mensaje claro que indicara lo ocurrido. Después de añadirla, existe una etiqueta especial que puede activarse para decirle al jugador que la partida actual ha finalizado. Esto hace que la experiencia sea más clara y más parecida a un juego completo, donde el Game Over es un elemento esperado.

crear_asteroides(delta)

Esta función es la responsable de ir generando nuevos asteroides que «caen» desde la parte superior de la pantalla. Para controlar la frecuencia con la que aparecen, se usa un contador (tiempo_proximo_asteroide) que va acumulando el tiempo transcurrido en cada frame gracias a delta. Cuando ese contador supera el valor de intervalo_asteroides (por ejemplo, cada 1 segundo, o 0.5 según la dificultad), se “resetea” a 0 y se crea un nuevo asteroide.

Cada asteroide se genera con cierta aleatoriedad para que la partida sea más interesante:

  • Primero, se calcula una posición horizontal aleatoria (x = randi_range(0, pantalla.x - TAM_ASTEROIDE)), asegurándose de que el asteroide esté dentro de los límites de la pantalla.
  • Después, se le asigna un tamaño también aleatorio, que puede ser desde la mitad hasta el doble del tamaño base (tam = randi_range(TAM_ASTEROIDE / 2.0, TAM_ASTEROIDE * 2.0)). Eso significa que en la partida habrá asteroides más pequeños y fáciles de esquivar, y otros mucho más grandes y complicados.
  • Finalmente, se crea un Rect2 con esas dimensiones y se coloca justo por encima de la pantalla (y = -tam). Esto hace que el asteroide “entre” desde fuera del área visible, cayendo hacia abajo.
func _crear_asteroides(delta: float):
	# Cada “intervalo_asteroides” segundos, creamos un asteroide nuevo arriba.
	tiempo_proximo_asteroide += delta
	if tiempo_proximo_asteroide >= intervalo_asteroides:
		tiempo_proximo_asteroide = 0.0

		# Posición X aleatoria dentro de los límites de la pantalla.
		var x = randi_range(0, pantalla.x - TAM_ASTEROIDE)

		# Tamaño aleatorio entre la mitad y el doble del tamaño base.
		var tam = randi_range(TAM_ASTEROIDE / 2.0, TAM_ASTEROIDE * 2.0)

		# Colocamos el rectángulo justo por encima de la pantalla (y negativo), y así “entra” cayendo.
		var asteroide = Rect2(x, -tam, tam, tam)
		asteroides.append(asteroide)

Al añadir la función _crear_asteroides(), el juego empieza a generar asteroides de forma automática cada cierto tiempo, con posiciones horizontales aleatorias y tamaños variables, lo que introduce variedad y dificultad progresiva. Aunque en este punto aún no se ven en pantalla (solo se almacenan en la lista asteroides), pronto conseguiremos que el juego tenga un “ritmo de obstáculos” que irán cayendo y que después podrán moverse, dibujarse y comprobar colisiones con el jugador.

mover_asteroides(delta)

Esta función se encarga de dar vida y movimiento a los asteroides que fueron creados con la función anterior. Hasta ese momento, los asteroides existían pero estaban fijos en la parte superior, esperando. Con esta función, comienzan a caer.

El primer bloque del código recorre la lista de asteroides (for i in asteroides.size():) y a cada uno le incrementa su posición vertical (position.y) multiplicando la velocidad vel_asteroides por delta. Esto asegura que todos se muevan hacia abajo de forma suave y proporcional al tiempo real, sin importar los frames por segundo del ordenador. Así, el jugador ve cómo los asteroides entran desde arriba y caen hacia la zona donde se encuentra su nave.

El segundo bloque se ocupa de la limpieza: elimina los asteroides que ya se han salido por la parte inferior de la pantalla. Lo hace con filter, que crea una nueva lista solo con aquellos cuya posición vertical sigue siendo menor que la altura de la pantalla (pantalla.y). Gracias a esto, no se acumulan objetos invisibles fuera del área de juego, lo que evita que el rendimiento baje innecesariamente.

func _mover_asteroides(delta: float):
	# Los asteroides caen hacia abajo a velocidad constante (vel_asteroides).
	for i in asteroides.size():
		asteroides[i].position.y += vel_asteroides * delta

	# Eliminamos los que ya salieron de la pantalla (su y es mayor que la altura).
	# filter crea una nueva lista con los elementos que cumplan la condición.
	asteroides = asteroides.filter(func(o): return o.position.y < pantalla.y)

Antes de esta función, los asteroides simplemente aparecían arriba y se quedaban quietos. Después de añadirla, realmente caen con velocidad, se desplazan por toda la pantalla y desaparecen al llegar abajo, convirtiéndose en los obstáculos que el jugador debe esquivar. En otras palabras, es aquí donde los asteroides dejan de ser “estáticos” y se transforman en un peligro real.

dibujar_asteroides()

Esta función se encarga de mostrar en pantalla todos los asteroides que existen en ese momento. Hasta aquí, los asteroides se habían creado con _crear_asteroides() y habían empezado a moverse con _mover_asteroides(), pero aún eran solo datos en memoria (rectángulos con posiciones y tamaños). Con esta función se convierten en imágenes visibles para el jugador.

El código recorre la lista asteroides y, para cada uno, usa draw_texture_rect(TEX_ASTEROIDE, asteroide, false). Eso significa que se dibuja la textura asociada al asteroide (TEX_ASTEROIDE) dentro del rectángulo que ya define su posición y tamaño. El false asegura que la textura se estire y se dibuje ocupando toda el área de cada rectángulo.

func _dibujar_asteroides():
	# Dibujamos cada asteroide con su textura y su rectángulo correspondiente.
	for asteroide in asteroides:
		draw_texture_rect(TEX_ASTEROIDE, asteroide, false)

Aunque los asteroides “existían” y se movían antes de añadir esta función, el jugador no podía verlos. Después de añadirla, los asteroides aparecen claramente en pantalla mientras caen, con su tamaño aleatorio y desde distintas posiciones, convirtiéndose en los obstáculos visibles que hay que esquivar.

game_over()

Con esta función conseguimos que el juego reaccione al momento en que el jugador pierde: se marca el estado de derrota con la variable muerto = true (así el resto de funciones dejan de mover al jugador o crear asteroides), se desactivan los controles (tocando_izquierda y tocando_derecha a false), y en la interfaz se oculta el contador de tiempo para mostrar en su lugar el mensaje “GAME OVER” junto con el tiempo que el jugador ha sobrevivido. En otras palabras, pasamos de la acción a la pantalla final que indica claramente que la partida ha terminado.

func _game_over():
	# Marcamos estado de derrota y actualizamos UI.
	muerto = true
	tocando_izquierda = false
	tocando_derecha = false	
	etiqueta_game_over.text = "GAME OVER (" + etiqueta_tiempo.text + ")"
	etiqueta_tiempo.visible = false
	etiqueta_game_over.visible = true

Antes de esta función, al chocar con un asteroide no había un final claro: el jugador podía seguir moviéndose y no se mostraba ningún mensaje. Después de añadirla, la partida termina de forma ordenada: la nave se bloquea y aparece un mensaje de “GAME OVER” y el tiempo sobrevivido, dejando claro al jugador que esa ronda ha terminado.

reiniciar_juego()

Esta función es la encargada de volver a empezar la partida desde cero después de un Game Over. Su lógica es muy sencilla: recarga toda la escena actual con get_tree().reload_current_scene().

En Godot, la escena es como el “contenedor” de todo lo que forma el juego en ese momento: el jugador, los asteroides, las etiquetas de UI, etc. Al recargarla, se destruye la escena en curso y se crea una nueva copia desde el estado inicial definido en el script. Eso significa que todas las variables (muerto, tiempo_total, asteroides, etc.) vuelven automáticamente a los valores originales con los que empezó la partida.

func _reiniciar_juego():
	# Recargamos la escena actual (vuelta a empezar).
	# Todas las variables vuelven a su valor inicial definido en el script.
	get_tree().reload_current_scene()

Antes de tener esta función, al finalizar una partida no teníamos forma de volver a jugar sin cerrar y abrir el juego de nuevo. Tras añadirla, el jugador puede reiniciar al instante: la nave aparece otra vez en su posición inicial, los asteroides desaparecen, el contador vuelve a cero y el marcador de Game Over se oculta. En resumen, el juego se resetea como si acabara de iniciarse.

escalar_rect(r)

Esta función sirve para ajustar la zona de colisión del jugador o de los asteroides, lo que normalmente se llama la hitbox. En los juegos, la imagen (sprite) de un objeto suele ser un poco más grande o tener bordes irregulares, y si usáramos su tamaño exacto para detectar colisiones, sería demasiado artificial: parecería que te golpean incluso cuando los objetos apenas se rozan visualmente.

Lo que hace el código es recibir un rectángulo r (el área original del objeto) y devolver una versión un poco más pequeña. Para eso utiliza el método grow, que puede agrandar o encoger un rectángulo en todas sus direcciones. Si le pasamos un número positivo, el rectángulo crece; si es negativo, se encoge.

La clave está en este cálculo: (FACTOR_HITBOX - 1.0) * r.size.x / 2. Aquí se usa una constante que definimos al principio de nuestro código, llamada FACTOR_HITBOX. Si vale, por ejemplo, 0.8, el resultado será negativo y el rectángulo se hará un 20 % más pequeño en cada lado. De ese modo, la zona que cuenta para la colisión ya no es el borde exacto del sprite, sino una versión reducida que da más sensación de justicia al jugador.

func _escalar_rect(r: Rect2):
	# Hacemos la “hitbox” un poco más pequeña que el sprite real para
	# que las colisiones estén más ajustadas.
	# Rect2.grow(margen) crece (margen > 0) o encoge (margen < 0) por todos los lados.
	# Aquí calculamos un margen negativo proporcional al ancho.
	return r.grow((FACTOR_HITBOX - 1.0) * r.size.x / 2)

Antes de esta función, cualquier roce mínimo entre el sprite del jugador y un asteroide contaba como choque. Después de añadirla, el área efectiva de colisión es más pequeña, y el jugador tiene un poco más de margen: puede esquivar mejor, aunque visualmente las imágenes parezcan rozarse. Esto hace que el juego sea menos frustrante y más equilibrado.

comprobar_colision()

Esta función es la encargada de decidir si el jugador ha chocado con algún asteroide. Para hacerlo más justo, no compara los rectángulos originales, sino versiones reducidas gracias a la función _escalar_rect().

Primero, coge el rectángulo del jugador y lo pasa por _escalar_rect(), creando jugador_escalado, que es básicamente la hitbox ajustada del jugador. Luego, recorre cada asteroide en la lista y hace lo mismo: genera un asteroide_escalado con una hitbox más pequeña que la del sprite real.

Después, utiliza el método intersects(), que comprueba si dos rectángulos se solapan. Si alguno de los asteroides escalados se cruza con el rectángulo escalado del jugador, significa que han colisionado, y en ese momento se llama a _game_over(), terminando la partida.

func _comprobar_colision():
	# Comprobamos si el rect del jugador (reducido) intersecta con alguno de los asteroides.
	var jugador_escalado = _escalar_rect(jugador)
	for asteroide in asteroides:
		var asteroide_escalado = _escalar_rect(asteroide)
		if jugador_escalado.intersects(asteroide_escalado):
			_game_over()

Antes de esta función, no había forma de detectar un choque: el jugador y los asteroides podían ocupar el mismo espacio sin consecuencias. Después de añadirla, el juego ya sabe cuándo se produce un impacto, y reacciona mostrando el Game Over. Además, gracias a que se usan hitbox reducidas, los choques resultan más realistas: parece que la nave esquiva “por los pelos”, aunque en realidad los rectángulos reales que ocupan las imágenes ya se están tocando.

color_fondo_jugando()

Esta función se encarga de dibujar el fondo del juego mientras la partida está en marcha. En este caso, el fondo no es una imagen, sino simplemente un rectángulo pintado con un color liso.

El código crea un Rect2 que empieza en la esquina superior izquierda de la pantalla (Vector2.ZERO, es decir, coordenada (0,0)) y que ocupa todo el ancho y el alto guardados en la variable pantalla. Luego, con draw_rect(..., COLOR_FONDO, true) se rellena ese rectángulo del color definido en COLOR_FONDO. El true indica que el rectángulo debe pintarse sólido, no solo con el borde.

func _color_fondo_jugando():
	# Pintamos un rectángulo del tamaño de la pantalla con color de fondo.
	draw_rect(Rect2(Vector2.ZERO, pantalla), COLOR_FONDO, true)

Antes de esta función, el fondo podía quedar transparente o mostrar un color por defecto, sin coherencia visual. Después de añadirla, la pantalla completa se pinta con el color elegido para el estado de “jugando” (por ejemplo, un azul oscuro o un negro espacial). Esto da uniformidad a la partida y diferencia claramente el área del juego.

color_game_over()

Esta función se encarga de dar un efecto visual cuando el jugador pierde la partida. En lugar de cambiar completamente la pantalla, pinta una capa de color encima de todo lo que ya estaba dibujado.

El código crea, igual que antes, un rectángulo que cubre toda la pantalla (Rect2(Vector2.ZERO, pantalla)) y lo rellena con COLOR_GAME_OVER. Este color no suele ser totalmente opaco, sino que se define con algo de transparencia (por ejemplo, un rojo semitransparente). Eso hace que, al dibujarse, todavía se pueda ver el juego debajo, pero como “oscurecido” o teñido de rojo.

func _color_game_over():
	# Dibujamos una capa roja semitransparente por encima al perder.
	# Truco visual: se ve el juego “oscurecido” debajo.
	draw_rect(Rect2(Vector2.ZERO, pantalla), COLOR_GAME_OVER, true)

Antes de esta función, al perder solo aparecía el texto de “GAME OVER”, sin un cambio visual que transmitiera impacto. Después de añadirla, la pantalla se tiñe con un rojo translúcido, creando una atmósfera de derrota. Es un truco visual que no borra lo que ya estaba en pantalla, sino que lo cubre con un filtro de color. Así, el jugador sigue viendo los asteroides y la nave congelados en su sitio, pero bajo una capa que marca el final de la partida.

actualizar_dificultad(delta)

Esta función es la que hace que el juego se vuelva más complicado conforme pasa el tiempo. La clave está en que usa el parámetro delta (el tiempo que ha pasado desde el último frame) para aplicar cambios de manera suave y proporcional al tiempo real.

Por un lado, aumenta la velocidad de caída de los asteroides: vel_asteroides += 10 * delta. Eso significa que, cuanto más tiempo sobrevivas, más deprisa caerán, obligándote a reaccionar más rápido. Por otro lado, reduce el intervalo entre apariciones (intervalo_asteroides), haciendo que cada vez haya más asteroides en pantalla. Para que no se vuelva imposible, se usa un max(0.1, ...), lo que garantiza que nunca se generen más rápido que una décima de segundo.

func _actualizar_dificultad(delta: float):
	# Aumenta poco a poco la velocidad de caída de los asteroides
	vel_asteroides += 10 * delta
	
	# Disminuye el tiempo entre asteroides (aparecen más rápido)
	# Usamos max() para poner un límite inferior y que nunca baje de 0.1 s.
	intervalo_asteroides = max(0.1, intervalo_asteroides - 0.01 * delta)

Antes de esta función, el juego se mantenía siempre con la misma dificultad: los asteroides caían a la misma velocidad y con la misma frecuencia. Después de añadirla, la partida se vuelve dinámica: al principio es más tranquila, pero poco a poco la pantalla se llena de asteroides más rápidos y que aparecen con más frecuencia. Esto introduce una curva de dificultad progresiva que hace que cada partida tenga tensión creciente y que la supervivencia sea un verdadero reto.

Resumiendo

La eficacia del modelo de Godot para este tipo de juegos es evidente en la clara separación de responsabilidades entre las funciones del ciclo de vida. _ready() gestiona la configuración inicial, _process(delta) se encarga de la lógica evolutiva del estado del juego, _draw() traduce ese estado en una representación visual e _input(event) maneja las interacciones del usuario de forma asíncrona.

Estructura del proyecto y configuración inicial

La base del juego se establece sobre una escena simple cuyo nodo raíz es un Node2D. A este nodo se le asocia un único script que contendrá toda la programación, centralizando la lógica en un solo lugar. Para la gestión de los recursos gráficos, se pre-cargan las imágenes del jugador (jugador.png), los asteroides (asteroide.png) y la interfaz (boton_cerrar.png). El uso de preload() es una decisión clave para garantizar una experiencia de usuario fluida, ya que asegura que todos los recursos gráficos estén cargados en memoria antes de que la partida comience.

Los componentes de configuración más relevantes definidos mediante la interfaz de Godot para el proyecto son los siguientes:

  • Mapa de Entrada: Define las acciones abstractas (ui_left, ui_right, ui_accept) y su mapeo a teclas físicas (cursores, A/D, Enter/Espacio), desacoplando el código de las entradas específicas del hardware.
  • Ventana de Visualización: Configura la resolución (por ejemplo 1920x1080), el modo de pantalla (Fullscreen) y el comportamiento de escalado (viewport, expand) para asegurar una experiencia visual consistente en diferentes dispositivos.

Una vez definidos estos parámetros estáticos, el siguiente paso es definir los datos que gestionarán el estado dinámico de cada partida.

Gestión del estado del juego

El rol de las constantes (const) es definir parámetros de configuración que no cambian durante la partida. Actúan como un «panel de ajustes» centralizado que permite modificar el comportamiento del juego sin alterar la lógica del código. Entre las constantes clave se encuentran los tamaños de los objetos (TAM_JUGADOR, TAM_ASTEROIDE), los colores de la interfaz (COLOR_FONDO, COLOR_GAME_OVER) y las rutas a los recursos de texturas (TEX_JUGADOR, TEX_ASTEROIDE), cargadas eficientemente con preload().

Por otro lado, las variables actúan como la memoria viva del juego, almacenando toda la información que cambia dinámicamente durante la partida:

  • pantalla (Vector2): Almacena las dimensiones del viewport (ancho y alto). Su función es esencial para el posicionamiento de entidades y la validación de los límites de la pantalla, asegurando que los objetos permanezcan dentro del área visible.
  • jugador (Rect2): Representa la posición y el tamaño de la nave del jugador. Actúa como su entidad lógica y física, utilizada tanto para el dibujo como para la detección de colisiones.
  • asteroides (Array[Rect2]): Es una colección dinámica que contiene todas las instancias de asteroides activos en la escena. Esta lista se modifica constantemente a medida que se crean nuevos asteroides y se eliminan los que salen de la pantalla.
  • muerto (bool): Una variable booleana que controla el flujo principal del juego. Al pasar a true, detiene la lógica de la partida (movimiento, creación de asteroides, etc.), indicando que se ha alcanzado el estado de «Game Over».
  • tiempo_total (float): Funciona como un cronómetro de supervivencia, acumulando los segundos que el jugador ha permanecido con vida.
  • tocando_izquierda / tocando_derecha (bool): Implementan un patrón de sondeo de estado. En lugar de ejecutar el movimiento directamente desde el evento de entrada, desacoplan la detección (en _input) de la lógica de movimiento (en _process), garantizando que la física del juego se actualice de forma consistente dentro del bucle principal y no de manera esporádica basada en eventos de hardware.

Una vez que la estructura de datos que define el estado del juego está establecida, el siguiente paso es analizar cómo el motor Godot orquesta la manipulación de este estado a lo largo del tiempo a través de su ciclo de vida.

Ciclo de vida en Godot

El modelo de ejecución de Godot se puede entender como un sistema de «relojes internos» o funciones de callback que el motor invoca en momentos específicos. Este modelo estructura el flujo de un programa en tiempo real, organizando el código en fases lógicas y predecibles. Para esta implementación de «Asteroides», el ciclo de vida se gestiona a través de cuatro funciones principales, cada una con una responsabilidad claramente definida.

Fase de inicialización

La función _ready() es el punto de entrada que se ejecuta una única vez cuando se crea la escena. Su propósito es preparar el estado inicial del juego, asegurando que todos los componentes estén en una condición válida y conocida antes de que comience la partida.

La secuencia de operaciones de inicialización es la siguiente:

  1. randomize(): Se inicializa el generador de números aleatorios con una nueva semilla. Esto garantiza que la aparición y el tamaño de los asteroides varíen en cada partida.
  2. _inicializar_pantalla(): Se capturan y almacenan las dimensiones del viewport en la variable pantalla.
  3. _inicializar_jugador(): Se crea el Rect2 del jugador y se posiciona en su ubicación inicial, centrado en la parte inferior de la pantalla.
  4. _crear_ui_tiempo(), _crear_boton_cerrar(), _crear_ui_game_over(): Instanciación, configuración y adición de los nodos de la interfaz de usuario (Label y áreas de Rect2) al árbol de escenas principal a través de add_child().

Bucle principal

La función _process(delta) es el corazón del juego, ejecutándose en cada frame. Es responsable de actualizar el estado del juego en función del tiempo y las interacciones. El parámetro delta es crucial, ya que representa el tiempo transcurrido (en segundos) desde el frame anterior, permitiendo que el movimiento y otras lógicas basadas en el tiempo sean independientes de la tasa de frames del dispositivo.

El flujo de control dentro de esta función está gobernado por la variable muerto. Una instrucción return detiene la ejecución de la lógica principal si el jugador ha perdido, congelando el estado del juego. Si la partida está activa, se ejecutan las siguientes actualizaciones en un orden específico:

  • _mover_jugador(delta)
  • _crear_asteroides(delta)
  • _mover_asteroides(delta)
  • _comprobar_colision()
  • _actualizar_tiempo(delta)
  • _actualizar_dificultad(delta)

Finalmente, se llama a queue_redraw(), un mecanismo que solicita al motor que invoque la función _draw() en el ciclo de renderizado actual, asegurando que los cambios de estado se reflejen visualmente.

Renderizado

La función _draw() es la responsable del dibujo inmediato de todas las entidades visuales del juego en cada frame en el que se ha solicitado un redibujado. En esta arquitectura, se utiliza para traducir el estado del juego (las variables de posición, color y texturas) a una representación gráfica en la pantalla.

El orden de las llamadas a las funciones de dibujo es crítico, ya que establece un sistema de capas visuales implícito. La secuencia es la siguiente:

  1. _color_fondo_jugando(): Se dibuja el fondo negro, que actúa como la capa más profunda.
  2. _dibujar_asteroides(): Se dibujan todos los asteroides.
  3. _dibujar_jugador(): Se dibuja la nave del jugador sobre los asteroides y el fondo.
  4. _mostrar_boton_cerrar(): Se dibuja el botón de la interfaz.
  5. _color_game_over(): Si el jugador ha perdido, se dibuja una capa roja semitransparente por encima de todo. Este es un ‘truco visual’ efectivo que comunica el fin de la partida sin destruir la escena, permitiendo al jugador ver el estado final congelado (su nave y los asteroides) bajo un filtro de color.

Eventos de entrada

La función _input() actúa como el gestor de eventos de entrada asíncronos. Se invoca cada vez que el usuario realiza una acción, como presionar una tecla, hacer clic con el ratón o tocar la pantalla. Para mantener el código organizado, la función delega la lógica de procesamiento a dos sub-funciones: _comprobar_pantalla_tactil_y_raton() y _comprobar_teclado().

El patrón de diseño clave empleado aquí es el desacoplamiento entre la detección de la entrada y la acción resultante. La función _input no ejecuta el movimiento del jugador directamente. En su lugar, modifica variables de estado (tocando_izquierda y tocando_derecha). Estas variables se consultan posteriormente en el bucle _process(), que es el responsable de calcular y aplicar el movimiento.

Componentes principales del juego

El juego se compone de cuatro elementos clave: la nave del jugador, que se mueve dentro de los límites de la pantalla; los asteroides, generados y desplazados de forma aleatoria; un sistema de colisiones que determina el fin de la partida y permite reiniciar; y la interfaz con dificultad progresiva, que muestra la información al jugador y aumenta el reto con el tiempo.

El jugador (nave)

La gestión del jugador está distribuida en una cadena de funciones que manejan su ciclo de vida completo. _inicializar_jugador() establece su estado inicial, posicionándolo centrado en la parte inferior de la pantalla. _dibujar_jugador() se encarga de su representación visual, dibujando la textura correspondiente en la posición definida por su Rect2.

El movimiento se implementa en _mover_jugador(). Esta función lee las variables de estado tocando_izquierda y tocando_derecha para determinar una dirección de movimiento (dir con valor -1, 0 o 1). La nueva posición se calcula con la fórmula posición_actual + dir * vel_jugador * delta, garantizando un desplazamiento suave e independiente del framerate. Finalmente, la función clamp() restringe la posición resultante dentro de los límites horizontales de la pantalla, evitando que la nave se salga del área de juego.

Los obstáculos (asteroides)

El ciclo de vida de los asteroides es gestionado por tres funciones principales. La función _crear_asteroides() utiliza un temporizador (tiempo_proximo_asteroide) para controlar la frecuencia de aparición de nuevos obstáculos. Cada vez que se genera un nuevo asteroide, se le asigna una posición horizontal (randi_range) y un tamaño aleatorios, lo que introduce variedad y jugabilidad.

Una vez creados, _mover_asteroides() actualiza la posición vertical de cada asteroide en la lista asteroides, haciendo que se muevan por la pantalla. Esta función también incluye un importante mecanismo de optimización: utiliza asteroides.filter() para crear una nueva lista que contiene únicamente los asteroides que todavía se encuentran dentro de los límites de la pantalla. Debemos aclarar que aunque este enfoque es fácil de implementar, en escenarios de alto rendimiento con miles de entidades, la creación de un nuevo array en cada frame no sería óptimo. Para un juego de esta escala es válido, pero en proyectos más complejos se podría considerar un enfoque que modifique la lista in-situ para minimizar la asignación de memoria. Finalmente, _dibujar_asteroides() itera sobre la lista y renderiza cada uno en la pantalla.

Sistema de colisiones y transición de estado

La detección de colisiones se centraliza en _comprobar_colision(). Para que las colisiones sean más «justas» y menos frustrantes, el sistema no utiliza los rectángulos visuales de los objetos. En su lugar, emplea el concepto de «hitbox», una zona de impacto efectiva más pequeña que el sprite. Esto se implementa a través de la función _escalar_rect(), que recibe un Rect2 y devuelve una versión reducida basada en la constante FACTOR_HITBOX. La función comprueba si la hitbox del jugador se superpone (intersects()) con la de algún asteroide. Esta técnica de desacoplar la ‘hitbox’ del sprite visible es un pilar fundamental en el diseño de juegos de acción, ya que impacta directamente en la ‘sensación de juego’, primando la percepción del jugador sobre la precisión física.

Si se detecta una colisión, se invocan las funciones de transición de estado. _game_over() es la responsable de cambiar el estado del juego: establece la variable muerto a true, desactiva los controles del jugador y actualiza la interfaz de usuario para mostrar el mensaje de fin de partida. Para volver a jugar, la función _reiniciar_juego() utiliza el método get_tree().reload_current_scene(), para restablecer completamente el estado del juego, ya que al recargar la escena, todas las variables (muerto, tiempo_total, asteroides, etc.) se reinicializan automáticamente a sus valores por defecto definidos en el script, eliminando la necesidad de una función de reseteo manual.

Interfaz de usuario (UI) y dificultad progresiva

La gestión de la interfaz de usuario se basa en la creación dinámica de nodos Label. Funciones como _crear_ui_tiempo() y _crear_ui_game_over() instancian estos nodos, configuran sus propiedades (tamaño de fuente, visibilidad) y los añaden a la escena. La actualización del cronómetro se realiza en _actualizar_tiempo(), que incrementa un contador y refresca el texto del Label correspondiente en cada frame.

Para mantener el interés del jugador, el juego implementa una curva de dificultad progresiva en la función _actualizar_dificultad(). Este mecanismo ajusta dinámicamente dos parámetros clave con el paso del tiempo: aumenta gradualmente la velocidad de caída de los asteroides (vel_asteroides) y reduce el intervalo de tiempo entre sus apariciones (intervalo_asteroides). El resultado es un desafío que se intensifica de forma constante, manteniendo la tensión a lo largo de la partida. Para evitar que la dificultad escale hasta un punto injugable, se impone un límite inferior de 0.1 segundos al intervalo de aparición mediante el uso de max(), garantizando que la cadencia de obstáculos nunca supere un umbral predefinido.

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 y las imágenes, 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 (paleta de colores, 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.
  • Google AI Studio (https://aistudio.google.com/): entorno online gratuito con inteligencia artificial para generar 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.

Modificar la velocidad inicial de los asteroides

Cambia el valor de la variable vel_asteroides para que los asteroides empiecen cayendo más rápido o más lento. Comprueba cómo afecta esto a la dificultad de las partidas.

Modificar el incremento de la dificultad

En la función _actualizar_dificultad(), haz que la velocidad de los asteroides aumente más o menos rápido, o que el intervalo entre asteroides baje más despacio o más rápido. ¿Qué configuración te parece la más divertida?

Cambiar los colores de fondo

Edita las constantes COLOR_FONDO (cuando juegas) y COLOR_GAME_OVER (cuando pierdes). Prueba colores llamativos o incluso combina tonos que creen un efecto distinto al perder (por ejemplo, verde o azul en lugar de rojo).

Ajustar el tamaño de la nave y los asteroides

Modifica las constantes TAM_JUGADOR y TAM_ASTEROIDE para 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: ¿es más fácil esquivar los asteroides cuando son pequeños?

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) una nueva imagen para la nave y otra para los asteroides. Si cambias el nombre de los ficheros, actualiza las variables TEX_JUGADOR y TEX_ASTEROIDE para utilizar el nombre de tus imágenes y juega para ver cómo cambia la estética del juego.

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:

Y en el siguiente vídeo puedes observar cómo el juego se ejecuta desde Godot a pantalla completa, y cómo la dificultad del juego se va incrementando hasta que finalmente el jugador colisiona con un asteroide. En ese momento se utiliza el botón de cerrar para volver al entorno de desarrollo: