
FSM과 비슷한 개념이다. 아니, 게임에 한해서는 거의 동일한 개념이다게임 구현에서 자주 사용되는 디자인 패턴으로, 객체가 가진 상태를 관리하고, 각 상태에 따른 행동을 미리 지정할 수 있다
현업에서는 코드의 재사용성과 유지보수성을 높이기 위해 객체지향적인 설계를 지향하고 있다. 상태패턴은 어떤 한 객체의 상태를 직관적으로 선언하고 제어어할 수 있다. 게임 프로그래밍에서는 필수 디자인 패턴이다
플레이어 캐릭터가 'Idle','Move','Jump','Attack' 등의 상태를 가졌을 때 객체지향적인 설계가 고려되지 않으면 어떻게 스크립팅 되는지 보자
public enum StateList
{
Idle, Move, Jump, Attack
}
public class PlayerState : MonoBehaviour
{
private int _direction;
[SerializeField] private StateList CurrentState;
private void Update()
{
// 키 입력 에 따른 각 상태 변화
}
private void Idle()
{
// 기능 로직
// 애니메이션 변화 로직
// 예외처리 등
}
private void Move()
{
// 기능 로직
// 애니메이션 변화 로직
// 예외처리 등
}
private void Jump()
{
// 기능 로직
// 애니메이션 변화 로직
// 예외처리 등
}
private void Attack()
{
// 기능 로직
// 애니메이션 변화 로직
// 예외처리 등
}
}
상태패턴을 이렇게 짜면 안된다. 조건이나 예외 처리를 해야하는 것들이 너무 많아진다
상태가 하나씩 추가될 때마다 하나의 클래스에 각 기능구현과 더불어 각 상황에 대한 예외처리까지 생각해야 하므로, 결과적으로 버그나 오류 발생 확률을 높이게 된다. 그리고 State를 플레이어 뿐만 아니라 플레이어와 상태와 동작이 동일한 몬스터 객체에도 적용해야 할 경우, 몬스터 클래스 내부에서 다시 새롭게 개별 동작을 정의해야 한다는 단점이 있다.
HFSM
록맨 X4의 조작같은 경우에는 상태 패턴으로 만들기는 힘들다. 애니메이터의FSM처럼 만들어야 할까? 예를들어, 대쉬중이면서 점프 중이면대쉬점프 상태, 대쉬점프 상태에서 발사면대쉬점프발사 상태가 된다.이동상태,이동발사상태,이동대쉬상태등 너무 많은 상태가 만들어진다. 그러면 유지보수가 힘들어진다. 이런식으로 중첩된 상태들 같은 경우,큰 상태 하나에 작은 상태들을 가지는 형태인 하이라키 FSM(HFSM)으로 만들어야 한다
피격, 발사도 하나의 상태여야 한다
Behaviour Tree 구조
- BT 구조 개요
- Behaviour Tree를 알아보자
Tree 자료구조를 통해 구현하는 상태 패턴이라고 볼 수 있다Root 노드가 상태를 결정하는 방식이라서 상태들 간의 연결 상태를 체크할 필요가 없다- 다만, 상태에서 다른 상태로 연결되는 부분에 새로운 상태가 필요한(예를들어 애니메이션) 경우 유용하지 않은 방법
- 적의
AI로 많이 쓰인다

