룰렛 기반 강화 방식과 확률 판정 구조

목차

1. 시스템 요구 사항

강화 비용과 조건 계산 구조를 정리한 이후, 다음으로 마주한 문제는 강화 성공 여부를 어떻게 결정하고, 이를 플레이어에게 어떻게 전달할 것인가였다.

일반적인 버튼 클릭형 강화 방식은 확률이 내부적으로만 처리되기 때문에, 플레이어 입장에서 성공/실패가 갑작스럽고 일방적으로 느껴지는 문제가 있었다.

즉, 강화 시도가 어떤 확률로 진행되는지 체감하기 어렵고, 결과를 기다리는 긴장감이 부족하며 실패했을 때 납득하기 어려운 구조가 되기 쉽다는 한계가 있었다.

따라서 단순히 확률을 계산하는 것이 아니라, 확률이 보이고, 결과를 기다리게 만드는 강화 방식이 필요하다고 판단했다.

2. 설계  목표

- 버튼 클릭 즉시 결과가 나오는 방식에서 벗어날 것

- 강화 결과가 결정되는 과정을 플레이어가 직접 경험할 수 있을 것

- 확률 계산 로직과 연출 로직을 분리할 것

- 중복 판정 없이 단 한 번만 결과가 적용되도록 할 것

3. 흐름도

룰렛 기반 강화는 '강화 시도 → 룰렛 회전 → 정지 → 확률 판정 → 결과 적용' 의 단계로 구성된다.

이 흐름을 통해 강화 결과가 즉시 결정되지 않고, 플레이어가 결과를 기다리는 과정 자체가 강화 경험의 일부가 되도록 설계했다.

4. 구현

위 표는 본 강화 시스템에서 사용한 룰렛 기반 강화 방식의 전체 구조를 나타낸다.

버튼 클릭형 강화와 달리, 강화 시도 이후 즉시 결과를 반환하지 않고, 룰렛 회전과 정지 과정을 거친 뒤 확률이 결정되는 흐름으로 설계하였다.

플레이어는 강화 버튼을 누른 뒤 룰렛이 회전하는 과정을 직접 확인하고, 정지 시점에 선택된 확률 구간을 통해 강화 결과를 기다리게 된다.

이를 통해 강화 성공 여부가 단순한 수치 판정이 아닌 체험 가능한 과정으로 인식되도록 구성하였다.

이는 강화에 대한 긴장감과 몰입도를 높이고, 실패 시에도 결과에 대한 납득 가능성을 제공하기 위함이다.

4.1. 강화 시도 시작과 룰렛 회전 제어

강화 버튼 클릭 시, 재화 차감과 동시에 룰렛 UI를 활성화하고 룰렛 회전을 시작한다.

public void UpgradeBtn()
{
    itemManager.coin -= requiredCoin;
    itemManager.GetItem(CrystalItemId).count -= requiredCrystal;

    roulettePanel.SetActive(true);

    upgradeAttempted = false;
    isRotating = true;
}

강화 버튼 클릭 시 UpgradeBtn() 함수가 호출된다.

이 단계에서는 강화 성공/실패 판정을 수행하지 않으며, 오직 강화 시도가 시작되었다는 상태 전환만 담당한다.

강화에 필요한 재화와 크리스탈은 강화 성공 여부와 관계없이 시도 시점에 즉시 차감되도록 설계하였다.

이는 강화 실패 시에도 자원이 소모되는 구조를 명확히 하여, 강화 시도가 하나의 선택이자 리스크라는 점을 분명히 전달하고자 했다.

이후 룰렛 패널을 활성화하고, isRotating 플래그를 true로 설정하여 룰렛 회전을 시작한다.

룰렛 회전 여부는 이 플래그 하나로 제어되며, 다른 스크립트에서도 동일한 상태를 참조할 수 있도록 설계하였다.

또한 upgradeAttempted 값을 초기화하여 이후 확률 판정 단계에서 중복 강화 시도가 발생하지 않도록 준비 상태를 만든다.

4.2. 룰렛 회전 로직 분리

룰렛의 회전 자체는 강화 시스템 로직과 분리된 전용 컴포넌트에서 처리한다.

