728x90
SMALL

게임 진행 UI 구현

게임을 시작하면 지금 몇 스테이지를 플레이하고 있는지, 몇 명의 손님을 태웠는지, 몇 번째 손님을 태우고 있는지 등의 정보를 출력하는 UI를 구현하였습니다.


게임 진행상황을 UI에 전달할 수 있도록 GameLogic 클래스에 게임시작, 종료, 손님승차/하차 이벤트 추가하였습니다.

public event EventHandler GamePlayEvent;
public event EventHandler<int> CustomerTakeInEvent;
public event EventHandler<int> CustomerTakeOutEvent;
public event EventHandler<bool> GameEndedEvent;

 

StageProgressViewUI 클래스를 추가하여 위 그림과 같은 기능을 가진 UI를 구현하였습니다.

public class StageProgressViewUI : MonoBehaviour
{
    [Serializable]
    public class Part
    {
        [SerializeField]
        GameObject container;
        [SerializeField]
        Image stateImage;
        [SerializeField]
        Sprite nonSprite;
        [SerializeField]
        Sprite takeInSprite;
        [SerializeField]
        Sprite passSprite;

        public void SetVisible(bool visible) =>
            container.SetActive(visible);

        public void SetState(int state)
        {
            if (state == 1)
                stateImage.sprite = takeInSprite;
            else if (state == 2)
                stateImage.sprite = passSprite;
            else
                stateImage.sprite = nonSprite;
        }
    }

    [SerializeField]
    LayoutGroup layoutGroup;
    [SerializeField]
    TMP_Text currentStageText;
    [SerializeField]
    Part[] parts;
    [SerializeField]
    Image nextStageImage;
    [SerializeField]
    Sprite nextStageNonSprite;
    [SerializeField]
    Sprite nextStagePassSprite;
    [SerializeField]
    TMP_Text nextStageText;

    void OnEnable()
    {
        StartCoroutine(UpdateLayoutGroup());
    }

    public void Init()
    {
        var stage = GameLogic.StageIndex + 1;
        var stageCount = ClientManager
            .Instance
            .TemplateService
            .StageTemplates
            .Count;
        var customerCount = GameLogic.Instance.CustomerCount;

        currentStageText.text = stage.ToString();

        for (int i = 0; i < parts.Length; i++)
        {
            parts[i].SetVisible(i < customerCount);
            parts[i].SetState(0);
        }
        nextStageText.text = Math.Min(stage + 1, stageCount).ToString();

        if (gameObject.activeInHierarchy)
            StartCoroutine(UpdateLayoutGroup());

        GameLogic.Instance.CustomerTakeInEvent += (sender, customerNumber) =>
        {
            parts[customerNumber - 1].SetState(1);
        };
        GameLogic.Instance.CustomerTakeOutEvent += (sender, customerNumber) =>
        {
            parts[customerNumber - 1].SetState(2);
        };
        GameLogic.Instance.GameEndedEvent += (sender, isGoal) =>
        {
            if (isGoal)
                nextStageImage.sprite = nextStagePassSprite;
        };
    }

    IEnumerator UpdateLayoutGroup()
    {
        layoutGroup.enabled = true;
        yield return new WaitForEndOfFrame();
        layoutGroup.enabled = false;
    }
}

 

결과창 구현

 

결과창을 구현하기 전, 제가 참고하는 프로젝트에서 게임을 실패하여도 손님을 태운 만큼 보상을 받지만, 저는 실패하면 보상을 받지 않도록 구현하였습니다.
그래서 서버와 클라이언트에 있는 EndStage 메소드를 각각 수정하였습니다.

[HttpPut("EndStage")]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public async Task<ActionResult> EndStage([FromBody] EndStageRequest body)
{
    var userId = ClaimHelper.FindNameIdentifier(User);
    if (string.IsNullOrEmpty(userId))
        return Unauthorized();

    var user = await userRepository.Get(userId);
    if (user == null)
        return Forbid();

    var stageTemps = await templateService.GetStages();
    var stageCount = stageTemps?.Count ?? 0;
    var stageIndex = body.StageIndex;
    // 템플릿에 등록되지 않은 스테이지
    if (stageIndex < 0 || body.StageIndex >= stageCount)
        return BadRequest();

    // 현재 스테이지도 아직 클리어하지 않았을 경우
    if (stageIndex > user.CurrentStageIndex)
        return Forbid();

    // 클라이언트에서 계산한 요금이 서버에서 계산한 요금보다 더 큰 경우
    var maxCoin = stageTemps?[stageIndex]?.MaxCoin ?? 0;
    if (body.Coin >= maxCoin)
        return Forbid();

    if (body.IsGoal)
    {
        // 다음 스테이지 개방
        if (stageIndex == user.CurrentStageIndex && stageIndex < stageCount + 1)
            user.CurrentStageIndex = stageIndex + 1;
    }
    user.Coin += body.Coin;
    await userRepository.Update(user.Id!, user);

    return NoContent();
}
public async UniTask<HttpStatusCode> EndStage(int stageIndex, bool isGoal, long coin)
{
    var currStageIndex = User.CurrentStage.Index;
    var stageCount = templateService.StageTemplates.Count;

    if (stageIndex < 0 || stageIndex >= stageCount)
    {
        Debug.LogError($"End stage failed. Because {stageIndex} is out of range.");
        return HttpStatusCode.BadRequest;
    }

    if (stageIndex > currStageIndex)
    {
        Debug.LogWarning($"End stage({stageIndex}) failed. Because {User.CurrentStage.Index} was not clear.");
        return HttpStatusCode.Forbidden;
    }

    if (coin >= templateService.StageTemplates[stageIndex].MaxCoin)
    {
        Debug.LogError($"End stage failed. Because {coin} is too much.");
        return HttpStatusCode.Forbidden;
    }

    if (isGoal)
    {
        if (stageIndex == currStageIndex && stageIndex < stageCount - 1)
            User.CurrentStage = templateService.StageTemplates[stageIndex + 1];
    }
    User.Coin += coin;

    var res = await http.Put($"User/EndStage", new EndStageRequest
    {
        StageIndex = stageIndex,
        IsGoal = isGoal,
        Coin = coin
    });
    if (!res.IsSuccess())
    {
        Debug.LogWarning($"End stage failed. - {res}");
        UserUpdateFailed?.Invoke(this, EventArgs.Empty);
    }
    return res;
}

 

결과창은 돈을 얼마나 벌었는지, 게임을 성공하였다면 다음 스테이지로 이동하고, 실패하였다면 재시작하는 버튼 1개만 추가하여 간단하게 끝냈습니다.

 

ResultViewUI 클래스를 추가하여 기능을 구현하였습니다.

public class ReadyViewUI : MonoBehaviour
{
    [SerializeField]
    TMP_Text coinText;
    [SerializeField]
    Button playButton;
    [SerializeField]
    Button carListButton;

    void Awake()
    {
        UpdateCointext();
    }

    void Start()
    {
        playButton.onClick.AddListener(() =>
        {
            GameUI.Instance.ShowPlayView();
            GameLogic.Instance.PlayGame();
            gameObject.SetActive(false);
        });
        carListButton.onClick.AddListener(() =>
        {
            GameUI.Instance.ShowCarList();
        });
    }

    public void UpdateCointext()
    {
        coinText.text = ClientManager.Instance.UserService.User.Coin.ToString();
    }
}

 

가이드 UI 구현

 

플레이 버튼을 누르면 바로 게임을 시작하지 않고 게임 조작법을 먼저 출력하고, 화면을 터치하면 조작법 UI가 사라지면서 게임을 시작하도록 수정하였습니다.
제가 참고 하였던 프로젝트는 조작법을 출력하는 동안에 조작을 안 해도 자동차가 최소 속도로 이동하였습니다.
하지만 저는 개인적으로 버그처럼 보여서 저는 플레이 버튼을 누른 후 아무 조작도 하지 않으면 자동차가 출발하지 않도록 구현하였습니다.

 

2초 간격으로 텍스트를 강조하는 효과는 GuideViewUI 클래스를 추가하여 구현하였고, 손가락이 움직이는 효과는 직접 애니메이션을 만들었습니다.

