Movimiento Principiante 11 min de lectura

Steering Behaviors I: seek, flee, arrive

Aprende a crear movimientos orgánicos en tus juegos. Entenderás lo que son los agentes y aprenderás a calcular fuerzas hacia un target.

Publicado: · Por Juanjo "Banyo" López

0. Introducción

Cuando un enemigo se mueve hacia el jugador en línea recta y con velocidad constante, el resultado se siente robótico. Craig Reynolds resolvió ese problema en 1999 con steering behaviors: en lugar de teletransportar la velocidad deseada, le aplicas una fuerza (force) que lo empuja hacia su objetivo (target).

Antes de tocar la demo, conviene fijar tres conceptos que van repetirse durante todo el tutorial.

Si ya los conoces, puedes ir directo al seudocódigo

¿Qué es un agente?

Un agente es cualquier entidad del juego que se mueve por su cuenta: un enemigo, un NPC, un compañero, un proyectil guiado. En código, lo modelas como un objeto que mínimo tiene:

  • position — dónde está (Vec2 o Vec3).
  • velocity — hacia dónde va y a qué ritmo. (Vec2 o Vec3).
  • Límites: maxSpeed (Lo rápido que puede ir) y maxForce (Su límite en que cambia de dirección).

El agente no tiene “inteligencia” en el sentido clásico. Solo aplica reglas locales cada frame. La sensación de “estar vivo” emerge de cómo esas reglas se combinan.

¿Qué son los steering behaviors?

El comportamiento de movimiento, o dicho de otro modo, el como se mueven los objetos. Los Steering Behaviors son funciones puras que calculan una fuerza para asignar a un agente un movimiento. Esta fuerza corrige la velocidad actual. Cada comportamiento responde a una sola pregunta:

  • Seek: ¿cómo me acerco a este punto?
  • Flee: ¿cómo me alejo de este punto?
  • Arrive: ¿cómo me detengo sin pasarme?

La magia de los Steering Behaviors que que puedes sumar sus fuerzas:

total = seek(...) + 0.5 * flee(...)

¿Qué son las fuerzas?

Una fuerza es un vector (Vec2 o Vec3) que representa una corrección a la velocidad del agente. La regla universal de Reynolds es:

fuerza = velocidad_deseada − velocidad_actual

La “velocidad deseada” es a dónde el agente quiere ir, sin embargo esta limitada por su velocidad. Es como si el agente tuviera a un perrito con correa que lo esta jalando hacia un lugar.

Ademas, la fuerza del perrito también es limitada, ahi es donde entra maxForce. No es lo mismo pasear a un Chihuahua que a un Husky.

En cada frame la posicion del agente se calcula a partir de la fuerza de este modo.:

velocity += force * dt
position += velocity * dt

Los valores de force y velocity serán limitados por maxForce y maxSpeed correspondientemente.

Eso es todo. El resto del tutorial son variaciones sobre cómo calcular esa “velocidad deseada” según la situación.

Ahora sí, mueve el cursor sobre la demo y observa cómo el agente no salta a tu posición: empuja hacia ella.

1. Demo

Seek con Arrive Mueve el mouse, ve como el agente lo persigue. Pulsa Enter o Espacio para pausar/reanudar
Ajusta los valores y ve como cambian los vectores (velocidad & fuerza).

2. Concepto y matemáticas

La idea cabe en una línea:

fuerza_correccion = deseo − situacion_actual.

Mi deseo (desired) es hacia donde me quiero dirigir, se representa como un vector que va desde el agente hacia el objetivo (target).

Arrastra el agente o el target: la longitud del vector cambia con la distancia.

Antes dijimos que una fuerza es un vector, respetando esa idea, mi deseo sera un vector al igual que mi situacion_actual.

Después de todo, no puedo obtener perros restando peras a plumas. Sino que debo tener el mismo tipo de dato siempre.

Mi desired se calcula con una resta de vectores: la position del target menos la position del agente.

position_target - position_agent

