XR플밍 - 12. UnityEngine3D Reactive 프로그래밍 - 기업협약 프로젝트 17일차 (9/9)

이형원·2025년 9월 9일
0

XR플밍

목록 보기
191/215

1. 금일 한 업무 정리

  • 캐릭터 데이터 - DB 연동작업 완료

  • 스테이터스 부분까지만 로컬로 구현하고, 연동되는 부분 확인함

  • 맵 선택 UI 연출 작업

2. 문제의 발생과 해결 과정

2.1 캐릭터 데이터의 DB 연동 작업

기본적인 틀은 다른 사람이 만들어 둔 상태였기 때문에 이를 분석하여 DB를 연동시키는 작업이 진행되어야 했다.
다른 사람의 코드를 분석하여 작업을 이어가는 과정 자체가 생각보다 스트레스긴 했지만, DB를 다루는 작업은 한 번 쯤 해 봐야 한다고 생각했기 때문에 차분하게 분석해보았다.

우선 내 나름대로 분석한 내용을 바탕으로 구조를 그려봤을 때 이런 느낌이라고 파악했다.

이에 따라 우선은 지금 데이터테이블이 따로 없기 때문에 DBManager에 로드하는 기능과 Update에 대한 이벤트를 실행시켰다.
하지만 이걸로는 변화가 없었다. 어찌 보면 당연한 것이다.

이 다음으론 데이터가 변동되는 지점에서 저장하도록 하면, 이벤트의 발생에 따라 DB의 데이터 내용도 변한다.

대표적인 예시로, 가챠로 조각을 획득하는 구간에서 이와 같이 DB와 관련된 내용을 넣어주면 된다.

private async void TestItemSelect()
{
    UnitData data = _testData;

    if (_testData.UpgradeData.CurrentUpgradeData.UpgradeLevel == 0)
    {
        _testData.UpgradeData.ObtainCharacter();

        _resultUI.HeroGachaUpdate(data, 0, "New");
    }
    else
    {
        // 조각 등장 확률도 나중에 가중치로 전환되면 가중치로 적용 필요
        int pieceNum = UnityEngine.Random.Range(1, 11);

        _testData.UpgradeData.AddPiece(pieceNum);

        _resultUI.HeroGachaUpdate(data, 0, pieceNum.ToString());
    }

    await DBManager.Instance.charDB.SaveCharacterData(_testData);

    _resultUI.gameObject.SetActive(true);
}

조각을 획득하고 UI를 출력하는 마지막 구간에 await DBManager.Instance.charDB.SaveCharacterData(_testData); 를 추가하면, DB에서 현재 업데이트 되는 SO의 데이터를 다시 저장하게 된다. 이러면 DB에도 변동사항이 반영되게 되고, 게임의 종료 이후에도 해당 데이터는 유지되는 상태가 된다.
(애초에 해당 데이터는 SO로 저장되어서 데이터 변동에 대해 영구적인 저장을 하지만, DB에 저장하고 로드하는 방식으로 로컬 데이터 변조 등에서 대응할 수 있다.)

또한 데이터의 저장 및 로드는 백엔드의 영역이다 보니, 시간이 걸리는 점을 감안하여 await/async가 필수적으로 진행되어야 한다.

일..단은 데이터가 저장되고 로드되는 부분까지는 구현해냈지만 여전히 구멍이 많다.

  1. 초기화에 대한 기능이 없음.(데이터테이블이 아직 구성되지 않은 상태라 그럼)
  2. 기본 데이터도 DB에 올리기로 결정되었지만, 해당 기능에 대한 구현이 아직 없음
  3. 현재 작동되는 방식이 정상적인 작동 방식인지 확인이 필요함.(테스트 케이스 부족)

2.2 맵 선택 UI 연출 1 - 가로 스크롤 스냅 연출

이 게임에서 맵 선택에 대한 연출은 기본적으로 아래 래퍼런스와 거의 동일하게 간다고 생각하면 된다.

딱 봐도 간단하지는 않겠구나 싶었다. 다른 부분은 그렇다고 쳐도, 여기서 쉽지 않은 부분은 크게 두 가지다.

  1. 스크롤을 옮기고, 특정 구간에서 멈추게 함. 여기서 디테일함을 살리자면, 속도에 따라서 튕기면 여러 칸을 넘어갈 수도 있게 해야 함.

  2. 중앙에 있는 맵 이미지와 측면에 있는 맵 이미지의 크기/혹은 위치가 다르게 표기되어야 함. 이는 실시간으로 진행되어야 함.

일단 여기서, 1번에 대한 작업을 어떻게 했는지에 대해 알아보고자 한다.

이런 연출에 대한 부분으로 참고할 만한 영상이 있는가 확인해봤는데, 우선 아래 영상을 참고했다.

https://www.youtube.com/watch?v=K_ujyelRZUA&pp=2AaRBw%3D%3D