public class GuideViewUI : MonoBehaviour, IPointerDownHandler
{
    [SerializeField]
    List<TMP_Text> texts;
    [SerializeField]
    Material normalMaterial;
    [SerializeField]
    Material highlightedMaterial;


    void OnEnable()
    {
        if (texts.Count == 0)
        {
            Debug.LogError("Not exist any texts.");
            return;
        }
        StartCoroutine(HighlightText(0));
    }

    public void OnPointerDown(PointerEventData eventData)
    {
        gameObject.SetActive(false);
    }

    IEnumerator HighlightText(int index)
    {
        for (int i = 0; i < texts.Count; i++)
        {
            if (i == index)
            {
                texts[i].textStyle = texts[i].styleSheet.GetStyle("Guide Highlighted");
                texts[i].fontMaterial = highlightedMaterial;
            }
            else
            {
                texts[i].textStyle = texts[i].styleSheet.GetStyle("Normal");
                texts[i].fontMaterial = normalMaterial;
            }
        }
        yield return new WaitForSecondsRealtime(2f);
        if (gameObject.activeInHierarchy)
            StartCoroutine(HighlightText((index + 1) % texts.Count));
    }
}

 

버그 수정

 

UI를 만들고 게임을 플레이하였을 때 4번 맵에서 자동차가 거꾸로 생성되어 추락하는 버그를 발견하였습니다.

 

이 버그를 해결하기 위해 각도 등의 수치를 조절하거나 도로의 기울기를 조절하는 포인트를 이동시켜 보거나 처음부터 다시 이동경로를 배치해봤지만 해결하지 못 했습니다.
그러다 별 생각 없이 직선 도로에 포인트를 1개 더 추가하여 문제를 해결하였기 때문에 원인은 알 수 없습니다.

 

이동경로 문제를 해결하고, 다시 게임 테스트를 해보니 이번엔 일부 커브 구간에서 큰 자동차와 충돌하여 의도치 않게 게임오버되는 버그도 발견하였습니다.

 

이 버그는 자동차들의 사이즈를 줄여서 해결하였습니다.

 

원래 게임 플레이 중 출력되는 UI를 모두 구현한 후에 글을 게시하려고 하였지만 위에 언급한 버그 외에 TextMeshPro에서 제공하는 스타일 시트를 사용해보려다 원하는 결과가 안 나와 삽질을 하는데 시간을 소비하여 다 구현하지 않고 작업을 마무리 하였습니다.
다음에 손님을 타고, 내릴 때 메시지가 출력 되도록 구현한 후 플레이 UI 작업은 완료하겠습니다.

 

 

구현 결과

깃 허브 저장소 : taxi-game-3d-unity

728x90
LIST
728x90
SMALL

UI 작업은 처음에는 따로 공부를 시작한 UIToolkit을 실제로 적용해볼지 아니면 계속 사용했던 UGUI를 사용할지 고민하다가 다른 게임 회사 채용공고에 UIToolkit을 사용하는 프로젝트는 아직 보이지 않아 UGUI를 사용하기로 하였습니다.

 

로그인 UI 구현

LoginLogic를 수정하고, LoginUI를 추가하여 직접 이메일과 비밀번호를 입력하여 로그인을 하도록 수정하였습니다.

public class LoginLogic : MonoBehaviour
{
    public static LoginLogic Instance
    {
        get;
        private set;
    }

    void Awake()
    {
        Instance = this;

        if (GameUI.Instance != null)
            GameUI.Instance.HideAll();
    }

    IEnumerator Start()
    {
        ClientManager.CreateInstance();
        yield return new WaitForEndOfFrame();
        Loading();
    }

    async void Loading()
    {
        var cliMgr = ClientManager.Instance;

        if (!await cliMgr.TemplateService.LoadAll())
        {
            Debug.LogError("Load templates failed.");
            return;
        }

        var statusCode = await cliMgr.AuthService.Relogin();
        if (!statusCode.IsSuccess())
        {
            Debug.Log("Relogin failed.");
            LoginUI.Instance.SetLoginContainerVisible(true);
            return;
        }

        statusCode = await cliMgr.UserService.Load();
        if (!statusCode.IsSuccess())
        {
            Debug.LogError("Load user failed.");
            LoginUI.Instance.SetLoginContainerVisible(true);
            return;
        }

        GotoGame();
    }

    public async UniTask<HttpStatusCode> Login(string email, string password)
    {
        var statusCode = await ClientManager.Instance.AuthService.Login(new()
        {
            Email = email,
            Password = password
        });
        if (!statusCode.IsSuccess())
            return statusCode;
        return await ClientManager.Instance.UserService.Load();
    }

    public async UniTask<HttpStatusCode> Register(string email, string password)
    {
        var statusCode = await ClientManager.Instance.AuthService.CreateUser(new()
        {
            Email = email,
            Password = password
        });
        if (!statusCode.IsSuccess())
            return statusCode;
        return await ClientManager.Instance.UserService.Load();
    }

    public void GotoGame()
    {
        GameLogic.LoadStage(ClientManager.Instance.UserService.User.CurrentStage.Index);
    }
}
public class LoginUI : MonoBehaviour
{
    [SerializeField]
    GameObject loginContainer;
    [SerializeField]
    TMP_InputField emailField;
    [SerializeField]
    TMP_InputField passwordField;
    [SerializeField]
    Button loginButton;
    [SerializeField]
    Button registerButton;

    public static LoginUI Instance
    {
        get;
        private set;
    }

    void Awake()
    {
        Instance = this;
        SetLoginContainerVisible(false);
    }

    void Start()
    {
        emailField.onValueChanged.AddListener(text => UpdateLoginButtonInteractable());
        passwordField.onValueChanged.AddListener(text => UpdateLoginButtonInteractable());
        loginButton.onClick.AddListener(async () =>
        {
            SetLoginInteractable(false);
            var statusCode = await LoginLogic.Instance.Login(
                emailField.text,
                passwordField.text
            );
            if (statusCode.IsSuccess())
            {
                LoginLogic.Instance.GotoGame();
                return;
            }
            SetLoginInteractable(true);
        });
        registerButton.onClick.AddListener(async () =>
        {
            SetLoginInteractable(false);
            var statusCode = await LoginLogic.Instance.Register(
                emailField.text,
                passwordField.text
            );
            if (statusCode.IsSuccess())
            {
                LoginLogic.Instance.GotoGame();
                return;
            }
            SetLoginInteractable(true);
        });
    }

    public void SetLoginContainerVisible(bool visible)
    {
        loginContainer.SetActive(visible);
        UpdateLoginButtonInteractable();
    }

    void UpdateLoginButtonInteractable()
    {
        var interactable =
                !string.IsNullOrEmpty(emailField.text) &&
                !string.IsNullOrEmpty(passwordField.text);
        loginButton.interactable = interactable;
        registerButton.interactable = interactable;
    }

    void SetLoginInteractable(bool interactable)
    {
        emailField.interactable = interactable;
        passwordField.interactable = interactable;
        loginButton.interactable = interactable;
        registerButton.interactable = interactable;
    }
}

 

게임 플레이전 메인 UI 구현

 

GameLogic를 수정하고, GameUI를 추가하여 Play 버튼을 누르지 않으면 게임을 시작할 수 없도록 수정하였습니다.
Start 메소드에서 플레이어 자동차를 생성하는 부분도 함수로 따로 분리하여 클래스에서 호출할 수 있도록 수정하였습니다.

void Start()
{
    GameUI.CreateInstance();

    npcCarManager.Play();

    foreach (var trigger in customerTriggers)
    {
        trigger.OnPlayerEntered += (sender, args) =>
        {
            OnCarEnterTrigger(sender as CustomerTrigger);
        };
    }

    RespawnPlayerCar();
}

public void PlayGame()
{
    PlayerCar.PlayMoving();
}

public void RespawnPlayerCar()
{
    if (PlayerCar != null)
    {
        Destroy(PlayerCar.gameObject);
        PlayerCar = null;
    }

    var carPrefab = ClientManager.Instance.UserService.User.CurrentCar.Prefab;
    if (carPrefab == null)
    {
        Debug.LogError("Player car prefab is null.");
        return;
    }

    PlayerCar = Instantiate(carPrefab)?.GetComponent<PlayerCar>();
    PlayerCar.SetPath(playerPath.path);
    PlayerCar.OnCrashed += (sender, args) =>
    {
        EndGame(false);
    };
    PlayerCar.OnArrive += (sender, args) =>
    {
        EndGame(true);
    };
}
public class GameUI : MonoBehaviour
{
    [SerializeField]
    ReadyViewUI readyView;
    [SerializeField]
    PlayViewUI playView;
    [SerializeField]
    CarListViewUI carListView;
    [SerializeField]
    GameObject eventSystem;


