구현 배경

타워 디펜스류 게임에서 강화카드를 사용하여 타워의 능력치를 상승시키는 시스템을 만들었습니다.
하지만 개발 도중 다음과 같은 문제가 발생했는데요.

같은 강화카드를 반복해서 뽑을 경우 능력치가 무한히 상승한다는 문제였습니다.

예를 들어 사거리 증가 카드를 계속 뽑는다면, 게임 후반에는 사정거리가 화면 밖까지 닿는 상황도 발생할 수 있었죠.

그래서 저는 카드별로 뽑을 수 있는 최대 횟수를 제한하는 구조를 설계하기로 했습니다.


기존 방식의 문제

처음에는 아래와 같이 SelectedCard() 메서드 내에서 하드코딩된 조건문으로 각각 제한을 걸었습니다:

if ((cardKey == 4101 && currentAmount >= 3) ||
    (cardKey == 4103 && currentAmount >= 4) ||
    (cardKey == 4104 && currentAmount >= 1) ||
    (cardKey == 4105 && currentAmount >= 4))
{
    list.RemoveAll(card => card.key == cardKey);
}

이 방식은 빠르게 작동하지만, 단점이 몇 가지 있습니다:

  • 카드가 추가될 때마다 조건문을 일일이 수정해야 한다는 점
  • 외부에서 데이터를 관리하거나 튜닝하기 어려운 구조라는 점

개선 방향

그래서 저는 카드 키와 최대 강화 횟수를 딕셔너리로 관리하는 방식으로 리팩토링했습니다.

딕셔너리 기반 구조

// 강화 키값 별 최대 허용 횟수
private Dictionary<int, int> reinforcementLimit = new Dictionary<int, int>
{
    { 4101, 3 }, // 사거리 증가
    { 4103, 4 }, // 공격속도 증가
    { 4104, 1 }, // 동시 공격 대상 증가
    { 4105, 4 }, // 범위 공격 영역 증가
};

이제 SelectedCard() 안의 조건문은 이렇게 간단하게 바뀌었습니다:

if (reinforcementAssign.TryGetValue(deckCard, out var list))
{
    if (reinforcementLimit.TryGetValue(cardKey, out int limit) &&
        currentAmount >= limit)
    {
        list.RemoveAll(card => card.key == cardKey);
        Debug.Log($"[SelectedCard] 강화카드 {cardKey}가 제한 {limit}회를 초과해 제거됨");
    }
}

추가 디버그 기능

개발 중 상태를 확인하기 위해 다음과 같이 선택된 카드와 현재 강화 목록을 로그로 출력했습니다:

private void ListDebug(TowerOperList deckCard, int cardKey)
{
    if (reinforcementAssign.TryGetValue(deckCard, out var assignedCards))
    {
        string keyList = $"[SelectedCard] cardKey: {cardKey} / reinforcementAssign key list: ";
        foreach (var card in assignedCards)
        {
            keyList += card.key + ", ";
        }
        Debug.Log(keyList.TrimEnd(',', ' '));
    }
}

결과 및 장점

  • 강화카드의 중복 제한이 간단하고 안정적으로 관리됩니다.
  • 카드 밸런스를 데이터 기반으로 튜닝할 수 있어 개발 효율이 올라갑니다.
  • 추후 JSON이나 외부 설정으로 카드 제한을 불러오는 구조로 확장도 가능합니다.

마무리

이번 개선을 통해 무한 강화의 문제를 효과적으로 해결하고,
유지보수가 쉬운 구조로 발전시킬 수 있었습니다.

모바일 게임 빌드 후 테스트하던 중,
상단 UI가 기기의 카메라 홀(노치)에 가려지는 문제가 발생했습니다.

특히 상단 체력 바나 버튼이 중요한데, 이 영역이 일부 기기에서는 보이지 않게 되는 큰 문제였어요.
해상도 설정으로 해결되지 않아 찾아보다가 Unity Asset Store에서
Safe Area Helper 패키지를 발견했고,
이걸로 간단하게 문제를 해결할 수 있었습니다.


문제 상황

  • 모바일 상단 UI가 카메라 홀, 노치, 소프트키 영역에 겹쳐서 보이지 않음
  • 다양한 해상도와 기기를 고려할 때 수동 Anchor 조정으로는 한계
  • 자동으로 Safe Area를 인식해서 UI를 조정할 필요가 있음

