디아블로 스타일 전리품 테이블 알고리즘: 가중치, 등급, 피티 시스템

디아블로 스타일 전리품 테이블 알고리즘: 가중치, 등급, 피티 시스템

가중치 기반 드롭과 아이템 등급 롤링을 분리해 설계하는 방법을 정리합니다. 재현 가능한 난수, 중복 방지, 피티(pity) 시스템까지 구현 관점에서 다룹니다.

전리품 테이블을 한 번에 뽑지 말아야 하는 이유

디아블로 스타일의 드롭 시스템은 보통 몬스터가 아이템을 드롭하는가드롭된 아이템이 무엇인가를 별개 문제로 다룹니다. 여기에 희귀도, 아이템 레벨, 지역, 난이도, 플레이어 상태가 얹히므로 단일 확률표 하나로 관리하면 빠르게 복잡해집니다.

실용적인 구조는 다음처럼 여러 단계의 롤(roll)을 조합하는 방식입니다. 각 단계가 독립적이면 밸런스를 조정할 때 영향 범위를 예측하기 쉽고 디버깅 로그도 읽기 좋아집니다.

flowchart TD
    A[처치 이벤트] --> B[드롭 횟수 결정]
    B --> C{각 드롭 슬롯}
    C --> D[아이템 그룹 선택]
    D --> E[희귀도 결정]
    E --> F[후보 아이템 필터링]
    F --> G[가중치로 아이템 선택]
    G --> H[옵션 및 수치 생성]
    H --> I[인벤토리 또는 월드에 생성]
    C -->|남은 슬롯| C

데이터 모델: 아이템과 드롭 규칙을 분리한다

아이템 정의에는 이름, 장비 슬롯, 기본 능력치처럼 아이템 자체의 정보를 둡니다. 반면 드롭 테이블에는 어느 몬스터가 어떤 조건에서 그 아이템을 후보로 삼을지 둡니다. 같은 검이라도 보스, 일반 몬스터, 상자에서 서로 다른 확률로 등장할 수 있기 때문입니다.

다음은 엔진과 무관하게 적용할 수 있는 C++ 형태의 최소 데이터 모델입니다.

#include <string>
#include <vector>

enum class Rarity {
    Normal,
    Magic,
    Rare,
    Legendary
};

struct ItemDefinition {
    std::string id;
    std::string group;       // "weapon", "armor", "material" 등
    int requiredLevel;
    Rarity minRarity;
    Rarity maxRarity;
};

struct LootEntry {
    std::string itemId;
    int weight;              // 0보다 큰 정수 가중치
    int minItemLevel;
    int maxItemLevel;
    bool uniquePerKill;      // 한 번의 처치에서 중복을 막을지 여부
};

struct LootTable {
    std::string id;
    std::vector<LootEntry> entries;
};

weight는 퍼센트가 아니라 상대적인 비율입니다. 예를 들어 세 항목의 가중치가 70, 25, 5라면 각각 70%, 25%, 5%가 됩니다. 하지만 700, 250, 50으로 함께 바꿔도 결과는 같으므로 확률값처럼 보이는 이름보다 weight라는 이름이 오해를 줄입니다.

가중치 랜덤 선택

가중치 선택의 기본 구현은 전체 가중치의 합 안에서 난수를 뽑고 누적 가중치를 넘는 첫 항목을 고르는 방식입니다.

#include <random>
#include <stdexcept>

template <typename T, typename WeightFn>
const T& PickWeighted(const std::vector<T>& candidates,
                      WeightFn getWeight,
                      std::mt19937& rng) {
    int totalWeight = 0;

    for (const T& candidate : candidates) {
        const int weight = getWeight(candidate);
        if (weight > 0) {
            totalWeight += weight;
        }
    }

    if (totalWeight <= 0) {
        throw std::runtime_error("선택 가능한 전리품 가중치가 없습니다.");
    }

    std::uniform_int_distribution<int> distribution(1, totalWeight);
    const int roll = distribution(rng);

    int accumulatedWeight = 0;
    for (const T& candidate : candidates) {
        accumulatedWeight += std::max(0, getWeight(candidate));
        if (roll <= accumulatedWeight) {
            return candidate;
        }
    }

    throw std::runtime_error("가중치 선택 계산이 예상과 다릅니다.");
}

이 방식은 후보가 수십~수백 개인 일반적인 드롭 테이블에 충분합니다. 후보가 수천 개이고 매우 자주 호출된다면 누적 가중치 배열과 이진 탐색, 또는 Alias Method를 검토할 수 있습니다. 다만 테이블이 전투 중 갱신되는 게임에서는 단순 구현이 데이터 갱신과 검증에 유리한 경우가 많습니다.

희귀도와 아이템 종류를 분리해 롤링한다

희귀도를 아이템 항목별 가중치에 직접 섞으면 희귀도 조정이 어렵습니다. 예를 들어 전설 아이템 확률을 두 배로 만들려면 모든 전설 항목을 수정해야 할 수 있습니다. 먼저 희귀도를 결정하고 이후 해당 희귀도를 만들 수 있는 아이템만 후보로 거르는 편이 낫습니다.

struct RarityWeight {
    Rarity rarity;
    int weight;
};

Rarity RollRarity(std::mt19937& rng) {
    const std::vector<RarityWeight> weights = {
        { Rarity::Normal,    7000 },
        { Rarity::Magic,     2400 },
        { Rarity::Rare,       550 },
        { Rarity::Legendary,   50 }
    };

    return PickWeighted(weights,
        [](const RarityWeight& entry) { return entry.weight; }, rng).rarity;
}

