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

 

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

 

 

실행화면

아이템
아이템을 주웠을 때

 

인벤토리

 



오늘은 State패턴에 대해 알아보고 실제 코드를 통해 작동하는 것을 보자. 

State패턴은 객체 내부 상태에 따라 스스로 행동을 변경하는 것으로 State를 인터페이스로 만들고 이를 상속받은 여러 상태를 클래스로 만든 다음 상태 전환을 클래스 간의 참조 변경으로 처리한다.

용도

애니메이션이나 플레이어 움직임, 몬스터AI에 사용하곤 한다,

 

코드

우선 State들이 상속받을 인터페이스를 알아보자.

// State 패턴
public interface IPlayerState
{
    void Enter(Player player);      //진입
    void Execute();                 //변경조건
    void Exit();                    //종료
}

 

 

이런식으로 인터페이스는 진입했을 때의 행동, 변경 조건, 종료시의 행동을 정의할 수 있게 구성되어있다.

간단한 쿠키런 같은 점프 게임을 통해 실제로 어떤식으로 작동하는지 알아보자.

점프게임에 필요한 플레이어의 행동은 평상시, 점프, 떨어지기, 피격등이 있을 것이다. 이를 IPlayerState를 상속받는 클래스로 만들어주자.

 

IdleState

private class IdleState : IPlayerState
{
    private Player _player;

    public void Enter(Player player)
    {
        _player = player;
        _player.animator.SetInteger("state", 0);
    }

    public void Execute()
    {
        // 플레이어가 땅에 붙어있지 않다면 떨어지고 있어야한다.
        if (!_player.isGrounded) _player.ChangeState(_player._fallState);
    }

    public void Exit() { }
}

 

JumpState

private class JumpState : IPlayerState
{
    private Player _player;

    public void Enter(Player player)
    {
        _player = player;
        _player.animator.SetInteger("state", 1);
        _player.rb.AddForce(Vector2.up * _player.jumpForce, ForceMode2D.Impulse);
        Managers.Audio.PlaySound("Jump");
        _player.isGrounded = false;
    }

    public void Execute()
    {
        if (_player.rb.linearVelocityY <= 0) _player.ChangeState(_player._fallState);
    }

    public void Exit() { }
}

 

Fall State

private class FallState : IPlayerState
{
    private Player _player;

    public void Enter(Player player)
    {
        _player = player;
        _player.animator.SetInteger("state", 2);
    }

    public void Execute()
    {
        // 땅에 도달하면 Idle로
        if (_player.isGrounded) _player.ChangeState(_player._idleState);
    }

    public void Exit() { }
}

 

Hit State

 private class HitState : IPlayerState
 {
     private Player _player;

     public void Enter(Player player)
     {
         _player = player;
         Managers.Audio.PlaySound("HealthDown");
         _player.CurrentLives--;

         if (_player.CurrentLives <= 0) _player.ChangeState(_player._deathState);
     }

     public void Execute() { }

     public void Exit() { }
 }

 

이렇게 해준 다음 플레이어의 Awake단에서 이 클래스를 선언하고 각 키의 호출 시에 State를 변경하도록 하고 주기적으로 변경 조건을 검사하도록 FixedUpdate에서 이 부분을 호출해주자.

Player.cs

   private void Awake()
   {
       // State 클래스 선언
       _idleState = new IdleState();
       _jumpState = new JumpState();
       _fallState = new FallState();
       _hitState = new HitState();
       _deathState = new DeathState();

       ChangeState(_idleState);
   }
   
    private void FixedUpdate()
 {
     _currentState?.Execute();
 }
 
 private void ChangeState(IPlayerState newState)
{
    _currentState?.Exit();
    _currentState = newState;
    _currentState.Enter(this);
}

 

Jump동작만 예시를 들어보자면 다음과 같이 작동한다.

private void OnEnable()
{
	playerInputActions.PlayerAction.Jump.performed += JumpAction;
    playerInputActions.PlayerAction.Enable();
}

private void OnDisable()
{
	playerInputActions.PlayerAction.Jump.performed -= JumpAction;
    playerInputActions.PlayerAction.Disable();
}

private void JumpAction(InputAction.CallbackContext context)
{
    if (isGrounded)
    {
        ChangeState(_jumpState);
    }
}

 

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

 

이 게임은 다음 링크에서 해볼 수 있다.

