728x90
SMALL

본 게시글은 제가 개발하면서 겪었던 시행착오들 중 일부를 짧게 글로 정리하였을 뿐 완벽한 정답은 절대 아닙니다.
그러니 반박시 여러분들이 맞습니다.

 

유니티와 파이어베이스를 연동하여 작업하셨던 분들 중 아래 이미지와 같은 에러를 경험해보신 분이 저 말고도 있을 겁니다.

 

만약에 깃으로 받은 프로젝트일 경우 .gitignore을 확인해보시기 바랍니다.
아마도 .exe파일도 포함되어 있을 수 있습니다.

 

프로젝트에 파이어베이스 SDK를 추가한 후 깃으로 프로젝트를 Commnit할 때 generate_xml_from_google_services_json.exe 파일과 network_request.exe 파일이 제외되었을 겁니다.

 

그래서 깃으로 프로젝트를 받은 다른 사람들이 위에 언급한 에러 때문에 고통을 받았을 겁니다.
해결책은 파이어베이스 .gitignore 파일에서 .exe를 제거한 후 파이어베이스 SDK에서 제외되었던 파일들만 다시 포함시킨 다음 프로젝트를 다시 불러오면 문제가 해결됩니다.

 

저는 비트버킷으로 프로젝트를 공유할 때 .gitignore를 자동생성하는 옵션을 켜고, 저장소를 만들었다가 이런 시련을 겪었습니다.

 

그래서 저는 개인적인 작업을 할 때는 절대 비트버킷을 사용하지 않습니다.

728x90
LIST
728x90
SMALL

본 게시글은 제가 개발하면서 겪었던 시행착오들 중 일부를 짧게 글로 정리하였을 뿐 완벽한 정답은 절대 아닙니다.
그러니 반박시 여러분들이 맞습니다.

 

기존에 유니티와 비주얼 스튜디오를 이용하여 개발하던 프로젝트를 비주얼 스튜디오 코드로 대신 연동하여 개발을 시작했을 때 제목과 같은 문제를 겪었습니다.
비주얼 스튜디오 코드로 프로젝트를 불러왔을 때 기존에 소스코드 안에 적었던 한글들이 전부 깨져있었습니다.

 

저는 비주얼 스튜디오와 비주얼 스튜디오 코드의 기본 인코딩이 달라서 그런 거라고 추측하고 있습니다.
이런 문제를 해결하기 위해서 저는 비주얼 스튜디오 코드에서 문제가 발생한 소스코드를 ECU-KR 인코딩으로 다시 불러온 후 바로 UTF-8 인코딩으로 다시 저장하여 문제를 해결하였습니다.

비주얼 스튜디오 코드 오른쪽 하단을 확인하면 현재 소스코드의 인코딩을 확인할 수 있습니다.

 

오른쪽 하단에 표기된 인코딩을 클릭하면 중앙 상단에 두개의 메뉴가 나옵니다.
둘 중 Repoen with Encoding을 선택합니다.

 

Reopen with Encoding을 선택하면 인코딩을 선택할 수 있는 메뉴가 나옵니다.

 

검색창에 korean을 입력하면 검색되는 ECU-KOR 인코딩을 선택합니다.

 

여기까지만 작업하여도 한글이 깨지는 현상은 잘 해결된 것 같지만 나중에 프로젝트를 다시 열면 또 UTF-8 인코딩으로 파일을 불러와 텍스트가 깨져 있기 때문에 비주얼 스튜디오 코드에서 직접 UTF-8 인코딩으로 다시 저장해야 됩니다.

 

오른쪽 하단에 표기되고 있는 현재 인코딩을 다시 클릭하여 중앙 상단에 메뉴가 나오게 만듭니다.
이번에는 Save with Encoding을 선택합니다.

 

UTF-8 인코딩을 선택하여 저장합니다.

 

이 과정을 거치면 나중에 프로젝트를 다시 열어도 한글 안 깨지고 잘 열립니다.

728x90
LIST
728x90
SMALL

버그 수정

