FSM y HFSM II: arquitectura limpia, POCO y StateMachine reusable
Optimiza tus scripts, no dependas de MonoBehaviour. Maquinas de estado con contexto genérico, testeable sin unity.
Publicado: · Por Juanjo "Banyo" López
0. Introducción
Si vienes de FSM y HFSM I, ya sabes cómo modelar un agente con estados y transiciones. Funciona — hasta que el proyecto crece y te das cuenta de que cada enemigo nuevo te obliga a copiar el EnemyController.cs entero, renombrarlo, y modificar 200 líneas. Eso es un olor a código muy claro: tu lógica está casada con MonoBehaviour.
Este tutorial es la continuación arquitectónica del primero. Mismo mecanismo (estados, transiciones, hooks), pero reorganizado de forma que escala a un proyecto real con 5 tipos de enemigos, 3 NPCs y 2 jefes — sin duplicar código.
¿Qué problema concreto resuelve?
- El estado deja de conocer Unity. Lo escribes como una clase pura C#. Lo testeas con
dotnet testsin abrir el editor. - Un solo motor
StateMachine<TContext>para todos los agentes. Mismo código compilado, distintos contextos en runtime. - Transiciones componibles. Una
ITransitionse reusa entre estados; las compones con AND/OR/NOT como bloques de Lego. - HFSM con stack. Cuando un sub-FSM se interrumpe, el padre que queda debajo conserva su estado interno — no hay que “recordarlo a mano”.
¿Cuándo migrar a esta arquitectura?
- Tienes 3+ tipos de agente que comparten la mayoría de su lógica.
- Quieres escribir tests de la lógica de IA sin levantar la escena.
- Tu
MonoBehaviourde estado pasa los 150 líneas y tiene 4 dependencias inyectadas a mano. - Quieres un inspector limpio: configurar comportamientos por
ScriptableObjecten vez de tocar código.
¿Cuándo NO?
- Prototipo de game-jam de 48 horas: la FSM plana del tutorial I es perfecta. Esta refactorización paga después, no antes.
- Un solo enemigo con 5 estados: overkill total.
En esta página vas a ver:
- POCO vs
MonoBehaviourcon el mismo estado escrito de las dos formas. - Cómo escribir un
StateMachine<TContext>que sirva para Enemy, NPC y Boss simultáneamente. - El patrón Strategy para que
ITransitionsea un objeto componible. - HFSM con stack de contexto: empuje, popeo, preservación de estado.
- DI manual vs ServiceLocator vs Blackboard, con la elección honesta para cada caso.
- Testing — por qué ahora puedes escribir tests rápidos.
1. Estados como POCO — separar lógica de Unity
1.1 El problema del estado-MonoBehaviour
En el tutorial I, los estados eran clases que heredaban de una State base. Sin embargo, el ejemplo real en Unity tiende a degenerar en algo así:
public class ChaseState : MonoBehaviour {
Animator animator;
NavMeshAgent agent;
Transform player;
AudioSource footsteps;
void OnEnable() { animator.SetBool("run", true); footsteps.Play(); }
void OnDisable() { animator.SetBool("run", false); footsteps.Stop(); }
void Update() {
agent.SetDestination(player.position);
if (Vector3.Distance(transform.position, player.position) < 1f) {
// ... transicionar a Attack
}
}
}
El ChaseState conoce Unity. Conoce Animator, NavMeshAgent, AudioSource, Transform. Para reusarlo en otro enemigo necesitas recablear esas referencias. Para testearlo necesitas una escena con animator, agente y player en su sitio. Para mockear el Animator en un test, te ríes solo.
1.2 La separación POCO + Host
La idea: el estado no toca Unity. Toca un contexto, una interfaz que defines tú. El MonoBehaviour (lo llamamos Host) solo orquesta: instancia el contexto, le pasa los datos del frame, y delega.
public class ChaseState : MonoBehaviour {
Animator animator;
NavMeshAgent agent;
void OnEnable() { animator.SetBool("run", true); }
void OnDisable() { animator.SetBool("run", false); }
void Update() {
agent.SetDestination(player.position);
if (Vector3.Distance(...) < 1f) ...
}
}- Acoplado: el estado conoce
Animator,NavMeshAgent,player. - No testeable: necesitás escena de Unity para correrlo.
- No reusable: migrar el estado a otro tipo de agente requiere copiar/pegar.
Cambia entre las dos pestañas y compara. La diferencia clave:
- Antes — el estado importa
UnityEngine. Vive con la escena. - Ahora — el estado importa solo tu
IEnemyContext. Compila sin Unity.
1.3 Paso a paso del split
-
Definir
IAgentContext— la interfaz mínima que cualquier agente debe exponer al estado. Lo que el estado necesita preguntar y mandar a hacer.interface IEnemyContext: position: Vec3 playerPos: Vec3 hp: int function MoveTo(target: Vec3) function SetAnimation(name: string) function RequestTransition(to: string) -
Implementar el contexto en una clase concreta —
EnemyContextenvuelve elTransform,NavMeshAgent,Animator, etc. Aquí es donde Unity entra. En un solo lugar. -
Estados como POCO — clases puras que reciben el contexto en cada hook.
interface IState<TContext>: function Enter(c: TContext) function Update(c: TContext, dt: float) function Exit(c: TContext) -
Host MonoBehaviour delgado — UNA clase que instancia el contexto, instancia los estados, y los conecta al
StateMachine<TContext>. SuUpdate()mide ~5 líneas.
2. StateMachine<TContext> genérico
2.1 Un solo motor para todos
Si los estados son POCO y el contexto es una interfaz, el motor de la FSM (la clase que orquesta current, transitions, tick) no necesita saber qué tipo de agente es. Se vuelve genérico:
class StateMachine<TContext>:
current: IState<TContext>
transitions: List<Transition<TContext>>
function ChangeTo(next: IState<TContext>, ctx: TContext)
current.Exit(ctx)
current = next
current.Enter(ctx)
function Tick(ctx: TContext, dt: float)
for each t in transitions
if t.from == current and t.cond.Evaluate(ctx)
ChangeTo(t.to, ctx)
return
current.Update(ctx, dt)
Ese motor lo escribes una vez. Lo usas para todos tus agentes. Cada agente solo aporta:
- Su
TContextconcreto. - Sus estados concretos (clases que implementan
IState<TContext>).
El mismo motor StateMachine<TContext> sirve para 3 tipos de agente. Cada uno aporta su propio Context (datos) y sus States concretos. El motor solo orquesta.
Tres agentes (Enemy, NPC, Boss) — un solo motor. Cada uno con su propio contexto y estados, todos pasando por la misma StateMachine<TContext>. Esa caja central es código compilado UNA vez.
2.2 Paso a paso del genérico
-
Definir
IState<TContext>con los tres hooks:Enter,Update,Exit. Recibe siempre el contexto. -
Definir
Transition<TContext>—from,to,cond(dondecondes unaITransitionCondition<TContext>). -
Implementar
StateMachine<TContext>—current,transitions,Tick(ctx, dt). -
Por cada tipo de agente:
- Definir
TContextconcreto (EnemyContext,NpcContext,BossContext). - Crear estados concretos que implementen
IState<TContext>. - Configurar la lista de transiciones en el
Awake()del Host.
- Definir
-
Host MonoBehaviour — instancia y delega:
class EnemyHost: MonoBehaviour ctx: EnemyContext fsm: StateMachine<EnemyContext> function Awake() ctx = new EnemyContext(this) fsm = new StateMachine<EnemyContext>(initialState) fsm.AddTransition(...) function Update() fsm.Tick(ctx, Time.deltaTime)
2.3 Beneficios concretos
- Una sola caja de motor. Si arreglas un bug en
StateMachine, todos los agentes se benefician. - Tipado fuerte.
TContextes genérico C#: el compilador atrapa errores donde un estado de Boss intenta usar un campo que solo Enemy tiene. - Testing real. Instancias
StateMachine<MockContext>, le inyectas un mock simple, tickeas 100 veces, verificas transiciones. Sin Unity.
3. Strategy pattern para transiciones
3.1 El problema de las condiciones inline
En el tutorial I, las transiciones usaban lambdas:
fsm.AddTransition(patrol, () => CanSeePlayer(), chase);
fsm.AddTransition(chase, () => Distance() < attackRadius, attack);
fsm.AddTransition(chase, () => !CanSeePlayer(), patrol);
fsm.AddTransition(patrol, () => hp < 20, flee);
fsm.AddTransition(chase, () => hp < 20, flee);
fsm.AddTransition(attack, () => hp < 20, flee);
Funciona, pero no escala:
- La condición
hp < 20está repetida 3 veces. Si cambia (ej: ahp < 25 OR allies < 2), tocas 3 lugares. - No puedes serializarla. No la puedes exponer en el inspector.
- No la puedes combinar fácilmente:
hp < 20 AND outOfReachrequiere otra lambda inline.
3.2 Cada condición es un objeto
El patrón Strategy aplicado: una condición es un objeto con un método Evaluate(ctx). Las condiciones se reusan, se serializan, se componen.
interface ITransitionCondition<TContext>:
function Evaluate(ctx: TContext) -> bool
class HpBelow<TContext>: ITransitionCondition<TContext>
threshold: int
function Evaluate(ctx) => ctx.hp < threshold
class PlayerInRange<TContext>: ITransitionCondition<TContext>
radius: float
function Evaluate(ctx) => distance(ctx.position, ctx.playerPos) < radius
class CooldownReady<TContext>: ITransitionCondition<TContext>
function Evaluate(ctx) => ctx.cooldownTimer <= 0
3.3 Componer con AND, OR, NOT
Como cada condición es un objeto, puedes combinarlas:
class And<T>: ITransitionCondition<T>
a, b: ITransitionCondition<T>
function Evaluate(ctx) => a.Evaluate(ctx) && b.Evaluate(ctx)
class Or<T>: ITransitionCondition<T> ...
class Not<T>: ITransitionCondition<T> ...
Y construir condiciones complejas declarativamente:
var attackCond = new And<EnemyContext>(
new PlayerInRange<EnemyContext>(5f),
new CooldownReady<EnemyContext>()
);
HpBelow(20)→Flee—PlayerInRange(5) AND CooldownReady→Attack—PlayerInRange(5) AND NOT CooldownReady→Chase—(default)→Patrol✓ activaCada ITransition es un objeto Strategy. Las componés con AND/OR/NOT. La FSM evalúa en orden de prioridad y la primera que cumple gana — independiente del estado actual del agente.
Mueve los sliders. La regla con prioridad más alta que se cumple gana, sin importar cuál sea el estado actual del agente. Esa es la transición que la FSM ejecuta.
3.4 Beneficios prácticos
- Reuso entre estados — la misma
HpBelow(20)la usas en 8 transiciones distintas, instanciada una vez. - Inspector friendly — si las condiciones son
ScriptableObject, las arrastras y combinas visualmente sin recompilar. - Testing trivial — una condición es un objeto sin estado interno (o con estado mínimo); la testeas con
Evaluate()y assert.
4. HFSM con stack de contexto
4.1 El problema sin stack
Imagina: un guardia patrullando entra en Investigate porque escuchó un ruido. En medio de Investigate ve al jugador y entra en Combat. Mata al jugador. ¿Qué quiere hacer ahora?
Lo correcto: volver a Investigate y completar la inspección. Si ya no hay nada, volver a Patrol exactamente donde estaba (el waypoint exacto).
Lo común mal hecho: que vuelva al inicio del patrullaje. Eso rompe la sensación de inteligencia.
4.2 La solución: stack de FSMs anidadas
Cada vez que entras a un sub-FSM, lo apilas sobre el anterior. Cuando termina, lo popeas y el de abajo retoma exactamente donde estaba — porque su current y sus variables internas siguen intactas.
class HfsmStack<TContext>:
stack: Stack<StateMachine<TContext>>
function Push(machine: StateMachine<TContext>, ctx: TContext)
machine.EnterInitial(ctx)
stack.Push(machine)
function Pop(ctx: TContext)
var top = stack.Pop()
top.ExitCurrent(ctx)
# el de abajo NO se reinicia: su current se mantiene
function Tick(ctx: TContext, dt: float)
if stack.Count == 0: return
stack.Peek().Tick(ctx, dt) # solo el top tickea
Cada vez que entrás a un sub-FSM nuevo, el padre actual se apila sin perder su estado interno. Al popear, el padre que queda debajo retoma exactamente donde estaba.
Pulsa “Avanzar” paso por paso. Mira cómo Patrol se queda preservada mientras Investigate y Combat se apilan encima. Al popear, retoma exactamente donde la dejaste.
4.3 Cuándo apilar y cuándo cambiar plano
No siempre quieres apilar. Reglas prácticas:
| Situación | Mejor estrategia |
|---|---|
| Una interrupción temporal (combate, investigar un ruido, esquivar una explosión) | Push: quieres volver a lo que hacías. |
| Un cambio de propósito (terminó la patrulla, ahora descansa permanentemente) | Cambio plano: limpia el stack y empieza de cero. |
| Una degradación de comportamiento (HP < 20 → Flee permanente) | Cambio plano + lock (no permitir push hasta restaurar HP). |
| Animación interrumpida por daño | Push corto que se popea solo al terminar la animación. |
4.4 Paso a paso de la implementación
-
Cada nivel de la jerarquía es una
StateMachine<TContext>— el padre tiene sus estados; cada estado del padre puede tener su propiaStateMachineinterna. -
El Host mantiene un
HfsmStack— no unStateMachineplano. -
El push/pop es disparado por una transición — igual que cambiar de estado plano, pero la transición sabe que el destino es un sub-FSM (no un estado del actual).
-
Ticksolo afecta al top. LosStateMachineapilados debajo no consumen CPU; solo guardan estado. -
ctxes compartido entre todos los niveles del stack. Eso permite que el sub-FSM modifique blackboard y el padre lo vea al retomar.
5. DI: cómo le llega el contexto al estado
Tres opciones, en orden de complejidad:
5.1 Constructor injection (DI manual)
El StateMachine recibe el ctx por constructor (o en Tick(ctx, dt)) y lo pasa a cada estado. Sin librería, sin magia.
Pro: trivial, explícito, sin dependencias externas. Contra: si tu contexto crece a 12 campos, el estado pasa a tener mucho que conocer.
5.2 Service Locator
El estado pide servicios al contexto: ctx.Get<IPathfindingService>(). El contexto es un registro central por agente.
Pro: flexible, permite servicios por capa.
Contra: ocultas dependencias — un test mal escrito pasa porque el ServiceLocator devuelve null silenciosamente.
5.3 Blackboard compartido
El contexto es un blackboard: un ScriptableObject o Dictionary<string, object> accesible por estados, condiciones y módulos.
Pro: pizarra natural para IA cooperativa entre módulos. Contra: typing débil (a menos que uses keys tipadas), riesgo de “todo el mundo escribe en todo”.
Recomendación práctica: arranca con DI manual (constructor o Tick(ctx)). Migra a Service Locator solo cuando el contexto pase los 8-10 campos. Migra a Blackboard cuando necesites IA cooperativa entre agentes distintos (esto se cubre profundo en Arquitectura de IA).
6. Testing — el pago de toda esta refactorización
Toda esta separación tiene un punto culminante: tests rápidos sin Unity.
// Mock barato: no necesita escena, ni Animator, ni nada.
public class MockEnemyContext : IEnemyContext {
public Vector3 Position { get; set; }
public Vector3 PlayerPos { get; set; }
public int Hp { get; set; }
public string LastTransitionRequest { get; set; }
public void MoveTo(Vector3 t) { Position = t; }
public void SetAnimation(string s) {}
public void RequestTransition(string to) { LastTransitionRequest = to; }
}
[Test]
public void Chase_RequestsAttack_WhenInRange() {
var ctx = new MockEnemyContext { Position = Vector3.zero, PlayerPos = new Vector3(0.5f, 0, 0), Hp = 100 };
var chase = new ChaseState();
chase.Update(ctx, 0.016f);
Assert.AreEqual("attack", ctx.LastTransitionRequest);
}
Ese test corre en milisegundos. Lo puedes correr en CI sin abrir Unity. Eso solo ya justifica toda la refactorización.
7. Pseudocódigo completo
interface IState<TContext>
function Enter(ctx: TContext)
function Update(ctx: TContext, dt: float)
function Exit(ctx: TContext)
interface ITransitionCondition<TContext>
function Evaluate(ctx: TContext) -> bool
class Transition<TContext>
from: IState<TContext>
to: IState<TContext>
cond: ITransitionCondition<TContext>
class StateMachine<TContext>
current: IState<TContext>
transitions: List<Transition<TContext>>
function AddTransition(from, cond, to)
transitions.Add(new Transition(from, to, cond))
function ChangeTo(next: IState<TContext>, ctx: TContext)
current.Exit(ctx)
current = next
current.Enter(ctx)
function Tick(ctx: TContext, dt: float)
for each t in transitions
if t.from == current and t.cond.Evaluate(ctx)
ChangeTo(t.to, ctx)
return
current.Update(ctx, dt)
class HfsmStack<TContext>
stack: Stack<StateMachine<TContext>>
function Push(machine, ctx)
machine.EnterInitial(ctx)
stack.Push(machine)
function Pop(ctx)
var top = stack.Pop()
top.ExitCurrent(ctx)
function Tick(ctx, dt)
if stack.Count > 0
stack.Peek().Tick(ctx, dt)
8. Implementación en Unity / C#
using System;
using System.Collections.Generic;
using UnityEngine;
// ---------- Contratos ----------
public interface IState<TContext> {
void Enter(TContext ctx);
void Update(TContext ctx, float dt);
void Exit(TContext ctx);
}
public interface ITransitionCondition<TContext> {
bool Evaluate(TContext ctx);
}
// ---------- Composición de condiciones ----------
public class And<T> : ITransitionCondition<T> {
readonly ITransitionCondition<T> a, b;
public And(ITransitionCondition<T> a, ITransitionCondition<T> b) { this.a = a; this.b = b; }
public bool Evaluate(T ctx) => a.Evaluate(ctx) && b.Evaluate(ctx);
}
public class Or<T> : ITransitionCondition<T> {
readonly ITransitionCondition<T> a, b;
public Or(ITransitionCondition<T> a, ITransitionCondition<T> b) { this.a = a; this.b = b; }
public bool Evaluate(T ctx) => a.Evaluate(ctx) || b.Evaluate(ctx);
}
public class Not<T> : ITransitionCondition<T> {
readonly ITransitionCondition<T> a;
public Not(ITransitionCondition<T> a) { this.a = a; }
public bool Evaluate(T ctx) => !a.Evaluate(ctx);
}
// ---------- Motor genérico ----------
public class Transition<T> {
public IState<T> From, To;
public ITransitionCondition<T> Cond;
}
public class StateMachine<T> {
IState<T> current;
readonly List<Transition<T>> transitions = new();
public StateMachine(IState<T> initial, T ctx) {
current = initial;
current.Enter(ctx);
}
public void AddTransition(IState<T> from, ITransitionCondition<T> cond, IState<T> to) {
transitions.Add(new Transition<T> { From = from, To = to, Cond = cond });
}
public void Tick(T ctx, float dt) {
foreach (var t in transitions) {
if (t.From == current && t.Cond.Evaluate(ctx)) {
current.Exit(ctx);
current = t.To;
current.Enter(ctx);
return;
}
}
current.Update(ctx, dt);
}
}
// ---------- Contexto y estados concretos para Enemy ----------
public interface IEnemyContext {
Vector3 Position { get; }
Vector3 PlayerPos { get; }
int Hp { get; }
void MoveTo(Vector3 target);
void SetAnimation(string name);
}
public class HpBelow : ITransitionCondition<IEnemyContext> {
readonly int threshold;
public HpBelow(int t) { threshold = t; }
public bool Evaluate(IEnemyContext ctx) => ctx.Hp < threshold;
}
public class PlayerInRange : ITransitionCondition<IEnemyContext> {
readonly float radius;
public PlayerInRange(float r) { radius = r; }
public bool Evaluate(IEnemyContext ctx) => Vector3.Distance(ctx.Position, ctx.PlayerPos) < radius;
}
public class ChaseState : IState<IEnemyContext> {
public void Enter(IEnemyContext ctx) { ctx.SetAnimation("run"); }
public void Exit(IEnemyContext ctx) { ctx.SetAnimation("idle"); }
public void Update(IEnemyContext ctx, float dt) { ctx.MoveTo(ctx.PlayerPos); }
}
public class AttackState : IState<IEnemyContext> { /* ... */
public void Enter(IEnemyContext ctx) {} public void Exit(IEnemyContext ctx) {} public void Update(IEnemyContext ctx, float dt) {}
}
public class FleeState : IState<IEnemyContext> { /* ... */
public void Enter(IEnemyContext ctx) {} public void Exit(IEnemyContext ctx) {} public void Update(IEnemyContext ctx, float dt) {}
}
// ---------- Host: el ÚNICO MonoBehaviour ----------
public class EnemyHost : MonoBehaviour, IEnemyContext {
public Transform player;
public int Hp { get; set; } = 100;
public Vector3 Position => transform.position;
public Vector3 PlayerPos => player.position;
StateMachine<IEnemyContext> fsm;
public void MoveTo(Vector3 target) { transform.position = Vector3.MoveTowards(transform.position, target, 3f * Time.deltaTime); }
public void SetAnimation(string name) { /* GetComponent<Animator>().Play(name); */ }
void Start() {
var chase = new ChaseState();
var attack = new AttackState();
var flee = new FleeState();
fsm = new StateMachine<IEnemyContext>(chase, this);
fsm.AddTransition(chase, new PlayerInRange(1.5f), attack);
fsm.AddTransition(attack, new Not<IEnemyContext>(new PlayerInRange(2f)), chase);
// Una sola transición global a Flee, reusable:
var lowHp = new HpBelow(20);
fsm.AddTransition(chase, lowHp, flee);
fsm.AddTransition(attack, lowHp, flee);
}
void Update() => fsm.Tick(this, Time.deltaTime);
}
9. En otros engines
- Godot: GDScript no tiene generics tan limpios como C#, pero puedes simular
TContextcon tipado dinámico y typed dicts. La separación POCO/Host se mantiene: elNodees el host, losResourceson tus estados. - Unreal: el patrón se traduce a
UObjectpara los estados (puros, sin componentes) yAAIControllerpara el host. Las transiciones comoUObjectpermiten arrastrarlas en el editor. - JavaScript / TypeScript: clases ES6 puras como en el pseudocódigo. Es lo que ya hace internamente la lib del sitio en
~/lib/viz/sim/.
10. Quiz
Pon a prueba lo que entendiste
Responde una por una. La explicación aparece al elegir, correcta o no.
¿Por qué separar el estado en POCO + Host es preferible a estado-MonoBehaviour?
Tienes un Enemy y un Boss. Ambos necesitan un estado 'Patrol' casi idéntico. ¿Cómo lo modelas con StateMachine<TContext>?
¿Qué te gana definir HpBelow(20) como una clase ITransitionCondition en vez de una lambda inline?
Un guardia patrulla, escucha un ruido (entra a Investigate), ve al jugador (entra a Combat), lo mata. ¿Qué garantiza el HFSM con stack?
¿Cuándo conviene NO migrar a esta arquitectura?
¿Qué responsabilidad NO debería estar en un estado POCO?