본문 바로가기
App && Game

[유니티] 랭크 보드에 이어 랭크 순위 변동 애니메이션 만들기

by 배애앰이 좋아 2025. 3. 23.
반응형

 

안녕하세요. 
이번에는 저번에 만들었던 랭크보드를 가지고 추가적인 업데이트를 시켜보았습니다.

 

저번 랭킹 UI 제작 글 : https://88-it.tistory.com/444

 

[유니티 / Unity] 랭킹 보드 UI 및 기능 만들기

안녕하세요! 오랜만에 게임을 만들다가 랭킹 보드를 만들어야 했는데 옛날에 만들었던 걸 재활용하려고 보니깐 없어서 다시 노가다하지 않도록 이렇게 글을 적게 되었습니다. 랭킹보드는 멀티

88-it.tistory.com

 

먼저, 저번 글을 보고 오시지 못한 분들을 위해 링크 공유해드립니다.
저번 글에서는 랭크보드 전반적인 UI 를 만들고 prefab aseet 을 공유해드렸습니다.
그리고 기본적인 유저 리스트 더하기, 점수 업데이트, 초기화 기능까지 만들었습니다. 기본적으로 SortRanks 라고 유저 점수에 따라 정렬을 해주었는데 단순히 값만 바꿔주는 형식으로 하였습니다.

하지만 만들고 나니 점수가 업데이트 될 때마다 값만 바뀌면 순위 변동이 있는지 잘 실감이 안 나는게 멋지지 않아서 이렇게 애니메이션을 제작해보게 되었습니다.

 

 

 

먼저, 결과 영상부터 공유해드립니다.
보시다싶이 기존에는 바의 이동 없이 값만 바꿨다면 현재는 순위 변동이 있을 때마다 해당 유저의 바가 직접 순위 쪽으로 이동하는 방식으로 바꿨습니다. 또한, 중간에 유저를 추가해도 문제없이 잘 반영해서 작동하는 것을 확인하실 수 있습니다.

 



나름 결과가 나쁘지 않게 나오지 않았나요?

일단은 여기까지 완성하고 추후에 더 뭔가 있으면 업데이트 해볼까 합니다. 애니메이션을 사용하기 위해서 다트윈, 닷트윈으로 불리는 유니티에서 유명한 애니메이션 에셋을 사용했습니다. 모르시는 분을 위해 유니티 에셋 스토어 링크 올려드립니다.

 

Unity Asset Store Link : DOTween (HOTween v2)

 

위에는 무료 버전이고 아래는 유료 버전입니다. 

 

Unity Asset Store Link : DOTween Pro

 

유료 버전이 더 좋은 점은 스크립팅 없이 gameObject을 애니메이션해 이동, 페이드, 색상, 회전, 스케일, 펀치, 흔들기, 텍스트, 카메라 속성 등 조작할 수 있고 무료 버전과 다르게 2D Toolkit  TextMesh Pro 오브젝트에도 적용할 수 있다는 점입니다. 비전공자나 디자이너 분들에게 더 유용한 것 같습니다. 한가지 더 좋은 기능은 오브젝트를 작동 경로를 따라 움직일 수 있는 컴포넌트도 있다는 점입니다. 이 부분은 코딩으로도 복잡한데 좋은 것 같습니다.


기존 글에서 따로 스크립트 추가는 없고 기존 RankManager.cs 을 전반적으로 수정했습니다.

 

using System;
using System.Collections;
using System.Collections.Generic;
using DG.Tweening;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// 플레이어 한 명에 대한 정보 
/// </summary>
[Serializable]
public class PlayerRank
{
    public string playerName;
    public string birthNum;
    public int score;
    public PlayerRank(string playerName, string birthNum, int score)
    {
        this.playerName = playerName;
        this.birthNum = birthNum;
        this.score = score;
    }
}

#region 랭킹 정렬 방식 

/// <summary>
/// 랭킹을 정렬하는 방법
/// </summary>
public abstract class RankSortStrategy : MonoBehaviour
{
    // 랭크 순서를 업데이트하는 추상 메서드
    public abstract void SortRanks(List<PlayerRank> rankListInfo, List<RankUI> rankBarScripts);
    public abstract void Clear();
}

