최초에는 FOnTurnEnemyDead OnDead를 UPROPERTY를 통해 BlueprintAssignable 속성을 부여했으나DECLARE_MULTICAST_DELEGATE는 정적(C++) 타입이어서 등록되지 않음 이를 해결하기위해서는 동적(DYNAMIC) 델리케이트를 사용하거나 C++전용으로 UPROPERTY를 제거해야함 결국 아래와 같이 수정
// 완전 C++ 전용으로, UPROPERTY 제거
DECLARE_MULTICAST_DELEGATE(FOnTurnEnemyDead);
FOnTurnEnemyDead OnDead;
오류 해결 이후에도 브로드캐스트 호출 시그니처 관련 이슈로 델리게이트 선언과 Broadcast 호출의 매개변수가 일치하지 않아 아래와 같이 수정
//수정 전
OnDead.Broadcast(DamageCauser);
//수정 후
OnDead.Broadcast();
리스너(바인딩) 방식에서도 AddDynamic이 아닌 AddUObject로 수정
//수정 전
Enemy->OnDead.AddDynamic(this, &ATurnGameMode::OnEnemyDied);
//수정 후
Enemy->OnDead.AddUObject(this, &ATurnGameMode::OnEnemyDied);
만일 델리게이트에서 파라미터로 AActor* 같은 인자를 넘기고 싶다면 아래와 같이 사용해야함
// 직접 호출 방식
class APlayer : public APqwn
{
private:
AUIManager* UIManager; //직접 참조 필요
ASoundManager* SoundManager; //직접 참조 필요
AGameMode* GameMode; // 직접 참조 필요
public:
void TakeDamage(float Damage)
{
CurrentHealth -= Damage;
// 모든 시스템을 직접 호출
if (UIManager) UIManager->UpdateHealthBar(CurrentHealth);
if (SoundManager) SoundManager->PlayDamageSound();
if (GameMode) GameMode->CheckGameOver();
//새로운 시스템 추가시 이어서 수정
}
};
// 델리게이트 방식
class APlayer : public APawn
{
public:
DECLARE_MULTICAST_DELEGATE_OneParam(FOnHealthChaged, float);
FOnHealthChange OnHealthChanged;
void TakeDamage(float Damge)
{
CurrentHealth -= Damage;
// 누가 듣고 있는지 몰라도 됨
OnHealthChanaged.Broadcast(CurrentHealth);
}
};
확장성
이벤트 기반 아키텍처
Observer 패턴: 하나의 이벤트에 여러 관찰자
발행 구독 모델: 이벤트 발생자와 처리자 분리
비동기 처리: 이벤트 발생과 처리의 분리
유지보수성
단일 수정: 이벤트 로직만바꾸면 모든 곳에 적용
버그 격리: 한 시스템 오류가 다른 시스템에는 영향 X
코드 재사용: 같은 이벤트를 여러 곳에서 활용
//새로운 시스템 추가가 쉬움
class AParticleManager : public AActor
{
void BeginPlay() override
{
//기존 Player 클래스 수정 없이 새 기능 추가
Player->OnHealthChanged.AddUObject(this,&AParticleManager::PlayBloodEffect);
}
};
위와 같이 ECC_GameTraceChannel1 에 Item할당되게 되면 기존의 BTService_SearchTarget에서 Player를 찾기위해 OverlapMultiByChannel을 사용할때 기본 설정으로 사용했던 ECC_GameTraceChannel1가 새로 추가한 Item 오브젝트의 설정을 따라가게되고 기본 반응이 무시로 덮어쓰이게됨.
따라서 이를 해결하기 위해서는 새로운 Object Channel을 설정하고 그 채널을 사용하거나 아무것도 할당되지 않은 ECC_GameTraceChannel2 채널을 사용하도록 수정할 필요가있어 ECC_GameTraceChannel2 채널을 사용하도록 수정하였음
//수정 전
void UBTService_SearchTarget::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);
auto Pawn = OwnerComp.GetAIOwner()->GetPawn();
if (Pawn != nullptr)
{
FVector Center = Pawn->GetActorLocation();
float SearchDistance = 500.f;
TArray<FOverlapResult> OverlapResults;
FCollisionQueryParams QueryParams(NAME_Name, false, Pawn);
bool Result = GetWorld()->OverlapMultiByChannel
(
OverlapResults,
Center,
FQuat::Identity,
ECollisionChannel::ECC_GameTraceChannel1, // Item Object Channel이 할당되어 수정이 필요해진 부분
FCollisionShape::MakeSphere(SearchDistance),
QueryParams
);
if (Result)
{
for (auto& OverlapResult : OverlapResults)
{
auto Player = Cast<AMyPlayer>(OverlapResult.GetActor());
if (Player)
{
DrawDebugSphere(GetWorld(), Center, SearchDistance, 10, FColor::Green, false, 0.5f);
OwnerComp.GetBlackboardComponent()->SetValueAsObject(FName("Target"), Player);
return;
}
}
}
OwnerComp.GetBlackboardComponent()->SetValueAsObject(FName("Target"), nullptr);
DrawDebugSphere(GetWorld(), Center, SearchDistance, 10, FColor::Red, false, 0.5f);
}
}
//수정 후
void UBTService_SearchTarget::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);
auto Pawn = OwnerComp.GetAIOwner()->GetPawn();
if (Pawn != nullptr)
{
FVector Center = Pawn->GetActorLocation();
float SearchDistance = 500.f;
TArray<FOverlapResult> OverlapResults;
FCollisionQueryParams QueryParams(NAME_Name, false, Pawn);
bool Result = GetWorld()->OverlapMultiByChanne2 // 수정 된 부분
(
OverlapResults,
Center,
FQuat::Identity,
ECollisionChannel::ECC_GameTraceChannel1,
FCollisionShape::MakeSphere(SearchDistance),
QueryParams
);
if (Result)
{
for (auto& OverlapResult : OverlapResults)
{
auto Player = Cast<AMyPlayer>(OverlapResult.GetActor());
if (Player)
{
DrawDebugSphere(GetWorld(), Center, SearchDistance, 10, FColor::Green, false, 0.5f);
OwnerComp.GetBlackboardComponent()->SetValueAsObject(FName("Target"), Player);
return;
}
}
}
OwnerComp.GetBlackboardComponent()->SetValueAsObject(FName("Target"), nullptr);
DrawDebugSphere(GetWorld(), Center, SearchDistance, 10, FColor::Red, false, 0.5f);
}
}
DoDefaultAttack() 함수에서 실제 공격 판정 Sphere와 DrawDebugCapsule에서 그리는 Capsule의 형태가 일치 X
실제 공격판정인 SweepSingleByChannel에서는 FQuat::Identity를 사용해 스피어가 Z축 기준으로 수직으로 서있는 형태이고 DrawDebugCapsule에서는 FQuat Rotation = FRotationMatrix::MakeFromZ(Vec).ToQuat() 변수를 선언한뒤 Rotaion을 적용하여 캐릭터의 정면 방향으로 그리게 되는 것을 FQuat Rot = FRotationMatrix::MakeFromZ(EndPos - StartPos).ToQuat(); 를 공통으로 사용하여 일치하게 수정
void ACOECharacter::DoDefaultAttack()
{
FHitResult HitResult;
FCollisionQueryParams Params;
Params.AddIgnoredActor(this);
Params.bTraceComplex = false;
Params.bReturnPhysicalMaterial = false;
float AttackRange = 250.f;
float AttackRadius = 50.f;
FVector StartPos = GetActorLocation();
FVector EndPos = GetActorLocation() + GetActorForwardVector() * AttackRange;
FQuat Rot = FRotationMatrix::MakeFromZ(EndPos - StartPos).ToQuat(); // 수정을 위해 추가된 부분
bool Result = GetWorld()->SweepSingleByChannel
(
HitResult,
StartPos,
EndPos,
Rot, //FQuat::Identity에서 미리 선언한 Rot으로 수정
ECC_GameTraceChannel3,
FCollisionShape::MakeSphere(AttackRadius),
Params
);
FVector Center = (StartPos + EndPos) * 0.5f;
float HalfHeight = AttackRange * 0.5f;
//FQuat Rotation = FRotationMatrix::MakeFromZ(Vec).ToQuat(); -> 같은 FQuat Rot을 사용하기 위해 삭제
FColor DrawColor;
DrawColor = Result ? FColor::Green : FColor::Red;
//실제 공격에 사용한 FQuat값을 사용하기위해 기존의 Rotation 대신 Rot을 사용
DrawDebugCapsule(GetWorld(), Center, HalfHeight, AttackRadius, Rot, DrawColor, false, 2.f);
if (Result && HitResult.GetActor())
{
UE_LOG(LogTemp, Log, TEXT("Hit : %s"), *HitResult.GetActor()->GetName());
UGameplayStatics::ApplyDamage(HitResult.GetActor(), 10.f, GetInstigatorController(), this, nullptr);
if (AExplorationEnemy* Enemy = Cast<AExplorationEnemy>(HitResult.GetActor()))
{
if (Enemy->PossibleBattleLevels.Num() > 0)
{
FName SelectedBattleMap = Enemy->PossibleBattleLevels[FMath::RandRange(0, Enemy->PossibleBattleLevels.Num() - 1)];
if (UCOEGameInstance* GI = Cast<UCOEGameInstance>(UGameplayStatics::GetGameInstance(this)))
{
GI->bPlayerInitiative = true;
GI->bPlayerWasDetected = false;
GI->ReturnLocation = GetActorLocation();
GI->ReturnMapName = FName("Lvl_ThirdPerson");
GI->EnemyToRemove = HitResult.GetActor();
}
UGameplayStatics::OpenLevel(this, SelectedBattleMap);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("Enemy has no PossibleBattleLevels!"));
}
}
}
UE_LOG(LogTemp, Warning, TEXT("DefaultAttack() called in TurnBattleLevel!"));
}
DetectPlayerComponent.cpp 구현 중 생성자에 스피어 반경, CollisionProfile 설정, Bind overlap Event 등을 구현하였더니 컴파일이 안되는 오류 발생
생성자에서 구현 X BeginPlay에서 구현하여 CollsionProfile 시스템이 완전히 초기화 된 이후 안전하게 적용
// 초기 작성 코드
UDetectPlayerComponent::UDetectPlayerComponent()
{
// 충돌 반경설정
SetSphereRadius(100.f);
SetCollisionProfileName(TEXT("OverlapAllDynamic"));
// Bind overlap events
OnComponentBeginOverlap.AddDynamic(this, &UDetectPlayerComponent::HandleBeginOverlap);
OnComponentEndOverlap.AddDynamic(this, &UDetectPlayerComponent::HandleEndOverlap);
}
void UDetectPlayerComponent::BeginPlay()
{
Super::BeginPlay();
}
// 수정 코드
UDetectPlayerComponent::UDetectPlayerComponent()
{
}
void UDetectPlayerComponent::BeginPlay()
{
Super::BeginPlay();
// 충돌 반경설정
SetSphereRadius(100.f);
SetCollisionProfileName(TEXT("OverlapAllDynamic"));
// Bind overlap events
OnComponentBeginOverlap.AddDynamic(this, &UDetectPlayerComponent::HandleBeginOverlap);
OnComponentEndOverlap.AddDynamic(this, &UDetectPlayerComponent::HandleEndOverlap);
}
처음에는 가장 베이스가 될 COECharacter클래스에 CharacterStats에 관련된 변수를 선언하려 하였으나 구초체로 구현하는 것이 더 적합할 것이라는 판단에 구현하던중 여러 Character 및 Enemy 클래스에서 활용하기에는 구조체 클래스를 하나 만드는 것이 적합하다는 판단에 FCharacterStats 클래스를 만들기로 결정 앞의 F는 언리얼 명명체계임
FCharacterStats 클래스 생성을위해 빈 C++클래스 생성
생성된 FCharacterStats.h에서 #include "CoreMinimal.h" #include "FCharacterStats.generated.h" 두개 만 남기고 다 삭제한다음 아래코드로 작성 #include "클래스명.generated.h" 가 가장 마지막에 선언되지않으면 빌드에 문제가 생김 → 몰라서 다른 문제인지 찾아보다가 GPT에서 알려줘서 고침
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "FCharacterStats.generated.h"
/**
*
*/
USTRUCT(BlueprintType)
struct FCharacterStats
{
GENERATED_BODY()
public:
/** HPMAX */
UPROPERTY(BlueprintReadOnly, Category = "Status")
float MAXHP;
/** HP */
UPROPERTY(BlueprintReadOnly, Category = "Status")
float CurrentHP;
/** Vitality */
UPROPERTY(BlueprintReadOnly, Category = "Status")
float Vitality;
/** AttackPower */
UPROPERTY(BlueprintReadOnly, Category = "Status")
float AttackPower;
/** Defense */
UPROPERTY(BlueprintReadOnly, Category = "Status")
float Defense;
/** Agility */
UPROPERTY(BlueprintReadOnly, Category = "Status")
float Agility;
/** Luck */
UPROPERTY(BlueprintReadOnly, Category = "Status")
float Luck;
};
//COECharacter 클래스에서 사용하기 위해 COECharacter.h에 #include "FCharacterStats.h" 추가
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "Logging/LogMacros.h"
#include "FCharacterStats.h"
#include "COECharacter.generated.h" //각 클래스의 generate.h가 가장 마지막에 선언 되어야함
공격 Mongtage 작업 완료 후 공격 모션중 충돌 판정이 필요한 부분에 Collision 작업과 공격당한 상대에게 Damage 판정을 구현 중 이전의 코드 상태면 AnimMontage는 공격 모션이 다 끝나야 다시 시작할 수 있지만 Controller 입력과 Character 의 함수는 계속 호출 됨으로써 COECharacter에 구현할 Collision과 Damage판정에 오류가 생길 가능성을 예방하기 위해 bIsAttacking 값을 만들어 false일때만 공격하게 만들 필요성이 생김
처음에는 COECharacter에서 bool bIsAttacking = false인 변수를 public으로 만들고 COEPlayerController에서 인풋이 들어오면 true로 바꾼다음 AnimMontage가 끝날때 OnAttackMontageEnded 함수를 통해 bIsAttacking = fasle로 만들어주려 했으나 계속 true값이 나오는 문제가 생겨 AnimNotify를 이용하는 방법을 찾아 아래와 같이 적용함
//COEPlayerController
// COEChar nullptr 검사와 COEChar->GetCharacter()->IsFalling()으로 공중에있는지 이미 공격 중인지 체크
if (!IsValid(COEChar) || COEChar->GetCharacterMovement()->IsFalling())
{
UE_LOG(LogTemp, Log, TEXT("COEChar == nullptr && bIsFalling == true"));
return;
}
// 공격중이라면 입력 X
if (COEChar->bIsAttacking)
{
UE_LOG(LogTemp, Log, TEXT("bIsAttacking == true"));
return;
}
// 문제없을 시 COECharacter의 DefaultAttack() 실행
COEChar->DefaultAttack();
COEChar->bIsAttacking = true;
//COEAnimInstance
//공격이 끝나면 bIsAttacking = false
UFUNCTION(BlueprintCallable, Category = "Animation")
void AnimNotify_End();
//공격모션 중 타격 타이밍에 Collision 생성
UFUNCTION(BlueprintCallable, Category = "Animation")
void AnimNotify_DoDefaultAttack();
void UCOEAnimInstance::AnimNotify_End()
{
Character->bIsAttacking = false;
UE_LOG(LogTemp, Log, TEXT("bIsAttacking == false"));
}
void UCOEAnimInstance::AnimNotify_DoDefaultAttack()
{
Character->DoDefaultAttck();
UE_LOG(LogTemp, Log, TEXT("DoDefaultAttack"));
}
→ 위와 같은 수정으로 AttackMontage가 끝나야만 공격 Input을 받을 수 있게 변경
언리얼 C++ Character 변수명 오류 및 Cast<자료형>(GetCharacter())오류 해결과 Build 시간 단축
언리얼 포트폴리오 작성 중 샘플C++파일을 수정하면서 모든 Input은 PlayerController쪽 클래스에 몰아넣고 Character클래스 쪽에는 카메라 및 AnimInstance 캐스트 정도만 생성자에 몰아 넣어둔 상태로 개발하였고 매번 빌드때마다 오래걸리는 이슈가 발생하였으나 원인을 찾지못한 상태로 진행
PlayerController쪽에 키보드 F클릭시 기본공격(근접)을 받아오고 거기서 Character의 DefaultAttakc() 을 호출해서 Character클래스의 DefaultAttack()을 실행 그 뒤 AnimInstance의 DefaultAttackAnim()으로 AttackAnimMontage를 실행 시켜주는 방식으로 구성
그 상태에서 ACOECharacter* Character 같은 변수선언을 COEPlayerController클래스에서 사용 시 오류가 발생하는것을 발견 이전에도 COEPlayerController에서 GetPawn()과 GetCharacter()를 받을때 Character, Pawn등을 변수명으로 사용하면 문제가 되었기에 혹시나 하는 마음으로 Character는 COEChar 으로 아래와 같이 수정하고 매번 Cast해주던 것을 BeginPlay()에서 한번만 Cast해줬더니 빌드가 오래걸리는 문제와 수정전의 불필요해보이고 지저분했던 코드 문제 해결
if (ACOECharacter* Character = Cast<ACOECharacter>(GetCharacter()))
//처음엔 위 코드로 작성하여 사용할때는 Character변수명에 오류 발생
ACharacter* ControllerCharacter = this->GetCharacter(); //수정전
if (ControllerCharacter != nullptr)
//처럼 매번 ControllerCharacter에 this->GetCharacter()를 할당해서 사용하던 코드를 아래처럼 BeginPlay()에서 한번만 Cast해주고
void ACOEPlayerController::BeginPlay()
{
Super::BeginPlay();
COEChar = Cast<ACOECharacter>(GetCharacter());
}
if (IsValid(COEChar)) //수정후
//위 코드로 수정하였더니 코드도 간결해 지고 빌드도 빨라짐
추가로 오늘 작업목표중 근접공격 구현에서 아래와 같이 코드를 작성했을때 AnimInstance가 null이 되는 문제가 생겨 찾아보니 생성자에서 AnimInstance를 캐스트하면 매시가 생성전이라 제대로 작동하지 않을 수 있다고 하여 아래와 같이 BeginPlay() 에서 캐스트 해주도록 수정