Unreal 로그라이크 게임

정재훈·2023년 5월 15일
0

프로젝트 설명

fps 로크라이크 게임으로 몬스터들의 공격을 피하고 죽여
자신의 스킬을 강화하고 최대한 오래 버티는게 목표인 게임입니다.
시간이 지날수록 점점 많은적과 강한적이 나오며 8분이 지나면 보스몬스터가 소환되며
보스몬스터가 소환되는 주기는 점점 짧아집니다.
플래이어는 소환되는 몬스터들을 잡고 나오는 경험치를 먹어
새로운 스킬을 해금하거나 기존 능력을 강화할수 있습니다.
플레이어의 hp가 0이되면 모든 능력치가 초기화되고 게임을 처음부터 다시할수 있습니다.

게임영상

풀게임
https://www.youtube.com/watch?v=E3379T-zNIY

기능설명
https://www.youtube.com/watch?v=PBKkgBKR7PY

목차

  • Player Character
  • Player 레벨업과 능력치 업그레이드
  • 속성 반응
  • Enemy
  • Boss
  • Enemy Spawn
  • 후기

Player Character

Character의 전투 능력 리스트

  • Attack : 기본 공격, 라이플을 쏜다
  • E : 강력한 총알 한발을 쏜다
  • Q : 폭탄을쏘고 충돌 지점에 지속적으로 데미지를 주는 영역을 생성한다
  • Tesla : character 옆에 드론을 생성하고 기본공격에 trigger되어 총알을 발사한다
    총알은 충돌지점에 터져 광역 데미지를 가한다.
  • Passive : character 주위에 일정 텀마다 데미지를 주는 영역을 생성한다.

위에 능력들은 각각 아래의 속성값중 하나를 가질수 있습니다.

  • 물리
  • 얼음
  • 번개
  • 바람

Attack 핵심 코드

  • ProjectLDCharacter.cpp(74)
//Gun actor을 스폰하고 socket에 붙인뒤 이 캐릭터를 Owner로 설정한다
	Gun = GetWorld()->SpawnActor<AGun>(GunClass);
	Gun->AttachToComponent(GetMesh(), FAttachmentTransformRules::KeepRelativeTransform, TEXT("hand_r"));
	Gun->SetOwner(this);
  • ProjectLDCharacter.cpp(112)
EnhancedInputComponent->BindAction(ShootAction, ETriggerEvent::Triggered, this, &AProjectLDCharacter::StartShoot);
EnhancedInputComponent->BindAction(ShootAction, ETriggerEvent::Completed, this, &AProjectLDCharacter::StopShoot);

라이플은 좌클릭을 누르는동안 계속 발사되야하기 때문에 눌렀을때와 뗐을때 두가지 버튼을 바인딩해준다.

  • ProjectLDCharacter.cpp(297)
void AProjectLDCharacter::StartShoot() {
	if (CanShoot == true) {
		Shoot();
		GetWorldTimerManager().SetTimer(FireRateTimerHandle, this, &AProjectLDCharacter::Shoot, FireRate, true);
		//fire animation을 위한 anim momantage 변수
		FireButtonDown = true;
	}
}

좌클릭을 눌렀을때 StartShoot 함수가 호출되어 Firate를 주기로 Shoot 함수를 호출합니다.

  • ProjectLDCharacter.cpp(306)
void AProjectLDCharacter::StopShoot() {
	GetWorldTimerManager().ClearTimer(FireRateTimerHandle);
	FireButtonDown = false;
}

우클릭을 뗐을때는 타이머핸들을 비워줘 Shoot함수 호출을 멈춥니다.

  • ProjectLDCharacter.cpp(331)
void AProjectLDCharacter::Shoot() {
	//총 반동 camerashake
	if (ShootGunShake != nullptr) {
		GetWorld()->GetFirstPlayerController()->ClientStartCameraShake(ShootGunShake);
	}
	Gun->PullTrigger();
	if (TeslaType != 5 && CanTesla) {
		CanTesla = false;
		GetWorldTimerManager().SetTimer(TeslaTimerHandle, this, &AProjectLDCharacter::TeslaTrueTimer, TeslaTime, false);
		Tesla->PullTrigger();
	}
}

CameraShake를 이용해 총 반동을 구현했습니다.
Gun class에 PullTrigger 함수를 호출합니다
또한 평타에 trigger되어 합동공격을 하는 Tesla를 쏠수있는 condition을 확인하고 호출합니다.
Tesla에대한 코드는 Tesla파트에서 더 다루겠습니다.

  • Gun.cpp(38)
void AGun::PullTrigger()
{
	FHitResult Hit;
	FVector ShotDirection;
	bool bSuccess = GunTrace(Hit, ShotDirection);
    
	//shoot projectile
	if (bSuccess) {
		FVector Location = MuzzleLocation;
		FRotator Rotation = (Hit.Location - MuzzleLocation).Rotation();

		PlayerBullet = GetWorld()->SpawnActor<APlayerBullet>(PlayerBulletClass, Location, Rotation);
		PlayerBullet->SetOwner(this);

		PlayerBullet->setDirection(-Rotation.Vector());
		//PlayerBullet->SetBulletAttackType(GunAttackType);
	}

	else {
		FVector Location = MuzzleLocation;
		FRotator Rotation = (End - MuzzleLocation).Rotation();

		PlayerBullet = GetWorld()->SpawnActor<APlayerBullet>(PlayerBulletClass, Location, Rotation);
		PlayerBullet->SetOwner(this);

		PlayerBullet->setDirection(-Rotation.Vector());
		//PlayerBullet->SetBulletAttackType(GunAttackType);
	}
}

bool AGun::GunTrace(FHitResult& Hit, FVector& ShotDirection) {
	AController* OwnerController = GetOwnerController();

	if (OwnerController != nullptr) {
		//Player 시점의 location과 rotation값을 받아온다
		FVector Location;
		FRotator Rotation;
		OwnerController->GetPlayerViewPoint(Location, Rotation);
		ShotDirection = -Rotation.Vector();

		//위에 구한 lcoation에 rotation의 벡터에 사거리를 곱해 더하여 사거리의 끝을 구한다
		End = Location + Rotation.Vector() * MaxRange;
        
		//ai가 자기 자신을 쏘는것 방지
		FCollisionQueryParams Params;
		Params.AddIgnoredActor(this);
		Params.AddIgnoredActor(GetOwner());
		//line trace를 통해 Hit에 출동 정보 반환
		return GetWorld()->LineTraceSingleByChannel(Hit, Location, End, ECollisionChannel::ECC_GameTraceChannel1, Params);
	}
	return false;
}

GunTrace 함수에서는 플래이어의 카메라 뷰포인트에서 line tracing을해 Hit정보와 location 참조인자값을 구해줍니다.
이때 총을 쏘는 와중에 플레이어가 죽으면 OwnerController가 사라져 에러가 발생해 예외처리를 해주었습니다.

PullTrigger 함수에서는 Guntrace에서 받아온 값으로 총구(muzzle location)에서 hit location으로 총알을 발사해야 합니다.
FRotator Rotation = (Hit.Location - MuzzleLocation).Rotation();
hit location vector에서 총구 vector를 빼줘 rotation 값을 구하면 3인칭 fps에서도 플래이어가
의도한 방향으로 투사체를 발사 시킬수 있습니다.
이때 허공으로 line tracing할때는 Hit정보가 없기때문에 사거리 끝인 End vector를 사용 끝사거리로 투사체를 발사해줍니다.

  • PlayerBullet.cpp(22)
ProjectileMovementComponent = CreateDefaultSubobject <UProjectileMovementComponent>(TEXT("Projectile Movement Component"));
ProjectileMovementComponent->MaxSpeed = 1300.f;
ProjectileMovementComponent->InitialSpeed = 1300.f;

