내일배움캠프 13일차

박나연·2025년 4월 23일

내배캠

목록 보기
13/69

아이템 랜덤 드롭... 이렇게 어려운 거였나?

오늘의 키워드 : 너무 많은 일이 있었어...

사람들이 진도가 빠른지 벌써 필수기능이 모두 구현되었다. 그래서 도전기능을 구현해보려 하는데 내가 맡은 부분은 몬스터 종류 추가, 아이템 추가(포션, 방어구, 무기), 몬스터별 아이템 드롭기능 구현이었다. 몬스터 종류야 뭐 기존에 있던 코드에서 한두줄씩만 추가하면 되니 어려울 것은 없었고, 아이템도 저번 개인 과제에서 해봤으니 쉽게 할 수 있었다. 하지만 문제는 몬스터별 아이템 드롭 기능 구현이었다....일단 까먹지 않게 오늘 구현한 것들을 정리해보는 시간을 가져보자.

Enums.cs

    public enum ITEMTYPE
    {
        ARMOR,
        WEAPON,
        POTION
    }

우리게임은 방어구, 무기, 포션을 사용할 계획이므로 추가해주었다.

Item.cs

    public class Item
    {
        public int Id { get; }              // 메뉴 선택용 번호
        public string Name { get; }         // 아이템 이름
        public int ATKbonus { get; }        // 공격력 추가
        public int DEFbonus { get; }        // 방어력 추가
        public int Cost { get; }            // 골드 가격
        public string Description { get; }  // 아이템 설명
        public bool IsPurchased { get; set; }  // 구매 여부
        public int HealAmount { get; }      //회복량
        public bool IsEquipped { get; set; } = false; //

        public ITEMTYPE ItemCategory { get; }

        public Item(int id, ITEMTYPE itemCategory, string name, int atk, int def, int cost, string desc, int healAmount = 0)
        {
            Id = id;
            ItemCategory = itemCategory;
            Name = name;
            ATKbonus = atk;
            DEFbonus = def;
            Cost = cost;
            Description = desc;
            HealAmount = healAmount;
        }
    }

우리 아이템들이 가지게 될 정보다.

ItemDatabase.cs

    public static class ItemDatabase
    {
        public static List<Item> Items = new List<Item>
        {
            new Item(1, ITEMTYPE.ARMOR,"수련자 갑옷", 0, 5, 1000, "수련에 도움을 주는 갑옷입니다."),//방어구1
            new Item(2, ITEMTYPE.ARMOR, "무쇠갑옷",   0, 9, 2000, "무쇠로 만들어져 튼튼한 갑옷입니다."),//방어구2
            new Item(3, ITEMTYPE.ARMOR, "스파르타의 갑옷", 0, 15, 3500, "스파르타의 전사들이 사용했다는 전설의 갑옷입니다."),//방어구3
            new Item(4, ITEMTYPE.WEAPON, "낡은 검", 2, 0, 600, "쉽게 볼 수 있는 낡은 검 입니다."),//무기1
            new Item(5, ITEMTYPE.WEAPON, "청동 도끼", 5, 0, 1500, "어디선가 사용됐던것 같은 도끼입니다."),//무기2
            new Item(6, ITEMTYPE.WEAPON, "스파르타의 창", 7, 0, 5000, "스파르타의 전사들이 사용했다는 전설의 창입니다."),//무기3

            new Item(100, ITEMTYPE.POTION, "소형물약", 0, 0, 100, "Hp 20 회복", 20),//더 추가 가능
            new Item(101, ITEMTYPE.POTION, "중형물약", 0, 0, 200, "Hp 50 회복", 50)
        };
    }

아이템 타입도 추가되었고, 포션도 추가되었다. 확실한 구분을 위해 아이디를 확 다르게 했다.

MonsterItemDrop.cs

가장 어렵고 힘들었던... MonsterItemDrop을 차근차근 살펴보자
일단 내가 임시로 만든 몬스터 별 아이템 드롭 기획이다.
<기획>
레벨 1 몬스터 : 소형포션 1개 고정드랍 / 방어구(id : 1), 무기(id : 4) 중에서 하나 드랍
레벨 2 몬스터 : 소형포션 1~2개 드랍 / 방어구(id : 1), 무기(id : 4) 중에서 하나만 줄 확률 50퍼, 둘 다 줄 확률 50퍼
레벨 3 몬스터 : 소형포션 1~3개 드랍 / 방어구(id : 2), 무기(id : 5) 중에서 하나 드랍
레벨 4 몬스터 : 소형포션 3개 드랍 또는 중형포션 1~2개 드랍 / 방어구(id : 2), 무기(id : 5) 중에서 하나만 줄 확률 50퍼, 둘 다 줄 확률 50퍼
레벨 5 몬스터 : 중형포션 2~3개 드랍 / 방어구(id : 3), 무기(id : 6) 중에서 하나만 줄 확률 50퍼, 둘 다 줄 확률 50퍼

