728x90
SMALL

1. 템플릿 제작

 

다음 스테이지 이동, 자동차 선택 등의 기능을 구현하기 전에 템플릿을 제작해보겠습니다.
팀마다 부르는 법이 다르긴 했지만, 엑셀에 미리 입력했던, 게임 중에 수정되지 않는 데이터 템플릿이라 불렀던 팀도 있었습니다.
그래서 이 프로젝트에서 엑셀에서 미리 입력해둔 데이터를 템플릿이라고 부르기로 했습니다.

처음에는 ScriptableObject이용해 만들려고 했지만 아래 단점들 때문에 포기하고 엑셀을 사용하기로 하였습니다.

  • 서버를 유니티로 만들지 않는 이상 서버에서 쓸 수 없다.
  • 개발 도중에 소스코드를 바꿨다가 힘들게 입력했던 데이터들이 날아 갈 수 있다.

 

프로젝트 폴더에 taxi-game-3d-templates 폴더 추가하였습니다.
taxi-game-3d-templates 폴더에 Template(Dev).xlsx 파일을 추가하였습니다.
리브레 오피스로 xlsx파일을 열었을 때 생기는 lock 파일을 깃 허브에 올려버리지 않도록 아래 내용을 적은 .gitignore 파일을 추가하였습니다.
(저는 크랙을 찾기 귀찮아서 개인적으로 하드에 저장된 xlsx파일을 수할 때는 LibreOffic Calc사용합니다.)

.~lock.*.xlsx#

 

xlsx 파일에 Car 시트를 추가한 후 임시로 데이터를 입력하였습니다.

 

xlsx 파일에 Stage 시트를 추가한 후 임시로 데이터를 입력하였습니다.

 

그리고 위에 입력한 데이터들은 파이썬을 이용하여 JSON으로 변환한 후 리소스 폴더에 넣는 작업을 하기로 하였습니다.
과거에 유니티 에디터를 이용하여 xlsx파일을 JSON이나 BSON으로 변환하도록 구현하였습니다.
하지만 게임에 들어가는 정보가 많아져 시트 하나에 몇 만줄되어 버렸고, 그 데이터를 불러와서 변환하다가 유니티 에디터가 멈춘 후, 강제종료되어 버렸습니다.
이 문제를 급하게 해결하기 위해 csv로 바꿨던 경험이 있었기 때문에 이번에는 파이썬을 사용해보기로 하였습니다.

 

파이썬은 왜 설치했었는지 까먹었지만 어쨌든 설치되어 있어 설치과정을 생략하겠습니다.
콘솔창을 열어 아래 문구를 입력하여 openpyxl을 설치하였습니다.

pip install openpyxl

 

taxi-game-3d-templates 폴더에 deploy-car-dev.py 파일을 추가하여 간단하게 JSON으로 변환하는 기능을 테스트 해보았습니다.

import json
from openpyxl import load_workbook

book = load_workbook('./Template(Dev).xlsx', data_only=True)
sheet = book['Car']
  
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': row[5].value
    }
    temp_group.append(new_temp)

with open('car.json', 'w') as f:
    json.dump(temp_group, f)

 

한글이 이상하게 나왔습니다.

[{"Id": "car1", "Name": {"Table": null, "Key": "\ucc281"}, "Icon": "icons/car1.png", "Prefab": null, "Cost": 0}, {"Id": "car2", "Name": {"Table": null, "Key": "\ucc282"}, "Icon": "icons/car2.png", "Prefab": null, "Cost": 1000}, {"Id": "car3", "Name": {"Table": null, "Key": "\ucc283"}, "Icon": "icons/car3.png", "Prefab": null, "Cost": 2000}]

 

인터넷에서 해결방법을 찾아 수정하였습니다.

with open('car.json', 'w', encoding='UTF-8-sig') as f:
    json.dump(temp_group, f, ensure_ascii=False)
[{"Id": "car1", "Name": {"Table": null, "Key": "차1"}, "Icon": "icons/car1.png", "Prefab": null, "Cost": 0}, {"Id": "car2", "Name": {"Table": null, "Key": "차2"}, "Icon": "icons/car2.png", "Prefab": null, "Cost": 1000}, {"Id": "car3", "Name": {"Table": null, "Key": "차3"}, "Icon": "icons/car3.png", "Prefab": null, "Cost": 2000}]

 