    public static GameUI Instance
    {
        get;
        private set;
    }

    void Awake()
    {
        Instance = this;
        ShowReadyView();
    }

    public void ShowReadyView()
    {
        readyView.gameObject.SetActive(true);
        playView.gameObject.SetActive(false);
        carListView.gameObject.SetActive(false);
        eventSystem.SetActive(true);
    }

    public void ShowPlayView()
    {
        readyView.gameObject.SetActive(false);
        playView.gameObject.SetActive(true);
        carListView.gameObject.SetActive(false);
    }

    public void ShowCarList()
    {
        carListView.gameObject.SetActive(true);
    }

    public void HideAll()
    {
        readyView.gameObject.SetActive(false);
        playView.gameObject.SetActive(false);
        carListView.gameObject.SetActive(false);
        eventSystem.SetActive(false);
    }

    public static void CreateInstance()
    {
        if (Instance != null)
            return;
        Instantiate(Resources.Load(nameof(GameUI)));
    }
}

로그인 UI는 Login 씬에 배치하였지만, 게임 UI는 프리팹을 따로 만들어 최초로 게임씬을 불러왔을 때 리소스 폴더에서 불러오도록 구현하였습니다.

 

자동차 목록 UI 구현

자동차를 구매하고, 선택하는 기능을 구현하기 위해 자동차 목록 UI를 구현하였습니다.
CarListViewUI를 추가하여, 자동차를 구매하고, 선택하는 기능을 구현하였습니다.

public class CarListViewUI : MonoBehaviour
{
    [SerializeField]
    TMP_Text coinText;
    [SerializeField]
    CarEntryViewUI[] carEntries;
    [SerializeField]
    TMP_Text pageText;
    [SerializeField]
    Button leftPageButton;
    [SerializeField]
    Button rightPageButton;
    [SerializeField]
    TMP_Text costText;
    [SerializeField]
    Button buyButton;
    [SerializeField]
    Button selectButton;
    [SerializeField]
    Button cancelButton;

    bool wasStarted;
    CarTemplate selectedCar;
    int pageIndex;
    int pageCount;

    void Start()
    {
        var cliMgr = ClientManager.Instance;

        var templateCount = cliMgr.TemplateService.CarTemplates.Count;
        pageCount = templateCount / carEntries.Length;
        if (templateCount % carEntries.Length != 0)
            ++pageCount;

        foreach (var entry in carEntries)
        {
            entry.ClickEvent += (sender, e) =>
            {
                var entry = sender as CarEntryViewUI;
                entry.IsSelect = true;
                selectedCar = entry.Template;
                var other = carEntries
                    .Where(x => x != entry && x.IsSelect)
                    .FirstOrDefault();
                if (other != null)
                    other.IsSelect = false;
                RefreshBuyOrSelect();
            };
        }

        leftPageButton.onClick.AddListener(() =>
        {
            if (pageIndex <= 0)
                return;
            --pageIndex;
            RefreshEntries();
        });
        rightPageButton.onClick.AddListener(() =>
        {
            if (pageIndex >= carEntries.Length - 1)
                return;
            ++pageIndex;
            RefreshEntries();
        });

        buyButton.onClick.AddListener(() =>
        {
            _ = ClientManager.Instance.UserService.BuyCar(selectedCar.Id);
            Refresh();
        });
        selectButton.onClick.AddListener(() =>
        {
            if (selectedCar != cliMgr.UserService.User.CurrentCar)
                cliMgr.UserService.SelectCar(selectedCar.Id);
            GameLogic.Instance.RespawnPlayerCar();
            gameObject.SetActive(false);
        });
        cancelButton.onClick.AddListener(() =>
        {
            gameObject.SetActive(false);
        });

        wasStarted = true;
        Refresh();
    }

    void OnEnable()
    {
        pageIndex = 0;
        selectedCar = ClientManager.Instance.UserService.User.CurrentCar;
        if (wasStarted)
            Refresh();
    }

    void Refresh()
    {
        coinText.text = ClientManager.Instance.UserService.User.Coin.ToString();
        RefreshEntries();
        RefreshBuyOrSelect();
    }

    void RefreshEntries()
    {
        var cliMgr = ClientManager.Instance;
        pageText.text = $"{pageIndex + 1}/{pageCount}";

        leftPageButton.interactable = pageIndex > 0;
        rightPageButton.interactable = pageIndex < pageCount - 1;

        int tempIndex = pageIndex * carEntries.Length;
        for (int i = 0; i < carEntries.Length; i++)
        {
            if (tempIndex >= cliMgr.TemplateService.CarTemplates.Count)
            {
                carEntries[i].IsSelect = false;
                carEntries[i].gameObject.SetActive(false);
                continue;
            }
            var template = cliMgr.TemplateService.CarTemplates[tempIndex];
            carEntries[i].Template = template;
            carEntries[i].IsSelect = selectedCar == template;
            carEntries[i].gameObject.SetActive(true);
            ++tempIndex;
        }
    }

    void RefreshBuyOrSelect()
    {
        var user = ClientManager.Instance.UserService.User;
        var hasCar = user.Cars.Contains(selectedCar);
        if (hasCar)
        {
            selectButton.gameObject.SetActive(true);
            selectButton.interactable = selectedCar != user.CurrentCar;
            buyButton.gameObject.SetActive(false);
        }
        else
        {
            var enoughCoin = user.Coin >= selectedCar.Cost;
            selectButton.gameObject.SetActive(false);
            buyButton.gameObject.SetActive(true);
            buyButton.interactable = enoughCoin;
            costText.text = selectedCar.Cost.ToString();
            costText.color = enoughCoin ? Color.white : Color.red;
        }   
    }
}

 

그리고 자동차 목록창에서 각각의 자동차 아이콘을 출력하기 위해 CarEntryViewUI를 구현하였습니다.

public class CarEntryViewUI : MonoBehaviour, IPointerClickHandler
{
    [SerializeField]
    Image iconImage;
    [SerializeField]
    GameObject selected;

    CarTemplate template;
    bool isSelected;

    public CarTemplate Template
    {
        get => template;
        set
        {
            template = value;
            if (enabled)
                Refresh();
        }
    }

    public bool IsSelect
    {
        get => isSelected;
        set
        {
            isSelected = value;
            selected.SetActive(isSelected);
        }
    }

    public bool HasCar
    {
        get
        {
            if (template == null)
                return false;
            return ClientManager.Instance
                .UserService
                .User
                .Cars
                .Contains(template);
        }
    }


    public event EventHandler ClickEvent;

    void OnEnable()
    {
        Refresh();
    }

    void Refresh()
    {
        if (template == null)
        {
            iconImage.gameObject.SetActive(false);
            return;
        }
        iconImage.gameObject.SetActive(true);
        iconImage.sprite = template.Icon;
        iconImage.color = HasCar ? Color.white : new Color(0.4f, 0.4f, 0.4f, 0.8f);
        selected.SetActive(isSelected);
    }

    public void OnPointerClick(PointerEventData eventData)
    {
        ClickEvent?.Invoke(this, EventArgs.Empty);
    }
}

 

 

지금 프로젝트를 시작한지 1개월이 넘어가니 질리기 시작하여, "다음 프로젝트는 이걸로 해볼까?", "다음 프로젝트는 저걸로 해볼까?"라는 생각에 멈추지 않아 딴짓을 많이 했습니다.
그래서 생각보다 시간이 걸렸습니다.


다음 작업은 1월 1일까지만 딴짓 좀 하다가 실제 게임 플레이를 할 때 출력되는 UI를 구현하겠습니다.

 

구현 결과

 

깃 허브 저장소 : taxi-game-3d-unity

728x90
LIST
728x90
SMALL

서버와 클라이언트 통신을 위해 구현했던 ClientManager 게임에 적용하는 작업을 하였습니다.

 

TemplateManager 제거