해결 방법: Safe Area Helper 사용

에셋스토어 패키지 설치

  • Unity Asset Store에서 Safe Area Helper 검색
  • 프로젝트에 임포트

적용 순서

  1. Canvas 안에 빈 오브젝트 생성
    • 이름은 SafeAreaPanel, SafeArea, 또는 원하는 대로 설정
  2. RectTransform을 풀스크린으로 설정
    • Anchor Preset을 오른쪽 하단의 stretch - stretch로 설정합니다.
  3. SafeArea 컴포넌트 추가
    • 해당 오브젝트에 SafeArea.cs 컴포넌트를 추가하면 끝!
  4. SafeArea 안에 UI 요소 배치
    • SafeArea 오브젝트 아래에 버튼, 텍스트 등 필요한 UI 요소를 넣으면
      자동으로 SafeArea 범위 안에만 표시됩니다.

적용 후 결과

  •  상단 UI가 노치에 가려지지 않음
  •  별도 코드 작성 없이 빠르게 적용 가능
  •  다양한 기기에서도 안정적인 화면 구성 유지

마무리

이전에는 직접 Screen.safeArea를 계산해서 Anchor를 일일이 조정해야 했지만,
Safe Area Helper 덕분에 빈 오브젝트 하나, 컴포넌트 하나만 붙이면 해결되었습니다.

모바일 UI가 잘리거나 겹치는 문제가 있다면,
"Canvas 안에 빈 오브젝트 하나 만들고 SafeArea 붙이기"만으로 대부분 해결됩니다!

오늘은 프로젝트 중간발표 날이었다

지금까지 만들었던 시스템을 정리하고, 발표 자료를 준비해 팀원들 앞에서 설명했다.
발표는 짧았지만, 그 과정을 통해 무엇을 만들었는지보다
어떤 생각으로 만들었고, 어떤 고민이 있었는지를 더 깊이 되돌아보는 시간이 되었다.


발표를 준비하며

처음 발표 자료를 만들 때는 지금까지 구현한 기능들을 순서대로 나열하고,
그 기능이 어떤 역할을 하는지만 간단히 소개하는 방식으로 구성했다.
발표에서도 자연스럽게 “이건 이렇게 동작합니다”, “이 기능은 이런 이유로 만들었습니다”라는 식으로 설명했다.

하지만 발표 이후 받은 피드백은 예상과 조금 달랐다.
단순히 '기능을 소개하는 발표'보다 더 중요한 건,
그 기능을 만들 때 어떤 선택을 했고, 왜 그 방법을 택했는지를 설명하는 것이라고 하셨다.

예를 들어 특정 함수를 사용한 이유, 구조를 이렇게 설계한 이유 같은 개발자의 의도가 드러날수록
청중은 “이 개발자가 단순히 만들기만 한 게 아니라, 알고 설계하고 구현했구나”라는 인상을 받는다는 것이었다.
그리고 이 관점은 취업 포트폴리오를 작성할 때도 매우 중요하다는 조언을 들었다.


앞으로의 계획

현재 구현된 가챠 시스템은 기본 기능 중심으로 구성돼 있어 연출이나 UX 요소가 부족한 상태다.
앞으로는 다음과 같은 개선을 계획하고 있다:

  • DOTween 등을 활용한 연출 강화
  • UX 측면 개선: 스킵 버튼, 결과 연출, 등급별 시각 피드백 등
  • 다른 시스템과의 연계성 확보
  • 구조 정리를 위한 리팩토링

그리고 한 달 뒤 최종 발표 때는 이번 피드백을 적극 반영하여,
단순히 기능을 보여주는 것이 아니라, 그 기능을 왜 이렇게 만들었는지를 명확히 전달하는 발표를 준비할 예정이다.


마무리

이번 중간발표는 단순한 개발 결과물 소개가 아닌,
내가 어떤 시선으로 시스템을 바라보고, 어떤 고민 속에서 구현했는지를 되돌아보게 해준 중요한 시간이었다.
이 경험을 바탕으로 다음 발표는 더 깊이 있는 내용으로 채워나가고 싶다.

