Unity/Blue Archive 2차 창작 프로젝트

[Unity- Operation Kivotos] 4. 장비 시스템

Vfly 2026. 3. 20. 23:19

https://github.com/vfly1189/OperationKivotos

 

GitHub - vfly1189/OperationKivotos: 유니티 공부용 창작 프로젝트

유니티 공부용 창작 프로젝트. Contribute to vfly1189/OperationKivotos development by creating an account on GitHub.

github.com


서론

이전 글에서 비동기로딩 구조를 어느정도 완성시키고 이번에는 컨텐츠쪽을 한번 구현해보기로 했다. 

 

처음에는 블루아키이브의 장비시스템을 그대로 가져오는 방식을 생각했었다. 전용무기+장비3종 구조에서 장비3종을 1티어~10티어까지 성장시키는 방식을 생각했었는데.... 뭔가 뭔가 그냥 뭔가 맘에 안들었다. 딱히 이유는 없는데 뭔가 재미가 그렇게 있을거같진 않았다.

 

그래서 최근 서브컬쳐쪽에서 많이 사용하는 성유물 시스템을 한번 구현해보기로 했다.

 

정확히 성유물 시스템이 뭐냐?

 

아래는 붕괴스타레일의 예시다

 

장비를 획득하면 부위마다 고정 MainStat과 랜덤 SubStat 3~4종류가 붙은 채로 드랍된다. 강화는 최대 +15까지 가능하며, 강화할 때마다 MainStat은 고정 수치로 성장하고, +3 / +6 / +9 / +12 / +15 구간에 도달할 때마다 SubStat들이 추가로 성장하는 구조다.

핵심은 같은 부위, 같은 등급의 장비라도 붙는 SubStat의 종류와 수치는 매번 랜덤하다는 점이다. 덕분에 단순히 장비를 획득하는 것으로 끝나지 않고, "좋은 옵션이 붙은 장비를 파밍 → 강화 → 불필요한 장비는 분해해 재료로 환산 → 다시 강화에 투자" 하는 순환 루프가 형성된다. 이 구조가 장비 파밍 콘텐츠의 반복 플레이 동기를 만들어내는 핵심 설계 포인트다.

이번 포트폴리오 프로젝트에서는 이 구조를 직접 분석하고, 인벤토리 / 무기 강화 / 전술장비 강화 / 장비 분해 시스템으로 구현했다. 각 시스템의 흐름과 기술적 설계를 아래에 정리한다.


0. 데이터 파이프라인

데이터 파이프라인 — Excel → ScriptableObject → DataManager


왜 이 구조를 선택했나

장비 스탯, 강화 비용, 레벨별 경험치 등 밸런스 데이터는 개발 중에도 수시로 수정이 생긴다. 이 값들을 코드에 직접 하드코딩하거나 JSON으로만 관리하면, 수치 하나를 바꿀 때마다 코드를 열어야 한다.

그래서 Excel에서 데이터를 편집하고 → NPOI 라이브러리로 파싱해서 → ScriptableObject(.asset)로 변환하는 방식을 선택했다. 기획 데이터를 코드와 완전히 분리할 수 있고, 수치 조정은 Excel만 수정한 뒤 변환 버튼 한 번으로 반영된다.

📊 Excel (.xlsx)
     ↓  NPOI 파싱 (Editor Tool)
📦 ScriptableObject (.asset)
     ↓  Addressables 비동기 로드
🗄️ DataManager (Dictionary 캐싱)
     ↓  GetData<TKey, TValue>(key)
⚙️ Service / UI 클래스

 

 

사실 기획쪽에서 이런식으로 엑셀을 정리하는지는 잘 모르겠지만 나름대로 한번 속성들을 나눠서 장비에 대한 정보들을 엑셀의 형태로 정리해 뒀다.

 

강화 상한선, 초기 SubStat 개수 같은 등급별 규칙이 모두 데이터로 관리되기 때문에, 새로운 등급을 추가하거나 밸런스를 조정할 때 코드를 전혀 건드리지 않아도 된다.

 

DataManager — 제네릭 캐싱 구조

DataManager는 게임 시작 시 모든 SO 데이터를 Dictionary<Type, object> 하나에 캐싱한다. 조회는 타입을 키로 사용하는 제네릭 메서드 하나로 통일했다.

// 초기화 — UniTask.WhenAll로 모든 데이터 병렬 로드
public async UniTask InitAsync()
{
    var tasks = new List<UniTask>();
    tasks.Add(LoadAndCacheSOAsync<SOItemDatabase>("ItemDatabase"));
    tasks.Add(LoadAndCacheSOAsync<SOWeaponEnhanceCostTable>("WeaponEnhanceCostTable"));
    // ... 기타 데이터
    await UniTask.WhenAll(tasks);
}

// SO 로드 → dataDicts에 캐싱
private async UniTask LoadAndCacheSOAsync<T>(string addressableKey)
    where T : ScriptableObject, IDataCacheable
{
    T soData = await Managers.Resource.LoadAsync<T>(addressableKey);
    soData?.CacheData(dataDicts); // SO 스스로 자신의 Dictionary를 등록
}

// 타입 기반 조회 — 어디서든 같은 인터페이스로
public TValue GetData<TKey, TValue>(TKey key) where TValue : class
{
    if (dataDicts.TryGetValue(typeof(TValue), out object dictObj))
    {
        var dict = dictObj as Dictionary<TKey, TValue>;
        if (dict != null && dict.TryGetValue(key, out TValue data))
            return data;
    }
    return null;
}


Service 클래스에서 데이터를 쓸 때는 타입만 알면 어디서든 바로 꺼낼 수 있는 구조다.

