이번에는 사운드를 체계적으로 관리하기 위해
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 전환, 페이드 효과, 스테이지 클리어 시 효과음 재생 등으로 확장할 수 있을 것 같아 기대가 됩니다.

 

 

이번 프로젝트에서는 유니티 기반의 게임에서 몬스터 처치 후 발생하는 보상 아이템을 드롭 테이블 기반으로 계산하고, UI에 시각적으로 표현하는 기능을 구현하였습니다.
보상 드롭 로직은 기존에 제작해두었던 가챠 시스템의 가중치 기반 추첨 알고리즘을 응용하여 구성하였으며, 사용자에게 시각적으로 직관적인 보상 결과를 제공하는 것을 목표로 하였습니다.


구현 목표 및 개요

  • 몬스터 처치 시 확률 기반 보상 아이템을 계산
  • 드롭된 아이템을 RewardItem 형태로 저장
  • UI에 정렬된 형태로 표시
  • 수량이 1개인 보상 아이템은 수량 텍스트를 생략

1. 보상 아이템 저장 구조

드롭된 보상 아이템은 다음과 같은 RewardItem 클래스를 통해 정의됩니다.

public class RewardItem
{
    public ItemData ItemData;
    public int amount;
}

보상 아이템은 Dictionary 형태로 저장되며, 동일한 아이템이 중복 드롭될 경우 수량을 누적합니다.


2. 보상 드롭 처리 (RewardSystem 클래스)

TakeReward 클래스에서는 드롭 테이블 정보를 기반으로 아이템을 무작위 추출하고, 해당 정보를 보상 리스트에 누적합니다.

public void DropItem()
{
    int dropItemKey = GetDropItemKey(dropTableKey);
    AddItem(dropItemKey);
}

아이템 추가 처리:

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 });
    }
}

3. 가중치 기반 추첨 알고리즘 적용

보상 아이템 추출은 기존에 구현한 가챠 시스템의 가중치 기반 랜덤 추첨 구조를 그대로 재활용하였습니다.
이 로직은 DropEntryTable에서 설정된 확률 값을 기반으로 추첨이 이루어집니다.

public int GetDropItemKey(int dropTableKey)
{
    var filtered = dropEntryTable
        .Where(e => e.dropTable == dropTableKey)
        .OrderBy(e => Random.value) // 무작위 정렬을 통해 동일 확률 시 편향 제거
        .ToList();

    float totalWeight = filtered.Sum(e => e.weight);
    float rand = Random.Range(0, totalWeight);
    float cumulative = 0f;

    foreach (var entry in filtered)
    {
        cumulative += entry.weight;
        if (rand <= cumulative)
            return entry.item;
    }

    return -1;
}
더보기

해당 추첨 로직은 이전에 구현한 가챠 시스템과 동일한 구조를 활용한 것으로, 코드 재사용성과 확장성 측면에서 효율적입니다.


4. 보상 UI 구성

사용자에게 보상 결과를 명확하게 전달하기 위해, 드롭된 아이템은 UI 상에 정렬된 슬롯 형태로 출력됩니다.

4-1. RewardUI

보상 리스트를 순회하며 프리팹으로 UI 슬롯을 생성합니다.

public void SetRewardUI(Dictionary<int, RewardItem> rewardList)
{
    foreach (var rewardItem in rewardList.OrderBy(r => r.Key))
    {
        RewardSlots rewardSlots = Instantiate(rewardSlotsPrefabs, spwonPoint);
        rewardSlots.SetData(rewardItem.Value);
    }
}

4-2. RewardSlots

슬롯 UI 내에 아이콘 및 수량을 설정합니다.

public void SetData(RewardItem rewardItem)
{
    if (rewardItem == null || rewardItem.ItemData == null)
    {
        Debug.LogError("RewardItem 또는 ItemData가 null입니다.");
        return;
    }

    if (rewardItem.ItemData.icon != null)
        itemIcon.sprite = rewardItem.ItemData.icon;

    int amount = rewardItem.amount;
    itemAmountText.text = amount.ToString();

    if (amount == 1)
        itemAmountText.enabled = false;
}

5. 실제 적용 결과 화면

아래는 전투 종료 후 보상 UI가 출력되는 실제 게임 화면입니다.

 

  • 보상 아이템은 Key 기준 오름차순으로 정렬되어 출력됨
  • 아이콘 및 수량이 명확히 표시되며, 수량이 1개일 경우 숫자 텍스트는 생략됨
  • UI는 전투 결과 화면과 함께 자연스럽게 통합되어 있음

마무리

이번 보상 시스템 구현 과정에서는 기존에 제작한 가중치 추첨 로직을 구조화하여 재활용하는 것의 중요성을 체감하였습니다.
드롭 테이블 구조를 데이터 중심으로 분리하고, UI는 독립적으로 처리한 결과, 유지보수와 확장성이 모두 향상되었습니다.

 

Unity로 UI 작업을 하다가 예상치 못한 정렬 문제를 겪었습니다.
바로 GridLayoutGroup을 사용했을 때, 마지막 줄의 아이템들이 자동으로 중앙 정렬되지 않는 문제였습니다.