타워디펜스 게임을 만들다 보면
적 유닛의 움직임이나 웨이브 진행 속도를 빠르게 하고 싶은 경우가 많습니다.
이럴 때 Time.timeScale을 활용한 배속 기능이 유용하지만,
실시간으로 흐르는 플레이 타이머까지 함께 빨라져 버리는 문제가 발생합니다.


구현 목표

  • 버튼 클릭으로 게임 배속을 1배속 ↔ 2배속으로 전환
  • 배속 여부와 무관하게 **UI 타이머(GamePlayTimer)**는 실제 시간 기준으로 작동
  • 타이머는 게임 진행 시간(분:초)을 표시

배속 버튼 구현

[SerializeField] private Button speedButton;
[SerializeField] private TextMeshProUGUI speedText;

private bool isFast = false;

private void Start()
{
    speedButton.onClick.AddListener(OnClickGameSpeedButton);
}

private void OnClickGameSpeedButton()
{
    isFast = !isFast;
    Time.timeScale = isFast ? 2f : 1f;
    speedText.text = isFast ? "x2" : "x1";
}

 

  • Time.timeScale을 통해 게임의 전체 시간 속도를 조절합니다.
  • TextMeshProUGUI로 현재 배속 상태를 사용자에게 표시합니다.

실시간 타이머 유지 (unscaledDeltaTime 사용)

private Coroutine timerCoroutine;

public void StartTimer()
{
    timerCoroutine = StartCoroutine(GamePlayTimer());
}

public void StopTimer()
{
    if (timerCoroutine != null)
    {
        StopCoroutine(timerCoroutine);
        timerCoroutine = null;
    }
}

IEnumerator GamePlayTimer()
{
    float timer = 0f;

    while (BattleManager.Instance.CurrentBattle.isBattle)
    {
        timer += Time.unscaledDeltaTime; // 실제 시간 기준으로 증가
        playTime.text = SetTimeText(timer);
        yield return null;
    }

    playTime.text = SetTimeText(timer);
}

public string SetTimeText(float time)
{
    int minutes = (int)(time / 60);
    int seconds = (int)(time % 60);
    return string.Format("{0:00}:{1:00}", minutes, seconds);
}

 

 

  • Time.unscaledDeltaTime은 Time.timeScale 영향을 받지 않으므로, 실제 시간 흐름을 그대로 측정합니다.
  • deltaTime을 사용하면 2배속 시 1초에 2초가 증가하는 문제가 발생하지만, unscaledDeltaTime을 사용하면 정확한 시간 측정이 가능하여 정확하게 게임 플레이 시간을 측정이 가능합니다.

마무리

  • Time.timeScale은 전체 게임의 시간 흐름을 조절하지만, 특정 시스템(UI 타이머 등)은 실시간 기준으로 유지해야 하는 경우가 있습니다.
  • 이럴 때는 Time.unscaledDeltaTime을 활용하면 깔끔하게 해결할 수 있습니다.
  • 또한 AudioSource.pitch 등 추가 요소에 대해 배속 적용 여부를 개별적으로 처리할 수도 있습니다.

 

문제 상황

NavMeshAgent가 적용된 적 오브젝트들이 장애물 옆을 지나갈 때 이동 속도가 갑자기 느려지거나 멈칫하는 현상이 발생했습니다.
특히 장애물의 모서리나 좁은 통로에서 이 문제가 더 자주 나타났습니다.


1차 시도 – NavMeshAgent 속성 조정

먼저 NavMeshAgent 컴포넌트의 다양한 옵션을 조절해보았습니다.

  • Angular Speed (회전 속도)
  • Acceleration (가속도)
  • Stopping Distance (정지 거리)
  • Auto Braking (자동 감속)
  • Radius (에이전트 반지름)
  • Obstacle Avoidance Quality (회피 품질)

하지만 이 설정들만으로는 문제가 해결되지 않았고, 장애물 옆에서의 감속 현상은 여전히 발생했습니다.


2차 시도 – 장애물과의 간격 확보

이번엔 장애물의 NavMeshObstacle 크기를 줄이고,
적 오브젝트의 Collider 크기도 조정하여 서로 직접적으로 닿지 않도록 간격을 확보했습니다.

그 결과,
적이 장애물 옆을 지날 때 감속 현상이 눈에 띄게 줄어들었고,
보다 부드럽게 회피 이동하는 모습을 확인할 수 있었습니다.

