개인 프로젝트 코드를 전체적으로 리펙토링하고
장비 장착 기능과 발사대를 구현하고
이외에 디테일한 부분을 수정하였다.
이거 구현하는데 고생 대비 코드가 좀 못생긴(?)것 같다.
아래 과정은 모두 구현한 후 복기하면서 작성한거라 약간 매끄럽다. 실상은 여기저기동서남북왔다갔다 구현했다.
아이템 데이터 스크립터블 오브젝트를 수정하였다.
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;
}
장비 아이템은 소비형아이템과 달리 다른 인벤토리에 들어간다. 이를 위한 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); // 새로 추가할 델리게이트
}
}
}
```
위 과정을 통해 장비 인벤토리에 해당 데이터 생성해야한다.
장비 아이템을 넣고 빼는 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);
}
}
```
각 슬롯에 데이터를 넣고, 이미지를 활성화하는, 즉 슬룻에서 아이템이 추가되는, 그리고 삭제되는 메소드, 역할을 가질 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);
}
}
장착한 장비를 인 게임 내에서 장착한것을 보여주는, 그리고 추가 능력치를 추가하는 역할을 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);
}
}
}
이거도 구현하는 시간이 생각보다 걸렸다.
기존 이동 시스템을 뒤바꾸느라 …
플렛폼 발사대 오브젝트 만들기 및 스크립트 작성
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; ;
}
}
여기서 문제가 생겼는데, 아무리 각도를 주어도 위로만 발사하는 사태가 벌어졌다.
문제는 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();
}
}
아래는 마저 못한 구현들이다.
~~레이저 트랩 (난이도 : ★★★★☆)~~
~~상호작용 가능한 오브젝트 표시 (난이도 : ★★★★★)~~
~~발전된 AI (난이도 : ★★★★★)~~