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
728x90
SMALL

원래는 출석 체크만 구현하다 서버에 다른 기능들도 한꺼번에 구현하고 마무리하기로 결정했습니다.
서버에 아래 기능들을 추가하였습니다.

  • 출석체크
    • 메일 접속하여 출석체크를 하면 보상을 지급
    • 세계 시간 기준으로 0시(한국 시간 오전 9시) 마다 초기화
    • 1일차 출석 후, N일 후에 접속하여 출석하면 2일차 출석으로 처리
  • 일일룰렛
    • 하루에 한 번 룰렛을 돌린다.
    • 룰렛을 돌리면 자동차를 지급
    • 이미 가진 자동차일 경우 자동차의 비용만큼 돈을 지급
    • 세계 시간 기준으로 0시(한국 시간 오전 9시) 마다 초기화
  • 자동수집(?)
    • 1분에 1개씩 돈을 수집
    • 오른쪽 하단에 코인 아이콘을 누르면 수집된 돈을 획득
    • 최대 수집량은 스테이지 템플릿에 입력

 

템플릿 추가, 수정

 

Template(Dev).xlsx 파일에 DailyReward 시트를 추가하여 출석 보상 정보를 입력하였습니다.
Type 1일 경우 돈을 보상으로 주고, Type 2일 경우 자동차를 렌덤하게 보상으로 지급하도록 구현할 계획입니다.

 

자동차의 가격은 모두 똑같지 않기 때문에 Car 시트를 수정하여 보상으로 지급할 자동차와 직접구매만 할 수 있는 자동차를 구분하기로 하였습니다.
Car 시트에 Enable Reward 컬럼을 추가하였습니다.

 

룰렛 보상도 Car 시트의 Enable Reward를 이용할 계획이기 때문에 따로 템플릿을 추가하지 않았습니다.

스테이지마다 최대로 돈을 수집할 수 있는 양을 다르게 처리하기 위해 Stage 시트에 Max Collect 컬럼을 추가하였습니다.

 

template_generator.py 파일을 수정하여 변경된 템플릿의 데이터들을 변환하도록 수정하였습니다.

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,
            'Prefab': row[4].value,
            'Cost': int(row[5].value),
            'EnableReward': int(row[6].value)
        }
        temp_group.append(new_temp)
    return temp_group

