이제 단일 지점으로의 Patrol은 가능한 것을 볼 수 있다. 이제 한 Patrol Point에서 다른 Patrol Point로 이동할 수 있도록 구현해보자.

이를 위해 타켓포인트를 여러개 만들어주고 배열에 할당해주자

 

그리고 Tick함수에서 Target과의 거리를 측정해주는 함수가 작동할 수 있도록 Actor와의 지정해준 거리에 따라 bool값을 반환해주는 새로운 함수를 만들어주자.

Enemy.cpp

bool AEnemy::InTargetRange(AActor* Target, double Radius)
{
	const double DistanceToTarget = (Target->GetActorLocation() - GetActorLocation()).Size();
	return DistanceToTarget <= Radius;
}

 

이를 바탕으로  Tick함수에서 HealthBar 조건문도 변경해주고 PatrolTarget조건문도 만들어주자.

이때 PatrolRadius(멈출 거리)를 지정해주어야하는데 이 값이 double인데 moveTo에서는 이 반경보다 조금 더 이동하기 때문에 조금 더 거리를 지정해주어야한다.

Enemy.h

	UPROPERTY(EditAnywhere)
	double PatrolRadius = 200.f;

Enemy.cpp

void AEnemy::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

	if (CombatTarget)
	{
		if (!InTargetRange(CombatTarget,CombatRadius))
		{
			CombatTarget = nullptr;
			if (HealthBarWidget)
			{
				HealthBarWidget->SetVisibility(false);
				Attributes->SetHealth(100.f);
			}
		}
	}

	if (PatrolTarget && EnemyController)
	{
		TArray<AActor*> ValidTargets;
		for (AActor* Target : PatrolTargets)
		{
			if (Target != PatrolTarget)
			{
				ValidTargets.AddUnique(Target);
			}
		}


		if (InTargetRange(PatrolTarget, PatrolRadius))
		{
			const int32 TargetSelection = FMath::RandRange(0, ValidTargets.Num() - 1);
			PatrolTarget = ValidTargets[TargetSelection];
			
			FAIMoveRequest MoveRequest;
			MoveRequest.SetGoalActor(PatrolTarget);
			//도착이라고 생각할 거리
			MoveRequest.SetAcceptanceRadius(15.f);
			EnemyController->MoveTo(MoveRequest);
		}
	}
}

 

이렇게 해주고 실행해주면 랜덤한 순찰포인트로 순찰하는 것을 볼 수 있다.



이전 시간에 애니메이션을 임포트해주었으니 이제 올바른 상황에 맞게 출력되도록 설정해주자 

애니메이션 블루프린트의 Main State에 Blend Space를  사용하는 것으로 걷고 뛰는 애니메이션이 재생되도록 해주자.

이때 Blend Space는 속도와 같은 변수 값의 상태에 따라 다른 애니메이션이 재생되도록 해준다.

이를 위해 애니메이션/블랜드스페이스1D를 생성해주자.

가로축의 이름을 GroundSpeed로 바꿔주고 최소를0 최대를 300으로 해준다음 Idle Walk Run을 배치해주자

 

이렇게 배치해준다음 Idle State에서 애니메이션 포즈가 이 Blend Space를 통해 출력되도록 수정해주자

 

이렇게 해주고 이제 적의 속도를 가져와야하기 때문에 이벤트그래프에서 적캐릭터 BP를 가져오고 BP에 있는 Character Movement도 가져오도록 수정해주자.

 

 

그리고 Float로 GroundSpeed라는 변수를 선언해주고 Thread Safe Update Animation에서 이 GroundSpeed를 설정해줄 수 있도록 해주자.

 

그 다음, 이 변수 값을 가져와서 Blend Space 입력에 넣어주자

이렇게 해주면 움직임이 한쪽으로 치우쳐보이는데 이는 Character에서 Orient 값을 체크해주고 BP_Enemy의 컨트롤러 요 사용을 꺼주면 된다.

 

이를 코드로 구현해보자

AEnemy::AEnemy()
{
	PrimaryActorTick.bCanEverTick = true;

	GetMesh()->SetCollisionObjectType(ECollisionChannel::ECC_WorldDynamic);
	GetMesh()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Visibility, ECollisionResponse::ECR_Block);
	GetMesh()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore);
	GetMesh()->SetGenerateOverlapEvents(true);
	GetCapsuleComponent()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore);

	Attributes = CreateDefaultSubobject<UAttributeComponent>(TEXT("Attributes"));
	HealthBarWidget = CreateDefaultSubobject<UHealthBarComponent>(TEXT("HealthBar"));
	HealthBarWidget->SetupAttachment(GetRootComponent());

	GetCharacterMovement()->bOrientRotationToMovement = true;
	bUseControllerRotationPitch = false;
	bUseControllerRotationRoll = false;
	bUseControllerRotationYaw = false;
}

 