UProjectileMovementComponent 를 사용하면 spawn되었을때 투사체로 날라가게된다

  • PlayerBullet.cpp(22)
void APlayerBullet::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
	//sending damage to actor got hit
	AActor* HitActor = Hit.GetActor();
	APawn* HitPawn = Cast<APawn>(HitActor);

	//DrawDebugPoint(GetWorld(), Hit.Location, 20, FColor::Red, true);
	UGameplayStatics::SpawnEmitterAtLocation(GetWorld(), ImpactEffect, Hit.Location, ShotDirection.Rotation());
	//총맞은자리에 소리
	UGameplayStatics::SpawnSoundAtLocation(GetWorld(), ImpactSound, Hit.Location);

	if (HitPawn != nullptr) {
		ASurvivorGameMode* GameMode = GetWorld()->GetAuthGameMode<ASurvivorGameMode>();
		Damage*= (1+0.5*GameMode->AttackLevel);
		UE_LOG(LogTemp, Warning, TEXT("Attack %f"), Damage);
		FPointDamageEvent DamageEvent(Damage, Hit, ShotDirection, AttackDamageTypeClass);
		AController* OwnerController = GetOwnerController();
		HitActor->TakeDamage(Damage, DamageEvent, OwnerController, this);
		ACharacter* HitChar = Cast<ACharacter>(HitActor);
		/*
		if (HitChar != nullptr) {
			HitChar->LaunchCharacter(ShotDirection, true, true);
		}
		*/
	}
	Destroy();
}

총알 actor가 충돌하게 되면 onhit 함수를 호출하게 됩니다.
Damage값은 플래이어가 강화한 스킬레벨에 비례해 높아지며
hit actor에 DamageEvent로 데미지를 주며 DamageType에 총알의 속성정보를 담아 넘겨줍니다.
속성은 위에 상기했듯이 물리, 불, 얼음, 번개, 바람이 있습니다.
LaunchCharacter를 통해 총알에 맞은 actor를 뒤로 밀려나게 구현할수 있지만 직접 플래이 해보니 밀려나지 않는게 재밌어 주석처리해 주었습니다.
마지막에는 Destroy 함수로 총알을 없에준다.

E 핵심 코드

E는 강화총알이라 위에 Attack과 많은 부분을 공유합니다.
다른점은 trigger event로 한번만 발동하며 한번 발동하면 스킬 쿨타임이 돌아갑니다.

  • ProjectLDCharacter.cpp(201)
void AProjectLDCharacter::EAttack() {
	if (EType != 5&&CanEAbility) {
		CanEAbility = false;
		EAttackCount = EAbilityTime;
		GetWorldTimerManager().SetTimer(EAttackTimerHandle, this, &AProjectLDCharacter::EAttackTrueTimer, 1, true);
		EAbilityShoot();
	}
}

void AProjectLDCharacter::EAttackTrueTimer()
{
	EAttackCount--;
	if (EAttackCount <1) {
		CanEAbility = true;
		GetWorldTimerManager().ClearTimer(EAttackTimerHandle);
	}
}

E를 쏠수있는 컨디션인 CanEAbility변수를 통해 쿨타임동안은 E가 발동되지 못하게합니다.
또한 타이머를 사용해 쿨타임을 1초씩 카운트해가며 게임 UI에 쿨타임이 얼마나 남았는지 표시해줍니다.
쿨타임이 다되면 CanEAbility를 true로 바꿔주고 타이머 핸들을 클리어해줍니다.
이뒤로 나머지 코드는 Attack과 공유하며 blueprint에서 비주얼적인 부분과 총알의 collision 범위에 그리고 데미지에서 차이를 뒀습니다.

Q 핵심 코드

Q또한 쿨타임, trigger방식 등많은 부분을 E와 공유하며
다른 부분은 투사체가 중력의 영향을 받아 포물선을 그려야하기때문에 블루프린트에서
simulation Enabled를 체크해줍니다.
또한 투사체의 충돌 지점의 바닥에 지속적으로 데미지를 주는 영역을 spawn 해야합니다.
QBullet의 onhit함수에서 SpawnActor를 통해 spawn해줍니다.

QBullet.cpp(39)

void AQBullet::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
	//sending damage to actor got hit
	AActor* HitActor = Hit.GetActor();
	APawn* HitPawn = Cast<APawn>(HitActor);

	//DrawDebugPoint(GetWorld(), Hit.Location, 20, FColor::Red, true);
	UGameplayStatics::SpawnEmitterAtLocation(GetWorld(), ImpactEffect, Hit.Location, ShotDirection.Rotation());
	//총맞은자리에 소리
	UGameplayStatics::SpawnSoundAtLocation(GetWorld(), ImpactSound, Hit.Location);
	
	FHitResult HitGround;
	FVector CharLocation = Hit.Location;

	//char위치에서의 바닥 vector을 구한다
	FVector End = CharLocation + FVector(0.f, 0.f, -90.f) * 10000;
	GetWorld()->LineTraceSingleByChannel(HitGround, CharLocation, End, ECollisionChannel::ECC_GameTraceChannel6);
	FVector BoomLocation = HitGround.Location + FVector(0.f, 0.f, 10.f);
	GetWorld()->SpawnActor<AQBoom>(AQBoomClass, BoomLocation, ShotDirection.Rotation());
	if (HitPawn != nullptr) {
		FPointDamageEvent DamageEvent(Damage, Hit, ShotDirection, AttackDamageTypeClass);
		AController* OwnerController = GetOwnerController();
		HitActor->TakeDamage(Damage, DamageEvent, OwnerController, this);
		ACharacter* HitChar = Cast<ACharacter>(HitActor);
		if (HitChar != nullptr) {
			HitChar->LaunchCharacter(ShotDirection, true, true);
		}

	}
	Destroy();
}

이전 코드와 큰 차이점은 collision location 바닥으로 수직으로 linetracing을 해
바닥 location에 지속적으로 광역 데미지를 주는 QBoom 클래스를 spawn한다는 것입니다.

QBoom.cpp(22)

void AQBoom::BeginPlay()
{
	Super::BeginPlay();
	// declare overlap events
	GetWorldTimerManager().SetTimer(BoomTickHandle, this, &AQBoom::SpawnBoom, TickTime, true);
	Player = Cast<AActor>(UGameplayStatics::GetPlayerCharacter(GetWorld(), 0));
}

void AQBoom::SpawnBoom()
{
	ASurvivorGameMode* GameMode = GetWorld()->GetAuthGameMode<ASurvivorGameMode>();
	Damage =10*( 1 + 0.5 * GameMode->QLevel);
	TArray<AActor*> ignoredActors{};
	ignoredActors.Add(Player);
	UGameplayStatics::ApplyRadialDamage(
		GetWorld(),
		Damage,
		GetActorLocation(),
		DamageRadius,
		AttackDamageTypeClass,
		ignoredActors,
		this,
		GetInstigatorController(),
		true
	);
	//DrawDebugSphere(GetWorld(), GetActorLocation(), DamageRadius,16,FColor::Yellow,false,5.0f,0,3.0f);
}

충돌지점에 타이머를 통해 ApplyRadialDamage를 통해 광역 데미지를 가하며
Player actor를 ignoredActors TArray에 추가해 플래이어는 데미지를 입지 않도록 구현했다.
5초의 지속시간이후 Destroy되도록 했습니다.

Tesla 핵심 코드

tesla를 평타에 트리거 되어 합동공격하는 구체입니다.

  • ProjectLDCharacter.cpp(358)
