Skip to content

State Machine

Namespace: Brine2D.Animation

AnimationStateMachine is a lightweight code-driven state machine that evaluates transition conditions each frame and calls SpriteAnimator.Play automatically when one passes. You never manually poll input and call Play in OnUpdate — the state machine does that for you once you've declared the transitions.

Overview

Transitions are evaluated in descending Priority order. Within the same priority, insertion order wins. The first passing condition fires and evaluation stops. AnyState transitions are evaluated after all regular transitions for the current state. Non-looping clips block outgoing transitions unless CanInterrupt = true.

AnimationSystem calls StateMachine.Update(delta) before advancing the animator each tick.


Setting a Default State

The default state is the fallback the machine enters when no animation is playing. It is played (or restarted) automatically.

var sm = animComp.StateMachine;
sm.SetDefaultState("idle");

Adding Transitions

Condition-based transition

sm.AddTransition("idle", "walk", () => speed > 0.1f);
sm.AddTransition("walk", "idle", () => speed <= 0.1f);
sm.AddTransition("walk", "run",  () => speed > 4f);
sm.AddTransition("run",  "walk", () => speed <= 4f);

From any state

// Fires regardless of which animation is currently playing
sm.AddAnyTransition("hurt", () => tookDamage, canInterrupt: true);

On-complete transition

Fires when a non-looping clip reaches its natural end:

sm.AddOnCompleteTransition("jump", "land");
sm.AddOnCompleteTransition("land", "idle");

// Unconditional AnyState on-complete
sm.AddAnyOnCompleteTransition("idle");

Transition options

All AddTransition / AddAnyTransition overloads share these optional parameters:

Parameter Type Default Purpose
canInterrupt bool false Allow transition to interrupt a non-looping clip mid-play
crossFadeDuration float 0 Blend over this many seconds instead of hard-cutting
minStateDuration float 0 Source must have been active at least this many seconds
minNormalizedTime float 0 Source must have reached this normalised position [0, 1]
priority int 0 Higher values evaluated first within the same list
restartSelf bool false Allow a transition from a state back to itself
// Exit-time: don't transition until 80% through the clip
sm.AddTransition("attack", "idle", () => true, minNormalizedTime: 0.8f);

// Cross-fade with a 0.1 s blend
sm.AddTransition("walk", "run", () => speed > 4f, crossFadeDuration: 0.1f);

AnimationParameters

AnimationParameters is a typed named-parameter store designed for use in transition conditions. It eliminates manual boolean reset bookkeeping.

var p = animComp.Parameters;

// In your setup:
sm.AddTransition("idle", "walk",   () => p.GetFloat("speed") > 0.1f);
sm.AddTransition("walk", "idle",   () => p.GetFloat("speed") <= 0.1f);
sm.AddTransition("idle", "attack", () => p.GetBool("isAttacking"));
sm.AddTransition("walk", "attack", () => p.GetBool("isAttacking"));

// In OnUpdate every frame:
p.SetFloat("speed", velocity.Length());
p.SetBool("isAttacking", isAttackButtonHeld);

Four parameter types

Type Set Get Notes
Bool SetBool(name, value) GetBool(name) Latching; stays set until explicitly cleared
Float SetFloat(name, value) GetFloat(name) Continuous value
Int SetInt(name, value) GetInt(name) Integer value
Trigger SetTrigger(name) GetTrigger(name) Fire-once: returns true exactly once then auto-resets

Triggers and safe consumption

Trigger short-circuit hazard

GetTrigger consumes the trigger immediately on read. Do not use it in compound conditions like () => p.GetTrigger("attack") && isGrounded — if isGrounded short-circuits to false first, the trigger is still consumed.

Use IsTriggerArmed as the guard and let AddTriggerTransition consume it safely via OnFired:

// Safe trigger transition — uses IsTriggerArmed as condition, ResetTrigger in OnFired
sm.AddTriggerTransition("idle",   "attack", p, "attackPressed");
sm.AddTriggerTransition("walk",   "attack", p, "attackPressed");
sm.AddAnyTriggerTransition("hurt", p, "tookDamage", canInterrupt: true);

// Trigger with on-complete chaining
sm.AddOnCompleteTriggerTransition("attack", "combo2", p, "attackPressed");