걷는 모션이 생겼으니 이제 특정 포인트를 순찰하도록 만들어보자. 여러 포인트를 가진 다음 상황에 따라 특정 포인트를 선택하여 순찰하도록 구현해보자. 이를 위해 AActor값의 TArray를 만들어주자.

 

Enemy.h

private:
	UPROPERTY(EditInstanceOnly,Category="AI Navigation")
	AActor* PatrolTarget;

	UPROPERTY(EditInstanceOnly, Category = "AI Navigation")
	TArray<AActor*> PatrolTargets;

 

이를 활용하기 위해 AAIController의 MoveTo함수를 사용하자.

 

이를 사용하려면 모듈에 AIModule을 추가해줘야한다. 이렇게 해준 다음 순찰타켓을 지정해주고 옵션값을 지정해준 다음 OutPath를 가져와서 DebugSphere을 그려주게 하자.

Enemy.cpp

void AEnemy::BeginPlay()
{
	Super::BeginPlay();
	if (HealthBarWidget)
	{
		HealthBarWidget->SetVisibility(false);
	}

	EnemyController = Cast<AAIController>(GetController());
	if (EnemyController && PatrolTarget)
	{
		FAIMoveRequest MoveRequest;
		MoveRequest.SetGoalActor(PatrolTarget);
		//도착이라고 생각할 거리
		MoveRequest.SetAcceptanceRadius(15.f);
		FNavPathSharedPtr NavPath;
		EnemyController->MoveTo(MoveRequest, &NavPath);
		//& : 복사x -> 참조
		TArray<FNavPathPoint>& PathPoints = NavPath->GetPathPoints();
		for (auto& Point : PathPoints)
		{
			const FVector& Location = Point.Location;
			DrawDebugSphere(GetWorld(), Location, 12.f, FColor::Green, false, 10.f);
		}
	}
}

 

이렇게 해준다음 BP에서 Patrol Point를 지정해주면 된다.

 

이렇게 해주고 실행하면 직선이기 때문에 구가 2개만 등장하는 것을 볼 수 있다.

만약 여기서 장애물이나 벽을 추가하면 구가 늘어나게 된다.

'게임공부 > Unreal Engine' 카테고리의 다른 글

[Unreal Engine][C++]25. Patrol  (0) 2025.02.07
[Unreal Engine][C++]23. Enemey Behavior  (0) 2025.01.27
[Unreal Engine][C++]22. Death  (1) 2025.01.26
[Unreal Engine][C++]21. Damage  (1) 2025.01.21
[Unreal Engine][C++]20. Actor Component  (0) 2025.01.16

https://www.youtube.com/watch?v=XhfB3ZS3JoM&list=LL&index=5&t=11139s

이번에 유투브 강의영상을 보고 따라 만들어 보기로 하였다. 여기서 똑같이 만드는 것이 아닌 내 방식대로 만들어보려고 한다.

그리고 여기에 내가 응원하는 배우님이 카페에 올려주신 사진을 활용해서 카드 짝 맞추기 게임을 만들어 볼 것이다.

구현 순서는 영상에 나오는 순서대로 구현을 해보도록 할 것이다.

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

 

UI구성은 다음과 같다. 


이제 적에 대한 코드를 구현해보자. 우선 적이 어떤 상태를 가지게 될지 생각해보자.

간단한 쯔구르게임이기 때문에 가장 기본인 순찰, 추적, 공격상태가 있을 것이다. 우선은 순찰 및 추적까지 구현해보자.

이를 위해 상태 인터페이스를 구현해주자. 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를 눈으로 보고 직접 수정해줄 수 있으며 초기화해줄 수 있다.

 

이렇게 해주고 실행해주면 Patrol이 잘 실행되는것을 볼 수 있다.


이제 적이 움직일 수 있도록 만들어보자 

이를 위해 Nav Mesh를 사용하자. 이를 위해 액터 배치 패널을 열어주고 navmeshBounds Volume을 선택해주자

선택해주고 맵에 배치해주고 P키를 눌러보면 nav 매쉬가 적용된 부분을 볼 수 있다.

 

이 볼륨이 맵 전체에 적용될 수 있도록 크기를 키워주자.

 

이렇게 해주면 이제 AI 기능을 사용하여 적을 움직이게 할 수 있다. 이 기능을 사용하기 위해 블루프린트 클래스로 이동해주자.

이때 AI 컨트롤러가 적이 Spawn했을 때도 잘 작동하게 하기위해 디테일창에서 AI자동 빙의를 수정해주자

일단 테스트를 위해 시작할 때 AI컨트롤러를 통해 움직이도록 해보자

 

이렇게 해주면 0,0,0으로 이동하는 것을 볼 수 있다.

 

이제 특정 액터로 이동하게 만들기 위해 Actor 타입의 변수를 하나 선언해주자. 이때 기본값을 설정해줄 수도 있는데 이때 변수의 인스턴스 편집가능을 체크해주어야한다.

