룰렛 기반 강화 방식과 확률 판정 구조
목차
1. 시스템 요구 사항
강화 비용과 조건 계산 구조를 정리한 이후, 다음으로 마주한 문제는 강화 성공 여부를 어떻게 결정하고, 이를 플레이어에게 어떻게 전달할 것인가였다.
처음엔 일반적인 버튼 클릭형 강화 방식을 고려하였지만, 플레이어의 입장에서 기존 버튼 클릭형 강화 방식을 분석했을 때, 확률 처리가 내부적으로만 이루어져 성공과 실패의 결과가 다소 갑작스럽고 일방적으로 느껴질 수 있다고 판단하였다.
즉, 강화 시도가 어떤 확률로 진행되는지 체감하기 어렵고, 결과를 기다리는 긴장감이 부족하며, 실패했을 때 납득하기 어려운 구조가 되기 쉽다는 한계가 있었다.
따라서 단순히 확률을 계산하는 것이 아니라, 확률이 보이고, 결과를 기다리게 만드는 강화 방식이 필요하다고 생각했다.
이에 따라, 강화 비용과 조건 계산 구조를 정리한 이후, 강화 성공 여부를 어떤 방식으로 판정하고, 그 과정을 시스템적으로 어떻게 분리할 것인가가 다음 과제가 되었다.
2. 흐름도

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

위 표는 룰렛 기반 강화 방식의 전체 구조를 나타낸다.
버튼 클릭형 강화와 달리, 강화 시도 이후 즉시 결과를 반환하지 않고, 룰렛 회전과 정지 과정을 거친 뒤 확률이 결정되는 흐름으로 설계하였다.
플레이어는 강화 버튼을 누른 뒤 룰렛이 회전하는 과정을 직접 확인하고, 정지 시점에 선택된 확률 구간을 통해 강화 결과를 기다리게 된다.
이를 통해 강화 성공 여부가 단순한 수치 판정이 아닌 체험 가능한 과정으로 인식되도록 구성하였다.
이는 강화에 대한 긴장감과 몰입도를 높이고, 실패 시에도 결과에 대한 납득 가능성을 제공하기 위함이다.
3.1. 강화 시도 시작과 룰렛 회전 제어
// UpdateSlot.cs
[HideInInspector] public bool isRotating = false;
[HideInInspector] public bool upgradeAttempted = false;
public void UpgradeBtn()
{
itemManager.coin -= requiredCoin;
itemManager.GetItem(CrystalItemId).count -= requiredCrystal;
roulettePanel.SetActive(true);
upgradeAttempted = false;
isRotating = true;
}
UpgradeBtn 함수는 강화 시도를 시작하는 진입점 역할을 한다.
강화 버튼 클릭 시 필요한 재화와 재료를 즉시 차감한 뒤, 룰렛 UI를 활성화한다.
이 시점에서는 강화 성공 여부를 판정하지 않으며, 강화 결과는 이후 룰렛 정지 및 확률 판정 단계에서 처리된다.
룰렛 UI를 활성화하고, isRotating 플래그를 true로 설정하여 룰렛 회전을 시작한다.
룰렛의 회전 여부는 isRotating 플래그 하나로 제어되며, 해당 상태는 다른 스크립트에서도 동일하게 참조할 수 있도록 public 으로구성하였다.
또한 upgradeAttempted를 false로 초기화하여, 이후 확률 판정 단계에서 강화 결과가 단 한 번만 적용되도록 준비 상태를 만든다.
3.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 환경에서 별도의 좌표 계산 없이 안정적인 룰렛 연출이 가능하도록 구성하였다.
*결과

3.3. 룰렛 정지와 확률 영역
// UpdateSlot.cs
public void StopBtn()
{
isRotating = false;
}정지 버튼 클릭 시, StopRotation 함수를 통해 isRotating 플래그를 false로 변경한다.
플래그가 false가 되면서, Roulette 클래스에서 조건을 만족하지 못하게 되면서 룰렛이 정지한다.
플래그를 기준으로만 룰렛 회전이 결정되기에, 별도의 복잡한 상태 머신 없이도 회전 제어가 가능하도록 하였다.
public class RouletteCollisionDetector : MonoBehaviour
{
private UpgradeSlot upgrade;
private void Start()
{
upgrade = GetComponentInParent<UpgradeSlot>();
}
void OnTriggerStay(Collider other)
{
if (!upgrade.isRotating && other.CompareTag("Percent") && !upgrade.upgradeAttempted)
{
Debug.Log("충돌!");
TextMeshProUGUI probabilityText = other.GetComponentInChildren<TextMeshProUGUI>();
if (probabilityText != null)
{
Debug.Log("확률" + probabilityText.text);
upgrade.AttemptUpgradeWithProbability(probabilityText.text);
}
}
}
}룰렛이 멈췄을 때, 화살표가 어떤 확률 영역과 겹쳤는지를 감지하여 해당 확률 값을 추출해야 한다.
이 역할은 RouletteCollistionDetector에서 담당하며, 충돌 감지 전용 로직으로 분리하여 구성하였다.
충돌 감지는 OnTriggerStay 콜백 함수를 사용하였다.
OnTriggerEnter는 충돌 순간 한 프레임만 감지되기 때문에, 회전 정지 타이밍과 어긋날 가능성이 있다.
반면, OnTriggerStay는 두 Collider가 겹쳐진 상태를 지속적으로 감지할 수 있기 때문에, 룰렛이 정지한 이후 현재 위치를 안정적으로 판별하는 데 적합하다고 판단하였다.
룰렛이 멈춘 현재 위치를 판정해야 하는 구조에서, 단발성 이벤트보다 적합하다고 판단하였다.
또한, CompareTag 메서드를 사용하여 문자열 비교보다 성능적으로 유리한 방식으로 태그를 판별하였다.
이 함수는 '룰렛이 회전 중이 아닐 것, 충돌한 오브젝트가 확률 영역일 것, 아직 강화 판정이 이루어지지 않았을 것' 이라는 세 가지 조건을 모두 만족할 경우에만 동작한다.
룰렛의 각 확률 구간은 이미지가 아닌 TextMeshProUGUI 오브젝트로 구성하였다.
확률 값을 코드에 하드코딩 하지 않고 UI 텍스트를 기준으로 읽어오는 구조이기 때문에, 추후에 기획자나 다른 팀원이 코드 수정 없이도 확률 밸런스를 조정할 수 있다.
각 확률 텍스트 오브젝트에는 Percent 태그와 BoxCollider (isTrigger)가 설정되어 있다.
룰렛 중심의 화살표 오브젝트도 BoxCollider와 Rigidbody를 가지고 있으며, 룰렛이 회전을 멈춘 시점에 어떤 확률 구간과 충돌 중인지 감지할 수 있도록 설계하였다.
이 단계에서는 확률 계산이나 강화 결과 판정을 수행하지 않으며, 오직 선택된 확률 값만을 추출하여 다음 단계의 확률 판정 로직으로 전달하는 역할만 수행한다.
*결과