저번에 모바일 버전에서는 화면을 터치해도 자동차가 이동하지 않는 현상을 수정하는 작업을 먼저 시작하였습니다.
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로 편집하여 제거하였습니다.

 

완성된 동영상은 유튜브 채널에 일부 공개로 설정하여 업로드하였습니다.

 

 

이 프로젝트는 여기까지만 하겠습니다.
다시 읽어 봤을 때 저도 알아보기 힘든 게시글들을 봐주셔서 감사합니다.

 

깃 허브 저장소 : taxi-game-3d-unity

728x90
LIST
728x90
SMALL

몽고DB 아틀라스 설정

몽고DB 아틀라스에 접속하여 무료 클러스터(저장소?)를 추가하였습니다.
동시에 보안 설정에서 제 PC의 IP주소를 등록하였습니다.

 

MongoDB Compass를 이용하여 새로 추가된 클러스터에 잘 접속 되는지 확인해 보았고, 잘 접속 되었습니다.

 

다음으로 제가 만든 서버도 DB주소를 바꿔도 잘 되는지 확인해 보았습니다.
서버 프로젝트에서 appsettings.AzureDev.json 파일 추가한 후, DB 주소를 새로 만든 클러스터의 주소로 수정하였습니다.

 

서버를 실행했을 때 appsettings.AzureDev.json 파일을 이용하도록 만들기 위해 Properties/launchSettings.json 파일의 ASPNETCORE_ENVIRONMENT 수정하였습니다.

"environmentVariables": {
  "ASPNETCORE_ENVIRONMENT": "AzureDev"
}

 

appsettings.AzureDev.json 파일에는 민감한 정보가 있기 때문에 .gitignore 파일에 등록하여 깃허브에 업로드 되지 않도록 않도록 처리하였습니다.

.vs
TaxiGame3D.Server/bin
TaxiGame3D.Server/obj
TaxiGame3D.Server/appsettings.AzureDev.json

 

서버를 실행한 후, 파이썬 스크립트를 이용하여 모든 템플릿을 DB에 등록해보았습니다.
새로 추가한 클러스터에 아무 문제 없이 DB가 추가되었습니다.

 

Azure 설정

 

https://portal.azure.com/ 에 접속하여 무료 웹 앱을 새로 추가하였습니다.

 

Azure 웹 앱에서도 MongoDB Atlas에 잘 접속할 수 있도록 아웃바운드 IP주소를 등록하였습니다.

 

Azure 웹 앱에 배포된 서버는 appsettings.AzureDev.json 파일에 입력된 설정을 사용하도록 ASPNETCORE_ENVIRONMENT 설정하였습니다.

 

Visiual Studio 에서 서버 게시를 할 수 있도록 게시 프로필 생성하였습니다.

 

민감한 정보 때문에 .gitignore 파일에 게시 프로필 경로를 입력하여 깃허브에 업로드 되지 않도록 처리하였습니다.

.vs
TaxiGame3D.Server/bin
TaxiGame3D.Server/obj
TaxiGame3D.Server/appsettings.AzureDev.json
TaxiGame3D.Server/Properties/PublishProfiles
TaxiGame3D.Server/Properties/ServiceDependencies

 

Azure에 게시된 서버에서도 Swagger를 이용하여 테스트할 수 있도록 Program.cs 수정하였습니다.

//if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

 

Azure에 서버를 게시한 후 Swagger를 이용하여 서버와 DB가 잘 동작하는지 확인하였습니다.
/Template/Versions를 실행하여 미리 등록했던 템플릿 버전확인을 해 보았습니다.

 

클라이언트 수정/테스트

 

접속할 서버를 변경할 때마다 ClientManager 프리팹에서 서버 주소를 직접 수정하지 않도록 ClientSettings 스크립트 추가하였습니다.
ClientSettingsScriptableObject를 상속받아 .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 파일을 이용하여 접속할 서버를 선택할 수 있도록 수정하였습니다.

public class ClientManager : MonoBehaviour
{
    [SerializeField]
    ClientSettings settings;

    public static ClientManager Instance
    {
        get;
        private set;
    }