TemplateService를 구현하면서 필요 없어진 TemplateManager와 리소스 폴더에 저장된 템플릿을 제거하였습니다.

 

로그인 임시 구현

Login 씬을 추가하여 이전에 만들었던 맵들과 함께 Build Settings에 등록하였습니다.

 

LoginLogic 스크립트를 추가하여 Login 씬에 추가하였습니다.
UI작업들은 나중에 할 계획이기 때문에 로그인 작업은 이걸로 마무리하고, 다음 작업을 시작하였습니다.

public class LoginLogic : MonoBehaviour
{
    IEnumerator Start()
    {
        ClientManager.CreateInstance();
        yield return new WaitForEndOfFrame();
        Loading();
    }

    async void Loading()
    {
        var cliMgr = ClientManager.Instance;

        if (!await cliMgr.TemplateService.LoadAll())
        {
            Debug.LogError("Load templates failed.");
            return;
        }

        var email = "test@test.com";
        var password = "12345678";

        var statusCode = await cliMgr.AuthService.Relogin();
        if (!statusCode.IsSuccess())
        {
            statusCode = await cliMgr.AuthService.Login(new()
            {
                Email = email,
                Password = password
            });
        }
        if (!statusCode.IsSuccess())
        {
            statusCode = await cliMgr.AuthService.CreateUser(new()
            {
                Email = email,
                Password = password
            });
        }
        if (!statusCode.IsSuccess())
        {
            Debug.LogError("Login and CreateUser failed.");
            return;
        }

        statusCode = await cliMgr.UserService.Load();
        if (!statusCode.IsSuccess())
        {
            Debug.LogError("Load user failed.");
            return;
        }

        GameLogic.LoadStage(cliMgr.UserService.User.CurrentStage.Index);
    }
}

 

게임 임시 구현

 

GameLogic 스크립트에서 스테이지 씬을 불러오면서 불러온 스테이지의 인덱스 번호도 저장할 수 있도록 LoadStage 메소드를 추가하였습니다.

public static void LoadStage(int index)
{
    stageIndex = index;
    var template = ClientManager.Instance.TemplateService.StageTemplates[index];
    SceneManager.LoadScene(template.SceneName);
}

 

GameLogic 스크립트에서 서버에서 받은 유저정보를 이용하여 자동차를 생성하도록 수정하였습니다.

IEnumerator Start()
{
    npcCarManager.Play();

    foreach (var trigger in customerTriggers)
    {
        trigger.OnPlayerEntered += (sender, args) =>
        {
            OnCarEnterTrigger(sender as CustomerTrigger);
        };
    }

    var carPrefab = ClientManager.Instance.UserService.User.CurrentCar.Prefab;
    if (carPrefab == null)
    {
        Debug.LogError("Player car prefab is null.");
        yield break;
    }

    PlayerCar = Instantiate(carPrefab)?.GetComponent<PlayerCar>();
    PlayerCar.SetPath(playerPath.path);
    PlayerCar.OnCrashed += (sender, args) =>
    {
        StartCoroutine(EndGame(false));
    };
    PlayerCar.OnArrive += (sender, args) =>
    {
        StartCoroutine(EndGame(true));
    };
    yield return new WaitForSeconds(1);
    PlayerCar.PlayMoving();
}

 

GameLogic 스크립트에서 토큰이 만료되었거나 서버와의 통신을 실패하였을 경우 Login 씬으로 다시 이동하도록 처리하였습니다.

void OnEnable()
{
    if (inputControls == null)
        inputControls = new();
    inputControls.Player.SetCallbacks(this);
    inputControls.Player.Enable();

    ClientManager.Instance.AuthService.TokenExpired += OnTokenExpired;
    ClientManager.Instance.UserService.UserUpdateFailed += OnUserUpdateFailed;
}

void OnDisable()
{
    inputControls?.Player.Disable();

    ClientManager.Instance.AuthService.TokenExpired -= OnTokenExpired;
    ClientManager.Instance.UserService.UserUpdateFailed -= OnUserUpdateFailed;
}

void OnTokenExpired(object sender, EventArgs args)
{
    Debug.LogError("Token was expired.");
    SceneManager.LoadScene(0);
}

void OnUserUpdateFailed(object sender, EventArgs args)
{
    Debug.LogError("User updating was failed.");
    SceneManager.LoadScene(0);
}

 

게임을 완료하면 보상을 획득하고, 다음 스테이지로 이동하도록 수정하였습니다.

async void EndGame(bool isGoal)
{
    PlayerCar.StopMoving();
    npcCarManager.Stop();

    var dt = DateTime.Now;

    if (isGoal)
        await ClientManager.Instance.UserService.EndStage(stageIndex, coin);

    var ts = DateTime.Now - dt;
    if (ts.TotalSeconds < 3)
        await UniTask.WaitForSeconds(3.0f - (float)ts.TotalSeconds, true);

    LoadStage(ClientManager.Instance.UserService.User.CurrentStage.Index);
}

 

이전에 자동차 프리팹을 만들었을 때 카메라를 추가 하지 않아 카메라를 추가하는 작업을 하였습니다.
모든 맵에 카메라를 추가하고, 각각의 자동차 프리팹은 Cinemachine에서 제공하는 가상 카메라를 추가하여 카메라가 플레이어를 따라다니도록 구현하였습니다.

 

버그 수정

위 작업들을 모두 끝내고 첫 테스트하였을 때 계속 버그가 발생하여 버그 수정작업을 하였습니다.

 

클라이언트

HttpContext 에서 발생하는 null 참조 에러를 수정하였습니다.

public Dictionary<string, string> Headers
{
    get;
    private set;
} = new();

 

UnityWebRequest 클래스의 Put 메소드를 사용할 때 헤더정보를 설정하지 않으면 JSON으로 데이터를 주고 받지 않기 때문에 application/json 추가하도록 수정하였습니다.

public async UniTask<HttpStatusCode> Put(string subUri, object data)
{
    using (var req = UnityWebRequest.Put($"{BaseUri}{subUri}", ToJsonSafety(data)))
    {
        foreach (var h in Headers)
            req.SetRequestHeader(h.Key, h.Value);
        req.SetRequestHeader("Content-Type", "application/json");
        await req.SendWebRequest();
        return (HttpStatusCode)req.responseCode;
    }
}

public async UniTask<(HttpStatusCode, T)> Put<T>(string subUri, object data)
{
    using (var req = UnityWebRequest.Put($"{BaseUri}{subUri}", ToJsonSafety(data)))
    {
        foreach (var h in Headers)
            req.SetRequestHeader(h.Key, h.Value);
        req.SetRequestHeader("Content-Type", "application/json");
        await req.SendWebRequest();
        return (
            (HttpStatusCode)req.responseCode,
            FromJsonSafety<T>(req.downloadHandler.text)
        );
    }
}

 

버그 수정 후 새로 테스트할 수 있도록 TemplateService에 저장된 버전을 리셋하는 메소드를 추가하였습니다.
추가한 메소드는 ClientManager에서 호출하도록 구현하였습니다.

public static void ResetTemplateVersions(string enviroment)
{
    PlayerPrefs.DeleteKey($"{enviroment}/TemplateVersions/Car");
    PlayerPrefs.DeleteKey($"{enviroment}/TemplateVersions/Stage");
}

 

TemplateService에 템플릿을 로컬에 처음 저장할 때, File.WriteAllText 에서 이미 열린파일에 접근하고 있다는 내용의 에러가 발생하여 수정하였습니다.

void SaveToLocal(string name, object content)
{
    try
    {
        var directory = $"{Application.persistentDataPath}/{enviroment}/Templates";
        if (!Directory.Exists(directory))
            Directory.CreateDirectory(directory);

        var path = $"{directory}/{name}";
        if (!File.Exists(path))
            File.Create(path).Close();

        File.WriteAllText(path, JsonConvert.SerializeObject(content));
    }
    catch (Exception ex)
    {
        Debug.LogException(ex);
    }
}

 

UserService 클래스의 EndStage 메소드는 다음 스테이지로 바꿔주는 과정을 작성하지 않아 작성하였습니다.

