Enemigos y patrones de comportamiento 2D con GDScript

Capítulo 9

Tiempo estimado de lectura: 9 minutos

+ Ejercicio

Objetivo del capítulo

En este capítulo vas a construir una escena de enemigo reutilizable con parámetros exportables (por ejemplo, velocidad y rango), movimiento con colisión y un sistema de interacción con el jugador (daño, retroceso o reinicio). Además, usarás señales para comunicar eventos (por ejemplo, “el jugador fue golpeado”) sin acoplar el enemigo a la implementación del jugador.

Escena base: Enemigo patrullero (reutilizable)

Estructura recomendada de nodos

Crea una escena nueva llamada EnemyPatrol.tscn con un nodo raíz CharacterBody2D (nombre: Enemy). Añade:

  • Sprite2D (o AnimatedSprite2D si ya tienes animaciones)
  • CollisionShape2D (para el cuerpo del enemigo)
  • Area2D (nombre: Hitbox) con su CollisionShape2D (para detectar al jugador)
  • RayCast2D (opcional, nombre: WallCheck) apuntando hacia delante para detectar paredes
  • RayCast2D (opcional, nombre: FloorCheck) apuntando hacia abajo y ligeramente hacia delante para detectar borde/precipicio

La idea es separar “cuerpo que colisiona con el mundo” (CharacterBody2D) de “zona de daño” (Area2D). Esto facilita variar el comportamiento sin rearmar colisiones.

Parámetros exportables y señales

En el script del enemigo, exporta variables para ajustar el comportamiento desde el Inspector y define señales para notificar eventos. Esto permite reutilizar la escena en distintos niveles con valores diferentes.

extends CharacterBody2D

signal hit_player(player: Node, damage: int, knockback: Vector2)
signal died

@export var speed: float = 60.0
@export var patrol_range: float = 120.0
@export var damage: int = 1
@export var knockback_strength: float = 220.0
@export var gravity: float = 900.0

var _start_x: float
var _dir: int = 1

@onready var hitbox: Area2D = $Hitbox
@onready var wall_check: RayCast2D = get_node_or_null("WallCheck")
@onready var floor_check: RayCast2D = get_node_or_null("FloorCheck")

func _ready() -> void:
	_start_x = global_position.x
	hitbox.body_entered.connect(_on_hitbox_body_entered)

Movimiento de patrulla con colisión

El patrullero se mueve horizontalmente entre dos límites basados en patrol_range. Además, puede invertir dirección si detecta pared o si no hay suelo delante (si usas RayCast2D).

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

Descargar la aplicación

func _physics_process(delta: float) -> void:
	# Gravedad
	if not is_on_floor():
		velocity.y += gravity * delta
	else:
		velocity.y = 0.0

	# Lógica de giro por rango
	var left_limit := _start_x - patrol_range * 0.5
	var right_limit := _start_x + patrol_range * 0.5
	if global_position.x <= left_limit:
		_dir = 1
	elif global_position.x >= right_limit:
		_dir = -1

	# Lógica de giro por sensores (opcional)
	if wall_check and wall_check.is_colliding():
		_dir *= -1
	if floor_check and not floor_check.is_colliding():
		_dir *= -1

	velocity.x = _dir * speed
	move_and_slide()

	# (Opcional) Voltear sprite según dirección
	if has_node("Sprite2D"):
		$Sprite2D.flip_h = _dir < 0

Interacción con el jugador: daño, retroceso o reinicio

Para mantener el código modular, el enemigo no debería “asumir” cómo está implementado el jugador. En lugar de llamar métodos específicos, emite una señal con datos (daño y retroceso) y deja que el jugador o un “GameManager” decida qué hacer.

Conecta el Area2D (Hitbox) al evento body_entered. Cuando el jugador entra, emite la señal. El retroceso se calcula desde la posición relativa.

func _on_hitbox_body_entered(body: Node) -> void:
	# Filtra por grupo para evitar acoplarte a una clase concreta
	if not body.is_in_group("player"):
		return

	var dir_to_player := (body.global_position - global_position)
	var knock_dir := dir_to_player.normalized()
	# Empuja al jugador hacia afuera (desde el enemigo hacia el jugador)
	var knockback := knock_dir * knockback_strength

	emit_signal("hit_player", body, damage, knockback)