유니티 리소스 폴더에 json파일을 옮기는 기능을 구현하기 전에 미리 json을 저장할 폴더를 만들어 두었습니다.(taxi-game-3d-client/Assets/_TaxiGame/Resources/Templates)
taxi-game-3d-client/Assets/_TaxiGame/Resources/Templates 폴더에 변환된 json을 저장하도록 수정하였습니다.

client_path = '../taxi-game-3d-client/Assets/_TaxiGame/Resources/Templates/Car.json'
with open(client_path, 'w', encoding='UTF-8-sig') as f:
    json.dump(temp_group, f, ensure_ascii=False)

 

동일한 방법으로 deploy-stage-dev.py 파일을 만들어 스테이지 정보도 전달할 수 있도록 구현하였습니다.
그리고 각각의 파일들을 따로 실행하지 않고, 한꺼번에 실행할 수 있도록 deploy-all-client.bat 파일도 만들었습니다.

python deploy-car-client.py
python deploy-stage-client.py

 

2. 클라이언트 연동

 

다음으로 클라이언트에서 json파일들을 불러오는 기능을 구현하였습니다.
Assets/_TaxiGame/Scripts/Templates 폴더 추가한 후 CarTemplate.cs, StageTemplate.cs, LocalizationTemplate.cs, TemplateManager.cs 추가하였습니다.
JSON은 개인적으로 사용하기 편했던 Newtonsoft.Json을 사용하기로 하였습니다.
Package Manager를 통해 Newtonsoft.Json 추가하였습니다.

 

새로 추가한 소스코드에 내용을 작성하였습니다.

  • TemplateManager.cs
public class TemplateManager : MonoBehaviour
{
    public static TemplateManager Instance
    {
        get;
        private set;
    }

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

    public List<StageTamplate> Stages
    {
        get;
        private set;
    }
    void Awake()
    {
        Instance = this;
        DontDestroyOnLoad(gameObject);

        Cars = Load<CarTemplate>("Templates/Car");
        Stages = Load<StageTamplate>("Templates/Stage");
    }
    List<T> Load<T>(string path)
    {
        var asset = Resources.Load<TextAsset>(path);
        return JsonConvert.DeserializeObject<List<T>>(asset.text);
    }
}
  • CarTemplate.cs
public class CarTemplate
{
    public string Id { get; set; }
    public LocalizationTemplate Name { get; set; }
    [JsonProperty("Icon")]
    public string IconPath { get; set; }
    [JsonProperty("Prefab")]
    public string PrefabPath { get; set; }
    public int Cost { get; set; }
}
  • StageTemplate.cs
public class StageTamplate
{
    public string Id { get; set; }
    [JsonProperty("Scene")]
    public string SceneName { get; set; }
}
  • LocalizationTemplate.cs
public class LocalizationTemplate
{
    public string Table { get; set; }
    public string Key { get; set; }
}

 

GameLogic 클래스에 템플릿이 잘 읽어지는지 테스트하는 코드 추가하였습니다.

if (TemplateManager.Instance == null)
    new GameObject("TemplateManager", typeof(TemplateManager));

foreach (var carTemp in TemplateManager.Instance.Cars)
    Debug.Log(JsonConvert.SerializeObject(carTemp));
foreach (var stageTemp in TemplateManager.Instance.Stages)
    Debug.Log(JsonConvert.SerializeObject(stageTemp));

구현 결과

 

다음에는 자동차 프리팹과 스테이지 씬들을 만들어 템플릿에 실제로 입력할 계획입니다.

 

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

 

728x90
LIST
728x90
SMALL

 

1. 요금 획득 구현

먼저 요금 계산을 위한 공식을 먼저 고민하여 아래와 같은 공식을 만들어 봤습니다.

  • 승객을 태우고 이동한 거리
    • d = b − a
      • a : 승객이 탑승한 위치
      • b : 승객이 하차한 위치
      • , 모두 Bézier Path Creator 이용하여 계산한 이동량을 저장
  • 요금 계산
    • m = d × ((70+i) / 100)
      • m : 택시 요금(소수점 뒤는 버림)
      • d : 승객을 태우고 이동한 거리
      • i : 스테이지 인덱스(배열 인덱스 개념이기 때문에 0부터 시작)
    • 승객을 태우고 이동한 거리의 70%를 요금으로 지급
    • 스테이지가 증가할 수록 1%씩 증가