또한 Target Poin 객체를 추가하는 것으로 움직일 위치를 정해줄 수 있다.

 

움직이는 사용자를 Chase하려면 플레이어가 공격할 때 이 변수를 조정해주고 움직이는 함수를 호출해주는 것을 반복하는것으로 구현할 수 있을 것이다. 

그리고 프로젝트 세팅에서 네비게이션 매쉬의 런타임 생성을 Dynamic으로 바꿔주면 항아리같은 부숴지는 물체나 문 같이 열릴 수 있는 물체의 상태에 따라 이 매쉬가 재구성 된다. 이는 고비용이 든다.

 

또한 셀 사이즈 및 높이를 조정해주는 것으로 매쉬가 어떻게 얼마나 적용될지 정해줄 수 있다.

 

적이 움직일 때 애니메이션을 추가해주자 애니메이션은 Mixamo에서 추가해주자. 이때 Blender를 통해 Root본을 추가해주는 작업을 해주어야한다. 이때 폴더에 하나의 with Skin Animation이 존재해야 다른 것에도 일괄적으로 자동 적용이 된다.

Blender에서 N키를 눌러 사이드 바를 열 수 있다.

이렇게 변환이 끝난 애니메이션을 매쉬없이 임폴트해준다.

'게임공부 > Unreal Engine' 카테고리의 다른 글

[Unreal Engine][C++]25. Patrol  (0) 2025.02.07
[Unreal Engine][C++]24. Enemey Behavior2  (0) 2025.02.05
[Unreal Engine][C++]22. Death  (1) 2025.01.26
[Unreal Engine][C++]21. Damage  (1) 2025.01.21
[Unreal Engine][C++]20. Actor Component  (0) 2025.01.16

 



 Die 몽타주의 섹션이 재생되지만 바로 Idle로 돌아오기 때문에 계속 누워있을수 있도록 수정해주어야 한다.

이를 위해 각 애니메이션에서 제일 마지막부분을 따로 애니메이션으로 만들기 위해 마지막 부분을 선택한 뒤 에셋생성/애니메이션 생성/현재포즈를 통해 만들어주자.

그 다음 애니메이 블루플린트에서 새로운 State Machine을 만들어주자.

 

이 Main States에는 Idle과 Dead가 있고 내부 에는 각각의 애니메이션이 재생되도록 한다. 

 

이때 Idle과 Dead의 전환 조건도 만들어줘야하고 어떤 Dead애니메이션이 재생될지도 정해줘야한다. 이를 코드로 구현배자 Death포즈 여러개와 Alive 포즈를 정의해줄 enum 클래스를 선언해주자.

UENUM(BlueprintType)
enum class EDeathPose :uint8
{
	EDP_Alive UMETA(DisplayName = "Alive"),
	EDP_Death1 UMETA(DisplayName = "Death1"),
	EDP_Death2 UMETA(DisplayName = "Death2"),
	EDP_Death3 UMETA(DisplayName = "Death3"),
	EDP_Death4 UMETA(DisplayName = "Death4"),
	EDP_Death5 UMETA(DisplayName = "Death5"),
	EDP_Death6 UMETA(DisplayName = "Death6")
};

 

이렇게 정의해준 enum 클래스를 Enemy 클래스에서 사용하도록 하자. 이때 애니메이션 블루프린트에서 사용할 수 있게 UPROPERTY값을 BluePrintReadOnly로 설정해주었다.

Enemy.h

protected:
	UPROPERTY(BlueprintReadOnly)
	EDeathPose DeathPose = EDeathPose::EDP_Alive;

 

그리고 Die함수 내에서 각 Death 애니메이션 섹션에 맞게 enum으로 선언된 변수도 바뀌도록 static_cast를 통해 처리해주었다.

Enemy.cpp

void AEnemy::Die()
{
	UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
	if (AnimInstance && DeathMontage)
	{
		AnimInstance->Montage_Play(DeathMontage);
		const int32 Selection = FMath::RandRange(1, 6);
		FString SectionString = FString::Printf(TEXT("Death%d"), Selection); // "Death1", "Death2" 등의 문자열 생성
		DeathPose = static_cast<EDeathPose>(RandomIndex);
		FName SectionName = FName(*SectionString); // FString을 FName으로 변환
		AnimInstance->Montage_JumpToSection(SectionName, DeathMontage);
	}
}

 

이렇게 해주고 이 Enum 변수값을 가져오기 위해 애니메이션이 Initialize 될때 Enemy값을 변수로 가져올 수 있도록 해주자.

 

이때 DeathPose의 값을 가져오는 것을 멀티스레딩를 활용하여 가져오자. 이것은 Tread Safe Update Animation을 활용하여 만들 수 있는데 이때 멀티 스레딩에서 값에 안전하게 접근하도록 Property Access를 활용한다.

 

이렇게 해주면 이제 Death Pose가 Alive가 아니라면 Death State로 넘어가면 되게 해주면 된다.

 