하지만 이 과정에서 새로운 문제가 발생했습니다.


문제 발생 – 적 오브젝트 간 충돌로 인한 밀림 현상

장애물 회피는 좋아졌지만, 이번엔 적 오브젝트들끼리 너무 가까워졌을 때 서로를 밀어내는 현상이 발생했습니다.
좁은 길목에서는 이동 경로가 꼬이거나 일부 적이 제자리를 맴도는 문제도 확인되었습니다.


최종 해결 – Layer Collision Matrix 설정

문제를 근본적으로 해결하기 위해,
Unity의 Project Settings > Physics > Layer Collision Matrix에서
적 오브젝트가 속한 레이어들 간의 충돌을 비활성화했습니다.

  • 적 오브젝트들끼리의 충돌 체크를 꺼줌으로써,
  • 물리적 충돌로 인해 밀리거나 감속되는 현상이 사라졌습니다.
  • 장애물 회피도 자연스럽게 작동하며 부드럽게 경로를 유지했습니다.

마무리

이번 문제는 단순히 NavMeshAgent 설정만으로 해결되지 않았고,
장애물의 사이즈 조정 → Collider 최적화 → 물리 충돌 설정까지
여러 레이어의 조정이 필요했던 복합적인 이슈였습니다.

Unity에서 NavMesh를 활용한 AI를 구현하신다면,
충돌 레이어 설정과 장애물 사이즈 조정도 꼭 함께 고려하시는 것을 추천드립니다.

Unity로 만든 게임을 웹에서 빠르게 테스트하거나,
외부에 배포하고 싶은 경우, itch.io는 정말 좋은 선택지가 됩니다.

저는 이번에 개발 중인 **MVP(최소 기능 제품)**와 프로토타입
직접 WebGL로 빌드해 업로드하면서,
빌드 결과를 웹에서 바로 확인하는 데에 itch.io를 활용해 보았습니다.

원래 Unity에서도 Unity Play라는 플랫폼을 통해 게임을 올릴 수 있지만,
Unity Play는 해상도 설정이 제한적이라 제가 원하는 비율(예: 세로형 9:16)로 제대로 테스트할 수 없었습니다.
그래서 직접 해상도와 임베드 설정이 가능한 itch.io를 선택하게 되었어요.

이번 글에서는
Unity WebGL 빌드부터 itch.io 업로드까지
처음 해보는 분들도 쉽게 따라 할 수 있도록 단계별로 정리해 보았습니다.
테스트용이든, 배포용이든 누구나 손쉽게 적용할 수 있습니다.


1. Unity에서 WebGL 빌드 설정

 

File > Build Settings로 들어가서 플랫폼을 WebGL로 설정해줍니다.
Switch Platform을 눌러 전환해주세요.

아래쪽 Player Settings 버튼을 클릭해서 해상도를 설정해줍니다.
세로형 게임이라면 Width: 360, Height: 640처럼 지정할 수 있습니다.

 

설정이 끝났다면 다시 Build Settings 창으로 돌아와 Build And Run 버튼을 눌러 빌드를 시작합니다.

 


2. 빌드 폴더 압축하기

빌드가 완료되면 index.html, Build 폴더 등이 생성됩니다.
이 파일들이 모두 포함된 상태로 압축(zip)해야 합니다.
주의: 압축했을 때 index.html이 최상단(루트)에 위치해야 합니다.


3. itch.io에서 새 프로젝트 생성

itch.io에 로그인 후 상단의 Dashboard를 클릭합니다.

 

Create new project를 클릭해 새 게임을 등록합니다.


4. 프로젝트 정보 입력

기본 정보들을 입력해주세요.

  • 제목, URL, 설명, 커버 사진 등은 자유롭게 작성
  • Kind of project는 반드시 HTML로 설정해야 합니다.


5. 빌드 파일 업로드

  • .zip 파일을 업로드
  • This file will be played in the browser 체크박스를 활성화 (브라우저에서 다운로드 없이 플레이 가능)

Pricing 설정

  • 기본적으로 Free 또는 No payments로 설정하면 테스트/공유용으로 적합합니다.
  •  
  • 유료 판매를 하고 싶다면 Paid를 선택하고 금액을 설정하세요.

 

 


6. 해상도 및 임베드 설정

