서버와 통신은 유니티에서 제공하는 UnityWebRequest와 UniTask를 사용해보기로 하였습니다.
PackageManager를 이용하여 UniTask를 추가하였습니다.
UniTask와 UnityWebRequest를 이용하여 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는 아래 서술한 작업을 위해 구현하였습니다.
- 기본주소를 메모리에 저장하여 다른 클래스에서 호출할 때 서버주소를 모두 입력하지 않아도 된다.
- 로그인할 때 https://localhost:7170/Auth/LoginEmail 을 입력해야 된다면, HttpContext 에서는 https://localhost:7170/ 까지 저장하도록 하여 다른 클래스에서는 Auth/LoginEmail 까지만 입력하여 로그인요청을 할 수 있도록 만든다.
- 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에 저장된 템플릿과 버전이 달라질 수 밖에 없다.
- 에디터는 개발서버, 출시서버를 모두 사용하여 테스트해야 될 경우가 있기 때문에 어떤 서버에서 받은 템플릿인지 구분할 수 있어야 된다.
유저정보를 관리하기 위해 UserService 와 UserModel을 추가하였습니다.
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
'개발노트 > Taxi Game 3D' 카테고리의 다른 글
Devlog) Taxi Game 3D) 15) UI 최초 구현 (2) | 2023.12.29 |
---|---|
Devlog) Taxi Game 3D) 14) ClientManager 적용 (2) | 2023.12.26 |
Devlog) Taxi Game 3D) 12) 서버 구현2 (2) | 2023.12.20 |
Devlog) Taxi Game 3D) 11) 서버 구현1 (0) | 2023.12.15 |
Devlog) Taxi Game 3D) 10) 자동차 제작 (0) | 2023.12.13 |