FSM (상태 머신)

직접 틱하는 결정론적 오브젝트별 상태 머신 — 이름 있는 상태, 가드 전이, 전역 인터럽트를 순수 C#으로 제공합니다.

모든 적, 메뉴, 게임 페이즈에 깔끔한 이름 있는 상태 집합을 주고 — 전환은 선언적 가드 전이에 맡기세요.

인터페이스IStateMachineServiceIStateMachine<TContext> 인스턴스를 만드는 팩토리
끄기 스위치NullStateMachineService
어셈블리CommonGameSystem.Core
시작부팅 시 자동 등록 (23개 서비스 중 하나) — 별도 설정 불필요

하는 일

유한 상태 머신(FSM)은 동작을 이름 있는 상태로 조직합니다 — 예를 들어 적이라면 Idle, Chase, Dead — 그리고 상태 사이를 오가는 규칙을 함께 둡니다. 이 서비스는 평평한(flat) 헤드리스 FSM을 제공합니다: 각 상태를 작은 클래스로 정의한 뒤, 선언적 가드 전이("적이 플레이어를 보면 Idle에서 Chase로")나 직접적인 ChangeState 호출로 머신을 구동합니다.

클록은 게임이 소유합니다: 매 프레임 각 머신을 직접 틱하고 델타 타임도 직접 전달합니다. 내부 티커도, MonoBehaviour 군더더기도 없습니다. 이 머신은 적 AI, 메뉴 흐름, 게임 페이즈 루프를 위해 만들어졌습니다 — 틱당 제로 할당, 결정론적 순서, 순수 C#, 그리고 IL2CPP(플레이어 빌드에 쓰이는 Unity의 AOT 컴파일러)에서 안전합니다.

빠른 시작

팩토리를 한 번 resolve한 뒤, 오브젝트마다 머신을 하나씩 만드세요:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)] // Run after the framework has started.
public class MyStateMachineUser : MonoBehaviour
{
    private IStateMachine<MyStateMachineUser> _fsm;

    private void Awake()
    {
        // Via the factory (recommended — you get diagnostics and the one-line off switch):
        _fsm = ServiceLocator.Resolve<IStateMachineService>().Create<MyStateMachineUser>(this);

        // Or construct one directly (works standalone, but is not counted in diagnostics):
        // _fsm = new StateMachine<MyStateMachineUser>(this);
    }
}

보여 준 대로 머신을 필드에 캐시하세요. Update() 안에서 서비스를 resolve하지 마세요.

API 레퍼런스

IStateMachine<TContext> — 매 프레임 직접 틱하는 인스턴스별 머신

TContext는 상태들이 읽고 수정하는 오브젝트입니다 — 보통은 소유 MonoBehaviour나 엔티티입니다.

멤버시그니처설명
CurrentStateNamestring (프로퍼티)현재 상태의 이름. Start 전에는 null.
IsRunningbool (프로퍼티)Starttrue, Disposefalse.
Addvoid Add(string name, IState<TContext> state)상태를 등록합니다. public const string 이름을 쓰면 오타를 컴파일 타임에 잡을 수 있습니다.
AddTransitionvoid AddTransition(string from, string to, Func<TContext, bool> guard)가드 전이를 등록합니다. 전역 인터럽트에는 from으로 StateMachine<TContext>.AnyState를 쓰세요. 그것들이 먼저 평가됩니다. 가드는 저렴하고 부작용 없이 유지하세요 — 레이캐스트도, 상태 변경도 금물입니다.
Startvoid Start(string name)이름 있는 상태로 진입합니다(OnEnter 호출). Tick 전에 한 번 호출해야 합니다. StateChanged를 발생시키지 않습니다.
ChangeStatevoid ChangeState(string name)지금 전이합니다: 이전 상태의 OnExit, 새 상태의 OnEnter, 그 뒤 StateChanged 발생. 알 수 없는 상태 이름이면 안전한 no-op입니다.
Tickvoid Tick(float deltaTime)한 스텝 전진: OnUpdate(deltaTime)를 호출한 뒤 선언적 전이를 평가합니다. 델타를 여러분이 전달하므로 클록도 여러분이 고릅니다.
StateChangedevent Action<string, string>전이 시 OnEnter 후에 (fromName, toName)과 함께 발생합니다. Start에서는 발생하지 않습니다.
Disposevoid Dispose()깔끔한 종료: 현재 상태의 OnExit를 한 번 호출한 뒤 테이블을 비웁니다. 두 번 호출해도 안전합니다.

IState<TContext> — 여러분이 구현하는 것

이 인터페이스를 구현하거나, StateBase<TContext>를 상속해 필요한 것만 오버라이드하세요:

멤버시그니처설명
OnEntervoid OnEnter(TContext ctx)머신이 이 상태에 진입할 때 한 번 호출됩니다.
OnUpdatevoid OnUpdate(TContext ctx, float deltaTime)이 상태가 현재 상태인 동안 매 틱 호출됩니다. ChangeState를 호출해도 됩니다.
OnExitvoid OnExit(TContext ctx)머신이 이 상태를 떠날 때 한 번 호출됩니다.

IStateMachineService — 팩토리

멤버시그니처설명
Create<TContext>IStateMachine<TContext> Create<TContext>(TContext context, StateMachineOptions options = default)새 인스턴스별 머신을 반환합니다. 팩토리로 만든 머신은 ActiveMachineCount에 추적됩니다.
ActiveMachineCountint (프로퍼티)아직 dispose되지 않은 팩토리 생성 머신의 수. new로 직접 만든 머신은 추적되지 않습니다.

예제