이렇게 해준 다음 Dead에서는 DeadPose 변수값을 가져와서 Blend Pose를 통해 각 애니메이션에 맞는 Dead 애니메이션을 마지막에 출력해주도록 하자

 

 

이렇게 해주고 블랜드 타임을 0으로 해주면 잘 작동하는 것을 볼 수 있다. 

 

이제 Enemy가 죽었을 때 더 이상 충돌하지 않게 해주고 몇초 뒤에 Destroy 되도록 하자.

Enemy.cpp

void AEnemy::Die()
{
	UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
	if (AnimInstance && DeathMontage)
	{
		AnimInstance->Montage_Play(DeathMontage);
		const int32 Selection = FMath::RandRange(1, 6);
		FString SectionString = FString::Printf(TEXT("Death%d"), Selection); // "Death1", "Death2" 등의 문자열 생성
		DeathPose = static_cast<EDeathPose>(Selection);
		FName SectionName = FName(*SectionString); // FString을 FName으로 변환
		AnimInstance->Montage_JumpToSection(SectionName, DeathMontage);
	}

	GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
	SetLifeSpan(3.f);
}

 

이렇게 해주고 HP바가 공격했을 때 표시되고 멀리 갔을 때는 다시 없어지게 구현해보자. 멀리 갔을 때 없어지는 것을 구현하기 위해 Enemy에서 현재 공격하고 있는 Actor를 저장하고 이 Actor와의 거리에 따라 없어지게 해주면 된다.

Enemy.h

	UPROPERTY()
	AActor* CombatTarget;

	UPROPERTY()
	double CombatRadius = 500.f;

 

Enemy.cpp

void AEnemy::BeginPlay()
{
	Super::BeginPlay();
	if (HealthBarWidget)
	{
		HealthBarWidget->SetVisibility(false);
	}
}

void AEnemy::Die()
{
	UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
	if (AnimInstance && DeathMontage)
	{
		AnimInstance->Montage_Play(DeathMontage);
		const int32 Selection = FMath::RandRange(1, 6);
		FString SectionString = FString::Printf(TEXT("Death%d"), Selection); // "Death1", "Death2" 등의 문자열 생성
		DeathPose = static_cast<EDeathPose>(Selection);
		FName SectionName = FName(*SectionString); // FString을 FName으로 변환
		AnimInstance->Montage_JumpToSection(SectionName, DeathMontage);
	}
	if (HealthBarWidget)
	{
		HealthBarWidget->SetVisibility(false);
	}
	GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
	SetLifeSpan(3.f);
}

void AEnemy::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

	if (CombatTarget)
	{
		const double DistanceToTarget = (CombatTarget->GetActorLocation() - GetActorLocation()).Size();
		if (DistanceToTarget > CombatRadius)
		{
			CombatTarget = nullptr;
			if (HealthBarWidget)
			{
				HealthBarWidget->SetVisibility(false);
				Attributes->SetHealth(100.f);
			}
		}
	}
}

void AEnemy::GetHit_Implementation(const FVector& ImpactPoint)
{
	if (HealthBarWidget)
	{
		HealthBarWidget->SetVisibility(true);
	}
	if (Attributes && Attributes->IsAlive())
	{
		DirectionalHitReact(ImpactPoint);
	}
	else
	{
		Die();
	}
	
	

	if (HitSound)
	{
		UGameplayStatics::PlaySoundAtLocation(
			this,
			HitSound,
			ImpactPoint
		);
	}

	if (HitParticles&& GetWorld())
	{
		UGameplayStatics::SpawnEmitterAtLocation(
			GetWorld(),
			HitParticles,
			ImpactPoint
		);
	}
}

float AEnemy::TakeDamage(float DamageAmount, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
	if (Attributes)
	{
		Attributes->ReceiveDamage(DamageAmount);
		HealthBarWidget->SetHealthPercent(Attributes->GetHealthPercent());
	}

	CombatTarget = EventInstigator->GetPawn();

	return DamageAmount;
}

 

 

이렇게 하면 잘 작동하는 것을 볼 수 있다.

 

https://www.udemy.com/share/107vg03@Xgb5iRXcpg26gJ-bUYComus3EEfNr2SumFm1Wlohh2QkeHxGAaMbERJi-mc4LTXQig==/

 


이제 체력바는 있기 때문에 이를 활용해보자

적에게 Damage를 주면 체력바의 퍼센트가 감소하도록 하자.

이때 AActor클래스에 있는 TakeDamage함수를 재정의하여 사용하자. 이 함수를 통해 데미지를 입었을 때 발생할 함수를 연결해줄 수 있다.

 

 

그리고 ApplyDamage를 통해 데미지를 줄 수 있다. 이때 EventInstigator은 데미지를 주는 객체를 소유하고 있는 객인데 이때 칼을 쥐고 있는 캐릭터가 여기에 해당된다. DamageCauser은 칼 그자체를 뜻한다.

 

 

