룰렛 기반 강화 방식과 확률 판정 구조
목차
1. 시스템 요구 사항
강화 비용과 조건 계산 구조를 정리한 이후, 다음으로 마주한 문제는 강화 성공 여부를 어떻게 결정하고, 이를 플레이어에게 어떻게 전달할 것인가였다.
일반적인 버튼 클릭형 강화 방식은 확률이 내부적으로만 처리되기 때문에, 플레이어 입장에서 성공/실패가 갑작스럽고 일방적으로 느껴지는 문제가 있었다.
즉, 강화 시도가 어떤 확률로 진행되는지 체감하기 어렵고, 결과를 기다리는 긴장감이 부족하며 실패했을 때 납득하기 어려운 구조가 되기 쉽다는 한계가 있었다.
따라서 단순히 확률을 계산하는 것이 아니라, 확률이 보이고, 결과를 기다리게 만드는 강화 방식이 필요하다고 판단했다.
이데 따라, 강화 비용과 조건 계산 구조를 정리한 이후, 강화 성공 여부를 어떤 방식으로 판정하고, 그 과정을 시스템적으로 어떻게 분리할 것인가가 다음 과제가 되었다.
2. 설계 목표
- 버튼 클릭 즉시 결과가 나오는 방식에서 벗어날 것
- 강화 결과가 결정되는 과정을 플레이어가 직접 경험할 수 있을 것
- 확률 계산 로직과 연출 로직을 분리할 것
- 중복 판정 없이 단 한 번만 결과가 적용되도록 할 것
3. 흐름도

룰렛 기반 강화는 '강화 시도 → 룰렛 회전 → 정지 → 확률 판정 → 결과 적용' 의 단계로 구성된다.
이 흐름을 통해 강화 결과가 즉시 결정되지 않고, 플레이어가 결과를 기다리는 과정 자체가 강화 경험의 일부가 되도록 설계했다.
4. 구현

