[Unity] Coin Collection Animation [ + UI Tool Kit ](2)

suhan0304·2024년 6월 30일
0

유니티 - UI Tool Kit

목록 보기
5/8
post-thumbnail

이제 직접 구현해보자!

Implementation!

일단 VFX 프리팹을 오브젝트 풀링으로 관리해보자.

ObjectPoolData.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;


[CreateAssetMenu(fileName = "Data_ObjectPool_", menuName = "Utilities/ObjectPool", order = 1)]
public class ObjectPoolData : ScriptableObject {
    [Header("Settings")]
    public GameObject objectToPool;
    public int amountToPool;
    public bool shouldExpand;
}

이제 ObjectPoolData를 기반으로 오브젝트 풀을 실제로 생성해줄 ObjectPoolBehaviour를 만들자

ObjectPoolBehaviour.cs

using System.Collections;
using System.Collections.Generic;
using System.Data.Common;
using UnityEngine;

public class ObjectPoolBehaviour : MonoBehaviour
{
    public ObjectPoolData data;

    private List<GameObject> pooledObjects;

    void Awake() {
        CreatePool();
    }

    public void CreatePool() {
        pooledObjects = new List<GameObject>();

        for(int i = 0; i < data.amountToPool; i++) {
            GameObject obj = (GameObject)Instantiate(data.objectToPool);
            obj.SetActive(false);
            pooledObjects.Add(obj);
        }
    }    

    public GameObject GetPooledObject() {
        for(int i = 0; i< pooledObjects.Count; i++) {
            if(!pooledObjects[i].activeInHierarchy) {
                return pooledObjects[i];
            }
        }

        if(data.shouldExpand) {
            GameObject obj = (GameObject)Instantiate(data.objectToPool);
            obj.SetActive(false);
            pooledObjects.Add(obj);
            return obj;
        }

        return null;
    }

}

이제 이 Behaviour 게임 오브젝트를 하나 만들어준다.

실행을 해주면 미리 지정해둔 수 만큼 VFX가 만들어진다!


UI 작성

일단 UI Tool Kit을 사용해서 UI를 먼저 작성해두자. 그냥 간단히 코인을 지급해주는 버튼과 리셋 버튼하나만 만들자. UI Tool Kit (1)에서 진행했던 것처럼 UI Document로 생성해준다.


하나씩 천천히 해보자. 일단 버튼을 누르면 Force Field 활성화 해주고 Externel Force를 설정해주는 PlayPooledFX를 만들어보자.

CoinMagnet.cs

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEditor.Search;
using UnityEngine;
using UnityEngine.Pool;
using UnityEngine.UIElements;

namespace UICoinFX
{
    [Serializable]
    public class MagnetData 
    {
        // ParticleSystem Pool
        public ObjectPoolBehaviour FXPool;

        //forcefield target
        public ParticleSystemForceField ForceField;
    }

    public class CoinMagnet : MonoBehaviour {
        [Header("UI Elements")]
        [Tooltip("UI 요소의 screen space position을 가져오기 위한 UI Document")]
        [SerializeField] UIDocument m_Document;
        [SerializeField] MagnetData m_MagnetData;

        [Header("Camera")]
        [Tooltip("World Space Positions를 구하기 위해 Camera와 Depth를 사용")]
        [SerializeField] Camera m_Camera;
        [SerializeField] float m_ZDepth = 10f;
        [Tooltip("3D offset to the particle emission")]
        [SerializeField] Vector3 m_SourceOffset = new Vector3(0f, 0.1f, 0f);

        // start and end corrdinates for effect
        void OnEnable() {
            // 이벤트 + 메소드 연결
            CoinEvents.CoinButtonClick += OnCoinButtonClick;
        }

        void OnDisable() {
            // 이벤트 + 메소드 연결 해제
            CoinEvents.CoinButtonClick -= OnCoinButtonClick;
        }

        ObjectPoolBehaviour GetFXPool() {
            MagnetData magnetData = m_MagnetData;
            return magnetData.FXPool;
        }

        ParticleSystemForceField GetForceField() {
            MagnetData magnetData = m_MagnetData;
            return magnetData.ForceField;
        }

        void PlayPooledFX(Vector2 screenPos) {
            Vector3 worldPos = screenPos.ScreenPosToWorldPos(m_Camera, m_ZDepth) + m_SourceOffset;

            ObjectPoolBehaviour fxPool = GetFXPool();

            // ParticleSystem 초기화
            ParticleSystem ps = fxPool.GetPooledObject().GetComponent<ParticleSystem>();

            if (ps == null)
                return;

            ps.gameObject.SetActive(true);
            ps.gameObject.transform.position = worldPos;

            // ForceField 추가 (목적지 설정)
            ParticleSystemForceField forceField = GetForceField();
            forceField.gameObject.SetActive(true);

            // UI 위치를 기반으로 ForceField 위치 업데이트
            PositionToVisualElement positionToVisualElement = forceField.gameObject.GetComponent<PositionToVisualElement>();
            positionToVisualElement.MoveToElement();

            // 파티클 시스템에 ForceField 부착
            ParticleSystem.ExternalForcesModule externalForces = ps.externalForces;
            externalForces.enabled = true;
            externalForces.AddInfluence(forceField);

            ps.Play();
        }        
        