if (TeslaType != 5 && CanTesla) {
		CanTesla = false;
		GetWorldTimerManager().SetTimer(TeslaTimerHandle, this, &AProjectLDCharacter::TeslaTrueTimer, TeslaTime, false);
		Tesla->PullTrigger();
	}

위에 Attack 에서 Shoot함수에 trigger하며 타이머를 통해 내부쿨을 돌려 Attack중에 일정시간마다 발동되도록 합니다.

나머지 코드는 비슷하지만 총구에서 발사되는것이 아닌 tesla구체에서 발사되야 하기때문에
hit location에서 tesla location vector을 빼줘 Attack의 총알과 출발지는 다르지만
같은 목표를 향해 날라가도록 구현했으며 hit location에서 ApplyRadialDamage를 통해
광역 데미지를 가하도록 했습니다. 위에 코드와 비슷하기때문에 생략하겠습니다.

Passive 핵심 코드

플래이어 주위에 일정시간마다 광역 데미지를 가하는 능력입니다.
Q가 일정틱마다 광역데미지를 주는 방식과 비슷하게 구현했으며 차이점은
데미지주는 actor를 player socket에 attach해주어 player 주변에서 데미지를 주도록 구현했습니다.

Player의 비전투 능력

Dash 핵심코드

공격 능력 외에도 플래이어의 능력중에는 대쉬 능력이 있다.

  • ProjectLDCharacter.cpp(168)
void AProjectLDCharacter::Dash()
{
	if (CanDash) {
		UGameplayStatics::SpawnSoundAttached(DashSound, GetMesh(), TEXT("MuzzleFlash"));
		CanDash = false;
		DashCount = DashTime;
		const FVector ForwardDir{ this->GetVelocity().X,this->GetVelocity().Y,0.0f };
		LaunchCharacter(ForwardDir * DashDistance, true, true);
		GetWorldTimerManager().SetTimer(DashRateTimerHandle, this, &AProjectLDCharacter::SetDashTrue, 1, true);
		if (ShieldAbility) {
			Shield = true;
			GetWorldTimerManager().SetTimer(ShieldTimerHandle, this, &AProjectLDCharacter::ShieldTrueTimer, ShieldTime, false);
			UE_LOG(LogTemp, Warning, TEXT("Shield On"));
		}
	}
}

쿨타임 관리는 E나 Q와 같이 타이머로 1초씩 카운트하는 방식을 사용하며 UI로 플래이어가 확인할수 있다.
대쉬 방식은 플래이어의 velocity를 얻어와 플래이어의 진행 방향으로 LaunchCharacter를 이용해 대쉬한다.
이때 점프하면서 LaunchCharacter을 하면 하늘로 날라가버리는 문제가 발생하기 때문에 velocity의 z-axis 값을 0으로 설정해준다.
밑에서 다루겠지만 레벨업 카드를 통해 Shield 능력을 해금하면 Dash시 일정시간동안 무적상태를 얻는다.
대쉬시 Shield변수를 true로 설정하면 데미지를 입지 않으며 타이머를 통해 일정시간후 다시 false로 설정해 무적상태를 해제한다.

TakeDamage 핵심코드

  • ProjectLDCharacter.cpp(368)
float AProjectLDCharacter::TakeDamage(float DamageAmount, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
	
	float DamageToApply = Super::TakeDamage(DamageAmount, DamageEvent, EventInstigator, DamageCauser);
	if (!GodMode&&!Shield) {
		DamageToApply = FMath::Min(Health, DamageToApply);
		Health -= DamageToApply;
		
		//anim montage
		HitReact();

		//피격 신음
		UGameplayStatics::SpawnSoundAttached(HitSound, GetMesh(), TEXT("faceAttach"));

		if (IsDead())
		{
			//게임모드에 사망정보를 모낸다
			
			AProjectLDGameMode* GameMode = GetWorld()->GetAuthGameMode<AProjectLDGameMode>();
			if (GameMode != nullptr) {
				GameMode->PawnKilled(this);
			}
			
			//죽으면 컨트롤러 제거해 더이상 ai가 행동하지 못하게 한다
			DetachFromControllerPendingDestroy();
			GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);

		}
		GodMode = true;
		GetWorldTimerManager().SetTimer(UnusedHandle, this, &AProjectLDCharacter::TimerElapsed, HitGodModeTime, false);
	}
	return DamageToApply;
}

캐릭터에게 데미지가 들어올때 호출되는 함수다.
데미지가 입는 조건은 Shield가 없을때 그리고 GodMode라는 변수가 있는데
캐릭터는 일정시간에 한번의 데미지밖에 입지 않는다. 이게임에서는 0.2초로 설정했으며
한번 데미지를 입으면 0.2초동안 무적상태가 되며 플래이어가 데미지를 입었음을 인식하기위해
소리와 animMontage 그리고 UI를 붉은색으로 반짝이게해 Damage Indicator를 구현했다.
또한 IsDead 함수를 호출해 HP가 0이하로 내려가게되면 GameMode에 플래이어가 죽었음을 알려
패배UI를 띄우고 플래이어 캐릭터를 더이상 컨트롤할수 없게 한다.

GetExp 핵심코드

void AProjectLDCharacter::GetEXP(float num) {
	MyEXP+=num*EXPPercent;
	if (MyEXP >= MaxEXP) {
		Level++;
		MyEXP -= MaxEXP;
		MaxEXP *= 1.1;
		UE_LOG(LogTemp, Warning, TEXT("Level up %d"), Level);
		ASurvivorGameMode* GameMode = GetWorld()->GetAuthGameMode<ASurvivorGameMode>();
		GameMode->PlayerLevelUp();
	}
	UE_LOG(LogTemp, Warning, TEXT("Got EXP %f"), MyEXP);
}

몹을 죽이고 얻은 EXP는 MaxEXP를 넘기면 레벨업하며 게임모드에 레벨업 함수를 호출해
레벨업 카드를 얻을수 있습니다.
얻는 EXP는 레벨업카드중에 EXP카드를 레벨업하면 EXPPercent만큼 추가 휙득하며
레벨업 할때마다 MaxEXP는 커져 더많은 EXP를 요구합니다.

Player 레벨업과 능력치 업그레이드

이 게임은 로그라이크 게임으로 플레이어가 몹을죽이고 경험치를 먹어 레벨업시
총 14개의 레벨업 카드중 3개의 카드가 랜덤으로 주어집니다.

레벨업 카드 리스트

0 MaxHP 최대 HP를 증가시킨다
1 Heal 초당 최대 HP의 0.1% 회복한다
2 EXP 경험치를 추가 휙득한다
3 Tesla 협동공격하는 드론을 생성한다
4 Passive 일정시간마다 주위에 데미지를 준다
5 Run 이동속도를 증가시킨다
6 Dash 대쉬 쿨타임을 감소 시킨다
7 Shield 대쉬시 일정시간동안 무적상태가 된다
8 Attack 평타 강화
9 E E로 특수 공격을 한다
10 Q Q로 특수 공격을 한다
11 Food 최대 HP의 50% 회복
12 Money 일정량의 돈을 얻는다
13 HaHa 꽝

  • AbilityList.h
struct FAbilityStruct : public FTableRowBase
{
	GENERATED_BODY()
	//struct contructer
	FAbilityStruct()
	{}

	FAbilityStruct(const FString& InAbility_name, const FString& InAbility_description, const int InLevel) : Ability_name(InAbility_name), Ability_description(InAbility_description), Level(InLevel)
	{}
	
	UPROPERTY(EditAnywhere, BlueprintReadOnly)
		FString Ability_name;

	UPROPERTY(EditAnywhere, BlueprintReadOnly)
		FString Ability_description;
	UPROPERTY(EditAnywhere, BlueprintReadOnly)
		int Level;
};