// 강화 비용 조회
var costData = Managers.Data.GetData<int, WeaponEnhanceCost>(targetLevel);

// 등급별 최대 레벨 조회
var gradeConfig = Managers.Data.GetData<ItemGrade, GradeConfig>(grade);

// 장비 분해 결과 조회
var decomposeData = Managers.Data.GetData<int, EquipmentDecompositionData>(tier);

 

GetData<TKeyTValue> 하나로 어떤 데이터든 동일한 방식으로 접근할 수 있기 때문에, 새 데이터 타입을 추가해도 DataManager 자체를 수정할 필요가 없다.

 

이 구조의 실질적인 이점

  • 밸런스 수정이 빠르다 — 수치 변경은 Excel만 열면 된다. 코드 수정 없이 변환만 재실행하면 반영된다.
  • 조회 코드가 단순하다 — 모든 데이터가 GetData<TKey, TValue> 하나로 통일돼 있어서, 어떤 시스템이든 같은 방식으로 데이터를 가져온다.
  • 게임 시작 시 병렬 로드 — UniTask.WhenAll로 모든 SO를 동시에 비동기 로드해서 초기화 시간을 최소화했다.

1. 장비 생성

인벤토리, 강화, 분해 시스템을 설명하기 전에, 먼저 장비가 어떻게 만들어지는지를 정리할 필요가 있다.

장비를 드랍하거나 지급할 때 단순히 itemID만 저장하는 게 아니라, 그 시점에 MainStat과 SubStat을 랜덤으로 결정해서 EquipmentInstance에 박아넣는다. 이 역할을 담당하는 것이 EquipmentFactory다.

public static InventorySlot CreateEquipment(int itemID)
{
    EquipmentData data = Managers.Data.GetData<int, EquipmentData>(itemID);

    InventorySlot newSlot = new InventorySlot();
    newSlot.itemID = itemID;
    newSlot.Amount = 1;
    newSlot.EquipInstance = new EquipmentInstance();

    ApplyMainStat(data, newSlot.EquipInstance);   // MainStat 결정
    ApplySubStats(data, newSlot.EquipInstance);   // SubStat 결정

    return newSlot;
}

 

호출하는 쪽에선느 itemID 하나만 넘기면 스탯이 붙은 완성된 InventorySlot이 반환된다.

 

MainStat 결정 - 가중치 기반 랜덤

MainStat은 장비 부위마다 고정된 StatPool에서 가중치(Weight) 에 따라 하나가 선택된다.

예를 들어 Tier 1 장갑(PoolID: 1102)은 AttackFlat ( 깡 공격력 )  고정이지만, Tier 1 신발(PoolID: 1103)은 아래처럼 5종류가 가중치로 경쟁한다.

StatType      | Weight | BaseValue
MaxHPPercent  |  260   |  5%
AttackPercent |  260   |  5%
DefensePercent|  260   |  6%
CritRate      |  110   |  3%
CritDamage    |  110   |  5%

 

private static StatPoolEntry PickRandomEntryByWeight(List<StatPoolEntry> entries)
{
    int totalWeight = 0;
    foreach (var entry in entries) totalWeight += entry.Weight;

    int randomValue = UnityEngine.Random.Range(1, totalWeight + 1);
    int currentWeight = 0;

    foreach (var entry in entries)
    {
        currentWeight += entry.Weight;
        if (randomValue <= currentWeight) return entry;
    }
    return null;
}

 

Weight가 높을수록 뽑힐 확률이 높아지므로, CritRate / CritDamage처럼 강력한 스탯은 낮은 Weight를 부여해서 자연스럽게 희소성을 조절할 수 있다.

 

SubStat 결정 - 등급별 개수 +  중복 방지

1. 개수는 등급으로 결정된다.

GradeConfig 데이터에서 등급별 초기 SubStat 개수를 읽어온다. 코드에 하드코딩하지 않고 데이터로 관리하기 때문에, 등급 추가나 개수 조정은 Excel 수정만으로 처리된다.

int subStatCount = Managers.Data
    .GetData<ItemGrade, GradeConfig>(data.Grade).InitialSubStatCount;
// Common → 0개, Rare → 2개, Epic → 3개, Legendary·Mythic → 4개

 

2. 같은 스탯이 중복으로 붙지 않는다.

가중치 풀에서 하나를 뽑을 때마다 그 항목을 availableEntries에서 제거해서, 동일한 스탯이 두 번 붙는 경우를 막는다.

List<StatPoolEntry> availableEntries = new List<StatPoolEntry>(subPool.Entries);

for (int i = 0; i < subStatCount; i++)
{
    StatPoolEntry picked = PickRandomEntryByWeight(availableEntries);
    instance.SubStats.Add(new StatOption {
        StatType = picked.StatType,
        Value = picked.BaseValue
    });
    availableEntries.Remove(picked); // 중복 방지
}

 

생성 결과 — InventorySlot 구조

EquipmentFactory.CreateEquipment()가 반환하는 InventorySlot의 내부 구조는 이렇다.

InventorySlot
├─ itemID : 10014  (Rare 장갑 Tier5)
├─ Amount : 1
└─ EquipmentInstance
    ├─ UpgradeLevel : 0
    ├─ CurrentExp : 0
    ├─ MainStats : [ AttackFlat = 25.0 ]
    └─ SubStats  : [ MaxHPPercent = 7.5%,
                     CritRate = 5.0%  ]     ← Rare이므로 2개

 

EquipmentInstance는 강화 레벨, 경험치, 스탯 정보를 모두 담고 있어서 이후 강화 시스템에서 이 인스턴스를 직접 수정하는 방식으로 레벨업을 처리하게 된다.