처음에는 제 설정이 잘못된 줄 알았는데, 조사해보니 GridLayoutGroup 자체가 마지막 줄에 대한 정렬 보정 기능을 제공하지 않는 구조라는 걸 알게 되었습니다.
그래서 이 문제를 직접 해결하기 위해, 간단한 커스텀 레이아웃 스크립트를 작성하게 되었습니다.


문제 상황

예를 들어 columnCount = 3으로 설정하고 10개의 아이템을 배치하면 아래와 같은 형태가 됩니다.

 

하지만 기본 GridLayoutGroup은 마지막 줄(여기선 아이템 1개)을 왼쪽 정렬해버립니다.
아이템 수에 따라 마지막 줄이 1개, 2개처럼 애매하게 남을 경우 정렬이 어색하게 보이는 문제가 발생합니다.


시도했던 해결 방법

처음에는 Child Alignment 옵션을 바꿔보거나, 콘텐츠 사이즈 피터를 수정해보기도 했습니다.
하지만 정렬 기준은 전체 영역 기준이고, 마지막 줄만 따로 정렬하는 기능은 지원되지 않았습니다.

StackOverflow나 공식 포럼도 찾아봤지만, 명확한 해결책보다는 "직접 커스텀해야 된다"라는 식의 조언이 많았습니다.


해결 방법: 직접 커스텀 레이아웃 구현

결국 GridLayoutGroup을 쓰지 않고, RectTransform을 직접 계산해서 정렬하는 스크립트를 만들었습니다.
구현 방향은 다음과 같습니다:

  • columnCount 기준으로 위치를 수동 계산
  • 마지막 줄인지 판단 후, 그 줄만 가운데 정렬
  • 전체 정렬 방식은 상단 기준 또는 중앙 기준 선택 가능
  •  

주요 기능

  • 열 개수(columnCount) 고정
  • 셀 크기, 간격 설정 가능
  • 마지막 줄만 자동 가운데 정렬
  • 상단 정렬 / 중앙 정렬 선택 지원 (isMiddleAlign 설정)
  • 에디터에서도 실시간 반영 ([ExecuteAlways] + OnValidate)

적용 결과

적용 전 적용 후

코드

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

/// <summary>
/// GridLayoutGroup처럼 자식 RectTransform을 그리드 형태로 정렬하는 커스텀 레이아웃 컴포넌트입니다.
/// 자식 오브젝트는 Pivot이 (0.5, 0.5)로 고정되어 있어야 하며,
/// 마지막 줄은 자식 수에 맞춰 중앙 정렬됩니다.
/// </summary>
[ExecuteAlways] // 에디터에서도 동작하게 만듭니다.
public class CustomGridLayout : MonoBehaviour
{
    public Vector2 cellSize = new Vector2(100, 100); // 각 셀의 너비와 높이
    public Vector2 spacing = new Vector2(10, 10);    // 셀 사이의 간격
    public int columnCount = 3;                      // 고정 열 개수
    public bool isMiddleAlign = false;               // true면 세로 중앙 정렬, false면 상단 정렬

    private RectTransform rectTransform;

    /// <summary>
    /// 오브젝트가 활성화될 때 자동으로 레이아웃을 갱신합니다.
    /// </summary>
    private void OnEnable()
    {
        rectTransform = GetComponent<RectTransform>();
        UpdateLayout();
    }

    /// <summary>
    /// 자식 Transform이 추가/삭제되면 레이아웃을 자동 갱신합니다.
    /// </summary>
    private void OnTransformChildrenChanged()
    {
        UpdateLayout();
    }

    /// <summary>
    /// 인스펙터에서 속성을 변경했을 때 자동 갱신합니다 (에디터 전용).
    /// </summary>
    private void OnValidate()
    {
        UpdateLayout();
    }