        // buying some coin from the ShopScreen
        void OnCoinButtonClick(Vector2 screenPos)
        {
            PlayPooledFX(screenPos);
        }
    }
}

원래 샘플에서는 item의 Type을 확인한 후에 적절한 Pool과 ForceField를 가져오는데 구현에서는 하나만 할 거라서 굳이 List로 MagnetData를 유지하지 않았다.

이제 Magnet 오브젝트와 ForceField 오브젝트를 만들어보자.

만들어준 CoinMagnet의 Coin Magnet을 적절히 연결해준다. 우리는 Main Camera만 쓰고 있어서 Main Camera를 Camera로 연결한다.

이제 ForceField의 위치 업데이트(목적지 설정)를 위한 PositionToVisualElement를 작성해보자.

PositionToVisualElement.cs

using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEditor.Search;
using UnityEngine;
using UnityEngine.UIElements;

public class PositionToVisualElement : MonoBehaviour
{
    /// GameObject의 위치를 ​​지정된 UI 상의 VisualElement 위치에 할당
    
    [Header("Transform")]
    [SerializeField] GameObject m_ObjectToMode;

    [Header("Camera parameters")]
    [SerializeField] Camera m_Camera;
    [SerializeField] float m_Depth = 10f;

    [Header("UI Target")]
    [SerializeField] UIDocument m_Document;
    [SerializeField] string m_ElementName;

    VisualElement m_TargetElement;

    void OnEnable() {
        if (m_Document == null) {
            Debug.LogError("[PositionToVisualElement] : UIDocument 할당 오류");
            return;
        }

        VisualElement root = m_Document.rootVisualElement;
        m_TargetElement = root.Q<VisualElement>(name : m_ElementName);

        if (m_TargetElement == null) {
            Debug.LogError($"[PositionToVisualElement] : Element '{m_ElementName}'를 찾을 수 없습니다.");
            return;
        }
    }

    void Start() {
        MoveToElement();
    }

    public void MoveToElement() {
        if (m_Camera == null) {
            Debug.LogError("[PositionToVisualElement] MoveToElement : Camera 할당 오류");
            return;
        }

        if (m_ObjectToMode == null) {
            Debug.LogError("[PositionToVisualElement] MoveToElement : ObjectToMove 할당 오류");
            return;
        }

        // UI Tool Kit의 center screen Position 위치 계산
        Rect worldBound = m_TargetElement.worldBound;
        Vector2 centerPosion = new Vector2(worldBound.x + worldBound.width / 2, worldBound.y + worldBound.height / 2);

        // 확장 함수를 사용해서 screen Pos pixel 계산
        Vector2 screenPos = centerPosion.GetScreenCoordinate(m_Document.rootVisualElement);

        // 확장 함수를 사용해서 screen Pos를 world Pos로 변환
        Vector3 worldPosition = screenPos.ScreenPosToWorldPos(m_Camera, m_Depth);

        if (m_ObjectToMode != null) {
            m_ObjectToMode.transform.position = worldPosition;
        }
    }
}

여기서 GetScreenCoordinate과 ScreenPosToWorldPos 함수를 확장 함수 (Extension Methods에 작성하자.)

ExtensionMethods.cs

using Unity.VisualScripting;
using UnityEngine;
using UnityEngine.UIElements;

// Static class
public static class ExtensionMethods 
{
    // UI Tool Kit screen Pos를 World Pos로 바꿈
    public static Vector3 ScreenPosToWorldPos(this Vector2 screenPos, Camera camera = null, float zDepth = 10f) {
        if (camera == null)
            camera = Camera.main;
        
        if (camera == null) 
            return Vector2.zero;

        float xPos = screenPos.x;
        float yPos = screenPos.y;
        Vector3 worldPos = Vector3.zero;

        if( !float.IsNaN(screenPos.x) && !float.IsNaN(screenPos.y) && !float.IsInfinity(screenPos.x) && !float.IsInfinity(screenPos.y)) {
            // Camera class를 사용해서 world space로 변환
            Vector3 screenCoord = new Vector3(xPos, yPos, zDepth);
            worldPos = camera.ScreenToWorldPoint(screenCoord);
        }

        return worldPos;
    }