public async UniTask<HttpStatusCode> EndStage(int stageIndex, long coin)
{
    var currStageIndex = User.CurrentStage.Index;
    var stageCount = templateService.StageTemplates.Count;

    if (stageIndex < 0 || stageIndex >= stageCount)
    {
        Debug.LogError($"End stage failed. Because {stageIndex} is out of range.");
        return HttpStatusCode.BadRequest;
    }


    if (stageIndex > currStageIndex)
    {
        Debug.LogWarning($"End stage({stageIndex}) failed. Because {User.CurrentStage.Index} was not clear.");
        return HttpStatusCode.Forbidden;
    }

    if (coin >= templateService.StageTemplates[stageIndex].MaxCoin)
    {
        Debug.LogError($"End stage failed. Because {coin} is too much.");
        return HttpStatusCode.Forbidden;
    }

    if (stageIndex == currStageIndex && stageIndex < stageCount - 1)
        User.CurrentStage = templateService.StageTemplates[stageIndex + 1];
    User.Coin += coin;

    var res = await http.Put($"User/EndStage", new EndStageRequest
    {
        StageIndex = stageIndex,
        Coin = coin
    });
    if (!res.IsSuccess())
    {
        Debug.LogWarning($"End stage failed. - {res}");
        UserUpdateFailed?.Invoke(this, EventArgs.Empty);
    }
    return res;
}

 

서버

 

TemplateService에서 실수로 BsonSerializer를 이용하여 JSON 데이터를 클래스로 변환하도록 구현했기 때문에 JsonSerializer로 수정하였습니다.

public async Task<List<CarTemplate>?> GetCars()
{
    if (cars == null)
    {
        var model = await templates.Find(e => e.Name == "Car").FirstOrDefaultAsync();
        cars = JsonSerializer.Deserialize<List<CarTemplate>>(model.Datas.ToJson());
    }
    return cars;
}

public async Task<List<StageTemplate>?> GetStages()
{
    if (stages == null)
    {
        var model = await templates.Find(e => e.Name == "Stage").FirstOrDefaultAsync();
        stages = JsonSerializer.Deserialize<List<StageTemplate>>(model.Datas.ToJson());
    }
    return stages;
}

 

UserController에서 처음 시작하는 유저에게 자동차를 지급한 후 유저정보를 DB에 저장하지 않아 수정하였습니다.

[HttpGet]
[ProducesResponseType<UserResponse>(200)]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public async Task<ActionResult> Get()
{
    var userId = ClaimHelper.FindNameIdentifier(User);
    if (string.IsNullOrEmpty(userId))
        return Unauthorized();

    var user = await userRepository.Get(userId);
    if (user == null)
        return Forbid();

    // 자동차 지급
    if (user.Cars == null || user.Cars.Count == 0)
    {
        var cars = await templateService.GetCars();
        var newCar = cars?.FirstOrDefault(e => e.Cost == 0);
        if (newCar != null)
        {
            user.CurrentCarId = newCar?.Id;
            user.Cars = [ user.CurrentCarId! ];
            _ = userRepository.Update(userId, user);
        }
    }

    return Ok(new UserResponse
    {
        Nickname = user.Nickname,
        Coin = user.Coin,
        Cars = user.Cars,
        CurrentCar = user.CurrentCarId,
        CurrentStage = user.CurrentStageIndex
    });
}

 

UserController 클래스의 EndStage 메소드는 다음 스테이지로 바꿔주는 과정을 작성하지 않아 작성하였습니다.

[HttpPut("EndStage")]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public async Task<ActionResult> EndStage([FromBody] EndStageRequest body)
{
    var userId = ClaimHelper.FindNameIdentifier(User);
    if (string.IsNullOrEmpty(userId))
        return Unauthorized();

    var user = await userRepository.Get(userId);
    if (user == null)
        return Forbid();

    var stageTemps = await templateService.GetStages();
    var stageCount = stageTemps?.Count ?? 0;
    var stageIndex = body.StageIndex;
    // 템플릿에 등록되지 않은 스테이지
    if (stageIndex < 0 || body.StageIndex >= stageCount)
        return BadRequest();

    // 현재 스테이지도 아직 클리어하지 않았을 경우
    if (stageIndex > user.CurrentStageIndex)
        return Forbid();

    // 클라이언트에서 계산한 요금이 서버에서 계산한 요금보다 더 큰 경우
    var maxCoin = stageTemps?[stageIndex]?.MaxCoin ?? 0;
    if (body.Coin >= maxCoin)
        return Forbid();

    // 다음 스테이지 개방
    if (stageIndex == user.CurrentStageIndex && stageIndex < stageCount + 1)
        user.CurrentStageIndex = stageIndex + 1;
    user.Coin += body.Coin;
    await userRepository.Update(user.Id!, user);

    return NoContent();
}

 

템플릿

 

xlsx 파일에 입력한 숫자들 중 일부는 JSON으로 변환할 문자열로 변환되는 현상이 발생하여 반드시 숫자로 변환해야 되는 데이터는 float 함수와 int 함수로 강제 형변환을 하도록 수정하였습니다.

def generate_car(file_path, sheet_name):
    book = load_workbook(file_path, data_only=True)
    sheet = book[sheet_name]
    skip = 0
    temp_group = []
    for row in sheet.rows:
        # 세번째 줄부터 데이터 저장
        if skip < 2:
            skip += 1
            continue      
        # ID컬럼이 비어 있으면 추가하지 않음
        if row[0].value == None:
            continue
        new_temp = {
            'Id': row[0].value,
            'Name': {
                'Table': row[1].value,
                'Key': row[2].value
            },
            'Icon': row[3].value,
            'Prefab': row[4].value,
            'Cost': int(row[5].value)
        }
        temp_group.append(new_temp)
    return temp_group



def generate_stage(file_path, sheet_name):
    book = load_workbook(file_path, data_only=True)
    sheet = book[sheet_name]
    skip = 0
    temp_group = []
    for row in sheet.rows:
        # 두번째 줄부터 데이터 저장
        if skip < 1:
            skip += 1
            continue  
        # ID컬럼이 비어 있으면 추가하지 않음
        if row[0].value == None:
            continue
        new_temp = {
            'Id': row[0].value,
            'Scene': row[1].value,
            'Distance': float(row[2].value),
            'FareRate': float(row[3].value)
        }
        temp_group.append(new_temp)
    return temp_group

 

다음에는 UI를 추가하여 직접 이메일을 입력하여 로그인을 하거나 새로운 자동차를 구매하고 선택하는 기능들을 구현하도록 하겠습니다.

 

깃 허브 저장소 : taxi-game-3d-unity

728x90
LIST
728x90
SMALL

서버와 통신은 유니티에서 제공하는 UnityWebRequestUniTask를 사용해보기로 하였습니다.
PackageManager를 이용하여 UniTask를 추가하였습니다.

 

UniTaskUnityWebRequest를 이용하여 HttpContext 제작하였습니다.

public class HttpContext
{
    public string BaseUri
    {
        get;
        private set;
    }

    public Dictionary<string, string> Headers
    {
        get;
        private set;
    }

    public HttpContext(string baseUri)
    {
        if (string.IsNullOrWhiteSpace(baseUri))
            throw new InvalidOperationException("Invalid base address.");

        if (baseUri.Last() == '/')
            BaseUri = baseUri;
        else
            BaseUri = $"{baseUri}/";
    }

    public async UniTask<HttpStatusCode> Get(string subUri)
    {
        using (var req = UnityWebRequest.Get($"{BaseUri}{subUri}"))
        {
            foreach (var h in Headers)
                req.SetRequestHeader(h.Key, h.Value);
            await req.SendWebRequest();
            return (HttpStatusCode)req.responseCode;
        }
    }

    public async UniTask<(HttpStatusCode, T)> Get<T>(string subUri)
    {
        using (var req = UnityWebRequest.Get($"{BaseUri}{subUri}"))
        {
            foreach (var h in Headers)
                req.SetRequestHeader(h.Key, h.Value);
            await req.SendWebRequest();
            return (
                (HttpStatusCode)req.responseCode,
                FromJsonSafety<T>(req.downloadHandler.text)
            );
        }
    }

    public async UniTask<HttpStatusCode> Post(string subUri, object data)
    {
        using (var req = UnityWebRequest.Post($"{BaseUri}{subUri}", ToJsonSafety(data), "application/json"))
        {
            foreach (var h in Headers)
                req.SetRequestHeader(h.Key, h.Value);
            await req.SendWebRequest();
            return (HttpStatusCode)req.responseCode;
        }
    }