2. 인벤토리

전체 구조

인벤토리는 장비 / 소비 / 재료 3개의 탭으로 구성된다. 탭별로 ItemCategory enum을 키로 사용하는 Dictionary 하나에 슬롯 배열을 저장해서 관리한다.

 

// InventoryManager
public Dictionary<ItemCategory, InventorySlot[]> Inventory { get; private set; }

// 초기화 시 탭별로 슬롯 배열 생성
foreach (ItemCategory category in Enum.GetValues(typeof(ItemCategory)))
{
    InventorySlot[] slots = new InventorySlot[maxSlotCount]; // 50슬롯
    for (int i = 0; i < slots.Length; i++)
        slots[i] = new InventorySlot();
    Inventory[category] = slots;
}

 

탭 전환은 단순히 어떤 ItemCategory 배열을 렌더링할지 바꾸는 것으로 처리되기 때문에, 탭 구조가 늘어나도 슬롯 UI 자체는 재사용된다.

 

InventorySlot 구조

슬롯 하나가 들고 있는 데이터는 이렇게 생겼다.

[Serializable]
public class InventorySlot
{
    public int itemID;
    public int Amount;
    public EquipmentInstance EquipInstance; // 장비 전용, 나머지는 null

    public bool IsEmpty => itemID == 0 && Amount == 0;
    public bool IsEquipment => itemID >= 10000 && itemID <= 19999;
}

 

IsEquipment는 ID 범위로 장비 여부를 판단한다. 장비는 같은 종류라도 스탯이 다를 수 있기 때문에 EquipmentInstance를 별도로 들고 있고, 재료나 소비 아이템은 EquipInstance가 null인 채로 Amount만 쌓인다.

 

앞으로 이 InventorySlot 타입을 이용해서 Service와 UI간에 데이터를 주고 받을 것이다.

 

아이템 추가 - 스택 처리

AddItem()은 기존 슬롯에 먼저 채우고, 그래도 남으면 빈 슬롯을 찾아 새로 생성하는 방식으로 처리한다.

public void AddItem(int itemID, ItemCategory category, int amount)
{
    int maxStack = GetMaxStack(itemID, category); // DataManager에서 MaxStack 조회
    int remaining = amount;

    // 1단계: 기존 슬롯에 공간이 있으면 채우기
    foreach (var slot in Inventory[category])
    {
        if (slot.itemID == itemID && slot.Amount < maxStack)
        {
            int spaceLeft = maxStack - slot.Amount;
            if (remaining <= spaceLeft) { slot.Amount += remaining; remaining = 0; break; }
            else { slot.Amount = maxStack; remaining -= spaceLeft; }
        }
    }

    // 2단계: 남은 수량은 빈 슬롯에 추가
    while (remaining > 0)
    {
        int emptyIndex = FindEmptySlot(category);
        if (emptyIndex == -1) break; // 슬롯 부족
        Inventory[category][emptyIndex].itemID = itemID;
        Inventory[category][emptyIndex].Amount = Mathf.Min(remaining, maxStack);
        remaining -= maxStack;
    }

    OnInventoryUpdated?.Invoke(category);
}

 

MaxStack은 하드코딩하지 않고 DataManager에서 아이템 데이터를 읽어온다. 강화재료 책은 9999개, 장비는 1개로 자동 제한된다.

슬롯 UI - ItemSlotHandler 인터페이스

처음에 ItemSlot이 가지는 클릭, 더블클릭, 드래그 등등 여러 이벤트들을 해당 ItemSlot이 사용되는 UI에 따라서 동작되는게 다르게 하려면 어떤식으로 해야될까? 고민이 있었다. 그 방법이 아래와 같다.

 

슬롯 UI(UI_ItemSlot)는 클릭, 더블클릭, 드래그, 툴팁 이벤트를 핸들러 인터페이스로 위임한다. 

public interface IItemSlotHandler
{
    void OnSlotClicked(UIItemSlot slot);
    void OnSlotDoubleClicked(UIItemSlot slot);
    void OnSlotDrop(UIItemSlot from, UIItemSlot to);
    void OnSlotPointerEnter(UIItemSlot slot, Vector2 screenPos);
    void OnSlotPointerExit(UIItemSlot slot);
}

 

각 패널이 IItemSlotHandler를 구현하고 슬롯에 주입하면, 슬롯은 어떤 패널에 속해 있는지 신경 쓰지 않고 이벤트만 전달한다.

UIItemSlot (슬롯 프리팹)
    → 클릭/드래그 감지
    → handler.OnSlotClicked(this) 호출
         ↓
    UI_Inventory : IItemSlotHandler  → 장착 처리
    UI_RelicUpgradePanel : IItemSlotHandler  → 강화 대상 선택
    UI_EquipmentDecomposePanel : IItemSlotHandler  → 분해 목록 등록

 

 

더블클릭 장착 - EquipmentManager

장비 슬롯을 더블클릭하면 EquipmentManager.Equip()이 호출된다. 같은 부위에 이미 장착된 장비가 있으면 자동으로 교체된다.

더블클릭
    ↓
EquipmentManager.Equip(inventoryIndex)
    ├─ DataManager에서 EquipmentData 조회 → 부위(EquipType) 확인
    ├─ 기존 장착 장비 있음? → UnEquip() → 인벤토리로 반환
    ├─ 새 장비 equippedItem[EquipType]에 저장
    └─ 파티원 전체 CharacterStat에 StatModifier 적용

 

물론 뒤에서 설명하겠지만 장비창에서 더블클릭해도 인벤토리로 돌아간다.

 