    // UI Tool Kit ClickEvent 위치에서 화면 좌표를 가져옴
    public static Vector2 GetScreenCoordinate(this Vector2 clickPosition, VisualElement rootVisualElement) {
        float borderLeft = rootVisualElement.resolvedStyle.borderLeftWidth;
        float borderTop = rootVisualElement.resolvedStyle.borderTopWidth;
        clickPosition.x += borderLeft;
        clickPosition.y += borderTop;

        Vector2 normalizedPosition = clickPosition.NormalizeClickEventPosition(rootVisualElement);

        float xValue = normalizedPosition.x * Screen.width;
        float yValue = normalizedPosition.y * Screen.height;
        return new Vector2(xValue, yValue);
    }

    // UI ToolKit 클릭 이벤트 위치를 (0,0)과 (1,1) 사이 값으로 정규화 해줌
    public static Vector2 NormalizeClickEventPosition(this Vector2 clickPosition, VisualElement rootVisualElement) {
        // UI Toolkit에서 화면 경계를 나타내는 Rect를 가져옴
        Rect rootWorldBound = rootVisualElement.worldBound;

        float normalizedX = clickPosition.x / rootWorldBound.xMax;

        // Flip the y value (0이 스크린의 바닥)
        float normalizedY = 1 - clickPosition.y / rootWorldBound.yMax;

        return new Vector2(normalizedX, normalizedY);
    } 
}

이제 인스펙터로 연결해주자. UI의 위치로 움직일 ObjectToMove는 당연히 ForceField고 Camera는 메인, UI Document 넣어주고, Element Name은 목적지로 할 Coin 아이콘 이름으로 해준다.


이제 원하는 버튼에 이벤트를 연결해주어야 한다. 예제의 ShopEvents와 비슷한 느낌으로 스크립트를 하나 만들자. 예제에서는 많은 이벤트에 대응하기 위해 Action 변수가 굉장히 많은데 나는 간단히 이펙트 출력을 위한 UI 제작이기 때문에 버튼을 눌렀는가라는 이벤트만 간단히 만들어준다.

예제에서 Transaction으로 따로 구분해둔 이유는 실제로 아이템을 구매하면 버튼을 눌렀을때 바로 아이템을 지급하는게 아니라 버튼을 누르면 사용자가 재화가 충분한지, 인앱결제면 실제로 결제가 완료됐는지, 재화 소비를 통해 재화를 감소 시켰는지에 대한 여부를 모두 잘 확인한 후에 아이템을 지급하기 위해서 두 단계로 나눈 것이라고 에상 하고 있는데 한 번 더 살펴보자.

CoinEvents.cs

using System;
using UnityEngine;

public static class CoinEvents
{
    // Click the Button on UI Document
    public static Action<Vector2> CoinButtonClick;
    public static Action<Vector2> ResetButtonClick;

}

이제 다시 CoinMagnet으로 돌아가서 Event Action을 연결해주자.

CoinMagnet.cs

// start and end corrdinates for effect
void OnEnable() {
    // 이벤트 + 메소드 연결
    CoinEvents.CoinButtonClick += OnCoinButtonClick;
}

void OnDisable() {
    // 이벤트 + 메소드 연결 해제
    CoinEvents.CoinButtonClick -= OnCoinButtonClick;
}

// buying some coin from the ShopScreen
void OnTransactionProcessed(Vector2 screenPos)
{
    PlayPooledFX(screenPos);
}

이제 이 TransactionProcessed에서 PlayPooledFX가 실행되는데 예제에서는 GameDataMangers에서 해당 함수를 실행한다. 위에서 예상했던 것처럼 PurchaseItem을 하면 먼저 shopItem을 확인하고 충분한 Funds가 있는지 확인하고 있다면 PayTransition, ReceivePurchasedGoods와 같은 아이템 지금 로직을 실행한 다음에 TransactionProcessed를 실행한다!

GameDataManager.cs (IN SAMPLE)

// event-handling methods

// buying item from ShopScreen, pass button screen position 
void OnPurchaseItem(ShopItemSO shopItem, Vector2 screenPos)
{
    if (shopItem == null)
        return;

    // invoke transaction succeeded or failed
    if (HasSufficientFunds(shopItem))
    {
        PayTransaction(shopItem);
        ReceivePurchasedGoods(shopItem);
        ShopEvents.TransactionProcessed?.Invoke(shopItem, screenPos);

        AudioManager.PlayDefaultTransactionSound();
    }
    else
    {
        // notify listeners (PopUpText, sound, etc.)
        ShopEvents.TransactionFailed?.Invoke(shopItem);
        AudioManager.PlayDefaultWarningSound();
    }
}