레벨업 카드를 데이터 struct를 생성하고 생성한 struct를 바탕으로
아래와 같이 unreal datatable blueprint를 만듭니다.

  • AbilityList.cpp
void AAbilityList::BeginPlay()
{
	Super::BeginPlay();
	for (auto it : AbilityTable->GetRowMap())
	{
		// it.Key has the key from first column of the CSV file
		// it.Value has a pointer to a struct of data. You can safely cast it to your actual type, e.g FMyStruct* data = (FMyStruct*)(it.Value);
		
		FAbilityStruct* data = (FAbilityStruct*)(it.Value);
		UE_LOG(LogTemp, Warning, TEXT("Data succeeded %s"), *FString(data->Ability_name));
		AbilityArray.Emplace(*data);
	}
}

게임이 시작되면 위에 생성한 struct를 바탕으로 TArray를 만들고 위에 생성한 datatable에 있는 값을 읽어옵니다.

랜덤으로 카드 3장 뽑기

14개의 카드중 3개의 카드를 뽑아 플래이어에게 제시해줘야 합니다.
3개의 카드를 뽑을때 조건은 아래와 같습니다.

  • 3개의 카드는 중복되면 안됩니다.
  • level5에 도달한 카드는 제외시켜야합니다.
  • Tesla, Passive, Attack, E, Q 즉 플래이어의 전투능력카드라면 5개의 속성값중 하나를 랜덤으로 가집니다.
  • 플래이어가 이미 한번 뽑은 속성전투능력카드는 항상 그 속성으로 고정합니다.
    예를들어 E(Ice)카드를 한번 골랐다면 다음부터 등장할 E카드의 속성은 항상 Ice로 고정됩니다.
    즉 한번 속성을 정하면 다른 속성으로 변경 불가능합니다.
  • SurvivorGameMode.cpp(132)
