내일배움캠프 23일차

박나연·2025년 5월 9일

내배캠

목록 보기
23/69

2D 팀 프로젝트 진행중(필수 기능 구현 : 점수 기능)

오늘의 키워드 : 이벤트 구독!

오늘은 어제에 이어서 점수 구현을 계속 작업했다. 비주얼적인 부분을 빼고 본다면 기본적인 기능 구현은 모두 완료되었다. 기획이 좀 바뀌었는데 원래 코인 점수제를 채택해서 코인마다 점수가 다르고, 클리어 시간과 코인점수를 합쳐서 등급을 매기려고 했었는데 그냥 원작 게임과 같은 점수제를 가져가기로 했다. 바뀐 기획은 아래와 같다.

A랭크 : 정해진 시간 안에 클리어 + 모든 코인 수집

B랭크 : 정해진 시간 안에 클리어 + 코인 미달 / 시간 초과 클리어 + 모든 코인 수집

C랭크 : 시간 초과 클리어 + 코인 미달

각각의 스테이지가 map 프리팹으로 되어있어 어제 만들었던 ScriptableObject를 사용해 각각의 맵마다 클리어 시간 기준점을 다르게 잡을 수 있도록 하였다. 점수제를 버린만큼 어제 작업했던 것 중에 점수와 관련된건 삭제했다. 오늘 작업한 내용을 잊어버리지 않게 하나하나씩 기록해보겠다.

이벤트 구독 패턴 정리

오늘은 이벤트 구독 패턴이라는 것을 사용해서 스크립트를 써봤다. 자주 보긴 했지만 내가 사용한 적은 없어서 이번 기회에 잘 배워보고 싶었다.

이벤트 구독 패턴이란?
publisher(발행자), subscriber(구독자)가 서로 클래스 이름, 인스턴스를 몰라도 되며, 기능 추가, 교체 시 서로의 코드를 거의 건드리지 않아도 되는 패턴이다. 구독자는 필요에 따라 언제든지 구독을 해지할 수 있고, 여러 구독자가 동일한 이벤트를 구독할 수 있다.

언제 사용?

  • 여러 컴포넌트가 동일한 사건(이벤트)에 반응해야 할때나 의존성 결합을 최소화 하고 싶을 때 등등 사용하면 좋다.

장점

  • 느슨한 결합
  • 유연성과 확장성 : 새로운 구독 추가,교체가 쉽다

사용 방법
오늘 내가 사용한 코드를 바탕으로 예시를 들어보겠다.

  1. 이벤트 선언
    먼저 이벤트를 선언한다.
ScoreManager.cs

public static event Action<COINTYPE> OnCoinType;

Action<T>형식의 이벤트를 정의하면 여러 구독자가 콜백 메서드를 등록할 수 있다.

  1. 이벤트 발행
    Coin에서 이벤트를 발행하고 ScoreManager에서 구독한다. Coin스크립트에서 ScoreManager.AddCoin(..)을 호출하면 내부에
ScoreManager.cs

    public static void AddCoin(COINTYPE type)
    {
        OnCoinType?.Invoke(type);
    }

가 실행되어 어떤 타입의 코인이 수집되었는지 OnCoinType이벤트를 발행한다.

  1. 이벤트 구독, 해제
    현재 이 OnCoinType이벤트를 구독하고 있는 메서드인 ScoreManager
    
    private void OnEnable()
    {
        OnCoinType += CoinTypeCollected;
    }

    private void OnDisable()
    {
        OnCoinType -= CoinTypeCollected;
    }
    

OnEnable에서 이 메서드를 등록하여 구독한다.

  1. 실제 로직 예시
    private void CoinTypeCollected(COINTYPE type)
    {
        switch (type)
        {
            case COINTYPE.FIRESTAR: fireCollected++; break;
            case COINTYPE.ICESTAR: iceCollected++; break;
            default: break;
        }
        scoreUI.UpdateCoinUI(fireCollected, iceCollected);
    }

Coin.cs

public class Coin : MonoBehaviour
{
    [Header("코인설정")]
    public ScoreConfig scoreConfig;

    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (scoreConfig.coinType == COINTYPE.FIRESTAR
            && !collision.CompareTag("PlayerFire"))
        {
            //파이어스타와 아이스플레이어를 만나면 없어지지 말기
            return;
        }
        else if (scoreConfig.coinType == COINTYPE.ICESTAR
            && !collision.CompareTag("PlayerIce"))
        {
            return;
        }
        ScoreManager.AddCoin(scoreConfig.coinType);
        Destroy(gameObject);
    }
}

우리게임에는 조작할 수 있는 플레이어가 두 개 있다. 각자 다른 태그를 갖고 있고, 각자 먹어야 할 코인이 다르므로 태그로 구분해 OnTriggerEnter2D를 만들었다.
만약 코인이 FIRESTAR 타입을 갖고 있는데 PlayerFire가 아닌 플레이어랑 만나면 충돌처리를 하지 않는다. 하지만 PlayerFire태그를 가진 오브젝트와 충돌하면 ScoreManager에 자신이 먹혔다는 사실을 전달하고 파괴된다.