https://play.unity.com/en/games/bb897f9a-ede5-4217-9d35-47793607782d/kimchi-run-by-munchi

 

 

1. call by value vs call by reference

 

 

C#으로 사용하는 Unity에서는 제네릭 함수가 많은데 이때 벨류타입의 제네릭을 많이 사용하게 되면 공유 코드를 사용하지 못할 때 JIT(Just In Time)방식, C#에서 IL(Intermediate Language)라고 중간 언어로 변환을 하는데 유저 코드 자체를 실시간으로 파싱하기 때문에 굉장히 느린데 JIT방식은 중간 형태의 .Net Assembly를 가지고 Target device에 맞게 컴파일 하기 때문에 인터프리터보다 빠르다. 이렇게 빌드되기 때문에 빌드 사이즈가 커질 수록 시간이 오래 걸린다. 하지만 Faster builds 방식은 이 사이즈와 시간을 감소 시킬 수 있지만 런타임 퍼포먼스가 떨어진다.

 

2. Sprite Atlas

sprite가 여러개 있는 경우 Draw Call이 각각의 Sprite마다 실행되는데 Sprite Atlas를 사용하는 경우에는 이 Draw Call이 하나로 묶여서 실행된다.

 

※Layer로 오브젝트들을 묶어주게 되면 이 또한 하나로 묶어서 DrawCall이 발생한다.

 

이제 항아리를 부쉈을 때 다양한 종류의 보물이 나오도록 구현해보자.

이를 구현하기에 앞서 플레이어가 모은 보물의 양에 따라 골드량이 달라지는 것을 구현해보자. 

우선 Treasure 클래스에 Gold변수를 추가해서 보물이 가진 골드량을 나타내도록 하자

Treasure.h

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

#pragma once

#include "CoreMinimal.h"
#include "Items/Item.h"
#include "Treasure.generated.h"

/**
 * 
 */
UCLASS()
class SLASH_API ATreasure : public AItem
{
	GENERATED_BODY()
protected:
	virtual void OnSphereOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult) override;
private:
	UPROPERTY(EditAnywhere, Category = Sounds)
	USoundBase* PickupSound;

	UPROPERTY(EditAnywhere, Category = "Treasure Properties")
	int32 Gold;
};

 

이렇게 해주고 Treasure 클래스를 바탕으로 만들어둔 블루프린트 클래스가 기본으로 두 다른 파생 블루프린트 클래스를 만들어주자.

 

보면 부모의 이벤트를 자동으로 호출하고 있는것도 볼 수 있다.

 

이제 이 방법으로 다양한 아이템을 만들어주자.

 

이렇게 만들어준 보물들중에 랜덤하게 스폰하도록 코드로 구현해보자. 이를 위해 TArray를 사용할 것이다.

BreakableActor.h

private:	

	UPROPERTY(EditAnywhere, Category="Breakable Properties")
	TArray<TSubclassOf<class ATreasure>> TreasureClasses;

 

BreakableActor.cpp

void ABreakableActor::GetHit_Implementation(const FVector& ImpactPoint)
{
	UWorld* World = GetWorld();
	if (World && TreasureClasses.Num() > 0)
	{
		FVector Location = GetActorLocation();
		Location.Z += 75.f;

		int32 Selection = FMath::RandRange(0, TreasureClasses.Num() - 1);

		World->SpawnActor<ATreasure>(TreasureClasses[Selection], Location, GetActorRotation());
	}
}

 

이렇게 해주면 랜덤하게 보물이 드랍된다.

 

여기에 약간의 효과를 추가해보자 이번에는 이펙트에 나이아가라 시스템을 사용해볼 것이다.

이를 위해 나이아가라시스템을 만들어주자.

 

이렇게 생성해주면 기본적인 요소들이 만들어져있다.

이 요소들을 살펴보면 다양하게 있는데 이를 통해 이펙트가 만들어지게 되는 것이다. 이 하나하나가 규칙이라고 보면 된다.

이 중에 렌더는 말 그대로 화면에 보이도록하는 것인데 지금은 하얀 입자 하나하나가 스프라이더 이고 이를 보이게 해준다.

 

Emitter State는 생명주기에 관한것으로 Self는 자체 수명 주기를 사용하는 것이다. 만약 이것을 시스템으로 바궈주면 시스템에서 생명주기를 정해주게 되는 것이다. 무한정 반복되며 주기는 2초이다.

 

SpawnRate 초당 스폰되는 Emitter의 수이다.

