확률 미니게임 설계 – 확률 누적과 최종 판정 분리 구조

목차

1. 시스템 요구 사항

속성 부여 시스템의 네 번째 핵심은 확률 미니 게임의 결과가 단순한 연출에 그치지 않고, 최종 속성 부여 결과와 정확히 연결되도록 하는 것이었다.

확률 미니 게임은 속성 부여 버튼을 누른 직후 결과를 반환하는 방식이 아니라, 플레이어의 입력 과정을 거쳐 확률이 점진적으로 변화한 뒤 최종 성공 여부가 결정되는 구조다.

이 단계에서는 미니 게임 내부의 확률 변화, 최종 확률 확정 시점, 그리고 그 결과가 아이템 데이터에 적용되는 흐름이 명확히 분리되어야 했다.

따라서 이 단계에서는 확률 변화 과정과 최종 결과 적용을 명확히 분리하여, 확률 미니게임이 하나의 독립된 판단 단계로 동작하도록 설계하는 것을 목표로 했다.

2. 설계  목표

- 확률 계산과 속성 적용 로직을 분리할 것

- 미니게임 내부에서만 확률을 변화시킬 것

- 최종 확률 확정 시점은 하나로 제한할 것

- 결과 적용은 단일 진입점에서 처리할 것

- 자동 실행과 수동 입력이 완전히 동일한 로직 경로를 사용하도록 할 것

3. 흐름도

최종 확률 확정 단계에서는 값만 결정되고, 실제 성공·실패 판정은 그 이후 단 한 번만 수행된다.

4. 구현

4.1. 확률 미니 게임 내부 구조

확률 미니 게임은 여러 개의 버튼을 순차적으로 클릭하는 구조로 구성하였다.

public void OnButtonClick(int buttonIndex)
{
    if (Random.Range(0.0f, 100.0f) <= probability)
    {
        buttons[buttonIndex].GetComponent<Image>().color = new Color(0, 1, 0.933f);
        probability -= 5.0f;
    }
    else
    {
        buttons[buttonIndex].GetComponent<Image>().color = new Color(0, 0.33f, 0.4f);
        probability += 5.0f;
    }

    if (buttonIndex != buttons.Length - 1)
    {
        buttons[buttonIndex].interactable = false;
        ActivateButton(buttonIndex + 1);
        UpdateProbabilityTxt();
    }
    else
    {
        propertySystem.UpdateProbability(probability);
    }
}

확률 미니 게임은 여러 개의 버튼을 순차적으로 클릭하는 구조로 구성하였다.

각 버튼 클릭 시 Random.Range를 사용해 성공 · 실패를 임시로 판정하고, 그 결과에 따라 현재 확률 값을 증가 또는 감소시키는 방식으로 동작한다.

이때 중요한 점은, 이 단계에서의 성공·실패 판정은 최종 속성 부여 성공 여부를 결정하기 위한 판정이 아니라, 확률을 변화시키기 위한 중간 판단이라는 점이다.

즉, Random.Range는 이번 속성 부여가 성공했는가를 판단하기 위해 사용된 것이 아니라, 플레이어의 선택에 따라 확률이 어떻게 변화하는지를 결정하기 위한 수단으로 사용된다.

Unity의 Random.Range는 프레임 독립적이며, 미니게임 도중 여러 번 호출되더라도 상태를 내부적으로 유지하지 않기 때문에 누적 확률 계산과 충돌하지 않는다.

버튼 클릭 결과에 따라 확률을 ±5% 조정함으로써, 플레이어는 자신의 입력이 이후 결과 확률에 누적적으로 영향을 미친다는 피드백을 받게 된다.

이 구조를 통해 확률 미니 게임은 단순히 버튼을 누르는 연출이 아니라, 확률을 만들어가는 과정으로 인식되도록 설계하였다.

또한 각 버튼 클릭 시 즉시 최종 성공 여부를 판정하지 않고, 오직 확률 값만 갱신하도록 제한함으로써 중복 판정이나 확률 불일치 문제를 원천적으로 차단했다.

버튼 색상 변경과 확률 UI 갱신은 이 확률 변화 과정을 시각적으로 드러내기 위한 연출이며, 실제 성공 · 실패 판정은 모든 버튼 입력이 끝난 이후 단 한 번만 수행된다.