En tu escena del jugador (o en un nodo controlador), conecta la señal hit_player del enemigo. Ejemplo de manejo en el jugador (conceptual): aplicar retroceso, reducir vida o reiniciar.

# En el script del jugador (ejemplo de receptor)
func _on_enemy_hit_player(player: Node, damage: int, knockback: Vector2) -> void:
	# Si este método está en el propio jugador, puedes ignorar 'player'
	# 1) Daño
	health -= damage
	# 2) Retroceso
	velocity += knockback
	# 3) Alternativa: reinicio
	# get_tree().reload_current_scene()

Conexión de señales en el editor (flujo recomendado)

  • Selecciona el nodo Enemy en la escena donde lo instancies.
  • En la pestaña “Node”, localiza la señal hit_player.
  • Conéctala al nodo que deba reaccionar (jugador o manager).
  • Así, el enemigo queda reutilizable y no depende de rutas como ../Player.

Guía práctica paso a paso (implementación rápida)

1) Prepara el enemigo base

  • Crea EnemyPatrol.tscn con CharacterBody2D como raíz.
  • Añade CollisionShape2D al cuerpo.
  • Añade Area2D (Hitbox) con su CollisionShape2D ligeramente más grande o igual al cuerpo (según tu diseño).
  • (Opcional) Añade WallCheck y FloorCheck como RayCast2D y activa enabled.

2) Añade grupos y capas

  • Asigna al jugador el grupo player (Project Settings → Groups).
  • Configura capas/máscaras: el cuerpo del enemigo debe colisionar con el mundo; la Hitbox debe detectar al jugador (y no necesariamente al mundo).

3) Pega el script y ajusta exportables

  • Adjunta el script al nodo raíz Enemy.
  • En el Inspector, prueba valores: speed=60, patrol_range=160, knockback_strength=220.

4) Instancia y conecta señal

  • Instancia EnemyPatrol.tscn en tu nivel.
  • Conecta hit_player al jugador o a un nodo “GameManager”.
  • Implementa la reacción: daño, retroceso o reinicio.

Variaciones de comportamiento (mismo enfoque modular)

Las siguientes variaciones comparten la idea clave: parámetros exportables + señales + detección por grupos. Puedes implementarlas como escenas separadas (recomendado) que heredan de una base, o como un único script con “modo” configurable. Para mantener claridad, aquí se muestran como escenas/scripts distintos.

Variación A: Enemigo que cae desde arriba (emboscada)

Comportamiento: permanece “quieto” hasta que el jugador entra en una zona de activación; luego cae (o se suelta) y al tocar el suelo puede quedarse quieto, patrullar o destruirse.

Estructura sugerida:

  • Raíz: CharacterBody2D (EnemyDrop)
  • Area2D (Trigger) para detectar al jugador debajo
  • Area2D (Hitbox) para daño
extends CharacterBody2D

signal hit_player(player: Node, damage: int, knockback: Vector2)

@export var damage: int = 1
@export var gravity: float = 1200.0
@export var drop_speed_cap: float = 900.0
@export var activate_once: bool = true

var _active := false

@onready var trigger: Area2D = $Trigger
@onready var hitbox: Area2D = $Hitbox

func _ready() -> void:
	trigger.body_entered.connect(_on_trigger_body_entered)
	hitbox.body_entered.connect(_on_hitbox_body_entered)

func _physics_process(delta: float) -> void:
	if not _active:
		velocity = Vector2.ZERO
		return
	velocity.y = min(velocity.y + gravity * delta, drop_speed_cap)
	move_and_slide()

func _on_trigger_body_entered(body: Node) -> void:
	if body.is_in_group("player"):
		_active = true
		if activate_once:
			trigger.monitoring = false

func _on_hitbox_body_entered(body: Node) -> void:
	if not body.is_in_group("player"):
		return
	var knockback := (body.global_position - global_position).normalized() * 200.0
	emit_signal("hit_player", body, damage, knockback)

Variación B: Enemigo que persigue a corta distancia (chaser)

Comportamiento: patrulla o se queda quieto, pero si el jugador entra en un radio, lo persigue. Para evitar persecuciones infinitas, usa un rango de detección y otro de “desenganche” (histeresis) o un temporizador.

