저번에 모바일 버전에서는 화면을 터치해도 자동차가 이동하지 않는 현상을 수정하는 작업을 먼저 시작하였습니다. 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);
}
}
이번주는 밖으로 자주 나가서 글을 오랜만에 게시하였습니다. 다음 주도 밖에 자주 나갈 예정이어서 글을 많이 못 올릴 것 같습니다