파티클 스폰은 스폰되는 모양에 관한 것으로 각 입자에 대한 초기값이다. 이때 Color값을 1보다 높게하면 반짝이는 효과를 낼 수 있다.

 

그리고 Shape Location을 조절해주는 것으로 이펙트 산란구역을 설정해줄 수 있다.

 

이펙트의 속도도 느리게 하고 나오는 각도도 조절해주자

 

그리고 이 이펙트가 자동으로 종료되는 것이 아닌 우리가 종료시키고 싶기 때문에 Kill Particles를 해제해준다.

 

Drag는 커질수록 입자의 속도를 느리게 해준다.

 

여기에 입자들이 소용돌이처럼 돌도록 하기위해 Vortex Velocity를 추가해주자. 그리고 Kill Particles를 다시 체크해주자.

 

효과는 성공적으로 만들었으니 이를 모든 아이템에서 작동하도록 아이템 코드에 나이아가라 시스템 변수 추가해주자.

Item.h

	private:
    UPROPERTY(EditAnywhere)
	class UNiagaraComponent* EmbersEffect;

Item.cpp

#include "NiagaraComponent.h"

// Sets default values
AItem::AItem()
{
	// Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
	PrimaryActorTick.bCanEverTick = true;

	ItemMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("ItemMeshComponent"));
	RootComponent = ItemMesh;

	Sphere = CreateDefaultSubobject<USphereComponent>(TEXT("Sphere"));
	Sphere->SetupAttachment(GetRootComponent());

	EmbersEffect = CreateDefaultSubobject<UNiagaraComponent>(TEXT("Embers"));
	EmbersEffect->SetupAttachment(GetRootComponent());
}

 

컴파일해준다음 BaseTreasure에 만들어둔 나이아가라 시스템을 추가해주자.

 

잘작동하는 것을 볼 수 있다.

 

이를 무기에도 붙여주는데 무기는 장착했을 때 이 효과가 없어지게 해주자.

Weapon.cpp

#include "NiagaraComponent.h"

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


이제 부숴지는 항아리는 만들었으니 부숴졌을 때 보물같은 것이 나오도록 구현해보자

우선 fab에서 무료 보물 에셋을 다운받아주자.

https://www.fab.com/ko/listings/a4c1584f-e305-4562-ad37-4c1483e09a10

다운받은 다음 보물이 나타날때 소리효과를 추가하기 위해 다음 사이트에서 Coin sfx를 하나 다운받아주자.

https://soundimage.org/

 

Soundimage.org | Thousands of free music tracks, sounds and images for your projects by Eric Matyas

“Ancient Mars”_Looping  (You can freely download this music track in MP3 format from my Sci-Fi 12 page…enjoy!) ______________________________________________________________________________ The purpose of this site to make good-sounding music, sound

soundimage.org

 

mp3로 다운받아 질텐데 wav로 변환한 다음 임포트해주고 이를 바탕으로 meta sound를 만들어주자.

이렇게 해준 다음 아이템 클래스를 상속받는 Treasure 클래스를 생성해주자.

 

이때 OnSphereOverlap 함수를 가져와서 재정의 해줄것이다. 이때 파생클래스는 UFUNCTION을 사용할 수 없다.

플레이와 overlap될 때 보물이 없어지고 소리가 재생되도록 해주자.

Treasure.h

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

#pragma once

#include "CoreMinimal.h"
#include "Items/Item.h"
#include "Treasure.generated.h"

/**
 * 
 */
UCLASS()
class SLASH_API ATreasure : public AItem
{
	GENERATED_BODY()
protected:
	virtual void OnSphereOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult) override;
private:
	UPROPERTY(EditAnywhere, Category = Sounds)
	USoundBase* PickupSound;
};

Treasure.cpp

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


#include "Items/Treasure.h"
#include "Characters/SlashCharacter.h"
#include "Kismet\GameplayStatics.h"

void ATreasure::OnSphereOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	ASlashCharacter* SlashCharacter = Cast<ASlashCharacter>(OtherActor);
	if (SlashCharacter)
	{
		if (PickupSound)
		{
			UGameplayStatics::PlaySoundAtLocation(
				this,
				PickupSound,
				GetActorLocation()
			);
		}
		Destroy();
	}
}

 

컴파일해준 다음 이 클래스를 기반으로 블루프린트 클래스를 만들어주자. 만들어준 다음 소리를 추가해주고 매쉬를 적당한 매쉬로 추가해주자.

 

 

 