전체적인 작동방식은 다음과 같다

 

이제 실제 코드에서 적용해보자. 우선 무기에서 ApplyDamage를 구현하자. 우선 장착할때 Owner와 Instigator를 설정하도록 변경해주자.

Weapon.cpp

void AWeapon::Equip(USceneComponent* Inparent, FName InSocketName, AActor* NewOwner, APawn* NewInstigator)
{
	SetOwner(NewOwner);
	SetInstigator(NewInstigator);
	AttachMeshToSocket(Inparent, InSocketName);
	ItemState = EItemState::EIS_Equipped;
	if (EquipSound)
	{
		UGameplayStatics::PlaySoundAtLocation(
			this,
			EquipSound,
			GetActorLocation()
		);
	}
	if (Sphere)
	{
		Sphere->SetCollisionEnabled(ECollisionEnabled::NoCollision);
	}
	if (EmbersEffect)
	{
		EmbersEffect->Deactivate();
	}
}

void AWeapon::OnBoxOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	const FVector Start = BoxTraceStart->GetComponentLocation();
	const FVector End = BoxTraceEnd->GetComponentLocation();

	TArray<AActor*> ActorsToIgnore;
	ActorsToIgnore.Add(this);

	for (AActor* Actor : IgnoreActors)
	{
		ActorsToIgnore.AddUnique(Actor);
	}

	//충돌 결과
	FHitResult BoxHit;

	UKismetSystemLibrary::BoxTraceSingle(
		this,
		Start,
		End,
		FVector(5.f, 5.f, 5.f),
		BoxTraceStart->GetComponentRotation(),
		ETraceTypeQuery::TraceTypeQuery1,
		false,
		ActorsToIgnore,		//무시할거
		EDrawDebugTrace::ForDuration,
		BoxHit,
		true		//자신무시
	);

	if (BoxHit.GetActor())
	{
		IHitInterface* HitInterface = Cast<IHitInterface>(BoxHit.GetActor());
		if (HitInterface)
		{
			HitInterface->Execute_GetHit(BoxHit.GetActor(), BoxHit.ImpactPoint);
		}
		IgnoreActors.AddUnique(BoxHit.GetActor());

		CreateFields(BoxHit.ImpactPoint);

		UGameplayStatics::ApplyDamage(
			BoxHit.GetActor(),
			Damage,
			GetInstigator()->GetController(),
			this,
			UDamageType::StaticClass()
		);
	}
}

 

이렇게 해주고 Enemy 클래스에서 TakeDamae를 override해서 사용해주면 된다.

Enemy.cpp

float AEnemy::TakeDamage(float DamageAmount, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
	if (Attributes)
	{
		Attributes->ReceiveDamage(DamageAmount);
		HealthBarWidget->SetHealthPercent(Attributes->GetHealthPercent());
	}

	return DamageAmount;
}

 

이렇게 해서 테스트해보면 정상적으로 작동하는 것을 볼 수 있다.

 

여기서 체력바를 조금 커스텀해보자. 이를 위해 커스텀 체력바를 다운받아주고 각 이미지을 프로젝트에 import해준 다음 압축 설정을 UserInterface2D로 설정해주자

 

이렇게 해준다음 배경이미지와 필이미지를 커스텀 이미지로 변경해주는데 이때 기존에 색이 영향을 주지않도록 Fill Color의 RGBA값을 모두 1로 설정해주자

그리고 배경의 색이 투명하도록 배경의 색조에 A값을 0으로 설정해주자

그리고 이미지를 활용하여 윤곽선을 나타내도록 해주자

 

이렇게 해주면 커스텀 HP바가 잘 적용된 것을 볼 수 있다.

 

이제 적의 체력이 0이 됐을 때 Death 애니메이션이 출력되도록 만들어보자.

우선 Death 애니메이션을 Mixamo에서 다운받아주자. 이때 전에 해줬던 것처럼 Root본을 추가해주는 작업을 Blender의 플러그인을 통해 해주면 된다. 애니메이션은 Death 애니메이션 중에 마음에 드는 것을 4개정도 다운받아주자.

다운받아준 다음 Blender를 열고 mixamo 사이드탭을 열어준다. 설정값은 다음과 같다. Input Path에 다운받은 폴더를 넣어주고 Output Path는 Convert된 값이 들어갈 곳으로 원하는 폴더를 지정해주면 된다. 설정해준 다음 Convert해주자.

이렇게 변환된 것을 이제 임폴트 해주면 되는데 이때 Skeleton은 SK_Paladin으로 지정해주고 매시 임포트는 꺼주자.

이렇게 해준다음 애니메이션 몽타주를 만들어주자. 이때 각 몽타주 섹션이 독립적이도록 섹션을 만든다음 지우기를 해주어야한다.

 

