0523 Unity 부트캠프 [32일차]

Hyeon O·2025년 5월 23일

Unity_BootCamp 7주차

목록 보기
5/5

📚 오늘은….

개인 프로젝트 코드를 전체적으로 리펙토링하고
장비 장착 기능과 발사대를 구현하고
이외에 디테일한 부분을 수정하였다.

💡프로젝트 진행

  • 장비 장착 (난이도 : ★★★★☆)
    • 장비를 장착하여 캐릭터의 능력을 강화하는 시스템 구현
    • 예) 속도 증가 장비, 점프력 증가 장비 등

이거 구현하는데 고생 대비 코드가 좀 못생긴(?)것 같다.

아래 과정은 모두 구현한 후 복기하면서 작성한거라 약간 매끄럽다. 실상은 여기저기동서남북왔다갔다 구현했다.

  1. 아이템 데이터 스크립터블 오브젝트를 수정하였다.

        
    public enum ItemType
    {
        Resource,
        Equipable,
        Consumable
    }
    public enum EquipSlotType // 장비 타입 
    {
        Head,
        Hand
    }
    [CreateAssetMenu(fileName = "Item", menuName = "New Item")]
    public class ItemData : ScriptableObject
    {
        [Header("Info")]
        public string displayName;
        public string description;
        public ItemType type;
        public float coolTime;
        public Sprite icon;
        public GameObject dropPrefab;
       
        [Header("Stacking")]
        public bool canStack;
        public int maxStackAmount;
    
        [Header("Consumable")
        public List<ScriptableObject> actionAssets;
        public IEnumerable<IItemAction> GetActions() =>
            actionAssets.OfType<IItemAction>();
    
        [Header("Equip")]
        public GameObject equipPrefab;
        public float addSpeed;
        public float addJumpPower;
        public EquipSlotType equipSlotType;
    
    }
  2. 장비 아이템은 소비형아이템과 달리 다른 인벤토리에 들어간다. 이를 위한 UI를 구성하고
    필요한 스크립트를 다음과 같이 생각했다.
    1. 장비 아이템과 상호작용 → ItemObject의 OnIntercat() 호출 → player.AddItem 호출 : 여기에 열거형을 통해 동작 분리, 즉 장비에 들어갈 것인지 소비에 들어갈 것인지

    ```csharp
    public class Player : MonoBehaviour
    {
        public PlayerController controller;
        public PlayerCondition condition;
        public PlayerEquipment equipment;
    
        public Action<ItemData> addItem;
        public Action<ItemData> addEquipItem;
    
        public Transform dropPosition;
    
        private void Awake()
        {
            PlayerManager.Instance.Player = this;
            controller = GetComponent<PlayerController>();
            condition = GetComponent<PlayerCondition>();
            equipment = GetComponent<PlayerEquipment>();
        }
        public void AddItem(ItemData data)
        {
            if (data.type == ItemType.Consumable)
            {
                addItem?.Invoke(data); // 기존 소비형 인벤토리
            }
            else if (data.type == ItemType.Equipable)
            {
                addEquipItem?.Invoke(data); // 새로 추가할 델리게이트
            }
    
        }
    
    }
    ```
  3. 위 과정을 통해 장비 인벤토리에 해당 데이터 생성해야한다.
    장비 아이템을 넣고 빼는 EInventoryUI 스크립트를 작성

    ```csharp
    using System.Collections.Generic;
    using TMPro;
    using UnityEngine;
    using UnityEngine.UI;
    
    //장착 가능한 아이템(장비)의 인벤토리UI 관리
    public class EInventoryUI : MonoBehaviour
    {
        [Header("Root Panel")]
        public GameObject rootPanel;
    
        [Header("인벤 슬롯")]
        public List<EItemSlot> inventorySlots;
    
        [Header("장착 중 슬롯 2개")]
        public List<EItemSlot> equippedSlots;
    
        [Header("버튼")]
        public Button equipButton;
        public Button unequipButton;
    
        [Header("InfoText")]
        public TextMeshProUGUI EitemName;
        public TextMeshProUGUI EitemDescription;
    
        private EItemSlot selectedSlot;
        private EItemSlot selectedEquippedSlot;
    
        private void Awake()
        {
            rootPanel.SetActive(false);
        }
    
        // Start에서 구독 → Awake/OnEnable 타이밍 이슈 회피
        private void Start()
        {
            // Player가 세팅된 이후에만 구독
            var player = PlayerManager.Instance?.Player;
            if (player != null)
                player.addEquipItem += OnEquipItemAdded;
    
            equipButton.onClick.AddListener(OnEquipPressed);
            unequipButton.onClick.AddListener(OnUnequipPressed);
        }
    
        private void OnDestroy()
        {
            // 구독 해제
            var player = PlayerManager.Instance?.Player;
            if (player != null)
                player.addEquipItem -= OnEquipItemAdded;
    
            equipButton.onClick.RemoveListener(OnEquipPressed);
            unequipButton.onClick.RemoveListener(OnUnequipPressed);
        }
    
        public void Toggle(bool open)
        {
            rootPanel.SetActive(open);
            selectedSlot = null;
            selectedEquippedSlot = null;
            equipButton.gameObject.SetActive(false);
            unequipButton.gameObject.SetActive(false);
        }
    
        private void OnEquipItemAdded(ItemData data)
        {
            foreach (var slot in inventorySlots)
                if (slot.IsEmpty)
                {
                    slot.AddItem(data);
                    return;
                }
        }
    
        public void SelectSlot(EItemSlot slot)
        {
            if (selectedSlot != null && selectedSlot != slot)
            {
                selectedSlot.outline.enabled = false;
            }
            if(selectedEquippedSlot != null && selectedEquippedSlot != slot)
            {
                selectedEquippedSlot.outline.enabled = false;
            }
    
            if (inventorySlots.Contains(slot))
            {
                selectedSlot = slot;
                selectedEquippedSlot = null;
                EitemName.text = slot.itemData.name;
                EitemDescription.text  = slot.itemData.description;
                equipButton.gameObject.SetActive(slot.HasItem && equippedSlots.Exists(s => s.IsEmpty));
                unequipButton.gameObject.SetActive(false);
            }
            else if(equippedSlots.Contains(slot))
            {
                selectedEquippedSlot = slot;
                selectedSlot = null;
                EitemName.text = slot.itemData.name;
                EitemDescription.text = slot.itemData.description;
                equipButton.gameObject.SetActive(false);
                unequipButton.gameObject.SetActive(slot.HasItem);
            }
            else
            {
                selectedEquippedSlot = slot;
                selectedSlot = null;
                EitemName.text = null;
                EitemDescription.text = null;
                equipButton.gameObject.SetActive(false);
                unequipButton.gameObject.SetActive(slot.HasItem);
            }
        }
        
    
    private void OnEquipPressed()
        {
            if (selectedSlot == null || selectedSlot.IsEmpty) return;
    
            var free = equippedSlots.Find(s => s.IsEmpty);
            if (free == null) return;
    
            var data = selectedSlot.itemData;
    
            free.AddItem(selectedSlot.itemData);
            PlayerManager.Instance.Player.equipment.Equip(data.equipSlotType, data);
            selectedSlot.Clear();
            equipButton.gameObject.SetActive(false);
        }
    
        private void OnUnequipPressed()
        {
            if (selectedEquippedSlot == null || selectedEquippedSlot.IsEmpty) return;
    
            EquipSlotType slotType = selectedEquippedSlot.itemData.equipSlotType;
    
            PlayerManager.Instance.Player.equipment.Unequip(slotType);
    
            foreach (var slot in inventorySlots)
                if (slot.IsEmpty)
                {
                    slot.AddItem(selectedEquippedSlot.itemData);
                    break;
                }
    
            selectedEquippedSlot.Clear();
            unequipButton.gameObject.SetActive(false);
        }
    }
    
    ```
  4. 각 슬롯에 데이터를 넣고, 이미지를 활성화하는, 즉 슬룻에서 아이템이 추가되는, 그리고 삭제되는 메소드, 역할을 가질 EItemSlot 스크립트 작성

    using UnityEngine;
    using UnityEngine.UI;
    using UnityEngine.EventSystems;
    
    //장비 아이템 슬룻 UI
    public class EItemSlot : MonoBehaviour, IPointerClickHandler
    {
        [Header("Icon Image")]
        public Image iconImage;
    
        [Header("Selection Outline")]
        public Outline outline;
    
        public ItemData itemData;
    
        public bool IsEmpty => itemData == null;
        public bool HasItem => !IsEmpty;
    
        private void Awake()
        {
            // Ensure outline component
            outline = outline != null ? outline : GetComponent<Outline>();
            outline.enabled = false;
    
            // Enable raycast on icon so clicks register
            if (iconImage != null)
                iconImage.raycastTarget = true;
        }
    
        public void AddItem(ItemData data)
        {
            itemData = data;
            iconImage.sprite = data.icon;
            iconImage.enabled = true;
            iconImage.raycastTarget = true;
        }
    
        public void Clear()
        {
            itemData = null;
            iconImage.enabled = false;
            iconImage.raycastTarget = false;
            outline.enabled = false;
        }
    
        // IPointerClickHandler implementation
        public void OnPointerClick(PointerEventData eventData)
        {
            if (itemData == null) return;
    
            // Highlight selected slot
            outline.enabled = true;
    
            // Notify the inventory UI
            var ui = FindObjectOfType<EInventoryUI>();
            if (ui != null)
                ui.SelectSlot(this);
        }
    }
    
  5. 장착한 장비를 인 게임 내에서 장착한것을 보여주는, 그리고 추가 능력치를 추가하는 역할을 PlayerEquipment 스크립트를 작성

    using System;
    using System.Collections.Generic;
    using UnityEngine;
    
    //플레이어 아이템 장착 관리
    
    public class PlayerEquipment : MonoBehaviour
    {
        public Transform headMount;
        public Transform handMount;
    
        // 슬롯 타입별 장착 아이템
        
        public Dictionary<EquipSlotType, ItemData> equipped =
            new Dictionary<EquipSlotType, ItemData>();
    
        // 마운트된 비주얼
        private Dictionary<EquipSlotType, GameObject> visuals =
            new Dictionary<EquipSlotType, GameObject>();
    
        public event Action<EquipSlotType, ItemData> OnEquip;
        public event Action<EquipSlotType, ItemData> OnUnequip;
    
        private PlayerController controller;
        private float baseSpeed, baseJump;
    
        void Awake()
        {
            controller = GetComponent<PlayerController>();
            baseSpeed = controller.movSpeed;
            baseJump = controller.JumpPower;
        }
    
        public bool Equip(EquipSlotType slotType, ItemData data)
        {
            if (equipped.ContainsKey(slotType))
                return false;  // 이미 차 있음
    
            equipped[slotType] = data;
            ApplyStats();
            SpawnVisual(slotType, data);
            OnEquip?.Invoke(slotType, data);
            return true;
        }
    
        public bool Unequip(EquipSlotType slotType)
        {
            if (!equipped.TryGetValue(slotType, out var data))
                return false;
    
            equipped.Remove(slotType);
            RemoveVisual(slotType);
            ApplyStats();
            OnUnequip?.Invoke(slotType, data);
            return true;
        }
    
        private void ApplyStats()
        {
            // 모든 슬롯 합산
            float bonusSpeed = 0, bonusJump = 0;
            foreach (var data in equipped.Values)
            {
                bonusSpeed += data.addSpeed;
                bonusJump += data.addJumpPower;
            }
            controller.movSpeed = baseSpeed + bonusSpeed;
            controller.JumpPower = baseJump + bonusJump;
        }
    
        private void SpawnVisual(EquipSlotType slot, ItemData data)
        {
            Transform mount = slot == EquipSlotType.Head ? headMount : handMount;
            var prefab = data.equipPrefab;
            if (prefab == null) return;
    
            var go = Instantiate(prefab, mount);
            go.transform.localPosition = Vector3.zero;
            go.transform.localRotation = Quaternion.identity;
            var rb = go.GetComponent<Rigidbody>();
            if (rb != null)
            {
                rb.isKinematic = true;   // 물리 시뮬레이션 비활성
                rb.detectCollisions = false;  // 충돌 감지 해제
            }
    
            var col = go.GetComponent<Collider>();
            if (col != null)
                col.enabled = false;          // 콜라이더 끄기
            visuals[slot] = go;
        }
    
        private void RemoveVisual(EquipSlotType slot)
        {
            if (visuals.TryGetValue(slot, out var go))
            {
                Destroy(go);
                visuals.Remove(slot);
            }
        }
    }
    
  • 플랫폼 발사기 (난이도 : ★★★★★)
    • 캐릭터가 플랫폼 위에 서 있을 때 특정 방향으로 힘을 가해 발사하는 시스템 구현
      특정 키를 누르거나 시간이 경과하면 ForceMode를 사용해 발사

이거도 구현하는 시간이 생각보다 걸렸다.

기존 이동 시스템을 뒤바꾸느라 …

  1. 플렛폼 발사대 오브젝트 만들기 및 스크립트 작성

    using System.Collections;
    using System.Collections.Generic;
    using TMPro;
    using UnityEngine;
    
    public class LaunchPlatform : MonoBehaviour
    {
        [SerializeField]
        private float force;
        [SerializeField]
        private int WaitTime;
        [SerializeField]
        private TextMeshPro time;
        [SerializeField] 
        private float launchAngle = 60f;
    
        private int wtime;
    
        private Coroutine launchCoroutine;
        private Rigidbody targetBody;
    
        private void Start()
        {
            wtime = WaitTime;
        }
        public void OnCollisionEnter(Collision collision)
        {
            // 플레이어만 (태그 또는 컴포넌트 체크)
            if (!collision.gameObject.CompareTag("Player")) return;
    
            // 이미 코루틴이 돌고 있다면 멈추고 새로 시작
            if (launchCoroutine != null)
                StopCoroutine(launchCoroutine);
    
            targetBody = collision.rigidbody;
            time.gameObject.SetActive(true);
            launchCoroutine = StartCoroutine(Launching());
        }
    
        public void OnCollisionExit(Collision collision)
        {
            if (collision.rigidbody == targetBody)
            {
                // 플랫폼에서 벗어나면 타이머와 코루틴 취소
                if (launchCoroutine != null)
                    StopCoroutine(launchCoroutine);
                launchCoroutine = null;
                time.gameObject.SetActive(false);
            }
        }
    
        private IEnumerator Launching()
        {
            
            for (int i = WaitTime; i >=0; i--)
            {
                time.text = $"{i}";
                Debug.Log($"발사 {i}초 전");
                yield return new WaitForSeconds(1);
            }
            time.text = "발사!";
    
            float rad = launchAngle * Mathf.Deg2Rad;
            Vector3 dir = new Vector3(-Mathf.Cos(rad), Mathf.Sin(rad), 0f);
            
            targetBody.AddForce(dir * force , ForceMode.VelocityChange);
    
            yield return new WaitForSeconds(1f);
            time.gameObject.SetActive(false);
            launchCoroutine = null; ;
    
        }
    }
    
  2. 여기서 문제가 생겼는데, 아무리 각도를 주어도 위로만 발사하는 사태가 벌어졌다.
    문제는 PlayerController의 FixedUpdate()에서 호출되는 Move()가 문제였다.
    이를 해결하기위해 PlayerController를 수정하였다.

using System;
using System.Collections;
using UnityEngine;
using UnityEngine.InputSystem;

public enum PlayerState
{
    Normal,
    Jumping,
    Climbing
}

//플레이어 움직임, 시점제어, 등 물리 동작 컨트롤 담당

public class PlayerController : MonoBehaviour
{
    [Header("이동")]
    public float movSpeed;
    public float JumpPower;
    private Vector2 curMovementInput;
    private Rigidbody rb;
    public LayerMask groundLayerMask;

    private float baseSpeed;
    private float baseJump;

    [Header("상태")]
    public PlayerState state = PlayerState.Normal;

    [Header("방향")]
    public Transform cameraContainer;
    [SerializeField] private Camera playerCamera;
    public float minXLook;
    public float maxXLook;
    public float minFOV = 30f;
    public float maxFOV = 90f;
    private float camCurXRot;
    public float lookSensitivity;
    public float zoomSensitivity;
    private Vector2 mouseDelta;
    private bool canLook = true;

    [Header("카메라 전환")]
    public bool isFirstPerson = false;
    // 시점 위치
    [SerializeField] private Vector3 thirdPersonOffset = new Vector3(0, 2, -4);
    [SerializeField] private Vector3 firstPersonOffset = new Vector3(0, 1.6f, 0.2f);


    [Header("벽타기")]
    [SerializeField] private float climbingSpeed;
    [SerializeField] private LayerMask climbable;

    [Header("장비 인벤토리 UI")]
    public EInventoryUI equipUI;        
    private bool isEquipUIOpen = false;

    private bool isLaunched = false;

    // PlayerInput 컴포넌트 참조
    private PlayerInputHandler inputHandler;

    public Transform bodyBottom;

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

    private void Start()
    {
        Cursor.lockState = CursorLockMode.Locked;
        baseSpeed = movSpeed;
        baseJump = JumpPower;
    }



    private void FixedUpdate()
    {
        // 클라이밍 우선 처리
        if (state == PlayerState.Climbing)
        {
            ClimbMove(inputHandler.MoveInput);
            return;
        }
        // 지면 감지: 바닥이면 Normal, 아니면 Jumping
        if (IsGrounded())
        {
            state = PlayerState.Normal;
            Move(inputHandler.MoveInput);
        }
        else
        {
            state = PlayerState.Jumping;
            // 공중에서는 이동 입력 무시
        }
    }

    private void LateUpdate()
    {
        if (canLook)
            CameraLook(inputHandler.LookInput);
    }

    // Look  처리
    private void OnLook(Vector2 input)
    {
        mouseDelta = input;
    }

    // Jump 처리
    public void TryJump()
    {
        if (state != PlayerState.Normal) return;
        if (IsGrounded() && PlayerManager.Instance.player.condition.stamina.curValue > 10 )
        {
            PlayerManager.Instance.player.condition.UseStamina(10);

            state = PlayerState.Jumping;
            rb.AddForce(Vector2.up * JumpPower, ForceMode.Impulse);
            
        }
    }

    // Inventory 입력에 대한 처리(인벤토리 열기)
    public void ToggleInventory()
    {
       isEquipUIOpen = !isEquipUIOpen;
        equipUI.Toggle(isEquipUIOpen);

        // 마우스 회전 제어
        canLook = !isEquipUIOpen;

        // 커서 보이기/숨기기
        Cursor.visible = isEquipUIOpen;
        Cursor.lockState = isEquipUIOpen
                              ? CursorLockMode.None
                              : CursorLockMode.Locked;
    }
    public void Zoom(float scrollY)
    {
        float newFOV = playerCamera.fieldOfView - scrollY * zoomSensitivity;
        playerCamera.fieldOfView = Mathf.Clamp(newFOV, minFOV, maxFOV);
    }
 
    private void Move(Vector2 input)
    {
        Vector3 dir = (transform.forward * input.y + transform.right * input.x) * movSpeed;
        dir.y = rb.velocity.y;
        rb.velocity = dir;
    }


    private void ClimbMove(Vector2 input)
    {
        Vector3 dir = new Vector3(input.x, input.y, 0f) * climbingSpeed;
        rb.velocity = dir;

        // 벽 꼭대기 및 바닥 감지
        Vector3 bottom = bodyBottom.transform.position + Vector3.down * 1f;
        
        bool hitBelow = Physics.Raycast(bottom, transform.forward, 1f, climbable);
        Debug.DrawRay(bottom, transform.forward, Color.yellow);
        
        if (!hitBelow)
        {
            ExitClimbMode();
        }

    }
    public void EnterClimbMode()
    {
        state = PlayerState.Climbing;
        rb.useGravity = false;
    }

    public void ExitClimbMode()
    {
        rb.useGravity = true;
        // 벽? 위로 올라서기 위한코드
        //이 코드가 없으면 올라가놓고 다시 내려온다..
        rb.AddForce(transform.forward * 3f, ForceMode.VelocityChange);
        // 항상 Normal 상태로 전환 (점프 방지)
        state = PlayerState.Normal;
    }


        private void CameraLook(Vector2 lookInput)
    {
        camCurXRot += lookInput.y * lookSensitivity;
        camCurXRot = Mathf.Clamp(camCurXRot, minXLook, maxXLook);
        cameraContainer.localEulerAngles = new Vector3(-camCurXRot, 0, 0);
        transform.eulerAngles += Vector3.up * (lookInput.x * lookSensitivity);
        mouseDelta = Vector2.zero;
    }

    private bool IsGrounded()
    {
        Vector3 origin = bodyBottom != null ? bodyBottom.position : (transform.position + Vector3.up * 0.1f);
        bool r = Physics.Raycast(origin, Vector3.down, 1.1f, groundLayerMask);
        Debug.DrawRay(origin, Vector3.down, Color.yellow);
        return r;
    }

    //스피드아이템 사용시 이동속도 증가
    public void ApplySpeedUp(float a,float d)
    {
        StartCoroutine(SpeedUpCoroutine(a,d));
    }
    private IEnumerator SpeedUpCoroutine(float a,float d)
    {
        movSpeed += a;
        yield return  new WaitForSeconds(d);
        movSpeed -= a;
    }
    //시점 변환
    public void SwitchView()
    {
        isFirstPerson = !isFirstPerson;

        Vector3 targetOffset = isFirstPerson ? firstPersonOffset : thirdPersonOffset;
        playerCamera.transform.localPosition = targetOffset;
    }

    //벽타기 상호작용 시 호출.
    public void ToggleClimbMode()
    {
        if (PlayerState.Climbing == state)
            ExitClimbMode();
        else
            EnterClimbMode();
    }
}

아래는 마저 못한 구현들이다.

  • ~~레이저 트랩 (난이도 : ★★★★☆)~~

    • Raycast를 사용해 특정 구간을 레이저로 감시하고, 플레이어가 레이저를 통과하면 경고 메시지나 트랩 발동
  • ~~상호작용 가능한 오브젝트 표시 (난이도 : ★★★★★)~~

    • 상호작용 가능한 오브젝트에 마우스를 올리면 해당 오브젝트에 UI를 표시
    • 예) 문에 마우스를 올리면 'E키를 눌러 열기' 텍스트 표시.
      레버(Lever): 'E키를 눌러 당기기' 텍스트 표시.
      상자(Box): 'E키를 눌러 열기' 텍스트 표시.
      버튼(Button): 'E키를 눌러 누르기' 텍스트 표시.
  • ~~발전된 AI (난이도 : ★★★★★)~~

    • AI Navigation 시스템을 활용하여 맵에 다양한 구조물에 대한 계산 가중치를 설정
    • Navigation 시스템 활용 예시
profile
천천히, 꾸준하게, 끝까지

0개의 댓글