Parte: 5 — Inteligencia artificial para juegos · Fuente: Mat Buckland, "Programming Game AI by Example" + Ian Millington, "Artificial Intelligence for Games" ⏱️ Duración estimada: 55 min · Nivel: Intermedio
Implementar la estructura de decisión más usada de la historia de los videojuegos: la máquina de estados finita. Al terminar habrás construido un enemigo real en un CharacterBody2D que patrulla, detecta al jugador con un RayCast2D, lo persigue, lo ataca en rango y vuelve a patrullar cuando lo pierde. Dominarás las dos formas de implementarla —enum+match y clases de estado con enter/update/exit— y sabrás cuándo conviene cada una.
Al finalizar, el alumno podrá:
enum y match en un CharacterBody2D.enter/update/exit.RayCast2D y distancia para disparar transiciones creíbles.| # | Tema | Por qué importa |
|---|---|---|
| 1 | Estados y transiciones | Son los dos únicos conceptos que necesita una FSM |
| 2 | FSM con enum + match | La forma más rápida y legible para pocos estados |
| 3 | enter / update / exit | Estructura cada estado y evita lógica duplicada |
| 4 | Condiciones de transición | Deciden cuándo el agente cambia de comportamiento |
| 5 | Patrón de clases de estado | Escala mejor cuando los estados crecen |
| 6 | Percepción con RayCast2D | Da al enemigo una línea de visión honesta |
| 7 | Patrol → Chase → Attack → Patrol | El ciclo clásico de enemigo de acción |
enter inicializa, update corre cada frame, exit limpia.velocity y move_and_slide(). Clave: base de casi todo agente 2D en Godot 4.Necesitas Godot 4.x y una escena 2D. Prepara un CharacterBody2D para el enemigo con un Sprite2D (o ColorRect), un RayCast2D para la visión y opcionalmente un Timer para el cooldown de ataque. Ten en la escena un nodo que represente al jugador dentro de un grupo llamado player para poder localizarlo con get_tree().get_first_node_in_group(). Repasa el patrón enum/match en la documentación de GDScript y el uso de RayCast2D. Añade un TileMapLayer o unos muros con colisión para que la visión pueda bloquearse.
Construiremos el enemigo dos veces: primero con enum+match (rápido) y luego mostraremos el salto a clases de estado.
Paso 1 — Escena del enemigo. Crea un CharacterBody2D llamado Enemigo. Añádele hijos: un Sprite2D, un CollisionShape2D y un RayCast2D con su target_position apuntando hacia adelante (por ejemplo Vector2(120, 0)). Marca Enabled en el RayCast.
Paso 2 — FSM con enum + match. Adjunta este script al Enemigo:
extends CharacterBody2D
enum Estado { PATRULLA, PERSIGUE, ATAQUE }
@export var velocidad: float = 80.0
@export var rango_vision: float = 220.0
@export var rango_ataque: float = 40.0
@export var puntos_patrulla: Array[Vector2] = [Vector2(-150, 0), Vector2(150, 0)]
@onready var vision: RayCast2D = $RayCast2D
var estado: Estado = Estado.PATRULLA
var jugador: Node2D
var indice_patrulla: int = 0
var origen: Vector2
func _ready() -> void:
origen = global_position
jugador = get_tree().get_first_node_in_group("player")
func _physics_process(_delta: float) -> void:
match estado:
Estado.PATRULLA: _patrullar()
Estado.PERSIGUE: _perseguir()
Estado.ATAQUE: _atacar()
move_and_slide()
func _ve_al_jugador() -> bool:
if jugador == null:
return false
var d := global_position.distance_to(jugador.global_position)
if d > rango_vision:
return false
# Apuntamos el rayo al jugador y comprobamos que no haya un muro en medio.
vision.target_position = to_local(jugador.global_position)
vision.force_raycast_update()
if vision.is_colliding():
return vision.get_collider() == jugador
return true
func _distancia_jugador() -> float:
return global_position.distance_to(jugador.global_position) if jugador else INF
func _patrullar() -> void:
var destino := origen + puntos_patrulla[indice_patrulla]
velocity = global_position.direction_to(destino) * velocidad
if global_position.distance_to(destino) < 8.0:
indice_patrulla = (indice_patrulla + 1) % puntos_patrulla.size()
if _ve_al_jugador():
estado = Estado.PERSIGUE
func _perseguir() -> void:
velocity = global_position.direction_to(jugador.global_position) * velocidad
if _distancia_jugador() <= rango_ataque:
estado = Estado.ATAQUE
elif not _ve_al_jugador():
estado = Estado.PATRULLA
func _atacar() -> void:
velocity = Vector2.ZERO # se detiene para golpear
if _distancia_jugador() > rango_ataque:
estado = Estado.PERSIGUE
Paso 3 — Prueba en movimiento. Coloca el jugador (un CharacterBody2D en el grupo player, muévelo con las flechas) en la escena. Ejecuta: el enemigo patrulla entre dos puntos, y cuando entras en su cono de visión sin muro de por medio, te persigue; si te acercas, se detiene a atacar; si te alejas o te escondes tras un muro, vuelve a patrullar.
Paso 4 — Depuración visual. Añade en _physics_process un print(Estado.keys()[estado]) temporal para ver en consola cada transición, o pinta el Sprite2D de otro color por estado usando $Sprite2D.modulate.
Paso 5 — El salto a clases de estado. Cuando los estados crecen, match se vuelve un bloque enorme. La alternativa es una clase por estado con enter/update/exit:
# estado_base.gd — clase base para el patrón de estados
class_name EstadoIA
extends RefCounted
var agente: CharacterBody2D
func _init(a: CharacterBody2D) -> void:
agente = a
func enter() -> void: pass
func update(_delta: float) -> void: pass
func exit() -> void: pass
Cada estado concreto (EstadoPatrulla, EstadoPersigue) hereda de EstadoIA, implementa su update y devuelve el siguiente estado. El agente guarda estado_actual y en _physics_process llama a estado_actual.update(delta). Este patrón es la base de la HFSM de la próxima clase.
Resultado visible: un enemigo que patrulla, te caza al verte, se detiene a atacar en rango y regresa a su ruta al perderte, con la línea de visión bloqueada por muros.
HUIDA que se active cuando la "vida" del enemigo baje de un umbral.PERSIGUE el enemigo gire su Sprite2D hacia el jugador con look_at o atan2.Timer de cooldown (memoria de golpe).PATRULLA y PERSIGUE al patrón de clases de estado.PERSIGUE vuelva a PATRULLA para que no cambie bruscamente.Entrega un enemigo con cuatro estados (PATRULLA, PERSIGUE, ATAQUE, HUIDA) implementado con FSM, cuya visión use RayCast2D y respete los muros, y con al menos una transición basada en un Timer (cooldown o memoria).
Criterio de aceptación: el enemigo transita correctamente entre los cuatro estados durante una partida, no atraviesa muros con la visión, y demuestras con logs o cambios de color del sprite que cada transición se dispara por su condición.
| Síntoma | Causa y arreglo |
|---|---|
| El enemigo "ve" a través de muros | No compruebas el colisionador del RayCast2D; verifica get_collider() == jugador |
| Se queda vibrando entre dos estados | Las condiciones de ida y vuelta se solapan; separa los umbrales (histéresis) |
move_and_slide() da error de argumentos |
Estás usando la firma de Godot 3; en Godot 4 se llama sin argumentos y usa velocity |
jugador es null |
El nodo del jugador no está en el grupo player; añádelo con add_to_group |
| El RayCast no detecta nada | Olvidaste force_raycast_update() tras mover target_position en el mismo frame |
| Todos los estados en una función ilegible | Muchos estados en un match; migra al patrón de clases de estado |
¿Cuándo uso enum+match y cuándo clases de estado?
enum+match para 3-5 estados simples. Clases de estado cuando hay muchos estados, cada uno con lógica de entrada/salida propia.
¿Por qué move_and_slide() no lleva argumentos en Godot 4?
Porque ahora lee la propiedad velocity del cuerpo. En Godot 3 recibía la velocidad como parámetro; ese código ya no compila.
¿Puedo tener dos estados activos a la vez? No en una FSM plana: por definición hay uno solo. Si necesitas concurrencia (moverse y disparar), lo verás con HFSM y behavior trees.
¿Cómo evito que el enemigo tiemble al cambiar de estado? Añade histéresis (umbrales distintos para entrar y salir) o un pequeño temporizador que retrase la transición inversa.
Clase 108 - Panorama de la IA de juegos: qué es y qué no