    public async UniTask<(HttpStatusCode, T)> Post<T>(string subUri, object data)
    {
        using (var req = UnityWebRequest.Post($"{BaseUri}{subUri}", ToJsonSafety(data), "application/json"))
        {
            foreach (var h in Headers)
                req.SetRequestHeader(h.Key, h.Value);
            await req.SendWebRequest();
            return (
                (HttpStatusCode)req.responseCode,
                FromJsonSafety<T>(req.downloadHandler.text)
            );
        }
    }

    public async UniTask<HttpStatusCode> Put(string subUri, object data)
    {
        using (var req = UnityWebRequest.Put($"{BaseUri}{subUri}", ToJsonSafety(data)))
        {
            foreach (var h in Headers)
                req.SetRequestHeader(h.Key, h.Value);
            await req.SendWebRequest();
            return (HttpStatusCode)req.responseCode;
        }
    }

    public async UniTask<(HttpStatusCode, T)> Put<T>(string subUri, object data)
    {
        using (var req = UnityWebRequest.Put($"{BaseUri}{subUri}", ToJsonSafety(data)))
        {
            foreach (var h in Headers)
                req.SetRequestHeader(h.Key, h.Value);
            await req.SendWebRequest();
            return (
                (HttpStatusCode)req.responseCode,
                FromJsonSafety<T>(req.downloadHandler.text)
            );
        }
    }

    public async UniTask<HttpStatusCode> Delete(string subUri)
    {
        using (var req = UnityWebRequest.Delete($"{BaseUri}{subUri}"))
        {
            foreach (var h in Headers)
                req.SetRequestHeader(h.Key, h.Value);
            await req.SendWebRequest();
            return (HttpStatusCode)req.responseCode;
        }
    }

    public async UniTask<(HttpStatusCode, T)> Delete<T>(string subUri)
    {
        using (var req = UnityWebRequest.Delete($"{BaseUri}{subUri}"))
        {
            foreach (var h in Headers)
                req.SetRequestHeader(h.Key, h.Value);
            await req.SendWebRequest();
            return (
                (HttpStatusCode)req.responseCode,
                FromJsonSafety<T>(req.downloadHandler.text)
            );
        }
    }

    string ToJsonSafety(object data)
    {
        try
        {
            return JsonConvert.SerializeObject(data);
        }
        catch
        {
            return null;
        }
    }

    T FromJsonSafety<T>(string json)
    {
        try
        {
            return JsonConvert.DeserializeObject<T>(
                json,
                new JsonSerializerSettings
                {
                    NullValueHandling = NullValueHandling.Ignore,
                    MissingMemberHandling = MissingMemberHandling.Ignore
                }
            );
        }
        catch
        {
            return default;
        }
    }
}

 

HttpContext는 아래 서술한 작업을 위해 구현하였습니다.

  • 기본주소를 메모리에 저장하여 다른 클래스에서 호출할 때 서버주소를 모두 입력하지 않아도 된다.
  • Http 헤더 정보를 저장하여 통신을 할 때마다 UnityWebRequest 를 생성할 때마다 헤더정보를 포함할 수 있도록 처리한다.
    • ASP.NET Core는 발급된 JWT 토큰을 Http 헤더를 포함하였을 때, 로그인한 것으로 인식한다.

 

ClientManager를 추가하여 HttpContext 외에 앞으로 추가할 클래스들도 관리할 수 있도록 구현하였습니다.
ClientManager에는 서버주소 외에 Enviroment 정보를 입력하여 다른 클래스에서 개발서버와 통신하는지 출시버전의 서버와 통신하는지 구분하여 처리할 수 있도록 만들었습니다.

public class ClientManager : MonoBehaviour
{
    [SerializeField]
    string enviroment = "Development";
    [SerializeField]
    string serverUri;

    public static ClientManager Instance
    {
        get;
        private set;
    }

    public string Enviroment => enviroment;

    public HttpContext Http
    {
        get;
        private set;
    }

    public AuthService AuthService
    {
        get;
        private set;
    }

    public UserService UserService
    {
        get;
        private set;
    }

    public TemplateService TemplateService
    {
        get;
        private set;
    }


    void Awake()
    {
        Instance = this;
        DontDestroyOnLoad(gameObject);

        Http = new(serverUri);

        AuthService = gameObject.AddComponent<AuthService>();
        UserService = gameObject.AddComponent<UserService>();
        TemplateService = gameObject.AddComponent<TemplateService>();
    }

    public static void CreateInstance()
    {
        if (Instance != null)
            return;
        Instantiate(Resources.Load(nameof(ClientManager)));
    }
}

 

_TaxiGame/Resources 폴더에 ClientManager 프리팹을 만들어 추가하였습니다.
ClientManager 프리팹은 게임을 처음 실행하였을 때 리소스에서 불러오도록 만들 계획입니다.

 

AuthService 클래스를 추가하여 로그인, 로그아웃, 회원가입, JWT 토큰 관리를 하는 기능을 만들었습니다.

public class AuthService : MonoBehaviour
{
    const string AuthHeaderKey = "Authorization";
    const string AuthTypeKey = "AuthType";
    const string AuthIdKey = "AuthId";
    const string AuthPasswordKey = "AuthPassword";

    HttpContext http;
    UserService userService;
    DateTime tokenExpireUtc;

    public bool WasLoggedIn => http.Headers.ContainsKey(AuthHeaderKey);

    public event EventHandler TokenExpired;


    void Start()
    {
        http = ClientManager.Instance?.Http;
        userService = ClientManager.Instance?.UserService;
    }

    void OnApplicationPause(bool pause)
    {
        if (!pause)
            return;

        if (!WasLoggedIn)
            return;

        if (tokenExpireUtc >= DateTime.UtcNow)
        {
            http.Headers.Remove(AuthHeaderKey);
            TokenExpired?.Invoke(this, EventArgs.Empty);
            return;
        }

        StartCoroutine(AutoRefreshToken());
    }

    public async UniTask<HttpStatusCode> Relogin()
    {
        var type = (AuthType)PlayerPrefs.GetInt(AuthTypeKey);
        if (type == AuthType.Email)
        {
            return await Login(new LoginWithEmailRequest
            {
                Email = PlayerPrefs.GetString(AuthIdKey),
                Password = PlayerPrefs.GetString(AuthPasswordKey)
            });
        }
        return HttpStatusCode.Unauthorized;
    }

    public async UniTask<HttpStatusCode> Login(LoginWithEmailRequest request)
    {
        var res = await http.Post<LoginResponse>("Auth/LoginEmail", request);
        if (!res.Item1.IsSuccess())
        {
            Debug.LogWarning($"Login with email failed. - {res.Item1}");
            http.Headers.Remove(AuthHeaderKey);
            return res.Item1;
        }
        http.Headers[AuthHeaderKey] = res.Item2.BearerToken;
        SaveAuth(AuthType.Email, request.Email, request.Password);
        StartCoroutine(AutoRefreshToken());
        return res.Item1;
    }

    public async UniTask<HttpStatusCode> CreateUser(LoginWithEmailRequest request)
    {
        var res = await http.Post<LoginResponse>("Auth/CreateEmail", request);
        if (!res.Item1.IsSuccess())
        {
            Debug.LogWarning($"Ceate with email failed. - {res.Item1}");
            http.Headers.Remove(AuthHeaderKey);
            return res.Item1;
        }
        http.Headers[AuthHeaderKey] = res.Item2.BearerToken;
        SaveAuth(AuthType.Email, request.Email, request.Password);
        StartCoroutine(AutoRefreshToken());
        return res.Item1;
    }

    public async void RefreshToken()
    {
        var res = await http.Get<LoginResponse>("Auth/RefreshToken");
        if (!res.Item1.IsSuccess())
        {
            Debug.LogWarning($"Refresh token failed. - {res.Item1}");
            http.Headers.Remove(AuthHeaderKey);
            TokenExpired?.Invoke(this, EventArgs.Empty);
            return;
        }

        http.Headers[AuthHeaderKey] = res.Item2.BearerToken;
        tokenExpireUtc = res.Item2.ExpireUtc;

        StartCoroutine(AutoRefreshToken());
    }