public class Roulette : MonoBehaviour
{
    private UpgradeSlot upgrade;
    RectTransform rtf;
    private float rotationSpeed = 1000.0f;

    private void Start()
    {
        upgrade = GetComponentInParent<UpgradeSlot>();
        rtf = GetComponent<RectTransform>();
    }

    private void Update()
    {
        if (upgrade.isRotating)
        {
            rtf.Rotate(0f, 0f, rotationSpeed);
        }
    }
}

룰렛의 회전 동작은 강화 시스템 로직과 분리된 전용 컴포넌트(Roulette)에서 단독으로 처리하도록 구성하였다.

이 클래스는 오직 isRotating 상태 값만을 참조하여 룰렛 UI를 회전시키는 역할만 수행한다.

회전 여부를 UpgradeSlot에서 관리하도록 한 이유는, 강화 시스템이 룰렛의 상태를 명확한 플래그 하나로 제어할 수 있도록 하기 위함이다.

이를 통해 연출 로직이 강화 판정 로직에 직접 의존하지 않도록 구조를 분리할 수 있었다.

이 구조를 사용함으로써, 룰렛의 회전 속도나 방향, 애니메이션 방식이 변경되더라도 강화 판정 로직에는 영향을 주지 않도록 구성할 수 있었다.

룰렛은 UI 요소이기 때문에 Transform이 아닌 RectTransform을 사용하여 회전을 구현하였고, Canvas 환경에서도 좌표 변환 없이 안정적인 연출이 가능하도록 했다.

4.3. 룰렛 정지와 확률 영역 충돌 감지

룰렛이 멈췄을 때, 화살표가 어떤 확률 영역과 겹쳤는지를 감지하여 해당 확률 값을 추출한다.

public void StopRotation()
{
	isRotating = false;
}

정지 버튼 클릭 시 StopRotation() 함수를 통해 isRotating 플래그를 false로 변경한다.

플래그가 false가 되면서, Roulette클래스에서 조건을 만족하지 못하게 되면서 룰렛이 정지한다.

이를 통해 별도의 복잡한 상태 머신 없이도 회전 제어가 가능하도록 단순화하였다.

void OnTriggerStay(Collider other)
{
    if (!upgrade.isRotating && other.CompareTag("Percent") && !upgrade.upgradeAttempted)
    {
        TextMeshProUGUI probabilityText =
            other.GetComponentInChildren<TextMeshProUGUI>();

        if (probabilityText != null)
        {
            upgrade.AttemptUpgradeWithProbability(probabilityText.text);
        }
    }
}

룰렛이 멈춘 이후에는 화살표가 어떤 확률 영역과 겹쳤는지를 감지해야 한다.

이 역할은 RouletteCollisionDetector에서 담당하며, 충돌 감지 전용 로직으로 분리하여 구성하였다.

이 함수는 '룰렛이 회전 중이 아닐 것, 충돌한 오브젝트가 확률 영역일 것, 아직 강화 판정이 이루어지지 않았을 것' 이라는 세 가지 조건을 모두 만족할 경우에만 동작한다.

룰렛의 각 확률 구간은 이미지가 아닌 TextMeshProUGUI 오브젝트로 구성하였다.

확률 값을 코드에 하드코딩 하지 않고 UI 텍스트를 기준으로 읽어오는 구조이기 때문에, 추후에 기획자가 코드 수정 없이도 확률 밸런스를 조정할 수 있다.

각 확률 텍스트 오브젝트에는 Percent 태그와 BoxCollider (isTrigger)가 설정되어 있다.

룰렛 중심의 화살표 오브젝트도 BoxCollider와 Rigidbody를 가지고 있으며, 룰렛이 회전을 멈춘 시점에 어떤 확률 구간과 충돌 중인지 감지할 수 있도록 설계하였다.

충돌 감지는 OnTriggerStay()를 사용하였다.

OnTriggerEnter는 충돌 순간 한 프레임만 감지되기 때문에, 회전 정지 타이밍과 어긋날 가능성이 있다.

반면, OnTriggerStay()는 두 Collider가 겹쳐진 상태를 지속적으로 감지할 수 있기 때문에, 룰렛이 정지한 이후 현재 위치를 안정적으로 판별하는 데 적합하다고 판단하였다.