장착/해제 시 StatModifier를 장비 인스턴스 자체를 source로 지정해서 추가하고, 해제 시에는 해당 source에서 온 Modifier를 전부 제거한다. 장비 교체 시 수치 중복 적용이 발생하지 않는다.

// 장착
targetStat.AddModifier(new StatModifier(value, modType, equipment)); // source = equipment

// 해제
targetStat.RemoveAllModifiersFromSource(equipment); // source 기준으로 일괄 제거

 

StatModifier는 캐릭터 Stat과 관련된 부분으로 나중에 따로 설명하도록 하겠다.

 

툴팁

마우스를 슬롯에 올리면 IItemSlotHandler.OnSlotPointerEnter()를 통해 Managers.UI.ShowItemTooltip()이 호출된다. itemID와 ItemCategory로 DataManager에서 아이템 정보를 꺼내 툴팁 UI를 구성하는 방식이라, 슬롯 자체는 툴팁의 존재를 모른다.

 

인벤토리의 흐름

장비 획득 (EquipmentFactory)
    ↓
InventoryManager.AddEquipmentSlot()
    ↓
OnInventoryUpdated 이벤트 → UI 갱신
    ↓
슬롯 렌더링 (UIItemSlot)
    ├─ 마우스 오버 → 툴팁
    ├─ 드래그 → Ghost + Swap
    └─ 더블클릭 → 장착/교체 + StatModifier 적용

 


3. 무기 강화

무기쪽은 던전클리어나 사냥을 통해 획득 하는 방식이 아닌 각 캐릭터마다 고유한 무기 자체를 이미 가지고 있고, 파밍을 통해 얻은 강화석 재료들을 이용해 강화를 지속적으로 해나가는 방식이다.

 

왜 획득하는 방식이 아니냐면......... 캐릭터마다 무기 리소스가 하나밖에 없다............

 

물론 무기 자체 이미지가 그런것들은 많지만 시각적으로 보여지는 3D 모델이랑 2D 이미지간에 차이가 심해지기 때문에 무기 하나를 꾸준히 강화해나가는 방식을 채택했다.

 

 

전체 구조

무기강화는 전술장비 강화와 달리 확률 기반 시스템이다. 재화와 재료를 소모하고 강화를 시도하면, 미리 정해진 성공 확률에 따라 레벨업이 결정된다.

 

데이터형식내용

강화 성공 확률 JSON 레벨별 성공 확률 (EnhancementRateData)
강화 비용 / 성장치 Excel → SO 레벨별 필요 재화·재료, ATK/HP/CritRate/CritDmg 성장값

 

 

무기 데이터 구조

엑셀 파일에 캐릭터별 무기의 기본 스탯과 레벨별 성장치가 들어있다.

WeaponStatTable
intID  | stringID      | WPNName        | OwnerCharID | BaseATK | BaseHP | BaseCritRate | BaseCritDmg
2001   | wpnShiroko    | WHITE FANG 465 | 1001        | 50      | 200    | 0.05         | 0.5

WeaponGrowthTable
Level | GrowthATK | GrowthHP | GrowthCritRate | GrowthCritDmg
1     |   0       |   0      |   0.000        |   0.000
5     |  39       | 138      |   0.020        |   0.040
10    | 142       | 472      |   0.062        |   0.124
25    | 811       | 2554     |   0.300        |   0.600

 

기본 스탯에 레벨별 성장치를 더하는 구조라서, 강화 레벨이 오를수록 4가지 스탯이 동시에 성장한다.


​강화 비용 구조

WeaponEnhanceCostTable에는 레벨별로 소모되는 골드와 재료가 정의되어 있다. 재료는 3종류(Tier 1/2/3 강화석)이며, 레벨이 올라갈수록 더 높은 등급의 재료가 추가로 요구된다.

TargetLevel | RequireGold | Material1(Tier1석) | Material2(Tier2석) | Material3(Tier3석)
2           |  200        |  2                 |  0                 |  0
10          |  495        |  4                 |  1                 |  0   ← Lv10부터 Tier2 등장
18          | 1226        |  9                 |  2                 |  1   ← Lv18부터 Tier3 등장
25          | 2710        | 18                 |  5                 |  3

 

 

강화 UI 흐름

패널 진입 시 파티원 Portrait가 표시되고, Portrait를 클릭하면 해당 캐릭터의 무기 정보가 오른쪽에 갱신된다.

Portrait 클릭
    ↓
WeaponData 조회 (현재 레벨, 최대 레벨 확인)
    ├─ 최대 레벨 도달 → MAX 표시, 강화 버튼 비활성화
    └─ 강화 가능 → WeaponEnhanceCostTable 조회
           ↓
    UI 갱신
    ├─ 현재 레벨 / 다음 레벨 스탯 미리보기
    ├─ 성공 확률 (EnhancementRateData)
    ├─ 필요 재화·재료
    └─ 보유량과 비교 → 부족한 항목 빨간색 표시
           ↓
    강화하기 클릭
    ├─ 재화·재료 소모
    └─ Random.value < successRate ?
           ├─ 성공 → WeaponLevel++, 성장치 적용
           └─ 실패 → 소모만 됨

 

 

WeaponUpgradeService 핵심 로직

강화 시도 시 흐름은 크게 세 단계다.

 

1. 비용 소모 시도

재화나 재료 중 하나라도 부족하면 소모 자체를 하지 않는다.

