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

+ Recent posts