FSM 패턴
- Fineite State machine : 유한 상태 기계
- 객체의 상태에 따라 모션, 애니메이션 등을 달리 할 때에 사용
- enum /interface/ai behavior tree / hfsm / playmaker fsm(only unity asset) 등 다양한 방식이 있음
enum 방식으로 switch case로 상태를 검사하고 애니메이션을 추가함.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
public enum CharacterFSMState
{
Idle,
Walk,
Jump
}
public class CharacterFSM : MonoBehaviour
{
private static readonly int Speed_Hash = Animator.StringToHash("Speed");
private CharacterFSMState currentState = CharacterFSMState.Idle;
private CharacterFSMState prevState = CharacterFSMState.Idle;
private Rigidbody rb;
[SerializeField] private float moveSpeed = 3.0f;
[SerializeField] private float jumpForce = 10.0f;
private bool isGrounded;
private Vector2 moveInput;
private InputAction moveAction;
private InputAction jumpAction;
private Animator animator;
void Start()
{
rb = GetComponent<Rigidbody>();
animator = GetComponent<Animator>();
currentState = CharacterFSMState.Idle;
moveAction = GetComponent<PlayerInput>().actions["Move"];
jumpAction = GetComponent<PlayerInput>().actions["Jump"];
}
// Update is called once per frame
void Update()
{
// 움직임 값 확인
moveInput = moveAction.ReadValue<Vector2>();
bool bPressedJump = jumpAction.triggered;
GroundCheck();
StateChange(bPressedJump);
EnterState();
UpdateState();
ExitState();
}
private void GroundCheck()
{
// 점프 상태 확인
isGrounded = rb.velocity.y == 0.0f;
}
private void StateChange(bool bPressedJump)
{
prevState = currentState;
switch (currentState)
{
case CharacterFSMState.Idle:
{
if (moveInput.sqrMagnitude > 0.0f)
{
currentState = CharacterFSMState.Walk;
}
if (bPressedJump && isGrounded)
{
currentState = CharacterFSMState.Jump;
}
}
break;
case CharacterFSMState.Walk:
{
if (moveInput.sqrMagnitude <= 0.0f)
{
currentState = CharacterFSMState.Idle;
}
if (bPressedJump && isGrounded)
{
currentState = CharacterFSMState.Jump;
}
}
break;
case CharacterFSMState.Jump:
{
if (isGrounded)
{
currentState = CharacterFSMState.Idle;
}
}
break;
}
}
private void EnterState()
{
if (prevState != currentState)
{
switch (currentState)
{
case CharacterFSMState.Idle:
{
animator.SetFloat(Speed_Hash, 0.0f);
}
break;
case CharacterFSMState.Walk:
{
animator.SetFloat(Speed_Hash, 1.0f);
}
break;
case CharacterFSMState.Jump:
{
animator.CrossFade("Jump", 0.1f);
rb.velocity = new Vector3(rb.velocity.x, jumpForce, rb.velocity.z);
}
break;
}
}
}
private void ExitState()
{
if (prevState != currentState)
{
switch (prevState)
{
case CharacterFSMState.Jump:
{
animator.CrossFade("Idles", 0.1f);
}
break;
}
}
}
private void UpdateState()
{
switch (currentState)
{
case CharacterFSMState.Walk:
{
rb.velocity = new Vector3(moveInput.x * moveSpeed, rb.velocity.y, moveInput.y * moveSpeed);
}
break;
case CharacterFSMState.Jump:
{
}
break;
}
}
}
moveInput에 따라 상태를 제어하고, 상태에 따라 다른 애니메이션을 플레이하는 방식 enum을 사용한 것이다.
다만 이 경우, 상태가 계속해서 늘어나면 관리가 너무 힘들어진다.
using System.Collections;
using UnityEngine;
public class MyCharacterFsm_I : MonoBehaviour
{
private StateMachine stateMachine;
// Start is called before the first frame update
void Start()
{
stateMachine = GetComponent<StateMachine>();
stateMachine.Run();
}
// Update is called once per frame
void Update()
{
stateMachine.UpdateState();
}
}
using System;
using System.Collections.Generic;
using UnityEngine;
public class StateMachine : MonoBehaviour
{
[SerializeField] private IState defaultState;
private IState currentState;
private Dictionary<Type, IState> states = new Dictionary<Type, IState>();
public void Run()
{
IState[] states = GetComponents<IState>();
foreach (var state in states)
{
AddState(state);
}
ChangeState(defaultState.GetType());
}
public void AddState(IState state)
{
state.Fsm = this;
states.Add(typeof(IState), state);
}
public void ChangeState<T>() where T : IState
{
ChangeState(typeof(T));
}
private void ChangeState(Type stateType)
{
currentState?.Exit();
if (!states.TryGetValue(stateType, out currentState)) return;
currentState?.Enter();
}
public void UpdateState()
{
if (currentState != null)
currentState.UpdateState(Time.deltaTime);
}
}
using UnityEngine;
public class WalkState : MonoBehaviour, IState
{
public StateMachine Fsm { get; set; }
public void InitState()
{
}
public void Enter()
{
}
public void UpdateState(float deltaTime)
{
}
public void Exit()
{
}
}
using UnityEngine;
public class WalkState : MonoBehaviour, IState
{
public StateMachine Fsm { get; set; }
public void InitState()
{
}
public void Enter()
{
}
public void UpdateState(float deltaTime)
{
}
public void Exit()
{
}
}
using UnityEngine;
public class IdleState : MonoBehaviour, IState
{
public StateMachine Fsm { get; set; }
public void InitState()
{
}
public void Enter()
{
}
public void UpdateState(float deltaTime)
{
}
public void Exit()
{
}
}
public interface IState
{
StateMachine Fsm { get; set; }
void InitState();
void Enter();
void UpdateState(float deltaTime);
void Exit();
}
길어 보이지만 여러개의 클래스를 하나로 합쳐 놓은 것이다.
* 밑 처럼 바꿔주어야 함.
상태 클래스를 여러개 두고, 그 클래스에서 Enter(), UpdateState(), Exit()를 강제로 구현하게 만들어 놓음.
이 줄이 이해가 가지 않아서 GPT 강사님과 대화했다.
TryGetValue 함수라는 것이 정확히 뭔지 몰라서, 그리고 해석의 순서가 잘못되었기 때문에 발생했던 문제였다
!를 붙이지 않았다고 생각해보면, states.TryGetValue로 states 배열 안에 stateType이 존재한다면 currentState를
바꿔준다는 로직이다.
그리고 if문 안에 있기에 stateType이 존재하면 반환을 하는 것인데 우리는 반환을 하면 안되고 밑의
currentState?.Enter()함수로 들어가야한다. 그렇기 때문에 !을 붙여 주어야 한다.
이렇게 만들고 나면
using UnityEngine;
public class JumpState : MonoBehaviour, IState
{
public StateMachine Fsm { get; set; }
public Blackboard_Default Blackboard { get; set; }
public void InitState(IBlackboardBase blackboard)
{
Blackboard = blackboard as Blackboard_Default;
}
public void Enter()
{
Blackboard.animator.CrossFade("Jump", 0.1f);
Blackboard.rigidbody.velocity = new Vector3(Blackboard.rigidbody.velocity.x, Blackboard.JumpForce, Blackboard.rigidbody.velocity.z);
}
public void UpdateState(float deltaTime)
{
if (Blackboard.rigidbody.velocity.y == 0.0f)
{
Fsm.ChangeState<IdleState>();
}
}
public void Exit()
{
}
}
이렇게 각각의 클래스에 Enter, UpdateState, Exit 함수를 각각 구현해주면 끝 !!
블랙보드 만들기
지금은 모든 변수들을 한 번에 캐싱하지 않고, 상태마다 따로 캐싱함 비효율을 없에기 위해 새로운 클래스를 만들고
거기서 참조할 예정.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
public class Blackboard_Default : MonoBehaviour, IBlackboardBase
{
public float JumpForce = 3f;
public float moveSpeed = 3.0f;
public Animator animator;
public Rigidbody rigidbody;
public InputAction moveInput;
public InputAction jumpInput;
public new void InitBlackboard()
{
animator = GetComponent<Animator>();
rigidbody = GetComponent<Rigidbody>();
moveInput = GetComponent<PlayerInput>().actions["Move"];
jumpInput = GetComponent<PlayerInput>().actions["Jump"];
}
}
필요한 모든 변수들을 모두 한 번에 캐싱해준다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public interface IBlackboardBase
{
void InitBlackboard();
}
상속한 인터페이스인 IBlackboardBase는 확장성을 위한 작업이다.
그리고 StateMachine 에서는
public void Run()
{
IBlackboardBase blackboardDefault = GetComponent<IBlackboardBase>();
blackboardDefault.InitBlackboard();
List<IState> states = this.CreateStates(StaterType.Character);
foreach (var state in states)
{
AddState(state, blackboardDefault);
}
ChangeState(Type.GetType(defaultState));
}
Addstate 과정에서 블랙보드를 참조할 수 있게 추가해준다.
public void AddState(IState state, IBlackboardBase blackboard){
state.Fsm = this;
state.InitState(blackboard);
states.Add(state.GetType(), state);
}
각각 state의 InitState는 다음과 같다.
public class WalkState : MonoBehaviour, IState
{ public StateMachine Fsm { get; set; }
public Blackboard_Default Blackboard { get; set; }
public void InitState(IBlackboardBase blackboard)
{
Blackboard = blackboard as Blackboard_Default;
}
Black보드로 참조하기 끝 ~
스스로 구현해보기
일단 switch Case부터...하는데 문제가 하나 있다
void GroundCheck()
{
//이걸 해결하고 싶음, 그 순간만 velocity가 있으니까 올라가는 순간 내내 빨간색으로 하고 싶은디.
isGrounded = Mathf.Abs(rb.velocity.y) < 0.1f;
}
case state.Jump:
{
if (isGrounded)
{
currentState = state.Move;
}
}
break;
이 코드가 해결되지 않는다.
isGround가 바로 들어가는 것 같다.
디버그해보니 이놈이 문제의 원인이 아니었다.
velocity.y가 변화하는 문제였다.
rb.velocity = new Vector3(rb.velocity.x, jumpForce, rb.velocity.z);
case state.Jump:
{
rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
Debug.Log(rb.velocity.y);
// rb.velocity = new Vector3(rb.velocity.x, jumpForce, rb.velocity.z);
ColorChange();
}
여기서 찍어보니 velocity가 0이 나온다.. 이 다음프레임에서도 0이 나오는 것 같다.
using System.Collections;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Security.Cryptography.X509Certificates;
using UnityEngine;
using UnityEngine.InputSystem;
public enum state
{
Idle,
Move,
Jump
}
public class CapsuleMove_enum : MonoBehaviour
{
public float speed;
public float jumpForce;
public state currentState = state.Idle;
private InputAction Jump;
private InputAction Move;
private Vector2 moveInput;
private Rigidbody rb;
bool isGrounded = false;
public Color color;
private MeshRenderer rend;
private bool bPressed;
void Start()
{
Jump = GetComponent<PlayerInput>().actions["Jump"];
Move = GetComponent<PlayerInput>().actions["Move"];
rb = GetComponent<Rigidbody>();
rend = GetComponent<MeshRenderer>();
}
void Update()
{
moveInput = Move.ReadValue<Vector2>();
bPressed = Jump.triggered;
GroundCheck();
StateChange();
CapsuleMove();
}
void CapsuleMove()
{
switch (currentState)
{
case state.Idle:
break;
case state.Move:
{
rb.velocity = new Vector3(moveInput.x * speed, rb.velocity.y, moveInput.y * speed);
}
break;
case state.Jump:
{
}
break;
}
}
void GroundCheck()
{
//이걸 해결하고 싶음, 그 순간만 velocity가 있으니까 올라가는 순간 내내 빨간색으로 하고 싶은디.
isGrounded = Mathf.Abs(rb.velocity.y) <= 0.001f;
}
public void StateChange()
{
state prevState = currentState;
switch (currentState)
{
case state.Idle:
{
if (moveInput.sqrMagnitude > 0.0f)
{
currentState = state.Move;
}
if (bPressed && isGrounded)
{
currentState = state.Jump;
}
}
break;
case state.Move:
{
if (moveInput.sqrMagnitude <= 0.0f)
{
currentState = state.Idle;
}
if (bPressed && isGrounded)
{
currentState = state.Jump;
}
}
break;
case state.Jump:
{
if (isGrounded)
{
currentState = state.Idle;
}
}
break;
}
if(prevState != currentState)
{
EnterState();
Debug.Log($"전 상태{prevState}, 지금상태{currentState} :{rb.velocity.y}");
}
}
void EnterState()
{
switch(currentState)
{
case state.Idle:
{
ColorChange();
}
break;
case state.Move:
{
ColorChange();
}
break;
case state.Jump:
{
// rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
rb.velocity = new Vector3(rb.velocity.x, jumpForce, rb.velocity.z);
ColorChange();
}
break;
}
}
public void ColorChange()
{
color = Color.white;
switch (currentState)
{
case state.Idle:
{
color = Color.white;
}
break;
case state.Move:
{
color = Color.blue;
}
break;
case state.Jump:
{
color = Color.red;
}
break;
}
rend.material.color = color;
}
}
Addforce로 바꾸고
if(prevState != currentState)
{
EnterState();
Debug.Log($"전 상태{prevState}, 지금상태{currentState} :{rb.velocity.y}");
}
모두 로그를 찍어보니 계속 점프를 시작하자마자 idle - jump - idle 과정이 빠르게 반복되어서 돌아왔었다.
Addforce는 순간적으로 충격을 주는 것인데 다음 프레임에서는 velocity.y가 0이되나보다....
값을 주어서 해결하는 방법도 있겠지만 ! 이라고 생각해서 해 봤다.
해결 ㅎㅎㅎ
그런데 이런 방식으로 만들면 지금 isGrounded를 체크하는 방식이
isGrounded = Mathf.Abs(rb.velocity.y) <= 0.001f;
다음과 같은 방식으로 isGrounded를 체크할텐데, 그럼 공중에서 isGrounded로 바뀌는 경우가 있을 것 같은데
그 예외처리를 하려면 어떻게 해야 할까 ?
스스로 구현해보기 2
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public interface Cstate
{
//보통 baseState를 만들어서 사용함.
//baseState를 두는 이유는 공통적으로 처리해야 하는 것이 있기 때문
//똑같은 코드를 여러번 쓰지 않게 하기 위함 + 탈출 조건이 동일한 경우
// 어쨌든, 중복해서 여러번 쓰기 귀찮기 때문에 하는 일 => 리팩토링을 계속 하자 준수야.
CBlackBoard BlackBoard { get; }
CStateMachine FSM { get; set; }
void InitState(CBlackBoard blackBoard);
void Enter();
void UpdateState();
void Exit();
}
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public enum StaterType
{
None,
Capsule,
Max
}
public class CStateMachine : MonoBehaviour
{
private Cstate currentState;
//불필요하게 중복된 코드
public CStateMachine FSM;
//이거 왜쓴거임 ? 아직 진짜 모르겠음
private Dictionary<Type,Cstate> CStates = new Dictionary<Type, Cstate>();
//private CBlackBoard blackboard;
public void Run()
{
CBlackBoard blackboard = GetComponent<CBlackBoard>();
blackboard.InitBlackBoard();
Cstate[] cstates = this.GetComponents<Cstate>();
foreach (var cstate in cstates)
{
AddCState(cstate, blackboard);
}
ChangeState(typeof(IdleState));
}
public void AddCState(Cstate cstate, CBlackBoard blackboard)
{
cstate.FSM = this;
cstate.InitState(blackboard);
CStates.Add(cstate.GetType(),cstate);
}
public void ChangeState(Type stateType)
{
currentState?.Exit();
if (!CStates.TryGetValue(stateType, out currentState)) return;
currentState.Enter();
}
// 이 친구는 실행할 객체에서 Update에서 호출해 줄 핢수
public void UpdateState()
{
//이 친구는 강제로 구현하게 한(인터페이스로) 각각의 상태 안에서 구현하게 할 함수
currentState?.UpdateState();
Debug.Log(currentState);
}
}
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
public class CBlackBoard : MonoBehaviour
{
public float JumpForce = 3.0f;
public float moveSpeed = 3.0f;
public Rigidbody rb;
public MeshRenderer meshRenderer;
private MeshRenderer rend;
public InputAction moveInput;
public InputAction jumpInput;
public bool isGrounded;
public TMP_Text textMesh;
public void InitBlackBoard()
{
rb = GetComponent<Rigidbody>();
meshRenderer = GetComponent<MeshRenderer>();
moveInput = GetComponent<PlayerInput>().actions["Move"];
jumpInput = GetComponent<PlayerInput>().actions["Jump"];
textMesh = GetComponentInChildren<TMP_Text>();
}
public Color color
{
get => meshRenderer.material.color;
set => meshRenderer.material.color = value;
}
}
using System;
using UnityEngine;
public class WalkState : MonoBehaviour, Cstate
{
public CBlackBoard BlackBoard { get; set; }
public CStateMachine FSM { get; set; }
public void InitState(CBlackBoard blackBoard)
{
this.BlackBoard = blackBoard;
}
public void Enter()
{
// BlackBoard.meshRenderer.material.color = Color.green;
BlackBoard.color = Color.green;
BlackBoard.textMesh.color = Color.green;
BlackBoard.textMesh.text = "Walk";
}
public void UpdateState()
{
var value = BlackBoard.moveInput.ReadValue<Vector2>();
if (BlackBoard.rb.velocity.magnitude < 0.01f)
{
FSM.ChangeState(typeof(IdleState));
}
BlackBoard.rb.velocity = new Vector3(value.x *BlackBoard.moveSpeed,BlackBoard.rb.velocity.y,value.y * BlackBoard.moveSpeed);
if (BlackBoard.jumpInput.triggered)
{
BlackBoard.rb.velocity = new Vector3(BlackBoard.rb.velocity.x,BlackBoard.JumpForce,BlackBoard.rb.velocity.z);
FSM.ChangeState(typeof(JumpState));
}
}
public void Exit()
{
}
}
using System;
using UnityEngine;
public class JumpState : MonoBehaviour, Cstate
{
public CBlackBoard BlackBoard { get; set; }
public CStateMachine FSM { get; set; }
public void InitState(CBlackBoard blackBoard)
{
this.BlackBoard = blackBoard;
}
public void Enter()
{
BlackBoard.color = Color.red;
BlackBoard.textMesh.color = Color.red;
BlackBoard.textMesh.text = "Jump";
}
public void UpdateState()
{
//groundcheck를 한쪽에다 몰아놓고 할 수 있도록,
if (MathF.Abs(BlackBoard.rb.velocity.y) <= 0.001f)
{
FSM.ChangeState(typeof(IdleState));
}
}
public void Exit()
{
}
}
using System.Runtime.Serialization;
using TMPro;
using UnityEngine;
public class IdleState : MonoBehaviour, Cstate
{
public CBlackBoard BlackBoard { get; set; }
public CStateMachine FSM { get; set; }
public void InitState(CBlackBoard blackBoard)
{
this.BlackBoard = blackBoard;
}
public void Enter()
{
// BlackBoard.meshRenderer.material.color = Color.white;
BlackBoard.color = Color.white;
BlackBoard.textMesh.color = Color.white;
BlackBoard.textMesh.text = "Idle";
}
public void UpdateState()
{
var value = BlackBoard.moveInput.ReadValue<Vector2>();
if (value.sqrMagnitude > 0.1f)
{
FSM.ChangeState(typeof(WalkState));
}
if (BlackBoard.jumpInput.triggered)
{
BlackBoard.rb.velocity = new Vector3(BlackBoard.rb.velocity.x,BlackBoard.JumpForce,BlackBoard.rb.velocity.z);
FSM.ChangeState(typeof(JumpState));
}
}
public void Exit()
{
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CapsuleMove_Interface : MonoBehaviour
{
private CStateMachine stateMachine;
void Start()
{
stateMachine = GetComponent<CStateMachine>();
stateMachine.Run();
}
void Update()
{
stateMachine.UpdateState();
}
}
이렇게 놓고 , 캡슐 안에 다 넣으면 !! 실행이 완료된다.
여기서 어려웠던 부분은, color 변화에서 참조 타입과 값 타입을 혼동해 변화시키는걸 구현하는데 오래걸렸다.
총 소요시간은 4시간 정도이고, 여기서 조금 더 발전시켜서 확장성을 늘릴 것이다.
public enum StaterType
{
None,
Capsule,
Max
}
public static class StateFactory
{
public static List<Cstate> CreateStates(this CStateMachine stateMachine, StaterType staterType)
{
List<Cstate> states = new List<Cstate>();
switch (staterType)
{
case StaterType.Capsule:
{
states.Add(stateMachine.AddComponent<IdleState>());
states.Add(stateMachine.AddComponent<WalkState>());
states.Add(stateMachine.AddComponent<JumpState>());
}
break;
}
return states;
}
}
발전 완료 ! 다만 여기서 '확장 메서드'라는 개념 때문에 한 삼십분 골머리를 앓았다.
// 연관되어서 List<Cstate> cstates = this.CreateStates( StaterType.Capsule); 이건 작동을 하는데
// List<Cstate> cstates = StateFactory.CreateStates(this.FSM ,StaterType.Capsule); 이건 작동을 왜 안할까
생각하기로는, this.CreateStates는 자기 자신에 기능을 덧붙이는 거라서 가능하지만
StateFactory.CreateStates(this.FSM, StaterType.Capusle은
states.Add(stateMachine.AddComponent<IdleState>()); 에 들어 갔을 때
FSM에 AddComponent를 하는거라 작동을 안했나보다. FSM은 StateMachin일 뿐 게임오브젝트를 붙일 수 있는 존재가 아니기 때문이라고 생각한다.
그리고 List<Cstate> cstates = this.CreateStates( StaterType.Capsule); 은 this에다가 AddComponent를 해서 작동했나보다. 확장 메서드는 호출 객체를 암묵적으로(this)로 전달받기 때문에 호출한 객체에 새로운 컴포넌트를 추가할 수 있다.
GPT님이 맞다고 하셨다. this.FSM 자체는 MonoBehaviour를 붙일 수 있는 객체가 아니라서 불가능하다.
https://chatgpt.com/share/6776cd6f-6354-800d-845a-e621fc94743b
ChatGPT - 인터페이스 속성 사용법
Shared via ChatGPT
chatgpt.com
확장 메서드에 대해 아직 개념이 명확하게 잡히지 않아서 어려웠나보다.
'TIL' 카테고리의 다른 글
[멋쟁이사자처럼 부트캠프 TIL회고] 45일차 : 옵저버 패턴 + 리팩토링 (1) | 2025.01.05 |
---|---|
[멋쟁이사자처럼 부트캠프TIL회고] 44일차 : 옵저버 패턴 +α (1) | 2025.01.04 |
[멋쟁이사자처럼 부트캠프TIL회고] 42일차 : 테트리스 기능 추가 (0) | 2025.01.01 |
[멋쟁이사자처럼 부트캠프 TIL회고] 41일차 : 테트리스 (1) | 2025.01.01 |
[멋쟁이사자처럼 부트캠프 TIL회고] 40일차 : 테트리스 (1) | 2024.12.31 |