본 게시글은 제가 개발하면서 겪었던 시행착오들 중 일부를 짧게 글로 정리하였을 뿐 완벽한 정답은 절대 아닙니다. 그러니 반박시 여러분들이 맞습니다.
유니티와 파이어베이스를 연동하여 작업하셨던 분들 중 아래 이미지와 같은 에러를 경험해보신 분이 저 말고도 있을 겁니다.
만약에 깃으로 받은 프로젝트일 경우 .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로 만든 제동등 이펙트는 이런 현상이 발생하지 않아 서로 번갈아가며 설정을 바꿔봤지만 해결되지 않아 포기하였습니다.
택시에 타고 있던 손님이 내린 후 새로운 새로운 손님이 택시를 부르는 메시지 창이 출력될 때 효과음이 나오도록 수정하였습니다.
public class TalkViewUI : MonoBehaviour
{
[SerializeField]
Image customerIconImage;
[SerializeField]
TMP_Text talkContentText;
[SerializeField]
AudioClip callSfx;
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.Key;
gameObject.SetActive(true);
}
}
자동차 효과음 추가
자동차의 효과음을 관리하기 위해 CarSFXController 추가하였습니다. 각각의 효과음들은 등록된 효과음 중 1개를 렌덤하게 출력할 수 있도록 각각의 효과음은 배열로 정의하였습니다.
brakeSfx
자동차를 급제동할 때 발생하는 타이어 마찰음
화면을 터치하지 않을 때 출력
최저 속도까지 감소하거나 바로 멈출 경우에는 출력되지 않음
소리가 중복 출력되면 안 되고, 상황에 따라 바로 재생을 멈춰야 되는 경우도 있기 때문에 AudioSource를 따로 사용
doorSfx
문을 열고 닫을 때 출력되는 효과음
손님이 차에 타고, 내릴 때 출력
incomeSfx
동전소리 효과음
손님이 차에 내릴 때, 돈을 벌었다는 느낌을 주기 위해 출력
나중에 동전 이펙트를 구현하면 수정할 계획
crashSfx
다른 자동차와 충돌할 때 발생하는 효과음
hornSfx
자동차 경적음
앞에 다른 자동차가 있을 경우 출력
public class CarSFXController : MonoBehaviour
{
[SerializeField]
AudioClip[] brakeSfx;
[SerializeField]
AudioClip[] doorSfx;
[SerializeField]
AudioClip[] incomeSfx;
[SerializeField]
AudioClip[] crashSfx;
[SerializeField]
AudioClip[] hornSfx;
AudioSource brakeSfxSource;
AudioSource hornSfxSource;
public void PlayBrakeSfx()
{
if (brakeSfx.Length == 0)
return;
if (brakeSfxSource == null)
{
brakeSfxSource = SoundManager.Instance.CreateSfxSource("BrakeSFX", transform);
brakeSfxSource.loop = true;
brakeSfxSource.transform.localPosition = Vector3.zero;
}
if (brakeSfxSource.isPlaying)
return;
brakeSfxSource.clip = brakeSfx[Random.Range(0, brakeSfx.Length)];
brakeSfxSource.Play();
}
public void StopBrakeSfx()
{
if (brakeSfxSource == null || !brakeSfxSource.isPlaying)
return;
brakeSfxSource.Stop();
}
public void PlayDoorSfx()
{
if (doorSfx.Length == 0)
return;
SoundManager.Instance.PlaySfx(doorSfx[Random.Range(0, doorSfx.Length)]);
}
public void PlayIncomeSfx()
{
if (incomeSfx.Length == 0)
return;
SoundManager.Instance.PlaySfx(incomeSfx[Random.Range(0, incomeSfx.Length)]);
}
public void PlayCrashSfx()
{
if (crashSfx.Length == 0)
return;
SoundManager.Instance.PlaySfx(crashSfx[Random.Range(0, crashSfx.Length)]);
}
public void PlayHornSfx()
{
if (hornSfx.Length == 0)
return;
if (hornSfxSource == null)
{
hornSfxSource = SoundManager.Instance.CreateSfxSource("HornSFX", transform);
hornSfxSource.transform.localPosition = Vector3.zero;
}
if (hornSfxSource.isPlaying)
return;
hornSfxSource.clip = hornSfx[Random.Range(0, hornSfx.Length)];
hornSfxSource.Play();
}
public void StopHornSfx()
{
if (hornSfxSource == null)
return;
hornSfxSource.Stop();
}
}
바로 앞에 다른 자동차가 지나가는지 안 지나가는지 감지하기 위해 BoxCollider 추가하였습니다.
앞에 지나가는 자동차가 감지되었을 때 PlayerCar 스크립트에 이벤트를 전달할 수 있도록 TriggerInvoker 스크립트를 추가하였습니다.
public class TriggerInvoker : MonoBehaviour
{
public event EventHandler<Collider> TriggerEnteredEvent;
public event EventHandler<Collider> TriggerStayingEvent;
public event EventHandler<Collider> TriggerExitedEvent;
public void OnTriggerEnter(Collider other) =>
TriggerEnteredEvent?.Invoke(this, other);
public void OnTriggerStay(Collider other) =>
TriggerStayingEvent?.Invoke(this, other);
public void OnTriggerExit(Collider other) =>
TriggerExitedEvent?.Invoke(this, other);
}
PlayerCar 스크립트에서 필요한 상황에 맞춰 효과음을 출력하도록 수정하였습니다.
public class PlayerCar : MonoBehaviour
{
[Header("Movement")]
[SerializeField]
float acceleration = 1f;
[SerializeField]
float maxSpeed = 5f;
[SerializeField]
float brakeForce = 1f;
VertexPath path;
const float minSpeed = 0.5f;
float speed = minSpeed;
Rigidbody rb;
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>();
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();
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;
sfxController.StopBrakeSfx();
}
public void PressAccel()
{
if (IsEnableMoving)
{
speed = Mathf.Min(speed + Time.deltaTime * acceleration, maxSpeed);
sfxController.StopBrakeSfx();
}
}
public void PressBrake()
{
if (IsEnableMoving)
{
speed = Mathf.Max(speed - Time.deltaTime * brakeForce, minSpeed);
if (speed > minSpeed)
sfxController.PlayBrakeSfx();
else
sfxController.StopBrakeSfx();
}
}
public Transform SelectNearestPoint(Vector3 poisition)
{
var left = (LeftPoint.position - poisition).sqrMagnitude;
var right = (RightPoint.position - poisition).sqrMagnitude;
return left < right ? LeftPoint : RightPoint;
}
}
설정창 구현
UGUI를 이용하여 설정창을 제작하였습니다.
UGUI에서 기본 제공하는 Toggle는 ✔️ 아이콘을 보여줬다, 감췄다하는 방식으로 구현되어 있기 때문에 간단하게 SimpleToggle를 구현하였습니다.
public class SimpleToggle : MonoBehaviour, IPointerClickHandler
{
[SerializeField]
Image target;
[SerializeField]
Sprite on;
[SerializeField]
Sprite off;
[SerializeField]
AudioClip clickSfx;
bool value;
public bool Value
{
get => value;
set => SetValue(value, true);
}
public event EventHandler<bool> ValueChangedEvent;
void OnEnable()
{
target.sprite = value ? on : off;
}
public void OnPointerClick(PointerEventData eventData)
{
SoundManager.Instance.PlaySfx(clickSfx);
Value = !Value;
}
public void SetValue(bool value, bool callEvent)
{
this.value = value;
target.sprite = value ? on : off;
if (Application.isPlaying)
{
if (callEvent)
ValueChangedEvent.Invoke(this, value);
}
}
}
SettingsViewUI 스크립트를 추가하여 배경음 on/off, 효과음 on/off, 로그아웃을 구현하였습니다.
클라이언트 CarTemplate 도 변경된 자동차 데이터들을 저장할 수 있도록 수정하였습니다.
public class CarTemplate
{
[JsonIgnore]
public int Index
{
get;
set;
}
public string Id
{
get;
set;
}
public LocalizationTemplate Name
{
get;
set;
}
[JsonProperty("Icon")]
public string IconPath
{
get;
set;
}
[JsonIgnore]
public Sprite Icon => Resources.Load<Sprite>(IconPath);
[JsonProperty("PlayerPrefab")]
public string PlayerPrefabPath
{
get;
set;
}
[JsonIgnore]
public GameObject PlayerPrefab => Resources.Load<GameObject>(PlayerPrefabPath);
[JsonProperty("UiPrefab")]
public string UiPrefabPath
{
get;
set;
}
[JsonIgnore]
public GameObject UiPrefab => Resources.Load<GameObject>(UiPrefabPath);
public int Cost
{
get;
set;
}
}
자동차 목록창 수정
자동차 선택창에서 자동차를 자주 선택하기 때문에 계속 오브젝트를 생성/제거하지 않고, 활성화/비활성화로 관리할 수 있도록 UICarManager를 추가하였습니다.
public class UICarManager : MonoBehaviour
{
Dictionary<string, GameObject> unused = new();
public string SelectedId
{
get;
private set;
}
public GameObject SelectedObject
{
get;
private set;
}
public void Select(string id)
{
if (SelectedId == id)
return;
if (SelectedObject != null)
Deselect();
if (!unused.TryGetValue(id, out var go))
{
var template = ClientManager.Instance.TemplateService.Cars.Find(e => e.Id == id);
go = Instantiate(template.UiPrefab, transform);
go.transform.localPosition = Vector3.zero;
go.transform.localRotation = Quaternion.identity;
go.transform.localScale = Vector3.one;
}
go.SetActive(true);
SelectedId = id;
SelectedObject = go;
}
public void Deselect()
{
if (SelectedObject == null)
return;
SelectedObject.SetActive(false);
unused[SelectedId] = SelectedObject;
SelectedObject = null;
SelectedId = null;
}
}
자동차 모델은 RenderTexture가 연결된 다른 카메라로 자동차 모델을 촬영하고, RenderTexture는 RawImage를 이용하여 UI에 출력하도록 구현하였습니다.
보상획득창 구현
UGUI를 이용하여 보상획득 창 제작하였습니다.
RewardPopupViewUI 스크립트 구현하였습니다. 보상으로 자동차를 획득하면 자동차 모델을 출력하고, 돈을 획득하였다면 코인 아이콘을 출력하도록 구현하였습니다. 과거에 획득했던 자동차를 또 획득할 때는 자동차 모델을 출력하고, 아래 이미 획득했던 자동차라고 알려주는 텍스트가 출력되도록 구현하였습니다.
public class RewardPopupViewUI : MonoBehaviour
{
[SerializeField]
Image coinImage;
[SerializeField]
RawImage carImage;
[SerializeField]
UICarManager carManager;
[SerializeField]
TMP_Text contentText;
[SerializeField]
Button closeButton;
void Start()
{
closeButton.onClick.AddListener(() =>
{
carManager.Deselect();
gameObject.SetActive(false);
});
}
public void Show(int coin)
{
coinImage.gameObject.SetActive(true);
carImage.gameObject.SetActive(false);
contentText.text = $"Gain {coin}";
gameObject.SetActive(true);
}
public void Show(string carId, bool newCar)
{
coinImage.gameObject.SetActive(false);
carImage.gameObject.SetActive(true);
carManager.Select(carId);
if (newCar)
{
contentText.text = $"Gain {carId}";
}
else
{
var car = ClientManager.Instance.UserService.User.Cars.Find(e => e.Id == carId);
contentText.text = $"{carId} is already exit. Gain {car.Cost}";
}
gameObject.SetActive(true);
}
}
룰렛으로 자동차 획득했을 때, 출석보상 받았을 때, 자동차 구매했을 때 보상회득창 출력하도록 구현하였습니다.
보상획득창을 출력하도록 구현하면서 UserService 클래스도 약간 수정하였습니다.
출석 보상을 받을 수 없을 때는 보상팝업창의 출력하지 않도록 구현하기 위해 출석보상 받을 수 있는지 없는지 체크하는 메소드를 추가하였습니다.
public bool CheckEnableAttendance()
{
var now = DateTime.UtcNow;
if (User.NumberOfAttendance >= templateService.DailyRewards.Count)
{
Debug.LogWarning("Attendance failed. Because attendance is already completed.");
return false;
}
if (now <= User.DailyRewardedAtUtc.Date.AddDays(1))
{
Debug.LogWarning($"Attendance failed. Because of already rewarded today({now}/{User.DailyRewardedAtUtc}).");
return false;
}
return true;
}
룰렛으로 획득한 자동차가 이미 획득했던 자동차인지 아닌지 구분할 수 있도록 UserService의 SpinRoulette 메소드를 수정하였습니다.
public async UniTask<(int index, bool newCar)> SpinRoulette()
{
var now = DateTime.UtcNow;
var res = await http.Put<RouletteResponse>("User/SpinRoulette", new DateRequest
{
UtcDate = now
});
if (!res.Item1.IsSuccess())
{
Debug.LogWarning($"Spin roulette failed. - {res}");
UserUpdateFailed?.Invoke(this, EventArgs.Empty);
return (-1, false);
}
User.RouletteSpunAtUtc = now;
var car = User.RouletteCarRewards[res.Item2.Index];
if (User.Cars.Contains(car))
{
User.Coin += car.Cost;
return (res.Item2.Index, false);
}
else
{
User.Cars.Add(car);
return (res.Item2.Index, true);
}
}
이번주는 밖으로 자주 나가서 글을 오랜만에 게시하였습니다. 다음 주도 밖에 자주 나갈 예정이어서 글을 많이 못 올릴 것 같습니다
원래 제가 참고하고 있는 코코스 스토어용 프로젝트처럼 자동차를 획득했을 때 자동차 3D 모델을 출력하는 팝업창까지 구현하고 글을 작성하려고 했습니다. 하지만 UI에 출력할 프리팹을 다시 만드는 노가다 작업을 하다 보니 생각보다 오래 걸려 룰렛과 빨간점을 구현하는 과정까지만 게시하였습니다.
룰렛 구현
UGUI를 이용하여 룰렛UI 제작하였습니다.
RouletteViewUI 클래스 구현하여 룰렛UI에 연결하였습니다.
public class RouletteViewUI : MonoBehaviour
{
[SerializeField]
Button spinButton;
[SerializeField]
Button closeButton;
[SerializeField]
Transform spinner;
[SerializeField]
Image[] rewardIcons;
Tween tween;
void Start()
{
spinButton.onClick.AddListener(async () =>
{
spinButton.interactable = false;
closeButton.interactable = false;
var index = await ClientManager.Instance.UserService.SpinRoulette();
closeButton.interactable = true;
var curAngle = 0f;
var targetAngle = CalcSpinnerAngle(index);
tween = DOTween
.To(() => curAngle, x => curAngle = x, targetAngle, 3f)
.SetEase(Ease.OutQuart)
.OnUpdate(() =>
{
spinner.rotation = Quaternion.Euler(0, 0, curAngle);
})
.OnComplete(() =>
{
tween = null;
GameUI.Instance.Refresh();
});
});
closeButton.onClick.AddListener(() =>
{
gameObject.SetActive(false);
});
}
void OnEnable()
{
var user = ClientManager.Instance.UserService.User;
spinButton.interactable = DateTime.UtcNow > user.RouletteSpunAtUtc.AddDays(1);
if (spinButton.interactable)
spinner.rotation = Quaternion.identity;
for (int i = 0; i < rewardIcons.Length; i++)
{
if (i >= user.RouletteCarRewards.Count)
{
rewardIcons[i].gameObject.SetActive(false);
continue;
}
rewardIcons[i].sprite = user.RouletteCarRewards[i].Icon;
rewardIcons[i].gameObject.SetActive(rewardIcons[i].sprite != null);
}
}
void OnDisable()
{
if (tween != null && tween.IsPlaying())
tween.Complete(false);
}
float CalcSpinnerAngle(int index)
{
return 360f * 5f + 30f + (index * 60f);
}
}
서버에서 룰렛을 돌려 당첨된 상품은 0~5사이의 인덱스 값을 전달하도록 처리하였습니다. 클라이언트에서는 서버에서 받은 인덱스값을 이용하여 룰렛상품이 그려진 Transform의 Z축 각도를 아래와 같이 설정하도록 만들었습니다.
0번 : 30º
1번 : 90º
2번 : 150º
3번 : 210º
4번 : 270º
5번 : 330º
룰렛을 돌렸을 때 미리 설정해둔 각도로 변경하고 끝나면 룰렛을 돌렸다는 느낌이 들지 않기 때문에 무조건 5바퀴를 회전한 후 미리 설정한 각도에 맞춰 멈추도록 CalcSpinnerAngle 메소드를 구현하였습니다. 룰렛은 DOTween을 이용하여 3초동안 회전하도록 구현하였습니다. 룰렛을 돌릴 때 처음에는 빨리 회전하다가 서서히 느려진 후 멈추는 느낌을 주도록 Ease.OutQuart를 사용하였습니다.
그리고 테스트를 해봤지만 410번 HTTP 상태값을 서버에서 계속 전달 받아 원인을 찾아봤습니다. 서버의 SpinRoulette 함수에서 룰렛을 돌린 후 날짜가 지났는지 안 지났는지 확인하는 부분의 비교 연산자를 잘 못 적어 수정하였습니다.
if (body.UtcDate <= user.RouletteSpunAtUtc.Date.AddDays(1))
return StatusCode(StatusCodes.Status410Gone);
빨간점 구현
받을 수 있는 보상이 있을 때 일부 UI에서 빨간점을 출력하는 기능을 구현해 보았습니다. 빨간점은 아래 조건에 맞춰 출력되도록 구현하였습니다.
출석 보상을 받을 수 있을 때
재화 자동 수집을 완료하였을 때
룰렛을 돌릴 수 있을 때
준비화면 UI에 빨간점을 배치하였습니다.
ReadyViewUI 에서 빨간점 출력을 설정할 수 있도록 구현하였습니다.
void Update()
{
var user = ClientManager.Instance.UserService.User;
var collectMinutes = (DateTime.UtcNow - user.CoinCollectedAtUtc).TotalMinutes;
collectButton.interactable = collectMinutes >= 1;
if (collectMinutes >= user.CurrentStage.MaxCollect)
{
collectAmountText.text = user.CurrentStage.MaxCollect.ToString();
collectProgress.fillAmount = 1f;
collectRedDot.SetActive(true);
return;
}
var collectAmount = Math.Truncate(collectMinutes);
collectAmountText.text = collectAmount.ToString();
collectProgress.fillAmount = Convert.ToSingle(collectMinutes - collectAmount);
collectRedDot.SetActive(false);
}
public void Refresh()
{
var user = ClientManager.Instance.UserService.User;
coinText.text = user.Coin.ToString();
var now = DateTime.UtcNow;
attendanceRedDot.SetActive(now > user.DailyRewardedAtUtc.Date.AddDays(1));
rouletteRedDot.SetActive(now > user.RouletteSpunAtUtc.Date.AddDays(1));
}
재화수집은 실시간으로 UI를 갱신하기 때문에 빨간점을 Update에서 출력할지 말지 설정하도록 구현하였습니다. 출석보상과 룰렛은 한국 시간기준으로 매일 오전9시에 갱신되기 때문에 굳이 Update에서 갱신할 필요가 없다고 생각하여 Refresh에서 출력할지 말지 설정하도록 구현하였습니다.
UI에 출력할 자동차 프리팹을 모두 완성하면 자동차 획득 알림창, 자동차 선택창에 적용하는 작업을 진행하겠습니다. 원래 다음 계획은 사운드 적용, 옵션창 제작을 할 계획이었지만 다다음으로 미루겠습니다.
일일보상 템플릿에 아이콘 컬럼에 실제 아이콘 경로를 입력하고, DB에 다시 업로드하였습니다. 하지만 보상이 자동차일 경우에는 아이콘 경로를 따로 입력하지 않았습니다. 서버에서 미리 보상지급용으로 선택된 자동차의 아이콘을 출력해야 되기 때문에 자동차 템플릿에 등록된 아이콘 경로를 대신 사용하도록 구현하였습니다.
UGUI를 이용하여 출석보상 UI를 제작하였습니다.
유저정보를 변경된 후 각각의 UI를 갱신하는 작업을 줄여보기 위해 GameUI 클래스에 Refresh 메소드를 추가하여 현재 활성화된 UI들만 갱신하도록 구현하였습니다.
public void Refresh()
{
if (readyView.gameObject.activeInHierarchy)
readyView.Refresh();
if (carListView.gameObject.activeInHierarchy)
carListView.Refresh();
if (dailyRewardListView.gameObject.activeInHierarchy)
dailyRewardListView.Refresh();
}
DailyRewardListViewUI, DailyRewardEntryViewUI 클래스를 추가하여 출석보상UI에 연결하였습니다.
public class DailyRewardListViewUI : MonoBehaviour
{
[SerializeField]
DailyRewardEntryViewUI[] entries;
[SerializeField]
Button closeButton;
void Start()
{
closeButton.onClick.AddListener(() =>
{
gameObject.SetActive(false);
});
}
void OnEnable()
{
var templates = ClientManager.Instance.TemplateService.DailyRewards;
for (int i = 0; i < templates.Count; i++)
{
entries[i].gameObject.SetActive(true);
entries[i].Template = templates[i];
}
for (int i = templates.Count; i < entries.Length; i++)
entries[i].gameObject.SetActive(false);
}
public void Refresh()
{
foreach (var e in entries)
{
if (e.gameObject.activeInHierarchy)
e.Refresh();
}
}
}
아래 이미지의 게이지는 1분 단위로 한 바퀴를 돌도록 구현하였습니다. (gif는 4배속으로 편집하였습니다.)
실제 구현하고 테스트했을 때 실제 획득한 금액과 메인화면 UI에 출력되는 금액이 달라 원인을 찾아 봤습니다. UserService의 CollectCoin 메소드에서 최대 수집양을 넘기지 않도록 처리할 때 실수로 MaxCoin 프로퍼티를 사용하고 있었기 때문에 수정였습니다.
public async void CollectCoin()
{
var now = DateTime.UtcNow;
if (now <= User.CoinCollectedAtUtc)
return;
var minutes = (int)(now - User.CoinCollectedAtUtc).TotalMinutes;
if (minutes <= 0)
return;
User.CoinCollectedAtUtc = now;
User.Coin += Math.Min(minutes, User.CurrentStage.MaxCollect);
var res = await http.Put($"User/CollectCoin", new DateRequest
{
UtcDate = now
});
if (!res.IsSuccess())
{
Debug.LogWarning($"Collect coin failed. - {res}");
UserUpdateFailed?.Invoke(this, EventArgs.Empty);
}
}
버그 수정
게임을 테스트하면서 손님을 태우는 구간이 3개인 스테이지에서 세번째 구간은 손님을 안 태우고 그냥 지나가는 버그를 발견하였습니다. 각각의 스테이지를 만들 때 깜박하고, 맵에 배치했던 CustomerTrigger들을 GameLogic에 모두 연결하지 않아 발생한 버그였기 때문에 스테이지들을 다시 확인하면서 트리거를 모두 연결하였습니다.