using CommonGameSystem.Core;
using UnityEngine;

// The context: the object your states read and modify.
public class Enemy : MonoBehaviour
{
    public bool SeesPlayer;
    public float Health = 100f;
}

// Define state names as public const strings to avoid typos.
public static class EnemyStates
{
    public const string Idle = "Idle";
    public const string Chase = "Chase";
    public const string Dead = "Dead";
}

// Minimal states — extend StateBase and override only what you need.
public class IdleState : StateBase<Enemy> { }
public class ChaseState : StateBase<Enemy> { }
public class DeadState : StateBase<Enemy> { }

[DefaultExecutionOrder(100)] // Run after the framework has started.
public class EnemyController : MonoBehaviour
{
    private IStateMachine<Enemy> _fsm;
    private ITimeService _time;

    private void Awake()
    {
        _time = ServiceLocator.Resolve<ITimeService>();
        var service = ServiceLocator.Resolve<IStateMachineService>();
        _fsm = service.Create<Enemy>(GetComponent<Enemy>());

        _fsm.Add(EnemyStates.Idle, new IdleState());
        _fsm.Add(EnemyStates.Chase, new ChaseState());
        _fsm.Add(EnemyStates.Dead, new DeadState());

        // Declarative transitions (preferred).
        _fsm.AddTransition(EnemyStates.Idle, EnemyStates.Chase, enemy => enemy.SeesPlayer);
        _fsm.AddTransition(EnemyStates.Chase, EnemyStates.Idle, enemy => !enemy.SeesPlayer);

        // Global interrupt: any state can go to Dead.
        _fsm.AddTransition(StateMachine<Enemy>.AnyState, EnemyStates.Dead, enemy => enemy.Health <= 0);

        _fsm.Start(EnemyStates.Idle);
    }

    private void Update()
    {
        // The machine reads no clock — you pass the delta, so you pick the clock.
        _fsm.Tick(_time.DeltaTime(Clock.Gameplay));
    }

    private void OnDestroy() => _fsm.Dispose();
}

끄는 방법

ServiceLocator.Replace<IStateMachineService>(new NullStateMachineService());

이렇게 하면 상태 머신이 무음 처리됩니다: Tick, ChangeState, Start는 no-op이 되고, CurrentStateName은 항상 null이며, StateChanged는 절대 발생하지 않고, ActiveMachineCount는 0입니다. 호출 코드를 바꾸지 않고 테스트 중에 AI나 메뉴 흐름을 끌 때 유용합니다. no-op 서비스는 생성 시 한 번만 경고를 로그하며, 이후의 각 Create<T> 호출은 조용합니다.

흔한 함정

  • 메인 스레드 전용입니다. Tick, ChangeState, Start는 모두 메인 스레드에서 실행해야 합니다. 워커 스레드 호출은 에디터에서 InvalidOperationException을 throw합니다(IL2CPP 릴리즈 빌드에서는 이 검사가 제거됩니다). 머신을 프레임 루프 위에 두세요.
  • 클록은 여러분이 고릅니다. 머신은 클록을 읽지 않습니다 — TickdeltaTime을 전달하세요. 게임과 함께 일시정지되는 동작에는 timeService.DeltaTime(Clock.Gameplay)를 전달하세요. 일시정지나 슬로모션 중에도 계속 틱하려면 timeService.UnscaledDeltaTime(Clock.Gameplay)를 전달하세요. 별도의 "unscaled" 클록은 없습니다: 클록은 Gameplay, UI, Background이고, 각 클록은 스케일된 값과 스케일되지 않은 값을 모두 읽을 수 있습니다 — 타임 참고.
  • 가드는 저렴하게 유지하세요. 전이 가드는 모든 머신에 대해 매 틱 실행됩니다. 가드 안의 비싼 작업 — 레이캐스트, 길찾기, 할당 — 은 머신 수 × 전이 수만큼 배가됩니다. 그런 결과는 Tick을 호출하기 전에 컨텍스트 오브젝트에 계산해 캐시해 두세요.
  • 구조체 컨텍스트는 변경을 유지하지 않습니다. TContextstruct이면 OnUpdate 안에서의 변경은 로컬 복사본에만 적용되고 사라집니다. 참조 타입 — 보통은 소유 MonoBehaviour나 엔티티 — 을 가변 공유 컨텍스트로 쓰세요.
  • 구조체 컨텍스트와 IL2CPP. TContext가 값 타입이면, 제네릭 인스턴스화 StateMachine<YourStruct>가 IL2CPP 코드 스트리핑에서 살아남도록 프로젝트에 link.xml 항목을 추가하세요. 클래스 컨텍스트는 항목이 필요 없습니다.

동작 보장

  • 상태 훅 안에서 요청된 ChangeState는 현재 훅이 끝난 뒤에 적용됩니다. 여러 개가 요청되면 가장 최근 것이 이깁니다. 머신은 이를 재귀가 아니라 루프로 처리합니다 — 전이가 사슬처럼 이어져도 스택이 넘칠 수 없습니다.
  • AnyState 전역 인터럽트는 항상 현재 상태 자신의 전이보다 먼저 평가됩니다.
  • default(StateMachineOptions)가 안전한 설정입니다: 경고 켜짐, 상태 예외 격리, 현재 상태로의 전이는 no-op.
  • 이 머신은 평평한 FSM입니다 — 내장 계층 구조나 상태 스택은 없습니다. 쌓아 올려 재개 가능한 게임 상태에는 푸시다운 스택을 보세요. 백 스택 메뉴 내비게이션에는 UI 프레임워크의 UI 패널 스택을 쓰세요.

관련 페이지