12.18 - TIL

김혁·2025년 12월 18일

TIL

목록 보기
77/84

오늘의 코드카타

팀 프로젝트 진행 내용

1. 오버랩 시 플레이어에게 데미지를 가하는 투명 영역 구현

  • 팀원이 레벨에서 용암 같은 곳을 밟으면 데미지를 가하는 볼륨이 필요하다고 해서 플레이어에게 데미지를 가하는 액터를 제작했다.
  • BoxComponent와 오버랩 이벤트가 발생하면 DamageEffectClass를 가하는 방식으로 구현했다.
  • 또한 오버랩이 끝나면 해당 Effect를 해제해야 하기 때문에, 해당 EffectHandle을 맵으로 가지고 있다가 해제시키는 방향으로 구현했다.
// AO_DamageVolume.h

UCLASS()
class AO_API AAO_DamageVolume : public AActor
{
	GENERATED_BODY()

public:
	AAO_DamageVolume();

protected:
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Component")
	TObjectPtr<UBoxComponent> BoxComponent;

	UPROPERTY(EditAnywhere, Category = "GAS")
	TSubclassOf<UGameplayEffect> DamageEffectClass;

	UPROPERTY(EditAnywhere, Category = "Damage")
	float DamagePerSecond = 10.f;

	TMap<TWeakObjectPtr<AActor>, FActiveGameplayEffectHandle> ActiveHandles;
	
	UFUNCTION()
	void OnBeginOverlap(
		UPrimitiveComponent* OverlappedComp,
		AActor* OtherActor,
		UPrimitiveComponent* OtherComp,
		int32 OtherBodyIndex,
		bool bFromSweep,
		const FHitResult& SweepResult);

	UFUNCTION()
	void OnEndOverlap(
		UPrimitiveComponent* OverlappedComp,
		AActor* OtherActor,
		UPrimitiveComponent* OtherComp,
		int32 OtherBodyIndex);
};
  • 오버랩 이벤트가 발생하면 데미지 이펙트 클래스의 데미지 값을 Data.DamagePerSecond 태그로 SetByCaller를 통해 데미지를 설정해서 이펙트를 부여하게끔 구현했다.
  • 해당 데미지를 가하는 것은 서버에서만 실행되어야 하기 때문에 서버에서만 실행되게 제한했다.
// AO_DamageVolume.cpp

AAO_DamageVolume::AAO_DamageVolume()
{
	...
    
	BoxComponent->OnComponentBeginOverlap.AddDynamic(this, &AAO_DamageVolume::OnBeginOverlap);
	BoxComponent->OnComponentEndOverlap.AddDynamic(this, &AAO_DamageVolume::OnEndOverlap);
}

void AAO_DamageVolume::OnBeginOverlap(UPrimitiveComponent* OverlappedComp, AActor* OtherActor,
	UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	if (!HasAuthority())
	{
		return;
	}

	...
    
	FGameplayEffectContextHandle Context = ASC->MakeEffectContext();
	Context.AddSourceObject(this);

	FGameplayEffectSpecHandle SpecHandle = ASC->MakeOutgoingSpec(DamageEffectClass, 1.f, Context);
	checkf(SpecHandle.IsValid(), TEXT("Handle is invalid!"));

	SpecHandle.Data->SetSetByCallerMagnitude(FGameplayTag::RequestGameplayTag(FName("Data.DamagePerSecond")), DamagePerSecond);

	FActiveGameplayEffectHandle ActiveHandle = ASC->ApplyGameplayEffectSpecToSelf(*SpecHandle.Data.Get());
	ActiveHandles.Add(OtherActor, ActiveHandle);
}

void AAO_DamageVolume::OnEndOverlap(UPrimitiveComponent* OverlappedComp, AActor* OtherActor,
	UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
	...

	if (FActiveGameplayEffectHandle* Handle = ActiveHandles.Find(OtherActor))
	{
		ASC->RemoveActiveGameplayEffect(*Handle);
		ActiveHandles.Remove(OtherActor);
	}
}
  • GE_AreaDamage는 1초를 주기로 SetByCaller 값을 캐릭터의 체력에 가하게끔 아래와 같이 설정했다.
  • 지금 데미지는 정상적으로 캐릭터에게 들어가지만, 캐릭터의 리액션에 대한 구현은 하지 않았다.
  • 해당 과정은 애니메이션보다는 GameplayCue를 통해 화면에 데미지를 입고 있다는 이펙트를 보여주는 방식으로 추후에 구현할 예정이다.


