일정표
| 시간 | 할 일 | 비고 |
| 08:30~10:00 | 코드카타, 디자인 패턴 케이스 스터디 | |
| 10:00~13:00 | 팀프로젝트 | |
| 14:00~18:00 | 2시 스크럼, 팀프로젝트 | |
| 19:00~21:00 | 7시 스크럼, 팀프로젝트, TIL | |
| 21:00~22:00 | 운동 | |
| 22:00~23:30 | 개인 프로젝트 |
*오늘의 코드카타*
문제. 명예의 전당(1)
"명예의 전당"이라는 TV 프로그램에서는 매일 1명의 가수가 노래를 부르고, 시청자들의 문자 투표수로 가수에게 점수를 부여합니다. 매일 출연한 가수의 점수가 지금까지 출연 가수들의 점수 중 상위 k번째 이내이면 해당 가수의 점수를 명예의 전당이라는 목록에 올려 기념합니다. 즉 프로그램 시작 이후 초기에 k일까지는 모든 출연 가수의 점수가 명예의 전당에 오르게 됩니다. k일 다음부터는 출연 가수의 점수가 기존의 명예의 전당 목록의 k번째 순위의 가수 점수보다 더 높으면, 출연 가수의 점수가 명예의 전당에 오르게 되고 기존의 k번째 순위의 점수는 명예의 전당에서 내려오게 됩니다
이 프로그램에서는 매일 "명예의 전당"의 최하위 점수를 발표합니다. 예를 들어, k = 3이고, 7일 동안 진행된 가수의 점수가 [10, 100, 20, 150, 1, 100, 200]이라면, 명예의 전당에서 발표된 점수는 아래의 그림과 같이 [10, 10, 10, 20, 20, 100, 100]입니다
명예의 전당 목록의 점수의 개수 k, 1일부터 마지막 날까지 출연한 가수들의 점수인 score가 주어졌을 때, 매일 발표된 명예의 전당의 최하위 점수를 return하는 solution 함수를 완성해주세요
#include <vector>
#include <algorithm>
using namespace std;
vector<int> solution(int k, vector<int> score) {
vector<int> answer;
answer.reserve(score.size());
vector<int> honor;
for (int s : score)
{
honor.emplace_back(s);
sort(honor.begin(), honor.end(), greater<int>());
if (honor.size() > k)
{
honor.pop_back();
}
answer.emplace_back(honor.back());
}
return answer;
}
(1) 상위의 값 k개는 유지하면서, 그 중 가장 작은 값을 찾아서 제거하는 문제. 우선 일반 벡터를 사용해서 문제를 풀어보았다.
다만 매 루프(for문)마다 sort를 수행함므로 성능이 매우 떨어질 위험이 있다.
#include <vector>
#include <queue>
#include <functional>
using namespace std;
vector<int> solution(int k, vector<int> score)
{
vector<int> answer;
answer.reserve(score.size());
priority_queue<int, vector<int>, greater<int>> pq;
for (int s : score)
{
pq.push(s);
if (pq.size() > k)
{
pq.pop();
}
answer.emplace_back(pq.top());
}
return answer;
}
(2) priority_queue<int, vector<int>, greater<int>> pq; 를 사용하는 방법.
기본 priority_queue는 숫자가 큰 순서대로 정렬되는 '최대 힙'이지만, greater<int>를 세번째 인자에 적으면 숫자가 가장 작은 것이 맨 위로 올라오는 '최소 힙' 이 된다. 즉, 최하위 점수를 0(1)의 속도로 즉시 조회할 수 있다. 벡터를 이용한 경우 O(N) 이지만, 우선순위 큐를 사용하면 O(N log k)로 시간 복잡도가 줄어들게 된다. 따라서 대용량 데이터에서 빠르게 동작할 수 있다는 장점이 있다.
*언리얼 C++ 디자인 패턴*
첫번째 케이스 스터디.
대규모 RPG를 위한 능동형 글로벌 스킬 & 효과 시스템을 구축해보자.
1. 아키텍처
1. 패턴의 역할 정의
(예시)
[UDataAsset (기획자 설계서)]
│ (팩토리 패턴: 생성 및 주입)
▼
[URPGEffectInstance (육체/데코레이터)] ──► [URPGEffectStrategy (두뇌/스트래티지)]
│
│ (옵저버 패턴: 전역 방송)
▼
[URPGCombatComponent (서버 복제 및 UI 업데이트)]
1) 팩토리 패턴 : 기획자가 에디터에서 세팅한 스킬 데이터 자산(UDataAsset)을 기반으로, 런타임에 알맞은 효과 인스턴스(UObject)를 동적으로 찍어내고 생명 주기를 부여한다.
2) 스트래티지 패턴 : "데미지 계산", "도트 힐", "스탯 변경" 등 구체적인 효과의 계산 로직을 독립된 클래스로 분리하여, 효과 인스턴스가 런타임에 자신의 계산법을 마음대로 갈아끼울 수 있게 한다.
3) 데코레이터 패턴 : 동일한 버프가 여러 개 중첩되거나, 특정 효과가 다른 효과의 수치를 동적으로 감싸고 수정(Wrapping)할 수 있는 중첩 버프 컨테이너 구조를 형성한다.
4) 옵저버 패턴 : 효과가 적용되거나 체력이 변경되는 순간, 언리얼 델리게이트를 통해 사운드, 파티클, 로컬 UI 위젯 등에 전역적으로 상태 변화를 방송한다.
2. 언리얼 C++ 적용
'서버 주도(Server-Authoritative) 계산과 UPROPERTY를 통한 GC 추적'을 기본 전제로 코드를 설계해보자.
1) 먼저 스트래티지 레이어에서는 효과 계산 규약을 정의한다. 에디터에서 직접 인스턴스를 생성할 수 있도록, EditInlineNew 매크로를 활용한다.
(예시 : RPGEffectStrategy.h, cpp)
#pragma once
#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "RPGEffectStrategy.generated.h"
// 앞서 배운 오브젝트 가상화 및 데이터 주도 설계를 위해 에디터 노출 세팅 적용
UCLASS(Abstract, BlueprintType, EditInlineNew, DefaultToInstanced)
class UNREALDESIGNPATTERN_API URPGEffectStrategy : public UObject
{
GENERATED_BODY()
public:
// 이 전략을 실행할 때 호출할 순수 수학 계산 함수
// Instigator: 가해자, Target: 피해자, Magnitude: 효과의 기본 수치(기획자 입력값)
UFUNCTION(BlueprintCallable, Category = "RPG|Strategy")
virtual void ExecuteEffect(AActor* Instigator, AActor* Target, float Magnitude, float DeltaTime) const PURE_VIRTUAL(URPGEffectStrategy::ExecuteEffect, );
};
/** 1. 구체적 전략 A: 즉시 데미지 계산 */
UCLASS(EditInlineNew, meta = (DisplayName = "Strategy: Instant Damage"))
class UNREALDESIGNPATTERN_API URPGDamageStrategy : public URPGEffectStrategy
{
GENERATED_BODY()
public:
virtual void ExecuteEffect(AActor* Instigator, AActor* Target, float Magnitude, float DeltaTime) const override;
};
/** 2. 구체적 전략 B: 시간에 따른 도트 힐(HoT) 계산 */
UCLASS(EditInlineNew, meta = (DisplayName = "Strategy: Heal Over Time"))
class UNREALDESIGNPATTERN_API URPGHealOverTimeStrategy : public URPGEffectStrategy
{
GENERATED_BODY()
public:
virtual void ExecuteEffect(AActor* Instigator, AActor* Target, float Magnitude, float DeltaTime) const override;
};
#include "RPGEffectStrategy.h"
#include "GameFramework/Actor.h"
void URPGDamageStrategy::ExecuteEffect(AActor* Instigator, AActor* Target, float Magnitude, float DeltaTime) const
{
if (!Target) return;
// 실제 게임에서는 여기에 방어력 공식을 적용한 복잡한 수학 연산이 들어감.
// 데미지 적용을 위해 언리얼의 내장 데미지 시스템 시스템 호출
Target->TakeDamage(Magnitude, FDamageEvent(), Instigator ? Instigator->GetInstigatorController() : nullptr, Instigator);
UE_LOG(LogTemp, Log, TEXT("[Strategy] %s 가 %s 에게 %f 의 데미지를 입혔습니다."), *GetNameSafe(Instigator), *GetNameSafe(Target), Magnitude);
}
void URPGHealOverTimeStrategy::ExecuteEffect(AActor* Instigator, AActor* Target, float Magnitude, float DeltaTime) const
{
// 틱(Tick)당 회복량 계산 (시간 변화량 반영)
float HealAmount = Magnitude * DeltaTime;
// Target의 컴포넌트를 찾아가 체력을 회복시키는 로직을 수행.
UE_LOG(LogTemp, Log, TEXT("[Strategy] %s 가 도트 힐 수혜 중: +%f HP (DeltaTime: %f)"), *GetNameSafe(Target), HealAmount, DeltaTime);
}
2) 데코레이터 레이어에서는 중첩 버프 및 런타임 인스턴스를 설계한다. 기획자가 설계한 데이터 UDataAsset을 복사하여 런타임에 실존하는 효과 객체를 만든다. 데코레이터 패턴의 핵심인 "기존 효과 위에 버프를 중첩하여 감싸는 구조"와 시간 기반 틱 연산을 처리하게 된다.
(예시 : RPGEffectInstance.h, cpp)
#pragma once
#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "RPGEffectStrategy.h"
#include "RPGEffectInstance.generated.h"
UCLASS(BlueprintType)
class UNREALDESIGNPATTERN_API URPGEffectInstance : public UObject
{
GENERATED_BODY()
public:
// 팩토리에서 인스턴스를 초기화할 때 사용할 함수
void InitializeEffect(URPGEffectStrategy* InStrategy, float InMagnitude, float InDuration)
{
CalculationStrategy = InStrategy;
BaseMagnitude = InMagnitude;
Duration = InDuration;
TimeRemaining = InDuration;
}
// 매 프레임 효과를 업데이트하는 함수 (데코레이터 체인 및 틱 처리)
void UpdateEffect(AActor* Instigator, AActor* Target, float DeltaTime);
bool IsExpired() const { return bIsPeriodic && TimeRemaining <= 0.0f; }
// [데코레이터 패턴의 핵심] 타 버프에 의해 변조될 수 있는 최종 수치 반환
float GetModifiedMagnitude() const;
// 외부에서 다른 데코레이터 버프를 덧씌울 때 사용하는 포인터 연결 (마트료시카 구조)
UPROPERTY()
URPGEffectInstance* NextModifierEffect;
private:
// 스트래티지 패턴: 이 효과가 실행할 구체적인 연산 두뇌
UPROPERTY()
URPGEffectStrategy* CalculationStrategy;
UPROPERTY()
float BaseMagnitude;
UPROPERTY()
float Duration;
UPROPERTY()
float TimeRemaining;
UPROPERTY()
bool bIsPeriodic = true;
};
#include "RPGEffectInstance.h"
void URPGEffectInstance::UpdateEffect(AActor* Instigator, AActor* Target, float DeltaTime)
{
if (CalculationStrategy)
{
// 데코레이터 패턴을 통해 계산된 변조된 최종 수치를 스트래티지에 전달하여 실행
float FinalMagnitude = GetModifiedMagnitude();
CalculationStrategy->ExecuteEffect(Instigator, Target, FinalMagnitude, DeltaTime);
}
if (Duration > 0.0f)
{
TimeRemaining -= DeltaTime;
}
// 내 뒤에 연결된 다른 장식자(버프)가 있다면 꼬리를 물며 함께 업데이트 (데코레이터 체인 구동)
if (NextModifierEffect)
{
NextModifierEffect->UpdateEffect(Instigator, Target, DeltaTime);
}
}
float URPGEffectInstance::GetModifiedMagnitude() const
{
float TotalMagnitude = BaseMagnitude;
// 만약 내 뒤에 공격력을 20% 올려주는 '분노' 장식자 버프가 붙어있다면 수치를 덧씌움
if (NextModifierEffect)
{
// 재귀적으로 꼬리를 물며 수치를 중첩 장식(Decoration)함
TotalMagnitude += NextModifierEffect->GetModifiedMagnitude();
}
return TotalMagnitude;
}
3) 팩토리 & 옵저버 레이어에서는 컴포넌트 통합 및 네트워크 복제를 수행한다. 모든 캐릭터에게 부착되어 스킬 인스턴스를 스폰(팩토리)하고, 효과 발생을 델리게이트로 방송하며, 네트워크 복제를 총괄하는 컴포넌트이다.
(예시 : RPGCombatComponent.h, cpp)
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "RPGEffectInstance.h"
#include "RPGCombatComponent.generated.h"
// 옵저버 패턴: 효과가 추가되었음을 전역(UI, 파티클 시스템 등)에 알릴 멀티캐스트 델리게이트 선언
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnEffectAppliedDelegate, URPGEffectInstance*, NewEffect);
UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class UNREALDESIGNPATTERN_API URPGCombatComponent : public UActorComponent
{
GENERATED_BODY()
public:
URPGCombatComponent();
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
// [팩토리 메서드] 기획자의 에셋 지시서를 받아 효과 인스턴스를 동적으로 생성 및 등록
UFUNCTION(Server, Reliable, WithValidation)
void Server_ApplyEffect(URPGEffectStrategy* StrategyClass, float Magnitude, float Duration);
UPROPERTY(BlueprintAssignable, Category = "RPG|Observer")
FOnEffectAppliedDelegate OnEffectApplied;
private:
// 현재 캐릭터에게 활성화되어 작동 중인 버프/효과 인스턴스 배열
// UPROPERTY를 지정하여 UObject 기반 인스턴스들이 가비지 컬렉션에 의해 증발하는 크래시 방지
UPROPERTY()
TArray<URPGEffectInstance*> ActiveEffects;
};
#include "RPGCombatComponent.h"
#include "Net/UnrealNetwork.h"
URPGCombatComponent::URPGCombatComponent()
{
PrimaryComponentTick.bCanEverTick = true;
SetIsReplicatedByDefault(true); // 멀티플레이어 환경을 위한 복제 활성화
}
void URPGCombatComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
// 중요: 모든 스킬 효과 연산은 오직 데미지 해킹을 막기 위해 '서버'에서만 수행
if (GetOwner()->HasAuthority())
{
// 역순 순회를 통해 만료된 효과를 안전하게 청소하며 업데이트 (RemoveAtSwap 최적화 연계)
for (int32 i = ActiveEffects.Num() - 1; i >= 0; --i)
{
if (ActiveEffects[i]->IsExpired())
{
ActiveEffects.RemoveAtSwap(i);
}
else
{
ActiveEffects[i]->UpdateEffect(GetOwner(), GetOwner(), DeltaTime);
}
}
}
}
bool URPGCombatComponent::Server_ApplyEffect_Validate(URPGEffectStrategy* StrategyClass, float Magnitude, float Duration)
{
return true; // 실무에서는 여기서 치트 검증 로직(쿨타임 유효성 등)을 구현해놓음.
}
void URPGCombatComponent::Server_ApplyEffect_Implementation(URPGEffectStrategy* StrategyClass, float Magnitude, float Duration)
{
if (!StrategyClass) return;
// [팩토리 패턴] Outer를 Component(this)로 지정하여 메모리 라이프사이클을 컴포넌트에 종속시킴
URPGEffectInstance* NewEffectInstance = NewObject<URPGEffectInstance>(this);
NewEffectInstance->InitializeEffect(StrategyClass, Magnitude, Duration);
// 배열에 등록하여 관리 시작
ActiveEffects.Add(NewEffectInstance);
// [옵저버 패턴] 새로운 효과가 등록되었음을 전역 구독자들에게 알림 (클라이언트 연동용)
// 멀티플레이어 환경에서는 이 부분에 RPC를 태워 클라이언트 클라이언트 UI를 갱신
OnEffectApplied.Broadcast(NewEffectInstance);
}
3. 멀티플레이어 RPC 최적화
RPG 게임을 만약 라이브 서비스한다고 하면, 수백마리의 몬스터가 쏘는 모든 도트 힐과 버프 연산의 Tick 수치 변화를 서버에서 클라이언트로 매 프레임 변수 복제(Replication)를 보내면, 네트워크 대역폭이 과부화되어 서버가 터지거나 네트워크 렉이 발생하게 된다. 따라서 '하이브리드 동기화 최적화' 규칙을 철저하게 준수해야 한다.
1) 논리연산은 Server Only. 체력이 깎이고 계산되는 모든 백엔드 수학 수치 연산은 오직 서버의 TickComponent 내부에서만 조용히 처리된다. 변수 동기화를 매 프레임 보내지 않는다.
2) 시각/청각 연산은 클라이언트에서. (RPC Client-Side Simulation) : 효과가 처음 시작될 때(Server_ApplyEffect)와 종료될 때 딱 2번만 멀티캐스트 RPC(Multicast_PlayEffectFX)를 날려 클라이언트 컴퓨터가 스스로 로컬 파티클(VFX)과 사운드(SFX)를 켜고 끄게 만든다.
4. 추가 내용
전체 코드가 움직이는 순서를 상상하면서 구조를 이해해보자.
1) 기획자 : 에디터에서 UDataAsset을 만들고 저주 스킬의 스탯을 입력함
2) 플레이어 : 인게임에서 Q 버튼을 누름
3) 캐릭터 클래스 : 쥐어진 데이터 자산을 들고 스킬 서브시스템을 호출
4) 스킬 서브시스템 : 데이터의 유효성을 검증한 뒤, 타겟의 전투 컴포넌트에게 RPC로 효과 적용을 명령
5) 전투 컴포넌트(서버) : 팩토리를 가동해 런타임 객체(Instance)를 찍어내고 스트래티지를 구동
이 순서를 '메모리와 네트워크 레이어'에서 벌어지는 일들을 순서대로 따라가며 다시 이해해보자.
1) 플레이어가 Q를 누르면 캐릭터는 URPGEffectDataAsset을 들고 URPGSkillSubsystem을 호출함
2) 서브시스템은 URPGCombatComponent를 찾아 랜선을 타고 서버 함수인 Server_ApplyEffect로 데이터를 보냄
3) 서버에서 컴포넌트는 NewObject<URPGEffectInstance> 팩토리를 가동해 런타임에 효과 인스턴스를 메모리에 올림
4) 인스턴스는 URPGEffectStrategy를 장착하고, 매 프레임 UpdateEffect를 돌며 실시간 연산을 수행함
5) 연산 결과는 변수 복제(Replicated)나 델리게이트 방송(옵저버 패턴)을 타고 클라이언트 UI를 갱신.
1단계 : 기획자는 RPGEffectDataAsset.h와 같이 UDataAsset을 만들게 된다. 이 데이터에셋은 팩토리 패턴의 주문서가 된다.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "RPGEffectStrategy.h" // 스트래티지 헤더
#include "RPGEffectDataAsset.generated.h"
/**
* 기획자가 에디터에서 스킬/효과의 세부 스탯을 정의하는 템플릿 자산
*/
UCLASS(BlueprintType)
class UNREALDESIGNPATTERN_API URPGEffectDataAsset : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Skill Info")
FString SkillName;
// [스트래티지 패턴] 드롭다운에서 DamageStrategy나 HealStrategy를 선택할 수 있게 됨
UPROPERTY(EditAnywhere, BlueprintReadOnly, Instanced, Category = "Skill Logic")
URPGEffectStrategy* EffectStrategy;
// 효과의 기본 수치 (예: 50데미지, 20힐)
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Skill Stats")
float Magnitude;
// 지속 시간 (0이면 즉시 발동)
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Skill Stats")
float Duration;
};
이후 기획자는 에디터에서 C++ 컴파일 없이도 DA_FireCurse와 같은 데이터 에셋을 만들고, 기획한 수치를 입력하여 스킬의 '설명'을 완성할 수 있다.
2단계 : RPGSkillSubsystem.h / cpp 서브시스템을 구현한다. 서브시스템은 싱글톤이자 Facade 창구로서, 캐릭터들이 복잡한 컴포넌트 내부 사정을 몰라도 "대상을 스킬 데이터로 타겟팅"하도록 명령할 수 있게 해준다.
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "RPGEffectDataAsset.h"
#include "RPGSkillSubsystem.generated.h"
UCLASS()
class UNREALDESIGNPATTERN_API URPGSkillSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
// 전역에서 아무나 호출할 수 있는 스킬 사용 통합 창구 (Facade)
UFUNCTION(BlueprintCallable, Category = "RPG|SkillSystem")
void RequestApplySkill(AActor* Instigator, AActor* Target, URPGEffectDataAsset* SkillData);
};
/** */
#include "RPGSkillSubsystem.h"
#include "RPGCombatComponent.h"
#include "GameFramework/Actor.h"
void URPGSkillSubsystem::RequestApplySkill(AActor* Instigator, AActor* Target, URPGEffectDataAsset* SkillData)
{
// 1. 안전성 검사 (방어 코드)
if (!IsValid(Instigator) || !IsValid(Target) || !SkillData)
{
UE_LOG(LogTemp, Warning, TEXT("[Subsystem] 유효하지 않은 스킬 요청."));
return;
}
// 2. 타겟 캐릭터에게 공격을 받아낼 '전투 컴포넌트'가 붙어있는지 스캔
URPGCombatComponent* TargetCombatComp = Target->FindComponentByClass<URPGCombatComponent>();
if (TargetCombatComp)
{
// 3. 컴포넌트의 네트워크 안전 서버 함수(RPC)를 호출하여 주문서(DataAsset)의 알맹이를 넘겨줌
// 컴포넌트 내부의 Server_ApplyEffect 함수를 구동
TargetCombatComp->Server_ApplyEffect(
SkillData->EffectStrategy,
SkillData->Magnitude,
SkillData->Duration
);
UE_LOG(LogTemp, Log, TEXT("[Subsystem] %s 스킬이 %s 에게 성공적으로 요청됨."), *SkillData->SkillName, *Target->GetName());
}
}
3단계 : 플레이어 캐릭터가 Enhanced Input을 받아 Q키를 누르면, 아키텍처를 호출하게 된다.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "RPGEffectDataAsset.h"
#include "MyCharacter.generated.h"
UCLASS()
class UNREALDESIGNPATTERN_API AMyCharacter : public ACharacter
{
GENERATED_BODY()
protected:
// 에디터에서 기획자가 장착해 줄 스킬 데이터 자산 포인터
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Skills")
URPGEffectDataAsset* ActiveSkillQ;
// 현재 조준하고 있는 타겟 캐릭터 (실무에서는 라인트레이싱 등으로 취득)
UPROPERTY(BlueprintReadWrite, Category = "Skills")
AActor* CurrentTargetActor;
// Q 키 입력 시 발동될 C++ 함수
void OnSkillQPressed();
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
};
#include "MyCharacter.h"
#include "RPGSkillSubsystem.h"
#include "EnhancedInputComponent.h"
void AMyCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
// 향상된 입력 시스템에 Q 키 바인딩 (프로젝트 세팅 완료 전제)
if (UEnhancedInputComponent* EnhancedInputComp = Cast<UEnhancedInputComponent>(PlayerInputComponent))
{
// 임의의 IA_SkillQ 래핑 액션에 함수 연결
// EnhancedInputComp->BindAction(IA_SkillQ, ETriggerEvent::Triggered, this, &AMyCharacter::OnSkillQPressed);
}
}
void AMyCharacter::OnSkillQPressed()
{
// Q 키를 누르면 발생하는 실존 런타임 흐름
if (ActiveSkillQ && CurrentTargetActor)
{
// 1. 전역 싱글톤인 스킬 서브시스템을 엔진에서 가져옴. (new할 필요 없음)
URPGSkillSubsystem* SkillSys = GetGameInstance()->GetSubsystem<URPGSkillSubsystem>();
if (SkillSys)
{
// 2. 서브시스템이라는 창구에 "내가(this) 타겟에게(Target) 이 스킬(DataAsset)을 쓸게" 라고 토스
SkillSys->RequestApplySkill(this, CurrentTargetActor, ActiveSkillQ);
}
}
}
3단계 심화 : 예시에서는 Character 코드가 모든 상호작용 역할을 수행하고 있었지만, 컴포넌트와 인터페이스 구조를 감안하면 다음과 같이 디커플링 되어 구현하게 된다.
1) 액터 본체를 컴포넌트와 인터페이스를 붙일 수 있는 껍데기로 만든다.
// MyCharacter.h
#pragma once
#include "GameFramework/Character.h"
#include "CombatInterface.h"
#include "MyCharacter.generated.h"
UCLASS()
class UNREALDESIGNPATTERN_API AMyCharacter : public ACharacter, public ICombatInterface
{
GENERATED_BODY()
public:
AMyCharacter();
// 인터페이스 구현 (Override)
virtual class URPGCombatComponent* GetCombatComponent() const override { return CombatComponent; }
protected:
// 향상된 입력 에셋 바인딩용 스킬 데이터
UPROPERTY(EditAnywhere, Category = "Skills")
class URPGEffectDataAsset* SkillQData;
UPROPERTY(BlueprintReadWrite, Category = "Skills")
AActor* CurrentTarget;
// Q 입력 시 실행될 순수 입력 전동 함수
void OnSkillQPressed();
private:
// 합성(Composition): 컴포넌트를 뼈대에 장착
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, meta = (AllowPrivateAccess = "true"))
class URPGCombatComponent* CombatComponent;
};
// MyCharacter.cpp
#include "MyCharacter.h"
#include "RPGCombatComponent.h"
AMyCharacter::AMyCharacter()
{
// 생성자에서 부품 조립 (CreateDefaultSubobject)
CombatComponent = CreateDefaultSubobject<URPGCombatComponent>(TEXT("CombatComponent"));
}
void AMyCharacter::OnSkillQPressed()
{
// [핵심 위임 패턴] 캐릭터 본체는 서브시스템이 뭔지 몰라도 됨.
// 인터페이스 규약을 준수하는 컴포넌트를 꺼내서 인터페이스에게 떠넘김.
if (ICombatInterface* CombatInterface = Cast<ICombatInterface>(this))
{
if (URPGCombatComponent* CombatComp = CombatInterface->GetCombatComponent())
{
CombatComp->FireSkill(SkillQData, CurrentTarget);
}
}
}
2) 전투 인터페이스를 정의 (CombatInterface.h)
#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "CombatInterface.generated.h"
UINTERFACE(MinimalAPI)
class UCombatInterface : public UInterface { GENERATED_BODY() };
class UNREALDESIGNPATTERN_API ICombatInterface
{
GENERATED_BODY()
public:
// "스킬 컴포넌트를 가지고 있다면 나에게 넘겨줘"라는 규약 선언
virtual class URPGCombatComponent* GetCombatComponent() const = 0;
};
3) 서브시스템 호출을 전담할 컴포넌트 고도화
RPGCombatComponent 파일도 일부 변경이 필요하다. 캐릭터 본체에 있던 SkillData 소유권과 서브시스템 호출 코드를 해당 컴포넌트 안으로 이전시킨다. 즉, 캐릭터 대신 컴포넌트가 직접 서브시스템을 찾고 호출을 전담하게 된다.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "RPGEffectDataAsset.h" // 스킬 데이터 자산
#include "RPGCombatComponent.generated.h"
UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class UNREALDESIGNPATTERN_API URPGCombatComponent : public UActorComponent
{
GENERATED_BODY()
public:
// 캐릭터 대신 컴포넌트가 직접 서브시스템을 찾고 호출을 전담
void FireSkill(URPGEffectDataAsset* SkillData, AActor* Target);
};
// RPGCombatComponent.cpp
#include "RPGCombatComponent.h"
#include "RPGSkillSubsystem.h" // 글로벌 서브시스템
void URPGCombatComponent::FireSkill(URPGEffectDataAsset* SkillData, AActor* Target)
{
if (!SkillData || !Target) return;
// 컴포넌트가 스스로 전역 지휘관(Subsystem)을 찾아가 명령을 내림
if (URPGSkillSubsystem* SkillSys = GetWorld()->GetGameInstance()->GetSubsystem<URPGSkillSubsystem>())
{
// 소유자(GetOwner) 정보를 포함하여 서브시스템에 토스
SkillSys->RequestApplySkill(GetOwner(), Target, SkillData);
}
}
*팀 프로젝트*
1. 지난 코드 리뷰 및 피드백
현재 코드가 모호하고 끊어진 것처럼 느껴진 이유는 UInteractionComponent가 이름은 Interaction인데, 정작 내부 레이저 검사에서는 IInteractable이 아닌 ICarryable 인터페이스를 직접 검사하도록 코드가 엇나게 짜여있기 때문
*플레이어 신체 컴포넌트 (Player Side)
ParcelCharacter (몸통): 컴포넌트들을 장착하는 가벼운 껍데기 액터.
UParcelHeroComponent (뇌/신경): 이동(WASD) 및 시점(마우스) 입력, 카메라 암 조작 전담.
UInteractionComponent (눈): 화면 중앙에서 정면으로 레이저(LineTrace)를 쏘아 조준된 대상을 포착하고, E키 입력을 서버에 전달.
UCharacterCarryComponent (양손): 상자를 캐릭터의 HandSocket 위치에 붙이거나 떼어내고, 무거운 상자를 들었을 때 속도를 조절하는 손 역할
*규격 및 오브젝트 (Object Side)
IInteractable (상호작용 규격): "눈으로 조준해서 E키를 누를 수 있는 모든 물체(상자, 레버, 버튼)"가 구현해야 하는 규칙.
ICarryable (들기 규격): "양손으로 집어 들어 나를 수 있는 물체(택배 상자)"가 구현해야 하는 규칙.
ADeliveryBox (실제 상자): 위 두 가지 규격을 모두 상속받아, 조준될 수도 있고 들릴 수도 있는 오브젝트.
플레이어의 눈(UInteractionComponent)은 오직 IInteractable만 찾아서 E키 신호를 보내게 하고,
상자(ADeliveryBox)는 IInteractable을 상속받아 "상호작용(조준) 됨"를 인지.
상자의 상호작용 몸통(Interact)이 실행되는 순간, 자신을 조준한 플레이어에게 "UCharacterCarryComponent 컴포넌트를 꺼내서 집어가"라고 신호를 역으로 토스해 주어야 고리가 완성.
(수정 이후)
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "GameplayTagContainer.h"
#include "DeliveryTypes.h"
#include "Delivery/Carryable.h"
#include "Delivery/Interactable.h"
#include "DeliveryBox.generated.h"
DECLARE_LOG_CATEGORY_EXTERN(LogParcelDelivery, Log, All);
#define DELIVERY_LOG(Category, Verbosity, Format, ...) UE_LOG(Category, Verbosity, Format, ##__VA_ARGS__)
class UBoxComponent;
class UStaticMeshComponent;
class UCarryComponent;
UCLASS()
class PARCEL_KNIGHT_API ADeliveryBox : public AActor, public ICarryable, public IInteractable
{
GENERATED_BODY()
public:
ADeliveryBox();
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
protected:
virtual void BeginPlay() override;
public:
// 서버에서 상태 태그를 제어하는 권한 전용 함수
void AddStateTag(FGameplayTag NewStateTag);
void RemoveStateTag(FGameplayTag StateTag);
void InitializeBox(int32 InBoxID, const FBoxData& InBoxData);
// [Interface] ICarryable 오버라이드
virtual bool CanCarry(AActor* Carrier) override;
virtual void OnPickedUp(AActor* Carrier) override;
virtual void OnDropped() override;
// [Interface] IInteractable 오버라이드
virtual bool CanInteract(AActor* Interactor) override;
virtual void Interact(AActor* Interactor) override;
// Getter 함수. 상자 ID, Tag, BoxData, 물리 임계값
FORCEINLINE bool HasStateTag(FGameplayTag StateTag) const { return BoxStateTags.HasTag(StateTag); }
FORCEINLINE int32 GetBoxID() const { return BoxID; }
FORCEINLINE FGameplayTagContainer GetBoxStateTags() const { return BoxStateTags; }
FORCEINLINE FBoxData GetBoxData() const { return BoxData; }
FORCEINLINE float GetDamageThreshold() const { return BoxData.DamageThreshold; }
protected:
// [Client] 태그 변경 시 클라이언트 연출용 RepNotify
UFUNCTION()
void OnRep_BoxStateTags();
// [Client] 데이터 설정 시 클라이언트 Mesh 동기화용 RepNotify
UFUNCTION()
void OnRep_BoxData();
// [Server] 서버에서 호출될 파손 판정용 콜백 함수
UFUNCTION()
void OnPhysicsHit(
UPrimitiveComponent* HitComponent,
AActor* OtherActor, UPrimitiveComponent* OtherComp,
const FVector NormalImpulse,
const FHitResult& Hit);
private:
// 컴포넌트
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components", meta = (AllowPrivateAccess = "true"))
TObjectPtr<UBoxComponent> CollisionComponent;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components", meta = (AllowPrivateAccess = "true"))
TObjectPtr<UStaticMeshComponent> BoxMesh;
/* ==========================================================================
동기화 규칙 변수 (Replicated)
========================================================================== */
UPROPERTY(Replicated)
int32 BoxID;
UPROPERTY(ReplicatedUsing = OnRep_BoxStateTags)
FGameplayTagContainer BoxStateTags;
UPROPERTY(ReplicatedUsing = OnRep_BoxData)
FBoxData BoxData;
UPROPERTY(Replicated)
TObjectPtr<APlayerController> HolderPlayer;
};
#include "Delivery/DeliveryBox.h"
#include "Components/StaticMeshComponent.h"
#include "Components/BoxComponent.h"
#include "Delivery/PhysicsJudgeManager.h"
#include "Character/CharacterCarryComponent.h"
#include "Net/UnrealNetwork.h"
DEFINE_LOG_CATEGORY(LogParcelDelivery);
ADeliveryBox::ADeliveryBox()
{
PrimaryActorTick.bCanEverTick = false;
bReplicates = true;
// 박스 콜리전 생성 >> 물리 바디 콜리전 세팅 >> Mesh 부착 후 자체 물리 Off
CollisionComponent = CreateDefaultSubobject<UBoxComponent>(TEXT("CollisionComponent"));
RootComponent = CollisionComponent;
CollisionComponent->SetSimulatePhysics(true);
CollisionComponent->SetCollisionProfileName(TEXT("PhysicsBody"));
CollisionComponent->SetNotifyRigidBodyCollision(true);
BoxMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("BoxMesh"));
BoxMesh->SetupAttachment(RootComponent);
BoxMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
BoxMesh->SetSimulatePhysics(false);
BoxID = -1;
}
void ADeliveryBox::BeginPlay()
{
SetReplicateMovement(true); // 리슨 서버 물리 동기화 활성화
Super::BeginPlay();
if (HasAuthority() && CollisionComponent)
{
CollisionComponent->OnComponentHit.AddDynamic(this, &ADeliveryBox::OnPhysicsHit);
}
}
void ADeliveryBox::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ADeliveryBox, BoxID);
DOREPLIFETIME(ADeliveryBox, BoxStateTags);
DOREPLIFETIME(ADeliveryBox, BoxData);
DOREPLIFETIME(ADeliveryBox, HolderPlayer);
}
void ADeliveryBox::InitializeBox(int32 InBoxID, const FBoxData& InBoxData)
{
if (!HasAuthority()) return;
BoxID = InBoxID;
BoxData = InBoxData;
if (BoxData.BoxMeshAsset && BoxMesh && CollisionComponent)
{
BoxMesh->SetStaticMesh(BoxData.BoxMeshAsset);
// 무게 적용 (밸런싱 수치 조절 (현재 1.0f))
CollisionComponent->SetMassOverrideInKg(NAME_None, BoxData.Weight * 1.0f, true);
}
// Spawned 태그 부여
AddStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Spawned")));
DELIVERY_LOG(LogParcelDelivery, Log, TEXT("[Server] 상자 고유ID %d번 초기화 완료. 타입 태그: %s, 설정 무게: %f kg"),
BoxID, *BoxData.BoxTypeTag.ToString(), BoxData.Weight);
}
void ADeliveryBox::OnRep_BoxData()
{
// 클라이언트 측에서도 서버가 채워준 BoxData를 받으면 메시 외형을 동기화함
if (BoxData.BoxMeshAsset && BoxMesh)
{
BoxMesh->SetStaticMesh(BoxData.BoxMeshAsset);
}
DELIVERY_LOG(LogParcelDelivery, Display, TEXT("[Client] %d번 상자의 외형 데이터 동기화 완료. 표시 이름: %s"),
BoxID, *BoxStateTags.ToString());
}
void ADeliveryBox::AddStateTag(FGameplayTag NewStateTag)
{
if (!HasAuthority() || !NewStateTag.IsValid()) return;
if (!BoxStateTags.HasTagExact(NewStateTag))
{
BoxStateTags.AddTag(NewStateTag);
DELIVERY_LOG(LogParcelDelivery, Log, TEXT("[Server] %d번 상자에 새로운 상태 태그 추가됨: %s"), BoxID, *NewStateTag.ToString());
if (NewStateTag.MatchesTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Held"))))
{
CollisionComponent->SetSimulatePhysics(false);
CollisionComponent->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
//플레이어가 상자를 들면 상자의 물리 규칙은 잠시 꺼야 함.
if (NewStateTag.MatchesTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Held"))))
{
CollisionComponent->SetSimulatePhysics(false);
CollisionComponent->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
}
}
void ADeliveryBox::RemoveStateTag(FGameplayTag StateTag)
{
if (!HasAuthority() || !StateTag.IsValid()) return;
if (BoxStateTags.HasTagExact(StateTag))
{
BoxStateTags.RemoveTag(StateTag);
DELIVERY_LOG(LogParcelDelivery, Log, TEXT("[Server] %d번 상자에서 상태 태그 제거됨: %s"), BoxID, *StateTag.ToString());
// 상태가 제거될 때의 예외 복구 로직
if (StateTag.MatchesTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Held"))))
{
CollisionComponent->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
CollisionComponent->SetSimulatePhysics(true);
}
}
}
void ADeliveryBox::OnRep_BoxStateTags()
{
// [Client] 시각/청각 연출 분기점
FGameplayTag HeldTag = FGameplayTag::RequestGameplayTag(TEXT("Box.State.Held"));
FGameplayTag DamagedTag = FGameplayTag::RequestGameplayTag(TEXT("Box.State.Damaged"));
DELIVERY_LOG(LogParcelDelivery, Log, TEXT("[Client] %d번 상자의 상태 태그 컨테이너 갱신됨. 현재 태그 목록: %s"),
BoxID, *BoxStateTags.ToString());
if (BoxStateTags.HasTag(HeldTag))
{
CollisionComponent->SetSimulatePhysics(false);
CollisionComponent->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
else
{
CollisionComponent->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
CollisionComponent->SetSimulatePhysics(true);
}
if (BoxStateTags.HasTag(DamagedTag))
{
// Todo : 찌그러진 메쉬로 교체하거나 내용물이 튀어나오는 이펙트/사운드 호출
}
}
/* ==========================================================================
ICarryable 인터페이스
========================================================================== */
bool ADeliveryBox::CanCarry(AActor* Carrier)
{
return BoxStateTags.HasTagExact(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Spawned")));
}
void ADeliveryBox::OnPickedUp(AActor* Carrier)
{
if (!HasAuthority() || !Carrier) return;
if (APawn* CarrierPawn = Cast<APawn>(Carrier))
{
HolderPlayer = Cast<APlayerController>(CarrierPawn->GetController());
SetOwner(CarrierPawn);
DELIVERY_LOG(LogParcelDelivery, Log, TEXT("[Server] %d번 상자 획득 처리 완료. 소유 플레이어: %s"),
BoxID, HolderPlayer ? *HolderPlayer->GetName() : TEXT("알 수 없음"));
}
RemoveStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Spawned")));
AddStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Held")));
}
void ADeliveryBox::OnDropped()
{
if (!HasAuthority()) return;
DELIVERY_LOG(LogParcelDelivery, Log, TEXT("[Server] %d번 상자 낙하 처리 완료."), BoxID);
HolderPlayer = nullptr;
SetOwner(nullptr); // 소유권 해제
RemoveStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Held")));
AddStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Spawned")));
}
void ADeliveryBox::OnPhysicsHit(UPrimitiveComponent* HitComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, const FVector NormalImpulse, const FHitResult& Hit)
{
if (!HasAuthority()) return;
if (HasStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Damaged")))) return;
if (HasStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Held")))) return;
float ImpactForce = NormalImpulse.Size();
if (ImpactForce < 100.0f) return;
DELIVERY_LOG(LogParcelDelivery, Warning, TEXT("[Server] %d번 상자 물리 충돌 발생. 충돌 대상: %s, 검출된 충격량 수치: %f (파손 임계값: %f)"),
BoxID, OtherActor ? *OtherActor->GetName() : TEXT("None"), ImpactForce, BoxData.DamageThreshold);
if (UWorld* World = GetWorld())
{
if (UPhysicsJudgeManager* DamageManager = World->GetSubsystem<UPhysicsJudgeManager>())
{
DamageManager->EvaluateImpact(this, ImpactForce);
}
}
}
/* ==========================================================================
IInteractable 인터페이스
========================================================================== */
bool ADeliveryBox::CanInteract(AActor* Interactor)
{
return CanCarry(Interactor);
}
void ADeliveryBox::Interact(AActor* Interactor)
{
if (!Interactor) return;
// 상태를 Held 태그로 바꿈
OnPickedUp(Interactor);
// UCarryComponent 컴포넌트를 호출
if (UCharacterCarryComponent* CharacterCarryComp = Interactor->FindComponentByClass<UCharacterCarryComponent>())
{
// 캐릭터 양손에 붙임
CharacterCarryComp->Pickup(this);
}
}
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "InteractionComponent.generated.h"
UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class PARCEL_KNIGHT_API UInteractionComponent : public UActorComponent
{
GENERATED_BODY()
public:
UInteractionComponent();
virtual void BeginPlay() override;
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
void PrimaryInteract();
protected:
UFUNCTION(Server, Reliable, WithValidation)
void Server_RequestPrimaryInteract(AActor* TargetActor);
private:
void CheckTraceTarget();
private:
UPROPERTY()
AActor* CurrentFocusedActor;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction", meta = (AllowPrivateAccess = "true"))
float TraceDistance;
};
#include "Delivery/InteractionComponent.h"
#include "Delivery/Interactable.h"
#include "Delivery/CarryComponent.h"
#include "GameFramework/Character.h"
#include "GameFramework/PlayerController.h"
#include "CollisionQueryParams.h"
UInteractionComponent::UInteractionComponent()
{
PrimaryComponentTick.bCanEverTick = true;
TraceDistance = 300.f;
CurrentFocusedActor = nullptr;
}
void UInteractionComponent::BeginPlay()
{
Super::BeginPlay();
}
void UInteractionComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
CheckTraceTarget();
}
void UInteractionComponent::CheckTraceTarget()
{
ACharacter* OwnerCharacter = Cast<ACharacter>(GetOwner());
if (!OwnerCharacter) return;
APlayerController* PC = Cast<APlayerController>(OwnerCharacter->GetController());
if (!PC) return;
FVector TraceStart;
FRotator TraceRotation;
PC->GetPlayerViewPoint(TraceStart, TraceRotation);
FVector TraceEnd = TraceStart + (TraceRotation.Vector() * TraceDistance);
FHitResult HitResult;
FCollisionQueryParams QueryParams;
QueryParams.AddIgnoredActor(OwnerCharacter);
if (GetWorld()->LineTraceSingleByChannel(HitResult, TraceStart, TraceEnd, ECC_Visibility, QueryParams))
{
AActor* HitActor = HitResult.GetActor();
if (HitActor && HitActor->GetClass()->ImplementsInterface(UInteractable::StaticClass()))
{
if (CurrentFocusedActor != HitActor)
{
CurrentFocusedActor = HitActor;
}
return;
}
}
CurrentFocusedActor = nullptr;
}
void UInteractionComponent::PrimaryInteract()
{
if (!CurrentFocusedActor) return;
ACharacter* OwnerCharacter = Cast<ACharacter>(GetOwner());
if (!OwnerCharacter) return;
IInteractable* InteractableTarget = Cast<IInteractable>(CurrentFocusedActor);
if (InteractableTarget && InteractableTarget->CanInteract(OwnerCharacter))
{
if (OwnerCharacter->HasAuthority())
{
// Host 유저 : 즉시 상자 상호작용 호출
InteractableTarget->Interact(OwnerCharacter);
}
else
{
// 원격 클라이언트 유저 : RPC 요청 발송
Server_RequestPrimaryInteract(CurrentFocusedActor);
}
}
}
bool UInteractionComponent::Server_RequestPrimaryInteract_Validate(AActor* TargetActor)
{
if (!TargetActor) return false;
ACharacter* OwnerCharacter = Cast<ACharacter>(GetOwner());
if (!OwnerCharacter) return false;
float DistSq = FVector::DistSquared(OwnerCharacter->GetActorLocation(), TargetActor->GetActorLocation());
return DistSq <= FMath::Square(TraceDistance + 50.f);
}
void UInteractionComponent::Server_RequestPrimaryInteract_Implementation(AActor* TargetActor)
{
ACharacter* OwnerCharacter = Cast<ACharacter>(GetOwner());
if (!OwnerCharacter || !TargetActor) return;
// [Server] 서버에서도 상자의 IInteractable 인지
IInteractable* InteractableTarget = Cast<IInteractable>(TargetActor);
if (InteractableTarget && InteractableTarget->CanInteract(OwnerCharacter))
{
InteractableTarget->Interact(OwnerCharacter);
}
}
2. 작업한 내용들
[1. ParcelInteractionComponent] : 캐릭터 또는 빙의된 대상이 사용할 수 있는 컴포넌트. 상호작용이 가능한 오브젝트를 탐지하는 LineTrace와 상호작용 로직을 캐릭터 몸체에서 분리했다. 클라이언트가 조준하고 서버가 검증 및 실행하는 원칙을 지키기 위해서 LineTrace 검사는 로컬 클라이언트에서만 가볍게 돌리고, 실제 E키 입력이 들어왔을 때 서버 RPC를 통해 거리를 검증하는 컴포넌트 코드를 작성했다.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "Engine/EngineTypes.h"
#include "ParcelInteractionComponent.generated.h"
UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class PARCEL_KNIGHT_API UParcelInteractionComponent : public UActorComponent
{
GENERATED_BODY()
public:
UParcelInteractionComponent();
virtual void BeginPlay() override;
void PrimaryInteract();
// 현재 조준 중인 대상을 외부에서 가져갈 수 있는 게터
FORCEINLINE AActor* GetCurrentFocusedActor() const { return CurrentFocusedActor; }
protected:
// [Server] 상호작용을 안전하게 검증하고 실행할 RPC
UFUNCTION(Server, Reliable, WithValidation)
void Server_RequestPrimaryInteract(AActor* TargetActor);
private:
// 0.1초마다 타이머에 의해 주기적으로 호출하는 시선 검사 함수
void CheckTraceTarget();
// 가비지 컬렉터로부터 포인터를 보호하기 위한 매크로
UPROPERTY(Transient)
TObjectPtr<AActor> CurrentFocusedActor;
// 조준 레이저 최대 거리
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Interaction", meta = (AllowPrivateAccess = "true"))
float TraceDistance;
// 0.1초 주기 검사를 관리할 타이머 핸들
FTimerHandle TraceTimerHandle;
/** 타이머가 실행될 주기 (0.1초) */
const float TraceInterval = 0.1f;
};
#include "Character/ParcelInteractionComponent.h"
#include "Delivery/Interactable.h"
#include "GameFramework/Character.h"
#include "GameFramework/PlayerController.h"
#include "CollisionQueryParams.h"
#include "TimerManager.h"
UParcelInteractionComponent::UParcelInteractionComponent()
{
PrimaryComponentTick.bCanEverTick = false;
TraceDistance = 300.f;
CurrentFocusedActor = nullptr;
}
void UParcelInteractionComponent::BeginPlay()
{
Super::BeginPlay();
// (참고) 멀티플레이어 환경에서 빙의(Possession) 타이밍 문제를 방지하기 위해
// 타이머는 우선 모든 클라이언트에서 돌리되 실제 레이저 연산은 내부에서 로컬 제어권 여부로 필터링
if (GetWorld())
{
GetWorld()->GetTimerManager().SetTimer(
TraceTimerHandle,
this,
&UParcelInteractionComponent::CheckTraceTarget,
TraceInterval,
true
);
}
}
void UParcelInteractionComponent::CheckTraceTarget()
{
ACharacter* OwnerCharacter = Cast<ACharacter>(GetOwner());
// 내가 조종하는 로컬 캐릭터 화면이 아니라면 레이저를 쏘지 않고 즉시 리턴
if (!OwnerCharacter || !OwnerCharacter->IsLocallyControlled()) return;
APlayerController* PC = Cast<APlayerController>(OwnerCharacter->GetController());
if (!PC) return;
FVector TraceStart;
FRotator TraceRotation;
PC->GetPlayerViewPoint(TraceStart, TraceRotation);
FVector TraceEnd = TraceStart + (TraceRotation.Vector() * TraceDistance);
FHitResult HitResult;
FCollisionQueryParams QueryParams;
// 플레이어 자신은 조준에서 제외
QueryParams.AddIgnoredActor(OwnerCharacter);
if (GetWorld()->LineTraceSingleByChannel(HitResult, TraceStart, TraceEnd, ECC_Visibility, QueryParams))
{
AActor* HitActor = HitResult.GetActor();
// 조준된 액터가 인터페이스 규격을 구현했는지 검사
if (HitActor && HitActor->GetClass()->ImplementsInterface(UInteractable::StaticClass()))
{
IInteractable* InteractableTarget = Cast<IInteractable>(HitActor);
if (InteractableTarget && InteractableTarget->CanInteract(OwnerCharacter))
{
CurrentFocusedActor = HitActor;
return;
}
}
}
// 아무것도 조준하지 않았거나 상호작용 불가능한 대상이라면 타겟 클리어
CurrentFocusedActor = nullptr;
}
void UParcelInteractionComponent::PrimaryInteract()
{
// 로컬 화면상 포커싱된 타겟이 없다면 무시
if (!CurrentFocusedActor) return;
ACharacter* OwnerCharacter = Cast<ACharacter>(GetOwner());
if (!OwnerCharacter) return;
if (CurrentFocusedActor->GetClass()->ImplementsInterface(UInteractable::StaticClass()))
{
IInteractable* InteractableTarget = Cast<IInteractable>(CurrentFocusedActor);
if (InteractableTarget && InteractableTarget->CanInteract(OwnerCharacter))
{
// 호스트 유저 : 즉시 실행
if (OwnerCharacter->HasAuthority())
{
InteractableTarget->Interact(OwnerCharacter);
}
// 원격 클라이언트 유저 : 서버에 RPC 요청
else
{
Server_RequestPrimaryInteract(CurrentFocusedActor);
}
}
}
}
// ==========================================================================
// [Server] 멀티플레이 보안용 RPC 레이턴시 보정 거리 검증
// ==========================================================================
bool UParcelInteractionComponent::Server_RequestPrimaryInteract_Validate(AActor* TargetActor)
{
if (!TargetActor) return false;
ACharacter* OwnerCharacter = Cast<ACharacter>(GetOwner());
if (!OwnerCharacter) return false;
// (참고) 패킷 변조 핵 방어: 서버사이드에서 캐릭터와 타겟의 실제 거리를 역계산
float DistSq = FVector::DistSquared(OwnerCharacter->GetActorLocation(), TargetActor->GetActorLocation());
// (참고) 0.1초 타이머 주기와 네트워크 지연시간(Ping)을 고려하여 최대 거리에 마진(+50.f)을 더해 검증
return DistSq <= FMath::Square(TraceDistance + 50.f);
}
void UParcelInteractionComponent::Server_RequestPrimaryInteract_Implementation(AActor* TargetActor)
{
ACharacter* OwnerCharacter = Cast<ACharacter>(GetOwner());
if (!OwnerCharacter || !TargetActor) return;
if (TargetActor->GetClass()->ImplementsInterface(UInteractable::StaticClass()))
{
IInteractable* InteractableTarget = Cast<IInteractable>(TargetActor);
if (InteractableTarget && InteractableTarget->CanInteract(OwnerCharacter))
{
// 서버 월드에서 상자의 상호작용 몸통 로직 실행
InteractableTarget->Interact(OwnerCharacter);
}
}
}
[1.5. ParcelLog. 파일 구현]
가벼운 공용 로그 전용 헤더 파일(ParcelLog.h)을 분리하고, 각 클래스들이 이를 참조하여 파트별 로그 매크로를 호출하는 최종 아키텍처로 전면 리팩토링. 이렇게 하면 매크로 내부에 로그 카테고리가 자동으로 숨겨지기 때문에, 코드를 작성할 때 카테고리 이름을 매번 넘겨줄 필요 없이 MOVEMENT_LOG(Log, TEXT("...")) 형태로 심플하게 사용할 수 있게 했음.
[2. ParcelCharacter 리팩토링]
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "ParcelCharacter.generated.h"
class UParcelMovementStatComponent;
class URagdollComponent;
class UParcelHeroComponent;
class UParcelInteractionComponent;
// 플레이어가 조종하는 기본 캐릭터 클래스
// 이동, 시점 회전, 점프, 래그돌 테스트 입력을 처리하고 멀티플레이 복제를 지원
//
// 담당자: 김로운
UCLASS()
class PARCEL_KNIGHT_API AParcelCharacter : public ACharacter
{
GENERATED_BODY()
public:
AParcelCharacter();
virtual void BeginPlay() override;
// 플레이어 입력 컴포넌트에 Enhanced Input 액션들을 바인딩
virtual void SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) override;
// 서버에서 이 캐릭터가 컨트롤러에 빙의될 때 호출, Listen Server의 로컬 플레이어 입력 매핑 등록을 보강
virtual void PossessedBy(AController* NewController) override;
// 클라이언트에서 Controller 값이 복제되어 바뀔 때 호출, 원격 접속 클라이언트가 possession 이후 입력 매핑을 놓치지 않게 합니다.
virtual void OnRep_Controller() override;
// 다른 요소들이 상호작용 컴포넌트에 접근할 수 있도록 하는 게터
FORCEINLINE UParcelInteractionComponent* GetParcelInteractionComponent() const { return InteractionComp; }
FORCEINLINE UParcelMovementStatComponent* GetParcelMovementStatComponent() const { return MovementStatComp; }
protected:
// Ragdoll Component
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Component")
TObjectPtr<URagdollComponent> RagdollComp;
// Hero Component
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Component")
TObjectPtr<UParcelHeroComponent> HeroComp;
// Interaction Component
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Component")
TObjectPtr<UParcelInteractionComponent> InteractionComp;
// MovementStat Component
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Component")
TObjectPtr<UParcelMovementStatComponent> MovementStatComp;
};
#include "Character/ParcelCharacter.h"
#include "ParcelLog.h"
#include "Character/RagdollComponent.h"
#include "Character/ParcelHeroComponent.h"
#include "Character/ParcelInteractionComponent.h"
#include "Character/ParcelMovementStatComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
DEFINE_LOG_CATEGORY(LogCharacter);
AParcelCharacter::AParcelCharacter()
{
PrimaryActorTick.bCanEverTick = false;
// 캐릭터 액터 자체를 네트워크에 복제
bReplicates = true;
// 움직임이 잦은 플레이어 캐릭터라서 기본보다 높은 빈도로 네트워크 갱신을 요청
SetNetUpdateFrequency(100.f);
// 네트워크 상태가 안정적일 때도 너무 낮은 빈도로 떨어지지 않게 최소 갱신 빈도를 지정합니다.
SetMinNetUpdateFrequency(33.f);
bUseControllerRotationPitch = false;
bUseControllerRotationYaw = false;
bUseControllerRotationRoll = false;
// 이동 입력 방향을 바라보도록 캐릭터를 자동 회전
GetCharacterMovement()->bOrientRotationToMovement = true;
// 공중에서 이동 입력이 얼마나 반영되는지 정합니다.
GetCharacterMovement()->AirControl = 0.35f;
// 래그돌 상태 전환과 복제를 담당하는 컴포넌트를 생성
RagdollComp = CreateDefaultSubobject<URagdollComponent>(TEXT("RagdollComp"));
// 조작 및 카메라를 담당하는 컴포넌트 생성
HeroComp = CreateDefaultSubobject<UParcelHeroComponent>(TEXT("HeroComp"));
// 상호작용 컴포넌트 생성
InteractionComp = CreateDefaultSubobject<UParcelInteractionComponent>(TEXT("InteractionComp"));
// 이동 관련 스탯 컴포넌트 인스턴스 생성 및 부착
MovementStatComp = CreateDefaultSubobject<UParcelMovementStatComponent>(TEXT("MovementStatComp"));
}
void AParcelCharacter::BeginPlay()
{
// 위치, 회전 같은 Actor Movement를 서버에서 클라이언트로 복제
// ACharacter의 기본 CharacterMovement 복제와 함께 동작합니다.
SetReplicateMovement(true);
Super::BeginPlay();
}
void AParcelCharacter::PossessedBy(AController* NewController)
{
// 서버의 possession 처리 흐름을 유지합니다.
Super::PossessedBy(NewController);
// 서버 Possessed 시점에 HeroComponent에 이벤트를 넘김
if (HeroComp)
{
HeroComp->AddInputMappingContext();
}
}
void AParcelCharacter::OnRep_Controller()
{
// 클라이언트에서 복제된 Controller 변경 처리를 부모 클래스에 맡깁니다.
Super::OnRep_Controller();
// 클라이언트의 Controller 복제 시점에 HeroComponent에 이벤트를 넘김
if (HeroComp)
{
HeroComp->AddInputMappingContext();
}
}
void AParcelCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
// 부모 클래스의 입력 설정을 먼저 실행합니다.
Super::SetupPlayerInputComponent(PlayerInputComponent);
// 입력 컴포넌트 통로를 히어로 컴포넌트 내부의 바인딩 연산으로 넘김
if (HeroComp)
{
HeroComp->InitializePlayerInput(PlayerInputComponent);
}
}
기존에 비대했던 Character 클래스를 여러 컴포넌트와 인터페이스로 역할을 분담하는 작업을 진행중임(리팩토링)
[3. 플레이어 스탯을 담는 ParcelStatComponent 추가]
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "ParcelMovementStatComponent.generated.h"
UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class PARCEL_KNIGHT_API UParcelMovementStatComponent : public UActorComponent
{
GENERATED_BODY()
public:
UParcelMovementStatComponent();
// 멀티플레이어 동기화
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
virtual void BeginPlay() override;
// [Server] 스탯 변경을 위한 세터
UFUNCTION(BlueprintAuthorityOnly, BlueprintCallable, Category = "Stats|Movement")
void SetMaxWalkSpeed(float NewSpeed);
// 스탯 조회를 위한 게터
FORCEINLINE float GetMaxWalkSpeed() const { return MaxWalkSpeed; }
FORCEINLINE float GetJumpZVelocity() const { return JumpZVelocity; }
FORCEINLINE float GetAirControl() const { return AirControl; }
FORCEINLINE FRotator GetRotationRate() const { return RotationRate; }
private:
// 클라이언트 값이 복제되어 바뀔 때 무브먼트 컴포넌트에 동기화해주는 RepNotify 함수
UFUNCTION() void OnRep_MaxWalkSpeed();
UFUNCTION() void OnRep_JumpZVelocity();
UFUNCTION() void OnRep_AirControl();
UFUNCTION() void OnRep_RotationRate();
// 실제 캐릭터 무브먼트 컴포넌트에 현재 스탯값들을 주입하는 내부 함수
void ApplyStatsToMovement();
// 서버가 통제하고 클라이언트에 복제되는 캐릭터 무브먼트 데이터
UPROPERTY(ReplicatedUsing = OnRep_MaxWalkSpeed, EditAnywhere, Category = "Stats|Movement")
float MaxWalkSpeed;
UPROPERTY(ReplicatedUsing = OnRep_JumpZVelocity, EditAnywhere, Category = "Stats|Movement")
float JumpZVelocity;
UPROPERTY(ReplicatedUsing = OnRep_AirControl, EditAnywhere, Category = "Stats|Movement")
float AirControl;
UPROPERTY(ReplicatedUsing = OnRep_RotationRate, EditAnywhere, Category = "Stats|Movement")
FRotator RotationRate;
};
#include "Character/ParcelMovementStatComponent.h"
#include "ParcelLog.h"
#include "GameFramework/Character.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "Net/UnrealNetwork.h"
DEFINE_LOG_CATEGORY(LogParcelMovementStat);
UParcelMovementStatComponent::UParcelMovementStatComponent()
{
PrimaryComponentTick.bCanEverTick = false;
SetIsReplicatedByDefault(true);
// 캐릭터 속도 관련 스탯 기본값
MaxWalkSpeed = 450.0f;
JumpZVelocity = 500.f;
AirControl = 0.35f;
RotationRate = FRotator(0.f, 540.f, 0.f);
}
void UParcelMovementStatComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// 동기화 변수 등록
DOREPLIFETIME(UParcelMovementStatComponent, MaxWalkSpeed);
DOREPLIFETIME(UParcelMovementStatComponent, JumpZVelocity);
DOREPLIFETIME(UParcelMovementStatComponent, AirControl);
DOREPLIFETIME(UParcelMovementStatComponent, RotationRate);
}
void UParcelMovementStatComponent::BeginPlay()
{
Super::BeginPlay();
// 서버와 클라이언트 모두 각자의 로컬 캐릭터 무브먼트에 기본값 주입
ApplyStatsToMovement();
}
void UParcelMovementStatComponent::SetMaxWalkSpeed(float NewSpeed)
{
// [Server] 서버 권한이 있을 때만 수정 가능하도록 방어
if (!GetOwner() || !GetOwner()->HasAuthority()) return;
MaxWalkSpeed = NewSpeed;
// [Server] RepNotify가 로컬에서 자동으로 호출되지 않으므로 수동으로 적용 함수 실행
ApplyStatsToMovement();
}
// RepNotify 함수
void UParcelMovementStatComponent::OnRep_MaxWalkSpeed() { ApplyStatsToMovement(); }
void UParcelMovementStatComponent::OnRep_JumpZVelocity() { ApplyStatsToMovement(); }
void UParcelMovementStatComponent::OnRep_AirControl() { ApplyStatsToMovement(); }
void UParcelMovementStatComponent::OnRep_RotationRate() { ApplyStatsToMovement(); }
void UParcelMovementStatComponent::ApplyStatsToMovement()
{
if (ACharacter* OwnerCharacter = Cast<ACharacter>(GetOwner()))
{
if (UCharacterMovementComponent* Movement = OwnerCharacter->GetCharacterMovement())
{
Movement->MaxWalkSpeed = MaxWalkSpeed;
Movement->JumpZVelocity = JumpZVelocity;
Movement->AirControl = AirControl;
Movement->RotationRate = RotationRate;
MOVEMENT_LOG(Log, TEXT("[%s] 이동 스탯이 캐릭터에게 적용되었습니다."),
OwnerCharacter->HasAuthority() ? TEXT("Server") : TEXT("Client"));
}
}
}
Todo : HeroComponent 안에 하드코딩 되어있는 걷기 속도, 달리기 속도도 MovementStatComponent로 옮길 예정임.
*개인 프로젝트 및 개인 공부*
오늘은 팀프로젝트를 하면서 Log 매크로를 작성하는 연습을 많이 한 것 같다.
#pragma once
#include "CoreMinimal.h"
/** <사용법>
1) 사용할 cpp 파일에 #include "ParcelLog.h" 하고,
2) 사용할 cpp 파일 #include 묶음 아래에 DEFINE_LOG_CATEGORY(사용할 로그 이름); 입력하고,
3) ParcelLog.h에 (1)외부 선언, (2) 파트별 전용 로그 매크로 정의를 적어 넣으면 됩니다.
<언리얼 콘솔창(~)에서>
1) Log LogParcelCharacter none, Log LogParcelMovement none, .... 이런식으로 입력하면 관련 로그를 끌 수 있습니다.
2) 반대로 Log LogParcelCharacter all, ... 이런식으로 입력하면 관련 로그만 켤 수 있습니다.
*/
// ==========================================================================
// [DECLARE] 로그 카테고리 외부 선언
// ==========================================================================
// Character
DECLARE_LOG_CATEGORY_EXTERN(LogCharacter, Log, All);
DECLARE_LOG_CATEGORY_EXTERN(LogParcelMovementStat, Log, All);
DECLARE_LOG_CATEGORY_EXTERN(LogParcelInteraction, Log, All);
DECLARE_LOG_CATEGORY_EXTERN(LogHeroComp, Log, All);
// Delivery
DECLARE_LOG_CATEGORY_EXTERN(LogDeliveryBox, Log, All);
DECLARE_LOG_CATEGORY_EXTERN(LogDeliveryZone, Log, All);
DECLARE_LOG_CATEGORY_EXTERN(LogDeliveryPhysics, Log, All);
DECLARE_LOG_CATEGORY_EXTERN(LogDeliverySubsystem, Log, All);
// ==========================================================================
// [define] 파트별 전용 로그 매크로 정의
// ==========================================================================
// Character
#define PLAYER_LOG(Verbosity, Format, ...) UE_LOG(LogCharacter, Verbosity, Format, ##__VA_ARGS__)
#define MOVEMENT_LOG(Verbosity, Format, ...) UE_LOG(LogParcelMovementStat, Verbosity, Format, ##__VA_ARGS__)
#define INTERACT_LOG(Verbosity, Format, ...) UE_LOG(LogParcelInteraction, Verbosity, Format, ##__VA_ARGS__)
#define HEROCOMP_LOG(Verbosity, Format, ...) UE_LOG(LogParcelInteraction, Verbosity, Format, ##__VA_ARGS__)
// Delivery
#define DELIVERYBOX_LOG(Verbosity, Format, ...) UE_LOG(LogParcelInteraction, Verbosity, Format, ##__VA_ARGS__)
#define DELIVERYZONE_LOG(Verbosity, Format, ...) UE_LOG(LogParcelInteraction, Verbosity, Format, ##__VA_ARGS__)
#define PHYSICSJUDGE_LOG(Verbosity, Format, ...) UE_LOG(LogParcelInteraction, Verbosity, Format, ##__VA_ARGS__)
#define DELIVERYSUBSYSTEM_LOG(Verbosity, Format, ...) UE_LOG(LogParcelInteraction, Verbosity, Format, ##__VA_ARGS__)