Pero esto no estara limitado, si estoy muy lejos, entonces mi fuerza sera muy grande.

Arrastra los targets en cada panel. Sin normalizar, la magnitud crece linealmente con la distancia — por eso necesitamos limitarla.

Para limitar esta fuerza, puedo hacerlo limitando el vector con un valor maximo, en este caso, a mi maxSpeed asi mi perro que jala de mi correa se limita y no alcanza velocidades de un tren magnético.

normalize(position_target - position_agent) * maxSpeed

El círculo violeta es el "alcance" de maxSpeed. Arrastra el target dentro y fuera: la flecha violeta es el deseo limitado, la gris es el crudo.

Una vez que tengo definido mi desired, entonces puedo ajustar mi velocity, haciendo un cálculo a partir de restar estos valores.

Nota: Si tienes problemas en entender por que se restan, busca operaciones de vectores.

Una vez teniendo esto, mi fuerza de correccion (steer) se calcula limitando con maxForce:

normalize(desired - velocity) * maxForce

Al tener esta force, solo queda actualizar la velocity y la position del agente, recordando:

velocity += force * dt
position += velocity * dt

2.1 Seek paso a paso

  1. Calculo mi deseo (velocidad deseada hacia el target)

    desired = normalize(target − position) * maxSpeed

  2. Calculo mi fuerza de correccion (steer | steering force)

    steer = desired − velocity (limitado a maxForce)

  3. Actualizo mi velocity actual

    velocity += steer * dt (luego clamp a maxSpeed)

  4. Actualizo mi position actual

    position += velocity * dt

Ese ciclo se debe aplicar en cada frame, asi el ajuste es organico y se siente natural.

2.2 Arrive: desacelerar al llegar

Nuestro Seek funciona perfecto para perseguir pero no para al estar cerca. El agente llega al target a su velocidad máxima, se pasa y regresa terminando en una oscilacion infinita.

Seek puro: el agente llega al target con demasiada velocidad y oscila a su alrededor sin parar. Esto es lo que Arrive viene a corregir.

Lo normal es que cuando el agente este cerca del target empiece a desacelerar, si esta lejos entonces que se comporte normalmente.

Para esto necesitaremos un nuevo concepto: slowRadius o radio de frenado. Es un radio alrededor del target que nos indicara cuando empezar a frenar.

  • Si dist > slowRadius → me comporto como seek (velocidad deseada = maxSpeed).
  • Si dist < slowRadius → escalo la velocidad deseada en proporción a qué tan cerca estoy.

En seudocodigo:

desired_speed = maxSpeed * min(1, dist / slowRadius)

Cuando dist → 0, ya no tengo necesidad de un deseo, es decir, se volveria 0 y el agente frena solo. Es como el perrito en mi correa atrapara la pelota y ahora se sienta a masticarla.

2.3 Flee: la huida

Flee es el espejo de seek. Mismo cálculo, signo invertido.

Donde seek decía “quiero ir hacia el target, flee dice “quiero alejarme del threat. Mi desired ya no apunta del agente al target — apunta del threat hacia el agente.

desired = normalize(position - threat) * maxSpeed

La formula es la misma, solo se cambia el orden a la resta. El resto del pipeline (steer, velocity, position) es idéntico.

2.3.1 El “Arrive invertido”: zona segura

Flee tiene el mismo problema que tenía seek: nunca para. El agente huye, huye, huye… aunque ya esté lejísimos del threat. En un principio pareceria normal, pero mientras mas pasa el tiempo mas se evidencia su falta de “Inteligencia”.

La idea es la misma que en el Arrive, pero en espejo. En vez de “frenar al acercarme al target”, quiero “frenar al alejarme del threat”.

Introduzco un safeRadius: un radio alrededor del threat a partir del cual ya me considero a salvo.

  • Si dist < safeRadius → estoy en peligro, huyo. Y huyo más fuerte cuanto más cerca esté.
  • Si dist >= safeRadius → ya estoy lejos. El desired pasa a ser velocidad cero: el agente frena suavemente con maxForce y se queda quieto.