이로 인해 확률 계산 과정과 결과 판정 과정이 명확히 분리되고, 미니 게임 내부 로직은 오직 확률 조정이라는 단일 책임만 갖게 된다.

4.2. 게임 자동 실행 구조

속성 부여는 반복적으로 사용되는 시스템이기 때문에, 자동 실행 기능을 함께 고려하였다.

public void StartAutoPlay()
{
    if (isAutoPlaying) return;
    isAutoPlaying = true;
    StartCoroutine(AutoPlayRoutine());
}

private IEnumerator AutoPlayRoutine()
{
    int startIndex = 0;

    for (int i = 0; i < buttons.Length; i++)
    {
        if (buttons[i].interactable)
        {
            startIndex = i;
            break;
        }
    }

    for (int i = startIndex; i < buttons.Length; i++)
    {
        if (!gameObject.activeInHierarchy)
            break;

        OnButtonClick(i);

        if (i < buttons.Length - 1)
            yield return new WaitForSeconds(0.3f);
    }

    isAutoPlaying = false;
}

자동 실행은 현재 활성화된 버튼부터 마지막 버튼까지 동일한 클릭 흐름을 그대로 재현한다.

이를 통해 수동 입력과 자동 실행이 완전히 동일한 로직 경로를 사용하도록 구성하였다.

이를 통해 미니게임이 중간에 종료되거나 결과 화면으로 전환될 경우, 자동 실행 루틴이 자연스럽게 중단되도록 했다.

4.3. 최종 확률 확정 및 결과 판정

미니 게임의 마지막 버튼에 도달하면 최종 확률이 확정되고, 속성 부여 성공 여부가 판정된다.

public void UpdateProbability(float newProbability)
{
    result.SetActive(true);
    miniGame.SetActive(false);

    bool isUpgradeSuccessful =
        Random.Range(0.0f, 100.0f) <= newProbability;

    if (isUpgradeSuccessful && currentItem != null)
    {
        ApplyRandomPropertyToCurrentItem();
    }
    else
    {
        resultTxt.text = "추가 실패!";
    }
}

미니 게임의 마지막 버튼에 도달하면, 그동안 누적된 확률 변화 결과를 하나의 최종 확률 값으로 확정한다.

이 시점에서만 Random.Range를 사용해 속성 부여의 성공·실패를 판정한다.

앞선 미니 게임 단계에서 사용된 Random.Range가 확률을 변화시키기 위한 중간 판단이었다면, 이 단계의 Random.Range는 실제 속성 결과를 결정하는 유일한 최종 판정이다.

이 구조에서 중요한 점은, 확률 판정이 버튼 입력 과정 중에 여러 번 수행되지 않고, 반드시 하나의 진입점에서 단 한 번만 수행된다는 것이다.

이를 통해 미니 게임 도중 확률 값이 변할 때마다 성공 여부가 중복 판정되거나, UI에 표시된 확률과 실제 결과가 어긋나는 상황을 구조적으로 방지할 수 있었다.

최종 확률은 오직 “확정된 값”으로만 사용되며, 그 이전 단계에서는 어떤 경우에도 결과 판정이 이루어지지 않는다.

초기 구현에서는 확률 판정, 속성 선택, 기존 속성 탐색, 누적 처리, 신규 속성 추가까지 모든 로직이 하나의 함수 안에서 처리되고 있었다.

이로 인해 확률 계산과 결과 적용의 경계가 모호해졌고, 로직 수정 시 의도치 않은 부작용이 발생할 여지가 컸다.

그래서 최종 성공·실패 판정은 이 단계로 한정하고, 실제 속성 변경 로직 ApplyRandomPropertyToCurrentItem() 함수로 분리하였다.

4.4. 랜덤 속성 부여 및 결과 적용

속성 부여 성공 시 랜덤으로 선택된 속성이 현재 아이템에 적용된다.