bool TryConsumeWeaponEnhanceCost(string weaponID, int targetLevel)
{
    var costData = Managers.Data.GetData<int, WeaponEnhanceCost>(targetLevel);

    // 골드 확인 및 소모
    if (!Managers.Wallet.ConsumeCurrency(CurrencyType.Gold, costData.RequireGold))
        return false;

    // 재료 확인 및 소모 (Material1~3)
    if (!Managers.Inventory.ConsumeMaterial(costData.Material1ID, costData.Material1Count))
        return false;
    // Material2, Material3도 동일...

    return true;
}

 

2. 확률 판정

var rateData = Managers.Data.GetData<int, EnhancementRateData>(currentLevel);

if (UnityEngine.Random.value < rateData.SuccessRate)
{
    // 성공 → 레벨업 + 성장치 적용
    weaponInstance.Level++;
    ApplyGrowthStat(weaponInstance);
}
else
{
    // 실패 → 소모만 됨
}

 

3. 성장치 적용

 

레벨업 성공 시 WeaponGrowthTable에서 해당 레벨의 성장치를 읽어 BaseATK, BaseHP, CritRate, CritDmg에 누적한다.

void ApplyGrowthStat(WeaponInstance instance)
{
    var growth = Managers.Data.GetData<int, WeaponGrowthData>(instance.Level);
    instance.ATK  = weaponBase.BaseATK  + growth.GrowthATK;
    instance.HP   = weaponBase.BaseHP   + growth.GrowthHP;
    instance.CritRate  = weaponBase.BaseCritRate  + growth.GrowthCritRate;
    instance.CritDmg   = weaponBase.BaseCritDmg   + growth.GrowthCritDmg;
}

 

전술장비 강화와의 차이점

무기 강화를 먼저 설명한 이유기도 한데, 두 시스템은 구조적으로 완전히 다르다.

방식 확률 기반 성공/실패 경험치 누적 레벨업
재료 전용 강화석 강화재료책 or 다른 장비 자체
실패 소모만 되고 레벨 유지 실패 개념 없음
스탯 성장 ATK/HP/CritRate/CritDmg 4종 고정 성장 MainStat 고정 성장 + SubStat 랜덤 성장
데이터 출처 JSON(확률) + Excel(비용/성장치) Excel 전용

 

왜 확률 데이터는 JSON으로 관리했나

강화 비용이나 성장치는 "레벨 하나 = 행 하나"의 테이블 구조라서 Excel이 자연스럽다. 반면 확률 데이터는 추후 보너스 재료, 천장 시스템, 조건부 확률 같은 요소가 붙으면 중첩 구조로 변할 가능성이 높다. 이런 계층형 데이터를 Excel 행/열로 억지로 표현하면 오히려 가독성이 떨어진다.

또한 확률 데이터는 서비스 중에도 밸런스 이슈로 긴급 수정이 필요한 경우가 생길 수 있다. Excel → SO 파이프라인은 수정 후 빌드와 배포까지 거쳐야 반영되지만, JSON 파일은 서버에 올려두고 클라이언트가 실행 시 내려받는 구조로 전환하면 빌드 없이 즉시 반영할 수 있다. 비용이나 성장치처럼 자주 바뀌지 않는 데이터는 SO로 굳혀두고, 확률처럼 민감하게 조정될 수 있는 데이터는 JSON으로 분리해두는 것이 이 구조를 선택한 이유다.


4. 전술장비 강화

전체 구조

무기 강화가 확률 기반이라면, 전술장비 강화는 경험치 누적 기반이다. 재료를 투입할수록 경험치가 쌓이고, 필요 경험치를 채우면 레벨이 오른다. 실패가 없는 대신 어떤 재료를 얼마나 투입하느냐가 핵심 선택지가 된다.

로직 전체는 EquipmentUpgradeService에 집중되어 있고, UI는 이벤트만 구독한다.

EquipmentUpgradeService
├─ SelectEquipment()       → 강화 대상 선택
├─ TryAddMaterial()        → 재료 등록
├─ CalculatePreviewExp()   → 강화 결과 미리보기 (실시간)
├─ CalcCredit()            → 소모 크레딧 계산
└─ ExecuteUpgrade()        → 강화 실행

 

강화 재료 2종류

재료로 사용할 수 있는 아이템은 두 종류다.

 

1. 강화재료 책 ( Small / Medium / Large )

전용 소모 재료로, 종류 별로 경험치 환산값이 다르다.

Small  (ID: 30003) → 200  EXP
Medium (ID: 30004) → 600  EXP
Large  (ID: 30005) → 1800 EXP

 

2. 다른 전술 장비 자체

 

보유 중인 다른 전술장비를 재료로 넣을 수 있다. 이 경우 해당 장비를 분해했을 때 나오는 책의 경험치 합산값으로 환산된다.

// 장비를 재료로 넣었을 때 경험치 계산
private int GetEquipmentDecompositionExp(InventorySlot slot)
{
    var decomposeData = Managers.Data.GetData<int, EquipmentDecompositionData>(data.Tier);

    return decomposeData.Mat1Count * mat1Exp   // Small책 환산
         + decomposeData.Mat2Count * mat2Exp   // Medium책 환산
         + decomposeData.Mat3Count * mat3Exp   // Large책 환산
         + slot.EquipInstance.CurrentExp;      // 장비에 쌓인 경험치도 포함
}

 

장비에 이미 쌓인 경험치까지 그대로 이전되기 때문에, 강화된 장비를 재료로 넣을수록 더 많은 경험치를 얻는다.

 

실시간 경험치 미리보기

재료를 선택할 때마다 강화 결과가 실시간으로 갱신된다. 재료를 등록·취소할 때마다 CalculatePreviewExp()를 호출하고 결과를 OnExpPreviewChanged 이벤트로 발행한다. UI는 이 이벤트를 구독해서 경험치 바와 도달 레벨을 즉시 업데이트한다.