    /// <summary>
    /// 자식들을 그리드 형태로 정렬합니다.
    /// </summary>
    public void UpdateLayout()
    {
        if (rectTransform == null)
            rectTransform = GetComponent<RectTransform>();

        int childCount = rectTransform.childCount;

        // 활성화된 자식만 필터링합니다.
        var activeChildren = new System.Collections.Generic.List<RectTransform>();
        for (int i = 0; i < childCount; i++)
        {
            var child = rectTransform.GetChild(i) as RectTransform;
            if (child != null && child.gameObject.activeSelf)
                activeChildren.Add(child);
        }

        int actualCount = activeChildren.Count;
        if (actualCount == 0) return;

        // 전체 줄 수 계산
        int rowCount = Mathf.CeilToInt((float)actualCount / columnCount);

        // 전체 높이 계산 (spacing 포함)
        float totalHeight = (cellSize.y + spacing.y) * rowCount - spacing.y;
        Vector2 parentSize = rectTransform.rect.size;

        // Y축 기준 offset (세로 정렬: 상단 또는 중앙)
        float baseOffsetY = isMiddleAlign
            ? -(parentSize.y - totalHeight) / 2f  // MiddleCenter 정렬
            : 0f;                                  // UpperCenter 정렬

        // 자식 오브젝트마다 위치 계산
        for (int i = 0; i < actualCount; i++)
        {
            RectTransform child = activeChildren[i];

            int row = i / columnCount; // 몇 번째 줄
            int col = i % columnCount; // 몇 번째 열

            // 마지막 줄 여부 판별
            bool isLastRow = row == rowCount - 1;

            // 마지막 줄의 아이템 개수 (중앙 정렬 계산에 필요)
            int itemsInLastRow = actualCount % columnCount;
            if (itemsInLastRow == 0) itemsInLastRow = columnCount;

            // 현재 줄의 실제 열 개수
            int effectiveColCount = isLastRow ? itemsInLastRow : columnCount;

            // 현재 줄의 전체 너비
            float rowWidth = (cellSize.x + spacing.x) * effectiveColCount - spacing.x;

            // X축 중앙 정렬 오프셋
            float offsetX = (parentSize.x - rowWidth) / 2f;

            // 중심 정렬 기준 위치 계산
            float x = (cellSize.x + spacing.x) * col + offsetX + cellSize.x * 0.5f;
            float y = -((cellSize.y + spacing.y) * row) + baseOffsetY - cellSize.y * 0.5f;

            // 위치 및 크기 설정
            child.anchorMin = new Vector2(0, 1); // 부모 좌상단 기준
            child.anchorMax = new Vector2(0, 1);
            child.pivot = new Vector2(0.5f, 0.5f); // 반드시 중앙 고정
            child.anchoredPosition = new Vector2(x, y);
            child.sizeDelta = cellSize;
        }
    }
}

사용 시 주의사항

  • 자식 오브젝트의 Pivot은 반드시 (0.5, 0.5) 로 설정해야 위치가 정확하게 계산됩니다.
  • RectTransform을 기준으로 하기 때문에 UI 요소 외 일반 GameObject에는 사용되지 않습니다.
  • 런타임과 에디터 모두에서 동작하지만, 편의상 OnEnable, OnValidate, OnTransformChildrenChanged 이벤트에만 연동했습니다.

마무리

이번 문제를 겪으면서 Unity의 기본 UI 시스템이 의외로 단순한 구조라는 점을 느꼈습니다.
그동안은 기본 제공 컴포넌트만으로 대부분 해결될 거라 생각했지만, 실제로 사용해보니 세세한 부분에서는 손이 많이 가는 일이 많았습니다.

특히 마지막 줄만 중앙 정렬하는 기능처럼, 사소하지만 사용자 경험에 영향을 주는 요소는
직접 구현해보는 과정을 통해 Unity 내부 동작 방식을 조금씩 이해하게 되었습니다.
비록 코드가 완벽하진 않더라도, 문제를 정의하고 나만의 방식으로 해결해보는 경험이 저에게는 큰 공부가 되었습니다.

 

게임에서 뽑기 시스템은 플레이어의 몰입도를 극대화하는 핵심 메커니즘 중 하나입니다.
이번 글에서는 Unity에서 2단계 확률 기반 가챠 시스템을 설계하고 구현하는 방법을 소개합니다.


시스템 개요

이 가챠 시스템은 다음과 같은 구조로 구성됩니다:

  1. 1단계: 제작 방식(일반, 고급, 특수 등)을 확률에 따라 결정
  2. 2단계: 선택된 제작 방식에 따라 세부 결과(동일 오퍼레이터, 상위 등급 등)를 다시 확률 기반으로 결정

이 방식은 단계별로 결과를 분기할 수 있어, 일반적인 단일 룰 방식보다 확장성과 다양성이 뛰어납니다.


예시 데이터 구조

실제 프로젝트와 헷갈리지 않도록 예시용 테이블로 구성하였습니다.

🔹 1단계: 제작 타입 선택 테이블

Key category weight desc
20001 normal 70 일반 제작
20002 advanced 20 고급 제작
20003 special 10 특수 제작

🔹 2단계: 세부 결과 분기 테이블

Key category resultType weight desc
30001 20001 same_operator 15 동일 오퍼레이터 등장
30002 20001 higher_tier 25 상위 등급 등장
30003 20001 material_difference 60 재료 조합 변화
30004 20002 same_operator 20 동일 오퍼레이터 등장
30005 20002 higher_tier 30 상위 등급 등장
30006 20002 material_difference 50 재료 조합 변화
30007 20003 special_case 100 특수 제작 고정 결과

코드 구현 (작동 흐름 설명 포함)

🔸 데이터 구조 정의

public enum ProductionType { normal, advanced, special }
public enum ResultType { same_operator, higher_tier, material_difference, special_case }

[System.Serializable]
public class ProductionEntry
{
    public int key;
    public ProductionType category;
    public float weight;
    public string desc;
}

[System.Serializable]
public class ResultEntry
{
    public int key;
    public int productionKey;
    public ResultType resultType;
    public float weight;
    public string desc;
}

 

 

  • 제작 방식과 결과 분기를 각각 별도의 클래스로 관리
  • enum을 사용해 가독성과 유지보수성 확보

🔸 1단계 가챠: 제작 방식 선택