항아리가 깨졌을 때 스폰되게 만들어야하지만 일단 함수가 올바르게 작동하는지 확인하기 위해 씬에 하나 배치해보자.

시작하고 가까이 다가가면 소리가 재생되면서 없어지는 것을 볼 수 있다.

 

이제 항아리가 부숴지면 항아리가 있던 자리 중앙에 보물이 생성되게 만들어야한다. 이를 코드로 구현해보자. SpawnActor 함수를 통해 소환해줄 수 있다.
이때 함수의 정의와 매개변수에 대해 찾아보면 UClass를 매개변수로 받고있는 것을 볼 수 있다.

 

우선 코드상에서 각 클래스를 지정해주는 변수에 대해 알아보면 다음과 같다. 

UClass 변수를 지정해주고 블루프린트상에서 할당해주면 블루프린트 클래스를 가져다가 쓸수 있다.

 

항아리가 부숴질때 Actor를 Spawn하기 위해 Breakable 클래스에 UClass변수를 추가해주자.

BreakableActor.h

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

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Interfaces/HitInterface.h"
#include "BreakableActor.generated.h"

class UGeometryCollectionComponent;

UCLASS()
class SLASH_API ABreakableActor : public AActor, public IHitInterface
{
	GENERATED_BODY()

private:	
	UPROPERTY(EditAnywhere)
	UClass* TreasureClass;
};

 

이렇게 해주고 블루프린트상에서도 할당을 해주자.

 

이렇게 해주고 SpawnActor에서 이 변수를 매개변수로 사용하면 C++클래스 대신 블루프린트 클래스가 소환된다.

BreakableActor.cpp

void ABreakableActor::GetHit_Implementation(const FVector& ImpactPoint)
{
	UWorld* World = GetWorld();
	if (World && TreasureClass)
	{
		FVector Location = GetActorLocation();
		Location.Z += 75.f;
		World->SpawnActor<ATreasure>(TreasureClass, Location, GetActorRotation());
	}
}

 

컴파일 해주면 보물이 잘 생성되는 것을 볼 수 있다.

 

이때 UClass 변수로 생성해줬기 때문에 할당할 수 있는 것에 제한이 없다. 이러한 점을 보완하기 위해서 TSubclassOf라는 변수를 사용한다.

 

BreakableActor.h

private:	
	UPROPERTY(VisibleAnywhere)
	UGeometryCollectionComponent* GeometryCollection;

	UPROPERTY(EditAnywhere)
	TSubclassOf<class ATreasure> TreasureClass;
};

 

이렇게 해주고 컴파일해주면 선택할 수 있는 Actor가 제한되어있는것을 볼 수 있다.

 

그리고 여기서 부수고 난 다음에 충돌을 끄기 위해 UCapsuleComponent를 사용하자. 이것을 통해 Pawn이 통과하지 못하지만 깨지고 난 다음에는 지나다닐 수 있도록하자.

BreakableActor.h

	protected:
    UPROPERTY(VisibleAnywhere, BlueprintReadWrite)
	class UCapsuleComponent* Capsule;

 

BreakableActor.cpp

#include "Breakable/BreakableActor.h"
#include "Components/CapsuleComponent.h"
#include "GeometryCollection/GeometryCollectionComponent.h"
#include "Items/Treasure.h"

ABreakableActor::ABreakableActor()
{

	PrimaryActorTick.bCanEverTick = false;

	GeometryCollection = CreateDefaultSubobject<UGeometryCollectionComponent>(TEXT("GeometryCollection"));
	SetRootComponent(GeometryCollection);
	GeometryCollection->SetGenerateOverlapEvents(true);
	GeometryCollection->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore);
	GeometryCollection->SetCollisionResponseToChannel(ECollisionChannel::ECC_Pawn, ECollisionResponse::ECR_Ignore);

	Capsule = CreateDefaultSubobject<UCapsuleComponent>(TEXT("Capsule"));
	Capsule->SetupAttachment(GetRootComponent());
	Capsule->SetCollisionResponseToAllChannels(ECollisionResponse::ECR_Ignore);
	Capsule->SetCollisionResponseToChannel(ECollisionChannel::ECC_Pawn, ECollisionResponse::ECR_Block);

}

 

이렇게 해주고 LifeSpan이 끝날때 이 캡슐과 Pawn의 충돌도 Ignore로 바꿔준다.

 

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

 