public ExpPreviewResult CalculatePreviewExp()
{
    int totalGainExp = 0;
    foreach (MaterialEntry entry in _materialSlots)
        totalGainExp += GetExpByMaterial(entry) * entry.Count;

    // 현재 경험치 + 획득 경험치로 최종 레벨 시뮬레이션
    int simulatedExp = instance.CurrentExp + totalGainExp;
    int simulatedLevel = instance.UpgradeLevel;

    while (simulatedExp >= simulatedRequire)
    {
        simulatedExp -= simulatedRequire;
        simulatedLevel++;

        if (simulatedLevel >= maxLevel)
        {
            simulatedRequire = 0;
            break; // 최대 레벨 초과 방지
        }
        simulatedRequire = Managers.Data
            .GetData<int, EquipmentLevelExpData>(simulatedLevel + 1).RequireExp;
    }

    return new ExpPreviewResult(totalGainExp, simulatedLevel, simulatedExp, simulatedRequire);
}

 

실제 강화를 실행하기 전에 결과를 시뮬레이션하는 구조라서, 재료를 추가하거나 취소할 때마다 "강화 후 레벨이 얼마가 될지"를 즉시 확인할 수 있다.

 

재료 등록 / 취소 흐름

강화 대상 장비 더블클릭
    ↓
SelectEquipment() → 오른쪽 패널에 장비 정보 표시
    ↓
소모재료 탭 클릭 → UIMaterialSelectPopup 오픈
    ↓
재료 클릭
    ↓
TryAddMaterial()
    ├─ 강화재료 책: 보유 수량 범위 내에서 Count 누적
    └─ 다른 장비: 슬롯 단위로 등록
    ↓
소모재료 탭에 등록 + 슬롯 우상단 취소 버튼 활성화
OnExpPreviewChanged 이벤트 → 경험치 바 실시간 갱신
    ↓
최대 레벨 도달 예정?
    ├─ 예 → 재료 추가 등록 차단
    └─ 아니오 → 소모 크레딧 갱신 (부족 시 빨간색 표시)

 

취소 버튼이 두 곳에 있는 역할이 다르다.

소모재료 탭의 ItemSlot 우상단 해당 재료 등록 해제
강화재료 선택 팝업의 대응 슬롯 우상단 동일한 재료의 등록 상태 반영 (연동)

 

한쪽에서 취소하면 반대쪽도 자동으로 비활성호되어 양쪽이 항상 동기화 된다.

 

 

강화 실행 - ExecuteUpgrade()

강화하기 버튼을 누르면 아래 순서로 실행된다.

public bool ExecuteUpgrade()
{
    ExpPreviewResult preview = CalculatePreviewExp();

    // 1. 크레딧 소모 (부족하면 강화 중단)
    int creditCost = CalcCredit(currentLevel, preview.SimulatedLevel);
    if (!Managers.Wallet.ConsumeCurrency(CurrencyType.Credit, creditCost))
        return false;

    // 2. 초과 경험치 → 강화재료 책으로 환산해서 인벤토리 지급
    if (preview.SimulatedLevel >= maxLevel)
    {
        int rest = preview.SimulatedExp;
        // Large → Medium → Small 순으로 역산
        foreach (int bookExp in upgradeBookExpList)
        {
            restCount.Add(rest / bookExp);
            rest %= bookExp;
        }
        Managers.Inventory.AddItem(UpgradeBookID.Large,  Material, restCount[0]);
        Managers.Inventory.AddItem(UpgradeBookID.Medium, Material, restCount[1]);
        Managers.Inventory.AddItem(UpgradeBookID.Small,  Material, restCount[2]);
    }

    // 3. 레벨 구간별 스탯 성장
    for (int lv = currentLevel + 1; lv <= targetLevel; lv++)
    {
        GrowMainStat(instance);                                  // 매 레벨 고정 성장
        if (lv % 3 == 0 && instance.SubStats.Count > 0)
            GrowRandomSubStat(instance);                         // 3의 배수 레벨에서 랜덤 성장
    }

    // 4. 재료 소모 (책은 Count만큼, 장비는 슬롯째 삭제)
    ConsumeUsedMaterials();
}

 

 

소모 크레딧은 목표 레벨까지의 누적 비용을 미리 계산해서 한 번에 차감한다.

// 생성자에서 누적 비용 미리 계산
for (int i = 1; i <= 15; i++)
    _requireAccumulatedCost[i] = _requireAccumulatedCost[i - 1]
        + Managers.Data.GetData<int, EquipmentUpgradeCost>(i).EnhancementCost;

// 실제 소모량 = 목표 레벨 누적값 - 현재 레벨 누적값
public int CalcCredit(int curLevel, int targetLevel)
    => _requireAccumulatedCost[targetLevel] - _requireAccumulatedCost[curLevel];

 

 

스탯 성장 - MainStat과 SubStat

MainStat 은 매 레벨업마다 StatPool의 UpgradeMinValue만큼 고정 증가한다.

SubStat 은 레벨 +3 / +6 / +9 / +12 / +15 구간에서만 성장하고, 성장할 때마다 아래 3가지 값 중 하나가 랜덤으로 선택된다.

성장값 ∈ { UpgradeMinValue , UpgradeMaxValue , (UpgradeMinValue + UpgradeMaxValue) / 2 }