이제 체력이 0이 될 때 이 몽타주중 하나를 재생하게 해주면된다. 이를 위해 우선 체력이 0보다 큰지 확인해줄 bool 함수를 만들어주자.

AttributeComponent.cpp

bool UAttributeComponent::IsAlive()
{
	return Health > 0.f;
}

 

이렇게 해준 다음 적이 죽었는지 GetHit에서 확인해서 그에 따라 다른 몽타주를 재생하도록 무기에서 충돌할 때 데미지를 먼저 준 다음 GetHit이 실행되도록 순서를 바꿔주자.

Weapon.cpp

if (BoxHit.GetActor())
{
	UGameplayStatics::ApplyDamage(
		BoxHit.GetActor(),
		Damage,
		GetInstigator()->GetController(),
		this,
		UDamageType::StaticClass()
	);

	IHitInterface* HitInterface = Cast<IHitInterface>(BoxHit.GetActor());
	if (HitInterface)
	{
		HitInterface->Execute_GetHit(BoxHit.GetActor(), BoxHit.ImpactPoint);
	}
	IgnoreActors.AddUnique(BoxHit.GetActor());

	CreateFields(BoxHit.ImpactPoint);

	
}

 

이렇게 해준 다음 GetHit에서 살았는지 확인하고 살았다면 HitReact를 죽었다면 Die함수를 실행하도록 하자.

Enemy.cpp

void AEnemy::Die()
{
	UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
	if (AnimInstance && DeathMontage)
	{
		AnimInstance->Montage_Play(DeathMontage);
		const int32 Selection = FMath::RandRange(1, 6);
		FString SectionString = FString::Printf(TEXT("Death%d"), Selection); // "Death1", "Death2" 등의 문자열 생성
		FName SectionName = FName(*SectionString); // FString을 FName으로 변환
		AnimInstance->Montage_JumpToSection(SectionName, DeathMontage);
	}
}

void AEnemy::GetHit_Implementation(const FVector& ImpactPoint)
{
	if (Attributes && Attributes->IsAlive())
	{
		DirectionalHitReact(ImpactPoint);
	}
	else
	{
		Die();
	}
	
	

	if (HitSound)
	{
		UGameplayStatics::PlaySoundAtLocation(
			this,
			HitSound,
			ImpactPoint
		);
	}

	if (HitParticles&& GetWorld())
	{
		UGameplayStatics::SpawnEmitterAtLocation(
			GetWorld(),
			HitParticles,
			ImpactPoint
		);
	}
}

 

이렇게 해주면 Die 몽타주의 섹션이 재생되지만 바로 Idle로 돌아오기 때문에 계속 누워있을수 있도록 수정해주어야 한다.

이를 위해 각 애니메이션에서 제일 마지막부분을 따로 애니메이션으로 만들기 위해 마지막 부분을 선택한 뒤 에셋생성/애니메이션 생성/현재포즈를 통해 만들어주자.

그 다음 애니메이 블루플린트에서 새로운 State Machine을 만들어주자.

 

이 Main States에는 Idle과 Dead가 있고 내부 에는 각각의 애니메이션이 재생되도록 한다. 

 

이때 Idle과 Dead의 전환 조건도 만들어줘야하고 어떤 Dead애니메이션이 재생될지도 정해줘야한다. 이를 코드로 구현배자 Death포즈 여러개와 Alive 포즈를 정의해줄 enum 클래스를 선언해주자.

UENUM(BlueprintType)
enum class EDeathPose :uint8
{
	EDP_Alive UMETA(DisplayName = "Alive"),
	EDP_Death1 UMETA(DisplayName = "Death1"),
	EDP_Death2 UMETA(DisplayName = "Death2"),
	EDP_Death3 UMETA(DisplayName = "Death3"),
	EDP_Death4 UMETA(DisplayName = "Death4"),
	EDP_Death5 UMETA(DisplayName = "Death5"),
	EDP_Death6 UMETA(DisplayName = "Death6")
};

 

이렇게 정의해준 enum 클래스를 Enemy 클래스에서 사용하도록 하자. 이때 애니메이션 블루프린트에서 사용할 수 있게 UPROPERTY값을 BluePrintReadOnly로 설정해주었다.

Enemy.h

protected:
	UPROPERTY(BlueprintReadOnly)
	EDeathPose DeathPose = EDeathPose::EDP_Alive;

 

그리고 Die함수 내에서 각 Death 애니메이션 섹션에 맞게 enum으로 선언된 변수도 바뀌도록 static_cast를 통해 처리해주었다.

Enemy.cpp

void AEnemy::Die()
{
	UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
	if (AnimInstance && DeathMontage)
	{
		AnimInstance->Montage_Play(DeathMontage);
		const int32 Selection = FMath::RandRange(1, 6);
		FString SectionString = FString::Printf(TEXT("Death%d"), Selection); // "Death1", "Death2" 등의 문자열 생성
		DeathPose = static_cast<EDeathPose>(RandomIndex);
		FName SectionName = FName(*SectionString); // FString을 FName으로 변환
		AnimInstance->Montage_JumpToSection(SectionName, DeathMontage);
	}
}

 

