본 게시글은 제가 개발하면서 겪었던 시행착오들 중 일부를 짧게 글로 정리하였을 뿐 완벽한 정답은 절대 아닙니다. 그러니 반박시 여러분들이 맞습니다.
유니티와 파이어베이스를 연동하여 작업하셨던 분들 중 아래 이미지와 같은 에러를 경험해보신 분이 저 말고도 있을 겁니다.
만약에 깃으로 받은 프로젝트일 경우 .gitignore을 확인해보시기 바랍니다. 아마도 .exe파일도 포함되어 있을 수 있습니다.
프로젝트에 파이어베이스 SDK를 추가한 후 깃으로 프로젝트를 Commnit할 때 generate_xml_from_google_services_json.exe 파일과 network_request.exe 파일이 제외되었을 겁니다.
그래서 깃으로 프로젝트를 받은 다른 사람들이 위에 언급한 에러 때문에 고통을 받았을 겁니다. 해결책은 파이어베이스 .gitignore 파일에서 .exe를 제거한 후 파이어베이스 SDK에서 제외되었던 파일들만 다시 포함시킨 다음 프로젝트를 다시 불러오면 문제가 해결됩니다.
저는 비트버킷으로 프로젝트를 공유할 때 .gitignore를 자동생성하는 옵션을 켜고, 저장소를 만들었다가 이런 시련을 겪었습니다.
본 게시글은 제가 개발하면서 겪었던 시행착오들 중 일부를 짧게 글로 정리하였을 뿐 완벽한 정답은 절대 아닙니다. 그러니 반박시 여러분들이 맞습니다.
기존에 유니티와 비주얼 스튜디오를 이용하여 개발하던 프로젝트를 비주얼 스튜디오 코드로 대신 연동하여 개발을 시작했을 때 제목과 같은 문제를 겪었습니다. 비주얼 스튜디오 코드로 프로젝트를 불러왔을 때 기존에 소스코드 안에 적었던 한글들이 전부 깨져있었습니다.
저는 비주얼 스튜디오와 비주얼 스튜디오 코드의 기본 인코딩이 달라서 그런 거라고 추측하고 있습니다. 이런 문제를 해결하기 위해서 저는 비주얼 스튜디오 코드에서 문제가 발생한 소스코드를 ECU-KR 인코딩으로 다시 불러온 후 바로 UTF-8 인코딩으로 다시 저장하여 문제를 해결하였습니다.
비주얼 스튜디오 코드 오른쪽 하단을 확인하면 현재 소스코드의 인코딩을 확인할 수 있습니다.
오른쪽 하단에 표기된 인코딩을 클릭하면 중앙 상단에 두개의 메뉴가 나옵니다. 둘 중 Repoen with Encoding을 선택합니다.
Reopen with Encoding을 선택하면 인코딩을 선택할 수 있는 메뉴가 나옵니다.
검색창에 korean을 입력하면 검색되는 ECU-KOR 인코딩을 선택합니다.
여기까지만 작업하여도 한글이 깨지는 현상은 잘 해결된 것 같지만 나중에 프로젝트를 다시 열면 또 UTF-8 인코딩으로 파일을 불러와 텍스트가 깨져 있기 때문에 비주얼 스튜디오 코드에서 직접 UTF-8 인코딩으로 다시 저장해야 됩니다.
오른쪽 하단에 표기되고 있는 현재 인코딩을 다시 클릭하여 중앙 상단에 메뉴가 나오게 만듭니다. 이번에는 Save with Encoding을 선택합니다.
저번에 모바일 버전에서는 화면을 터치해도 자동차가 이동하지 않는 현상을 수정하는 작업을 먼저 시작하였습니다. Input System을 확인해보니 마우스 클릭과 화면 터치는 각각 따로 처리하도록 만들어져 있었습니다. 그래서 화면을 터치했을 때도 Accelerate 액션이 발생하도록 수정하였습니다.
모바일에서 시작 라인과 끝 라인에 사용하는 텍스쳐들이 흐리게 출력되는 현상을 발견하였습니다.
Mipmap을 끄면 바로 해결되지만 Mipmap을 켠 상태에서도 해결이 가능할지 알아보기 위해 설정을 조금씩 바꾸면서 빌드해 봤습니다. 하지만 다른 설정을 바꿔도 해결되지 않아 Generate Mipmaps 옵션을 체크 해제하였습니다.
손님의 대사들 중 첫 번째 대사를 못 불러오는 현상을 발견하였습니다.
Game String Table.csv 파일을 확인해보니 첫 번째 대사(talk/1-1)만 입력하지 않았었습니다.
한국어 컬럼에 값을 입력하기 위해 "Go to the place where you spend the most." 는 "가장 많은 돈을 쓰는 곳으로 가세요." 라고 번역되었고, 대사가 이상하다고 생각이 들어 "요즘 뜨는 핫플레이스로 가주세요." 라고 대신 적었습니다.
Localization 이 적용되지 않았던 UI들이 발견되어 Localization을 적용하는 작업도 하였습니다.
게임을 테스트하다가 왼쪽만 이펙트가 출력되는 자동차를 발견하였습니다.
이펙트를 잘 못 배치해서 그럴 거라고 생각하고 프리팹을 열고, 이펙트를 확인 해 보았습니다. 이펙트는 오른쪽 바퀴의 자식으로 배치하고, 로컬 좌표를 (0, 0, 0)으로 설정였지만 알 수 없는 이유로 윈쪽 바퀴에서 출력되고 있었습니다.
원인을 찾고 싶어도 검색창에 어떻게 적어야 될지 감이 안 잡혀, 이펙트들을 직접 오른쪽으로 좌표를 이동하여 배치하였습니다.
이후에 (제)눈에 띄는 버그는 안 보여 게임을 안드로이드와 윈도우로 빌드하였습니다.
플레이 영상 제작
OBS Studio를 이용하여 게임 플레이 영상을 촬영해 봤습니다. OBS Studio를 처음 사용해 보았고, 오캠보다 복잡해 보여서 영상을 촬영하는데 시간이 걸렸습니다. 그리고 OBS Studio는 촬영하는 영역과 화면비율이 맞지 않으면 검은 화면으로 채워버렸고, 저는 검은 화면이 마음에 안 들어 제거해보려고 계속 시도하다가 검은 화면은 제거하지 못 하고, 시간만 낭비하였습니다.
결국 검은 화면은 OBS Studio로 촬영한 영상을 Clipchamp로 편집하여 제거하였습니다.
완성된 동영상은 유튜브 채널에 일부 공개로 설정하여 업로드하였습니다.
이 프로젝트는 여기까지만 하겠습니다. 다시 읽어 봤을 때 저도 알아보기 힘든 게시글들을 봐주셔서 감사합니다.
Azure에 서버를 게시한 후 Swagger를 이용하여 서버와 DB가 잘 동작하는지 확인하였습니다. /Template/Versions를 실행하여 미리 등록했던 템플릿 버전확인을 해 보았습니다.
클라이언트 수정/테스트
접속할 서버를 변경할 때마다 ClientManager 프리팹에서 서버 주소를 직접 수정하지 않도록 ClientSettings 스크립트 추가하였습니다. ClientSettings는 ScriptableObject를 상속받아 .asset 파일로 만들 수 있도록 구현하였습니다.
[CreateAssetMenu(menuName = "TaxiGame/ClientSettings")]
public class ClientSettings : ScriptableObject
{
[field: SerializeField]
public string Enviroment
{
get;
private set;
}
[field: SerializeField]
public string ServerUri
{
get;
private set;
}
}
ClientSettings 타입의 AzureDev.asset, LocalDev.asset 파일을 추가하여 기존에 사용하던 로컬주소와 Azure 웹앱 주소를 등록하였습니다.
ClientManager 프리팹은 ClientSettings 파일을 이용하여 접속할 서버를 선택할 수 있도록 수정하였습니다.
Azure에 게시했던 서버에 잘 접속 되는지 테스트해봤습니다. 자동 로그인 실패 후 로그인 UI가 출력되지 않는 현상 발생하여 로그를 추가하여 원인을 찾아 보았습니다. 제 PC에서 실행한 서버와 통신하였을 때는 Exception이 발생해도 결과값을 잘 반환하였지만 Azure에 게시했던 서버와 통신하였을 때는 Exception이 발생하면 결과값을 반환하지 못 했습니다. 로그를 계속 입력하여 확인했을 때 UniTask 에서 다르게 처리하는 것 같다고 의심이 되었지만 더 자세히 확인하지 않고, HttpContext 스크립트에서 서버에 요청하는 구간은 try~catch로 감싸줘서 어떤 문제가 발생하여도 결과값은 반환하도록 수정하였습니다.
public async UniTask<HttpStatusCode> Get(string subUri)
{
using (var req = UnityWebRequest.Get($"{BaseUri}{subUri}"))
{
try
{
foreach (var h in Headers)
req.SetRequestHeader(h.Key, h.Value);
await req.SendWebRequest();
}
catch (Exception e)
{
Debug.LogException(e);
}
return (HttpStatusCode)req.responseCode;
}
}
public async UniTask<(HttpStatusCode, T)> Get<T>(string subUri)
{
using (var req = UnityWebRequest.Get($"{BaseUri}{subUri}"))
{
try
{
foreach (var h in Headers)
req.SetRequestHeader(h.Key, h.Value);
await req.SendWebRequest();
}
catch (Exception e)
{
Debug.LogException(e);
}
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"))
{
try
{
foreach (var h in Headers)
req.SetRequestHeader(h.Key, h.Value);
await req.SendWebRequest();
}
catch (Exception e)
{
Debug.LogException(e);
}
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"))
{
try
{
foreach (var h in Headers)
req.SetRequestHeader(h.Key, h.Value);
await req.SendWebRequest();
}
catch (Exception e)
{
Debug.LogException(e);
}
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)))
{
try
{
foreach (var h in Headers)
req.SetRequestHeader(h.Key, h.Value);
req.SetRequestHeader("Content-Type", "application/json");
await req.SendWebRequest();
}
catch (Exception e)
{
Debug.LogException(e);
}
return (HttpStatusCode)req.responseCode;
}
}
public async UniTask<(HttpStatusCode, T)> Put<T>(string subUri, object data)
{
using (var req = UnityWebRequest.Put($"{BaseUri}{subUri}", ToJsonSafety(data)))
{
try
{
foreach (var h in Headers)
req.SetRequestHeader(h.Key, h.Value);
req.SetRequestHeader("Content-Type", "application/json");
await req.SendWebRequest();
}
catch (Exception e)
{
Debug.LogException(e);
}
return (
(HttpStatusCode)req.responseCode,
FromJsonSafety<T>(req.downloadHandler.text)
);
}
}
public async UniTask<HttpStatusCode> Delete(string subUri)
{
using (var req = UnityWebRequest.Delete($"{BaseUri}{subUri}"))
{
try
{
foreach (var h in Headers)
req.SetRequestHeader(h.Key, h.Value);
await req.SendWebRequest();
}
catch (Exception e)
{
Debug.LogException(e);
}
return (HttpStatusCode)req.responseCode;
}
}
public async UniTask<(HttpStatusCode, T)> Delete<T>(string subUri)
{
using (var req = UnityWebRequest.Delete($"{BaseUri}{subUri}"))
{
try
{
foreach (var h in Headers)
req.SetRequestHeader(h.Key, h.Value);
await req.SendWebRequest();
}
catch (Exception e)
{
Debug.LogException(e);
}
return (
(HttpStatusCode)req.responseCode,
FromJsonSafety<T>(req.downloadHandler.text)
);
}
}
클라이언트를 빌드하기 전에 Azure 웹 앱은 무료버전으로 만들 경우 30분 이상 통신하지 않으면 슬립모드가 되기 때문에 최소 비용을 지불하도록 요금제를 변경했던 경험이 떠올랐습니다. 그래서 40분 정도 대기했다가 게임을 실행해보았고, 처음 실행할 때 응답속도가 엄청 느리다는 것 외에는 큰 문제 없어 다른 처리를 하지 않아 안드로이드로 빌드 하였습니다.
빌드 후 테스트를 해보니 화면 터치를 해도 자동차가 이동하지 않았습니다. InputSystem을 적용할 때 마우스 입력만 추가하였기 때문인 것 같습니다. 원래 모바일에서도 잘 돌아가면 프로젝트를 마무리 할 계획이었지만 입력 문제와 그 밖에 빌드 후 발생하는 다른 문제들도 찾아서 수정한 후 프로젝트를 마무리하겠습니다.
System String Table에 앱 이름을 영어와 한국어로 입력한 후 메타 데이터에 등록
게임에서 사용되는 텍스트들은 따로 관리하기 위해 Game String Table을 추가하였습니다.
유니티에서 제공하는 텍스트 테이블 관리툴은 텍스트가 많아질 수록 관리하기 불편했기 때문에 csv 파일을 연동할 수 있도록 설정하였습니다. Game String Table.csv 파일을 추가하여 Game String Table과 연결하였습니다. System String Table은 텍스트를 많이 추가하지 않을 계획이기 때문에 csv 파일을 추가하지 않았습니다.
Game String Table.csv 파일에 텍스트를 입력하였습니다.
한번 실행하면 내용이 변경되지 않는 텍스트들은 Localize String Event 스크립트를 연결하여 다국어 처리를 하도록 구현하였습니다.
템플릿 연동
템플릿에도 Game String Table에 등록된 텍스트의 불러올 수 있도록 구현하는 작업을 시작하였습니다. Game String Table.csv 파일에 자동차 이름, 손님 대사를 추가하였습니다. Template(Dev).xlsx 파일의 Car, Talk 시트에 Game String Table의 이름과 각각의 키 값을 추가한 후 Car, Talk 템플릿을 DB에 등록하였습니다.
이전에 추가했었던 LocalizationTemplate을 이용하여 다국어 처리된 텍스트를 불러올 수 있도록 수정하였습니다.
public class LocalizationTemplate
{
[JsonIgnore]
LocalizedString localizedString;
public string Table { get; set; }
public string Key { get; set; }
public string GetLocalizedString()
{
if (localizedString == null)
localizedString = new LocalizedString(Table, Key);
return localizedString.GetLocalizedString();
}
public string GetLocalizedString(params object[] arguments)
{
if (localizedString == null)
localizedString = new LocalizedString(Table, Key);
return localizedString.GetLocalizedString(arguments);
}
}
TalkViewUI 스크립트에서 다국어 처리된 대사를 출력하도록 수정하였습니다.
public void Show(int customerIndex, int talkIndex)
{
var templateService = ClientManager.Instance.TemplateService;
var talk = templateService.Talks[talkIndex];
if (talk.Type == TalkType.Call)
SoundManager.Instance.PlaySfx(callSfx);
customerIconImage.sprite = templateService.Customers[customerIndex].Icon;
talkContentText.text = talk.Content.GetLocalizedString();
gameObject.SetActive(true);
}
RewardPopupViewUI 보상으로 받은 자동차의 이름을 출력하도록 수정하였습니다. 돈을 보상으로 받았을 때 메시지와 자동차를 보상으로 받았을 때 메시지를 미리 등록할 수 있도록 구현하였습니다.
public void Show(string carId, bool newCar)
{
coinImage.gameObject.SetActive(false);
carImage.gameObject.SetActive(true);
carManager.Select(carId);
var car = ClientManager.Instance.UserService.User.Cars.Find(e => e.Id == carId);
var carName = car.Name.GetLocalizedString();
if (newCar)
contentText.text = contentForCar.GetLocalizedString(carName);
else
contentText.text = contentForCarCost.GetLocalizedString(carName, car.Cost);
gameObject.SetActive(true);
}
언어변경 구현
설정창 UI에 언어 선택을 할 때 이용할 드롭다운을 추가하였습니다.
SettingViewUI 스크립트에서 로캐일을 선택을 할 수 있도록 구현하였습니다. 드롭다운을 이용하여 로캐일을 선택하면 선택된 로캐일의 인덱스값을 PlayerPrebs로 저장하도록 구현하였습니다.
속도가 줄어들 때 타이어 자국과 함께 출력될 연기 이펙트를 파티클 시스템을 이용하여 제작하였습니다. 연기 이미지를 따로 구하지 않고, 회색 3D 출력되도록 구현해보았습니다.
연기 이펙트를 프리팹으로 만든 후 각각의 자동차 바퀴에 추가하였습니다.
이펙트 출력 구현
CarVFXController 스크립트를 추가하여 각각의 이펙트를 조절할 수 있도록 구현하였습니다.
public class CarVFXController : MonoBehaviour
{
[SerializeField]
TrailRenderer[] brakeLights;
[SerializeField]
TrailRenderer[] tireMarks;
[SerializeField]
ParticleSystem[] tireSmoke;
public void EnableBrakeLightsEmitting()
{
foreach (var t in brakeLights)
t.emitting = true;
}
public void DisableBrakeLightsEmitting()
{
foreach (var t in brakeLights)
t.emitting = false;
}
public void EnableTireMarksEmitting()
{
foreach (var t in tireMarks)
t.emitting = true;
}
public void DisableTireMarksEmitting()
{
foreach (var t in tireMarks)
t.emitting = false;
}
public void PlayTireSmokes()
{
foreach (var p in tireSmoke)
{
if (!p.isPlaying)
p.Play();
}
}
public void StopTireSmokes()
{
foreach (var p in tireSmoke)
{
if (p.isPlaying)
p.Stop();
}
}
}
PlayerCar 스크립트에서 CarVFXController를 제어하도록 구현하수정하였습니다. OnCollisionEnter 메소드에 NPC 자동차와 충돌하면 NPC 자동차가 날아가도록 만드는 기능도 추가하였습니다.
public class PlayerCar : MonoBehaviour
{
/// <summary>
/// 가속도
/// </summary>
[Header("Movement")]
[SerializeField]
float acceleration = 1f;
/// <summary>
/// 최고 속도
/// </summary>
[SerializeField]
float maxSpeed = 5f;
/// <summary>
/// 제동력
/// </summary>
[SerializeField]
float brakeForce = 1f;
VertexPath path;
const float minSpeed = 0.5f;
float speed = minSpeed;
Rigidbody rb;
CarVFXController vfxController;
CarSFXController sfxController;
public bool IsEnableMoving
{
get;
set;
}
public float Movement
{
get;
private set;
}
public bool IsArrive => Movement >= path.length;
[field: Header("Points")]
[field: SerializeField]
public Transform LeftPoint
{
get;
private set;
}
[field: SerializeField]
public Transform RightPoint
{
get;
private set;
}
public event EventHandler OnCrashed;
public event EventHandler OnArrive;
void Awake()
{
rb = GetComponent<Rigidbody>();
vfxController = GetComponent<CarVFXController>();
sfxController = GetComponent<CarSFXController>();
}
void Start()
{
GameLogic.Instance.CustomerTakeInEvent += (sender, numOfCustomers) =>
{
sfxController.PlayDoorSfx();
};
GameLogic.Instance.CustomerTakeOutEvent += (sender, numOfCustomers) =>
{
sfxController.PlayDoorSfx();
sfxController.PlayIncomeSfx();
};
GetComponentInChildren<TriggerInvoker>().TriggerEnteredEvent += (sender, other) =>
{
if (other.gameObject.CompareTag("NpcCar"))
sfxController.PlayHornSfx();
};
}
void Update()
{
if (!IsEnableMoving)
return;
Movement += Time.deltaTime * speed;
rb.MovePosition(path.GetPointAtDistance(Movement, EndOfPathInstruction.Stop));
rb.MoveRotation(path.GetRotationAtDistance(Movement, EndOfPathInstruction.Stop));
if (IsArrive)
OnArrive?.Invoke(this, EventArgs.Empty);
}
void OnCollisionEnter(Collision collision)
{
if (collision.gameObject.CompareTag("NpcCar"))
{
sfxController.PlayCrashSfx();
var dir = (collision.rigidbody.position - rb.position).normalized;
dir.y = 0.1f;
collision.gameObject.GetComponent<Rigidbody>().AddForce(dir * 500);
collision.gameObject.GetComponent<Rigidbody>().AddTorque(Vector3.up * 100);
OnCrashed?.Invoke(this, EventArgs.Empty);
}
}
public void SetPath(VertexPath path)
{
this.path = path;
Movement = 0f;
rb.position = path.GetPoint(0);
rb.rotation = path.GetRotation(0f, EndOfPathInstruction.Stop);
}
public void PlayMoving()
{
IsEnableMoving = true;
speed = minSpeed;
}
public void StopMoving()
{
IsEnableMoving = false;
speed = 0f;
vfxController.DisableBrakeLightsEmitting();
vfxController.DisableTireMarksEmitting();
vfxController.StopTireSmokes();
sfxController.StopBrakeSfx();
}
public void PressAccel()
{
if (IsEnableMoving)
{
speed = Mathf.Min(speed + Time.deltaTime * acceleration, maxSpeed);
vfxController.DisableBrakeLightsEmitting();
vfxController.DisableTireMarksEmitting();
vfxController.StopTireSmokes();
sfxController.StopBrakeSfx();
}
}
public void PressBrake()
{
if (IsEnableMoving)
{
speed = Mathf.Max(speed - Time.deltaTime * brakeForce, minSpeed);
if (speed > minSpeed)
{
vfxController.EnableBrakeLightsEmitting();
vfxController.EnableTireMarksEmitting();
vfxController.PlayTireSmokes();
sfxController.PlayBrakeSfx();
}
else
{
vfxController.DisableBrakeLightsEmitting();
vfxController.DisableTireMarksEmitting();
vfxController.StopTireSmokes();
sfxController.StopBrakeSfx();
}
}
}
public Transform SelectNearestPoint(Vector3 poisition)
{
var left = (LeftPoint.position - poisition).sqrMagnitude;
var right = (RightPoint.position - poisition).sqrMagnitude;
return left < right ? LeftPoint : RightPoint;
}
}
실제로 구현하여 테스트 했을 때 타이어 자국 이펙트의 시작 부분과 끝 부분이 뾰족하게 출력되었습니다. 타이어 자국처럼 TrailRenderer로 만든 제동등 이펙트는 이런 현상이 발생하지 않아 서로 번갈아가며 설정을 바꿔봤지만 해결되지 않아 포기하였습니다.