private void GrowRandomSubStat(EquipmentInstance instance)
{
    // 보유 SubStat 중 랜덤으로 하나 선택
    int idx = UnityEngine.Random.Range(0, instance.SubStats.Count);
    StatOption target = instance.SubStats[idx];

    List<float> values = new List<float>
    {
        entry.UpgradeMinValue,
        entry.UpgradeMaxValue,
        (entry.UpgradeMinValue + entry.UpgradeMaxValue) / 2f
    };

    target.Value += values[UnityEngine.Random.Range(0, values.Count)];
    target.UpgradeCount++;
}

 

어떤 SubStat이 선택되는지, 그 SubStat이 min/max/avg 중 어떤 값을 받는지 모두 랜덤이라서 같은 등급의 장비라도 강화 결과가 매번 달라진다. 이것이 장비 파밍 반복 동기의 핵심이다.

 

초과 경험치 환산

재료를 넉넉하게 넣어서 최대 레벨을 초과하는 경험치가 생겼을 때, 그 경험치를 그냥 버리지 않고 강화재료 책으로 환산해서 인벤토리에 지급한다. Large → Medium → Small 순서로 최대한 큰 단위로 먼저 변환한다.

남은 경험치: 2350 EXP

Large  (1800 EXP) → 1개  (2350 / 1800 = 1)  → 나머지 550
Medium  (600 EXP) → 0개  (550  /  600 = 0)  → 나머지 550
Small   (200 EXP) → 2개  (550  /  200 = 2)  → 나머지 150 버림

결과: Large x1, Small x2 획득

 


5. 장비 분해

전체 구조

장비 분해는 파밍 루프의 마지막 단계다. 강화에 쓰지 않을 잉여 장비를 분해해서 강화재료 책으로 환산하고, 그 재료를 다시 강화에 투입하는 순환 구조를 완성한다.

 

로직은 EquipmentDecomposeService가 담당하고, UI는 이벤트만 구독한다.

EquipmentDecomposeService
├─ TryAddMaterial()      → 분해 목록 등록
├─ RemoveMaterial()      → 분해 목록 해제
├─ EvaluateResult()      → 분해 결과 계산 (등록/해제 시마다 갱신)
├─ ExecuteDecompose()    → 분해 실행 및 아이템 지급
└─ ClearMaterials()      → 목록 초기화

 

 

분해 결과 계산 - EvaluateResult()

분해 결과는 두 가지 요소의 합산이다.

 

1. Tier별 고정 지급량

EquipmentDecomposition 테이블에 Tier별로 기본 지급되는 책 수량이 정의되어 있다.

Tier | Small책 | Medium책 | Large책
  1  |    2    |    0     |    0
  3  |    6    |    1     |    0
  6  |   12    |    4     |    1
 10  |   20    |    8     |    3

2. 장비에 쌓인 경험치 환산

장비에 이미 쌓여있는 경험치도 Large → Medium → Small 순서로 역산해서 책으로 환산된다.

private void EvaluateResult(bool isAdd, InventorySlot slot)
{
    int sign = isAdd ? 1 : -1;

    // 1. Tier별 고정 지급량 반영
    var decomposeData = Managers.Data.GetData<int, EquipmentDecompositionData>(data.Tier);
    _upgradeBookResultCounts[0] += sign * decomposeData.Mat1Count; // Small
    _upgradeBookResultCounts[1] += sign * decomposeData.Mat2Count; // Medium
    _upgradeBookResultCounts[2] += sign * decomposeData.Mat3Count; // Large

    // 2. 경험치 환산 (Large → Medium → Small 순 역산)
    int equipExp = slot.EquipInstance?.CurrentExp ?? 0;
    for (int i = 2; i >= 0; i--)
    {
        _upgradeBookResultCounts[i] += sign * (equipExp / bookExp[i]);
        equipExp %= bookExp[i];
    }
}

 

isAdd가 true면 결과에 더하고, false면 빼는 방식이라 등록과 취소 모두 같은 메서드 하나로 처리된다. sign 변수 하나로 분기를 없앤 구조다.

등록할 때마다 결과가 즉시 갱신되기 때문에, 분해하기 버튼을 누르기 전에 "이 장비들을 분해하면 책이 얼마나 나오는지" 미리 확인할 수 있다.

 

분해 목록 등록 / 취소 흐름

보유 장비 목록에서 장비 클릭
    ↓
TryAddMaterial()
    ├─ 빈 분해 슬롯 탐색
    ├─ 분해 목록에 등록
    └─ EvaluateResult(true) → 하단 결과 수치 갱신
    ↓
ItemSlot 우상단 취소 버튼 활성화
분해 목록 슬롯 취소 버튼 활성화
    ↓
취소 버튼 클릭
    ↓
RemoveMaterial()
    ├─ 해당 슬롯 Clear()
    └─ EvaluateResult(false) → 결과 수치 재감산
    ↓
양쪽 취소 버튼 비활성화 (연동)

 

전술장비 강화와 동일하게 취소 버튼이 두 곳에 있고, 한쪽에서 취소하면 반대쪽도 자동으로 비활성화되어 동기화된다.

// 취소 버튼 동기화 — slotMap으로 InventorySlot ↔ UIItemSlot 대응
private void RefreshCancelButtons()
{
    foreach (var (inventorySlot, uiSlot) in _slotMap)
    {
        int idx = _decomposeService.FindMaterialSlotIndex(inventorySlot);
        if (idx >= 0)
            uiSlot.SetCancelActive(true, () => _decomposeService.RemoveMaterial(idx));
        else
            uiSlot.SetCancelActive(false);
    }
}

 

_slotMap은 Dictionary<InventorySlotUIItemSlot> 구조로, 실제 데이터 슬롯과 UI 슬롯을 1:1로 연결해서 관리한다.

 

 

분해 실행 - ExecuteDecompse()

