일정표
| 시간 | 할 일 | 비고 |
| 08:00~10:00 | 코드카타, 팀프로젝트 | |
| 10:00~12:00 | TA 수업 및 복습 | |
| 12:00~13:00 | 팀프로젝트 | |
| 14:00~18:00 | 2시 스크럼, 팀프로젝트 | |
| 19:00~21:00 | 7시 스크럼, 팀프로젝트 | |
| 21:00~22:00 | 운동 | |
| 22:00~23:30 | 개인 프로젝트 |
*오늘의 코드카타*
문제. 콜라 문제
이 문제는 빈 병 a개를 가져다주면 콜라 b병을 주는 마트가 있을 때, 빈 병 n개를 가져다주면 몇 병을 받을 수 있는지 계산하는 문제입니다. 기존 콜라 문제와 마찬가지로, 보유 중인 빈 병이 a개 미만이면, 추가적으로 빈 병을 받을 순 없습니다. 상빈이는 열심히 고심했지만, 일반화된 콜라 문제의 답을 찾을 수 없었습니다. 상빈이를 도와, 일반화된 콜라 문제를 해결하는 프로그램을 만들어 주세요
콜라를 받기 위해 마트에 주어야 하는 병 수 a, 빈 병 a개를 가져다 주면 마트가 주는 콜라 병 수 b, 상빈이가 가지고 있는 빈 병의 개수 n이 매개변수로 주어집니다. 상빈이가 받을 수 있는 콜라의 병 수를 return 하도록 solution 함수를 작성해주세요
#include <iostream>
using namespace std;
int solution(int a, int b, int n) {
int answer = 0;
while (n >= a)
{
int temp = (n / a) * b;
answer += temp;
n = temp + (n % a);
}
return answer;
}
(1) 문제 풀이 과정을 그대로 따라가서, 코드로 옮기면 되는 문제였다. 마지막에 놓치면 안되는 부분이, '빈 병'은 내가 새로 받은 콜라를 마시고 나온 빈 병과, 기존에 교환하지 못했던 빈 병 수를 더해주어야 한다는 것이다. 기존에 교환하지 못했던 것을 포함하지 않으면 안된다.
*TA*
주제(1) : 각도와 삼각함수 기초
1. 도(angle)과 라디안(Radian)
원을 360등분한 것이 도이고, 원의 반지름 길이와 호의 길이가 같아지는 중심각을 1라디안이라고 한다.
프로그래밍에서는 라디안을 주로 많이 사용한다. 예를 들어, FMath::Acos()는 라디안 값을 반환한다. 따라서, 사람이 읽기 쉬운 도 단위로 바꾸기 위해서는 FMath::RadiansToDegreess(AngleRadians); 를 사용한다.
2. 내적(Dot Product)
내적은 두 벡터가 얼마나 같은 방향을 향하고 있는지 판단하는 계산이다.
정규화된 두 방향 벡터를 내적하면, 그 결과는 두 방향 사이 각도의 cos 값과 연결된다. 각도가 0도라면 cos값은 1이고, 내적의 의미는 같은 방향이고, 각도가 90도라면 cos값은 0이고, 내적의 의미는 서로 직각 방향이고, 각도가 180도라면, cos값은 -1이고, 내적의 의미는 완전히 반대 방향을 의미.
방향 판정이 중요한 이유는 오브젝트가 "어디를 바라보고 있는가"의 상태를 가지기 때문이다. 따라서 이를 내적으로 판단한다.
| Dot 결과 | 의미 |
| 1 | 두 방향이 완전히 같다 |
| 0 | 두 방향이 서로 90도이다 |
| -1 | 두 방향이 완전히 반대이다 |
| 0보다 큼 | 대상이 앞쪽 방향에 있다 |
| 0보다 작음 | 대상이 뒤쪽 방향에 있다 |
if (TargetActor == nullptr)
{
return;
}
FVector Forward = GetActorForwardVector();
FVector ToTarget = TargetActor->GetActorLocation() - GetActorLocation();
FVector DirectionToTarget = ToTarget.GetSafeNormal();
float Dot = FVector::DotProduct(Forward, DirectionToTarget);
if (Dot > 0.0f)
{
UE_LOG(LogTemp, Log, TEXT("Target is in front"));
}
else if (Dot < 0.0f)
{
UE_LOG(LogTemp, Log, TEXT("Target is behind"));
}
else
{
UE_LOG(LogTemp, Log, TEXT("Target is on the side"));
}
Target이 앞에 있는지 뒤에 있는지 내적으로 확인하는 C++ 코드이다.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "View Test")
AActor* TargetActor;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "View Test")
float ViewAngle = 90.0f;
void ADirectionTestActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (TargetActor == nullptr)
{
return;
}
FVector Forward = GetActorForwardVector();
FVector ToTarget = TargetActor->GetActorLocation() - GetActorLocation();
FVector DirectionToTarget = ToTarget.GetSafeNormal();
float Dot = FVector::DotProduct(Forward, DirectionToTarget);
Dot = FMath::Clamp(Dot, -1.0f, 1.0f);
float AngleRadians = FMath::Acos(Dot);
float AngleDegrees = FMath::RadiansToDegrees(AngleRadians);
if (AngleDegrees <= ViewAngle * 0.5f)
{
UE_LOG(LogTemp, Log, TEXT("Target is inside view angle. Angle: %f"), AngleDegrees);
}
else
{
UE_LOG(LogTemp, Log, TEXT("Target is outside view angle. Angle: %f"), AngleDegrees);
}
}
캐릭터의 시야각을 내적으로 확인하는 C++ 코드. FMath::Clamp를 사용했다는 것을 알 수 있다.
내적의 이론상 결과값은 항상 -1.0~1.0 사이의 값 ( [-1. 1] ) 이 나와야 하는데, 컴퓨터의 부동소수점 연산에는 항상 미세한 오차가 발생할 수 있다. 만약 이 범위를 미세하게 초과하게 되면, Acos(아크코사인)을 씌워 두 벡터 사이의 각도(Radian)을 구할 때 수학적으로 정의되지 않는 범위의 계산이어서 크래시를 유발할 수 있다. 따라서, FMath::Clamp()를 씌워서 안전하게 오차를 보정하는 것이다. 또한, 일반적으로 게임 수학에서는 두 벡터가 서로 반대 방향을 바라볼 때 나오는 음수값을 0으로 퉁쳐버리는 경우가 많다. 이와 유사하게, FOV 및 판정 범위를 제한하는데에 있어도 Clamp를 사용해서 보정한다.
3. 외적 (Vector Cross Product)
외적은 두 벡터에 수직인 방향을 구하는 계산이다. 즉, Target의 왼쪽, 오른쪽을 판단할 수 있게된다.
if (TargetActor == nullptr)
{
return;
}
FVector Forward = GetActorForwardVector();
FVector ToTarget = TargetActor->GetActorLocation() - GetActorLocation();
FVector DirectionToTarget = ToTarget.GetSafeNormal();
FVector Cross = FVector::CrossProduct(Forward, DirectionToTarget);
if (Cross.Z > 0.0f)
{
UE_LOG(LogTemp, Log, TEXT("Target is on one side. Cross.Z: %f"), Cross.Z);
}
else if (Cross.Z < 0.0f)
{
UE_LOG(LogTemp, Log, TEXT("Target is on the other side. Cross.Z: %f"), Cross.Z);
}
else
{
UE_LOG(LogTemp, Log, TEXT("Target is aligned. Cross.Z: %f"), Cross.Z);
}
방향 벡터(Normal Vector, UpVector, 법선 벡터)는 모두 크기가 1인 단위 벡터를 의미한다.
UpDot = 1 * 1 * cos(theta) = cos(theta)
1) UpDot이 1과 가깝다면 바닥 판정. 표면의 Normal 벡터와 하늘(UpVector)이 거의 같은 방향을 향하고 있음을 의미한다. 따라서, 평평한 바닥 또는 완만한 경사로이다.
2) UpDot이 0과 가깝다면 벽 판정. 표면의 Normal 벡터와 하늘이 서로 수직을 이룬다. 지면을 기준으로 서 있는 수직 벽이다.
3) UpDot이 <0 이라면 천장 판정. 표면의 Normal 벡터가 아래쪽을 향하고 있다. 캐릭터 머리 위의 천장이다.
FVector HitNormal = HitResult.ImpactNormal;
// 두 단위 벡터를 내적하여 cos(theta) 값을 구함
float UpDot = FVector::DotProduct(HitNormal, FVector::UpVector);
// 기준 스펙: 걸어 올라갈 수 있는 최대 경사각 (예: 45도)
// cos(45도) 값은 약 0.707
const float WalkableFloorThreshold = FMath::Cos(FMath::DegreesToRadians(45.0f));
if (UpDot >= WalkableFloorThreshold)
{
// UpDot이 0.707에서 1.0 사이면 '바닥'으로 판단
UE_LOG(LogTemp, Log, TEXT("결과: 바닥 (캐릭터 이동 가능)"));
}
else if (UpDot > -0.1f && UpDot < WalkableFloorThreshold)
{
// UpDot이 0 근처면 '벽'으로 판단
UE_LOG(LogTemp, Log, TEXT("결과: 벽 (이동 차단 또는 벽타기 가능)"));
}
else
{
// UpDot이 음수면 '천장'
UE_LOG(LogTemp, Log, TEXT("결과: 천장"));
}
4. 실습
1) Target이 앞에 있는지 뒤에 있는지 확인해보자. --> 내적
- Actor 또는 새 Actor를 레벨에 배치한다.
- TargetActor를 하나 배치한다.
- Actor에 TargetActor 변수를 추가한다.
- GetActorForwardVector()로 Actor의 Forward 방향을 가져온다.
- TargetActor->GetActorLocation() - GetActorLocation()으로 Target 방향을 구한다.
- GetSafeNormal()로 방향을 정규화한다.
- FVector::DotProduct()로 Forward와 Target 방향을 내적한다.
- Dot 값이 0보다 큰지 작은지 확인한다.
====
- TargetActor를 Actor 앞에 두면 Dot 값은 어떻게 나오는가?
- TargetActor를 Actor 뒤에 두면 Dot 값은 어떻게 나오는가?
- TargetActor를 Actor 옆에 두면 Dot 값은 어떻게 나오는가?
2) 캐릭터의 시야각을 확인해보자. --> 내적과 각도 계산
- Actor에 ViewAngle 변수를 추가한다.
- 기본값을 90도로 설정한다.
- Forward와 DirectionToTarget을 내적한다.
- Dot 값을 Clamp한다.
- FMath::Acos()로 각도를 구한다.
- FMath::RadiansToDegrees()로 도 단위로 변환한다.
- Angle <= ViewAngle * 0.5f 조건으로 시야각 안에 있는지 판정한다
====
- ViewAngle이 90도일 때 TargetActor는 어느 범위 안에 있어야 감지되는가?
- ViewAngle을 30도로 줄이면 감지 범위는 어떻게 달라지는가?
- ViewAngle을 180도로 늘리면 감지 범위는 어떻게 달라지는가?
3) Target이 왼쪽인지 오른쪽인지 확인해보자 --> 외적
- Forward와 DirectionToTarget을 구한다.
- FVector::CrossProduct()를 사용한다.
- Cross 결과의 Z 값을 출력한다.
- TargetActor를 Actor의 왼쪽과 오른쪽에 배치한다.
- Cross.Z 값이 어떻게 변하는지 확인한다.
====
- TargetActor를 왼쪽에 두면 Cross.Z 값은 어떻게 나오는가?
- TargetActor를 오른쪽에 두면 Cross.Z 값은 어떻게 나오는가?
- CrossProduct의 벡터 순서를 바꾸면 결과는 어떻게 달라지는가?
4) 표면 방향 판정 확인하기 --> Normal Vector와 내적
- 바닥 표면의 Normal은 어떤 방향을 향하는가?
- 벽 표면의 Normal은 어떤 방향을 향하는가?
- HitNormal과 UpVector를 내적하면 무엇을 알 수 있는가?
5) 테스트용 코드 작성 예시
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "DirectionTestActor.generated.h"
UCLASS()
class YOURPROJECT_API ADirectionTestActor : public AActor
{
GENERATED_BODY()
public:
ADirectionTestActor();
protected:
virtual void Tick(float DeltaTime) override;
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Direction Test")
AActor* TargetActor;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Direction Test")
float ViewAngle = 90.0f;
};
#include "DirectionTestActor.h"
ADirectionTestActor::ADirectionTestActor()
{
PrimaryActorTick.bCanEverTick = true;
}
void ADirectionTestActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (TargetActor == nullptr)
{
return;
}
FVector Forward = GetActorForwardVector();
FVector ToTarget = TargetActor->GetActorLocation() - GetActorLocation();
FVector DirectionToTarget = ToTarget.GetSafeNormal();
float Dot = FVector::DotProduct(Forward, DirectionToTarget);
if (Dot > 0.0f)
{
UE_LOG(LogTemp, Log, TEXT("Front / Dot: %f"), Dot);
}
else if (Dot < 0.0f)
{
UE_LOG(LogTemp, Log, TEXT("Behind / Dot: %f"), Dot);
}
else
{
UE_LOG(LogTemp, Log, TEXT("Side / Dot: %f"), Dot);
}
float ClampedDot = FMath::Clamp(Dot, -1.0f, 1.0f);
float AngleRadians = FMath::Acos(ClampedDot);
float AngleDegrees = FMath::RadiansToDegrees(AngleRadians);
if (AngleDegrees <= ViewAngle * 0.5f)
{
UE_LOG(LogTemp, Log, TEXT("Inside View Angle / Angle: %f"), AngleDegrees);
}
else
{
UE_LOG(LogTemp, Log, TEXT("Outside View Angle / Angle: %f"), AngleDegrees);
}
FVector Cross = FVector::CrossProduct(Forward, DirectionToTarget);
UE_LOG(LogTemp, Log, TEXT("Cross: %s / Cross.Z: %f"), *Cross.ToString(), Cross.Z);
}
주제(2) : 기초 물리
1. 속도
게임에서의 속도는 Velocity. 뱡항과 빠르기를 모두 포함하는 벡터 값이다.
Velocity = Direction * Speed
항상 중요한 것!
컴퓨터 성능(프레임 레이트)에 상관없이 항상 일정한 속도로 움직이게 하려면, 델타 타임을 반드시 곱해야 한다.
// 1. 캐릭터가 바라보는 앞 방향(단위 벡터)을 가져옴
FVector Direction = GetActorForwardVector();
// 2. 방향에 속력을 곱해 속도(Velocity) 벡터 생성
FVector Velocity = Direction * MoveSpeed;
// 3. 프레임 지연을 보정하여 이번 프레임의 실제 이동량 계산
FVector Movement = Velocity * DeltaTime;
// 4. 액터의 위치를 월드 좌표 기준으로 이동시킴
AddActorWorldOffset(Movement);
총알 이동
void ABulletActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
FVector Direction = GetActorForwardVector();
FVector Velocity = Direction * BulletSpeed;
FVector Movement = Velocity * DeltaTime;
AddActorWorldOffset(Movement);
}
2. 가속도
속도가 시간에 따라 변하는 정도.
Velocity = Velocity + Acceleration * DeltaTimePosition = Position + Velocity * DeltaTime
게임에서는 대시, 미끄러짐, 넉백 후 감속 등에 활용된다.
3. 힘
오브젝트의 속도를 바꾸는 원인으로 이해할 수 있다.
Force = Mass * Accelerator
(예시)
1) 점프 순간에는 Velocity.Z = JumpPower 의 힘을 주어 속도를 변화시킨다.
2) 매 프레임(Tick)마다 Velocity = Gravity * DeltaTime, Location = Velocity * DeltaTime 식으로 속도와 위치값을 계산한다.
3) 넉백은 공격을 받았을 때 캐릭터가 뒤로 밀려나는 효과이다. KnockbackDirection = HitActorLocation - AttackerLocation, KnockbackVelocity = KnockbackDirection * KnockbackPower 식으로 계산할 수 있다.
4. 중력
아래 방향으로 계속 작용하는 가속도. 게임에서는 보통 Z축 아래 방향으로 중력을 적용한다.
FVectorGravity = FVector(0.0f, 0.0f, -980.0f);
(예시)
1) 점프 시작 순간에 JumpPower로 Z축 + 방향으로 힘을 준다면, 이후 매 프레임 Gravity * DeltaTime의 힘으로 반대방향으로 힘이 작용하게 됨.
2) 투사체 포물선의 경우, Initial Velocity = (Forward * Speed) + (Up * LaunchPower) 식으로 나타낸다.
5. 충돌
두 오브젝트가 공간에서 만나거나 겹치는 상황을 의미한다.
| 구분 | 의미 | 예시 |
| Hit | 물체가 부딪히는 충돌 | 총알이 벽에 맞음, 공이 바닥에 닿음 |
| Overlap | 두 영역이 겹침 | 아이템 획득, 감지 영역 진입, 공격 범위 판정 |
캐릭터가 벽을 통과하지 못하도록 할 때
총알이 적에게 맞아야 할 때
플레이어가 아이템과 겹치면 아이템을 획득해야 할 때
공격 범위 안에 적이 들어오면 데미지를 줄 수 있어야 할 때
6. 반사
오브젝트가 표면에 부딪힌 뒤 방향이 바뀌는 현상
반사 계산의 핵심은 입사 벡터와 반사 벡터를 계산하는 것!
7. 실습
1) Velocity를 이용해 Actor를 일정한 방향으로 이동시키기.
FVector Direction = GetActorForwardVector();
FVector Velocity = Direction * MoveSpeed;
FVector Movement = Velocity * DeltaTime;
AddActorWorldOffset(Movement);
2) 중력으로 Actor 떨어뜨리기
FVector Gravity = FVector(0.0f, 0.0f, -980.0f);
Velocity += Gravity * DeltaTime;
AddActorWorldOffset(Velocity * DeltaTime);
3) 바닥에 닿으면 멈추게 하기
FVector Location = GetActorLocation();
if (Location.Z <= GroundZ)
{
Location.Z = GroundZ;
SetActorLocation(Location);
Velocity.Z = 0.0f;
}
4) Hit와 Overlap 구분하기
| 상황 | Hit / Overlap |
| 총알이 벽에 맞음 | Hit |
| 플레이어가 코인을 먹음 | Overlap |
| 캐릭터가 바닥에 착지함 | Hit |
| 적이 공격 범위 안에 들어옴 | Overlap 또는 Trace |
| 공이 벽에 부딪혀 튕김 | Hit |
5) 반사 방향 확인하기
FVector IncomingDirection = Velocity.GetSafeNormal();
FVector HitNormal = FVector::UpVector;
FVector ReflectedDirection = FMath::GetReflectionVector(IncomingDirection, HitNormal);
6) PhysicsActor를 만들 때 코드 예시
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PhysicsTestActor.generated.h"
UCLASS()
class YOURPROJECT_API APhysicsTestActor : public AActor
{
GENERATED_BODY()
public:
APhysicsTestActor();
protected:
virtual void Tick(float DeltaTime) override;
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Physics Test")
FVector Velocity = FVector::ZeroVector;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Physics Test")
FVector Gravity = FVector(0.0f, 0.0f, -980.0f);
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Physics Test")
float MoveSpeed = 300.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Physics Test")
float GroundZ = 0.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Physics Test")
bool bUseGravity = true;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Physics Test")
bool bMoveForward = false;
};
#include "PhysicsTestActor.h"
APhysicsTestActor::APhysicsTestActor()
{
PrimaryActorTick.bCanEverTick = true;
}
void APhysicsTestActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (bMoveForward)
{
FVector Direction = GetActorForwardVector();
Velocity = Direction * MoveSpeed;
}
if (bUseGravity)
{
Velocity += Gravity * DeltaTime;
}
AddActorWorldOffset(Velocity * DeltaTime);
FVector Location = GetActorLocation();
if (Location.Z <= GroundZ)
{
Location.Z = GroundZ;
SetActorLocation(Location);
if (Velocity.Z < 0.0f)
{
Velocity.Z = 0.0f;
}
}
UE_LOG(LogTemp, Log, TEXT("Velocity: %s"), *Velocity.ToString());
}
*팀 프로젝트*
1. 내 코드 + 팀원 코드 피드백 후 수정하기
[1. 팀 커스텀 로그 매크로 적용 및 로그 한글화]
가독성을 위해서 커스텀 로그를 적용하였고, 한글로 로그를 수정.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "GameplayTagContainer.h"
#include "DeliveryTypes.h"
#include "Delivery/Carryable.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;
UCLASS()
class PARCEL_KNIGHT_API ADeliveryBox : public AActor, public ICarryable
{
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;
// 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 "Net/UnrealNetwork.h"
DEFINE_LOG_CATEGORY(LogParcelDelivery);
ADeliveryBox::ADeliveryBox()
{
PrimaryActorTick.bCanEverTick = false;
bReplicates = true;
SetReplicateMovement(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()
{
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); // 리슨 서버 소유권(Owner) 변경
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번 상자 낙하 처리 시작. 기존 소유 플레이어: %s"),
BoxID, HolderPlayer ? *HolderPlayer->GetName() : TEXT("없음"));
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);
}
}
}
[2. 에디터에서 DataTable 세팅]
DT_BoxData를 만들고, Row Name에 NormalBox, HeavyBox, FragileBox 추가. 이후 Box Type Tag를 알맞게 지정하고, 세부 사항을 임시로 설정하였음. 추가로 기존에 다운로드 해둔 스태틱 메쉬 형태의 상자 에셋들을 각각에 할당했음.
[3. DeliverySubsystem, DeliveryZone 전면 리팩토링]
GameplayTags 기능을 사용하지 않은 두 파일을 재검토하고 전면 리팩토링 하였음.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "GameplayTagContainer.h"
#include "DeliveryZone.generated.h"
class UBoxComponent;
class UStaticMeshComponent;
UCLASS()
class PARCEL_KNIGHT_API ADeliveryZone : public AActor
{
GENERATED_BODY()
public:
ADeliveryZone();
protected:
virtual void BeginPlay() override;
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Delivery | Zone Settings")
FGameplayTag ZoneTag;
protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Delivery | Components")
TObjectPtr<UBoxComponent> OverlapVolume;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Delivery | Components")
TObjectPtr<UStaticMeshComponent> ZoneMesh;
private:
// Server : Overlap 이벤트 콜백 함수
UFUNCTION()
void OnZoneOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex,
bool bFromSweep, const FHitResult& SweepResult);
void ProcessDelivery(AActor* InBox);
};
#include "Delivery/DeliveryZone.h"
#include "Components/BoxComponent.h"
#include "Components/StaticMeshComponent.h"
#include "Delivery/DeliveryBox.h"
#include "Delivery/DeliverySubsystem.h"
#include "Core/ParcelGameState.h"
#include "Core/TeamScoreComponent.h"
#include "Core/ParcelPlayerState.h"
#include "GameFramework/Controller.h"
ADeliveryZone::ADeliveryZone()
{
PrimaryActorTick.bCanEverTick = false;
ZoneMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("ZoneMesh"));
RootComponent = ZoneMesh;
OverlapVolume = CreateDefaultSubobject<UBoxComponent>(TEXT("OverlapVolume"));
OverlapVolume->SetupAttachment(RootComponent);
OverlapVolume->SetCollisionProfileName(TEXT("Trigger"));
}
void ADeliveryZone::BeginPlay()
{
Super::BeginPlay();
// OverlapVolume이 true인지도 미리 체크
if (HasAuthority() && OverlapVolume)
{
OverlapVolume->OnComponentBeginOverlap.AddDynamic(this, &ADeliveryZone::OnZoneOverlap);
}
}
void ADeliveryZone::OnZoneOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex,
bool bFromSweep, const FHitResult& SweepResult)
{
if (!HasAuthority() || !OtherActor) return;
if (ADeliveryBox* Box = Cast<ADeliveryBox>(OtherActor))
{
if (Box->HasStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Delivered")))) return;
ProcessDelivery(Box);
}
}
void ADeliveryZone::ProcessDelivery(AActor* InBox)
{
ADeliveryBox* Box = Cast<ADeliveryBox>(InBox);
if (!Box || !HasAuthority()) return;
FBoxData Data = Box->GetBoxData();
// 게임플레이태그 시스템으로 리팩토링 (조건문 간소화)
bool bIsCorrectZone = Data.TargetZoneTag.MatchesTagExact(ZoneTag);
int32 ScoreChange = bIsCorrectZone ? Data.BaseScore : Data.DamagePenalty;
if (bIsCorrectZone)
{
DELIVERY_LOG(LogParcelDelivery, Log, TEXT("[Server] 배송지점에 도착. 점수 +%d"), ScoreChange);
Box->AddStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Delivered")));
}
else
{
DELIVERY_LOG(LogParcelDelivery, Warning, TEXT("[Server] 배송지점이 아님. 점수 페널티 %d"), ScoreChange);
Box->AddStateTag(FGameplayTag::RequestGameplayTag(TEXT("Box.State.Failed")));
}
// 팀 점수
if (AParcelGameState* GameState = GetWorld()->GetGameState<AParcelGameState>())
{
if (UTeamScoreComponent* TeamScoreComp = GameState->GetTeamScoreComponent())
{
TeamScoreComp->AddTeamScore(ScoreChange);
}
}
// Todo : 상자를 던져서 골인시키게 되면, 그 순간 Box->GetOwner()가 nullptr일수 있음. 해당 버그를 미리 차단할 필요.
if (APawn* CarrierPawn = Cast<APawn>(Box->GetOwner()))
{
if (AController* CarrierController = CarrierPawn->GetController())
{
if (AParcelPlayerState* PlayerState = CarrierController->GetPlayerState<AParcelPlayerState>())
{
PlayerState->AddScore(ScoreChange);
if (bIsCorrectZone)
{
PlayerState->OnDeliverySuccess();
}
else
{
PlayerState->OnDeliveryFail();
}
}
}
}
// 사용 완료된 상자는 서브시스템을 통해 삭제
if (UDeliverySubsystem* DeliverySubsystem = GetWorld()->GetSubsystem<UDeliverySubsystem>())
{
DeliverySubsystem->DespawnBox(Box);
}
}
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/WorldSubsystem.h"
#include "GameplayTagContainer.h"
#include "DeliverySubsystem.generated.h"
class UDataTable;
class UStageData;
UCLASS()
class PARCEL_KNIGHT_API UDeliverySubsystem : public UWorldSubsystem
{
GENERATED_BODY()
public:
UDeliverySubsystem();
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
void InitializeStage(UStageData* InStageData);
UFUNCTION(BlueprintCallable, Category = "Delivery")
AActor* SpawnBox(FGameplayTag BoxTypeTag, FVector SpawnLocation, FRotator SpawnRotation);
void DespawnBox(AActor* Box);
int32 GenerateBoxID();
FORCEINLINE UStageData* GetCurrentStageData() const { return CurrentStageData; }
private:
UPROPERTY()
TArray<AActor*> ActiveBoxes;
UPROPERTY(EditDefaultsOnly, Category = "Delivery")
TObjectPtr<UDataTable> BoxDataTable;
UPROPERTY()
TObjectPtr<UStageData> CurrentStageData;
int32 NextBoxID;
};
#include "Delivery/DeliverySubsystem.h"
#include "Delivery/DeliveryBox.h"
#include "Delivery/StageData.h"
#include "Engine/DataTable.h"
UDeliverySubsystem::UDeliverySubsystem()
{
NextBoxID = 1000;
CurrentStageData = nullptr;
}
void UDeliverySubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
ActiveBoxes.Empty();
}
void UDeliverySubsystem::Deinitialize()
{
ActiveBoxes.Empty();
CurrentStageData = nullptr;
Super::Deinitialize();
}
void UDeliverySubsystem::InitializeStage(UStageData* InStageData)
{
if (!InStageData) return;
CurrentStageData = InStageData;
BoxDataTable = InStageData->BoxDataTable;
DELIVERY_LOG(LogParcelDelivery, Log, TEXT("[Subsystem] 스테이지가 시작되었습니다. 목표 점수: %d"), CurrentStageData->TargetScore);
}
int32 UDeliverySubsystem::GenerateBoxID()
{
return ++NextBoxID;
}
AActor* UDeliverySubsystem::SpawnBox(FGameplayTag BoxTypeTag, FVector SpawnLocation, FRotator SpawnRotation)
{
if (!GetWorld() || GetWorld()->GetNetMode() == NM_Client) return nullptr;
if (!BoxDataTable)
{
DELIVERY_LOG(LogParcelDelivery, Error, TEXT("[Subsystem] BoxDataTable이 비어있어 상자를 생성할 수 없습니다"));
return nullptr;
}
// 1. 데이터 테이블을 런타임에 순회하며 인자로 들어온 고유 태그와 exact 매칭되는 행(Row) 파싱
FBoxData* FoundData = nullptr;
TArray<FBoxData*> AllRows;
BoxDataTable->GetAllRows<FBoxData>(TEXT(""), AllRows);
for (FBoxData* RowData : AllRows)
{
if (RowData && RowData->BoxTypeTag.MatchesTagExact(BoxTypeTag))
{
FoundData = RowData;
break;
}
}
if (!FoundData)
{
DELIVERY_LOG(LogParcelDelivery, Error, TEXT("[Subsystem] 태그 [%s] 에 매칭되는 박스 스펙이 DT_BoxData에 없습니다"), *BoxTypeTag.ToString());
return nullptr;
}
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
ADeliveryBox* NewBox = GetWorld()->SpawnActor<ADeliveryBox>(ADeliveryBox::StaticClass(), SpawnLocation, SpawnRotation, SpawnParams);
if (NewBox)
{
int32 AssignedID = GenerateBoxID();
NewBox->InitializeBox(AssignedID, *FoundData);
ActiveBoxes.Add(NewBox);
return NewBox;
}
return nullptr;
}
void UDeliverySubsystem::DespawnBox(AActor* Box)
{
if (!Box) return;
ActiveBoxes.Remove(Box);
Box->Destroy();
}
[게임모드/레벨] ──> UStageData(데이터에셋) 주입 ──> [UDeliverySubsystem] 에 DT_BoxData 자동 로드
│
[다른 팀원의 스폰 요청] ── SpawnBox(태그 던지기) ───────────────┘
│
(DT에서 스펙 조립)
▼
ADeliveryBox 탄생!
│
(플레이어가 집어 들기)
▼
UCarryComponent 작동 (상자 무게에 맞춰 플레이어 속도 자동 감수)
│
(배송 구역 골인!)
▼
ADeliveryZone 트리거 (상자의 타겟 태그와 구역 태그가 일치하는지 한 줄로 판정)
│
┌─────────────────┴─────────────────┐
(bIsCorrectZone == true) (bIsCorrectZone == false)
▼ ▼
팀/개인 점수 정상 추가 (+BaseScore) 팀/개인 점수 감점 (-DamagePenalty)
상자에 Box.State.Delivered 부여 상자에 Box.State.Failed 부여
└─────────────────┬─────────────────┘
▼
DeliverySubsystem->DespawnBox()로 상자 소멸
2. 추가 작업한 내용들(2시 스크럼 이후)
[1. 맵 제작시 구역에 배치할 수 있는 BP_DeliveryZone 액터 추가. 크기 조절/태그 할당만 하면 되도록 했음]
[2. DT_BoxData, DA_Stage_01 등 임시로 사용할 수 있는 데이터 에셋과 데이터 테이블을 추가하였음]
[3. HeroComponent 구현]
(1) 기존 작성되어있던 코드
#pragma once
// 엔진에서 자주 쓰는 기본 타입, 매크로, 컨테이너를 포함
#include "CoreMinimal.h"
// ACharacter를 상속받기 위해 필요한 헤더
#include "GameFramework/Character.h"
// Enhanced Input 액션 값(FInputActionValue)을 함수 인자로 사용하기 위해 포함
#include "InputActionValue.h"
// UnrealHeaderTool이 생성하는 리플렉션 코드를 포함
#include "ParcelCharacter.generated.h"
// 이 캐릭터 클래스 전용 로그 카테고리
DECLARE_LOG_CATEGORY_EXTERN(LogCharacter, Log, All);
// 헤더 의존성을 줄이기 위한 전방 선언
class AController;
class UCameraComponent;
class URagdollComponent;
class UInputAction;
class UInputComponent;
class UInputMappingContext;
class USpringArmComponent;
// 플레이어가 조종하는 기본 캐릭터 클래스
// 이동, 시점 회전, 점프, 래그돌 테스트 입력을 처리하고 멀티플레이 복제를 지원
//
// 담당자: 김로운
UCLASS()
class PARCEL_KNIGHT_API AParcelCharacter : public ACharacter
{
GENERATED_BODY()
public:
// 기본 컴포넌트, 이동 설정, 네트워크 복제 설정을 초기화
AParcelCharacter();
// 매 프레임 호출됩니다. 현재는 로컬 래그돌 카메라 위치 보정에 사용
virtual void Tick(float DeltaTime) override;
// 서버에서 이 캐릭터가 컨트롤러에 빙의될 때 호출
// Listen Server의 로컬 플레이어 입력 매핑 등록을 보강
virtual void PossessedBy(AController* NewController) override;
// 클라이언트에서 Controller 값이 복제되어 바뀔 때 호출됩니다.
// 원격 접속 클라이언트가 possession 이후 입력 매핑을 놓치지 않게 합니다.
virtual void OnRep_Controller() override;
protected:
// 게임 시작 시 호출됩니다. 로컬 플레이어라면 Enhanced Input 매핑을 등록합니다.
virtual void BeginPlay() override;
// 플레이어 입력 컴포넌트에 Enhanced Input 액션들을 바인딩합니다.
virtual void SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) override;
// 캐릭터 뒤를 따라가는 카메라 암입니다.
// 컨트롤러 회전을 사용해서 카메라 방향을 정합니다.
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
USpringArmComponent* SpringArm;
// 실제 화면을 렌더링하는 추적 카메라입니다.
// SpringArm 끝 소켓에 붙어 있습니다.
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
UCameraComponent* FollowCamera;
// 래그돌 시작/종료와 상태 복제를 담당하는 커스텀 컴포넌트.
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Component")
URagdollComponent* RagdollComp;
// 이 캐릭터가 사용할 Enhanced Input Mapping Context입니다.
// 블루프린트나 에디터에서 지정합니다.
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
UInputMappingContext* InputMappingContext;
// WASD, 왼쪽 스틱 등 이동 입력 액션입니다.
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
UInputAction* MoveAction;
// 마우스, 오른쪽 스틱 등 시점 회전 입력 액션
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
UInputAction* LookAction;
// 점프 시작/종료 입력 액션
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
UInputAction* JumpAction;
// 누르고 있는 동안 달리기 속도로 전환하는 입력 액션
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
UInputAction* SprintAction;
// 래그돌 상태를 토글하는 테스트 입력 액션
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
UInputAction* RagdollAction;
// MoveAction에서 들어온 2D 입력값으로 캐릭터 이동 입력을 추가
void Move(const FInputActionValue& Value);
// LookAction에서 들어온 2D 입력값으로 컨트롤러 yaw/pitch를 회전
void Look(const FInputActionValue& Value);
// JumpAction이 시작될 때 캐릭터 점프를 시작
void StartJump(const FInputActionValue& Value);
// JumpAction이 끝날 때 점프 입력을 중단
void StopJump(const FInputActionValue& Value);
// SprintAction이 시작될 때 이동 속도를 달리기 속도로 바꿉니다.
void StartSprint(const FInputActionValue& Value);
// SprintAction이 끝날 때 이동 속도를 걷기 속도로 되돌립니다.
void StopSprint(const FInputActionValue& Value);
// RagdollAction이 시작될 때 래그돌 상태를 토글합니다.
void TestRagdoll(const FInputActionValue& Value);
private:
UFUNCTION(Server, Reliable)
void ServerSetSprinting(bool bNewIsSprinting);
void ApplySprintSpeed(bool bNewIsSprinting);
bool IsRagdollCloseToGround() const;
// 현재 캐릭터를 소유한 로컬 플레이어에게 Enhanced Input Mapping Context를 등록
void AddInputMappingContext();
// 이 인스턴스가 입력을 처리해도 되는 로컬 소유 캐릭터인지 확인합니다.
bool CanProcessLocalInput() const;
};
// 이 클래스의 선언을 포함합니다.
#include "Character/ParcelCharacter.h"
// 래그돌 상태 전환과 복제를 담당하는 컴포넌트
#include "Character/RagdollComponent.h"
// 카메라 컴포넌트를 생성하고 설정하기 위해 필요함
#include "Camera/CameraComponent.h"
// Enhanced Input 액션 바인딩을 위해 필요함
#include "EnhancedInputComponent.h"
// Local Player Subsystem에 Input Mapping Context를 등록하기 위해 필요함
#include "EnhancedInputSubsystems.h"
// 캐릭터 이동 속도, 회전, 점프 값을 설정하기 위해 필요함
#include "GameFramework/CharacterMovementComponent.h"
// 현재 캐릭터를 조종하는 PlayerController를 얻기 위해 필요함
#include "GameFramework/PlayerController.h"
// 캐릭터 추적 카메라 암을 생성하고 설정하기 위해 필요함
#include "GameFramework/SpringArmComponent.h"
// 헤더에서 선언한 LogCharacter 로그 카테고리를 실제로 정의합니다.
DEFINE_LOG_CATEGORY(LogCharacter);
namespace
{
constexpr float WalkSpeed = 450.f;
constexpr float SprintSpeed = 850.f;
constexpr float RagdollCameraHeightOffset = 20.f;
constexpr float RagdollCameraBackOffset = 90.f;
constexpr float RagdollStopGroundTraceDistance = 120.f;
constexpr float CameraCollisionProbeSize = 18.f;
}
AParcelCharacter::AParcelCharacter()
{
// Tick에서 래그돌 중 로컬 카메라 위치를 계속 보정해야 하므로 Tick을 켭니다.
PrimaryActorTick.bCanEverTick = true;
// 캐릭터 액터 자체를 네트워크에 복제
bReplicates = true;
// 위치, 회전 같은 Actor Movement를 서버에서 클라이언트로 복제
// ACharacter의 기본 CharacterMovement 복제와 함께 동작합니다.
SetReplicateMovement(true);
// 움직임이 잦은 플레이어 캐릭터라서 기본보다 높은 빈도로 네트워크 갱신을 요청
SetNetUpdateFrequency(100.f);
// 네트워크 상태가 안정적일 때도 너무 낮은 빈도로 떨어지지 않게 최소 갱신 빈도를 지정합니다.
SetMinNetUpdateFrequency(33.f);
// 캐릭터 몸체는 컨트롤러 pitch를 직접 따라가지 않게 합니다.
// 카메라만 위아래를 볼 수 있게 하기 위한 설정
bUseControllerRotationPitch = false;
// 캐릭터 몸체는 컨트롤러 yaw를 직접 따라가지 않게 합니다.
// 이동 방향 기준으로 자연스럽게 회전하도록 CharacterMovement에 맡깁니다.
bUseControllerRotationYaw = false;
// 캐릭터 몸체는 컨트롤러 roll을 직접 따라가지 않게 합니다.
bUseControllerRotationRoll = false;
// 이동 입력 방향을 바라보도록 캐릭터를 자동 회전
GetCharacterMovement()->bOrientRotationToMovement = true;
// 이동 방향으로 돌아갈 때의 회전 속도.
GetCharacterMovement()->RotationRate = FRotator(0.f, 540.f, 0.f);
// 걷기 최대 속도
GetCharacterMovement()->MaxWalkSpeed = WalkSpeed;
// 점프 시작 시 위로 가해지는 속도
GetCharacterMovement()->JumpZVelocity = 500.f;
// 공중에서 이동 입력이 얼마나 반영되는지 정합니다.
GetCharacterMovement()->AirControl = 0.35f;
// 카메라를 캐릭터에서 일정 거리 떨어뜨리기 위한 SpringArm 컴포넌트를 생성
SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm"));
// SpringArm을 캐릭터 루트 컴포넌트에 붙입니다.
SpringArm->SetupAttachment(RootComponent);
// 캐릭터와 카메라 사이 거리
SpringArm->TargetArmLength = 350.f;
// 벽과 지형에 카메라가 파고들지 않도록 SpringArm 충돌 검사를 명시적으로 켭니다.
SpringArm->bDoCollisionTest = true;
SpringArm->ProbeChannel = ECC_Camera;
SpringArm->ProbeSize = CameraCollisionProbeSize;
// 컨트롤러 회전값을 SpringArm에 적용해서 카메라 방향을 바꿉니다.
SpringArm->bUsePawnControlRotation = true;
// 카메라 이동을 즉시 따라가지 않고 약간 부드럽게 따라가게 합니다.
SpringArm->bEnableCameraLag = true;
// 카메라 지연 보간 속도
SpringArm->CameraLagSpeed = 10.f;
// 실제 화면 렌더링에 쓰는 카메라 컴포넌트를 생성
FollowCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("FollowCamera"));
// 카메라를 SpringArm 끝 소켓에 붙입니다.
FollowCamera->SetupAttachment(SpringArm, USpringArmComponent::SocketName);
// 카메라 자체는 컨트롤러 회전을 다시 적용하지 않습니다.
// SpringArm이 이미 컨트롤러 회전을 처리합니다.
FollowCamera->bUsePawnControlRotation = false;
// 래그돌 상태 전환과 복제를 담당하는 컴포넌트를 생성
RagdollComp = CreateDefaultSubobject<URagdollComponent>(TEXT("RagdollComp"));
}
void AParcelCharacter::BeginPlay()
{
// 부모 클래스의 BeginPlay 로직을 먼저 실행
Super::BeginPlay();
// 로컬 플레이어가 소유한 캐릭터라면 입력 매핑을 등록
AddInputMappingContext();
}
void AParcelCharacter::PossessedBy(AController* NewController)
{
// 서버의 possession 처리 흐름을 유지합니다.
Super::PossessedBy(NewController);
// Listen Server의 로컬 플레이어처럼 서버에서도 로컬 입력이 있는 경우를 보강합니다.
AddInputMappingContext();
}
void AParcelCharacter::OnRep_Controller()
{
// 클라이언트에서 복제된 Controller 변경 처리를 부모 클래스에 맡깁니다.
Super::OnRep_Controller();
// 클라이언트가 Controller 복제를 받은 뒤 입력 매핑을 등록할 수 있게 합니다.
AddInputMappingContext();
}
void AParcelCharacter::Tick(float DeltaTime)
{
// 부모 클래스의 Tick 로직을 먼저 실행합니다.
Super::Tick(DeltaTime);
// 래그돌 중에는 로컬 플레이어의 카메라 기준점을 머리 소켓 위치로 옮깁니다.
// 원격 캐릭터의 카메라 컴포넌트를 움직일 필요는 없으므로 로컬 소유 캐릭터만 처리합니다.
if (IsLocallyControlled() && RagdollComp && RagdollComp->IsRagdoll() && GetMesh() && SpringArm)
{
// 머리 소켓의 월드 위치를 가져옵니다.
const FVector HeadLocation = GetMesh()->GetSocketLocation(TEXT("head"));
const FRotator ViewRotation = Controller ? Controller->GetControlRotation() : GetActorRotation();
const FVector CameraBackDirection = -FRotationMatrix(ViewRotation).GetUnitAxis(EAxis::X);
const FVector TargetLocation =
HeadLocation +
FVector::UpVector * RagdollCameraHeightOffset +
CameraBackDirection * RagdollCameraBackOffset;
// SpringArm을 몸 밖의 기준점으로 이동시켜 래그돌 중 카메라가 캐릭터 내부로 들어가지 않게 합니다.
SpringArm->SetWorldLocation(TargetLocation);
}
}
void AParcelCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
// 부모 클래스의 입력 설정을 먼저 실행합니다.
Super::SetupPlayerInputComponent(PlayerInputComponent);
// 입력 컴포넌트가 준비되는 시점에도 매핑 등록을 한 번 더 보장합니다.
AddInputMappingContext();
// 소유하지 않은 프록시 캐릭터는 입력 바인딩을 만들 필요가 없습니다.
if (!CanProcessLocalInput())
{
return;
}
// 일반 UInputComponent를 Enhanced Input 전용 컴포넌트로 캐스팅합니다.
UEnhancedInputComponent* EnhancedInputComponent = Cast<UEnhancedInputComponent>(PlayerInputComponent);
// Enhanced Input이 적용되어 있지 않으면 액션 바인딩을 할 수 없습니다.
if (!EnhancedInputComponent)
{
UE_LOG(LogCharacter, Warning, TEXT("EnhancedInputComponent is missing."));
return;
}
// 이동 액션이 지정되어 있으면 입력이 발생하는 동안 Move 함수를 호출합니다.
if (MoveAction)
{
EnhancedInputComponent->BindAction(MoveAction, ETriggerEvent::Triggered, this, &AParcelCharacter::Move);
}
// 시점 액션이 지정되어 있으면 입력이 발생하는 동안 Look 함수를 호출합니다.
if (LookAction)
{
EnhancedInputComponent->BindAction(LookAction, ETriggerEvent::Triggered, this, &AParcelCharacter::Look);
}
// 점프 액션이 지정되어 있으면 시작/종료 이벤트에 각각 점프 시작과 중단을 바인딩합니다.
if (JumpAction)
{
EnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Started, this, &AParcelCharacter::StartJump);
EnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Completed, this, &AParcelCharacter::StopJump);
}
// 달리기 액션이 지정되어 있으면 누르는 동안만 달리기 속도를 적용합니다.
if (SprintAction)
{
EnhancedInputComponent->BindAction(SprintAction, ETriggerEvent::Started, this, &AParcelCharacter::StartSprint);
EnhancedInputComponent->BindAction(SprintAction, ETriggerEvent::Completed, this, &AParcelCharacter::StopSprint);
EnhancedInputComponent->BindAction(SprintAction, ETriggerEvent::Canceled, this, &AParcelCharacter::StopSprint);
}
// 래그돌 액션이 지정되어 있으면 입력 시작 시 래그돌 토글 함수를 호출합니다.
if (RagdollAction)
{
EnhancedInputComponent->BindAction(
RagdollAction,
ETriggerEvent::Started,
this,
&AParcelCharacter::TestRagdoll
);
}
}
void AParcelCharacter::Move(const FInputActionValue& Value)
{
// 이동 입력은 로컬 소유 캐릭터에서만 처리합니다.
// ACharacter의 이동 컴포넌트가 서버 보정과 클라이언트 예측을 처리합니다.
if (!CanProcessLocalInput())
{
return;
}
// IA_Move의 Axis2D 값을 가져옵니다.
// X는 좌우, Y는 앞뒤 입력으로 사용합니다.
const FVector2D MoveValue = Value.Get<FVector2D>();
// 컨트롤러 회전값을 가져와 카메라가 바라보는 방향을 기준으로 이동 방향을 계산합니다.
const FRotator ControlRotation = Controller->GetControlRotation();
// 이동은 수평 방향만 사용하므로 pitch와 roll은 제거하고 yaw만 남깁니다.
const FRotator YawRotation(0.f, ControlRotation.Yaw, 0.f);
// yaw 기준의 앞 방향 벡터입니다.
const FVector ForwardDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
// yaw 기준의 오른쪽 방향 벡터입니다.
const FVector RightDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);
// 앞뒤 입력을 캐릭터 이동 입력으로 추가합니다.
AddMovementInput(ForwardDirection, MoveValue.Y);
// 좌우 입력을 캐릭터 이동 입력으로 추가
AddMovementInput(RightDirection, MoveValue.X);
}
void AParcelCharacter::Look(const FInputActionValue& Value)
{
// 시점 입력은 로컬 소유 캐릭터에서만 처리
if (!CanProcessLocalInput())
{
return;
}
// IA_Look의 Axis2D 값을 가져옵니다.
// X는 좌우 회전, Y는 상하 회전으로 사용.
const FVector2D LookValue = Value.Get<FVector2D>();
// 컨트롤러 yaw를 회전시켜 좌우 시점을 변경
AddControllerYawInput(LookValue.X);
// 컨트롤러 pitch를 회전시켜 상하 시점을 변경
AddControllerPitchInput(LookValue.Y);
}
void AParcelCharacter::StartJump(const FInputActionValue& Value)
{
// 점프 입력은 로컬 소유 캐릭터에서만 처리
if (!CanProcessLocalInput())
{
return;
}
// ACharacter 기본 점프 로직을 실행
Jump();
}
void AParcelCharacter::StopJump(const FInputActionValue& Value)
{
// 점프 중단 입력은 로컬 소유 캐릭터에서만 처리
if (!CanProcessLocalInput())
{
return;
}
// ACharacter 기본 점프 중단 로직을 실행
StopJumping();
}
void AParcelCharacter::StartSprint(const FInputActionValue& Value)
{
if (!CanProcessLocalInput() || (RagdollComp && RagdollComp->IsRagdoll()))
{
return;
}
ApplySprintSpeed(true);
if (!HasAuthority())
{
ServerSetSprinting(true);
}
}
void AParcelCharacter::StopSprint(const FInputActionValue& Value)
{
if (!CanProcessLocalInput())
{
return;
}
ApplySprintSpeed(false);
if (!HasAuthority())
{
ServerSetSprinting(false);
}
}
void AParcelCharacter::TestRagdoll(const FInputActionValue& Value)
{
// 래그돌 토글은 로컬 소유 캐릭터 입력에서만 시작합니다.
// 실제 상태 변경은 URagdollComponent의 서버 RPC를 통해 처리됩니다.
if (!CanProcessLocalInput() || !RagdollComp)
{
return;
}
// 토글 전 상태를 저장합니다.
// 래그돌을 끄는 경우에는 카메라 암을 다시 루트에 붙여야 합니다.
const bool bWasRagdoll = RagdollComp->IsRagdoll();
if (bWasRagdoll && !IsRagdollCloseToGround())
{
return;
}
// 래그돌 컴포넌트에 상태 전환을 요청
RagdollComp->ToggleRagdoll();
// 이전 상태가 래그돌이었다면 이번 입력은 래그돌 해제 요청
if (bWasRagdoll && SpringArm)
{
// 래그돌 중 머리 위치로 이동했던 SpringArm을 캐릭터 루트에 다시 붙입니다.
SpringArm->AttachToComponent(
RootComponent,
FAttachmentTransformRules::SnapToTargetNotIncludingScale
);
// 루트 기준 위치를 원점으로 되돌립니다.
SpringArm->SetRelativeLocation(FVector::ZeroVector);
}
}
void AParcelCharacter::ServerSetSprinting_Implementation(bool bNewIsSprinting)
{
ApplySprintSpeed(bNewIsSprinting);
}
void AParcelCharacter::ApplySprintSpeed(bool bNewIsSprinting)
{
if (UCharacterMovementComponent* Movement = GetCharacterMovement())
{
Movement->MaxWalkSpeed = bNewIsSprinting ? SprintSpeed : WalkSpeed;
}
}
bool AParcelCharacter::IsRagdollCloseToGround() const
{
const USkeletalMeshComponent* MeshComponent = GetMesh();
const UWorld* World = GetWorld();
if (!MeshComponent || !World)
{
return true;
}
const FVector TraceStart = MeshComponent->GetSocketLocation(TEXT("pelvis"));
const FVector TraceEnd = TraceStart - FVector::UpVector * RagdollStopGroundTraceDistance;
FCollisionQueryParams QueryParams(SCENE_QUERY_STAT(RagdollGroundTrace), false, this);
FHitResult Hit;
return World->LineTraceSingleByChannel(Hit, TraceStart, TraceEnd, ECC_Visibility, QueryParams);
}
void AParcelCharacter::AddInputMappingContext()
{
// 입력 매핑은 로컬 플레이어에게만 등록해야 합니다.
// 서버의 원격 프록시나 다른 클라이언트의 캐릭터에는 등록하지 않습니다.
if (!IsLocallyControlled())
{
return;
}
// 현재 캐릭터를 조종하는 컨트롤러를 플레이어 컨트롤러로 가져옵니다.
APlayerController* PlayerController = Cast<APlayerController>(GetController());
if (!PlayerController)
{
return;
}
// Enhanced Input Local Player Subsystem은 LocalPlayer에서 얻을 수 있습니다.
ULocalPlayer* LocalPlayer = PlayerController->GetLocalPlayer();
if (!LocalPlayer)
{
return;
}
// LocalPlayer에 연결된 Enhanced Input Subsystem을 가져옵니다.
UEnhancedInputLocalPlayerSubsystem* Subsystem =
ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(LocalPlayer);
if (!Subsystem)
{
return;
}
// 에디터나 블루프린트에서 Mapping Context가 지정되지 않은 경우를 경고합니다.
if (!InputMappingContext)
{
UE_LOG(LogCharacter, Warning, TEXT("InputMappingContext is not assigned."));
return;
}
// possession/replication/setup 흐름에서 여러 번 호출될 수 있으므로 먼저 제거합니다.
Subsystem->RemoveMappingContext(InputMappingContext);
// 현재 캐릭터의 입력 매핑을 우선순위 0으로 등록합니다.
Subsystem->AddMappingContext(InputMappingContext, 0);
}
bool AParcelCharacter::CanProcessLocalInput() const
{
// 컨트롤러가 있고, 이 캐릭터가 현재 머신의 로컬 플레이어에게 소유된 경우에만 입력을 처리합니다.
return Controller && IsLocallyControlled();
}
(2) 문제점
1. 하드코딩 제거해야함. 현재 캐릭터 스탯 자체가 하드코딩 되어있어 멀티플레이에 적합하지 않음.
2. Tick 함수 최소한으로 사용할 필요가 있음.
3. 상호작용이 가능하도록 인터페이스가 필요
4. 라이라 스타일을 적용하여 Character 코드를 간소화하고 컴포넌트와 인터페이스로 분리할 필요가 있음.
(3) 개선안
1. 캐릭터 고유의 UPlayerState 또는 다른 데이터 에셋을 만들어서 에디터에서 제어하도록 이관 필요 :
primaryDataAsset을 상속받아서 캐릭터 스펙 전용 통인 ParcelCharacterData 데이터 에셋을 만드는 것을 제안. 서버가 캐릭터를 스폰하거나 컨트롤러가 캐릭터에 빙의할 때 서버가 데이터 에셋을 해당 캐릭터에게 주는 형태. 캐릭터가 에셋을 받으면 클라이언트 PC로 Replicate 되어 동기화.
2. Tick함수를 Ragdoll 상태일 때에만 Tick 함수가 활성화되도록 수정 제안
3. 상자는 캐릭터가 들 수 있는 물건(ICarryable) + 눈으로 조준해서 조작할 수 있는 오브젝트(IInteractable).
따라서 플레이어의 양손인 UCarryComponent와 연동되는 상호작용 인터페이스가 필요함.(ICarryable)
추가로 이와 연관된 DeliveryBox 내부에도 상호작용 인터페이스를 구현할 필요가 있음.
4. HeroComponent를 구현하여 조작 및 카메라를 떼네어 부착하는 형태로 변경.
(1차 변경)
#pragma once
// 엔진에서 자주 쓰는 기본 타입, 매크로, 컨테이너를 포함
#include "CoreMinimal.h"
// ACharacter를 상속받기 위해 필요한 헤더
#include "GameFramework/Character.h"
// UnrealHeaderTool이 생성하는 리플렉션 코드를 포함
#include "ParcelCharacter.generated.h"
// 이 캐릭터 클래스 전용 로그 카테고리
DECLARE_LOG_CATEGORY_EXTERN(LogCharacter, Log, All);
// 헤더 의존성을 줄이기 위한 전방 선언
class URagdollComponent;
class UParcelHeroComponent;
// 플레이어가 조종하는 기본 캐릭터 클래스
// 이동, 시점 회전, 점프, 래그돌 테스트 입력을 처리하고 멀티플레이 복제를 지원
//
// 담당자: 김로운
UCLASS()
class PARCEL_KNIGHT_API AParcelCharacter : public ACharacter
{
GENERATED_BODY()
public:
// 기본 컴포넌트, 이동 설정, 네트워크 복제 설정을 초기화
AParcelCharacter();
// 서버에서 이 캐릭터가 컨트롤러에 빙의될 때 호출
// Listen Server의 로컬 플레이어 입력 매핑 등록을 보강
virtual void PossessedBy(AController* NewController) override;
// 클라이언트에서 Controller 값이 복제되어 바뀔 때 호출됩니다.
// 원격 접속 클라이언트가 possession 이후 입력 매핑을 놓치지 않게 합니다.
virtual void OnRep_Controller() override;
protected:
// 게임 시작 시 호출됩니다. 로컬 플레이어라면 Enhanced Input 매핑을 등록합니다.
virtual void BeginPlay() override;
// 플레이어 입력 컴포넌트에 Enhanced Input 액션들을 바인딩합니다.
virtual void SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) override;
// 래그돌 컴포넌트
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Component")
TObjectPtr<URagdollComponent> RagdollComp;
// HeroComponent
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Component")
TObjectPtr<UParcelHeroComponent> HeroComp;
};
#include "Character/ParcelCharacter.h"
#include "Character/RagdollComponent.h"
#include "Character/ParcelHeroComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
DEFINE_LOG_CATEGORY(LogCharacter);
AParcelCharacter::AParcelCharacter()
{
PrimaryActorTick.bCanEverTick = false;
// 캐릭터 액터 자체를 네트워크에 복제
bReplicates = true;
// 위치, 회전 같은 Actor Movement를 서버에서 클라이언트로 복제
// ACharacter의 기본 CharacterMovement 복제와 함께 동작합니다.
SetReplicateMovement(true);
// 움직임이 잦은 플레이어 캐릭터라서 기본보다 높은 빈도로 네트워크 갱신을 요청
SetNetUpdateFrequency(100.f);
// 네트워크 상태가 안정적일 때도 너무 낮은 빈도로 떨어지지 않게 최소 갱신 빈도를 지정합니다.
SetMinNetUpdateFrequency(33.f);
// 캐릭터 몸체는 컨트롤러 pitch를 직접 따라가지 않게 합니다.
// 카메라만 위아래를 볼 수 있게 하기 위한 설정
bUseControllerRotationPitch = false;
// 캐릭터 몸체는 컨트롤러 yaw를 직접 따라가지 않게 합니다.
// 이동 방향 기준으로 자연스럽게 회전하도록 CharacterMovement에 맡깁니다.
bUseControllerRotationYaw = false;
// 캐릭터 몸체는 컨트롤러 roll을 직접 따라가지 않게 합니다.
bUseControllerRotationRoll = false;
// 이동 입력 방향을 바라보도록 캐릭터를 자동 회전
GetCharacterMovement()->bOrientRotationToMovement = true;
// 이동 방향으로 돌아갈 때의 회전 속도.
GetCharacterMovement()->RotationRate = FRotator(0.f, 540.f, 0.f);
// 걷기 최대 속도
GetCharacterMovement()->MaxWalkSpeed = 450.0f;
// 점프 시작 시 위로 가해지는 속도
GetCharacterMovement()->JumpZVelocity = 500.f;
// 공중에서 이동 입력이 얼마나 반영되는지 정합니다.
GetCharacterMovement()->AirControl = 0.35f;
// 래그돌 상태 전환과 복제를 담당하는 컴포넌트를 생성
RagdollComp = CreateDefaultSubobject<URagdollComponent>(TEXT("RagdollComp"));
// 조작 및 카메라를 담당하는 컴포넌트 생성
HeroComp = CreateDefaultSubobject<UParcelHeroComponent>(TEXT("HeroComp"));
}
void AParcelCharacter::BeginPlay()
{
// 부모 클래스의 BeginPlay 로직을 먼저 실행
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);
}
}
(조작 기능과 카메라 기능을 가진 HeroComponent 코드 추가)
#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();
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;
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);
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 "GameFramework/Character.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "GameFramework/PlayerController.h"
#include "Camera/CameraComponent.h"
#include "GameFramework/SpringArmComponent.h"
#include "EnhancedInputComponent.h"
#include "EnhancedInputSubsystems.h"
#include "Character/RagdollComponent.h"
// Todo : 하드코딩 요소 제거 필요함.
namespace
{
constexpr float WalkSpeed = 450.f;
constexpr float SprintSpeed = 850.f;
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);
}
AddInputMappingContext();
}
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);
}
void UParcelHeroComponent::Move(const FInputActionValue& Value)
{
ACharacter* Character = Cast<ACharacter>(GetOwner());
if (!CanProcessLocalInput() || !Character || !Character->GetController()) 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()) return;
RagdollComp->ToggleRagdoll();
// 래그돌이 켜질 때만 컴포넌트 틱을 킴(Tick 최적화)
if (RagdollComp->IsRagdoll())
{
PrimaryComponentTick.SetTickFunctionEnable(true);
}
else
{
PrimaryComponentTick.SetTickFunctionEnable(false);
if (SpringArm)
{
SpringArm->AttachToComponent(Character->GetRootComponent(), FAttachmentTransformRules::SnapToTargetNotIncludingScale);
SpringArm->SetRelativeLocation(FVector::ZeroVector);
}
}
}
void UParcelHeroComponent::ServerSetSprinting_Implementation(bool bNewIsSprinting)
{
ApplySprintSpeed(bNewIsSprinting);
}
void UParcelHeroComponent::ApplySprintSpeed(bool bNewIsSprinting)
{
if (ACharacter* Character = Cast<ACharacter>(GetOwner()))
{
if (UCharacterMovementComponent* Movement = Character->GetCharacterMovement())
{
Movement->MaxWalkSpeed = bNewIsSprinting ? SprintSpeed : WalkSpeed;
}
}
}
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) return;
ULocalPlayer* LocalPlayer = PlayerController->GetLocalPlayer();
if (!LocalPlayer) return;
UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(LocalPlayer);
if (!Subsystem) return;
if (!InputMappingContext) return;
Subsystem->RemoveMappingContext(InputMappingContext);
Subsystem->AddMappingContext(InputMappingContext, 0);
}
bool UParcelHeroComponent::CanProcessLocalInput() const
{
ACharacter* Character = Cast<ACharacter>(GetOwner());
return Character && Character->GetController() && Character->IsLocallyControlled();
}
*개인 프로젝트 및 개인 공부*
1. DgaQuestTrackerWidget 수정
1) 코드 수정 및 추가 : 퀘스트가 추가될 때마다 이름 + 설명 + 수치가 한 몸으로 묶인 퀘스트 카드 에셋을 동적으로 주입할 수 있도록 위젯을 C++로 구현.
2) 컴파일 에러와 트러블 슈팅 :
InQuestComponent->OnQuestUpdated.AddDynamic(this, &UDgaQuestTrackerWidget::RefreshAllQuests); 에러.발생.
DgaQuestComponent.h 에서 델리게이트를 구울 때 DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(..., const FDgaQuestState&, UpdatedQuest); 매크로를 사용했었는데, 여기에서는 OneParam을 쓰고 있음. 즉, 신호를 보낼 떄 무조건 const FDgaQuestState& 라는 데이터 1개를 같이 얹어서 쏘아 보내는 규칙을 가지고 있음.
그런데 이번에 바인딩하려고 했던 RefreshAllQuest 함수의 형태는 매개변수가 0개인 함수였음. 따라서 서로 규격이 맞지 않아 컴파일러 에러가 발생한 것.
3) 해결방법 : 델리게이트 전용 수신 함수(Wrapper)를 개설하여 우회하는 방법이 정석.
private: 구역에 델리게이트 무전을 수신할 규격 맞춤형 함수인 HandleOnQuestUpdated를 추가. cpp 파일에서는 AddDynamic 링크 대상을 해당 Wrapper 함수로 변경하였음. 즉, 다음과 같이 '책임 분리 원칙'에 따라 함수를 구현하게 되었음
- 방송 : OnQuestUpdated
- 수신 : HandleOnQuestUpdated
- 공장 : RefreshAllQuests
2. WBP_QuestEntry
기존 QuestTrackerWidget 안에서 가지런한 퀘스트 카드 구현을 위해서 만들기로 함.
*오늘의 총평*
팀프로젝트에서 많은 부분을 구현했고, 에디터에서 조작하고 함수 호출만 하면 원하는 액터 + 에셋이 호출될 수 있도록 구현했다.