private void ApplyRandomPropertyToCurrentItem()
{
    resultTxt.text = "강화 성공! " + upgradelist[randomIndex];

    string statToUpdate = "";
    float statValueToAdd = 0f;

    switch (randomIndex)
    {
        case 0: statToUpdate = "체력(%)";         statValueToAdd = 5;    break;
        case 1: statToUpdate = "공격속도";        statValueToAdd = 0.1f; break;
        case 2: statToUpdate = "사거리";          statValueToAdd = 5;    break;
        case 3: statToUpdate = "이동속도";        statValueToAdd = 5;    break;
        case 4: statToUpdate = "경험치 획득량";   statValueToAdd = 8;    break;
    }

    bool statFound = false;

    for (int i = 0; i < currentItem.additionalStat.Length; i++)
    {
        if (string.IsNullOrEmpty(currentItem.additionalStat[i]))
            continue;

        string[] splitStat = currentItem.additionalStat[i].Split(',');
        if (splitStat[0] == statToUpdate)
        {
            float currentStatValue = float.Parse(splitStat[1]);
            float newValue = currentStatValue + statValueToAdd;

            currentItem.additionalStat[i] = statToUpdate + "," + newValue.ToString();
            statFound = true;

            currentItem.isPropertyMax = true;
            break;
        }
    }

    if (!statFound)
    {
        List<string> additionalStatsList = new List<string>(currentItem.additionalStat);
        additionalStatsList.Add(statToUpdate + "," + statValueToAdd.ToString());
        currentItem.additionalStat = additionalStatsList.ToArray();
    }

    UpdateCrystalUI();
    UpdateUpgradeCostUI();
}

이미 동일한 속성이 존재할 경우에는 기존 수치에 누적되도록 처리하였고,

새로운 속성일 경우에는 속성 리스트에 추가하도록 구성했다.

이를 통해 속성 중복 시의 처리 규칙을 명확히 하고, 예측 가능한 성장 구조를 유지할 수 있었다.

이 구조는 이후 속성 종류가 늘어나더라도 기존 저장 방식과 로직을 유지한 채 확장이 가능하도록 고려한 설계다.

지금 반지(아케인 스톤)의 속성 데이터는 Additional Stat 이 문자열 배열 형태로 저장되어 있고, 실제 값은 "사거리,5" 처럼 “키,수치” 포맷으로 들어가 있다.

이 구조를 문자열 기반으로 유지한 가장 큰 이유는 Unity 인스펙터와 직렬화 관점에서 관리 비용이 낮기 때문이다.

문자열 배열은 인스펙터에서 즉시 확인 ·수정이 가능하고, 프리팹 / 스크립터블오브젝트 / 세이브 데이터에서도 구조가 단순해 저장과 로딩이 편하다.

특히 속성 시스템은 밸런싱 대상이기 때문에, 개발 중에 속성 이름을 바꾸거나 수치를 조정하는 작업이 자주 발생한다.

이때 별도의 커스텀 타입(StatData 구조체, enum 기반 딕셔너리)을 만들면 타입 안정성은 좋아지지만, 데이터 정의 / 직렬화 / 에디터 표시 / 마이그레이션 비용이 같이 따라온다.

반면 "이름,수치" 문자열 방식은 규칙만 정해두면 데이터 형태가 바뀌어도 에디터에서 바로 조정 가능하고, 디버깅 시에도 인스펙터에서 한눈에 읽힌다는 장점이 있다.

그래서 이 포트폴리오 단계에서는 확장·유지보수의 실용성(툴링/밸런싱)을 우선하여 문자열 기반을 채택했다.

문자열 기반으로 저장하면 실제 로직에서 값 연산이 필요할 때 파싱이 필요하다.

그래서 "사거리,5" 같은 문자열은 Split(',')로 분해해서 앞쪽은 스탯 키(사거리), 뒤쪽은 수치(5)로 분리하고, 기존 Additional Stat 배열을 순회하면서 동일한 키가 존재하는지 찾는다.

문자열을 Split(',')로 분해하면 데이터 구조와 무관하게 속성 키 기준 비교가 가능해진다.

이 방식은 향후 데이터 구조가 변경되더라도 ‘동일 속성 탐색 → 누적’이라는 로직 책임을 그대로 유지할 수 있다.

동일 키가 이미 있으면 수치만 누적하는데, 이렇게 하면 같은 속성 재획득 시 누적이라는 룰을 데이터 구조와 무관하게 일관되게 적용할 수 있다.

반대로 동일 키가 없으면 새 엔트리를 추가해야 하는데, 여기서 string[] 배열의 한계가 나온다.

배열은 길이가 고정이기 때문에 런타임에 원소를 하나 더 넣으려면 새 배열을 만들고 기존 데이터를 복사한 뒤 마지막에 추가해야 한다.