게임 뷰포트 크기를 직접 설정할 수 있습니다.
세로형 게임이라면 360 x 640, 가로형이면 960 x 540 정도로 지정하면 좋아요.


7. 커뮤니티 및 공개 설정

Community 설정

  • Disabled : 댓글 기능을 아예 비활성화합니다.
  • Comments : 프로젝트 페이지 하단에 일반적인 댓글창이 생깁니다.
  • Discussion board : 카테고리별 게시판 형태로 피드백을 받고 싶을 때 사용합니다.

※ 개인 프로젝트나 테스트용 업로드라면 보통 Comments 정도면 충분합니다.

 

Visibility & Access 설정

  • Draft : 작성 중인 상태로, 나만 볼 수 있음
  • Restricted : 특정 계정에게만 공개 (비밀번호 설정, 협업자 등)
  • Public : 누구나 접근 가능 (공개 배포 시 필수!)

테스트 중이라면 Draft로, 배포할 준비가 되었다면 Public으로 설정해주세요.

오늘은 전에 만들었던 SoundManager씬에 따라 자동으로 BGM이 재생되는 기능을 추가해봤습니다.

게임을 만들다 보면, 로비에서는 로비 음악, 전투에서는 전투 음악을 따로 틀어줘야 하는데요.
이걸 매번 수동으로 호출하지 않고 자동으로 바뀌게 만들고 싶어서 이번 업데이트를 진행하게 되었습니다.


추가된 주요 기능

씬 이름에 따라 BGM 자동 변경

  • 새로 추가된 메서드: PlayBGMForScene(string sceneName)
  • 씬 이름(MainScenes, BattleScenes 등)에 따라 미리 정한 BGMType을 선택해서 재생합니다.
  • 만약 등록되지 않은 씬이면 콘솔에 경고 로그를 출력합니다.
public void PlayBGMForScene(string sceneName)
{
    BGMType bgmType = BGMType.None;

    switch (sceneName)
    {
        case "MainScenes":
            bgmType = BGMType.Main;
            break;
        case "BattleScenes":
        case "TestBattleScenes":
            bgmType = BGMType.Battle;
            break;
        default:
            Debug.LogWarning($"씬 {sceneName}에 대한 BGM 설정이 없습니다.");
            break;
    }

    PlayBGM(bgmType);
}

간단한 switch문으로 구성되어 있어서, 나중에 다른 씬이 추가되어도 쉽게 확장할 수 있습니다
예를 들어, 나중에 "BossScenes"가 추가되면, 여기에 한 줄만 추가하면 됩니다.


코드상 작은 개선사항

BGMType에 None 추가

  • 기본값이 필요하기 때문에 None 타입을 Enum에 추가했습니다.
  • 혹시 모르는 예외 상황(등록되지 않은 씬 등)을 대비하기 위함입니다.
public enum BGMType
{
    None,
    Main,
    Battle,
    // 필요시 추가
}

기존 코드와 이번 코드 차이 요약

항목 기존 코드 변경 코드
씬 이름에 따른 BGM 전환 직접 PlayBGM 호출 필요 자동화 메서드 추가 (PlayBGMForScene)
BGMType Enum 구성 Main, Battle만 존재 None 타입 추가
예외 처리 없음 등록되지 않은 씬 경고 출력
확장성 직접 수정해야 함 switch문에 간단 추가 가능

마무리리

이번 기능 추가를 통해,
앞으로 개발할 때 유지보수성과 확장성을 함께 고려하는 것이 얼마나 중요한지 다시 한번 느꼈습니다.

특히 작은 부분이더라도,
미리 예외 상황을 생각해두는 습관을 들이면 코드가 훨씬 튼튼해질 것 같다는 생각이 들었습니다.

앞으로도 이런 식으로 조금씩 발전하는 개발 기록을 남겨가고 싶습니다.

기존에는 TakeReward라는 클래스를 통해 적을 처치했을 때 드랍되는 아이템 보상을 관리하고 있었습니다.
해당 구조는 단순한 보상 지급만을 처리하는 형태였기 때문에 다음과 같은 문제점이 있었습니다:

  • 보상 아이템의 수량이 고정(항상 1개) 으로 처리됨
  • 경험치 보상을 아이템으로 나눠주는 기능이 없음
  • 향후 기능을 추가하거나 확장할 때 기존 구조 전체를 수정해야 하는 부담