확정은 아니고 나중에 충분히 수정될 수 있다. 이런 저런 경우를 다 가정해보고 싶어서 이렇게 기획해봤다.

        public class DropConfig
        {
            //드랍할 포션 아이템 id랑 최소 최대값 받아서 몇 개 드랍할지 범위
            public (int potionId, int minCount, int maxCount) PotionRule { get; }
            //그 레벨에서 가능한 방어구와 무기 id
            public int[] ArmorIds { get; }
            public int[] WeaponIds { get; }
            public bool AlwaysOne { get; }   // true무조건 하나 false 50% 확률로 둘 다

            public DropConfig(
                (int potionId, int minCount, int maxCount) potionRule,
                int[] armorIds,
                int[] weaponIds,
                bool alwaysOne
            )
            {
                PotionRule = potionRule;
                ArmorIds = armorIds;
                WeaponIds = weaponIds;
                AlwaysOne = alwaysOne;
            }
        }
  • PotionRule은 어떤 포션(id) 몇 개를 최소, 최대 범위로 드랍할지 결정한다.
  • ArmorIds, WeaponIds는 배열 형태로 몬스터 레벨에서 드롭할 수 있는 방어구, 무기 Id 목록이다.
  • AlwawOne가 true면 방어구, 무기 중 하나만 / false면 절반 확률로 둘 다 주거나, 아니면 하나만 드롭하게 한다.
  • 생성자에서 위 4가지 값을 받아 프로퍼티에 할당한다.
        public static class MonsterDropTable
        {
            public static readonly DropConfig[] Configs = {
                null,  // 인덱스 0은 사용하지 않음
                new DropConfig((100,1,1), new[]{1}, new[]{4}, true),   // Lv1
                new DropConfig((100,1,2), new[]{1}, new[]{4}, false),  // Lv2
                new DropConfig((100,1,3), new[]{2}, new[]{5}, true),   // Lv3
                new DropConfig((100,3,3), new[]{2}, new[]{5}, false),  // Lv4
                new DropConfig((101,2,3), new[]{3}, new[]{6}, false),  // Lv5
            };
        }
  • 아까 기획을 토대로 적혀있다. 배열 요소마다 DropConfig를 넣어두었다.
  • 외부에서는 Configs[monsterLv]하나만 꺼내면 해당 레벨의 드랍 룰이 모두 담겨 있다.
        public class DropResult
        {
            public List<(string name, int count)> PotionDrops { get; }
            public List<(string name, int count)> EquipDrops  { get; }

            public DropResult(
                List<(string Name, int Count)> potionDrops,
                List<(string Name, int Count)> equipDrops)
            {
                PotionDrops = potionDrops;
                EquipDrops  = equipDrops;
            }
        }

DropResult는 드랍 결과를 담는다.

  • PotionDrops는 포션 종류 및 개수를 이름, 개수 리스트로 담는다. EuipDrops도 동일한 구조로 담아둔다.
  • UI에서는 이 두 프로퍼티만 보이면 된다.
        public DropResult MonsterDrops(int monsterLv)
        {
            var drops = new List<Item>();
            var allItems = ItemDatabase.Items;
            var cfg = MonsterDropTable.Configs[monsterLv];

            //포션 드랍
            int pid = cfg.PotionRule.potionId;
            int pmin = cfg.PotionRule.minCount;
            int pmax = cfg.PotionRule.maxCount;
            int pcount = random.Next(pmin, pmax + 1);
  ///////////
  /////////
        }

실제 드랍 로직은 이 MonsterDrops에서 이루어진다.

  • drops는 뽑힌 아이템을 담을 리스트이다.
  • ItemDatabase를 한번에 가져와 allItems를 담는다.
  • cfg는 현재 몬스터 레벨에 맞는 DropConfig를 저장한다.

기획 특이 사항으로 레벨이 4인 몬스터만 소형 포션 3개 또는 중형포션 1~2개라는 조건이 있었다.

            // Lv.4 몬스터 특수 조건
            // 중형 포션 드랍 조건(소형 3개 또는 중형 1 ~ 2개 확률 50퍼)
            if (monsterLv == 4 && random.NextDouble() < 0.5)
            {
                //중형 포션 1~2개
                pid = 101;
                pmin = 1;
                pmax = 2;
                pcount = random.Next(pmin, pmax + 1);
            }

            //id가 pid와 같은 첫 번째 아이템 객체를 찾아 potion 변수에 담기
            var potion = allItems.First(i => i.Id == pid);
            for (int i = 0; i < pcount; i++)
                drops.Add(potion);//같은 potion객체를 리스트에 차례로 추가