    public string Enviroment => settings.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(settings.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)));
    }

    [ContextMenu("Reset Template Versions")]
    void ResetTemplateVersions() => TemplateService.ResetTemplateVersions(Enviroment);

    [ContextMenu("Reset Saved Auth")]
    void ResetSavedAuth() => AuthService.ResetSavedAuth();
}

 

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을 적용할 때 마우스 입력만 추가하였기 때문인 것 같습니다.
원래 모바일에서도 잘 돌아가면 프로젝트를 마무리 할 계획이었지만 입력 문제와 그 밖에 빌드 후 발생하는 다른 문제들도 찾아서 수정한 후 프로젝트를 마무리하겠습니다.

깃 허브 저장소 : taxi-game-3d-unity

728x90
LIST
728x90
SMALL

Localization 추가

Package Manager를 이용하여 Localization 패키지 추가하였습니다.

 

Project Settings에서 Localization을 아래와 같이 설정하였습니다.

  • 영어 로캐일(Locale), 한국어 로캐일 등록(영어, 한국어 지원)
  • System String Table을 추가하여 기본 문자열 테이블로 등록
  • 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로 저장하도록 구현하였습니다.

void Start()
{
    bgmToggle.ValueChangedEvent += (sender, value) =>
    {
        SoundManager.Instance.BgmVolume = value ? SoundManager.maxVolume : SoundManager.minVolume;
    };
    sfxToggle.ValueChangedEvent += (sender, value) =>
    {
        SoundManager.Instance.SfxVolume = value ? SoundManager.maxVolume : SoundManager.minVolume;
    };
    languageDropdown.ClearOptions();
    languageDropdown.AddOptions(
        LocalizationSettings.AvailableLocales.Locales.Select(
            l => l.Identifier.CultureInfo.NativeName
        ).ToList()
    );
    languageDropdown.value = LocalizationSettings
        .AvailableLocales
        .Locales
        .IndexOf(LocalizationSettings.SelectedLocale);
    languageDropdown.onValueChanged.AddListener(value =>
    {
        var locale = LocalizationSettings.AvailableLocales.Locales[value];
        if (LocalizationSettings.SelectedLocale == locale)
            return;
        LocalizationSettings.SelectedLocale = locale;
        PlayerPrefs.SetInt("SelctedLocale", value);
    });
    logoutButton.onClick.AddListener(() =>
    {
        ClientManager.Instance.AuthService.Logout();
        GameUI.Instance.HideAll();
        SceneManager.LoadScene(0);
    });
    closeButton.onClick.AddListener(() =>
    {
        gameObject.SetActive(false);
    });
}

 

이전에 영어로 변경한 후 게임을 재시작하면 모든 텍스트가 영어로 출력되도록 하는 기능을 LoginLogic 스크립트에 추가하였습니다.
SettingViewUI에서 PlayerPrebs를 이용하여 저장했던 인덱스 값을 불러와 로캐일을 선택하도록 구현하였습니다.

    IEnumerator Start()
    {
        if (GameUI.Instance != null)
            GameUI.Instance.HideAll();
        SoundManager.CreateInstance();
        SoundManager.Instance.StopBgm();
        ClientManager.CreateInstance();
        yield return LocalizationSettings.InitializationOperation;
        SelectLocale();
        Loading();
    }

    void SelectLocale()
    {
        var index = PlayerPrefs.GetInt("SelctedLocale", -1);
        var locales = LocalizationSettings.AvailableLocales.Locales;
        if (index < 0 || index >= locales.Count)
            return;
        LocalizationSettings.SelectedLocale = LocalizationSettings.AvailableLocales.Locales[index];
    }

 

 

다음에는 Azure에 서버를 등록하고, MongoDB Atlas로 DB를 만든 후, 게임을 빌드하는 과정을 정리한 후 프로젝트를 마무리하도록 하겠습니다.

 

구현 결과

 

깃 허브 저장소 : taxi-game-3d-unity

728x90
LIST
728x90
SMALL

이펙트 제작

TrailRenderer를 이용하여 타이어 자국 이펙트를 제작하였습니다.

 