2. 카메라 관리 매니저 구현

  • 파쿠르, 스프린트 동작 시에 카메라 줌아웃되는 것을 구현하려고 하는데, 스프링암의 값을 임의로 계속 변경하거나 하면 의존성과 복잡성이 너무 증가하는 것으로 보여서 카메라 관리 매니저를 별도로 두고자 했다.
  • 나는 GAS 기반으로 캐릭터의 동작들이 구분되기 때문에 GameplayTag를 활용하여 해당 Tag가 붙는 이벤트가 발생하면 카메라가 자동으로 변경되게끔 구현하고자 했다.

  • 먼저 카메라 세팅에 필요한 정보를 저장하는 구조체와 구조체를 저장한 배열을 가지고 있는 데이터 에셋을 구현했다.
  • 우선순위를 기반으로 디폴트 카메라 세팅을 0으로 두고, 태그가 겹쳐서 부여되는 상황을 고려해서 우선순위가 높은 카메라 세팅을 우선하도록 구현하고자 했다.
// AO_CameraProfile.h

USTRUCT(BlueprintType)
struct FAO_CameraSettings
{
	GENERATED_BODY()

	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	FGameplayTag Tag;

	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	int32 Priority = 0;

	// Spring Arm
	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	float TargetArmLength = 150.f;
	
	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	FVector SocketOffset = FVector(0.f, 50.f, 50.f);
	
	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	bool bEnableCameraLag = true;
	
	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	float CameraLagSpeed = 10.f;

	// Camera
	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	float FOV = 90.f;

	// Blend
	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	float BlendInTime = 0.25f;

	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	float BlendOutTime = 0.25f;
};

UCLASS()
class AO_API UAO_CameraProfile : public UDataAsset
{
	GENERATED_BODY()

public:
	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	TArray<FAO_CameraSettings> Profiles;

	const FAO_CameraSettings* FindByTag(const FGameplayTag& Tag) const;
};


// AO_CameraProfile.cpp

const FAO_CameraSettings* UAO_CameraProfile::FindByTag(const FGameplayTag& Tag) const
{
	for (const auto& P : Profiles)
	{
		if (P.Tag == Tag) return &P;
	}

	return nullptr;
}

  • 플레이어 컨트롤러에 부착할 카메라 매니저 컴포넌트를 아래와 같이 구현했다.
  • 카메라 전환 시에 블렌딩을 위한 정보를 가지는 구조체도 별도로 선언을 했다.
// AO_CameraManagerComponent.h

USTRUCT()
struct FAO_CameraRequest
{
	GENERATED_BODY()

	FGameplayTag Tag;
	int32 Priority = 0;
	float Weight = 0.f;
	float BlendIn = 0.25f;
	float BlendOut = 0.25f;
	bool bActive = false;
};

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class AO_API UAO_CameraManagerComponent : public UActorComponent
{
	GENERATED_BODY()

public:
	UAO_CameraManagerComponent();

protected:
	virtual void TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
	virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

public:
	UPROPERTY(EditAnywhere, Category = "Camera")
	TObjectPtr<UAO_CameraProfile> ProfileDB;

public:
	void BindCameraComponents(USpringArmComponent* InSpringArm, UCameraComponent* InCamera);

	void BindToASC(UAbilitySystemComponent* InASC);

	void PushCameraState(const FGameplayTag& CameraTag);
	void PopCameraState(const FGameplayTag& CameraTag);

private:
	void OnStateTagChanged(FGameplayTag Tag, int32 NewCount);

	void UpdateRequests(float DeltaTime);
	const FAO_CameraSettings* ChooseTargetProfile() const;
	void ApplyProfileBlended(const FAO_CameraSettings& Target, float DeltaTime);
	
private:
	UPROPERTY()
	TObjectPtr<USpringArmComponent> SpringArm;

	UPROPERTY()
	TObjectPtr<UCameraComponent> Camera;

	UPROPERTY()
	TObjectPtr<UAbilitySystemComponent> ASC;

	UPROPERTY()
	TMap<FGameplayTag, FAO_CameraRequest> Requests;

	FTimerHandle TimerHandle_Update;
	
	// 현재 적용 중인 세팅
	float CurArmLength = 150.f;
	FVector CurSocketOffset = FVector(0.f, 50.f, 50.f);
	float CurFOV = 90.f;
	bool bCurEnableLag = true;
	float CurLagSpeed = 10.f;

	// 캐릭터 상태 관련 태그
	FGameplayTag State_Dead;
	FGameplayTag State_Sprint;
	FGameplayTag State_Traversal;
	
	// 카메라 프로필 태그
	FGameplayTag Camera_Default;
	FGameplayTag Camera_Dead;
	FGameplayTag Camera_Sprint;
	FGameplayTag Camera_Traversal;

	// 델리게이트 핸들
	TArray<FDelegateHandle> TagEventHandles;
};
  • 먼저 생성자에서 캐릭터의 상태 관련 태그와 카메라 프로필 태그를 지정했다. 추후에 수정할지는 모르겠지만, 카메라 전환 시에 블렌딩 과정이 필수여서 Tick을 통해 해당 과정을 처리하고자 했다.