이 과정을 매번 직접 구현하면 코드가 길어지고, 인덱스 실수나 누락 같은 버그 가능성이 커진다.

그래서 새 속성 추가 케이스에서는 List<string>을 사용했다.

List<T>는 C#의 동적 배열 컨테이너로, 내부적으로 크기를 자동 관리하면서 원소를 늘릴 수 있다.

즉, 배열처럼 고정 길이가 아니라서 Add() 한 줄로 안전하게 항목을 추가할 수 있다.

Add()는 리스트의 끝에 새로운 원소를 붙이는 함수이며, 필요한 경우 내부 배열의 용량(capacity)을 확장하고 기존 데이터를 복사하는 일을 List<T>가 알아서 처리한다.

개발자가 직접 '새 배열 생성 + 복사' 로직을 반복해서 작성하지 않아도 되기 때문에 코드가 간결해지고 유지보수성이 올라간다.

추가 속성 개수 제한(MaxPropertyCount)이 바뀌거나, 향후 속성 종류가 늘어나더라도 추가 로직 자체는 흔들리지 않는다는 점이 이 구조의 실용적인 강점이다.

다만 시스템 전체는 currentItem.additionalStat을 배열(string[])로 들고 있게 설계되어 있다.

인스펙터에서 Additional Stat 필드가 배열로 보이는 것도 이 때문이고, 다른 함수들(UI 갱신, 최대 상태 판단 등)도 배열을 전제로 작성되어 있다.

그래서 새 속성을 추가할 때만 임시로 List<string>로 변환해 확장 작업을 처리한 뒤, 최종 저장 형태는 다시 배열로 되돌려야 한다.

이때 ToArray()를 사용한다.

ToArray()는 리스트에 들어 있는 원소들을 그대로 복사해서 새로운 배열을 만들어주는 함수다.

즉, 동적 확장은 List로 편하게 처리하고, 시스템의 표준 저장 포맷은 string[]로 유지한다는 역할 분담을 만들어준다.

결과적으로 추가 처리에서는 List<string>.Add()로 안전성과 간결성을 확보하고, 저장 및 시스템 연동 단계에서는 ToArray()로 기존 데이터 구조와의 정합성을 유지하는 구조가 된다.

정리하면, 문자열 기반은 Unity 인스펙터 / 밸런싱 / 디버깅 / 직렬화의 실용성을 우선한 선택이고, List<string> + Add() + ToArray() 흐름은 배열 고정 길이 한계를 안전하게 우회하면서도 기존 시스템 저장 형태는 유지하기 위한 구현 전략이다.

이 방식은 구현 속도와 유지보수성을 동시에 잡을 수 있고, 추후에 필요해지면 문자열 대신 구조체 / enum 기반 데이터로 교체할 때도 로직 책임(탐색/누적/추가)을 그대로 유지한 채 데이터 표현만 바꾸는 식으로 점진적으로 개선할 수 있다.

물론 문자열 기반 구조는 타입 안정성이나 컴파일 타임 검증 측면에서는 한계가 있다.

다만 이 프로젝트에서는 구현 속도, 인스펙터 기반 밸런싱, 디버깅 가시성을 우선하여 해당 방식을 선택했고, 이후 필요해질 경우 구조체나 enum 기반 데이터 구조로 점진적으로 전환할 수 있도록 로직 책임을 분리해두었다.

배열을 직접 확장하지 않고 List를 경유하는 방식은, 런타임 안전성과 코드 가독성을 동시에 확보하기 위한 선택이다. 이는 데이터 저장 포맷과 연산용 자료구조를 분리한 설계다.

5. 개발 의도

확률 미니게임은 단순한 연출 요소가 아니라, 속성 부여 결과를 결정하는 핵심 로직의 일부다.

그래서 확률 변화, 결과 판정, 속성 적용을 서로 다른 단계로 분리하여 각 단계가 명확한 책임을 갖도록 설계하였다.

이 구조를 통해 수동 입력, 자동 실행, 추후 확률 조정이나 연출 변경이 이루어지더라도 결과 처리 로직은 영향을 받지 않는다.

확률 미니게임은 속성 부여 시스템 전체 흐름에서 마지막 결정 지점 역할을 하며, 앞선 게시글에서 정리한 입력 처리, 상태 동기화, 시도 검증 구조 위에서 안정적으로 동작하도록 구성하였다.