1. 장비 착용UI 만들기

 

플레이어가 착용한 장비가 보이게 UI를 수정해주자

착용한 장비에 따라 인벤토리칸이 달라지도록해야한다.

'

 

이렇게 만들고 UI_Inventory코드를 수정하고 EquipSlot코드도 새로 만들어주자.

UI_Inventory.cs

using System.Collections.Generic;
using UnityEngine;

public class UI_Inventory : UI_Popup
{
    [SerializeField] private Transform slotContainer; // 슬롯 UI 부모 객체
    [SerializeField] private GameObject slotPrefab;   // 슬롯 UI 프리팹

    enum GameObjects
    {
        EquipSlot,
    }

    private List<ItemSlot> itemSlots = new List<ItemSlot>();

    public override void Init()
    {
        Bind<GameObject>(typeof(GameObjects));
        base.Init();
        RefreshUI(); // UI 활성화 시 즉시 인벤토리 데이터 업데이트
    }

    public void RefreshUI()
    {
        List<ItemSlot> inventory = Managers.Inventory.GetOwnedItems();
        int requiredSlotCount = inventory.Count;

        GetObject((int)GameObjects.EquipSlot).GetComponent<EquipSlot>().Init();

        // **슬롯 초기화**
        for (int i = 0; i < slotContainer.childCount; i++)
        {
            GameObject slotObj = slotContainer.GetChild(i).gameObject;
            if (i < requiredSlotCount)
            {
                slotObj.SetActive(true);
                SlotButton slotButton = slotObj.GetComponent<SlotButton>();
                slotButton.UpdateSlotUI(inventory[i]);
            }
            else
            {
                slotObj.SetActive(false); // 남는 슬롯 비활성화
            }
        }

        // **부족한 슬롯 생성**
        for (int i = slotContainer.childCount; i < requiredSlotCount; i++)
        {
            GameObject newSlot = Instantiate(slotPrefab, slotContainer);
            newSlot.SetActive(true);
            SlotButton slotButton = newSlot.GetComponent<SlotButton>();
            slotButton.UpdateSlotUI(inventory[i]);
        }
    }



    private void CreateSlotUI(ItemSlot itemSlot)
    {
        GameObject slotObj = Instantiate(slotPrefab, slotContainer);
        slotObj.SetActive(true);
        SlotButton slotButton = slotObj.GetComponent<SlotButton>();
        slotButton.UpdateSlotUI(itemSlot);

        // 슬롯 데이터 디버그 확인
        Debug.Log($"Created Slot for Item: {itemSlot.itemData.itemName}, Quantity: {itemSlot.quantity}");
    }
}

 

EquipSlot.cs

using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class EquipSlot : UI_Base, IPointerClickHandler, IPointerUpHandler, IPointerEnterHandler, IPointerExitHandler
{
    [SerializeField] private Image itemIcon;          // 아이템 아이콘 표시
    [SerializeField] private TextMeshProUGUI itemName; // 아이템 이름 
    [SerializeField] private TextMeshProUGUI itemInfo;  // 아이템 설명
    [SerializeField] private TextMeshProUGUI AttackInfo; // 공격력 설명

    public ItemSlot currentItemSlot;                 // 현재 슬롯에 연결된 아이템 데이터


    public override void Init()
    {
        ItemSlot CurrentEquippedItem = Managers.Inventory.EquippedWeapon;

        if(CurrentEquippedItem != null ) 
        { 
            UpdateSlotUI(CurrentEquippedItem);
        }else
        {
            itemIcon.gameObject.SetActive(false);
            itemName.text = "장착 없음";
            itemInfo.text = "";
            AttackInfo.text = "";
        }
    }

    // 슬롯 UI 업데이트
    public void UpdateSlotUI(ItemSlot itemSlot)
    {
        currentItemSlot = itemSlot;

        if (currentItemSlot != null && currentItemSlot.itemData != null)
        {
            itemIcon.sprite = currentItemSlot.itemData.icon;
            itemIcon.gameObject.SetActive(true);
            itemName.text = currentItemSlot.itemData.itemName;
            itemInfo.text = currentItemSlot.itemData.description;
            AttackInfo.text = $"공격력: {currentItemSlot.itemData.AttackDamage}";
        }
        else
        {
            itemIcon.gameObject.SetActive(false);
            itemName.text = "장착 없음";
            itemInfo.text = "";
            AttackInfo.text = "";
        }
    }


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

        UI_EquipSel uI_EquipSel = FindAnyObjectByType<UI_EquipSel>();
        if (uI_EquipSel == null)
        {
            uI_EquipSel = Managers.UI.ShowPopupUI<UI_EquipSel>();
            uI_EquipSel.gameObject.transform.SetParent(transform, false);
            Vector3 loaction = eventData.position;
            loaction.y -= 50;
            uI_EquipSel.gameObject.transform.SetPositionAndRotation(loaction, Quaternion.identity);
        }
        else
        {
            Managers.UI.ClosePopupUI(uI_EquipSel);
        }

    }
    }
}