이전에 사용한 구조가 궁금하신 분들은 아래 글을 참고하시면 됩니다.
👉 이전 보상 시스템 구조

 

[Unity] 보상 드롭 시스템 및 UI 구현

이번 프로젝트에서는 유니티 기반의 게임에서 몬스터 처치 후 발생하는 보상 아이템을 드롭 테이블 기반으로 계산하고, UI에 시각적으로 표현하는 기능을 구현하였습니다.보상 드롭 로직은 기

danpat77.tistory.com


1. 구현 목표 및 개요

이번 리팩토링에서는 다음과 같은 점을 중심으로 구조를 개선했습니다:

  • 보상 아이템의 수량을 자유롭게 지정할 수 있도록 변경
  • 경험치 수치를 기반으로 경험치 아이템을 자동으로 계산하여 보상
  • 기존의 드롭 테이블 기반 구조는 그대로 유지하면서 기능만 확장
  • UI 연동 방식은 그대로 유지해, 시스템 변경 없이 연결 가능

2. 리팩토링 전후 코드 비교

AddItem 메서드 구조

🔸 리팩토링 전 (TakeReward 클래스)

private void AddItem(int item)
{
    ItemData itemData = GameManager.Instance.Player.inventory.items[item].data;
    if (rewardList.ContainsKey(item))
    {
        rewardList[item].amount++;
    }
    else
    {
        rewardList.Add(item, new RewardItem { ItemData = itemData, amount = 1 });
    }
}

 

🔸 리팩토링 후 (RewardSystem 클래스)

private void AddItem(int item, int amount)
{
    ItemData itemData = GameManager.Instance.Player.inventory.items[item].data;
    if (rewardList.ContainsKey(item))
    {
        rewardList[item].amount += amount;
    }
    else
    {
        rewardList.Add(item, new RewardItem { ItemData = itemData, amount = amount });
    }
}

차이점 :
수량을 직접 지정할 수 있도록 amount 매개변수를 추가해
다양한 보상 케이스에 유연하게 대응할 수 있게 되었습니다.

 


경험치 보상 기능 추가

public void TakeExpItem(int exp)
{
    Dictionary<int, int> expItemDict = ExpItemCal(exp);

    foreach (var pair in expItemDict)
    {
        AddItem(pair.Key, pair.Value);
    }
}

경험치 수치를 입력하면, 자동으로 적절한 경험치 아이템으로 환산해 보상 리스트에 추가해줍니다.
반복되는 코드 없이 깔끔하게 경험치 보상을 처리할 수 있게 되었어요.


경험치 분배 알고리즘

public Dictionary<int, int> ExpItemCal(int exp)
{
    Dictionary<int, int> expItems = new Dictionary<int, int>();
    int[] expValues = { 250, 100, 50, 20 };
    int[] itemKeys = { 9103, 9102, 9101, 9100 };

    for (int i = 0; i < expValues.Length; i++)
    {
        int count = exp / expValues[i];
        if (count > 0)
        {
            expItems[itemKeys[i]] = count;
            exp %= expValues[i];
        }
    }

    if (exp > 0)
    {
        if (expItems.ContainsKey(9100))
            expItems[9100] += 1;
        else
            expItems[9100] = 1;
    }

    return expItems;
}

기능 설명:

  • 경험치를 큰 단위부터 순차적으로 나누어 아이템화
  • 남은 경험치는 무조건 20짜리 경험치 아이템(9100) 1개로 처리
  • 잔여 경험치도 버려지지 않도록 100% 보상 처리 가능

 


3. 마무리

이번 리팩토링에서는 단순히 코드를 정리하는 데 그치지 않고,
앞으로 다양한 보상 시스템을 추가할 수 있는 확장성까지 고려해 구조를 다듬었습니다.

특히 경험치 보상과 수량 지정 기능이 추가되면서
전투 외에도 퀘스트 보상, 이벤트 보상, 성장 보상 등 다양한 영역에서도 재활용 가능해졌다는 점이 가장 만족스러웠습니다.

앞으로도 기능을 설계할 때는 "확장 가능성" 을 항상 염두에 두고 시작해야겠다는 생각이 들었던 작업이었습니다

