Introducción
En esta segunda versión de Asteroides para Godot 4.5 vamos a transformar un mini–arcade en un proyecto con más vida, más feedback y más profundidad visual, manteniendo una premisa clave: que cada paso sea claro y aislado para quien está aprendiendo. Si en la primera versión nos centramos en la estructura básica —jugador, asteroides, colisiones y ciclo de juego—, en esta nueva versión nos proponemos añadir:
- Fondo desplazándose en bucle y estrellas con parallax (capas que se mueven a distintas velocidades según su “profundidad”).
- Disparos desde la nave (ratón y táctil, y también teclado), con colisiones que destruyen asteroides.
- Música de fondo y SFX (disparo, destrucción, muerte) para reforzar cada acción.
- Game Over visual (overlay) y pausa breve para evitar reinicios accidentales.
- Dificultad progresiva: la partida acelera poco a poco (más velocidad, mayor frecuencia de asteroides), potenciando el “pico de atención” típico del arcade.
La idea no es “tirar código y ya”, sino aprender a diseñar un juego por capas: primero el esqueleto con todas las funciones vacías (para ver el mapa completo), y luego la implementación función a función, explicando qué hace cada trozo de código, y cómo se integra en el ciclo de vida de Godot: _ready() para preparar, _process() para actualizar lógicas dependientes de tiempo, _draw() para dibujar en orden de capas, e _input() para traducir interacciones en estado de juego.
Además, reforzamos principios que conviene tener en cuenta cuando estás empezando:
- Separación de responsabilidades: los eventos de entrada solo cambian el valor de ciertas variables; el movimiento real se realiza en
_process()y el pintado en_draw(). - Independencia de FPS: cualquier desplazamiento (jugador, asteroides, disparos, fondo) se calcula con
delta, garantizando la misma experiencia a 30 o a 144 FPS. - Datos → Lógica → Render: representamos entidades con estructuras simples (
Rect2y diccionarios) y las transformamos en imágenes con dibujo inmediato, sin necesidad de nodos por cada elemento (ideal para comprender el pipeline y para arcades ligeros). - Programación defensiva y funciones reutilizables: inicialización y reproducción de audio, hitboxes ajustadas, reciclaje de entidades y “puntos únicos” de configuración.
Nociones básicas
Con el siguiente vídeo te harás una idea en unos pocos minutos del trabajo que llevaremos a cabo en esta unidad. Además, se explican conceptos básicos de forma muy sencilla, y así podrás entender más fácilmente cómo se puede implementar el juego propuesto utilizando Godot.
Análisis previo del código fuente
En el siguiente enlace encontrarás una presentación estructurada con varias diapositivas explicando paso a paso el desarrollo del juego (qué hace cada función, cómo se implementa, qué efectos visuales tienen, etc.):
Las imágenes y los ficheros de audio
Para nuestro juego necesitaremos varias imágenes y ficheros de audio. Para poder incluirlas en nuestro proyecto, primero debemos descargarlas en nuestro ordenador, y a continuación las arrastraremos a la zona de «Sistema de Archivos» de Godot. Las tienes disponibles en este archivo ZIP.
Imágenes





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