그리고 GameLogicTakeOut 메소드에 위의 공식을 이용하여 계산한 요금을 획득하는 코드를 추가하였습니다.

var distance = PlayerCar.Movement - customerTakePos;
var reward = Mathf.FloorToInt(
    distance * ((70f + stageIndex) / 100f)
);
if (reward > 0)
    coin += reward;

 

2. CustomerTrigger 클래스 수정

 

각각의 클래스들이 최대한 각자도생을 하는 구조로 만들어 보기 위해 기존에 GameLogic.Instance.OnCarEnterTrigger 대신 EventHandler를 사용하도록 수정하였습니다.

public event EventHandler OnPlayerEntered;

void OnTriggerEnter(Collider other)
{
    if (other.CompareTag("Player"))
        OnPlayerEntered?.Invoke(this, EventArgs.Empty);
}

 

3. 승객 구현

 

코코스 크리에이터 샘플에서 제공하는 캐릭터 모델

 

어차피 자동차 모델도 다 바꿨고, 샘플에서 제공하는 캐릭터 모델들도 개인적으로 안 귀여워 보여서 에셋 스토어에서 Low Poly 3D Animated Donguri Brothers 에셋을 추가하였습니다.

 

새로 추가한 캐릭터 모델도 마젠타색으로 출력되어 머테리얼을 모두 Universal Renderer Pipeline/Lit로 변경하였습니다.

 

CapsuleCollider, RigidbodyCustomer클래스를 만들어 새로 추가한 캐릭터 모델에 연결하여 승객 프리팹을 만들었습니다.

 

승객 프리팹 택시와 물리적 충돌을 일으키지 않도록 CapsuleColliderIs Trigger 체크하였습니다.
그리고 바닥을 뚫고 떨어지지 않도록 RigidbodyIs Kinematic 체크하였습니다.

 

캐릭터 모델에 연결되어 있던 Donguri_Controller.controller 파일을 복사하여 Customer_Controller.controller 파일을 만든 후 IsMoving 파라미터를 이용하여 Idle 동작을 할지 Run 동작을 할지 선택하도록 설정하였습니다.

 

승객이 왼쪽 문으로 들어가고 나갈지, 오른쪽 문으로 들어가고 나갈지 처리하기 위해 자동차 프리팹에 LeftPoint, RightPoint 라고 이름 지은 빈 오브젝트를 추가하여 PlayerCar와 연결하였습니다.

 

PlayerCar 클래스에서 차량의 승객이 등장하는 위치를 시작점과의 거리가 왼쪽, 오른쪽 중 더 가까운 곳을 계산하는 메소드를 추가하였습니다.

public Transform SelectNearestPoint(Vector3 poisition)
{
    var left = (LeftPoint.position - poisition).sqrMagnitude;
    var right = (RightPoint.position - poisition).sqrMagnitude;
    return left < right ? LeftPoint : RightPoint;
}

 

CustomerManager 클래스를 추가하여 씬에 빈 오브젝트를 만들어 연결한 후 배치하였습니다.
먼저 만들어 두었던 승객 프리팹들을 CustomerManager에 연결하여 승객이 렌덤하게 등장하도록 구현하였습니다.

Customer Spawn(Vector3 position, Quaternion rotation)
{
    customerIndex = Random.Range(0, customers.Length);
    if (customers[customerIndex] == null)
    {
        var go = Instantiate(customerPrefabs[customerIndex]);
        customers[customerIndex] = go.GetComponent<Customer>();
        customers[customerIndex].OnTakeIn += (sender, args) =>
        {
            if (!WasCustomerTaken)
                (sender as Customer).StopMove();
        };
    }

    customers[customerIndex].transform.SetPositionAndRotation(position, rotation);
    customers[customerIndex].gameObject.SetActive(true);

    return customers[customerIndex];
}

 

GameLogic 클래스에 있던 WasCustomerTaken 프로퍼티는 CustomerManager 클래스에 옮겨두었습니다.
GameLogic 클래스의 TakeIn, TakeOut 메소드에서 3초 대기하던 부분을 승객이 자동차에 탑승하거나 내려서 건물로 들어갈 때까지 대기하도록 수정하였습니다.

IEnumerator TakeIn(CustomerTrigger trigger)
{
	PlayerCar.StopMoving();
	yield return StartCoroutine(
		customerManager.TakeIn(trigger, PlayerCar)
	);
	customerTakePos = PlayerCar.Movement;
	PlayerCar.PlayMoving();
}

