데미지 시스템 3 - 공격 부분

공격 구조

Untitled

위 이미지는 데미지 시스템의 공격 부분을 대략적으로 나타낸 것이다. 인터페이스는 표시하지 않았다. 공격 시작부터 종료까지의 과정은 다음과 같다.

  1. 플레이어의 마우스 입력을 통해 캐릭터의 공격 명령이 최초로 호출된다.
  2. 플레이어는 스탯 컴포넌트에서 현재 상태를 파악하고, 공격이 가능한 상태라면 공격 컴포넌트에 공격 명령을 전달한다.
  3. 공격 컴포넌트는 무기와의 통신, 콤보 제어, 몽타주 재생을 담당한다
    1. 무기에게 데미지 연산에 필요한 오너 스탯 정보를 제공하고, 이번 콤보에 사용할 몽타주를 받아온다.
    2. 받아온 몽타주를 재생한다. 몽타주 종료 시 인터페이스를를 통해 이를 오너에게 전달한다.
    3. 콤보 카운트를 증가시킨다. 최대 콤보 수는 무기에 정의되어있다.
  4. 몽타주에는 HitDetection AnimNofityState와 AttackEnd AnimNotify가 있다.
    1. HitDetection AnimNofityState는 몽타주 재생 대상이 소유한 무기에 접근하여, 지정 기간 동안 무기의 HitDetection을 호출한다.
    2. 무기는 미리 설정해둔 콜라이더 모양을 사용해 HitDetection 함수에서 충돌 탐지를 진행한다. 만약 이번 공격 동작에서 해당 물체와 최초로 충돌했다면, 해당 물체에게 데미지를 전달한다.
    3. 몽타주의 지정 시점에 AttackEnd AnimNotify가 호출된다. 이는 AttackEnd 인터페이스를 통해 오너에게 공격이 종료되었음을 알린다.
  5. 공격 종료 시, 캐릭터는 상태를 변경하고 공격 컴포넌트는 콤보 리셋 타이머를 작동시킨다.
    1. 공격 종료 판정은 2회 진행된다. 외부 개입 없이 몽타주가 재생되어 AttackEnd AnimNotify가 호출되었을 때 먼저 공격 종료 처리를 진행한다. 공격 도중 피격당한다면 해당 노티파이 실행 이전에 몽타주가 종료된다. 이런 경우를 대비하여 몽타주 종료 시 인터페이스를 통해 오너의 공격 종료 함수를 호출한다.

무기 클래스

어떤 클래스인가?

데미지 시스템을 사용하기 위해서는 무기 클래스에 대해 알 필요가 있다. 무기 클래스는 공격에 필요한 데이터와 기능들을 소유한다.

  • 데이터 → 무기 데미지 스탯, 공격 몽타주, 공격 이펙트, 공격 사운드, 스태틱 메쉬, 콜라이더 등
  • 기능 → 충돌 감지, 데미지 연산, 데미지 전달

캐릭터는 무기를 직접 사용하거나 소유하지 않고, 장착된 컴포넌트를 통해 제어한다.

EquipComponent는 무기 인스턴스를 소유하고 관리하는 컴포넌트이다. 캐릭터에 부착된 다른 컴포넌트나 외부 인터페이스는 캐릭터가 상속받은 IIVWeaponProvider 인터페이스의 GetWeapon()을 통해 장착중인 무기에 접근할 수 있다.

AttackComponent는 무기 인스턴스가 아닌 포인터를 가진다. EquipComponent의 무기가 변경될 때 마다, 해당 포인터를 사용해 정보를 갱신한다.

AttackComponent-WeaponInterface-Weapon

	// IIVWeaponInterface.h
	/* 무기 소유자 전달용 */
	virtual void SetOwnerController(AController* NewOwnerController) = 0;
	virtual void SetOwnerDamageStat(FBaseDamageStat NewDamageStat) = 0;

	virtual int32 GetMaxComboCount() const = 0;

	/* 이번 콤보 카운트에 사용할 몽타주 획득용 */
	virtual UAnimMontage* GetComboMontage(int32 ComboIndex) const = 0;

무기와 공격 컴포넌트는 WeaponInterface를 통해 데이터를 전달한다. 공격 컴포넌트는 캐릭터로부터 공격 명령을 전달받았을 때, 무기에게 오너 컨트롤러와 오너의 데미지 스탯을 전달한다. 이들은 충돌 감지 및 데미지 연산에 필요하다. 반대로 무기는 공격 컴포넌트에게 이번 콤보에 사용할 몽타주와 최대 몽타주 수를 전달한다. 공격컴포넌트는 그저 전달하고, 재생할 뿐이다.

무기 충돌 검사

Untitled

무기는 콤보에 필요한 여러 몽타주를 가지고 있고, 각 몽타주에는 HitDetection AnimNotifyStateAttackEnd AnimNotify가 지정되어있다.

// IVWeaponHitDetection.cpp
void UIVWeaponHitDetection::NotifyBegin(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, float TotalDuration)
{
	// 노티파이 시작 시 몽타주 오너가 소유한 무기를 가져온다
	if (IIIVWeaponProvider* WeaponProvider = Cast<IIIVWeaponProvider>(MeshComp->GetOwner()))
	{
		Weapon = WeaponProvider->GetWeapon().Get();
	}
}

void UIVWeaponHitDetection::NotifyTick(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, float FrameDeltaTime)
{
	// 노티파이 틱마다 무기의 충돌 검사 및 데미지 전달을 수행한다
	if (Weapon)
	{
		Weapon->HitDetection();
	}
}

void UIVWeaponHitDetection::NotifyEnd(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation)
{
	// 노티파이 종료 시 무기를 초기화한다
	if (Weapon)
	{
		Weapon->ClearHitActors();
		Weapon = nullptr;
	}
}

