유니티3D - 마무리
마무리
디테일한 것들과 중간에 처리하지못한 버그들을 해결하고 맵과 클리어조건을 만들어 게임을 완성시켜보자.
맵제작
노멀맵
심심한 부분이 사라졌다 똑같이 벽과 바닥에 알맞게 넣어서 마무리하자.
맵에 생동감이 생겨 더 재미있게 느껴진다.
피스톨 애니메이션
피스톨 애니메이션전에 일단 주무기를 들면 그에맞는 액션이 되게끔 하겠다. ex)Idle->Rifle
무기를 교체하면서 현재무기에 맞게끔 CurrenntIndex를 설정하기 때문에 인덱스에 맞게 state 컴포넌트에서 enum을 정리했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public enum ECharacterAnimState
{
Idle = 0,
Rifle = 1,
Pistol = 2,
Throwing = 3
}
public void ChangeWeapon(int Index)
{
if (CurrentWeapon != null)
{
CurrentWeapon.gameObject.SetActive(false);
if (CurrentIndex == Index)
{
OnPutGunEvent.Invoke();
CurrentWeapon = null;
CurrentIndex = 0;
state.SetCharacterState(CurrentIndex);
return;
}
}
switch (Index)
{
case 1:
CurrentWeapon = MainGun;
CurrentIndex = 1;
break;
case 2:
CurrentWeapon = pistol;
CurrentIndex = 2;
break;
case 3:
CurrentWeapon = Throw;
CurrentIndex = 3;
break;
default:
CurrentWeapon = null;
CurrentIndex = 0;
break;
}
state.SetCharacterState(CurrentIndex);
}
피스톨 애니메이션을 찾아서 가져온 후 Animator Override Controller를 이용해서 피스톨일 때 바꾸는 식으로 할것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
void SetAnimState(ECharacterAnimState state)
{
animator.SetInteger("State", (int)state);
switch (state)
{
case ECharacterAnimState.Rifle:
animator.runtimeAnimatorController = defaultController;
break;
case ECharacterAnimState.Pistol:
animator.runtimeAnimatorController = pistolOverride;
break;
}
}
무기상태 애님변경
애니메이션 컨트롤러를 런타임에 바꾸어 사용해봤는데 피스톨 애니메이션이 마땅한게 없는거 같다 그냥 라이플로 통합하는게 더 좋을거 같다.
애니메이션을 건드렸으니 로코모션을 이용한 점프 만들기
클리어조건
맵에 Bot과 Key를 배치하여 모두 처치하고 키를 가지면 탈출하여 클리어한다.
이 정보를 UI에 표출해준다. 그러기위해 UI와 GameState 스크립트를 만들어관리하겠다.
- gameState ```c# using System; using UnityEngine;
public class GameState : MonoBehaviour { public static GameState instance; public event Action
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int EnemyCount;
int EnemyMaxCount;
int KeyCount;
int KeyMaxCount;
public void IncreaseEnemy() { EnemyCount++; EnemyChanged.Invoke(EnemyCount); }
public void IncreaseKey() { KeyCount++; KeyChanged.Invoke(KeyCount); }
private void Awake()
{
instance = this;
}
void Start()
{
GameObject[] Enemies = GameObject.FindGameObjectsWithTag("Enemy");
EnemyMaxCount = Enemies.Length;
GameObject[] keys = GameObject.FindGameObjectsWithTag("Key");
KeyMaxCount = keys.Length;
UIMission.Instance.SetEnemyCount(EnemyMaxCount);
UIMission.Instance.SetKeyCount(KeyMaxCount);
EnemyChanged.Invoke(EnemyCount);
KeyChanged.Invoke(KeyCount);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
- UI
```c#
using TMPro;
using UnityEngine;
public class UIMission : MonoBehaviour
{
public static UIMission Instance;
public TextMeshProUGUI EnemyText;
public TextMeshProUGUI KeyText;
public GameState GameState;
int MaxEnemy;
int MaxKey;
public void SetEnemyCount(int Amount) { MaxEnemy = Amount; }
public void SetKeyCount(int Amount) { MaxKey = Amount; }
void SetEnemyText(int Amount)
{
EnemyText.text = "적군 ( " + Amount.ToString() + " / " + MaxEnemy.ToString() + " )";
}
void SetKeyText(int Amount)
{
KeyText.text = "열쇠 ( " + Amount.ToString() +" / " + MaxKey.ToString() + " )";
}
private void Awake()
{
Instance = this;
}
private void OnEnable()
{
GameState.EnemyChanged += SetEnemyText;
GameState.KeyChanged += SetKeyText;
}
}
델리게이트를 이용해서 적을 처치하거나 키를 먹거나 할 때 업데이트해주는 구조로 구현했다.
AI 죽음로직에 추가하고 플레이어의 픽업 부분에 이제 아이템종류를 구분하여 다르게 작용하도록하자.
일단 DropData에 Enum데이터를 추가하고 Object타입으로 스크립트도 만들어 나중에 오브젝트가 추가되는 상황도 고려해서 만들자.
그냥 간단하게 ObjectItem 클래스를 만들고 가상함수로 상호작용을 하도록 설계하겠다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void OnPickUp(InputAction.CallbackContext context)
{
if (context.performed)
{
if (PickUp == true && dropItem != null)
{
if (dropItem.GetDropType() == EDropItem.Object)
{
ObjectItem objectItem = dropItem.GetComponent<ObjectItem>();
objectItem.OnInteraction();
}
else
{
weapon.PickUpItem(dropItem);
}
currentTarget.OffUIItem();
Destroy(dropItem.gameObject);
currentTarget = null;
dropItem = null;
}
}
}
디버깅
콜리더 충돌 문제 리지드바디를 키오브젝트에 안넣어놨었는데 이거때문에 콜리더 충돌이 안일어나는 일이 있었다.
GPT에게 물어보니 결론적으로 Collider만 있고 Rigidbody가 없으면 OnCollisionEnter나 OnTriggerEnter가 절대 호출되지 않아요.
이러한 조건이 있었나보다..AI 코드문제? 그리고 구조를 다바꾸고 다시 AI한테 가보니 총을 한발만 쏘고 만다 이것도 디버깅해봐야겠다..
유니티에서 디버깅을 하면서 느낀건데 코드의 흐름을 따라가다가 갑자기 return 문처럼 뚝 끊긴다. 이것은 아마 언리얼에서는 크러쉬나는 상황 아마 Null 값에 접근했을 때 인거같다.
플레이어 총알 UI를 위해 만든 델리게이트에 Null 체크를 안 해서 생긴 문제였다..
유니티는 언리얼처럼 Null체크를 하지않아도 크러쉬가 나지않아서 디버깅하면 어느줄에서 코드가 실행이 멈춘다.
이런방식으로 찾아야한다.
미니맵
미니맵 전용 카메라를 플레이어에게 부착하고 카메라 투시를 직교로 하고 렌더텍스처를 만들어 캐싱해준다.
그 후에 UI에 Raw Image로 만들어 미니맵 렌더텍스처를 이미지에 넣어 이용한다.
그러면 일단 모든 오브젝트가 보이는데 간단하게 만들기위해 메인 카메라에 보이지않는 미니맵 레이어를 만들었다.
플레이어나 Ai에겐 실린더에 파란색, 빨간색 머티리얼로 만들고 키에는 스프라이트를 이용해 표시했다.
미니맵
생각보다 나쁘지않다. 오브젝트나 AI가 사라지면 즉각반영된다.
원래는 미니맵 카메라에 마스크를 만들어 관리해주는게 좋다!
상점
상점을 활성화하는 오브젝트와 그를 표시하는 UI 플레이어에게 인풋 부분을 만들어 마무리하겠다.
간단하게 UI는 활성화되면 페이드 인, 아웃 효과로 글씨를 깜빡거리게 하겠다.
그리고 활성화하는 동시에 플레이어의 bool 변수도 true로 만들어 특정 키를 누르면 활성화가 가능하다.
- 인게임 마켓 코드 ```c# using UnityEngine;
public class GameMarket : MonoBehaviour { public MarketText MarketText;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Player"))
{
MarketText.OnUI();
PlayerController PC = other.GetComponent<PlayerController>();
PC.MarketTrigger(true);
}
}
private void OnTriggerExit(Collider other)
{
if (other.CompareTag("Player"))
{
MarketText.OffUI();
PlayerController PC = other.GetComponent<PlayerController>();
PC.MarketTrigger(false);
}
} } ``` - 마켓 활성화 텍스트 UI 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using TMPro;
using UnityEditor.ShaderGraph;
using UnityEngine;
public class MarketText : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI marketText;
public float speed = 2f;
private Color originalColor;
void Start()
{
originalColor = marketText.color;
}
void Update()
{
float alpha = (Mathf.Sin(Time.time * speed) + 1f) / 2f;
marketText.color = new Color(originalColor.r, originalColor.g, originalColor.b, alpha);
}
public void OnUI()
{
gameObject.SetActive(true);
}
public void OffUI()
{
gameObject.SetActive(false);
}
}
수류탄
수류탄 구현을 안했었다 마무리하면서 수류탄까지 구현을해보자.
수류탄은 던질때 준비동작이 있고 던지는 동작이 있다.
그래서 Onfire트리거와 OFFfire트리거 둘다 이용해서 구현하겠다.
1
2
3
4
5
6
7
8
9
public void InputThrowing(Weapon Item)
{
Throw = Instantiate(Item, Hand_R);
Throw.SetOwner(gameObject);
Throw throwItem = Throw.GetComponent<Throw>();
throwItem.OnFireEvent += OnThorwing;
throwItem.OffFireEvent += OffThrwing;
Throw.gameObject.SetActive(false);
}
델리게이트를 일단 구독을 해놓고 이제 애니메이션 컨트롤러에도 사용하기 때문에 역시나 이중 델리게이트를 이용했다.
1
2
3
4
5
6
7
8
9
10
11
12
public void OnThorwing()
{
if (OnThrowEvent != null)
OnThrowEvent.Invoke();
}
public void OffThrwing()
{
if (OffThrowEvent != null)
OffThrowEvent.Invoke();
}
수류탄 던지기전 준비동작 애니메이션을 찾지못했다.
특정 프레임에서 애니메이션 이벤트를 사용하여 그 구간을 반복하여 준비동작을 만들었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void OnThrow()
{
animator.SetTrigger("Throw");
targetWeight = 1f;
isBlending = true;
OnReady = true;
}
void OffThrow()
{
OnReady = false;
}
public void ReadyThrow()
{
if(OnReady)
animator.Play("Throwing", 1, 0.2727f);
}
준비 동작에서 이제 수류탄을 던질 예상 경로를 라인렌더러를 이용해 구현했다.
찾아보니 위치랑 속력으로 물리공식에 대입해 이용하는것이다.
위치는 현재 오브젝트 즉 수류탄의 위치와 던질 방향벡터를 나는 카메라의 전방 방향에 적당한 파워를 넣어주었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private void DrawTrajectory()
{
Vector3 cameraPos = Camera.main.transform.position;
Vector3 cameraForward = Camera.main.transform.forward;
float deltaTime = Time.fixedDeltaTime;
Vector3 startPos = gameObject.transform.position;
Vector3 startVel = cameraForward * throwForce;
Vector3[] points = new Vector3[trajectoryPoints];
for (int i = 0; i < trajectoryPoints; i++)
{
startPos += startVel * deltaTime + 0.5f * Physics.gravity * deltaTime * deltaTime;
startVel += Physics.gravity * deltaTime;
lineRenderer.SetPosition(i, startPos);
}
}
이제 좌클릭을 했을 때 예상경로를 그려주고 준비동작 애니메이션을 실행키신다.
그리고 좌클릭을 땟을 때 예상경로를 꺼주고 반복하던 준비동작을 꺼준다.
- 전체적인 Throw Code ```c# using Unity.XR.OpenVR; using UnityEngine; using UnityEngine.UIElements;
public class Throw : Weapon { [SerializeField] private Grenade grenadePrefab; [SerializeField] private float throwForce = 15f; [SerializeField] private int trajectoryPoints = 60; [SerializeField] private float timeStep = 0.1f; [SerializeField] private LineRenderer lineRenderer; [SerializeField] private float rayDistance = 10;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
private bool isAiming = false;
void Update()
{
if (isAiming)
{
DrawTrajectory();
}
}
private void Awake()
{
lineRenderer.positionCount = trajectoryPoints;
lineRenderer.enabled = false;
}
public override void OnFire()
{
if(OnFireEvent != null)
OnFireEvent.Invoke();
isAiming = true;
lineRenderer.enabled = true;
}
public override void OffFire()
{
isAiming = false;
lineRenderer.enabled = false;
if(OffFireEvent != null)
OffFireEvent.Invoke();
}
private void DrawTrajectory()
{
Vector3 cameraPos = Camera.main.transform.position;
Vector3 cameraForward = Camera.main.transform.forward;
float deltaTime = Time.fixedDeltaTime;
Vector3 startPos = gameObject.transform.position;
Vector3 startVel = cameraForward * throwForce;
Vector3[] points = new Vector3[trajectoryPoints];
for (int i = 0; i < trajectoryPoints; i++)
{
startPos += startVel * deltaTime + 0.5f * Physics.gravity * deltaTime * deltaTime;
startVel += Physics.gravity * deltaTime;
lineRenderer.SetPosition(i, startPos);
}
}
public void Throwing()
{
Grenade grenade = Instantiate(grenadePrefab, gameObject.transform.position, Quaternion.identity);
Vector3 cameraForward = Camera.main.transform.forward;
Vector3 startVel = cameraForward * throwForce;
grenade.Throwing(startVel);
} } ``` Thrwoing 부분도 애니메이션 이벤트에서 델리게이트를 이용해 역으로 WeaponComp에서 실행시켰다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Anim
{
public void Throwing()
{
if (OnThrowEvent != null)
OnThrowEvent.Invoke();
}
}
class WeaponComp
{
private void Throwing()
{
Throw throwWeapon = CurrentWeapon.GetComponent<Throw>();
if(throwWeapon)
{
throwWeapon.Throwing();
}
}
}
이런식으로 던질때 실행시키고 수류탄 내부 코드는 리지드바디를 이용한 물리로 이동시킨다.
그리고 3초후에 터지도록 하였고, 수류탄의 중심부에서부터 거리에 따라 데미지를 주었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
using UnityEngine;
public class Grenade : MonoBehaviour
{
[SerializeField] private GameObject Effect;
[SerializeField] private float damage = 100f;
[SerializeField] private float explosionRadius = 5f;
[SerializeField] private float explosionDelay = 3f;
[SerializeField] private AudioSource audioSource;
[SerializeField] private AudioClip explosionSound;
private Rigidbody rb;
void Awake()
{
rb = GetComponent<Rigidbody>();
}
public void Throwing(Vector3 velocity)
{
rb.collisionDetectionMode = CollisionDetectionMode.Continuous;
rb.AddForce(velocity * rb.mass, ForceMode.Impulse);
Invoke(nameof(Boom), explosionDelay);
}
private void Boom()
{
GameObject effectInstance = Instantiate(Effect, transform.position, Quaternion.identity);
audioSource.clip = explosionSound;
audioSource.Play();
Collider[] hits = Physics.OverlapSphere(transform.position, explosionRadius);
foreach (Collider hit in hits)
{
Status status = hit.GetComponent<Status>();
if (status != null)
{
float distance = Vector3.Distance(transform.position, hit.transform.position);
float distanceRatio = Mathf.Clamp01(1f - (distance / explosionRadius));
float finalDamage = damage * distanceRatio;
status.DecreaseHP(finalDamage);
}
}
Destroy(effectInstance, 2f);
Destroy(gameObject, 0.8f);
}
}
수류탄
어색하지만 구현은 됐다…
게임 종료
오브젝트를 다채워서 탈출을 하거나 플레이어가 죽을경우 게임 재시작과 종료를 만들자.
간단하게 그냥 버튼 두개로 하겠다.
죽었을떄나 클리어했을때 이거역시 static 변수로 싱글톤처럼 이용하겠다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public class UIGameMenu : MonoBehaviour
{
public static UIGameMenu Instance;
public TextMeshProUGUI GamePhrase;
public Button ReStart;
public Button Exit;
private void Awake()
{
Instance = this;
}
private void Start()
{
gameObject.SetActive(false);
}
public void RestartGame()
{
Scene currentScene = SceneManager.GetActiveScene();
SceneManager.LoadScene(currentScene.buildIndex);
Time.timeScale = 1f;
}
public void QuitGame()
{
Application.Quit();
}
public void SetGamePhrase(string text)
{
GamePhrase.text = text;
}
public void OnUIMenu()
{
gameObject.SetActive(true);
Cursor.lockState = CursorLockMode.Confined;
Cursor.visible = true;
Time.timeScale = 0f;
}
}
진짜 간단하게 게임을 다시하거나 종료하게끔만 만들었다. 이제 게임 클리어 조건을 GameState에서 받아오겠다.
MaxEnemy와 MaxKey값에 도달하면 텍스트에 취소선을 생성하고 회색으로 바꾼다.
둘 다 완료했다면 이제 미션텍스트를 출구를 찾아 탈출하라고 바꿔주겠다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
void SetEnemyText(int Amount)
{
if (MaxEnemy == Amount)
{
EnemyText.color = new Color32(97, 97, 97, 255);
EnemyText.text = "<s>적군 ( " + Amount + " / " + MaxEnemy + " )</s>";
ClearEnemy = true;
MissionClear();
}
else
{
EnemyText.text = "적군 ( " + Amount + " / " + MaxEnemy + " )";
}
}
void SetKeyText(int Amount)
{
if (MaxKey == Amount)
{
KeyText.color = new Color32(97, 97, 97, 255);
KeyText.text = "<s>열쇠 ( " + Amount + " / " + MaxKey + " )</s>";
ClearKey = true;
MissionClear();
}
else
{
KeyText.text = "열쇠 ( " + Amount + " / " + MaxKey + " )";
}
}
private void MissionClear()
{
if(ClearKey && ClearEnemy)
{
StartCoroutine(BlinkAndChangeText("출구를 찾아 탈출하세요!"));
}
}
IEnumerator BlinkAndChangeText(string newText)
{
Color originalColor = MissionText.color;
Color tempColor = originalColor;
// 1. 페이드 아웃 (알파 감소)
for (float t = 0; t < fadeDuration; t += Time.deltaTime)
{
float alpha = Mathf.Lerp(1f, 0f, t / fadeDuration);
tempColor.a = Mathf.Abs(Mathf.Sin(Time.time * blinkSpeed)) * alpha;
MissionText.color = tempColor;
yield return null;
}
// 알파 완전히 0
tempColor.a = 0f;
MissionText.color = tempColor;
// 2. 텍스트 교체
MissionText.text = newText;
// 3. 페이드 인 (알파 증가 + 깜빡임)
for (float t = 0; t < fadeDuration; t += Time.deltaTime)
{
float alpha = Mathf.Lerp(0f, 1f, t / fadeDuration);
tempColor.a = Mathf.Abs(Mathf.Sin(Time.time * blinkSpeed)) * alpha;
MissionText.color = tempColor;
yield return null;
}
// 최종 알파 1로 설정
tempColor.a = 1f;
MissionText.color = tempColor;
}
이 코드를 실행 시킨후에 게임 클리어 UI (Game Over) 띄우면 버튼 클릭이되지않았다.
이것도 디버깅으로 하나하나 어떤 조건에서 안되는건지 찾는데도 힘들었다.
근데 이건 코드문제가 아니였다.. 그냥 UI에 EventSystem이 없어서 나오는 오류였던 것
하지만 저게 없다면 원래 상점이나 똑같은 게임 클리어 UI에서 저 위에 코드가 실행되기전에는 잘됨…
저 코드에 아무리봐도 이벤트시스템을 건드리는 코드는 모르겠음 없는거 같은데 확실히
이 세가지를 유니티 UI를 이용할 때 꼭 써야한다. ✅ Canvas (UI를 띄우는 기본) ✅ GraphicRaycaster (Canvas에 기본 포함) ✅ EventSystem (클릭 감지용)
게임클리어
리게임
이로써 이 프로젝트를 사용함으로써 유니티의 기능들을 많이 사용해보고 학습했다.
솔직히 완성도가 완전 마음에 들지는 않는다. 그래도 독학으로 게을렀지만 유니티를 학습해봤다. 이정도면 포트폴리오도 안되려나? 싶지만.. 언리얼과 유니티 둘 다 어느정도 이제 사용할줄안다고 자부할수있을거같다.
이제는 나의 가치를 높일 언리얼로 Staem에 게임을 출시하여 실제로 필요한 SDK등을 공부해서 출시하면 취업에 문에 가까워질수있다고 생각한다.
유니티 프로젝트를 너무 게을리한거같아 정리를 다하고나서는 언리얼 프로젝트 설계부터 출시까지 내가 만족할수있게 달릴것이다.
제일크게 느낀점
유니티를 하면서 진짜 크게 느낀점은 먼가 에셋을 불러와서 내 입맛대로 바꾸는게 좀 불편하다?
애니메이션이 특히 엄청 크다 언리얼은 애니메이션을 내가 키를 이용해 트랜스폼을 바꾸어 새로운 애니메이션처럼 사용이 가능한데..
언리얼의 몽타주가 대단한 것 같다. 그리고 UI가 음 언리얼처럼 로컬창에서 디자인을 못한다는 점 등등 이 불편하다.
아마 이건 엔진의 큰 장단점인거 같다. 언리얼은 확실히 디자이너와 프로그래머가 협업하기 좋은환경인거같다.
한줄평 : 유니티보다는 언리얼이 협업에 더 특화되어있어 편한장점들이 더 부각됐다. (내가 유니티를 잘 몰라서그런가?)