IEnumerator TakeOut(CustomerTrigger trigger)
{
	PlayerCar.StopMoving();

	var distance = PlayerCar.Movement - customerTakePos;
	var reward = Mathf.FloorToInt(
		distance * ((70f + stageIndex) / 100f)
	);
     
	if (reward > 0)
		coin += reward;

	yield return StartCoroutine(
		customerManager.TakeOut(trigger.CustomerPoint, PlayerCar)
	);
      
	customerTakePos = 0f;
	PlayerCar.PlayMoving();
}

 

구현 결과

 

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

728x90
LIST
728x90
SMALL

이번에는 NPC 차량과 충돌하였을 때 게임실패하는 경우를 구현하였습니다.
지난번에 Car 클래스 하나로 플레이어 차량과 NPC 차량을 처리하겠다고 했지만 기존에 만든 Car 클래스를 PlayerCar로 바꾸고, NpcCar와 NpcCarManager를 따로 만들었습니다.

 

NPC로 사용할 자동차 프리팹을 만들어 NpcCar 스크립트와 연결하였습니다.

 

빈 오브젝트를 씬에 배치한 후에 NpcCarManager 스크립트 연결하였습니다.
NpcCarManager는 아래의 역할을 위해 만들었습니다.

  • NPC 자동차 끼리는 추돌사고가 나면 안되기 때문에 모두 동일한 속도로 이동해야 된다.
    • 프리팹 마다 속도를 따로 따로 설정할 필요가 없도록 만든다.
    • 게임이 끝나면 각각의 NPC자동차를 전부 멈추게 하지 않고, NpcCarManager만 멈추면 끝나도록 처리한다.
  • 이동을 끝낸 NPC 차량은 Destroy로 파괴하지 않고, 큐에 잠깐 보관해뒀다가 재활용한다.

NpcCarManager는 나중에 맵마다 다른 종류의 NPC 차량이 등장하도록 설정할 수 있도록 리소스에서 자동차들을 불러오지 않고, 참조하고 있던 프리팹을 바로 복사하는 방식으로 구현하였습니다.

 

NPC 차량을 모두 구현한 후에 게임을 실행하니 게임은 NPC 차량이 움직이지 않고, 아래의 에러가 발생하였습니다.

 

인터넷을 찾아 보니 Rigidbody의 Kinematic을 체크하거나 MeshCollider를 제거하라고 하여 MeshCollider를 모두 지웠습니다.
그리고 게임을 실행해 봤습니다.

처음 올릴 때 실수로 추가하지 않아 다시 올립니다...

 

해당 gif는 테스트를 위해 자동차가 등장하는 간격을 줄였습니다.
위 gif를 보시면 도착지점에서 자동차가 잠깐 깜박이는 현상이 발생합니다.
재활용할 자동차를 활성화한 후 시작점으로 좌표를 바꾸는 순간 잠깐 깜박이는 것 처럼 보이는 현상이라고 생각합니다.
어차피 화면 밖에서만 일어나는 현상이기 때문에 고치지 않고 놔두려고 했는데 프리팹을 처음 복사할 때도 (0,0,0)좌표에서 동일한 현상이 발생하였습니다.
그래서 씬을 처음 불러왔을 때 NpcCarManager에서 프리팹을 위치를 바닥밑으로 초기화하는 처리를 추가하였습니다.
이동을 끝낸 자동차들으 바닥밑으로 이동시킨 후 보관하도록 수정하였습니다.

 

테스트해보니 노란 트럭은 너무 큰 것 같아 작은 자동차로 바꿨습니다.

 

NPC 차량의 태그를 NpcCar로 수정하여 플레이어가 NPC 차량과 충돌하였을 때 처리도 구현하였습니다.

using PathCreation;
using System.Collections;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.SceneManagement;

namespace TaxiGame3D
{
    public class GameLogic : MonoBehaviour, InputControls.IPlayerActions
    {
        [SerializeField]
        PathCreator playerPath;
        [SerializeField]
        NpcCarManager npcCarManager; 
        [SerializeField]
        TMP_Text stateText;

        InputControls inputControls;
        bool isAccelPressing = false;

        bool wasCustomerTaken;

        public static GameLogic Instance
        {
            get;
            private set;
        }

        [field: SerializeField]
        public PlayerCar PlayerCar
        {
            get;
            private set;
        }

        void Awake()
        {
            Instance = this;
        }