id == pid인 아이템을 찾아 pcount번 만큼 drops리스트에 추가한다.

            bool oneOnly = cfg.AlwaysOne;
            if (oneOnly)
            {
                // armor 또는 weapon 중 하나만 랜덤
                var choice = random.NextDouble() < 0.5 ? armors[0] : weaps[0];
                drops.Add(choice);
            }
            else
            {
                // 50% 확률로 둘 다, 아니면 하나만
                if (random.NextDouble() < 0.5)
                {
                    drops.AddRange(armors);
                    drops.AddRange(weaps);
                }
                else
                {
                    drops.Add(random.NextDouble() < 0.5 ? armors[0] : weaps[0]);
                }
            }

AlwaysOne == true라면 방어구 0번 또는 무기 0번 중 하나.
AlwaysOne == false라면 50퍼센트 확률로 둘다, 아니면 하나만
armors[0], weaps[0]이냐면

            //장비 드랍
            //아이템 리스트에서 id가 같은 첫번째 객체를 꺼내라
            var armors = cfg.ArmorIds.Select(id => allItems.First(i => i.Id == id)).ToList();//지정된 방어구 id에 대응하는 객체 리스트
            var weaps = cfg.WeaponIds.Select(id => allItems.First(i => i.Id == id)).ToList();

이렇게 아이템 리스트에서 id가 같은 객체 첫번째를 꺼내고 [0]에 저장했기 때문이다.

            //그룹핑
            var potionGroups = drops
                .Where(i => i.ItemCategory == ITEMTYPE.POTION)
                .GroupBy(i => i.Name)
                .Select(g => (Name: g.Key, Count: g.Count()))
                .ToList();

            var equipGroups = drops
                .Where(i => i.ItemCategory != ITEMTYPE.POTION)
                .GroupBy(i => i.Name)
                .Select(g => (Name: g.Key, Count: g.Count()))
                .ToList();
                
           return new DropResult(potionGroups, equipGroups);

이 부분을 좀 더 뜯어보자면

1.

var potionGroups = drops .Where(i => i.ItemCategory == ITEMTYPE.POTION)
drops안에 모든 Item을 순회하면서 아이템 카테고리가 포션인 요소만 필터한다.

2.

.GroupBy(i => i.Name)
필터된 포션 리스트에서 Name 프로퍼티(소형 물약, 중형물약)를 기준으로 같은 이름끼리 묶어서 그룹을 만든다. 이 때 각 그룹에는

  • g.Key : 그룹을 묶은 기준값(이름)
  • g : 그 이름을 가진 아이템들의 컬렉션
  • g.Count() : 그룹 내 요소 개수가 담겨있다.

3.

.Select(g => (Name: g.Key, Count: g.Count()))
GroupBy로 만들어진 그룹들을 이름과 개수 두가지 정보만 담긴 튜플로 바꾼다. 예를 들어 그룹 키가 '소형물약'이고 그 그룹의 크기가 3이면 (Name: "소형 물약", Count: 3)이 나오는 것이다.

4.

.ToList()
최종적으로 List<(string Name, int Count)>형태로 바꿔주는 메서드이다.

equipGroups도 같은 패턴이지만 포션이 아닌 장비 아이템들만 필러링한 뒤 동일한 과정을 거치게 했다.

5.

return new DropResult(potionGroups, equipGroups);

최종적으로 그룹화된 결과를 DropResult객체에 담아 변환해 다른 스크립트에서 이 결과를 받아 활용할 수 있게 되었다.

정리하기

  • DropConfig에 드랍 규칙을 담아두고, MonsterDrops는 그 규칙만 읽어서 뽑고 그룹핑하는 실제 동작을 담당한다.
  • 레벨별 드랍 룰이 추후 변동된다면 MonsterDropTable.Configs만 수정해주면 된다.
  • LINQ를 활용해 First(). Where(), GroupBy()로 조회->필터->그룹핑 과정을 간결하게 하였다.

마무리하며

오늘은 도전과제도 진행하고 계속 머지하면서 하루를 보냈다. 중간에 다같이 튜터님께 찾아가 질문도 했다.

팀원들이 내가 구현한 부분에 질문을 하거나 어디가 안된다는 말을 들으면 아직까진 맘에 철렁하지만.. 어떻게 해내고는 있다. 내일이나 모레쯤에 마무리가 될 것 같은데 큰 문제없이 마무리 되었으면 하는 바람이다.

트러블 슈팅이랑 튜터님 상담도 어땠는지 적고싶은데... 나중에 추가하던가 하겠다...

0개의 댓글