이렇게 해주고 이 Enum 변수값을 가져오기 위해 애니메이션이 Initialize 될때 Enemy값을 변수로 가져올 수 있도록 해주자.

 

이때 DeathPose의 값을 가져오는 것을 멀티스레딩를 활용하여 가져오자. 이것은 Tread Safe Update Animation을 활용하여 만들 수 있는데 이때 멀티 스레딩에서 값에 안전하게 접근하도록 Property Access를 활용한다.

 

이렇게 해주면 이제 Death Pose가 Alive가 아니라면 Death State로 넘어가면 되게 해주면 된다.

 

이렇게 해준 다음 Dead에서는 DeadPose 변수값을 가져와서 Blend Pose를 통해 각 애니메이션에 맞는 Dead 애니메이션을 마지막에 출력해주도록 하자

 

 

이렇게 해주고 블랜드 타임을 0으로 해주면 잘 작동하는 것을 볼 수 있다. 


이제 만들어둔 HP바의 부모클래스인 유저 위젯을 상속받는 C++클래스를 만들어주자.

 

이때 블루프린트에서 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;
};

 

HealthBarComponent.cpp

void UHealthBarComponent::SetHealthPercent(float Percent)
{
	if (HealthBarWidget == nullptr)
	{
		HealthBarWidget = Cast<UHealthBar>(GetUserWidgetObject());
	}
	
	if (HealthBarWidget && HealthBarWidget->HealthBar)
	{
		HealthBarWidget->HealthBar->SetPercent(Percent);
	}
}

 

이렇게 유저위젯관련 코드를 작성해주었으니 Enemy 클래스에서 멤버변수의 타입도 바꿔주고 생성자에서 CreateDefaultSubObject의 클래스명을 바꿔주고 BeginPlay에서 이를 호출하여 테스트해보자.

Enemy.h

private:

	UPROPERTY(VisibleAnywhere)
	class UHealthBarComponent* HealthBarWidget;

 

Enemy.cpp

AEnemy::AEnemy()
{
	PrimaryActorTick.bCanEverTick = true;

	GetMesh()->SetCollisionObjectType(ECollisionChannel::ECC_WorldDynamic);
	GetMesh()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Visibility, ECollisionResponse::ECR_Block);
	GetMesh()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore);
	GetMesh()->SetGenerateOverlapEvents(true);
	GetCapsuleComponent()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore);

	Attributes = CreateDefaultSubobject<UAttributeComponent>(TEXT("Attributes"));
	HealthBarWidget = CreateDefaultSubobject<UHealthBarComponent>(TEXT("HealthBar"));
	HealthBarWidget->SetupAttachment(GetRootComponent());
}

void AEnemy::BeginPlay()
{
	Super::BeginPlay();
	
	if (HealthBarWidget)
	{
		HealthBarWidget->SetHealthPercent(.1f);
	}
}

 

정상적으로 작동하는 것을 볼 수 있다.

'게임공부 > Unreal Engine' 카테고리의 다른 글

[Unreal Engine][C++]22. Death  (1) 2025.01.26
[Unreal Engine][C++]21. Damage  (1) 2025.01.21
[Unreal Engine][C++]19. Actor Component  (0) 2025.01.14
[Unreal Engine][C++]18. Treasure2  (0) 2025.01.03
[Unreal Engine][C++]17. Treasure  (0) 2025.01.03

 

일반적인 RPG게임에서는 체력이나 경험치, 골드량이 존재한다. 이번에는 이러한 요소들을 생성하고 캐릭터 클래스에 추가해보자. 이때 각 요소들은 모아서 Actor Component로 만들어주자. 

이를 위해 새로운 C++클래스를 생성해주자. 이때 Actor Component를 베이스로 해서 생성해주자.

 

 

일단 우선은 체력만을 가지도록 만들어주자.

AttributeComponent.h

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "AttributeComponent.generated.h"


UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class SLASH_API UAttributeComponent : public UActorComponent
{
	GENERATED_BODY()

public:	
	UAttributeComponent();
	virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
protected:
	virtual void BeginPlay() override;

private:	
	UPROPERTY(EditAnywhere,Category="Actor Attributes")
	float Health;

	UPROPERTY(EditAnywhere, Category = "Actor Attributes")
	float MaxHealth;
		
};

 

이렇게 만든 클래스를 일단 적 클래스에 적용시켜보자. 

헤더파일에 추가한 다음 생성될 때 컴포넌트로 생성되도록하자.

Enemy.h

private:

	UPROPERTY(VisibleAnywhere)
	class UAttributeComponent* Attributes;

 

Enemy.cpp

#include "Components/AttributeComponent.h"

