DevDino

FSM을 활용한 캐릭터 행동 제어 본문

개발 일지

FSM을 활용한 캐릭터 행동 제어

데브다이노 2026. 7. 14. 14:50

육성 게임을 개발하며 유저가 몰입할 수 있도록 캐릭터의 다양한 상태 변화를 자연스럽게 구현해야 했습니다.

단순하게 Update 문 안에서 조건문으로 모든 행동을 처리하거나, 여러 상태를 하나의 스크립트에 몰아넣으면 초기 구현은 편할 수 있습니다. 하지만 기획이 조금만 변경되어도 유지보수에 드는 비용이 기하급수적으로 커질 뿐만 아니라 상태가 언제 어떤 조건으로 넘어가는지 그 흐름 자체를 추적하고 통제하기가 어려워집니다. 

 

상태 전환을 더 직관적으로 관리함과 동시에 프로젝트의 확장성과 가독성을 높이기 위해 유한 상태 기계(FSM, Finite State Machine)을 사용해 캐릭터의 행동을 제어해 보았습니다.

행동 제어를 위해 설계한 다이어그램

 

1. 상태 인터페이스 및 매니저 구현

먼저 모든 상태가 공통으로 가질 규격을 인터페이스(IDinoState)로 정의하고, 상태 전환을 담당할 핵심 엔진(DinoStateMachine)을 구현했습니다.

using System;
using System.Collections.Generic;

public enum DinoStateType
{
    Idle,
    Hungry,
    Sick,
    Walk,
    Happy,
    Playful
}

public class DinoStateMachine 
{
    readonly Dictionary<DinoStateType, IDinoState> _states = new();
    public IDinoState Current { get; private set; }
    public event Action<DinoStateType> OnStateChanged;

    public void AddState(IDinoState state)
    {
        _states[state.Type] = state;
    }

    public void InitState()
    {
        Current = _states[DinoStateType.Idle];
        Current?.Enter();
        OnStateChanged?.Invoke(DinoStateType.Idle);
    }

    public void ChangeState(DinoStateType next)
    {
        if (Current == null || Current.Type == next) return;

        Current?.Exit();
        Current = _states[next];
        Current?.Enter();
        OnStateChanged?.Invoke(next);
    }

    public void Tick(float dt)
    {
        Current?.Tick(dt);
    }
}

 

구체적인 상태 클래스들이 공통으로 사용하는 변수(Animator, DinoContext 등)를 매번 선언하는 것을 막기 위해 추상 클래스로 추가로 두어 코드의 재사용성을 높였습니다.

using UnityEngine;

public interface IDinoState
{
    DinoStateType Type { get; }
    void Enter();
    void Tick(float dt);
    void Exit();
}

public abstract class DinoStateBase : IDinoState
{
    protected readonly DinoContext ctx;
    protected readonly Animator anim;
    protected readonly DinoStateMachine fsm;

    protected DinoStateBase(DinoContext ctx, Animator anim, DinoStateMachine fsm)
    {
        this.ctx = ctx;
        this.anim = anim;
        this.fsm = fsm;
    }

    public abstract DinoStateType Type { get; }
    public virtual void Enter() { }
    public virtual void Tick(float dt) { }
    public virtual void Exit() { }
    public virtual void FixedTick(float fixedDt) {}

    protected void PlayAnim(string animName)
    {
        Debug.Log(animName);
        if (anim == null) return;
        anim.Play(animName);
    }
}

 

2. 구체적인 상태 클래스 분리

각 행동들을 독립적인 클래스로 분리합니다. 다음은 대기(Idle) 상태일 때 확률에 따라 걷기(Walk) 상태로 전환되도록 구현한 예시입니다.

using UnityEngine;
using VContainer;

// 대기 상태
public class DinoIdleState : DinoStateBase
{
    // ...
    readonly float _walkChance = 0.35f;
    FocusManager _focusManager;

    public DinoIdleState(DinoContext ctx, Animator anim, DinoStateMachine fsm, FocusManager focusMgr) : base(ctx, anim, fsm) 
    {
        _focusManager = focusMgr;
    }

    public override DinoStateType Type => DinoStateType.Idle;

    public override void Tick(float dt)
    {
        base.Tick(dt);
        // ...
        // 일정 확률 및 Focus 상태에 따라 Walk 상태로 전환
        if (Random.value < _walkChance && _focusManager.Current == null)
            fsm.ChangeState(DinoStateType.Walk);
    }

    public override void Enter()
    {
        PlayAnim("Idle");
    }
}
using UnityEngine;
using VContainer;
public class DinoWalkState : DinoStateBase 
{
    readonly Transform _self;
    readonly Rigidbody2D _rb;
    // 최대 거리
    readonly float _radius = 2f;
    // 이동 시간 
    readonly float _duration = 3f;
    // 진행 시간
    float _t;
    // 이동할 곳
    Vector3 _target;
    FocusManager _focusManager;