        IEnumerator Start()
        {
            npcCarManager.Play();

            PlayerCar.SetPath(playerPath.path);
            PlayerCar.OnCrashed += (sender, args) =>
            {
                StartCoroutine(EndGame(false));
            };
            PlayerCar.OnArrive += (sender, args) =>
            {
                StartCoroutine(EndGame(true));
            };
            yield return new WaitForSeconds(1);
            PlayerCar.PlayMoving();
        }

        void OnEnable()
        {
            if (inputControls == null)
                inputControls = new();
            inputControls.Player.SetCallbacks(this);
            inputControls.Player.Enable();
        }

        void OnDisable()
        {
            inputControls?.Player.Disable();
        }

        void Update()
        {
            var s = $"Moving: {PlayerCar.IsEnableMoving}\n";
            s += $"Customer: {wasCustomerTaken}";
            stateText.text = s;

            if (isAccelPressing)
                PlayerCar.PressAccel();
            else
                PlayerCar.PressBrake();
        }

        public void OnAccelerate(InputAction.CallbackContext context)
        {
            isAccelPressing = context.ReadValue<float>() != 0f;
        }

        public void OnCarEnterTrigger(CustomerTrigger trigger)
        {
            if (wasCustomerTaken)
                StartCoroutine(TakeOut());
            else
                StartCoroutine(TakeIn());
        }

        IEnumerator TakeIn()
        {
            PlayerCar.StopMoving();
            yield return new WaitForSeconds(3f);
            wasCustomerTaken = true;
            PlayerCar.PlayMoving();
        }

        IEnumerator TakeOut()
        {
            PlayerCar.StopMoving();
            yield return new WaitForSeconds(3f);
            wasCustomerTaken = false;
            PlayerCar.PlayMoving();
        }

        IEnumerator EndGame(bool isGoal)
        {
            PlayerCar.StopMoving();
            npcCarManager.Stop();
            yield return new WaitForSeconds(3);
            SceneManager.LoadScene(SceneManager.GetActiveScene().name);
        }
    }
}
using PathCreation;
using System;
using UnityEngine;

namespace TaxiGame3D
{
    public class PlayerCar : MonoBehaviour
    {
        [SerializeField]
        float acceleration = 1f;
        [SerializeField]
        float maxSpeed = 5f;
        [SerializeField]
        float brakeForce = 1f;

        VertexPath path;

        float speed = 1f;
        float movement = 0f;

        Rigidbody rb;

        public bool IsEnableMoving
        {
            get;
            set;
        }

        public event EventHandler OnCrashed;
        public event EventHandler OnArrive;

        void Awake()
        {
            rb = GetComponent<Rigidbody>();
        }

        void Update()
        {
            if (!IsEnableMoving)
                return;

            movement += Time.deltaTime * speed;
            rb.MovePosition(path.GetPointAtDistance(movement, EndOfPathInstruction.Stop));
            rb.MoveRotation(path.GetRotationAtDistance(movement, EndOfPathInstruction.Stop));

            if (movement >= path.length)
                OnArrive?.Invoke(this, EventArgs.Empty);
        }

        void OnCollisionEnter(Collision collision)
        {
            if (collision.gameObject.CompareTag("NpcCar"))
                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 = 1f;
        }

        public void StopMoving()
        {
            IsEnableMoving = false;
            speed = 0f;
        }

        public void PressAccel()
        {
            if (IsEnableMoving)
                speed = Mathf.Min(speed + Time.deltaTime * acceleration, maxSpeed);
        }

