StateManager
Tsvrc.StateMachine.StateManager is a generic, local-only (unsynced) finite state machine:
states are plain integers, each with optional enter/exit method dispatch on a chosen target
behaviour. Its generated shadow is TsStateManager. It has no dependency on any other
TsVRC module beyond TsvrcBehaviour. See Give a screen or flow its own state
machine for a task-oriented walkthrough.
Getting an instance
StateManager ships as one of TsVRC's own default factory
entries (TsBuiltinConfig).
- Default:
_ts.CreateStateManager(parent)just works, nothing to register in Configure first. - Manual: skip the factory entirely.
AddComponentit (or drag the shippedStateManagerprefab into your scene) and start callingRegisterState/SetStatedirectly. Unlike most other TsVRC runtime types,StateManagernever overridesTsStartand never touches_ts, so it doesn't needTsConstructto have run at all: first-class support for the fully manual path.
Usage
manager.RegisterState(StateIdle, this, exitMethod: nameof(OnExitIdle));
manager.RegisterState(StateActive, this, enterMethod: nameof(OnEnterActive));
manager.SetState(StateActive);
RegisterState(state, target, enterMethod, exitMethod) associates a state integer with a
target UdonSharpBehaviour and up to two method names, dispatched via SendCustomEvent.
Use nameof() for the method names so a rename doesn't silently break the wiring. Pass
this as the target for a StateManager subclass that hosts its own enter/exit methods.
Either method name is optional; a state can register only an exit, only an enter, or
neither (useful if you only need to react through OnStateChanged instead).
UnregisterState removes an entry entirely: a later transition into or out of an
unregistered state simply skips dispatch for it, without throwing.
SetState(newState) runs the transition: calls the current state's registered exit method
(skipped if no state has been entered yet, the initial state is -1), calls the virtual
OnStateChanged(oldState, newState) hook and emits an
"OnStateChanged" event
via TsEmit, then calls the new state's registered enter method. Calling SetState with the
state already current is a no-op: no dispatch happens at all, not even a redundant
exit/enter of the same state. CurrentState/PreviousState are also exposed as
GetCurrentState()/GetPreviousState() method calls returning the same values, useful
anywhere only a method, not a property getter, can be wired up, such as a UnityEvent bound
in the inspector.
Reentrant SetState calls are queued, not interleaved
Calling SetState again from inside OnStateChanged, from an external subscriber to the
OnStateChanged event, or from an enter/exit method itself doesn't run that new transition
immediately: it's queued (only the most recently queued state survives if several arrive
before the current transition finishes) and runs only after the in-progress transition
completes in full. This guarantees a transition's exit → OnStateChanged → enter sequence
is never interrupted partway through by a reentrant call jumping the queue.
That queue drains in a loop inside the original SetState call, not in a separate future
tick: if a queued transition's own OnStateChanged (or dispatched method) immediately
queues yet another different state every time it runs, the loop keeps draining and never
returns control to whoever made the original call. It won't overflow the call stack the way
naive recursion would, but it will hang that calling frame indefinitely. Redirecting to a
state and stopping there is fine (already the pattern a reentrant redirect exists for); an
enter/exit chain that never settles is not.
Failure mode worth knowing
SetState sets _isTransitioning = true, runs the transition, then sets it back to
false. UdonSharp can't compile try/catch/finally, so that reset can't be wrapped in a
finally block. If OnStateChanged, a dispatched enter/exit method, or an external
subscriber to the "OnStateChanged" event throws mid-transition, the exception unwinds
straight past the reset and _isTransitioning is left stuck true. Every subsequent
SetState call is then permanently treated as reentrant: it queues its state instead of
running it, and nothing ever processes the queue again, since nothing completes the
transition that's stuck "in progress." A StateManager instance that hits this is stuck for
good, silently swallowing every later SetState call rather than throwing again or
recovering. If your enter/exit methods or OnStateChanged override can throw, catch inside
them rather than letting an exception escape into SetState.
Dispatch target lifetime
A registered target whose GameObject was destroyed after RegisterState is treated the
same as a null target: its dispatch is skipped silently rather than throwing, using the
same "compare to null through Unity's overridden equality" check used elsewhere in the
framework. A destroyed target for one state doesn't affect dispatch for any other
registered state.