들어가기 앞서
들어오기전에 이상함을 느꼈을 것이다 바로 일차가 이상하게 스킵되어 있다는점을(16->21)
쩔수없었다 그 동안 팀프로젝트를 진행하면서 지내다보니 작성할 여유가 남지않아 이번 게시글에 본인이 진행한 부분에 대한 정리를 해보도록 하겠다
프로젝트 구상 단계
<모티브>
● 모바일 게임 '궁수의 전설'을 모티브로 함
=> 모티브 게임 간단한 분석
*출처: 본인
=>발전
● 궁수의 전설과 체스의 기물특성을 조합 |
궁수의 전설의 기본적인 필수 요소를 변형시키지 않고 뼈대를 둔 상태에서 몬스터의 패턴, 전체 게임플로우의 기본 스토리를 체스의 기물 특성을 참고삼아 진행함 |
프로젝트 구현 단계
<보상&업그레이드 시스템>
● ScriptableObject 활용한 데이터 정리
● 등급, 특정방에 따라 보상 확률 조작
● 결정한 보상에 따라 플레이어 스탯 업그레이드 적용
● UI와 연동
=>코드(설명은 코드아래)
public class RewardManager : MonoBehaviour
{
[Header("생성한 RewardData 에셋들")]
[SerializeField] List<StageRewardData> rewardDatas;
string commonUIPath = "Prefabs/UI/RewardUI";
string VkrUIPath = "Prefabs/UI/VkrRewardUI";
string AngleUIPath = "Prefabs/UI/AngleRewardUI";
string DevilUIPath = "Prefabs/UI/DevilRewardUI";
void Awake()
{
// 인스펙터 비어있으면 Resources 폴더에서 로드
if (rewardDatas == null || rewardDatas.Count == 0)
{
var loaded = Resources.LoadAll<StageRewardData>("Data");
rewardDatas = new List<StageRewardData>(loaded);
Debug.Log($"RewardManager: {rewardDatas.Count}개의 StageRewardData 로드");
}
}
public void ShowReward(ESkillGrade grade, ESkillCategory category, int pickCount)
{
// SkillManager가 초기화되었는지 확인
if (SkillManager.Instance == null)
{
Debug.LogError("SkillManager가 초기화되지 않았습니다. ShowReward를 호출할 수 없습니다.");
return;
}
// 모든 스킬 후보들을 가져오기 (grade 제한 제거하여 모든 등급이 섞여서 나오도록)
var candidates = rewardDatas.SelectMany(so => so.rewardEntries).Where(e => e.skillCategory == category).ToList();
// UI 슬롯 개수에 맞춰 보상 선택 (rewardSlots 개수만큼)
int slotCount = GetRewardSlotCount(category);
var picks = ReawrdSampling(candidates, slotCount);
//섞기
Shuffle(picks);
switch(category)
{
case ESkillCategory.LevelUp:
{
// common 방 전용 UI
GameManager.UI.ShowPopUpUI<RewardSelect_PopUpUI>(commonUIPath).Initialize(picks, ChoseReward);
}
break;
case ESkillCategory.Valkyrie:
{
// 발키리 방 전용 UI
GameManager.UI.ShowPopUpUI<VkrRewardSelect_PopUpUI>(VkrUIPath).Initialize(picks, ChoseReward);
}
break;
case ESkillCategory.Angel:
{
// 천사 방 전용 UI
GameManager.UI.ShowPopUpUI<AngleRewardSelect_PopUpUI>(AngleUIPath).Initialize(picks, ChoseReward);
}
break;
case ESkillCategory.Devil:
{
// 악마 방 전용 UI
GameManager.UI.ShowPopUpUI<DevilRewardSelect_PopUpUI>(DevilUIPath).Initialize(picks, ChoseReward);
}
break;
};
}
// UI 결정 버튼 클릭시 적용되는 함수
private void ChoseReward(StageRewardEntry entry)
{
if (entry == null)
{
Debug.LogWarning("StageRewardEntry가 null입니다.");
return;
}
if (SkillManager.Instance == null)
{
Debug.LogError("SkillManager.Instance가 null입니다. SkillManager가 초기화되지 않았습니다.");
return;
}
var skill = SkillManager.Instance.GetSkillInfo(entry.skillEffectID, entry.skillGrade, entry.skillCategory);
if (skill != null)
{
SkillManager.Instance.SelectSkill(skill);
}
else
{
Debug.LogWarning($"스킬을 찾을 수 없습니다: {entry.skillEffectID}, {entry.skillGrade}, {entry.skillCategory}");
}
}
private List<StageRewardEntry> ReawrdSampling(List<StageRewardEntry> source, int count)
{
var result = new List<StageRewardEntry>();
var pool = new List<StageRewardEntry>(source);
// 요청된 개수만큼 선택
for (int i = 0; i < count && pool.Count > 0; i++)
{
// pool이 비어있으면 다시 채우기
if (pool.Count == 0)
{
pool.AddRange(source);
}
float totalWeight = pool.Sum(e => e.baseWeight);
float roll = UnityEngine.Random.Range(0f, totalWeight);
float acc = 0f;
foreach (var e in pool)
{
acc += e.baseWeight;
if (roll <= acc)
{
result.Add(e);
pool.Remove(e); // 선택된 항목 제거
break;
}
}
}
return result;
}
private int GetRewardSlotCount(ESkillCategory category)
{
// 각 카테고리별 UI의 rewardSlots 개수를 동적으로 가져오기
string uiPath = GetUIPath(category);
if (!string.IsNullOrEmpty(uiPath))
{
// 분류에 정해진 UI에 따라 Load
var uiPrefab = GameManager.Resource.Load<GameObject>(uiPath);
if (uiPrefab != null)
{
var rewardUI = uiPrefab.GetComponent<RewardSelectBaseUI>();
if (rewardUI != null)
{
// 리플렉션을 사용하여 rewardSlots 개수 가져오기
var rewardSlotsField = typeof(RewardSelectBaseUI).GetField("rewardSlots", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (rewardSlotsField != null)
{
var rewardSlots = rewardSlotsField.GetValue(rewardUI) as List<RectTransform>;
if (rewardSlots != null)
{
return rewardSlots.Count;
}
}
}
}
}
// 기본값 반환
return 3;
}
private string GetUIPath(ESkillCategory category)
{
switch (category)
{
case ESkillCategory.LevelUp:
return commonUIPath;
case ESkillCategory.Valkyrie:
return VkrUIPath;
case ESkillCategory.Angel:
return AngleUIPath;
case ESkillCategory.Devil:
return DevilUIPath;
default:
return commonUIPath;
}
}
private void Shuffle<T>(IList<T> list)
{
for (int i = list.Count - 1; i > 0; i--)
{
int j = UnityEngine.Random.Range(0, i + 1);
(list[i], list[j]) = (list[j], list[i]);
}
}
}
> 엄... 일단 길다 호출 순서에 맞게 설명을 이어나가 보겠다
1. 데이터 불러오기 |
우선 미리 [System.Serializable]와 ScriptableObject로 만들어둔 데이터를 List로 불러온다 |
2. ShowReward 메소드 |
1. SkillManager클래스의 초기화 확인 2. StageRewardData 데이터 List에서 모든 스킬 후보들을 가져오기 (모든 등급이 섞여서 나오도록) - SelectMany로 리스트의 구조를 정리해준다 - Where을 통해 정리된 리스트중 조건에 일치하는 항목만 두고 나머지는 걸러낸다 - .ToList()로 필터링된 리스트 반환 3. UI 슬롯 개수에 맞춰 보상 선택 (rewardSlots 개수만큼) 4. 한번 셔플후 현재 플레이어가 진입한 방종류에 맞는 보상UI 띄우기 |
3. ChoseReward 메소드 |
UI에서 고른보상의 데이터를 탐색하여 SkillManager에 등록되어있는 버프 연산 호출 |
● ReawrdSampling |
1. 슬롯의 갯수만큼 채우기 시작 2. 랜덤확률인데 보상마다 각자 있는 가중치에 따라 확률이 정해짐, 가중치가 클수록 뽑힐 확률이 높아짐 ex. 가중치별 확률 (%)(3개일경우) A. | 가중치: 5 | 확률: 5 / (5+3+2) = 50% B. | 가중치: 3 | 확률: 3 / (5+3+2) = 30% C. | 가중치: 2 | 확률: 2 / (5+3+2) = 20% |
● GetRewardSlotCount |
방종류가 4개이다 보니 사용하는 UI가 4개이고 슬롯또한 UI 마다 갯수가 다르기애 동적으로 슬롯 갯수를 가저오도록 기능을 구현함 - typeof.GetField으로 특정값에 데이터 가저오기 - System.Reflection.BindingFlags.NonPublic로 런타임중 동적으로 클래스안에 변수를 가저올수있게 함 .NonPublic로 하여 접근자 상관없이 가저오게 함 |
>어때요 정리하니까 어떤 클래스인지 알겠죠?
<보상 시스템 UI연동>
● 이전 프로젝트때 사용한 UIManager 재사용
● 오브젝트풀 활용한 UI 재사용
public abstract class RewardSelectBaseUI : PopUpUI
{
[SerializeField] protected List<RectTransform> rewardSlots = new List<RectTransform>();
protected List<StageRewardEntry> entries;
protected Action<StageRewardEntry> onChosen;
protected StageRewardEntry selectedEntry;
protected override void Awake()
{
base.Awake();
if (buttons.ContainsKey("DecideButton"))
{
buttons["DecideButton"].onClick.AddListener(OnDecideClicked);
}
}
protected override void OnDisable()
{
base.OnDisable();
ClearSlots();
}
public virtual void Initialize(List<StageRewardEntry> entries, Action<StageRewardEntry> onChosen)
{
this.entries = entries;
this.onChosen = onChosen;
this.selectedEntry = null;
StartCoroutine(OpenRoutine());
}
protected virtual IEnumerator OpenRoutine()
{
// 애니메이션이 있다면 대기
yield return new WaitForSeconds(0.1f);
// 슬롯 초기화 (자식 오브젝트들 제거)
ClearSlots();
// 보상 슬롯 생성
CreateRewardSlots();
}
protected virtual void CreateRewardSlots()
{
for (int i = 0; i < entries.Count && i < rewardSlots.Count; i++)
{
var entry = entries[i];
var slot = rewardSlots[i];
if (entry.uiPrefab != null && slot != null)
{
GameObject obj = GameManager.Resource.Instantiate(entry.uiPrefab, slot);
obj.transform.localPosition = Vector3.zero;
// 버튼이 있다면 해당 엔트리 선택 이벤트 연결
if (obj.TryGetComponent<Button>(out var btn))
{
btn.onClick.RemoveAllListeners();
btn.onClick.AddListener(() => selectedEntry = entry);
}
}
}
}
protected virtual void ClearSlots()
{
if (rewardSlots == null) return;
foreach (var slot in rewardSlots)
{
if (slot != null)
{
ClearSlotChildren(slot);
}
}
}
protected virtual void ClearSlotChildren(RectTransform slot)
{
if (slot == null) return;
// 자식들을 배열로 복사해서 반복 중 수정 방지
List<Transform> childrenToRemove = new List<Transform>();
for (int i = 0; i < slot.childCount; i++)
{
Transform child = slot.GetChild(i);
if (child != null)
{
childrenToRemove.Add(child);
}
}
// 모든 자식들을 한 번에 처리
foreach (Transform child in childrenToRemove)
{
if (child != null)
{
try
{
// 풀에서 관리되는 오브젝트는 풀에 반환
if (GameManager.Pool.IsContain(child.gameObject))
{
GameManager.Pool.Release(child.gameObject);
}
else
{
// 풀에서 관리되지 않는 오브젝트는 파괴
GameManager.Resource.Destroy(child.gameObject);
}
}
catch (System.Exception e)
{
Debug.LogWarning($"자식 오브젝트 정리 중 오류 발생: {e.Message}");
}
}
}
}
protected virtual void OnDecideClicked()
{
var result = selectedEntry ?? (entries.Count > 0 ? entries[0] : null);
// 버튼 클릭시 선택된 보상 적용
onChosen?.Invoke(result);
GameManager.UI.ClosePopUpUI();
}
}
● UI같은 경우 슬롯에 데이터를 자식오브젝트로 장착하고 pool로 관리하는것이 메인이여서 나머지는 코드 사이사이 주석을 확인하면 금방 순서 이해가 가능하다
<보상 데이터 관리(Serializable&ScriptableObject)>
● UI프리팹과 여러 데이터값을 관리하기 위해 ScriptableObject제작
=> 직렬데이터
[System.Serializable]
public class StageRewardEntry
{
[Header("UI프리팹 등록")]
public GameObject uiPrefab;
[Header("보상 스킬 ID (Skill.EffectID)")]
public string skillEffectID;
[Header("보상 이름")]
public string rewardInfo;
[Header("스킬 등급")]
public ESkillGrade skillGrade;
[Header("스킬 카테고리")]
public ESkillCategory skillCategory;
[Header("기본 드롭 가중치")]
[Tooltip("값이 클수록 뽑힐 확률이 높아짐")]
public float baseWeight = 1f;
[Header("(선택)보상 설명")]
[TextArea] public string description; // (선택) 보상 설명
}
● 여러 데이터를 생성하는것 보다 같은 데이터는 한 데이터파일안에 목록으로 관리하기 위해 데이터 직렬화 시킴
=> ScriptableObject
[CreateAssetMenu(fileName = "StageRewardData", menuName = "Reward/StageRewardData")]
public class StageRewardData : ScriptableObject
{
public List<StageRewardEntry> rewardEntries;
[Tooltip("Index = 방 번호 (0=첫 방, 1=두 번째 방, …)")]
public StageRewardEntry GetRewardForStage(int stageIndex)
{
if (stageIndex < 0 || stageIndex >= rewardEntries.Count)
return null;
return rewardEntries[stageIndex];
}
public Skill GetSkillReward(StageRewardEntry entry)
{
return GameManager.SkillReward.GetSkillInfo(entry.skillEffectID, entry.skillGrade, entry.skillCategory);
}
}
● List로 데이터를 불러와서 목록화 진행
● 등록한 데이터를 확인하기 위한 탐색 메소드 제작
● +와 - 통해 목록 추가/삭제로 쉽게 데이터 여러개를 관리할수있음
<보상주는 조건>
● 방종류와 총 던전방의 갯수를 이용한 클리어 카운트를 계산
● 계산되는 값에 따라 방의 종류를 순서를 정함
public void CheckStageClear()
{
var unitDict = BattleManager.GetInstance.GetUnitDIct;
if (unitDict.Count == 1)
{
var last = unitDict.Last().Key.gameObject;
if (last.CompareTag("Monster")) return;
if (last.CompareTag("Player"))
{
clearCount--;
if (clearCount > 0)
{
int roomsCleared = 23 - clearCount;
int[] groupSizes = { 3, 1, 2, 1, 1 };
ESkillCategory[] categories = { ESkillCategory.LevelUp, ESkillCategory.Valkyrie, ESkillCategory.LevelUp, ESkillCategory.Angel, ESkillCategory.Devil };
int cycleLength = groupSizes.Sum();
int posInCycle = (roomsCleared - 1) % cycleLength;
int cumulative = 0, groupIndex = 0;
for (int i = 0; i < groupSizes.Length; i++)
{
cumulative += groupSizes[i];
if (posInCycle < cumulative)
{
groupIndex = i;
break;
}
}
int pickCount = groupSizes[groupIndex];
ESkillCategory cat = categories[groupIndex];
var grades = Enum.GetValues(typeof(ESkillGrade)).Cast<ESkillGrade>().ToArray();
var randomGrade = grades[UnityEngine.Random.Range(0, grades.Length)];
Reward.ShowReward(randomGrade, cat, pickCount);
}
else
{
GameManager.UI.ShowPopUpUI<ClearGameUI>("Prefabs/UI/ClearGameUI");
}
}
}
}
● 구조 |
1. 방안에 플레이어, 몬스터 전부 포함한 BattleManager 클래스에서 리스트 불러오기 2. 방안에 오브젝트(플레이어 or 몬스터) 하나 남으면 조건문 실행 3. 플레이어가 죽을경우 다른 클래스에서 처리하기 때문에 몬스터만 남을경우 return처리 4. 3(첫번째때), 1(네번째때), 2, 1, 1 순으로 방의 종류가 바뀌고 23번 방을 클리어하게 됨 5. 방의 종류에 맞게 보상시스템 연동(RewardManager.ShowReward(randomGrade, cat, pickCount);) 6. 23번 전부 클리어 될경우 게임 클리어 UI띄우기 |
버그수정
● UI canvas가 한 Scene에서만 존재 |
원인 : 다른 Scene에서 게임 내내 쓰일 canvas을 다른 Scene에서만 생성하고 거기서만 쓰임 => 해결 : 생성된 캔버스는 UI매니저 아래 DontDestroyOnLoad로 관리 |
● 23번에 보상동안 몇몇 횟수에서 제대로 보상데이터가 슬롯에 적용되지 않는 문제 발생 |
원인 : 1. UI에서 OpenRountine()에서 자식들을 제거할 때 GameManager.Resource.Destroy로 파괴하여 자식오브젝트를 다시 불러올때 최대수량이 정해진 상태에서 몇번 시도하면 결국 부족해저서 보상이 적게 장착되며 ClearSlotChildren에서 오브젝트가 제대로 정리되지 않아서 다음 UI에서 슬롯이 부족한 현상이 나타남 2. RewardManager에서 ChoseReward 메서드에서 SkillManager.Instance가 null일 수 있음 3. RewardManager에서 슬롯의 갯수를 정확히 알수없음 => 해결 : 1. PoolManager를 통해 UI와 data리스트를 풀링으로 관리 2. SkillManager.Instance가 null일 경우에 대비한 예외처리 진행 3. RewardManager에서 UI슬롯갯수를 알수있게 동적으로 탐색 및 할당을 진행(아닐경우 기본 3개반환) |
마치며.
개발자도 결국 R&D를 진행하는 연구하는 직종이라고 생각한다
그럼 연구하는 사람들은 어떠한 습관을 잘 가저야 할까?
개인적인 생각으론 그건 바로 항상 기록하는 습관이라고 생각한다 미디어나 다른 공학 연구원들을 보면 연구일지, 연구노트를 작성한다
해당 기록을 적는것은 일기가 아닌 나의 연구기록으로 이러한 과정을 걸처서 발전된 과정을 보면 더욱 발전할수있다고 생각한다
온고지신의 마음!
그러므로 기록을 생활화 해야될것 같다
일다 나부터
'유니티개발 TIL' 카테고리의 다른 글
9주차 2일 TIL_유니티 입문(3D프로젝트_플레이어AI만들기) (0) | 2025.08.26 |
---|---|
6주차 4일 TIL_유니티 입문(3D프로젝트_new InputSystem의 고찰) (6) | 2025.08.07 |
5주차 2일 TIL_유니티 입문(유사 2D메타버스 만들기_플래피버드, UI작업) (6) | 2025.07.29 |
5주차 1일 TIL_유니티 입문(유사 2D메타버스 만들기_버그수정) (1) | 2025.07.28 |
4주차 5일 TIL_유니티 입문(유사 2D메타버스 만들기_펜싱 미니게임 기초) (2) | 2025.07.25 |