본문 바로가기

무제_LR

플레이어에 FSM 적용에 FSM 적용

플레이어에게 FSM을 적용했습니다.

State 없이 될 줄 알았는데 어쩌다보니 FSM을 적용하는게 낫겠더라구요.

 

또한 IPlayerPresenter의 로직 부분을 분리했습니다.

 

public interface IPlayerPresenter: IPlayerMoveController, IStageObjectController, IPlayerHPController, IPlayerReactionController, IPlayerStateController

기존 플레이어 Presenter에 붙어있던 인터페이스들의 역할은 내부 로직 구현보단
외부 Port 연결용이나 다름없었으니 붙은 인터페이스들은 그대로 유지했습니다.

 

public class BasePlayerPresenter : IPlayerPresenter
{
  private BasePlayerView view;
  private PlayerModel model;

  private BasePlayerHPController hpController;
  private BasePlayerMoveController moveController;
  private PlayerStateController stateController;

  private IDisposable viewFixedUpdate;

  public BasePlayerPresenter(PlayerType playerType, BasePlayerView view, PlayerModel model)
  {
    this.view = view;
    this.model = model;

    view.SetWorldPosition(model.beginPosition);    

    hpController = new BasePlayerHPController(playerType, model);
    moveController = new BasePlayerMoveController(view, model);

    stateController = new PlayerStateController();
    stateController.AddState(PlayerStateType.Idle, new PlayerIdleState(moveController,this));
    stateController.AddState(PlayerStateType.Move, new PlayerMoveState(this,moveController));
    stateController.AddState(PlayerStateType.Bounced, new PlayerBouncedState());

    stateController.ChangeState(PlayerStateType.Idle);

    viewFixedUpdate = view
      .FixedUpdateAsObservable()
      .Subscribe(_ => stateController.FixedUpdate());
    view
      .OnDestroyAsObservable()
      .Subscribe(_ => viewFixedUpdate.Dispose());
  }

  #region IStageObjectController
  public void Enable(bool enable)
  {
    EnableAllInputActions(enable);
  }

  public void Restart()
  {
    EnableAllInputActions(true);

    view.SetWorldPosition(model.beginPosition);
    SetHP(model.maxHP);
  }
  #endregion

  #region IPlayerMoveController
  public void CreateMoveInputAction(Dictionary<string, Direction> pathDirectionPairs)
    =>moveController.CreateMoveInputAction(pathDirectionPairs);

  public void CreateMoveInputAction(string path, Direction direction)
    =>moveController.CreateMoveInputAction(path, direction);  

  public void EnableInputAction(Direction direction, bool enable)
    =>moveController.EnableInputAction(direction, enable);

  public void EnableAllInputActions(bool enable)
    =>moveController.EnableAllInputActions(enable);

  public void SubscribeOnPerformed(UnityAction<Direction> performed)
    => moveController.SubscribeOnPerformed(performed);

  public void SubscribeOnCanceled(UnityAction<Direction> canceled)
    => moveController.SubscribeOnCanceled(canceled);

  public void UnsubscribePerfoemd(UnityAction<Direction> performed)
    => moveController.UnsubscribePerfoemd(performed);

  public void UnsubscribeCanceled(UnityAction<Direction> canceled)
    => moveController.UnsubscribeCanceled(canceled);

  public void ApplyMoveAcceleration()
    => moveController.ApplyMoveAcceleration();

  public void ApplyMoveDeceleration()
    => moveController.ApplyMoveDeceleration();
  #endregion

  #region IPlayerHPController
  public void SetHP(int value)
    =>hpController.SetHP(value);

  public void DamageHP(int damage)
    =>hpController.DamageHP(damage);

  public void RestoreHP(int value)
    =>hpController.RestoreHP(value);

  public void SubscribeOnHPChanged(UnityAction<int> onHPChanged)
    =>hpController.SubscribeOnHPChanged(onHPChanged);

  public void UnsubscribeOnHPChanged(UnityAction<int> onHPChanged)
    => hpController.UnsubscribeOnHPChanged(onHPChanged);
  #endregion

  #region IPlayerReactionController
  public void Bounce(BounceData data, Vector3 direction)
  {
    //아직 미구현
    throw new System.NotImplementedException();
  }
  #endregion

  #region IStateController
  public void ChangeState(PlayerStateType playerState)
    => stateController.ChangeState(playerState);  
  #endregion
}

약간 길긴 하지만, IPlayerPresenter의 대부분 Interface 구현 부분들이 실제 로직 구현 클래스에 위임된 것을 보실 수 있습니다.

여기서 로직 구현자 부분들을 전부 보여주는건 FUN COOL SEXY하지 않으니 스킵

 

그러고 보니 지금까지 Rigidbody 관련 로직들을 PlayerView에서 연산하고 있길래 그 부분까지 Presenter로 다시 올려줬습니다.

    viewFixedUpdate = view
      .FixedUpdateAsObservable()
      .Subscribe(_ => stateController.FixedUpdate());
    view
      .OnDestroyAsObservable()
      .Subscribe(_ => viewFixedUpdate.Dispose());