def generate_daily_reward(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 < 1:
            skip += 1
            continue  
        if row[0].value == None:
            continue
        new_temp = {
            'Type': int(row[1].value),
            'Icon': row[2].value,
            'Amount': int(row[3].value),
        }
        temp_group.append(new_temp)
    return temp_group

def generate_stage(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 < 1:
            skip += 1
            continue  
        if row[0].value == None:
            continue
        new_temp = {
            'Id': row[0].value,
            'Scene': row[1].value,
            'Distance': float(row[2].value),
            'FareRate': float(row[3].value),
            'MaxCollect': int(row[4].value)
        }
        temp_group.append(new_temp)
    return temp_group

 

서버 구현

 

서버 구현을 하다가 계속 ?연산자 사용하기 귀찮아서 Null 허용 설정 사용 안 함으로 수정하였습니다.

 

템플릿 정보를 저장하는 클래스들을 추가하거나 수정하였습니다.

public enum DailyRewardType
{
    Undefined = 0,
    Coin = 1,
    Car = 2
}

public class DailyRewardTemplate
{
    public DailyRewardType Type
    {
        get;
        set;
    }

    public int Amount
    {
        get;
        set;
    }
}

public class CarTemplate
{
    public string Id
    {
        get;
        set;
    }
    public int Cost
    {
        get;
        set;
    }
    [JsonConverter(typeof(BooleanConverter))]
    public bool EnableReward
    {
        get;
        set;
    }
}

 

MS에서 제공하는 JSON 라이브러리는 정수를 bool 값으로 자동 변환하지 않기 때문에 BooleanConverter를 추가하였습니다.

public class BooleanConverter : JsonConverter<bool>
{
    public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        switch (reader.TokenType)
        {
            case JsonTokenType.True:
                return true;
            case JsonTokenType.False:
                return false;
            case JsonTokenType.String:
                return reader.GetString()?.ToLower() switch
                {
                    "true" => true,
                    "false" => false,
                    _ => throw new JsonException()
                };
            case JsonTokenType.Number:
                return reader.GetInt16() != 0;
            default:
                throw new JsonException();
        }
    }

    public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
    {
        writer.WriteBooleanValue(value);
    }
}

 

UserModel에 새로운 프로퍼티를 추가하였습니다.

  • DailyCarRewards 일일보상으로 지급할 자동차
  • DailyRewardedAtUtc 출석보상 받은 날
  • NumberOfAttendance 출석횟수
  • CoinCollectedAtUtc 코인 자동수집 보상 받은 날
  • RouletteCarRewards 룰렛보상으로 지급할 자동차
  • RouletteSpunAtUtc 룰렛 돌린 날
    public class UserModel
    {
      [BsonId]
      [BsonRepresentation(BsonType.ObjectId)]
      public string Id
      {
          get;
          set;
      }
      public string Nickname
      {
          get;
          set;
      }
      public required string Email
      {
          get;
          set;
      }
      public required string Password
      {
          get;
          set;
      }
      public long Coin
      {
          get;
          set;
      }
      public List<string> Cars
      {
          get;
          set;
      }
      [BsonElement("CurrentCar")]
      [JsonPropertyName("CurrentCar")]
      public string CurrentCarId
      {
          get;
          set;
      }
      [BsonElement("CurrentStage")]
      [JsonPropertyName("CurrentStage")]
      public int CurrentStageIndex
      {
          get;
          set;
      }
      public Dictionary<string, string> DailyCarRewards
      {
          get;
          set;
      }
      [BsonDateTimeOptions(Kind = DateTimeKind.Utc)]
      public DateTime DailyRewardedAtUtc
      {
          get;
          set;
      }
      public short NumberOfAttendance
      {
          get;
          set;
      }
      [BsonDateTimeOptions(Kind = DateTimeKind.Utc)]
      public DateTime CoinCollectedAtUtc
      {
          get;
          set;
      }
      public List<string> RouletteCarRewards
      {
          get;
          set;
      }
      [BsonDateTimeOptions(Kind = DateTimeKind.Utc)]
      public DateTime RouletteSpunAtUtc
      {
          get;
          set;
      }
    }

 

UserController 클래스에서 Get 메소드를 수정한 후 Attendance, SpinRoulette, CollectCoin을 수정하였습니다.
Get은 이미 계정이 등록되어 새로운 기능에 필요한 데이터가 없는 유저들의 정보도 자동 추가되도록 처리하였습니다.
새로 추가한 기능들은 서버와 클라이언트가 통신하는 시간 때문에 오차가 발생할 수 있어 클라이언트에서 작업한 시간을 서버로 보내주도록 구현하였습니다.
서버에서는 클라이언트에서 보낸 시간을 저장하도록 처리하였습니다.
서버가 클라이언트에서 작업했던 시간만 저장해두면 클라이언트에서 시간조작을 하여 보상을 미리 받을 수는 있어도 이미 받았던 보상을 또 받을 수는 없을 거라고 생각하여 다른 작업은 하지 않았습니다.
룰렛은 서버에서 보상을 고른 후 클라이언트에 인덱스값만 전달하도록 구현하였습니다.

[HttpGet]
[ProducesResponseType<UserResponse>(200)]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public async Task<ActionResult<UserResponse>> Get()
{
    var userId = ClaimHelper.FindNameIdentifier(User);
    if (string.IsNullOrEmpty(userId))
        return Unauthorized();

    var user = await userRepository.Get(userId);
    if (user == null)
        return Forbid();

    var utcToday = DateTime.UtcNow.Date;
    var rand = new Random((int)DateTime.Now.Ticks);
    var cars = await templateService.GetCars();
    var carsForReward = cars
        .Where(e => e.EnableReward)
        .OrderBy(e => rand.Next())
        .ToList();
    var carIndex = 0;
    var dailyRewards = await templateService.GetDailyRewards();
    var needUpdate = false;

    // 자동차 지급
    if (user.Cars == null || user.Cars.Count == 0)
    {
        var newCar = cars?.FirstOrDefault(e => e.Cost == 0);
        if (newCar != null)
        {
            user.CurrentCarId = newCar?.Id;
            user.Cars = [ user.CurrentCarId! ];
            needUpdate = true;
        }
    }

    // 출석보상 생성
    // 어제 모든 보상을 다 받았다면 보상생성
    var rewardedAt = user.DailyRewardedAtUtc;
    if (
        rewardedAt <= DateTime.UnixEpoch ||
        (user.NumberOfAttendance >= dailyRewards.Count && utcToday > rewardedAt)
    )
    {
        user.NumberOfAttendance = 0;
        if (user.DailyCarRewards == null)
            user.DailyCarRewards = new();
        else
            user.DailyCarRewards.Clear();
        for (int i = 0; i < dailyRewards.Count; i++)
        {
            if (dailyRewards[i].Type == DailyRewardType.Car)
            {
                user.DailyCarRewards[i.ToString()] = carsForReward[carIndex].Id;
                carIndex = (carIndex + 1) % carsForReward.Count;
            }
        }
        // 출석체크하기 전까지 계속 데이터 갱신하지 않도록 처리
        if (rewardedAt <= DateTime.UnixEpoch)
            user.DailyRewardedAtUtc = utcToday.AddYears(-1);
        needUpdate = true;
    }

    if (user.CoinCollectedAtUtc <= DateTime.UnixEpoch)
    {
        user.CoinCollectedAtUtc = DateTime.UtcNow;
        needUpdate = true;
    }

    // 룰렛 보상 생성
    // 어제 룰렛을 돌렸다면 보상생성
    rewardedAt = user.RouletteSpunAtUtc;
    if (rewardedAt <= DateTime.UnixEpoch || utcToday > rewardedAt)
    {
        if (user.RouletteCarRewards == null)
            user.RouletteCarRewards = new();
        else
            user.RouletteCarRewards.Clear();
        const int rouletteCount = 6;
        for (int i = 0; i < rouletteCount; i++)
        {
            user.RouletteCarRewards.Add(carsForReward[carIndex].Id);
            carIndex = (carIndex + 1) % carsForReward.Count;
        }
        // 룰렛 돌리기 전까지 계속 데이터 갱신하지 않도록 처리
        if (rewardedAt <= DateTime.UnixEpoch)
            user.RouletteSpunAtUtc = utcToday.AddYears(-1);
        needUpdate = true;
    }

    if (needUpdate)
        _ = userRepository.Update(userId, user);

    return Ok(new UserResponse(user));
}

[HttpPut("Attendance")]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public async Task<IActionResult> Attendance([FromBody] DateRequest body)
{
    var userId = ClaimHelper.FindNameIdentifier(User);
    if (string.IsNullOrEmpty(userId))
        return Unauthorized();

    var user = await userRepository.Get(userId);
    if (user == null)
        return Forbid();

    var rewards = await templateService.GetDailyRewards();
    // 출석 완료함
    if (user.NumberOfAttendance >= rewards.Count)
        return StatusCode(StatusCodes.Status410Gone);

    // 오늘 이미 출석함
    if (body.UtcDate >= user.DailyRewardedAtUtc.Date.AddDays(1))
        return StatusCode(StatusCodes.Status410Gone);

    var r = rewards[user.NumberOfAttendance];
    if (r.Type == DailyRewardType.Coin)
    {
        // 돈 지급
        user.Coin += r.Amount;
    }
    else
    {
        var cars = await templateService.GetCars();
        // 미리 지급할 자동차를 지급
        if (!user.DailyCarRewards.TryGetValue(user.NumberOfAttendance.ToString(), out var carId))
            carId = cars.FirstOrDefault(e => e.EnableReward).Id;
        if (user.Cars.Contains(carId))
        {
            // 이미 가지고 있으면, 자동차 가격만큼 돈을 지급
            var car = cars.Find(e => e.Id == carId);
            user.Coin += car.Cost;
        }
        else
        {
            user.Cars.Add(carId);
        }
    }
    ++user.NumberOfAttendance;
    user.DailyRewardedAtUtc = body.UtcDate;
    await userRepository.Update(userId, user);

    return NoContent();
}

[HttpPut("SpinRoulette")]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public async Task<ActionResult<RouletteResponse>> SpinRoulette([FromBody] DateRequest body)
{
    var userId = ClaimHelper.FindNameIdentifier(User);
    if (string.IsNullOrEmpty(userId))
        return Unauthorized();

    var user = await userRepository.Get(userId);
    if (user == null)
        return Forbid();

    // 오늘 이미 룰렛 돌림
    if (body.UtcDate >= user.RouletteSpunAtUtc.Date.AddDays(1))
        return StatusCode(StatusCodes.Status410Gone);

    var cars = await templateService.GetCars();
    var index = new Random((int)DateTime.Now.Ticks).Next(user.RouletteCarRewards.Count);
    var car = cars.Find(e => e.Id == user.RouletteCarRewards[index]);
    if (car == null)
        return NotFound();

    if (user.Cars.Contains(car.Id))
        user.Coin += car.Cost;
    else
        user.Cars.Add(car.Id);
    user.RouletteSpunAtUtc = body.UtcDate;
    await userRepository.Update(userId, user);

    return Ok(new RouletteResponse
    {
        Index = index
    });
}

[HttpPut("CollectCoin")]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public async Task<IActionResult> CollectCoin([FromBody] DateRequest body)
{
    var userId = ClaimHelper.FindNameIdentifier(User);
    if (string.IsNullOrEmpty(userId))
        return Unauthorized();

    var user = await userRepository.Get(userId);
    if (user == null)
        return Forbid();

    if (body.UtcDate <= user.CoinCollectedAtUtc)
        return StatusCode(StatusCodes.Status410Gone);

    var stages = await templateService.GetStages();
    var stage = stages[user.CurrentStageIndex];
    int minutes = (int)(body.UtcDate - user.CoinCollectedAtUtc).TotalMinutes;
    if (minutes <= 0)
        return NoContent();

    user.Coin += Math.Min(minutes, stage.MaxCollect);
    user.CoinCollectedAtUtc = body.UtcDate;
    await userRepository.Update(userId, user);

    return NoContent();
}

 

클라이언트 통신 구현

 

UserModel을 출석, 룰렛, 수집 기능을 처리할 때 필요한 프로퍼티들을 추가하였습니다.

public class UserModel
{
    public string Nickname
    {
        get;
        set;
    }
    public long Coin
    {
        get;
        set;
    }
    public List<CarTemplate> Cars
    {
        get;
        set;
    }
    public CarTemplate CurrentCar
    {
        get;
        set;
    }
    public StageTemplate CurrentStage
    {
        get;
        set;
    }
    public Dictionary<int, CarTemplate> DailyCarRewards
    {
        get;
        set;
    }
    public DateTime DailyRewardedAtUtc
    {
        get;
        set;
    }
    public short NumberOfAttendance
    {
        get;
        set;
    }
    public DateTime CoinCollectedAtUtc
    {
        get;
        set;
    }

    public List<CarTemplate> RouletteCarRewards
    {
        get;
        set;
    }
    public DateTime RouletteSpunAtUtc
    {
        get;
        set;
    }
}

 

UserService 클래스를 Load 메소드를 수정한 후 Attendance, SpinRoulette, CollectCoin을 수정하였습니다.
룰렛은 서버에서 응답해주기 전까지 보상으로 뭘 받을지 알 수 없기 때문에 서버와의 통신을 완료하면 클라이언트 정보를 수정하도록 구현하였습니다.

public async UniTask<HttpStatusCode> Load()
{
    var res = await http.Get<UserResponse>("User");
    if (!res.Item1.IsSuccess())
    {
        Debug.LogWarning($"Load user failed. - {res.Item1}");
        return res.Item1;
    }
    User = new()
    {
        Nickname = res.Item2.Nickname,
        Coin = res.Item2.Coin,
        Cars = res.Item2.Cars
            .Select(id => templateService.Cars.Find(e => e.Id == id))
            .ToList(),
        CurrentCar = templateService.Cars
            .Find(e => e.Id == res.Item2.CurrentCar),
        CurrentStage = templateService.Stages[res.Item2.CurrentStage],
        DailyCarRewards = new(res.Item2.DailyCarRewards.Select(
            e => new KeyValuePair<int, CarTemplate>(
                int.Parse(e.Key),
                templateService.Cars.Find(c => c.Id == e.Value)
            )
        )),
        DailyRewardedAtUtc = res.Item2.DailyRewardedAt,
        NumberOfAttendance = res.Item2.NumberOfAttendance,
        CoinCollectedAtUtc = res.Item2.CoinCollectedAt,
        RouletteCarRewards = res.Item2.RouletteCarRewards
            .Select(id => templateService.Cars.Find(e => e.Id == id))
            .ToList(),
        RouletteSpunAtUtc = res.Item2.RouletteSpunAt
    };
    return res.Item1;
}

public async void Attendance()
{
    var now = DateTime.UtcNow;
    if (User.NumberOfAttendance >= templateService.DailyRewards.Count)
        return;
    if (now >= User.DailyRewardedAtUtc.Date.AddDays(1))
        return;
    var reward = templateService.DailyRewards[User.NumberOfAttendance];
    if (reward.Type == DailyRewardType.Coin)
    {
        User.Coin += reward.Amount;
    }
    else
    {
        User.DailyCarRewards.TryGetValue(User.NumberOfAttendance, out var carTemp);
        if (User.Cars.Contains(carTemp))
            User.Coin += carTemp.Cost;
        else
            User.Cars.Add(carTemp);
    }
    ++User.NumberOfAttendance;
    User.DailyRewardedAtUtc = now;

    var res = await http.Put($"User/Attendance", new DateRequest
    {
        UtcDate = now
    });
    if (!res.IsSuccess())
    {
        Debug.LogWarning($"Attendance failed. - {res}");
        UserUpdateFailed?.Invoke(this, EventArgs.Empty);
    }
}

public async UniTask<int> 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;
    }
    User.RouletteSpunAtUtc = now;
    var car = User.RouletteCarRewards[res.Item2.Index];
    if (User.Cars.Contains(car))
        User.Coin += car.Cost;
    else
        User.Cars.Add(car);
    return res.Item2.Index;
}

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

 