이 영상은 궁수의 전설 UI 같이 손가락 스냅으로 화면을 좌우로 넘기는 방식을 구현하고 있다.
물론, 내가 원하는 방식과 완전히 일치하지는 않는 방향이기 때문에, 우선 해당 영상을 보고 R&D를 진행했다.

스크롤 스냅 연출을 만들기 위한 핵심 구조는 다음과 같다.

  1. 초기화 과정(Start) 에서 배열로 어느 구간에서 스크롤이 멈춰야 하는지 지정하기.
  2. IDragHandler, IEndDragHandler가 필수적으로 필요, 드래그 중에는 드래그 중이라는 bool 변수를 이용하여 드래그를 원활히 함. 드래그가 종료된 시점(혹은 본인이 원하는 시점)에서 targetPos를 지정하고, 해당 지점으로 Lerp하도록 하는 것.

여기에서는 가속 스냅 스크롤에 대한 연출까지도 있었기 떄문에, 여기에서 팁을 얻었다.

PointerEventData eventData - eventData.delta.x 를 통해 드래그 종료 시점에서의 드래그 이벤트에 대한 x값(드래그 속도)를 구할 수 있다. 이를 이용해 가속 드래그를 했을 때의 관성을 구현할 것이다.

따라서, 드래그 종료 시점에서의 속도를 바탕으로 코루틴을 실행하여, 저장한 속도를 Time.DeltaTime으로 빼주면서 대기시간을 주고, 대기시간이 종료되는 시점에서 TargetPos를 지정하여 특정 지점에 고정되도록 하였다.

private void Update()
{
    if (!_isDrag)
    {
        _scrollBar.value = Mathf.Lerp(_scrollBar.value, _targetPos, Time.deltaTime * 5f);
        if (Mathf.Abs(_scrollBar.value - _targetPos) < 0.01)
        {
            _scrollBar.value = _targetPos;
        }
    }
}

public void OnDrag(PointerEventData eventData) => _isDrag = true;

/// <summary>
/// 드래그 종료 시점의 속도를 저장하여,
/// 해당 속도가 일정 수준 이하로 떨어졌을 때 드래그를 멈추고 맵을 지정함.
/// </summary>
/// <param name="eventData"></param>
public void OnEndDrag(PointerEventData eventData)
{
    _scrollSpeed = eventData.delta.x;
    _dragCoroutine = StartCoroutine(DragCoroutine());
}

/// <summary>
/// 현재 스크롤바의 위치를 기준으로 가장 가까운 위치를 반환
/// </summary>
/// <returns></returns>
private float SetPos()
{
    for (int i = 0; i < SIZE; i++)
    {
        if (_scrollBar.value < _pos[i] + _distance * 0.5f && _scrollBar.value > _pos[i] - _distance * 0.5f)
        {
            _targetIndex = i;
            return _pos[i];
        }
    }
    return 0;
}

/// <summary>
/// 드래그가 종료되었을 때, 해당 드래그의 속도에 따라
/// 타겟의 위치를 지정
/// </summary>
/// <returns></returns>
private IEnumerator DragCoroutine()
{
    while (_scrollSpeed > 20)
    {
        _scrollSpeed -= Time.deltaTime * 250;
        yield return null;
    }

    _targetPos = SetPos();
    _isDrag = false;
    _dragCoroutine = null;
}

여기서 유의해야 할 점이, 코루틴 내에서 _scrollSpeed -= Time.deltaTime * 250; 에서 250이라는 구체적인 수치를 넣은 점이다.
이건 실제로 직접 테스트를 진행해보면서 넣은 수치이며, 스크롤 스피드가 Time.deltaTime으로 빼기에는 너무 높은 수치가 찍히는 경우도 있기 때문에, 이를 보정해주기 위해 넣은 수치이다.

2.3 맵 이미지의 사이즈가 달라지거나 위치가 달라지는 연출

이 부분에 대해서는 우선 사이즈 조정을 중심으로 연출을 진행했다.

위에서 구현한 방식을 기준으로, targetPos를 이용하여 거리를 측정하고, 해당 거리만큼 사이즈를 줄이는 방식으로 구현해보았다.
현재 위치를 실시간으로 지정하면서, 해당 위치를 기준으로, 타겟의 위치를 파악하고 그 타겟의 위치와 현재 위치와의 거리 차이만큼 사이즈를 줄이는 방식을 사용했다.

private void DragScaleAnimation()
{
    _currentPos = SetPos();
    for(int i = 0; i < SIZE; i++)
    {
        _mapImage[i].transform.DOScale(1f - _mapImgShrinkSize * (Mathf.Abs(_pos[i] - _currentPos)), Time.deltaTime * _mapImgShrinkDuration);
    }
}

어차피 현재 위치에서 두 칸 이상 위치한 맵의 크기는 보이지 않으니 선택할 수 있는 방법이었다.
또한 아쉽게도, 이런 맵의 연출을 자연스럽게 구현하기 위해서는 어쩔 수 없이 해당 함수를 Update문에서 선언해야 하는 한계점도 있었다.

이로서 해당 연출을 구성하는 코드 전문은 다음과 같다.

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