    public void Logout()
    {
        http.Headers.Remove(AuthHeaderKey);
        userService.Clear();
        PlayerPrefs.DeleteKey(AuthTypeKey);
        PlayerPrefs.DeleteKey(AuthIdKey);
        PlayerPrefs.DeleteKey(AuthPasswordKey);
    }

    void SaveAuth(AuthType type, string id, string pw)
    {
        PlayerPrefs.SetInt(AuthTypeKey, (int)type);
        PlayerPrefs.SetString(AuthIdKey, id);
        if (type == AuthType.Email)
            PlayerPrefs.SetString(AuthPasswordKey, pw);
    }

    IEnumerator AutoRefreshToken()
    {
        // 토큰이 만료되기 1분전에 토큰을 갱신하도록 처리
        var ts = tokenExpireUtc - DateTime.UtcNow.AddMinutes(1);
        yield return new WaitForSecondsRealtime((float)ts.TotalSeconds);
        RefreshToken();   
    }
}

 

AuthService 는 아래 서술한 기능을 구현하였습니다.

  • 이메일과 비밀번호를 이용하여 로그인과 회원가입 구현
  • 로그인을 한 번 완료하면 이메일과 비밀번호를 PlayerPrebs에 저장하여 자동로그인을 할 수 있도록 구현
    • 이 방법은 보안에 좋지 않기 때문에 언젠가 암호화도 적용해 보도록 하겠습니다.
  • 서버에서 발급 받은 JWT 토큰은 Http헤더에 저장
  • JWT 토큰을 발급 받으면 토큰이 만료되기 1분 전까지 코루틴을 이용하여 대기한 후 서버에게 토큰 갱신을 요청하도록 처리
  • 토큰 갱신을 실패했거나 홈 버튼을 누르고 나간 후 토큰이 만료되었을 때 앱으로 다시 돌아왔을 때 TokenExpired 이벤트를 전달하도록 처리
    • 게임 로직에서 해당 이벤트를 받으면 로그인 혹은 타이틀 씬으로 되돌아가도록 처리할 계획
  • 서버는 토큰을 DB에 저장하지 않고, 검사만 하기 때문에 로그아웃은 클라이언의 메모리에 저장된 토큰과 PlayerPrebs에 저장된 이메일과 비밀번호만 지우도록 처리

 

StageTemplate은 xlsx 파일에 새로 입력한 정보도 저장할 수 있도록 수정하였습니다.
StageTemplate, CarTemplate 모두 인덱스를 추가하여 다른 클래스에서 데이터 탐색을 편하게 할 수 있도록 만들었습니다.

public class StageTemplate
{
    [JsonIgnore]
    public int Index { get; set; }
    public string Id { get; set; }
    public double Distance { get; set; }
    public double FareRate { get; set; }
    [JsonProperty("Scene")]
    public string SceneName { get; set; }

    [JsonIgnore]
    public long MaxCoin => CalcCoin(Distance);

    public long CalcCoin(double diatance) => (long)(diatance * FareRate);
}

 

템플릿 관리를 위해 TemplateService 를 추가하였습니다.
이전에 구현했던 TemplateManager는 삭제할 계획입니다.

public class TemplateService : MonoBehaviour
{
    string enviroment;
    HttpContext http;

    public List<CarTemplate> CarTemplates
    {
        get;
        private set;
    }

    public List<StageTemplate> StageTemplates
    {
        get;
        private set;
    }

    void Start()
    {
        enviroment = ClientManager.Instance?.Enviroment;
        http = ClientManager.Instance?.Http;
    }

    public async UniTask<bool> LoadAll()
    {
        var res = await http.Get<Dictionary<string, ulong>>("Template/Versions");
        if (!res.Item1.IsSuccess())
        {
            Debug.LogWarning($"Load template versions failed. - {res.Item1}");
            return false;
        }

        if (!res.Item2.TryGetValue("Car", out var version))
            return false;
        CarTemplates = await Load<CarTemplate>("Car", version);
        if (CarTemplates == null)
            return false;
        for (int i = 0; i < CarTemplates.Count; i++)
            CarTemplates[i].Index = i;

        if (!res.Item2.TryGetValue("Stage", out version))
            return false;
        StageTemplates = await Load<StageTemplate>("Stage", version);
        if (StageTemplates == null)
            return false;
        for (int i = 0; i < StageTemplates.Count; i++)
            StageTemplates[i].Index = i;

        return true;
    }

    async UniTask<List<T>> Load<T>(string name, ulong remoteVersion)
    {
        var path = $"{enviroment}/TemplateVersions/{name}";
        var versionText = PlayerPrefs.GetString(path, "0");
        ulong.TryParse(versionText, out var localVersion);

        if (localVersion >= remoteVersion)
            return LoadFromLocal<T>(name);

        var res = await http.Get<List<T>>($"Template/{name}");
        if (!res.Item1.IsSuccess())
        {
            Debug.LogWarning($"Load {name} template failed. - {res.Item1}");
            return res.Item2;
        }
        SaveToLocal(name, res.Item2);
        PlayerPrefs.SetString(path, remoteVersion.ToString());

        return res.Item2;
    }

    List<T> LoadFromLocal<T>(string name)
    {
        try
        {
            var path = $"{Application.persistentDataPath}/{enviroment}/Templates/{name}";
            var content = File.ReadAllText(path);
            return JsonConvert.DeserializeObject<List<T>>(content);
        }
        catch (Exception ex)
        {
            Debug.LogException(ex);
            return default;
        }
    }

    void SaveToLocal(string name, object content)
    {
        try
        {
            var directory = $"{Application.persistentDataPath}/{enviroment}/Templates";
            if (!Directory.Exists(directory))
                Directory.CreateDirectory(directory);

            var path = $"{directory}/{name}";
            if (!File.Exists(path))
                File.Create(path);

            File.WriteAllText(path, JsonConvert.SerializeObject(content));
        }
        catch (Exception ex)
        {
            Debug.LogException(ex);
        }
    }
}

 

TemplateService는 아래 서술한 기능을 구현하였습니다.

  • 처음 게임을 실행하였을 때 서버에서 템플릿을 받은 후 버전정보와 함께 로컬에 저장한다.
  • 다음에 실행하였을 때는 서버에 등록된 템플릿의 버전과 로컬에 저장했던 버전을 비교하여 동일한 버전일 경우 로컬에서 템플릿을 바로 불러오고, 서버에서 불러온 버전이 더 높으면 서버에서 템플릿을 다시 받도록 처리
  • ClientManager에 입력된 Enviroment를 이용하여 저장되는 템플릿의 위치를 버전마다 달라지도록 처리
    • 개발서버 DB에 저장된 템플릿은 자주 데이터가 변하기 때문에 출시서버 DB에 저장된 템플릿과 버전이 달라질 수 밖에 없다.
    • 에디터는 개발서버, 출시서버를 모두 사용하여 테스트해야 될 경우가 있기 때문에 어떤 서버에서 받은 템플릿인지 구분할 수 있어야 된다.

 

유저정보를 관리하기 위해 UserServiceUserModel을 추가하였습니다.

public class UserModel
{
    public string Nickname { get; set; }
    public long Coin { get; set; }
    public List<CarTemplate> Cars { get; set; }
    public CarTemplate CurrentCar { get; set; }
    public StageTemplate CurrentStage { get; set; }
}
public class UserService : MonoBehaviour
{
    HttpContext http;
    TemplateService templateService;

    public event EventHandler UserUpdateFailed;

    public UserModel User
    {
        get;
        private set;
    }

    void Start()
    {
        http = ClientManager.Instance?.Http;
        templateService = ClientManager.Instance?.TemplateService;
    }

    public async UniTask<HttpStatusCode> Load()
    {
        var res = await http.Get<UserResponse>("User");
        if (!res.Item1.IsSuccess())
        {
            Debug.LogWarning($"Load user failed. - {res.Item1}");
            return res.Item1;
        }

        User = new()
        {
            Nickname = res.Item2.Nickname,
            Coin = res.Item2.Coin,
            Cars = res.Item2.Cars
                .Select(id => templateService.CarTemplates.Find(e => e.Id == id))
                .ToList(),
            CurrentCar = templateService.CarTemplates
                .Find(e => e.Id == res.Item2.CurrentCar),
            CurrentStage = templateService.StageTemplates[res.Item2.CurrentStage]
        };

        return res.Item1;
    }

