Parte: 5 — Inteligencia artificial para juegos · Fuente: Steve Rabin, "Game AI Pro" (artículo de behavior trees) + Ian Millington, "Artificial Intelligence for Games" ⏱️ Duración estimada: 55 min · Nivel: Intermedio
Entender e implementar desde cero la estructura de decisión estándar de la industria para enemigos complejos: el behavior tree. Al terminar sabrás qué es un nodo composite (Sequence, Selector), un decorator (Inverter, Repeater) y un leaf (Action, Condition), qué significan los estados SUCCESS / FAILURE / RUNNING, y habrás construido un mini-BT en GDScript con clases RefCounted que ejecuta una lógica real y observable por consola.
Al finalizar, el alumno podrá:
| # | Tema | Por qué importa |
|---|---|---|
| 1 | Qué es un behavior tree | Reemplaza el enredo de transiciones de la FSM |
| 2 | Estados SUCCESS/FAILURE/RUNNING | El lenguaje común de todos los nodos |
| 3 | Composite: Sequence | Ejecuta hijos en orden hasta que uno falla |
| 4 | Composite: Selector | Prueba hijos hasta que uno tiene éxito |
| 5 | Decorator: Inverter/Repeater | Modifican el resultado o repiten un hijo |
| 6 | Leaf: Action y Condition | Donde ocurre el trabajo real |
| 7 | El tick del árbol | Cómo se evalúa cada frame de arriba abajo |
SUCCESS, FAILURE o RUNNING. Clave: RUNNING permite acciones que duran varios frames.RUNNING mientras trabaja.SUCCESS/FAILURE. Clave: nunca devuelve RUNNING.Necesitas Godot 4.x. Este laboratorio es de arquitectura pura: crearemos varias clases RefCounted con class_name, sin depender de nodos de escena, y un Node raíz que haga el tick cada frame. Ten claro el concepto de herencia en GDScript y el uso de Array tipados. Como lectura de apoyo, el artículo clásico "Behavior Trees for AI: How They Work" y los capítulos de BT en Game AI Pro explican la teoría; aquí la implementamos. Repasa RefCounted y la herencia con extends en la documentación de GDScript.
Construiremos un mini-framework de BT con clases y lo probaremos con un árbol que decide entre "beber" (si tiene sed) o "trabajar".
Paso 1 — El nodo base y los estados. Crea bt.gd con la clase base y el enum de estados:
# bt.gd
class_name BTNode
extends RefCounted
enum Estado { SUCCESS, FAILURE, RUNNING }
# tick() se llama en cada evaluación; cada nodo lo sobrescribe.
func tick() -> Estado:
return Estado.FAILURE
Paso 2 — Composites: Sequence y Selector. Ambos guardan una lista de hijos y difieren en la lógica de recorrido:
# secuencia.gd
class_name BTSequence
extends BTNode
var hijos: Array[BTNode] = []
func _init(lista: Array[BTNode] = []) -> void:
hijos = lista
# Sequence = "Y": corre hijos en orden; si uno falla, falla todo.
func tick() -> Estado:
for hijo in hijos:
var r := hijo.tick()
if r != Estado.SUCCESS:
return r # propaga FAILURE o RUNNING y se detiene
return Estado.SUCCESS
# selector.gd
class_name BTSelector
extends BTNode
var hijos: Array[BTNode] = []
func _init(lista: Array[BTNode] = []) -> void:
hijos = lista
# Selector = "O": prueba hijos; el primero que triunfa gana.
func tick() -> Estado:
for hijo in hijos:
var r := hijo.tick()
if r != Estado.FAILURE:
return r # devuelve SUCCESS o RUNNING y se detiene
return Estado.FAILURE
Paso 3 — Decorator: Inverter. Un decorator envuelve a un solo hijo y transforma su resultado:
# inverter.gd
class_name BTInverter
extends BTNode
var hijo: BTNode
func _init(h: BTNode) -> void:
hijo = h
# Invierte SUCCESS<->FAILURE; RUNNING pasa sin tocar.
func tick() -> Estado:
var r := hijo.tick()
if r == Estado.SUCCESS:
return Estado.FAILURE
if r == Estado.FAILURE:
return Estado.SUCCESS
return Estado.RUNNING
Paso 4 — Leaves: Condition y Action con callables. Para no crear una clase por comportamiento, aceptamos un Callable:
# hojas.gd
class_name BTCondition
extends BTNode
var prueba: Callable # debe devolver bool
func _init(fn: Callable) -> void:
prueba = fn
func tick() -> Estado:
return Estado.SUCCESS if prueba.call() else Estado.FAILURE
# accion.gd
class_name BTAction
extends BTNode
var accion: Callable # debe devolver un BTNode.Estado
func _init(fn: Callable) -> void:
accion = fn
func tick() -> Estado:
return accion.call()
Paso 5 — Montar y tickear el árbol. Crea un Node raíz con este script y ejecútalo:
extends Node
var sed: bool = true
func _ready() -> void:
# Selector: primero intenta "si tiene sed, beber"; si no, trabaja.
var arbol := BTSelector.new([
BTSequence.new([
BTCondition.new(func(): return sed),
BTAction.new(_beber),
]) as BTNode,
BTAction.new(_trabajar) as BTNode,
])
for frame in 3:
print("Tick ", frame, " -> ", BTNode.Estado.keys()[arbol.tick()])
sed = false # tras el primer tick ya no tiene sed
func _beber() -> BTNode.Estado:
print(" Bebo agua. Sed saciada.")
return BTNode.Estado.SUCCESS
func _trabajar() -> BTNode.Estado:
print(" Trabajo tranquilo.")
return BTNode.Estado.SUCCESS
Resultado visible: en consola, el primer tick imprime "Bebo agua" (había sed) y los siguientes imprimen "Trabajo tranquilo", demostrando cómo el Selector prioriza la rama de beber solo cuando su condición se cumple.
BTRepeater que repita a su hijo N veces y pruébalo con una acción que imprima.hay_enemigo_cerca y una acción huir, y combínalas en una Sequence.BTInverter para expresar "si NO tiene sed".descansar) al Selector raíz._trabajar devuelva RUNNING en los primeros dos ticks y SUCCESS al tercero.Construye un behavior tree con al menos un Selector, dos Sequences, un decorator (Inverter o Repeater) y cuatro hojas (dos Condition, dos Action), que resuelva una lógica de "sobrevivir" (buscar comida si hay hambre, huir si hay peligro, si no explorar) y que imprima por consola la decisión de cada tick.
Criterio de aceptación: el árbol se ejecuta sin errores durante varios ticks, cambia de decisión al cambiar las condiciones simuladas, y usas correctamente al menos una vez cada tipo de nodo (composite, decorator, leaf).
| Síntoma | Causa y arreglo |
|---|---|
| Una Condition devuelve RUNNING | Las condiciones solo devuelven SUCCESS/FAILURE; corrige el nodo |
| El Selector nunca prueba el segundo hijo | El primero devuelve SUCCESS o RUNNING; revisa si esa es tu intención |
| Error "Callable is null" al tickear | Pasaste una función inexistente; verifica el nombre del Callable |
| El árbol siempre falla | Confundiste Sequence (Y) con Selector (O); revisa la semántica |
| RUNNING se pierde y reinicia la acción | No propagas RUNNING hacia arriba; los composites deben devolverlo |
| Array de hijos sin tipar da warnings | Declara Array[BTNode] y castea con as BTNode si es necesario |
¿Por qué un BT escala mejor que una FSM? Porque la lógica es composicional: añades una rama sin tocar transiciones existentes. En una FSM, cada estado nuevo puede requerir muchas transiciones.
¿Qué aporta el estado RUNNING? Permite acciones que duran varios frames (caminar hasta un punto) sin bloquear el árbol: el nodo dice "sigo en ello" y se retoma en el próximo tick.
¿Cada frame se evalúa todo el árbol?
En la versión simple sí, desde la raíz. Implementaciones avanzadas recuerdan el nodo RUNNING para reanudar, optimización que verás más adelante.
¿Diferencia entre Selector y un if/elif?
Conceptualmente parecidos, pero el Selector es un dato componible: puedes reordenar prioridades, insertar decorators y reutilizar subárboles sin reescribir condicionales.
Clase 110 - Máquinas de estado jerárquicas (HFSM)