Parte: 3 — Física y matemáticas de juegos aplicadas · Fuente: Craig Reynolds — "Steering Behaviors for Autonomous Characters" (1999) y práctica con Godot 4.x ⏱️ Duración estimada: 60 min · Nivel: Intermedio
Programar agentes que se mueven de forma creíble sin scripts rígidos ni caminos predefinidos, usando el modelo de steering behaviors de Craig Reynolds. Entenderás cómo una fuerza de dirección modifica la velocidad de un CharacterBody2D frame a frame para producir comportamientos como perseguir (seek), huir (flee), llegar frenando (arrive) y deambular (wander). Al final tendrás un agente que conmuta entre conductas y reacciona al mouse en tiempo real.
Al finalizar, el alumno podrá:
velocity de un CharacterBody2D.| # | Tema | Por qué importa |
|---|---|---|
| 1 | Velocidad deseada vs. actual | Núcleo del steering: la fuerza es su diferencia |
| 2 | Límites: max_speed y max_force |
Evitan movimiento imposible o tembloroso |
| 3 | Seek | Persecución básica de cualquier IA |
| 4 | Flee | Huida y evasión |
| 5 | Arrive | Llegar a un punto sin rebotar |
| 6 | Wander | Vida ambiental sin destino fijo |
| 7 | Combinar fuerzas | Base de flocking y comportamientos ricos |
max_speed. Clave: es "a dónde querría ir ahora mismo".deseada - velocidad_actual, recortada a max_force. Clave: corrige la trayectoria, no la impone de golpe.max_speed: rapidez máxima del agente. Clave: limita la magnitud de velocity con limit_length.max_force: cuánto puede corregir por frame. Clave: valores altos giran brusco; bajos, con inercia suave.Necesitas Godot 4.x (godotengine.org). Crea un proyecto 2D y una escena con un CharacterBody2D como agente; dale un Sprite2D (o un Polygon2D triangular para ver hacia dónde mira) y un CollisionShape2D. El agente se moverá con move_and_slide(), así que trabajaremos su propiedad velocity. Ten a mano la referencia de CharacterBody2D y Vector2. El objetivo será la posición del mouse (get_global_mouse_position()), así que podrás probar todo moviendo el cursor.
Construiremos un único agente con las cuatro conductas conmutables por tecla, que persigue o huye del mouse.
Paso 1 — Estructura base y seek. Adjunta este script al CharacterBody2D. El agente busca el mouse.
extends CharacterBody2D
enum Modo { SEEK, FLEE, ARRIVE, WANDER }
@export var max_speed := 300.0
@export var max_force := 800.0
@export var modo: Modo = Modo.SEEK
func _steer_hacia(deseada: Vector2) -> Vector2:
# Fuerza = velocidad deseada - velocidad actual (recortada)
return (deseada - velocity).limit_length(max_force)
func seek(objetivo: Vector2) -> Vector2:
var deseada := (objetivo - global_position).normalized() * max_speed
return _steer_hacia(deseada)
func _physics_process(delta: float) -> void:
var objetivo := get_global_mouse_position()
var fuerza := seek(objetivo)
velocity = (velocity + fuerza * delta).limit_length(max_speed)
move_and_slide()
if velocity.length() > 1.0:
rotation = velocity.angle() # mirar hacia donde va
Observable: el agente persigue el cursor y, al no frenar, lo orbita cuando lo alcanza. Esa órbita es la señal de que falta arrive.
Paso 2 — Flee y arrive. Añade las dos conductas. flee invierte la deseada; arrive escala la rapidez dentro de un radio.
@export var radio_frenado := 120.0
func flee(objetivo: Vector2) -> Vector2:
var deseada := (global_position - objetivo).normalized() * max_speed
return _steer_hacia(deseada)
func arrive(objetivo: Vector2) -> Vector2:
var hacia := objetivo - global_position
var dist := hacia.length()
var rapidez := max_speed
if dist < radio_frenado:
rapidez = max_speed * (dist / radio_frenado) # frena al acercarse
var deseada := hacia.normalized() * rapidez
return _steer_hacia(deseada)
Observable: con flee el agente escapa del cursor; con arrive llega al mouse y se detiene suavemente en vez de orbitar.
Paso 3 — Wander. El objetivo es un punto sobre un círculo proyectado delante del agente; su ángulo se desplaza con ruido cada frame.
@export var wander_dist := 80.0 # cuán adelante está el círculo
@export var wander_radio := 40.0 # tamaño del círculo
@export var wander_jitter := 4.0 # cuánto varía el ángulo
var _wander_ang := 0.0
func wander() -> Vector2:
_wander_ang += randf_range(-wander_jitter, wander_jitter) * 0.1
var dir := velocity.normalized()
if dir == Vector2.ZERO:
dir = Vector2.RIGHT
var centro := global_position + dir * wander_dist
var desplazamiento := Vector2(cos(_wander_ang), sin(_wander_ang)) * wander_radio
var deseada := (centro + desplazamiento - global_position).normalized() * max_speed
return _steer_hacia(deseada)
Observable: el agente vaga con curvas suaves y giros impredecibles pero no bruscos, como un animal explorando.
Paso 4 — Conmutar conductas. Un match en _physics_process elige la fuerza según modo, cambiable por teclado.
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_accept"):
modo = (modo + 1) % Modo.size() # rota entre conductas
func _fuerza_actual() -> Vector2:
var m := get_global_mouse_position()
match modo:
Modo.SEEK: return seek(m)
Modo.FLEE: return flee(m)
Modo.ARRIVE: return arrive(m)
Modo.WANDER: return wander()
return Vector2.ZERO
Observable: pulsando la tecla el mismo agente pasa de perseguir a huir, a llegar frenando y a deambular sin reiniciar la escena.
flee: solo huye si el mouse está a menos de 200 px.wander + flee sumando ambas fuerzas ponderadas para que vague pero evite el cursor.draw_line el vector velocity y la fuerza de steering para depurar visualmente.max_speed disminuya con una "energía" que se agota al perseguir y se recupera al deambular.CharacterBody3D reusando la misma lógica con Vector3.seek.Crea una manada de 8 agentes que hacen wander por defecto, pero cambian a flee cuando el mouse entra en su radio de pánico y vuelven a wander al alejarse. Los agentes no deben salir de la pantalla (rebota o envuelve en los bordes).
Criterio de aceptación: los 8 agentes se dispersan al acercar el cursor y retoman el vagar al alejarlo, ninguno queda atascado vibrando en un borde, y todas las conductas manipulan velocity respetando max_speed (ningún agente supera esa rapidez).
| Síntoma | Causa y arreglo |
|---|---|
| El agente orbita el objetivo sin parar | Usas seek donde necesitas arrive con radio de frenado |
| Movimiento tembloroso o hiperveloz | No limitas velocity con limit_length(max_speed) |
wander da giros bruscos aleatorios |
Reasignas el ángulo entero en vez de sumar un pequeño jitter |
| El agente no mira hacia donde va | Falta rotation = velocity.angle() (y evita hacerlo con velocidad ~0) |
| No se mueve nada | Olvidaste move_and_slide() o no asignaste velocity |
¿Steering es lo mismo que pathfinding? No. El pathfinding (A) decide la ruta en un mapa; el steering decide el movimiento suave momento a momento. Suelen combinarse: A da waypoints y arrive los sigue.
¿Por qué la fuerza es deseada - velocidad? Porque queremos corregir gradualmente hacia la velocidad deseada, dando inercia natural, en vez de teletransportar la velocidad.
¿Multiplico la fuerza por delta? Sí, al integrarla sobre la velocidad, para que el comportamiento sea independiente de los FPS.
¿Puedo sumar varias conductas a la vez? Sí: ese es el flocking. Suma las fuerzas (a veces ponderadas) y luego limita el total a max_force.
Clase 081 - Interpolación y easing (lerp, slerp y tweens)