[압량(Amnyang)] #13. 2D 게임 적(Enemy) 패턴 구현하기

2022. 2. 21. 15:25Projects/Amnyang

 

 

 

이 때까지 플레이어 캐릭터, 조명 처리, 배경 세팅 작업을 진행했었다. 오늘은 플레이어를 방해할 경관(Police) 패턴을 만들어볼 생각이다.

 

오늘의 주인공, 경관

 

공포 게임 특성상, 좀 어두워 보여야 하기에 유니티 스프라이트 렌더러에서 색감을 어둡게 낮췄다. 이제 차례대로 한 번 만들어보자.

 

 

 

1. 경관 외관 세팅하기

 

평소 상태(Idle), 걷기(Walk), 인기척 감지(Feel) 애니메이션은 다른 팀원이 만들어준 상태다. 따라서, 이번에는 따로 애니메이션 작업을 안 해도 됐다. 다만, 원래 기획서 상에는 이런 내용이 없었지만 우리가 개발하는 게임이 공포게임인큼 좀 더 확실한 이펙트를 주고 싶었다.

 

원래 인게임 밝기로 캡쳐하니, 너무 안 보여서 밝기를 살짝 올렸다.

 

경관이 들고 있는 손전등에 라이트(Light)를 달아줬다. 흰색이나 노란색 조명은 오히려 플레이어를 구하러 온 경관 느낌이 났기 때문에 처음 딱 봤을 때, "아, 적이구나!"라는 걸 느낄 수 있도록 빨간색 조명으로 해줬다. 평상 시에 순찰하며 걸을 때는 저런 모습으로 걸어다니는 것이다.

 

그런데, 또 하나 고민이 생겼었다. 이것 또한, 기획서 상에는 없는 내용이었다. 만약 플레이어가 적에게 들켰을 때, 경관이 추적 상태에 있다는 걸 플레이어에게 어떻게 각인시켜줄까?

 

플레이어를 발견하여 추적 상태에 들어간 경관

 

위와 같이 표현하면 되게 실감나고 좋지 않을까란 생각이 들었다. 수지의 집 이벤트나 무당집 앞 대문에서 사용했던 Freeform 2D Light를 경관에게도 배치해줬다. 평상 시에는 이 불빛이 비활성화 되어 있다가, 경관이 추적 상태에 들어가면 위와 같이 활성화가 되는 것이다.

 

이렇게 하면, 외관 세팅은 대강 다 한 것 같다.

 

 

 

2. 탐지 및 추격 판정 영역 설정하기

 

경관 또한 물리효과를 받아야 하기에 Rigidbody 2D 컴포넌트를 달아줬다. 그리고 본래 몸체, 경관의 뒷 부분에 탐지용, 경관의 앞 부분 플레이어 발견, 게임오버 영역에 사용할 콜라이더 이렇게 4가 필요했다.

 

경관의 콜라이더 세팅 모습

 

물론, 본체 콜라이더를 제외하고는 각 콜라이더마다 트리거 이벤트를 발생시킬 예정이기에 빈 오브젝트들을 만들어서 각각에 배치해줬다. 본체 콜라이더 빼고 전부 "is Trigger"를 활성화해주면 된다. 아직 게임 오버 화면 리소스가 완성되지 않았기 때문에 여기서 게임 오버까지는 다루지 않을 예정이다.

 

이제 스크립트 작성하는 일만 남았다.

 

 

 

3. 경관 패턴 만들기

 

먼저, 어떤 패턴들을 만들어야 하는지 한 번 리스트를 나열해봤다.

  • 평소 상태일 땐, 랜덤하게 이리 저리 다니며 순찰
  • 경관 뒤에 플레이어가 일정 거리 내에 들어온다면, 인기척을 느끼고 잠깐 머뭇거리다가 뒤돌아봄
    • 뒤돌아봤는데 플레이어가 없다면 다시 평소 상태, 있다면 추격 시작
  • 경관 앞에 플레이어가 일정 거리 내에 들어온다면, 추적 상태로 변하여 플레이어 추격 시작
  • 플레이어가 추격 당하는 도중에 오브젝트 뒤에 숨었다면, 경관은 플레이어를 찾아 이리저리 다님
  • 플레이어가 숨고 나서, 일정 시간이 지나면 경관은 다시 평소 상태로 돌아와서 순찰 시작
  • "플레이어가 경관에게 잡히면 게임 오버" 패턴은 나중에 리소스가 만들어지면 그 때 구현

 

 

이렇게 나열만 해봤는데, 벌써 머리가 아팠다. 어떻게 먼저 구조 틀을 잡고 시작해야할까 고민하다가 팀원이 디자인 패턴 중에 상태 패턴이란 걸 찾았다고 말해줬다. 그래서, 그걸 먼저 공부하고 따라해보며 만들긴 했는데 이렇게 하는 게 맞는지는 솔직히 의문이다...ㅎㅎㅎ

/*  < 참고한 공부 블로그> (공부하는 식빵맘 님), (글릭의 만들어가는 세상 님)(김선민 벨로그 님)  */

 

 

StateMachine.cs

 

상태들도 생명 주기가 있다는 글을 보고 먼저 인터페이스를 작성했다. Enemy는 경관에 부착할 클래스다.

public interface IState
{
    void StateEnter(Enemy enemy);
    void StateFixedUpdate(Enemy enemy);
    void StateUpdate(Enemy enemy);
    void StateExit(Enemy enemy);
}

 

상태에 들어갔을 때(StateEnter), 물리 연산과 관련된 로직을 사용하는 상태(StateFixedUpdate), 매 프레임마다 실행되는 상태(StateUpdate), 상태를 벗어날 때 실행(StateExit) 이렇게 구성했다. 그리고 이런 상태들을 제어해줄 StateMachine 클래스를 만들었다.