void ASurvivorGameMode::PlayerLevelUp()
{
	AbilityCount = AbilityAvailable.Num();
	ChooseArray.Init(20, 3);

	//능력 리스트에서 3개를 랜덤으로 정한다
	for (int i = 0; i < 3; i++) {
		int AbilityChoose = FMath::FRandRange(0, AbilityCount);
		ChooseArray[i] = AbilityAvailable[AbilityChoose];
		for (int j = 0; j < i; j++) {
			if (ChooseArray[j] == AbilityAvailable[AbilityChoose]) {
				i--;
			}
		}
	}
	

플래이어가 레벨업 했을시 호출되는 함수
중복되지 않는 카드 3장을 랜덤으로 뽑는다. 만약 중복된다면 다시뽑는걸 반복한다.

//속성 능력의 경우 5가지 속성중 하나를 랜덤으로 정함
	/*
	0 물리
	1 불
	2 얼음
	3 번개
	4 바람
	*/
	for (int i = 0; i < 3; i++) {
		AProjectLDCharacter* PlayerLevelUp = Cast<AProjectLDCharacter>(UGameplayStatics::GetPlayerPawn(this, 0));
		//Tesla
		if (ChooseArray[i] == 3) {
			if (!TeslaTypeSet) {
				int ElementChoose = FMath::FRandRange(0, 5);
				if (ElementChoose == 0) {
					AbilityList->AbilityArray[ChooseArray[i]].Ability_name = FString(TEXT("Tesla(Physic)"));
				}
				else if (ElementChoose == 1) {
					AbilityList->AbilityArray[ChooseArray[i]].Ability_name = FString(TEXT("Tesla(Fire)"));
				}
				else if (ElementChoose == 2) {
					AbilityList->AbilityArray[ChooseArray[i]].Ability_name = FString(TEXT("Tesla(Ice)"));
				}
				else if (ElementChoose == 3) {
					AbilityList->AbilityArray[ChooseArray[i]].Ability_name = FString(TEXT("Tesla(Electronic)"));
				}
				else if (ElementChoose == 4) {
					AbilityList->AbilityArray[ChooseArray[i]].Ability_name = FString(TEXT("Tesla(Wind)"));
				}
			}
			
		}
  .
  .
  .
  LevelUpUIDisplay();

속성능력일때 아직 속성이 정해지않은 능력일 경우 랜덤으로 하나의 속성을 정한다
코드는 테슬라의경우에만있지만 다른속성공격의경우에도 똑같이 처리해줬다.
전부 처리해주고나서는 BlueprintImplementableEvent function으로 블루프린트에서
3개의 카드를 보여주는 ui를 플래이어 화면에 띄워주었다.

SurvivorGameMode.cpp()

void ASurvivorGameMode::AbilityPressed(int num)
{
	if (ChooseArray[num] == 3)
		TeslaLevel++;
	if (ChooseArray[num] == 4)
		PassiveLevel++;
	if (ChooseArray[num] == 8)
		AttackLevel++;
	if (ChooseArray[num] == 9)
		ELevel++;
	if (ChooseArray[num] == 10)
		QLevel++;

	if (ChooseArray[num] != 11 && ChooseArray[num] != 12 && ChooseArray[num] != 13)
	{
		//선택한 능력의 레벨을 올린다
		AbilityList->AbilityArray[ChooseArray[num]].Level += 1;
		//최대레벨을 찍으면 카드에 더이상 나오지 않게한다
		UE_LOG(LogTemp, Warning, TEXT("skill level up %d"), ChooseArray[num]);
		if (AbilityList->AbilityArray[ChooseArray[num]].Level >= 5) {
			AbilityAvailable.Remove(ChooseArray[num]);
		}
	}
	AProjectLDCharacter* PlayerLevelUp= Cast<AProjectLDCharacter>(UGameplayStatics::GetPlayerPawn(this, 0));
	PlayerLevelUp->GetSkillNum(ChooseArray[num], AbilityList->AbilityArray[ChooseArray[num]].Ability_name);
}

플래이어가 3개의카드중 하나의 카드를 클릭했을때 호출되는 함수.
클릭한 카드의 능력번호를 인자로받아와 레벨업 해준다.
11 Food 최대 HP의 50% 회복
12 Money 일정량의 돈을 얻는다
13 HaHa 꽝
단 이 3가지 경우에는 레벨업 하지 않는다.
또한 레벨5에 도달했을경우 나올수있는 카드리스트에서 제거해준다.
레벨업한 카드를 플래이어의 능력치에 적용하기위해 플래이어 클래스에 카드번호를 넘겨준다.

레벨업한 공격카드 플래이어 적용

기본적으로 플래이어는 Attack을 물리속성으로 가지고 있으며
E, Q, Tesla, Passive는 카드를 처음 선택하기 전까지는 사용이 불가능합니다.
따라서 처음 선택했을때 각 공격을 실행할 클래스를 캐릭터에 spawn해줍니다.

void AProjectLDCharacter::SetAttackType(int num)
{
	AttackType = num;
	if (num == 1) {
		Gun->Destroy();
		Gun = GetWorld()->SpawnActor<AGun>(FireGunClass);
		Gun->AttachToComponent(GetMesh(), FAttachmentTransformRules::KeepRelativeTransform, TEXT("hand_r"));
		Gun->SetOwner(this);
	}
	else if (num == 2) {
		Gun->Destroy();
		Gun = GetWorld()->SpawnActor<AGun>(IceGunClass);
		Gun->AttachToComponent(GetMesh(), FAttachmentTransformRules::KeepRelativeTransform, TEXT("hand_r"));
		Gun->SetOwner(this);
	}
	else if (num == 3) {
		Gun->Destroy();
		Gun = GetWorld()->SpawnActor<AGun>(ElecGunClass);
		Gun->AttachToComponent(GetMesh(), FAttachmentTransformRules::KeepRelativeTransform, TEXT("hand_r"));
		Gun->SetOwner(this);
	}
	else if (num == 4) {
		Gun->Destroy();
		Gun = GetWorld()->SpawnActor<AGun>(WindGunClass);
		Gun->AttachToComponent(GetMesh(), FAttachmentTransformRules::KeepRelativeTransform, TEXT("hand_r"));
		Gun->SetOwner(this);
	}
}

예를 들어 Attack 카드를 선택하면 물리 속성카드가 아닐경우 처음에 가지고있던
물리 속성의 Gun을 Destroy하고 각 속성에 맞는 Gun을 새로 Spawn해줍니다.

속성 반응

플래이어는 5가지의 공격능력에 5가지 속성을 조합하여 다양한 속성 공격을 가하며
속성 공격은 몬스터에 속성 상태를 부여한다. 속성 상태가 부여된 몹이 다른 속성 공격을 맞으면
각각 다양한 속성반응을 하게됩니다.
기본적으로 몬스터는 모든 속성의 데미지에대하여 40%의 내성을 가지고 있습니다

속성반응

  • 불 + 얼음 : 데미지 2배 (반대의 경우도 동일)

  • 불 + 번개 : 주변에 광역 물리 데미지 (반대의 경우도 동일)

  • 얼음 + 번개 : 물리 내성 80% 감소 (반대의 경우도 동일)

  • 불 or 얼음 or 번개 + 바람: 몹의 해당 속성 내성 50% 감소

  • LDEnemy.cpp(150)

float ALDEnemy::TakeDamage(float DamageAmount, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
	float DamageToApply = Super::TakeDamage(DamageAmount, DamageEvent, EventInstigator, DamageCauser);
	UAttackDamageType* DamageType = Cast<UAttackDamageType>(DamageEvent.DamageTypeClass->GetDefaultObject());
	if (DamageType != nullptr) {
		CharState->CalculateDamage(DamageType->AttackType, DamageToApply);
	}
	DamageDisplay(DamageToApply, DamageCauser);
	DamageToApply = FMath::Min(Health, DamageToApply);
	Health -= DamageToApply;
	
	if (IsDead())
	{
		//spawn deadeffect
		DeadEffect();
		
		//spawn Item
		FVector Location = GetActorLocation();
		FRotator Rotation = GetActorRotation();
		GetWorld()->SpawnActor<AItem>(ItemEXPClass, Location, Rotation);

		Destroy();

	}
	
	return DamageToApply;
}

몬스터가 데미지를 받을때 호출되는 takeDamge에서 DamageTypeClass에 데미지의 속성정보를 받아옵니다.
그리고 들어온 데미지와 속성정보를 CharState 클래스에 넘겨줘 몬스터의 속성 반응과 데미지값을 계산해줍니다.

불 반응

  • CharState.cpp
void ACharState::CalculateDamage(int Type, float& Damage) {
	if (state == 1) {
		//불+불
		if (Type == 1) {
			stateCount = 5;
			Damage *= FireShield;
		}
		//불+얼음
		else if (Type == 2) {
			state = 5;
			Damage *= 2;
			Damage *= IceShield;
		}
		//불+전기
		else if (Type == 3) {
			state = 5;
			stateCount = 0;
			Damage *= ElecShield;
			TArray<AActor*> ignoredActors{};
			UGameplayStatics::ApplyRadialDamage(
				GetWorld(),
				20,
				GetOwner()->GetActorLocation(),
				300,
				AttackDamageTypeClass,
				ignoredActors,
				this,
				GetInstigatorController(),
				true
			);
		}
		//불+바람
		else if (Type == 4) {
			stateCount = 5;
			Damage *= WindShield;
			FireShield = 0.8;
			FireShieldCount = 5;
			GetWorldTimerManager().ClearTimer(FireShieldHandle);
			GetWorldTimerManager().SetTimer(FireShieldHandle, this, &ACharState::FireShieldState, 1, true);
		}
		else {
			Damage *= Shield;
		}
	}
    .
    .
    .

몬스터에 불 상태가 부여되어있는 상태에서 다른속성데미지가 들어 왔을때의 코드다.

  • 불+불 : 이미 불상태가 부여되어있기때문에 불 데미지가 들어오면 상태를 유지하는 타이머의 카운트를 다시 갱신해준다.
  • 불+얼음 : 데미지를 2배로 적용해준다
  • 불+전기 : ApplyRadialDamage로 주변에 광역 데미지를 준다.
  • 불+바람 : 불에대한 내성을 감소시키고 타이머를 통해 내성감소 시간을 관리해준다.
  • 불+물리 : 따로 원소반응없이 물리데미지를 준다.

다른 속성반응도 위와 비슷한 방식으로 구현해주었습니다.

Enemy

4가지의 몬스터들이 있다.

  • LDEnemy : 기본 몹으로 collision시 데미지를 준다.
  • ShootEnemy : 화염구를 발사해 데미지를 준다.
  • BoomEnemy : 플래이어 위치에 일정시간마다 데미지를 주는 구모양의 화염 영역을 생성한다.
  • BlastEnemy : 플래이어를 향해 긴 원기둥 모양의 화염 기둥을 생성한다.

LDEnemy

  • LDEnemy.cpp
void ALDEnemy::OnHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
	FVector ShotDirection;
	bool bSuccess = DirTrace(ShotDirection);
	//sending damage to actor got hit
	AActor* HitActor = Hit.GetActor();
	APawn* HitPawn = Cast<APawn>(HitActor);
	
	if (HitPawn != nullptr) {
		//충돌한 actor가 player일때 데미지이벤트 발생
		if (HitActor != nullptr && HitPawn->IsPlayerControlled()) {
			FPointDamageEvent DamageEvent(Damage, Hit, ShotDirection, nullptr);
			AController* OwnerController = GetController();
			HitActor->TakeDamage(Damage, DamageEvent, OwnerController, this);
		}
	}
}

충돌시 데미지를 주기때문에 전에 사용했던 플래이어의 투사체에서 사용했던 OnHit 함수와 비슷한 구조입니다.
단 서로 collision하며 서로 데미지를 주는 현상을 방지하기위해 Player일경우에만 데미지를 주도록 제한했습니다.

나머지 3개의 몬스터는 기본몹과 움직임 TakeDamage등의 기능을 똑같이 가져야하기때문에
LDEnemy의 child class로 생성하였습니다.

ShootEnemy

  • ShootEnemy.cpp
void AShootEnemy::Shoot() {
	/*
	FVector ProjectileSpawnPointLocation= ProjectileSpawnPoint->GetComponentLocation();
	DrawDebugSphere(GetWorld(), ProjectileSpawnPointLocation, 25.f, 12, FColor::Red, false, 3.f);
	*/
	
	FVector Location = ProjectileSpawnPoint->GetComponentLocation();
	FVector Loc;
	FRotator Rotation;
	GetController()->GetPlayerViewPoint(Loc, Rotation);

	EnemyProjectile=GetWorld()->SpawnActor<AEnemyProjectile>(ProjectileClass, Location, Rotation);
	EnemyProjectile->SetOwner(this);
	
	EnemyProjectile->setDirection(-Rotation.Vector());
}

역시 플래이어가 총을 발사하는 코드와 비슷하게 구현했습니다.
다만 몹의 투사체가 다른 몹과 collision해 없어지는것을 방지하기위해 blueprint에서
collision channel을 Enemy를 무시하도록 조절했습니다.

BoomEnemy

  • BoomEnemy.cpp
void ABoomEnemy::Boom() {
	/*
	FVector ProjectileSpawnPointLocation= ProjectileSpawnPoint->GetComponentLocation();
	DrawDebugSphere(GetWorld(), ProjectileSpawnPointLocation, 25.f, 12, FColor::Red, false, 3.f);
	*/

	APawn* PlayerPawn = UGameplayStatics::GetPlayerPawn(GetWorld(), 0);

	if (PlayerPawn != nullptr) {
		FHitResult Hit;
		FVector CharLocation = PlayerPawn->GetActorLocation();

		//char위치에서의 바닥 vector을 구한다
		End = CharLocation + FVector(0.f,0.f,-90.f) * MaxRange;
		GetWorld()->LineTraceSingleByChannel(Hit, CharLocation, End, ECollisionChannel::ECC_GameTraceChannel6);
		BoomLocation = Hit.Location+ FVector(0.f, 0.f, 10.f);

		//폭탄범위를 스폰하고 일정시간뒤 폭파한다
		GetWorldTimerManager().SetTimer(BoomDelay, this, &ABoomEnemy::BoomTime, 0.7f, false);

		BoomEffect();
	}
}

플래이어의 Boom능력과 비슷한 방식으로 구현되었습니다.
캐릭터의 위치vector을 받아오고 그위치에서 바닥으로 linetracing을해 바닥에 Boom을 소환하는 방식입니다.
다만 그렇게 바로 Boom을 소환하면 플래이어가 공격을 피할 여지가 없어집니다.
그래서 BoomEffect로 먼저 스킬 범위 이팩트를 소환하고 타이머를 사용해 BoomDelay뒤에
데미지를 주는 actor를 소환하도록해 플래이어가 스킬범위 이팩트를 보고 피할수 있도록 구현했습니다.

또한 데미지를 주는 actor의 구현 방식에도 차이점이 있습니다.

  • EnemyBoom.cpp(47)
void AEnemyBoom::OnOverlapBegin(class UPrimitiveComponent* OverlappedComp, class AActor* OtherActor, class UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	
	overlapping = true;

	//sending damage to actor got hit
	HitActor = OtherActor;
	HitPawn = Cast<APawn>(HitActor);
	Hit = SweepResult;

	UE_LOG(LogTemp, Warning, TEXT("overlapped %s"), *OtherActor->GetName());

	if (HitPawn != nullptr) {
		UE_LOG(LogTemp, Warning, TEXT("overlapped"));
		if (HitActor != nullptr && HitPawn->IsPlayerControlled()) {
			
			FPointDamageEvent DamageEvent(Damage, SweepResult, ShotDirection, nullptr);
			AController* OwnerController = GetOwnerController();
			HitActor->TakeDamage(Damage, DamageEvent, OwnerController, this);
			GetWorldTimerManager().SetTimer(BoomTickHandle, this, &AEnemyBoom::BoomTick, TickTime, true);
		}

	}
}

void AEnemyBoom::BoomTick() {
	if (overlapping) {
		FPointDamageEvent DamageEvent(Damage, Hit, ShotDirection, nullptr);
		AController* OwnerController = GetOwnerController();
		HitActor->TakeDamage(Damage, DamageEvent, OwnerController, this);
	}
	else {
		GetWorldTimerManager().ClearTimer(BoomTickHandle);
	}
}

void AEnemyBoom::OnOverlapEnd(class UPrimitiveComponent* OverlappedComp, class AActor* OtherActor, class UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
	UE_LOG(LogTemp, Warning, TEXT("overlap end"));
	overlapping = false;
}

EnemyBoom은 플래이어에서 RadialDamageEvent를 통해 광역 데미지를 줬던것과 다르게
일정 크기의 actor을 소환하고 collision을 overlap으로 설정해 actor의 영역에 overlap되는
캐릭터에 데미지를 주는 방식으로 구현했습니다.
사실 이 방식보다는 RadialDamageEvent를 사용하는게 더 맞는 설계방식이라고 생각하고 플래이어의 Boom을 만들기 이전에 EnemyBoom을 먼저 구현하면서 잘몰라서 벌어진 시행착오였습니다.
하지만 이 프로젝트에서 overlap을 다루는 파트가 없었기때문에 사용방식을 기록하고자 그대로 남겨두기로했습니다.
이런방식으로 구현했을때의 문제점이 제가 솔로플래이할때는 따로 발견되지 않았지만 예상하기로는
데미지를 받는 대상이 여러명일시 actor에 overlap되는 시간이 각자 다를겁니다. 그로인해 같은 스킬을 맞고있지만 서로 다른타이밍에 데미지가 들어오는 문제가 발생할 수 있습니다.

BlastEnemy

BlastEnemy는 BoomEnemy와 데미지를 주는 영역의 모양이 다른것과 데미지주는 actor을 플래이어의 위치에 소환하는 것이 아닌 Enemy의 위치에서 플래이어의 방향으로 소환한다는점 외에는 동일하기때문에 따로 다루지 않겠습니다.

Enemy AI

Enemy AI의 Behavior Tree는 대략 위의 사진과 같습니다.
가장 위의 selector에서 custom BT_Service로 Player Location If Seen은 플래이어가 Enemy의 시야에 있을경우 PlayerLocation값을 Set 합니다.
하지만 Enemy의 시야에 없을경우에는 PlayerLocation값을 비워주죠 코드로 보면 아래와 같습니다.

  • BTService_PlayerLocationIfSeen.cpp
void UBTService_PlayerLocationIfSeen::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
	Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);

	APawn* PlayerPawn = UGameplayStatics::GetPlayerPawn(GetWorld(), 0);
	if (PlayerPawn == nullptr) {
		return;
	}

	if (OwnerComp.GetAIOwner()->LineOfSightTo(PlayerPawn))
	{
		OwnerComp.GetBlackboardComponent()->SetValueAsVector(GetSelectedBlackboardKey(), PlayerPawn->GetActorLocation());
	}
	
	else {
		OwnerComp.GetBlackboardComponent()->ClearValue(GetSelectedBlackboardKey());
	}
}