타이어 자국 이펙트를 프리팹으로 만든 후 각각의 자동차 바퀴에 추가하였습니다.

 

TrailRenderer를 이용하여 제동등 이펙트를 제작하였습니다.

 

제동등 이펙트를 프리팹으로 만든 후 각각의 자동차 프리팹 뒤에 추가하였습니다.

 

속도가 줄어들 때 타이어 자국과 함께 출력될 연기 이펙트를 파티클 시스템을 이용하여 제작하였습니다.
연기 이미지를 따로 구하지 않고, 회색 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로 만든 제동등 이펙트는 이런 현상이 발생하지 않아 서로 번갈아가며 설정을 바꿔봤지만 해결되지 않아 포기하였습니다.

 

 

다음에는 Localization 패키지를 추가하여 다국어 처리 작업을 하겠습니다.

 

구현 결과

 

깃 허브 저장소 : taxi-game-3d-unity

728x90
LIST
728x90
SMALL

SoundManager 제작

배경음과 효과음의 볼륨을 관리할 수 있도록 AudioMixer 추가였습니다.
AudioMixerBGM 그룹과 SFX 그룹 추가한 후 볼륨을 조절할 수 있도록 BgmVolume, SfxVolume 파라미터도 추가하였습니다.

 

SoundManager 스크립트를 추가하였고, SoundManager 스크립트에는 아래 기능들을 구현하였습니다.

  • 배경음 출력용 AudioSource와 효과음 출력용 AudioSource를 1개씩 추가하여 배경음과 간단한 효과음을 재생할 수 있도록 구현
  • 배경음, 효과음 볼륨을 조절할 수 있도록 구현
  • 볼륨은 PlayerPrefs에 저장하여 어플을 종료하여도 조절했던 볼륨이 초기화되지 않도록 구현
  • 도중에 재생을 멈추거나 반복 재생을 해줘야 되는 효과음들도 있기 때문에 효과음 재생용 AudioSource를 생성하는 기능 구현
public class SoundManager : MonoBehaviour
{
    [SerializeField]
    AudioMixer audioMixer;
    [SerializeField]
    AudioSource bgmSource;
    [SerializeField]
    AudioSource sfxSource;

    public const float maxVolume = 0f;
    public const float minVolume = -80f;

    public static SoundManager Instance
    {
        get;
        private set;
    }

    public float BgmVolume
    {
        get
        {
            audioMixer.GetFloat("BgmVolume", out var value);
            return value;
        }
        set
        {
            var volume = Mathf.Clamp(value, minVolume, maxVolume);
            audioMixer.SetFloat("BgmVolume", volume);
            PlayerPrefs.SetFloat("BgmVolume", volume);
        }
    }

    public float SfxVolume
    {
        get
        {
            audioMixer.GetFloat("SfxVolume", out var value);
            return value;
        }
        set
        {
            var volume = Mathf.Clamp(value, minVolume, maxVolume);
            audioMixer.SetFloat("SfxVolume", volume);
            PlayerPrefs.SetFloat("SfxVolume", volume);
        }
    }

    void Awake()
    {
        Instance = this;
        DontDestroyOnLoad(gameObject);
        audioMixer.SetFloat("BgmVolume", PlayerPrefs.GetFloat("BgmVolume", maxVolume));
        audioMixer.SetFloat("SfxVolume", PlayerPrefs.GetFloat("SfxVolume", maxVolume));
        bgmSource.loop = true;
        bgmSource.playOnAwake = false;
        sfxSource.loop = false;
        sfxSource.playOnAwake = false;
    }

    public void PlayBgm(AudioClip clip)
    {
        if (clip == null)
            return;
        if (bgmSource.isPlaying && bgmSource.clip == clip)
            return;
        if (bgmSource.clip != clip)
            bgmSource.clip = clip;
        bgmSource.Play();
    }

    public void StopBgm()
    {
        bgmSource.Stop();
    }

    public void PauseBgm()
    {
        bgmSource.Pause();
    }

    public void ResumeBgm()
    {
        bgmSource.UnPause();
    }

    public void PlaySfx(AudioClip clip)
    {
        if (clip != null)
            sfxSource.PlayOneShot(clip);
    }