public int GetRandomProductionKey(ProductionType category)
{
    var filtered = productionTable.Where(e => e.category == category).ToList();
    float totalWeight = filtered.Sum(e => e.weight);
    float rand = Random.Range(0, totalWeight);
    float cumulative = 0f;

    foreach (var entry in filtered)
    {
        cumulative += entry.weight;
        if (rand <= cumulative)
            return entry.key;
    }

    return -1;
}

 

 

  • category에 해당하는 제작 방식 리스트에서
  • 가중치 기반으로 하나의 제작 ID(key)를 무작위로 선택합니다.
  • 예시:
    • rand가 85일 경우:
      • 첫 항목(70)보다 크므로 패스
      • 두 번째 항목(70+20=90)이 되면 85 <= 90 이 항목이 뽑힘

🔸 2단계 가챠: 결과 분기 결정

public ResultEntry GetRandomResult(int productionKey)
{
    var filtered = resultTable.Where(e => e.productionKey == productionKey).ToList();
    float totalWeight = filtered.Sum(e => e.weight);
    float rand = Random.Range(0, totalWeight);
    float cumulative = 0f;

    foreach (var entry in filtered)
    {
        cumulative += entry.weight;
        if (rand <= cumulative)
            return entry;
    }

    return null;
}

 

 

  • 1단계에서 선택된 제작 ID에 따라 다시 세부 결과를 가중치 기반으로 무작위 선택합니다.

🔸 전체 실행 흐름

public void ExecuteGacha(ProductionType category)
{
    int productionKey = GetRandomProductionKey(category);
    var result = GetRandomResult(productionKey);

    Debug.Log($"제작 방식: {category}, 선택된 제작 ID: {productionKey}");
    Debug.Log($"결과: {result.resultType} (key: {result.key})");
}

 


마무리

이 포스트에서는 Unity에서 2단계 확률 기반 가챠 시스템을 설계하고 구현하는 과정을 다뤘습니다.
단순한 뽑기 시스템을 넘어서 구조적이며 확장 가능한 설계 방식을 도입하면, 운영 편의성과 밸런싱 조정 모두에 유리한 기반을 마련할 수 있습니다.

현재는 Inspector에서 수동으로 설정한 테이블을 사용하고 있지만,
다음 단계에서는 JSON 기반의 외부 테이블을 불러와 가챠 데이터를 동적으로 구성할 계획입니다. 이를 통해 실제 서비스 환경에서도 실시간 데이터 수정이 가능하도록 만들 예정입니다.

이번 포스트에서는 Unity UI에서 좌우로 슬라이드 전환되는 패널 전환 시스템을 구현한 과정을 소개합니다.
DOTween을 활용해 부드럽고 직관적인 전환 효과를 만들 수 있었고, 좌우 방향도 자연스럽게 처리해 사용성을 높였습니다.


목표

여러 개의 UI 패널을 Next, Prev 버튼으로 슬라이드 전환할 수 있게 만들고 싶었습니다.
전환 시 화면을 가득 채우는 전체 패널이 부드럽게 좌우로 움직이며 자연스럽게 교체되는 UI를 구현하는 것이 목표였습니다.


완성된 결과 미리보기

버튼 클릭 시, 다음 패널로 자연스럽게 전환되는 모습을 확인할 수 있습니다.

(실제 UI 구현 장면 – GIF )


구현 방식

핵심 기능 요약

  • RectTransform[] panels 배열에 전환할 UI 패널들을 담아 관리
  • currentIndex로 현재 보여지고 있는 패널 인덱스를 추적
  • DOTween을 이용해 anchoredPosition을 변경하며 슬라이드 애니메이션 처리
  • 양쪽 끝에서는 순환되도록 인덱스 범위 자동 조절

주요 코드


1. 기본 변수 선언 및 초기화

public RectTransform[] panels; // 전환 대상 패널들
public float slideDuration = 0.5f; // 슬라이드 애니메이션 지속 시간

[SerializeField] private int currentIndex = 0; // 현재 활성화된 패널 인덱스
private bool isSliding = false; // 슬라이딩 중 여부 체크

[SerializeField] private Button nextButton;
[SerializeField] private Button prevButton;

2. 버튼 이벤트 등록

private void Start()
{
    currentIndex = 0;
    SetOnClickButton();
}

private void SetOnClickButton()
{
    // 좌우 버튼 클릭 시 ShowPanel 호출
    nextButton.onClick.AddListener(() => ShowPanel(currentIndex - 1));
    prevButton.onClick.AddListener(() => ShowPanel(currentIndex + 1));
}

3. ShowPanel() - 전환 애니메이션의 핵심