    public DinoWalkState(DinoContext ctx, Animator anim, DinoStateMachine fsm, FocusManager focusMgr, Transform self, Rigidbody2D rb) : base(ctx, anim, fsm)
    {
        _focusManager = focusMgr;
        _self = self;
        _rb = rb;
    }

    public override DinoStateType Type => DinoStateType.Walk;

    public override void Enter()
    {
        _t = 0f;

        var offset = Random.insideUnitSphere;

        Vector3 nextTarget = _self.position + offset.normalized * Random.Range(1f, _radius);
        nextTarget.x = Mathf.Clamp(nextTarget.x, GameConstants.MAP_MIN_X, GameConstants.MAP_MAX_X);
        nextTarget.y = Mathf.Clamp(nextTarget.y, GameConstants.MAP_MIN_Y, GameConstants.MAP_MAX_Y);

        _target = nextTarget;

        if (_target.x < _self.position.x) PlayAnim("WalkLeft");
        else PlayAnim("WalkRight");
    }
    public override void Tick(float dt)
    {
        _t += dt;

        if (_t >= _duration || Vector2.Distance(_self.position, _target) < 0.05f || _focusManager.Current != null)
            fsm.ChangeState(DinoStateType.Idle);
    }
    public override void FixedTick(float fixedDt)
    {
        Vector2 nextPos = Vector2.MoveTowards(_rb.position, _target, fixedDt);
        _rb.MovePosition(nextPos);
    }
}

 

3. 데이터 로직의 분리

복잡한 데이터를 따로 관리하기 위해 캐릭터의 배고픔, 지루함, 애정도 같은 수치는 DinoContext라는 전용 클래스로 분리했습니다.

DinoContext는 시간의 흐름에 따라 수치를 갱신하고 유의미한 변화가 일어날 때 이벤트를 발행(OnVitalsChanged)하여 외부에 알립니다.

// DinoContext.cs 일부
public class DinoContext 
{
    [Range(0, 1)] public float satiety;   // 포만감
    [Range(0, 1)] public float boredom;   // 지루함
    public bool IsHungry => satiety <= 0.1f;
    public bool IsSick { get; private set; }

    public event Action OnVitalsChanged;

    public void Tick(float dt)
    {
        // 생체 수치 업데이트 및 아픔(Sick) 판정 로직
        // ... 

        // 수치 변화가 임계치를 넘었을 때만 이벤트 발행
        if (prevHungry != IsHungry || prevSick != IsSick)
            OnVitalsChanged?.Invoke();
    }
}

 

4. 메인 두뇌 구성

이 모든 것을 조립하고 실행해 줄 MonoBehaviour인 DinoBrain입니다.

DinoContext의 이벤트(OnVitalChanged)를 구독하여 수치에 변화가 생겼을 때 다음 상태에 대한 판단을 하고 FSM에 상태 변경을 요청합니다.

using UnityEngine;
using VContainer;
// ...

public class DinoBrain : MonoBehaviour
{
    public DinoContext context = new DinoContext();
    DinoStateMachine fsm;

    // 외부 매니저 DI 주입
    [Inject] InputManager _inputManager;
    [Inject] GameDataManager _gameDataManager;
    [Inject] FocusManager _focusManager;
    // ...

    private void Awake()
    {
        fsm = new DinoStateMachine();

        // 각 상태 인스턴스화 및 FSM에 등록
        fsm.AddState(new DinoIdleState(context, anim, fsm, _focusManager));
        fsm.AddState(new DinoWalkState(context, anim, fsm, _focusManager, transform));
        // ...

        fsm.InitState();
        // 생체 수치 변화 이벤트 구독 
        context.OnVitalsChanged += HandleVitalsChanged;
    }

    private void Update()
    {
        float dt = Time.deltaTime;

        // 현재 상태 행동 실행 
        fsm.Tick(dt);
        // ...
    }

    // 생체 수치(포만감, 체력 등)가 변할 때 상태 결정 위임
    void HandleVitalsChanged()
    {
        // 내부 판별 로직을 거쳐 다음 상태를 결정
        DinoStateType nextState = DetermineNextState();
        fsm.ChangeState(nextState);
    }
}

 

5. 마무리 및 적용 후기

FSM 도입 후 가장 크게 체감한 장점은 디버깅의 편리함이었습니다. 걷기 로직에 버그가 발생했을 때 거대한 스크립트를 뒤질 필요 없이 DinoWalkState 클래스 하나만 확인하면 돼서 빠르고 정확하게 오류를 잡았습니다.

 

또한 상태의 진입, 진행, 퇴장 생명주기를 명확하게 관리할 수 있어 관리하기가 편했습니다.

그리고 프로젝트 일정이나 리소스 상황에 따라 특정 상태의 구현을 미루거나 뗐다 붙였다 하는 확장성 면에서 만족스러웠습니다.

 

개별 상태 클래스를 만들어야 하므로 오버엔지니어링(Over-engineering)의 위험성도 존재합니다. 이를 방지하기 위해 모든 상태 로직이 단순하다면 가벼운 열거형으로 대체하는 등 여러 방식을 혼용하여 유연하게 대처하고 있습니다.