    public AudioSource CreateSfxSource(string name, Transform parent = null)
    {
        var source = Instantiate(sfxSource, parent);
        source.name = name;
        return source;
    }

    public static void CreateInstance()
    {
        if (Instance != null)
            return;
        Instantiate(Resources.Load(nameof(SoundManager)));
    }
}

 

Resources 폴더에 SoundManager 프리팹을 추가한 후 SoundManager 스크립트와 연결하였습니다.

 

배경음 구현

 

로그인 씬에서는 배경음을 재생하지 않도록 LoginLogic 스크립트를 수정하였습니다.

IEnumerator Start()
{
    if (GameUI.Instance != null)
        GameUI.Instance.HideAll();
    SoundManager.CreateInstance();
    SoundManager.Instance.StopBgm();
    ClientManager.CreateInstance();
    yield return new WaitForEndOfFrame();
    Loading();
}

 

게임 씬에서는 배경음을 재생하도록 구현 GameLogic 수정하였습니다.

void Start()
{
    GameUI.CreateInstance();
    SoundManager.CreateInstance();
    SoundManager.Instance.PlayBgm(bgmClip);
    npcCarManager.Play();
    foreach (var trigger in customerTriggers)
    {
        trigger.OnPlayerEntered += (sender, args) =>
        {
            OnCarEnterTrigger(sender as CustomerTrigger);
        };
    }
    RespawnPlayerCar();
    GameUI.Instance.OnGameLoaded();
}

 

각각의 게임 맵에 배경음으로 사용할 AudioClip을 연결하였습니다.

 

UI 효과음 추가

 

버튼을 클릭하였을 때 효과음이 발생하는 기능을 간단하게 구현해보기 위해 ButtonClickSFX 스크립트를 추가하였습니다.

[RequireComponent(typeof(Button))]
public class ButtonClickSFX : MonoBehaviour
{
    [SerializeField]
    AudioClip clip;

    void Start()
    {
        GetComponent<Button>().onClick.AddListener(() =>
        {
            SoundManager.Instance.PlaySfx(clip);
        });
    }
}

 

각각의 버튼 오브젝트에 ButtonClickSFX 스크립트를 연결하였습니다.

 

ResultViewUI 게임 성공시 배경음 일시 중단하고, 다른 효과음 나오도록 수정하였습니다.

public class ResultViewUI : MonoBehaviour
{
    [SerializeField]
    TMP_Text coinText;
    [SerializeField]
    Button claimButton;
    [SerializeField]
    AudioClip goalSfx;

    void Start()
    {
        claimButton.onClick.AddListener(() =>
        {
            gameObject.SetActive(false);
            GameLogic.LoadStage(ClientManager.Instance.UserService.User.CurrentStage.Index);
        });
    }

    void OnDisable()
    {
        coinText.text = "0";
        SoundManager.Instance.ResumeBgm();
    }

    public void Show(bool isGoal)
    {
        coinText.text = GameLogic.Instance.RewardedCoin.ToString();
        gameObject.SetActive(true);
        if (isGoal)
            StartCoroutine(PlayGoalSfx());
    }

    IEnumerator PlayGoalSfx()
    {
        SoundManager.Instance.PauseBgm();
        SoundManager.Instance.PlaySfx(goalSfx);
        yield return new WaitForSeconds(goalSfx.length);
        SoundManager.Instance.ResumeBgm();
    }
}

 

택시에 타고 있던 손님이 내린 후 새로운 새로운 손님이 택시를 부르는 메시지 창이 출력될 때 효과음이 나오도록 수정하였습니다.

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, 로그아웃을 구현하였습니다.

public class SettingsViewUI : MonoBehaviour
{
    [SerializeField]
    SimpleToggle bgmToggle;
    [SerializeField]
    SimpleToggle sfxToggle;
    [SerializeField]
    Button logoutButton;
    [SerializeField]
    Button closeButton;

