이번에 유투브 강의영상을 보고 따라 만들어 보기로 하였다. 여기서 똑같이 만드는 것이 아닌 내 방식대로 만들어보려고 한다.
그리고 여기에 내가 응원하는 배우님이 카페에 올려주신 사진을 활용해서 카드 짝 맞추기 게임을 만들어 볼 것이다.
구현 순서는 영상에 나오는 순서대로 구현을 해보도록 할 것이다.
1.카드 및 보드 구현
우선 카드 객체를 구현해보자 필요한 기능은 뒤집는 기능이 필요하다. 이 기능은 Dotween과 IPointerClickHandler를 사용하여 구현하였다.
Card.cs
using UnityEngine;
using UnityEngine.EventSystems;
using DG.Tweening;
public class Card : MonoBehaviour, IPointerClickHandler
{
[SerializeField] private SpriteRenderer spriteRenderer;
[SerializeField] private Sprite frontSprite;
[SerializeField] private Sprite backSprite;
private Board board;
private bool isFlipped = false;
private bool isAnimating = false;
private void Awake()
{
spriteRenderer = GetComponent<SpriteRenderer>();
}
public void SetCard(Sprite sprite, Board board)
{
frontSprite = sprite;
this.board = board;
backSprite = Resources.Load<Sprite>("Sprites/Front/Front"); // 뒷면 기본 이미지
spriteRenderer.sprite = backSprite;
}
public void OnPointerClick(PointerEventData eventData)
{
if (board.GetSelectedCardCount() >= 2 || isFlipped || isAnimating)
return; // 이미 2장이 선택되었으면 클릭 방지
FlipCard();
board.SelectCard(this);
}
public void FlipCard()
{
if (isAnimating) return;
isAnimating = true;
Vector3 targetScale = new Vector3(0f, transform.localScale.y, transform.localScale.z);
transform.DOScale(targetScale, 0.2f).OnComplete(() =>
{
spriteRenderer.sprite = isFlipped ? backSprite : frontSprite;
isFlipped = !isFlipped;
transform.DOScale(Vector3.one, 0.2f).OnComplete(() =>
{
isAnimating = false;
});
});
}
public void FlipBack()
{
if (!isFlipped) return;
FlipCard();
}
public Sprite GetSprite()
{
return frontSprite;
}
public bool IsFlipped()
{
return isFlipped;
}
}
보드는 모든 카드의 위치를 관리해주고 룰을 결정해준다. 카드는 4*5배열로 배치되게 하였다.
Board.cs
using UnityEngine;
using System.Collections.Generic;
using System.Linq;
public class Board : MonoBehaviour
{
[SerializeField] private GameObject cardPrefab;
[SerializeField] private Sprite[] cardSprites; // 카드 앞면 (1~6번 이미지)
private List<Card> cards = new List<Card>();
private List<Card> selectedCards = new List<Card>(); // 선택된 카드들
private int rowCount = 5; //세로
private int colCount = 4; //가로
private float xStart = -2.1f;
private float yStart = 3.3f;
private float xSpacing = 1.4f;
private float ySpacing = -1.8f;
private void Awake()
{
cardPrefab = Resources.Load<GameObject>("Prefabs/Card/Card");
LoadSprites();
ShuffleCards();
InitBoard();
}
void LoadSprites()
{
// Resources 폴더에서 "Sprites/0~9" 로드
cardSprites = new Sprite[10];
for (int i = 0; i < 10; i++)
{
cardSprites[i] = Resources.Load<Sprite>($"Sprites/Back/{i}");
}
}
public void ShuffleCards()
{
List<Sprite> tempSprites = new List<Sprite>();
// 0~9번 카드 각각 2장씩 추가
foreach (var sprite in cardSprites)
{
tempSprites.Add(sprite);
tempSprites.Add(sprite);
}
// 랜덤 섞기
tempSprites = tempSprites.OrderBy(x => Random.value).ToList();
// cards 리스트에 카드 추가
cards.Clear();
for (int i = 0; i < tempSprites.Count; i++)
{
GameObject newCard = Instantiate(cardPrefab, Vector3.zero, Quaternion.identity, this.transform);
Card card = newCard.GetComponent<Card>();
card.SetCard(tempSprites[i], this);
cards.Add(card);
}
}
public void InitBoard()
{
int index = 0;
for (int i = 0; i < rowCount; i++)
{
for (int j = 0; j < colCount; j++)
{
if (index >= cards.Count) return; // 카드 개수 초과 방지
// 위치 설정
Vector3 pos = new Vector3(xStart + (xSpacing * j), yStart + (ySpacing * i), 0);
// 기존에 생성된 카드 객체를 위치만 변경
cards[index++].transform.position = pos;
}
}
}
public void SelectCard(Card card)
{
if (selectedCards.Contains(card) || selectedCards.Count >= 2)
return;
selectedCards.Add(card);
if (selectedCards.Count == 2)
{
CheckMatch();
}
}
void CheckMatch()
{
if (selectedCards.Count < 2) return; // 두 장 선택되지 않으면 비교 불가
if (selectedCards[0].GetSprite() == selectedCards[1].GetSprite())
{
// 같은 카드라면 유지
selectedCards.Clear();
Managers.Audio.PlaySound("Match"); // 카드 맞추면 효과음 재생!
}
else
{
// 다른 카드라면 1초 후 다시 뒤집기
Invoke(nameof(ResetCards), 1f);
}
}
void ResetCards()
{
foreach (var card in selectedCards)
{
card.FlipBack();
}
selectedCards.Clear();
}
public int GetSelectedCardCount() => selectedCards.Count;
public List<Card> GetCards() => cards;
}
실제로 배치된 모습을 보면 다음과 같다
이제 GameManager를 통해 이 보드의 초기화 함수인 Init을 호출해주고 코루틴 함수를 통해 모든 카드를 오픈하고 다시 뒤집어서 유저가 게임을 진행할 수 있게 한다. 또한 시간을 관리해주고 게임이 이겼는지 확인해준다.
GamaManager.cs
using System;
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.PlayerLoop;
using UnityEngine.UI;
using static UnityEngine.RuleTile.TilingRuleOutput;
public class GameManager
{
private Board board;
private List<Card> cards;
private bool isGameActive = false;
private float gameTime = 60f; // 총 게임 시간
private float remainingTime; // 현재 남은 시간
private Slider timeSlider;
private Image sliderFill; // 슬라이더의 Fill 색상 변경용
private TextMeshProUGUI timeText; // 남은 시간을 표시하는 UI
public event Action<float> OnTimeUpdated;
public void Init()
{
board = GameObject.Find("Board")?.GetComponent<Board>();
timeSlider = GameObject.Find("TimeOutSlider")?.GetComponent<Slider>();
timeText = GameObject.Find("TimeOutText")?.GetComponent<TextMeshProUGUI>();
if (board == null || timeSlider == null || timeText == null)
{
Debug.LogError("GameManager 초기화 실패 - 필수 UI 요소가 없음.");
return;
}
// 게임 시작
isGameActive = true;
sliderFill = timeSlider.fillRect.GetComponent<Image>();
remainingTime = gameTime;
CoroutineHelper.StartCoroutine(StartGameSequence());
}
IEnumerator StartGameSequence()
{
// 보드가 초기화될 시간을 기다림
yield return new WaitForSeconds(0.3f);
cards = board.GetCards();
// 모든 카드 공개 (처음 1초 동안)
foreach (var card in cards)
{
card.FlipCard();
}
yield return new WaitForSeconds(1.5f);
// 다시 뒤집기
foreach (var card in cards)
{
card.FlipBack();
}
yield return new WaitForSeconds(0.3f);
// 타이머 UI 활성화
timeSlider.gameObject.SetActive(true);
timeText.gameObject.SetActive(true);
Managers.Audio.PlayBGM("BGM");
CoroutineHelper.StartCoroutine(UpdateTimer());
}
IEnumerator UpdateTimer()
{
while (remainingTime > 0 && isGameActive)
{
remainingTime -= Time.deltaTime;
timeSlider.value = remainingTime;
OnTimeUpdated?.Invoke(remainingTime); // UI 업데이트 호출
if (CheckWinCondition())
{
GameOver(true);
yield return new WaitForSeconds(1f);
yield break;
}
yield return null;
}
if (remainingTime <= 0)
{
GameOver(false);
}
}
private bool CheckWinCondition()
{
foreach (var card in board.GetCards())
{
if (!card.IsFlipped()) return false;
}
return true;
}
private void GameOver(bool isWin)
{
isGameActive = false;
Time.timeScale = 0.0f;
CoroutineHelper.StartCoroutine(GameOverSequence(isWin));
}
private IEnumerator GameOverSequence(bool isWin)
{
yield return new WaitForSecondsRealtime(0.5f); // 0.5초 딜레이 후 실행
// DOTween의 모든 트위닝을 제거
DG.Tweening.DOTween.KillAll();
if (isWin)
{
Managers.UI.ShowPopupUI<UI_Success>();
}
else
{
Managers.UI.ShowPopupUI<UI_GameOver>();
}
}
}
그리고 게임에 필요한 UI는 UI_Game으로 묶어서 관리해주도록 했다. 옵저버 패턴을 사용하여 시간이 지남에 따라 색깔이 바뀌고 슬라이더의 바가 줄어들도록 구현했다. 이때 각 오브젝트의 이름과 Bind하는 enum 변수들의 이름이 같아야 정상적으로 Bind가 이루어진다.
UI_Game.cs
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public class UI_Game : UI_Scene
{
enum Buttons
{
EscapeButton,
}
enum Texts
{
TimeOutText,
}
enum GameObjects
{
TimeOutSlider,
}
private float gameTime = 60f; // 총 게임 시간
public override void Init()
{
base.Init(); // 상위 클래스의 초기화 메서드 호출
Bind<Button>(typeof(Buttons));
Bind<TextMeshProUGUI>(typeof(Texts));
Bind<GameObject>(typeof(GameObjects));
GetObject((int)GameObjects.TimeOutSlider).GetComponent<Slider>().maxValue = gameTime;
GetObject((int)GameObjects.TimeOutSlider).GetComponent<Slider>().value = gameTime;
GetButton((int)Buttons.EscapeButton).gameObject.AddUIEvent(PauseOrResume);
Managers.Game.OnTimeUpdated += UpdateTimeUI;
}
void PauseOrResume(PointerEventData eventData)
{
// 1. 뭐든지 열려있으면 다 닫기
// 2. 아무것도 없으면 열기
if (Managers.UI.GetStackSize() > 0)
Managers.UI.CloseAllPopupUI();
else
Managers.UI.ShowPopupUI<UI_PausePopup>();
}
private void UpdateTimeUI(float time)
{
GetText((int)Texts.TimeOutText).text = Mathf.CeilToInt(time).ToString();
GetObject((int)GameObjects.TimeOutSlider).GetComponent<Slider>().value = time;
UpdateTimeColor(time);
}
private void UpdateTimeColor(float time)
{
float normalizedTime = time / gameTime;
Color startColor = new Color(0.96f, 0.55f, 0.0f);
Color endColor = new Color(1.0f, 0.0f, 0.0f);
Color timeColor = Color.Lerp(endColor, startColor, normalizedTime);
GetText((int)Texts.TimeOutText).color = timeColor;
GetObject((int)GameObjects.TimeOutSlider).GetComponent<Slider>().fillRect.GetComponent<Image>().color = timeColor;
}
private void OnDisable()
{
Managers.Game.OnTimeUpdated -= UpdateTimeUI;
}
}
간단한 쯔구르게임이기 때문에 가장 기본인 순찰, 추적, 공격상태가 있을 것이다. 우선은 순찰 및 추적까지 구현해보자.
이를 위해 상태 인터페이스를 구현해주자. 2개의 PatrolPoint에서 하나씩 목적지로 선정하여 가까워지면 다른 포인트로 이동하게 구현하였다.
IMobState.cs
using UnityEngine;
public interface IMobState
{
void Enter(BaseMobController mob); // 상태 진입
void Execute(); // 상태 실행
void Exit(); // 상태 종료
}
public class PatrolState : IMobState
{
private BaseMobController _mob;
private Vector2 currentTargetPoint;
private Vector2 patrolPointA;
private Vector2 patrolPointB;
public PatrolState(Vector2 pointA, Vector2 pointB)
{
patrolPointA = pointA;
patrolPointB = pointB;
currentTargetPoint = patrolPointA;
}
public void Enter(BaseMobController mob)
{
this._mob = mob;
_mob.SetDestination(currentTargetPoint);
}
public void Execute()
{
if (_mob.IsPlayerDetected())
{
_mob.ChangeState(new ChaseState());
return;
}
if (Vector2.Distance(_mob.transform.position, currentTargetPoint) < 1f)
{
currentTargetPoint = (currentTargetPoint == patrolPointA) ? patrolPointB : patrolPointA;
_mob.SetDestination(currentTargetPoint);
}
_mob.Move(currentTargetPoint);
}
public void Exit()
{
}
}
public class ChaseState : IMobState
{
private BaseMobController _mob;
private Transform playerTransform;
public void Enter(BaseMobController mob)
{
this._mob = mob;
playerTransform = Managers.Game.GetPlayer().transform;
}
public void Execute()
{
float distanceToPlayer = Vector2.Distance(_mob.transform.position,playerTransform.position);
if(distanceToPlayer <= _mob.GetAttackRange())
{
_mob.ChangeState(new AttackState());
}else if(distanceToPlayer > _mob.GetAttackRange())
{
Vector2 pointA = _mob.GetPatrolPointA();
Vector2 pointB = _mob.GetPatrolPointB();
_mob.ChangeState(new PatrolState(pointA, pointB));
}else
{
_mob.Move(playerTransform.position);
}
}
public void Exit()
{
}
}
그리고 다양한 Mob이 있을 수 있기때문에 Mob이 가지고 있어야 할 함수와 변수를 남은 abstract 객체를 선언해주자. 그리고 기본적인 정보는 FlyWeight 패턴을 사용하여 Scriptable Object를 참조하도록 했다.
BaseMobController.cs
using System.Collections.Generic;
using UnityEngine;
public abstract class BaseMobController : MonoBehaviour
{
protected IMobState currentState;
public MobData mobData;
public IMobState GetCurrentState()
{
return currentState;
}
public abstract void ChangeState(IMobState state);
public abstract float GetDetectionRange();
public abstract float GetAttackRange();
public abstract float GetChasableRange();
public abstract Vector2 GetPatrolPointA();
public abstract Vector2 GetPatrolPointB();
public abstract void SetDestination(Vector2 destination);
public abstract void SetPatrolPoints(Vector2 pointA, Vector2 pointB);
public abstract bool IsPlayerDetected();
public abstract void Move(Vector2 target);
}
MobData.cs
using UnityEngine;
[CreateAssetMenu(fileName = "MobBaseStat", menuName = "Game Data/Stats")]
public class MobData : ScriptableObject
{
public float currentHealth;
public float deffensePower;
}
그리고 일단 하나의 Mob을 움직여보기 위해 위의 abstract 클래스를 상속받는 MobController를 만들어주자.
using UnityEditor;
using UnityEngine;
public class MobController : BaseMobController
{
[SerializeField] private float speed = 10f;
[SerializeField] private float detectionRange = 5f;
[SerializeField] private float chasableRange = 5f;
[SerializeField] private float attackRange = 5f;
[SerializeField] private Vector2 patrolPointA;
[SerializeField] private Vector2 patrolPointB;
private Rigidbody2D Rigidbody2D;
private Animator animator;
public override float GetDetectionRange() => detectionRange;
public override float GetAttackRange() => attackRange;
public override float GetChasableRange() => chasableRange;
public override Vector2 GetPatrolPointA() => patrolPointA;
public override Vector2 GetPatrolPointB() => patrolPointB;
private void Awake()
{
Rigidbody2D = GetComponent<Rigidbody2D>();
animator = GetComponent<Animator>();
// 초기 상태를 순찰 상태로 설정
ChangeState(new PatrolState(patrolPointA, patrolPointB));
}
private void FixedUpdate()
{
currentState?.Execute();
}
public override void ChangeState(IMobState newState)
{
currentState?.Exit();
currentState = newState;
currentState.Enter(this);
}
public override void SetDestination(Vector2 destination)
{
Move(destination);
}
public override void SetPatrolPoints(Vector2 pointA, Vector2 pointB)
{
patrolPointA = pointA;
patrolPointB = pointB;
#if UNITY_EDITOR
EditorUtility.SetDirty(this); // 에디터에서 변경사항을 감지하도록 설정
#endif
}
public override bool IsPlayerDetected()
{
return Vector2.Distance(transform.position, Managers.Game.GetPlayer().transform.position) <= detectionRange;
}
public override void Move(Vector2 target)
{
//이동
Vector2 currentPosition = transform.position;
//방향벡터
Vector2 direction = (target - currentPosition).normalized;
//방향*속도 => 해당방향으로의 속도
Rigidbody2D.velocity = direction * speed;
animator.SetFloat("MoveX", direction.x);
animator.SetFloat("MoveY", direction.y);
animator.SetFloat("Speed",Rigidbody2D.velocity.magnitude);
}
}
이때 움직이는 방향에 따라 다른 애니메이션이 재생되도록 몹의 애니메이션에 Blend Tree를 적용시켜 주었다.
그리고 이 PatrolPoint를 에디터에서 보고 직접 조정할 수 있으며 초기화할 수도 있게하기위해 Editor 수정 코드를 추가해주었다.
MobController.cs
using UnityEditor;
using UnityEngine;
public class MobController : BaseMobController
{
[SerializeField] private float speed = 10f;
[SerializeField] private float detectionRange = 5f;
[SerializeField] private float chasableRange = 5f;
[SerializeField] private float attackRange = 5f;
[SerializeField] private Vector2 patrolPointA;
[SerializeField] private Vector2 patrolPointB;
private Rigidbody2D Rigidbody2D;
private Animator animator;
public override float GetDetectionRange() => detectionRange;
public override float GetAttackRange() => attackRange;
public override float GetChasableRange() => chasableRange;
public override Vector2 GetPatrolPointA() => patrolPointA;
public override Vector2 GetPatrolPointB() => patrolPointB;
private void Awake()
{
Rigidbody2D = GetComponent<Rigidbody2D>();
animator = GetComponent<Animator>();
// 초기 상태를 순찰 상태로 설정
ChangeState(new PatrolState(patrolPointA, patrolPointB));
}
private void FixedUpdate()
{
currentState?.Execute();
}
public override void ChangeState(IMobState newState)
{
currentState?.Exit();
currentState = newState;
currentState.Enter(this);
}
public override void SetDestination(Vector2 destination)
{
Move(destination);
}
public override void SetPatrolPoints(Vector2 pointA, Vector2 pointB)
{
patrolPointA = pointA;
patrolPointB = pointB;
#if UNITY_EDITOR
EditorUtility.SetDirty(this); // 에디터에서 변경사항을 감지하도록 설정
#endif
}
public override bool IsPlayerDetected()
{
return Vector2.Distance(transform.position, Managers.Game.GetPlayer().transform.position) <= detectionRange;
}
public override void Move(Vector2 target)
{
//이동
Vector2 currentPosition = transform.position;
//방향벡터
Vector2 direction = (target - currentPosition).normalized;
//방향*속도 => 해당방향으로의 속도
Rigidbody2D.velocity = direction * speed;
animator.SetFloat("MoveX", direction.x);
animator.SetFloat("MoveY", direction.y);
animator.SetFloat("Speed",Rigidbody2D.velocity.magnitude);
}
}
에디터를 수정해주려면 [CustomEditor(typeof(BaseMobController), true)] 이러한 코드를 클래스전에 선언해주어야한다. 또한 클래스에서 Editor 클래스를 상속받아 코드를 구현해야한다. setDirty를 통해 진행상황이 바로 저장되도록 구현하였다.
MobControllerEditor.cs
using UnityEditor;
using UnityEngine;
[CustomEditor(typeof(BaseMobController), true)] // 이제 모든 MobController 파생 클래스에 대해 이 에디터를 사용할 수 있습니다.
public class MobControllerEditor : Editor
{
private float _handleSize = 5f; // Scene view에서의 핸들 크기를 더 작게 조정
public override void OnInspectorGUI()
{
base.OnInspectorGUI(); // 기존 인스펙터 GUI 요소를 그린다.
BaseMobController mob = (BaseMobController)target;
if (GUILayout.Button("Initialize Patrol Points"))
{
Vector2 center = mob.transform.position;
Vector2 pointA = center + new Vector2(2f, 0f);
Vector2 pointB = center + new Vector2(-2f, 0f);
mob.SetPatrolPoints(pointA, pointB);
//변경사항 적용
EditorUtility.SetDirty(mob);
}
}
protected void OnSceneGUI()
{
BaseMobController mob = (BaseMobController)target;
EditorGUI.BeginChangeCheck();
Vector3 pointAWorld = mob.GetPatrolPointA();
Vector3 pointBWorld = mob.GetPatrolPointB();
// 🔹 Scene 뷰 줌 레벨에 따라 핸들 크기 자동 조정
float handleSize = HandleUtility.GetHandleSize(mob.transform.position) * 0.2f;
// 현재 상태에 따라 범위를 다르게 그리기
IMobState currentState = mob.GetCurrentState();
if (currentState is PatrolState)
{
DrawDetectionRange(mob);
DrawAttackRange(mob);
}
else if (currentState is ChaseState)
{
DrawChasableRange(mob);
DrawAttackRange(mob);
}
else if (currentState is AttackState)
{
DrawAttackRange(mob);
}
Handles.color = Color.red;
pointAWorld = Handles.FreeMoveHandle(pointAWorld, handleSize, Vector3.zero, Handles.SphereHandleCap);
Handles.Label(pointAWorld, "Patrol Point A");
Handles.color = Color.blue;
pointBWorld = Handles.FreeMoveHandle(pointBWorld, handleSize, Vector3.zero, Handles.SphereHandleCap);
Handles.Label(pointBWorld, "Patrol Point B");
if (EditorGUI.EndChangeCheck())
{
Undo.RecordObject(mob, "Change Patrol Points");
mob.SetPatrolPoints(pointAWorld, pointBWorld);
EditorUtility.SetDirty(mob);
}
Handles.DrawLine(pointAWorld, pointBWorld);
}
private void DrawDetectionRange(BaseMobController mob)
{
Handles.color = Color.yellow;
Handles.DrawWireArc(mob.transform.position, Vector3.forward, Vector3.up, 360, mob.GetDetectionRange());
}
private void DrawChasableRange(BaseMobController mob)
{
Handles.color = Color.yellow;
Handles.DrawWireArc(mob.transform.position, Vector3.forward, Vector3.up, 360, mob.GetChasableRange());
}
private void DrawAttackRange(BaseMobController mob)
{
Handles.color = Color.red;
Handles.DrawWireArc(mob.transform.position, Vector3.forward, Vector3.up, 360, mob.GetAttackRange());
}
}
이렇게 해주면 Scene창에서 캐릭터의 Patorl Point를 눈으로 보고 직접 수정해줄 수 있으며 초기화해줄 수 있다.
이때 블루프린트에서 ProgressBar로 HP바를 정의해뒀는데 이 C++ 클래스에서 똑같은 이름으로 바인딩을 UPROPERTY로 해줄 수 있다.
HealthBar.h
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "HealthBar.generated.h"
/**
*
*/
UCLASS()
class SLASH_API UHealthBar : public UUserWidget
{
GENERATED_BODY()
public:
UPROPERTY(meta = (BindWidget))
class UProgressBar* HealthBar;
};
Enemy클래스에서 블루프린트 객체를 호출하기 위해 부모 객체의 GetUserWidgetObject함수를 사용할 수 있다.
이렇게 해주고 블루프린트 상에서 부모클래스를 HealthBar로 바꿔주자.
이제 코드상에서 이 위젯을 호출하고 ProgressBar의 속성을 변경할 수 있게 하자.
일단 퍼센트에 따라 체력바가 조정되게 해야한다. 이때 UWidgetComponent에서 자신의 유저 위젯을 반환해주는 함수를
사용해서 현재 사용자 위젯을 가져올 수 있다. 이때 이 함수는 UUserWidget을 반환해주데 우리가 필요한 것은 HealthBar이기 때문에 캐스팅을 해주어야한다.
이때 캐스팅이 여러번 이루어지지 않도록 멤버변수를 활용해주자. 그리고 UPROPERTY를 활용해서 기본값에 가비지 값이 할당되지 않도록 하자.
HealthBarComponent.h
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Components/WidgetComponent.h"
#include "HealthBarComponent.generated.h"
/**
*
*/
UCLASS()
class SLASH_API UHealthBarComponent : public UWidgetComponent
{
GENERATED_BODY()
public:
void SetHealthPercent(float Percent);
private:
UPROPERTY()
class UHealthBar* HealthBarWidget;
};
지금 아이템코드는 아이템의 타입에 따라 Switch문을 통해 다른 사용효과를 부여하고 있는데 이렇게 하면 나중에 코드가 복잡해질 수 도 있고 확장성에 좋지 않다고 생각해서 인터페이스를 만들고 이를 상속받는 다양한 아이템 클래스를 만들어주겠다.
IItemBase.cs
using Controllers.Entity;
using UnityEngine;
using static Define;
public interface IItemBase
{
ItemType ItemType { get; } // 아이템 유형
ItemData ItemData { get; set; }
int Quantity { get; }
void Use(); // 아이템 사용
void Initialize();
}
이를 상속받는 장비 클래스와 소모품 클래스를 만들어주자.
이때 각각의 클래스는 주울 수 있어야하고 실제 프리펩에 부착되어야 하기 때문에 Monobehaviour와 IPickable 또한 상속받도록 해주었다.
Equiment.cs
using Controllers.Entity;
using UnityEngine;
using static Define;
public class Equipment : MonoBehaviour, IItemBase, IPickable
{
[SerializeField] private ItemData itemData; // Inspector에서 할당할 수 있는 필드
[SerializeField] private int quantity;
public ItemType ItemType => ItemType.Equipment;
public ItemData ItemData
{
get => itemData;
set => itemData = value;
}
public int Quantity
{
get => quantity;
set => quantity = 1;
}
public void Initialize()
{
if (ItemData == null)
{
Debug.LogError("ItemData가 설정되지 않았습니다.");
return;
}
// 공통 초기화 로직 (예: 아이콘 설정)
var spriteRenderer = GetComponent<SpriteRenderer>();
if (spriteRenderer != null && ItemData.icon != null)
{
spriteRenderer.sprite = ItemData.icon;
}
}
public void Pickup()
{
bool addedSuccessfully = Managers.Inventory.TryAddItem(this.ItemData.itemID,quantity);
if (addedSuccessfully)
{
Destroy(gameObject);
}
else
{
Debug.LogWarning("아이템을 인벤토리에 추가할 수 없습니다.");
}
}
public void Use()
{
if( Managers.Inventory.EquippedWeapon != null )
{
Managers.Inventory.UnEquipCurrentWeapon();
}
else
{
Managers.Inventory.EquipWeapon(this.ItemData.itemID);
}
}
}
Consumable.cs
using Controllers.Entity;
using UnityEngine;
using static Define;
public class Consumable : MonoBehaviour, IItemBase, IPickable
{
[SerializeField] private ItemData itemData; // Inspector에서 할당할 수 있는 필드
[SerializeField] private int quantity;
public ItemType ItemType => ItemType.Consumable;
public ItemData ItemData
{
get => itemData;
set => itemData = value;
}
public int Quantity
{
get=> quantity;
set => quantity = value;
}
public void Initialize()
{
if (ItemData == null)
{
Debug.LogError("ItemData가 설정되지 않았습니다.");
return;
}
// 공통 초기화 로직 (예: 아이콘 설정)
var spriteRenderer = GetComponent<SpriteRenderer>();
if (spriteRenderer != null && ItemData.icon != null)
{
spriteRenderer.sprite = ItemData.icon;
}
}
public void Pickup()
{
bool addedSuccessfully = Managers.Inventory.TryAddItem(this.ItemData.itemID,quantity);
if (addedSuccessfully)
{
Destroy(gameObject);
}
else
{
Debug.LogWarning("아이템을 인벤토리에 추가할 수 없습니다.");
}
}
public void Use()
{
Debug.Log($"장비 아이템 '{ItemData.itemName}'를 장착합니다.");
Managers.Inventory.UseItem(this.ItemData.itemID);
}
}
이를 실제 프리펩에 부착하고 테스트해보자.
이렇게 나타나는 것을 볼 수 있다. 이에 맞춰 인벤토리 코드도 수정을 해주자. 주요한 수정사항은 아이템 추가시에 맵에 배치된 아이템과 관련된 정보를 가져와서 추가해준다는 점이다. 이때 나는 계속 아이템을 찾는 부하를 줄이기 위해 시작시에 모든 아이템의정보를 캐싱해서 사용했다.
using System;
using System.Collections.Generic;
using UnityEngine;
public class InventoryManager : MonoBehaviour
{
private Dictionary<int, ItemSlot> ownedItems = new Dictionary<int, ItemSlot>(); // 소유한 아이템과 수량
private Dictionary<int, ItemData> itemDataCache = new Dictionary<int, ItemData>(); // 캐싱된 아이템 데이터
private Dictionary<int, IItemBase> ItemCache = new Dictionary<int, IItemBase>(); // 캐싱된 아이템 데이터
public Equipment EquippedWeapon { get; private set; }
public void Init()
{
CacheItemData();
EquippedWeapon = null;
}
// 1. 아이템 데이터를 캐싱하여 메모리에 저장
private void CacheItemData()
{
ItemData[] items = Resources.LoadAll<ItemData>("Items");
GameObject[] itemDatas = Resources.LoadAll<GameObject>("Prefabs/Items");
foreach (var item in items)
{
if (!itemDataCache.ContainsKey(item.itemID))
{
//Debug.Log(item.itemName + "아이템 데이터 캐싱 완료");
itemDataCache[item.itemID] = item;
}
}
foreach (var item in itemDatas)
{
IItemBase itemBase = item.GetComponent<IItemBase>();
if (!ItemCache.ContainsKey(itemBase.ItemData.itemID))
{
Debug.Log(itemBase.ItemData.itemName + "아이템 데이터 캐싱 완료");
ItemCache[itemBase.ItemData.itemID] = itemBase;
}
}
}
// 2. 아이템 데이터 검색
public ItemData FindItemDataByID(int itemID)
{
if (itemDataCache.TryGetValue(itemID, out var itemData))
{
return itemData;
}
Debug.LogWarning($"아이템 데이터를 찾을 수 없습니다: ID = {itemID}");
return null;
}
public IItemBase FindItemBaseByID(int itemID)
{
if (ItemCache.TryGetValue(itemID, out var itemBase))
{
return itemBase;
}
Debug.LogWarning($"아이템 데이터를 찾을 수 없습니다: ID = {itemID}");
return null;
}
// 3. 아이템 추가
public bool TryAddItem(int itemID, int quantity = 1)
{
// 아이템 데이터를 가져옴
if (!itemDataCache.TryGetValue(itemID, out var itemData))
{
Debug.LogWarning($"추가하려는 아이템 데이터(ID: {itemID})가 유효하지 않습니다.");
return false;
}
// 기존 아이템이 있는 경우 수량 증가
if (ownedItems.TryGetValue(itemID, out var existingSlot))
{
existingSlot.Quantity += quantity;
Debug.Log($"아이템 '{itemData.itemName}'의 수량이 {existingSlot.Quantity}로 증가했습니다.");
}
else
{
// 새로운 아이템 슬롯 추가
ownedItems[itemID] = new ItemSlot(FindItemBaseByID(itemID), quantity);
Debug.Log($"새로운 아이템 '{itemData.itemName}'이(가) {quantity}개 추가되었습니다.");
}
RefreshUI();
return true;
}
// 4. 아이템 제거
public void RemoveItem(int itemID, int quantity = 1)
{
if (!ownedItems.TryGetValue(itemID, out var itemSlot))
{
Debug.LogWarning($"제거하려는 아이템(ID: {itemID})이(가) 없습니다.");
return;
}
itemSlot.Quantity -= quantity;
if (itemSlot.Quantity <= 0)
{
ownedItems.Remove(itemID);
Debug.Log($"아이템 '{FindItemDataByID(itemID)?.itemName}'이(가) 인벤토리에서 제거되었습니다.");
}
else
{
Debug.Log($"아이템 '{FindItemDataByID(itemID)?.itemName}'의 수량이 {itemSlot.Quantity}로 줄었습니다.");
}
RefreshUI();
}
// 5. 아이템 사용
public void UseItem(int itemID)
{
if (!ownedItems.TryGetValue(itemID, out var itemSlot))
{
Debug.LogWarning($"사용하려는 아이템(ID: {itemID})이(가) 없습니다.");
return;
}
var itemData = FindItemDataByID(itemID);
if (itemData == null)
{
Debug.LogWarning($"아이템 데이터를 찾을 수 없습니다: ID = {itemID}");
return;
}
Debug.Log($"아이템 '{itemData.itemName}' 사용됨.");
itemSlot.Item?.Use();
// 소비 아이템은 사용 후 제거
if (itemData.itemType != Define.ItemType.Equipment)
{
RemoveItem(itemID, 1);
}
RefreshUI();
}
// 6. 무기 장착
public void EquipWeapon(int itemID)
{
if (!ownedItems.TryGetValue(itemID, out var itemSlot))
{
Debug.LogWarning($"장착하려는 무기(ID: {itemID})이(가) 없습니다.");
return;
}
if (EquippedWeapon != null)
{
UnEquipCurrentWeapon();
}
EquippedWeapon = (Equipment)itemSlot.Item;
RemoveItem(itemID, 1);
Debug.Log($"무기 '{EquippedWeapon.ItemData.itemName}' 장착 완료.");
RefreshUI();
}
// 7. 장착 해제
public void UnEquipCurrentWeapon()
{
if (EquippedWeapon == null)
{
Debug.LogWarning("장착된 무기가 없습니다.");
return;
}
TryAddItem(EquippedWeapon.ItemData.itemID, 1);
Debug.Log($"무기 '{EquippedWeapon.ItemData.itemName}' 해제 완료.");
EquippedWeapon = null;
RefreshUI();
}
// 8. 인벤토리 UI 갱신
public void RefreshUI()
{
UI_ItemSel uI_ItemSel = FindAnyObjectByType<UI_ItemSel>();
if (uI_ItemSel != null)
{
Managers.UI.ClosePopupUI(uI_ItemSel);
}
UI_Inventory inventoryUI = Managers.UI.GetTopPopupUI() as UI_Inventory;
if (inventoryUI != null)
{
inventoryUI.RefreshUI();
}
}
// 9. 저장
public void SaveInventory(ref SaveData saveData)
{
saveData.inventoryData = new List<SerializedItemSlot>();
foreach (var slot in ownedItems)
{
saveData.inventoryData.Add(new SerializedItemSlot
{
itemID = slot.Key,
quantity = slot.Value.Quantity
});
}
saveData.equippedWeaponID = EquippedWeapon?.ItemData.itemID ?? -1;
}
// 10. 로드
public void LoadInventory(SaveData saveData)
{
if (saveData.inventoryData == null) return;
ownedItems.Clear();
foreach (var serializedSlot in saveData.inventoryData)
{
var itemBase = FindItemBaseByID(serializedSlot.itemID);
if (itemBase != null)
{
ownedItems[serializedSlot.itemID] = new ItemSlot(itemBase, serializedSlot.quantity);
}
}
if (saveData.equippedWeaponID != -1)
{
var weaponData = FindItemBaseByID(saveData.equippedWeaponID);
Debug.Log("현재 장착된 무기 : " + weaponData.ItemData.itemName + " 로드완료");
if (weaponData != null)
{
EquippedWeapon = (Equipment)weaponData;
}
}
RefreshUI();
}
public List<ItemSlot> GetOwnedItems()
{
return new List<ItemSlot>(ownedItems.Values);
}
}
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public class EquipSlot : UI_Base, IPointerClickHandler
{
[SerializeField] private Image itemIcon; // 아이템 아이콘 표시
[SerializeField] private TextMeshProUGUI itemName; // 아이템 이름
[SerializeField] private TextMeshProUGUI itemInfo; // 아이템 설명
[SerializeField] private TextMeshProUGUI AttackInfo; // 공격력 설명
public Equipment currentItem; // 현재 슬롯에 연결된 아이템 데이터
public override void Init()
{
Equipment equippedItem = Managers.Inventory.EquippedWeapon;
if (equippedItem != null)
{
UpdateSlotUI(equippedItem);
}
else
{
ClearSlotUI();
}
}
// 슬롯 UI 업데이트
public void UpdateSlotUI(Equipment item)
{
currentItem = item;
if (currentItem != null)
{
itemIcon.sprite = currentItem.ItemData.icon;
itemIcon.gameObject.SetActive(true);
itemName.text = currentItem.ItemData.itemName;
itemInfo.text = currentItem.ItemData.description;
if (currentItem is Equipment equipment)
{
AttackInfo.text = $"공격력: {equipment.ItemData.AttackDamage}";
}
else
{
AttackInfo.text = "";
}
}
else
{
ClearSlotUI();
}
}
// 슬롯 UI 초기화
private void ClearSlotUI()
{
currentItem = null;
itemIcon.gameObject.SetActive(false);
itemName.text = "장착 없음";
itemInfo.text = "";
AttackInfo.text = "";
}
public void OnPointerClick(PointerEventData eventData)
{
Debug.Log("클릭 이벤트 호출!");
if (currentItem == null) return;
UI_ItemSel equipPopup = FindAnyObjectByType<UI_ItemSel>();
if (equipPopup == null)
{
equipPopup = Managers.UI.ShowPopupUI<UI_ItemSel>();
equipPopup.transform.SetParent(transform, false);
Vector3 location = eventData.position;
location.y -= 50;
equipPopup.transform.SetPositionAndRotation(location, Quaternion.identity);
}
else
{
Managers.UI.ClosePopupUI(equipPopup);
}
}
}
UI_ItemSel.cs
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public class UI_ItemSel : UI_Popup
{
public enum Buttons
{
UseButton,
RemoveButton,
}
public override void Init()
{
Bind<Button>(typeof(Buttons));
//현재 장비를 클릭했을 때
if (transform.parent.GetComponent<EquipSlot>() != null)
{
GetButton((int)Buttons.UseButton).GetComponentInChildren<TextMeshProUGUI>().text = "장착해제";
}
else //인벤토리 창을 클릭했을 때
{
if (GetComponentInParent<SlotButton>().currentItem.ItemType == Define.ItemType.Equipment)
{
GetButton((int)Buttons.UseButton).GetComponentInChildren<TextMeshProUGUI>().text = "장착하기";
}
else if (GetComponentInParent<SlotButton>().currentItem.ItemType == Define.ItemType.Consumable)
{
GetButton((int)Buttons.UseButton).GetComponentInChildren<TextMeshProUGUI>().text = "사용하기";
}
else
{
GetButton((int)Buttons.UseButton).GetComponentInChildren<TextMeshProUGUI>().text = "";
}
}
GetButton((int)Buttons.UseButton).gameObject.AddUIEvent(UseItem);
}
void UseItem(PointerEventData eventData)
{
//현재 장비를 클릭했을 때
if (transform.parent.GetComponent<EquipSlot>() != null)
{
GetComponentInParent<EquipSlot>().currentItem.Use();
}
else
{
GetComponentInParent<SlotButton>().currentItem.Use();
}
}
}