상태 패턴은 각 개별적인 상태를 하나의 클래스, 객체로 지정해 놓고 각 상태에 따른 동작을 미리 정의하는 것이 핵심. 실습을 통해 상태패턴을 구현하고, 캐릭터에 적용해 보겠습니다.
먼저 BaseState를 만든다
// MonoBehaviour를 상속받지 않는다
// 여기서는 따로 구현을 하지 않는다
// 다른 상태들은 이 클래스를 상속받는다
// 추상화 클래스로 만들고, 상태들이 기능을 각자 구현한다
// 플레이어, 적 모두 BaseState를 상속받는다
public abstract class BaseState
{
// FixedUpdate를 사용하기 원한다면 이것을 true로 가진다
public bool HasPhysics;
// 1. 상태가 시작 될 때
// 2. 해당 상태에서 동작을 담당
// 3. 상태가 끝날 때
// 1. 상태가 시작 될 때
public abstract void Enter();
// 2. 해당 상태에서 동작을 담당
public abstract void Update();
// 2-1. 사용하지 않는 상태들도 있기 때문에 가상 함수로 선언
public virtual void FixedUpdate() { }
// 3. 상태가 끝날 때
public abstract void Exit();
}
// 상태를 나타내는 열거형
public enum EState
{
Idle, Walk, Jump
}
BaseState를 상속받아서 플레이어 전용 상태 패턴을 구현한다// BaseState를 상속받는다
// 플레이어의 상태들은 이 클래스를 상속받는다
// AnyState와 같은 기능을 이 클래스에서 담당한다
public class PlayerState : BaseState
{
// 플레이어 정보를 가져와서 상태 판단
protected Player player;
public PlayerState(Player player)
{
this.player = player;
}
// 모두 오버라이드 했으므로 하위 클래스들은 원하는 기능만 구현하면 된다
public override void Enter() { }
public override void Update()
{
if (player.isJump && player.isGround)
{
player.stateMachine.ChangeState(player.stateMachine.stateDic[EState.Jump]);
}
}
public override void Exit() { }
}
// 플레이어의 상태들을 클래스로 작성한다
// PlayerState를 상속받는다
public class Player_Idle : PlayerState
{
public Player_Idle(Player player) : base(player)
{
HasPhysics = false;
}
public override void Enter()
{
player.animator.Play(player.IDLE_HASH);
// 속도 초기화
player.rig.velocity = Vector2.zero;
}
public override void Update()
{
// 점프 판단을 위해 상위 Update도 수행한다
base.Update();
// 딱 0으로 떨어지게 구하는 것은 피한다(업계 불문율)
// Mathf.Abs(player.inputX)>0.05f
if (player.inputX != 0)
{
player.stateMachine.ChangeState(player.stateMachine.stateDic[EState.Run]);
}
// 점프는 Idle 상태에서도, Walk 상태에서도 넘어갈 수 있으므로 AnyState(상위 상태 = PlayerState)에서 넘어가는 걸로 한다
}
public override void Exit() { }
}
public class Player_Run : PlayerState
{
public Player_Run(Player player) : base(player)
{
HasPhysics = true;
}
public override void Enter()
{
player.animator.Play(player.RUN_HASH);
}
public override void Update()
{
base.Update();
// 입력값이 없을 경우, Idle로 전이
if (Mathf.Abs(player.inputX) < 0.05f)
{
player.stateMachine.ChangeState(player.stateMachine.stateDic[EState.Idle]);
}
// 왼쪽, 오른쪽 방향 전환(스프라이트, 카메라)
if (player.inputX < 0)
{
player.spriteRenderer.flipX = true;
player.cinemachine.m_TrackedObjectOffset = new Vector3(-2, 2, 0);
}
else if (player.inputX > 0)
{
player.spriteRenderer.flipX = false;
player.cinemachine.m_TrackedObjectOffset = new Vector3(2, 2, 0);
}
}
public override void FixedUpdate()
{
player.rig.velocity = new Vector2(player.inputX * player.moveSpeed, player.rig.velocity.y);
}
public override void Exit() { }
}
public class Player_Jump : PlayerState
{
public Player_Jump(Player player) : base(player)
{
HasPhysics = true;
}
public override void Enter()
{
player.animator.Play(player.JUMP_HASH);
player.rig.AddForce(Vector2.up * player.jumpPower, ForceMode2D.Impulse);
// isGround는 Player 인스턴스의 OnCollision 부분에서 true로 바뀐다
player.isGround = false;
// 점프키 입력이 있을경우 true로 들어온다
player.isJump = false;
}
public override void Update()
{
if (player.isGround)
{
player.stateMachine.ChangeState(player.stateMachine.stateDic[EState.Idle]);
}
// 왼쪽, 오른쪽 방향 전환(스프라이트, 카메라)
// 모든 상태에서 동일한 내용이면, 최상위(PlayerState)로 올리는게 낫다
if (player.inputX < 0)
{
player.spriteRenderer.flipX = true;
player.cinemachine.m_TrackedObjectOffset = new Vector3(-5, 2, 0);
}
else if (player.inputX > 0)
{
player.spriteRenderer.flipX = false;
player.cinemachine.m_TrackedObjectOffset = new Vector3(5, 2, 0);
}
}
public override void FixedUpdate()
{
player.rig.velocity = new Vector2(player.inputX * player.moveSpeed, player.rig.velocity.y);
}
}
PlayerState에서 조건을 받아서 넘어가는 걸로 한다// 플레이어 컨트롤러가 해당 클래스를 받도록 한다
public class StateMachine
{
// 1. 상태를 가지고 있어야 한다. 딕셔너리로 관리
public Dictionary<EState, BaseState> stateDic;
// 2. 각 상태를 받아서 조건에 따라 상태를 전이(Transition) 시켜줘야 한다
// 2-1. 현재 상태 저장
public BaseState curState;
// 2-2. 상태 변경 함수
public void ChangeState(BaseState changeState)
{
// 변경 하는게 현재 상태이면 return
if (curState == changeState) return;
curState.Exit(); // 현재 상태를 벗어난다
curState = changeState; // 다음 상태로 전이
curState.Enter(); // 다음 상태 진입
}
// 3. 각 상태의 Enter, Update, Exit ... 을 실행시켜준다
public void Update() => curState.Update();
public void FixedUpdate()
{
// FixedUpdate를 사용하기 위해서 HasPhysics를 true로 바꾸면 사용할 수 있다
if(curState.HasPhysics)
curState.FixedUpdate();
}
}
public class Player : MonoBehaviour , IDamagable
{
[field: Header("Set Value")]
[field: SerializeField] public float moveSpeed { get; set; }
[field: SerializeField] public float dashSpeed { get; set; }
[field: SerializeField] public float jumpPower { get; set; }
[field: SerializeField] public float fireSpeed { get; set; }
[field: SerializeField] public float dashTime { get; set; }
// 인스턴스 및 컴포넌트 참조
public StateMachine stateMachine;
public Rigidbody2D rig;
public Animator animator;
public SpriteRenderer spriteRenderer;
[SerializeField] public CinemachineVirtualCamera virtualCamera;
public CinemachineFramingTransposer cinemachine;
//변수
//public float MoveSpeed;
public bool isMove;
public bool isJump;
public bool isGround;
public bool isDash;
public bool isCharging;
public float dashDir;
public float chargeTime;
public float dashTimer;
public float inputX;
//애니메이션
public readonly int IDLE_HASH = Animator.StringToHash("Idle");
public readonly int RUN_HASH = Animator.StringToHash("Run");
public readonly int JUMP_HASH = Animator.StringToHash("Jump");
public readonly int DASH_HASH = Animator.StringToHash("Dash");
public readonly int ATK_HASH = Animator.StringToHash("Attack");
// 게임 시작 시 설정
private void Start()
{
animator = GetComponent<Animator>();
rig = GetComponent<Rigidbody2D>();
spriteRenderer = GetComponent<SpriteRenderer>();
cinemachine = virtualCamera.GetCinemachineComponent<CinemachineFramingTransposer>();
// 스테이트 머신 인스턴스 생성
StateMachineInit();
}
// 매 프레임마다 수행
private void Update()
{
// 인풋만 여기서 받도록 한다
inputX = Input.GetAxisRaw("Horizontal");
isJump = Input.GetKeyDown(KeyCode.C);
// state의 업데이트로 책임 전가
stateMachine.Update();
}
private void FixedUpdate()
{
stateMachine.FixedUpdate();
}
private void OnCollisionEnter2D(Collision2D collision)
{
if (collision.gameObject.layer == 6)
{
isGround = true;
}
}
// SM 생성 및 딕셔너리 추가
void StateMachineInit()
{
stateMachine = new StateMachine();
// 여기서 this는 Player 클래스의 인스턴스다
stateMachine.stateDic.Add(EState.Idle, new Player_Idle(this));
stateMachine.stateDic.Add(EState.Run, new Player_Run(this));
stateMachine.stateDic.Add(EState.Jump, new Player_Jump(this));
// 초기 상태 설정
stateMachine.curState = stateMachine.stateDic[EState.Idle];
}
public void TakeDamage(int damage)
{
}
}
Player스크립트와 동일한 기능을 수행한다public class Enemy : MonoBehaviour
{
[field: Header("Set Value")]
[field: SerializeField] public float moveSpeed { get; set; }
[field: SerializeField] public float fireSpeed { get; set; }
[field: SerializeField] public float fireDelay { get; set; }
[field: SerializeField] public int damage { get; set; }
[field: SerializeField] public float searchDistance { get; set; }
[field: SerializeField] public float hideDistance { get; set; }
[Header("Set Reference")]
[SerializeField] public EnemyBullet bulletPrefab;
// 상태
public bool isHiding = false;
public bool isAttacking = false;
// 참조
public Rigidbody2D rig;
public Animator animator;
public SpriteRenderer spriteRenderer;
public StateMachine stateMachine;
// 지역변수
public float timer;
public int flip = 1;
public float moveTimer;
public Vector2 moveDir;
// 애니메이션
public readonly int IDLE_HASH = Animator.StringToHash("Idle");
public readonly int MOVE_HASH = Animator.StringToHash("Move");
public readonly int ATK_HASH = Animator.StringToHash("Attack");
public readonly int HATD_HASH = Animator.StringToHash("HatDown");
public readonly int HATU_HASH = Animator.StringToHash("HatUp");
private void Start()
{
animator = GetComponent<Animator>();
spriteRenderer = GetComponent<SpriteRenderer>();
rig = GetComponent<Rigidbody2D>();
StateMachineInit();
moveDir = Vector2.left;
}
private void FixedUpdate()
{
stateMachine.FixedUpdate();
}
private void Update()
{
stateMachine.Update();
Debug.Log($"적 현재 상태 {stateMachine.curState}");
}
void StateMachineInit()
{
stateMachine = new StateMachine();
stateMachine.stateDic.Add(EState.Idle, new Enemy_Idle(this));
stateMachine.stateDic.Add(EState.Move, new Enemy_Move(this));
stateMachine.stateDic.Add(EState.Hide, new Enemy_Hide(this));
stateMachine.stateDic.Add(EState.Attack, new Enemy_Attack(this));
stateMachine.curState = stateMachine.stateDic[EState.Idle];
}
private void OnCollisionEnter2D(Collision2D collision)
{
// TODO : 플레이어에게 데미지 넣기
// IDamagable
}
}
Player State와 동일한 기능을 수행한다public class EnemyState : BaseState
{
protected Enemy enemy;
protected float fireDelay;
public EnemyState(Enemy enemy)
{
this.enemy = enemy;
}
public override void Enter() { }
public override void Update()
{
RaycastHit2D forwardHitInfo = Physics2D.Raycast(enemy.transform.position, enemy.moveDir, enemy.searchDistance, 1 << 7);
if (forwardHitInfo.collider != null)
{
if (forwardHitInfo.distance < enemy.hideDistance)
{
enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Hide]);
}
else if (enemy.stateMachine.curState != enemy.stateMachine.stateDic[EState.Attack])
{
enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Attack]);
}
}
if(fireDelay>0)
{
fireDelay -= Time.deltaTime;
}
}
public override void Exit()
{
}
}
//적 아이들
public class Enemy_Idle : EnemyState
{
private float randomTimer;
public Enemy_Idle(Enemy enemy) : base(enemy)
{
HasPhysics = false;
}
public override void Enter()
{
enemy.animator.Play(enemy.IDLE_HASH,0,0);
randomTimer = Random.Range(1, 5);
// 속도 초기화
enemy.rig.velocity = Vector2.zero;
}
public override void Update()
{
base.Update();
if (randomTimer <= 0)
{
enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Move]);
}
else
{
randomTimer -= Time.deltaTime;
}
}
public override void Exit()
{
}
}
//적 이동
public class Enemy_Move : EnemyState
{
// 여기서만 적이 회전한다. flipX 및 그와 관련 로직을 여기서 구현
public Enemy_Move(Enemy enemy) : base(enemy)
{
HasPhysics = true;
}
public override void Enter()
{
enemy.animator.Play(enemy.MOVE_HASH);
}
public override void Update()
{
base.Update();
Vector2 rayOrigin = enemy.transform.position + new Vector3(enemy.moveDir.x * 0.5f, 0);
RaycastHit2D downHitInfo = Physics2D.Raycast(rayOrigin, Vector2.down, 1f, 1 << 6);
if (downHitInfo.collider == null)
{
enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Idle]);
enemy.spriteRenderer.flipX = !enemy.spriteRenderer.flipX;
enemy.moveDir *= -1;
}
}
public override void FixedUpdate()
{
enemy.rig.velocity = new Vector2(enemy.moveSpeed * enemy.moveDir.x, 0);
}
}
// 적 숨기
public class Enemy_Hide : EnemyState
{
public Enemy_Hide(Enemy enemy) : base(enemy) { }
public override void Enter()
{
enemy.animator.Play(enemy.HATD_HASH);
}
public override void Update()
{
RaycastHit2D forwardHitInfo = Physics2D.Raycast(enemy.transform.position, enemy.moveDir, enemy.searchDistance, 1 << 7);
if (forwardHitInfo.collider != null)
{
if (forwardHitInfo.distance < enemy.hideDistance)
{
enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Hide]);
}
else
{
enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Attack]);
}
}
else
{
enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Idle]);
}
}
public override void Exit() { }
}
//적 공격
public class Enemy_Attack : EnemyState
{
public Enemy_Attack(Enemy enemy) : base(enemy) { }
public override void Enter()
{
if (fireDelay <= 0)
{
enemy.animator.Play(enemy.ATK_HASH);
Rigidbody2D bullet = MonoBehaviour.Instantiate(enemy.bulletPrefab, enemy.transform.position, Quaternion.identity).GetComponent<Rigidbody2D>();
bullet.velocity = enemy.moveDir * enemy.fireSpeed;
fireDelay = enemy.fireDelay;
}
}
public override void Update()
{
// 쿨타임마다 공격
if (fireDelay > 0)
{
fireDelay -= Time.deltaTime;
}
else
{
enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Idle]);
}
// 전방 레이캐스트
RaycastHit2D forwardHitInfo = Physics2D.Raycast(enemy.transform.position, enemy.moveDir, enemy.searchDistance, 1 << 7);
if (forwardHitInfo.collider != null)
{
if (forwardHitInfo.distance < enemy.hideDistance)
{
enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Hide]);
}
else
{
enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Attack]);
}
}
else
{
enemy.stateMachine.ChangeState(enemy.stateMachine.stateDic[EState.Idle]);
}
}
public override void Exit()
{
}
}