    void Start()
    {
        bgmToggle.ValueChangedEvent += (sender, value) =>
        {
            SoundManager.Instance.BgmVolume = value ? SoundManager.maxVolume : SoundManager.minVolume;
        };
        sfxToggle.ValueChangedEvent += (sender, value) =>
        {
            SoundManager.Instance.SfxVolume = value ? SoundManager.maxVolume : SoundManager.minVolume;
        };
        logoutButton.onClick.AddListener(() =>
        {
            ClientManager.Instance.AuthService.Logout();
            GameUI.Instance.HideAll();
            SceneManager.LoadScene(0);
        });
        closeButton.onClick.AddListener(() =>
        {
            gameObject.SetActive(false);
        });
    }

    void OnEnable()
    {
        bgmToggle.SetValue(SoundManager.Instance.BgmVolume > SoundManager.minVolume, false);
        sfxToggle.SetValue(SoundManager.Instance.SfxVolume > SoundManager.minVolume, false);
    }
}

 

 

다음에는 자동차에 제동등, 타이어 자국 등의 이펙트를 추가하는 작업을 하겠습니다.

 

깃 허브 저장소 : taxi-game-3d-unity

728x90
LIST
728x90
SMALL

UI 출력용 자동차 프리팹 추가

기존에 만들었던 게임 플레이용 프리팹을 모두 복사한 후 아래 작업들 진행하였습니다.

  • PlayerCar, BoxCollider, Rigidbody, CinemachineVirtualCamera 제거
  • 왼쪽, 오른쪽을 구분하기 위해 배치 했던 빈 오브젝트 제거
  • 자동차의 중심축을 가운데로 변경
  • 레이어 UI로 수정

추가한 프리팹들은 Resources 폴더에 추가하였습니다.

 

추가한 프리팹을 게임에서 불러올 수 있도록 Car 템플릿을 수정하였습니다.
Car 템플릿에 있던 Prefab 컬럼 Player Prefab 으로 수정하고, Car 템플릿에 UI Prefab 컬럼을 추가하였습니다.

 

template_generator.py 수정한 후 DB에 수정한 템플릿을 새로 등록하였습니다.

def generate_car(file_path, sheet_name):
    book = load_workbook(file_path, data_only=True)
    sheet = book[sheet_name]
    skip = 0
    temp_group = []
    for row in sheet.rows:
        if skip < 2:
            skip += 1
            continue      
        if row[0].value == None:
            continue
        new_temp = {
            'Id': row[0].value,
            'Name': {
                'Table': row[1].value,
                'Key': row[2].value
            },
            'Icon': row[3].value,
            'Player Prefab': row[4].value,
            'UI Prefab': row[5].value,
            'Cost': int(row[6].value),
            'EnableReward': int(row[7].value)
        }
        temp_group.append(new_temp)
    return temp_group

 

클라이언트 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가 연결된 다른 카메라로 자동차 모델을 촬영하고, RenderTextureRawImage를 이용하여 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;
}

 

룰렛으로 획득한 자동차가 이미 획득했던 자동차인지 아닌지 구분할 수 있도록 UserServiceSpinRoulette 메소드를 수정하였습니다.

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);
    }
}

 

 

이번주는 밖으로 자주 나가서 글을 오랜만에 게시하였습니다.
다음 주도 밖에 자주 나갈 예정이어서 글을 많이 못 올릴 것 같습니다


다음에는 게임 사운드를 추가하고, 옵션창을 제작해보도록 하겠습니다.

 

깃 허브 저장소 : taxi-game-3d-unity

728x90
LIST
728x90
SMALL

원래 제가 참고하고 있는 코코스 스토어용 프로젝트처럼 자동차를 획득했을 때 자동차 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사이의 인덱스 값을 전달하도록 처리하였습니다.
클라이언트에서는 서버에서 받은 인덱스값을 이용하여 룰렛상품이 그려진 TransformZ축 각도를 아래와 같이 설정하도록 만들었습니다.

  • 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에 출력할 자동차 프리팹을 모두 완성하면 자동차 획득 알림창, 자동차 선택창에 적용하는 작업을 진행하겠습니다.
원래 다음 계획은 사운드 적용, 옵션창 제작을 할 계획이었지만 다다음으로 미루겠습니다.

 