그 다음 현재 아이템 레벨과 선택된 희귀도를 기준으로 후보를 필터링합니다. 필터 결과가 비어 있으면 상위 희귀도를 강제로 하위 희귀도로 내리기보다 테이블 오류로 기록하고 명시적인 대체 규칙을 적용하는 편이 안전합니다. 예를 들어 전설 후보가 없으면 희귀 아이템을 지급한다는 정책을 코드와 데이터에 모두 드러내야 합니다.

드롭 횟수와 중복 규칙

한 몬스터가 아이템을 몇 개 생성하는지도 별도 롤로 두는 것이 좋습니다. 보스는 최소 2개, 최대 4개를 드롭하고 일반 몬스터는 0~1개를 드롭하도록 설정할 수 있습니다.

같은 처치에서 고유 아이템이 중복되면 안 된다면 이미 선택한 itemId를 집합에 기록한 뒤 다음 후보를 필터링합니다. 후보가 부족할 때 무한 재시도가 발생하지 않도록 최대 시도 횟수 또는 명시적인 대체 테이블을 둬야 합니다.

#include <unordered_set>

std::vector<LootEntry> FilterCandidates(
    const LootTable& table,
    int itemLevel,
    const std::unordered_set<std::string>& selectedIds) {

    std::vector<LootEntry> result;
    for (const LootEntry& entry : table.entries) {
        const bool levelAllowed = entry.minItemLevel <= itemLevel
                               && itemLevel <= entry.maxItemLevel;
        const bool duplicated = entry.uniquePerKill
                             && selectedIds.contains(entry.itemId);

        if (levelAllowed && !duplicated && entry.weight > 0) {
            result.push_back(entry);
        }
    }
    return result;
}

피티 시스템은 확률을 숨기기보다 규칙으로 관리한다

낮은 확률의 보상은 독립 시행만으로 처리하면 긴 실패 구간이 생길 수 있습니다.

피티(Pity) 시스템은 게임, 특히 가챠(확률형 뽑기) 게임에서 유저가 운이 없더라도 일정 횟수 이상 도전하면 최고 등급 아이템이나 캐릭터를 확정적으로 얻을 수 있게 해주는 ‘불쌍함(Pity) 보상 안전장치’입니다.

가장 이해하기 쉬운 방식은 실패 횟수에 비례한 보정값입니다.

double GetLegendaryChance(int missesSinceLegendary) {
    constexpr double baseChance = 0.005; // 0.5%
    constexpr double bonusPerMiss = 0.001;
    constexpr double maxChance = 0.05;

    const double chance = baseChance + missesSinceLegendary * bonusPerMiss;
    return std::min(chance, maxChance);
}

이 카운터는 계정 단위인지 캐릭터 단위인지 특정 콘텐츠 단위인지 먼저 결정해야 합니다. 또한 전설 보상 획득 시 카운터를 0으로 초기화할지 일부만 감소시킬지도 경제 설계의 일부입니다. 피티 시스템이 있다면 테스트와 운영 분석을 위해 현재 실패 횟수, 최종 확률, 지급 이유를 로그로 남기는 편이 좋습니다.

재현 가능한 난수와 검증

드롭 버그를 조사하려면 같은 입력에서 같은 결과를 재생성할 수 있어야 합니다. 서버 권한 게임이라면 서버에서 난수 시드를 관리하고 클라이언트는 결과만 받아 표시하는 것이 기본입니다. 클라이언트가 최종 드롭을 결정하면 조작 위험이 커집니다.

테스트에서는 고정 시드의 난수 생성기를 사용해 결과를 재현하고 대량 시뮬레이션으로 기대 비율을 검증합니다. 예를 들어 100만 번 시행한 관측값이 설정 비율과 크게 다르다면 난수 구현보다 먼저 후보 필터, 중복 제거, 가중치 합산이 의도대로 작동하는지 확인합니다.

void SimulateLoot(const LootTable& table, int trials) {
    std::mt19937 rng(12345); // 테스트 전용 고정 시드
    std::unordered_map<std::string, int> counts;

    for (int i = 0; i < trials; ++i) {
        const LootEntry& entry = PickWeighted(
            table.entries,
            [](const LootEntry& value) { return value.weight; },
            rng);
        ++counts[entry.itemId];
    }

    // counts를 CSV 또는 테스트 리포트로 내보내 설정값과 비교한다.
}

구현 체크리스트

  • 드롭 횟수, 희귀도, 아이템 선택, 옵션 생성을 서로 다른 단계로 분리한다.
  • 가중치는 상대값으로 저장하고 합계가 0인 테이블을 데이터 검증 단계에서 막는다.
  • 아이템 레벨, 난이도, 퀘스트 상태 같은 필터 조건을 먼저 적용한 뒤 가중치 선택을 수행한다.
  • 중복 금지와 후보 부족 상황의 대체 규칙을 명시한다.
  • 피티 시스템은 상태 저장 범위와 초기화 규칙을 문서화한다.
  • 고정 시드 테스트와 대량 시뮬레이션으로 확률 테이블을 검증한다.

좋은 전리품 시스템은 단지 난수를 호출하는 코드가 아니라 데이터 변경에 안전하고 결과를 설명할 수 있는 규칙의 집합입니다. 단계별 롤과 명확한 로그를 갖추면 밸런스 조정, 버그 재현, 라이브 운영 모두가 훨씬 수월해집니다.

#게임 프로그래밍#Loot Table#Unity#C++#확률 시스템

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs