

우리가 구현할것은 바로 무기 업그레이드 부분 파츠형 시스템이며 간단히 생각하면 배틀그라운드나 콜오브듀티같은 구현이다.
물론 디자인적인면에선 양해를 바란다.. 솔직히 기능적인부분에서만 구현한거라 좀 구리기도하지만 뭐...

잘 작동은 한다! 물론 그것만이 답은 아니지만.
서론은 이쯤하고 우리가 이 시스템을 구현하기 위해선 다음과 같은일을 해야한다
👉 이번 글에서 다룰 내용들
1. 기획 의도와 구현 목표
• 파츠 개조시스템에서 필요한 기능 정리2. 데이터 구조 설계 (
WeaponDataSO)
• 원본 데이터 보존, 에디터 내 슬롯 중복 등록을 막는 방어 코드 및 파츠별 스탯 저장3. 파츠 수정자 생성 (
WeaponUpgradeSO)
• 각 부품이 가질 스탯 변수와 능력치 증감(곱연산/합연산) 방식 구조화4. 런타임 복사용 인스턴스화 및 로드아웃 시스템
• 원본 SO 오염을 막기 위한 사본 인스턴스화 및 실시간 스탯 연산 로직5. UI 상호작용 및 해금 시스템 구현
• 스탯 변경 내역 시각화, 소켓 장착, 드롭다운 연동 및 자금 소모 언락
우리가구현할건 뭐 요즘게임은 다 넣는 유명한기능,

마치 콜오브듀티의 파츠 업그레이드 시스템이다.
단순히 +1강, +2강 처럼 수치만 무지성으로 올리는 강화 방식보다는, 플레이어가 직접 파츠를 조합하며 자신만의 무기 빌드를 짤 수 있게 만들고있으며. 요즘 많은 게임들이 추구하는 트렌드이기도 하다.
이놈이 없으면 뭐 올리든 말든 의미가없을거다 원본이 있어야 기능이 올라가고 말고를 확인할수있을테니깐?
뭐 특이한케이스의 게임에선 한슬롯에 전술라이트를 수십개 박을수도있긴하지만,
그건좀 과하다 슬롯마다 그에맞는 기능별 부품이 적당할꺼다.
파츠를 꼈다 뺐다 할 때마다 무기 데이터가 실시간으로 바뀌야 한다.
하지만 그렇다고 유니티 에디터에 있는 원본 SO 자체를 증감하거나 건든다면 게임이 박살날꺼다 아마도?
원본 설계도는 그대로 보존하면서, 파츠 효과만 누적해서 안전하게 계산해 줄 런타임 사본을 보내야한다.
속도 1.2배 증가 같은 단일값 조정뿐많이 아니라 상태같은 값도 수정하는 유연함이 필요하다.
이쪽분야에선 SO가 전문이기도하고
당연 구현을했으면 그걸 상호작용하고 볼 UI가 필요하다 간단하게 스텟을 볼 패널과
부품 소켓, 커스텀 드롭다운을 만들고 임시로 자금을 사용하여 언락까지 구현해볼것이다.

일단 필요한건 먼저 대상 데이터가있어야 한다 업그레이드 할대상이 없으면 당연히 업그레이드 구현도 못할테니깐.
간단한 스크립트를 짜보자.. 라고 하기전 가장중요한건 스크립터블 오브젝트 스크립트를짜워야 넣을텐데 위치를 알아야 뭐든 할것이다.

간단하게 프로젝트창에서 우클릭-> 스크립팅 -> 스크립터블 오브젝트창을 클릭하면 자동으로 생성시켜준다.

처음보면 뭐가뭔지 모를수도있다 하지만 뭐 구조는 엄청나게 간단하다
[CreateAssetMenu(fileName = "alpha", menuName = "Scriptable Objects/alpha")]
fileName 이건 파일명이다 SO는 재사용성이 핵심이기에 뽑아쓰기 좋게 파일명을 만들수있다.
menuName 이건 경로이다 아무리 SO를 잘만들어도 어디다 둿는지 알아야 쓸테니깐?

ㄴ
만들면 대강 이위치에 생긴다 여기서 만든 SO를 사용할수있다.
하지만 지금은껍대기만 있지 뭐 차있는거도없고 넣는거도없다 이제 내용물을 만들어야한다.
껍데기를 만들었으니 이제 무기의 원본 스탯 데이터와 파츠를 장착할 슬롯 정보를 채워 넣을 차례다.
먼저 무기의 기본데이터를 넣어보자.
public class WeaponDataSO : ScriptableObject
{
[Header("미사일 식별")]
public MissileModel MissileModel;
public Sprite MissileSprite;
public int Price;
[Header("미사일 파라미터")]
public SeekerType SeekerType;
public TrackLogicType LogicType;
public DLMode DLSystem;
public float ProjectileSpeed;
public float MaxAngularVelocity;
public float LifeTime;
public float NavigationConstant;
}
뭐 이대로 똑같이 할필요는없다. 애당초 이걸 그대로써봤자 쓸 용도도 딱히없을테고,,
물론 그래도 식별부분은 왠만하면 사용하는걸 추천한다 특히 가격부분은 나중에 언락개념에서 쓸것이다.
하지만 원본만 있으면 이걸로 뭐 업글같은걸 저장할수도없고 편리하지도않다. 즉 업그레이드 요소들을 저장할 필드가 필요하다.
업그레이드(파츠)슬롯 저장 필드
[Header("가능한 튜닝옵션")] public List<UpgradeSlots> AvailableUpgrades;
이 튜닝옵션을 저 클래스 내부에 넣어주자. 그런데 저 알수없는 클래스는 무엇인가? 할수도있는데 이놈은 한마디로 소켓이다.
뭐 장비마다 모든소켓을 열면 관리도 복잡하고 하나하나 다넣기엔 골머리썩는다 이를해결하기위해선? 그냥 필요한놈만 생성하면된다.
업그레이드(파츠) 클래스
[Serializable] public class UpgradeSlots { public UpgradeSlot Slot; public WeaponUpgradeSO DefaultUpgrade; public List<WeaponUpgradeSO> Upgrades; }
이 클래스의 설명을 하자면 간단하다
다만 사람은 항상 실수를 하기 마련이다. 뭐 고의가 아니라면 조로도아니고 투구칸에 칼장착하게 칼슬롯만 3개만들순없을테고,,
대부분 슬롯은 한칸이다 특히 파츠개념이라면 말이지. 이럴땐 에디터의 도움을 받아보자
중복 방지 메서드
private void OnValidate() { var usedSlots = new HashSet<UpgradeSlot>(); foreach (var entry in AvailableUpgrades) { if (usedSlots.Contains(entry.Slot)) { Debug.LogWarning($"[{name}] 슬롯 중복 감지: {entry.Slot} → 자동 변경됨"); foreach (UpgradeSlot slot in Enum.GetValues(typeof(UpgradeSlot))) { if (!usedSlots.Contains(slot)) { entry.Slot = slot; break; } } } usedSlots.Add(entry.Slot); } }
이부분은 중복된 슬롯을 생성할려면 자동으로 다음 빈슬롯을 만들어준다. 왠만하면 슬롯을 따로만들기에 이방식을쓰는게 좋다
이방식을쓰면 리스트 생성시 전에있던 슬롯부위를자동으로 피하며 새로 생성해준다. 물론 내용물은 직접채워야겠지만?
완성된 코드는 다음과 같다
using UnityEngine;
using System.Collections.Generic;
using System;
[CreateAssetMenu(fileName = "NewWeapon", menuName = "Weapon/WeaponData")]
public class WeaponDataSO : ScriptableObject
{
[Header("미사일 식별")]
public MissileModel MissileModel;
public Sprite MissileSprite;
public int Price;
[Header("미사일 파라미터")]
public SeekerType SeekerType;
public TrackLogicType LogicType;
public DLMode DLSystem;
public float ProjectileSpeed;
public float MaxAngularVelocity;
public float LifeTime;
public float NavigationConstant;
[Header("가능한 튜닝옵션")]
public List<UpgradeSlots> AvailableUpgrades;
private void OnValidate()
{
var usedSlots = new HashSet<UpgradeSlot>();
foreach (var entry in AvailableUpgrades)
{
if (usedSlots.Contains(entry.Slot))
{
Debug.LogWarning($"[{name}] 슬롯 중복 감지: {entry.Slot} → 자동 변경됨");
foreach (UpgradeSlot slot in Enum.GetValues(typeof(UpgradeSlot)))
{
if (!usedSlots.Contains(slot))
{
entry.Slot = slot;
break;
}
}
}
usedSlots.Add(entry.Slot);
}
}
}
[Serializable]
public class UpgradeSlots
{
public UpgradeSlot Slot;
public WeaponUpgradeSO DefaultUpgrade;
public List<WeaponUpgradeSO> Upgrades;
}
이제 슬롯을 준비했으니 이제 그 슬롯에 꽂아 넣을 실질적인 파츠를 설계할 차례다.
여기서 중요하게 생각한 점은 파츠의 다양성이다. 단순히 미사일 속도를 1.2배 올려주는 단순 수치 보정 파츠도 있겠지만, 나중에 예를즐들자면 내 코드의 예제처럼 시커 탐색 방식을 Mono에서 TWS로 통째로 갈아끼우거나 데이터링크 시스템 모드를 오버라이드하는 등 수치뿐만이아니라 다양한 수정자도 나와야 한다.
하지만 이걸 매번 새 스크립트로 짜면? 개판 오분전이다. 그래서 모든 파츠의 중추가 될 공통 추상 클래스를 먼저 파고 시작한다.
먼저 또 스크립터블 오브젝트가 필요하다 뭐 위에 만드는법은 알려줬으니 두번할필요는없이 바로 스크립팅을 시작한다.
공용 접근자
using UnityEngine; public abstract class WeaponUpgradeSO : ScriptableObject { [Header("업그레이드 정보")] public string UpgradeName; [TextArea] public string Description; public UpgradeSlot Slot; // 부품이 장착될 슬롯 종류 public int Price; // 파츠 해금/구매 가격 // 복사된 런타임 무기 인스턴스에 실제로 파츠 효과를 먹일 추상 메서드 public abstract void Apply(WeaponInstance instance); }
이건 한마디로 공통 접근자라 하면 편하다 일단뭐 업그레이드마다 파츠 이름이랑 설명은있는건 공통적일테고 어디 장착하는가
이쯤은 모두 공용화되기때문이다 뭐 작성자는 언락기능을위해 가격도 넣은상태지만.
두번째 추상 메서드는 한마디로 작용자이다 수치를올리거나 값을변경하거나 불리언을 조절하는등말이다. 물론 상속구조를 사용할꺼기에
abstract 를 사용할꺼다
이제 기틀을 만들었으니 작용할 클래스를 만들자 위에있는 클래스는 한마디로 업그레이드 파츠의 틀이니 이제 내용을 채워야한다.
예를 들자면 속도를 올린다할때는 이런식이다.
속도 수정자
using System; using System.Collections.Generic; using UnityEngine; [CreateAssetMenu(fileName = "NewMultiplierUpgrade", menuName = "Weapon/Upgrade/StatMultiplier")] public class StatMultiplierUpgradeSO : WeaponUpgradeSO { public List<StatModifier> Modifiers; public override void Apply(WeaponInstance instance) { foreach (var mod in Modifiers) { switch (mod.StatType) { case StatType.ProjectileSpeed: instance.ProjectileSpeed *= mod.Multiplier; break; case StatType.MaxAngularVelocity: instance.MaxAngularVelocity *= mod.Multiplier; break; case StatType.LifeTime: instance.LifeTime *= mod.Multiplier; break; case StatType.NavigationConstant: instance.NavigationConstant *= mod.Multiplier; break; } } } } [Serializable] public struct StatModifier { public StatType StatType; public float Multiplier; } public enum StatType { ProjectileSpeed, MaxAngularVelocity, LifeTime, NavigationConstant }
뭐 보기엔 엄청 길어보이는데 사실 핵심은 딱 두개다
리스트로 어딜 조정할건지, Apply에서 스위치문으로 어디다 배율값을 넣어줄껀지. 구조체를보면 딱 보기 쉽게 구현되어있을것이다.
물론 C#초보자라면 구조체는 다소 생소할수도있지만 따로 배워둔다면 훌륭한 도구기에 이번기회에 배워보는것이 좋을것이다.
뭐 이런식으로 배율을 올리던지 아니면 클래스의값을 바꾸던지하면된다. 사실 이 스위치문이복잡하면 각 속도업글용단일파츠,수명파츠등등 독립적으로 해도될꺼다 그저 WeaponUpgradeSO클래스를 부모로가진 자식만 생성하고 만들면 괜찮은 파츠들이 양산될꺼다.
아 참고로 이건 Create에서 보여야 생성하기쉽기에 CreateAssetMenu를 작성하는걸 까먹지 않도록하자.
사실 원래라면 이놈이 3번이고 전에꺼가 4번이긴하다. 다만 코드를짤때는 이순서가 맞기에 여기에두긴했다.
이제 살짝 복잡한부분이 시작될꺼다.
인스턴스는 런타임에서 받는데이터, 즉 게임상에서 유저가 해당 객체를쓸때 받는 데이터다 원본만주면 당연 업그레이드가 체감이 안갈테니깐?
로드아웃은 여기에 어떤 파츠가 꽂혀있는지 기억하고 스탯을 정산해 주는 역할이다.
배틀그라운드로 치면 길바닥에서 막 주운 껍데기 총이 인스턴스라면, 거기에 앵글 손잡이, 대용량 탄창, 4배율 스코프를 착착 부착해서 최종 반동 수치와 탄속을 실시간으로 계산해 내는 총기 개조대 시스템이 바로 로드아웃인 셈이다. 뭐 게임에 관심있거나 해본사람들은 이름만 들어도 뭘할껀지 알것이다.
개념을 정리했으니 원본 설계도를 안전하게 복사하고 업그레이드 값을 추가해 리턴시키는 인스턴스를 만들어보자
인스턴스
using System.Collections.Generic; public class WeaponInstance { public MissileModel MissileModel; public SeekerType SeekerType; public TrackLogicType LogicType; public DLMode DLSystem; public float ProjectileSpeed; public float MaxAngularVelocity; public float LifeTime; public float NavigationConstant; public WeaponInstance(WeaponDataSO baseData, List<WeaponUpgradeSO> appliedUpgrades) { // 원본값 사본 MissileModel = baseData.MissileModel; SeekerType = baseData.SeekerType; LogicType = baseData.LogicType; DLSystem = baseData.DLSystem; ProjectileSpeed = baseData.ProjectileSpeed; MaxAngularVelocity = baseData.MaxAngularVelocity; LifeTime = baseData.LifeTime; NavigationConstant = baseData.NavigationConstant; // 업그레이드 순서대로 적용 foreach (var upgrade in appliedUpgrades) { upgrade.Apply(this); } } }코드가 아주 직관적이다. 생성자 Constructor가 실행되는 순간 원본 SO의 순정 수치들을 싹 긁어와서 자리를 잡는다.
직후, 현재 장착된 파츠 리스트를 foreach문으로 돌면서 아까 3장에서 만든 실행 스위치인upgrade.Apply(this)를 갱신. 매개변수로 자기 this을 넘겨주기 때문에, 파츠들이 알아서 이 인스턴스의 스탯을 기획대로 최종 정산이 완료된다.
유니티나 C# 좀 해본 사람들은 "이런 가벼운 데이터 묶음은 구조체
struct로 만들어서 가비지 컬렉터(GC) 부하를 줄이는 게 이득 아닌가?" 라는 의문이 들 수 있다. 처음에 그 생각을 안 해본 건 아니다.하지만 이 시스템에서 구조체를 쓰면 데이터가 아예 반영되지 않는 대참사가 난다.클래스로 설계해야 하는 치명적인 이유 2가지가 있다.
1. 값 복사(Call by Value)의 늪
구조체는 값 타입이기 때문에 메서드 매개변수로 넘겨줄 때 주소가 아니라 데이터 복사본이 넘어간다. 즉,
upgrade.Apply(this)를 호출하는 순간 파츠 클래스로 넘어가는 건 진짜 내가 아니라 복사된 가짜 사본이다. 파츠 안에서 아무리 스탯에 배율을 곱해봐야 함수가 끝나는 순간 그 가짜 사본은 메모리에서 증발하고, 진짜 내 무기 스탯은 단 1도 변하지 않는다.2. 동기화 불가능 (UI vs 인게임 스크립트)
이 무기 인스턴스는 한 곳에서만 쓰는 게 아니다. UI 스크립트도 이 인스턴스를 바라보며 스탯 바를 채워야 하고, 발사 스크립트도 이 인스턴스를 바라보며 미사일을 날려야 한다.
만약 이게 클래스(참조 타입)라면 모두가 메모리상의 동일한 주소를 바라보므로 파츠를 바꾸는 순간 UI와 미사일 탄속이 실시간으로 동기화된다. 하지만 구조체라면 UI에 넘길 때 사본 1개, 발사할 때 사본 2개가 생겨서 UI 상권에서는 스탯이 만렙인데 정작 미사일을 쏘면 1레벨 순정 미사일이 나가는 기적의 싱크 아웃을 경험하게 된다.인스턴스는 매 프레임
new를 때리는 게 아니라 무기 개조 창을 켜거나 무기를 바꿀 때 딱 한 번만 정산해서 들고 갈 놈이라 클래스로 만들어도 GC 부하가 거의 없으니 안심하고 클래스로 밀고 가자.
물론 업데이트쪽에 넣어서 매프레임마다 갱신은.. 그건 유감이지만
요건 좀 길다. 뭐 단순한 기능만있는놈이 아니기에 다만 어려운건아니다.
이건뭐 쪼개서 합치는거보다 먼저 보여주고 부분별로 설명하는편이 더 낫다.
로드아웃
using System.Collections.Generic; public class WeaponLoadout { public WeaponDataSO BaseData; public Dictionary<UpgradeSlot, List<WeaponUpgradeSO>> AllUpgradeParts = new(); //등록된 파츠 슬롯과 파츠들 public Dictionary<UpgradeSlot, List<WeaponUpgradeSO>> UnLockedUpgradeParts = new(); // 사용가능한 파츠들 private Dictionary<UpgradeSlot, WeaponUpgradeSO> _appliedUpgrades = new(); // 장착한 파츠 public WeaponLoadout(WeaponDataSO baseData) { BaseData = baseData; foreach (var entry in baseData.AvailableUpgrades) { // 업그레이드 등록 AllUpgradeParts[entry.Slot] = entry.Upgrades; // 기본형은 처음부터 해금 if (entry.DefaultUpgrade != null) { UnLockedUpgradeParts[entry.Slot] = new List<WeaponUpgradeSO> { entry.DefaultUpgrade }; _appliedUpgrades[entry.Slot] = entry.DefaultUpgrade; } } } #region 슬롯 온오프 관리 public bool Unlock(WeaponUpgradeSO upgrade) { if (!AllUpgradeParts.TryGetValue(upgrade.Slot, out var all)) return false; if (!all.Contains(upgrade)) return false; if (!UnLockedUpgradeParts.ContainsKey(upgrade.Slot)) UnLockedUpgradeParts[upgrade.Slot] = new List<WeaponUpgradeSO>(); UnLockedUpgradeParts[upgrade.Slot].Add(upgrade); return true; } #endregion #region 장착 해제 관리 public bool ApplyUpgrade(WeaponUpgradeSO upgrade) { if (!UnLockedUpgradeParts.TryGetValue(upgrade.Slot, out var unlocked)) return false; if (!unlocked.Contains(upgrade)) return false; _appliedUpgrades[upgrade.Slot] = upgrade; return true; } #endregion // 리턴 // 출력값 public WeaponInstance GetInstance() => new(BaseData, new List<WeaponUpgradeSO>(_appliedUpgrades.Values)); public WeaponUpgradeSO GetApplied(UpgradeSlot slot) { _appliedUpgrades.TryGetValue(slot, out var upgrade); return upgrade; } public bool IsPartUnlocked(WeaponUpgradeSO upgrade) { if (!UnLockedUpgradeParts.TryGetValue(upgrade.Slot, out var unlockedList)) return false; return unlockedList.Contains(upgrade); } }
이 코드의 핵심 뼈대를 크게 3가지로 쪼개서 가볍게 짚고 넘어가자.
일단뭐 넣는방식이나 장착 언락방식은 게임 방식에선 각각 미묘한차이가있다, 작성자는 돈으로 언락하고 언락된걸 장착할수있는것으로
구현을했다.
public WeaponDataSO BaseData;
public Dictionary<UpgradeSlot, List<WeaponUpgradeSO>> AllUpgradeParts = new(); //등록된 파츠 슬롯과 파츠들
public Dictionary<UpgradeSlot, List<WeaponUpgradeSO>> UnLockedUpgradeParts = new(); // 사용가능한 파츠들
private Dictionary<UpgradeSlot, WeaponUpgradeSO> _appliedUpgrades = new(); // 장착한 파츠
public WeaponLoadout(WeaponDataSO baseData)
{
BaseData = baseData;
foreach (var entry in baseData.AvailableUpgrades)
{
// 업그레이드 등록
AllUpgradeParts[entry.Slot] = entry.Upgrades;
// 기본형은 처음부터 해금
if (entry.DefaultUpgrade != null)
{
UnLockedUpgradeParts[entry.Slot] = new List<WeaponUpgradeSO> { entry.DefaultUpgrade };
_appliedUpgrades[entry.Slot] = entry.DefaultUpgrade;
}
}
}
유저가 파츠를 사고 장착하는 흐름을 완벽하게 격리하기 위해 3개의 딕셔너리를 사용했다.
AllUpgradeParts: 이 무기가 가질 수 있는 슬롯 내 모든 파츠다.
UnLockedUpgradeParts: 돈 주고 사서 내 인벤토리에 들어온 사용 가능한 파츠다.
_appliedUpgrades: 그 창고에 있는 파츠 중 지금 사용중인 파츠다.
생성자가 켜지면 무기의 '순정 부품(DefaultUpgrade)'들을 자동으로 해금 창고와 장착 슬롯에 싹 넣어주며 초기화를 마친다.
Unlock)과 장착(ApplyUpgrade)#region 슬롯 온오프 관리
public bool Unlock(WeaponUpgradeSO upgrade)
{
if (!AllUpgradeParts.TryGetValue(upgrade.Slot, out var all)) return false;
if (!all.Contains(upgrade)) return false;
if (!UnLockedUpgradeParts.ContainsKey(upgrade.Slot))
UnLockedUpgradeParts[upgrade.Slot] = new List<WeaponUpgradeSO>();
UnLockedUpgradeParts[upgrade.Slot].Add(upgrade);
return true;
}
#endregion
#region 장착 해제 관리
public bool ApplyUpgrade(WeaponUpgradeSO upgrade)
{
if (!UnLockedUpgradeParts.TryGetValue(upgrade.Slot, out var unlocked)) return false;
if (!unlocked.Contains(upgrade)) return false;
_appliedUpgrades[upgrade.Slot] = upgrade;
return true;
}
#endregion
Unlock()은 해금 조건을 달성했을 때, 전체 파츠 목록(AllUpgradeParts)에서 안전 검사를 거친 뒤UnLockedUpgradeParts 딕셔너리에 추가한다. 여기있는 파츠들은 장착을 수행할수잇다.
ApplyUpgrade()는 그렇게 해금 창고에 들어온 파츠인지 최종 확인을 한 뒤, 통과하면 '현재 장착 딕셔너리'의 해당 슬롯 부위에 덮어씌운다. 슬롯 하나당 파츠는 무조건 하나만 장착되어야 하므로, 리스트가 아닌 단일 WeaponUpgradeSO를 그대로 대입해 오버라이드하는 구조다.
외부 스크립트(UI 및 발사 시스템 등)에서 WeaponLoa
dout 내부 데이터에 접근할 수 있도록 제공하는 public 메서드들이다. 각 기능은 필요한 데이터를 반환하거나 검증하는 역할을 한다.
public WeaponInstance GetInstance() => new(BaseData, new List<WeaponUpgradeSO>(_appliedUpgrades.Values));
public WeaponUpgradeSO GetApplied(UpgradeSlot slot)
{
_appliedUpgrades.TryGetValue(slot, out var upgrade);
return upgrade;
}
public bool IsPartUnlocked(WeaponUpgradeSO upgrade)
{
if (!UnLockedUpgradeParts.TryGetValue(upgrade.Slot, out var unlockedList))
return false;
return unlockedList.Contains(upgrade);
}
각각 기능은 뭐 단순하게 리턴주는거다
GetInstance() 최신 무기 인스턴스 반환 원본 데이터(BaseData)와 현재 장착된 파츠들의 값(_appliedUpgrades.Values)을 리스트로 가공하여 새로운 WeaponInstance 객체를 생성하고 반환한다.
GetApplied() 현재 장착된 파츠 조회 인자로 전달받은 UpgradeSlot 키를 기반으로 현재 장착 딕셔너리에 등록된 파츠 데이터를 찾아 반환한다.
IsPartUnlocked() 파츠 해금 여부 검증 특정 파츠가 현재 해금 리스트에 포함되어 있어 사용 가능한 상태인지 bool 값으로 반환한다.
를 하기전에 사실 먼저해야할게 있다. SO를 만들어야한다 뭐 일단 코드를 잘따라왔다면 준비는 끝났을거다.
단계별로 따라해보자.

작업공간에 SO를 생성한다 저걸 누르면 빈 SO객체가 생성된다.

기본값들을 넣어준다 이거는 뭐 아주쉽다 차후 이걸 이용해서 벨런싱도 쉽게할수있다
3.슬롯 개방 및 파츠 할당

아래 리스트에 이와같이 채워준다 참고로 디폴트 업그레이드는 선행으로 열려있는기본 파츠개념이다. 물론 디폴트에넣었다고 업글레이드에 넣는걸 까먹으면안된다.
이럼끝이다 정말쉽다!
일단 UI외관은 다음과같다.

임시로 만든거라 분할이나 기능이 좀 섞인놈도있지만 일단 여기있는기능만으로 작동이 가능하다.
뭐 이형태를 반드시 따를 필요는 전혀없다.
1-1. 상단부

펀드랑 네임은 필수다.펀드는 자본을 시각화하고 네임은 SO의 이름을 시각화할것이다.
1-2. 하단부

뭐가 좀 많긴하다 그래도 뭐 핵심만을 간추리자면 다음과같다.
컨텐츠 내부에있는 Lable은 각 TMP로 연결해서 값을 시각화한다.
디버그존에있는 버튼은 한마디로 디버그용버튼이다.
뭐 UI는 이쯤하고 이대로 안해도된다 이구조가 맘에안들면 커스텀해서 사용하는방법을 적극 추천한다.
일단이제 연결할 클래스를 만들자

역시 가장 좋은건 최상위 부분에 해당도메인 제어 클래스를두는것일꺼다.

2-1. 클래스 생성
UI부분은 좀 할게많다 그래서 빨리넘어가도록하자.
using UnityEngine;
using UnityEngine.UI;
using TMPro;
public class UpgradePanel : MonoBehaviour
{
[Header("참조")]
[SerializeField] private WeaponDataSO weaponData;
[SerializeField] private UpgradeSlot_AddOn slotEntryPrefab;
[SerializeField] private Transform contents;
[Header("스탯")]
[SerializeField] private TextMeshProUGUI AMMNameText;
[SerializeField] private TextMeshProUGUI FundsText;
[SerializeField] private TextMeshProUGUI speedText;
[SerializeField] private TextMeshProUGUI timeText;
[SerializeField] private TextMeshProUGUI seekerText;
[SerializeField] private TextMeshProUGUI dlSystemText;
[Header("버튼")]
[SerializeField] private Button fireButton;
[Header("자금")]
[SerializeField] private int funds;
private WeaponLoadout _loadout;
private void Start()
{
_loadout = new WeaponLoadout(weaponData);
// 슬롯 수만큼 SlotEntry 생성
AMMNameText.text = weaponData.name;
foreach (var entry in weaponData.AvailableUpgrades)
{
var slotEntry = Instantiate(slotEntryPrefab, contents);
slotEntry.Initialize(_loadout, entry.Slot, RefreshStats, TrySpendFunds);
}
// 발사 버튼
fireButton.onClick.AddListener(OnFireButton);
// 초기 스탯 표시
RefreshStats();
RefreshFunds();
}
public void RefreshStats()
{
var instance = _loadout.GetInstance();
speedText.text = $"{instance.ProjectileSpeed}";
timeText.text = $"{instance.LifeTime}";
seekerText.text = $"{instance.SeekerType}";
dlSystemText.text = $"{instance.DLSystem}";
}
bool TrySpendFunds(int price)
{
if (funds < price) return false;
funds -= price;
RefreshFunds();
return true;
}
void RefreshFunds()
{
FundsText.text = $"{funds} $";
}
private void OnFireButton()
{
var instance = _loadout.GetInstance();
Debug.Log($"발사!\n" +
$"Speed: {instance.ProjectileSpeed}\n" +
$"LifeTime: {instance.LifeTime}\n" +
$"Seeker: {instance.SeekerType}\n" +
$"DL: {instance.DLSystem}");
}
}
뭐 핵심만 집고 넘어가자.
2-2. UI 갱신자
클래스 내부에서 데이터의 변경을 화면에 반영하고 검증하는 핵심 로직이다.
RefreshStats() _loadout.GetInstance()를 호출하여 현재 장착된 파츠들이 실시간으로 누적 정산된 최신 WeaponInstance 객체를 받아온다. 이후 가져온 인스턴스의 필드 값들을 각각의 TextMeshPro 텍스트 컴포넌트에 대입하여 화면을 동기화한다.
RefreshFunds() 위와 비슷하게 펀드를 갱신한다.
2-3. 디버그버튼과 조건연산자
fireButton에 연결된 리스너 메서드로, 게임 뷰에서 버튼을 눌렀을 때 작동한다.UI 화면에 텍스트로만 보이던 능력치 수치들이 실제 인게임 발사 로직 영역에서도 정상적으로 주입되어 동작하는지 검증하기 위한 디버그 창구다.
TrySpendFunds(int price) 외부(하위 슬롯 프리팹)에서 특정 파츠를 해금하려 할 때 가격을 매개변수로 받아 보유 자금(funds)과 비교하는 조건 연산 메서드다. 자금이 부족하면 false를 리턴해 해금을 취소시키고, 자금이 충분하면 값을 차감한 뒤 true를 리턴하여 구매 성공 여부를 안전하게 판별한다.2-4. 그래서 슬롯은?

슬롯은 어디간거지? Start() 안에있는 이건뭐지?
_loadout = new WeaponLoadout(weaponData);
// 슬롯 수만큼 SlotEntry 생성
AMMNameText.text = weaponData.name;
foreach (var entry in weaponData.AvailableUpgrades)
{
var slotEntry = Instantiate(slotEntryPrefab, contents);
slotEntry.Initialize(_loadout, entry.Slot, RefreshStats, TrySpendFunds);
}
바로 슬롯을 생성하는 코드다 슬롯의 개수는 각 SO마다 다를수있기에 동적으로 생성해야하며 이를위해선 로드아웃의 값을 읽고
적절히 배치해야 하기때문이다 그럼 이제 해야할건 무엇인가? 슬롯을 만들여야한다..
솔직히 SO소개한다고했는데 어째 UI가 파이를먹는중이다.
각 슬롯은 슬롯별 소켓과 슬롯창내 부품을 바꿀수있는 드롭다운이있다.

1-1. 파츠 슬롯
using UnityEngine;
using TMPro;
using System;
public class UpgradeSlot_AddOn : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI slotNameText;
[SerializeField] private TextMeshProUGUI nowPartsText;
[SerializeField] private DropDown_Custom DropDown;
[SerializeField] private WeaponLoadout _loadout; // 로드아웃 참조
[SerializeField] private UpgradeSlot _slot; // 로드아웃 종류
private Action OnUpgradeChanged;
private Func<int, bool> OnSpend; // 리턴필요함
public void Initialize(WeaponLoadout loadout, UpgradeSlot slot, Action OnUpgrade, Func<int, bool> OnSpend)
{
_loadout = loadout;
_slot = slot;
slotNameText.text = slot.ToString();
OnUpgradeChanged += OnUpgrade;
this.OnSpend += OnSpend;
UpdateNowPartsText();
}
public void OpenPartsPanel()
{
var parts = _loadout.AllUpgradeParts[_slot];
DropDown.OpenDropdown(parts.Count);
var items = DropDown.GetItemComponents<UpgradeSlotDD_AddOn>();
for (int i = 0; i < items.Count; i++)
{
var index = i; // 캡처용 복사본
var part = parts[i];
bool isUnlocked = _loadout.UnLockedUpgradeParts.TryGetValue(_slot, out var unlocked) && unlocked.Contains(part);
items[index].Render(part,
isUnlocked,
onUnlock: (target) => { if (BuyParts(target)) items[index].Render(target, true, null, (onSelect) => PartSelect(onSelect)); },
onSelect: (target) => PartSelect(target));
}
}
bool PartSelect(WeaponUpgradeSO part)
{
if (!_loadout.ApplyUpgrade(part))
{
Debug.Log("교체 실패");
return false;
}
UpdateNowPartsText();
OnUpgradeChanged?.Invoke();
return true;
}
bool BuyParts(WeaponUpgradeSO part)
{
if (!OnSpend.Invoke(part.Price))
{
Debug.Log("언락 실패");
return false;
}
_loadout.Unlock(part);
return true;
}
void UpdateNowPartsText()
{
var applied = _loadout.GetApplied(_slot);
if (applied != null)
nowPartsText.text = applied.UpgradeName;
}
}
처음보면 이게뭐지 싶을수있긴하다 실제로 뭐 좀 어지럽게생기도했고 뭐 하나하나 설명하자면 너무 길어지니 함수별로 설명하겠다.
Initialize() — 초기화 및 델리게이트 참조 캐싱
상위 패널 클래스(UpgradePanel)가 슬롯을 동적 생성한 직후 호출하는 초기화 함수다.
의존성 주입: 현재 무기의 로드아웃 데이터와 본인이 어떤 부위(엔진, 시커 등)를 담당할지 UpgradeSlot 타입을 주입받는다.
대리자(Callback) 연동: 장착이 바뀌었을 때 상위 패널의 UI를 새로고침할 Action과, 돈을 쓸 수 있는지 검증하고 차감해 줄 리턴형 대리자 Func<int, bool>를 전달받아 등록한다.
OpenPartsPanel() — 커스텀 드롭다운 리스트 빌드 및 렌더링
유저가 슬롯을 클릭했을 때 장착 가능한 파츠 목록을 하단 커스텀 드롭다운 UI에 뿌려주는 핵심 함수다.
PartSelect() — 파츠 실제 장착 및 상위 UI 노티파이
드롭다운 리스트에서 특정 파츠를 클릭했을 때 실행되는 장착 처리 함수다.
BuyParts() — 조건부 재화 소모 및 파츠 해금 실행
미해금 파츠의 해금 버튼을 눌렀을 때 작동하는 구매 함수다.
에디터상에서 이렇게 연결해보자.

버튼의 경우는

일단 여긴 온클릭에 메서드를 할당했다.
지금까지 뭐가 한게 좀 많다.

하지만 여기서 끝이 아니다. 드롭다운을 만들어서 슬롯내 파츠를 뭘낄수있는지 보여줘야한다. 그러기엔 드롭다운을 생성해야한다.
아물론 기본적으로있는 드롭다운이있긴하다.. 하지만 이놈은 좀 문제가 있기에 직접 만들어보자.
1-2. 커스텀 드롭다운 클래스
using System.Collections.Generic;
using UnityEngine.UI;
using UnityEngine;
using System.Linq;
public class DropDown_Custom : MonoBehaviour
{
[Header("커스텀 드롭다운")]
[SerializeField] Transform ItemListPosition;
[SerializeField] GameObject DropdownItem;
[SerializeField] GameObject BlockerPanel;
[Header("아이템")]
[SerializeField] List<GameObject> Items = new();
bool IsOpen = false;
private GameObject activeBlocker;// 런타임 관제
public List<GameObject> GetItemObject => Items;
public List<T> GetItemComponents<T>() where T : Component => Items.Select(item => item.GetComponent<T>()).Where(item => item != null).ToList();
public bool OpenDropdown(int count)
{
if(IsOpen) return false;
IsOpen = true;
BlockerCreate();
if (ItemListPosition.TryGetComponent<LayoutGroup>(out var layoutGroup))
layoutGroup.CalculateLayoutInputHorizontal();
for (int i = 0; i < count; i++)
{
var item = Instantiate(DropdownItem, ItemListPosition);
Items.Add(item);
}
return true;
}
public bool CLoseDropdown()
{
if(!IsOpen) return false;
foreach (var item in Items)
{
item.transform.SetParent(null);
Destroy(item);
}
Items.Clear();
if (activeBlocker != null) Destroy(activeBlocker);
return true;
}
void BlockerCreate()
{
var rootCanvas = GetComponentInParent<Canvas>();
if (rootCanvas == null) return;
activeBlocker = Instantiate(BlockerPanel, rootCanvas.rootCanvas.transform);
activeBlocker.transform.SetAsFirstSibling();
if (activeBlocker.TryGetComponent<Button>(out var btn)) btn.onClick.AddListener(() => { CLoseDropdown(); IsOpen = false; });
}
}
유니티 내장 내비게이션인 TMP 드롭다운은 복잡한 데이터 바인딩이나 커스텀 프리팹 항목을 유연하게 제어하기에 다소 까다로운 제약이 있다. 이를 해결하기 위해 직접 오브젝트를 동적 생성하고 특정 컴포넌트를 직접 참조하여 가동하는 DropDown_Custom 클래스를 작성한다.
이 커스텀 드롭다운을 만든이유는 바로 다음 드롭다운내 내부요소때문이다. 그전에 뭐 UI부터 배치하자.

캔버스를만들고 오버라이딩과 소트오더를 충분히 높여주자 이래야 뒷부분에대한 영향을 막을수있다. 또한 그래픽 레이캐스터도 반드시 필요할것이다

블락커는 소트를 대상패널의 -1만해줘도된다 TMP 드롭다운도 드롭다운은 30000 블락커는29999를 사용한다.
뭐 이제 필요한건 마지막으로 각 드롭다운내 아이템을 컨트롤해야하는거다.
1-3. 파츠 장비별 패널 클래스
이제 TMP 드롭다운을 유기하고 커스텀 드롭다운을 쓴 이유가 나온다 바로 하위 아이템들한테 전파를걸어주고 입력을 받을놈이 필요하기 때문이다.
일단 UI먼저 만들어보자.

이런식으로 만들고 필드도 적당히 넣어주자 요번엔 온클릭 컴포넌트를 안쓴다.
Q. 왜 이건 OnClick()을 안쓰나요?
A. 나중에 만들어서요.
대강 이쯤하고 뭐 클래스로 넘어가도록하자
using UnityEngine;
using TMPro;
using UnityEngine.UI;
using System;
public class UpgradeSlotDD_AddOn : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI partNameText;
[SerializeField] private TextMeshProUGUI priceText;
[SerializeField] private Button PanelButton;
[SerializeField] private Button unlockButton;
public void Render(WeaponUpgradeSO part,
bool isUnlocked,
Action<WeaponUpgradeSO> onUnlock,
Action<WeaponUpgradeSO> onSelect)
{
if (unlockButton == null || priceText == null) return;
partNameText.text = part.UpgradeName;
PanelButton.onClick.RemoveAllListeners();
PanelButton.onClick.AddListener(() => onSelect?.Invoke(part));
if (isUnlocked)
{
ShowAddOn(false);
}
else
{
ShowAddOn(true);
priceText.text = $"{part.Price} $"; // 가격 기입
// 버튼 클릭 리스너 바인딩
unlockButton.onClick.RemoveAllListeners();
unlockButton.onClick.AddListener(() =>
{
onUnlock?.Invoke(part);
});
}
}
void ShowAddOn(bool show)
{
unlockButton.gameObject.SetActive(show);
priceText.gameObject.SetActive(show);
}
}
일단 내 코딩습관이자 원칙이 하나있다면 왠만하면 랜더링하는 뷰한테서는 저장할 필드값을 안준다는거다.
초기엔 편한데 나중에 데이터 파편화로 문제를 일으키는적이 한두번이 아니였다.
일단뭐 구조는 사실상 하나이며 파라미터로 선택 액션과 언락액션 바인딩 이게끝이다. 이런식으로 람다식으로 메서드를 액션으로
넘기고 바인딩을걸면 의존성을 크게 줄일수있다. C#입문자라면 참조방식도편리하지만 Action이나 Event도 적응을하면 상당히
유용할꺼다.
뭐 이제 사실상끝이다. 아이템도 연결했으니 이제 조립만하면 끝난다.
💡 왜 뒤로 갈수록 UI 내용이 압축되나..
눈치채신 분들도 있겠지만, UI 파트로 오면서 코드 양이 급격히 늘어나 내용이 다소 거칠게 마무리된 감이 있다. 사실 이번 시리즈의 원래 목적은 SO를 활용한 확장성 높은 무기 데이터 아키텍처를 다루는 것이였기 때문이다.
구현된 시스템이 실제로 굴러가는 걸 보여주려다 보니 UI 코드까지 얹어져 배보다 배꼽이 더 커지는 상황이 왔다. 자칫 본질인 데이터 구조 설명이 흐려질 것 같아, UI 파트는 흐름을 증명하는 수준에서 빠르게 끝내기로했다.
여기서 가볍게 치고 넘어간 커스텀 드롭다운 제작기, 서브 캔버싱 최적화, 제네릭 하위 컴포넌트 접근 같은 UI 주제들은 조만간 별도의 UI 전용 딥다이브 글로 돌아올 예정이다.
시연

데이터는 다음과 같다.

이쯤에서 마치겠다.