ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 내일배움캠프 30일차 TIL - 3D 게임 기초 개발 3
    TIL/Unity 2024. 5. 28. 18:10

     

    [한 줄 요약]

    유니티 숙련 주차 개인 강의를 학습하고, 3D 게임 개발 기초 단계 중 인벤토리, 아이템 장착, 자원 채취, 스태미나, AI 네비게이션, 적 생성과 로직, 사운드 등을 실습했다.


    [학습 내용]

     

    유니티 숙련 주차 강의


    인벤토리 구현

    아이템 슬롯 UI 생성

    1. ItemSlot 이미지 오브젝트 생성 후 Button과 OutLine 컴포넌트 부착 후 OutLine 컴포넌트 비활성화
    2. ItemSlot 스크립트 생성 후 ItemSlot 오브젝트에 부착
    3. 아이템 이미지를 담을 이미지 오브젝트와 갯수를 담을 텍스트 오브젝트를 자식 오브젝트로 부착
    4. ItemSlot 오브젝트를 프리펩화하고 UIInventory 캔버스 오브젝트를 만들어서 그곳에 자식 오브젝트로 부착

    4. ItemSlot 구성

     

    인벤토리 UI 생성

    1. 배경 이미지를 담을 BG 이미지 오브젝트와 Grid Layout Group 컴포넌트를 담은 Slots 오브젝트 생성
    2. ItemSlot을 여러개 복사 후 Slots 자식 오브젝트로 부착
    3. InfoBG 이미지 오브젝트를 만들고 하위에 아이템 이름, 설명, 스탯 정보 등 4개의 텍스트 부착
    4. 아이템 사용, 장착, 해제, 버리기 총 4개의 버튼 추가 부착
    5. UIInventory 스크립트 생성 후 UIInventory 오브젝트에 부착

    2. Slots에 부착
    2. Grid Layout으로 정렬된 모습

     

    3. 텍스트 4개 부착
    4. 버튼 4개 추가 부착
    4. 텍스트와 버튼이 추가된 모습

     

    인벤토리 스크립트 부착

    1. ItemSlot 스크립트 작성
    2. UIInventory 스크립트 작성 후 컴포넌트에서 값 할당
    3. Player 오브젝트 이벤트에 OnInventory 메서드 등록

    2. UIInventory 값 할당
    3. 이벤트 등록

     

    아이템 파밍

    1. UIInventory 스크립트에 AddItem 메서드 등 아이템 획득 기능 추가
    2. Player 오브젝트 하위에 DropPositon 오브젝트 추가 후 Player 컴포넌트 값 할당
    3. ItemSlot 스크립트에 Set 메서드 등 획득 아이템 인벤토리 표시 기능 추가
    4. ItemSlot 프리펩의 ItemSlot 컴포넌트에 값 할당

    2. DropPosition
    2. Player 컴포넌트 값 할당
    4.ItemSlot 컴포넌트 값 할당
    4. 획득한 아이템이 인벤토리에 표시되는 모습

     

    파밍 아이템 정보 표시

    1. ItemSlot 스크립트에 OnClickButton 메서드로 버튼 클릭 시 버튼 기능 추가
    2. ItemSlot 프리펩의 이벤트에 OnClickButton 메서드 등록
    3. UIInventory 스크립트에 SelectItem 메서드 등 선택 아이템 정보 표시 기능 추가
    4. Item_Carrot SO에 Consumable 값 할당

    2. 이벤트에 OnClickButton 메서드 등록
    4. Item_Carrot의 Consumables 값
    4. 아이템 클릭 시 아이템 정보가 표시되는 모습

     

    사용, 버리기 버튼 기능 구현

    1. UIInventory 스크립트에 OnUseButton 메서드 등 버튼 클릭 시 동작 기능 구현
    2. 각 버튼 오브젝트에 이벤트 등록

    2. UseButton 이벤트에 등록된 OnUseButton 메서드

     

    아이템 장착

    아이템 카메라 생성

    1. 플레이어 오브젝트에 새 카메라 추가 후 Clear Flags를 Depth Only로 변경
    2. 레이어에 Equip 레이어 추가
    3. 카메라의 Culling Mask를 Equip 레이어로 설정
    4. 이름을 EquipCamera로 변경 후 오디오 리스너 컴포넌트 제거

    1. 카메라 추가
    2. Equip 레이어 추가
    3. Culling Mask

     

    아이템 장착 버튼 기능 구현

    1. 장착할 아이템을 프리펩화
    2. ItemData 스크립트에 equipPrefab 게임오브젝트 변수 추가
    3. 장착할 아이템 SO들의 값에 프리펩 추가
    4. Equip, EquipTool 스크립트 작성
    5. 장착 아이템 프리펩에 EquipTool 스크립트 부착 후 레이어를 Equip 레이어로 변경
    6. Equipment 스크립트 작성
    7. Player 오브젝트에 Equipment 스크립트 부착 후 컴포넌트 값 할당
    8. UIInventory 스크립트에 OnEquipButton 메서드 등 버튼 클릭 시 장착 기능 구현
    9. 각 버튼에 맞는 이벤트에 메서드 등록

    1. 프리펩화된 아이템
    3. 프리펩 추가
    7. Equipment 컴포넌트 값 할당
    9. 버튼 이벤트에 메서드 등
    9. 검이 장착된 모습

     

    공격 및 자원 채취

    공격 애니메이션

    1. 제공된 애니메이션 임포트
    2. EquipTool와 Equipment 스크립트에 OnAttackInput 메서드 등 공격 기능 구현
    3. Player 오브젝트의 이벤트에 OnAttackInput 메서드 등록
    4. 장착 아이템 프리펩에 애니메이터 추가
    5. 장착 아이템 프리펩의 EquipTool 스크립트 컴포넌트에 값 할당

    1. 제공된 애니메이션
    3. 이벤트에 OnAttackInput 메서드 등록
    4. 애니메이터 추가
    5. EquipTool 스크립트에 값 할당

     

    자원 채취

    1. Resource_Tree 오브젝트 생성
    2. Resource 레이어 추가 및 Resource_Tree에 적용
    3. Resource 스크립트 작성 및 Resource_Tree에 컴포넌트 부착
    4. Attack 애니메이션에 이벤트 추가
    5. Equip_Sword 프리펩에 들어가서 애니메이션 안의 이벤트를 클릭
    6. EquipTool 스크립트의 OnHit 메서드 등록
    7. Resource_Tree 오브젝트의 Resource 컴포넌트에 값 할당

    2. Resource 레이어 추가
    4. 애니메이션에 이벤트 추가
    6 OnHit 메서드 등록
    7. Resource 스크립트 값 할당
    7. 나무가 생성된 모습

     

    스태미나 사용

    1. PlayerCondition 스크립트에 UseStamina 메서드 등 스태미나 소모 기능 구현
    2. EquipTool 스크립트에서 공격 시 UseStamina 메서드 호출하는 기능 구현
    3. 장착 아이템 프리펩에서 EquipTool 컴포넌트의 UseStamina 값 할당

    3. EquipTool 스크립트 값 할당

     

    AI 네비게이션

    AI 네비게이션(AI Navigation)

    AI 네비게이션은 인공지능이 게임이나 시뮬레이션 등 가상환경에서 이동하는 방법을 결정하는 기술이다. 주로 3D 게임에서 캐릭터나 NPC가 지능적으로 이동하도록 만들어진다. 이를 위해 지형, 장애물, 목표지점 등을 고려하여 적절한 경로를 생성하고 이동하는데 사용된다.

     

    주요 기술 및 기능

    • Navigation Mesh : 3D 공간을 그리드로 나누어 이동 가능한 지역과 장애물이 있는 지역을 구분하는 매쉬
      캐릭터가 이동할 수 있는 영역과 이동할 수 없는 영역을 정의하고 이를 기반으로 경로를 설정
    • Pathfinding : 캐릭터의 현재 위치에서 목표지점까지 가장 적절한 경로를 찾는 알고리즘
      주로 A* 알고리즘이 사용된다.
    • Steering Behavior : 경로를 따라 이동할 때 보다 자연스러운 동작을 구현하는데 사용
      동적으로 캐릭터의 이동 방향과 속력을 조정하여 부드럽고 현실적인 이동을 시뮬레이션한다.
    • Obstacle Avoidance : 캐릭터가 이동 중에 장애물과 충돌하지 않도록 하는 기술
      각종 센서나 알고리즘을 사용하여 장애물을 감지하고 피하는 동작을 수행한다.
    • Local Avoidance : 여러 캐릭터나 NPC가 서로 충돌하지 않도록 하는 기술
      캐릭터 사이의 거리를 유지하거나 회피동작을 수행하여 서로 부딪히지 않게 한다.

     

    적 구현

    AI Naviagtion

    패키지 매니저에서 AI Naviagtion를 설치한다.

    AI Navigation
    Window - AI - Navigation (Obsolete)
    LowPolyTerrain 오브젝트의 Navigation
    _Environments 오브젝트에서 Bake 버튼을 누르면
    걸어다닐 수 있는 공간과 없는 공간이 표시된다.
    Resource_Tree 오브젝트에 Nav Mesh Obstacle 컴포넌트 추가

     

    적 오브젝트 생성

    1. NPC 오브젝트 생성 후 준비된 프리펩을 자식으로 부착하고 Animator 컴포넌트 부착
    2. 애니메이션 컨트롤러 설정
    3. NPC 스크립트 생성 후 NPC 오브젝트에 컴포넌트로 추가
    4. NPC 오브젝트에 Box Collider와 Nav Mesh Agent 컴포넌트 추가
    5. NPC 스크립트 작성 후 NPC 컴포넌트 일부 값 할당
    6. NPC 오브젝트에 BearAvatar 아바타 부착
    7. NPC 스크립트에 AttackingUpdate 메서드 등 공격 기능 구현
    8. NPC 오브젝트에 NPC 컴포넌트 나머지 값 할당

    1. Animator 컴포넌트 부착
    2. 애니메이션 컨트롤러 설정
    4. NPC 오브젝트 컴포넌트 추가
    5. NPC 컴포넌트 일부 값 할당
    6.아바타 부착
    6. NPC 오브젝트가 동작하는 모습
    8. NPC 컴포넌트 나머지 값 할당

     

    적 오브젝트 피격 구현

    1. NPC 스크립트에 IDamagable 인터페이스 상속하고 TakePhysicalDamage 메서드 등으로 피격 구현
    2. EquipTool 스크립트의 OnHit 메서드에서 TakePhysicalDamage 메서드 호출
    3. NPC 컴포넌트의 Drop On Death에 Item_Carrot과 Item_Wood 오브젝트 추가

    3. NPC 컴포넌트 값 추가

     

    사운드

    걸음소리 효과음

    1. FootSteps, MusicZone 스크립트 생성
    2. Player 오브젝트에 FootSteps, Audio Source 컴포넌트 부착
    3. FootSteps 스크립트 작성 후 Player 오브젝트의 컴포넌트에서 값 할당

    2. Player 오브젝트에 컴포넌트 부착
    3. FootSteps 컴포넌트 값 할당

     

    뮤직존

    1. MusicZone 오브젝트를 추가하고 Box Collider 컴포넌트 부착
    2. Box Collider 크기 조절 후 IsTrigger 체크
    3. Audio Source 컴포넌트 부착 후 AudioClip 설정해준 뒤 Loop 체크
    4. MusicZone 스크립트 작성 후 MusicZone 오브젝트에 부착
    5. MusicZone 컴포넌트 값 할당
    6. Player 오브젝트 Player 태그로 변경

    5. MusicZone 컴포넌트 값 할당

     

    코드

    더보기

    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;
            }
        }
    }

     


    [결과물]

    플레이 화면


    [회고]

    오늘 배운 게 많아서 머리에 다 들어갔을지 걱정이다. 그런데 점점 게임다워지는 거 같아서 재밌었다. 내일부터는 개인과제에 전념해야 한다. 오늘 배운 것들이 기억이 잘 나지 않겠지만 찾아보면서 열심히 해야겠다.