프로토타입은 급하게 만든다고 코드가 못봐줄 지경이었죠.

Update메소드 안에서 입력을 받으면 if-else if else… 로 이런저런 상태를 확인하고, 또 그 안에서 switch로 상태를 확인하고. 이래서는 알아보기 힘들죠.

public class PlayerController : MonoBehaviour
{
    // Update is called once per frame
    void Update()
    {
        // 이게 터치 확인이었다면, phase를 확인하는 switch문이 있겠죠... 끔찍한데요?
        if (Input.GetKeyDown(KeyCode.Space)) 
        {
            if (touching)
            {
                if (OnGround)
                {
                    Debug.Log("jump");
                    rb.AddForce(Vector2.up * eff, ForceMode2D.Impulse);
                } 
                else
                {

                    // 붙어있는 나무의 충돌판정 비활성화
                    RaycastHit2D tree = Physics2D.BoxCast(player.coll.bounds.center, player.coll.bounds.size, 0f, Vector2.right, 0.01f, LayerMask.GetMask("Tree"));

                    tree.transform.gameObject.GetComponent<Collider2D>().enabled = false;

                    Debug.Log("wall jump");
                    rb.AddForce(new Vector2(0, 3f) * eff, ForceMode2D.Impulse);
                }
            }
        }

        if (!touching)
        {
            Vector2 pos = transform.position;
            pos.x += 0.1f;
            transform.position = pos;
        }
    }

}

그래서 깔끔하게 State Machine 디자인패턴을 적용했습니다.

구조는 단순합니다. 우선 각 상태가 행해야 하는 행동양식을 정의해줍니다. Interface로 말이죠.

public interface IState
{
    IState HandleUpdate();
    IState HandleInput();
}

매 프레임마다 호출되어야 하는 메소드는 두 개입니다. 우선 입력을 받고, 그 다음에 각 상태의 고유한 처리를 하는 것이죠.

플레이어에게 상태 필드를 선언해줍니다.

public partial class PlayerController : MonoBehaviour
{
    private IState state;

    void Start()
    {
        state = new StateRun(this);
    }

    void Update()
    {
        state = state.HandleInput();
        state = state.HandleUpdate();
    }
}

다람쥐 게임에서 플레이어가 가지는 상태는 딱 3가지입니다. 하이퍼캐주얼 장르답게, 조작은 단순하고 각 상태로의 전환 또한 복잡하지 않아서 이러한 스테이트 머신 구조를 간단하게 보여줄 만한 훌륭한 표본이 되겠군요. Run, Jump, Climb를 정의해줍니다.

public partial class PlayerController
{
    class StateRun : IState
    {
        private PlayerController player;
        ...

각 상태를 내부클래스로 정의해주는 이유는 몇 가지가 있습니다. 플레이어의 상태를 외부에서 참조할 필요도 없으며, 참조가 되어서도 안 됩니다. 플레이어 안에서만 활용할 수 있도록 만드는 것이 첫 번째 이유죠. 또한 내부클래스는 외부클래스의 private 필드에 접근할 수 있습니다. 여러모로 나쁠 것 없는 거죠.

이렇게 만들게 되면, 각 상태에서 받는 입력 처리가 매우 명확해집니다. 버그가 생겨도, 어떤 상태에서 생기는 버그인지 쉽게 찾을 수 있게 되죠.

// Run State
private IState HandleKeyboardInput()
{
    if (Input.GetKeyDown(KeyCode.Space))
    {
        // 위로 점프
        player.rb.AddForce(Vector2.up * player.eff, ForceMode2D.Impulse);

        // 점프했다면, StateJump를 반환하여 플레이어가 점프 상태가 될 수 있도록 함
        return new StateJump(player);
    }

    return this;
}

// Jump State
private IState HandleKeyboardInput()
{
    if (Input.GetKeyDown(KeyCode.Space))
    {
        // 더블 점프
        if (!doubleJump)
        {
            doubleJump = true;

            player.rb.velocity = Vector2.zero;
            player.rb.AddForce(Vector2.up * player.eff, ForceMode2D.Impulse);
        }
    }
    return this;
}

// Climb State
private IState HandleKeyboardInput()
{
    if (Input.GetKeyDown(KeyCode.Space))
    {
        // 붙어있는 나무의 충돌판정 비활성화
        RaycastHit2D tree = Physics2D.BoxCast(player.coll.bounds.center, player.coll.bounds.size, 0f, Vector2.right, 0.01f, LayerMask.GetMask("Tree"));

        tree.transform.gameObject.GetComponent<Collider2D>().enabled = false;

        player.rb.velocity = Vector2.zero;

        // 벽 점프
        player.rb.AddForce(Vector2.up * player.eff, ForceMode2D.Impulse);

        player.rb.gravityScale = 1f;
        return new StateJump(player); // Jump 상태로 이동
    }
    return this;
}

딱히 갈라놓을 이유가 없겠다 생각될 수도 있겠지만, 더 큰 프로젝트였다면 말이 달라지겠죠.

다음번엔 현준님께 맡겨뒀던 레벨디자인 툴을 리뷰해볼까요?