Skip to content

State Types

Inspiaaa edited this page May 28, 2024 · 2 revisions

Overview over the built-in state types

UnityHFSM supports multiple state classes, which can be used in different situations. When implementing custom state classes, it is especially important to know which class to inherit from.

Here's a high-level overview over the state inheritance hierarchy of UnityHFSM, ignoring the fact that it supports generics for a moment:

  • The base class of all states is StateBase. It's basically an empty state without any functionality.
  • States that support custom actions, have to implement the IActionable interface.
  • As the implementation to support them is usually the same, there exists the ActionState base class. It inherits from the StateBase class.
  • Then come the higher-level state classes such as State and CoState that inherit from ActionState and provide advanced user-customisable behaviour.
  • To allow for hierarchical state machines, the StateMachine class also inherits from the StateBase class.
  • The HybridStateMachine class is essentially a mixture between the StateMachine and the State class. It inherits from the StateMachine class.

In reality, however, UnityHFSM is a little bit more complicated in order to support generics. The core classes have generic type parameters for the state ID and the event ID types. To reduce boilerplate code, there are also class overloads (that inherit from the generic base classes), filling the type parameters in with common default values (strings).

This means that if you are implementing your own state class, it makes sense to either inherit from the StateBase or the ActionState class, depending on whether you need custom actions (e.g. OnFixedUpdate, OnDamageTaken, ...). Ideally, you also inherit from the generic versions of these classes, so that you can reuse your state in state machines that use different types for the state IDs.


Simplified inheritance hierarchy without generics (not the reality):

classDiagram
    class StateBase
    class ActionState
    class State
    class CoState
    class StateMachine

	StateBase <|-- ActionState
	ActionState <|-- State
	ActionState <|-- CoState
	StateBase <|-- StateMachine
	StateMachine <|-- HybridStateMachine
Loading

Actual inheritance hierarchy:

classDiagram
	class StateBaseT["StateBase[T]"]
	class StateBase["StateBase"]

	class ActionStateTT["ActionState[T, T]"]
	class ActionStateT["ActionState[T]"]
	class ActionState["ActionState"]

	class StateTT["State[T, T]"]
	class StateT["State[T]"]
	class State["State"]

	class CoStateTT["CoState[T, T]"]
	class CoStateT["CoState[T]"]
	class CoState["CoState"]
	
    class StateMachineTTT["StateMachine[T, T, T]"]
    class StateMachineTT["StateMachine[T, T]"]
    class StateMachineT["StateMachine[T]"]
    class StateMachine["StateMachine"]

    class HybridStateMachineTTT["HybridStateMachine[T, T, T]"]
    class HybridStateMachineTT["HybridStateMachine[T, T]"]
    class HybridStateMachineT["HybridStateMachine[T]"]
    class HybridStateMachine["HybridStateMachine"]

	class IActionableT["IActionable[T]"]
	<<Interface>> IActionableT

	StateBaseT <|-- StateBase

	StateBaseT <|-- ActionStateTT
	ActionStateTT <|-- ActionStateT
	ActionStateTT <|-- ActionState

	ActionStateTT <|-- StateTT
	StateTT <|-- StateT
	StateTT <|-- State
	
	ActionStateTT <|-- CoStateTT
	CoStateTT <|-- CoStateT
	CoStateTT <|-- CoState

	StateBaseT <|-- StateMachineTTT
    StateMachineTTT <|-- StateMachineTT
    StateMachineTTT <|-- StateMachineT
    StateMachineTTT <|-- StateMachine

    StateMachineTTT <|-- HybridStateMachineTTT
    HybridStateMachineTTT <|-- HybridStateMachineTT
    HybridStateMachineTTT <|-- HybridStateMachineT
    HybridStateMachineTTT <|-- HybridStateMachine
    
	ActionStateTT ..|> IActionableT
	StateMachineTTT ..|> IActionableT
Loading

This diagram can also explain some minor inconveniences found in UnityHFSM. Consider the following example:

new State(onEnter: state => print("Hello World!"));

The function that is passed in actually does not take the state as type State as its parameter, but as State<string, string>. This is because State<string, string> is the class that defines the actual logic, whereas State merely inherits from this class to fill in the generic type parameters. The same applies to the other types (states and transitions), too.


States

StateBase

  • Base class of all states. It's basically an empty state without any functionality.
new StateBase(needsExitTime: false, isGhostState: false);
  • Generic type parameters: StateBase<TStateId>
var state = new StateBase<int>(needsExitTime: false);

var fsm = new StateMachine<int>();
fsm.AddState(5, state);
  • Generic overloads:
    • StateBase = StateBase<string>

ActionState

  • Base class of all states that should support custom, user-defined actions.
  • Internally, it uses the ActionStorage class to store the custom actions.
new ActionState(needsExitTime: false)
    .AddAction("OnGameOver", () => print("GameOver"))
    .AddAction<float>("OnHit", damage => print($"Damage: {damage}"));
  • Generic type parameters: ActionState<TStateId, TEvent>
  • Generic overloads:
    • ActionState = ActionState<string, string>
    • ActionState<TStateId> = ActionState<TStateId, string>

State

  • Default state type.
  • Allows you to define custom logic for when the state enters (i.e. becomes the active state), when the next update (OnLogic) occurs or when the state exits.
  • If the state needsExitTime and you can define a custom function to determine when the state can exit (canExit).
  • If you don't need onEnter / onExit / canExit / needsExitTime, you can simply leave them with their default value (Same applies to the other state and transition classes).
  • Supports actions.
new State(
    onEnter: self => {
        // Called when this state becomes the active state.
    },
    onLogic: self => {
        // Called every frame.
    },
    onExit: self => {
        // Called when the state machine transitions to another state.
    },
    canExit: self => {
        // Called while a transition is pending to check whether the state
        // can exit.
        return false; // Default.
    },
    needsExitTime: false,
    isGhostState: false
);
  • Generics and overloads are the same as ActionState (State<TStateId, TEvent>)

