Saltar al contenido principal
Volver atrás

Godot #3: Señales y Enemigos

#godot #gdscript #signals #area2d

Damos vida al juego con enemigos. Aprende sobre Area2D, colisiones y señales por código.

Enemigos y Señales: ¡Contacto!

Ya disparamos, pero nuestras balas viajan hacia el infinito sin golpear nada. Es hora de crear dianas voladoras. Pero antes, una decisión arquitectónica vital.

1. El Nodo Area2D

Para el jugador usamos CharacterBody2D porque queríamos física de movimiento precisa (chocar con paredes y deslizarse). Para las balas y enemigos simples, usaremos Area2D.

¿Por qué usamos Area2D?

  • Es más barato: Consume menos CPU que un cuerpo físico completo.
  • Permite atravesar: En un Space Shooter, no quieres que los enemigos reboten, sino que detecten que te han tocado y exploten. Area2D es perfecta para detectar solapamientos (“overlap”) sin empujar.
  • Superpoderes Físicos: Hereda de CollisionObject2D, lo que lo hace visible para el motor de física (a diferencia de un Node2D normal que sería invisible aunque tuviera forma).

Preparando la Escena Enemiga

Puedes usar tu propio sprite o descargar este:

Sprite del Enemigo

Clic derecho -> Guardar imagen.
Créditos: Set of military aircraft por brgfx en Freepik (Licencia gratuita con atribución).

  1. Crea nueva Escena. Nodo raíz: Area2D. Nombre: EnemyPlane.
  2. Añade Sprite2D (tu nave enemiga).
  3. Añade CollisionShape2D (círculo o rectángulo que cubra la nave).
  4. Adjunta un Script en Area2D: Guárdalo como enemy_plane.gd.
¡No olvides la forma!

Un Area2D sin CollisionShape2D no sirve para nada. Godot te mostrará una alerta ⚠️ amarilla si se te olvida ponerlo.

2. Preparando el Script

Antes de escribir el código, analicemos qué necesita hacer nuestro enemigo. Vamos a mezclar tres conceptos:

A. Señales por Código

En el capítulo anterior usamos el editor para conectar señales. Hoy lo haremos por Código en _ready() usando connect(). Esto permite que cada enemigo sea autónomo y sepa reaccionar sin necesidad de configurarlo manualmente en el editor cada vez que instanciamos uno.

B. Físicas Simuladas

Como usamos Area2D y no un cuerpo físico (RigidBody), la gravedad no nos afecta. Si queremos que el enemigo caiga dramáticamente al morir, tendremos que programar esa caída manualmente modificando su posición. Es un truco visual muy común y barato.

C. Estado

Necesitaremos saber si el enemigo está vivo (moviéndose recto) o muriendo (cayendo). Usaremos una variable (“bandera”) para controlar este estado.

3. Programando al Enemigo

Vamos a construir el script pieza a pieza.

Fase 1: Movimiento Básico

Primero, hagamos que se mueva a la izquierda. Nada nuevo aquí.

gdscript
extends Area2D

# Exportamos la velocidad para que la podamos ajustar en el editor
@export var speed = 150

# Ignoramos por ahora _ready()
func _ready() -> void:
  pass

# Añadimos el movimiento en _process
func _process(delta) -> void:
  position.x -= speed * delta

Profundizando en @export:

En Part 1 vimos que @export hace que la variable aparezca en el Inspector. Pero hay más:

  1. Balanceo sin recompilar: Puedes cambiar speed en el Inspector mientras el juego está PAUSADO (F7) y ver el efecto inmediatamente. Esto es invaluable para ajustar la dificultad.
  2. Valores por instancia: Si tienes 3 enemigos en la escena, cada uno puede tener un speed diferente SIN modificar el script. Simple: selecciona uno y cambia su valor en el Inspector.
  3. Documentación implícita: El nombre de la variable aparece en el editor, haciendo que otros (o tú del futuro) entiendan qué se puede ajustar.

Fase 2: Conexión de Señales

Ahora añadimos la conexión. Recuerda: connect(quien_responde).

gdscript
func _ready():
  # area_entered es una señal de Area2D, con .connect haremos que llame a una 
  # función que definiremos ahora más abajo
  area_entered.connect(_on_area_entered)

# Esta función se ejecutará cuando algo toque este el CollisionShape2D de este Area2D
# gracias al comportamiento de area_entered
func _on_area_entered(area):
  print("¡Me han dado!") # <-- Esto es solo temporal

¿De dónde sale el parámetro area?

La señal area_entered no solo avisa de la colisión, también pasa información: el otro Area2D que ha colisionado contigo. En nuestro caso, ese “otro” es la bala disparada por el jugador.

Por eso, dentro de _on_area_entered(area), la variable area contiene una referencia directa al nodo bala. Esto nos permite hacer cosas como area.queue_free() para destruirla.

Fase 3: Vida y Muerte

Añadimos vida (hp) y la lógica de recibir daño.

gdscript
@export var speed = 150
@export var hp = 3 # Añadimos la variable hp para controlar la vida y la exponemos 
                   # en el editor por comodidad

# Código intermedio ---

func _on_area_entered(area):
  # El parámetro 'area' es el OTRO Area2D que ha colisionado, la bala
  hp -= 1
  # area.queue_free() # Si lo descomentas, la bala se destruirá (no atravesará)
  
  # Un condicional 'if' básico, si hp es menor o igual a 0, llama a die()
  if hp <= 0:
      die()