게임을 만들다 보면 일정 시간만 화면에 나타나는 오브젝트들이 반복적으로 생성되고 삭제되는 상황이 자주 발생합니다.
예를 들어 다음과 같은 것들이 있습니다:

  • 적 처치 시 드랍되는 골드나 경험치
  • 총알이나 이펙트 등 짧게 등장하는 오브젝트들

처음에는 Instantiate()와 Destroy()를 반복해서 사용했지만,
이 방식은 성능 저하와 메모리 낭비, 그리고 GC 문제로 이어졌습니다.

그래서 이번엔 이를 해결하기 위해 오브젝트 풀링(Object Pooling) 시스템을 직접 구현해 보았습니다.


구현 목표 및 개요

  • 매번 Instantiate() 없이 오브젝트를 재사용
  • 오브젝트 타입별로 구분된 풀을 관리할 수 있는 구조
  • 새로운 타입의 오브젝트가 생겨도 쉽게 확장 가능한 구조 설계

코드 구현

ObjectType 정의

public enum ObjectType
{
    EXP,
    Gold,
    TowerBullet,
}

풀을 타입별로 구분하기 위해 enum을 사용했습니다.
이렇게 하면 코드 가독성이 좋아지고, 확장성도 좋아집니다.


초기화 - 풀 생성

protected override void Awake()
{
    base.Awake();
    foreach (GameObject prefab in prefabs)
    {
        PoolObjectType info = prefab.GetComponent<PoolObjectType>();
        if (info != null && !pools.ContainsKey(info.objectType))
        {
            pools[info.objectType] = new Queue<GameObject>();
        }
    }
}

Inspector에 등록된 프리팹들을 기준으로 타입별로 큐를 초기화합니다.
PoolObjectType 스크립트는 각 프리팹이 어떤 타입인지 알려주는 역할을 합니다.


오브젝트 꺼내기 - GetObject()

public GameObject GetObject(ObjectType type, Vector3 position, Quaternion rotation)
{
    if (!pools.ContainsKey(type)) return null;

    GameObject obj;
    if (pools[type].Count > 0)
    {
        obj = pools[type].Dequeue();
    }
    else
    {
        GameObject prefab = GetPrefabByType(type);
        obj = Instantiate(prefab);
        obj.GetComponent<IPoolable>()?.Initialize(o => ReturnObject(type, o));
    }

    obj.transform.SetPositionAndRotation(position, rotation);
    obj.SetActive(true);
    obj.GetComponent<IPoolable>()?.OnSpawn();
    return obj;
}

오브젝트가 풀에 남아 있다면 꺼내서 재사용하고,
없으면 새로 생성해서 Initialize()를 호출한 후 등록해 줍니다.


오브젝트 반납 - ReturnObject()

public void ReturnObject(ObjectType type, GameObject obj)
{
    if (!pools.ContainsKey(type))
    {
        Destroy(obj);
        return;
    }

    obj.SetActive(false);
    pools[type].Enqueue(obj);
}

사용이 끝난 오브젝트는 SetActive(false)로 비활성화하고 다시 큐에 넣습니다.
다음에 다시 꺼내서 쓰게 됩니다.


마무리

풀링 시스템은 성능 최적화뿐 아니라 구조적인 유지보수에도 큰 도움이 되었습니다.
enum과 인터페이스 조합으로 확장성 있는 구조를 만들 수 있었던 점이 인상 깊었습니다.
앞으로 자동 반환이나 초기화 개수 설정 등 추가 기능도 도전해볼 예정입니다.

이번에는 사운드를 체계적으로 관리하기 위해
AudioMixer를 기반으로 한 BGM / SFX 분리형 사운드 매니저를 제작해봤습니다.

슬라이더로 실시간 볼륨 조절도 가능하고,버튼으로 음소거/해제 시 아이콘이 바뀌는 기능까지 구현했습니다.


구현 목표 및 개요

 

  • BGM과 SFX를 AudioMixer를 통해 개별 제어
  • 슬라이더로 실시간 볼륨 조절
  • 음소거 버튼 클릭 시 아이콘 변경
  • 씬 전환에도 BGM 유지
  • 슬라이더 값 0일 경우에도 자동으로 음소거 아이콘 반영

