FSM (상태 머신)
직접 틱하는 결정론적 오브젝트별 상태 머신 — 이름 있는 상태, 가드 전이, 전역 인터럽트를 순수 C#으로 제공합니다.
모든 적, 메뉴, 게임 페이즈에 깔끔한 이름 있는 상태 집합을 주고 — 전환은 선언적 가드 전이에 맡기세요.
| 인터페이스 | IStateMachineService — IStateMachine<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나 엔티티입니다.
| 멤버 | 시그니처 | 설명 |
|---|---|---|
CurrentStateName | string (프로퍼티) | 현재 상태의 이름. Start 전에는 null. |
IsRunning | bool (프로퍼티) | Start 후 true, Dispose 후 false. |
Add | void Add(string name, IState<TContext> state) | 상태를 등록합니다. public const string 이름을 쓰면 오타를 컴파일 타임에 잡을 수 있습니다. |
AddTransition | void AddTransition(string from, string to, Func<TContext, bool> guard) | 가드 전이를 등록합니다. 전역 인터럽트에는 from으로 StateMachine<TContext>.AnyState를 쓰세요. 그것들이 먼저 평가됩니다. 가드는 저렴하고 부작용 없이 유지하세요 — 레이캐스트도, 상태 변경도 금물입니다. |
Start | void Start(string name) | 이름 있는 상태로 진입합니다(OnEnter 호출). Tick 전에 한 번 호출해야 합니다. StateChanged를 발생시키지 않습니다. |
ChangeState | void ChangeState(string name) | 지금 전이합니다: 이전 상태의 OnExit, 새 상태의 OnEnter, 그 뒤 StateChanged 발생. 알 수 없는 상태 이름이면 안전한 no-op입니다. |
Tick | void Tick(float deltaTime) | 한 스텝 전진: OnUpdate(deltaTime)를 호출한 뒤 선언적 전이를 평가합니다. 델타를 여러분이 전달하므로 클록도 여러분이 고릅니다. |
StateChanged | event Action<string, string> | 전이 시 OnEnter 후에 (fromName, toName)과 함께 발생합니다. Start에서는 발생하지 않습니다. |
Dispose | void Dispose() | 깔끔한 종료: 현재 상태의 OnExit를 한 번 호출한 뒤 테이블을 비웁니다. 두 번 호출해도 안전합니다. |
IState<TContext> — 여러분이 구현하는 것
이 인터페이스를 구현하거나, StateBase<TContext>를 상속해 필요한 것만 오버라이드하세요:
| 멤버 | 시그니처 | 설명 |
|---|---|---|
OnEnter | void OnEnter(TContext ctx) | 머신이 이 상태에 진입할 때 한 번 호출됩니다. |
OnUpdate | void OnUpdate(TContext ctx, float deltaTime) | 이 상태가 현재 상태인 동안 매 틱 호출됩니다. ChangeState를 호출해도 됩니다. |
OnExit | void OnExit(TContext ctx) | 머신이 이 상태를 떠날 때 한 번 호출됩니다. |
IStateMachineService — 팩토리
| 멤버 | 시그니처 | 설명 |
|---|---|---|
Create<TContext> | IStateMachine<TContext> Create<TContext>(TContext context, StateMachineOptions options = default) | 새 인스턴스별 머신을 반환합니다. 팩토리로 만든 머신은 ActiveMachineCount에 추적됩니다. |
ActiveMachineCount | int (프로퍼티) | 아직 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 릴리즈 빌드에서는 이 검사가 제거됩니다). 머신을 프레임 루프 위에 두세요. - 클록은 여러분이 고릅니다. 머신은 클록을 읽지 않습니다 —
Tick에deltaTime을 전달하세요. 게임과 함께 일시정지되는 동작에는timeService.DeltaTime(Clock.Gameplay)를 전달하세요. 일시정지나 슬로모션 중에도 계속 틱하려면timeService.UnscaledDeltaTime(Clock.Gameplay)를 전달하세요. 별도의 "unscaled" 클록은 없습니다: 클록은Gameplay,UI,Background이고, 각 클록은 스케일된 값과 스케일되지 않은 값을 모두 읽을 수 있습니다 — 타임 참고. - 가드는 저렴하게 유지하세요. 전이 가드는 모든 머신에 대해 매 틱 실행됩니다. 가드 안의 비싼 작업 — 레이캐스트, 길찾기, 할당 — 은 머신 수 × 전이 수만큼 배가됩니다. 그런 결과는
Tick을 호출하기 전에 컨텍스트 오브젝트에 계산해 캐시해 두세요. - 구조체 컨텍스트는 변경을 유지하지 않습니다.
TContext가struct이면OnUpdate안에서의 변경은 로컬 복사본에만 적용되고 사라집니다. 참조 타입 — 보통은 소유 MonoBehaviour나 엔티티 — 을 가변 공유 컨텍스트로 쓰세요. - 구조체 컨텍스트와 IL2CPP.
TContext가 값 타입이면, 제네릭 인스턴스화StateMachine<YourStruct>가 IL2CPP 코드 스트리핑에서 살아남도록 프로젝트에link.xml항목을 추가하세요. 클래스 컨텍스트는 항목이 필요 없습니다.
동작 보장
- 상태 훅 안에서 요청된
ChangeState는 현재 훅이 끝난 뒤에 적용됩니다. 여러 개가 요청되면 가장 최근 것이 이깁니다. 머신은 이를 재귀가 아니라 루프로 처리합니다 — 전이가 사슬처럼 이어져도 스택이 넘칠 수 없습니다. AnyState전역 인터럽트는 항상 현재 상태 자신의 전이보다 먼저 평가됩니다.default(StateMachineOptions)가 안전한 설정입니다: 경고 켜짐, 상태 예외 격리, 현재 상태로의 전이는 no-op.- 이 머신은 평평한 FSM입니다 — 내장 계층 구조나 상태 스택은 없습니다. 쌓아 올려 재개 가능한 게임 상태에는 푸시다운 스택을 보세요. 백 스택 메뉴 내비게이션에는 UI 프레임워크의 UI 패널 스택을 쓰세요.