2025. 12. 12. 21:03ㆍTIL
Unity에서 State Pattern으로 유닛 FSM 구현하기
배경
오토배틀 게임을 만들면서 유닛의 상태를 관리할 필요가 생겼다. 유닛은 이동(MoveState), 공격(AttackState), 사망(DieState) 등 여러 상태를 가지는데, 이를 if-else나 switch문으로 관리하면 코드가 복잡해지고 유지보수가 어려워진다.
State Pattern이란?
객체의 내부 상태가 변경될 때 행동을 변경할 수 있게 하는 디자인 패턴. 각 상태를 별도의 클래스로 분리해서 상태별 로직을 캡슐화한다.
1. State 인터페이스 정의
모든 State가 구현해야 할 메서드를 정의했다.
public interface IUnitState
{
void Enter(UnitFSM unitFSM); // 상태 진입 시
void Execute(UnitFSM unitFSM); // 매 프레임
void PhysicsExecute(UnitFSM unitFSM); // 물리 업데이트
void Exit(UnitFSM unitFSM); // 상태 종료 시
}
처음엔 Enter/Exit만 있었는데, Update와 FixedUpdate를 분리할 필요가 생겨서 Execute와 PhysicsExecute를 나눴다. 이동은 물리 계산이 필요하고, 타겟 탐색은 일반 업데이트에서 처리하는 게 맞다고 판단했다.
2. FSM 매니저 구현
상태를 관리하고 전환하는 FSM 클래스를 만들었다.
public class UnitFSM : MonoBehaviour
{
private UnitBase owner;
private IUnitState currentState;
public void Init(UnitBase unit)
{
owner = unit;
ChangeState(new MoveState()); // 초기 상태
}
public void ChangeState(IUnitState newState)
{
currentState?.Exit(this);
currentState = newState;
currentState?.Enter(this);
}
private void Update()
{
currentState?.Execute(this);
}
private void FixedUpdate()
{
currentState?.PhysicsExecute(this);
}
public UnitBase GetOwner() => owner;
}
핵심은 ChangeState 메서드다. 이전 상태를 Exit하고, 새 상태로 Enter한다. Null 체크는 ?. 연산자로 간결하게 처리했다.
3. 구체적인 State 구현
MoveState (이동 상태)
public class MoveState : IUnitState
{
private UnitBase unit;
private Vector3 moveDirection;
public void Enter(UnitFSM unitFSM)
{
unit = unitFSM.GetOwner();
moveDirection = unit.GetMoveDirection();
}
public void Execute(UnitFSM unitFSM)
{
// 적 탐색
UnitBase enemy = unit.FindNearestEnemy();
if (enemy != null)
{
unit.SetCurrentTarget(enemy);
moveDirection = (enemy.transform.position - unit.transform.position).normalized;
// 사거리 안에 들어오면 공격 상태로 전환
if (unit.IsTargetInAttackRange(enemy))
{
unitFSM.ChangeState(new AttackState());
return;
}
}
}
public void PhysicsExecute(UnitFSM unitFSM)
{
// Transform 직접 이동 (Physics2D 사용 안 함)
float moveSpeed = unit.Data.unitMoveSpeed ?? 2f;
unit.transform.position += moveDirection * moveSpeed * Time.fixedDeltaTime;
}
public void Exit(UnitFSM unitFSM) { }
}
AttackState (공격 상태)
public class AttackState : IUnitState
{
private UnitBase unit;
private float attackTimer;
private Vector3 combatPosition;
public void Enter(UnitFSM unitFSM)
{
unit = unitFSM.GetOwner();
attackTimer = 0f;
combatPosition = unit.transform.position; // 전투 위치 고정
unit.SetCombatState(true);
}
public void Execute(UnitFSM unitFSM)
{
UnitBase target = unit.GetCurrentTarget();
// 타겟 유효성 체크
if (target == null || target.IsDead)
{
unitFSM.ChangeState(new MoveState());
return;
}
// 사거리 벗어나면 이동 상태로
if (!unit.IsTargetInAttackRange(target))
{
unitFSM.ChangeState(new MoveState());
return;
}
// 공격 타이머
attackTimer += Time.deltaTime;
float attackSpeed = unit.Data.unitAttackSpeed ?? 1f;
if (attackTimer >= 1f / attackSpeed)
{
attackTimer = 0f;
target.TakeDamage(unit.CurAtk);
}
}
public void PhysicsExecute(UnitFSM unitFSM)
{
// 위치 고정 (밀림 방지)
unit.transform.position = combatPosition;
}
public void Exit(UnitFSM unitFSM)
{
unit.SetCombatState(false);
// 다음 적 즉시 탐색
UnitBase nextEnemy = unit.FindNearestEnemy();
if (nextEnemy != null)
{
unit.SetCurrentTarget(nextEnemy);
}
}
}
마주친 문제들과 해결
문제 1: 상태 전환이 너무 자주 일어남
적을 찾았다가 놓쳤다가를 반복하면서 MoveState ↔ AttackState를 왔다갔다했다.
해결: 적 감지를 0.3초 간격으로 체크하도록 최적화했다. 매 프레임 체크할 필요가 없었다.
private float detectionCheckInterval = 0.3f;
private float lastDetectionCheck = 0f;
public void Execute(UnitFSM unitFSM)
{
lastDetectionCheck += Time.deltaTime;
if (lastDetectionCheck >= detectionCheckInterval)
{
lastDetectionCheck = 0f;
// 적 탐색
}
}
문제 2: 적 처치 후 불필요하게 전진함
AttackState에서 적을 죽이고 MoveState로 돌아왔을 때, 다음 적을 찾기까지 0.3초 동안 원래 방향(위쪽)으로 전진했다.
해결:
- AttackState의 Exit에서 즉시 다음 적을 탐색
- MoveState에서 타겟이 없으면 moveDirection을 Vector3.zero로 설정해서 정지
문제 3: 전투 중 뒷유닛이 앞유닛을 밀어냄
유닛끼리 충돌하면서 전투 중인 유닛이 밀려났다.
해결:
- AttackState에서 위치를 완전히 고정 (transform.position = combatPosition)
- Physics2D를 사용하지 않아서 물리 엔진의 간섭이 없음
문제 4: State 간 중복 코드
MoveState와 AttackState 모두 FindNearestEnemy() 메서드가 필요했다.
해결: UnitBase에 공용 메서드로 빼서 중복 제거
// UnitBase.cs
public UnitBase FindNearestEnemy()
{
float detectionRange = Data.unitDetectionRange ?? 5f;
Collider2D[] hits = Physics2D.OverlapCircleAll(transform.position, detectionRange);
// ...
}
// State에서 사용
UnitBase enemy = unit.FindNearestEnemy();
State Pattern의 장점 (실제로 느낀 점)
1. 코드 가독성
각 상태의 로직이 분리되어 있어서 MoveState 파일만 열면 이동 관련 로직만 볼 수 있다. if-else 지옥에서 벗어났다.
2. 유지보수
공격 로직을 수정하려면 AttackState만 건드리면 된다. 다른 상태에 영향을 주지 않는다.
3. 확장성
새로운 상태 추가가 쉽다. IUnitState를 구현한 새 클래스만 만들면 된다.
public class IdleState : IUnitState { }
public class SkillState : IUnitState { }
public class StunState : IUnitState { }
4. 테스트
각 State를 독립적으로 테스트할 수 있다.
State Pattern의 단점 (겪어본 것)
1. 클래스 개수 증가
상태마다 파일이 생긴다. 지금은 3개지만 나중에 10개 넘어가면 관리가 복잡해질 수 있다.
2. State 간 데이터 공유 어려움
MoveState에서 계산한 값을 AttackState에서 쓰고 싶을 때가 있는데, UnitBase를 통해 우회해야 한다.
3. 메모리 할당
상태 전환할 때마다 new MoveState()로 새 객체를 생성한다. 나중에 오브젝트 풀링이 필요할 수도 있다.
배운 점
- 디자인 패턴은 도구다: State Pattern이 만능은 아니다. 상태가 2-3개면 오히려 과한 설계일 수 있다. 하지만 상태가 많고 전환이 복잡하면 확실히 효과적이다.
- 상태 전환 타이밍: 상태 전환을 언제 할지가 중요하다. 너무 자주 하면 왔다갔다하고, 너무 늦으면 반응이 느리다. 적절한 체크 간격(0.3초)을 찾는 게 핵심이었다.
- Exit의 중요성: 단순히 정리만 하는 게 아니라, 다음 상태를 위한 준비도 할 수 있다. AttackState의 Exit에서 다음 적을 미리 찾아두니 끊김이 없어졌다.
- 중복 제거는 나중에: 처음엔 각 State에 FindNearestEnemy()를 따로 만들었다. 일단 동작하게 만들고, 나중에 리팩토링해서 UnitBase로 옮겼다. 처음부터 완벽하게 설계하려 하지 말자.
다음에 시도해볼 것
- State 재사용: new MoveState() 대신 State를 미리 만들어두고 재사용하면 GC 부담을 줄일 수 있을 것 같다.
- Sub-State Machine: AttackState 안에서도 "조준 -> 공격 -> 쿨다운" 같은 서브 상태가 필요할 수 있다.
- Transition 클래스 분리: 상태 전환 조건이 복잡해지면 Transition을 별도 클래스로 만드는 것도 고려해볼 만하다.
'TIL' 카테고리의 다른 글
| [2025_12_16] 오토배틀 게임 유닛 AI 구현하며 마주친 문제들 (1) | 2025.12.16 |
|---|---|
| [2025_12_15] SpreadSheet에서 배열 파싱 오류 (0) | 2025.12.15 |
| [2025_12_11] 변동된 데이터 테이블 확인의 중요 (0) | 2025.12.11 |
| [2025_12_10]SpreadSheet -> Json 파싱 툴 (0) | 2025.12.10 |
| [2025_12_09] 기획의 방향성 재정립 (0) | 2025.12.09 |