구현한 기능들이 잘 되는지는 실제 UI작업을 할 때 테스트하면서 확인하도록 하겠습니다.

 

지난주부터 어제까지는 밖으로 자주 돌아다녀 8일 만에 글을 게시하였습니다.

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

728x90
LIST
728x90
SMALL

이번에는 손님을 탔을 때, 손님이 내리고 이동을 시작했을 때 대화상자를 출력하는 기능을 구현하였습니다.

 

대화상자가 아래 조건에 의해 출력됩니다.

  • 손님이 타면 XXX에 태워 달라는 내용의 메시지가 출력 된다.
  • 목적지에 손님이 내린 후 다음 손님이 있다면 와 달라는 내용의 메시지가 출력된다.

 

템플릿 추가

 

Devlog) Taxi Game 3D) 10) 자동차 제작 에서 자동차 아이콘을 만들었을 때와 동일한 방법으로 손님 아이콘을 만들었습니다.

 

Template(Dev).xlsx 파일에 손님들의 아이콘 이미지와 프리팹 정보가 입력된 Customer 시트를 추가하였습니다.

 

Template(Dev).xlsx 파일에 메시지 창에 출력할 텍스트 정보를 저장한 Talk 시트를 추가하였습니다.

 

Type 1은 택시에 탄 손님의 대사이고, Type 2는 다음 손님의 대사입니다.
대사들은 코코스 크리에이터 샘플 프로젝트에 적힌 대사를 그대로 사용하였습니다.
지금은 Key 컬럼에 대사를 그대로 입력하였지만 나중에 유니티에서 제공하는 Localization을 이용하여 다국어 처리를 하게 되면 수정할 계획입니다.

 