그리고 단일 책임 원칙을 지키고 중앙화된 아이템 관리를 하기 위해  플레이어가 장착한 무기도 인벤토리 매니저에서 관리하도록 수정해주었다.

InventoryManager.cs

using System.Collections.Generic;
using UnityEngine;

public class InventoryManager : MonoBehaviour
{
    [SerializeField] private Transform slotContainer; // 슬롯 UI 부모 객체
    [SerializeField] private GameObject slotPrefab;   // 슬롯 UI 프리팹
    private List<ItemSlot> inventory = new List<ItemSlot>();
    private Dictionary<int, ItemData> itemDataCache = new Dictionary<int, ItemData>();

    public ItemSlot EquippedWeapon { get; private set; }

    public void Init()
    {
        inventory = new List<ItemSlot>();
        slotPrefab = Resources.Load<GameObject>("Prefabs/Item");
        CacheItemData();
        EquippedWeapon = null;
    }

    private void CacheItemData()
    {
        ItemData[] items = Resources.LoadAll<ItemData>("Items");
        foreach (var item in items)
        {
            if (!itemDataCache.ContainsKey(item.itemID))
            {
                itemDataCache.Add(item.itemID, item);
            }
        }
    }

    public ItemData FindItemDataByID(int itemID)
    {
        if (itemDataCache.TryGetValue(itemID, out ItemData itemData))
        {
            return itemData;
        }

        Debug.LogWarning($"ItemData를 찾을 수 없습니다: ID = {itemID}");
        return null;
    }

    public void EquipWeapon(ItemData itemData)
    {
        if (EquippedWeapon != null)
        {
            Debug.LogWarning("이미 무기가 장착되어 있습니다.");
            return;
        }

        ItemSlot slot = inventory.Find(s => s.itemData.itemID == itemData.itemID);
        if (slot != null)
        {
            slot.quantity--;
            if (slot.quantity <= 0)
            {
                inventory.Remove(slot);
            }

            EquippedWeapon = new ItemSlot { itemData = itemData, quantity = 1 };
            Debug.Log($"무기 '{itemData.itemName}' 장착 완료.");
        }
        else
        {
            Debug.LogWarning("장착하려는 무기가 인벤토리에 없습니다.");
        }

        RefreshUI();
    }

    public void UnEquipWeapon()
    {
        if (EquippedWeapon == null)
        {
            Debug.LogWarning("장착된 무기가 없습니다.");
            return;
        }

        TryAddItem(EquippedWeapon.itemData, 1);
        Debug.Log($"무기 '{EquippedWeapon.itemData.itemName}' 해제 완료.");
        EquippedWeapon = null;

        RefreshUI();
    }

    public bool TryAddItem(ItemData itemData, int count = 1)
    {
        ItemSlot slot = inventory.Find(s => s.itemData.itemID == itemData.itemID);

        if (itemData.itemType == Define.ItemType.Equipment)
        {
            if (slot != null && slot.quantity >= 1)
            {
                Debug.LogWarning("이미 인벤토리에 무기가 있습니다.");
                return false;
            }
        }

        if (inventory.Count >= 30)
        {
            Debug.LogWarning("인벤토리 용량이 초과되었습니다.");
            return false;
        }

        if (slot != null)
        {
            slot.quantity += count;
        }
        else
        {
            inventory.Add(new ItemSlot { itemData = itemData, quantity = count });
        }

        RefreshUI();
        return true;
    }

