상태패턴 시연 영상
FSM이란?
상태패턴?
Behavior Tree란?
구현 난이도 : FSM < 상태패턴 < BT
구조 복잡할수록 효율 : FSM < 상태패턴 < BT
싱글톤, 오브젝트 풀만큼 자주 쓰이기 때문에 잘 알아둬야 한다고 한다.
FSM도 enum상태로 다른 로직을 수행한다는 점에서 비슷하지만,
FSM은 switch문이나 if-else문을 길게 늘여놓기 때문에 코드 가독성과 유지보수성이 매우 떨어진다.
그렇기때문에 각각 상태마다 Class를 만드는 상태패턴을 사용
EnemyStateController 와 같이 State 상태를 관리할 클래스를 하나 만든다.EnemyStateController 에서 상태를 판단해서 변경하는 방식이 좋아보임. 각자 상태들도 판단해서 다른 상태로 변환해도 된다.아래 코드들은 코드 1, 2는 사실 이해를 위해 남겨둠. 추후 상태패턴에 익숙해지면 1,2번은 지우자
//오직 “Idle → Move” 두 상태만 가진 가장 단순한 상태패턴
//IState.cs파일
public interface IState
{
void Enter();
void Update();
void Exit();
}
//IdleState.cs파일
public class IdleState : IState
{
public void Enter() => Debug.Log("Idle 시작");
public void Update() => Debug.Log("가만히 있는중…");
public void Exit() => Debug.Log("Idle 종료");
}
//MoveState.cs파일
public class MoveState : IState
{
public void Enter() => Debug.Log("Move 시작");
public void Update() => Debug.Log("움직이는중…");
public void Exit() => Debug.Log("Move 종료");
}
//StateMachine.cs파일
public class StateMachine
{
private IState _state;
public void ChangeState(IState newState)
{
_state?.Exit();
_state = newState;
_state.Enter();
}
public void Update() => _state?.Update();
}
//Test.cs파일
public class Test : MonoBehaviour
{
StateMachine fsm = new StateMachine();
IdleState idle = new IdleState();
MoveState move = new MoveState();
void Start() => fsm.ChangeState(idle);
void Update()
{
fsm.Update();
if (Input.GetKeyDown(KeyCode.Space))
fsm.ChangeState(move);
}
}
코드1에서 살짝 실제 사례 섞은 것
1. 플레이어 컨트롤러 코드
[RequireComponent(typeof(Rigidbody))]
public class PlayerMovement : MonoBehaviour
{
[Header("Movement")]
public float moveSpeed = 5f;
public float jumpForce = 6f;
public Vector2 MoveInput { get; private set; }
public bool JumpPressed { get; set; }
private Rigidbody rb;
private IPlayerState currentState;
void Start()
{
rb = GetComponent<Rigidbody>();
// 초기 상태를 new로 생성
ChangeState(new IdleState(this));
// 입력 연결
InputSystem.actions["Move"].performed += ctx => MoveInput = ctx.ReadValue<Vector2>();
InputSystem.actions["Move"].canceled += ctx => MoveInput = Vector2.zero;
InputSystem.actions["Jump"].started += ctx => JumpPressed = true;
}
void Update()
{
currentState?.Update();
}
public void ChangeState(IPlayerState newState)
{
currentState?.Exit();
currentState = newState;
currentState.Enter();
}
// 실제 이동/점프 로직
public void Move()
{
Vector3 dir = new Vector3(MoveInput.x, 0f, MoveInput.y);
transform.position += dir * moveSpeed * Time.deltaTime;
}
public void Jump()
{
rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
JumpPressed = false;
}
}
2.인터페이스 + 상태 코드
public interface IPlayerState
{
void Enter();
void Update();
void Exit();
}
public class IdleState : IPlayerState
{
private PlayerMovement player;
public IdleState(PlayerMovement player)
{
this.player = player;
}
public void Enter() { }
public void Update()
{
if (player.JumpPressed)
{
player.ChangeState(new JumpState(player));
}
else if (player.MoveInput != Vector2.zero)
{
player.ChangeState(new MoveState(player));
}
}
public void Exit() { }
}
public class MoveState : IPlayerState
{
private PlayerMovement player;
public MoveState(PlayerMovement player)
{
this.player = player;
}
public void Enter() { }
public void Update()
{
player.Move();
if (player.MoveInput == Vector2.zero)
{
player.ChangeState(new IdleState(player));
}
else if (player.JumpPressed)
{
player.ChangeState(new JumpState(player));
}
//걷는 로직으로 변하는 내용이 있어야 함
}
public void Exit() { }
}
public class JumpState : IPlayerState
{
private PlayerMovement player;
public JumpState(PlayerMovement player)
{
this.player = player;
}
public void Enter()
{
player.Jump();
}
public void Update()
{
// Jump 후 바로 Idle로
player.ChangeState(new IdleState(player));
}
public void Exit() { }
}
상태마다 클래스를 지정, 상태에 따라서 다른 상태를 지정해서 계속 진행한다.
아래 코드는 상태끼리 서로 상태를 변경시키는데 EnemyStatePattern 에서 상태 변경시켜도 상관없음
public interface IEnemyState
{
void Enter();
void Exit();
void Update();
}
public class EnemyStatePattern : MonoBehaviour
{
//따라갈 플레이어 위치 지정
[SerializeField] private Transform _player;
//몬스터가 지속적으로 순찰할 위치 지정
[SerializeField] private Transform[] _patrolPoints;
//몬스터 감지거리, 이동속도
[SerializeField] private float _detectRange = 5f;
[SerializeField] private float _moveSpeed = 2f;
//현재 상태
private IEnemyState _currentState;
//상태들에서 접근할수있게 프로퍼티 생성(읽기전용)
public Transform[] PatrolPoints => _patrolPoints;
public float MoveSpeed => _moveSpeed;
public float DetectRange => _detectRange;
public Transform PlayerTrf => _player;
//초기 상태 지정 -> 순찰
private void Start()
{
SetState(new PatrolState(this));
}
//상태들마다 다른 기본 행동을 수행한다
private void Update()
{
_currentState.Update();
}
/*
//만약 new로 하는게 너무 메모리낭비같다. 하면 여기에 모든 패턴을 정의해두고 사용하면됨.
ChaseState chaseState;
PatrolState patrolState;
private void Start()
{
_currentState = new ChaseState(this);
}
*/
//상태 지정
public void SetState(IEnemyState newState)
{
_currentState?.Exit();
_currentState = newState;
_currentState.Enter();
}
}
public class PatrolState : IEnemyState
{
private EnemyStatePattern _enemy;
private int _currentPatrolIndex = 0;
private Transform[] _patrolPoints;
private float _moveSpeed;
private float _detectRange;
private Transform _player;
public PatrolState(EnemyStatePattern enemy)
{
_enemy = enemy;
_patrolPoints = _enemy.PatrolPoints;
_moveSpeed = _enemy.MoveSpeed;
_player = _enemy.PlayerTrf;
_detectRange = _enemy.DetectRange;
}
public void Enter()
{
}
public void Exit()
{
}
public void Update()
{
if (_patrolPoints.Length == 0) return;
Transform target = _patrolPoints[_currentPatrolIndex];
_enemy.transform.position = Vector3.MoveTowards(_enemy.transform.position,
target.position, _moveSpeed * Time.deltaTime);
if (Vector2.Distance(_enemy.transform.position, target.position) < 0.1f)
{
_currentPatrolIndex = (_currentPatrolIndex + 1) % _patrolPoints.Length;
}
//플레이어 감지 및 상태 변경
if(Vector2.Distance(_enemy.transform.position,_player.position) <= _detectRange)
{
_enemy.SetState(new ChaseState(_enemy)); //new가 불편하면?? ~~하면된다
}
}
}
public class ChaseState : IEnemyState
{
private EnemyStatePattern _enemy;
private Transform _player;
private float _moveSpeed;
private float _detectRange;
public ChaseState(EnemyStatePattern enemy)
{
_enemy = enemy;
_player = enemy.PlayerTrf;
_moveSpeed = enemy.MoveSpeed;
_detectRange = enemy.DetectRange;
}
public void Enter()
{
}
public void Exit()
{
}
public void Update()
{
Vector3 direction = (_player.position - _enemy.transform.position).normalized;
Vector3 nextPos = new Vector3(_enemy.transform.position.x + direction.x * _moveSpeed * Time.deltaTime,
_enemy.transform.position.y);
_enemy.transform.position = nextPos;
if (Vector2.Distance(_enemy.transform.position, _player.position) > _detectRange)
{
_enemy.SetState(new PatrolState(_enemy));
}
}
}
hierachy state Machie이라는 명칭으로 불리는 듯. (자주 들어보지는 못했다)
만약에 이동+공격, 이동+점프+공격, 이동+점프 이런 다양한 상태들이 동시에 수행되어야하는경우
일반적인 FSM이나 상태패턴으로는 불가능
아래처럼 상태를 따로따로 나눠준다 병렬적으로
아래 코드가 상당히 길다...
개선점으로는 1. 점프 그라운드 체크 버그있어서 점프 반복됨. 일단 상태패턴만 공부하자
2. 상태들에 Class를 보면 생성자부분이랑 PlayerStateController 가 필드로 있다는 사실이 겹친다. 이거를 추상 클래스로 바꿔볼 수 있을것
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static UnityEngine.RuleTile.TilingRuleOutput;
public interface IPlayerMoveState
{
void Enter();
void Exit();
void Update();
}
public interface IPlayerJumpState
{
void Enter();
void Exit();
void Update();
}
//이동 초기화 상태
public class IdleStatePlayer : IPlayerMoveState
{
PlayerStateController playerStateController;
public IdleStatePlayer(PlayerStateController playerState)
{
playerStateController = playerState;
Debug.Log("Idle상태 진입");
}
public void Enter(){}
public void Exit(){}
public void Update()
{
if(playerStateController.MoveX != 0)
{
playerStateController.SetStateMove(new RunStatePlayer(playerStateController));
}
}
}
//달리기 상태
public class RunStatePlayer : IPlayerMoveState
{
PlayerStateController playerStateController;
public RunStatePlayer(PlayerStateController state)
{
playerStateController = state;
Debug.Log("Run상태 진입");
}
public void Enter(){}
public void Exit(){}
public void Update()
{
Vector3 movePos = new Vector3(playerStateController.MoveX, 0, 0) * Time.deltaTime;
playerStateController.MovePlayer(movePos);
if(playerStateController.MoveX == 0 )
{
playerStateController.SetStateMove(new IdleStatePlayer(playerStateController));
}
}
}
public class GroundedStatePlayer : IPlayerJumpState
{
PlayerStateController playerStateController;
public GroundedStatePlayer(PlayerStateController state)
{
playerStateController = state;
}
public void Enter(){ Debug.Log("그라운드상태로 진행"); }
public void Exit(){}
public void Update()
{
if(playerStateController.JumpPressed)
{
playerStateController.SetStateJump(new JumpStatePlayer(playerStateController));
}
}
}
//점프 상태
public class JumpStatePlayer : IPlayerJumpState
{
PlayerStateController playerStateController;
public JumpStatePlayer( PlayerStateController state)
{
playerStateController = state;
}
public void Enter()
{
Debug.Log("점프 상태로 진행");
Rigidbody2D rigidbody2D = playerStateController.GetComponent<Rigidbody2D>();
rigidbody2D.velocity = new Vector2(0, 7);
}
public void Exit(){}
public void Update()
{
//원래는 레이캐스트로 바닥을 체크하거나
//캐릭터 발밑에 Collider두어서 바닥 Wall이랑 충돌했는지 체크해서 바닥 체크해야하지만. 편의상 y좌표로 계산
if(playerStateController.IsGrounded)
{
//현재는 그라운드 체크가 이상하게 되서 점프가 계속되긴하는데.. 중요한건 상태가 변한다는것
playerStateController.SetStateJump(new GroundedStatePlayer(playerStateController));
}
}
}
public class PlayerStateController : MonoBehaviour
{
IPlayerMoveState _playerMoveState;
IPlayerJumpState _playerJumpState;
//입력처리를 받아서 전역으로 쓰기위해서
public float MoveX { get; private set; }
//public bool JumpPressed => Input.GetKeyDown(KeyCode.Space); //Updatd에서 안해주고 이렇게 해줘도 되긴하는듯
public bool JumpPressed { get; private set; }
public bool IsGrounded { get; private set; }
private void Start()
{
_playerMoveState = new IdleStatePlayer(this);
_playerJumpState = new JumpStatePlayer(this);
}
void Update()
{
//입력 체크
MoveX = Input.GetAxisRaw("Horizontal"); //이동체크
JumpPressed = Input.GetKeyDown(KeyCode.Space); //점프체크
IsGrounded = Physics2D.Raycast(transform.position, Vector2.down, 0.1f); //바닥체크
_playerMoveState.Update();
_playerJumpState.Update();
}
//아래 SetState를 상태별로 public하는거 개선 가능할듯(인터페이스에서 정의하거나... 추상으로 바꿔서 하거나)
public void SetStateMove(IPlayerMoveState state)
{
state?.Exit();
_playerMoveState = state;
state.Enter();
}
public void SetStateJump(IPlayerJumpState state)
{
state?.Exit();
_playerJumpState = state;
state.Enter();
}
public void MovePlayer(Vector3 moveDir)
{
transform.position += moveDir;
}
}
(코드는 아닌데 예시)
코드4에서 이동,걷기,점프,공격 이런 상태들만 있었다면
그냥 모션과 액션을 나누는 방법이 쉬운 해결책이다
이동과 걷기는 상태가 변화하는 것이므로 똑같이 상태패턴으로 클래스 지정
점프, 공격은 1회성 동작(액션)이므로 Controller에서 Update문 Input을 확인해서 그냥 처리해준다
코드는 비슷하지만 상태를 어디에서 전환해주는지가 다르다
https://gameprogrammingpatterns.com/state.html
이미 알고있는 내용들이긴한데 가끔 보면 좋을것같아 추가