하지만 우리가 작성 중인 예제는 재화확인 필요없이 버튼을 누르면 바로 지급할 것이라서 그냥 바로 Button Click을 호출하도록 이벤트를 바로 연결하자.

예제를 보면 ShopItemAsset에 있는 ShopItemButton마다 이벤트를 연결 시켜주기 위해 ShopItemComponent를 별도로 작성해놓고, ShopItemAsset을 Instantiate 할 때마다 shopItemComponent로 넘겨서 shopItemController라는 변수에 받은 다음 shopItemController에서 또 별도의 함수를 진행해주고 마지막에 RegisterCallbacks로 callback 함수를 연결시키고 parentElement에 Add 해주면서 shopItemElem를 하나씩 추가해준다.

상점에 상품이 많으면 이런식으로 처리해주는것이 적합하다! 하지만? 나는 코인 하나만 해줄거라서 그냥 UI Screen Controller를 만들어서 바로 연결을 시켜보자.

UIScreenController.cs

using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
using UnityEngine.UIElements;

public class UIScreenController : MonoBehaviour
{
    [Header("UI Elements")]
    [Tooltip("UI Document")]
    [SerializeField] UIDocument m_Document;
    [SerializeField] Button m_CoinButton;
    [SerializeField] Button m_ResetButton;

    VisualElement root;

    private const string coinButtonID = "Button--Coin";
    private const string coinResetID = "Button--Reset";

    void OnEnable() {
        root = m_Document.rootVisualElement;
        m_CoinButton = root.Q<Button>(coinButtonID);
        m_ResetButton = root.Q<Button>(coinResetID);

        m_CoinButton.RegisterCallback<ClickEvent>(OnClickCoinButton);
        m_ResetButton.RegisterCallback<ClickEvent>(OnClickResetButton);
    }

    void OnDisable() {
        m_CoinButton.UnregisterCallback<ClickEvent>(OnClickCoinButton);
        m_ResetButton.UnregisterCallback<ClickEvent>(OnClickResetButton);
    }

    void OnClickCoinButton(ClickEvent evt) {

        Vector2 clickPos = new Vector2(evt.position.x, evt.position.y);
        Vector2 screenPos = clickPos.GetScreenCoordinate(root);

        CoinEvents.CoinButtonClick?.Invoke(screenPos);
    }

    void OnClickResetButton(ClickEvent evt) {
        return;
    }
}

다 잘 작동하는데 게임 오브젝트가 UI 뒤로 렌더링되는 문제가 있다. 샘플을 확인해보니 CoinsFXRenderCameraLandscape로 따로 렌더링 하고 있는 것을 확인할 수 있다.

동일하게 파티클 시스템 프리팹의 레이어를 VFX로 설정하고 Render Camera를 하나 새로 만들어서 Culling Mask에 VFX만 렌더링 하도록 한다.

메인 카메라에서는 VFX를 제외해준다.

그런 다음 예제를 분석해보니깐 아무래도 Render Texture로 Ouput을 내보낸 다음 Uxml 상에서 해당 RenderTexture를 그냥 overlay로 추가로 그려주는 방법 같다.

동일하게 Render Texture로 내보낸 다음 그 Render Texture를 Uxml에서 불러오도록 하자

중요한 점은 해상도 비율이 어느 정도 맞아야 한다는 점이다. 나는 지금 FHD Vertical 고정 해상도를 쓰고 있기 때문에 Render Texture도 동일한 비율의 해상도로 해야 이 Render Texture를 다시 uxml에 올려놔도 비율이 깨지지 않는다.
uxml도 화면 해상도를 그대로 받아서 FHD Vertical을 쓰고 있기 때문이다. 해상도 비율이 같아야 Render Texture가 size (100%, 100%)로 화면에 들어가도 비율이 무너지지 않는다.

여기서 VFX 카메라의 백그라운드를 무조건 투명하게 해줘야한다. 투명하게 해주지 않으면 Render Texture에도 배경색이 렌더링되고 이 Render Texture를 uxml에 올리면 백그라운드 때문에 화면을 모두 가리게 된다. ( 이 오류를 찾는데 정말 시간이 오래 걸렸다. )

Background 불투명

Backgounrd 투명


게임 오브젝트를 UI 위에 올리는 방법이 생각보다 어려운 방법은 아니었다.. 이 오류 거치는데 2일은 걸린거 같다.

별도의 카메라로 Render Texture로 내보내고 이거를 다시 image의 Background에 할당해서 가져오는 방식이 처음이라 어려웠던 것 같다.

난이도 자체는 UGUI와 비슷할 정도로 어렵지는 않은 듯 하다!

아무튼! uxml을 이용해 코인 효과를 구현해내는데 성공했다!

profile
Be Honest, Be Harder, Be Stronger

0개의 댓글