// AO_CameraManagerComponent.cpp

UAO_CameraManagerComponent::UAO_CameraManagerComponent()
{
	PrimaryComponentTick.bCanEverTick = true;

	State_Dead		= FGameplayTag::RequestGameplayTag(FName("Status.Death"));
	State_Sprint	= FGameplayTag::RequestGameplayTag(FName("Status.Action.Sprint"));
	State_Traversal = FGameplayTag::RequestGameplayTag(FName("Status.Action.Traversal"));
	
	Camera_Default   = FGameplayTag::RequestGameplayTag(FName("Camera.Default"));
	Camera_Dead      = FGameplayTag::RequestGameplayTag(FName("Camera.Dead"));
	Camera_Sprint    = FGameplayTag::RequestGameplayTag(FName("Camera.Sprint"));
	Camera_Traversal = FGameplayTag::RequestGameplayTag(FName("Camera.Traversal"));
}
  • Charcter에 있는 SpringArm과 Camera 컴포넌트를 바인딩하는 함수와 ASC를 연결하고 태그 이벤트로 상태가 변경되었을 때의 처리를 바인딩하는 함수를 별도로 구현했다.
// AO_CameraManagerComponent.cpp

void UAO_CameraManagerComponent::BindCameraComponents(USpringArmComponent* InSpringArm, UCameraComponent* InCamera)
{
	SpringArm = InSpringArm;
	Camera = InCamera;

	if (SpringArm)
	{
		CurArmLength = SpringArm->TargetArmLength;
		CurSocketOffset = SpringArm->SocketOffset;
		bCurEnableLag = SpringArm->bEnableCameraLag;
		CurLagSpeed = SpringArm->CameraLagSpeed;
	}

	if (Camera)
	{
		CurFOV = Camera->FieldOfView;
	}
}

void UAO_CameraManagerComponent::BindToASC(UAbilitySystemComponent* InASC)
{
	ASC = InASC;
	TagEventHandles.Reset();

	checkf(ASC, TEXT("ASC is null"));

	const int32 DeadTagCount      = ASC->GetTagCount(State_Dead);
	const int32 SprintTagCount    = ASC->GetTagCount(State_Sprint);
	const int32 TraversalTagCount = ASC->GetTagCount(State_Traversal);

	OnStateTagChanged(State_Dead, DeadTagCount);
	OnStateTagChanged(State_Sprint, SprintTagCount);
	OnStateTagChanged(State_Traversal, TraversalTagCount);

	TagEventHandles.Add(
		ASC->RegisterGameplayTagEvent(State_Dead, EGameplayTagEventType::NewOrRemoved)
		.AddUObject(this, &UAO_CameraManagerComponent::OnStateTagChanged));
	TagEventHandles.Add(
		ASC->RegisterGameplayTagEvent(State_Sprint, EGameplayTagEventType::NewOrRemoved)
		.AddUObject(this, &UAO_CameraManagerComponent::OnStateTagChanged));
	TagEventHandles.Add(
		ASC->RegisterGameplayTagEvent(State_Traversal, EGameplayTagEventType::NewOrRemoved)
		.AddUObject(this, &UAO_CameraManagerComponent::OnStateTagChanged));
}
  • 위에서 바인딩한 태그가 추가되거나 제거되었을 때 발생할 함수로, 상태 태그에 따라 관련된 카메라 프로필이 추가되거나 삭제되는 구조로 구현했다. 추후에 카메라 관련 상태가 많아질 경우에는 Map을 만들어서 관리하는 것도 좋은 방법으로 보인다.
// AO_CameraManagerComponent.cpp