ScoreManager.cs

    private void Awake()
    {
        // 씬에 있는 모든 Coin 오브젝트를 찾아서 총 목표치 계산
        var allCoins = FindObjectsOfType<Coin>();
        fireTotal = allCoins.Count(c => c.scoreConfig.coinType == COINTYPE.FIRESTAR);
        iceTotal = allCoins.Count(c => c.scoreConfig.coinType == COINTYPE.ICESTAR);

        //게임 시작 초기 UI 호출
        scoreUI.InitializeTotals(fireTotal, iceTotal);
    }

맵마다 코인이 다르게 들어갈텐데 이걸 뭐 스테이지 스크립트를 만들어서 조절하는 것보단 그냥 FindObjectOfType을 사용해서 Coin 컴포넌트를 찾아 코인별 목표 개수를 계산할 수 있게 하였다. Find함수를 사용하는게 좋진 않지만 이렇게 하면 지금 맵 만드는 팀원이랑 상의할 필요 없이 알아서 코인계산이 될 테니까 편하다는 장점이 있다.

    // Coin.cs 에서 호출
    public static void AddCoin(COINTYPE type)
    {
        OnCoinType?.Invoke(type);
    }

    // 실제 획득된 별 타입별 개수 관리 및 UI 갱신
    private void CoinTypeCollected(COINTYPE type)
    {
        switch (type)
        {
            case COINTYPE.FIRESTAR: fireCollected++; break;
            case COINTYPE.ICESTAR: iceCollected++; break;
            default: break;
        }
        scoreUI.UpdateCoinUI(fireCollected, iceCollected);
    }

AddCoinCoin스크립트에서 충돌 시 호출하며 어떤 타입의 코인이 수집되었는지 OnCoinType이벤트로 발행한다.
CoinTypeCollected는 실제 개수를 누적하고 누적된 값을 넘겨 후술할 ScoreUI에 넘겨 UI를 업데이트 한다. 이렇게 하면 코인 추가 로직을 중앙에서 관리하고, 새 코인 타입이 생겨도 switch문만 확장하면 되므로 유지보수가 쉽다.

    public void Rank()
    {

        bool withinTime = !timeTracker.isTimeExceeded;
        //모든 파이어스타 먹으면 true
        bool allFire = fireCollected == fireTotal;
        //모든 아이스스타 먹으면 true
        bool allIce = iceCollected == iceTotal;

        GRADE result;
        if (withinTime && allFire && allIce)
        {
            result = GRADE.A;
        }
        else if ((withinTime && (!allFire || !allIce))
              || (!withinTime && allFire && allIce))
        {
            result = GRADE.B;
        }
        else result = GRADE.C;

        scoreUI.DisplayGrade(result);
        Debug.Log($"최종 등급: {result}");
    }

시간 조건과 코인 수집 조건을 계산해 A/B/C랭크를 결정한다.

TimeTracker.cs

    public bool isTimeExceeded { get; private set; } 
    public float elapsedTime {  get; private set; }
    private float curtimeLimit;
    void Awake()
    {
        var map = FindObjectOfType<Map>();
        if (map != null && map.timeConfig != null)
        {
            curtimeLimit = map.timeConfig.timeLimit;
        }
        else
        {
            Debug.LogWarning("Map 또는 TimeConfig를 찾을 수 없습니다.");
        }
    }
    void Update()
    {
        // 경과시간 누적
        elapsedTime += Time.deltaTime;

        // 제한시간 초과 판정
        // 제한시간보다 경과시간이 크면 초과로 판단
        if (elapsedTime > curtimeLimit)
        {
            isTimeExceeded = true;
        }
    }

아까와 마찬가지로 FindObjectOfType을 사용해 씬에 배치된 Map 컴포넌트를 찾아 거기에 있는 timeConfig를 읽어온다.

  • elapsedTime은 매 프레임 Time.deltaTime을 더해 게임 시작 후 시간이 얼마나 흘렀는지를 누적해서 보여준다.
  • isTimeExceeded는 누적된 elapsedTimecurtimeLimit을 넘기면 true가 된다. 이것은 ScoreManagerRank()와 연결된다.

마무리하며

오늘은 전체적으로 거의 모든 코드를 리팩토링했다. 이벤트 구독 방식은 잘 안써봐서 좀 생소했는데 지교수님 도움으로 잘 써볼 수 있었다....그리고 Awake를 지금 두 스크립트에서 사용했는데 너무 모든 곳에서 사용하면 안될거 같아서 앞으로는 잘 안쓰려고 한다. 처음엔 ScoreManager에서 획득한 코인에 대해 점수도 계산하고 개수도 계산하려고 이벤트 구독방식을 도입했는데 지금은 기획이 바뀌어서 개수만 계산하다보니 이 방식이 맞나 싶은 생각도 든다.. 근데 또 작업하면 이게 몇번째 리펙토링인지 모르겠다 ㅠㅠ 아무튼 이제 주말이고 다음주면 발표인데 주말동안 더 도전기능 작업을 또 열심히 해야할 것 같다. 화테엥

0개의 댓글