public void ShowPanel(int index)
{
    // 슬라이딩 중이거나 같은 패널이면 무시
    if (index == currentIndex || isSliding) return;

    // 인덱스 범위 벗어나면 순환 처리
    if (index >= panels.Length) index = 0;
    else if (index < 0) index = panels.Length - 1;

    isSliding = true;

    RectTransform fromPanel = panels[currentIndex];
    RectTransform toPanel = panels[index];

    // 패널 순서 조정 (새 패널을 뒤에 배치)
    fromPanel.transform.SetAsLastSibling();
    toPanel.transform.SetAsFirstSibling();

    // 이동 방향 계산 (왼쪽/오른쪽)
    Vector2 panelPosition = currentIndex > index ? new Vector2(Screen.width, 0) : new Vector2(-Screen.width, 0);

    // 새 패널 위치 지정 및 활성화
    toPanel.gameObject.SetActive(true);
    toPanel.anchoredPosition = panelPosition;

    // DOTween 애니메이션
    fromPanel.DOAnchorPos(-panelPosition, slideDuration).SetEase(Ease.OutCubic);
    toPanel.DOAnchorPos(Vector2.zero, slideDuration).SetEase(Ease.OutCubic)
        .OnComplete(() =>
        {
            // 이전 패널 비활성화 및 리셋
            fromPanel.gameObject.SetActive(false);
            fromPanel.anchoredPosition = Vector2.zero;
            currentIndex = index;
            isSliding = false;
        });
}

구현 시 고민했던 포인트

  • 인덱스 자동 순환: 양쪽 끝에서도 끊기지 않고 루프되도록 처리
  • 중복 클릭 방지: isSliding으로 애니메이션 중 동작 중첩 방지
  • 슬라이드 방향: 현재 인덱스 기준으로 좌우 방향 자동 계산

마무리

이번 패널 전환 시스템은 단순하지만, 사용자 경험을 직관적으로 개선하는 핵심적인 UI 요소입니다.
DOTween을 이용하면 코드량도 줄이고, 애니메이션 퀄리티도 높일 수 있어 추천드립니다.

번에는 카드 뽑기 시스템 중
SetRandomCards()SelectedCard() 메서드를 리팩터링했습니다.

복잡했던 중복 체크 로직과 카드 선택 흐름을 간결하게 정리해,
코드를 읽기 쉽게 만들고, 안정성도 함께 개선했습니다.


1. SetRandomCards() 로직 개선

기존 문제점

  • 선택할 카드를 고를 때,
    selectedCardList를 직접 for문으로 돌며 하나하나 중복 여부를 확인했습니다.
  • 코드가 복잡하고 길어 가독성이 떨어졌습니다.
  • 강화 카드(copyCardKeyList)를 사용할 때 흐름이 명확하지 않았습니다.

개선 전 코드

for (int i = 0; i < battleCardList.Count; i++)
{
    int randomNum = Random.Range(0, selectTowerCardList.Count);
    TowerOperList selectData = selectTowerCardList[randomNum];

    if (selectedCardList == null)
    {
        battleCardList[i].SetInfo(selectData, cardKeyList[0]);
        selectTowerCardList.RemoveAt(randomNum);
    }
    else
    {
        bool test = false;
        for (int j = 0; j < selectedCardList.Count; j++)
        {
            if (selectedCardList[j].Equals(selectData))
            {
                test = true;
                int randomCardNum = Random.Range(0, copyCardKeyList.Count);
                battleCardList[i].SetInfo(selectData, cardKeyList[randomCardNum]);
                copyCardKeyList.RemoveAt(randomCardNum);
            }
        }
        if (!test)
        {
            battleCardList[i].SetInfo(selectData, cardKeyList[0]);
            selectTowerCardList.RemoveAt(randomNum);
        }
        test = false;
    }
}
  • 리스트를 직접 순회하며 중복을 확인
  • test 플래그를 따로 관리해야 해서 코드가 복잡해짐

개선 방향

  • List.Contains()로 중복 여부를 간단히 확인
  • 카드 키 결정 과정을 명확하게 분리
  • 카드 복사도 new List<T>로 간결화

개선 후 코드

public void SetRandomCards()
{
    CopyLists();
    AddCardSlots();

    for (int i = 0; i < battleCardList.Count; i++)
    {
        if (selectTowerCardList.Count == 0)
            break;

        int randomIndex = Random.Range(0, selectTowerCardList.Count);
        TowerOperList selectedData = selectTowerCardList[randomIndex];
        selectTowerCardList.RemoveAt(randomIndex);

        if (selectedCardList.Contains(selectedData))
        {
            if (copyCardKeyList.Count > 0)
            {
                int randomCardKeyIndex = Random.Range(0, copyCardKeyList.Count);
                int bonusCardKey = copyCardKeyList[randomCardKeyIndex];
                copyCardKeyList.RemoveAt(randomCardKeyIndex);

                battleCardList[i].SetInfo(selectedData, bonusCardKey);
            }
        }
        else
        {
            battleCardList[i].SetInfo(selectedData, 4000); // 기본 카드
        }
    }
}

 


개선 효과

  • 중복 여부 판단을 간단하게 처리할 수 있게 되었습니다.
  • 카드 키 결정 흐름이 깔끔해졌습니다.
  • 코드 길이와 복잡성이 크게 줄었습니다.

2. SelectedCard() 메서드 개선

기존 문제점

  • 새 카드를 선택할 때,
    직접 for문을 돌면서 리스트에 중복 여부를 검사했습니다.
  • test 플래그 변수를 별도로 관리해야 했습니다.

개선 전 코드