La fórmula del desired cuando estoy en peligro queda:

scale   = maxSpeed * (1 - dist / safeRadius)
desired = normalize(position - threat) * scale

La velocidad deseada no decae al acercarme al target, decae al alejarme del threat. Cuando dist → safeRadius, scale → 0 y la force de huida se desvanece justo en la frontera.

Activa el toggle Zona segura en la demo y juega con safeRadius: vas a ver el círculo punteado y al agente quedándose quieto fuera de él, en lugar de pegarse a la pared.

Mueve el cursor: el agente huye y se queda confinado al lienzo. Activa Zona segura para que deje de huir cuando ya esté lejos — el círculo punteado marca safeRadius. Enter o Espacio pausan.

Intenta crearlo a partir de lo que aprendiste. Antes de ver el seudocódigo del siguiente apartado, prueba escribirlo de cero: una función flee(agent, threat, safeRadius) que devuelva una force con la zona segura aplicada como acabamos de describir. Si te atascas, recuerda que es seek con la resta al revés y Arrive con el slowRadius también al revés.

3. Pseudocódigo

function seek(agent: Agent, target: Vec2) -> Vec2
    desired = normalize(target - agent.position) * agent.maxSpeed
    steer   = desired - agent.velocity
    return limit(steer, agent.maxForce)

function arrive(agent: Agent, target: Vec2, slowRadius: Float) -> Vec2
    toTarget = target - agent.position
    dist     = length(toTarget)
    # velocidad objetivo decae linealmente dentro del radio
    speed    = agent.maxSpeed * min(1, dist / slowRadius)
    desired  = normalize(toTarget) * speed
    steer    = desired - agent.velocity
    return limit(steer, agent.maxForce)

function apply(agent: Agent, force: Vec2, dt: Float)
    agent.velocity = limit(agent.velocity + force * dt, agent.maxSpeed)
    agent.position = agent.position + agent.velocity * dt

El patrón es siempre el mismo: cada comportamiento devuelve una fuerza, las sumas (ponderadas si hace falta) y las aplicas al final del frame.

4. Implementación en Unity / C#

using UnityEngine;

public class SteeringAgent : MonoBehaviour {
    public float maxSpeed = 5f;
    public float maxForce = 12f;
    public float slowRadius = 2.5f;
    public Transform target;

    Vector3 velocity;

    void Update() {
        if (target == null) return;
        var steer = Arrive(target.position);
        velocity = Vector3.ClampMagnitude(velocity + steer * Time.deltaTime, maxSpeed);
        transform.position += velocity * Time.deltaTime;
        if (velocity.sqrMagnitude > 0.01f)
            transform.forward = velocity.normalized;
    }

    Vector3 Arrive(Vector3 t) {
        var toTarget = t - transform.position;
        var dist = toTarget.magnitude;
        var speed = maxSpeed * Mathf.Min(1f, dist / slowRadius);
        var desired = toTarget.normalized * speed;
        return Vector3.ClampMagnitude(desired - velocity, maxForce);
    }
}

5. En otros engines

  • Godot: CharacterBody3D.velocity reemplaza al vector manual. Suma steer * delta en _physics_process y llama a move_and_slide().
  • Unreal: en ACharacter usa AddMovementInput(dir.GetSafeNormal(), 1.0f). La ley de steering vive en tu AIController, no en el movement component.
  • JavaScript / Canvas: es exactamente lo que hace la demo de arriba. Un requestAnimationFrame, un Vec2 y el mismo algoritmo.

6. Quiz

Pon a prueba lo que entendiste

Responde una por una. La explicación aparece al elegir, correcta o no.

  1. Subes mucho maxForce y dejas maxSpeed bajo. ¿Qué ves en el agente?

  2. ¿Qué hace la diferencia entre seek puro y arrive?

  3. Si no limitas la fuerza (maxForce → ∞), ¿qué pasa?