Estructura sugerida:

  • Raíz: CharacterBody2D (EnemyChaser)
  • Area2D (Aggro) con CollisionShape2D circular
  • Area2D (Hitbox)
extends CharacterBody2D

signal hit_player(player: Node, damage: int, knockback: Vector2)

@export var speed: float = 80.0
@export var gravity: float = 900.0
@export var damage: int = 1
@export var knockback_strength: float = 220.0
@export var disengage_distance: float = 220.0

var _target: Node2D = null

@onready var aggro: Area2D = $Aggro
@onready var hitbox: Area2D = $Hitbox

func _ready() -> void:
	aggro.body_entered.connect(_on_aggro_body_entered)
	aggro.body_exited.connect(_on_aggro_body_exited)
	hitbox.body_entered.connect(_on_hitbox_body_entered)

func _physics_process(delta: float) -> void:
	if not is_on_floor():
		velocity.y += gravity * delta
	else:
		velocity.y = 0.0

	if _target and is_instance_valid(_target):
		var to_target := _target.global_position - global_position
		# Desenganche por distancia (por si sale del área por teletransporte, etc.)
		if to_target.length() > disengage_distance:
			_target = null
		else:
			velocity.x = sign(to_target.x) * speed
	else:
		velocity.x = 0.0

	move_and_slide()

func _on_aggro_body_entered(body: Node) -> void:
	if body.is_in_group("player"):
		_target = body as Node2D

func _on_aggro_body_exited(body: Node) -> void:
	if body == _target:
		_target = null

func _on_hitbox_body_entered(body: Node) -> void:
	if not body.is_in_group("player"):
		return
	var knockback := (body.global_position - global_position).normalized() * knockback_strength
	emit_signal("hit_player", body, damage, knockback)

Recomendaciones para mantener el código modular

1) Señales + grupos en lugar de dependencias directas

  • El enemigo emite hit_player con datos; el receptor decide si resta vida, aplica invulnerabilidad, reproduce sonido o reinicia.
  • Usa body.is_in_group("player") para identificar al jugador sin importar su clase concreta.

2) Separa “detección” de “respuesta”

  • Area2D detecta; el script decide qué señal emitir.
  • La vida del jugador, UI y reinicio de escena deberían vivir fuera del enemigo.

3) Base común por herencia o composición

Si tendrás varios enemigos, crea una base EnemyBase.gd con señales, parámetros comunes y utilidades (por ejemplo, cálculo de knockback). Luego hereda:

# EnemyBase.gd
extends CharacterBody2D
class_name EnemyBase

signal hit_player(player: Node, damage: int, knockback: Vector2)

@export var damage: int = 1
@export var knockback_strength: float = 220.0

func compute_knockback(target: Node2D) -> Vector2:
	return (target.global_position - global_position).normalized() * knockback_strength

Y en cada enemigo:

extends EnemyBase

func _on_hitbox_body_entered(body: Node) -> void:
	if body.is_in_group("player"):
		emit_signal("hit_player", body, damage, compute_knockback(body))

4) Exporta “tuning knobs” y evita números mágicos

  • Velocidad, rango, gravedad, daño y fuerza de retroceso deben ser @export.
  • Esto permite iterar el diseño sin tocar código y reduce errores al duplicar enemigos.

5) Mantén estados simples y explícitos

Para comportamientos más complejos, usa un estado (enum) y transiciones claras (por ejemplo, IDLE, PATROL, CHASE, STUN). Evita mezclar muchas condiciones sueltas en _physics_process.

enum State { IDLE, PATROL, CHASE }
var state: State = State.PATROL

Ahora responde el ejercicio sobre el contenido:

¿Cuál es la forma más modular de manejar que un enemigo dañe y empuje al jugador sin depender de cómo está implementado el jugador?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Para mantener el enemigo reutilizable y desacoplado, debe comunicar el evento mediante una señal con los datos necesarios (daño y retroceso). La lógica concreta (restar vida, invulnerabilidad o reinicio) se resuelve en el receptor.

Siguiente capítulo

Sistema de juego: puntuación, vida, reinicio y condiciones de victoria

Arrow Right Icon
Portada de libro electrónico gratuitaGodot desde Cero: Crea tu Primer Juego 2D con GDScript
75%

Godot desde Cero: Crea tu Primer Juego 2D con GDScript

Nuevo curso

12 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.