[Unity] (2024_09_25) 2D 플랫 포머 게임 개발일지 FSM Part 1

2024. 9. 26. 00:01·Unity

깃헙 링크(에셋 저작권 이슈 때문에 Script만 따로 빼옴) : https://github.com/ljs1206/NKProject-Script-

 

오늘 한거

FSM 기반으로 플레이어 구현과 애니메이션 구현!

FSM 관련된 내용들을 BT와 함께 노션에 자세히 정리해두었으니 궁금하면 봐주시면 감사하겠습니다.

[https://icy-beet-2d1.notion.site/7d4c82064b8b435a8312c39327d4850f]

FSM?

상태 기반

상태 기반이란 모든 행동들을 State로 나누어서 그 State마다 조건을 통해서 다른 State로 이동하는 Transition을 가지는 구조를 말한다. 이렇게 말하면 이해가 안되니까 그림을 통해서 설명하겠다.

다음 그림은 간단하게 FSM을 나타내는 그림이다. 인터넷에 검색하면 보통 저런식의 구조를 많이 봤을텐데 여튼 딱봐도 알수 있듯 Idle, Chase, Attack 3개의 State를 특정 조건의 의해서 정해지고 있는 모습이다. 

예를 들어

더 간단하게 2개의 State만 보겠다.

Idle To Chase = 추적범위를 벗어났는가? 라는 Transition을 가지고 있다. 따라서 저 조건을 만족한다면?? Idle에서 Chase로 State가 변환되게 되는 것이다.

반대로 Chase To Idle = 추적범위 내로 들어왔는가?라는 Transition을 가지고 있고 저 조건을 완료한다면 Chase에서 Idle로 State가 변환되게 되는 것이다.

정말 쉽다!

그럼 유니티에 적용해보자

상속 구조를 통해 우리는 FSM 기반 오브젝트를 만들어볼 것이다. 

구조는 다음과 같이 만들었다. 다만 이 구조에서는  Movement라는 행동이 겹치므로 이 부분을 해결을 해봐야겠다.

 

Script : Agent

 

일단 모든 상태기반 오브젝트의 상위 자식의 부모 스크립트인 Agent를 만든다.

using System;
using System.Collections;
using UnityEngine;

public abstract class Agent : MonoBehaviour
{
    #region component list section
    public Animator AnimatorCompo { get; protected set; }
    public SpriteRenderer SpriteRendererComp { get; protected set; }
    #endregion

    public bool CanStateChangeable { get; protected set; } = true; // 현재 State를 바꿀 수 있나? 를 확인하는 bool변수
    public bool isDead; // 죽었는지 확인 하는 Bool 변수

    protected virtual void Awake()
    {
        Transform visualTrm = transform.Find("Visual"); 
        // 스크립트가 붙는 GameObject에 Animator와 SpriteRenderer가 없을 예정이기 때문에
        // 이 Componenet가 붙는 자식 Visual를 찾아준다.
        
        AnimatorCompo = visualTrm.GetComponent<Animator>();
        SpriteRendererComp = visualTrm.GetComponent<SpriteRenderer>();
    }
    
    // Delay가 필요할 시..
    public Coroutine StartDelayCallback(float time, Action Callback)
    {
        return StartCoroutine(DelayCoroutine(time, Callback));
    }

    protected IEnumerator DelayCoroutine(float time, Action Callback)
    {
        yield return new WaitForSeconds(time);
        Callback?.Invoke();
    }
    
    public virtual void Attack()
    {
        // 실제 공격 시 필요한 코드...
    }

    public abstract void SetDead();
}

 

Agent 스크립는 abstract 키워드를 붙여 추상 클래스로 만들어준다. 왜냐하면 Agent Script는 Componenet로 사용하지않고 자식 스크립트들을 Componenet로 사용할 예정이다. (따라서 자식에서 사용할 함수는 protected or 추상, 가상 함수로 만든다.)

public abstract class Agent : MonoBehaviour

 

애니메이션에 필요한 Animator와 SpriteRenderer변수를 선언한다.

public Animator AnimatorCompo { get; protected set; }
public SpriteRenderer SpriteRendererComp { get; protected set; }

 

그 다음 CanStateChangeable이라는 bool 타입 public 변수를 만들어준다. 이 변수는 현재 State를 변경하는게 가능한지? 체크 해주는 bool 변수이다. (지금은 FSM만 사용할 예정이라서 Agent에 이 변수를 넣어주지만 나중에 다른 AI 알고리즘을 사용한다면 분류 해줄 필요가 있어보인다!)

public bool CanStateChangeable { get; protected set; } = true;

 

bool 타입 public 변수 isDead는 말 그대로 현재 Agent가 죽었는지 확인하는 변수이다.

public bool isDead;

 

모든 Component는 Awake에서 초기화 해준다.

protected virtual void Awake()
{
    Transform visualTrm = transform.Find("Visual");
    AnimatorCompo = visualTrm.GetComponent<Animator>();
    SpriteRendererComp = visualTrm.GetComponent<SpriteRenderer>();
}

(visual를 자식에서 찾은 이유는

Player GameObject에서 스크립트를 넣어줄 예정이고 항상 Visual 항상 자식을 둘 예정이다 Visual은 Animator나 SpriteRenderer를 넣어주는 역활이다. 진짜 비주얼임!)

 

DelayCorutine는 필요 따라 많이 쓰이는 함수이기 때문에 만들어준다.

public Coroutine StartDelayCallback(float time, Action Callback)
{
    return StartCoroutine(DelayCoroutine(time, Callback));
}

protected IEnumerator DelayCoroutine(float time, Action Callback)
{
    yield return new WaitForSeconds(time);
    Callback?.Invoke();
}

 

Attack은 실제 공격을 담당하는 함수 SetDead 실제로 Agent가 죽을 때 실행시켜줄 함수이다. (내가 만드는 게임에 죽지 않는 Agent는 없으니 SetDead는 Abstract로 한다.)

public virtual void Attack()
{
        
}

public abstract void SetDead();

 

Script : PlayerState

using System;
using UnityEngine;

public abstract class PlayerState 
{    
    protected PlayerStateMachine _stateMachine;
    protected Player _player;

    protected int _animBoolHash; // Animator는 Hash를 통해서 BoolParamator를 변경할 예정

    protected bool _endTriggerCalled; // Animation이 끝났는지 확인하는 bool변수

    public PlayerState(Player player, PlayerStateMachine stateMachine, string boolName)
    {
        _player = player;
        _stateMachine = stateMachine;
        _animBoolHash = Animator.StringToHash(boolName);
    }

    // State에 들어왔을때
    public virtual void Enter()
    {
        _player.AnimatorCompo.SetBool(_animBoolHash, true);
        _endTriggerCalled = false;
    }
    
    // State에서 매 프래입마다 실행해야하는 코드들
    public virtual void UpdateState()
    {

    }
    
    // State를 나갈때
    public virtual void Exit()
    {
        _player.AnimatorCompo.SetBool(_animBoolHash, false);
    }
    
    // Animation 종료 시
    public virtual void AnimationFinishTrigger()
    {
        _endTriggerCalled = true;
    }
}

실제 State들이 상속받을 PlayerState이다.

protected PlayerStateMachine _stateMachine;
protected Player _player;

protected int _animBoolHash; // Animator는 Hash를 통해서 BoolParamator를 변경할 예정

protected bool _endTriggerCalled; // Animation이 끝났는지 확인하는 bool변수

public PlayerState(Player player, PlayerStateMachine stateMachine, string boolName)
{
    _player = player;
    _stateMachine = stateMachine;
    _animBoolHash = Animator.StringToHash(boolName);
}

State는 State를 변경하기 위해서 State를 관리하는 StateMachine 스크립트가 필요하기 때문에 StateMachine 스크립트를 선언한다. (아래에 StateMachine은 다룰 예정)

Player 스크립트는 당연히 필요하다.

animBoolHash 변수는 현재 State의 애니메이션의 Bool Paramator를 변경 시키위해서 필요한 Hash값을 담는 int 변수이다.

endTriggerCalled는 현재 animation이 끝날 때 필요에 따라 Event를 실행시켜주어 현재 애니메이션이 끝났다고 알려주는 역활을 하는 Bool변수이다. (ex) Attack Animation이 끝났다면 AttackState는 종료 되어야 한다. 따라서 endTriggerCalled를 true로 만들어주어서 다른 State로 변환한다.)