bool test = false;
if (selectedCardList == null)
{
    selectedCardList.Add(data);
}
else
{
    for (int i = 0; i < selectedCardList.Count; i++)
    {
        if (selectedCardList[i].Equals(data))
        {
            test = true;
            return;
        }
    }
    if (!test)
    {
        selectedCardList.Add(data);
    }
}
test = false;
  • 복잡한 조건문과 for문 사용
  • 의미 없는 플래그 변수 관리 필요

개선 방향

  • List.Contains()를 사용해 한 줄로 간단하게 처리
  • 불필요한 플래그 제거

개선 후 코드

public void SelectedCard(TowerOperList data)
{
    if (!selectedCardList.Contains(data))
    {
        selectedCardList.Add(data);
    }
}

개선 효과

  • 한 줄 코드로 중복 체크가 가능해졌습니다.
  • 코드 가독성이 크게 향상되었습니다.
  • 실수 가능성을 줄이고, 유지보수성을 높였습니다.

최종 정리

항목  개선 전 개선 후
SetRandomCards() 직접 for문을 돌며 중복 확인 Contains() 사용으로 간단하게 중복 확인
SelectedCard() 플래그 변수 + for문 사용 Contains()로 한 줄 처리

마무리

이번 리팩터링을 통해
카드 뽑기 시스템의 흐름이 훨씬 깔끔하고 직관적으로 바뀌었습니다.

특히,

  • 카드 선택 시 중복 체크
  • 카드 정보 세팅
  • 강화 카드 처리 흐름

이 부분들이 명확해지면서,
추후 카드 종류 추가나 시스템 확장 작업이 훨씬 수월해질 기반을 만들 수 있었습니다.


앞으로 작업 예정

  • 엑셀 데이터와 연동하여 CardKey를 관리하는 방식으로 전환할 예정입니다.
  • 카드 강화 효과도 직접 수치나 설명을 설정할 수 있도록 확장할 계획입니다.

이 과정을 통해,
디자인 → 데이터 입력 → 게임 적용이 자연스럽게 이어지는
운영 자동화 구조를 목표로 하고 있습니다.

개요

게임 내에서 플레이어가 편성한 포탑 카드들 중 일부를 랜덤으로 뽑아 화면에 보여주는 기능을 구현했습니다.
중복 없이 무작위로 선택하고, 선택된 카드를 카드 UI에 적용하는 것이 목표였습니다.


문제 상황

처음에는 단순히 Random.Range를 사용해서 리스트에서 랜덤으로 골라오는 방식으로 접근했지만,
같은 카드가 중복으로 선택되는 문제가 발생했습니다.

또한, 무작위 선택 후에도 **원본 리스트(towerCardList)**는 그대로 유지되어야 했기 때문에,
직접 원본을 수정할 수 없는 상황이었습니다.


해결 방법

1. 원본 리스트 복제

먼저, 무작위로 뽑기 전에 편성된 카드 리스트(towerCardList)를 복제합니다.
이렇게 하면 복제한 리스트(selectTowerCardList)에서 자유롭게 삭제하거나 조작할 수 있습니다.

public void CopyList()
{
    selectTowerCardList.Clear();
    foreach (var data in towerCardList)
    {
        selectTowerCardList.Add(data);
    }
}

2. 랜덤으로 카드 뽑기

복제한 리스트에서 Random.Range를 사용해 인덱스를 랜덤으로 뽑고,
선택된 카드를 UI에 적용한 뒤, 리스트에서 제거하는 방식으로 중복 선택을 방지합니다.

public void SetRandomCards()
{
    CopyList(); // 먼저 리스트 복제
    isFirst = false;
    TowerOperList selectData;

    for (int i = 0; i < battleCardList.Count; i++)
    {
        int randomNum = Random.Range(0, selectTowerCardList.Count);

        selectData = selectTowerCardList[randomNum];
        battleCardList[i].SetInfo(selectData);

        selectTowerCardList.RemoveAt(randomNum); // 선택한 카드는 리스트에서 제거
    }
}

이렇게 하면 매번 새로운 카드 조합이 안정적으로 생성됩니다.


마무리

  • 원본 리스트를 복제해 작업하면 원본 데이터를 보호할 수 있습니다.
  • 랜덤으로 선택 후 제거하는 방식으로 중복을 방지할 수 있습니다.
  • 매번 CopyList → SetRandomCards 순서로 호출하면, 새로운 랜덤 조합을 만들 수 있습니다.

Unity에서 게임을 만들다 보면, SceneManager.LoadScene()으로 씬을 이동시킨 뒤에
바로 어떤 작업(예: BattleManager.Instance.StageStart())을 실행하려고 할 때 문제가 생기는 경우가 있습니다.

 

문제
씬 이동 코드를 작성했는데, 이동 직후 실행하고 싶은 함수가 제대로 작동하지 않는다.
예를 들면, Battle 씬으로 넘어간 직후 StageStart()를 호출했는데 아무 반응이 없거나,
NullReferenceException(널 참조 오류)이 발생한다.


문제의 원인

SceneManager.LoadScene()을 호출하면 씬 로딩이 즉시 완료되는 것이 아닙니다.
로딩이 완료되기 전에 바로 다음 코드가 실행되기 때문에,
새로운 씬에 있는 오브젝트나 매니저가 아직 준비되지 않은 상태일 수 있습니다.