        public void PressBrake()
        {
            if (IsEnableMoving)
                speed = Mathf.Max(speed - Time.deltaTime * brakeForce, 1f);
        }
    }
}
using PathCreation;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace TaxiGame3D
{
    public class NpcCarManager : MonoBehaviour
    {
        [SerializeField]
        GameObject[] carPrefabs;
        [SerializeField]
        PathCreator[] paths;
        [SerializeField]
        Transform poolPosition;
        [SerializeField]
        [Min(0.001f)]
        float minSpawnDelay = 1f;
        [SerializeField]
        [Min(0.001f)]
        float maxSpawnDelay = 1f;
        [SerializeField]
        float moveSpeed = 50f;

        HashSet<NpcCar> activeCars = new();
        Queue<NpcCar> carPool = new();

        public bool IsPlaying
        {
            get;
            private set;
        }

        public void Play()
        {
            IsPlaying = true;
            for (int i = 0; i < paths.Length; i++)
                StartCoroutine(WaitAndSpawn(i));
        }

        public void Stop()
        {
            IsPlaying = false;
            StopAllCoroutines();
        }

        void Start()
        {
            foreach (var prefab in carPrefabs)
            {
                prefab.transform.SetPositionAndRotation(
                    poolPosition.position, poolPosition.rotation
                );
            }
        }

        void Update()
        {
            if (!IsPlaying)
                return;

            var desapwns = new Queue<NpcCar>();
            var moveAmount = moveSpeed * Time.deltaTime;
            foreach (var car in activeCars)
            {
                car.UpdateMoving(moveAmount);
                if (car.IsArrive)
                    desapwns.Enqueue(car);
            }
            while (desapwns.Count > 0)
                Despawn(desapwns.Dequeue());
        }

        IEnumerator WaitAndSpawn(int pathIndex)
        {
            if (!IsPlaying)
                yield break;

            yield return new WaitForSeconds(Random.Range(minSpawnDelay, maxSpawnDelay));

            NpcCar car = null;
            if (carPool.Count > 0)
            {
                car = carPool.Dequeue();
            }
            else
            {
                var go = Instantiate(carPrefabs[Random.Range(0, carPrefabs.Length)]);
                car = go.GetComponent<NpcCar>();
            }
            car.SetPath(paths[pathIndex].path);
            car.gameObject.SetActive(true);
            activeCars.Add(car);

            StartCoroutine(WaitAndSpawn(pathIndex));
        }

        void Despawn(NpcCar car)
        {
            car.gameObject.SetActive(false);
            car.transform.SetPositionAndRotation(
                poolPosition.position, poolPosition.rotation
            );
            activeCars.Remove(car);
            carPool.Enqueue(car);
        }
    }
}
using PathCreation;
using UnityEngine;

public class NpcCar : MonoBehaviour
{
    VertexPath path;
    float movement;
    Rigidbody rb;

    public bool IsArrive => movement >= path.length;

    public void SetPath(VertexPath path)
    {
        this.path = path;
        movement = 0;

        if (rb == null)
            rb = GetComponent<Rigidbody>();
        rb.position = path.GetPoint(0);
        rb.rotation = path.GetRotation(0f, EndOfPathInstruction.Stop);
    }

    public void UpdateMoving(float amount)
    {
        movement += amount;
        if (rb == null)
            return;
        rb.MovePosition(path.GetPointAtDistance(movement, EndOfPathInstruction.Stop));
        rb.MoveRotation(path.GetRotationAtDistance(movement, EndOfPathInstruction.Stop));
    }
}

 

구현결과

 

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

728x90
LIST
728x90
SMALL

게임 로직을 구현하기 위해 맵을 먼저 배치하였습니다.

 

Bézier Path Creator는 게임 오브젝트에 스크립트를 연결하면 Null참조 에러가 발생하고, 기즈모가 출력되지 않아 이동경로를 만들 수 없는 버그가 있습니다.

 

지난번에 자동차를 구현할 때는 데모씬에 이미 배치되어 버그가 발생하지 않 이동경로를 복사, 붙여넣기하여 기즈모를 조절하여 해당 버그를 우회하였습니다.
하지만 계속 다른 씬을 열어 복사하는 작업이 귀찮아 이 버그를 직접 고쳐보기로 하였습니다.
(이 글을 쓰면서 그냥 버그 발생하지 않는 오브젝트를 프리팹으로 만들어 사용했으면 되지 않았을까? 생각하고 있습니다.)
결국 Bézier Path Creator의 버그는 수정하였고, 이 글은 강의 시리즈가 아닌 개발과정을 정리한 노트이기 때문에 과정 설명하지 생략하겠습니다.

 

집 오브젝트 앞에 트리거를 체크한 BoxCollider를 배치하였고, 각각의 오브젝트에 CustomerTrigger 스크립트를 만들어 연결했습니다.
오브젝트에 Point라고 이름 지은 오브젝트를 자식으로 등록하였습니다.
Point 오브젝트는 나중에 택시에 탈 손님이 나타나거나 택시에 내린 손님이 이동할 목적지로 사용할 예정입니다.

 