HitDetection 노티파이 스테이트는 공격 시 무기 충돌검사 진행구간을 지정한다. 메쉬 오너인 캐릭터로부터 무기 포인터를 가져오고, 매 틱마다 해당 무기에게 충돌 검사를 지시하는 방식이다.

// IVWeapon.cpp
void AIVWeapon::HitDetection()
{
	// 콜라이더의 위치와 방향을 기준으로 Trace
	FVector Start = HitCollider->GetComponentLocation();
	FQuat Rotation = HitCollider->GetComponentQuat();
	FCollisionShape CollisionShape = HitCollider->GetCollisionShape();

	// Trace를 통해 충돌한 액터들을 저장
	TArray<FHitResult> HitResults;
	FCollisionQueryParams CollisionParams;
	CollisionParams.AddIgnoredActor(this);

	// Trace 진행
	if (GetWorld()->SweepMultiByObjectType(
		HitResults,
		Start,
		Start,
		Rotation,
		FCollisionObjectQueryParams::AllDynamicObjects,
		CollisionShape))
	{
		// 이번 Trace에서 충돌한 액터들에게 데미지 적용
		for (const FHitResult& HitResult : HitResults)
		{
			if (!HitActors.Contains(HitResult.GetActor()) && HitResult.GetActor() != Owner)
			{
				UGameplayStatics::ApplyPointDamage(
					HitResult.GetActor(),
					TotalDamage,
					HitResult.Normal,
					HitResult,
					OwnerController,
					this,
					UDamageType::StaticClass());
				HitActors.Add(HitResult.GetActor());
			}
		}
	}
}

무기는 캡슐 콜라이더를 가지고 있지만, 해당 콜라이더를 충돌 검사에 직접 사용하지는 않는다. 해당 콜라이더의 위치와 모양을 기반으로 임시 충돌 판단을 진행한다.

한 번의 공격은 여러 프레임에 걸쳐 진행되므로 이전에 충돌했던 물체가 다음 틱에 다시 충돌할 수도 있다. 무기는 이번 공격에서 충돌했던 액터 목록인 TArray<TObjectPtr<AActor>> HitActors를 사용하여 해당 물체와 최초 충돌할 때만 데미지를 입힌다.

공격 컴포넌트

어떤 역할을 수행하는가?

공격 컴포넌트는 주로 공격 몽타주 재생과 콤보 관리를 수행한다. 실질적인 데미지 전달은 무기가, 공격 입력은 캐릭터가, 무기 보유는 EquipComponent가 담당한다.

// IVAttackComponent.cpp
void UIVAttackComponent::Attack(FBaseDamageStat DamageStat)
{
	if (WeaponInstance)
	{
		IIIVWeaponInterface* WeaponInterface = Cast<IIIVWeaponInterface>(WeaponInstance);
		if (WeaponInterface)
		{
			// 무기에 데미지 계산을 위한 캐릭터 데미지 스탯 전달
			WeaponInterface->SetOwnerDamageStat(DamageStat);

			// 콤보 몽타주 재생
			UAnimMontage* ComboMontage = WeaponInterface->GetComboMontage(ComboCount);
			if (ComboMontage && AnimInstance)
			{
				AnimInstance->Montage_Play(ComboMontage); 
				
				// 공격 도중 피격	상태로 전환되는 경우를 위한 대리자
				FOnMontageBlendingOutStarted BlendingOutDelegate;
				BlendingOutDelegate.BindLambda([this](UAnimMontage* Montage, bool bInterrupted)
					{
						IIIVAttackEndInterface* AttackEndInterface = Cast<IIIVAttackEndInterface>(GetOwner());
						if (AttackEndInterface)
						{
							AttackEndInterface->AttackEnd(false); // 2차 검사이므로 False 전달
						}
					});
				AnimInstance->Montage_SetBlendingOutDelegate(BlendingOutDelegate, ComboMontage);
			}
			ComboCount = (ComboCount < MaxComboCount - 1) ? ComboCount + 1 : 0; // 콤보 카운트 증가
		}
	}
	GetWorld()->GetTimerManager().ClearTimer(ComboTimer); // 콤보 타이머 초기화
}

캐릭터로부터 공격 명령을 전달받으면 WeaponInterface를 통해 무기에서 현재 콤보에 해당하는 몽타주를 가져와 재생한다.

콤보 공격

콤보 공격은 마지막 공격이 종료된 이후 일정 시간 내에 다시 공격을 입력하면, 다른 동작으로 연계되는 공격을 의미한다. 각 무기에는 지정된 콤보 몽타주가 있으며, 몽타주의 갯수가 곧 해당 무기의 최대 콤보 수가 된다. 공격 컴포넌트는 콤보 카운트와 콤보 리셋 타이머를 통해 이를 관리한다.

Untitled

공격 몽타주가 정상적으로 재생된다면 미리 설정해둔 AttackEnd AnimNotify가 발동된다. 이를 통해 인터페이스를 사용하여 캐릭터의 공격 종료 함수를 호출하고, 공격 컴포넌트의 콤보 타이머를 작동시키며 다음 입력을 대기한다.

그러나 공격 도중 피격이 발생하면 상황이 달라진다. 피격 동작은 다른 동작들보다 우선순위가 높기에, 진행 중인 공격 몽타주를 중단하고 피격 몽타주를 재생한다. 이로인해 Attacked AnimNotify가 발동되지 않지만, 몽타주 종료 대리자를 통해 동일한 기능을 수행하도록했다.

최종 작업 결과물

https://youtu.be/iEMFwKxTIik?si=nx9NO4KYITMqWdL6

작업에 비해 뭔가 한게 없어보여서 아쉽다.