| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- 이벤트 버스
- Abstract Factory Pattern
- 의존성 주입
- 유니티
- C#
- 상태 패턴
- Di
- unity
- New Input System
- Canvas Scaler
- github desktop
- Render Mode
- Unity 기술면접
- 오브젝트 풀
- Dependency Injection
- 기술면접
- 콘솔 앱
- Visual Studio Installer
- MVP 패턴
- 구글 스프레드 시트
- 개발
- 추상 팩토리 패턴
- event bus
- 게임 디자인 패턴
- 데이터 형식
- 구글플레이콘솔
- Object Pool
- 스타일 지침
- 테이블 데이터
- 프로젝트 생성
- Today
- Total
DevDino
FSM을 활용한 캐릭터 행동 제어 본문
육성 게임을 개발하며 유저가 몰입할 수 있도록 캐릭터의 다양한 상태 변화를 자연스럽게 구현해야 했습니다.
단순하게 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)의 위험성도 존재합니다. 이를 방지하기 위해 모든 상태 로직이 단순하다면 가벼운 열거형으로 대체하는 등 여러 방식을 혼용하여 유연하게 대처하고 있습니다.
'개발 일지' 카테고리의 다른 글
| 구글 스프레드 시트 데이터를 ScriptableObject로 변환하기 (0) | 2026.07.18 |
|---|---|
| Unity 이벤트 버스 적용: 최적화와 데이터 주도 설계 (0) | 2026.07.14 |
| VContainer를 사용한 의존성 주입(Dependency Injection) 도입 (0) | 2026.03.23 |
| Unity New Input System (0) | 2026.01.13 |