자동차 오브젝트에 BoxCollider와 Rigidbody를 추가하여 트리거에 인식되도록 만들었습니다.
다른 내가 조작하는 자동차와 다른 자동차를 구분할 수 있도록 태그를 Player로 등록하였습니다.
자동차 오브젝트의 크기가 조금씩 다르기 때문에 자동차의 기준점은 앞으로 설정하였습니다.

 

GameLogic 클래스를 추가하여 손님의 탑승, 하차 처리를 3초 대기하는 걸로 임시 구현하였습니다.
나중에 만들 NPC자동차도 Car 스크립트를 사용할 수 있도록 만들기 위해 자동차 조작도 GameLogic으로 옮겨두었습니다.
결승점에 도착하면 3초 대기 후 씬을 다시 불러오도록 임시 구현하였습니다.

 

GameLogic.cs

using PathCreation;
using System.Collections;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.SceneManagement;

namespace TaxiGame3D
{
    public class GameLogic : MonoBehaviour, InputControls.IPlayerActions
    {
        [SerializeField]
        PathCreator path;
        [SerializeField]
        TMP_Text stateText;

        InputControls inputControls;
        bool isAccelPressing = false;

        bool wasCustomerTaken;

        public static GameLogic Instance
        {
            get;
            private set;
        }

        [field: SerializeField]
        public Car PlayerCar
        {
            get;
            private set;
        }

        void Awake()
        {
            Instance = this;
        }

        IEnumerator Start()
        {
            PlayerCar.SetPath(path.path);
            PlayerCar.OnArrive += (sender, args) =>
            {
                StartCoroutine(EndGame());
            };
            yield return new WaitForSeconds(1);
            PlayerCar.PlayMoving();
        }

        void OnEnable()
        {
            if (inputControls == null)
                inputControls = new();
            inputControls.Player.SetCallbacks(this);
            inputControls.Player.Enable();
        }

        void OnDisable()
        {
            inputControls?.Player.Disable();
        }

        void Update()
        {
            var s = $"Moving: {PlayerCar.IsEnableMoving}\n";
            s += $"Customer: {wasCustomerTaken}";
            stateText.text = s;

            if (isAccelPressing)
                PlayerCar.PressAccel();
            else
                PlayerCar.PressBrake();
        }

        public void OnAccelerate(InputAction.CallbackContext context)
        {
            isAccelPressing = context.ReadValue<float>() != 0f;
        }

        public void OnCarEnterTrigger(CustomerTrigger trigger)
        {
            if (wasCustomerTaken)
                StartCoroutine(TakeOut());
            else
                StartCoroutine(TakeIn());
        }

        /// <summary> 손님 탑승 </summary>
        IEnumerator TakeIn()
        {
            PlayerCar.StopMoving();
            yield return new WaitForSeconds(3f);
            wasCustomerTaken = true;
            PlayerCar.PlayMoving();
        }

        /// <summary> 손님 하차 </summary>
        IEnumerator TakeOut()
        {
            PlayerCar.StopMoving();
            yield return new WaitForSeconds(3f);
            wasCustomerTaken = false;
            PlayerCar.PlayMoving();
        }

        IEnumerator EndGame()
        {
            PlayerCar.StopMoving();
            yield return new WaitForSeconds(3);
            SceneManager.LoadScene(SceneManager.GetActiveScene().name);
        }
    }
}

 

Car.cs

using PathCreation;
using System;
using UnityEngine;

namespace TaxiGame3D
{
    public class Car : MonoBehaviour
    {
        [SerializeField]
        float acceleration = 1f;
        [SerializeField]
        float maxSpeed = 5f;
        [SerializeField]
        float brakeForce = 1f;

        VertexPath path;

        float speed = 1f;
        float movement = 0f;

        Rigidbody rb;

        public bool IsEnableMoving
        {
            get;
            set;
        }

        public event EventHandler OnArrive;

        void Awake()
        {
            rb = GetComponent<Rigidbody>();
        }

        void Update()
        {
            if (!IsEnableMoving)
                return;

            movement += Time.deltaTime * speed;
            rb.MovePosition(path.GetPointAtDistance(movement, EndOfPathInstruction.Stop));
            rb.MoveRotation(path.GetRotationAtDistance(movement, EndOfPathInstruction.Stop));

            if (movement >= path.length)
                OnArrive?.Invoke(this, EventArgs.Empty);
        }

        public void SetPath(VertexPath path)
        {
            this.path = path;
            rb.position = path.GetPointAtDistance(movement, EndOfPathInstruction.Stop);
            rb.rotation = path.GetRotationAtDistance(movement, EndOfPathInstruction.Stop);
        }