CoState

  • Like the State class but allows you to run a Unity coroutine as its logic / update method. See the README for a longer description and a more advanced example.

  • Define a coroutine:

IEnumerator MyCoroutine()
{
    // ...
    yield return null;
    // ...
    yield return new WaitForSeconds(2);
}
  • Create the CoState:
new CoState(
    this,  // Pass in the MonoBehaviour that should run the coroutine.
    MyCoroutine,
    onEnter: self => { },
    onExit: self => { },
    canExit: self => false,
    loop: true  // If true, the coroutine is run again once completed.
    needsExitTime: false,
    isGhostState: false
);
  • If you don't need onEnter / onExit / needsExitTime, you can simply leave them with their default value (Same applies to the other state and transition classes).
new CoState(
    this,
    MyCoroutine
);
  • Generics and overloads are same as ActionState (CoState<TStateId, TEvent>)

ParallelStates

  • State class that allows you to run multiple states in parallel.
new ParallelStates(
    new State(onLogic: self => MoveTowardsPlayer()),
    new State(onLogic: self => Animate())
);
  • By default, when a transition by the parent state machine is requested, it will instantly exit, regardless of whether its child states need exit time. This can be changed by setting needsExitTime to true, which will make it wait until any child state calls StateCanExit() before exiting. If this is undesired or other exit behaviour is wanted, you can set the canExit function which determines when the ParallelStates state may exit.
new ParallelStates(
    canExit: self => IsPlayerInRange(),
    needsExitTime: true,

    new State(onLogic: s => MoveTowardsPlayer()),
    new State(onLogic: s => Animate())
);
  • The states to run can also be added using the AddState(name, state) method. The name parameter is only used for debugging purposes:
var state = new ParallelStates();
state.AddState("Move", new State(onLogic: s => MoveTowardsPlayer()));
state.AddState("Animate", new State(onLogic: s => Animate()));

fsm.AddState("FollowPlayer", state);
  • If the states are added in the constructor and you are using the non-generic ParallelStates class that uses strings, then the states are implicitly assigned names ("0", "1", ...). If you add the states in the constructor of a generic version of the ParallelStates class, then the states become nameless, which means that calling GetActiveHierarchyPath() will not show as much information.

  • If all the child states have names, then calling the GetActiveHierarchyPath() gives you useful debug information:

// When the "FollowPlayer" state is active...
print(fsm.GetActiveHierarchyPath());  // Prints "FollowPlayer/(Move & Animate)"
  • Calling GetActiveHierarchyPath() on a more complex parallel state machine that has nested state machines may return something like this: "/FollowPlayer/(Move/Jump & Animate & Defend/Block)"

  • Generic type parameters: ParallelStates<TOwnId, TStateId, TEvent>

    • TOwnId: Type of the name / identifier of the ParallelStates state in the parent state machine.
    • TStateId: Type for the names / identifiers of the child states. These names fulfil no direct function and are only meant for debugging purposes (they appear when calling GetActiveHierarchyPath()).
    • TEvent: Type for the events / action triggers.
  • Generic overloads:

    • ParallelStates = ParallelStates<string, string, string>
    • ParallelStates<TStateId> = ParallelStates<TStateId, TStateId, string>
    • ParallelStates<TStateId, TEvent> = ParallelStates<TStateId, TStateId, TEvent>

State Machines

The base class of all state machines is the StateMachine<TOwnId, TStateId, TEvent> class.

StateMachine

  • Default state machine class.
  • See the README for an introduction.
var fsm = new StateMachine(
    needsExitTime: false,  // Only when used as a nested fsm.
    isGhostState: false,  // Only when used as a nested fsm.
    // Only when used as a nested fsm: If true, the state machine will 
    // return to its last active state when it enters, instead of its 
    // original start state.
    rememberLastState: false
);
  • Generic type parameters: StateMachine<TOwnId, TStateId, TEvent>
    • TOwnId: Only relevant for nested state machines: When this state machine is a nested state machine, i.e. it is a state inside a parent state machine, TOwnId determines the type of its name / identifier in the parent fsm.
    • TStateId: Type of the names / identifiers of states in this state machine.
    • TEvent: Type for the events / action triggers.
  • Generic overloads:
    • StateMachine = StateMachine<string, string, string>
    • StateMachine<TStateId> = StateMachine<TStateId, TStateId, string>
    • StateMachine<TStateId, TEvent> = StateMachine<TStateId, TStateId, TEvent>

HybridStateMachine

  • A state machine that allows you to define custom behaviour (for enter / update / logic, and custom actions) like the State class.
  • For more information, see the wiki.
var fsm = new HybridStateMachine(
    beforeOnEnter: self => {
        // Called before the OnEnter method of the start state when the parent
        // state machine switches to this state machine.
    },
    afterOnEnter: self => {
        // Called after the OnEnter method of the start state.
    },

    beforeOnLogic: self => {
        // Called on each OnLogic call before the OnLogic method 
        // of the active state.
    },
    afterOnLogic: self => {
        // Called after the OnLogic method of the active state.
    },

    beforeOnExit: self => {
        // Called before the OnExit method of the active state when the parent
        // state machine switches to another state, exiting this state machine.
    },
    afterOnExit: self => {
        // Called after the OnExit method of the active state.
    },
    // ... (same as StateMachinee)
);

fsm.AddAction<float>("OnHit", damage => print(damage));
fsm.AddAction("OnGameOver", () => print("Game Over!"));
  • Generics and overloads are the same as StateMachine (HybridStateMachine<TOwnId, TStateId, TEvent>)