template_generator.py 파일에 새로 추가한 Customer, Talk 시트를 JSON으로 변환하는 함수를 추가하였습니다.

def generate_customer(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 < 1:
            skip += 1
            continue  
        if row[0].value == None:
            continue
        new_temp = {
            'Id': row[0].value,
            'Icon': row[1].value,
            'Prefab': row[2].value,
        }
        temp_group.append(new_temp)
    return temp_group

def generate_talk(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 < 1:
            skip += 1
            continue  
        if row[0].value == None:
            continue
        new_temp = {
            'Type': int(row[0].value),
            'Content': {
                'Table': row[1].value,
                'Key': row[2].value
            }
        }
        temp_group.append(new_temp)
    return temp_group

 

기존에 변환된 데이터들을 클라이언트에 배포하던 파이썬 스크리립트들은 더 이상 필요없기 때문에 지우고, deploy-customer-server.py, deploy-talk-server.py 파일을 새로 추가하였습니다.

import requests
from template_generator import generate_customer

temp_group = generate_customer('./Template(Dev).xlsx', 'Customer')
res = requests.put('https://localhost:7170/Template/Customer', json=temp_group, verify=False)

print(res.status_code)
import requests
from template_generator import generate_talk

temp_group = generate_talk('./Template(Dev).xlsx', 'Talk')
res = requests.put('https://localhost:7170/Template/Talk', json=temp_group, verify=False)

print(res.status_code)

 

서버 템플릿 적용

 

서버 프로젝트에서는 새로운 템플릿 데이터를 등록할 수 있도록 TemplateService를 수정하였습니다.
하지만 새로 추가한 템플릿들은 서버에서는 필요없기 때문에 메모리에 저장해두는 기능까지 구현하지 않았습니다.

public async Task<ulong> Update(string name, JsonArray datas)
{
    switch (name)
    {
        case "Car":
            cars = datas.Deserialize<List<CarTemplate>>();
            break;
        case "Stage":
            stages = datas.Deserialize<List<StageTemplate>>();
            break;
        // 클라이언트 전용
        case "Customer":
        case "Talk":
            break;
        default:
            return 0;
    }

    var verModel = await versions.Find(e => e.Name == name).FirstOrDefaultAsync();
    if (verModel != null)
    {
        ++verModel.Version;
        await versions.ReplaceOneAsync(e => e.Name == name, verModel);
        await templates.ReplaceOneAsync(e => e.Name == name, new()
        {
            Name = name,
            Datas = BsonSerializer.Deserialize<BsonArray>(datas.ToJsonString())
        });
        return verModel.Version;
    }
    else
    {
        await versions.InsertOneAsync(new()
        {
            Name = name,
            Version = 1
        });
        await templates.InsertOneAsync(new()
        {
            Name = name,
            Datas = BsonSerializer.Deserialize<BsonArray>(datas.ToJsonString())
        });
        return 1;
    }
}

 

서버를 실행한 후 파이썬 스크립트를 실행하여 템플릿을 DB에 등록하였습니다.

 

클라이언트 템플릿 적용

 

클라이언트에서 새로 추가한 템플릿 정보를 사용할 수 있도록 CustomerTemplate, TalkTemplate 클래스를 추가한 후 TemplateService 클래스를 수정하였습니다.

public class CustomerTemplate
{
    public string Id { get; set; }
    [JsonProperty("Icon")]
    public string IconPath { get; set; }
    [JsonIgnore]
    public Sprite Icon => Resources.Load<Sprite>(IconPath);
    [JsonProperty("Prefab")]
    public string PrefabPath { get; set; }
    [JsonIgnore]
    public GameObject Prefab => Resources.Load<GameObject>(PrefabPath);
}
public enum TalkType
{
    Undefined = 0,
    Request = 1,
    Call = 2
}

public class TalkTemplate
{
    [JsonIgnore]
    public int Index { get; set; }
    public TalkType Type { get; set; }
    public LocalizationTemplate Content { get; set; }
}
public class TemplateService : MonoBehaviour
{
    string enviroment;
    HttpContext http;

    public List<CarTemplate> Cars
    {
        get;
        private set;
    }

    public List<CustomerTemplate> Customers
    {
        get;
        private set;
    }

    public List<StageTemplate> Stages
    {
        get;
        private set;
    }

    public List<TalkTemplate> Talks
    {
        get;
        private set;
    }

    void Start()
    {
        enviroment = ClientManager.Instance?.Enviroment;
        http = ClientManager.Instance?.Http;
    }

    public async UniTask<bool> LoadAll()
    {
        var res = await http.Get<Dictionary<string, ulong>>("Template/Versions");
        if (!res.Item1.IsSuccess())
        {
            Debug.LogError($"Load template versions failed. - {res.Item1}");
            return false;
        }

        if (!res.Item2.TryGetValue("Car", out var version))
            return false;
        Cars = await Load<CarTemplate>("Car", version);
        if (Cars == null)
        {
            Debug.LogWarning("Load car templates failed.");
            return false;
        }
        for (int i = 0; i < Cars.Count; i++)
            Cars[i].Index = i;

        if (!res.Item2.TryGetValue("Customer", out version))
            return false;
        Customers = await Load<CustomerTemplate>("Customer", version);
        if (Customers == null)
        {
            Debug.LogWarning("Load customer templates failed.");
            return false;
        }

        if (!res.Item2.TryGetValue("Stage", out version))
            return false;
        Stages = await Load<StageTemplate>("Stage", version);
        if (Stages == null)
        {
            Debug.LogWarning("Load stage templates failed.");
            return false;
        }
        for (int i = 0; i < Stages.Count; i++)
            Stages[i].Index = i;

        if (!res.Item2.TryGetValue("Talk", out version))
            return false;
        Talks = await Load<TalkTemplate>("Talk", version);
        if (Talks == null)
        {
            Debug.LogWarning("Load talk templates failed.");
            return false;
        }
        for (int i = 0; i < Talks.Count; i++)
            Talks[i].Index = i;

        return true;
    }

    async UniTask<List<T>> Load<T>(string name, ulong remoteVersion)
    {
        var path = $"{enviroment}/TemplateVersions/{name}";
        var versionText = PlayerPrefs.GetString(path, "0");
        ulong.TryParse(versionText, out var localVersion);
        if (localVersion >= remoteVersion)
            return LoadFromLocal<T>(name);
        var res = await http.Get<List<T>>($"Template/{name}");
        if (!res.Item1.IsSuccess())
        {
            Debug.LogWarning($"Load {name} template failed. - {res.Item1}");
            return res.Item2;
        }
        SaveToLocal(name, res.Item2);
        PlayerPrefs.SetString(path, remoteVersion.ToString());
        return res.Item2;
    }

    List<T> LoadFromLocal<T>(string name)
    {
        try
        {
            var path = $"{Application.persistentDataPath}/{enviroment}/Templates/{name}";
            var content = File.ReadAllText(path);
            return JsonConvert.DeserializeObject<List<T>>(content);
        }
        catch (Exception ex)
        {
            Debug.LogException(ex);
            return default;
        }
    }

    void SaveToLocal(string name, object content)
    {
        try
        {
            var directory = $"{Application.persistentDataPath}/{enviroment}/Templates";
            if (!Directory.Exists(directory))
            Directory.CreateDirectory(directory);
            var path = $"{directory}/{name}";
            if (!File.Exists(path))
            File.Create(path).Close();
            File.WriteAllText(path, JsonConvert.SerializeObject(content));
        }
        catch (Exception ex)
        {
            Debug.LogException(ex);
        }
    }

    public static void ResetTemplateVersions(string enviroment)
    {
        PlayerPrefs.DeleteKey($"{enviroment}/TemplateVersions/Car");
        PlayerPrefs.DeleteKey($"{enviroment}/TemplateVersions/Customer");
        PlayerPrefs.DeleteKey($"{enviroment}/TemplateVersions/Stage");
        PlayerPrefs.DeleteKey($"{enviroment}/TemplateVersions/Talk");
    }
}

 

CustomerManager는 손님 오브젝트를 생성할 때, 미리 등록해둔 프리팹 대신 템플릿에 등록된 정보를 이용하도록 수정하였습니다.

그리고 지금 타고 있는 손님정보말고, 다음에 탈 손님의 정보도 저장할 수 있도록 CurrentCustomerIndex, NextCustomerIndex 프로퍼티를 추가하였습니다.

public class CustomerManager : MonoBehaviour
{
    Customer[] customers;

    public int CurrentCustomerIndex
    {
        get;
        private set;
    } = -1;

    public int NextCustomerIndex
    {
        get;
        private set;
    } = -1;

    public bool WasCustomerTaken
    {
        get;
        private set;
    }

    void Start()
    {
        customers = new Customer[ClientManager.Instance.TemplateService.Customers.Count];
        NextCustomerIndex = Random.Range(0, customers.Length);
    }

    public IEnumerator TakeIn(Transform startPoint, PlayerCar car, bool isLast)
    {
        CurrentCustomerIndex = NextCustomerIndex;
        NextCustomerIndex = !isLast ? Random.Range(0, customers.Length) : -1;

        var customer = Spawn(startPoint.position, startPoint.rotation);
        var endPoint = car.SelectNearestPoint(startPoint.position);

        yield return null;

        customer.MoveTo(endPoint.position);
        while (customer.IsMoving)
            yield return null;

        customer.gameObject.SetActive(false);
        WasCustomerTaken = true;
    }

    public IEnumerator TakeOut(Transform endPoint, PlayerCar car)
    {
        var customer = customers[CurrentCustomerIndex];
        customer.gameObject.SetActive(true);
        var startPoint = car.SelectNearestPoint(endPoint.position);
        customer.transform.SetPositionAndRotation(
            startPoint.position, startPoint.rotation
        );

        yield return null;
        customer.MoveTo(endPoint.position);
        while (customer.IsMoving)
            yield return null;

        customer.gameObject.SetActive(false);
        CurrentCustomerIndex = -1;
        WasCustomerTaken = false;
    }

    Customer Spawn(Vector3 position, Quaternion rotation)
    {
        if (customers[CurrentCustomerIndex] == null)
        {
            var prefab = ClientManager.Instance.TemplateService.Customers[CurrentCustomerIndex].Prefab;
            var go = Instantiate(prefab);
            customers[CurrentCustomerIndex] = go.GetComponent<Customer>();
            customers[CurrentCustomerIndex].OnTakeIn += (sender, args) =>
            {
                if (!WasCustomerTaken)
                    (sender as Customer).StopMove();
            };
        }
customers[CurrentCustomerIndex].transform.SetPositionAndRotation(position, rotation);
        customers[CurrentCustomerIndex].gameObject.SetActive(true);
        return customers[CurrentCustomerIndex];
    }
}

 

UI 제작

 

먼저 UGUI를 이용하여 대화창 UI를 만들었습니다.

 

TalkViewUI 클래스를 구현하여 손님 아이콘과 대사를 출력하도록 구현하였습니다.

public class TalkViewUI : MonoBehaviour
{
    [SerializeField]
    Image customerIconImage;
    [SerializeField]
    TMP_Text talkContentText;

    public void Show(int customerIndex, int talkIndex)
    {
        var templateService = ClientManager.Instance.TemplateService;
        customerIconImage.sprite = templateService.Customers[customerIndex].Icon;
        talkContentText.text = templateService.Talks[talkIndex].Content.Key;
    }
}

 

추가한 TextViewUIPlayViewUI에서 조작하도록 구현하였습니다.
손님이 내렸을 때 발생하는 이벤트는 손님이 자동차에서 나오자마자 발생하기 때문에 2초 후에 다음 손님의 메시지를 출력하도록 구현하였습니다.

public class PlayViewUI : MonoBehaviour
{
    [SerializeField]
    StageProgressViewUI stageProgressView;
    [SerializeField]
    TalkViewUI talkView;
    [SerializeField]
    GuideViewUI guideView;
    [SerializeField]
    ResultViewUI resultView;

    void OnEnable()
    {
        talkView.gameObject.SetActive(false);
        guideView.gameObject.SetActive(true);
    }

    public void OnGameLoaded()
    {
        stageProgressView.Init();
        resultView.gameObject.SetActive(false);

        GameLogic.Instance.CustomerTakeInEvent += (sender, customer) =>
        {
            StopCoroutine("ShowTalkView");
            talkView.gameObject.SetActive(false);
            var ci = GameLogic.Instance.CustomerManager.CurrentCustomerIndex;
            var ti = ClientManager.Instance.TemplateService.Talks
                .Where(e => e.Type == TalkType.Request)
                .OrderBy(e => Random.value)
                .FirstOrDefault()?.Index ?? 0;
            StartCoroutine(ShowTalkView(0f, ci, ti));
        };
        GameLogic.Instance.CustomerTakeOutEvent += (sender, customer) =>
        {
            StopCoroutine("ShowTalkView");
            talkView.gameObject.SetActive(false);
            var ci = GameLogic.Instance.CustomerManager.NextCustomerIndex;
            if (ci < 0)
                return;
            var ti = ClientManager.Instance.TemplateService.Talks
                .Where(e => e.Type == TalkType.Call)
                .OrderBy(e => Random.value)
                .FirstOrDefault()?.Index ?? 0;
            StartCoroutine(ShowTalkView(2f, ci, ti));
        };
        GameLogic.Instance.GameEndedEvent += (sender, isGoal) =>
        {
            resultView.gameObject.SetActive(true);
        };
    }

    IEnumerator ShowTalkView(float delay, int customerIndex, int talkIndex)
    {
        talkView.Show(customerIndex, talkIndex);
        if (delay > 0)
            yield return new WaitForSecondsRealtime(delay);
        else
            yield return new WaitForEndOfFrame();
        talkView.gameObject.SetActive(true);
        yield return new WaitForSecondsRealtime(2f);
        talkView.gameObject.SetActive(false);
    }
}

 

 

 

다음에는 출석보상 기능을 구현하겠습니다.

 

구현 결과

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

728x90
LIST

+ Recent posts