결과적으로,

  • BattleManager.Instance가 아직 생성되지 않았거나,
  • 씬에 필요한 세팅이 완료되지 않아서 예상한 동작이 일어나지 않는 문제가 발생합니다.

해결 방법

핵심
"씬 로드가 끝난 후"
StageStart()를 호출해야 한다는 것입니다.

이걸 해결하는 방법은 대표적으로 두 가지가 있습니다.


방법 1. SceneManager.sceneLoaded 이벤트 사용하기

Unity는 씬이 다 로드된 순간에 SceneManager.sceneLoaded 이벤트를 발생시킵니다.
이 이벤트를 이용해 StageStart를 호출하면 됩니다.

예시 코드

using UnityEngine;
using UnityEngine.SceneManagement;

public class SceneLoader : MonoBehaviour
{
    public void LoadBattleScene()
    {
        SceneManager.sceneLoaded += OnSceneLoaded; // 씬 로드 완료 이벤트 등록
        SceneManager.LoadScene("TestBattleScenes");
    }

    private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
    {
        if (scene.name == "TestBattleScenes")
        {
            BattleManager.Instance.StageStart();
            SceneManager.sceneLoaded -= OnSceneLoaded; // 이벤트 등록 해제 (중복 방지)
        }
    }
}

주의할 점

  • sceneLoaded는 모든 씬 로드에 반응합니다.
    → 원하는 씬(TestBattleScenes)일 때만 StageStart를 실행해야 합니다.
  • 작업이 끝나면 이벤트 해제를 반드시 해주세요.
    → 메모리 누수나 예기치 않은 버그를 예방할 수 있습니다.

방법 2. 코루틴 + LoadSceneAsync 사용하기

또 다른 방법은 코루틴을 사용해서 씬 로드 완료를 직접 기다리는 방법입니다.

예시 코드

using UnityEngine;
using UnityEngine.SceneManagement;
using System.Collections;

public class SceneLoader : MonoBehaviour
{
    public void LoadBattleScene()
    {
        StartCoroutine(LoadSceneAndStartStage());
    }

    private IEnumerator LoadSceneAndStartStage()
    {
        AsyncOperation asyncLoad = SceneManager.LoadSceneAsync("TestBattleScenes");

        while (!asyncLoad.isDone)
        {
            yield return null;
        }

        BattleManager.Instance.StageStart();
    }
}

주의할 점

  • LoadSceneAsync는 씬을 비동기로 로드합니다.
  • asyncLoad.isDone가 true가 될 때까지 while 루프로 기다립니다.
  • 이 방법은 로딩바, 로딩 애니메이션 등을 추가할 때도 매우 유용합니다.

정리

항목 이벤트 방식 (sceneLoaded) 코루틴 방식 (LoadSceneAsync)
장점 이벤트 기반, 정확함 직관적, 확장성 좋음
주의사항 이벤트 해제 필수 코루틴 관리 필요
추천 상황 간단한 처리 로딩 UI나 진행률 표시 필요할 때

 


결론

만약 "씬 이동 후 메소드 호출이 안 된다" 같은 문제가 발생했다면,
"씬 로드 완료 시점"을 제대로 잡아주는 것이 문제 해결의 핵심입니다.

씬 로드 완료를 감지하는 방법으로는

  • SceneManager.sceneLoaded 이벤트를 이용하는 방법
  • 코루틴과 LoadSceneAsync를 이용하는 방법
    이 있습니다.

코루틴 방식을 사용하면
로딩 화면이나 진행률 표시 같은 추가 기능도 쉽게 구현할 수 있기 때문에
실전 개발에서는 코루틴 방식을 추천합니다.

이번에는 Unity 프로젝트에서 GameManager를 만들어, 게임에 필요한 다양한 매니저들을 하나로 관리하는 시스템을 구축해봤습니다.


개요

게임을 만들다 보면 DataManager, BattleManager, EnemyManager 같은 다양한 매니저 객체들이 필요합니다.
초반에는 매니저마다 따로 만들고 따로 관리해도 괜찮지만, 규모가 커질수록 매니저끼리 의존성이 생기고, 초기화 순서가 꼬이기 시작합니다.

이 문제를 해결하기 위해, 모든 매니저를 하나의 GameManager를 통해 초기화하고 관리하는 시스템을 만들었습니다.


코드

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

public class GameManager : Singleton<GameManager>
{
    public DataManager DataManager { get; private set; }
    public TowerManager TowerManager { get; private set; }
    public TileManager TileManager { get; private set; }
    public BattleManager BattleManager { get; private set; }
    public StageManager StageManager { get; private set; }
    public EnemyManager EnemyManager { get; private set; }
    public Player Player { get; private set; }

    protected override void Awake()
    {
        Init();
    }