public class StateMachine
{
    public IState currentState { get; private set; }


    public StateMachine(Enemy enemy, IState defaultState)
    {
        currentState = defaultState;
        currentState.StateEnter(enemy);
    }
    
    public void SetState(Enemy enemy, IState state)
    {

        if (currentState == null || currentState == state)
        {
            Debug.Log("상태를 변경할 수 없습니다.");
            return;
        }

        currentState.StateExit(enemy);
        currentState = state;
        currentState.StateEnter(enemy);
    }

    public void UpdateState(Enemy enemy)
    {
        currentState.StateUpdate(enemy);
    }

    public void FixedUpdateState(Enemy enemy)
    {
        currentState.StateFixedUpdate(enemy);
    }
}

 

이걸 바탕으로 상태 클래스들을 싱글톤 패턴으로 정의하였다. (상태 전환마다 새로운 객체 생성은 가비지라고 판단)

public class Idle : IState          // 평소 상태
{
    private static Idle Instance = new Idle();
    private Idle() { }
    public static Idle GetInstance() {  return Instance; }
     ...
}

public class FeelStrange : IState   // 인기척을 느끼는 상태
{
    private static FeelStrange Instance = new FeelStrange();
    private FeelStrange() { }
    public static FeelStrange GetInstance()
    {
        return Instance;
    }
    
     ...
}

public class ChaseState : IState    // 추격 상태
{
    private static ChaseState Instance = new ChaseState();
    private ChaseState() { }
    public static ChaseState GetInstance()
    {
        return Instance;
    }
    
     ...
}

 

그리고 각 상태 안에서 경관이 해야할 기능들을 실행하는 로직을 만들어주면 됐다. 하나하나 다 적어가며 설명하자니, 글이 너무 길어질 것 같았고 쉽게 설명하기에도 조금 벅차보여서 간단히 적었다.

 

Enemy.cs와 위 상태 클래스들의 내부 내용들은 생략한다. /*  자세한 내부 코드는 나중에 이 글에다가 깃허브 주소를 올리도록 하겠습니다.  */

그래도 탐지 콜라이더추격 콜라이더 로직을 담당하는 Detect.csChase.cs 정도는 적을 수 있을 것 같다.

 

 

 

Detect.cs

 

Enemy 클래스 내부에 StateMachine 객체를 생성해놓은 게 있다. 그걸 참고하여 봐주길 바란다.

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

public class Detect : MonoBehaviour
{

    public Enemy enemy;

    private void OnTriggerEnter2D(Collider2D collision)
    {
        Find(collision);
    }

    private void OnTriggerStay2D(Collider2D collision)
    {
        Find(collision);
    }


    private void Find(Collider2D collision)
    {
        if (collision != null)
        {
            SujiController suji = collision.GetComponent<SujiController>();
            bool findSuji = collision.gameObject.CompareTag("Player") && !suji.IsHiding;

            if (findSuji && enemy.StateMachine.currentState != ChaseState.GetInstance())
            {
                enemy.StateMachine.SetState(enemy, FeelStrange.GetInstance());
            }
        }
    }
}

 

경관이 추격 상태일 때는 뒤돌아볼 필요가 없고, 플레이어 캐릭터가 숨지 않았을 때 작동해야 한다. 태그랑 레이어 중에 어떤 걸로 플레이어를 감별해낼까 고민하다가 우선 태그를 사용했다.

 

OntriggerStay2D()에도 같은 기능을 넣어줌으로써, 버그를 수정했다. 경관 뒤에 가서 숨고 다시 나와서 뒤에 가면 작동하지 않는 버그가 있었다. 

 

 

Chase.cs

 

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

public class Chase : MonoBehaviour
{
    public Enemy enemy;


    private void OnTriggerEnter2D(Collider2D collision)
    {
        ChaseTarget(collision);
    }

    private void OnTriggerStay2D(Collider2D collision)
    {
        ChaseTarget(collision);
    }

    private void ChaseTarget(Collider2D collision)
    {
        bool findSuji = collision != null && collision.gameObject.CompareTag("Player");

        if (findSuji)
        {
            SujiController suji = collision.gameObject.GetComponent<SujiController>();
            bool isNotHide = suji != null && !suji.IsHiding;


            if (isNotHide)
            {
                // 수지 추격을 시작
                ChaseState state = ChaseState.GetInstance();
                state.SetTarget(collision.gameObject.transform);
                enemy.StateMachine.SetState(enemy, state);
            }
        }
    }
}

 

비슷한 구성이라서 이해하기 쉬울 것으로 생각된다. 차이점이라면 추격을 시작해야 하므로, 타겟 대상을 지정해주는 부분이 있는 것을 볼 수 있다.

 

 

 

완성

 

버그도 많이 나오고, 새로운 구조 설계에 도전해보며 한 거라 하루종일 코딩하기도 했다. 꽤나 어렵고 힘들었던 부분인데 그래도 잘 나와줘서 너무 기뻤다.

 

과정을 전부 적고 싶었으나, 내용이 매우 길어질 것 같아서 이렇게 밖에 설명하지 못한 부분이 매우 아쉬웠다.

경관이 뒤돌아 봤을 때, 없을 경우 그냥 다시 가는 영상도 찍고 싶었는데 좀처럼 각이 잘 나오지 않아 포기했다....ㅎㅎ

 

경관이 뒤돌아봤는데, 들켰을 경우 (추격 구현 안 한 버전)

 

경관에게 추적 당하는데, 숨었을 경우

 

경관에게 추적 당하는 부분 (게임 오버 처리는 아직 만들지 않음)

 

728x90
반응형