생성자에서는  Player, StateMachine, AnimBoolHash를 초기화 해준다.

 

모든 State에서 Enter, Update, Exit, AnimationFinishTrigger가 필요한 것은 아니니 가상 함수(virtual)로 만들어준다.

    // State에 들어왔을때
    public virtual void Enter()
    {
        _player.AnimatorCompo.SetBool(_animBoolHash, true);
        _endTriggerCalled = false;
    }
    
    // State에서 매 프래입마다 실행해야하는 코드들
    public virtual void UpdateState()
    {

    }
    
    // State를 나갈때
    public virtual void Exit()
    {
        _player.AnimatorCompo.SetBool(_animBoolHash, false);
    }
    
    // Animation 종료 시
    public virtual void AnimationFinishTrigger()
    {
        _endTriggerCalled = true;
    }

Enter 스크립트에서는 Hash값을 통해 Paramator를 true로 만들어서 애니메이션을 실행시켜준다. 그리고 당연히endTriggerCalled가 true가 된 경우를 생각해서 false로 만들어준다.

UpdateState는 비워준다. 딱히 채워줄 코드가 없기 때문

Exit는 Paramator는 false로 만들어준다.

AnimationFinishTrigger는 endTriggerCalled를 true로 만들어주는 함수이다. 

Script : PlayerStateMachine

