플레이어에게 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 추가
'무제_LR' 카테고리의 다른 글
| UI Indicator 만들기 만들기(Selctable, Navigation) (0) | 2025.11.26 |
|---|---|
| 피격 후 무적 시간 적용 (0) | 2025.11.22 |
| 간단한 연출용 UI(Animator 사용) (0) | 2025.11.18 |
| ScriptableObject는 항상 동일한 인스턴스가 아니다?!?!?!?!?!?!?!?!?!?!?!? (0) | 2025.11.13 |
| ScriptableObject로 전역 이벤트 써먹기 (0) | 2025.11.12 |