    public void RemoveItem(ItemData itemData, int count = 1)
    {
        ItemSlot slot = inventory.Find(s => s.itemData.itemID == itemData.itemID);
        if (slot != null)
        {
            slot.quantity -= count;
            if (slot.quantity <= 0)
            {
                inventory.Remove(slot);
                Debug.Log($"아이템 '{itemData.itemName}'이(가) 인벤토리에서 제거되었습니다.");
            }
            else
            {
                Debug.Log($"아이템 '{itemData.itemName}'의 수량이 {slot.quantity}로 줄었습니다.");
            }
        }
        else
        {
            Debug.LogWarning($"제거하려는 아이템 '{itemData.itemName}'이(가) 인벤토리에 없습니다.");
        }

        RefreshUI();
    }

    public void RefreshUI()
    {
        UI_WeaponSel uI_WeaponSel = FindAnyObjectByType<UI_WeaponSel>();
        UI_EquipSel uI_EquipSel = FindAnyObjectByType<UI_EquipSel>();
        if(uI_WeaponSel != null)
        {
            Managers.UI.ClosePopupUI(uI_WeaponSel);
        }else if(uI_EquipSel != null)
        {
            Managers.UI.ClosePopupUI(uI_EquipSel);
        }

        UI_Inventory inventoryUI = Managers.UI.GetTopPopupUI() as UI_Inventory;
        
        if (inventoryUI != null)
        {
            inventoryUI.RefreshUI();
        }
    }

    public List<ItemSlot> GetOwnedItems()
    {
        return inventory;
    }

    public void SaveInventory(ref SaveData saveData)
    {
        List<SerializedItemSlot> serializedInventory = new List<SerializedItemSlot>();
        foreach (var slot in inventory)
        {
            serializedInventory.Add(new SerializedItemSlot
            {
                itemID = slot.itemData.itemID,
                quantity = slot.quantity
            });
        }

        saveData.inventoryData = serializedInventory;

        saveData.equippedWeaponID = EquippedWeapon?.itemData.itemID ?? -1;
    }

    public void LoadInventory(SaveData saveData)
    {
        if (saveData.inventoryData == null) return;

        inventory.Clear();
        foreach (var serializedSlot in saveData.inventoryData)
        {
            ItemData itemData = FindItemDataByID(serializedSlot.itemID);
            if (itemData != null)
            {
                inventory.Add(new ItemSlot { itemData = itemData, quantity = serializedSlot.quantity });
            }
        }

        if (saveData.equippedWeaponID != -1)
        {
            ItemData weaponData = FindItemDataByID(saveData.equippedWeaponID);
            if (weaponData != null)
            {
                EquippedWeapon = new ItemSlot { itemData = weaponData, quantity = 1 };
                Debug.Log($"무기 '{weaponData.itemName}' 장착 복원.");
            }
        }

        RefreshUI();
    }
}

 

RefreshUI에서 만약 장착이나 장착해제 UI가 떠있는 상태라면 인벤토리 UI가 최상단이 아니기 때문에 꺼주고 최상단 UI를 불러와서 Refresh를 진행하도록 해주었다. 

이에 맞춰서 장착해제와 장착의 UI의 코드도 수정해줬다.

UI_WeaponSel.cs

using UnityEngine.EventSystems;
using UnityEngine.UI;

public class UI_WeaponSel : UI_Popup
{
    public enum Buttons
    {
        EquipButton,
        RemoveButton,
    }

    public override void Init()
    {
        Bind<Button>(typeof(Buttons));
        GetButton((int)Buttons.EquipButton).gameObject.AddUIEvent(EquipWeapon);
    }

    void EquipWeapon(PointerEventData eventData)
    {
        SlotButton slotButton = transform.parent.GetComponent<SlotButton>();
        if (slotButton != null)
        {
            Managers.Inventory.EquipWeapon(slotButton.currentItemSlot.itemData);
            Managers.Inventory.RefreshUI();
        }
    }
}

 

SlotButton.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class UI_EquipSel : UI_Popup
{
    public enum Buttons
    {
        UnequipButton,
        RemoveButton,
    }

    public override void Init()
    {
        Bind<Button>(typeof(Buttons));

        GetButton((int)Buttons.UnequipButton).gameObject.AddUIEvent(UnequipWeapon);
    }

    void UnequipWeapon(PointerEventData eventData)
    {
        EquipSlot currentItem = transform.parent.gameObject.GetComponent<EquipSlot>();
        if (currentItem != null)
        {
            Managers.Inventory.UnEquipWeapon();
            Managers.Inventory.RefreshUI();
        }
    }
}

 

이렇게 해주면 인벤토리가 잘 작동하는 것을 볼 수 있다.

+ Recent posts