개발노트/Taxi Game 3D

Devlog) Taxi Game 3D) 17) 손님 대화 구현

username263 2024. 1. 9. 15:57
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