using System.Collections.Generic;
using UnityEngine;

public class PlayerStateMachine : MonoBehaviour
{
    public PlayerState CurrentState { get; private set; }
    public Dictionary<PlayerStateEnum, PlayerState> stateDictionary;

    private Player _player;

    public PlayerStateMachine()
    {
        stateDictionary = new Dictionary<PlayerStateEnum, PlayerState>();
    }

    public void Initialize(PlayerStateEnum startState, Player player)
    {
        _player = player;
        CurrentState = stateDictionary[startState];
        CurrentState.Enter();
    }

    public void ChangeState(PlayerStateEnum newState)
    {
        if (_player.CanStateChangeable == false) return;

        CurrentState.Exit(); //현재 상태를 나가고
        CurrentState = stateDictionary[newState]; //새로운 상태로 업데이트 하고
        CurrentState.Enter(); //새로운 상태로 진입한다.
    }

    public void AddState(PlayerStateEnum stateEnum, PlayerState state)
    {
        stateDictionary.Add(stateEnum, state);
    }
}

 

PlayerStateMachine는 State들을 관리하는 스크립트이다.

public PlayerState CurrentState { get; private set; }
public Dictionary<PlayerStateEnum, PlayerState> stateDictionary;

private Player _player;

public PlayerStateMachine()
{
    stateDictionary = new Dictionary<PlayerStateEnum, PlayerState>();
}

현재 State를 저장하는 CurrentState 변수를 선언한다.

모든 State를 Enum을 Key로 만들어서 Dictionary로 저장하여둔다.

Player 스크립트는 당연히 필요하다.

생성자에서 Dictionary를 생성하여준다.

public void Initialize(PlayerStateEnum startState, Player player)
{
    _player = player;
    CurrentState = stateDictionary[startState];
    CurrentState.Enter();
}

public void ChangeState(PlayerStateEnum newState)
{
    if (_player.CanStateChangeable == false) return;

    CurrentState.Exit(); //현재 상태를 나가고
    CurrentState = stateDictionary[newState]; //새로운 상태로 업데이트 하고
    CurrentState.Enter(); //새로운 상태로 진입한다.
}

public void AddState(PlayerStateEnum stateEnum, PlayerState state)
{
    stateDictionary.Add(stateEnum, state);
}

Initialize함수에서는 Player 스크립트 초기화 현재 State를 시작 State로 초기화 현재 스테이트의 Enter를 실행시켜 FSM을 시작한다.