// Set the trigger from input
if (inputAttack) p.SetTrigger("attackPressed");

Checking and removing parameters

bool hasBool  = p.HasBool("isAttacking");
bool hasFloat = p.HasFloat("speed");

p.RemoveBool("isAttacking");
p.RemoveFloat("speed");
p.ResetTrigger("attackPressed"); // manually consume a trigger
p.ClearAll();                    // remove everything

State Callbacks

Register callbacks that fire whenever a named state is entered or exited. Multiple callbacks per state are supported.

sm.OnStateEnter("attack", prev =>
{
    audioService.Play("sword_swing");
});

sm.OnStateExit("hurt", next =>
{
    invincibilityTimer = 0.5f;
});

Remove a specific callback:

Action<string?> onEnterAttack = prev => audioService.Play("sword_swing");
sm.OnStateEnter("attack", onEnterAttack);
// later:
sm.RemoveStateEnterCallback("attack", onEnterAttack);

// Remove all callbacks for a state
sm.RemoveStateEnterCallback("attack");
sm.RemoveStateExitCallback("hurt");

The OnStateChanged event fires for every state change regardless of name:

sm.OnStateChanged += (prev, next) =>
{
    Logger.LogDebug("Transition: {Prev} → {Next}", prev, next);
};

Forced Transitions

Bypass condition evaluation and drive the state machine imperatively:

sm.ForceState("attack");              // immediate, no restart if already playing
sm.ForceState("attack", restart: true); // always restart from frame 0
sm.ForceStop();                         // stop and clear (fires OnStopped by default = false)
sm.ForceStateQueued("idle");            // queue after current non-looping clip
sm.ForceStateQueuedWithCrossFade("idle", 0.15f);

ForceState still fires OnStateChanged and state enter/exit callbacks. It suppresses the internal play-event loop so the state machine doesn't double-track the change.


Control and Diagnostics

sm.IsEnabled = false;  // suspend transition evaluation (animator keeps playing)
sm.IsEnabled = true;   // re-enable

bool inWalk = sm.IsInState("walk");
bool can    = sm.CanTransitionTo("run");

// Last 16 state names (oldest at index 0)
IReadOnlyList<string> history = sm.StateHistory;
sm.ClearStateHistory();

float elapsed = sm.StateTimer; // seconds in the current state

string? current  = sm.CurrentState;
string? previous = sm.PreviousState;

Validation

var issues = sm.ValidateTransitions();
foreach (var msg in issues)
    Logger.LogWarning(msg);

ValidateTransitions checks that every transition's To target is registered on the animator. Call it after setup during development.


Removing Transitions

// By handle returned from AddTransition
var t = sm.AddTransition("walk", "run", () => speed > 4f);
sm.RemoveTransition(t);

// By source/target
sm.RemoveTransitions("walk", "run");
sm.RemoveAnyTransitions("hurt");
sm.RemoveOnCompleteTransitions("jump", "land");
sm.RemoveAnyOnCompleteTransition("idle");

sm.ClearTransitions(); // remove everything

Full Example

var p  = animComp.Parameters;
var sm = animComp.StateMachine;

sm.SetDefaultState("idle");

sm.AddTransition("idle", "walk",  () => p.GetFloat("speed") > 0.1f);
sm.AddTransition("walk", "idle",  () => p.GetFloat("speed") <= 0.1f);
sm.AddTransition("walk", "run",   () => p.GetFloat("speed") > 4f);
sm.AddTransition("run",  "walk",  () => p.GetFloat("speed") <= 4f);

sm.AddAnyTriggerTransition("attack", p, "attackPressed", canInterrupt: false);
sm.AddOnCompleteTransition("attack", "idle");

sm.AddAnyTriggerTransition("hurt", p, "tookDamage", canInterrupt: true, crossFadeDuration: 0.05f);
sm.AddOnCompleteTransition("hurt", "idle");

sm.OnStateEnter("attack", _ => audioService.Play("sword_swing"));

// Each frame (e.g. in a Behavior or System):
p.SetFloat("speed", rigidbody.LinearVelocity.Length());
if (attackInput) p.SetTrigger("attackPressed");
if (hitByEnemy)  p.SetTrigger("tookDamage");