void UAO_CameraManagerComponent::OnStateTagChanged(FGameplayTag Tag, int32 NewCount)
{
	const bool bOn = (NewCount > 0);

	if (Tag == State_Dead)
	{
		if (bOn)
		{
			PushCameraState(Camera_Dead);
		}
		else
		{
			PopCameraState(Camera_Dead);
		}
	}
	else if (Tag == State_Sprint)
	{
		if (bOn)
		{
			PushCameraState(Camera_Sprint);
		}
		else
		{
			PopCameraState(Camera_Sprint);
		}
	}
	else if (Tag == State_Traversal)
	{
		if (bOn)
		{
			PushCameraState(Camera_Traversal);
		}
		else
		{
			PopCameraState(Camera_Traversal);
		}
	}
}
  • 데이터 에셋을 통해 설정한 카메라 프로필에서 관련 태그를 찾아서 블렌딩을 위한 값 정보로 추가하고 제거하는 함수이다. bActive 변수를 통해 해당 요청을 BlendIn할지, BlendOut할지 구분하는 역할을 하게 된다.
// AO_CameraManagerComponent.cpp

void UAO_CameraManagerComponent::PushCameraState(const FGameplayTag& CameraTag)
{
	checkf(ProfileDB, TEXT("ProfileDB is null"));

	const FAO_CameraSettings* Settings = ProfileDB->FindByTag(CameraTag);
	checkf(Settings, TEXT("Failed to find CameraSettings by CameraTag"));

	FAO_CameraRequest& Request = Requests.FindOrAdd(CameraTag);
	Request.Tag = CameraTag;
	Request.Priority = Settings->Priority;
	Request.BlendIn = FMath::Max(0.01f, Settings->BlendInTime);
	Request.BlendOut = FMath::Max(0.01f, Settings->BlendOutTime);
	Request.bActive = true;
}

void UAO_CameraManagerComponent::PopCameraState(const FGameplayTag& CameraTag)
{
	if (FAO_CameraRequest* Request = Requests.Find(CameraTag))
	{
		Request->bActive = false;
	}
}
  • 다음으로 Tick 과정을 보면 먼저 (1) 요청 상태들의 가중치(Weight)를 시간에 따라 갱신하고, (2) 그 중에서 이번 프레임에 적용할 카메라 프로필을 선택한 다음, (3) 현재 카메라 값을 목표 프로필 쪽으로 보간하면서 이동하는 구조로 작동한다.
// AO_CameraManagerComponent.cpp

void UAO_CameraManagerComponent::TickComponent(float DeltaTime, enum ELevelTick TickType,
	FActorComponentTickFunction* ThisTickFunction)
{
	Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
	
	if (!Camera || !SpringArm)
	{
		return;
	}
	
    // 1. 요청 상태들의 가중치를 시간에 따라 갱신
	UpdateRequests(DeltaTime);

	// 2. 이번 프레임에 적용할 카메라 프로필 선택
	const FAO_CameraSettings* Target = ChooseTargetProfile();
	if (!Target)
	{
		if (const FAO_CameraSettings* Default = ProfileDB->FindByTag(Camera_Default))
		{
			ApplyProfileBlended(*Default, DeltaTime);
		}
		return;
	}
	
    // 3. 현재 카메라 값을 목표 프로필 쪽으로 보간하면서 이동
	ApplyProfileBlended(*Target, DeltaTime);
}
  1. UpdateRequests()
    • 카메라 상태 요청들의 Weight를 업데이트하고, 필요 없는 요청은 정리함
    • bActive == true -> Weight를 0 -> 1로 올림 (Blend In 속도로)
    • bActive == false -> Weight를 1 -> 0으로 내림 (Blend Out 속도로)
    • Weight가 0까지 떨어지면 Map에서 제거
  2. ChooseTargetProfile()
    • 현재 Requests 중에서 이번에 적용할 카메라 프로필을 하나 선택함
    • Weight > 0인 후보 중에서 Priority가 가장 높은 프로필을 선택하게 됨
  3. ApplyProfileBlend()
    • 목표 프로필을 향해 현재 카메라 상태를 부드럽게 이동시킴
    • 보간하는 방식은 현재 InterpTo()를 사용해서 보간하고 있음. 추후에 변경 가능
// AO_CameraManagerComponent.cpp