func die():
  # Como ya explicamos, queue_free() eliminará al Area2D 
  # (lo mete en una cola para eliminarlo )
  queue_free()

Fase 4: Muerte Dramática (Físicas Fake)

Como usamos Area2D y no RigidBody2D, no tenemos física real. Vamos a “fingir” que el enemigo cae al morir. Necesitamos una variable is_dying para saber si está en “modo caída”.

gdscript
# Definimos variables un par de variables de estado del enemigo
var is_dying = false # booleano, si está muerto o no, por defecto no está muerto (false)
var fall_speed = 0.0 # número, controla la velocidad de caída, por defecto 0

func _process(delta):
  if is_dying:
      # Caída con gravedad simulada, si muere le añadimos velocidad
      fall_speed += 500 * delta 
      # Ahora, teniendo la velocidad le decimos hacia dónde moverse
      position.y += fall_speed * delta # Le añadimos velocidad vertical
      position.x -= speed * 0.5 * delta # Le restamos velocidad horizontal (frenada)
  else:
      position.x -= speed * delta # Movimiento normal que ya teníamos

¿Qué es ese else? Hasta ahora hemos usado if (“Si pasa esto…”). El else significa “Si NO pasa eso…”. Es como un interruptor de dos vías:

  • IF (is_dying es true): Ejecuta la física de caída.
  • ELSE (si no, en el otro caso): Ejecuta el movimiento normal hacia la izquierda.

Esto nos garantiza que el enemigo nunca hará las dos cosas a la vez. O está se está acercando al player, o está muriendo.

Fase 5: Eliminación de la Instancia

Cuando hp llega a 0, llamamos a die(). Esta función:

  1. Activa el estado is_dying para que _process ejecute la caída.
  2. Desactiva las colisiones para ignorar impactos durante la animación.
  3. Usa un timer para destruir el nodo después de 1 segundo.
gdscript
func die():
  is_dying = true
  # set_deferred evita errores si se llama durante una colisión
  $CollisionShape2D.set_deferred("disabled", true)
  # Espera 1 segundo y destruye
  await get_tree().create_timer(1.0).timeout
  queue_free()

Fase 6: Evitando Bugs (La Cláusula de Guardia)

Hay un detalle sutil pero peligroso. ¿Qué pasa si le metemos 20 balazos al enemigo mientras está cayendo? Que llamaríamos a die() 20 veces.

Para evitar esto mejoramos _on_area_entered con dos reglas de oro:

  1. Si ya estoy muriendo, ignora las balas: Usamos if is_dying: return para salir de la función inmediatamente.
  2. Si me tocas, desapareces: La bala (area) debe destruirse siempre tras impactar.
gdscript
func _on_area_entered(area):
  # 1. Cláusula de Guardia: Si ya estoy muerto, no molestes
  if is_dying:
      return
      
  hp -= 1
  # 2. La bala impacta y desaparece (siempre)
  area.queue_free()
  
  if hp <= 0:
      die()

5. Script Completo

Ahora junta todas las piezas. Así queda tu archivo enemy_plane.gd final:

gdscript
extends Area2D

@export var speed = 150
@export var hp = 3

var is_dying = false
var fall_speed = 0.0

func _ready():
  area_entered.connect(_on_area_entered)

func _process(delta):
  if is_dying:
      fall_speed += 500 * delta
      position.y += fall_speed * delta
      position.x -= speed * 0.5 * delta
  else:
      position.x -= speed * delta

func _on_area_entered(area):
  if is_dying:
      return
      
  hp -= 1
  area.queue_free()
  
  if hp <= 0:
      die()

func die():
  is_dying = true
  $CollisionShape2D.set_deferred("disabled", true)
  await get_tree().create_timer(1.0).timeout
  queue_free()

¡Hecho! Has creado un enemigo autónomo que gestiona su propio movimiento, sus colisiones y su muerte, todo encapsulado en un script robusto.


Probando en tu Mundo

Antes de pasar al siguiente capítulo, verifica que todo funciona:

  1. Abre level.tscn (tu escena principal).
  2. Arrastra enemy_plane.tscn desde el panel FileSystem al viewport.
  3. Posiciona el enemigo a la derecha de la pantalla (para que vuele hacia el jugador).
  4. Ejecuta el juego (F5).
  5. Dispara al enemigo y verifica:
    • Las balas desaparecen al impactar.
    • El enemigo pierde HP y eventualmente cae girando.
    • Tras 1 segundo de caída, desaparece.

Si todo funciona, ¡felicidades! Tu sistema de combate está operativo.


Repasemos lo aprendido

  1. Area2D: Nodo para detectar solapamientos sin física de empuje.
  2. Señales por código: Usar signal.connect(función) en _ready() para encapsular la lógica.
  3. Estado (is_dying): Usar variables “bandera” para controlar el comportamiento.
  4. Físicas simuladas: Modificar position manualmente para crear efectos de caída.
  5. set_deferred(): Desactivar colisiones de forma segura durante callbacks de física.
  6. await + Timer: Esperar un tiempo antes de ejecutar código (ej: destruir tras 1 segundo).

En el próximo capítulo, aprenderemos a dar feedback visual con Shaders (el famoso “Hit Flash”).