룰렛이 멈춘 현재 위치를 판정해야 하는 구조에서, 단발성 이벤트보다 적합하다고 판단하였다.

또한, CompareTag()를 사용하여 문자열 비교보다 성능적으로 유리한 방식으로 태그를 판별하였다.

이 단계에서는 확률 계산을 수행하지 않는다.

오직 어떤 확률 값이 선택되었는가만 추출하여 다음 단계로 전달하는 역할만 수행한다.

이를 통해 충돌 감지와 확률 계산을 명확히 분리할 수 있었다.

4.4. 확률 계산 및 강화 결과 판정

충돌로 얻은 확률 값을 기반으로 실제 강화 성공/실패를 판정한다.

public void AttemptUpgradeWithProbability(string probabilityText)
{
    StartCoroutine(ShowTemp(roulettePanel, 1f));
    StartCoroutine(ShowTemp(resultPanel, 1f));

    if (upgradeAttempted) return;

    if (!float.TryParse(probabilityText, out float successProbability))
        return;

    successProbability /= 100f;

    percentTxt.text = $"강화 확률 : {probabilityText}%";

    bool isSuccess = Random.value <= successProbability;
    ApplyUpgradeResult(isSuccess);

    upgradeAttempted = true;
}

이 함수는 강화 확률 계산을 담당한다.

확률 판정 초기 구현에서는 확률 문자열 파싱, 확률 계산, 강화 성공·실패 처리, 결과 UI 출력이 하나의 함수에 집중되어 있었다.

이 구조는 강화 결과 이후의 연출 확장이나 데이터 처리 변경이 어려운 형태였다.

그래서 확률 계산과 결과 적용을 분리했다.

확률 판정은 AttemptUpgradeWithProbability()에서 단 한 번만 수행하고, 강화 결과 적용과 연출은 ApplyUpgradeResult()에 위임했다.

이를 통해 확률 판정 로직은 단일 책임을 가지게 되었고, 결과 연출 방식이 바뀌더라도 확률 계산 코드는 수정하지 않아도 되도록 개선했다.

이 함수는 충돌 감지 단계에서 전달받은 확률 문자열을 입력값으로 받아,이를 실제 강화 성공 여부 판정에 사용한다.

여기서, 확률 판정과 동시에 결과 UI를 출력하여, 강화 결과 인지와 연출 사이의 지연이 발생하지 않도록 구성하였다.

확률 값(문자열)은 사람이 인식하기 쉬운 0~100 범위의 텍스트로 관리하되, 추후에 TryParse()함수를 통해 실수로 파싱 후 0-1범위로 정규화하여 Random.value와 직접 비교 가능하게 설계하였다.

percentTxt에 실제 시도 확률을 표시하여, 플레이어가 몇% 확률이었는가를 명확히 인식할 수 있도록 하였다.

이를 통해 확률 계산 방식이 직관적이면서도 Unity의 난수 시스템과 자연스럽게 연결되도록 구성하였다.

또한 실제 적용된 확률 값을 UI에 출력하여, 플레이어가 어떤 확률로 강화 시도가 이루어졌는지를 명확히 인지할 수 있도록 했다.

강화 판정은 upgradeAttempted 플래그를 통해 단 한 번만 실행되도록 제어하여, 중복 판정으로 인한 강화 단계 오류를 방지하도록 설계하였다.

5. 개발 의도

룰렛 기반 강화 방식은 단순한 확률 계산 로직보다 구조가 복잡해질 수 있다.

하지만 이 구조를 선택한 이유는 다음과 같다.

강화 확률을 플레이어가 직접 인식할 수 있는 경험으로 만들고 싶었고, 확률 계산, 충돌 판정, 연출 로직을 분리하여 각 책임을 명확히 나누고 싶었다.

이후 확률 테이블 변경이나 연출 수정 시, 기존 강화 로직에 영향을 주지 않기 위함이었다.

이 구조를 통해 강화 시스템은 단순한 수치 판정이 아닌, 플레이어 경험을 중심으로 한 강화 방식으로 확장할 수 있었다.