ChangeState 함수는 말 그대로 이다.  Player 스크립트에 있는 CanStateChangeable(나중에 설명됨)이 false라면 ChangeState는 실행되지 않는다. 만일 true라면 현재 스테이트의 Exit를 실행시켜 주고 현재 스테이트를 매개변수로 받는 State로 변경시켜주고 Enter를 실행 시킨다.

AddState 함수는 Dictionary에 Enum과 State를 받아 저장 시켜주는 함수이다. 초기화 과정이라고 보면 된다.

Script : Player

Player 스크립트는 뭐가 좀 많다.

using System;
using System.Collections.Generic;
using UnityEngine;

public enum PlayerStateEnum
{
    Idle, 
    Run, 
    Fall,
    Attack,
    Dash,
    Hit,
    Ground,
    Jump,
    JumpAttack
}

public class Player : Agent
{
    [Header("Setting Values")]
    public float moveSpeed = 8f;
    public float dashSpeed = 20f;

    [Header("Attack Settings")]
    public float attackSpeed = 1f;
    public AnimationEvent _animationEvent;
    public List<int> frontMoves;

    public PlayerStateMachine StateMachine { get; protected set; }
    [Header("Input")]
    [SerializeField] private InputReader _InputReader;
    
    private PlayerMovement _playerMovement;
    public PlayerMovement PlayerMovement => _playerMovement;
    public InputReader InputReder => _InputReader;
    

    protected override void Awake()
    {
        base.Awake();
        
        _playerMovement = GetComponent<PlayerMovement>();
        
        StateMachine = new PlayerStateMachine();

        foreach(PlayerStateEnum stateEnum in Enum.GetValues(typeof(PlayerStateEnum)))
        {
            string typeName = stateEnum.ToString();

            try
            {
                Type t = Type.GetType($"Player{typeName}State");
                PlayerState state = Activator.CreateInstance(
                                t, this, StateMachine, typeName) as PlayerState;
                StateMachine.AddState(stateEnum, state);
            }catch(Exception ex)
            {
                Debug.LogError($"{typeName} is loading error! check Message");
                Debug.LogError(ex.Message);
            }
        }
    }

    protected void Start()
    {
        StateMachine.Initialize(PlayerStateEnum.Idle, this);
    }

    protected void Update()
    {
        StateMachine.CurrentState.UpdateState();
    }


    public override void Attack()
    {
		
    }

    public override void SetDead()
    {
        
    }

    public void Jump()
    {
        PlayerMovement.Jump();
    }
}

자 가장 먼저 리플랙션이라는 개념에 대해서 알 필요가 있다. 이 친구를 왜 쓰냐?

원래 간단하게 구현하는 FSM은 Swtich로도 가능하지만 좀 더 효율적인 개발을 위해서 스크립트 기반으로 만들 필요가 있었다. 따라서 그때 나오게 된 개념이 리플랙션이다.

리플랙션은 JAVA API로 구체적으로 클래스의 Type의 정보를 알지 못해도 접근이 가능하게 해주는 녀석이다. (보통 런타임 도중 클래스를 불러올 때 사용함)

public enum PlayerStateEnum
{
    Idle, 
    Run, 
    Fall,
    Attack,
    Dash,
    Hit,
    Ground,
    Jump,
    JumpAttack
}


foreach(PlayerStateEnum stateEnum in Enum.GetValues(typeof(PlayerStateEnum)))
{
    string typeName = stateEnum.ToString();

    try
    {
        Type t = Type.GetType($"Player{typeName}State");
        PlayerState state = Activator.CreateInstance(
                        t, this, StateMachine, typeName) as PlayerState;
        StateMachine.AddState(stateEnum, state);
    }catch(Exception ex)
    {
        Debug.LogError($"{typeName} is loading error! check Message");
        Debug.LogError(ex.Message);
    }
}

위에 있던 코드를 가지고 온 것인데... 뭐하는 코드냐면 리플랙션을 사용하여 Enum에 써져 있는 이름의 클래스를 불러오는 코드이다.

리플랙션은 GetType부분에서 사용한다.

1, foreach로 Enum에있는 모든 원소를 하나씩 확인하면 돈다.