AEnemy::AEnemy()
{
	PrimaryActorTick.bCanEverTick = true;

	GetMesh()->SetCollisionObjectType(ECollisionChannel::ECC_WorldDynamic);
	GetMesh()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Visibility, ECollisionResponse::ECR_Block);
	GetMesh()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore);
	GetMesh()->SetGenerateOverlapEvents(true);
	GetCapsuleComponent()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore);

	Attributes = CreateDefaultSubobject<UAttributeComponent>(TEXT("Attributes"));

}

 

이렇게 해주고 핫리로딩해주면 잘 적용되어있는 것을 볼 수 있다.

 

이때 VisibleAnyWhere로 설정되어있기 때문에 Detail패널을 보고 수정할 수 있다.

 

 

이제 적에 머리에 HP바가 달려있도록 구현해보자. 이것은 위젯 컴포넌으로 만들 수 있다.

 

이렇게 생성해주고 구성은 다음과 같이 해주자

 

 

이렇게 해주고 위젯 컴포넌트를 상속받는 C++클래스를 생성해주자.

 

 

이렇게 만들어준 다음 이것을 Enemy 클래스에 추가해보자. 이때 WidgetComponent를 사용하려면 빌드파일에 모듈을 추가해줘야한다.

Slash.Build.cs

PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore","EnhancedInput","HairStrandsCore", "Niagara", "GeometryCollectionEngine", "UMG" });

 

Enemy.h

	private:
    UPROPERTY(VisibleAnywhere)
	class UWidgetComponent* HealthBarWidget;

Enemy.cpp

AEnemy::AEnemy()
{
	PrimaryActorTick.bCanEverTick = true;

	GetMesh()->SetCollisionObjectType(ECollisionChannel::ECC_WorldDynamic);
	GetMesh()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Visibility, ECollisionResponse::ECR_Block);
	GetMesh()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore);
	GetMesh()->SetGenerateOverlapEvents(true);
	GetCapsuleComponent()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore);

	Attributes = CreateDefaultSubobject<UAttributeComponent>(TEXT("Attributes"));
	HealthBarWidget = CreateDefaultSubobject<UWidgetComponent>(TEXT("HealthBar"));
	HealthBarWidget->SetupAttachment(GetRootComponent());
}

 

이렇게 해주면 BP_Enemy에 체력바를 넣을 수 있는 위젯 컴포넌트가 생긴것을 볼 수 있다. 여기서 만들어둔 체력바를 넣어주고 스페이스를 Screen으로 바꿔주면 된다. 

 

이렇게 하면 적 머리위에 체력바가 잘 부착되어있는 것을 볼 수 있다.



지금 아이템코드는 아이템의 타입에 따라 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);
    }
}

 

 

이제 이에 맞춰서 인벤토리의 장비칸, 아이템칸의 코드도 수정해주고

아이템을 선택했을 때 장착, 사용하기가 나오는 선택UI도 하나로 통합해주자.

SlotButton.cs

using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class SlotButton : UI_Base, IPointerClickHandler
{
    [SerializeField] private Image itemIcon;
    [SerializeField] private TextMeshProUGUI itemName;
    [SerializeField] private TextMeshProUGUI itemInfo;
    [SerializeField] private TextMeshProUGUI itemCount;
    [SerializeField] public ItemSlot currentItem;

    public override void Init() { }

    public void UpdateSlotUI(ItemSlot item)
    {
        currentItem = item;
        if(item!= null)
        {
            Debug.Log(item.Item.ItemData.itemName + " " + item.Quantity);
        }

        if (currentItem == null)
        {
            ClearSlotUI();
            return;
        }

        itemIcon.sprite = currentItem.Item.ItemData.icon;
        itemIcon.gameObject.SetActive(true);
        itemName.text = currentItem.Item.ItemData.itemName;
        itemCount.text = $"{currentItem.Quantity} 개";
    }

    private void ClearSlotUI()
    {
        currentItem = null;
        itemIcon.gameObject.SetActive(false);
        itemName.text = "";
        itemCount.text = "";
    }

    public void OnPointerClick(PointerEventData eventData)
    {
        Debug.Log("클릭 이벤트 호출!");

        UI_ItemSel uI_ItemSel = FindAnyObjectByType<UI_ItemSel>();

        if (uI_ItemSel == null)
        {
            uI_ItemSel = Managers.UI.ShowPopupUI<UI_ItemSel>();
            uI_ItemSel.transform.SetParent(transform, false);
            Vector3 location = eventData.position;
            location.y -= 50;
            uI_ItemSel.transform.SetPositionAndRotation(location, Quaternion.identity);
        }
        else
        {
            Managers.UI.ClosePopupUI(uI_ItemSel);
        }
    }
}

 

EquipSlot.cs

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

 

이렇게 해주고 테스트해보면 정상적으로 작동하는 것을 볼 수 있다.

 

 

실행화면

아이템
아이템을 주웠을 때

 

인벤토리

 

+ Recent posts