3.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과 결과 UI를 일정 시간 동안만 표시하기 위한 코루틴을 실행한다.
이를 개선하기 위해 강화 판정 직후 결과 UI를 활성화하고, 지정된 시간이 지나면 자동으로 비활성화되는 코루틴 기반의 UI 제어 로직을 구현하여 사용자 편의성을 높였다.
결과 UI 일정 시간 표시를 Update에서 타이머로 관리할 경우, 강화 상태 플래그와 UI 표시 시간이 서로 얽혀 로직이 복잡해질 수 있고, Invoke 방식은 중간 취소나 상태 동기화가 어렵다.
따라서 시간 기반 UI 제어를 강화 판정 로직과 분리하기 위해 StartCoroutine 기반의 코루틴 방식을 선택하였다.
ShowTemp 코루틴은 패널을 활성화한 뒤 지정된 시간만큼 대기하고 자동으로 비활성화함으로써, 결과 UI가 다음 강화 시도에 영향을 주지 않도록 한다.
이후 upgradeAttempted 플래그를 검사하여, 이미 판정이 완료된 강화 시도에 대해서는 즉시 종료함으로써 중복 확률 판정이 발생하지 않도록 제어하였다.
이 플래그는 룰렛 충돌 감지 단계와 확률 판정 단계 양쪽에서 참조되며, 하나의 강화 시도에서 결과가 단 한 번만 적용되도록 보장한다.
if (!float.TryParse(probabilityText, out float successProbability))
return;
successProbability /= 100f;
bool isSuccess = Random.value <= successProbability;전달받은 확률 값은 UI의 TextMeshProUGUI 텍스트를 그대로 가져오기 때문에 문자열(string) 형태로 전달된다.
이 값은 사람에게 친숙한 0~100 범위의 정수이지만, Unity의 난수 시스템(Random.value)은 0~1 범위의 실수를 반환한다.
따라서 이를 비교하기 위해 float.TryParse()를 사용해 문자열을 실수 값으로 변환 후, 이를 100으로 나누어 0~1 범위로 정규화하였다.
복잡한 수식 대신 Random.value와 직접 비교하는 방식을 선택하여, 동료 개발자가 코드를 보더라도 판정 로직을 직관적으로 이해할 수 있게 가독성을 높였다.
또한, 확률 데이터를 UI 텍스트 기준으로 관리함으로써 기획 수치가 변경되어도 로직 수정 없이 유연하게 대응할 수 있도록 설계하였다.
percentTxt.text = $"강화 확률 : {probabilityText}%";
ApplyUpgradeResult(isSuccess);
upgradeAttempted = true;확률 판정이 끝나면 ApplyUpgradeResult로 결과 처리를 위임하고, 플래그(upgradeAttempted)를 true로 설정하여 중복 판정을 방지하였다.
이 과정에서 확률 판정 로직을 독립시켜 연출이나 UI가 바뀌어도 계산 코드는 유지되는 유연한 구조를 만들었다.
또한, 실제 적용된 확률을 UI(percentTxt)에 즉시 표시하여, 플레이어가 판정 근거를 명확히 알 수 있게 하였다.
4. 개발 의도
룰렛 기반 강화 방식은 단순한 확률 계산 로직보다 구조가 복잡해질 수 있다.
하지만 이 구조를 선택한 이유는 다음과 같다.
강화 확률을 플레이어가 직접 인식할 수 있는 경험으로 만들고 싶었고, 확률 계산, 충돌 판정, 연출 로직을 분리하여 각 책임을 명확히 나누고 싶었다.
이후 확률 테이블 변경이나 연출 수정 시, 기존 강화 로직에 영향을 주지 않기 위함이었다.