1. AudioMixer 설정

  1. MainMixer 생성
    Assets > Create > Audio > Audio Mixer
  2. Group 분리
    Master 하위에 BGM, SFX 그룹 생성
  3. Volume 파라미터 Expose
    각 그룹의 Volume 슬라이더 → 우클릭 → Expose to script
    • 이름: BGMVolume, SFXVolume
  4. AudioSource 연결
    오디오 출력용 AudioSource 2개 생성 후 각각 Output에 BGM / SFX 연결

2.구현된 기능

2-1. 슬라이더 볼륨 조절

Mathf.Log10(volume) * 20을 통해 AudioMixer가 기대하는 dB 기준으로 정확한 제어

2-2. 음소거 버튼

음소거 버튼 클릭 시 -80dB 설정, 다시 누르면 이전 볼륨 복원

2-3. 아이콘 자동 전환

  • 음소거 상태이면 🔇
  • 슬라이더 값 0일 경우도 🔇
  • 음량이 살아 있으면 🔊

2-4. 씬 전환 후에도 유지

DontDestroyOnLoad() 적용으로 BGM 유지


3. 핵심 코드 정리

3-1. 사운드 타입 enum 정의

public enum BGMType
{
    Main,
    Battle,
    // 필요시 확장 가능
}

public enum SFXType
{
    ButtonClick,
    Gacha,
    TurretShoot,
    Explosion,
    EnemyDie
}
  • BGMType과 SFXType으로 사운드 종류를 구분합니다.
  • 이후 코드에서 PlayBGM(BGMType.Main)처럼 사용하면, 오타 없이 안전하게 사운드를 관리할 수 있습니다.

3-2. SoundManager.cs (요약본)

public class SoundManager : Singleton<SoundManager>
{
    [SerializeField] private AudioSource bgmSource;
    [SerializeField] private AudioSource sfxSource;
    [SerializeField] private AudioMixer audioMixer;

    private float lastBgmVolume = 0.8f;
    private float lastSfxVolume = 0.8f;
    private bool isBgmMuted = false;
    private bool isSfxMuted = false;

    public bool IsBGMMuted => isBgmMuted;
    public bool IsSFXMuted => isSfxMuted;

    private Dictionary<BGMType, AudioClip> bgmDict = new();
    private Dictionary<SFXType, AudioClip> sfxDict = new();

    public void PlayBGM(BGMType type, bool loop = true)
    {
        if (bgmDict.TryGetValue(type, out var clip))
        {
            bgmSource.clip = clip;
            bgmSource.loop = loop;
            bgmSource.Play();
        }
    }

    public void PlaySFX(SFXType type)
    {
        if (sfxDict.TryGetValue(type, out var clip))
            sfxSource.PlayOneShot(clip);
    }
}

 

  • Dictionary<BGMType, AudioClip> 구조로 관리되기 때문에, 사운드 키의 충돌이나 중복 위험 없이 효율적으로 접근할 수 있습니다.
  • enum을 쓰면 코드 자동완성도 지원되기 때문에, 관리가 매우 편리해집니다.

3-3. UI 연동 예시 - SoundController.cs

private void UpdateIcon(Image icon, float volume, bool isMuted)
{
    bool isEffectivelyMuted = isMuted || volume <= 0.0001f;
    icon.sprite = isEffectivelyMuted ? iconSoundOff : iconSoundOn;
}

 

  • 슬라이더 값이 0이 되거나 음소거 상태일 경우 자동으로 아이콘 변경
  • UI와 논리 상태를 명확히 연결해주는 역할을 합니다

4. 적용 화면 예시

 

 

  • 슬라이더를 조절하면서 소리가 즉시 반응
  • 버튼 클릭으로 음소거 및 복구
  • 음소거 상태에 따라 아이콘도 자동 전환

마무리

번 작업에서는 단순히 소리를 내는 기능을 넘어서,
볼륨 상태 관리, UI 반응, 재생 상태 유지
게임 전반에서 필요한 사운드 구조를 체계적으로 구성해볼 수 있었습니다.

특히 SoundManager와 SoundController의 역할을 분리하면서
UI와 로직의 책임을 나누는 연습도 되었습니다.

앞으로도 이 구조를 기반으로 씬별 BGM 전환, 페이드 효과, 스테이지 클리어 시 효과음 재생 등으로 확장할 수 있을 것 같아 기대가 됩니다.

 

 

+ Recent posts