/// <summary>
/// 애니메이션을 통한 정렬 방식 
/// </summary>
public class AnimateSortRanks : RankSortStrategy
{
    private readonly float animationDuration = 1f;  // 애니메이션 지속 시간
    private bool isStartAnimation = false;
    private List<int> rankOrder = new List<int>();
    private List<int> newRankOrder = new List<int>();
    public List<RectTransform> items = new List<RectTransform>();

    public override void SortRanks(List<PlayerRank> rankListInfo, List<RankUI> rankBarScripts)
    {
        if(isStartAnimation) return;
        Debug.Log("AnimateSortRanks");

        // first
        if(items.Count != rankBarScripts.Count && rankOrder.Count != rankBarScripts.Count){
            items.Clear();
            rankOrder.Clear();
            rankBarScripts.ForEach(rankBar => items.Add(rankBar.gameObject.GetComponent<RectTransform>()));
            for(int i=0; i<rankBarScripts.Count; i++){
                rankOrder.Add(i);
            }
        }

        newRankOrder.Clear();
        rankListInfo.Sort((player1, player2) => player2.score.CompareTo(player1.score));
        for(int i=0; i<rankBarScripts.Count; i++){
            for(int j=0; j<rankListInfo.Count; j++){
                if(rankBarScripts[i].playerNameText.text == rankListInfo[j].playerName && rankBarScripts[i].birthNumText.text == rankListInfo[j].birthNum){
                    rankBarScripts[i].scoreText.text = rankListInfo[j].score.ToString();
                    rankBarScripts[i].sequenceText.text = (j+1).ToString();
                    newRankOrder.Add(j);
                }
            }
        }
        StartCoroutine(AnimateRankChange(newRankOrder));
    }

    /// <summary>
    /// 교차하는 애니메이션 동작 
    /// </summary>
    public IEnumerator AnimateRankChange(List<int> newRankOrder) 
    {
        isStartAnimation = true;

        rankOrder.ForEach(rank => Debug.Log("기존 랭크 순서: " + rank));
        newRankOrder.ForEach(rank => Debug.Log("새로운 랭크 순서: " + rank));

        // 각 아이템의 새 위치를 계산합니다.
        Vector3[] targetPositions = new Vector3[items.Count];
        for (int i = 0; i < items.Count; i++)
        {
            int newRank = newRankOrder[i]; // 0 번째 오브젝트의 new Rank
            targetPositions[i] = items[rankOrder.IndexOf(newRank)].anchoredPosition; // 기존 new Rank 위치 오브젝트 번호를 받아서 위치 설정
        }

        for (int i = 0; i < items.Count; i++)
        {
            RectTransform item = items[i]; // 0 번째 오브젝트
            Debug.Log($"Animating {i} to position: {targetPositions[i]}");
            item.DOAnchorPos(targetPositions[i], animationDuration).SetEase(Ease.InOutQuad);
        }

        yield return new WaitForSeconds(animationDuration);
        rankOrder = new List<int>(newRankOrder);
        isStartAnimation = false;
        Debug.Log("AnimateRankChange Finish");
    }

    public override void Clear(){
        isStartAnimation = false;
        rankOrder.Clear();
        newRankOrder.Clear();
        items.Clear();
    }
}

/// <summary>
/// 애니메이션 없이 데이터만 바꾸는 정렬 방식 
/// </summary>
public class QuickSortRanks : RankSortStrategy
{
    public override void SortRanks(List<PlayerRank> rankListInfo, List<RankUI> rankBarScripts)
    {
        Debug.Log("QuickSortRanks");

        rankListInfo.Sort((player1, player2) => player2.score.CompareTo(player1.score));
        for(int i=0; i<rankListInfo.Count; i++){
            if (i < rankBarScripts.Count)
            {
                PlayerRank playerRank = rankListInfo[i];
                rankBarScripts[i].SettingInfo(i + 1 , playerRank.playerName, playerRank.birthNum, playerRank.score);
            }
            else
            {
                Debug.LogWarning($"Rank script not found for rank {i}.");
            }
        }
    }
    public override void Clear(){}
}

#endregion