    public async void SelectCar(string carId)
    {
        var car = User.Cars.Find(e => e.Id == carId);
        if (car == null)
        {
            Debug.LogError($"Select car failed. Because user has not {carId}.");
            return;
        }
        User.CurrentCar = car;

        var res = await http.Put($"User/SelectCar/{carId}", null);
        if (!res.IsSuccess())
        {
            Debug.LogWarning($"Select car failed. - {res}");
            UserUpdateFailed?.Invoke(this, EventArgs.Empty);
        }
    }

    public async UniTask<HttpStatusCode> BuyCar(string carId)
    {
        if (User.Cars.Any(e => e.Id == carId))
        {
            Debug.LogWarning($"Buy car failed. Because user already owned {carId}.");
            return HttpStatusCode.NoContent;
        }

        var car = templateService.CarTemplates.Find(e => e.Id == carId);
        if (car == null)
        {
            Debug.LogError($"Buy car failed. Because {carId} is not exist in templates.");
            return HttpStatusCode.NotFound;
        }

        if (User.Coin < car.Cost)
        {
            Debug.LogWarning($"Buy car failed. Because of not enough coin.");
            return HttpStatusCode.Forbidden;
        }

        User.Coin -= car.Cost;
        User.Cars.Add(car);

        var res = await http.Put($"User/BuyCar/{carId}", null);
        if (!res.IsSuccess())
        {
            Debug.LogWarning($"Buy car failed. - {res}");
            UserUpdateFailed?.Invoke(this, EventArgs.Empty);
        }
        return res;
    }

    public async UniTask<HttpStatusCode> EndStage(int stageIndex, long coin)
    {
        var currStageIndex = User.CurrentStage.Index;
        var stageCount = templateService.StageTemplates.Count;

        if (stageIndex < 0 || stageIndex >= stageCount)
        {
            Debug.LogError($"End stage failed. Because {stageIndex} is out of range.");
            return HttpStatusCode.BadRequest;
        }


        if (stageIndex > currStageIndex)
        {
            Debug.LogWarning($"End stage({stageIndex}) failed. Because {User.CurrentStage.Index} was not clear.");
            return HttpStatusCode.Forbidden;
        }

        if (coin >= templateService.StageTemplates[stageIndex].MaxCoin)
        {
            Debug.LogError($"End stage failed. Because {coin} is too much.");
            return HttpStatusCode.Forbidden;
        }

        if (stageIndex == currStageIndex)
            User.CurrentStage = templateService.StageTemplates[stageIndex];
        User.Coin += coin;

        var res = await http.Put($"User/EndStage", new EndStageRequest
        {
            StageIndex = stageIndex,
            Coin = coin
        });
        if (!res.IsSuccess())
        {
            Debug.LogWarning($"End stage failed. - {res}");
            UserUpdateFailed?.Invoke(this, EventArgs.Empty);
        }
        return res;
    }

    public void Clear()
    {
        User = null;
    }
}

 

UserModel은 템플릿 ID 대신 템플릿 클래스를 저장하도록 만들어 다른 클래스에서 데이터 탐색을 덜 하도록 만들었습니다.

UserService는 아래 서술한 기능을 구현하였습니다.

  • 유저정보 불러오기 구현
    • 유저정보는 로그인 후 1번만 불러오면 되도록 구현
  • 자동차 선택, 자동차 구매, 스테이지 클리어 구현
    • 서버와의 통신횟수를 줄이기 위해 클라이언트에서도 데이터 검증을 하도록 구현
    • 서버와의 통신이 끝날 때까지 기다릴 필요가 없도록 검증된 데이터를 클라이언트에서 먼저 갱신한 후 서버에 요청하도록 구현
    • 서버와 통신, 데이터 갱신 등을 실패했다면 UserUpdateFailed 이벤트를 보낸다.
      • 게임 로직에서 해당 이벤트를 받으면 로그인 혹은 타이틀 씬으로 되돌아가도록 처리할 계획

 

이번 작업은 테스트 없이 '이렇게 구현하면 되겠지?'라고 생각하면서 코딩만 하였기 때문에 잘 동작하는지 안 하는지 테스트하지 않았습니다.
다음 작업을 할 때 게임을 구현하면서 잘 동작하는지 테스트해보도록 하겠습니다.

 

깃 허브 저장소 : taxi-game-3d-unity

728x90
LIST
728x90
SMALL

1. 트리거, 이동경로 배치

모든 맵에 트리거를 배치하였습니다.
작업은 하루도 안 걸려 끝났습니다.

 

그리고 모든 맵의 이동경로를 배치하였습니다.
4일 동안 작업하였습니다.
작업하는 도중에 유니티가 뻗어 버려서 다시 배치하기도 했습니다.

 

이동경로를 모두 배치한 후에는 자동차들이 잘 달리는지 테스트하는 작업을 하였습니다.

 

2. 이동경로 테스트

테스트를 위해 각각의 씬에 배치했던 NpcCarManager 스크립트에 플레이어의 이동경로를 연결하였습니다.

 

플레이어의 이동경로에서 NPC가 이동할 수 있도록 설정하였기 때문에 NPC끼리 충돌하지 않도록 프리팹을 아래 표기한 내용으로 임시 수정하였습니다.

  • BoxCollider의 Is Trigger 체크
  • RigidBody의 Use Gravity 해제
  • RigidBody의 Is Kinematic 체크

 

설정을 마친 후 게임을 실행해보니 역시 자동차가 제대로 이동하지 않는 경로들을 발견하였습니다.

 

문제가 발생한 이동경로는 Path CreatorGlobal Angle의 수치와 파란색 Anchor Point의 위치를 조절하는 작업을 하였습니다.

 

특히 Global Angle는 결과가 일정하지 않아 90도로 입력해야 잘되는 곳도 있고, 180도로 입력해야 잘 되는 곳도 있었습니다.
그래서 문제가 발생한 이동경로는 90도 단위로 일일히 바꿔가면서 확인했습니다.

 

다음에는 플레이어 자동차 프리팹을 제작하겠습니다.

 

구현 결과

 

깃 허브 저장소 : taxi-game-3d-unity

728x90
LIST
728x90
SMALL

1. 씬 템플릿 제작

20개의 맵을 만드는데 걸리는 시간을 줄여보기 위해 씬 템플릿을 만들어 맵에 필요한 최소한의 오브젝트들이 배치된 씬을 새로 만들 수 있도록 설정해 보기로 하였습니다.
GameTest 씬을 복사하여 MapTemplate 씬으로 이름을 변경한 후 플레이어 자동차와 UI를 제거한 후 씬 팀플릿으로 만들었습니다.
씬 템플릿 이름은 Taxi Game Map이라고 지었습니다.

 

Taxi Game Map 템플릿을 이용하여 새로운 씬을 만들어 보았습니다.
그런데 씬을 저장할 때 마다 씬에 배치된 오브젝트, 텍스쳐 등이 계속 새로 복사되는 현상이 발생하였습니다.
이 현상은 씬 템플릿 설정에서 Clone을 꺼도 계속 발생하였습니다.
Clone을 끈 후 다시 확인해보면 계속 켜져 있었습니다.
끝내 원인을 찾지 못 해 씬 템플릿 사용은 포기하기로 하였습니다.

 

혹시 지나가던 고수님 중 이 글을 보고 해결책을 알려주길 바라는 마음으로 증상을 gif로 남겨 두었습니다.

 

2. 맵 제작

 

씬 템플릿으로 사용하려고 했던 MapTemplate 씬을 복사하여 맵들을 제작하였습니다.

Map1
Map02
Map03
Map04
Map05
Map06
Map07
Map08
Map09
Map10
Map11
Map12
Map13
Map14
Map15
Map16
Map17
Map18
Map19
Map20

 

오브젝트만 배치했는데 4일이나 걸려버렸습니다.
다음에는 승객이 차에 타고, 내리는 이벤트가 발생하는 트리거와 자동차들의 이동경로를 모두 배치하도록 하겠습니다.

 

깃 허브 저장소 : taxi-game-3d-unity

728x90
LIST

+ Recent posts