public class MapSelectAnimationController : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
    [Header("RectTransform")]
    [SerializeField] private Scrollbar _scrollBar;

    [Header("MapDescription PopUp")]
    [SerializeField] private GameObject[] _popUps;

    [Header("Map Image")]
    [SerializeField] private GameObject[] _mapImage;

    [Header("Offset")]
    [SerializeField] private float _mapImgShrinkDuration = 3f;
    [SerializeField] private float _mapImgShrinkSize = 1.5f;
    [SerializeField] private float _mapInfoActivateDuration = 0.5f;

    // Init
    const int SIZE = 7;
    private float[] _pos = new float[SIZE];
    private float _distance;

    // 드래그 중 지정 변수
    private float _currentPos;
    private float _targetPos;
    private float _scrollSpeed;

    private int _targetIndex;

    // 드래그 중 여부
    private bool _isDrag;

    private Coroutine _dragCoroutine;

    public Action OnTargetPosSelected;

    private void Start()
    {
        _distance = 1f / (SIZE - 1);
        for (int i = 0; i < SIZE; i++) _pos[i] = _distance * i;

        InActivateMapInfo();
    }

    private void Update()
    {
        if (!_isDrag)
        {
            _scrollBar.value = Mathf.Lerp(_scrollBar.value, _targetPos, Time.deltaTime * 5f);
            if (Mathf.Abs(_scrollBar.value - _targetPos) < 0.01)
            {
                _scrollBar.value = _targetPos;
                OnTargetPosSelected?.Invoke();
            }
        }

        DragScaleAnimation();
    }

    private void OnEnable()
    {
        OnTargetPosSelected += ActivateMapInfo;
    }

    private void OnDisable()
    {
        OnTargetPosSelected -= ActivateMapInfo;
    }

    #region Drag Event

    public void OnBeginDrag(PointerEventData eventData) => InActivateMapInfo();


    public void OnDrag(PointerEventData eventData) => _isDrag = true;

    /// <summary>
    /// 드래그 종료 시점의 속도를 저장하여,
    /// 해당 속도가 일정 수준 이하로 떨어졌을 때 드래그를 멈추고 맵을 지정함.
    /// </summary>
    /// <param name="eventData"></param>
    public void OnEndDrag(PointerEventData eventData)
    {
        _scrollSpeed = eventData.delta.x;
        _dragCoroutine = StartCoroutine(DragCoroutine());
    }

    /// <summary>
    /// 현재 스크롤바의 위치를 기준으로 가장 가까운 위치를 반환
    /// </summary>
    /// <returns></returns>
    private float SetPos()
    {
        for (int i = 0; i < SIZE; i++)
        {
            if (_scrollBar.value < _pos[i] + _distance * 0.5f && _scrollBar.value > _pos[i] - _distance * 0.5f)
            {
                _targetIndex = i;
                return _pos[i];
            }
        }
        return 0;
    }

    private void DragScaleAnimation()
    {
        _currentPos = SetPos();
        for(int i = 0; i < SIZE; i++)
        {
            _mapImage[i].transform.DOScale(1f - _mapImgShrinkSize * (Mathf.Abs(_pos[i] - _currentPos)), Time.deltaTime * _mapImgShrinkDuration);
        }
    }

    /// <summary>
    /// 드래그가 종료되었을 때, 해당 드래그의 속도에 따라
    /// 타겟의 위치를 지정
    /// </summary>
    /// <returns></returns>
    private IEnumerator DragCoroutine()
    {
        while (_scrollSpeed > 20)
        {
            _scrollSpeed -= Time.deltaTime * 250;
            yield return null;
        }

        _targetPos = SetPos();
        _isDrag = false;
        _dragCoroutine = null;
    }

    #endregion

    #region MapInfo PopUp

    private void ActivateMapInfo()
    {
        for(int i = 0; i < _popUps.Length; i++)
        {
            _popUps[i].transform.DOScale(1f, _mapInfoActivateDuration);
            _popUps[i].gameObject.SetActive(true);
        }
    }
    
    private void InActivateMapInfo()
    {
        for(int i = 0; i < _popUps.Length; i++)
        {
            _popUps[i].transform.DOScale(0f, _mapInfoActivateDuration);
            _popUps[i].gameObject.SetActive(false);
        }
    }    

    #endregion
}

3. 개선점 및 과제

3.1 업무 분배 - 몬스터 부분 작업

아직 구체적인 작업 내용에 대해 듣지는 못했지만, UI 연출 작업을 하고 있던 와중에 몬스터 관련으로 나온 기획 때문에 나에게 UI 연출 작업이 끝난 이후로 바로 몬스터 쪽에 붙어달라는 요청을 받았다.
몬스터 애니메이션이나 이런 부분에 붙을 수도 있을 것 같긴 한데, 해당 작업을 빨리 끝내야제 CBT 때 그럴싸한 게임을 만들 수 있을 것이다.

3.2 인게임 씬 연출 및 폴리싱

3.3 리팩토링

profile
게임 만들러 코딩 공부중

0개의 댓글