    private void Init()
    {
        DataManager = new DataManager();
        TowerManager = FindObjectOfType<TowerManager>();
        TileManager = FindObjectOfType<TileManager>();
        BattleManager = FindObjectOfType<BattleManager>();
        StageManager = FindObjectOfType<StageManager>();
        EnemyManager = FindObjectOfType<EnemyManager>();
        Player = new Player();

        DataManager.Initialize();
        TowerManager.Initialize();
        TileManager.Initialize();
        BattleManager.Initialize();
        StageManager.Initialize();
        EnemyManager.Initialize();
        Player.Initialize();
    }
}

 

  • GameManager는 Singleton 패턴을 상속받아 하나만 존재하게 했습니다.
  • 각 매니저들을 public 프로퍼티로 가져와서 다른 스크립트에서도 쉽게 접근할 수 있도록 했습니다.
  • Init() 함수에서 각 매니저들을 초기화하고 준비시킵니다.

주의할 점

  • FindObjectOfType<>()로 찾는 매니저들은 씬에 미리 배치되어 있어야 합니다.
  • DataManager처럼 직접 new로 생성하는 경우도 있습니다.

"왜 new를 쓰고 왜 FindObjectOfType을 쓰는 이유

 

  • new로 만드는 매니저 → 그냥 C# 객체. Unity Scene에 존재할 필요 없음. (데이터/로직 관리용)
  • FindObjectOfType로 찾는 매니저 → Unity GameObject 컴포넌트. Scene에 존재해야 함. (시각/게임플레이 관리용)

 


사용하는 방법

다른 스크립트에서는 이렇게 사용하면 됩니다:

 

 
GameManager.Instance.BattleManager.StartBattle();
GameManager.Instance.DataManager.LoadData();

GameManager 하나만 있으면 나머지 매니저들에 접근이 가능해지니까, 관리가 훨씬 편해집니다.


마무리

아직 완성형은 아니지만, 기본 골격은 갖췄습니다.
필요한 매니저가 추가될 때마다 GameManager에 연결만 해주면 되고, 초반 프로젝트 구조를 안정적으로 잡는 데 도움이 될 것 같습니다.

개요

처음에는 ScriptableObject를 활용해 적 능력치를 관리했지만,
점점 게임 데이터가 많아지고 관리가 어려워지면서,
JSON 파일을 통한 데이터 관리로 리팩토링을 진행했다.

이번 포스팅에서는 기존 방식과 새 방식의 차이점, 그리고 수정된 적 자동 생성 시스템을 소개하겠습니다.


기존 방식: ScriptableObject를 이용한 능력치 관리

초기에는 적마다 별도로 ScriptableObject를 만들어서 능력치를 세팅했다.

[SerializeField] private List<EnemySO> enemySOList;
  • EnemySO 안에 체력, 공격력 등의 데이터가 들어있고
  • 적 오브젝트는 이 EnemySO를 참조해서 능력치를 가져왔다.

장점: 직관적이고 에디터에서 쉽게 수정할 수 있음
단점: 적 개수가 많아지면 관리가 복잡하고, 빌드에 모든 데이터가 포함되어 최적화에 불리함

 


변경된 방식: JSON 파일을 이용한 능력치 관리

이제는 DataManager를 통해 JSON 파일에서 적 데이터를 읽어오고,
딕셔너리로 관리하는 방식으로 바꿨다.

EnemyDataDict = GameManager.Instance.DataManager.EnemyDataLoader.EnemyDict;
  • EnemyDataLoader가 JSON 파일을 읽고 EnemyDict에 데이터를 저장
  • EnemyDict는 key(id)를 기준으로 적 데이터(EnemyData)를 빠르게 찾을 수 있다.

구조 예시

{
  "1000": { "hp": 100, "attack": 10, "speed": 5 },
  "2000": { "hp": 300, "attack": 20, "speed": 3 }
}

주요 수정 사항

1. 적 오브젝트에 데이터 부여

적 프리팹 리스트(enemyModlePrefabs)를 돌면서,
EnemyDataDict에서 읽어온 데이터로 능력치를 설정한다.

public void GetEnemyData(List<int> key)
{
    for (int i = 0; i < key.Count; i++)
    {
        for (int j = 0; j < enemyModlePrefabs.Count; j++)
        {
            if (enemyModlePrefabs[j].gameObject.name == EnemyDataDict[key[i]].key.ToString())
            {
                enemyModlePrefabs[j].GetComponent<EnemyState>().SetStatus(EnemyDataDict[key[i]]);
                enemyPrefabs.Add(enemyModlePrefabs[j]);
            }
        }
    }
}
  • 프리팹 이름과 데이터 키를 비교해서 연결
  • 연결된 프리팹을 enemyPrefabs 리스트에 저장

정리

비교 ScriptableObject 방식 JSON + Dictionary 방식
장점 에디터 친화적 대규모 데이터 관리에 강함
단점 데이터 증가 시 관리 부담 초기 설정 코드 필요
추천 상황 소규모 프로젝트 데이터가 많거나 서버 연동 예정일 때

마치며

이번 리팩토링으로 적 데이터 관리가 훨씬 유연해졌고,

앞으로 다양한 적 추가 및 밸런스 조정이 훨씬 편해질 것으로 기대된다.

앞으로 남은 작업이나 개선 사항도 계속 정리해서 올릴 예정입니다.

+ Recent posts