일정표
| 시간 | 할 일 | 비고 |
| 08:30~10:00 | 코드카타 | |
| 10:00~13:00 | 오늘의 수학 / 팀프로젝트 / 개인 프로젝트 | |
| 14:00~18:00 | 2시 스크럼 / 팀프로젝트 / 개인 프로젝트 | |
| 19:00~21:00 | 저녁 스크럼 / 개인 프로젝트 | |
| 21:00~22:00 | 운동 | |
| 22:00~23:30 | 개인 프로젝트 |
*오늘의 코드카타*
문제. 2016
2016년 1월 1일은 금요일입니다. 2016년 a월 b일은 무슨 요일일까요? 두 수 a ,b를 입력받아 2016년 a월 b일이 무슨 요일인지 리턴하는 함수, solution을 완성하세요. 요일의 이름은 일요일부터 토요일까지 각각 SUN,MON,TUE,WED,THU,FRI,SAT
#include <string>
#include <vector>
#include <numeric>
using namespace std;
string solution(int a, int b) {
int months[] = {0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
string days[] = {"THU", "FRI", "SAT", "SUN", "MON", "TUE", "WED"};
int total_days = 0;
for (int i = 1; i < a; ++i) {
total_days += months[i];
}
total_days += b;
return days[total_days % 7];
}
1) days 배열의 시작 인덱스 설정: 1월 1일은 총 일수가 1이다. 1 % 7 = 1이므로 days[1]이 "FRI"가 되도록 배열의 0번째 칸을 "THU"로 설정한다.
2) 시간 복잡도 O(1): 최댓값은 12로 매우 작기 때문에 반복문이 최대 11번만 수행된다.
3) 공간 복잡도 O(1): 크기가 13인 정수 배열과 크기가 7인 문자열 배열만 사용하므로 추가적인 메모리 할당이나 오버헤드가 발생하지 않는다.
*수학 공부*
주제 : 노이즈 알고리즘 (Noise Algorithm)
산맥, 구름, 나무의 나이테 같은 자연물은 자연스러운 랜덤을 띠고 있다. 이 자연의 수학을 컴퓨터로 모방해 낸 알고리즘을 알아보자.
1. 펄린 노이즈(Perlin Noise)
수학적 원리 : 기울기(Gradient) 노이즈
일반 랜덤이 점(Pixel)마다 무작위 숫자를 뱉는다면, 펄린 노이즈는 격자(Grid)의 모서리마다 무작위 방향의 벡터를 심어놓는다. 그리고 격자 안의 임의의 점의 높이값을 구할 때, 모서리 벡터들과의 내적을 구하고 그 값들을 보간(Interpolation)한다. 결과적으로 인접한 픽셀들은 같은 모서리 벡터의 영향을 받기 때문에, 값이 갑자기 널뛰지 않고 부드러운 언덕과 골짜기를 만들어낸다.
다만, 단순하게 펄린 노이즈를 한 겹 사용하면 그냥 둥그스름한 언덕을 만들 수 있을 뿐이다. 거대한 산맥과 바위, 자갈 느낌을 내려면 수학적으로 '프랙탈(Fractal) 개념을 써서 여러 겹의 노이즈를 겹쳐야 한다.
2. 프랙탈 노이즈(Fractal Noise)
프랙탈의 3대 파라미터(암기)
1) 옥타브(Octaves) : 노이즈를 몇 겹으로 겹칠 것인가? (4~6 겹 정도 겹쳐야 산맥처럼 보임)
2) 라쿠나리티(Lacunarity) : 겹칠 때마다 지형을 얼마나 '조밀하기(Frequencyt)' 구길 것인가? (보통 주름을 2.0배씩 늘림)
3) 퍼시스턴스(Persistence) : 겹쳐진 잔주름의 영향력(Amplitude, 높이)을 얼마나 줄일 것인가? (보통 0.5배씩 깎아서 바위나 자갈이 산맥 전체의 높이를 뚫고 나오지 않도록 제어함)
3. 심플렉스 노이즈(Simplex Noise)
펄린 노이즈는 단점 두가지가 있었는데,
1) 방향성 아티팩터 : 사각형 격자를 사용하다보니 맵을 멀리서 보면 십자가 모양으로 결이지는 어색한 현상이 생김
2) 3D/4D 연산시 마칭 큐브로 3D 노이즈를 적용하면 사각형 모서리를 계산할 때 꼭짓점이 매우 많아져 연산이 무거움
따라서 격자를 사각형이 아니라 삼각형(Simplex)으로 바꿔버린 것이 심플렉스 노이즈이다. 연산량은 적어지고, 대각선 방향으로도 부드러우며, 3D/4D 차원 확장이 빠르다. 현재 3D 오픈월드 게임의 대부분은 이 심플렉스 노이즈 알고리즘을 코어로 사용한다.
(사례 1 : 3D 심플렉스 노이즈를 활용한 동굴 밀도 계산)
#include "Math/UnrealMathUtility.h"
// 자체적인 심플렉스 노이즈 라이브러리 (또는 FastNoiseLite 플러그인)가 있다고 가정
float GetSimplexNoise3D(float X, float Y, float Z);
float AMyTerrainGenerator::CalculateDensityAt(FVector Location)
{
float TotalNoise = 0.0f;
float Frequency = 1.0f / BaseScale; // 지형의 넓이 조절
float Amplitude = 1.0f; // 지형의 높이 조절
float MaxAmplitude = 0.0f;
// 프랙탈의 마법: 옥타브(레이어)만큼 반복하며 주름을 쌓아 올림
for (int32 i = 0; i < Octaves; ++i)
{
// 3D 공간(X, Y, Z)에 노이즈 함수 적용 (-1.0 ~ 1.0 사이 값 반환)
float NoiseVal = GetSimplexNoise3D(Location.X * Frequency, Location.Y * Frequency, Location.Z * Frequency);
TotalNoise += NoiseVal * Amplitude;
MaxAmplitude += Amplitude;
// 다음 레이어 준비 (라쿠나리티와 퍼시스턴스 적용)
Amplitude *= Persistence; // 영향력은 줄어들고 (거칠기)
Frequency *= Lacunarity; // 빈도는 촘촘해짐 (디테일)
}
// -1.0 ~ 1.0 사이로 값 정규화
TotalNoise /= MaxAmplitude;
// 높이(Z축)가 올라갈수록 밀도(Density)를 깎아서 지붕이 생기도록(하늘과 맞닿도록) 유도
float Density = TotalNoise - (Location.Z * HeightFalloffMultiplier);
return Density; // 이 값이 0보다 크면 흙이 되고, 0보다 작으면 동굴(빈 공간)이 됨
}
4. 컬 노이즈 (Curl Noise)
노이즈 알고리즘은 유체 시뮬레이션, VFX(시각 효과) 파트에서 많이 사용되는데, 실제로 파티클 시스템이나 구름 렌더링에 많이 쓰이는 '컬 노이즈'를 알아보자.
심플렉스 노이즈는 특정 좌표에 넣으면 하나의 스칼라 값을 뱉어내는 함수이다. 높이를 결정하거나 밀도를 결정하기에 이 방식을 사용한다. 하지만, 이 노이즈 값을 수학적으로 비틀면, '높이'가 아니라 '방향(Vector)'을 만들어낼 수 있게 된다. 이를 벡터장(Vector Field)이라고 부른다.
단순하게 3D 심플렉스 노이즈를 X, Y, Z 방향으로 3번 돌려서 무작위 바람(벡터)을 만들었다고 가정해보면, 여기에 파티클 수만개를 뿌리는 순간 어느 순간 한 점으로 수렴하거나 밖으로 발산해버리게 된다. 진짜 유체는 소용돌이치면서 계속 돌아야 된다는 것을 생각해보면, 이렇게 단순한 심플렉스 노이즈를 활용한 방법은 압력이 보존되지 않는다.
따라서 컴퓨터 그래픽스에서는 물리학의 유체 역학(나비에-스토크스 방정식)에서, "어떤 임의의 벡터장에 회전(Curl) 연산을 걸어주면, 그 결과물은 수학적으로 절대 수렴하거나 발산하지 않는 완벽한 비압축성(Incompressible) 유체의 흐름이 된다" 라는 아이디어를 빌려오게된다. 즉, 심플렉스 노이즈로 만든 벡터장에 미적분학의 컬 연산을 수학적으로 적용하는 것이다.
이렇게 만들어낸 컬 노이즈 벡터장에 파티클이나 연기를 뿌리면 파티클이 수렴/발산을 하지 않고 소용돌이치며 끊임없이 섞이는 사실적인 난기류(Turbulence)가 만들어진다.
이 '컬 연산'은 언리얼 엔진 내부의 나이아가라 파티클 시스템에도 적용되어있다!
'Curl Noise Force 모듈'을 추가하고 나이아가라 파티클 시스템을 사용하는 것이다. 이 모듈을 키게 되면 C++ 코드가 매 프레임마다 심플렉스 노이즈를 미분하여 컬 벡터를 뽑아내고, 수만 개의 파티클을 그 궤적에 따라 움직이게 한다.
(사례 2 : 컬 노이즈 도출 수학)
float3 PotentialField(float3 Position) {
return float3(
SimplexNoise3D(Position.x, Position.y, Position.z),
SimplexNoise3D(Position.y + 123.4, Position.z, Position.x),
SimplexNoise3D(Position.z + 567.8, Position.x, Position.y)
);
}
// 2. 미적분의 외적(Cross Product)과 편미분을 활용한 컬(Curl) 연산
// 이 연산을 거치면 '절대 뭉치지 않는 완벽한 소용돌이 바람'이 탄생함
float3 ComputeCurlNoise(float3 P) {
float e = 0.001; // 미분을 위한 아주 작은 오차값
// x축 방향 편미분
float3 dx = (PotentialField(P + float3(e, 0, 0)) - PotentialField(P - float3(e, 0, 0))) / (2.0 * e);
// y축 방향 편미분
float3 dy = (PotentialField(P + float3(0, e, 0)) - PotentialField(P - float3(0, e, 0))) / (2.0 * e);
// z축 방향 편미분
float3 dz = (PotentialField(P + float3(0, 0, e)) - PotentialField(P - float3(0, 0, e))) / (2.0 * e);
// 컬 연산 공식 (행렬식 기반)
return float3(
dy.z - dz.y,
dz.x - dx.z,
dx.y - dy.x
);
}
*팀 프로젝트*
1. 시작하기에 앞서 개념 공부
생략
2. 지난 코드 리뷰 및 피드백
1. IA_Interact 바인딩 과정에서 IA, IMC, BP_ParcelCharacter에 수정사항이 있었습니다.
2. 난잡한 주석을 조금 정리했습니다.
3. HeroComponent에 있던 하드코딩 요소(기존 ParcelCharacter에서 받아옴)를 제거하고, MovementStatComponent로 옮겨서 캐릭터 속도 파라미터는 해당 컴포넌트가 관리하도록 분리했습니다.
4. RagdollComponent의 UE_LOG를 RAGDOLL_LOG 방식으로 수정했습니다. + 헤더 인클루드쪽의 주석을 제거했습니다.
5. DeliveryBox가 담당하던 물리 관련 상호작용을 대거 삭제했습니다. 상자는 GameplayTag 기능을 이용한 상태만 담고 있고, 운반 상호작용은 캐릭터의 CharacterCarryComponent가 담당합니다.
6. CharacterCarryComponent의 BeginPlay() 함수에 있던 '속도'관련 함수를 MovementStatComponent에서 관리하도록 이전시켰습니다.
멀티플레이를 위해 Rep 처리(GetLifetimeReplicatedProps)를 했고, 포인터를 TObjectPtr<> 형식으로 수정했습니다.
추가로 물리 콜리전과 물리 작용 켜고 끄기를 담당하기 위해 PreviousCarriedBox를 선언하고 관련함수를 수정했습니다(nullptr 버그 예방).
7. MovementStatComponent에서도 일부 변수 이름 변경이 있었습니다(기존에 코드 작성할 때 서버와의 연결을 고려하지 않아서 오류가 있었음)
8. ParcelLog는 파일 내부 주석에 사용법 적어놨으니 편하게 사용하실 수 있을 것 같습니다.
3. 팀원 코드 리뷰 및 중요 개념 습득하기
생략 (다른 팀원이 작성한 코드가 없음)
4. 내 코드 + 팀원 코드 피드백 후 수정하기
1단계: 구형 코드 청소 및 인터페이스 표준화
구형 UInteractionComponent 제거 및 통합: Delivery 폴더에 잘못 남아있던 매 프레임 틱(Tick)을 쓰던 구형 컴포넌트를 완전히 삭제. 대신 성능이 압도적으로 좋은 0.1초 타이머 기반의 UParcelInteractionComponent로 기능을 일원화.
델리게이트 구출: 구형 컴포넌트에 있던 조준 대상 변경 알림용 OnFocusChanged 멀티캐스트 델리게이트를 신형 컴포넌트로 안전하게 이전. (나중에 크로스헤더 UI에 " [E] 상자 줍기 " 띄울 때 쓸 핵심 부품)
Execute_ 인터페이스 표준 적용: 블루프린트 구현체와의 완벽한 호환 및 크래시 방지를 위해 일반 C++ 클래스 캐스팅 방식을 지우고, 언리얼 표준인 IInteractableInterface::Execute_CanInteract / Execute_Interact 정적 호출 방식으로 전면 교체.
2단계: 플레이어 상태 전담 컴포넌트(UParcelPlayerStateComponent) 설계
상태 창고를 완벽하게 분리.
PlayerState 분리 정당화: 걷기, 뛰기, 들기 같은 휘발성 상태를 따로 관리
UParcelPlayerStateComponent 탄생: 오직 플레이어의 실시간 상태 태그(FGameplayTagContainer)만 전문적으로 관리하고 네트워크 복제(Replication)를 담당하는 컴포넌트를 새로 만들어서 캐릭터에 부착. AParcelCharacter는 부품 조립만 하는 순수한 컨테이너(껍데기) 상태를 유지.
치명적 빌드 에러 해결: 로그를 찍을 때 상세 수치 인자에 All을 넣으면 컴파일러가 뻗어버리는 문제를 발견해서 Log로 싹 고쳐서 빌드 가로막기를 해결.
멀티플레이 인풋 먹통 방지: 원격 클라이언트 유저가 접속했을 때 키보드 조작이 먹통이 되지 않도록, 캐릭터의 OnRep_Controller() 함수를 오버라이드해서 클라이언트 기기에서 컨트롤러가 복제되는 순간 인풋 매핑 컨텍스트(IMC)를 안전하게 바인딩하도록 구현.
3단계: 하드코딩 수치 및 컴포넌트 간 결합도 완전 파괴 (Decoupling)
C++ 단에서 함수 인자로 이리저리 수치를 배달하던 구시대적 방식을 버리고, 모듈형 구조로 진화.
UpdateDynamicSpeedModifier(bool, float) ➔ RefreshMoveSpeed()로 개조: 뇌(HeroComp)가 손(CarryComp)의 무게 배율을 가로채서 발(MovementStatComp)에 직접 배달해 주던 지저분한 중계 구조 개선
독립적 새로고침 체계: 이제 무브먼트 컴포넌트의 RefreshMoveSpeed()는 인자를 전혀 받지 않음. 함수가 호출되면 스스로 상태 창고 컴포넌트를 열어 Sprinting 태그가 있는지 검사하고, 양손 컴포넌트를 열어 상자의 무게 배율을 직접 가져와서 MaxWalkSpeed를 산출하지. 1.0f 같은 하드코딩 느낌의 예외 처리 변수 소멸.
4단계: 실시간 게임플레이 태그 생명주기 및 조작 차단(Block) 구축
애니메이션이 밀리지 않도록 실제 상태가 변하는 물리/인풋 타이머 최전선에 태그 제어를 연동.
달리기 태그: UParcelHeroComponent::ApplySprintSpeed에서 오직 서버 권한으로 Character.State.Sprinting 태그를 넣고 빼게 조립.
래그돌 예외 처리: 막 달리다가 기절해서 래그돌(URagdollComponent)이 되었을 때 찌꺼기 태그가 남는 버그를 예방하기 위해, ApplyStartRagdoll 내부에서 달리기 태그를 강제로 지워주는 방어 코드를 심음.
상자 들기 태그: UCharacterCarryComponent에서 상자를 완벽히 집었을 때(Pickup) Character.State.Carrying 태그를 ON 하고, 놓거나 던질 때(Drop, Throw) 태그를 OFF 하도록 연동한 뒤 RefreshMoveSpeed()를 실행. 이 덕분에 상자를 던지면 속도가 자동으로 알아서 원상복구 되는 흐름이 완성됨.
물리 기반 점프 태그: 점프 버튼 입력에 의존하지 않고, 추락이나 착지 등 진짜 공중 상태를 판정하기 위해 AParcelCharacter 내부의 내장 함수인 OnJumped_Implementation()과 Landed()를 오버라이드하여 Character.State.InAir 태그의 라이프사이클을 통제.
인풋 필터링: 확장성을 위해 HeroComponent에서 점프나 상호작용을 할 때, 상태 창고에 Action.Throwing이나 State.InAir 태그가 있다면 인풋을 즉시 리턴(차단) 시키는 뼈대를 구성.
Normal 태그 생략: Character.State.Normal 같은 태그를 따로 만드는 것은 태그 관리 지옥을 부르는 안티패턴임을 이해하고, "창고가 비어있거나 특정 액션 태그가 없는 상태" 그 자체를 평시 상태로 정의. 애님 블루프린트의 Blend Poses by Gameplay Tag 노드의 Default 핀이 알아서 처리해줌.
[1. DeliveryBox]
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "GameplayTagContainer.h"
#include "DeliveryTypes.h"
#include "Delivery/CarryableInterface.h"
#include "Delivery/InteractableInterface.h"
#include "DeliveryBox.generated.h"
class UBoxComponent;
class UStaticMeshComponent;
UCLASS()
class PARCEL_KNIGHT_API ADeliveryBox : public AActor, public ICarryableInterface, public IInteractableInterface
{
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] ICarryableInterface 오버라이드
virtual bool CanCarry(AActor* Carrier) override;
virtual void OnPickedUp(AActor* Carrier) override;
virtual void OnDropped() override;
// [Interface] IInteractableInterface 오버라이드
virtual bool CanInteract_Implementation(AActor* Interactor) override;
virtual void Interact_Implementation(AActor* Interactor) override;
FORCEINLINE TWeakObjectPtr<class AParcelPlayerState> GetLastCarrierPlayerState() const { return LastCarrierPlayerState; }
// 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;
// Pawn 을 복제해서 모든 유저가 들기 상태를 인지
UPROPERTY(Replicated)
TObjectPtr<APawn> HoldingCarrier;
// [Server] 서버에서 스코어링 판정용 PlayerState 캐시
TWeakObjectPtr<AParcelPlayerState> LastCarrierPlayerState;
};
#include "Delivery/DeliveryBox.h"
#include "ParcelLog.h"
#include "Components/StaticMeshComponent.h"
#include "Components/BoxComponent.h"
#include "Delivery/PhysicsJudgeManager.h"
#include "Character/CharacterCarryComponent.h"
#include "Core/ParcelPlayerState.h"
#include "Net/UnrealNetwork.h"
DEFINE_LOG_CATEGORY(LogDeliveryBox);
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, HoldingCarrier);
}
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")));
DELIVERYBOX_LOG(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);
}
DELIVERYBOX_LOG(Log, TEXT("[Client] %d번 상자의 외형 데이터 동기화 완료. 상자 타입 태그: %s"),
BoxID, *BoxData.BoxTypeTag.ToString());
}
void ADeliveryBox::AddStateTag(FGameplayTag NewStateTag)
{
if (!HasAuthority() || !NewStateTag.IsValid()) return;
if (!BoxStateTags.HasTagExact(NewStateTag))
{
BoxStateTags.AddTag(NewStateTag);
DELIVERYBOX_LOG(Log, TEXT("[Server] %d번 상자에 새로운 상태 태그 추가됨: %s"), BoxID, *NewStateTag.ToString());
//플레이어가 상자를 들면 상자의 물리 규칙은 잠시 꺼야 함.
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);
DELIVERYBOX_LOG(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()
{
FGameplayTag DamagedTag = FGameplayTag::RequestGameplayTag(TEXT("Box.State.Damaged"));
DELIVERYBOX_LOG(Log, TEXT("[Client] %d번 상자의 상태 태그 컨테이너 갱신됨. 현재 태그 목록: %s"),
BoxID, *BoxStateTags.ToString());
// 클라이언트 사이드 물리 콜리전 켜고 끄기는 CharacterCarryComponent가 담당하도록 이전
if (BoxStateTags.HasTag(DamagedTag))
{
// Todo : 찌그러진 메쉬로 교체하거나 내용물이 튀어나오는 이펙트/사운드 호출
}
}
/* ==========================================================================
ICarryableInterface 인터페이스
========================================================================== */
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))
{
HoldingCarrier = CarrierPawn;
SetOwner(CarrierPawn);
if (APlayerController* PC = Cast<APlayerController>(CarrierPawn->GetController()))
{
LastCarrierPlayerState = PC->GetPlayerState<AParcelPlayerState>();
}
DELIVERYBOX_LOG(Log, TEXT("[Server] %d번 상자 획득 처리 완료. 소유 캐릭터: %s"),
BoxID, *HoldingCarrier->GetName());
}
RemoveStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Spawned")));
AddStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Held")));
}
void ADeliveryBox::OnDropped()
{
if (!HasAuthority()) return;
DELIVERYBOX_LOG(Log, TEXT("[Server] %d번 상자 낙하 처리 완료."), BoxID);
HoldingCarrier = 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;
DELIVERYBOX_LOG(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);
}
}
}
/* ==========================================================================
IInteractableInterface 인터페이스
========================================================================== */
bool ADeliveryBox::CanInteract_Implementation(AActor* Interactor)
{
return CanCarry(Interactor);
}
void ADeliveryBox::Interact_Implementation(AActor* Interactor)
{
if (!Interactor) return;
// 상태를 Held 태그로 바꿈
OnPickedUp(Interactor);
// CharacterCarryComponent 호출하여 손에 붙임
if (UCharacterCarryComponent* CharacterCarryComp = Interactor->FindComponentByClass<UCharacterCarryComponent>())
{
CharacterCarryComp->Pickup(this);
}
}
[2. ParcelHeroComponent]
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "InputActionValue.h"
#include "ParcelHeroComponent.generated.h"
class USpringArmComponent;
class UCameraComponent;
class UInputMappingContext;
class UInputAction;
UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class PARCEL_KNIGHT_API UParcelHeroComponent : public UActorComponent
{
GENERATED_BODY()
public:
UParcelHeroComponent();
virtual void BeginPlay() override;
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
// 캐릭터의 입력을 바인딩 (SetupPlayerInputComponent 시점에 호출)
void InitializePlayerInput(UInputComponent* PlayerInputComponent);
void AddInputMappingContext();
void ResetCameraAttachment();
protected:
// 카메라 컴포넌트 (인게임에 시점 변경이 필요하다면 이것도 분리 가능)
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
TObjectPtr<USpringArmComponent> SpringArm;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
TObjectPtr<UCameraComponent> FollowCamera;
// EnhanceInput (인게임에서 조작 변경이 필요하다면 이것도 분리 가능)
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
TObjectPtr<UInputMappingContext> InputMappingContext;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
TObjectPtr<UInputAction> MoveAction;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
TObjectPtr<UInputAction> LookAction;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
TObjectPtr<UInputAction> JumpAction;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
TObjectPtr<UInputAction> SprintAction;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
TObjectPtr<UInputAction> RagdollAction;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
TObjectPtr<UInputAction> InteractAction;
void Move(const FInputActionValue& Value);
void Look(const FInputActionValue& Value);
void StartJump(const FInputActionValue& Value);
void StopJump(const FInputActionValue& Value);
void StartSprint(const FInputActionValue& Value);
void StopSprint(const FInputActionValue& Value);
void TestRagdoll(const FInputActionValue& Value);
void Interact(const FInputActionValue& Value);
private:
// 달리기 기능을 위한 Server RPC
UFUNCTION(Server, Reliable)
void ServerSetSprinting(bool bNewIsSprinting);
void ApplySprintSpeed(bool bNewIsSprinting);
bool IsRagdollCloseToGround() const;
bool CanProcessLocalInput() const;
};
#include "Character/ParcelHeroComponent.h"
#include "ParcelLog.h"
#include "GameFramework/Character.h"
#include "GameFramework/PlayerController.h"
#include "GameFramework/SpringArmComponent.h"
#include "Camera/CameraComponent.h"
#include "EnhancedInputSubsystems.h"
#include "EnhancedInputComponent.h"
#include "Character/ParcelCharacter.h"
#include "Character/RagdollComponent.h"
#include "Character/ParcelInteractionComponent.h"
#include "Character/ParcelMovementStatComponent.h"
#include "Character/CharacterCarryComponent.h"
DEFINE_LOG_CATEGORY(LogHeroComp);
namespace
{
constexpr float RagdollCameraHeightOffset = 20.f;
constexpr float RagdollCameraBackOffset = 90.f;
constexpr float RagdollStopGroundTraceDistance = 120.f;
constexpr float CameraCollisionProbeSize = 18.f;
}
UParcelHeroComponent::UParcelHeroComponent()
{
PrimaryComponentTick.bCanEverTick = true;
PrimaryComponentTick.bStartWithTickEnabled = false;
// [Server] : 컴포넌트에서 Server RPC 가동
SetIsReplicatedByDefault(true);
// 카메라
SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm"));
SpringArm->TargetArmLength = 350.f;
SpringArm->bDoCollisionTest = true;
SpringArm->ProbeChannel = ECC_Camera;
SpringArm->ProbeSize = CameraCollisionProbeSize;
SpringArm->bUsePawnControlRotation = true;
SpringArm->bEnableCameraLag = true;
SpringArm->CameraLagSpeed = 10.f;
FollowCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("FollowCamera"));
FollowCamera->bUsePawnControlRotation = false;
}
void UParcelHeroComponent::BeginPlay()
{
Super::BeginPlay();
// 부모 캐릭터 루트에 카메라 부착
if (ACharacter* Character = Cast<ACharacter>(GetOwner()))
{
SpringArm->AttachToComponent(Character->GetRootComponent(), FAttachmentTransformRules::SnapToTargetNotIncludingScale);
FollowCamera->AttachToComponent(SpringArm, FAttachmentTransformRules::SnapToTargetNotIncludingScale, USpringArmComponent::SocketName);
HEROCOMP_LOG(Log, TEXT("[%s] 캐릭터에 카메라 컴포넌트 부착 완료."), *Character->GetName());
}
AddInputMappingContext();
}
void UParcelHeroComponent::ResetCameraAttachment()
{
if (ACharacter* Character = Cast<ACharacter>(GetOwner()))
{
if (SpringArm)
{
SpringArm->AttachToComponent(Character->GetRootComponent(), FAttachmentTransformRules::SnapToTargetNotIncludingScale);
SpringArm->SetRelativeLocation(FVector::ZeroVector);
}
}
}
void UParcelHeroComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
ACharacter* Character = Cast<ACharacter>(GetOwner());
if (!Character) return;
URagdollComponent* RagdollComp = Character->FindComponentByClass<URagdollComponent>();
// 래그돌 상태가 활성화 되었을 때만 카메라 연산 가동함(카메라 보정)
if (Character->IsLocallyControlled() && RagdollComp && RagdollComp->IsRagdoll() && Character->GetMesh() && SpringArm)
{
const FVector HeadLocation = Character->GetMesh()->GetSocketLocation(TEXT("head"));
const FRotator ViewRotation = Character->GetController() ? Character->GetController()->GetControlRotation() : Character->GetActorRotation();
const FVector CameraBackDirection = -FRotationMatrix(ViewRotation).GetUnitAxis(EAxis::X);
const FVector TargetLocation = HeadLocation + FVector::UpVector * RagdollCameraHeightOffset + CameraBackDirection * RagdollCameraBackOffset;
SpringArm->SetWorldLocation(TargetLocation);
}
}
void UParcelHeroComponent::InitializePlayerInput(UInputComponent* PlayerInputComponent)
{
AddInputMappingContext();
if (!CanProcessLocalInput()) return;
// IA 바인딩
UEnhancedInputComponent* EnhancedInputComponent = Cast<UEnhancedInputComponent>(PlayerInputComponent);
if (!EnhancedInputComponent) return;
if (MoveAction) EnhancedInputComponent->BindAction(MoveAction, ETriggerEvent::Triggered, this, &UParcelHeroComponent::Move);
if (LookAction) EnhancedInputComponent->BindAction(LookAction, ETriggerEvent::Triggered, this, &UParcelHeroComponent::Look);
if (JumpAction)
{
EnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Started, this, &UParcelHeroComponent::StartJump);
EnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Completed, this, &UParcelHeroComponent::StopJump);
}
if (SprintAction)
{
EnhancedInputComponent->BindAction(SprintAction, ETriggerEvent::Started, this, &UParcelHeroComponent::StartSprint);
EnhancedInputComponent->BindAction(SprintAction, ETriggerEvent::Completed, this, &UParcelHeroComponent::StopSprint);
EnhancedInputComponent->BindAction(SprintAction, ETriggerEvent::Canceled, this, &UParcelHeroComponent::StopSprint);
}
if (RagdollAction) EnhancedInputComponent->BindAction(RagdollAction, ETriggerEvent::Started, this, &UParcelHeroComponent::TestRagdoll);
if (InteractAction)
{
EnhancedInputComponent->BindAction(InteractAction, ETriggerEvent::Started, this, &UParcelHeroComponent::Interact);
}
HEROCOMP_LOG(Log, TEXT("Enhanced Input 바인딩 완료."));
}
void UParcelHeroComponent::Move(const FInputActionValue& Value)
{
ACharacter* Character = Cast<ACharacter>(GetOwner());
if (!CanProcessLocalInput() || !Character || !Character->GetController()) return;
// [Add] 방어 코드 : 래그돌 상태에서는 입력 처리 불가
URagdollComponent* RagdollComp = Character->FindComponentByClass<URagdollComponent>();
if (RagdollComp && RagdollComp->IsRagdoll()) return;
const FVector2D MoveValue = Value.Get<FVector2D>();
const FRotator ControlRotation = Character->GetController()->GetControlRotation();
const FRotator YawRotation(0.f, ControlRotation.Yaw, 0.f);
const FVector ForwardDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
const FVector RightDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);
Character->AddMovementInput(ForwardDirection, MoveValue.Y);
Character->AddMovementInput(RightDirection, MoveValue.X);
}
void UParcelHeroComponent::Look(const FInputActionValue& Value)
{
ACharacter* Character = Cast<ACharacter>(GetOwner());
if (!CanProcessLocalInput() || !Character) return;
const FVector2D LookValue = Value.Get<FVector2D>();
Character->AddControllerYawInput(LookValue.X);
Character->AddControllerPitchInput(LookValue.Y);
}
void UParcelHeroComponent::StartJump(const FInputActionValue& Value)
{
ACharacter* Character = Cast<ACharacter>(GetOwner());
if (CanProcessLocalInput() && Character) Character->Jump();
}
void UParcelHeroComponent::StopJump(const FInputActionValue& Value)
{
ACharacter* Character = Cast<ACharacter>(GetOwner());
if (CanProcessLocalInput() && Character) Character->StopJumping();
}
void UParcelHeroComponent::StartSprint(const FInputActionValue& Value)
{
ACharacter* Character = Cast<ACharacter>(GetOwner());
if (!Character) return;
URagdollComponent* RagdollComp = Character->FindComponentByClass<URagdollComponent>();
if (!CanProcessLocalInput() || (RagdollComp && RagdollComp->IsRagdoll())) return;
ApplySprintSpeed(true);
if (!Character->HasAuthority()) ServerSetSprinting(true);
}
void UParcelHeroComponent::StopSprint(const FInputActionValue& Value)
{
ACharacter* Character = Cast<ACharacter>(GetOwner());
if (!CanProcessLocalInput() || !Character) return;
ApplySprintSpeed(false);
if (!Character->HasAuthority()) ServerSetSprinting(false);
}
void UParcelHeroComponent::TestRagdoll(const FInputActionValue& Value)
{
ACharacter* Character = Cast<ACharacter>(GetOwner());
if (!CanProcessLocalInput() || !Character) return;
URagdollComponent* RagdollComp = Character->FindComponentByClass<URagdollComponent>();
if (!RagdollComp) return;
const bool bWasRagdoll = RagdollComp->IsRagdoll();
if (bWasRagdoll && !IsRagdollCloseToGround())
{
HEROCOMP_LOG(Warning, TEXT("래그돌 해제 실패 : 현재 공중에 떠 있는 상태입니다. (지면과 너무 멂)"));
return;
}
RagdollComp->ToggleRagdoll();
// 래그돌이 켜질 때만 컴포넌트 틱을 킴(Tick 최적화)
if (RagdollComp->IsRagdoll())
{
HEROCOMP_LOG(Log, TEXT("래그돌 상태 진입: 카메라 보정을 위한 컴포넌트 틱 활성화"));
PrimaryComponentTick.SetTickFunctionEnable(true);
}
else
{
HEROCOMP_LOG(Log, TEXT("래그돌 상태 해제: 카메라 위치 복구 및 컴포넌트 틱 비활성화"));
PrimaryComponentTick.SetTickFunctionEnable(false);
if (SpringArm)
{
SpringArm->AttachToComponent(Character->GetRootComponent(), FAttachmentTransformRules::SnapToTargetNotIncludingScale);
SpringArm->SetRelativeLocation(FVector::ZeroVector);
}
}
}
void UParcelHeroComponent::Interact(const FInputActionValue& Value)
{
if (!CanProcessLocalInput()) return;
HEROCOMP_LOG(Log, TEXT("상호작용 조작(E키) 감지: InteractionComponent 호출"));
// [Add] 방어 코드 : 캐릭터가 없으면 상호작용 중단
ACharacter* Character = Cast<ACharacter>(GetOwner());
if (!Character) return;
// [Add] 방어 코드 : 래그돌 도중에는 상호작용 불가
URagdollComponent* RagdollComp = Character->FindComponentByClass<URagdollComponent>();
if (RagdollComp && RagdollComp->IsRagdoll()) return;
// 장착된 InteractionComponent 호출
if (AParcelCharacter* OwnerChar = Cast<AParcelCharacter>(GetOwner()))
{
if (UParcelInteractionComponent* InteractComp = OwnerChar->GetParcelInteractionComponent())
{
InteractComp->PrimaryInteract();
}
}
}
void UParcelHeroComponent::ServerSetSprinting_Implementation(bool bNewIsSprinting)
{
HEROCOMP_LOG(Log, TEXT("[Server] 클라이언트의 요청으로 달리기 상태 변경 적용: %s"), bNewIsSprinting ? TEXT("True") : TEXT("False"));
ApplySprintSpeed(bNewIsSprinting);
}
void UParcelHeroComponent::ApplySprintSpeed(bool bNewIsSprinting)
{
if (UParcelMovementStatComponent* StatComp = GetOwner()->FindComponentByClass<UParcelMovementStatComponent>())
{
float CarrySubMultiplier = 1.0f;
if (UCharacterCarryComponent* CarryComp = GetOwner()->FindComponentByClass<UCharacterCarryComponent>())
{
CarrySubMultiplier = CarryComp->GetMoveSpeedMultiplier();
}
// MovementStat을 담당하는 매니저에 속도 계산 위임
StatComp->UpdateDynamicSpeedModifier(bNewIsSprinting, CarrySubMultiplier);
}
}
bool UParcelHeroComponent::IsRagdollCloseToGround() const
{
ACharacter* Character = Cast<ACharacter>(GetOwner());
if (!Character || !GetWorld()) return true;
const USkeletalMeshComponent* MeshComponent = Character->GetMesh();
if (!MeshComponent) return true;
const FVector TraceStart = MeshComponent->GetSocketLocation(TEXT("pelvis"));
const FVector TraceEnd = TraceStart - FVector::UpVector * RagdollStopGroundTraceDistance;
FCollisionQueryParams QueryParams(SCENE_QUERY_STAT(RagdollGroundTrace), false, Character);
FHitResult Hit;
return GetWorld()->LineTraceSingleByChannel(Hit, TraceStart, TraceEnd, ECC_Visibility, QueryParams);
}
void UParcelHeroComponent::AddInputMappingContext()
{
ACharacter* Character = Cast<ACharacter>(GetOwner());
if (!Character || !Character->IsLocallyControlled()) return;
APlayerController* PlayerController = Cast<APlayerController>(Character->GetController());
if (!PlayerController || !PlayerController->GetLocalPlayer()) return;
UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(PlayerController->GetLocalPlayer());
if (!Subsystem || !InputMappingContext) return;
Subsystem->RemoveMappingContext(InputMappingContext);
Subsystem->AddMappingContext(InputMappingContext, 0);
}
bool UParcelHeroComponent::CanProcessLocalInput() const
{
ACharacter* Character = Cast<ACharacter>(GetOwner());
return Character && Character->GetController() && Character->IsLocallyControlled();
}
[3. ParcelMovementStatComponent]
#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/Client] 상태 변화에 따른 속도 재계산 인터페이스 (예측 동기화 지원)
void UpdateDynamicSpeedModifier(bool bInSprinting, float InCarryMultiplier);
// [Server] 스탯 변경을 위한 세터
UFUNCTION(BlueprintAuthorityOnly, BlueprintCallable, Category = "Stats|Movement")
void SetBaseMaxWalkSpeed(float NewSpeed);
// 스탯 조회를 위한 게터
FORCEINLINE float GetMaxWalkSpeed() const { return MaxWalkSpeed; }
FORCEINLINE float GetJumpZVelocity() const { return JumpZVelocity; }
FORCEINLINE float GetAirControl() const { return AirControl; }
private:
// 클라이언트 값이 복제되어 바뀔 때 무브먼트 컴포넌트에 동기화해주는 RepNotify 함수
UFUNCTION() void OnRep_MaxWalkSpeed();
UFUNCTION() void OnRep_JumpZVelocity();
UFUNCTION() void OnRep_AirControl();
UFUNCTION() void OnRep_RotationRate();
void ApplyStatsToMovement();
// 속도 계산을 위한 기본 속도 스탯
UPROPERTY(EditDefaultsOnly, Category = "Stats|Movement")
float BaseMaxWalkSpeed;
UPROPERTY(EditDefaultsOnly, Category = "Stats|Movement")
float SprintSpeedMultiplier;
// 런타임에 계산되어 복제되는 속도 데이터
UPROPERTY(ReplicatedUsing = OnRep_MaxWalkSpeed, VisibleAnywhere, 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;
// 로컬 예측 및 서버 계산용 동적 상태 기록 상태 변수
bool bIsSprinting;
float CurrentCarryMultiplier;
};
#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);
BaseMaxWalkSpeed = 450.0f;
SprintSpeedMultiplier = 2.0f;
MaxWalkSpeed = BaseMaxWalkSpeed;
JumpZVelocity = 500.f;
AirControl = 0.35f;
RotationRate = FRotator(0.f, 540.f, 0.f);
bIsSprinting = false;
CurrentCarryMultiplier = 1.0f;
}
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::UpdateDynamicSpeedModifier(bool bInSprinting, float InCarryMultiplier)
{
bIsSprinting = bInSprinting;
CurrentCarryMultiplier = InCarryMultiplier;
// (로컬과 서버 컴포넌트에 주입) 속도 공식 계산식
float SprintMod = bIsSprinting ? SprintSpeedMultiplier : 1.0f;
MaxWalkSpeed = BaseMaxWalkSpeed * SprintMod * CurrentCarryMultiplier;
ApplyStatsToMovement();
}
void UParcelMovementStatComponent::SetBaseMaxWalkSpeed(float NewSpeed)
{
// [Server] 서버 권한이 있을 때만 수정 가능하도록 방어
if (!GetOwner() || !GetOwner()->HasAuthority()) return;
BaseMaxWalkSpeed = NewSpeed;
UpdateDynamicSpeedModifier(bIsSprinting, CurrentCarryMultiplier);
}
// 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] 통합 연산 속도(%f)가 적용되었습니다."),
OwnerCharacter->HasAuthority() ? TEXT("Server") : TEXT("Client"), MaxWalkSpeed);
}
}
}
4.5. 개선이 필요한 내용
1) OnRep_CarriedBox 물리 레이스 컨디션 방어 코드 추가 필요
서버에서는 ADeliveryBox::OnPickedUp이 먼저 실행되어 상자의 물리가 꺼진 뒤 부착되지만, 원격 클라이언트 기기에서는 BoxStateTags 복제 패킷과 CarriedBox 복제 패킷 중 무엇이 먼저 도착할지 순서를 보장할 수 없음.
만약 클라이언트에서 상자의 물리가 켜져 있는 상태에서 AttachToComponent가 실행되면, 언리얼 엔진 물리 엔진이 부착을 거부하거나 상자가 캐릭터 주위를 맴도는 버그가 생길 수 있음. 따라서 OnRep_CarriedBox 내부에서 부착 직전에 물리를 꺼주는 코드를 추가하는 것이 안전함.
2) UParcelInteractionComponent의 인터페이스 표준 안전 호출 (Execute_ 매크로)
IInteractableInterface는 현재 BlueprintNativeEvent로 선언되어 있음. C++ 상속 객체(ADeliveryBox)에는 일반 화살표 연산자(->Interact())가 작동하지만, 향후 디자이너나 팀원이 블루프린트로만 상호작용 오브젝트를 만들 경우 C++에서 해당 호출을 인식하지 못하고 무시하게 됨.
안전한 언리얼 표준 정적 호출 방식(IInteractableInterface::Execute_...)으로 변경이 필요함.
3) 구형 UInteractionComponent 파일 및 클래스 정리
소스코드 하단에 매 프레임 틱(Tick)을 돌며 FOnInteractionFocusChanged 델리게이트를 사용하는 구형 UInteractionComponent 클래스 정의가 여전히 남아있음. 이미 0.1초 타이머 기반의 UParcelInteractionComponent로 전환을 완료했기 때문에, 프로젝트 내에 헷갈리는 구형 클래스(UInteractionComponent.h / .cpp)가 남아있다면 협업 시 팀원들에게 혼선을 줄 수 있으니 과감히 삭제하는 것을 제안. 다만, UI에게 신호를 보내는 멀티캐스트델리게이트의 경우 UParcelInteractionComponent로 기능 이전이 필요함.
5. 작업한 내용들
[1. ParcelPlayerStateComponent 구현]
플레이어 상태 (StateTags) = 실시간 런타임 상태 (Dynamic Runtime State)
플레이어 캐릭터는 고정된 설계도가 필요하기보다, "지금 이 순간 무엇을 하고 있는가?"를 계속 기록하고 변하는 상태 창고가 필요함. 예를 들어, Shift를 누르면 달리기가 켜지고(Sprinting), 상자를 들면 들기 상태(Carrying)가 켜지는 방식이기 때문. 이 정보는 데이터 테이블에서 읽어오는 게 아니라, 게임플레이 도중 컴포넌트들의 상호작용에 의해 실시간으로 추가/제거되는 휘발성 데이터임. 따라서 Character 껍데기에 붙어서 캐릭터의 상태를 담아 놓는 컴포넌트를 추가로 구현하였음.
[2. ParcelStateComponent와 연동한 HeroComponent 수정]
1) 리슨 서버 호스트(방장)가 Shift를 누를 때: StartSprint ➔ ApplySprintSpeed(true) 호출 ➔ 서버 권한이 있으므로 속도가 바뀌는 동시에 즉시 StateComp에 Sprinting 태그가 박힘. (그 후 if (!HasAuthority()) 조건에 걸리지 않아 RPC를 안 쏘고 깔끔하게 종료)
2) 원격 접속 클라이언트가 Shift를 누를 때: StartSprint ➔ ApplySprintSpeed(true) 호출 (로컬 화면에서 속도가 즉시 빨라짐 - 예측) ➔ 클라이언트라 태그 로직은 패스 ➔ ServerSetSprinting(true) RPC 발송 ➔ 서버에서 RPC를 받아 ServerSetSprinting_Implementation 실행 ➔ 서버 월드의 ApplySprintSpeed(true)가 실행되며 해당 클라이언트의 StateComp에 Sprinting 태그를 박아줌 ➔ 이 태그가 복제(Replication)되어 전 유저에게 퍼짐
[3. 개선한 것들(3가지)]
1) OnRep_CarriedBox 물리 레이스 컨디션 방어 코드 추가
2) UParcelInteractionComponent의 인터페이스 표준 안전 호출 (Execute_ 매크로) 추가
3) 구형 UInteractionComponent 파일 및 클래스 정리 완료(다른 팀원이 해결)
4) Character에서 사용할 GameplayTags 정리
상태(State) vs 행동(Action)의 태그 계층을 나누어 분리했다.
Character
├── State (지속성: 켜고 끄는 스위치)
│ ├── Sprinting (달리기 중)
│ ├── Carrying (상자 들고 있음)
│ └── Crouching (앉아있음)
└── Action (순간성: 실행 후 끝나는 트리거)
├── Jumping (점프 중/공중에 떠 있음)
└── Throwing (상자 던지는 연출 중)
이후 리팩토링을 통해 다음과 같은 내용을 개선했다.
- 물리 기반 점프 (AParcelCharacter): 플레이어가 점프 키를 누르든 낭떠러지에서 떨어지든, 엔진이 캐릭터 상태를 판단하여 오직 서버 권한으로 Character.State.InAir 태그를 넣고 빼줌.
- 태그 기반 속도 제어 (UParcelMovementStatComponent): 이제 무브먼트 컴포넌트는 외부 파라미터에 의존하지 않고, 상태 창고를 들여다보며 현재 Sprinting 상태인지 분석하여 최대 속도를 실시간으로 산출함.
- 인풋 필터링 (UParcelHeroComponent): 공중 상태(InAir) 태그가 감지되면 상자 줍기(Interact) 조작을 씹어버리는 등, 완벽한 하드코딩 없는 확장성 방어 코드가 완성됨.
- UParcelMovementStatComponent와 UParcelHeroComponent 두 컴포넌트의 디커플링 : 인풋 컴포넌트에서는 태그만 관리하고, 무브먼트 컴포넌트가 알아서 태그와 무게를 검사해서 속도를 새로고침하는 방식으로 개조했음. (하드코딩 변수 완전제거) 이렇게 하면 나중에 상자를 들거나 놓을 때 CharacterCarryComponent에서 StartComp->RefreshMoveSpeed(); 한 줄만 호출해주면 알아서 무브먼트스탯컴포넌트가 태그와 상자 무게를 계산하게 됨.
*개인 프로젝트 및 개인 공부*
1. 하네스 / 바이브 코딩 도입(개인 프로젝트 속도 내기 위함)
2. 전체 인터페이스,컴포넌트,서브시스템,데이터에셋,데이터테이블,UI/Dialogue/Widget,게임모드,게임인스턴스,게임스테이트,플레이어스테이트,플레이어컨트롤러,플레이어캐릭터,NPC(base 기반),몬스터,Furniture,Guild,Audio,Animation,물리작용 manager 등등 구조 클래스 다이어그램, 시퀀스 다이어그램 작성하기(최대한 자세하게)
3. 추가 제안 : gameplayTag 관리 방법 : FGameplayTags:: 로 계속 호출하면 map 탐색을 매번 해야해서 불필요한 비용이 발생하므로 구조체 또는 헤더 파일로 별도 분리하고 한번만 호출하고 탐색하도록 만드는 방법이 필요.
*오늘의 총평*
오늘은 하루종일 리팩토링만 했다. GameplayTag가 적용되지 않은 파트가 많아서 전부 살펴보고 고쳤다. 덕분에 많은 것을 배우고 한번 더 코드들을 살펴보면서 어떻게 구조가 이루어져있는지 보고, 진짜 이제 내것이 된 것 같은 생각이 든다. 이제 누구한테 설명해줄 정도로 공부했으니 앞으로 더 발전해서 실제 상용게임까지 만들어낼 정도로 빠르게 진도를 나가보려 한다.