분해하기 버튼을 누르면 계산된 결과대로 책을 지급하고, 분해된 장비를 인벤토리에서 삭제한다.

public void ExecuteDecompose()
{
    // 1. 강화재료 책 인벤토리 지급
    Managers.Inventory.AddItem(UpgradeBookID.Small,  Material, _upgradeBookResultCounts[0]);
    Managers.Inventory.AddItem(UpgradeBookID.Medium, Material, _upgradeBookResultCounts[1]);
    Managers.Inventory.AddItem(UpgradeBookID.Large,  Material, _upgradeBookResultCounts[2]);

    // 2. 분해된 장비 인벤토리에서 삭제
    ConsumeUsedMaterials();

    // 3. 목록 초기화 + UI 이벤트 발행
    ClearMaterials();
    OnDecomposeExecuted?.Invoke();
}

private void ConsumeUsedMaterials()
{
    foreach (MaterialEntry entry in _materialSlots)
    {
        if (entry.IsEmpty) continue;
        Managers.Inventory.RemoveSlot(entry.Slot); // 슬롯째 삭제
    }
}

 

OnDecomposeExecuted 이벤트가 발행되면 UI 측에서 보유 장비 ScrollView를 다시 렌더링해서, 분해된 장비들이 목록에서 사라진다.

 

분해와 강화의 관계

분해 시스템이 단독으로 존재하는 게 아니라, 전술장비 강화와 맞물려서 파밍 루프를 완성한다.

장비 파밍
    ↓
좋은 옵션의 장비 → 인벤토리 보관 → 강화 투자
나쁜 옵션의 장비 → 분해 → 강화재료 책 획득
    ↓
획득한 책 → 좋은 장비 강화에 재투자
    ↓
다시 파밍...

 

분해 결과가 Tier와 경험치 두 가지를 모두 고려하는 이유도 여기에 있다. 강화를 많이 진행한 고티어 장비일수록 분해 가치가 높아지기 때문에, "이 장비를 계속 강화할 것인가, 분해해서 재료로 쓸 것인가" 라는 선택이 플레이어의 실질적인 고민이 된다.


결론

잘된점

Service - UI 분리

이번 구현에서 가장 신경 쓴 부분이다. EquipmentUpgradeService, EquipmentDecomposeService 모두 MonoBehaviour를 상속받지 않는 순수 C# 클래스로 작성했고, UI 코드에 강화 계산 로직이 섞이지 않도록 의도적으로 분리했다. Service는 이벤트만 발행하고, UI는 이벤트만 구독하는 구조 덕분에 패널을 교체하거나 새로운 진입점을 추가해도 Service 코드를 건드리지 않아도 된다.

IItemSlotHandler 인터페이스

슬롯 프리팹 하나가 인벤토리, 강화, 분해 패널에서 각각 다르게 동작하는 걸 핸들러 주입 하나로 해결했다. 패널마다 슬롯 클래스를 따로 만드는 대신, 패널이 IItemSlotHandler를 구현해서 슬롯에 주입하는 방식이라 슬롯 UI의 재사용성이 높다.

데이터 포맷을 성격에 따라 분리한 것

강화 성공 확률처럼 서버 검증이 필요하거나 핫픽스 가능성이 있는 데이터는 JSON으로, 비용이나 스탯 성장치처럼 빌드에 고정되어도 되는 테이블형 데이터는 Excel → SO로 관리했다. 단순히 "편해서" 선택한 게 아니라 데이터의 성격에 따라 포맷을 구분했다는 점에서 의미있는 설계 결정이었다.


구현 중 어려웠던 문제들

문제 1. 기획 설계 — 등급별 SubStat 성장 구조

문제 상황

전술장비 강화 구현 전, SubStat이 성장하는 방식에 대해 방향이 잡히지 않았다. "3레벨마다 SubStat이 성장한다"는 큰 틀은 있었지만, 어떤 SubStat이 성장할지 플레이어가 선택하는지, 완전 랜덤인지 결정이 안 된 상태였다. 또한 등급별로 SubStat 성장 횟수를 다르게 가져가는 구조에서, 최대 레벨인 +15 구간에 SubStat 성장을 넣을지 말지도 명확하지 않았다.

문제 해결

이 프로젝트에서는 "어떤 SubStat이 성장할지는 랜덤이되, 성장값은 min/max/avg 3종류 중 하나" 라는 방식으로 확정했다. 등급별 최대 레벨과 SubStat 성장 횟수는 GradeConfig 테이블로 데이터화해서, 코드 수정 없이 밸런스 조정이 가능한 구조로 만들었다.

 

문제 2. 데이터 파이프라인 — 어떤 포맷을 선택할 것인가

문제 상황

무기 데이터를 처음에 JSON으로 관리하려다가, 레벨별 스탯 수치를 일일이 JSON으로 작성하는 게 비효율적이라는 걸 느꼈다. 반대로 모든 데이터를 Excel로 관리하자니, 강화 확률처럼 서버 검증이 필요한 데이터까지 SO로 굳혀버리는 게 맞는지 확신이 없었다.

문제 해결

데이터 성격에 따라 포맷을 구분하는 기준을 세웠다. "이 데이터가 서버를 거쳐야 하는가?" 가 핵심 판단 기준이었다. 레벨별 스탯 성장치, 강화 비용처럼 빌드에 고정돼도 되는 테이블형 데이터는 Excel → SO, 강화 확률처럼 핫픽스 가능성이 있거나 서버 검증이 필요한 데이터는 JSON으로 분리했다. Excel은 수식으로 수치를 자동 계산할 수 있어서 50레벨짜리 데이터도 수식 드래그 한 번으로 완성되는 이점도 컸다.