public class RankManager : MonoBehaviour
{
    [SerializeField] private GameObject rankBoard;
    [SerializeField] private GameObject rankBarPrefab;
    [SerializeField] private Transform rankListContainer;
    private List<PlayerRank> rankListInfo = new List<PlayerRank>();
    private List<GameObject> rankBarGameObjects = new List<GameObject>();
    private List<RankUI> rankBarScripts = new List<RankUI>();
    private RCGameManager manager;
    [SerializeField] private RankUI myRankInfo;
    private RankSortStrategy currentRankSortStrategy;

    // public void Start()
    // {
    //     /*
    //     private RankSortStrategy currentRankSortStrategy = new AnimateSortRanks(); 코드에서 currentRankSortStrategy를 선언하고 
    //     AnimateSortRanks의 인스턴스를 할당하려고 시도하는 부분은 Unity의 MonoBehaviour와 관련된 문제로 오류를 발생시킬 수 있습니다. 
    //     Unity에서 MonoBehaviour를 상속받은 클래스는 new 키워드로 직접 인스턴스를 생성할 수 없습니다. 
    //     Unity는 MonoBehaviour 객체를 직접 생성하는 대신, Instantiate 메서드를 사용하여 객체를 생성하고, Unity의 오브젝트 관리 시스템에 맞게 처리해야 합니다.
    //     */
        // if(GetComponent<QuickSortRanks>() != null) Destroy(GetComponent<QuickSortRanks>());
        // if(GetComponent<AnimateSortRanks>() == null) {
        //     currentRankSortStrategy = this.gameObject.AddComponent<AnimateSortRanks>(); 
        // }  
    // }

    public void OnEnable() // 게임 오브젝트가 활성화될 때마다 확인
    {
        /*
        private RankSortStrategy currentRankSortStrategy = new AnimateSortRanks(); 코드에서 currentRankSortStrategy를 선언하고 
        AnimateSortRanks의 인스턴스를 할당하려고 시도하는 부분은 Unity의 MonoBehaviour와 관련된 문제로 오류를 발생시킬 수 있습니다. 
        Unity에서 MonoBehaviour를 상속받은 클래스는 new 키워드로 직접 인스턴스를 생성할 수 없습니다. 
        Unity는 MonoBehaviour 객체를 직접 생성하는 대신, Instantiate 메서드를 사용하여 객체를 생성하고, Unity의 오브젝트 관리 시스템에 맞게 처리해야 합니다.
        */
        if(GetComponent<QuickSortRanks>() != null) Destroy(GetComponent<QuickSortRanks>());
        if(GetComponent<AnimateSortRanks>() == null) {
            currentRankSortStrategy = this.gameObject.AddComponent<AnimateSortRanks>(); 
        }  
    }

    #region 랭킹 main 기능

    public void OnRankBoard(bool isActive){
        rankBoard?.SetActive(isActive);
    }

    /// <summary>
    /// 내 링킹 데이터 업데이트 - 현재는 쓰지 않음음
    /// </summary>
    public void ChangeMyRankInfo(string playerName, string birthNum, int score){
        playerName = playerName.Substring(0, playerName.Length - 6);
        birthNum = birthNum.Substring(birthNum.Length - 6);

        int index = rankListInfo.FindIndex(p => p.playerName == playerName && p.birthNum == birthNum);
        PlayerRank player = rankListInfo[index];
        if (player != null)
        {
            myRankInfo.SettingInfo(index + 1, player.playerName, player.birthNum, player.score);
        }else{
            Debug.LogWarning($"Player {playerName} not found in the rank list!");
        }
    }

    /// <summary>
    /// 랭킹 유저 추가하기 
    /// </summary>
    public void AddRankList(string playerName, string birthNum, int score)
    {
        playerName = playerName.Substring(0, playerName.Length - 6);
        birthNum = birthNum.Substring(birthNum.Length - 6);

        GameObject rankBar = Instantiate(rankBarPrefab, rankListContainer);
        rankBarGameObjects.Add(rankBar);

        RankUI rankUI = rankBar.GetComponent<RankUI>();
        rankUI.SettingInfo(rankBarScripts.Count + 1, playerName, birthNum, score);
        rankBarScripts.Add(rankUI);

        PlayerRank newPlayer = new PlayerRank(playerName, birthNum, score);
        rankListInfo.Add(newPlayer);

        //currentRankSortStrategy.SortRanks(rankListInfo, rankBarScripts);
    }