PlayerLocation이 set되어 있으면 Chase Sequence를 실행하고 unset 되어있으면 Investigate Sequence를 실행하게 됩니다.
Chase에서는 Service로 LastKnowPlayerLocation을 업데이트해주며 플래이어가 시야에서 벗어나 PlayerLocation이 unset되었을때 Investiage에서 플래이어가 마지막으로 목격된 위치로 Enemy를 이동시킵니다.
Chase에서 플래이어가 일정거리 가까워지면 BT_Task로 공격을 실행합니다. 이때 Chase는 플래이어가 시야에 있을때기때문에 Enemy가 벽넘어에서 공격하는 것을 방지했습니다.

4가지의 Enemy전부 비슷하게 구현되었으며 collision 데미지를 주는 LDEnemy만 플래이어에 collision할정도로 가깝게 moveto task를 설정했습니다.

Boss

Boss를 죽이는 방법은 일반적인 몹을 죽이는 방식과 다릅니다.
Boss는 머리위에 3개의 구체를 가지고 있습니다.
3개의 구체에 데미지를줘 전부 파괴하기 전에는 Boss는 데미지를 입지 않습니다.
하지만 이 구체는 파괴되고 일정시간뒤에 각각 회복되기 때문에 3개의 구체를 모두 파괴했을때 빠르게 데미지를 넣는것이 중요합니다.
이 3개의 구체는 보스의 공격 패턴과도 관련 있습니다. 구체를 한개씩 파괴할때마다 대응되는 보스의 공격 패턴이 하나씩 줄어들어 플래이어가 공격을 회피하기 쉬워지죠.