이 부분이 그 잔재입니다.

기존 BasePlayerView의 FixedUpdate에서 연산하던 것을 각 State에 위임했습니다.

PlayerPresenter 및 PlayerState들은 MonoBehaviour가 아니니 UniRx의 FixedUpdateAsObservable을 사용해 실제 FixedUpdate 연산 주기에 맞춰줬고, view가 Destroy될 때 dispose되도록 했습니다.

 

public interface IPlayerState
{
  public void OnEnter();

  public void OnExit();

  public void FixedUpdate();
}

public class PlayerStateController
{
  private Dictionary<PlayerStateType, IPlayerState> states = new();
  private IPlayerState currentState = null;

  public void AddState(PlayerStateType type, IPlayerState state)
    => states[type] = state;

  public void RemoveState(PlayerStateType type, IPlayerState state)
  {
    if(states.ContainsKey(type))
      states.Remove(type);
  }

  public void ChangeState(PlayerStateType type)
  {
    currentState?.OnExit();
    currentState = states[type];
    currentState.OnEnter();
  }

  public void FixedUpdate()
  {
    currentState.FixedUpdate();
  }
}

FSM 로직은 다음과 같습니다.

OnEnter/OnExit/Update만 가지고 있는 IPlayerState와

IPlayerState들을 전환시켜주는 PlayerStateController입니다.

전환 신호 -> (이전 State 존재 시) 이전 State OnExit -> 신규 State OnEnter

BasePlayerPresenter에서 PlayerView의 FixedUpdate 주기마다 PlayerStateController의 FixedUdate를 호출해줍니다.

 

MVP 원칙상 View에서는 로직을 연산하면 안 되니 Presenter(의 StateController[의 State의 {FixedUpdate}])가 각 State에 걸맞는 FixedUpdate 연산을 해줍니다.

 

현재 IdleState와 MoveState를 분리했습니다.

public class PlayerIdleState : IPlayerState
{
  private readonly IPlayerMoveController moveController;
  private readonly IPlayerStateController playerStateController;


  public PlayerIdleState(IPlayerMoveController moveController, IPlayerStateController stateController)
  {
    this.moveController = moveController;
    this.playerStateController = stateController;
  }

  public void FixedUpdate()
  {
    moveController.ApplyMoveDeceleration();	//0.0f까지 감속
  }

  public void OnEnter()
  {
    moveController.SubscribeOnPerformed(OnMovePerformed); //입력 감지 시 Move로 이동
  }

  public void OnExit()
  {
    moveController.UnsubscribePerfoemd(OnMovePerformed);  //OnEnter에서 구독했던 것 원상복귀
  }

  private void OnMovePerformed(Direction direction)
  {
    playerStateController.ChangeState(PlayerStateType.Move);
  }
}

IdleState: 아무 입력도 없는 상태입니다.

FixedUpdate에서 이동 연산 담당인 moveController의 ApplyMoveDeceleration을 호출하는데, 물리 속도에서 감속 연산입니다. 아무 입력도 없으니 속도가 0.0까지 돌아가야겠죠.

OnEnter 시기에 moveController에서 아무 InputAction의 Performed가 입력되었을 때 MoveState로 ChangeState되도록 구독합니다.

다른 State로 변경될 시(OnExit) 기존 구독은 제대로 수습해줍니다.

 

public class PlayerMoveState : IPlayerState
{
  private readonly IPlayerStateController stateController;
  private readonly IPlayerMoveController moveController;


  public PlayerMoveState(
    IPlayerStateController stateController, 
    IPlayerMoveController moveController)
  {
    this.stateController = stateController;
    this.moveController = moveController;
  }

  public void FixedUpdate()
  {
    moveController.ApplyMoveAcceleration();//이동 입력중이니 속도 연산 적용
  }

  public void OnEnter()
  {
    moveController.SubscribeOnCanceled(OnMoveCanceled);//이동 없으면 Idle로 복귀
  }

  public void OnExit()
  {
    moveController.UnsubscribeCanceled(OnMoveCanceled);//저질렀던 것 수습
  }

  private void OnMoveCanceled(Direction direction)
  {
    stateController.ChangeState(PlayerStateType.Idle);
  }
}

MoveState: 

FixedUpdate에서 이동 연산 담당인 moveController의 ApplyMoveAcceleration을 호출해 플레이어가 제대로 이동 연산이 되도록 합니다.

OnEnter에서 InputActionCanceled에 Idle로 돌아가기 이벤트를 구독,

OnExit에서 해당 구독을 해제하도록 해줍니다.

 

원래는 트리거 오브젝트에 닿을 시 반대 방향으로 통통 튀는 그런 시스템을 만들어보려다가 좀 더 생각해보니 FSM도 만들어보면 나쁘지 않을 것 같아 작업이 이렇게 틀어졌습니다.

암튼 다음 목표: BounceState 추가