void UAO_CameraManagerComponent::UpdateRequests(float DeltaTime)
{
	TArray<FGameplayTag> ToRemove;

	for (auto& It : Requests)
	{
		FAO_CameraRequest& CurRequest = It.Value;

		const float Speed = CurRequest.bActive ? (1.f / CurRequest.BlendIn) : (1.f / CurRequest.BlendOut);
		const float Dir = CurRequest.bActive ? 1.f : -1.f;
		CurRequest.Weight = FMath::Clamp(CurRequest.Weight + Dir * Speed * DeltaTime, 0.f, 1.f);

		if (!CurRequest.bActive && CurRequest.Weight <= 0.f)
		{
			ToRemove.Add(It.Key);
		}
	}

	for (const auto& Tag : ToRemove)
	{
		Requests.Remove(Tag);
	}
}

const FAO_CameraSettings* UAO_CameraManagerComponent::ChooseTargetProfile() const
{
	const FAO_CameraSettings* Best = nullptr;
	int32 BestPriority = -1;
	float BestWeight = 0.f;

	for (const auto& It : Requests)
	{
		const FAO_CameraRequest& Request = It.Value;
		if (Request.Weight <= 0.f)
		{
			continue;
		}

		const FAO_CameraSettings* Profile = ProfileDB->FindByTag(Request.Tag);
		if (!Profile)
		{
			continue;
		}

		if (Profile->Priority > BestPriority
			|| (Profile->Priority == BestPriority && Request.Weight > BestWeight))
		{
			BestPriority = Profile->Priority;
			BestWeight = Request.Weight;
			Best = Profile;
		}
	}

	return Best;
}

void UAO_CameraManagerComponent::ApplyProfileBlended(const FAO_CameraSettings& Target, float DeltaTime)
{
	const float InterpSpeed = 12.f;

	CurArmLength = FMath::FInterpTo(CurArmLength, Target.TargetArmLength, DeltaTime, InterpSpeed);
	CurSocketOffset = FMath::VInterpTo(CurSocketOffset, Target.SocketOffset, DeltaTime, InterpSpeed);
	CurFOV = FMath::FInterpTo(CurFOV, Target.FOV, DeltaTime, InterpSpeed);

	bCurEnableLag = Target.bEnableCameraLag;
	CurLagSpeed = Target.CameraLagSpeed;

	SpringArm->TargetArmLength = CurArmLength;
	SpringArm->SocketOffset = CurSocketOffset;
	SpringArm->bEnableCameraLag = bCurEnableLag;
	SpringArm->CameraLagSpeed = CurLagSpeed;

	Camera->SetFieldOfView(CurFOV);
}
  • 컴포넌트를 부착하고 초기화를 플레이어 컨트롤러에서 진행했다.
  • 초기화 함수에서는 카메라 컴포넌트와 ASC를 바인딩하고, Default 카메라를 초기에 Push했다.
  • 초기화 시점은 리슨 서버를 기준으로 서버에서는 OnPossess() 함수에서 진행했고, 클라에서는 OnRep_Pawn() 함수에서 진행했다.
// AO_PlayerController_InGameBase

void AAO_PlayerController_InGameBase::OnPossess(APawn* InPawn)
{
	Super::OnPossess(InPawn);

	if (!IsLocalController())
	{
		return;
	}

	InitCameraManager();
}

void AAO_PlayerController_InGameBase::OnRep_Pawn()
{
	Super::OnRep_Pawn();

	if (!IsLocalController())
	{
		return;
	}

	InitCameraManager();
}

void AAO_PlayerController_InGameBase::InitCameraManager()
{
	checkf(CameraManagerComponent, TEXT("CameraManagerComponent not found"));
	
	AAO_PlayerCharacter* PlayerCharacter = Cast<AAO_PlayerCharacter>(GetPawn());
	checkf(PlayerCharacter, TEXT("Character not found"));

	CameraManagerComponent->BindCameraComponents(PlayerCharacter->GetSpringArm(), PlayerCharacter->GetCamera());
	CameraManagerComponent->PushCameraState(FGameplayTag::RequestGameplayTag(FName("Camera.Default")));

	if (UAbilitySystemComponent* ASC = PlayerCharacter->GetAbilitySystemComponent())
	{
		CameraManagerComponent->BindToASC(ASC);
	}
}
  • 프로필 데이터 에셋을 아래와 같이 생성을 했다.
  • 너무 많은 이동이 있는 경우, 다소 어지러움이 생길 수도 있을 것으로 보인다. 또한 디폴트가 캐릭터와 카메라 거리가 상당히 짧기 때문에 많이 움직이면 휙휙 움직이는 느낌을 받았다.
  • 따라서 스프린트, 파쿠르 시에 캐릭터의 중간이 아닌 살짝 중간으로 가는 느낌만 받게끔 값을 설정했다.


결과

profile
게임 개발자를 향해..

0개의 댓글