    /// <summary>
    /// 유저 이름과 새 점수를 받아서 업데이트 하기 
    /// </summary>
    public void UpdateRank(string playerName, int newScore)
    {
        string birthNum = playerName.Substring(playerName.Length - 6);
        playerName = playerName.Substring(0, playerName.Length - 6);
        
        PlayerRank player = rankListInfo.Find(p => p.playerName == playerName && p.birthNum == birthNum);
        if (player != null)
        {
            player.score = newScore;
            currentRankSortStrategy.SortRanks(rankListInfo, rankBarScripts);
        }else{
            Debug.LogWarning($"Player {playerName} not found in the rank list!");
        }

        if(manager == null){
            manager = GameObject.Find("GameManager").GetComponent<RCGameManager>();
        }
        if(manager != null){
            // manager.RobotSay("TIME_OVER");
            // manager.SendToGame("TIME_OVER");
        }
    }

    public void InitializeRanks()
    {
        rankListInfo.Clear();
        rankBarGameObjects.Clear();
        rankBarScripts.Clear();
        currentRankSortStrategy.Clear();
        foreach (Transform child in rankListContainer)
        {
            Destroy(child.gameObject);
        }
    }

    public void OnGameTimeout(){
        if(GetComponent<AnimateSortRanks>() != null) Destroy(GetComponent<AnimateSortRanks>());
        currentRankSortStrategy =  this.gameObject.AddComponent<QuickSortRanks>();
        currentRankSortStrategy.SortRanks(rankListInfo, rankBarScripts);
    }

    #endregion

    #region 랭킹 부가가 기능

    /// <summary>
    /// 유저 이름으로 정보 찾기기
    /// </summary>
    public PlayerRank FindPlayerRankInfo(string playerName)
    {
        PlayerRank player = rankListInfo.Find(p => p.playerName == playerName);
        if (player == null)
        {
            throw new KeyNotFoundException($"Player {playerName} not found in the rank list!");
        }
        return player;
    }

    /// <summary>
    /// 순위로 정보 찾기 
    /// </summary>
    public PlayerRank FindCurrentNumRankInfo(int rankNum)
    {
        return rankListInfo[rankNum];
    }

    // 테스트 코드 
    // private void Update()
    // {
    //     // Q 키 입력 시 이벤트 발생
    //     if (Input.GetKeyDown(KeyCode.Q))
    //     {
    //         Debug.Log("Input.GetKeyDown(KeyCode.Q)");
    //         AddRankList("A123456", "A123456", 30);
    //         AddRankList("B123456", "B123456", 25);
    //         AddRankList("C123456", "C123456", 20);
    //         AddRankList("D123456", "D123456", 15);
    //         AddRankList("E123456", "E123456", 10);
    //     }
    //     // W 키 입력 시 이벤트 발생
    //     if (Input.GetKeyDown(KeyCode.W))
    //     {
    //         Debug.Log("Input.GetKeyDown(KeyCode.W)");
    //         AddRankList("F123456", "F123456", 10);
    //     }
    //     // E 키 입력 시 이벤트 발생
    //     if (Input.GetKeyDown(KeyCode.E))
    //     {
    //         Debug.Log("Input.GetKeyDown(KeyCode.E)");
    //         int randomInt = UnityEngine.Random.Range(1, 30);
    //         Debug.Log("Input.GetKeyDown(KeyCode.E) " + randomInt);
    //         UpdateRank( GetRandomCharacter() + "123456", randomInt);
    //     }
    // }

    // private char GetRandomCharacter()
    // {
    //     char[] chars = { 'A', 'B', 'C', 'D', 'E' };
    //     int index = UnityEngine.Random.Range(0, 4); 
    //     return chars[index];
    // }

    #endregion
}



