강화 시스템

아이템 타입과 강화 단계에 따라 필요한 재화와 재료를 계산하고, 룰렛을 이용한 확률 기반 강화 결과를 제공한다.

성공과 실패는 명확히 분기되며, 각 결과에 따라 아이템 상태와 시각·청각적 피드백이 즉시 반영된다.

목차

1. 유니티 구현

2. 전체 코드

유니티 구현

룰렛 UI는 회전 중인 원판이 멈추는 순간 어떤 확률 구간이 침 아래에 위치했는지를 판정해야 한다.

일반적으로는 룰렛의 회전 각도를 계산해 해당 각도가 어느 구간에 속하는지를 판정하는 방식도 사용할 수 있지만, 각도 기반 방식은 룰렛의 UI 구조가 변경되거나 확률 구간의 개수가 바뀌는 경우 추가적인 매핑 로직이 필요해진다.

또한, 회전 애니메이션과 판정 로직이 서로 다른 기준으로 동작하는 문제가 발생할 수 있다.

따라서 룰렛 시스템에서는 화면에 보이는 결과와 판정 기준을 동일하게 만들기 위해, 룰렛의 회전 결과를 각도 계산으로 판정하는 대신 침이 마지막으로 닿은 확률 구간을 충돌 이벤트로 감지하는 방식을 사용하였다.

즉, 룰렛 결과는 별도의 계산 로직이 아니라 실제 화면에서 침이 가리키고 있는 영역을 기준으로 결정된다.

이 구조에서 룰렛의 각 숫자 칸은 결과 판정을 위한 영역 역할을 한다.

각 확률 구간은 룰렛 원판 위에 배치된 UI 요소이며, 실제로 물리적인 힘을 받거나 움직일 필요는 없다.

따라서 각 확률 구간 오브젝트에는 BoxCollider를 추가하고 isTrigger 옵션을 활성화하여 단순한 영역 감지용 Collider로 사용하였다.

Trigger Collider는 물리적인 충돌 반응을 발생시키지 않고, 단순히 두 객체가 겹쳤을 때 이벤트를 전달하는 역할을 한다.

이를 통해 룰렛의 숫자 칸은 물리 시뮬레이션에 영향을 받지 않으면서도 침과의 겹침을 감지할 수 있는 판정 영역으로 동작한다.

또한 모든 확률 칸에는 Percent 태그를 부여하여 충돌 이벤트 발생 시 해당 객체가 실제 확률 구간인지 빠르게 식별할 수 있도록 하였다.

이 방식은 UI 장식 요소나 다른 오브젝트가 트리거 이벤트를 발생시키는 상황을 방지하고, 판정 로직을 명확하게 유지하는 데 도움이 된다.

반면 룰렛의 침(포인터)은 이러한 판정 이벤트를 발생시키는 기준 객체이기 때문에 Collider뿐 아니라 Rigidbody를 함께 사용하였다.

Unity의 물리 시스템에서는 Trigger 이벤트(OnTriggerEnter, OnTriggerStay, OnTriggerExit)가 정상적으로 발생하기 위해 충돌하는 두 객체 중 최소 하나는 Rigidbody를 가지고 있어야 한다.

만약 침과 확률 칸이 모두 단순 Collider만 가지고 있다면 물리 엔진이 이를 물리 이벤트로 처리하지 않아 충돌 이벤트가 발생하지 않을 수 있다.

이러한 문제를 방지하기 위해 침 오브젝트에는 BoxCollider와 Rigidbody를 함께 추가하여 물리 엔진이 두 객체의 겹침을 정상적인 Trigger 이벤트로 인식하도록 구성하였다.

여기서 Rigidbody는 실제 물리 시뮬레이션을 위해 사용된 것이 아니라 충돌 이벤트 발생을 안정적으로 보장하기 위한 구성 요소다.

룰렛의 회전은 물리 연산이 아닌 UI Transform 회전으로 처리되기 때문에 침이 중력이나 힘의 영향을 받을 필요는 없다.

따라서 Rigidbody는 일반적으로 isKinematic 상태로 설정하여 물리 계산에 의해 위치가 변경되지 않도록 유지한다.

이렇게 하면 침은 화면상에서는 고정된 포인터 역할을 유지하면서도 물리 엔진 기준에서는 충돌 이벤트를 발생시키는 객체로 동작하게 된다.