        public void PlayMoving()
        {
            IsEnableMoving = true;
            speed = 1f;
        }

        public void StopMoving()
        {
            IsEnableMoving = false;
            speed = 0f;
        }

        public void PressAccel()
        {
            if (IsEnableMoving)
                speed = Mathf.Min(speed + Time.deltaTime * acceleration, maxSpeed);
        }

        public void PressBrake()
        {
            if (IsEnableMoving)
                speed = Mathf.Max(speed - Time.deltaTime * brakeForce, 1f);
        }
    }
}

 

구현결과

 

 

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

728x90
LIST
728x90
SMALL

이번에는 자동차의 가속과 감속기능을 구현하였습니다.
자동차는 손님을 태우거나 결승점에 도착하기 전까지 멈추지 않습니다.
화면을 터치하면 속도가 증가하고, 터치하지 않으면 속도가 감소하기만 합니다.

 

유니티로 키보드 마우스 등의 입력을 작업할 때마다 옛날 방식으로만 작업해왔기 때문에 이번에는 Input System을 사용해 보았습니다.

 

PlayerSettings에서 Active Input HandlingInput System Package로 변경하였습니다.

 

화면을 터치하였을 때 속도가 증가하는 기능밖에 없기 때문에 엑션은 Accelerate만 추가하였습니다.

 

Car.cs 파일에 가속과 감속처리를 구현하였습니다.

 

구현 결과

 

다음에는 맵을 1개 완성한 후 게임로직을 구현해 보겠습니다.

 

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

728x90
LIST
728x90
SMALL

테스트 씬을 만든 후 맵을 임시로 배치하였습니다.

 

그리고 자동차 오브젝트를 임시로 만들려고 했는데 문제가 발생하였습니다.
코코스에서는 잘 나오던 자동차 오브젝트가 유니티에서는 이상하게 출력되었습니다.

 

콘솔창에서도 무슨 뜻인지 알 수 없는 경고들이 출력되어 인터넷에 검색해 봤습니다.
3D 모델링 툴에서 매쉬를 고쳐라, 유니티에서 fbx파일 설정을 바꿔봐라 같은 답변들이 있어 유니에서 fbx설정을 바꿔 봤지만 끝내 고치지 못 했습니다.

인터넷에서 리소스를 따로 구하고 싶지 않아 이 프로젝트를 시작했는데 결국 에셋 스토어에서 무료 리소스를 구했습니다.

새로 받은 자동차 리소스들은 분홍색으로 출력되어 상단 메뉴에서 Edit>Rendering>Generate Shader Includes를 눌러 봤습니다.

 

그래도 분홍색으로 나와 모든 머테리얼들의 쉐이더를 Universal Render Pipline/Lit으로 바꿨습니다.

 

자동차가 분홍색으로 보이는 문제를 모두 해결하여 미리 만들어둔 맵에 자동차를 배치한 후 카메라를 자동차의 자식으로 배치하였습니다.

 

자동차 이동은 처음에는 코코스 크리에이터로 만든 샘플과 비슷하게 만들어 보려고 했다가, 좀 더 쉽게 구현 해보는 건 어떨까 생각하며 DOTween을 이용해 구현해보기로 하였습니다.(점점 처음 프로젝트를 선택한 목적에서 벗어나는 것 같네요...)
하지만 DOTween으로 자동차가 이동하는 도중에 이동속도를 조절할 방법을 찾지 못해 포기하였습니다.
대신 에셋스토어에 무료도 등록된 Bézier Path Creator를 이용하여 자동차 이동을 구현해보기로 하였습니다.

 

미리 만들어 둔 맵에 이동경로를 배치하였습니다.

 

하는 김에 커브 이동도 테스트 해볼 겸, 커브 구간도 만들어 이동경로를 배치하였습니다.

 

Car.cs 파일을 만들어 내용을 작성한 후 미리 배치해둔 자동차 오브젝트에 붙여 실행 해 보았습니다.

 

그런데 자동차가 도로 위를 이상하게 달렸습니다.

 

해당 문제는 PathCreator의 설정을 바꿔 해결하였습니다.

 

구현 결과
(촬영할 때 게임이 멈춰버려서 PlayerSettings에서 Run In Background를 체크하였습니다.)

 

 

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

728x90
LIST

+ Recent posts