보스의 패턴은 총 4가지가 있습니다.

  • 기본공격 : 망치를 휘둘러 데미지를 줍니다.
  • 구체0 : ShootEnemy의 화염구를 여러개 사방으로 발사합니다.
  • 구체1 : BoomEnemy의 화염 영역을 플래이어 주변에 여러개 랜덤위치에 소환합니다.
  • 구체2 : 망치를 내려찍으며 주변에 광역데미지를 줍니다.

즉 구체를 모두파괴하면 보스는 기본공격밖에 하지 않습니다.

기본공격

animation이 망치를 휘두름에 따라 망치에 collision damage를 줘야하기때문에
Anim Notify State를 이용해 망치socket 시작에서 끝부분을 trace해줍니다

위에 만든 Notify를 Attack animation의 notifies에 추가해줍니다.

그러면 이 Anim Montage를 실행하면 망치에 맞은 플래이어는 데미지를 입게됩니다.

Shoot

void ABoss::Shoot() {
	/*
	FVector ProjectileSpawnPointLocation= ProjectileSpawnPoint->GetComponentLocation();
	DrawDebugSphere(GetWorld(), ProjectileSpawnPointLocation, 25.f, 12, FColor::Red, false, 3.f);
	*/
	if (AbilityBall0 == true)
	{
		FVector Location = ProjectileSpawnPoint->GetComponentLocation();
		FVector Loc;
		FRotator Rotation;
		GetController()->GetPlayerViewPoint(Loc, Rotation);

		for (int i = 0; i < 10; i++) {
			int z = FMath::FRandRange(0, 360);
			z -= 180;
			FRotator RotTemp = FRotator(Rotation.Roll, Rotation.Pitch, 0.f) + FRotator(0.f, z, 0.f);
			GetWorld()->SpawnActor<AEnemyProjectile>(ProjectileClass, Location, RotTemp);
		}
	}
}

구체0이 있을때만 실행되는 공격패턴이며 360도 랜덤으로 10개의 화염구체를 발사합니다.

Boom

void ABoss::Boom() {
	if (AbilityBall1 == true)
	{
		APawn* PlayerPawn = UGameplayStatics::GetPlayerPawn(GetWorld(), 0);

		if (PlayerPawn != nullptr) {
			FHitResult Hit;
			FVector CharLocation = PlayerPawn->GetActorLocation();

			//char위치에서의 바닥 vector을 구한다
			End = CharLocation + FVector(0.f, 0.f, -90.f) * MaxRange;
			GetWorld()->LineTraceSingleByChannel(Hit, CharLocation, End, ECollisionChannel::ECC_GameTraceChannel6);

			for (int i = 0; i < 6; i++) {
				int x = FMath::FRandRange(0, 2000);
				int y = FMath::FRandRange(0, 2000);
				x -= 1000;
				y -= 1000;
				BoomLoc = Hit.Location + FVector(x, y, 10.f);
				BoomLocation.Emplace(BoomLoc);
				//폭탄범위를 스폰하고 일정시간뒤 폭파한다
				BoomEffect();
			}
			GetWorldTimerManager().SetTimer(BoomDelay, this, &ABoss::BoomTime, 0.7f, false);
		}
	}
}

void ABoss::BoomTime()
{
	for (auto& Loc : BoomLocation) {
		FRotator Rotation = GetActorRotation();
		GetWorld()->SpawnActor<AEnemyBoom>(EnemyBoomClass, Loc, Rotation);
	}
	BoomLocation.Empty();
}

구체1이 있을때만 실행되는 공격패턴이며
플래이어 주변의 6개의 Location을 랜덤으로 얻은뒤 6개의 위치에 스킬범위 이팩트를 먼저 생성해
플래이어에게 경고해주고 일정시간뒤 6개의 데미지주는 acotr을 소환해줍니다.

내려찍기

마지막으로 구체2가 있을때만 실행되는 공격패턴으로 망치를 내려찍고 주변에 광역 데미지를 줍니다.
구현방식은 기본공격과 같지만 마지막에 내려찍은 위치에서 RadialDamage를 가합니다.

Boss 구체

  • Boss.cpp
void ABoss::BeginPlay()
{
	Super::BeginPlay();
	.
    .
    .
	BossShootAbilityBall = GetWorld()->SpawnActor<ABossAbilityBall>(BossShootAbilityBallClass);
	BossShootAbilityBall->AttachToComponent(GetMesh(), FAttachmentTransformRules::KeepRelativeTransform, TEXT("AbilityBallShoot"));
	BossShootAbilityBall->SetOwner(this);
	.
    .
    .
}

보스가 spawn됬을때 구체 3개를 모두 가진체로 시작하기위해 beginPlay에서 구체actor을 소환하고 Boss Skeletal Mesh에 미리 생성해둔 socket에 붙여줍니다.

  • BossAbilityBall.cpp
bool ABossAbilityBall::IsDead() const
{
	ABoss* OwnerBoss = Cast<ABoss>(GetOwner());
	if (Health<=0&&OwnerBoss != nullptr)
	{
		OwnerBoss->AbilityBallDestroyed(AbilityNum);
	}
	return Health <= 0;
}

takeDamage는 전에 방식과 비슷하며 구체의 hp가 0이하로 내려가면 owner Boss에 구체가 파괴되었다는 함수를 구체번호를 인자로 넘겨주며 호출합니다.

  • Boss.cpp
void ABoss::AbilityBallDestroyed(int num) {
	UE_LOG(LogTemp, Warning, TEXT("destroyed %d"), num);
	if (num == 0) {
		AbilityBall0 = false;
		GetWorldTimerManager().SetTimer(BallDelay0, this, &ABoss::SpawnBall0, 30.0f, false);
	}
	else if (num == 1) {
		AbilityBall1 = false;
		GetWorldTimerManager().SetTimer(BallDelay1, this, &ABoss::SpawnBall1, 30.0f, false);
	}
	else {
		AbilityBall2 = false;
		GetWorldTimerManager().SetTimer(BallDelay2, this, &ABoss::SpawnBall2, 30.0f, false);
	}
}

구체가 파괴되면 구체의 파괴여부를 유지하는 bool변수를 false로 바꿔주며 30초뒤에 구체가 다시 회복할수 있도록 타이머를 생성해둡니다.

  • Boss.cpp
void ABoss::SpawnBall0() {
	BossShootAbilityBall = GetWorld()->SpawnActor<ABossAbilityBall>(BossShootAbilityBallClass);
	BossShootAbilityBall->AttachToComponent(GetMesh(), FAttachmentTransformRules::KeepRelativeTransform, TEXT("AbilityBallShoot"));
	BossShootAbilityBall->SetOwner(this);
	AbilityBall0 = true;
}

30초뒤에는 다시 파괴됬던 구체를 생성하고 socket에 붙여줍니다.

TakeDamage에서는 전에 구현한것과 똑같지만 구체가 전부 false일때만 데미지를 입어야하기 때문에
if (AbilityBall2 == false && AbilityBall1 == false && AbilityBall0 == false)
조건을 추가해줍니다.

Boss AI


Boss의 AI의 작동 순서는 아래와 같다
일단 Player가 시야와 있을때와 없을때의 작동은 이전 EnemyAI와 작동방식이 비슷하다.
하지만 시야에 있을때 Boss의 State에 따라 Boss의 행동 방식이 바뀌게된다.
Boss의 State는 ENum으로 관리된다

Player에 일정거리 이상 가까워지면 Holding State가 되어 Player 주변을 random하게 맴도는
wandering sequence가 실행된다.

그리고 Hoding State 가 일정시간 지속되어 타이머가 다하면 AttackState로 바뀌고 공격한다.

공격순서는
1. 현재위치에서 Shoot task를 실행해 사방으로 화염구체를 발사한다.
2. Player에 빠른속도로 돌진해 기본공격을함과 동시에 Player주변에 6개의 Boom을 생성한다.
3. 다시 플래이어에 돌진해 내려찍고 주변에 광역데미지를 준다.
4. State를 초기화하고 다시 Holding state로 돌아온다.