위 코드입니다.
해당 코드를 짤 때, 여러 가지 테스트를 용이하게 하고 유지보수와 확장성을 향상 시키기 위해 RankSortStrategy 추상 클래스를 만든 후 추상 클래스를 상속받은 자식 클래스를 2개 기존의 애니메이션 없이 빠르게 정렬 가능한 QuickSortRanks 와 애니메이션으로 정렬하는 AnimateSortRanks 를 만들었습니다.
굳이 정의하자면, SOLID 법칙 중 개방-폐쇄 원칙에 해당되게 만들었습니다. 덕분에 currentRankSortStrategy = this.gameObject.AddComponent(); 이런 식의 정의를 통해 따로 수정없이 둘 다 번갈아가며 테스트할 수 있었습니다.

제작하면서 몇가지 시행착오가 있었는데 똑같은 실수하지 않도록 적어볼까 합니다.

1. 추상클래스 정의
원래는 private RankSortStrategy currentRankSortStrategy = new QuickSortRanks(); 이런 식으로 선언할 때, 정의도 같이 해주었는데 AnimateSortRanks 에서는 Coroutine 를 사용하기 때문에 MonoBehaviour 가 꼭 필요합니다.
RankSortStrategy : MonoBehaviour 추상 클래스가 MonoBehaviour 를 상속받았기 때문에 아무 문제 없을 거라고 생각했는데 테스트해본 결과 아니였습니다. 열심히 ChatGPT에게 물어본 결과 : private RankSortStrategy currentRankSortStrategy = new AnimateSortRanks(); 코드에서 currentRankSortStrategy를 선언하고 AnimateSortRanks의 인스턴스를 할당하려고 시도하는 부분은 Unity의 MonoBehaviour와 관련된 문제로 오류를 발생시킬 수 있습니다. Unity에서 MonoBehaviour를 상속받은 클래스는 new 키워드로 직접 인스턴스를 생성할 수 없습니다. Unity는 MonoBehaviour 객체를 직접 생성하는 대신, Instantiate 메서드를 사용하여 객체를 생성하고, Unity의 오브젝트 관리 시스템에 맞게 처리해야 합니다. 이야기해서 currentRankSortStrategy = this.gameObject.AddComponent(); 이런식으로 바꾸니 문제 해결! 처음으로 알게 되었네요.

2. 정의할 때 문제
사실 단순한 문제이지만 잊고 있어서 문제였습니다. 코드 중에 전 랭킹에다가 새 랭킹 값을 넣어주는 다음과 같은 rankOrder = new List(newRankOrder); 코드가 있었는데 원래는 rankOrder = newRankOrder; 이렇게 썼습니다.
그 결과 rankOrder 에 newRankOrder 값이 들어가는 것이 아닌 아예 참조를 해버려서 위에서 newRankOrder 의 새 값이 들어올 때, 자동 동기화되어서 랭킹 차이가 있어야 이동을 하는데 같아서 이동이 안 일어나는 문제가 있었습니다.
이걸 놓치나 싶었지만 머리가 안 돌아가면 놓치는 것 같습니다. 일일이 로그 찍으면서 해당 문제를 알았네요. 고로 값만 복사하고 싶다면 rankOrder = new List(newRankOrder); 이런 식으로 새롭게 만들어줘야 합니다.

3. 애니메이션 로직
보시다 싶이 코드가 복잡합니다. 현재 로직은 현재 랭킹 값과 랭킹 UI를 받으면 랭킹 값을 이용해 정렬(미고정)하고 랭킹 UI는 고정이기 때문에 0 번째 오브젝트가 n 번째 랭킹인지 알려줍니다.
이렇게 알려주면 0 번째 오브젝트가 현재 n 번째에 위치한 오브젝트의 위치를 가져와서 이동하게 됩니다.
그렇기 때문에 기존 랭킹 값이 필요한 이유는 랭킹 n 번째에 어떤 m 오브젝트가 있는지 확인하기 위합니다... 스스로 생각해도 너무 복잡한 거 같아서 수정할려고 했는데 머리만 터지고 실패... 다음에 더 좋은 방법이 생각나서 수정해서 올려보겠습니다.

여기까지이며 다음에 만들게 되면 이런 수고 필요없이 바로 갖다가 써야겠네요.
또 추가적으로 업데이트 되는 게 있다면 글을 작성하겠습니다.

읽어주셔서 감사합니다.

반응형