[75. Interpolating to the Enemy]
몬스터가 공격을 시작하고 캐릭터가 전투 범위를 벗어나지 않는다면 계속 허공에 공격을 시도하게 된다.
또한 몬스터를 바라보는 방향에 따라 공격이 빗나갈수도 있기때문에 별로 바람직한 현상은 아니다.
몬스터가 허공에 공격하는 것은 차후 수정하기로 하고 캐릭터가 몬스터의 전투 범위에 들어온 상태에서 공격 시, 회전보간을 적용해보자.
// Main.h
public:
float InterpSpeed;
bool bInterpToEnemy;
void SetInterpToEnemy(bool Interp);
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Combat")
class AEnemy* CombatTarget;
FORCEINLINE void SetCombatTarget(AEnemy* Target) { CombatTarget = Target; }
FRotator GetLookAtRotationYaw(FVector Target);
// Main.cpp
void AMain::Tick(float DeltaTime)
{
// ..생략
if (bInterpToEnemy && CombatTarget)
{
FRotator LookAtYaw = GetLookAtRotationYaw(CombatTarget->GetActorLocation());
// 현재 액터의 회전자와 타겟을 바라봐야하는 회전자의 회전보간값
FRotator InterpRotation = FMath::RInterpTo(GetActorRotation(), LookAtYaw, DeltaTime, InterpSpeed);
SetActorRotation(InterpRotation);
}
}
FRotator AMain::GetLookAtRotationYaw(FVector Target)
{
// 액터가 Target을 바라보는 회전자를 반환
FRotator LookAtRotation = UKismetMathLibrary::FindLookAtRotation(GetActorLocation(), Target);
// 그중 Yaw에 해당하는 값만 사용
FRotator LookAtRotationYaw(0.f, LookAtRotation.Yaw, 0.f);
return LookAtRotationYaw;
}
void AMain::SetInterpToEnemy(bool Interp)
{
bInterpToEnemy = Interp;
}
// Enemy.cpp
void AEnemy::CombatSphereOnOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
if (OtherActor)
{
if (Main)
{
// 공격 범위 안에 들어오면 자기 자신을 타겟으로 알려줌
Main->SetCombatTarget(this);
}
}
}
void AEnemy::CombatSphereOnOverlapEnd(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
if (OtherActor)
{
if (Main)
{
// 공격 범위를 벗어나면 초기화
Main->SetCombatTarget(nullptr);
}
}
}
캐릭터가 일단 한번 공격을 시작하고 적의 전투범위에 들어가있다면 캐릭터는 전투범위에 있는동안 계속 몬스터를 바라보게 될 것이다.
이것은 의도한 사항이 아니다. 공격을 시도할 때만 몬스터를 바라보고 그 외에는 다른곳을 보는것이 훨씬 자연스럽다.
// Main.cpp
AMain::AMain()
{
InterpSpeed = 15.f;
bInterpToEnemy = false;
}
void AMain::Attack()
{
if (!bAttacking)
{
bAttacking = true;
SetInterpToEnemy(true);
// .. 생략
}
void AMain::AttackEnd()
{
bAttacking = false;
SetInterpToEnemy(false); // 공격이 끝나면 보간할 적이 없다고 판단함
if (bLMBDown)
{
Attack();
}
}
공격 시작시 true, 종료시 false를 반복하기 때문에 지속적으로 적을 쳐다보지 않게 된다.
보간 전 및 전투 범위 밖에 있을 시
전투범위 내에서 공격시 보간을 통해 서서히 적을 바라봄
오토 타겟팅 게임의 경우 필요한 기능이겠지만 논타겟팅에서는 아마 필요가 없을 것이다.
논타겟팅의 경우 적의 위치보다는 마우스 커서 위치를 바라보며 회전을 해야 할 것이다.
[76. Enemy Attack Delay]
현재 몬스터의 매커니즘은 [대기 상태-어그로 범위에 있을시 추적-공격 범위에 있을시 공격] 으로 이루어져 있다.
공격 범위에 있을시 공격하는 부분은 몬스터가 지속적으로 공격하기 때문에 회피하기가 어려울 것이다.
일반적인 RPG에서의 몬스터를 보면 공격이 연속적으로 이루어지지 않고 조금씩 딜레이를 두고 다시 공격한다.
그래서 여타 게임들처럼 공격의 간격을 조정할 필요가 있다.
타이머 핸들을 이용하면 생각보다 쉽게 구현할 수 있을 것이다.
상수시간으로 딜레이를 줄 수도 있겠지만 랜덤한 시간으로 딜레이를 주는것도 나쁘지 않을것이다.
// Enemy.h
public:
FTimerHandle AttackTimer; // 콜백 등록을 위한 타이머 핸들
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Combat")
float AttackMinTime;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat")
float AttackMaxTime;
// Enemy.cpp
void AEnemy::CombatSphereOnOverlapEnd(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
if (OtherActor)
{
AMain* Main = Cast<AMain>(OtherActor);
if (Main)
{
Main->SetCombatTarget(nullptr);
bOverlappingCombatSphere = false;
if (EnemyMovementStatus != EEnemyMovementStatus::EMS_Attacking)
{
MoveToTarget(Main);
CombatTarget = nullptr;
}
// 공격 범위 밖으로 나갈 시, 타이머 핸들에 등록된 콜백을 모두 지운다
GetWorldTimerManager().ClearTimer(AttackTimer);
}
}
}
void AEnemy::AttackEnd()
{
bAttacking = false;
if (bOverlappingCombatSphere)
{
float AttackTime = FMath::FRandRange(AttackMinTime, AttackMaxTime);
// 공격이 끝나면 타이머 핸들에 Attack 함수를 콜백으로 등록한다
GetWorldTimerManager().SetTimer(AttackTimer, this, &AEnemy::Attack, AttackTime);
}
}
구현도 어렵지 않고 이해하는데 매우 직관적이다.
상수시간만 필요하다면 SetTimer와 ClearTimer 두개만 사용하면 될 것이다.
[77. Damage And Death]
캐릭터와 몬스터간의 피해를 입히는 부분을 제외하고는 전투의 매커니즘은 구현했다고 볼 수 있다.
언리얼에서는 데미지를 주고 받는 함수마저 미리 구현이 되어있다.
그걸 오버라이딩 해서 사용하면 된다.
ApplyDamage, TakeDamage 등등 종류가 다양하다.
재밌는점은 TakeDamage와 관련된 부분은 Actor, ApplyDamage와 관련된 부분은 GameplayStatics에 정의가 되어있다.
// Enemy.h
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat")
TSubclassOf<UDamageType> DamageTypeClass;
// Enemy.cpp
void AEnemy::CombatOnOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
// .. 생략
if (DamageTypeClass)
{
UGameplayStatics::ApplyDamage(Main, Damage, AIController, this, DamageTypeClass);
}
}
// Main.h
public:
virtual float TakeDamage(float Damage, struct FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser) override;
// Main.cpp
float AMain::TakeDamage(float Damage, struct FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
DecreamentHealth(Damage);
return Damage;
}
Enemy_BP에서 DamageTypeClass를 DamageType으로 설정해주고 실행하면 적의 공격이 적중할 때 체력이 감소한다.
ApplyDamage를 호출했더니 플레이어의 TakeDamage가 호출되어서 감소되고 있는 것이다.
float UGameplayStatics::ApplyDamage(AActor* DamagedActor, float BaseDamage, AController* EventInstigator, AActor* DamageCauser, TSubclassOf<UDamageType> DamageTypeClass)
{
if ( DamagedActor && (BaseDamage != 0.f) )
{
// make sure we have a good damage type
TSubclassOf<UDamageType> const ValidDamageTypeClass = DamageTypeClass ? DamageTypeClass : TSubclassOf<UDamageType>(UDamageType::StaticClass());
FDamageEvent DamageEvent(ValidDamageTypeClass);
return DamagedActor->TakeDamage(BaseDamage, DamageEvent, EventInstigator, DamageCauser);
}
return 0.f;
}
ApplyDamage의 정의를 보면 DamagedActor, 즉 Main캐릭터의 TakeDamage를 호출하는것을 볼 수 있다.
체력이 다 감소되면 죽는 애니메이션까지 추가해보도록 한다.
// Main.cpp
void AMain::Die()
{
UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
if (AnimInstance && CombatMontage)
{
AnimInstance->Montage_Play(CombatMontage, 1.f);
AnimInstance->Montage_JumpToSection(FName("Death"), CombatMontage);
}
}
과거에 이미 몽타주에 모션도 추가하고 섹션도 나눠준적이 있다. 거기에 DecreamentHP에서 Die를 실행하기 때문에 내용만 구현 해주면 우선 죽는 애니메이션이 나올 것이다.
다만 사망시 콜리전이 그대로 남아있거나 죽는 애니메이션이 딱 한번만 재생되고 다시 Idle 상태로 돌아가는 문제가 있지만 차후 수정하기로 하고 우선은 넘어간다.
공격을 받기만 하는것은 억울하므로 이제 몬스터도 공격을 받아야 한다.
// Enemy.h
EMS_Dead UMETA(DisplayName="Dead"), // 열거형에 추가할 것
public:
virtual float TakeDamage(float Damage, struct FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser) override;
void Die();
// Enemy.cpp
float AEnemy::TakeDamage(float Damage, struct FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
if (Health - Damage <= 0.f)
{
Health = 0.f;
Die();
}
else
{
Health -= Damage;
}
return Damage;
}
void AEnemy::Die()
{
UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
if (AnimInstance && CombatMontage)
{
AnimInstance->Montage_Play(CombatMontage, 1.f);
AnimInstance->Montage_JumpToSection(FName("Death"), CombatMontage);
}
SetEnemyMovementStatus(EEnemyMovementStatus::EMS_Dead);
CombatCollision->SetCollisionEnabled(ECollisionEnabled::NoCollision);
AgroSphere->SetCollisionEnabled(ECollisionEnabled::NoCollision);
CombatSphere->SetCollisionEnabled(ECollisionEnabled::NoCollision);
GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
Enemy에서도 똑같이 TakeDamage를 오버라이딩 하고 사망 시 모든 충돌처리가 무효가 되게 한다.
죽음 상태가 하나 추가된 것이기 때문에 얼거형에도 Dead를 추가해준다.
이제 진짜로 몬스터에게 피해를 입힐 차례이다.
몬스터는 무기를 따로 들고 있지 않았으므로 스스로가 공격력도 가지고 있고 직접 공격을 처리했는데 캐릭터는 조금 다르다. 무기를 휘두르긴 하지만 실제로 공격하는 객체는 무기이고 공격력도 무기가 가지고 있을 것이다.
그래서 Weapon클래스에서 ApplyDamage를 호출 할 것이다.
// Weapon.h
public:
FORCEINLINE void SetInstigator(AController* Inst) { WeaponInstigator = Inst; }
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Combat")
TSubclassOf<UDamageType> DamageTypeClass;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Combat")
AController* WeaponInstigator;
// Weapon.cpp
void AWeapon::CombatOnOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
// .. 생략
if (Enemy->HitSound)
{
UGameplayStatics::PlaySound2D(this, Enemy->HitSound);
}
if (DamageTypeClass)
{
UGameplayStatics::ApplyDamage(Enemy, Damage, WeaponInstigator, this, DamageTypeClass);
}
}
void AWeapon::Equip(AMain* Char)
{
if (Char)
{
SetInstigator(Char->GetController());
// .. 생략
}
무기의 공격력이 25, 기본체력이 75로 되어있으므로 3번만 공격하면 죽는 애니메이션이 재생 될 것이다. 그리고 모든 콜리전을 비활성화 시켰기 때문에 더이상 충돌이 일어나지 않는다.
캐릭터의 죽음에서도 발생했던 문제를 해결해볼 시간이다.
죽는 애니메이션이 1회 재생되고 다시 본래의 상태로 돌아오는데, 의도하지 않은 사항이다.
이것 역시 노티파이로 해결한다.
// Enemy.h
public:
UFUNCTION(BlueprintCallable)
void DeathEnd();
bool Alive();
// Enemy.cpp
AEnemy::AEnemy()
{
EnemyMovementStatus = EEnemyMovementStatus::EMS_Idle;
}
void AEnemy::DeathEnd()
{
GetMesh()->bPauseAnims = true; // 애니메이션 재생 일시중지
GetMesh()->bNoSkeletonUpdate = true; // 스켈레톤 업데이트 중지
}
bool AEnemy::Alive()
{
return GetEnemyMovementStatus() != EEnemyMovementStatus::EMS_Dead;
}
몬스터의 생존 여부를 판단해서 탐지범위, 공격범위 등 원하지 않는 기능에 들어가지 않도록 막아줄 안전장치도 하나 필요할 것이다.
탐지범위, 공격범위, 공격 함수를 Alive의 반환값으로 구현부를 감싸주면 된다.
캐릭터와 몬스터가 데미지를 주고 받고 죽음에 대한 처리까지 어느정도 마무리가 됐다.
몬스터가 사망했을 경우 오브젝트가 사라지지 않고 계속 남아있는데, 보통 게임에서는 일정시간이 지나면 사라지게끔 되어있는 경우가 많다.
// Enemy.h
public:
void Disappear();
FTimerHandle DeathTimer;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat")
float DeathDelay;
// Enemy.cpp
AEnemy::AEnemy()
{
DeathDelay = 3.f;
}
void AEnemy::DeathEnd()
{
GetWorldTimerManager().SetTimer(DeathTimer, this, &AEnemy::Disappear, DeathDelay);
}
void AEnemy::Disappear()
{
Destroy();
}
지금같은 경우는 따로 함수를 만들필요 없이 바로 Destory를 타이머 매니저에 등록시키면 된다.
추가적인 처리를 할 필요가 있을 경우 위와 같이 따로 만들어서 등록 시켜주면 될 것이다.
[정리]
- 회전 보간을 통해 자연스러운 회전을 가능하게 할 수 있다. (FMath::RInterpTo)
- 타이머 매니저를 통해 콜백을 등록하면 일정시간 뒤에 호출이 된다.
SetTimer: 콜백등록, ClearTimer: 해제, ClearAllTimersForObject: 오브젝트에 종속된 모든 타이머 해제 - ApplyDamage, TakeDamage를 통해 오브젝트간 피해를 주고 받을 수 있다.
[추가]
- 노티파이가 너무 만능인것처럼 남발되는 느낌이 좀 있다. 실제로 많이 사용되는지 정보를 찾아봐야 할 것 같다.
'언리얼 엔진 > UE4 C++' 카테고리의 다른 글
[UE C++] Combat #9 (0) | 2022.09.14 |
---|---|
[UE C++] Combat #8 (0) | 2022.09.14 |
[UE C++] Combat #6 (0) | 2022.09.13 |
[UE C++] Combat #5 (0) | 2022.09.12 |
[UE C++] Combat #4 (0) | 2022.09.11 |