-
내일배움캠프 30일차 TIL - 3D 게임 기초 개발 3TIL/Unity 2024. 5. 28. 18:10
[한 줄 요약]
유니티 숙련 주차 개인 강의를 학습하고, 3D 게임 개발 기초 단계 중 인벤토리, 아이템 장착, 자원 채취, 스태미나, AI 네비게이션, 적 생성과 로직, 사운드 등을 실습했다.
[학습 내용]
유니티 숙련 주차 강의
인벤토리 구현
아이템 슬롯 UI 생성
- ItemSlot 이미지 오브젝트 생성 후 Button과 OutLine 컴포넌트 부착 후 OutLine 컴포넌트 비활성화
- ItemSlot 스크립트 생성 후 ItemSlot 오브젝트에 부착
- 아이템 이미지를 담을 이미지 오브젝트와 갯수를 담을 텍스트 오브젝트를 자식 오브젝트로 부착
- ItemSlot 오브젝트를 프리펩화하고 UIInventory 캔버스 오브젝트를 만들어서 그곳에 자식 오브젝트로 부착
인벤토리 UI 생성
- 배경 이미지를 담을 BG 이미지 오브젝트와 Grid Layout Group 컴포넌트를 담은 Slots 오브젝트 생성
- ItemSlot을 여러개 복사 후 Slots 자식 오브젝트로 부착
- InfoBG 이미지 오브젝트를 만들고 하위에 아이템 이름, 설명, 스탯 정보 등 4개의 텍스트 부착
- 아이템 사용, 장착, 해제, 버리기 총 4개의 버튼 추가 부착
- UIInventory 스크립트 생성 후 UIInventory 오브젝트에 부착
인벤토리 스크립트 부착
- ItemSlot 스크립트 작성
- UIInventory 스크립트 작성 후 컴포넌트에서 값 할당
- Player 오브젝트 이벤트에 OnInventory 메서드 등록
아이템 파밍
- UIInventory 스크립트에 AddItem 메서드 등 아이템 획득 기능 추가
- Player 오브젝트 하위에 DropPositon 오브젝트 추가 후 Player 컴포넌트 값 할당
- ItemSlot 스크립트에 Set 메서드 등 획득 아이템 인벤토리 표시 기능 추가
- ItemSlot 프리펩의 ItemSlot 컴포넌트에 값 할당
파밍 아이템 정보 표시
- ItemSlot 스크립트에 OnClickButton 메서드로 버튼 클릭 시 버튼 기능 추가
- ItemSlot 프리펩의 이벤트에 OnClickButton 메서드 등록
- UIInventory 스크립트에 SelectItem 메서드 등 선택 아이템 정보 표시 기능 추가
- Item_Carrot SO에 Consumable 값 할당
사용, 버리기 버튼 기능 구현
- UIInventory 스크립트에 OnUseButton 메서드 등 버튼 클릭 시 동작 기능 구현
- 각 버튼 오브젝트에 이벤트 등록
아이템 장착
아이템 카메라 생성
- 플레이어 오브젝트에 새 카메라 추가 후 Clear Flags를 Depth Only로 변경
- 레이어에 Equip 레이어 추가
- 카메라의 Culling Mask를 Equip 레이어로 설정
- 이름을 EquipCamera로 변경 후 오디오 리스너 컴포넌트 제거
아이템 장착 버튼 기능 구현
- 장착할 아이템을 프리펩화
- ItemData 스크립트에 equipPrefab 게임오브젝트 변수 추가
- 장착할 아이템 SO들의 값에 프리펩 추가
- Equip, EquipTool 스크립트 작성
- 장착 아이템 프리펩에 EquipTool 스크립트 부착 후 레이어를 Equip 레이어로 변경
- Equipment 스크립트 작성
- Player 오브젝트에 Equipment 스크립트 부착 후 컴포넌트 값 할당
- UIInventory 스크립트에 OnEquipButton 메서드 등 버튼 클릭 시 장착 기능 구현
- 각 버튼에 맞는 이벤트에 메서드 등록
공격 및 자원 채취
공격 애니메이션
- 제공된 애니메이션 임포트
- EquipTool와 Equipment 스크립트에 OnAttackInput 메서드 등 공격 기능 구현
- Player 오브젝트의 이벤트에 OnAttackInput 메서드 등록
- 장착 아이템 프리펩에 애니메이터 추가
- 장착 아이템 프리펩의 EquipTool 스크립트 컴포넌트에 값 할당
자원 채취
- Resource_Tree 오브젝트 생성
- Resource 레이어 추가 및 Resource_Tree에 적용
- Resource 스크립트 작성 및 Resource_Tree에 컴포넌트 부착
- Attack 애니메이션에 이벤트 추가
- Equip_Sword 프리펩에 들어가서 애니메이션 안의 이벤트를 클릭
- EquipTool 스크립트의 OnHit 메서드 등록
- Resource_Tree 오브젝트의 Resource 컴포넌트에 값 할당
스태미나 사용
- PlayerCondition 스크립트에 UseStamina 메서드 등 스태미나 소모 기능 구현
- EquipTool 스크립트에서 공격 시 UseStamina 메서드 호출하는 기능 구현
- 장착 아이템 프리펩에서 EquipTool 컴포넌트의 UseStamina 값 할당
AI 네비게이션
AI 네비게이션(AI Navigation)
AI 네비게이션은 인공지능이 게임이나 시뮬레이션 등 가상환경에서 이동하는 방법을 결정하는 기술이다. 주로 3D 게임에서 캐릭터나 NPC가 지능적으로 이동하도록 만들어진다. 이를 위해 지형, 장애물, 목표지점 등을 고려하여 적절한 경로를 생성하고 이동하는데 사용된다.
주요 기술 및 기능
- Navigation Mesh : 3D 공간을 그리드로 나누어 이동 가능한 지역과 장애물이 있는 지역을 구분하는 매쉬
캐릭터가 이동할 수 있는 영역과 이동할 수 없는 영역을 정의하고 이를 기반으로 경로를 설정 - Pathfinding : 캐릭터의 현재 위치에서 목표지점까지 가장 적절한 경로를 찾는 알고리즘
주로 A* 알고리즘이 사용된다. - Steering Behavior : 경로를 따라 이동할 때 보다 자연스러운 동작을 구현하는데 사용
동적으로 캐릭터의 이동 방향과 속력을 조정하여 부드럽고 현실적인 이동을 시뮬레이션한다. - Obstacle Avoidance : 캐릭터가 이동 중에 장애물과 충돌하지 않도록 하는 기술
각종 센서나 알고리즘을 사용하여 장애물을 감지하고 피하는 동작을 수행한다. - Local Avoidance : 여러 캐릭터나 NPC가 서로 충돌하지 않도록 하는 기술
캐릭터 사이의 거리를 유지하거나 회피동작을 수행하여 서로 부딪히지 않게 한다.
적 구현
AI Naviagtion
패키지 매니저에서 AI Naviagtion를 설치한다.
적 오브젝트 생성
- NPC 오브젝트 생성 후 준비된 프리펩을 자식으로 부착하고 Animator 컴포넌트 부착
- 애니메이션 컨트롤러 설정
- NPC 스크립트 생성 후 NPC 오브젝트에 컴포넌트로 추가
- NPC 오브젝트에 Box Collider와 Nav Mesh Agent 컴포넌트 추가
- NPC 스크립트 작성 후 NPC 컴포넌트 일부 값 할당
- NPC 오브젝트에 BearAvatar 아바타 부착
- NPC 스크립트에 AttackingUpdate 메서드 등 공격 기능 구현
- NPC 오브젝트에 NPC 컴포넌트 나머지 값 할당
적 오브젝트 피격 구현
- NPC 스크립트에 IDamagable 인터페이스 상속하고 TakePhysicalDamage 메서드 등으로 피격 구현
- EquipTool 스크립트의 OnHit 메서드에서 TakePhysicalDamage 메서드 호출
- NPC 컴포넌트의 Drop On Death에 Item_Carrot과 Item_Wood 오브젝트 추가
사운드
걸음소리 효과음
- FootSteps, MusicZone 스크립트 생성
- Player 오브젝트에 FootSteps, Audio Source 컴포넌트 부착
- FootSteps 스크립트 작성 후 Player 오브젝트의 컴포넌트에서 값 할당
뮤직존
- MusicZone 오브젝트를 추가하고 Box Collider 컴포넌트 부착
- Box Collider 크기 조절 후 IsTrigger 체크
- Audio Source 컴포넌트 부착 후 AudioClip 설정해준 뒤 Loop 체크
- MusicZone 스크립트 작성 후 MusicZone 오브젝트에 부착
- MusicZone 컴포넌트 값 할당
- Player 오브젝트 Player 태그로 변경
코드
더보기UIInventory.cs
using TMPro; using UnityEngine; public class UIInventory : MonoBehaviour { public ItemSlot[] slots; public GameObject InventoryWindow; public Transform slotPanel; public Transform dropPosition; [Header("Select")] public TextMeshProUGUI selectedItemName; public TextMeshProUGUI selectedItemDescription; public TextMeshProUGUI selectedStatName; public TextMeshProUGUI selectedStatValue; public GameObject useButton; public GameObject equipButton; public GameObject unequipButton; public GameObject dropButton; private PlayerController controller; private PlayerCondition condition; private ItemData selectedItem; private int selectedItemIndex = 0; private int curEquipIndex; private void Start() { controller = CharacterManager.Instance.Player.controller; condition = CharacterManager.Instance.Player.condition; dropPosition = CharacterManager.Instance.Player.dropPosition; controller.inventory += Toggle; CharacterManager.Instance.Player.addItem += AddItem; InventoryWindow.SetActive(false); slots = new ItemSlot[slotPanel.childCount]; for (int i = 0; i < slots.Length; i++) { slots[i] = slotPanel.GetChild(i).GetComponent<ItemSlot>(); slots[i].index = i; slots[i].inventory = this; } ClearSelectedItemWindow(); } private void Update() { } private void ClearSelectedItemWindow() { selectedItemName.text = string.Empty; selectedItemDescription.text = string.Empty; selectedStatName.text = string.Empty; selectedStatValue.text = string.Empty; useButton.SetActive(false); equipButton.SetActive(false); unequipButton.SetActive(false); dropButton.SetActive(false); } public void Toggle() { if(IsOpen()) { InventoryWindow.SetActive(false); } else { InventoryWindow.SetActive(true); } } public bool IsOpen() { return InventoryWindow.activeInHierarchy; } void AddItem() { ItemData data = CharacterManager.Instance.Player.itemData; // 아이템이 중복가능한지 체크 if (data.canStack) { ItemSlot slot = GetItemStack(data); if(slot != null) { slot.quantity++; UpdateUI(); CharacterManager.Instance.Player.itemData = null; return; } } // 중복이 불가능하면 빈 슬롯 ItemSlot emptySlot = GetEmptySlot(); // 빈 슬롯이 있을 때 if(emptySlot != null) { emptySlot.item = data; emptySlot.quantity = 1; UpdateUI(); CharacterManager.Instance.Player.itemData = null; return; } // 빈 슬롯이 없을 때 ThrowItem(data); CharacterManager.Instance.Player.itemData = null; } private void UpdateUI() { for (int i = 0; i < slots.Length; i++) { if (slots[i].item != null) { slots[i].Set(); } else { slots[i].Clear(); } } } private ItemSlot GetItemStack(ItemData data) { for (int i = 0; i < slots.Length; i++) { if (slots[i].item == data && slots[i].quantity < data.maxStackAmount) { return slots[i]; } } return null; } private ItemSlot GetEmptySlot() { for (int i = 0; i < slots.Length; i++) { if (slots[i].item == null) { return slots[i]; } } return null; } private void ThrowItem(ItemData data) { Instantiate(data.dropPrefab, dropPosition.position, Quaternion.Euler(Vector3.one * Random.value * 360)); } public void SelectItem(int index) { if (slots[index].item == null) return; selectedItem = slots[index].item; selectedItemIndex = index; selectedItemName.text = selectedItem.displayName; selectedItemDescription.text = selectedItem.description; selectedStatName.text = string.Empty; selectedStatValue.text = string.Empty; for (int i = 0; i < selectedItem.consumables.Length; i++) { selectedStatName.text += selectedItem.consumables[i].type.ToString() + "\n"; selectedStatValue.text += selectedItem.consumables[i].value.ToString() + "\n"; } useButton.SetActive(selectedItem.type == ItemType.Consumable); equipButton.SetActive(selectedItem.type == ItemType.Equipable && !slots[index].equiped); unequipButton.SetActive(selectedItem.type == ItemType.Equipable && slots[index].equiped); dropButton.SetActive(true); } public void OnUseButton() { if (selectedItem.type == ItemType.Consumable) { for (int i = 0; i < selectedItem.consumables.Length; i++) { switch(selectedItem.consumables[i].type) { case ConsumableType.Health: condition.Heal(selectedItem.consumables[i].value); break; case ConsumableType.Hunger: condition.Eat(selectedItem.consumables[i].value); break; } } RemoveSelectedItem(); } } public void OnDropButton() { ThrowItem(selectedItem); RemoveSelectedItem(); } private void RemoveSelectedItem() { slots[selectedItemIndex].quantity--; if (slots[selectedItemIndex].quantity <= 0 ) { selectedItem = null; slots[selectedItemIndex].item = null; selectedItemIndex = -1; ClearSelectedItemWindow(); } UpdateUI(); } public void OnEquipButton() { if (slots[curEquipIndex].equiped) { UnEquip(curEquipIndex); } slots[selectedItemIndex].equiped = true; curEquipIndex = selectedItemIndex; CharacterManager.Instance.Player.equip.EquipNew(selectedItem); UpdateUI(); SelectItem(selectedItemIndex); } private void UnEquip(int index) { slots[index].equiped = false; CharacterManager.Instance.Player.equip.UnEquip(); UpdateUI(); if (selectedItemIndex == index) { SelectItem(selectedItemIndex); } } public void OnUnEquipButton() { UnEquip(selectedItemIndex); } }
ItemSlot.cs
using TMPro; using UnityEngine; using UnityEngine.UI; public class ItemSlot : MonoBehaviour { public ItemData item; public Button button; public Image icon; public TextMeshProUGUI quantityText; private Outline outline; public UIInventory inventory; public int index; public bool equiped; public int quantity; private void Awake() { outline = GetComponent<Outline>(); } private void OnEnable() { outline.enabled = equiped; } public void Set() { icon.gameObject.SetActive(true); icon.sprite = item.Icon; quantityText.text = quantity > 1 ? quantity.ToString() : string.Empty; if (outline != null) { outline.enabled = equiped; } } public void Clear() { item = null; icon.gameObject.SetActive(false); quantityText.text = string.Empty; } public void OnClickButton() { inventory.SelectItem(index); } }
PlayerController.cs (수정)
using System; using UnityEngine; using UnityEngine.InputSystem; public class PlayerController : MonoBehaviour { [Header("Movement")] public float moveSpeed; public float jumpPower; private Vector2 curMovementInput; public LayerMask groundLayerMask; [Header("Look")] public Transform cameraContainer; public float minXLook; public float maxXLook; private float camCurXRot; public float lookSensivity; private Vector2 mouseDelta; public bool canLook = true; public Action inventory; private Rigidbody _rigidbody; private void Awake() { _rigidbody = GetComponent<Rigidbody>(); } private void Start() { Cursor.lockState = CursorLockMode.Locked; } private void FixedUpdate() { Move(); } private void LateUpdate() { if (canLook) { CameraLook(); } } private void Move() { Vector3 dir = transform.forward * curMovementInput.y + transform.right * curMovementInput.x; dir *= moveSpeed; dir.y = _rigidbody.velocity.y; // 점프할 때 상하 움직임도 반영 _rigidbody.velocity = dir; } private void CameraLook() { camCurXRot += mouseDelta.y * lookSensivity; camCurXRot = Mathf.Clamp(camCurXRot, minXLook, maxXLook); cameraContainer.localEulerAngles = new Vector3(-camCurXRot, 0, 0); transform.eulerAngles += new Vector3(0, mouseDelta.x * lookSensivity); } public void OnMove(InputAction.CallbackContext context) { // 키가 눌렸을 때 if (context.phase == InputActionPhase.Performed) { curMovementInput = context.ReadValue<Vector2>(); } // 키가 떼어졌을 때 else if (context.phase == InputActionPhase.Canceled) { curMovementInput = Vector2.zero; } } public void OnLook(InputAction.CallbackContext context) { mouseDelta = context.ReadValue<Vector2>(); } public void OnJump(InputAction.CallbackContext context) { if (context.phase == InputActionPhase.Started && IsGrounded()) { _rigidbody.AddForce(Vector2.up * jumpPower, ForceMode.Impulse); } } private bool IsGrounded() { // 다리 4개라고 생각 Ray[] rays = new Ray[4] { new Ray(transform.position + (transform.forward * 0.2f) + (transform.up * 0.01f), Vector3.down), new Ray(transform.position + (-transform.forward * 0.2f) + (transform.up * 0.01f), Vector3.down), new Ray(transform.position + (transform.right * 0.2f) + (transform.up * 0.01f), Vector3.down), new Ray(transform.position + (-transform.right * 0.2f) + (transform.up * 0.01f), Vector3.down) }; for (int i = 0; i < rays.Length; i++) { if (Physics.Raycast(rays[i], 0.1f, groundLayerMask)) { return true; } } return false; } public void OnInventory(InputAction.CallbackContext context) { if(context.phase == InputActionPhase.Started) { inventory?.Invoke(); ToggleCursor(); } } private void ToggleCursor() { bool toggle = Cursor.lockState == CursorLockMode.Locked; Cursor.lockState = toggle ? CursorLockMode.None : CursorLockMode.Locked; canLook = !toggle; } }
Player.cs (수정)
using System; using UnityEngine; public class Player : MonoBehaviour { public PlayerController controller; public PlayerCondition condition; public Equipment equip; public ItemData itemData; public Action addItem; public Transform dropPosition; private void Awake() { CharacterManager.Instance.Player = this; controller = GetComponent<PlayerController>(); condition = GetComponent<PlayerCondition>(); equip = GetComponent<Equipment>(); } }
Equip.cs
using UnityEngine; public class Equip : MonoBehaviour { public virtual void OnAttackInput() { } }
EquipTool.cs
using UnityEngine; public class EquipTool : Equip { public float attackRate; private bool attacking; public float attackDistance; public float useStamina; [Header("Resource Gathering")] public bool doesGatherResources; [Header("Combat")] public bool doesDealDamage; public int damage; private Animator animator; private Camera camera; private void Start() { animator = GetComponent<Animator>(); camera = Camera.main; } public override void OnAttackInput() { if (!attacking) { if (CharacterManager.Instance.Player.condition.UseStamina(useStamina)) { attacking = true; animator.SetTrigger("Attack"); Invoke("OnCanAttack", attackRate); } } } private void OnCanAttack() { attacking = false; } public void OnHit() { Ray ray = camera.ScreenPointToRay(new Vector3(Screen.width / 2, Screen.height / 2, 0)); RaycastHit hit; if (Physics.Raycast(ray, out hit, attackDistance)) { if (doesGatherResources && hit.collider.TryGetComponent(out Resource resource)) { resource.Gather(hit.point, hit.normal); } if (doesDealDamage && hit.collider.TryGetComponent(out NPC npc)) { npc.TakePhysicalDamage(damage); } } } }
Equipment.cs
using UnityEngine; using UnityEngine.InputSystem; public class Equipment : MonoBehaviour { public Equip curEquip; public Transform equipParent; private PlayerController controller; private PlayerCondition condition; private void Start() { controller = GetComponent<PlayerController>(); condition = GetComponent<PlayerCondition>(); } public void EquipNew(ItemData data) { UnEquip(); curEquip = Instantiate(data.equipPrefab, equipParent).GetComponent<Equip>(); } public void UnEquip() { if (curEquip != null) { Destroy(curEquip.gameObject); curEquip = null; } } public void OnAttackInput(InputAction.CallbackContext context) { if (context.phase == InputActionPhase.Performed && curEquip != null && controller.canLook) { curEquip.OnAttackInput(); } } }
Resource.cs
using UnityEngine; public class Resource : MonoBehaviour { public ItemData itemToGive; public int quantityPerHit = 1; public int capacity; public void Gather(Vector3 hitPoint, Vector3 hitNormal) { for (int i = 0; i < quantityPerHit; i++) { if (capacity <= 0) break; capacity -= 1; Instantiate(itemToGive.dropPrefab, hitPoint + Vector3.up, Quaternion.LookRotation(hitNormal, Vector3.up)); } } }
PlayerCondition.cs
using System; using UnityEngine; public interface IDamagable { void TakePhysicalDamage(int damage); } public class PlayerCondition : MonoBehaviour, IDamagable { public UICondition uiCondition; Condition health { get { return uiCondition.health; } } Condition hunger { get { return uiCondition.hunger; } } Condition stamina { get { return uiCondition.Stamina; } } public float noHungerHealthDecay; public event Action onTakeDamage; private void Update() { hunger.Subtract(hunger.passiveValue * Time.deltaTime); stamina.Add(stamina.passiveValue * Time.deltaTime); if (hunger.curValue <= 0f) { health.Subtract(noHungerHealthDecay * Time.deltaTime); } if (health.curValue <= 0f) { Die(); } } public void Heal(float amount) { health.Add(amount); } public void Eat(float amount) { hunger.Add(amount); } public void Die() { Debug.Log("사망"); } public void TakePhysicalDamage(int damage) { health.Subtract(damage); onTakeDamage?.Invoke(); } public bool UseStamina(float amount) { if (stamina.curValue - amount < 0f) { return false; } stamina.Subtract(amount); return true; } }
PlayerCondition.cs
using System.Collections; using UnityEngine; using UnityEngine.AI; public enum AIState { Idle, Wandering, Attacking } public class NPC : MonoBehaviour, IDamagable { [Header("Stats")] public int health; public float walkSpeed; public float runSpeed; public ItemData[] dropOnDeath; [Header("AI")] private NavMeshAgent agent; public float detectDistance; private AIState aiState; [Header("Wandering")] public float minWanderDistance; public float maxWanderDistance; public float minWanderWaitTime; public float maxWanderWaitTime; [Header("Combat")] public int damage; public float attackRate; private float lastAttackTime; public float attackDistance; private float playerDistance; public float fieldOfView = 120f; private Animator animator; private SkinnedMeshRenderer[] meshRenderers; private void Awake() { agent = GetComponent<NavMeshAgent>(); animator = GetComponent<Animator>(); meshRenderers = GetComponentsInChildren<SkinnedMeshRenderer>(); } private void Start() { SetState(AIState.Wandering); } private void Update() { playerDistance = Vector3.Distance(transform.position, CharacterManager.Instance.Player.transform.position); animator.SetBool("Moving", aiState != AIState.Idle); switch (aiState) { case AIState.Idle: case AIState.Wandering: PassiveUpdate(); break; case AIState.Attacking: AttackingUpdate(); break; } } public void SetState(AIState state) { aiState = state; switch (aiState) { case AIState.Idle: agent.speed = walkSpeed; agent.isStopped = true; break; case AIState.Wandering: agent.speed = walkSpeed; agent.isStopped = false; break; case AIState.Attacking: agent.speed = runSpeed; agent.isStopped = false; break; } animator.speed = agent.speed / walkSpeed; } private void PassiveUpdate() { if (aiState == AIState.Wandering && agent.remainingDistance < 0.1f) { SetState(AIState.Idle); Invoke("WanderToNewLocation", Random.Range(minWanderWaitTime, maxWanderWaitTime)); } if (playerDistance < detectDistance) { SetState(AIState.Attacking); } } private void WanderToNewLocation() { if (aiState != AIState.Idle) return; SetState(AIState.Wandering); agent.SetDestination(GetWanderLocation()); } private Vector3 GetWanderLocation() { NavMeshHit hit; // 이동하는 최대 영역 NavMesh.SamplePosition(transform.position + (Random.onUnitSphere * Random.Range(minWanderDistance, maxWanderDistance)), out hit, maxWanderDistance, NavMesh.AllAreas); int i = 0; while (Vector3.Distance(transform.position, hit.position) < detectDistance) { NavMesh.SamplePosition(transform.position + (Random.onUnitSphere * Random.Range(minWanderDistance, maxWanderDistance)), out hit, maxWanderDistance, NavMesh.AllAreas); i++; if (i == 30) break; } return hit.position; } private void AttackingUpdate() { if (playerDistance < attackDistance && IsPlayerInFieldOfView()) { agent.isStopped = true; if (Time.time - lastAttackTime > attackRate) { lastAttackTime = Time.time; CharacterManager.Instance.Player.controller.GetComponent<IDamagable>().TakePhysicalDamage(damage); animator.speed = 1; animator.SetTrigger("Attack"); } } else { if (playerDistance < detectDistance) { agent.isStopped = false; NavMeshPath path = new NavMeshPath(); if (agent.CalculatePath(CharacterManager.Instance.Player.transform.position, path)) { agent.SetDestination(CharacterManager.Instance.Player.transform.position); } else { agent.SetDestination(transform.position); agent.isStopped = true; SetState(AIState.Wandering); } } else { agent.SetDestination(transform.position); agent.isStopped = true; SetState(AIState.Wandering); } } } bool IsPlayerInFieldOfView() { Vector3 directionToPlayer = CharacterManager.Instance.Player.transform.position - transform.position; float angle = Vector3.Angle(transform.forward, directionToPlayer); return angle < fieldOfView * 0.5f; } public void TakePhysicalDamage(int damage) { health -= damage; if (health <= 0) { Die(); } StartCoroutine(DamageFlash()); } private void Die() { for (int i = 0; i < dropOnDeath.Length; i++) { Instantiate(dropOnDeath[i].dropPrefab, transform.position + Vector3.up * 2, Quaternion.identity); } Destroy(gameObject); } private IEnumerator DamageFlash() { for (int i = 0; i < meshRenderers.Length; i++) { meshRenderers[i].material.color = new Color(1.0f, 0.6f, 0.6f); } yield return new WaitForSeconds(0.1f); for (int i = 0; i < meshRenderers.Length; i++) { meshRenderers[i].material.color = Color.white; } } }
NPC.cs
using System.Collections; using UnityEngine; using UnityEngine.AI; public enum AIState { Idle, Wandering, Attacking } public class NPC : MonoBehaviour, IDamagable { [Header("Stats")] public int health; public float walkSpeed; public float runSpeed; public ItemData[] dropOnDeath; [Header("AI")] private NavMeshAgent agent; public float detectDistance; private AIState aiState; [Header("Wandering")] public float minWanderDistance; public float maxWanderDistance; public float minWanderWaitTime; public float maxWanderWaitTime; [Header("Combat")] public int damage; public float attackRate; private float lastAttackTime; public float attackDistance; private float playerDistance; public float fieldOfView = 120f; private Animator animator; private SkinnedMeshRenderer[] meshRenderers; private void Awake() { agent = GetComponent<NavMeshAgent>(); animator = GetComponent<Animator>(); meshRenderers = GetComponentsInChildren<SkinnedMeshRenderer>(); } private void Start() { SetState(AIState.Wandering); } private void Update() { playerDistance = Vector3.Distance(transform.position, CharacterManager.Instance.Player.transform.position); animator.SetBool("Moving", aiState != AIState.Idle); switch (aiState) { case AIState.Idle: case AIState.Wandering: PassiveUpdate(); break; case AIState.Attacking: AttackingUpdate(); break; } } public void SetState(AIState state) { aiState = state; switch (aiState) { case AIState.Idle: agent.speed = walkSpeed; agent.isStopped = true; break; case AIState.Wandering: agent.speed = walkSpeed; agent.isStopped = false; break; case AIState.Attacking: agent.speed = runSpeed; agent.isStopped = false; break; } animator.speed = agent.speed / walkSpeed; } private void PassiveUpdate() { if (aiState == AIState.Wandering && agent.remainingDistance < 0.1f) { SetState(AIState.Idle); Invoke("WanderToNewLocation", Random.Range(minWanderWaitTime, maxWanderWaitTime)); } if (playerDistance < detectDistance) { SetState(AIState.Attacking); } } private void WanderToNewLocation() { if (aiState != AIState.Idle) return; SetState(AIState.Wandering); agent.SetDestination(GetWanderLocation()); } private Vector3 GetWanderLocation() { NavMeshHit hit; // 이동하는 최대 영역 NavMesh.SamplePosition(transform.position + (Random.onUnitSphere * Random.Range(minWanderDistance, maxWanderDistance)), out hit, maxWanderDistance, NavMesh.AllAreas); int i = 0; while (Vector3.Distance(transform.position, hit.position) < detectDistance) { NavMesh.SamplePosition(transform.position + (Random.onUnitSphere * Random.Range(minWanderDistance, maxWanderDistance)), out hit, maxWanderDistance, NavMesh.AllAreas); i++; if (i == 30) break; } return hit.position; } private void AttackingUpdate() { if (playerDistance < attackDistance && IsPlayerInFieldOfView()) { agent.isStopped = true; if (Time.time - lastAttackTime > attackRate) { lastAttackTime = Time.time; CharacterManager.Instance.Player.controller.GetComponent<IDamagable>().TakePhysicalDamage(damage); animator.speed = 1; animator.SetTrigger("Attack"); } } else { if (playerDistance < detectDistance) { agent.isStopped = false; NavMeshPath path = new NavMeshPath(); if (agent.CalculatePath(CharacterManager.Instance.Player.transform.position, path)) { agent.SetDestination(CharacterManager.Instance.Player.transform.position); } else { agent.SetDestination(transform.position); agent.isStopped = true; SetState(AIState.Wandering); } } else { agent.SetDestination(transform.position); agent.isStopped = true; SetState(AIState.Wandering); } } } bool IsPlayerInFieldOfView() { Vector3 directionToPlayer = CharacterManager.Instance.Player.transform.position - transform.position; float angle = Vector3.Angle(transform.forward, directionToPlayer); return angle < fieldOfView * 0.5f; } public void TakePhysicalDamage(int damage) { health -= damage; if (health <= 0) { Die(); } StartCoroutine(DamageFlash()); } private void Die() { for (int i = 0; i < dropOnDeath.Length; i++) { Instantiate(dropOnDeath[i].dropPrefab, transform.position + Vector3.up * 2, Quaternion.identity); } Destroy(gameObject); } private IEnumerator DamageFlash() { for (int i = 0; i < meshRenderers.Length; i++) { meshRenderers[i].material.color = new Color(1.0f, 0.6f, 0.6f); } yield return new WaitForSeconds(0.1f); for (int i = 0; i < meshRenderers.Length; i++) { meshRenderers[i].material.color = Color.white; } } }
FootSteps.cs
using UnityEngine; public class FootSteps : MonoBehaviour { public AudioClip[] footstepClips; private AudioSource audioSource; private Rigidbody _rigidbody; public float footstepThreshold; public float footstepRate; private float footstepTime; private void Start() { _rigidbody = GetComponent<Rigidbody>(); audioSource = GetComponent<AudioSource>(); } private void Update() { if (Mathf.Abs(_rigidbody.velocity.y) < 0.1f) { if (_rigidbody.velocity.magnitude > footstepThreshold) { if(Time.time - footstepTime > footstepRate) { footstepTime = Time.time; audioSource.PlayOneShot(footstepClips[Random.Range(0, footstepClips.Length)]); } } } } }
MusicZone.cs
using UnityEngine; public class MusicZone : MonoBehaviour { public AudioSource audioSource; public float fadeTime; public float maxVolume; private float targetVolume; private void Start() { targetVolume = 0; audioSource = GetComponent<AudioSource>(); audioSource.volume = targetVolume; audioSource.Play(); } private void Update() { // 근삿값 if (!Mathf.Approximately(audioSource.volume, targetVolume)) { audioSource.volume = Mathf.MoveTowards(audioSource.volume, targetVolume, (maxVolume / fadeTime) * Time.deltaTime); } } private void OnTriggerEnter(Collider other) { if (other.CompareTag("Player")) { targetVolume = maxVolume; } } private void OnTriggerExit(Collider other) { if (other.CompareTag("Player")) { targetVolume = 0f; } } }
[결과물]
[회고]
오늘 배운 게 많아서 머리에 다 들어갔을지 걱정이다. 그런데 점점 게임다워지는 거 같아서 재밌었다. 내일부터는 개인과제에 전념해야 한다. 오늘 배운 것들이 기억이 잘 나지 않겠지만 찾아보면서 열심히 해야겠다.
'TIL > Unity' 카테고리의 다른 글
내일배움캠프 32일차 TIL - 3D 게임 기초 개발 - 개인 과제2 (0) 2024.05.30 내일배움캠프 31일차 TIL - 3D 게임 기초 개발 - 개인 과제 1 (0) 2024.05.29 내일배움캠프 29일차 TIL - 3D 게임 기초 개발 2 (0) 2024.05.27 내일배움캠프 28일차 TIL - 3D 게임 기초 개발 1 (0) 2024.05.24 내일배움캠프 27일차 TIL - 유니티 입문 팀 프로젝트 6 (0) 2024.05.23