3개의 패턴이 전부 실행되면 상당히 불합리해 매우 피하기 어렵습다.
따라서 보스가 스폰되기전에 레벨업을해 Dash의 쿨타임을 줄여 피하고 Shield를 레벨업해 데미지를 막아내거나
빠르게 구체를 부셔 보스의 패턴을 줄이는게 중요합니다.

Enemy Spawn

몬스터가 spawn되는 구역은 총7개로 사각형의 맵에 각모서리 4개와 3개의 변 중간에서 생성됩니다.
한개의 변 중간은 Boss가 spawn되는 구역입니다.

Enemy Spawn

void ASurvivorGameMode::Spawn() {
	//iterate all spawn Enemy actor and call spawn function to spawn enemy
	//SpawnRate -= GameMinute / 6;
	

	for (AEnemySpawn* Spawner : TActorRange<AEnemySpawn>(GetWorld()))
	{
		int ran = FMath::FRandRange(0, 60);
		
		if (ran <= 30+(GameMinute+GameSecond*0.01)/2) {
			int charType = FMath::FRandRange(0, 10);
			if (charType < sqrt(GameMinute/3)) {
				int bossType = FMath::FRandRange(1, 4);
				Spawner->SpawnChar(bossType);
			}
			else {
				Spawner->SpawnChar(0);
			}
		}
	}
	SpawnRate *= 0.997;
	GetWorldTimerManager().SetTimer(SpawnHandle, this, &ASurvivorGameMode::Spawn, SpawnRate, false);
}

Enemy를 spawn하는 함수다. 밸런스에 굉장히 영향을 미치는 부분이라 테스트 플래이해보면서 자주 고친 파트이기도합니다.
방식은 이러합니다.

  1. 맵에있는 EnemySpawnner 를 for loop로 전부 순회하며
  2. 0-60까지 랜덤숫자를 하나 뽑는다.
  3. 뽑힌 숫자가 30+(GameMinute+GameSecond*0.01)/2 이하일경우 Spawnner에서 Enemy가 Spawn되며
  4. 다시 0-10의 랜덤 숫자를 하나뽑는다.
  5. 뽑힌 숫자가 sqrt(GameMinute/3)보다 작다면 1-4의 랜덤숫자를 하나뽑는다.
    이경우는 특별몹이 소환되는 경우이며 각숫자에 해당하는 특수 몹(Shoot, Boom, Blast)이있다.
  6. 뽑힌 숫자가 sqrt(GameMinute/3)보다 크다면 기본몹(LDEnemy)가 소환된다.
  7. 처음시작할때는 10초마다 이함수를 반복하며 SpawnRate *= 0.997 를 통해 Spawn되는 주기가
    계속 줄어들게 된다.

30+(GameMinute+GameSecond*0.01)/2 이걸통해 7개의 구역에서 Enemy가 Spawn될 확률은 점점 올라갑니다
sqrt(GameMinute/3)를 통해 확률적으로 특수몹또한 Spawn될 확률이 시간이 지남에 따라 점점올라가며
sqrt를 사용한 이유는 특수몹의 비율이 너무 높아지는것을 방지하기 위함입니다.
또한 시간이 지남에 따라 SpawnRate도 점점 감소해 난이도가 점점 높아집니다.

  • EnemySpawn.cpp(29)
void AEnemySpawn::SpawnChar(int type) {
	FVector Location = GetActorLocation();
	FRotator Rotation = GetActorRotation();
	if (type == 0) {
		GetWorld()->SpawnActor<ALDEnemy>(LDEnemyClass, Location, Rotation);
	}
	else if (type == 1) {
		GetWorld()->SpawnActor<ALDEnemy>(ShootEnemyClass, Location, Rotation);
	}
	else if (type == 2) {
		GetWorld()->SpawnActor<ALDEnemy>(BlastEnemyClass, Location, Rotation);
	}
	else {
		GetWorld()->SpawnActor<ALDEnemy>(BoomEnemyClass, Location, Rotation);
	}
}

위에 함수에서 받아온 type 숫자에따라 몹을 최종적으로 spawn하는 함수다.

  • 0 : 기본몹
  • 1 : ShootEnemy
  • 2 : BlastEnemy
  • 3 : BoomEnemy

Boss Spawn

void ASurvivorGameMode::SpawnBossRate() {
	for (ABossSpawner* Spawner : TActorRange<ABossSpawner>(GetWorld())) {
		Spawner->SpawnBoss();
		UE_LOG(LogTemp, Warning, TEXT("Boss Spawned"));
	}
	SpawnBossRateTime *= 0.8;
	GetWorldTimerManager().SetTimer(SpawnBossRateHandle, this, &ASurvivorGameMode::SpawnBossRate, SpawnBossRateTime, false);
}

Boss를 spawn하는 방식은 간단합니다.
처음으로는 8분에 spawn되며 다음에 spawn되는 타임은 0.8을 곱해 점점 주기가 짧아집니다.

후기

단순히 포트폴리오가 아닌 정말 재밌는 게임을 하나 만들어보고자 시작한 프로젝트였습니다. 하지만 일단 직접 플래이 해보니 왜 성공한 로그라이크 게임에는 탑다운 뷰나 횡스크롤 게임이 많은지 간접적으로 느낄수 있었습니다. 물론 제가 만든 게임이 많이 부족해서 그런 것 이겠지만 몹이 많아지고 시간이 지남에따라 너무 정신없어지는게 문제였습니다. 기본적으로 3인칭 시점이 앞을 보기때문에 탑뷰나 횡스크롤보다 시야가 매우 좁습니다. 그에따라 사방에서 적이 오는게 정신없게 느껴진게 컸습니다. 그부분을 최소화 하고자 Camer Arm을 길게 늘렸지만 역부족이였습다.

또 프로그램을 만들면서 개인적인 역량에도 부족함을 느꼈습니다.
제가 느낀 부족한점과 개선 방향은 다음과 같습니다.
클래스의 설계방식에 부족함이 많아 GameMode와 Character cpp 파일에 너무 많은 기능을 몰아서 코딩해 각 클래스이 독립성이 부족해 게임의 확장성이 부족합니다.
캐릭터의 모든 ability를 하나의 character 파일에 구현해 파일이 커지고 하나 하나의 ability들이 캐릭터에 종속되어 확장성이 부족합니다. 또한 character의 level과 같은 기능이 gamemode에 구현되어 character가 하나의 gamemode에 종속되어 다른 gamemode에서는 이캐릭터클래스를 재활용하기가 불가능 합니다. 또한 게임에 레벨업 카드를 관리하고 레벨을 올리는 부분이 하드코딩되어 확장성이 부족합니다. 이부분을 별개의 클래스로 추상화 하여 확장성을 높일수 있을거라 생각됩니다.

앞으로의 계획은 다음과 같습니다.
이러한 문제점을 해결하기위해 각 게임 프래임워크에 알맞는 구현방식과 설계방식을 공부하고 각 클래스의 의존성을 줄여 프로그램의 확장성을 높일 계획입니다.
또한 unreal의 멀티플래이의 구현 방식을 공부해 하나의 프로젝트에 두개의 게임으로 확장해 구현하려고 합니다. 하나는 위와같은 문제점을 해결하고 더욱 재미있게 업그레이드한 이 솔로 로그라이크 게임이 될것이고 하나는 로그라이크 게임을 기반으로한 멀티플래이 게임으로 pvp 컨텐츠를 추가할 것입니다.

profile
게임 개발 공부중!

0개의 댓글