이와 같은 구조를 통해 룰렛 시스템은 UI 연출과 판정 로직을 분리하면서도 결과 판정 기준을 화면에 보이는 상태와 완전히 동일하게 유지할 수 있다.

룰렛이 멈추는 순간 침과 겹친 확률 구간을 Trigger 이벤트로 검출하고, 해당 영역에 포함된 확률 값을 읽어 강화 결과를 결정한다.

이러한 방식은 각도 계산 기반 판정에서 발생할 수 있는 오차나 복잡한 보정 로직을 줄이고, UI 구조가 변경되더라도 Collider 영역만 수정하면 동일한 판정 로직을 유지할 수 있다는 장점이 있다.

전체 코드

using System.Collections;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class UpgradeSlot : MonoBehaviour
{
    private const string CrystalItemId = "200_02_01";   // 강화 크리스탈 아이템 ID

    ItemManager itemManager;
    Item selectedItem;  // 선택한 아이템의 대한 정보

    [Header("선택된 아이템 정보")]
    [SerializeField] Image icon = null;                    // 아이템 이미지
    [SerializeField] Image gradeImage = null;              // 아이템의 강화단계 이미지
    [SerializeField] TextMeshProUGUI itemName = null;      // 아이템 이름
    [SerializeField] TextMeshProUGUI upgradeInfo = null;   // 강화 정보 메세지 (현재 레벨 > 다음 레벨)  

    [Header("필요한 아이템 정보")]
    [SerializeField] TextMeshProUGUI crystalCount = null;  // '소지하고 있는 수량 / 필요한 크리스탈 수량'
    [SerializeField] TextMeshProUGUI reqCoin = null;       // 필요한 재화

    int requiredCoin;           // 필요한 재화
    int requiredCrystal;        // 필요한 크리스탈 수량
    int currentCrystalCount;    // 소유하고 있는 크리스탈 수량

    [SerializeField] GameObject upgradeBtn;     // 강화 버튼
    [SerializeField] GameObject warningText;    // 재료가 부족할 때 띄울 경고 메세지

    [Header("룰렛")]
    public GameObject roulettePanel;                          // 룰렛 패널 
    [HideInInspector] public bool isRotating = false;         // 룰렛 회전 여부
    [HideInInspector] public bool upgradeAttempted = false;   // 강화의 추가 시도를 제한

    [Header("결과창")]
    public GameObject resultPanel;      // 결과 패널
    public TextMeshProUGUI percentTxt;  // 강화 확률
    public TextMeshProUGUI resultTxt;   // 강화 결과

    [Header("이펙트 & 사운드")]
    [SerializeField] private ParticleSystem successEffect;   // 성공 이펙트
    [SerializeField] private ParticleSystem failEffect;      // 실패 이펙트

    [SerializeField] private AudioSource audioSource;        // 공용 오디오 소스
    [SerializeField] private AudioClip successClip;          // 성공 사운드
    [SerializeField] private AudioClip failClip;             // 실패 사운드

    void Start()
    {
        itemManager = GameManager_LDW.instance.itemManager;
        CancelSelection();
    }

    void Update()
    {
        currentCrystalCount = itemManager.GetItem(CrystalItemId).count;   // 현재 소지하고 있는 크리스탈 수량

        // [ 선택한 아이템에 따라 재료 설정 ]
        // 아이템 선택 시 
        if (selectedItem != null)
        {
            UpdateSelectedItemUI();
            UpdateRequirementsByType();
            UpdateUpgradeState();
        }

        UpdateGradeImageVisibility();
    }

    public void UpdateItem()
    {
        currentCrystalCount = itemManager.GetItem(CrystalItemId).count;   // 현재 소지하고 있는 크리스탈 수량
    }

    // 아이템 선택
    public void SelectItem(Item _item)  // SelectItem함수에서 Item을 받아옴
    {
        selectedItem = _item;   // SelectedItem에 받아온 아이템의 정보를 담음
    }

    // 선택된 아이템의 기본 UI 정보 업데이트
    private void UpdateSelectedItemUI()
    {
        icon.enabled = true;                            // 선택된 아이템이 있다면, 아이템 아이콘 이미지를 활성화
        icon.sprite = selectedItem.itemImage;           // icon에 선택한 아이템의 이미지를 가져옴
        gradeImage.sprite = selectedItem.gradeSprite;   // 강화 정보에 선택한 아이템의 강화 이미지를 가져옴
        itemName.text = selectedItem.itemName;          // 선택한 아이템 이름을 화면에 띄움
    }

    // 아이템 선택 취소 및 초기화 (UI 리셋)
    public void CancelSelection()
    {
        // 선택된 아이템 정보 초기화
        selectedItem = null;

        // 아이콘 / 강화 이미지 / 텍스트들 리셋
        icon.enabled = false;
        icon.sprite = null;

        gradeImage.enabled = false;
        gradeImage.sprite = null;

        itemName.text = "";
        upgradeInfo.text = "";
        crystalCount.text = "";
        reqCoin.text = "";

        // 강화 버튼 / 경고 문구 비활성화
        BtnOrErr(false, false);

        // 룰렛 / 결과창도 혹시 열려 있으면 닫기
        if (roulettePanel != null) roulettePanel.SetActive(false);
        if (resultPanel != null) resultPanel.SetActive(false);

        // 룰렛 상태 플래그 초기화
        isRotating = false;
        upgradeAttempted = false;
    }

    // 강화 단계 이미지 표시 여부
    private void UpdateGradeImageVisibility()
    {
        //이미 강화된 아이템이면, 강화 단계 이미지를 활성화, 강회되지 않았으면 강화 단계 이미지 비활성화
        gradeImage.enabled = (gradeImage.sprite != null);
    }


    // --- 비용 계산 / 조건 설정 --- //
    // 아이템 타입에 따라 강화 설정 분기
    private void UpdateRequirementsByType()
    {
        if (selectedItem.equipType == Item.EquipType.Weapon)
        {
            SetAccordingToType(1, 10);  // 무기: x = 1, 최대강화 10
        }
        else
        {
            SetAccordingToType(2, 3);   // 예: 반지: x = 2, 최대강화 3
        }
    }

    // 아이템 타입에 따른 필요 재료 및 UI 텍스트 설정
    private void SetAccordingToType(int x, int maxGrade)
    {
        requiredCoin = (selectedItem.grade + 1) * 400 * x;   // 필요한 재화 = (현재 강화 단계 + 1) * 400 * x
        requiredCrystal = (selectedItem.grade + 1) * x;      // 필요한 크리스탈 = 현재 강화 단계 + 1 * x

        if (selectedItem.grade < maxGrade)    // 최대 강화가 아닐 때 (최대강화 단계 = 10)
        {
            // 강화 정보 UI 업데이트
            upgradeInfo.text = $"{selectedItem.grade}  >  {selectedItem.grade + 1}";   
            crystalCount.text = $"{currentCrystalCount} / {requiredCrystal}";          
            reqCoin.text = $"필요 재화: {requiredCoin}";    
        }
        else   // 최대 강화
        {
            upgradeInfo.text = "최대 강화"; 
            crystalCount.text = "-";       
            reqCoin.text = "필요 재화: -"; 
        }
    }

    // 강화가 가능 여부에 따라 버튼/경고 상태 제어
    private void UpdateUpgradeState()
    {
        // 해당 아이템이 이미 최대 강화라면
        if (upgradeInfo.text == "최대 강화")
        {
            BtnOrErr(false, false);
            return;
        }

        bool hasEnoughCrystal = currentCrystalCount >= requiredCrystal;
        bool hasEnoughCoin = itemManager.coin >= requiredCoin;

        // 강화 가능
        if (hasEnoughCrystal && hasEnoughCoin) BtnOrErr(true, false);

        // 재료 부족
        else BtnOrErr(false, true);
    }

    // 강화버튼 / 경고 활성화 상태 관리
    public void BtnOrErr(bool btn, bool err)
    {
        upgradeBtn.SetActive(btn);
        warningText.SetActive(err);
    }


    /// --- 강화 시작 / 룰렛 --- //
    // 강화 클릭 시 호출
    public void UpgradeBtn()
    {
        // 재화, 크리스탈 차감
        itemManager.coin -= requiredCoin;
        itemManager.GetItem(CrystalItemId).count -= requiredCrystal;

        // 룰렛 창 활성화
        roulettePanel.SetActive(true);  

        // 룰렛 회전 시작
        upgradeAttempted = false;   // 강화의 추가 시도를 가능 (룰렛 창에서 멈춤 버튼을 안눌렀음)
        isRotating = true;          // 룰렛 변수를 true로 하여, 룰렛이 돌아가도록 설정
    }

    // 멈추기 버튼 클릭 시 호출
    public void StopBtn()
    {
        isRotating = false; // 룰렛을 멈춤
    }


    /// --- 강화 결과 처리 --- ///
    // 룰렛이 멈추었을 때, 해당 확률로 강화 시도
    public void AttemptUpgradeWithProbability(string probabilityText)
    {
        // 룰렛/결과 패널 잠깐 보여주기
        StartCoroutine(ShowTemp(roulettePanel, 1f));    // 1초 뒤에 upgradeSystemPanel을 비활성화 함
        StartCoroutine(ShowTemp(resultPanel, 1f));        // 결과 창인 resultPanel을 활성화하고, 0.5초 뒤에 비활성화 함

        // 이미 한 번 처리된 강화라면 중복 실행 방지
        if (upgradeAttempted) return;

        // 확률 문자열을 실수로 변환
        if (!float.TryParse(probabilityText, out float successProbability))
            return;

        // 0-100 사이의 확률 값을 0-1 사이로 변환함
        successProbability /= 100f;

        percentTxt.text = $"강화 확률 : {probabilityText}%";    // 강화 확률 텍스트에 '강화 확률 : 현재 강화 확률 %'로 초기화 

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

        upgradeAttempted = true;    // 성공 또는 실패 후에 upgradeAttempted를 true로 설정하여 추가 시도 제한
    }

    // 성공/실패에 따른 결과 텍스트 및 강화 단계 반영
    private void ApplyUpgradeResult(bool success)
    {
        if (success)
        {
            int currentGrade = selectedItem.grade;
            int nextGrade = currentGrade + 1;

            selectedItem.grade = nextGrade;

            resultTxt.color = Color.yellow;
            resultTxt.text = $"강화 성공: {currentGrade} -> {nextGrade}";

            UpdateSelectedItemUI();
            UpdateRequirementsByType();
            UpdateUpgradeState();

            PlayResultEffect(true);
        }
        else
        {
            resultTxt.color = Color.red;
            resultTxt.text = "강화 실패";

            PlayResultEffect(false);
        }
    }

    // obj 오브젝트를 duration만큼만 활성화
    private IEnumerator ShowTemp(GameObject obj, float duration) 
    {
        obj.SetActive(true);                       
        yield return new WaitForSeconds(duration);  
        obj.SetActive(false);                    
    }

    // 성공/실패 공통 이펙트 재생
    private void PlayResultEffect(bool success)
    {
        // 파티클
        if (success && successEffect != null)
            successEffect.Play();
        else if (!success && failEffect != null)
            failEffect.Play();

        // 사운드
        if (audioSource != null)
        {
            AudioClip clip = success ? successClip : failClip;
            if (clip != null)
                audioSource.PlayOneShot(clip);
        }
    }
}
using TMPro;
using UnityEngine;

public class RouletteCollisionDetector : MonoBehaviour
{
    private UpgradeSlot upgrade;

    private void Start()
    {
        // UpgradeSlot 컴포넌트가 현재 GameObject에 없으면 부모에서 찾음
        upgrade = GetComponentInParent<UpgradeSlot>();
    }

    void OnTriggerStay(Collider other)
    {
        // upgrade가 null이 아니고, 부딪힌 객체의 태그가 Percent이고, isRotating이 false일 때만 충돌을 체크.
        if (!upgrade.isRotating && other.CompareTag("Percent") && !upgrade.upgradeAttempted)
        {
            Debug.Log("충돌!");
            TextMeshProUGUI probabilityText = other.GetComponentInChildren<TextMeshProUGUI>();   // 부딪힌 객체의 TextMeshProUGUI 컴포넌트를 찾아서 참조

            if (probabilityText != null)
            {
                Debug.Log("확률" + probabilityText.text);
                upgrade.AttemptUpgradeWithProbability(probabilityText.text);
            }
        }
    }
}
using UnityEngine;

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);  // 룰렛의 rectTransform에서 rotation에서 z축만 돌림
        }   
    }
}