2. 가장 먼저 현재 Enum의 이름을 String으로 변경하여 준다.

3. try Catch문을 사용하여 혹시나 Enum 이름의 스크립트가 없을 경우를 대비한다.

4. GetType의 매개변수로 문자열 [$" Player{Enum}State"]를 넣어주면 그 String에 맞는 클래스의 타입을 반환해준다. (이때 리플랙션을 사용한다.)

5. 가져온 type로 state를 만들어준다. Activator.CreateInstance(이 함수는 다음 링크에서 자세히 알아보는 것을 추천함  https://learn.microsoft.com/ko-kr/dotnet/api/system.activator.createinstance?view=net-8.0)

함수를 통해서 만든다. 그리고 StateMachine에 AddState 함수를 통해서 State를 StateMachine에 저장시켜준다.

 

[Header("Setting Values")]
public float moveSpeed = 8f;
public float dashSpeed = 20f;

[Header("Attack Settings")]
public float attackSpeed = 1f;
public AnimationEvent _animationEvent;
public List<int> frontMoves;

public PlayerStateMachine StateMachine { get; protected set; }
[Header("Input")]
[SerializeField] private InputReader _InputReader;

private PlayerMovement _playerMovement;
public PlayerMovement PlayerMovement => _playerMovement;
public InputReader InputReder => _InputReader;

리플랙션과 Enum 부분을 제외한 나머지 코드이다.

SettingValues 부분은 dash와 Move의 속도를 값을 조절하는 변수이고

AttackSettings 는 공격에 필요한 정보들이다. 공격속도, 공격 애니메이션 이벤트, 공격할 때 줄 힘

StateMachine은 FSM를 돌리기 위해서 필요하기 때문에 가지고 온다.

Input 부분은 InputSystem 때문에 가지고 온다. (InputReader는 저번 글에서 다루었으니 저번 글을 읽어보시면 감사하겠습니다.)

PlayerMovement는 이동관련 스크립트이다.

protected void Start()
{
    StateMachine.Initialize(PlayerStateEnum.Idle, this);
}

protected void Update()
{
    StateMachine.CurrentState.UpdateState();
}


public override void Attack()
{

}

public override void SetDead()
{

}

public void Jump()
{
    PlayerMovement.Jump();
}

나머지는 함수들이다.

Start 할때 StateMachine의 Init을 해주고

Update에서 현재 State의 Update를 돌려준다.

Attack과 SetDead는 아까 Agent에서 있던 함수들이다. (아직까진 구현 안해두었음)

Jump 실제 점프를 하기 위해서 PlayerMovement의 Jump함수를 불어와 실행시킨다.

 

실제 State 스크립트들은 너무 길어서 다음 파트로 짜르겠다.

'Unity' 카테고리의 다른 글

[엔진 프로젝트]Enemy Walking Around with BT  (0) 2024.11.01
[Unity] (2024_10_15) 2D 플랫 포머 게임 개발일지(Pooling)  (2) 2024.10.17
[Unity] (2024_09_30) 2D 플랫 포머 게임 개발일지  (0) 2024.09.30
[Unity] (2024_09_25) 2D 플랫 포머 게임 개발일지 FSM Part 2  (0) 2024.09.26
[Unity] (2024_09_09) 2D 플랫 포머 게임 개발일지  (3) 2024.09.11
'Unity' 카테고리의 다른 글
  • [Unity] (2024_10_15) 2D 플랫 포머 게임 개발일지(Pooling)
  • [Unity] (2024_09_30) 2D 플랫 포머 게임 개발일지
  • [Unity] (2024_09_25) 2D 플랫 포머 게임 개발일지 FSM Part 2
  • [Unity] (2024_09_09) 2D 플랫 포머 게임 개발일지
HK1206
HK1206
고3 게임 개발자의 개발 일지
  • HK1206
    GGM-LJS
    HK1206
  • 전체
    오늘
    어제
    • 분류 전체보기 (25)
      • Unity (16)
      • Shader (1)
      • Editor (8)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.0
HK1206
[Unity] (2024_09_25) 2D 플랫 포머 게임 개발일지 FSM Part 1
상단으로

티스토리툴바