위 표는 본 강화 시스템에서 사용한 룰렛 기반 강화 방식의 전체 구조를 나타낸다.
버튼 클릭형 강화와 달리, 강화 시도 이후 즉시 결과를 반환하지 않고, 룰렛 회전과 정지 과정을 거친 뒤 확률이 결정되는 흐름으로 설계하였다.
플레이어는 강화 버튼을 누른 뒤 룰렛이 회전하는 과정을 직접 확인하고, 정지 시점에 선택된 확률 구간을 통해 강화 결과를 기다리게 된다.
이를 통해 강화 성공 여부가 단순한 수치 판정이 아닌 체험 가능한 과정으로 인식되도록 구성하였다.
이는 강화에 대한 긴장감과 몰입도를 높이고, 실패 시에도 결과에 대한 납득 가능성을 제공하기 위함이다.
4.1. 강화 시도 시작과 룰렛 회전 제어
public void UpgradeBtn()
{
itemManager.coin -= requiredCoin;
itemManager.GetItem(CrystalItemId).count -= requiredCrystal;
roulettePanel.SetActive(true);
upgradeAttempted = false;
isRotating = true;
}
UpgradeBtn 함수는 강화 시도를 시작하는 진입점 역할을 한다.
강화 버튼 클릭 시 필요한 재화와 크리스탈을 즉시 차감한 뒤, 룰렛 UI를 활성화한다.
이 시점에서는 강화 성공 여부를 판정하지 않으며, 강화 결과는 이후 룰렛 정지 및 확률 판정 단계에서 처리된다.
룰렛 패널을 활성화하고, isRotating 플래그를 true로 설정하여 룰렛 회전을 시작한다.
룰렛의 회전 여부는 isRotating 플래그 하나로 제어되며, 해당 상태는 다른 스크립트에서도 동일하게 참조할 수 있도록 구성하였다.
또한 upgradeAttempted를 false로 초기화하여, 이후 확률 판정 단계에서 강화 결과가 단 한 번만 적용되도록 준비 상태를 만든다.
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을 기준으로 회전을 구현하였다.
RectTransform은 Canvas 좌표계를 기반으로 동작하므로, 해상도 변경이나 Canvas 스케일 조정이 발생하더라도 UI의 위치와 회전이 화면 기준으로 일관되게 유지된다.
만약 일반 Transform을 사용할 경우, Canvas 렌더링 모드(Screen Space / World Space)에 따라 좌표 변환이나 스케일 보정이 추가로 필요해질 수 있다.
이를 피하기 위해 RectTransform.Rotate()를 사용하여 Z축 회전만을 적용함으로써, UI 환경에서 별도의 좌표 계산 없이 안정적인 룰렛 연출이 가능하도록 구성하였다.
이를 통해 룰렛의 회전 속도나 연출 방식이 변경되더라도, 좌표계 문제로 인한 추가 수정 없이 UI 레벨에서 유연하게 대응할 수 있도록 하였다.
*결과

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에서 담당하며, 충돌 감지 전용 로직으로 분리하여 구성하였다.
충돌 감지는 OnTriggerStay()를 사용하였다.
OnTriggerEnter는 충돌 순간 한 프레임만 감지되기 때문에, 회전 정지 타이밍과 어긋날 가능성이 있다.
반면, OnTriggerStay()는 두 Collider가 겹쳐진 상태를 지속적으로 감지할 수 있기 때문에, 룰렛이 정지한 이후 현재 위치를 안정적으로 판별하는 데 적합하다고 판단하였다.
룰렛이 멈춘 현재 위치를 판정해야 하는 구조에서, 단발성 이벤트보다 적합하다고 판단하였다.
또한, CompareTag()를 사용하여 문자열 비교보다 성능적으로 유리한 방식으로 태그를 판별하였다.
이 함수는 '룰렛이 회전 중이 아닐 것, 충돌한 오브젝트가 확률 영역일 것, 아직 강화 판정이 이루어지지 않았을 것' 이라는 세 가지 조건을 모두 만족할 경우에만 동작한다.
룰렛의 각 확률 구간은 이미지가 아닌 TextMeshProUGUI 오브젝트로 구성하였다.
확률 값을 코드에 하드코딩 하지 않고 UI 텍스트를 기준으로 읽어오는 구조이기 때문에, 추후에 기획자가 코드 수정 없이도 확률 밸런스를 조정할 수 있다.
각 확률 텍스트 오브젝트에는 Percent 태그와 BoxCollider (isTrigger)가 설정되어 있다.
룰렛 중심의 화살표 오브젝트도 BoxCollider와 Rigidbody를 가지고 있으며, 룰렛이 회전을 멈춘 시점에 어떤 확률 구간과 충돌 중인지 감지할 수 있도록 설계하였다.
이 단계에서는 확률 계산이나 강화 결과 판정을 수행하지 않으며, 오직 선택된 확률 값만을 추출하여 다음 단계의 확률 판정 로직으로 전달하는 역할만 수행한다.
이를 통해 충돌 감지와 확률 계산을 명확히 분리할 수 있었다.
*결과

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;
}
AttemptUpgradeWithProbability 함수는 룰렛 충돌 감지 단계에서 전달받은 확률 값을 기반으로,
하나의 강화 시도에 대해 단 한 번만 강화 성공 여부를 판정하는 최종 진입점이다.
초기 구현에서는 확률 문자열 파싱, 확률 계산, 강화 성공 · 실패 처리, 결과 UI 출력이 하나의 함수에 모두 집중된 구조였다.
이 방식은 기능 구현 자체는 단순했지만, 강화 결과 이후 연출을 추가하거나 UI 표시 시간을 조절하려 할 경우, 하나의 함수에 수정이 집중되는 문제가 있었다.
특히 결과 UI를 일정 시간 동안 표시하거나, 강화 성공/실패에 따른 연출을 확장하려 할 때 확률 판정 로직과 UI 제어 로직이 강하게 결합되었고, 이로 인해 강화 로직의 책임이 불분명해지며 유지보수가 어려운 구조가 되었다.
또한 확률 판정이 여러 조건에서 중복 실행될 가능성도 존재했다.
이러한 문제를 해결하기 위해, 확률 판정은 단 한 번만 수행하도록 고정하고, 강화 결과 적용과 연출 처리는 별도의 함수로 위임하는 구조로 개선하였다.
이를 통해 확률 계산 로직은 결과 결정에만 집중하고, UI 연출과 결과 반영은 독립적으로 확장할 수 있도록 책임을 분리하였다.
함수가 호출되면 가장 먼저 룰렛 패널과 결과 패널을 일정 시간 동안만 표시하기 위한 코루틴을 실행한다.
결과 UI는 강화 판정 직후 즉시 활성화되지만, 일정 시간이 지난 뒤 자동으로 비활성화되어야 한다.
이를 Update에서 타이머로 관리할 경우 강화 상태 플래그와 UI 표시 시간이 서로 얽혀 로직이 복잡해질 수 있고, Invoke 방식은 중간 취소나 상태 동기화가 어렵다.
따라서 시간 기반 UI 제어를 강화 판정 로직과 분리하기 위해 StartCoroutine 기반의 코루틴 방식을 선택하였다.
ShowTemp 코루틴은 패널을 활성화한 뒤 지정된 시간만큼 대기하고 자동으로 비활성화함으로써, 결과 UI가 다음 강화 시도에 영향을 주지 않도록 한다.
이를 통해 강화 결과 연출과 확률 판정 로직을 명확히 분리하고, 강화 흐름이 단일 프레임에 묶이지 않고 자연스럽게 이어지도록 구성하였다.
이후 upgradeAttempted 플래그를 검사하여, 이미 판정이 완료된 강화 시도에 대해서는 즉시 종료함으로써 중복 확률 판정이 발생하지 않도록 제어한다.
이 플래그는 룰렛 충돌 감지 단계와 확률 판정 단계 양쪽에서 참조되며, 하나의 강화 시도에서 결과가 단 한 번만 적용되도록 보장한다.
전달받은 확률 값은 UI에서 표시되는 TextMeshProUGUI 텍스트를 기준으로 전달되기 때문에 문자열(string) 형태로 입력된다.
이 확률 값은 사람이 직관적으로 인식하기 쉬운 0~100 범위의 숫자이지만, Unity의 난수 시스템(Random.value)은 0~1 범위의 실수 값을 반환한다.
따라서 확률 계산을 위해 float.TryParse()를 사용해 문자열을 실수 값으로 변환한 뒤, 이를 100으로 나누어 0~1 범위로 정규화하였다.
이와 같은 방식은 확률 계산 로직을 Unity의 Random.value와 직접 비교 가능한 형태로 단순화하며, 확률 수식이 코드 상에서 명확히 드러나도록 한다.
또한 확률 값을 UI 텍스트로 관리하면서도 내부 계산은 실수 기반으로 처리함으로써, 기획 단계에서 확률 수치를 수정하더라도 코드 구조를 변경하지 않아도 되는 장점을 가진다.
확률 판정이 완료되면 ApplyUpgradeResult 함수로 결과 적용을 위임하고, 마지막으로 upgradeAttempted를 true로 설정하여 동일한 강화 시도에서 추가 판정이 발생하지 않도록 한다.
이 구조를 통해 확률 판정 로직은 결과 결정이라는 단일 책임만을 가지게 되었고, 강화 결과 연출이나 UI 출력 방식이 변경되더라도 확률 계산 코드는 수정하지 않아도 되도록 개선되었다.
또한 실제 적용된 확률 값을 percentTxt에 출력함으로써, 플레이어가 어떤 확률로 강화 시도가 이루어졌는지를 명확히 인식할 수 있도록 했다.
결과적으로 이 구조는 강화 판정의 안정성과 확장성을 동시에 확보하며, 룰렛 기반 강화 시스템의 핵심 로직을 명확하게 분리하는 역할을 한다.
5. 개발 의도
룰렛 기반 강화 방식은 단순한 확률 계산 로직보다 구조가 복잡해질 수 있다.
하지만 이 구조를 선택한 이유는 다음과 같다.
강화 확률을 플레이어가 직접 인식할 수 있는 경험으로 만들고 싶었고, 확률 계산, 충돌 판정, 연출 로직을 분리하여 각 책임을 명확히 나누고 싶었다.
이후 확률 테이블 변경이나 연출 수정 시, 기존 강화 로직에 영향을 주지 않기 위함이었다.
이 구조를 통해 강화 시스템은 단순한 수치 판정이 아닌, 플레이어 경험을 중심으로 한 강화 방식으로 확장할 수 있었다.