깃 허브 저장소 : taxi-game-3d-unity

728x90
LIST
728x90
SMALL

이번에는 Devlog) Taxi Game 3D) 18) 서버 구현 3 에서 서버로만 구현했던 출석방과, 재화 자동수집 기능을 구현해보았습니다.

출석 보상 구현

일일보상 템플릿에 아이콘 컬럼에 실제 아이콘 경로를 입력하고, 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();
        }    
    }
}
public class DailyRewardEntryViewUI : MonoBehaviour, IPointerClickHandler
{
    [SerializeField]
    TMP_Text dayText;
    [SerializeField]
    Image iconImage;
    [SerializeField]
    TMP_Text amountText;
    [SerializeField]
    GameObject taken;
    [SerializeField]
    GameObject highlight;

    DailyRewardTemplate template;

    public DailyRewardTemplate Template
    {
        get => template;
        set
        {
            template = value;
            if (enabled)
                Refresh();
        }
    }

    void OnEnable()
    {
        Refresh();
    }

    public void Refresh()
    {
        if (template == null)
        {
            dayText.text = "0";
            iconImage.sprite = null;
            iconImage.gameObject.SetActive(false);
            amountText.text = null;
            taken.SetActive(false);
            highlight.SetActive(false);
        }

        var user = ClientManager.Instance.UserService.User;
        var day = template.Index + 1;
        dayText.text = day.ToString();
        iconImage.sprite = template.Type == DailyRewardType.Coin ?
            template.Icon :
            user.DailyCarRewards[day - 1].Icon;
        iconImage.gameObject.SetActive(iconImage.sprite != null);
        amountText.text = template.Type == DailyRewardType.Coin ?
            template.Amount.ToString() :
            null;
        taken.SetActive(day <= user.NumberOfAttendance);
        highlight.SetActive(day == user.NumberOfAttendance + 1);
    }

    void IPointerClickHandler.OnPointerClick(PointerEventData eventData)
    {
        ClientManager.Instance.UserService.Attendance();
        GameUI.Instance.Refresh();
    }
}

 

재화 수집 구현

 

ReadyViewUI 에서 얼마나 수집하였는지 출력하고, 수집버튼을 누르면 재화를 획득할 수 있도록 수정하였습니다.

void Start()
{
    playButton.onClick.AddListener(() =>
    {
        GameUI.Instance.ShowPlayView();
        GameLogic.Instance.PlayGame();
        gameObject.SetActive(false);
    });
    carListButton.onClick.AddListener(() =>
    {
        GameUI.Instance.ShowCarList();
    });
    attendanceButton.onClick.AddListener(() =>
    {
        GameUI.Instance.ShowDailyRewardList();
    });
    collectButton.onClick.AddListener(() =>
    {
        var userService = ClientManager.Instance.UserService;
        var collectMinutes = (DateTime.UtcNow - userService.User.CoinCollectedAtUtc).TotalMinutes;
        if (collectMinutes < 1.0)
            return;
        userService.CollectCoin();
        Refresh();
    });
}

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;
        return;
    }
    var collectAmount = Math.Truncate(collectMinutes);
    collectAmountText.text = collectAmount.ToString();
    collectProgress.fillAmount = Convert.ToSingle(collectMinutes - collectAmount);
}

 

아래 이미지의 게이지는 1분 단위로 한 바퀴를 돌도록 구현하였습니다.
(gif는 4배속으로 편집하였습니다.)

 

실제 구현하고 테스트했을 때 실제 획득한 금액과 메인화면 UI에 출력되는 금액이 달라 원인을 찾아 봤습니다.
UserServiceCollectCoin 메소드에서 최대 수집양을 넘기지 않도록 처리할 때 실수로 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에 모두 연결하지 않아 발생한 버그였기 때문에 스테이지들을 다시 확인하면서 트리거를 모두 연결하였습니다.

 

 

다음에는 룰렛을 구현하도록 하겠습니다.

 

깃 허브 저장소 : taxi-game-3d-unity

728x90
LIST

+ Recent posts